first commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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