Initial Commit
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user