From 944dc0bba61e2149e1522ef42ff8a8a764dc96ac Mon Sep 17 00:00:00 2001 From: Aere Network Date: Wed, 23 Sep 2026 15:33:49 +0300 Subject: [PATCH] tools: verify-anchor.mjs discovers registry v3 epochs (AIP-22 stage 3, formatVersion 3 entries of the same manifest index) and verifies each in full with registry-v3.mjs (every proof of possession, every ECDSA link, the rotation chain, the Merkle registry hash), then requires every v3 row to carry exactly the Falcon-512 key, SLH-DSA key and address of the key manifest that signs at its height; the identity height is the first epoch's bindHeight, IDENTITY_BLOCK=none disables it as a negative control. Adds tools/registry-v3.mjs and tools/falcon512.mjs, which verify-anchor.mjs imports and the package did not carry. Checked 2026-09-23: testnet 28001 20/20 with its published v3 epoch (registry hash 0xd19eef4a..., identity from 3,080,010); chain 2800 20/20 unchanged; a byte-altered epoch and an epoch whose SLH-DSA key differs from the signing manifest both refused with the reason. --- tools/falcon512.mjs | 197 +++++++++++++++++++++++++++++++++++++ tools/registry-v3.mjs | 213 ++++++++++++++++++++++++++++++++++++++++ tools/verify-anchor.mjs | 72 +++++++++++--- 3 files changed, 468 insertions(+), 14 deletions(-) create mode 100644 tools/falcon512.mjs create mode 100644 tools/registry-v3.mjs diff --git a/tools/falcon512.mjs b/tools/falcon512.mjs new file mode 100644 index 0000000..f1dab2d --- /dev/null +++ b/tools/falcon512.mjs @@ -0,0 +1,197 @@ +// 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; +} diff --git a/tools/registry-v3.mjs b/tools/registry-v3.mjs new file mode 100644 index 0000000..d62c809 --- /dev/null +++ b/tools/registry-v3.mjs @@ -0,0 +1,213 @@ +// registry-v3.mjs - the Aere validator registry v3 (AIP-22 stage 3), verified in plain JavaScript. +// +// A THIRD implementation, written from the canonical forms in the AIP (section 3.2) and in +// PqRegistryV3.java, sharing no code with either client: Falcon-512 is checked with falcon512.mjs +// (verification from the public spec), SLH-DSA-SHA2-128s and secp256k1 with @noble. Its job is to +// read the same conformance vectors as the two clients (aerenew/vectors/registry-v3/) and reach the +// same outcome on every one: "ok" with the same registry hash and row hashes, or the same named +// refusal (AERE-PQC-REG3-xx). Three independent implementations agreeing byte for byte is what +// makes the forms a specification rather than one client's habit. +// +// node registry-v3.mjs exit 0: every vector as named; 1: a mismatch +// import { load, rowHash, merkleRoot, registryHash, idOf } from './registry-v3.mjs' +import fs from 'node:fs'; +import path from 'node:path'; +import { keccak_256 } from '@noble/hashes/sha3'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { verifyFalcon512 } from './falcon512.mjs'; + +let slh = null; +try { slh = (await import('@noble/post-quantum/slh-dsa.js')).slh_dsa_sha2_128s; } catch { slh = null; } + +const keccak = (b) => Buffer.from(keccak_256(b)); +const u32 = (v) => { const b = Buffer.alloc(4); b.writeUInt32BE(v); return b; }; +const u64 = (v) => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(v)); return b; }; +const ascii = (s) => Buffer.from(s, 'ascii'); + +export const SCHEMES = { + 'falcon-512': { wire: 1, pkLen: 897, verify: (pk, m, s) => { try { return verifyFalcon512(new Uint8Array(pk), new Uint8Array(m), new Uint8Array(s)); } catch { return false; } } }, + 'slh-dsa-sha2-128s': { wire: 2, pkLen: 32, verify: (pk, m, s) => { if (!slh) throw new Error('NEMASURAT: @noble/post-quantum is not installed; SLH-DSA cannot be checked here'); try { return slh.verify(new Uint8Array(s), new Uint8Array(m), new Uint8Array(pk)); } catch { return false; } } }, +}; + +export class RegistryV3Error extends Error { + constructor(code, message) { super(code + ': ' + message); this.code = code; } +} +const fail = (n, msg) => { throw new RegistryV3Error('AERE-PQC-REG3-' + String(n).padStart(2, '0'), msg); }; + +export const idOf = (falcon897) => keccak(Buffer.concat([Buffer.from([0x50, 0x20]), falcon897])).subarray(12, 32); + +export function popMessage(chainId, bindHeight, count, index, id, wire, pk) { + return keccak(Buffer.concat([ascii('AERE-PQ-POP-3'), Buffer.from([3]), u64(chainId), u64(bindHeight), u32(count), u32(index), id, + Buffer.from([wire]), u32(pk.length), pk])); +} + +// row: { index, id, keys: [{ name, wire, pk, pop }] sorted by wire, transport: Buffer|null } +export function rowPreimage(chainId, bindHeight, count, row) { + const parts = [ascii('AERE-PQ-ROW-3'), Buffer.from([3]), u64(chainId), u64(bindHeight), u32(count), u32(row.index), row.id, + Buffer.from([row.keys.length])]; + for (const k of row.keys) parts.push(Buffer.from([k.wire]), u32(k.pk.length), k.pk, u32(k.pop.length), k.pop); + parts.push(row.transport ? Buffer.concat([Buffer.from([1]), row.transport]) : Buffer.from([0])); + return Buffer.concat(parts); +} +export const rowHash = (chainId, bindHeight, count, row) => keccak(rowPreimage(chainId, bindHeight, count, row)); +const node = (l, r) => keccak(Buffer.concat([Buffer.from([0x01]), l, r])); +export function merkleRoot(leaves) { + let level = leaves; + while (level.length > 1) { const next = []; for (let i = 0; i < level.length; i += 2) next.push(node(level[i], level[i + 1] ?? level[i])); level = next; } + return level[0]; +} +export function merkleProof(leaves, index) { + const proof = []; let level = leaves; let i = index; + while (level.length > 1) { + const sib = i % 2 === 0 ? i + 1 : i - 1; proof.push(level[sib] ?? level[i]); + const next = []; for (let j = 0; j < level.length; j += 2) next.push(node(level[j], level[j + 1] ?? level[j])); level = next; i = Math.floor(i / 2); + } + return proof; +} +export function verifyMerkleProof(leaf, index, count, proof, root) { + if (index < 0 || index >= count || count < 1) return false; + let depth = 0; for (let n = count; n > 1; n = Math.ceil(n / 2)) depth++; + if (proof.length !== depth) return false; + let acc = leaf, i = index; + for (const sib of proof) { acc = i % 2 === 0 ? node(acc, sib) : node(sib, acc); i = Math.floor(i / 2); } + return acc.equals(root); +} +export const registryHash = (chainId, bindHeight, count, root) => + keccak(Buffer.concat([ascii('AERE-PQ-REGISTRY-3'), u64(chainId), u64(bindHeight), u32(count), root])); +export const rotateMessage = (rh) => keccak(Buffer.concat([ascii('AERE-PQ-ROTATE-3'), rh])); +export function claimDigest(chainId, rh) { + const ctx = keccak(Buffer.concat([ascii('AERE-PQ-CLAIM-1'), u64(chainId)])).subarray(12, 32); + return keccak(Buffer.concat([Buffer.from([0x19, 0x00]), ctx, rh])); +} +function recover(digest, sig65) { + if (!sig65 || sig65.length !== 65) return null; + let rec = sig65[64]; if (rec >= 27) rec -= 27; + if (rec !== 0 && rec !== 1) return null; + try { + const s = secp256k1.Signature.fromCompact(sig65.subarray(0, 64)).addRecoveryBit(rec); + return keccak(Buffer.from(s.recoverPublicKey(digest).toRawBytes(false).subarray(1))).subarray(12, 32); + } catch { return null; } +} + +function hex(v, len, code, what) { + if (typeof v !== 'string' || !/^0x([0-9a-f]{2})*$/.test(v)) fail(code, what + ' is not lowercase 0x-hex of even length'); + const b = Buffer.from(v.slice(2), 'hex'); + if (len >= 0 && b.length !== len) fail(code, what + ' is ' + b.length + ' bytes, expected ' + len); + return b; +} +const isObj = (o) => o !== null && typeof o === 'object' && !Array.isArray(o); + +function parseRow(index, e) { + for (const f of Object.keys(e)) if (!['id', 'keys', 'pop', 'rotation', 'ecdsa', 'transport'].includes(f)) fail(3, 'row ' + index + ' has an unknown field ' + f); + const id = hex(e.id, 20, 4, 'row ' + index + ' id'); + if (!isObj(e.keys) || Object.keys(e.keys).length === 0) fail(3, 'row ' + index + ' has no keys'); + if (!isObj(e.pop)) fail(8, 'row ' + index + ' has no pop object'); + const keys = []; + for (const [name, v] of Object.entries(e.keys)) { + const s = SCHEMES[name]; if (!s) fail(5, 'row ' + index + ' names the unknown scheme ' + name); + const pk = hex(v, s.pkLen, 7, 'row ' + index + ' ' + name); + if (name === 'falcon-512' && pk[0] !== 0x09) fail(7, 'row ' + index + ' falcon-512: the standard encoding starts with 0x09'); + if (e.pop[name] === undefined) fail(8, 'row ' + index + ' ' + name + ' has no proof of possession'); + keys.push({ name, wire: s.wire, pk, pop: hex(e.pop[name], -1, 8, 'row ' + index + ' pop') }); + } + if (Object.keys(e.pop).length !== Object.keys(e.keys).length) fail(8, 'row ' + index + ' carries a proof for a scheme it has no key for'); + keys.sort((a, b) => a.wire - b.wire); + let transport = null; + if (e.transport !== undefined) { + if (!isObj(e.transport) || Object.keys(e.transport).length !== 1 || e.transport.secp256k1Address === undefined) fail(15, 'row ' + index + ' transport must be {secp256k1Address}'); + transport = hex(e.transport.secp256k1Address, 20, 15, 'row ' + index + ' transport'); + } + const rotation = {}; + if (e.rotation !== undefined) { + if (!isObj(e.rotation) || Object.keys(e.rotation).length === 0) fail(13, 'row ' + index + ' rotation must be a non-empty map'); + for (const [name, v] of Object.entries(e.rotation)) { if (!SCHEMES[name]) fail(5, 'row ' + index + ' rotation names an unknown scheme'); rotation[name] = hex(v, -1, 12, 'row ' + index + ' rotation'); } + } + let ecdsa = null; + if (e.ecdsa !== undefined) { + if (!isObj(e.ecdsa) || Object.keys(e.ecdsa).length !== 2 || e.ecdsa.addr === undefined || e.ecdsa.claim === undefined) fail(14, 'row ' + index + ' ecdsa must be {addr, claim}'); + ecdsa = { addr: hex(e.ecdsa.addr, 20, 14, 'ecdsa.addr'), claim: hex(e.ecdsa.claim, 65, 14, 'ecdsa.claim') }; + } + return { index, id, keys, transport, rotation, ecdsa }; +} + +const keyOf = (row, name) => row.keys.find(k => k.name === name); +const sameKeys = (a, b) => a.keys.length === b.keys.length && a.keys.every(k => { const o = b.keys.find(x => x.wire === k.wire); return o && o.pk.equals(k.pk); }); + +export function load(json, chainId, requiredSchemes, previous, ecdsaRequired) { + if (!isObj(json)) fail(3, 'the manifest is not a JSON object'); + if (json.formatVersion !== 3) fail(1, 'formatVersion must be the number 3'); + if (json.chainId !== chainId) fail(2, 'chainId must be the number ' + chainId); + if (!Number.isInteger(json.bindHeight) || json.bindHeight < 0) fail(3, 'bindHeight must be a number >= 0'); + if (!Number.isInteger(json.count) || json.count < 1) fail(3, 'count must be a number >= 1'); + const { bindHeight, count } = json; + const allowed = new Set(['formatVersion', 'chainId', 'bindHeight', 'count', 'registryHash']); + for (let i = 0; i < count; i++) allowed.add(String(i)); + for (const k of Object.keys(json)) if (!allowed.has(k)) fail(3, 'unexpected top-level field ' + k); + const rows = []; + for (let i = 0; i < count; i++) { if (!isObj(json[String(i)])) fail(3, 'row ' + i + ' is missing'); rows.push(parseRow(i, json[String(i)])); } + for (const row of rows) { + for (const s of requiredSchemes) if (!keyOf(row, s)) fail(6, 'row ' + row.index + ' has no ' + s + ' key'); + if (!keyOf(row, 'falcon-512')) fail(6, 'row ' + row.index + ' has no falcon-512 key'); + for (const k of row.keys) { + const m = popMessage(chainId, bindHeight, count, row.index, row.id, k.wire, k.pk); + if (k.pop.length === 0 || !SCHEMES[k.name].verify(k.pk, m, k.pop)) fail(8, 'row ' + row.index + ' ' + k.name + ': the proof of possession does not verify'); + } + } + const ids = new Set(), keys = new Set(); + for (const row of rows) { + const idh = row.id.toString('hex'); if (ids.has(idh)) fail(10, 'id appears on two rows'); ids.add(idh); + for (const k of row.keys) { const h = k.pk.toString('hex'); if (keys.has(h)) fail(11, 'row ' + row.index + ' ' + k.name + ': the key appears on another row'); keys.add(h); } + } + const hashes = rows.map(r => rowHash(chainId, bindHeight, count, r)); + for (const row of rows) { + const prev = previous ? previous.rows.find(p => p.id.equals(row.id)) : undefined; + if (!prev) { + if (!idOf(keyOf(row, 'falcon-512').pk).equals(row.id)) fail(9, 'row ' + row.index + ': a new id must be the derivation of its falcon key'); + if (Object.keys(row.rotation).length) fail(13, 'row ' + row.index + ' is a new id and carries a rotation'); + continue; + } + if (sameKeys(prev, row)) { if (Object.keys(row.rotation).length) fail(13, 'row ' + row.index + ' did not change its keys and carries a rotation'); continue; } + const rot = rotateMessage(hashes[row.index]); + for (const old of prev.keys) { + const sig = row.rotation[old.name]; + if (!sig || !SCHEMES[old.name].verify(old.pk, rot, sig)) fail(12, 'row ' + row.index + ' changed its keys; the previous ' + old.name + ' key did not sign the new row'); + } + if (Object.keys(row.rotation).length !== prev.keys.length) fail(13, 'row ' + row.index + ' carries a rotation signature for a scheme the previous row did not bind'); + } + const ecd = new Set(); + for (const row of rows) { + if (!row.ecdsa) { if (ecdsaRequired) fail(14, 'row ' + row.index + ' has no ecdsa link in the switch epoch'); continue; } + const rec = recover(claimDigest(chainId, hashes[row.index]), row.ecdsa.claim); + if (!rec || !rec.equals(row.ecdsa.addr)) fail(14, 'row ' + row.index + ': the ecdsa claim does not recover to its address'); + const a = row.ecdsa.addr.toString('hex'); if (ecd.has(a)) fail(14, 'ecdsa address on two rows'); ecd.add(a); + } + const root = merkleRoot(hashes); + const hash = registryHash(chainId, bindHeight, count, root); + if (json.registryHash !== undefined && !hex(json.registryHash, 32, 16, 'registryHash').equals(hash)) fail(16, 'the file declares another registry hash'); + return { chainId, bindHeight, count, rows, rowHashes: hashes, root, hash }; +} + +// ---- CLI: run every vector in a directory ---------------------------------------------------- +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'))) { + const dir = process.argv[2]; + if (!dir) { console.error('usage: node registry-v3.mjs '); process.exit(2); } + const files = fs.readdirSync(dir).filter(f => f.endsWith('.json')).sort(); + const cache = new Map(); + const read = (n) => JSON.parse(fs.readFileSync(path.join(dir, n + '.json'), 'utf8')); + const run = (v) => load(v.manifest, v.chainId, v.requiredSchemes, v.previous ? (cache.get(v.previous) ?? (cache.set(v.previous, run(read(v.previous))), cache.get(v.previous))) : null, v.ecdsaRequired); + let good = 0, bad = 0; + for (const f of files) { + const v = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); + let got, r = null; + try { r = run(v); got = 'ok'; } catch (e) { if (e instanceof RegistryV3Error) got = e.code; else { console.log(f + ' NEMASURAT: ' + e.message); process.exitCode = 2; continue; } } + let same = got === v.expect; + if (same && got === 'ok') { + same = '0x' + r.hash.toString('hex') === v.registryHash && r.rowHashes.every((h, i) => '0x' + h.toString('hex') === v.rowHashes[i]) + && r.rows.every((row, i) => verifyMerkleProof(r.rowHashes[i], i, r.count, v.merkleProofs[String(i)].map(x => Buffer.from(x.slice(2), 'hex')), r.root)); + } + console.log((same ? 'AS NAMED ' : 'MISMATCH ') + f.padEnd(34) + ' expected ' + v.expect + ', got ' + got); + same ? good++ : bad++; + } + console.log(`\n${good} of ${files.length} vectors reach the outcome they name` + (bad ? `; ${bad} DO NOT` : '')); + if (bad) process.exitCode = 1; +} diff --git a/tools/verify-anchor.mjs b/tools/verify-anchor.mjs index ec203cc..63bdf8a 100644 --- a/tools/verify-anchor.mjs +++ b/tools/verify-anchor.mjs @@ -176,7 +176,9 @@ console.log(''); console.log('KEY MANIFESTS (leg 2): every row signed by its Falcon key (possession) AND by the validator\'s ECDSA key (claim)'); const manifests = []; let manifestBad = 0; +const v3Entries = []; // AIP-22 stage 3: registry v3 epochs live in the same index; they are verified further below for (const m of index.manifests) { + if (String(m.formatVersion) === '3') { v3Entries.push(m); continue; } const text = await loadText(join(MANIFESTS, m.file)); const digest = Buffer.from(sha256(Buffer.from(text, 'utf8'))).toString('hex'); const man = JSON.parse(text); @@ -308,21 +310,63 @@ if (anchor < ANCHOR_FROM) { console.error('FAIL: no anchor at or below ' + top + if (vanity.length !== 32 || Buffer.from(vanity).includes(Buffer.from('besu'))) { console.error('FAIL: block ' + anchor + ' does not look like an anchor (vanity is the client string, not a digest)'); process.exit(1); } } -// AIP-22 stage 3 (2026-09-23): from IDENTITY_BLOCK a chain's validator set holds post-quantum ids, not ECDSA addresses, while -// the Falcon/hybrid manifests above still name each index by its classical address (the first stage keeps the keys where they -// are and adds the identity). REGISTRY_V3 (a v3 epoch manifest, file or URL) supplies the link: the row whose ecdsa.addr is the -// manifest's address gives the id that must be in the set. This tool only READS the link; the v3 manifest's own proofs are -// verified by verifica-epoca-v3.mjs (the independent JS implementation of the registry). Without both variables nothing changes. -const IDENTITY_BLOCK = process.env.IDENTITY_BLOCK ? Number(process.env.IDENTITY_BLOCK) : Infinity; -let V3_LINK = null; -if (process.env.REGISTRY_V3 && Number.isFinite(IDENTITY_BLOCK)) { - const v3 = JSON.parse(await loadText(process.env.REGISTRY_V3)); - if (v3.formatVersion !== 3 || v3.chainId !== CHAIN_ID) { console.error('FAIL: REGISTRY_V3 is not a v3 epoch of chain ' + CHAIN_ID); process.exit(1); } - V3_LINK = new Map(); - for (let i = 0; i < v3.count; i++) { const r = v3[String(i)]; if (r && r.ecdsa) V3_LINK.set(r.ecdsa.addr.toLowerCase(), r.id.toLowerCase()); } - console.log('identity: post-quantum ids from block ' + IDENTITY_BLOCK + ', ' + V3_LINK.size + ' ecdsa links from the v3 epoch bound at ' + v3.bindHeight); +// AIP-22 stage 3 (2026-09-23): from the identity height a chain's validator set holds post-quantum ids, not ECDSA addresses. +// The v3 registry epochs are read from the SAME manifest index (entries with formatVersion 3), so a stranger needs no variable: +// each epoch's sha256 must match the index, and each is verified in full by registry-v3.mjs (the independent JavaScript +// implementation of the registry: every proof of possession, Falcon-512 from the spec and SLH-DSA via @noble, every ecdsa link, +// the rotation chain from the epoch before, and the Merkle registry hash). In this first stage the keys that SIGN seals are still +// the ones of the key manifests above, so every v3 row must carry exactly the Falcon key (0x09 || the 896 bytes), the SLH-DSA key +// and the address of the same index in the key manifest in force at its bindHeight: otherwise its id would be a label on a key +// that does not sign, and this tool refuses. The identity height is the first v3 epoch's bindHeight. IDENTITY_BLOCK and +// REGISTRY_V3 (a v3 epoch file or URL, verified the same way) override the index, for an epoch not yet published. +// IDENTITY_BLOCK=none ignores the v3 epochs altogether (the set is read as ECDSA addresses at every height): a negative +// control, under which every anchor sealed after the identity height must fail with "keys not in validator set". +const IDENTITY_OFF = process.env.IDENTITY_BLOCK === 'none'; +let IDENTITY_BLOCK = process.env.IDENTITY_BLOCK && !IDENTITY_OFF ? Number(process.env.IDENTITY_BLOCK) : Infinity; +const V3_EPOCHS = []; // [{ bindHeight, link: Map(ecdsa addr -> id) }], ascending +if (IDENTITY_OFF) console.log('identity: IDENTITY_BLOCK=none, v3 epochs ignored (negative control)'); +if (!IDENTITY_OFF) { + const sources = process.env.REGISTRY_V3 + ? [{ loc: process.env.REGISTRY_V3, sha: null, file: process.env.REGISTRY_V3 }] + : [...v3Entries].sort((a, b) => Number(a.bindHeight) - Number(b.bindHeight)).map(m => ({ loc: join(MANIFESTS, m.file), sha: String(m.sha256).toLowerCase(), file: m.file })); + if (sources.length) { + let reg; + try { reg = await import('./registry-v3.mjs'); } + catch (e) { console.error('FAIL: this chain publishes v3 registry epochs, and registry-v3.mjs (published beside this tool) cannot be loaded: ' + e.message); process.exit(1); } + let previous = null, link = new Map(); + for (const s of sources) { + const text = await loadText(s.loc); + if (s.sha && Buffer.from(sha256(Buffer.from(text, 'utf8'))).toString('hex') !== s.sha) { console.error('FAIL: v3 epoch ' + s.file + ': sha256 does not match the index'); process.exit(1); } + const json = JSON.parse(text); + let r; + try { r = reg.load(json, CHAIN_ID, schemesAt(json.bindHeight), previous, previous === null); } + catch (e) { console.error('FAIL: v3 epoch ' + s.file + ' does not verify: ' + e.message); process.exit(1); } + const km = manifestAt(r.bindHeight); + if (!km || km.count !== r.count) { console.error('FAIL: v3 epoch bound at ' + r.bindHeight + ' has ' + r.count + ' rows; the key manifest in force there has ' + (km ? km.count : 'none')); process.exit(1); } + for (const row of r.rows) { + const k = km.rows[row.index]; + const f = row.keys.find(x => x.name === 'falcon-512'), sl = row.keys.find(x => x.name === 'slh-dsa-sha2-128s'); + const falconSame = f && Buffer.concat([Buffer.from([0x09]), Buffer.from(k.pk)]).equals(Buffer.from(f.pk)); + const slhSame = !k.keys['slh-dsa-sha2-128s'] ? !sl : (sl && Buffer.from(k.keys['slh-dsa-sha2-128s']).equals(Buffer.from(sl.pk))); + const addrSame = !row.ecdsa || ('0x' + row.ecdsa.addr.toString('hex')) === k.addr.toLowerCase(); + if (!(falconSame && slhSame && addrSame)) { console.error('FAIL: v3 epoch bound at ' + r.bindHeight + ', row ' + row.index + ': ' + (falconSame ? '' : 'falcon key ') + (slhSame ? '' : 'slh-dsa key ') + (addrSame ? '' : 'ecdsa address ') + 'differ from the key manifest that signs there'); process.exit(1); } + } + link = new Map(link); + for (const row of r.rows) if (row.ecdsa) link.set('0x' + row.ecdsa.addr.toString('hex'), '0x' + row.id.toString('hex')); + V3_EPOCHS.push({ bindHeight: r.bindHeight, link }); + console.log(' v3 epoch ' + String(r.bindHeight).padEnd(10) + ' ' + String(s.file).padEnd(26) + ' rows ' + r.count + '/' + r.count + + ' registry hash 0x' + r.hash.toString('hex').slice(0, 16) + '.. every possession proof, ecdsa link and the Merkle hash verified; keys = the key manifest of that height'); + previous = r; + } + if (!Number.isFinite(IDENTITY_BLOCK)) IDENTITY_BLOCK = V3_EPOCHS[0].bindHeight; + console.log('identity: from block ' + IDENTITY_BLOCK + ' the validator set holds post-quantum ids (' + V3_EPOCHS.length + ' v3 epoch(s))'); + } } -const memberOf = (addr, h) => (V3_LINK && h >= IDENTITY_BLOCK ? (V3_LINK.get(addr) ?? addr) : addr); +const memberOf = (addr, h) => { + if (h < IDENTITY_BLOCK || !V3_EPOCHS.length) return addr; + const e = [...V3_EPOCHS].reverse().find(x => x.bindHeight <= h) || V3_EPOCHS[0]; + return e.link.get(addr) ?? addr; +}; const validatorsCache = new Map(); async function validatorsAt(h) {