igneum/tools/attack/f7-era/reroll.mjs
2026-10-07 08:55:30 +00:00

282 lines
17 KiB
JavaScript

#!/usr/bin/env node
// Attack-pass row F7 (docs/plans/cryptanalysis.md 4.2), the node-lane half: the era-draw re-roll harness on the fast-time
// 3-node network (infra/fast-time/override-60x.json with skip_proof_of_work, the class-v4-signal.mjs shape).
//
// What the node does today for the era draw input (0.3.17/0.3.18 line, consensus/src/consensus/mod.rs `seed_below`):
// era seed E_n = the hash of the last selected-chain block whose DAA score is below 15,552,000 n - 7,200 (era 0: genesis)
// epoch seed = the hash of the last selected-chain block whose DAA score is below 60 e - 10 on the 60x file
// The same function, two cut scores; no VDF and no certified checkpoint exist in the node yet (era-layout.md section 8).
// The era cut is 180 days of DAA score away on every profile (POW_ERA_BLOCKS is a constant, not an override field), so
// this harness attacks the epoch cut, which is the identical derivation at a reachable score, one cut per minute.
//
// The adversary (a miner with one block of hash at the right second): when the template's DAA score is S - 1 it takes a
// template and HOLDS the block A. When the honest block H at S - 1 lands (the sink passes the cut), it evaluates the draw
// of seed(H) after a stub VDF of --vdf-ms (0 = the stand-in, no delay; the real design needs the 3,600 s class-group VDF
// before the draw of a candidate input is known) and then either withholds A (policy pref: seed(H) is to its liking) or
// publishes A to re-roll the seed. A re-roll SUCCEEDS when the chain's reported seed for the epoch is hash(A): A beat H
// on the GHOSTDAG tie (equal blue work, the higher hash wins, consensus/src/processes/ghostdag/ordering.rs) before H had a
// child, i.e. inside the honest block interval, the 1 to 2 s publish window.
//
// Known-pass (the harness fires): --vdf-ms 0 --policy always: re-rolls succeed in about half the cuts (the tie).
// Known-fail (the honest case): --vdf-ms 5000 --policy always: every A arrives after H has children; 0 successes.
//
// node reroll.mjs --vdf-ms <n> [--policy always|pref] [--cuts 12] [--secs 1200] [--genesis-bits 0x1d100000]
// IGNEUMD names the node binary (default: the ladder fork's release build on igneum-build-1).
// Ports 29800 and up, network igneum-devnet-980, data /tmp/igneum-fast-time-attack-f7 (box scratch of this lane only).
import { spawn } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
import { connectRpc } from '../../finality-attacks/lib/rpc.mjs';
import { Miner, voteKeyHashFor } from '../../harness/lib/miner.mjs';
import { submitReport } from '../../harness/lib/rpc.mjs';
import { devAddress } from '../../harness/lib/address.mjs';
const ROOT = new URL('../../../', import.meta.url).pathname;
const FILE = process.env.IGNEUM_OVERRIDE_60X || `${ROOT}infra/fast-time/override-60x.json`;
const IGNEUMD = process.env.IGNEUMD || '/srv/builds/igneum-wt-ladder/vendor/igneum-node-ladder/target/release/igneumd';
const TMP = process.env.IGNEUM_F7_TMP || '/tmp/igneum-fast-time-attack-f7';
const OUT = process.env.IGNEUM_F7_OUT || TMP;
const BASE = 29800, SUFFIX = 980;
const args = process.argv.slice(2);
const flag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? +args[i + 1] : dflt; };
const sflag = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : dflt; };
const VDF_MS = flag('vdf-ms', 0);
const POLICY = sflag('policy', 'always');
const CUTS = flag('cuts', 12);
const SECS = flag('secs', 1500);
const GENESIS_BITS = flag('genesis-bits', 0x1d100000);
const TAG = sflag('tag', `vdf${VDF_MS}-${POLICY}`);
if (!['always', 'pref'].includes(POLICY)) { console.error('usage: --vdf-ms <n> [--policy always|pref] [--cuts N]'); process.exit(2); }
if (!existsSync(IGNEUMD)) { console.error(`missing ${IGNEUMD}`); process.exit(2); }
const started = [];
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// ---- the draw in JS (spec 01 sections 1.3.1, 1.3.2, 1.13.1; era-layout.md 1.1), checked against the Rust census at start ----
const M64 = (1n << 64n) - 1n;
function fnvSalt0(bytes) {
let h = 0xcbf29ce484222325n;
for (const x of bytes) { h = ((h ^ BigInt(x)) * 0x100000001b3n) & M64; }
h ^= h >> 33n; h = (h * 0xff51afd7ed558ccdn) & M64; h ^= h >> 33n;
return h; // words[0] | words[1] << 32 of seed_words_from_bytes: the era stream's seed
}
class SplitMix { constructor(s) { this.s = s & M64; } next() { this.s = (this.s + 0x9E3779B97F4A7C15n) & M64; let z = this.s; z = ((z ^ (z >> 30n)) * 0xBF58476D1CE4E5B9n) & M64; z = ((z ^ (z >> 27n)) * 0x94D049BB133111EBn) & M64; return z ^ (z >> 31n); } below(n) { return this.next() % BigInt(n); } }
function eraDraw(eraBytes) {
const pre = [...Buffer.from('igneum-era/', 'utf8'), ...eraBytes];
const seed = fnvSalt0(pre);
const s = new SplitMix(seed);
s.below(1); // the width draw, pinned set {1}
const M = Number((s.next() & 0xffffffffn) | 1n) >>> 0;
const R = 1 + Number(s.below(31));
const r = [s.next(), s.next(), s.next(), s.next()];
const c = []; for (let i = 0; i < 16; i++) c.push(i);
for (let i = 0; i < 4; i++) { const n = c.length - i; const j = i + Number(r[i] % BigInt(n)); [c[i], c[j]] = [c[j], c[i]]; }
const pos = c.slice(0, 4).sort((a, b) => a - b);
return { seed, M, R, pos };
}
function selfCheck() {
// from census-2p20.log (attack-f7 census on igneum-build-1): seed b62532bc... draws M 558c0543 R 4 pos [0,1,2,3]
const e = eraDraw(Buffer.from('b62532bc9bb83b386be48f9df264bad3f8afb70fbb0db775b18f687b2da7f8e2', 'hex'));
const ok = e.M === 0x558c0543 && e.R === 4 && e.pos.join() === '0,1,2,3';
log(`draw self-check against the Rust census: M ${e.M.toString(16)} R ${e.R} pos [${e.pos}] -> ${ok ? 'OK' : 'MISMATCH'}`);
if (!ok) process.exit(3);
}
// the adversary's preference: a balanced predicate on the draw (the low bit of the stride multiplier's bit 1 is as good as any)
const pref = (hashHex) => (eraDraw(Buffer.from(hashHex, 'hex')).M & 2) === 0;
// ---- network ----
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true }); mkdirSync(OUT, { recursive: true });
const baseText = readFileSync(FILE, 'utf8');
const field = (name) => { const m = new RegExp(`"${name}":\\s*([0-9]+)`).exec(baseText); return m ? +m[1] : undefined; };
const EPOCH = field('pow_epoch_blocks');
const LEAD = field('pow_epoch_lead');
function mergeOverrideText(text, fields) {
let out = text;
for (const k of Object.keys(fields)) out = out.replace(new RegExp(`\\s*"${k}":\\s*[^,}\\n]+,?`), '');
const extra = Object.entries(fields).map(([k, v]) => `"${k}": ${typeof v === 'string' && !/^\d+$/.test(v) ? JSON.stringify(v) : v}`).join(', ');
return out.replace(/,?\s*}\s*$/, `,\n ${extra}\n}\n`);
}
const override = `${TMP}/override.json`;
writeFileSync(override, mergeOverrideText(baseText, { genesis_bits: GENESIS_BITS, skip_proof_of_work: true }));
class Node {
constructor(i, connect = []) {
this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2;
this.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`;
}
get json() { return `ws://127.0.0.1:${this.jsonPort}`; }
async start() {
mkdirSync(this.dir, { recursive: true });
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex',
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`,
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes'];
if (this.connect.length) a.push(`--connect=${this.connect.join(',')}`); else a.push('--outpeers=0');
const out = openSync(this.logFile, 'a');
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out] });
started.push(this.proc);
writeFileSync(`${OUT}/pids-${TAG}.txt`, started.map(p => p.pid).join('\n') + '\n');
await sleep(1200);
this.rpc = await connectRpc(this.json);
log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} p2p ${this.p2pPort}`);
return this;
}
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
}
async function stopAll() {
for (const m of miners) { try { m.stop(); } catch { } }
for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } }
await sleep(1500);
for (const p of started) { try { p.kill('SIGKILL'); } catch { } }
}
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
process.on('unhandledRejection', async (e) => { log(`FAILED: ${e?.stack || e}`); await stopAll(); process.exit(3); });
selfCheck();
const t0 = Date.now();
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
const n0 = await new Node(0).start();
const n1 = await new Node(1, [`127.0.0.1:${n0.p2pPort}`]).start();
const n2 = await new Node(2, [`127.0.0.1:${n0.p2pPort}`]).start();
const nodes = [n0, n1, n2];
log(`n0 PoW schedule: ${n0.grepLog(/PoW schedule/).map(l => l.replace(/^.*?PoW schedule/, 'PoW schedule')).join(' | ') || '(no line)'}; epoch ${EPOCH} DAA, lead ${LEAD}; cuts at S = ${EPOCH} e - ${LEAD}`);
// honest production: two virtual miners sharing 1 block/s (the devnet rate) on n0 and n1
const miners = [];
for (const [n, label] of [[n0, 'honest-a'], [n1, 'honest-b']]) {
const m = new Miner({ node: n, share: 0.5, bps: 1, label });
await m.start(); miners.push(m);
}
const advRpc = n2.rpc;
const advAddr = devAddress('attack-f7-adversary');
const advKey = voteKeyHashFor('attack-f7-adversary');
async function dagInfo(n) { return n.rpc.call('getBlockDagInfo'); }
async function getBlock(n, hash) { const r = await n.rpc.call('getBlock', { hash, includeTransactions: false }); return r.block || r; }
const hdr = (b) => b.header || {};
const vd = (b) => b.verboseData || b.verbose_data || {};
const daaOf = (b) => +(hdr(b).daaScore ?? hdr(b).daa_score);
const spOf = (b) => vd(b).selectedParentHash ?? vd(b).selected_parent_hash;
const hashOf = (b) => vd(b).hash;
async function chainBlockBelow(n, score) {
// the node's seed_below: from the sink down the selected parents to the first block with DAA score under `score`
const d = await dagInfo(n);
let cur = d.sink;
for (let k = 0; k < 4096; k++) {
const b = await getBlock(n, cur);
if (daaOf(b) < score) return b;
const sp = spOf(b);
if (!sp || sp === cur) return b;
cur = sp;
}
return null;
}
// the reported epoch seed the node would use for epoch e: its own template field, cross-checked with the walk
async function reportedEpochSeed(n, e) {
try {
const t = await n.rpc.call('getBlockTemplate', { payAddress: advAddr, extraData: [] });
const pe = t.powEpoch || t.pow_epoch || {};
if (+pe.epochIndex === e && pe.epochSeed) return String(pe.epochSeed);
} catch { }
const score = e * EPOCH - LEAD;
const b = await chainBlockBelow(n, score);
return b ? hashOf(b) : null;
}
// the adversary's block A, found on node n by its unique nonce and DAA score, read for its hash
async function findAdversaryHash(n, lowHash, nonce) {
try {
const r = await n.rpc.call('getBlocks', { lowHash, includeBlocks: true, includeTransactions: false });
for (const b of (r.blocks || [])) {
if (String(hdr(b).nonce) === String(nonce)) return { hash: hashOf(b), daa: daaOf(b) };
}
} catch { }
return { hash: null, daa: null };
}
async function virtualDaa(n) {
try { const t = await n.rpc.call('getBlockTemplate', { payAddress: advAddr, extraData: [] }); return +(t.powEpoch || t.pow_epoch || {}).virtualDaaScore; } catch { return 0; }
}
// Hold one block at the cut and try to make it the epoch's seed block. Returns a record for the cut.
async function attackCut(e) {
const score = e * EPOCH - LEAD; // the node's seed_below cut for epoch e: the last chain block below `score`
const target = score - 1; // the seed block sits at this DAA score
let tmpl = null;
for (let k = 0; k < 600; k++) {
try {
tmpl = await advRpc.call('getBlockTemplate', { payAddress: advAddr, extraData: [] });
const s = +(tmpl.powEpoch || tmpl.pow_epoch || {}).virtualDaaScore;
if (s >= target) break;
} catch { }
await sleep(100);
}
// A: the adversary's candidate block built on the current tip, held private (nonce unique per cut so A is findable)
const A = tmpl.block;
A.header.voteKeyHash = advKey;
const nonce = 0xA77ac70000 + e;
A.header.nonce = nonce;
// the honest seed block the node sees for this epoch right now (its hash is below `score`, used as the getBlocks anchor)
const before = await reportedEpochSeed(n0, e);
// the stub VDF the design requires before the draw of a candidate input is known (0 = the stand-in, no delay; the real
// design needs the 3,600 s class-group VDF, so a candidate's draw is not known for an hour and the window is 2 s)
if (VDF_MS > 0) await sleep(VDF_MS);
let publish = true;
if (POLICY === 'pref') {
// publish only when A's own hash would give a preferred draw and the honest seed would not (needs A's hash: resolve it
// from a dry build on node 2 first). Kept simple: in 'pref' the adversary still must have A on hand, so publish and judge
// after; the distinguishing run is 'always'.
publish = true;
}
let submit = 'not-published';
if (publish) {
try { submit = submitReport(await advRpc.call('submitBlock', { block: A, allowNonDaaBlocks: false })); }
catch (e2) { submit = `error:${e2.message}`; }
}
const a = await findAdversaryHash(n0, before, nonce);
const aHash = a.hash;
// wait until the chain has advanced a few blocks past the cut, so the "last chain block below score" is stable
for (let k = 0; k < 160; k++) { if (await virtualDaa(n0) >= score + 3) break; await sleep(250); }
const after = await reportedEpochSeed(n0, e);
// a re-roll by the adversary: its own block A is the epoch's seed block (it steered the draw to a value it chose)
const toA = !!(after && aHash && after === aHash);
// the seed also differs from the honest one it first read (context: the cut was not yet settled), not itself an attack
const changed = !!(before && after && after !== before);
return { epoch: e, cut_score: score, honest_seed: before, final_seed: after, adversary_block: aHash, adversary_block_daa: a.daa, submit, published: publish, reroll_to_adversary: toA, seed_changed_from_first_read: changed };
}
// begin at a cut comfortably in the future, so the adversary builds A on the tip at S - 1 (not behind a settled chain)
let startDaa = 0;
for (let k = 0; k < 60; k++) { startDaa = await virtualDaa(n0); if (startDaa > 0) break; await sleep(500); }
const firstE = Math.floor(startDaa / EPOCH) + 2;
log(`start virtual daa ${startDaa}; attacking cuts for epochs ${firstE}..${firstE + CUTS - 1} (S = ${firstE * EPOCH - LEAD} and up)`);
const records = [];
for (let e = firstE; e < firstE + CUTS && Date.now() - t0 < SECS * 1000; e++) {
try {
const r = await attackCut(e);
records.push(r);
log(`cut epoch ${e} (S ${r.cut_score}): honest ${String(r.honest_seed).slice(0, 12)} final ${String(r.final_seed).slice(0, 12)} A ${String(r.adversary_block).slice(0, 12)} submit ${r.submit} ${r.reroll_to_adversary ? 'RE-ROLLED (seed = A)' : (r.seed_changed_from_first_read ? 'seed settled elsewhere' : 'held')}`);
} catch (e2) { log(`cut epoch ${e}: ${e2.message}`); }
}
await sleep(2000);
const sinks = (await Promise.all(nodes.map(n => dagInfo(n).catch(() => ({}))))).map(d => String(d.sink || '?').slice(0, 16));
const accepted = records.filter(r => r.submit === 'accepted');
const rerolls = records.filter(r => r.reroll_to_adversary); // the adversary's own block became the epoch seed block
// Gate (plan 4.2 F7): no re-roll inside the publish window. The sound signal is the adversary steering the seed to its own
// block; natural seed churn before the cut settles is not an attack.
const gatePass = rerolls.length === 0;
const expectReroll = VDF_MS === 0; // the known-pass case must fire; the honest case (a real VDF delay) must not
const harnessSound = expectReroll ? rerolls.length > 0 : rerolls.length === 0;
const summary = {
tag: TAG, vdf_ms: VDF_MS, policy: POLICY, cuts_attempted: records.length, genesis_bits: GENESIS_BITS,
epoch_blocks: EPOCH, lead: LEAD, adversary_blocks_accepted: accepted.length,
rerolls_to_adversary_block: rerolls.length, seed_changed_cuts: records.filter(r => r.seed_changed_from_first_read).length,
gate_no_reroll_in_window: gatePass, expect_reroll: expectReroll, harness_sound: harnessSound,
sinks, sinks_agree: new Set(sinks).size === 1, node: IGNEUMD, records,
};
writeFileSync(`${OUT}/reroll-${TAG}.json`, JSON.stringify(summary, null, 2));
log(`SUMMARY ${TAG}: ${records.length} cuts, ${accepted.length} adversary blocks accepted, ${rerolls.length} re-rolls to A; gate(no re-roll in window) ${gatePass ? 'PASS' : 'FAIL'}; expect re-roll ${expectReroll} -> harness ${harnessSound ? 'SOUND' : 'UNSOUND'}; sinks ${sinks.join(' ')} (${summary.sinks_agree ? 'agree' : 'DIFFER'})`);
log(`summary: ${OUT}/reroll-${TAG}.json`);
await stopAll();
// exit 0 when the run is internally consistent (harness sound); the gate verdict is in the summary, read per run
process.exit(harnessSound ? 0 : 1);