From 089dc9803c5858f39d66f2d62aa35920035dc5e0 Mon Sep 17 00:00:00 2001 From: Aere Network Date: Wed, 23 Sep 2026 16:05:24 +0300 Subject: [PATCH] verify-anchor.mjs: AIP-22 stage 3b read path, inert until an index marks it - from KEYS_FROM (or the v3 epoch whose index entry carries keysFrom) the key and the id of a seal's index come from the v3 epoch in force at the parent, as both clients read them; the ECDSA link of an id is inherited across epochs that omit it, and an id linked again drops its older address. Without keysFrom anywhere nothing changes: chain 2800 5/5, testnet 28001 5/5 re-run on 2026-09-23. --- tools/verify-anchor.mjs | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/tools/verify-anchor.mjs b/tools/verify-anchor.mjs index 63bdf8a..a1fdb50 100644 --- a/tools/verify-anchor.mjs +++ b/tools/verify-anchor.mjs @@ -328,7 +328,7 @@ if (IDENTITY_OFF) console.log('identity: IDENTITY_BLOCK=none, v3 epochs ignored if (!IDENTITY_OFF) { const sources = process.env.REGISTRY_V3 ? [{ loc: process.env.REGISTRY_V3, sha: null, file: process.env.REGISTRY_V3 }] - : [...v3Entries].sort((a, b) => Number(a.bindHeight) - Number(b.bindHeight)).map(m => ({ loc: join(MANIFESTS, m.file), sha: String(m.sha256).toLowerCase(), file: m.file })); + : [...v3Entries].sort((a, b) => Number(a.bindHeight) - Number(b.bindHeight)).map(m => ({ loc: join(MANIFESTS, m.file), sha: String(m.sha256).toLowerCase(), file: m.file, keysFrom: m.keysFrom === true || m.keysFrom === 'true' })); if (sources.length) { let reg; try { reg = await import('./registry-v3.mjs'); } @@ -342,8 +342,11 @@ if (!IDENTITY_OFF) { 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); } 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); } - for (const row of r.rows) { + 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)); @@ -351,9 +354,22 @@ if (!IDENTITY_OFF) { 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); } } + // 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 link = new Map(link); + const idsNow = new Set(r.rows.map(row => '0x' + row.id.toString('hex'))); + for (const row of r.rows) if (row.ecdsa) { const idh = '0x' + row.id.toString('hex'); for (const [a, i] of link) if (i === idh) link.delete(a); } for (const row of r.rows) if (row.ecdsa) link.set('0x' + row.ecdsa.addr.toString('hex'), '0x' + row.id.toString('hex')); - V3_EPOCHS.push({ bindHeight: r.bindHeight, link }); + for (const [a, i] of link) if (!idsNow.has(i)) link.delete(a); + // AIP-22 stage 3b: the key set of this epoch by index (Falcon-512 without its 0x09 header, the other schemes by name) and the + // index's id; used for seals from KEYS_FROM on, exactly as both clients read keys from the v3 epoch in force at the parent + const byIndex = r.rows.map(row => { + const keys = {}; + for (const k of row.keys) if (k.name !== 'falcon-512') keys[k.name] = new Uint8Array(k.pk); + const f = row.keys.find(k => k.name === 'falcon-512'); + return { id: '0x' + row.id.toString('hex'), pk: f ? new Uint8Array(f.pk.subarray(1)) : null, keys }; + }); + V3_EPOCHS.push({ bindHeight: r.bindHeight, link, byIndex, keysFrom: s.keysFrom === true }); console.log(' v3 epoch ' + String(r.bindHeight).padEnd(10) + ' ' + String(s.file).padEnd(26) + ' rows ' + r.count + '/' + r.count + ' registry hash 0x' + r.hash.toString('hex').slice(0, 16) + '.. every possession proof, ecdsa link and the Merkle hash verified; keys = the key manifest of that height'); previous = r; @@ -362,6 +378,10 @@ if (!IDENTITY_OFF) { console.log('identity: from block ' + IDENTITY_BLOCK + ' the validator set holds post-quantum ids (' + V3_EPOCHS.length + ' v3 epoch(s))'); } } +// the height from which the keys that seal come from the v3 epochs (AIP-22 stage 3b): KEYS_FROM, or the epoch the index marks +const KEYS_FROM = process.env.KEYS_FROM ? Number(process.env.KEYS_FROM) : (V3_EPOCHS.find(e => e.keysFrom)?.bindHeight ?? Infinity); +if (Number.isFinite(KEYS_FROM)) console.log('keys: from block ' + KEYS_FROM + ' the keys that seal come from the v3 epoch in force (AIP-22 stage 3b)'); +const v3EpochAt = (h) => [...V3_EPOCHS].reverse().find(x => x.bindHeight <= h) || null; const memberOf = (addr, h) => { if (h < IDENTITY_BLOCK || !V3_EPOCHS.length) return addr; const e = [...V3_EPOCHS].reverse().find(x => x.bindHeight <= h) || V3_EPOCHS[0]; @@ -423,11 +443,13 @@ for (let i = 0; i < COUNT; i++) { sealsTotal++; const name = SCHEME_NAMES[scheme]; if (!name) { unknownScheme.push(scheme); continue; } - const row = man && man.rows[idx]; + // AIP-22 stage 3b: from KEYS_FROM the index names the row of the v3 epoch in force at the PARENT (its id and its keys) + const v3k = h - 1 >= KEYS_FROM ? v3EpochAt(h - 1) : null; + const row = v3k ? v3k.byIndex[idx] : (man && man.rows[idx]); if (!row) { unknownIndex.push(idx); continue; } - if (!vset.has(memberOf(row.addr.toLowerCase(), h))) { notValidator.push(idx); continue; } + if (!vset.has(v3k ? row.id : memberOf(row.addr.toLowerCase(), h))) { notValidator.push(idx); continue; } if (scheme === 1) { - if (!verifyFalcon512(row.pk, msg, sig)) { invalid.push('falcon@' + idx); continue; } + if (!row.pk || !verifyFalcon512(row.pk, msg, sig)) { invalid.push('falcon@' + idx); continue; } } else { if (!falconIdx.has(idx)) { unbound.push(name + '@' + idx); continue; } const pk = row.keys && row.keys[name];