aere-node/tools/falcon512.mjs

198 lines
8.3 KiB
JavaScript

// falcon512.mjs - Falcon-512 signature VERIFICATION in plain JavaScript, from the public spec.
//
// Why this exists. Aere Network's anchor certificates carry Falcon-512 seals. Until now the only
// way for a stranger to check that a seal is a VALID signature (and not just bytes of the right
// length) was to ask the chain's own precompile at 0x0AE1, i.e. to ask us. This file removes that
// trust point: it verifies a Falcon-512 signature with nothing but the public key, the message and
// SHAKE256. No network, no Aere code path, no precompile.
//
// Scope, stated honestly: VERIFY ONLY. No key generation, no signing, no floating point, no
// constant time (verification handles public data only). Written from the Falcon specification
// (round 3, falcon.pdf) and the reference implementation's vrfy.c / codec.c / common.c, which
// BouncyCastle's FalconSigner also follows. Every encoding rule below names where it comes from.
//
// Signature format accepted (the one carried on chain 2800 and produced by BouncyCastle):
// byte 0 0x30 + logn = 0x39 for Falcon-512 (reference nist.c, "sig header")
// bytes 1..40 nonce, 40 bytes (hashed with the message)
// bytes 41.. s2, "compressed" encoding, variable length (codec.c comp_encode/comp_decode)
// Public key: 897 bytes, byte 0 = 0x00 + logn = 0x09, then h as 14-bit coefficients (modq_decode).
//
// Verification (vrfy.c verify_raw): c = HashToPoint(SHAKE256(nonce || message))
// s1 = c - s2*h (mod q, coefficients centered)
// accept iff ||s1||^2 + ||s2||^2 <= 34034726 (l2bound[9])
//
// Polynomial multiplication is schoolbook, O(n^2) = 262,144 products per verify. About a
// millisecond. Deliberately NOT an NTT: a wrong NTT root gives a verifier that rejects everything
// and looks "secure"; schoolbook has no parameters to get wrong and can be read in ten lines.
//
// Usage:
// import { verifyFalcon512 } from './falcon512.mjs';
// verifyFalcon512(pkBytes /*897*/, message /*Uint8Array*/, sigBytes /*0x39||nonce||s2*/) -> boolean
// Throws only on malformed ENCODINGS of the public key (a bad key is a caller bug, not a forgery);
// a malformed signature returns false, because "this signature does not verify" is the right answer
// for garbage just as for a forgery.
import { shake256 } from '@noble/hashes/sha3';
export const Q = 12289;
export const N = 512;
export const LOGN = 9;
// reference vrfy.c, l2bound[logn] for logn = 9. beta^2 = floor((1.1 * sigma * sqrt(2n))^2).
export const L2BOUND = 34034726;
export const PK_LEN = 897;
export const NONCE_LEN = 40;
/** modq_decode (codec.c): 14 bits per coefficient, big-endian bit packing, every value < q, trailing bits zero. */
export function decodePublicKey(pk) {
// Two encodings are in the wild and both are accepted: 897 bytes with the 0x09 header (the
// NIST / reference layout, what the precompile takes) and 896 bytes of bare h (what
// BouncyCastle's FalconPublicKeyParameters.getH() returns, and what the Aere manifests carry).
// Measured 2026-08-19 on manifest-13600000.json: "pk" is 1792 hex chars = 896 bytes, no header.
if (!(pk instanceof Uint8Array) || (pk.length !== PK_LEN && pk.length !== PK_LEN - 1)) {
throw new Error('falcon512: public key must be ' + PK_LEN + ' or ' + (PK_LEN - 1) + ' bytes, got ' + (pk && pk.length));
}
let v = 0;
if (pk.length === PK_LEN) {
if (pk[0] !== LOGN) throw new Error('falcon512: public key header 0x' + pk[0].toString(16) + ', expected 0x09');
v = 1;
}
const h = new Int32Array(N);
let acc = 0, accLen = 0, u = 0;
while (u < N) {
if (v >= pk.length) throw new Error('falcon512: public key too short');
acc = ((acc << 8) | pk[v++]) >>> 0;
accLen += 8;
if (accLen >= 14) {
accLen -= 14;
const w = (acc >>> accLen) & 0x3fff;
if (w >= Q) throw new Error('falcon512: public key coefficient out of range');
h[u++] = w;
acc &= (1 << accLen) - 1; // keep only the unconsumed low bits; avoids 32-bit overflow
}
}
if (v !== pk.length) throw new Error('falcon512: public key has trailing bytes');
if (acc !== 0) throw new Error('falcon512: public key trailing bits not zero');
return h;
}
/**
* comp_decode (codec.c): per coefficient, 1 sign bit, 7 low bits, then the high part in unary
* (zeros terminated by a one). "-0" is forbidden; the unused bits of the last byte must be zero;
* and the decoder must consume EXACTLY the bytes given (nist.c checks comp_decode(...) == len).
* Returns Int32Array or null if malformed.
*/
export function decodeCompressedSignature(buf) {
const s = new Int32Array(N);
let acc = 0, accLen = 0, v = 0;
for (let u = 0; u < N; u++) {
if (v >= buf.length) return null;
acc = ((acc << 8) | buf[v++]) >>> 0;
const b = (acc >>> accLen) & 0xff;
const sign = b & 0x80;
let m = b & 0x7f;
for (;;) {
if (accLen === 0) {
if (v >= buf.length) return null;
acc = ((acc << 8) | buf[v++]) >>> 0;
accLen = 8;
}
accLen--;
if (((acc >>> accLen) & 1) !== 0) break;
m += 128;
if (m > 2047) return null;
}
if (sign && m === 0) return null;
s[u] = sign ? -m : m;
acc &= (1 << accLen) - 1;
}
if ((acc & ((1 << accLen) - 1)) !== 0) return null;
if (v !== buf.length) return null;
return s;
}
/**
* hash_to_point_vartime (common.c): SHAKE256(nonce || message) as an XOF; take 16-bit big-endian
* words, accept w < 61445 (= 5q, rejection sampling for uniformity), reduce mod q.
*/
export function hashToPoint(nonce, message) {
const xof = shake256.create({}).update(nonce).update(message);
const c = new Int32Array(N);
let i = 0;
// Pull the XOF in chunks; 2 bytes per candidate, acceptance rate 61445/65536, so 1024 bytes
// nearly always suffice; loop until filled regardless.
const out = xof.xofInto(new Uint8Array(2 * N + 256));
let p = 0;
let stream = out;
while (i < N) {
if (p + 2 > stream.length) {
// extend: continue the XOF. @noble's xofInto can be called repeatedly on the same instance.
stream = xof.xofInto(new Uint8Array(512));
p = 0;
}
let w = (stream[p] << 8) | stream[p + 1];
p += 2;
if (w < 61445) {
while (w >= Q) w -= Q;
c[i++] = w;
}
}
return c;
}
/** s2 * h mod (x^N + 1, q), schoolbook. s2 may be negative; h in [0,q). */
function mulModQ(s2, h) {
const r = new Float64Array(N); // exact: each product < 2^28, at most 512 of them summed with sign < 2^38
for (let i = 0; i < N; i++) {
const a = s2[i];
if (a === 0) continue;
for (let j = 0; j < N; j++) {
const k = i + j;
const p = a * h[j];
if (k < N) r[k] += p; else r[k - N] -= p;
}
}
const out = new Int32Array(N);
for (let i = 0; i < N; i++) {
let v = r[i] % Q;
if (v < 0) v += Q;
out[i] = v;
}
return out;
}
/**
* verify_raw (vrfy.c). Returns true iff the signature verifies. Never throws for a bad signature.
* @param {Uint8Array} pk 897-byte public key
* @param {Uint8Array} message the signed message (for Aere seals: the 32-byte commit hash)
* @param {Uint8Array} sig 0x39 || nonce(40) || compressed s2
*/
export function verifyFalcon512(pk, message, sig) {
const h = decodePublicKey(pk); // throws on malformed key: caller bug
if (!(sig instanceof Uint8Array) || sig.length < 1 + NONCE_LEN + 1) return false;
if (sig[0] !== 0x30 + LOGN) return false;
const nonce = sig.subarray(1, 1 + NONCE_LEN);
const s2 = decodeCompressedSignature(sig.subarray(1 + NONCE_LEN));
if (s2 === null) return false;
const c = hashToPoint(nonce, message);
const s2h = mulModQ(s2, h);
// s1 = c - s2*h mod q, then center into (-q/2, q/2]; norm check with the spec's bound.
let norm = 0;
for (let i = 0; i < N; i++) {
let w = c[i] - s2h[i];
w %= Q; if (w < 0) w += Q;
if (w > (Q >> 1)) w -= Q;
norm += w * w + s2[i] * s2[i];
if (norm > L2BOUND) return false; // early exit is fine: public data, no timing concern
}
return norm <= L2BOUND;
}
/** Convenience: hex string (with or without 0x) -> Uint8Array. */
export function fromHex(s) {
const t = s.startsWith('0x') ? s.slice(2) : s;
if (t.length % 2) throw new Error('odd hex length');
const out = new Uint8Array(t.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(t.substr(2 * i, 2), 16);
return out;
}