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,287 @@
# vault — Vault Orchestration Layer
- **Change ID**: vault
- **Package**: `@suaveplan/vault`
- **Status**: PROPOSED
- **Type**: new-package
- **Date**: 2026-04-29
## Why
The Vaulted password-manager ecosystem needs a single domain abstraction that orchestrates `crypto-vault` (key hierarchy), `@suaveplan/crypto/keychain` (keypair utilities), `db-sqlite` (item storage), `drive` (attachments), `drive-sharing` (share links), and `vault-sync` (sync engine). Today, vault-shaped helpers (`sealVaultPayload`, `openVaultPayload`, `LegacyVault`) live alongside primitive crypto in `@suaveplan/crypto/vault` — that subpath is fine for the low-level seal/open building blocks, but it does NOT provide the lifecycle, item-CRUD, attachment, share, or sync orchestration consumers need. Creating `@suaveplan/vault` introduces a clean orchestration layer above the existing crypto primitives; satisfies the security invariant that key material never leaves `crypto-vault`; and provides a clean API surface for desktop, mobile, and browser-extension consumers.
> **Amendment 2026-05-03:** This proposal originally referenced `@suaveplan/keys` as the source for `VaultManager` / `VaultFactory`. On the genesis branch that package never landed — keypair management was absorbed into `@suaveplan/crypto/keychain` and the legacy vault helpers into `@suaveplan/crypto/vault` during the genesis architecture redesign. References to `@suaveplan/keys` below have been amended to point at the corresponding `@suaveplan/crypto` subpaths.
## What changes
- ADDED: `Vault` class — `Vault.open(config, options?)`, `unlock(password, secretKey, options?)`, `lock()`
- ADDED: `OpaqueKeyHandle` — wraps raw `Uint8Array` keys exported by `crypto-vault` before crossing module boundaries
- ADDED: `vault.items` — `create()`, `get()`, `update()`, `delete()`, `list()`, `search()` delegated to `db-sqlite`
- ADDED: `vault.attachments` — `upload()`, `download()`, `delete()` delegated to `drive`
- ADDED: `vault.shares` — `createLink()`, `revokeLink()` delegated to `drive-sharing`
- ADDED: `vault.sync` — `pull()`, `push()`, `sync()` delegated to `vault-sync`
- ADDED: `VaultConfig`, `VaultState`, `VaultItem`, `ItemFields`, `Attachment`, `ShareLink`, `SearchQuery`, `SyncResult` types
- ADDED: `VaultError` with structured error codes for lifecycle, item, attachment, share, and sync failures
- MODIFIED: `@suaveplan/crypto/vault` — the orchestration concerns previously envisioned for `keys`'s `VaultManager` / `VaultFactory` are authored fresh inside `@suaveplan/vault`. The existing primitives at `@suaveplan/crypto/vault` (`sealPrivateKeyBlob`, `openPrivateKeyBlob`, `sealVaultPayload`, `openVaultPayload`, `changePassword`, `rotateApplicationData`, `LegacyVault`) are unchanged; `@suaveplan/vault` consumes them. No `@suaveplan/keys` migration is required because that package was never created on genesis (its responsibilities are split across `@suaveplan/crypto/keychain`, `@suaveplan/crypto/authority`, and `@suaveplan/crypto/vault`).
## Phased delivery
- **Phase 1** (this change): `vault` defines adapter interfaces (`DriveLike`, `DriveSharingLike`, `VaultSyncLike`) so it compiles and tests without the real `drive`, `drive-sharing`, and `vault-sync` packages. Adapters are injected via `VaultConfig`.
- **Phase 2** (follow-up changes): When `drive`, `drive-sharing`, and `vault-sync` land, `vault` swaps the adapter interfaces for real implementations. No public API changes are expected at that point.
## Out of scope
- UI components or React hooks — belong in `@suaveplan/react-vault` (future package)
- Browser-extension-specific vault access patterns — extensions delegate crypto to the desktop app via `@suaveplan/native-messaging`
- Biometric unlock integration — `vault.unlock` accepts password+secretKey; biometric path wraps this in `@suaveplan/biometrics`
- Import/export from competing password managers (1Password, Bitwarden, KeePass) — deferred to dedicated import packages
- SQLCipher native integration — `db-sqlite` handles SQLite; encryption-at-rest is the vault layer's responsibility via `drive`
## Gap analysis: sub-standard → enterprise
Most password-manager vault implementations fall well below enterprise standards:
| Sub-standard practice | Enterprise replacement |
|---|---|
| Mixed concerns (key generation, vault logic, and UI state in one module) | Clean separation: `crypto-vault` owns keys, `vault` owns orchestration, `react-vault` owns UI |
| No clear lifecycle (implicit initialization, ambiguous lock/unlock boundaries) | Explicit `Vault.open(config) → unlock(password, secretKey) → lock()` state machine with enforced transitions |
| Key material leaked across module boundaries as strings or plain buffers | `SecureBuffer` from `crypto-vault` never crosses the `vault` API boundary; only opaque handles are exchanged |
| No structured error handling (generic `Error` with string messages) | `VaultError` with structured error codes for every failure mode (lifecycle, item, attachment, share, sync) |
| Ad-hoc item encryption (some fields encrypted, others not, no versioning) | Deterministic field-level encryption: concealed fields always encrypted, schema versioned, history append-only |
| No search safety (full-text search over plaintext or unencrypted indices) | FTS5 over encrypted blind indices; concealed fields excluded from search results |
| No offline queue for mutations | Durable offline queue via `vault-sync`; reads continue from local snapshot when disconnected |
| Monolithic sync with no conflict resolution | Client-side CRDT merge with domain-specific strategies delegated to `vault-sync` |
This package migrates vault orchestration from an ad-hoc, tightly-coupled module to a rigorously designed, lifecycle-driven, enterprise-grade layer.
## Security model
### Threat model
| Attacker | Capability | Mitigation |
|---|---|---|
| **Local attacker** (malware, keylogger) | Can read process memory, intercept file system | `SecureBuffer` eager zeroization via `fill(0)` from `crypto-vault`; key material never leaves `crypto-vault` boundary; vault layer only handles opaque handles |
| **Remote attacker** (network MITM, compromised API) | Can intercept sync payloads, replay auth | E2EE via `vault-sync` (XChaCha20-Poly1305); SRP-6a authentication in `api` layer |
| **Compromised sync server** | Can read stored blobs, modify sync data | Client-side encryption; server never sees plaintext, keys, or metadata; `vault` orchestrates encryption before any network operation |
| **Forensic recovery** (stolen device, cold boot) | Can extract RAM contents post-unlock | `SecureBuffer` zeroization on lock; no key material persisted unencrypted; `vault.lock()` triggers global zeroization |
### Encryption boundaries
- **Plaintext lives only inside** the `vault` memory boundary during active sessions (unlocked state).
- **Key material lives only inside** `crypto-vault`; `vault` wraps raw `Uint8Array` keys in `OpaqueKeyHandle` objects before crossing module boundaries.
- **Item concealed fields** (passwords, PINs, secure notes) are encrypted with per-item keys derived from the hierarchical key tree.
- **Attachment plaintext** is ephemeral: decrypted for upload/download streaming only, then zeroized.
- **Search indices** use blind-index tokens (HMAC of field values) rather than plaintext to enable encrypted search.
### Key hierarchy and derivation
```text
Account Password + Secret Key + Email + Salt ──► Argon2id ──► AUK (in crypto-vault)
│
▼
HKDF-Expand ──► Personal Keyset
│
├──► HKDF ──► Vault Key (per vaultId)
│ │
│ ├──► HKDF ──► Item Key (per itemId)
│ │
│ └──► HKDF ──► Sync Encryption Key
│
└──► HKDF ──► SRP Private Key / Verifier
```
- **AUK**: Ephemeral root held only in `crypto-vault`. `vault` never accesses it directly.
- **Vault Key**: Per-vault symmetric key used to derive item keys.
- **Item Key**: Per-item symmetric key for encrypting concealed fields.
- **Sync Encryption Key**: Derived from vault key for E2EE sync payloads.
### Two-secret key derivation (2SKD) threat model
`vault` does not perform 2SKD itself, but it enforces the security invariant that the AUK (2SKD output) never leaves `crypto-vault`:
- `vault.unlock(password, secretKey)` delegates derivation to `crypto-vault` and receives only an opaque vault-key handle.
- Compromise of the `vault` process alone does not reveal key material because `crypto-vault` uses pinned memory and zeroization.
- Compromise of both `vault` and `crypto-vault` still requires the password and Secret Key to derive the AUK.
### Secure memory
- All key material is allocated inside `crypto-vault` as `Uint8Array` keys; `vault` wraps them in `OpaqueKeyHandle` instances before storing them.
- `vault` stores only opaque key handles in its state machine.
- On `vault.lock()`, all opaque handles are invalidated and `crypto-vault.zeroizeAll()` is invoked.
- Attachment streaming uses fixed-size chunks; no large plaintext buffers are retained.
### Side-channel resistance
- Search blind-index tokens are computed via constant-time HMAC to prevent timing leaks of field values.
- Item field comparison (e.g., duplicate detection) uses constant-time comparison for concealed fields.
- Vault state transitions (locked → unlocked) are not secret-dependent branches at the `vault` layer.
### Compliance
- **NIST SP 800-63B**: Relies on `crypto-vault` Argon2id for memorized secret verification; satisfies NIST-approved key hierarchy via HKDF.
- **OWASP Cryptographic Storage Cheat Sheet**: Field-level encryption with AEAD, unique per-item keys, and secure memory satisfies recommended controls.
- **GDPR Article 32**: E2EE with client-side key management provides a strong technical safeguard for personal data processing.
## Performance SLOs
| Metric | Target | Measurement |
|---|---|---|
| **Unlock latency** | P99 ≤ 1.5s on target hardware | End-to-end from `vault.unlock()` call to usable vault state (includes Argon2id in `crypto-vault`) |
| **Item CRUD latency** | P99 ≤ 10 ms | Create, read, update, delete a single item (includes encryption/decryption) |
| **Search latency (FTS5)** | P99 ≤ 50 ms for 10K items | Full-text query over 10,000 items with category/tag filters |
| **Sync latency (pull+push)** | P99 ≤ 500 ms for ≤ 100 changed items | End-to-end sync delegation to `vault-sync` |
| **Attachment upload/download** | P99 ≤ 200 ms per 1 MiB | Streaming encryption/decryption via `drive` |
| **Memory bound (10K items)** | ≤ 128 MiB resident | Vault state + SQLite connection + `crypto-vault` key hierarchy |
| **Argon2id parameterization** | Memory = 64 MiB, iterations = 3, parallelism = 4 | Default from `crypto-vault`; higher tiers configurable |
| **Scalability: max vault size** | 100,000 items | Hierarchical key derivation is O(1) per item; SQLite FTS5 scales with proper indexing |
| **Scalability: max attachment size** | 100 MiB | Streaming through `drive`; vault layer handles metadata only |
## Observability plan
All telemetry is **safe-by-construction**: no key material, no item content, no plaintext fields, no vault-specific identifiers, and no user data in spans, metrics, or logs.
### OTel spans and metrics
- `vault.unlock.duration` — histogram of unlock latency (no password, no secret key info)
- `vault.lock.duration` — histogram of lock latency (includes zeroization time)
- `vault.item.crud.duration` — histogram of item CRUD latency (no item type, no count)
- `vault.search.duration` — histogram of search latency (no query terms, no result count)
- `vault.sync.duration` — histogram of sync delegation latency (no direction, no vault ID)
- `vault.attachment.transfer.duration` — histogram of attachment upload/download latency (no size, no file name)
- `vault.state` — gauge of current vault state (`0 = locked`, `1 = unlocked`)
- Span names: `vault.unlock`, `vault.lock`, `vault.item.crud`, `vault.search`, `vault.sync`, `vault.attachment.transfer`
### Structured logging
Safe-to-log fields only:
- `event: "vault_unlocked"` — `durationMs`, `success`
- `event: "vault_locked"` — `durationMs`, `zeroizedBufferCount`
- `event: "item_created"` — `durationMs` (no item type, no field names)
- `event: "search_completed"` — `durationMs` (no query, no result count)
- `event: "sync_delegated"` — `durationMs`, `direction` (`pull`/`push`/`bidirectional`)
- `event: "attachment_transferred"` — `durationMs`, `operation` (`upload`/`download`)
### Error telemetry
- Aggregate error counts by `VaultError` code (e.g., `VAULT_LOCKED`, `ITEM_NOT_FOUND`, `SYNC_FAILED`).
- No vault ID, item ID, attachment name, or user context attached.
### Health checks
- `health.vault.unlock` — periodic test unlock with a synthetic config; fails if latency > 2× SLO.
- `health.vault.crud` — periodic create/get/delete of a synthetic item; fails on error or latency spike.
- `health.vault.search` — periodic FTS5 query over synthetic index; fails on error or latency spike.
- `health.vault.lock` — verifies lock triggers zeroization and state transition.
### Audit logging
- `audit.vault.unlock` — `timestamp`, `deviceIdHash`, `success` (no password, no key material).
- `audit.vault.lock` — `timestamp`, `deviceIdHash`, `zeroizedBufferCount`.
- `audit.vault.item.access` — `timestamp`, `deviceIdHash`, `operation` (`create`/`read`/`update`/`delete`), `itemTypeHash` (one-way hash of item type, not item ID).
- Retention: 90 days hot, 1 year cold.
## Dependency audit
| Dependency | Justification | Supply-chain risk | Tree-shakeable | License |
|---|---|---|---|---|
| `@suaveplan/crypto-vault` | Key hierarchy, 2SKD, `SecureBuffer`, zeroization | Internal monorepo; audited in CI | Yes | MIT |
| `@suaveplan/crypto` (subpaths `/keychain`, `/keychain/keypair`, `/keychain/storage`, `/authority`, `/vault`) | Keypair generation, validation, keychain storage adapters, Authority chain-of-trust (replaces upstream `ChainSigner`), low-level vault seal/open primitives consumed by the orchestration layer | Internal monorepo; audited in CI | Yes | MIT |
| `@suaveplan/db-sqlite` | Item storage, FTS5 search, history append | Internal monorepo | Yes | MIT |
| `@suaveplan/drive` | Attachment upload/download streaming | Internal monorepo | Yes | MIT |
| `@suaveplan/drive-sharing` | Share link creation/revocation | Internal monorepo | Yes | MIT |
| `@suaveplan/vault-sync` | Offline-first sync engine, CRDT merge | Internal monorepo; audited in CI | Yes | MIT |
| `@suaveplan/error` | Base error class | Internal monorepo | Yes | MIT |
**No external runtime dependencies.** All orchestration dependencies are internal monorepo packages. This eliminates hidden transitive dependencies and minimizes supply-chain attack surface.
## Operational readiness
### Graceful degradation
- If sync server is unreachable, the vault enters **offline mode**: reads and local mutations continue; sync is deferred to `vault-sync` queue.
- If attachment upload fails (network or storage quota), the item is saved locally and the attachment is queued for retry.
- If search index is corrupted, the vault falls back to **linear scan** with a warning telemetry event and schedules index rebuild.
- If `crypto-vault` derivation fails (bad password), unlock rejects immediately with `VaultError` — no silent downgrades.
### Resource limits
- **Max items per vault**: 100,000. Beyond this, creation is rejected with `VaultError`.
- **Max attachment size**: 100 MiB per file. Prevents memory exhaustion and storage abuse.
- **Max concurrent attachment transfers**: 3 per vault. Prevents network and memory saturation.
- **Max search query length**: 256 characters. Prevents pathological FTS5 queries.
- **Max offline sync queue depth**: 10,000 pending operations (enforced by `vault-sync`).
### Error recovery
- Unlock failures are non-retryable by design (bad password, bad config). Caller receives structured error code.
- Item CRUD failures trigger rollback of in-memory state; SQLite transaction ensures atomicity.
- Sync failures are retried with exponential backoff by `vault-sync` (base 2, max delay 60s, max 10 retries).
- Attachment transfer failures are retried up to 3 times; persistent failures queue for later retry.
- Search index corruption triggers automatic rebuild from encrypted item data on next unlock.
### Backup/restore compatibility
- Vault data is backed up as standard SQLite + encrypted attachments. No proprietary formats.
- Key hierarchy is fully deterministic from mnemonic + passphrase (stored in `crypto-vault`); restore re-derives identical keys.
- Sync queue is serialized as standard JSON with base64-encoded ciphertext fields — portable across platforms.
- Restore replays the sync queue in causal order; merging is deterministic.
### Upgrade/downgrade safety
- Vault item schema includes a version field. New versions can add optional fields; old versions ignore unknown fields.
- SQLite schema migrations are versioned and tested. Downgrade is supported to the previous minor version.
- Attachment format includes a version header. New encryption algorithms can be introduced alongside old ones.
- No key format migrations required for pure orchestration — math does not change.
### Key rotation without full re-encryption
- **Account password rotation**: Delegated to `crypto-vault`; re-derives AUK and re-encrypts personal keyset wrapper only. Vault keys and item keys remain unchanged.
- **Secret Key rotation**: Delegated to `crypto-vault`; re-derives AUK and re-encrypts personal keyset wrapper. Vault keys and item keys remain unchanged.
- **Vault key rotation**: Delegated to `crypto-vault`; derives new vault key from personal keyset. Item keys are re-encrypted under the new vault key (header-only change).
- **Item key rotation**: Delegated to `crypto-vault`; derives new item key from vault key. Re-encrypts single item only.
- **Sync encryption key rotation**: Delegated to `vault-sync`; re-encrypts pending queue entries and latest sync payload header.
## Verification approach
- `src/vault/vault.test.ts` — lifecycle (open/unlock/lock), state transitions, locked-operation rejection, re-unlock freshness
- `src/items/items.test.ts` — CRUD, encryption of concealed fields, history append, tombstone deletion, validation
- `src/search/search.test.ts` — FTS5 delegation, category/tag/favorite filters, concealed-field exclusion
- `src/attachments/attachments.test.ts` — upload/download round-trip, encryption, deletion, locked-state rejection
- `src/shares/shares.test.ts` — link creation/revocation, key-material exclusion, locked-state rejection
- `src/sync/sync.test.ts` — pull/push/bidirectional sync delegation, offline queue, locked-state rejection
- `src/error/error-codes.test.ts` — every error code is thrown by at least one production path
- `tests/dep-graph.test.ts` — dependency invariants (no circular deps with `crypto-vault`, `keys`, `drive`, `drive-sharing`, `vault-sync`)
### Coverage and quality targets
- **100% coverage target**: All source files must achieve 100% statement and branch coverage.
- **Property-based tests**: `fast-check` properties for vault lifecycle state transitions, item CRUD round-trip, search query safety, attachment upload/download round-trip, and sync delegation correctness.
- **Load/stress tests**: 10,000-item vault CRUD to verify memory bounds and latency SLOs; 100 concurrent unlock/lock cycles to verify zeroization and state machine robustness; 1,000 concurrent searches to verify FTS5 contention limits.
- **Security audit checklist**:
- [ ] Argon2id memory cost ≥ 64 MiB enforced at runtime (delegated to `crypto-vault`)
- [ ] All key material zeroized on lock (`vault.lock()` triggers `crypto-vault.zeroizeAll()`)
- [ ] No plaintext key material in any log, error message, or `toString()` output
- [ ] Concealed fields encrypted before persistence; no plaintext passwords in SQLite
- [ ] Search blind indices do not reveal field values (HMAC with vault-scoped key)
- [ ] Locked vault rejects all item, attachment, share, and sync operations
- [ ] Vault state machine transitions are deterministic and enforced (no invalid states)
- [ ] Share links never include key material or vault identifiers
- **Penetration test scenarios**:
- [ ] Dump process memory after `vault.lock()` — verify no vault key, item key, or AUK material present
- [ ] Attempt CRUD operation on locked vault — verify rejection with `VAULT_LOCKED` error
- [ ] Fuzz search queries — verify no crashes, no information leakage, no SQL injection
- [ ] Attempt direct SQLite access — verify concealed fields are ciphertext, not plaintext
- [ ] Crash during attachment upload — verify no plaintext in crash dump or temp files
## Migration impact
None on genesis — additive only. `@suaveplan/keys` was never created on genesis; its responsibilities are already split across the existing `@suaveplan/crypto` subpaths. The vault layer is therefore a pure new-package landing with no source moves and no deprecation aliases.
Final ownership matrix on genesis:
- `@suaveplan/crypto/keychain` (existing) owns: `generateKeypair`, `validateKeypair`, `assertValidKeypair`, `Keychain` plus storage adapters.
- `@suaveplan/crypto/authority` (existing) owns: chain-of-trust signing — replaces upstream's `ChainedKeySigner`.
- `@suaveplan/crypto/vault` (existing) owns: low-level seal / open primitives (`sealPrivateKeyBlob`, `openPrivateKeyBlob`, `sealVaultPayload`, `openVaultPayload`, `changePassword`, `rotateApplicationData`, `LegacyVault`). Unchanged by this proposal.
- `@suaveplan/crypto-vault` (shipped) owns: BIP39, Argon2id, 2SKD, SRP-6a, key hierarchy, `SecureBuffer`, `OpaqueKeyHandle`.
- `@suaveplan/vault` (this change) owns: `Vault` lifecycle (`Vault.open` / `unlock` / `lock`), `VaultFactory`, item / attachment / share / sync orchestration, encrypted-field policy, `OpaqueKeyHandle` boundary enforcement.

View File

@@ -0,0 +1,222 @@
# 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`.

View File

@@ -0,0 +1,73 @@
# Tasks — vault
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened.
## 1. Spec authoring
- [x] 1.1 Read `MIGRATION.md` Section 7 (Vault Orchestration Layer) and Section 8
- [x] 1.2 Read `openspec/AGENTS.md` Sections 13 and 14
- [x] 1.3 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.4 Draft `specs/vault/spec.md` from `openspec/templates/spec-delta.md`
- [x] 1.5 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
## 2. Implementation
- [x] 2.1 Scaffold package at `packages/vault/vault` with `package.json`, `tsconfig.json`, `vitest.config.ts`
- [x] 2.2 Implement `Vault` class: `Vault.open(config)`, `unlock(password, secretKey)`, `lock()`, `isLocked`
- [x] 2.3 Implement `vault.items` delegation: `create`, `get`, `update`, `delete`, `list`, `search` → `db-sqlite`
- [x] 2.4 Implement `vault.attachments` delegation: `upload`, `download`, `delete` → `drive`
- [x] 2.5 Implement `vault.shares` delegation: `createLink`, `revokeLink` → `drive-sharing`
- [x] 2.6 Implement `vault.sync` delegation: `pull`, `push`, `sync` → `vault-sync`
- [x] 2.7 Implement field encryption: concealed fields encrypted with per-item key from `crypto-vault`
- [x] 2.8 Implement error module: `VaultError`, structured error codes
- [x] 2.9 ~~Migrate `VaultManager` / `VaultFactory` from `@suaveplan/keys` into `@suaveplan/vault`~~ — N/A on genesis. Amended 2026-05-03: `@suaveplan/keys` was never created on genesis; keypair / Keychain / Authority / low-level vault primitives already live under `@suaveplan/crypto/keychain`, `@suaveplan/crypto/authority`, and `@suaveplan/crypto/vault`. `VaultManager` / `VaultFactory` are authored fresh inside `@suaveplan/vault` consuming those existing surfaces. See proposal `## Why` amendment block and `## Migration impact` for the revised ownership matrix.
- [x] 2.10 Add `OpaqueKeyHandle` wrapper for `crypto-vault` keys
- [x] 2.11 Add `rng?: Rng` injection to `Vault.open` and `Vault.unlock`
- [x] 2.12 Wire subpath exports in `package.json` for every feature module
- [x] 2.13 Add `README.md` with quick-start, modules list, and install instruction
## 3. Tests
- [x] 3.1 Unit tests at `src/vault/vault.test.ts` covering lifecycle, state transitions, zeroization
- [x] 3.2 Unit tests at `src/items/items.test.ts` covering CRUD, encryption, history, validation
- [x] 3.3 Unit tests at `src/search/search.test.ts` covering FTS5 delegation and filter correctness
- [x] 3.4 Unit tests at `src/attachments/attachments.test.ts` covering upload/download round-trip
- [x] 3.5 Unit tests at `src/shares/shares.test.ts` covering link creation, revocation, key exclusion
- [x] 3.6 Unit tests at `src/sync/sync.test.ts` covering pull/push/bidirectional delegation
- [x] 3.7 Spec-conformance tests at `src/spec-conformance.test.ts` pinning type-level / strict-mode scenarios
- [x] 3.8 Integration tests verifying `vault` + `crypto-vault` + `db-sqlite` + `drive` end-to-end
- [x] 3.9 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
## 4. Documentation
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
- [x] 4.2 README links every co-located `.md` under `## Modules`
- [x] 4.3 Update `CLAUDE.md` package listing if this is a new package
- [x] 4.4 ~~Update `@suaveplan/keys` sibling docs to remove vault-related references~~ — N/A on genesis (no `@suaveplan/keys` package exists; see §2.9 amendment).
- [x] 4.5 ~~Update `@suaveplan/keys` tests to remove `VaultManager`/`VaultFactory` coverage~~ — N/A on genesis (see §2.9 amendment).
- [x] 4.6 ~~Add deprecation re-exports in `@suaveplan/keys` for one minor version~~ — N/A on genesis (see §2.9 amendment).
- [x] 4.7 ~~Update `keys` sibling `.md` docs to reflect reduced scope~~ — N/A on genesis (see §2.9 amendment).
## 5. Pre-archive verification (the 5-gate triple)
- [x] 5.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the whole monorepo
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/vault` — green (167 tests pass across 16 files)
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/vault` — 100/100/100/100 (statements/branches/functions/lines)
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/vault` — exit 0, 0 errors (2 `schemas-at-boundaries` warnings on `VaultItemSchema` and `ManifestArgon2idSchema` are advisory; both schemas are part of the public surface for downstream consumers)
- [x] 5.5 `bunx turbo build --filter=@suaveplan/vault` — green
## 6. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [x] 6.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim
- [x] 6.2 Zod schemas wired at every declared boundary
- [x] 6.3 Every error code in `error-codes.ts` thrown by at least one production path
- [x] 6.4 Strict-mode behaviors tested both on and off
- [x] 6.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`
- [x] 6.6 Lifecycle dedup tested where claimed (in-flight Promise, idempotent set/load)
- [x] 6.7 No retroactive tick — every box above reflects work done in this change
## 7. Archive
- [x] 7.1 Fold spec delta into `openspec/specs/vault/spec.md`
- [x] 7.2 Move `openspec/changes/vault/` → `openspec/archive/2026-04-29-vault/`
- [x] 7.3 Refresh `MANIFEST.md` if package set or deps changed