Files
suaveplan/openspec/specs/vault/spec.md
2026-05-27 23:29:53 +01:00

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.open is called with a valid configuration
  • THEN the returned Vault instance is in the locked state AND no key material is decrypted AND drive is untouched

Scenario: Unlock transitions from locked to unlocked

  • WHEN unlock is called with the correct password and secret key
  • THEN the vault transitions to the unlocked state AND each module facade returns a working instance

Scenario: Unlock with wrong password fails

  • WHEN unlock is called with an incorrect password
  • THEN a VaultError with code VAULT.DECRYPT_FAILED is thrown AND the vault remains locked. Wrong password and tampered ciphertext are indistinguishable at the AEAD layer; both surface VAULT.DECRYPT_FAILED

Scenario: Lock zeroizes keys and resets the orchestrator

  • WHEN lock is called on an unlocked vault
  • THEN the vault transitions to locked AND every live SecureBuffer is overwritten with zero bytes AND subsequent CRUD calls throw VaultError with code VAULT.LOCKED

Scenario: Operations on locked vault throw

  • WHEN vault.items, vault.search, vault.attachments, vault.shares, or vault.sync is accessed while the vault is locked
  • THEN each access throws VaultError with code VAULT.LOCKED

Scenario: Lock is idempotent on already-locked vaults

  • WHEN lock is called twice in a row
  • THEN the second call is a no-op AND the vault remains in locked state

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.unlock derives the vault key
  • THEN the field stored on the orchestrator is an OpaqueKeyHandle wrapping a SecureBuffer, not a raw Uint8Array

Scenario: Tampering with the handle field surfaces a typed error

  • WHEN the orchestrator's private vaultKeyHandle is forced to null while state remains unlocked
  • THEN the next call that requires the vault key throws VaultError with code VAULT.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 into decryptContainer(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 decryptContainer throws VaultError with code VAULT.DECRYPT_FAILED

Scenario: Drive failures map to typed errors

  • WHEN DriveLike.read or DriveLike.write rejects
  • THEN loadContainer / saveContainer throw VaultError with code VAULT.DRIVE.FAILURE chaining 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 vaultIdHash is exactly 32 bytes

Scenario: Unsupported format version is rejected

  • WHEN a packed manifest with formatVersion === 0xFFFF is unpacked
  • THEN unpackManifest throws VaultError with code VAULT.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 encryptField and then decryptField are 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 itemId than the one it was encrypted under
  • THEN decryptField throws VaultError with code VAULT.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.create is called with valid fields
  • THEN the returned item has a 32-character hex id, version === 1, createdAt === updatedAt, and deleted === false

Scenario: Update with stale expectedVersion is rejected

  • WHEN vault.items.update is called with an expectedVersion lower than the current row version
  • THEN the call throws VaultError with code VAULT.ITEM.VERSION_CONFLICT

Scenario: Delete tombstones the row

  • WHEN vault.items.delete is called
  • THEN subsequent get returns null AND the row remains in the container with deleted: 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.search returns matching rows
  • THEN no result object contains a concealedFields property

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.upload is called and then vault.attachments.download(id)
  • THEN the returned bytes equal the original plaintext

Scenario: Over-cap upload is rejected

  • WHEN vault.attachments.upload is called with data.length > maxAttachmentBytes
  • THEN the call throws VaultError with code VAULT.ATTACHMENT.TOO_LARGE BEFORE 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"


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.createLink is called
  • THEN DriveSharingLike.createLink receives wrappedKey: Uint8Array containing only ciphertext bytes — no plaintext key material

Scenario: Revoke removes local metadata

  • WHEN vault.shares.revokeLink(id) succeeds
  • THEN vault.shares.isActive(id) returns false

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.pull is called
  • THEN the delegate's hydrate is invoked with the current local snapshot

Scenario: Delegate failures surface as SYNC.FAILED

  • WHEN the delegate's pull / push / sync rejects
  • THEN the orchestrator throws VaultError with code VAULT.SYNC.FAILED chaining 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 runMigrations is 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 runMigrations is 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.