Files
suaveplan/openspec/specs/crypto-vault/spec.md
2026-05-27 23:29:53 +01:00

21 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 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"

ADDED Requirements (extend-crypto-vault-orchestration — 2026-05-18)

Requirement: createRootKeys produces a keychain + encrypted vault + signed public-key bytes

The package SHALL export async createRootKeys(password: string, options?: CreateRootKeysOptions): Promise<RootKeysResult> that generates a fresh root Keychain via @suaveplan/crypto generateKeychain, derives an Argon2id KEK from password (defaults from DEFAULT_ARGON2ID_PARAMS), AEAD-encrypts the canonical-encoded keychain bytes into vault bytes, extracts the public keychain via extractPublicKeys, and returns { keychain, vaultBytes, publicKeyBytes, publicKeys }. On success, openVault(vaultBytes, password) SHALL recover an equivalent keychain.

Verified by: src/orchestration/orchestration.test.ts::"createRootKeys output round-trips through openVault"


Requirement: createServerKeys derives a versioned child keychain from root master

The package SHALL export async createServerKeys(rootKeys, serverPassword, options?): Promise<ServerKeysResult> that derives a server Keychain from rootKeys.keychain.master.private, encrypts under serverPassword, and returns { keychain, vaultBytes, publicKeyBytes, publicKeys, version }. Default version SHALL be 1. Re-running with same (rootKeys, password, version) SHALL produce identical sign-slot public keys (deterministic derivation).

Verified by: src/orchestration/orchestration.test.ts::"createServerKeys deterministically derives from root master"


Requirement: rollServerKeys bumps the version deterministically

The package SHALL export async rollServerKeys(rootKeys, currentVersion, serverPassword, options?): Promise<ServerKeysResult> returning a result at version = currentVersion + 1. currentVersion < 0 SHALL raise CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.INVALID_VERSION).

Verified by: src/orchestration/orchestration.test.ts::"rollServerKeys increments version" AND src/orchestration/orchestration.test.ts::"rollServerKeys rejects negative currentVersion"


Requirement: openVault and packVault are inverses

The package SHALL export async openVault(vaultBytes, password) and async packVault(keychain, password, options?). openVault(packVault(k, p), p) SHALL produce a keychain with the same canonical-encoded public bytes as k. Wrong password or tampered bytes SHALL raise CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.DECRYPT_FAILED).

Verified by: src/orchestration/orchestration.test.ts::"openVault and packVault round-trip" AND src/orchestration/orchestration.test.ts::"openVault rejects wrong password" AND src/orchestration/orchestration.test.ts::"openVault rejects tampered bytes"


Requirement: viewPublicKeys decodes a signed public-key file with optional verification

The package SHALL export async viewPublicKeys(publicKeyBytes, optionalSignKey?): Promise<PublicKeychain> that decodes via decodePublicKeychain. When optionalSignKey is provided, the embedded signature SHALL be verified; mismatch SHALL raise CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID).

Verified by: src/orchestration/orchestration.test.ts::"viewPublicKeys verifies signature when sign key supplied" AND src/orchestration/orchestration.test.ts::"viewPublicKeys rejects wrong sign key"


Requirement: Storage helpers wrap AbstractStorage idempotently

The package SHALL export async saveVaultToStorage(storage, key, vaultBytes), async savePublicKeysToStorage(storage, key, publicKeyBytes), and async getDataFromStorage(storage, key). getDataFromStorage SHALL raise CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.STORAGE_KEY_NOT_FOUND) when the key is absent. Save helpers SHALL be idempotent.

Verified by: src/orchestration/orchestration.test.ts::"saveVaultToStorage + getDataFromStorage round-trip" AND src/orchestration/orchestration.test.ts::"getDataFromStorage raises CRYPTO_VAULT_STORAGE_KEY_NOT_FOUND on missing key" AND src/orchestration/orchestration.test.ts::"saveVaultToStorage is idempotent"


Requirement: New error codes registered without breaking existing ones

The package SHALL extend CRYPTO_VAULT_CODES with ORCHESTRATION.DECRYPT_FAILED, ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID, ORCHESTRATION.STORAGE_KEY_NOT_FOUND, ORCHESTRATION.INVALID_VERSION. Every existing code remains registered. Every new code SHALL be raised by at least one production call site.

Verified by: src/orchestration/orchestration.spec-conformance.test.ts::"every new orchestration code is thrown from a production path"


Requirement: Subpath export, sibling docs, no breaking change to existing surface

The package SHALL declare ./orchestration/orchestration in package.json exports. src/orchestration/orchestration.ts SHALL have a sibling .md of ≥200 words. Existing exports SHALL be unchanged.

Verified by: tests/dep-graph.test.ts::"orchestration subpath export is reachable" AND tests/dep-graph.test.ts::"orchestration.ts has a sibling .md"

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, CRYPTOVAULT.ORCHESTRATION.DECRYPT_FAILED, CRYPTOVAULT.ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID, CRYPTOVAULT.ORCHESTRATION.STORAGE_KEY_NOT_FOUND, CRYPTOVAULT.ORCHESTRATION.INVALID_VERSION

Version

Published: 0.1.0. Spec deltas folded: crypto-vault, extend-crypto-vault-orchestration (2026-05-18).