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

30 KiB
Raw Permalink Blame History

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"