# `@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` — Argon2id via `@suaveplan/crypto-hash` with OWASP-aligned defaults (`memoryKB ≥ 65536`, `iterations ≥ 3`). - ADDED: `verifyRecoveryCode(input: string, hashedCodes: string[]): Promise` — 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`. #### 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://` | | 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).