igneum/tools/attack/f3-cache/src/main.rs
igneum-labs b98090649e attack-pass F3: PASS (0 of 64 and 0 of 1,024 lines under j+1, curve monotone, f=1 unchanged); AP-H1 box-clean hazard recorded
The F3 record and its harness crate (tools/attack/f3-cache: the extracted chain,
the exhaustive closure search, the pebbling cross-check, the store-set DP and
brute force, the two planted broken chains). The optimal-placement observation
(3.17 blocks per read at f=1/8, 16.0 at f=1/64) noted against funding.md B2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 08:16:48 +00:00

1003 lines
39 KiB
Rust

//! Attack-pass row F3 (`docs/plans/cryptanalysis.md` section 4.2): the chained cache's j + 1 bound and the
//! storage-against-recompute curve over cache LINES.
//!
//! Target: `igneum-pow/src/memhard.rs` at 924288d1, `Cache::fill_segment` and `chacha_block`:
//! `in_j = prev XOR (sigma || K || seg || j || tag)`, `line_j = ChaCha12core(in_j) + in_j`, `prev_0 = 0`, 64 lines
//! per segment, 2^16 independent segments. The model here is the code, run generically over `u32` (bit-exact
//! against igneum-pow, `verify`) and over taint sets (the block dependency graph the exhaustive search runs on,
//! `search`). Two deliberately broken variants are the known-fail firings: `skip2` (line j fed from line j - 2,
//! so a line costs about j / 2 + 1 blocks) and `nofeed` (no previous line fed in, so every line costs 1 block).
//!
//! Sub-commands: verify, search, pebble, store, curve, avalanche, census, bench. Every number is printed with
//! its inputs so a log line is the citation.
use igneum_pow::memhard::{
chacha_block, derive_items, mixer, round_key_mult, Cache, MixParams, Shape, CACHE_LINES_PER_SEGMENT,
CACHE_LOG2_WORDS, CACHE_TAG, CHACHA_SIGMA, ITEM_ROUNDS,
};
use igneum_pow::seed::{day_key, SplitMix64};
use std::collections::{HashMap, VecDeque};
use std::time::Instant;
const MAX_LINES: usize = 1024;
const TW: usize = MAX_LINES / 64;
/// Operations per chained line, counted from `memhard.rs`: 48 quarter rounds of 12 (4 add, 4 xor, 4 rotate)
/// = 576, the 16 feed-forward adds, the 16 input XORs with the previous line. The constant assembly (sigma, key,
/// seg, j, tag) is wiring on a chip and is not counted.
const OPS_PER_BLOCK: u64 = 48 * 12 + 16 + 16;
/// MEMHARD.md section 3 item 4's approximate figure, the one funding.md B2 rank 2 prices (2,450 = 3.5 x 700).
const OPS_PER_BLOCK_MEMHARD: u64 = 700;
/// Mixer ops per item under class v4 (`mixer_mult = 8`): 72 applications x 128 hoisted + 144 (chip-model-v3.md 5.2).
const OPS_PER_ITEM_MIXER: u64 = 9_360;
/// Program ops per hash beside the items (chip-model-v3.md 5.4: "ops per hash = 128 (1 - f) x 9,360 + 512").
const OPS_PER_HASH_PROGRAM: u64 = 512;
const ITEMS_PER_HASH: u64 = 128;
const READS_PER_ITEM: u64 = ITEM_ROUNDS as u64;
// ------------------------------------------------------------------------------------------------------------
// Words: u32 (the real arithmetic) and taint sets (which block outputs a value depends on)
// ------------------------------------------------------------------------------------------------------------
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct Taint([u64; TW]);
impl Taint {
const NONE: Taint = Taint([0; TW]);
fn of(i: usize) -> Taint {
let mut t = [0u64; TW];
t[i / 64] |= 1u64 << (i % 64);
Taint(t)
}
fn union(self, o: Taint) -> Taint {
let mut t = self.0;
for k in 0..TW {
t[k] |= o.0[k];
}
Taint(t)
}
fn has(&self, i: usize) -> bool {
(self.0[i / 64] >> (i % 64)) & 1 == 1
}
fn members(&self, n: usize) -> Vec<usize> {
(0..n).filter(|&i| self.has(i)).collect()
}
}
trait Word: Copy {
fn add(self, o: Self) -> Self;
fn xor(self, o: Self) -> Self;
fn rotl(self, n: u32) -> Self;
}
impl Word for u32 {
fn add(self, o: u32) -> u32 {
self.wrapping_add(o)
}
fn xor(self, o: u32) -> u32 {
self ^ o
}
fn rotl(self, n: u32) -> u32 {
self.rotate_left(n)
}
}
impl Word for Taint {
fn add(self, o: Taint) -> Taint {
self.union(o)
}
fn xor(self, o: Taint) -> Taint {
self.union(o)
}
fn rotl(self, _n: u32) -> Taint {
self
}
}
// ------------------------------------------------------------------------------------------------------------
// The block function and the chain, the shape of memhard.rs
// ------------------------------------------------------------------------------------------------------------
#[allow(clippy::too_many_arguments)]
fn qr<W: Word>(s: &mut [W; 16], a: usize, b: usize, c: usize, d: usize, r1: u32, r2: u32, r3: u32, r4: u32) {
s[a] = s[a].add(s[b]);
s[d] = s[d].xor(s[a]);
s[d] = s[d].rotl(r1);
s[c] = s[c].add(s[d]);
s[b] = s[b].xor(s[c]);
s[b] = s[b].rotl(r2);
s[a] = s[a].add(s[b]);
s[d] = s[d].xor(s[a]);
s[d] = s[d].rotl(r3);
s[c] = s[c].add(s[d]);
s[b] = s[b].xor(s[c]);
s[b] = s[b].rotl(r4);
}
/// `y = ChaCha12 core(x) + x`, the shape of `memhard::chacha_block`.
fn block<W: Word>(x: &[W; 16]) -> [W; 16] {
let mut y = *x;
for _ in 0..6 {
qr(&mut y, 0, 4, 8, 12, 16, 12, 8, 7);
qr(&mut y, 1, 5, 9, 13, 16, 12, 8, 7);
qr(&mut y, 2, 6, 10, 14, 16, 12, 8, 7);
qr(&mut y, 3, 7, 11, 15, 16, 12, 8, 7);
qr(&mut y, 0, 5, 10, 15, 16, 12, 8, 7);
qr(&mut y, 1, 6, 11, 12, 16, 12, 8, 7);
qr(&mut y, 2, 7, 8, 13, 16, 12, 8, 7);
qr(&mut y, 3, 4, 9, 14, 16, 12, 8, 7);
}
for i in 0..16 {
y[i] = y[i].add(x[i]);
}
y
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Variant {
/// The shipped chain: line j fed from line j - 1.
Real,
/// Known-fail A: line j fed from line j - 2 (lines 0 and 1 from zero).
Skip2,
/// Known-fail B: no previous line fed in; every line is a keystream block.
NoFeed,
}
impl Variant {
fn parse(s: &str) -> Variant {
match s {
"real" => Variant::Real,
"skip2" => Variant::Skip2,
"nofeed" => Variant::NoFeed,
_ => panic!("variant must be real, skip2 or nofeed"),
}
}
fn name(&self) -> &'static str {
match self {
Variant::Real => "real",
Variant::Skip2 => "skip2",
Variant::NoFeed => "nofeed",
}
}
}
/// The chain of `Cache::fill_segment` with `lines` lines under `v`. `const_of(j)` is the 16-word constant
/// block of line j; `relabel(j, line)` is what later steps see of line j (identity for u32; for taints the
/// single node {j}, so `on_input` sees DIRECT parents and not the transitive closure); `on_input(j, in_j)`
/// sees the block input before the block runs.
fn chain<W: Word>(
lines: usize,
v: Variant,
zero: W,
const_of: &dyn Fn(usize) -> [W; 16],
relabel: &dyn Fn(usize, [W; 16]) -> [W; 16],
on_input: &mut dyn FnMut(usize, &[W; 16]),
) -> Vec<[W; 16]> {
let mut out: Vec<[W; 16]> = Vec::with_capacity(lines);
let mut kept: Vec<[W; 16]> = Vec::with_capacity(lines);
for j in 0..lines {
let prev = match v {
Variant::Real => {
if j >= 1 {
kept[j - 1]
} else {
[zero; 16]
}
}
Variant::Skip2 => {
if j >= 2 {
kept[j - 2]
} else {
[zero; 16]
}
}
Variant::NoFeed => [zero; 16],
};
let mut x = const_of(j);
for i in 0..16 {
x[i] = x[i].xor(prev[i]);
}
on_input(j, &x);
let y = block(&x);
kept.push(relabel(j, y));
out.push(y);
}
out
}
fn real_const(key: [u32; 8], seg: u32) -> impl Fn(usize) -> [u32; 16] {
move |j| {
let mut x = [0u32; 16];
x[..4].copy_from_slice(&CHACHA_SIGMA);
x[4..12].copy_from_slice(&key);
x[12] = seg;
x[13] = j as u32;
x[14] = CACHE_TAG[0];
x[15] = CACHE_TAG[1];
x
}
}
fn u32_chain(lines: usize, v: Variant, key: [u32; 8], seg: u32) -> Vec<[u32; 16]> {
let c = real_const(key, seg);
let identity = |_: usize, y: [u32; 16]| y;
let mut nothing = |_: usize, _: &[u32; 16]| {};
chain(lines, v, 0u32, &c, &identity, &mut nothing)
}
// ------------------------------------------------------------------------------------------------------------
// The dependency graph and the derivation search
// ------------------------------------------------------------------------------------------------------------
struct Dag {
/// Direct parents of block j: the earlier blocks whose outputs appear in in_j.
parents: Vec<Vec<usize>>,
/// word_dep[o][i]: output word o of one block depends on input word i (taint).
word_dep: [[bool; 16]; 16],
}
fn extract_dag(lines: usize, v: Variant) -> Dag {
assert!(lines <= MAX_LINES);
let mut parents: Vec<Vec<usize>> = vec![Vec::new(); lines];
let const_of = |_j: usize| [Taint::NONE; 16];
let relabel = |j: usize, _y: [Taint; 16]| [Taint::of(j); 16];
let mut on_input = |j: usize, x: &[Taint; 16]| {
let mut u = Taint::NONE;
for w in x.iter() {
u = u.union(*w);
}
parents[j] = u.members(lines);
};
chain(lines, v, Taint::NONE, &const_of, &relabel, &mut on_input);
// one block at word granularity: input word i carries taint {i}
let mut x = [Taint::NONE; 16];
for (i, w) in x.iter_mut().enumerate() {
*w = Taint::of(i);
}
let y = block(&x);
let mut word_dep = [[false; 16]; 16];
for o in 0..16 {
for i in 0..16 {
word_dep[o][i] = y[o].has(i);
}
}
Dag { parents, word_dep }
}
/// The minimum number of block evaluations that produce block `target` when the blocks in `stored` are held:
/// every non-stored block in the backward closure of the target must be evaluated at least once (its value is
/// needed and not held), and evaluating each exactly once in dependency order suffices. `pebble` checks this
/// formula against an exhaustive search over every pebbling sequence at small sizes.
fn min_blocks(parents: &[Vec<usize>], target: usize, stored: &[bool]) -> usize {
if stored[target] {
return 0;
}
let n = parents.len();
let mut seen = vec![false; n];
let mut stack = vec![target];
seen[target] = true;
let mut count = 0usize;
while let Some(v) = stack.pop() {
count += 1;
for &p in &parents[v] {
if !seen[p] && !stored[p] {
seen[p] = true;
stack.push(p);
}
}
}
count
}
/// Exhaustive 0-1 BFS over every pebble configuration of a DAG of at most 20 nodes: place a pebble on a node
/// whose parents are pebbled (cost 1), remove any pebble (cost 0); the minimum cost to pebble `target` from the
/// configuration `start`.
fn pebble_min(parents: &[Vec<usize>], target: usize, start: u32) -> u32 {
let n = parents.len();
assert!(n <= 20);
let states = 1usize << n;
let mut dist = vec![u32::MAX; states];
let mut dq: VecDeque<u32> = VecDeque::new();
dist[start as usize] = 0;
dq.push_back(start);
while let Some(m) = dq.pop_front() {
let d = dist[m as usize];
if (m >> target) & 1 == 1 {
return d;
}
for v in 0..n {
let bit = 1u32 << v;
if m & bit != 0 {
let nm = m & !bit;
if dist[nm as usize] > d {
dist[nm as usize] = d;
dq.push_front(nm);
}
} else if parents[v].iter().all(|&p| m & (1u32 << p) != 0) {
let nm = m | bit;
if dist[nm as usize] > d + 1 {
dist[nm as usize] = d + 1;
dq.push_back(nm);
}
}
}
}
u32::MAX
}
// ------------------------------------------------------------------------------------------------------------
// Store patterns on the chain
// ------------------------------------------------------------------------------------------------------------
fn total_cost(parents: &[Vec<usize>], stored: &[bool]) -> u64 {
(0..parents.len()).map(|j| min_blocks(parents, j, stored) as u64).sum()
}
fn stored_from(lines: usize, positions: &[usize]) -> Vec<bool> {
let mut s = vec![false; lines];
for &p in positions {
s[p] = true;
}
s
}
/// funding.md B2 rank 2's pattern: every k-th line from line 0.
fn naive_positions(lines: usize, n: usize) -> Vec<usize> {
let k = lines / n;
(0..n).map(|i| i * k).collect()
}
/// The cost of a stored set on the real path chain by the gap formula: lines before the first stored line cost
/// j + 1, a line after stored line p costs j - p. Equal to the closure cost on the extracted DAG (checked in
/// `store` on random sets).
fn path_cost(lines: usize, sorted_positions: &[usize]) -> u64 {
let mut total = 0u64;
let mut last: Option<usize> = None;
let mut idx = 0usize;
for j in 0..lines {
if idx < sorted_positions.len() && sorted_positions[idx] == j {
last = Some(j);
idx += 1;
}
total += match last {
Some(p) => (j - p) as u64,
None => (j + 1) as u64,
};
}
total
}
/// Exhaustive minimum over every n-subset of the L lines, no pruning: C(L, n) sets, each costed incrementally
/// by the gap formula (the chunk before the first stored line p1 costs C(p1 + 1, 2), a chunk of g lines after a
/// stored line costs C(g, 2); `path_cost` is the same sum line by line and the two agree on every set `store`
/// samples).
fn brute_force_best(lines: usize, n: usize) -> (u64, Vec<usize>) {
fn c2(g: usize) -> u64 {
(g * g.saturating_sub(1) / 2) as u64
}
#[allow(clippy::too_many_arguments)]
fn rec(lines: usize, n: usize, start: usize, prev: Option<usize>, partial: u64, chosen: &mut Vec<usize>, best: &mut (u64, Vec<usize>), count: &mut u64) {
let remaining = n - chosen.len();
if remaining == 1 {
for p in start..lines {
let gap = match prev {
Some(q) => c2(p - q),
None => c2(p + 1),
};
let c = partial + gap + c2(lines - p);
*count += 1;
if c < best.0 {
chosen.push(p);
*best = (c, chosen.clone());
chosen.pop();
}
}
return;
}
for p in start..=(lines - remaining) {
let gap = match prev {
Some(q) => c2(p - q),
None => c2(p + 1),
};
chosen.push(p);
rec(lines, n, p + 1, Some(p), partial + gap, chosen, best, count);
chosen.pop();
}
}
let mut best = (u64::MAX, Vec::new());
let mut count = 0u64;
rec(lines, n, 0, None, 0, &mut Vec::new(), &mut best, &mut count);
eprintln!(" brute force: {count} subsets of size {n} of {lines} lines enumerated");
best
}
/// Exact minimum over store sets of size n on the path by dynamic programming over the n + 1 chunks: the
/// chunk before the first stored line costs g0 (g0 + 1) / 2, every later chunk of h lines (starting at a stored
/// line) costs h (h - 1) / 2; chunk lengths sum to L. Returns (cost, positions).
fn dp_best(lines: usize, n: usize) -> (u64, Vec<usize>) {
// chunks: c0 = g0 + 1 in 1..=L+1, c_i = h_i in 1..; sum of the n + 1 chunk sizes = L + 1; cost = sum C(c, 2)
let total = lines + 1;
let chunks = n + 1;
let c2 = |c: usize| ((c * (c.saturating_sub(1))) / 2) as u64;
let inf = u64::MAX / 4;
let mut dp = vec![vec![inf; total + 1]; chunks + 1];
let mut arg = vec![vec![0usize; total + 1]; chunks + 1];
dp[0][0] = 0;
for i in 1..=chunks {
for r in i..=total {
let mut best = inf;
let mut bh = 0;
for h in 1..=(r - (i - 1)) {
let prev = dp[i - 1][r - h];
if prev >= inf {
continue;
}
let c = prev + c2(h);
if c < best {
best = c;
bh = h;
}
}
dp[i][r] = best;
arg[i][r] = bh;
}
}
let cost = dp[chunks][total];
// reconstruct chunk sizes
let mut sizes = Vec::with_capacity(chunks);
let mut r = total;
for i in (1..=chunks).rev() {
let h = arg[i][r];
sizes.push(h);
r -= h;
}
sizes.reverse();
let g0 = sizes[0] - 1;
let mut positions = Vec::with_capacity(n);
let mut p = g0;
for h in sizes.iter().skip(1) {
positions.push(p);
p += h;
}
(cost, positions)
}
// ------------------------------------------------------------------------------------------------------------
// Commands
// ------------------------------------------------------------------------------------------------------------
fn cmd_verify(args: &HashMap<String, String>) {
let day = args.get("day").map(String::as_str).unwrap_or("2026-10-03");
let seed: u64 = args.get("seed").map(|s| s.parse().unwrap()).unwrap_or(1);
let mut rng = SplitMix64::new(seed);
let mut keys = vec![day_key(day)];
for _ in 0..3 {
let mut k = [0u32; 8];
for w in k.iter_mut() {
*w = rng.next() as u32;
}
keys.push(k);
}
let segs = [0usize, 1, 12_345, 65_535];
let mut ok = 0usize;
let mut bad = 0usize;
for (ki, key) in keys.iter().enumerate() {
for &seg in &segs {
let mut words = vec![0u32; (seg + 1) * CACHE_LINES_PER_SEGMENT * 16];
Cache::fill_segment(&mut words, seg, key);
let base = seg * CACHE_LINES_PER_SEGMENT * 16;
let model = u32_chain(CACHE_LINES_PER_SEGMENT, Variant::Real, *key, seg as u32);
let flat: Vec<u32> = model.iter().flat_map(|l| l.iter().copied()).collect();
let same = flat[..] == words[base..base + CACHE_LINES_PER_SEGMENT * 16];
if same {
ok += 1;
} else {
bad += 1;
}
println!("verify key{ki} seg {seg}: model chain == Cache::fill_segment: {same}");
}
}
// the block itself on random inputs
let mut same = 0usize;
let n = 100_000usize;
for _ in 0..n {
let mut x = [0u32; 16];
for w in x.iter_mut() {
*w = rng.next() as u32;
}
if block(&x) == chacha_block(&x) {
same += 1;
}
}
println!("verify block == memhard::chacha_block on {n} random inputs: {same}/{n}");
// the 1024-line model's first 64 lines are the real segment
let long = u32_chain(1024, Variant::Real, keys[0], 7);
let short = u32_chain(64, Variant::Real, keys[0], 7);
let prefix = long[..64] == short[..];
println!("verify 1024-line model prefix == 64-line chain: {prefix}");
// the broken variants differ from the real one (so the firings are on different functions)
let s2 = u32_chain(64, Variant::Skip2, keys[0], 7);
let nf = u32_chain(64, Variant::NoFeed, keys[0], 7);
println!(
"verify variants differ from real: skip2 {} nofeed {} (line 0 equal to real: skip2 {} nofeed {})",
s2 != short,
nf != short,
s2[0] == short[0],
nf[0] == short[0]
);
let pass = bad == 0 && same == n && prefix && s2 != short && nf != short;
println!("VERIFY {} ({ok} segment comparisons equal, {bad} unequal)", if pass { "PASS" } else { "FAIL" });
if !pass {
std::process::exit(2);
}
}
fn cmd_search(args: &HashMap<String, String>) {
let lines: usize = args.get("lines").map(|s| s.parse().unwrap()).unwrap_or(64);
let v = Variant::parse(args.get("variant").map(String::as_str).unwrap_or("real"));
let t0 = Instant::now();
let dag = extract_dag(lines, v);
// word-level dependency of one block
let mut missing = 0usize;
for o in 0..16 {
for i in 0..16 {
if !dag.word_dep[o][i] {
missing += 1;
}
}
}
println!("search variant {} lines {}: block word dependency matrix: {} of 256 (output word, input word) pairs dependent, {} missing", v.name(), lines, 256 - missing, missing);
// parents summary
let mut hist: HashMap<String, usize> = HashMap::new();
for (j, p) in dag.parents.iter().enumerate() {
let shape = p.iter().map(|&q| format!("j-{}", j - q)).collect::<Vec<_>>().join(",");
*hist.entry(if shape.is_empty() { "none".to_string() } else { shape }).or_insert(0) += 1;
}
let mut hv: Vec<_> = hist.into_iter().collect();
hv.sort();
println!("search parents of block j (direct, from the taint trace): {:?}", hv);
// exhaustive: every target, nothing stored
let none = vec![false; lines];
let mut under = Vec::new();
let mut costs = Vec::with_capacity(lines);
for j in 0..lines {
let c = min_blocks(&dag.parents, j, &none);
costs.push(c);
if c < j + 1 {
under.push((j, c));
}
}
let show: Vec<usize> = if lines <= 64 { (0..lines).collect() } else { vec![0, 1, 2, 3, 7, 15, 31, 63, 64, 127, 255, 511, 1023].into_iter().filter(|&j| j < lines).collect() };
println!("| j | blocks to derive line j, nothing stored | j + 1 | under |");
println!("|---|---|---|---|");
for &j in &show {
println!("| {} | {} | {} | {} |", j, costs[j], j + 1, if costs[j] < j + 1 { "YES" } else { "no" });
}
let total: usize = costs.iter().sum();
println!(
"search variant {} lines {}: {} of {} lines derivable under j + 1 blocks; mean blocks per line {:.3} (j + 1 mean {:.3}); {:.2} s",
v.name(),
lines,
under.len(),
lines,
total as f64 / lines as f64,
(lines as f64 + 1.0) / 2.0,
t0.elapsed().as_secs_f64()
);
if !under.is_empty() {
let first: Vec<String> = under.iter().take(8).map(|(j, c)| format!("j={j}:{c}")).collect();
println!("search FIRE: cheapest derivations under j + 1: {}", first.join(" "));
}
let gate = under.is_empty() && missing == 0;
println!("SEARCH variant {} lines {}: {}", v.name(), lines, if gate { "PASS (no line under j + 1 blocks)" } else { "FIRE (derivation under j + 1 found)" });
}
fn cmd_pebble(args: &HashMap<String, String>) {
let lines: usize = args.get("lines").map(|s| s.parse().unwrap()).unwrap_or(10);
assert!(lines <= 16, "pebble enumerates 2^lines configurations; keep lines at most 16");
for v in [Variant::Real, Variant::Skip2, Variant::NoFeed] {
let dag = extract_dag(lines, v);
let t0 = Instant::now();
let mut checked = 0u64;
let mut mismatch = 0u64;
let mut example = String::new();
let mut target_cost_none = Vec::new();
for start in 0u32..(1u32 << lines) {
let stored: Vec<bool> = (0..lines).map(|i| (start >> i) & 1 == 1).collect();
for target in 0..lines {
let a = min_blocks(&dag.parents, target, &stored) as u32;
let b = pebble_min(&dag.parents, target, start);
checked += 1;
if a != b {
mismatch += 1;
if example.is_empty() {
example = format!("start {start:#b} target {target}: closure {a} pebbling {b}");
}
}
if start == 0 {
target_cost_none.push(b);
}
}
}
println!(
"pebble variant {} lines {}: {} (stored set, target) pairs, exhaustive pebbling == closure formula on {} of them, {} mismatches {}; nothing stored, cost per target: {:?}; {:.1} s",
v.name(),
lines,
checked,
checked - mismatch,
mismatch,
example,
target_cost_none,
t0.elapsed().as_secs_f64()
);
let under: Vec<usize> = (0..lines).filter(|&j| (target_cost_none[j] as usize) < j + 1).collect();
println!("PEBBLE variant {} lines {}: {}", v.name(), lines, if under.is_empty() { "PASS (no target under j + 1 by exhaustive pebbling)".to_string() } else { format!("FIRE (targets under j + 1 by exhaustive pebbling: {under:?})") });
}
}
fn fractions() -> Vec<(usize, &'static str)> {
vec![(64, "1/64"), (32, "1/32"), (16, "1/16"), (8, "1/8"), (4, "1/4"), (2, "1/2"), (1, "1")]
}
fn cmd_store(args: &HashMap<String, String>) {
let lines: usize = args.get("lines").map(|s| s.parse().unwrap()).unwrap_or(64);
let v = Variant::parse(args.get("variant").map(String::as_str).unwrap_or("real"));
let brute_max: usize = args.get("brute-max").map(|s| s.parse().unwrap()).unwrap_or(if lines <= 64 { 8 } else { 2 });
let seed: u64 = args.get("seed").map(|s| s.parse().unwrap()).unwrap_or(1);
let dag = extract_dag(lines, v);
// the gap formula against the closure cost on random sets (real variant only: the formula is the path's)
if v == Variant::Real {
let mut rng = SplitMix64::new(seed);
let mut agree = 0usize;
let trials = 2_000usize;
for _ in 0..trials {
let n = 1 + rng.below(lines as u64 / 2) as usize;
let mut pos: Vec<usize> = Vec::new();
while pos.len() < n {
let p = rng.below(lines as u64) as usize;
if !pos.contains(&p) {
pos.push(p);
}
}
pos.sort_unstable();
let a = path_cost(lines, &pos);
let b = total_cost(&dag.parents, &stored_from(lines, &pos));
if a == b {
agree += 1;
}
}
println!("store lines {lines}: gap formula == DAG closure cost on {agree}/{trials} random stored sets");
}
println!("| f | stored lines n | naive (every L/n-th from 0) blocks per read | optimal positions (DP) | optimal blocks per read | brute force (exhaustive over C(L, n) sets) | DP == brute |");
println!("|---|---|---|---|---|---|---|");
for (den, label) in fractions() {
if lines < den {
continue;
}
let n = lines / den;
let naive = naive_positions(lines, n);
let naive_cost = total_cost(&dag.parents, &stored_from(lines, &naive));
let (dp_cost, dp_pos) = dp_best(lines, n);
let dp_cost_dag = total_cost(&dag.parents, &stored_from(lines, &dp_pos));
let brute = if n <= brute_max {
let t0 = Instant::now();
let (c, p) = brute_force_best(lines, n);
Some((c, p, t0.elapsed().as_secs_f64()))
} else {
None
};
let pos_str = if dp_pos.len() <= 16 { format!("{dp_pos:?}") } else { format!("{:?}..{:?} ({} lines)", &dp_pos[..4], &dp_pos[dp_pos.len() - 2..], dp_pos.len()) };
let brute_str = match &brute {
Some((c, _, s)) => format!("{:.4} ({:.1} s)", *c as f64 / lines as f64, s),
None => "not run".to_string(),
};
let eq = match &brute {
Some((c, _, _)) => if *c == dp_cost { "yes" } else { "NO" },
None => "n/a",
};
println!(
"| {} | {} | {:.4} | {} | {:.4} (DAG {:.4}) | {} | {} |",
label,
n,
naive_cost as f64 / lines as f64,
pos_str,
dp_cost as f64 / lines as f64,
dp_cost_dag as f64 / lines as f64,
brute_str,
eq
);
}
}
fn cmd_curve(args: &HashMap<String, String>) {
let lines: usize = args.get("lines").map(|s| s.parse().unwrap()).unwrap_or(64);
let ops_block: u64 = args.get("ops-block").map(|s| s.parse().unwrap()).unwrap_or(OPS_PER_BLOCK);
let cache_mib: f64 = args.get("cache-mib").map(|s| s.parse().unwrap()).unwrap_or(256.0);
let dag = extract_dag(lines, Variant::Real);
println!("curve: ops per block {ops_block} (counted {OPS_PER_BLOCK}; MEMHARD.md approximate {OPS_PER_BLOCK_MEMHARD}), mixer ops per item {OPS_PER_ITEM_MIXER}, {READS_PER_ITEM} dependent reads per item, {ITEMS_PER_HASH} items per hash + {OPS_PER_HASH_PROGRAM}");
println!("| f (cache lines stored) | SRAM MiB | blocks per read, naive | blocks per read, optimal | recompute ops per item, naive | recompute ops per item, optimal | ops per item, naive | ops per item, optimal | ops per hash, optimal | MH/s at 50 T op/s, optimal |");
println!("|---|---|---|---|---|---|---|---|---|---|");
let mut prev_opt: Option<f64> = None;
let mut prev_naive: Option<f64> = None;
let mut monotone = true;
for (den, label) in fractions() {
if lines < den {
continue;
}
let n = lines / den;
let naive_cost = total_cost(&dag.parents, &stored_from(lines, &naive_positions(lines, n))) as f64 / lines as f64;
let (dp_cost, _) = dp_best(lines, n);
let opt_cost = dp_cost as f64 / lines as f64;
let rec_naive = READS_PER_ITEM as f64 * naive_cost * ops_block as f64;
let rec_opt = READS_PER_ITEM as f64 * opt_cost * ops_block as f64;
let item_naive = OPS_PER_ITEM_MIXER as f64 + rec_naive;
let item_opt = OPS_PER_ITEM_MIXER as f64 + rec_opt;
let hash_opt = ITEMS_PER_HASH as f64 * item_opt + OPS_PER_HASH_PROGRAM as f64;
let mhs = 50.0e12 / hash_opt / 1.0e6;
if let Some(p) = prev_opt {
if item_opt > p {
monotone = false;
}
}
if let Some(p) = prev_naive {
if item_naive > p {
monotone = false;
}
}
prev_opt = Some(item_opt);
prev_naive = Some(item_naive);
println!(
"| {} | {:.1} | {:.4} | {:.4} | {:.0} | {:.0} | {:.0} | {:.0} | {:.0} | {:.1} |",
label,
cache_mib / den as f64,
naive_cost,
opt_cost,
rec_naive,
rec_opt,
item_naive,
item_opt,
hash_opt,
mhs
);
}
println!("CURVE lines {lines}: ops per item {} with f on both patterns; f = 1 point = {} ops per item, {} ops per hash (the chip-model-v3.md 5.4 'none, f = 0' row of the ITEM curve: 1,198,592 ops per hash, 41.7 MH/s at 50 T op/s)", if monotone { "monotone non-increasing" } else { "NOT monotone" }, OPS_PER_ITEM_MIXER, ITEMS_PER_HASH * OPS_PER_ITEM_MIXER + OPS_PER_HASH_PROGRAM);
}
fn cmd_avalanche(args: &HashMap<String, String>) {
let samples: u64 = args.get("samples").map(|s| s.parse().unwrap()).unwrap_or(1 << 20);
let seed: u64 = args.get("seed").map(|s| s.parse().unwrap()).unwrap_or(1);
let mut rng = SplitMix64::new(seed);
let t0 = Instant::now();
// single-bit avalanche of the block
let mut sum = 0u64;
let mut min = u32::MAX;
let mut max = 0u32;
let mut word_miss = [[0u64; 16]; 16];
let mut per_word = [0u64; 16];
for _ in 0..samples {
let mut x = [0u32; 16];
for w in x.iter_mut() {
*w = rng.next() as u32;
}
let bit = rng.below(512) as usize;
let mut x2 = x;
x2[bit / 32] ^= 1u32 << (bit % 32);
let y = chacha_block(&x);
let y2 = chacha_block(&x2);
let mut d = 0u32;
for o in 0..16 {
let dd = (y[o] ^ y2[o]).count_ones();
d += dd;
if dd == 0 {
word_miss[o][bit / 32] += 1;
}
}
per_word[bit / 32] += 1;
sum += d as u64;
min = min.min(d);
max = max.max(d);
}
let mean = sum as f64 / samples as f64;
let worst_miss = word_miss.iter().flat_map(|r| r.iter()).copied().max().unwrap_or(0);
println!("avalanche block: {samples} single-bit flips, output bits changed mean {:.2} of 512 (ideal 256), min {min}, max {max}; (output word, input word) pairs where the output word did not change: worst count {worst_miss} (ideal about {:.1} = samples / 16 / 2^32)", mean, samples as f64 / 16.0 / 4_294_967_296.0);
// the chained step: flip one bit of line j - 1, measure line j (x_j = line_{j-1} XOR const, the same block)
// and line j + 1 (two blocks on): both should be at 256
let key = day_key("2026-10-03");
let c = real_const(key, 5);
let mut sum1 = 0u64;
let mut sum2 = 0u64;
let n2 = samples / 8;
for _ in 0..n2 {
let mut prev = [0u32; 16];
for w in prev.iter_mut() {
*w = rng.next() as u32;
}
let j = 1 + rng.below(62) as usize;
let bit = rng.below(512) as usize;
let mut prev2 = prev;
prev2[bit / 32] ^= 1u32 << (bit % 32);
let step = |p: &[u32; 16], jj: usize| {
let mut x = c(jj);
for i in 0..16 {
x[i] ^= p[i];
}
chacha_block(&x)
};
let a1 = step(&prev, j);
let b1 = step(&prev2, j);
let a2 = step(&a1, j + 1);
let b2 = step(&b1, j + 1);
sum1 += (0..16).map(|i| (a1[i] ^ b1[i]).count_ones() as u64).sum::<u64>();
sum2 += (0..16).map(|i| (a2[i] ^ b2[i]).count_ones() as u64).sum::<u64>();
}
println!("avalanche chain step: {n2} flips of one bit of line j-1: line j changes {:.2} bits of 512, line j+1 changes {:.2} bits", sum1 as f64 / n2 as f64, sum2 as f64 / n2 as f64);
// differential linearity: does B(x ^ d) ^ B(x) depend on x? (a block whose differences were x-independent
// would let a stored line of one segment derive another's)
let mut equal = 0u64;
let n3 = samples / 4;
for _ in 0..n3 {
let mut x = [0u32; 16];
let mut y = [0u32; 16];
let mut d = [0u32; 16];
for i in 0..16 {
x[i] = rng.next() as u32;
y[i] = rng.next() as u32;
}
let bit = rng.below(512) as usize;
d[bit / 32] = 1u32 << (bit % 32);
let mut xd = x;
let mut yd = y;
for i in 0..16 {
xd[i] ^= d[i];
yd[i] ^= d[i];
}
let bx = chacha_block(&x);
let bxd = chacha_block(&xd);
let by = chacha_block(&y);
let byd = chacha_block(&yd);
let mut same = true;
for i in 0..16 {
if (bx[i] ^ bxd[i]) != (by[i] ^ byd[i]) {
same = false;
break;
}
}
if same {
equal += 1;
}
}
println!("avalanche differential: {n3} (x, y, single-bit d): B(x^d)^B(x) == B(y^d)^B(y) in {equal} cases (ideal 0); {:.1} s", t0.elapsed().as_secs_f64());
let pass = worst_miss <= 4 && (mean - 256.0).abs() < 1.0 && equal == 0;
println!("AVALANCHE {}", if pass { "PASS" } else { "FAIL" });
}
fn cmd_census(args: &HashMap<String, String>) {
let day = args.get("day").map(String::as_str).unwrap_or("2026-10-03");
let t0 = Instant::now();
let key = day_key(day);
let cache = Cache::fill(key);
let fill_s = t0.elapsed().as_secs_f64();
let words = cache.words();
let lines = cache.lines();
let mut v: Vec<[u32; 16]> = Vec::with_capacity(lines);
for l in 0..lines {
let mut a = [0u32; 16];
a.copy_from_slice(&words[l * 16..l * 16 + 16]);
v.push(a);
}
let zero = v.iter().filter(|a| a.iter().all(|&w| w == 0)).count();
v.sort_unstable();
let mut distinct = 1usize;
let mut dup_pairs = 0usize;
for i in 1..v.len() {
if v[i] != v[i - 1] {
distinct += 1;
} else {
dup_pairs += 1;
}
}
println!(
"census day {day} key {:08x}..: {} lines in {} segments (2^{} words), fill {:.1} s on one thread ({:.0} ns per block); distinct lines {distinct}, duplicate adjacent pairs {dup_pairs}, all-zero lines {zero}; {:.1} s",
key[0],
lines,
cache.segments(),
CACHE_LOG2_WORDS,
fill_s,
fill_s * 1e9 / lines as f64,
t0.elapsed().as_secs_f64()
);
println!("CENSUS {}", if distinct == lines && zero == 0 { "PASS (every line distinct: no two chains merge, no repeated block input)" } else { "FAIL" });
}
fn cmd_bench(args: &HashMap<String, String>) {
let n: u64 = args.get("n").map(|s| s.parse().unwrap()).unwrap_or(20_000_000);
let day = args.get("day").map(String::as_str).unwrap_or("2026-10-03");
let key = day_key(day);
let shape = Shape { mixer_mult: 8, cache_log2_words: CACHE_LOG2_WORDS as u32, derive_len: 0 };
let mp = MixParams::with_shape(key, shape);
// dependent ChaCha12 blocks
let mut x = [0u32; 16];
x[..8].copy_from_slice(&key);
let t0 = Instant::now();
for _ in 0..n {
x = chacha_block(&x);
}
let block_ns = t0.elapsed().as_nanos() as f64 / n as f64;
println!("bench block: {n} dependent chacha_block: {:.2} ns each (sink {:08x})", block_ns, x[0]);
// dependent mixer applications under class v4's parameters
let mut s = [0u32; 16];
s[..8].copy_from_slice(&key);
let t0 = Instant::now();
for i in 0..n {
let rk = round_key_mult((i % 9) as usize, (i % 8) as usize, 8);
mixer(&mut s, rk, &mp);
}
let mixer_ns = t0.elapsed().as_nanos() as f64 / n as f64;
println!("bench mixer: {n} dependent applications: {:.2} ns each (sink {:08x}); block / mixer application = {:.2} (counted ops {} / 128 = {:.2})", mixer_ns, s[0], block_ns / mixer_ns, OPS_PER_BLOCK, OPS_PER_BLOCK as f64 / 128.0);
// whole items against the real 256 MiB cache (72 mixer applications + 8 dependent DRAM reads)
let t0 = Instant::now();
let cache = Cache::fill(key);
println!("bench cache fill: {:.2} s on one thread", t0.elapsed().as_secs_f64());
let items: u64 = (n / 200).max(32);
let mut rng = SplitMix64::new(7);
let mut out = [[0u32; 16]; 32];
let mut sink = 0u32;
let t0 = Instant::now();
let mut done = 0u64;
while done < items {
let ts: Vec<u32> = (0..32).map(|_| rng.next() as u32).collect();
derive_items(&ts, &mp, &cache, &mut out);
sink ^= out[0][0];
done += 32;
}
let item_ns = t0.elapsed().as_nanos() as f64 / done as f64;
println!("bench item: {done} items (batches of 32) against the 256 MiB cache: {:.1} ns each (72 x mixer = {:.1} ns; remainder {:.1} ns = 8 dependent reads and the fold; sink {:08x})", item_ns, 72.0 * mixer_ns, item_ns - 72.0 * mixer_ns, sink);
// a line recomputed from nothing: j + 1 blocks, j uniform
let lines_n = (n / 64).max(1);
let mut rng = SplitMix64::new(9);
let mut sink2 = 0u32;
let mut blocks = 0u64;
let t0 = Instant::now();
for _ in 0..lines_n {
let seg = rng.below(65_536) as u32;
let j = rng.below(64) as usize;
let c = u32_chain(j + 1, Variant::Real, key, seg);
sink2 ^= c[j][0];
blocks += (j + 1) as u64;
}
let line_ns = t0.elapsed().as_nanos() as f64 / lines_n as f64;
println!("bench line recompute from nothing: {lines_n} random (seg, j): {:.1} ns per line, {:.1} blocks per line on average, {:.2} ns per block (sink {:08x})", line_ns, blocks as f64 / lines_n as f64, line_ns * lines_n as f64 / blocks as f64, sink2);
println!("BENCH block {:.2} ns, mixer application {:.2} ns, item {:.1} ns, ratio block/mixer {:.2}", block_ns, mixer_ns, item_ns, block_ns / mixer_ns);
}
fn main() {
let argv: Vec<String> = std::env::args().collect();
if argv.len() < 2 {
eprintln!("usage: attack-f3 <verify|search|pebble|store|curve|avalanche|census|bench> [--lines N] [--variant real|skip2|nofeed] [--samples N] [--seed N] [--day D] [--n N] [--brute-max N] [--ops-block N]");
std::process::exit(1);
}
let cmd = argv[1].clone();
let mut args: HashMap<String, String> = HashMap::new();
let mut i = 2;
while i < argv.len() {
let k = argv[i].trim_start_matches("--").to_string();
let v = argv.get(i + 1).cloned().unwrap_or_default();
args.insert(k, v);
i += 2;
}
println!("attack-f3 {} {:?} (igneum-pow memhard: {} lines per segment, tag {:08x}{:08x})", cmd, args, CACHE_LINES_PER_SEGMENT, CACHE_TAG[0], CACHE_TAG[1]);
match cmd.as_str() {
"verify" => cmd_verify(&args),
"search" => cmd_search(&args),
"pebble" => cmd_pebble(&args),
"store" => cmd_store(&args),
"curve" => cmd_curve(&args),
"avalanche" => cmd_avalanche(&args),
"census" => cmd_census(&args),
"bench" => cmd_bench(&args),
_ => {
eprintln!("unknown command {cmd}");
std::process::exit(1);
}
}
}