diff --git a/tools/verify-anchor.mjs b/tools/verify-anchor.mjs index 20bb70e..dc3ed21 100644 --- a/tools/verify-anchor.mjs +++ b/tools/verify-anchor.mjs @@ -22,6 +22,11 @@ // 5. ON-CHAIN PIN each manifest's hash, keccak256(addr||pk over rows), equals slot 0 of the immutable // anchor contract named in the index, and that contract's code is the 11-byte // read-only getter with no SSTORE: the manifest you hold is the one the chain pinned. +// 6. PROPOSER (AIP-22 stage 5, 2026-09-24) on a chain where headers carry element 6, the proposer's +// Falcon-512 seal over keccak256(RLP["AERE-PQ-PROPOSAL-1", chainId, height, round, digest]) +// verifies against the v3 registry key of its index, and that index is the header's +// coinbase, on the most recent headers (PROPOSER_HEADERS, default 20). Where no header +// carries it (chain 2800 today) the tool says so and this leg does not change the verdict. // // What this still does NOT prove: who RUNS the validators (today: one operator, the Foundation; we // publish that ourselves), or that the RPC you use is honest about chain contents - bring your own @@ -510,19 +515,78 @@ for (let i = 0; i < COUNT; i++) { } } +// 6. PROPOSER SEALS, AIP-22 stage 5 (added 2026-09-24). From H_hdr every header carries element 6, [index, signature]: the +// proposer's Falcon-512 seal over keccak256(RLP["AERE-PQ-PROPOSAL-1", chainId, height, round, digest]), where digest is the hash +// of the header re-encoded in the EXCLUDE_COMMIT_SEALS form (SPEC 2.3: round kept, commit seals an empty list, the certificate +// and the proposer seal omitted). The index names a row of the v3 registry epoch in force at that height, and that row's id must +// be the header's coinbase. Each header is first re-encoded in the on-chain form (round 0) and must reproduce the hash the +// endpoint reports: that is the control of THIS header's encoding, and a header that fails it is not judged at all. Headers +// without element 6 (every header of a chain, or of heights, before stage 5) are counted and named, never judged. +const PROPOSER_HEADERS = Number(process.env.PROPOSER_HEADERS || 20); +const PROPOSAL_DOMAIN = 'AERE-PQ-PROPOSAL-1'; +const nested = (node) => (node.list ? node.item.map(nested) : Buffer.from(node.item)); +function headerRlp(b, extra) { + const f = [hex(b.parentHash), hex(b.sha3Uncles), hex(b.miner), hex(b.stateRoot), hex(b.transactionsRoot), hex(b.receiptsRoot), + hex(b.logsBloom), scalar(b.difficulty), scalar(b.number), scalar(b.gasLimit), scalar(b.gasUsed), scalar(b.timestamp), extra, + hex(b.mixHash), hex(b.nonce)]; + if (b.baseFeePerGas != null) f.push(scalar(b.baseFeePerGas)); + if (b.withdrawalsRoot != null) f.push(hex(b.withdrawalsRoot)); + if (b.blobGasUsed != null) { f.push(scalar(b.blobGasUsed)); f.push(scalar(b.excessBlobGas)); } + if (b.parentBeaconBlockRoot != null) f.push(hex(b.parentBeaconBlockRoot)); + if (b.requestsHash != null) f.push(hex(b.requestsHash)); + if (b.balHash != null) f.push(hex(b.balHash)); + return rlpEncode(f); +} +let propOk = 0, propBad = 0, propUnmeasured = 0, propAbsent = 0; +{ + const headNow = parseInt(await rpc('eth_blockNumber', []), 16); + for (let k = 0; k < PROPOSER_HEADERS; k++) { + const h = headNow - 2 - k; if (h < 1) break; + const b = await rpc('eth_getBlockByNumber', ['0x' + h.toString(16), false]); + const items = rlpRead(hex(b.extraData), 0).item; + if (items.length < 7) { propAbsent++; continue; } + const onchain = keccak(headerRlp(b, rlpEncode([nested(items[0]), nested(items[1]), nested(items[2]), Buffer.alloc(0), []]))); + if (!onchain.equals(hex(b.hash))) { + propUnmeasured++; + console.log(h + ' PROPOSER SEAL NOT CHECKED HERE: this header does not re-encode to the hash the endpoint reports, so its proposal digest cannot be computed with confidence'); + continue; + } + const seal = items[6]; + if (!seal.list || seal.item.length !== 2 || seal.item[0].list || seal.item[1].list) { propBad++; console.log(h + ' FAIL: the proposer seal is not [index, signature]'); continue; } + const idx = seal.item[0].item.length ? parseInt(Buffer.from(seal.item[0].item).toString('hex'), 16) : 0; + const digestP = keccak(headerRlp(b, rlpEncode([nested(items[0]), nested(items[1]), nested(items[2]), nested(items[3]), []]))); + const msgP = keccak(rlpEncode([Buffer.from(PROPOSAL_DOMAIN, 'ascii'), scalar(CHAIN_ID), scalar(h), Buffer.from(items[3].item), digestP])); + const ep = v3EpochAt(h); + const row = ep && ep.byIndex[idx]; + if (!row) { propBad++; console.log(h + ' FAIL: proposer seal index ' + idx + ' names no row of a published v3 registry epoch in force at this height'); continue; } + if (String(b.miner).toLowerCase() !== row.id) { propBad++; console.log(h + ' FAIL: proposer seal index ' + idx + ' is id ' + row.id + ', but the coinbase is ' + b.miner); continue; } + if (!row.pk || !verifyFalcon512(row.pk, msgP, new Uint8Array(seal.item[1].item))) { propBad++; console.log(h + ' FAIL: the proposer seal of index ' + idx + ' is not a valid Falcon-512 signature over the proposal message'); continue; } + propOk++; + } +} +const propChecked = propOk + propBad + propUnmeasured; console.log(''); +console.log(propChecked === 0 + ? 'proposer seals (AIP-22 stage 5): none of the ' + propAbsent + ' most recent headers carries one; stage 5 is not active at these heights on this chain, so there is nothing to verify' + : 'proposer seals (AIP-22 stage 5): ' + propOk + ' of ' + propChecked + ' recent headers carry a valid Falcon-512 proposal seal by the key of their coinbase' + + (propUnmeasured ? ', ' + propUnmeasured + ' could not be checked here' : '') + (propAbsent ? ' (' + propAbsent + ' earlier headers carry none)' : '')); console.log('seals: ' + sealsValid + '/' + sealsTotal + ' cryptographically valid and validator-bound manifests: ' + (manifests.length - manifestBad) + '/' + manifests.length + ' self-consistent'); -const all = bad === 0 && manifestBad === 0 && unmeasured === 0; +const all = bad === 0 && manifestBad === 0 && unmeasured === 0 && propBad === 0 && propUnmeasured === 0; console.log(all ? 'VERIFIED: ' + ok + '/' + COUNT + ' anchor blocks carry a post-quantum certificate bound under the block hash, every seal a valid ' + ([...schemesSeen].sort().map(x => SCHEME_NAMES[x] || ('scheme ' + x)).join(' or ') || 'post-quantum') + ' signature by a key that the validator of that height bound to itself with its own consensus key.' - : (bad === 0 && manifestBad === 0 - ? 'UNMEASURED: ' + unmeasured + ' of ' + COUNT + ' anchors could not be fully checked ON THIS MACHINE because the SLH-DSA\n' - + ' verifier is missing. Nothing here says the chain is wrong; install it and run again:\n' - + ' npm install @noble/post-quantum' - : 'PROBLEMS: ' + bad + ' of ' + COUNT + ' anchors and ' + manifestBad + ' manifests failed - do not take our word for anything; the failure list above is the finding.' + + (propOk ? ' And ' + propOk + ' of the ' + propOk + ' most recent headers checked carry a valid Falcon-512 proposal seal by the key of their coinbase (AIP-22 stage 5).' : '') + : (bad === 0 && manifestBad === 0 && propBad === 0 + ? (unmeasured + ? 'UNMEASURED: ' + unmeasured + ' of ' + COUNT + ' anchors could not be fully checked ON THIS MACHINE because the SLH-DSA\n' + + ' verifier is missing. Nothing here says the chain is wrong; install it and run again:\n' + + ' npm install @noble/post-quantum' + : '') + + (propUnmeasured ? (unmeasured ? '\n' : '') + 'UNMEASURED: ' + propUnmeasured + ' recent header(s) did not re-encode to the hash the endpoint reports, so their' + + ' proposer seals were not judged. This says nothing about the chain; try another endpoint (RPC=...).' : '') + : 'PROBLEMS: ' + bad + ' of ' + COUNT + ' anchors and ' + manifestBad + ' manifests failed' + (propBad ? ', and ' + propBad + ' proposer seals' : '') + ' - do not take our word for anything; the failure list above is the finding.' + (unmeasured ? ' (' + unmeasured + ' more could not be checked here: npm install @noble/post-quantum)' : ''))); if (!slhDsa) console.log('note: @noble/post-quantum is not installed, so a v2 certificate carrying SLH-DSA seals cannot be verified here (npm install @noble/post-quantum).'); // three states, three exit codes: 0 verified, 1 the chain failed a check, 2 this machine could not measure. -process.exit(all ? 0 : (bad === 0 && manifestBad === 0 ? 2 : 1)); +process.exit(all ? 0 : (bad === 0 && manifestBad === 0 && propBad === 0 ? 2 : 1));