Initial Commit
This commit is contained in:
153
packages/security-stack/crypto/crypto-vault/README.md
Normal file
153
packages/security-stack/crypto/crypto-vault/README.md
Normal 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
|
||||
|
||||
203
packages/security-stack/crypto/crypto-vault/package.json
Normal file
203
packages/security-stack/crypto/crypto-vault/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
109
packages/security-stack/crypto/crypto-vault/src/bip39/bip39.md
Normal file
109
packages/security-stack/crypto/crypto-vault/src/bip39/bip39.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
116
packages/security-stack/crypto/crypto-vault/src/bip39/bip39.ts
Normal file
116
packages/security-stack/crypto/crypto-vault/src/bip39/bip39.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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];
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
CRYPTO_VAULT_CODES,
|
||||
type CryptoVaultCode,
|
||||
} from "./codes.js";
|
||||
export { CryptoVaultError } from "./crypto-vault-error.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
102
packages/security-stack/crypto/crypto-vault/src/hd-key/hd-key.md
Normal file
102
packages/security-stack/crypto/crypto-vault/src/hd-key/hd-key.md
Normal 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.
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
124
packages/security-stack/crypto/crypto-vault/src/hd-key/hd-key.ts
Normal file
124
packages/security-stack/crypto/crypto-vault/src/hd-key/hd-key.ts
Normal 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" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
119
packages/security-stack/crypto/crypto-vault/src/index.ts
Normal file
119
packages/security-stack/crypto/crypto-vault/src/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
>();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)]);
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
115
packages/security-stack/crypto/crypto-vault/src/twoskd/twoskd.md
Normal file
115
packages/security-stack/crypto/crypto-vault/src/twoskd/twoskd.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}
|
||||
);
|
||||
}
|
||||
101
packages/security-stack/crypto/crypto-vault/src/types/types.md
Normal file
101
packages/security-stack/crypto/crypto-vault/src/types/types.md
Normal 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.
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
75
packages/security-stack/crypto/crypto-vault/vite.config.ts
Normal file
75
packages/security-stack/crypto/crypto-vault/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
Reference in New Issue
Block a user