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

288 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.