# 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/vault/srp/src/groups/groups-2048.test.ts::"N_2048 byte-exact to RFC 5054 Appendix A"` AND `packages/vault/srp/src/groups/groups-2048.test.ts::"g_2048 equals 2"` AND `packages/vault/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/vault/srp/src/groups/1024/groups-1024.test.ts::"N_1024 byte-exact to RFC 5054 Appendix A"` AND `packages/vault/srp/src/groups/groups.spec-conformance.test.ts::"8192-bit subpath emits 1024-byte modulus"` AND `packages/vault/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/vault/srp/src/math/pad.test.ts::"left-zero-extends a short byte string to byteLength(N)"` AND `packages/vault/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/vault/srp/src/math/k.test.ts::"matches RFC 5054 Appendix B k for 1024-bit group"` AND `packages/vault/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/vault/srp/src/params/params.test.ts::"params2048Sha256 emits SHA-256 algorithm tag"` AND `packages/vault/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/vault/srp/src/verifier/verifier.test.ts::"matches RFC 5054 Appendix B verifier"` AND `packages/vault/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/vault/srp/src/client/client-start.test.ts::"a carries at least 256 bits of entropy"` AND `packages/vault/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/vault/srp/src/client/client-finish.test.ts::"rejects B mod N == 0 with SRP.PUBLIC_KEY_INVALID"` AND `packages/vault/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/vault/srp/src/client/client-finish.test.ts::"matches RFC 5054 Appendix B K"` AND `packages/vault/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/vault/srp/src/client/client-verify.test.ts::"accepts correct server proof M2"` AND `packages/vault/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/vault/srp/src/server/server-start.test.ts::"b carries at least 256 bits of entropy"` AND `packages/vault/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/vault/srp/src/server/server-finish.test.ts::"accepts correct client proof and emits M2 = H(A | M1 | K)"` AND `packages/vault/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/vault/srp/src/server/server-m2.test.ts::"matches inline serverFinishHandshake M2 output"` AND `packages/vault/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/vault/srp/src/srp.spec-conformance.test.ts::"RFC 5054 Appendix B vectors — every intermediate matches"` AND `packages/vault/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/vault/srp/src/srp.spec-conformance.test.ts::"2048/SHA-256 fixture — generator output is deterministic"` AND `packages/vault/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/vault/srp/src/srp.spec-conformance.test.ts::"property — client K equals server K across 1000 random triples"` AND `packages/vault/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/vault/srp/src/srp.spec-conformance.test.ts::"source audit — no direct === comparison of M1/M2/S Uint8Arrays"` AND `packages/vault/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/vault/srp/src/math/modpow.test.ts::"agrees with naive BigInt reference on small inputs"` AND `packages/vault/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()` runs AND a hand-written no-op `PakeProvider` is type-checked - **THEN** both type-level checks pass **Verified by:** `packages/vault/srp/src/pake/pake-provider.test.ts::"srpPakeProvider round-trips register → client → server with K agreement"` AND `packages/vault/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/vault/srp/src/hash/hash-bridge.test.ts::"noble and subtle backends agree on RFC 5054 Appendix B H(I:P) input"` AND `packages/vault/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/vault/srp/src/hash/hash-bridge.test.ts::"pre-aborted signal rejects without invoking subtle.digest"` AND `packages/vault/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` 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/vault/srp/src/errors/errors.test.ts::"every SRP code reachable from a production path"` AND `packages/vault/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/vault/srp/src/schemas/schemas.test.ts::"rejects N whose byteLength is not a multiple of 4"` AND `packages/vault/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/vault/srp/src/observability/metrics.test.ts::"full handshake records ok outcome on both roles"` AND `packages/vault/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/vault/srp/src/index.test.ts::"every documented subpath export resolves"` AND `packages/vault/srp/src/groups/groups.spec-conformance.test.ts::"root barrel does not re-export non-default group constants"`