diff --git a/tools/verify-anchor.mjs b/tools/verify-anchor.mjs index ad2570f..1545695 100644 --- a/tools/verify-anchor.mjs +++ b/tools/verify-anchor.mjs @@ -99,6 +99,10 @@ const COMMIT_DOMAIN = 'AERE-PQ-COMMIT-1'; // PARENT for that r. The tool dispatches on the first element of the certificate: a LIST is v1, the scalar 2 is v2, the scalar 3 is // v3; at an anchor at or above H_r only v3 is admitted, below H_r a v3 is refused, and every refusal of a v3 anchor names the code // both clients name (AERE-PQ-V3-01..08, in the fixed order of PROIECT-1B 1.4), so a stranger's verdict can be compared with theirs. +// 2026-10-09: three margins of the v3 rule, each with its note where it applies: R2 in two passes (every Falcon row, then every extra +// row), a row index above 2^31-1 refused 03 at the decoding, and an element 6 that is present but is not a list (03 from H_r). +// They follow the fixed order of the codes named above (R1: the form by height 01 / 02, then the decoding 03, then the digest 04; R2: the +// Falcon rows, then the extra rows). This tool runs neither client: how a client binary answers these margins is not measured here. const COMMIT_DOMAIN_2 = 'AERE-PQ-COMMIT-2'; const ANCHOR_DOMAIN_V3 = 'AERE-PQ-ANCHOR-3'; const MAX_ROUND = 1024; // PqAnchorV3.MAX_ROUND in both clients; no QBFT round timer gets there @@ -565,7 +569,16 @@ for (let i = 0; i < COUNT; i++) { + ' is accepted as written; checked only that its ' + pairs.length + ' seal indices are distinct (' + (pairs.join(',') || 'no seals') + '). Not verified.'); continue; } - if (items.length < 6 || !items[5].list || items[5].item.length === 0) { + // 2026-10-09: an element 6 that is PRESENT but is not an RLP list (a byte string, or the empty string 0x80) is not "no certificate": + // it is a certificate slot that does not decode, so from H_r this tool refuses it with 03 (the decoding), not 02. Until this date it + // was named 02 ("no certificate element"). Below H_r this tool names no v3 code for it: it stays a FAIL without a code, named for what + // it is. Which code, if any, a client binary names for it is not measured here. + if (items.length >= 6 && !items[5].list) { + if (certificateFormAt(h) === 3) console.log(h + ' FAIL: AERE-PQ-V3-03 (certificate v3): element 6 of extraData is present but is not an RLP list (' + items[5].item.length + ' byte string), so it does not decode as a certificate'); + else console.log(h + ' FAIL: element 6 of extraData (the certificate) is present but is not an RLP list (' + items[5].item.length + ' byte string)'); + bad++; continue; + } + if (items.length < 6 || items[5].item.length === 0) { // 1(b): at or above H_r the absence of a certificate is the named refusal 02 (PROIECT-1B 1.4: "at a >= H_r the anchor carries no v3") if (certificateFormAt(h) === 3) console.log(h + ' FAIL: AERE-PQ-V3-02 (certificate v3): no certificate element; from H_r=' + COMMIT_ROUND_BLOCK + ' every anchor must carry a v3 certificate'); else console.log(h + ' FAIL: no certificate element'); @@ -610,7 +623,14 @@ for (let i = 0; i < COUNT; i++) { 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; }; - // R1: the form by height (01, 02), the decoding (03), the digest (04) + // R1: the form by height (01: a v3 below H_r; 02: a v1 / v2 at or above H_r), then the decoding (03), then the digest (04). The form + // by height comes first: a certificate whose first element is the version scalar 3 at an anchor below H_r is 01 whatever the rest of + // its bytes, malformed or not. + // 2026-10-10: this is the order the specification fixes (the first failure returns its code; the form by height before the + // decoding), and both clients now give 01 here too, measured on the shared test vectors ([3, rows] and [3, [], rows] below H_r). For + // a few days an unpublished working copy of this tool gave the decoding first (03); every published copy kept this order. Not the same + // as the clients: this tool reads RLP leniently, so a version scalar written in a non-canonical form (0x8103 for 3) counts as 3 here, + // while both clients' strict readers refuse that framing first and give no code. A header the chain carries never has that form. const parseV3 = () => { if (certificateFormAt(h) !== 3) return refuse('AERE-PQ-V3-01', 'a v3 certificate at anchor ' + h + ', below the round height H_r' + (COMMIT_ROUND_BLOCK === null ? ' (H_r is not set for chain ' + CHAIN_ID + ' in this tool, so no v3 anchor is expected on it; if the fleet has published H_r, pass COMMIT_ROUND_BLOCK=)' : '=' + COMMIT_ROUND_BLOCK)); @@ -632,7 +652,11 @@ for (let i = 0; i < COUNT; i++) { const sb = Buffer.from(s.item[0].item), ib = Buffer.from(s.item[1].item), sig = new Uint8Array(s.item[2].item); if ((sb.length && sb[0] === 0) || (ib.length && ib[0] === 0)) return refuse('AERE-PQ-V3-03', 'row ' + k + ': a scalar with a leading zero byte'); if (sb.length > 1 || !SCHEME_NAMES[Number(scalarOf(sb))]) return refuse('AERE-PQ-V3-03', 'row ' + k + ': unknown scheme tag 0x' + (sb.toString('hex') || '00')); + // 2026-10-09: a seal index is an integer 0..2^31-1 (a signed 32-bit integer) in at most 4 bytes, and anything else is refused + // with 03 at the decoding. Until this date only "at most 4 bytes" was required, so 0x80000000..0xffffffff passed the form and were + // refused only later, at the binding (06) or at the digest (04). if (ib.length > 4) return refuse('AERE-PQ-V3-03', 'row ' + k + ': the index does not fit 32 bits'); + if (scalarOf(ib) > 0x7fffffffn) return refuse('AERE-PQ-V3-03', 'row ' + k + ': the index ' + scalarOf(ib) + ' is above 2^31-1, the largest seal index (a signed 32-bit integer)'); if (sig.length === 0) return refuse('AERE-PQ-V3-03', 'row ' + k + ': empty signature'); const row = { scheme: Number(scalarOf(sb)), idx: Number(scalarOf(ib)), sig }; const prev = rows[rows.length - 1]; @@ -648,10 +672,16 @@ for (let i = 0; i < COUNT; i++) { const digestV3 = keccak(rlpEncode([Buffer.from(ANCHOR_DOMAIN_V3, 'ascii'), scalar(CHAIN_ID), scalar(h - 1), hex(parent.hash), rawCert])); if (!digestV3.equals(vanity)) refuse('AERE-PQ-V3-04', 'the digest in vanityData is not keccak256(RLP["AERE-PQ-ANCHOR-3", chainId, ' + (h - 1) + ', parentHash, certificate]) of the certificate carried (round ' + v3.round + ')'); } - // R2: the Falcon rows against K (05); then, row by row in canonical order, the binding (06) and the signature (07), an extra also - // 08; at the end every required scheme (05). All or nothing: the first failure names the code. Every seal is over M_r of the - // PARENT for the ONE round the certificate names, with the keys in force at the parent (the v3 epoch from KEYS_FROM, else the - // key manifest); the validator of a row must be in the set of the PARENT (D-477) and counts once per scheme. + // R2: the Falcon rows against K (05); then EVERY FALCON ROW in canonical order, the binding (06) and the signature (07); then + // every extra row in canonical order, the scheme (08), its Falcon companion (08), its key (06) and its signature (07); at the end + // every required scheme (05). All or nothing: the first failure names the code. Every seal is over M_r of the PARENT for the ONE + // round the certificate names, with the keys in force at the parent (the v3 epoch from KEYS_FROM, else the key manifest); the + // validator of a row must be in the set of the PARENT (D-477) and counts once per scheme. + // 2026-10-09: TWO PASSES, as the fixed order R2 says (every Falcon row, then every extra row). Until this date there was one pass over + // the rows in wire order (index, scheme): a certificate with two defects, a bad extra row at a LOW index and a bad or non-member Falcon + // seal at a HIGH index, was 08 here instead of 07 or 06. Same verdict, different code. The doors to the parent (its set, its keys) + // are opened at the first FALCON row, on the Falcon branch only; on the extra branch they were already opened by the Falcon pass (an + // extra row without a Falcon row of the same index is 08 first). const MIN_SEALS = minSealsAt(h); const msgR = v3.round === null ? null : commitMessageRound(h - 1, v3.round, hex(parent.hash)); const v3k = h - 1 >= KEYS_FROM ? v3EpochAt(h - 1) : null; // v3: the epoch in force at the parent @@ -663,17 +693,20 @@ for (let i = 0; i < COUNT; i++) { const slhMissing = []; // rows of a scheme this machine cannot verify (no @noble/post-quantum): a gap of this run, never a refusal if (!v3.code && falconRows.length < MIN_SEALS) refuse('AERE-PQ-V3-05', 'only ' + falconRows.length + ' Falcon-512 row(s) at K=' + MIN_SEALS + (falconRows.length === 0 ? ' (an empty certificate is valid only where K is 0)' : '')); const keySource = v3k ? 'the registry v3 epoch ' + v3k.bindHeight : 'the key manifest ' + (man ? man.bindHeight : '?'); - // The two doors to the parent (its set, its keys) are opened at the FIRST row, after the count 05, as PqAnchorV3.judgeSeals - // does: an empty certificate at K=0 is valid without either, and a certificate short of K is refused 05 before 06 - // (2026-10-07, review of step 8: until then "no manifest" was refused 06 before 05, and on the empty certificate too). + // The two doors to the parent (its set, its keys) are opened at the FIRST Falcon row (until 2026-10-09: at the first row of any + // scheme), after the count 05, as PqAnchorV3.judgeSeals does: an empty certificate at K=0 is valid without either, and a + // certificate short of K is refused 05 before 06 (2026-10-07, review of step 8: until then "no manifest" was refused 06 before 05, + // and on the empty certificate too). const keysBound = v3k || (man && man.good); - for (const row of (v3.code ? [] : v3.rows)) { + // the order of R2: every Falcon row (canonical order), then every extra row (canonical order) + const ordineR2 = v3.code ? [] : [...falconRows, ...v3.rows.filter(x => x.scheme !== 1)]; + for (const row of ordineR2) { const name = SCHEME_NAMES[row.scheme]; sealsTotal++; schemesSeen.add(row.scheme); - if (!vset) { refuse('AERE-PQ-V3-06', 'the validator set of the parent ' + (h - 1) + ' could not be resolved: the validator list in element 1 of its header is empty or malformed, so no seal can be bound to a validator'); break; } - if (!keysBound) { refuse('AERE-PQ-V3-06', man ? 'the key manifest in force at block ' + (h - 1) + ' (bindHeight ' + man.bindHeight + ') is not self-consistent, so no index can be bound to a key' : 'no key manifest is in force at block ' + (h - 1)); break; } - const src = v3k ? v3k.byIndex[row.idx] : man.rows[row.idx]; if (row.scheme === 1) { + if (!vset) { refuse('AERE-PQ-V3-06', 'the validator set of the parent ' + (h - 1) + ' could not be resolved: the validator list in element 1 of its header is empty or malformed, so no seal can be bound to a validator'); break; } + if (!keysBound) { refuse('AERE-PQ-V3-06', man ? 'the key manifest in force at block ' + (h - 1) + ' (bindHeight ' + man.bindHeight + ') is not self-consistent, so no index can be bound to a key' : 'no key manifest is in force at block ' + (h - 1)); break; } + const src = v3k ? v3k.byIndex[row.idx] : man.rows[row.idx]; if (!src) { refuse('AERE-PQ-V3-06', 'index ' + row.idx + ' names no row of ' + keySource + ' (in force at block ' + (h - 1) + ')'); break; } const member = v3k ? src.id : memberOf(src.addr.toLowerCase(), h - 1); if (!vset.has(member)) { refuse('AERE-PQ-V3-06', 'the Falcon-512 seal of index ' + row.idx + ' is bound to ' + member + ', which is not in the validator set of block ' + (h - 1) + ', the block the seals sign'); break; } @@ -684,6 +717,9 @@ for (let i = 0; i < COUNT; i++) { } else { if (!namedSchemes.includes(name)) { refuse('AERE-PQ-V3-08', 'a ' + name + ' seal at index ' + row.idx + ', a scheme the schedule does not name at block ' + (h - 1)); break; } if (!falconIdx3.has(row.idx)) { refuse('AERE-PQ-V3-08', 'the ' + name + ' seal of index ' + row.idx + ' rides without a Falcon-512 seal of the same index'); break; } + // the doors were opened by the Falcon pass (this row has a Falcon companion that passed them); kept as a guard + if (!keysBound) { refuse('AERE-PQ-V3-06', 'no key set is in force at block ' + (h - 1) + ' for the ' + name + ' seal of index ' + row.idx); break; } + const src = v3k ? v3k.byIndex[row.idx] : man.rows[row.idx]; if (!src) { refuse('AERE-PQ-V3-06', 'index ' + row.idx + ' names no row of ' + keySource); break; } const pk = src.keys && src.keys[name]; if (!pk) { refuse('AERE-PQ-V3-06', keySource + ' has no ' + name + ' key for index ' + row.idx); break; }