482 lines
30 KiB
Markdown
482 lines
30 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|