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

15 KiB
Raw Permalink Blame History

@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).