122 lines
10 KiB
JavaScript
122 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
// Consensus proof verification harness (0.3.16, 6 October 2026; ledger P21): a forged proof record on a private
|
|
// fast-time simnet (ports 29770+, suffix 959; never the live devnet). The object pins the embedded program ids and
|
|
// sets proving_consensus_verify_daa 0. Cases, each asserted:
|
|
// 1. node A (the attacker: IGNEUM_TEST_SKIP_PROOF_RULE=1 turns its own body rule off, and IGNEUM_PROOF_VERIFY=trust
|
|
// makes its pool carry whatever it is given) mines; the harness signs a shard record for a mined block with random proof bytes
|
|
// that hash to proof_hash (the statement is the node's own, so only the proof is fake), submits it to A; A's next
|
|
// template carries it (igneum_getProofRecords shows it carried)
|
|
// 2. node B (peered, the same object, no external verifier either) refuses the carrying block: its log carries
|
|
// "IgneumInvalidProofRecord", its chain never includes that block (eth_getBlockByNumber at the carrier's height
|
|
// differs or B stays below it), and B pays nothing for the shard
|
|
// 3. known-finished: before the forgery, B followed A block for block (same hash at the same height)
|
|
// The positive leg (a real compressed proof verifies, a wrong statement or key is refused) is the unit test in
|
|
// igneum/exec/src/nativeverify.rs on a proof made by the host (IGNEUM_PROOF_FIXTURE); a simnet statement cannot be
|
|
// proven inside this harness's time.
|
|
// Usage: node tools/exec-sync/forged.mjs IGNEUM_EXEC_BIN=<dir with igneumd and igneum-miner>
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, openSync } from 'node:fs';
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
|
|
const ROOT = new URL('../../', import.meta.url).pathname;
|
|
const REL = process.env.IGNEUM_EXEC_BIN || `${ROOT}vendor/igneum-node/target-exec-sync/release`;
|
|
const IGNEUMD = `${REL}/igneumd`;
|
|
const MINER = `${REL}/igneum-miner`;
|
|
const TMP = '/tmp/igneum-exec-forged';
|
|
const BASE = 29770, SUFFIX = 959;
|
|
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
|
|
const MANIFEST = JSON.parse(readFileSync(`${ROOT}proving/igneum-prove/elf/manifest.json`, 'utf8'));
|
|
for (const b of [IGNEUMD, MINER]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
|
|
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
|
|
const override = `${TMP}/override.json`;
|
|
let text = readFileSync(FILE, 'utf8').replace(/\s*"proving_v2_activation_daa":\s*\d+,?/, '').replace(/"skip_proof_of_work":\s*false/, '"skip_proof_of_work": true');
|
|
// the consensus verification switch at 0 under the embedded ids (the text edit keeps the u64::MAX values intact)
|
|
text = text.replace(/\n}\s*$/, `,\n "proving_consensus_verify_daa": 0,\n "proving_shard_program_id": "${MANIFEST.shard.program_id}",\n "proving_aggregator_id": "${MANIFEST.aggregator.program_id}"\n}\n`);
|
|
if (!/"proving_consensus_verify_daa": 0/.test(text)) throw new Error('override edit failed');
|
|
writeFileSync(override, text);
|
|
const t0 = Date.now();
|
|
const log = (m) => console.log(`${new Date().toISOString().slice(11, 23)} t=${((Date.now() - t0) / 1000).toFixed(1)}s ${m}`);
|
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
const hexn = (h) => Number(BigInt(h));
|
|
const started = [];
|
|
class Node {
|
|
constructor(i, connect = [], env = {}) { this.env = env; this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2; this.evmPort = BASE + i * 10 + 3; this.connect = connect; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`; }
|
|
get evm() { return `http://127.0.0.1:${this.evmPort}`; }
|
|
async start() {
|
|
mkdirSync(this.dir, { recursive: true });
|
|
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex', '--unsaferpc',
|
|
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`, `--evm-rpclisten=127.0.0.1:${this.evmPort}`,
|
|
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes', ...this.connect.map(c => `--addpeer=${c}`)];
|
|
const out = openSync(this.logFile, 'a');
|
|
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out], env: { ...process.env, IGNEUM_PROOF_VERIFY: 'trust', ...this.env } });
|
|
started.push(this.proc);
|
|
for (let k = 0; k < 120; k++) { try { await this.eth('eth_chainId'); return this; } catch { await sleep(500); } }
|
|
throw new Error(`node ${this.i} did not answer on ${this.evm}: ${this.logText().slice(-400)}`);
|
|
}
|
|
async stop() { try { this.proc.kill('SIGINT'); } catch { } for (let k = 0; k < 60; k++) { if (this.proc.exitCode !== null) return; await sleep(500); } try { this.proc.kill('SIGKILL'); } catch { } }
|
|
async eth(method, params = []) {
|
|
const r = await fetch(this.evm, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
|
|
const j = await r.json(); if (j.error) throw new Error(`${method}: ${JSON.stringify(j.error)}`); return j.result;
|
|
}
|
|
logText() { try { return readFileSync(this.logFile, 'utf8'); } catch { return ''; } }
|
|
}
|
|
function mine(node, label) {
|
|
const out = openSync(`${TMP}/miner-${label}.log`, 'a');
|
|
const p = spawn(MINER, ['vmine', `grpc://127.0.0.1:${node.grpcPort}`, '3600', '--label', label, '--share', '1', '--bps', '2', '--evm-address', '0x4343434343434343434343434343434343434343'], { stdio: ['ignore', out, out] });
|
|
started.push(p); return p;
|
|
}
|
|
const checks = [];
|
|
const check = (name, ok, detail) => { checks.push({ name, ok }); log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ': ' + JSON.stringify(detail).slice(0, 320) : ''}`); if (!ok) throw new Error(`check failed: ${name}`); };
|
|
async function waitTip(n, want, secs = 600) { for (let k = 0; k < secs * 2; k++) { const tip = hexn(await n.eth('eth_blockNumber')); if (tip >= want) return tip; await sleep(500); } throw new Error(`node ${n.i} did not reach ${want}`); }
|
|
const PAYOUT = '0x4343434343434343434343434343434343434343';
|
|
|
|
async function main() {
|
|
// A is the attacker: its node has the body rule switched off (the harness hook), so it carries the fake proof
|
|
// itself; an unmodified node refuses its own carrying block (seen 20:45Z: the forged record was never carried)
|
|
const a = await new Node(0, [], { IGNEUM_TEST_SKIP_PROOF_RULE: '1' }).start();
|
|
const pin = a.logText().match(/consensus proof verification from DAA score 0 \(shard program id (0x[0-9a-f]+)/);
|
|
check('A starts with consensus proof verification on under the embedded ids', !!pin && pin[1] === MANIFEST.shard.program_id, { line: pin && pin[0].slice(0, 120) });
|
|
const ma = mine(a, 'forger');
|
|
await waitTip(a, 20);
|
|
const b = await new Node(1, [`127.0.0.1:${a.p2pPort}`]).start();
|
|
const h0 = await waitTip(b, 24);
|
|
const [ra, rb] = await Promise.all([a.eth('eth_getBlockByNumber', ['0x' + h0.toString(16), false]), b.eth('eth_getBlockByNumber', ['0x' + h0.toString(16), false])]);
|
|
check('known-finished: B follows A block for block before the forgery', ra.hash === rb.hash, { height: h0 });
|
|
// the forged record: a mined block's shard 0, the node's own statement for the payout, random proof bytes
|
|
const target = hexn(await a.eth('eth_blockNumber')) - 2;
|
|
const plan = await a.eth('igneum_getShardPlan', ['0x' + target.toString(16), PAYOUT]);
|
|
const shard = plan.shards[0];
|
|
check('the shard plan reports the statement for the payout', typeof shard.statement === 'string' && shard.statement.length === 66, { block: target, statement: shard.statement && shard.statement.slice(0, 18) });
|
|
const fake = randomBytes(4096);
|
|
const proofHash = '0x' + createHash('sha256').update(fake).digest('hex');
|
|
const chain = `igneum-devnet-${SUFFIX}`;
|
|
const signed = spawnSync(MINER, ['sign-record', 'forger', chain, plan.hash.replace(/^0x/, ''), String(target), '0', PAYOUT, shard.statement, proofHash], { encoding: 'utf8' });
|
|
const rec = JSON.parse(signed.stdout.trim().split('\n').pop());
|
|
const sub = await a.eth('igneum_submitProofRecord', [{ record: rec.record, proof: '0x' + fake.toString('hex') }]);
|
|
check('A (trust mode) accepts the forged record into its pool', sub.accepted === true, sub);
|
|
// A carries it in a later block; B must refuse that block
|
|
let carrier = null;
|
|
for (let k = 0; k < 120 && !carrier; k++) {
|
|
const pr = await a.eth('igneum_getProofRecords', ['0x' + target.toString(16)]);
|
|
const c = (pr.carried || []).find(x => x.proofHash === proofHash || (x.record && x.record.proofHash === proofHash));
|
|
if (c) carrier = hexn(c.carrierNumber); else await sleep(500);
|
|
}
|
|
check('A carries the forged record in a later block', carrier !== null, { carrier });
|
|
// B: the rule fires; B never has A's carrier block
|
|
let bline = null;
|
|
for (let k = 0; k < 120 && !bline; k++) { bline = b.logText().split('\n').find(l => /IgneumInvalidProofRecord|carried proof record's proof does not verify/.test(l)) || null; if (!bline) await sleep(500); }
|
|
check('B refuses the carrying block: the consensus rule fires', !!bline, { line: bline && bline.slice(-220) });
|
|
await sleep(4000);
|
|
const tipB = hexn(await b.eth('eth_blockNumber'));
|
|
const carrierHashA = (await a.eth('eth_getBlockByNumber', ['0x' + carrier.toString(16), false])).hash;
|
|
let bHasCarrier = false;
|
|
if (tipB >= carrier) { const blk = await b.eth('eth_getBlockByNumber', ['0x' + carrier.toString(16), false]); bHasCarrier = blk && blk.hash === carrierHashA; }
|
|
check("B's chain never includes A's carrying block", !bHasCarrier, { tipB, carrier });
|
|
const paidB = (await b.eth('igneum_getProvingStatus'))['paidShards'];
|
|
check('B pays nothing for the forged shard', hexn(paidB || '0x0') === 0, { paidB });
|
|
try { ma.kill('SIGINT'); } catch { }
|
|
await a.stop(); await b.stop();
|
|
log(`RESULT forged-record harness: PASSED (${checks.length} checks) in ${((Date.now() - t0) / 1000).toFixed(1)} s`);
|
|
}
|
|
main().then(() => cleanup(0)).catch(e => { log(`FAILED: ${e.message}`); cleanup(1); });
|
|
function cleanup(code) { for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } } setTimeout(() => { for (const p of started) { try { p.kill('SIGKILL'); } catch { } } process.exit(code); }, 3000); }
|