adv-mixer: attack plan for the memory-hard mixer M_r, harnesses copied

Internal adversarial pass, not an independent review. The plan restates M_r
from the frozen crate and spec 1.8, ranks Q3/Q2/Q1/Q4, gives the method, the
planted known-fail shape and the box-hour estimate per step, and lists every
file opened. Records the byte-identity result: build/master differs from the
frozen commit in accept.rs, emit.rs, generator.rs, packcheck.rs and two test
files, but is byte-identical over memhard.rs, seed.rs, bind.rs, derive.rs and
the Cargo pin, which define M_r.

Harnesses: adv-mixer (new: diffusion margin for Q2, the fold probe for Q1,
with a planted-weak-day hook), f4-weakday (copied read-only from
build/attack-pass for the Q3 census), f8-uniform (copied from the regate
worktree).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 18:09:36 +00:00
parent 5fb6d2b10f
commit 6033df803c
9 changed files with 3411 additions and 0 deletions

View file

@ -0,0 +1,215 @@
# Attack plan: the memory-hard mixer M_r
Internal adversarial pass, not an independent review.
Label rule: the phrase "internal adversarial pass, not an independent review" goes on every sentence from this
work that could be quoted in public. This is such a pass. It is not an outside review.
- Target commit: 017e70376489251e18564c0abce7e466e606c8b3 (class v4 sub-version 3, object byte 7).
- Branch: adv-mixer, from build/master.
- Attacker model: an outsider with the public kit. No defender numbers are read; any defender figure here is
derived from the crate or marked unknown.
- Author identity in the mixer: the attacker has never worked on the hash code.
## 0. The byte-identity check (the brief's gate 1)
The brief asks that `git diff --stat 017e7037... HEAD -- igneum-pow` print nothing. It does NOT print nothing.
Six files differ between the frozen commit and build/master HEAD:
| File | In the target? |
|---|---|
| igneum-pow/src/accept.rs | No (program acceptance, not the mixer) |
| igneum-pow/src/emit.rs | No (kernel emitters) |
| igneum-pow/src/generator.rs | No (program generator; V4_CLASS and Shape present on both) |
| igneum-pow/src/packcheck.rs | No (pack checker) |
| igneum-pow/tests/mixer.rs | No (test harness) |
| igneum-pow/tests/recheck.rs | No (test harness) |
The mixer itself is byte-identical. `git diff --stat 017e7037... HEAD -- igneum-pow/src/memhard.rs
igneum-pow/src/seed.rs igneum-pow/src/bind.rs igneum-pow/src/derive.rs igneum-pow/Cargo.toml
igneum-pow/Cargo.lock` prints nothing. So the mixer draw code (seed.rs), the mixer function and the item
derivation (memhard.rs), the day rule (bind.rs) and the dependency pin (Cargo.toml, Cargo.lock) are the frozen
ones. The harness builds against build/master's igneum-pow, whose generator.rs and accept.rs differ from frozen;
those files are not M_r. So the numbers this harness produces are the frozen mixer's numbers. Stated plainly:
build/master is NOT byte-identical to the frozen commit over the whole crate, but it IS byte-identical over every
file that defines M_r and its parameters.
## 1. The target, in the attacker's words
The dataset item is 16 words of 32 bits. The mixer M is one keyed round made of two layers.
1. Multiply layer, per word: `s[i] = (s[i] XOR (RC[i] + rk)) * MUL[i]`. MUL[i] is odd, so the multiply is a
bijection on 32 bits. rk is the 32-bit round key, the only thing that changes between applications.
2. Diffusion layer: one ChaCha-shaped double round. Four column quarter rounds with rotations ROT[0..3], then
four diagonal quarter rounds with ROT[4..7]. A quarter round is add, xor, rotate, four times.
Per item, class v4 (`mixer_mult = 8`): init the state from the day key and `t`, then for each of 8 rounds apply
M eight times (keys `round_key(r*8 + j)`, j = 0..7) and do one dependent cache read; after the last read apply M
eight more times. 9 x 8 = 72 applications per item. Only the round key changes between the 72. ROT (8 values in
1..31), MUL (16 odd values), RC (16 values) are drawn once per day from one SplitMix64 stream seeded with
`K[0] | (K[1] << 32)`, K the day key.
The day key is `seed_words_from_bytes("igneum-day/" || day_le64)` on the chain (interim rule, `bind::day_bytes`),
or `seed_words("day/" + iso)` in the spec's examples. The day is a pure function of the calendar day, so a weak
day is a public calendar.
The round key is `round_key(k) = (k + 1) * 0x9E3779B9 mod 2^32`. The 72 keys are the first 72 odd-ish multiples
of 0x9E3779B9. They are fixed, not drawn.
The cost model (chip-model-v3.md, read sections 1, 2, 5, 6): 130 ops per application hoisted, 9,360 ops per item,
1,198,080 ops per hash at m = 8. A shortcut is priced in ops per item against 9,360.
## 2. The questions, in attack order
The order is cheapest-reproducible first, then the structural questions.
| Rank | Q | What a result looks like |
|---|---|---|
| 1 | Q3 weak parameter draws | counted fraction of days in each class over >= 2^24 day keys, per-day op gain |
| 2 | Q2 round margin | largest K applications a distinguisher reaches, against 8 and 72 |
| 3 | Q1 structural shortcut | an op count below 8x for the 8 keyed applications, or the bound |
| 4 | Q4 anything else | any other measured gain |
## 3. Method per question
### Q3, weak parameter draws (harness: f4-weakday, copied read-only into tools/attack/f4-weakday)
The census walks consecutive chain days through the real draw code (`MixParams::with_shape`) and classifies each
day. Classes: ROT all equal, ROT distinct <= 3 or 4, ROT max multiplicity >= 4, ROT pair sums to 32 (same word
and any), ROT all or mostly in {1,2,30,31} or {8,16,24}; MUL any = 1, any = 2^32-1, any low popcount or low NAF
weight, any < 256, two equal, M2 class (NAF weight <= 3 frees a DSP); RC any = 0, RC + rk = 0 for any of the 72
keys, RC extreme popcount, two equal. The gain metric M1 is the per-day LUT datapath cost in adder-equivalents,
`32 adds + 32 xors + sum(NAF(MUL_i) - 1)`, gain = census median cost over the day cost. M2 gain = 16/(16-k).
Tool: `attack-f4 census --from 20729 --count 16777216 --threads 32 [--out file]`. Also `attack-f4 expect
--threads 32` for the exact analytic tail (NAF weight and popcount tables over all 2^31 odd constants, the
16-fold convolution), so the counted census is checked against the closed form.
Gate: the plan's gain gate is 1.1x. Any class with a per-day gain at or above 1.1x on a non-negligible fraction
of days is a FINDING. A verifier is bit-exact and never skips an application, so ROT and RC values hand a
datapath 0 ops and are reported as structure, not as a wall-time gain. Only MUL weight moves the LUT cost.
Known-failed shapes (the plant must fire): `attack-f4 plant alleq` (ROT all equal), `plant mul1` (one MUL = 1),
`plant mul1all` (all MUL = 1), `plant rc0` (one RC = 0), `plant rcrk0` (RC + rk = 0). Each prints the day 20729
draw with the planted field and the detector must flag the matching class.
### Q2, the round margin (harness: adv-mixer diffusion, new)
The strict-avalanche census of K consecutive keyed applications, K = 1..12, over N random states. For each state,
flip each of 512 input bits, apply K applications, tally the output bit-flip probability p[in][out]. Report, per
K: dependency holes (p exactly 0 or 1), strong-bias cells (|p - 0.5| above 8 sigma), the worst cell and its
sigma. A distinguisher reaches K if a hole or a strong bias survives at K. The bound is the largest such K
against the 8 between reads and the 72 per item.
Tool: `attack-adv-mixer diffusion --day 20729 --apps K --states 2000000 --threads 32` for K in 1..12. Also
`--start-app A` to confirm the margin does not depend on where in the 72 the window sits (keys differ).
Gate: full diffusion (no hole, no strong bias at the census band) at K means the distinguisher does not reach K.
The margin is 8 - K_max between reads and 72 - K_max per item.
Known-failed shape (the plant must fire): `--plant weak` builds a degenerate day by hand (MUL all 1, RC all 0,
ROT all 16). The detector must report many holes and strong bias at every K. A real day must not.
The avalanche census is a bound, not a full trail search. It does not prove the absence of a high-order
differential or a linear trail below the census band. Its reach is N states: a bias under 8/sqrt(N) is invisible.
At N = 2e6, 8 sigma is about 0.0057, so a bias below 0.57 percent is not seen. This limit is stated with the
result. A SAT or MILP trail search to tighten Q2 is scoped as owed work, not run tonight.
### Q1, the structural shortcut (harness: adv-mixer fold, new)
Three probes on the 8 keyed applications where only rk changes.
(a) The two multiply layers of adjacent applications do not merge. Test the two-application map g for GF(2)
affinity: for an affine g, `g(a) ^ g(b) ^ g(c) ^ g(a^b^c)` is constant. Count violations over N random
quadruples. Zero violations would mean g is affine and the two applications collapse to one linear map plus a
constant, a BREAK. Many violations is the bound: the diffusion between the two multiply layers is nonlinear,
so the multiplies do not fold.
(b) Word separability. Flip each input word of the full 8-application block and record which output words move.
A dead (in_word, out_word) pair over all probes is a broken dependency a shortcut could split on. Zero dead
pairs is the bound.
(c) Key-order commutation. Compare M(M(s,rk1),rk2) with M(M(s,rk2),rk1). Agreement would mean key order does not
matter and the 8 keys could be folded into fewer. Any agreement is a FINDING.
Tool: `attack-adv-mixer fold --day 20729 --trials 1000000`.
Gate: (a) at least one violation, (b) zero dead pairs, (c) zero agreements is the bound that the 8 keyed
applications cost 8x. Any breach is priced in ops per item against 9,360 and reported as a BREAK.
The algebraic view (Q1 candidate 3): the multiply layer is `x -> (x ^ c) * MUL` per word, a bijection but not
GF(2)-linear (the integer multiply carries). The diffusion layer mixes words. So the composition over 8
applications has rising algebraic degree. Probe (a) is the GF(2)-degree-1 test of the first two applications; a
pass there already rules out the cheapest fold. A full algebraic-degree or integral-distinguisher search is owed
work, scoped not run tonight.
### Q4, anything else
Two things to watch while the above runs. First, the round keys are fixed multiples of 0x9E3779B9, not drawn, so
a bad rk is the same every day: `round_key(k)` is checked in the self-test and the RC + rk = 0 class in f4 covers
the one way a fixed rk interacts with a drawn RC. Second, the item init `s[8+i] = t*MUL[i] + RC[i]` reuses MUL
and RC; a MUL[i] = 1 collapses that init word to `t + RC[i]`, which f4's mul1 class already counts. Any further
finding is added here.
## 4. Known-failed shape per method (the plant each tool must fire on)
| Method | Tool | Planted weakness | The tool must |
|---|---|---|---|
| Q3 census | attack-f4 plant alleq / mul1 / mul1all / rc0 / rcrk0 | the genesis day with one field forced weak | flag the matching class |
| Q2 diffusion | attack-adv-mixer diffusion --plant weak | MUL all 1, RC all 0, ROT all 16 | report holes and strong bias at every K |
| Q1 fold | (built in) | n/a: the probes are their own control, a real day must pass (a)-(c) | (a) violations > 0, (b) dead = 0, (c) agree = 0 |
The plant discipline follows the Mac rule: a watcher is trusted only after it fires on a known-failed case. Every
run prints its plant state and its verdict.
## 5. Box-hours per step
Build box 2, core band 64-95, nice 10, one slot at a time. Budget 8 box-hours for first results.
| Step | Command | Estimate |
|---|---|---|
| Build the two harnesses (release) | build-remote.sh --box 2 -- build --release | 0.15 box-hours |
| Q3 census 2^24 days, 32 threads | attack-f4 census --count 16777216 --threads 32 | 0.2 box-hours |
| Q3 analytic tail | attack-f4 expect --threads 32 | 0.3 box-hours |
| Q2 diffusion K = 1..12, 2e6 states each | attack-adv-mixer diffusion per K | 1.5 box-hours total |
| Q2 plant-weak firing check, K = 1..4 | attack-adv-mixer diffusion --plant weak | 0.1 box-hours |
| Q1 fold, 1e6 trials | attack-adv-mixer fold | 0.1 box-hours |
| Headroom for a wider census or a tighter K | | the rest |
Estimates are first-cut from the op counts (one application is about 130 ops; 2e6 states x 512 flips x K
applications fits a 32-core band in minutes). The report records the box-hours actually spent.
## 6. Files opened (the outsider read set)
Only these were read. Nothing else in the repository.
1. igneum-pow/src/memhard.rs (frozen, via git show at 017e7037).
2. igneum-pow/src/seed.rs (frozen).
3. igneum-pow/src/bind.rs (frozen, the day_bytes and day_index functions).
4. igneum-pow/src/generator.rs (frozen, the LoadClass, V3_CLASS, V4_CLASS, ProgramClass, generator version and
attempt-cap definitions; grepped, not read whole).
5. igneum-pow/tests/mixer.rs (frozen, the head: the test harness contract on the class v3 and v4 construction).
6. igneum-pow/Cargo.toml and Cargo.lock (dependency pin).
7. docs/spec/01-lottery-hash.md section 1.8 (frozen: 1.8.1 to 1.8.5).
8. docs/analysis/chip-model-v3.md sections 1, 2, 5, 6 (HEAD).
9. The public kit packs under proto-cuda/packs-ca3-v4 (frozen, the file listing; the eight packs named in the
brief). The kit zip sha256 is 4f2445c50c58d76a5544023492d8b858d0b07c5e372d31f9c90c4ce51f829154 per the brief,
to be checked when a pack is used as a vector.
10. The Devnet 3 epoch-0 pack v4-devnet3-epoch0 (public-kit class, named by a teammate lane): on build-1 at
/srv/artefacts/packs/v4-devnet3-epoch0/, zip sha256
e025750f71175ed14d6e2a24e387ebbf1979b1cd0faee9139c41a7671165b334, program id 0xfce15bf61030be57 at attempt 0,
2^24 fingerprint from base 0 e510ad92b4d24846, day bytes le64(20733). Copied read-only and the sha verified
before any use as a vector. This matches the Devnet 3 epoch-0 program id named in the brief as the check.
11. tools/attack/f4-weakday (build/attack-pass, copied read-only) and tools/attack/f8-uniform (the Mac worktree,
copied with cp -R, original untouched).
12. tools/build-remote.sh, infra/build-server/lib.sh, infra/build-server/remote-run.sh (the operating files).
13. CLAUDE.md (loaded on its own; only its operating rules are followed, not its doc references).
## 7. What is owed beyond tonight
- A SAT or MILP differential and linear trail search on M_r reduced to K applications, to tighten Q2 below the
avalanche census band.
- A rotational-XOR search on the drawn double round.
- An algebraic-degree or integral-distinguisher measurement across the 8 applications, to tighten Q1 beyond the
affinity probe.
- The census at more than 2^24 days if any class sits near the gate.

View file

@ -0,0 +1,21 @@
[package]
name = "attack-adv-mixer"
version = "0.1.0"
edition = "2021"
description = "Adversarial cryptanalysis of the memory-hard mixer M_r (spec 01 section 1.8.4): diffusion margin across the keyed applications (Q2), the composition-fold probe (Q1), with the planted-weak-day hooks that prove the harness fires"
license = "MIT"
publish = false
[[bin]]
name = "attack-adv-mixer"
path = "src/main.rs"
[dependencies]
igneum-pow = { path = "../../../igneum-pow" }
[workspace]
[profile.release]
opt-level = 3
lto = true
codegen-units = 1

View file

@ -0,0 +1,370 @@
//! attack-adv-mixer: adversarial cryptanalysis of the memory-hard mixer `M_r` (spec 01 section 1.8.4), the
//! internal adversarial pass, not an independent review. Every number here comes from the real `igneum-pow`
//! mixer (`memhard::mixer`, `round_key_mult`, `MixParams::with_shape`); nothing is re-implemented.
//!
//! The target in the attacker's words. One application `M(s, rk)` on a 16-word state:
//! 1. prologue, per word: `s[i] = (s[i] XOR (RC[i] + rk)) * MUL[i]` (MUL odd, so each is a bijection).
//! 2. one ChaCha-shaped double round: four column quarter rounds with rotations ROT[0..3], four diagonal
//! quarter rounds with ROT[4..7].
//! Under class v4 (`mixer_mult = 8`) the derivation applies `M` eight times between each pair of the eight
//! dependent cache reads, round keys `round_key(r*8 + j)`, then eight more after the last read: 72 per item.
//! ROT, MUL, RC are drawn once per day from one SplitMix64 stream (the day key's first two words).
//!
//! The commands, each a BOUND or a BREAK with a command and a seed:
//!
//! diffusion --apps K --states N --day D [--start-app A] [--threads T] [--plant weak|none]
//! The strict-avalanche census of K consecutive keyed applications (Q2). For N random states it flips each
//! of the 512 input bits, applies K applications (keys from app A in derive order), and tallies the output
//! bit-flip probability p[in][out] over the N states. Reports, per K, the number of output bits a single
//! input bit never reaches (dependency holes), the number of (in,out) cells with |p - 0.5| above the
//! census bias band, and the worst cell. Full diffusion at K is the bound: the largest K at which a hole
//! or a strong bias survives is the distinguisher reach. The `weak` plant is a hand-built degenerate day
//! (MUL all 1, RC all 0, ROT all 16): the detector must fire on it (holes and strong bias at every K).
//!
//! fold --day D [--trials N]
//! The composition-shortcut probe (Q1). Checks three ways the 8 keyed applications might cost less than 8x:
//! (a) the two multiply layers of adjacent applications do not merge: M has a nonlinear diffusion between
//! them, shown by a GF(2) affinity test of the two-application map on N random probes (an affine map
//! satisfies f(a)+f(b)+f(c)=f(a+b+c); count violations). (b) word separability: does output word w
//! depend on every input word, tested by flipping each input word and checking each output word moves.
//! A shortcut needs a broken dependency. (c) key-only commutation: M(M(s,rk1),rk2) vs M(M(s,rk2),rk1);
//! if they agreed the key order would not matter and keys could be folded. Prints the counts; a zero
//! in (a) or a missing dependency in (b) or an agreement in (c) would be a BREAK, else the BOUND.
//!
//! The genesis test vector (day 2026-10-03) is checked at startup against the spec so the mixer wiring is the
//! library's.
use igneum_pow::generator::V4_CLASS;
use igneum_pow::memhard::{mixer, round_key_mult, MixParams, Shape, ITEM_ROUNDS};
use igneum_pow::seed::{day_key, seed_words_from_bytes, SplitMix64};
use igneum_pow::bind::day_bytes;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
/// The chain genesis day index (`bind.rs`: day_index(0x1a0ff0f7c00) = 20,729, 3 October 2026).
const GENESIS_DAY: u64 = 20_729;
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");
s
}
/// Params for a chain day index (the interim day rule, `bind::day_bytes`).
fn params_of_day(d: u64) -> MixParams {
MixParams::with_shape(seed_words_from_bytes(&day_bytes(d)), v4_shape())
}
/// A hand-built degenerate day: identity multiply, zero round constants, one rotation amount everywhere. Not a
/// drawable day (it is the plant); every field is in range (MUL odd, ROT in 1..31).
fn planted_weak(d: u64) -> MixParams {
let mut mp = params_of_day(d);
mp.mul = [1u32; 16];
mp.rc = [0u32; 16];
mp.rot = [16u32; 8];
mp
}
/// The 72 application round keys of an item under m = 8, in derive order.
fn app_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
}
/// Apply `apps` keyed applications starting at application index `start` (derive order).
#[inline]
fn apply_n(mut s: [u32; 16], mp: &MixParams, keys: &[u32; 72], start: usize, apps: usize) -> [u32; 16] {
for a in 0..apps {
mixer(&mut s, keys[(start + a) % 72], mp);
}
s
}
/// A per-thread SplitMix64 PRNG for random probe states (the attacker's own randomness, not the mixer's).
struct Rng(SplitMix64);
impl Rng {
fn new(seed: u64) -> Self {
Rng(SplitMix64::new(seed))
}
fn state(&mut self) -> [u32; 16] {
let mut s = [0u32; 16];
for w in s.iter_mut() {
*w = self.0.next() as u32;
}
s
}
}
// --------------------------------------------------------------------------------------------------------------
// diffusion (Q2): the strict-avalanche census over K keyed applications
// --------------------------------------------------------------------------------------------------------------
/// 512 x 512 counters (input bit -> output bit flip count), summed as u64. Flat for cache behaviour.
struct Aval {
n: u64,
counts: Vec<u64>, // 512*512
}
impl Aval {
fn new() -> Self {
Aval { n: 0, counts: vec![0u64; 512 * 512] }
}
fn merge(&mut self, o: &Aval) {
self.n += o.n;
for (a, b) in self.counts.iter_mut().zip(o.counts.iter()) {
*a += b;
}
}
}
#[inline]
fn bit_of(s: &[u32; 16], b: usize) -> u32 {
(s[b >> 5] >> (b & 31)) & 1
}
#[inline]
fn flip_bit(s: &mut [u32; 16], b: usize) {
s[b >> 5] ^= 1u32 << (b & 31);
}
fn diffusion(day: u64, plant_weak: bool, apps: usize, states: u64, start: usize, threads: usize) {
let mp = if plant_weak { planted_weak(day) } else { params_of_day(day) };
let keys = app_keys();
let per = states / threads as u64;
let mp = Arc::new(mp);
let keys = Arc::new(keys);
let mut handles = Vec::new();
for t in 0..threads {
let mp = Arc::clone(&mp);
let keys = Arc::clone(&keys);
let count = if t as u64 == threads as u64 - 1 { states - per * (threads as u64 - 1) } else { per };
let seed = 0x1234_5678_9abc_def0 ^ ((day as u64).wrapping_mul(0x9E3779B97F4A7C15)) ^ (t as u64 + 1);
handles.push(thread::spawn(move || {
let mut rng = Rng::new(seed);
let mut acc = Aval::new();
for _ in 0..count {
let base = rng.state();
let out0 = apply_n(base, &mp, &keys, start, apps);
for ib in 0..512 {
let mut s = base;
flip_bit(&mut s, ib);
let out1 = apply_n(s, &mp, &keys, start, apps);
let row = ib * 512;
for ob in 0..512 {
if bit_of(&out0, ob) != bit_of(&out1, ob) {
acc.counts[row + ob] += 1;
}
}
}
acc.n += 1;
}
acc
}));
}
let mut total = Aval::new();
for h in handles {
total.merge(&h.join().unwrap());
}
// census band: a cell at the ideal 0.5 over N states has stddev 0.5/sqrt(N); call a bias "strong" at 8 sigma.
let n = total.n as f64;
let sigma = 0.5 / n.sqrt();
let band = 8.0 * sigma;
let mut holes = 0u64; // cells with p == 0 or p == 1 exactly (a dependency hole or a perfect relation)
let mut strong = 0u64; // |p-0.5| > band and not a hole
let mut worst_dev = 0.0f64;
let mut worst = (0usize, 0usize, 0.0f64);
let mut global_flips = 0u64;
for ib in 0..512 {
for ob in 0..512 {
let c = total.counts[ib * 512 + ob];
global_flips += c;
let p = c as f64 / n;
if c == 0 || c == total.n {
holes += 1;
} else {
let dev = (p - 0.5).abs();
if dev > band {
strong += 1;
}
if dev > worst_dev {
worst_dev = dev;
worst = (ib, ob, p);
}
}
}
}
let mean_p = global_flips as f64 / (n * 512.0 * 512.0);
println!(
"diffusion day={} plant={} apps={} start={} states={} threads={}",
day,
if plant_weak { "weak" } else { "none" },
apps,
start,
total.n,
threads
);
println!(" mean output-flip probability over all 262144 cells: {:.6} (ideal 0.5)", mean_p);
println!(" census band: 8 sigma = {:.6} ({} states)", band, total.n);
println!(" dependency holes (p == 0 or p == 1 exactly): {} of 262144", holes);
println!(" strong-bias cells (|p-0.5| > band, not a hole): {} of 262144", strong);
println!(
" worst cell: in_bit {} -> out_bit {} p = {:.6} dev = {:.6} ({:.1} sigma)",
worst.0,
worst.1,
worst.2,
worst_dev,
worst_dev / sigma
);
let verdict = if holes > 0 || strong > 0 { "FINDING (holes or strong bias at this K)" } else { "no distinguisher at this K" };
println!(" VERDICT: {}", verdict);
}
// --------------------------------------------------------------------------------------------------------------
// fold (Q1): the composition-shortcut probe
// --------------------------------------------------------------------------------------------------------------
#[inline]
fn xor16(a: &[u32; 16], b: &[u32; 16]) -> [u32; 16] {
let mut o = [0u32; 16];
for i in 0..16 {
o[i] = a[i] ^ b[i];
}
o
}
fn fold(day: u64, trials: u64) {
let mp = params_of_day(day);
let keys = app_keys();
let rk1 = keys[0];
let rk2 = keys[1];
let mut rng = Rng::new(0xfeed_face_cafe_babe ^ day.wrapping_mul(0x9E3779B97F4A7C15));
// (a) GF(2) affinity of the two-application map g(s) = M(M(s,rk1),rk2). An affine map over GF(2)^512 obeys
// g(a) ^ g(b) ^ g(c) ^ g(a^b^c) = g(0^...) summed; exactly, g(a)^g(b)^g(c)^g(a^b^c) is constant for an
// affine g. We test the 4-point relation g(a)^g(b)^g(c)^g(a^b^c) == g(d0)^g(d0)... using the zero anchor:
// for affine g, g(a)^g(b)^g(c)^g(a^b^c) == g(0) (four points a,b,c,a^b^c vs the origin). Count nonzero.
let g = |s: [u32; 16]| -> [u32; 16] {
let s1 = apply_n(s, &mp, &keys, 0, 1);
apply_n(s1, &mp, &keys, 1, 1)
};
let g0 = g([0u32; 16]);
let mut affine_violations = 0u64;
for _ in 0..trials {
let a = rng.state();
let b = rng.state();
let c = rng.state();
let abc = xor16(&xor16(&a, &b), &c);
let lhs = xor16(&xor16(&g(a), &g(b)), &xor16(&g(c), &g(abc)));
if lhs != g0 {
affine_violations += 1;
}
}
// (b) word separability: flip each input word fully (xor 0xffffffff) and see whether every output word of the
// full 8-application block moves on at least one probe. A dead (in_word -> out_word) pair over all probes
// is a broken dependency a shortcut could exploit.
let full = |s: [u32; 16]| apply_n(s, &mp, &keys, 0, 8);
let mut dep = [[false; 16]; 16]; // dep[iw][ow] = out word ow ever changed when in word iw flipped
for _ in 0..trials {
let base = rng.state();
let o0 = full(base);
for iw in 0..16 {
let mut s = base;
s[iw] ^= 0xffff_ffff;
let o1 = full(s);
for ow in 0..16 {
if o0[ow] != o1[ow] {
dep[iw][ow] = true;
}
}
}
}
let mut dead_pairs = 0u64;
for iw in 0..16 {
for ow in 0..16 {
if !dep[iw][ow] {
dead_pairs += 1;
}
}
}
// (c) key-order commutation: M(M(s,rk1),rk2) vs M(M(s,rk2),rk1). Agreement would let keys fold.
let mut commute_agree = 0u64;
for _ in 0..trials {
let s = rng.state();
let ab = apply_n(apply_n(s, &mp, &keys, 0, 1), &mp, &keys, 1, 1);
// apply rk2 then rk1 by temporarily swapping via explicit keys
let mut s1 = s;
mixer(&mut s1, rk2, &mp);
mixer(&mut s1, rk1, &mp);
if ab == s1 {
commute_agree += 1;
}
}
println!("fold day={} trials={}", day, trials);
println!(" (a) GF(2) affinity violations of the 2-application map: {} of {} (0 would be a BREAK: the map is affine)", affine_violations, trials);
println!(" (b) dead (in_word -> out_word) pairs over the full 8-application block: {} of 256 (any would be a broken dependency)", dead_pairs);
println!(" (c) key-order agreements M(M(.,rk1),rk2) == M(M(.,rk2),rk1): {} of {} (any would let keys fold)", commute_agree, trials);
let verdict = if affine_violations == 0 || dead_pairs > 0 || commute_agree > 0 { "FINDING" } else { "BOUND: no fold on these probes" };
println!(" VERDICT: {}", verdict);
}
// --------------------------------------------------------------------------------------------------------------
// startup self-check: the genesis test vector of the spec
// --------------------------------------------------------------------------------------------------------------
fn self_check() {
let mp = MixParams::for_day("2026-10-03");
assert_eq!(mp.rot, [20, 20, 19, 4, 26, 3, 3, 27], "spec 1.8.4 ROT vector");
assert_eq!(mp.mul[0], 0x42146205, "spec 1.8.4 MUL[0]");
assert_eq!(mp.mul[15], 0x99cfb423, "spec 1.8.4 MUL[15]");
assert_eq!(mp.rc[0], 0xbab68293, "spec 1.8.4 RC[0]");
assert_eq!(mp.rc[15], 0x31b49ee2, "spec 1.8.4 RC[15]");
let k = day_key("2026-10-03");
assert_eq!(k[0], 0x3067619f, "spec 1.8.1 day key K[0]");
// the 72 application keys are distinct multiples of 0x9E3779B9
let keys = app_keys();
for (i, &v) in keys.iter().enumerate() {
assert_eq!(v, ((i as u32) + 1).wrapping_mul(0x9E3779B9), "application key {i}");
}
eprintln!("self-check: spec 1.8.1 and 1.8.4 genesis vectors OK; 72 application keys are 1..72 times 0x9E3779B9");
}
fn arg_u64(args: &[String], flag: &str, default: u64) -> u64 {
args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).and_then(|s| s.parse().ok()).unwrap_or(default)
}
fn arg_str<'a>(args: &'a [String], flag: &str, default: &'a str) -> &'a str {
args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).map(|s| s.as_str()).unwrap_or(default)
}
fn main() {
self_check();
let args: Vec<String> = std::env::args().collect();
let cmd = args.get(1).map(|s| s.as_str()).unwrap_or("help");
let day = arg_u64(&args, "--day", GENESIS_DAY);
let threads = arg_u64(&args, "--threads", 16).max(1) as usize;
match cmd {
"diffusion" => {
let apps = arg_u64(&args, "--apps", 8) as usize;
let states = arg_u64(&args, "--states", 100_000);
let start = arg_u64(&args, "--start-app", 0) as usize;
let plant = arg_str(&args, "--plant", "none") == "weak";
diffusion(day, plant, apps, states, start, threads);
}
"fold" => {
let trials = arg_u64(&args, "--trials", 100_000);
fold(day, trials);
}
_ => {
eprintln!("usage: attack-adv-mixer diffusion|fold [--day D] [--apps K] [--states N] [--start-app A] [--plant weak|none] [--trials N] [--threads T]");
}
}
let _ = AtomicU64::new(0).fetch_add(0, Ordering::Relaxed);
}

14
tools/attack/f4-weakday/Cargo.lock generated Normal file
View file

@ -0,0 +1,14 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "attack-f4-weakday"
version = "0.1.0"
dependencies = [
"igneum-pow",
]
[[package]]
name = "igneum-pow"
version = "0.2.0"

View file

@ -0,0 +1,21 @@
[package]
name = "attack-f4-weakday"
version = "0.1.0"
edition = "2021"
description = "Attack pass F4: the weak-day census over 2^24 day keys through MixParams::with_shape (docs/plans/cryptanalysis.md 4.2)"
license = "MIT"
publish = false
[[bin]]
name = "attack-f4"
path = "src/main.rs"
[dependencies]
igneum-pow = { path = "../../../igneum-pow" }
[workspace]
[profile.release]
opt-level = 3
lto = true
codegen-units = 1

View file

@ -0,0 +1,990 @@
//! 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");
}
}

14
tools/attack/f8-uniform/Cargo.lock generated Normal file
View file

@ -0,0 +1,14 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "attack-f8"
version = "0.1.0"
dependencies = [
"igneum-pow",
]
[[package]]
name = "igneum-pow"
version = "0.2.0"

View file

@ -0,0 +1,21 @@
[package]
name = "attack-f8"
version = "0.1.0"
edition = "2021"
description = "Attack-pass row F8: the uniformity censuses of the class v4 derivation (line index over 2^28 derivations, distinct lines per hash and warp, the cross-hash item histogram), with the plant hooks that prove the harness fires"
license = "MIT"
publish = false
[[bin]]
name = "attack-f8"
path = "src/main.rs"
[dependencies]
igneum-pow = { path = "../../../igneum-pow" }
[workspace]
[profile.release]
opt-level = 3
lto = true
codegen-units = 1

File diff suppressed because it is too large Load diff