Files
2026-05-27 23:29:53 +01:00

175 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `@suaveplan/otp` — RFC 4226 / 6238 / 6287 OTP, Key URI, recovery codes
**Package**: `@suaveplan/otp` (NEW)
**Status**: PROPOSED
**Type**: new-package
**Date**: 2026-05-10
**Path**: `packages/vault/otp/`
**Tier**: universal
## Why
`@suaveplan/vault`'s "Authenticator" item type stores TOTP shared secrets and emits the current code for the user. There is no in-genesis primitive that satisfies the RFCs the vault must comply with, so vault cannot ship until this lands. The package also has to round-trip the Google Authenticator Key URI Format so QR-code provisioning works out of the box (every commercial password manager exposes this surface — the vault has to as well).
Greenfield in genesis. The enterprise audit at `../suaveplan/enterprise/otp.md` lists the full surface contract; this proposal pins it to the published RFC clauses (RFC 4226 §5.3 dynamic truncation, RFC 6238 §4 time-step formula, RFC 6287 §6 OCRA suite syntax, the Google Authenticator Key URI Format wiki spec). There is no upstream `packages/universal/otp/` to migrate from, so source-mapping cites RFC sections rather than upstream line numbers.
## What changes
### New package: `@suaveplan/otp`
#### HOTP (RFC 4226)
- ADDED: `hotp(secret: Uint8Array, counter: bigint, opts?: HotpOptions): string` — returns a `digits`-length decimal string per RFC 4226 §5.3 dynamic truncation. Counter is `bigint` (RFC 4226 §5.1 mandates an 8-byte big-endian counter; `Number` loses precision past 2^53).
- ADDED: `HotpOptions` — `{ algorithm?: "SHA1" | "SHA256" | "SHA512"; digits?: 6 | 7 | 8 }`. Defaults: `SHA1`, `digits=6`. RFC 6238 §1.2 broadens HOTP to SHA-256/512.
- ADDED: `HotpOptionsSchema` — Zod `strict()` boundary; mismatch → `OTP.INVALID_OPTIONS`.
#### TOTP (RFC 6238)
- ADDED: `totp(secret: Uint8Array, opts?: TotpOptions): string` — computes `T = floor((now − T0) / period)` per §4 and delegates to `hotp(secret, T, ...)`.
- ADDED: `verify(code: string, secret: Uint8Array, opts?: TotpVerifyOptions): TotpVerifyResult` — checks `±window` time steps per §5.2; returns `{ valid: boolean; delta: number }` so callers can detect clock drift inside the window.
- ADDED: `TotpOptions` — `{ algorithm?; digits?; period?: number; t0?: number; clock?: Clock }`. Defaults: `period=30`, `t0=0`, `digits=6`, `algorithm="SHA1"`. `Clock` is the canonical interface from `@suaveplan/types`; never reads `Date.now()` at module scope.
- ADDED: `TotpVerifyOptions` — `TotpOptions & { window?: number }`. `window` defaults to 1, hard-capped at 10 (`OTP.WINDOW_OUT_OF_RANGE`).
- ADDED: `TotpVerifyResult` — `{ valid: true; delta: number } | { valid: false; delta: null }`.
- ADDED: Constant-time string compare on verify — uses `constantTimeEqual` from `@suaveplan/crypto-utils` so equality checks do not leak via early-exit timing. The compare is applied to the candidate code AND every windowed code, even after a match is found, to prevent loop-position timing leaks.
#### Key URI Format (`./uri` subpath)
- ADDED: `parseUri(uri: string): OtpUri` — parses `otpauth://totp/{label}?secret=...&issuer=...&algorithm=...&digits=...&period=...` and the `hotp` variant (with `counter` instead of `period`). Per the Google Authenticator Key URI Format spec.
- ADDED: `formatUri(opts: OtpUri): string` — round-trip with `parseUri` (URI-encoding the `label` and `issuer` parameters; `:` forbidden in label, must be percent-encoded).
- ADDED: `OtpUri` — discriminated union `{ type: "totp"; ... } | { type: "hotp"; ... }`.
- ADDED: `OtpUriSchema` — Zod boundary; mismatch → `OTP.URI_MALFORMED` or `OTP.URI_UNSUPPORTED_TYPE` if `type` is neither `"totp"` nor `"hotp"`.
#### Base32 (`./base32` subpath, RFC 4648 §6)
- ADDED: `base32Encode(bytes: Uint8Array, opts?: { padding?: boolean }): string` — `padding=false` default to match Google Authenticator; opt-in `padding=true` for spec-strict consumers.
- ADDED: `base32Decode(input: string): Uint8Array` — accepts both padded and unpadded forms, case-insensitive. Invalid alphabet → `OTP.BASE32_INVALID`.
#### OCRA (`./ocra` subpath, RFC 6287)
- ADDED: `ocra(suite: string, key: Uint8Array, params: OcraParams): string` — RFC 6287 §6 challenge-response. Suite-string parser handles `OCRA-1:HOTP-SHA1-6:QN08`, `OCRA-1:HOTP-SHA256-8:C-QN08-PSHA1`, etc.
- ADDED: `parseOcraSuite(suite: string): OcraSuite` — parsed suite-spec value; mismatch → `OTP.OCRA_SUITE_INVALID`.
- ADDED: `verifyOcra(response: string, suite: string, key: Uint8Array, params: OcraParams): boolean` — constant-time compare.
- ADDED: `OcraMutualAuthClient`, `OcraMutualAuthServer` — helper objects implementing the §6 mutual-auth flow (server challenges client, client responds + counter-challenges, server responds).
#### Backup-aware verify
- ADDED: `verifyMany(code: string, secrets: Uint8Array[], opts?: TotpVerifyOptions): TotpVerifyManyResult` — returns `{ valid: true; delta: number; secretIndex: number } | { valid: false; delta: null; secretIndex: null }`. Constant-time across the secret list (every secret evaluated even after a match is found). Used during the secret-rotation window where two valid secrets coexist.
#### Recovery codes
- ADDED: `generateRecoveryCodes(count: number, opts?: RecoveryCodeOptions): string[]` — Crockford-base32 grouping (default 4-4-4-4), RNG-injectable (`opts.rng?: Rng` from `@suaveplan/types`).
- ADDED: `hashRecoveryCode(code: string, opts?: { params?: Argon2idParams }): Promise<string>` — Argon2id via `@suaveplan/crypto-hash` with OWASP-aligned defaults (`memoryKB ≥ 65536`, `iterations ≥ 3`).
- ADDED: `verifyRecoveryCode(input: string, hashedCodes: string[]): Promise<RecoveryVerifyResult>` — constant-time over the full set; returns `{ valid: true; codeIndex: number } | { valid: false; codeIndex: null }`.
#### Clock-drift detector
- ADDED: `detectDrift(code: string, secret: Uint8Array, opts?: DriftDetectOptions): DriftResult` — searches `±maxSteps` (default 60 = ±30 minutes at period=30) outside the verify window; returns `{ delta: number | null; suggestedClockOffsetMs: number | null }` so the calling app can surface "your device clock is N seconds off."
#### Errors
- ADDED: `OTP_CODES` registry via `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`. `OtpError extends SuaveplanError<OtpErrorCode>`.
#### Observability
- ADDED: OTel histograms `otp.generate_duration_ms{type,algorithm}` and `otp.verify_duration_ms{type,algorithm}`. `type` ∈ `"hotp" | "totp" | "ocra"`.
- ADDED: OTel counter `otp.verify_outcome_total{outcome,delta_bucket}` — `outcome` ∈ `"valid" | "invalid"`; `delta_bucket` ∈ `"0" | "+-1" | "+-2..N"` (low-cardinality fixed enum).
- ADDED: OTel counter `otp.errors_total{code}`.
### Subpath exports
- `.` — root barrel (HOTP, TOTP, verify, verifyMany, recovery codes, drift detector)
- `./uri` — `parseUri`, `formatUri`, `OtpUri`, `OtpUriSchema`
- `./base32` — `base32Encode`, `base32Decode`
- `./ocra` — `ocra`, `verifyOcra`, `parseOcraSuite`, `OcraMutualAuthClient`, `OcraMutualAuthServer`
- `./errors` — `OTP_CODES`, `OtpError`, `OtpErrorCode`
- `./observability` — OTel instrument factories (consumers can rebind to a custom `MeterProvider` if needed)
### Dependencies
- `@suaveplan/crypto-hash workspace:^` — HMAC-SHA1/256/512, Argon2id (recovery-code hashing).
- `@suaveplan/crypto-utils workspace:^` — `constantTimeEqual`, byte helpers.
- `@suaveplan/error workspace:^` — `defineCodes`, `SuaveplanError`.
- `@suaveplan/types workspace:^` — `Clock`, `Rng` interfaces.
- `@opentelemetry/api catalog:observability` — meter / span emission.
- `tslib catalog:build`.
- `zod catalog:forms` — boundary schemas.
## Out of scope
The enterprise audit doc places everything in scope; explicit non-goals (named so reviewers don't expect them):
- **WebAuthn / FIDO2** — separate package (`@suaveplan/webauthn`); OTP is the shared-secret family only.
- **Push-based 2FA** (Duo, OneSpan etc.) — out of scope; transport-bound, vendor-specific.
- **Server-side rate limiting and lockout policy** — caller's responsibility (vault, auth gateway); the package returns `{ valid, delta }` and emits OTel; it does not maintain attempt counters.
- **Persistence / storage of secrets** — caller's responsibility; this package operates on `Uint8Array` and never touches disk.
- **QR-code image rendering** — covered by `@suaveplan/qrcode`; this package only emits the `otpauth://` URI string.
- **Backup / export of the full `KeyRing`** — vault's responsibility (`@suaveplan/drive-sharing`); not OTP's scope.
## Source mapping
> No upstream `packages/universal/otp/` exists. Source-mapping cites the canonical RFC clauses and the Google Authenticator Key URI Format wiki page rather than upstream line numbers.
| Requirement | Source | Notes |
|---|---|---|
| HOTP dynamic truncation | RFC 4226 §5.3 step 2 | `offset = HS[19] & 0xF`; `binCode = (HS[off] & 0x7F) << 24 \| HS[off+1] << 16 \| HS[off+2] << 8 \| HS[off+3]`; `value = binCode mod 10^digits` — implemented verbatim |
| 8-byte big-endian counter | RFC 4226 §5.1 | `bigint` chosen specifically so values past 2^53 round-trip |
| HOTP digits 6/7/8 | RFC 4226 §5.3 final paragraph | Reference test vectors at digits=6; 7/8 widened by RFC 6238 §1.2 |
| TOTP time-step | RFC 6238 §4 first paragraph | `T = (CurrentUnixTime − T0) / X` with default `X = 30 s` per §5.2 |
| Drift window | RFC 6238 §5.2 | "At most one time step in either direction" recommended; spec exposes configurable `window` so callers can opt for tighter (`0`) or looser (`>1`) windows up to a hard cap of 10 |
| Constant-time compare | RFC 6238 §5.2 (Security Considerations) | "implementations MUST verify the OTP through a constant-time comparison" |
| HMAC-SHA-{1,256,512} | RFC 6238 §1.2 | "either HMAC-SHA-256 or HMAC-SHA-512 functions … MAY be used" |
| Key URI Format `otpauth://` | <https://github.com/google/google-authenticator/wiki/Key-Uri-Format> | Authoritative ad-hoc spec — `secret`, `algorithm`, `digits`, `period`, `counter`, `issuer`, `label` |
| Key URI label encoding | Google Authenticator Key URI Format §"Label" | `label = accountname / issuer (":" / "%3A") *"%20" accountname` — `:` MUST be percent-encoded |
| Base32 alphabet | RFC 4648 §6 | A–Z + 2–7; padding optional in the OTP profile to match Google Authenticator |
| OCRA challenge-response | RFC 6287 §5–§6 | Suite-string grammar, computation algorithm |
| OCRA suite-string parser | RFC 6287 §6.1 | `OCRA-1:CryptoFunction:DataInput` — three colon-separated segments |
| OCRA mutual-auth flow | RFC 6287 §7.1 | Server-challenge / client-response / counter-challenge sequence |
| RFC 4226 Appendix D test vectors | RFC 4226 Appendix D table | 10 (counter, HOTP) pairs at digits=6, SHA-1 |
| RFC 6238 Appendix B test vectors | RFC 6238 Appendix B table | 6 (T, TOTP) pairs across SHA-1, SHA-256, SHA-512, digits=8 |
| RFC 6287 Appendix C test vectors | RFC 6287 Appendix C tables C.1–C.3 | One-way, mutual-auth, plain-challenge variants |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `verifyMany` (backup-aware verify) | Vault rotation window — when a user rotates the TOTP secret, both old and new must validate for the rotation grace period. Constant-time across the secret list is non-negotiable; implementations that early-exit leak which secret matched. |
| Recovery codes | Industry-standard fallback when the user's authenticator is unavailable. Argon2id-hashed at rest because they are long-lived shared secrets. |
| Clock-drift detector | RFC 6238 §6 recommends servers tolerate a one-step drift but does not address the "I want to surface a UI hint to the user" use case. `detectDrift` searches outside the verify window so the app can render `"your device clock is 47 seconds fast"`. |
| OTel `delta_bucket` low-cardinality enum | Cardinality discipline — emitting `delta` directly as an attribute would explode metric storage. The fixed-bucket enum (`"0"`, `"+-1"`, `"+-2..N"`) caps cardinality at 3 while still letting alerting distinguish "everyone is on time" from "everyone is drifting." |
## Verification approach
Each requirement carries a `**Verified by:**` line in the spec delta. Test files (every `it(...)` carries `{ timeout: 5000 }`):
- `packages/vault/otp/src/hotp/hotp.test.ts` — RFC 4226 Appendix D test vectors (10 pairs); SHA-256/512 widening; `bigint` counter past 2^53 round-trip; digits 6/7/8.
- `packages/vault/otp/src/totp/totp.test.ts` — RFC 6238 Appendix B test vectors (6 pairs across all three algorithms); custom `T0` and `period`; injected `Clock`.
- `packages/vault/otp/src/totp/verify.test.ts` — `window=0` (no drift accepted), `window=1` (default ±1 step), `window=10` (max), `window=11` throws `WINDOW_OUT_OF_RANGE`; `delta` returned for off-step matches; `verifyMany` constant-time across multiple secrets.
- `packages/vault/otp/src/totp/constant-time.test.ts` — pin: `verify` and `verifyMany` evaluate every windowed step / every secret regardless of an early match (assert via inspection of windowed loop counter).
- `packages/vault/otp/src/uri/parse-uri.test.ts` — every documented parameter; `hotp` variant with `counter`; round-trip with `formatUri`; `URI_MALFORMED` and `URI_UNSUPPORTED_TYPE` paths.
- `packages/vault/otp/src/uri/format-uri.test.ts` — label / issuer percent-encoding; `:` in label rejected.
- `packages/vault/otp/src/uri/round-trip.property.test.ts` — fast-check property test: `parseUri(formatUri(opts)) ≡ opts` for every valid option shape.
- `packages/vault/otp/src/base32/base32.test.ts` — RFC 4648 §6 reference vectors; padded and unpadded forms; case-insensitivity; `BASE32_INVALID` on non-alphabet input.
- `packages/vault/otp/src/ocra/ocra.test.ts` — RFC 6287 Appendix C.1–C.3 vectors.
- `packages/vault/otp/src/ocra/suite.test.ts` — every grammar branch in §6.1; malformed suite → `OCRA_SUITE_INVALID`.
- `packages/vault/otp/src/ocra/mutual-auth.test.ts` — three-message §7.1 mutual-auth round-trip.
- `packages/vault/otp/src/recovery/generate.test.ts` — Crockford-base32 grouping; injected `Rng` produces deterministic codes.
- `packages/vault/otp/src/recovery/verify.test.ts` — Argon2id round-trip; constant-time over the full hashed-code list; `RECOVERY_CODE_INVALID` on no match.
- `packages/vault/otp/src/drift/detect.test.ts` — drift outside the verify window surfaces the correct `delta` and `suggestedClockOffsetMs`.
- `packages/vault/otp/src/errors/codes.spec-conformance.test.ts` — every code in `OTP_CODES` thrown by at least one production path.
- `packages/vault/otp/src/observability/metrics.spec-conformance.test.ts` — every documented histogram and counter is emitted from the production code path; assertions made against an in-memory `MeterProvider`.
- `packages/vault/otp/src/index.test.ts` — every documented subpath export resolves.
## Migration impact
Additive. New universal-tier package. No existing consumers — `@suaveplan/vault` is the gating consumer and consumes this package after it ships.
## Impact
### Affected dependents
`bunx turbo ls --filter=...@suaveplan/otp` returns no current dependents (package does not exist yet). Downstream blast radius after this change ships:
- `@suaveplan/vault` — [x] tests run / [x] docs reviewed / [ ] no action needed (vault Authenticator item type wires this in; landed under a separate change)
For all other packages: No dependents (verified by `bunx turbo ls --filter=...@suaveplan/otp` returning the package alone).