Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

491
openspec/specs/otp/spec.md Normal file
View File

@@ -0,0 +1,491 @@
<!-- 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 -->