282 lines
17 KiB
JavaScript
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);
|