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.

This commit is contained in:
Aere Network 2026-09-23 15:33:49 +03:00
parent 79e5dafd53
commit 944dc0bba6
3 changed files with 468 additions and 14 deletions

197
tools/falcon512.mjs Normal file
View File

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

213
tools/registry-v3.mjs Normal file
View File

@ -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 <dir with the vectors> 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 <vector dir>'); 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;
}

View File

@ -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)'); console.log('KEY MANIFESTS (leg 2): every row signed by its Falcon key (possession) AND by the validator\'s ECDSA key (claim)');
const manifests = []; const manifests = [];
let manifestBad = 0; 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) { for (const m of index.manifests) {
if (String(m.formatVersion) === '3') { v3Entries.push(m); continue; }
const text = await loadText(join(MANIFESTS, m.file)); const text = await loadText(join(MANIFESTS, m.file));
const digest = Buffer.from(sha256(Buffer.from(text, 'utf8'))).toString('hex'); const digest = Buffer.from(sha256(Buffer.from(text, 'utf8'))).toString('hex');
const man = JSON.parse(text); 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); } 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 // AIP-22 stage 3 (2026-09-23): from the identity height a chain's validator set holds post-quantum ids, not ECDSA addresses.
// the Falcon/hybrid manifests above still name each index by its classical address (the first stage keeps the keys where they // The v3 registry epochs are read from the SAME manifest index (entries with formatVersion 3), so a stranger needs no variable:
// are and adds the identity). REGISTRY_V3 (a v3 epoch manifest, file or URL) supplies the link: the row whose ecdsa.addr is the // each epoch's sha256 must match the index, and each is verified in full by registry-v3.mjs (the independent JavaScript
// 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 // implementation of the registry: every proof of possession, Falcon-512 from the spec and SLH-DSA via @noble, every ecdsa link,
// verified by verifica-epoca-v3.mjs (the independent JS implementation of the registry). Without both variables nothing changes. // the rotation chain from the epoch before, and the Merkle registry hash). In this first stage the keys that SIGN seals are still
const IDENTITY_BLOCK = process.env.IDENTITY_BLOCK ? Number(process.env.IDENTITY_BLOCK) : Infinity; // 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
let V3_LINK = null; // 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
if (process.env.REGISTRY_V3 && Number.isFinite(IDENTITY_BLOCK)) { // that does not sign, and this tool refuses. The identity height is the first v3 epoch's bindHeight. IDENTITY_BLOCK and
const v3 = JSON.parse(await loadText(process.env.REGISTRY_V3)); // REGISTRY_V3 (a v3 epoch file or URL, verified the same way) override the index, for an epoch not yet published.
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); } // IDENTITY_BLOCK=none ignores the v3 epochs altogether (the set is read as ECDSA addresses at every height): a negative
V3_LINK = new Map(); // control, under which every anchor sealed after the identity height must fail with "keys not in validator set".
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()); } const IDENTITY_OFF = process.env.IDENTITY_BLOCK === 'none';
console.log('identity: post-quantum ids from block ' + IDENTITY_BLOCK + ', ' + V3_LINK.size + ' ecdsa links from the v3 epoch bound at ' + v3.bindHeight); 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(); const validatorsCache = new Map();
async function validatorsAt(h) { async function validatorsAt(h) {