# Spec: @suaveplan/vault ## Capability Vault orchestration layer for the Vaulted password-manager ecosystem. Unifies `@suaveplan/crypto-vault`, `@suaveplan/keys`, `@suaveplan/db-sqlite`, `@suaveplan/drive`, and `@suaveplan/drive-sharing` behind a single domain abstraction: a `Vault` that can be opened, unlocked, locked, and queried. All sensitive key material is held inside `crypto-vault` secure buffers and zeroized on lock. `vault` is the password-manager domain layer; `drive-sharing` remains the secure-sharing primitive layer. ## Requirements ### Requirement: Vault lifecycle — open, unlock, lock The package SHALL export `Vault` as a class with static `Vault.open(config: VaultConfig, options?: { rng?: Rng }): Promise` and instance methods `unlock(password: string, secretKey: string, options?: { rng?: Rng }): Promise` and `lock(): Promise`. `Vault.open` SHALL validate the vault path, initialise the SQLite repository (delegated to `db-sqlite`), and prepare the drive filesystem (delegated to `drive`) without decrypting any key material. `unlock` SHALL derive the AUK via `crypto-vault` 2SKD, decrypt the personal keyset, derive the vault key, open the SQLite DB with the vault key, and initialise the drive session. `lock` SHALL close the SQLite DB, destroy drive session keys, and call `crypto-vault.zeroizeAll()`. Calling any item/attachment/share/sync method while locked MUST throw `VaultError` with code `VAULT.LOCKED`. #### Scenario: Open creates a vault instance without decrypting keys - **WHEN** `Vault.open` is called with a valid path, email, and metadata - **THEN** the returned `Vault` instance is in the `locked` state AND no key material is decrypted #### 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 the DB is queryable AND drive is accessible #### Scenario: Unlock with wrong password fails - **WHEN** `unlock` is called with an incorrect password - **THEN** a `VaultError` with code `VAULT.AUTHENTICATION_FAILED` is thrown AND the vault remains locked #### Scenario: Lock zeroizes keys and closes DB - **WHEN** `lock` is called on an unlocked vault - **THEN** the vault transitions to `locked` AND subsequent DB queries throw `VaultError` with code `VAULT.LOCKED` #### Scenario: Operations on locked vault throw - **WHEN** `vault.items.create`, `vault.items.search`, `vault.attachments.upload`, `vault.shares.createLink`, `vault.sync.pull`, or `vault.sync.push` is called while the vault is locked - **THEN** each call throws `VaultError` with code `VAULT.LOCKED` #### Scenario: Injectable RNG is accepted - **WHEN** `Vault.open` or `vault.unlock` is called with `options.rng` - **THEN** the operation succeeds and the RNG is forwarded to `crypto-vault` derivation paths **Verified by:** `src/vault/vault.test.ts::"open returns locked vault instance"` AND `src/vault/vault.test.ts::"unlock transitions to unlocked with correct credentials"` AND `src/vault/vault.test.ts::"unlock fails with wrong password"` AND `src/vault/vault.test.ts::"lock zeroizes keys and closes DB"` AND `src/vault/vault.test.ts::"items.create throws when locked"` AND `src/vault/vault.test.ts::"items.search throws when locked"` AND `src/vault/vault.test.ts::"attachments.upload throws when locked"` AND `src/vault/vault.test.ts::"shares.createLink throws when locked"` AND `src/vault/vault.test.ts::"sync.pull throws when locked"` AND `src/vault/vault.test.ts::"sync.push throws when locked"` AND `src/vault/vault.test.ts::"accepts injectable rng"` --- ### Requirement: Opaque key handles The package SHALL export `OpaqueKeyHandle` as an opaque wrapper around raw `Uint8Array` keys. `crypto-vault` currently exports `Uint8Array` keys; the `vault` layer MUST wrap them in `OpaqueKeyHandle` objects before crossing module boundaries. `OpaqueKeyHandle` SHALL expose no methods that reveal the underlying bytes; only `crypto-vault` may unwrap the handle. #### Scenario: Vault wraps crypto-vault keys in opaque handles - **WHEN** `vault.unlock` derives keys via `crypto-vault` - **THEN** the vault layer stores `OpaqueKeyHandle` instances internally, not raw `Uint8Array`s #### Scenario: Opaque handle does not expose raw bytes - **WHEN** `OpaqueKeyHandle.toString()` or `JSON.stringify` is called - **THEN** no raw key bytes are exposed **Verified by:** `src/vault/vault.test.ts::"unlock stores opaque key handles"` AND `src/vault/vault.test.ts::"opaque handle does not expose bytes"` --- ### Requirement: Item CRUD delegated to db-sqlite The package SHALL expose `vault.items` with methods `create(fields: ItemFields): Promise`, `get(id: string): Promise`, `update(id: string, fields: Partial): Promise`, `delete(id: string): Promise`, and `list(options?: ListOptions): Promise`. `create` SHALL assign a UUIDv4 `id`, set `createdAt` and `modifiedAt` to the injectable `Clock` time, encrypt sensitive fields (password, notes, card number, CVV) with the item key derived via `crypto-vault`, and persist via `db-sqlite`. `update` SHALL re-encrypt changed sensitive fields, update `modifiedAt`, and append an audit entry to the item's history. `delete` SHALL write a tombstone record (not immediate physical deletion) so that sync can propagate the deletion. All methods MUST validate inputs with Zod schemas and throw `VaultError` with code `VAULT.ITEM.VALIDATION_FAILED` on schema violations. #### Scenario: Create assigns id and timestamps - **WHEN** `vault.items.create` is called with valid fields - **THEN** the returned item has a UUIDv4 `id`, `createdAt`, and `modifiedAt` populated from the injectable `Clock` #### Scenario: Create encrypts sensitive fields - **WHEN** `vault.items.create` is called with a `password` field marked `concealed: true` - **THEN** the persisted value is ciphertext, not plaintext #### Scenario: Update modifies timestamp and history - **WHEN** `vault.items.update` is called with a new `title` - **THEN** the item's `modifiedAt` is updated AND a history entry recording the change is appended #### Scenario: Delete writes tombstone - **WHEN** `vault.items.delete` is called - **THEN** subsequent `get` returns null AND the sync payload includes a tombstone for that id #### Scenario: Validation rejects malformed input - **WHEN** `vault.items.create` is called with a missing required field or invalid type - **THEN** a `VaultError` with code `VAULT.ITEM.VALIDATION_FAILED` is thrown **Verified by:** `src/items/items.test.ts::"create assigns uuid id and timestamps"` AND `src/items/items.test.ts::"create encrypts concealed fields"` AND `src/items/items.test.ts::"update modifies timestamp and appends history"` AND `src/items/items.test.ts::"delete writes tombstone"` AND `src/items/items.test.ts::"list returns paginated results"` AND `src/items/items.test.ts::"validation rejects missing title"` AND `src/items/items.test.ts::"validation rejects unknown category"` AND `src/items/items.test.ts::"get returns null for missing item"` --- ### Requirement: Search delegation to db-sqlite FTS5 The package SHALL expose `vault.items.search(query: SearchQuery): Promise`. The implementation SHALL delegate to `db-sqlite` FTS5 full-text search across item titles, URLs, usernames, and tags. `SearchQuery` MUST support `query` (free-text), `category` (filter by item category), `tags` (array filter), and `favorites` (boolean filter). Results MUST be ranked by FTS5 relevance score. If the vault is locked, search MUST throw `VaultError` with code `VAULT.LOCKED`. Search MUST NOT return plaintext of concealed fields; only titles, URLs, and non-sensitive metadata are searchable and returnable. #### Scenario: Full-text search matches title - **WHEN** `vault.items.search({ query: "github" })` is called - **THEN** results include items whose title contains "github" ordered by relevance #### Scenario: Category filter narrows results - **WHEN** `vault.items.search({ query: "github", category: "login" })` is called - **THEN** only login items matching "github" are returned #### Scenario: Tags filter intersects - **WHEN** `vault.items.search({ tags: ["work", "dev"] })` is called - **THEN** only items tagged with both "work" AND "dev" are returned #### Scenario: Concealed fields are never returned in search - **WHEN** `vault.items.search` returns results - **THEN** no result object contains a plaintext password, card number, or CVV **Verified by:** `src/search/search.test.ts::"full-text search matches title"` AND `src/search/search.test.ts::"category filter narrows results"` AND `src/search/search.test.ts::"tags filter intersects"` AND `src/search/search.test.ts::"favorites filter returns only favorites"` AND `src/search/search.test.ts::"concealed fields not returned"` AND `src/search/search.test.ts::"empty query returns all items"` AND `src/search/search.test.ts::"no matches returns empty array"` AND `src/search/search.test.ts::"search throws when vault is locked"` --- ### Requirement: Attachment delegation to drive The package SHALL expose `vault.attachments` with methods `upload(itemId: string, source: ReadableStream | Uint8Array | string): Promise` and `download(attachmentId: string): Promise`. `upload` SHALL encrypt the attachment with the vault key (via `drive`'s encryption transforms), store it in the drive filesystem under the item's partition, and return an `Attachment` record containing `id`, `itemId`, `filename`, `size`, and `mimeType`. `download` SHALL stream the decrypted attachment. If the vault is locked, both methods MUST throw `VaultError` with code `VAULT.LOCKED`. Attachment deletion SHALL be `vault.attachments.delete(attachmentId): Promise` which removes the drive object and writes a tombstone. #### Scenario: Upload encrypts and stores attachment - **WHEN** `vault.attachments.upload` is called with a file buffer - **THEN** the returned attachment has a UUID `id` AND the drive stores encrypted bytes #### Scenario: Download streams decrypted attachment - **WHEN** `vault.attachments.download` is called with a valid attachment id - **THEN** the resulting stream yields the original plaintext bytes #### Scenario: Delete removes attachment and writes tombstone - **WHEN** `vault.attachments.delete` is called - **THEN** subsequent `download` throws `VaultError` with code `VAULT.ATTACHMENT.NOT_FOUND` AND the sync payload reflects the deletion **Verified by:** `src/attachments/attachments.test.ts::"upload encrypts and stores attachment"` AND `src/attachments/attachments.test.ts::"download streams decrypted plaintext"` AND `src/attachments/attachments.test.ts::"delete removes attachment and writes tombstone"` AND `src/attachments/attachments.test.ts::"upload throws when vault is locked"` AND `src/attachments/attachments.test.ts::"download throws when vault is locked"` AND `src/attachments/attachments.test.ts::"download throws for missing attachment"` --- ### Requirement: Share delegation to drive-sharing The package SHALL expose `vault.shares` with methods `createLink(options: ShareLinkOptions): Promise` and `revokeLink(linkId: string): Promise`. `createLink` SHALL extract the specified item from `db-sqlite`, re-encrypt it with a share-specific key generated by `drive-sharing`, create a grant token with the configured expiry and view limit, and return the share link URL and metadata. `revokeLink` SHALL invalidate the grant token via `drive-sharing`. If the vault is locked, both methods MUST throw `VaultError` with code `VAULT.LOCKED`. Share links MUST never contain plaintext key material; the share key is encrypted to the recipient or stored in `drive-sharing`'s partition store. #### Scenario: CreateLink generates shareable link - **WHEN** `vault.shares.createLink` is called with `itemId`, `expiresIn`, and `maxViews` - **THEN** the returned `ShareLink` contains a URL, `linkId`, `expiresAt`, and `maxViews` #### Scenario: RevokeLink invalidates share - **WHEN** `vault.shares.revokeLink` is called with a valid `linkId` - **THEN** subsequent access attempts via the link URL are rejected #### Scenario: Share link never contains plaintext key material - **WHEN** `createLink` returns a `ShareLink` URL - **THEN** the URL and its query parameters contain no plaintext encryption keys or AUK-derived material **Verified by:** `src/shares/shares.test.ts::"createLink generates shareable link"` AND `src/shares/shares.test.ts::"revokeLink invalidates share"` AND `src/shares/shares.test.ts::"share link contains no plaintext keys"` AND `src/shares/shares.test.ts::"createLink throws when vault is locked"` AND `src/shares/shares.test.ts::"revokeLink throws when vault is locked"` AND `src/shares/shares.test.ts::"createLink rejects expired expiry"` --- ### Requirement: Sync delegation to vault-sync The package SHALL expose `vault.sync` with methods `pull(): Promise`, `push(): Promise`, and `sync(): Promise`. The implementation SHALL delegate to `@suaveplan/vault-sync` `SyncEngine`, passing the current local snapshot (derived from `db-sqlite`) and the vault key for payload encryption/decryption. `pull` SHALL fetch the remote snapshot via the caller-provided transport, merge changes into local state, and update `db-sqlite`. `push` SHALL serialise local changes since the last sync, encrypt them, and hand the payload to the caller's transport. `sync` SHALL perform bidirectional reconciliation. If the vault is locked, all methods MUST throw `VaultError` with code `VAULT.LOCKED`. #### Scenario: Pull merges remote changes into local state - **WHEN** `vault.sync.pull` is called and the remote has new items - **THEN** those items appear in `vault.items.list()` AND `SyncResult.merged` lists them #### Scenario: Push serialises only local changes - **WHEN** `vault.sync.push` is called after local item creation - **THEN** the payload contains only the new item AND `SyncResult.merged` reflects it #### Scenario: Bidirectional sync reconciles both directions - **WHEN** `vault.sync.sync` is called with divergence in both local and remote state - **THEN** the local state reflects the merged union AND `SyncResult` reports merged and resolved conflicts **Verified by:** `src/sync/sync.test.ts::"pull merges remote changes"` AND `src/sync/sync.test.ts::"push serialises only local changes"` AND `src/sync/sync.test.ts::"bidirectional sync reconciles divergence"` AND `src/sync/sync.test.ts::"sync throws when vault is locked"` AND `src/sync/sync.test.ts::"sync propagates deletions"` AND `src/sync/sync.test.ts::"sync handles offline queue flush"` --- ### Requirement: Key zeroization on lock The package SHALL ensure that `vault.lock()` triggers complete zeroization of all cryptographic key material held by the vault and its delegates. Specifically: the AUK, personal keyset, vault key, item keys, drive session keys, and any cached decrypted item fields MUST be overwritten with zero bytes and the references released. After zeroization, `vault` MUST be in the `locked` state and any attempt to access decrypted data MUST throw. The `Vault` class MUST expose a readonly `isLocked: boolean` property for callers to check state. #### Scenario: Lock zeroizes all key material - **WHEN** `vault.lock()` is called after unlocking - **THEN** `vault.isLocked` is `true` AND all internal `SecureBuffer` instances contain zero bytes #### Scenario: Zeroized vault cannot perform decryption - **WHEN** `vault.items.get` is called after lock - **THEN** the call throws `VaultError` with code `VAULT.LOCKED` because decryption requires the zeroized vault key #### Scenario: Re-unlock requires full re-derivation - **WHEN** `vault.unlock` is called again after `vault.lock` - **THEN** the AUK and key hierarchy are re-derived from scratch; no stale key material is reused **Verified by:** `src/vault/vault.test.ts::"lock zeroizes all key material"` AND `src/vault/vault.test.ts::"zeroized vault cannot decrypt"` AND `src/vault/vault.test.ts::"re-unlock derives keys from scratch"` AND `src/vault/vault.test.ts::"isLocked reflects state correctly"` AND `src/vault/vault.test.ts::"concurrent lock is idempotent"` AND `src/vault/vault.test.ts::"unlock after lock succeeds with correct credentials"` ## Error codes `VAULT.LOCKED`, `VAULT.AUTHENTICATION_FAILED`, `VAULT.ITEM.VALIDATION_FAILED`, `VAULT.ITEM.NOT_FOUND`, `VAULT.ATTACHMENT.NOT_FOUND`, `VAULT.SHARE.NOT_FOUND`, `VAULT.SHARE.EXPIRED`, `VAULT.SYNC.NO_TRANSPORT`, `VAULT.DB.CORRUPTED` ## Version Published: `0.1.0` (proposed). Spec delta: `vault`.