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).
|
||||
491
openspec/archive/2026-05-10-enterprise-otp/specs/otp/spec.md
Normal file
491
openspec/archive/2026-05-10-enterprise-otp/specs/otp/spec.md
Normal file
@@ -0,0 +1,491 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: otp (enterprise-otp)
|
||||
|
||||
**Package**: `@suaveplan/otp`
|
||||
**Status**: PROPOSED — first delta; becomes canonical spec on archive.
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: HOTP per RFC 4226 with dynamic truncation
|
||||
|
||||
The package SHALL export `hotp(secret: Uint8Array, counter: bigint, opts?: HotpOptions): string` returning a `digits`-length decimal string computed via the dynamic-truncation algorithm in 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`. The counter SHALL be a `bigint` and SHALL be encoded as an 8-byte big-endian block per RFC 4226 §5.1. `digits` SHALL accept `6`, `7`, or `8`; any other value SHALL throw `OtpError(OTP.INVALID_DIGITS)`. `algorithm` SHALL accept `"SHA1"` (default), `"SHA256"`, and `"SHA512"` per RFC 6238 §1.2.
|
||||
|
||||
#### Scenario: RFC 4226 Appendix D test vector (counter=0)
|
||||
|
||||
- **WHEN** `hotp(SECRET, 0n)` is called with the Appendix D shared secret `"12345678901234567890"` and default options
|
||||
- **THEN** the result equals `"755224"`
|
||||
|
||||
#### Scenario: RFC 4226 Appendix D test vector (counter=9)
|
||||
|
||||
- **WHEN** `hotp(SECRET, 9n)` is called with the same secret and default options
|
||||
- **THEN** the result equals `"520489"`
|
||||
|
||||
#### Scenario: counter past 2^53 round-trips
|
||||
|
||||
- **WHEN** `hotp(SECRET, 9007199254740993n)` is called
|
||||
- **THEN** the call returns a 6-digit decimal string and does not lose precision (the 8-byte big-endian encoding receives the full bigint value)
|
||||
|
||||
#### Scenario: invalid digits throws
|
||||
|
||||
- **WHEN** `hotp(SECRET, 0n, { digits: 5 as 6 })` is called
|
||||
- **THEN** the call throws `OtpError` with code `"OTP.INVALID_DIGITS"`
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/hotp/hotp.test.ts::"RFC 4226 Appendix D vector counter=0"` AND `packages/vault/otp/src/hotp/hotp.test.ts::"RFC 4226 Appendix D vector counter=9"` AND `packages/vault/otp/src/hotp/hotp.test.ts::"bigint counter past 2^53 round-trips"` AND `packages/vault/otp/src/hotp/hotp.test.ts::"throws INVALID_DIGITS for digits=5"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: TOTP per RFC 6238 with injectable Clock
|
||||
|
||||
The package SHALL export `totp(secret: Uint8Array, opts?: TotpOptions): string` that computes `T = floor((now − T0) / period)` per RFC 6238 §4 and delegates to `hotp(secret, T, ...)`. The current time SHALL be obtained from an injected `Clock` (interface `Clock` from `@suaveplan/types`); the package SHALL NOT read `Date.now()` at module scope. Defaults: `period = 30`, `T0 = 0`, `digits = 6`, `algorithm = "SHA1"`. Negative `period` or `period === 0` SHALL throw `OtpError(OTP.INVALID_PERIOD)`.
|
||||
|
||||
#### Scenario: RFC 6238 Appendix B test vector at T=59 (SHA-1, 8 digits)
|
||||
|
||||
- **GIVEN** the Appendix B shared secret `"12345678901234567890"`, `algorithm: "SHA1"`, `digits: 8`, `period: 30`
|
||||
- **WHEN** `totp(SECRET, { ..., clock: { now: () => 59_000 } })` is called
|
||||
- **THEN** the result equals `"94287082"`
|
||||
|
||||
#### Scenario: RFC 6238 Appendix B test vector at T=1111111109 (SHA-256, 8 digits)
|
||||
|
||||
- **GIVEN** the Appendix B SHA-256 secret, `algorithm: "SHA256"`, `digits: 8`, `period: 30`
|
||||
- **WHEN** `totp(SECRET, { ..., clock: { now: () => 1_111_111_109_000 } })` is called
|
||||
- **THEN** the result equals `"68084774"`
|
||||
|
||||
#### Scenario: zero period throws
|
||||
|
||||
- **WHEN** `totp(SECRET, { period: 0 })` is called
|
||||
- **THEN** the call throws `OtpError` with code `"OTP.INVALID_PERIOD"`
|
||||
|
||||
#### Scenario: never reads Date.now at module scope
|
||||
|
||||
- **GIVEN** the package is imported in an environment where `Date.now` is replaced with `() => { throw new Error("forbidden") }`
|
||||
- **WHEN** the import completes
|
||||
- **THEN** the import does not throw (no top-level call to `Date.now()` exists)
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/totp/totp.test.ts::"RFC 6238 Appendix B vector T=59 SHA-1"` AND `packages/vault/otp/src/totp/totp.test.ts::"RFC 6238 Appendix B vector T=1111111109 SHA-256"` AND `packages/vault/otp/src/totp/totp.test.ts::"throws INVALID_PERIOD for period=0"` AND `packages/vault/otp/src/totp/totp.test.ts::"does not read Date.now at module scope"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: TOTP verify with drift window
|
||||
|
||||
The package SHALL export `verify(code: string, secret: Uint8Array, opts?: TotpVerifyOptions): TotpVerifyResult` that checks the supplied code against the current step and `±window` neighbouring steps per RFC 6238 §5.2. On match, the result SHALL be `{ valid: true; delta: number }` where `delta` is the signed step offset (`0` for the current step, `+1` for one step ahead, `-1` for one step behind, etc.). On no match, the result SHALL be `{ valid: false; delta: null }`. `window` SHALL default to `1` and SHALL be hard-capped at `10`; a `window` outside `[0, 10]` SHALL throw `OtpError(OTP.WINDOW_OUT_OF_RANGE)`.
|
||||
|
||||
#### Scenario: matches the current step
|
||||
|
||||
- **GIVEN** a TOTP code generated at the current `Clock.now()`
|
||||
- **WHEN** `verify(code, secret, { clock })` is called
|
||||
- **THEN** the result equals `{ valid: true, delta: 0 }`
|
||||
|
||||
#### Scenario: matches one step in the past
|
||||
|
||||
- **GIVEN** a code generated at `now - period` and a verifier at `now`
|
||||
- **WHEN** `verify(code, secret, { clock, window: 1 })` is called
|
||||
- **THEN** the result equals `{ valid: true, delta: -1 }`
|
||||
|
||||
#### Scenario: rejects a code outside the window
|
||||
|
||||
- **GIVEN** a code generated two steps in the future and `window: 1`
|
||||
- **WHEN** `verify(code, secret, { clock, window: 1 })` is called
|
||||
- **THEN** the result equals `{ valid: false, delta: null }`
|
||||
|
||||
#### Scenario: window above the cap throws
|
||||
|
||||
- **WHEN** `verify(code, secret, { window: 11 })` is called
|
||||
- **THEN** the call throws `OtpError` with code `"OTP.WINDOW_OUT_OF_RANGE"`
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/totp/verify.test.ts::"matches current step delta=0"` AND `packages/vault/otp/src/totp/verify.test.ts::"matches one step in the past delta=-1"` AND `packages/vault/otp/src/totp/verify.test.ts::"rejects code outside window"` AND `packages/vault/otp/src/totp/verify.test.ts::"throws WINDOW_OUT_OF_RANGE for window=11"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Constant-time verify
|
||||
|
||||
`verify` and `verifyMany` SHALL use a constant-time string comparison (`constantTimeEqual` from `@suaveplan/crypto-utils`) for every candidate code, and SHALL evaluate every step in the window AND every secret in the list regardless of whether an earlier candidate already matched. Implementations that early-exit the window loop or the secret loop on first match are non-conformant per RFC 6238 §5.2 ("OTPs MUST be verified through a constant-time comparison").
|
||||
|
||||
#### Scenario: window loop runs every step on a successful match
|
||||
|
||||
- **GIVEN** a verify call with `window: 5` and a code that matches at `delta = 0`
|
||||
- **WHEN** the windowed comparison loop is instrumented
|
||||
- **THEN** every step in the range `[-5, +5]` is evaluated (11 comparisons), not just up to the matching step
|
||||
|
||||
#### Scenario: secret loop runs every secret on a successful match
|
||||
|
||||
- **GIVEN** `verifyMany(code, [s1, s2, s3], opts)` where the code matches against `s1`
|
||||
- **WHEN** the secret loop is instrumented
|
||||
- **THEN** all three secrets are evaluated, not just the first
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/totp/constant-time.test.ts::"window loop evaluates every step on match"` AND `packages/vault/otp/src/totp/constant-time.test.ts::"verifyMany evaluates every secret on match"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Backup-aware verifyMany
|
||||
|
||||
The package SHALL export `verifyMany(code: string, secrets: Uint8Array[], opts?: TotpVerifyOptions): TotpVerifyManyResult` returning `{ valid: true; delta: number; secretIndex: number }` on match or `{ valid: false; delta: null; secretIndex: null }` on no match. `verifyMany` SHALL evaluate every secret in the list (constant-time across the list per the previous requirement). An empty `secrets` array SHALL return `{ valid: false; delta: null; secretIndex: null }` without throwing.
|
||||
|
||||
#### Scenario: matches the second secret in the list
|
||||
|
||||
- **GIVEN** `secrets = [oldSecret, newSecret]` and a code generated from `newSecret`
|
||||
- **WHEN** `verifyMany(code, secrets, { clock })` is called
|
||||
- **THEN** the result is `{ valid: true, delta: 0, secretIndex: 1 }`
|
||||
|
||||
#### Scenario: empty secret list returns invalid without throwing
|
||||
|
||||
- **WHEN** `verifyMany(code, [], opts)` is called
|
||||
- **THEN** the result is `{ valid: false, delta: null, secretIndex: null }`
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/totp/verify-many.test.ts::"matches second secret returns secretIndex=1"` AND `packages/vault/otp/src/totp/verify-many.test.ts::"empty secret list returns invalid"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Key URI Format parseUri
|
||||
|
||||
The package SHALL export `parseUri(uri: string): OtpUri` that parses Google Authenticator Key URI Format strings of the shape `otpauth://totp/{label}?secret=...&issuer=...&algorithm=...&digits=...&period=...` and the `hotp` variant (with `counter` instead of `period`). The result SHALL be a discriminated union on `type: "totp" | "hotp"`. Unknown URI schemes SHALL throw `OtpError(OTP.URI_UNSUPPORTED_TYPE)`. Malformed URIs (missing required `secret`, invalid percent-encoding, malformed query string) SHALL throw `OtpError(OTP.URI_MALFORMED)`.
|
||||
|
||||
#### Scenario: parses a TOTP URI with all parameters
|
||||
|
||||
- **WHEN** `parseUri("otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30")` is called
|
||||
- **THEN** the result has `type: "totp"`, `label: "Example:alice@example.com"`, `secret` decoded from base32, `issuer: "Example"`, `algorithm: "SHA1"`, `digits: 6`, `period: 30`
|
||||
|
||||
#### Scenario: parses an HOTP URI with counter
|
||||
|
||||
- **WHEN** `parseUri("otpauth://hotp/Example:alice?secret=JBSWY3DPEHPK3PXP&counter=42")` is called
|
||||
- **THEN** the result has `type: "hotp"` and `counter === 42n`
|
||||
|
||||
#### Scenario: rejects unsupported scheme
|
||||
|
||||
- **WHEN** `parseUri("otpauth://yubico/...")` is called
|
||||
- **THEN** the call throws `OtpError` with code `"OTP.URI_UNSUPPORTED_TYPE"`
|
||||
|
||||
#### Scenario: rejects missing secret parameter
|
||||
|
||||
- **WHEN** `parseUri("otpauth://totp/Example:alice")` is called
|
||||
- **THEN** the call throws `OtpError` with code `"OTP.URI_MALFORMED"`
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/uri/parse-uri.test.ts::"parses TOTP URI with all parameters"` AND `packages/vault/otp/src/uri/parse-uri.test.ts::"parses HOTP URI with counter"` AND `packages/vault/otp/src/uri/parse-uri.test.ts::"throws URI_UNSUPPORTED_TYPE for non-otp scheme"` AND `packages/vault/otp/src/uri/parse-uri.test.ts::"throws URI_MALFORMED when secret is missing"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Key URI Format formatUri with round-trip
|
||||
|
||||
The package SHALL export `formatUri(opts: OtpUri): string` that serialises an `OtpUri` value back to a `otpauth://` URI string. The function SHALL percent-encode the `label` and `issuer` per the Google Authenticator Key URI Format Label section. A `label` that contains a literal `":"` (i.e. an unencoded issuer-prefix separator that does not match the canonical `{issuer}:{accountname}` form expected by the spec) SHALL throw `OtpError(OTP.URI_MALFORMED)`; callers must percent-encode `:` to `%3A` if they want the literal character in the account-name portion. `parseUri` and `formatUri` SHALL round-trip: `parseUri(formatUri(opts))` SHALL equal `opts` for every valid `OtpUri` value.
|
||||
|
||||
#### Scenario: round-trips a TOTP URI
|
||||
|
||||
- **GIVEN** `opts = { type: "totp", label: "Example:alice", secret, issuer: "Example", algorithm: "SHA1", digits: 6, period: 30 }`
|
||||
- **WHEN** `parseUri(formatUri(opts))` is called
|
||||
- **THEN** the result deep-equals `opts`
|
||||
|
||||
#### Scenario: rejects literal colon in label after `{issuer}:` prefix
|
||||
|
||||
- **WHEN** `formatUri({ type: "totp", label: "Example:al:ice", secret, ... })` is called
|
||||
- **THEN** the call throws `OtpError` with code `"OTP.URI_MALFORMED"`
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/uri/format-uri.test.ts::"round-trips TOTP URI"` AND `packages/vault/otp/src/uri/format-uri.test.ts::"rejects unescaped colon in label"` AND `packages/vault/otp/src/uri/round-trip.property.test.ts::"parseUri ∘ formatUri is identity"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Base32 encode/decode (RFC 4648 §6)
|
||||
|
||||
The package SHALL export `base32Encode(bytes: Uint8Array, opts?: { padding?: boolean }): string` and `base32Decode(input: string): Uint8Array` implementing the RFC 4648 §6 alphabet (`A`–`Z` + `2`–`7`). `padding` SHALL default to `false` to match Google Authenticator's de-facto behaviour; `padding: true` SHALL append `=` characters to align to 8-character boundaries per the strict spec. `base32Decode` SHALL accept both padded and unpadded forms case-insensitively. Input containing characters outside the alphabet (after stripping `=`) SHALL throw `OtpError(OTP.BASE32_INVALID)`.
|
||||
|
||||
#### Scenario: encodes the empty string
|
||||
|
||||
- **WHEN** `base32Encode(new Uint8Array(0))` is called
|
||||
- **THEN** the result equals `""`
|
||||
|
||||
#### Scenario: round-trips an arbitrary byte string
|
||||
|
||||
- **GIVEN** any 32-byte secret
|
||||
- **WHEN** `base32Decode(base32Encode(bytes))` is called
|
||||
- **THEN** the result deep-equals `bytes`
|
||||
|
||||
#### Scenario: decodes case-insensitively
|
||||
|
||||
- **WHEN** `base32Decode("jbswy3dpehpk3pxp")` is called
|
||||
- **THEN** the result equals `base32Decode("JBSWY3DPEHPK3PXP")`
|
||||
|
||||
#### Scenario: rejects an out-of-alphabet character
|
||||
|
||||
- **WHEN** `base32Decode("JBSWY!3D")` is called
|
||||
- **THEN** the call throws `OtpError` with code `"OTP.BASE32_INVALID"`
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/base32/base32.test.ts::"encodes empty input"` AND `packages/vault/otp/src/base32/base32.test.ts::"round-trips arbitrary bytes"` AND `packages/vault/otp/src/base32/base32.test.ts::"decodes case-insensitively"` AND `packages/vault/otp/src/base32/base32.test.ts::"throws BASE32_INVALID on bad alphabet"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OCRA per RFC 6287
|
||||
|
||||
The package SHALL export `ocra(suite: string, key: Uint8Array, params: OcraParams): string` implementing RFC 6287 §6. The crypto-function segment SHALL accept `HOTP-SHA1-{4,6,8}`, `HOTP-SHA256-{4,6,8}`, and `HOTP-SHA512-{4,6,8}`. The data-input segment SHALL accept counters (`C`), challenges (`Q[A|N|H]\d+`), passwords (`P[SHA1|SHA256|SHA512]`), session info (`S\d+`), and timestamps (`T[\d][SHM]`).
|
||||
|
||||
#### Scenario: RFC 6287 Appendix C.1 one-way vector (Q="00000000")
|
||||
|
||||
- **GIVEN** suite `"OCRA-1:HOTP-SHA1-6:QN08"`, the Appendix C standard 20-byte key, and challenge `"00000000"`
|
||||
- **WHEN** `ocra(suite, key, { challenge: "00000000" })` is called
|
||||
- **THEN** the result equals `"237653"`
|
||||
|
||||
#### Scenario: RFC 6287 Appendix C.1 one-way vector (Q="11111111")
|
||||
|
||||
- **GIVEN** the same suite and key, and challenge `"11111111"`
|
||||
- **WHEN** `ocra(suite, key, { challenge: "11111111" })` is called
|
||||
- **THEN** the result equals `"243178"`
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/ocra/ocra.test.ts::"RFC 6287 Appendix C.1 Q=00000000"` AND `packages/vault/otp/src/ocra/ocra.test.ts::"RFC 6287 Appendix C.1 Q=11111111"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OCRA suite-string parser
|
||||
|
||||
The package SHALL export `parseOcraSuite(suite: string): OcraSuite` that parses the three-segment grammar `OCRA-Version:CryptoFunction:DataInput` defined in RFC 6287 §6.1. Malformed suites SHALL throw `OtpError(OTP.OCRA_SUITE_INVALID)`.
|
||||
|
||||
#### Scenario: parses a one-way suite
|
||||
|
||||
- **WHEN** `parseOcraSuite("OCRA-1:HOTP-SHA1-6:QN08")` is called
|
||||
- **THEN** the result describes version `1`, `cryptoFunction.algorithm: "SHA1"`, `cryptoFunction.digits: 6`, and a `dataInput` containing one numeric-challenge field of length 8
|
||||
|
||||
#### Scenario: parses a mutual-auth suite with counter and password
|
||||
|
||||
- **WHEN** `parseOcraSuite("OCRA-1:HOTP-SHA256-8:C-QN08-PSHA1")` is called
|
||||
- **THEN** the result describes algorithm `"SHA256"`, `digits: 8`, and `dataInput` with `counter: true`, `challenge: { format: "N", length: 8 }`, `password: "SHA1"`
|
||||
|
||||
#### Scenario: rejects malformed suite
|
||||
|
||||
- **WHEN** `parseOcraSuite("OCRA-1:NOT-A-FUNCTION:QN08")` is called
|
||||
- **THEN** the call throws `OtpError` with code `"OTP.OCRA_SUITE_INVALID"`
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/ocra/suite.test.ts::"parses one-way suite"` AND `packages/vault/otp/src/ocra/suite.test.ts::"parses mutual-auth suite with counter and password"` AND `packages/vault/otp/src/ocra/suite.test.ts::"throws OCRA_SUITE_INVALID for malformed suite"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OCRA mutual authentication
|
||||
|
||||
The package SHALL export `OcraMutualAuthClient` and `OcraMutualAuthServer` helpers implementing the three-message mutual-authentication flow described in RFC 6287 §7.1: server emits a challenge, client responds with its own challenge plus an OCRA response, server verifies the client response and emits its own OCRA response over the client's challenge.
|
||||
|
||||
#### Scenario: three-message round-trip succeeds with matching keys
|
||||
|
||||
- **GIVEN** a shared 20-byte key and the suite `"OCRA-1:HOTP-SHA1-6:QN08"` on both sides
|
||||
- **WHEN** the server emits a challenge, the client responds, the server verifies and emits its response, the client verifies
|
||||
- **THEN** both sides end the protocol in the `authenticated` state
|
||||
|
||||
#### Scenario: server detects a tampered client response
|
||||
|
||||
- **GIVEN** a client response with a flipped digit
|
||||
- **WHEN** the server runs `verifyClientResponse`
|
||||
- **THEN** the server returns `{ valid: false }` and does not emit the next message
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/ocra/mutual-auth.test.ts::"three-message round-trip succeeds"` AND `packages/vault/otp/src/ocra/mutual-auth.test.ts::"server rejects tampered client response"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Recovery code generation
|
||||
|
||||
The package SHALL export `generateRecoveryCodes(count: number, opts?: RecoveryCodeOptions): string[]` returning `count` Crockford-base32-encoded codes. Each code SHALL default to four 4-character groups separated by `-` (16 alphanumeric chars excluding `I`, `L`, `O`, `U` per Crockford). The RNG SHALL be injectable (`opts.rng?: Rng` from `@suaveplan/types`) so deterministic test seeds produce reproducible codes. `count` outside `[1, 50]` SHALL throw `OtpError(OTP.INVALID_OPTIONS)`.
|
||||
|
||||
#### Scenario: generates the requested number of codes
|
||||
|
||||
- **WHEN** `generateRecoveryCodes(10)` is called
|
||||
- **THEN** the result is a 10-element array of Crockford-base32 strings matching `/^[0-9A-HJ-KM-NP-TV-Z]{4}-[0-9A-HJ-KM-NP-TV-Z]{4}-[0-9A-HJ-KM-NP-TV-Z]{4}-[0-9A-HJ-KM-NP-TV-Z]{4}$/`
|
||||
|
||||
#### Scenario: deterministic with injected RNG
|
||||
|
||||
- **GIVEN** an `Rng` that yields a fixed byte stream
|
||||
- **WHEN** `generateRecoveryCodes(2, { rng })` is called twice
|
||||
- **THEN** both calls return the same two-element array
|
||||
|
||||
#### Scenario: count above the cap throws
|
||||
|
||||
- **WHEN** `generateRecoveryCodes(51)` is called
|
||||
- **THEN** the call throws `OtpError` with code `"OTP.INVALID_OPTIONS"`
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/recovery/generate.test.ts::"produces requested count of Crockford codes"` AND `packages/vault/otp/src/recovery/generate.test.ts::"is deterministic with injected RNG"` AND `packages/vault/otp/src/recovery/generate.test.ts::"throws INVALID_OPTIONS for count=51"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Recovery code hashing and verification (Argon2id)
|
||||
|
||||
The package SHALL export `hashRecoveryCode(code: string, opts?: { params?: Argon2idParams }): Promise<string>` that hashes a recovery code with Argon2id via `@suaveplan/crypto-hash`, defaulting to OWASP-aligned parameters (`memoryKB ≥ 65536`, `iterations ≥ 3`, `parallelism: 1`). The package SHALL export `verifyRecoveryCode(input: string, hashedCodes: string[]): Promise<RecoveryVerifyResult>` returning `{ valid: true; codeIndex: number }` on match or `{ valid: false; codeIndex: null }` on no match. Verification SHALL evaluate every entry in `hashedCodes` regardless of an earlier match (constant-time over the list).
|
||||
|
||||
#### Scenario: round-trips a generated code
|
||||
|
||||
- **GIVEN** a code from `generateRecoveryCodes(1)` and its `hashRecoveryCode` output
|
||||
- **WHEN** `verifyRecoveryCode(code, [hashed])` is called
|
||||
- **THEN** the result is `{ valid: true, codeIndex: 0 }`
|
||||
|
||||
#### Scenario: rejects a non-matching code
|
||||
|
||||
- **WHEN** `verifyRecoveryCode("0000-0000-0000-0000", [hashed])` is called
|
||||
- **THEN** the result is `{ valid: false, codeIndex: null }`
|
||||
|
||||
#### Scenario: evaluates every hashed entry regardless of match position
|
||||
|
||||
- **GIVEN** `hashedCodes = [h1, h2, h3]` and a plaintext that matches `h1`
|
||||
- **WHEN** the verify routine is instrumented
|
||||
- **THEN** all three Argon2id verifications run
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/recovery/verify.test.ts::"round-trips a generated code"` AND `packages/vault/otp/src/recovery/verify.test.ts::"rejects non-matching code"` AND `packages/vault/otp/src/recovery/verify.test.ts::"evaluates every hashed entry on match"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Clock-drift detector
|
||||
|
||||
The package SHALL export `detectDrift(code: string, secret: Uint8Array, opts?: DriftDetectOptions): DriftResult` that searches `±maxSteps` (default `60` = ±30 minutes at `period=30`) outside the configured verify window for a matching code. The result SHALL be `{ delta: number | null; suggestedClockOffsetMs: number | null }`. When a match is found, `suggestedClockOffsetMs` equals `delta * period * 1000` (rounded so positive deltas mean the device is ahead of the server). When no match is found in the entire `±maxSteps` range, both fields are `null`. `maxSteps` outside `[1, 1000]` SHALL throw `OtpError(OTP.INVALID_OPTIONS)`.
|
||||
|
||||
#### Scenario: detects a +5-step drift
|
||||
|
||||
- **GIVEN** a code generated 5 steps in the future
|
||||
- **WHEN** `detectDrift(code, secret, { clock, maxSteps: 60 })` is called
|
||||
- **THEN** the result is `{ delta: 5, suggestedClockOffsetMs: 150_000 }` (period=30 → 150 s)
|
||||
|
||||
#### Scenario: returns null when drift exceeds maxSteps
|
||||
|
||||
- **GIVEN** a code generated 100 steps in the future and `maxSteps: 60`
|
||||
- **WHEN** `detectDrift(code, secret, { clock, maxSteps: 60 })` is called
|
||||
- **THEN** the result is `{ delta: null, suggestedClockOffsetMs: null }`
|
||||
|
||||
#### Scenario: maxSteps above the cap throws
|
||||
|
||||
- **WHEN** `detectDrift(code, secret, { maxSteps: 1001 })` is called
|
||||
- **THEN** the call throws `OtpError` with code `"OTP.INVALID_OPTIONS"`
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/drift/detect.test.ts::"detects +5 step drift"` AND `packages/vault/otp/src/drift/detect.test.ts::"returns null when drift exceeds maxSteps"` AND `packages/vault/otp/src/drift/detect.test.ts::"throws INVALID_OPTIONS for maxSteps=1001"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Error registry coverage
|
||||
|
||||
The package SHALL export an `OTP_CODES` registry (defined via `defineCodes("OTP", [...])` from `@suaveplan/error`) containing exactly: `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`. Every code SHALL be thrown by at least one production path covered by the test suite.
|
||||
|
||||
#### Scenario: every registered code is thrown by a production path
|
||||
|
||||
- **WHEN** the spec-conformance suite enumerates `OTP_CODES` and runs the corresponding error-trigger fixtures
|
||||
- **THEN** every entry produces a thrown `OtpError` whose `code` field matches the registry value
|
||||
|
||||
#### Scenario: OtpError extends SuaveplanError
|
||||
|
||||
- **WHEN** `expectTypeOf<OtpError>().toMatchTypeOf<SuaveplanError<OtpErrorCode>>()` runs
|
||||
- **THEN** the type-level check passes
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/errors/codes.spec-conformance.test.ts::"every OTP_CODES entry is thrown by a production path"` AND `packages/vault/otp/src/errors/codes.spec-conformance.test.ts::"OtpError extends SuaveplanError"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod boundary schemas
|
||||
|
||||
The package SHALL declare `HotpOptionsSchema`, `TotpOptionsSchema`, `TotpVerifyOptionsSchema`, `OtpUriSchema`, and `OcraParamsSchema` Zod schemas at every public-API boundary. Each schema SHALL be invoked at the entry point of the corresponding function; mismatches SHALL throw `OtpError(OTP.INVALID_OPTIONS)` (or the more specific code when one applies — `INVALID_DIGITS`, `INVALID_PERIOD`, `WINDOW_OUT_OF_RANGE`, `OCRA_PARAMS_INVALID`).
|
||||
|
||||
#### Scenario: invalid HotpOptions throws INVALID_OPTIONS
|
||||
|
||||
- **WHEN** `hotp(secret, 0n, { algorithm: "MD5" as never })` is called
|
||||
- **THEN** the call throws `OtpError` with code `"OTP.INVALID_OPTIONS"`
|
||||
|
||||
#### Scenario: invalid TotpVerifyOptions window throws WINDOW_OUT_OF_RANGE
|
||||
|
||||
- **WHEN** `verify(code, secret, { window: -1 })` is called
|
||||
- **THEN** the call throws `OtpError` with code `"OTP.WINDOW_OUT_OF_RANGE"`
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/hotp/hotp.test.ts::"throws INVALID_OPTIONS for unknown algorithm"` AND `packages/vault/otp/src/totp/verify.test.ts::"throws WINDOW_OUT_OF_RANGE for window=-1"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Observability (OTel histograms and counters)
|
||||
|
||||
The package SHALL emit the following OTel instruments from production paths:
|
||||
|
||||
- Histogram `otp.generate_duration_ms{type,algorithm}` — `type` ∈ `"hotp" | "totp" | "ocra"`; recorded in every `hotp` / `totp` / `ocra` call.
|
||||
- Histogram `otp.verify_duration_ms{type,algorithm}` — recorded in every `verify` / `verifyMany` / `verifyOcra` / `verifyRecoveryCode` call.
|
||||
- Counter `otp.verify_outcome_total{outcome,delta_bucket}` — `outcome` ∈ `"valid" | "invalid"`; `delta_bucket` ∈ `"0" | "+-1" | "+-2..N"` (low-cardinality fixed enum).
|
||||
- Counter `otp.errors_total{code}` — incremented on every thrown `OtpError`; `code` is the registry code value.
|
||||
|
||||
`delta_bucket` SHALL be `"0"` when `delta === 0`, `"+-1"` when `Math.abs(delta) === 1`, and `"+-2..N"` for any other matched delta. The bucket cardinality is fixed at 3.
|
||||
|
||||
#### Scenario: every documented instrument fires from a production path
|
||||
|
||||
- **GIVEN** an in-memory `MeterProvider` collecting all metrics
|
||||
- **WHEN** the spec-conformance suite drives a generate path, a verify path, and an error path
|
||||
- **THEN** the four documented instruments record at least one data point each
|
||||
|
||||
#### Scenario: delta_bucket cardinality stays at 3
|
||||
|
||||
- **WHEN** the suite drives matches at deltas `0`, `+1`, `-1`, `+5`, `-3`
|
||||
- **THEN** the `otp.verify_outcome_total` counter exposes exactly three distinct `delta_bucket` attribute values: `"0"`, `"+-1"`, `"+-2..N"`
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/observability/metrics.spec-conformance.test.ts::"every documented instrument fires"` AND `packages/vault/otp/src/observability/metrics.spec-conformance.test.ts::"delta_bucket cardinality stays at 3"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RFC 4226 Appendix D test vectors
|
||||
|
||||
The package SHALL pass every (counter, HOTP) pair in RFC 4226 Appendix D using the Appendix D shared secret `"12345678901234567890"`, `algorithm: "SHA1"`, and `digits: 6`. The Appendix D table covers counters 0 through 9 producing codes `755224`, `287082`, `359152`, `969429`, `338314`, `254676`, `287922`, `162583`, `399871`, `520489`.
|
||||
|
||||
#### Scenario: every Appendix D row matches
|
||||
|
||||
- **WHEN** `hotp(SECRET, BigInt(counter))` is called for each counter in `[0..9]`
|
||||
- **THEN** the result equals the matching Appendix D code
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/hotp/hotp.test.ts::"RFC 4226 Appendix D every row"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RFC 6238 Appendix B test vectors
|
||||
|
||||
The package SHALL pass every (T, TOTP) pair in RFC 6238 Appendix B at `digits: 8`. The table covers `T ∈ {59, 1111111109, 1111111111, 1234567890, 2000000000, 20000000000}` across `algorithm ∈ {"SHA1", "SHA256", "SHA512"}` with the Appendix B per-algorithm secrets (`12345678901234567890` for SHA-1, the 32-byte and 64-byte ASCII variants for SHA-256 and SHA-512 respectively).
|
||||
|
||||
#### Scenario: every Appendix B row matches
|
||||
|
||||
- **WHEN** `totp(secretFor(algorithm), { algorithm, digits: 8, clock: { now: () => T * 1000 } })` is called for each row
|
||||
- **THEN** the result equals the matching Appendix B code
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/totp/totp.test.ts::"RFC 6238 Appendix B every row"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RFC 6287 Appendix C test vectors
|
||||
|
||||
The package SHALL pass every test vector in RFC 6287 Appendix C tables C.1 (one-way), C.2 (mutual-authentication), and C.3 (plain-challenge with hashed-PIN) using the Appendix C 20-byte and 32-byte standard keys.
|
||||
|
||||
#### Scenario: Appendix C.1 one-way vectors match
|
||||
|
||||
- **WHEN** `ocra("OCRA-1:HOTP-SHA1-6:QN08", standardKey20, { challenge: q })` is called for each `q` in the C.1 table
|
||||
- **THEN** the result equals the matching C.1 code
|
||||
|
||||
#### Scenario: Appendix C.2 mutual-authentication vectors match
|
||||
|
||||
- **WHEN** the mutual-auth suite is exercised against the C.2 input rows
|
||||
- **THEN** every server-response and client-response value equals the matching C.2 code
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/ocra/ocra.test.ts::"RFC 6287 Appendix C.1 every row"` AND `packages/vault/otp/src/ocra/mutual-auth.test.ts::"RFC 6287 Appendix C.2 every row"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Key URI Format round-trip property
|
||||
|
||||
For every value drawn from a fast-check arbitrary over `OtpUri` (well-formed labels, every supported algorithm, every digit count, both `totp` and `hotp` variants, randomly-encoded issuer strings), `parseUri(formatUri(opts))` SHALL deep-equal `opts`.
|
||||
|
||||
#### Scenario: property holds across 1000 random samples
|
||||
|
||||
- **WHEN** the fast-check property runs with `numRuns: 1000`
|
||||
- **THEN** every sample satisfies `parseUri(formatUri(opts)) ≡ opts`
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/uri/round-trip.property.test.ts::"parseUri ∘ formatUri is identity (1000 samples)"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports
|
||||
|
||||
The package SHALL declare subpath exports so consumers can import only the surfaces they need:
|
||||
|
||||
- `@suaveplan/otp` — root barrel (HOTP, TOTP, verify, verifyMany, recovery, drift)
|
||||
- `@suaveplan/otp/uri` — `parseUri`, `formatUri`, `OtpUri`, `OtpUriSchema`
|
||||
- `@suaveplan/otp/base32` — `base32Encode`, `base32Decode`
|
||||
- `@suaveplan/otp/ocra` — `ocra`, `verifyOcra`, `parseOcraSuite`, `OcraMutualAuthClient`, `OcraMutualAuthServer`
|
||||
- `@suaveplan/otp/errors` — `OTP_CODES`, `OtpError`, `OtpErrorCode`
|
||||
- `@suaveplan/otp/observability` — OTel instrument factories
|
||||
|
||||
#### Scenario: every subpath resolves
|
||||
|
||||
- **WHEN** every documented subpath is imported in a consumer test fixture
|
||||
- **THEN** every import resolves with no `Module not found` errors
|
||||
|
||||
**Verified by:** `packages/vault/otp/src/index.test.ts::"every documented subpath export resolves"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
158
openspec/archive/2026-05-10-enterprise-otp/tasks.md
Normal file
158
openspec/archive/2026-05-10-enterprise-otp/tasks.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Tasks — `@suaveplan/otp` (RFC 4226 / 6238 / 6287 + Key URI + recovery codes)
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened. Do not tick a box for a test that doesn't exist or doesn't pin the requirement it claims to verify.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Read `../suaveplan/enterprise/otp.md` end-to-end
|
||||
- [ ] 1.2 Read `openspec/AGENTS.md` Sections 13, 14, 14a
|
||||
- [ ] 1.3 Read RFC 4226 (HOTP), RFC 6238 (TOTP), RFC 6287 (OCRA), Google Authenticator Key URI Format wiki page
|
||||
- [ ] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [ ] 1.5 Fill in `## Source mapping` table — every requirement cites an RFC clause OR appears in the "no source line" table with justification
|
||||
- [ ] 1.6 Draft `specs/otp/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [ ] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Package scaffold
|
||||
|
||||
- [ ] 2.1 `packages/vault/otp/` directory structure (`src/{hotp,totp,uri,base32,ocra,recovery,drift,errors,observability}/`)
|
||||
- [ ] 2.2 `package.json` — `@suaveplan/otp@0.0.0`, `type: "module"`, `sideEffects: false`, deps via `workspace:^` / `catalog:`
|
||||
- [ ] 2.3 `tsconfig.json` extends `@suaveplan/typescript-config/library.json`
|
||||
- [ ] 2.4 `vite.config.ts` for build, `vitest.config.ts` from `@suaveplan/testing/config`
|
||||
- [ ] 2.5 `README.md` with package description, install snippet, quick-start, `## Modules` table linking every co-located `.md`
|
||||
- [ ] 2.6 Subpath exports map covers `.`, `./uri`, `./base32`, `./ocra`, `./errors`, `./observability`
|
||||
|
||||
## 3. Implementation — HOTP (RFC 4226)
|
||||
|
||||
- [ ] 3.1 `src/hotp/hotp.ts` — `hotp(secret, counter, opts?)` with dynamic truncation per §5.3 step 2
|
||||
- [ ] 3.2 `src/hotp/hotp.types.ts` — `HotpOptions`, `HotpAlgorithm`, `HotpDigits`
|
||||
- [ ] 3.3 `src/hotp/hotp.schema.ts` — `HotpOptionsSchema` Zod boundary
|
||||
- [ ] 3.4 8-byte big-endian counter encoding helper in `src/hotp/encode-counter.ts`
|
||||
- [ ] 3.5 `src/hotp/index.ts` — explicit named re-exports
|
||||
|
||||
## 4. Implementation — TOTP (RFC 6238)
|
||||
|
||||
- [ ] 4.1 `src/totp/totp.ts` — `totp(secret, opts?)` over `T = floor((now − T0) / period)` with injectable `Clock`
|
||||
- [ ] 4.2 `src/totp/verify.ts` — `verify(code, secret, opts?)` with `±window` drift; constant-time loop
|
||||
- [ ] 4.3 `src/totp/verify-many.ts` — `verifyMany(code, secrets[], opts?)` constant-time across the secret list
|
||||
- [ ] 4.4 `src/totp/totp.types.ts` — `TotpOptions`, `TotpVerifyOptions`, `TotpVerifyResult`, `TotpVerifyManyResult`
|
||||
- [ ] 4.5 `src/totp/totp.schema.ts` — `TotpOptionsSchema`, `TotpVerifyOptionsSchema` (`window` ∈ [0,10])
|
||||
- [ ] 4.6 `src/totp/index.ts` — explicit named re-exports
|
||||
|
||||
## 5. Implementation — Key URI Format
|
||||
|
||||
- [ ] 5.1 `src/uri/parse-uri.ts` — `parseUri(uri)` for `otpauth://totp/...` and `hotp` variant
|
||||
- [ ] 5.2 `src/uri/format-uri.ts` — `formatUri(opts)` round-trip with percent-encoding of label and issuer
|
||||
- [ ] 5.3 `src/uri/uri.types.ts` — `OtpUri` discriminated union (`type: "totp" | "hotp"`)
|
||||
- [ ] 5.4 `src/uri/uri.schema.ts` — `OtpUriSchema` Zod boundary
|
||||
- [ ] 5.5 `src/uri/index.ts` — explicit named re-exports
|
||||
|
||||
## 6. Implementation — Base32 (RFC 4648 §6)
|
||||
|
||||
- [ ] 6.1 `src/base32/base32.ts` — `base32Encode(bytes, opts?)` and `base32Decode(input)`; `padding=false` default to match Google Authenticator
|
||||
- [ ] 6.2 `src/base32/index.ts` — explicit named re-exports
|
||||
|
||||
## 7. Implementation — OCRA (RFC 6287)
|
||||
|
||||
- [ ] 7.1 `src/ocra/ocra.ts` — `ocra(suite, key, params)` per §6 computation
|
||||
- [ ] 7.2 `src/ocra/suite.ts` — `parseOcraSuite(suite)` per §6.1 grammar
|
||||
- [ ] 7.3 `src/ocra/verify-ocra.ts` — `verifyOcra(...)` constant-time
|
||||
- [ ] 7.4 `src/ocra/mutual-auth.ts` — `OcraMutualAuthClient`, `OcraMutualAuthServer` per §7.1
|
||||
- [ ] 7.5 `src/ocra/ocra.types.ts` — `OcraSuite`, `OcraParams`, `OcraCryptoFunction`, `OcraDataInput`
|
||||
- [ ] 7.6 `src/ocra/ocra.schema.ts` — `OcraParamsSchema` Zod boundary
|
||||
- [ ] 7.7 `src/ocra/index.ts` — explicit named re-exports
|
||||
|
||||
## 8. Implementation — Recovery codes
|
||||
|
||||
- [ ] 8.1 `src/recovery/generate.ts` — `generateRecoveryCodes(count, opts?)` Crockford-base32 grouping, injectable `Rng`
|
||||
- [ ] 8.2 `src/recovery/hash.ts` — `hashRecoveryCode(code, opts?)` Argon2id via `@suaveplan/crypto-hash`
|
||||
- [ ] 8.3 `src/recovery/verify.ts` — `verifyRecoveryCode(input, hashedCodes[])` constant-time over the full list
|
||||
- [ ] 8.4 `src/recovery/recovery.types.ts` — `RecoveryCodeOptions`, `RecoveryVerifyResult`
|
||||
- [ ] 8.5 `src/recovery/index.ts` — explicit named re-exports
|
||||
|
||||
## 9. Implementation — Clock-drift detector
|
||||
|
||||
- [ ] 9.1 `src/drift/detect.ts` — `detectDrift(code, secret, opts?)` searches `±maxSteps` outside the verify window
|
||||
- [ ] 9.2 `src/drift/drift.types.ts` — `DriftDetectOptions`, `DriftResult`
|
||||
- [ ] 9.3 `src/drift/index.ts` — explicit named re-exports
|
||||
|
||||
## 10. Implementation — Errors and observability
|
||||
|
||||
- [ ] 10.1 `src/errors/codes.ts` — `OTP_CODES` registry via `defineCodes("OTP", [...])`; `OtpError extends SuaveplanError<OtpErrorCode>`
|
||||
- [ ] 10.2 `src/errors/index.ts` — explicit named re-exports
|
||||
- [ ] 10.3 `src/observability/metrics.ts` — histograms and counters wired against `@opentelemetry/api`
|
||||
- [ ] 10.4 `src/observability/index.ts` — explicit named re-exports
|
||||
|
||||
## 11. Root barrel + subpath exports
|
||||
|
||||
- [ ] 11.1 `src/index.ts` — explicit named re-exports of HOTP, TOTP, verify, verifyMany, recovery, drift, errors, observability surfaces (no `export *`)
|
||||
- [ ] 11.2 Verify exports map count matches feature module count (§13 step 8)
|
||||
|
||||
## 12. Documentation
|
||||
|
||||
- [ ] 12.1 Sibling `.md` for every implementation `.ts` (≥200 words; basic + advanced examples; full API reference; implementation notes)
|
||||
- [ ] 12.2 README links every co-located `.md` under `## Modules`
|
||||
|
||||
## 13. Tests (runner-neutral imports — `@suaveplan/testing/runner` only)
|
||||
|
||||
- [ ] 13.1 `src/hotp/hotp.test.ts` — RFC 4226 Appendix D 10 vectors, SHA-256/512 widening, `bigint` past 2^53, digits 6/7/8
|
||||
- [ ] 13.2 `src/totp/totp.test.ts` — RFC 6238 Appendix B 6 vectors across SHA-1/256/512 at digits=8
|
||||
- [ ] 13.3 `src/totp/verify.test.ts` — window 0 / 1 / 10 / >10; `delta` semantics
|
||||
- [ ] 13.4 `src/totp/verify-many.test.ts` — multi-secret happy path + miss path
|
||||
- [ ] 13.5 `src/totp/constant-time.test.ts` — every windowed step and every secret evaluated regardless of early match
|
||||
- [ ] 13.6 `src/uri/parse-uri.test.ts` — every documented parameter; `hotp` variant; malformed paths
|
||||
- [ ] 13.7 `src/uri/format-uri.test.ts` — label / issuer percent-encoding; `:` in label rejected
|
||||
- [ ] 13.8 `src/uri/round-trip.property.test.ts` — fast-check property test
|
||||
- [ ] 13.9 `src/base32/base32.test.ts` — RFC 4648 §6 vectors; padded + unpadded; case-insensitivity; invalid alphabet
|
||||
- [ ] 13.10 `src/ocra/ocra.test.ts` — RFC 6287 Appendix C.1–C.3 vectors
|
||||
- [ ] 13.11 `src/ocra/suite.test.ts` — every §6.1 grammar branch
|
||||
- [ ] 13.12 `src/ocra/mutual-auth.test.ts` — three-message §7.1 round-trip
|
||||
- [ ] 13.13 `src/recovery/generate.test.ts` — Crockford-base32 grouping; deterministic with injected `Rng`
|
||||
- [ ] 13.14 `src/recovery/verify.test.ts` — Argon2id round-trip; constant-time over the full set
|
||||
- [ ] 13.15 `src/drift/detect.test.ts` — drift outside the verify window
|
||||
- [ ] 13.16 `src/errors/codes.spec-conformance.test.ts` — every `OTP_CODES` entry thrown by at least one production path
|
||||
- [ ] 13.17 `src/observability/metrics.spec-conformance.test.ts` — every histogram and counter emitted; assertions against an in-memory `MeterProvider`
|
||||
- [ ] 13.18 `src/index.test.ts` — every documented subpath resolves
|
||||
- [ ] 13.19 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
|
||||
- [ ] 13.20 Runner-neutral imports — CLAUDE.md §11 grep clean
|
||||
|
||||
## 14. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 14.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the whole monorepo
|
||||
- [ ] 14.2 `bunx turbo lint typecheck test --filter=@suaveplan/otp` — green
|
||||
- [ ] 14.3 `bunx turbo test:coverage --filter=@suaveplan/otp` — 100/100/100/100
|
||||
- [ ] 14.4 `bun scripts/openspec-conformance.ts @suaveplan/otp` — exit 0, no `error` findings
|
||||
- [ ] 14.5 `bunx turbo build --filter=@suaveplan/otp` — green
|
||||
|
||||
## 15. Pre-archive 7-row checklist (AGENTS.md Section 13)
|
||||
|
||||
- [ ] 15.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim (`HotpAlgorithm`, `HotpDigits`, `OtpUri.type`)
|
||||
- [ ] 15.2 Zod schemas wired at every declared boundary (`HotpOptionsSchema`, `TotpOptionsSchema`, `TotpVerifyOptionsSchema`, `OtpUriSchema`, `OcraParamsSchema`)
|
||||
- [ ] 15.3 Every error code in `OTP_CODES` thrown by at least one production path
|
||||
- [ ] 15.4 Strict-mode behaviors tested both on and off (window cap, base32 padding, label `:` rejection)
|
||||
- [ ] 15.5 OTel histograms and counters emitted from production paths and asserted with a real in-memory `MeterProvider`
|
||||
- [ ] 15.6 Lifecycle dedup — N/A (pure functions; no shared mutable state)
|
||||
- [ ] 15.7 No retroactive tick — every box above reflects work done in this change
|
||||
|
||||
## 16. Benchmarks (mitata, `src/__benchmarks__/`)
|
||||
|
||||
- [ ] 16.1 `totp_generate.bench.ts`
|
||||
- [ ] 16.2 `totp_verify_window_1.bench.ts`
|
||||
- [ ] 16.3 `totp_verify_window_10.bench.ts`
|
||||
- [ ] 16.4 `parse_uri.bench.ts`
|
||||
- [ ] 16.5 `format_uri.bench.ts`
|
||||
- [ ] 16.6 Baseline numbers published in README
|
||||
|
||||
## 17. Root-doc refresh + markdownlint (AGENTS.md §13b)
|
||||
|
||||
- [ ] 17.1 `bun scripts/folder-reorg/generate-manifest.ts` — refresh `MANIFEST.md`
|
||||
- [ ] 17.2 `bun scripts/generate-missing.ts` — refresh `MISSING.yml`
|
||||
- [ ] 17.3 `STATUS.md` — move `@suaveplan/otp` from `Pending` to `Completed`
|
||||
- [ ] 17.4 `SUMMARY.md` — add per-package paragraph
|
||||
- [ ] 17.5 `CLAUDE.md` — add package listing under "Vaulted" / vault-orchestration section
|
||||
- [ ] 17.6 `bunx markdownlint-cli2 --fix` — auto-fix; re-stage
|
||||
- [ ] 17.7 `bunx markdownlint-cli2` — second pass exit 0
|
||||
|
||||
## 18. Archive
|
||||
|
||||
- [ ] 18.1 Fold spec delta into `openspec/specs/otp/spec.md` (canonical, status ACTIVE)
|
||||
- [ ] 18.2 Move `openspec/changes/enterprise-otp/` → `openspec/archive/<yyyy-mm-dd>-enterprise-otp/`
|
||||
Reference in New Issue
Block a user