15 KiB
@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 adigits-length decimal string per RFC 4226 §5.3 dynamic truncation. Counter isbigint(RFC 4226 §5.1 mandates an 8-byte big-endian counter;Numberloses 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— Zodstrict()boundary; mismatch →OTP.INVALID_OPTIONS.
TOTP (RFC 6238)
- ADDED:
totp(secret: Uint8Array, opts?: TotpOptions): string— computesT = floor((now − T0) / period)per §4 and delegates tohotp(secret, T, ...). - ADDED:
verify(code: string, secret: Uint8Array, opts?: TotpVerifyOptions): TotpVerifyResult— checks±windowtime 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".Clockis the canonical interface from@suaveplan/types; never readsDate.now()at module scope. - ADDED:
TotpVerifyOptions—TotpOptions & { window?: number }.windowdefaults 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
constantTimeEqualfrom@suaveplan/crypto-utilsso 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— parsesotpauth://totp/{label}?secret=...&issuer=...&algorithm=...&digits=...&period=...and thehotpvariant (withcounterinstead ofperiod). Per the Google Authenticator Key URI Format spec. - ADDED:
formatUri(opts: OtpUri): string— round-trip withparseUri(URI-encoding thelabelandissuerparameters;:forbidden in label, must be percent-encoded). - ADDED:
OtpUri— discriminated union{ type: "totp"; ... } | { type: "hotp"; ... }. - ADDED:
OtpUriSchema— Zod boundary; mismatch →OTP.URI_MALFORMEDorOTP.URI_UNSUPPORTED_TYPEiftypeis neither"totp"nor"hotp".
Base32 (./base32 subpath, RFC 4648 §6)
- ADDED:
base32Encode(bytes: Uint8Array, opts?: { padding?: boolean }): string—padding=falsedefault to match Google Authenticator; opt-inpadding=truefor 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 handlesOCRA-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?: Rngfrom@suaveplan/types). - ADDED:
hashRecoveryCode(code: string, opts?: { params?: Argon2idParams }): Promise<string>— Argon2id via@suaveplan/crypto-hashwith 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_CODESregistry viadefineCodes("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}andotp.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 customMeterProviderif 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,Rnginterfaces.@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
Uint8Arrayand never touches disk. - QR-code image rendering — covered by
@suaveplan/qrcode; this package only emits theotpauth://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;bigintcounter 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); customT0andperiod; injectedClock.packages/vault/otp/src/totp/verify.test.ts—window=0(no drift accepted),window=1(default ±1 step),window=10(max),window=11throwsWINDOW_OUT_OF_RANGE;deltareturned for off-step matches;verifyManyconstant-time across multiple secrets.packages/vault/otp/src/totp/constant-time.test.ts— pin:verifyandverifyManyevaluate 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;hotpvariant withcounter; round-trip withformatUri;URI_MALFORMEDandURI_UNSUPPORTED_TYPEpaths.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)) ≡ optsfor every valid option shape.packages/vault/otp/src/base32/base32.test.ts— RFC 4648 §6 reference vectors; padded and unpadded forms; case-insensitivity;BASE32_INVALIDon 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; injectedRngproduces deterministic codes.packages/vault/otp/src/recovery/verify.test.ts— Argon2id round-trip; constant-time over the full hashed-code list;RECOVERY_CODE_INVALIDon no match.packages/vault/otp/src/drift/detect.test.ts— drift outside the verify window surfaces the correctdeltaandsuggestedClockOffsetMs.packages/vault/otp/src/errors/codes.spec-conformance.test.ts— every code inOTP_CODESthrown 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-memoryMeterProvider.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).