Files
2026-05-27 23:29:53 +01:00

223 lines
16 KiB
Markdown

# 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 `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<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
#### 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<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.
#### 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<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`.