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 constantsN_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/1024subpath; 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—SrpParamstype ({ N: Uint8Array; g: Uint8Array; bitLength: number; hash: HashLike; algorithm: "SHA256" | "SHA1" }),SrpParamsSchemaZod boundary,params2048Sha256(hash)constructor, validation rejecting groups whosebyteLength(N)is not a multiple of 4 or whosegexceedsN. - ADDED:
src/math/pad.ts—PAD(x, N)per RFC 5054 §2.5.3 — left-zero-extendsxtobyteLength(N). ThrowsSRP.INVALID_PARAMSifx.byteLength > N.byteLength. - ADDED:
src/math/modpow.ts—modPow(base, exp, mod)thin wrapper over@noble/curvesbig-integer modpow. Constant-time-ish (Montgomery ladder inside noble); neverBigInt ** 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. ThrowsSRP.PUBLIC_KEY_INVALIDifu ≡ 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 }.ais≥ 256random bits drawn from injectablerng: Rng(@suaveplan/types).A = g^a mod N. Caller must sendAto the server; if server signalsA mod N == 0, client rebuilds with fresha. - ADDED:
src/client/client-finish.ts—clientFinishHandshake(I, P, salt, A, B, a, params): { K: Uint8Array; M1: Uint8Array; S: Uint8Array }— fullS = (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). ThrowsSRP.PUBLIC_KEY_INVALIDwhenB mod N == 0per RFC 5054 §2.5.4. - ADDED:
src/client/client-verify.ts—clientVerifyServerProof(M1, M2Server, K, A, params): void— recomputes expectedM2 = H(A | M1 | K), constant-time-compares againstM2Server, throwsSRP.MUTUAL_AUTH_FAILEDon mismatch. - ADDED:
src/server/server-start.ts—serverStartHandshake(v, rng, params): { b: Uint8Array; B: Uint8Array }—b ≥ 256random bits,B = (k * v + g^b) mod N. Re-rolls automatically ifB mod N == 0. - ADDED:
src/server/server-finish.ts—serverFinishHandshake(I, salt, A, B, b, v, M1Client, params): { K: Uint8Array; M2: Uint8Array; S: Uint8Array }— verifiesA mod N != 0(throwsSRP.PUBLIC_KEY_INVALID), computesS = (A * v^u)^b mod N, derives expectedM1, constant-time compares againstM1Client(throwsSRP.AUTH_FAILEDon mismatch), emitsM2 = H(A | M1 | K). - ADDED:
src/server/server-m2.ts—serverComputeM2(A, M1, K): Uint8Array— split-roundtrip helper for transports that ackM1over one HTTP request and emitM2from another worker / route. - ADDED:
src/pake/pake-provider.ts—PakeProviderinterface (registerCredential,clientHandshake,serverHandshake) +srpPakeProvider(params): PakeProviderSRP-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—HashLikere-export from@suaveplan/types,nobleHashBridge(algorithm)sync-only@noble/hashes-backed implementation,subtleHashBridge(algorithm, signal?)asynccrypto.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—srpMetricsfactory: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 (noexport *). - 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—PakeProviderinterface +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 emissiontslib catalog:buildzod catalog:forms—SrpParamsSchema,SrpHandshakeStateSchema
Out of scope
- OPAQUE implementation — only the
PakeProviderseam ships. A real OPAQUE provider lives in a future@suaveplan/opaquepackage; 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-clibinary — application code, not library. - Browser-tier
crypto.subtlepolyfill for Node < 20 — Node ≥ 20 shipsglobalThis.crypto.subtle; older runtimes usenobleHashBridgedirectly. - 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 = 2048packages/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; bannedBigInt ** BigInt %pattern absent from any group constant filepackages/vault/srp/src/params/params.test.ts—params2048Sha256builds,SrpParamsSchemarejects non-multiple-of-4 N, rejectsg >= Npackages/vault/srp/src/math/pad.test.ts—PADleft-zero-extends, throwsSRP.INVALID_PARAMSon overflowpackages/vault/srp/src/math/k.test.ts—kmatches RFC 5054 Appendix B 1024-bit reference valuepackages/vault/srp/src/math/u.test.ts—umatches RFC 5054 Appendix B; throwsSRP.PUBLIC_KEY_INVALIDwhenu ≡ 0packages/vault/srp/src/math/x.test.ts—xmatches RFC 5054 Appendix B reference valuepackages/vault/srp/src/math/modpow.test.ts— agrees with naiveBigIntreference on small inputs (banned in production but allowed in tests)packages/vault/srp/src/verifier/verifier.test.ts— RFC 5054 Appendix Bvmatches; locally-derived 2048/SHA-256 vector matchespackages/vault/srp/src/client/client-start.test.ts—ais ≥ 256 bits;Areproducible from injected RNGpackages/vault/srp/src/client/client-finish.test.ts— RFC 5054 Appendix BKmatches; throwsSRP.PUBLIC_KEY_INVALIDonB ≡ 0 mod Npackages/vault/srp/src/client/client-verify.test.ts— accepts correctM2; throwsSRP.MUTUAL_AUTH_FAILEDon bit-flippedM2; constant-time compare path exercisedpackages/vault/srp/src/server/server-start.test.ts—Breproducible from injected RNG; re-rolls when synthetic RNG returnsbsuch thatB ≡ 0 mod Npackages/vault/srp/src/server/server-finish.test.ts— accepts correctM1; throwsSRP.AUTH_FAILEDon mismatchedM1; throwsSRP.PUBLIC_KEY_INVALIDonA ≡ 0packages/vault/srp/src/server/server-m2.test.ts— split-roundtripM2matches inlineserverFinishHandshakeoutputpackages/vault/srp/src/pake/pake-provider.test.ts—srpPakeProviderround-trips throughregister → clientHandshake → serverHandshake; outcomeKmatches between client and serverpackages/vault/srp/src/hash/hash-bridge.test.ts—nobleHashBridge("SHA256")andsubtleHashBridge("SHA256")produce byte-identical digests on the RFC 5054 Appendix B input; SHA-1 path exercised; AbortSignal pre-aborted onsubtleHashBridgethrowsSRP.HASH_UNSUPPORTED(or wraps theAbortError)packages/vault/srp/src/errors/errors.test.ts— every code in the registry is thrown by at least one test pathpackages/vault/srp/src/observability/metrics.test.ts— every histogram and counter emitted with expected attributes during a full handshakepackages/vault/srp/src/schemas/schemas.test.ts—SrpParamsSchemarejects malformed shapes;SrpHandshakeStateSchemaenforces invariantspackages/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 codepackages/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).