/** * Spec-conformance pin: RFC 5054 Appendix B end-to-end + 2048/SHA-256 * fixture + property test (1000 random triples → K_client === K_server). */ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { constantTimeEqual } from "@suaveplan/crypto-utils"; import { describe, expect, it } from "@suaveplan/testing/runner"; import type { Rng } from "@suaveplan/types"; import { A_PRIV_HEX, A_PUB_HEX, B_PRIV_HEX, B_PUB_HEX, fromHex, I, K_HEX, P, S_HEX, SALT_HEX, U_HEX, V_HEX, X_HEX, } from "./__fixtures__/rfc5054-appendix-b.js"; import { clientFinishHandshake } from "./client/client-finish.js"; import { clientStartHandshake } from "./client/client-start.js"; import { clientVerifyServerProof } from "./client/client-verify.js"; import { g_1024, N_1024 } from "./groups/1024/groups-1024.js"; import { nobleHashBridge } from "./hash/hash-bridge.js"; import { computeK } from "./math/k.js"; import { bigIntToBytes, bytesToBigInt, modPow } from "./math/modpow.js"; import { computeU } from "./math/u.js"; import { computeX } from "./math/x.js"; import type { SrpParams } from "./params/params.js"; import { params2048Sha256 } from "./params/params.js"; import { serverFinishHandshake } from "./server/server-finish.js"; import { serverStartHandshake } from "./server/server-start.js"; import { computeVerifier } from "./verifier/verifier.js"; function params1024Sha1(): SrpParams { return { N: N_1024, g: g_1024, bitLength: 1024, hash: nobleHashBridge("SHA1"), algorithm: "SHA1", }; } function deterministicRngFromBytes(bytes: Uint8Array): Rng { let i = 0; return { nextUint32: () => { const v = ((bytes[i % bytes.length] ?? 0) << 24) | ((bytes[(i + 1) % bytes.length] ?? 0) << 16) | ((bytes[(i + 2) % bytes.length] ?? 0) << 8) | (bytes[(i + 3) % bytes.length] ?? 0); i += 4; return v >>> 0; }, nextFloat: () => 0.5, nextBytes: (n: number) => { const out = new Uint8Array(n); for (let j = 0; j < n; j++) { out[j] = bytes[(i + j) % bytes.length] ?? 0; } i += n; return out; }, }; } function seededRng(seed: number): Rng { let state = seed >>> 0; return { nextUint32: () => { state = (state * 1664525 + 1013904223) >>> 0; return state; }, nextFloat: () => 0.5, nextBytes: (n: number) => { const out = new Uint8Array(n); let i = 0; while (i < n) { state = (state * 1664525 + 1013904223) >>> 0; out[i++] = state & 0xff; if (i < n) out[i++] = (state >>> 8) & 0xff; if (i < n) out[i++] = (state >>> 16) & 0xff; if (i < n) out[i++] = (state >>> 24) & 0xff; } return out; }, }; } function toHex(bytes: Uint8Array): string { return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); } describe("srp.spec-conformance — RFC 5054 Appendix B", () => { it("RFC 5054 Appendix B vectors — every intermediate matches", { timeout: 10000, }, () => { const params = params1024Sha1(); const salt = fromHex(SALT_HEX); // x = H(salt | H(I | ":" | P)) const x = computeX(I, P, salt, params); expect(toHex(x).toUpperCase()).toBe(X_HEX); // v = g^x mod N (encoded as the RFC's reference) const { v } = computeVerifier(I, P, salt, params); expect(toHex(v).toUpperCase()).toBe(V_HEX); // k = H(N | PAD(g)) const k = computeK(params); expect(toHex(k).toUpperCase()).toBe(K_HEX); // A = g^a mod N (with a from the RFC, injected via a custom Rng) const aBytes = fromHex(A_PRIV_HEX); const aRng = deterministicRngFromBytes(aBytes); const { A } = clientStartHandshake(aRng, params); expect(toHex(A).toUpperCase()).toBe(A_PUB_HEX); // B = (k*v + g^b) mod N (with b from the RFC) const bBytes = fromHex(B_PRIV_HEX); const bRng = deterministicRngFromBytes(bBytes); const { B } = serverStartHandshake(v, bRng, params); expect(toHex(B).toUpperCase()).toBe(B_PUB_HEX); // u = H(PAD(A) | PAD(B)) const u = computeU(A, B, params); expect(toHex(u).toUpperCase()).toBe(U_HEX); // S derived by client and server, must match RFC's premaster secret const client = clientFinishHandshake(I, P, salt, A, B, aBytes, params); expect(toHex(client.S).toUpperCase()).toBe(S_HEX); const server = serverFinishHandshake( I, salt, A, B, bBytes, v, client.M1, params ); expect(toHex(server.S).toUpperCase()).toBe(S_HEX); // M2 cross-verify expect(() => clientVerifyServerProof(client.M1, server.M2, client.K, A, params) ).not.toThrow(); }); it("RFC 5054 Appendix B — client K equals server K", { timeout: 10000, }, () => { const params = params1024Sha1(); const salt = fromHex(SALT_HEX); const { v } = computeVerifier(I, P, salt, params); const aBytes = fromHex(A_PRIV_HEX); const bBytes = fromHex(B_PRIV_HEX); const { A } = clientStartHandshake( deterministicRngFromBytes(aBytes), params ); const { B } = serverStartHandshake( v, deterministicRngFromBytes(bBytes), params ); const client = clientFinishHandshake(I, P, salt, A, B, aBytes, params); const server = serverFinishHandshake( I, salt, A, B, bBytes, v, client.M1, params ); expect(constantTimeEqual(client.K, server.K)).toBe(true); }); }); describe("srp.spec-conformance — 2048/SHA-256 fixture", () => { const fixturePath = join( import.meta.dirname, "__fixtures__", "vectors-2048-sha256.json" ); type Fixture = { inputs: { I: string; P: string; salt_hex: string; a_hex: string; b_hex: string; }; outputs: { x_hex: string; v_hex: string; A_hex: string; B_hex: string; K_hex: string; M1_hex: string; M2_hex: string; S_hex: string; }; }; const fixture = JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture; it("2048/SHA-256 fixture — full handshake replays byte-exactly", { timeout: 10000, }, () => { const params = params2048Sha256(nobleHashBridge("SHA256")); const salt = fromHex(fixture.inputs.salt_hex); const a = fromHex(fixture.inputs.a_hex); const b = fromHex(fixture.inputs.b_hex); const { x, v } = computeVerifier( fixture.inputs.I, fixture.inputs.P, salt, params ); expect(toHex(x)).toBe(fixture.outputs.x_hex); expect(toHex(v)).toBe(fixture.outputs.v_hex); const gInt = bytesToBigInt(params.g); const NInt = bytesToBigInt(params.N); const A = bigIntToBytes( modPow(gInt, bytesToBigInt(a), NInt), params.N.byteLength ); expect(toHex(A)).toBe(fixture.outputs.A_hex); const k = computeK(params); const kvModN = (bytesToBigInt(k) * bytesToBigInt(v)) % NInt; const gbModN = modPow(gInt, bytesToBigInt(b), NInt); const B = bigIntToBytes((kvModN + gbModN) % NInt, params.N.byteLength); expect(toHex(B)).toBe(fixture.outputs.B_hex); const client = clientFinishHandshake( fixture.inputs.I, fixture.inputs.P, salt, A, B, a, params ); expect(toHex(client.S)).toBe(fixture.outputs.S_hex); expect(toHex(client.K)).toBe(fixture.outputs.K_hex); expect(toHex(client.M1)).toBe(fixture.outputs.M1_hex); const server = serverFinishHandshake( fixture.inputs.I, salt, A, B, b, v, client.M1, params ); expect(toHex(server.M2)).toBe(fixture.outputs.M2_hex); }); it("2048/SHA-256 fixture — generator output is deterministic", { timeout: 10000, }, () => { // Replay the same derivation the generator script performs, twice, // and assert byte-identical outputs each time. We do not exec the // script subprocess (would require shell); the determinism contract // is on the underlying math, which we exercise directly. const params = params2048Sha256(nobleHashBridge("SHA256")); const salt = fromHex(fixture.inputs.salt_hex); const a = fromHex(fixture.inputs.a_hex); const b = fromHex(fixture.inputs.b_hex); const run = () => { const { x, v } = computeVerifier( fixture.inputs.I, fixture.inputs.P, salt, params ); const NInt = bytesToBigInt(params.N); const gInt = bytesToBigInt(params.g); const A = bigIntToBytes( modPow(gInt, bytesToBigInt(a), NInt), params.N.byteLength ); const k = computeK(params); const kvModN = (bytesToBigInt(k) * bytesToBigInt(v)) % NInt; const gbModN = modPow(gInt, bytesToBigInt(b), NInt); const B = bigIntToBytes( (kvModN + gbModN) % NInt, params.N.byteLength ); const client = clientFinishHandshake( fixture.inputs.I, fixture.inputs.P, salt, A, B, a, params ); const server = serverFinishHandshake( fixture.inputs.I, salt, A, B, b, v, client.M1, params ); return { x: toHex(x), v: toHex(v), A: toHex(A), B: toHex(B), K: toHex(client.K), M1: toHex(client.M1), M2: toHex(server.M2), }; }; const a1 = run(); const a2 = run(); expect(a1).toEqual(a2); }); }); describe("srp.spec-conformance — handshake symmetry property", () => { it("property — client K equals server K across 1000 random triples", { timeout: 240000, }, () => { const params = params2048Sha256(nobleHashBridge("SHA256")); for (let trial = 0; trial < 1000; trial++) { const seed = trial * 31 + 7; const I = `user-${trial}@example.com`; const P = `password-${seed}`; const salt = seededRng(seed).nextBytes!(16); const { v } = computeVerifier(I, P, salt, params); const { a, A } = clientStartHandshake(seededRng(seed + 1), params); const { b, B } = serverStartHandshake( v, seededRng(seed + 2), params ); const client = clientFinishHandshake(I, P, salt, A, B, a, params); const server = serverFinishHandshake( I, salt, A, B, b, v, client.M1, params ); expect(constantTimeEqual(client.K, server.K)).toBe(true); } }); it("property — M1 and M2 cross-verify across 25 random triples", { timeout: 30000, }, () => { const params = params2048Sha256(nobleHashBridge("SHA256")); for (let trial = 0; trial < 25; trial++) { const seed = trial * 17 + 3; const I = `m-${trial}`; const P = `pw-${seed}`; const salt = seededRng(seed).nextBytes!(16); const { v } = computeVerifier(I, P, salt, params); const { a, A } = clientStartHandshake(seededRng(seed + 1), params); const { b, B } = serverStartHandshake( v, seededRng(seed + 2), params ); const client = clientFinishHandshake(I, P, salt, A, B, a, params); const server = serverFinishHandshake( I, salt, A, B, b, v, client.M1, params ); expect(() => clientVerifyServerProof( client.M1, server.M2, client.K, A, params ) ).not.toThrow(); } }); }); describe("srp.spec-conformance — MUST-abort gates", () => { it("A ≡ 0 mod N triggers SRP.PUBLIC_KEY_INVALID on server-finish", { timeout: 5000, }, () => { const params = params2048Sha256(nobleHashBridge("SHA256")); const salt = new Uint8Array(16); const { v } = computeVerifier("a", "p", salt, params); const { b, B } = serverStartHandshake(v, seededRng(1), params); expect(() => serverFinishHandshake( "a", salt, new Uint8Array(256), B, b, v, new Uint8Array(32), params ) ).toThrow(/PUBLIC_KEY_INVALID/); }); it("B ≡ 0 mod N triggers SRP.PUBLIC_KEY_INVALID on client-finish", { timeout: 5000, }, () => { const params = params2048Sha256(nobleHashBridge("SHA256")); const salt = new Uint8Array(16); const a = new Uint8Array(32); a[31] = 1; const A = new Uint8Array(256); A[255] = 1; expect(() => clientFinishHandshake( "a", "p", salt, A, new Uint8Array(256), a, params ) ).toThrow(/PUBLIC_KEY_INVALID/); }); it("smoke timing — bit-flipped M1 within an order of magnitude of correct M1", { timeout: 10000, }, () => { const params = params2048Sha256(nobleHashBridge("SHA256")); const salt = new Uint8Array(16); const { v } = computeVerifier("a", "p", salt, params); const { a, A } = clientStartHandshake(seededRng(1), params); const { b, B } = serverStartHandshake(v, seededRng(2), params); const client = clientFinishHandshake("a", "p", salt, A, B, a, params); const tampered = new Uint8Array(client.M1); tampered[0] = (tampered[0] ?? 0) ^ 0xff; const N_SAMPLES = 25; let okTime = 0; let badTime = 0; for (let i = 0; i < N_SAMPLES; i++) { const t0 = performance.now(); serverFinishHandshake("a", salt, A, B, b, v, client.M1, params); okTime += performance.now() - t0; const t1 = performance.now(); try { serverFinishHandshake("a", salt, A, B, b, v, tampered, params); } catch { // expected } badTime += performance.now() - t1; } const okAvg = okTime / N_SAMPLES; const badAvg = badTime / N_SAMPLES; // Both paths go through the same modpow chain before the // constant-time compare. We assert the means stay within an // order of magnitude — a smoke check, NOT a formal timing-attack // proof. const ratio = Math.max(okAvg, badAvg) / Math.min(okAvg, badAvg); expect(ratio).toBeLessThan(5); }); });