30 KiB
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. (Seeopenspec/AGENTS.mdSection 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
OtpErrorwith 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
OtpErrorwith code"OTP.INVALID_PERIOD"
Scenario: never reads Date.now at module scope
- GIVEN the package is imported in an environment where
Date.nowis 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 - periodand a verifier atnow - 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
OtpErrorwith 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: 5and a code that matches atdelta = 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 againsts1 - 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 fromnewSecret - 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",secretdecoded 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"andcounter === 42n
Scenario: rejects unsupported scheme
- WHEN
parseUri("otpauth://yubico/...")is called - THEN the call throws
OtpErrorwith code"OTP.URI_UNSUPPORTED_TYPE"
Scenario: rejects missing secret parameter
- WHEN
parseUri("otpauth://totp/Example:alice")is called - THEN the call throws
OtpErrorwith 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
OtpErrorwith 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
OtpErrorwith 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 adataInputcontaining 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, anddataInputwithcounter: 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
OtpErrorwith 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
authenticatedstate
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
Rngthat 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
OtpErrorwith 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 itshashRecoveryCodeoutput - 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 matchesh1 - 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
OtpErrorwith 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_CODESand runs the corresponding error-trigger fixtures - THEN every entry produces a thrown
OtpErrorwhosecodefield 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
OtpErrorwith 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
OtpErrorwith 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 everyhotp/totp/ocracall. - Histogram
otp.verify_duration_ms{type,algorithm}— recorded in everyverify/verifyMany/verifyOcra/verifyRecoveryCodecall. - 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 thrownOtpError;codeis 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
MeterProvidercollecting 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_totalcounter exposes exactly three distinctdelta_bucketattribute 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 eachqin 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 founderrors
Verified by: packages/vault/otp/src/index.test.ts::"every documented subpath export resolves"