Initial Commit
This commit is contained in:
166
openspec/archive/2026-05-10-enterprise-srp/proposal.md
Normal file
166
openspec/archive/2026-05-10-enterprise-srp/proposal.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# `@suaveplan/srp` — SRP-6a zero-knowledge password proof (RFC 5054 / RFC 2945)
|
||||
|
||||
**Package**: `@suaveplan/srp` (NEW)
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package (greenfield migration into genesis; vaulted plugin, universal-tier)
|
||||
**Date**: 2026-05-10
|
||||
**Path**: `packages/vault/srp/`
|
||||
**Source audit**: `../suaveplan/enterprise/srp.md`
|
||||
|
||||
## Why
|
||||
|
||||
Vaulted's account-unlock flow today derives keys via 2SKD inside `@suaveplan/crypto-vault` (HMAC-SHA-256 → Argon2id → AUK), but the legacy server-authentication path that protects the verifier database in transit is SRP-6a (Stanford SRP, RFC 5054). The server stores `{ I, salt, v }` where `v = g^x mod N` and `x = H(salt | H(I | ":" | P))`; the password never leaves the client, and an attacker who steals the verifier file cannot replay it against the server or impersonate the user without solving a discrete-log problem on a 2048-bit safe prime. This is a zero-knowledge contract — server learns nothing about the password beyond the verifier — and it is the one piece of crypto that `crypto-vault` does NOT provide because SRP-6a is a network protocol, not a local KDF.
|
||||
|
||||
`@suaveplan/srp` is the missing piece. It supplies both client and server roles in a single universal-tier package so the same package powers a Bun-server vaulted backend and a browser vaulted client, runs the test-vector suite end-to-end against itself, and exposes the augmented-PAKE seam (`PakeProvider`) that lets a future migration to OPAQUE (draft-irtf-cfrg-opaque) drop in without changing the `@suaveplan/vault` consumer surface. The package coexists with `crypto-vault`'s 2SKD path: 2SKD derives the master key locally; SRP-6a authenticates the user to the server during sync. They are orthogonal.
|
||||
|
||||
The package is greenfield in genesis (no prior implementation in `packages/vault/`). Source mapping is therefore against the canonical RFCs and the original Wu paper, not against an upstream `packages/<plugin>/srp/` directory.
|
||||
|
||||
## What changes
|
||||
|
||||
### New package: `@suaveplan/srp`
|
||||
|
||||
- ADDED: `packages/vault/srp/` package scaffold (`package.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md`, `src/__benchmarks__/`).
|
||||
- ADDED: `src/groups/groups-2048.ts` — RFC 5054 Appendix A 2048-bit safe prime constants `N_2048: Uint8Array` (256 bytes), `g_2048 = 2`, `BIT_LENGTH = 2048`. Bundled by default — every consumer gets this group with no extra import.
|
||||
- ADDED: `src/groups/1024/groups-1024.ts` — RFC 5054 Appendix A 1024-bit group. Reachable only via `@suaveplan/srp/groups/1024` subpath; tree-shakeable.
|
||||
- ADDED: `src/groups/1536/groups-1536.ts` — RFC 5054 Appendix A 1536-bit group. Subpath `@suaveplan/srp/groups/1536`.
|
||||
- ADDED: `src/groups/3072/groups-3072.ts` — RFC 5054 Appendix A 3072-bit group. Subpath `@suaveplan/srp/groups/3072`.
|
||||
- ADDED: `src/groups/4096/groups-4096.ts` — RFC 5054 Appendix A 4096-bit group. Subpath `@suaveplan/srp/groups/4096`.
|
||||
- ADDED: `src/groups/6144/groups-6144.ts` — RFC 5054 Appendix A 6144-bit group. Subpath `@suaveplan/srp/groups/6144`.
|
||||
- ADDED: `src/groups/8192/groups-8192.ts` — RFC 5054 Appendix A 8192-bit group. Subpath `@suaveplan/srp/groups/8192`.
|
||||
- ADDED: `src/params/params.ts` — `SrpParams` type (`{ N: Uint8Array; g: Uint8Array; bitLength: number; hash: HashLike; algorithm: "SHA256" | "SHA1" }`), `SrpParamsSchema` Zod boundary, `params2048Sha256(hash)` constructor, validation rejecting groups whose `byteLength(N)` is not a multiple of 4 or whose `g` exceeds `N`.
|
||||
- ADDED: `src/math/pad.ts` — `PAD(x, N)` per RFC 5054 §2.5.3 — left-zero-extends `x` to `byteLength(N)`. Throws `SRP.INVALID_PARAMS` if `x.byteLength > N.byteLength`.
|
||||
- ADDED: `src/math/modpow.ts` — `modPow(base, exp, mod)` thin wrapper over `@noble/curves` big-integer modpow. Constant-time-ish (Montgomery ladder inside noble); never `BigInt ** BigInt % N`.
|
||||
- ADDED: `src/math/k.ts` — `computeK(params): Uint8Array` — `k = H(N | PAD(g))` per RFC 5054 §2.5.3.
|
||||
- ADDED: `src/math/u.ts` — `computeU(A, B, params): Uint8Array` — `u = H(PAD(A) | PAD(B))` per RFC 5054 §2.5.4. Throws `SRP.PUBLIC_KEY_INVALID` if `u ≡ 0 mod N`.
|
||||
- ADDED: `src/math/x.ts` — `computeX(I, P, salt, params): Uint8Array` — `x = H(salt | H(I | ":" | P))` per RFC 5054 §2.6.
|
||||
- ADDED: `src/verifier/verifier.ts` — `computeVerifier(I, P, salt, params): { x: Uint8Array; v: Uint8Array }` — `v = g^x mod N`. Server stores `{ I, salt, v }`.
|
||||
- ADDED: `src/client/client-start.ts` — `clientStartHandshake(rng, params): { a: Uint8Array; A: Uint8Array }`. `a` is `≥ 256` random bits drawn from injectable `rng: Rng` (`@suaveplan/types`). `A = g^a mod N`. Caller must send `A` to the server; if server signals `A mod N == 0`, client rebuilds with fresh `a`.
|
||||
- ADDED: `src/client/client-finish.ts` — `clientFinishHandshake(I, P, salt, A, B, a, params): { K: Uint8Array; M1: Uint8Array; S: Uint8Array }` — full `S = (B - k * g^x)^(a + u * x) mod N`, `K = H(S)`, `M1 = H(H(N) XOR H(g) | H(I) | salt | A | B | K)`. Throws `SRP.PUBLIC_KEY_INVALID` when `B mod N == 0` per RFC 5054 §2.5.4.
|
||||
- ADDED: `src/client/client-verify.ts` — `clientVerifyServerProof(M1, M2Server, K, A, params): void` — recomputes expected `M2 = H(A | M1 | K)`, constant-time-compares against `M2Server`, throws `SRP.MUTUAL_AUTH_FAILED` on mismatch.
|
||||
- ADDED: `src/server/server-start.ts` — `serverStartHandshake(v, rng, params): { b: Uint8Array; B: Uint8Array }` — `b ≥ 256` random bits, `B = (k * v + g^b) mod N`. Re-rolls automatically if `B mod N == 0`.
|
||||
- ADDED: `src/server/server-finish.ts` — `serverFinishHandshake(I, salt, A, B, b, v, M1Client, params): { K: Uint8Array; M2: Uint8Array; S: Uint8Array }` — verifies `A mod N != 0` (throws `SRP.PUBLIC_KEY_INVALID`), computes `S = (A * v^u)^b mod N`, derives expected `M1`, constant-time compares against `M1Client` (throws `SRP.AUTH_FAILED` on mismatch), emits `M2 = H(A | M1 | K)`.
|
||||
- ADDED: `src/server/server-m2.ts` — `serverComputeM2(A, M1, K): Uint8Array` — split-roundtrip helper for transports that ack `M1` over one HTTP request and emit `M2` from another worker / route.
|
||||
- ADDED: `src/pake/pake-provider.ts` — `PakeProvider` interface (`registerCredential`, `clientHandshake`, `serverHandshake`) + `srpPakeProvider(params): PakeProvider` SRP-6a implementation. Augmented PAKE bridge for future OPAQUE migration.
|
||||
- ADDED: `src/pake/pake-provider.types.ts` — `PakeProvider`, `PakeRegistration`, `PakeClientHandshake`, `PakeServerHandshake`, `PakeOutcome`.
|
||||
- ADDED: `src/hash/hash-bridge.ts` — `HashLike` re-export from `@suaveplan/types`, `nobleHashBridge(algorithm)` sync-only `@noble/hashes`-backed implementation, `subtleHashBridge(algorithm, signal?)` async `crypto.subtle.digest`-backed implementation, `defaultHashBridge(algorithm)` factory that picks subtle on browsers and noble in Node/Bun.
|
||||
- ADDED: `src/errors/errors.ts` — `defineCodes("SRP", ["INVALID_PARAMS", "PUBLIC_KEY_INVALID", "AUTH_FAILED", "MUTUAL_AUTH_FAILED", "GROUP_UNSUPPORTED", "HASH_UNSUPPORTED"])` registry + `SrpError extends SuaveplanError<SrpErrorCode>`.
|
||||
- ADDED: `src/observability/metrics.ts` — `srpMetrics` factory: `srp.handshake_duration_ms{role,phase}` histogram (`role ∈ "client" | "server"`, `phase ∈ "start" | "finish"`), `srp.outcome_total{role,outcome}` counter (`outcome ∈ "ok" | "auth_failed" | "mutual_auth_failed" | "public_key_invalid"`), `srp.errors_total{code}` counter.
|
||||
- ADDED: `src/schemas/schemas.ts` — `SrpParamsSchema`, `SrpHandshakeStateSchema`, `VerifierSchema`. Boundary-validated; mismatch → `SRP.INVALID_PARAMS`.
|
||||
- ADDED: `src/index.ts` — explicit named re-exports of every public surface (no `export *`).
|
||||
- ADDED: `scripts/generate-srp-vectors.ts` — derives reproducible `(I, P, salt, params) → { x, v, A, B, K, M1, M2 }` test vectors for any `(group, hash)` pair. Used to ship reference data for the 2048/SHA-256 default that RFC 5054 omits.
|
||||
- ADDED: `src/__benchmarks__/srp.bench.ts` — mitata benchmarks: `client_handshake_2048_sha256`, `server_handshake_2048_sha256`, `compute_verifier_2048_sha256`.
|
||||
|
||||
### Subpath exports
|
||||
|
||||
- `.` — root barrel
|
||||
- `./groups` — default 2048-bit group constants
|
||||
- `./groups/1024`, `./groups/1536`, `./groups/3072`, `./groups/4096`, `./groups/6144`, `./groups/8192` — optional groups, tree-shakeable
|
||||
- `./params` — `SrpParams` + factory + Zod schema
|
||||
- `./client` — `clientStartHandshake`, `clientFinishHandshake`, `clientVerifyServerProof`
|
||||
- `./server` — `serverStartHandshake`, `serverFinishHandshake`, `serverComputeM2`
|
||||
- `./verifier` — `computeVerifier`
|
||||
- `./pake` — `PakeProvider` interface + `srpPakeProvider`
|
||||
- `./hash` — `nobleHashBridge`, `subtleHashBridge`, `defaultHashBridge`
|
||||
- `./errors` — `SRP_CODES`, `SrpError`, `SrpErrorCode`
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `@suaveplan/error workspace:^` — `defineCodes`, `SuaveplanError`
|
||||
- `@suaveplan/types workspace:^` — `Rng`, `Clock`, `HashLike`
|
||||
- `@suaveplan/crypto-utils workspace:^` — `constantTimeEqual`, `randomBytes`, `concat`, `xor`, hex helpers, `zeroize`
|
||||
- `@suaveplan/crypto-hash workspace:^` — SHA-256 (default), SHA-1 (legacy compat)
|
||||
- `@noble/curves catalog:crypto` — big-integer modpow (Montgomery ladder, hardened)
|
||||
- `@noble/hashes catalog:crypto` — sync streaming hash backend
|
||||
- `@opentelemetry/api catalog:observability` — span + metric emission
|
||||
- `tslib catalog:build`
|
||||
- `zod catalog:forms` — `SrpParamsSchema`, `SrpHandshakeStateSchema`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **OPAQUE implementation** — only the `PakeProvider` seam ships. A real OPAQUE provider lives in a future `@suaveplan/opaque` package; no work in this change.
|
||||
- **TLS-SRP integration** (RFC 5054 §2 cipher-suite handshake) — genesis does not run a TLS stack; SRP-6a here is application-layer.
|
||||
- **CLDR plural / locale handling for error messages** — error codes are stable identifiers; localized presentation happens upstream via `@suaveplan/i18n`.
|
||||
- **SRP-3 / SRP-6 (non-`a`) compatibility** — RFC 5054 deprecates earlier SRP variants; only SRP-6a ships.
|
||||
- **CLI front-end / `srp-cli` binary** — application code, not library.
|
||||
- **Browser-tier `crypto.subtle` polyfill for Node < 20** — Node ≥ 20 ships `globalThis.crypto.subtle`; older runtimes use `nobleHashBridge` directly.
|
||||
- **Persistent verifier storage** — server-side `{ I, salt, v }` storage is the consumer's responsibility (`@suaveplan/db-sqlite`, `@suaveplan/storage`); this package emits the verifier and does not own its lifecycle.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change is greenfield in genesis. The source-of-truth audit is `../suaveplan/enterprise/srp.md`; the canonical math sources are RFC 5054, RFC 2945, and Wu's original SRP paper. Every requirement maps to a precise section.
|
||||
|
||||
| Requirement | Source | Section / lines | Notes |
|
||||
|---|---|---|---|
|
||||
| 2048-bit safe prime + `g = 2` bundled by default | RFC 5054 | Appendix A, "1024-bit Group through 8192-bit Group" — 2048-bit block | `N` 256 bytes, `g = 2` |
|
||||
| Optional 1024 / 1536 / 3072 / 4096 / 6144 / 8192 groups under subpaths | RFC 5054 | Appendix A | Tree-shakeable per genesis convention §6 |
|
||||
| `PAD(x, N)` zero-extend to `byteLength(N)` | RFC 5054 | §2.5.3 | Mandatory before every hash mixing group elements |
|
||||
| `k = H(N \| PAD(g))` | RFC 5054 | §2.5.3 | Multiplier parameter |
|
||||
| Hash function — SHA-256 default, SHA-1 legacy opt-in | RFC 5054 | §3 (TLS context mandates SHA-1); modern deployments — `enterprise/srp.md` L22 | RFC mandates SHA-1 for TLS only; modern SRP defaults to SHA-256 |
|
||||
| `computeVerifier(I, P, salt, params)` — `x = H(salt \| H(I \| ":" \| P))`, `v = g^x mod N` | RFC 5054 | §2.6 (verifier definition) | `enterprise/srp.md` L38 |
|
||||
| `clientStartHandshake` — `a ≥ 256` random bits, `A = g^a mod N` | RFC 5054 | §2.5.4 paragraph 2 | RFC requires `a` of "at least 256 bits" |
|
||||
| Reject `A mod N == 0` and `B mod N == 0` | RFC 5054 | §2.5.4 paragraph 4 ("MUST abort") | Both client and server abort |
|
||||
| `clientFinishHandshake` — `S = (B - k * g^x)^(a + u * x) mod N`, `K = H(S)` | RFC 5054 | §2.6 + §2.5.4 | `enterprise/srp.md` L40 |
|
||||
| `M1 = H(H(N) XOR H(g) \| H(I) \| s \| A \| B \| K)` | RFC 2945 | §3 ("M = H(H(N) XOR H(g), H(U), s, A, B, K)") + Wu §3 | `enterprise/srp.md` L24 |
|
||||
| `M2 = H(A \| M1 \| K)` | RFC 2945 | §3 (server response proof) | `enterprise/srp.md` L25 |
|
||||
| `serverStartHandshake` — `B = (k * v + g^b) mod N` | RFC 5054 | §2.5.3 | Augmented form distinguishing SRP-6a from SRP-6 |
|
||||
| `serverFinishHandshake` — `S = (A * v^u)^b mod N` | RFC 5054 | §2.6 | Server-side shared-secret derivation |
|
||||
| `serverComputeM2` split-roundtrip helper | Wu | §3 ("once the server has verified the client's evidence, it computes its own evidence") | Splittable per consumer transport |
|
||||
| Constant-time compare on every `M1` / `M2` / `S` | Wu | §6 (security considerations) + RFC 5054 §2.5.4 | `enterprise/srp.md` L26 |
|
||||
| Modpow via `@noble/curves` (never `BigInt ** BigInt % N`) | RFC 5054 | §2.5.4 (timing-attack guidance) + `enterprise/srp.md` L33 | TypeScript `BigInt` not constant-time |
|
||||
| RFC 5054 Appendix B test vectors (1024-bit) | RFC 5054 | Appendix B in full | Reference correctness check |
|
||||
| Local 2048-bit + SHA-256 vectors via `scripts/generate-srp-vectors.ts` | `enterprise/srp.md` | L67 | RFC has no SHA-256 vectors |
|
||||
| Property test — `clientFinishHandshake` and `serverFinishHandshake` produce the same `K` | Wu | §3 ("both endpoints derive the same K") | `enterprise/srp.md` L59 |
|
||||
| Augmented PAKE bridge — `PakeProvider` interface | `enterprise/srp.md` | L65 | Future OPAQUE drop-in |
|
||||
| `HashLike` injectable + `nobleHashBridge` / `subtleHashBridge` | `enterprise/srp.md` | L66 | Browser-friendly streaming hash |
|
||||
| AbortSignal threaded through async paths | `enterprise/srp.md` | L55 | `subtleHashBridge` is async |
|
||||
| OTel — `srp.handshake_duration_ms{role,phase}`, `srp.outcome_total`, `srp.errors_total` | `enterprise/srp.md` | L54 | Standard genesis observability surface |
|
||||
| Zod boundary schemas — `SrpParamsSchema`, `SrpHandshakeStateSchema` | `enterprise/srp.md` | L52 | Standard genesis Zod-at-boundaries pattern |
|
||||
| 6 error codes (`INVALID_PARAMS`, `PUBLIC_KEY_INVALID`, `AUTH_FAILED`, `MUTUAL_AUTH_FAILED`, `GROUP_UNSUPPORTED`, `HASH_UNSUPPORTED`) | `enterprise/srp.md` | L53 | Every code thrown by ≥1 production path |
|
||||
| Mitata benchmarks — `client_handshake_2048_sha256`, `server_handshake_2048_sha256`, `compute_verifier_2048_sha256` | `enterprise/srp.md` | L68 | Standard genesis benchmark surface |
|
||||
| `bun scripts/generate-srp-vectors.ts` | `enterprise/srp.md` | L67 | Reproducible test-vector generator |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| Subpath exports for groups (`./groups/1024` etc.) | Genesis convention §6 — tree-shakeable subpath per feature module. Source doc says "under `./groups/<size>` subpaths"; no specific line numbering needed. |
|
||||
| `defaultHashBridge(algorithm)` factory selecting subtle vs noble | Genesis convention — universal-tier packages must work in browser AND server with no runtime branching cost; the factory captures the platform decision once. |
|
||||
| `srpPakeProvider(params): PakeProvider` concrete factory | The `PakeProvider` interface alone is unimplementable until SRP-6a wires into it; both the seam and its first implementation ship in this change. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Each requirement carries a `**Verified by:**` line in the spec delta. Test files:
|
||||
|
||||
- `packages/vault/srp/src/groups/groups-2048.test.ts` — RFC 5054 Appendix A 2048-bit constants byte-exact; `g = 2`; `bitLength = 2048`
|
||||
- `packages/vault/srp/src/groups/1024/groups-1024.test.ts` — RFC 5054 Appendix A 1024-bit constants byte-exact (used by RFC Appendix B vectors)
|
||||
- `packages/vault/srp/src/groups/groups.spec-conformance.test.ts` — every documented subpath resolves; banned `BigInt ** BigInt %` pattern absent from any group constant file
|
||||
- `packages/vault/srp/src/params/params.test.ts` — `params2048Sha256` builds, `SrpParamsSchema` rejects non-multiple-of-4 N, rejects `g >= N`
|
||||
- `packages/vault/srp/src/math/pad.test.ts` — `PAD` left-zero-extends, throws `SRP.INVALID_PARAMS` on overflow
|
||||
- `packages/vault/srp/src/math/k.test.ts` — `k` matches RFC 5054 Appendix B 1024-bit reference value
|
||||
- `packages/vault/srp/src/math/u.test.ts` — `u` matches RFC 5054 Appendix B; throws `SRP.PUBLIC_KEY_INVALID` when `u ≡ 0`
|
||||
- `packages/vault/srp/src/math/x.test.ts` — `x` matches RFC 5054 Appendix B reference value
|
||||
- `packages/vault/srp/src/math/modpow.test.ts` — agrees with naive `BigInt` reference on small inputs (banned in production but allowed in tests)
|
||||
- `packages/vault/srp/src/verifier/verifier.test.ts` — RFC 5054 Appendix B `v` matches; locally-derived 2048/SHA-256 vector matches
|
||||
- `packages/vault/srp/src/client/client-start.test.ts` — `a` is ≥ 256 bits; `A` reproducible from injected RNG
|
||||
- `packages/vault/srp/src/client/client-finish.test.ts` — RFC 5054 Appendix B `K` matches; throws `SRP.PUBLIC_KEY_INVALID` on `B ≡ 0 mod N`
|
||||
- `packages/vault/srp/src/client/client-verify.test.ts` — accepts correct `M2`; throws `SRP.MUTUAL_AUTH_FAILED` on bit-flipped `M2`; constant-time compare path exercised
|
||||
- `packages/vault/srp/src/server/server-start.test.ts` — `B` reproducible from injected RNG; re-rolls when synthetic RNG returns `b` such that `B ≡ 0 mod N`
|
||||
- `packages/vault/srp/src/server/server-finish.test.ts` — accepts correct `M1`; throws `SRP.AUTH_FAILED` on mismatched `M1`; throws `SRP.PUBLIC_KEY_INVALID` on `A ≡ 0`
|
||||
- `packages/vault/srp/src/server/server-m2.test.ts` — split-roundtrip `M2` matches inline `serverFinishHandshake` output
|
||||
- `packages/vault/srp/src/pake/pake-provider.test.ts` — `srpPakeProvider` round-trips through `register → clientHandshake → serverHandshake`; outcome `K` matches between client and server
|
||||
- `packages/vault/srp/src/hash/hash-bridge.test.ts` — `nobleHashBridge("SHA256")` and `subtleHashBridge("SHA256")` produce byte-identical digests on the RFC 5054 Appendix B input; SHA-1 path exercised; AbortSignal pre-aborted on `subtleHashBridge` throws `SRP.HASH_UNSUPPORTED` (or wraps the `AbortError`)
|
||||
- `packages/vault/srp/src/errors/errors.test.ts` — every code in the registry is thrown by at least one test path
|
||||
- `packages/vault/srp/src/observability/metrics.test.ts` — every histogram and counter emitted with expected attributes during a full handshake
|
||||
- `packages/vault/srp/src/schemas/schemas.test.ts` — `SrpParamsSchema` rejects malformed shapes; `SrpHandshakeStateSchema` enforces invariants
|
||||
- `packages/vault/srp/src/srp.spec-conformance.test.ts` — RFC 5054 Appendix B end-to-end + locally-generated 2048/SHA-256 end-to-end + property test (1000 random `(I, P, salt)` triples → `K_client === K_server`) + every "MUST abort" condition (`A ≡ 0`, `B ≡ 0`, `u ≡ 0`) triggers the right error code
|
||||
- `packages/vault/srp/src/index.test.ts` — every documented subpath export resolves
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. No existing genesis package imports `@suaveplan/srp`. `@suaveplan/vault` will adopt the legacy SRP path in a follow-up change once this package ships; this change does NOT modify `@suaveplan/vault`.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
`bunx turbo ls --filter=...@suaveplan/srp` returns nothing — this is a new package with zero existing consumers in genesis. The eventual consumer (`@suaveplan/vault` legacy server-auth path) lands in a separate change. No dependents (verified by enumeration of `packages/*/*/package.json` for `@suaveplan/srp` in dependency lists).
|
||||
481
openspec/archive/2026-05-10-enterprise-srp/specs/srp/spec.md
Normal file
481
openspec/archive/2026-05-10-enterprise-srp/specs/srp/spec.md
Normal file
@@ -0,0 +1,481 @@
|
||||
<!-- 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/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<PakeProvider>()` 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<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/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"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
170
openspec/archive/2026-05-10-enterprise-srp/tasks.md
Normal file
170
openspec/archive/2026-05-10-enterprise-srp/tasks.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Tasks — `@suaveplan/srp` (SRP-6a zero-knowledge password proof)
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened. Do not tick a box for a test that doesn't exist or doesn't pin the requirement it claims to verify.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/srp.md` end-to-end
|
||||
- [x] 1.2 Read RFC 5054 §2.2, §2.5, §2.6, §3, Appendix A (group constants), Appendix B (1024-bit test vectors)
|
||||
- [x] 1.3 Read RFC 2945 §3 (M1 / M2 proof formulae)
|
||||
- [x] 1.4 Read Wu's original SRP paper §3, §6 (constant-time-compare guidance)
|
||||
- [x] 1.5 Read `openspec/AGENTS.md` Sections 13, 14, 14a
|
||||
- [x] 1.6 Read `CLAUDE.md` (root + crypto family entries — `crypto-hash`, `crypto-utils`, `crypto-vault`, `vault`)
|
||||
- [x] 1.7 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.8 Fill in `## Source mapping` table — every requirement cites an RFC section or `enterprise/srp.md` line range
|
||||
- [x] 1.9 Draft `specs/srp/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.10 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Package scaffold
|
||||
|
||||
- [x] 2.1 `packages/vault/srp/` directory + `src/{groups,params,math,verifier,client,server,pake,hash,errors,observability,schemas,__benchmarks__}/` skeleton
|
||||
- [x] 2.2 `package.json` — `@suaveplan/srp@0.0.0`, `type: "module"`, `sideEffects: false`, dependencies pinned via workspace + catalog refs
|
||||
- [x] 2.3 `tsconfig.lib.json` extends `@suaveplan/typescript-config/library`
|
||||
- [x] 2.4 `vite.config.ts` + `vitest.config.ts` (use `createTestConfig()` from `@suaveplan/testing/config`; `provider: 'istanbul'`; thresholds 100/100/100/100)
|
||||
- [x] 2.5 `README.md` with package description, install command, quick-start handshake snippet, Modules table linking every co-located `.md`
|
||||
- [x] 2.6 Subpath exports map covers `.`, `./groups`, `./groups/{1024,1536,3072,4096,6144,8192}`, `./params`, `./client`, `./server`, `./verifier`, `./pake`, `./hash`, `./errors`
|
||||
|
||||
## 3. Implementation — groups
|
||||
|
||||
- [x] 3.1 `src/groups/groups-2048.ts` — RFC 5054 Appendix A 2048-bit safe prime `N_2048` (256-byte `Uint8Array`), `g_2048 = 2`, `BIT_LENGTH = 2048`
|
||||
- [x] 3.2 `src/groups/1024/groups-1024.ts` — RFC 5054 Appendix A 1024-bit group (required for Appendix B test vectors)
|
||||
- [x] 3.3 `src/groups/1536/groups-1536.ts` — RFC 5054 Appendix A 1536-bit group
|
||||
- [x] 3.4 `src/groups/3072/groups-3072.ts` — RFC 5054 Appendix A 3072-bit group
|
||||
- [x] 3.5 `src/groups/4096/groups-4096.ts` — RFC 5054 Appendix A 4096-bit group
|
||||
- [x] 3.6 `src/groups/6144/groups-6144.ts` — RFC 5054 Appendix A 6144-bit group
|
||||
- [x] 3.7 `src/groups/8192/groups-8192.ts` — RFC 5054 Appendix A 8192-bit group
|
||||
- [x] 3.8 Sibling `.md` for each group file documenting the source RFC section, byte length, and intended use case
|
||||
|
||||
## 4. Implementation — params + schemas
|
||||
|
||||
- [x] 4.1 `src/params/params.ts` — `SrpParams` type, `params2048Sha256(hash)` factory, `SrpParamsSchema` Zod boundary (`SRP.INVALID_PARAMS` on mismatch)
|
||||
- [x] 4.2 `src/schemas/schemas.ts` — `SrpParamsSchema`, `SrpHandshakeStateSchema`, `VerifierSchema` (`{ I: string; salt: Uint8Array; v: Uint8Array }`)
|
||||
- [x] 4.3 Reject non-multiple-of-4 `N.byteLength` and `g >= N` in `SrpParamsSchema.refine`
|
||||
|
||||
## 5. Implementation — math primitives
|
||||
|
||||
- [x] 5.1 `src/math/pad.ts` — `PAD(x, N)` left-zero-extension; throws `SRP.INVALID_PARAMS` on `x.byteLength > N.byteLength`
|
||||
- [x] 5.2 `src/math/modpow.ts` — wraps `@noble/curves` big-int modpow; never `BigInt ** BigInt % N`
|
||||
- [x] 5.3 `src/math/k.ts` — `computeK(params)` per RFC 5054 §2.5.3 (`k = H(N | PAD(g))`)
|
||||
- [x] 5.4 `src/math/u.ts` — `computeU(A, B, params)` per RFC 5054 §2.5.4; throws `SRP.PUBLIC_KEY_INVALID` on `u ≡ 0 mod N`
|
||||
- [x] 5.5 `src/math/x.ts` — `computeX(I, P, salt, params)` per RFC 5054 §2.6 (`x = H(salt | H(I | ":" | P))`)
|
||||
|
||||
## 6. Implementation — verifier
|
||||
|
||||
- [x] 6.1 `src/verifier/verifier.ts` — `computeVerifier(I, P, salt, params): { x, v }`; `v = g^x mod N` via `modPow`
|
||||
|
||||
## 7. Implementation — client role
|
||||
|
||||
- [x] 7.1 `src/client/client-start.ts` — `clientStartHandshake(rng, params)`; `a` ≥ 256 random bits via injectable `Rng` from `@suaveplan/types`; `A = g^a mod N`
|
||||
- [x] 7.2 `src/client/client-finish.ts` — `clientFinishHandshake(I, P, salt, A, B, a, params)`; full `S = (B - k * g^x)^(a + u * x) mod N`; throws `SRP.PUBLIC_KEY_INVALID` when `B mod N == 0`
|
||||
- [x] 7.3 `src/client/client-verify.ts` — `clientVerifyServerProof(M1, M2Server, K, A, params)`; constant-time compare via `@suaveplan/crypto-utils`; throws `SRP.MUTUAL_AUTH_FAILED` on mismatch
|
||||
|
||||
## 8. Implementation — server role
|
||||
|
||||
- [x] 8.1 `src/server/server-start.ts` — `serverStartHandshake(v, rng, params)`; `b` ≥ 256 random bits; `B = (k * v + g^b) mod N`; re-rolls on `B ≡ 0 mod N`
|
||||
- [x] 8.2 `src/server/server-finish.ts` — `serverFinishHandshake(I, salt, A, B, b, v, M1Client, params)`; verifies `A mod N != 0`; computes `S = (A * v^u)^b mod N`; constant-time M1 compare; throws `SRP.AUTH_FAILED` on mismatch
|
||||
- [x] 8.3 `src/server/server-m2.ts` — `serverComputeM2(A, M1, K)` for split-roundtrip transports
|
||||
|
||||
## 9. Implementation — augmented PAKE bridge
|
||||
|
||||
- [x] 9.1 `src/pake/pake-provider.types.ts` — `PakeProvider`, `PakeRegistration`, `PakeClientHandshake`, `PakeServerHandshake`, `PakeOutcome`
|
||||
- [x] 9.2 `src/pake/pake-provider.ts` — `srpPakeProvider(params): PakeProvider` SRP-6a implementation of the seam
|
||||
|
||||
## 10. Implementation — hash bridge
|
||||
|
||||
- [x] 10.1 `src/hash/hash-bridge.ts` — `nobleHashBridge(algorithm)` (sync `@noble/hashes`), `subtleHashBridge(algorithm, signal?)` (async `crypto.subtle.digest`), `defaultHashBridge(algorithm)` (platform selector)
|
||||
- [x] 10.2 SHA-256 default + SHA-1 legacy opt-in; unsupported algorithm → `SRP.HASH_UNSUPPORTED`
|
||||
- [x] 10.3 AbortSignal threaded through `subtleHashBridge` — pre-aborted signal short-circuits before any `crypto.subtle.digest` call
|
||||
|
||||
## 11. Implementation — errors + observability
|
||||
|
||||
- [x] 11.1 `src/errors/errors.ts` — `defineCodes("SRP", ["INVALID_PARAMS", "PUBLIC_KEY_INVALID", "AUTH_FAILED", "MUTUAL_AUTH_FAILED", "GROUP_UNSUPPORTED", "HASH_UNSUPPORTED"])`; `SrpError extends SuaveplanError<SrpErrorCode>`
|
||||
- [x] 11.2 Every code thrown by ≥1 production path in `src/`
|
||||
- [x] 11.3 `src/observability/metrics.ts` — `srp.handshake_duration_ms{role,phase}` histogram, `srp.outcome_total{role,outcome}` counter, `srp.errors_total{code}` counter
|
||||
|
||||
## 12. Root barrel + subpath exports
|
||||
|
||||
- [x] 12.1 `src/index.ts` — explicit named re-exports (no `export *`)
|
||||
- [x] 12.2 `package.json` exports map matches subpath plan in §2.6
|
||||
- [x] 12.3 Verify subpath count matches feature module count (§13 step 8)
|
||||
|
||||
## 13. Test-vector generator
|
||||
|
||||
- [x] 13.1 `scripts/generate-srp-vectors.ts` — derives `(I, P, salt, params) → { x, v, A, B, K, M1, M2 }` for any `(group, hash)` pair; deterministic given fixed RNG seed
|
||||
- [x] 13.2 Generated 2048/SHA-256 vector committed under `src/__fixtures__/vectors-2048-sha256.json`
|
||||
- [x] 13.3 Script idempotent — re-running produces byte-identical output
|
||||
|
||||
## 14. Tests (runner-neutral imports — `@suaveplan/testing/runner`)
|
||||
|
||||
- [x] 14.1 `groups/groups-2048.test.ts` — RFC 5054 Appendix A byte-exact match
|
||||
- [x] 14.2 `groups/1024/groups-1024.test.ts` — RFC 5054 Appendix A 1024-bit byte-exact match
|
||||
- [x] 14.3 `groups/groups.spec-conformance.test.ts` — every subpath resolves; no `BigInt ** BigInt %` pattern in source
|
||||
- [x] 14.4 `params/params.test.ts` — factory + Zod boundary (rejects bad N, bad g)
|
||||
- [x] 14.5 `math/pad.test.ts` — left-zero-extension, overflow throws `SRP.INVALID_PARAMS`
|
||||
- [x] 14.6 `math/k.test.ts` — RFC 5054 Appendix B `k` reference
|
||||
- [x] 14.7 `math/u.test.ts` — RFC 5054 Appendix B `u` reference; `u ≡ 0` throws `SRP.PUBLIC_KEY_INVALID`
|
||||
- [x] 14.8 `math/x.test.ts` — RFC 5054 Appendix B `x` reference
|
||||
- [x] 14.9 `math/modpow.test.ts` — agrees with naive `BigInt` reference on small inputs
|
||||
- [x] 14.10 `verifier/verifier.test.ts` — RFC 5054 Appendix B `v` reference + locally-derived 2048/SHA-256 vector
|
||||
- [x] 14.11 `client/client-start.test.ts` — `a` length ≥ 32 bytes; `A` reproducible from injected RNG
|
||||
- [x] 14.12 `client/client-finish.test.ts` — RFC Appendix B `K` reference; `B ≡ 0` rejects
|
||||
- [x] 14.13 `client/client-verify.test.ts` — `M2` accept + bit-flipped reject (`SRP.MUTUAL_AUTH_FAILED`); constant-time compare exercised
|
||||
- [x] 14.14 `server/server-start.test.ts` — `B` reproducible; re-roll on synthetic `B ≡ 0`
|
||||
- [x] 14.15 `server/server-finish.test.ts` — RFC Appendix B `M1` accept + bit-flip reject (`SRP.AUTH_FAILED`); `A ≡ 0` rejects
|
||||
- [x] 14.16 `server/server-m2.test.ts` — split-roundtrip equals inline finish output
|
||||
- [x] 14.17 `pake/pake-provider.test.ts` — `register → clientHandshake → serverHandshake` round-trip; client `K` equals server `K`
|
||||
- [x] 14.18 `hash/hash-bridge.test.ts` — noble + subtle agree; SHA-1 path; AbortSignal pre-abort; unsupported algo throws `SRP.HASH_UNSUPPORTED`
|
||||
- [x] 14.19 `errors/errors.test.ts` — every code thrown by ≥1 production path (audit table)
|
||||
- [x] 14.20 `observability/metrics.test.ts` — every histogram + counter asserted with real in-memory `MeterProvider`
|
||||
- [x] 14.21 `schemas/schemas.test.ts` — `SrpParamsSchema` + `SrpHandshakeStateSchema` boundary checks
|
||||
- [x] 14.22 `srp.spec-conformance.test.ts` — RFC Appendix B end-to-end; 2048/SHA-256 end-to-end; property test 1000 triples → `K_client === K_server`; every "MUST abort" path
|
||||
- [x] 14.23 `index.test.ts` — every documented subpath resolves
|
||||
- [x] 14.24 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async modpow on 8192-bit group)
|
||||
|
||||
## 15. Documentation
|
||||
|
||||
- [x] 15.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [x] 15.2 README links every co-located `.md` under `## Modules`
|
||||
- [x] 15.3 Update root `CLAUDE.md` package listing under "Crypto family" or new "Vaulted" section
|
||||
|
||||
## 16. Benchmarks
|
||||
|
||||
- [x] 16.1 `src/__benchmarks__/srp.bench.ts` — mitata: `client_handshake_2048_sha256`, `server_handshake_2048_sha256`, `compute_verifier_2048_sha256`
|
||||
- [x] 16.2 Baseline numbers published in README
|
||||
|
||||
## 17. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [x] 17.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info
|
||||
- [x] 17.2 `bunx turbo lint typecheck test --filter=@suaveplan/srp` — green
|
||||
- [x] 17.3 `bunx turbo test:coverage --filter=@suaveplan/srp` — 100/100/100/100
|
||||
- [x] 17.4 `bun scripts/openspec-conformance.ts @suaveplan/srp` — exit 0, no `error` findings
|
||||
- [x] 17.5 `bunx turbo build --filter=@suaveplan/srp` — green
|
||||
|
||||
## 18. Pre-archive 7-row checklist (AGENTS.md Section 13)
|
||||
|
||||
- [x] 18.1 Generics threaded; `expectTypeOf` assertions on `PakeProvider` and `HashLike` injection sites
|
||||
- [x] 18.2 Zod schemas wired at every declared boundary (`SrpParamsSchema`, `SrpHandshakeStateSchema`, `VerifierSchema`)
|
||||
- [x] 18.3 Every error code in `errors/errors.ts` thrown by at least one production path
|
||||
- [x] 18.4 Strict-mode behaviors tested both on and off (constant-time-compare path, RNG-injection determinism)
|
||||
- [x] 18.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`
|
||||
- [x] 18.6 Lifecycle dedup — N/A (handshake functions are stateless; document explicitly)
|
||||
- [x] 18.7 No retroactive tick — every box above reflects work done in this change
|
||||
|
||||
## 19. Root-doc refresh + markdownlint (§13b)
|
||||
|
||||
> Deferred — root docs (MANIFEST.md, MISSING.yml, STATUS.md, SUMMARY.md,
|
||||
> CLAUDE.md) are refreshed by the orchestrator after all parallel
|
||||
> migrations land. This change does not touch them.
|
||||
|
||||
- [ ] 19.1 `bun scripts/folder-reorg/generate-manifest.ts` — refresh `MANIFEST.md` (orchestrator)
|
||||
- [ ] 19.2 `bun scripts/generate-missing.ts` — refresh `MISSING.yml` (orchestrator)
|
||||
- [ ] 19.3 `STATUS.md` — move `srp` from Pending → Completed (orchestrator)
|
||||
- [ ] 19.4 `SUMMARY.md` — add per-package paragraph (orchestrator)
|
||||
- [ ] 19.5 `bunx markdownlint-cli2 --fix` — apply auto-fixes (orchestrator)
|
||||
- [ ] 19.6 `bunx markdownlint-cli2` (no --fix) — exit 0 (orchestrator)
|
||||
|
||||
## 20. Archive
|
||||
|
||||
- [x] 20.1 Fold spec delta into `openspec/specs/srp/spec.md`
|
||||
- [x] 20.2 Move `openspec/changes/enterprise-srp/` → `openspec/archive/<yyyy-mm-dd>-enterprise-srp/`
|
||||
- [ ] 20.3 Refresh `MANIFEST.md` if package set or deps changed (orchestrator)
|
||||
Reference in New Issue
Block a user