# `@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//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`. - 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/` 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).