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

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. (See openspec/AGENTS.md Section 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_2048 is 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_2048 is read from @suaveplan/srp/groups
  • THEN the value is a single-byte Uint8Array([2])

Scenario: bitLength reports 2048

  • WHEN BIT_LENGTH is 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_1024 is read from @suaveplan/srp/groups/1024
  • THEN the value, hex-encoded uppercase, equals the RFC 5054 Appendix A 1024-bit modulus literal AND g_1024 equals 2

Scenario: 8192-bit group is reachable but not bundled at root

  • WHEN @suaveplan/srp/groups/8192 is imported
  • THEN N_8192.byteLength equals 1024 and BIT_LENGTH equals 8192

Scenario: root barrel does not export non-2048 groups

  • WHEN the root @suaveplan/srp barrel is enumerated
  • THEN symbols N_1024, N_1536, N_3072, N_4096, N_6144, N_8192 are 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 is 0

Scenario: rejects oversize input

  • WHEN PAD(x, N_2048) is called with x.byteLength === 257
  • THEN the call throws SrpError with 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 k value 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.algorithm equals "SHA256" and SrpParams.bitLength equals 2048

Scenario: unsupported algorithm rejected at the hash bridge

  • WHEN defaultHashBridge("SHA512" as never) is called
  • THEN the call throws SrpError with 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 v equals 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-seeded Rng instances
  • 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 with B_zero such that B_zero mod N === 0n
  • THEN the call throws SrpError with 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 with A_zero mod N === 0n
  • THEN the call throws SrpError with 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 under params1024Sha1()
  • THEN the returned K equals the Appendix B reference K byte-exactly

Scenario: matches RFC 2945 §3 M1 formula

  • WHEN clientFinishHandshake(...) is called with RFC 5054 Appendix B inputs
  • THEN the returned M1 is byte-exact to the value computed by the reference formula H(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 with correctM2 === H(A | M1 | K)
  • THEN the call returns undefined and 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 SrpError with 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 forces B ≡ 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 M2 equals H(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 SrpError with 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 serverFinishHandshake is run end-to-end and serverComputeM2(A, M1, K, params) is then called separately
  • THEN both M2 values 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 (with a injected) + serverStartHandshake (with b injected) + 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 K and the server's K are 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.ts is 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 M1 returned by clientFinishHandshake against the M1 reconstructed inside serverFinishHandshake, and the M2 returned by serverFinishHandshake against the M2 accepted by clientVerifyServerProof
  • 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 two Uint8Array values inside client/, server/, or pake/ 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/**/*.ts for \\*\\*\\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 naive BigInt-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_client equals K_server byte-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-op PakeProvider is 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) and await subtleHashBridge("SHA256").digest(input) are called with the RFC 5054 Appendix B H(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.digest invocation

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 digest is 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 SrpError and instanceof SuaveplanError, and error.code equals the documented SRP.* 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 with N.byteLength === 17
  • THEN the call throws SrpError with code "SRP.INVALID_PARAMS"

Scenario: rejects g >= N

  • WHEN SrpParamsSchema.parse(badParams) is called with g whose decoded big-int equals N exactly
  • THEN the call throws SrpError with 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 → client handshake runs to success under a real in-memory MeterProvider
  • 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, and srp.outcome_total{role=server,outcome=ok} = 1

Scenario: server-side AUTH_FAILED records outcome and error counter

  • WHEN serverFinishHandshake rejects a bit-flipped M1 under a real in-memory MeterProvider
  • THEN srp.outcome_total{role=server,outcome=auth_failed} is incremented by 1 AND srp.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 found errors

Scenario: optional groups are not pulled into the root bundle

  • WHEN a bundler (or static enumeration) inspects @suaveplan/srp root barrel exports
  • THEN none of N_1024, N_1536, N_3072, N_4096, N_6144, N_8192 appear

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"