16 KiB
Spec: @suaveplan/vault
Capability
Vault orchestration layer for the Vaulted password-manager ecosystem. Unifies @suaveplan/crypto-vault, @suaveplan/crypto-cipher, @suaveplan/bitpacker, and the Phase-1 DriveLike / DriveSharingLike / VaultSyncLike adapter interfaces behind a single domain abstraction: a Vault that can be opened, unlocked, locked, and queried. All sensitive key material is held inside crypto-vault SecureBuffer envelopes and zeroized on lock. vault is the password-manager domain layer; crypto-vault remains the key-hierarchy primitive layer, drive remains the bytes-in/bytes-out blob layer, and vault-sync remains the CRDT-merge layer.
Requirements
Requirement: Vault lifecycle — open, unlock, lock
The package SHALL export Vault as a class with static Vault.open(config: VaultConfig): Vault and instance methods unlock(password: string, secretKey: string): Promise<void> and lock(): Promise<void>. Vault.open SHALL return a LOCKED vault without touching drive. unlock SHALL derive the AUK via crypto-vault 2SKD, derive the personal keyset and per-vault key, decrypt the drive-backed encrypted SQLite-style container blob with the vault key (manifest bound as AAD), run pending migrations against the in-memory state, instantiate the per-unlock module facades, and transition to UNLOCKED. lock SHALL drain pending flushes, encrypt the container, atomically write back to drive, zeroize every key buffer via crypto-vault.zeroizeAll(), and transition to LOCKED. Calling any item / search / attachment / share / sync method while locked MUST throw VaultError with code VAULT.LOCKED.
Scenario: Open returns a locked vault instance without decrypting keys
- WHEN
Vault.openis called with a valid configuration - THEN the returned
Vaultinstance is in thelockedstate AND no key material is decrypted AND drive is untouched
Scenario: Unlock transitions from locked to unlocked
- WHEN
unlockis called with the correct password and secret key - THEN the vault transitions to the
unlockedstate AND each module facade returns a working instance
Scenario: Unlock with wrong password fails
- WHEN
unlockis called with an incorrect password - THEN a
VaultErrorwith codeVAULT.DECRYPT_FAILEDis thrown AND the vault remains locked. Wrong password and tampered ciphertext are indistinguishable at the AEAD layer; both surfaceVAULT.DECRYPT_FAILED
Scenario: Lock zeroizes keys and resets the orchestrator
- WHEN
lockis called on an unlocked vault - THEN the vault transitions to
lockedAND every liveSecureBufferis overwritten with zero bytes AND subsequent CRUD calls throwVaultErrorwith codeVAULT.LOCKED
Scenario: Operations on locked vault throw
- WHEN
vault.items,vault.search,vault.attachments,vault.shares, orvault.syncis accessed while the vault is locked - THEN each access throws
VaultErrorwith codeVAULT.LOCKED
Scenario: Lock is idempotent on already-locked vaults
- WHEN
lockis called twice in a row - THEN the second call is a no-op AND the vault remains in
lockedstate
Verified by: src/vault/vault.test.ts::"open returns a locked vault without touching drive" AND src/vault/vault.test.ts::"unlock + lock cycle: locked vault rejects items" AND src/vault/vault.test.ts::"wrong password rejects with DECRYPT_FAILED (AEAD authentication)" AND src/vault/vault.test.ts::"lock is idempotent on already-locked vaults" AND src/vault/vault.test.ts::"every getter throws VAULT.LOCKED when locked" AND src/vault/vault.test.ts::"every facade getter returns a working module when unlocked"
Requirement: Opaque key handles
The package SHALL hold the vault key inside a SecureBuffer from @suaveplan/crypto-vault and access it through the package-internal OpaqueKeyHandle boundary. The vault layer NEVER stores raw Uint8Array keys at the field level; the handle is the only reference. On lock(), the handle is dropped and the underlying buffer is zeroized via zeroizeAll().
Scenario: Vault stores an OpaqueKeyHandle, not raw bytes
- WHEN
vault.unlockderives the vault key - THEN the field stored on the orchestrator is an
OpaqueKeyHandlewrapping aSecureBuffer, not a rawUint8Array
Scenario: Tampering with the handle field surfaces a typed error
- WHEN the orchestrator's private
vaultKeyHandleis forced tonullwhile state remainsunlocked - THEN the next call that requires the vault key throws
VaultErrorwith codeVAULT.OPAQUE_HANDLE_INVALID
Verified by: src/vault/vault.test.ts::"unwrappedKey throws OPAQUE_HANDLE_INVALID when handle is missing"
Requirement: Drive-backed encrypted SQLite container
The package SHALL store the in-memory item state as a single encrypted blob on DriveLike. The blob layout is [ manifest header (50 bytes) ][ AEAD nonce (24 bytes) ][ AEAD ciphertext + 16-byte Poly1305 tag ]. The manifest is bitpacker-packed (@suaveplan/bitpacker's defineBitSchema) and bound as the AEAD AAD so any tampering with the header invalidates authentication. AEAD is XChaCha20-Poly1305 from @suaveplan/crypto-cipher.
Scenario: Container round-trips through encrypt + decrypt
- WHEN
encryptContainer({ manifest, state, vaultKey })is called and the result is fed intodecryptContainer(blob, vaultKey) - THEN the recovered state's items, history, ledger, and schemaVersion match the inputs
Scenario: Tampered ciphertext fails authentication
- WHEN any byte of the encrypted blob is flipped after encryption
- THEN
decryptContainerthrowsVaultErrorwith codeVAULT.DECRYPT_FAILED
Scenario: Drive failures map to typed errors
- WHEN
DriveLike.readorDriveLike.writerejects - THEN
loadContainer/saveContainerthrowVaultErrorwith codeVAULT.DRIVE.FAILUREchaining the underlying cause
Verified by: src/container/container.test.ts::"encryptContainer + decryptContainer round-trip preserves manifest and state" AND src/container/container.test.ts::"decryptContainer throws DECRYPT_FAILED on tampered ciphertext" AND src/container/container.test.ts::"loadContainer wraps drive failures as DRIVE.FAILURE" AND src/container/container.test.ts::"saveContainer wraps drive write failures as DRIVE.FAILURE"
Requirement: Bitpacker-packed manifest with AAD binding
The vault container manifest SHALL be packed via @suaveplan/bitpacker's defineBitSchema into a fixed-size 50-byte header containing formatVersion: u16, kdfId: u8, aeadAlgorithm: u8, argon2idMemoryKB: u32, argon2idIterations: u16, argon2idParallelism: u8, createdAt: u53, lastModifiedAt: u53, schemaVersion: u16, and vaultIdHash: bytes32. The packed bytes MUST be bound as AEAD AAD on the container ciphertext. Loading a manifest of an unsupported formatVersion MUST throw VAULT.MANIFEST_VERSION_UNSUPPORTED.
Scenario: Pack and unpack round-trip preserves every field
- WHEN a manifest is created, packed, and then unpacked
- THEN every numeric field round-trips losslessly AND
vaultIdHashis exactly 32 bytes
Scenario: Unsupported format version is rejected
- WHEN a packed manifest with
formatVersion === 0xFFFFis unpacked - THEN
unpackManifestthrowsVaultErrorwith codeVAULT.MANIFEST_VERSION_UNSUPPORTED
Verified by: src/manifest/manifest.test.ts::"packManifest + unpackManifest round-trip preserves all fields" AND src/manifest/manifest.test.ts::"unpackManifest rejects unsupported format versions"
Requirement: Per-item field encryption (defence in depth)
Concealed-field values (passwords, PINs, secure notes) SHALL be encrypted with per-item keys derived via @suaveplan/crypto-vault's deriveItemKey(vaultKey, itemId). The AEAD AAD MUST bind to (itemId, fieldName) so an envelope cannot be replayed against a different item or field. The per-item key MUST be zeroized in a finally block after every encrypt or decrypt operation.
Scenario: Encrypt + decrypt round-trip preserves arbitrary plaintexts
- WHEN
encryptFieldand thendecryptFieldare called against the same(vaultKey, itemId, fieldName, value) - THEN the recovered value equals the input
Scenario: Envelope replay across items fails authentication
- WHEN an envelope is decrypted with a different
itemIdthan the one it was encrypted under - THEN
decryptFieldthrowsVaultErrorwith codeVAULT.DECRYPT_FAILED
Verified by: src/encryption/field-encryption.test.ts::"encryptField + decryptField round-trip preserves the plaintext" AND src/encryption/field-encryption.test.ts::"decryptField rejects an envelope with the wrong itemId"
Requirement: Item CRUD with optimistic concurrency
The package SHALL expose vault.items with create, get, update, delete, list, and allRows methods. Every public method validates inputs via Zod and throws VAULT.ITEM.VALIDATION_FAILED on schema violations. update accepts an optional expectedVersion for optimistic concurrency; mismatches throw VAULT.ITEM.VERSION_CONFLICT. delete writes a tombstone (the row stays in the container with deleted: true) so sync can propagate the deletion. Every mutation appends to the history log and notifies the orchestrator's flush scheduler.
Scenario: Create assigns a unique id, version=1, and timestamps
- WHEN
vault.items.createis called with valid fields - THEN the returned item has a 32-character hex id,
version === 1,createdAt === updatedAt, anddeleted === false
Scenario: Update with stale expectedVersion is rejected
- WHEN
vault.items.updateis called with anexpectedVersionlower than the current row version - THEN the call throws
VaultErrorwith codeVAULT.ITEM.VERSION_CONFLICT
Scenario: Delete tombstones the row
- WHEN
vault.items.deleteis called - THEN subsequent
getreturnsnullAND the row remains in the container withdeleted: true
Verified by: src/items/items.test.ts::"create assigns id, version=1, and timestamps" AND src/items/items.test.ts::"update with stale expectedVersion throws VERSION_CONFLICT" AND src/items/items.test.ts::"delete tombstones the item"
Requirement: Search excludes concealed fields
The package SHALL expose vault.search.search(query) with optional query, category, tags, favorite, and limit filters. Tag filter is intersection — every tag in query.tags must appear on the row. Results MUST NEVER include concealedFields; the search projection structurally strips them via Omit<VaultItem, "concealedFields">. Every search records an op: "search" outcome counter.
Scenario: Concealed-field plaintext is never returned
- WHEN
vault.search.searchreturns matching rows - THEN no result object contains a
concealedFieldsproperty
Scenario: Tag filter requires every tag to match
- WHEN
vault.search.search({ tags: ["work", "dev"] })is called - THEN only rows with both
"work"AND"dev"in their tags are returned
Verified by: src/search/search.test.ts::"results never carry concealedFields" AND src/search/search.test.ts::"tag intersection requires every tag to match"
Requirement: Attachments encrypted under per-attachment keys
The package SHALL expose vault.attachments with upload, download, delete, and list methods. Each attachment is AEAD-encrypted under a per-attachment key derived via deriveItemKey(vaultKey, "attachment/<id>") with the AAD bound to "vault/v1/attachment/<itemId>/<filename>". Plaintext exceeding maxAttachmentBytes (default 100 MiB; configurable via VaultConfig.maxAttachmentBytes) is rejected with VAULT.ATTACHMENT.TOO_LARGE. Drive failures map to VAULT.DRIVE.FAILURE. Missing-id reads or deletes throw VAULT.ATTACHMENT.NOT_FOUND.
Scenario: Upload + download round-trips the plaintext bytes
- WHEN
vault.attachments.uploadis called and thenvault.attachments.download(id) - THEN the returned bytes equal the original plaintext
Scenario: Over-cap upload is rejected
- WHEN
vault.attachments.uploadis called withdata.length > maxAttachmentBytes - THEN the call throws
VaultErrorwith codeVAULT.ATTACHMENT.TOO_LARGEBEFORE any drive I/O
Verified by: src/attachments/attachments.test.ts::"upload + download round-trips the plaintext bytes" AND src/attachments/attachments.test.ts::"upload throws ATTACHMENT.TOO_LARGE for over-cap blobs"
Requirement: Share-link orchestration
The package SHALL expose vault.shares with createLink, revokeLink, isActive, and list methods. createLink wraps the per-share key under a KEK derived via deriveItemKey(vaultKey, "share/<itemId>") and delegates to a pluggable DriveSharingLike adapter. The wrapped envelope (opaque ciphertext bytes) is the only key material that leaves the vault layer. revokeLink delegates to the adapter and removes local metadata. Drive failures map to VAULT.DRIVE.FAILURE. Missing-id revokes throw VAULT.SHARE.NOT_FOUND.
Scenario: Wrapped key is opaque to drive-sharing
- WHEN
vault.shares.createLinkis called - THEN
DriveSharingLike.createLinkreceiveswrappedKey: Uint8Arraycontaining only ciphertext bytes — no plaintext key material
Scenario: Revoke removes local metadata
- WHEN
vault.shares.revokeLink(id)succeeds - THEN
vault.shares.isActive(id)returnsfalse
Verified by: src/shares/shares.test.ts::"createLink wraps the plaintext and returns metadata" AND src/shares/shares.test.ts::"isActive returns the canonical answer from drive-sharing"
Requirement: Sync delegation through VaultSyncLike
The package SHALL expose vault.sync with pull, push, sync, and currentSnapshot methods. The orchestrator hydrates the delegate with the current local snapshot before every operation, projects rows into the SyncSnapshot wire shape, translates the SyncDelegationResult into the public SyncResult, and wraps any delegate failure in VAULT.SYNC.FAILED.
Scenario: Sync hydrates the delegate
- WHEN
vault.sync.pullis called - THEN the delegate's
hydrateis invoked with the current local snapshot
Scenario: Delegate failures surface as SYNC.FAILED
- WHEN the delegate's
pull/push/syncrejects - THEN the orchestrator throws
VaultErrorwith codeVAULT.SYNC.FAILEDchaining the underlying cause
Verified by: src/sync/sync.test.ts::"pull hydrates and returns the delegate's merged result" AND src/sync/sync.test.ts::"pull failures map to SYNC.FAILED"
Requirement: Forward-only schema migration ledger
The package SHALL run runMigrations(state) on every unlock to advance the in-memory container state to the current head schema version. Migrations are described by { version, name, up(state) } with strictly-increasing versions. The ledger is append-only — every successfully-applied migration adds one entry. Re-running migrations on an at-head state is a no-op.
Scenario: Migrations apply in version order
- WHEN
runMigrationsis called with an out-of-order migration array - THEN the migrations are sorted by version and applied in increasing order
Scenario: Idempotent re-run
- WHEN
runMigrationsis called twice in a row on the same state - THEN the second call applies zero migrations and the ledger is unchanged
Verified by: src/items/migrations/migrations.test.ts::"runMigrations sorts migrations before applying" AND src/items/migrations/migrations.test.ts::"runMigrations is idempotent at head version"
Error codes
VAULT.LOCKED, VAULT.INVALID_CREDENTIALS, VAULT.ALREADY_OPEN, VAULT.NOT_INITIALIZED, VAULT.MANIFEST_INVALID, VAULT.MANIFEST_VERSION_UNSUPPORTED, VAULT.DECRYPT_FAILED, VAULT.ITEM.NOT_FOUND, VAULT.ITEM.VALIDATION_FAILED, VAULT.ITEM.VERSION_CONFLICT, VAULT.ATTACHMENT.NOT_FOUND, VAULT.ATTACHMENT.TOO_LARGE, VAULT.SHARE.NOT_FOUND, VAULT.SHARE.REVOKED, VAULT.SYNC.FAILED, VAULT.OPAQUE_HANDLE_INVALID, VAULT.DRIVE.FAILURE.
Version
Published: 0.1.0 (Phase-1 landing on genesis). Spec id: vault.