// 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; }