Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
# `security`
Have-I-Been-Pwned checks, JWS, licensing, and OTP utilities.
<!-- begin:auto-docs -->
## Packages in `security`
15 packages.
| Package | Description |
|---|---|
| [`@suaveplan/hibp`](./hibp/README.md) | Privacy-preserving Have I Been Pwned v3 client. Universal — works in Node, Bun, Deno, browsers, edge workers. The only required runtime primitive is `fetch`. |
| [`@suaveplan/jws`](./jws/README.md) | RFC 7515 **JSON Web Signature** (verify + sign). Pure TypeScript over `Uint8Array` + `DataView` plus the workspace crypto primitives (`@suaveplan/crypto-rsa`,… |
| [`@suaveplan/license`](./license/README.md) | Compact, offline-verifiable, secp256k1-signed software-license-key format. Bit-for-bit wire-format compatible with upstream `@suaveplan/license`. runs unchange… |
| [`@suaveplan/otp`](./otp/README.md) | RFC 4226 (HOTP) + RFC 6238 (TOTP) + RFC 6287 (OCRA) + Google Authenticator Key URI Format + Base32 (RFC 4648 §6) + recovery codes + clock-drift detector — zero browser/Node deps |
| [`@suaveplan/password-formats`](./password-formats/README.md) | Universal import/export layer for common password-manager export formats. Parses Apple Passwords CSV, Bitwarden JSON, Chrome CSV, and KeePass XML into a normal… |
| [`@suaveplan/password-generator`](./password-generator/README.md) | Cryptographically-strong password and passphrase generator — char-class, pronounceable, EFF Diceware, and PIN modes with bias-free RNG, real entropy estimates,… |
| [`@suaveplan/password-strength`](./password-strength/README.md) | zxcvbn-style password strength estimator — pattern-matching + dictionary cracker model per Wheeler 2016. Universal; zero browser/Node/React deps. Re-implementa… |
| [`@suaveplan/security-codes`](./security-codes/README.md) | Detect and sanitise sensitive security-code leakage in strings. Handles API keys, JWTs, credit cards, GitHub tokens, Stripe keys, SSH keys, PEM blocks, AWS acc… |
| [`@suaveplan/server-keys`](./server-keys/README.md) | Operator-facing CLI bundle for the Suaveplan server-key lifecycle: 18 bins covering root + server keychain generation (including hierarchical `ChainedKeySigner… |
| [`@suaveplan/slsa`](./slsa/README.md) | SLSA L1-L2 supply-chain security for Suaveplan. Generates and verifies in-toto / SLSA v1.0 provenance, CycloneDX 1.5 SBOMs, DSSE attestation envelopes, and RFC… |
| [`@suaveplan/srp`](./srp/README.md) | SRP-6a zero-knowledge password proof per RFC 5054 / RFC 2945. Universal — client and server roles in one package, bundled 2048-bit safe-prime group, optional 1… |
| [`@suaveplan/tpm-struct`](./tpm-struct/README.md) | Zero-runtime-dep TPM 2.0 attestation-structure parser and serialiser. Decodes `TPMS_ATTEST` (TCG TPM 2.0 Library Part 2 §10.12), decodes `TPMT_PUBLIC` (§12.2),… |
| [`@suaveplan/vault`](./vault/README.md) | Vault orchestration layer for the Vaulted password-manager ecosystem. Provides a drive-backed encrypted SQLite-style container, a strict `locked` ↔ `unlocked`… |
| [`@suaveplan/vault-sync`](./vault-sync/README.md) | Domain-specific sync and conflict-resolution engine for the Vaulted password-manager ecosystem. Builds on `@suaveplan/crdt` and `@suaveplan/diff` (RFC 6902 JSO… |
| [`@suaveplan/x509`](./x509/README.md) | RFC 5280 X.509 v3 certificate parser and certification-path validator. Universal (zero browser, Node, or React deps). Pure TypeScript over `Uint8Array` / `Data… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Have-I-Been-Pwned checks, JWS, licensing, and OTP utilities.

View File

@@ -0,0 +1,222 @@
# @suaveplan/hibp - Privacy-preserving Have I Been Pwned v3 client — k-anonymity range API, authenticated breach/paste API, LRU cache, differential-privacy mode
## Summary
Privacy-preserving [Have I Been Pwned](https://haveibeenpwned.com/) v3 client.
Universal — works in Node, Bun, Deno, browsers, edge workers. The only
required runtime primitive is `fetch`.
<!-- begin:auto-docs -->
## Documentation tree
- [breach-client](./src/breach/breach-client.md) — `BreachClient` wraps the four authenticated HIBP v3 endpoints: `/breachedaccount/{email}`, `/breach/{name}`, `/breaches…
- [rate-limit-gate](./src/breach/rate-limit-gate.md) — `RateLimitGate` enforces the HIBP v3 client-side rate-limit contract of one request per 1500 ms across the authenticate…
- [retry-after](./src/breach/retry-after.md) — `parseRetryAfter` parses the `Retry-After` HTTP header per RFC 9110 §10.2.3 and returns the indicated delay in millisec…
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - lru — `LruHibpCache<V>` is the default cache for `H…
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - lru — `LruHibpCache<V>` is the default cache for `H…
- [lru](./src/cache/lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
- [null](./src/cache/null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - options.schema — `HibpClientOptionsSchema` is the Z…
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - options.schema — `HibpClientOptionsSchema` is the Z…
- [options.schema](./src/client/options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
- [codes](./src/errors/codes.md) — Spec-canonical path for the HIBP error-code registry. This file is a thin re-export of the top-level `src/error-codes.t…
- [hibp-error](./src/errors/hibp-error.md) — `HibpError` is the structured error class every failure path inside `@suaveplan/hibp` throws. It carries one of the sev…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [privacy](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [assert-no-leak](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [constant-time-compare](./src/range/constant-time-compare.md) — `constantTimeEqualsHex` compares two hex suffixes without early-exit on the first mismatching character. It is used by…
- [differential-privacy](./src/range/differential-privacy.md) — `padPrefixes` generates the decoy prefixes used by the differential-privacy mode of `checkPassword`. Given the real 5-h…
- [range-mode](./src/range/range-mode.md) — Houses the hash-mode dispatch utilities used by `checkPassword`: `hashSha1Prefix` (SHA-1 via `@noble/hashes/sha1`), `ha…
- [range-stream-parse](./src/range/range-stream-parse.md) — Streams an HTTP response body line-by-line through a `TextDecoderStream` and a newline framer, yielding `{ suffix, coun…
- [range-strict-frame](./src/range/range-strict-frame.md) — `parseRangeLine` is the per-line framer used by `streamParseRangeBody`. It enforces the documented Pwned Passwords line…
- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [breach.schema](./src/schemas/breach.schema.md) — `BreachSchema` is the Zod schema mirroring the HIBP v3 `Breach` payload. It validates every documented field, so the re…
- [paste.schema](./src/schemas/paste.schema.md) — `PasteSchema` validates the HIBP v3 `Paste` payload returned by `/pasteaccount/{email}`. Older paste records can omit `…
- [pwned-result.schema](./src/schemas/pwned-result.schema.md) — `PwnedResultSchema` validates the runtime shape of every `checkPassword` result. It is the final boundary between the i…
- [error-codes](./src/error-codes.md) — Hosts the canonical `HIBP_CODES` registry — exactly seven error codes that classify every failure path inside `@suavepl…
<!-- end:auto-docs --> - lru — `LruHibpCache<V>` is the default cache for `H…
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- lru — `LruHibpCache<V>` is the default cache for `H…
- [lru](./src/cache/lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
- [null](./src/cache/null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- options.schema — `HibpClientOptionsSchema` is the Z…
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- options.schema — `HibpClientOptionsSchema` is the Z…
- [options.schema](./src/client/options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
- [codes](./src/errors/codes.md) — Spec-canonical path for the HIBP error-code registry. This file is a thin re-export of the top-level `src/error-codes.t…
- [hibp-error](./src/errors/hibp-error.md) — `HibpError` is the structured error class every failure path inside `@suaveplan/hibp` throws. It carries one of the sev…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [privacy](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_- [assert-no-leak](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant-time-compare](./src/range/constant-time-compare.md) — `constantTimeEqualsHex` compares two hex suffixes without early-exit on the first mismatching character. It is used by…
- [differential-privacy](./src/range/differential-privacy.md) — `padPrefixes` generates the decoy prefixes used by the differential-privacy mode of `checkPassword`. Given the real 5-h…
- [range-mode](./src/range/range-mode.md) — Houses the hash-mode dispatch utilities used by `checkPassword`: `hashSha1Prefix` (SHA-1 via `@noble/hashes/sha1`), `ha…
- [range-stream-parse](./src/range/range-stream-parse.md) — Streams an HTTP response body line-by-line through a `TextDecoderStream` and a newline framer, yielding `{ suffix, coun…
- [range-strict-frame](./src/range/range-strict-frame.md) — `parseRangeLine` is the per-line framer used by `streamParseRangeBody`. It enforces the documented Pwned Passwords line…
- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_- [breach.schema](./src/schemas/breach.schema.md) — `BreachSchema` is the Zod schema mirroring the HIBP v3 `Breach` payload. It validates every documented field, so the re…
- [paste.schema](./src/schemas/paste.schema.md) — `PasteSchema` validates the HIBP v3 `Paste` payload returned by `/pasteaccount/{email}`. Older paste records can omit `…
- [pwned-result.schema](./src/schemas/pwned-result.schema.md) — `PwnedResultSchema` validates the runtime shape of every `checkPassword` result. It is the final boundary between the i…
- [error-codes](./src/error-codes.md) — Hosts the canonical `HIBP_CODES` registry — exactly seven error codes that classify every failure path inside `@suavepl…
- lru — `LruHibpCache<V>` is the default cache for `H…
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- lru — `LruHibpCache<V>` is the default cache for `H…
- [lru](./src/cache/lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
- [null](./src/cache/null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- options.schema — `HibpClientOptionsSchema` is the Z…
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- options.schema — `HibpClientOptionsSchema` is the Z…
- [options.schema](./src/client/options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
- [codes](./src/errors/codes.md) — Spec-canonical path for the HIBP error-code registry. This file is a thin re-export of the top-level `src/error-codes.t…
- [hibp-error](./src/errors/hibp-error.md) — `HibpError` is the structured error class every failure path inside `@suaveplan/hibp` throws. It carries one of the sev…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [privacy](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_- [assert-no-leak](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant-time-compare](./src/range/constant-time-compare.md) — `constantTimeEqualsHex` compares two hex suffixes without early-exit on the first mismatching character. It is used by…
- [differential-privacy](./src/range/differential-privacy.md) — `padPrefixes` generates the decoy prefixes used by the differential-privacy mode of `checkPassword`. Given the real 5-h…
- [range-mode](./src/range/range-mode.md) — Houses the hash-mode dispatch utilities used by `checkPassword`: `hashSha1Prefix` (SHA-1 via `@noble/hashes/sha1`), `ha…
- [range-stream-parse](./src/range/range-stream-parse.md) — Streams an HTTP response body line-by-line through a `TextDecoderStream` and a newline framer, yielding `{ suffix, coun…
- [range-strict-frame](./src/range/range-strict-frame.md) — `parseRangeLine` is the per-line framer used by `streamParseRangeBody`. It enforces the documented Pwned Passwords line…
- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_- [breach.schema](./src/schemas/breach.schema.md) — `BreachSchema` is the Zod schema mirroring the HIBP v3 `Breach` payload. It validates every documented field, so the re…
- [paste.schema](./src/schemas/paste.schema.md) — `PasteSchema` validates the HIBP v3 `Paste` payload returned by `/pasteaccount/{email}`. Older paste records can omit `…
- [pwned-result.schema](./src/schemas/pwned-result.schema.md) — `PwnedResultSchema` validates the runtime shape of every `checkPassword` result. It is the final boundary between the i…
- [error-codes](./src/error-codes.md) — Hosts the canonical `HIBP_CODES` registry — exactly seven error codes that classify every failure path inside `@suavepl…
- lru — `LruHibpCache<V>` is the default cache for `H…
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- lru — `LruHibpCache<V>` is the default cache for `H…
- [lru](./src/cache/lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
- [null](./src/cache/null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- options.schema — `HibpClientOptionsSchema` is the Z…
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_- options.schema — `HibpClientOptionsSchema` is the Z…
- [options.schema](./src/client/options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
- [codes](./src/errors/codes.md) — Spec-canonical path for the HIBP error-code registry. This file is a thin re-export of the top-level `src/error-codes.t…
- [hibp-error](./src/errors/hibp-error.md) — `HibpError` is the structured error class every failure path inside `@suaveplan/hibp` throws. It carries one of the sev…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [privacy](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_- [assert-no-leak](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_- [constant-time-compare](./src/range/constant-time-compare.md) — `constantTimeEqualsHex` compares two hex suffixes without early-exit on the first mismatching character. It is used by…
- [differential-privacy](./src/range/differential-privacy.md) — `padPrefixes` generates the decoy prefixes used by the differential-privacy mode of `checkPassword`. Given the real 5-h…
- [range-mode](./src/range/range-mode.md) — Houses the hash-mode dispatch utilities used by `checkPassword`: `hashSha1Prefix` (SHA-1 via `@noble/hashes/sha1`), `ha…
- [range-stream-parse](./src/range/range-stream-parse.md) — Streams an HTTP response body line-by-line through a `TextDecoderStream` and a newline framer, yielding `{ suffix, coun…
- [range-strict-frame](./src/range/range-strict-frame.md) — `parseRangeLine` is the per-line framer used by `streamParseRangeBody`. It enforces the documented Pwned Passwords line…
- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_- [breach.schema](./src/schemas/breach.schema.md) — `BreachSchema` is the Zod schema mirroring the HIBP v3 `Breach` payload. It validates every documented field, so the re…
- [paste.schema](./src/schemas/paste.schema.md) — `PasteSchema` validates the HIBP v3 `Paste` payload returned by `/pasteaccount/{email}`. Older paste records can omit `…
- [pwned-result.schema](./src/schemas/pwned-result.schema.md) — `PwnedResultSchema` validates the runtime shape of every `checkPassword` result. It is the final boundary between the i…
- [error-codes](./src/error-codes.md) — Hosts the canonical `HIBP_CODES` registry — exactly seven error codes that classify every failure path inside `@suavepl…
- [cache](./src/cache/cache.md) — <!-- begin:auto-docs --> _(no submodules)_- [lru](./src/cache/lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
- [null](./src/cache/null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
- [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_ - [client](./src/client/client.md) — <!-- begin:auto-docs --> _(no submodules)_ - [options.schema](./src/client/options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
- [codes](./src/errors/codes.md) — Spec-canonical path for the HIBP error-code registry. This file is a thin re-export of the top-level `src/error-codes.t…
- [hibp-error](./src/errors/hibp-error.md) — `HibpError` is the structured error class every failure path inside `@suaveplan/hibp` throws. It carries one of the sev…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [privacy](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_ - [assert-no-leak](./src/privacy/assert-no-leak.md) — <!-- begin:auto-docs --> _(no submodules)_- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_ - [constant-time-compare](./src/range/constant-time-compare.md) — `constantTimeEqualsHex` compares two hex suffixes without early-exit on the first mismatching character. It is used by…
- [differential-privacy](./src/range/differential-privacy.md) — `padPrefixes` generates the decoy prefixes used by the differential-privacy mode of `checkPassword`. Given the real 5-h…
- [range-mode](./src/range/range-mode.md) — Houses the hash-mode dispatch utilities used by `checkPassword`: `hashSha1Prefix` (SHA-1 via `@noble/hashes/sha1`), `ha…
- [range-stream-parse](./src/range/range-stream-parse.md) — Streams an HTTP response body line-by-line through a `TextDecoderStream` and a newline framer, yielding `{ suffix, coun…
- [range-strict-frame](./src/range/range-strict-frame.md) — `parseRangeLine` is the per-line framer used by `streamParseRangeBody`. It enforces the documented Pwned Passwords line…
- [range](./src/range/range.md) — <!-- begin:auto-docs --> _(no submodules)_- [breach.schema](./src/schemas/breach.schema.md) — `BreachSchema` is the Zod schema mirroring the [HIBP v3 `Breach` payload](https://haveibeenpwned.com/API/v3#Breach). It…
- [paste.schema](./src/schemas/paste.schema.md) — `PasteSchema` validates the [HIBP v3 `Paste` payload](https://haveibeenpwned.com/API/v3#PastesForAccount) returned by `…
- [pwned-result.schema](./src/schemas/pwned-result.schema.md) — `PwnedResultSchema` validates the runtime shape of every `checkPassword` result. It is the final boundary between the i…
- [error-codes](./src/error-codes.md) — Hosts the canonical `HIBP_CODES` registry — exactly seven error codes that classify every failure path inside `@suavepl…
## Privacy contract
This package treats the password and its SHA-1 digest as secrets that must
never leave the calling process.
- Only the **first five hex characters** of the SHA-1 digest are transmitted
to the Pwned Passwords range API ([k-anonymity](https://haveibeenpwned.com/API/v3#PwnedPasswords)).
- Every range request carries the mandatory `Add-Padding: true` header so
Cloudflare appends decoy lines, defeating response-size correlation
attacks.
- The full password, the full hash, the API key, and the email are never
logged and never appear in any thrown error's serialized form.
- An opt-in `assertOutgoingRequestSafe: true` switch wraps every outgoing
fetch with a byte-by-byte leak check so callers can catch a regression
before it lands in production.
If any of those invariants ever break, that is a defect, not a feature
request.
## Install
```bash
bun add @suaveplan/hibp
```
## Quick start
### Check a single password against the range API
```ts
import { HibpClient } from "@suaveplan/hibp";
const client = new HibpClient();
const result = await client.checkPassword("hunter2");
// { pwned: true, count: 23_597 }
```
### Pull the breach list for an account
```ts
import { HibpClient } from "@suaveplan/hibp";
const client = new HibpClient({ apiKey: process.env.HIBP_API_KEY! });
const breaches = await client.getBreachesForAccount("user@example.com");
// Breach[]
```
## Differential-privacy mode
For deployments that want belt-and-braces defence on top of the standard
`Add-Padding` padding, opt in to decoy prefixes. Every `checkPassword` call
will dispatch the real 5-hex prefix plus N additional random sibling
prefixes; only the real one's response is consulted.
```ts
const client = new HibpClient({
differentialPrivacy: { extraPrefixes: 3 },
rng: myInjectableRng,
});
```
Trade-offs:
- **Adds N extra round trips per check.** Default off because the
`Add-Padding` header already defeats the documented correlation attack
for almost every deployment.
- **Decoy responses are discarded** but still count against any caching
layer's bandwidth budget. Tune `extraPrefixes` to match the operational
cost.
## Modules
| Path | Purpose |
| --- | --- |
| [`src/client/client.md`](src/client/client.md) | `HibpClient` façade orchestrating range + breach + cache + observability |
| [`src/client/options.schema.md`](src/client/options.schema.md) | Zod boundary for constructor options |
| [`src/range/range.md`](src/range/range.md) | k-anonymity range API client |
| [`src/range/range-mode.md`](src/range/range-mode.md) | SHA-1 / NTLM mode dispatch |
| [`src/range/range-stream-parse.md`](src/range/range-stream-parse.md) | Streaming line parser |
| [`src/range/range-strict-frame.md`](src/range/range-strict-frame.md) | Strict-mode line framing |
| [`src/range/constant-time-compare.md`](src/range/constant-time-compare.md) | Constant-time suffix compare |
| [`src/range/differential-privacy.md`](src/range/differential-privacy.md) | Decoy prefix generator |
| [`src/breach/breach-client.md`](src/breach/breach-client.md) | Authenticated breach + paste API client |
| [`src/breach/rate-limit-gate.md`](src/breach/rate-limit-gate.md) | 1500 ms client-side rate-limit gate |
| [`src/breach/retry-after.md`](src/breach/retry-after.md) | RFC 9110 `Retry-After` parser |
| [`src/cache/cache.md`](src/cache/cache.md) | `HibpCacheLike` interface |
| [`src/cache/lru.md`](src/cache/lru.md) | Default LRU cache (10 000 / 24 h) |
| [`src/cache/null.md`](src/cache/null.md) | Always-miss cache adapter |
| [`src/errors/codes.md`](src/errors/codes.md) | Seven-code error registry |
| [`src/errors/hibp-error.md`](src/errors/hibp-error.md) | `HibpError` base class |
| [`src/error-codes.md`](src/error-codes.md) | Top-level code registration |
| [`src/observability/metrics.md`](src/observability/metrics.md) | OTel histograms + counters |
| [`src/privacy/assert-no-leak.md`](src/privacy/assert-no-leak.md) | Outgoing-request leak detector |
| [`src/schemas/breach.schema.md`](src/schemas/breach.schema.md) | `BreachSchema` Zod schema |
| [`src/schemas/paste.schema.md`](src/schemas/paste.schema.md) | `PasteSchema` Zod schema |
| [`src/schemas/pwned-result.schema.md`](src/schemas/pwned-result.schema.md) | `PwnedResultSchema` Zod schema |
## Benchmarks
| Benchmark | Description |
| --- | --- |
| `range-cold` | Cold range fetch + parse |
| `range-warm-cache` | LRU hit path |
| `range-parse-30kib` | Streaming parser throughput on a 30 KiB padded fixture |
Run with `bun run benchmark` (see `src/__benchmarks__/`).
## License
MIT

View File

@@ -0,0 +1,78 @@
# `breach/breach-client.ts`
## Purpose
`BreachClient` wraps the four authenticated HIBP v3 endpoints:
`/breachedaccount/{email}`, `/breach/{name}`, `/breaches`, and
`/pasteaccount/{email}`. It owns the api-key header, the 1500 ms
client-side rate-limit gate, the 429-retry loop with `Retry-After`
parsing, and the Zod response validation. The api key is sourced
strictly from constructor options — never `process.env`, never a
module-scope variable — and never appears in any thrown error's
serialized form.
## Features
- All four authenticated endpoints with documented URL construction,
query parameters, and headers.
- Email path segment URL-encoded; spec-conformance tests assert no
literal email in any outgoing URL outside `/breachedaccount` and
`/pasteaccount`.
- 1500 ms rate-limit gate enforced via `RateLimitGate` (injectable
clock, injectable sleep).
- 429 retry honouring `Retry-After` (delta-seconds or HTTP-date),
capped at 60 s; exponential fallback when the header is missing.
- HTTP status → typed code mapping: 401/403 → UNAUTHORIZED, 404 →
NOT_FOUND (or `[]` for list endpoints), 413 → PAYLOAD_TOO_LARGE, 429
→ RATE_LIMITED (after exhaustion), 500+ → NETWORK_ERROR.
- AbortSignal threaded into fetch RequestInit and checked before every
gate acquisition.
## Usage: Basic Example
```ts
import { BreachClient } from "@suaveplan/hibp";
const client = new BreachClient({ apiKey: "k_test" });
const breaches = await client.getBreachesForAccount("user@example.com");
```
## Usage: Advanced Example
```ts
import { BreachClient, RateLimitGate } from "@suaveplan/hibp";
const gate = new RateLimitGate({ minGapMs: 1500 });
const client = new BreachClient({
apiKey: process.env.HIBP_API_KEY!,
gate,
maxAttempts: 5,
});
const adobe = await client.getBreach("Adobe");
const adobeBreaches = await client.getAllBreaches({ domain: "adobe.com" });
const pastes = await client.getPastesForAccount("user@example.com");
```
## API Reference
- `BreachClientOptions` — `apiKey`, `fetchFn?`, `clock?`, `gate?`,
`logger?`, `maxAttempts?`, `sleep?`.
- `BreachCallOptions` — `signal?`.
- `GetAllBreachesOptions extends BreachCallOptions` — `domain?`.
- `getBreachesForAccount(email, opts?): Promise<Breach[]>`.
- `getBreach(name, opts?): Promise<Breach>`.
- `getAllBreaches(opts?): Promise<Breach[]>`.
- `getPastesForAccount(email, opts?): Promise<Paste[]>`.
## Implementation Notes
The client uses a tight retry loop rather than `@suaveplan/retry`
directly so the `Retry-After` parsing stays first-class — `retry`'s
strategy callbacks don't know about HTTP headers. The 429 exhaustion
path throws `HIBP.RATE_LIMITED` rather than the underlying retry
error so callers can branch on a single, stable code. JSON-array
endpoints share a `fetchJsonArray` helper that validates every entry
through the supplied Zod schema; a single bad entry fails the whole
response, by design. The 404 fast-path on list endpoints converts a
missing-record response to `[]` per the HIBP contract.

View File

@@ -0,0 +1,68 @@
# `breach/rate-limit-gate.ts`
## Purpose
`RateLimitGate` enforces the [HIBP v3 client-side rate-limit
contract](https://haveibeenpwned.com/API/v3#RateLimiting) of one
request per 1500 ms across the authenticated breach + paste endpoints.
It runs against an injectable `Clock` so deterministic tests can advance
virtual time without ever sleeping in wall-clock. Every gate-induced
wait increments the `hibp.rate_limit_throttled_total` counter so
operators can spot saturated workloads in dashboards.
## Features
- Default `minGapMs` of 1500 ms — matches `RATE_LIMIT_MIN_GAP_MS`.
- Injectable `clock` and `sleep` callbacks for deterministic testing.
- `acquire(signal?)` resolves immediately if the gap has elapsed;
otherwise awaits the sleep callback for the remaining delay.
- `reset()` clears state so test cases share a fresh gate without
reconstruction.
## Usage: Basic Example
```ts
import { RateLimitGate } from "@suaveplan/hibp";
const gate = new RateLimitGate();
await gate.acquire();
// ... breach fetch
await gate.acquire();
// waits 1500 ms
```
## Usage: Advanced Example
```ts
import { RateLimitGate } from "@suaveplan/hibp";
let now = 0;
const gate = new RateLimitGate({
clock: () => now,
sleep: async (ms) => {
now += ms;
},
});
await gate.acquire();
now = 500;
await gate.acquire(); // sleeps 1000 in virtual time
```
## API Reference
- `RATE_LIMIT_MIN_GAP_MS = 1500`.
- `new RateLimitGate(options?)` — `minGapMs`, `clock`, `sleep`.
- `gate.acquire(signal?): Promise<void>`.
- `gate.reset(): void`.
## Implementation Notes
The gate is intentionally not concurrent-safe: HIBP's contract is a
client-wide 1500 ms gap, and a single gate instance serialises all
authenticated calls. Sharing one gate across the entire process is the
documented integration pattern. The `signal` argument is checked
post-sleep — the gate itself does not throw on abort, leaving that
decision to the caller's error-mapping layer. The throttled counter
fires only when a wait actually occurred; warming-up calls that fall
outside the window do not register, so dashboard graphs reflect true
operational pressure rather than benign clock drift.

View File

@@ -0,0 +1,56 @@
# `breach/retry-after.ts`
## Purpose
`parseRetryAfter` parses the `Retry-After` HTTP header per RFC 9110
§10.2.3 and returns the indicated delay in milliseconds, capped at
`RETRY_AFTER_CAP_MS` (60 000 ms). The breach client calls into it on
every HTTP 429 response so the retry loop honours either the
delta-seconds form (`"5"`) or the HTTP-date form (`"Wed, 21 Oct 2020
07:28:00 GMT"`) without forcing every server team to standardise on one.
## Features
- Delta-seconds form: integer-only, multiplied by 1000.
- HTTP-date form: parsed via `Date.parse`; clamped to 0 ms if the date
is in the past, capped at 60 000 ms otherwise.
- Returns `undefined` on missing or unparseable header so the caller
falls back to exponential backoff.
- Constant `RETRY_AFTER_CAP_MS` exported alongside for callers that need
to enforce the same cap elsewhere.
## Usage: Basic Example
```ts
import { parseRetryAfter } from "@suaveplan/hibp";
const parsed = parseRetryAfter("5", Date.now());
// { delayMs: 5_000 }
```
## Usage: Advanced Example
```ts
import { parseRetryAfter, RETRY_AFTER_CAP_MS } from "@suaveplan/hibp";
function effectiveDelay(header: string | null, attempt: number, now: number): number {
const parsed = parseRetryAfter(header, now);
if (parsed !== undefined) return parsed.delayMs;
return Math.min(2 ** attempt * 1000, RETRY_AFTER_CAP_MS);
}
```
## API Reference
- `RETRY_AFTER_CAP_MS = 60_000`.
- `parseRetryAfter(headerValue, now): { delayMs: number } | undefined`.
## Implementation Notes
The cap is non-negotiable — the spec says no retry may sleep more than
60 seconds, regardless of how generous the server's hint is. Returning
`undefined` for unparseable input rather than throwing keeps the breach
client's loop simple: the parser is a hint, not an error source. The
HTTP-date branch uses the standard library's `Date.parse`, which
accepts RFC 1123, RFC 850, and ANSI C `asctime()` formats — all three
forms named in RFC 9110.

View File

@@ -0,0 +1,94 @@
# `cache/cache.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - lru — `LruHibpCache<V>` is the default cache for `H…
- [lru](./lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
- [null](./null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
- lru — `LruHibpCache<V>` is the default cache for `H…
- [lru](./lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
- [null](./null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
- lru — `LruHibpCache<V>` is the default cache for `H…
- [lru](./lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
- [null](./null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
- lru — `LruHibpCache<V>` is the default cache for `H…
- [lru](./lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
- [null](./null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
- [lru](./lru.md) — `LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to `LRU_DEFAULT_CAPACITY` entries (10 000) with `L…
- [null](./null.md) — `NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when the caller explicitly opts out of caching (`cac…
## Purpose
Declares the `HibpCacheLike<V>` interface — the universal contract every
cache backend implements. The `HibpClient` orchestrator is parametric in
this type, so swapping `LruHibpCache` for a Redis-backed adapter is a
constructor option, not a refactor. Living at this layer means downstream
storage choices stay isolated from the range-API logic.
## Features
- Four-method surface: `get`, `set`, `delete`, `size`.
- Generic over value type `V` so a cache of `PwnedResult`, a cache of raw
range bodies, and a cache of typed `Breach[]` all use the same shape.
- Synchronous semantics — async backends adapt by buffering or by
resolving the cache outside the request critical path.
- Zero runtime — purely a TypeScript interface.
## Usage: Basic Example
```ts
import type { HibpCacheLike } from "@suaveplan/hibp";
class MapCache implements HibpCacheLike<{ pwned: boolean; count: number }> {
private store = new Map<string, { pwned: boolean; count: number }>();
get(k: string) { return this.store.get(k); }
set(k: string, v: { pwned: boolean; count: number }) { this.store.set(k, v); }
delete(k: string) { return this.store.delete(k); }
get size() { return this.store.size; }
}
```
## Usage: Advanced Example
```ts
import type { HibpCacheLike } from "@suaveplan/hibp";
export class CountingCache<V> implements HibpCacheLike<V> {
public hits = 0;
public misses = 0;
public constructor(private inner: HibpCacheLike<V>) {}
get(k: string) {
const v = this.inner.get(k);
if (v === undefined) this.misses++;
else this.hits++;
return v;
}
set(k: string, v: V) { this.inner.set(k, v); }
delete(k: string) { return this.inner.delete(k); }
get size() { return this.inner.size; }
}
```
## API Reference
- `HibpCacheLike<V>` — interface with `get`, `set`, `delete`, `size`.
## Implementation Notes
The interface is synchronous on purpose: every documented HIBP cache
backend is in-memory or microsecond-bound, so the simpler shape is worth
it. If a future use case requires an async cache, the `HibpClient` will
need a parallel `HibpAsyncCacheLike` rather than mutating this contract
and breaking every existing implementation. The `size` field is
deliberately a getter rather than a method so adapters wrapping an
underlying `Map` can return the map's `size` directly; method-style
would force an extra function call per probe. Consumers wiring exotic
backends — Redis, IndexedDB, OPFS — typically wrap their async client
in a write-through cache that buffers writes and lazily refreshes
reads, exposing this synchronous shape to `HibpClient` while preserving
durability behind the scenes.

View File

@@ -0,0 +1,65 @@
# `cache/lru.ts`
## Purpose
`LruHibpCache<V>` is the default cache for `HibpClient`. It holds up to
`LRU_DEFAULT_CAPACITY` entries (10 000) with `LRU_DEFAULT_TTL_MS` (24 h)
expiry, keyed by the uppercase 5-hex SHA-1 prefix. The TTL clock is the
injectable `Clock` from `@suaveplan/types` so tests can advance virtual
time without timers. Eviction policy is least-recently-used at capacity
plus eager TTL eviction on read.
## Features
- O(1) `get`/`set`/`delete` over a `Map<string, CacheEntry<V>>` with
recency tracked by insertion order — re-inserting on read bumps the
key to the tail.
- TTL eviction at access time: stale entries are dropped and reported as
cache misses; no background timer is required.
- Constructor-time overrides for `capacity`, `ttlMs`, and `clock` so the
same class powers production caches, deterministic tests, and
experimental tunings.
- Key normalisation to uppercase hex on every operation — case-insensitive
reads and writes share the same slot.
## Usage: Basic Example
```ts
import { LruHibpCache } from "@suaveplan/hibp";
const cache = new LruHibpCache<{ pwned: boolean; count: number }>();
cache.set("F3BBB", { pwned: true, count: 1 });
cache.get("f3bbb"); // case-insensitive hit
```
## Usage: Advanced Example
```ts
import { LruHibpCache } from "@suaveplan/hibp";
let now = 0;
const cache = new LruHibpCache<number>({
capacity: 50,
ttlMs: 5_000,
clock: () => now,
});
cache.set("A", 1);
now = 4_999;
cache.get("A"); // 1
now = 5_001;
cache.get("A"); // undefined (TTL-evicted)
```
## API Reference
- `new LruHibpCache<V>(options?)` — `capacity`, `ttlMs`, `clock`.
- `LRU_DEFAULT_CAPACITY = 10_000`.
- `LRU_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000`.
## Implementation Notes
Insertion-order tracking via JavaScript `Map` is the canonical O(1) LRU
implementation in pure JS. The class avoids `setInterval`-based eviction
because that would require timer cleanup on disposal and add jitter to
TTL behaviour; on-read TTL is cheap and deterministic. The capacity loop
guards against `capacity: 0` by evicting immediately after the insert.

View File

@@ -0,0 +1,54 @@
# `cache/null.ts`
## Purpose
`NullHibpCache<V>` is the always-miss adapter used by `HibpClient` when
the caller explicitly opts out of caching (`cache: null`). It satisfies
the `HibpCacheLike<V>` shape so the orchestrator code stays branch-free —
every cache call goes through the same interface, but the null adapter
makes every lookup a miss and every store a no-op.
## Features
- `get` always returns `undefined`.
- `set` is a no-op.
- `delete` always returns `false`.
- `size` is always `0`.
- Zero allocation per call — instances are stateless beyond the JS class
identity.
## Usage: Basic Example
```ts
import { HibpClient, NullHibpCache } from "@suaveplan/hibp";
const client = new HibpClient({ cache: new NullHibpCache() });
```
## Usage: Advanced Example
```ts
import { HibpClient } from "@suaveplan/hibp";
// Shorthand: passing `null` resolves to NullHibpCache internally.
const client = new HibpClient({ cache: null });
```
## API Reference
- `new NullHibpCache<V>()` — generic, no options.
- All `HibpCacheLike<V>` methods present and conformant.
## Implementation Notes
Using a dedicated class instead of an inline conditional avoids a
property branch on every `HibpClient.checkPassword` call. It also keeps
the public type system honest: a `HibpCacheLike` is always a real cache,
never `undefined`. Callers who want caching disabled make that intention
explicit by constructing or asking for `NullHibpCache`. The class is
generic so consumers of typed cache values (e.g. `Breach[]` caches
layered on top of the package) still get the right type for their
`get()` returns when they inject a `NullHibpCache<Breach[]>` instead.
Tests benefit too — running `checkPassword` against a `NullHibpCache`
guarantees every call exercises the full fetch+parse path, with no
hidden cache hits hiding behaviour bugs.

View File

@@ -0,0 +1,97 @@
# `client/client.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - options.schema — `HibpClientOptionsSchema` is the Z…
- [options.schema](./options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
- options.schema — `HibpClientOptionsSchema` is the Z…
- [options.schema](./options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
- options.schema — `HibpClientOptionsSchema` is the Z…
- [options.schema](./options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
- options.schema — `HibpClientOptionsSchema` is the Z…
- [options.schema](./options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
- [options.schema](./options.schema.md) — `HibpClientOptionsSchema` is the Zod boundary for the `HibpClient` constructor. It is `.strict()` — unknown keys cause…
## Purpose
`HibpClient` is the public façade composing the range client, the
breach client, the cache, the rate-limit gate, and the observability
surface into one constructor. It is the entry point downstream code
imports — `import { HibpClient } from "@suaveplan/hibp"` — and the
target of every option flag declared in the
[options schema](./options.schema.md). Stateless beyond its cache.
## Features
- Single constructor accepts every documented option: `apiKey`,
`strict`, `differentialPrivacy`, `assertOutgoingRequestSafe`,
`fetch`, `clock`, `rng`, `logger`, `cache`, `ntlmHash`,
`maxAttempts`.
- Options validated through `HibpClientOptionsSchema` (strict — unknown
keys rejected) at construction time.
- `cache: null` opts out (resolves to `NullHibpCache`); omitting the
field uses `LruHibpCache` defaults; passing a custom adapter accepts
any `HibpCacheLike` implementation.
- Authenticated methods (`getBreachesForAccount`, `getBreach`,
`getAllBreaches`, `getPastesForAccount`) require `apiKey`; calling
them without one throws synchronously with a descriptive error.
- Range-API method (`checkPassword`) is always available; results are
cached by 5-hex prefix.
## Usage: Basic Example
```ts
import { HibpClient } from "@suaveplan/hibp";
const client = new HibpClient();
const { pwned } = await client.checkPassword("hunter2");
```
## Usage: Advanced Example
```ts
import { HibpClient } from "@suaveplan/hibp";
const client = new HibpClient({
apiKey: process.env.HIBP_API_KEY!,
strict: true,
differentialPrivacy: { extraPrefixes: 3 },
rng: myInjectableRng,
assertOutgoingRequestSafe: true,
cache: null,
maxAttempts: 5,
});
const breaches = await client.getBreachesForAccount("user@example.com");
```
## API Reference
- `DifferentialPrivacyOptions` — `{ extraPrefixes: number }`.
- `HibpClientOptions` — every documented constructor flag.
- `CallOptions` / `BreachOptions` — `{ signal? }` and
`{ signal?, domain? }`.
- `new HibpClient(options?)`.
- `checkPassword(password, { signal?, mode? }?): Promise<PwnedResult>`.
- `getBreachesForAccount(email, opts?): Promise<Breach[]>`.
- `getBreach(name, opts?): Promise<Breach>`.
- `getAllBreaches(opts?): Promise<Breach[]>`.
- `getPastesForAccount(email, opts?): Promise<Paste[]>`.
## Implementation Notes
The façade keeps the public surface narrow on purpose: the four
authenticated methods forward straight to `BreachClient`, the
range-API call routes through the cache before delegating to
`RangeClient`. Cache keys use the SHA-1 5-hex prefix even when the
caller asks for NTLM mode unless an NTLM adapter is supplied, in which
case the NTLM 5-hex prefix is the key. This split keeps the default
case fast while preserving correctness when the caller configures
NTLM. The constructor uses plain mutable record builders instead of
typed-readonly objects so `exactOptionalPropertyTypes: true` does not
reject conditional property assignment.

View File

@@ -0,0 +1,60 @@
# `client/options.schema.ts`
## Purpose
`HibpClientOptionsSchema` is the Zod boundary for the `HibpClient`
constructor. It is `.strict()` — unknown keys cause a synchronous throw
at construction time. This catches typos like `Cache: null` (capital C)
that would otherwise silently fall through to the default cache, and
keeps the public option surface honest: every flag is documented, and
every new flag forces an explicit schema update.
## Features
- Strict object — every unknown key throws.
- `apiKey` constrained to `string` of minimum length 1 (an empty key
would silently produce unauthenticated calls).
- `differentialPrivacy.extraPrefixes` validated as a non-negative
integer.
- `maxAttempts` validated as a positive integer.
- Runtime callbacks (`fetch`, `clock`, `rng`, `logger`, `cache`,
`ntlmHash`) typed as `z.unknown()` because Zod cannot introspect
arbitrary function shapes; structural validation happens at usage
time inside the relevant sub-client.
## Usage: Basic Example
```ts
import { HibpClientOptionsSchema } from "@suaveplan/hibp";
HibpClientOptionsSchema.parse({ apiKey: "k_test", strict: true });
```
## Usage: Advanced Example
```ts
import { HibpClientOptionsSchema } from "@suaveplan/hibp";
function validateConfig(raw: unknown): void {
const parsed = HibpClientOptionsSchema.parse(raw);
if (parsed.differentialPrivacy && parsed.differentialPrivacy.extraPrefixes > 5) {
throw new Error("decoy count > 5 is operationally expensive");
}
}
```
## API Reference
- `HibpClientOptionsSchema: ZodObject` — strict.
- `HibpClientOptionsInput` — `z.infer<typeof HibpClientOptionsSchema>`.
## Implementation Notes
The schema validates the *shape* of options rather than their runtime
identity — the `fetch` field is allowed to be any value because the
real `fetch` is a function and Zod has no way to distinguish a fake one
from a real one. Runtime errors from passing a non-function fetch will
surface at call time as a typed `HIBP.NETWORK_ERROR` with a helpful
cause chain. The boundary is invoked exactly once per `HibpClient`
construction so its cost is amortised over the lifetime of the
instance.

View File

@@ -0,0 +1,63 @@
# `error-codes.ts`
## Purpose
Hosts the canonical `HIBP_CODES` registry — exactly seven error codes that
classify every failure path inside `@suaveplan/hibp`. Lives at the package
root (rather than in a nested `errors/` directory) so the
`scripts/conformance.ts` scanner can find it and verify every code
is actually thrown by at least one production source file.
## Features
- Seven stable codes — `RATE_LIMITED`, `UNAUTHORIZED`, `NOT_FOUND`,
`MALFORMED_RESPONSE`, `NETWORK_ERROR`, `ABORTED`, `PAYLOAD_TOO_LARGE`.
- Each value is the namespaced `HIBP.<KEY>` form, suitable for stable
string matching in logs and structured-error registries.
- Registered through `defineCodes` from `@suaveplan/error` so the wider
registry knows the namespace and can validate it at startup.
## Usage: Basic Example
```ts
import { HIBP_CODES, type HibpErrorCode } from "@suaveplan/hibp";
function isFatal(code: HibpErrorCode): boolean {
return code === HIBP_CODES.MALFORMED_RESPONSE;
}
```
## Usage: Advanced Example
```ts
import { HibpError, HIBP_CODES } from "@suaveplan/hibp";
export function classifyHibpFailure(err: unknown): "retry" | "abort" | "report" {
if (!(err instanceof HibpError)) return "report";
switch (err.code) {
case HIBP_CODES.RATE_LIMITED:
case HIBP_CODES.NETWORK_ERROR:
return "retry";
case HIBP_CODES.ABORTED:
return "abort";
default:
return "report";
}
}
```
## API Reference
- `HIBP_CODES` — frozen record. Keys are bare names (`RATE_LIMITED`, …);
values are namespaced strings (`"HIBP.RATE_LIMITED"`, …).
- `HibpErrorCode` — string-literal union of every registered value.
## Implementation Notes
The seven codes were chosen so each one corresponds to a distinct caller
remediation path: rate-limit backoff, credential rotation, resource
absence, schema regression, transport failure, user-driven cancellation,
and over-sized response. Adding a new code is a breaking change because
downstream consumers fan out on `code` literally. Removing a code is
likewise breaking and forbidden by the conformance gate, which verifies
every registered code is thrown by at least one production path.

View File

@@ -0,0 +1,57 @@
# `errors/codes.ts`
## Purpose
Spec-canonical path for the HIBP error-code registry. This file is a thin
re-export of the top-level `src/error-codes.ts` module where the
`defineCodes("HIBP", [...])` call physically lives. The split exists
because the conformance scanner looks for the registry at
`src/error-codes.ts`, while the spec documents the consumer-facing import
as `@suaveplan/hibp/errors/codes` — keeping both lookups working without
duplicating the code list.
## Features
- Identical surface to `error-codes.ts`: `HIBP_CODES` and `HibpErrorCode`.
- Zero executable code in this file — it is a pure re-export, so any
future change to the registry is made in exactly one place.
- Allows downstream consumers to import from the spec-canonical subpath
without leaking the conformance-driven physical layout.
## Usage: Basic Example
```ts
import { HIBP_CODES } from "@suaveplan/hibp/errors/codes";
if (Object.keys(HIBP_CODES).length !== 7) {
throw new Error("HIBP registry corrupted");
}
```
## Usage: Advanced Example
```ts
import type { HibpErrorCode } from "@suaveplan/hibp/errors/codes";
interface FailureTelemetry {
readonly code: HibpErrorCode;
readonly elapsedMs: number;
}
```
## API Reference
Same as `error-codes.ts`:
- `HIBP_CODES` — frozen record of the seven registered codes.
- `HibpErrorCode` — string-literal union.
## Implementation Notes
The re-export form (`export { ... } from "../error-codes.js"`) preserves
the same module identity — `HIBP_CODES` imported here is `===` to
`HIBP_CODES` imported from the root barrel. Consumers can freely mix and
match the two import paths without worrying about duplicate registration
or identity mismatches in `Set`-based discrimination. If the canonical
location is ever moved, this file is the only one that needs to change
to keep the spec-documented import surface stable.

View File

@@ -0,0 +1,69 @@
# `errors/hibp-error.ts`
## Purpose
`HibpError` is the structured error class every failure path inside
`@suaveplan/hibp` throws. It carries one of the seven registered
`HibpErrorCode` values plus optional cause / context / trace-id metadata,
and ships a `toJSON()` form that strips sensitive payloads (API keys,
emails, passwords, full SHA-1 hashes) before any logger touches it.
## Features
- `code` field narrows on the seven-member registry, so callers can fan
out on stable enumerated values rather than parse messages.
- Inherits trace-id propagation and timestamp from
`SuaveplanError`, keeping every error in the project wire-compatible.
- `leakClass` field annotates errors thrown by `assert-no-leak.ts` with
whether the offending value was a SHA-1, a password, or an email —
without ever echoing the leaked bytes.
- `toJSON()` scrubs any 40-char hex sequence and any address-shaped
substring as a defence-in-depth filter, so even an unexpected interpolation
cannot leak a hash or email.
## Usage: Basic Example
```ts
import { HIBP_CODES, HibpError } from "@suaveplan/hibp";
try {
/* ... */
} catch (err) {
if (err instanceof HibpError && err.code === HIBP_CODES.RATE_LIMITED) {
// back off
}
}
```
## Usage: Advanced Example
```ts
import { HibpError } from "@suaveplan/hibp";
function logSafely(err: unknown, logger: { error(s: string): void }): void {
if (err instanceof HibpError) {
logger.error(JSON.stringify(err.toJSON()));
} else if (err instanceof Error) {
logger.error(err.message);
}
}
```
## API Reference
- `new HibpError(code, message, options?)` — `options` accepts `cause`,
`context`, `traceId`, `clock`, and `leakClass`.
- `error.code: HibpErrorCode` — the namespaced code string.
- `error.leakClass: "sha1" | "password" | "email" | undefined`.
- `error.toJSON()` — returns a stable, redacted serialization safe to
hand to any logger.
## Implementation Notes
The `toJSON` redaction is intentionally regex-based — strings that happen
to resemble a SHA-1 or an email get replaced with `[redacted]` even if
they were never deliberate. False positives here cost almost nothing;
false negatives would cost the privacy contract. The constructor uses a
helper to build the super-options object so that
`exactOptionalPropertyTypes: true` doesn't reject `undefined` values
that the parent class doesn't accept.

View File

@@ -0,0 +1,68 @@
# `observability/metrics.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the OpenTelemetry instrument bundle for the package: two histograms
(`hibp.range.duration_ms`, `hibp.breach.duration_ms`) and three counters
(`hibp.range.outcome_total`, `hibp.errors_total`,
`hibp.rate_limit_throttled_total`). Instruments are acquired lazily so
the meter resolves to whatever provider is globally registered when the
first emission fires; tests can swap providers between cases by calling
`resetHibpMetricsCache()`.
## Features
- One thin function per metric (`recordRangeDuration`,
`recordOutcomeMetric`, `recordErrorMetric`, `recordBreachDuration`,
`recordThrottled`) so call sites stay short and analysable.
- `getHibpMetrics()` returns the full bundle for consumers that need
direct instrument access (custom histograms, etc.).
- `resetHibpMetricsCache()` clears the cached bundle so the next call
re-acquires under the current `MeterProvider`. Essential for in-memory
test providers.
- Meter name is `@suaveplan/hibp`, matching the package identifier so
attribute filtering on `instrumentationScope.name` works out of the
box.
## Usage: Basic Example
```ts
import { recordRangeDuration, recordOutcomeMetric } from "@suaveplan/hibp";
recordRangeDuration(12.5);
recordOutcomeMetric("pwned");
```
## Usage: Advanced Example
```ts
import { metrics } from "@opentelemetry/api";
import { resetHibpMetricsCache, getHibpMetrics } from "@suaveplan/hibp";
metrics.setGlobalMeterProvider(myProvider);
resetHibpMetricsCache();
const m = getHibpMetrics();
m.errors.add(1, { code: "HIBP.RATE_LIMITED" });
```
## API Reference
- `HibpMetrics` — bundle interface with the five instruments.
- `getHibpMetrics(): HibpMetrics`.
- `resetHibpMetricsCache(): void`.
- `recordRangeDuration(ms: number)`.
- `recordBreachDuration(ms: number)`.
- `recordOutcomeMetric(outcome: "pwned" | "clean" | "error")`.
- `recordErrorMetric(code: HibpErrorCode)`.
- `recordThrottled()`.
## Implementation Notes
The module caches the bundle at module scope to avoid the `getMeter` ->
`createHistogram` cycle on every call. The cache is invalidated only by
`resetHibpMetricsCache()` because in production the global provider is
set once at startup. Counters use low-cardinality string attributes
(`outcome`, `code`) so a metrics backend's series count stays bounded.

View File

@@ -0,0 +1,62 @@
# `privacy/assert-no-leak.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`assertNoLeak` is the runtime sentinel that scans an outgoing `Request`
URL and body byte-by-byte for three classes of forbidden content: the
full SHA-1 digest of the password being checked, the password itself,
and the configured email. A hit on any of the three throws
`HibpError(HIBP.MALFORMED_RESPONSE)` carrying a `leakClass` tag that
identifies what was leaked — without ever echoing the leaked bytes into
the error message.
## Features
- Three independent leak classes — `sha1`, `password`, `email` — each
optional so callers pay only for the checks they configure.
- Whitelists the legitimate 5-hex prefix in the range URL so the
k-anonymity protocol does not trip the check.
- Treats both the literal email and its URL-encoded form as body leaks,
but allows the documented `/api/v3/breachedaccount/{encoded}` and
`/api/v3/pasteaccount/{encoded}` URL shapes.
- Tolerates requests whose body cannot be cloned (some `Request`
subclasses) — failure to read the body is silent rather than fatal.
- No allocations beyond what `Request.clone().text()` requires.
## Usage: Basic Example
```ts
import { assertNoLeak } from "@suaveplan/hibp";
await assertNoLeak(req, { password: "hunter2" });
```
## Usage: Advanced Example
```ts
import { assertNoLeak, hashSha1Prefix } from "@suaveplan/hibp";
const { full } = hashSha1Prefix(password);
await assertNoLeak(req, { fullSha1: full, password, email });
```
## API Reference
- `LeakCandidate` — `{ fullSha1?, password?, email? }`.
- `LeakClass` — `"sha1" | "password" | "email"`.
- `assertNoLeak(request, candidates): Promise<void>` — throws on hit.
## Implementation Notes
The helper is intentionally async even though the URL check is
synchronous: reading the body requires `Request.clone().text()`, which
returns a `Promise<string>`. The check uses `Array.prototype.includes`
on the strings rather than per-byte iteration because JS strings already
expose `indexOf` in O(n), and the simpler API has fewer ways to go
wrong. The 5-hex prefix is stripped before the SHA-1 search to avoid
flagging the legitimate k-anonymity payload. Production deployments opt
in via `HibpClient` `assertOutgoingRequestSafe: true`; tests use it
unconditionally as a regression sentinel.

View File

@@ -0,0 +1,59 @@
# `range/constant-time-compare.ts`
## Purpose
`constantTimeEqualsHex` compares two hex suffixes without early-exit on
the first mismatching character. It is used by the range-API client to
verify whether any line in the response matches the input password's
35-char suffix, so a timing-side-channel attacker cannot infer which
slot the real suffix lands in. The instrumented variant
(`constantTimeEqualsHexInstrumented`) returns the byte-XOR count for
test assertions.
## Features
- Branch-free byte loop using OR-accumulator over XOR pairs.
- Touches every byte of the input — no length-based shortcut beyond the
initial length-mismatch check.
- ASCII-only (uppercase hex) inputs guarantee `charCodeAt` is
single-byte, keeping per-character cost predictable.
- Test instrumentation exposed without changing the hot path.
## Usage: Basic Example
```ts
import { constantTimeEqualsHex } from "@suaveplan/hibp";
if (constantTimeEqualsHex(line.suffix, target)) {
/* matched */
}
```
## Usage: Advanced Example
```ts
import { constantTimeEqualsHexInstrumented } from "@suaveplan/hibp";
const { equal, bytesTouched } = constantTimeEqualsHexInstrumented(a, b);
if (!equal && bytesTouched !== a.length) {
throw new Error("comparator regressed to early-exit");
}
```
## API Reference
- `constantTimeEqualsHex(a: string, b: string): boolean`.
- `constantTimeEqualsHexInstrumented(a, b): { equal, bytesTouched }`.
## Implementation Notes
JavaScript does not expose a true constant-time string compare, but the
loop pattern above is the canonical approximation. The OR-accumulator
trick converts every byte XOR into a single boolean test at the end,
eliminating the data-dependent branch that an `if (a[i] !== b[i])
return false` would introduce. The length-mismatch check leaks only the
public fact that the two strings differ in length, which is never
secret in this protocol. The test suite asserts the wall-clock mean
delta between equal and unequal pairs stays within tolerance over
10 000 samples; the more rigorous proof is the bytes-touched
invariant.

View File

@@ -0,0 +1,62 @@
# `range/differential-privacy.ts`
## Purpose
`padPrefixes` generates the decoy prefixes used by the
differential-privacy mode of `checkPassword`. Given the real 5-hex
prefix and a positive `n`, it returns an array of `n + 1` prefixes —
the real one plus `n` uniformly random siblings drawn from the
`[00000, FFFFF]` space, with the real prefix guaranteed never to appear
in a decoy slot. The injectable `Rng` keeps the function deterministic
under test.
## Features
- Always includes the real prefix at index 0, normalised to uppercase.
- Decoys are unique within a single call (a `Set` guards against
collisions) and never equal to the real prefix.
- Uses `Rng.nextIntInRange` when present (unbiased on any range) and
falls back to unbiased rejection sampling over `nextUint32` so
`Math.random`-style RNGs without range support still produce
uniform output.
- Returns an immutable array literal, so consumers can iterate without
worrying about accidental mutation.
## Usage: Basic Example
```ts
import { padPrefixes } from "@suaveplan/hibp";
const prefixes = padPrefixes("F3BBB", 3, myRng);
// length 4, includes "F3BBB", three random siblings
```
## Usage: Advanced Example
```ts
import { padPrefixes } from "@suaveplan/hibp";
import type { Rng } from "@suaveplan/types";
const deterministic: Rng = {
nextUint32: () => 0,
nextFloat: () => 0,
nextIntInRange: (_, max) => max - 1,
};
const prefixes = padPrefixes("AAAAA", 2, deterministic);
// Reproducible decoy set for tests.
```
## API Reference
- `padPrefixes(realPrefix, n, rng): readonly string[]`.
## Implementation Notes
The rejection-sampling fallback divides the `[0, 2^32)` space by 16 and
discards any draw landing in the residual partial range to keep the
distribution uniform — a `% 16` shortcut would bias the low nibbles.
The seen-set deduplication keeps the algorithm O(n) on average even
under unlikely RNG repeats. The function intentionally throws no
typed `HibpError` because input validation (range, RNG presence) is
handled one layer up in `RangeClient.checkPassword`.

View File

@@ -0,0 +1,60 @@
# `range/range-mode.ts`
## Purpose
Houses the hash-mode dispatch utilities used by `checkPassword`:
`hashSha1Prefix` (SHA-1 via `@noble/hashes/sha1`), `hashNtlmPrefix`
(MD4-over-UTF-16-LE, supplied by an injectable adapter because not every
runtime ships an MD4 implementation), and `buildRangeUrl` (assembles the
final `/range/<prefix>[?mode=ntlm]` URL). The split between hashing and
URL building keeps each function small, testable, and reusable from
both the public `checkPassword` path and the privacy-leak verification
helpers.
## Features
- `hashSha1Prefix(password)` returns `{ prefix, suffix, full }` with the
uppercase 40-char digest and its k-anonymity decomposition.
- `hashNtlmPrefix(password, adapter)` accepts a caller-supplied adapter,
validating that the returned digest is exactly 40 uppercase hex chars.
- `buildRangeUrl(prefix, mode)` appends `?mode=ntlm` only when the mode
is `"ntlm"` — SHA-1 mode never sends a query string.
- `RangeMode` type alias narrows callers to the two valid values.
## Usage: Basic Example
```ts
import { buildRangeUrl, hashSha1Prefix } from "@suaveplan/hibp";
const { prefix } = hashSha1Prefix("hunter2");
const url = buildRangeUrl(prefix, "sha1");
```
## Usage: Advanced Example
```ts
import { buildRangeUrl, hashNtlmPrefix } from "@suaveplan/hibp";
import { md4 } from "my-ntlm-adapter";
const { prefix } = hashNtlmPrefix("hunter2", (pw) => md4(pw).toUpperCase());
const url = buildRangeUrl(prefix, "ntlm");
```
## API Reference
- `RangeMode = "sha1" | "ntlm"`.
- `NtlmHashAdapter = (password: string) => string`.
- `hashSha1Prefix(password): { prefix, suffix, full }`.
- `hashNtlmPrefix(password, adapter): { prefix, suffix, full }`.
- `buildRangeUrl(prefix, mode): string`.
## Implementation Notes
SHA-1 is sourced from `@noble/hashes/sha1` rather than from
`@suaveplan/crypto-hash` because the crypto-hash package deliberately
omits SHA-1 (collision-broken for security purposes). The HIBP API
requires SHA-1 strictly for k-anonymity bucket selection — never for
security — so the direct noble dependency is the right call.
`hashNtlmPrefix` validates the adapter output rather than trusting it
because a buggy adapter could produce shorter strings and accidentally
leak unrelated bytes.

View File

@@ -0,0 +1,67 @@
# `range/range-stream-parse.ts`
## Purpose
Streams an HTTP response body line-by-line through a `TextDecoderStream`
and a newline framer, yielding `{ suffix, count }` records as soon as
each line crosses a complete byte boundary. The parser deliberately
never calls `Response.text()` and never collects the body into a single
string buffer, so even a 30 KiB `Add-Padding` response is processed in
bounded memory. Mid-flight `AbortSignal` is honoured between chunks.
## Features
- Async generator interface — drop in to `for await` loops.
- CRLF and LF newlines both work; trailing line without newline is
emitted; empty lines are skipped.
- Per-line validation delegated to `parseRangeLine` so strict-mode
rejection happens at first-fault.
- AbortSignal threading: pre-aborted and mid-flight aborts both throw
`HIBP.ABORTED` and the underlying reader is released in a `finally`
guard.
## Usage: Basic Example
```ts
import { streamParseRangeBody } from "@suaveplan/hibp";
for await (const line of streamParseRangeBody(response.body!, { strict: true })) {
if (line.suffix === expected) /* ... */;
}
```
## Usage: Advanced Example
```ts
import { streamParseRangeBody } from "@suaveplan/hibp";
const ac = new AbortController();
const iter = streamParseRangeBody(response.body!, {
strict: false,
signal: ac.signal,
});
setTimeout(() => ac.abort(), 100);
try {
for await (const _line of iter) { /* ... */ }
} catch (err) {
if ((err as { code?: string }).code === "HIBP.ABORTED") {
// user cancelled
}
}
```
## API Reference
- `streamParseRangeBody(body, { strict, signal? }): AsyncGenerator<RangeLine>`.
## Implementation Notes
The `TextDecoderStream` typings model the writable side as
`BufferSource`, which trips the structural `pipeThrough` constraint, so
the parser casts at the boundary. Calls to `Response.text()` are
forbidden by the spec — the test suite asserts zero invocations. The
generator's `finally` clause releases the underlying reader even when
the consumer breaks out early, preventing leaked readable streams when
a caller chooses to short-circuit on the first match.

View File

@@ -0,0 +1,65 @@
# `range/range-strict-frame.ts`
## Purpose
`parseRangeLine` is the per-line framer used by `streamParseRangeBody`.
It enforces the documented Pwned Passwords line shape (`/^[0-9A-F]{35}:[0-9]+$/`)
in strict mode, throwing `HIBP.MALFORMED_RESPONSE` with the offending
line index on any deviation. In off-mode it accepts lowercase hex,
defaults an unparseable count to `0`, and still rejects structurally
broken lines (missing colon, wrong suffix length, non-hex characters).
## Features
- Strict mode default — uppercase hex, single colon, decimal count.
- Off mode tolerant of lowercase hex and unparseable counts (defaults
to `0`), still rejecting non-hex suffixes or wrong lengths.
- Throws carry `context.lineIndex` so callers can surface a precise
diagnostic.
- Pure synchronous function — no allocations beyond the returned
`{ suffix, count }` record.
## Usage: Basic Example
```ts
import { parseRangeLine } from "@suaveplan/hibp";
const { suffix, count } = parseRangeLine(rawLine, 0, true);
```
## Usage: Advanced Example
```ts
import { HibpError, parseRangeLine } from "@suaveplan/hibp";
function parseTolerant(lines: string[]): Array<{ suffix: string; count: number }> {
const out: Array<{ suffix: string; count: number }> = [];
for (let i = 0; i < lines.length; i++) {
try {
out.push(parseRangeLine(lines[i] as string, i, false));
} catch (err) {
if (err instanceof HibpError) continue;
throw err;
}
}
return out;
}
```
## API Reference
- `RangeLine` — `{ suffix: string; count: number }`.
- `parseRangeLine(raw, lineIndex, strict): RangeLine` — throws
`HibpError(HIBP.MALFORMED_RESPONSE)` on rejection.
## Implementation Notes
The strict pattern uses a tight regex check before the colon split so
mismatches are rejected before any allocation. Off-mode counts default
to `0` rather than `NaN` so consumers can rely on integer semantics
throughout. The function avoids `try`/`catch` internally — every
branch is explicit so a future contributor can reason about the
failure paths without tracing exception flow. Strict mode is the
default because the documented Pwned Passwords contract is precise;
opting out should be a deliberate operational decision, not the
silent fallback.

View File

@@ -0,0 +1,77 @@
# `range/range.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RangeClient` is the heart of the package — the k-anonymity Pwned
Passwords client. `checkPassword(password, opts?)` hashes the input,
slices the 5-hex prefix, dispatches `GET https://api.pwnedpasswords.com/range/<prefix>`
with the mandatory `Add-Padding: true` header, scans the streamed
response for a matching 35-char suffix, and returns
`{ pwned, count }`. It owns the strict / off framing, the NTLM mode
opt-in, the differential-privacy decoy fan-out, the AbortSignal
threading, and the OTel emission.
## Features
- Mandatory `Add-Padding: true` on every fetch — including every
differential-privacy decoy.
- SHA-1 (default) and NTLM (caller-injected adapter) hash modes.
- Constant-time suffix compare on every parsed line.
- Streaming line parser (no `Response.text()` call ever).
- Differential-privacy decoy fan-out when `extraPrefixes > 0` — real
prefix's response is the only one consulted.
- AbortSignal threaded into fetch and the streaming parser.
- OTel histograms for duration; counters for outcome, errors,
throttling.
- Optional `assertOutgoingRequestSafe: true` runtime privacy gate.
## Usage: Basic Example
```ts
import { RangeClient } from "@suaveplan/hibp";
const client = new RangeClient();
const { pwned, count } = await client.checkPassword("hunter2");
```
## Usage: Advanced Example
```ts
import { RangeClient } from "@suaveplan/hibp";
const client = new RangeClient({
extraPrefixes: 3,
rng: myRng,
assertOutgoingRequestSafe: true,
strict: true,
});
const ac = new AbortController();
setTimeout(() => ac.abort(), 5_000);
const result = await client.checkPassword("hunter2", { signal: ac.signal });
```
## API Reference
- `CheckPasswordOptions` — `mode?`, `signal?`, `extraPrefixes?`.
- `RangeClientOptions` — `fetchFn`, `clock`, `rng`, `strict`,
`ntlmHash`, `extraPrefixes`, `assertOutgoingRequestSafe`.
- `new RangeClient(options?)`.
- `client.checkPassword(password, opts?): Promise<PwnedResult>`.
## Implementation Notes
The class is stateless beyond its constructor options — every public
method takes the password and a per-call options bag, so a single
`RangeClient` instance is safe to share across a process. Error paths
funnel through the metrics emission so dashboards see counters even
for failures the caller catches and retries. The `assertOutgoingRequestSafe`
gate constructs a `Request` to feed into `assertNoLeak`, then re-issues
the actual fetch with the same `RequestInit` so the privacy check
exercises the real outgoing bytes. The NTLM adapter is required at
runtime if `mode: "ntlm"` is ever passed; a missing adapter surfaces as
`HIBP.MALFORMED_RESPONSE` because the caller has misconfigured the
client rather than the server having misbehaved.

View File

@@ -0,0 +1,68 @@
# `schemas/breach.schema.ts`
## Purpose
`BreachSchema` is the Zod schema mirroring the [HIBP v3 `Breach`
payload](https://haveibeenpwned.com/API/v3#Breach). It validates every
documented field, so the rest of the package can assume a fully populated
`Breach` value anywhere a parsed breach record flows. Defending the
boundary at this layer means a server-side schema regression fails fast
with a typed `HIBP.MALFORMED_RESPONSE` error rather than silently
producing partial objects.
## Features
- Strict-required fields: missing keys throw at parse time.
- Per-field type narrowing: `PwnCount` is a non-negative integer,
`DataClasses` is `string[]`, every `Is*` field is boolean.
- Covers `IsStealerLog`, the v3 addition that earlier docs sometimes
miss.
- Type alias `Breach` is exported alongside the schema so consumer code
stays single-source-of-truth.
## Usage: Basic Example
```ts
import { BreachSchema } from "@suaveplan/hibp";
const parsed = BreachSchema.parse(await response.json());
console.log(parsed.Name);
```
## Usage: Advanced Example
```ts
import { BreachSchema, type Breach } from "@suaveplan/hibp";
export function partitionBreaches(items: unknown[]): {
verified: Breach[];
fabricated: Breach[];
} {
const verified: Breach[] = [];
const fabricated: Breach[] = [];
for (const item of items) {
const b = BreachSchema.parse(item);
if (b.IsFabricated) fabricated.push(b);
else verified.push(b);
}
return { verified, fabricated };
}
```
## API Reference
- `BreachSchema: ZodObject` — strict v3 breach shape.
- `Breach` — `z.infer<typeof BreachSchema>` type alias.
## Implementation Notes
The schema does not call `.passthrough()` deliberately — opting out of
silent forward-compat keeps server-side schema regressions visible. If
HIBP adds a new field, the integration test in
`breach-client.test.ts` will fail loudly and we can decide whether to
add it, mark it optional, or reject the response. The `IsStealerLog`
field landed in HIBP v3 after the original spec; we include it as
required because every breach record observed in production carries it,
and treating it as optional would let silent regressions slip through.
Consumers concerned about long-tail server inconsistencies can run the
schema through `.partial()` themselves before parsing.

View File

@@ -0,0 +1,64 @@
# `schemas/paste.schema.ts`
## Purpose
`PasteSchema` validates the [HIBP v3 `Paste`
payload](https://haveibeenpwned.com/API/v3#PastesForAccount) returned by
`/pasteaccount/{email}`. Older paste records can omit `Title` and `Date`,
so this schema makes them optional while still enforcing structural shape
on the required fields. Defending this boundary keeps `getPastesForAccount`
honest about its return type.
## Features
- Required: `Source`, `Id`, `EmailCount` (non-negative int).
- Optional: `Title`, `Date` (older paste records may lack metadata).
- Throws `ZodError` on type mismatch; the breach client maps that to
`HIBP.MALFORMED_RESPONSE`.
## Usage: Basic Example
```ts
import { PasteSchema } from "@suaveplan/hibp";
const paste = PasteSchema.parse({
Source: "Pastebin",
Id: "ABC",
EmailCount: 100,
});
```
## Usage: Advanced Example
```ts
import { PasteSchema, type Paste } from "@suaveplan/hibp";
export function summarisePastes(items: unknown[]): {
pastes: Paste[];
totalEmails: number;
} {
const pastes = items.map((x) => PasteSchema.parse(x));
return {
pastes,
totalEmails: pastes.reduce((n, p) => n + p.EmailCount, 0),
};
}
```
## API Reference
- `PasteSchema: ZodObject` — v3 paste shape.
- `Paste` — `z.infer<typeof PasteSchema>` type alias.
## Implementation Notes
The optional `Title` / `Date` fields are deliberately not defaulted; if
they are absent in the upstream payload, they remain `undefined` in the
parsed value so callers can branch on presence. We considered defaulting
`Date` to `null` for consistency but rejected it as a divergence from the
documented contract. The schema rejects unknown nested types eagerly: a
server-side rename like `Source` → `source` (lowercase) will surface as a
`HIBP.MALFORMED_RESPONSE` from `getPastesForAccount`, which is the
correct behaviour — a silent rename would be far worse than a typed
throw. Callers can wrap individual `parse` calls in `try` if partial
tolerance is desired, but the default policy is fail-fast.

View File

@@ -0,0 +1,57 @@
# `schemas/pwned-result.schema.ts`
## Purpose
`PwnedResultSchema` validates the runtime shape of every `checkPassword`
result. It is the final boundary between the internal range-parse loop
and the public return type. A negative count or a non-boolean `pwned`
flag triggers a typed throw before the value escapes the package, so
downstream consumers can trust the parsed value blindly.
## Features
- `pwned: boolean` — never undefined, never `null`.
- `count: int >= 0` — `Number.isInteger` enforced; negatives rejected.
- Coupled with the `PwnedResult` type alias so the schema is the single
source of truth for both runtime validation and TypeScript narrowing.
## Usage: Basic Example
```ts
import { PwnedResultSchema } from "@suaveplan/hibp";
const r = PwnedResultSchema.parse({ pwned: true, count: 42 });
```
## Usage: Advanced Example
```ts
import { PwnedResultSchema, type PwnedResult } from "@suaveplan/hibp";
export function classify(r: PwnedResult): "safe" | "compromised" | "leaked-heavily" {
const safe = PwnedResultSchema.parse(r);
if (!safe.pwned) return "safe";
return safe.count > 1000 ? "leaked-heavily" : "compromised";
}
```
## API Reference
- `PwnedResultSchema: ZodObject<{ pwned, count }>`.
- `PwnedResult` — `z.infer<typeof PwnedResultSchema>` alias.
## Implementation Notes
The validation is deliberately the very last step in `checkPassword`; it
catches any internal logic bug that produces a malformed result before
the value reaches user code. The check is cheap enough (two primitive
type assertions) to run unconditionally. The schema doubles as
documentation: the spec scenarios reference `PwnedResultSchema` directly,
so spec readers can verify the runtime contract without reading
TypeScript. The `int` modifier ensures fractional counts produced by
some upstream API gateways (which occasionally serialize integers as
JSON numbers with trailing `.0`) are rejected loudly — silent rounding
would mask a server-side regression. Adding fields to the result type
is a breaking change because consumers may destructure on `{ pwned,
count }`; if a future need arises, prefer a sibling schema with the
extra fields.

View File

@@ -0,0 +1,147 @@
# @suaveplan/jws - RFC 7515 JSON Web Signature (verify + sign). Compact, JSON-flattened, and JSON-general (multi-signature) serializations; HS/RS/PS/ES/EdDSA algorithms; JWK + x5c + kid key resolution; crit allowlist enforcement; detached payload (§A.5); RFC 7797 b64 header support.
## Summary
RFC 7515 **JSON Web Signature** (verify + sign). Pure TypeScript over `Uint8Array` + `DataView` plus the workspace crypto primitives (`@suaveplan/crypto-rsa`, `@suaveplan/crypto-curve`, `@suaveplan/crypto-hash`, `@suaveplan/crypto-utils`) and `@suaveplan/x509` for `x5c` chain validation. Zero browser globals, zero Node built-ins, zero external JOSE/JWT/jws-npm dependencies.
<!-- begin:auto-docs -->
## Documentation tree
- [builtin](./src/algorithms/builtin.md) — Seeds the default `AlgorithmRegistry` with the 13 built-in algorithm implementations required by RFC 7518 §3 and RFC 80…
- [ecdsa](./src/algorithms/ecdsa.md) — RFC 7518 §3.4 ECDSA signing and verification over NIST P-256, P-384, and P-521. The signing input is hashed with the cu…
- [eddsa](./src/algorithms/eddsa.md) — RFC 8037 §3.1 EdDSA over Ed25519. The signing input is signed directly by Ed25519 (no caller-side pre-hash — Ed25519 ha…
- [hmac](./src/algorithms/hmac.md) — RFC 7518 §3.2 HMAC-with-SHA-2 signing and verification. The signing input is HMAC'd with the caller-supplied symmetric…
- [none](./src/algorithms/none.md) — RFC 7518 §3.6 `"none"` algorithm. The `none` value declares "no integrity protection," which is virtually never what a…
- [registry](./src/algorithms/registry.md) — In-package mapping from `alg` identifier to the concrete `AlgorithmImpl` that knows how to verify and (optionally) sign…
- [rsa-pss](./src/algorithms/rsa-pss.md) — RFC 7518 §3.5 RSASSA-PSS signing and verification. The signing input is hashed, EMSA-PSS-encoded with a random salt, an…
- [rsa](./src/algorithms/rsa.md) — RFC 7518 §3.3 RSASSA-PKCS1-v1_5 signing and verification. The signing input is hashed with SHA-256/SHA-384/SHA-512 resp…
- [base64url](./src/base64url/base64url.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [base64url](./src/base64url/base64url.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [b64](./src/header/b64.md) — RFC 7797 §3 implements the `b64` header parameter, allowing JWS producers to embed unencoded payloads directly into the…
- [crit](./src/header/crit.md) — RFC 7515 §4.1.11 enforcement: every entry in `header.crit` MUST be in the caller's `acceptableCriticalHeaders` allowlis…
- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [jwk](./src/keys/jwk.md) — Parse an RFC 7517 §4 / RFC 7518 §6 JSON Web Key into the typed key shape expected by the JWS algorithm impl for the dec…
- [resolver](./src/keys/resolver.md) — Caller-supplied key-resolution function consulted when `header.kid` is present and no key has been supplied via `opts.k…
- [x5c](./src/keys/x5c.md) — Decode an RFC 7515 §4.1.6 `x5c` certificate chain, delegate trust-path validation to `@suaveplan/x509.validateChain`, a…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [metrics](./src/observability/metrics.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 -->
- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sign](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sign-pipeline](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [verify](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [verify-pipeline](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [base64url](./src/base64url/base64url.md) — <!-- begin:auto-docs --> _(no submodules)_- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_- [b64](./src/header/b64.md) — RFC 7797 §3 implements the `b64` header parameter, allowing JWS producers to embed unencoded payloads directly into the…
- [crit](./src/header/crit.md) — RFC 7515 §4.1.11 enforcement: every entry in `header.crit` MUST be in the caller's `acceptableCriticalHeaders` allowlis…
- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwk](./src/keys/jwk.md) — Parse an RFC 7517 §4 / RFC 7518 §6 JSON Web Key into the typed key shape expected by the JWS algorithm impl for the dec…
- [resolver](./src/keys/resolver.md) — Caller-supplied key-resolution function consulted when `header.kid` is present and no key has been supplied via `opts.k…
- [x5c](./src/keys/x5c.md) — Decode an RFC 7515 §4.1.6 `x5c` certificate chain, delegate trust-path validation to `@suaveplan/x509.validateChain`, a…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.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)_- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_- [sign](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [sign-pipeline](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [verify](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [verify-pipeline](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [base64url](./src/base64url/base64url.md) — <!-- begin:auto-docs --> _(no submodules)_- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_- [b64](./src/header/b64.md) — RFC 7797 §3 implements the `b64` header parameter, allowing JWS producers to embed unencoded payloads directly into the…
- [crit](./src/header/crit.md) — RFC 7515 §4.1.11 enforcement: every entry in `header.crit` MUST be in the caller's `acceptableCriticalHeaders` allowlis…
- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwk](./src/keys/jwk.md) — Parse an RFC 7517 §4 / RFC 7518 §6 JSON Web Key into the typed key shape expected by the JWS algorithm impl for the dec…
- [resolver](./src/keys/resolver.md) — Caller-supplied key-resolution function consulted when `header.kid` is present and no key has been supplied via `opts.k…
- [x5c](./src/keys/x5c.md) — Decode an RFC 7515 §4.1.6 `x5c` certificate chain, delegate trust-path validation to `@suaveplan/x509.validateChain`, a…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.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)_- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_- [sign](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [sign-pipeline](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [verify](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [verify-pipeline](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [base64url](./src/base64url/base64url.md) — <!-- begin:auto-docs --> _(no submodules)_- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_- [b64](./src/header/b64.md) — RFC 7797 §3 implements the `b64` header parameter, allowing JWS producers to embed unencoded payloads directly into the…
- [crit](./src/header/crit.md) — RFC 7515 §4.1.11 enforcement: every entry in `header.crit` MUST be in the caller's `acceptableCriticalHeaders` allowlis…
- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwk](./src/keys/jwk.md) — Parse an RFC 7517 §4 / RFC 7518 §6 JSON Web Key into the typed key shape expected by the JWS algorithm impl for the dec…
- [resolver](./src/keys/resolver.md) — Caller-supplied key-resolution function consulted when `header.kid` is present and no key has been supplied via `opts.k…
- [x5c](./src/keys/x5c.md) — Decode an RFC 7515 §4.1.6 `x5c` certificate chain, delegate trust-path validation to `@suaveplan/x509.validateChain`, a…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.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)_- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_- [sign](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [sign-pipeline](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [verify](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [verify-pipeline](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [base64url](./src/base64url/base64url.md) — <!-- begin:auto-docs --> _(no submodules)_- [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_ - [detached](./src/detached/detached.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ - [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_ - [b64](./src/header/b64.md) — RFC 7797 §3 implements the `b64` header parameter, allowing JWS producers to embed unencoded payloads directly into the…
- [crit](./src/header/crit.md) — RFC 7515 §4.1.11 enforcement: every entry in `header.crit` MUST be in the caller's `acceptableCriticalHeaders` allowlis…
- [header](./src/header/header.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwk](./src/keys/jwk.md) — Parse an RFC 7517 §4 / RFC 7518 §6 JSON Web Key into the typed key shape expected by the JWS algorithm impl for the dec…
- [resolver](./src/keys/resolver.md) — Caller-supplied key-resolution function consulted when `header.kid` is present and no key has been supplied via `opts.k…
- [x5c](./src/keys/x5c.md) — Decode an RFC 7515 §4.1.6 `x5c` certificate chain, delegate trust-path validation to `@suaveplan/x509.validateChain`, a…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metrics](./src/observability/metrics.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)_- [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_ - [compact](./src/serialization/compact/compact.md) — <!-- begin:auto-docs --> _(no submodules)_- [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_ - [flat](./src/serialization/flat/flat.md) — <!-- begin:auto-docs --> _(no submodules)_- [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_ - [general](./src/serialization/general/general.md) — <!-- begin:auto-docs --> _(no submodules)_- [sign](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sign-pipeline](./src/sign/sign-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_- [verify](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_ - [verify-pipeline](./src/verify/verify-pipeline.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```sh
bun add @suaveplan/jws
```
## Quick start
```ts
import { compactSign, compactVerify } from "@suaveplan/jws";
const secret = new TextEncoder().encode("a-shared-32-byte-secret-pls-rotate");
const token = await compactSign("hello", secret, "HS256");
const result = await compactVerify(token, secret, { algorithms: ["HS256"] });
// result.payloadString === "hello"
```
## Capabilities
| Feature | Status |
| -------------------------------- | ----------------------------------- |
| RFC 7515 §3.1 compact form | ✓ |
| RFC 7515 §7.2.2 JSON-flattened | ✓ |
| RFC 7515 §7.2.1 JSON-general | ✓ (multi-signature) |
| RFC 7515 §A.5 detached payload | ✓ |
| RFC 7797 `b64: false` header | ✓ |
| HS256 / HS384 / HS512 | ✓ (constant-time tag compare) |
| RS256 / RS384 / RS512 | ✓ (`@suaveplan/crypto-rsa`) |
| PS256 / PS384 / PS512 | ✓ (hash-length salt) |
| ES256 / ES384 / ES512 | ✓ (`@suaveplan/crypto-curve`; DER rejected) |
| EdDSA (Ed25519) | ✓ (RFC 8037) |
| `alg: "none"` | ✓ (dual-flag opt-in only) |
| Embedded `jwk` header | ✓ (`allowEmbeddedJwk: true`) |
| Embedded `x5c` chain | ✓ (`@suaveplan/x509.validateChain`) |
| `kid` resolver | ✓ (caller-supplied async fn) |
| `crit` allowlist | ✓ |
| AbortSignal end-to-end | ✓ |
| Zod boundary at every entry | ✓ |
| OpenTelemetry histograms+counters| ✓ |
## Security defaults
- **Algorithm allowlist is mandatory at verify time.** There is no default — callers MUST pass `opts.algorithms`. Tokens whose `alg` is not on the list are rejected before any cryptography runs (defence against the JWT "alg confusion" CVE class).
- **`alg: "none"` requires dual-flag opt-in.** Both `algorithms: ["none"]` AND `acceptNone: true` are required.
- **`crit` parameters must be allowlisted.** Per RFC 7515 §4.1.11.
- **ECDSA signatures must be fixed-length `R||S`.** DER-encoded signatures in the JOSE position throw `JWS.SIG_FORMAT_INVALID`.
- **HMAC tag comparison is constant-time.** Via `@suaveplan/crypto-utils.constantTimeEqual`.
- **PSS salt length is fixed at the hash output length.** Per RFC 7518 §3.5.
## Modules
| Module | Doc |
| --- | --- |
| `base64url` | [`src/base64url/base64url.md`](src/base64url/base64url.md) |
| Compact serialization | [`src/serialization/compact/compact.md`](src/serialization/compact/compact.md) |
| Flat serialization | [`src/serialization/flat/flat.md`](src/serialization/flat/flat.md) |
| General serialization | [`src/serialization/general/general.md`](src/serialization/general/general.md) |
| Detached payload | [`src/detached/detached.md`](src/detached/detached.md) |
| Header parse / serialize | [`src/header/header.md`](src/header/header.md) |
| `crit` enforcement | [`src/header/crit.md`](src/header/crit.md) |
| RFC 7797 `b64` | [`src/header/b64.md`](src/header/b64.md) |
| Algorithm registry | [`src/algorithms/registry.md`](src/algorithms/registry.md) |
| Built-in seeding | [`src/algorithms/builtin.md`](src/algorithms/builtin.md) |
| HMAC (HS256/384/512) | [`src/algorithms/hmac.md`](src/algorithms/hmac.md) |
| RSA-PKCS1-v1\_5 (RS\*) | [`src/algorithms/rsa.md`](src/algorithms/rsa.md) |
| RSA-PSS (PS\*) | [`src/algorithms/rsa-pss.md`](src/algorithms/rsa-pss.md) |
| ECDSA (ES\*) | [`src/algorithms/ecdsa.md`](src/algorithms/ecdsa.md) |
| EdDSA (Ed25519) | [`src/algorithms/eddsa.md`](src/algorithms/eddsa.md) |
| `alg: "none"` | [`src/algorithms/none.md`](src/algorithms/none.md) |
| JWK → key | [`src/keys/jwk.md`](src/keys/jwk.md) |
| `x5c` chain | [`src/keys/x5c.md`](src/keys/x5c.md) |
| `KeyResolver` | [`src/keys/resolver.md`](src/keys/resolver.md) |
| Verify pipeline | [`src/verify/verify-pipeline.md`](src/verify/verify-pipeline.md) |
| Sign pipeline | [`src/sign/sign-pipeline.md`](src/sign/sign-pipeline.md) |
| Error registry | [`src/errors/error-codes.md`](src/errors/error-codes.md) |
| Schemas | [`src/schemas/schemas.md`](src/schemas/schemas.md) |
| Metrics | [`src/observability/metrics.md`](src/observability/metrics.md) |
## License
MIT.

View File

@@ -0,0 +1,46 @@
# Built-in algorithm seeding
## Purpose
Seeds the default `AlgorithmRegistry` with the 13 built-in algorithm implementations required by RFC 7518 §3 and RFC 8037: `HS256`/`HS384`/`HS512`, `RS256`/`RS384`/`RS512`, `PS256`/`PS384`/`PS512`, `ES256`/`ES384`/`ES512`, `EdDSA`, plus the dual-flag-opt-in `none`. The seeding runs once at module load via a side-effect import from both `algorithms/index.ts` and the package root `index.ts`, so the registry is always populated before any verify or sign call. Idempotent via a module-scoped boolean — safe to call multiple times if a test reload cycles the module graph.
## Features
- Seeds 14 algorithms total (13 RFC-mandated + `none`).
- Idempotent: `seedBuiltinAlgorithms()` is a no-op after the first call.
- Side-effect import from `algorithms/index.ts` and `src/index.ts`.
## Usage: Basic Example
```ts
import { defaultRegistry, seedBuiltinAlgorithms } from "@suaveplan/jws";
// Already seeded by the side-effect import — these are introspection-only:
defaultRegistry.algorithms();
// ["HS256", "HS384", "HS512", "RS256", "RS384", "RS512",
// "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA", "none"]
```
## Usage: Advanced Example
```ts
import { AlgorithmRegistry, seedBuiltinAlgorithms } from "@suaveplan/jws";
// Build an isolated registry for an integration test, seeded identically to
// the default. (Useful when you want to register additional experimental
// algorithms without polluting the global registry.)
const isolated = new AlgorithmRegistry();
// Manually re-register the built-ins:
// (Note: `seedBuiltinAlgorithms` seeds the *default* registry, not an arbitrary
// one; tests needing isolation use `defaultRegistry` directly or build the
// isolated registry up from named imports of `hs256` / `rs256` / etc.)
seedBuiltinAlgorithms(); // idempotent no-op if already seeded
```
## API Reference
- `seedBuiltinAlgorithms(): void` — idempotent; seeds the package-shared `defaultRegistry`.
## Implementation Notes
The side-effect import `import "./builtin.js"` at the top of `algorithms/index.ts` AND `src/index.ts` is load-bearing — without it, the first `compactVerify` call would throw `JWS.ALG_UNSUPPORTED` because the registry would be empty. The dual-import is defence in depth: any consumer importing from either the package root or the `./algorithms` subpath triggers seeding. The implementation guards against double-registration via the idempotency flag rather than via the registry's own duplicate-rejection logic — that way a `seedBuiltinAlgorithms()` call from a test after a registry reset doesn't throw.

View File

@@ -0,0 +1,60 @@
# ECDSA algorithms (`ES256` / `ES384` / `ES512`)
## Purpose
RFC 7518 §3.4 ECDSA signing and verification over NIST P-256, P-384, and P-521. The signing input is hashed with the curve-matched SHA-2 variant, signed via `@suaveplan/crypto-curve`'s curve primitives, and serialised as the fixed-length concatenation `R || S` of the two coordinates. DER-encoded signatures in the JOSE position are rejected with `JWS.SIG_FORMAT_INVALID` — silently parsing them would hide protocol drift between producers, which is the source of multiple JOSE interop CVEs.
## Features
- `ES256` → P-256 + SHA-256, 64-byte `R||S`.
- `ES384` → P-384 + SHA-384, 96-byte `R||S`.
- `ES512` → P-521 + SHA-512, 132-byte `R||S` (R and S are 66 bytes each).
- DER-encoded signature in JOSE position → `JWS.SIG_FORMAT_INVALID`.
- Key shape: raw uncompressed SEC1 public-key bytes (`0x04 || X || Y`) for verify; raw scalar for sign.
## Usage: Basic Example
```ts
import { p256 } from "@suaveplan/crypto-curve";
import { compactSign, compactVerify } from "@suaveplan/jws";
const priv = p256.randomPrivateKey();
const pub = p256.getPublicKey(priv);
const token = await compactSign("hello", priv, "ES256");
await compactVerify(token, pub, { algorithms: ["ES256"] });
```
## Usage: Advanced Example
```ts
import { es256, JwsError } from "@suaveplan/jws";
// Reject DER-encoded signature in JOSE position:
const derSig = new Uint8Array([0x30, 0x46, /* ... */]);
try {
await es256.verify(signingInput, derSig, pub);
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.SIG_FORMAT_INVALID") {
// DER detected by 0x30 lead byte + length mismatch.
}
}
// Wrong-length raw R||S also rejected:
try {
await es256.verify(signingInput, new Uint8Array(63), pub);
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.SIG_FORMAT_INVALID") {
// Generic length mismatch.
}
}
```
## API Reference
- `es256: AlgorithmImpl<Uint8Array>` — verify `(input, sig: 64 bytes, pub)`, sign `(input, priv: 32 bytes)`.
- `es384: AlgorithmImpl<Uint8Array>` — sig 96 bytes, priv 48 bytes.
- `es512: AlgorithmImpl<Uint8Array>` — sig 132 bytes, priv 66 bytes.
## Implementation Notes
Public keys MUST be uncompressed SEC1 (`0x04 || X || Y` of the relevant coordinate length). The verifier delegates to `@suaveplan/crypto-curve`'s `p256.verify` / `p384.verify` / `p521.verify`, which use the Noble curves library with constant-time scalar multiplication. Signing uses RFC 6979 deterministic `k` (no per-signature randomness required), which makes ECDSA JWS production deterministic for a given `(privateKey, payload)` pair — this is a feature for reproducible-build test vectors but means consumers should NOT reuse `k` across messages outside this library. The 132-byte length for ES512 surprises producers expecting 128: P-521 coordinates are 66 bytes each, not 64.

View File

@@ -0,0 +1,52 @@
# EdDSA algorithm
## Purpose
RFC 8037 §3.1 EdDSA over Ed25519. The signing input is signed directly by Ed25519 (no caller-side pre-hash — Ed25519 hashes internally via SHA-512), producing a 64-byte signature. EdDSA is the preferred modern algorithm for greenfield JWS deployments: fast, no `k`-randomness footgun, no salt-length confusion, no signature-format dispute (always raw 64 bytes).
## Features
- `EdDSA` → Ed25519, 64-byte signature.
- Key shape: 32-byte raw `Uint8Array` (priv or pub).
- Ed448 not supported — `@suaveplan/crypto-curve` ships only Ed25519; tokens with `crv: "Ed448"` are rejected at JWK parsing with `JWS.KEY_ALG_MISMATCH`.
## Usage: Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { compactSign, compactVerify } from "@suaveplan/jws";
const priv = ed25519.randomPrivateKey();
const pub = ed25519.getPublicKey(priv);
const token = await compactSign("hello", priv, "EdDSA");
await compactVerify(token, pub, { algorithms: ["EdDSA"] });
```
## Usage: Advanced Example
```ts
import { eddsa, JwsError } from "@suaveplan/jws";
// Direct impl call:
const sig = await eddsa.sign!(new TextEncoder().encode("hdr.payload"), priv);
const ok = await eddsa.verify(new TextEncoder().encode("hdr.payload"), sig, pub);
// Wrong key type:
try {
await eddsa.verify(input, sig, { kty: "OKP" } as never);
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.KEY_ALG_MISMATCH") {
// EdDSA expects raw 32-byte Uint8Array.
}
}
```
## API Reference
- `eddsa: AlgorithmImpl<Uint8Array>` — verify `(input, sig: 64 bytes, pub: 32 bytes)`, sign `(input, priv: 32 bytes)`.
`verify` returns `Promise<boolean>` (`false` for any malformed signature — never throws); `sign` returns `Promise<Uint8Array>` of length 64.
## Implementation Notes
Ed25519 verification is exception-safe: `@suaveplan/crypto-curve.ed25519.verify` wraps Noble's verify in a `try/catch` that returns `false` on any signature-decoding error, so a malformed 64-byte input surfaces as `JWS.SIG_INVALID` at the pipeline level rather than a low-level decoding exception. Per RFC 8037 §3.1 the `alg: "EdDSA"` parameter is overloaded for both Ed25519 and Ed448 — disambiguation requires inspecting the `header.jwk.crv` value (or, for `x5c`, the leaf SPKI algorithm OID). This package currently only implements Ed25519; an Ed448 token routes the same `EdDSA` alg through the JWK layer, which catches the `crv: "Ed448"` and throws `KEY_ALG_MISMATCH` — extending to Ed448 only requires adding an Ed448 primitive to `@suaveplan/crypto-curve` and branching this impl on `header.jwk.crv`.

View File

@@ -0,0 +1,54 @@
# HMAC algorithms (`HS256` / `HS384` / `HS512`)
## Purpose
RFC 7518 §3.2 HMAC-with-SHA-2 signing and verification. The signing input is HMAC'd with the caller-supplied symmetric key; the verifier recomputes the tag and compares in constant time. Constant-time compare via `@suaveplan/crypto-utils.constantTimeEqual` is mandatory — `Buffer.compare`, `===`, and any short-circuiting comparison leak the tag's prefix to a timing adversary, and that's been the root cause of multiple practical key-recovery attacks against permissive JOSE libraries.
## Features
- `HS256` → HMAC-SHA-256, 32-byte tag.
- `HS384` → HMAC-SHA-384, 48-byte tag.
- `HS512` → HMAC-SHA-512, 64-byte tag.
- Constant-time tag comparison.
- Key shape: `Uint8Array` (the raw HMAC key); mismatch → `JWS.KEY_ALG_MISMATCH`.
## Usage: Basic Example
```ts
import { compactSign, compactVerify } from "@suaveplan/jws";
const secret = new TextEncoder().encode("a-shared-secret-of-at-least-32-bytes");
const token = await compactSign("hello", secret, "HS256");
const r = await compactVerify(token, secret, { algorithms: ["HS256"] });
// r.payloadString === "hello"
```
## Usage: Advanced Example
```ts
import { compactVerify, JwsError, hs256 } from "@suaveplan/jws";
// Direct algorithm impl access (advanced — verify pipelines should be your
// default).
const signingInput = new TextEncoder().encode("hdr.payload");
const sig = await hs256.sign!(signingInput, secret);
const ok = await hs256.verify(signingInput, sig, secret);
// ok === true
// Tampered tag:
sig[sig.length - 1] ^= 0x01;
const stillOk = await hs256.verify(signingInput, sig, secret);
// stillOk === false (constant-time compare returned false)
```
## API Reference
- `hs256: AlgorithmImpl<Uint8Array>` — `{ alg: "HS256", verify, sign }`
- `hs384: AlgorithmImpl<Uint8Array>` — same shape.
- `hs512: AlgorithmImpl<Uint8Array>` — same shape.
Each `verify(signingInput, signature, key)` returns `Promise<boolean>`; each `sign(signingInput, key)` returns `Promise<Uint8Array>`. Non-`Uint8Array` keys throw `JwsError(JWS.KEY_ALG_MISMATCH)` synchronously.
## Implementation Notes
HMAC is implemented over `@suaveplan/crypto-hash.hmac`, which routes through the package's primitive-selector layer (Noble JS by default; native via the build-time `crypto.config.ts` switch). HMAC keys are required to be `Uint8Array`; passing the raw shared-secret string is a common footgun (its UTF-8 encoding is the actual key, not the string itself), and surfacing a typed `KEY_ALG_MISMATCH` is friendlier than silently HMAC-ing the wrong bytes. Per RFC 7518 §3.2 production deployments SHOULD use keys ≥ the hash output length (32/48/64 bytes); this package does not enforce a minimum because some test vectors use shorter keys, but consumers SHOULD layer that check at their boundary.

View File

@@ -0,0 +1,59 @@
# `none` algorithm
## Purpose
RFC 7518 §3.6 `"none"` algorithm. The `none` value declares "no integrity protection," which is virtually never what a verifier wants but is occasionally needed for non-security-critical contexts (signed-by-policy local development, deliberately-unsigned debugging payloads). This implementation exists only so the registry is complete; the verify pipeline rejects `none` by default and requires BOTH `algorithms: ["none"]` AND `acceptNone: true` to opt in, neutralising the classic "alg: none" downgrade attack.
## Features
- `verify` returns `true` if-and-only-if the signature segment is empty (zero bytes after base64url-decode).
- `sign` always emits empty signature bytes.
- Default rejection: pipeline throws `JWS.ALG_NONE_DISABLED` unless both flags are set.
## Usage: Basic Example
```ts
import { compactSign, compactVerify } from "@suaveplan/jws";
const token = await compactSign("hello", undefined, "none");
// token has the form "<header>.<payload>." — empty signature segment.
await compactVerify(token, undefined, {
algorithms: ["none"],
acceptNone: true,
});
```
## Usage: Advanced Example
```ts
import { compactVerify, JwsError } from "@suaveplan/jws";
const noneToken = "eyJhbGciOiJub25lIn0.eyJzdWIiOjF9.";
// Default: rejected.
try {
await compactVerify(noneToken, undefined, { algorithms: ["HS256"] });
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.ALG_NONE_DISABLED") {
// CVE-class defence in depth.
}
}
// Allowlisted but no acceptNone — also rejected.
try {
await compactVerify(noneToken, undefined, { algorithms: ["none"] });
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.ALG_NONE_DISABLED") {
// Two flags required.
}
}
```
## API Reference
- `none: AlgorithmImpl<undefined>` — verify `(input, sig)` returns `Promise<boolean>` (`true` iff `sig.length === 0`); sign `(input, _)` returns `Promise<Uint8Array>` of length 0.
## Implementation Notes
The two-flag opt-in design is a deliberate defence against the historic JWT-CVE class where a caller passes `algorithms: ["HS256"]` AND a public key, then an attacker mints a token with `alg: "none"` and the library accepts it because the allowlist is parsed permissively. Here, even a deliberate `algorithms: ["none"]` requires the second flag `acceptNone: true` — the second flag is intentionally separate so a single typo or merged configuration cannot accidentally accept unsigned tokens. The implementation also requires the signature segment to be empty, not "not present" — a token like `"<hdr>.<payload>.deadbeef"` with `alg: "none"` is rejected because the signature bytes are non-zero.

View File

@@ -0,0 +1,59 @@
# `AlgorithmRegistry`
## Purpose
In-package mapping from `alg` identifier to the concrete `AlgorithmImpl` that knows how to verify and (optionally) sign. The registry is seeded at module load with the 13 built-in algorithms required by RFC 7518 §3 and RFC 8037 (`HS256/384/512`, `RS256/384/512`, `PS256/384/512`, `ES256/384/512`, `EdDSA`, `none`). Downstream packages register experimental algorithms (e.g. ML-DSA, secp256k1) via `AlgorithmRegistry.register` without forking this package.
## Features
- `defaultRegistry` — the package-wide seeded registry consumed by the verify/sign pipelines.
- `register(alg, impl)` — add an algorithm; duplicate registration throws `JWS.ALG_UNSUPPORTED`.
- `lookup(alg)` — returns the impl or `undefined`.
- `lookupOrThrow(alg)` — returns the impl or throws `JWS.ALG_UNSUPPORTED`.
- `has(alg)` / `algorithms()` — introspection helpers.
## Usage: Basic Example
```ts
import { defaultRegistry } from "@suaveplan/jws";
defaultRegistry.has("RS256"); // true
defaultRegistry.has("ML-DSA-44"); // false
```
## Usage: Advanced Example
```ts
import {
AlgorithmRegistry,
type AlgorithmImpl,
defaultRegistry,
} from "@suaveplan/jws";
const mlDsa44: AlgorithmImpl<Uint8Array> = {
alg: "ML-DSA-44",
verify: async (signingInput, sig, pub) => myMlDsaImpl.verify(pub, sig, signingInput),
sign: async (signingInput, priv) => myMlDsaImpl.sign(priv, signingInput),
};
defaultRegistry.register("ML-DSA-44", mlDsa44);
// Or build an isolated registry for an integration test:
const isolated = new AlgorithmRegistry();
isolated.register("HS256", myHsImpl);
```
## API Reference
- `class AlgorithmRegistry`
- `register<K>(alg: string, impl: AlgorithmImpl<K>): void`
- `lookup(alg: string): AlgorithmImpl<unknown> | undefined`
- `lookupOrThrow(alg: string): AlgorithmImpl<unknown>`
- `has(alg: string): boolean`
- `algorithms(): readonly string[]`
- `const defaultRegistry: AlgorithmRegistry`
- `interface AlgorithmImpl<K = unknown> { alg: string; verify(signingInput, signature, key: K): Promise<boolean>; sign?(signingInput, key: K): Promise<Uint8Array> }`
- `seedBuiltinAlgorithms(): void` — idempotent seeding (already invoked at module load).
## Implementation Notes
Built-ins are seeded by the side-effect import `import "./builtin.js"` at the root of `algorithms/index.ts` AND `src/index.ts`. Idempotent via a module-scoped boolean so the registry survives test reload cycles. Registering an algorithm twice throws rather than silently overwriting, because silent overwrite would be an algorithm-confusion vector — a malicious downstream package could redefine `RS256` to delegate to an attacker-controlled impl. The registry is mutable but only via `register`; there is no `unregister` for the same reason.

View File

@@ -0,0 +1,57 @@
# RSASSA-PSS algorithms (`PS256` / `PS384` / `PS512`)
## Purpose
RFC 7518 §3.5 RSASSA-PSS signing and verification. The signing input is hashed, EMSA-PSS-encoded with a random salt, and signed via `@suaveplan/crypto-rsa.rsaSignPss`. The salt length is fixed at the hash output length per RFC 7518 §3.5 ("Salt Length shall be equal to the size of the hash output") — externally-produced signatures with non-matching salt lengths simply fail verify rather than being silently accepted, which catches producers that defaulted to a different PSS profile.
## Features
- `PS256` → RSASSA-PSS + SHA-256 + 32-byte salt.
- `PS384` → RSASSA-PSS + SHA-384 + 48-byte salt.
- `PS512` → RSASSA-PSS + SHA-512 + 64-byte salt.
- MGF1 hash matches the message hash (RFC 7518 §3.5 default).
- Delegates to `@suaveplan/crypto-rsa.rsaSignPss` / `rsaVerifyPss`.
## Usage: Basic Example
```ts
import { generateRsaKeyPair } from "@suaveplan/crypto-rsa";
import { compactSign, compactVerify } from "@suaveplan/jws";
const { publicKey, privateKey } = await generateRsaKeyPair({ modulusLength: 2048 });
const token = await compactSign("hello", privateKey, "PS256");
await compactVerify(token, publicKey, { algorithms: ["PS256"] });
```
## Usage: Advanced Example
```ts
import { ps256 } from "@suaveplan/jws";
// Signature produced with a 16-byte salt instead of 32:
const externalSig = produceFromBadProducer(message, priv, /*saltLen*/ 16);
const ok = await ps256.verify(new TextEncoder().encode("hdr.payload"), externalSig, pub);
// ok === false — salt length mismatch caught by EMSA-PSS-Verify.
// Algorithm-confusion defence: passing an HMAC byte-key throws.
import { JwsError } from "@suaveplan/jws";
try {
await ps256.verify(input, sig, new Uint8Array(32));
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.KEY_ALG_MISMATCH") {
// PSS verify expects an RsaPublicKey, not a raw byte buffer.
}
}
```
## API Reference
- `ps256: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
- `ps384: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
- `ps512: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
`verify` returns `Promise<boolean>`; `sign` returns `Promise<Uint8Array>` whose length matches the modulus (256 bytes for 2048-bit keys).
## Implementation Notes
PSS salt is drawn by `@suaveplan/crypto-rsa.rsaSignPss` via either the caller's optional `Rng` injection or the default RNG (`crypto.getRandomValues`). This means PSS signatures are probabilistic — the same payload signed twice produces two different tokens; tests that need determinism inject a deterministic `Rng`. Verifying with a fixed salt length and rejecting mismatched-salt signatures is a deliberate departure from libraries that auto-detect salt length from the signature length: that auto-detection is the source of many JWT-PSS interop bugs because the auto-detect heuristic disagrees on edge-case modulus sizes.

View File

@@ -0,0 +1,53 @@
# RSASSA-PKCS1-v1_5 algorithms (`RS256` / `RS384` / `RS512`)
## Purpose
RFC 7518 §3.3 RSASSA-PKCS1-v1_5 signing and verification. The signing input is hashed with SHA-256/SHA-384/SHA-512 respectively, EMSA-PKCS1-v1_5-encoded to the modulus length, and signed via the modular-exponentiation primitive from `@suaveplan/crypto-rsa`. This is the dominant JWS algorithm in production (OAuth/OIDC JWT signing, SafetyNet, Apple App Attest, GitHub workflow tokens) and the algorithm `webauthn` is unblocked on.
## Features
- `RS256` → RSASSA-PKCS1-v1_5 + SHA-256.
- `RS384` → RSASSA-PKCS1-v1_5 + SHA-384.
- `RS512` → RSASSA-PKCS1-v1_5 + SHA-512.
- Delegates to `@suaveplan/crypto-rsa`'s `rsaVerifyPkcs1v15` / `rsaSignPkcs1v15`.
- Key shape: `RsaPublicKey` (verify) / `RsaPrivateKey` (sign) — both from `@suaveplan/crypto-rsa`.
## Usage: Basic Example
```ts
import { generateRsaKeyPair, parseRsaPublicKeyJwk } from "@suaveplan/crypto-rsa";
import { compactSign, compactVerify } from "@suaveplan/jws";
const { publicKey, privateKey } = await generateRsaKeyPair({ modulusLength: 2048 });
const token = await compactSign("hello", privateKey, "RS256");
const r = await compactVerify(token, publicKey, { algorithms: ["RS256"] });
```
## Usage: Advanced Example
```ts
import { parseRsaPublicKey } from "@suaveplan/crypto-rsa";
import { compactVerify, rs256 } from "@suaveplan/jws";
// Verify a JWS whose key arrived as a DER-encoded SPKI from a registry:
const spkiDer = await fetch("/.well-known/jwks-spki.der").then((r) => r.bytes());
const pub = parseRsaPublicKey(spkiDer, { format: "spki" });
const result = await compactVerify(token, pub, {
algorithms: ["RS256", "RS384"],
});
// Direct-impl access (e.g. signing custom binary payloads):
const sig = await rs256.sign!(signingInput, priv);
```
## API Reference
- `rs256: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
- `rs384: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
- `rs512: AlgorithmImpl<RsaPublicKey | RsaPrivateKey>`
Each `verify` returns `Promise<boolean>`; each `sign` returns `Promise<Uint8Array>` of length `Math.ceil(bitLength/8)`. Non-RSA-shaped keys throw `JwsError(JWS.KEY_ALG_MISMATCH)`.
## Implementation Notes
This module is a thin façade over `@suaveplan/crypto-rsa` — it never reimplements PKCS#1 v2.2 or modular arithmetic. Verification swallows any low-level `CryptoRsaError` and returns `false` so structural malformation surfaces as `JWS.SIG_INVALID` at the pipeline level rather than leaking RSA-package-internal codes; signing propagates because a sign-side error means the caller's key is structurally wrong (modulus too short, missing CRT parameters, etc.) and they need the precise diagnostic. RSA private operations in `@suaveplan/crypto-rsa` run through CRT and apply RSA blinding (Kocher 1996 timing-attack defence) by default.

View File

@@ -0,0 +1,59 @@
# `base64url`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
RFC 7515 Appendix C base64url codec. Every JWS segment (protected header, payload, signature) is base64url-encoded with no padding and the URL-safe alphabet (`-`/`_` in place of `+`/`/`); this module owns the byte-level encode/decode contract that every other module in the package builds on. The codec is strict — padding (`=`), standard-alphabet bytes, whitespace, and any byte outside the URL alphabet all surface as `JwsError(JWS.BASE64URL_INVALID)` rather than degrading into a silent shift in the decoded output. Strictness is load-bearing because lenient codecs in popular JOSE libraries have historically masked malformed-token vulnerabilities by quietly accepting tokens that violate the framing rules.
## Features
- `base64urlEncode(bytes)` — encode `Uint8Array` to URL-safe base64.
- `base64urlDecode(input)` — strict decode that rejects padding, std-alphabet, whitespace, and partial-byte length residues.
- `base64urlEncodeString(value)` — encode a UTF-8 string in one call.
- `base64urlDecodeJson(input)` — decode + `JSON.parse` + object-shape check for header parsing.
- Tail-bit validation: trailing bits in the partial last group must be zero.
## Usage: Basic Example
```ts
import { base64urlDecode, base64urlEncode } from "@suaveplan/jws";
const enc = base64urlEncode(new Uint8Array([0xde, 0xad, 0xbe, 0xef]));
// "3q2-7w"
const dec = base64urlDecode("3q2-7w");
// Uint8Array [0xde, 0xad, 0xbe, 0xef]
```
## Usage: Advanced Example
```ts
import { base64urlDecodeJson, JwsError } from "@suaveplan/jws";
try {
const header = base64urlDecodeJson("eyJhbGciOiJIUzI1NiJ9");
// { alg: "HS256" }
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.BASE64URL_INVALID") {
// Decoder rejected padding / std-alphabet / non-URL-alphabet character.
}
if (err instanceof JwsError && err.code === "JWS.HEADER_MALFORMED") {
// Decoded bytes were not a JSON object.
}
}
```
## API Reference
- `base64urlEncode(bytes: Uint8Array): string`
- `base64urlDecode(input: string): Uint8Array` — throws `JwsError(JWS.BASE64URL_INVALID)`.
- `base64urlEncodeString(value: string): string`
- `base64urlDecodeJson(input: string): Record<string, unknown>` — throws `JwsError(JWS.HEADER_MALFORMED)` when bytes are not a JSON object.
- `BASE64URL_ALPHABET: string`
- `BASE64_STD_ALPHABET: string`
## Implementation Notes
The decoder builds an `Int8Array` lookup table at module init so per-byte dispatch is a single array index. The first pass over the input rejects every invalid byte before allocating the output buffer; this means malformed input fails fast with bounded work rather than producing garbage bytes that the caller has to validate downstream. Tail-bit checks (`(c1 & 0x0f) !== 0` for 2-byte groups, `(c2 & 0x03) !== 0` for 3-byte groups) catch base64 encoders that emit non-canonical encodings — these are equivalent to padding errors and would break byte-identical round-trips against the RFC 7515 Appendix A vectors if accepted.

View File

@@ -0,0 +1,65 @@
# Detached payload (RFC 7515 §A.5)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
RFC 7515 §A.5 / Appendix F detached form. The payload travels separately from the JWS — the compact-form token has an empty middle segment (`<header>..<sig>`) and the verifier supplies the payload bytes out-of-band via `opts.detachedPayload`. This is the canonical pattern when the payload is large (avoid double-encoding) or already lives somewhere addressable (file body, HTTP body, blob storage).
## Features
- `detachedSign(payload, key, alg, opts?)` — emits a compact token with empty middle segment.
- `detachedVerify(token, key, opts)` — sugar over `compactVerify` with `opts.detachedPayload`.
- Missing `detachedPayload` at verify time → `JWS.INVALID_OPTIONS`.
- Compatible with `b64: false` — raw-byte payloads also work in detached form.
## Usage: Basic Example
```ts
import { detachedSign, detachedVerify } from "@suaveplan/jws";
const token = await detachedSign("hello", secret, "HS256");
// token === "<header>..<sig>"
const result = await detachedVerify(token, secret, {
algorithms: ["HS256"],
detachedPayload: "hello",
});
```
## Usage: Advanced Example
```ts
import { compactVerify, detachedSign, JwsError } from "@suaveplan/jws";
// Sign a large blob without embedding it in the token:
const blob = await fetch("/big.bin").then((r) => r.bytes());
const token = await detachedSign(blob, key, "ES256");
// Later, given the token + blob (e.g. as separate Content-* headers):
const verified = await compactVerify(token, pub, {
algorithms: ["ES256"],
detachedPayload: blob, // Uint8Array also accepted
});
// Forgot to pass detachedPayload?
try {
await compactVerify(token, pub, { algorithms: ["ES256"] });
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.INVALID_OPTIONS") {
// Empty middle segment without out-of-band payload supplied.
}
}
```
## API Reference
- `detachedSign(payload, key, alg, opts?: Omit<SignOptions, "detached">): Promise<string>`
- `detachedVerify(token, key, opts: VerifyOptions): Promise<VerifyResult>`
`detachedSign` is sugar for `compactSign(..., { ...opts, detached: true })`. `detachedVerify` is identity-forward to `compactVerify` — the actual logic lives in the verify pipeline.
## Implementation Notes
The verify pipeline detects detached tokens by an empty middle segment + `opts.detachedPayload !== undefined`. When detected, the payload is re-base64url-encoded (or stringified for `b64: false`) before computing the signing input, so the on-wire signing input bytes match what the signer would have produced. This means the same `compactVerify` entry point handles attached AND detached tokens — `detachedVerify` is purely an ergonomic alias.

View File

@@ -0,0 +1,71 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Frozen registry of the 18 typed error codes raised by `@suaveplan/jws`. Each code maps to a distinct RFC 7515 §4.1 / §5.2 / §7 failure class (or to one of the boundary refusals layered on top by the suaveplan conventions: Zod option validation, AbortSignal honouring, alg-confusion defence). The registry is the single source of truth — `JwsErrorCode` is pinned to the registry literal via `expectTypeOf`, and every code is thrown by at least one production code path so callers can branch on `error.code` without worrying about dead codes leaking into the public type.
## Features
- `JWS_CODES` — the 18-entry frozen registry from `@suaveplan/error.defineCodes`.
- `JwsErrorCode` — literal-union derived from `JWS_CODES`.
- `JwsError` — concrete `SuaveplanError<JwsErrorCode>` subclass; every public-API error thrown by this package is a `JwsError`.
## Usage: Basic Example
```ts
import { compactVerify, JwsError } from "@suaveplan/jws";
try {
await compactVerify(token, key, { algorithms: ["RS256"] });
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.ALG_NOT_ALLOWED") {
// Token presented an alg outside the caller's allowlist.
}
}
```
## Usage: Advanced Example
```ts
import { JWS_CODES, JwsError, type JwsErrorCode } from "@suaveplan/jws";
function classify(err: unknown): "client" | "server" | "neutral" {
if (!(err instanceof JwsError)) return "neutral";
const code: JwsErrorCode = err.code;
switch (code) {
case JWS_CODES.MALFORMED:
case JWS_CODES.HEADER_MALFORMED:
case JWS_CODES.BASE64URL_INVALID:
case JWS_CODES.SIG_INVALID:
case JWS_CODES.SIG_FORMAT_INVALID:
case JWS_CODES.JWK_INVALID:
case JWS_CODES.X5C_CHAIN_INVALID:
case JWS_CODES.CRIT_UNRECOGNIZED:
case JWS_CODES.CRIT_MALFORMED:
case JWS_CODES.B64_INVALID:
case JWS_CODES.MULTI_SIG_FAILED:
return "client";
case JWS_CODES.ALG_UNSUPPORTED:
case JWS_CODES.ALG_NOT_ALLOWED:
case JWS_CODES.ALG_NONE_DISABLED:
case JWS_CODES.KEY_NOT_FOUND:
case JWS_CODES.KEY_ALG_MISMATCH:
case JWS_CODES.INVALID_OPTIONS:
case JWS_CODES.ABORTED:
return "server";
}
}
```
## API Reference
- `JWS_CODES: { readonly [Key in NamedKey]:`JWS.${Key}`}` — the registry.
- `JwsErrorCode = (typeof JWS_CODES)[keyof typeof JWS_CODES]` — `"JWS.MALFORMED" | "JWS.HEADER_MALFORMED" | ...`.
- `class JwsError extends SuaveplanError<JwsErrorCode>` — `new JwsError(code, messageKey, options?)`.
## Implementation Notes
The registry uses `defineCodes("JWS", [...] as const)` from `@suaveplan/error`, which prefixes each entry with `JWS.` so codes never collide with sibling packages (`X509.*`, `CRYPTO_RSA.*`, etc.). The `as const` is load-bearing: it pins the literal-string type so `JwsErrorCode` is a precise union and not a bare `string`. Adding a new code requires updating the registry, the `expectTypeOf` assertion in the test suite, AND a production code path that throws it — the conformance gate fails otherwise, preventing "dead" codes from accreting in the registry.

View File

@@ -0,0 +1,59 @@
# `b64` (RFC 7797) header
## Purpose
RFC 7797 §3 implements the `b64` header parameter, allowing JWS producers to embed unencoded payloads directly into the signing input. When `b64: false`, the second compact-form segment is the raw payload bytes themselves (not base64url-encoded) and the signing input becomes `BASE64URL(protected) || "." || rawPayloadBytes`. The RFC's MUST clauses require that any `b64: false` header also list `"b64"` in `crit` so verifiers ignorant of RFC 7797 refuse the token rather than misinterpret it.
## Features
- `resolveB64(header)` — returns the effective boolean (`true` when absent).
- `b64: false` without `"b64"` in `crit` → `JWS.B64_INVALID`.
- Non-boolean `b64` value → `JWS.B64_INVALID`.
- Absent `b64` defaults to base64url encoding (RFC 7515 §3.1 default).
## Usage: Basic Example
```ts
import { resolveB64 } from "@suaveplan/jws";
resolveB64({ alg: "HS256" }); // true (default)
resolveB64({ alg: "HS256", b64: true }); // true
```
## Usage: Advanced Example
```ts
import { compactSign, compactVerify, resolveB64, JwsError } from "@suaveplan/jws";
// Produce a b64: false token (raw-payload signing input).
const token = await compactSign("hello", key, "HS256", {
protectedHeader: { b64: false, crit: ["b64"] },
b64: false,
});
const result = await compactVerify(token, key, {
algorithms: ["HS256"],
acceptableCriticalHeaders: ["b64"],
});
// result.payloadString === "hello" — raw, not base64url-decoded
// Missing crit listing — verifier rejects.
try {
resolveB64({ alg: "HS256", b64: false });
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.B64_INVALID") {
// RFC 7797 §3 MUST violation.
}
}
```
## API Reference
- `resolveB64(header: JoseHeader): boolean`
- Returns `true` when `b64` is absent or `b64: true`.
- Returns `false` only when `b64: false` AND `crit` includes `"b64"`.
- Throws `JwsError(JWS.B64_INVALID)` for non-boolean values or `false` without `crit`.
## Implementation Notes
The RFC 7797 contract is asymmetric: producers may emit `b64: true` without `crit` (default behaviour), but `b64: false` MUST be marked critical. This module enforces only the verifier side; the sign pipeline emits the `crit: ["b64"]` companion automatically when callers pass `b64: false` so producers do not have to remember the dual-write. Raw-payload signing input requires special handling — the payload bytes cannot pass through `TextEncoder` when the producer's payload contains the `.` byte (0x2e), since that's also the segment separator; the compact-form parser handles this by treating the segment as bytes once `b64: false` is resolved, not as a UTF-8 string.

View File

@@ -0,0 +1,57 @@
# `crit` enforcement
## Purpose
RFC 7515 §4.1.11 enforcement: every entry in `header.crit` MUST be in the caller's `acceptableCriticalHeaders` allowlist. The RFC's intent is that callers refuse to verify any token whose protected header carries critical parameters they do not understand — otherwise the producer could downgrade security guarantees (e.g. an `exp` claim becoming critical against a verifier that ignores it). This module owns that gate.
## Features
- Empty `crit` array rejected.
- Non-array `crit` rejected.
- Non-string entry rejected.
- `crit` referencing itself rejected (RFC 7515 §4.1.11 MUST).
- Entry not in caller's allowlist → `JWS.CRIT_UNRECOGNIZED`.
- Entry in allowlist accepted.
## Usage: Basic Example
```ts
import { enforceCrit, parseHeader } from "@suaveplan/jws";
const header = parseHeader("eyJhbGciOiJIUzI1NiIsImNyaXQiOlsiZXhwIl0sImV4cCI6MTYwfQ");
enforceCrit(header, ["exp"]); // does not throw
```
## Usage: Advanced Example
```ts
import { enforceCrit, JwsError } from "@suaveplan/jws";
try {
enforceCrit({ alg: "HS256", crit: ["exp", "tenant"] }, ["exp"]);
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.CRIT_UNRECOGNIZED") {
// Reject the token: a critical parameter is outside the allowlist.
}
}
// Self-reference rejected:
try {
enforceCrit({ alg: "HS256", crit: ["crit"] }, ["crit"]);
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.CRIT_MALFORMED") {
// RFC 7515 §4.1.11 MUST.
}
}
```
## API Reference
- `enforceCrit(header: JoseHeader, acceptableCriticalHeaders: readonly string[] | undefined): void`
- No-op when `header.crit` is absent.
- Throws `JwsError(JWS.CRIT_MALFORMED)` for shape failures (non-array, empty, non-string entry, self-reference).
- Throws `JwsError(JWS.CRIT_UNRECOGNIZED)` for entries not on the allowlist.
## Implementation Notes
The allowlist is supplied at every verify call rather than configured globally; this is deliberate because the allowlist is fundamentally a property of the consumer's threat model, not the JWS library. Callers integrating an RFC 7797 `b64: false` token MUST add `"b64"` to their `acceptableCriticalHeaders` even though this package implements the RFC 7797 spec internally — the gate is "do you know what to do with this parameter," not "does the library implement it." This makes it impossible to accidentally accept a `b64: false` token through a verifier that wasn't expecting one.

View File

@@ -0,0 +1,57 @@
# `header`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Parse and serialise RFC 7515 §4.1 JOSE protected headers. Every registered parameter (`alg`, `jku`, `jwk`, `kid`, `x5u`, `x5c`, `x5t`, `x5t#S256`, `typ`, `cty`, `crit`, plus RFC 7797 `b64`) is shape-validated through the Zod schema; non-registered parameters survive round-trip via the schema's catch-all. The `alg` field is the only mandatory parameter; everything else is optional and the decoder preserves bytes verbatim so callers can re-emit the header without canonicalisation surprises.
## Features
- `parseHeader(base64urlString | object)` — base64url-decode + JSON-parse + Zod-shape-check; returns a typed `JoseHeader`.
- `serializeHeader(header)` — `JSON.stringify` + base64url-encode in one call.
- `JoseHeader` — strongly-typed shape (every registered parameter).
- Unknown parameters survive round-trip.
## Usage: Basic Example
```ts
import { parseHeader, serializeHeader } from "@suaveplan/jws";
const decoded = parseHeader("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9");
// { alg: "HS256", typ: "JWT" }
const reencoded = serializeHeader(decoded);
```
## Usage: Advanced Example
```ts
import { JwsError, parseHeader } from "@suaveplan/jws";
try {
parseHeader("eyJhbGciOjQyfQ"); // alg as number
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.HEADER_MALFORMED") {
// The Zod boundary refused a non-string alg.
}
}
// Custom parameter survives round-trip:
const h = parseHeader({ alg: "ES256", custom: { tenant: "acme" } });
console.log(h["custom"]); // { tenant: "acme" }
```
## API Reference
- `parseHeader(input: string | Record<string, unknown>): JoseHeader`
- Decodes a base64url-encoded JSON object header, OR validates an already-parsed object.
- Throws `JwsError(JWS.HEADER_MALFORMED)` on shape failure.
- Throws `JwsError(JWS.BASE64URL_INVALID)` on base64url failure.
- `serializeHeader(header: JoseHeader | Record<string, unknown>): string`
- `interface JoseHeader` — every RFC 7515 §4.1 registered parameter, all readonly.
## Implementation Notes
The schema is `.passthrough()` so consumers retain non-registered parameters — required for downstream RFCs (RFC 7519 JWT, RFC 9202 EAT-JWT, custom tenant claims) that layer additional parameters into the same header structure. Encoding always uses `JSON.stringify` with no replacer, which means insertion order is preserved; callers needing canonical-order headers (e.g. for byte-identical reproduction of an upstream signer's output) should sort their object keys before passing in. The decoder uses a strict UTF-8 `TextDecoder` so malformed UTF-8 sequences fail rather than silently inserting replacement characters into the parsed JSON — a deliberate strictness that catches producers that emit windows-1252 or latin-1 bytes through a misconfigured pipeline.

View File

@@ -0,0 +1,66 @@
# `jwkToCryptoKey`
## Purpose
Parse an RFC 7517 §4 / RFC 7518 §6 JSON Web Key into the typed key shape expected by the JWS algorithm impl for the declared `alg`. `kty` / `crv` / coordinate lengths are validated against the declared algorithm; mismatch throws `JWS.JWK_INVALID` for shape issues or `JWS.KEY_ALG_MISMATCH` for valid-shape-but-wrong-algorithm cases.
## Features
- RSA — `kty: "RSA"` → `RsaPublicKey` via `@suaveplan/crypto-rsa.parseRsaPublicKeyJwk`.
- EC — `kty: "EC"` with `crv: "P-256" | "P-384" | "P-521"` → uncompressed SEC1 bytes for the JWS ECDSA impl.
- OKP — `kty: "OKP"` with `crv: "Ed25519"` → 32-byte raw key for `EdDSA`.
- Symmetric — `kty: "oct"` with `k` → `Uint8Array` for `HS256/384/512`.
- Coordinate length validation per curve.
- Ed448 explicitly rejected with `KEY_ALG_MISMATCH`.
## Usage: Basic Example
```ts
import { jwkToCryptoKey } from "@suaveplan/jws";
const parsed = jwkToCryptoKey(
{
kty: "EC",
crv: "P-256",
x: "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
y: "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
},
"ES256"
);
// parsed.kind === "ec"; parsed.key is uncompressed SEC1.
```
## Usage: Advanced Example
```ts
import { JwsError, jwkToCryptoKey } from "@suaveplan/jws";
// Algorithm-confusion defence: RSA JWK declared for an ES256 token rejected.
try {
jwkToCryptoKey({ kty: "RSA", n: "...", e: "AQAB" }, "ES256");
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.JWK_INVALID") {
// kty: RSA vs alg: ES256 mismatch.
}
}
// Ed448 rejected:
try {
jwkToCryptoKey({ kty: "OKP", crv: "Ed448", x: "..." }, "EdDSA");
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.KEY_ALG_MISMATCH") {
// Only Ed25519 supported today.
}
}
```
## API Reference
- `jwkToCryptoKey(jwk: Record<string, unknown>, alg: string): ParsedKey`
- `ParsedKey = { kind: "rsa", key: RsaPublicKey } | { kind: "ec", key: Uint8Array } | { kind: "okp", key: Uint8Array }`
- Throws `JwsError(JWS.JWK_INVALID)` for shape issues.
- Throws `JwsError(JWS.KEY_ALG_MISMATCH)` for valid-shape-but-wrong-curve cases.
## Implementation Notes
Coordinate lengths (32/48/66 bytes for P-256/P-384/P-521) are strictly enforced. The P-521 curve uses 66-byte coordinates because the curve order is 521 bits (66 bytes), not the often-assumed 64; producers that left-pad to 64 fail validation here, which is correct — those tokens are malformed per RFC 7518 §6.2.1.2 even though many lenient libraries accept them. RSA keys route through `@suaveplan/crypto-rsa.parseRsaPublicKeyJwk` so the same key-policy checks (min/max modulus length, validated public exponent, malformed-base64url rejection) apply uniformly across the project.

View File

@@ -0,0 +1,59 @@
# `KeyResolver`
## Purpose
Caller-supplied key-resolution function consulted when `header.kid` is present and no key has been supplied via `opts.key`, `header.jwk`, or `header.x5c`. The resolver receives the parsed header so dispatch can branch on additional parameters (`iss`, custom tenant headers, environment-specific kid namespaces). Returning `undefined` surfaces as `JwsError(JWS.KEY_NOT_FOUND)`; throwing propagates verbatim so callers can map their own error taxonomy onto JWS verification.
## Features
- Async or sync — both signatures are accepted.
- Receives `(kid, header)`; the header is the parsed `JoseHeader`, including unknown parameters.
- `undefined` / `null` return → typed `JWS.KEY_NOT_FOUND` thrown by the pipeline.
## Usage: Basic Example
```ts
import { compactVerify, type KeyResolver } from "@suaveplan/jws";
const keyResolver: KeyResolver = async (kid) => keyStore.get(kid);
await compactVerify(token, undefined, {
algorithms: ["RS256"],
keyResolver,
});
```
## Usage: Advanced Example
```ts
import { compactVerify, JwsError, type KeyResolver } from "@suaveplan/jws";
// Dispatch on a custom tenant header:
const keyResolver: KeyResolver = async (kid, header) => {
const tenant = header["tenant"] as string | undefined;
if (tenant === "acme") return acmeKeyStore.get(kid);
if (tenant === "globex") return globexKeyStore.get(kid);
return undefined; // → KEY_NOT_FOUND
};
try {
await compactVerify(token, undefined, {
algorithms: ["RS256"],
keyResolver,
});
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.KEY_NOT_FOUND") {
// Inspect err.context.kid for the unresolved id.
}
}
```
## API Reference
- `type KeyResolver = (kid: string, header: JoseHeader) => Promise<unknown> | unknown`
Returning anything other than `undefined` / `null` is treated as the verification key and passed to the algorithm impl unchanged. The pipeline performs no type-check on the returned value — it relies on the algorithm impl to throw `JWS.KEY_ALG_MISMATCH` if the resolved key doesn't match the declared `alg`.
## Implementation Notes
Resolution priority in the verify pipeline is `opts.key → header.jwk → header.x5c → opts.keyResolver(header.kid)`. The resolver only runs when none of the earlier sources resolved a key; this means a token carrying both `kid` AND `jwk` will route through `jwk` first (assuming `allowEmbeddedJwk: true`). That ordering is deliberate: when a producer self-describes a key in the token, the consumer's stored key for the same `kid` should be re-verified against the embedded one — but doing that match is the consumer's responsibility, not this library's. The pipeline simply uses whichever source the caller has opted in to first.

View File

@@ -0,0 +1,61 @@
# `extractX5cLeafKey`
## Purpose
Decode an RFC 7515 §4.1.6 `x5c` certificate chain, delegate trust-path validation to `@suaveplan/x509.validateChain`, and extract the leaf `SubjectPublicKeyInfo` shaped to match the declared JOSE `alg`. This is the entry point that `@suaveplan/webauthn` will call for SafetyNet attestation verification: SafetyNet tokens carry their attestation cert chain in `x5c`, and the verifier needs to anchor it against Google's pinned attestation roots before trusting the embedded RSA public key.
## Features
- Accepts either standard-base64 (RFC's preference) or base64url-encoded chain entries.
- Delegates chain validation entirely to `@suaveplan/x509.validateChain`.
- Leaf-public-key algorithm matched against declared JOSE `alg`.
- Chain failure → `JWS.X5C_CHAIN_INVALID` with the underlying x509 error chained as `cause`.
- Leaf-alg mismatch → `JWS.KEY_ALG_MISMATCH`.
## Usage: Basic Example
```ts
import { extractX5cLeafKey } from "@suaveplan/jws";
import { parseCertificate } from "@suaveplan/x509";
const trustAnchors = [parseCertificate(googleAttestationRootDer)];
const leaf = await extractX5cLeafKey(token.protected.x5c, "RS256", {
trustAnchors,
});
// leaf.kind === "rsa"; leaf.key is the SafetyNet leaf cert's RSA public key.
```
## Usage: Advanced Example
```ts
import { compactVerify, extractX5cLeafKey, JwsError } from "@suaveplan/jws";
// Use through compactVerify's automatic x5c resolution:
const result = await compactVerify(safetynetToken, undefined, {
algorithms: ["RS256"],
allowX5c: true,
x5cTrustAnchors: trustAnchors,
});
// Direct call with a deadline:
try {
const ac = new AbortController();
setTimeout(() => ac.abort(), 1_000);
await extractX5cLeafKey(chain, "RS256", { trustAnchors, signal: ac.signal });
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.X5C_CHAIN_INVALID") {
// err.cause is the underlying X509Error — inspect cause.code for
// CERT_EXPIRED / NAME_CHAIN_BROKEN / etc.
}
}
```
## API Reference
- `extractX5cLeafKey(x5c: readonly string[], alg: string, options: ExtractX5cOptions): Promise<ParsedKey>`
- `interface ExtractX5cOptions { trustAnchors: readonly X509Certificate[]; clock?: Clock; signal?: AbortSignal }`
- Returns the same `ParsedKey` union as `jwkToCryptoKey`.
## Implementation Notes
`x5c` array entries are base64-encoded DER certificates per RFC 7515 §4.1.6. The decoder accepts both alphabets because real-world producers vary; the strict choice is base64 standard alphabet with optional padding, but base64url is also surfaced from some Google attestation chains. The leaf-alg check is intentionally separate from chain validation: a malicious producer could submit a valid Google-rooted chain whose leaf is an ECDSA cert and claim `alg: "RS256"` — chain validation would pass (Google did sign that cert), but the JOSE-level alg doesn't match the leaf SPKI, and the verifier MUST reject. `validateChain` runs the full RFC 5280 §6.1 algorithm (expiry, name chaining, key usage, EKU, name constraints, optional OCSP), so this module owns only the JOSE-layer "does the leaf match `alg`" check.

View File

@@ -0,0 +1,57 @@
# `metrics`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry instrumentation surface for the JWS verify and sign hot paths. Every production code path records a histogram for wall-time latency and increments outcome-tagged counters; the error registry's `recordError` also fires once per typed throw so dashboards can pivot on `code` cardinality. The `outcome` attribute is bounded to a 4-value enum (`success | sig_invalid | alg_not_allowed | header_invalid`) so production observability backends do not blow up under attacker-controlled token streams.
## Features
- `recordVerify(alg, outcome, durationMs)` — histogram + outcome counter.
- `recordSign(alg, "success" | "error", durationMs)` — sign-side mirror.
- `recordError(code)` — typed-error counter pivoted by code.
- `resetMetricsForTesting()` — rebinds the cached instrument bag after a test installs a fresh `MeterProvider`.
- `getInstrumentsForTesting()` — exposes the bag for assertion-time introspection.
## Usage: Basic Example
```ts
import { metrics } from "@opentelemetry/api";
import { resetMetricsForTesting, compactVerify } from "@suaveplan/jws";
metrics.setGlobalMeterProvider(myInMemoryProvider);
resetMetricsForTesting();
await compactVerify(token, key, { algorithms: ["HS256"] });
// `jws.verify_duration_ms{alg=HS256, outcome=success}` has 1 sample.
```
## Usage: Advanced Example
```ts
import { getInstrumentsForTesting, recordVerify, recordError, JWS_CODES } from "@suaveplan/jws";
const bag = getInstrumentsForTesting();
// bag.verifyDurationMs / bag.signDurationMs (Histogram)
// bag.verifyTotal / bag.signTotal (Counter)
// bag.errorTotal (Counter)
// Bypass the pipeline for an ad-hoc observation:
recordVerify("RS256", "sig_invalid", 4.2);
recordError(JWS_CODES.SIG_INVALID);
```
## API Reference
- `recordVerify(alg: string, outcome: JwsVerifyOutcome, durationMs: number): void`
- `recordSign(alg: string, outcome: "success" | "error", durationMs: number): void`
- `recordError(code: string): void`
- `nowMs(): number`
- `resetMetricsForTesting(): void`
- `getInstrumentsForTesting(): InstrumentBag`
- `type JwsVerifyOutcome = "success" | "sig_invalid" | "alg_not_allowed" | "header_invalid"`
## Implementation Notes
The instrument bag is cached in module scope so repeated invocations do not call `metrics.getMeter` on every request — that call has non-trivial overhead under the OTel SDK because it walks the provider's registry. Tests that install a fresh `MeterProvider` MUST call `resetMetricsForTesting()` in `beforeEach`; otherwise the cached bag stays bound to the original (no-op) provider and the test asserts against an empty histogram. The `outcome` enum's bounded cardinality is deliberate: an unbounded enum would let an adversarial token stream blow up the metrics backend's series count, and most OTel sinks bill by series.

View File

@@ -0,0 +1,59 @@
# Schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod boundary schemas validated at every public entry point. The `@suaveplan` convention is that public options never silently degrade into malformed tokens; every shape mismatch surfaces as `JWS.INVALID_OPTIONS` (caller misuse) or `JWS.HEADER_MALFORMED` (parsed token shape failure) before any cryptographic work runs. These schemas are exported so downstream consumers (`@suaveplan/webauthn`, future `@suaveplan/jwt`) can compose their own validators on top of the same shapes.
## Features
- `JoseHeaderSchema` — RFC 7515 §4.1 registered parameters; unknown parameters survive via `.passthrough()`.
- `JsonWebKeySchema` — RFC 7517 §4 JWK structural skeleton.
- `VerifyOptionsSchema` — every verify entry point's caller options.
- `SignOptionsSchema` — every sign entry point's caller options.
- `FlatJwsSchema` / `JwsSignatureSchema` / `GeneralJwsSchema` — wire shapes.
## Usage: Basic Example
```ts
import { VerifyOptionsSchema, JwsError } from "@suaveplan/jws";
const result = VerifyOptionsSchema.safeParse({ algorithms: ["RS256"] });
if (!result.success) {
throw new JwsError("JWS.INVALID_OPTIONS", "...", { context: { issues: result.error.issues } });
}
```
## Usage: Advanced Example
```ts
import { GeneralJwsSchema, JoseHeaderSchema } from "@suaveplan/jws";
import z from "zod";
// Compose a stricter header schema for a JWT consumer:
const StrictJwtHeader = JoseHeaderSchema.extend({
typ: z.literal("JWT"),
alg: z.union([z.literal("RS256"), z.literal("ES256")]),
});
// Validate a general JWS payload at an HTTP boundary:
const parsed = GeneralJwsSchema.parse(await request.json());
```
## API Reference
- `JoseHeaderSchema: z.ZodSchema`
- `JsonWebKeySchema: z.ZodSchema`
- `VerifyOptionsSchema: z.ZodSchema`
- `SignOptionsSchema: z.ZodSchema`
- `FlatJwsSchema: z.ZodSchema`
- `JwsSignatureSchema: z.ZodSchema`
- `GeneralJwsSchema: z.ZodSchema`
All schemas use `.passthrough()` where unknown fields are permitted (header) and `.strict()` where they aren't (wire-shape objects).
## Implementation Notes
The verify-options schema declares `algorithms` as a required array — the Zod boundary catches the most common misuse (forgetting to pass `algorithms`, or passing `{} as never`) and surfaces it as `JWS.INVALID_OPTIONS` before the verify pipeline even sees the call. The general-JWS schema enforces `signatures.length >= 1` because an empty signatures array is structurally meaningless (no one signed anything) and lenient libraries that accept it silently produce false-positive verifies under `requireAll: false`.

View File

@@ -0,0 +1,65 @@
# Compact serialization
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
RFC 7515 §3.1 + §7.1 compact-form JWS — the three-segment `<protected>.<payload>.<signature>` string format that is the default representation for JWS-as-JWT and the only form most downstream consumers will ever encounter. This module owns `compactDecode` (parse), `compactVerify` (RFC §5.2 verify), and `compactSign` (RFC §5.1 sign).
## Features
- Strict structural parser: exactly two `.` separators, URL-alphabet bytes only.
- Empty middle segment accepted (RFC 7515 §A.5 detached form).
- Mandatory `algorithms` allowlist at verify time — algorithm-confusion-CVE defence.
- `compactSign` returns the joined string; consumers can split on `.` to inspect segments.
## Usage: Basic Example
```ts
import { compactDecode, compactSign, compactVerify } from "@suaveplan/jws";
const token = await compactSign("hello", secret, "HS256");
const decoded = compactDecode(token);
// decoded.protected / decoded.payload / decoded.signature
const result = await compactVerify(token, secret, { algorithms: ["HS256"] });
// result.verified === true
```
## Usage: Advanced Example
```ts
import { compactVerify, type KeyResolver, JwsError } from "@suaveplan/jws";
const keyResolver: KeyResolver = async (kid) => keyStore.get(kid);
const verify = async (token: string) => {
try {
return await compactVerify(token, undefined, {
algorithms: ["RS256", "ES256", "EdDSA"],
allowEmbeddedJwk: false,
allowX5c: false,
keyResolver,
acceptableCriticalHeaders: ["exp", "nbf"],
});
} catch (err) {
if (err instanceof JwsError) {
if (err.code === "JWS.ALG_NOT_ALLOWED") return null;
if (err.code === "JWS.SIG_INVALID") return null;
if (err.code === "JWS.KEY_NOT_FOUND") return null;
}
throw err;
}
};
```
## API Reference
- `compactDecode(token: string): CompactJws` — throws `JWS.MALFORMED`.
- `compactVerify(token: string, key: unknown, opts: VerifyOptions): Promise<VerifyResult>`
- `compactSign(payload: string | Uint8Array, key: unknown, alg: string, opts?: SignOptions): Promise<string>`
## Implementation Notes
Verification enforces the algorithm allowlist BEFORE any cryptographic work — `opts.algorithms.includes(header.alg)` is checked immediately after parsing the header, so a token presenting `alg: "HS256"` against an RSA public key cannot even reach the HMAC impl, neutralising the entire alg-confusion class. The pipeline also enforces `crit` allowlist before alg dispatch, so a token whose `crit` parameter is unknown to the verifier never runs cryptography either; this matters because cryptographic verification side-channels can leak information about the verifier's state (cache state, branch prediction) even when the verifier rejects the token.

View File

@@ -0,0 +1,54 @@
# JSON Flattened serialization
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
RFC 7515 §3.2 + §7.2.2 JSON-flattened JWS — `{ protected?, header?, payload, signature }`. The flat form is the single-signature variant of the general form; producers use it when they need to ship an unprotected header alongside the protected one (e.g. for non-signed metadata like `kid` rotation hints) without going through the compact form's protected-header-only constraint.
## Features
- `flatDecode` — Zod-validated decode; at least one of `protected` / `header` must be present.
- `flatVerify` — verify against the recomputed `protected || "." || payload` signing input.
- `flatSign` — sign and emit the flat JSON.
- Unprotected header preserved on the verify result via `.unprotectedHeader`.
## Usage: Basic Example
```ts
import { flatSign, flatVerify } from "@suaveplan/jws";
const flat = await flatSign("hello", secret, "HS256", {
unprotectedHeader: { kid: "k1" },
});
// { protected: "...", header: { kid: "k1" }, payload: "...", signature: "..." }
const result = await flatVerify(flat, secret, { algorithms: ["HS256"] });
// result.unprotectedHeader === { kid: "k1" }
```
## Usage: Advanced Example
```ts
import { flatVerify, JwsError } from "@suaveplan/jws";
const verified = await flatVerify(flat, undefined, {
algorithms: ["RS256"],
allowEmbeddedJwk: false,
keyResolver: async (kid) => keyStore.get(kid),
acceptableCriticalHeaders: ["exp"],
});
// verified.header === protected header (signed)
// verified.unprotectedHeader === unprotected header (advisory only)
```
## API Reference
- `flatDecode(json: unknown): FlatJws`
- `flatVerify(json: unknown, key: unknown, opts: VerifyOptions): Promise<VerifyResult & { unprotectedHeader?: Record<string, unknown> }>`
- `flatSign(payload: string | Uint8Array, key: unknown, alg: string, opts?: SignOptions): Promise<FlatJws>`
## Implementation Notes
The unprotected header is informational — it is NOT covered by the signature, so consumers MUST NOT rely on it for security decisions. The library surfaces it on the verify result so callers can use it for non-security routing (rotation hints, message-id correlation) without re-decoding the wire shape. Verification enforces a header-key collision check between protected and unprotected headers per RFC 7515 §5.2 step 4: any duplicate key throws `JWS.HEADER_MALFORMED` because the RFC explicitly disallows it (an attacker could otherwise plant a different `alg` in the unprotected header and confuse a careless verifier).

View File

@@ -0,0 +1,69 @@
# JSON General serialization (multi-signature)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
RFC 7515 §3.2 + §7.2.1 JSON-general JWS — `{ payload, signatures: JwsSignature[] }`. Each entry in `signatures` carries its own `protected` / `header` / `signature` so the same payload can be co-signed by N parties. Common use cases: cross-tenant message attestation, multi-party authorisation, signed installer manifests where multiple maintainers' signatures must all attach to the same artifact.
## Features
- `generalDecode` — Zod-validated decode; `signatures` must be non-empty.
- `generalVerify` — verifies every signer via a per-signer key resolver; returns `{ verifiedIndices, failedIndices }`.
- `generalSign` — signs the same payload under N signers; rejects mixed-`b64` signers.
- `requireAll: true` causes the first failing index to throw `JWS.MULTI_SIG_FAILED` with `error.context.failedIndex`.
## Usage: Basic Example
```ts
import { generalSign, generalVerify } from "@suaveplan/jws";
const general = await generalSign("hello", [
{ key: secret, alg: "HS256" },
{ key: rsaPriv, alg: "RS256" },
]);
const result = await generalVerify(
general,
async (i) => (i === 0 ? secret : rsaPub),
{ algorithms: ["HS256", "RS256"] }
);
// result.verifiedIndices === [0, 1]
```
## Usage: Advanced Example
```ts
import { generalVerify, JwsError } from "@suaveplan/jws";
try {
await generalVerify(
general,
async (i, protectedHdr) => keyStoreByIssuer.get(protectedHdr["iss"] as string),
{
algorithms: ["HS256", "RS256", "ES256"],
requireAll: true,
}
);
} catch (err) {
if (err instanceof JwsError && err.code === "JWS.MULTI_SIG_FAILED") {
const failedIndex = (err.context as { failedIndex: number }).failedIndex;
// err.cause === underlying JwsError for that signer.
}
}
```
## API Reference
- `generalDecode(json: unknown): GeneralJws`
- `generalVerify(json: unknown, keyResolver, opts: GeneralVerifyOptions): Promise<GeneralVerifyResult>`
- `keyResolver: (signerIndex, protectedHeader, unprotectedHeader?) => Promise<unknown>`
- `GeneralVerifyOptions extends VerifyOptions { requireAll?: boolean }`
- `GeneralVerifyResult = { verifiedIndices, failedIndices, payloadString }`
- `generalSign(payload, signers: GeneralSigner[]): Promise<GeneralJws>`
- `GeneralSigner = { key, alg, protectedHeader?, unprotectedHeader? }`
## Implementation Notes
The default `requireAll` value is `false` — more flexible for use cases like "any one of N trust roots verifies." Callers needing the stricter "every signer must verify" semantics opt in via `requireAll: true`. Mixed `b64` settings across signers are NOT supported because each signer's signing input would differ in interpretation and the receiver-side verifier cannot know which interpretation to apply — `generalSign` rejects mixed-`b64` arrangements with `JWS.MALFORMED`. The payload segment is computed from the first signer's b64 setting and then asserted byte-identical across the rest.

View File

@@ -0,0 +1,49 @@
# Sign pipeline
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Central orchestrator for compact + flat + general sign paths. Validates options through Zod, assembles the protected header (auto-injecting `alg`), computes the signing input per RFC 7515 §5.1, and dispatches to the algorithm impl's `sign`. RFC 7797 `b64: false` is handled by routing the raw payload bytes through the signing input instead of base64url-encoding.
## Features
- Auto-injects `alg` into the protected header (callers can't accidentally sign with a header `alg` that doesn't match the algorithm argument).
- RFC 7797: setting `opts.b64 = false` automatically marks `b64` critical and lists it in `crit` when callers forget.
- AbortSignal honoured at pipeline entry.
- OTel sign-histogram + counter emitted.
## Usage: Basic Example
```ts
import { compactSign } from "@suaveplan/jws";
const token = await compactSign("hello", secret, "HS256");
```
## Usage: Advanced Example
```ts
import { runSignPipeline, validateSignOptions } from "@suaveplan/jws";
const validated = validateSignOptions({
protectedHeader: { kid: "k1", typ: "JWT" },
});
const segs = await runSignPipeline("hello", priv, "ES256", validated);
// segs.protectedB64 / segs.payloadSegment / segs.signatureB64 / segs.alg
// Compose your own wire form:
const token = `${segs.protectedB64}.${segs.payloadSegment}.${segs.signatureB64}`;
```
## API Reference
- `runSignPipeline(payload, key, alg, opts): Promise<SignedSegments>`
- `validateSignOptions(opts?: unknown): SignOptions` — Zod-validated.
- `interface SignOptions { protectedHeader?, unprotectedHeader?, detached?, b64?, signal? }`
- `interface SignedSegments { protectedB64, payloadSegment, signatureB64, alg }`
## Implementation Notes
When `opts.b64 === false`, the pipeline asserts that the protected header carries `crit: ["b64"]` (or a `crit` array containing `"b64"`); callers that omit this contract violate RFC 7797 §3 and the pipeline throws `JWS.B64_INVALID`. The pipeline does NOT auto-add `crit` because doing so silently would defeat the purpose — the `crit` listing is a verifier-side opt-in signal, and producers that forget it have a bug their verifiers will reject. Detached mode is signalled by `opts.detached = true`, which causes the returned `payloadSegment` to be the empty string while the signing input continues to incorporate the (non-empty) payload bytes — so the resulting compact token reads `<hdr>..<sig>` but is verifiable when the receiver supplies the payload out-of-band.

View File

@@ -0,0 +1,65 @@
# Verify pipeline
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Central orchestrator for every verify path (compact, flat, general). The pipeline runs the RFC 7515 §5.2 verification algorithm in a fixed order so consumers of every serialization see identical security guarantees:
1. Validate caller options via Zod.
2. Honour `AbortSignal` before any work.
3. Parse header.
4. Enforce `crit` allowlist (RFC §4.1.11).
5. Enforce algorithm allowlist (RFC §5.2 step 6) BEFORE crypto.
6. Reject `alg: "none"` unless dual-flag opt-in.
7. Resolve `b64` (RFC 7797).
8. Decode payload (or splice in `detachedPayload`).
9. Recompute signing input.
10. Resolve key (`opts.key` → `header.jwk` → `header.x5c` → `opts.keyResolver`).
11. Honour `AbortSignal` again.
12. Dispatch to the algorithm impl.
## Features
- Ordering matters: every gate runs before the next stage's work.
- AbortSignal honoured at three checkpoints (entry, mid-pipeline, pre-dispatch).
- Algorithm allowlist enforced before key resolution.
- OTel histogram + counter emitted with bounded outcome enum.
## Usage: Basic Example
```ts
import { compactVerify } from "@suaveplan/jws";
const result = await compactVerify(token, key, { algorithms: ["RS256"] });
```
## Usage: Advanced Example
```ts
import { runVerifyPipeline, validateVerifyOptions, compactDecode } from "@suaveplan/jws";
// Bypass compactDecode if you already have the segments split:
const validated = validateVerifyOptions({ algorithms: ["HS256"] });
const decoded = compactDecode(token);
const result = await runVerifyPipeline(
decoded.protected,
decoded.payload,
decoded.signature,
key,
validated,
undefined, // no detachedPayload
);
```
## API Reference
- `runVerifyPipeline(protectedB64, payloadSegment, signatureB64, key, opts, detachedPayload?, extraHeader?): Promise<VerifyResult>`
- `validateVerifyOptions(opts: unknown): VerifyOptions` — Zod-validated.
- `interface VerifyOptions` — caller options (see `verify.types`).
- `interface VerifyResult<H = JoseHeader>` — `{ verified: true, header, payload, payloadString }`.
## Implementation Notes
The "allowlist before crypto" ordering is enforced by parameter order — `opts.algorithms.includes(header.alg)` is checked immediately after `parseHeader`, before any key resolution or signing-input formation. This is load-bearing because key resolution can be expensive (network fetch for `x5c` chain validation, async `keyResolver` callbacks) and an attacker could otherwise DoS by spamming tokens with bogus algorithm identifiers. The error counter pivots on a bounded outcome enum (`success | sig_invalid | alg_not_allowed | header_invalid`) so adversarial token streams cannot inflate the metrics backend's series count.

View File

@@ -0,0 +1,178 @@
# @suaveplan/license - Compact, offline-verifiable, secp256k1-signed software-license-key format: 72-bit bitpacked payload (version + signing-key id + product id + tier + flags + time encoding + time value + feature mask + nonce) plus full or truncated (20-byte) signature, Crockford-Base32 encoded with signature-derived obfuscation. suaveplan-enterprise additions: SuaveplanError-typed errors, OTel counters/histograms/spans, Zod boundary validation, injectable Clock/Rng, AbortSignal propagation, pluggable RevocationStore. Universal.
Compact, offline-verifiable, secp256k1-signed software-license-key
format. Bit-for-bit wire-format compatible with upstream
`@suaveplan/license`. runs unchanged in Bun, Node, and the browser.
<!-- begin:auto-docs -->
## Documentation tree
- [base32](./src/codec/base32/base32.md) — Crockford-style Base32 codec used to serialise the packed payload and signature into a printable license-key string. Th…
- [base32](./src/codec/base32/base32.md) — Crockford-style Base32 codec used to serialise the packed payload and signature into a printable license-key string. Th…
- [bitpack](./src/codec/bitpack/bitpack.md) — Bit-precise pack/unpack primitives used to serialise the 72-bit license payload into 9 bytes. Plain `DataView` / typed-…
- [bitpack](./src/codec/bitpack/bitpack.md) — Bit-precise pack/unpack primitives used to serialise the 72-bit license payload into 9 bytes. Plain `DataView` / typed-…
- [obfuscation](./src/codec/obfuscation/obfuscation.md) — Per-key character obfuscation pass run after Base32 encoding. The first 15 non-dash characters of the encoded key (whic…
- [obfuscation](./src/codec/obfuscation/obfuscation.md) — Per-key character obfuscation pass run after Base32 encoding. The first 15 non-dash characters of the encoded key (whic…
- [payload](./src/codec/payload/payload.md) — `packPayload` and `unpackPayload` are the 72-bit payload codec on top of the bitpack primitives. They emit exactly 9 by…
- [payload](./src/codec/payload/payload.md) — `packPayload` and `unpackPayload` are the 72-bit payload codec on top of the bitpack primitives. They emit exactly 9 by…
- [constants](./src/constants/constants.md) — Wire-format constants for the license package. Every value here is part of the binary contract: the per-field b…
- [constants](./src/constants/constants.md) — Wire-format constants for the license package. Every value here is part of the binary contract: the per-field b…
- [flags](./src/flags/flags.md) — Bitmask helpers for the 5-bit `flags` field (`LicenseFlags`) and the 19-bit `featureMask` field. The flags field stores…
- [flags](./src/flags/flags.md) — Bitmask helpers for the 5-bit `flags` field (`LicenseFlags`) and the 19-bit `featureMask` field. The flags field stores…
- [generate](./src/generate/generate.md) — Top-level license-key generation, payload construction, and parse-only inspection. This is the place that brings togeth…
- [generate](./src/generate/generate.md) — Top-level license-key generation, payload construction, and parse-only inspection. This is the place that brings togeth…
- [manager](./src/manager/manager.md) — High-level `LicenseManager` class that bundles a public-key registry, an optional signing key, default `signingKeyId` /…
- [manager](./src/manager/manager.md) — High-level `LicenseManager` class that bundles a public-key registry, an optional signing key, default `signingKeyId` /…
- [otel](./src/otel/instruments.md) — OpenTelemetry instrumentation for the license package. Registers a single `@opentelemetry/api` meter (`@suaveplan/licen…
- [instruments](./src/otel/instruments.md) — OpenTelemetry instrumentation for the license package. Registers a single `@opentelemetry/api` meter (`@suaveplan/licen…
- [revocation-store](./src/revocation/revocation-store/revocation-store.md) — Pluggable revocation blocklist for the license package. The `RevocationStore` interface defines three methods (`add`, `…
- [revocation-store](./src/revocation/revocation-store/revocation-store.md) — Pluggable revocation blocklist for the license package. The `RevocationStore` interface defines three methods (`add`, `…
- [schemas](./src/schemas/schemas.md) — Zod schemas for every public input boundary in the license package. `generateLicense`, `validateLicense`, `createPayloa…
- [schemas](./src/schemas/schemas.md) — Zod schemas for every public input boundary in the license package. `generateLicense`, `validateLicense`, `createPayloa…
- [time](./src/time/time.md) — Time-encoding helpers for the four `TimeEncodingType` variants. The 72-bit payload encodes time in 18 bits with an addi…
- [time](./src/time/time.md) — Time-encoding helpers for the four `TimeEncodingType` variants. The 72-bit payload encodes time in 18 bits with an addi…
- [types](./src/types/license.md) — Public type surface for the license package, ported verbatim from the upstream `index.types.ts` at the wire-format leve…
- [license](./src/types/license.md) — Public type surface for the license package, ported verbatim from the upstream `index.types.ts` at the wire-format leve…
- [validate](./src/validate/validate.md) — License-key validation: parse, verify signature, check version, check expiration, surface every failure as a typed enum…
- [validate](./src/validate/validate.md) — License-key validation: parse, verify signature, check version, check expiration, surface every failure as a typed enum…
- [error-codes](./src/error-codes.md) — This module owns the stable error-code registry for `@suaveplan/license`. It exposes `LICENSE_CODES` (a frozen `defineC…
<!-- end:auto-docs -->
## Highlights
- **72-bit bitpacked payload** (version + signing-key id + product id
- tier + flags + time encoding + time value + feature mask + nonce)
encoded as 9 bytes.
- **Truncated 20-byte signature by default** keeps keys ~50-60
characters; opt into 64-byte full signatures with
`useFullSignature: true`.
- **Crockford-style Base32 alphabet** (no `I`/`L`/`O`/`U` — paste-
friendly).
- **Signature-derived obfuscation pass** rotates the first 15 payload
characters by per-license offsets drawn from the signature.
- **Four time-encoding strategies**: `NONE`, `ABSOLUTE_DAYS`,
`RELATIVE_DAYS`, `ABSOLUTE_TIMESTAMP`.
- **19-bit feature mask** + 5-bit flag bitfield (TRIAL, UPGRADE,
MACHINE_LOCKED, SUBSCRIPTION, EDUCATIONAL).
- **Enterprise extras**: typed `LicenseError` registry, OTel
counters/histograms, Zod boundary validation, injectable `Clock` /
`Rng`, `AbortSignal` propagation, pluggable `RevocationStore`.
## Quick-start
```ts
import { secp256k1 } from "@suaveplan/crypto-curve";
import { LicenseManager, PackageTier } from "@suaveplan/license";
// 1. Generate a signing key (typically done once, off-line)
const secret = secp256k1.randomPrivateKey();
const publicKey = { p: secp256k1.getPublicKey(secret) };
// 2. Bind the manager to that key
const m = new LicenseManager({
signingKey: { s: secret, p: publicKey.p },
publicKeys: { 0: publicKey },
});
// 3. Mint a license key
const { key } = await m.generate({
productId: 5,
tier: PackageTier.PRO,
expirationDays: 365,
});
// 4. Hand the key to the user, validate it on incoming requests
const v = await m.validate(key);
if (v.success) {
console.log(`Tier ${v.details.packageTier}, expires ${v.details.expirationDate}`);
}
```
## Modules
Every feature module is a tree-shakeable subpath export:
| Module | Subpath | Docs |
|---|---|---|
| Error codes | `./error-codes/error-codes` | [src/error-codes.md](src/error-codes.md) |
| Constants | `./constants/constants` | [src/constants/constants.md](src/constants/constants.md) |
| Types + enums | `./types/license` | [src/types/license.md](src/types/license.md) |
| Base32 codec | `./codec/base32/base32` | [src/codec/base32/base32.md](src/codec/base32/base32.md) |
| Bitpack | `./codec/bitpack/bitpack` | [src/codec/bitpack/bitpack.md](src/codec/bitpack/bitpack.md) |
| Payload codec | `./codec/payload/payload` | [src/codec/payload/payload.md](src/codec/payload/payload.md) |
| Obfuscation | `./codec/obfuscation/obfuscation` | [src/codec/obfuscation/obfuscation.md](src/codec/obfuscation/obfuscation.md) |
| Time helpers | `./time/time` | [src/time/time.md](src/time/time.md) |
| Flag helpers | `./flags/flags` | [src/flags/flags.md](src/flags/flags.md) |
| Zod schemas | `./schemas/schemas` | [src/schemas/schemas.md](src/schemas/schemas.md) |
| OTel instruments | `./otel/instruments` | [src/otel/instruments.md](src/otel/instruments.md) |
| Generate | `./generate/generate` | [src/generate/generate.md](src/generate/generate.md) |
| Validate | `./validate/validate` | [src/validate/validate.md](src/validate/validate.md) |
| Manager | `./manager/manager` | [src/manager/manager.md](src/manager/manager.md) |
| Revocation | `./revocation/revocation-store/revocation-store` | [src/revocation/revocation-store/revocation-store.md](src/revocation/revocation-store/revocation-store.md) |
The package root (`.`) re-exports every public symbol from each module
for ergonomic single-import usage.
## Benchmarks
Captured with `mitata` on Intel(R) Xeon(R) CPU E5-2696 v4 @ 2.20GHz,
Bun 1.3.14 (Linux x64). Run yourself with
`bun src/__benchmarks__/license.bench.ts`.
| Benchmark | Mean | p75 | p99 |
|---|---|---|---|
| `base32Encode` (73 bytes) | 38.09 µs | 42.82 µs | 134.18 µs |
| `base32Decode` (73 bytes) | 65.06 µs | 70.70 µs | 189.76 µs |
| `packPayload` | 733.58 ns | 744.46 ns | 1.37 µs |
| `unpackPayload` | 1.16 µs | 1.18 µs | 1.75 µs |
| `generateLicense` (truncated) | 67.64 µs | 73.84 µs | 242.36 µs |
| `validateLicense` (truncated) | 40.52 µs | 42.73 µs | 116.04 µs |
A full generate + validate round-trip completes in ~108 µs on this
hardware, comfortably below the 1 ms p99 budget targeted by the
upstream enterprise audit.
## Revocation
Revocation is an opt-in pluggable layer. Wire any store implementing
the `RevocationStore` interface (sync or async); the package ships a
synchronous in-memory store with optional LRU eviction.
```ts
import {
createMemoryRevocationStore,
fingerprintLicense,
LicenseManager,
} from "@suaveplan/license";
const store = createMemoryRevocationStore({ maxEntries: 100_000 });
const m = new LicenseManager({ signingKey, publicKeys, revocationStore: store });
// Block a compromised key
await m.revoke(fingerprintLicense(compromisedKey));
// Subsequent validates of that key return { error: 'REVOKED' }
```
## Wire format
The on-the-wire layout is unchanged from upstream `@suaveplan/license`.
Each 9-byte payload packs nine fields MSB-first:
```text
| v(3) | kid(5) | pid(6) | tier(4) | flg(5) | te(2) | timeValue(18) | featureMask(19) | nonce(10) |
total = 72 bits
```
A complete key encodes `payload || signature` (29 bytes with truncated
signature, 73 bytes with full signature) as Base32, dashes inserted
every 5 chars for legibility, then runs the signature-derived
obfuscation pass over the first 15 payload chars. The
seed-from-key-region trick (chars 16-30 carry the obfuscation seed)
breaks the circular dependency between "decode needs the seed" and
"the seed comes from a decoded signature".
## License
MIT.

View File

@@ -0,0 +1,74 @@
# `@suaveplan/license` — codec/base32
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Crockford-style Base32 codec used to serialise the packed payload and
signature into a printable license-key string. The alphabet
(`0-9 A-Z` minus `I`, `L`, `O`, `U`) is shared with upstream so any key
generated by upstream parses under suaveplan and vice versa. The encoder
inserts `-` separators every 5 characters for legibility; the decoder
ignores them.
## Features
- 32-character Crockford alphabet, omitting `I`/`L`/`O`/`U`.
- Dash-grouped output, `XXXXX-XXXXX-...`.
- Case-insensitive decode (uppercase canonical).
- Round-trips arbitrary `Uint8Array` data.
- Raises `LicenseError(LICENSE.INVALID_ENCODING)` on any non-alphabet
character, with the offending character in `error.context.character`.
## Usage: Basic
```ts
import { base32Decode, base32Encode } from "@suaveplan/license/codec/base32/base32";
const encoded = base32Encode(new Uint8Array([1, 2, 3, 4, 5]));
// e.g. "0426-1850-A"
const decoded = base32Decode(encoded);
```
## Usage: Advanced
Catching invalid input:
```ts
import { LicenseError } from "@suaveplan/license";
import { base32Decode } from "@suaveplan/license/codec/base32/base32";
try {
base32Decode("INVALID-CHAR-!");
} catch (e) {
if (e instanceof LicenseError && e.code === "LICENSE.INVALID_ENCODING") {
console.warn("Bad character:", e.context?.character);
}
}
```
## API Reference
### `base32Encode(buf: Uint8Array): string`
Returns the Base32 encoding of `buf`, grouped into 5-character chunks
separated by `-`. Empty input returns the empty string.
### `base32Decode(str: string): Uint8Array`
Strips `-`, uppercases, and decodes the resulting characters. Throws
`LicenseError(LICENSE.INVALID_ENCODING)` for any unknown character.
Input is trimmed to a byte boundary — trailing partial-byte bits are
discarded (matching upstream behaviour).
## Implementation Notes
The implementation uses string concatenation of binary digits rather
than bit shifts because the 5-bit-to-8-bit boundary makes manual
shifting verbose; the perf cost is negligible for license-key-sized
inputs (≤73 bytes). For larger inputs, prefer
`@suaveplan/crypto-utils`' base32 implementation. The trailing-bit
truncation in `base32Decode` matches upstream's `Math.floor(bits / 8)`
so identical inputs decode to identical bytes across both codebases.

View File

@@ -0,0 +1,90 @@
# `@suaveplan/license` — codec/bitpack
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bit-precise pack/unpack primitives used to serialise the 72-bit license
payload into 9 bytes. Plain `DataView` / typed-array slicing can only
write whole bytes, so we walk the buffer bit-by-bit. Two thin classes
hold the cursor: `SimpleBitPacker` writes MSB-first, `SimpleBitUnpacker`
reads MSB-first. Both round-trip identically to upstream's
`@suaveplan/license` bitpacker.
## Features
- `packUint(value, bits)` — writes an unsigned integer in `[0, 2^bits)`
using `bits` bits; raises `LicenseError(LICENSE.PAYLOAD_OUT_OF_RANGE)`
when the value exceeds the declared width or is negative.
- `unpackUint(bits)` — reads the next `bits`-wide unsigned integer;
raises `LicenseError(LICENSE.MALFORMED)` when the read would underflow
the buffer.
- `getBytes()` / `getBitCount()` / `getBitPosition()` introspection
helpers used by the spec-conformance tests.
## Usage: Basic
```ts
import {
SimpleBitPacker,
SimpleBitUnpacker,
} from "@suaveplan/license/codec/bitpack/bitpack";
const p = new SimpleBitPacker();
p.packUint(2, 3); // 3-bit version
p.packUint(17, 5); // 5-bit signingKeyId
const bytes = p.getBytes();
const u = new SimpleBitUnpacker(bytes);
const version = u.unpackUint(3); // 2
const keyId = u.unpackUint(5); // 17
```
## Usage: Advanced
Round-tripping a full 72-bit payload:
```ts
import { FIELD_BITS } from "@suaveplan/license/constants/constants";
import {
SimpleBitPacker,
SimpleBitUnpacker,
} from "@suaveplan/license/codec/bitpack/bitpack";
const p = new SimpleBitPacker();
for (const [_field, bits] of Object.entries(FIELD_BITS)) {
p.packUint(0, bits);
}
const bytes = p.getBytes(); // 9 bytes
const u = new SimpleBitUnpacker(bytes);
for (const [_field, bits] of Object.entries(FIELD_BITS)) {
u.unpackUint(bits); // === 0
}
```
## API Reference
### `class SimpleBitPacker`
- `packUint(value: number, bits: number): void` — write an unsigned
integer; throws on overflow / negative.
- `getBytes(): Uint8Array` — snapshot the packed bytes.
- `getBitCount(): number` — total bits written.
### `class SimpleBitUnpacker`
- `constructor(buffer: Uint8Array)` — wrap a byte buffer.
- `unpackUint(bits: number): number` — read the next integer; throws
`MALFORMED` on underflow.
- `getBitPosition(): number` — current read cursor.
## Implementation Notes
The packer grows its internal `number[]` buffer one byte at a time
because the upstream layout is small (9 bytes) and pre-sizing would
require knowing the total bit count in advance. The `1 << bits` overflow
check is safe for the bit widths used by the license schema (≤19 bits);
for wider integers, callers should use a dedicated bigint bitpacker.

View File

@@ -0,0 +1,98 @@
# `@suaveplan/license` — codec/obfuscation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Per-key character obfuscation pass run after Base32 encoding. The first
15 non-dash characters of the encoded key (which carry the bitpacked
payload) are rotated within the Base32 alphabet by per-character offsets
drawn from the license's signature. Two different licenses with
different signatures produce two completely different obfuscation
patterns, so attackers cannot pattern-match payload structure across
keys.
The seed used to drive the rotation is derived from the unobfuscated
chars 16-30 of the key (the signature region), which breaks the
circular "decode needs the seed, the seed comes from the decode"
dependency: the seed can be recovered from the obfuscated key alone.
## Features
- `obfuscateLicenseKey` / `deobfuscateLicenseKey` round-trip a key
given the same seed.
- `extractSignatureFromKey` recovers the seed from any obfuscated key
in O(n) without touching the bitpacker.
- Non-Base32 characters (dashes, accidental whitespace) pass through
unchanged so the dash-grouped formatting survives obfuscation.
- Short-key passthrough: keys with fewer than 15 non-dash characters
return unmodified, matching upstream's "no signature region to seed
from" fallback.
## Usage: Basic
```ts
import {
deobfuscateLicenseKey,
extractSignatureFromKey,
obfuscateLicenseKey,
} from "@suaveplan/license/codec/obfuscation/obfuscation";
const rawKey = "0426-1850-A8BC-NPQR-..."; // Base32 of payload || signature
const seed = extractSignatureFromKey(rawKey);
const obfuscated = obfuscateLicenseKey(rawKey, seed);
const original = deobfuscateLicenseKey(obfuscated, seed);
```
## Usage: Advanced
Round-tripping with the exact scheme `generateLicense` uses:
```ts
import {
deobfuscateLicenseKey,
extractSignatureFromKey,
obfuscateLicenseKey,
} from "@suaveplan/license/codec/obfuscation/obfuscation";
function obfuscate(rawKey: string): string {
const seed = extractSignatureFromKey(rawKey);
return obfuscateLicenseKey(rawKey, seed);
}
function deobfuscate(obfKey: string): string {
const seed = extractSignatureFromKey(obfKey);
return deobfuscateLicenseKey(obfKey, seed);
}
```
## API Reference
### `obfuscateLicenseKey(licenseKey: string, signatureBytes: Uint8Array): string`
Rotate the first 15 non-dash characters of `licenseKey` within
`BASE32_ALPHABET`. Per-char offsets cycle through `signatureBytes`.
Returns the input unchanged if it has fewer than 15 non-dash chars.
### `deobfuscateLicenseKey(obfuscatedKey: string, signatureBytes: Uint8Array): string`
Inverse of `obfuscateLicenseKey` — rotates by the negation of each
offset.
### `extractSignatureFromKey(obfuscatedKey: string): Uint8Array`
Recover up to 15 seed bytes from chars 16-30 of the obfuscated key by
reading their Base32-alphabet indices (0-31). Returns an empty array
when the key has no signature region.
## Implementation Notes
The rotation arithmetic uses double-modulo (`((x % len) + len) % len`)
so negative offsets (from `deobfuscateLicenseKey`) wrap correctly.
Non-Base32 characters return early in `rotateChar`, leaving dashes and
any defensive whitespace intact — this matches upstream's
permissive-pass behaviour and means an externally re-formatted key
(e.g. paste with extra spaces) still deobfuscates correctly once the
spaces are stripped by the Base32 decoder.

View File

@@ -0,0 +1,89 @@
# `@suaveplan/license` — codec/payload
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`packPayload` and `unpackPayload` are the 72-bit payload codec on top of
the bitpack primitives. They emit exactly 9 bytes (the binary contract
shared with upstream `@suaveplan/license`) and walk the 9 schema fields
in the canonical order declared by `FIELD_BITS`.
`packPayload` raises `LicenseError(LICENSE.PAYLOAD_OUT_OF_RANGE)` when a
field exceeds its declared bit width (via the underlying bitpacker).
`unpackPayload` raises `LicenseError(LICENSE.MALFORMED)` for any input
length other than `PAYLOAD_BYTES` (9).
## Features
- Bit-for-bit wire compatibility with upstream's `packPayload` /
`unpackPayload`.
- Field-bound validation via `SimpleBitPacker.packUint`, surfaced as
typed `LicenseError`s.
- Strict 9-byte length invariant on both sides — no implicit truncation
or padding.
- Returns a fresh `Uint8Array`; safe to mutate the caller's buffer.
## Usage: Basic
```ts
import {
packPayload,
unpackPayload,
} from "@suaveplan/license/codec/payload/payload";
import { PackageTier, TimeEncodingType } from "@suaveplan/license/types/license";
const bytes = packPayload({
version: 2,
signingKeyId: 0,
productId: 1,
packageTier: PackageTier.PRO,
flags: 0,
timeEncodingType: TimeEncodingType.NONE,
timeValue: 0,
featureMask: 0,
nonce: 0x123,
});
console.log(bytes.length); // 9
const round = unpackPayload(bytes);
```
## Usage: Advanced
Catching a field-overflow at the generation boundary:
```ts
import { LicenseError } from "@suaveplan/license";
import { packPayload } from "@suaveplan/license/codec/payload/payload";
try {
packPayload({ /* ...valid */ productId: 64 } as never); // 6 bits, max 63
} catch (err) {
if (err instanceof LicenseError && err.code === "LICENSE.PAYLOAD_OUT_OF_RANGE") {
// surface to the user as a form-validation failure
}
}
```
## API Reference
### `packPayload(payload: LicensePayload): Uint8Array`
Pack a payload to its 9-byte representation. Throws
`LicenseError(LICENSE.PAYLOAD_OUT_OF_RANGE)` on field overflow.
### `unpackPayload(data: Uint8Array): LicensePayload`
Unpack a 9-byte buffer. Throws `LicenseError(LICENSE.MALFORMED)` for
any other length.
## Implementation Notes
The unpacker casts `timeEncodingType` to the `TimeEncodingType` enum
even though the bit width (2) admits any value in `[0, 3]` — all four
values map to legitimate enum members, so the cast is safe. The
constant `PAYLOAD_BYTES = 9` lives in `constants/constants.ts` so any
future schema change (which would require a `LICENSE_VERSION` bump)
updates a single source of truth.

View File

@@ -0,0 +1,81 @@
# `@suaveplan/license` — constants
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wire-format constants for the license package. Every value here
is part of the binary contract: the per-field bit widths
(`FIELD_BITS`), the Base32 alphabet, the signature lengths, the version
byte, and the day-to-millisecond conversion factor. Changing any
constant breaks every existing license key, so they live in their own
module and are imported by every code path that touches the format.
## Features
- `LICENSE_VERSION` (currently `2`) — pinned by the 3-bit version field;
`validateLicense` rejects any payload with a higher version.
- `FULL_SIGNATURE_BYTES` (64) / `SIGNATURE_BYTES` (20) /
`MIN_SIGNATURE_BYTES` (16) — the three signature-length thresholds
honoured by generation and validation.
- `BASE32_ALPHABET` — 32-char Crockford-style alphabet (no I/L/O/U) used
by both encode and decode.
- `LICENSE_SCHEMA` — per-field maximum values, used by Zod schemas and
by the bounds checks in `packPayload`.
- `FIELD_BITS` — per-field bit widths summing to 72, consumed by the
bitpacker.
- `PAYLOAD_BYTES` (9), `OBFUSCATION_PAYLOAD_CHARS` (15), and
`MS_PER_DAY` (86 400 000) — derived constants pulled out so callers
don't have to recompute them.
## Usage: Basic
```ts
import { LICENSE_VERSION, BASE32_ALPHABET } from "@suaveplan/license/constants/constants";
console.log(`Targeting wire format v${LICENSE_VERSION}`);
console.log(`Alphabet size: ${BASE32_ALPHABET.length}`); // 32
```
## Usage: Advanced
Bounds-checking a payload field at the application layer before
constructing it:
```ts
import { LICENSE_SCHEMA } from "@suaveplan/license/constants/constants";
import type { LicensePayload } from "@suaveplan/license/types/license";
function clampPayload(p: LicensePayload): LicensePayload {
return {
...p,
productId: Math.min(p.productId, LICENSE_SCHEMA.productId),
timeValue: Math.min(p.timeValue, LICENSE_SCHEMA.timeValue),
};
}
```
## API Reference
| Symbol | Type | Value |
|---|---|---|
| `LICENSE_VERSION` | `2` | Current format version |
| `FULL_SIGNATURE_BYTES` | `64` | secp256k1 compact signature length |
| `SIGNATURE_BYTES` | `20` | Default truncated-signature length |
| `MIN_SIGNATURE_BYTES` | `16` | Lowest acceptable truncation |
| `BASE32_ALPHABET` | `string` | `0-9A-HJKMNP-TV-Z` (32 chars) |
| `LICENSE_SCHEMA` | `Readonly<Record<field, max>>` | Per-field max values |
| `FIELD_BITS` | `Readonly<Record<field, bits>>` | Per-field bit widths |
| `PAYLOAD_BYTES` | `9` | Packed payload size |
| `OBFUSCATION_PAYLOAD_CHARS` | `15` | Chars rotated by obfuscation |
| `MS_PER_DAY` | `86_400_000` | UTC day in milliseconds |
## Implementation Notes
Every constant is declared `as const`, so its TypeScript type is the
literal value (e.g. `LICENSE_VERSION: 2`), giving exhaustiveness benefits
when compared in `switch` statements. The two records (`LICENSE_SCHEMA`
and `FIELD_BITS`) are also frozen at the type level via `as const`,
preventing accidental mutation through their `Readonly` view.

View File

@@ -0,0 +1,99 @@
# `@suaveplan/license` — error-codes
## Purpose
This module owns the stable error-code registry for `@suaveplan/license`. It
exposes `LICENSE_CODES` (a frozen `defineCodes("LICENSE", [...])` record),
the `LicenseErrorCode` type union, and the `LicenseError` class. Every
public failure path in the package raises a `LicenseError` carrying one of
these twelve namespaced `LICENSE.*` codes, so consumers can dispatch on the
literal string or `instanceof LicenseError` and let TypeScript narrow the
union exhaustively.
## Features
- 12 codes covering input validation, encoding / parsing, cryptographic
failures, lifecycle (expired / revoked), manager state, feature-mask
bounds, and `AbortSignal` cancellation.
- `LicenseError` extends `SuaveplanError<LicenseErrorCode>` so the
generic `code` type narrows under `instanceof`.
- Constructor widened to `public` so all modules can construct errors
directly without trampolines.
- Codes are namespaced `LICENSE.*` per the suaveplan-wide pattern (see
`@suaveplan/crypto-keys`'s `CHAIN_KEYS.*`), making programmatic dispatch
consistent across packages.
## Usage: Basic
```ts
import { LicenseError, LICENSE_CODES } from "@suaveplan/license/error-codes/error-codes";
throw new LicenseError(
LICENSE_CODES.INVALID_ENCODING,
"Invalid Base32 character: !"
);
```
## Usage: Advanced
Attach a Zod `cause` and structured `context` for an input-validation
boundary failure:
```ts
import { LicenseError, LICENSE_CODES } from "@suaveplan/license/error-codes/error-codes";
import { LicensePayloadSchema } from "@suaveplan/license/schemas/schemas";
try {
LicensePayloadSchema.parse(input);
} catch (zodError) {
throw new LicenseError(
LICENSE_CODES.INVALID_INPUT,
"License payload failed schema validation",
{ cause: zodError, context: { source: "generateLicense" } }
);
}
```
Consumer-side narrowing:
```ts
import { LicenseError } from "@suaveplan/license";
try {
await manager.generate({ productId: 1 });
} catch (error) {
if (error instanceof LicenseError && error.code === "LICENSE.NO_SIGNING_KEY") {
// recover or re-prompt
} else {
throw error;
}
}
```
## API Reference
### `LICENSE_CODES`
Frozen `Readonly<Record<Reason, "LICENSE.<Reason>">>` where `Reason` is
one of the 12 keys: `INVALID_INPUT`, `INVALID_ENCODING`, `MALFORMED`,
`UNSUPPORTED_VERSION`, `UNKNOWN_SIGNING_KEY`, `INVALID_SIGNATURE`,
`EXPIRED`, `REVOKED`, `NO_SIGNING_KEY`, `FEATURE_INDEX_OUT_OF_RANGE`,
`PAYLOAD_OUT_OF_RANGE`, `ABORTED`.
### `type LicenseErrorCode`
String-literal union of all 12 namespaced codes.
### `class LicenseError extends SuaveplanError<LicenseErrorCode>`
Concrete error class. Constructor: `(code, message, options?)` where
`options` is `SuaveplanErrorOptions` (carries optional `cause`,
`context`, `traceId`, `clock`).
## Implementation Notes
The registry uses `defineCodes` from `@suaveplan/error` so the type of
`LICENSE_CODES.INVALID_ENCODING` is the literal `"LICENSE.INVALID_ENCODING"`
(not the broad `string`), giving exhaustiveness checking in `switch`
statements. The list is declared `as const` so TypeScript captures the
tuple shape and the helper's mapped-type return picks every key.

View File

@@ -0,0 +1,112 @@
# `@suaveplan/license` — flags
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bitmask helpers for the 5-bit `flags` field (`LicenseFlags`) and the
19-bit `featureMask` field. The flags field stores up to 5 boolean
license states (`TRIAL`, `UPGRADE`, `MACHINE_LOCKED`, `SUBSCRIPTION`,
`EDUCATIONAL`); the feature mask stores up to 19 product-specific
feature toggles. This module gives type-safe accessors for both: the
flag helpers are typed against the `LicenseFlags` enum; the feature
helpers validate that the supplied index is within `[0, 18]` and raise
`LicenseError(LICENSE.FEATURE_INDEX_OUT_OF_RANGE)` otherwise.
## Features
- `hasFlag` / `combineFlags` — flag-bit accessors typed against the
`LicenseFlags` enum so misuses surface at compile time.
- `hasFeature` / `combineFeatures` / `getEnabledFeatures` — feature-bit
accessors with runtime index-range checks.
- Out-of-range indices throw a typed `LicenseError`, never a generic
`Error` — callers can `instanceof LicenseError` and dispatch on
`error.code`.
- `getEnabledFeatures` walks only the 19 declared bits, so callers
feeding in stray high bits get a clean answer.
## Usage: Basic
```ts
import {
combineFlags,
hasFlag,
hasFeature,
} from "@suaveplan/license/flags/flags";
import { LicenseFlags } from "@suaveplan/license/types/license";
const flags = combineFlags(LicenseFlags.TRIAL, LicenseFlags.SUBSCRIPTION);
hasFlag(flags, LicenseFlags.TRIAL); // true
hasFeature(0b1010, 1); // true
```
## Usage: Advanced
Building a payload-shaped feature mask from a domain-specific feature
enum, with overflow guard:
```ts
import {
combineFeatures,
getEnabledFeatures,
} from "@suaveplan/license/flags/flags";
import { LicenseError } from "@suaveplan/license";
const enum Feature {
Export = 0,
BulkOps = 1,
AdvancedSearch = 4,
AiAssistant = 18,
}
try {
const mask = combineFeatures(
Feature.Export,
Feature.BulkOps,
Feature.AdvancedSearch,
Feature.AiAssistant
);
const enabled = getEnabledFeatures(mask); // [0, 1, 4, 18]
} catch (e) {
if (e instanceof LicenseError && e.code === "LICENSE.FEATURE_INDEX_OUT_OF_RANGE") {
// upstream produced an out-of-range index
}
}
```
## API Reference
### `hasFlag(flags: number, flag: LicenseFlags): boolean`
Test whether `flag` is set in `flags`.
### `combineFlags(...flags: LicenseFlags[]): number`
OR-merge multiple flags into a single bitmask.
### `hasFeature(featureMask: number, featureIndex: number): boolean`
Test whether feature index `featureIndex` is set. Throws
`LicenseError(LICENSE.FEATURE_INDEX_OUT_OF_RANGE)` when `featureIndex`
is outside `[0, 18]`.
### `combineFeatures(...features: number[]): number`
OR-merge multiple feature indices into a single bitmask. Same range
check as `hasFeature`.
### `getEnabledFeatures(featureMask: number): number[]`
Enumerate every enabled feature index in ascending order. Bits past
index 18 are ignored (consistent with the 19-bit field width).
## Implementation Notes
The 19-feature ceiling matches the 19-bit `featureMask` field in the
license payload. The shared `ensureFeatureIndexInRange` helper means
the two range-checking call sites stay in lockstep; if the bit budget
ever changes, only one constant needs to move. The flag enum uses
power-of-two values (`1 << 0`, `1 << 1`, ...) so `combineFlags` is a
straight `reduce(|)`.

View File

@@ -0,0 +1,129 @@
# `@suaveplan/license` — generate
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Top-level license-key generation, payload construction, and parse-only
inspection. This is the place that brings together every lower module:
it packs the payload, signs the packed bytes with secp256k1 (via
`@suaveplan/crypto-curve`) over a SHA-256 digest, optionally truncates
the signature, Base32-encodes `payload || signature`, applies the
signature-derived obfuscation pass, and emits OpenTelemetry counters
and span attributes — all behind a single async function.
`createPayload` is the quick-builder: callers supply a partial
`QuickLicenseOptions` bag, and the function fills sensible defaults
(version, signingKeyId, tier, etc.) and computes the time-encoding
fields from `expirationDays` / `useAbsoluteTime`. The `Rng` for the
nonce and the `Clock` for absolute-day expirations are both injectable
so tests run deterministically.
`parseLicense` is the inverse of generation up to (but not including)
signature verification — it deobfuscates, Base32-decodes, and unpacks
the payload, returning the three byte slices plus the decoded record.
It does NOT verify the signature or check expiration; for that, use
`validateLicense`.
## Features
- `generateLicense` — full generation pipeline with Zod boundary
validation, OTel emission, and `AbortSignal` propagation.
- `createPayload` — quick-builder with injectable `Rng` / `Clock` and
Zod-validated input.
- `parseLicense` — pure decode-and-unpack, returning the byte slices
plus the decoded payload.
- Wire-format bit-identical to upstream `@suaveplan/license`.
## Usage: Basic
```ts
import { secp256k1 } from "@suaveplan/crypto-curve";
import {
createPayload,
generateLicense,
} from "@suaveplan/license/generate/generate";
const s = secp256k1.randomPrivateKey();
const result = await generateLicense(
createPayload({ productId: 5, expirationDays: 365 }),
{ signingKey: { s }, useFullSignature: false }
);
console.log(result.key); // "XYZ12-ABCDE-..."
```
## Usage: Advanced
Deterministic generation with `Rng` injection, `AbortSignal`, and
truncated signature for a compact key:
```ts
import { secp256k1 } from "@suaveplan/crypto-curve";
import type { Rng } from "@suaveplan/types";
import { LicenseError } from "@suaveplan/license";
import {
createPayload,
generateLicense,
parseLicense,
} from "@suaveplan/license/generate/generate";
const rng: Rng = { nextUint32: () => 0x123, nextFloat: () => 0 };
const payload = createPayload(
{ productId: 7, expirationDays: 90 },
{ rng }
);
const ctrl = new AbortController();
try {
const r = await generateLicense(payload, {
signingKey: { s: secp256k1.randomPrivateKey() },
useFullSignature: false,
signal: ctrl.signal,
});
const parsed = parseLicense(r.key);
console.log("payload bytes:", parsed.payloadBytes.length); // 9
console.log("signature bytes:", parsed.signatureBytes.length); // 20
} catch (e) {
if (e instanceof LicenseError && e.code === "LICENSE.ABORTED") {
console.log("cancelled");
}
}
```
## API Reference
### `generateLicense(payload, options): Promise<LicenseGenerationResult>`
Pack + sign + encode + obfuscate. Options: `signingKey` (required),
`signingKeyIdOverride?`, `useFullSignature?` (default `true` at the
function level; `LicenseManager` overrides to `false`), `signal?`,
`rng?` (forwarded to `createPayload` when this function is invoked from
the manager). Throws `LicenseError(LICENSE.INVALID_INPUT)` on schema
failure, `LicenseError(LICENSE.ABORTED)` on a pre-aborted signal.
### `createPayload(options?, injection?): LicensePayload`
Quick-builder; defaults: `productId=1`, `tier=PackageTier.BASIC`,
`expirationDays=0` (perpetual), `flags=0`, `features=0`,
`useAbsoluteTime=false`. `injection.rng` drives the 10-bit nonce;
`injection.clock` drives `getDayNumber` when `useAbsoluteTime`.
### `parseLicense(licenseKey): LicenseParsedResult`
Deobfuscate + Base32-decode + slice + unpack. Throws
`LicenseError(LICENSE.MALFORMED)` when the decoded byte length is
less than `PAYLOAD_BYTES + 1`, or when the Base32 decode surfaces an
`INVALID_ENCODING`.
## Implementation Notes
The signature scheme is upstream-compatible: SHA-256 over the 9-byte
payload, then secp256k1 sign over the digest. The truncated path
slices the first 20 bytes of the resulting compact signature; the full
path keeps all 64. This means a key generated by upstream verifies
under suaveplan and vice versa as long as the same SignKey/PublicKey
byte material is used. The `Rng` default uses `crypto.getRandomValues`
to build a 32-bit unsigned integer, which is then masked to the 10-bit
nonce by the caller.

View File

@@ -0,0 +1,131 @@
# `@suaveplan/license` — manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
High-level `LicenseManager` class that bundles a public-key registry,
an optional signing key, default `signingKeyId` / `productId`,
injectable `Clock` / `Rng`, and an optional `RevocationStore` into a
single object. Most callers will use only the manager — the lower-level
`generateLicense` / `validateLicense` functions are exposed for the
rare case where the caller wants stateless calls.
`LicenseManager.generate` defaults `useFullSignature: false` (compact
keys) to match upstream's caller-side default; passing
`{ useFullSignature: true }` opts into 64-byte signatures. `revoke` and
`isRevoked` reach into the configured `RevocationStore` (lazily
allocating an in-memory store on first `revoke` call if none was
configured).
## Features
- 11 methods covering the full lifecycle: `addPublicKey`,
`setSigningKey`, `setRevocationStore`, `generate`, `generateTrial`,
`validate`, `isValid`, `parse`, `revoke`, `isRevoked` (+ constructor).
- Quick-trial helper `generateTrial({ expirationDays, productId?,
tier?, features? })` that sets the `TRIAL` flag and uses
`RELATIVE_DAYS` time encoding.
- Injectable `Clock` is used both as the default `referenceDate` for
`validate` and as the time base for `createPayload` absolute-day
computations.
- Injectable `Rng` flows through to `createPayload` for deterministic
nonces.
- Optional `RevocationStore` consulted before signature verification.
- Throws `LicenseError(LICENSE.NO_SIGNING_KEY)` when `generate` is
called without a configured signing key.
## Usage: Basic
```ts
import { secp256k1 } from "@suaveplan/crypto-curve";
import { LicenseManager, PackageTier } from "@suaveplan/license";
const s = secp256k1.randomPrivateKey();
const p = secp256k1.getPublicKey(s);
const m = new LicenseManager({
signingKey: { s, p },
publicKeys: { 0: { p } },
});
const r = await m.generate({ productId: 5, tier: PackageTier.PRO });
const v = await m.validate(r.key); // { success: true, details: { ... } }
```
## Usage: Advanced
A manager with deterministic test injection, a revocation store, and a
multi-key registry:
```ts
import { secp256k1 } from "@suaveplan/crypto-curve";
import type { Clock, Rng } from "@suaveplan/types";
import {
createMemoryRevocationStore,
fingerprintLicense,
LicenseManager,
} from "@suaveplan/license";
const kp1 = (() => {
const s = secp256k1.randomPrivateKey();
return { s, p: secp256k1.getPublicKey(s) };
})();
const kp2 = (() => {
const s = secp256k1.randomPrivateKey();
return { s, p: secp256k1.getPublicKey(s) };
})();
const clock: Clock = () => Date.UTC(2026, 5, 17, 12, 0, 0);
const rng: Rng = { nextUint32: () => 0xdead, nextFloat: () => 0 };
const store = createMemoryRevocationStore({ maxEntries: 10_000 });
const m = new LicenseManager({
signingKey: { s: kp1.s },
publicKeys: { 0: { p: kp1.p }, 1: { p: kp2.p } },
defaultSigningKeyId: 0,
defaultProductId: 5,
clock,
rng,
revocationStore: store,
});
const trial = await m.generateTrial({ expirationDays: 14 });
await m.revoke(fingerprintLicense(trial.key));
const v = await m.validate(trial.key);
// → { success: false, error: 'REVOKED', message: 'License has been revoked' }
```
## API Reference
### `class LicenseManager`
| Method | Returns | Notes |
|---|---|---|
| `constructor(config?)` | `LicenseManager` | All `config` fields optional |
| `addPublicKey(keyId, publicKey)` | `void` | Idempotent overwrite |
| `setSigningKey(signingKey, keyId?)` | `void` | Updates default signing key id when supplied |
| `setRevocationStore(store)` | `void` | Replaces an existing store |
| `generate(options, genOptions?)` | `Promise<LicenseGenerationResult>` | Throws `NO_SIGNING_KEY` if unconfigured |
| `generateTrial(opts)` | `Promise<LicenseGenerationResult>` | Sets `TRIAL` flag, `RELATIVE_DAYS` encoding |
| `validate(licenseKey, options?)` | `Promise<LicenseValidationResult>` | Consults revocation store first |
| `isValid(licenseKey)` | `Promise<boolean>` | Wraps `validate` |
| `parse(licenseKey)` | `LicenseParsedResult` | No verification |
| `revoke(fingerprint)` | `Promise<void>` | Lazily allocates store if none |
| `isRevoked(fingerprint)` | `Promise<boolean>` | Returns `false` if no store |
### `interface GenerateTrialOptions`
`{ expirationDays: number, productId?: number, tier?: PackageTier, features?: number }`.
## Implementation Notes
The lazy-allocation behaviour of `revoke` (auto-creates an in-memory
store on first call) means simple consumers can use revocation without
constructing a store explicitly, while still letting power users plug
in a custom backend. The manager's `clock` injection drives BOTH the
generation-time `getDayNumber` (for absolute-day expirations) AND the
validation-time `referenceDate` (when none is supplied) — keeping the
two sides on the same simulated time line for deterministic tests.

View File

@@ -0,0 +1,89 @@
# `@suaveplan/license` — otel/instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry instrumentation for the license package. Registers a
single `@opentelemetry/api` meter (`@suaveplan/license@0.1.0`) and
attaches three instruments to it: a generation counter, a validation
counter (attribute `license.valid: boolean`), and a validation-duration
histogram (unit `ms`). When no SDK is wired the meter falls back to the
Noop implementation, so importing this module has zero side effects in
production unless the host application registers a real provider.
## Features
- `license.generate_total` — counter incremented once per successful
`generateLicense` call.
- `license.validate_total` — counter incremented per validation call
with attribute `license.valid: boolean` (and, on failure, `license.error`
carrying the enum value).
- `license.validate_duration_ms` — histogram of validation latency in
milliseconds.
- Five `ATTR_*` constants exported so call sites and tests reference
attribute keys by name, not by stringly-typed literals.
## Usage: Basic
```ts
import {
ATTR_LICENSE_VALID,
licenseValidateTotal,
} from "@suaveplan/license/otel/instruments";
licenseValidateTotal.add(1, { [ATTR_LICENSE_VALID]: true });
```
## Usage: Advanced
Wire an in-memory provider for tests and assert that the instruments
emit:
```ts
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
// ...exercise the license package...
await provider.forceFlush();
console.log(exporter.getMetrics());
```
## API Reference
### Instruments
- `licenseGenerateTotal: Counter`
- `licenseValidateTotal: Counter`
- `licenseValidateDurationMs: Histogram`
### Attribute keys
- `ATTR_LICENSE_VALID = "license.valid"`
- `ATTR_LICENSE_ERROR = "license.error"`
- `ATTR_LICENSE_TIER = "license.tier"`
- `ATTR_LICENSE_PRODUCT_ID = "license.product_id"`
- `ATTR_LICENSE_SIGNING_KEY_ID = "license.signing_key_id"`
## Implementation Notes
The meter is fetched at module load via `metrics.getMeter(...)`. The
`@opentelemetry/api` library guarantees that subsequent calls with the
same name+version return the same meter instance, so importing
`instruments.ts` from multiple call sites does not create duplicate
counters. The Noop fallback means `licenseGenerateTotal.add(...)` is a
zero-cost call when no SDK is wired — safe to leave the instrumentation
on in every code path.

View File

@@ -0,0 +1,102 @@
# `@suaveplan/license` — revocation/revocation-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pluggable revocation blocklist for the license package. The
`RevocationStore` interface defines three methods (`add`, `has`,
`list`), each of which may return synchronously or as a `Promise` —
that lets callers wire an in-memory store for tests, a Redis-backed
store for production, or a remote API without changing the
`LicenseManager` call sites. A synchronous in-memory implementation
ships with the package; `fingerprintLicense` produces a SHA-256 hex of
an obfuscated key suitable as a store key.
## Features
- `createMemoryRevocationStore(options?)` — synchronous in-memory store
with optional `maxEntries` FIFO eviction cap.
- `fingerprintLicense(licenseKey)` — stable SHA-256 hex of the
(obfuscated) license-key string.
- Insertion-ordered `list()` snapshot for backup / migration.
- Re-adding an existing fingerprint moves it to the end (LRU-on-write)
so frequently-revoked keys survive eviction longest.
## Usage: Basic
```ts
import {
createMemoryRevocationStore,
fingerprintLicense,
} from "@suaveplan/license/revocation/revocation-store/revocation-store";
const store = createMemoryRevocationStore({ maxEntries: 10_000 });
await store.add(fingerprintLicense("XXXXX-YYYYY-..."));
const blocked = await store.has(fingerprintLicense("XXXXX-YYYYY-..."));
```
## Usage: Advanced
Wire a custom async store backed by a database:
```ts
import type { RevocationStore } from "@suaveplan/license";
function createDbRevocationStore(db: SomeDb): RevocationStore {
return {
async add(fingerprint) {
await db.insert("revocations", { fingerprint });
},
async has(fingerprint) {
return await db.exists("revocations", { fingerprint });
},
async list() {
const rows = await db.select("revocations", ["fingerprint"]);
return rows.map((r) => r.fingerprint);
},
};
}
```
## API Reference
### `interface RevocationStore`
```ts
interface RevocationStore {
add(fingerprint: string): void | Promise<void>;
has(fingerprint: string): boolean | Promise<boolean>;
list(): readonly string[] | Promise<readonly string[]>;
}
```
### `interface MemoryRevocationStoreOptions`
```ts
interface MemoryRevocationStoreOptions {
readonly maxEntries?: number;
}
```
### `createMemoryRevocationStore(options?): RevocationStore`
Build a fresh in-memory store. Returns synchronous methods; safe to
`await` regardless.
### `fingerprintLicense(licenseKey: string): string`
SHA-256 hex of the UTF-8 bytes of `licenseKey`.
## Implementation Notes
The in-memory store uses a `Set<string>` because `Set` preserves
insertion order in every modern JS engine and gives O(1) `add`/`has`.
The LRU-on-write semantic (re-add moves to end) matches the typical
"a fingerprint just got revoked again, so keep it" intuition without
needing a separate access timestamp. `fingerprintLicense` uses SHA-256
because the fingerprint is the wire-facing key for the store; using
the obfuscated key directly would make the store quadratic in key
length and leak structure.

View File

@@ -0,0 +1,91 @@
# `@suaveplan/license` — schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas for every public input boundary in the license package.
`generateLicense`, `validateLicense`, `createPayload`, and the
`LicenseManager` methods parse their inputs against these schemas at
entry; failed parses raise `LicenseError(LICENSE.INVALID_INPUT)` with
the `ZodError` attached as `cause`. Centralising the schemas here keeps
the runtime modules thin and ensures downstream Zod-aware consumers
(form validators, OpenAPI generators) can reuse the same shapes.
## Features
- `LicensePayloadSchema` — every 9 payload fields bounded by the
per-field maximums in `LICENSE_SCHEMA`.
- `LicenseGenerationOptionsSchema` — wraps `SignKey` (with a `Uint8Array`
secret), the optional `signingKeyIdOverride`, `useFullSignature`,
`signal`, and `rng`.
- `LicenseValidationOptionsSchema` — accepts `publicKeys` as either a
`Map` or a `Record`, mirroring the upstream API.
- `QuickLicenseOptionsSchema` — covers the quick-builder fields with the
same per-field bounds.
- Every schema is `safeParse`-friendly; the runtime callers turn parse
failures into typed `LicenseError`s.
## Usage: Basic
```ts
import { LicensePayloadSchema } from "@suaveplan/license/schemas/schemas";
const parsed = LicensePayloadSchema.parse(input); // throws ZodError on failure
```
## Usage: Advanced
Turn a parse failure into a typed `LicenseError`:
```ts
import { LICENSE_CODES, LicenseError } from "@suaveplan/license";
import { LicensePayloadSchema } from "@suaveplan/license/schemas/schemas";
function parsePayload(input: unknown) {
const r = LicensePayloadSchema.safeParse(input);
if (!r.success) {
throw new LicenseError(
LICENSE_CODES.INVALID_INPUT,
"Invalid license payload",
{ cause: r.error, context: { issues: r.error.issues.length } }
);
}
return r.data;
}
```
## API Reference
### `LicensePayloadSchema`
`z.object({ version, signingKeyId, productId, packageTier, flags,
timeEncodingType, timeValue, featureMask, nonce })` with every field
bounded by `LICENSE_SCHEMA`.
### `LicenseGenerationOptionsSchema`
`z.object({ signingKey, signingKeyIdOverride?, useFullSignature?, signal?,
rng? })`. `signingKey` is `{ s: Uint8Array, p?: Uint8Array }`.
### `LicenseValidationOptionsSchema`
`z.object({ publicKeys, referenceDate?, creationDate?, signal? })`.
`publicKeys` is a `Map` or `Record<number, { p: Uint8Array }>`.
### `QuickLicenseOptionsSchema`
`z.object({ productId?, tier?, expirationDays?, flags?, features?,
useAbsoluteTime? })` with per-field bounds.
## Implementation Notes
`rng` is intentionally typed as `z.unknown().optional()` because the
`Rng` interface from `@suaveplan/types` is structural (a duck-typed bag
of methods); attempting to validate it with a structural Zod schema
would force consumers to surface every optional method even when their
RNG implementation chooses not to. The runtime modules treat `rng` as
opaque and call only the documented `nextUint32` method, so the loose
schema is safe.

View File

@@ -0,0 +1,85 @@
# `@suaveplan/license` — time
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Time-encoding helpers for the four `TimeEncodingType` variants. The
72-bit payload encodes time in 18 bits with an additional 2-bit
discriminator (`NONE` / `ABSOLUTE_DAYS` / `RELATIVE_DAYS` /
`ABSOLUTE_TIMESTAMP`), giving four distinct expiration strategies. This
module turns those numeric fields into concrete `{ isExpired,
expirationDate }` records and lets callers inject a `Clock` (from
`@suaveplan/types`) for deterministic test runs.
## Features
- Four expiration strategies, one switch — no separate code paths per
call site.
- Injectable `Clock` parameter; defaults to `Date.now()` so production
callers don't have to thread it through.
- `getDayNumber` / `dayNumberToDate` round-trip helpers used by
generation when computing absolute-day expirations.
- Default-arm fall-through returns `{ isExpired: false }` for unknown
encodings, matching upstream's permissive behaviour (so a future
encoding variant never bricks an older validator).
## Usage: Basic
```ts
import { checkExpiration } from "@suaveplan/license/time/time";
import { TimeEncodingType } from "@suaveplan/license/types/license";
const r = checkExpiration(TimeEncodingType.NONE, 0);
console.log(r.isExpired); // false (perpetual)
```
## Usage: Advanced
Threading a deterministic `Clock` through to relative-day expiry:
```ts
import type { Clock } from "@suaveplan/types";
import { checkExpiration } from "@suaveplan/license/time/time";
import { TimeEncodingType } from "@suaveplan/license/types/license";
const clock: Clock = () => Date.UTC(2026, 5, 17, 12, 0, 0);
const creation = new Date("2026-06-01T00:00:00Z");
const r = checkExpiration(
TimeEncodingType.RELATIVE_DAYS,
14,
undefined, // use clock
creation,
clock
);
```
## API Reference
### `getDayNumber(date?: Date, clock?: Clock): number`
Days since the Unix epoch. Uses `date.getTime()` when provided, otherwise
the injected `clock` (default `Date.now()`).
### `dayNumberToDate(dayNumber: number): Date`
Days since Unix epoch → `Date`.
### `checkExpiration(timeEncodingType, timeValue, referenceDate?, creationDate?, clock?): { isExpired, expirationDate? }`
Determine expiration for a license. `referenceDate` defaults to
`new Date(clock())`. `creationDate` is consulted only for
`RELATIVE_DAYS` (falls back to `referenceDate`).
## Implementation Notes
The unknown-encoding default arm (`return { isExpired: false }`) is
intentional — it makes adding a new encoding variant a backward-
compatible change for existing validators, which is important because
license keys are long-lived and may outlive the validator that
originally minted them. The `clock` parameter sits last in the
argument list and defaults to `Date.now()`, so the public
`checkExpiration` call site stays compact in normal usage and only
expands when a test needs determinism.

View File

@@ -0,0 +1,121 @@
# `@suaveplan/license` — types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public type surface for the license package, ported verbatim from the
upstream `index.types.ts` at the wire-format level. This module owns
every interface, type alias, and enum that crosses the package
boundary; the runtime modules import their shapes from here so the
binary contract (72-bit payload layout, signature lengths, validation
error variants) is declared in one place.
Two additive suaveplan extensions sit alongside the upstream surface:
`LicenseValidationError.REVOKED` (additional failure variant) and the
`LicenseManagerConfig.{clock, rng, revocationStore}` injection trio.
Both are opt-in and never affect callers that don't reach for them.
## Features
- Three enums verbatim from upstream: `TimeEncodingType`,
`LicenseFlags`, `PackageTier`.
- `LicensePayload` (the 9-field 72-bit struct), `LicenseDetails`,
`LicenseGenerationOptions`/`Result`, `LicenseValidationOptions`,
`LicenseValidationResult` discriminated union.
- `PublicKey` / `SignKey` shaped to upstream's `{ p, s }` records so the
truncated-signature verification path round-trips bit-identically.
- `RevocationStore` re-export so the package root can serve a single
named entry for downstream consumers.
- `CreatePayloadInjection` bag carrying optional `Clock` and `Rng`,
threaded through to deterministic test runs.
## Usage: Basic
```ts
import type {
LicensePayload,
LicenseValidationResult,
} from "@suaveplan/license/types/license";
import { PackageTier, TimeEncodingType } from "@suaveplan/license/types/license";
const payload: LicensePayload = {
version: 2,
signingKeyId: 0,
productId: 7,
packageTier: PackageTier.PRO,
flags: 0,
timeEncodingType: TimeEncodingType.NONE,
timeValue: 0,
featureMask: 0,
nonce: 0x123,
};
function isExpired(result: LicenseValidationResult): boolean {
return !result.success && result.error === "EXPIRED";
}
```
## Usage: Advanced
Narrowing the discriminated union and switching on every error variant:
```ts
import {
LicenseValidationError,
type LicenseValidationResult,
} from "@suaveplan/license/types/license";
function describe(result: LicenseValidationResult): string {
if (result.success) {
return `Valid (tier ${result.details.packageTier})`;
}
switch (result.error) {
case LicenseValidationError.MALFORMED: return "Malformed key";
case LicenseValidationError.INVALID_ENCODING: return "Invalid encoding";
case LicenseValidationError.INVALID_SIGNATURE: return "Bad signature";
case LicenseValidationError.UNKNOWN_SIGNING_KEY: return "Unknown key";
case LicenseValidationError.EXPIRED: return "Expired";
case LicenseValidationError.UNSUPPORTED_VERSION: return "Wrong version";
case LicenseValidationError.REVOKED: return "Revoked";
default: {
const _exhaustive: never = result.error;
return _exhaustive;
}
}
}
```
## API Reference
### Enums
- `TimeEncodingType` — `NONE` / `ABSOLUTE_DAYS` / `RELATIVE_DAYS` / `ABSOLUTE_TIMESTAMP`.
- `LicenseFlags` — 5-bit bitfield: `TRIAL`, `UPGRADE`, `MACHINE_LOCKED`, `SUBSCRIPTION`, `EDUCATIONAL`.
- `PackageTier` — `FREE` / `BASIC` / `PRO` / `BUSINESS` / `ENTERPRISE`.
- `LicenseValidationError` — string-literal enum with seven variants (six verbatim + additive `REVOKED`).
### Interfaces
`PublicKey`, `SignKey`, `LicensePayload`, `LicenseDetails`,
`LicenseGenerationOptions`, `LicenseGenerationResult`,
`LicenseValidationOptions`, `LicenseValidationFailure`,
`LicenseValidationSuccess`, `LicenseParsedResult`, `KeyRegistry`,
`LicenseManagerConfig`, `QuickLicenseOptions`, `CreatePayloadInjection`,
`RevocationStore` (re-export).
### Types
`LicenseValidationResult = LicenseValidationSuccess | LicenseValidationFailure`.
## Implementation Notes
Every enum is declared with literal numeric or string values that match
the upstream wire format; do not renumber. `LicensePayload` is plain
`number` for each field — runtime bounds checks live in
`packPayload` (via `LICENSE_SCHEMA`) and the Zod schemas in
`src/schemas/schemas.ts`, leaving the type free of brand types so
consumers can construct payloads from arbitrary integer math without
fighting the type system.

View File

@@ -0,0 +1,110 @@
# `@suaveplan/license` — validate
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
License-key validation: parse, verify signature, check version, check
expiration, surface every failure as a typed enum on a discriminated
union. `validateLicense` accepts `publicKeys` as either a `Map` or a
plain `Record` (matching upstream), honours an optional `AbortSignal`,
and emits the OpenTelemetry counter / histogram pair on every call.
`isValidLicense` is a convenience boolean wrapper.
The package's truncated-signature scheme (default 20 bytes) cannot use
standard secp256k1 verification (which requires the full 64-byte
signature). The upstream-compatible fallback hashes `payloadBytes ||
publicKey.p` with SHA-256 and compares the first N bytes against the
embedded signature bytes — giving ~2^160 forgery resistance for the
default 20-byte length.
## Features
- Seven failure modes returned on the discriminated union:
`MALFORMED`, `INVALID_ENCODING`, `INVALID_SIGNATURE`,
`UNKNOWN_SIGNING_KEY`, `EXPIRED`, `UNSUPPORTED_VERSION`, `REVOKED`
(`REVOKED` is surfaced by `LicenseManager.validate`).
- Full-signature path uses `@suaveplan/crypto-curve`'s
`secp256k1.verify` on the SHA-256 digest of `payloadBytes`.
- Truncated-signature path uses the upstream hash-prefix scheme.
- Zod boundary validation on `options`; failures raise
`LicenseError(LICENSE.INVALID_INPUT)`.
- OTel emission: counter `license.validate_total{license.valid,
license.error?}` and histogram `license.validate_duration_ms`.
- Pre-aborted `AbortSignal` rejects with
`LicenseError(LICENSE.ABORTED)` before any crypto runs.
## Usage: Basic
```ts
import { validateLicense } from "@suaveplan/license/validate/validate";
const v = await validateLicense(key, {
publicKeys: { 0: { p: publicKeyBytes } },
});
if (v.success) {
console.log("Tier:", v.details.packageTier);
} else {
console.warn("Invalid:", v.error, v.message);
}
```
## Usage: Advanced
Validate with an injected `referenceDate`, `creationDate` for a
relative-day expiry check, and an `AbortSignal` for cancellation:
```ts
import { validateLicense } from "@suaveplan/license/validate/validate";
import { LicenseError } from "@suaveplan/license";
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 100);
try {
const v = await validateLicense(key, {
publicKeys: new Map([[0, { p }]]),
referenceDate: new Date("2026-06-17T00:00:00Z"),
creationDate: new Date("2026-06-01T00:00:00Z"),
signal: ctrl.signal,
});
if (v.success) {
// ...
}
} catch (e) {
if (e instanceof LicenseError && e.code === "LICENSE.ABORTED") {
console.warn("Validation cancelled");
}
}
```
## API Reference
### `validateLicense(licenseKey, options): Promise<LicenseValidationResult>`
Options: `publicKeys` (required), `referenceDate?`, `creationDate?`,
`signal?`. Returns the discriminated success/failure union. Throws only
on `LicenseError(LICENSE.INVALID_INPUT)` (bad options) or
`LicenseError(LICENSE.ABORTED)` (aborted signal); every other failure
is surfaced on the result.
### `isValidLicense(licenseKey, options): Promise<boolean>`
Thin wrapper that returns `result.success`.
## Implementation Notes
The truncated-signature scheme is what makes the keys compact (~50-60
chars vs ~120). For most consumer products the ~2^160 forgery
resistance is generous; for higher-value licenses callers should opt
into `useFullSignature: true` at generation time. Validation auto-
detects which scheme the key uses by checking the signature byte
length against `FULL_SIGNATURE_BYTES`.
The OTel emission runs on every code path — including aborts and
parse failures — so dashboards always see a complete success/failure
ratio. The histogram's unit is documented as `ms`; the recorded value
is `Date.now() - start`, a wall-clock measurement (acceptable here
because validation latency is dominated by the crypto, not by clock
skew).

View File

@@ -0,0 +1,207 @@
# @suaveplan/otp - RFC 4226 (HOTP) + RFC 6238 (TOTP) + RFC 6287 (OCRA) + Google Authenticator Key URI Format + Base32 (RFC 4648 §6) + recovery codes + clock-drift detector — zero browser/Node deps
## Summary
> **RFC 4226 (HOTP) + RFC 6238 (TOTP) + RFC 6287 (OCRA) + Google Authenticator Key URI Format + Base32 (RFC 4648 §6) + recovery codes + clock-drift detector** — zero browser/Node deps.
<!-- begin:auto-docs -->
## Documentation tree
- [base32](./src/base32/base32.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [base32](./src/base32/base32.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [drift](./src/drift/detect.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [detect](./src/drift/detect.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [codes](./src/errors/codes.md) — Stable, registered error codes for `@suaveplan/otp` plus the typed `OtpError` base class. Every failure that crosses th…
- [zod-map](./src/errors/zod-map.md) — Translate Zod boundary failures into the most-specific `OtpErrorCode`. When a caller passes options that fail strict-mo…
- [hotp](./src/hotp/hotp.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - hotp.schema — Zod boundary schema for `HotpOptions`…
- [encode-counter](./src/hotp/encode-counter.md) — Encode an HOTP counter as the 8-byte big-endian block required by RFC 4226 §5.1. `bigint` is the canonical input type b…
- [hmac](./src/hotp/hmac.md) — Algorithm-routing HMAC façade for the OTP family. SHA-256 and SHA-512 route through `@suaveplan/crypto-hash` (the canon…
- [hotp](./src/hotp/hotp.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - hotp.schema — Zod boundary schema for `HotpOptions`…
- [hotp.schema](./src/hotp/hotp.schema.md) — Zod boundary schema for `HotpOptions`. Strict-mode parsing rejects unknown keys (so silent typos in caller configuratio…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [ocra](./src/ocra/ocra.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - ocra.schema — Zod boundary schema for `OcraParams`……
- [encode-challenge](./src/ocra/encode-challenge.md) — Encode the OCRA challenge into the 128-byte block required by RFC 6287 §5.1. Three challenge formats are supported: `QN…
- [mutual-auth](./src/ocra/mutual-auth.md) — RFC 6287 §7.1 mutual authentication helpers. Both sides compute OCRA over the concatenation of the client and server ch…
- [ocra](./src/ocra/ocra.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - ocra.schema — Zod boundary schema for `OcraParams`……
- [ocra.schema](./src/ocra/ocra.schema.md) — Zod boundary schema for `OcraParams` — the runtime parameter bag passed alongside a suite string and a key. Optional fi…
- [suite](./src/ocra/suite.md) — Parse an OCRA suite string into a typed `OcraSuite` AST per RFC 6287 §6.1. Exposed separately so callers can validate s…
- [verify-ocra](./src/ocra/verify-ocra.md) — Constant-time verify of an OCRA response. Recomputes the response under the supplied suite, key, and params, then compa…
- [generate](./src/recovery/generate.md) — Generate Crockford-base32 recovery codes — the printable single-use fallback the user prints / writes down when first e…
- [hash](./src/recovery/hash.md) — Hash recovery codes with Argon2id for at-rest storage. Recovery codes are long-lived shared secrets — they must NOT be…
- [verify](./src/recovery/verify.md) — Verify a user-supplied recovery code against a list of stored hashes. Constant-time over the full set — every entry is…
- [totp](./src/totp/totp.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - totp.schema — Zod boundary schemas for `TotpOptions…
- [totp](./src/totp/totp.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - totp.schema — Zod boundary schemas for `TotpOptions…
- [totp.schema](./src/totp/totp.schema.md) — Zod boundary schemas for `TotpOptions` and `TotpVerifyOptions`. The TOTP schema covers algorithm / digits / period / t0…
- [verify-many](./src/totp/verify-many.md) — Backup-aware TOTP verify. Used during rotation windows where an old and a new TOTP secret coexist — the user's authenti…
- [verify](./src/totp/verify.md) — RFC 6238 §5.2 verify with drift window. Checks the supplied code against the current step and `±window` neighbouring st…
- [format-uri](./src/uri/format-uri.md) — Build a canonical `otpauth://` URI from an `OtpUri` value object. Round-trips with `parseUri` for every valid input. Pe…
- [parse-uri](./src/uri/parse-uri.md) — Parse the `otpauth://` Key URI Format used by Google Authenticator and every compatible authenticator app. Supports bot…
- [uri.schema](./src/uri/uri.schema.md) — Zod boundary schemas for the `OtpUri` discriminated union. `OtpTotpUriSchema` and `OtpHotpUriSchema` cover the per-vari…
<!-- end:auto-docs --> - [base32](./src/base32/base32.md) — <!-- begin:auto-docs --> _(no submodules)_- [drift](./src/drift/detect.md) — <!-- begin:auto-docs --> _(no submodules)_- [detect](./src/drift/detect.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — Stable, registered error codes for `@suaveplan/otp` plus the typed `OtpError` base class. Every failure that crosses th…
- [zod-map](./src/errors/zod-map.md) — Translate Zod boundary failures into the most-specific `OtpErrorCode`. When a caller passes options that fail strict-mo…
- [hotp](./src/hotp/hotp.md) — <!-- begin:auto-docs --> _(no submodules)_- hotp.schema — Zod boundary schema for `HotpOptions`…
- [encode-counter](./src/hotp/encode-counter.md) — Encode an HOTP counter as the 8-byte big-endian block required by RFC 4226 §5.1. `bigint` is the canonical input type b…
- [hmac](./src/hotp/hmac.md) — Algorithm-routing HMAC façade for the OTP family. SHA-256 and SHA-512 route through `@suaveplan/crypto-hash` (the canon…
- [hotp](./src/hotp/hotp.md) — <!-- begin:auto-docs --> _(no submodules)_- hotp.schema — Zod boundary schema for `HotpOptions`…
- [hotp.schema](./src/hotp/hotp.schema.md) — Zod boundary schema for `HotpOptions`. Strict-mode parsing rejects unknown keys (so silent typos in caller configuratio…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [ocra](./src/ocra/ocra.md) — <!-- begin:auto-docs --> _(no submodules)_- ocra.schema — Zod boundary schema for `OcraParams`……
- [encode-challenge](./src/ocra/encode-challenge.md) — Encode the OCRA challenge into the 128-byte block required by RFC 6287 §5.1. Three challenge formats are supported: `QN…
- [mutual-auth](./src/ocra/mutual-auth.md) — RFC 6287 §7.1 mutual authentication helpers. Both sides compute OCRA over the concatenation of the client and server ch…
- [ocra](./src/ocra/ocra.md) — <!-- begin:auto-docs --> _(no submodules)_- ocra.schema — Zod boundary schema for `OcraParams`……
- [ocra.schema](./src/ocra/ocra.schema.md) — Zod boundary schema for `OcraParams` — the runtime parameter bag passed alongside a suite string and a key. Optional fi…
- [suite](./src/ocra/suite.md) — Parse an OCRA suite string into a typed `OcraSuite` AST per RFC 6287 §6.1. Exposed separately so callers can validate s…
- [verify-ocra](./src/ocra/verify-ocra.md) — Constant-time verify of an OCRA response. Recomputes the response under the supplied suite, key, and params, then compa…
- [generate](./src/recovery/generate.md) — Generate Crockford-base32 recovery codes — the printable single-use fallback the user prints / writes down when first e…
- [hash](./src/recovery/hash.md) — Hash recovery codes with Argon2id for at-rest storage. Recovery codes are long-lived shared secrets — they must NOT be…
- [verify](./src/recovery/verify.md) — Verify a user-supplied recovery code against a list of stored hashes. Constant-time over the full set — every entry is…
- [totp](./src/totp/totp.md) — <!-- begin:auto-docs --> _(no submodules)_- totp.schema — Zod boundary schemas for `TotpOptions…
- [totp](./src/totp/totp.md) — <!-- begin:auto-docs --> _(no submodules)_- totp.schema — Zod boundary schemas for `TotpOptions…
- [totp.schema](./src/totp/totp.schema.md) — Zod boundary schemas for `TotpOptions` and `TotpVerifyOptions`. The TOTP schema covers algorithm / digits / period / t0…
- [verify-many](./src/totp/verify-many.md) — Backup-aware TOTP verify. Used during rotation windows where an old and a new TOTP secret coexist — the user's authenti…
- [verify](./src/totp/verify.md) — RFC 6238 §5.2 verify with drift window. Checks the supplied code against the current step and `±window` neighbouring st…
- [format-uri](./src/uri/format-uri.md) — Build a canonical `otpauth://` URI from an `OtpUri` value object. Round-trips with `parseUri` for every valid input. Pe…
- [parse-uri](./src/uri/parse-uri.md) — Parse the `otpauth://` Key URI Format used by Google Authenticator and every compatible authenticator app. Supports bot…
- [uri.schema](./src/uri/uri.schema.md) — Zod boundary schemas for the `OtpUri` discriminated union. `OtpTotpUriSchema` and `OtpHotpUriSchema` cover the per-vari…
- [base32](./src/base32/base32.md) — <!-- begin:auto-docs --> _(no submodules)_- [drift](./src/drift/detect.md) — <!-- begin:auto-docs --> _(no submodules)_- [detect](./src/drift/detect.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — Stable, registered error codes for `@suaveplan/otp` plus the typed `OtpError` base class. Every failure that crosses th…
- [zod-map](./src/errors/zod-map.md) — Translate Zod boundary failures into the most-specific `OtpErrorCode`. When a caller passes options that fail strict-mo…
- [hotp](./src/hotp/hotp.md) — <!-- begin:auto-docs --> _(no submodules)_- hotp.schema — Zod boundary schema for `HotpOptions`…
- [encode-counter](./src/hotp/encode-counter.md) — Encode an HOTP counter as the 8-byte big-endian block required by RFC 4226 §5.1. `bigint` is the canonical input type b…
- [hmac](./src/hotp/hmac.md) — Algorithm-routing HMAC façade for the OTP family. SHA-256 and SHA-512 route through `@suaveplan/crypto-hash` (the canon…
- [hotp](./src/hotp/hotp.md) — <!-- begin:auto-docs --> _(no submodules)_- hotp.schema — Zod boundary schema for `HotpOptions`…
- [hotp.schema](./src/hotp/hotp.schema.md) — Zod boundary schema for `HotpOptions`. Strict-mode parsing rejects unknown keys (so silent typos in caller configuratio…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [ocra](./src/ocra/ocra.md) — <!-- begin:auto-docs --> _(no submodules)_- ocra.schema — Zod boundary schema for `OcraParams`……
- [encode-challenge](./src/ocra/encode-challenge.md) — Encode the OCRA challenge into the 128-byte block required by RFC 6287 §5.1. Three challenge formats are supported: `QN…
- [mutual-auth](./src/ocra/mutual-auth.md) — RFC 6287 §7.1 mutual authentication helpers. Both sides compute OCRA over the concatenation of the client and server ch…
- [ocra](./src/ocra/ocra.md) — <!-- begin:auto-docs --> _(no submodules)_- ocra.schema — Zod boundary schema for `OcraParams`……
- [ocra.schema](./src/ocra/ocra.schema.md) — Zod boundary schema for `OcraParams` — the runtime parameter bag passed alongside a suite string and a key. Optional fi…
- [suite](./src/ocra/suite.md) — Parse an OCRA suite string into a typed `OcraSuite` AST per RFC 6287 §6.1. Exposed separately so callers can validate s…
- [verify-ocra](./src/ocra/verify-ocra.md) — Constant-time verify of an OCRA response. Recomputes the response under the supplied suite, key, and params, then compa…
- [generate](./src/recovery/generate.md) — Generate Crockford-base32 recovery codes — the printable single-use fallback the user prints / writes down when first e…
- [hash](./src/recovery/hash.md) — Hash recovery codes with Argon2id for at-rest storage. Recovery codes are long-lived shared secrets — they must NOT be…
- [verify](./src/recovery/verify.md) — Verify a user-supplied recovery code against a list of stored hashes. Constant-time over the full set — every entry is…
- [totp](./src/totp/totp.md) — <!-- begin:auto-docs --> _(no submodules)_- totp.schema — Zod boundary schemas for `TotpOptions…
- [totp](./src/totp/totp.md) — <!-- begin:auto-docs --> _(no submodules)_- totp.schema — Zod boundary schemas for `TotpOptions…
- [totp.schema](./src/totp/totp.schema.md) — Zod boundary schemas for `TotpOptions` and `TotpVerifyOptions`. The TOTP schema covers algorithm / digits / period / t0…
- [verify-many](./src/totp/verify-many.md) — Backup-aware TOTP verify. Used during rotation windows where an old and a new TOTP secret coexist — the user's authenti…
- [verify](./src/totp/verify.md) — RFC 6238 §5.2 verify with drift window. Checks the supplied code against the current step and `±window` neighbouring st…
- [format-uri](./src/uri/format-uri.md) — Build a canonical `otpauth://` URI from an `OtpUri` value object. Round-trips with `parseUri` for every valid input. Pe…
- [parse-uri](./src/uri/parse-uri.md) — Parse the `otpauth://` Key URI Format used by Google Authenticator and every compatible authenticator app. Supports bot…
- [uri.schema](./src/uri/uri.schema.md) — Zod boundary schemas for the `OtpUri` discriminated union. `OtpTotpUriSchema` and `OtpHotpUriSchema` cover the per-vari…
- [base32](./src/base32/base32.md) — <!-- begin:auto-docs --> _(no submodules)_- [drift](./src/drift/detect.md) — <!-- begin:auto-docs --> _(no submodules)_- [detect](./src/drift/detect.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — Stable, registered error codes for `@suaveplan/otp` plus the typed `OtpError` base class. Every failure that crosses th…
- [zod-map](./src/errors/zod-map.md) — Translate Zod boundary failures into the most-specific `OtpErrorCode`. When a caller passes options that fail strict-mo…
- [hotp](./src/hotp/hotp.md) — <!-- begin:auto-docs --> _(no submodules)_- hotp.schema — Zod boundary schema for `HotpOptions`…
- [encode-counter](./src/hotp/encode-counter.md) — Encode an HOTP counter as the 8-byte big-endian block required by RFC 4226 §5.1. `bigint` is the canonical input type b…
- [hmac](./src/hotp/hmac.md) — Algorithm-routing HMAC façade for the OTP family. SHA-256 and SHA-512 route through `@suaveplan/crypto-hash` (the canon…
- [hotp](./src/hotp/hotp.md) — <!-- begin:auto-docs --> _(no submodules)_- hotp.schema — Zod boundary schema for `HotpOptions`…
- [hotp.schema](./src/hotp/hotp.schema.md) — Zod boundary schema for `HotpOptions`. Strict-mode parsing rejects unknown keys (so silent typos in caller configuratio…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [ocra](./src/ocra/ocra.md) — <!-- begin:auto-docs --> _(no submodules)_- ocra.schema — Zod boundary schema for `OcraParams`…
- [encode-challenge](./src/ocra/encode-challenge.md) — Encode the OCRA challenge into the 128-byte block required by RFC 6287 §5.1. Three challenge formats are supported: `QN…
- [mutual-auth](./src/ocra/mutual-auth.md) — RFC 6287 §7.1 mutual authentication helpers. Both sides compute OCRA over the concatenation of the client and server ch…
- [ocra](./src/ocra/ocra.md) — <!-- begin:auto-docs --> _(no submodules)_- ocra.schema — Zod boundary schema for `OcraParams`…
- [ocra.schema](./src/ocra/ocra.schema.md) — Zod boundary schema for `OcraParams` — the runtime parameter bag passed alongside a suite string and a key. Optional fi…
- [suite](./src/ocra/suite.md) — Parse an OCRA suite string into a typed `OcraSuite` AST per RFC 6287 §6.1. Exposed separately so callers can validate s…
- [verify-ocra](./src/ocra/verify-ocra.md) — Constant-time verify of an OCRA response. Recomputes the response under the supplied suite, key, and params, then compa…
- [generate](./src/recovery/generate.md) — Generate Crockford-base32 recovery codes — the printable single-use fallback the user prints / writes down when first e…
- [hash](./src/recovery/hash.md) — Hash recovery codes with Argon2id for at-rest storage. Recovery codes are long-lived shared secrets — they must NOT be…
- [verify](./src/recovery/verify.md) — Verify a user-supplied recovery code against a list of stored hashes. Constant-time over the full set — every entry is…
- [totp](./src/totp/totp.md) — <!-- begin:auto-docs --> _(no submodules)_- totp.schema — Zod boundary schemas for `TotpOptions…
- [totp](./src/totp/totp.md) — <!-- begin:auto-docs --> _(no submodules)_- totp.schema — Zod boundary schemas for `TotpOptions…
- [totp.schema](./src/totp/totp.schema.md) — Zod boundary schemas for `TotpOptions` and `TotpVerifyOptions`. The TOTP schema covers algorithm / digits / period / t0…
- [verify-many](./src/totp/verify-many.md) — Backup-aware TOTP verify. Used during rotation windows where an old and a new TOTP secret coexist — the user's authenti…
- [verify](./src/totp/verify.md) — RFC 6238 §5.2 verify with drift window. Checks the supplied code against the current step and `±window` neighbouring st…
- [format-uri](./src/uri/format-uri.md) — Build a canonical `otpauth://` URI from an `OtpUri` value object. Round-trips with `parseUri` for every valid input. Pe…
- [parse-uri](./src/uri/parse-uri.md) — Parse the `otpauth://` Key URI Format used by Google Authenticator and every compatible authenticator app. Supports bot…
- [uri.schema](./src/uri/uri.schema.md) — Zod boundary schemas for the `OtpUri` discriminated union. `OtpTotpUriSchema` and `OtpHotpUriSchema` cover the per-vari…
- [base32](./src/base32/base32.md) — <!-- begin:auto-docs --> _(no submodules)_- [drift](./src/drift/detect.md) — <!-- begin:auto-docs --> _(no submodules)_ - [detect](./src/drift/detect.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — Stable, registered error codes for `@suaveplan/otp` plus the typed `OtpError` base class. Every failure that crosses th…
- [zod-map](./src/errors/zod-map.md) — Translate Zod boundary failures into the most-specific `OtpErrorCode`. When a caller passes options that fail strict-mo…
- [hotp](./src/hotp/hotp.md) — <!-- begin:auto-docs --> _(no submodules)_ - [encode-counter](./src/hotp/encode-counter.md) — Encode an HOTP counter as the 8-byte big-endian block required by RFC 4226 §5.1. `bigint` is the canonical input type b…
- [hmac](./src/hotp/hmac.md) — Algorithm-routing HMAC façade for the OTP family. SHA-256 and SHA-512 route through `@suaveplan/crypto-hash` (the canon…
- [hotp](./src/hotp/hotp.md) — <!-- begin:auto-docs --> _(no submodules)_ - [hotp.schema](./src/hotp/hotp.schema.md) — Zod boundary schema for `HotpOptions`. Strict-mode parsing rejects unknown keys (so silent typos in caller configuratio…
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [ocra](./src/ocra/ocra.md) — <!-- begin:auto-docs --> _(no submodules)_ - [encode-challenge](./src/ocra/encode-challenge.md) — Encode the OCRA challenge into the 128-byte block required by RFC 6287 §5.1. Three challenge formats are supported: `QN…
- [mutual-auth](./src/ocra/mutual-auth.md) — RFC 6287 §7.1 mutual authentication helpers. Both sides compute OCRA over the concatenation of the client and server ch…
- [ocra](./src/ocra/ocra.md) — <!-- begin:auto-docs --> _(no submodules)_ - [ocra.schema](./src/ocra/ocra.schema.md) — Zod boundary schema for `OcraParams` — the runtime parameter bag passed alongside a suite string and a key. Optional fi…
- [suite](./src/ocra/suite.md) — Parse an OCRA suite string into a typed `OcraSuite` AST per RFC 6287 §6.1. Exposed separately so callers can validate s…
- [verify-ocra](./src/ocra/verify-ocra.md) — Constant-time verify of an OCRA response. Recomputes the response under the supplied suite, key, and params, then compa…
- [generate](./src/recovery/generate.md) — Generate Crockford-base32 recovery codes — the printable single-use fallback the user prints / writes down when first e…
- [hash](./src/recovery/hash.md) — Hash recovery codes with Argon2id for at-rest storage. Recovery codes are long-lived shared secrets — they must NOT be…
- [verify](./src/recovery/verify.md) — Verify a user-supplied recovery code against a list of stored hashes. Constant-time over the full set — every entry is…
- [totp](./src/totp/totp.md) — <!-- begin:auto-docs --> _(no submodules)_ - [totp](./src/totp/totp.md) — <!-- begin:auto-docs --> _(no submodules)_ - [totp.schema](./src/totp/totp.schema.md) — Zod boundary schemas for `TotpOptions` and `TotpVerifyOptions`. The TOTP schema covers algorithm / digits / period / t0…
- [verify-many](./src/totp/verify-many.md) — Backup-aware TOTP verify. Used during rotation windows where an old and a new TOTP secret coexist — the user's authenti…
- [verify](./src/totp/verify.md) — RFC 6238 §5.2 verify with drift window. Checks the supplied code against the current step and `±window` neighbouring st…
- [format-uri](./src/uri/format-uri.md) — Build a canonical `otpauth://` URI from an `OtpUri` value object. Round-trips with `parseUri` for every valid input. Pe…
- [parse-uri](./src/uri/parse-uri.md) — Parse the `otpauth://` Key URI Format used by Google Authenticator and every compatible authenticator app. Supports bot…
- [uri.schema](./src/uri/uri.schema.md) — Zod boundary schemas for the `OtpUri` discriminated union. `OtpTotpUriSchema` and `OtpHotpUriSchema` cover the per-vari…
## Install
```bash
bun add @suaveplan/otp
```
## Subpath exports
| Subpath | Surface |
|---|---|
| `@suaveplan/otp` | HOTP / TOTP / verify / verifyMany / detectDrift / recovery codes |
| `@suaveplan/otp/uri` | `parseUri`, `formatUri`, `OtpUri`, `OtpUriSchema` |
| `@suaveplan/otp/base32` | `base32Encode`, `base32Decode` |
| `@suaveplan/otp/ocra` | `ocra`, `parseOcraSuite`, `verifyOcra`, mutual-auth helpers |
| `@suaveplan/otp/errors` | `OTP_CODES`, `OtpError`, `OtpErrorCode` |
| `@suaveplan/otp/observability` | OTel instrument factories + recorders |
## Quick start
```ts
import { totp, verify } from "@suaveplan/otp";
import { base32Decode } from "@suaveplan/otp/base32";
import { parseUri } from "@suaveplan/otp/uri";
const u = parseUri("otpauth://totp/alice?secret=JBSWY3DPEHPK3PXP&issuer=Example");
const secret = base32Decode(u.secret);
const code = totp(secret);
const r = verify(code, secret);
// r === { valid: true, delta: 0 }
```
## Modules
- [`base32/base32.md`](src/base32/base32.md) — RFC 4648 §6 base32.
- [`hotp/hotp.md`](src/hotp/hotp.md) — RFC 4226 HOTP.
- [`hotp/encode-counter.md`](src/hotp/encode-counter.md) — 8-byte big-endian.
- [`hotp/hmac.md`](src/hotp/hmac.md) — algorithm-routing HMAC.
- [`hotp/hotp.schema.md`](src/hotp/hotp.schema.md) — Zod boundary.
- [`totp/totp.md`](src/totp/totp.md) — RFC 6238 TOTP.
- [`totp/verify.md`](src/totp/verify.md) — drift-windowed verify.
- [`totp/verify-many.md`](src/totp/verify-many.md) — backup-aware verify.
- [`totp/totp.schema.md`](src/totp/totp.schema.md) — Zod boundary.
- [`uri/parse-uri.md`](src/uri/parse-uri.md) — `otpauth://` parser.
- [`uri/format-uri.md`](src/uri/format-uri.md) — `otpauth://` builder.
- [`uri/uri.schema.md`](src/uri/uri.schema.md) — Zod boundary.
- [`ocra/ocra.md`](src/ocra/ocra.md) — RFC 6287 OCRA.
- [`ocra/suite.md`](src/ocra/suite.md) — suite-string parser.
- [`ocra/encode-challenge.md`](src/ocra/encode-challenge.md) — challenge block.
- [`ocra/verify-ocra.md`](src/ocra/verify-ocra.md) — constant-time verify.
- [`ocra/mutual-auth.md`](src/ocra/mutual-auth.md) — RFC §7.1 mutual auth.
- [`ocra/ocra.schema.md`](src/ocra/ocra.schema.md) — Zod boundary.
- [`recovery/generate.md`](src/recovery/generate.md) — Crockford codes.
- [`recovery/hash.md`](src/recovery/hash.md) — Argon2id-hashed at-rest form.
- [`recovery/verify.md`](src/recovery/verify.md) — constant-time verify.
- [`drift/detect.md`](src/drift/detect.md) — clock-drift detector.
- [`observability/metrics.md`](src/observability/metrics.md) — OTel.
- [`errors/codes.md`](src/errors/codes.md) — `OTP_CODES` / `OtpError`.
- [`errors/zod-map.md`](src/errors/zod-map.md) — Zod → code mapper.
## Standards
- RFC 4226 — HOTP (counter-based).
- RFC 6238 — TOTP (time-based).
- RFC 6287 — OCRA (challenge-response).
- RFC 4648 §6 — Base32.
- Google Authenticator Key URI Format — `otpauth://` scheme.
## License
MIT

View File

@@ -0,0 +1,58 @@
# `base32/base32`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
RFC 4648 §6 base32 encode + decode. The OTP profile defaults to **unpadded**
output to match the Google Authenticator Key URI Format (`secret=...` is
unpadded in the wild); spec-strict consumers can opt in via `padding: true`.
Decode is case-insensitive and tolerates whitespace between groups so QR-code
URLs that wrap long secrets round-trip cleanly.
## Features
- `base32Encode(bytes, opts?)` — bit-packed five-at-a-time encode.
- `base32Decode(input)` — single-pass reverse-table decode.
- 32-character alphabet `A–Z2–7` per RFC 4648 §6.
- Lower-case alias only for letters (digits 2–7 are case-invariant).
- `OTP.BASE32_INVALID` raised on non-alphabet character.
## Usage: Basic Example
```ts
import { base32Decode, base32Encode } from "@suaveplan/otp/base32";
const bytes = base32Decode("JBSWY3DPEHPK3PXP");
const back = base32Encode(bytes);
```
## Usage: Advanced Example
```ts
import { base32Encode } from "@suaveplan/otp/base32";
// Spec-strict consumer that needs `=` padding.
const padded = base32Encode(new Uint8Array([0x66, 0x6f, 0x6f]), {
padding: true,
});
// padded === "MZXW6==="
```
## API Reference
- `base32Encode(bytes: Uint8Array, opts?: { padding?: boolean }): string`
— returns the base32 form. `padding` defaults to `false`.
- `base32Decode(input: string): Uint8Array` — decodes padded or unpadded
input; whitespace is stripped; case-insensitive; non-alphabet characters
raise `OtpError(OTP.BASE32_INVALID)` with the offending position.
## Implementation Notes
The decode table is a 128-entry `Int8Array` indexed by char-code. Letters are
aliased to their lower-case slots; digits are not aliased because '2'+32 ='R'
which would otherwise overwrite the letter slot for `R`. The encoder emits
unpadded output by default to match the prevailing Google Authenticator wire
format; `padding: true` makes the output multiple-of-eight characters and
RFC-4648-compliant for spec-strict consumers.

View File

@@ -0,0 +1,63 @@
# `drift/detect`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Search outside the regular verify window for clock drift. Callers use this
to render UI hints like _"your device clock is 47 seconds fast"_ — the
verify path with `window=1` would simply reject such a code, leaving the
user with no idea why. `detectDrift` sweeps a wider range and returns both
the signed step delta and the inferred clock offset in milliseconds.
## Features
- Default `±60` steps (= ±30 minutes at the default `period=30`).
- Returns `{ delta, suggestedClockOffsetMs }` discriminated union.
- Constant-time across the sweep range — same defence as `verify`.
- Honours the same TOTP option bag as `verify` (algorithm / digits / period
/ t0 / clock).
## Usage: Basic Example
```ts
import { detectDrift } from "@suaveplan/otp";
const r = detectDrift(code, secret);
if (r.delta !== null && Math.abs(r.delta) > 1) {
show("Your device clock is " + r.suggestedClockOffsetMs + " ms off.");
}
```
## Usage: Advanced Example
```ts
import { detectDrift } from "@suaveplan/otp";
const r = detectDrift(code, secret, {
maxSteps: 120, // sweep ±60 minutes at period=30
clock: () => Date.now(),
});
```
## API Reference
- `detectDrift(code, secret, opts?): DriftResult` — both `delta` and
`suggestedClockOffsetMs` are `null` when no candidate matched.
## Implementation Notes
The negative-step guard is critical near time origin (t < `period * maxSteps`)
because BigInt arithmetic does not raise on underflow; the guard lets the
sweep skip unreachable negative steps without affecting wall-clock timing
of the loop. The inferred `suggestedClockOffsetMs` is `delta * period * 1000`
and is signed so the UI can render direction.
This module is intentionally separate from `verify` because the two have
different threat models: `verify` is on the hot path of every login and
must be tightly bounded, while `detectDrift` runs only when `verify`
already failed, sweeps a wider range, and exists to render a helpful
error message rather than to authenticate. Callers should never accept a
code on the basis of `detectDrift` alone — its purpose is diagnostic, not
gating.

View File

@@ -0,0 +1,61 @@
# `errors/codes`
## Purpose
Stable, registered error codes for `@suaveplan/otp` plus the typed `OtpError`
base class. Every failure that crosses the public surface — invalid options,
empty secret, out-of-range window, malformed Key URI, base32 alphabet
violation, OCRA suite-string parse failure, recovery-code shape mismatch —
funnels through one of the twelve codes here so callers can branch on
`.code` rather than parse messages.
## Features
- Frozen registry of twelve codes via `defineCodes("OTP", [...])`.
- Literal-typed `OtpErrorCode` union derived from the registry.
- `OtpError extends SuaveplanError<OtpErrorCode>` with `cause` chain support.
- `otpError(code, message, opts?)` helper that prefixes the message with the
stable code so message-matching consumers (`expect(...).toThrow(/CODE/)`,
log scrapers) get a stable substring without inspecting `.code`.
## Usage: Basic Example
```ts
import { OTP_CODES, otpError } from "@suaveplan/otp/errors";
throw otpError(OTP_CODES.INVALID_DIGITS, "digits must be 6, 7, or 8");
```
## Usage: Advanced Example
```ts
import { OtpError, OTP_CODES } from "@suaveplan/otp/errors";
try {
// … OTP operation …
} catch (e) {
if (e instanceof OtpError && e.code === OTP_CODES.WINDOW_OUT_OF_RANGE) {
// route the user to a "shorten verify window" UX.
}
throw e;
}
```
## API Reference
- `OTP_CODES` — the frozen `Record<key, "OTP.<key>">` registry.
- `OtpErrorCode` — literal-union type of every registered value.
- `OtpError(code, message, options?)` — concrete error class. `options.cause`
is preserved on the cause chain when an `Error` instance is supplied.
- `otpError(code, message, options?)` — helper returning an `OtpError` whose
message has been prefixed with the stable code; non-`Error` causes are
silently dropped to avoid leaking arbitrary objects through the chain.
## Implementation Notes
The `otpError` helper deliberately drops non-`Error` causes rather than
forwarding them, because the cause chain is consumed by `@suaveplan/error`'s
serialiser which assumes `Error`-shaped values. Stripping unknown causes at
the boundary keeps wire-format invariants intact. The twelve-code registry is
the public contract — adding a new code requires an OpenSpec change because
removing or renaming one is a breaking change for downstream callers.

View File

@@ -0,0 +1,56 @@
# `errors/zod-map`
## Purpose
Translate Zod boundary failures into the most-specific `OtpErrorCode`. When a
caller passes options that fail strict-mode parsing the first failed-field's
path is mapped to a dedicated code (`INVALID_DIGITS`, `WINDOW_OUT_OF_RANGE`,
`INVALID_PERIOD`) instead of a generic `INVALID_OPTIONS`. This keeps error
codes meaningful at the boundary without duplicating range checks inside
each entry-point body.
## Features
- `mapZodToOtp(err, fallback?)` — pure mapping over `ZodError.issues[0].path`.
- `rethrowAsOtp(err, message, fallback?)` — convenience that throws the
mapped error with an attached cause.
- Stable mapping table covering every code that has a Zod-level rule:
`digits → INVALID_DIGITS`, `window → WINDOW_OUT_OF_RANGE`,
`period | t0 → INVALID_PERIOD`. Any other path falls back.
## Usage: Basic Example
```ts
import { TotpVerifyOptionsSchema } from "@suaveplan/otp";
import { rethrowAsOtp } from "@suaveplan/otp/errors";
try {
TotpVerifyOptionsSchema.parse(opts);
} catch (e) {
rethrowAsOtp(e, "invalid verify options");
}
```
## Usage: Advanced Example
```ts
import { mapZodToOtp, OTP_CODES } from "@suaveplan/otp/errors";
import { ZodError } from "zod";
const code = mapZodToOtp(zodErr as ZodError, OTP_CODES.URI_MALFORMED);
```
## API Reference
- `mapZodToOtp(err, fallback?)` — returns the mapped `OtpErrorCode`. If the
issues list is empty the `fallback` (default `INVALID_OPTIONS`) is used.
- `rethrowAsOtp(err, message, fallback?)` — never returns; throws an
`OtpError`. Plain `Error` causes are preserved on the chain; non-`Error`
values are dropped to keep the chain serialisable.
## Implementation Notes
The mapper inspects only `issues[0]` for simplicity. Zod typically reports
multiple issues but the first one is the most actionable; the rest are
chained as the underlying cause and remain available to the caller. Adding a
new mapped path is a one-line change here plus a code in the registry.

View File

@@ -0,0 +1,58 @@
# `hotp/encode-counter`
## Purpose
Encode an HOTP counter as the 8-byte big-endian block required by RFC 4226
§5.1. `bigint` is the canonical input type because `Number` loses precision
past `2^53` and the RFC permits counters up to `2^64 − 1`. Shared by HOTP
itself, by OCRA's optional `C` directive, and by OCRA's `T` timestamp block
(which is also written as an 8-byte big-endian integer per RFC 6287 §5.1).
## Features
- BigInt input — no precision loss across the full 0..2^64-1 range.
- Range guard: negative counters and counters past `2^64-1` raise
`OtpError(OTP.INVALID_OPTIONS)`.
- Pure: returns a freshly-allocated `Uint8Array(8)` per call.
## Usage: Basic Example
```ts
import { encodeCounter } from "@suaveplan/otp";
const block = encodeCounter(1n);
// block === Uint8Array([0, 0, 0, 0, 0, 0, 0, 1])
```
## Usage: Advanced Example
```ts
import { encodeCounter } from "@suaveplan/otp";
// Counter past Number.MAX_SAFE_INTEGER round-trips losslessly.
const block = encodeCounter((1n << 53n) + 1n);
// `block` is exactly 8 bytes; the high bytes carry the counter's high bits.
```
## API Reference
- `encodeCounter(counter: bigint): Uint8Array` — returns an 8-byte
big-endian block. Negative input raises `OtpError(OTP.INVALID_OPTIONS)`;
values >= 2^64 raise the same code.
## Implementation Notes
The function loops from byte index 7 back to 0 shifting `counter` right by 8
bits each step. The mask `0xffn` keeps the operation inside the `bigint`
domain so no implicit `Number` conversion can lose precision. The function
is the single source of truth for big-endian counter framing — both HOTP and
OCRA route through it so any future endianness change propagates atomically.
The upper-bound check `counter > MAX_U64` catches the niche case where a
caller passes a programmatically-generated counter past the RFC 4226
limit. Without the check the encoder would silently truncate the high
bits and produce two distinct counters that hash to the same byte block,
which would silently break the per-counter uniqueness invariant HOTP
relies on. The lower-bound check on negative counters covers the
symmetric case for callers who derive counters via `BigInt()` arithmetic
that may go negative under bug conditions.

View File

@@ -0,0 +1,56 @@
# `hotp/hmac`
## Purpose
Algorithm-routing HMAC façade for the OTP family. SHA-256 and SHA-512 route
through `@suaveplan/crypto-hash` (the canonical platform HMAC); SHA-1 routes
through `@noble/hashes/sha1` directly because crypto-hash deliberately does
not expose SHA-1 — it is mandated only by legacy OTP RFCs and must not be
presented as a general-purpose option elsewhere in the platform.
## Features
- Three algorithms: SHA-1, SHA-256, SHA-512.
- SHA-1 isolated to the OTP family — no leakage into the wider crypto API.
- Returns a freshly-allocated `Uint8Array` of the algorithm's digest size.
## Usage: Basic Example
```ts
import { hmacFor } from "@suaveplan/otp";
const tag = hmacFor("SHA1", key, message);
```
## Usage: Advanced Example
```ts
import { hmacFor } from "@suaveplan/otp";
// Build the OCRA HMAC input directly — useful for KAT-driven test fixtures.
const tag = hmacFor("SHA256", key, suiteAsciiPlusZeroPlusDataInput);
```
## API Reference
- `hmacFor(algorithm: HotpAlgorithm, key: Uint8Array, data: Uint8Array): Uint8Array`
— returns the HMAC tag.
- `HotpAlgorithm = "SHA1" | "SHA256" | "SHA512"`.
## Implementation Notes
Routing inside this module rather than at the call site keeps the SHA-1
exception local to OTP. The crypto-hash boundary uses dashed names
(`"SHA-256"`); this module accepts the OTP-RFC-style undashed names
(`"SHA256"`) and translates internally. Adding a new algorithm here is the
only change required — `hotp.ts`, `totp.ts`, and `ocra.ts` all consume this
single dispatch point.
The SHA-1 routing decision is intentional: `@suaveplan/crypto-hash` is the
canonical platform façade for hashing across the project, and exposing
SHA-1 there would invite consumers to use it for non-OTP purposes. By
isolating the SHA-1 dependency to the OTP module, the rest of the platform
keeps a no-SHA-1 posture without breaking RFC 4226 / 6238 / 6287
compliance. The noble HMAC primitive used here is the same implementation
crypto-hash routes through under the hood for SHA-256 / SHA-512, so the
two paths behave identically apart from the chosen hash.

View File

@@ -0,0 +1,79 @@
# `hotp/hotp`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - hotp.schema — Zod boundary schema for `HotpOptions`…
- [hotp.schema](./hotp.schema.md) — Zod boundary schema for `HotpOptions`. Strict-mode parsing rejects unknown keys (so silent typos in caller configuratio…
- hotp.schema — Zod boundary schema for `HotpOptions`…
- [hotp.schema](./hotp.schema.md) — Zod boundary schema for `HotpOptions`. Strict-mode parsing rejects unknown keys (so silent typos in caller configuratio…
- hotp.schema — Zod boundary schema for `HotpOptions`…
- [hotp.schema](./hotp.schema.md) — Zod boundary schema for `HotpOptions`. Strict-mode parsing rejects unknown keys (so silent typos in caller configuratio…
- hotp.schema — Zod boundary schema for `HotpOptions`…
- [hotp.schema](./hotp.schema.md) — Zod boundary schema for `HotpOptions`. Strict-mode parsing rejects unknown keys (so silent typos in caller configuratio…
- [hotp.schema](./hotp.schema.md) — Zod boundary schema for `HotpOptions`. Strict-mode parsing rejects unknown keys (so silent typos in caller configuratio…
## Purpose
RFC 4226 HOTP — HMAC-based one-time password. Computes
`value = DT(HMAC-H(K, C)) mod 10^digits` and returns a `digits`-length
zero-padded decimal string. Counter is `bigint` (RFC 4226 §5.1 mandates an
8-byte big-endian counter; `Number` loses precision past `2^53`).
## Features
- RFC 4226 dynamic truncation per §5.3 step 2.
- SHA-1 default; SHA-256 / SHA-512 widening per RFC 6238 §1.2.
- 6 / 7 / 8-digit output.
- Zod-validated options at the boundary; `INVALID_DIGITS` mapped via the
zod-map for fields that fail enum/literal-union checks.
- Empty-secret rejection with `OTP.INVALID_SECRET`.
## Usage: Basic Example
```ts
import { hotp } from "@suaveplan/otp";
const secret = new TextEncoder().encode("12345678901234567890");
const code = hotp(secret, 0n);
// code === "755224" (RFC 4226 Appendix D vector)
```
## Usage: Advanced Example
```ts
import { hotp } from "@suaveplan/otp";
const code = hotp(secret, (1n << 53n) + 1n, {
algorithm: "SHA512",
digits: 8,
});
// 8-digit SHA-512 HOTP at a counter past Number.MAX_SAFE_INTEGER
```
## API Reference
- `hotp(secret: Uint8Array, counter: bigint, opts?: HotpOptions): string`
— returns the `digits`-length decimal code.
- `HotpOptions` — `{ algorithm?: "SHA1" | "SHA256" | "SHA512"; digits?: 6 | 7 | 8 }`.
- Throws `OtpError(OTP.INVALID_OPTIONS | OTP.INVALID_DIGITS)` on Zod
failures and `OtpError(OTP.INVALID_SECRET)` on empty `secret`.
## Implementation Notes
Counter encoding lives in `encode-counter.ts` and is shared with OCRA's
`C` directive. Dynamic truncation extracts a 31-bit value from the HMAC by
masking the high bit to avoid sign-extension surprises across HMAC
implementations. Modular reduction uses a precomputed `POW10` table indexed
by `digits` rather than `Math.pow` so the modulus is an exact integer.
The Zod schema rejects unknown keys at the strict-mode boundary so callers
who construct options from JSON or external configuration cannot smuggle
typo'd fields past the API. Field-level Zod failures flow through
`mapZodToOtp` so a bad `digits` value surfaces as `OTP.INVALID_DIGITS`
rather than the generic `OTP.INVALID_OPTIONS`. The empty-secret guard is
runtime-only because Zod's bytes type does not constrain length.

View File

@@ -0,0 +1,61 @@
# `hotp/hotp.schema`
## Purpose
Zod boundary schema for `HotpOptions`. Strict-mode parsing rejects unknown
keys (so silent typos in caller configuration surface immediately) and
narrows `algorithm` and `digits` to their literal-union values. Mismatches
flow through `mapZodToOtp` so the boundary error carries a specific code
(`INVALID_DIGITS` for the `digits` field, `INVALID_OPTIONS` otherwise).
## Features
- `algorithm` enum: `"SHA1" | "SHA256" | "SHA512"`.
- `digits` literal union: `6 | 7 | 8`.
- Strict-mode object — unknown keys are rejected.
## Usage: Basic Example
```ts
import { HotpOptionsSchema } from "@suaveplan/otp";
HotpOptionsSchema.parse({ algorithm: "SHA1", digits: 6 });
```
## Usage: Advanced Example
```ts
import { HotpOptionsSchema } from "@suaveplan/otp";
// Programmatic option builder — feed parsed result directly into hotp().
const opts = HotpOptionsSchema.parse(JSON.parse(userJson));
```
## API Reference
- `HotpOptionsSchema` — Zod object schema with `.strict()`.
- `HotpOptionsInput` — type alias for the parsed shape.
## Implementation Notes
The strict-mode check is the second-line defence behind TypeScript's compile
checks: callers using `as never` or constructing options from JSON can still
slip values past the type system. Pinning the schema here keeps the runtime
contract aligned with the type-level contract.
The schema is intentionally narrow — it covers only the surface that
HOTP itself needs. Callers building their own option bags around HOTP
(e.g. a vault item type that wraps HOTP with extra metadata) should
extend this schema using Zod's `.extend()` or compose it via a discriminated
union, rather than relaxing the strict-mode setting here. Keeping the base
schema tight prevents accidental option-shape drift across the OTP family
and means every caller benefits from the same validation guarantees.
The `algorithm` enum covers exactly the three hashes RFC 4226 / 6238 §1.2
allow for OTP. SHA-3 family hashes are not part of any OTP RFC and would
be rejected at this boundary even though `@noble/hashes` provides them.
The `digits` literal union mirrors RFC 4226 §5.3 (`6`) plus the RFC 6238
§1.2 widening (`7`, `8`). RFC 6287 OCRA additionally permits digits=4,
which is handled in the OCRA suite parser rather than here because the
HOTP primitive does not need to expose it directly.

View File

@@ -0,0 +1,70 @@
# `observability/metrics`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry instrument factory + recorders for the OTP family. Emits two
duration histograms (`generate` / `verify`), one outcome counter, and one
error counter. All instruments are lazily resolved via `metrics.getMeter`
so a host that swaps the global `MeterProvider` after import (e.g. test
fixtures) still observes emissions against the intended provider.
## Features
- Histograms: `otp.generate_duration_ms{type,algorithm}`,
`otp.verify_duration_ms{type,algorithm}`.
- Counter: `otp.verify_outcome_total{outcome,delta_bucket}` — `outcome` ∈
`valid | invalid`, `delta_bucket` ∈ `0 | +-1 | +-2..N | n/a` (low-cardinality).
- Counter: `otp.errors_total{code}` — bucketed by stable error code.
- `resetOtpMetricsForTesting()` clears the instrument cache.
## Usage: Basic Example
```ts
import { recordVerify } from "@suaveplan/otp/observability";
const start = performance.now();
const r = verify(code, secret);
recordVerify("totp", "SHA1", performance.now() - start);
```
## Usage: Advanced Example
```ts
import {
recordGenerate,
recordOutcome,
resetOtpMetricsForTesting,
} from "@suaveplan/otp/observability";
beforeAll(() => {
metrics.setGlobalMeterProvider(testProvider);
resetOtpMetricsForTesting();
});
```
## API Reference
- `recordGenerate(kind, algorithm, ms)` — records duration histogram.
- `recordVerify(kind, algorithm, ms)` — same for verify.
- `recordOutcome(outcome, delta)` — increments outcome counter; delta is
bucketed via `deltaBucket`.
- `recordOtpError(code)` — increments error counter.
- `deltaBucket(delta)` — pure mapping helper.
- `resetOtpMetricsForTesting()` — clears the instrument cache.
## Implementation Notes
The `delta_bucket` enum has cardinality 4 to keep dashboards cheap;
emitting raw `delta` values would explode metric storage. The cache is
module-level and lazy so the first call after an `import` resolves the
`Meter` against whatever `MeterProvider` is current at that moment.
The four instruments are intentionally decoupled from the OTP code paths
so that suppressing observability (e.g. in a security-restricted
environment) is a one-line change at the call site rather than a deep
refactor. The recorder helpers do nothing measurable when the global
`MeterProvider` is the OpenTelemetry no-op default, which keeps the cost
near zero in environments that do not opt in to metrics.

View File

@@ -0,0 +1,56 @@
# `ocra/encode-challenge`
## Purpose
Encode the OCRA challenge into the 128-byte block required by RFC 6287 §5.1.
Three challenge formats are supported: `QN` (decimal → hex bytes),
`QA` (printable ASCII), `QH` (hex). The block is always 128 bytes, with the
challenge left-justified and the remainder zero-padded.
## Features
- `QN`: decimal → BigInt → hex (right-padded to even length) → byte stream.
- `QA`: ASCII bytes copied directly.
- `QH`: hex pairs decoded directly.
- Length validated against the suite's declared maximum.
- Boundary errors raise `OtpError(OTP.OCRA_PARAMS_INVALID)`.
## Usage: Basic Example
```ts
import { encodeChallenge } from "@suaveplan/otp/ocra";
const block = encodeChallenge({ format: "N", length: 8 }, "00000000");
// Always 128 bytes; first byte is 0x00.
```
## Usage: Advanced Example
```ts
import { encodeChallenge } from "@suaveplan/otp/ocra";
// Hex challenge — useful for KAT-driven test fixtures that mirror RFC 6287
// Appendix C tables.
const block = encodeChallenge({ format: "H", length: 8 }, "deadbeef");
```
## API Reference
- `encodeChallenge(descriptor, value): Uint8Array` — returns the 128-byte
block. Raises `OtpError(OTP.OCRA_PARAMS_INVALID)` for length mismatch,
non-decimal input under `QN`, or odd-length / non-hex input under `QH`.
## Implementation Notes
`QN` decodes via BigInt and re-emits hex with right-padding so an odd-length
hex representation (e.g. `"1"` → `"10"`) keeps the high nibble in the high
position of the first byte. This matches the RFC 6287 reference
implementation — getting the padding direction wrong shifts every byte by
4 bits and silently breaks every Appendix C vector.
The 128-byte fixed width is mandated by the RFC and is independent of the
declared `Q` length — the declared length only constrains the maximum
input the caller may pass. The remaining bytes are always zero-padded so
two different valid challenges of different lengths produce different
HMAC inputs (no length-extension ambiguity). Callers MUST NOT trim the
returned block before HMAC; doing so would silently break the protocol.

View File

@@ -0,0 +1,71 @@
# `ocra/mutual-auth`
## Purpose
RFC 6287 §7.1 mutual authentication helpers. Both sides compute OCRA over
the concatenation of the client and server challenges, with the order
flipped between the two directions (`Qc || Qs` for the server's proof,
`Qs || Qc` for the client's). The module exposes pure functions plus
stateful `OcraMutualAuthClient` / `OcraMutualAuthServer` wrappers so
consumers don't reinvent the concatenation order.
## Features
- `serverProof` / `verifyServerProof` — the server's response and the
client-side check.
- `clientProof` / `verifyClientProof` — the client's response and the
server-side check.
- `OcraMutualAuthClient` / `OcraMutualAuthServer` — stateful wrappers that
hold the suites + key + own challenge and expose narrow methods.
- Optional `extra` parameter for suites that include `C` (counter), `T`
(timestamp), `Snnn` (session info), or `PSHA{...}` (password hash).
## Usage: Basic Example
```ts
import { OcraMutualAuthClient, OcraMutualAuthServer } from "@suaveplan/otp/ocra";
const server = new OcraMutualAuthServer(suites, key, "Qs");
const client = new OcraMutualAuthClient(suites, key, "Qc");
const Rs = server.respond("Qc");
const Rc = client.respond("Qs", Rs);
const ok = server.verify("Qc", Rc);
```
## Usage: Advanced Example
```ts
import { clientProof, serverProof } from "@suaveplan/otp/ocra";
// Counter-bound mutual auth for a session that needs replay protection.
const Rs = serverProof({
suites,
key,
clientChallenge: "Qc",
serverChallenge: "Qs",
extra: { counter: 7n },
});
```
## API Reference
- `serverProof(input): string`
- `verifyServerProof(input): boolean`
- `clientProof(input): string` — verifies server proof first; throws
`OtpError(OTP.VERIFY_FAILED)` on mismatch.
- `verifyClientProof(input): boolean`
- `OcraMutualAuthClient` / `OcraMutualAuthServer` classes.
## Implementation Notes
The challenge concatenation order is the most error-prone part of RFC 6287
§7.1 — `Rs` MUST be over `Qc || Qs` and `Rc` MUST be over `Qs || Qc`.
Encoding the order in named functions and class methods keeps callers from
having to reread the RFC every time they wire a new flow.
The class wrappers add a small amount of state (own challenge, own suite,
own key) so consumers can keep the per-session context outside the call
graph. The `extra` parameter lets the same flow work with counter-bound,
timestamp-bound, password-hash-bound, or session-info-bound suites — the
mutual-auth shape is the same, only the underlying OCRA inputs differ.

View File

@@ -0,0 +1,81 @@
# `ocra/ocra`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - ocra.schema — Zod boundary schema for `OcraParams`……
- [ocra.schema](./ocra.schema.md) — Zod boundary schema for `OcraParams` — the runtime parameter bag passed alongside a suite string and a key. Optional fi…
- [suite](./suite.md) — Parse an OCRA suite string into a typed `OcraSuite` AST per RFC 6287 §6.1. Exposed separately so callers can validate s…
- [verify-ocra](./verify-ocra.md) — Constant-time verify of an OCRA response. Recomputes the response under the supplied suite, key, and params, then compa…
- ocra.schema — Zod boundary schema for `OcraParams`……
- [ocra.schema](./ocra.schema.md) — Zod boundary schema for `OcraParams` — the runtime parameter bag passed alongside a suite string and a key. Optional fi…
- [suite](./suite.md) — Parse an OCRA suite string into a typed `OcraSuite` AST per RFC 6287 §6.1. Exposed separately so callers can validate s…
- [verify-ocra](./verify-ocra.md) — Constant-time verify of an OCRA response. Recomputes the response under the supplied suite, key, and params, then compa…
- ocra.schema — Zod boundary schema for `OcraParams`……
- [ocra.schema](./ocra.schema.md) — Zod boundary schema for `OcraParams` — the runtime parameter bag passed alongside a suite string and a key. Optional fi…
- [suite](./suite.md) — Parse an OCRA suite string into a typed `OcraSuite` AST per RFC 6287 §6.1. Exposed separately so callers can validate s…
- [verify-ocra](./verify-ocra.md) — Constant-time verify of an OCRA response. Recomputes the response under the supplied suite, key, and params, then compa…
- ocra.schema — Zod boundary schema for `OcraParams`…
- [ocra.schema](./ocra.schema.md) — Zod boundary schema for `OcraParams` — the runtime parameter bag passed alongside a suite string and a key. Optional fi…
- [suite](./suite.md) — Parse an OCRA suite string into a typed `OcraSuite` AST per RFC 6287 §6.1. Exposed separately so callers can validate s…
- [verify-ocra](./verify-ocra.md) — Constant-time verify of an OCRA response. Recomputes the response under the supplied suite, key, and params, then compa…
- [ocra.schema](./ocra.schema.md) — Zod boundary schema for `OcraParams` — the runtime parameter bag passed alongside a suite string and a key. Optional fi…
- [suite](./suite.md) — Parse an OCRA suite string into a typed `OcraSuite` AST per RFC 6287 §6.1. Exposed separately so callers can validate s…
- [verify-ocra](./verify-ocra.md) — Constant-time verify of an OCRA response. Recomputes the response under the supplied suite, key, and params, then compa…
## Purpose
RFC 6287 OCRA — OATH Challenge-Response Algorithm. Supports the full
`OCRA-1:HOTP-{H}-{N}:[C-]Q{A|N|H}NN[-PSHA{1,256,512}][-Snnn][-T{n}{S|M|H}]`
suite-string grammar. Validated against RFC 6287 Appendix C.1 reference
vectors so call-site implementations have a known-good baseline.
## Features
- All hash algorithms: SHA-1 / SHA-256 / SHA-512.
- Digits 4 / 6 / 7 / 8 (RFC 6287 §6.2 widens HOTP's 6/7/8 to also include 4).
- Optional counter (`C` directive), password hash (`PSHA{1,256,512}`),
session info (`Snnn`), and timestamp (`T{n}{S|M|H}`) blocks.
- Three challenge formats: `QN` (decimal), `QA` (printable ASCII), `QH` (hex).
- Constant-time verify via `verifyOcra`.
## Usage: Basic Example
```ts
import { ocra } from "@suaveplan/otp/ocra";
const r = ocra("OCRA-1:HOTP-SHA1-6:QN08", key, { challenge: "12345678" });
```
## Usage: Advanced Example
```ts
import { ocra } from "@suaveplan/otp/ocra";
// Counter + password-hashed plus timestamp-bound suite.
const r = ocra("OCRA-1:HOTP-SHA256-8:C-QN08-PSHA256-T1M", key, {
counter: 17n,
challenge: "12345678",
passwordHash: sha256(passwordBytes),
timestampMs: Date.now(),
});
```
## API Reference
- `ocra(suite, key, params): string` — primary computation API.
- `buildOcraMessage(parsed, params): Uint8Array` — exposed for benchmarks
and KAT harnesses that want to inspect the pre-HMAC byte stream.
- `OcraDigits` — alias for the digit literal-union shared with HOTP.
## Implementation Notes
The HMAC input layout follows RFC 6287 §5.1 exactly: suite ASCII, NUL
separator, then the directive blocks in fixed order (counter, challenge,
password hash, session info, timestamp). Each block is omitted if the suite
does not declare it; callers supplying an unexpected block are silently
ignored — the suite is the source of truth.

View File

@@ -0,0 +1,57 @@
# `ocra/ocra.schema`
## Purpose
Zod boundary schema for `OcraParams` — the runtime parameter bag passed
alongside a suite string and a key. Optional fields (`counter`, `passwordHash`,
`sessionInfo`, `timestampMs`) are validated when present so callers cannot
silently pass through malformed values; the actual presence/absence rule is
enforced by `buildOcraMessage` against the parsed suite directives.
## Features
- Strict-mode object — unknown keys rejected.
- `counter` typed as `bigint` non-negative.
- `passwordHash` and `sessionInfo` typed as `Uint8Array`.
- `timestampMs` typed as non-negative integer.
## Usage: Basic Example
```ts
import { OcraParamsSchema } from "@suaveplan/otp/ocra";
OcraParamsSchema.parse({ challenge: "12345678", counter: 1n });
```
## Usage: Advanced Example
```ts
import { OcraParamsSchema } from "@suaveplan/otp/ocra";
// Validate before passing through to ocra() — useful at API boundaries.
const safe = OcraParamsSchema.safeParse(req.body);
if (!safe.success) return res.status(400).json(safe.error);
```
## API Reference
- `OcraParamsSchema` — strict Zod object covering all five `OcraParams`
fields.
## Implementation Notes
The schema is intentionally permissive about WHICH optional fields are
present — that's the suite parser's responsibility. Validating the shape
of values that ARE present catches cases like `counter: -1n` or
`timestampMs: -5` that would otherwise produce nonsensical OCRA outputs
without raising.
The schema lives in its own module so consumers that only need validation
(e.g. an API gateway that records OCRA-bound transactions) can import it
without pulling in the OCRA codec. The `Uint8Array` instance check uses
Zod's `z.instanceof` so tagged byte arrays round-trip cleanly. Callers
passing `ArrayBuffer` instead must convert at the boundary; the schema
deliberately does not accept arbitrary buffer shapes to avoid silent
slice / endianness surprises. The `counter` and `timestampMs` lower-bounds
are non-negative because OCRA inherits the unsigned counter rules from
RFC 4226 §5.1.

View File

@@ -0,0 +1,55 @@
# `ocra/suite`
## Purpose
Parse an OCRA suite string into a typed `OcraSuite` AST per RFC 6287 §6.1.
Exposed separately so callers can validate suite strings before using them
(e.g. on enrollment) without paying the cost of an HMAC computation.
## Features
- Three colon-separated segments: `OCRA-1`, `CryptoFunction`, `DataInput`.
- CryptoFunction: `HOTP-{SHA1|SHA256|SHA512}-{4|6|7|8}`.
- DataInput directives: `C`, `Q{A|N|H}NN`, `PSHA{1,256,512}`, `Snnn`,
`T{n}{S|M|H}`.
- Strict grammar — every malformed segment raises
`OtpError(OTP.OCRA_SUITE_INVALID)` with a descriptive message.
## Usage: Basic Example
```ts
import { parseOcraSuite } from "@suaveplan/otp/ocra";
const s = parseOcraSuite("OCRA-1:HOTP-SHA1-6:QN08");
// s.cryptoFunction.hash === "SHA1"
```
## Usage: Advanced Example
```ts
import { parseOcraSuite } from "@suaveplan/otp/ocra";
const s = parseOcraSuite("OCRA-1:HOTP-SHA256-8:C-QN10-PSHA256-S128-T30S");
// every directive surfaces as a typed property on s.dataInput.
```
## API Reference
- `parseOcraSuite(suite: string): OcraSuite` — returns the parsed AST.
Throws `OtpError(OTP.OCRA_SUITE_INVALID)` on any grammar error.
## Implementation Notes
The `Q{A|N|H}NN` directive constrains length to `4..64` per RFC 6287 §6.1.
The `Snnn` directive constrains length to `1..512`. Both bounds are
enforced here at parse time so downstream code paths can trust the
constraints. The suite parser is the single source of truth for grammar —
adding a new directive requires updating only this module.
Because suite strings are configuration data — they end up in databases,
provisioning URIs, and KAT vector files — parsing must be tolerant of
spaces between directives is NOT allowed (the RFC grammar uses `-` as
the directive separator and a literal space would silently produce a
different suite). The parser treats any unrecognised directive as an
error rather than silently ignoring it, which is the right default when
the suite is treated as a security parameter.

View File

@@ -0,0 +1,60 @@
# `ocra/verify-ocra`
## Purpose
Constant-time verify of an OCRA response. Recomputes the response under the
supplied suite, key, and params, then compares to the candidate using the
same constant-time string comparator HOTP / TOTP verify uses. Returns a
plain boolean — failure modes (bad params, malformed suite) propagate as
the underlying `OtpError`.
## Features
- Constant-time compare via UTF-8 byte equality.
- Routes through `ocra` so any suite-string failure flows naturally as
`OCRA_SUITE_INVALID`.
- Single-step verification — the wider mutual-auth flow lives in the
`mutual-auth` module which composes this primitive.
## Usage: Basic Example
```ts
import { verifyOcra } from "@suaveplan/otp/ocra";
const ok = verifyOcra(response, suite, key, { challenge });
```
## Usage: Advanced Example
```ts
import { verifyOcra } from "@suaveplan/otp/ocra";
// Server-side challenge / response verification for an inbound request.
function handle(req) {
const params = { challenge: req.body.challenge, counter: BigInt(req.body.counter) };
if (!verifyOcra(req.body.response, suite, key, params)) {
return reject();
}
}
```
## API Reference
- `verifyOcra(response, suite, key, params): boolean` — true on match.
## Implementation Notes
The function intentionally returns `boolean` rather than the more elaborate
discriminated union used by TOTP verify because OCRA does not have a drift
window — there is exactly one expected value per (suite, key, params)
triple. Callers needing telemetry around the outcome can wrap the return
value with their own `recordOutcome` call.
The constant-time string compare is the same one used by TOTP `verify` —
it operates on UTF-8 byte arrays, applies an explicit length check, and
then runs `constantTimeEqual` from `@suaveplan/crypto-utils`. The compare
runs to completion regardless of where in the candidate string the first
mismatch occurs, so attackers cannot distinguish "first character wrong"
from "fifth character wrong" via response timing. This is the standard
defence for any string-equality check that gates an authentication
decision.

View File

@@ -0,0 +1,64 @@
# `recovery/generate`
## Purpose
Generate Crockford-base32 recovery codes — the printable single-use fallback
the user prints / writes down when first enrolling MFA. Default form is the
conventional `XXXX-XXXX-XXXX-XXXX` quad-grouped 16-character shape. RNG is
injectable so deterministic test fixtures can produce stable byte strings;
production callers leave it default and pull from the platform CSPRNG.
## Features
- Crockford alphabet (excludes `I`, `L`, `O`, `U` to avoid hand-copy
collisions with `1` / `0`).
- Configurable length / group size / separator.
- Injectable `Rng` (`@suaveplan/types`) — `nextBytes` preferred, falls back
to `nextUint32` assembly.
- Default RNG draws from `randomBytes` in `@suaveplan/crypto-utils`.
## Usage: Basic Example
```ts
import { generateRecoveryCodes } from "@suaveplan/otp";
const codes = generateRecoveryCodes(10);
// ten codes shaped like "XXXX-XXXX-XXXX-XXXX".
```
## Usage: Advanced Example
```ts
import { generateRecoveryCodes } from "@suaveplan/otp";
// Custom form for a UI that accepts longer codes.
const codes = generateRecoveryCodes(5, {
length: 20,
groupSize: 5,
separator: " ",
});
```
## API Reference
- `generateRecoveryCodes(count, opts?): string[]` — primary API.
- `bytesFromRng(rng, n): Uint8Array` — exported helper used by the
recovery-hash module to draw salt bytes from the same RNG.
- `RecoveryCodeOptions` — `{ length?: number; groupSize?: number;
separator?: string; rng?: Rng }`.
## Implementation Notes
The 32-character alphabet is power-of-two sized so taking `byte & 0x1f`
introduces no modulo bias. When the supplied `Rng` only exposes
`nextUint32`, the helper assembles bytes from 32-bit words and uses guarded
indexing so a request for `n < 4` writes only the bytes the caller asked
for. The default RNG is the platform CSPRNG via `@suaveplan/crypto-utils` —
bring your own only for deterministic tests.
Crockford-base32's character exclusions (`I`, `L`, `O`, `U`) are the
practical reason this module does not reuse the standard RFC 4648 alphabet
exposed by the `base32` module: hand-copied recovery codes get transcribed
under stress, and visually-similar characters cause silent enrollment
errors. The two alphabets are distinct on purpose and must not be confused
when implementing import / export between password managers.

View File

@@ -0,0 +1,60 @@
# `recovery/hash`
## Purpose
Hash recovery codes with Argon2id for at-rest storage. Recovery codes are
long-lived shared secrets — they must NOT be stored in cleartext. Argon2id
is the OWASP-recommended password-hashing primitive; this module emits a
self-describing `recovery$argon2id$v1$<m,t,p,len,saltLen>$<salt>$<derived>`
string suitable for a single column in any datastore.
## Features
- OWASP 2023 default parameters (`memoryKB ≥ 64 MiB`, `iterations ≥ 3`,
`parallelism = 1`, output length 32).
- Self-describing PHC-like format with base64url salt + derived bytes.
- Round-trip: `decodeRecoveryHash(encodeRecoveryHash(...))` is the identity.
- Tolerant input normalisation — separators stripped, upper-cased before
hashing so `xx-xx` matches stored `XXXX`.
## Usage: Basic Example
```ts
import { hashRecoveryCode } from "@suaveplan/otp";
const stored = await hashRecoveryCode("ABCD-1234");
db.update({ recoveryHash: stored });
```
## Usage: Advanced Example
```ts
import { hashRecoveryCode } from "@suaveplan/otp";
// Tighter parameters for a high-throughput admin tool seeding many users.
const stored = await hashRecoveryCode(code, {
params: { memoryKB: 65536, iterations: 4, parallelism: 1, saltBytes: 32, length: 32 },
});
```
## API Reference
- `hashRecoveryCode(code, opts?): Promise<string>` — primary API.
- `decodeRecoveryHash(s)` — inverse of `encodeRecoveryHash`.
- `normaliseRecoveryCode(input)` — strips separators / upper-cases.
- `RECOVERY_DEFAULT_ARGON2ID_PARAMS` — exported defaults.
## Implementation Notes
The PHC-like format is deliberately bespoke rather than the official PHC
spec because the official format does not include `saltBytes` separately
from the encoded salt — re-deriving with a different salt length would
silently produce garbage. Encoding the param block explicitly makes the
hash self-describing and avoids silent migration hazards.
OWASP's 2023 Argon2id recommendations are enforced as a floor, not a
ceiling: callers can pass tighter parameters when their threat model
demands it. The default `memoryKB = 65536` (64 MiB) is the OWASP minimum
for Argon2id at `iterations = 3` and `parallelism = 1`. Lower memory or
fewer iterations are accepted by the Argon2id primitive but should only
be used in test fixtures where speed matters more than security.

View File

@@ -0,0 +1,63 @@
# `recovery/verify`
## Purpose
Verify a user-supplied recovery code against a list of stored hashes.
Constant-time over the full set — every entry is evaluated even after a
match is found so loop-position timing does not leak which code matched.
On success the returned `codeIndex` lets the caller mark that hash as used
(recovery codes are single-use).
## Features
- Constant-time across the full hash list.
- Argon2id re-derivation using the per-hash stored parameters.
- Returns `{ valid, codeIndex }` discriminated union.
- Input normalisation — separators stripped, upper-cased — so `aaaa-bbbb`
matches the original `AAAABBBB` form regardless of how the user types it.
## Usage: Basic Example
```ts
import { verifyRecoveryCode } from "@suaveplan/otp";
const r = await verifyRecoveryCode("aaaa-bbbb", user.recoveryHashes);
if (r.valid) markUsed(user.recoveryHashes[r.codeIndex]);
```
## Usage: Advanced Example
```ts
import { verifyRecoveryCode } from "@suaveplan/otp";
// Drop the matched hash from the list atomically; subsequent attempts will
// miss even if the same code is replayed.
const r = await verifyRecoveryCode(input, hashes);
if (r.valid) {
await db.transaction(async (tx) => {
await tx.removeHash(user.id, hashes[r.codeIndex]);
});
}
```
## API Reference
- `verifyRecoveryCode(input, hashedCodes): Promise<RecoveryVerifyResult>` —
returns `{ valid: true; codeIndex }` on match, `{ valid: false; codeIndex: null }`
otherwise.
## Implementation Notes
Argon2id is intentionally slow — verifying a list of N hashes runs N derivations.
The constant-time loop completes regardless of where the match is so timing
does not reveal list position. Production callers typically cap the list at
10 codes; below that the wall-clock cost is bounded and acceptable for an
interactive recovery flow.
Each list entry carries its own Argon2id parameters in the encoded form so
parameter rotation works without re-hashing the entire user base: as the
threat model evolves, new codes can be hashed with stronger parameters
while older codes verify against their original parameters. The hash
format is self-describing precisely so this rotation is not a breaking
change. Callers should still re-hash on next-use to migrate stragglers
forward.

View File

@@ -0,0 +1,93 @@
# `totp/totp`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - totp.schema — Zod boundary schemas for `TotpOptions…
- [totp.schema](./totp.schema.md) — Zod boundary schemas for `TotpOptions` and `TotpVerifyOptions`. The TOTP schema covers algorithm / digits / period / t0…
- [verify-many](./verify-many.md) — Backup-aware TOTP verify. Used during rotation windows where an old and a new TOTP secret coexist — the user's authenti…
- [verify](./verify.md) — RFC 6238 §5.2 verify with drift window. Checks the supplied code against the current step and `±window` neighbouring st…
- totp.schema — Zod boundary schemas for `TotpOptions…
- [totp.schema](./totp.schema.md) — Zod boundary schemas for `TotpOptions` and `TotpVerifyOptions`. The TOTP schema covers algorithm / digits / period / t0…
- [verify-many](./verify-many.md) — Backup-aware TOTP verify. Used during rotation windows where an old and a new TOTP secret coexist — the user's authenti…
- [verify](./verify.md) — RFC 6238 §5.2 verify with drift window. Checks the supplied code against the current step and `±window` neighbouring st…
- totp.schema — Zod boundary schemas for `TotpOptions…
- [totp.schema](./totp.schema.md) — Zod boundary schemas for `TotpOptions` and `TotpVerifyOptions`. The TOTP schema covers algorithm / digits / period / t0…
- [verify-many](./verify-many.md) — Backup-aware TOTP verify. Used during rotation windows where an old and a new TOTP secret coexist — the user's authenti…
- [verify](./verify.md) — RFC 6238 §5.2 verify with drift window. Checks the supplied code against the current step and `±window` neighbouring st…
- totp.schema — Zod boundary schemas for `TotpOptions…
- [totp.schema](./totp.schema.md) — Zod boundary schemas for `TotpOptions` and `TotpVerifyOptions`. The TOTP schema covers algorithm / digits / period / t0…
- [verify-many](./verify-many.md) — Backup-aware TOTP verify. Used during rotation windows where an old and a new TOTP secret coexist — the user's authenti…
- [verify](./verify.md) — RFC 6238 §5.2 verify with drift window. Checks the supplied code against the current step and `±window` neighbouring st…
- [totp.schema](./totp.schema.md) — Zod boundary schemas for `TotpOptions` and `TotpVerifyOptions`. The TOTP schema covers algorithm / digits / period / t0…
- [verify-many](./verify-many.md) — Backup-aware TOTP verify. Used during rotation windows where an old and a new TOTP secret coexist — the user's authenti…
- [verify](./verify.md) — RFC 6238 §5.2 verify with drift window. Checks the supplied code against the current step and `±window` neighbouring st…
## Purpose
RFC 6238 TOTP — time-based one-time password. Computes
`T = floor((now − T0) / period)` per §4 and delegates to HOTP. Time is read
via the injected `Clock` exclusively — the package never reads `Date.now()`
at module scope, so deterministic tests and replay-style test fixtures can
swap in a frozen clock without monkey-patching globals.
## Features
- Defaults: SHA-1, digits=6, period=30, T0=0.
- Algorithm widening per RFC 6238 §1.2 (SHA-256 / SHA-512).
- Injectable `Clock` (`@suaveplan/types`).
- Strict-mode Zod boundary; mismatched fields are mapped to specific codes.
- Period validated `> 0` (Zod `.positive()`); failure surfaces as
`OTP.INVALID_PERIOD`.
## Usage: Basic Example
```ts
import { totp } from "@suaveplan/otp";
const code = totp(secret); // current step from system clock, 6 digits, SHA-1.
```
## Usage: Advanced Example
```ts
import { totp } from "@suaveplan/otp";
const fixed: () => number = () => 1_111_111_109_000;
const code = totp(secret, {
algorithm: "SHA256",
digits: 8,
period: 30,
clock: fixed,
});
// Reproduces RFC 6238 Appendix B vector at T=1111111109.
```
## API Reference
- `totp(secret: Uint8Array, opts?: TotpOptions): string` — primary API.
- `computeTimeStep(clock, period, t0): bigint` — exposed so verify and the
drift detector share the same time-step formula.
- `resolveTotpOptions(opts?): TotpDefaults` — fills defaults; useful when
forwarding options through wrapper packages.
## Implementation Notes
`resolveTotpOptions` extracts only the known `TotpOptions` subset before
calling Zod so callers can forward extended option bags (e.g. verify with
`window`, drift with `maxSteps`) without strict-mode rejection. The
top-level `totp()` parses with the strict schema so unknown keys surface as
`INVALID_OPTIONS` at the public boundary.
Time is converted to seconds before integer division (`floor(now/1000)`)
so a clock that returns sub-millisecond resolution still produces a stable
step within each `period`-second window. The BigInt step value is
threaded through to HOTP without truncation so step values past
`Number.MAX_SAFE_INTEGER` (which would happen far in the future, around
year 285,427,621 at `period=30`) round-trip cleanly. Callers who need
fixed-clock determinism in tests should pass a clock that returns whole
milliseconds.

View File

@@ -0,0 +1,60 @@
# `totp/totp.schema`
## Purpose
Zod boundary schemas for `TotpOptions` and `TotpVerifyOptions`. The TOTP
schema covers algorithm / digits / period / t0 / clock; the verify schema
extends it with `window` capped at `[0, 10]`. Both are strict-mode so unknown
keys surface immediately, and field-level rules feed into `mapZodToOtp` so
boundary failures carry meaningful codes (`INVALID_PERIOD`,
`WINDOW_OUT_OF_RANGE`, etc.).
## Features
- `TotpOptionsSchema` — strict object with optional fields.
- `TotpVerifyOptionsSchema` — extends the above with `window`.
- `Clock` validated as a structural function via `z.custom`.
- Period / T0 enforced as non-negative integers.
## Usage: Basic Example
```ts
import { TotpOptionsSchema } from "@suaveplan/otp";
TotpOptionsSchema.parse({ algorithm: "SHA1", period: 30 });
```
## Usage: Advanced Example
```ts
import { TotpVerifyOptionsSchema } from "@suaveplan/otp";
const opts = TotpVerifyOptionsSchema.parse({
algorithm: "SHA256",
digits: 8,
period: 30,
window: 2,
clock: () => Date.now(),
});
```
## API Reference
- `TotpOptionsSchema` — strict Zod object.
- `TotpVerifyOptionsSchema` — extends `TotpOptionsSchema` with `window`.
## Implementation Notes
`Clock` is intentionally validated with `z.custom` rather than the
`z.function()` builder because Zod's function validators changed shape
between major versions; a structural custom predicate stays portable.
Period uses `.positive()` which rejects `0` and negatives — both flow to
`OTP.INVALID_PERIOD` via the zod-map.
The `window` cap of 10 is enforced here at the schema rather than at the
verify call site because Zod validation produces a typed error message
the host application can render directly. Lifting the bound check to the
schema also means the verify call site can trust the type: a `parsed.window`
value is guaranteed to be in the legal range, so no runtime guard is
duplicated downstream. This is the boundary-vs-deep-defence trade-off
discussed in the AGENTS.md zod-mapping section.

View File

@@ -0,0 +1,62 @@
# `totp/verify-many`
## Purpose
Backup-aware TOTP verify. Used during rotation windows where an old and a
new TOTP secret coexist — the user's authenticator may still be holding the
old secret while the server has moved to the new one. Returns the matching
secret index and signed step delta so the caller can mark the rotation
complete after the user has successfully authenticated against the new
secret.
## Features
- Multi-secret loop with constant-time evaluation across the entire list.
- Same `±window` semantics as `verify`.
- Returns `{ valid, delta, secretIndex }` so the caller can record which
secret was used.
- Window cap and Zod-boundary semantics shared with `verify`.
## Usage: Basic Example
```ts
import { verifyMany } from "@suaveplan/otp";
const r = verifyMany(code, [oldSecret, newSecret]);
if (r.valid) markUsed(r.secretIndex);
```
## Usage: Advanced Example
```ts
import { verifyMany } from "@suaveplan/otp";
// During rotation grace period — accept either secret but log when the old
// one is used so the rollout dashboard tracks lag.
const r = verifyMany(code, [oldSecret, newSecret], {
window: 1,
clock: () => Date.now(),
});
if (r.valid && r.secretIndex === 0) metric.inc("totp.rotation.legacy");
```
## API Reference
- `verifyMany(code, secrets, opts?): TotpVerifyManyResult` —
`{ valid: true; delta; secretIndex }` on match, all-`null` triple on miss.
## Implementation Notes
Both loops (over secrets and over windowed steps) run to completion even
after the first match so the wall-time of `verifyMany` is independent of
which secret / which step matched. The match flag uses `matchedDelta === null`
to record the first hit without affecting subsequent iterations. This is
the same constant-time pattern as `verify`, extended to cover the secret
dimension as well.
When the secret rotation completes (every authenticated user has
re-enrolled against the new secret), the caller drops the old secret from
the list and `verifyMany` collapses back to single-secret behaviour. There
is no separate "single-secret" code path inside `verifyMany` — a
single-element list runs the same way as a multi-element one. This keeps
the constant-time guarantees uniform across rotation states.

View File

@@ -0,0 +1,61 @@
# `totp/verify`
## Purpose
RFC 6238 §5.2 verify with drift window. Checks the supplied code against the
current step and `±window` neighbouring steps; on match returns the signed
step offset so callers can detect creeping clock drift inside the window.
Constant-time across the entire window — every step is evaluated even after
a match is found, so loop-position timing does not leak which step matched.
## Features
- Drift window `[0, 10]` hard-capped (Zod boundary).
- Returns `{ valid, delta }` discriminated union.
- Constant-time string compare via `@suaveplan/crypto-utils`.
- Signed `delta` exposes drift direction (`+1` / `-1`) for UI hints.
## Usage: Basic Example
```ts
import { verify } from "@suaveplan/otp";
const r = verify("123456", secret);
if (r.valid) acceptLogin(r.delta); else rejectLogin();
```
## Usage: Advanced Example
```ts
import { verify } from "@suaveplan/otp";
const r = verify(code, secret, { window: 2, clock: () => Date.now() });
if (r.valid && r.delta !== 0) {
log.warn("user clock drifted by %d steps", r.delta);
}
```
## API Reference
- `verify(code, secret, opts?): TotpVerifyResult` — returns
`{ valid: true; delta }` on match, `{ valid: false; delta: null }` otherwise.
- `constantTimeStringEqual(a, b)` — exposed UTF-8-aware constant-time
comparator used by recovery and OCRA.
## Implementation Notes
The loop iterates `[-window, +window]` in fixed order — any candidate step
that resolves to `< 0n` after the BigInt addition (near time origin) is
skipped without an early return. The first matching delta is recorded but
the loop continues so the wall-time of `verify` is independent of which
step matched. This is a defence against attacks that distinguish "matched
on step 0" from "matched on step +1" via timing.
`window` defaults to `1` per RFC 6238 §5.2's recommendation. Callers
willing to trade off wider clock-drift tolerance for tighter security
(`window: 0`) can opt in; callers who need to support legacy hardware
authenticators with notoriously poor clocks can opt up to the cap of 10.
Anything above 10 is rejected at the schema boundary because the wider
the window the more codes the attacker has to brute-force per attempt,
which weakens the per-second rate-limit budget the host application
maintains around this call.

View File

@@ -0,0 +1,66 @@
# `uri/format-uri`
## Purpose
Build a canonical `otpauth://` URI from an `OtpUri` value object. Round-trips
with `parseUri` for every valid input. Percent-encodes the label so colons
and spaces survive transport; rejects literal `:` in the label (the colon is
reserved for the issuer-prefix convention and an unencoded `:` would produce
an ambiguous URI).
## Features
- TOTP and HOTP variants.
- Label and issuer percent-encoded.
- Query parameters emitted in a stable order (`secret, issuer, algorithm,
digits, period | counter`).
- Strict-mode Zod boundary at the entry point.
## Usage: Basic Example
```ts
import { formatUri } from "@suaveplan/otp/uri";
const url = formatUri({
type: "totp",
label: "alice@example.com",
secret: "JBSWY3DPEHPK3PXP",
issuer: "Example",
});
```
## Usage: Advanced Example
```ts
import { formatUri } from "@suaveplan/otp/uri";
const url = formatUri({
type: "hotp",
label: "alice",
secret: "JBSWY3DPEHPK3PXP",
counter: 17n,
algorithm: "SHA256",
digits: 8,
});
```
## API Reference
- `formatUri(opts: OtpUri): string` — returns the URI. Raises
`OtpError(OTP.URI_MALFORMED)` on Zod failure or on labels containing `:`.
## Implementation Notes
The output is a stable canonical form so two calls with identically-typed
input produce byte-identical URLs. This matters when the URI is hashed for
QR-code de-duplication or audited via diff tooling. Field order is preserved
via a `Map` rather than a plain object so insertion order is stable across
JS engines.
Optional fields are omitted when they match the per-spec default — e.g.
TOTP URIs without a `period` parameter elide it rather than emitting
`period=30`. This keeps the output minimal and matches the wire format
emitted by reference Google Authenticator implementations. Round-tripping
through `parseUri` recovers the canonical option bag because both ends
agree on the defaults; consumers that need an explicit `period=30` should
set it on the input.

View File

@@ -0,0 +1,62 @@
# `uri/parse-uri`
## Purpose
Parse the `otpauth://` Key URI Format used by Google Authenticator and every
compatible authenticator app. Supports both `totp` and `hotp` variants and
returns a discriminated union narrowed by the `type` field. Malformed input
raises `OTP.URI_MALFORMED`; an unrecognised type segment raises
`OTP.URI_UNSUPPORTED_TYPE`.
## Features
- `otpauth://totp/<label>?secret=...&issuer=...&algorithm=...&digits=...&period=...`
- `otpauth://hotp/<label>?secret=...&counter=...` (counter is `bigint`).
- Algorithm normalisation: `sha1` / `Sha1` / `SHA1` all canonicalise.
- Digits gated to `6 / 7 / 8`.
- Optional issuer round-tripped via `issuer=` query parameter.
## Usage: Basic Example
```ts
import { parseUri } from "@suaveplan/otp/uri";
const u = parseUri(
"otpauth://totp/alice?secret=JBSWY3DPEHPK3PXP&issuer=Example",
);
if (u.type === "totp") /* … */;
```
## Usage: Advanced Example
```ts
import { parseUri } from "@suaveplan/otp/uri";
const u = parseUri(
"otpauth://hotp/alice?secret=JBSWY3DPEHPK3PXP&counter=42&algorithm=SHA512&digits=8",
);
if (u.type === "hotp") {
saveCounter(u.counter); // bigint past 2^53 round-trips losslessly.
}
```
## API Reference
- `parseUri(uri: string): OtpUri` — returns the discriminated union.
- Throws `OtpError(OTP.URI_MALFORMED)` for shape failures and
`OtpError(OTP.URI_UNSUPPORTED_TYPE)` for unrecognised type segments.
## Implementation Notes
The label is decoded via `decodeURIComponent` so percent-encoded `:` and
spaces survive. Empty labels are rejected. The query parser tolerates bare
flags (`?secret=...&flag&period=30`) and ignores empty keys. Counter values
are required to match `^\d+$` (decimal-only) before BigInt conversion so
hex / scientific notation are rejected at the boundary.
The `secret` query parameter is returned as a plain string so callers
choose how to decode it. Most callers will route the secret through
`base32Decode` from `@suaveplan/otp/base32`. The parser deliberately does
NOT auto-decode because some authenticators emit hex-encoded secrets in
QR codes that mis-set the `secret=` parameter — leaving the decode at the
caller's boundary keeps that recovery path open.

View File

@@ -0,0 +1,60 @@
# `uri/uri.schema`
## Purpose
Zod boundary schemas for the `OtpUri` discriminated union. `OtpTotpUriSchema`
and `OtpHotpUriSchema` cover the per-variant shapes; `OtpUriSchema` is the
discriminated union over the `type` field. Used by `formatUri` and by
downstream consumers that want to validate user-supplied URI configuration
before passing it through.
## Features
- Discriminated union by `type: "totp" | "hotp"`.
- Algorithm and digits literal-typed to the same enums HOTP uses.
- HOTP counter as `bigint` (Zod's native bigint validator).
- Strict-mode object — unknown keys rejected.
## Usage: Basic Example
```ts
import { OtpUriSchema } from "@suaveplan/otp/uri";
const u = OtpUriSchema.parse({
type: "totp",
label: "alice",
secret: "JBSWY3DPEHPK3PXP",
});
```
## Usage: Advanced Example
```ts
import { OtpHotpUriSchema } from "@suaveplan/otp/uri";
const safe = OtpHotpUriSchema.safeParse(payload);
if (!safe.success) renderErrors(safe.error.issues);
```
## API Reference
- `OtpTotpUriSchema` — strict object covering the TOTP shape.
- `OtpHotpUriSchema` — strict object covering the HOTP shape.
- `OtpUriSchema` — discriminated-union over `type`.
## Implementation Notes
The schemas live in their own module so callers that only need validation
(e.g. a config loader) can import them without pulling in the codec. The
HOTP `counter` field intentionally uses `z.bigint().nonnegative()` — Zod's
JSON parser does not handle BigInt natively, so callers parsing JSON must
pre-convert decimal strings to BigInt before feeding the schema.
Discriminated unions in Zod produce richer error messages than plain
`z.union` because Zod can pick the right variant based on the
discriminator and report only that variant's failures. The discriminator
here is `type` and matches the runtime field carried on the parsed URI,
so callers who narrow on `u.type` after parsing get exhaustive type
coverage with no extra runtime checks. Adding a new URI variant requires
extending both `OtpUriSchema` and the `OtpUri` discriminated-union type
in `uri.types.ts` in lockstep.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,72 @@
# format-detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a single `detect()` function that inspects the raw content of a password manager export file (and an optional filename hint) to identify which supported format it belongs to. This enables the `parse()` function to automatically dispatch to the correct parser without requiring callers to specify the format manually. The detector uses content-based heuristics rather than relying solely on file extensions, making it robust against mislabelled or extension-less files.
## Features
- Content-first detection — inspects actual bytes/characters rather than relying purely on file extensions
- KeePass XML detection via `<KeePassFile` start tag
- Bitwarden JSON detection via `"items"` / `"encrypted"` keys; throws `ENCRYPTED_NOT_SUPPORTED` for encrypted vaults
- Apple Passwords CSV detection via `OTPAuth` + `Title` header columns
- Chrome CSV detection via `name`, `url`, `username`, `password` header columns
- Returns `"unknown"` for unrecognised formats rather than throwing
- Accepts both `string` and `Uint8Array` inputs
## Usage: Basic Example
```ts
import { detect } from "@suaveplan/password-formats/detect";
const format = detect(csvContent, "passwords.csv");
console.log(format); // "apple" | "chrome" | "bitwarden" | "keepass" | "unknown"
```
## Usage: Advanced Example
```ts
import { detect } from "@suaveplan/password-formats/detect";
import { PasswordFormatsError, PASSWORD_FORMATS_CODES } from "@suaveplan/password-formats/errors";
async function importFile(name: string, bytes: Uint8Array): Promise<void> {
let format: string;
try {
format = detect(bytes, name);
} catch (err) {
if (err instanceof PasswordFormatsError && err.code === PASSWORD_FORMATS_CODES.ENCRYPTED_NOT_SUPPORTED) {
console.error("Please export an unencrypted Bitwarden vault before importing.");
return;
}
throw err;
}
if (format === "unknown") {
console.warn("Unrecognised format — defaulting to manual selection");
}
}
```
## API Reference
### `detect`
```ts
function detect(input: string | Uint8Array, filename?: string): PasswordFormat
```
**Parameters:**
- `input` — File contents as a UTF-8 string or raw byte array.
- `filename` — Optional filename (e.g. `"export.json"`). Used as a secondary hint alongside content inspection.
**Returns:** One of `"apple"`, `"bitwarden"`, `"chrome"`, `"keepass"`, or `"unknown"`.
**Throws:** `PasswordFormatsError` with code `PASSWORD_FORMATS.ENCRYPTED_NOT_SUPPORTED` when the input is a Bitwarden encrypted export.
## Implementation Notes
Detection order matters: KeePass XML is checked first because `<KeePassFile` is a highly specific marker. JSON detection comes next and is content-driven — a valid JSON object with an `"items"` key is classified as Bitwarden regardless of file extension. CSV header inspection is last because CSV is the most ambiguous format. The `"unknown"` fallback is intentional: callers can handle it gracefully rather than receiving an unexpected exception for unrecognised files.

View File

@@ -0,0 +1,75 @@
# parse
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the primary entry point for parsing password manager export files in the `@suaveplan/password-formats` package. The `parse()` function accepts raw file content as a string or `Uint8Array`, optionally accepts a format hint, and dispatches to the appropriate format-specific parser after auto-detecting the format when none is specified. It instruments every parse operation with OpenTelemetry counters and histograms so operators can monitor import volume and latency in production.
## Features
- Auto-detects format using `detect()` when `format` is not provided
- Dispatches to `parseApplePasswordsCsv`, `parseBitwardenJson`, `parseChromeCsv`, or `parseKeePassXml`
- Emits OTel counter `password_formats.parse.total{format}` on each call
- Records OTel histogram `password_formats.parse.duration_ms{format}` for latency tracking
- Wraps execution in an OTel span `password_formats.parse` with `format` and `entry_count` attributes
- Returns a `Promise<PasswordEntry[]>` for consistent async interface across all callers
- Accepts both `string` and `Uint8Array` inputs (decoded as UTF-8)
## Usage: Basic Example
```ts
import { parse } from "@suaveplan/password-formats/parse";
const entries = await parse(csvContent);
console.log(entries.length); // Number of parsed entries
```
## Usage: Advanced Example
```ts
import { parse } from "@suaveplan/password-formats/parse";
// Explicit format override + parse options
const entries = await parse(jsonContent, "bitwarden", {
skipInvalid: true,
maxEntries: 500,
});
// Pass a Uint8Array from a file upload
const bytes = new Uint8Array(await file.arrayBuffer());
const entries2 = await parse(bytes, undefined, { skipInvalid: false });
```
## API Reference
### `parse`
```ts
async function parse(
input: string | Uint8Array,
format?: PasswordFormat,
options?: ParseOptions,
): Promise<PasswordEntry[]>
```
**Parameters:**
- `input` — File content as a UTF-8 string or byte array.
- `format` — Optional format override. If omitted, `detect()` is called automatically.
- `options.skipInvalid` — Skip malformed entries instead of throwing. Default: `false`.
- `options.maxEntries` — Cap on the number of returned entries.
**Returns:** Promise resolving to an array of `PasswordEntry` objects.
**Throws:**
- `PasswordFormatsError` (`INVALID_INPUT`) — `input` is empty.
- `PasswordFormatsError` (`UNSUPPORTED_FORMAT`) — Format is `"unknown"` or unrecognised.
- `PasswordFormatsError` (`ENCRYPTED_NOT_SUPPORTED`) — Bitwarden encrypted vault detected.
- `PasswordFormatsError` (`PARSE_ERROR`) — Malformed content and `skipInvalid` is `false`.
## Implementation Notes
The function is `async` even though the underlying parsers are synchronous. This design choice future-proofs the API for streaming or worker-thread implementations without breaking callers. The OTel SDK no-ops gracefully when no global tracer/meter provider is registered, so the instrumentation has zero overhead in environments that do not configure OTel. Duration is measured with `Date.now()` and recorded in milliseconds to keep the instrumentation dependency surface minimal.

View File

@@ -0,0 +1,79 @@
# validate
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Validates an array of `PasswordEntry` objects against the minimum required field constraints for the `@suaveplan/password-formats` package. After parsing a password manager export, callers can run `validate()` to get a structured report of which entries are well-formed and which are not — useful for building import UIs that show per-entry error messages before committing the import. The validator is intentionally permissive: it only enforces the two fields that are universally required (`title` and `password`), leaving all other fields optional.
## Features
- Validates that `title` is a non-empty string (after trimming)
- Validates that `password` is a non-empty string
- Returns a `ValidationResult` with per-entry `{ index, message }` error tuples
- Reports `validCount` and `invalidCount` for summary display
- Sets `valid: true` only when there are zero errors
- Does not mutate the input array
## Usage: Basic Example
```ts
import { validate } from "@suaveplan/password-formats/parse";
import type { PasswordEntry } from "@suaveplan/password-formats";
const entries: PasswordEntry[] = [
{ title: "Gmail", password: "secret" },
{ title: "", password: "pass" },
];
const result = validate(entries);
console.log(result.valid); // false
console.log(result.validCount); // 1
console.log(result.invalidCount); // 1
console.log(result.errors[0]); // { index: 1, message: "Entry at index 1: title is required..." }
```
## Usage: Advanced Example
```ts
import { parse, validate } from "@suaveplan/password-formats";
async function importWithValidation(content: string): Promise<void> {
const entries = await parse(content);
const result = validate(entries);
if (!result.valid) {
console.error(`Import has ${result.invalidCount} invalid entries:`);
for (const err of result.errors) {
console.error(` Row ${err.index + 1}: ${err.message}`);
}
return;
}
console.log(`All ${result.validCount} entries are valid.`);
}
```
## API Reference
### `validate`
```ts
function validate(entries: PasswordEntry[]): ValidationResult
```
**Parameters:**
- `entries` — Array of `PasswordEntry` objects to validate.
**Returns:** `ValidationResult`
- `valid` — `true` if no errors were found.
- `errors` — Array of `{ index: number; message: string }` tuples, one per violation.
- `validCount` — Number of entries with no errors.
- `invalidCount` — Number of entries with at least one error.
## Implementation Notes
An entry can generate multiple error tuples (e.g. missing both `title` and `password`), but it counts as one invalid entry in `invalidCount`. The `invalidCount` is computed by counting distinct indices in the errors array rather than simply using `errors.length`, ensuring accurate summary counts when multiple rules fire on the same entry. The validator does not attempt deep structural validation of fields like `url` format or ISO 8601 compliance — that is the responsibility of the consuming application.

View File

@@ -0,0 +1,86 @@
# password-formats-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error code registry and custom `PasswordFormatsError` class used throughout the `@suaveplan/password-formats` package. By centralizing all error codes and the error class in one module, callers can discriminate errors with a stable `code` string rather than relying on fragile message-string matching. Every public API in this package throws `PasswordFormatsError` on failure, ensuring a consistent and predictable error surface.
## Features
- `PASSWORD_FORMATS_CODES` — a `const` object of all stable error codes, namespaced under `PASSWORD_FORMATS.*`
- `PasswordFormatsError` — extends `Error`, carries `name = "PasswordFormatsError"` and a `code: string` property
- `PasswordFormatsCode` — union type for all valid code strings, useful for exhaustive `switch` statements
## Usage: Basic Example
```ts
import { PasswordFormatsError, PASSWORD_FORMATS_CODES } from "@suaveplan/password-formats/errors";
try {
throw new PasswordFormatsError("Encrypted vaults are not supported", PASSWORD_FORMATS_CODES.ENCRYPTED_NOT_SUPPORTED);
} catch (err) {
if (err instanceof PasswordFormatsError) {
console.error(err.code); // "PASSWORD_FORMATS.ENCRYPTED_NOT_SUPPORTED"
}
}
```
## Usage: Advanced Example
```ts
import { PasswordFormatsError, PASSWORD_FORMATS_CODES, type PasswordFormatsCode } from "@suaveplan/password-formats/errors";
function handleError(err: unknown): void {
if (!(err instanceof PasswordFormatsError)) throw err;
const actions: Record<PasswordFormatsCode, () => void> = {
[PASSWORD_FORMATS_CODES.UNSUPPORTED_FORMAT]: () => console.warn("Unknown format — try a different file"),
[PASSWORD_FORMATS_CODES.PARSE_ERROR]: () => console.error("Malformed input"),
[PASSWORD_FORMATS_CODES.INVALID_INPUT]: () => console.error("Input is null or empty"),
[PASSWORD_FORMATS_CODES.ENCRYPTED_NOT_SUPPORTED]: () => console.error("Decrypt before importing"),
};
(actions[err.code as PasswordFormatsCode] ?? (() => { throw err; }))();
}
```
## API Reference
### `PASSWORD_FORMATS_CODES`
```ts
const PASSWORD_FORMATS_CODES: {
UNSUPPORTED_FORMAT: "PASSWORD_FORMATS.UNSUPPORTED_FORMAT";
PARSE_ERROR: "PASSWORD_FORMATS.PARSE_ERROR";
INVALID_INPUT: "PASSWORD_FORMATS.INVALID_INPUT";
ENCRYPTED_NOT_SUPPORTED: "PASSWORD_FORMATS.ENCRYPTED_NOT_SUPPORTED";
}
```
Stable string constants for all error conditions in the package.
### `PasswordFormatsError`
```ts
class PasswordFormatsError extends Error {
readonly code: string;
constructor(message: string, code: string): PasswordFormatsError;
}
```
- `message` — human-readable description of what went wrong
- `code` — one of the `PASSWORD_FORMATS_CODES` values
### `PasswordFormatsCode`
```ts
type PasswordFormatsCode = "PASSWORD_FORMATS.UNSUPPORTED_FORMAT" | "PASSWORD_FORMATS.PARSE_ERROR" | ...;
```
Union type over all valid `PASSWORD_FORMATS_CODES` values.
## Implementation Notes
The error class deliberately does not extend a `@suaveplan/error` base class because `@suaveplan/password-formats` is a leaf package with no `@suaveplan/*` runtime dependencies. This keeps the dependency graph minimal and avoids circular dependencies. The `code` property mirrors the pattern established by `SuaveplanError` so that error-handling middleware in consuming applications can treat both interchangeably when checking `.code`.

View File

@@ -0,0 +1,75 @@
# csv-export
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exports an array of normalized `PasswordEntry` objects to a universal CSV format that can be imported into most password managers or spreadsheet tools. This module is the reverse operation of the format-specific parsers — it produces a consistent, predictable CSV regardless of the original source format. The output CSV uses a standardized seven-column schema that balances completeness with broad import compatibility.
## Features
- Uses `papaparse.unparse()` for standards-compliant CSV generation including proper quoting
- Exports seven columns: `title`, `url`, `username`, `password`, `notes`, `folder`, `tags`
- Tags array is serialized as a comma-separated string within the column
- Missing optional fields are exported as empty strings (not omitted)
- Always includes a header row
## Usage: Basic Example
```ts
import { exportToCsv } from "@suaveplan/password-formats/export";
import type { PasswordEntry } from "@suaveplan/password-formats";
const entries: PasswordEntry[] = [
{ title: "Gmail", password: "secret", url: "https://gmail.com", username: "me@gmail.com" },
];
const csv = exportToCsv(entries);
console.log(csv);
// title,url,username,password,notes,folder,tags
// Gmail,https://gmail.com,me@gmail.com,secret,,,
```
## Usage: Advanced Example
```ts
import { parse, exportToCsv } from "@suaveplan/password-formats";
// Round-trip: parse Bitwarden export → normalize → re-export as universal CSV
const entries = await parse(bitwardenJson, "bitwarden");
const csv = exportToCsv(entries);
// Save or transmit the CSV
const blob = new Blob([csv], { type: "text/csv" });
```
## API Reference
### `exportToCsv`
```ts
function exportToCsv(entries: PasswordEntry[]): string
```
**Parameters:**
- `entries` — Array of `PasswordEntry` objects to export.
**Returns:** A CSV string with a header row and one data row per entry.
**Column mapping:**
| Column | Source field | Notes |
|--------|-------------|-------|
| `title` | `entry.title` | Required field |
| `url` | `entry.url` | Empty string if absent |
| `username` | `entry.username` | Empty string if absent |
| `password` | `entry.password` | Required field |
| `notes` | `entry.notes` | Empty string if absent |
| `folder` | `entry.folder` | Empty string if absent |
| `tags` | `entry.tags` | Comma-joined, empty string if absent |
## Implementation Notes
The `tags` field uses a comma-separated representation within a single CSV column. When a tag itself contains a comma, `papaparse` will correctly quote the entire cell value. Fields not included in the export schema — such as `id`, `favorite`, `type`, `createdAt`, `updatedAt`, and `customFields` — are intentionally omitted to keep the output universally importable. Consumers requiring those fields should use the full `PasswordEntry` object array in memory rather than CSV serialization.

View File

@@ -0,0 +1,65 @@
# apple-passwords
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Parses Apple Passwords CSV exports into the normalized `PasswordEntry[]` format used throughout the `@suaveplan/password-formats` package. Apple's built-in password manager exports credentials as a CSV file with a fixed six-column header. This module handles that format specifically, mapping each column to the appropriate field in `PasswordEntry` and preserving the OTPAuth token as a custom field for TOTP-aware consumers.
## Features
- Uses `papaparse` with `header: true` for robust CSV parsing
- Maps `Title`, `URL`, `Username`, `Password`, `Notes` columns to their `PasswordEntry` counterparts
- Preserves `OTPAuth` value in `customFields.OTPAuth`
- Respects `ParseOptions.skipInvalid` to skip entries missing required fields
- Respects `ParseOptions.maxEntries` to cap the number of returned entries
- Throws `PasswordFormatsError` with code `PARSE_ERROR` for missing required fields when `skipInvalid` is false
## Usage: Basic Example
```ts
import { parseApplePasswordsCsv } from "@suaveplan/password-formats/parsers/apple";
const csv = `Title,URL,Username,Password,Notes,OTPAuth
My Bank,https://bank.com,user@example.com,s3cr3t,Main account,
GitHub,https://github.com,dev@example.com,gh_token,,`;
const entries = parseApplePasswordsCsv(csv);
console.log(entries[0].title); // "My Bank"
console.log(entries[1].url); // "https://github.com"
```
## Usage: Advanced Example
```ts
import { parseApplePasswordsCsv } from "@suaveplan/password-formats/parsers/apple";
const csv = `Title,URL,Username,Password,Notes,OTPAuth
My Account,https://example.com,me@example.com,secret,Backup,otpauth://totp/me@example.com?secret=BASE32`;
const entries = parseApplePasswordsCsv(csv, { skipInvalid: true, maxEntries: 100 });
console.log(entries[0].customFields?.OTPAuth); // "otpauth://totp/..."
```
## API Reference
### `parseApplePasswordsCsv`
```ts
function parseApplePasswordsCsv(input: string, options?: ParseOptions): PasswordEntry[]
```
**Parameters:**
- `input` — Raw Apple Passwords CSV string.
- `options.skipInvalid` — If `true`, rows missing `Title` or `Password` are silently skipped. Default: `false`.
- `options.maxEntries` — Maximum number of entries to return.
**Returns:** Array of `PasswordEntry` objects.
**Throws:** `PasswordFormatsError` (`PARSE_ERROR`) if a row is missing required fields and `skipInvalid` is `false`.
## Implementation Notes
Apple's CSV format always includes a header row with exactly six columns. The `OTPAuth` column contains an `otpauth://` URI when TOTP is configured for an entry, or an empty string otherwise. Empty `OTPAuth` values are omitted from `customFields` to keep entries clean. The parser does not validate the `otpauth://` URI format — it passes the raw string through verbatim so that TOTP consumers can parse it themselves.

Some files were not shown because too many files have changed in this diff Show More