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:
@@ -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 -->
|
||||
@@ -0,0 +1 @@
|
||||
Have-I-Been-Pwned checks, JWS, licensing, and OTP utilities.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
94
plugins/document-navigator/docs/security-stack/security/hibp/src/cache/cache.md
vendored
Normal file
94
plugins/document-navigator/docs/security-stack/security/hibp/src/cache/cache.md
vendored
Normal 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.
|
||||
65
plugins/document-navigator/docs/security-stack/security/hibp/src/cache/lru.md
vendored
Normal file
65
plugins/document-navigator/docs/security-stack/security/hibp/src/cache/lru.md
vendored
Normal 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.
|
||||
54
plugins/document-navigator/docs/security-stack/security/hibp/src/cache/null.md
vendored
Normal file
54
plugins/document-navigator/docs/security-stack/security/hibp/src/cache/null.md
vendored
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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(|)`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user