docs/analysis: binding review section 5a, the external review's A05 (FNV initialisation, the 64-bit fold): the argument, five measured lines, no change for 2.0, the class-version shape if one is wanted

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-08 17:55:49 +00:00
parent 26e72e9d1f
commit 781538f138

View file

@ -134,6 +134,110 @@ by which an intermediate computed once serves a second winning attempt at less t
Run line (box 2 through `tools/build-remote.sh`, `cargo test --release -p igneum-pow -- bind::
state::a_stateless_hasher_is_wrong_on_every_item`): 8 October 2026, 19:0x UK, box 2 (igneum-build-2) through `tools/build-remote.sh --box 2`, the crate at the mirror's master 494fb981: `bind::tests::bound_hash_properties` ok, `bound_vectors` ok, `init_bytes_layout` ok, `day_bytes_layout` ok, `pow256_and_target64` ok, `closed_form_bound_also_works` ok (6 passed, 0 failed, 0.43 s); `state::tests::a_stateless_hasher_is_wrong_on_every_item` ok (1 passed, 0 failed, 2.15 s). The pool crate's `the same nonce on another template hashes differently` and the fork engine's timestamp-bound test are in their crates' suites (pool.md section 4; `igneum.rs:200`), not re-run here.
## 5a. Finding A05 of the external review: the FNV initialisation and the 64-bit fold
The external review's finding A05 (`docs/analysis/review-2026-10-08/evidence.md`): the seed and the header-bound
initialisation are four salted FNV-1a-64 passes with a final mix (`seed.rs:37`, `bind.rs:57`), the final state
reduces by rotated XOR to 64 bits placed in the top 64 bits of the 256-bit proof-of-work value (`verify.rs:649`,
`bind.rs:76`), FNV is non-cryptographic by its own specification, and ProgPoW's keccak-f800 at both ends of a cheap
inner mix is the pattern to measure against. Two questions: can one expensive intermediate be reused across cheaper
attempts through the initialisation or the fold, and is the binding to job and nonce complete. The argument, then
the measured lines, then what a change would be if one were wanted.
### 5a.1 What the initialisation has to do, and what it does not
ProgPoW's keccak at the start exists because its inner mix is a fixed structure for a 50-block period and a hasher
must not be able to choose or correlate its inputs cheaply; the keccak at the end exists because the inner mix is
not a cryptographic function and its output must not be shaped. Igneum's initialisation does not carry that burden
alone, for three reasons that are in the code:
1. The attacker does not choose the FNV input. The 49 input bytes are the tag, `H` and `nonce_hi` (`bind.rs:48`). `H`
is a BLAKE2b-256 over the template (the chain's `BlockHash`, `hashing/header.rs:7`); the only way to change it is
to change a header field, which gives a fresh 256-bit value the attacker cannot steer. The attacker's whole
freedom at the FNV input is the 32 bits of `nonce_hi` under a fixed `H`.
2. FNV-1a with the final mix gives no control over its output beyond brute force over those 32 bits. Each pass is
`h = (h XOR byte) x P` over the bytes, then `h ^= h >> 33; h *= C; h ^= h >> 33`. The mix is a bijection, so a
target output pins the pre-mix state; the last four bytes of the chain are then four steps `s_{k+1} = (s_k XOR
b_k) x P`, each inverted by `b_k = (s_{k+1} x P^-1) XOR s_k`, which must land in 0..255: probability 2^-56 per
byte for a random target. So hitting a chosen 64-bit pass output costs the full 2^64 and hitting a chosen 32-bit
word of `I` costs 2^32, the brute-force figures. The measured control line below reads this at the byte scale:
256 tries of `nonce_hi` hit a chosen byte of `I[7]` at the random rate.
3. What a weak initialisation would give is an `I` correlated across `nonce_hi` values or across templates, so that
the prefix of the program (the init-determined loads of F9, 1 to 7 sites in iteration 0) could be shared. The
avalanche lines below read 128 of 256 bits flipped per input bit, with the min and max inside the binomial's
range; no two of 2^18 `nonce_hi` values under one `H` share an `I`, nor a single 64-bit pass.
The non-cryptographic property FNV lacks, preimage resistance against a chosen input, is not used: nothing in the
chain asks that `I` be hard to invert, only that it be a near-uniform function of `(H, nonce_hi)` that the attacker
cannot steer, and the steering is bounded by BLAKE2b on one side and by 32 bits on the other.
### 5a.2 The fold
The fold is linear over GF(2): `lo = r0 ^ rotl(r1,7) ^ rotl(r2,14) ^ rotl(r3,21)`, `hi = r4 ^ rotl(r5,9) ^
rotl(r6,18) ^ rotl(r7,27)`, and the verdict reads `hash <= target64` with `hash = hi || lo` (`bind.rs:83`,
`target64_from_le256`). What a hasher would want from a linear fold is to shape the top bits of `hi` cheaply at the
end. Three facts close that:
- Nothing cheap varies at the end. The nonce enters once, at initialisation (section 2); the last instructions
before the fold are the shadow block's 27th application in iteration 7, pure ALU on the lane's eight registers,
and every register at that point is a function of the whole program over the dataset words the lane read. There
is no second cheap input to vary after the expensive part; the warp is recomputed from the start for every nonce.
- The top 64 bits need `r4..r7` in full and those depend on `r0..r3` throughout the program (every register is the
`dst` of an injecting write, part (b), and the draw's source rule keeps the dataflow fresh, part (a')), so no
register is skippable.
- The fold's output is tested per program: part (c) of acceptance refuses a program whose output bits' ones-count
over 2,048 hashes falls outside 1,024 plus or minus 136 (`OutputBias`, `accept.rs:68`, 6 sigma), and F8's
cross-hash histogram rows read the 64-bit outputs uniform at 2^24 nonces on every accepted seed. A program whose
final state left the fold biased is refused before it is the epoch's program.
Where ProgPoW needs keccak at the end is that its mix is fixed and public for a period, so an output-shaping
structure could be found once and used for 50 blocks; Igneum's program is a fresh random draw per epoch that is
itself tested for output bias, so the fold's linearity has no fixed structure to exploit. Against a hasher that
tries anyway: the cost of computing `r4..r7` is the hash, and the comparison is one 64-bit compare after it.
### 5a.3 Is the binding to job and nonce complete
A job is `(prehash, target64, share_target64, nonce_start, nonce_count)` (spec 9.5); a share is a nonce under that
prehash. The lane hash is `fold(program(init(n, I(H, nonce_hi))))` with `H` the template's pre-PoW hash, so a share
commits to every header field (the coinbase and the transactions through `hash_merkle_root`, the key through
`vote_key_hash`, the epoch through `daa_score` and the parents, the day through the timestamp) and to both halves of
the nonce. Two nonces give two warps (`bound_hash_properties`); two templates give two `I` (the fork's timestamp
test); a nonce moved to another template fails as `wrong_hash` (the pool's test). Nothing in the computation reads
anything outside `(H, nonce, program, dataset)`, so there is no unbound input a hasher could hold fixed. The 2.0
wire shape adds the key to the job and share lines (spec 09 at master 2e9b3e73) for the pool's evidence, not for the
binding, which the prehash already carries.
### 5a.4 Measured lines (8 October 2026, 19:5x UK, the spec-exact replica `a05/fnv.py` on the Mac, integer arithmetic, seconds of CPU)
| Line | Known-pass | Known-fail | Result |
|---|---|---|---|
| The replica is the spec's function | `seed_words("igneum-genesis")` equals the spec 1.3.1 vector `67a9a7be 1a155b25 fddfb732 4b5af2e8 c55caf33 a27c13b7 06628a48 03852469` | the replica without the final mix must differ | OK; FIRED (differs) |
| Avalanche over `nonce_hi` (one random bit flipped, 20,000 trials) | | | mean 128.02 of 256 bits, min 94, max 160 (binomial expectation 128, sd 8) |
| Avalanche over `H` (one random bit flipped, 20,000 trials) | | | mean 128.01 of 256 bits, min 96, max 158 |
| Collisions over 2^18 `nonce_hi` values under one `H` | | | 0 equal `I` (256 bits), 0 equal pass-0 outputs (64 bits; birthday expectation 1.9e-9) |
| Control: a chosen value for the top byte of `I[7]` reached by one of 256 `nonce_hi` tries, 2,000 trials | | | 1,280 of 2,000 (0.640) against the random expectation 0.633 |
The crate's own lines of section 5 stand beside these (`bound_hash_properties`, `bound_vectors`, the timestamp
test, the pool's `wrong_hash` test).
### 5a.5 Verdict and what a change would be
Verdict: the argument holds; no attack. The initialisation is a near-uniform, unsteerable function of a BLAKE2b
hash and 32 free bits, the fold follows a per-epoch random program that acceptance tests for output bias, and the
binding to the job and both nonce halves is complete through the prehash. No consensus change is named for 2.0.
If main wants the ProgPoW shape regardless, the change is a class version, not a patch, because every vector moves:
| Change | What it buys | Cost | Vectors |
|---|---|---|---|
| Init: `I = BLAKE2b-256("igneum-block/" || H || nonce_hi)` as eight words (`blake2b.rs` exists in the crate) | a cryptographic initialisation by its own specification; no change to the argument above | one BLAKE2b per `nonce_hi` per warp space, about 1 microsecond on a CPU core, nothing on the card (the init is a kernel argument) | every pack's vectors, the acceptance stream (`"igneum-accept/"` is FNV too), the day key, the program seed: a new generator version with a new vector set and a new frozen object |
| Finalisation: BLAKE2b over the 256-bit final state before the fold | removes the fold's linearity as a question | one BLAKE2b per hash on the card, about 2 to 5 percent of hash rate (Designed, unmeasured; ProgPoW pays keccak-f800 per hash) | the same new version |
The seat's recommendation is neither for 2.0: the measured lines and the argument close A05 as written, and the
cost of the second row is paid by every honest card for every hash. The first row is cheap and could ride the next
class version whenever one is cut for another reason; it is named here so the decision is main's, with the vectors
as the price.
## 6. Open questions, named for main
| Id | Question | What closes it |