Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,153 @@
# @suaveplan/crypto-vault - Cryptographic foundation for the Vaulted password-manager ecosystem: BIP39 mnemonics, Argon2id-based HD key derivation, two-secret key derivation (2SKD), SRP-6a authentication, hierarchical key tree (AUK -> personal keyset -> vault key -> item key), and SecureBuffer with eager zeroization.
## Summary
Cryptographic foundation for the Vaulted password-manager ecosystem.
Provides BIP39 mnemonic generation, OWASP-grade Argon2id derivation,
two-secret key derivation (2SKD: password + Secret Key + email → AUK),
SRP-6a authentication primitives, and a hierarchical key tree
(AUK → personal keyset → vault key → item key) — all held in `SecureBuffer`
instances that zeroize on lock.
<!-- begin:auto-docs -->
## Documentation tree
- [argon2id](./src/argon2id/argon2id.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [argon2id](./src/argon2id/argon2id.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bip39](./src/bip39/bip39.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bip39](./src/bip39/bip39.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [codes](./src/errors/codes.md) — `codes.ts` is the canonical, frozen registry of every error code emitted by `@suaveplan/crypto-vault`. The registry exi…
- [crypto-vault-error](./src/errors/crypto-vault-error.md) — `CryptoVaultError` is the umbrella error class for `@suaveplan/crypto-vault`. Every failure mode in the package — inval…
- [handle](./src/handle/opaque-handle.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [opaque-handle](./src/handle/opaque-handle.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [hd-key](./src/hd-key/hd-key.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [hd-key](./src/hd-key/hd-key.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [key-hierarchy](./src/key-hierarchy/key-hierarchy.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [key-hierarchy](./src/key-hierarchy/key-hierarchy.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [orchestration](./src/orchestration/orchestration.md) — High-level orchestration helpers that bundle keychain generation, password-AEAD encryption, signed public-key file prod…
- [internals](./src/orchestration/internals.md) — Internal building blocks for the orchestration surface.
- [orchestration](./src/orchestration/orchestration.md) — High-level orchestration helpers that bundle keychain generation, password-AEAD encryption, signed public-key file prod…
- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [secure](./src/secure/secure-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [secure-buffer](./src/secure/secure-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [srp-client](./src/srp/srp-client.md) — `srp-client.ts` implements the client side of the SRP-6a zero-knowledge password-authenticated key exchange per RFC 505…
- [srp-group](./src/srp/srp-group.md) — `srp-group.ts` exports the canonical 2048-bit MODP group from RFC 5054 Appendix A as `bigint` constants. The SRP-6a cli…
- [twoskd](./src/twoskd/twoskd.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [twoskd](./src/twoskd/twoskd.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [argon2id](./src/argon2id/argon2id.md) — <!-- begin:auto-docs --> _(no submodules)_- [bip39](./src/bip39/bip39.md) — <!-- begin:auto-docs --> _(no submodules)_- [bip39](./src/bip39/bip39.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `codes.ts` is the canonical, frozen registry of every error code emitted by `@suaveplan/crypto-vault`. The registry exi…
- [crypto-vault-error](./src/errors/crypto-vault-error.md) — `CryptoVaultError` is the umbrella error class for `@suaveplan/crypto-vault`. Every failure mode in the package — inval…
- [handle](./src/handle/opaque-handle.md) — <!-- begin:auto-docs --> _(no submodules)_- [opaque-handle](./src/handle/opaque-handle.md) — <!-- begin:auto-docs --> _(no submodules)_- [hd-key](./src/hd-key/hd-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [hd-key](./src/hd-key/hd-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-hierarchy](./src/key-hierarchy/key-hierarchy.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-hierarchy](./src/key-hierarchy/key-hierarchy.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [secure](./src/secure/secure-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [secure-buffer](./src/secure/secure-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [srp-client](./src/srp/srp-client.md) — `srp-client.ts` implements the client side of the SRP-6a zero-knowledge password-authenticated key exchange per RFC 505…
- [srp-group](./src/srp/srp-group.md) — `srp-group.ts` exports the canonical 2048-bit MODP group from RFC 5054 Appendix A as `bigint` constants. The SRP-6a cli…
- [twoskd](./src/twoskd/twoskd.md) — <!-- begin:auto-docs --> _(no submodules)_- [twoskd](./src/twoskd/twoskd.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [argon2id](./src/argon2id/argon2id.md) — <!-- begin:auto-docs --> _(no submodules)_- [bip39](./src/bip39/bip39.md) — <!-- begin:auto-docs --> _(no submodules)_- [bip39](./src/bip39/bip39.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `codes.ts` is the canonical, frozen registry of every error code emitted by `@suaveplan/crypto-vault`. The registry exi…
- [crypto-vault-error](./src/errors/crypto-vault-error.md) — `CryptoVaultError` is the umbrella error class for `@suaveplan/crypto-vault`. Every failure mode in the package — inval…
- [handle](./src/handle/opaque-handle.md) — <!-- begin:auto-docs --> _(no submodules)_- [opaque-handle](./src/handle/opaque-handle.md) — <!-- begin:auto-docs --> _(no submodules)_- [hd-key](./src/hd-key/hd-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [hd-key](./src/hd-key/hd-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-hierarchy](./src/key-hierarchy/key-hierarchy.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-hierarchy](./src/key-hierarchy/key-hierarchy.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [secure](./src/secure/secure-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [secure-buffer](./src/secure/secure-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [srp-client](./src/srp/srp-client.md) — `srp-client.ts` implements the client side of the SRP-6a zero-knowledge password-authenticated key exchange per RFC 505…
- [srp-group](./src/srp/srp-group.md) — `srp-group.ts` exports the canonical 2048-bit MODP group from RFC 5054 Appendix A as `bigint` constants. The SRP-6a cli…
- [twoskd](./src/twoskd/twoskd.md) — <!-- begin:auto-docs --> _(no submodules)_- [twoskd](./src/twoskd/twoskd.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [argon2id](./src/argon2id/argon2id.md) — <!-- begin:auto-docs --> _(no submodules)_- [bip39](./src/bip39/bip39.md) — <!-- begin:auto-docs --> _(no submodules)_- [bip39](./src/bip39/bip39.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `codes.ts` is the canonical, frozen registry of every error code emitted by `@suaveplan/crypto-vault`. The registry exi…
- [crypto-vault-error](./src/errors/crypto-vault-error.md) — `CryptoVaultError` is the umbrella error class for `@suaveplan/crypto-vault`. Every failure mode in the package — inval…
- [handle](./src/handle/opaque-handle.md) — <!-- begin:auto-docs --> _(no submodules)_- [opaque-handle](./src/handle/opaque-handle.md) — <!-- begin:auto-docs --> _(no submodules)_- [hd-key](./src/hd-key/hd-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [hd-key](./src/hd-key/hd-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-hierarchy](./src/key-hierarchy/key-hierarchy.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-hierarchy](./src/key-hierarchy/key-hierarchy.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [secure](./src/secure/secure-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [secure-buffer](./src/secure/secure-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [srp-client](./src/srp/srp-client.md) — `srp-client.ts` implements the client side of the SRP-6a zero-knowledge password-authenticated key exchange per RFC 505…
- [srp-group](./src/srp/srp-group.md) — `srp-group.ts` exports the canonical 2048-bit MODP group from RFC 5054 Appendix A as `bigint` constants. The SRP-6a cli…
- [twoskd](./src/twoskd/twoskd.md) — <!-- begin:auto-docs --> _(no submodules)_- [twoskd](./src/twoskd/twoskd.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [argon2id](./src/argon2id/argon2id.md) — <!-- begin:auto-docs --> _(no submodules)_- [bip39](./src/bip39/bip39.md) — <!-- begin:auto-docs --> _(no submodules)_ - [bip39](./src/bip39/bip39.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `codes.ts` is the canonical, frozen registry of every error code emitted by `@suaveplan/crypto-vault`. The registry exi…
- [crypto-vault-error](./src/errors/crypto-vault-error.md) — `CryptoVaultError` is the umbrella error class for `@suaveplan/crypto-vault`. Every failure mode in the package — inval…
- [handle](./src/handle/opaque-handle.md) — <!-- begin:auto-docs --> _(no submodules)_ - [opaque-handle](./src/handle/opaque-handle.md) — <!-- begin:auto-docs --> _(no submodules)_- [hd-key](./src/hd-key/hd-key.md) — <!-- begin:auto-docs --> _(no submodules)_ - [hd-key](./src/hd-key/hd-key.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-hierarchy](./src/key-hierarchy/key-hierarchy.md) — <!-- begin:auto-docs --> _(no submodules)_ - [key-hierarchy](./src/key-hierarchy/key-hierarchy.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instruments](./src/otel/instruments.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [secure](./src/secure/secure-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [secure-buffer](./src/secure/secure-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [srp-client](./src/srp/srp-client.md) — `srp-client.ts` implements the client side of the SRP-6a zero-knowledge password-authenticated key exchange per RFC 505…
- [srp-group](./src/srp/srp-group.md) — `srp-group.ts` exports the canonical 2048-bit MODP group from RFC 5054 Appendix A as `bigint` constants. The SRP-6a cli…
- [twoskd](./src/twoskd/twoskd.md) — <!-- begin:auto-docs --> _(no submodules)_ - [twoskd](./src/twoskd/twoskd.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/crypto-vault
```
## Quick start
```ts
import {
DEFAULT_ARGON2ID_PARAMS,
deriveAuk,
deriveItemKey,
derivePersonalKeyset,
deriveVaultKey,
generateMnemonic,
SecureBuffer,
SrpClient,
zeroizeAll,
} from "@suaveplan/crypto-vault";
// 1. Generate a backup phrase.
const mnemonic = generateMnemonic(128);
// 2. Derive the AUK from the user's secrets.
const salt = crypto.getRandomValues(new Uint8Array(16));
const auk = await deriveAuk(
"correct horse battery staple",
"A3-7PMW82-XJZS9-NQDLV-EYKBR",
"alice@example.com",
salt,
DEFAULT_ARGON2ID_PARAMS
);
// 3. Fan out into the hierarchy.
const personal = derivePersonalKeyset(auk);
const vaultKey = deriveVaultKey(personal, "vault-1");
const itemKey = deriveItemKey(vaultKey, "login-bank");
// 4. Authenticate via SRP-6a.
const client = new SrpClient();
const x = client.derivePrivateKey("alice@example.com", "pw", salt);
const v = client.deriveVerifier(x);
// 5. Lock — sweep every live SecureBuffer.
const wrapped = new SecureBuffer(itemKey);
zeroizeAll();
console.log(wrapped.isClosed); // true
```
## Modules
- [`bip39/`](./src/bip39/bip39.md) — BIP39 mnemonic generation, conversion,
and validation (English word list).
- [`argon2id/`](./src/argon2id/argon2id.md) — OWASP-safe Argon2id derivation
wrapper around `@suaveplan/crypto-hash`.
- [`hd-key/`](./src/hd-key/hd-key.md) — BIP39 seed and BIP32-shaped HD key
derivation.
- [`twoskd/`](./src/twoskd/twoskd.md) — Two-Secret Key Derivation
(`password ⊕ secretKey ⊕ email → AUK`).
- [`key-hierarchy/`](./src/key-hierarchy/key-hierarchy.md) — HKDF-driven
AUK → personal keyset → vault key → item key tree.
- [`srp/srp-client`](./src/srp/srp-client.md) — RFC 5054 SRP-6a client.
- [`srp/srp-group`](./src/srp/srp-group.md) — RFC 5054 2048-bit MODP group
parameters.
- [`secure/`](./src/secure/secure-buffer.md) — `SecureBuffer` envelope with
eager zeroization, clone isolation, and global `zeroizeAll()`.
- [`handle/`](./src/handle/opaque-handle.md) — `OpaqueKeyHandle` for the
vault layer to hold key references without seeing raw bytes.
- [`errors/codes`](./src/errors/codes.md) — frozen error-code registry.
- [`errors/crypto-vault-error`](./src/errors/crypto-vault-error.md) —
`CryptoVaultError` umbrella class.
- [`schemas/`](./src/schemas/schemas.md) — Zod schemas for every public
shape (`Argon2idParams`, `PersonalKeyset`, etc.).
- [`types/`](./src/types/types.md) — shared types (`Argon2idParams`,
`Auk`, `PersonalKeyset`, …).
- [`otel/`](./src/otel/instruments.md) — lazy OpenTelemetry instrument set.
## Security model
- Plaintext key material lives only inside `SecureBuffer` instances; the
buffers' `toString` and `toJSON` mask the bytes.
- `zeroizeAll()` walks every reachable `SecureBuffer` via a `WeakRef`
registry and overwrites its bytes with zeros.
- Argon2id parameters below the OWASP minimums (memoryKB ≥ 64 MiB,
iterations ≥ 3, parallelism ≥ 1) are rejected at runtime.
- All MAC / proof comparisons are constant-time
(`@suaveplan/crypto-utils`'s `constantTimeEqual`).
- SRP-6a uses SHA-256 over the RFC 5054 2048-bit safe-prime group with
full server-proof verification.
## License
MIT

View File

@@ -0,0 +1,203 @@
{
"name": "@suaveplan/crypto-vault",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Cryptographic foundation for the Vaulted password-manager ecosystem: BIP39 mnemonics, Argon2id-based HD key derivation, two-secret key derivation (2SKD), SRP-6a authentication, hierarchical key tree (AUK -> personal keyset -> vault key -> item key), and SecureBuffer with eager zeroization.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./bip39/bip39": {
"import": {
"types": "./dist/bip39/bip39.d.ts",
"default": "./dist/bip39/bip39.js"
},
"require": {
"types": "./dist/bip39/bip39.d.ts",
"default": "./dist/bip39/bip39.js"
}
},
"./argon2id/argon2id": {
"import": {
"types": "./dist/argon2id/argon2id.d.ts",
"default": "./dist/argon2id/argon2id.js"
},
"require": {
"types": "./dist/argon2id/argon2id.d.ts",
"default": "./dist/argon2id/argon2id.js"
}
},
"./hd-key/hd-key": {
"import": {
"types": "./dist/hd-key/hd-key.d.ts",
"default": "./dist/hd-key/hd-key.js"
},
"require": {
"types": "./dist/hd-key/hd-key.d.ts",
"default": "./dist/hd-key/hd-key.js"
}
},
"./twoskd/twoskd": {
"import": {
"types": "./dist/twoskd/twoskd.d.ts",
"default": "./dist/twoskd/twoskd.js"
},
"require": {
"types": "./dist/twoskd/twoskd.d.ts",
"default": "./dist/twoskd/twoskd.js"
}
},
"./key-hierarchy/key-hierarchy": {
"import": {
"types": "./dist/key-hierarchy/key-hierarchy.d.ts",
"default": "./dist/key-hierarchy/key-hierarchy.js"
},
"require": {
"types": "./dist/key-hierarchy/key-hierarchy.d.ts",
"default": "./dist/key-hierarchy/key-hierarchy.js"
}
},
"./srp/srp-client": {
"import": {
"types": "./dist/srp/srp-client.d.ts",
"default": "./dist/srp/srp-client.js"
},
"require": {
"types": "./dist/srp/srp-client.d.ts",
"default": "./dist/srp/srp-client.js"
}
},
"./secure/secure-buffer": {
"import": {
"types": "./dist/secure/secure-buffer.d.ts",
"default": "./dist/secure/secure-buffer.js"
},
"require": {
"types": "./dist/secure/secure-buffer.d.ts",
"default": "./dist/secure/secure-buffer.js"
}
},
"./handle/opaque-handle": {
"import": {
"types": "./dist/handle/opaque-handle.d.ts",
"default": "./dist/handle/opaque-handle.js"
},
"require": {
"types": "./dist/handle/opaque-handle.d.ts",
"default": "./dist/handle/opaque-handle.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
},
"require": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
}
},
"./schemas/schemas": {
"import": {
"types": "./dist/schemas/schemas.d.ts",
"default": "./dist/schemas/schemas.js"
},
"require": {
"types": "./dist/schemas/schemas.d.ts",
"default": "./dist/schemas/schemas.js"
}
},
"./types": {
"import": {
"types": "./dist/types/types.d.ts",
"default": "./dist/types/types.js"
},
"require": {
"types": "./dist/types/types.d.ts",
"default": "./dist/types/types.js"
}
},
"./otel": {
"import": {
"types": "./dist/otel/instruments.d.ts",
"default": "./dist/otel/instruments.js"
},
"require": {
"types": "./dist/otel/instruments.d.ts",
"default": "./dist/otel/instruments.js"
}
},
"./orchestration/orchestration": {
"import": {
"types": "./dist/orchestration/orchestration.d.ts",
"default": "./dist/orchestration/orchestration.js"
},
"require": {
"types": "./dist/orchestration/orchestration.d.ts",
"default": "./dist/orchestration/orchestration.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@noble/curves": "catalog:crypto",
"@noble/hashes": "catalog:crypto",
"@opentelemetry/api": "catalog:observability",
"@scure/bip39": "catalog:crypto",
"@suaveplan/crypto": "workspace:^",
"@suaveplan/crypto-cipher": "workspace:^",
"@suaveplan/crypto-curve": "workspace:^",
"@suaveplan/crypto-hash": "workspace:^",
"@suaveplan/crypto-utils": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/storage": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,116 @@
# Argon2id wrapper
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`argon2id.ts` is the OWASP-grade Argon2id derivation entry point for
`@suaveplan/crypto-vault`. It wraps `@suaveplan/crypto-hash`'s primitive
`argon2id` with two enterprise concerns the primitive itself does not
enforce: an OWASP-safe parameter floor (memoryKB ≥ 64 MiB, iterations ≥ 3,
parallelism ≥ 1) and a hard ceiling (memoryKB ≤ 1 GiB, outputLength ≤ 4 KiB)
that prevents accidental denial-of-service from misconfigured callers.
## Features
- `assertSafeArgon2idParams(params)` — pure validator usable in tests, in
config validation, and as a precondition for any Argon2id call.
- `deriveKeyWithArgon2id(password, salt, params?, options?)` — full
derivation pipeline with telemetry recording and structured errors.
- `DEFAULT_ARGON2ID_PARAMS` — frozen `{ memoryKB: 65536, iterations: 3,
parallelism: 4, outputLength: 32 }` constant.
- `OWASP_MIN_*` and `ARGON2ID_MAX_*` exported constants for callers that
build their own parameter UI.
## Usage: Basic Example
```ts
import {
DEFAULT_ARGON2ID_PARAMS,
deriveKeyWithArgon2id,
} from "@suaveplan/crypto-vault/argon2id/argon2id";
const password = new TextEncoder().encode("correct horse battery staple");
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await deriveKeyWithArgon2id(password, salt, DEFAULT_ARGON2ID_PARAMS);
console.log(key.length); // 32
```
## Usage: Advanced Example
```ts
import {
assertSafeArgon2idParams,
deriveKeyWithArgon2id,
} from "@suaveplan/crypto-vault/argon2id/argon2id";
import type { Argon2idParams } from "@suaveplan/crypto-vault/types";
// Stronger profile for highly sensitive accounts.
const strict: Argon2idParams = {
memoryKB: 131_072, // 128 MiB
iterations: 4,
parallelism: 4,
outputLength: 64,
};
assertSafeArgon2idParams(strict); // throws if any cell breaks the bands
const password = new TextEncoder().encode("super-strong-passphrase");
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await deriveKeyWithArgon2id(password, salt, strict, {
clock: () => 0, // deterministic telemetry timestamps for tests
});
console.log(key.length); // 64
```
## API Reference
### `deriveKeyWithArgon2id(password, salt, params?, options?)`
- `password: Uint8Array` — must be non-empty.
- `salt: Uint8Array` — must be non-empty (16+ bytes recommended).
- `params: Argon2idParams = DEFAULT_ARGON2ID_PARAMS` — validated through
`assertSafeArgon2idParams`.
- `options.clock?: () => number` — wall-clock source for the duration
histogram.
Returns `Promise<Uint8Array>` of length `params.outputLength`.
Throws `CryptoVaultError`:
- `ARGON2ID.INVALID_INPUT` for empty `password` or `salt`.
- `ARGON2ID.UNSAFE_PARAMETERS` for parameters outside the OWASP / hard-cap
bands.
### `assertSafeArgon2idParams(params)`
Throws `CryptoVaultError(ARGON2ID.UNSAFE_PARAMETERS)` when any of these
conditions are violated:
- `memoryKB` integer in `[OWASP_MIN_MEMORY_KB, ARGON2ID_MAX_MEMORY_KB]`.
- `iterations` integer ≥ `OWASP_MIN_ITERATIONS`.
- `parallelism` integer ≥ `OWASP_MIN_PARALLELISM`.
- `outputLength` integer in `(0, ARGON2ID_MAX_OUTPUT_LENGTH]`.
### Constants
- `OWASP_MIN_MEMORY_KB = 65_536`
- `OWASP_MIN_ITERATIONS = 3`
- `OWASP_MIN_PARALLELISM = 1`
- `ARGON2ID_MAX_MEMORY_KB = 1_048_576`
- `ARGON2ID_MAX_OUTPUT_LENGTH = 4_096`
- `DEFAULT_ARGON2ID_PARAMS` — frozen object.
## Implementation Notes
- The underlying primitive is sync (noble's `@noble/hashes/argon2`); the
wrapper exposes `Promise<Uint8Array>` to keep the public API stable
against future native or worker-backed implementations.
- The OWASP minimums encode the November 2024 password storage cheat sheet
guidance for Argon2id (m=64MiB, t=3, p=1+).
- The hard cap on memory cost (1 GiB) and output length (4 KiB) defends
against caller bugs and denial-of-service-by-misconfiguration. A caller
that legitimately needs more should open a follow-up change rather than
bypass the guard.
- Telemetry timing comes from the injected (or default) clock, not
`performance.now()`; this keeps the histogram reproducible under tests.

View File

@@ -0,0 +1,264 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import {
ARGON2ID_MAX_MEMORY_KB,
ARGON2ID_MAX_OUTPUT_LENGTH,
assertSafeArgon2idParams,
DEFAULT_ARGON2ID_PARAMS,
deriveKeyWithArgon2id,
OWASP_MIN_ITERATIONS,
OWASP_MIN_MEMORY_KB,
OWASP_MIN_PARALLELISM,
} from "./argon2id.js";
const password = new TextEncoder().encode("correct horse battery staple");
const salt = new Uint8Array(16).fill(7);
describe("assertSafeArgon2idParams", () => {
it("accepts the canonical defaults", { timeout: 5000 }, () => {
expect(() =>
assertSafeArgon2idParams(DEFAULT_ARGON2ID_PARAMS)
).not.toThrow();
});
it("rejects memoryKB below OWASP minimum", { timeout: 5000 }, () => {
try {
assertSafeArgon2idParams({
...DEFAULT_ARGON2ID_PARAMS,
memoryKB: OWASP_MIN_MEMORY_KB - 1,
});
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["ARGON2ID.UNSAFE_PARAMETERS"]
);
}
});
it("rejects non-integer memoryKB", { timeout: 5000 }, () => {
try {
assertSafeArgon2idParams({
...DEFAULT_ARGON2ID_PARAMS,
memoryKB: 65536.5,
});
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
}
});
it("rejects memoryKB above hard ceiling", { timeout: 5000 }, () => {
try {
assertSafeArgon2idParams({
...DEFAULT_ARGON2ID_PARAMS,
memoryKB: ARGON2ID_MAX_MEMORY_KB + 1,
});
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["ARGON2ID.UNSAFE_PARAMETERS"]
);
}
});
it("rejects iterations below OWASP minimum", { timeout: 5000 }, () => {
try {
assertSafeArgon2idParams({
...DEFAULT_ARGON2ID_PARAMS,
iterations: OWASP_MIN_ITERATIONS - 1,
});
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["ARGON2ID.UNSAFE_PARAMETERS"]
);
}
});
it("rejects non-integer iterations", { timeout: 5000 }, () => {
try {
assertSafeArgon2idParams({
...DEFAULT_ARGON2ID_PARAMS,
iterations: 3.5,
});
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
}
});
it("rejects parallelism below minimum", { timeout: 5000 }, () => {
try {
assertSafeArgon2idParams({
...DEFAULT_ARGON2ID_PARAMS,
parallelism: OWASP_MIN_PARALLELISM - 1,
});
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["ARGON2ID.UNSAFE_PARAMETERS"]
);
}
});
it("rejects non-integer parallelism", { timeout: 5000 }, () => {
try {
assertSafeArgon2idParams({
...DEFAULT_ARGON2ID_PARAMS,
parallelism: 1.5,
});
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
}
});
it("rejects zero outputLength", { timeout: 5000 }, () => {
try {
assertSafeArgon2idParams({
...DEFAULT_ARGON2ID_PARAMS,
outputLength: 0,
});
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["ARGON2ID.UNSAFE_PARAMETERS"]
);
}
});
it("rejects outputLength above ceiling", { timeout: 5000 }, () => {
try {
assertSafeArgon2idParams({
...DEFAULT_ARGON2ID_PARAMS,
outputLength: ARGON2ID_MAX_OUTPUT_LENGTH + 1,
});
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["ARGON2ID.UNSAFE_PARAMETERS"]
);
}
});
it("rejects non-integer outputLength", { timeout: 5000 }, () => {
try {
assertSafeArgon2idParams({
...DEFAULT_ARGON2ID_PARAMS,
outputLength: 32.5,
});
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
}
});
});
describe("deriveKeyWithArgon2id", () => {
it(
"derives a 32-byte key with default params",
{ timeout: 30_000 },
async () => {
const out = await deriveKeyWithArgon2id(
password,
salt,
DEFAULT_ARGON2ID_PARAMS
);
expect(out.length).toBe(32);
},
30_000
);
it("is deterministic for same inputs", { timeout: 60_000 }, async () => {
const a = await deriveKeyWithArgon2id(
password,
salt,
DEFAULT_ARGON2ID_PARAMS
);
const b = await deriveKeyWithArgon2id(
password,
salt,
DEFAULT_ARGON2ID_PARAMS
);
expect(Array.from(a)).toEqual(Array.from(b));
});
it("different salts produce different keys", {
timeout: 60_000,
}, async () => {
const a = await deriveKeyWithArgon2id(
password,
salt,
DEFAULT_ARGON2ID_PARAMS
);
const otherSalt = new Uint8Array(16).fill(8);
const b = await deriveKeyWithArgon2id(
password,
otherSalt,
DEFAULT_ARGON2ID_PARAMS
);
expect(Array.from(a)).not.toEqual(Array.from(b));
});
it("rejects empty password", { timeout: 5000 }, async () => {
await expect(
deriveKeyWithArgon2id(
new Uint8Array(0),
salt,
DEFAULT_ARGON2ID_PARAMS
)
).rejects.toBeInstanceOf(CryptoVaultError);
});
it("rejects empty salt", { timeout: 5000 }, async () => {
await expect(
deriveKeyWithArgon2id(
password,
new Uint8Array(0),
DEFAULT_ARGON2ID_PARAMS
)
).rejects.toBeInstanceOf(CryptoVaultError);
});
it("rejects memory below 65536 KB", { timeout: 5000 }, async () => {
await expect(
deriveKeyWithArgon2id(password, salt, {
...DEFAULT_ARGON2ID_PARAMS,
memoryKB: 1024,
})
).rejects.toBeInstanceOf(CryptoVaultError);
});
it("rejects iterations below 3", { timeout: 5000 }, async () => {
await expect(
deriveKeyWithArgon2id(password, salt, {
...DEFAULT_ARGON2ID_PARAMS,
iterations: 2,
})
).rejects.toBeInstanceOf(CryptoVaultError);
});
it("invokes the injected clock for telemetry", {
timeout: 30_000,
}, async () => {
let calls = 0;
await deriveKeyWithArgon2id(password, salt, DEFAULT_ARGON2ID_PARAMS, {
clock: () => ++calls,
});
expect(calls).toBeGreaterThanOrEqual(2);
});
it("uses defaults when params are omitted", {
timeout: 30_000,
}, async () => {
const out = await deriveKeyWithArgon2id(password, salt);
expect(out.length).toBe(32);
});
});

View File

@@ -0,0 +1,166 @@
import { argon2id as primitiveArgon2id } from "@suaveplan/crypto-hash";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import { getCryptoVaultInstruments } from "../otel/instruments.js";
import type { Argon2idParams } from "../types/types.js";
/** OWASP password-hashing minimum memory cost (KiB). */
export const OWASP_MIN_MEMORY_KB = 65_536;
/** OWASP password-hashing minimum iteration count. */
export const OWASP_MIN_ITERATIONS = 3;
/** OWASP password-hashing minimum parallelism. */
export const OWASP_MIN_PARALLELISM = 1;
/** Hard upper bound on memory cost — defends against accidental DoS. */
export const ARGON2ID_MAX_MEMORY_KB = 1_048_576; // 1 GiB
/** Hard upper bound on output length — defends against accidental DoS. */
export const ARGON2ID_MAX_OUTPUT_LENGTH = 4_096;
/** Convenience defaults that already satisfy OWASP password storage guidance. */
export const DEFAULT_ARGON2ID_PARAMS: Argon2idParams = Object.freeze({
memoryKB: OWASP_MIN_MEMORY_KB,
iterations: OWASP_MIN_ITERATIONS,
parallelism: 4,
outputLength: 32,
});
/**
* Validate an `Argon2idParams` bag against the OWASP minimums and the
* package-internal hard upper bounds. Throws `CryptoVaultError` with
* `ARGON2ID.UNSAFE_PARAMETERS` on any violation.
*/
export function assertSafeArgon2idParams(params: Argon2idParams): void {
if (
!Number.isInteger(params.memoryKB) ||
params.memoryKB < OWASP_MIN_MEMORY_KB
) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ARGON2ID.UNSAFE_PARAMETERS"],
"error.crypto-vault.argon2id.unsafe-parameters",
{
context: {
field: "memoryKB",
value: params.memoryKB,
minimum: OWASP_MIN_MEMORY_KB,
},
}
);
}
if (params.memoryKB > ARGON2ID_MAX_MEMORY_KB) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ARGON2ID.UNSAFE_PARAMETERS"],
"error.crypto-vault.argon2id.unsafe-parameters",
{
context: {
field: "memoryKB",
value: params.memoryKB,
maximum: ARGON2ID_MAX_MEMORY_KB,
},
}
);
}
if (
!Number.isInteger(params.iterations) ||
params.iterations < OWASP_MIN_ITERATIONS
) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ARGON2ID.UNSAFE_PARAMETERS"],
"error.crypto-vault.argon2id.unsafe-parameters",
{
context: {
field: "iterations",
value: params.iterations,
minimum: OWASP_MIN_ITERATIONS,
},
}
);
}
if (
!Number.isInteger(params.parallelism) ||
params.parallelism < OWASP_MIN_PARALLELISM
) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ARGON2ID.UNSAFE_PARAMETERS"],
"error.crypto-vault.argon2id.unsafe-parameters",
{
context: {
field: "parallelism",
value: params.parallelism,
minimum: OWASP_MIN_PARALLELISM,
},
}
);
}
if (
!Number.isInteger(params.outputLength) ||
params.outputLength <= 0 ||
params.outputLength > ARGON2ID_MAX_OUTPUT_LENGTH
) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ARGON2ID.UNSAFE_PARAMETERS"],
"error.crypto-vault.argon2id.unsafe-parameters",
{
context: {
field: "outputLength",
value: params.outputLength,
minimum: 1,
maximum: ARGON2ID_MAX_OUTPUT_LENGTH,
},
}
);
}
}
/** Options accepted by {@link deriveKeyWithArgon2id}. */
export interface DeriveKeyOptions {
/** Wall-clock source for telemetry. Defaults to `Date.now`. */
readonly clock?: () => number;
}
/**
* Derive a key with Argon2id. The function refuses parameters that fall
* below the OWASP password-storage minima (memoryKB ≥ 65536, iterations ≥ 3,
* parallelism ≥ 1) and refuses pathologically high values that would exhaust
* the host.
*
* The wrapper is **synchronous** — `@suaveplan/crypto-hash`'s `argon2id` is
* sync — but is exposed as `Promise<Uint8Array>` so future native bindings
* can move to a worker without a breaking change.
*
* @throws CryptoVaultError(CRYPTOVAULT.ARGON2ID.UNSAFE_PARAMETERS) on any
* parameter that violates the safety bands.
* @throws CryptoVaultError(CRYPTOVAULT.ARGON2ID.INVALID_INPUT) when the
* password or salt is empty.
*/
export async function deriveKeyWithArgon2id(
password: Uint8Array,
salt: Uint8Array,
params: Argon2idParams = DEFAULT_ARGON2ID_PARAMS,
options: DeriveKeyOptions = {}
): Promise<Uint8Array> {
if (password.length === 0) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ARGON2ID.INVALID_INPUT"],
"error.crypto-vault.argon2id.invalid-input",
{ context: { field: "password" } }
);
}
if (salt.length === 0) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ARGON2ID.INVALID_INPUT"],
"error.crypto-vault.argon2id.invalid-input",
{ context: { field: "salt" } }
);
}
assertSafeArgon2idParams(params);
const clock = options.clock ?? (() => Date.now());
const start = clock();
const out = primitiveArgon2id(password, {
salt,
memoryCost: params.memoryKB,
timeCost: params.iterations,
parallelism: params.parallelism,
length: params.outputLength,
});
getCryptoVaultInstruments().argon2idDurationMs.record(clock() - start);
return out;
}

View File

@@ -0,0 +1,109 @@
# BIP39 mnemonics
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`bip39.ts` wraps `@scure/bip39` (the audited BIP39 reference implementation)
with the Suaveplan ergonomics expected by the rest of `crypto-vault`: a
literal-typed `strength` argument, RNG injection, structured
`CryptoVaultError` failures, and a default-enforced English word list. The
goal is to provide everything a vault layer needs for backup phrase
generation, restore, and validation, while keeping the cryptographic core
delegated to a maintained upstream package.
## Features
- `generateMnemonic(strength?, options?)` — 128-bit (12-word) or 256-bit
(24-word) phrases, with optional `Rng` injection for deterministic tests.
- `entropyToMnemonic(entropy)` and `mnemonicToEntropy(mnemonic)` — strict
round-trip; rejects malformed inputs with a typed error.
- `validateMnemonic(mnemonic)` — non-throwing boolean form for UIs.
- `BIP39_ENGLISH_WORDLIST` — frozen view of the official English list for
consumers that need autocomplete data.
## Usage: Basic Example
```ts
import {
generateMnemonic,
mnemonicToEntropy,
} from "@suaveplan/crypto-vault/bip39/bip39";
const phrase = generateMnemonic(128);
const entropy = mnemonicToEntropy(phrase);
console.log(phrase.split(" ").length); // 12
console.log(entropy.length); // 16
```
## Usage: Advanced Example
```ts
import {
entropyToMnemonic,
generateMnemonic,
validateMnemonic,
} from "@suaveplan/crypto-vault/bip39/bip39";
// Deterministic test fixtures — supply a seeded RNG.
const fixedBytes = new Uint8Array(16).fill(0x42);
const phrase = generateMnemonic(128, {
rng: {
nextUint32: () => 0,
nextFloat: () => 0,
nextBytes: (n) => fixedBytes.slice(0, n),
},
});
if (!validateMnemonic(phrase)) {
throw new Error("generation produced an invalid phrase");
}
console.log(phrase === entropyToMnemonic(fixedBytes)); // true
```
## API Reference
### `generateMnemonic(strength?, options?)`
- `strength: 128 | 256` (default 128).
- `options.rng?: Rng` — uses `nextBytes(strength / 8)` when provided.
Returns a space-separated phrase using the English word list.
Throws `CryptoVaultError(CRYPTOVAULT.BIP39.INVALID_ENTROPY_LENGTH)` when
`strength` is not 128 or 256, or when the injected RNG returns a buffer of
the wrong length.
### `entropyToMnemonic(entropy: Uint8Array)`
Converts 16-byte or 32-byte entropy into a BIP39 phrase. Throws
`CRYPTOVAULT.BIP39.INVALID_ENTROPY_LENGTH` for any other length.
### `mnemonicToEntropy(mnemonic: string)`
Validates the phrase against the English word list and the BIP39 checksum,
then returns the underlying entropy. Throws
`CRYPTOVAULT.BIP39.INVALID_MNEMONIC` on any failure (empty input, unknown
word, bad checksum, or wrong word count). The original error is attached
as `cause`.
### `validateMnemonic(mnemonic: string)`
Non-throwing boolean wrapper around `mnemonicToEntropy`.
### `BIP39_ENGLISH_WORDLIST`
A `readonly string[]` view of the 2048-word English list.
## Implementation Notes
- The package only ships the English word list. Other languages are out of
scope per the proposal; consumers that need them should depend on
`@scure/bip39` directly.
- The wrapper translates `@scure/bip39`'s thrown errors into
`CryptoVaultError` so callers can rely on the package's stable code
registry without learning upstream error shapes.
- `generateMnemonic` reads entropy _before_ delegating to `entropyToMnemonic`
inside `@scure/bip39`. This is what allows `options.rng.nextBytes` to
drive the result deterministically.

View File

@@ -0,0 +1,234 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import {
BIP39_ENGLISH_WORDLIST,
entropyToMnemonic,
generateMnemonic,
mnemonicToEntropy,
validateMnemonic,
} from "./bip39.js";
describe("generateMnemonic", () => {
it("generates a 12-word mnemonic", { timeout: 5000 }, () => {
const phrase = generateMnemonic(128);
expect(phrase.split(" ")).toHaveLength(12);
});
it("generates a 24-word mnemonic", { timeout: 5000 }, () => {
const phrase = generateMnemonic(256);
expect(phrase.split(" ")).toHaveLength(24);
});
it("defaults to 128-bit (12 words) when strength is omitted", {
timeout: 5000,
}, () => {
const phrase = generateMnemonic();
expect(phrase.split(" ")).toHaveLength(12);
});
it("uses english wordlist words", { timeout: 5000 }, () => {
const phrase = generateMnemonic(128);
const words = phrase.split(" ");
for (const w of words) {
expect(BIP39_ENGLISH_WORDLIST).toContain(w);
}
});
it("rejects unsupported strength values", { timeout: 5000 }, () => {
try {
generateMnemonic(192 as 128);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["BIP39.INVALID_ENTROPY_LENGTH"]
);
}
});
it("injectable rng determines entropy", { timeout: 5000 }, () => {
const fixed = new Uint8Array(16).fill(0x42);
const phrase = generateMnemonic(128, {
rng: {
nextUint32: () => 0,
nextFloat: () => 0,
nextBytes: (n) => fixed.slice(0, n),
},
});
expect(phrase).toBe(entropyToMnemonic(fixed));
});
it("falls back to default RNG when rng has no nextBytes", {
timeout: 5000,
}, () => {
const phrase = generateMnemonic(128, {
rng: { nextUint32: () => 0, nextFloat: () => 0 },
});
expect(phrase.split(" ")).toHaveLength(12);
});
it("rejects rng that returns wrong byte length", { timeout: 5000 }, () => {
try {
generateMnemonic(128, {
rng: {
nextUint32: () => 0,
nextFloat: () => 0,
nextBytes: () => new Uint8Array(8),
},
});
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["BIP39.INVALID_ENTROPY_LENGTH"]
);
}
});
});
describe("entropyToMnemonic + mnemonicToEntropy round trip", () => {
it("round-trips 16-byte entropy", { timeout: 5000 }, () => {
const entropy = new Uint8Array(16).fill(7);
const phrase = entropyToMnemonic(entropy);
const back = mnemonicToEntropy(phrase);
expect(Array.from(back)).toEqual(Array.from(entropy));
});
it("round-trips 32-byte entropy", { timeout: 5000 }, () => {
const entropy = new Uint8Array(32).fill(0xab);
const phrase = entropyToMnemonic(entropy);
const back = mnemonicToEntropy(phrase);
expect(Array.from(back)).toEqual(Array.from(entropy));
});
it("round-trips arbitrary 16-byte entropy (property)", {
timeout: 30_000,
}, () => {
fc.assert(
fc.property(
fc.uint8Array({ minLength: 16, maxLength: 16 }),
(entropy) => {
const phrase = entropyToMnemonic(entropy);
const back = mnemonicToEntropy(phrase);
expect(Array.from(back)).toEqual(Array.from(entropy));
}
),
{ numRuns: 50 }
);
});
it("rejects entropy length other than 16 or 32", { timeout: 5000 }, () => {
for (const length of [0, 1, 8, 15, 17, 24, 31, 33, 64]) {
try {
entropyToMnemonic(new Uint8Array(length));
expect.unreachable(`expected to reject length ${length}`);
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["BIP39.INVALID_ENTROPY_LENGTH"]
);
}
}
});
});
describe("mnemonicToEntropy validation", () => {
it("rejects empty mnemonic", { timeout: 5000 }, () => {
try {
mnemonicToEntropy("");
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["BIP39.INVALID_MNEMONIC"]
);
}
});
it("rejects whitespace-only mnemonic", { timeout: 5000 }, () => {
try {
mnemonicToEntropy(" ");
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["BIP39.INVALID_MNEMONIC"]
);
}
});
it("rejects mnemonic with unknown word", { timeout: 5000 }, () => {
const phrase = generateMnemonic(128);
const tampered = phrase
.split(" ")
.map((w, i) => (i === 0 ? "notaword" : w))
.join(" ");
try {
mnemonicToEntropy(tampered);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["BIP39.INVALID_MNEMONIC"]
);
}
});
it("rejects mnemonic with bad checksum", { timeout: 5000 }, () => {
// Replace the last word with a different valid word — checksum fails
// in the overwhelming majority of cases.
const phrase = generateMnemonic(128);
const words = phrase.split(" ");
const lastIndex = BIP39_ENGLISH_WORDLIST.indexOf(words[11] as string);
const replacement = BIP39_ENGLISH_WORDLIST[(lastIndex + 1) % 2048];
const tampered = [...words.slice(0, 11), replacement].join(" ");
// It might be one of the rare cases where the checksum still
// matches; loop until we get a tamper that fails.
let failed = false;
for (let i = 0; i < 8 && !failed; i++) {
try {
mnemonicToEntropy(tampered);
} catch (err) {
failed = true;
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["BIP39.INVALID_MNEMONIC"]
);
}
}
// Statistically guaranteed across regenerations; if it ever passes
// the test is still meaningful (round-trip succeeded).
expect(failed || validateMnemonic(tampered)).toBe(true);
});
it("rejects mnemonic with wrong word count", { timeout: 5000 }, () => {
try {
mnemonicToEntropy("abandon abandon abandon");
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["BIP39.INVALID_MNEMONIC"]
);
}
});
});
describe("validateMnemonic", () => {
it("returns true for a freshly generated mnemonic", {
timeout: 5000,
}, () => {
expect(validateMnemonic(generateMnemonic(128))).toBe(true);
expect(validateMnemonic(generateMnemonic(256))).toBe(true);
});
it("returns false for empty input", { timeout: 5000 }, () => {
expect(validateMnemonic("")).toBe(false);
});
it("returns false for an unknown-word phrase", { timeout: 5000 }, () => {
expect(validateMnemonic("notaword ".repeat(12).trim())).toBe(false);
});
});

View File

@@ -0,0 +1,116 @@
import {
entropyToMnemonic as scureEntropyToMnemonic,
mnemonicToEntropy as scureMnemonicToEntropy,
} from "@scure/bip39";
import { wordlist as englishWordlist } from "@scure/bip39/wordlists/english";
import { randomBytes as cryptoRandomBytes } from "@suaveplan/crypto-utils";
import type { Rng } from "@suaveplan/types";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
/** Allowed BIP39 entropy strengths (bits). */
export type Bip39Strength = 128 | 256;
/** Options accepted by {@link generateMnemonic}. */
export interface GenerateMnemonicOptions {
/** Random source. Defaults to `crypto.getRandomValues`. */
readonly rng?: Rng;
}
/**
* The BIP39 English word list as a frozen array. Re-exported for callers that
* need it for autocompletion or wordlist verification UIs.
*/
export const BIP39_ENGLISH_WORDLIST: readonly string[] = englishWordlist;
/**
* Generate a fresh BIP39 mnemonic from `strength` bits of entropy.
*
* @param strength Either `128` (12 words) or `256` (24 words). Defaults to 128.
* @param options.rng Optional random source. When provided, `nextBytes` is
* used; otherwise the package falls back to
* `crypto.getRandomValues` via `@suaveplan/crypto-utils`.
* @throws CryptoVaultError(CRYPTOVAULT.BIP39.INVALID_ENTROPY_LENGTH) when
* `strength` is not 128 or 256.
*/
export function generateMnemonic(
strength: Bip39Strength = 128,
options: GenerateMnemonicOptions = {}
): string {
if (strength !== 128 && strength !== 256) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["BIP39.INVALID_ENTROPY_LENGTH"],
"error.crypto-vault.bip39.invalid-entropy-length",
{ context: { strength } }
);
}
const lengthBytes = strength / 8;
const entropy =
options.rng?.nextBytes !== undefined
? options.rng.nextBytes(lengthBytes)
: cryptoRandomBytes(lengthBytes);
if (entropy.length !== lengthBytes) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["BIP39.INVALID_ENTROPY_LENGTH"],
"error.crypto-vault.bip39.invalid-entropy-length",
{ context: { expected: lengthBytes, actual: entropy.length } }
);
}
return scureEntropyToMnemonic(entropy, englishWordlist);
}
/**
* Convert raw entropy into a BIP39 English mnemonic.
*
* @throws CryptoVaultError(CRYPTOVAULT.BIP39.INVALID_ENTROPY_LENGTH) when the
* length is not 16 (128-bit) or 32 (256-bit) bytes.
*/
export function entropyToMnemonic(entropy: Uint8Array): string {
if (entropy.length !== 16 && entropy.length !== 32) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["BIP39.INVALID_ENTROPY_LENGTH"],
"error.crypto-vault.bip39.invalid-entropy-length",
{ context: { length: entropy.length } }
);
}
return scureEntropyToMnemonic(entropy, englishWordlist);
}
/**
* Convert a BIP39 English mnemonic into the underlying entropy bytes.
*
* @throws CryptoVaultError(CRYPTOVAULT.BIP39.INVALID_MNEMONIC) when the
* mnemonic is empty, contains an unknown word, has a bad checksum,
* or has a wrong word count.
*/
export function mnemonicToEntropy(mnemonic: string): Uint8Array {
if (typeof mnemonic !== "string" || mnemonic.trim() === "") {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["BIP39.INVALID_MNEMONIC"],
"error.crypto-vault.bip39.invalid-mnemonic",
{ context: { reason: "empty" } }
);
}
try {
return scureMnemonicToEntropy(mnemonic, englishWordlist);
} catch (cause) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["BIP39.INVALID_MNEMONIC"],
"error.crypto-vault.bip39.invalid-mnemonic",
{ cause }
);
}
}
/**
* Validate a BIP39 English mnemonic without throwing. Returns `true` only
* when the mnemonic decodes to legal entropy with a valid checksum.
*/
export function validateMnemonic(mnemonic: string): boolean {
try {
mnemonicToEntropy(mnemonic);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,78 @@
# crypto-vault — Error code registry
## Purpose
`codes.ts` is the canonical, frozen registry of every error code emitted by
`@suaveplan/crypto-vault`. The registry exists so that consumers — vault
orchestrators, sync layers, telemetry pipelines, audit log post-processors —
can assert against a stable set of strings rather than parsing free-form
messages. The registry is built with `defineCodes` from `@suaveplan/error`,
which freezes the resulting record at module load and infers each code's
literal type so `instanceof CryptoVaultError` can narrow `code` to the union.
## Features
- Frozen `CRYPTO_VAULT_CODES` record with `CRYPTOVAULT.<MODULE>.<REASON>`
namespacing.
- `CryptoVaultCode` type union for exhaustive `switch` discrimination.
- One canonical home for every failure mode the package can produce. Adding a
new code requires touching this file plus the production path that throws.
## Usage: Basic Example
```ts
import {
CRYPTO_VAULT_CODES,
CryptoVaultError,
} from "@suaveplan/crypto-vault/errors";
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["BIP39.INVALID_ENTROPY_LENGTH"],
"error.crypto-vault.bip39.invalid-entropy-length",
{ context: { length: 7 } }
);
```
## Usage: Advanced Example
```ts
import { CryptoVaultError } from "@suaveplan/crypto-vault/errors";
import type { CryptoVaultCode } from "@suaveplan/crypto-vault/errors";
function describe(err: unknown): string {
if (!(err instanceof CryptoVaultError)) return "unknown error";
const code: CryptoVaultCode = err.code;
switch (code) {
case "CRYPTOVAULT.BIP39.INVALID_MNEMONIC":
return "Invalid recovery phrase";
case "CRYPTOVAULT.SRP.AUTHENTICATION_FAILED":
return "Wrong password — try again";
default:
return `Unhandled crypto-vault code: ${code}`;
}
}
```
## API Reference
### `CRYPTO_VAULT_CODES`
A frozen `Record<string, string>` mapping each reason key (e.g.
`BIP39.INVALID_ENTROPY_LENGTH`) to its fully-qualified code (e.g.
`CRYPTOVAULT.BIP39.INVALID_ENTROPY_LENGTH`).
### `CryptoVaultCode`
Union of every string literal value held by `CRYPTO_VAULT_CODES`. Use as the
generic parameter of `CryptoVaultError`.
## Implementation Notes
- The registry is intentionally append-only. Removing a code is a breaking
change for any consumer that asserts on it.
- Codes are checked at runtime via the `defineCodes` factory — any duplicate
reason in the array would surface as the same key collapsing in the output
record.
- Every code listed here must be thrown by at least one production path. The
spec-conformance test enumerates the registry and verifies the production
surface throws each code at least once.

View File

@@ -0,0 +1,41 @@
import { defineCodes } from "@suaveplan/error";
/**
* Stable error code registry for `@suaveplan/crypto-vault`.
*
* Every code is namespaced under `CRYPTOVAULT.<MODULE>.<REASON>`. Codes are
* frozen at module-load time and may never be removed without a major-version
* bump (consumers MAY assert on them).
*/
export const CRYPTO_VAULT_CODES = defineCodes("CRYPTOVAULT", [
"BIP39.INVALID_ENTROPY_LENGTH",
"BIP39.INVALID_MNEMONIC",
"ARGON2ID.UNSAFE_PARAMETERS",
"ARGON2ID.INVALID_INPUT",
"TWOSKD.MISSING_PASSWORD",
"TWOSKD.MISSING_SECRET_KEY",
"TWOSKD.MISSING_EMAIL",
"HD.INVALID_MNEMONIC",
"HD.INVALID_INDEX",
"HIERARCHY.INVALID_AUK_LENGTH",
"HIERARCHY.EMPTY_VAULT_ID",
"HIERARCHY.EMPTY_ITEM_ID",
"SRP.AUTHENTICATION_FAILED",
"SRP.WEAK_PARAMETERS",
"SRP.INVALID_B",
"SRP.INVALID_PRIVATE_KEY",
"SECURE.ZEROIZED",
"SECURE.INVALID_LENGTH",
"OPAQUE.INVALID_HANDLE",
"ORCHESTRATION.DECRYPT_FAILED",
"ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID",
"ORCHESTRATION.STORAGE_KEY_NOT_FOUND",
"ORCHESTRATION.INVALID_VERSION",
] as const);
/**
* Union of every fully-qualified error code emitted by `crypto-vault`.
* Use as the type parameter of `CryptoVaultError` for narrowed `code` checks.
*/
export type CryptoVaultCode =
(typeof CRYPTO_VAULT_CODES)[keyof typeof CRYPTO_VAULT_CODES];

View File

@@ -0,0 +1,93 @@
# CryptoVaultError
## Purpose
`CryptoVaultError` is the umbrella error class for `@suaveplan/crypto-vault`.
Every failure mode in the package — invalid BIP39 entropy, unsafe Argon2id
parameters, SRP authentication failures, zeroized SecureBuffer access — is
surfaced as a `CryptoVaultError` carrying a code from
`CRYPTO_VAULT_CODES`. Extending `SuaveplanError` means the error participates
in the project's wire format, OTel span events, redaction, and i18n
infrastructure for free.
## Features
- Inherits structured `code`, `context`, `timestamp`, and `traceId` from
`SuaveplanError`.
- Code field is narrowed to the `CryptoVaultCode` union so `switch (err.code)`
is exhaustive.
- Tagged with a unique brand symbol so cross-realm `instanceof` checks (web
workers, iframes) can be defended against via `CryptoVaultError.is(value)`.
- Public constructor — internal modules instantiate freely; consumers may
also instantiate when re-throwing across boundaries.
## Usage: Basic Example
```ts
import {
CRYPTO_VAULT_CODES,
CryptoVaultError,
} from "@suaveplan/crypto-vault/errors";
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["BIP39.INVALID_MNEMONIC"],
"error.crypto-vault.bip39.invalid-mnemonic"
);
```
## Usage: Advanced Example
```ts
import {
CRYPTO_VAULT_CODES,
CryptoVaultError,
} from "@suaveplan/crypto-vault/errors";
try {
// ... call into crypto-vault ...
} catch (raw) {
if (CryptoVaultError.is(raw)) {
switch (raw.code) {
case CRYPTO_VAULT_CODES["SRP.AUTHENTICATION_FAILED"]:
return { kind: "auth-failed" } as const;
case CRYPTO_VAULT_CODES["SECURE.ZEROIZED"]:
return { kind: "vault-locked" } as const;
default:
throw raw;
}
}
throw raw;
}
```
## API Reference
### `class CryptoVaultError extends SuaveplanError<CryptoVaultCode>`
#### Constructor
`new CryptoVaultError(code, messageKey, options?)`
- `code: CryptoVaultCode` — fully-qualified registry code.
- `messageKey: string` — i18n key resolved by `@suaveplan/error`'s
`MessageResolver`.
- `options?: SuaveplanErrorOptions` — forwarded to `SuaveplanError`.
#### `static is(value: unknown): value is CryptoVaultError`
Returns `true` when `value` was constructed by this package. Combines
`instanceof` with a brand-symbol identity check, defending against cross-realm
prototype divergence.
## Implementation Notes
- The brand symbol is a `unique symbol` keyed off
`Symbol("@suaveplan/crypto-vault.error")`; the symbol identity is not
stable across realms by design — `CryptoVaultError.is` uses it as a
same-realm assertion only. Cross-realm instances must be re-thrown by
their owning realm before reaching this guard.
- The constructor sets the brand field after `super(...)` so the assignment
happens after the prototype chain is initialised.
- The class is intentionally non-abstract. Internal callers throw it
directly with a `CRYPTO_VAULT_CODES.*` value rather than maintaining one
subclass per error reason.

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_VAULT_CODES } from "./codes.js";
import { CryptoVaultError } from "./crypto-vault-error.js";
describe("CryptoVaultError", () => {
it("carries the supplied code on instances", { timeout: 5000 }, () => {
const err = new CryptoVaultError(
CRYPTO_VAULT_CODES["BIP39.INVALID_MNEMONIC"],
"error.crypto-vault.bip39.invalid-mnemonic"
);
expect(err.code).toBe("CRYPTOVAULT.BIP39.INVALID_MNEMONIC");
});
it("forwards context through to SuaveplanError", { timeout: 5000 }, () => {
const err = new CryptoVaultError(
CRYPTO_VAULT_CODES["ARGON2ID.UNSAFE_PARAMETERS"],
"error.crypto-vault.argon2id.unsafe-parameters",
{ context: { memoryKB: 1024 } }
);
expect(err.context).toEqual({ memoryKB: 1024 });
});
it("CryptoVaultError.is returns true for branded instances", {
timeout: 5000,
}, () => {
const err = new CryptoVaultError(
CRYPTO_VAULT_CODES["SECURE.ZEROIZED"],
"error.crypto-vault.secure.zeroized"
);
expect(CryptoVaultError.is(err)).toBe(true);
});
it("CryptoVaultError.is returns false for plain Error", {
timeout: 5000,
}, () => {
expect(CryptoVaultError.is(new Error("plain"))).toBe(false);
});
it("CryptoVaultError.is returns false for non-error values", {
timeout: 5000,
}, () => {
expect(CryptoVaultError.is(null)).toBe(false);
expect(CryptoVaultError.is(undefined)).toBe(false);
expect(CryptoVaultError.is({ code: "fake" })).toBe(false);
expect(CryptoVaultError.is("string")).toBe(false);
});
it("instances are instanceof Error", { timeout: 5000 }, () => {
const err = new CryptoVaultError(
CRYPTO_VAULT_CODES["SRP.AUTHENTICATION_FAILED"],
"error.crypto-vault.srp.authentication-failed"
);
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(CryptoVaultError);
});
});
describe("CRYPTO_VAULT_CODES", () => {
it("namespaces every code under CRYPTOVAULT.*", { timeout: 5000 }, () => {
for (const code of Object.values(CRYPTO_VAULT_CODES)) {
expect(code.startsWith("CRYPTOVAULT.")).toBe(true);
}
});
it("freezes the registry", { timeout: 5000 }, () => {
expect(Object.isFrozen(CRYPTO_VAULT_CODES)).toBe(true);
});
it("contains the documented BIP39 codes", { timeout: 5000 }, () => {
expect(CRYPTO_VAULT_CODES["BIP39.INVALID_ENTROPY_LENGTH"]).toBe(
"CRYPTOVAULT.BIP39.INVALID_ENTROPY_LENGTH"
);
expect(CRYPTO_VAULT_CODES["BIP39.INVALID_MNEMONIC"]).toBe(
"CRYPTOVAULT.BIP39.INVALID_MNEMONIC"
);
});
});

View File

@@ -0,0 +1,42 @@
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
import type { CryptoVaultCode } from "./codes.js";
const CRYPTO_VAULT_ERROR_BRAND: unique symbol = Symbol(
"@suaveplan/crypto-vault.error"
);
/**
* Concrete `SuaveplanError` subclass for every failure raised by
* `@suaveplan/crypto-vault`.
*
* The class is parameterised by `CryptoVaultCode` so consumers can write
* `if (err instanceof CryptoVaultError) switch (err.code) { ... }` and have
* the compiler enforce exhaustive-match on the registry. A unique brand
* symbol is attached to every instance so subclasses or duck-typed peers
* cannot impersonate a `CryptoVaultError` thrown by this package.
*/
export class CryptoVaultError extends SuaveplanError<CryptoVaultCode> {
/** Unique brand asserting this instance was constructed by this package. */
public readonly [CRYPTO_VAULT_ERROR_BRAND]: true;
public constructor(
code: CryptoVaultCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this[CRYPTO_VAULT_ERROR_BRAND] = true;
}
/**
* Type guard verifying both `instanceof` and the brand symbol. Use for
* defence-in-depth when handling errors that may have crossed iframe or
* worker boundaries (which can break `instanceof`).
*/
public static is(value: unknown): value is CryptoVaultError {
return (
value instanceof CryptoVaultError &&
(value as CryptoVaultError)[CRYPTO_VAULT_ERROR_BRAND] === true
);
}
}

View File

@@ -0,0 +1,5 @@
export {
CRYPTO_VAULT_CODES,
type CryptoVaultCode,
} from "./codes.js";
export { CryptoVaultError } from "./crypto-vault-error.js";

View File

@@ -0,0 +1,92 @@
# Opaque key handles
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`opaque-handle.ts` lets the broader `vault` layer hold a typed reference
to a SecureBuffer without ever seeing the raw bytes. The handle is a
frozen, brand-symbol-tagged object containing only a stable `id`, a
`length`, and a `_zeroize` thunk. The mapping back to the SecureBuffer is
held in a module-level `WeakMap` keyed by the handle, so a leaked handle
on its own does not yield key material.
## Features
- `wrap(SecureBuffer): OpaqueKeyHandle` — frozen handle with a unique
symbol id, the buffer's length, and a `_zeroize` callable.
- `unwrap(OpaqueKeyHandle): SecureBuffer` — package-internal resolver,
validates the brand and throws when the handle is malformed or the
underlying buffer has been GC'd.
- `isValidHandle(value): value is OpaqueKeyHandle` — runtime brand check
for values arriving across an `unknown`-typed boundary.
## Usage: Basic Example
```ts
import { SecureBuffer } from "@suaveplan/crypto-vault/secure/secure-buffer";
import { wrap } from "@suaveplan/crypto-vault/handle/opaque-handle";
const buf = new SecureBuffer(32);
buf.fillRandom();
const handle = wrap(buf);
console.log(handle.length); // 32
handle._zeroize();
console.log(buf.isClosed); // true
```
## Usage: Advanced Example
```ts
import { SecureBuffer } from "@suaveplan/crypto-vault/secure/secure-buffer";
import {
isValidHandle,
wrap,
} from "@suaveplan/crypto-vault/handle/opaque-handle";
function passToVaultLayer(handle: unknown) {
if (!isValidHandle(handle)) {
throw new Error("vault layer received a non-handle value");
}
// The vault layer stores the handle but cannot read the underlying bytes.
return handle;
}
const wrapped = wrap(new SecureBuffer(32));
passToVaultLayer(wrapped);
```
## API Reference
### `wrap(buffer: SecureBuffer): OpaqueKeyHandle`
Returns a frozen handle. The handle's `_zeroize` thunk forwards to the
buffer's `close()` method.
### `unwrap(handle: OpaqueKeyHandle): SecureBuffer`
Resolves the handle back to the underlying SecureBuffer. Throws
`CryptoVaultError(CRYPTOVAULT.OPAQUE.INVALID_HANDLE)` for missing brand,
unknown handle, or GC'd buffer. **Internal — vault-side packages
within `@suaveplan/*` may import this; consumers outside the project
should treat handles as opaque.**
### `isValidHandle(value: unknown): value is OpaqueKeyHandle`
Runtime brand check.
## Implementation Notes
- The handle uses a `unique symbol` brand so the TypeScript compiler can
refuse foreign objects masquerading as `OpaqueKeyHandle`.
- The `WeakMap<OpaqueKeyHandle, SecureBuffer>` ensures that once the
handle becomes unreachable, the SecureBuffer link is also collected.
Callers that want to keep the buffer alive must keep a strong reference
to the handle.
- The `_zeroize` field deliberately starts with an underscore to flag
it as an implementation detail rather than a stable public method.
- This module is the boundary that prevents the `vault` layer from
needing direct `Uint8Array` access to derived keys; the orchestrator
hands handles to AEAD operations and the AEAD layer calls `unwrap`
immediately before passing the bytes into the noble cipher.

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import { SecureBuffer } from "../secure/secure-buffer.js";
import { isValidHandle, unwrap, wrap } from "./opaque-handle.js";
describe("OpaqueKeyHandle", () => {
it("wrap returns a frozen handle with id, length, and _zeroize", {
timeout: 5000,
}, () => {
const buf = new SecureBuffer(8);
const handle = wrap(buf);
expect(handle.length).toBe(8);
expect(typeof handle.id).toBe("symbol");
expect(typeof handle._zeroize).toBe("function");
expect(Object.isFrozen(handle)).toBe(true);
handle._zeroize();
expect(buf.isClosed).toBe(true);
});
it("each handle has a fresh id", { timeout: 5000 }, () => {
const a = wrap(new SecureBuffer(4));
const b = wrap(new SecureBuffer(4));
expect(a.id).not.toBe(b.id);
});
it("unwrap resolves a wrapped handle back to the SecureBuffer", {
timeout: 5000,
}, () => {
const buf = new SecureBuffer(new Uint8Array([1, 2, 3]));
const handle = wrap(buf);
const resolved = unwrap(handle);
expect(resolved).toBe(buf);
buf.close();
});
it("unwrap throws on a forged handle missing the brand", {
timeout: 5000,
}, () => {
const fake = { id: Symbol(), length: 8, _zeroize: () => undefined };
try {
unwrap(fake as unknown as ReturnType<typeof wrap>);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["OPAQUE.INVALID_HANDLE"]
);
}
});
it("unwrap throws when the handle is unknown to the registry", {
timeout: 5000,
}, () => {
// Manually mint a frozen object with the brand but never registered.
const buf = new SecureBuffer(4);
const real = wrap(buf);
// Construct a separate fake object that copies brand identity (not
// possible without the unique symbol — instead, drop the registry
// entry by triggering GC indirectly). Simulate the gc'd path by
// wrapping a buffer and then deleting the only strong reference: the
// WeakMap.delete is internal, so we exercise the brand-but-unknown
// path by faking a brand. Re-using `Object.assign` keeps the frozen
// shape minimal and avoids depending on internal symbol identity.
const cloned = { ...real } as Record<string, unknown>;
delete cloned.id; // keep brand but drop id; still untracked.
try {
unwrap(cloned as unknown as ReturnType<typeof wrap>);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["OPAQUE.INVALID_HANDLE"]
);
}
buf.close();
});
it("isValidHandle distinguishes wrapped from arbitrary values", {
timeout: 5000,
}, () => {
const buf = new SecureBuffer(4);
const handle = wrap(buf);
expect(isValidHandle(handle)).toBe(true);
expect(isValidHandle(null)).toBe(false);
expect(isValidHandle(undefined)).toBe(false);
expect(isValidHandle({})).toBe(false);
expect(
isValidHandle({ id: Symbol(), length: 4, _zeroize: () => 0 })
).toBe(false);
expect(isValidHandle(42)).toBe(false);
buf.close();
});
it("_zeroize is idempotent", { timeout: 5000 }, () => {
const buf = new SecureBuffer(4);
const handle = wrap(buf);
handle._zeroize();
expect(() => handle._zeroize()).not.toThrow();
expect(buf.isClosed).toBe(true);
});
});

View File

@@ -0,0 +1,85 @@
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import type { SecureBuffer } from "../secure/secure-buffer.js";
/**
* Brand symbol used to gate access to the underlying SecureBuffer behind
* an opaque handle. Re-exported as a `unique symbol` so the type system
* can prove "this handle came from this package".
*/
const OPAQUE_BRAND: unique symbol = Symbol(
"@suaveplan/crypto-vault.opaque-handle"
);
const HANDLE_BUFFERS = new WeakMap<OpaqueKeyHandle, SecureBuffer>();
/**
* Opaque, type-branded reference to a SecureBuffer. Consumers (e.g. the
* `vault` layer) hold this handle but never see the raw bytes — only the
* package-internal `unwrap` function can resolve it back to a SecureBuffer.
*/
export interface OpaqueKeyHandle {
/** Stable id for this handle — useful for diagnostics. */
readonly id: symbol;
/** Length of the underlying buffer in bytes. */
readonly length: number;
/** Eagerly close the underlying buffer. Idempotent. */
readonly _zeroize: () => void;
/** Brand — present only on handles minted by `wrap`. */
readonly [OPAQUE_BRAND]: true;
}
/**
* Wrap a SecureBuffer in an opaque handle. The handle never exposes the
* underlying bytes; only modules in this package can resolve the handle
* back to a SecureBuffer via `unwrap`.
*/
export function wrap(buffer: SecureBuffer): OpaqueKeyHandle {
const id = Symbol("crypto-vault.key");
const handle: OpaqueKeyHandle = Object.freeze({
id,
length: buffer.length,
_zeroize: () => buffer.close(),
[OPAQUE_BRAND]: true as const,
});
HANDLE_BUFFERS.set(handle, buffer);
return handle;
}
/**
* Resolve an opaque handle back to its SecureBuffer. Internal — exported
* for cross-module use within `crypto-vault` only. Consumers must NOT
* import this function directly; the public surface treats handles as
* opaque.
*
* @throws CryptoVaultError(CRYPTOVAULT.OPAQUE.INVALID_HANDLE) when the
* handle is malformed (missing brand) or the buffer has been
* garbage-collected / unregistered.
*
* @internal
*/
export function unwrap(handle: OpaqueKeyHandle): SecureBuffer {
if (!isValidHandle(handle)) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["OPAQUE.INVALID_HANDLE"],
"error.crypto-vault.opaque.invalid-handle"
);
}
const buffer = HANDLE_BUFFERS.get(handle);
if (buffer === undefined) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["OPAQUE.INVALID_HANDLE"],
"error.crypto-vault.opaque.invalid-handle"
);
}
return buffer;
}
/**
* Type guard checking that `value` was minted by `wrap`. Useful for
* validating values that flowed across an `unknown`-typed boundary.
*/
export function isValidHandle(value: unknown): value is OpaqueKeyHandle {
if (typeof value !== "object" || value === null) return false;
return (value as { [OPAQUE_BRAND]?: true })[OPAQUE_BRAND] === true;
}

View File

@@ -0,0 +1,102 @@
# HD key derivation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`hd-key.ts` turns a BIP39 mnemonic into deterministic key material that the
rest of the package can fan out into a full hierarchy. The module performs
two jobs: BIP39 seed derivation (PBKDF2-HMAC-SHA512 with 2048 rounds and the
mnemonic + passphrase salt scheme) and indexed child-key derivation
(HMAC-SHA512 over the parent + 32-bit big-endian index). Together they
provide a flat, safe, BIP32-shaped HD path without requiring callers to
maintain hardened-derivation chain codes.
## Features
- `deriveMasterKeyFromMnemonic(mnemonic, passphrase?)` — 32-byte master key
(first half of the BIP39 seed).
- `deriveSeedFromMnemonic(mnemonic, passphrase?)` — full 64-byte seed for
callers that need both halves.
- `deriveChildKey(parent, index)` — 32-byte HMAC-SHA512 child.
- All entry points reject invalid mnemonics (word-count, unknown word,
checksum failure) with `CryptoVaultError(CRYPTOVAULT.HD.INVALID_MNEMONIC)`.
## Usage: Basic Example
```ts
import { generateMnemonic } from "@suaveplan/crypto-vault/bip39/bip39";
import { deriveMasterKeyFromMnemonic } from "@suaveplan/crypto-vault/hd-key/hd-key";
const phrase = generateMnemonic(128);
const master = deriveMasterKeyFromMnemonic(phrase);
console.log(master.length); // 32
```
## Usage: Advanced Example
```ts
import {
deriveChildKey,
deriveMasterKeyFromMnemonic,
deriveSeedFromMnemonic,
} from "@suaveplan/crypto-vault/hd-key/hd-key";
const phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
// Two devices, same mnemonic + passphrase => same master key (deterministic).
const m1 = deriveMasterKeyFromMnemonic(phrase, "shared-passphrase");
const m2 = deriveMasterKeyFromMnemonic(phrase, "shared-passphrase");
console.log(Buffer.from(m1).equals(Buffer.from(m2))); // true
// Different passphrases => different masters.
const m3 = deriveMasterKeyFromMnemonic(phrase, "different");
console.log(Buffer.from(m1).equals(Buffer.from(m3))); // false
// Indexed children.
const seed = deriveSeedFromMnemonic(phrase, "shared-passphrase");
const c0 = deriveChildKey(seed.slice(0, 32), 0);
const c1 = deriveChildKey(seed.slice(0, 32), 1);
console.log(Buffer.from(c0).equals(Buffer.from(c1))); // false
```
## API Reference
### `deriveMasterKeyFromMnemonic(mnemonic, passphrase?): Uint8Array`
- `mnemonic: string` — must be 12 / 15 / 18 / 21 / 24 BIP39 English words
with a valid checksum.
- `passphrase?: string` — optional BIP39 passphrase (defaults to `""`).
Returns a 32-byte master key (first half of the BIP39 seed).
Throws `CryptoVaultError(CRYPTOVAULT.HD.INVALID_MNEMONIC)` on empty input,
unsupported word counts, unknown words, or checksum failures.
### `deriveSeedFromMnemonic(mnemonic, passphrase?): Uint8Array`
Same validation as above; returns the full 64-byte seed.
### `deriveChildKey(parent, index): Uint8Array`
- `parent: Uint8Array` — any byte string (typically a master key or another
derived key).
- `index: number` — non-negative integer ≤ `0xffffffff`.
Returns a 32-byte child key (`HMAC-SHA512(parent, uint32-be(index))[:32]`).
Throws `CryptoVaultError(CRYPTOVAULT.HD.INVALID_INDEX)` for non-integer,
negative, or > 32-bit indices.
## Implementation Notes
- BIP39 seed derivation is intentionally synchronous (`mnemonicToSeedSync`)
so it can be called from constructors and worker init. PBKDF2 with 2048
rounds completes in ~5 ms on modern hardware.
- The validator runs both word-list membership and the BIP39 checksum
(`@scure/bip39.validateMnemonic`) so callers cannot bypass the checksum
by re-importing words.
- Child-key derivation deliberately does not implement BIP32 hardened paths
with chain codes. The spec-driven hierarchy in `key-hierarchy/` uses HKDF
for fan-out and only needs a single deterministic 32-byte parent.

View File

@@ -0,0 +1,174 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { generateMnemonic } from "../bip39/bip39.js";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import {
BIP39_SEED_LENGTH,
CHILD_KEY_LENGTH,
deriveChildKey,
deriveMasterKeyFromMnemonic,
deriveSeedFromMnemonic,
MASTER_KEY_LENGTH,
} from "./hd-key.js";
const PHRASE =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
describe("deriveMasterKeyFromMnemonic", () => {
it("derives deterministic master key from mnemonic", {
timeout: 5000,
}, () => {
const a = deriveMasterKeyFromMnemonic(PHRASE);
const b = deriveMasterKeyFromMnemonic(PHRASE);
expect(Array.from(a)).toEqual(Array.from(b));
expect(a.length).toBe(MASTER_KEY_LENGTH);
});
it("passphrase changes derived key", { timeout: 5000 }, () => {
const a = deriveMasterKeyFromMnemonic(PHRASE);
const b = deriveMasterKeyFromMnemonic(PHRASE, "secret");
expect(Array.from(a)).not.toEqual(Array.from(b));
});
it("different mnemonics yield different keys", { timeout: 5000 }, () => {
const a = deriveMasterKeyFromMnemonic(PHRASE);
const other = generateMnemonic(128);
const b = deriveMasterKeyFromMnemonic(other);
expect(Array.from(a)).not.toEqual(Array.from(b));
});
it("rejects invalid mnemonic — empty", { timeout: 5000 }, () => {
try {
deriveMasterKeyFromMnemonic("");
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["HD.INVALID_MNEMONIC"]
);
}
});
it("rejects invalid mnemonic — wrong word count", { timeout: 5000 }, () => {
try {
deriveMasterKeyFromMnemonic("abandon abandon abandon");
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["HD.INVALID_MNEMONIC"]
);
}
});
it("rejects invalid mnemonic — unknown word", { timeout: 5000 }, () => {
const tampered = PHRASE.split(" ")
.map((w, i) => (i === 0 ? "notaword" : w))
.join(" ");
try {
deriveMasterKeyFromMnemonic(tampered);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["HD.INVALID_MNEMONIC"]
);
}
});
it("rejects invalid mnemonic — bad checksum", { timeout: 5000 }, () => {
// Replace the last word with one that produces a wrong checksum.
const bad = PHRASE.split(" ");
bad[11] = "zoo";
try {
deriveMasterKeyFromMnemonic(bad.join(" "));
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["HD.INVALID_MNEMONIC"]
);
}
});
});
describe("deriveSeedFromMnemonic", () => {
it("returns the full 64-byte seed", { timeout: 5000 }, () => {
const seed = deriveSeedFromMnemonic(PHRASE);
expect(seed.length).toBe(BIP39_SEED_LENGTH);
});
it("rejects invalid mnemonic", { timeout: 5000 }, () => {
expect(() => deriveSeedFromMnemonic("")).toThrow(CryptoVaultError);
});
it("the master key equals seed[:32]", { timeout: 5000 }, () => {
const seed = deriveSeedFromMnemonic(PHRASE);
const master = deriveMasterKeyFromMnemonic(PHRASE);
expect(Array.from(master)).toEqual(Array.from(seed.slice(0, 32)));
});
});
describe("deriveChildKey", () => {
const parent = new Uint8Array(32).fill(0xab);
it("derives a 32-byte child", { timeout: 5000 }, () => {
const child = deriveChildKey(parent, 0);
expect(child.length).toBe(CHILD_KEY_LENGTH);
});
it("is deterministic for same parent + index", { timeout: 5000 }, () => {
const a = deriveChildKey(parent, 7);
const b = deriveChildKey(parent, 7);
expect(Array.from(a)).toEqual(Array.from(b));
});
it("different indices yield different children", { timeout: 5000 }, () => {
const a = deriveChildKey(parent, 0);
const b = deriveChildKey(parent, 1);
expect(Array.from(a)).not.toEqual(Array.from(b));
});
it("rejects negative index", { timeout: 5000 }, () => {
try {
deriveChildKey(parent, -1);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["HD.INVALID_INDEX"]
);
}
});
it("rejects non-integer index", { timeout: 5000 }, () => {
try {
deriveChildKey(parent, 1.5);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["HD.INVALID_INDEX"]
);
}
});
it("rejects index above 32-bit range", { timeout: 5000 }, () => {
try {
deriveChildKey(parent, 0x1_0000_0000);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["HD.INVALID_INDEX"]
);
}
});
it("encodes the index in big-endian", { timeout: 5000 }, () => {
// Encoding two adjacent uint32 values produces different children.
const c1 = deriveChildKey(parent, 0x01020304);
const c2 = deriveChildKey(parent, 0x04030201);
expect(Array.from(c1)).not.toEqual(Array.from(c2));
});
});

View File

@@ -0,0 +1,124 @@
import {
mnemonicToSeedSync,
validateMnemonic as scureValidateMnemonic,
} from "@scure/bip39";
import { wordlist as englishWordlist } from "@scure/bip39/wordlists/english";
import { hmac } from "@suaveplan/crypto-hash";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
/** Length of the BIP39 master key (bytes). */
export const MASTER_KEY_LENGTH = 32;
/** Length of the BIP39 seed (bytes). */
export const BIP39_SEED_LENGTH = 64;
/** Length of a derived child key (bytes). */
export const CHILD_KEY_LENGTH = 32;
/**
* Derive a 32-byte master key from a BIP39 mnemonic plus optional passphrase.
*
* The mnemonic is first run through BIP39's PBKDF2-HMAC-SHA512 (2048 rounds,
* salt = `"mnemonic" + passphrase`) producing a 64-byte seed. The first 32
* bytes of that seed are returned as the master key — sufficient for any
* downstream HKDF tree.
*
* @throws CryptoVaultError(CRYPTOVAULT.HD.INVALID_MNEMONIC) when the
* mnemonic is empty or fails BIP39 word-list / checksum validation.
*/
export function deriveMasterKeyFromMnemonic(
mnemonic: string,
passphrase?: string
): Uint8Array {
if (typeof mnemonic !== "string" || mnemonic.trim() === "") {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["HD.INVALID_MNEMONIC"],
"error.crypto-vault.hd.invalid-mnemonic",
{ context: { reason: "empty" } }
);
}
// Eagerly validate the mnemonic so we surface a CryptoVaultError, not a
// raw upstream exception with a different shape.
assertValidMnemonic(mnemonic);
const seed = mnemonicToSeedSync(mnemonic, passphrase ?? "");
return seed.slice(0, MASTER_KEY_LENGTH);
}
/**
* Derive the full 64-byte BIP39 seed from a mnemonic + passphrase. Useful
* for callers that need both halves (e.g. BIP32 chain code in the trailing
* 32 bytes for compatibility with non-Suaveplan wallets).
*
* @throws CryptoVaultError(CRYPTOVAULT.HD.INVALID_MNEMONIC) on any invalid
* mnemonic.
*/
export function deriveSeedFromMnemonic(
mnemonic: string,
passphrase?: string
): Uint8Array {
if (typeof mnemonic !== "string" || mnemonic.trim() === "") {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["HD.INVALID_MNEMONIC"],
"error.crypto-vault.hd.invalid-mnemonic",
{ context: { reason: "empty" } }
);
}
assertValidMnemonic(mnemonic);
return mnemonicToSeedSync(mnemonic, passphrase ?? "");
}
/**
* Derive an indexed child key from a parent key. The derivation uses
* `HMAC-SHA512(parent, uint32-be(index))` and returns the first 32 bytes —
* functionally equivalent to a flattened BIP32 path with a single index
* level.
*
* @throws CryptoVaultError(CRYPTOVAULT.HD.INVALID_INDEX) when `index` is not
* a non-negative 32-bit integer.
*/
export function deriveChildKey(
parentKey: Uint8Array,
index: number
): Uint8Array {
if (!Number.isInteger(index) || index < 0 || index > 0xff_ff_ff_ff) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["HD.INVALID_INDEX"],
"error.crypto-vault.hd.invalid-index",
{ context: { index } }
);
}
const idxBuf = new Uint8Array(4);
idxBuf[0] = (index >>> 24) & 0xff;
idxBuf[1] = (index >>> 16) & 0xff;
idxBuf[2] = (index >>> 8) & 0xff;
idxBuf[3] = index & 0xff;
const out = hmac("SHA-512", parentKey, idxBuf);
return out.slice(0, CHILD_KEY_LENGTH);
}
function assertValidMnemonic(mnemonic: string): void {
const words = mnemonic.trim().split(/\s+/);
const valid = [12, 15, 18, 21, 24].includes(words.length);
if (!valid) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["HD.INVALID_MNEMONIC"],
"error.crypto-vault.hd.invalid-mnemonic",
{ context: { reason: "word-count", count: words.length } }
);
}
for (const w of words) {
if (!englishWordlist.includes(w)) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["HD.INVALID_MNEMONIC"],
"error.crypto-vault.hd.invalid-mnemonic",
{ context: { reason: "unknown-word", word: w } }
);
}
}
if (!scureValidateMnemonic(mnemonic, englishWordlist)) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["HD.INVALID_MNEMONIC"],
"error.crypto-vault.hd.invalid-mnemonic",
{ context: { reason: "checksum-failed" } }
);
}
}

View File

@@ -0,0 +1,119 @@
// Argon2id
export {
ARGON2ID_MAX_MEMORY_KB,
ARGON2ID_MAX_OUTPUT_LENGTH,
assertSafeArgon2idParams,
DEFAULT_ARGON2ID_PARAMS,
type DeriveKeyOptions,
deriveKeyWithArgon2id,
OWASP_MIN_ITERATIONS,
OWASP_MIN_MEMORY_KB,
OWASP_MIN_PARALLELISM,
} from "./argon2id/argon2id.js";
// BIP39
export {
BIP39_ENGLISH_WORDLIST,
type Bip39Strength,
entropyToMnemonic,
type GenerateMnemonicOptions,
generateMnemonic,
mnemonicToEntropy,
validateMnemonic,
} from "./bip39/bip39.js";
// Errors
export {
CRYPTO_VAULT_CODES,
type CryptoVaultCode,
CryptoVaultError,
} from "./errors/index.js";
// Handle
export {
isValidHandle,
type OpaqueKeyHandle,
unwrap,
wrap,
} from "./handle/opaque-handle.js";
// HD key
export {
BIP39_SEED_LENGTH,
CHILD_KEY_LENGTH,
deriveChildKey,
deriveMasterKeyFromMnemonic,
deriveSeedFromMnemonic,
MASTER_KEY_LENGTH,
} from "./hd-key/hd-key.js";
// Key hierarchy
export {
AUK_LENGTH,
deriveItemKey,
derivePersonalKeyset,
deriveVaultKey,
HIERARCHY_KEY_LENGTH,
} from "./key-hierarchy/key-hierarchy.js";
// Orchestration
export {
type CreateRootKeysOptions,
type CreateServerKeysOptions,
createRootKeys,
createServerKeys,
getDataFromStorage,
type LayoutV1Fields,
openVault,
type PackVaultOptions,
packVault,
type RollServerKeysOptions,
type RootKeysResult,
rollServerKeys,
type ServerKeysResult,
savePublicKeysToStorage,
saveVaultToStorage,
type VaultEncryptionOptions,
viewPublicKeys,
} from "./orchestration/index.js";
// OTel
export {
type CryptoVaultInstruments,
getCryptoVaultInstruments,
resetCryptoVaultInstruments,
setCryptoVaultMeterProvider,
} from "./otel/instruments.js";
// Schemas
export {
Argon2idParamsSchema,
AukSchema,
Bip39StrengthSchema,
HierarchyKeySchema,
PersonalKeysetSchema,
} from "./schemas/schemas.js";
// Secure
export {
liveSecureBufferCount,
pruneSecureBufferRegistry,
SecureBuffer,
type SecureBufferOptions,
zeroizeAll,
} from "./secure/secure-buffer.js";
// SRP
export {
type EphemeralKeypair,
type SessionKeyResult,
SrpClient,
type SrpClientOptions,
} from "./srp/srp-client.js";
export {
SRP_G,
SRP_N_2048,
SRP_N_BITLEN,
SRP_N_BYTELEN,
} from "./srp/srp-group.js";
// 2SKD
export { deriveAuk } from "./twoskd/twoskd.js";
// Types
export type {
Argon2idParams,
Auk,
CryptoVaultOptions,
ItemKey,
PersonalKeyset,
VaultKey,
} from "./types/types.js";

View File

@@ -0,0 +1,114 @@
# Hierarchical key derivation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`key-hierarchy.ts` fans the AUK out into a deterministic tree of keys —
personal keyset → vault key → item key — using HKDF-SHA256 with versioned
info labels. The hierarchy makes targeted rotation cheap: rotating a vault
key only invalidates that vault's items; rotating an item key only
invalidates that item. The AUK never leaves `crypto-vault`'s memory
boundary; only the derived material is handed downstream.
## Features
- `derivePersonalKeyset(auk)` — three pairwise-distinct 32-byte keys
(`encryptKey`, `signKey`, `kxKey`).
- `deriveVaultKey(personalKeyset, vaultId)` — 32-byte vault-scoped key.
- `deriveItemKey(vaultKey, itemId)` — 32-byte item-scoped key.
- All info labels are prefixed with `v1/` so future hierarchy revisions can
fork without breaking existing vaults.
- Strict input validation: empty `vaultId` / `itemId` and wrong-length AUK
raise typed `CryptoVaultError`s.
## Usage: Basic Example
```ts
import { deriveAuk } from "@suaveplan/crypto-vault/twoskd/twoskd";
import {
deriveItemKey,
derivePersonalKeyset,
deriveVaultKey,
} from "@suaveplan/crypto-vault/key-hierarchy/key-hierarchy";
const salt = crypto.getRandomValues(new Uint8Array(16));
const auk = await deriveAuk("pw", "sk", "alice@example.com", salt);
const personal = derivePersonalKeyset(auk);
const vaultKey = deriveVaultKey(personal, "personal-vault");
const itemKey = deriveItemKey(vaultKey, "login-bank-2026");
console.log(itemKey.length); // 32
```
## Usage: Advanced Example
```ts
import {
deriveItemKey,
derivePersonalKeyset,
deriveVaultKey,
} from "@suaveplan/crypto-vault/key-hierarchy/key-hierarchy";
// Rotate a single vault key without invalidating any item key — the new
// vault key is independent of the old one's exact value because both are
// HKDF-derived from the (stable) personal keyset with different info.
function rotateVaultKey(
personalKeyset: ReturnType<typeof derivePersonalKeyset>,
oldVaultId: string,
newVaultId: string
) {
const oldVaultKey = deriveVaultKey(personalKeyset, oldVaultId);
const newVaultKey = deriveVaultKey(personalKeyset, newVaultId);
// Re-encrypt only the vault header with newVaultKey.
return { oldVaultKey, newVaultKey };
}
// Item keys can rotate by including a counter in itemId.
const personal = /* … */ derivePersonalKeyset(new Uint8Array(32));
const vaultKey = deriveVaultKey(personal, "vault-1");
const v1 = deriveItemKey(vaultKey, "login-bank/v1");
const v2 = deriveItemKey(vaultKey, "login-bank/v2");
console.log(Buffer.from(v1).equals(Buffer.from(v2))); // false
```
## API Reference
### `derivePersonalKeyset(auk: Auk): PersonalKeyset`
Returns `{ encryptKey, signKey, kxKey }` — three distinct 32-byte arrays
derived via HKDF-Expand from the AUK with info labels
`v1/personal/encrypt`, `v1/personal/sign`, `v1/personal/kx`.
Throws `CryptoVaultError(CRYPTOVAULT.HIERARCHY.INVALID_AUK_LENGTH)` when
`auk.length !== 32`.
### `deriveVaultKey(personalKeyset, vaultId): VaultKey`
Derives a 32-byte vault key via HKDF-SHA256 over `personalKeyset.encryptKey`
with info `v1/vault/<vaultId>`. Throws
`CryptoVaultError(CRYPTOVAULT.HIERARCHY.EMPTY_VAULT_ID)` for empty input.
### `deriveItemKey(vaultKey, itemId): ItemKey`
Derives a 32-byte item key via HKDF-SHA256 over the supplied vault key with
info `v1/item/<itemId>`. Throws
`CryptoVaultError(CRYPTOVAULT.HIERARCHY.EMPTY_ITEM_ID)` for empty input.
### Constants
- `AUK_LENGTH = 32`, `HIERARCHY_KEY_LENGTH = 32`.
## Implementation Notes
- HKDF salt is intentionally empty: the AUK is already a uniformly random
cryptographic key, so HKDF-Extract over an empty salt is no weaker than
using a random salt and saves one allocation per derivation.
- Info labels are version-prefixed (`v1/...`). A future hierarchy revision
would bump the prefix and treat `v1/` material as legacy.
- The personal keyset's `encryptKey` is used as the IKM for vault keys.
This narrows the cross-vault blast radius if the encrypt key leaks: the
`signKey` and `kxKey` slots are unaffected.
- All three derivations are O(1) — no hashing of vault contents, no
iteration. Per-item key derivation completes in ~10 µs on modern
hardware.

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import {
AUK_LENGTH,
deriveItemKey,
derivePersonalKeyset,
deriveVaultKey,
HIERARCHY_KEY_LENGTH,
} from "./key-hierarchy.js";
const auk = new Uint8Array(AUK_LENGTH).fill(0x42);
describe("derivePersonalKeyset", () => {
it("derives three distinct keys in personal keyset", {
timeout: 5000,
}, () => {
const ks = derivePersonalKeyset(auk);
expect(ks.encryptKey.length).toBe(HIERARCHY_KEY_LENGTH);
expect(ks.signKey.length).toBe(HIERARCHY_KEY_LENGTH);
expect(ks.kxKey.length).toBe(HIERARCHY_KEY_LENGTH);
expect(Array.from(ks.encryptKey)).not.toEqual(Array.from(ks.signKey));
expect(Array.from(ks.encryptKey)).not.toEqual(Array.from(ks.kxKey));
expect(Array.from(ks.signKey)).not.toEqual(Array.from(ks.kxKey));
});
it("deterministic personal keyset for same AUK", { timeout: 5000 }, () => {
const a = derivePersonalKeyset(auk);
const b = derivePersonalKeyset(auk);
expect(Array.from(a.encryptKey)).toEqual(Array.from(b.encryptKey));
expect(Array.from(a.signKey)).toEqual(Array.from(b.signKey));
expect(Array.from(a.kxKey)).toEqual(Array.from(b.kxKey));
});
it("different AUKs yield different keysets", { timeout: 5000 }, () => {
const ks1 = derivePersonalKeyset(auk);
const ks2 = derivePersonalKeyset(new Uint8Array(AUK_LENGTH).fill(0x99));
expect(Array.from(ks1.encryptKey)).not.toEqual(
Array.from(ks2.encryptKey)
);
});
it("rejects non-32-byte AUK", { timeout: 5000 }, () => {
for (const length of [0, 1, 16, 31, 33, 64]) {
try {
derivePersonalKeyset(new Uint8Array(length));
expect.unreachable(`length ${length}`);
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["HIERARCHY.INVALID_AUK_LENGTH"]
);
}
}
});
});
describe("deriveVaultKey", () => {
const ks = derivePersonalKeyset(auk);
it("deterministic vault key for same vaultId", { timeout: 5000 }, () => {
const a = deriveVaultKey(ks, "vault-1");
const b = deriveVaultKey(ks, "vault-1");
expect(Array.from(a)).toEqual(Array.from(b));
expect(a.length).toBe(HIERARCHY_KEY_LENGTH);
});
it("different vaultId yields different vault key", {
timeout: 5000,
}, () => {
const a = deriveVaultKey(ks, "vault-1");
const b = deriveVaultKey(ks, "vault-2");
expect(Array.from(a)).not.toEqual(Array.from(b));
});
it("rejects empty vaultId", { timeout: 5000 }, () => {
try {
deriveVaultKey(ks, "");
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["HIERARCHY.EMPTY_VAULT_ID"]
);
}
});
it("rejects whitespace-only vaultId", { timeout: 5000 }, () => {
try {
deriveVaultKey(ks, " ");
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["HIERARCHY.EMPTY_VAULT_ID"]
);
}
});
});
describe("deriveItemKey", () => {
const ks = derivePersonalKeyset(auk);
const vaultKey = deriveVaultKey(ks, "vault-1");
it("deterministic item key for same itemId", { timeout: 5000 }, () => {
const a = deriveItemKey(vaultKey, "item-1");
const b = deriveItemKey(vaultKey, "item-1");
expect(Array.from(a)).toEqual(Array.from(b));
expect(a.length).toBe(HIERARCHY_KEY_LENGTH);
});
it("different itemId yields different item key", { timeout: 5000 }, () => {
const a = deriveItemKey(vaultKey, "item-1");
const b = deriveItemKey(vaultKey, "item-2");
expect(Array.from(a)).not.toEqual(Array.from(b));
});
it("rejects empty itemId", { timeout: 5000 }, () => {
try {
deriveItemKey(vaultKey, "");
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["HIERARCHY.EMPTY_ITEM_ID"]
);
}
});
it("rejects whitespace-only itemId", { timeout: 5000 }, () => {
try {
deriveItemKey(vaultKey, " ");
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["HIERARCHY.EMPTY_ITEM_ID"]
);
}
});
it("vault rotation isolates item keys to their parent vault", {
timeout: 5000,
}, () => {
const v1 = deriveVaultKey(ks, "v1");
const v2 = deriveVaultKey(ks, "v2");
const i1 = deriveItemKey(v1, "item-x");
const i2 = deriveItemKey(v2, "item-x");
expect(Array.from(i1)).not.toEqual(Array.from(i2));
});
});

View File

@@ -0,0 +1,115 @@
import { hkdf } from "@suaveplan/crypto-hash";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import { getCryptoVaultInstruments } from "../otel/instruments.js";
import type { Auk, ItemKey, PersonalKeyset, VaultKey } from "../types/types.js";
/** Length (bytes) of an AUK. */
export const AUK_LENGTH = 32;
/** Length (bytes) of every leaf key in the hierarchy. */
export const HIERARCHY_KEY_LENGTH = 32;
const ENC = new TextEncoder();
const EMPTY_SALT = new Uint8Array(0);
/** HKDF info labels — versioned so future hierarchy changes are non-breaking. */
const INFO_PERSONAL_ENCRYPT = ENC.encode("v1/personal/encrypt");
const INFO_PERSONAL_SIGN = ENC.encode("v1/personal/sign");
const INFO_PERSONAL_KX = ENC.encode("v1/personal/kx");
function recordHkdfTiming(start: number): void {
getCryptoVaultInstruments().hkdfDurationMs.record(Date.now() - start);
}
/**
* Derive the long-lived personal keyset (encrypt / sign / key-exchange) from
* the AUK. Each key is HKDF-Expand-derived with a unique versioned label so
* the three slots are guaranteed to differ even though they share an input.
*
* @throws CryptoVaultError(CRYPTOVAULT.HIERARCHY.INVALID_AUK_LENGTH) when
* the AUK is not exactly {@link AUK_LENGTH} bytes long.
*/
export function derivePersonalKeyset(auk: Auk): PersonalKeyset {
if (auk.length !== AUK_LENGTH) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["HIERARCHY.INVALID_AUK_LENGTH"],
"error.crypto-vault.hierarchy.invalid-auk-length",
{ context: { length: auk.length, expected: AUK_LENGTH } }
);
}
const start = Date.now();
const encryptKey = hkdf("SHA-256", auk, {
salt: EMPTY_SALT,
info: INFO_PERSONAL_ENCRYPT,
length: HIERARCHY_KEY_LENGTH,
});
const signKey = hkdf("SHA-256", auk, {
salt: EMPTY_SALT,
info: INFO_PERSONAL_SIGN,
length: HIERARCHY_KEY_LENGTH,
});
const kxKey = hkdf("SHA-256", auk, {
salt: EMPTY_SALT,
info: INFO_PERSONAL_KX,
length: HIERARCHY_KEY_LENGTH,
});
recordHkdfTiming(start);
return { encryptKey, signKey, kxKey };
}
/**
* Derive a vault-specific 32-byte key from a personal keyset and a vault id.
*
* The personal keyset's `encryptKey` is used as the IKM; the vault id is
* encoded as part of the HKDF info so two different ids never produce the
* same vault key.
*
* @throws CryptoVaultError(CRYPTOVAULT.HIERARCHY.EMPTY_VAULT_ID) when
* `vaultId` is empty after trimming.
*/
export function deriveVaultKey(
personalKeyset: PersonalKeyset,
vaultId: string
): VaultKey {
if (typeof vaultId !== "string" || vaultId.trim() === "") {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["HIERARCHY.EMPTY_VAULT_ID"],
"error.crypto-vault.hierarchy.empty-vault-id"
);
}
const info = ENC.encode(`v1/vault/${vaultId}`);
const start = Date.now();
const out = hkdf("SHA-256", personalKeyset.encryptKey, {
salt: EMPTY_SALT,
info,
length: HIERARCHY_KEY_LENGTH,
});
recordHkdfTiming(start);
return out;
}
/**
* Derive a per-item key from a vault key and an item id. Itemid uniqueness
* within the vault is the caller's responsibility; the function is
* deterministic for any (vaultKey, itemId) pair.
*
* @throws CryptoVaultError(CRYPTOVAULT.HIERARCHY.EMPTY_ITEM_ID) when
* `itemId` is empty after trimming.
*/
export function deriveItemKey(vaultKey: VaultKey, itemId: string): ItemKey {
if (typeof itemId !== "string" || itemId.trim() === "") {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["HIERARCHY.EMPTY_ITEM_ID"],
"error.crypto-vault.hierarchy.empty-item-id"
);
}
const info = ENC.encode(`v1/item/${itemId}`);
const start = Date.now();
const out = hkdf("SHA-256", vaultKey, {
salt: EMPTY_SALT,
info,
length: HIERARCHY_KEY_LENGTH,
});
recordHkdfTiming(start);
return out;
}

View File

@@ -0,0 +1,21 @@
export {
createRootKeys,
createServerKeys,
getDataFromStorage,
openVault,
packVault,
rollServerKeys,
savePublicKeysToStorage,
saveVaultToStorage,
viewPublicKeys,
} from "./orchestration.js";
export type {
CreateRootKeysOptions,
CreateServerKeysOptions,
LayoutV1Fields,
PackVaultOptions,
RollServerKeysOptions,
RootKeysResult,
ServerKeysResult,
VaultEncryptionOptions,
} from "./orchestration.types.js";

View File

@@ -0,0 +1,88 @@
# internals
Internal building blocks for the orchestration surface.
## Purpose
`internals` is a private implementation seam: it holds the lower-level helpers
that `orchestration.ts` composes (vault sealing, vault opening, Ed25519 sign +
append, public-key-file projection). Splitting these helpers into a sibling
module keeps `orchestration.ts` under the file-size budget without polluting
the public barrel — none of the symbols here are re-exported from
`src/orchestration/index.ts` or `src/index.ts`.
This file is documented because every `.ts` in the package requires a sibling
`.md` (see `CLAUDE.md` §4); the docs treat it as an implementation note rather
than a public contract.
## Features
- `sealVault(plaintext, password, params)` — Argon2id KEK derivation followed
by XChaCha20-Poly1305 AEAD encryption; emits the canonical wire layout
`salt(16) || nonce(24) || ciphertext`.
- `openVaultBytes(vaultBytes, password)` — inverse of `sealVault`. Always uses
`DEFAULT_ARGON2ID_PARAMS`. Collapses short input, wrong password, and
ciphertext tamper into a single `ORCHESTRATION.DECRYPT_FAILED` error.
- `buildPublicKeyFile(keychain, signingSecretKey)` — projects the keychain to
its public-only form, encodes it, and appends an Ed25519 signature.
- Re-exported header byte-size constants (`SALT_BYTES`, `NONCE_BYTES`,
`SIGNATURE_BYTES`, `VAULT_HEADER_BYTES`) for use in the public layer.
## Usage: Basic
```ts
// internals are package-private — consumers go through @suaveplan/crypto-vault
import { sealVault } from "./internals.js";
import { DEFAULT_ARGON2ID_PARAMS } from "../argon2id/argon2id.js";
const bytes = await sealVault(
new TextEncoder().encode("payload"),
"pw",
DEFAULT_ARGON2ID_PARAMS
);
```
## Usage: Advanced
```ts
// Compose with a custom Argon2id profile for a hardened on-disk artefact.
import { sealVault, openVaultBytes } from "./internals.js";
const hardened = {
memoryKB: 256 * 1024,
iterations: 4,
parallelism: 4,
outputLength: 32,
};
const wire = await sealVault(payload, "pw", hardened);
const back = await openVaultBytes(wire, "pw");
```
## API Reference
- `sealVault(plaintext: Uint8Array, password: string, params: Argon2idParams)`
→ `Promise<Uint8Array>`. The wire bytes are exactly
`salt(16) || nonce(24) || ciphertext`. Salt and nonce are drawn from
`crypto.getRandomValues`. The function does not modify `plaintext`.
- `openVaultBytes(vaultBytes: Uint8Array, password: string)`
→ `Promise<Uint8Array>`. Throws
`CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.DECRYPT_FAILED)` for any failure.
- `buildPublicKeyFile(keychain, signingSecretKey)` →
`{ publicKeyBytes, publicKeys }`. The signature is appended; the returned
`publicKeys` is the same in-memory projection produced by `extractPublicKeys`.
- `SALT_BYTES = 16`, `NONCE_BYTES = 24`, `SIGNATURE_BYTES = 64`,
`VAULT_HEADER_BYTES = SALT_BYTES + NONCE_BYTES`.
## Implementation Notes
- `openVaultBytes` always re-derives the KEK with `DEFAULT_ARGON2ID_PARAMS`
rather than reading params from a header. This keeps the wire format flat at
the cost of locking the on-disk profile to the package default; a future
versioned header is the migration path.
- Decryption failure is intentionally indistinguishable from a short-input or
tampered ciphertext failure — the same error code surfaces in all three
cases. Tests verify the property.
- The signing helper takes a raw 32-byte Ed25519 secret key (a `KeySlotPrivate.secretKey`
field) rather than the full slot. This keeps the seam narrow and makes the
unit tests trivial to feed with a hand-rolled keypair.

View File

@@ -0,0 +1,140 @@
/**
* Internal helpers shared by the public {@link createRootKeys} /
* {@link createServerKeys} / {@link openVault} surface. These are NOT part of
* the package's public API — they are an implementation seam that keeps
* `orchestration.ts` under the file-size budget. Consumers always go through
* the top-level barrel.
*/
import { aeadDecrypt, aeadEncrypt } from "@suaveplan/crypto";
import {
encodePublicKeychain,
extractPublicKeys,
type Keychain,
type PublicKeychain,
} from "@suaveplan/crypto/keychain";
import { ed25519 } from "@suaveplan/crypto-curve";
import {
DEFAULT_ARGON2ID_PARAMS,
deriveKeyWithArgon2id,
} from "../argon2id/argon2id.js";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import type { Argon2idParams } from "../types/types.js";
import type { LayoutV1Fields } from "./orchestration.types.js";
/** Length of the Argon2id salt embedded in the vault header. */
export const SALT_BYTES = 16;
/** Length of the XChaCha20-Poly1305 nonce embedded in the vault header. */
export const NONCE_BYTES = 24;
/** Length of an Ed25519 signature appended to the public-key file. */
export const SIGNATURE_BYTES = 64;
/** Combined header size: salt (16) + nonce (24). */
export const VAULT_HEADER_BYTES = SALT_BYTES + NONCE_BYTES;
const PASSWORD_ENCODER = new TextEncoder();
function randomBytes(length: number): Uint8Array {
const out = new Uint8Array(length);
crypto.getRandomValues(out);
return out;
}
/**
* Encrypt `plaintext` under a password-derived KEK. Layout:
* `salt(16) || nonce(24) || ciphertext`. Both header values are drawn fresh
* so two encryptions of the same plaintext under the same password share no
* bytes.
*/
export async function sealVault(
plaintext: Uint8Array,
password: string,
params: Argon2idParams
): Promise<Uint8Array> {
const salt = randomBytes(SALT_BYTES);
const nonce = randomBytes(NONCE_BYTES);
const kek = await deriveKeyWithArgon2id(
PASSWORD_ENCODER.encode(password),
salt,
params
);
const ciphertext = aeadEncrypt("XChaCha20-Poly1305", kek, plaintext, {
nonce,
});
const out = new Uint8Array(VAULT_HEADER_BYTES + ciphertext.length);
out.set(salt, 0);
out.set(nonce, SALT_BYTES);
out.set(ciphertext, VAULT_HEADER_BYTES);
return out;
}
/**
* Inverse of {@link sealVault}. Any failure — short input, wrong password, or
* tampered ciphertext — surfaces as a single
* `CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.DECRYPT_FAILED)` so callers do
* not learn whether the password or the integrity tag was the failure.
*/
export async function openVaultBytes(
vaultBytes: Uint8Array,
password: string
): Promise<Uint8Array> {
if (vaultBytes.length <= VAULT_HEADER_BYTES) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ORCHESTRATION.DECRYPT_FAILED"],
"error.crypto-vault.orchestration.decrypt-failed",
{ context: { reason: "short-input", length: vaultBytes.length } }
);
}
const salt = vaultBytes.slice(0, SALT_BYTES);
const nonce = vaultBytes.slice(SALT_BYTES, VAULT_HEADER_BYTES);
const ciphertext = vaultBytes.slice(VAULT_HEADER_BYTES);
const kek = await deriveKeyWithArgon2id(
PASSWORD_ENCODER.encode(password),
salt,
DEFAULT_ARGON2ID_PARAMS
);
try {
return aeadDecrypt("XChaCha20-Poly1305", kek, ciphertext, { nonce });
} catch {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ORCHESTRATION.DECRYPT_FAILED"],
"error.crypto-vault.orchestration.decrypt-failed",
{ context: { reason: "aead-verify-failed" } }
);
}
}
/**
* Sign `data` with the supplied Ed25519 secret key and append the 64-byte
* signature, producing the canonical signed public-key file layout
* `data || signature`.
*/
function signAndAppend(
data: Uint8Array,
ed25519SecretKey: Uint8Array
): Uint8Array {
const signature = ed25519.sign(data, ed25519SecretKey);
const out = new Uint8Array(data.length + signature.length);
out.set(data, 0);
out.set(signature, data.length);
return out;
}
/**
* Project `keychain` to its public-only counterpart, encode, sign, and
* package both forms for callers.
*/
export function buildPublicKeyFile(
keychain: Keychain<LayoutV1Fields>,
signingSlotSecretKey: Uint8Array
): {
publicKeyBytes: Uint8Array;
publicKeys: PublicKeychain<LayoutV1Fields>;
} {
const publicKeys = extractPublicKeys(keychain);
const encoded = encodePublicKeychain(publicKeys);
return {
publicKeyBytes: signAndAppend(encoded, signingSlotSecretKey),
publicKeys,
};
}

View File

@@ -0,0 +1,146 @@
# orchestration
High-level orchestration helpers that bundle keychain generation, password-AEAD
encryption, signed public-key file production, and storage I/O behind a single
call surface.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/crypto-vault` already ships rich low-level primitives — BIP39
mnemonics, Argon2id KDF, hierarchical key derivation, SecureBuffer, SRP — but
no opinionated "create a root keychain plus encrypted vault plus signed
public-key file" entrypoint. The orchestration module fills that gap.
It composes `@suaveplan/crypto`'s `generateKeychain` / `encodeKeychain` /
`encodePublicKeychain` primitives with this package's
`deriveKeyWithArgon2id` KEK derivation and the Ed25519 signing surface from
`@suaveplan/crypto-curve`, producing the single-call surface used by
`@suaveplan/server-keys` operator bins (`create-root-keys`, `create-server-keys`,
`roll-server-keys`, `view-keys`, `view-vault`, `view-public-key-file`, etc.).
## Features
- One-call `createRootKeys` / `createServerKeys` / `rollServerKeys` producing
keychain + encrypted vault + signed public-key file in one trip.
- Deterministic server-key derivation: `(rootMaster, version)` reproduces the
same private material across hosts so rotation never loses key continuity.
- Versioned rotation via `rollServerKeys` with strict `version ≥ 0` validation.
- Round-trip `openVault` / `packVault` with constant-time wrong-password
rejection (no information leak about which of password / integrity failed).
- `viewPublicKeys` with optional Ed25519 signature verification.
- Idempotent storage helpers (`saveVaultToStorage`,
`savePublicKeysToStorage`, `getDataFromStorage`) that translate "missing key"
outcomes from any backend into a typed `CryptoVaultError`.
- suaveplan-native wire formats — explicitly NOT byte-compatible with the
upstream `@suaveplan/keys` BitPacker format; rotation is an offline migration.
## Usage: Basic
```ts
import {
createRootKeys,
openVault,
saveVaultToStorage,
} from "@suaveplan/crypto-vault";
import { MemoryStorage } from "@suaveplan/storage";
const storage = new MemoryStorage({ cleanupIntervalMs: 0 });
const root = await createRootKeys("correct horse battery staple");
await saveVaultToStorage(storage, "root.vault", root.vaultBytes);
const recovered = await openVault(root.vaultBytes, "correct horse battery staple");
// Eight default-purpose slots: "mast" (master), "sign", "encr", "kx ",
// "salt", "hash", "strm", "dev " — keyed by the 4-byte purpose tag.
console.log(recovered["mast"].publicKey.length); // 32
```
## Usage: Advanced
```ts
import {
createRootKeys,
createServerKeys,
rollServerKeys,
viewPublicKeys,
} from "@suaveplan/crypto-vault";
const root = await createRootKeys("root-pw");
// Provision a server keychain at version 1 (default)
const server1 = await createServerKeys(root, "server-pw");
// Roll the server keychain forward — deterministically derives a fresh master.
const server2 = await rollServerKeys(root, server1.version, "server-pw");
// Verify the server's signed public-key file using the root sign pubkey.
const decoded = await viewPublicKeys(
server2.publicKeyBytes,
root.keychain["sign"].publicKey
);
console.log(decoded["sign"].publicKey === server2.publicKeys["sign"].publicKey);
```
## API Reference
- `createRootKeys(password, options?)` → `Promise<RootKeysResult>`. Generates a
fresh root keychain via `generateKeychain`. `options.masterKey` lets callers
supply a pre-existing 32-byte seed; `options.layoutFields` overrides the
default v1 layout `{ masterVersion: 1, userId: 0, depth: 0, slotVersion: 1 }`;
`options.argon2id` overrides KDF parameters.
- `createServerKeys(rootKeys, serverPassword, options?)` →
`Promise<ServerKeysResult>`. Deterministically derives a child master via
`HMAC-SHA512(rootMaster, u32be(version))` truncated to 32 bytes. Throws
`CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.INVALID_VERSION)` on negative or
non-integer `version`. Public-key file is signed by the *root* sign slot.
- `rollServerKeys(rootKeys, currentVersion, serverPassword, options?)` →
`Promise<ServerKeysResult>`. Returns a result at `version = currentVersion + 1`.
Throws the same `INVALID_VERSION` error on negative `currentVersion`.
- `openVault(vaultBytes, password)` → `Promise<Keychain>`. Splits the wire
layout (`salt(16) || nonce(24) || ciphertext`), re-derives the KEK with
Argon2id, and decrypts. Throws
`CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.DECRYPT_FAILED)` on wrong
password, short input, or tampered ciphertext.
- `packVault(keychain, password, options?)` → `Promise<Uint8Array>`. Inverse of
`openVault`; reusable by callers that already hold a `Keychain`.
- `viewPublicKeys(publicKeyBytes, optionalSignKey?)` → `Promise<PublicKeychain>`.
Splits the trailing 64-byte Ed25519 signature, decodes the body, and (when
`optionalSignKey` is provided) verifies the signature. Throws
`CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID)` on
short input or signature mismatch.
- `saveVaultToStorage(storage, key, vaultBytes)` /
`savePublicKeysToStorage(storage, key, publicKeyBytes)` → `Promise<void>`.
Thin idempotent wrappers over `AbstractStorage.set`.
- `getDataFromStorage(storage, key)` → `Promise<Uint8Array>`. Translates a
missing key (either `undefined` return or a thrown backend error) into
`CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.STORAGE_KEY_NOT_FOUND)`.
## Implementation Notes
- The KEK is derived with `deriveKeyWithArgon2id` using
`DEFAULT_ARGON2ID_PARAMS` (OWASP-compliant: 64 MiB memory, 3 iterations,
parallelism 4) unless callers override via `options.argon2id`.
- Vault layout: 16-byte Argon2id salt, then a 24-byte XChaCha20-Poly1305
nonce, then ciphertext. Both header values are drawn from
`crypto.getRandomValues` so two encryptions of the same plaintext under the
same password share no bytes.
- Public-key file layout: `encodePublicKeychain(extractPublicKeys(keychain))`
followed by a 64-byte Ed25519 signature. For root keychains the keychain
self-signs; for server keychains the root's sign slot signs the child's
public bytes so a verifier holding only the root sign pubkey can attest the
full chain.
- `openVault` always uses `DEFAULT_ARGON2ID_PARAMS` to re-derive the KEK; the
salt is embedded in the vault header but the params themselves are not — the
trade-off keeps the format flat at the cost of locking the on-disk profile to
the package default. Future overrides will append a versioned header.
- `viewPublicKeys` collapses every signature failure into a single error code
to avoid leaking which check failed first; tests rely on that property when
asserting the conformance behaviour.
- Storage helpers do not introspect the value — they pass `Uint8Array` straight
through. Backends with their own size limits (e.g. `MemoryStorage`'s
`maxValueSize`) surface as their own typed error, which is intentional: the
vault layer should not silently mutate the backend's contract.

View File

@@ -0,0 +1,136 @@
/**
* Spec conformance for the orchestration module:
*
* 1. Every new orchestration-namespaced error code is raised by at least one
* production call path.
* 2. Every exported function signature in the orchestration barrel is the
* shape the spec requires (verified via expectTypeOf).
*/
import { MemoryStorage } from "@suaveplan/storage";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import {
type CreateRootKeysOptions,
type CreateServerKeysOptions,
createRootKeys,
createServerKeys,
getDataFromStorage,
openVault,
type PackVaultOptions,
packVault,
type RollServerKeysOptions,
type RootKeysResult,
rollServerKeys,
type ServerKeysResult,
savePublicKeysToStorage,
saveVaultToStorage,
viewPublicKeys,
} from "./index.js";
const NEW_CODES = [
CRYPTO_VAULT_CODES["ORCHESTRATION.DECRYPT_FAILED"],
CRYPTO_VAULT_CODES["ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID"],
CRYPTO_VAULT_CODES["ORCHESTRATION.STORAGE_KEY_NOT_FOUND"],
CRYPTO_VAULT_CODES["ORCHESTRATION.INVALID_VERSION"],
] as const;
describe("orchestration spec conformance — error codes", () => {
it("every new orchestration code is thrown from a production path", {
timeout: 60_000,
}, async () => {
const seen = new Set<string>();
const record = (err: unknown): void => {
if (err instanceof CryptoVaultError) seen.add(err.code);
};
// DECRYPT_FAILED
try {
await openVault(new Uint8Array(8), "pw");
} catch (e) {
record(e);
}
// PUBLIC_KEY_SIGNATURE_INVALID
try {
await viewPublicKeys(new Uint8Array(8));
} catch (e) {
record(e);
}
// STORAGE_KEY_NOT_FOUND
try {
await getDataFromStorage(
new MemoryStorage({ cleanupIntervalMs: 0 }),
"absent"
);
} catch (e) {
record(e);
}
// INVALID_VERSION
try {
await rollServerKeys({} as RootKeysResult, -1, "pw");
} catch (e) {
record(e);
}
const missing = NEW_CODES.filter((c) => !seen.has(c));
expect(missing).toEqual([]);
});
});
describe("orchestration spec conformance — type-level signatures", () => {
it("exports the spec-required function signatures", {
timeout: 5000,
}, () => {
expectTypeOf(createRootKeys).toBeFunction();
expectTypeOf(createRootKeys).parameter(0).toEqualTypeOf<string>();
expectTypeOf(createRootKeys)
.parameter(1)
.toEqualTypeOf<CreateRootKeysOptions | undefined>();
expectTypeOf(createRootKeys).returns.toEqualTypeOf<
Promise<RootKeysResult>
>();
expectTypeOf(createServerKeys).toBeFunction();
expectTypeOf(createServerKeys).parameter(1).toEqualTypeOf<string>();
expectTypeOf(createServerKeys)
.parameter(2)
.toEqualTypeOf<CreateServerKeysOptions | undefined>();
expectTypeOf(createServerKeys).returns.toEqualTypeOf<
Promise<ServerKeysResult>
>();
expectTypeOf(rollServerKeys).toBeFunction();
expectTypeOf(rollServerKeys).parameter(1).toEqualTypeOf<number>();
expectTypeOf(rollServerKeys)
.parameter(3)
.toEqualTypeOf<RollServerKeysOptions | undefined>();
expectTypeOf(openVault).toBeFunction();
expectTypeOf(openVault).parameter(0).toEqualTypeOf<Uint8Array>();
expectTypeOf(openVault).parameter(1).toEqualTypeOf<string>();
expectTypeOf(packVault).toBeFunction();
expectTypeOf(packVault)
.parameter(2)
.toEqualTypeOf<PackVaultOptions | undefined>();
expectTypeOf(packVault).returns.toEqualTypeOf<Promise<Uint8Array>>();
expectTypeOf(viewPublicKeys).toBeFunction();
expectTypeOf(viewPublicKeys)
.parameter(1)
.toEqualTypeOf<Uint8Array | undefined>();
expectTypeOf(saveVaultToStorage).toBeFunction();
expectTypeOf(saveVaultToStorage).returns.toEqualTypeOf<Promise<void>>();
expectTypeOf(savePublicKeysToStorage).toBeFunction();
expectTypeOf(savePublicKeysToStorage).returns.toEqualTypeOf<
Promise<void>
>();
expectTypeOf(getDataFromStorage).toBeFunction();
expectTypeOf(getDataFromStorage).returns.toEqualTypeOf<
Promise<Uint8Array>
>();
});
});

View File

@@ -0,0 +1,350 @@
import type {
Keychain,
KeySlotPrivate,
KeySlotPublic,
PublicKeychain,
} from "@suaveplan/crypto/keychain";
import { ed25519 } from "@suaveplan/crypto-curve";
import { MemoryStorage } from "@suaveplan/storage";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import {
createRootKeys,
createServerKeys,
getDataFromStorage,
type LayoutV1Fields,
openVault,
packVault,
rollServerKeys,
savePublicKeysToStorage,
saveVaultToStorage,
viewPublicKeys,
} from "./index.js";
function privSlot(
keychain: Keychain<LayoutV1Fields>,
name: "mast" | "sign"
): KeySlotPrivate<LayoutV1Fields> {
return (
keychain as unknown as Record<string, KeySlotPrivate<LayoutV1Fields>>
)[name] as KeySlotPrivate<LayoutV1Fields>;
}
function pubSlot(
keychain: PublicKeychain<LayoutV1Fields>,
name: "mast" | "sign"
): KeySlotPublic<LayoutV1Fields> {
return (
keychain as unknown as Record<string, KeySlotPublic<LayoutV1Fields>>
)[name] as KeySlotPublic<LayoutV1Fields>;
}
function newStorage(): MemoryStorage {
return new MemoryStorage({ cleanupIntervalMs: 0 });
}
describe("createRootKeys", () => {
it("createRootKeys output round-trips through openVault", {
timeout: 30_000,
}, async () => {
const result = await createRootKeys("hunter2");
const recovered = await openVault(result.vaultBytes, "hunter2");
// master slot is symmetric (HKDF) so its publicKey is the all-zero
// placeholder; we round-trip the secret bytes instead.
expect(privSlot(recovered, "mast").secretKey).toEqual(
privSlot(result.keychain, "mast").secretKey
);
expect(privSlot(recovered, "sign").publicKey).toEqual(
privSlot(result.keychain, "sign").publicKey
);
expect(privSlot(recovered, "sign").secretKey).toEqual(
privSlot(result.keychain, "sign").secretKey
);
});
it("produces a signed public-key file with the keychain's sign slot", {
timeout: 30_000,
}, async () => {
const result = await createRootKeys("pw");
const decoded = await viewPublicKeys(
result.publicKeyBytes,
privSlot(result.keychain, "sign").publicKey
);
expect(pubSlot(decoded, "sign").publicKey).toEqual(
pubSlot(result.publicKeys, "sign").publicKey
);
});
});
describe("createServerKeys", () => {
it("createServerKeys deterministically derives from root master", {
timeout: 60_000,
}, async () => {
const root = await createRootKeys("root-pw");
const a = await createServerKeys(root, "server-pw");
const b = await createServerKeys(root, "server-pw");
// Sign-slot publicKey diverges per-master (Ed25519). Comparing the
// master slot's publicKey is a tautology (always all-zero placeholder);
// we additionally compare the master slot's secret bytes.
expect(pubSlot(a.publicKeys, "sign").publicKey).toEqual(
pubSlot(b.publicKeys, "sign").publicKey
);
expect(privSlot(a.keychain, "mast").secretKey).toEqual(
privSlot(b.keychain, "mast").secretKey
);
expect(a.version).toBe(1);
});
it("rejects negative version on createServerKeys", {
timeout: 30_000,
}, async () => {
const root = await createRootKeys("pw");
await expect(
createServerKeys(root, "s", { version: -2 })
).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES["ORCHESTRATION.INVALID_VERSION"],
});
});
it("signs server public-key file with root sign slot", {
timeout: 60_000,
}, async () => {
const root = await createRootKeys("pw");
const server = await createServerKeys(root, "spw");
const decoded = await viewPublicKeys(
server.publicKeyBytes,
privSlot(root.keychain, "sign").publicKey
);
expect(pubSlot(decoded, "sign").publicKey).toEqual(
pubSlot(server.publicKeys, "sign").publicKey
);
});
});
describe("rollServerKeys", () => {
it("rollServerKeys increments version", {
timeout: 60_000,
}, async () => {
const root = await createRootKeys("pw");
const at3 = await createServerKeys(root, "spw", { version: 3 });
const rolled = await rollServerKeys(root, 3, "spw");
expect(rolled.version).toBe(4);
// Master slot is HKDF (no public material). Compare the sign slot,
// which is Ed25519 and carries real public-key bytes that diverge
// across versions.
expect(pubSlot(rolled.publicKeys, "sign").publicKey).not.toEqual(
pubSlot(at3.publicKeys, "sign").publicKey
);
});
it("rollServerKeys rejects negative currentVersion", {
timeout: 30_000,
}, async () => {
const root = await createRootKeys("pw");
await expect(rollServerKeys(root, -1, "pw")).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES["ORCHESTRATION.INVALID_VERSION"],
});
});
it("rollServerKeys rejects non-integer currentVersion", {
timeout: 30_000,
}, async () => {
const root = await createRootKeys("pw");
await expect(rollServerKeys(root, 1.5, "pw")).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES["ORCHESTRATION.INVALID_VERSION"],
});
});
});
describe("openVault and packVault", () => {
it("openVault and packVault round-trip", { timeout: 60_000 }, async () => {
const initial = await createRootKeys("pw");
const repacked = await packVault(initial.keychain, "pw2");
const recovered = await openVault(repacked, "pw2");
expect(privSlot(recovered, "mast").secretKey).toEqual(
privSlot(initial.keychain, "mast").secretKey
);
expect(privSlot(recovered, "sign").publicKey).toEqual(
privSlot(initial.keychain, "sign").publicKey
);
});
it("openVault rejects wrong password", { timeout: 60_000 }, async () => {
const initial = await createRootKeys("right");
await expect(
openVault(initial.vaultBytes, "wrong")
).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES["ORCHESTRATION.DECRYPT_FAILED"],
});
});
it("openVault rejects tampered bytes", {
timeout: 60_000,
}, async () => {
const initial = await createRootKeys("pw");
const tampered = new Uint8Array(initial.vaultBytes);
// Flip a byte inside the ciphertext (past the 40-byte header).
tampered[tampered.length - 1] ^= 0x01;
await expect(openVault(tampered, "pw")).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES["ORCHESTRATION.DECRYPT_FAILED"],
});
});
it("openVault rejects short input", {
timeout: 30_000,
}, async () => {
await expect(openVault(new Uint8Array(8), "pw")).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES["ORCHESTRATION.DECRYPT_FAILED"],
});
});
});
describe("viewPublicKeys", () => {
it("viewPublicKeys verifies signature when sign key supplied", {
timeout: 30_000,
}, async () => {
const result = await createRootKeys("pw");
const decoded = await viewPublicKeys(
result.publicKeyBytes,
privSlot(result.keychain, "sign").publicKey
);
expect(pubSlot(decoded, "sign").publicKey).toEqual(
pubSlot(result.publicKeys, "sign").publicKey
);
});
it("viewPublicKeys rejects wrong sign key", {
timeout: 30_000,
}, async () => {
const result = await createRootKeys("pw");
const otherSecret = ed25519.randomPrivateKey();
const wrongPub = ed25519.getPublicKey(otherSecret);
await expect(
viewPublicKeys(result.publicKeyBytes, wrongPub)
).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES[
"ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID"
],
});
});
it("viewPublicKeys without sign key decodes without verification", {
timeout: 30_000,
}, async () => {
const result = await createRootKeys("pw");
const decoded = await viewPublicKeys(result.publicKeyBytes);
expect(pubSlot(decoded, "sign").publicKey).toEqual(
pubSlot(result.publicKeys, "sign").publicKey
);
});
it("viewPublicKeys rejects short input", {
timeout: 5000,
}, async () => {
await expect(viewPublicKeys(new Uint8Array(8))).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES[
"ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID"
],
});
});
});
describe("storage helpers", () => {
it("saveVaultToStorage + getDataFromStorage round-trip", {
timeout: 30_000,
}, async () => {
const storage = newStorage();
const bytes = new Uint8Array([1, 2, 3, 4, 5]);
await saveVaultToStorage(storage, "k", bytes);
const out = await getDataFromStorage(storage, "k");
expect(out).toEqual(bytes);
});
it("getDataFromStorage raises CRYPTO_VAULT_STORAGE_KEY_NOT_FOUND on missing key", {
timeout: 5000,
}, async () => {
const storage = newStorage();
await expect(
getDataFromStorage(storage, "absent")
).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES["ORCHESTRATION.STORAGE_KEY_NOT_FOUND"],
});
});
it("getDataFromStorage translates backend throw to typed error", {
timeout: 5000,
}, async () => {
const storage = {
get: async () => {
throw new Error("backend explode");
},
} as unknown as MemoryStorage;
await expect(getDataFromStorage(storage, "k")).rejects.toBeInstanceOf(
CryptoVaultError
);
});
it("saveVaultToStorage is idempotent", { timeout: 5000 }, async () => {
const storage = newStorage();
const bytes = new Uint8Array([7, 8, 9]);
await saveVaultToStorage(storage, "k", bytes);
await saveVaultToStorage(storage, "k", bytes);
const out = await getDataFromStorage(storage, "k");
expect(out).toEqual(bytes);
});
it("savePublicKeysToStorage round-trips", { timeout: 5000 }, async () => {
const storage = newStorage();
const bytes = new Uint8Array([10, 11, 12]);
await savePublicKeysToStorage(storage, "p", bytes);
const out = await getDataFromStorage(storage, "p");
expect(out).toEqual(bytes);
});
});
describe("options coverage", () => {
it("createRootKeys accepts an explicit masterKey", {
timeout: 30_000,
}, async () => {
const masterKey = new Uint8Array(32).fill(7);
const a = await createRootKeys("pw", { masterKey });
const b = await createRootKeys("pw", { masterKey });
expect(pubSlot(a.publicKeys, "sign").publicKey).toEqual(
pubSlot(b.publicKeys, "sign").publicKey
);
});
it("createRootKeys accepts an explicit clock", {
timeout: 30_000,
}, async () => {
const fixed = 1_700_000_000_000;
const result = await createRootKeys("pw", { clock: () => fixed });
expect(privSlot(result.keychain, "mast").notBefore).toBe(fixed);
});
it("createRootKeys accepts layoutFields override", {
timeout: 30_000,
}, async () => {
const layout = {
masterVersion: 7,
userId: 9,
depth: 2,
slotVersion: 3,
};
const result = await createRootKeys("pw", { layoutFields: layout });
expect(
privSlot(result.keychain, "mast").coordinate.layoutFields
).toMatchObject(layout);
});
it("createServerKeys accepts userId option", {
timeout: 60_000,
}, async () => {
const root = await createRootKeys("pw");
const server = await createServerKeys(root, "spw", { userId: 42 });
expect(
privSlot(server.keychain, "mast").coordinate.layoutFields.userId
).toBe(42);
});
});

View File

@@ -0,0 +1,279 @@
/**
* High-level orchestration helpers for `@suaveplan/crypto-vault`.
*
* These functions compose the package's own Argon2id KEK derivation with the
* primitives exposed by `@suaveplan/crypto` (keychain generation, public-key
* encoding) and `@suaveplan/crypto-curve` (Ed25519 signing) to produce the
* single-call surface that `@suaveplan/server-keys` operator bins rely on.
*
* Wire formats are genesis-native (NOT byte-compatible with the upstream
* `@suaveplan/keys` BitPacker-based format). A migration command from old to
* new lives in a separate package (`@suaveplan/keys-migration`, follow-up).
*/
import {
decodeKeychain,
decodePublicKeychain,
encodeKeychain,
generateKeychain,
type Keychain,
type PublicKeychain,
} from "@suaveplan/crypto/keychain";
import { ed25519 } from "@suaveplan/crypto-curve";
import type { AbstractStorage } from "@suaveplan/storage";
import { DEFAULT_ARGON2ID_PARAMS } from "../argon2id/argon2id.js";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import { deriveChildKey } from "../hd-key/hd-key.js";
import type { Argon2idParams } from "../types/types.js";
import {
buildPublicKeyFile,
openVaultBytes,
SIGNATURE_BYTES,
sealVault,
} from "./internals.js";
import type {
CreateRootKeysOptions,
CreateServerKeysOptions,
LayoutV1Fields,
PackVaultOptions,
RollServerKeysOptions,
RootKeysResult,
ServerKeysResult,
VaultEncryptionOptions,
} from "./orchestration.types.js";
const DEFAULT_ROOT_LAYOUT: LayoutV1Fields = Object.freeze({
masterVersion: 1,
userId: 0,
depth: 0,
slotVersion: 1,
});
type DefaultSlot = Keychain<LayoutV1Fields>[keyof Keychain<LayoutV1Fields>];
function getSlot(
keychain: Keychain<LayoutV1Fields>,
purpose: "mast" | "sign"
): DefaultSlot {
// `Keychain<LayoutV1Fields>` is `Readonly<Record<PurposeTag, KeySlotPrivate>>`.
// The named string keys ("master", "sign", …) are exactly the eight default
// purposes — always present after `generateKeychain` — but TS can't narrow
// brand-typed Record keys via dot access, so we go through a string-keyed
// view. The `Required` wrapper drops the `| undefined` introduced by index
// signatures under `noUncheckedIndexedAccess`.
const view = keychain as unknown as Record<string, DefaultSlot>;
return view[purpose] as DefaultSlot;
}
function defaultClock(
options: VaultEncryptionOptions | undefined
): () => number {
return options?.clock ?? Date.now;
}
function getArgon2idParams(
options: VaultEncryptionOptions | undefined
): Argon2idParams {
return options?.argon2id ?? DEFAULT_ARGON2ID_PARAMS;
}
function assertNonNegativeInteger(field: string, value: number): void {
if (!Number.isInteger(value) || value < 0) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ORCHESTRATION.INVALID_VERSION"],
"error.crypto-vault.orchestration.invalid-version",
{ context: { field, value } }
);
}
}
/**
* Generate a fresh root keychain, encrypt it under a password, and produce
* the matching signed public-key file in one step. The returned `vaultBytes`
* round-trip through {@link openVault} with the same password.
*/
export async function createRootKeys(
password: string,
options?: CreateRootKeysOptions
): Promise<RootKeysResult> {
const clock = defaultClock(options);
const layoutFields = options?.layoutFields ?? DEFAULT_ROOT_LAYOUT;
const keychain = generateKeychain({
clock,
layoutFields,
...(options?.masterKey !== undefined
? { masterKey: options.masterKey }
: {}),
});
const vaultBytes = await packVault(keychain, password, options);
const { publicKeyBytes, publicKeys } = buildPublicKeyFile(
keychain,
getSlot(keychain, "sign").secretKey
);
return { keychain, vaultBytes, publicKeyBytes, publicKeys };
}
async function buildServerResult(
rootKeys: RootKeysResult,
serverPassword: string,
version: number,
options: CreateServerKeysOptions | undefined
): Promise<ServerKeysResult> {
const clock = defaultClock(options);
const layoutFields: LayoutV1Fields = {
masterVersion: version,
userId: options?.userId ?? 1,
depth: 1,
slotVersion: 1,
};
const childMaster = deriveChildKey(
getSlot(rootKeys.keychain, "mast").secretKey,
version
);
const keychain = generateKeychain({
clock,
layoutFields,
masterKey: childMaster,
});
const vaultBytes = await packVault(keychain, serverPassword, options);
const { publicKeyBytes, publicKeys } = buildPublicKeyFile(
keychain,
getSlot(rootKeys.keychain, "sign").secretKey
);
return { keychain, vaultBytes, publicKeyBytes, publicKeys, version };
}
/**
* Derive a server keychain deterministically from `rootKeys.keychain.master`
* at the supplied `version` (default `1`), encrypt under `serverPassword`,
* and produce a public-key file signed by the *root* keychain's sign slot.
*/
export async function createServerKeys(
rootKeys: RootKeysResult,
serverPassword: string,
options?: CreateServerKeysOptions
): Promise<ServerKeysResult> {
const version = options?.version ?? 1;
assertNonNegativeInteger("version", version);
return buildServerResult(rootKeys, serverPassword, version, options);
}
/**
* Roll a server keychain to `currentVersion + 1`. Rejects negative
* `currentVersion` values with `CRYPTOVAULT.ORCHESTRATION.INVALID_VERSION`.
*/
export async function rollServerKeys(
rootKeys: RootKeysResult,
currentVersion: number,
serverPassword: string,
options?: RollServerKeysOptions
): Promise<ServerKeysResult> {
assertNonNegativeInteger("currentVersion", currentVersion);
return buildServerResult(
rootKeys,
serverPassword,
currentVersion + 1,
options
);
}
/**
* Decrypt `vaultBytes` (produced by {@link packVault} or any of the
* `create*Keys` helpers) and decode the embedded keychain.
*/
export async function openVault(
vaultBytes: Uint8Array,
password: string
): Promise<Keychain<LayoutV1Fields>> {
const plaintext = await openVaultBytes(vaultBytes, password);
return decodeKeychain<LayoutV1Fields>(plaintext);
}
/**
* Inverse of {@link openVault}: encrypt an existing `Keychain` under
* `password` and return the wire bytes.
*/
export async function packVault(
keychain: Keychain<LayoutV1Fields>,
password: string,
options?: PackVaultOptions
): Promise<Uint8Array> {
const encoded = encodeKeychain(keychain);
return sealVault(encoded, password, getArgon2idParams(options));
}
/**
* Decode a signed public-key file. When `optionalSignKey` is supplied the
* trailing 64-byte signature is verified against it; otherwise the file is
* decoded without verification (caller has another out-of-band path to trust).
*/
export async function viewPublicKeys(
publicKeyBytes: Uint8Array,
optionalSignKey?: Uint8Array
): Promise<PublicKeychain<LayoutV1Fields>> {
if (publicKeyBytes.length <= SIGNATURE_BYTES) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID"],
"error.crypto-vault.orchestration.public-key-signature-invalid",
{
context: {
reason: "short-input",
length: publicKeyBytes.length,
},
}
);
}
const data = publicKeyBytes.slice(0, -SIGNATURE_BYTES);
if (optionalSignKey !== undefined) {
const signature = publicKeyBytes.slice(-SIGNATURE_BYTES);
const ok = ed25519.verify(signature, data, optionalSignKey);
if (!ok) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES[
"ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID"
],
"error.crypto-vault.orchestration.public-key-signature-invalid",
{ context: { reason: "verify-failed" } }
);
}
}
return decodePublicKeychain<LayoutV1Fields>(data);
}
/** Idempotent convenience wrapper over `storage.set` for vault bytes. */
export async function saveVaultToStorage(
storage: AbstractStorage,
key: string,
vaultBytes: Uint8Array
): Promise<void> {
await storage.set(key, vaultBytes);
}
/** Idempotent convenience wrapper over `storage.set` for public-key files. */
export async function savePublicKeysToStorage(
storage: AbstractStorage,
key: string,
publicKeyBytes: Uint8Array
): Promise<void> {
await storage.set(key, publicKeyBytes);
}
/**
* Fetch bytes from `storage`, translating any "missing key" outcome into
* `CryptoVaultError(CRYPTOVAULT.ORCHESTRATION.STORAGE_KEY_NOT_FOUND)`.
*/
export async function getDataFromStorage(
storage: AbstractStorage,
key: string
): Promise<Uint8Array> {
const value = await storage.get(key).catch(() => undefined);
if (value === undefined) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["ORCHESTRATION.STORAGE_KEY_NOT_FOUND"],
"error.crypto-vault.orchestration.storage-key-not-found",
{ context: { key } }
);
}
return value;
}

View File

@@ -0,0 +1,72 @@
import type { Keychain, PublicKeychain } from "@suaveplan/crypto/keychain";
import type { Clock } from "@suaveplan/types";
import type { Argon2idParams } from "../types/types.js";
/**
* v1 layout discriminator carried in every derivation coordinate. Mirrors the
* shape exported by `@suaveplan/crypto`'s derivation grammar; declared locally
* because the upstream package does not expose a subpath for the derivation
* module. Keeping the contract here lets `orchestration` plug into
* `generateKeychain` without taking on a deeper import dependency.
*/
export interface LayoutV1Fields {
readonly masterVersion: number;
readonly userId: number;
readonly depth: number;
readonly slotVersion: number;
}
/**
* Shared base options accepted by every orchestration entrypoint that runs an
* Argon2id-bound encryption step. Callers MAY override the KDF parameters to
* a stronger profile; the defaults (`DEFAULT_ARGON2ID_PARAMS`) already satisfy
* OWASP password-storage guidance.
*/
export interface VaultEncryptionOptions {
/** Argon2id parameters used for the KEK derivation. */
readonly argon2id?: Argon2idParams;
/** Optional clock for keychain validity windows. Defaults to `Date.now`. */
readonly clock?: Clock;
}
/** Options accepted by {@link createRootKeys}. */
export interface CreateRootKeysOptions extends VaultEncryptionOptions {
/** Pre-existing 32-byte master seed; generated if omitted. */
readonly masterKey?: Uint8Array;
/** v1 layout fields override; defaults to the canonical root layout. */
readonly layoutFields?: LayoutV1Fields;
}
/** Options accepted by {@link createServerKeys}. */
export interface CreateServerKeysOptions extends VaultEncryptionOptions {
/** Logical version of the server keychain. Defaults to `1`. */
readonly version?: number;
/** User-id field for the v1 layout. Defaults to `1`. */
readonly userId?: number;
}
/** Options accepted by {@link rollServerKeys}. */
export type RollServerKeysOptions = CreateServerKeysOptions;
/** Options accepted by {@link packVault}. */
export type PackVaultOptions = VaultEncryptionOptions;
/**
* Return shape of {@link createRootKeys}. `keychain` holds private material;
* `vaultBytes` is its password-encrypted form; `publicKeyBytes` is the signed
* public-key file; `publicKeys` is the in-memory decoded `PublicKeychain`.
*/
export interface RootKeysResult {
readonly keychain: Keychain<LayoutV1Fields>;
readonly vaultBytes: Uint8Array;
readonly publicKeyBytes: Uint8Array;
readonly publicKeys: PublicKeychain<LayoutV1Fields>;
}
/**
* Return shape of {@link createServerKeys} and {@link rollServerKeys}.
* Adds the deterministic `version` discriminator used for rotation tracking.
*/
export interface ServerKeysResult extends RootKeysResult {
readonly version: number;
}

View File

@@ -0,0 +1,98 @@
# crypto-vault — OpenTelemetry instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`instruments.ts` holds the full OTel instrument set emitted by
`@suaveplan/crypto-vault` production paths. The module exposes a lazy
singleton that defers `metrics.getMeter(...)` until first use, so loading
the package does nothing observable when no exporter is wired up. Every
instrument is safe-by-construction — no key bytes, salts, plaintext, or
user-identifiable data appears in attributes.
## Features
- Lazy `getCryptoVaultInstruments()` accessor — first call materialises the
instrument set against the global meter; subsequent calls hit the cached
set.
- `setCryptoVaultMeterProvider(provider)` lets tests register a deterministic
in-memory `MeterProvider` and then assert against captured observations.
- `resetCryptoVaultInstruments()` clears the cache so tests can re-run with a
fresh provider without leaking state.
- Counters and histograms covering Argon2id derivations, HKDF derivations,
zeroization, SecureBuffer allocations / zeroizations, and the SRP-6a
handshake.
## Usage: Basic Example
```ts
import { getCryptoVaultInstruments } from "@suaveplan/crypto-vault/otel";
const i = getCryptoVaultInstruments();
i.argon2idDurationMs.record(143.2);
i.secureBufferAllocateTotal.add(1, { length: "32" });
```
## Usage: Advanced Example
```ts
import {
getCryptoVaultInstruments,
resetCryptoVaultInstruments,
setCryptoVaultMeterProvider,
} from "@suaveplan/crypto-vault/otel";
class CountingMeter {
public readonly events: Array<[string, number]> = [];
public createHistogram = (name: string) => ({
record: (v: number) => this.events.push([`hist:${name}`, v]),
});
public createCounter = (name: string) => ({
add: (v: number) => this.events.push([`ctr:${name}`, v]),
});
}
const meter = new CountingMeter();
setCryptoVaultMeterProvider({ getMeter: () => meter as unknown as never });
getCryptoVaultInstruments().secureBufferZeroizeTotal.add(1);
console.log(meter.events); // [["ctr:crypto_vault.secure_buffer.zeroize.total", 1]]
setCryptoVaultMeterProvider(undefined);
resetCryptoVaultInstruments();
```
## API Reference
### `getCryptoVaultInstruments(): CryptoVaultInstruments`
Returns the cached instrument set, building it on first call against the
global OTel meter.
### `setCryptoVaultMeterProvider(provider?)`
Replace the meter provider used by subsequent
`getCryptoVaultInstruments()` calls. Pass `undefined` to revert to the
global default.
### `resetCryptoVaultInstruments(): void`
Clear the cache so the next `getCryptoVaultInstruments()` re-acquires the
meter. Tests use this to defeat singleton leakage between suites.
### `CryptoVaultInstruments`
Bag of `Counter` and `Histogram` instances. Field names map to the metric
names listed under "Features".
## Implementation Notes
- The cache is intentionally module-level — production code should not need
per-instance instrument sets. Tests that want isolation register a
provider, run their assertions, then reset.
- Metric names follow the OTel semantic-conventions style:
`<package>.<feature>.<dimension>_<unit>`.
- The package never adds key material, vault IDs, item IDs, salts, or email
addresses to instrument attributes. The only attributes used are
fixed-cardinality status / outcome strings.

View File

@@ -0,0 +1,126 @@
import type { MeterProvider } from "@opentelemetry/api";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
getCryptoVaultInstruments,
resetCryptoVaultInstruments,
setCryptoVaultMeterProvider,
} from "./instruments.js";
interface CapturedMeter {
histograms: Map<string, number[]>;
counters: Map<string, Array<[number, Record<string, string> | undefined]>>;
}
function makeCaptureProvider(): {
provider: MeterProvider;
captured: CapturedMeter;
} {
const captured: CapturedMeter = {
histograms: new Map(),
counters: new Map(),
};
const meter = {
createHistogram: (name: string) => {
captured.histograms.set(name, []);
return {
record: (value: number) => {
captured.histograms.get(name)?.push(value);
},
};
},
createCounter: (name: string) => {
captured.counters.set(name, []);
return {
add: (value: number, attrs?: Record<string, string>) => {
captured.counters.get(name)?.push([value, attrs]);
},
};
},
};
const provider = {
getMeter: () =>
meter as unknown as ReturnType<MeterProvider["getMeter"]>,
} as unknown as MeterProvider;
return { provider, captured };
}
afterEach(() => {
setCryptoVaultMeterProvider(undefined);
resetCryptoVaultInstruments();
});
describe("getCryptoVaultInstruments — default no-op path", () => {
it("returns instruments with record/add functions", {
timeout: 5000,
}, () => {
const i = getCryptoVaultInstruments();
expect(typeof i.argon2idDurationMs.record).toBe("function");
expect(typeof i.hkdfDurationMs.record).toBe("function");
expect(typeof i.zeroizeDurationMs.record).toBe("function");
expect(typeof i.srpHandshakeDurationMs.record).toBe("function");
expect(typeof i.secureBufferAllocateTotal.add).toBe("function");
expect(typeof i.secureBufferZeroizeTotal.add).toBe("function");
expect(typeof i.srpAuthTotal.add).toBe("function");
});
it("no-op records do not throw", { timeout: 5000 }, () => {
const i = getCryptoVaultInstruments();
expect(() => i.argon2idDurationMs.record(123)).not.toThrow();
expect(() => i.secureBufferAllocateTotal.add(1)).not.toThrow();
});
it("returns the same singleton on repeated calls", {
timeout: 5000,
}, () => {
resetCryptoVaultInstruments();
const a = getCryptoVaultInstruments();
const b = getCryptoVaultInstruments();
expect(a).toBe(b);
});
});
describe("setCryptoVaultMeterProvider", () => {
it("routes record/add through the supplied MeterProvider", {
timeout: 5000,
}, () => {
const { provider, captured } = makeCaptureProvider();
setCryptoVaultMeterProvider(provider);
const i = getCryptoVaultInstruments();
i.argon2idDurationMs.record(50);
i.secureBufferAllocateTotal.add(1, { length: "32" });
expect(
captured.histograms.get("crypto_vault.argon2id.duration_ms")
).toEqual([50]);
expect(
captured.counters.get("crypto_vault.secure_buffer.allocate.total")
).toEqual([[1, { length: "32" }]]);
});
it("clears cached instruments when called with undefined", {
timeout: 5000,
}, () => {
const { provider, captured } = makeCaptureProvider();
setCryptoVaultMeterProvider(provider);
getCryptoVaultInstruments().argon2idDurationMs.record(10);
expect(
captured.histograms.get("crypto_vault.argon2id.duration_ms")
).toEqual([10]);
setCryptoVaultMeterProvider(undefined);
getCryptoVaultInstruments().argon2idDurationMs.record(99);
// Captured provider should NOT have received the second record.
expect(
captured.histograms.get("crypto_vault.argon2id.duration_ms")
).toEqual([10]);
});
});
describe("resetCryptoVaultInstruments", () => {
it("forces re-acquisition from the global meter on next call", {
timeout: 5000,
}, () => {
const a = getCryptoVaultInstruments();
resetCryptoVaultInstruments();
const b = getCryptoVaultInstruments();
expect(a).not.toBe(b);
});
});

View File

@@ -0,0 +1,117 @@
import type {
Counter,
Histogram,
Meter,
MeterProvider,
} from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/crypto-vault";
/**
* The full instrument set emitted from `@suaveplan/crypto-vault` production
* paths. Every instrument is safe-by-construction: no key bytes, no salts,
* no plaintext, no user-identifiable data appears in attributes.
*/
export interface CryptoVaultInstruments {
/** Histogram of Argon2id derivation duration (ms). */
argon2idDurationMs: Histogram;
/** Histogram of HKDF derivation duration (ms). */
hkdfDurationMs: Histogram;
/** Histogram of zeroization duration (ms). */
zeroizeDurationMs: Histogram;
/** Counter of SecureBuffer allocations. */
secureBufferAllocateTotal: Counter;
/** Counter of SecureBuffer zeroizations. */
secureBufferZeroizeTotal: Counter;
/** Histogram of SRP-6a handshake duration (ms). */
srpHandshakeDurationMs: Histogram;
/** Counter of SRP authentication outcomes (`success` / `failure`). */
srpAuthTotal: Counter;
}
let instruments: CryptoVaultInstruments | null = null;
function buildInstruments(meter: Meter): CryptoVaultInstruments {
return {
argon2idDurationMs: meter.createHistogram(
"crypto_vault.argon2id.duration_ms",
{
description:
"Wall-clock duration of Argon2id derivations in ms.",
unit: "ms",
}
),
hkdfDurationMs: meter.createHistogram("crypto_vault.hkdf.duration_ms", {
description: "Wall-clock duration of HKDF derivations in ms.",
unit: "ms",
}),
zeroizeDurationMs: meter.createHistogram(
"crypto_vault.zeroize.duration_ms",
{
description: "Wall-clock duration of zeroization in ms.",
unit: "ms",
}
),
secureBufferAllocateTotal: meter.createCounter(
"crypto_vault.secure_buffer.allocate.total",
{
description: "Total SecureBuffer allocations.",
unit: "{allocations}",
}
),
secureBufferZeroizeTotal: meter.createCounter(
"crypto_vault.secure_buffer.zeroize.total",
{
description: "Total SecureBuffer zeroizations.",
unit: "{zeroizations}",
}
),
srpHandshakeDurationMs: meter.createHistogram(
"crypto_vault.srp.handshake.duration_ms",
{
description:
"Wall-clock duration of an SRP-6a client handshake in ms.",
unit: "ms",
}
),
srpAuthTotal: meter.createCounter("crypto_vault.srp.auth.total", {
description:
"SRP-6a authentication outcomes by status (success | failure).",
unit: "{authentications}",
}),
};
}
/**
* Lazy-singleton accessor for the crypto-vault instrument set. On first call
* this captures `metrics.getMeter(...)` from the global OTel API; no-op
* instruments are returned automatically when no MeterProvider is registered.
*/
export function getCryptoVaultInstruments(): CryptoVaultInstruments {
if (instruments !== null) return instruments;
instruments = buildInstruments(metrics.getMeter(METER_NAME));
return instruments;
}
/**
* Override the OTel `MeterProvider` used to acquire the crypto-vault meter.
* Pass `undefined` to fall back to the global default. Intended for tests
* that want to assert against a deterministic provider.
*/
export function setCryptoVaultMeterProvider(
provider: MeterProvider | undefined
): void {
instruments = null;
if (provider !== undefined) {
instruments = buildInstruments(provider.getMeter(METER_NAME));
}
}
/**
* Reset the cached instruments. Used in tests to isolate one suite from
* another after `setCryptoVaultMeterProvider`.
*/
export function resetCryptoVaultInstruments(): void {
instruments = null;
}

View File

@@ -0,0 +1,86 @@
# Zod schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`schemas.ts` exposes Zod schemas for every public-surface shape that
`@suaveplan/crypto-vault` accepts or returns. Schemas are the runtime
counterpart to the TypeScript types in `types/types.ts` — consumers can
parse caller-supplied JSON, configuration, or RPC payloads through these
schemas to get the same safety guarantees the package's own validation
provides.
## Features
- `Argon2idParamsSchema` — pinned to the OWASP minimums and the
package-internal hard ceilings for `memoryKB` and `outputLength`.
- `AukSchema` — refines `Uint8Array` to exactly 32 bytes.
- `HierarchyKeySchema` — same shape, used for personal-keyset slots and
vault/item keys.
- `PersonalKeysetSchema` — strict object schema for the three-key bundle.
- `Bip39StrengthSchema` — `128 | 256` literal union.
## Usage: Basic Example
```ts
import { Argon2idParamsSchema } from "@suaveplan/crypto-vault/schemas/schemas";
const params = Argon2idParamsSchema.parse({
memoryKB: 65536,
iterations: 3,
parallelism: 4,
outputLength: 32,
});
```
## Usage: Advanced Example
```ts
import {
AukSchema,
PersonalKeysetSchema,
} from "@suaveplan/crypto-vault/schemas/schemas";
function importPersonalKeyset(json: unknown) {
const ks = PersonalKeysetSchema.parse(json);
AukSchema.parse(ks.encryptKey); // also valid as an AUK shape (32 bytes)
return ks;
}
```
## API Reference
### `Argon2idParamsSchema`
Strict object schema enforcing:
- `memoryKB ∈ [OWASP_MIN_MEMORY_KB, ARGON2ID_MAX_MEMORY_KB]` (integer).
- `iterations ≥ OWASP_MIN_ITERATIONS` (integer).
- `parallelism ≥ OWASP_MIN_PARALLELISM` (integer).
- `outputLength ∈ (0, ARGON2ID_MAX_OUTPUT_LENGTH]` (integer).
### `AukSchema`, `HierarchyKeySchema`
Instance-of `Uint8Array` refined to exactly 32 bytes.
### `PersonalKeysetSchema`
Strict object with three `HierarchyKeySchema` fields.
### `Bip39StrengthSchema`
`z.union([z.literal(128), z.literal(256)])`.
## Implementation Notes
- The schemas import the same constants used by the runtime validators so
the two paths cannot drift. Adjusting an OWASP minimum updates both at
once.
- `z.instanceof(Uint8Array)` is used so cross-realm `Uint8Array`s (e.g.
from a worker) parse correctly even though their prototype chain is
different — Zod's instanceof check is structural via `instanceof`.
- Strict mode is enabled on object schemas to reject unknown keys; this
is intentional, as crypto-vault has zero forward-compatible padding
fields.

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
Argon2idParamsSchema,
AukSchema,
Bip39StrengthSchema,
HierarchyKeySchema,
PersonalKeysetSchema,
} from "./schemas.js";
describe("Argon2idParamsSchema", () => {
it("accepts canonical OWASP defaults", { timeout: 5000 }, () => {
expect(() =>
Argon2idParamsSchema.parse({
memoryKB: 65536,
iterations: 3,
parallelism: 4,
outputLength: 32,
})
).not.toThrow();
});
it("rejects below-minimum memoryKB", { timeout: 5000 }, () => {
expect(
Argon2idParamsSchema.safeParse({
memoryKB: 1024,
iterations: 3,
parallelism: 4,
outputLength: 32,
}).success
).toBe(false);
});
it("rejects above-ceiling memoryKB", { timeout: 5000 }, () => {
expect(
Argon2idParamsSchema.safeParse({
memoryKB: 1_073_741_824,
iterations: 3,
parallelism: 4,
outputLength: 32,
}).success
).toBe(false);
});
it("rejects below-minimum iterations", { timeout: 5000 }, () => {
expect(
Argon2idParamsSchema.safeParse({
memoryKB: 65536,
iterations: 1,
parallelism: 4,
outputLength: 32,
}).success
).toBe(false);
});
it("rejects unknown keys", { timeout: 5000 }, () => {
expect(
Argon2idParamsSchema.safeParse({
memoryKB: 65536,
iterations: 3,
parallelism: 4,
outputLength: 32,
extra: true,
}).success
).toBe(false);
});
});
describe("AukSchema and HierarchyKeySchema", () => {
it("accept exactly 32 bytes", { timeout: 5000 }, () => {
const ok = new Uint8Array(32);
expect(() => AukSchema.parse(ok)).not.toThrow();
expect(() => HierarchyKeySchema.parse(ok)).not.toThrow();
});
it("reject non-32-byte arrays", { timeout: 5000 }, () => {
expect(AukSchema.safeParse(new Uint8Array(31)).success).toBe(false);
expect(AukSchema.safeParse(new Uint8Array(33)).success).toBe(false);
expect(HierarchyKeySchema.safeParse(new Uint8Array(0)).success).toBe(
false
);
});
it("reject non-Uint8Array values", { timeout: 5000 }, () => {
expect(AukSchema.safeParse(new Array(32).fill(0)).success).toBe(false);
expect(AukSchema.safeParse("hex").success).toBe(false);
});
});
describe("PersonalKeysetSchema", () => {
const ks = {
encryptKey: new Uint8Array(32),
signKey: new Uint8Array(32),
kxKey: new Uint8Array(32),
};
it("accepts a well-formed bundle", { timeout: 5000 }, () => {
expect(() => PersonalKeysetSchema.parse(ks)).not.toThrow();
});
it("rejects bundles with wrong-length keys", { timeout: 5000 }, () => {
expect(
PersonalKeysetSchema.safeParse({
...ks,
encryptKey: new Uint8Array(16),
}).success
).toBe(false);
});
it("rejects unknown keys", { timeout: 5000 }, () => {
expect(
PersonalKeysetSchema.safeParse({ ...ks, extra: 1 }).success
).toBe(false);
});
});
describe("Bip39StrengthSchema", () => {
it("accepts 128 and 256", { timeout: 5000 }, () => {
expect(Bip39StrengthSchema.safeParse(128).success).toBe(true);
expect(Bip39StrengthSchema.safeParse(256).success).toBe(true);
});
it("rejects other values", { timeout: 5000 }, () => {
expect(Bip39StrengthSchema.safeParse(192).success).toBe(false);
expect(Bip39StrengthSchema.safeParse(0).success).toBe(false);
expect(Bip39StrengthSchema.safeParse("128").success).toBe(false);
});
});

View File

@@ -0,0 +1,53 @@
import * as z from "zod";
import {
ARGON2ID_MAX_MEMORY_KB,
ARGON2ID_MAX_OUTPUT_LENGTH,
OWASP_MIN_ITERATIONS,
OWASP_MIN_MEMORY_KB,
OWASP_MIN_PARALLELISM,
} from "../argon2id/argon2id.js";
import {
AUK_LENGTH,
HIERARCHY_KEY_LENGTH,
} from "../key-hierarchy/key-hierarchy.js";
/** Zod schema for `Argon2idParams` mirroring the runtime safety bands. */
export const Argon2idParamsSchema = z
.object({
memoryKB: z
.number()
.int()
.min(OWASP_MIN_MEMORY_KB)
.max(ARGON2ID_MAX_MEMORY_KB),
iterations: z.number().int().min(OWASP_MIN_ITERATIONS),
parallelism: z.number().int().min(OWASP_MIN_PARALLELISM),
outputLength: z.number().int().min(1).max(ARGON2ID_MAX_OUTPUT_LENGTH),
})
.strict();
/** Zod schema for a 32-byte AUK. */
export const AukSchema = z
.instanceof(Uint8Array)
.refine((b) => b.length === AUK_LENGTH, {
message: `AUK must be ${AUK_LENGTH} bytes`,
});
/** Zod schema for any 32-byte hierarchy key (vault, item, personal slot). */
export const HierarchyKeySchema = z
.instanceof(Uint8Array)
.refine((b) => b.length === HIERARCHY_KEY_LENGTH, {
message: `Hierarchy key must be ${HIERARCHY_KEY_LENGTH} bytes`,
});
/** Zod schema for the personal-keyset bundle. */
export const PersonalKeysetSchema = z
.object({
encryptKey: HierarchyKeySchema,
signKey: HierarchyKeySchema,
kxKey: HierarchyKeySchema,
})
.strict();
/** Zod schema for a BIP39 strength value. */
export const Bip39StrengthSchema = z.union([z.literal(128), z.literal(256)]);

View File

@@ -0,0 +1,128 @@
# SecureBuffer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SecureBuffer` is the canonical envelope for sensitive cryptographic
material in `@suaveplan/crypto-vault`. It owns a private `Uint8Array`,
exposes an opt-in `bytes` accessor, and overwrites the backing storage
with zero bytes the moment `close()` is called. A module-level registry
of `WeakRef<SecureBuffer>` lets `zeroizeAll()` sweep every live buffer in
one call — useful on lock, on a security event, or as a final
process-shutdown hook. Strings and JSON serializations are masked so the
buffer can never leak through accidental logging.
## Features
- Eager zeroization via `Uint8Array#fill(0)` on `close()`.
- Idempotent close (`close()` after `close()` is a no-op).
- Clone isolation — `clone()` allocates a fresh independent backing store.
- Slice copy semantics — `slice()` returns a fresh `Uint8Array`; the caller
cannot mutate the buffer through the result.
- Random fill via `fillRandom(rng?)` (uses `crypto.getRandomValues` by
default; honours an injected `Rng` with `nextBytes`).
- `toString` and `toJSON` print only `length` and `closed` flags.
- Global `zeroizeAll()` sweeps every reachable `SecureBuffer` and closes it.
## Usage: Basic Example
```ts
import { SecureBuffer } from "@suaveplan/crypto-vault/secure/secure-buffer";
const buf = new SecureBuffer(32);
buf.fillRandom();
// ... use buf.bytes ...
buf.close();
```
## Usage: Advanced Example
```ts
import {
SecureBuffer,
zeroizeAll,
} from "@suaveplan/crypto-vault/secure/secure-buffer";
// Initialise from existing key material — copied, never shared.
const seed = new Uint8Array(32).fill(7);
const masterBuf = new SecureBuffer(seed);
// Clone for an isolated branch derivation.
const branch = masterBuf.clone();
branch.bytes[0] = 1;
console.log(masterBuf.bytes[0]); // 7 — clone is fully isolated
// Lock everything in one shot.
zeroizeAll();
console.log(masterBuf.isClosed); // true
console.log(branch.isClosed); // true
```
## API Reference
### `class SecureBuffer`
#### `new SecureBuffer(lengthOrSource, options?)`
- `lengthOrSource: number | Uint8Array`. A `number` allocates a fresh
zero-filled buffer of that length. A `Uint8Array` is **copied** into the
internal store — the buffer never aliases caller storage.
- `options?: SecureBufferOptions = { clock?, rng? }`. Reserved for future
allocation telemetry; `fillRandom` reads `rng` directly.
Throws `CryptoVaultError(CRYPTOVAULT.SECURE.INVALID_LENGTH)` when the
numeric length is negative or non-integer.
#### `bytes: Uint8Array`
Mutable view of the live bytes. Throws
`CryptoVaultError(CRYPTOVAULT.SECURE.ZEROIZED)` once the buffer has been
closed.
#### `length: number`, `isClosed: boolean`
Length of the backing store and the closed flag.
#### `close(): void`
Overwrite the bytes with zeros and mark closed. Idempotent.
#### `clone(): SecureBuffer`
Allocate a fresh `SecureBuffer` with a copy of the current bytes.
#### `slice(start?, end?): Uint8Array`
Copy a range out into a new `Uint8Array`. The result is not registered.
#### `fillRandom(rng?): void`
Fill the bytes with CSPRNG output. Honours `rng?.nextBytes(n)` when
provided; otherwise falls back to `crypto.getRandomValues`.
#### `toString()`, `toJSON()`
Return `[SecureBuffer length=<n> closed=<bool>]` and a structurally
equivalent JSON object — never the raw bytes.
### `zeroizeAll(): void`
Walk every live `WeakRef<SecureBuffer>` in the module-level registry, call
`close()` on each non-closed instance, and prune the registry.
### `liveSecureBufferCount(): number`
Test helper — prune dead refs and return the number of live entries.
## Implementation Notes
- The registry stores `WeakRef`s so registered buffers can still be
garbage-collected normally.
- JavaScript cannot guarantee the GC has not relocated memory before
`close()`, so eager zeroization is best-effort. Combine with aggressive
scoping: allocate inside a function, close, return nothing sensitive.
- Memory-locking (`mlock`) is intentionally out of scope. A future native
binding will plug in via the `SecureBufferOptions` bag without breaking
callers.

View File

@@ -0,0 +1,240 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import {
liveSecureBufferCount,
pruneSecureBufferRegistry,
SecureBuffer,
zeroizeAll,
} from "./secure-buffer.js";
describe("SecureBuffer construction", () => {
it("allocates a zeroed buffer when given a length", {
timeout: 5000,
}, () => {
const buf = new SecureBuffer(8);
expect(buf.length).toBe(8);
expect(Array.from(buf.bytes)).toEqual([0, 0, 0, 0, 0, 0, 0, 0]);
buf.close();
});
it("copies an incoming Uint8Array (no aliasing)", { timeout: 5000 }, () => {
const src = new Uint8Array([1, 2, 3, 4]);
const buf = new SecureBuffer(src);
src[0] = 99;
expect(buf.bytes[0]).toBe(1);
buf.close();
});
it("rejects non-integer lengths", { timeout: 5000 }, () => {
try {
new SecureBuffer(1.5);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["SECURE.INVALID_LENGTH"]
);
}
});
it("rejects negative lengths", { timeout: 5000 }, () => {
try {
new SecureBuffer(-1);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["SECURE.INVALID_LENGTH"]
);
}
});
it("accepts injectable clock and rng", { timeout: 5000 }, () => {
const buf = new SecureBuffer(8, {
clock: () => 0,
rng: {
nextUint32: () => 0,
nextFloat: () => 0,
nextBytes: (n) => new Uint8Array(n).fill(7),
},
});
expect(buf.length).toBe(8);
buf.close();
});
});
describe("SecureBuffer close()", () => {
it("zeroizes the underlying bytes and marks closed", {
timeout: 5000,
}, () => {
const buf = new SecureBuffer(new Uint8Array([1, 2, 3, 4]));
buf.close();
expect(buf.isClosed).toBe(true);
});
it("accessing zeroized key throws", { timeout: 5000 }, () => {
const buf = new SecureBuffer(8);
buf.close();
try {
void buf.bytes;
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["SECURE.ZEROIZED"]
);
}
});
it("close() is idempotent", { timeout: 5000 }, () => {
const buf = new SecureBuffer(8);
expect(() => {
buf.close();
buf.close();
buf.close();
}).not.toThrow();
});
it("clone after close throws", { timeout: 5000 }, () => {
const buf = new SecureBuffer(8);
buf.close();
expect(() => buf.clone()).toThrow(CryptoVaultError);
});
it("slice after close throws", { timeout: 5000 }, () => {
const buf = new SecureBuffer(8);
buf.close();
expect(() => buf.slice(0, 4)).toThrow(CryptoVaultError);
});
it("fillRandom after close throws", { timeout: 5000 }, () => {
const buf = new SecureBuffer(8);
buf.close();
expect(() => buf.fillRandom()).toThrow(CryptoVaultError);
});
});
describe("SecureBuffer clone() and slice()", () => {
it("clone creates independent copy", { timeout: 5000 }, () => {
const src = new SecureBuffer(new Uint8Array([1, 2, 3, 4]));
const clone = src.clone();
clone.bytes[0] = 99;
expect(src.bytes[0]).toBe(1);
src.close();
// clone still works
expect(clone.bytes[0]).toBe(99);
clone.close();
});
it("slice does not share underlying buffer", { timeout: 5000 }, () => {
const buf = new SecureBuffer(new Uint8Array([1, 2, 3, 4, 5]));
const slice = buf.slice(1, 4);
expect(Array.from(slice)).toEqual([2, 3, 4]);
slice[0] = 99;
expect(buf.bytes[1]).toBe(2);
buf.close();
});
it("slice with default args returns full copy", { timeout: 5000 }, () => {
const buf = new SecureBuffer(new Uint8Array([1, 2, 3]));
const all = buf.slice();
expect(Array.from(all)).toEqual([1, 2, 3]);
buf.close();
});
});
describe("SecureBuffer fillRandom()", () => {
it("uses injected rng.nextBytes when supplied", { timeout: 5000 }, () => {
const buf = new SecureBuffer(8);
buf.fillRandom({
nextUint32: () => 0,
nextFloat: () => 0,
nextBytes: (n) => new Uint8Array(n).fill(0xab),
});
expect(Array.from(buf.bytes)).toEqual([
0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab, 0xab,
]);
buf.close();
});
it("falls back to crypto.getRandomValues when rng has no nextBytes", {
timeout: 5000,
}, () => {
const buf = new SecureBuffer(32);
buf.fillRandom({ nextUint32: () => 0, nextFloat: () => 0 });
const sum = buf.bytes.reduce<number>((a, b) => a + b, 0);
// Almost certainly non-zero; failure rate ~ 1 in 2^256.
expect(sum).toBeGreaterThan(0);
buf.close();
});
it("uses crypto.getRandomValues when no rng is supplied", {
timeout: 5000,
}, () => {
const buf = new SecureBuffer(32);
buf.fillRandom();
const sum = buf.bytes.reduce<number>((a, b) => a + b, 0);
expect(sum).toBeGreaterThan(0);
buf.close();
});
});
describe("SecureBuffer string and JSON masking", () => {
it("toString does not leak bytes", { timeout: 5000 }, () => {
const buf = new SecureBuffer(new Uint8Array([0xde, 0xad, 0xbe, 0xef]));
const out = String(buf);
expect(out).toContain("SecureBuffer");
expect(out).toContain("length=4");
expect(out).not.toContain("de");
expect(out).not.toContain("ad");
buf.close();
});
it("JSON.stringify does not leak bytes", { timeout: 5000 }, () => {
const buf = new SecureBuffer(new Uint8Array([0xde, 0xad, 0xbe, 0xef]));
const out = JSON.stringify(buf);
expect(out).not.toContain("222"); // 0xde decimal
expect(out).toContain("SecureBuffer");
expect(out).toContain('"length":4');
buf.close();
});
});
describe("zeroizeAll()", () => {
it("zeroizeAll overwrites all live buffers", { timeout: 5000 }, () => {
pruneSecureBufferRegistry();
const a = new SecureBuffer(new Uint8Array([1, 2, 3]));
const b = new SecureBuffer(new Uint8Array([4, 5, 6]));
zeroizeAll();
expect(a.isClosed).toBe(true);
expect(b.isClosed).toBe(true);
});
it("liveSecureBufferCount tracks open buffers", { timeout: 5000 }, () => {
zeroizeAll();
const a = new SecureBuffer(8);
const b = new SecureBuffer(8);
expect(liveSecureBufferCount()).toBeGreaterThanOrEqual(2);
a.close();
b.close();
zeroizeAll();
expect(liveSecureBufferCount()).toBe(0);
});
it("zeroizeAll skips already-closed buffers", { timeout: 5000 }, () => {
zeroizeAll();
const a = new SecureBuffer(new Uint8Array([1, 2, 3]));
a.close();
expect(() => zeroizeAll()).not.toThrow();
expect(a.isClosed).toBe(true);
});
it("liveSecureBufferCount prunes dead refs", { timeout: 5000 }, () => {
zeroizeAll();
// Allocate and immediately close, then sweep.
new SecureBuffer(8).close();
zeroizeAll();
expect(liveSecureBufferCount()).toBe(0);
});
});

View File

@@ -0,0 +1,186 @@
import { zeroize } from "@suaveplan/crypto-utils";
import type { Clock, Rng } from "@suaveplan/types";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import { getCryptoVaultInstruments } from "../otel/instruments.js";
/** Constructor options for `SecureBuffer`. */
export interface SecureBufferOptions {
/** Optional wall-clock source used by allocation telemetry. */
readonly clock?: Clock;
/** Optional random source forwarded to {@link SecureBuffer.fillRandom}. */
readonly rng?: Rng;
}
/**
* Module-scoped registry of every live (non-closed) SecureBuffer. Stored as
* `WeakRef`s so the registry never extends the lifetime of a buffer beyond
* its natural reachability — `zeroizeAll()` walks the registry and best-effort
* clears any buffer still reachable when called.
*/
const liveBuffers = new Set<WeakRef<SecureBuffer>>();
/**
* Eagerly-zeroized envelope around a `Uint8Array` containing sensitive
* material (keys, AUKs, vault keys, item keys, session keys).
*
* `SecureBuffer` provides:
* - byte-level access via `bytes` (throws if closed),
* - `close()` overwriting the underlying `Uint8Array` with zero bytes,
* - clone isolation (`clone()` allocates a fresh independent backing store),
* - `slice()` returning a copied range — never sharing the backing buffer,
* - `toString` / `toJSON` masking the raw contents,
* - registration in a module-level registry so a single `zeroizeAll()` can
* sweep every live buffer.
*/
export class SecureBuffer {
private readonly underlying: Uint8Array;
private closed = false;
public readonly length: number;
public constructor(
sourceOrLength: number | Uint8Array,
_options: SecureBufferOptions = {}
) {
if (typeof sourceOrLength === "number") {
if (!Number.isInteger(sourceOrLength) || sourceOrLength < 0) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["SECURE.INVALID_LENGTH"],
"error.crypto-vault.secure.invalid-length",
{ context: { length: sourceOrLength } }
);
}
this.underlying = new Uint8Array(sourceOrLength);
this.length = sourceOrLength;
} else {
// Always copy — never share the caller's backing store.
this.underlying = new Uint8Array(sourceOrLength);
this.length = sourceOrLength.length;
}
liveBuffers.add(new WeakRef(this));
getCryptoVaultInstruments().secureBufferAllocateTotal.add(1, {
length: String(this.length),
});
}
/**
* Mutable view of the underlying bytes. Throws once the buffer has been
* closed via `close()` or the global `zeroizeAll()` sweep.
*/
public get bytes(): Uint8Array {
this.assertOpen();
return this.underlying;
}
/** Whether the buffer has been zeroized and rendered unusable. */
public get isClosed(): boolean {
return this.closed;
}
/**
* Eagerly overwrite the underlying bytes with zeros and mark the buffer
* closed. Idempotent — calling `close()` on an already-closed buffer is a
* no-op.
*/
public close(): void {
if (this.closed) return;
const start = Date.now();
zeroize(this.underlying);
this.closed = true;
const i = getCryptoVaultInstruments();
i.secureBufferZeroizeTotal.add(1);
i.zeroizeDurationMs.record(Date.now() - start);
}
/**
* Return an isolated clone of this buffer. Zeroizing the clone does not
* affect the source and vice versa.
*/
public clone(): SecureBuffer {
this.assertOpen();
return new SecureBuffer(this.underlying);
}
/**
* Return a copied slice as a fresh `Uint8Array`. The returned array does
* not share storage with `this` — mutating it cannot leak through.
*/
public slice(start = 0, end: number = this.length): Uint8Array {
this.assertOpen();
const out = new Uint8Array(this.underlying.subarray(start, end));
return out;
}
/**
* Fill the buffer in-place with random bytes from the supplied (or
* default) RNG. Convenience over `crypto.getRandomValues` for buffers
* the caller already holds.
*/
public fillRandom(rng?: Rng): void {
this.assertOpen();
if (rng?.nextBytes !== undefined) {
const bytes = rng.nextBytes(this.length);
this.underlying.set(bytes);
return;
}
// Allocate a fresh ArrayBuffer-backed buffer for getRandomValues
// (its strict type does not accept SharedArrayBuffer-backed views).
const tmp = new Uint8Array(this.length);
crypto.getRandomValues(tmp);
this.underlying.set(tmp);
}
/** Masked string form — never leaks raw bytes. */
public toString(): string {
return `[SecureBuffer length=${this.length} closed=${this.closed}]`;
}
/** Masked JSON form — never leaks raw bytes. */
public toJSON(): { type: "SecureBuffer"; length: number; closed: boolean } {
return {
type: "SecureBuffer",
length: this.length,
closed: this.closed,
};
}
private assertOpen(): void {
if (this.closed) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["SECURE.ZEROIZED"],
"error.crypto-vault.secure.zeroized"
);
}
}
}
/**
* Best-effort sweep that closes every live SecureBuffer reachable through
* the module-level registry. Call on lock, on a security event, or before
* process termination.
*/
export function zeroizeAll(): void {
for (const ref of liveBuffers) {
const buf = ref.deref();
if (buf !== undefined && !buf.isClosed) {
buf.close();
}
liveBuffers.delete(ref);
}
}
/** Internal — drop closed/garbage-collected entries from the registry. */
export function pruneSecureBufferRegistry(): void {
for (const ref of liveBuffers) {
const buf = ref.deref();
if (buf === undefined || buf.isClosed) {
liveBuffers.delete(ref);
}
}
}
/** Internal — count live entries (test instrumentation only). */
export function liveSecureBufferCount(): number {
pruneSecureBufferRegistry();
return liveBuffers.size;
}

View File

@@ -0,0 +1,304 @@
/**
* Spec conformance: every code in `CRYPTO_VAULT_CODES` is thrown by at least
* one production path; every public-surface invariant pinned in the spec
* (BIP39 round-trip, AUK length, hierarchy isolation, SRP handshake) is
* verified through the public exports of the package.
*/
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import {
Argon2idParamsSchema,
AUK_LENGTH,
AukSchema,
BIP39_SEED_LENGTH,
Bip39StrengthSchema,
CHILD_KEY_LENGTH,
CRYPTO_VAULT_CODES,
type CryptoVaultCode,
CryptoVaultError,
DEFAULT_ARGON2ID_PARAMS,
deriveChildKey,
deriveItemKey,
deriveMasterKeyFromMnemonic,
derivePersonalKeyset,
deriveSeedFromMnemonic,
deriveVaultKey,
entropyToMnemonic,
generateMnemonic,
HIERARCHY_KEY_LENGTH,
HierarchyKeySchema,
isValidHandle,
MASTER_KEY_LENGTH,
mnemonicToEntropy,
PersonalKeysetSchema,
SecureBuffer,
SRP_G,
SRP_N_2048,
SRP_N_BITLEN,
SRP_N_BYTELEN,
SrpClient,
unwrap,
validateMnemonic,
type wrap,
zeroizeAll,
} from "./index.js";
describe("Spec conformance — error codes", () => {
it("every CRYPTO_VAULT_CODES entry is thrown by a production path", {
timeout: 60_000,
}, async () => {
const expected = new Set(Object.values(CRYPTO_VAULT_CODES));
const seen = new Set<string>();
const record = (err: unknown): void => {
if (err instanceof CryptoVaultError) seen.add(err.code);
};
// BIP39.INVALID_ENTROPY_LENGTH
try {
entropyToMnemonic(new Uint8Array(8));
} catch (e) {
record(e);
}
// BIP39.INVALID_MNEMONIC
try {
mnemonicToEntropy("");
} catch (e) {
record(e);
}
// ARGON2ID.UNSAFE_PARAMETERS — exercised by 2SKD via deriveAuk path
// (param validation runs before the slow Argon2id step).
try {
const { deriveKeyWithArgon2id } = await import(
"./argon2id/argon2id.js"
);
await deriveKeyWithArgon2id(
new TextEncoder().encode("pw"),
new Uint8Array(16),
{
...DEFAULT_ARGON2ID_PARAMS,
memoryKB: 1,
}
);
} catch (e) {
record(e);
}
// ARGON2ID.INVALID_INPUT
try {
const { deriveKeyWithArgon2id } = await import(
"./argon2id/argon2id.js"
);
await deriveKeyWithArgon2id(new Uint8Array(0), new Uint8Array(16));
} catch (e) {
record(e);
}
// TWOSKD.MISSING_PASSWORD / SECRET_KEY / EMAIL
try {
const { deriveAuk } = await import("./twoskd/twoskd.js");
await deriveAuk("", "sk", "alice@example.com", new Uint8Array(16));
} catch (e) {
record(e);
}
try {
const { deriveAuk } = await import("./twoskd/twoskd.js");
await deriveAuk("pw", "", "alice@example.com", new Uint8Array(16));
} catch (e) {
record(e);
}
try {
const { deriveAuk } = await import("./twoskd/twoskd.js");
await deriveAuk("pw", "sk", "", new Uint8Array(16));
} catch (e) {
record(e);
}
// HD.INVALID_MNEMONIC
try {
deriveMasterKeyFromMnemonic("");
} catch (e) {
record(e);
}
// HD.INVALID_INDEX
try {
deriveChildKey(new Uint8Array(32), -1);
} catch (e) {
record(e);
}
// HIERARCHY.INVALID_AUK_LENGTH
try {
derivePersonalKeyset(new Uint8Array(7));
} catch (e) {
record(e);
}
// HIERARCHY.EMPTY_VAULT_ID
try {
const ks = derivePersonalKeyset(new Uint8Array(32));
deriveVaultKey(ks, "");
} catch (e) {
record(e);
}
// HIERARCHY.EMPTY_ITEM_ID
try {
const ks = derivePersonalKeyset(new Uint8Array(32));
const vk = deriveVaultKey(ks, "v");
deriveItemKey(vk, "");
} catch (e) {
record(e);
}
// SRP.WEAK_PARAMETERS
try {
new SrpClient({ N: 23n });
} catch (e) {
record(e);
}
// SRP.INVALID_PRIVATE_KEY
try {
new SrpClient().derivePrivateKey("", "pw", new Uint8Array(8));
} catch (e) {
record(e);
}
// SRP.INVALID_B
try {
const c = new SrpClient();
c.deriveSessionKey(1n, 1n, SRP_N_2048, new Uint8Array(32));
} catch (e) {
record(e);
}
// SRP.AUTHENTICATION_FAILED
try {
const c = new SrpClient();
const A = 1n;
const M1 = new Uint8Array(32);
const K = new Uint8Array(32);
const tampered = new Uint8Array(32).fill(0xff);
c.verifyServerProof(A, M1, K, tampered);
} catch (e) {
record(e);
}
// SECURE.INVALID_LENGTH
try {
new SecureBuffer(-1);
} catch (e) {
record(e);
}
// SECURE.ZEROIZED
try {
const buf = new SecureBuffer(8);
buf.close();
void buf.bytes;
} catch (e) {
record(e);
}
// OPAQUE.INVALID_HANDLE
try {
unwrap({
id: Symbol(),
length: 0,
_zeroize: () => 0,
} as unknown as ReturnType<typeof wrap>);
} catch (e) {
record(e);
}
// ORCHESTRATION.DECRYPT_FAILED
try {
const { openVault } = await import("./orchestration/index.js");
await openVault(new Uint8Array(8), "pw");
} catch (e) {
record(e);
}
// ORCHESTRATION.PUBLIC_KEY_SIGNATURE_INVALID
try {
const { viewPublicKeys } = await import("./orchestration/index.js");
await viewPublicKeys(new Uint8Array(8));
} catch (e) {
record(e);
}
// ORCHESTRATION.STORAGE_KEY_NOT_FOUND
try {
const { getDataFromStorage } = await import(
"./orchestration/index.js"
);
const { MemoryStorage } = await import("@suaveplan/storage");
await getDataFromStorage(
new MemoryStorage({ cleanupIntervalMs: 0 }),
"absent"
);
} catch (e) {
record(e);
}
// ORCHESTRATION.INVALID_VERSION
try {
const { rollServerKeys } = await import("./orchestration/index.js");
await rollServerKeys(
// Cast: we only need the negative-version branch which runs
// before any rootKeys-field access.
{} as Parameters<typeof rollServerKeys>[0],
-1,
"pw"
);
} catch (e) {
record(e);
}
const missing: string[] = [];
for (const code of expected) {
if (!seen.has(code)) missing.push(code);
}
expect(missing).toEqual([]);
});
});
describe("Spec conformance — type-level claims", () => {
it("CryptoVaultCode is a literal union", { timeout: 5000 }, () => {
expectTypeOf<CryptoVaultCode>().toMatchTypeOf<string>();
const code: CryptoVaultCode =
CRYPTO_VAULT_CODES["BIP39.INVALID_MNEMONIC"];
expect(code).toBe("CRYPTOVAULT.BIP39.INVALID_MNEMONIC");
});
it("HierarchyKeySchema and AukSchema agree on length", {
timeout: 5000,
}, () => {
const ok = new Uint8Array(AUK_LENGTH);
expect(AukSchema.safeParse(ok).success).toBe(true);
expect(HierarchyKeySchema.safeParse(ok).success).toBe(true);
});
});
describe("Spec conformance — public exports", () => {
it("re-exports the full surface from src/index.ts", {
timeout: 5000,
}, () => {
expect(typeof generateMnemonic).toBe("function");
expect(typeof validateMnemonic).toBe("function");
expect(typeof entropyToMnemonic).toBe("function");
expect(typeof mnemonicToEntropy).toBe("function");
expect(typeof deriveSeedFromMnemonic).toBe("function");
expect(typeof deriveMasterKeyFromMnemonic).toBe("function");
expect(typeof deriveChildKey).toBe("function");
expect(typeof derivePersonalKeyset).toBe("function");
expect(typeof deriveVaultKey).toBe("function");
expect(typeof deriveItemKey).toBe("function");
expect(typeof zeroizeAll).toBe("function");
expect(typeof isValidHandle).toBe("function");
expect(typeof Argon2idParamsSchema.safeParse).toBe("function");
expect(typeof PersonalKeysetSchema.safeParse).toBe("function");
expect(typeof Bip39StrengthSchema.safeParse).toBe("function");
expect(MASTER_KEY_LENGTH).toBe(32);
expect(CHILD_KEY_LENGTH).toBe(32);
expect(BIP39_SEED_LENGTH).toBe(64);
expect(HIERARCHY_KEY_LENGTH).toBe(32);
expect(AUK_LENGTH).toBe(32);
expect(SRP_N_BITLEN).toBe(2048);
expect(SRP_N_BYTELEN).toBe(256);
expect(SRP_G).toBe(2n);
expect(SRP_N_2048 > 0n).toBe(true);
});
it("BIP39 round-trip works through the public surface", {
timeout: 5000,
}, () => {
const phrase = generateMnemonic(128);
const bytes = mnemonicToEntropy(phrase);
expect(entropyToMnemonic(bytes)).toBe(phrase);
});
});

View File

@@ -0,0 +1,133 @@
# SRP-6a client
## Purpose
`srp-client.ts` implements the client side of the SRP-6a zero-knowledge
password-authenticated key exchange per RFC 5054, using SHA-256 as the
one-way hash and the 2048-bit safe-prime group `N`/`g` from Appendix A.
The class hands the caller everything needed for an interoperable
handshake against any conformant server: salt generation, private key
derivation `x = H(s, H(I:p))`, verifier `v = g^x mod N`, ephemeral
keypair `(a, A = g^a mod N)`, session key derivation, and constant-time
server-proof verification.
## Features
- Salt generation via `generateSalt(length?)` honouring an injected RNG.
- Private key, verifier, and ephemeral keypair primitives — pure,
deterministic given inputs (or RNG).
- `deriveSessionKey(...)` produces `K = SHA-256(S)` and the client proof
`M1 = H(A || B || K)` per the RFC.
- Tamper-resistant `B` validation: `B mod N == 0` aborts with
`SRP.INVALID_B`. The `u = H(A || B) == 0` case is excluded via the
collision-resistance of SHA-256 and is documented in code rather than
re-checked at runtime.
- `verifyServerProof` is constant-time (`constantTimeEqual`) and throws on
mismatch with `CRYPTOVAULT.SRP.AUTHENTICATION_FAILED`.
- Reject groups with `N` shorter than 2048 bits at construction time.
- OTel histograms for handshake duration and counters for success / failure.
## Usage: Basic Example
```ts
import { SrpClient } from "@suaveplan/crypto-vault/srp/srp-client";
const client = new SrpClient();
const salt = client.generateSalt();
const privateKey = client.derivePrivateKey(
"alice@example.com",
"correct horse battery staple",
salt
);
const verifier = client.deriveVerifier(privateKey);
console.log(verifier.length); // 256
```
## Usage: Advanced Example
```ts
import { SrpClient } from "@suaveplan/crypto-vault/srp/srp-client";
import {
SRP_G,
SRP_N_2048,
} from "@suaveplan/crypto-vault/srp/srp-group";
import { pow } from "@noble/curves/abstract/modular";
// Symmetric handshake fixture: same client + a hand-rolled server share.
const client = new SrpClient();
const salt = client.generateSalt();
const x = client.derivePrivateKey("alice", "pw", salt);
const v = BigInt("0x" + Buffer.from(client.deriveVerifier(x)).toString("hex"));
// Server side computes B = (k*v + g^b) mod N (hand-rolled for the example).
const k = 3n; // simplified — production: H(N||g)
const b = 7n;
const B = ((k * v) + pow(SRP_G, b, SRP_N_2048)) % SRP_N_2048;
// Client side derives session key and proof.
const { a, A } = client.generateEphemeralKeypair();
const result = client.deriveSessionKey(a, A, B, x);
console.log(result.K.length); // 32
```
## API Reference
### `class SrpClient`
#### `new SrpClient(options?)`
`SrpClientOptions` accepts:
- `clock?: () => number` — defaults to `Date.now`.
- `rng?: Rng` — overrides the default CSPRNG for salt and ephemeral
generation.
- `N?: bigint`, `g?: bigint` — group overrides for tests. Construction
rejects any `N` shorter than 2048 bits with `SRP.WEAK_PARAMETERS`.
#### `generateSalt(length = 16): Uint8Array`
Random salt. Honours `rng?.nextBytes` if provided; otherwise uses
`crypto.getRandomValues` via `@suaveplan/crypto-utils`.
#### `derivePrivateKey(identity, password, salt): Uint8Array`
Computes `x = H(salt, H(identity:password))`. Throws
`SRP.INVALID_PRIVATE_KEY` for empty inputs.
#### `deriveVerifier(privateKey): Uint8Array`
Returns a 256-byte big-endian `v = g^x mod N`.
#### `generateEphemeralKeypair(): EphemeralKeypair`
`{ a: bigint; A: bigint }` — `a` is the private exponent (kept by the
client), `A` the public form sent to the server. Reseeds when the entropy
collapses to zero.
#### `deriveSessionKey(a, A, B, privateKey): SessionKeyResult`
Returns `{ K, S, M1 }` per the RFC. Throws `SRP.INVALID_B` when `B mod N`
is zero.
#### `verifyServerProof(A, M1, K, serverProof): boolean`
Constant-time compare with the recomputed `M2 = H(A || M1 || K)`. Throws
`SRP.AUTHENTICATION_FAILED` on mismatch; otherwise returns `true`.
#### `computeServerProof(A, M1, K): Uint8Array`
Helper exposed for symmetric tests and server-side adapters.
## Implementation Notes
- The implementation deliberately keeps every state transition pure —
callers thread `(a, A, B, x)` through the methods rather than relying
on instance fields.
- All `bigint` ↔ byte conversions are big-endian and zero-padded to the
group byte length so that hashing is deterministic regardless of the
numeric value.
- `k = H(N || g)` and `u = H(A || B)` use SHA-256 with both inputs padded
to 256 bytes per RFC 5054 §2.5.3 / §2.6.
- Constant-time comparison protects against timing-based proof guessing,
matching the broader Suaveplan posture of using `constantTimeEqual` for
every MAC / proof check.

View File

@@ -0,0 +1,331 @@
import { pow } from "@noble/curves/abstract/modular";
import { sha256 } from "@suaveplan/crypto-hash";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import { SrpClient } from "./srp-client.js";
import { SRP_G, SRP_N_2048, SRP_N_BYTELEN } from "./srp-group.js";
const BYTE_LEN = SRP_N_BYTELEN;
function bigintToBytes(value: bigint, length: number): Uint8Array {
const out = new Uint8Array(length);
let v = value;
for (let i = length - 1; i >= 0; i--) {
out[i] = Number(v & 0xffn);
v >>= 8n;
}
return out;
}
function bytesToBigint(bytes: Uint8Array): bigint {
let n = 0n;
for (const byte of bytes) {
n = (n << 8n) | BigInt(byte);
}
return n;
}
function concat(...parts: Uint8Array[]): Uint8Array {
let total = 0;
for (const p of parts) total += p.length;
const out = new Uint8Array(total);
let offset = 0;
for (const p of parts) {
out.set(p, offset);
offset += p.length;
}
return out;
}
function H(...parts: Uint8Array[]): Uint8Array {
return sha256(concat(...parts));
}
/**
* Reference server simulator — used only in tests. Mirrors the SRP-6a server
* computation per RFC 5054 §2.5/§2.6.
*/
function serverDeriveSession(
A: bigint,
v: bigint,
b: bigint,
N: bigint,
g: bigint
): { B: bigint; S: bigint } {
const nPad = bigintToBytes(N, BYTE_LEN);
const gPad = bigintToBytes(g, BYTE_LEN);
const k = bytesToBigint(H(nPad, gPad));
const B = (((k * v) % N) + pow(g, b, N)) % N;
const aPad = bigintToBytes(A, BYTE_LEN);
const bPadVal = bigintToBytes(B, BYTE_LEN);
const u = bytesToBigint(H(aPad, bPadVal));
const Avu = (A * pow(v, u, N)) % N;
const S = pow(Avu, b, N);
return { B, S };
}
describe("SrpClient construction", () => {
it("rejects weak group parameters", { timeout: 5000 }, () => {
try {
new SrpClient({ N: 23n, g: 2n });
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["SRP.WEAK_PARAMETERS"]
);
}
});
it("accepts the default group", { timeout: 5000 }, () => {
expect(() => new SrpClient()).not.toThrow();
});
it("honours injected clock and rng options", { timeout: 5000 }, () => {
const client = new SrpClient({
clock: () => 0,
rng: {
nextUint32: () => 0,
nextFloat: () => 0,
nextBytes: (n) => new Uint8Array(n).fill(7),
},
});
const salt = client.generateSalt();
expect(Array.from(salt).every((b) => b === 7)).toBe(true);
});
});
describe("SrpClient.generateSalt", () => {
it("returns the requested length", { timeout: 5000 }, () => {
const client = new SrpClient();
expect(client.generateSalt(8).length).toBe(8);
expect(client.generateSalt(32).length).toBe(32);
});
it("salt is unique per generation", { timeout: 5000 }, () => {
const client = new SrpClient();
const a = client.generateSalt();
const b = client.generateSalt();
expect(Array.from(a)).not.toEqual(Array.from(b));
});
it("injectable rng produces deterministic salt", { timeout: 5000 }, () => {
const client = new SrpClient({
rng: {
nextUint32: () => 0,
nextFloat: () => 0,
nextBytes: (n) => new Uint8Array(n).fill(0xee),
},
});
const a = client.generateSalt();
const b = client.generateSalt();
expect(Array.from(a)).toEqual(Array.from(b));
});
it("uses default RNG when no nextBytes is present", {
timeout: 5000,
}, () => {
const client = new SrpClient({
rng: { nextUint32: () => 0, nextFloat: () => 0 },
});
expect(client.generateSalt().length).toBe(16);
});
});
describe("SrpClient.derivePrivateKey", () => {
const client = new SrpClient();
const salt = new Uint8Array(16).fill(0x77);
it("derives a 32-byte private key (SHA-256 output)", {
timeout: 5000,
}, () => {
const key = client.derivePrivateKey("alice", "pw", salt);
expect(key.length).toBe(32);
});
it("rejects empty identity", { timeout: 5000 }, () => {
try {
client.derivePrivateKey("", "pw", salt);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["SRP.INVALID_PRIVATE_KEY"]
);
}
});
it("rejects empty password", { timeout: 5000 }, () => {
expect(() => client.derivePrivateKey("alice", "", salt)).toThrow(
CryptoVaultError
);
});
it("rejects empty salt", { timeout: 5000 }, () => {
expect(() =>
client.derivePrivateKey("alice", "pw", new Uint8Array(0))
).toThrow(CryptoVaultError);
});
});
describe("SrpClient.deriveVerifier", () => {
const client = new SrpClient();
it("verifier is deterministic", { timeout: 5000 }, () => {
const x = new Uint8Array(32).fill(1);
const v1 = client.deriveVerifier(x);
const v2 = client.deriveVerifier(x);
expect(Array.from(v1)).toEqual(Array.from(v2));
expect(v1.length).toBe(BYTE_LEN);
});
});
describe("SrpClient.generateEphemeralKeypair", () => {
const client = new SrpClient();
it("returns a private exponent and the corresponding public", {
timeout: 5000,
}, () => {
const { a, A } = client.generateEphemeralKeypair();
expect(typeof a).toBe("bigint");
expect(A).toBe(pow(SRP_G, a, SRP_N_2048));
});
it("avoids zero exponent when rng returns all-zero bytes", {
timeout: 5000,
}, () => {
const c = new SrpClient({
rng: {
nextUint32: () => 0,
nextFloat: () => 0,
nextBytes: (n) => new Uint8Array(n),
},
});
const { a, A } = c.generateEphemeralKeypair();
expect(a > 0n).toBe(true);
expect(A).toBe(pow(SRP_G, a, SRP_N_2048));
});
});
describe("SrpClient.deriveSessionKey + verifyServerProof — full handshake", () => {
it("full handshake produces matching session keys", {
timeout: 30_000,
}, () => {
const client = new SrpClient();
const identity = "alice@example.com";
const password = "correct horse battery staple";
const salt = client.generateSalt();
const x = client.derivePrivateKey(identity, password, salt);
const vBytes = client.deriveVerifier(x);
const v = bytesToBigint(vBytes);
const { a, A } = client.generateEphemeralKeypair();
const b = bytesToBigint(new Uint8Array(32).fill(0x42));
const { B, S: serverS } = serverDeriveSession(
A,
v,
b,
SRP_N_2048,
SRP_G
);
const { K, S, M1 } = client.deriveSessionKey(a, A, B, x);
expect(S).toBe(serverS);
const serverK = sha256(bigintToBytes(serverS, BYTE_LEN));
expect(Array.from(K)).toEqual(Array.from(serverK));
const serverProof = client.computeServerProof(A, M1, K);
expect(client.verifyServerProof(A, M1, K, serverProof)).toBe(true);
});
it("wrong password yields authentication failure", {
timeout: 30_000,
}, () => {
const client = new SrpClient();
const salt = client.generateSalt();
const xCorrect = client.derivePrivateKey("alice", "right", salt);
const v = bytesToBigint(client.deriveVerifier(xCorrect));
// Client now uses the WRONG password.
const xWrong = client.derivePrivateKey("alice", "wrong", salt);
const { a, A } = client.generateEphemeralKeypair();
const b = bytesToBigint(new Uint8Array(32).fill(0x42));
const { B, S: serverS } = serverDeriveSession(
A,
v,
b,
SRP_N_2048,
SRP_G
);
const { K, M1 } = client.deriveSessionKey(a, A, B, xWrong);
const serverK = sha256(bigintToBytes(serverS, BYTE_LEN));
expect(Array.from(K)).not.toEqual(Array.from(serverK));
const serverProof = client.computeServerProof(A, M1, serverK);
try {
client.verifyServerProof(A, M1, K, serverProof);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["SRP.AUTHENTICATION_FAILED"]
);
}
});
it("tampered server proof is rejected", { timeout: 30_000 }, () => {
const client = new SrpClient();
const salt = client.generateSalt();
const x = client.derivePrivateKey("alice", "pw", salt);
const v = bytesToBigint(client.deriveVerifier(x));
const { a, A } = client.generateEphemeralKeypair();
const b = bytesToBigint(new Uint8Array(32).fill(0x33));
const { B } = serverDeriveSession(A, v, b, SRP_N_2048, SRP_G);
const { K, M1 } = client.deriveSessionKey(a, A, B, x);
const tampered = client.computeServerProof(A, M1, K);
tampered[0] ^= 0xff;
try {
client.verifyServerProof(A, M1, K, tampered);
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["SRP.AUTHENTICATION_FAILED"]
);
}
});
it("rejects B that is 0 mod N", { timeout: 5000 }, () => {
const client = new SrpClient();
const x = new Uint8Array(32).fill(1);
const { a, A } = client.generateEphemeralKeypair();
try {
client.deriveSessionKey(a, A, SRP_N_2048, x); // B mod N = 0
expect.unreachable();
} catch (err) {
expect(err).toBeInstanceOf(CryptoVaultError);
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["SRP.INVALID_B"]
);
}
});
});
describe("SrpClient — defence in depth", () => {
it("constant-time compare returns the same proof on mismatch", {
timeout: 5000,
}, () => {
const client = new SrpClient();
const x = new Uint8Array(32).fill(1);
const { a, A } = client.generateEphemeralKeypair();
const v = bytesToBigint(client.deriveVerifier(x));
const b = bytesToBigint(new Uint8Array(32).fill(7));
const { B } = serverDeriveSession(A, v, b, SRP_N_2048, SRP_G);
const { K, M1 } = client.deriveSessionKey(a, A, B, x);
const wrong = new Uint8Array(K.length).fill(0xff);
try {
client.verifyServerProof(A, M1, K, wrong);
expect.unreachable();
} catch (err) {
expect((err as CryptoVaultError).code).toBe(
CRYPTO_VAULT_CODES["SRP.AUTHENTICATION_FAILED"]
);
}
});
});

View File

@@ -0,0 +1,267 @@
import { pow } from "@noble/curves/abstract/modular";
import { sha256 } from "@suaveplan/crypto-hash";
import {
constantTimeEqual,
randomBytes as cryptoRandomBytes,
} from "@suaveplan/crypto-utils";
import type { Rng } from "@suaveplan/types";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import { getCryptoVaultInstruments } from "../otel/instruments.js";
import { SRP_G, SRP_N_2048, SRP_N_BITLEN, SRP_N_BYTELEN } from "./srp-group.js";
/** Result of `deriveSessionKey`. */
export interface SessionKeyResult {
/** Shared SHA-256 session key K = SHA-256(S). */
readonly K: Uint8Array;
/** Premaster secret S as a `bigint` — internal; do not transmit. */
readonly S: bigint;
/** Client proof M1 = H(A || B || K). */
readonly M1: Uint8Array;
}
/** Ephemeral SRP keypair. */
export interface EphemeralKeypair {
/** Private exponent a (kept on the client). */
readonly a: bigint;
/** Public ephemeral A = g^a mod N (sent to the server). */
readonly A: bigint;
}
/** Constructor options for `SrpClient`. */
export interface SrpClientOptions {
/** Wall-clock source — used for handshake telemetry only. */
readonly clock?: () => number;
/** Random source — overrides the default `crypto.getRandomValues`. */
readonly rng?: Rng;
/** Override the group prime for testing. Defaults to RFC 5054 2048-bit. */
readonly N?: bigint;
/** Override the group generator for testing. Defaults to `2`. */
readonly g?: bigint;
}
const TEXT = new TextEncoder();
const BYTE_LENGTH = SRP_N_BYTELEN;
function bigintToBytes(value: bigint, length: number): Uint8Array {
const out = new Uint8Array(length);
let v = value;
for (let i = length - 1; i >= 0; i--) {
out[i] = Number(v & 0xffn);
v >>= 8n;
}
return out;
}
function bytesToBigint(bytes: Uint8Array): bigint {
let n = 0n;
for (const byte of bytes) {
n = (n << 8n) | BigInt(byte);
}
return n;
}
function concat(...parts: Uint8Array[]): Uint8Array {
let total = 0;
for (const p of parts) total += p.length;
const out = new Uint8Array(total);
let offset = 0;
for (const p of parts) {
out.set(p, offset);
offset += p.length;
}
return out;
}
function H(...parts: Uint8Array[]): Uint8Array {
return sha256(concat(...parts));
}
/**
* Stateless client implementation of the SRP-6a protocol per RFC 5054.
*
* The class is "stateless" in the sense that no method mutates instance
* fields except the protocol-required constants. Each handshake threads
* derived material (private key, ephemeral keys, session key) through the
* caller's local variables — never through the client.
*
* Methods are pure (modulo entropy) and can be invoked in any order; the
* caller is responsible for sequencing per RFC 5054 §2.
*/
export class SrpClient {
public readonly N: bigint;
public readonly g: bigint;
private readonly clock: () => number;
private readonly rng?: Rng;
public constructor(options: SrpClientOptions = {}) {
this.N = options.N ?? SRP_N_2048;
this.g = options.g ?? SRP_G;
this.clock = options.clock ?? Date.now;
if (options.rng !== undefined) {
this.rng = options.rng;
}
// Reject groups smaller than the RFC 5054 minimum recommendation.
if (this.N.toString(2).length < SRP_N_BITLEN) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["SRP.WEAK_PARAMETERS"],
"error.crypto-vault.srp.weak-parameters",
{
context: {
bitLength: this.N.toString(2).length,
minimum: SRP_N_BITLEN,
},
}
);
}
}
/** Generate a fresh 16-byte salt. */
public generateSalt(length = 16): Uint8Array {
if (this.rng?.nextBytes !== undefined) {
return this.rng.nextBytes(length);
}
return cryptoRandomBytes(length);
}
/**
* Derive the SRP private key `x = H(salt || H(I || ":" || P))`
* per RFC 5054 §2.6.
*/
public derivePrivateKey(
identity: string,
password: string,
salt: Uint8Array
): Uint8Array {
if (typeof identity !== "string" || identity.trim() === "") {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["SRP.INVALID_PRIVATE_KEY"],
"error.crypto-vault.srp.invalid-private-key",
{ context: { reason: "empty-identity" } }
);
}
if (typeof password !== "string" || password.length === 0) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["SRP.INVALID_PRIVATE_KEY"],
"error.crypto-vault.srp.invalid-private-key",
{ context: { reason: "empty-password" } }
);
}
if (salt.length === 0) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["SRP.INVALID_PRIVATE_KEY"],
"error.crypto-vault.srp.invalid-private-key",
{ context: { reason: "empty-salt" } }
);
}
const inner = H(TEXT.encode(`${identity}:${password}`));
return H(salt, inner);
}
/** Compute the verifier `v = g^x mod N`. */
public deriveVerifier(privateKey: Uint8Array): Uint8Array {
const x = bytesToBigint(privateKey);
const v = pow(this.g, x, this.N);
return bigintToBytes(v, BYTE_LENGTH);
}
/** Generate a fresh ephemeral keypair `(a, A = g^a mod N)`. */
public generateEphemeralKeypair(): EphemeralKeypair {
const aBytes =
this.rng?.nextBytes !== undefined
? this.rng.nextBytes(BYTE_LENGTH)
: cryptoRandomBytes(BYTE_LENGTH);
const a = bytesToBigint(aBytes) % (this.N - 1n);
const aSafe = a === 0n ? 1n : a;
const A = pow(this.g, aSafe, this.N);
return { a: aSafe, A };
}
/**
* Derive the SRP session key from the client and server ephemeral
* publics. Throws when the server's `B` is invalid (`B mod N == 0`).
*/
public deriveSessionKey(
clientEphemeralPrivate: bigint,
clientEphemeralPublic: bigint,
serverEphemeralPublic: bigint,
privateKey: Uint8Array
): SessionKeyResult {
if (serverEphemeralPublic % this.N === 0n) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["SRP.INVALID_B"],
"error.crypto-vault.srp.invalid-b"
);
}
const start = this.clock();
const aPad = bigintToBytes(clientEphemeralPublic, BYTE_LENGTH);
const bPad = bigintToBytes(serverEphemeralPublic, BYTE_LENGTH);
// u = H(A || B) per RFC 5054 §2.6. SHA-256 ensures the value is
// non-zero with overwhelming probability — finding A and B such that
// SHA-256(A || B) is the all-zero block requires a preimage of
// SHA-256, which is computationally infeasible. The `B mod N == 0`
// guard above is therefore the only practically reachable abort
// condition for invalid server input.
const u = bytesToBigint(H(aPad, bPad));
// k = H(N || g) per RFC 5054 §2.5.3.
const nPad = bigintToBytes(this.N, BYTE_LENGTH);
const gPad = bigintToBytes(this.g, BYTE_LENGTH);
const k = bytesToBigint(H(nPad, gPad));
const x = bytesToBigint(privateKey);
// S = (B - k * g^x)^(a + u * x) mod N
const gx = pow(this.g, x, this.N);
const kgx = (k * gx) % this.N;
const base = (serverEphemeralPublic - kgx + this.N) % this.N;
const exponent = clientEphemeralPrivate + u * x;
const S = pow(base, exponent, this.N);
const sPad = bigintToBytes(S, BYTE_LENGTH);
const K = sha256(sPad);
const M1 = H(aPad, bPad, K);
getCryptoVaultInstruments().srpHandshakeDurationMs.record(
this.clock() - start
);
return { K, S, M1 };
}
/**
* Verify the server's proof M2 = H(A || M1 || K). Returns `true` on
* match; throws `CryptoVaultError` (`SRP.AUTHENTICATION_FAILED`) on
* mismatch.
*/
public verifyServerProof(
clientEphemeralPublic: bigint,
m1: Uint8Array,
sessionKey: Uint8Array,
serverProof: Uint8Array
): boolean {
const aPad = bigintToBytes(clientEphemeralPublic, BYTE_LENGTH);
const expected = H(aPad, m1, sessionKey);
const ok = constantTimeEqual(expected, serverProof);
const i = getCryptoVaultInstruments();
i.srpAuthTotal.add(1, { status: ok ? "success" : "failure" });
if (!ok) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["SRP.AUTHENTICATION_FAILED"],
"error.crypto-vault.srp.authentication-failed"
);
}
return true;
}
/**
* Server-side helper used in tests and for symmetric verification:
* compute M2 = H(A || M1 || K). Production servers compute this
* themselves; consumers normally do not call this.
*/
public computeServerProof(
clientEphemeralPublic: bigint,
m1: Uint8Array,
sessionKey: Uint8Array
): Uint8Array {
return H(
bigintToBytes(clientEphemeralPublic, BYTE_LENGTH),
m1,
sessionKey
);
}
}

View File

@@ -0,0 +1,71 @@
# SRP-6a 2048-bit group
## Purpose
`srp-group.ts` exports the canonical 2048-bit MODP group from RFC 5054
Appendix A as `bigint` constants. The SRP-6a client uses this group to
exchange ephemeral keys, derive the shared secret, and produce mutual
proofs. The constants are pinned because they are part of the protocol
contract — switching groups silently would break interoperability with
every server using the same RFC.
## Features
- `SRP_N_2048` — the 2048-bit safe prime `N` as a positive `bigint`.
- `SRP_G` — the generator `g = 2`.
- `SRP_N_BITLEN = 2048`, `SRP_N_BYTELEN = 256` — convenience constants for
byte-padding and validation.
## Usage: Basic Example
```ts
import { SRP_G, SRP_N_2048 } from "@suaveplan/crypto-vault/srp/srp-group";
console.log(SRP_N_2048.toString(2).length); // 2048
console.log(SRP_G); // 2n
```
## Usage: Advanced Example
```ts
import {
SRP_G,
SRP_N_2048,
SRP_N_BITLEN,
SRP_N_BYTELEN,
} from "@suaveplan/crypto-vault/srp/srp-group";
import { pow } from "@noble/curves/abstract/modular";
// Compute v = g^x mod N for an arbitrary private key x.
function makeVerifier(x: bigint): bigint {
return pow(SRP_G, x, SRP_N_2048);
}
console.log(SRP_N_BITLEN); // 2048
console.log(SRP_N_BYTELEN); // 256
console.log(typeof makeVerifier(123n)); // bigint
```
## API Reference
### `SRP_N_2048: bigint`
The exact 2048-bit safe prime defined in RFC 5054 Appendix A, parsed from
the canonical hex constant.
### `SRP_G: bigint`
The generator: `2n`.
### `SRP_N_BITLEN: number`, `SRP_N_BYTELEN: number`
Bit length (`2048`) and byte length (`256`) of the prime.
## Implementation Notes
- The hex string is laid out as a TypeScript string concatenation matching
the line breaks in the RFC for diffability against the source.
- The constant is parsed once at module load; subsequent imports share the
resulting `bigint`.
- The 1024-bit and 1536-bit groups from the same RFC are intentionally
omitted. RFC 5054 §1 states they should be deprecated for new
deployments, and the proposal pins us to ≥ 2048 bits.

View File

@@ -0,0 +1,30 @@
/**
* RFC 5054 Appendix A — 2048-bit MODP group used by SRP-6a.
*
* `N` is the safe prime, `g = 2` is the generator. Both values are constants
* from the RFC; they are not part of the protocol negotiation. The bit
* length is pinned by tests so future drift is caught immediately.
*/
const N_2048_HEX =
"AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050" +
"A37329CBB4A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50" +
"E8083969EDB767B0CF6095179A163AB3661A05FBD5FAAAE82918A9962F0B93B8" +
"55F97993EC975EEAA80D740ADBF4FF747359D041D5C33EA71D281E446B14773B" +
"CA97B43A23FB801676BD207A436C6481F1D2B9078717461A5B9D32E688F87748" +
"544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB37861602790" +
"04E57AE6AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8" +
"E9DBFBB694B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F" +
"9E4AFF73";
/** The 2048-bit safe prime `N` from RFC 5054 Appendix A as a `bigint`. */
export const SRP_N_2048: bigint = BigInt(`0x${N_2048_HEX}`);
/** Generator `g = 2` per RFC 5054. */
export const SRP_G: bigint = 2n;
/** Bit length of the safe prime — used for validity checks. */
export const SRP_N_BITLEN = 2048;
/** Byte length of the safe prime padded to a multiple of 8. */
export const SRP_N_BYTELEN = SRP_N_BITLEN / 8;

View File

@@ -0,0 +1,115 @@
# Two-Secret Key Derivation (2SKD)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`twoskd.ts` derives the Account Unlock Key (AUK) from three independent
factors: the user's password (something they know), the Secret Key (a
high-entropy device-bound string — something they have), and the account
email (binding the derivation to the account identity). 2SKD is the entry
point of the entire crypto-vault hierarchy: every downstream key — personal
keyset, vault key, item key — is HKDF-derived from this AUK. Compromising
any single factor does not yield the AUK; an attacker must compromise both
the password and the Secret Key.
## Features
- Combines `password`, `secretKey`, and `email` into a single Argon2id input
via HMAC-SHA-256.
- Uses the project-wide OWASP-safe Argon2id wrapper for the slow step.
- Email is normalised (trim + lowercase) before mixing so users entering
`Alice@Example.COM` and `alice@example.com` derive the same AUK.
- Empty-input rejection: empty `password`, empty `secretKey`, or
empty/whitespace `email` throws a typed `CryptoVaultError`.
- Honours `CryptoVaultOptions.clock` and `rng` so consumers can inject
deterministic test sources without changing the call surface.
## Usage: Basic Example
```ts
import { deriveAuk } from "@suaveplan/crypto-vault/twoskd/twoskd";
const salt = crypto.getRandomValues(new Uint8Array(16));
const auk = await deriveAuk(
"correct horse battery staple",
"A3-7PMW82-XJZS9-NQDLV-EYKBR",
"alice@example.com",
salt
);
console.log(auk.length); // 32
```
## Usage: Advanced Example
```ts
import {
DEFAULT_ARGON2ID_PARAMS,
type Argon2idParams,
} from "@suaveplan/crypto-vault/argon2id/argon2id";
import { deriveAuk } from "@suaveplan/crypto-vault/twoskd/twoskd";
const high: Argon2idParams = {
...DEFAULT_ARGON2ID_PARAMS,
memoryKB: 131_072, // 128 MiB
iterations: 4,
};
const salt = crypto.getRandomValues(new Uint8Array(16));
// Deterministic for the same triple — useful for unlock retry without
// re-running the user through the whole prompt sequence.
const auk1 = await deriveAuk(
"pw",
"sk",
"alice@example.com",
salt,
high,
{ clock: () => 0 }
);
const auk2 = await deriveAuk(
"pw",
"sk",
"ALICE@example.com",
salt,
high,
{ clock: () => 0 }
);
console.log(Buffer.from(auk1).equals(Buffer.from(auk2))); // true (email normalised)
```
## API Reference
### `deriveAuk(password, secretKey, email, salt, params?, options?)`
- `password: string` — non-empty.
- `secretKey: string` — non-empty (high-entropy 30+ chars recommended).
- `email: string` — non-empty / non-whitespace; normalised internally.
- `salt: Uint8Array` — non-empty (16+ bytes recommended).
- `params: Argon2idParams = DEFAULT_ARGON2ID_PARAMS` — validated through
`assertSafeArgon2idParams`.
- `options: CryptoVaultOptions = {}` — `{ clock?, rng? }`.
Returns `Promise<Uint8Array>` — the 32-byte AUK by default.
Throws `CryptoVaultError`:
- `TWOSKD.MISSING_PASSWORD`, `TWOSKD.MISSING_SECRET_KEY`,
`TWOSKD.MISSING_EMAIL` for empty inputs.
- `ARGON2ID.UNSAFE_PARAMETERS` propagated from the Argon2id wrapper.
- `ARGON2ID.INVALID_INPUT` for an empty `salt`.
## Implementation Notes
- HMAC-SHA-256 is used as the input commit step rather than concatenation
alone, so a 32-byte fixed-length string enters Argon2id regardless of
the password length.
- Email normalisation matches the rules used by upstream 1Password 2SKD
(RFC 5321 dot-atom rules are not applied — only trim + lowercase). This
preserves portability with other 2SKD-style password managers.
- The order of bytes inside the HMAC message matters and is fixed:
`password || 0x00 || email`. The null byte prevents collisions where
`("ab", "c")` and `("a", "bc")` would otherwise hash identically.
- `options.rng` is not consumed by 2SKD because the derivation is fully
deterministic; it is accepted only to match the broader
`CryptoVaultOptions` shape and is silently discarded.

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import { deriveAuk } from "./twoskd.js";
const salt = new Uint8Array(16).fill(0x42);
describe("deriveAuk", () => {
it("deterministic AUK for same inputs", { timeout: 60_000 }, async () => {
const a = await deriveAuk("pw", "sk", "alice@example.com", salt);
const b = await deriveAuk("pw", "sk", "alice@example.com", salt);
expect(Array.from(a)).toEqual(Array.from(b));
expect(a.length).toBe(32);
});
it("different password yields different AUK", {
timeout: 60_000,
}, async () => {
const a = await deriveAuk("pw", "sk", "alice@example.com", salt);
const b = await deriveAuk("different", "sk", "alice@example.com", salt);
expect(Array.from(a)).not.toEqual(Array.from(b));
});
it("different secret key yields different AUK", {
timeout: 60_000,
}, async () => {
const a = await deriveAuk("pw", "sk", "alice@example.com", salt);
const b = await deriveAuk(
"pw",
"different-sk",
"alice@example.com",
salt
);
expect(Array.from(a)).not.toEqual(Array.from(b));
});
it("different email yields different AUK", {
timeout: 60_000,
}, async () => {
const a = await deriveAuk("pw", "sk", "alice@example.com", salt);
const b = await deriveAuk("pw", "sk", "bob@example.com", salt);
expect(Array.from(a)).not.toEqual(Array.from(b));
});
it("normalises email (case + whitespace)", {
timeout: 60_000,
}, async () => {
const a = await deriveAuk("pw", "sk", "alice@example.com", salt);
const b = await deriveAuk("pw", "sk", " ALICE@Example.COM ", salt);
expect(Array.from(a)).toEqual(Array.from(b));
});
it("rejects empty password", { timeout: 5000 }, async () => {
await expect(
deriveAuk("", "sk", "alice@example.com", salt)
).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES["TWOSKD.MISSING_PASSWORD"],
});
});
it("rejects empty secret key", { timeout: 5000 }, async () => {
await expect(
deriveAuk("pw", "", "alice@example.com", salt)
).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES["TWOSKD.MISSING_SECRET_KEY"],
});
});
it("rejects empty email", { timeout: 5000 }, async () => {
await expect(deriveAuk("pw", "sk", "", salt)).rejects.toBeInstanceOf(
CryptoVaultError
);
});
it("rejects whitespace-only email", { timeout: 5000 }, async () => {
await expect(deriveAuk("pw", "sk", " ", salt)).rejects.toMatchObject({
code: CRYPTO_VAULT_CODES["TWOSKD.MISSING_EMAIL"],
});
});
it("accepts injectable clock and rng", { timeout: 60_000 }, async () => {
let clockCalls = 0;
const auk = await deriveAuk(
"pw",
"sk",
"alice@example.com",
salt,
undefined,
{
clock: () => ++clockCalls,
rng: { nextUint32: () => 0, nextFloat: () => 0 },
}
);
expect(auk.length).toBe(32);
expect(clockCalls).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -0,0 +1,87 @@
import { hmac } from "@suaveplan/crypto-hash";
import {
DEFAULT_ARGON2ID_PARAMS,
deriveKeyWithArgon2id,
} from "../argon2id/argon2id.js";
import { CRYPTO_VAULT_CODES } from "../errors/codes.js";
import { CryptoVaultError } from "../errors/crypto-vault-error.js";
import type {
Argon2idParams,
Auk,
CryptoVaultOptions,
} from "../types/types.js";
/**
* Derive the Account Unlock Key (AUK) via Two-Secret Key Derivation.
*
* The combinator is, in order:
*
* 1. `email = email.trim().toLowerCase()` — normalised account identifier.
* 2. `combined = HMAC-SHA256(secretKey, password || 0x00 || email)` — mixes
* "what you know" (the password) with "what you have" (the Secret Key)
* and binds the derivation to the account.
* 3. `Argon2id(combined, salt, params)` — applies the slow memory-hard
* function so that even a leak of `combined` does not let an attacker
* brute-force the password without the configured Argon2id cost.
*
* Compromise of the password alone leaks nothing — Argon2id requires the
* Secret Key to compute `combined`. Compromise of the Secret Key alone
* leaks nothing — Argon2id input still requires the password and email.
*
* @throws CryptoVaultError(CRYPTOVAULT.TWOSKD.MISSING_PASSWORD) for empty
* `password`.
* @throws CryptoVaultError(CRYPTOVAULT.TWOSKD.MISSING_SECRET_KEY) for empty
* `secretKey`.
* @throws CryptoVaultError(CRYPTOVAULT.TWOSKD.MISSING_EMAIL) for empty
* `email`.
* @throws CryptoVaultError(CRYPTOVAULT.ARGON2ID.UNSAFE_PARAMETERS) for any
* parameter outside the OWASP / hard-cap bands.
*/
export async function deriveAuk(
password: string,
secretKey: string,
email: string,
salt: Uint8Array,
params: Argon2idParams = DEFAULT_ARGON2ID_PARAMS,
options: CryptoVaultOptions = {}
): Promise<Auk> {
if (typeof password !== "string" || password.length === 0) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["TWOSKD.MISSING_PASSWORD"],
"error.crypto-vault.twoskd.missing-password"
);
}
if (typeof secretKey !== "string" || secretKey.length === 0) {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["TWOSKD.MISSING_SECRET_KEY"],
"error.crypto-vault.twoskd.missing-secret-key"
);
}
if (typeof email !== "string" || email.trim() === "") {
throw new CryptoVaultError(
CRYPTO_VAULT_CODES["TWOSKD.MISSING_EMAIL"],
"error.crypto-vault.twoskd.missing-email"
);
}
const enc = new TextEncoder();
const passwordBytes = enc.encode(password);
const secretKeyBytes = enc.encode(secretKey);
const emailNormalised = email.trim().toLowerCase();
const emailBytes = enc.encode(emailNormalised);
const message = new Uint8Array(
passwordBytes.length + 1 + emailBytes.length
);
message.set(passwordBytes, 0);
message[passwordBytes.length] = 0x00;
message.set(emailBytes, passwordBytes.length + 1);
const combined = hmac("SHA-256", secretKeyBytes, message);
// Discard `options.rng` — 2SKD is fully deterministic. `options.clock`
// is forwarded into the Argon2id telemetry path below.
void options.rng;
return deriveKeyWithArgon2id(
combined,
salt,
params,
options.clock !== undefined ? { clock: options.clock } : {}
);
}

View File

@@ -0,0 +1,101 @@
# crypto-vault — Public types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`types.ts` collects the cross-module type surface shared across
`@suaveplan/crypto-vault`. Every type here is consumed by ≥2 modules — for
example `Argon2idParams` is used by both the `argon2id/argon2id` and
`twoskd/twoskd` derivation paths, and `Auk` / `PersonalKeyset` flow from the
2SKD layer through the key-hierarchy layer. Pulling these types into a single
file keeps the public contract reviewable and prevents accidental shape drift
between feature modules.
## Features
- `CryptoVaultOptions` — common `{ clock?, rng? }` bag accepted by every
telemetry-aware entry point.
- `Argon2idParams` — Suaveplan-flavoured Argon2id parameter shape mapped onto
noble's underlying `(memoryCost, timeCost, parallelism, length)` at the
call site.
- `PersonalKeyset` — three-key bundle (`encryptKey`, `signKey`, `kxKey`)
derived from the AUK.
- `VaultKey`, `ItemKey`, `Auk` — typed aliases for 32-byte `Uint8Array`s
flowing through the hierarchy.
## Usage: Basic Example
```ts
import type { Argon2idParams } from "@suaveplan/crypto-vault/types";
const owaspMin: Argon2idParams = {
memoryKB: 65536,
iterations: 3,
parallelism: 4,
outputLength: 32,
};
```
## Usage: Advanced Example
```ts
import type {
Auk,
CryptoVaultOptions,
PersonalKeyset,
} from "@suaveplan/crypto-vault/types";
function summarisePerKeyset(
auk: Auk,
keyset: PersonalKeyset,
options?: CryptoVaultOptions
): { ts: number; lengths: [number, number, number] } {
const ts = (options?.clock ?? Date.now)();
return {
ts,
lengths: [
keyset.encryptKey.length,
keyset.signKey.length,
keyset.kxKey.length,
],
};
}
```
## API Reference
### `CryptoVaultOptions`
- `clock?: Clock` — wall-clock time source from `@suaveplan/types`.
- `rng?: Rng` — random source from `@suaveplan/types`.
### `Argon2idParams`
- `memoryKB: number` — memory cost in KiB.
- `iterations: number` — Argon2id `t` (time cost).
- `parallelism: number` — Argon2id `p` (lane count).
- `outputLength: number` — derived key length in bytes.
### `PersonalKeyset`
- `encryptKey: Uint8Array` — 32 bytes.
- `signKey: Uint8Array` — 32 bytes.
- `kxKey: Uint8Array` — 32 bytes.
### `VaultKey`, `ItemKey`, `Auk`
Typed aliases for `Uint8Array`. They are nominal in name only (TypeScript
treats them structurally) — runtime guarantees come from the modules that
produce them (each enforces the 32-byte length).
## Implementation Notes
- The file lives outside any feature directory to make it cheap to import
without dragging in module-level OTel instruments or runtime code.
- `Argon2idParams` deliberately uses Suaveplan-flavoured names. Internally
these map to noble's `Argon2idOptions` (`memoryCost`, `timeCost`,
`parallelism`, `length`) at the wrapper boundary in `argon2id/argon2id.ts`.
- `Clock` and `Rng` are imported from `@suaveplan/types` so this file does
not re-declare cross-cutting contracts.

View File

@@ -0,0 +1,53 @@
import type { Clock, Rng } from "@suaveplan/types";
/**
* Options bag for telemetry-aware crypto-vault entry points. Every public
* derivation accepts this shape so consumers can inject deterministic time
* and entropy sources without changing the call surface.
*/
export interface CryptoVaultOptions {
/** Wall-clock time source. Defaults to `() => Date.now()`. */
readonly clock?: Clock;
/** Random source (CSPRNG-grade). Defaults to a `crypto.getRandomValues` adapter. */
readonly rng?: Rng;
}
/**
* Argon2id derivation parameters surfaced through `crypto-vault`.
*
* Suaveplan-flavoured names (memoryKB / iterations / parallelism / outputLength)
* are used at the public surface. They map onto noble's
* (memoryCost / timeCost / parallelism / length) at the call site.
*/
export interface Argon2idParams {
/** Argon2id memory cost in KiB. OWASP minimum: 65536 (= 64 MiB). */
readonly memoryKB: number;
/** Argon2id time cost (iteration count). OWASP minimum: 3. */
readonly iterations: number;
/** Argon2id parallelism (lanes). OWASP minimum: 1. */
readonly parallelism: number;
/** Output key length in bytes. */
readonly outputLength: number;
}
/**
* Hierarchical personal keyset — three pairwise-distinct 32-byte keys
* derived from the AUK via HKDF with disjoint info labels.
*/
export interface PersonalKeyset {
/** Symmetric encryption key for personal-keyset wrapper material. */
readonly encryptKey: Uint8Array;
/** Signing key (Ed25519-compatible 32-byte seed). */
readonly signKey: Uint8Array;
/** Key-exchange key (X25519-compatible 32-byte private scalar). */
readonly kxKey: Uint8Array;
}
/** 32-byte symmetric key derived per vaultId from the personal keyset. */
export type VaultKey = Uint8Array;
/** 32-byte symmetric key derived per itemId from the vault key. */
export type ItemKey = Uint8Array;
/** Account Unlock Key — 32 bytes derived from the 2SKD pipeline. */
export type Auk = Uint8Array;

View File

@@ -0,0 +1,51 @@
/**
* Dep-graph integration: the orchestration module is reachable through its
* declared subpath export and ships with its sibling `.md` docs.
*/
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = resolve(__dirname, "..");
describe("orchestration dep-graph", () => {
it("orchestration subpath export is reachable", {
timeout: 5000,
}, async () => {
const mod = await import("../src/orchestration/index.js");
expect(typeof mod.createRootKeys).toBe("function");
expect(typeof mod.createServerKeys).toBe("function");
expect(typeof mod.rollServerKeys).toBe("function");
expect(typeof mod.openVault).toBe("function");
expect(typeof mod.packVault).toBe("function");
expect(typeof mod.viewPublicKeys).toBe("function");
expect(typeof mod.saveVaultToStorage).toBe("function");
expect(typeof mod.savePublicKeysToStorage).toBe("function");
expect(typeof mod.getDataFromStorage).toBe("function");
});
it("orchestration subpath is declared in package.json exports", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(resolve(PKG_ROOT, "package.json"), "utf8")
) as { exports: Record<string, unknown> };
expect(pkg.exports["./orchestration/orchestration"]).toBeDefined();
});
it("orchestration.ts has a sibling .md", { timeout: 5000 }, () => {
const mdPath = resolve(PKG_ROOT, "src/orchestration/orchestration.md");
expect(existsSync(mdPath)).toBe(true);
const body = readFileSync(mdPath, "utf8");
// ≥200 words excluding code blocks — quick floor check.
const prose = body.replace(/```[\s\S]*?```/g, "");
const words = prose.split(/\s+/).filter(Boolean);
expect(words.length).toBeGreaterThanOrEqual(200);
});
it("internals.ts has a sibling .md", { timeout: 5000 }, () => {
const mdPath = resolve(PKG_ROOT, "src/orchestration/internals.md");
expect(existsSync(mdPath)).toBe(true);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,75 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/crypto-vault",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"bip39/bip39": "src/bip39/bip39.ts",
"argon2id/argon2id": "src/argon2id/argon2id.ts",
"hd-key/hd-key": "src/hd-key/hd-key.ts",
"twoskd/twoskd": "src/twoskd/twoskd.ts",
"key-hierarchy/key-hierarchy":
"src/key-hierarchy/key-hierarchy.ts",
"srp/srp-client": "src/srp/srp-client.ts",
"secure/secure-buffer": "src/secure/secure-buffer.ts",
"handle/opaque-handle": "src/handle/opaque-handle.ts",
"errors/index": "src/errors/index.ts",
"schemas/schemas": "src/schemas/schemas.ts",
"types/types": "src/types/types.ts",
"otel/instruments": "src/otel/instruments.ts",
"orchestration/orchestration":
"src/orchestration/orchestration.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@noble/curves",
/^@noble\/curves\//,
"@noble/hashes",
/^@noble\/hashes\//,
"@opentelemetry/api",
"@scure/bip39",
/^@scure\/bip39\//,
"@suaveplan/crypto",
/^@suaveplan\/crypto\//,
"@suaveplan/crypto-cipher",
/^@suaveplan\/crypto-cipher\//,
"@suaveplan/crypto-curve",
/^@suaveplan\/crypto-curve\//,
"@suaveplan/crypto-hash",
/^@suaveplan\/crypto-hash\//,
"@suaveplan/crypto-utils",
/^@suaveplan\/crypto-utils\//,
"@suaveplan/error",
"@suaveplan/storage",
/^@suaveplan\/storage\//,
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,4 @@
import { createTestConfig } from "@suaveplan/testing/config";
// crypto-vault is heavily Argon2id-bound; bump the timeout for those suites.
export default createTestConfig({ testTimeout: 30_000 });