Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

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