198 lines
8.3 KiB
JavaScript
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;
|
|
}
|