B1 (R15-05): the published MTP/DRSample primitive reproduced, with its complete verifier and fixtures

Blocki and Smearsoll (eprint 2025/1456 as archived, sha256 13c3646e...) Sec 2.2, 3.2 and App. C over
DRSample (ABH17 Alg. 1, eprint 2017/443 as archived): CPython reference in tools/mhpow/b1 (params, drsample,
mtp, attacks), the verifier's named checks in the paper's order with a known-failed test each (17 of 17 OK
on build-6), five deterministic known-answer fixtures (n 4 to 20), honest rows n 12 to 24, the malicious
provers A1 to A6 (Dinur-Nadler eprint 2017/497 Sec 4.1, 4.3/5, 8; the paper's Attack 1; the LuckyQuery
regrind) and the k table. Encoding pinned with B2 (BLAKE2b-256, tags L/M/C/G, u64 LE, raw leaves,
LSB-at-root, public graph seed). Findings: proven k with ABH17's constant exceeds N to about n 25 (F-3);
one labelling yields many accepted roots (F-4); lambda 256 meets Lemma 3 only for q <= 2^31.5 (F-5).
Research paths added to tools/ci/export-exclude.txt beside B2's. Registry batch NOT RUN for the steward.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-09 09:38:00 +00:00
parent 206e73f70e
commit 29d7bfebd1
31 changed files with 3774 additions and 0 deletions

View file

@ -0,0 +1,290 @@
# B1 (R15-05): the published MTP/DRSample primitive, reproduced
Track B of the 1.5x Research Programme, item B1, 9 October 2026. Lane: the cryptography lane. Box: build-6 (32 vCPU,
CPU only). Status of every row: NOT RUN until the panel reads it. This lane writes no PASS.
What this is: an exact CPU reference of the Merkle Tree Proof framework of Blocki and Smearsoll (TCC 2025) over the
DRSample graph, its complete verifier, known-answer fixtures, honest-prover rows and malicious-prover fixtures. It is a
control and a specification. It is not an Igneum work unit, it carries no lottery, and nothing here activates.
## 1. Sources (full PDFs, fetched on build-6, sha256 of the bytes as fetched)
eprint.iacr.org answers the build boxes with a Cloudflare managed challenge. No check was bypassed: the eprint PDFs
were taken from the Internet Archive's snapshot of the eprint URL (`web.archive.org/web/<date>id_/https://eprint.iacr.org/<id>.pdf`).
| key | paper | source as fetched | sha256 | pages |
|---|---|---|---|---|
| [BS25] | Blocki, Smearsoll, Provably Memory-Hard Proofs of Work With Memory-Easy Verification, TCC 2025 | eprint 2025/1456, archive snapshot 2025-12-31T15:20:15Z | `13c3646e7d85c1aa58a92914582caab5798d90cf2a3cad33e58d81d49c1d31db` | 46 |
| [DN17] | Dinur, Nadler, Time-Memory Tradeoff Attacks on the MTP Proof-of-Work Scheme, CRYPTO 2017 | eprint 2017/497, archive snapshot 2026-05-09T02:34:47Z | `d2fd7774535dd3226bb4a767d4930a5c3a91ac6ed2f1421ee511b66ea37b0b03` | 30 |
| [ABH17] | Alwen, Blocki, Harsha, Practical Graphs for Optimal Side-Channel Resistant Memory-Hard Functions, CCS 2017 | eprint 2017/443, archive snapshot 2025-12-10T21:05:18Z | `f8e08d361118120c905550d3ac63efffe2a806d68276b070c24e7aa15d8fa8eb` | 17 |
| [BK16] | Biryukov, Khovratovich, Egalitarian computing (MTP 1.2) | arXiv 1606.03588v2 | `5dca4aef485f480cf010b744a9c0252a8460dd4a31152ffedeba70ef77399623` | 16 |
The [BS25] bytes were fetched twice, once by the B3 lane and once by this lane from the same snapshot. Both reads gave the
same sha256. The copies are at `build-6:/srv/builds/mhpow-b1/papers/`. Row O-3 records that each is "the revision as
archived" and has not been checked against eprint's current revision.
What each source supplies to the code:
| what | where in the paper | code |
|---|---|---|
| labelling game, local consistency, parents in ascending order | [BS25] Sec 2.2 | `mtp.label_of`, `mtp.compute_labels` |
| the framework: Prove steps 1 to 4, Verify steps 1 to 4 | [BS25] Sec 3.2 | `mtp.prove_commit`, `mtp.respond`, `mtp.verify` |
| Merkle construction, reveal, check | [BS25] Sec 3.2 step 2, Sec 4.1, App. C.1, Def. 15, App. C.2 | `mtp.MerkleTree`, `MerkleTree.reveal`, `mtp.merkle_tree_check` |
| challenges c_i = H(chi, i, tau) mod N | [BS25] Sec 3.2 step 3, Sec 5 Def. 14 | `mtp.challenges` |
| soundness chain implemented against | [BS25] Theorem 4, Theorem 6, Theorem 7, Corollary 1, Corollary 2, Fact 8, Corollary 3 | section 6 (k), section 7 (attacks) |
| the paper's own attack | [BS25] Sec 7.1 Attack 1 | `attacks.a5_attack1` |
| the DRSample edge rule (cited by [BS25] Sec 6.1, defined only here) | [ABH17] Sec 3, Algorithm 1 | `drsample.get_parent`, `drsample.parents` |
| DRSample's proven depth-robustness constants | [ABH17] Theorem 3.1 | `k_table.py` |
| the attacks on MTP-Argon2d | [DN17] Sec 4.1, 4.3, 5, 8 | `attacks.a1` to `a4` |
## 2. Parameters and the decisions the paper leaves open
[BS25] fixes the construction abstractly. It has a random oracle H with lambda-bit output, labels H(chi, v, parent
labels), Merkle nodes H(chi, left, right) and challenges H(chi, i, tau) mod N. It names no hash, no lambda, no byte
encoding, no domain tag, and no concrete N or k. Every such choice below is this lane's. Each is pinned in
`tools/mhpow/b1/params.py` with its reason. The B2 lane agreed to them before the first fixture was cut and moved its instance layout to them.
| symbol | value | basis |
|---|---|---|
| H | BLAKE2b, 32-byte digest, unkeyed | B1 decision: one oracle for B1, B2 (chi) and B3 (GPU oracle) |
| lambda | 256 | B1 decision; see row O-4 for what [BS25] Lemma 3 then covers |
| chi | 32 bytes (B2: BLAKE2b-256('I' \|\| 328-byte instance)) | [BS25] Def. 5 input string; layout is B2's |
| domain tags | 'L' label, 'M' Merkle node, 'C' challenge, 'G' graph draw ('I' is B2's) | B1 decision on top of the paper's salt-only separation (finding F-1) |
| integers | node index v and challenge index i as u64 little-endian | B1 decision |
| G | DRSample(N), [ABH17] Alg. 1, indegree 2, sampled once from a public 32-byte seed | [BS25] Sec 1.2 needs G fixed a priori (finding F-2) |
| N | 2^n with n = 4, 8, 12, 16, 20 (fixtures) and 12 to 24 (rows) | the paper states no N (row O-2) |
| k | 8 at n = 4, 64 otherwise | fixture sizes only, NOT a security level (row O-1) |
| leaves | the raw 32-byte labels, not hashed | [BS25] Sec 3.2 step 2: tau_v = l_{1+bin(v)} |
| tree layout | the bit taken at depth j from the root is bit j-1 of the leaf index | [BS25] Sec 3.2: bin(v) = sum v_i 2^(i-1) (row R-3) |
| openings | per challenge: node c_i + 1 and each of its parents, each with its own full path | [BS25] Sec 3.2 step 3(a)(b); no path sharing |
Exact query byte layouts (every query is one BLAKE2b block, 41 to 105 bytes):
```
label, node 1 H('L' || chi || u64(1))
label, node v >= 2 H('L' || chi || u64(v) || l_p1 || ... ) parents ascending; node 2 has the one parent 1
Merkle inner node H('M' || chi || left || right)
challenge i (1..k) c_i = int_le(H('C' || chi || u64(i) || tau)) mod N opened node c_i + 1, leaf index c_i
graph draw H('G' || graph_seed || u64(v) || u8(which) || u64(ctr)) first 8 bytes, rejection sampling
proof container "B1MTPDR1" | n u8 | k u32le | tau 32 | per challenge: label 32 | n x 32 | count u8 | per parent: label 32 | n x 32
```
graph_seed = BLAKE2b-256("igneum/mhpow/b1/drsample-graph-seed/v1") =
`params.GRAPH_SEED` (the fixtures carry it in hex).
## 3. The construction in words, tied to the code
1. Graph ([ABH17] Alg. 1; `drsample.parents`). Nodes 1..N. Node 1 has no parent and node 2 has the parent 1. Every
node v >= 3 has the parents v - 1 and v - r. To pick r, first draw g' uniformly from [1, floor(log2 v) + 1] and set
g = min(v - 1, 2^g'). Then draw r uniformly from [max(ceil(g/2), 2), g]. So r >= 2 and the two parents are distinct.
The draws come from the public seed, so the graph is a fixed public object, never a function of chi.
2. Labels ([BS25] Sec 2.2, Sec 3.2 step 1; `mtp.compute_labels`). In topological order, l_v = H('L', chi, v, parent
labels). This is one sequential pass of N oracle calls, holding 32N bytes.
3. Commitment ([BS25] Sec 3.2 step 2, App. C.1; `mtp.MerkleTree`). A binary tree of depth n over the raw labels. The
tree is salted by chi, and the leaf for node w sits at leaf index w - 1 with LSB-at-root position bits. tau is the root.
4. Challenges ([BS25] Sec 3.2 step 3; `mtp.challenges`). c_i = H('C', chi, i, tau) mod N for i = 1..k. N divides
2^256, so the reduction is exactly uniform.
5. Proof ([BS25] Sec 3.2 step 3(a)(b) and step 4; `mtp.respond`, `Proof.to_bytes`). For each i, the proof carries
l_{c_i+1} with its n siblings, then each parent's label with its n siblings. No positions are carried: the verifier
derives every position.
6. Verifier ([BS25] Sec 3.2 Verify; `mtp.verify`). Each check is a named function. They run in the paper's order and the
first failure is returned by name:
| check | paper | refuses |
|---|---|---|
| `check_0_container` | B1 addition (parsing) | wrong N, wrong k, truncation, trailing bytes, bad magic |
| `check_1_challenges` | Verify step 1 | (derives c'_i from the proof's tau; it cannot fail, it binds positions) |
| `check_2a_parent_set` | Verify step 2 (separating L into the parents of c_i + 1) | a skipped or extra parent opening |
| `check_2b_merkle_openings` | Verify step 2, App. C.2 MerkleTreeCheck | a wrong label, wrong path, wrong position, wrong chi, wrong Merkle tag |
| `check_3_local_consistency` | Verify step 3 | a red challenged node (including a label made with the wrong tag) |
| accept | Verify step 4 | |
Known-failed tests (`tools/mhpow/b1/test_b1.py`). The suite has 17 tests and all 17 ran OK on build-6. Each refusal is
asserted by the name of the check that fires:
| test | the wrong thing | refused by |
|---|---|---|
| test_wrong_label | one bit of a challenged label | check_2b_merkle_openings |
| test_wrong_parent_label | one bit of a parent label | check_2b_merkle_openings |
| test_wrong_path | one bit of one sibling hash | check_2b_merkle_openings |
| test_wrong_challenge | responses for i = 2..k+1 instead of 1..k | check_2a_parent_set or check_2b_merkle_openings |
| test_skipped_parent | one parent opening dropped | check_2a_parent_set |
| test_duplicated_parent_position | both openings for the same parent ([BK16] App. B, Bevand's attack 2 class) | check_2b_merkle_openings |
| test_wrong_domain_tag_label | the whole array labelled with tag 'M', committed honestly | check_3_local_consistency |
| test_wrong_domain_tag_merkle | the tree built with tag 'L' | check_2b_merkle_openings |
| test_wrong_chi | a valid proof verified against another chi | check_2b_merkle_openings |
| test_container | truncated, extended, wrong k, wrong n | check_0_container |
| test_every_check_has_a_refusal | each refusing check shown to fire at least once | all four |
| graph and Merkle tests | edge rule and bucket ranges, determinism, bucket spread, every-leaf reveal and check, LSB-at-root layout | |
## 4. Known-answer fixtures
Command (build-6, under `/srv/builds/mhpow-b1/run.pid`, mirrored to `build-1:/srv/queue/pids/build-6-b1.pid`):
`tools/mhpow/b1/run-b1.sh /srv/builds/mhpow-b1/out-final2 all` (09:33:52Z to 09:36:50Z). The fixtures are deterministic.
Four runs (`out`, `out-repeat`, `out-final`, `out-final2`) produced byte-identical kat files, and two runs byte-identical attack
files (attacks-n10-k16.json sha256 `805035ebfc8d2f34441325b5d5ca46c67e81f7d0b0b86f62ba1a906964b8c51f`). chi for the fixture at n is
BLAKE2b-256("B1-fixture" || "kat" || u64(n)).
| fixture | n | k | tau (first 16 hex) | proof bytes | json sha256 | proof.bin sha256 |
|---|---|---|---|---|---|---|
| kat-n04 | 4 | 8 | 9f98a28d3d84dbdc | 3,893 | `9f85b22ae54785e0e078630f890036713cf3925b7e1345bd949c6945d2702f7f` | `481a39fa47ff4d3393f34e15e318ee4bda7683ee457d3b9da79d277c9ba2831d` |
| kat-n08 | 8 | 64 | 90cb87eabf42f4c1 | 55,405 | `2c741be9a5a7c47773f902ed024c49188eacfc84f9cabdf34244f289e77f51f7` | `d45a9756b8d66357eaee34f1efa3b5fc975e25fcf7b7eb5a516746c6e078bbc7` |
| kat-n12 | 12 | 64 | 884d36cd2df99fa9 | 79,981 | `51ccacd3030d97ec371d8cc8ea64fbb362f00bd323bd84827105c89b4eb30864` | `3c7de0539a8715e75d3d64eb11ec3af7d8a2a338c101958a0c29e300c3ad4f7f` |
| kat-n16 | 16 | 64 | 41e05146c5819b21 | 104,557 | `240f50bc5ac5e12c7630f24c072bf291fb6ab9644530badcd1c41c7a7341440c` | `793fd4d505155f133c8acf1c657a98795f29f7d6cba2568570c8324c42fb1118` |
| kat-n20 | 20 | 64 | e3d83489e27af44b | 129,133 | `fd0d75b66af9cbd9ef12e63d571036f41bdac0e1d21e1f4d98559deb98502392` | `a49367d9c80f0855504fe1f0994acbd5ddc4b023eaf5904a28ad5f7e71ce951c` |
kat-n04 and kat-n08 carry every parent, every label and the proof inline. kat-n12 to kat-n20 carry chi, tau, the
challenges, the sha256 of the parent table (u64le), the sha256 of the label array and the proof sha256. The verifier
accepted each one from bytes. The fixture files are in `tools/mhpow/b1/fixtures/` with their `SHA256SUMS`.
## 5. Honest-prover rows (no hash-rate comparison anywhere)
Software: `tools/mhpow/b1` at this branch, CPython 3.12.3, single thread. Hardware: build-6 (Hetzner Cloud ccx53,
32 vCPU), shared with two other lanes' fuzz and sweep jobs at the time, so the times are loaded wall times. Boundary:
process wall clock only, no power measured, stock clocks. These are a reference implementation's times. They are not a
GPU figure, an optimised figure or an energy figure. Command: `run-b1.sh <out> rows` (one process per N, so each RSS is
its own). Rows: `fixtures/row-nXX-k64.json`.
| n | N | prover sequential s (labels + Merkle + proof) | labels s | Merkle s | graph sample s (one-time, public) | prover state model bytes (96N - 32) | peak RSS measured | proof bytes (k 64) | verify s (from bytes) | verifier oracle calls |
|---|---|---|---|---|---|---|---|---|---|---|
| 12 | 4,096 | 0.010 | 0.004 | 0.006 | 0.013 | 393,184 | 19.7 MB | 79,981 | 0.0027 | 2,432 |
| 16 | 65,536 | 0.180 | 0.062 | 0.115 | 0.204 | 6,291,424 | 39.9 MB | 104,557 | 0.0032 | 3,200 |
| 20 | 1,048,576 | 3.48 | 1.03 | 2.41 | 3.31 | 100,663,264 | 355.7 MB | 129,133 | 0.0040 | 3,968 |
| 22 | 4,194,304 | 14.91 | 4.35 | 10.39 | 13.80 | 402,653,152 | 1,365.7 MB | 141,421 | 0.0483 | 4,352 |
| 24 | 16,777,216 | 66.97 | 17.54 | 48.69 | 52.28 | 1,610,612,704 | 5,407.2 MB | 153,709 | 0.0047 | 4,736 |
What the rows mean:
- Honest work is linear in N, as [BS25] Sec 3.2 "Efficiency" states (O(N + k log N) sequential). 2N - 1 + k oracle calls
are made in one pass, and the label array (32N bytes) is held for the whole pass. The CPython RSS is about 3.4 times
the 96N-byte model because of object overhead. A packed implementation holds 32N bytes of labels plus 64N bytes of
tree, or 32N if the tree is streamed and the openings are recomputed. That trade-off is B3's to measure.
- The proof grows with n: proof bytes = 45 + k (1 + 96 (n + 1)) when every challenged node has indegree 2 (the exact
formula is `mtp.proof_bytes_formula`). Verification is k (3n + 2) oracle calls plus 2k graph draws. It needs no
memory beyond the proof and no table.
- The n = 22 verify time (0.047 and 0.048 s in two runs, against 0.004 to 0.005 s at n = 20 and n = 24) is not the
verifier's work. Its oracle-call count rises smoothly (4,352). The cause inside the measuring CPython process is not
established. A clean cold-verification budget at an admitted rate is B6's row (O-11).
- Per user tier: a GPU miner never runs this CPython path. What carries over is the shape: one N-label sequential pass
per instance (about 96 MiB of state at n = 20, about 1.5 GiB at n = 24 with the tree held) and a proof of 129 KB
(n = 20, k = 64) that a validator checks with about 4,000 BLAKE2b calls. The B3 lane owns the GPU adaptation and B6
the node budget.
## 6. The challenge count k: proven against attacked (code-generated)
Command: `python3 tools/mhpow/b1/k_table.py <out>`. Output: `fixtures/k-table.json`.
(a) PROVEN. [BS25] Corollary 2 gives k = ceil(2 N ln2 lambda / e) for an (e, d)-depth-robust G. [ABH17] Theorem 3.1
gives DRSample, with high probability over the sample, (e, d, b)-block depth-robustness with e >= 2.43 x 10^-4 N / log N
and d >= 0.03 N. Block depth-robustness implies depth-robustness. Here log is read as log2 (row R-4). At lambda = 256:
| n | k (Cor. 2 with [ABH17]'s e) | k / N | proof bytes at that k | proof / label array |
|---|---|---|---|---|
| 12 | 17,525,500 | 4,279 | 21.9 GB | 167,002 x |
| 16 | 23,367,333 | 357 | 38.2 GB | 18,196 x |
| 20 | 29,209,166 | 27.9 | 58.9 GB | 1,756 x |
| 24 | 35,050,999 | 2.09 | 84.2 GB | 157 x |
| 30 | 43,813,748 | 0.041 | 130.4 GB | 3.8 x |
| 32 | 46,734,665 | 0.011 | 148.1 GB | 1.08 x |
FINDING F-3: with the only proven DRSample constant in the literature cited, the theorem's k exceeds N up to about
n = 25. The certificate exceeds the whole label array up to n = 32. So the proven regime has no practical parameter
today. The paper says the constants are not tight. A usable k needs a better proven constant or an accepted heuristic
constant, and either is a decision for the panel (row O-1).
(b) ATTACKED (a floor on k, not a security level). [BS25] Sec 7.1 Attack 1 was run with a greedy depth-reducing set
measured on this exact graph. The greedy rule puts v in S whenever v's depth in G - S would exceed d. The k below pushes
that attack's acceptance (1 - |S|/N)^k under 2^-s:
| n | d | abs(S) / N | k for 2^-40 | k for 2^-128 | proof bytes at k for 2^-128 |
|---|---|---|---|---|---|
| 12 | N/4 | 0.160 | 160 | 510 | 637,035 |
| 12 | N/256 | 0.266 | 90 | 287 | 358,508 |
| 16 | N/4 | 0.161 | 158 | 504 | 823,077 |
| 16 | N/256 | 0.240 | 101 | 323 | 527,504 |
| 20 | N/4 | 0.168 | 151 | 482 | 972,239 |
| 20 | N/256 | 0.242 | 100 | 320 | 645,485 |
The greedy set is a weak depth-reducing attack. Stronger ones (layered and recursive, [BS25] Sec 7.2, [ABH17] Sec 5)
remove fewer nodes for the same d. Those move these k values UP. Row O-9 holds them.
## 7. Malicious-prover fixtures and verdicts
Command: `run-b1.sh <out> attacks`. Output: `fixtures/attacks-n10-k16.json`. N = 2^10 and k = 16 were chosen so that the
accepting probabilities can be measured. Each adversary is implemented to win, and its verdict is measured.
| id | adversary (source) | what it does | measured | verdict and mechanism |
|---|---|---|---|---|
| A1 | [DN17] Sec 4.1 first attack | replays a valid proof made for chi' as a proof for chi | 0 of 50 accepted (check_2b, check_2a) | REFUSED. chi salts every label, Merkle node and challenge ([BS25] Sec 3.2). |
| A2 | [DN17] Sec 4.1 second attack | commits a constant array with no memory, then (a) answers with the label the verifier would recompute or (b) opens the constant label | (a) 0 of 50, check_2b; (b) 0 of 50, check_3 | REFUSED. [BS25] Sec 3.2 opens l_{c_i+1} itself at the derived position and checks it against its opened parents. |
| A3 | [DN17] Sec 4.3 and Sec 5, control blocks (the attack that broke MTP-Argon2d) | t = 20; grinds each control block up to 1,000 times to steer the next t - 1 parents into the stored set (Eq. 5), then stores only control blocks as red values | Eq. 5 met in 0 of 50 intervals after 1 try and 0 of 50 after 1,000; steering gain 0; red-node fallback accepted 191 of 400 = 0.478 (analytic (1 - 1/t)^k = 0.442) | STEERING REFUSED BY CONSTRUCTION. DRSample's parents are fixed a priori, so no label value moves an edge. What remains is a red-node cheat, accepted with (1 - abs(S)/N)^k: the probability [BS25] Lemma 4 bounds and k must make small. |
| A4 | [DN17] Sec 8, self-similarity of data-independent MTP (Y2 := X2 gives Y1 = X1) | copies a label into a node that shares a long-edge parent | 0 of 200 copies reproduced a label; 189 of 200 accepted (one red node each) | SAVING REFUSED. The node index v is in every prelabel ([BS25] Sec 2.2; [DN17] Sec 8's own HAIFA countermeasure). The one red node is caught only when challenged. |
| A5 | [BS25] Sec 7.1 Attack 1 | labels 0^lambda on a greedy depth-reducing set S, the rest in parallel | d = 64: abs(S)/N 0.222, depth of G - S 64, accepted 6 of 400 = 0.015 (analytic 0.018); d = 256: abs(S)/N 0.151, accepted 26 of 400 = 0.065 (analytic 0.072) | ACCEPTED WITH PROBABILITY (1 - abs(S)/N)^k, as the paper states. The cheater needs only d parallel rounds and O(N d lambda) cumulative memory. This is what sets k (section 6). |
| A6 | [BS25] Sec 5 Def. 14 and Lemma 4 (LuckyQuery); B2's O-07 | one labelling; re-rolls tau by rewriting one red leaf and its path only (n + k = 26 oracle calls per try, no relabelling) | 1 red sink: 193 to 200 of 200 tries are accepted roots per labelling; 64 red: 140 to 152 of 400; first accept at try 1.2 and 1.6 on average | ACCEPTED: one labelling yields many accepted (tau, proof) pairs for one chi. The paper's soundness counts this in its q(1 - beta)^k term and still bounds cmc per accepted proof. But nothing in the primitive makes a second accepted root cost a second labelling. A lottery value derived from tau would be re-rolled for n + k calls (finding F-4, handed to B2 and B4). |
A1 to A4 are the Dinur and Nadler adversaries against the data-independent instantiation. The two that broke
MTP-Argon2d (A2 and A3) have no foothold. A2 is closed by the opening rule and A3 by the fixed graph. No Dinur-Nadler
adversary gained anything beyond the red-node probability that the theorem already prices. A5 and A6 are accepted with
exactly the probability the paper's own bound names. They are not new breaks. They are the reason k and the lottery
binding are open (O-1, O-8).
## 8. Findings (each with its consequence)
- F-1 Domain separation. [BS25] separates query kinds only by the chi salt and the query shape. With a common integer
width, the challenge query H(chi, i, tau) has the shape of the prelabel of an indegree-1 node (DRSample's node 2).
B1 pins one-byte tags (L, M, C, G). Any implementation that drops them is a different function and needs its own
argument. Consequence: B2 and B3 pin to the tags.
- F-2 The graph must be a public constant. [BS25] proves soundness for a graph fixed a priori. If the graph came from
chi, a prover who varies a nonce inside chi could grind for a weak sample. Consequence: the graph seed is a constant
and never per instance. Whether the one fixed sample is in fact depth-robust is unverified (row O-12).
- F-3 Proven k is impractical (section 6a): about 2.9 x 10^7 challenges at n = 20 with the cited constant.
Consequence: a Track B candidate cannot claim [BS25]'s theorem at any practical k today. A heuristic k must be
labelled as such.
- F-4 One labelling, many accepted roots (A6). Consequence: an Igneum lottery cannot be a hash of tau alone. B2's O-07
and B4's composed theorem must bind the lottery to the labelling.
- F-5 [BS25] Lemma 3 needs lambda/4 >= 2 log2(q) + log2(indeg). At lambda = 256 that covers q <= 2^31.5 oracle calls.
q = 2^64 needs lambda >= 516. Consequence: at network scale, the theorem's extraction step does not cover lambda = 256
as written (row O-4).
## 9. Open questions (BLOCKED rows)
| row | question | why blocked | owner | clock |
|---|---|---|---|---|
| O-1 | a practical k with a proof: a better proven DRSample (e, d) constant, or a panel-approved heuristic k | [ABH17] Theorem 3.1's constant gives k > N (section 6a); [BS25] states no concrete k | B4 (theory) with R15-07 | not set by B1 |
| O-2 | "the paper's smallest stated N" | [BS25] states no concrete N, lambda, k or hash; B1's fixture sizes are its own | B1 records; the panel picks N | n/a |
| O-3 | the archived revision against eprint's current revision of 2025/1456 and 2017/497 | eprint challenges the boxes; the snapshot dates are in section 1 | B1 or B4 when any box is served | open |
| O-4 | lambda against Lemma 3's precondition at network q (F-5): lambda = 512 (BLAKE2b-512) or a tighter extractor | a theory question; changing lambda changes every fixture and B2's chi | B4 | open |
| O-5 | the log base and constants in [BS25] Fact 8 and Corollary 3 (c1, c3, c4) and in [ABH17] Theorem 3.1 | the papers write "log"; B1 reads log2 (R-4) | B4 | open |
| O-6 | [BS25] App. C.2's loop processes x_1 first from the leaf, which matches Def. 15 / Sec 3.2 only if x is read leaf-first | an erratum question for the authors; B1 pins Sec 3.2's bin(v) and Def. 15's sibling order (R-3) | B1 to the panel | open |
| O-7 | Verify step 2 writes MerkleTreeReveal(chi, i, ...) with the challenge number i; B1 checks at the derived leaf index c_i and parent index u - 1 (R-5) | wording in the paper; B1's reading is the one under which the attacks are refused | B1 to the panel | open |
| O-8 | lottery binding: one labelling gives many accepted roots (F-4) | the primitive has no lottery; binding is B2's spec and B4's theorem | B2, B4 | open |
| O-9 | stronger depth-reducing attacks on this exact graph (layered, recursive, [BS25] Sec 7.2, [ABH17] Sec 5) to raise section 6b's floor | not implemented in this window | B4 / R15-07 | open |
| O-10 | energy: [BS25] bounds cumulative memory in the parallel random oracle model (bits of state per round). It says nothing about joules, SRAM or a chip | the physical translation is B4/B5's | B4, B5 | open |
| O-11 | a compiled verifier and cold verification at the admitted rate | only the CPython reference exists | B6 (node lane), B3 | open |
| O-12 | is the one fixed DRSample sample (graph_seed) actually (e, d)-depth-robust? [ABH17] Theorem 3.1 holds with high probability over the sample, not for every seed | no tool here certifies a sampled graph | B4 | open |
| O-13 | does [R4] (de Rezende, Engstrom, Reyzin 2026) touch [BS25]? [BS25]'s bound is on the PROM state size \|sigma_i\| in bits (Theorem 6), so encoded state is counted, but the reduction must be read against [R4] | a theory read | B4 | open |
| O-14 | the quantum case | [BS25] is classical (PROM); the plan's rule keeps the claim classical | none today | n/a |
Resolutions B1 made where a source was ambiguous (each one pinned in code):
- R-1. [ABH17] "[max(g/2, 2), g]" with g = v - 1 odd: the integer range starts at ceil(g/2).
- R-2. [ABH17]'s uniform draws: rejection sampling over 64-bit words of the 'G' oracle with the public seed.
- R-3. Tree layout: Sec 3.2 and Sec 4.1, with LSB-at-root; Def. 15 sibling order root-first.
- R-4. log means log2 wherever a constant is evaluated.
- R-5. Merkle positions are always the verifier's own (c_i for the node, u - 1 for parent u).
## 10. Files
| path | what |
|---|---|
| `tools/mhpow/b1/params.py` | the oracle, lambda, tags, integer encoding, graph seed, with the paper section beside each |
| `tools/mhpow/b1/drsample.py` | [ABH17] Alg. 1 DRSample, the derandomisation, depth of G - S |
| `tools/mhpow/b1/mtp.py` | labels, Merkle tree, reveal and check, challenges, the certificate container, honest prover, verifier |
| `tools/mhpow/b1/attacks.py` | adversaries A1 to A6 |
| `tools/mhpow/b1/test_b1.py` | the 17-test suite (known-failed cases by check name) |
| `tools/mhpow/b1/gen_fixtures.py`, `k_table.py`, `run-b1.sh` | fixture, row, attack and k-table generators; the box driver (pid file first) |
| `tools/mhpow/b1/fixtures/` | every fixture, row and table above, with `SHA256SUMS` |
| `docs/analysis/mhpow/b1/registry-batch-mhpow-b1-20261009-01.json` | the registry batch, rows NOT RUN, for the steward to record |
Run everything again (a build box only, never the Mac): `tools/mhpow/b1/run-b1.sh <out_dir> all`. Then compare
`<out_dir>/SHA256SUMS` with `tools/mhpow/b1/fixtures/SHA256SUMS`. The kat, attack and k-table files must be
byte-identical. The row files and tests.log differ in their timings only.

View file

@ -0,0 +1,32 @@
{
"run_id": "mhpow-b1-20261009-01",
"manifest_sha": "13c3646e",
"evidence_dir": "docs/analysis/mhpow/b1",
"method": "model",
"boxes": ["build-6"],
"note": "B1 (R15-05), the cryptography lane of the 1.5x programme's Track B (9 October 2026): the exact CPU reference of Blocki and Smearsoll's MTP framework (eprint 2025/1456 as archived, sha256 13c3646e..., Sec 2.2, 3.2, App. C) over DRSample (ABH17 Alg. 1, eprint 2017/443 as archived, sha256 f8e08d36...), its complete verifier (named checks in the paper's order, each with a known-failed test, 17 of 17 OK on build-6), five deterministic known-answer fixtures (n 4 to 20), honest-prover rows n 12 to 24 (CPython, wall time only, no power) and six malicious provers (Dinur-Nadler eprint 2017/497 Sec 4.1, 4.3/5, 8; the paper's Attack 1 and LuckyQuery regrind). Dinur-Nadler's steering and unopened-block attacks are refused; red-node cheats are accepted with the (1-|S|/N)^k probability the paper prices; one labelling yields many accepted roots (F-4). The proven k with ABH17's constant exceeds N to about n 25 (F-3). NOT RUN until the panel reads it; this lane writes no PASS.",
"cells": [
{
"cell": "model:mhpow-b1-reference",
"cases": ["POW-05", "POW-06"],
"status": "NOT RUN",
"method": "model",
"evidence": "docs/analysis/mhpow/b1/README.md",
"note": "POW-05: attack fixtures A1 to A6 (tools/mhpow/b1/fixtures/attacks-n10-k16.json): A1 replay and A2 unopened block REFUSED, A3 control-block steering gain 0 with the red-node fallback accepted at (1-1/t)^k, A4 self-similarity saving refused, A5 Attack 1 accepted at (1-|S|/N)^k, A6 many accepted roots per labelling (the lottery binding BLOCKED to B2/B4, rows O-1 and O-8). POW-06: the verifier's named checks refuse malformed containers, wrong positions, skipped parents and wrong tags (test_b1.py); proof bytes and verifier oracle calls per N are exact formulas with measured rows; cold verification at rate BLOCKED to B6 (O-11).",
"claim_impact": "none: no public figure moves; Track B's construction is not adopted and nothing activates",
"in_progress": true
}
],
"map_cell_requested": {
"model:mhpow-b1-reference": {
"command": "tools/mhpow/b1/run-b1.sh <out_dir> all (a build box under its pid file; kat, attack and k-table files byte-identical to tools/mhpow/b1/fixtures/SHA256SUMS)",
"box_class": "build box, CPU only (build-6)",
"fixtures": ["tools/mhpow/b1/fixtures/"],
"cases": ["POW-05", "POW-06"],
"coverage": {
"POW-05": "partial: the published primitive's attack fixtures only; the lottery binding and the composed accepted-proof theorem are B2/B4's",
"POW-06": "partial: verifier refusals and op counts in CPython; no compiled verifier, no admitted-rate timing"
}
}
}
}

View file

@ -55,3 +55,5 @@ docs/analysis/proving-outcome-ledger.md
# 9 October 2026: the 1.5x programme Track B, the B2 binding spec lane (research documents and their generator)
docs/analysis/mhpow/b2
tools/mhpow/b2
docs/analysis/mhpow/b1
tools/mhpow/b1

239
tools/mhpow/b1/attacks.py Normal file
View file

@ -0,0 +1,239 @@
"""Malicious provers against the B1 verifier. Each adversary is implemented honestly (it tries to win) and its verdict is
measured, never assumed. Sources: [DN17] Dinur and Nadler, eprint 2017/497 as archived 2026-05-09 (Sec 4.1, 4.3, 8);
[BS25] Sec 5 (LuckyQuery), Sec 7.1 (Attack 1).
Verdict vocabulary per adversary: REFUSED (never accepted, the refusing check named) or ACCEPTED-WITH-PROBABILITY p (measured
over trials, beside the analytic value the paper's bound uses) with the exact mechanism.
"""
from __future__ import annotations
from typing import Dict, List, Set, Tuple
import drsample
import mtp
import params
from params import H, u64
D = params.DIGEST_BYTES
ZERO = b"\0" * D
def _chi(tag: str, j: int) -> bytes:
return H(b"B1-attack", tag.encode(), u64(j))
def _respond_from(chi: bytes, n: int, k: int, par, labels, tree) -> mtp.Proof:
return mtp.respond(mtp.ProverState(chi, n, par, labels, tree), k)
# A1 [DN17] Sec 4.1 "A First Attack": replay a proof made for another challenge (chi') -------------------------------------------
def a1_replay(n: int, k: int, par, trials: int) -> Dict:
acc, checks = 0, set()
for j in range(trials):
old = mtp.prove(_chi("a1-old", j), n, k, par)
r = mtp.verify(_chi("a1-new", j), n, k, old)
acc += r.accepted
checks.add(r.check)
return {"id": "A1", "source": "[DN17] Sec 4.1 first attack (proof replay across challenges)",
"mechanism": "every label, Merkle node and challenge query carries chi ([BS25] Sec 3.2): a proof for chi' opens a tree "
"whose root and challenges are bound to chi'",
"trials": trials, "accepted": acc, "refusing_checks": sorted(checks),
"verdict": "REFUSED" if acc == 0 else "ACCEPTED"}
# A2 [DN17] Sec 4.1 "A Second Attack": constant array committed, the challenged block not truly opened -----------------------------
def a2_unopened_block(n: int, k: int, par, trials: int) -> Dict:
"""The cheater commits to an all-constant array (no memory), then (a) answers each challenge with the label the verifier
would recompute from the opened parents but the constant array's path, or (b) opens the constant label honestly."""
N = 1 << n
res = {}
for variant in ("a_recomputed_label", "b_constant_label"):
acc, checks = 0, set()
for j in range(trials):
chi = _chi("a2", j)
labels = [None] + [ZERO] * N
tree = mtp.MerkleTree(chi, labels, n)
proof = _respond_from(chi, n, k, par, labels, tree)
if variant == "a_recomputed_label":
for c, r in zip(mtp.challenges(chi, tree.root, k, n), proof.responses):
r.node.label = mtp.label_of(chi, c + 1, [p.label for p in r.parents])
v = mtp.verify(chi, n, k, proof)
acc += v.accepted
checks.add(v.check)
res[variant] = {"accepted": acc, "refusing_checks": sorted(checks)}
total = sum(x["accepted"] for x in res.values())
return {"id": "A2", "source": "[DN17] Sec 4.1 second attack (the challenged block X[i_j] not opened in MTP 1.0)",
"mechanism": "[BS25] Sec 3.2 step 3(a) opens l_{c_i+1} itself at the verifier-derived position and step 3 checks it "
"against its opened parents; the constant array is red everywhere",
"trials": trials, "variants": res, "verdict": "REFUSED" if total == 0 else "ACCEPTED"}
# A3 [DN17] Sec 4.3 / Sec 5 control blocks: grind control labels to steer later parents into the stored set -----------------------
def a3_control_block_steering(n: int, k: int, par, t: int, grind: int, trials: int) -> Dict:
"""[DN17] Sec 5 step 2(b): for each interval s, try control-block values X[s t] until the parents of the next t-1 blocks
fall in the stored set S_s (Eq. 5). Under Argon2d phi depends on X[i-1]; here parents() is a function of v alone, so the
attacker's grind is run and its effect measured: the fraction of intervals meeting Eq. 5 with 1 try and with `grind` tries.
Then the cheater does what [DN17] does next: stores only the control blocks, writes them as inconsistent (red) values, and
computes the rest; acceptance is measured against (1 - 1/t)^k."""
N = 1 << n
def eq5_holds(s: int) -> bool:
base = s * t
stored = {s2 * t for s2 in range(0, s + 1)}
for l in range(1, t):
v = base + l
if v > N:
break
for u in par[v]:
if not (u in stored or base < u < v or u == v - 1):
return False
return True
intervals = range(1, N // t)
# grinding: the control block's value does not enter parents(); each "try" re-evaluates Eq. 5 on the same graph
one_try = sum(eq5_holds(s) for s in intervals)
many = 0
for s in intervals:
ok = False
for _ in range(grind):
if eq5_holds(s):
ok = True
break
many += ok
# the fallback cheat: control nodes red (stored as attacker-chosen values), everything else honest from them
acc = 0
red = set(range(t, N + 1, t))
for j in range(trials):
chi = _chi("a3", j)
lab: List = [None] * (N + 1)
for v in range(1, N + 1):
lab[v] = H(b"attacker-control", chi, u64(v)) if v in red else mtp.label_of(chi, v, [lab[u] for u in par[v]])
tree = mtp.MerkleTree(chi, lab, n)
acc += mtp.verify(chi, n, k, _respond_from(chi, n, k, par, lab, tree)).accepted
return {"id": "A3", "source": "[DN17] Sec 4.3 and Sec 5 (control blocks steering the data-dependent phi), the attack that "
"broke MTP-Argon2d",
"t": t, "grind_tries_per_control_block": grind, "intervals": len(intervals),
"eq5_intervals_met_one_try": one_try, "eq5_intervals_met_after_grind": many,
"steering_gain": many - one_try,
"fallback_red_fraction": len(red) / N, "trials": trials, "accepted": acc,
"accept_rate": acc / trials, "analytic_accept": (1 - len(red) / N) ** k,
"mechanism": "DRSample's parents(v) are fixed a priori ([ABH17] Alg. 1, public seed): no label value moves an edge, so "
"grinding has zero steering gain; what remains is a red-node cheat accepted with (1-|S|/N)^k, the "
"probability [BS25] Lemma 4 bounds",
"verdict": "STEERING REFUSED BY CONSTRUCTION; RED-NODE FALLBACK ACCEPTED WITH PROBABILITY (1-1/t)^k"}
# A4 [DN17] Sec 8 self-similarity: Y2 := X2 so that Y1 = X1 (copy a label to another node sharing a parent) --------------------------
def a4_self_similarity(n: int, k: int, par, trials: int) -> Dict:
N = 1 << n
# find pairs (x, y) of nodes sharing their long-edge parent: X1 = F(X2, X3), Y1 = F(Y2, Y3) with X3 == Y3
by_parent: Dict[int, List[int]] = {}
for v in range(3, N + 1):
by_parent.setdefault(par[v][0], []).append(v)
pairs = [(vs[0], vs[1]) for vs in by_parent.values() if len(vs) >= 2 and vs[0] + 1 < vs[1]]
equal = 0
acc = 0
for j in range(trials):
chi = _chi("a4", j)
lab = mtp.compute_labels(chi, par)
x, y = pairs[j % len(pairs)]
# Y2 := X2 (the predecessor of y takes the predecessor of x's label), then y is computed from it honestly
lab2 = list(lab)
lab2[y - 1] = lab[x - 1]
lab2[y] = mtp.label_of(chi, y, [lab2[u] for u in par[y]])
equal += lab2[y] == lab[x]
tree = mtp.MerkleTree(chi, lab2, n)
acc += mtp.verify(chi, n, k, _respond_from(chi, n, k, par, lab2, tree)).accepted
return {"id": "A4", "source": "[DN17] Sec 8 (self-similarity of data-independent MTP: Y2 = X2 gives Y1 = X1)",
"pairs_available": len(pairs), "trials": trials, "copies_that_reproduced_a_label": equal,
"accepted": acc, "accept_rate": acc / trials,
"mechanism": "the node index v is inside every prelabel ([BS25] Sec 2.2; [DN17] Sec 8's HAIFA countermeasure): "
"copying X2 into Y2 never reproduces X1 at y, so nothing is saved; the one red node (y-1) is caught "
"only when challenged, as any single red node",
"verdict": "SAVING REFUSED (0 reproduced labels); ONE RED NODE ACCEPTED WITH PROBABILITY ~ (1-1/N)^k"}
# A5 [BS25] Sec 7.1 Attack 1: depth-reducing set S given label 0^lambda, the rest in parallel -------------------------------------
def greedy_depth_reducing_set(par, d: int) -> Set[int]:
"""Topological greedy: put v in S when its depth in G - S would exceed d. G - S then has no path longer than d."""
n_nodes = len(par) - 1
depth = [0] * (n_nodes + 1)
S: Set[int] = set()
for v in range(1, n_nodes + 1):
dv = 1 + max((depth[u] for u in par[v] if u not in S), default=0)
if dv > d:
S.add(v)
depth[v] = 0
else:
depth[v] = dv
return S
def a5_attack1(n: int, k: int, par, d: int, trials: int) -> Dict:
N = 1 << n
S = greedy_depth_reducing_set(par, d)
depth = drsample.depth_after_removal(par, S)
acc = 0
for j in range(trials):
chi = _chi("a5", j)
lab: List = [None] * (N + 1)
for v in range(1, N + 1):
lab[v] = ZERO if v in S else mtp.label_of(chi, v, [lab[u] for u in par[v]])
tree = mtp.MerkleTree(chi, lab, n)
acc += mtp.verify(chi, n, k, _respond_from(chi, n, k, par, lab, tree)).accepted
return {"id": "A5", "source": "[BS25] Sec 7.1 Attack 1 (labels 0^lambda on a depth-reducing set S, G - S pebbled in parallel)",
"d_target": d, "S_size": len(S), "S_fraction": len(S) / N, "depth_G_minus_S": depth,
"parallel_rounds_bound": depth, "trials": trials, "accepted": acc, "accept_rate": acc / trials,
"analytic_accept": (1 - len(S) / N) ** k,
"mechanism": "the paper's own attack: |S| red nodes escape all k challenges with probability (1-|S|/N)^k; the "
"cheater's cumulative memory is O(N d lambda + N lambda log N) against the honest O(N^2 lambda)",
"verdict": "ACCEPTED WITH PROBABILITY (1-|S|/N)^k (as the paper states; this is what sets k)"}
# A6 [BS25] Sec 5 LuckyQuery: regrind the Merkle root cheaply until every challenge is green -----------------------------------------
def a6_root_regrind(n: int, k: int, par, red_count: int, max_tries: int, trials: int) -> Dict:
"""One labelling with `red_count` red nodes (the last nodes, sinks of the honest order); the cheater re-rolls tau by
rewriting the value of one red leaf (the sink, node N) and rehashing its path only: n Merkle calls + k challenge calls per
try. Measured: tries to the first accepted root, and how many distinct accepted roots one labelling yields (B2's O-07)."""
N = 1 << n
results = []
for j in range(trials):
chi = _chi("a6", j)
lab = mtp.compute_labels(chi, par)
red = set(range(N - red_count + 1, N + 1))
for v in red:
lab[v] = H(b"red", chi, u64(v))
tree = mtp.MerkleTree(chi, lab, n)
p = mtp._bitrev(N - 1, n)
tries, accepted_roots, first = 0, 0, None
for g in range(max_tries):
tries += 1
leaf = H(b"regrind", chi, u64(g))
# rewrite leaf N-1 (node N) and its path to the root
tree.levels[n][p] = leaf
cur, pos = leaf, p
for depth in range(n, 0, -1):
sib = tree.levels[depth][pos ^ 1]
cur = mtp.merkle_node(chi, cur, sib) if pos % 2 == 0 else mtp.merkle_node(chi, sib, cur)
pos >>= 1
tree.levels[depth - 1][pos] = cur
lab[N] = leaf
cs = mtp.challenges(chi, tree.root, k, n)
if not any((c + 1) in red for c in cs):
if first is None:
first = tries
proof = _respond_from(chi, n, k, par, lab, tree)
assert mtp.verify(chi, n, k, proof).accepted
accepted_roots += 1
results.append({"first_accept_try": first, "accepted_roots": accepted_roots, "tries": tries})
firsts = [r["first_accept_try"] for r in results if r["first_accept_try"] is not None]
return {"id": "A6", "source": "[BS25] Sec 5 Def. 14 and Lemma 4 (LuckyQuery, the q(1-beta)^k term); B2's O-07",
"red_count": red_count, "red_fraction": red_count / N, "k": k, "max_tries": max_tries, "trials": trials,
"per_try_oracle_calls": n + k, "trials_with_accept": len(firsts),
"mean_first_accept_try": (sum(firsts) / len(firsts)) if firsts else None,
"predicted_tries": (1 - red_count / N) ** (-k),
"accepted_roots_per_labelling": [r["accepted_roots"] for r in results],
"mechanism": "tau is a fresh oracle output per rewrite of one red leaf (n + k calls, no relabelling): with beta = "
"red fraction the expected tries are (1-beta)^-k, and with ONE red sink nearly every try is an "
"accepted root, so one labelling yields many accepted (tau, proof) pairs for one chi; the theorem's "
"cmc bound still holds per accepted proof but nothing in the primitive makes a second accepted root "
"cost a second labelling",
"verdict": "ACCEPTED (many accepted roots per labelling): a lottery over tau is not bound to the labelling cost"}

View file

@ -0,0 +1,87 @@
"""DRSample, [ABH17] Algorithm 1, verbatim edge rule, derandomised by a public seed.
[ABH17] Algorithm 1 (eprint 2017/443, Sec 3):
Function DRSample(n): V := [n]; E := {(1, 2)}; for v in [3, n] and i in [2]: E := E u {(v, GetParent(v, i))}
Function GetParent(v, i):
if i = 1 then r := 1
else
g' <- [1, floor(log2(v)) + 1] // random range size
g := min(v - 1, 2^g') // don't make edges too long
r <- [max(g/2, 2), g] // random edge length
return v - r
Nodes are 1-indexed, node 1 is the only source, node 2 has the single parent 1, every v >= 3 has the two distinct parents
v - 1 and v - r with r >= 2. [BS25] Sec 2.2 orders parents ascending (v1 < v2), so parents(v) = (v - r, v - 1).
Resolutions of what the pseudocode leaves open (README rows R-1, R-2):
R-1 "[max(g/2, 2), g]" with g = v - 1 odd: the integer range starts at ceil(g/2).
R-2 "<-" (uniform draw): uniform_int() below, rejection sampling over 64-bit words of H('G' || GRAPH_SEED || v || which || ctr),
so the graph is a deterministic public function of GRAPH_SEED (fixed a priori, never chi).
"""
from __future__ import annotations
from typing import List, Tuple
import params
_TWO64 = 1 << 64
def uniform_int(lo: int, hi: int, v: int, which: int) -> int:
"""Uniform integer in [lo, hi] (inclusive) for draw `which` of node v; rejection sampling, no modulo bias."""
m = hi - lo + 1
if m <= 0:
raise ValueError("empty range")
if m == 1:
return lo
limit = _TWO64 - (_TWO64 % m)
ctr = 0
while True:
d = params.H(params.TAG_GRAPH, params.GRAPH_SEED, params.u64(v), bytes([which]), params.u64(ctr))
x = int.from_bytes(d[:8], "little")
if x < limit:
return lo + (x % m)
ctr += 1
def get_parent(v: int, i: int) -> int:
"""[ABH17] Alg. 1 GetParent(v, i) for v >= 3."""
if v < 3:
raise ValueError("GetParent is defined for v >= 3; node 1 has no parent and node 2 has parent 1")
if i == 1:
r = 1
else:
gp = uniform_int(1, v.bit_length(), v, 1) # floor(log2 v) + 1 == v.bit_length()
g = min(v - 1, 1 << gp)
r = uniform_int(max(-(-g // 2), 2), g, v, 2) # R-1: ceil(g/2)
return v - r
def parents(v: int) -> Tuple[int, ...]:
"""parents(v, G) in ascending order ([BS25] Sec 2.2). Node 1: (); node 2: (1,); v >= 3: (v - r, v - 1)."""
if v == 1:
return ()
if v == 2:
return (1,)
p2 = get_parent(v, 2)
p1 = get_parent(v, 1)
assert 1 <= p2 < p1 == v - 1
return (p2, p1)
def all_parents(n_nodes: int) -> List[Tuple[int, ...]]:
"""Index 0 unused; all_parents(N)[v] = parents(v) for v in 1..N."""
return [()] + [parents(v) for v in range(1, n_nodes + 1)]
def depth_after_removal(par: List[Tuple[int, ...]], removed: set) -> int:
"""Length (nodes) of the longest directed path in G - S ([BS25] Sec 7.1 uses this to bound parallel rounds)."""
n = len(par) - 1
d = [0] * (n + 1)
best = 0
for v in range(1, n + 1):
if v in removed:
continue
dv = 1 + max((d[u] for u in par[v] if u not in removed), default=0)
d[v] = dv
best = max(best, dv)
return best

View file

@ -0,0 +1,19 @@
805035ebfc8d2f34441325b5d5ca46c67e81f7d0b0b86f62ba1a906964b8c51f attacks-n10-k16.json
9f85b22ae54785e0e078630f890036713cf3925b7e1345bd949c6945d2702f7f kat-n04.json
481a39fa47ff4d3393f34e15e318ee4bda7683ee457d3b9da79d277c9ba2831d kat-n04.proof.bin
2c741be9a5a7c47773f902ed024c49188eacfc84f9cabdf34244f289e77f51f7 kat-n08.json
d45a9756b8d66357eaee34f1efa3b5fc975e25fcf7b7eb5a516746c6e078bbc7 kat-n08.proof.bin
51ccacd3030d97ec371d8cc8ea64fbb362f00bd323bd84827105c89b4eb30864 kat-n12.json
3c7de0539a8715e75d3d64eb11ec3af7d8a2a338c101958a0c29e300c3ad4f7f kat-n12.proof.bin
240f50bc5ac5e12c7630f24c072bf291fb6ab9644530badcd1c41c7a7341440c kat-n16.json
793fd4d505155f133c8acf1c657a98795f29f7d6cba2568570c8324c42fb1118 kat-n16.proof.bin
fd0d75b66af9cbd9ef12e63d571036f41bdac0e1d21e1f4d98559deb98502392 kat-n20.json
a49367d9c80f0855504fe1f0994acbd5ddc4b023eaf5904a28ad5f7e71ce951c kat-n20.proof.bin
1ceca69b04d46083bf7d6d6fa51e21b7ecd49e71eb6051324a439ebcb519336a k-table.json
06f50be652157fb685e17d65853c95628c336980174b238ca8738ebe0ac0de5d k-table.log
0e9f907ed4ffca6f558988ac91066475dcf60da21a9ba3106b79d014db268178 row-n12-k64.json
0dbd2103af742b3f9bb38e679615de34f128db30baa8dd7bdc1b1eaa6c4732bc row-n16-k64.json
23e1cd2749ca933abcde77602557b6228cefc76c6776bc8370720b8bcca833dc row-n20-k64.json
21cf88c3151cb5007304bf1ba8d75764878626c2384b5471e6cc5ceb5f8aa4f4 row-n22-k64.json
48d890a92c5bda6a55ddf65e2780fec63afc7f7adc8d6143f6b6270705b68e7c row-n24-k64.json
55c663b3ab539741aad0ff97fa11a2a52308f715e0ed5047b2c2add82243f5bf tests.log

View file

@ -0,0 +1,177 @@
[
{
"accepted": 0,
"id": "A1",
"mechanism": "every label, Merkle node and challenge query carries chi ([BS25] Sec 3.2): a proof for chi' opens a tree whose root and challenges are bound to chi'",
"params": {
"N": 1024,
"k": 16,
"n": 10
},
"refusing_checks": [
"check_2a_parent_set",
"check_2b_merkle_openings"
],
"source": "[DN17] Sec 4.1 first attack (proof replay across challenges)",
"trials": 50,
"verdict": "REFUSED"
},
{
"id": "A2",
"mechanism": "[BS25] Sec 3.2 step 3(a) opens l_{c_i+1} itself at the verifier-derived position and step 3 checks it against its opened parents; the constant array is red everywhere",
"params": {
"N": 1024,
"k": 16,
"n": 10
},
"source": "[DN17] Sec 4.1 second attack (the challenged block X[i_j] not opened in MTP 1.0)",
"trials": 50,
"variants": {
"a_recomputed_label": {
"accepted": 0,
"refusing_checks": [
"check_2b_merkle_openings"
]
},
"b_constant_label": {
"accepted": 0,
"refusing_checks": [
"check_3_local_consistency"
]
}
},
"verdict": "REFUSED"
},
{
"accept_rate": 0.4775,
"accepted": 191,
"analytic_accept": 0.44157668828816804,
"eq5_intervals_met_after_grind": 0,
"eq5_intervals_met_one_try": 0,
"fallback_red_fraction": 0.0498046875,
"grind_tries_per_control_block": 1000,
"id": "A3",
"intervals": 50,
"mechanism": "DRSample's parents(v) are fixed a priori ([ABH17] Alg. 1, public seed): no label value moves an edge, so grinding has zero steering gain; what remains is a red-node cheat accepted with (1-|S|/N)^k, the probability [BS25] Lemma 4 bounds",
"params": {
"N": 1024,
"k": 16,
"n": 10
},
"source": "[DN17] Sec 4.3 and Sec 5 (control blocks steering the data-dependent phi), the attack that broke MTP-Argon2d",
"steering_gain": 0,
"t": 20,
"trials": 400,
"verdict": "STEERING REFUSED BY CONSTRUCTION; RED-NODE FALLBACK ACCEPTED WITH PROBABILITY (1-1/t)^k"
},
{
"accept_rate": 0.945,
"accepted": 189,
"copies_that_reproduced_a_label": 0,
"id": "A4",
"mechanism": "the node index v is inside every prelabel ([BS25] Sec 2.2; [DN17] Sec 8's HAIFA countermeasure): copying X2 into Y2 never reproduces X1 at y, so nothing is saved; the one red node (y-1) is caught only when challenged, as any single red node",
"pairs_available": 253,
"params": {
"N": 1024,
"k": 16,
"n": 10
},
"source": "[DN17] Sec 8 (self-similarity of data-independent MTP: Y2 = X2 gives Y1 = X1)",
"trials": 200,
"verdict": "SAVING REFUSED (0 reproduced labels); ONE RED NODE ACCEPTED WITH PROBABILITY ~ (1-1/N)^k"
},
{
"S_fraction": 0.2216796875,
"S_size": 227,
"accept_rate": 0.015,
"accepted": 6,
"analytic_accept": 0.01813567966356165,
"d_target": 64,
"depth_G_minus_S": 64,
"id": "A5",
"mechanism": "the paper's own attack: |S| red nodes escape all k challenges with probability (1-|S|/N)^k; the cheater's cumulative memory is O(N d lambda + N lambda log N) against the honest O(N^2 lambda)",
"parallel_rounds_bound": 64,
"params": {
"N": 1024,
"k": 16,
"n": 10
},
"source": "[BS25] Sec 7.1 Attack 1 (labels 0^lambda on a depth-reducing set S, G - S pebbled in parallel)",
"trials": 400,
"verdict": "ACCEPTED WITH PROBABILITY (1-|S|/N)^k (as the paper states; this is what sets k)"
},
{
"S_fraction": 0.1513671875,
"S_size": 155,
"accept_rate": 0.065,
"accepted": 26,
"analytic_accept": 0.07236309223391428,
"d_target": 256,
"depth_G_minus_S": 256,
"id": "A5",
"mechanism": "the paper's own attack: |S| red nodes escape all k challenges with probability (1-|S|/N)^k; the cheater's cumulative memory is O(N d lambda + N lambda log N) against the honest O(N^2 lambda)",
"parallel_rounds_bound": 256,
"params": {
"N": 1024,
"k": 16,
"n": 10
},
"source": "[BS25] Sec 7.1 Attack 1 (labels 0^lambda on a depth-reducing set S, G - S pebbled in parallel)",
"trials": 400,
"verdict": "ACCEPTED WITH PROBABILITY (1-|S|/N)^k (as the paper states; this is what sets k)"
},
{
"accepted_roots_per_labelling": [
193,
199,
198,
200,
195
],
"id": "A6",
"k": 16,
"max_tries": 200,
"mean_first_accept_try": 1.2,
"mechanism": "tau is a fresh oracle output per rewrite of one red leaf (n + k calls, no relabelling): with beta = red fraction the expected tries are (1-beta)^-k, and with ONE red sink nearly every try is an accepted root, so one labelling yields many accepted (tau, proof) pairs for one chi; the theorem's cmc bound still holds per accepted proof but nothing in the primitive makes a second accepted root cost a second labelling",
"params": {
"N": 1024,
"k": 16,
"n": 10
},
"per_try_oracle_calls": 26,
"predicted_tries": 1.0157554632052712,
"red_count": 1,
"red_fraction": 0.0009765625,
"source": "[BS25] Sec 5 Def. 14 and Lemma 4 (LuckyQuery, the q(1-beta)^k term); B2's O-07",
"trials": 5,
"trials_with_accept": 5,
"verdict": "ACCEPTED (many accepted roots per labelling): a lottery over tau is not bound to the labelling cost"
},
{
"accepted_roots_per_labelling": [
140,
146,
140,
151,
152
],
"id": "A6",
"k": 16,
"max_tries": 400,
"mean_first_accept_try": 1.6,
"mechanism": "tau is a fresh oracle output per rewrite of one red leaf (n + k calls, no relabelling): with beta = red fraction the expected tries are (1-beta)^-k, and with ONE red sink nearly every try is an accepted root, so one labelling yields many accepted (tau, proof) pairs for one chi; the theorem's cmc bound still holds per accepted proof but nothing in the primitive makes a second accepted root cost a second labelling",
"params": {
"N": 1024,
"k": 16,
"n": 10
},
"per_try_oracle_calls": 26,
"predicted_tries": 2.808403965576447,
"red_count": 64,
"red_fraction": 0.0625,
"source": "[BS25] Sec 5 Def. 14 and Lemma 4 (LuckyQuery, the q(1-beta)^k term); B2's O-07",
"trials": 5,
"trials_with_accept": 5,
"verdict": "ACCEPTED (many accepted roots per labelling): a lottery over tau is not bound to the labelling cost"
}
]

View file

@ -0,0 +1,249 @@
[
{
"N": 4096,
"d_ABH17": 122.88,
"e_ABH17": 0.082944,
"k_cor2": 17525500,
"k_over_N": 4278.6865234375,
"label_array_bytes": 131072,
"lambda": 256,
"n": 12,
"proof_bytes": 21889349545,
"proof_over_label_array": 167002.48371124268,
"table": "proven"
},
{
"N": 65536,
"d_ABH17": 1966.08,
"e_ABH17": 0.995328,
"k_cor2": 23367333,
"k_over_N": 356.5572052001953,
"label_array_bytes": 2097152,
"lambda": 256,
"n": 16,
"proof_bytes": 38158854834,
"proof_over_label_array": 18195.55989933014,
"table": "proven"
},
{
"N": 1048576,
"d_ABH17": 31457.28,
"e_ABH17": 12.7401984,
"k_cor2": 29209166,
"k_over_N": 27.85603141784668,
"label_array_bytes": 33554432,
"lambda": 256,
"n": 20,
"proof_bytes": 58914887867,
"proof_over_label_array": 1755.800481647253,
"table": "proven"
},
{
"N": 4194304,
"d_ABH17": 125829.12,
"e_ABH17": 46.327994181818184,
"k_cor2": 32130082,
"k_over_N": 7.6604084968566895,
"label_array_bytes": 134217728,
"lambda": 256,
"n": 22,
"proof_bytes": 70975351183,
"proof_over_label_array": 528.8075743839145,
"table": "proven"
},
{
"N": 16777216,
"d_ABH17": 503316.48,
"e_ABH17": 169.869312,
"k_cor2": 35050999,
"k_over_N": 2.089202344417572,
"label_array_bytes": 536870912,
"lambda": 256,
"n": 24,
"proof_bytes": 84157448644,
"proof_over_label_array": 156.75546348839998,
"table": "proven"
},
{
"N": 1073741824,
"d_ABH17": 32212254.72,
"e_ABH17": 8697.3087744,
"k_cor2": 43813748,
"k_over_N": 0.04080473259091377,
"label_array_bytes": 34359738368,
"lambda": 256,
"n": 30,
"proof_bytes": 130433527841,
"proof_over_label_array": 3.7961152801581193,
"table": "proven"
},
{
"N": 4294967296,
"d_ABH17": 128849018.88,
"e_ABH17": 32614.907904,
"k_cor2": 46734665,
"k_over_N": 0.010881262132897973,
"label_array_bytes": 137438953472,
"lambda": 256,
"n": 32,
"proof_bytes": 148102153430,
"proof_over_label_array": 1.0775849909259705,
"table": "proven"
},
{
"N": 4096,
"S": 655,
"beta": 0.159912109375,
"cheater_parallel_rounds": 1024,
"d": 1024,
"k_for_2^-128": 510,
"k_for_2^-40": 160,
"n": 12,
"proof_bytes_2^-128": 637035,
"proof_bytes_2^-40": 199885,
"table": "attack1-greedy"
},
{
"N": 4096,
"S": 900,
"beta": 0.2197265625,
"cheater_parallel_rounds": 256,
"d": 256,
"k_for_2^-128": 358,
"k_for_2^-40": 112,
"n": 12,
"proof_bytes_2^-128": 447187,
"proof_bytes_2^-40": 139933,
"table": "attack1-greedy"
},
{
"N": 4096,
"S": 964,
"beta": 0.2353515625,
"cheater_parallel_rounds": 64,
"d": 64,
"k_for_2^-128": 331,
"k_for_2^-40": 104,
"n": 12,
"proof_bytes_2^-128": 413464,
"proof_bytes_2^-40": 129941,
"table": "attack1-greedy"
},
{
"N": 4096,
"S": 1090,
"beta": 0.26611328125,
"cheater_parallel_rounds": 16,
"d": 16,
"k_for_2^-128": 287,
"k_for_2^-40": 90,
"n": 12,
"proof_bytes_2^-128": 358508,
"proof_bytes_2^-40": 112455,
"table": "attack1-greedy"
},
{
"N": 65536,
"S": 10582,
"beta": 0.161468505859375,
"cheater_parallel_rounds": 16384,
"d": 16384,
"k_for_2^-128": 504,
"k_for_2^-40": 158,
"n": 16,
"proof_bytes_2^-128": 823077,
"proof_bytes_2^-40": 258059,
"table": "attack1-greedy"
},
{
"N": 65536,
"S": 14404,
"beta": 0.21978759765625,
"cheater_parallel_rounds": 4096,
"d": 4096,
"k_for_2^-128": 358,
"k_for_2^-40": 112,
"n": 16,
"proof_bytes_2^-128": 584659,
"proof_bytes_2^-40": 182941,
"table": "attack1-greedy"
},
{
"N": 65536,
"S": 15375,
"beta": 0.2346038818359375,
"cheater_parallel_rounds": 1024,
"d": 1024,
"k_for_2^-128": 332,
"k_for_2^-40": 104,
"n": 16,
"proof_bytes_2^-128": 542201,
"proof_bytes_2^-40": 169877,
"table": "attack1-greedy"
},
{
"N": 65536,
"S": 15745,
"beta": 0.2402496337890625,
"cheater_parallel_rounds": 256,
"d": 256,
"k_for_2^-128": 323,
"k_for_2^-40": 101,
"n": 16,
"proof_bytes_2^-128": 527504,
"proof_bytes_2^-40": 164978,
"table": "attack1-greedy"
},
{
"N": 1048576,
"S": 176424,
"beta": 0.16825103759765625,
"cheater_parallel_rounds": 262144,
"d": 262144,
"k_for_2^-128": 482,
"k_for_2^-40": 151,
"n": 20,
"proof_bytes_2^-128": 972239,
"proof_bytes_2^-40": 304612,
"table": "attack1-greedy"
},
{
"N": 1048576,
"S": 232446,
"beta": 0.2216777801513672,
"cheater_parallel_rounds": 65536,
"d": 65536,
"k_for_2^-128": 355,
"k_for_2^-40": 111,
"n": 20,
"proof_bytes_2^-128": 716080,
"proof_bytes_2^-40": 223932,
"table": "attack1-greedy"
},
{
"N": 1048576,
"S": 249184,
"beta": 0.237640380859375,
"cheater_parallel_rounds": 16384,
"d": 16384,
"k_for_2^-128": 327,
"k_for_2^-40": 103,
"n": 20,
"proof_bytes_2^-128": 659604,
"proof_bytes_2^-40": 207796,
"table": "attack1-greedy"
},
{
"N": 1048576,
"S": 253922,
"beta": 0.2421588897705078,
"cheater_parallel_rounds": 4096,
"d": 4096,
"k_for_2^-128": 320,
"k_for_2^-40": 100,
"n": 20,
"proof_bytes_2^-128": 645485,
"proof_bytes_2^-40": 201745,
"table": "attack1-greedy"
}
]

View file

@ -0,0 +1,19 @@
{"table": "attack1-greedy", "n": 12, "N": 4096, "d": 1024, "S": 655, "beta": 0.159912109375, "k_for_2^-40": 160, "proof_bytes_2^-40": 199885, "k_for_2^-128": 510, "proof_bytes_2^-128": 637035, "cheater_parallel_rounds": 1024}
{"table": "attack1-greedy", "n": 12, "N": 4096, "d": 256, "S": 900, "beta": 0.2197265625, "k_for_2^-40": 112, "proof_bytes_2^-40": 139933, "k_for_2^-128": 358, "proof_bytes_2^-128": 447187, "cheater_parallel_rounds": 256}
{"table": "attack1-greedy", "n": 12, "N": 4096, "d": 64, "S": 964, "beta": 0.2353515625, "k_for_2^-40": 104, "proof_bytes_2^-40": 129941, "k_for_2^-128": 331, "proof_bytes_2^-128": 413464, "cheater_parallel_rounds": 64}
{"table": "attack1-greedy", "n": 12, "N": 4096, "d": 16, "S": 1090, "beta": 0.26611328125, "k_for_2^-40": 90, "proof_bytes_2^-40": 112455, "k_for_2^-128": 287, "proof_bytes_2^-128": 358508, "cheater_parallel_rounds": 16}
{"table": "attack1-greedy", "n": 16, "N": 65536, "d": 16384, "S": 10582, "beta": 0.161468505859375, "k_for_2^-40": 158, "proof_bytes_2^-40": 258059, "k_for_2^-128": 504, "proof_bytes_2^-128": 823077, "cheater_parallel_rounds": 16384}
{"table": "attack1-greedy", "n": 16, "N": 65536, "d": 4096, "S": 14404, "beta": 0.21978759765625, "k_for_2^-40": 112, "proof_bytes_2^-40": 182941, "k_for_2^-128": 358, "proof_bytes_2^-128": 584659, "cheater_parallel_rounds": 4096}
{"table": "attack1-greedy", "n": 16, "N": 65536, "d": 1024, "S": 15375, "beta": 0.2346038818359375, "k_for_2^-40": 104, "proof_bytes_2^-40": 169877, "k_for_2^-128": 332, "proof_bytes_2^-128": 542201, "cheater_parallel_rounds": 1024}
{"table": "attack1-greedy", "n": 16, "N": 65536, "d": 256, "S": 15745, "beta": 0.2402496337890625, "k_for_2^-40": 101, "proof_bytes_2^-40": 164978, "k_for_2^-128": 323, "proof_bytes_2^-128": 527504, "cheater_parallel_rounds": 256}
{"table": "attack1-greedy", "n": 20, "N": 1048576, "d": 262144, "S": 176424, "beta": 0.16825103759765625, "k_for_2^-40": 151, "proof_bytes_2^-40": 304612, "k_for_2^-128": 482, "proof_bytes_2^-128": 972239, "cheater_parallel_rounds": 262144}
{"table": "attack1-greedy", "n": 20, "N": 1048576, "d": 65536, "S": 232446, "beta": 0.2216777801513672, "k_for_2^-40": 111, "proof_bytes_2^-40": 223932, "k_for_2^-128": 355, "proof_bytes_2^-128": 716080, "cheater_parallel_rounds": 65536}
{"table": "attack1-greedy", "n": 20, "N": 1048576, "d": 16384, "S": 249184, "beta": 0.237640380859375, "k_for_2^-40": 103, "proof_bytes_2^-40": 207796, "k_for_2^-128": 327, "proof_bytes_2^-128": 659604, "cheater_parallel_rounds": 16384}
{"table": "attack1-greedy", "n": 20, "N": 1048576, "d": 4096, "S": 253922, "beta": 0.2421588897705078, "k_for_2^-40": 100, "proof_bytes_2^-40": 201745, "k_for_2^-128": 320, "proof_bytes_2^-128": 645485, "cheater_parallel_rounds": 4096}
{"table": "proven", "n": 12, "N": 4096, "lambda": 256, "e_ABH17": 0.082944, "d_ABH17": 122.88, "k_cor2": 17525500, "k_over_N": 4278.6865234375, "proof_bytes": 21889349545, "label_array_bytes": 131072, "proof_over_label_array": 167002.48371124268}
{"table": "proven", "n": 16, "N": 65536, "lambda": 256, "e_ABH17": 0.995328, "d_ABH17": 1966.08, "k_cor2": 23367333, "k_over_N": 356.5572052001953, "proof_bytes": 38158854834, "label_array_bytes": 2097152, "proof_over_label_array": 18195.55989933014}
{"table": "proven", "n": 20, "N": 1048576, "lambda": 256, "e_ABH17": 12.7401984, "d_ABH17": 31457.28, "k_cor2": 29209166, "k_over_N": 27.85603141784668, "proof_bytes": 58914887867, "label_array_bytes": 33554432, "proof_over_label_array": 1755.800481647253}
{"table": "proven", "n": 22, "N": 4194304, "lambda": 256, "e_ABH17": 46.327994181818184, "d_ABH17": 125829.12, "k_cor2": 32130082, "k_over_N": 7.6604084968566895, "proof_bytes": 70975351183, "label_array_bytes": 134217728, "proof_over_label_array": 528.8075743839145}
{"table": "proven", "n": 24, "N": 16777216, "lambda": 256, "e_ABH17": 169.869312, "d_ABH17": 503316.48, "k_cor2": 35050999, "k_over_N": 2.089202344417572, "proof_bytes": 84157448644, "label_array_bytes": 536870912, "proof_over_label_array": 156.75546348839998}
{"table": "proven", "n": 30, "N": 1073741824, "lambda": 256, "e_ABH17": 8697.3087744, "d_ABH17": 32212254.72, "k_cor2": 43813748, "k_over_N": 0.04080473259091377, "proof_bytes": 130433527841, "label_array_bytes": 34359738368, "proof_over_label_array": 3.7961152801581193}
{"table": "proven", "n": 32, "N": 4294967296, "lambda": 256, "e_ABH17": 32614.907904, "d_ABH17": 128849018.88, "k_cor2": 46734665, "k_over_N": 0.010881262132897973, "proof_bytes": 148102153430, "label_array_bytes": 137438953472, "proof_over_label_array": 1.0775849909259705}

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -0,0 +1,93 @@
{
"N": 4096,
"challenges_c_i": [
2331,
739,
401,
182,
691,
3144,
301,
3840,
1473,
3907,
3027,
782,
1184,
3316,
331,
3084,
2937,
3059,
3432,
2573,
1944,
2975,
591,
3706,
556,
3167,
2343,
3262,
3890,
4081,
2659,
3382,
756,
2746,
1108,
3817,
1788,
2129,
972,
3805,
3363,
245,
1375,
3444,
2029,
1672,
3403,
43,
635,
94,
2323,
2054,
1986,
1239,
2175,
3712,
1677,
2346,
2707,
560,
4095,
2662,
3468,
592
],
"chi": "286bb44f63729d7b19b40f08071e3a9cb42e812edf4079e5a0189ff7c92fa030",
"construction": "[BS25] Sec 3.2 over [ABH17] Alg. 1 DRSample; encoding params.py",
"fixture": "kat-n12",
"graph_parents_u64le_sha256": "a12e9e1e0a063833687df3a7486cff329593079b6b63f24198c7cd4ddc2f8aed",
"graph_seed": "9f25395581b8a72f3e17a812fede366102eba1dbd75cb3a02ad8054d5d79af2d",
"hash": "BLAKE2b-256 unkeyed",
"k": 64,
"labels_concat_sha256": "5b2188573c4143fa1c69a262ebd656fc1bd3afa38e96c44ceca6533506cd5c75",
"lambda_bits": 256,
"n": 12,
"proof_bytes": 79981,
"proof_sha256": "3c7de0539a8715e75d3d64eb11ec3af7d8a2a338c101958a0c29e300c3ad4f7f",
"tags": {
"challenge": "C",
"graph": "G",
"label": "L",
"merkle": "M"
},
"tau": "884d36cd2df99fa94e49bc0c358d7cdd3663ebac944d21fee983c3176dfe2687",
"verifier": {
"accepted": true,
"check": "accept",
"oracle_calls": 2432
}
}

Binary file not shown.

View file

@ -0,0 +1,93 @@
{
"N": 65536,
"challenges_c_i": [
37058,
36166,
7395,
13143,
29525,
58720,
10284,
57346,
21195,
33217,
45923,
15399,
22255,
13950,
48909,
16634,
46736,
59559,
55581,
47879,
50708,
22007,
32888,
48509,
12067,
49112,
3385,
7094,
46049,
54059,
18450,
51765,
50610,
40490,
7132,
59844,
18264,
22098,
40888,
29136,
43203,
45899,
26164,
5304,
48542,
37977,
41471,
49209,
1718,
22943,
46330,
30881,
22914,
10602,
15776,
52395,
2205,
27808,
6684,
38160,
32810,
39169,
48933,
15339
],
"chi": "1dd81aac8403c3b65c7da0b52eadb1a08104a988e73e3aa92c955ca16fa67a9a",
"construction": "[BS25] Sec 3.2 over [ABH17] Alg. 1 DRSample; encoding params.py",
"fixture": "kat-n16",
"graph_parents_u64le_sha256": "811895268cb0e8f37e22e8762de44b4e5e5f0297a79207227580a6bf43ee4314",
"graph_seed": "9f25395581b8a72f3e17a812fede366102eba1dbd75cb3a02ad8054d5d79af2d",
"hash": "BLAKE2b-256 unkeyed",
"k": 64,
"labels_concat_sha256": "b8cf70b7a10c82dee36dda8ea0fef39308819d24611c9051b104ba704322d97b",
"lambda_bits": 256,
"n": 16,
"proof_bytes": 104557,
"proof_sha256": "793fd4d505155f133c8acf1c657a98795f29f7d6cba2568570c8324c42fb1118",
"tags": {
"challenge": "C",
"graph": "G",
"label": "L",
"merkle": "M"
},
"tau": "41e05146c5819b21487fc485ac9de87ff0521b8d4e921d8655c5a1cb7c99385e",
"verifier": {
"accepted": true,
"check": "accept",
"oracle_calls": 3200
}
}

Binary file not shown.

View file

@ -0,0 +1,93 @@
{
"N": 1048576,
"challenges_c_i": [
478328,
234763,
962086,
987402,
136367,
302234,
907558,
945947,
443172,
200341,
196554,
615131,
1032010,
24137,
31777,
462029,
299644,
270093,
905498,
969459,
16578,
368025,
859298,
419728,
841167,
1025362,
687240,
176569,
580698,
957290,
784586,
978457,
36129,
577448,
392226,
711776,
949535,
343819,
281553,
809444,
857405,
904463,
334337,
577019,
794312,
477162,
351630,
104684,
479418,
685136,
782054,
333943,
78240,
784632,
143051,
787678,
84897,
883533,
614079,
250175,
999091,
869462,
825523,
955417
],
"chi": "842efbdf693296a28d46e967fe9930bacd9c48cb06b518a4e9529308fabc2560",
"construction": "[BS25] Sec 3.2 over [ABH17] Alg. 1 DRSample; encoding params.py",
"fixture": "kat-n20",
"graph_parents_u64le_sha256": "a3c61becd7b467711f5ca3e44ec1faf51905f221d3e8be71ea8515b02523d5d5",
"graph_seed": "9f25395581b8a72f3e17a812fede366102eba1dbd75cb3a02ad8054d5d79af2d",
"hash": "BLAKE2b-256 unkeyed",
"k": 64,
"labels_concat_sha256": "02e355ae74233acbe451012ad58c438cee307ab5821204231e14e64da31daa96",
"lambda_bits": 256,
"n": 20,
"proof_bytes": 129133,
"proof_sha256": "a49367d9c80f0855504fe1f0994acbd5ddc4b023eaf5904a28ad5f7e71ce951c",
"tags": {
"challenge": "C",
"graph": "G",
"label": "L",
"merkle": "M"
},
"tau": "e3d83489e27af44b199e2a73db33ee2b9492b38e777e778b694bba152b53786b",
"verifier": {
"accepted": true,
"check": "accept",
"oracle_calls": 3968
}
}

Binary file not shown.

View file

@ -0,0 +1,24 @@
{
"N": 4096,
"boundary": "process wall time, no power measured",
"graph_sample_s": 0.012868257996160537,
"host": "igneum-build-6",
"k": 64,
"labels_bytes_model": 131072,
"labels_s": 0.003818661003606394,
"merkle_bytes_model": 262112,
"merkle_s": 0.005726456998672802,
"n": 12,
"oracle_calls_prover": 8255,
"peak_rss_bytes_measured": 19660800,
"proof_bytes": 79981,
"prover_sequential_s_excl_graph": 0.01040476000343915,
"prover_state_bytes_model": 393184,
"python": "3.12.3",
"respond_s": 0.0008596420011599548,
"row": "honest-n12-k64",
"setting": "stock",
"software": "tools/mhpow/b1 (CPython reference, single thread)",
"verifier_oracle_calls": 2432,
"verify_s": 0.002651128997968044
}

View file

@ -0,0 +1,24 @@
{
"N": 65536,
"boundary": "process wall time, no power measured",
"graph_sample_s": 0.20376398300140863,
"host": "igneum-build-6",
"k": 64,
"labels_bytes_model": 2097152,
"labels_s": 0.06238005399791291,
"merkle_bytes_model": 4194272,
"merkle_s": 0.11455441799625987,
"n": 16,
"oracle_calls_prover": 131135,
"peak_rss_bytes_measured": 39870464,
"proof_bytes": 104557,
"prover_sequential_s_excl_graph": 0.17990126999939093,
"prover_state_bytes_model": 6291424,
"python": "3.12.3",
"respond_s": 0.002966798005218152,
"row": "honest-n16-k64",
"setting": "stock",
"software": "tools/mhpow/b1 (CPython reference, single thread)",
"verifier_oracle_calls": 3200,
"verify_s": 0.003200609004124999
}

View file

@ -0,0 +1,24 @@
{
"N": 1048576,
"boundary": "process wall time, no power measured",
"graph_sample_s": 3.311251960003574,
"host": "igneum-build-6",
"k": 64,
"labels_bytes_model": 33554432,
"labels_s": 1.0262025019983412,
"merkle_bytes_model": 67108832,
"merkle_s": 2.4088434259974747,
"n": 20,
"oracle_calls_prover": 2097215,
"peak_rss_bytes_measured": 355725312,
"proof_bytes": 129133,
"prover_sequential_s_excl_graph": 3.4809705539955758,
"prover_state_bytes_model": 100663264,
"python": "3.12.3",
"respond_s": 0.04592462599975988,
"row": "honest-n20-k64",
"setting": "stock",
"software": "tools/mhpow/b1 (CPython reference, single thread)",
"verifier_oracle_calls": 3968,
"verify_s": 0.004032391007058322
}

View file

@ -0,0 +1,24 @@
{
"N": 4194304,
"boundary": "process wall time, no power measured",
"graph_sample_s": 13.798183551996772,
"host": "igneum-build-6",
"k": 64,
"labels_bytes_model": 134217728,
"labels_s": 4.345659284001158,
"merkle_bytes_model": 268435424,
"merkle_s": 10.386853066003823,
"n": 22,
"oracle_calls_prover": 8388671,
"peak_rss_bytes_measured": 1365721088,
"proof_bytes": 141421,
"prover_sequential_s_excl_graph": 14.914240817000973,
"prover_state_bytes_model": 402653152,
"python": "3.12.3",
"respond_s": 0.18172846699599177,
"row": "honest-n22-k64",
"setting": "stock",
"software": "tools/mhpow/b1 (CPython reference, single thread)",
"verifier_oracle_calls": 4352,
"verify_s": 0.04833257199788932
}

View file

@ -0,0 +1,24 @@
{
"N": 16777216,
"boundary": "process wall time, no power measured",
"graph_sample_s": 52.28400299400528,
"host": "igneum-build-6",
"k": 64,
"labels_bytes_model": 536870912,
"labels_s": 17.542553989995213,
"merkle_bytes_model": 1073741792,
"merkle_s": 48.69316720600182,
"n": 24,
"oracle_calls_prover": 33554495,
"peak_rss_bytes_measured": 5407219712,
"proof_bytes": 153709,
"prover_sequential_s_excl_graph": 66.96936401799758,
"prover_state_bytes_model": 1610612704,
"python": "3.12.3",
"respond_s": 0.7336428220005473,
"row": "honest-n24-k64",
"setting": "stock",
"software": "tools/mhpow/b1 (CPython reference, single thread)",
"verifier_oracle_calls": 4736,
"verify_s": 0.004654051001125481
}

View file

@ -0,0 +1,22 @@
test_bucket_spread (test_b1.GraphTests.test_bucket_spread) ... ok
test_deterministic (test_b1.GraphTests.test_deterministic) ... ok
test_edge_rule (test_b1.GraphTests.test_edge_rule) ... ok
test_layout_lsb_at_root (test_b1.MerkleTests.test_layout_lsb_at_root) ... ok
test_reveal_check_every_leaf (test_b1.MerkleTests.test_reveal_check_every_leaf) ... ok
test_container (test_b1.VerifierTests.test_container) ... ok
test_duplicated_parent_position (test_b1.VerifierTests.test_duplicated_parent_position) ... ok
test_every_check_has_a_refusal (test_b1.VerifierTests.test_every_check_has_a_refusal) ... ok
test_honest_accepts_object_and_bytes (test_b1.VerifierTests.test_honest_accepts_object_and_bytes) ... ok
test_skipped_parent (test_b1.VerifierTests.test_skipped_parent) ... ok
test_wrong_challenge (test_b1.VerifierTests.test_wrong_challenge) ... ok
test_wrong_chi (test_b1.VerifierTests.test_wrong_chi) ... ok
test_wrong_domain_tag_label (test_b1.VerifierTests.test_wrong_domain_tag_label) ... ok
test_wrong_domain_tag_merkle (test_b1.VerifierTests.test_wrong_domain_tag_merkle) ... ok
test_wrong_label (test_b1.VerifierTests.test_wrong_label) ... ok
test_wrong_parent_label (test_b1.VerifierTests.test_wrong_parent_label) ... ok
test_wrong_path (test_b1.VerifierTests.test_wrong_path) ... ok
----------------------------------------------------------------------
Ran 17 tests in 0.086s
OK

View file

@ -0,0 +1,139 @@
"""Known-answer fixtures, honest-prover rows and attack fixtures for B1. Deterministic; run on a build box only.
python3 gen_fixtures.py kat <out_dir> known-answer fixtures n = 4, 8, 12, 16, 20 (k = 8 at n = 4, else 64)
python3 gen_fixtures.py row <out_dir> <n> <k> one honest-prover row (own process, so its peak RSS is its own)
python3 gen_fixtures.py attacks <out_dir> the malicious-prover fixtures
Every file written is listed with its sha256 in <out_dir>/SHA256SUMS by the driver (run-b1.sh).
"""
from __future__ import annotations
import json
import os
import platform
import resource
import sys
import time
import attacks
import drsample
import mtp
import params
from params import H
D = params.DIGEST_BYTES
def kat_chi(n: int) -> bytes:
return H(b"B1-fixture", b"kat", params.u64(n))
def sha(b: bytes) -> str:
import hashlib
return hashlib.sha256(b).hexdigest()
def kat(out: str) -> None:
for n, k in ((4, 8), (8, 64), (12, 64), (16, 64), (20, 64)):
N = 1 << n
chi = kat_chi(n)
par = drsample.all_parents(N)
st = mtp.prove_commit(chi, n, par)
proof = mtp.respond(st, k)
pb = proof.to_bytes()
v = mtp.verify(chi, n, k, pb)
assert v.accepted, v
graph_bytes = b"".join(params.u64(u) for vv in range(1, N + 1) for u in par[vv])
label_bytes = b"".join(st.labels[1:])
cs = mtp.challenges(chi, st.tree.root, k, n)
fx = {
"fixture": f"kat-n{n:02d}", "construction": "[BS25] Sec 3.2 over [ABH17] Alg. 1 DRSample; encoding params.py",
"lambda_bits": params.LAMBDA_BITS, "hash": "BLAKE2b-256 unkeyed", "tags": {"label": "L", "merkle": "M",
"challenge": "C", "graph": "G"}, "graph_seed": params.GRAPH_SEED.hex(),
"n": n, "N": N, "k": k, "chi": chi.hex(),
"graph_parents_u64le_sha256": sha(graph_bytes),
"labels_concat_sha256": sha(label_bytes),
"tau": st.tree.root.hex(), "challenges_c_i": cs,
"proof_bytes": len(pb), "proof_sha256": sha(pb),
"verifier": {"accepted": v.accepted, "check": v.check, "oracle_calls": v.hashes},
}
if n <= 8:
fx["parents"] = {str(vv): list(par[vv]) for vv in range(1, N + 1)}
fx["labels"] = {str(vv): st.labels[vv].hex() for vv in range(1, N + 1)}
fx["proof_hex"] = pb.hex()
with open(os.path.join(out, f"kat-n{n:02d}.json"), "w") as f:
json.dump(fx, f, indent=1, sort_keys=True)
with open(os.path.join(out, f"kat-n{n:02d}.proof.bin"), "wb") as f:
f.write(pb)
print(f"kat n={n} tau={st.tree.root.hex()[:16]} proof={len(pb)}B accepted={v.accepted}", flush=True)
def row(out: str, n: int, k: int) -> None:
N = 1 << n
chi = kat_chi(n)
t0 = time.perf_counter()
par = drsample.all_parents(N)
t1 = time.perf_counter()
labels = mtp.compute_labels(chi, par)
t2 = time.perf_counter()
tree = mtp.MerkleTree(chi, labels, n)
t3 = time.perf_counter()
st = mtp.ProverState(chi, n, par, labels, tree)
proof = mtp.respond(st, k)
pb = proof.to_bytes()
t4 = time.perf_counter()
rss_kb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
del st, tree, labels
# cold verification: a fresh process state is not available here, so the verifier is timed from bytes with the graph
# parents re-derived on demand (no table), which is the network verifier's real path
t5 = time.perf_counter()
v = mtp.verify(chi, n, k, pb)
t6 = time.perf_counter()
assert v.accepted
r = {
"row": f"honest-n{n:02d}-k{k}", "n": n, "N": N, "k": k,
"graph_sample_s": t1 - t0, "labels_s": t2 - t1, "merkle_s": t3 - t2, "respond_s": t4 - t3,
"prover_sequential_s_excl_graph": t4 - t1,
"labels_bytes_model": N * D, "merkle_bytes_model": (2 * N - 1) * D,
"prover_state_bytes_model": N * D + (2 * N - 1) * D,
"peak_rss_bytes_measured": rss_kb * 1024,
"proof_bytes": len(pb), "verify_s": t6 - t5, "verifier_oracle_calls": v.hashes,
"oracle_calls_prover": N + (N - 1) + k,
"software": "tools/mhpow/b1 (CPython reference, single thread)", "python": platform.python_version(),
"host": platform.node(), "boundary": "process wall time, no power measured", "setting": "stock",
}
with open(os.path.join(out, f"row-n{n:02d}-k{k}.json"), "w") as f:
json.dump(r, f, indent=1, sort_keys=True)
print(json.dumps(r), flush=True)
def run_attacks(out: str) -> None:
n, k = 10, 16
par = drsample.all_parents(1 << n)
res = [
attacks.a1_replay(n, k, par, trials=50),
attacks.a2_unopened_block(n, k, par, trials=50),
attacks.a3_control_block_steering(n, k, par, t=20, grind=1000, trials=400),
attacks.a4_self_similarity(n, k, par, trials=200),
attacks.a5_attack1(n, k, par, d=64, trials=400),
attacks.a5_attack1(n, k, par, d=256, trials=400),
attacks.a6_root_regrind(n, k, par, red_count=1, max_tries=200, trials=5),
attacks.a6_root_regrind(n, k, par, red_count=64, max_tries=400, trials=5),
]
for r in res:
r["params"] = {"n": n, "N": 1 << n, "k": k}
print(json.dumps({x: r[x] for x in r if x not in ("mechanism",)}), flush=True)
with open(os.path.join(out, "attacks-n10-k16.json"), "w") as f:
json.dump(res, f, indent=1, sort_keys=True)
if __name__ == "__main__":
cmd, out = sys.argv[1], sys.argv[2]
os.makedirs(out, exist_ok=True)
if cmd == "kat":
kat(out)
elif cmd == "row":
row(out, int(sys.argv[3]), int(sys.argv[4]))
elif cmd == "attacks":
run_attacks(out)
else:
raise SystemExit("usage")

57
tools/mhpow/b1/k_table.py Normal file
View file

@ -0,0 +1,57 @@
"""The challenge count k two ways, code-generated (README section 6):
(a) PROVEN: [BS25] Corollary 2, k = ceil(2 N ln2 lambda / e) for an (e, d)-depth-robust G, with e from [ABH17] Theorem 3.1
(DRSample is (e, d, b)-block depth-robust w.h.p. with e >= 2.43e-4 N / log N, d >= 0.03 N; log read as log2, README R-4).
(b) ATTACK-DRIVEN (a lower bound on k, not a security claim): for [BS25] Sec 7.1 Attack 1 with the greedy depth-reducing set S
measured on THIS graph at depth targets d = N / 2^j, the k that pushes the attack's acceptance (1 - |S|/N)^k under 2^-s.
Proof bytes follow mtp.proof_bytes_formula with every challenged node of indegree 2.
python3 k_table.py <out_dir>
"""
import json
import math
import os
import sys
import attacks
import drsample
LAMBDA = 256
def proof_bytes(n: int, k: int) -> int:
return 45 + k * (1 + 32 * (n + 1) * 3)
def main(out: str) -> None:
rows = []
for n in (12, 16, 20, 22, 24, 30, 32):
N = 1 << n
e = 2.43e-4 * N / n
k = math.ceil(2 * N * math.log(2) * LAMBDA / e)
rows.append({"table": "proven", "n": n, "N": N, "lambda": LAMBDA, "e_ABH17": e, "d_ABH17": 0.03 * N,
"k_cor2": k, "k_over_N": k / N, "proof_bytes": proof_bytes(n, k),
"label_array_bytes": 32 * N, "proof_over_label_array": proof_bytes(n, k) / (32 * N)})
for n in (12, 16, 20):
N = 1 << n
par = drsample.all_parents(N)
for j in (2, 4, 6, 8):
d = N >> j
S = attacks.greedy_depth_reducing_set(par, d)
beta = len(S) / N
row = {"table": "attack1-greedy", "n": n, "N": N, "d": d, "S": len(S), "beta": beta}
for s in (40, 128):
row[f"k_for_2^-{s}"] = math.ceil(s * math.log(2) / -math.log1p(-beta)) if beta > 0 else None
row[f"proof_bytes_2^-{s}"] = proof_bytes(n, row[f"k_for_2^-{s}"]) if beta > 0 else None
row["cheater_parallel_rounds"] = drsample.depth_after_removal(par, S)
rows.append(row)
print(json.dumps(row), flush=True)
with open(os.path.join(out, "k-table.json"), "w") as f:
json.dump(rows, f, indent=1, sort_keys=True)
for r in rows:
if r["table"] == "proven":
print(json.dumps(r))
if __name__ == "__main__":
main(sys.argv[1])

304
tools/mhpow/b1/mtp.py Normal file
View file

@ -0,0 +1,304 @@
"""The Merkle Tree Proof framework of [BS25] Sec 3.2 over DRSample: honest prover, proof container, complete verifier.
Every step names the paragraph it implements. Section numbers are [BS25] = eprint 2025/1456 as archived 2025-12-31.
Merkle layout ([BS25] Sec 3.2 step 2, Sec 4.1, App. C.1). The tree position of a leaf is a bit string x = x_1 ... x_n read from
the root (x_1 picks the root's child), and the leaf holds tau_x = l_{1 + bin(x)} with bin(x) = sum_i x_i 2^(i-1). So the bit
chosen at depth j is bit j-1 of the leaf index (the root splits even and odd indices). Leaves are the raw labels (the paper
does not hash them). Inner nodes tau_x = H(chi, tau_x0, tau_x1). README row R-3 records App. C.2's printed loop order, which
walks x_1 first from the leaf and is consistent with this layout only if x is read leaf-first; B1 follows Sec 3.2 / 4.1 /
Def. 15's x(j) = x_1 ... x_{j-1} not(x_j) (sibling at depth j from the root).
"""
from __future__ import annotations
import struct
from dataclasses import dataclass, field
from typing import List, Optional, Sequence, Tuple
import drsample
import params
from params import H, u64
D = params.DIGEST_BYTES
# ----------------------------------------------------------------------------------------------------------------------------
# Labels: [BS25] Sec 2.2 "The Labeling Game" and Sec 3.2 Prove step 1.
# ----------------------------------------------------------------------------------------------------------------------------
def label_of(chi: bytes, v: int, parent_labels: Sequence[bytes], tag: bytes = params.TAG_LABEL) -> bytes:
"""l_v = H(chi, v, l_v1, ..., l_vk) with parents ascending; l_1 = H(chi, 1). `tag` is overridable only for the
wrong-domain-tag test adversary."""
return H(tag, chi, u64(v), *parent_labels)
def compute_labels(chi: bytes, par: List[Tuple[int, ...]], tag: bytes = params.TAG_LABEL) -> List[bytes]:
"""Prove step 1: every label in topological order 1..N. Returns a list indexed 0..N with index 0 unused (None)."""
n_nodes = len(par) - 1
lab: List[Optional[bytes]] = [None] * (n_nodes + 1)
for v in range(1, n_nodes + 1):
lab[v] = H(tag, chi, u64(v), *[lab[u] for u in par[v]])
return lab
# ----------------------------------------------------------------------------------------------------------------------------
# Merkle commitment: [BS25] Sec 3.2 Prove step 2, App. C.1 (construction), Def. 15 (reveal), App. C.2 (check).
# ----------------------------------------------------------------------------------------------------------------------------
def _bitrev(x: int, n: int) -> int:
r = 0
for _ in range(n):
r = (r << 1) | (x & 1)
x >>= 1
return r
def merkle_node(chi: bytes, left: bytes, right: bytes, tag: bytes = params.TAG_MERKLE) -> bytes:
return H(tag, chi, left, right)
class MerkleTree:
"""levels[j] holds the 2^j tree nodes at depth j, indexed by their root-first position bits read as a binary number;
levels[0] = [tau]."""
def __init__(self, chi: bytes, labels: List[bytes], n: int, tag: bytes = params.TAG_MERKLE):
N = 1 << n
assert len(labels) == N + 1
self.n = n
leaves = [labels[_bitrev(p, n) + 1] for p in range(N)] # tree position p holds leaf index bitrev(p)
levels = [leaves]
cur = leaves
for _ in range(n):
cur = [merkle_node(chi, cur[2 * q], cur[2 * q + 1], tag) for q in range(len(cur) // 2)]
levels.append(cur)
levels.reverse()
self.levels = levels
@property
def root(self) -> bytes:
return self.levels[0][0]
def reveal(self, leaf_index: int) -> List[bytes]:
"""Def. 15: the sibling labels tau_{x(j)} for j = 1..n (root-first), x = the tree position of leaf `leaf_index`."""
p = _bitrev(leaf_index, self.n)
return [self.levels[j][(p >> (self.n - j)) ^ 1] for j in range(1, self.n + 1)]
def merkle_tree_check(chi: bytes, leaf_index: int, leaf: bytes, path: Sequence[bytes], tau: bytes, n: int,
counter: Optional[list] = None) -> bool:
"""App. C.2 MerkleTreeCheck(chi, i, l_i, mu_i, tau): recompute the root from the leaf up and compare with tau."""
if len(path) != n or not (0 <= leaf_index < (1 << n)) or len(leaf) != D or any(len(s) != D for s in path):
return False
sigma = leaf
for j in range(n, 0, -1): # from the leaf (depth n) to the root (depth 1)
bit = (leaf_index >> (j - 1)) & 1 # x_j = bit j-1 of the index (bin(x) = sum x_j 2^(j-1))
sib = path[j - 1]
sigma = merkle_node(chi, sigma, sib) if bit == 0 else merkle_node(chi, sib, sigma)
if counter is not None:
counter[0] += 1
return sigma == tau
# ----------------------------------------------------------------------------------------------------------------------------
# Challenges: [BS25] Sec 3.2 Prove step 3 / Verify step 1; Sec 5 Def. 14.
# ----------------------------------------------------------------------------------------------------------------------------
def challenges(chi: bytes, tau: bytes, k: int, n: int, tag: bytes = params.TAG_CHALLENGE) -> List[int]:
"""c_i = H(chi, i, tau) mod N for i = 1..k (0-based leaf index; the challenged node is c_i + 1). N = 2^n divides
2^256, so the reduction is exactly uniform."""
N = 1 << n
return [int.from_bytes(H(tag, chi, u64(i), tau), "little") % N for i in range(1, k + 1)]
# ----------------------------------------------------------------------------------------------------------------------------
# The certificate: [BS25] Sec 3.2 Prove step 4, c = (tau, {(l_i, mu_i, L_i)}_{i=1..k}), L_i = {(l_v, mu_{i,v})}_{v in parents}.
# ----------------------------------------------------------------------------------------------------------------------------
@dataclass
class Opening:
label: bytes
path: List[bytes]
@dataclass
class ChallengeResponse:
node: Opening # (l_i, mu_i) for node c_i + 1
parents: List[Opening] = field(default_factory=list) # L_i in ascending parent order
@dataclass
class Proof:
n: int
tau: bytes
responses: List[ChallengeResponse]
def to_bytes(self) -> bytes:
"""B1 container: magic(8) | n(u8) | k(u32 le) | tau(32) | per challenge: node label(32) | n siblings(32 n) |
parent count(u8) | per parent: label(32) | n siblings(32 n). Positions are never carried: the verifier derives them."""
out = [params.PROOF_MAGIC, bytes([self.n]), struct.pack("<I", len(self.responses)), self.tau]
for r in self.responses:
out.append(r.node.label)
out.extend(r.node.path)
out.append(bytes([len(r.parents)]))
for p in r.parents:
out.append(p.label)
out.extend(p.path)
return b"".join(out)
@staticmethod
def from_bytes(b: bytes) -> "Proof":
"""Parser; raises ValueError on any malformed container (truncation, trailing bytes, bad magic)."""
if len(b) < 8 + 1 + 4 + D or b[:8] != params.PROOF_MAGIC:
raise ValueError("bad magic or short header")
n = b[8]
k = struct.unpack("<I", b[9:13])[0]
tau = b[13:13 + D]
off = 13 + D
def take(m: int) -> bytes:
nonlocal off
if off + m > len(b):
raise ValueError("truncated")
s = b[off:off + m]
off += m
return s
def opening() -> Opening:
lab = take(D)
return Opening(lab, [take(D) for _ in range(n)])
resps = []
for _ in range(k):
node = opening()
cnt = take(1)[0]
resps.append(ChallengeResponse(node, [opening() for _ in range(cnt)]))
if off != len(b):
raise ValueError("trailing bytes")
return Proof(n, tau, resps)
def proof_bytes_formula(n: int, k: int, indegs: Sequence[int]) -> int:
"""Exact container size: 45 + sum_i (1 + 32(n+1) (1 + indeg(c_i + 1)))."""
return 8 + 1 + 4 + D + sum(1 + D * (n + 1) * (1 + d) for d in indegs)
# ----------------------------------------------------------------------------------------------------------------------------
# Honest prover: [BS25] Sec 3.2 Prove^H(chi, N, k), steps 1 to 4.
# ----------------------------------------------------------------------------------------------------------------------------
@dataclass
class ProverState:
chi: bytes
n: int
par: List[Tuple[int, ...]]
labels: List[bytes]
tree: MerkleTree
def prove_commit(chi: bytes, n: int, par: List[Tuple[int, ...]]) -> ProverState:
labels = compute_labels(chi, par) # step 1
tree = MerkleTree(chi, labels, n) # step 2
return ProverState(chi, n, par, labels, tree)
def respond(st: ProverState, k: int) -> Proof:
resps = []
for c in challenges(st.chi, st.tree.root, k, st.n): # step 3: c_i
v = c + 1
node = Opening(st.labels[v], st.tree.reveal(c)) # step 3(a): mu_i for l_{c_i + 1} at bin(c_i)
pars = [Opening(st.labels[u], st.tree.reveal(u - 1)) for u in st.par[v]] # step 3(b): mu_{i,v} at bin(v - 1)
resps.append(ChallengeResponse(node, pars))
return Proof(st.n, st.tree.root, resps) # step 4
def prove(chi: bytes, n: int, k: int, par: Optional[List[Tuple[int, ...]]] = None) -> Proof:
if par is None:
par = drsample.all_parents(1 << n)
return respond(prove_commit(chi, n, par), k)
# ----------------------------------------------------------------------------------------------------------------------------
# The complete verifier: [BS25] Sec 3.2 Verify^H(chi, R, c', k), every check a named function, run in the paper's order.
# ----------------------------------------------------------------------------------------------------------------------------
class Refused(Exception):
def __init__(self, check: str, detail: str):
super().__init__(f"{check}: {detail}")
self.check = check
self.detail = detail
def check_0_container(proof: Proof, n: int, k: int) -> None:
"""B1 addition (parsing): the certificate is for this N and carries exactly k responses of the right widths."""
if proof.n != n:
raise Refused("check_0_container", f"n {proof.n} != {n}")
if len(proof.responses) != k:
raise Refused("check_0_container", f"{len(proof.responses)} responses != k {k}")
if len(proof.tau) != D:
raise Refused("check_0_container", "tau width")
def check_1_challenges(chi: bytes, proof: Proof, k: int, n: int, counter: list) -> List[int]:
"""Verify step 1: identify tau from c' and compute every challenge c'_i = H(chi, i, tau) mod N."""
counter[0] += k
return challenges(chi, proof.tau, k, n)
def check_2a_parent_set(cs: List[int], proof: Proof) -> List[Tuple[int, ...]]:
"""Verify step 2, the separation of L into {(l_j, mu_j)}_{j in parents(c_i + 1)}: the response must open exactly the
parents of c_i + 1 in G (the verifier derives them; a skipped or extra parent is refused). Returns the parent tuples."""
out = []
for i, (c, r) in enumerate(zip(cs, proof.responses), 1):
ps = drsample.parents(c + 1)
if len(r.parents) != len(ps):
raise Refused("check_2a_parent_set", f"challenge {i}: node {c + 1} has {len(ps)} parents, response opens {len(r.parents)}")
out.append(ps)
return out
def check_2b_merkle_openings(chi: bytes, cs: List[int], pss: List[Tuple[int, ...]], proof: Proof, n: int, counter: list) -> None:
"""Verify step 2: MerkleTreeCheck on every reveal, each at the position the verifier derived (node c_i + 1 at index c_i,
parent u at index u - 1), never at a position the prover names (the MTP 1.2 / Bevand 'Attack 2' class)."""
for i, (c, ps, r) in enumerate(zip(cs, pss, proof.responses), 1):
if not merkle_tree_check(chi, c, r.node.label, r.node.path, proof.tau, n, counter):
raise Refused("check_2b_merkle_openings", f"challenge {i}: node {c + 1} opening")
for u, op in zip(ps, r.parents):
if not merkle_tree_check(chi, u - 1, op.label, op.path, proof.tau, n, counter):
raise Refused("check_2b_merkle_openings", f"challenge {i}: parent {u} of node {c + 1} opening")
def check_3_local_consistency(chi: bytes, cs: List[int], proof: Proof, counter: list) -> None:
"""Verify step 3: l_v = H(chi, v, l_v1, ..., l_vk) for v = c_i + 1 with the committed parent labels."""
for i, (c, r) in enumerate(zip(cs, proof.responses), 1):
counter[0] += 1
if label_of(chi, c + 1, [p.label for p in r.parents]) != r.node.label:
raise Refused("check_3_local_consistency", f"challenge {i}: node {c + 1} is red")
@dataclass
class Verdict:
accepted: bool
check: str # "accept" or the first refusing check
detail: str
hashes: int # oracle calls made by the verifier (challenges + Merkle + local consistency; graph draws counted apart)
def verify(chi: bytes, n: int, k: int, proof_or_bytes) -> Verdict:
"""[BS25] Sec 3.2 Verify, steps 1 to 4, in order; returns the first refusing check by name."""
counter = [0]
try:
if isinstance(proof_or_bytes, (bytes, bytearray)):
try:
proof = Proof.from_bytes(bytes(proof_or_bytes))
except ValueError as e:
raise Refused("check_0_container", str(e))
else:
proof = proof_or_bytes
check_0_container(proof, n, k)
cs = check_1_challenges(chi, proof, k, n, counter)
pss = check_2a_parent_set(cs, proof)
check_2b_merkle_openings(chi, cs, pss, proof, n, counter)
check_3_local_consistency(chi, cs, proof, counter)
except Refused as r:
return Verdict(False, r.check, r.detail, counter[0])
return Verdict(True, "accept", "", counter[0]) # step 4

62
tools/mhpow/b1/params.py Normal file
View file

@ -0,0 +1,62 @@
"""Pinned constants of the B1 reference (R15-05): the MTP framework instantiated with DRSample.
Sources (full PDFs, sha256 recorded in docs/analysis/mhpow/b1/README.md):
[BS25] Blocki and Smearsoll, "Provably Memory-Hard Proofs of Work With Memory-Easy Verification", TCC 2025,
IACR eprint 2025/1456 as archived 2025-12-31T15:20:15Z (46 pp).
[ABH17] Alwen, Blocki and Harsha, "Practical Graphs for Optimal Side-Channel Resistant Memory-Hard Functions",
CCS 2017, IACR eprint 2017/443 as archived 2025-12-10T21:05:18Z; Algorithm 1 (DRSample, GetParent).
[BS25] fixes the construction abstractly: a random oracle H : {0,1}* -> {0,1}^lambda, labels H(chi, v, parents' labels)
(Sec 2.2 "The Labeling Game", Sec 3.2 step 1), Merkle inner nodes H(chi, left, right) with chi as the salt (Sec 3.2 step 2,
Sec 4.1, App. C.1), challenges H(chi, i, tau) mod N (Sec 3.2 step 3). It names no concrete hash, lambda, byte encoding or
domain tag; the paper's only domain separation is the salt chi and the shape of each query. Everything in this file that the
paper leaves open is a B1 decision, stated here with its reason, agreed with the B2 lane (instance layout) before the first
fixture.
"""
import hashlib
# lambda (bits) and the oracle. B1 decision: BLAKE2b with a 32-byte digest, lambda = 256. Reason: one primitive across B1, B2
# (chi = BLAKE2b('I' || instance)) and B3's GPU oracle; a 256-bit output keeps [BS25] Corollary 1's collision term
# 2^-lambda * C(q,2) negligible for any q a network could make. NOTE (README row O-4): [BS25] Lemma 3 also needs
# lambda/4 >= 2 log2(q) + log2(indeg), which lambda = 256 meets only for q <= 2^31.5 oracle calls.
LAMBDA_BITS = 256
DIGEST_BYTES = LAMBDA_BITS // 8
def H(*parts: bytes) -> bytes:
"""The random oracle instantiation: BLAKE2b-256 over the plain concatenation of fixed-width fields."""
h = hashlib.blake2b(digest_size=DIGEST_BYTES)
for p in parts:
h.update(p)
return h.digest()
# Domain tags: one leading byte per query kind. B1 decision on top of [BS25]'s salt-only separation. Reason: without a tag the
# challenge query H(chi, i, tau) (Sec 3.2 step 3) has exactly the shape of the prelabel H(chi, v, l_u) of an indegree-1 node
# (node 2 of DRSample, [ABH17] Alg. 1 E := {(1,2)}) when i and v share a width; the tags make the four query families disjoint
# by their first byte. The paper's proof (Sec 4.2, the extractor reads a parent label at offset |chi| + log2 N + h*lambda)
# is unaffected by a fixed one-byte prefix.
TAG_LABEL = b"L" # [BS25] Sec 2.2 labeling game; Sec 3.2 step 1: l_1 = H(chi, 1), l_v = H(chi, v, l_v1, ..., l_vk)
TAG_MERKLE = b"M" # [BS25] Sec 3.2 step 2; App. C.1: tau_x = H(chi, tau_x0, tau_x1), tau = H(chi, tau_0, tau_1)
TAG_CHALLENGE = b"C" # [BS25] Sec 3.2 step 3; Sec 5 Def. 14: c_i = H(chi, i, tau) mod N, i = 1..k
TAG_GRAPH = b"G" # [ABH17] Alg. 1 random draws (g', r): B1's derandomisation of DRSample, see drsample.py
TAG_INSTANCE = b"I" # owned by B2 (chi = BLAKE2b('I' || 328-byte instance)); listed so B1 never reuses it
ALL_TAGS = (TAG_LABEL, TAG_MERKLE, TAG_CHALLENGE, TAG_GRAPH, TAG_INSTANCE)
assert len(set(ALL_TAGS)) == len(ALL_TAGS)
CHI_BYTES = 32 # chi is the 32-byte BLAKE2b digest of B2's instance; B1's own fixtures use chi = H(b"B1-fixture", tag)
def u64(x: int) -> bytes:
"""Node indices v (1..N) and challenge indices i (1..k): unsigned 64-bit little-endian, fixed width."""
return int(x).to_bytes(8, "little")
# The DRSample graph is sampled ONCE from a public constant seed, never from chi. Reason: [BS25] proves soundness for a graph
# fixed a priori (Sec 1.2: "sound as long as the underlying graph is fixed a priori (i.e., cannot be adversarially modified)");
# a chi-derived graph would let a prover that chooses chi (a nonce) grind for a weak graph.
GRAPH_SEED = hashlib.blake2b(b"igneum/mhpow/b1/drsample-graph-seed/v1", digest_size=32).digest()
# Proof serialisation magic (B1's container; the paper defines the certificate c = (tau, {(l_i, mu_i, L_i)}) abstractly).
PROOF_MAGIC = b"B1MTPDR1"

23
tools/mhpow/b1/run-b1.sh Executable file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env bash
# B1 driver for a build box (never the Mac). Writes its pid file first, runs the tests, the known-answer fixtures, the honest
# rows and the attack fixtures, then SHA256SUMS. Stop it only by its pid file: kill "$(cut -d' ' -f1 "$PIDFILE")".
# tools/mhpow/b1/run-b1.sh <out_dir> [tests|kat|rows|attacks|ktable|all]
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
OUT="${1:?out dir}"; WHAT="${2:-all}"
PIDFILE="${B1_PIDFILE:-/srv/builds/mhpow-b1/run.pid}"
printf '%s %s b1-%s\n' "$$" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$WHAT" > "$PIDFILE"
mkdir -p "$OUT"
cd "$HERE"
if [ "$WHAT" = tests ] || [ "$WHAT" = all ]; then
python3 -m unittest -v test_b1 2>&1 | tee "$OUT/tests.log"
grep -q '^OK' "$OUT/tests.log"
fi
if [ "$WHAT" = kat ] || [ "$WHAT" = all ]; then python3 gen_fixtures.py kat "$OUT"; fi
if [ "$WHAT" = rows ] || [ "$WHAT" = all ]; then
for n in 12 16 20 22 24; do python3 gen_fixtures.py row "$OUT" "$n" 64; done
fi
if [ "$WHAT" = attacks ] || [ "$WHAT" = all ]; then python3 gen_fixtures.py attacks "$OUT"; fi
if [ "$WHAT" = ktable ] || [ "$WHAT" = all ]; then python3 k_table.py "$OUT" | tee "$OUT/k-table.log"; fi
(cd "$OUT" && sha256sum $(ls | grep -v '^SHA256SUMS$') > SHA256SUMS)
echo "B1 DONE $WHAT $(date -u +%Y-%m-%dT%H:%M:%SZ)"

162
tools/mhpow/b1/test_b1.py Normal file
View file

@ -0,0 +1,162 @@
"""B1 tests: the honest path accepts, and every verifier check refuses its own known-failed case by name.
Run on a build box only (the founder's rule): cd tools/mhpow/b1 && python3 -m unittest -v test_b1
"""
import copy
import unittest
import drsample
import mtp
import params
CHI = params.H(b"B1-test", b"chi-0")
CHI2 = params.H(b"B1-test", b"chi-1")
class GraphTests(unittest.TestCase):
def test_edge_rule(self):
par = drsample.all_parents(1 << 12)
self.assertEqual(par[1], ())
self.assertEqual(par[2], (1,))
for v in range(3, len(par)):
u, w = par[v]
self.assertEqual(w, v - 1)
r = v - u
self.assertGreaterEqual(r, 2)
self.assertLessEqual(r, v - 1)
# r lies in some bucket [max(ceil(g/2),2), g] with g = min(v-1, 2^g'), 1 <= g' <= floor(log2 v)+1
ok = any(max(-(-min(v - 1, 1 << gp) // 2), 2) <= r <= min(v - 1, 1 << gp) for gp in range(1, v.bit_length() + 1))
self.assertTrue(ok, (v, r))
def test_deterministic(self):
self.assertEqual(drsample.all_parents(512), drsample.all_parents(512))
def test_bucket_spread(self):
# [ABH17] Sec 3 intuition: each bucket B_i (2^(i-1) <= dist <= 2^i) is hit; a sanity check that the derandomisation
# did not collapse the distribution.
par = drsample.all_parents(1 << 14)
buckets = set((v - par[v][0]).bit_length() for v in range(3, len(par)))
self.assertGreaterEqual(len(buckets), 13)
class MerkleTests(unittest.TestCase):
def test_reveal_check_every_leaf(self):
n = 5
par = drsample.all_parents(1 << n)
lab = mtp.compute_labels(CHI, par)
t = mtp.MerkleTree(CHI, lab, n)
for x in range(1 << n):
self.assertTrue(mtp.merkle_tree_check(CHI, x, lab[x + 1], t.reveal(x), t.root, n))
self.assertFalse(mtp.merkle_tree_check(CHI, x ^ 1, lab[x + 1], t.reveal(x), t.root, n))
def test_layout_lsb_at_root(self):
# [BS25] Sec 3.2: tau_v = l_{1+bin(v)}, bin(v) = sum v_i 2^(i-1): the root's left subtree holds even leaf indices.
n = 3
lab = [None] + [bytes([v]) * 32 for v in range(1, 9)]
t = mtp.MerkleTree(CHI, lab, n)
left = mtp.merkle_node(CHI, mtp.merkle_node(CHI, lab[1], lab[5]), mtp.merkle_node(CHI, lab[3], lab[7]))
self.assertEqual(t.levels[1][0], left)
class VerifierTests(unittest.TestCase):
n, k = 8, 16
@classmethod
def setUpClass(cls):
cls.par = drsample.all_parents(1 << cls.n)
cls.st = mtp.prove_commit(CHI, cls.n, cls.par)
cls.proof = mtp.respond(cls.st, cls.k)
def v(self, proof, chi=CHI):
return mtp.verify(chi, self.n, self.k, proof)
def test_honest_accepts_object_and_bytes(self):
self.assertTrue(self.v(self.proof).accepted)
b = self.proof.to_bytes()
self.assertTrue(self.v(b).accepted)
self.assertEqual(mtp.Proof.from_bytes(b).to_bytes(), b)
indeg = [len(self.par[c + 1]) for c in mtp.challenges(CHI, self.proof.tau, self.k, self.n)]
self.assertEqual(len(b), mtp.proof_bytes_formula(self.n, self.k, indeg))
def test_wrong_label(self):
p = copy.deepcopy(self.proof)
p.responses[3].node.label = bytes([p.responses[3].node.label[0] ^ 1]) + p.responses[3].node.label[1:]
self.assertEqual(self.v(p).check, "check_2b_merkle_openings")
def test_wrong_parent_label(self):
p = copy.deepcopy(self.proof)
lab = p.responses[2].parents[0].label
p.responses[2].parents[0].label = bytes([lab[0] ^ 0x80]) + lab[1:]
self.assertEqual(self.v(p).check, "check_2b_merkle_openings")
def test_wrong_path(self):
p = copy.deepcopy(self.proof)
s = p.responses[0].node.path[4]
p.responses[0].node.path[4] = s[:-1] + bytes([s[-1] ^ 1])
self.assertEqual(self.v(p).check, "check_2b_merkle_openings")
def test_wrong_challenge(self):
# answer the challenges for i = 2..k+1 instead of 1..k (each response opens a node the verifier did not derive)
cs = mtp.challenges(CHI, self.st.tree.root, self.k + 1, self.n)[1:]
resps = []
for c in cs:
v = c + 1
resps.append(mtp.ChallengeResponse(mtp.Opening(self.st.labels[v], self.st.tree.reveal(c)),
[mtp.Opening(self.st.labels[u], self.st.tree.reveal(u - 1)) for u in self.par[v]]))
r = self.v(mtp.Proof(self.n, self.st.tree.root, resps))
self.assertFalse(r.accepted)
self.assertIn(r.check, ("check_2a_parent_set", "check_2b_merkle_openings"))
def test_skipped_parent(self):
p = copy.deepcopy(self.proof)
i = next(j for j, r in enumerate(p.responses) if len(r.parents) == 2)
p.responses[i].parents = p.responses[i].parents[1:]
self.assertEqual(self.v(p).check, "check_2a_parent_set")
def test_duplicated_parent_position(self):
# the Bevand 'Attack 2' class: both openings for the same parent; positions are bound by the verifier
p = copy.deepcopy(self.proof)
i = next(j for j, r in enumerate(p.responses) if len(r.parents) == 2)
p.responses[i].parents[0] = copy.deepcopy(p.responses[i].parents[1])
self.assertEqual(self.v(p).check, "check_2b_merkle_openings")
def test_wrong_domain_tag_label(self):
# a prover that labels with the Merkle tag instead of the label tag, consistently, and commits honestly to that array
lab = mtp.compute_labels(CHI, self.par, tag=params.TAG_MERKLE)
st = mtp.ProverState(CHI, self.n, self.par, lab, mtp.MerkleTree(CHI, lab, self.n))
self.assertEqual(self.v(mtp.respond(st, self.k)).check, "check_3_local_consistency")
def test_wrong_domain_tag_merkle(self):
tree = mtp.MerkleTree(CHI, self.st.labels, self.n, tag=params.TAG_LABEL)
st = mtp.ProverState(CHI, self.n, self.par, self.st.labels, tree)
self.assertEqual(self.v(mtp.respond(st, self.k)).check, "check_2b_merkle_openings")
def test_wrong_chi(self):
self.assertEqual(self.v(self.proof, chi=CHI2).check, "check_2b_merkle_openings")
def test_container(self):
b = self.proof.to_bytes()
self.assertEqual(self.v(b[:-1]).check, "check_0_container")
self.assertEqual(self.v(b + b"\0").check, "check_0_container")
self.assertEqual(mtp.verify(CHI, self.n, self.k + 1, b).check, "check_0_container")
self.assertEqual(mtp.verify(CHI, self.n + 1, self.k, b).check, "check_0_container")
def test_every_check_has_a_refusal(self):
# the named checks of the verifier, each shown to fire above (a watcher is trusted only after it fires)
names = {"check_0_container", "check_2a_parent_set", "check_2b_merkle_openings", "check_3_local_consistency"}
fired = {
self.v(self.proof.to_bytes()[:-1]).check,
self.v(self.proof, chi=CHI2).check,
}
p = copy.deepcopy(self.proof)
i = next(j for j, r in enumerate(p.responses) if len(r.parents) == 2)
p.responses[i].parents = p.responses[i].parents[1:]
fired.add(self.v(p).check)
lab = mtp.compute_labels(CHI, self.par, tag=params.TAG_MERKLE)
st = mtp.ProverState(CHI, self.n, self.par, lab, mtp.MerkleTree(CHI, lab, self.n))
fired.add(self.v(mtp.respond(st, self.k)).check)
self.assertEqual(fired, names)
if __name__ == "__main__":
unittest.main()