16 KiB
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<Vault> and instance methods unlock(password: string, secretKey: string, options?: { rng?: Rng }): Promise<void> and lock(): Promise<void>. 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.openis called with a valid path, email, and metadata - THEN the returned
Vaultinstance is in thelockedstate AND no key material is decrypted
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 the DB is queryable AND drive is accessible
Scenario: Unlock with wrong password fails
- WHEN
unlockis called with an incorrect password - THEN a
VaultErrorwith codeVAULT.AUTHENTICATION_FAILEDis thrown AND the vault remains locked
Scenario: Lock zeroizes keys and closes DB
- WHEN
lockis called on an unlocked vault - THEN the vault transitions to
lockedAND subsequent DB queries throwVaultErrorwith codeVAULT.LOCKED
Scenario: Operations on locked vault throw
- WHEN
vault.items.create,vault.items.search,vault.attachments.upload,vault.shares.createLink,vault.sync.pull, orvault.sync.pushis called while the vault is locked - THEN each call throws
VaultErrorwith codeVAULT.LOCKED
Scenario: Injectable RNG is accepted
- WHEN
Vault.openorvault.unlockis called withoptions.rng - THEN the operation succeeds and the RNG is forwarded to
crypto-vaultderivation 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.unlockderives keys viacrypto-vault - THEN the vault layer stores
OpaqueKeyHandleinstances internally, not rawUint8Arrays
Scenario: Opaque handle does not expose raw bytes
- WHEN
OpaqueKeyHandle.toString()orJSON.stringifyis 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<VaultItem>, get(id: string): Promise<VaultItem | null>, update(id: string, fields: Partial<ItemFields>): Promise<VaultItem>, delete(id: string): Promise<void>, and list(options?: ListOptions): Promise<VaultItem[]>. 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.createis called with valid fields - THEN the returned item has a UUIDv4
id,createdAt, andmodifiedAtpopulated from the injectableClock
Scenario: Create encrypts sensitive fields
- WHEN
vault.items.createis called with apasswordfield markedconcealed: true - THEN the persisted value is ciphertext, not plaintext
Scenario: Update modifies timestamp and history
- WHEN
vault.items.updateis called with a newtitle - THEN the item's
modifiedAtis updated AND a history entry recording the change is appended
Scenario: Delete writes tombstone
- WHEN
vault.items.deleteis called - THEN subsequent
getreturns null AND the sync payload includes a tombstone for that id
Scenario: Validation rejects malformed input
- WHEN
vault.items.createis called with a missing required field or invalid type - THEN a
VaultErrorwith codeVAULT.ITEM.VALIDATION_FAILEDis 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<SearchResult[]>. 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.searchreturns 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<Attachment> and download(attachmentId: string): Promise<ReadableStream>. 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<void> which removes the drive object and writes a tombstone.
Scenario: Upload encrypts and stores attachment
- WHEN
vault.attachments.uploadis called with a file buffer - THEN the returned attachment has a UUID
idAND the drive stores encrypted bytes
Scenario: Download streams decrypted attachment
- WHEN
vault.attachments.downloadis 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.deleteis called - THEN subsequent
downloadthrowsVaultErrorwith codeVAULT.ATTACHMENT.NOT_FOUNDAND 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<ShareLink> and revokeLink(linkId: string): Promise<void>. 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.createLinkis called withitemId,expiresIn, andmaxViews - THEN the returned
ShareLinkcontains a URL,linkId,expiresAt, andmaxViews
Scenario: RevokeLink invalidates share
- WHEN
vault.shares.revokeLinkis called with a validlinkId - THEN subsequent access attempts via the link URL are rejected
Scenario: Share link never contains plaintext key material
- WHEN
createLinkreturns aShareLinkURL - 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<SyncResult>, push(): Promise<SyncResult>, and sync(): Promise<SyncResult>. 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.pullis called and the remote has new items - THEN those items appear in
vault.items.list()ANDSyncResult.mergedlists them
Scenario: Push serialises only local changes
- WHEN
vault.sync.pushis called after local item creation - THEN the payload contains only the new item AND
SyncResult.mergedreflects it
Scenario: Bidirectional sync reconciles both directions
- WHEN
vault.sync.syncis called with divergence in both local and remote state - THEN the local state reflects the merged union AND
SyncResultreports 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.isLockedistrueAND all internalSecureBufferinstances contain zero bytes
Scenario: Zeroized vault cannot perform decryption
- WHEN
vault.items.getis called after lock - THEN the call throws
VaultErrorwith codeVAULT.LOCKEDbecause decryption requires the zeroized vault key
Scenario: Re-unlock requires full re-derivation
- WHEN
vault.unlockis called again aftervault.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.