# 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` and `lock(): Promise`. `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`. 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/")` with the AAD bound to `"vault/v1/attachment//"`. 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"` --- ### 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/")` 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`.