Initial Commit
This commit is contained in:
261
openspec/archive/2026-05-03-crypto-vault/proposal.md
Normal file
261
openspec/archive/2026-05-03-crypto-vault/proposal.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# crypto-vault — Cryptographic Foundation Layer
|
||||
|
||||
- **Change ID**: crypto-vault
|
||||
- **Package**: `@suaveplan/crypto-vault`
|
||||
- **Status**: PROPOSED
|
||||
- **Type**: migration-redesign
|
||||
- **Date**: 2026-04-29
|
||||
|
||||
## Why
|
||||
|
||||
The Vaulted password-manager ecosystem requires a dedicated cryptographic foundation that `@suaveplan/keys` (keypair management) and `@suaveplan/crypto-*` (primitives) do not provide. `crypto-vault` fills the gap between raw crypto primitives and vault orchestration: BIP39 mnemonic generation, Argon2id HD key derivation, two-secret key derivation (2SKD), SRP-6a authentication, and a hierarchical key path (AUK → personal keyset → vault key → item key). Without this package, vault key management would be scattered across `keys` and `vault`, violating the invariant that key material never leaves its memory boundary.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `generateMnemonic(strength?)` / `entropyToMnemonic` / `mnemonicToEntropy` — BIP39 English word-list support
|
||||
- ADDED: `deriveKeyWithArgon2id(password, salt, params)` — Argon2id with OWASP-safe minimum parameter enforcement
|
||||
- ADDED: `deriveMasterKeyFromMnemonic(mnemonic, passphrase?)` — BIP39 seed + BIP32-like HD derivation
|
||||
- ADDED: `deriveAuk(password, secretKey, email, salt, params)` — 2SKD combining password + Secret Key + email
|
||||
- ADDED: `derivePersonalKeyset(auk)` / `deriveVaultKey(personalKeyset, vaultId)` / `deriveItemKey(vaultKey, itemId)` — hierarchical HKDF key tree
|
||||
- ADDED: `SrpClient` class — full SRP-6a client (salt, private key, verifier, ephemeral keypair, session key, server proof verification)
|
||||
- ADDED: `SecureBuffer` — `Uint8Array`-based secure buffer with eager zeroization via `fill(0)` on close, `toString` masking, and clone isolation
|
||||
- ADDED: `zeroizeAll()` — global zeroization of all live secure buffers
|
||||
- ADDED: `CryptoVaultError` with structured error codes for every failure mode
|
||||
|
||||
## Current-state audit
|
||||
|
||||
Package exists at `packages/crypto/crypto-vault` (v0.0.0) with `mnemonic`, `vault`, `chain-signer`, `errors` modules. This change restructures and expands the package.
|
||||
|
||||
## Gap analysis: sub-standard → enterprise
|
||||
|
||||
Most password-manager cryptographic implementations fall well below enterprise standards:
|
||||
|
||||
| Sub-standard practice | Enterprise replacement |
|
||||
|---|---|
|
||||
| PBKDF2 with low iteration counts (often < 100k) | Argon2id with OWASP-safe minimum parameters (≥ 64 MiB memory, ≥ 3 iterations, parallelism ≥ 1) |
|
||||
| Single-factor key derivation (password only) | Two-secret key derivation (2SKD): password + Secret Key + email salt |
|
||||
| No constant-time comparison of MACs or passwords | Constant-time `timingSafeEqual` for all sensitive comparisons |
|
||||
| Key material scattered across modules, leaked in strings | `SecureBuffer` with eager zeroization via `fill(0)`, and masked `toString` |
|
||||
| Flat key hierarchy (one key encrypts everything) | Hierarchical HKDF tree: AUK → personal keyset → vault key → item key |
|
||||
| No secure memory zeroization on lock | Global `zeroizeAll()` + per-buffer `zeroize()` with clone isolation |
|
||||
| Mnemonic generation without checksum validation | Full BIP39 English word-list support with checksum validation |
|
||||
| Weak or non-standard authentication protocols | SRP-6a with full client-side proof verification and tamper rejection |
|
||||
|
||||
This package migrates the entire cryptographic foundation from ad-hoc, copy-pasted primitives to a rigorously designed, audited, and tested enterprise-grade layer.
|
||||
|
||||
## Security model
|
||||
|
||||
### Threat model
|
||||
|
||||
| Attacker | Capability | Mitigation |
|
||||
|---|---|---|
|
||||
| **Local attacker** (malware, keylogger) | Can read process memory, intercept system calls | `SecureBuffer` eager zeroization via `fill(0)`; key material never leaves crypto-vault boundary; zeroization on lock |
|
||||
| **Remote attacker** (network MITM, compromised API) | Can intercept sync payloads, replay auth | SRP-6a mutual auth; E2EE via XChaCha20-Poly1305 handled in vault-sync |
|
||||
| **Compromised sync server** | Can read stored blobs, modify sync data | Client-side encryption; server never sees plaintext, keys, or SRP verifier entropy |
|
||||
| **Forensic recovery** (stolen device, cold boot) | Can extract RAM contents post-unlock | SecureBuffer zeroization on lock; no key material persisted unencrypted; Argon2id slow brute-force |
|
||||
|
||||
### Encryption boundaries
|
||||
|
||||
- **Plaintext lives only inside** `SecureBuffer` instances within the `crypto-vault` memory boundary.
|
||||
- **Keys live only inside** `SecureBuffer` instances derived from the hierarchical tree.
|
||||
- No key material is serialized to JSON, strings, or plain `Uint8Array`s that survive garbage collection without zeroization.
|
||||
- The AUK (Account Unlock Key) is ephemeral: computed on unlock, zeroized on lock.
|
||||
|
||||
### Key hierarchy and derivation
|
||||
|
||||
```text
|
||||
Account Password + Secret Key + Email + Salt ──► Argon2id ──► AUK
|
||||
│
|
||||
▼
|
||||
HKDF-Expand ──► Personal Keyset
|
||||
│
|
||||
├──► HKDF ──► Vault Key (per vaultId)
|
||||
│ │
|
||||
│ └──► HKDF ──► Item Key (per itemId)
|
||||
│
|
||||
└──► HKDF ──► SRP Private Key / Verifier
|
||||
```
|
||||
|
||||
- **AUK**: Ephemeral root derived from 2SKD. Never persisted.
|
||||
- **Personal Keyset**: Long-lived HKDF-derived branch for vault and auth sub-keys.
|
||||
- **Vault Key**: Per-vault symmetric key for vault-level encryption.
|
||||
- **Item Key**: Per-item symmetric key for item-level encryption.
|
||||
|
||||
### Two-secret key derivation (2SKD) threat model
|
||||
|
||||
2SKD splits the entropy requirement across two factors:
|
||||
|
||||
1. **Something you know** (password) — vulnerable to phishing, keylogging, brute-force.
|
||||
2. **Something you have** (Secret Key) — a high-entropy device-local or paper key.
|
||||
|
||||
An attacker must compromise **both** factors to derive the AUK. Compromise of the password alone is insufficient because Argon2id parameters make brute-force infeasible. Compromise of the Secret Key alone is insufficient because it is high-entropy and useless without the password and email salt. The email salt binds the derivation to the account identity, preventing cross-account pre-computation.
|
||||
|
||||
### Secure memory
|
||||
|
||||
- **`SecureBuffer`**: Uses `Uint8Array` with eager zeroization via `fill(0)` on close; memory locking via `mlock` is deferred to a future native binding.
|
||||
- **Clone isolation**: Every clone creates a distinct allocation; zeroizing one does not affect siblings.
|
||||
- **`toString` masking**: Returns `[SecureBuffer]` instead of hex/base64 to prevent accidental logging.
|
||||
- **Global `zeroizeAll()`**: Emergency zeroization of all live `SecureBuffer`s on lock, crash, or security event.
|
||||
|
||||
### Side-channel resistance
|
||||
|
||||
- **Constant-time comparison**: All MAC verification, password comparison, and SRP proof checks use `timingSafeEqual`.
|
||||
- **Timing-safe MAC**: HMAC-SHA256 and Poly1305 tag verification are constant-time.
|
||||
- **Argon2id memory-hardness**: Memory-access pattern is data-dependent, mitigating cache-timing attacks.
|
||||
|
||||
### Compliance
|
||||
|
||||
- **NIST SP 800-63B**: Memorized secret verifier requirements (salted, iterated, memory-hard). Argon2id satisfies the latest NIST guidance for password hashing.
|
||||
- **OWASP Password Storage Guidelines**: Recommends Argon2id as the primary choice. Minimum parameters enforced at runtime (memory ≥ 64 MiB, iterations ≥ 3).
|
||||
|
||||
## Performance SLOs
|
||||
|
||||
| Metric | Target | Measurement |
|
||||
|---|---|---|
|
||||
| **Unlock latency (Argon2id)** | P99 ≤ 1.5s on target hardware | End-to-end from password entry to decrypted vault key |
|
||||
| **HD key derivation (AUK → item key)** | P99 ≤ 10 ms | Single HKDF-expand per key level |
|
||||
| **SecureBuffer allocation** | P99 ≤ 1 ms | Pinned memory allocation + zero-fill |
|
||||
| **Zeroization latency** | P99 ≤ 5 ms | Global zeroize of 1,000 live buffers |
|
||||
| **Memory bound (10K items)** | ≤ 128 MiB resident | All live SecureBuffers + key hierarchy |
|
||||
| **Argon2id parameterization** | Memory = 64 MiB, iterations = 3, parallelism = 4 | Default; higher tiers configurable |
|
||||
| **Scalability: max vault size** | 100,000 items | Hierarchical key derivation is O(1) per item |
|
||||
| **Scalability: max attachment size** | 100 MiB | Encryption streaming handled by drive layer; keys are fixed-size |
|
||||
|
||||
## Observability plan
|
||||
|
||||
All telemetry is **safe-by-construction**: no key material, no plaintext, no vault metadata, no item counts, and no user-identifiable data in spans, metrics, or logs.
|
||||
|
||||
### OTel spans and metrics
|
||||
|
||||
- `crypto-vault.derive.argon2id.duration` — histogram of Argon2id derivation time (no params, no salt)
|
||||
- `crypto-vault.derive.hkdf.duration` — histogram of HKDF derivation time
|
||||
- `crypto-vault.secure-buffer.count` — gauge of live SecureBuffers (count only, no addresses)
|
||||
- `crypto-vault.zeroize.duration` — histogram of zeroization latency
|
||||
- `crypto-vault.srp.handshake.duration` — histogram of SRP client handshake time
|
||||
- Span names: `derive.deriveAuk`, `derive.deriveVaultKey`, `srp.handshake`, `securebuffer.allocate`
|
||||
|
||||
### Structured logging
|
||||
|
||||
Safe-to-log fields only:
|
||||
|
||||
- `event: "auk_derived"` — no password, no secret key, no salt
|
||||
- `event: "vault_key_derived"` — `vaultIdHash: sha256(vaultId)` (one-way), no key
|
||||
- `event: "secure_buffer_zeroized"` — `bufferCount: number`
|
||||
- `event: "srp_handshake_complete"` — `success: boolean`, `durationMs: number`
|
||||
|
||||
### Error telemetry
|
||||
|
||||
- Aggregate error counts by `CryptoVaultError` code (e.g., `ARGON2ID_PARAMS_UNSAFE`, `SRP_PROOF_MISMATCH`).
|
||||
- No vault ID, item ID, salt, or user context attached.
|
||||
|
||||
### Health checks
|
||||
|
||||
- `health.crypto-vault.derivation` — periodic Argon2id derivation of a test vector; fails if latency > 2× SLO.
|
||||
- `health.crypto-vault.memory` — verifies SecureBuffer allocation and zeroization succeed.
|
||||
|
||||
### Audit logging
|
||||
|
||||
- `audit.crypto-vault.unlock` — `timestamp`, `deviceIdHash`, `success` (no password, no key material).
|
||||
- `audit.crypto-vault.lock` — `timestamp`, `deviceIdHash`, `zeroizedBufferCount`.
|
||||
- Retention: 90 days hot, 1 year cold.
|
||||
|
||||
## Dependency audit
|
||||
|
||||
| Dependency | Justification | Supply-chain risk | Tree-shakeable | License |
|
||||
|---|---|---|---|---|
|
||||
| `@suaveplan/crypto-hash` | Argon2id and HKDF primitives | Internal monorepo; audited in CI | Yes | MIT |
|
||||
| `@suaveplan/random` | CSPRNG for salts and ephemeral keys | Internal monorepo; audited in CI | Yes | MIT |
|
||||
| `@suaveplan/encoding` | Base64/hex for test vectors and interop | Internal monorepo | Yes | MIT |
|
||||
| `@suaveplan/error` | Base error class | Internal monorepo | Yes | MIT |
|
||||
|
||||
**No external runtime dependencies.** All primitives are either internal packages or standard Web Crypto / Node.js crypto APIs. This minimizes supply-chain attack surface and eliminates hidden transitive dependencies.
|
||||
|
||||
## Operational readiness
|
||||
|
||||
### Graceful degradation
|
||||
|
||||
- If Argon2id parameter validation fails (e.g., caller supplies unsafe params), derivation rejects immediately with `CryptoVaultError` — no silent downgrades.
|
||||
- `SecureBuffer` always uses standard `Uint8Array` with eager zeroization via `fill(0)` on close; a warning telemetry event is emitted if a future native `mlock` binding is unavailable.
|
||||
|
||||
### Resource limits
|
||||
|
||||
- **Argon2id max memory**: 1 GiB (enforced at runtime; reject params exceeding this).
|
||||
- **Max concurrent derivation operations**: 4 (prevents memory exhaustion from parallel unlocks).
|
||||
- **SecureBuffer max size**: 64 KiB (keys and secrets only; large data handled by streaming layers).
|
||||
|
||||
### Error recovery
|
||||
|
||||
- Derivation failures are non-retryable by design (bad password, bad params). Caller receives structured error code.
|
||||
- SRP handshake failures are non-retryable beyond standard TCP retry; caller must re-initiate.
|
||||
- SecureBuffer allocation failures trigger global zeroization and a fatal health-check failure.
|
||||
|
||||
### Backup/restore compatibility
|
||||
|
||||
- Mnemonic generation produces standard BIP39 seed phrases — compatible with any BIP39 wallet or backup tool.
|
||||
- Key hierarchy is fully deterministic from mnemonic + passphrase; restore re-derives identical keys.
|
||||
- No proprietary key formats; all serialized data uses standard encoding.
|
||||
|
||||
### Upgrade/downgrade safety
|
||||
|
||||
- Argon2id parameters are versioned in the salt/header. New versions can increase costs; old versions can still derive.
|
||||
- HKDF info strings include a version prefix (`"v1/..."`) for forward-compatible derivation paths.
|
||||
- No schema migrations required for pure crypto — math does not change.
|
||||
|
||||
### Key rotation without full re-encryption
|
||||
|
||||
- **Account password rotation**: Re-derive AUK with new password; re-encrypt personal keyset wrapper only. Vault keys and item keys remain unchanged.
|
||||
- **Secret Key rotation**: Requires re-derivation of AUK and re-encryption of personal keyset wrapper. Vault keys and item keys remain unchanged.
|
||||
- **Vault key rotation**: Derive new vault key from existing personal keyset; re-encrypt vault header only. Item keys remain unchanged.
|
||||
- **Item key rotation**: Derive new item key from existing vault key; re-encrypt single item only.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Server-side SRP-6a verifier storage or session management — belongs in `@suaveplan/api` gateway layer
|
||||
- BIP39 word lists other than English — defer until i18n requirement is concrete
|
||||
- Hardware security module (HSM) or secure-enclave key storage — deferred to `@suaveplan/biometrics` and platform-specific packages
|
||||
- Argon2id WASM or native bindings — pure-TS/Bun implementation using `@suaveplan/crypto-hash` primitives
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/bip39/bip39.test.ts` — BIP39 generation, round-trip, checksum validation, error paths
|
||||
- `src/argon2id/argon2id.test.ts` — Argon2id derivation, parameter safety, salt uniqueness
|
||||
- `src/hd-key/hd-key.test.ts` — HD master key derivation, passphrase sensitivity
|
||||
- `src/twoskd/twoskd.test.ts` — 2SKD determinism, factor separation, empty-input rejection
|
||||
- `src/key-hierarchy/key-hierarchy.test.ts` — hierarchical derivation determinism, branch isolation
|
||||
- `src/srp/srp-client.test.ts` — full handshake, wrong-password failure, tampered proof rejection
|
||||
- `src/secure/secure-buffer.test.ts` — zeroization, masking, clone isolation
|
||||
- `src/error/error-codes.test.ts` — every error code is thrown by at least one production path
|
||||
- `tests/dep-graph.test.ts` — pins zero `@suaveplan/*` runtime deps for `testing` packages
|
||||
|
||||
### Coverage and quality targets
|
||||
|
||||
- **100% coverage target**: All source files must achieve 100% statement and branch coverage.
|
||||
- **Property-based tests**: `fast-check` properties for BIP39 round-trip, Argon2id parameter enforcement, 2SKD commutativity, HKDF branch isolation, and SRP handshake correctness.
|
||||
- **Load/stress tests**: 1,000 concurrent Argon2id derivations to verify memory bounds and contention; 10,000 hierarchical key derivations to verify latency SLOs.
|
||||
- **Security audit checklist**:
|
||||
- [ ] Argon2id memory cost ≥ 64 MiB enforced at runtime
|
||||
- [ ] Argon2id iteration count ≥ 3 enforced at runtime
|
||||
- [ ] All key material zeroized on lock (`zeroizeAll()` + per-buffer `zeroize()`)
|
||||
- [ ] No plaintext key material in any log, error message, or `toString()` output
|
||||
- [ ] All MAC and comparison operations use constant-time `timingSafeEqual`
|
||||
- [ ] SRP-6a server proof verified before session key use
|
||||
- [ ] SecureBuffer clone isolation verified (zeroize one, verify sibling intact)
|
||||
- [ ] Salt uniqueness verified across 10,000 generations (collision probability < 2⁻¹²⁸)
|
||||
- **Penetration test scenarios**:
|
||||
- [ ] Dump process memory after `vault.lock()` — verify no AUK, vault key, or item key material present
|
||||
- [ ] Fuzz Argon2id parameters — verify rejection of all unsafe combinations
|
||||
- [ ] Fuzz SRP handshake messages — verify no crashes or incorrect proof acceptance
|
||||
- [ ] Attempt timing attack on MAC comparison — verify distribution is statistically uniform
|
||||
|
||||
## Impact
|
||||
|
||||
Existing consumers of `@suaveplan/crypto-vault` (v0.0.0) will be affected as follows:
|
||||
|
||||
- The current `vault` and `chain-signer` subpath exports will be restructured to align with the new hierarchy.
|
||||
- New subpath exports (`bip39`, `argon2id`, `twoskd`, `srp`, `secure`) will be added; existing exports remain available during the migration window.
|
||||
- Internal APIs may change shape as the package moves from a narrow mnemonic+vault utility to a full cryptographic foundation.
|
||||
- All downstream packages (`vault`, `vault-sync`, `keys`) must update their imports to use the new module layout.
|
||||
|
||||
## Migration impact
|
||||
|
||||
`VaultManager` and `VaultFactory` consumers in `@suaveplan/keys` migrate to `@suaveplan/vault`. `mnemonic` and `chain-signer` consumers update imports to the new subpath layout. A deprecation window of one minor version is provided for removed exports.
|
||||
@@ -0,0 +1,216 @@
|
||||
# Spec: @suaveplan/crypto-vault
|
||||
|
||||
## Capability
|
||||
|
||||
Cryptographic foundation for the Vaulted password-manager ecosystem. Provides BIP39 mnemonic generation, Argon2id-based HD key derivation, two-secret key derivation (2SKD: password + Secret Key → Account Unlock Key), SRP-6a authentication primitives, and a hierarchical key derivation path (AUK → personal keyset → vault key → item key). All key material is held in secure memory and zeroized on lock.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: BIP39 mnemonic generation and entropy conversion
|
||||
|
||||
The package SHALL export `generateMnemonic(strength?: 128 | 256, options?: { rng?: Rng }): string` producing a BIP39-compliant mnemonic phrase with 12 words (128-bit) or 24 words (256-bit) of entropy, and `mnemonicToEntropy(mnemonic: string): Uint8Array` which converts a mnemonic back to its raw entropy bytes. `entropyToMnemonic(entropy: Uint8Array): string` SHALL accept only 16-byte or 32-byte inputs and produce the canonical English word list phrase. Invalid entropy lengths MUST throw `CryptoVaultError` with code `CRYPTOVAULT.BIP39.INVALID_ENTROPY_LENGTH`. Invalid mnemonics (checksum failure, unknown words) MUST throw `CryptoVaultError` with code `CRYPTOVAULT.BIP39.INVALID_MNEMONIC`.
|
||||
|
||||
#### Scenario: Generate a 12-word mnemonic from 128-bit entropy
|
||||
|
||||
- **WHEN** `generateMnemonic(128)` is called
|
||||
- **THEN** the result is a string of exactly 12 space-separated words from the English BIP39 word list AND the final word satisfies the checksum
|
||||
|
||||
#### Scenario: Generate a 24-word mnemonic from 256-bit entropy
|
||||
|
||||
- **WHEN** `generateMnemonic(256)` is called
|
||||
- **THEN** the result is a string of exactly 24 space-separated words from the English BIP39 word list AND the final word satisfies the checksum
|
||||
|
||||
#### Scenario: Entropy round-trips through mnemonic
|
||||
|
||||
- **WHEN** 16-byte or 32-byte entropy is passed through `entropyToMnemonic` then `mnemonicToEntropy`
|
||||
- **THEN** the resulting bytes equal the input byte-for-byte
|
||||
|
||||
#### Scenario: Invalid entropy length is rejected
|
||||
|
||||
- **WHEN** `entropyToMnemonic` is called with a `Uint8Array` whose length is not 16 or 32
|
||||
- **THEN** a `CryptoVaultError` with code `CRYPTOVAULT.BIP39.INVALID_ENTROPY_LENGTH` is thrown
|
||||
|
||||
#### Scenario: Invalid mnemonic is rejected
|
||||
|
||||
- **WHEN** `mnemonicToEntropy` is called with a malformed mnemonic (bad checksum, unknown word, or wrong word count)
|
||||
- **THEN** a `CryptoVaultError` with code `CRYPTOVAULT.BIP39.INVALID_MNEMONIC` is thrown
|
||||
|
||||
#### Scenario: Injectable RNG determines entropy
|
||||
|
||||
- **WHEN** `generateMnemonic` is called with a test RNG in `options.rng`
|
||||
- **THEN** the resulting mnemonic is deterministic for that RNG
|
||||
|
||||
**Verified by:** `src/bip39/bip39.test.ts::"generates a 12-word mnemonic"` AND `src/bip39/bip39.test.ts::"generates a 24-word mnemonic"` AND `src/bip39/bip39.test.ts::"round-trips 16-byte entropy"` AND `src/bip39/bip39.test.ts::"round-trips 32-byte entropy"` AND `src/bip39/bip39.test.ts::"rejects entropy length other than 16 or 32"` AND `src/bip39/bip39.test.ts::"rejects mnemonic with bad checksum"` AND `src/bip39/bip39.test.ts::"rejects mnemonic with unknown word"` AND `src/bip39/bip39.test.ts::"rejects empty mnemonic"` AND `src/bip39/bip39.test.ts::"injectable rng determines entropy"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Argon2id parameterization and HD key derivation
|
||||
|
||||
The package SHALL export `deriveKeyWithArgon2id(password: Uint8Array, salt: Uint8Array, params: Argon2idParams): Promise<Uint8Array>` producing a derived key via Argon2id. `Argon2idParams` MUST expose `memoryKB`, `iterations`, `parallelism`, and `outputLength` with safe defaults (`memoryKB: 65536`, `iterations: 3`, `parallelism: 4`, `outputLength: 32`). Parameter values below OWASP minimums (`memoryKB < 65536`, `iterations < 3`) MUST throw `CryptoVaultError` with code `CRYPTOVAULT.ARGON2ID.UNSAFE_PARAMETERS`. `deriveMasterKeyFromMnemonic(mnemonic: string, passphrase?: string)` SHALL derive the BIP39 seed (mnemonic + optional passphrase → 512-bit seed via PBKDF2) then feed it into a BIP32-like HD path to produce a 256-bit master key.
|
||||
|
||||
#### Scenario: Argon2id derives a 32-byte key from password and salt
|
||||
|
||||
- **WHEN** `deriveKeyWithArgon2id` is called with a password, a unique 16-byte salt, and default parameters
|
||||
- **THEN** the result is a 32-byte `Uint8Array` AND the same inputs always produce the same output
|
||||
|
||||
#### Scenario: Different salts produce different keys
|
||||
|
||||
- **WHEN** `deriveKeyWithArgon2id` is called with the same password but different salts
|
||||
- **THEN** the resulting keys are different
|
||||
|
||||
#### Scenario: Unsafe parameters are rejected
|
||||
|
||||
- **WHEN** `deriveKeyWithArgon2id` is called with `memoryKB < 65536` or `iterations < 3`
|
||||
- **THEN** a `CryptoVaultError` with code `CRYPTOVAULT.ARGON2ID.UNSAFE_PARAMETERS` is thrown
|
||||
|
||||
#### Scenario: BIP39 seed derivation produces consistent master key
|
||||
|
||||
- **WHEN** `deriveMasterKeyFromMnemonic` is called with a valid mnemonic and no passphrase
|
||||
- **THEN** the result is a 32-byte key that is deterministic for that mnemonic
|
||||
|
||||
#### Scenario: Passphrase changes the derived master key
|
||||
|
||||
- **WHEN** `deriveMasterKeyFromMnemonic` is called with the same mnemonic but different passphrases
|
||||
- **THEN** the resulting master keys are different
|
||||
|
||||
**Verified by:** `src/argon2id/argon2id.test.ts::"derives a 32-byte key with default params"` AND `src/argon2id/argon2id.test.ts::"deterministic for same inputs"` AND `src/argon2id/argon2id.test.ts::"different salts produce different keys"` AND `src/argon2id/argon2id.test.ts::"rejects memory below 65536 KB"` AND `src/argon2id/argon2id.test.ts::"rejects iterations below 3"` AND `src/hd-key/hd-key.test.ts::"derives deterministic master key from mnemonic"` AND `src/hd-key/hd-key.test.ts::"passphrase changes derived key"` AND `src/hd-key/hd-key.test.ts::"rejects invalid mnemonic"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Two-Secret Key Derivation (2SKD)
|
||||
|
||||
The package SHALL export `deriveAuk(password: string, secretKey: string, email: string, salt: Uint8Array, params: Argon2idParams, options?: { clock?: Clock; rng?: Rng }): Promise<Uint8Array>` which implements 2SKD: the Account Unlock Key (AUK) is derived from the user's password AND their Secret Key (a high-entropy device-bound string). The derivation SHALL mix the password, the Secret Key, and the account-specific email into the Argon2id password input so that compromise of either factor alone does not reveal the AUK. The output MUST be 32 bytes. The exact combinator is: compute `combined = HMAC-SHA256(secretKey, password + "\0" + email)` then run `Argon2id(combined, salt, params)`.
|
||||
|
||||
#### Scenario: Same inputs produce the same AUK
|
||||
|
||||
- **WHEN** `deriveAuk` is called with identical password, secretKey, email, salt, and params
|
||||
- **THEN** the resulting 32-byte AUK is identical across calls
|
||||
|
||||
#### Scenario: Different password produces different AUK
|
||||
|
||||
- **WHEN** `deriveAuk` is called with the same secretKey and email but a different password
|
||||
- **THEN** the resulting AUK is different
|
||||
|
||||
#### Scenario: Different Secret Key produces different AUK
|
||||
|
||||
- **WHEN** `deriveAuk` is called with the same password and email but a different Secret Key
|
||||
- **THEN** the resulting AUK is different
|
||||
|
||||
#### Scenario: Different email produces different AUK
|
||||
|
||||
- **WHEN** `deriveAuk` is called with the same password and secretKey but a different email
|
||||
- **THEN** the resulting AUK is different
|
||||
|
||||
#### Scenario: Injectable clock and RNG are accepted
|
||||
|
||||
- **WHEN** `deriveAuk` is called with `options.clock` and `options.rng`
|
||||
- **THEN** the derivation succeeds and the options are forwarded to telemetry and entropy paths respectively
|
||||
|
||||
**Verified by:** `src/twoskd/twoskd.test.ts::"deterministic AUK for same inputs"` AND `src/twoskd/twoskd.test.ts::"different password yields different AUK"` AND `src/twoskd/twoskd.test.ts::"different secret key yields different AUK"` AND `src/twoskd/twoskd.test.ts::"different email yields different AUK"` AND `src/twoskd/twoskd.test.ts::"rejects empty password"` AND `src/twoskd/twoskd.test.ts::"rejects empty secret key"` AND `src/twoskd/twoskd.test.ts::"accepts injectable clock and rng"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Key hierarchy — AUK to personal keyset to vault key to item key
|
||||
|
||||
The package SHALL export `derivePersonalKeyset(auk: Uint8Array): PersonalKeyset` containing an encrypt key, a sign key, and a key-exchange key, all derived via HKDF-SHA256 from the AUK with distinct context labels. `deriveVaultKey(personalKeyset: PersonalKeyset, vaultId: string): VaultKey` SHALL derive a vault-specific key via HKDF using the vault UUID as salt/info. `deriveItemKey(vaultKey: VaultKey, itemId: string): ItemKey` SHALL derive a per-item key via HKDF using the item UUID. All derived keys MUST be 32-byte `Uint8Array` instances. The hierarchy ensures that vault rotation or item deletion only requires re-deriving the affected branch.
|
||||
|
||||
#### Scenario: Personal keyset derives three distinct keys from AUK
|
||||
|
||||
- **WHEN** `derivePersonalKeyset` is called with a 32-byte AUK
|
||||
- **THEN** the result contains `encryptKey`, `signKey`, and `kxKey` that are pairwise different 32-byte arrays
|
||||
|
||||
#### Scenario: Same AUK produces the same personal keyset
|
||||
|
||||
- **WHEN** `derivePersonalKeyset` is called twice with the same AUK
|
||||
- **THEN** both results contain identical key material
|
||||
|
||||
#### Scenario: Vault key is deterministic per vaultId
|
||||
|
||||
- **WHEN** `deriveVaultKey` is called with the same personalKeyset and vaultId
|
||||
- **THEN** the resulting vault key is identical
|
||||
|
||||
#### Scenario: Different vaultIds produce different vault keys
|
||||
|
||||
- **WHEN** `deriveVaultKey` is called with the same personalKeyset but different vaultIds
|
||||
- **THEN** the resulting vault keys are different
|
||||
|
||||
#### Scenario: Item key is deterministic per itemId
|
||||
|
||||
- **WHEN** `deriveItemKey` is called with the same vaultKey and itemId
|
||||
- **THEN** the resulting item key is identical
|
||||
|
||||
#### Scenario: Different itemIds produce different item keys
|
||||
|
||||
- **WHEN** `deriveItemKey` is called with the same vaultKey but different itemIds
|
||||
- **THEN** the resulting item keys are different
|
||||
|
||||
**Verified by:** `src/key-hierarchy/key-hierarchy.test.ts::"derives three distinct keys in personal keyset"` AND `src/key-hierarchy/key-hierarchy.test.ts::"deterministic personal keyset for same AUK"` AND `src/key-hierarchy/key-hierarchy.test.ts::"deterministic vault key for same vaultId"` AND `src/key-hierarchy/key-hierarchy.test.ts::"different vaultId yields different vault key"` AND `src/key-hierarchy/key-hierarchy.test.ts::"deterministic item key for same itemId"` AND `src/key-hierarchy/key-hierarchy.test.ts::"different itemId yields different item key"` AND `src/key-hierarchy/key-hierarchy.test.ts::"rejects non-32-byte AUK"` AND `src/key-hierarchy/key-hierarchy.test.ts::"rejects empty vaultId"` AND `src/key-hierarchy/key-hierarchy.test.ts::"rejects empty itemId"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: SRP-6a client integration
|
||||
|
||||
The package SHALL export `SrpClient` implementing the SRP-6a protocol for zero-knowledge password authentication. The constructor SHALL accept an optional options bag with `clock?: Clock` and `rng?: Rng`. `SrpClient` MUST expose `generateSalt(): Uint8Array`, `derivePrivateKey(email: string, password: string, salt: Uint8Array): Uint8Array`, `deriveVerifier(privateKey: Uint8Array): Uint8Array`, `generateEphemeralKeypair(): EphemeralKeypair`, `deriveSessionKey(clientEphemeral: Uint8Array, serverEphemeral: Uint8Array, privateKey: Uint8Array, salt: Uint8Array): SessionKeyResult`, and `verifyServerProof(clientProof: Uint8Array, serverProof: Uint8Array, sessionKey: Uint8Array): boolean`. The implementation SHALL use the group's safe prime parameters (2048-bit minimum) and SHA-256 as the one-way function per RFC 5054. Bad server proof MUST reject authentication with `CryptoVaultError` code `CRYPTOVAULT.SRP.AUTHENTICATION_FAILED`.
|
||||
|
||||
#### Scenario: Full SRP-6a handshake produces matching session keys
|
||||
|
||||
- **WHEN** client and server both run SRP-6a with the same email, password, and salt
|
||||
- **THEN** both sides derive the same session key AND the client successfully verifies the server proof
|
||||
|
||||
#### Scenario: Wrong password yields different session key
|
||||
|
||||
- **WHEN** the client uses a different password than the one registered for the salt/verifier
|
||||
- **THEN** the derived session key differs from the server's AND `verifyServerProof` returns false
|
||||
|
||||
#### Scenario: Bad server proof is rejected
|
||||
|
||||
- **WHEN** `verifyServerProof` is called with a tampered server proof
|
||||
- **THEN** it returns false AND throws `CryptoVaultError` with code `CRYPTOVAULT.SRP.AUTHENTICATION_FAILED`
|
||||
|
||||
#### Scenario: Verifier is deterministic for identical inputs
|
||||
|
||||
- **WHEN** `deriveVerifier` is called twice with the same private key
|
||||
- **THEN** both outputs are identical
|
||||
|
||||
#### Scenario: Injectable RNG produces deterministic salt
|
||||
|
||||
- **WHEN** `generateSalt` is called with a test RNG passed to the constructor
|
||||
- **THEN** the salt is deterministic for that RNG
|
||||
|
||||
**Verified by:** `src/srp/srp-client.test.ts::"full handshake produces matching session keys"` AND `src/srp/srp-client.test.ts::"wrong password yields authentication failure"` AND `src/srp/srp-client.test.ts::"tampered server proof is rejected"` AND `src/srp/srp-client.test.ts::"verifier is deterministic"` AND `src/srp/srp-client.test.ts::"salt is unique per generation"` AND `src/srp/srp-client.test.ts::"rejects weak group parameters"` AND `src/srp/srp-client.test.ts::"injectable rng produces deterministic salt"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Secure memory and zeroization
|
||||
|
||||
The package SHALL hold all sensitive key material (`Auk`, `PersonalKeyset`, `VaultKey`, `ItemKey`, `SessionKey`) in `SecureBuffer` instances backed by `Uint8Array` with eager zeroization via `fill(0)` on close; memory locking via `mlock` is deferred to a future native binding. `SecureBuffer` SHALL accept constructor options `clock?: Clock` and `rng?: Rng`. The package SHALL provide `zeroizeAll(): void` which overwrites every live buffer with zeros then releases the reference. No key material SHALL be exposed as a plain string or unprotected `Uint8Array` outside the secure envelope. After `zeroizeAll()`, any attempt to use a previously derived key MUST throw `CryptoVaultError` with code `CRYPTOVAULT.SECURE.ZEROIZED`.
|
||||
|
||||
#### Scenario: Zeroization overwrites all live key material
|
||||
|
||||
- **WHEN** `zeroizeAll` is called after deriving AUK, personal keyset, vault key, and item key
|
||||
- **THEN** every underlying buffer contains only zero bytes
|
||||
|
||||
#### Scenario: Using a zeroized key throws
|
||||
|
||||
- **WHEN** a key is accessed after `zeroizeAll` has been called
|
||||
- **THEN** a `CryptoVaultError` with code `CRYPTOVAULT.SECURE.ZEROIZED` is thrown
|
||||
|
||||
#### Scenario: SecureBuffer does not leak via toString
|
||||
|
||||
- **WHEN** a `SecureBuffer` containing key material is coerced to string
|
||||
- **THEN** the result is `"[SecureBuffer]"` or equivalent — never the raw bytes
|
||||
|
||||
#### Scenario: Injectable clock and RNG are accepted
|
||||
|
||||
- **WHEN** a `SecureBuffer` is constructed with `clock` and `rng` options
|
||||
- **THEN** construction succeeds and the options are available for allocation telemetry and secure randomness
|
||||
|
||||
**Verified by:** `src/secure/secure-buffer.test.ts::"zeroizeAll overwrites all live buffers"` AND `src/secure/secure-buffer.test.ts::"accessing zeroized key throws"` AND `src/secure/secure-buffer.test.ts::"toString does not leak bytes"` AND `src/secure/secure-buffer.test.ts::"JSON.stringify does not leak bytes"` AND `src/secure/secure-buffer.test.ts::"clone creates independent copy"` AND `src/secure/secure-buffer.test.ts::"slice does not share underlying buffer"` AND `src/secure/secure-buffer.test.ts::"accepts injectable clock and rng"`
|
||||
|
||||
## Error codes
|
||||
|
||||
`CRYPTOVAULT.BIP39.INVALID_ENTROPY_LENGTH`, `CRYPTOVAULT.BIP39.INVALID_MNEMONIC`, `CRYPTOVAULT.ARGON2ID.UNSAFE_PARAMETERS`, `CRYPTOVAULT.TWOSKD.MISSING_PASSWORD`, `CRYPTOVAULT.TWOSKD.MISSING_SECRET_KEY`, `CRYPTOVAULT.HIERARCHY.INVALID_AUK_LENGTH`, `CRYPTOVAULT.HIERARCHY.EMPTY_VAULT_ID`, `CRYPTOVAULT.HIERARCHY.EMPTY_ITEM_ID`, `CRYPTOVAULT.SRP.AUTHENTICATION_FAILED`, `CRYPTOVAULT.SRP.WEAK_PARAMETERS`, `CRYPTOVAULT.SECURE.ZEROIZED`
|
||||
|
||||
## Version
|
||||
|
||||
Published: `0.1.0` (proposed). Spec delta: `crypto-vault`.
|
||||
84
openspec/archive/2026-05-03-crypto-vault/tasks.md
Normal file
84
openspec/archive/2026-05-03-crypto-vault/tasks.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Tasks — crypto-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 and Section 8 for vault architecture context
|
||||
- [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/crypto-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/crypto/crypto-vault` with `package.json`, `tsconfig.json`, `vitest.config.ts`
|
||||
- [x] 2.2 Implement BIP39 module: `generateMnemonic`, `entropyToMnemonic`, `mnemonicToEntropy`, English word list
|
||||
- [x] 2.3 Implement Argon2id module: `deriveKeyWithArgon2id`, `Argon2idParams`, OWASP minimum enforcement
|
||||
- [x] 2.4 Implement HD key module: `deriveMasterKeyFromMnemonic`, BIP39 seed derivation, BIP32-like path
|
||||
- [x] 2.5 Implement 2SKD module: `deriveAuk`, password + Secret Key + email mixing
|
||||
- [x] 2.6 Implement key hierarchy module: `derivePersonalKeyset`, `deriveVaultKey`, `deriveItemKey`
|
||||
- [x] 2.7 Implement SRP-6a client module: `SrpClient`, safe-prime group parameters, SHA-256 per RFC 5054
|
||||
- [x] 2.8 Implement secure buffer module: `SecureBuffer`, `zeroizeAll`, toString masking, clone isolation
|
||||
- [x] 2.9 Implement error module: `CryptoVaultError`, structured error codes
|
||||
- [x] 2.10 Add `Clock` / `Rng` injection to `generateMnemonic`, `deriveAuk`, `SrpClient`, and `SecureBuffer`
|
||||
- [x] 2.11 Export `OpaqueKeyHandle` for `vault` layer consumption
|
||||
- [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/bip39/bip39.test.ts` covering every BIP39 scenario
|
||||
- [x] 3.2 Unit tests at `src/argon2id/argon2id.test.ts` covering derivation and parameter safety
|
||||
- [x] 3.3 Unit tests at `src/hd-key/hd-key.test.ts` covering master key derivation
|
||||
- [x] 3.4 Unit tests at `src/twoskd/twoskd.test.ts` covering 2SKD determinism and factor separation
|
||||
- [x] 3.5 Unit tests at `src/key-hierarchy/key-hierarchy.test.ts` covering hierarchical derivation
|
||||
- [x] 3.6 Unit tests at `src/srp/srp-client.test.ts` covering full handshake and failure modes
|
||||
- [x] 3.7 Unit tests at `src/secure/secure-buffer.test.ts` covering zeroization and masking
|
||||
- [x] 3.8 Spec-conformance tests at `src/spec-conformance.test.ts` pinning type-level / strict-mode scenarios
|
||||
- [x] 3.9 Property/fuzz tests for BIP39 round-trip and Argon2id determinism
|
||||
- [x] 3.10 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async / Argon2id-heavy)
|
||||
|
||||
## 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
|
||||
|
||||
## 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/crypto-vault` — green
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/crypto-vault` — 100/100/100/100
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/crypto-vault` — exit 0, no `error` findings
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/crypto-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/crypto-vault/spec.md`
|
||||
- [x] 7.2 Move `openspec/changes/crypto-vault/` → `openspec/archive/2026-05-03-crypto-vault/`
|
||||
- [x] 7.3 Refresh `MANIFEST.md` if package set or deps changed (orchestrator §13b)
|
||||
|
||||
## Audit Note (2026-05-02)
|
||||
|
||||
DEFERRED in the genesis spec-cleanup pass on 2026-05-02. Rationale:
|
||||
|
||||
- Depends on the (still-pending-migration) @suaveplan/vault package per the dep graph in the proposal. Until vault is migrated, this change cannot meaningfully ship. 0/46 done.
|
||||
|
||||
## Audit Note (2026-05-03)
|
||||
|
||||
UNBLOCKED — the change ships independently of `@suaveplan/vault`. `crypto-vault`
|
||||
is the foundational primitive layer; `@suaveplan/vault` (when later migrated)
|
||||
will consume `OpaqueKeyHandle`, `SecureBuffer`, and the hierarchy derivations
|
||||
shipped here. All 46 task boxes ticked; package shipped at v0.0.0 with
|
||||
100% / 100% / 100% / 100% coverage.
|
||||
Reference in New Issue
Block a user