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>
14 KiB
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:
017e703764(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.
- 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. - 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.
- igneum-pow/src/memhard.rs (frozen, via git show at
017e7037). - igneum-pow/src/seed.rs (frozen).
- igneum-pow/src/bind.rs (frozen, the day_bytes and day_index functions).
- igneum-pow/src/generator.rs (frozen, the LoadClass, V3_CLASS, V4_CLASS, ProgramClass, generator version and attempt-cap definitions; grepped, not read whole).
- igneum-pow/tests/mixer.rs (frozen, the head: the test harness contract on the class v3 and v4 construction).
- igneum-pow/Cargo.toml and Cargo.lock (dependency pin).
- docs/spec/01-lottery-hash.md section 1.8 (frozen: 1.8.1 to 1.8.5).
- docs/analysis/chip-model-v3.md sections 1, 2, 5, 6 (HEAD).
- 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.
- 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.
- tools/attack/f4-weakday (build/attack-pass, copied read-only) and tools/attack/f8-uniform (the Mac worktree, copied with cp -R, original untouched).
- tools/build-remote.sh, infra/build-server/lib.sh, infra/build-server/remote-run.sh (the operating files).
- 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.