Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,487 @@
/**
* 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);
});
});