# Capability Spec: otp **Package**: `@suaveplan/otp` **Status**: ACTIVE > Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.) ## 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/crypto/otp/src/hotp/hotp.test.ts::"RFC 4226 Appendix D vector counter=0"` AND `packages/crypto/otp/src/hotp/hotp.test.ts::"RFC 4226 Appendix D vector counter=9"` AND `packages/crypto/otp/src/hotp/hotp.test.ts::"bigint counter past 2^53 round-trips"` AND `packages/crypto/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/crypto/otp/src/totp/totp.test.ts::"RFC 6238 Appendix B vector T=59 SHA-1"` AND `packages/crypto/otp/src/totp/totp.test.ts::"RFC 6238 Appendix B vector T=1111111109 SHA-256"` AND `packages/crypto/otp/src/totp/totp.test.ts::"throws INVALID_PERIOD for period=0"` AND `packages/crypto/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/crypto/otp/src/totp/verify.test.ts::"matches current step delta=0"` AND `packages/crypto/otp/src/totp/verify.test.ts::"matches one step in the past delta=-1"` AND `packages/crypto/otp/src/totp/verify.test.ts::"rejects code outside window"` AND `packages/crypto/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/crypto/otp/src/totp/constant-time.test.ts::"window loop evaluates every step on match"` AND `packages/crypto/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/crypto/otp/src/totp/verify-many.test.ts::"matches second secret returns secretIndex=1"` AND `packages/crypto/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/crypto/otp/src/uri/parse-uri.test.ts::"parses TOTP URI with all parameters"` AND `packages/crypto/otp/src/uri/parse-uri.test.ts::"parses HOTP URI with counter"` AND `packages/crypto/otp/src/uri/parse-uri.test.ts::"throws URI_UNSUPPORTED_TYPE for non-otp scheme"` AND `packages/crypto/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/crypto/otp/src/uri/format-uri.test.ts::"round-trips TOTP URI"` AND `packages/crypto/otp/src/uri/format-uri.test.ts::"rejects unescaped colon in label"` AND `packages/crypto/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/crypto/otp/src/base32/base32.test.ts::"encodes empty input"` AND `packages/crypto/otp/src/base32/base32.test.ts::"round-trips arbitrary bytes"` AND `packages/crypto/otp/src/base32/base32.test.ts::"decodes case-insensitively"` AND `packages/crypto/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/crypto/otp/src/ocra/ocra.test.ts::"RFC 6287 Appendix C.1 Q=00000000"` AND `packages/crypto/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/crypto/otp/src/ocra/suite.test.ts::"parses one-way suite"` AND `packages/crypto/otp/src/ocra/suite.test.ts::"parses mutual-auth suite with counter and password"` AND `packages/crypto/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/crypto/otp/src/ocra/mutual-auth.test.ts::"three-message round-trip succeeds"` AND `packages/crypto/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/crypto/otp/src/recovery/generate.test.ts::"produces requested count of Crockford codes"` AND `packages/crypto/otp/src/recovery/generate.test.ts::"is deterministic with injected RNG"` AND `packages/crypto/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` 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` 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/crypto/otp/src/recovery/verify.test.ts::"round-trips a generated code"` AND `packages/crypto/otp/src/recovery/verify.test.ts::"rejects non-matching code"` AND `packages/crypto/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/crypto/otp/src/drift/detect.test.ts::"detects +5 step drift"` AND `packages/crypto/otp/src/drift/detect.test.ts::"returns null when drift exceeds maxSteps"` AND `packages/crypto/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().toMatchTypeOf>()` runs - **THEN** the type-level check passes **Verified by:** `packages/crypto/otp/src/errors/codes.spec-conformance.test.ts::"every OTP_CODES entry is thrown by a production path"` AND `packages/crypto/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/crypto/otp/src/hotp/hotp.test.ts::"throws INVALID_OPTIONS for unknown algorithm"` AND `packages/crypto/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/crypto/otp/src/observability/metrics.spec-conformance.test.ts::"every documented instrument fires"` AND `packages/crypto/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/crypto/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/crypto/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/crypto/otp/src/ocra/ocra.test.ts::"RFC 6287 Appendix C.1 every row"` AND `packages/crypto/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/crypto/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/crypto/otp/src/index.test.ts::"every documented subpath export resolves"`