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:
parent
26e72e9d1f
commit
781538f138
1 changed files with 104 additions and 0 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in a new issue