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