From 91a169662f1114415b739b24cb659230e81aa502 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:55:30 +0000 Subject: [PATCH] attack-pass F7: census and day-key seeding PASS at 2^24; re-roll harness INCOMPLETE pending the era VDF, named as a freeze precondition Co-Authored-By: Claude Fable 5.1 --- docs/analysis/attack-pass/f7-era.md | 165 +++++++++ tools/attack/f7-era/Cargo.lock | 14 + tools/attack/f7-era/Cargo.toml | 19 + tools/attack/f7-era/reroll.mjs | 282 +++++++++++++++ tools/attack/f7-era/src/main.rs | 533 ++++++++++++++++++++++++++++ 5 files changed, 1013 insertions(+) create mode 100644 docs/analysis/attack-pass/f7-era.md create mode 100644 tools/attack/f7-era/Cargo.lock create mode 100644 tools/attack/f7-era/Cargo.toml create mode 100644 tools/attack/f7-era/reroll.mjs create mode 100644 tools/attack/f7-era/src/main.rs diff --git a/docs/analysis/attack-pass/f7-era.md b/docs/analysis/attack-pass/f7-era.md new file mode 100644 index 000000000..27c1f13af --- /dev/null +++ b/docs/analysis/attack-pass/f7-era.md @@ -0,0 +1,165 @@ +# F7: the era-draw bias harness and the era-seed census + +Attack-pass row F7 (`docs/plans/cryptanalysis.md` 4.2; the record `docs/analysis/attack-pass-2026-10.md`), both halves: +the fast-time re-roll harness (node lane) and the 2^20 era-seed census plus the 64-bit day-key check (hash lane). +Written 7 October 2026. Every number cites its log path on igneum-build-1. + +## Target + +| Item | Value | +|---|---| +| Commit | `924288d1` on branch `attack-pass` (`igneum-pow` is byte-identical at worktree HEAD `11b375a0`: `git diff --stat 924288d1..HEAD -- igneum-pow docs/spec infra/fast-time` is empty) | +| Spec | `docs/spec/01-lottery-hash.md` 1.13.1 (era seed and draw), 1.8.4 (the day-key mixer stream); `docs/spec/04-seeds-and-vdf.md` 4.4 (era seed pipeline) and 4.6 (T from a reference core); `docs/plans/era-layout.md` sections 1 and 8 (branch `ca2-era`); `docs/analysis/horizon/algorithm.md` 5.4 | +| Draw code | `igneum_pow::generator::era_draw` over `V3_ALLOWED = [1]` (the chain's path): the width draw (consumed, pinned at 4 bytes), the odd stride multiplier `M`, the rotation `R` in 1..31, the four interleave positions `pos` by partial Fisher-Yates | +| Day-key code | `igneum_pow::memhard::MixParams::with_shape`: `SplitMix64::new(K[0] \| (K[1] << 32))` draws ROT[0..7], MUL[0..15], RC[0..15]; `K = seed_words_from_bytes("igneum-day/" \|\| day_le64)` (node fork `consensus/pow/src/igneum.rs`, `bind::day_bytes`) | +| Node draw input (today) | `consensus/src/consensus/mod.rs` `seed_below`: `E_n` is the hash of the last selected-chain block below `15,552,000 n - 7,200` (era 0: genesis). The 1-hour VDF of spec 4.4 and the certified checkpoint it reads do NOT exist in the node (era-layout.md section 8, `proto-vdf` is a prototype) | + +## Sub-row verdicts + +| Sub-row | Verdict | Gate (plan 4.2 F7) | +|---|---|---| +| (a) re-roll harness | INCOMPLETE, with the written argument | no re-roll inside the publish window | +| (b) 2^20 era-seed census | PASS | no era class with gain over 1.1x at a fraction over 2^-20 | +| (c) 64-bit day-key seeding | PASS, within spec intent (one observation recorded) | the draw's input set as the spec states it | + +## (a) The re-roll harness (node lane) + +`tools/attack/f7-era/reroll.mjs`: a 3-node fast-time network (`infra/fast-time/override-60x.json` with +`skip_proof_of_work`, the `class-v4-signal.mjs` shape), own ports 29800 and up, own devnet suffix 980, own data dir +`/tmp/igneum-fast-time-attack-f7`. The node binary is the ladder fork `vendor/igneum-node-ladder` at `1591ee1d` +(`igneumd 2.1.0`, already built on the box; read-only). Two honest virtual miners share 1 block/s on nodes 0 and 1; the +adversary on node 2 holds a block `A` built on the tip at DAA score `S - 1` (the seed block sits there), optionally waits +a stub VDF of `--vdf-ms`, then publishes `A` to try to make its own block the epoch's seed block (the last selected-chain +block below the cut `S`). A re-roll succeeds when the epoch's reported seed becomes `hash(A)`. + +The era cut `15,552,000 n - 7,200` is 180 days of DAA score away on every profile (`POW_ERA_BLOCKS` is a chain constant, +not an override field), so the harness attacks the EPOCH cut (`60 e - 10` at 60x), which runs the identical `seed_below` +derivation at a reachable score, one cut per minute. The harness's own era draw (JS) is checked byte-for-byte against the +Rust census at start: seed `b62532bc...` draws `M 558c0543 R 4 pos [0,1,2,3]` on both (log line "draw self-check ... OK"). + +Firings (both runs 6 cuts, box cores 36-37,84-85 under the shared measure lock): + +| Run | `--vdf-ms` | Re-rolls to A | Gate | Harness | Log | +|---|---|---|---|---|---| +| known-pass | 0 (no delay, the stand-in) | 1 of 6 (epoch 11, seed = A) | FAIL | SOUND (fires) | `/srv/builds/igneum-wt-attack/attack-f7/reroll-knownpass.log` | +| known-fail | 5,000 (a delay past one block interval) | 0 of 6 | PASS | SOUND (silent) | `/srv/builds/igneum-wt-attack/attack-f7/reroll-knownfail.log` | + +Both runs: 6 of 6 adversary blocks accepted, all three sinks agree, no reorg of the honest chain. The harness fires on the +known-pass and is silent on the known-fail, so it is trusted. + +Written argument (the plan allows one for the VDF's assumptions; the VDF's own delay soundness belongs to the finality +review row of `funding.md`). The re-roll is possible ONLY when the adversary can evaluate the draw of a candidate input +inside the block publish window. Today the node has no VDF: `E_n` is a plain block hash, so the input of any candidate +block is known the instant the block is built, and the harness shows the last-block-before-the-cut is grindable with one +block of hash (1 of 6 cuts steered in fast time, `--vdf-ms 0`). With any delay past one honest block interval the +re-roll is gone (`--vdf-ms 5000`: 0 of 6). The design closes this with the 1-hour class-group VDF of spec 4.4: re-rolling +by withholding needs the 3,600 s VDF evaluated inside the 2 s window, a 1,800x evaluator, and spec 4.6's margin table +gives 300x as the horizon (`algorithm.md` 5.4; `sim/horizon/algorithm/model.py --section era`). The forge route needs +2/3 of the 30-day weight, 20 days of 100 percent hash (CLAUDE.md headline). The sub-row is INCOMPLETE because the harness +cannot demonstrate the real gate: the VDF and the certified checkpoint it reads are not in the node yet (era-layout.md +section 8 states this). What the harness DOES establish: the C_era cut rule with no delay is grindable, so the era draw's +soundness rests entirely on the VDF landing before the draw procedure is frozen, and the delay-soundness measurement is +owed to the finality lane. + +## (b) The 2^20 era-seed census (hash lane) + +`tools/attack/f7-era/` (a cargo crate with `igneum-pow` as a path dependency and an empty `[workspace]`; ELF built on the +box, sha256 `a87818d8...`). `attack-f7 census` runs `era_draw` over `V3_ALLOWED` on `2^n` seeds and classifies each draw; +`attack-f7 all` runs the plant known-fail case, the census, the spec-stream op-weight census and the day-key check. + +Known-fail / known-pass of the classifier (planted parameters through a test hook in this crate; log +`/srv/builds/igneum-wt-attack/attack-f7/census-2p20.log`): every planted weak draw fires its flag (M = 1, M = 2^32-1, +M = 2^16+1, a naf-2 multiplier, an even M, R = 0, R = 32, pos linear, pos contiguous, pos not ascending) and a sound draw +(igneum-era-test/0) raises nothing. "Plant verdict: every planted case fired and the sound draw did not." + +Census results (2^24 = 16,777,216 draws, the stronger run; `census-2p24.log`; the 2^20 run agrees, `census-2p20.log`): + +| Class | Count (2^24) | Fraction | Expected (uniform) | Chip gain | +|---|---|---|---|---| +| M even (bijection failure) | 0 | 0 | 0 | finding if present: none | +| R out of 1..31 | 0 | 0 | 0 | finding if present: none | +| pos invalid (not 4 ascending) | 0 | 0 | 0 | finding if present: none | +| M = 1 (identity stride) | 0 | 0 | 4.66e-10 | 1.0034x | +| M = 2^32 - 1 | 0 | 0 | 4.66e-10 | 1.0030x | +| popcount(M) <= 2 | 1 | 5.96e-8 (2^-24) | 1.49e-8 | 1.0030x | +| popcount(M) <= 4 | 43 | 2.56e-6 (2^-18.6) | 2.33e-6 | 1.0022x | +| popcount(M) <= 6 | 1,626 | 9.69e-5 | 9.61e-5 | 1.0014x | +| popcount(M) <= 8 | 27,749 | 1.65e-3 | 1.66e-3 | 1.0007x | +| naf(M) <= 2 | 1 | 5.96e-8 | - | 1.0030x | +| naf(M) <= 3 | 18 | 1.07e-6 | - | 1.0026x | +| M = 2^k + 1 | 1 | 5.96e-8 | 1.44e-8 | 1.0030x | +| pos linear [0,1,2,3] | 9,257 | 5.52e-4 | 5.50e-4 | 1.0000x | +| pos contiguous | 120,054 | 7.16e-3 | 7.14e-3 | 1.0000x | +| pos in the low byte | 645,856 | 3.85e-2 | 3.85e-2 | 1.0000x | + +The gain metric is the datapath energy a chip saves per hash against the base weights, over the hash's datapath energy +(19.5 nJ at 100,000 ops x 0.195 pJ, the N5 floor of `algorithm.md` 5.4 / `model.py --section era`). The stride multiply is +one of three address operations, run 128 times per hash (16 loads x 8 iterations); a low-weight `M` replaces the multiplier +with a few shift-adds, worth at most 128 x 0.52 pJ = 67 pJ, so M = 1 is the richest corner at 1.0034x. The rotation is a +wire mux and the interleave an address-line permute, 0 pJ on the modelled chip. No drawn parameter touches the memory +bound, the item derivation, the load count or N. + +Gate: no class with gain over 1.1x at a fraction over 2^-20. The richest gain in the whole classifier is 1.0034x (M = 1), +and M = 1 did not occur in 2^24 draws (expected 4.66e-10). Every class at a fraction over 2^-20 has gain 1.0000x to +1.0007x. PASS on both counts. + +Uniformity of the draw (2^24): stride rotation R over 1..31 chi-square 38.5 on 30 dof (max bucket deviation 2.07 sigma, +R = 0 or 32 seen 0 times); interleave pos 1,820 of 1,820 four-subsets seen, chi-square 1,775.7 on 1,819 dof (max deviation +3.63 sigma, 0 draws with a non-4-subset); M bit 0 always set (odd by construction), bits 1..31 each set in 0.500 of draws +(worst bit 1.81 sigma); the stride bijection never failed (0 even M). The era stream's own 64-bit seed (words 0 and 1) was +distinct on all 2^24 draws. + +Op-weight corners (spec 1.13.1 first stream, implemented in `attack-f7 spec` from the spec text because `igneum-pow` does +not draw the op-weight perturbation at this commit; 2^20 draws, `census-2p20.log`): the ten non-load weights each +perturbed by -2..+2 and renormalised to 75 move the multiply share (mul+mad+mulhi, base 22 of 75) between 15 and 31. The +richest corner for a chip is 15/75 (0.152 pJ per op, -22 percent of the base datapath), seen once in 2^20; 16/75 at +3.22e-3. The GPU's energy moves the same way (its IMAD is the chain's own op), so the chip-against-GPU gain of every +weight corner is 1.0x, with 0 memory effect. Renormalised sums were 75 on every draw (0 failures). Fold rotations: a triple +all equal 2.13e-3, both triples all equal 1.91e-6, all six equal 0; uniform over 1..31, rotation 0 never drawn; a wire +mux, 1.0x. + +## (c) The 64-bit seeding of the day-key stream (hash lane) + +`attack-f7 days` over days 0..131,072 (`census-2p20.log`). The day key `K` is `seed_words_from_bytes("igneum-day/" || +day_le64)`: a calendar function, no chain state. All 256 bits of `K` enter the cache fill (spec 1.8.3, `K[0..7]` in every +block input), so the dataset depends on the full key; the mixer-constant stream (ROT, MUL, RC) is seeded from `K[0] | +(K[1] << 32)`, 64 bits, which is the spec's stated intent (spec 1.8.4). + +| Quantity | Value | +|---|---| +| Days the chain can have | about 65,745 in 180 years at 1 block/s (2^16.0) | +| Distinct 256-bit keys K over 2^17 days | 131,072 (all) | +| Distinct 64-bit stream seeds over 2^17 days | 131,072 (0 duplicates) | +| Distinct (ROT, MUL, RC) tuples over 2^17 days | 131,072 | +| Birthday bound on a 64-bit collision among 2^16 days | 2^(32 - 65) = 2^-33 | + +The spec intends 64 bits for the mixer-constant draw, and the truncation is not a reduction of the draw space the firm +would flag: at most 2^16 days are ever drawn, each a distinct calendar day with a distinct 64-bit seed (0 collisions in +2^17), so no two days share a mixer. One observation, within spec intent and recorded for the written argument of +`funding.md` B5 rank 6: the mixer-constant stream has 64 bits of seed entropy, so at most 2^64 distinct daily mixers are +reachable (not the ~2^1,047 nominal); this is not exploitable (the days used are 2^16, all distinct) and whether any +reachable tuple is weak is the separate weak-day census of row F4. + +## Consequences per tier + +The era draw and the day-key seeding are protocol-wide and do not differ by card tier: the load width and load count are +pinned, so every era is equally memory-bound and no 8, 12, 16 or 24/32 GB card is advantaged or disadvantaged by any draw +(the measured six-era hash-rate spread is 1.3 percent on the RTX 5090, 3.2 on the RX 9070 XT, 0.8 on the M5 Max, +`algorithm.md` 5.4). No drawn era parameter or day key makes a chip cheaper against a GPU: the richest datapath corner is +1.0034x and is shared with the GPU. The one operational consequence is for the protocol, not a miner tier: the era draw's +grinding resistance is not yet demonstrable because the 1-hour VDF and its certified checkpoint are not in the node, so +the freeze of the draw procedure and the C_era cut rule must wait on the VDF landing and the finality lane's delay- +soundness measurement. + +## Gate line + +- (a) harness: INCOMPLETE. No re-roll with a one-block delay (known-fail 0 of 6); a re-roll with no delay (known-pass 1 of + 6). The real gate (no re-roll inside the 2 s window) rests on the 1-hour VDF, which is not in the node; written argument + above. +- (b) census: PASS. No era class with gain over 1.1x at any fraction (richest 1.0034x, M = 1, absent in 2^24); the draw is + a bijection on every sample and uniform in R, pos and the M bits. +- (c) 64-bit seeding: PASS within spec intent. The spec intends 64 bits for the mixer stream; 2^16 days are all distinct; + the one observation (2^64 reachable mixers) is recorded, not a flaw. + +What a failure moves (plan 4.2 F7): the draw procedure or the C_era cut rule; a redraw rule for the era stream. Nothing in +(b) or (c) moves them. (a) moves nothing in shipped code but gates the freeze of the draw procedure on the VDF. diff --git a/tools/attack/f7-era/Cargo.lock b/tools/attack/f7-era/Cargo.lock new file mode 100644 index 000000000..5090a336b --- /dev/null +++ b/tools/attack/f7-era/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "attack-f7" +version = "0.1.0" +dependencies = [ + "igneum-pow", +] + +[[package]] +name = "igneum-pow" +version = "0.2.0" diff --git a/tools/attack/f7-era/Cargo.toml b/tools/attack/f7-era/Cargo.toml new file mode 100644 index 000000000..48fdd68a2 --- /dev/null +++ b/tools/attack/f7-era/Cargo.toml @@ -0,0 +1,19 @@ +# Attack-pass row F7 (docs/plans/cryptanalysis.md 4.2): the era-seed census and the day-key seeding checks. +# Built on igneum-build-1 through tools/build-remote.sh (the Mac cannot build this repo's lock file). +[package] +name = "attack-f7" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "attack-f7" +path = "src/main.rs" + +[dependencies] +igneum-pow = { path = "../../../igneum-pow" } + +[workspace] + +[profile.release] +opt-level = 3 diff --git a/tools/attack/f7-era/reroll.mjs b/tools/attack/f7-era/reroll.mjs new file mode 100644 index 000000000..8c2282c68 --- /dev/null +++ b/tools/attack/f7-era/reroll.mjs @@ -0,0 +1,282 @@ +#!/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 [--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 [--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); diff --git a/tools/attack/f7-era/src/main.rs b/tools/attack/f7-era/src/main.rs new file mode 100644 index 000000000..374d25a47 --- /dev/null +++ b/tools/attack/f7-era/src/main.rs @@ -0,0 +1,533 @@ +//! Attack-pass row F7 (`docs/plans/cryptanalysis.md` section 4.2), the hash-lane half: the era-seed census and the +//! day-key seeding checks. Record: `docs/analysis/attack-pass/f7-era.md`. +//! +//! Sub-commands: +//! census [--n 1048576] [--seeds raw|test] [--plant] 2^n era seeds through the real draw (`igneum_pow::generator::era_draw` +//! over `V3_ALLOWED`, the chain's path), classified; `--plant` runs the +//! classifier on planted weak parameters first (the known-fail case) +//! spec [--n 1048576] the first era stream of spec 01 section 1.13.1 (op-weight perturbation, +//! fold rotations, the consumed epoch_len draw), implemented here from the +//! spec's text because igneum-pow does not draw it +//! days [--n 131072] the 64-bit seeding of the day-key stream: distinct seeds over the days +//! the chain can have, and the same check on the era stream +//! all the three in order with the defaults +//! +//! Every number is printed as a table row so the log is the record. Nothing here edits igneum-pow. + +use igneum_pow::bind::day_bytes; +use igneum_pow::generator::{era_draw, EraParams, Op, NONLOAD_WEIGHTS, V3_ALLOWED}; +use igneum_pow::memhard::MixParams; +use igneum_pow::seed::{seed_words_from_bytes, SplitMix64}; +use std::collections::HashSet; + +/// Chip datapath energy per op at the N5 floor (`sim/horizon/algorithm/model.py` section era: 0.52 mul, 0.06 add, pJ). +const MUL_PJ: f64 = 0.52; +const ADD_PJ: f64 = 0.06; +/// Multiply share of the ten non-load weights (mul 8 + mad 8 + mulhi 6 of 75; `generator::NONLOAD_WEIGHTS`). +const MULT_SHARE_BASE: u64 = 22; +const NONLOAD_SUM: u64 = 75; +/// Counted ops per class v4 hash (`docs/analysis/latency-shadow-2026-10-06.md`: the M5 Max binds at about 100,000 ops +/// per hash on sh256x27). +const OPS_PER_HASH: f64 = 100_000.0; +/// Dataset loads per hash: 16 load slots x 8 iterations (spec 01 section 1.7). +const LOADS_PER_HASH: f64 = 128.0; + +fn base_pj_per_op() -> f64 { + (MULT_SHARE_BASE as f64 * MUL_PJ + (NONLOAD_SUM - MULT_SHARE_BASE) as f64 * ADD_PJ) / NONLOAD_SUM as f64 +} + +/// Datapath energy of one hash at the base weights, pJ. +fn hash_pj() -> f64 { + OPS_PER_HASH * base_pj_per_op() +} + +fn arg(args: &[String], name: &str) -> Option { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned()) +} + +fn hex32(b: &[u8; 32]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +/// A raw 32-byte era seed for census index `i`: four SplitMix64 words of a domain-separated state. Stands in for a +/// VDF output (uniform bytes); the draw hashes them through `seed_words_from_bytes` whatever their origin. +fn raw_era_bytes(i: u64) -> [u8; 32] { + let mut s = SplitMix64::new(i.wrapping_mul(0x9E3779B97F4A7C15) ^ 0xF7E7A5EEDC0DE001); + let mut out = [0u8; 32]; + for k in 0..4 { + out[k * 8..k * 8 + 8].copy_from_slice(&s.next().to_le_bytes()); + } + out +} + +fn era_bytes(i: u64, test: bool) -> [u8; 32] { + if test { + EraParams::test_era_bytes(&format!("igneum-era-test/{i}")) + } else { + raw_era_bytes(i) + } +} + +/// Non-adjacent-form weight of a 32-bit multiplier: the number of shift-add or shift-subtract terms a chip needs. +fn naf_weight(m: u32) -> u32 { + let mut x = m as u64; + let mut w = 0; + while x != 0 { + if x & 1 == 1 { + let z = 2 - (x % 4) as i64; // +1 or -1 + x = (x as i64 - z) as u64; + w += 1; + } + x >>= 1; + } + w +} + +/// The weak classes an era draw can fall in, as flags. +#[derive(Default, Clone, Copy, Debug)] +struct Flags { + m_even: bool, + r_out_of_range: bool, + pos_invalid: bool, + m_one: bool, + m_all_ones: bool, + m_pop_le2: bool, + m_pop_le4: bool, + m_pop_le6: bool, + m_pop_le8: bool, + m_naf_le2: bool, + m_naf_le3: bool, + m_pow2_plus1: bool, + pos_linear: bool, + pos_contiguous: bool, + pos_low_byte: bool, +} + +fn classify(e: &EraParams) -> Flags { + let m = e.stride_mul; + let pop = m.count_ones(); + let naf = naf_weight(m); + let pos = e.pos; + let valid = pos.iter().all(|&p| p < 16) && (1..4).all(|i| pos[i] > pos[i - 1]); + Flags { + m_even: m & 1 == 0, + r_out_of_range: !(1..=31).contains(&e.stride_rot), + pos_invalid: !valid, + m_one: m == 1, + m_all_ones: m == u32::MAX, + m_pop_le2: pop <= 2, + m_pop_le4: pop <= 4, + m_pop_le6: pop <= 6, + m_pop_le8: pop <= 8, + m_naf_le2: naf <= 2, + m_naf_le3: naf <= 3, + m_pow2_plus1: m != 1 && pop == 2 && m & 1 == 1, + pos_linear: pos == [0, 1, 2, 3], + pos_contiguous: valid && (1..4).all(|i| pos[i] == pos[i - 1] + 1), + pos_low_byte: valid && pos.iter().all(|&p| p < 8), + } +} + +/// Chip gain of a class, as the datapath energy a chip saves per hash against the base, over the hash's datapath +/// energy. The stride multiply is one of three address operations per load (spec 01 section 1.13.1); a chip evaluates +/// `x * M` with a shift-add tree of `naf(M)` terms, so a low-weight M saves `MUL_PJ - (naf - 1) * ADD_PJ` per load, and +/// M = 1 saves the whole multiply. The rotation is a wire mux and the interleave an address-line permute: 0 pJ on a chip +/// with a programmable decoder (`algorithm.md` 5.4). No class touches the memory bound, the item derivation or N. +fn gain_of(name: &str) -> (f64, &'static str) { + let per_load = |saved_pj: f64| 1.0 + (LOADS_PER_HASH * saved_pj.max(0.0)) / hash_pj(); + match name { + "m_one" => (per_load(MUL_PJ), "the stride multiply disappears (128 of about 100,000 ops)"), + "m_all_ones" => (per_load(MUL_PJ - ADD_PJ), "x * (2^32 - 1) = -x, one negate per load"), + "m_pop_le2" | "m_pow2_plus1" | "m_naf_le2" => (per_load(MUL_PJ - ADD_PJ), "one shift-add per load in place of a multiplier"), + "m_naf_le3" => (per_load(MUL_PJ - 2.0 * ADD_PJ), "two shift-adds per load"), + "m_pop_le4" => (per_load(MUL_PJ - 3.0 * ADD_PJ), "three shift-adds per load (upper bound)"), + "m_pop_le6" => (per_load(MUL_PJ - 5.0 * ADD_PJ), "five shift-adds per load (upper bound)"), + "m_pop_le8" => (per_load(MUL_PJ - 7.0 * ADD_PJ), "seven shift-adds per load (upper bound)"), + "pos_linear" | "pos_contiguous" | "pos_low_byte" => (1.0, "an address-line permute; 0 pJ on the modelled chip; a hard-wired linear chip runs only this class of era"), + "m_even" | "r_out_of_range" | "pos_invalid" => (f64::NAN, "a bijection or range failure: a finding, not a gain"), + _ => (1.0, ""), + } +} + +struct Census { + n: u64, + counts: Vec<(&'static str, u64)>, + first_seed: Vec<(&'static str, Option)>, + r_hist: [u64; 33], + pos_hist: Vec, // indexed by the 16-bit mask of the four positions + m_bit_ones: [u64; 32], + stream_seeds: HashSet, + lowest_pop: (u32, u32, Option), +} + +const CLASS_NAMES: [&str; 15] = [ + "m_even", + "r_out_of_range", + "pos_invalid", + "m_one", + "m_all_ones", + "m_pop_le2", + "m_pop_le4", + "m_pop_le6", + "m_pop_le8", + "m_naf_le2", + "m_naf_le3", + "m_pow2_plus1", + "pos_linear", + "pos_contiguous", + "pos_low_byte", +]; + +fn flags_vec(f: &Flags) -> [bool; 15] { + [ + f.m_even, + f.r_out_of_range, + f.pos_invalid, + f.m_one, + f.m_all_ones, + f.m_pop_le2, + f.m_pop_le4, + f.m_pop_le6, + f.m_pop_le8, + f.m_naf_le2, + f.m_naf_le3, + f.m_pow2_plus1, + f.pos_linear, + f.pos_contiguous, + f.pos_low_byte, + ] +} + +/// The expected fraction of each class under a uniform draw (M uniform odd, R uniform 1..31, pos a uniform 4-subset). +fn expected_fraction(name: &str) -> Option { + let odd_space = 2f64.powi(31); + let c = |n: u64, k: u64| -> f64 { + let mut r = 1f64; + for i in 0..k { + r = r * (n - i) as f64 / (i + 1) as f64; + } + r + }; + // M odd: bit 0 set; the other 31 bits uniform. popcount(M) <= k means <= k-1 of the 31 high bits set. + let pop_le = |k: u64| -> f64 { (0..k).map(|j| c(31, j)).sum::() / odd_space }; + Some(match name { + "m_even" | "r_out_of_range" | "pos_invalid" => 0.0, + "m_one" | "m_all_ones" => 1.0 / odd_space, + "m_pop_le2" => pop_le(2), + "m_pop_le4" => pop_le(4), + "m_pop_le6" => pop_le(6), + "m_pop_le8" => pop_le(8), + "m_pow2_plus1" => 31.0 / odd_space, + "pos_linear" => 1.0 / 1820.0, + "pos_contiguous" => 13.0 / 1820.0, + "pos_low_byte" => 70.0 / 1820.0, + _ => return None, + }) +} + +fn pos_mask(pos: &[u8; 4]) -> usize { + pos.iter().fold(0usize, |m, &p| m | (1 << (p as usize & 15))) +} + +fn run_census(n: u64, test: bool) -> Census { + let mut c = Census { + n, + counts: CLASS_NAMES.iter().map(|&s| (s, 0u64)).collect(), + first_seed: CLASS_NAMES.iter().map(|&s| (s, None)).collect(), + r_hist: [0; 33], + pos_hist: vec![0u64; 1 << 16], + m_bit_ones: [0; 32], + stream_seeds: HashSet::with_capacity(n as usize), + lowest_pop: (33, 0, None), + }; + for i in 0..n { + let eb = era_bytes(i, test); + let e = era_draw(&eb, &V3_ALLOWED); + let f = classify(&e); + let fv = flags_vec(&f); + for (k, hit) in fv.iter().enumerate() { + if *hit { + c.counts[k].1 += 1; + if c.first_seed[k].1.is_none() { + c.first_seed[k].1 = Some(format!("{} (M {:08x} R {} pos {:?})", hex32(&eb), e.stride_mul, e.stride_rot, e.pos)); + } + } + } + c.r_hist[(e.stride_rot as usize).min(32)] += 1; + c.pos_hist[pos_mask(&e.pos)] += 1; + for b in 0..32 { + if (e.stride_mul >> b) & 1 == 1 { + c.m_bit_ones[b] += 1; + } + } + c.stream_seeds.insert(e.words[0] as u64 | ((e.words[1] as u64) << 32)); + let pop = e.stride_mul.count_ones(); + if pop < c.lowest_pop.0 { + c.lowest_pop = (pop, e.stride_mul, Some(format!("{} (M {:08x} R {} pos {:?})", hex32(&eb), e.stride_mul, e.stride_rot, e.pos))); + } + } + c +} + +fn print_census(c: &Census, label: &str) { + let n = c.n as f64; + println!("\n## Era census: {label}, n = {} = 2^{:.0}\n", c.n, (c.n as f64).log2()); + println!("| Class | Count | Fraction | log2(fraction) | Expected (uniform) | Chip gain | Why |"); + println!("|---|---|---|---|---|---|---|"); + for (k, (name, count)) in c.counts.iter().enumerate() { + let frac = *count as f64 / n; + let (gain, why) = gain_of(name); + let exp = expected_fraction(name).map(|e| format!("{:.3e}", e)).unwrap_or_default(); + let l2 = if *count == 0 { format!("under -{:.0}", n.log2()) } else { format!("{:.2}", frac.log2()) }; + let g = if gain.is_nan() { "FINDING".to_string() } else { format!("{gain:.4}x") }; + let _ = k; + println!("| {name} | {count} | {frac:.3e} | {l2} | {exp} | {g} | {why} |"); + } + println!("\nGate: no class with gain over 1.1x at a fraction over 2^-20 (plan 4.2 F7). Hash datapath energy at the base weights {:.1} nJ ({:.0} ops x {:.3} pJ).", hash_pj() / 1000.0, OPS_PER_HASH, base_pj_per_op()); + println!("\nFirst seed per class (raw 32-byte E_n, hex):\n"); + for (name, s) in &c.first_seed { + if let Some(s) = s { + println!("- {name}: {s}"); + } + } + if let Some(s) = &c.lowest_pop.2 { + println!("- lowest popcount M in the census: popcount {} M {:08x}: {s}", c.lowest_pop.0, c.lowest_pop.1); + } + // uniformity: R over 1..31 + let exp_r = n / 31.0; + let chi_r: f64 = (1..=31).map(|r| (c.r_hist[r] as f64 - exp_r).powi(2) / exp_r).sum(); + let (rmin, rmax) = (1..=31).fold((u64::MAX, 0u64), |(lo, hi), r| (lo.min(c.r_hist[r]), hi.max(c.r_hist[r]))); + let sig_r = exp_r.sqrt(); + println!("\nStride rotation R over 1..31: chi-square {chi_r:.1} on 30 degrees of freedom (mean 30, sd 7.7); min bucket {rmin} max bucket {rmax} (expected {exp_r:.0}, sd {sig_r:.1}; max deviation {:.2} sigma); R = 0 or 32 seen {} times.", ((rmax as f64 - exp_r).abs().max((rmin as f64 - exp_r).abs())) / sig_r, c.r_hist[0] + c.r_hist[32]); + // uniformity: pos over the 1820 4-subsets of 0..15 + let subsets: Vec = (0usize..1 << 16).filter(|m| m.count_ones() == 4).map(|m| c.pos_hist[m]).collect(); + let exp_p = n / 1820.0; + let chi_p: f64 = subsets.iter().map(|&x| (x as f64 - exp_p).powi(2) / exp_p).sum(); + let (pmin, pmax) = subsets.iter().fold((u64::MAX, 0u64), |(lo, hi), &x| (lo.min(x), hi.max(x))); + let sig_p = exp_p.sqrt(); + let seen = subsets.iter().filter(|&&x| x > 0).count(); + let bad: u64 = (0usize..1 << 16).filter(|m| m.count_ones() != 4).map(|m| c.pos_hist[m]).sum(); + println!("Interleave pos over the 1,820 four-subsets of 0..15: {seen} of 1,820 seen; chi-square {chi_p:.1} on 1,819 degrees of freedom (mean 1,819, sd 60.3); min bucket {pmin} max bucket {pmax} (expected {exp_p:.1}, sd {sig_p:.1}; max deviation {:.2} sigma); draws with a non-4-subset {bad}.", ((pmax as f64 - exp_p).abs().max((pmin as f64 - exp_p).abs())) / sig_p); + // M bits + let sig_b = (n / 4.0).sqrt(); + let worst = (1..32).map(|b| ((c.m_bit_ones[b] as f64 - n / 2.0).abs() / sig_b, b)).fold((0f64, 0usize), |a, x| if x.0 > a.0 { x } else { a }); + println!("Stride multiplier M: bit 0 set in {} of {} (odd by construction); bits 1..31 each set in {:.3} to {:.3} of draws; worst bit {} at {:.2} sigma.", c.m_bit_ones[0], c.n, (1..32).map(|b| c.m_bit_ones[b] as f64 / n).fold(1f64, f64::min), (1..32).map(|b| c.m_bit_ones[b] as f64 / n).fold(0f64, f64::max), worst.1, worst.0); + println!("Era stream 64-bit seeds (words 0 and 1 of the era stream): {} distinct of {} seeds (birthday expectation of a collision at 2^20 draws from 2^64: 2^-25).", c.stream_seeds.len(), c.n); +} + +/// The known-fail case for the classifier: planted parameters that must fire, and a sound one that must not. +fn plant() -> bool { + let base = era_draw(&EraParams::test_era_bytes("igneum-era-test/0"), &V3_ALLOWED); + let mk = |m: u32, r: u32, pos: [u8; 4]| EraParams { stride_mul: m, stride_rot: r, pos, ..base }; + let cases: Vec<(&str, EraParams, &str)> = vec![ + ("M = 1 (identity stride)", mk(1, 7, [0, 3, 9, 14]), "m_one"), + ("M = 2^32 - 1", mk(u32::MAX, 7, [0, 3, 9, 14]), "m_all_ones"), + ("M = 2^16 + 1", mk(0x0001_0001, 7, [0, 3, 9, 14]), "m_pow2_plus1"), + ("M = 2^31 - 1 (naf 2, popcount 31)", mk(0x7fff_ffff, 7, [0, 3, 9, 14]), "m_naf_le2"), + ("M even (bijection failure)", mk(0x9E37_79B2, 7, [0, 3, 9, 14]), "m_even"), + ("R = 0 (rotate by 0 is undefined in the emitted text)", mk(0x9E37_79B1, 0, [0, 3, 9, 14]), "r_out_of_range"), + ("R = 32", mk(0x9E37_79B1, 32, [0, 3, 9, 14]), "r_out_of_range"), + ("pos linear [0,1,2,3]", mk(0x9E37_79B1, 7, [0, 1, 2, 3]), "pos_linear"), + ("pos contiguous [5,6,7,8]", mk(0x9E37_79B1, 7, [5, 6, 7, 8]), "pos_contiguous"), + ("pos not ascending", mk(0x9E37_79B1, 7, [3, 2, 1, 0]), "pos_invalid"), + ]; + println!("\n## Planted parameters through the classifier (the known-fail case)\n"); + println!("| Plant | Expected flag | Fired | Every flag raised |"); + println!("|---|---|---|---|"); + let mut ok = true; + for (name, e, expect) in &cases { + let f = classify(e); + let fv = flags_vec(&f); + let raised: Vec<&str> = CLASS_NAMES.iter().zip(fv.iter()).filter(|(_, &h)| h).map(|(n, _)| *n).collect(); + let fired = raised.contains(expect); + ok &= fired; + println!("| {name} | {expect} | {} | {} |", if fired { "yes" } else { "NO" }, raised.join(", ")); + } + // a sound draw must raise nothing + let f = classify(&base); + let raised: Vec<&str> = CLASS_NAMES.iter().zip(flags_vec(&f).iter()).filter(|(_, &h)| h).map(|(n, _)| *n).collect(); + println!("| igneum-era-test/0 (a sound draw: M {:08x} R {} pos {:?}) | none | {} | {} |", base.stride_mul, base.stride_rot, base.pos, if raised.is_empty() { "yes" } else { "NO" }, raised.join(", ")); + ok &= raised.is_empty(); + println!("\nPlant verdict: {}", if ok { "every planted case fired and the sound draw did not" } else { "FAILED: a planted case did not fire" }); + ok +} + +/// Spec 01 section 1.13.1, the first era stream (not in igneum-pow): `seed_words_from_bytes("igneum-era/" || n_le64 || E_n)` +/// words 0 and 1 seed one SplitMix64; ten op weights each `below(2B+1) - B` with B = 2, renormalised by largest remainder +/// to 75; six fold rotations `1 + below(31)`; one draw consumed for `epoch_len`. +fn spec_stream(n_index: u64, e: &[u8; 32]) -> ([u64; 10], [u32; 6]) { + let mut b = Vec::with_capacity(11 + 8 + 32); + b.extend_from_slice(b"igneum-era/"); + b.extend_from_slice(&n_index.to_le_bytes()); + b.extend_from_slice(e); + let w = seed_words_from_bytes(&b); + let mut s = SplitMix64::new(w[0] as u64 | ((w[1] as u64) << 32)); + const B: i64 = 2; + let mut p = [0i64; 10]; + for (i, (_, wt)) in NONLOAD_WEIGHTS.iter().enumerate() { + p[i] = *wt as i64 + s.below((2 * B + 1) as u64) as i64 - B; + } + let sum: i64 = p.iter().sum(); + // largest remainder to 75: floor(p_i * 75 / sum), then the leftover units to the largest remainders (ties by index) + let mut q = [0u64; 10]; + let mut rem: Vec<(i64, usize)> = Vec::new(); + let mut given = 0u64; + for i in 0..10 { + let num = p[i] * NONLOAD_SUM as i64; + q[i] = (num / sum) as u64; + given += q[i]; + rem.push((num % sum, i)); + } + rem.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + let mut left = NONLOAD_SUM - given; + for (_, i) in rem { + if left == 0 { + break; + } + q[i] += 1; + left -= 1; + } + let mut fold = [0u32; 6]; + for f in fold.iter_mut() { + *f = 1 + s.below(31) as u32; + } + let _epoch_len_draw = s.next(); + (q, fold) +} + +fn run_spec(n: u64) { + println!("\n## Spec 1.13.1 first stream (op-weight perturbation, fold rotations), n = {} = 2^{:.0}; implemented here from the spec text, NOT in igneum-pow\n", n, (n as f64).log2()); + let mul_ix: Vec = NONLOAD_WEIGHTS.iter().enumerate().filter(|(_, (op, _))| matches!(op, Op::Mul | Op::Mad | Op::MulHi)).map(|(i, _)| i).collect(); + let mut share_hist = [0u64; 76]; + let mut sum_bad = 0u64; + let mut fold_triple_equal = 0u64; + let mut fold_both_equal = 0u64; + let mut fold_repeat = 0u64; + let mut fold_all_six = 0u64; + let mut fold_hist = [0u64; 32]; + let mut first_min: Option = None; + let mut first_max: Option = None; + let (mut smin, mut smax) = (75u64, 0u64); + for i in 0..n { + let eb = raw_era_bytes(i); + let (q, fold) = spec_stream(i, &eb); + if q.iter().sum::() != NONLOAD_SUM { + sum_bad += 1; + } + let share: u64 = mul_ix.iter().map(|&k| q[k]).sum(); + share_hist[share as usize] += 1; + if share < smin { + smin = share; + first_min = Some(format!("{} weights {:?}", hex32(&eb), q)); + } + if share > smax { + smax = share; + first_max = Some(format!("{} weights {:?}", hex32(&eb), q)); + } + let t1 = fold[0] == fold[1] && fold[1] == fold[2]; + let t2 = fold[3] == fold[4] && fold[4] == fold[5]; + if t1 || t2 { + fold_triple_equal += 1; + } + if t1 && t2 { + fold_both_equal += 1; + } + if t1 && t2 && fold[0] == fold[3] { + fold_all_six += 1; + } + let rep = |t: &[u32]| t[0] == t[1] || t[1] == t[2] || t[0] == t[2]; + if rep(&fold[0..3]) || rep(&fold[3..6]) { + fold_repeat += 1; + } + for f in fold { + fold_hist[f as usize] += 1; + } + } + let nf = n as f64; + println!("| Multiply share (mul+mad+mulhi of 75) | Count | Fraction | Chip pJ per op | Against the base 22/75 |"); + println!("|---|---|---|---|---|"); + for s in smin..=smax { + let c = share_hist[s as usize]; + if c == 0 { + continue; + } + let e = (s as f64 * MUL_PJ + (75 - s) as f64 * ADD_PJ) / 75.0; + println!("| {s} | {c} | {:.3e} | {e:.3} | {:+.1}% |", c as f64 / nf, (e / base_pj_per_op() - 1.0) * 100.0); + } + println!("\nRenormalised weight sums not 75: {sum_bad}. Lowest multiply share seen {smin} (first seed {}); highest {smax} (first seed {}).", first_min.unwrap_or_default(), first_max.unwrap_or_default()); + println!("The share moves the GPU's energy the same way (its IMAD is the chain's own op, algorithm.md 5.4): the chip-against-GPU gain of any weight corner is 1.0x; the absolute datapath swing is the column above; 0 memory effect."); + println!("\n| Fold rotation class | Count | Fraction | Expected (uniform) | Chip gain |"); + println!("|---|---|---|---|---|"); + let p3 = 1.0 / 961.0; + println!("| a triple all equal | {fold_triple_equal} | {:.3e} | {:.3e} | 1.0x (a wire mux) |", fold_triple_equal as f64 / nf, 2.0 * p3 - p3 * p3); + println!("| both triples all equal | {fold_both_equal} | {:.3e} | {:.3e} | 1.0x |", fold_both_equal as f64 / nf, p3 * p3); + println!("| all six equal | {fold_all_six} | {:.3e} | {:.3e} | 1.0x |", fold_all_six as f64 / nf, p3 * p3 / 31.0); + println!("| a repeated value inside a triple | {fold_repeat} | {:.3e} | {:.3e} | 1.0x |", fold_repeat as f64 / nf, 1.0 - (26970.0f64 / 29791.0).powi(2)); + let exp_f = 6.0 * nf / 31.0; + let (fmin, fmax) = (1..=31).fold((u64::MAX, 0u64), |(lo, hi), r| (lo.min(fold_hist[r]), hi.max(fold_hist[r]))); + println!("\nFold rotations over 1..31 (6 per seed): min bucket {fmin} max bucket {fmax} (expected {exp_f:.0}, sd {:.0}); rotation 0 seen {} times.", exp_f.sqrt(), fold_hist[0]); +} + +fn run_days(n: u64) { + println!("\n## The 64-bit seeding of the day-key stream (memhard.rs `MixParams::with_shape`: `SplitMix64::new(key[0] | key[1] << 32)`), days 0..{n}\n"); + let mut seeds64 = HashSet::with_capacity(n as usize); + let mut keys256 = HashSet::with_capacity(n as usize); + let mut tuples = HashSet::with_capacity(n as usize); + let mut dup64 = 0u64; + for d in 0..n { + let k = seed_words_from_bytes(&day_bytes(d)); + let s = k[0] as u64 | ((k[1] as u64) << 32); + if !seeds64.insert(s) { + dup64 += 1; + } + keys256.insert(k); + let mp = MixParams::new(k); + tuples.insert((mp.rot, mp.mul, mp.rc)); + } + println!("| Quantity | Value |"); + println!("|---|---|"); + println!("| Day key bytes on the chain | `\"igneum-day/\" \\|\\| day_le64`, `day = timestamp_ms / 86,400,000` (node fork `consensus/pow/src/igneum.rs`; `igneum_pow::bind::day_bytes`): calendar, no chain state |"); + println!("| Days checked | {n} (the chain at 1 block/s has 65,745 days in 180 years, 2^16.0) |"); + println!("| Distinct 256-bit day keys K | {} |", keys256.len()); + println!("| Distinct 64-bit stream seeds `K[0] \\| K[1] << 32` | {} (duplicates {dup64}) |", seeds64.len()); + println!("| Distinct (ROT, MUL, RC) tuples | {} |", tuples.len()); + println!("| Bits of K that enter the cache fill | 256 (spec 1.8.3: `K[0..7]` in every block input) |"); + println!("| Bits of K that enter the mixer-constant draw | 64 (spec 1.8.4: `K[0] \\| (K[1] << 32)`, the stated intent) |"); + println!("| Nominal draw space of (ROT, MUL, RC) | 8 x log2(31) + 16 x 31 + 16 x 32 = {:.1} bits | ", 8.0 * 31f64.log2() + 16.0 * 31.0 + 16.0 * 32.0); + println!("| Reachable streams | 2^64 seeds, of which at most 2^16 are ever used (one per calendar day) |"); + println!("| Birthday bound on a 64-bit collision among 2^16 days | 2^(32 - 65) = 2^-33 |"); +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let cmd = args.first().map(String::as_str).unwrap_or("all"); + let n = arg(&args, "--n").and_then(|s| s.parse::().ok()); + let test = arg(&args, "--seeds").as_deref() == Some("test"); + let do_plant = args.iter().any(|a| a == "--plant"); + let t0 = std::time::Instant::now(); + match cmd { + "census" => { + if do_plant && !plant() { + std::process::exit(2); + } + let c = run_census(n.unwrap_or(1 << 20), test); + print_census(&c, if test { "igneum-era-test/ seeds" } else { "raw 32-byte seeds" }); + } + "spec" => run_spec(n.unwrap_or(1 << 20)), + "days" => run_days(n.unwrap_or(1 << 17)), + "all" => { + if !plant() { + std::process::exit(2); + } + let c = run_census(n.unwrap_or(1 << 20), false); + print_census(&c, "raw 32-byte seeds"); + run_spec(n.unwrap_or(1 << 20)); + run_days(1 << 17); + } + _ => { + eprintln!("usage: attack-f7 census|spec|days|all [--n N] [--seeds raw|test] [--plant]"); + std::process::exit(2); + } + } + println!("\nelapsed {:.1} s", t0.elapsed().as_secs_f64()); +}