30 KiB
Capability Spec Delta: srp (enterprise-srp)
Package: @suaveplan/srp
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: Bundled 2048-bit safe-prime group
The package SHALL export the RFC 5054 Appendix A 2048-bit safe-prime group (N_2048, g_2048 = 2) as the default group for all SRP-6a operations, where N_2048 is a 256-byte Uint8Array whose value is byte-exact to RFC 5054 Appendix A "2048-bit Group". The default group SHALL be reachable from the package root barrel and from the ./groups subpath without requiring any additional import.
Scenario: 2048-bit N matches RFC 5054 Appendix A byte-exactly
- WHEN
N_2048is read from@suaveplan/srp/groups - THEN the value, hex-encoded uppercase, equals the RFC 5054 Appendix A 2048-bit modulus literal
Scenario: 2048-bit g equals 2
- WHEN
g_2048is read from@suaveplan/srp/groups - THEN the value is a single-byte
Uint8Array([2])
Scenario: bitLength reports 2048
- WHEN
BIT_LENGTHis read from@suaveplan/srp/groups - THEN the value is
2048
Verified by: packages/crypto/srp/src/groups/groups-2048.test.ts::"N_2048 byte-exact to RFC 5054 Appendix A" AND packages/crypto/srp/src/groups/groups-2048.test.ts::"g_2048 equals 2" AND packages/crypto/srp/src/groups/groups-2048.test.ts::"BIT_LENGTH equals 2048"
Requirement: Optional groups under tree-shakeable subpaths
The package SHALL expose RFC 5054 Appendix A's 1024, 1536, 3072, 4096, 6144, and 8192-bit groups under per-size subpaths (@suaveplan/srp/groups/1024, …/1536, …/3072, …/4096, …/6144, …/8192). Each subpath SHALL emit (N, g, BIT_LENGTH) byte-exact to the corresponding RFC 5054 Appendix A block. Consumers that import only @suaveplan/srp/groups SHALL NOT pull any non-2048 group bytes into their bundle.
Scenario: 1024-bit group matches RFC Appendix A and Appendix B vector substrate
- WHEN
N_1024is read from@suaveplan/srp/groups/1024 - THEN the value, hex-encoded uppercase, equals the RFC 5054 Appendix A 1024-bit modulus literal AND
g_1024equals 2
Scenario: 8192-bit group is reachable but not bundled at root
- WHEN
@suaveplan/srp/groups/8192is imported - THEN
N_8192.byteLengthequals1024andBIT_LENGTHequals8192
Scenario: root barrel does not export non-2048 groups
- WHEN the root
@suaveplan/srpbarrel is enumerated - THEN symbols
N_1024,N_1536,N_3072,N_4096,N_6144,N_8192are NOT present
Verified by: packages/crypto/srp/src/groups/1024/groups-1024.test.ts::"N_1024 byte-exact to RFC 5054 Appendix A" AND packages/crypto/srp/src/groups/groups.spec-conformance.test.ts::"8192-bit subpath emits 1024-byte modulus" AND packages/crypto/srp/src/groups/groups.spec-conformance.test.ts::"root barrel does not re-export non-default group constants"
Requirement: PAD function zero-extends to byteLength(N)
The package SHALL export PAD(x: Uint8Array, N: Uint8Array): Uint8Array per RFC 5054 §2.5.3, returning a left-zero-extended copy of x whose length equals N.byteLength. If x.byteLength > N.byteLength, the function SHALL throw SrpError with code SRP.INVALID_PARAMS.
Scenario: pads a short byte string to the modulus length
- WHEN
PAD(new Uint8Array([0xab, 0xcd]), N_2048)is called - THEN the result has length 256, the trailing two bytes are
[0xab, 0xcd], and every preceding byte is0
Scenario: rejects oversize input
- WHEN
PAD(x, N_2048)is called withx.byteLength === 257 - THEN the call throws
SrpErrorwith code"SRP.INVALID_PARAMS"
Verified by: packages/crypto/srp/src/math/pad.test.ts::"left-zero-extends a short byte string to byteLength(N)" AND packages/crypto/srp/src/math/pad.test.ts::"throws SRP.INVALID_PARAMS on oversize input"
Requirement: Multiplier parameter k = H(N | PAD(g))
The package SHALL export computeK(params: SrpParams): Uint8Array that returns H(N | PAD(g)) per RFC 5054 §2.5.3. The hash function SHALL be the one supplied on params.hash. The result length SHALL match the chosen hash's digest size (32 bytes for SHA-256, 20 for SHA-1).
Scenario: matches RFC 5054 Appendix B reference k for 1024-bit group
- WHEN
computeK(params1024Sha1())is called with the RFC 5054 Appendix B parameters - THEN the result equals the Appendix B reference
kvalue byte-exactly
Scenario: produces 32-byte digest under SHA-256
- WHEN
computeK(params2048Sha256())is called - THEN the result has length 32
Verified by: packages/crypto/srp/src/math/k.test.ts::"matches RFC 5054 Appendix B k for 1024-bit group" AND packages/crypto/srp/src/math/k.test.ts::"emits 32-byte digest under SHA-256"
Requirement: Hash function — SHA-256 default, SHA-1 legacy opt-in
The package SHALL accept algorithm: "SHA256" | "SHA1" on SrpParams. The params2048Sha256(hash) factory SHALL produce a SrpParams value with algorithm === "SHA256". Callers requesting any algorithm not in {"SHA256", "SHA1"} SHALL receive SrpError with code SRP.HASH_UNSUPPORTED from the params constructor or from the hash bridge.
Scenario: SHA-256 is the default for the bundled 2048-bit group factory
- WHEN
params2048Sha256(defaultHashBridge("SHA256"))is called - THEN the returned
SrpParams.algorithmequals"SHA256"andSrpParams.bitLengthequals2048
Scenario: unsupported algorithm rejected at the hash bridge
- WHEN
defaultHashBridge("SHA512" as never)is called - THEN the call throws
SrpErrorwith code"SRP.HASH_UNSUPPORTED"
Verified by: packages/crypto/srp/src/params/params.test.ts::"params2048Sha256 emits SHA-256 algorithm tag" AND packages/crypto/srp/src/hash/hash-bridge.test.ts::"unsupported algorithm throws SRP.HASH_UNSUPPORTED"
Requirement: Verifier computation — v = g^x mod N
The package SHALL export computeVerifier(I: string, P: string, salt: Uint8Array, params: SrpParams): { x: Uint8Array; v: Uint8Array } per RFC 5054 §2.6, where x = H(salt | H(I | ":" | P)) and v = g^x mod N. The function SHALL never return v whose value is 0 mod N. The function SHALL be deterministic for fixed (I, P, salt, params).
Scenario: matches RFC 5054 Appendix B reference verifier
- WHEN
computeVerifier("alice", "password123", appendixBSalt, params1024Sha1())is called with RFC 5054 Appendix B inputs - THEN the returned
vequals the Appendix B reference verifier byte-exactly
Scenario: deterministic for fixed inputs
- WHEN
computeVerifier(I, P, salt, params)is called twice with identical arguments - THEN both calls return byte-identical
v
Verified by: packages/crypto/srp/src/verifier/verifier.test.ts::"matches RFC 5054 Appendix B verifier" AND packages/crypto/srp/src/verifier/verifier.test.ts::"deterministic for fixed inputs"
Requirement: Client handshake start — A = g^a mod N, a ≥ 256 bits
The package SHALL export clientStartHandshake(rng: Rng, params: SrpParams): { a: Uint8Array; A: Uint8Array }. The private exponent a SHALL be drawn from the injectable rng with at least 256 bits of entropy per RFC 5054 §2.5.4. The public value A SHALL equal g^a mod N. The function SHALL re-draw a if the resulting A mod N == 0 and SHALL surface no observable timing dependency on a.
Scenario: a carries at least 256 bits of entropy
- WHEN
clientStartHandshake(rng, params2048Sha256())is called - THEN
a.byteLength >= 32
Scenario: A is reproducible from a deterministic RNG
- WHEN
clientStartHandshake(seededRng, params2048Sha256())is called twice with identically-seededRnginstances - THEN both calls return byte-identical
A
Verified by: packages/crypto/srp/src/client/client-start.test.ts::"a carries at least 256 bits of entropy" AND packages/crypto/srp/src/client/client-start.test.ts::"A is reproducible from a deterministic RNG"
Requirement: Reject A mod N == 0 and B mod N == 0
The package SHALL throw SrpError with code SRP.PUBLIC_KEY_INVALID whenever a peer's public value reduces to zero modulo N, per RFC 5054 §2.5.4 ("the server MUST abort the authentication attempt"). The check SHALL be enforced on both sides — clientFinishHandshake SHALL reject B mod N == 0, and serverFinishHandshake SHALL reject A mod N == 0.
Scenario: client rejects B ≡ 0 mod N
- WHEN
clientFinishHandshake(I, P, salt, A, B_zero, a, params)is called withB_zerosuch thatB_zero mod N === 0n - THEN the call throws
SrpErrorwith code"SRP.PUBLIC_KEY_INVALID"
Scenario: server rejects A ≡ 0 mod N
- WHEN
serverFinishHandshake(I, salt, A_zero, B, b, v, M1, params)is called withA_zero mod N === 0n - THEN the call throws
SrpErrorwith code"SRP.PUBLIC_KEY_INVALID"
Verified by: packages/crypto/srp/src/client/client-finish.test.ts::"rejects B mod N == 0 with SRP.PUBLIC_KEY_INVALID" AND packages/crypto/srp/src/server/server-finish.test.ts::"rejects A mod N == 0 with SRP.PUBLIC_KEY_INVALID"
Requirement: Client handshake finish — produce K and M1
The package SHALL export clientFinishHandshake(I: string, P: string, salt: Uint8Array, A: Uint8Array, B: Uint8Array, a: Uint8Array, params: SrpParams): { K: Uint8Array; M1: Uint8Array; S: Uint8Array }. The function SHALL compute u = H(PAD(A) | PAD(B)), x = H(salt | H(I | ":" | P)), S = (B - k * g^x)^(a + u * x) mod N, K = H(S), and M1 = H(H(N) XOR H(g) | H(I) | salt | A | B | K) per RFC 5054 §2.6 + RFC 2945 §3. The function SHALL throw SrpError with code SRP.PUBLIC_KEY_INVALID if B mod N == 0 OR if the derived u ≡ 0 mod N.
Scenario: matches RFC 5054 Appendix B reference K
- WHEN
clientFinishHandshake(...)is called with the RFC 5054 Appendix B(I, P, salt, A, B, a)tuple underparams1024Sha1() - THEN the returned
Kequals the Appendix B referenceKbyte-exactly
Scenario: matches RFC 2945 §3 M1 formula
- WHEN
clientFinishHandshake(...)is called with RFC 5054 Appendix B inputs - THEN the returned
M1is byte-exact to the value computed by the reference formulaH(H(N) XOR H(g) | H(I) | salt | A | B | K)with the same hash
Verified by: packages/crypto/srp/src/client/client-finish.test.ts::"matches RFC 5054 Appendix B K" AND packages/crypto/srp/src/client/client-finish.test.ts::"M1 matches RFC 2945 H(H(N) XOR H(g) | H(I) | salt | A | B | K)"
Requirement: Client verifies server proof M2
The package SHALL export clientVerifyServerProof(M1: Uint8Array, M2Server: Uint8Array, K: Uint8Array, A: Uint8Array, params: SrpParams): void. The function SHALL recompute the expected M2 = H(A | M1 | K) per RFC 2945 §3, compare it to M2Server in constant time via @suaveplan/crypto-utils.constantTimeEqual, and throw SrpError with code SRP.MUTUAL_AUTH_FAILED on any mismatch — including a single-bit flip.
Scenario: accepts a correct server proof
- WHEN
clientVerifyServerProof(M1, correctM2, K, A, params)is called withcorrectM2 === H(A | M1 | K) - THEN the call returns
undefinedand does not throw
Scenario: rejects a single-bit-flipped server proof
- WHEN
clientVerifyServerProof(M1, bitFlipped(correctM2), K, A, params)is called - THEN the call throws
SrpErrorwith code"SRP.MUTUAL_AUTH_FAILED"
Verified by: packages/crypto/srp/src/client/client-verify.test.ts::"accepts correct server proof M2" AND packages/crypto/srp/src/client/client-verify.test.ts::"rejects single-bit-flipped M2 with SRP.MUTUAL_AUTH_FAILED"
Requirement: Server handshake start — B = (k * v + g^b) mod N
The package SHALL export serverStartHandshake(v: Uint8Array, rng: Rng, params: SrpParams): { b: Uint8Array; B: Uint8Array }. The private exponent b SHALL carry at least 256 bits of entropy per RFC 5054 §2.5.4. The public value B SHALL equal (k * v + g^b) mod N. If the resulting B mod N == 0, the function SHALL re-draw b and recompute B until the check passes.
Scenario: b carries at least 256 bits of entropy
- WHEN
serverStartHandshake(v, rng, params2048Sha256())is called - THEN
b.byteLength >= 32
Scenario: re-rolls when synthetic RNG produces B ≡ 0 mod N
- WHEN
serverStartHandshake(v, syntheticRng, params)is called with an RNG whose first draw forcesB ≡ 0 mod N - THEN the function discards that draw and returns a non-zero
B
Verified by: packages/crypto/srp/src/server/server-start.test.ts::"b carries at least 256 bits of entropy" AND packages/crypto/srp/src/server/server-start.test.ts::"re-rolls when first draw produces B mod N == 0"
Requirement: Server handshake finish — verify M1, emit M2
The package SHALL export serverFinishHandshake(I: string, salt: Uint8Array, A: Uint8Array, B: Uint8Array, b: Uint8Array, v: Uint8Array, M1Client: Uint8Array, params: SrpParams): { K: Uint8Array; M2: Uint8Array; S: Uint8Array }. The function SHALL compute u = H(PAD(A) | PAD(B)), S = (A * v^u)^b mod N, K = H(S), derive expected M1 = H(H(N) XOR H(g) | H(I) | salt | A | B | K), and constant-time-compare to M1Client via @suaveplan/crypto-utils.constantTimeEqual. On mismatch the function SHALL throw SrpError with code SRP.AUTH_FAILED. On success the function SHALL emit M2 = H(A | M1 | K).
Scenario: accepts correct client proof and emits matching M2
- WHEN
serverFinishHandshake(...)is called with the RFC 5054 Appendix B(I, salt, A, B, b, v, M1)tuple - THEN the returned
M2equalsH(A | M1 | K)byte-exactly
Scenario: rejects a single-bit-flipped client proof
- WHEN
serverFinishHandshake(I, salt, A, B, b, v, bitFlipped(M1), params)is called - THEN the call throws
SrpErrorwith code"SRP.AUTH_FAILED"
Verified by: packages/crypto/srp/src/server/server-finish.test.ts::"accepts correct client proof and emits M2 = H(A | M1 | K)" AND packages/crypto/srp/src/server/server-finish.test.ts::"rejects single-bit-flipped M1 with SRP.AUTH_FAILED"
Requirement: serverComputeM2 split-roundtrip helper
The package SHALL export serverComputeM2(A: Uint8Array, M1: Uint8Array, K: Uint8Array, params: SrpParams): Uint8Array returning H(A | M1 | K) per RFC 2945 §3. The function SHALL emit a byte-identical result to serverFinishHandshake's M2 field for the same (A, M1, K) triple, allowing transports that ack M1 over one HTTP request and emit M2 from a different worker / route.
Scenario: matches inline finish helper output
- WHEN
serverFinishHandshakeis run end-to-end andserverComputeM2(A, M1, K, params)is then called separately - THEN both
M2values are byte-identical
Scenario: deterministic for fixed (A, M1, K)
- WHEN
serverComputeM2(A, M1, K, params)is called twice with identical inputs - THEN both results are byte-identical
Verified by: packages/crypto/srp/src/server/server-m2.test.ts::"matches inline serverFinishHandshake M2 output" AND packages/crypto/srp/src/server/server-m2.test.ts::"deterministic for fixed inputs"
Requirement: RFC 5054 Appendix B test vectors green
The package SHALL include a conformance test that runs the full SRP-6a handshake against the RFC 5054 Appendix B 1024-bit reference vectors (username "alice", password "password123", the Appendix B salt, a and b per Appendix B), and SHALL assert byte-exact equality against the Appendix B reference k, x, v, u, S, K, M1, and M2 values.
Scenario: every Appendix B intermediate value matches
- WHEN the Appendix B test vector inputs are run through
computeVerifier+clientStartHandshake(withainjected) +serverStartHandshake(withbinjected) +clientFinishHandshake+serverFinishHandshake - THEN every emitted intermediate (
k,x,v,u,S,K,M1,M2) equals the Appendix B reference byte-exactly
Scenario: client K equals server K under Appendix B inputs
- WHEN the same Appendix B run completes
- THEN the client's
Kand the server'sKare byte-identical
Verified by: packages/crypto/srp/src/srp.spec-conformance.test.ts::"RFC 5054 Appendix B vectors — every intermediate matches" AND packages/crypto/srp/src/srp.spec-conformance.test.ts::"RFC 5054 Appendix B — client K equals server K"
Requirement: Locally-derived 2048/SHA-256 test vectors green
The package SHALL ship a deterministic test-vector generator at scripts/generate-srp-vectors.ts that derives (I, P, salt, params) → { x, v, A, B, K, M1, M2 } for any (group, hash) pair given a fixed RNG seed. The package SHALL include a committed fixture at src/__fixtures__/vectors-2048-sha256.json produced by that script and SHALL run a conformance test asserting end-to-end byte-exact agreement against that fixture.
Scenario: generator is deterministic
- WHEN
scripts/generate-srp-vectors.tsis run twice with the same seed - THEN both runs produce a byte-identical JSON fixture
Scenario: conformance test agrees with the fixture
- WHEN the full handshake is replayed against the committed
vectors-2048-sha256.json - THEN every emitted intermediate equals the fixture byte-exactly
Verified by: packages/crypto/srp/src/srp.spec-conformance.test.ts::"2048/SHA-256 fixture — generator output is deterministic" AND packages/crypto/srp/src/srp.spec-conformance.test.ts::"2048/SHA-256 fixture — full handshake replays byte-exactly"
Requirement: Handshake symmetry — client K equals server K
The package's client and server roles SHALL produce byte-identical K for any fixed (I, P, salt, A, B, a, b, v, params) tuple where v = g^x mod N and the protocol invariants hold. The conformance test SHALL drive 1000 randomly-generated (I, P, salt) triples through computeVerifier + clientStartHandshake + serverStartHandshake + clientFinishHandshake + serverFinishHandshake and SHALL assert client.K equals server.K on every run.
Scenario: 1000 random triples all agree
- WHEN the property test runs 1000 random
(I, P, salt)triples through the full handshake - THEN every run satisfies
constantTimeEqual(client.K, server.K) === true
Scenario: M1 and M2 cross-verify
- WHEN the property test compares the
M1returned byclientFinishHandshakeagainst theM1reconstructed insideserverFinishHandshake, and theM2returned byserverFinishHandshakeagainst theM2accepted byclientVerifyServerProof - THEN every comparison succeeds in constant time
Verified by: packages/crypto/srp/src/srp.spec-conformance.test.ts::"property — client K equals server K across 1000 random triples" AND packages/crypto/srp/src/srp.spec-conformance.test.ts::"property — M1 and M2 cross-verify across 1000 random triples"
Requirement: Constant-time compare on every M1, M2, and S derivation
The package SHALL use @suaveplan/crypto-utils.constantTimeEqual for every comparison of M1, M2, and any path that compares two S-derived values. Direct ===, Buffer.compare, or naive for-loop comparisons of these values SHALL be absent from src/. The conformance test SHALL audit the source tree for forbidden comparison patterns on the relevant variables.
Scenario: source audit finds no forbidden comparison patterns
- WHEN a regex scan is run over
src/for direct===or!==between twoUint8Arrayvalues insideclient/,server/, orpake/modules - THEN the scan returns no matches
Scenario: bit-flipped M1 takes the same wall-clock time as a correct M1 (smoke check)
- WHEN 100 timing samples are taken over correct-M1 and bit-flipped-M1 calls to
serverFinishHandshake - THEN the sample means differ by less than the runtime's measurable noise floor (smoke check, not a formal timing-attack proof)
Verified by: packages/crypto/srp/src/srp.spec-conformance.test.ts::"source audit — no direct === comparison of M1/M2/S Uint8Arrays" AND packages/crypto/srp/src/srp.spec-conformance.test.ts::"smoke timing — bit-flipped M1 within noise floor"
Requirement: Modular exponentiation via @noble/curves, never BigInt ** BigInt % N
The package SHALL implement modPow(base, exp, mod) over @noble/curves's big-integer modular exponentiation primitive. Direct BigInt ** BigInt % N patterns SHALL be absent from src/. The conformance test SHALL audit the source for the forbidden pattern.
Scenario: source audit finds no BigInt ** BigInt % pattern
- WHEN a regex scan is run over
src/**/*.tsfor\\*\\*\\s*\\w+n?\\s*% - THEN the scan returns no matches outside test files
Scenario: agreement with naive reference on small inputs
- WHEN
modPow(base, exp, mod)is run for 100 random small(base, exp, mod)triples and compared to a naiveBigInt-only reference - THEN every output matches
Verified by: packages/crypto/srp/src/math/modpow.test.ts::"agrees with naive BigInt reference on small inputs" AND packages/crypto/srp/src/groups/groups.spec-conformance.test.ts::"source audit — no BigInt ** BigInt % pattern in src/"
Requirement: Augmented PAKE bridge — PakeProvider interface
The package SHALL export a PakeProvider interface declaring registerCredential(I, P, salt) → PakeRegistration, clientHandshake(I, P, salt, serverPublic) → PakeClientHandshake, and serverHandshake(stored, clientPublic, clientProof) → PakeServerHandshake. The package SHALL ship srpPakeProvider(params): PakeProvider as the SRP-6a implementation. The interface SHALL be expressive enough that a future OPAQUE provider drops in without changing the consumer surface.
Scenario: srpPakeProvider round-trips registration → client → server
- WHEN
srpPakeProvider(params).registerCredential(I, P, salt)produces{ I, salt, v },.clientHandshake(...)produces(A, M1), and.serverHandshake(...)produces(B, M2, K_server) - THEN the client's
K_clientequalsK_serverbyte-exactly
Scenario: PakeProvider interface accepts both SRP-6a and a no-op stub
- WHEN
expectTypeOf(srpPakeProvider(params)).toMatchTypeOf<PakeProvider>()runs AND a hand-written no-opPakeProvideris type-checked - THEN both type-level checks pass
Verified by: packages/crypto/srp/src/pake/pake-provider.test.ts::"srpPakeProvider round-trips register → client → server with K agreement" AND packages/crypto/srp/src/pake/pake-provider.test.ts::"PakeProvider interface accepts SRP-6a and no-op stub"
Requirement: Browser-friendly streaming hash — sync and async backends
The package SHALL export nobleHashBridge(algorithm) (sync, @noble/hashes-backed), subtleHashBridge(algorithm, signal?) (async, crypto.subtle.digest-backed), and defaultHashBridge(algorithm) (platform selector — subtle in browsers, noble elsewhere). All three SHALL conform to HashLike from @suaveplan/types. Both backends SHALL produce byte-identical digests for the same input. The async backend SHALL accept an optional AbortSignal.
Scenario: noble and subtle agree on RFC 5054 Appendix B input
- WHEN
nobleHashBridge("SHA256").digest(input)andawait subtleHashBridge("SHA256").digest(input)are called with the RFC 5054 Appendix BH(I | ":" | P)input - THEN the two outputs are byte-identical
Scenario: pre-aborted AbortSignal short-circuits subtleHashBridge
- WHEN
subtleHashBridge("SHA256", preAbortedSignal).digest(input)is called - THEN the call rejects before any
crypto.subtle.digestinvocation
Verified by: packages/crypto/srp/src/hash/hash-bridge.test.ts::"noble and subtle backends agree on RFC 5054 Appendix B H(I:P) input" AND packages/crypto/srp/src/hash/hash-bridge.test.ts::"subtleHashBridge with pre-aborted signal short-circuits"
Requirement: AbortSignal threaded through async paths
The package SHALL accept an optional AbortSignal on every async surface (currently subtleHashBridge.digest). A pre-aborted signal SHALL cause the call to reject before any work is performed. A signal aborted mid-flight SHALL cause the in-flight crypto.subtle.digest to be cancelled where the runtime supports it. Sync surfaces (nobleHashBridge, every client* and server* function) are CPU-bound and DO NOT take an AbortSignal — this is documented in their respective .md files.
Scenario: pre-aborted signal rejects synchronously-equivalent
- WHEN
subtleHashBridge("SHA256", AbortSignal.abort(new Error("test"))).digest(new Uint8Array([1]))is called - THEN the returned promise rejects with the abort reason without invoking
crypto.subtle.digest
Scenario: signal aborted mid-flight reaches the consumer
- WHEN the abort fires after
digestis called but before it resolves - THEN the returned promise rejects with the abort reason
Verified by: packages/crypto/srp/src/hash/hash-bridge.test.ts::"pre-aborted signal rejects without invoking subtle.digest" AND packages/crypto/srp/src/hash/hash-bridge.test.ts::"signal aborted mid-flight propagates to caller"
Requirement: Error code registry — every code thrown by ≥1 production path
The package SHALL declare exactly the codes SRP.INVALID_PARAMS, SRP.PUBLIC_KEY_INVALID, SRP.AUTH_FAILED, SRP.MUTUAL_AUTH_FAILED, SRP.GROUP_UNSUPPORTED, and SRP.HASH_UNSUPPORTED via defineCodes("SRP", [...]) from @suaveplan/error. Every code SHALL be thrown by at least one production path in src/ (excluding tests). A SrpError extends SuaveplanError<SrpErrorCode> class SHALL be exported.
Scenario: every code is reachable from some production path
- WHEN the audit test enumerates every code in the registry and looks for a matching production-path throw
- THEN every code maps to ≥1 throw site under
src/(excluding*.test.ts)
Scenario: SrpError carries the code on the instance
- WHEN any error is caught and inspected
- THEN the instance is
instanceof SrpErrorandinstanceof SuaveplanError, anderror.codeequals the documentedSRP.*literal
Verified by: packages/crypto/srp/src/errors/errors.test.ts::"every SRP code reachable from a production path" AND packages/crypto/srp/src/errors/errors.test.ts::"SrpError instances carry the SRP.* code literal"
Requirement: Zod boundary schemas — SrpParamsSchema, SrpHandshakeStateSchema
The package SHALL export SrpParamsSchema and SrpHandshakeStateSchema from zod, validating SrpParams and the per-handshake state envelope respectively. SrpParamsSchema SHALL enforce: N is a Uint8Array whose byteLength is a multiple of 4, g is a Uint8Array whose decoded big-int is > 1 and < N, bitLength matches N.byteLength * 8, and algorithm is in {"SHA256", "SHA1"}. Schema-validation failures SHALL surface as SrpError(SRP.INVALID_PARAMS).
Scenario: rejects N whose byteLength is not a multiple of 4
- WHEN
SrpParamsSchema.parse(badParams)is called withN.byteLength === 17 - THEN the call throws
SrpErrorwith code"SRP.INVALID_PARAMS"
Scenario: rejects g >= N
- WHEN
SrpParamsSchema.parse(badParams)is called withgwhose decoded big-int equalsNexactly - THEN the call throws
SrpErrorwith code"SRP.INVALID_PARAMS"
Verified by: packages/crypto/srp/src/schemas/schemas.test.ts::"rejects N whose byteLength is not a multiple of 4" AND packages/crypto/srp/src/schemas/schemas.test.ts::"rejects g >= N"
Requirement: OTel observability — handshake duration, outcome, errors
The package SHALL emit (a) srp.handshake_duration_ms histogram with attributes role ∈ {"client", "server"} and phase ∈ {"start", "finish"}, (b) srp.outcome_total counter with attributes role and outcome ∈ {"ok", "auth_failed", "mutual_auth_failed", "public_key_invalid"}, and (c) srp.errors_total counter keyed by code (every value emitted MUST be one of the registered SRP.* codes). The instruments SHALL be observable via a real in-memory MeterProvider in tests.
Scenario: full handshake records duration and ok outcome on both roles
- WHEN a complete
client → server → clienthandshake runs to success under a real in-memoryMeterProvider - THEN the recorded data points include
srp.handshake_duration_ms{role=client,phase=finish},srp.handshake_duration_ms{role=server,phase=finish},srp.outcome_total{role=client,outcome=ok} = 1, andsrp.outcome_total{role=server,outcome=ok} = 1
Scenario: server-side AUTH_FAILED records outcome and error counter
- WHEN
serverFinishHandshakerejects a bit-flippedM1under a real in-memoryMeterProvider - THEN
srp.outcome_total{role=server,outcome=auth_failed}is incremented by 1 ANDsrp.errors_total{code=SRP.AUTH_FAILED}is incremented by 1
Verified by: packages/crypto/srp/src/observability/metrics.test.ts::"full handshake records ok outcome on both roles" AND packages/crypto/srp/src/observability/metrics.test.ts::"server AUTH_FAILED records outcome and errors_total"
Requirement: Subpath exports
The package SHALL declare subpath exports so consumers tree-shake to the minimum surface they need:
@suaveplan/srp— root barrel@suaveplan/srp/groups— default 2048-bit group@suaveplan/srp/groups/{1024,1536,3072,4096,6144,8192}— optional groups@suaveplan/srp/params—SrpParams+ factory + Zod schema@suaveplan/srp/client— client role@suaveplan/srp/server— server role@suaveplan/srp/verifier—computeVerifier@suaveplan/srp/pake—PakeProvider+srpPakeProvider@suaveplan/srp/hash—nobleHashBridge,subtleHashBridge,defaultHashBridge@suaveplan/srp/errors—SRP_CODES,SrpError,SrpErrorCode
Scenario: every documented subpath resolves
- WHEN every subpath listed above is imported in a consumer test fixture
- THEN every import resolves with no
Module not founderrors
Scenario: optional groups are not pulled into the root bundle
- WHEN a bundler (or static enumeration) inspects
@suaveplan/srproot barrel exports - THEN none of
N_1024,N_1536,N_3072,N_4096,N_6144,N_8192appear
Verified by: packages/crypto/srp/src/index.test.ts::"every documented subpath export resolves" AND packages/crypto/srp/src/groups/groups.spec-conformance.test.ts::"root barrel does not re-export non-default group constants"