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:
parent
5fb6d2b10f
commit
6033df803c
9 changed files with 3411 additions and 0 deletions
215
docs/plans/cryptanalysis/plan-mixer.md
Normal file
215
docs/plans/cryptanalysis/plan-mixer.md
Normal 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.
|
||||
21
tools/attack/adv-mixer/Cargo.toml
Normal file
21
tools/attack/adv-mixer/Cargo.toml
Normal 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
|
||||
370
tools/attack/adv-mixer/src/main.rs
Normal file
370
tools/attack/adv-mixer/src/main.rs
Normal 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
14
tools/attack/f4-weakday/Cargo.lock
generated
Normal 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"
|
||||
21
tools/attack/f4-weakday/Cargo.toml
Normal file
21
tools/attack/f4-weakday/Cargo.toml
Normal 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
|
||||
990
tools/attack/f4-weakday/src/main.rs
Normal file
990
tools/attack/f4-weakday/src/main.rs
Normal 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(¶ms_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
14
tools/attack/f8-uniform/Cargo.lock
generated
Normal 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"
|
||||
21
tools/attack/f8-uniform/Cargo.toml
Normal file
21
tools/attack/f8-uniform/Cargo.toml
Normal 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
|
||||
1745
tools/attack/f8-uniform/src/main.rs
Normal file
1745
tools/attack/f8-uniform/src/main.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue