diff --git a/tools/verify-anchor.mjs b/tools/verify-anchor.mjs index 898d0f5..949ec5b 100644 --- a/tools/verify-anchor.mjs +++ b/tools/verify-anchor.mjs @@ -52,6 +52,18 @@ import { secp256k1 } from '@noble/curves/secp256k1'; import fs from 'node:fs'; import path from 'node:path'; import { verifyFalcon512, fromHex } from './falcon512.mjs'; +// Early refusals stop through stop(1), not process.exit(1): on Windows (Node 24), process.exit() right after a fetch aborts node +// inside libuv while the HTTP sockets close, and the caller sees exit code 127 instead of 1 (measured 2026-09-27: 8 of 8 runs; with +// stop(), 6 of 6 runs exit 1). stop() prints nothing itself, sets the code, and throws a marker that the handler below swallows, so the +// module stops where it is and node exits normally once its sockets are closed. Any OTHER uncaught error still ends the run as a +// failure at once, so that nothing that went wrong in the background can be followed by a VERIFIED line. +class StopVerify extends Error {} +process.on('uncaughtException', (e) => { + if (e instanceof StopVerify) return; + console.error(e); + process.exit(1); +}); +const stop = (code) => { process.exitCode = code; throw new StopVerify(); }; const RPC = process.env.RPC || 'https://rpc.aere.network'; const MANIFESTS = process.env.MANIFESTS || 'https://aere.network/pq/manifests'; @@ -98,7 +110,7 @@ const SCHEME_SCHEDULE = (process.env.SCHEME_SCHEDULE || SCHEME_DEFAULT).split(', .map(p => { const [h, list] = p.split(':'); return [Number(h), list.split('+').map(x => x.trim()).filter(Boolean).map(e => { const [name, iv] = e.split('/'); return { name, interval: iv === undefined ? 0 : Number(iv) }; })]; }) .sort((a, b) => a[0] - b[0]); for (const [h, list] of SCHEME_SCHEDULE) for (const { name, interval } of list) { - if (!(interval === 0 || (Number.isInteger(interval) && interval > 0 && interval % 32 === 0))) { console.error('FAIL: SCHEME_SCHEDULE step ' + h + ' gives ' + name + ' the interval ' + interval + ', which is not a positive multiple of 32'); process.exit(1); } + if (!(interval === 0 || (Number.isInteger(interval) && interval > 0 && interval % 32 === 0))) { console.error('FAIL: SCHEME_SCHEDULE step ' + h + ' gives ' + name + ' the interval ' + interval + ', which is not a positive multiple of 32'); stop(1); } } const stepAt = (hh) => { let out = [{ name: 'falcon-512', interval: 0 }]; for (const [from, list] of SCHEME_SCHEDULE) { if (from <= hh) out = list; else break; } return out; }; const schemesAt = (hh) => stepAt(hh).map(s => s.name); @@ -176,9 +188,9 @@ const join = (a, b) => isUrl ? a.replace(/\/$/, '') + '/' + b : path.join(a, b); let index; try { index = JSON.parse(await loadText(join(MANIFESTS, 'index.json'))); } -catch (e) { console.error('FAIL: cannot load manifest index from ' + MANIFESTS + ': ' + e.message); process.exit(1); } -if (!index || !Array.isArray(index.manifests) || !index.manifests.length) { console.error('FAIL: manifest index has no entries'); process.exit(1); } -if (Number(index.chainId) !== CHAIN_ID) { console.error('FAIL: manifest index is for chain ' + index.chainId); process.exit(1); } +catch (e) { console.error('FAIL: cannot load manifest index from ' + MANIFESTS + ': ' + e.message); stop(1); } +if (!index || !Array.isArray(index.manifests) || !index.manifests.length) { console.error('FAIL: manifest index has no entries'); stop(1); } +if (Number(index.chainId) !== CHAIN_ID) { console.error('FAIL: manifest index is for chain ' + index.chainId); stop(1); } console.log('chain ' + CHAIN_ID + ' rpc ' + RPC + ' manifests ' + MANIFESTS); console.log(''); @@ -269,7 +281,7 @@ const ANCHOR_FROM = Number(process.env.ANCHOR_FROM || (CHAIN_ID === 28001 ? 64 : const INTERVAL_SCHEDULE = (process.env.ANCHOR_INTERVAL_SCHEDULE || '').split(',').filter(Boolean) .map(p => p.split(':').map(x => Number(x.trim()))).sort((a, b) => a[0] - b[0]); for (const [h, iv] of INTERVAL_SCHEDULE) { - if (!(iv > 0 && iv % 32 === 0) || h < ANCHOR_FROM || (h - ANCHOR_FROM) % iv !== 0) { console.error('FAIL: ANCHOR_INTERVAL_SCHEDULE entry ' + h + ':' + iv + ' is not a multiple of 32 on the grid from ' + ANCHOR_FROM); process.exit(1); } + if (!(iv > 0 && iv % 32 === 0) || h < ANCHOR_FROM || (h - ANCHOR_FROM) % iv !== 0) { console.error('FAIL: ANCHOR_INTERVAL_SCHEDULE entry ' + h + ':' + iv + ' is not a multiple of 32 on the grid from ' + ANCHOR_FROM); stop(1); } } // carries a certificate element? (RLP item 6 of extraData; enough to space the grid, not to trust it) @@ -293,13 +305,13 @@ if (!INTERVAL_SCHEDULE.length) { console.error('FAIL: only ' + seen.length + ' certificate-carrying header(s) in the ' + LOOKBACK + ' blocks below ' + top + ','); console.error(' so the anchor spacing cannot be measured on this chain. Either anchoring has stopped, or this'); console.error(' endpoint is not serving chain ' + CHAIN_ID + '. Pass ANCHOR_INTERVAL_SCHEDULE=h:interval to state it explicitly.'); - process.exit(1); + stop(1); } const step = seen[0] - seen[1]; if (!(step > 0 && step % 32 === 0) || (seen[0] - ANCHOR_FROM) % step !== 0) { console.error('FAIL: the two most recent anchors (' + seen[0] + ', ' + seen[1] + ') are ' + step + ' apart, which is not a'); console.error(' multiple of 32 on the grid from ' + ANCHOR_FROM + '. The chain is not anchoring on a regular grid.'); - process.exit(1); + stop(1); } DERIVED_INTERVAL = step; INTERVAL_SCHEDULE.push([seen[0] - Math.floor((seen[0] - ANCHOR_FROM) / step) * step, step]); @@ -314,11 +326,11 @@ if (DERIVED_INTERVAL) { } else if (INTERVAL_SCHEDULE.length) { console.log('interval schedule (given): ' + INTERVAL_SCHEDULE.map(([h, iv]) => h + ':' + iv).join(',') + ' (in force at ' + anchor + ': every ' + intervalAt(anchor) + 'th block)'); } -if (anchor < ANCHOR_FROM) { console.error('FAIL: no anchor at or below ' + top + ' (anchors start at ' + ANCHOR_FROM + ')'); process.exit(1); } +if (anchor < ANCHOR_FROM) { console.error('FAIL: no anchor at or below ' + top + ' (anchors start at ' + ANCHOR_FROM + ')'); stop(1); } { const b = await rpc('eth_getBlockByNumber', ['0x' + anchor.toString(16), false]); const vanity = rlpRead(hex(b.extraData), 0).item[0].item; - 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)'); stop(1); } } // AIP-22 stage 3 (2026-09-23): from the identity height a chain's validator set holds post-quantum ids, not ECDSA addresses. @@ -343,27 +355,27 @@ if (!IDENTITY_OFF) { 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); } + catch (e) { console.error('FAIL: this chain publishes v3 registry epochs, and registry-v3.mjs (published beside this tool) cannot be loaded: ' + e.message); stop(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); } + 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'); stop(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); } + catch (e) { console.error('FAIL: v3 epoch ' + s.file + ' does not verify: ' + e.message); stop(1); } const km = manifestAt(r.bindHeight); // from H_k (the first epoch the index marks keysFrom) a later epoch may rotate keys under the old keys' signatures, which // registry-v3.mjs has verified; the equality with the key manifest is required up to and including the H_k epoch const rotating = V3_EPOCHS.some(x => x.keysFrom) || (process.env.KEYS_FROM && r.bindHeight > Number(process.env.KEYS_FROM)); - 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); } + 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')); stop(1); } for (const row of (rotating ? [] : 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); } + 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'); stop(1); } } // the ecdsa link is inherited by id (a later epoch may omit it), exactly as both clients resolve a message's // author: an id that this epoch links again drops its older address, and an id no longer registered drops its link