Initial Commit
This commit is contained in:
174
openspec/archive/2026-05-10-enterprise-otp/proposal.md
Normal file
174
openspec/archive/2026-05-10-enterprise-otp/proposal.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# `@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).
|
||||
Reference in New Issue
Block a user