Files
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/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.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 Uint8Arrays

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<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.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<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
  • 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<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.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<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.

  • WHEN vault.shares.createLink is called with itemId, expiresIn, and maxViews
  • THEN the returned ShareLink contains a URL, linkId, expiresAt, and maxViews
  • WHEN vault.shares.revokeLink is called with a valid linkId
  • THEN subsequent access attempts via the link URL are rejected
  • 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<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.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.