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

19 KiB

@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).