Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,241 @@
# 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<void>` and `lock(): Promise<void>`. `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<VaultItem, "concealedFields">`. 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/<id>")` with the AAD bound to `"vault/v1/attachment/<itemId>/<filename>"`. 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/<itemId>")` 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`.