diff --git a/tools/verify-anchor.mjs b/tools/verify-anchor.mjs index 33fe08b..ec203cc 100644 --- a/tools/verify-anchor.mjs +++ b/tools/verify-anchor.mjs @@ -78,10 +78,24 @@ if (!K_SCHEDULE.length) K_SCHEDULE.push(...K_DEFAULT); // D-338 (2026-09-04): the SCHEMES the certificate must carry, by height of the PARENT (the seals are made when the // parent is committed), exactly as both clients read them. Without this the tool verified only the seals it found, // so a certificate that dropped a whole scheme passed. SCHEME_SCHEDULE ("h:scheme+scheme,...") overrides. -const SCHEME_DEFAULT = CHAIN_ID === 28001 ? '168000:falcon-512+slh-dsa-sha2-128s' : '17047568:falcon-512+slh-dsa-sha2-128s'; +// 28001, 2026-09-23: AIP-22 stage 2 rehearsal from 3,070,656 - Falcon-512 at every anchor (every 32nd block again), +// SLH-DSA only at every 128th; chain 2800 unchanged. +const SCHEME_DEFAULT = CHAIN_ID === 28001 ? '168000:falcon-512+slh-dsa-sha2-128s,3070656:falcon-512/32+slh-dsa-sha2-128s/128' : '17047568:falcon-512+slh-dsa-sha2-128s'; +// AIP-22 stage 2 (2026-09-23): a scheme may carry its own interval, "h:falcon-512/32+slh-dsa-sha2-128s/128". Such a scheme +// is REQUIRED only at anchors on its own grid ((anchor - ANCHOR_FROM) % interval == 0); at the other anchors it is still +// NAMED, so its seals, when present, are verified but need not reach K (surplus), exactly as both clients judge it. A scheme +// without "/interval" is required at every anchor (the rule before stage 2). Intervals are positive multiples of 32. const SCHEME_SCHEDULE = (process.env.SCHEME_SCHEDULE || SCHEME_DEFAULT).split(',').filter(Boolean) - .map(p => { const [h, list] = p.split(':'); return [Number(h), list.split('+').map(x => x.trim()).filter(Boolean)]; }).sort((a, b) => a[0] - b[0]); -const schemesAt = (hh) => { let out = ['falcon-512']; for (const [from, list] of SCHEME_SCHEDULE) { if (from <= hh) out = list; else break; } return out; }; + .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); } +} +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); +// the schemes an anchor at height a must carry at K: the step in force at the PARENT (seals are made on the parent's commit), +// filtered by each scheme's own grid; the grid is counted from ANCHOR_FROM, the same anchor block both clients use +const schemesRequiredForAnchor = (a) => stepAt(a - 1).filter(s => s.interval === 0 || (a - ANCHOR_FROM) % s.interval === 0).map(s => s.name); const minSealsAt = (hh) => Number(process.env.MIN_SEALS || K_SCHEDULE.filter(([h]) => hh >= h).map(([, k]) => k).pop()); const COUNT = Number(process.env.ANCHORS || 20); @@ -294,9 +308,26 @@ 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); +} +const memberOf = (addr, h) => (V3_LINK && h >= IDENTITY_BLOCK ? (V3_LINK.get(addr) ?? addr) : addr); + const validatorsCache = new Map(); async function validatorsAt(h) { - const key = Math.floor(h / 1000); + // the identity switch changes the form of the set; one cache window must never hold both forms + const key = Math.floor(h / 1000) + (h >= IDENTITY_BLOCK ? ':id' : ':addr'); if (!validatorsCache.has(key)) { const v = await rpc('qbft_getValidatorsByBlockNumber', ['0x' + h.toString(16)]); validatorsCache.set(key, new Set((v || []).map(a => a.toLowerCase()))); @@ -350,7 +381,7 @@ for (let i = 0; i < COUNT; i++) { if (!name) { unknownScheme.push(scheme); continue; } const row = man && man.rows[idx]; if (!row) { unknownIndex.push(idx); continue; } - if (!vset.has(row.addr.toLowerCase())) { notValidator.push(idx); continue; } + if (!vset.has(memberOf(row.addr.toLowerCase(), h))) { notValidator.push(idx); continue; } if (scheme === 1) { if (!verifyFalcon512(row.pk, msg, sig)) { invalid.push('falcon@' + idx); continue; } } else { @@ -365,12 +396,19 @@ for (let i = 0; i < COUNT; i++) { const n = rowsOf.length; const MIN_SEALS = minSealsAt(h); const falconValid = perScheme['falcon-512'] || 0; - const shortSchemes = Object.entries(perScheme).filter(([, c]) => c < MIN_SEALS).map(([nm, c]) => nm + ':' + c); - // D-338: every scheme the schedule names at the parent's height must reach K on its own; a scheme with NO seal at - // all is named as missing, not silently skipped. When K is 0 at this height (a dated step) nothing is required. - const requiredSchemes = MIN_SEALS > 0 ? schemesAt(h - 1) : []; + // D-338: every scheme the schedule REQUIRES at this anchor must reach K on its own; a scheme with NO seal at all is named + // as missing, not silently skipped. When K is 0 at this height (a dated step) nothing is required. AIP-22 stage 2: a + // 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) : []; + 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)); - const schemesOk = falconValid >= MIN_SEALS && shortSchemes.length === 0 && missingSchemes.length === 0 && (!isV2 || slhDsa || !rowsOf.some(r => r.scheme === 2)); + const unnamedSchemes = isV2 ? [...new Set(rowsOf.map(r => SCHEME_NAMES[r.scheme]).filter(nm => nm && !namedSchemes.includes(nm)))] : []; + const falconNeeded = requiredSchemes.includes('falcon-512') || requiredSchemes.length === 0; + const schemesOk = (!falconNeeded || falconValid >= MIN_SEALS) && shortSchemes.length === 0 && missingSchemes.length === 0 + && unnamedSchemes.length === 0 && (!isV2 || slhDsa || !rowsOf.some(r => r.scheme === 2)); const good = bound && man && man.good && schemesOk && valid === n; // blocat DE MEDIU: tot ce se putea verifica aici e in regula, si singurul lucru care lipseste e verificatorul // SLH-DSA. Orice alt defect (digest, manifest, sigiliu invalid, prag) il scoate din categoria asta. @@ -395,7 +433,8 @@ for (let i = 0; i < COUNT; i++) { else { bad++; console.log(h + ' FAIL:' + (bound ? '' : ' digest mismatch') + (man ? (man.good ? '' : ' manifest not self-consistent') : ' no manifest for this height') + - (falconValid < MIN_SEALS ? ' only ' + falconValid + ' valid Falcon seals (K=' + MIN_SEALS + ')' : '') + (shortSchemes.length ? ' short schemes ' + shortSchemes.join(',') : '') + (missingSchemes.length ? ' missing scheme ' + missingSchemes.join(',') + ' (required at ' + (h - 1) + ')' : '') + + (falconNeeded && falconValid < MIN_SEALS ? ' only ' + falconValid + ' valid Falcon seals (K=' + MIN_SEALS + ')' : '') + (shortSchemes.length ? ' short schemes ' + shortSchemes.join(',') : '') + (missingSchemes.length ? ' missing scheme ' + missingSchemes.join(',') + ' (required at anchor ' + h + ', step in force at ' + (h - 1) + ')' : '') + + (unnamedSchemes.length ? ' scheme(s) not named by the schedule at ' + (h - 1) + ': ' + unnamedSchemes.join(',') : '') + (invalid.length ? ' invalid seals ' + invalid.join(',') : '') + (unbound.length ? ' extra seals without a Falcon seal of the same index ' + unbound.join(',') : '') + (noKey.length ? ' manifest has no key for ' + noKey.join(',') : '') + (unknownScheme.length ? ' unknown scheme tag ' + unknownScheme.join(',') : '') + (notValidator.length ? ' keys not in validator set at index ' + notValidator.join(',') : '') + (unknownIndex.length ? ' unknown index ' + unknownIndex.join(',') : '') +