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:
parent
79e5dafd53
commit
944dc0bba6
197
tools/falcon512.mjs
Normal file
197
tools/falcon512.mjs
Normal 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
213
tools/registry-v3.mjs
Normal 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;
|
||||
}
|
||||
@ -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); }
|
||||
}
|
||||
const memberOf = (addr, h) => (V3_LINK && h >= IDENTITY_BLOCK ? (V3_LINK.get(addr) ?? addr) : addr);
|
||||
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) => {
|
||||
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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user