Initial Commit

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

View File

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

View File

@@ -0,0 +1,71 @@
{
"name": "@suaveplan/otp",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./uri": {
"import": { "types": "./dist/uri/index.d.ts", "default": "./dist/uri/index.js" }
},
"./base32": {
"import": { "types": "./dist/base32/index.d.ts", "default": "./dist/base32/index.js" }
},
"./ocra": {
"import": { "types": "./dist/ocra/index.d.ts", "default": "./dist/ocra/index.js" }
},
"./errors": {
"import": { "types": "./dist/errors/index.d.ts", "default": "./dist/errors/index.js" }
},
"./observability": {
"import": { "types": "./dist/observability/index.d.ts", "default": "./dist/observability/index.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/crypto-hash": "workspace:^",
"@suaveplan/crypto-utils": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@noble/hashes": "catalog:crypto",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

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

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { base32Decode, base32Encode } from "./base32.js";
const ENCODER = new TextEncoder();
// RFC 4648 §10 base32 reference vectors.
const RFC4648_VECTORS: ReadonlyArray<[string, string]> = [
["", ""],
["f", "MY======"],
["fo", "MZXQ===="],
["foo", "MZXW6==="],
["foob", "MZXW6YQ="],
["fooba", "MZXW6YTB"],
["foobar", "MZXW6YTBOI======"],
];
describe("base32Encode / base32Decode — RFC 4648 §10 vectors", () => {
for (const [text, encodedPadded] of RFC4648_VECTORS) {
it(`${JSON.stringify(text)} ↔ ${encodedPadded}`, {
timeout: 5000,
}, () => {
const bytes = ENCODER.encode(text);
expect(base32Encode(bytes, { padding: true })).toBe(encodedPadded);
const stripped = encodedPadded.replace(/=+$/u, "");
expect(base32Encode(bytes)).toBe(stripped);
expect(Array.from(base32Decode(encodedPadded))).toEqual(
Array.from(bytes)
);
expect(Array.from(base32Decode(stripped))).toEqual(
Array.from(bytes)
);
});
}
});
describe("base32Decode — case + whitespace tolerance", () => {
it("accepts lowercase", { timeout: 5000 }, () => {
expect(base32Decode("mzxw6ytb")).toEqual(ENCODER.encode("fooba"));
});
it("accepts whitespace between groups", { timeout: 5000 }, () => {
expect(base32Decode("MZXW 6YTB OI======")).toEqual(
ENCODER.encode("foobar")
);
});
});
describe("base32 — round-trip property", () => {
it("encode→decode round-trips for arbitrary bytes", {
timeout: 5000,
}, () => {
for (let len = 0; len < 24; len += 1) {
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i += 1) bytes[i] = (i * 7 + 3) & 0xff;
const enc = base32Encode(bytes);
expect(Array.from(base32Decode(enc))).toEqual(Array.from(bytes));
}
});
});
describe("base32Decode — error paths", () => {
it("rejects characters outside the alphabet", {
timeout: 5000,
}, () => {
expect(() => base32Decode("MZXW!YTB")).toThrow(/BASE32_INVALID/);
});
it("rejects high-codepoint character", { timeout: 5000 }, () => {
expect(() => base32Decode("ZZZÿ")).toThrow(/BASE32_INVALID/);
});
});

View File

@@ -0,0 +1,93 @@
import { OTP_CODES, otpError } from "../errors/codes.js";
/** RFC 4648 §6 base32 alphabet. */
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
/** Reverse lookup table: char-code → 5-bit value, or `-1` for non-alphabet. */
const DECODE_TABLE = (() => {
const t = new Int8Array(128).fill(-1);
for (let i = 0; i < ALPHABET.length; i += 1) {
const code = ALPHABET.charCodeAt(i);
t[code] = i;
// Lower-case alias so `base32Decode` is case-insensitive — only for
// letters (A–Z, indices 0..25); the digits 2–7 (indices 26..31) have
// no upper/lower distinction and aliasing them would collide with
// uppercase letter slots (e.g. '2'+32 = 'R').
if (i < 26) t[code + 32] = i;
}
return t;
})();
/** Options for `base32Encode`. */
export interface Base32EncodeOptions {
/**
* When `true` the output is padded with `=` to a multiple of eight characters
* per RFC 4648 §6. Defaults to `false` to match the Google Authenticator
* Key URI Format which strips padding from `secret=...`.
*/
padding?: boolean;
}
/**
* Encode bytes to RFC 4648 §6 base32. Padding defaults to `false` to match the
* Google Authenticator Key URI Format (`secret=...` is unpadded in the wild);
* pass `padding: true` for spec-strict consumers.
*/
export function base32Encode(
bytes: Uint8Array,
opts: Base32EncodeOptions = {}
): string {
const padding = opts.padding ?? false;
let out = "";
let buffer = 0;
let bits = 0;
for (const byte of bytes) {
buffer = (buffer << 8) | byte;
bits += 8;
while (bits >= 5) {
bits -= 5;
const idx = (buffer >> bits) & 0x1f;
out += ALPHABET[idx];
}
}
if (bits > 0) {
const idx = (buffer << (5 - bits)) & 0x1f;
out += ALPHABET[idx];
}
if (padding) {
while (out.length % 8 !== 0) out += "=";
}
return out;
}
/**
* Decode an RFC 4648 §6 base32 string. Padded and unpadded inputs are both
* accepted; lookups are case-insensitive. Whitespace is permitted between
* groups (Google Authenticator's URL-form QR codes wrap long secrets).
*/
export function base32Decode(input: string): Uint8Array {
const cleaned = input.replace(/\s+/g, "").replace(/=+$/u, "");
if (cleaned.length === 0) return new Uint8Array(0);
const out = new Uint8Array(Math.floor((cleaned.length * 5) / 8));
let buffer = 0;
let bits = 0;
let outIdx = 0;
for (let i = 0; i < cleaned.length; i += 1) {
const code = cleaned.charCodeAt(i);
const value = code < 128 ? DECODE_TABLE[code] : -1;
if (value === undefined || value < 0) {
throw otpError(
OTP_CODES.BASE32_INVALID,
`character '${cleaned[i]}' at position ${i} is not in the RFC 4648 §6 alphabet`
);
}
buffer = (buffer << 5) | value;
bits += 5;
if (bits >= 8) {
bits -= 8;
out[outIdx] = (buffer >> bits) & 0xff;
outIdx += 1;
}
}
return out.subarray(0, outIdx);
}

View File

@@ -0,0 +1,5 @@
export type { Base32EncodeOptions } from "./base32.js";
export {
base32Decode,
base32Encode,
} from "./base32.js";

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { OTP_CODES, OtpError, otpError } from "./errors/codes.js";
import { mapZodToOtp, rethrowAsOtp } from "./errors/zod-map.js";
import {
OcraMutualAuthClient,
OcraMutualAuthServer,
} from "./ocra/mutual-auth.js";
import { ocra } from "./ocra/ocra.js";
import { decodeRecoveryHash, hashRecoveryCode } from "./recovery/hash.js";
const ENCODER = new TextEncoder();
const KEY = ENCODER.encode("12345678901234567890");
describe("OtpError — bare-options constructor (codes.ts:35 default-arg branch)", () => {
it("constructs with default options", { timeout: 5000 }, () => {
const e = new OtpError(OTP_CODES.INVALID_OPTIONS, "raw");
expect(e.code).toBe(OTP_CODES.INVALID_OPTIONS);
expect(e.message).toBe("raw");
});
});
describe("zod-map — empty-issues + non-Zod fallback (zod-map.ts:35 + 40)", () => {
it("returns fallback when issue list is empty", {
timeout: 5000,
}, () => {
// ZodError-shaped object with no issues exercises the early-out.
const fakeEmpty = { issues: [] } as unknown as Parameters<
typeof mapZodToOtp
>[0];
expect(mapZodToOtp(fakeEmpty)).toBe(OTP_CODES.INVALID_OPTIONS);
});
it("rethrowAsOtp wraps a plain Error as INVALID_OPTIONS", {
timeout: 5000,
}, () => {
expect(() => rethrowAsOtp(new Error("inner"), "outer")).toThrow(
/INVALID_OPTIONS/
);
});
it("rethrowAsOtp wraps a non-Error / non-Zod value", {
timeout: 5000,
}, () => {
expect(() => rethrowAsOtp("oops", "outer")).toThrow(/INVALID_OPTIONS/);
});
});
describe("OCRA mutual-auth — class wrappers with `extra` parameter", () => {
it("OcraMutualAuthClient.respond accepts extra params", {
timeout: 5000,
}, () => {
const Qc = "ABCD1234";
const Qs = "WXYZ9999";
// Use a counter-aware suite so the `extra` is meaningful.
const SUITES = {
server: "OCRA-1:HOTP-SHA1-6:C-QA32",
client: "OCRA-1:HOTP-SHA1-6:C-QA32",
};
const server = new OcraMutualAuthServer(SUITES, KEY, Qs);
const client = new OcraMutualAuthClient(SUITES, KEY, Qc);
const Rs = server.respond(Qc, { counter: 1n });
const Rc = client.respond(Qs, Rs, { counter: 1n });
expect(server.verify(Qc, Rc, { counter: 1n })).toBe(true);
});
});
describe("decodeRecoveryHash — round-trip from hashRecoveryCode", () => {
const fastParams = {
memoryKB: 8,
iterations: 1,
parallelism: 1,
saltBytes: 8,
length: 16,
} as const;
it("a freshly hashed code decodes to its params", {
timeout: 30000,
}, async () => {
const s = await hashRecoveryCode("ABCD-1234", { params: fastParams });
const back = decodeRecoveryHash(s);
expect(back.params).toEqual(fastParams);
expect(back.derived.length).toBe(fastParams.length);
expect(back.salt.length).toBe(fastParams.saltBytes);
});
});
describe("ocra — POW10 fallback for digits=4 sanity", () => {
it("digits=4 produces 4-char output (POW10[4] hit)", {
timeout: 5000,
}, () => {
const r = ocra("OCRA-1:HOTP-SHA1-4:QN08", KEY, {
challenge: "00000000",
});
expect(r).toHaveLength(4);
});
});
describe("otpError — non-Error cause coverage (codes.ts:35 branches)", () => {
it("returns OtpError for plain-object cause", { timeout: 5000 }, () => {
const e = otpError(OTP_CODES.INVALID_OPTIONS, "msg", {
cause: { something: "weird" },
});
expect(e.cause).toBeUndefined();
});
});

View File

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

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock } from "@suaveplan/types";
import { totp } from "../totp/totp.js";
import { detectDrift } from "./detect.js";
const ENCODER = new TextEncoder();
const SECRET = ENCODER.encode("12345678901234567890");
const fixed =
(ms: number): Clock =>
() =>
ms;
describe("detectDrift", () => {
it("reports delta=0 for the current step", { timeout: 5000 }, () => {
const code = totp(SECRET, { clock: fixed(60_000) });
const r = detectDrift(code, SECRET, { clock: fixed(60_000) });
expect(r).toEqual({ delta: 0, suggestedClockOffsetMs: 0 });
});
it("reports negative delta when device clock is fast", {
timeout: 5000,
}, () => {
// Device generated code at T=0; verifier is at T=120s — device is 120 s
// BEHIND, so delta is -4 (4 steps behind).
const past = totp(SECRET, { clock: fixed(0) });
const r = detectDrift(past, SECRET, { clock: fixed(120_000) });
expect(r.delta).toBe(-4);
expect(r.suggestedClockOffsetMs).toBe(-4 * 30 * 1000);
});
it("returns null delta when no match within ±maxSteps", {
timeout: 5000,
}, () => {
const r = detectDrift("000000", SECRET, {
clock: fixed(0),
maxSteps: 5,
});
expect(r).toEqual({ delta: null, suggestedClockOffsetMs: null });
});
it("scans wider window than verify default", {
timeout: 5000,
}, () => {
// 30 steps in the past — outside verify's ±1 window but inside drift
// detector's default ±60.
const ancient = totp(SECRET, { clock: fixed(0) });
const r = detectDrift(ancient, SECRET, { clock: fixed(900_000) });
expect(r.delta).toBe(-30);
});
it("guards against negative candidate steps near time origin", {
timeout: 5000,
}, () => {
// T=0 with maxSteps=60 sweeps the range [-60..60]; every negative
// candidate step trips the `if (candidateStep < 0n) continue` guard.
const code = totp(SECRET, { clock: fixed(0) });
const r = detectDrift(code, SECRET, { clock: fixed(0) });
expect(r.delta).toBe(0);
});
it("uses default opts={} when none are supplied", {
timeout: 5000,
}, () => {
const r = detectDrift("000000", SECRET);
expect(typeof r.delta === "number" || r.delta === null).toBe(true);
});
});

View File

@@ -0,0 +1,45 @@
import { hotp } from "../hotp/hotp.js";
import { computeTimeStep, resolveTotpOptions } from "../totp/totp.js";
import { constantTimeStringEqual } from "../totp/verify.js";
import type { DriftDetectOptions, DriftResult } from "./drift.types.js";
const DEFAULT_MAX_STEPS = 60;
/**
* Search outside the verify window for clock drift. Returns the signed step
* delta and the inferred clock offset in milliseconds so the calling
* application can render `"your device clock is N seconds off"`. The search
* runs to completion (constant-time across `±maxSteps`) so loop position does
* not leak which step matched.
*/
export function detectDrift(
code: string,
secret: Uint8Array,
opts: DriftDetectOptions = {}
): DriftResult {
const maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS;
const resolved = resolveTotpOptions(opts);
const baseStep = computeTimeStep(
resolved.clock,
resolved.period,
resolved.t0
);
let matchedDelta: number | null = null;
for (let delta = -maxSteps; delta <= maxSteps; delta += 1) {
const candidateStep = baseStep + BigInt(delta);
if (candidateStep < 0n) continue;
const candidate = hotp(secret, candidateStep, {
algorithm: resolved.algorithm,
digits: resolved.digits,
});
const match = constantTimeStringEqual(code, candidate);
if (match && matchedDelta === null) matchedDelta = delta;
}
if (matchedDelta === null) {
return { delta: null, suggestedClockOffsetMs: null };
}
return {
delta: matchedDelta,
suggestedClockOffsetMs: matchedDelta * resolved.period * 1000,
};
}

View File

@@ -0,0 +1,17 @@
import type { TotpOptions } from "../totp/totp.types.js";
/** Options for `detectDrift`. Inherits TOTP options for algorithm/digits/period
* and adds a search-step cap. */
export interface DriftDetectOptions extends TotpOptions {
/** Maximum steps to search in either direction. Defaults to `60`
* (`±30 minutes` at the default `period=30`). */
maxSteps?: number;
}
/** Result of `detectDrift`. */
export interface DriftResult {
/** Signed step offset on success (`0` for current step), `null` on miss. */
delta: number | null;
/** Inferred clock offset in milliseconds, derived from `delta * period`. */
suggestedClockOffsetMs: number | null;
}

View File

@@ -0,0 +1,2 @@
export { detectDrift } from "./detect.js";
export type { DriftDetectOptions, DriftResult } from "./drift.types.js";

View File

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

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { OTP_CODES, OtpError, otpError } from "./codes.js";
describe("OTP_CODES registry", () => {
it("contains exactly 12 codes", { timeout: 5000 }, () => {
const keys = Object.keys(OTP_CODES);
expect(keys.length).toBe(12);
});
it("every value is the prefixed `OTP.<NAME>` form", {
timeout: 5000,
}, () => {
for (const [k, v] of Object.entries(OTP_CODES)) {
expect(v).toBe(`OTP.${k}`);
}
});
});
describe("otpError", () => {
it("constructs with prefixed message + code", {
timeout: 5000,
}, () => {
const e = otpError(OTP_CODES.INVALID_OPTIONS, "boom");
expect(e).toBeInstanceOf(OtpError);
expect(e.code).toBe(OTP_CODES.INVALID_OPTIONS);
expect(e.message).toBe("OTP.INVALID_OPTIONS: boom");
});
it("propagates Error cause", { timeout: 5000 }, () => {
const cause = new Error("inner");
const e = otpError(OTP_CODES.INVALID_OPTIONS, "outer", { cause });
expect(e.cause).toBe(cause);
});
it("ignores non-Error cause", { timeout: 5000 }, () => {
const e = otpError(OTP_CODES.INVALID_OPTIONS, "outer", {
cause: "not-an-error",
});
expect(e.cause).toBeUndefined();
});
});

View File

@@ -0,0 +1,55 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable, registered error codes for `@suaveplan/otp`.
*
* Each code maps to a specific failure class so callers can branch on the root
* cause rather than parse the message. Coverage spans HOTP option validation,
* TOTP verify-window bounds, RFC 4648 §6 base32 alphabet violations, RFC 6287
* OCRA suite-string parser failures, the Google Authenticator Key URI Format,
* and the recovery-code subsystem.
*/
export const OTP_CODES = defineCodes("OTP", [
"INVALID_OPTIONS",
"INVALID_SECRET",
"INVALID_DIGITS",
"INVALID_PERIOD",
"URI_MALFORMED",
"URI_UNSUPPORTED_TYPE",
"BASE32_INVALID",
"VERIFY_FAILED",
"WINDOW_OUT_OF_RANGE",
"OCRA_SUITE_INVALID",
"OCRA_PARAMS_INVALID",
"RECOVERY_CODE_INVALID",
] as const);
/** The literal-typed union of every code registered above. */
export type OtpErrorCode = (typeof OTP_CODES)[keyof typeof OTP_CODES];
/** Base class for every error thrown by `@suaveplan/otp`. */
export class OtpError extends SuaveplanError<OtpErrorCode> {
constructor(
code: OtpErrorCode,
message: string,
options: { cause?: Error } = {}
) {
super(code, message, options);
}
}
/**
* Helper that constructs an `OtpError` carrying a stable code and an optional
* underlying cause. Centralised so call sites stay terse and so message-matching
* consumers (`expect(...).toThrow(/CODE/)` and log scrapers) get a stable
* substring without inspecting `.code`.
*/
export function otpError(
code: OtpErrorCode,
message: string,
options: { cause?: unknown } = {}
): OtpError {
const opts: { cause?: Error } = {};
if (options.cause instanceof Error) opts.cause = options.cause;
return new OtpError(code, `${code}: ${message}`, opts);
}

View File

@@ -0,0 +1,6 @@
export {
OTP_CODES,
OtpError,
type OtpErrorCode,
otpError,
} from "./codes.js";

View File

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

View File

@@ -0,0 +1,37 @@
import type { ZodError } from "zod";
import { OTP_CODES, type OtpErrorCode, otpError } from "./codes.js";
/**
* Translate a `ZodError` into a stable `OtpError`. The first issue's `path[0]`
* is mapped to a specific code so callers branching on `.code` see meaningful
* values (`OTP.INVALID_DIGITS`, `OTP.WINDOW_OUT_OF_RANGE`, `OTP.INVALID_PERIOD`)
* rather than a generic `INVALID_OPTIONS` whenever the Zod boundary trips. Any
* unmapped path falls back to `fallback`.
*/
export function mapZodToOtp(
err: ZodError,
fallback: OtpErrorCode = OTP_CODES.INVALID_OPTIONS
): OtpErrorCode {
const issue = err.issues[0];
if (!issue) return fallback;
const head = issue.path[0];
if (head === "digits") return OTP_CODES.INVALID_DIGITS;
if (head === "window") return OTP_CODES.WINDOW_OUT_OF_RANGE;
if (head === "period" || head === "t0") return OTP_CODES.INVALID_PERIOD;
return fallback;
}
/** Convenience: throw the mapped error directly. */
export function rethrowAsOtp(
err: unknown,
message: string,
fallback: OtpErrorCode = OTP_CODES.INVALID_OPTIONS
): never {
if (err && typeof err === "object" && "issues" in err) {
const code = mapZodToOtp(err as ZodError, fallback);
throw otpError(code, message, { cause: err as unknown as Error });
}
throw otpError(fallback, message, {
cause: err instanceof Error ? err : undefined,
});
}

View File

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

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { encodeCounter } from "./encode-counter.js";
describe("encodeCounter — RFC 4226 §5.1 8-byte big-endian", () => {
it("encodes 0 to all-zero block", { timeout: 5000 }, () => {
expect(Array.from(encodeCounter(0n))).toEqual([0, 0, 0, 0, 0, 0, 0, 0]);
});
it("encodes 1 to ...0x01", { timeout: 5000 }, () => {
expect(Array.from(encodeCounter(1n))).toEqual([0, 0, 0, 0, 0, 0, 0, 1]);
});
it("encodes 2^32 - 1 to 0x00..0xFF FF FF FF", { timeout: 5000 }, () => {
expect(Array.from(encodeCounter((1n << 32n) - 1n))).toEqual([
0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff,
]);
});
it("encodes 2^64 - 1 to all-0xFF block", { timeout: 5000 }, () => {
expect(Array.from(encodeCounter((1n << 64n) - 1n))).toEqual([
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
]);
});
it("rejects negative counter", { timeout: 5000 }, () => {
expect(() => encodeCounter(-1n)).toThrow(/INVALID_OPTIONS/);
});
it("rejects counter > 2^64 - 1", { timeout: 5000 }, () => {
expect(() => encodeCounter(1n << 64n)).toThrow(/INVALID_OPTIONS/);
});
});

View File

@@ -0,0 +1,31 @@
import { OTP_CODES, otpError } from "../errors/codes.js";
const MAX_U64 = (1n << 64n) - 1n;
/**
* Encode an HOTP counter as the 8-byte big-endian block required by RFC 4226
* §5.1. `bigint` is the canonical input type — `Number` loses precision past
* `2^53` and the RFC permits counters up to `2^64 - 1`. Negative counters and
* counters past `2^64 - 1` raise `OtpError(OTP.INVALID_OPTIONS)`.
*/
export function encodeCounter(counter: bigint): Uint8Array {
if (counter < 0n) {
throw otpError(
OTP_CODES.INVALID_OPTIONS,
"HOTP counter must be non-negative (RFC 4226 §5.1)"
);
}
if (counter > MAX_U64) {
throw otpError(
OTP_CODES.INVALID_OPTIONS,
"HOTP counter exceeds 2^64-1 (RFC 4226 §5.1 mandates an 8-byte big-endian block)"
);
}
const out = new Uint8Array(8);
let v = counter;
for (let i = 7; i >= 0; i -= 1) {
out[i] = Number(v & 0xffn);
v >>= 8n;
}
return out;
}

View File

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

View File

@@ -0,0 +1,26 @@
import { hmac as nobleHmac } from "@noble/hashes/hmac";
import { sha1 } from "@noble/hashes/sha1";
import { hmac as suaveplanHmac } from "@suaveplan/crypto-hash";
import type { HotpAlgorithm } from "./hotp.types.js";
/**
* Compute HMAC over `data` keyed by `key` using one of the algorithms RFC 6238
* §1.2 permits for OTP. SHA-256 and SHA-512 route through `@suaveplan/crypto-hash`
* (the canonical HMAC façade); SHA-1 routes through `@noble/hashes/sha1`
* directly because crypto-hash does not expose SHA-1 (it is RFC-mandated for
* OTP backwards-compat ONLY and must not be presented as a general-purpose
* choice elsewhere in the platform).
*/
export function hmacFor(
algorithm: HotpAlgorithm,
key: Uint8Array,
data: Uint8Array
): Uint8Array {
if (algorithm === "SHA1") {
return nobleHmac(sha1, key, data);
}
if (algorithm === "SHA256") {
return suaveplanHmac("SHA-256", key, data);
}
return suaveplanHmac("SHA-512", key, data);
}

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
import z from "zod";
/**
* Zod boundary for `HotpOptions`. Strict-mode rejection of unknown keys keeps
* silent typos from masquerading as valid configuration; mismatches surface as
* `OtpError(OTP.INVALID_OPTIONS)` at the call boundary.
*/
export const HotpOptionsSchema = z
.object({
algorithm: z.enum(["SHA1", "SHA256", "SHA512"]).optional(),
digits: z.union([z.literal(6), z.literal(7), z.literal(8)]).optional(),
})
.strict();
export type HotpOptionsInput = z.infer<typeof HotpOptionsSchema>;

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { hotp } from "./hotp.js";
const ENCODER = new TextEncoder();
// RFC 4226 Appendix D shared secret: ASCII "12345678901234567890".
const RFC4226_SECRET = ENCODER.encode("12345678901234567890");
// RFC 4226 Appendix D test vectors (counter, expected 6-digit HOTP at SHA-1).
const RFC4226_VECTORS: ReadonlyArray<[bigint, string]> = [
[0n, "755224"],
[1n, "287082"],
[2n, "359152"],
[3n, "969429"],
[4n, "338314"],
[5n, "254676"],
[6n, "287922"],
[7n, "162583"],
[8n, "399871"],
[9n, "520489"],
];
// RFC 6238 Appendix B uses a wider 32 / 64-byte secret for SHA-256 / SHA-512.
const RFC6238_SHA256_SECRET = ENCODER.encode(
"12345678901234567890123456789012"
);
const RFC6238_SHA512_SECRET = ENCODER.encode(
"1234567890123456789012345678901234567890123456789012345678901234"
);
describe("hotp — RFC 4226 Appendix D vectors", () => {
for (const [counter, expected] of RFC4226_VECTORS) {
it(`RFC 4226 vector counter=${counter}`, { timeout: 5000 }, () => {
expect(hotp(RFC4226_SECRET, counter)).toBe(expected);
});
}
});
describe("hotp — algorithm widening", () => {
it("SHA-256 produces 8-digit code distinct from SHA-1", {
timeout: 5000,
}, () => {
const a = hotp(RFC6238_SHA256_SECRET, 1n, {
algorithm: "SHA256",
digits: 8,
});
const b = hotp(RFC4226_SECRET, 1n, {
algorithm: "SHA1",
digits: 8,
});
expect(a).toHaveLength(8);
expect(b).toHaveLength(8);
expect(a).not.toBe(b);
});
it("SHA-512 produces 8-digit code", { timeout: 5000 }, () => {
const code = hotp(RFC6238_SHA512_SECRET, 1n, {
algorithm: "SHA512",
digits: 8,
});
expect(code).toHaveLength(8);
expect(/^\d{8}$/u.test(code)).toBe(true);
});
});
describe("hotp — digits widening", () => {
it("digits=7 returns 7 chars", { timeout: 5000 }, () => {
expect(hotp(RFC4226_SECRET, 0n, { digits: 7 })).toHaveLength(7);
});
it("digits=8 returns 8 chars", { timeout: 5000 }, () => {
expect(hotp(RFC4226_SECRET, 0n, { digits: 8 })).toHaveLength(8);
});
});
describe("hotp — bigint counter beyond Number.MAX_SAFE_INTEGER", () => {
it("counter=2^53+1 round-trips through encode-counter", {
timeout: 5000,
}, () => {
const counter = (1n << 53n) + 1n;
const code = hotp(RFC4226_SECRET, counter);
expect(/^\d{6}$/u.test(code)).toBe(true);
});
});
describe("hotp — option / secret validation", () => {
it("rejects empty secret", { timeout: 5000 }, () => {
expect(() => hotp(new Uint8Array(0), 0n)).toThrow(/INVALID_SECRET/);
});
it("rejects unknown algorithm via Zod boundary", {
timeout: 5000,
}, () => {
expect(() =>
hotp(RFC4226_SECRET, 0n, {
algorithm: "MD5" as unknown as "SHA1",
})
).toThrow(/INVALID_OPTIONS/);
});
it("rejects digits=5 via Zod boundary (mapped to INVALID_DIGITS)", {
timeout: 5000,
}, () => {
expect(() =>
hotp(RFC4226_SECRET, 0n, {
digits: 5 as unknown as 6,
})
).toThrow(/INVALID_DIGITS/);
});
});

View File

@@ -0,0 +1,52 @@
import { OTP_CODES, otpError } from "../errors/codes.js";
import { rethrowAsOtp } from "../errors/zod-map.js";
import { recordOtpError } from "../observability/metrics.js";
import { encodeCounter } from "./encode-counter.js";
import { hmacFor } from "./hmac.js";
import { HotpOptionsSchema } from "./hotp.schema.js";
import type { HotpAlgorithm, HotpDigits, HotpOptions } from "./hotp.types.js";
const POW10: readonly number[] = [
1, 10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000,
];
/**
* RFC 4226 HOTP. Computes `value = DT(HMAC-H(K, C)) mod 10^digits` and returns
* a `digits`-length zero-padded decimal string. `H` defaults to SHA-1 per
* §5.1; RFC 6238 §1.2 widens the choice to SHA-256 / SHA-512. Counter is a
* `bigint` and is encoded as an 8-byte big-endian block per §5.1.
*/
export function hotp(
secret: Uint8Array,
counter: bigint,
opts: HotpOptions = {}
): string {
let parsed: ReturnType<typeof HotpOptionsSchema.parse>;
try {
parsed = HotpOptionsSchema.parse(opts);
} catch (cause) {
recordOtpError(OTP_CODES.INVALID_OPTIONS);
rethrowAsOtp(cause, "invalid HOTP options");
}
if (secret.length === 0) {
recordOtpError(OTP_CODES.INVALID_SECRET);
throw otpError(
OTP_CODES.INVALID_SECRET,
"HOTP secret must not be empty"
);
}
const algorithm: HotpAlgorithm = parsed.algorithm ?? "SHA1";
const digits: HotpDigits = parsed.digits ?? 6;
const counterBlock = encodeCounter(counter);
const hs = hmacFor(algorithm, secret, counterBlock);
// RFC 4226 §5.3 step 2: dynamic truncation
const offset = (hs[hs.length - 1] as number) & 0x0f;
const binCode =
(((hs[offset] as number) & 0x7f) << 24) |
(((hs[offset + 1] as number) & 0xff) << 16) |
(((hs[offset + 2] as number) & 0xff) << 8) |
((hs[offset + 3] as number) & 0xff);
const modulus = POW10[digits] as number;
const value = binCode % modulus;
return value.toString(10).padStart(digits, "0");
}

View File

@@ -0,0 +1,13 @@
/** RFC 6238 §1.2 widens HOTP to SHA-256 / SHA-512. */
export type HotpAlgorithm = "SHA1" | "SHA256" | "SHA512";
/** RFC 4226 §5.3 final paragraph permits 6 digits; RFC 6238 §1.2 widens to 7/8. */
export type HotpDigits = 6 | 7 | 8;
/** Options accepted by `hotp`. All fields optional with RFC defaults. */
export interface HotpOptions {
/** Defaults to `"SHA1"` per RFC 4226 §5.1. */
algorithm?: HotpAlgorithm;
/** Defaults to `6` per RFC 4226 §5.3. */
digits?: HotpDigits;
}

View File

@@ -0,0 +1,9 @@
export { encodeCounter } from "./encode-counter.js";
export { hmacFor } from "./hmac.js";
export { hotp } from "./hotp.js";
export { HotpOptionsSchema } from "./hotp.schema.js";
export type {
HotpAlgorithm,
HotpDigits,
HotpOptions,
} from "./hotp.types.js";

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("subpath exports", () => {
it("root barrel resolves", { timeout: 5000 }, async () => {
const m = await import("./index.js");
expect(typeof m.hotp).toBe("function");
expect(typeof m.totp).toBe("function");
expect(typeof m.verify).toBe("function");
expect(typeof m.verifyMany).toBe("function");
expect(typeof m.detectDrift).toBe("function");
expect(typeof m.base32Encode).toBe("function");
expect(typeof m.base32Decode).toBe("function");
expect(typeof m.generateRecoveryCodes).toBe("function");
expect(typeof m.hashRecoveryCode).toBe("function");
expect(typeof m.verifyRecoveryCode).toBe("function");
});
it("./uri subpath resolves", { timeout: 5000 }, async () => {
const m = await import("./uri/index.js");
expect(typeof m.parseUri).toBe("function");
expect(typeof m.formatUri).toBe("function");
});
it("./base32 subpath resolves", { timeout: 5000 }, async () => {
const m = await import("./base32/index.js");
expect(typeof m.base32Encode).toBe("function");
expect(typeof m.base32Decode).toBe("function");
});
it("./ocra subpath resolves", { timeout: 5000 }, async () => {
const m = await import("./ocra/index.js");
expect(typeof m.ocra).toBe("function");
expect(typeof m.parseOcraSuite).toBe("function");
expect(typeof m.verifyOcra).toBe("function");
expect(typeof m.OcraMutualAuthClient).toBe("function");
expect(typeof m.OcraMutualAuthServer).toBe("function");
});
it("./errors subpath resolves", { timeout: 5000 }, async () => {
const m = await import("./errors/index.js");
expect(typeof m.OTP_CODES).toBe("object");
expect(typeof m.OtpError).toBe("function");
expect(typeof m.otpError).toBe("function");
});
it("./observability subpath resolves", {
timeout: 5000,
}, async () => {
const m = await import("./observability/index.js");
expect(typeof m.recordGenerate).toBe("function");
});
});

View File

@@ -0,0 +1,74 @@
// Errors
// Base32 (RFC 4648 §6)
export {
type Base32EncodeOptions,
base32Decode,
base32Encode,
} from "./base32/base32.js";
// Clock-drift detector
export { detectDrift } from "./drift/detect.js";
export type { DriftDetectOptions, DriftResult } from "./drift/drift.types.js";
export {
OTP_CODES,
OtpError,
type OtpErrorCode,
otpError,
} from "./errors/codes.js";
// HOTP (RFC 4226)
export { encodeCounter } from "./hotp/encode-counter.js";
export { hmacFor } from "./hotp/hmac.js";
export { hotp } from "./hotp/hotp.js";
export { HotpOptionsSchema } from "./hotp/hotp.schema.js";
export type {
HotpAlgorithm,
HotpDigits,
HotpOptions,
} from "./hotp/hotp.types.js";
// Observability
export {
deltaBucket,
type OtpKind,
type OtpMetricAlgorithm,
recordGenerate,
recordOtpError,
recordOutcome,
recordVerify,
resetOtpMetricsForTesting,
} from "./observability/metrics.js";
// Recovery codes
export { bytesFromRng, generateRecoveryCodes } from "./recovery/generate.js";
export {
decodeRecoveryHash,
deriveAgainst,
encodeRecoveryHash,
hashRecoveryCode,
normaliseRecoveryCode,
} from "./recovery/hash.js";
export {
RECOVERY_DEFAULT_ARGON2ID_PARAMS,
type RecoveryArgon2idParams,
type RecoveryCodeOptions,
type RecoveryHashOptions,
type RecoveryVerifyResult,
} from "./recovery/recovery.types.js";
export { verifyRecoveryCode } from "./recovery/verify.js";
// TOTP (RFC 6238)
export {
computeTimeStep,
resolveTotpOptions,
type TotpDefaults,
totp,
} from "./totp/totp.js";
export {
TotpOptionsSchema,
TotpVerifyOptionsSchema,
} from "./totp/totp.schema.js";
export type {
TotpOptions,
TotpVerifyManyResult,
TotpVerifyOptions,
TotpVerifyResult,
} from "./totp/totp.types.js";
export { constantTimeStringEqual, verify } from "./totp/verify.js";
export { verifyMany } from "./totp/verify-many.js";

View File

@@ -0,0 +1,10 @@
export {
deltaBucket,
type OtpKind,
type OtpMetricAlgorithm,
recordGenerate,
recordOtpError,
recordOutcome,
recordVerify,
resetOtpMetricsForTesting,
} from "./metrics.js";

View File

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

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { OTP_CODES } from "../errors/codes.js";
import {
deltaBucket,
recordGenerate,
recordOtpError,
recordOutcome,
recordVerify,
resetOtpMetricsForTesting,
} from "./metrics.js";
describe("deltaBucket", () => {
it("buckets every supported delta value", { timeout: 5000 }, () => {
expect(deltaBucket(null)).toBe("n/a");
expect(deltaBucket(0)).toBe("0");
expect(deltaBucket(1)).toBe("+-1");
expect(deltaBucket(-1)).toBe("+-1");
expect(deltaBucket(2)).toBe("+-2..N");
expect(deltaBucket(-99)).toBe("+-2..N");
});
});
describe("metrics — emission API smoke (no-op meter provider)", () => {
it("recordGenerate does not throw", { timeout: 5000 }, () => {
expect(() => recordGenerate("totp", "SHA1", 1.5)).not.toThrow();
expect(() => recordGenerate("hotp", "SHA256", 2)).not.toThrow();
expect(() => recordGenerate("ocra", "SHA512", 3)).not.toThrow();
});
it("recordVerify does not throw", { timeout: 5000 }, () => {
expect(() => recordVerify("totp", "SHA1", 1)).not.toThrow();
});
it("recordOutcome does not throw for both branches", {
timeout: 5000,
}, () => {
expect(() => recordOutcome("valid", 0)).not.toThrow();
expect(() => recordOutcome("invalid", null)).not.toThrow();
});
it("recordOtpError does not throw", { timeout: 5000 }, () => {
expect(() => recordOtpError(OTP_CODES.INVALID_OPTIONS)).not.toThrow();
});
it("resetOtpMetricsForTesting clears the cache", {
timeout: 5000,
}, () => {
expect(() => resetOtpMetricsForTesting()).not.toThrow();
// First call after reset hits the create branch in every instrument
// factory; the second hits the cached branch.
expect(() => recordGenerate("totp", "SHA1", 0)).not.toThrow();
expect(() => recordGenerate("totp", "SHA1", 1)).not.toThrow();
expect(() => recordVerify("totp", "SHA1", 0)).not.toThrow();
expect(() => recordVerify("totp", "SHA1", 1)).not.toThrow();
expect(() => recordOutcome("valid", 0)).not.toThrow();
expect(() => recordOutcome("valid", 0)).not.toThrow();
expect(() => recordOtpError(OTP_CODES.INVALID_OPTIONS)).not.toThrow();
expect(() => recordOtpError(OTP_CODES.INVALID_OPTIONS)).not.toThrow();
});
});

View File

@@ -0,0 +1,113 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
import type { OtpErrorCode } from "../errors/codes.js";
const METER_NAME = "@suaveplan/otp";
let cachedGenerate: Histogram | undefined;
let cachedVerify: Histogram | undefined;
let cachedOutcome: Counter | undefined;
let cachedError: Counter | undefined;
function meter() {
return metrics.getMeter(METER_NAME);
}
/** OTel meter accessors are lazily resolved so a host that swaps the global
* `MeterProvider` after import (e.g. test fixtures) still observes emissions
* against the intended provider. */
function generateHistogram(): Histogram {
if (!cachedGenerate) {
cachedGenerate = meter().createHistogram("otp.generate_duration_ms", {
description:
"Wall time of `hotp` / `totp` / `ocra` code generation",
unit: "ms",
});
}
return cachedGenerate;
}
function verifyHistogram(): Histogram {
if (!cachedVerify) {
cachedVerify = meter().createHistogram("otp.verify_duration_ms", {
description:
"Wall time of `verify` / `verifyOcra` code verification",
unit: "ms",
});
}
return cachedVerify;
}
function outcomeCounter(): Counter {
if (!cachedOutcome) {
cachedOutcome = meter().createCounter("otp.verify_outcome_total", {
description:
"Verify outcomes bucketed by `outcome` and low-cardinality `delta_bucket`",
});
}
return cachedOutcome;
}
function errorCounter(): Counter {
if (!cachedError) {
cachedError = meter().createCounter("otp.errors_total", {
description: "OtpError throws bucketed by stable code",
});
}
return cachedError;
}
/** Reset the cached instruments — used by tests that swap the global
* `MeterProvider` between cases so the next call re-resolves against the new
* provider. Production callers never need this. */
export function resetOtpMetricsForTesting(): void {
cachedGenerate = undefined;
cachedVerify = undefined;
cachedOutcome = undefined;
cachedError = undefined;
}
/** OTP family — `hotp`, `totp`, `ocra`. */
export type OtpKind = "hotp" | "totp" | "ocra";
/** OTP algorithm label as it appears on the metric. */
export type OtpMetricAlgorithm = "SHA1" | "SHA256" | "SHA512";
/** Record a generation duration sample. */
export function recordGenerate(
kind: OtpKind,
algorithm: OtpMetricAlgorithm,
durationMs: number
): void {
generateHistogram().record(durationMs, { type: kind, algorithm });
}
/** Record a verification duration sample. */
export function recordVerify(
kind: OtpKind,
algorithm: OtpMetricAlgorithm,
durationMs: number
): void {
verifyHistogram().record(durationMs, { type: kind, algorithm });
}
/** Bucket a signed `delta` step offset to a low-cardinality enum suitable for
* an OTel attribute. Cardinality is capped at 4 so dashboards stay cheap. */
export function deltaBucket(delta: number | null): string {
if (delta === null) return "n/a";
if (delta === 0) return "0";
if (delta === 1 || delta === -1) return "+-1";
return "+-2..N";
}
/** Record a verify outcome. */
export function recordOutcome(
outcome: "valid" | "invalid",
delta: number | null
): void {
outcomeCounter().add(1, { outcome, delta_bucket: deltaBucket(delta) });
}
/** Increment the error counter for a specific code. */
export function recordOtpError(code: OtpErrorCode): void {
errorCounter().add(1, { code });
}

View File

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

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { encodeChallenge } from "./encode-challenge.js";
describe("encodeChallenge", () => {
it("QN encodes decimal as left-justified hex", { timeout: 5000 }, () => {
const out = encodeChallenge({ format: "N", length: 8 }, "00000000");
expect(out.length).toBe(128);
// 0 → hex "0" right-padded with "0" → "00..." → first byte 0x00.
expect(out[0]).toBe(0x00);
});
it("QN with single odd-length hex pads correctly", {
timeout: 5000,
}, () => {
// Decimal "1" → hex "1" → padded right to "10" → first byte 0x10.
const out = encodeChallenge({ format: "N", length: 8 }, "1");
expect(out[0]).toBe(0x10);
});
it("QA writes ASCII bytes left-justified", {
timeout: 5000,
}, () => {
const out = encodeChallenge({ format: "A", length: 8 }, "ABCD");
expect(out[0]).toBe(0x41);
expect(out[3]).toBe(0x44);
expect(out[4]).toBe(0x00);
});
it("QH writes hex bytes left-justified", {
timeout: 5000,
}, () => {
const out = encodeChallenge({ format: "H", length: 8 }, "deadbeef");
expect(out[0]).toBe(0xde);
expect(out[3]).toBe(0xef);
expect(out[4]).toBe(0x00);
});
it("rejects challenge longer than declared", {
timeout: 5000,
}, () => {
expect(() =>
encodeChallenge({ format: "N", length: 4 }, "12345")
).toThrow(/OCRA_PARAMS_INVALID/);
});
it("rejects non-decimal under QN", { timeout: 5000 }, () => {
expect(() =>
encodeChallenge({ format: "N", length: 8 }, "abc")
).toThrow(/OCRA_PARAMS_INVALID/);
});
it("rejects odd-length hex under QH", { timeout: 5000 }, () => {
expect(() =>
encodeChallenge({ format: "H", length: 8 }, "abc")
).toThrow(/OCRA_PARAMS_INVALID/);
});
it("rejects non-hex under QH", { timeout: 5000 }, () => {
expect(() =>
encodeChallenge({ format: "H", length: 8 }, "zzzz")
).toThrow(/OCRA_PARAMS_INVALID/);
});
});

View File

@@ -0,0 +1,54 @@
import { OTP_CODES, otpError } from "../errors/codes.js";
import type { OcraChallengeInput } from "./ocra.types.js";
const CHALLENGE_BYTES = 128;
/** Encode the OCRA challenge into the 128-byte block per RFC 6287 §5.1.
* - `QN`: parse decimal → bigint → hex → left-justify in the 128-byte buffer.
* - `QA`: write the ASCII bytes left-justified.
* - `QH`: parse hex → bytes, left-justify. */
export function encodeChallenge(
descriptor: OcraChallengeInput,
value: string
): Uint8Array {
if (value.length > descriptor.length) {
throw otpError(
OTP_CODES.OCRA_PARAMS_INVALID,
`challenge length ${value.length} exceeds suite-declared maximum ${descriptor.length}`
);
}
const out = new Uint8Array(CHALLENGE_BYTES);
if (descriptor.format === "N") {
if (!/^\d+$/u.test(value)) {
throw otpError(
OTP_CODES.OCRA_PARAMS_INVALID,
`challenge format 'N' requires a decimal-digit string (got '${value}')`
);
}
const big = BigInt(value);
// Convert to hex (no `0x` prefix) and pad RIGHT with `0` to even length.
let hex = big.toString(16);
if (hex.length % 2 !== 0) hex += "0";
for (let i = 0; i < hex.length; i += 2) {
out[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16);
}
return out;
}
if (descriptor.format === "A") {
for (let i = 0; i < value.length; i += 1) {
out[i] = value.charCodeAt(i) & 0xff;
}
return out;
}
// QH
if (!/^[0-9a-fA-F]+$/u.test(value) || value.length % 2 !== 0) {
throw otpError(
OTP_CODES.OCRA_PARAMS_INVALID,
`challenge format 'H' requires an even-length hex string (got '${value}')`
);
}
for (let i = 0; i < value.length; i += 2) {
out[i / 2] = Number.parseInt(value.slice(i, i + 2), 16);
}
return out;
}

View File

@@ -0,0 +1,29 @@
export { encodeChallenge } from "./encode-challenge.js";
export {
clientProof,
type OcraClientResponseInput,
OcraMutualAuthClient,
OcraMutualAuthServer,
type OcraMutualAuthSuites,
type OcraMutualBase,
type OcraServerVerifyInput,
serverProof,
verifyClientProof,
verifyServerProof,
} from "./mutual-auth.js";
export { buildOcraMessage, type OcraDigits, ocra } from "./ocra.js";
export { OcraParamsSchema } from "./ocra.schema.js";
export type {
OcraChallengeInput,
OcraCounterInput,
OcraCryptoFunction,
OcraDataInput,
OcraHash,
OcraParams,
OcraPasswordInput,
OcraSessionInput,
OcraSuite,
OcraTimestampInput,
} from "./ocra.types.js";
export { parseOcraSuite } from "./suite.js";
export { verifyOcra } from "./verify-ocra.js";

View File

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

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
clientProof,
OcraMutualAuthClient,
OcraMutualAuthServer,
serverProof,
verifyClientProof,
verifyServerProof,
} from "./mutual-auth.js";
const ENCODER = new TextEncoder();
const KEY = ENCODER.encode("12345678901234567890");
const SUITES = {
server: "OCRA-1:HOTP-SHA1-6:QA32",
client: "OCRA-1:HOTP-SHA1-6:QA32",
};
describe("OCRA mutual auth — three-message round-trip", () => {
it("happy path: server proof verifies, client proof verifies", {
timeout: 5000,
}, () => {
const Qc = "CLIENTRND";
const Qs = "SERVERRND";
const Rs = serverProof({
suites: SUITES,
key: KEY,
clientChallenge: Qc,
serverChallenge: Qs,
});
expect(
verifyServerProof({
suites: SUITES,
key: KEY,
clientChallenge: Qc,
serverChallenge: Qs,
serverResponse: Rs,
})
).toBe(true);
const Rc = clientProof({
suites: SUITES,
key: KEY,
clientChallenge: Qc,
serverChallenge: Qs,
serverResponse: Rs,
});
expect(
verifyClientProof({
suites: SUITES,
key: KEY,
clientChallenge: Qc,
serverChallenge: Qs,
clientResponse: Rc,
})
).toBe(true);
});
it("clientProof rejects an invalid server response", {
timeout: 5000,
}, () => {
expect(() =>
clientProof({
suites: SUITES,
key: KEY,
clientChallenge: "Qc",
serverChallenge: "Qs",
serverResponse: "000000",
})
).toThrow(/VERIFY_FAILED/);
});
it("verifyClientProof returns false for tampered response", {
timeout: 5000,
}, () => {
expect(
verifyClientProof({
suites: SUITES,
key: KEY,
clientChallenge: "Qc",
serverChallenge: "Qs",
clientResponse: "000000",
})
).toBe(false);
});
});
describe("OCRA mutual auth — class wrappers", () => {
it("client + server class flow", { timeout: 5000 }, () => {
const Qc = "ABCD1234";
const Qs = "WXYZ9999";
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);
expect(server.verify(Qc, Rc)).toBe(true);
});
it("server.verify returns false for wrong response", {
timeout: 5000,
}, () => {
const server = new OcraMutualAuthServer(SUITES, KEY, "Qs");
expect(server.verify("Qc", "000000")).toBe(false);
});
});

View File

@@ -0,0 +1,148 @@
import { OTP_CODES, otpError } from "../errors/codes.js";
import { ocra } from "./ocra.js";
import type { OcraParams } from "./ocra.types.js";
import { verifyOcra } from "./verify-ocra.js";
/** Per RFC 6287 §7.1 the mutual-auth flow is:
* 1. Client picks a challenge `Qc`, sends to server.
* 2. Server picks `Qs`, computes `Rs = OCRA(suite_s, key, Qc || Qs)`,
* sends `(Qs, Rs)` to client.
* 3. Client verifies `Rs`. On success, computes
* `Rc = OCRA(suite_c, key, Qs || Qc)` and sends `Rc` to server.
* 4. Server verifies `Rc`.
*
* Both sides therefore need to call OCRA with a concatenated challenge —
* the suite's `Q` directive width must accommodate `Qc.length + Qs.length`.
*
* This module exposes typed helpers around the four message constructions so
* consumers don't reinvent the concatenation order. The transport is the
* caller's responsibility — these helpers are pure functions over `Uint8Array`
* keys and challenge strings. */
export interface OcraMutualAuthSuites {
/** Suite used by the server when computing `Rs`. */
server: string;
/** Suite used by the client when computing `Rc`. */
client: string;
}
/** Common shape — both sides know the suites, the key, and both challenges. */
export interface OcraMutualBase {
suites: OcraMutualAuthSuites;
key: Uint8Array;
clientChallenge: string;
serverChallenge: string;
extra?: Omit<OcraParams, "challenge">;
}
/** Inputs the client needs to verify the server proof and produce its own. */
export interface OcraClientResponseInput extends OcraMutualBase {
serverResponse: string;
}
/** Inputs the server needs to verify the client proof. */
export interface OcraServerVerifyInput extends OcraMutualBase {
clientResponse: string;
}
/** Build the server-side OCRA response over `Qc || Qs`. */
export function serverProof(input: OcraMutualBase): string {
return ocra(input.suites.server, input.key, {
...(input.extra ?? {}),
challenge: input.clientChallenge + input.serverChallenge,
});
}
/** Verify the server-side proof. */
export function verifyServerProof(input: OcraClientResponseInput): boolean {
return verifyOcra(input.serverResponse, input.suites.server, input.key, {
...(input.extra ?? {}),
challenge: input.clientChallenge + input.serverChallenge,
});
}
/** Verify the server-side proof, then build the client-side proof over
* `Qs || Qc`. Returns the client proof on success; throws
* `OtpError(OTP.VERIFY_FAILED)` if the server proof is invalid. */
export function clientProof(input: OcraClientResponseInput): string {
if (!verifyServerProof(input)) {
throw otpError(
OTP_CODES.VERIFY_FAILED,
"server proof did not validate against the supplied suite + key"
);
}
return ocra(input.suites.client, input.key, {
...(input.extra ?? {}),
challenge: input.serverChallenge + input.clientChallenge,
});
}
/** Verify the client-side proof. */
export function verifyClientProof(input: OcraServerVerifyInput): boolean {
return verifyOcra(input.clientResponse, input.suites.client, input.key, {
...(input.extra ?? {}),
challenge: input.serverChallenge + input.clientChallenge,
});
}
/** Stateful client helper. Wraps `clientProof` so the consumer just calls
* `respond(serverChallenge, serverResponse)`. */
export class OcraMutualAuthClient {
constructor(
private readonly suites: OcraMutualAuthSuites,
private readonly key: Uint8Array,
private readonly clientChallenge: string
) {}
respond(
serverChallenge: string,
serverResponse: string,
extra?: Omit<OcraParams, "challenge">
): string {
return clientProof({
suites: this.suites,
key: this.key,
clientChallenge: this.clientChallenge,
serverChallenge,
serverResponse,
...(extra !== undefined ? { extra } : {}),
});
}
}
/** Stateful server helper. */
export class OcraMutualAuthServer {
constructor(
private readonly suites: OcraMutualAuthSuites,
private readonly key: Uint8Array,
private readonly serverChallenge: string
) {}
respond(
clientChallenge: string,
extra?: Omit<OcraParams, "challenge">
): string {
return serverProof({
suites: this.suites,
key: this.key,
clientChallenge,
serverChallenge: this.serverChallenge,
...(extra !== undefined ? { extra } : {}),
});
}
verify(
clientChallenge: string,
clientResponse: string,
extra?: Omit<OcraParams, "challenge">
): boolean {
return verifyClientProof({
suites: this.suites,
key: this.key,
clientChallenge,
serverChallenge: this.serverChallenge,
clientResponse,
...(extra !== undefined ? { extra } : {}),
});
}
}

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
import z from "zod";
/** Zod boundary for `OcraParams` — validated where the public surface accepts
* it. Optional fields are explicitly typed so accidental extras surface. */
export const OcraParamsSchema = z
.object({
counter: z.bigint().nonnegative().optional(),
challenge: z.string().min(1),
passwordHash: z.instanceof(Uint8Array).optional(),
sessionInfo: z.instanceof(Uint8Array).optional(),
timestampMs: z.number().int().nonnegative().optional(),
})
.strict();

View File

@@ -0,0 +1,192 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ocra } from "./ocra.js";
const ENCODER = new TextEncoder();
// RFC 6287 Appendix C.1 — OCRA-1:HOTP-SHA1-6:QN08, key = ASCII "12345678901234567890".
const KEY_SHA1 = ENCODER.encode("12345678901234567890");
const RFC6287_C1: ReadonlyArray<[string, string]> = [
["00000000", "237653"],
["11111111", "243178"],
["22222222", "653583"],
["33333333", "740991"],
["44444444", "608993"],
["55555555", "388898"],
["66666666", "816933"],
["77777777", "224598"],
["88888888", "750600"],
["99999999", "294470"],
];
describe("ocra — RFC 6287 Appendix C.1 (OCRA-1:HOTP-SHA1-6:QN08)", () => {
const SUITE = "OCRA-1:HOTP-SHA1-6:QN08";
for (const [challenge, expected] of RFC6287_C1) {
it(`Q=${challenge} → ${expected}`, { timeout: 5000 }, () => {
expect(ocra(SUITE, KEY_SHA1, { challenge })).toBe(expected);
});
}
});
describe("ocra — option / param validation", () => {
it("rejects challenge longer than declared length", {
timeout: 5000,
}, () => {
expect(() =>
ocra("OCRA-1:HOTP-SHA1-6:QN08", KEY_SHA1, {
challenge: "1234567890123456",
})
).toThrow(/OCRA_PARAMS_INVALID/);
});
it("rejects non-decimal challenge under QN format", {
timeout: 5000,
}, () => {
expect(() =>
ocra("OCRA-1:HOTP-SHA1-6:QN08", KEY_SHA1, { challenge: "abcd" })
).toThrow(/OCRA_PARAMS_INVALID/);
});
it("requires counter when suite includes 'C'", {
timeout: 5000,
}, () => {
expect(() =>
ocra("OCRA-1:HOTP-SHA1-6:C-QN08", KEY_SHA1, {
challenge: "00000000",
})
).toThrow(/OCRA_PARAMS_INVALID/);
});
it("requires timestampMs when suite includes 'T'", {
timeout: 5000,
}, () => {
expect(() =>
ocra("OCRA-1:HOTP-SHA1-6:QN08-T1M", KEY_SHA1, {
challenge: "00000000",
})
).toThrow(/OCRA_PARAMS_INVALID/);
});
it("uses timestampMs when suite includes 'T'", {
timeout: 5000,
}, () => {
const a = ocra("OCRA-1:HOTP-SHA1-6:QN08-T1M", KEY_SHA1, {
challenge: "00000000",
timestampMs: 60_000,
});
const b = ocra("OCRA-1:HOTP-SHA1-6:QN08-T1M", KEY_SHA1, {
challenge: "00000000",
timestampMs: 120_000,
});
expect(a).not.toBe(b);
});
it("supports counter when suite includes 'C'", {
timeout: 5000,
}, () => {
const a = ocra("OCRA-1:HOTP-SHA1-6:C-QN08", KEY_SHA1, {
counter: 1n,
challenge: "00000000",
});
const b = ocra("OCRA-1:HOTP-SHA1-6:C-QN08", KEY_SHA1, {
counter: 2n,
challenge: "00000000",
});
expect(a).not.toBe(b);
});
it("requires session info of correct length", {
timeout: 5000,
}, () => {
expect(() =>
ocra("OCRA-1:HOTP-SHA1-6:QN08-S064", KEY_SHA1, {
challenge: "00000000",
sessionInfo: new Uint8Array(8),
})
).toThrow(/OCRA_PARAMS_INVALID/);
});
it("accepts session info of correct length", {
timeout: 5000,
}, () => {
const r = ocra("OCRA-1:HOTP-SHA1-6:QN08-S064", KEY_SHA1, {
challenge: "00000000",
sessionInfo: new Uint8Array(64),
});
expect(/^\d{6}$/u.test(r)).toBe(true);
});
it("requires password hash of expected length", {
timeout: 5000,
}, () => {
expect(() =>
ocra("OCRA-1:HOTP-SHA1-6:QN08-PSHA1", KEY_SHA1, {
challenge: "00000000",
passwordHash: new Uint8Array(10),
})
).toThrow(/OCRA_PARAMS_INVALID/);
});
it("accepts SHA-256 password hash at 32 bytes", {
timeout: 5000,
}, () => {
const r = ocra("OCRA-1:HOTP-SHA1-6:QN08-PSHA256", KEY_SHA1, {
challenge: "00000000",
passwordHash: new Uint8Array(32),
});
expect(r).toHaveLength(6);
});
it("accepts SHA-512 password hash at 64 bytes", {
timeout: 5000,
}, () => {
const r = ocra("OCRA-1:HOTP-SHA1-6:QN08-PSHA512", KEY_SHA1, {
challenge: "00000000",
passwordHash: new Uint8Array(64),
});
expect(r).toHaveLength(6);
});
it("supports digits=4", { timeout: 5000 }, () => {
const r = ocra("OCRA-1:HOTP-SHA1-4:QN08", KEY_SHA1, {
challenge: "00000000",
});
expect(r).toHaveLength(4);
});
it("supports QA challenge format", { timeout: 5000 }, () => {
const r = ocra("OCRA-1:HOTP-SHA1-6:QA08", KEY_SHA1, {
challenge: "ABCD",
});
expect(r).toHaveLength(6);
});
it("supports QH (hex) challenge format", { timeout: 5000 }, () => {
const r = ocra("OCRA-1:HOTP-SHA1-6:QH08", KEY_SHA1, {
challenge: "deadbeef",
});
expect(r).toHaveLength(6);
});
it("rejects malformed hex challenge", { timeout: 5000 }, () => {
expect(() =>
ocra("OCRA-1:HOTP-SHA1-6:QH08", KEY_SHA1, {
challenge: "deadbeg",
})
).toThrow(/OCRA_PARAMS_INVALID/);
});
it("supports T directives in seconds + hours", {
timeout: 5000,
}, () => {
const a = ocra("OCRA-1:HOTP-SHA1-6:QN08-T30S", KEY_SHA1, {
challenge: "00000000",
timestampMs: 60_000,
});
const b = ocra("OCRA-1:HOTP-SHA1-6:QN08-T1H", KEY_SHA1, {
challenge: "00000000",
timestampMs: 60_000,
});
expect(a).not.toBe(b);
});
});

View File

@@ -0,0 +1,123 @@
import { OTP_CODES, otpError } from "../errors/codes.js";
import { encodeCounter } from "../hotp/encode-counter.js";
import { hmacFor } from "../hotp/hmac.js";
import type { HotpDigits } from "../hotp/hotp.types.js";
import { encodeChallenge } from "./encode-challenge.js";
import type { OcraParams, OcraSuite } from "./ocra.types.js";
import { parseOcraSuite } from "./suite.js";
const ENCODER = new TextEncoder();
const POW10: readonly number[] = [
1, 10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000,
];
/**
* RFC 6287 §5 OCRA computation. Builds the data-input message per the parsed
* suite directives, runs HMAC-{H} keyed by `key`, applies HOTP §5.3 dynamic
* truncation, and returns a `digits`-length zero-padded decimal string.
*/
export function ocra(
suite: string,
key: Uint8Array,
params: OcraParams
): string {
const parsed = parseOcraSuite(suite);
const message = buildOcraMessage(parsed, params);
const hs = hmacFor(parsed.cryptoFunction.hash, key, message);
const offset = (hs[hs.length - 1] as number) & 0x0f;
const binCode =
(((hs[offset] as number) & 0x7f) << 24) |
(((hs[offset + 1] as number) & 0xff) << 16) |
(((hs[offset + 2] as number) & 0xff) << 8) |
((hs[offset + 3] as number) & 0xff);
const digits = parsed.cryptoFunction.digits as unknown as number;
// The suite parser restricts `digits` to {4, 6, 7, 8} which are all valid
// POW10 indices; no fallback path needed.
const modulus = POW10[digits] as number;
const value = binCode % modulus;
return value.toString(10).padStart(digits, "0");
}
/** Build the OCRA HMAC message per RFC 6287 §5.1. Exposed for benchmarks +
* vector-driven unit tests. */
export function buildOcraMessage(
parsed: OcraSuite,
params: OcraParams
): Uint8Array {
const chunks: Uint8Array[] = [];
// Suite ASCII || 0x00 separator
chunks.push(ENCODER.encode(parsed.suite));
chunks.push(new Uint8Array([0x00]));
if (parsed.dataInput.counter?.include) {
if (params.counter === undefined) {
throw otpError(
OTP_CODES.OCRA_PARAMS_INVALID,
"OCRA suite includes 'C' but params.counter is undefined"
);
}
chunks.push(encodeCounter(params.counter));
}
chunks.push(encodeChallenge(parsed.dataInput.challenge, params.challenge));
if (parsed.dataInput.password) {
const expected = passwordHashLen(parsed.dataInput.password.hash);
if (!params.passwordHash || params.passwordHash.length !== expected) {
throw otpError(
OTP_CODES.OCRA_PARAMS_INVALID,
`OCRA suite includes 'P${parsed.dataInput.password.hash}' but params.passwordHash is missing or wrong length (expected ${expected})`
);
}
chunks.push(params.passwordHash);
}
if (parsed.dataInput.session) {
const sessionLen = parsed.dataInput.session.length;
if (!params.sessionInfo || params.sessionInfo.length !== sessionLen) {
throw otpError(
OTP_CODES.OCRA_PARAMS_INVALID,
`OCRA suite includes 'S${sessionLen}' but params.sessionInfo is missing or wrong length`
);
}
chunks.push(params.sessionInfo);
}
if (parsed.dataInput.timestamp) {
if (params.timestampMs === undefined) {
throw otpError(
OTP_CODES.OCRA_PARAMS_INVALID,
"OCRA suite includes a 'T' directive but params.timestampMs is undefined"
);
}
const periodSeconds =
parsed.dataInput.timestamp.period *
unitFactor(parsed.dataInput.timestamp.unit);
const t = Math.floor(params.timestampMs / 1000 / periodSeconds);
chunks.push(encodeCounter(BigInt(t)));
}
return concat(chunks);
}
function passwordHashLen(h: "SHA1" | "SHA256" | "SHA512"): number {
if (h === "SHA1") return 20;
if (h === "SHA256") return 32;
return 64;
}
function unitFactor(unit: "S" | "M" | "H"): number {
if (unit === "S") return 1;
if (unit === "M") return 60;
return 3600;
}
function concat(parts: Uint8Array[]): Uint8Array {
let total = 0;
for (const p of parts) total += p.length;
const out = new Uint8Array(total);
let off = 0;
for (const p of parts) {
out.set(p, off);
off += p.length;
}
return out;
}
/** Convenience type re-export so `HotpDigits` is reachable to OCRA consumers
* without depending on the `hotp` subpath directly. */
export type OcraDigits = HotpDigits;

View File

@@ -0,0 +1,71 @@
import type { HotpAlgorithm, HotpDigits } from "../hotp/hotp.types.js";
/** Hash function within an OCRA suite. */
export type OcraHash = HotpAlgorithm;
/** Cryptographic-function block of a parsed suite. */
export interface OcraCryptoFunction {
/** Always `"HOTP"` for OCRA-1. */
algorithm: "HOTP";
hash: OcraHash;
digits: HotpDigits;
}
/** Counter-input flag (`C`). */
export interface OcraCounterInput {
include: boolean;
}
/** Challenge-input descriptor (`Q[A|N|H]NN`). */
export interface OcraChallengeInput {
format: "A" | "N" | "H";
length: number;
}
/** Password-input descriptor (`P{HASH}`). */
export interface OcraPasswordInput {
hash: OcraHash;
}
/** Session-input descriptor (`SNNN`). */
export interface OcraSessionInput {
length: number;
}
/** Timestamp-input descriptor (`TG` where `G` is `S | M | H`). */
export interface OcraTimestampInput {
period: number;
unit: "S" | "M" | "H";
}
/** Parsed `DataInput` block of an OCRA suite. */
export interface OcraDataInput {
counter?: OcraCounterInput;
challenge: OcraChallengeInput;
password?: OcraPasswordInput;
session?: OcraSessionInput;
timestamp?: OcraTimestampInput;
}
/** Parsed OCRA suite (`OCRA-1:HOTP-SHA1-6:QN08` etc). */
export interface OcraSuite {
suite: string;
cryptoFunction: OcraCryptoFunction;
dataInput: OcraDataInput;
}
/** Runtime parameters supplied at compute time. */
export interface OcraParams {
counter?: bigint;
/** Challenge string — `Q[N|A]` accepts decimal / printable ASCII; `QH`
* accepts hex. Validated against `OcraChallengeInput`. */
challenge: string;
/** When the suite includes a `P{HASH}` block, this is the H(P) digest
* (caller-supplied — RFC 6287 §5.1 lets the caller hash the password
* however they prefer). */
passwordHash?: Uint8Array;
/** Session bytes when the suite includes `SNNN`. */
sessionInfo?: Uint8Array;
/** Unix-epoch milliseconds for the timestamp block. */
timestampMs?: number;
}

View File

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

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parseOcraSuite } from "./suite.js";
describe("parseOcraSuite — RFC 6287 §6.1 grammar", () => {
it("parses minimal SHA-1 suite", { timeout: 5000 }, () => {
const s = parseOcraSuite("OCRA-1:HOTP-SHA1-6:QN08");
expect(s.cryptoFunction).toMatchObject({
algorithm: "HOTP",
hash: "SHA1",
digits: 6,
});
expect(s.dataInput.challenge).toEqual({ format: "N", length: 8 });
});
it("parses counter + password + session + timestamp", {
timeout: 5000,
}, () => {
const s = parseOcraSuite(
"OCRA-1:HOTP-SHA256-8:C-QN10-PSHA256-S128-T30S"
);
expect(s.cryptoFunction.hash).toBe("SHA256");
expect(s.cryptoFunction.digits).toBe(8);
expect(s.dataInput.counter).toEqual({ include: true });
expect(s.dataInput.challenge).toEqual({ format: "N", length: 10 });
expect(s.dataInput.password).toEqual({ hash: "SHA256" });
expect(s.dataInput.session).toEqual({ length: 128 });
expect(s.dataInput.timestamp).toEqual({ period: 30, unit: "S" });
});
it("parses minute and hour timestamp units", {
timeout: 5000,
}, () => {
expect(
parseOcraSuite("OCRA-1:HOTP-SHA1-6:QN08-T1M").dataInput.timestamp
).toEqual({ period: 1, unit: "M" });
expect(
parseOcraSuite("OCRA-1:HOTP-SHA1-6:QN08-T2H").dataInput.timestamp
).toEqual({ period: 2, unit: "H" });
});
});
describe("parseOcraSuite — error paths", () => {
it("rejects bad segment count", { timeout: 5000 }, () => {
expect(() => parseOcraSuite("OCRA-1:HOTP-SHA1-6")).toThrow(
/OCRA_SUITE_INVALID/
);
});
it("rejects unknown version", { timeout: 5000 }, () => {
expect(() => parseOcraSuite("OCRA-2:HOTP-SHA1-6:QN08")).toThrow(
/OCRA_SUITE_INVALID/
);
});
it("rejects malformed crypto function", { timeout: 5000 }, () => {
expect(() => parseOcraSuite("OCRA-1:NOTHOTP:QN08")).toThrow(
/OCRA_SUITE_INVALID/
);
});
it("rejects unknown hash", { timeout: 5000 }, () => {
expect(() => parseOcraSuite("OCRA-1:HOTP-MD5-6:QN08")).toThrow(
/OCRA_SUITE_INVALID/
);
});
it("rejects unknown digits", { timeout: 5000 }, () => {
expect(() => parseOcraSuite("OCRA-1:HOTP-SHA1-9:QN08")).toThrow(
/OCRA_SUITE_INVALID/
);
});
it("rejects malformed challenge directive", {
timeout: 5000,
}, () => {
expect(() => parseOcraSuite("OCRA-1:HOTP-SHA1-6:QZ08")).toThrow(
/OCRA_SUITE_INVALID/
);
expect(() => parseOcraSuite("OCRA-1:HOTP-SHA1-6:QN03")).toThrow(
/OCRA_SUITE_INVALID/
);
});
it("rejects malformed password directive", {
timeout: 5000,
}, () => {
expect(() => parseOcraSuite("OCRA-1:HOTP-SHA1-6:QN08-PSHA384")).toThrow(
/OCRA_SUITE_INVALID/
);
});
it("rejects malformed session directive", {
timeout: 5000,
}, () => {
expect(() => parseOcraSuite("OCRA-1:HOTP-SHA1-6:QN08-S0")).toThrow(
/OCRA_SUITE_INVALID/
);
expect(() => parseOcraSuite("OCRA-1:HOTP-SHA1-6:QN08-S99999")).toThrow(
/OCRA_SUITE_INVALID/
);
});
it("rejects malformed timestamp directive", {
timeout: 5000,
}, () => {
expect(() => parseOcraSuite("OCRA-1:HOTP-SHA1-6:QN08-TX")).toThrow(
/OCRA_SUITE_INVALID/
);
});
it("rejects unknown directive", { timeout: 5000 }, () => {
expect(() => parseOcraSuite("OCRA-1:HOTP-SHA1-6:QN08-XBOGUS")).toThrow(
/OCRA_SUITE_INVALID/
);
});
it("rejects suite with no challenge directive", {
timeout: 5000,
}, () => {
expect(() => parseOcraSuite("OCRA-1:HOTP-SHA1-6:C-T1M")).toThrow(
/OCRA_SUITE_INVALID/
);
});
});

View File

@@ -0,0 +1,141 @@
import { OTP_CODES, otpError } from "../errors/codes.js";
import type { HotpDigits } from "../hotp/hotp.types.js";
import type { OcraDataInput, OcraHash, OcraSuite } from "./ocra.types.js";
/**
* Parse an OCRA suite per RFC 6287 §6.1. The grammar is three colon-separated
* segments: `OCRA-1:CryptoFunction:DataInput`. The crypto function is
* `HOTP-{SHA1|SHA256|SHA512}-{4|6|7|8}`. The data input is a sequence of
* directives chained by `-`: `[C-]Q{A|N|H}NN[-PSHAxxx][-Snnn][-T{n}{S|M|H}]`.
*/
export function parseOcraSuite(suite: string): OcraSuite {
const segments = suite.split(":");
if (segments.length !== 3) {
throw otpError(
OTP_CODES.OCRA_SUITE_INVALID,
`OCRA suite must have three ':'-separated segments (got ${segments.length})`
);
}
if (segments[0] !== "OCRA-1") {
throw otpError(
OTP_CODES.OCRA_SUITE_INVALID,
`OCRA suite version must be 'OCRA-1' (got '${segments[0] as string}')`
);
}
const crypto = parseCryptoFunction(segments[1] as string);
const data = parseDataInput(segments[2] as string);
return { suite, cryptoFunction: crypto, dataInput: data };
}
function parseCryptoFunction(s: string): OcraSuite["cryptoFunction"] {
const parts = s.split("-");
if (parts.length !== 3 || parts[0] !== "HOTP") {
throw otpError(
OTP_CODES.OCRA_SUITE_INVALID,
`OCRA crypto function must be 'HOTP-<hash>-<digits>' (got '${s}')`
);
}
const hash = parts[1] as string;
if (hash !== "SHA1" && hash !== "SHA256" && hash !== "SHA512") {
throw otpError(
OTP_CODES.OCRA_SUITE_INVALID,
`OCRA hash must be SHA1 / SHA256 / SHA512 (got '${hash}')`
);
}
const digits = Number.parseInt(parts[2] as string, 10);
if (digits !== 4 && digits !== 6 && digits !== 7 && digits !== 8) {
throw otpError(
OTP_CODES.OCRA_SUITE_INVALID,
`OCRA digits must be 4 / 6 / 7 / 8 (got '${parts[2] as string}')`
);
}
return {
algorithm: "HOTP",
hash: hash as OcraHash,
digits: digits as unknown as HotpDigits,
};
}
function parseDataInput(s: string): OcraDataInput {
const parts = s.split("-");
const out: OcraDataInput = {
challenge: { format: "N", length: 8 },
};
let sawChallenge = false;
for (const raw of parts) {
if (raw === "C") {
out.counter = { include: true };
continue;
}
if (raw.startsWith("Q")) {
const fmt = raw[1];
const len = Number.parseInt(raw.slice(2), 10);
if (
(fmt !== "A" && fmt !== "N" && fmt !== "H") ||
!Number.isInteger(len) ||
len < 4 ||
len > 64
) {
throw otpError(
OTP_CODES.OCRA_SUITE_INVALID,
`OCRA challenge directive malformed (got '${raw}')`
);
}
out.challenge = { format: fmt, length: len };
sawChallenge = true;
continue;
}
if (raw.startsWith("PSHA")) {
const hashSuffix = raw.slice(1);
if (
hashSuffix !== "SHA1" &&
hashSuffix !== "SHA256" &&
hashSuffix !== "SHA512"
) {
throw otpError(
OTP_CODES.OCRA_SUITE_INVALID,
`OCRA password directive malformed (got '${raw}')`
);
}
out.password = { hash: hashSuffix as OcraHash };
continue;
}
if (raw.startsWith("S")) {
const len = Number.parseInt(raw.slice(1), 10);
if (!Number.isInteger(len) || len < 1 || len > 512) {
throw otpError(
OTP_CODES.OCRA_SUITE_INVALID,
`OCRA session directive malformed (got '${raw}')`
);
}
out.session = { length: len };
continue;
}
if (raw.startsWith("T")) {
const body = raw.slice(1);
const m = /^(\d+)([SMH])$/u.exec(body);
if (!m) {
throw otpError(
OTP_CODES.OCRA_SUITE_INVALID,
`OCRA timestamp directive malformed (got '${raw}')`
);
}
out.timestamp = {
period: Number.parseInt(m[1] as string, 10),
unit: m[2] as "S" | "M" | "H",
};
continue;
}
throw otpError(
OTP_CODES.OCRA_SUITE_INVALID,
`OCRA data-input directive unrecognised (got '${raw}')`
);
}
if (!sawChallenge) {
throw otpError(
OTP_CODES.OCRA_SUITE_INVALID,
"OCRA suite must include a Q{A|N|H}NN challenge directive"
);
}
return out;
}

View File

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

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { ocra } from "./ocra.js";
import { verifyOcra } from "./verify-ocra.js";
const ENCODER = new TextEncoder();
const KEY = ENCODER.encode("12345678901234567890");
const SUITE = "OCRA-1:HOTP-SHA1-6:QN08";
describe("verifyOcra", () => {
it("returns true for a correct response", { timeout: 5000 }, () => {
const r = ocra(SUITE, KEY, { challenge: "12345678" });
expect(verifyOcra(r, SUITE, KEY, { challenge: "12345678" })).toBe(true);
});
it("returns false for a tampered response", {
timeout: 5000,
}, () => {
expect(
verifyOcra("000000", SUITE, KEY, { challenge: "12345678" })
).toBe(false);
});
});

View File

@@ -0,0 +1,15 @@
import { constantTimeStringEqual } from "../totp/verify.js";
import { ocra } from "./ocra.js";
import type { OcraParams } from "./ocra.types.js";
/** Constant-time verify: recomputes the OCRA response under the given suite
* and key and compares it to `response` without short-circuiting. */
export function verifyOcra(
response: string,
suite: string,
key: Uint8Array,
params: OcraParams
): boolean {
const expected = ocra(suite, key, params);
return constantTimeStringEqual(response, expected);
}

View File

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

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { bytesFromRng, generateRecoveryCodes } from "./generate.js";
const seedRng = (seed: number): Rng => {
let state = seed >>> 0;
return {
nextUint32: () => {
// Linear-congruential — deterministic for tests, NOT for production.
state = (state * 1103515245 + 12345) >>> 0;
return state;
},
nextFloat: () => 0,
};
};
const seedRngWithBytes = (seed: number): Rng => ({
...seedRng(seed),
nextBytes: (n: number) => {
const out = new Uint8Array(n);
for (let i = 0; i < n; i += 1) out[i] = (seed + i) & 0xff;
return out;
},
});
describe("generateRecoveryCodes", () => {
it("produces N codes of the conventional XXXX-XXXX-XXXX-XXXX shape", {
timeout: 5000,
}, () => {
const codes = generateRecoveryCodes(5);
expect(codes).toHaveLength(5);
for (const c of codes) {
expect(
/^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/.test(c)
).toBe(true);
}
});
it("uses Crockford alphabet — never contains I L O U", {
timeout: 5000,
}, () => {
const codes = generateRecoveryCodes(20);
for (const c of codes) {
expect(/[ILOU]/.test(c)).toBe(false);
}
});
it("is deterministic with a seeded RNG", { timeout: 5000 }, () => {
const a = generateRecoveryCodes(3, { rng: seedRng(42) });
const b = generateRecoveryCodes(3, { rng: seedRng(42) });
expect(a).toEqual(b);
});
it("uses nextBytes when the RNG provides it", {
timeout: 5000,
}, () => {
const a = generateRecoveryCodes(2, { rng: seedRngWithBytes(7) });
const b = generateRecoveryCodes(2, { rng: seedRngWithBytes(7) });
expect(a).toEqual(b);
});
it("supports custom length / groupSize / separator", {
timeout: 5000,
}, () => {
const codes = generateRecoveryCodes(1, {
length: 12,
groupSize: 6,
separator: "_",
});
expect(/^[0-9A-Z]{6}_[0-9A-Z]{6}$/.test(codes[0] as string)).toBe(true);
});
it("rejects non-positive count", { timeout: 5000 }, () => {
expect(() => generateRecoveryCodes(0)).toThrow(/INVALID_OPTIONS/);
expect(() => generateRecoveryCodes(1.5)).toThrow(/INVALID_OPTIONS/);
});
it("rejects non-positive length", { timeout: 5000 }, () => {
expect(() => generateRecoveryCodes(1, { length: 0 })).toThrow(
/INVALID_OPTIONS/
);
});
it("rejects non-positive groupSize", { timeout: 5000 }, () => {
expect(() => generateRecoveryCodes(1, { groupSize: 0 })).toThrow(
/INVALID_OPTIONS/
);
});
});
describe("bytesFromRng", () => {
it("uses the system CSPRNG when no RNG is supplied", {
timeout: 5000,
}, () => {
const a = bytesFromRng(undefined, 16);
const b = bytesFromRng(undefined, 16);
// Two CSPRNG draws should virtually never agree.
expect(Array.from(a)).not.toEqual(Array.from(b));
});
it("falls back to nextUint32 assembly when nextBytes is absent", {
timeout: 5000,
}, () => {
const out = bytesFromRng(seedRng(1), 7);
expect(out.length).toBe(7);
});
it("uses nextBytes when present", { timeout: 5000 }, () => {
const out = bytesFromRng(seedRngWithBytes(0xab), 4);
expect(Array.from(out)).toEqual([0xab, 0xac, 0xad, 0xae]);
});
it("handles n smaller than 4 via nextUint32 fallback (1, 2, 3)", {
timeout: 5000,
}, () => {
// No nextBytes — must walk the nextUint32 assembly with all the
// `i + N < n` guards firing the false-branch.
const a = bytesFromRng(seedRng(1), 1);
const b = bytesFromRng(seedRng(1), 2);
const c = bytesFromRng(seedRng(1), 3);
expect(a.length).toBe(1);
expect(b.length).toBe(2);
expect(c.length).toBe(3);
});
});

View File

@@ -0,0 +1,80 @@
import { randomBytes } from "@suaveplan/crypto-utils";
import type { Rng } from "@suaveplan/types";
import { OTP_CODES, otpError } from "../errors/codes.js";
import type { RecoveryCodeOptions } from "./recovery.types.js";
/** Crockford-base32 alphabet — excludes `I`, `L`, `O`, `U` so handwritten
* codes don't collide with `1`/`0`. */
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/** Bridge an `Rng` (or undefined) to a byte stream — uses `nextBytes` when
* provided, otherwise falls back to assembling bytes from `nextUint32`, and
* defaults to the platform CSPRNG via `randomBytes` when no RNG is supplied. */
export function bytesFromRng(rng: Rng | undefined, n: number): Uint8Array {
if (rng === undefined) return randomBytes(n);
if (rng.nextBytes) return rng.nextBytes(n);
const out = new Uint8Array(n);
let i = 0;
while (i < n) {
const word = rng.nextUint32() >>> 0;
out[i] = (word >>> 24) & 0xff;
if (i + 1 < n) out[i + 1] = (word >>> 16) & 0xff;
if (i + 2 < n) out[i + 2] = (word >>> 8) & 0xff;
if (i + 3 < n) out[i + 3] = word & 0xff;
i += 4;
}
return out;
}
/** Generate `count` Crockford-base32 recovery codes. The default `length=16`
* with `groupSize=4` produces the conventional `XXXX-XXXX-XXXX-XXXX` form. */
export function generateRecoveryCodes(
count: number,
opts: RecoveryCodeOptions = {}
): string[] {
if (!Number.isInteger(count) || count <= 0) {
throw otpError(
OTP_CODES.INVALID_OPTIONS,
`recovery code count must be a positive integer (got ${count})`
);
}
const length = opts.length ?? 16;
const groupSize = opts.groupSize ?? 4;
const separator = opts.separator ?? "-";
if (!Number.isInteger(length) || length <= 0) {
throw otpError(
OTP_CODES.INVALID_OPTIONS,
`recovery code length must be a positive integer (got ${length})`
);
}
if (!Number.isInteger(groupSize) || groupSize <= 0) {
throw otpError(
OTP_CODES.INVALID_OPTIONS,
`recovery code groupSize must be a positive integer (got ${groupSize})`
);
}
const out: string[] = [];
for (let i = 0; i < count; i += 1) {
out.push(generateOne(length, groupSize, separator, opts.rng));
}
return out;
}
function generateOne(
length: number,
groupSize: number,
separator: string,
rng: Rng | undefined
): string {
// Draw `length` random bytes; map each into the 32-character alphabet by
// taking the low 5 bits. The alphabet is power-of-two sized so there is
// no modulo bias.
const bytes = bytesFromRng(rng, length);
let out = "";
for (let i = 0; i < length; i += 1) {
const idx = (bytes[i] as number) & 0x1f;
out += ALPHABET[idx];
if ((i + 1) % groupSize === 0 && i + 1 < length) out += separator;
}
return out;
}

View File

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

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
decodeRecoveryHash,
encodeRecoveryHash,
hashRecoveryCode,
normaliseRecoveryCode,
} from "./hash.js";
import { RECOVERY_DEFAULT_ARGON2ID_PARAMS } from "./recovery.types.js";
import { verifyRecoveryCode } from "./verify.js";
// Argon2id is intentionally slow — these tests use OWASP-minimum parameters
// (`memoryKB=65536`, `iterations=3`) and a small code count to keep the suite
// under the global 30 s testTimeout.
describe("normaliseRecoveryCode", () => {
it("strips spaces + dashes and upper-cases", { timeout: 5000 }, () => {
expect(normaliseRecoveryCode("ab-cd ef-gh")).toBe("ABCDEFGH");
});
});
describe("encodeRecoveryHash / decodeRecoveryHash", () => {
it("round-trips params + salt + derived", { timeout: 5000 }, () => {
const salt = new Uint8Array([1, 2, 3, 4]);
const derived = new Uint8Array([5, 6, 7, 8]);
const s = encodeRecoveryHash(
RECOVERY_DEFAULT_ARGON2ID_PARAMS,
salt,
derived
);
const back = decodeRecoveryHash(s);
expect(back.params).toEqual(RECOVERY_DEFAULT_ARGON2ID_PARAMS);
expect(Array.from(back.salt)).toEqual(Array.from(salt));
expect(Array.from(back.derived)).toEqual(Array.from(derived));
});
it("rejects strings without prefix", { timeout: 5000 }, () => {
expect(() => decodeRecoveryHash("oops")).toThrow(
/RECOVERY_CODE_INVALID/
);
});
it("rejects strings missing segments", { timeout: 5000 }, () => {
expect(() =>
decodeRecoveryHash("recovery$argon2id$v1$1,2,3,4,5")
).toThrow(/RECOVERY_CODE_INVALID/);
});
it("rejects malformed metadata", { timeout: 5000 }, () => {
expect(() =>
decodeRecoveryHash("recovery$argon2id$v1$NaN,2,3,4,5$AA$BB")
).toThrow(/RECOVERY_CODE_INVALID/);
});
});
describe("hashRecoveryCode + verifyRecoveryCode", () => {
// Use minimum valid Argon2id params for speed; production should use the
// OWASP defaults exposed via `RECOVERY_DEFAULT_ARGON2ID_PARAMS`.
const fastParams = {
memoryKB: 8,
iterations: 1,
parallelism: 1,
saltBytes: 8,
length: 16,
} as const;
it("hash → verify happy path matches the right code", {
timeout: 30000,
}, async () => {
const a = await hashRecoveryCode("ABCD-1234", { params: fastParams });
const b = await hashRecoveryCode("WXYZ-9876", { params: fastParams });
const r = await verifyRecoveryCode("ABCD-1234", [a, b]);
expect(r).toEqual({ valid: true, codeIndex: 0 });
});
it("verify is case + separator insensitive", {
timeout: 30000,
}, async () => {
const h = await hashRecoveryCode("abcd-1234", { params: fastParams });
const r = await verifyRecoveryCode("AB CD1234", [h]);
expect(r.valid).toBe(true);
});
it("returns invalid for unknown code", {
timeout: 30000,
}, async () => {
const h = await hashRecoveryCode("ABCD-1234", { params: fastParams });
const r = await verifyRecoveryCode("WRONG-CODE", [h]);
expect(r).toEqual({ valid: false, codeIndex: null });
});
it("uses OWASP defaults when no opts supplied", {
timeout: 30000,
}, async () => {
// Just verifies the default-arg branch fires; no assertion on the
// specific bytes (timing-sensitive in CI).
const s = await hashRecoveryCode("DEFAULTS-PATH");
expect(s.startsWith("recovery$argon2id$v1$")).toBe(true);
});
});

View File

@@ -0,0 +1,110 @@
import { argon2id, fromBase64Url, toBase64Url } from "@suaveplan/crypto-hash";
import type { Rng } from "@suaveplan/types";
import { OTP_CODES, otpError } from "../errors/codes.js";
import { bytesFromRng } from "./generate.js";
import {
RECOVERY_DEFAULT_ARGON2ID_PARAMS,
type RecoveryArgon2idParams,
type RecoveryHashOptions,
} from "./recovery.types.js";
const ENCODER = new TextEncoder();
const PREFIX = "recovery$argon2id$v1$";
/** Encode the (params, salt, derived) triple as a self-describing PHC-like
* string. The format is `recovery$argon2id$v1$<m,t,p,len,saltLen>$<salt>$<derived>`
* with both buffers as base64url. */
export function encodeRecoveryHash(
params: RecoveryArgon2idParams,
salt: Uint8Array,
derived: Uint8Array
): string {
const meta = [
params.memoryKB,
params.iterations,
params.parallelism,
params.length,
params.saltBytes,
].join(",");
return `${PREFIX}${meta}$${toBase64Url(salt)}$${toBase64Url(derived)}`;
}
/** Inverse of `encodeRecoveryHash`. */
export function decodeRecoveryHash(s: string): {
params: RecoveryArgon2idParams;
salt: Uint8Array;
derived: Uint8Array;
} {
if (!s.startsWith(PREFIX)) {
throw otpError(
OTP_CODES.RECOVERY_CODE_INVALID,
"hashed recovery code is not in the expected format"
);
}
const rest = s.slice(PREFIX.length);
const segments = rest.split("$");
if (segments.length !== 3) {
throw otpError(
OTP_CODES.RECOVERY_CODE_INVALID,
"hashed recovery code must have three '$'-separated segments after the prefix"
);
}
const meta = (segments[0] as string).split(",").map((n) => Number(n));
if (meta.length !== 5 || meta.some((n) => !Number.isFinite(n))) {
throw otpError(
OTP_CODES.RECOVERY_CODE_INVALID,
"hashed recovery code metadata is malformed"
);
}
const [memoryKB, iterations, parallelism, length, saltBytes] = meta as [
number,
number,
number,
number,
number,
];
return {
params: { memoryKB, iterations, parallelism, length, saltBytes },
salt: fromBase64Url(segments[1] as string),
derived: fromBase64Url(segments[2] as string),
};
}
/** Hash a recovery code with Argon2id. Returns the self-describing string
* suitable for storage. */
export async function hashRecoveryCode(
code: string,
opts: RecoveryHashOptions = {}
): Promise<string> {
const params = opts.params ?? RECOVERY_DEFAULT_ARGON2ID_PARAMS;
const salt = bytesFromRng(opts.rng as Rng | undefined, params.saltBytes);
const derived = argon2id(ENCODER.encode(normaliseRecoveryCode(code)), {
salt,
timeCost: params.iterations,
memoryCost: params.memoryKB,
parallelism: params.parallelism,
length: params.length,
});
return encodeRecoveryHash(params, salt, derived);
}
/** Compute Argon2id over `code` against a stored hash and return the derived
* bytes for constant-time compare. */
export function deriveAgainst(
code: string,
stored: { params: RecoveryArgon2idParams; salt: Uint8Array }
): Uint8Array {
return argon2id(ENCODER.encode(normaliseRecoveryCode(code)), {
salt: stored.salt,
timeCost: stored.params.iterations,
memoryCost: stored.params.memoryKB,
parallelism: stored.params.parallelism,
length: stored.params.length,
});
}
/** Strip separators and upper-case so the user typing `xxxx-xxxx-xxxx-xxxx`
* matches a stored `XXXXXXXXXXXXXXXX`. */
export function normaliseRecoveryCode(input: string): string {
return input.replace(/[\s-]+/gu, "").toUpperCase();
}

View File

@@ -0,0 +1,16 @@
export { bytesFromRng, generateRecoveryCodes } from "./generate.js";
export {
decodeRecoveryHash,
deriveAgainst,
encodeRecoveryHash,
hashRecoveryCode,
normaliseRecoveryCode,
} from "./hash.js";
export {
RECOVERY_DEFAULT_ARGON2ID_PARAMS,
type RecoveryArgon2idParams,
type RecoveryCodeOptions,
type RecoveryHashOptions,
type RecoveryVerifyResult,
} from "./recovery.types.js";
export { verifyRecoveryCode } from "./verify.js";

View File

@@ -0,0 +1,44 @@
import type { Rng } from "@suaveplan/types";
/** Argon2id parameters tuned for recovery-code hashing. Defaults align with
* the OWASP 2023 Argon2id recommendation (`memoryKB ≥ 64 MiB`, `iterations ≥ 3`,
* `parallelism = 1`, output length 32). */
export interface RecoveryArgon2idParams {
memoryKB: number;
iterations: number;
parallelism: number;
saltBytes: number;
length: number;
}
export const RECOVERY_DEFAULT_ARGON2ID_PARAMS: RecoveryArgon2idParams = {
memoryKB: 65536,
iterations: 3,
parallelism: 1,
saltBytes: 16,
length: 32,
};
/** Options for `generateRecoveryCodes`. */
export interface RecoveryCodeOptions {
/** Total characters per code. Defaults to `16` (4-4-4-4 grouping). */
length?: number;
/** Group size — characters between separators. Defaults to `4`. */
groupSize?: number;
/** Group separator. Defaults to `"-"`. */
separator?: string;
/** Optional injectable RNG. Defaults to `randomBytes` from
* `@suaveplan/crypto-utils`. */
rng?: Rng;
}
/** Options for `hashRecoveryCode`. */
export interface RecoveryHashOptions {
params?: RecoveryArgon2idParams;
rng?: Rng;
}
/** Result of `verifyRecoveryCode`. */
export type RecoveryVerifyResult =
| { valid: true; codeIndex: number }
| { valid: false; codeIndex: null };

View File

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

View File

@@ -0,0 +1,24 @@
import { constantTimeEqual } from "@suaveplan/crypto-utils";
import { decodeRecoveryHash, deriveAgainst } from "./hash.js";
import type { RecoveryVerifyResult } from "./recovery.types.js";
/** Verify `input` against a list of hashed recovery codes. 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. Returns the index of
* the matching entry on success so the caller can mark it used. */
export async function verifyRecoveryCode(
input: string,
hashedCodes: string[]
): Promise<RecoveryVerifyResult> {
let matchedIndex: number | null = null;
for (let i = 0; i < hashedCodes.length; i += 1) {
const decoded = decodeRecoveryHash(hashedCodes[i] as string);
const candidate = deriveAgainst(input, decoded);
const match =
candidate.length === decoded.derived.length &&
constantTimeEqual(candidate, decoded.derived);
if (match && matchedIndex === null) matchedIndex = i;
}
if (matchedIndex !== null) return { valid: true, codeIndex: matchedIndex };
return { valid: false, codeIndex: null };
}

View File

@@ -0,0 +1,15 @@
export {
computeTimeStep,
resolveTotpOptions,
type TotpDefaults,
totp,
} from "./totp.js";
export { TotpOptionsSchema, TotpVerifyOptionsSchema } from "./totp.schema.js";
export type {
TotpOptions,
TotpVerifyManyResult,
TotpVerifyOptions,
TotpVerifyResult,
} from "./totp.types.js";
export { constantTimeStringEqual, verify } from "./verify.js";
export { verifyMany } from "./verify-many.js";

View File

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

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { resolveTotpOptions } from "./totp.js";
describe("resolveTotpOptions — direct invocation paths", () => {
it("uses every default when opts={}", { timeout: 5000 }, () => {
const r = resolveTotpOptions();
expect(r.algorithm).toBe("SHA1");
expect(r.digits).toBe(6);
expect(r.period).toBe(30);
expect(r.t0).toBe(0);
expect(typeof r.clock).toBe("function");
});
it("propagates Zod failure through the catch path", {
timeout: 5000,
}, () => {
// Bypass the top-level totp() schema by calling resolveTotpOptions
// directly with an invalid period.
expect(() => resolveTotpOptions({ period: -1 } as never)).toThrow(
/INVALID_PERIOD/
);
});
it("propagates an unknown-key Zod failure as INVALID_OPTIONS", {
timeout: 5000,
}, () => {
// resolveTotpOptions strips unknown keys via the subset extraction, so
// it never sees `bogus`. Build a "valid" subset with a deliberately
// out-of-bounds field — `t0: -1` triggers `nonnegative()` failure with
// path[0] === "t0", mapped to INVALID_PERIOD. The unknown-key path is
// exercised separately at the verify boundary.
expect(() => resolveTotpOptions({ t0: -1 } as never)).toThrow(
/INVALID_PERIOD/
);
});
});

View File

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

View File

@@ -0,0 +1,23 @@
import type { Clock } from "@suaveplan/types";
import z from "zod";
/** Validate the structural shape of a `Clock` (a zero-arg function that
* returns a `number`). Zod's function-typed validators changed shape across
* v3/v4 so a `z.custom` predicate stays portable. */
const ClockSchema = z.custom<Clock>((v) => typeof v === "function");
/** Zod boundary for `TotpOptions`. */
export const TotpOptionsSchema = z
.object({
algorithm: z.enum(["SHA1", "SHA256", "SHA512"]).optional(),
digits: z.union([z.literal(6), z.literal(7), z.literal(8)]).optional(),
period: z.number().int().positive().optional(),
t0: z.number().int().nonnegative().optional(),
clock: ClockSchema.optional(),
})
.strict();
/** Zod boundary for `TotpVerifyOptions`. */
export const TotpVerifyOptionsSchema = TotpOptionsSchema.extend({
window: z.number().int().min(0).max(10).optional(),
}).strict();

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock } from "@suaveplan/types";
import { totp } from "./totp.js";
const ENCODER = new TextEncoder();
const SHA1_SECRET = ENCODER.encode("12345678901234567890");
const SHA256_SECRET = ENCODER.encode("12345678901234567890123456789012");
const SHA512_SECRET = ENCODER.encode(
"1234567890123456789012345678901234567890123456789012345678901234"
);
const fixedClockMs =
(ms: number): Clock =>
() =>
ms;
// RFC 6238 Appendix B reference vectors at digits=8.
const RFC6238_VECTORS: ReadonlyArray<{
timeSeconds: number;
algorithm: "SHA1" | "SHA256" | "SHA512";
expected: string;
secret: Uint8Array;
}> = [
{
timeSeconds: 59,
algorithm: "SHA1",
expected: "94287082",
secret: SHA1_SECRET,
},
{
timeSeconds: 59,
algorithm: "SHA256",
expected: "46119246",
secret: SHA256_SECRET,
},
{
timeSeconds: 59,
algorithm: "SHA512",
expected: "90693936",
secret: SHA512_SECRET,
},
{
timeSeconds: 1111111109,
algorithm: "SHA1",
expected: "07081804",
secret: SHA1_SECRET,
},
{
timeSeconds: 1111111109,
algorithm: "SHA256",
expected: "68084774",
secret: SHA256_SECRET,
},
{
timeSeconds: 1111111109,
algorithm: "SHA512",
expected: "25091201",
secret: SHA512_SECRET,
},
];
describe("totp — RFC 6238 Appendix B vectors at digits=8", () => {
for (const v of RFC6238_VECTORS) {
it(`T=${v.timeSeconds}s ${v.algorithm} → ${v.expected}`, {
timeout: 5000,
}, () => {
const code = totp(v.secret, {
algorithm: v.algorithm,
digits: 8,
clock: fixedClockMs(v.timeSeconds * 1000),
});
expect(code).toBe(v.expected);
});
}
});
describe("totp — period / t0", () => {
it("custom period changes the time-step", { timeout: 5000 }, () => {
const a = totp(SHA1_SECRET, {
digits: 8,
period: 30,
clock: fixedClockMs(60_000),
});
const b = totp(SHA1_SECRET, {
digits: 8,
period: 60,
clock: fixedClockMs(60_000),
});
expect(a).not.toBe(b);
});
it("non-zero T0 shifts the time-step", { timeout: 5000 }, () => {
const a = totp(SHA1_SECRET, {
digits: 8,
t0: 0,
clock: fixedClockMs(120_000),
});
const b = totp(SHA1_SECRET, {
digits: 8,
t0: 30,
clock: fixedClockMs(120_000),
});
expect(a).not.toBe(b);
});
it("rejects period=0 with INVALID_PERIOD", { timeout: 5000 }, () => {
expect(() =>
totp(SHA1_SECRET, {
period: 0 as unknown as 30,
clock: fixedClockMs(0),
})
).toThrow(/INVALID_OPTIONS|INVALID_PERIOD/);
});
it("rejects unknown option key via strict Zod", { timeout: 5000 }, () => {
expect(() =>
totp(SHA1_SECRET, {
clock: fixedClockMs(0),
bogus: 1,
} as never)
).toThrow(/INVALID_OPTIONS/);
});
});
describe("totp — defaults", () => {
it("defaults to SHA-1 + digits=6 + period=30", { timeout: 5000 }, () => {
const code = totp(SHA1_SECRET, { clock: fixedClockMs(0) });
expect(/^\d{6}$/u.test(code)).toBe(true);
});
it("uses system clock when no clock injected (smoke)", {
timeout: 5000,
}, () => {
const code = totp(SHA1_SECRET);
expect(/^\d{6}$/u.test(code)).toBe(true);
});
});

View File

@@ -0,0 +1,78 @@
import type { Clock } from "@suaveplan/types";
import { OTP_CODES } from "../errors/codes.js";
import { rethrowAsOtp } from "../errors/zod-map.js";
import { hotp } from "../hotp/hotp.js";
import type { HotpAlgorithm, HotpDigits } from "../hotp/hotp.types.js";
import { recordOtpError } from "../observability/metrics.js";
import { TotpOptionsSchema } from "./totp.schema.js";
import type { TotpOptions } from "./totp.types.js";
const SYSTEM_CLOCK: Clock = () => Date.now();
/** Compute the integer time-step `T = floor((now - T0) / period)` per RFC
* 6238 §4. Exported so `verify` and `verifyMany` can share the formula. */
export function computeTimeStep(
clock: Clock,
period: number,
t0: number
): bigint {
const nowSeconds = Math.floor(clock() / 1000);
return BigInt(Math.floor((nowSeconds - t0) / period));
}
/** Default option set for TOTP — RFC 6238 §5.2 defaults. */
export interface TotpDefaults {
algorithm: HotpAlgorithm;
digits: HotpDigits;
period: number;
t0: number;
clock: Clock;
}
/** Resolve `TotpOptions` against RFC defaults. The input is narrowed to the
* `TotpOptions` subset before parsing so callers passing through extended
* option bags (verify with `window`, drift with `maxSteps`) can forward them
* without tripping strict-mode rejection. */
export function resolveTotpOptions(opts: TotpOptions = {}): TotpDefaults {
const subset: TotpOptions = {};
if (opts.algorithm !== undefined) subset.algorithm = opts.algorithm;
if (opts.digits !== undefined) subset.digits = opts.digits;
if (opts.period !== undefined) subset.period = opts.period;
if (opts.t0 !== undefined) subset.t0 = opts.t0;
if (opts.clock !== undefined) subset.clock = opts.clock;
let parsed: ReturnType<typeof TotpOptionsSchema.parse>;
try {
parsed = TotpOptionsSchema.parse(subset);
} catch (cause) {
recordOtpError(OTP_CODES.INVALID_OPTIONS);
rethrowAsOtp(cause, "invalid TOTP options");
}
return {
algorithm: parsed.algorithm ?? "SHA1",
digits: parsed.digits ?? 6,
period: parsed.period ?? 30,
t0: parsed.t0 ?? 0,
clock: parsed.clock ?? SYSTEM_CLOCK,
};
}
/**
* RFC 6238 TOTP. Computes `T = floor((now - T0) / period)` then delegates to
* `hotp(secret, T, ...)`. The current time is read via the injected `Clock`
* exclusively — the package never reads `Date.now()` at module scope.
*/
export function totp(secret: Uint8Array, opts: TotpOptions = {}): string {
let parsed: ReturnType<typeof TotpOptionsSchema.parse>;
try {
parsed = TotpOptionsSchema.parse(opts);
} catch (cause) {
recordOtpError(OTP_CODES.INVALID_OPTIONS);
rethrowAsOtp(cause, "invalid TOTP options");
}
const resolved = resolveTotpOptions(parsed as TotpOptions);
const t = computeTimeStep(resolved.clock, resolved.period, resolved.t0);
return hotp(secret, t, {
algorithm: resolved.algorithm,
digits: resolved.digits,
});
}

View File

@@ -0,0 +1,35 @@
import type { Clock } from "@suaveplan/types";
import type { HotpAlgorithm, HotpDigits } from "../hotp/hotp.types.js";
/** Options accepted by `totp`. */
export interface TotpOptions {
/** Defaults to `"SHA1"` per RFC 6238. */
algorithm?: HotpAlgorithm;
/** Defaults to `6`. */
digits?: HotpDigits;
/** Time step in seconds. Defaults to `30` per RFC 6238 §5.2. */
period?: number;
/** Unix-epoch offset in seconds. Defaults to `0`. */
t0?: number;
/** Injectable clock so the caller controls "now". MANDATORY for tests. */
clock?: Clock;
}
/** Options accepted by `verify` / `verifyMany`. */
export interface TotpVerifyOptions extends TotpOptions {
/** Tolerated drift in either direction, in time steps. Defaults to `1`,
* hard-capped at `10`. Anything outside `[0, 10]` raises
* `OtpError(OTP.WINDOW_OUT_OF_RANGE)`. */
window?: number;
}
/** Result of `verify`. `delta` is the signed step offset on success and
* `null` on miss. */
export type TotpVerifyResult =
| { valid: true; delta: number }
| { valid: false; delta: null };
/** Result of `verifyMany`. `secretIndex` identifies which secret matched. */
export type TotpVerifyManyResult =
| { valid: true; delta: number; secretIndex: number }
| { valid: false; delta: null; secretIndex: null };

View File

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

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock } from "@suaveplan/types";
import { totp } from "./totp.js";
import { verifyMany } from "./verify-many.js";
const ENCODER = new TextEncoder();
const SECRET_OLD = ENCODER.encode("12345678901234567890");
const SECRET_NEW = ENCODER.encode("00000000000000000000");
const fixed =
(ms: number): Clock =>
() =>
ms;
describe("verifyMany — backup-aware verify", () => {
it("matches the second secret in the list", { timeout: 5000 }, () => {
const code = totp(SECRET_NEW, { clock: fixed(60_000) });
const r = verifyMany(code, [SECRET_OLD, SECRET_NEW], {
clock: fixed(60_000),
});
expect(r).toEqual({ valid: true, delta: 0, secretIndex: 1 });
});
it("matches the first secret with delta=-1", { timeout: 5000 }, () => {
const oldCode = totp(SECRET_OLD, { clock: fixed(0) });
const r = verifyMany(oldCode, [SECRET_OLD, SECRET_NEW], {
clock: fixed(30_000),
});
expect(r).toEqual({ valid: true, delta: -1, secretIndex: 0 });
});
it("returns invalid when neither secret matches", {
timeout: 5000,
}, () => {
const r = verifyMany("000000", [SECRET_OLD, SECRET_NEW], {
clock: fixed(60_000),
});
expect(r).toEqual({ valid: false, delta: null, secretIndex: null });
});
it("propagates Zod failures as INVALID_OPTIONS", {
timeout: 5000,
}, () => {
expect(() =>
verifyMany("000000", [SECRET_OLD], { window: 99 } as never)
).toThrow(/INVALID_OPTIONS|WINDOW_OUT_OF_RANGE/);
});
it("rejects window=-1", { timeout: 5000 }, () => {
// Bypass Zod by spreading an unknown key first then re-asserting.
expect(() =>
verifyMany("000000", [SECRET_OLD], {
clock: fixed(0),
window: -1 as unknown as 0,
})
).toThrow(/INVALID_OPTIONS|WINDOW_OUT_OF_RANGE/);
});
it("guards against negative candidate steps near origin", {
timeout: 5000,
}, () => {
const code = totp(SECRET_OLD, { clock: fixed(0) });
const r = verifyMany(code, [SECRET_OLD], {
clock: fixed(0),
window: 1,
});
expect(r.valid).toBe(true);
});
it("uses default opts={} when none are supplied", {
timeout: 5000,
}, () => {
const r = verifyMany("000000", [SECRET_OLD]);
expect(typeof r.valid).toBe("boolean");
});
});

View File

@@ -0,0 +1,63 @@
import { OTP_CODES } from "../errors/codes.js";
import { rethrowAsOtp } from "../errors/zod-map.js";
import { hotp } from "../hotp/hotp.js";
import { recordOtpError, recordOutcome } from "../observability/metrics.js";
import { computeTimeStep, resolveTotpOptions } from "./totp.js";
import { TotpVerifyOptionsSchema } from "./totp.schema.js";
import type { TotpVerifyManyResult, TotpVerifyOptions } from "./totp.types.js";
import { constantTimeStringEqual } from "./verify.js";
/**
* Verify `code` against multiple secrets — used during rotation windows where
* an old and a new TOTP secret coexist. Constant-time across the secret list
* AND the windowed steps: every secret AND every step is evaluated even after
* a match is found.
*/
export function verifyMany(
code: string,
secrets: Uint8Array[],
opts: TotpVerifyOptions = {}
): TotpVerifyManyResult {
let parsed: ReturnType<typeof TotpVerifyOptionsSchema.parse>;
try {
parsed = TotpVerifyOptionsSchema.parse(opts);
} catch (cause) {
recordOtpError(OTP_CODES.INVALID_OPTIONS);
rethrowAsOtp(cause, "invalid verify options");
}
const window = parsed.window ?? 1;
const resolved = resolveTotpOptions(parsed as TotpVerifyOptions);
const baseStep = computeTimeStep(
resolved.clock,
resolved.period,
resolved.t0
);
let matchedDelta: number | null = null;
let matchedSecretIndex: number | null = null;
for (let s = 0; s < secrets.length; s += 1) {
const secret = secrets[s] as Uint8Array;
for (let delta = -window; delta <= window; delta += 1) {
const candidateStep = baseStep + BigInt(delta);
if (candidateStep < 0n) continue;
const candidate = hotp(secret, candidateStep, {
algorithm: resolved.algorithm,
digits: resolved.digits,
});
const match = constantTimeStringEqual(code, candidate);
if (match && matchedDelta === null) {
matchedDelta = delta;
matchedSecretIndex = s;
}
}
}
if (matchedDelta !== null && matchedSecretIndex !== null) {
recordOutcome("valid", matchedDelta);
return {
valid: true,
delta: matchedDelta,
secretIndex: matchedSecretIndex,
};
}
recordOutcome("invalid", null);
return { valid: false, delta: null, secretIndex: null };
}

View File

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

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock } from "@suaveplan/types";
import { totp } from "./totp.js";
import { constantTimeStringEqual, verify } from "./verify.js";
const ENCODER = new TextEncoder();
const SECRET = ENCODER.encode("12345678901234567890");
const fixed =
(ms: number): Clock =>
() =>
ms;
describe("verify — RFC 6238 §5.2 drift window", () => {
it("matches the current step (delta=0)", { timeout: 5000 }, () => {
const code = totp(SECRET, { clock: fixed(60_000) });
const r = verify(code, SECRET, { clock: fixed(60_000) });
expect(r).toEqual({ valid: true, delta: 0 });
});
it("matches one step in the past (delta=-1)", { timeout: 5000 }, () => {
const oldCode = totp(SECRET, { clock: fixed(0) });
const r = verify(oldCode, SECRET, { clock: fixed(30_000) });
expect(r).toEqual({ valid: true, delta: -1 });
});
it("matches one step in the future (delta=+1)", { timeout: 5000 }, () => {
const futureCode = totp(SECRET, { clock: fixed(60_000) });
const r = verify(futureCode, SECRET, { clock: fixed(30_000) });
expect(r).toEqual({ valid: true, delta: 1 });
});
it("rejects code two steps ahead with window=1", {
timeout: 5000,
}, () => {
const future = totp(SECRET, { clock: fixed(120_000) });
const r = verify(future, SECRET, { clock: fixed(30_000), window: 1 });
expect(r).toEqual({ valid: false, delta: null });
});
it("accepts code two steps ahead with window=2", {
timeout: 5000,
}, () => {
const future = totp(SECRET, { clock: fixed(120_000) });
const r = verify(future, SECRET, { clock: fixed(30_000), window: 3 });
expect(r).toEqual({ valid: true, delta: 3 });
});
it("window=0 only matches the current step", {
timeout: 5000,
}, () => {
const old = totp(SECRET, { clock: fixed(0) });
const r = verify(old, SECRET, { clock: fixed(30_000), window: 0 });
expect(r.valid).toBe(false);
});
it("window=10 is the cap", { timeout: 5000 }, () => {
const old = totp(SECRET, { clock: fixed(0) });
const r = verify(old, SECRET, { clock: fixed(300_000), window: 10 });
expect(r).toEqual({ valid: true, delta: -10 });
});
it("window=11 throws WINDOW_OUT_OF_RANGE via Zod boundary", {
timeout: 5000,
}, () => {
expect(() => verify("000000", SECRET, { window: 11 })).toThrow(
/INVALID_OPTIONS|WINDOW_OUT_OF_RANGE/
);
});
it("invalid period maps to INVALID_PERIOD", {
timeout: 5000,
}, () => {
expect(() => verify("000000", SECRET, { period: -5 } as never)).toThrow(
/INVALID_PERIOD/
);
});
it("unknown option key falls back to INVALID_OPTIONS", {
timeout: 5000,
}, () => {
expect(() => verify("000000", SECRET, { bogus: 1 } as never)).toThrow(
/INVALID_OPTIONS/
);
});
});
describe("verify — base-step underflow guard", () => {
it("ignores negative-step candidates near time origin", {
timeout: 5000,
}, () => {
// T=0 with window=1 would otherwise look at step `-1` which encodes
// negative; the guard skips it gracefully and the call returns the
// current-step code.
const code = totp(SECRET, { clock: fixed(0) });
const r = verify(code, SECRET, { clock: fixed(0), window: 1 });
expect(r).toEqual({ valid: true, delta: 0 });
});
});
describe("constantTimeStringEqual", () => {
it("returns false for different lengths", { timeout: 5000 }, () => {
expect(constantTimeStringEqual("abc", "abcd")).toBe(false);
});
it("returns true for equal strings", { timeout: 5000 }, () => {
expect(constantTimeStringEqual("hello", "hello")).toBe(true);
});
it("returns false for same length / different content", {
timeout: 5000,
}, () => {
expect(constantTimeStringEqual("12345", "12346")).toBe(false);
});
});
describe("verify — invalid code path", () => {
it("returns valid=false for a totally wrong code", {
timeout: 5000,
}, () => {
const r = verify("000000", SECRET, { clock: fixed(0) });
expect(r).toEqual({ valid: false, delta: null });
});
it("uses default opts={} when none are supplied", {
timeout: 5000,
}, () => {
// System clock — code is non-deterministic; just exercise the path.
const r = verify("000000", SECRET);
expect(typeof r.valid).toBe("boolean");
});
});

View File

@@ -0,0 +1,67 @@
import { constantTimeEqual } from "@suaveplan/crypto-utils";
import { OTP_CODES } from "../errors/codes.js";
import { rethrowAsOtp } from "../errors/zod-map.js";
import { hotp } from "../hotp/hotp.js";
import { recordOtpError, recordOutcome } from "../observability/metrics.js";
import { computeTimeStep, resolveTotpOptions } from "./totp.js";
import { TotpVerifyOptionsSchema } from "./totp.schema.js";
import type { TotpVerifyOptions, TotpVerifyResult } from "./totp.types.js";
const ENCODER = new TextEncoder();
/** Constant-time string equality — wraps `constantTimeEqual` over UTF-8 bytes
* with an explicit length check that is itself short-circuit-free in the
* happy path (lengths must match for both candidates to be valid OTP codes;
* length mismatch is treated as a non-match without leaking via early exit). */
export function constantTimeStringEqual(a: string, b: string): boolean {
const ab = ENCODER.encode(a);
const bb = ENCODER.encode(b);
if (ab.length !== bb.length) return false;
return constantTimeEqual(ab, bb);
}
/**
* RFC 6238 §5.2 verify. Checks `code` against the current step and `±window`
* neighbouring steps. 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.
*/
export function verify(
code: string,
secret: Uint8Array,
opts: TotpVerifyOptions = {}
): TotpVerifyResult {
let parsed: ReturnType<typeof TotpVerifyOptionsSchema.parse>;
try {
parsed = TotpVerifyOptionsSchema.parse(opts);
} catch (cause) {
recordOtpError(OTP_CODES.INVALID_OPTIONS);
rethrowAsOtp(cause, "invalid verify options");
}
const window = parsed.window ?? 1;
const resolved = resolveTotpOptions(parsed as TotpVerifyOptions);
const baseStep = computeTimeStep(
resolved.clock,
resolved.period,
resolved.t0
);
let matchedDelta: number | null = null;
for (let delta = -window; delta <= window; delta += 1) {
const candidateStep = baseStep + BigInt(delta);
if (candidateStep < 0n) continue;
const candidate = hotp(secret, candidateStep, {
algorithm: resolved.algorithm,
digits: resolved.digits,
});
const match = constantTimeStringEqual(code, candidate);
// Record the FIRST matching delta but keep iterating so loop position
// stays input-independent (every windowed step is evaluated).
if (match && matchedDelta === null) matchedDelta = delta;
}
if (matchedDelta !== null) {
recordOutcome("valid", matchedDelta);
return { valid: true, delta: matchedDelta };
}
recordOutcome("invalid", null);
return { valid: false, delta: null };
}

View File

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

View File

@@ -0,0 +1,43 @@
import { OTP_CODES, otpError } from "../errors/codes.js";
import { OtpUriSchema } from "./uri.schema.js";
import type { OtpUri } from "./uri.types.js";
/** Format an `OtpUri` into the canonical `otpauth://` form. The `label` is
* URI-encoded so colons and spaces survive the round-trip; per the Key URI
* Format spec a literal `:` in the label is reserved for the issuer-prefix
* convention and is rejected here so callers cannot accidentally produce an
* ambiguous URI. */
export function formatUri(opts: OtpUri): string {
let parsed: OtpUri;
try {
parsed = OtpUriSchema.parse(opts) as OtpUri;
} catch (cause) {
throw otpError(OTP_CODES.URI_MALFORMED, "invalid URI options", {
cause,
});
}
if (parsed.label.includes(":")) {
throw otpError(
OTP_CODES.URI_MALFORMED,
"label must not contain literal ':' — encode the issuer via 'issuer=' instead"
);
}
const params = new Map<string, string>();
params.set("secret", parsed.secret);
if (parsed.issuer !== undefined) params.set("issuer", parsed.issuer);
if (parsed.algorithm !== undefined)
params.set("algorithm", parsed.algorithm);
if (parsed.digits !== undefined)
params.set("digits", String(parsed.digits));
if (parsed.type === "totp") {
if (parsed.period !== undefined) {
params.set("period", String(parsed.period));
}
} else {
params.set("counter", parsed.counter.toString(10));
}
const query = Array.from(params.entries())
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
return `otpauth://${parsed.type}/${encodeURIComponent(parsed.label)}?${query}`;
}

View File

@@ -0,0 +1,13 @@
export { formatUri } from "./format-uri.js";
export { parseUri } from "./parse-uri.js";
export {
OtpHotpUriSchema,
OtpTotpUriSchema,
OtpUriSchema,
} from "./uri.schema.js";
export type {
OtpHotpUri,
OtpTotpUri,
OtpUri,
OtpUriCommon,
} from "./uri.types.js";

View File

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

View File

@@ -0,0 +1,140 @@
import { OTP_CODES, otpError } from "../errors/codes.js";
import type { HotpAlgorithm, HotpDigits } from "../hotp/hotp.types.js";
import type { OtpUri } from "./uri.types.js";
const SCHEME = "otpauth://";
/** Parse an `otpauth://` URI into the discriminated `OtpUri` union per the
* Google Authenticator Key URI Format spec. Both `totp` and `hotp` variants
* are supported. Malformed input → `OTP.URI_MALFORMED`; an unrecognised type
* segment → `OTP.URI_UNSUPPORTED_TYPE`. */
export function parseUri(uri: string): OtpUri {
if (!uri.startsWith(SCHEME)) {
throw otpError(
OTP_CODES.URI_MALFORMED,
`URI must start with '${SCHEME}'`
);
}
const remainder = uri.slice(SCHEME.length);
const slashIdx = remainder.indexOf("/");
if (slashIdx < 0) {
throw otpError(
OTP_CODES.URI_MALFORMED,
"URI is missing the type / label separator"
);
}
const typeSegment = remainder.slice(0, slashIdx);
const rest = remainder.slice(slashIdx + 1);
const queryIdx = rest.indexOf("?");
const labelPart = queryIdx < 0 ? rest : rest.slice(0, queryIdx);
const queryPart = queryIdx < 0 ? "" : rest.slice(queryIdx + 1);
if (typeSegment !== "totp" && typeSegment !== "hotp") {
throw otpError(
OTP_CODES.URI_UNSUPPORTED_TYPE,
`URI type must be 'totp' or 'hotp' (got '${typeSegment}')`
);
}
const label = decodeURIComponent(labelPart);
if (label.length === 0) {
throw otpError(OTP_CODES.URI_MALFORMED, "URI label must not be empty");
}
const params = parseQuery(queryPart);
const secret = params.get("secret");
if (!secret) {
throw otpError(OTP_CODES.URI_MALFORMED, "URI must include 'secret'");
}
const algorithm = parseAlgorithm(params.get("algorithm") ?? null);
const digits = parseDigits(params.get("digits") ?? null);
const issuer = params.get("issuer") ?? undefined;
if (typeSegment === "totp") {
const period = parsePositiveInt(params.get("period") ?? null, "period");
const out: OtpUri = {
type: "totp",
label,
secret,
...(algorithm ? { algorithm } : {}),
...(digits ? { digits } : {}),
...(period !== undefined ? { period } : {}),
...(issuer !== undefined ? { issuer } : {}),
};
return out;
}
const counterRaw = params.get("counter") ?? null;
if (counterRaw === null) {
throw otpError(
OTP_CODES.URI_MALFORMED,
"hotp URI must include 'counter'"
);
}
const counter = parseCounter(counterRaw);
const out: OtpUri = {
type: "hotp",
label,
secret,
counter,
...(algorithm ? { algorithm } : {}),
...(digits ? { digits } : {}),
...(issuer !== undefined ? { issuer } : {}),
};
return out;
}
function parseQuery(q: string): Map<string, string> {
const out = new Map<string, string>();
if (q.length === 0) return out;
for (const pair of q.split("&")) {
const eq = pair.indexOf("=");
const key = eq < 0 ? pair : pair.slice(0, eq);
const val = eq < 0 ? "" : pair.slice(eq + 1);
if (key.length === 0) continue;
out.set(decodeURIComponent(key), decodeURIComponent(val));
}
return out;
}
function parseAlgorithm(value: string | null): HotpAlgorithm | undefined {
if (value === null) return undefined;
const upper = value.toUpperCase();
if (upper === "SHA1" || upper === "SHA256" || upper === "SHA512") {
return upper;
}
throw otpError(
OTP_CODES.URI_MALFORMED,
`URI algorithm must be SHA1 / SHA256 / SHA512 (got '${value}')`
);
}
function parseDigits(value: string | null): HotpDigits | undefined {
if (value === null) return undefined;
const n = Number.parseInt(value, 10);
if (n === 6 || n === 7 || n === 8) return n;
throw otpError(
OTP_CODES.URI_MALFORMED,
`URI digits must be 6 / 7 / 8 (got '${value}')`
);
}
function parsePositiveInt(
value: string | null,
field: string
): number | undefined {
if (value === null) return undefined;
const n = Number.parseInt(value, 10);
if (!Number.isFinite(n) || n <= 0) {
throw otpError(
OTP_CODES.URI_MALFORMED,
`URI ${field} must be a positive integer (got '${value}')`
);
}
return n;
}
function parseCounter(value: string): bigint {
if (!/^\d+$/u.test(value)) {
throw otpError(
OTP_CODES.URI_MALFORMED,
`URI counter must be a non-negative decimal integer (got '${value}')`
);
}
return BigInt(value);
}

View File

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

View File

@@ -0,0 +1,33 @@
import z from "zod";
const Algorithm = z.enum(["SHA1", "SHA256", "SHA512"]);
const Digits = z.union([z.literal(6), z.literal(7), z.literal(8)]);
const Common = {
label: z.string().min(1),
secret: z.string().min(1),
issuer: z.string().optional(),
algorithm: Algorithm.optional(),
digits: Digits.optional(),
};
export const OtpTotpUriSchema = z
.object({
type: z.literal("totp"),
...Common,
period: z.number().int().positive().optional(),
})
.strict();
export const OtpHotpUriSchema = z
.object({
type: z.literal("hotp"),
...Common,
counter: z.bigint().nonnegative(),
})
.strict();
export const OtpUriSchema = z.discriminatedUnion("type", [
OtpTotpUriSchema,
OtpHotpUriSchema,
]);

View File

@@ -0,0 +1,235 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { formatUri } from "./format-uri.js";
import { parseUri } from "./parse-uri.js";
describe("parseUri — totp variant", () => {
it("parses canonical example", { timeout: 5000 }, () => {
const u = parseUri(
"otpauth://totp/Example:alice%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30"
);
expect(u.type).toBe("totp");
if (u.type !== "totp") throw new Error("narrow");
expect(u.label).toBe("Example:alice@example.com");
expect(u.secret).toBe("JBSWY3DPEHPK3PXP");
expect(u.issuer).toBe("Example");
expect(u.algorithm).toBe("SHA1");
expect(u.digits).toBe(6);
expect(u.period).toBe(30);
});
it("treats algorithm + digits as optional", {
timeout: 5000,
}, () => {
const u = parseUri("otpauth://totp/alice?secret=JBSWY3DPEHPK3PXP");
expect(u.type).toBe("totp");
if (u.type !== "totp") throw new Error("narrow");
expect(u.algorithm).toBeUndefined();
expect(u.digits).toBeUndefined();
expect(u.period).toBeUndefined();
});
it("lowercases SHA-1 algorithm to canonical SHA1", {
timeout: 5000,
}, () => {
const u = parseUri(
"otpauth://totp/alice?secret=JBSWY3DPEHPK3PXP&algorithm=sha1"
);
if (u.type !== "totp") throw new Error("narrow");
expect(u.algorithm).toBe("SHA1");
});
});
describe("parseUri — hotp variant", () => {
it("parses hotp with counter", { timeout: 5000 }, () => {
const u = parseUri(
"otpauth://hotp/alice?secret=JBSWY3DPEHPK3PXP&counter=42&issuer=ACME"
);
expect(u.type).toBe("hotp");
if (u.type !== "hotp") throw new Error("narrow");
expect(u.counter).toBe(42n);
expect(u.issuer).toBe("ACME");
});
it("rejects hotp without counter", { timeout: 5000 }, () => {
expect(() =>
parseUri("otpauth://hotp/alice?secret=JBSWY3DPEHPK3PXP")
).toThrow(/URI_MALFORMED/);
});
it("rejects non-decimal counter", { timeout: 5000 }, () => {
expect(() =>
parseUri(
"otpauth://hotp/alice?secret=JBSWY3DPEHPK3PXP&counter=0xff"
)
).toThrow(/URI_MALFORMED/);
});
});
describe("parseUri — error paths", () => {
it("rejects wrong scheme", { timeout: 5000 }, () => {
expect(() => parseUri("https://example.com")).toThrow(/URI_MALFORMED/);
});
it("rejects missing label/type separator", {
timeout: 5000,
}, () => {
expect(() => parseUri("otpauth://totp")).toThrow(/URI_MALFORMED/);
});
it("rejects unsupported type", { timeout: 5000 }, () => {
expect(() =>
parseUri("otpauth://magic/alice?secret=JBSWY3DPEHPK3PXP")
).toThrow(/URI_UNSUPPORTED_TYPE/);
});
it("rejects empty label", { timeout: 5000 }, () => {
expect(() =>
parseUri("otpauth://totp/?secret=JBSWY3DPEHPK3PXP")
).toThrow(/URI_MALFORMED/);
});
it("rejects missing secret", { timeout: 5000 }, () => {
expect(() => parseUri("otpauth://totp/alice")).toThrow(/URI_MALFORMED/);
});
it("rejects unknown algorithm", { timeout: 5000 }, () => {
expect(() =>
parseUri(
"otpauth://totp/alice?secret=JBSWY3DPEHPK3PXP&algorithm=md5"
)
).toThrow(/URI_MALFORMED/);
});
it("rejects digits=5", { timeout: 5000 }, () => {
expect(() =>
parseUri("otpauth://totp/alice?secret=JBSWY3DPEHPK3PXP&digits=5")
).toThrow(/URI_MALFORMED/);
});
it("rejects period<=0", { timeout: 5000 }, () => {
expect(() =>
parseUri("otpauth://totp/alice?secret=JBSWY3DPEHPK3PXP&period=0")
).toThrow(/URI_MALFORMED/);
});
it("rejects non-numeric period", { timeout: 5000 }, () => {
expect(() =>
parseUri("otpauth://totp/alice?secret=JBSWY3DPEHPK3PXP&period=abc")
).toThrow(/URI_MALFORMED/);
});
it("ignores empty key in query (`&=foo&`)", {
timeout: 5000,
}, () => {
const u = parseUri(
"otpauth://totp/alice?secret=JBSWY3DPEHPK3PXP&=ignored&period=30"
);
if (u.type !== "totp") throw new Error("narrow");
expect(u.period).toBe(30);
});
});
describe("formatUri", () => {
it("round-trips canonical totp", { timeout: 5000 }, () => {
const url = formatUri({
type: "totp",
label: "alice@example.com",
secret: "JBSWY3DPEHPK3PXP",
issuer: "Example",
algorithm: "SHA256",
digits: 8,
period: 60,
});
expect(url.startsWith("otpauth://totp/")).toBe(true);
const parsed = parseUri(url);
if (parsed.type !== "totp") throw new Error("narrow");
expect(parsed.label).toBe("alice@example.com");
expect(parsed.secret).toBe("JBSWY3DPEHPK3PXP");
expect(parsed.issuer).toBe("Example");
expect(parsed.algorithm).toBe("SHA256");
expect(parsed.digits).toBe(8);
expect(parsed.period).toBe(60);
});
it("round-trips canonical hotp", { timeout: 5000 }, () => {
const url = formatUri({
type: "hotp",
label: "alice",
secret: "JBSWY3DPEHPK3PXP",
counter: 17n,
});
const parsed = parseUri(url);
if (parsed.type !== "hotp") throw new Error("narrow");
expect(parsed.counter).toBe(17n);
});
it("rejects literal ':' in label", { timeout: 5000 }, () => {
expect(() =>
formatUri({
type: "totp",
label: "Acme:alice",
secret: "JBSWY3DPEHPK3PXP",
})
).toThrow(/URI_MALFORMED/);
});
it("rejects malformed input shape via Zod", { timeout: 5000 }, () => {
expect(() =>
formatUri({
type: "totp",
label: "",
secret: "JBSWY3DPEHPK3PXP",
})
).toThrow(/URI_MALFORMED/);
});
it("emits totp URI without period (false branch)", {
timeout: 5000,
}, () => {
const url = formatUri({
type: "totp",
label: "alice",
secret: "JBSWY3DPEHPK3PXP",
});
expect(url).not.toContain("period=");
});
it("emits hotp URI with algorithm + digits (true branches)", {
timeout: 5000,
}, () => {
const url = formatUri({
type: "hotp",
label: "alice",
secret: "JBSWY3DPEHPK3PXP",
counter: 0n,
algorithm: "SHA256",
digits: 8,
});
expect(url).toContain("algorithm=SHA256");
expect(url).toContain("digits=8");
expect(url).toContain("counter=0");
});
});
describe("parseUri — additional coverage paths", () => {
it("parses hotp URI carrying algorithm + digits", {
timeout: 5000,
}, () => {
const u = parseUri(
"otpauth://hotp/alice?secret=JBSWY3DPEHPK3PXP&counter=5&algorithm=SHA512&digits=8"
);
if (u.type !== "hotp") throw new Error("narrow");
expect(u.algorithm).toBe("SHA512");
expect(u.digits).toBe(8);
});
it("tolerates query params without '=' (parses as bare flag)", {
timeout: 5000,
}, () => {
const u = parseUri(
"otpauth://totp/alice?secret=JBSWY3DPEHPK3PXP&flag&period=30"
);
if (u.type !== "totp") throw new Error("narrow");
expect(u.period).toBe(30);
});
});

View File

@@ -0,0 +1,30 @@
import type { HotpAlgorithm, HotpDigits } from "../hotp/hotp.types.js";
/** Common parameters across both `totp` and `hotp` URIs. */
export interface OtpUriCommon {
/** Account label (e.g. `"alice@example.com"` or `"Example:alice@…"`). */
label: string;
/** Base32-encoded shared secret. */
secret: string;
/** Optional issuer string ("ACME Co"). Round-trips both into and out of
* the `label` and the `issuer` query parameter per Google Authenticator
* Key URI Format §"Issuer". */
issuer?: string;
algorithm?: HotpAlgorithm;
digits?: HotpDigits;
}
/** TOTP variant of the `otpauth://` URI. */
export interface OtpTotpUri extends OtpUriCommon {
type: "totp";
period?: number;
}
/** HOTP variant of the `otpauth://` URI. */
export interface OtpHotpUri extends OtpUriCommon {
type: "hotp";
counter: bigint;
}
/** Discriminated union — narrow on `type`. */
export type OtpUri = OtpTotpUri | OtpHotpUri;

View File

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

View File

@@ -0,0 +1,42 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/otp",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"@suaveplan/crypto-hash",
"@suaveplan/crypto-utils",
"@noble/hashes",
"@noble/hashes/sha1",
"@noble/hashes/hmac",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,25 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});