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

492 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# 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<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/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<OtpError>().toMatchTypeOf<SuaveplanError<OtpErrorCode>>()` 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"`
<!-- markdownlint-enable MD024 -->