16 KiB
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
entropyToMnemonicthenmnemonicToEntropy - THEN the resulting bytes equal the input byte-for-byte
Scenario: Invalid entropy length is rejected
- WHEN
entropyToMnemonicis called with aUint8Arraywhose length is not 16 or 32 - THEN a
CryptoVaultErrorwith codeCRYPTOVAULT.BIP39.INVALID_ENTROPY_LENGTHis thrown
Scenario: Invalid mnemonic is rejected
- WHEN
mnemonicToEntropyis called with a malformed mnemonic (bad checksum, unknown word, or wrong word count) - THEN a
CryptoVaultErrorwith codeCRYPTOVAULT.BIP39.INVALID_MNEMONICis thrown
Scenario: Injectable RNG determines entropy
- WHEN
generateMnemonicis called with a test RNG inoptions.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
deriveKeyWithArgon2idis called with a password, a unique 16-byte salt, and default parameters - THEN the result is a 32-byte
Uint8ArrayAND the same inputs always produce the same output
Scenario: Different salts produce different keys
- WHEN
deriveKeyWithArgon2idis called with the same password but different salts - THEN the resulting keys are different
Scenario: Unsafe parameters are rejected
- WHEN
deriveKeyWithArgon2idis called withmemoryKB < 65536oriterations < 3 - THEN a
CryptoVaultErrorwith codeCRYPTOVAULT.ARGON2ID.UNSAFE_PARAMETERSis thrown
Scenario: BIP39 seed derivation produces consistent master key
- WHEN
deriveMasterKeyFromMnemonicis 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
deriveMasterKeyFromMnemonicis 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
deriveAukis 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
deriveAukis 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
deriveAukis 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
deriveAukis 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
deriveAukis called withoptions.clockandoptions.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
derivePersonalKeysetis called with a 32-byte AUK - THEN the result contains
encryptKey,signKey, andkxKeythat are pairwise different 32-byte arrays
Scenario: Same AUK produces the same personal keyset
- WHEN
derivePersonalKeysetis called twice with the same AUK - THEN both results contain identical key material
Scenario: Vault key is deterministic per vaultId
- WHEN
deriveVaultKeyis called with the same personalKeyset and vaultId - THEN the resulting vault key is identical
Scenario: Different vaultIds produce different vault keys
- WHEN
deriveVaultKeyis called with the same personalKeyset but different vaultIds - THEN the resulting vault keys are different
Scenario: Item key is deterministic per itemId
- WHEN
deriveItemKeyis called with the same vaultKey and itemId - THEN the resulting item key is identical
Scenario: Different itemIds produce different item keys
- WHEN
deriveItemKeyis 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
verifyServerProofreturns false
Scenario: Bad server proof is rejected
- WHEN
verifyServerProofis called with a tampered server proof - THEN it returns false AND throws
CryptoVaultErrorwith codeCRYPTOVAULT.SRP.AUTHENTICATION_FAILED
Scenario: Verifier is deterministic for identical inputs
- WHEN
deriveVerifieris called twice with the same private key - THEN both outputs are identical
Scenario: Injectable RNG produces deterministic salt
- WHEN
generateSaltis 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
zeroizeAllis 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
zeroizeAllhas been called - THEN a
CryptoVaultErrorwith codeCRYPTOVAULT.SECURE.ZEROIZEDis thrown
Scenario: SecureBuffer does not leak via toString
- WHEN a
SecureBuffercontaining 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
SecureBufferis constructed withclockandrngoptions - 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.