igneum/tools/attack/f4-weakday/src/main.rs

990 lines
40 KiB
Rust

//! Attack pass F4: the weak-day census (`docs/plans/cryptanalysis.md` section 4.2 row F4, `funding.md` B2 rank 3 and
//! B5 rank 3). Every chain day `d` has the key `seed_words_from_bytes("igneum-day/" || d_le64)` (`bind::day_bytes`,
//! the interim day rule) and its mixer constants come from `MixParams::with_shape`, a SplitMix64 stream seeded from
//! `key[0] | key[1] << 32`: `ROT[0..7]` in 1..31, `MUL[0..15]` odd, `RC[0..15]`. This harness walks consecutive chain
//! days through the real draw code (the `igneum-pow` path dependency, nothing re-implemented) and classifies each
//! day's draw.
//!
//! The gain metrics, all exact and structural (what a datapath built for the day pays, in adder-equivalents per
//! mixer application; the verifier and every GPU pay the same ops on every day, so wall time cannot move):
//!
//! * M1, the per-day LUT datapath (an FPGA bitstream synthesised for the day, the only per-day attacker that
//! exists: a constant XOR is absorbed into the next LUT, a rotation by a constant is routing, a 32-bit add or
//! XOR is one 32-bit adder-equivalent, a multiply by a constant is `NAF(MUL) - 1` adders in canonical signed-digit
//! shift-add form): `cost = 32 adds + 32 xors + sum_i (naf(MUL_i) - 1)`. Gain of a day = the census median cost
//! over the day's cost. This is the generous bound: optimal single-constant multiplication is cheaper than NAF for
//! every constant, and a DSP-block multiply does not depend on the value at all.
//! * M2, the DSP-bound datapath (the multiplies in DSP blocks, value-independent): a word whose constant has NAF
//! weight at most 3 (two adders) moves to LUTs and frees its DSP, so gain = 16 / (16 - k) for k such words.
//! * ROT and RC classes: a constant rotation is wiring and a constant XOR is inverters on a per-day datapath, so
//! their value hands a datapath exactly 0 ops. They are censused as structure (counts against the analytic
//! expectation) and the worst members are measured for diffusion (`avalanche`), which bounds the only other
//! thing a rotation draw could move. A bit-exact verifier never lets a chip skip an application, however weak its
//! diffusion, so diffusion is reported and is not a gain.
//!
//! Commands:
//! attack-f4 census --from 20729 --count 16777216 --threads 12 [--out file] [--dedupe]
//! attack-f4 day --index 20729 one day's draw and classification
//! attack-f4 plant alleq|mul1|mul1all|rc0|rcrk0 the known-fail firings: the day 20729 draw with the planted field
//! attack-f4 expect --threads 12 exact per-word tables (NAF weight, popcount) over all 2^31 odd
//! constants, and the 16-fold convolution: the expected M1 tail
//! attack-f4 avalanche --index 20729 [--states 4096] single-application and two-application diffusion of a day
use igneum_pow::bind::day_bytes;
use igneum_pow::generator::V4_CLASS;
use igneum_pow::memhard::{mixer, round_key_mult, MixParams, Shape, ITEM_ROUNDS};
use igneum_pow::seed::{seed_words_from_bytes, SplitMix64};
use std::fmt::Write as _;
use std::io::Write as _;
/// The chain's genesis day index (`bind.rs` tests: day_index(0x1a0ff0f7c00) = 20,729, 3 October 2026).
const GENESIS_DAY: u64 = 20_729;
/// Mixer applications per item under class v4 (`Shape::mixers_per_item`): 9 x 8.
const APPLICATIONS_PER_ITEM: u64 = (ITEM_ROUNDS as u64 + 1) * 8;
/// Adds and XORs of one application outside the multiply layer: 8 quarter rounds x (4 adds + 4 xors).
const QR_ADDS_XORS: u32 = 64;
/// The gate's gain threshold (plan 1.4 (3), B5 rank 3).
const GAIN_GATE: f64 = 1.1;
/// M2: a constant of NAF weight at most this is cheaper in LUTs than in a DSP block.
const M2_NAF_CEIL: u32 = 3;
// ------------------------------------------------------------------------------------------------------------
// The day
// ------------------------------------------------------------------------------------------------------------
fn v4_shape() -> Shape {
let s = Shape::for_class(&V4_CLASS);
assert_eq!(s.mixer_mult, 8, "class v4 is the x8 mixer");
assert_eq!(s.derive_len, 0, "class v4 has no derivation program: the fixed mixer with drawn constants");
s
}
/// The chain day's key and its draw through the real code.
fn params_of_day(d: u64) -> MixParams {
MixParams::with_shape(seed_words_from_bytes(&day_bytes(d)), v4_shape())
}
fn seed64(key: &[u32; 8]) -> u64 {
key[0] as u64 | ((key[1] as u64) << 32)
}
/// Non-adjacent-form weight of a 32-bit constant (the number of nonzero signed digits).
fn naf_weight(v: u32) -> u32 {
let mut n = v as u64;
let mut w = 0;
while n != 0 {
if n & 1 == 1 {
// digit +1 when n = 1 mod 4, -1 when n = 3 mod 4
if n & 3 == 3 {
n += 1;
} else {
n -= 1;
}
w += 1;
}
n >>= 1;
}
w
}
/// Round keys of the 72 applications of an item under m = 8: `round_key(r * 8 + j)`, r in 0..=8, j in 0..8.
fn round_keys() -> [u32; 72] {
let mut k = [0u32; 72];
for r in 0..=ITEM_ROUNDS {
for j in 0..8 {
k[r * 8 + j] = round_key_mult(r, j, 8);
}
}
k
}
#[derive(Clone, Debug, Default)]
struct DayClass {
// ROT
rot_distinct: u32,
rot_all_equal: bool,
rot_max_mult: u32,
rot_comp_same_word: bool,
rot_comp_any: bool,
rot_small: u32,
rot_byte: u32,
// MUL
naf: [u32; 16],
naf_sum: u32,
naf_min: u32,
mul_one: u32,
mul_minus_one: u32,
mul_pop_le2: u32,
mul_pop_le4: u32,
mul_pop_le8: u32,
mul_naf_le2: u32,
mul_naf_le3: u32,
mul_naf_le4: u32,
mul_small: u32,
mul_dup: bool,
// RC
rc_zero: u32,
rc_pop_ext: u32,
rc_rk_zero: u32,
rc_dup: bool,
// gains
cost_m1: u32,
m2_k: u32,
}
fn classify(mp: &MixParams, rks: &[u32; 72]) -> DayClass {
let mut c = DayClass::default();
// ROT
let mut seen = [0u32; 32];
for &r in &mp.rot {
assert!((1..=31).contains(&r));
seen[r as usize] += 1;
if matches!(r, 1 | 2 | 30 | 31) {
c.rot_small += 1;
}
if matches!(r, 8 | 16 | 24) {
c.rot_byte += 1;
}
}
c.rot_distinct = seen.iter().filter(|&&n| n > 0).count() as u32;
c.rot_max_mult = *seen.iter().max().unwrap();
c.rot_all_equal = c.rot_distinct == 1;
// the same-word pairs of a quarter round: s[d] takes ROT[0] then ROT[2], s[b] takes ROT[1] then ROT[3]; the
// diagonal round the same with ROT[4..7]
for (a, b) in [(0, 2), (1, 3), (4, 6), (5, 7)] {
if mp.rot[a] + mp.rot[b] == 32 {
c.rot_comp_same_word = true;
}
}
for a in 0..8 {
for b in a + 1..8 {
if mp.rot[a] + mp.rot[b] == 32 {
c.rot_comp_any = true;
}
}
}
// MUL
c.naf_min = u32::MAX;
for i in 0..16 {
let m = mp.mul[i];
assert!(m & 1 == 1);
let w = naf_weight(m);
c.naf[i] = w;
c.naf_sum += w;
c.naf_min = c.naf_min.min(w);
let p = m.count_ones();
if m == 1 {
c.mul_one += 1;
}
if m == u32::MAX {
c.mul_minus_one += 1;
}
if p <= 2 {
c.mul_pop_le2 += 1;
}
if p <= 4 {
c.mul_pop_le4 += 1;
}
if p <= 8 {
c.mul_pop_le8 += 1;
}
if w <= 2 {
c.mul_naf_le2 += 1;
}
if w <= 3 {
c.mul_naf_le3 += 1;
}
if w <= 4 {
c.mul_naf_le4 += 1;
}
if m < 256 {
c.mul_small += 1;
}
for j in 0..i {
if mp.mul[j] == m {
c.mul_dup = true;
}
}
}
c.cost_m1 = QR_ADDS_XORS + c.naf_sum - 16;
c.m2_k = c.mul_naf_le3;
// RC
for i in 0..16 {
let r = mp.rc[i];
if r == 0 {
c.rc_zero += 1;
}
let p = r.count_ones();
if p <= 4 || p >= 28 {
c.rc_pop_ext += 1;
}
for &rk in rks.iter() {
if r.wrapping_add(rk) == 0 {
c.rc_rk_zero += 1;
}
}
for j in 0..i {
if mp.rc[j] == r {
c.rc_dup = true;
}
}
}
c
}
fn m2_gain(k: u32) -> f64 {
16.0 / (16.0 - k as f64)
}
// ------------------------------------------------------------------------------------------------------------
// The tally
// ------------------------------------------------------------------------------------------------------------
/// The worst member of a class: the lowest M1 cost among the days in it (ties: the earliest day).
#[derive(Clone, Copy, Debug)]
struct Worst {
day: u64,
cost: u32,
}
impl Worst {
fn none() -> Self {
Worst { day: u64::MAX, cost: u32::MAX }
}
fn offer(&mut self, day: u64, cost: u32) {
if cost < self.cost || (cost == self.cost && day < self.day) {
*self = Worst { day, cost };
}
}
fn merge(&mut self, o: &Worst) {
if o.day != u64::MAX {
self.offer(o.day, o.cost);
}
}
}
const CLASSES: &[&str] = &[
"ROT all equal",
"ROT distinct <= 3",
"ROT distinct <= 4",
"ROT max multiplicity >= 4",
"ROT same-word pair sums to 32",
"ROT any pair sums to 32",
"ROT all 8 in {1,2,30,31}",
"ROT >= 6 in {1,2,30,31}",
"ROT >= 4 in {1,2,30,31}",
"ROT >= 4 in {8,16,24}",
"MUL any = 1",
"MUL any = 2^32-1",
"MUL any popcount <= 2",
"MUL any popcount <= 4",
"MUL any popcount <= 8",
"MUL any NAF weight <= 2",
"MUL any NAF weight <= 3",
"MUL any NAF weight <= 4",
"MUL any < 256",
"MUL two equal",
"MUL M2 k >= 2 (gain >= 1.14x)",
"RC any = 0",
"RC any popcount <= 4 or >= 28",
"RC + rk = 0 for any of the 72 keys",
"RC two equal",
];
#[derive(Clone, Debug)]
struct Tally {
n: u64,
class_count: Vec<u64>,
class_worst: Vec<Worst>,
cost_hist: Vec<u64>,
naf_sum_hist: Vec<u64>,
naf_min_hist: Vec<u64>,
rot_distinct_hist: [u64; 9],
rot_small_hist: [u64; 9],
rot_byte_hist: [u64; 9],
rot_mult_hist: [u64; 9],
m2_k_hist: [u64; 17],
/// The 16 lowest-cost days seen (cost, day), sorted ascending.
lowest: Vec<(u32, u64)>,
highest: Vec<(u32, u64)>,
seeds: Vec<u64>,
}
impl Tally {
fn new(dedupe: bool, cap: usize) -> Self {
Tally {
n: 0,
class_count: vec![0; CLASSES.len()],
class_worst: vec![Worst::none(); CLASSES.len()],
cost_hist: vec![0; 400],
naf_sum_hist: vec![0; 400],
naf_min_hist: vec![0; 40],
rot_distinct_hist: [0; 9],
rot_small_hist: [0; 9],
rot_byte_hist: [0; 9],
rot_mult_hist: [0; 9],
m2_k_hist: [0; 17],
lowest: Vec::new(),
highest: Vec::new(),
seeds: if dedupe { Vec::with_capacity(cap) } else { Vec::new() },
}
}
fn flags(c: &DayClass) -> [bool; 25] {
[
c.rot_all_equal,
c.rot_distinct <= 3,
c.rot_distinct <= 4,
c.rot_max_mult >= 4,
c.rot_comp_same_word,
c.rot_comp_any,
c.rot_small == 8,
c.rot_small >= 6,
c.rot_small >= 4,
c.rot_byte >= 4,
c.mul_one > 0,
c.mul_minus_one > 0,
c.mul_pop_le2 > 0,
c.mul_pop_le4 > 0,
c.mul_pop_le8 > 0,
c.mul_naf_le2 > 0,
c.mul_naf_le3 > 0,
c.mul_naf_le4 > 0,
c.mul_small > 0,
c.mul_dup,
c.m2_k >= 2,
c.rc_zero > 0,
c.rc_pop_ext > 0,
c.rc_rk_zero > 0,
c.rc_dup,
]
}
fn add(&mut self, day: u64, mp: &MixParams, c: &DayClass, dedupe: bool) {
self.n += 1;
let f = Self::flags(c);
assert_eq!(f.len(), CLASSES.len());
for (i, &on) in f.iter().enumerate() {
if on {
self.class_count[i] += 1;
self.class_worst[i].offer(day, c.cost_m1);
}
}
self.cost_hist[c.cost_m1 as usize] += 1;
self.naf_sum_hist[c.naf_sum as usize] += 1;
self.naf_min_hist[c.naf_min as usize] += 1;
self.rot_distinct_hist[c.rot_distinct as usize] += 1;
self.rot_small_hist[c.rot_small as usize] += 1;
self.rot_byte_hist[c.rot_byte as usize] += 1;
self.rot_mult_hist[c.rot_max_mult as usize] += 1;
self.m2_k_hist[c.m2_k as usize] += 1;
push_sorted(&mut self.lowest, (c.cost_m1, day), 16, true);
push_sorted(&mut self.highest, (c.cost_m1, day), 4, false);
if dedupe {
self.seeds.push(seed64(&mp.key));
}
}
fn merge(&mut self, o: Tally) {
self.n += o.n;
for i in 0..CLASSES.len() {
self.class_count[i] += o.class_count[i];
self.class_worst[i].merge(&o.class_worst[i]);
}
for (a, b) in self.cost_hist.iter_mut().zip(o.cost_hist.iter()) {
*a += b;
}
for (a, b) in self.naf_sum_hist.iter_mut().zip(o.naf_sum_hist.iter()) {
*a += b;
}
for (a, b) in self.naf_min_hist.iter_mut().zip(o.naf_min_hist.iter()) {
*a += b;
}
for i in 0..9 {
self.rot_distinct_hist[i] += o.rot_distinct_hist[i];
self.rot_small_hist[i] += o.rot_small_hist[i];
self.rot_byte_hist[i] += o.rot_byte_hist[i];
self.rot_mult_hist[i] += o.rot_mult_hist[i];
}
for i in 0..17 {
self.m2_k_hist[i] += o.m2_k_hist[i];
}
for e in o.lowest {
push_sorted(&mut self.lowest, e, 16, true);
}
for e in o.highest {
push_sorted(&mut self.highest, e, 4, false);
}
self.seeds.extend(o.seeds);
}
}
/// Keep the `cap` smallest (ascending) or largest (descending) entries.
fn push_sorted(v: &mut Vec<(u32, u64)>, e: (u32, u64), cap: usize, ascending: bool) {
if v.len() == cap {
let last = *v.last().unwrap();
let keep = if ascending { e < last } else { e > last };
if !keep {
return;
}
v.pop();
}
let pos = if ascending { v.partition_point(|x| *x < e) } else { v.partition_point(|x| *x > e) };
v.insert(pos, e);
}
// ------------------------------------------------------------------------------------------------------------
// Analytic expectations (per day, independent draws; the modulo-31 bias of `below` is 2^-64 per value and ignored)
// ------------------------------------------------------------------------------------------------------------
fn ln_choose(n: u64, k: u64) -> f64 {
let lg = |x: u64| -> f64 { (1..=x).map(|i| (i as f64).ln()).sum() };
lg(n) - lg(k) - lg(n - k)
}
fn binom_tail(n: u64, p: f64, k_min: u64) -> f64 {
(k_min..=n).map(|k| (ln_choose(n, k) + (k as f64) * p.ln() + ((n - k) as f64) * (1.0 - p).ln()).exp()).sum()
}
/// P(d distinct values among 8 uniform draws from 31): S(8, d) x 31 falling d / 31^8.
fn p_rot_distinct(d: u32) -> f64 {
// Stirling numbers of the second kind S(8, d)
let mut s = vec![vec![0f64; 9]; 9];
s[0][0] = 1.0;
for n in 1..=8 {
for k in 1..=n {
s[n][k] = (k as f64) * s[n - 1][k] + s[n - 1][k - 1];
}
}
let mut falling = 1.0;
for i in 0..d {
falling *= (31 - i) as f64;
}
s[8][d as usize] * falling / 31f64.powi(8)
}
/// P(max multiplicity >= 4 among 8 draws from 31), by enumeration of the multiplicity patterns is long; the
/// census gives the exact count, so this is the first-order bound: C(8,4) x 31 x 31^-4 (approximate).
fn p_rot_mult4_approx() -> f64 {
70.0 * 31.0 / 31f64.powi(4)
}
fn p_any_of(n: u64, p: f64) -> f64 {
1.0 - (1.0 - p).powi(n as i32)
}
fn one_in(p: f64) -> String {
if p <= 0.0 {
"0".into()
} else {
format!("1 in {:.3e}", 1.0 / p)
}
}
// ------------------------------------------------------------------------------------------------------------
// Printing a day
// ------------------------------------------------------------------------------------------------------------
fn hex_bytes(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn describe(day: u64, mp: &MixParams, c: &DayClass, median_cost: Option<u32>) -> String {
let mut s = String::new();
let _ = writeln!(s, "day index {day} (genesis + {}), day bytes {} , seed64 {:016x}", day as i64 - GENESIS_DAY as i64, hex_bytes(&day_bytes(day)), seed64(&mp.key));
let _ = writeln!(s, "key {}", mp.key.iter().map(|w| format!("{w:08x}")).collect::<Vec<_>>().join(" "));
let _ = writeln!(s, "ROT {:?} distinct {} max multiplicity {} small {} byte-aligned {} same-word pair 32 {} any pair 32 {}", mp.rot, c.rot_distinct, c.rot_max_mult, c.rot_small, c.rot_byte, c.rot_comp_same_word, c.rot_comp_any);
let _ = writeln!(s, "MUL {}", mp.mul.iter().map(|w| format!("{w:08x}")).collect::<Vec<_>>().join(" "));
let _ = writeln!(s, "NAF {:?} sum {} min {} =1 {} =-1 {} pop<=4 {} naf<=3 {} <256 {} dup {}", c.naf, c.naf_sum, c.naf_min, c.mul_one, c.mul_minus_one, c.mul_pop_le4, c.mul_naf_le3, c.mul_small, c.mul_dup);
let _ = writeln!(s, "RC {}", mp.rc.iter().map(|w| format!("{w:08x}")).collect::<Vec<_>>().join(" "));
let _ = writeln!(s, "RC zero {} pop<=4|>=28 {} rc+rk=0 {} dup {}", c.rc_zero, c.rc_pop_ext, c.rc_rk_zero, c.rc_dup);
let _ = writeln!(s, "M1 cost {} adder-equivalents per application ({} per item, 72 applications); M2 k {} (gain {:.3}x)", c.cost_m1, c.cost_m1 as u64 * APPLICATIONS_PER_ITEM, c.m2_k, m2_gain(c.m2_k));
if let Some(m) = median_cost {
let _ = writeln!(s, "M1 gain against the census median {m}: {:.4}x", m as f64 / c.cost_m1 as f64);
}
let flags: Vec<&str> = Tally::flags(c).iter().zip(CLASSES.iter()).filter(|(f, _)| **f).map(|(_, n)| *n).collect();
let _ = writeln!(s, "classes: {}", if flags.is_empty() { "none".to_string() } else { flags.join("; ") });
s
}
// ------------------------------------------------------------------------------------------------------------
// Commands
// ------------------------------------------------------------------------------------------------------------
fn arg(args: &[String], name: &str) -> Option<String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
}
fn census(args: &[String]) {
let from: u64 = arg(args, "--from").map(|v| v.parse().unwrap()).unwrap_or(GENESIS_DAY);
let count: u64 = arg(args, "--count").map(|v| v.parse().unwrap()).unwrap_or(1 << 24);
let threads: usize = arg(args, "--threads").map(|v| v.parse().unwrap()).unwrap_or(12);
let out = arg(args, "--out");
let dedupe = args.iter().any(|a| a == "--dedupe");
let shape = v4_shape();
let rks = round_keys();
let t0 = std::time::Instant::now();
let chunk = count.div_ceil(threads as u64);
let tallies: Vec<Tally> = std::thread::scope(|sc| {
let hs: Vec<_> = (0..threads)
.map(|t| {
let rks = &rks;
sc.spawn(move || {
let lo = from + chunk * t as u64;
let hi = (lo + chunk).min(from + count);
let mut tally = Tally::new(dedupe, (hi.saturating_sub(lo)) as usize);
for d in lo..hi {
let mp = params_of_day(d);
debug_assert_eq!(mp.shape, shape);
let c = classify(&mp, rks);
tally.add(d, &mp, &c, dedupe);
}
tally
})
})
.collect();
hs.into_iter().map(|h| h.join().unwrap()).collect()
});
let mut all = Tally::new(false, 0);
for t in tallies {
all.merge(t);
}
let secs = t0.elapsed().as_secs_f64();
assert_eq!(all.n, count);
// the median cost and the gate fractions
let total = all.n;
let mut acc = 0u64;
let mut median = 0u32;
for (c, &n) in all.cost_hist.iter().enumerate() {
acc += n;
if acc * 2 >= total {
median = c as u32;
break;
}
}
let mean = all.cost_hist.iter().enumerate().map(|(c, &n)| c as f64 * n as f64).sum::<f64>() / total as f64;
let var = all.cost_hist.iter().enumerate().map(|(c, &n)| (c as f64 - mean).powi(2) * n as f64).sum::<f64>() / total as f64;
let gate_cost = |reference: f64| -> u32 { (reference / GAIN_GATE).floor() as u32 }; // cost <= this gives gain >= 1.1x... strictly over: cost < reference/1.1
let over = |reference: f64| -> u64 {
all.cost_hist.iter().enumerate().filter(|(c, _)| (*c as f64) * GAIN_GATE < reference).map(|(_, &n)| n).sum()
};
let over_median = over(median as f64);
let over_mean = over(mean);
let m2_over: u64 = all.m2_k_hist.iter().enumerate().filter(|(k, _)| m2_gain(*k as u32) > GAIN_GATE).map(|(_, &n)| n).sum();
let mut r = String::new();
let _ = writeln!(r, "# attack-f4 census: {count} chain days from day index {from} (genesis {GENESIS_DAY}), class v4 shape (mixer x{}, cache 2^{}), {threads} threads, {secs:.1} s", shape.mixer_mult, shape.cache_log2_words);
let _ = writeln!(r, "draw: MixParams::with_shape(seed_words_from_bytes(bind::day_bytes(d)), Shape::for_class(&V4_CLASS)); seed64 = key[0] | key[1] << 32");
let _ = writeln!(r);
let _ = writeln!(r, "## Gate (plan 1.4 (3), B5 rank 3): fraction of days with any gain over {GAIN_GATE}x under 2^-20 = {:.3e}", 2f64.powi(-20));
let _ = writeln!(r);
let _ = writeln!(r, "| Metric | Reference | Days over {GAIN_GATE}x | Fraction | Against 2^-20 |");
let _ = writeln!(r, "|---|---|---|---|---|");
let frac = |n: u64| n as f64 / total as f64;
let vs = |n: u64| if frac(n) < 2f64.powi(-20) { "under" } else { "OVER" };
let _ = writeln!(r, "| M1 per-day LUT datapath, adders per application | median cost {median} (gain over {GAIN_GATE}x = cost under {}) | {over_median} | {:.3e} | {} |", gate_cost(median as f64) + 1, frac(over_median), vs(over_median));
let _ = writeln!(r, "| M1 against the mean cost {mean:.2} (sd {:.2}) | cost under {:.2} | {over_mean} | {:.3e} | {} |", var.sqrt(), mean / GAIN_GATE, frac(over_mean), vs(over_mean));
let _ = writeln!(r, "| M2 DSP-bound datapath, 16/(16-k) with k = words of NAF weight <= {M2_NAF_CEIL} | k >= 2 | {m2_over} | {:.3e} | {} |", frac(m2_over), vs(m2_over));
let _ = writeln!(r, "| ROT value (wiring) and RC value (inverters) on a per-day datapath | exact 0 ops moved on every day | 0 | 0 | under |");
let _ = writeln!(r);
let _ = writeln!(r, "M1 cost = {QR_ADDS_XORS} + sum(NAF(MUL_i) - 1); mean {mean:.3}, sd {:.3}, median {median}, min {} (day {}), max {} (day {})", var.sqrt(), all.lowest[0].0, all.lowest[0].1, all.highest[0].0, all.highest[0].1);
let _ = writeln!(r);
// the classes
let p_mul1 = p_any_of(16, 2f64.powi(-31));
let p_small = p_any_of(16, 128.0 / 2f64.powi(31));
let p_rc0 = p_any_of(16, 2f64.powi(-32));
let p_rcrk = p_any_of(16, 72.0 / 2f64.powi(32));
let pop_le = |k: u32| -> f64 { (0..=k).map(|i| ln_choose(32, i as u64).exp()).sum::<f64>() };
let p_rc_pop = p_any_of(16, 2.0 * pop_le(4) / 2f64.powi(32));
// odd constants with popcount <= k: the low bit is set, so C(31, i) for the other i < k bits
let odd_pop_le = |k: u32| -> f64 { (0..k).map(|i| ln_choose(31, i as u64).exp()).sum::<f64>() / 2f64.powi(31) };
let p_dup16 = |space: f64| -> f64 { 1.0 - (1..16).map(|i| 1.0 - i as f64 / space).product::<f64>() };
let expectations: Vec<Option<f64>> = vec![
Some(31f64.powi(-7)),
Some((1..=3).map(p_rot_distinct).sum()),
Some((1..=4).map(p_rot_distinct).sum()),
Some(p_rot_mult4_approx()),
Some(p_any_of(4, 1.0 / 31.0)),
Some(p_any_of(28, 1.0 / 31.0)),
Some((4f64 / 31.0).powi(8)),
Some(binom_tail(8, 4.0 / 31.0, 6)),
Some(binom_tail(8, 4.0 / 31.0, 4)),
Some(binom_tail(8, 3.0 / 31.0, 4)),
Some(p_mul1),
Some(p_mul1),
Some(p_any_of(16, odd_pop_le(2))),
Some(p_any_of(16, odd_pop_le(4))),
Some(p_any_of(16, odd_pop_le(8))),
None,
None,
None,
Some(p_small),
Some(p_dup16(2f64.powi(31))),
None,
Some(p_rc0),
Some(p_rc_pop),
Some(p_rcrk),
Some(p_dup16(2f64.powi(32))),
];
let _ = writeln!(r, "## Classes over {count} days");
let _ = writeln!(r);
let _ = writeln!(r, "| Class | Count | Fraction | Expected per day (analytic) | Expected count | Worst member (day, M1 cost, M1 gain vs median, M2 gain) |");
let _ = writeln!(r, "|---|---|---|---|---|---|");
for (i, name) in CLASSES.iter().enumerate() {
let n = all.class_count[i];
let w = all.class_worst[i];
let worst = if w.day == u64::MAX {
"none".to_string()
} else {
let c = classify(&params_of_day(w.day), &rks);
format!("day {} , cost {} , {:.4}x , {:.3}x", w.day, w.cost, median as f64 / w.cost as f64, m2_gain(c.m2_k))
};
let (ep, ec) = match expectations[i] {
Some(p) => (format!("{p:.3e} ({})", one_in(p)), format!("{:.3}", p * total as f64)),
None => ("see `expect`".to_string(), "see `expect`".to_string()),
};
let _ = writeln!(r, "| {name} | {n} | {:.3e} | {ep} | {ec} | {worst} |", frac(n));
}
let _ = writeln!(r);
let _ = writeln!(r, "## Histograms");
let _ = writeln!(r);
let _ = writeln!(r, "| ROT distinct amounts | Count | Fraction | Expected (S(8,d) 31_d / 31^8) |");
let _ = writeln!(r, "|---|---|---|---|");
for d in 1..=8 {
let _ = writeln!(r, "| {d} | {} | {:.4e} | {:.4e} |", all.rot_distinct_hist[d], frac(all.rot_distinct_hist[d]), p_rot_distinct(d as u32));
}
let _ = writeln!(r);
let _ = writeln!(r, "| ROT amounts in {{1,2,30,31}} | Count | Expected Binomial(8, 4/31) | ROT amounts in {{8,16,24}} | Count | Expected Binomial(8, 3/31) | ROT max multiplicity | Count |");
let _ = writeln!(r, "|---|---|---|---|---|---|---|---|");
for k in 0..=8 {
let e1 = binom_tail(8, 4.0 / 31.0, k) - binom_tail(8, 4.0 / 31.0, k + 1);
let e2 = binom_tail(8, 3.0 / 31.0, k) - binom_tail(8, 3.0 / 31.0, k + 1);
let _ = writeln!(r, "| {k} | {} | {:.1} | {k} | {} | {:.1} | {k} | {} |", all.rot_small_hist[k as usize], e1 * total as f64, all.rot_byte_hist[k as usize], e2 * total as f64, all.rot_mult_hist[k as usize]);
}
let _ = writeln!(r);
let _ = writeln!(r, "| M2 k (words of NAF weight <= {M2_NAF_CEIL}) | Count | Gain 16/(16-k) |");
let _ = writeln!(r, "|---|---|---|");
for k in 0..=16 {
if all.m2_k_hist[k] > 0 || k <= 3 {
let _ = writeln!(r, "| {k} | {} | {:.3}x |", all.m2_k_hist[k], m2_gain(k as u32));
}
}
let _ = writeln!(r);
let _ = writeln!(r, "| Minimum NAF weight over the 16 MUL | Count |");
let _ = writeln!(r, "|---|---|");
for (w, &n) in all.naf_min_hist.iter().enumerate() {
if n > 0 {
let _ = writeln!(r, "| {w} | {n} |");
}
}
let _ = writeln!(r);
let _ = writeln!(r, "| M1 cost per application | Count | Cumulative fraction | M1 gain vs median |");
let _ = writeln!(r, "|---|---|---|---|");
let mut cum = 0u64;
for (c, &n) in all.cost_hist.iter().enumerate() {
if n > 0 {
cum += n;
let _ = writeln!(r, "| {c} | {n} | {:.4e} | {:.4}x |", frac(cum), median as f64 / c as f64);
}
}
let _ = writeln!(r);
let _ = writeln!(r, "## The 16 lowest-cost days (M1)");
let _ = writeln!(r);
for &(cost, day) in &all.lowest {
let mp = params_of_day(day);
let c = classify(&mp, &rks);
let _ = writeln!(r, "- day {day}: cost {cost}, gain {:.4}x vs median, NAF sum {}, M2 k {}, day-hex {}", median as f64 / cost as f64, c.naf_sum, c.m2_k, hex_bytes(&day_bytes(day)));
}
if dedupe {
all.seeds.sort_unstable();
let before = all.seeds.len();
all.seeds.dedup();
let _ = writeln!(r);
let _ = writeln!(r, "## 64-bit seeding: {} days, {} distinct 64-bit stream seeds ({} collisions; expected C(n,2)/2^64 = {:.3e})", before, all.seeds.len(), before - all.seeds.len(), (before as f64) * (before as f64 - 1.0) / 2.0 / 2f64.powi(64));
}
let _ = writeln!(r);
let _ = writeln!(r, "## Worst member of every class, in full");
let _ = writeln!(r);
let mut shown: Vec<u64> = Vec::new();
for (i, name) in CLASSES.iter().enumerate() {
let w = all.class_worst[i];
if w.day == u64::MAX || shown.contains(&w.day) {
continue;
}
shown.push(w.day);
let mp = params_of_day(w.day);
let c = classify(&mp, &rks);
let _ = writeln!(r, "### {name}: day {}", w.day);
let _ = writeln!(r, "```");
let _ = write!(r, "{}", describe(w.day, &mp, &c, Some(median)));
let _ = writeln!(r, "```");
}
print!("{r}");
if let Some(p) = out {
std::fs::File::create(&p).unwrap().write_all(r.as_bytes()).unwrap();
eprintln!("written {p}");
}
}
fn day_cmd(args: &[String]) {
let d: u64 = arg(args, "--index").map(|v| v.parse().unwrap()).unwrap_or(GENESIS_DAY);
let median: Option<u32> = arg(args, "--median").map(|v| v.parse().unwrap());
let mp = params_of_day(d);
let c = classify(&mp, &round_keys());
print!("{}", describe(d, &mp, &c, median));
}
/// The known-fail firings: the genesis day's draw with one field forced through this crate's own hook (the
/// `MixParams` fields are public; `igneum-pow` is untouched). Exit 0 when the classifier flags the plant and the
/// gain metric that the plant moves reads over the gate.
fn plant(args: &[String]) {
let what = args.get(2).cloned().unwrap_or_default();
let median: u32 = arg(args, "--median").map(|v| v.parse().unwrap()).unwrap_or(221);
let rks = round_keys();
let mut mp = params_of_day(GENESIS_DAY);
let before = classify(&mp, &rks);
println!("before the plant (day {GENESIS_DAY}):");
print!("{}", describe(GENESIS_DAY, &mp, &before, Some(median)));
let (flag_name, gain_metric): (&str, &str) = match what.as_str() {
"alleq" => {
mp.rot = [7; 8];
("ROT all equal", "structure (0 ops on a per-day datapath by construction; diffusion in `avalanche`)")
}
"mul1" => {
mp.mul[5] = 1;
("MUL any = 1", "M1")
}
"mul1all" => {
mp.mul = [1; 16];
("MUL any = 1", "M1")
}
"mulnaf" => {
// the lightest realistic plant: four words at NAF weight 3 (M2 k = 4), the rest untouched
for i in 0..4 {
mp.mul[i] = (1u32 << 20) + (1u32 << 9) + 1;
}
("MUL any NAF weight <= 3", "M1 and M2")
}
"rc0" => {
mp.rc[3] = 0;
("RC any = 0", "structure (0 ops on a per-day datapath by construction)")
}
"rcrk0" => {
mp.rc[3] = 0u32.wrapping_sub(round_key_mult(2, 5, 8));
("RC + rk = 0 for any of the 72 keys", "structure (one xor of 10,368 ops per item on a generic datapath: 1.0001x)")
}
_ => {
eprintln!("plant alleq|mul1|mul1all|mulnaf|rc0|rcrk0");
std::process::exit(2);
}
};
let after = classify(&mp, &rks);
println!("\nafter the plant `{what}`:");
print!("{}", describe(GENESIS_DAY, &mp, &after, Some(median)));
let idx = CLASSES.iter().position(|n| *n == flag_name).unwrap();
let flagged = Tally::flags(&after)[idx] && !Tally::flags(&before)[idx];
let gain_m1 = median as f64 / after.cost_m1 as f64;
let gain_m2 = m2_gain(after.m2_k);
println!("\nplant `{what}`: classifier flag `{flag_name}` {} (was {} before); M1 gain {gain_m1:.4}x, M2 gain {gain_m2:.4}x; gain metric for this plant: {gain_metric}", if flagged { "FIRED" } else { "did NOT fire" }, Tally::flags(&before)[idx]);
let gain_fired = gain_m1 > GAIN_GATE || gain_m2 > GAIN_GATE;
println!("gain over {GAIN_GATE}x: {}", if gain_fired { "FIRED" } else { "not over the gate" });
if !flagged {
std::process::exit(1);
}
}
/// Exact per-word tables over every odd 32-bit constant (2^31 of them): the NAF weight distribution and the
/// popcount distribution; then the 16-fold convolution of the NAF-weight distribution gives the expected M1 cost
/// distribution and the expected fraction of days under any cost.
fn expect(args: &[String]) {
let threads: usize = arg(args, "--threads").map(|v| v.parse().unwrap()).unwrap_or(12);
let median: u32 = arg(args, "--median").map(|v| v.parse().unwrap()).unwrap_or(221);
let t0 = std::time::Instant::now();
let per: Vec<([u64; 40], [u64; 33])> = std::thread::scope(|sc| {
let hs: Vec<_> = (0..threads)
.map(|t| {
sc.spawn(move || {
let mut naf = [0u64; 40];
let mut pop = [0u64; 33];
let lo = ((1u64 << 32) * t as u64 / threads as u64) | 1;
let hi = (1u64 << 32) * (t as u64 + 1) / threads as u64;
let mut v = lo;
while v < hi {
naf[naf_weight(v as u32) as usize] += 1;
pop[(v as u32).count_ones() as usize] += 1;
v += 2;
}
(naf, pop)
})
})
.collect();
hs.into_iter().map(|h| h.join().unwrap()).collect()
});
let mut naf = [0u64; 40];
let mut pop = [0u64; 33];
for (a, b) in per {
for i in 0..40 {
naf[i] += a[i];
}
for i in 0..33 {
pop[i] += b[i];
}
}
let total: u64 = naf.iter().sum();
assert_eq!(total, 1 << 31);
println!("# attack-f4 expect: all {total} odd 32-bit constants, {threads} threads, {:.1} s", t0.elapsed().as_secs_f64());
println!();
println!("| NAF weight | Odd constants | Fraction | Cumulative | Any of 16 per day | x 2^24 days |");
println!("|---|---|---|---|---|---|");
let mut cum = 0u64;
for w in 0..40 {
if naf[w] > 0 {
cum += naf[w];
let p = cum as f64 / total as f64;
println!("| {w} | {} | {:.4e} | {:.4e} | {:.4e} | {:.3} |", naf[w], naf[w] as f64 / total as f64, p, p_any_of(16, p), p_any_of(16, p) * 2f64.powi(24));
}
}
println!();
println!("| Popcount | Odd constants | Cumulative fraction | Any of 16 per day |");
println!("|---|---|---|---|");
cum = 0;
for w in 0..33 {
if pop[w] > 0 {
cum += pop[w];
let p = cum as f64 / total as f64;
println!("| {w} | {} | {:.4e} | {:.4e} |", pop[w], p, p_any_of(16, p));
}
}
// the 16-fold convolution of the NAF-weight distribution: the exact expected distribution of the NAF sum
let pw: Vec<f64> = naf.iter().map(|&n| n as f64 / total as f64).collect();
let mut dist = vec![0f64; 1];
dist[0] = 1.0;
for _ in 0..16 {
let mut next = vec![0f64; dist.len() + 39];
for (i, &a) in dist.iter().enumerate() {
if a == 0.0 {
continue;
}
for (w, &b) in pw.iter().enumerate() {
next[i + w] += a * b;
}
}
dist = next;
}
let mean: f64 = dist.iter().enumerate().map(|(s, &p)| s as f64 * p).sum();
let var: f64 = dist.iter().enumerate().map(|(s, &p)| (s as f64 - mean).powi(2) * p).sum();
println!();
println!("Expected NAF sum over 16 words: mean {mean:.4}, sd {:.4}; expected M1 cost mean {:.4}", var.sqrt(), mean + QR_ADDS_XORS as f64 - 16.0);
println!();
println!("| M1 cost | NAF sum | Expected fraction of days at this cost | Expected cumulative fraction | Gain vs median {median} |");
println!("|---|---|---|---|---|");
let mut c = 0f64;
for (s, &p) in dist.iter().enumerate() {
let cost = s as i64 + QR_ADDS_XORS as i64 - 16;
if cost < 0 {
continue;
}
c += p;
if p > 1e-12 && (cost as f64) <= median as f64 {
println!("| {cost} | {s} | {p:.4e} | {c:.4e} | {:.4}x |", median as f64 / cost as f64);
}
}
let gate: f64 = dist.iter().enumerate().filter(|(s, _)| ((*s as f64) + QR_ADDS_XORS as f64 - 16.0) * GAIN_GATE < median as f64).map(|(_, &p)| p).sum();
println!();
println!("Expected fraction of days with M1 gain over {GAIN_GATE}x against median {median}: {gate:.4e} ({} ; x 2^24 = {:.1}); 2^-20 = {:.4e}", one_in(gate), gate * 2f64.powi(24), 2f64.powi(-20));
}
/// Diffusion of one day's mixer: for `states` random 16-word states and each of the 512 input bits, the fraction of
/// the 512 output bits that flip after k = 1 and k = 2 applications (keys `round_key_mult(0, 0, 8)` and `(0, 1, 8)`),
/// mean over all, and the minimum per-output-bit flip probability. An ideal mixer reads 0.5 mean and about 0.5 min.
fn avalanche(args: &[String]) {
let d: u64 = arg(args, "--index").map(|v| v.parse().unwrap()).unwrap_or(GENESIS_DAY);
let states: usize = arg(args, "--states").map(|v| v.parse().unwrap()).unwrap_or(4096);
let plant_alleq: Option<u32> = arg(args, "--plant-alleq").map(|v| v.parse().unwrap());
let mut mp = params_of_day(d);
if let Some(r) = plant_alleq {
mp.rot = [r; 8];
}
let c = classify(&mp, &round_keys());
println!("avalanche of day {d}{}: ROT {:?}, NAF sum {}, {states} states x 512 input bits", plant_alleq.map(|r| format!(" with ROT planted all {r}")).unwrap_or_default(), mp.rot, c.naf_sum);
let mut rng = SplitMix64::new(0xF4F4_F4F4 ^ d);
for k in 1..=3usize {
let apply = |s: &mut [u32; 16]| {
for j in 0..k {
mixer(s, round_keys()[j], &mp);
}
};
let mut flips = [0u64; 512];
let mut total_flips = 0u64;
let mut trials = 0u64;
for _ in 0..states {
let mut base = [0u32; 16];
for w in base.iter_mut() {
*w = rng.next() as u32;
}
let mut y0 = base;
apply(&mut y0);
for bit in 0..512 {
let mut x = base;
x[bit / 32] ^= 1 << (bit % 32);
apply(&mut x);
for o in 0..512 {
let f = ((x[o / 32] ^ y0[o / 32]) >> (o % 32)) & 1;
flips[o] += f as u64;
total_flips += f as u64;
}
trials += 1;
}
}
let mean = total_flips as f64 / (trials as f64 * 512.0);
let min = flips.iter().map(|&f| f as f64 / trials as f64).fold(1.0, f64::min);
let max = flips.iter().map(|&f| f as f64 / trials as f64).fold(0.0, f64::max);
println!("| {k} application{} | mean flip {mean:.4} | min per output bit {min:.4} | max {max:.4} |", if k > 1 { "s" } else { "" });
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
match args.get(1).map(|s| s.as_str()) {
Some("census") => census(&args),
Some("day") => day_cmd(&args),
Some("plant") => plant(&args),
Some("expect") => expect(&args),
Some("avalanche") => avalanche(&args),
_ => {
eprintln!("attack-f4 census|day|plant|expect|avalanche (see the module doc)");
std::process::exit(2);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn naf_weights() {
assert_eq!(naf_weight(0), 0);
assert_eq!(naf_weight(1), 1);
assert_eq!(naf_weight(3), 2); // 4 - 1
assert_eq!(naf_weight(7), 2); // 8 - 1
assert_eq!(naf_weight(0xFFFF_FFFF), 2); // 2^32 - 1
assert_eq!(naf_weight(0xAAAA_AAAB), 17); // alternating odd: the maximum for 32 bits
assert_eq!(naf_weight((1 << 20) + (1 << 9) + 1), 3);
}
#[test]
fn genesis_day_draw_matches_memhard_md() {
// MEMHARD.md section 1.1 (string day "2026-10-03") is a different key from the chain's day index 20,729;
// what is checked here is that the chain-day path draws through the real code and stays in range.
let mp = params_of_day(GENESIS_DAY);
assert!(mp.rot.iter().all(|r| (1..=31).contains(r)));
assert!(mp.mul.iter().all(|m| m & 1 == 1));
assert_eq!(mp.shape, v4_shape());
let s = igneum_pow::seed::day_key("2026-10-03");
let mp2 = MixParams::with_shape(s, Shape::V2);
assert_eq!(mp2.rot, [20, 20, 19, 4, 26, 3, 3, 27], "MEMHARD.md 1.1 genesis-day ROT");
}
}