first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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.