// 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 // 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'; 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 = { 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); }); });