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