You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
DateKeys-App/src/lib/dkc/bls12381.contrast.test.ts

161 lines
6.0 KiB

// Contrast test of bls12381.ts against an audited implementation.
//
// checkCompressedPoint is our own code. It is kept because it matches the Go
// reference on every edge case, is 1.3 KB gzip and adds no runtime
// dependency. Its assurance comes from this file: on every run it is compared
// with the Go reference on 157 frozen edge cases and with @noble/curves 2.4.0
// (Cure53 2024; Trail of Bits 2026 review, whose BLS findings were fixed in
Phase 2, step 2: runtime dependencies and their guards - age-encryption 0.3.1, @noble/curves 2.4.0 (moved from dev) and @noble/hashes 2.4.0 become exact runtime dependencies (plan section 3, decision 5). The lockfile gains six packages: age-encryption, @noble/ciphers 2.4.0, @scure/base 2.4.0, @noble/post-quantum 0.5.4 and its own @noble/curves and @noble/hashes 2.0.1. No file of src/ imports them yet, so the site does not change. - src/lib/dependencies.test.ts guards them. package.json declares exactly these three, pinned. The lockfile has no tlock-js, drand-client or noble 1.x, and no noble 2.x copy other than 2.4.0 at the root and 2.0.1 under @noble/post-quantum. No file of src/ imports tlock-js or drand-client. Only ibe.ts, release.ts and the tests name @noble/, always subpaths of @noble/curves or @noble/hashes that resolve to the root 2.4.0 copy. Every check also runs on bad inputs. It replaces the "only tests import @noble/curves" test of bls12381.contrast.test.ts. - vite.config.ts records the modules of each client chunk in .svelte-kit/output/client-modules.json. check-build.mjs fails if the bundle holds tlock-js, drand-client or @babel/*, or a nested copy other than noble under @noble/post-quantum. It also reports the JavaScript each page loads: /inspect today loads 157 KB, 58.7 KB gzip. - Measured with a probe build (Vite 8, minified, gzip 9): the Decrypter is 48 KB gzip, with the Encrypter 56 KB, noble BLS12-381 plus SHA-256 28 KB, and all of them 73 KB. age-encryption imports its hybrid ML-KEM recipients statically, so post-quantum and its nested noble copy are about 99 KB of the Decrypter's 212 KB of rendered code. - npm audit --omit=dev: no vulnerabilities. The full audit finds two low ones in the tooling: cookie < 0.7.0 through @sveltejs/kit 2.70.3, which is the latest version and affects only SvelteKit's server. npm run verify is green: 2,384 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 weeks ago
// 2.3.0) on a deterministic corpus. noble is a runtime dependency since phase
// 2, for the IBE core and the release verification only: the guards of
// src/lib/dependencies.test.ts fail if any other file of the library or the
// page imports it.
import { bls12_381 } from '@noble/curves/bls12-381.js';
Phase 2, step 2: runtime dependencies and their guards - age-encryption 0.3.1, @noble/curves 2.4.0 (moved from dev) and @noble/hashes 2.4.0 become exact runtime dependencies (plan section 3, decision 5). The lockfile gains six packages: age-encryption, @noble/ciphers 2.4.0, @scure/base 2.4.0, @noble/post-quantum 0.5.4 and its own @noble/curves and @noble/hashes 2.0.1. No file of src/ imports them yet, so the site does not change. - src/lib/dependencies.test.ts guards them. package.json declares exactly these three, pinned. The lockfile has no tlock-js, drand-client or noble 1.x, and no noble 2.x copy other than 2.4.0 at the root and 2.0.1 under @noble/post-quantum. No file of src/ imports tlock-js or drand-client. Only ibe.ts, release.ts and the tests name @noble/, always subpaths of @noble/curves or @noble/hashes that resolve to the root 2.4.0 copy. Every check also runs on bad inputs. It replaces the "only tests import @noble/curves" test of bls12381.contrast.test.ts. - vite.config.ts records the modules of each client chunk in .svelte-kit/output/client-modules.json. check-build.mjs fails if the bundle holds tlock-js, drand-client or @babel/*, or a nested copy other than noble under @noble/post-quantum. It also reports the JavaScript each page loads: /inspect today loads 157 KB, 58.7 KB gzip. - Measured with a probe build (Vite 8, minified, gzip 9): the Decrypter is 48 KB gzip, with the Encrypter 56 KB, noble BLS12-381 plus SHA-256 28 KB, and all of them 73 KB. age-encryption imports its hybrid ML-KEM recipients statically, so post-quantum and its nested noble copy are about 99 KB of the Decrypter's 212 KB of rendered code. - npm audit --omit=dev: no vulnerabilities. The full audit finds two low ones in the tooling: cookie < 0.7.0 through @sveltejs/kit 2.70.3, which is the latest version and affects only SvelteKit's server. npm run verify is green: 2,384 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 weeks ago
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { checkCompressedPoint, type Group } from './bls12381.ts';
type Verdict = 'point' | 'identity' | 'invalid';
const SIZE: Record<Group, number> = { G1: 48, G2: 96 };
const P = bls12_381.fields.Fp.ORDER;
const R = bls12_381.fields.Fr.ORDER;
// The audited oracle. noble also decodes uncompressed encodings, which the
// protocol never uses for a public key, so the length is checked first, as
// the Go reference does; with that check noble >= 2.3.0 matches Go on every
// frozen edge case.
function noble(group: Group, bytes: Uint8Array): Verdict {
if (bytes.length !== SIZE[group]) return 'invalid';
try {
const point = bls12_381[group].Point.fromBytes(bytes);
point.assertValidity();
return point.is0() ? 'identity' : 'point';
} catch {
return 'invalid';
}
}
const hex = (s: string): Uint8Array => Uint8Array.from(s.match(/../g) ?? [], (b) => parseInt(b, 16));
const toHex = (b: Uint8Array): string => Array.from(b, (v) => v.toString(16).padStart(2, '0')).join('');
// splitmix64: a fixed-seed generator, so every run checks the same corpus.
function rng(seed: bigint): (n: number) => Uint8Array {
let state = seed;
const M = (1n << 64n) - 1n;
const next = (): bigint => {
state = (state + 0x9e3779b97f4a7c15n) & M;
let z = state;
z = ((z ^ (z >> 30n)) * 0xbf58476d1ce4e5b9n) & M;
z = ((z ^ (z >> 27n)) * 0x94d049bb133111ebn) & M;
return z ^ (z >> 31n);
};
return (n) => {
const out = new Uint8Array(n);
for (let i = 0; i < n; i += 8) {
let w = next();
for (let j = 0; j < 8 && i + j < n; j++, w >>= 8n) out[i + j] = Number(w & 0xffn);
}
return out;
};
}
const big = (b: Uint8Array): bigint => b.reduce((acc, v) => (acc << 8n) | BigInt(v), 0n);
function be48(x: bigint): Uint8Array {
const out = new Uint8Array(48);
for (let i = 47; i >= 0; i--, x >>= 8n) out[i] = Number(x & 0xffn);
return out;
}
function modPow(base: bigint, exp: bigint): bigint {
let result = 1n;
let b = base % P;
for (let e = exp; e > 0n; e >>= 1n) {
if (e & 1n) result = (result * b) % P;
b = (b * b) % P;
}
return result;
}
// A G1 encoding of a point on y^2 = x^3 + 4 chosen from a random x: the
// cofactor is about 2^126, so it is outside the prime-order subgroup.
function onCurveOutsideSubgroupG1(random: (n: number) => Uint8Array): Uint8Array {
for (;;) {
const x = big(random(48)) % P;
const rhs = (x * x * x + 4n) % P;
if (modPow(rhs, (P - 1n) / 2n) !== 1n) continue;
const y = modPow(rhs, (P + 1n) / 4n);
const out = be48(x);
out[0]! |= 0x80 | (y > (P - 1n) / 2n ? 0x20 : 0);
return out;
}
}
interface Case {
label: string;
group: Group;
bytes: Uint8Array;
}
function corpus(): Case[] {
const random = rng(20260926n);
const cases: Case[] = [];
const scalar = (): bigint => (big(random(40)) % (R - 1n)) + 1n;
for (const [group, valid, other] of [
['G1', 60, 120],
['G2', 20, 40],
] as const) {
const G = bls12_381[group].Point;
for (let i = 0; i < valid; i++) {
const bytes = G.BASE.multiply(scalar()).toBytes(true);
cases.push({ label: `${group} valid ${i}`, group, bytes });
const negated = new Uint8Array(bytes);
negated[0]! ^= 0x20;
cases.push({ label: `${group} valid ${i} negated`, group, bytes: negated });
const flipped = new Uint8Array(bytes);
const bit = Number(big(random(2)) % BigInt(SIZE[group] * 8 - 3)) + 3;
flipped[bit >> 3]! ^= 0x80 >> (bit & 7);
cases.push({ label: `${group} valid ${i} bit ${bit} flipped`, group, bytes: flipped });
}
for (let i = 0; i < other; i++) {
const bytes = random(SIZE[group]);
bytes[0] = 0x80 | (bytes[0]! & 0x3f);
cases.push({ label: `${group} random x ${i}`, group, bytes });
}
}
for (let i = 0; i < 60; i++) {
cases.push({ label: `G1 on the curve, outside the subgroup ${i}`, group: 'G1', bytes: onCurveOutsideSubgroupG1(random) });
}
return cases;
}
describe('bls12381.ts against the Go reference and @noble/curves 2.4.0', () => {
const file = JSON.parse(readFileSync(new URL('./testing/bls12381-vectors.json', import.meta.url), 'utf8')) as {
vectors: { label: string; group: Group; hex: string; go: Verdict }[];
};
it('matches the Go reference on every frozen edge case', () => {
expect(file.vectors.length).toBe(157);
for (const v of file.vectors) expect(checkCompressedPoint(v.group, hex(v.hex)), v.label).toBe(v.go);
});
it('noble agrees with the Go reference on every frozen edge case', () => {
for (const v of file.vectors) expect(noble(v.group, hex(v.hex)), v.label).toBe(v.go);
});
it('matches noble on a deterministic corpus', { timeout: 120_000 }, () => {
const cases = corpus();
const seen = { point: 0, identity: 0, invalid: 0 };
for (const c of cases) {
const want = noble(c.group, c.bytes);
expect(checkCompressedPoint(c.group, c.bytes), `${c.label}: ${toHex(c.bytes)}`).toBe(want);
seen[want]++;
}
// Both classes that matter are exercised: valid points (and their
// negations) and encodings that decode to no subgroup point.
expect(seen.point).toBeGreaterThanOrEqual(160);
expect(seen.invalid).toBeGreaterThanOrEqual(200);
});
});

Powered by TurnKey Linux.