diff --git a/tools/verify-anchor.mjs b/tools/verify-anchor.mjs index d0b4e49..873f3b5 100644 --- a/tools/verify-anchor.mjs +++ b/tools/verify-anchor.mjs @@ -43,6 +43,17 @@ // quorum the line says "PARENT DECIDED IN ROUND r (m members of n, quorum Q)", which is what it measured (a QBFT // quorum of commit seals for one round IS the decision), below it "CERTIFIED (participation)". It claims nothing // further: "finality" is the chain's rule, not a measurement of this tool. +// FORM (2026-10-19, AIP-25 findings C4 and C5) the form of a certificate is the chain's, by height, as both clients read it +// (certificateFormAt): v1, RLP[[index, signature], ...] with Falcon-512 seals only, below the v2 height V; v2, +// RLP[2, [[scheme, index, signature], ...]], from V; v3 from H_r. V per chain is in ANCHOR_V2_DEFAULT below (17,047,600 on +// 2800, 176,000 on 28001; another chain has none, which both clients read as "never"); ANCHOR_V2_BLOCK= +// overrides it. A form that does not match its height is refused by name, in both directions, and so is an EMPTY v2 +// certificate at any threshold. The rows of a v1 / v2 certificate are decoded as the clients decode them: exactly two / +// three scalars per row, every scalar canonical (no leading zero byte; 0 is the empty string), the scheme tag one of the +// known ids (0x01, 0x02), the index a whole integer 0..2^31-1 in at most 4 bytes, the signature not empty, at most 64 rows +// in a v2, and the bytes carried the canonical RLP encoding of what was read. Until this date this tool read the tag and +// the index from their FIRST byte (the tag 0x0101 was Falcon-512, the index 0x0100 was 1) and took the form from the bytes +// below H_r, so an endpoint could serve an anchor that both clients refuse and this tool admitted it. // // 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 @@ -55,6 +66,7 @@ // ANCHORS=100 node verify-anchor.mjs # more anchors // MANIFESTS=./manifeste node verify-anchor.mjs # manifests from a local directory instead of the site // RPC=http://127.0.0.1:8545 node verify-anchor.mjs +// ANCHOR_V2_BLOCK=17047600 node verify-anchor.mjs # the v2 height V (default: the chain's own, from the table; "none" = never) // // Exit 0 only when every leg holds on every anchor checked. Any failure is the finding. @@ -89,6 +101,7 @@ const ANCHOR_DOMAIN = 'AERE-PQ-ANCHOR-1'; // every non-Falcon seal must ride with a Falcon seal of the same index (the binding both clients enforce). const ANCHOR_DOMAIN_V2 = 'AERE-PQ-ANCHOR-2'; const SCHEME_NAMES = { 1: 'falcon-512', 2: 'slh-dsa-sha2-128s' }; +const MAX_V2_ROWS = 64; // PqAnchorV2.MAX_SEALS in both clients let slhDsa = null; try { slhDsa = (await import('@noble/post-quantum/slh-dsa.js')).slh_dsa_sha2_128s; } catch { slhDsa = null; } const verifySlhDsa = (pk, msg, sig) => { if (!slhDsa) return false; try { return slhDsa.verify(new Uint8Array(sig), new Uint8Array(msg), new Uint8Array(pk)); } catch { return false; } }; @@ -114,8 +127,20 @@ const COMMIT_ROUND_DEFAULT = CHAIN_ID === 28001 ? 6157024 : null; const COMMIT_ROUND_BLOCK = process.env.COMMIT_ROUND_BLOCK === undefined ? COMMIT_ROUND_DEFAULT : (/^(none|unset|)$/i.test(process.env.COMMIT_ROUND_BLOCK) ? null : Number(process.env.COMMIT_ROUND_BLOCK)); if (COMMIT_ROUND_BLOCK !== null && !(Number.isInteger(COMMIT_ROUND_BLOCK) && COMMIT_ROUND_BLOCK > 0)) { console.error('FAIL: COMMIT_ROUND_BLOCK must be a positive block height or "none"'); stop(1); } -// the form the certificate of anchor a must have (PROIECT-1B 2.1, certificateFormAt): v3 from H_r; below it the bytes decide (v1 or v2) -const certificateFormAt = (a) => (COMMIT_ROUND_BLOCK !== null && a >= COMMIT_ROUND_BLOCK ? 3 : null); +// V per chain (2026-10-19, AIP-25 C5, D-539): aere.pq.anchorV2Block, the height from which every anchor below H_r carries the +// scheme-tagged v2 certificate. Both clients: "A v2 certificate is refused below this height and a v1 one from it" +// (PqAnchorConfig.PROPERTY_ANCHOR_V2_BLOCK; PqAnchorDigestRule / PqAnchorSealsRule and PqAnchorRule.cs judge the form by height). 2800: +// 17,047,600 (the first hybrid anchor, 2026-09-04); 28001: 176,000 (2026-09-03); the same values cloud-gateway/orar-ancore.mjs carries +// for the consumers (FORMA_V2_2800, formaV2DeLa of 28001). Another chain has none, which both clients read as never (v1 below H_r). +// ANCHOR_V2_BLOCK overrides (a height, or "none"). Until this date this tool did not know V: below H_r it took the form from the bytes. +const ANCHOR_V2_DEFAULT = CHAIN_ID === 2800 ? 17047600 : CHAIN_ID === 28001 ? 176000 : null; +const ANCHOR_V2_BLOCK = process.env.ANCHOR_V2_BLOCK === undefined ? ANCHOR_V2_DEFAULT + : (/^(none|unset|)$/i.test(process.env.ANCHOR_V2_BLOCK) ? null : Number(process.env.ANCHOR_V2_BLOCK)); +if (ANCHOR_V2_BLOCK !== null && !(Number.isInteger(ANCHOR_V2_BLOCK) && ANCHOR_V2_BLOCK > 0)) { console.error('FAIL: ANCHOR_V2_BLOCK must be a positive block height or "none"'); stop(1); } +// H_r is above the v2 height on every chain that has one; both clients refuse any other configuration at startup (ROUND-01) +if (COMMIT_ROUND_BLOCK !== null && (ANCHOR_V2_BLOCK === null || COMMIT_ROUND_BLOCK <= ANCHOR_V2_BLOCK)) { console.error('FAIL: COMMIT_ROUND_BLOCK=' + COMMIT_ROUND_BLOCK + ' needs the v2 height below it (ANCHOR_V2_BLOCK is ' + (ANCHOR_V2_BLOCK === null ? 'unset' : ANCHOR_V2_BLOCK) + '); both clients refuse that configuration at startup'); stop(1); } +// the form the certificate of anchor a must have (PROIECT-1B 2.1, certificateFormAt in both clients): 3 from H_r, 2 from V, 1 below V +const certificateFormAt = (a) => (COMMIT_ROUND_BLOCK !== null && a >= COMMIT_ROUND_BLOCK ? 3 : ANCHOR_V2_BLOCK !== null && a >= ANCHOR_V2_BLOCK ? 2 : 1); const POP_DOMAIN = 'AERE-PQ-POP-1'; const CLAIM_DOMAIN = 'AERE-PQ-CLAIM-1'; const FORMAT_VERSION = 2; @@ -239,8 +264,43 @@ const commitMessageRound = (parentNumber, round, parentHash) => keccak(rlpEncode([Buffer.from(COMMIT_DOMAIN_2, 'ascii'), scalar(CHAIN_ID), scalar(parentNumber), scalar(round), parentHash])); // a scalar read from its RLP bytes as an unsigned integer (the canonical-form check is the round trip of the whole certificate below) const scalarOf = (b) => (b.length ? BigInt('0x' + Buffer.from(b).toString('hex')) : 0n); +// the value of a row's scheme tag or index on the v1 / v2 path: the WHOLE scalar (D-538), never its first byte; its width and form are +// judged before it is read (decodeV1V2) +const rowScalar = (b) => Number(scalarOf(b)); // QBFT quorum of a set of n validators, as both clients compute it (BftHelpers.calculateRequiredValidatorQuorum: ceil(2n/3)) const quorumOf = (n) => Math.floor((2 * n + 2) / 3); +// D-538 (2026-10-19, AIP-25 C4): the rows of a v1 or v2 certificate, decoded as both clients decode them: the Besu-derived client through +// its strict RLP reader (QbftExtraDataCodec: readIntScalar refuses a leading zero byte and more than 4 bytes, and the whole extraData must +// re-encode to the bytes received; PqAnchorV2.decode: version 2, at most 64 rows, scheme tag within a byte and known, index not negative), +// the second client through PqAnchor.DecodeCertificate (v1: no leading zero byte, the lone 0x00 included, index at most int.MaxValue, +// signature not empty) and PqAnchorV2.Decode (v2: exactly [2, rows], rows of exactly three scalars, the same scalar rules, known tags, +// signature not empty, the canonical round trip). `node` is element 6 as read by rlpRead, `raw` its bytes as carried. Returns +// { rows: [{ scheme, idx, sig }] } or { why }, the first refusal in the order below. The ORDER of the rows is judged by the caller, as before. +function decodeV1V2(node, isV2, raw) { + const tn = (x) => (x.list ? x.item.map(tn) : Buffer.from(x.item)); + if (isV2) { + const v = Buffer.from(node.item[0].item); + const shape = !(v.length === 1 && v[0] === 2) ? 'the certificate opens with the scalar ' + (v.length ? '0x' + v.toString('hex') : '(the empty string)') + ' where a v2 certificate opens with the version 2' + : node.item.length !== 2 ? 'a v2 certificate has two elements [2, rows]; this one has ' + node.item.length + : !node.item[1].list ? 'the rows element of a v2 certificate is not a list' + : node.item[1].item.length > MAX_V2_ROWS ? node.item[1].item.length + ' rows, above ' + MAX_V2_ROWS + ', the most a v2 certificate carries' : null; + if (shape) return { why: shape }; + } + const rows = []; + const lead = (x) => x !== null && x.length > 0 && x[0] === 0; + for (const [k, s] of (isV2 ? node.item[1].item : node.item).entries()) { + if (!s.list || s.item.length !== (isV2 ? 3 : 2) || s.item.some((x) => x.list)) return { why: 'row ' + k + ' is not ' + (isV2 ? '[scheme, index, signature]' : '[index, signature]') }; + const sb = isV2 ? Buffer.from(s.item[0].item) : null, ib = Buffer.from(s.item[isV2 ? 1 : 0].item), sig = new Uint8Array(s.item[isV2 ? 2 : 1].item); + if (lead(sb) || lead(ib)) return { why: 'row ' + k + ': the ' + (lead(sb) ? 'scheme tag' : 'index') + ' has a leading zero byte (a canonical scalar has none; 0 is the empty string)' }; + if (ib.length > 4 || scalarOf(ib) > 0x7fffffffn) return { why: 'row ' + k + ': the index ' + (ib.length > 4 ? 'does not fit 32 bits (' + ib.length + ' bytes)' : scalarOf(ib) + ' is above 2^31-1, the largest seal index') }; + const scheme = isV2 ? rowScalar(sb) : 1; + if (!SCHEME_NAMES[scheme]) return { why: 'row ' + k + ': unknown scheme tag ' + (sb.length ? '0x' + sb.toString('hex') : '(the empty string, 0)') + ' (the known tags are the single bytes 0x01 falcon-512 and 0x02 slh-dsa-sha2-128s)' }; + if (!sig.length) return { why: 'row ' + k + ': empty signature' }; + rows.push({ scheme, idx: rowScalar(ib), sig }); + } + if (!rlpEncode(tn(node)).equals(raw)) return { why: 'the certificate is not in canonical RLP form (its re-encoding differs from the bytes carried)' }; + return { rows }; +} // PqRegistryBinding.bindingPreimage function bindingPreimage(bindHeight, count, index, address, publicKey) { return Buffer.concat([Buffer.from(POP_DOMAIN, 'ascii'), Buffer.from([FORMAT_VERSION]), u64(CHAIN_ID), u64(bindHeight), @@ -341,6 +401,12 @@ console.log('ANCHORS: ' + COUNT + ' ending at or below ' + top); // 63399 on the testnet, a non-anchor, because that client's vanity never contains 'besu'. const ANCHOR_MOD = Number(process.env.ANCHOR_MOD || (CHAIN_ID === 28001 ? 0 : 16)); const ANCHOR_FROM = Number(process.env.ANCHOR_FROM || (CHAIN_ID === 28001 ? 64 : 13014000)); +// both clients refuse a v2 height below the anchor height at startup (the v2 form cannot take effect before the anchor rules do) +if (ANCHOR_V2_BLOCK !== null && ANCHOR_V2_BLOCK < ANCHOR_FROM) { console.error('FAIL: ANCHOR_V2_BLOCK=' + ANCHOR_V2_BLOCK + ' is below the anchor height ' + ANCHOR_FROM + '; both clients refuse that configuration at startup'); stop(1); } +// (H_r is never set without V: refused above) +console.log('certificate form by height: ' + (ANCHOR_V2_BLOCK === null + ? 'v1 at every anchor (no v2 height for chain ' + CHAIN_ID + ' in this tool; pass ANCHOR_V2_BLOCK= if the chain has one)' + : 'v1 below the v2 height V=' + ANCHOR_V2_BLOCK + ', v2 from V' + (COMMIT_ROUND_BLOCK === null ? '' : ', v3 from H_r=' + COMMIT_ROUND_BLOCK))); // D-336 (2026-09-04): the interval can change at scheduled heights ("h:interval,h:interval", ANCHOR_INTERVAL_SCHEDULE); // every scheduled interval is a multiple of 32 and every change height is on the new grid, so the anchors of a later // regime are a subset of the earlier ones. Default: no change (every 32nd block). Same rule as both clients. @@ -547,7 +613,8 @@ for (let i = 0; i < COUNT; i++) { cursor = prevAnchor(h); const b = await rpc('eth_getBlockByNumber', ['0x' + h.toString(16), false]); const parent = await rpc('eth_getBlockByNumber', ['0x' + (h - 1).toString(16), false]); - const items = rlpRead(hex(b.extraData), 0).item; + const ex = hex(b.extraData); + const items = rlpRead(ex, 0).item; const vanity = Buffer.from(items[0].item); // D-478, the lapse where nothing was in force (chain 2800, 2026-08-10): the nodes accept the header as written after ONE check that // is not relaxed at any height, that the certificate (possibly absent: empty) decodes as [index, signature] pairs with non-negative, @@ -555,14 +622,18 @@ for (let i = 0; i < COUNT; i++) { // is judged, so nothing is verified: the answer is NOT ENFORCED, counted apart from VERIFIED and from FAIL. if (lapseOff(h)) { const w = lapseAt(h); - let pairs = null; + // 2026-10-19 (D-538): the pairs decoded as on every other v1 anchor (decodeV1V2: canonical scalars, an index within 0..2^31-1 in at + // most 4 bytes, a signature, the canonical RLP form), since both clients decode them so inside the lapse too; until this date an + // index was read as its bytes with a leading zero admitted. The ORDER is still not required here, only the distinctness below. + let pairs = null, whyNot = 'it is not an RLP list'; try { const c = items.length >= 6 ? items[5] : { list: true, item: [] }; - if (c.list && c.item.every(s => s.list && s.item.length === 2 && !s.item[0].list && !s.item[1].list && s.item[0].item.length <= 4)) { - pairs = c.item.map(s => (s.item[0].item.length ? parseInt(Buffer.from(s.item[0].item).toString('hex'), 16) : 0)); + if (c.list) { + const d = items.length >= 6 ? decodeV1V2(c, false, ex.subarray(items[4].next, items[5].next)) : { rows: [] }; + if (d.why) whyNot = d.why; else pairs = d.rows.map(r => r.idx); } - } catch (e) { pairs = null; } - if (pairs === null) { bad++; console.log(h + ' FAIL: inside the dated lapse of ' + w.date + ' the certificate must still decode as [index, signature] pairs, and it does not'); continue; } + } catch (e) { pairs = null; whyNot = e.message; } + if (pairs === null) { bad++; console.log(h + ' FAIL: inside the dated lapse of ' + w.date + ' the certificate must still decode as [index, signature] pairs, and it does not: ' + whyNot); continue; } if (new Set(pairs).size !== pairs.length) { bad++; console.log(h + ' FAIL: inside the dated lapse of ' + w.date + ' the certificate repeats a validator index (' + pairs.join(',') + '); distinct indices are required at every height'); continue; } lapseNotEnforced++; console.log(h + ' NOT ENFORCED (dated lapse ' + w.date + '): the anchor rules were not in force at ' + w.first + '..' + w.last + ' on chain 2800, so this header' @@ -619,7 +690,6 @@ for (let i = 0; i < COUNT; i++) { if (judgeV3) { // The bytes of element 6 as carried (element 6 starts where element 5 ends): D3 is over these bytes, and the canonical round trip // below requires them to be exactly the re-encoding of what was read, so no second RLP form of the same certificate is admitted. - const ex = hex(b.extraData); const rawCert = ex.subarray(items[4].next, items[5].next); const v3 = { code: null, why: null, round: null, rows: [] }; const refuse = (code, why) => { v3.code = code; v3.why = why; return false; }; @@ -762,8 +832,28 @@ for (let i = 0; i < COUNT; i++) { continue; } // v1 opens with a LIST ([idx, sig]); v2 opens with the SCALAR 2. Nothing else is accepted. - const isV2 = cert.list && cert.item.length === 2 && !cert.item[0].list; - if (isV2 && !(cert.item[0].item.length === 1 && cert.item[0].item[0] === 2)) { console.log(h + ' FAIL: certificate opens with an unknown version scalar'); bad++; continue; } + // D-539 (2026-10-19, AIP-25 C5): the form by height FIRST, in both directions, as both clients judge it (a v2 below V and a v1 from V + // are refused whatever the rest of the bytes, as R1 refuses a v3 below H_r with 01 before it decodes anything). Until this date the + // bytes chose the form below H_r, so a v1 certificate (Falcon-512 only) at an anchor at or above V was judged as v1 and admitted. + const formWanted = certificateFormAt(h); + const isV2 = !cert.item[0].list; + if ((isV2 ? 2 : 1) !== formWanted) { + bad++; + console.log(h + ' FAIL: certificate form: ' + (formWanted === 2 + ? 'anchor ' + h + ' is at or above the v2 height V=' + ANCHOR_V2_BLOCK + ', where every certificate below H_r is the scheme-tagged v2 RLP[2, rows]; this one is v1 (it opens with a list)' + : ANCHOR_V2_BLOCK === null + ? 'the v2 height V is not set for chain ' + CHAIN_ID + ' in this tool, so every certificate below H_r is v1 (a list of [index, signature]); this one opens with a scalar (the scheme-tagged form). If the fleet has published a v2 height, pass ANCHOR_V2_BLOCK=' + : 'anchor ' + h + ' is below the v2 height V=' + ANCHOR_V2_BLOCK + ', where the certificate is v1 (a list of [index, signature]); this one opens with a scalar (the scheme-tagged form)')); + continue; + } + // D-538 (2026-10-19, AIP-25 C4): the rows decoded as both clients decode them (decodeV1V2). Until this date the scheme tag and the index + // of a row were read from their FIRST byte: the tag 0x0101 was Falcon-512 and the index 0x0100 was 1, where both clients refuse the row. + const decoded = decodeV1V2(cert, isV2, ex.subarray(items[4].next, items[5].next)); + if (decoded.why) { bad++; console.log(h + ' FAIL: certificate decoding (v' + (isV2 ? 2 : 1) + '): ' + decoded.why); continue; } + // D-429 in both clients: from V an EMPTY scheme-tagged certificate is refused at every threshold (the first client's codec cannot + // represent RLP[2, []], the second refuses it by name); until 2026-10-19 this tool admitted it where K is 0 (on 2800 the one such + // height at or above V is the dated step 17,102,384, whose anchor carries nine Falcon-512 seals). + if (isV2 && decoded.rows.length === 0) { bad++; console.log(h + ' FAIL: an empty scheme-tagged certificate RLP[2, []] at anchor ' + h + ' (v2 height V=' + ANCHOR_V2_BLOCK + '): refused at every threshold, K=' + minSealsAt(h) + ' here, as the nodes refuse it'); continue; } const digest = isV2 ? keccak(rlpEncode([Buffer.from(ANCHOR_DOMAIN_V2, 'ascii'), scalar(CHAIN_ID), scalar(h - 1), hex(parent.hash), rlpEncode(toNested(cert))])) : keccak(rlpEncode([Buffer.from(ANCHOR_DOMAIN, 'ascii'), scalar(CHAIN_ID), scalar(h - 1), hex(parent.hash), toNested(cert)])); @@ -774,10 +864,7 @@ for (let i = 0; i < COUNT; i++) { const perScheme = {}; // scheme name -> valid seals, each from a DISTINCT validator const countedBy = {}; // scheme name -> the validators already counted (D-477: a validator counts once per scheme, as in both clients) const falconIdx = new Set(); - const sealNodes = isV2 ? cert.item[1].item : cert.item; - const rowsOf = sealNodes.map(s => isV2 - ? { scheme: (s.item[0].item.length ? s.item[0].item[0] : 0), idx: (s.item[1].item.length ? s.item[1].item[0] : 0), sig: new Uint8Array(s.item[2].item) } - : { scheme: 1, idx: (s.item[0].item.length ? s.item[0].item[0] : 0), sig: new Uint8Array(s.item[1].item) }); + const rowsOf = decoded.rows; // D-478: the one order the production client accepts, at every height outside the lapse where nothing was in force (the window // where only the threshold was lowered included): v1, indices strictly increasing (PqAnchor.hasStrictlyIncreasingIndices, which also // makes a repeated index unrepresentable); v2, (index, scheme) strictly increasing (PqAnchorV2.CANONICAL) @@ -819,7 +906,11 @@ for (let i = 0; i < COUNT; i++) { // scheme that is NAMED at the parent's step but not required at this anchor (off its own grid) is SURPLUS - its seals are // verified above like any other, but it need not reach K; a scheme present in the certificate that the step does not // name at all is refused, as both clients refuse it. - const requiredSchemes = MIN_SEALS > 0 ? schemesRequiredForAnchor(h) : []; + // D-539 (2026-10-19): a v1 certificate carries Falcon-512 only, and at a v1 height both clients judge Falcon-512 only; the scheme + // schedule is asked at a v2 height (PqAnchorSealsRule.judgeV2, PqAnchorRule.cs CheckV2). Until this date the schedule was asked at a v1 + // height too, so where it names a second scheme below V (28001: SLH-DSA from the parent 168,000, V = 176,000) a sound v1 anchor was a + // FAIL "missing scheme" (measured 2026-10-19 on 28001: 20 of 20 anchors 175,360 .. 175,968; with this line, 20 of 20 verified). + const requiredSchemes = MIN_SEALS > 0 ? (isV2 ? schemesRequiredForAnchor(h) : ['falcon-512']) : []; const namedSchemes = schemesAt(h - 1); const shortSchemes = requiredSchemes.filter(nm => (perScheme[nm] || 0) > 0 && perScheme[nm] < MIN_SEALS).map(nm => nm + ':' + perScheme[nm]); const missingSchemes = requiredSchemes.filter(nm => !(perScheme[nm] > 0));