488 lines
16 KiB
TypeScript
488 lines
16 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|