diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml
index 222f19310..0bfc26daf 100644
--- a/.github/workflows/doc.yml
+++ b/.github/workflows/doc.yml
@@ -62,10 +62,16 @@ jobs:
with:
working_directory: doc/xmss
root_file: main.tex
+ - name: Compile SPHINCS specification
+ uses: xu-cheng/latex-action@v3
+ with:
+ working_directory: doc/sphincs
+ root_file: main.tex
- name: Name the artifacts
run: |
cp doc/leanvm/.build/main.pdf leanVM-b.pdf
cp doc/xmss/.build/main.pdf XMSS.pdf
+ cp doc/sphincs/.build/main.pdf SPHINCS.pdf
- name: Publish PDFs as release assets
uses: softprops/action-gh-release@v2
with:
@@ -76,7 +82,9 @@ jobs:
`leanVM-b.pdf` contains the leanVM-b specification.
`XMSS.pdf` contains the XMSS specification.
+ `SPHINCS.pdf` contains the SPHINCS specification.
make_latest: false
files: |
leanVM-b.pdf
XMSS.pdf
+ SPHINCS.pdf
diff --git a/AGENTS.md b/AGENTS.md
index d14c19b8b..9a2c5ada7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,6 +6,8 @@ A minimal (zero-knowledge Virtual Machine, which is actually not ZK in the real
- `doc/leanvm/` is the LaTeX project describing the machine ISA and the snark that proves it. Its root is `doc/leanvm/main.tex`; build it with `cd doc/leanvm && latexmk -pdf main.tex`, which writes to the gitignored `doc/leanvm/.build/`. Sections live in `doc/leanvm/body/`, numbered `01`..`10` plus the lettered annexes `a` (ring switching), `b` (the PCS), and `c` (Flock), and every symbol is defined once in `doc/leanvm/preamble/macros.tex`. If latexmk fails oddly (a bibtex error, or a missing `main.log`) right after inputs are renamed or `refs.bib` is edited, remove `doc/leanvm/.build` and rerun; it has not reproduced on unchanged inputs. **Drafting one section:** each section file carries a `% !TeX root` comment pointing at its generated driver in `doc/leanvm/drafts/`, so the LaTeX build key (`F5`, or the extension's `cmd+alt+b`) compiles only that section, numbered as in the full document and with cross-references and citations resolved against `.build/main.aux`; in `main.tex` the same key builds everything. Run `doc/leanvm/make-drafts.sh` after adding, renaming or renumbering a section.
- `doc/xmss/` is the standalone specification of the concrete XMSS instance implemented by `crates/xmss`.
+- `doc/sphincs/` is the standalone specification of the concrete SPHINCS+ instance we would use instead of XMSS where statelessness matters; its root is `doc/sphincs/main.tex`, built the same way as `doc/xmss`, and implemented by `crates/sphincs`. It shares XMSS's hash function, tweakable hash and target-sum code, so an aggregator implements one primitive.
+- `formal/xmss/` is a Lean 4 proof (over VCVio) of that instance's classical random-oracle security, `xmss_has_127_bits_of_classical_security`. `XmssSecurity/Statement.lean` is the only module a reviewer has to read: the concrete parameters, the byte layout of every hash input, the three algorithms, the game, and the claim. `lake exe cache get` once, then `lake build`. SPHINCS has no formalization; its security section is a target, not a theorem.
- The one hash function is BLAKE2s, in `primitives::hash`: scalar, streaming, keyed, and a lane-transposed batched form for the PCS Merkle tree. The VM proves one compression per opcode, and BLAKE2s takes the byte counter and final-block flag as ordinary compression inputs, so a single opcode is a complete hash for any length, with no tree structure to reproduce in-circuit.
- `crates/lean_compiler/zkDSL.md` documents the (pythonic) zkDSL (that compiles to the ISA that our VM runs, and that our snark proves).
@@ -28,7 +30,8 @@ Dependency order, leaves first:
| `lean_vm` | arithmetization: tables, bus, constraints, `cpu::prove`/`verify` |
| `lean_compiler` | zkDSL (Python subset) → ISA |
| `xmss` | XMSS over BLAKE2s; an independent leaf, consumed only by `rec_aggregation` |
-| `rec_aggregation` | recursive XMSS aggregation: the one guest, the public API, the benchmarks |
+| `sphincs` | the stateless SPHINCS+ instance of `doc/sphincs`; an independent leaf, consumed only by `rec_aggregation` |
+| `rec_aggregation` | recursive XMSS and SPHINCS aggregation: the one guest, the public API, the benchmarks |
`src/main.rs` is the CLI; guests are zkDSL under `crates/rec_aggregation/guests/`.
@@ -60,9 +63,12 @@ Heavy benches and measurement harnesses are `#[ignore]`d; run by name with `-- -
## Benchmarking
The benchmarks we care about:
-- `cargo run --release -- xmss --n-signatures 900 --log-inv-rate 1 --repeat 3`
+- `cargo run --release -- aggregate --xmss 900 --log-inv-rate 1 --repeat 3`
+- `cargo run --release -- aggregate --sphincs 220 --log-inv-rate 1 --repeat 3`
- `cargo run --release -- recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2 --repeat 3`
+`aggregate` takes a count per scheme, both defaulting to zero, so either alone or a mix of the two is one command; `recursion --sphincs-per-leaf` likewise puts both schemes in one tree. One SPHINCS signature costs 531 compressions against XMSS's 144, and about six times an XMSS signature's VM cycles, so a leaf of a given proven size holds proportionally fewer of them.
+
## The proving arena (`zk_alloc`)
One proof is one **phase**, opened by `cpu::prove`. `ArenaVec` bumps a per-thread slab, freeing is a no-op, and the next `begin_phase()` reclaims everything. Not a `#[global_allocator]`: `raw_dealloc` picks arena-vs-system by address range, so with no phase open `ArenaVec` is an ordinary system vector (used in particular by the verifier, where correctness and simplicity matters much more than performance).
@@ -87,9 +93,9 @@ The same verification algorithm is written out three times, in three languages.
1. **Rust**, `lean_vm::cpu::verify`. The performant verifier implem.
2. **Python**, `python-verifier/verifier.py` (~2.5k lines, no dependencies). pure python, for readability and simplicity. Pinned by `lean_vm/tests/verifiers/python_verifier.rs`.
-3. **Recursive verifier**, `crates/rec_aggregation/guests/aggregate.py` (~2.7k lines of zkDSL). Written using our pythonic zkDSL (but it's not real python!), which then compiles to our custom ISA. Proving it result in recursion -> a snark of another snark.
+3. **Recursive verifier**, `crates/rec_aggregation/guests/aggregate.py` (~3.2k lines of zkDSL). Written using our pythonic zkDSL (but it's not real python!), which then compiles to our custom ISA. Proving it result in recursion -> a snark of another snark.
-Understand the third before changing the verifier. `guests/aggregate.py` is zkDSL, not runnable Python. `lean_compiler` lowers it to the six-opcode, write-once-memory VM, so the prover proves every verifier step. The guest is ~330k instructions (2^19 padded), with the mix reported by the recursion benchmark. Two consequences:
+Understand the third before changing the verifier. `guests/aggregate.py` is zkDSL, not runnable Python. `lean_compiler` lowers it to the six-opcode, write-once-memory VM, so the prover proves every verifier step. The guest is ~354k instructions (2^19 padded), with the mix reported by the recursion benchmark. It verifies raw signatures of both schemes: a node's coverage table is one contiguous region per scheme, so the one range check a write already needs also keeps an XMSS signature off a declared SPHINCS claim, and the statement's two signer lists say which scheme verified which key. The XMSS signers share the statement's message and epoch; a SPHINCS signer's message rides its own four-cell slot, so that list is `(key, message)` pairs and its length counts claims rather than distinct signers. XMSS's tweaks ride the statement (they depend only on the public epoch); SPHINCS's are built in-circuit from the index its message digest picks. Two consequences:
- The guest is **self-referential**: it verifies proofs of itself, so `unified_guest` compiles it to a fixed point on its own log size. The digest needs no fixed point, riding the statement instead of the code, which is also what lets one bytecode serve any inner size and PCS rate.
- It does not verify *quite* everything in-circuit. Three claims on fixed polynomials (stacked bytecode, flock's A0/B0) are deferred. Each node batches its children's carried claims with the fresh ones its verifications raise, `2n` per polynomial down to one; only the root's are discharged natively, by `AggregateSignature::verify` (explained in `doc/leanvm/`).
diff --git a/Cargo.lock b/Cargo.lock
index 302b6d94e..b492ffa76 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -381,6 +381,7 @@ dependencies = [
"primitives",
"rand",
"serde",
+ "sphincs",
"tracing",
"xmss",
"zk_alloc",
@@ -448,6 +449,16 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+[[package]]
+name = "sphincs"
+version = "0.1.0"
+dependencies = [
+ "parallel",
+ "primitives",
+ "rand",
+ "serde",
+]
+
[[package]]
name = "strsim"
version = "0.11.1"
diff --git a/Cargo.toml b/Cargo.toml
index 80ee11e28..f7d39b556 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -38,6 +38,7 @@ lean_vm = { path = "crates/lean_vm" }
lean_compiler = { path = "crates/lean_compiler" }
rec_aggregation = { path = "crates/rec_aggregation" }
xmss = { path = "crates/xmss" }
+sphincs = { path = "crates/sphincs" }
zk_alloc = { path = "crates/zk_alloc" }
parallel = { path = "crates/parallel" }
libc = "0.2"
diff --git a/README.md b/README.md
index 6bee2b86b..c04c6e01a 100644
--- a/README.md
+++ b/README.md
@@ -9,8 +9,9 @@
-
-
+
+
+
Warning: highly experimental.
@@ -24,19 +25,35 @@ Machine: Mac M4 Max
Our XMSS is specified in [XMSS.pdf](https://github.com/leanEthereum/leanVM-b/releases/download/doc-latest/XMSS.pdf).
```bash
-cargo run --release -- xmss --n-signatures 900 --log-inv-rate 1 --repeat 3
+cargo run --release -- aggregate --xmss 900 --log-inv-rate 1 --repeat 3
```
```
-XMSS aggregation, 900 signatures
- cycles (VM steps) : 1,542,617 = 2^20.557
- proven rows : 1,967,104 = 2^20.908 (filled to powers of two)
- details : DEREF 2^18.988 (33.7%) SET 2^18.402 (22.4%) MUL 2^18.198 (19.5%) BLAKE2S 2^16.995 (8.5%) XOR 2^16.96 (8.3%) JUMP 2^16.831 (7.6%) PACK64X2 2^9.938 (0.1%) MEMORY 2^21.725 TOTAL_COMMITTED 2^26.195
- signers : 900
- proof size : 304.4 KiB
- aggregating : 0.816 s ± 4.2% peak memory 13.815 GiB
- per signature : 1,102.621 XMSS/s
- verifying : 0.0137 s
+aggregation, 900 XMSS signatures
+ cycles (VM steps) : 1,542,871 = 2^20.557
+ details : DEREF 2^18.988 (33.7%) SET 2^18.402 (22.4%) MUL 2^18.199 (19.5%) BLAKE2S 2^16.995 (8.5%) XOR 2^16.961 (8.3%) JUMP 2^16.843 (7.6%) MEMORY 2^21.724 TOTAL_COMMITTED 2^26.195
+ proof size : 304.5 KiB
+ proving time : 0.821 s ± 2.6% peak memory 13.956 GiB
+ per signature : 1,096.508 signatures/s
+ verifying : 0.0135 s
+```
+
+### SPHINCS aggregation
+
+Our SPHINCS is specified in [SPHINCS.pdf](https://github.com/leanEthereum/leanVM-b/releases/download/doc-latest/SPHINCS.pdf).
+
+```bash
+cargo run --release -- aggregate --sphincs 245 --log-inv-rate 1 --repeat 3
+```
+
+```
+aggregation, 245 SPHINCS signatures
+ cycles (VM steps) : 2,677,883 = 2^21.353
+ details : DEREF 2^19.45 (26.7%) XOR 2^19.306 (24.2%) MUL 2^19.212 (22.7%) SET 2^18.866 (17.8%) BLAKE2S 2^16.996 (4.9%) JUMP 2^16.568 (3.6%) MEMORY 2^22.089 TOTAL_COMMITTED 2^26.875
+ proof size : 345.8 KiB
+ proving time : 1.219 s ± 5.2% peak memory 20.119 GiB
+ per signature : 201.022 signatures/s
+ verifying : 0.0177 s
```
### Recursion
@@ -47,14 +64,12 @@ cargo run --release -- recursion --n 2 --xmss-per-leaf 900 --log-inv-rate 2 --re
```
```
-recursion 2→1, over leaves of 900 signatures
- cycles (VM steps) : 807,637 = 2^19.623
- proven rows : 1,179,648 = 2^20.17 (filled to powers of two)
- details : DEREF 2^18.108 (35.0%) MUL 2^17.876 (29.8%) XOR 2^17.503 (23.0%) SET 2^15.539 (5.9%) JUMP 2^14.826 (3.6%) BLAKE2S 2^14.437 (2.7%) MEMORY 2^19.911 TOTAL_COMMITTED 2^24.856
- signers : 1,800
- proof size : 213.4 KiB
- aggregating : 0.453 s ± 5.7% peak memory 17.292 GiB
- verifying : 0.016 s
+recursion 2→1, over leaves of 900 XMSS signatures
+ cycles (VM steps) : 807,861 = 2^19.624
+ details : DEREF 2^18.109 (35.0%) MUL 2^17.876 (29.8%) XOR 2^17.503 (23.0%) SET 2^15.539 (5.9%) JUMP 2^14.826 (3.6%) BLAKE2S 2^14.437 (2.7%) MEMORY 2^19.911 TOTAL_COMMITTED 2^24.856
+ proof size : 212.7 KiB
+ proving time : 0.453 s ± 4.9% peak memory 17.287 GiB
+ verifying : 0.0161 s
```
### Fibonacci
@@ -66,32 +81,33 @@ cargo run --release -- fibonacci --n 2000000 --log-inv-rate 1 --repeat 3
```
Fibonacci (in the exponent, i.e. modulo 2^64 - 1), N = 2,000,000
- cycles (VM steps) : 2,127,881
- details : MUL 2^20.937 (98.7%) DEREF 2^13.967 (0.8%) SET 2^12.552 (0.3%) JUMP 2^10.968 (0.1%) XOR 2^10.966 (0.1%) MEMORY 2^20.964 TOTAL_COMMITTED 2^25.263
- proof size : 284.7 KiB
- proving : 0.41 s ± 2.9% 5,191,741 cycles/s peak memory 7.482 GiB
- verifying : 0.00352 s
+ cycles (VM steps) : 2,127,880
+ details : MUL 2^20.937 (98.7%) DEREF 2^13.967 (0.8%) SET 2^12.552 (0.3%) JUMP 2^10.968 (0.1%) XOR 2^10.966 (0.1%) MEMORY 2^20.964 TOTAL_COMMITTED 2^25.263
+ proof size : 286.2 KiB
+ proving : 0.425 s ± 6.9% 5,009,971 cycles/s peak memory 7.523 GiB
+ verifying : 0.00315 s
```
### Batch proving BLAKE2s
```bash
-BENCH_REPEAT=3 BENCH_COOLDOWN=2 FLOCK_N_LOG=18 cargo test --release -p flock --test blake2s_batch -- --ignored --nocapture
+BENCH_REPEAT=3 BENCH_COOLDOWN=2 FLOCK_N_LOG=18 cargo test --release -p flock --test hash_batch -- --ignored --nocapture
```
```
Flock BLAKE2s batch proving, 262,144 compressions (2^18 slots)
setup (preprocessing, excluded) : 0.0 ms
- witness-gen : 51.2 ms ± 23.3% 8.6%
- commit : 100.1 ms ± 0.3% 16.8%
- zerocheck : 237.0 ms ± 4.2% 39.7%
- lincheck : 19.4 ms ± 10.8% 3.3%
- pcs opening : 188.9 ms ± 7.1% 31.7%
+ witness-gen : 62.2 ms ± 29.8% 10.1%
+ commit : 100.2 ms ± 1.2% 16.3%
+ zerocheck : 234.8 ms ± 1.4% 38.3%
+ lincheck : 20.5 ms ± 7.7% 3.3%
+ pcs opening : 195.9 ms ± 3.4% 31.9%
other : 0.0 ms 0.0%
------------------------------------------
- prove TOTAL (witness excluded) : 545.5 ms ± 3.9% 91.4%
+ prove TOTAL (witness excluded) : 551.4 ms ± 1.9% 89.9%
verify : 2.0 ms
- throughput : 480,600 compressions/s ± 3.9%
+ throughput : 475,423 compressions/s ± 1.9%
+ (~3256.3 XMSS/s equivalent at 146 compressions/signature)
```
## Security
diff --git a/crates/lean_compiler/zkDSL.md b/crates/lean_compiler/zkDSL.md
index ffb9012d7..b4d005bed 100644
--- a/crates/lean_compiler/zkDSL.md
+++ b/crates/lean_compiler/zkDSL.md
@@ -24,6 +24,7 @@ Machine **words** (the contents of a memory cell, an immediate, a hashed value,
- an integer literal `n` supplies up to 128 raw bits and is embedded as `F192(c0, c1, 0)`. This is a source-syntax limit, not the machine-word width: words have three 64-bit limbs. Thus `5` is `1 + x^2`, not the integer five, and `2 ** 64` is the tower element `y`. Full-width constants use `f192(c0, c1, c2)`, with each limb an unsigned 64-bit compile-time integer,
- `GEN` is the fixed generator `g = x` of the 64-bit subfield `K^×` (multiplicative order `2^64 − 1`),
- `GEN ** e` is the compile-time constant `g^e ∈ K` (`**` takes base `GEN` and a compile-time integer exponent: a literal, a constant, an `unroll` variable, `len(...)`, or index arithmetic of those). So `buf[GEN ** i]` names heap cell `i` directly inside an `unroll` loop, with no running-pointer cursor.
+- constant arithmetic means different things in the two positions, and this is a silent trap: `a + b` on two constants is **integer** addition in an index, a bound or a keyword (`buf[GEN ** (i + 1)]`, `unroll(0, n + 1)`, `counter=64 * (q + 1)`), and **XOR** in a value, where `1 + 1` is `0`. So a literal built in a value position must not add overlapping integers: `tweak = base + (level + 1) * SHIFT` drops the whole term on odd levels. Products are safe (an integer times a power of two is that shift, as long as the top bit stays inside the limb); to add, index a literal table with the integer arithmetic instead, `LEVELS[level + 1]`.
- `base ** e` with a **non-`GEN`** base and a compile-time exponent `e` is square-and-multiply: integer arithmetic in an index/bound position (`2 ** c`), or field arithmetic in a value position (`x ** k`, e.g. a loop counter `g^i` raised to a stride to reach cell `i·stride`). The base may be runtime.
A logical **index** `i` is carried as `g^i` in the 64-bit subfield (order `2^64 − 1`): incrementing is one multiplication by `GEN`, and memory/bytecode addresses are g-powers. This is the design idiom of the whole VM: loops, heap addressing, and range checks below all live in the exponent, in `K`.
diff --git a/crates/lean_vm/src/cpu/mod.rs b/crates/lean_vm/src/cpu/mod.rs
index 2fa62d66c..5536216c1 100644
--- a/crates/lean_vm/src/cpu/mod.rs
+++ b/crates/lean_vm/src/cpu/mod.rs
@@ -428,7 +428,7 @@ fn blake2s_value_slot(col: usize) -> Option {
/// committed witness size, the sum of the column lengths, i.e. the real data
/// before the stacked witness is zero-padded to a power of two `2^m`.
pub struct Stats {
- pub cycles: usize,
+ pub cycles: usize, // including the padding to make every instruction count a power of two
/// Rows per table as proven: each an exact power of two, the fill blocks having
/// filled them (`filler`).
pub counts: [usize; tables::N_TABLES],
diff --git a/crates/rec_aggregation/Cargo.toml b/crates/rec_aggregation/Cargo.toml
index e4bbaa0ff..3859460d7 100644
--- a/crates/rec_aggregation/Cargo.toml
+++ b/crates/rec_aggregation/Cargo.toml
@@ -14,6 +14,7 @@ flock.workspace = true
lean_vm.workspace = true
lean_compiler.workspace = true
xmss.workspace = true
+sphincs.workspace = true
rand.workspace = true
bincode.workspace = true
serde.workspace = true
diff --git a/crates/rec_aggregation/guests/aggregate.py b/crates/rec_aggregation/guests/aggregate.py
index 0d49f048d..03a2ee9c5 100644
--- a/crates/rec_aggregation/guests/aggregate.py
+++ b/crates/rec_aggregation/guests/aggregate.py
@@ -228,7 +228,7 @@
# two to a cell and four cells to a 64-byte block.
STMT_TAG_0 = STMT_TAG_0_PLACEHOLDER
STMT_TAG_1 = STMT_TAG_1_PLACEHOLDER
-STMT_HEADER = 9
+STMT_HEADER = STMT_HEADER_PLACEHOLDER
STMT_DEFER_OFF = 2 + STMT_HEADER
STMT_ODD = STMT_ODD_PLACEHOLDER
STMT_PAIRS = STMT_PAIRS_PLACEHOLDER
@@ -300,8 +300,73 @@
TIP_CELLS = WORDS_PER_VALUE * V # the V chain tips, one cell each
WOTS_PK_BLOCKS = (2 + V) / 4 # prefix (tweak, pp) + V tips, four cells per BLAKE2s block
-# Aggregation bounds. MAX_KEYS caps n_keys + n_dup, which is what the coverage
-# range check needs below 2^MIN_LOG_MEM; MAX_CHILDREN is the recursion arity.
+# ---- SPHINCS+ instance parameters (host-supplied via placeholders) ----
+# The scheme's own letters, prefixed SP_ where XMSS has the same one.
+SP_V = SP_V_PLACEHOLDER
+SP_W = SP_W_PLACEHOLDER
+SP_TARGET_SUM = SP_TARGET_SUM_PLACEHOLDER
+SP_D = SP_D_PLACEHOLDER
+SP_HEIGHTS = SP_HEIGHTS_PLACEHOLDER # h_lay, one per hypertree layer, top first
+SP_SUFFIX = SP_SUFFIX_PLACEHOLDER # SP_SUFFIX[lay] = sum of h_j for j >= lay
+SP_A = SP_A_PLACEHOLDER
+SP_K = SP_K_PLACEHOLDER
+SP_H = SP_H_PLACEHOLDER # the total hypertree height, SP_SUFFIX[0]
+
+SP_CHAIN_LENGTH = 2 ** SP_W
+SP_CHAIN_STEPS = SP_CHAIN_LENGTH - 1
+SP_DIGITS_PER_WORD = SP_V / 2
+SP_TIP_CELLS = SP_V
+SP_LEAF_BLOCKS = (2 + SP_V) / 4 # prefix (tweak, pp) + V tips, four cells a block
+SP_N_FTS = SP_K - 1 # the forest drops the last index's tree
+SP_ROOT_BLOCKS = (2 + SP_N_FTS) / 4
+
+# The message digest is h + k*a bits of a BLAKE2s output: the whole low cell and
+# the low 48 bits of the high one. Decomposing the high cell's low lane covers
+# them, so the buffer holds three lanes and the top 16 are never read.
+SP_BIT_LANES = 3
+SP_BIT_CELLS = SP_BIT_LANES * BASE_FIELD_BITS
+
+# Tweak types (the tweak's first byte). Types 0 and 5 are the seed derivation's,
+# which is a signer's own business: nothing in-circuit ever verifies one.
+SP_TW_PRF = 0
+SP_TW_CHAIN = 1
+SP_TW_LEAF = 2
+SP_TW_NODE = 3
+SP_TW_ENC = 4
+SP_TW_FTS_PRF = 5
+SP_TW_FTS_LEAF = 6
+SP_TW_FTS_NODE = 7
+SP_TW_FTS_ROOTS = 8
+SP_TW_MSG = 9
+
+# enc(t, lay, tau, p, j) packs t at bit 0, lay at 8, tau at 16, p at 48 and j at
+# 80, fourteen bytes of fields and two of padding. Every field this instance uses
+# is small enough that none straddles the 64-bit lane boundary (tau < 2^26 at bit
+# 16, p <= 334 at bit 48, j < 2^12 at bit 80), so a tweak cell is
+# `t + lay*2^8 + tau*2^16 + p*2^48` in lane 0 plus `j*2^16` in lane 1, and every
+# term is one field addition. `SP_TAU_POS` and `SP_J_POS` are where a bit of tau
+# or of j weighs in the coordinate basis, the j position already carrying the
+# lane, so nothing has to be multiplied by Y afterwards.
+SP_LAY_MUL = 2 ** 8
+SP_P_MUL = 2 ** 48
+SP_TAU_POS = 16
+SP_J_POS = BASE_FIELD_BITS + 16
+# A value expression's constants fold IN THE FIELD, where `1 + 1` is 0, so a
+# Merkle level cannot be written `level + 1` there (it would be `level XOR 1`,
+# and the p field would silently vanish on odd levels). SP_P_LEVEL[lambda] is
+# the literal `lambda * 2^48` outright, indexed with the integer arithmetic that
+# an index position does support.
+SP_P_LEVEL = SP_P_LEVEL_PLACEHOLDER
+SP_CHAIN_MUL = SP_CHAIN_LENGTH * SP_P_MUL # chain i's tweaks start at p = 2^w * i
+
+# The encoding counter, LE_32 in the low four bytes of its cell: bounded by
+# decomposing exactly that many bits, so the guest accepts no preimage the
+# native verifier cannot parse.
+SP_COUNTER_BITS = 32
+
+# Aggregation bounds. MAX_KEYS caps the coverage table's slots, both schemes'
+# declared keys and their duplicates, which is what the coverage range check
+# needs below 2^MIN_LOG_MEM; MAX_CHILDREN is the recursion arity.
MAX_KEYS = MAX_KEYS_PLACEHOLDER
MAX_CHILDREN = MAX_CHILDREN_PLACEHOLDER
@@ -2356,9 +2421,250 @@ def walk(value, chain_tweaks, pp, k: Const):
-def statement_digest(seed_0, seed_1, n_keys_g, pk_hash, msg, epoch, defer):
+@inline
+def sp_bit_field(bits_ptr, off: Const, n: Const, pos: Const):
+ # The integer held by bits [off, off+n) of the digest, weighed into the
+ # coordinate basis at `pos`: a tweak field placed where the tweak wants it,
+ # one fused multiply-add a bit, whatever lane the bits came from.
+ acc = 0
+ for i in unroll(0, n):
+ acc += bits_ptr[GEN ** (off + i)] * COORD_BASIS[pos + i]
+ return acc
+
+
+def sp_bind_lane(bits_ptr, lane):
+ # The 64 bits of one lane: boolean-pinned as in decode_query_bits (the cell
+ # already holds the bit, so storing its square IS the assert) and tied back
+ # by reconstruction, which is what makes the hinted decomposition the lane's.
+ acc = 0
+ for i in unroll(0, BASE_FIELD_BITS):
+ b = bits_ptr[GEN ** i]
+ bits_ptr[GEN ** i] = b * b
+ acc += b * COORD_BASIS[i]
+ assert acc == lane
+ return
+
+
+def sp_walk(value, tw_base, pp, k: Const):
+ # Walk chain steps k..SP_CHAIN_STEPS-1: value' = Th(P, tw_chain, value).
+ # `tw_base` already carries the type byte, the layer, 2^w*i and the position
+ # (tau, e), so step s's tweak is one addition of a compile-time literal.
+ block = StackBuf(WORDS_PER_BLOCK)
+ block[0] = value
+ block[1] = 0
+ for s in unroll(k, SP_CHAIN_STEPS):
+ step_tweak = StackBuf(WORDS_PER_BLOCK)
+ step_tweak[0] = tw_base + s * SP_P_MUL
+ step_tweak[1] = pp
+ out = StackBuf(WORDS_PER_BLOCK)
+ blake2s(step_tweak, block, out, counter=48, final=1)
+ block = StackBuf(WORDS_PER_BLOCK)
+ block[0] = out[0]
+ block[1] = 0
+ return block[0], k
+
+
+def sp_ots_leaf(tw_pos, pp, msg):
+ # One layer's one-time verification: the encoding of `msg` under the hinted
+ # counter, the V chains walked from the revealed values, and the leaf they
+ # hash to. `tw_pos` is the position's tweak base (layer, tau, e); this
+ # function is called once per layer, so the V dispatch tables are compiled
+ # once for the whole scheme.
+ ctr = StackBuf(1)
+ hint_witness(ctr, "sp_counter")
+ ctr_bits = HeapBuf(GEN ** SP_COUNTER_BITS)
+ hint_decompose_bits(ctr_bits, ctr[0], SP_COUNTER_BITS)
+ ctr_acc = 0
+ for i in unroll(0, SP_COUNTER_BITS):
+ b = ctr_bits[GEN ** i]
+ ctr_bits[GEN ** i] = b * b
+ ctr_acc += b * COORD_BASIS[i]
+ assert ctr_acc == ctr[0] # LE_32: the counter's cell is four bytes and twelve of padding
+
+ # D = Th(P, tw_enc, msg | LE_32(c)), a 52-byte one-block hash.
+ enc_tweak = StackBuf(WORDS_PER_BLOCK)
+ enc_tweak[0] = tw_pos + SP_TW_ENC
+ enc_tweak[1] = pp
+ enc_block = StackBuf(WORDS_PER_BLOCK)
+ enc_block[0] = msg
+ enc_block[1] = ctr[0]
+ digest = StackBuf(WORDS_PER_BLOCK)
+ blake2s(enc_tweak, enc_block, digest, counter=52, final=1)
+
+ # The codeword, as in XMSS: each digit is hinted in the exponent, range
+ # checked and dispatched once, arm k walking the remaining steps; the product
+ # of the digits is the target sum, and the digits weighted by 2^w within each
+ # 64-bit lane reconstruct D, which pins each lane's leftover top bit to zero.
+ tips = StackBuf(SP_TIP_CELLS)
+ digit_product = 1
+ acc_lo = 0
+ weight = 1
+ for i in unroll(0, SP_DIGITS_PER_WORD):
+ digit = StackBuf(1)
+ hint_witness(digit[0:1], "sp_digits")
+ assert log(digit[0]) < SP_CHAIN_LENGTH
+ chain_start = StackBuf(1)
+ hint_witness(chain_start, "sp_chain_starts")
+ tw_chain = tw_pos + SP_TW_CHAIN + i * SP_CHAIN_MUL
+ t, e = match_range(log(digit[0]), range(0, SP_CHAIN_LENGTH), lambda k: sp_walk(chain_start[0], tw_chain, pp, k))
+ tips[i] = t
+ digit_product = digit_product * digit[0]
+ acc_lo = acc_lo + e * weight
+ weight = weight * SP_CHAIN_LENGTH
+ acc_hi = 0
+ weight = 1
+ for i in unroll(SP_DIGITS_PER_WORD, SP_V):
+ digit = StackBuf(1)
+ hint_witness(digit[0:1], "sp_digits")
+ assert log(digit[0]) < SP_CHAIN_LENGTH
+ chain_start = StackBuf(1)
+ hint_witness(chain_start, "sp_chain_starts")
+ tw_chain = tw_pos + SP_TW_CHAIN + i * SP_CHAIN_MUL
+ t, e = match_range(log(digit[0]), range(0, SP_CHAIN_LENGTH), lambda k: sp_walk(chain_start[0], tw_chain, pp, k))
+ tips[i] = t
+ digit_product = digit_product * digit[0]
+ acc_hi = acc_hi + e * weight
+ weight = weight * SP_CHAIN_LENGTH
+ assert digit_product == GEN ** SP_TARGET_SUM
+ assert acc_lo + acc_hi * Y_TOWER == digest[0]
+
+ leaf_tweak = StackBuf(WORDS_PER_BLOCK)
+ leaf_tweak[0] = tw_pos + SP_TW_LEAF
+ leaf_tweak[1] = pp
+ leaf = StackBuf(WORDS_PER_BLOCK)
+ blake2s(leaf_tweak, tips[0:2], leaf, counter=64, final=0)
+ for q in unroll(1, SP_LEAF_BLOCKS):
+ next_leaf = StackBuf(WORDS_PER_BLOCK)
+ blake2s(tips[4 * q - 2:4 * q], tips[4 * q:4 * q + 2], next_leaf, cv=leaf, counter=64 * (q + 1), final=(q + 1) // SP_LEAF_BLOCKS)
+ leaf = next_leaf
+ return leaf[0]
+
+
+def verify_sig_sphincs(signer):
+ # `signer` is one 4-cell entry of the SPHINCS coverage table: the key's root
+ # and public parameter, then the message THAT signer signed. Where XMSS's
+ # message is one statement field for the whole node, a SPHINCS message rides
+ # its own slot, and the signer-set digest binds the two together.
+ pp = signer[GEN]
+
+ # ---- the message digest, which chooses the few-time key ----
+ # D = Truncate(H(tw_msg | P | rho | root | m)), 96 bytes in two blocks.
+ msg_tweak = StackBuf(WORDS_PER_BLOCK)
+ msg_tweak[0] = SP_TW_MSG
+ msg_tweak[1] = pp
+ rho_root = StackBuf(WORDS_PER_BLOCK)
+ hint_witness(rho_root[0:1], "sp_rand")
+ rho_root[1] = signer[1]
+ prefix = StackBuf(WORDS_PER_BLOCK)
+ blake2s(msg_tweak, rho_root, prefix, counter=64, final=0)
+ msg_block = StackBuf(WORDS_PER_BLOCK)
+ msg_block[0] = signer[GEN ** 2]
+ msg_block[1] = signer[GEN ** 3]
+ zero_block = StackBuf(WORDS_PER_BLOCK)
+ zero_block[0] = 0
+ zero_block[1] = 0
+ digest = StackBuf(WORDS_PER_BLOCK)
+ blake2s(msg_block, zero_block, digest, cv=prefix, counter=96, final=1)
+
+ # The index and the k leaf indices are bit fields of that digest, so its bits
+ # are advice-decomposed here and bound lane by lane. Nothing else derives
+ # them: every tweak below is built from these bits.
+ bits = HeapBuf(GEN ** SP_BIT_CELLS)
+ low = StackBuf(1)
+ hint_f192_limbs(low, digest[0])
+ high = (digest[0] + low[0]) * Y_INV
+ assert_in_k(low[0], high)
+ hint_decompose_bits(bits, low[0], BASE_FIELD_BITS)
+ hint_decompose_bits(bits * GEN ** BASE_FIELD_BITS, high, BASE_FIELD_BITS)
+ tail = StackBuf(1)
+ hint_f192_limbs(tail, digest[1])
+ tail_high = (digest[1] + tail[0]) * Y_INV
+ assert_in_k(tail[0], tail_high)
+ hint_decompose_bits(bits * GEN ** (2 * BASE_FIELD_BITS), tail[0], BASE_FIELD_BITS)
+ sp_bind_lane(bits, low[0])
+ sp_bind_lane(bits * GEN ** BASE_FIELD_BITS, high)
+ sp_bind_lane(bits * GEN ** (2 * BASE_FIELD_BITS), tail[0])
+
+ # The digest is admissible only if its last leaf index is zero, which is what
+ # lets the forest drop that tree.
+ for b in unroll(0, SP_A):
+ assert bits[GEN ** (SP_H + (SP_K - 1) * SP_A + b)] == 0
+
+ # ---- the few-time signature: one opened leaf per tree of the forest ----
+ idx_tau = sp_bit_field(bits, 0, SP_H, SP_TAU_POS)
+ roots = StackBuf(SP_N_FTS)
+ for kappa in unroll(0, SP_N_FTS):
+ leaf_off = SP_H + kappa * SP_A
+ secret = StackBuf(WORDS_PER_BLOCK)
+ hint_witness(secret[0:1], "sp_fts_secrets")
+ secret[1] = 0
+ fts_tweak = StackBuf(WORDS_PER_BLOCK)
+ fts_tweak[0] = SP_TW_FTS_LEAF + kappa * SP_LAY_MUL + idx_tau + sp_bit_field(bits, leaf_off, SP_A, SP_J_POS)
+ fts_tweak[1] = pp
+ fts_leaf = StackBuf(WORDS_PER_BLOCK)
+ blake2s(fts_tweak, secret, fts_leaf, counter=48, final=1)
+ node = fts_leaf[0]
+ for level in unroll(0, SP_A):
+ bit = bits[GEN ** (leaf_off + level)]
+ sibling = StackBuf(1)
+ hint_witness(sibling, "sp_fts_paths")
+ # Branchless child ordering, as in verify_sig: bit is one of the
+ # boolean-pinned digest bits, so the swap is a select.
+ diff = node + sibling[0]
+ m = bit * diff
+ children = StackBuf(WORDS_PER_BLOCK)
+ children[0] = node + m
+ children[1] = sibling[0] + m
+ node_tweak = StackBuf(WORDS_PER_BLOCK)
+ node_tweak[0] = SP_TW_FTS_NODE + kappa * SP_LAY_MUL + SP_P_LEVEL[level + 1] + idx_tau + sp_bit_field(bits, leaf_off + level + 1, SP_A - level - 1, SP_J_POS)
+ node_tweak[1] = pp
+ parent = StackBuf(WORDS_PER_BLOCK)
+ blake2s(node_tweak, children, parent)
+ node = parent[0]
+ roots[kappa] = node
+ roots_tweak = StackBuf(WORDS_PER_BLOCK)
+ roots_tweak[0] = SP_TW_FTS_ROOTS + idx_tau
+ roots_tweak[1] = pp
+ fts_key = StackBuf(WORDS_PER_BLOCK)
+ blake2s(roots_tweak, roots[0:2], fts_key, counter=64, final=0)
+ for q in unroll(1, SP_ROOT_BLOCKS):
+ next_key = StackBuf(WORDS_PER_BLOCK)
+ blake2s(roots[4 * q - 2:4 * q], roots[4 * q:4 * q + 2], next_key, cv=fts_key, counter=64 * (q + 1), final=(q + 1) // SP_ROOT_BLOCKS)
+ fts_key = next_key
+ signed = fts_key[0]
+
+ # ---- the hypertree, bottom layer first ----
+ # Layer lay signs what the layer below produced: the few-time key at the
+ # bottom, that layer's root above it, and the public key's root at the top.
+ for step in unroll(0, SP_D):
+ lay = SP_D - 1 - step
+ leaf_index_off = SP_SUFFIX[lay + 1]
+ tau_field = sp_bit_field(bits, SP_SUFFIX[lay], SP_H - SP_SUFFIX[lay], SP_TAU_POS)
+ tw_pos = tau_field + sp_bit_field(bits, leaf_index_off, SP_HEIGHTS[lay], SP_J_POS) + lay * SP_LAY_MUL
+ node = sp_ots_leaf(tw_pos, pp, signed)
+ for level in unroll(0, SP_HEIGHTS[lay]):
+ bit = bits[GEN ** (leaf_index_off + level)]
+ sibling = StackBuf(1)
+ hint_witness(sibling, "sp_siblings")
+ diff = node + sibling[0]
+ m = bit * diff
+ children = StackBuf(WORDS_PER_BLOCK)
+ children[0] = node + m
+ children[1] = sibling[0] + m
+ node_tweak = StackBuf(WORDS_PER_BLOCK)
+ node_tweak[0] = SP_TW_NODE + lay * SP_LAY_MUL + SP_P_LEVEL[level + 1] + tau_field + sp_bit_field(bits, leaf_index_off + level + 1, SP_HEIGHTS[lay] - level - 1, SP_J_POS)
+ node_tweak[1] = pp
+ parent = StackBuf(WORDS_PER_BLOCK)
+ blake2s(node_tweak, children, parent)
+ node = parent[0]
+ signed = node
+ assert signed == signer[1]
+ return
+
+
+def statement_digest(seed_0, seed_1, n_xmss_g, n_sphincs_g, pk_hash, msg, epoch_digest, defer):
# A node's statement, hashed to the two words the VM publishes: the proving
- # environment, the signer count, the signer-set digest, the shared
+ # environment, the two signer counts, the signer-set digest, the shared
# (message, epoch), and the deferred claims. A parent rebuilds a child's with
# the very same call, which is what forces the child to be a proof of THIS
# bytecode against THIS message and epoch.
@@ -2372,7 +2678,7 @@ def statement_digest(seed_0, seed_1, n_keys_g, pk_hash, msg, epoch, defer):
cells = StackBuf(4 * STMT_BLOCKS)
cells[0] = STMT_TAG_0
cells[1] = STMT_TAG_1
- hdr = [seed_0, seed_1, n_keys_g, pk_hash[1], pk_hash[GEN], msg[1], msg[GEN], epoch[1], epoch[GEN]]
+ hdr = [seed_0, seed_1, n_xmss_g, n_sphincs_g, pk_hash[1], pk_hash[GEN], msg[1], msg[GEN], epoch_digest[1], epoch_digest[GEN]]
for i in unroll(0, STMT_HEADER):
cells[2 + i] = hdr[i]
dfr = StackBuf(DEFER_STMT_CELLS + STMT_ODD)
@@ -2401,27 +2707,164 @@ def statement_digest(seed_0, seed_1, n_keys_g, pk_hash, msg, epoch, defer):
return st[0], st[1]
+def hash_key_range(state_0, state_1, keys_ptr, half_g, odd_g):
+ # Absorb one declared key list from the coverage table into the signer-set
+ # digest, continuing the chain from (state_0, state_1). Two keys a frame: the
+ # chain is unchanged, one compression a key, and what halves is the number of
+ # loop frames, a frame costing far more memory cells than the body it holds.
+ # `half` and `odd` are pinned by the caller to n//2 and n%2.
+ chain = HeapBuf(half_g ** 4 * GEN ** WORDS_PER_BLOCK)
+ chain[1] = state_0
+ chain[GEN] = state_1
+ for xp in mul_range(1, half_g):
+ pair = xp ** 4
+ keys = keys_ptr * pair
+ hint_witness(keys[0:4], "pubkeys")
+ state = chain * pair
+ blake2s(state[0:2], keys[0:2], state[2:4])
+ blake2s(state[2:4], keys[2:4], state[4:6])
+ # The odd key out, absorbed the same way. Only one branch runs, so both write
+ # the digest cells and the join reads them.
+ paired_end = chain * (half_g ** 4)
+ out = StackBuf(WORDS_PER_BLOCK)
+ if odd_g == 1:
+ out[0] = paired_end[1]
+ out[1] = paired_end[GEN]
+ else:
+ last = keys_ptr * (half_g ** 4)
+ hint_witness(last[0:2], "pubkeys")
+ blake2s(paired_end[0:2], last[0:2], out)
+ return out[0], out[1]
+
+
+def hash_sphincs_range(state_0, state_1, entries_ptr, n_g):
+ # Absorb the declared SPHINCS claims into the signer-set digest: two
+ # compressions an entry, its key then the message that key signed, so no
+ # pairing of the two can be swapped without changing the digest. One entry a
+ # frame where the XMSS list takes two keys, an entry being twice as wide and a
+ # SPHINCS leaf holding far fewer signers: there is no parity case to carry.
+ chain = HeapBuf(n_g ** 4 * GEN ** WORDS_PER_BLOCK)
+ chain[1] = state_0
+ chain[GEN] = state_1
+ for xe in mul_range(1, n_g):
+ quad = xe ** 4
+ entry = entries_ptr * quad
+ hint_witness(entry[0:4], "sphincs_signers")
+ state = chain * quad
+ blake2s(state[0:2], entry[0:2], state[2:4])
+ blake2s(state[2:4], entry[2:4], state[4:6])
+ end = chain * (n_g ** 4)
+ return end[1], end[GEN]
+
+
+def hash_child_sphincs(state_0, state_1, entries_ptr, cover, base, origin_g, limit_g, n_g):
+ # A child's SPHINCS claims, rebuilt from indices into THIS node's table, as
+ # hash_child_keys does for its XMSS keys. The index is an offset into the
+ # SPHINCS region and bounded by that region's size, so a child's SPHINCS claim
+ # can only ever land on a SPHINCS slot.
+ chain = HeapBuf(n_g ** 4 * GEN ** WORDS_PER_BLOCK)
+ chain[1] = state_0
+ chain[GEN] = state_1
+ for xe in mul_range(1, n_g):
+ off_hint = StackBuf(1)
+ hint_witness(off_hint, "child_sphincs_index")
+ assert log(off_hint[0]) < log(limit_g) # precondition as in the raw loops
+ cover[origin_g * off_hint[0]] = base * xe
+ entry = entries_ptr * (off_hint[0] ** 4)
+ quad = xe ** 4
+ state = chain * quad
+ blake2s(state[0:2], entry[0:2], state[2:4])
+ blake2s(state[2:4], entry[2:4], state[4:6])
+ end = chain * (n_g ** 4)
+ return end[1], end[GEN]
+
+
+def hash_child_keys(state_0, state_1, keys_ptr, cover, base, limit_g, half_g, odd_g):
+ # A child's XMSS keys, rebuilt from indices into THIS node's coverage table:
+ # each key is absorbed exactly as the child absorbed it, and the index is
+ # what ties the child's set into this node's coverage. The index is bounded
+ # by the XMSS region's size, so a child's XMSS key can only ever land on an
+ # XMSS slot. The XMSS region starts at slot 0, so one index serves both the
+ # coverage table and the key table; hash_child_sphincs, whose region starts
+ # past it, has to keep the two apart.
+ chain = HeapBuf(half_g ** 4 * GEN ** WORDS_PER_BLOCK)
+ chain[1] = state_0
+ chain[GEN] = state_1
+ for xp in mul_range(1, half_g):
+ two = StackBuf(2)
+ hint_witness(two, "child_index")
+ assert log(two[0]) < log(limit_g) # precondition as in the raw loops
+ assert log(two[1]) < log(limit_g)
+ first = two[0]
+ second = two[1]
+ even = xp * xp
+ cover[first] = base * even
+ cover[second] = base * even * GEN
+ state = chain * (even * even)
+ key_a = keys_ptr * (first * first)
+ key_b = keys_ptr * (second * second)
+ blake2s(state[0:2], key_a[0:2], state[2:4])
+ blake2s(state[2:4], key_b[0:2], state[4:6])
+ paired_end = chain * (half_g ** 4)
+ out = StackBuf(WORDS_PER_BLOCK)
+ if odd_g == 1:
+ out[0] = paired_end[1]
+ out[1] = paired_end[GEN]
+ else:
+ tail_hint = StackBuf(1)
+ hint_witness(tail_hint, "child_index")
+ assert log(tail_hint[0]) < log(limit_g)
+ tail_idx = tail_hint[0]
+ cover[tail_idx] = base * (half_g * half_g)
+ key_last = keys_ptr * (tail_idx * tail_idx)
+ blake2s(paired_end[0:2], key_last[0:2], out)
+ return out[0], out[1]
+
+
def main():
- # One node of an aggregation tree: n_raw XMSS signatures and n_children
- # sub-proofs OF THIS SAME BYTECODE, all against one (message, epoch).
+ # One node of an aggregation tree: n_raw_xmss XMSS signatures, n_raw_sphincs
+ # SPHINCS signatures and n_children sub-proofs OF THIS SAME BYTECODE. The
+ # XMSS half shares one message and one epoch; each SPHINCS signature is
+ # against the message in its own coverage slot.
#
- # meta = [n_keys, n_dup, n_raw, n_children], every count in the exponent.
- # n_keys is the declared signer set; the duplicate slots absorb keys a child
- # covers that the set already holds. Their sum bounds the coverage indices,
- # so it is what has to sit below the minimum memory size.
- meta = StackBuf(4)
+ # meta = [n_xmss, n_xmss_dup, n_sphincs, n_sphincs_dup, n_raw_xmss,
+ # n_raw_sphincs, n_children], every count in the exponent. The two declared
+ # lists are the signer set; the duplicate slots absorb keys a child covers
+ # that the set already holds. The coverage table is one region per scheme,
+ # each holding its declared keys then its duplicates:
+ #
+ # [0, n_xmss) [n_xmss, X) [X, X + n_sphincs) [X + n_sphincs, n_total)
+ # declared dup declared dup
+ # \------- XMSS, X slots -----/\------- SPHINCS ---------------------/
+ #
+ # so one range check per write keeps an XMSS signature off a declared SPHINCS
+ # claim and the other way round: that is what makes the split in the statement
+ # mean which scheme verified which key. An XMSS slot is two cells, a SPHINCS
+ # slot four: a key and the message that key signed.
+ meta = StackBuf(7)
hint_witness(meta, "meta")
- n_keys_g = meta[0]
- n_dup_g = meta[1]
- n_raw_g = meta[2]
- n_children_g = meta[3]
+ n_xmss_g = meta[0]
+ n_xdup_g = meta[1]
+ n_sphincs_g = meta[2]
+ n_sdup_g = meta[3]
+ n_raw_x_g = meta[4]
+ n_raw_s_g = meta[5]
+ n_children_g = meta[6]
+ assert log(n_xmss_g) < MAX_KEYS
+ assert log(n_xdup_g) < MAX_KEYS
+ assert log(n_sphincs_g) < MAX_KEYS
+ assert log(n_sdup_g) < MAX_KEYS
+ assert log(n_raw_x_g) < MAX_KEYS
+ assert log(n_raw_s_g) < MAX_KEYS
+ assert log(n_children_g) < MAX_CHILDREN + 1
+ n_keys_g = n_xmss_g * n_sphincs_g
assert n_keys_g != 1 # a signer set is never empty
- assert log(n_keys_g) < MAX_KEYS
- assert log(n_dup_g) < MAX_KEYS
- n_total_g = n_keys_g * n_dup_g
+ xmss_slots_g = n_xmss_g * n_xdup_g
+ sphincs_slots_g = n_sphincs_g * n_sdup_g
+ # The sum of every region bounds the coverage indices, so it is what has to
+ # sit below the minimum memory size.
+ n_total_g = xmss_slots_g * sphincs_slots_g
assert log(n_total_g) < MAX_KEYS
- assert log(n_raw_g) < MAX_KEYS
- assert log(n_children_g) < MAX_CHILDREN + 1
# The proving environment (flock's R1CS and this bytecode) as one digest. It
# rides the statement rather than the bytecode, so nothing here has to know
@@ -2432,16 +2875,20 @@ def main():
seed_0 = fs_seed[0]
seed_1 = fs_seed[1]
- message = HeapBuf(WORDS_PER_BLOCK)
+ # The one message every XMSS signer signed. A SPHINCS signer's is not this:
+ # it rides that signer's own slot in the coverage table.
+ xmss_msg = HeapBuf(WORDS_PER_BLOCK)
msg_hint = StackBuf(WORDS_PER_BLOCK)
hint_witness(msg_hint, "message")
- message[1] = msg_hint[0]
- message[GEN] = msg_hint[1]
+ xmss_msg[1] = msg_hint[0]
+ xmss_msg[GEN] = msg_hint[1]
# ---- the epoch, as the tweak table and the Merkle direction bits ----
# Both are hinted and bound by one digest in the statement; the outer
# verifier rebuilds them from the epoch and rehashes. Nothing derives a
- # tweak in-circuit.
+ # tweak in-circuit. They serve the XMSS signatures only: SPHINCS derives
+ # every tweak of its own from the index its digest picks, which is not
+ # public and differs per signer.
# A plain BLAKE2s, four cells a block, where a re-injected state left room
# for two. Each block is hashed out of the frame it was hinted into: a
# blake2s operand is addressed off `fp`, so a heap one would cost a DEREF
@@ -2471,70 +2918,59 @@ def main():
next_state = StackBuf(WORDS_PER_BLOCK)
blake2s(blk[0:2], blk[2:4], next_state, cv=epoch_state, counter=64 * (N_TWEAK_BLOCKS + u + 2), final=(u + 1) // MERKLE_BIT_BLOCKS)
epoch_state = next_state
- epoch = HeapBuf(WORDS_PER_BLOCK)
- epoch[1] = epoch_state[0]
- epoch[GEN] = epoch_state[1]
+ xmss_epoch = HeapBuf(WORDS_PER_BLOCK)
+ xmss_epoch[1] = epoch_state[0]
+ xmss_epoch[GEN] = epoch_state[1]
# ---- the signer set ----
- # all_pubkeys is the declared set (n_keys, strictly sorted: checked by the
- # outer verifier, which holds the list) followed by n_dup duplicate slots.
- # Signer i occupies cells g^{2i}..g^{2i+1}.
- n_total_2 = n_total_g * n_total_g
- all_pubkeys = HeapBuf(n_total_2)
- n_keys_2 = n_keys_g * n_keys_g
- # The count leads the chain, which makes the encoding prefix-free: a longer
- # key list starts from a different block 0, so no digest extends another and
- # the digest binds its own length rather than leaning on the statement's.
+ # One table per scheme, each the declared list (strictly sorted, checked by
+ # the outer verifier, which holds it) followed by its duplicate slots. An
+ # XMSS slot is a key's two cells; a SPHINCS slot is four, its key and the
+ # message that key signed. The coverage indices below still run over one
+ # space: the XMSS region, then the SPHINCS one.
+ xmss_table = HeapBuf(xmss_slots_g * xmss_slots_g)
+ sphincs_table = HeapBuf(sphincs_slots_g ** 4)
+ # Both counts lead the chain, which makes the encoding prefix-free: a longer
+ # key list, or the same keys split differently between the schemes, starts
+ # from a different block 0, so no digest extends another and the digest binds
+ # its own lengths rather than leaning on the statement's.
pk_seed = StackBuf(4)
pk_seed[0] = PK_IV_0
pk_seed[1] = PK_IV_1
- pk_seed[2] = n_keys_g
- pk_seed[3] = 0
- pk_chain = HeapBuf(n_keys_2 * GEN ** WORDS_PER_BLOCK)
- blake2s(pk_seed[0:2], pk_seed[2:4], pk_chain[0:2])
- # Two keys per iteration. The chain is unchanged, one compression per key;
- # what halves is the number of loop frames, and a frame costs far more memory
- # cells than the body it holds. `half` and `odd` are hinted and pinned by
- # half*half*odd == n_keys with odd in {0, 1}, which leaves half = n_keys // 2
- # and odd = n_keys % 2 as the only solution.
+ pk_seed[2] = n_xmss_g
+ pk_seed[3] = n_sphincs_g
+ pk_iv = StackBuf(WORDS_PER_BLOCK)
+ blake2s(pk_seed[0:2], pk_seed[2:4], pk_iv)
+ # `half` and `odd` are hinted and pinned by half*half*odd == n with odd in
+ # {0, 1}, which leaves half = n // 2 and odd = n % 2 as the only solution.
halves = StackBuf(2)
hint_witness(halves, "pk_halves")
- half_g = halves[0]
- odd_g = halves[1]
- assert log(odd_g) < 2
- assert log(half_g) < MAX_KEYS
- assert half_g * half_g * odd_g == n_keys_g
- for xp in mul_range(1, half_g):
- pair = xp ** 4
- keys = all_pubkeys * pair
- hint_witness(keys[0:4], "pubkeys")
- state = pk_chain * pair
- blake2s(state[0:2], keys[0:2], state[2:4])
- blake2s(state[2:4], keys[2:4], state[4:6])
- # The odd key out, absorbed the same way. Only one branch runs, so both write
- # the digest cells and the join reads them.
+ x_half_g = halves[0]
+ x_odd_g = halves[1]
+ assert log(x_odd_g) < 2
+ assert log(x_half_g) < MAX_KEYS
+ assert x_half_g * x_half_g * x_odd_g == n_xmss_g
+ mid_0, mid_1 = hash_key_range(pk_iv[0], pk_iv[1], xmss_table, x_half_g, x_odd_g)
+ hash_0, hash_1 = hash_sphincs_range(mid_0, mid_1, sphincs_table, n_sphincs_g)
pk_hash = HeapBuf(WORDS_PER_BLOCK)
- paired_end = pk_chain * (half_g ** 4)
- if odd_g == 1:
- pk_hash[1] = paired_end[1]
- pk_hash[GEN] = paired_end[GEN]
- else:
- last = all_pubkeys * (half_g ** 4)
- hint_witness(last[0:2], "pubkeys")
- blake2s(paired_end[0:2], last[0:2], pk_hash[0:2])
- # The duplicate slots ride the same table but outside the hashed prefix.
- for xd in mul_range(1, n_dup_g):
- dup = all_pubkeys * (n_keys_2 * xd * xd)
+ pk_hash[1] = hash_0
+ pk_hash[GEN] = hash_1
+ # The duplicate slots ride the same table but outside the hashed prefixes.
+ for xd in mul_range(1, n_xdup_g):
+ dup = xmss_table * (n_xmss_g * n_xmss_g * xd * xd)
hint_witness(dup[0:2], "dup_pubkeys")
+ for xd in mul_range(1, n_sdup_g):
+ dup = sphincs_table * ((n_sphincs_g * xd) ** 4)
+ hint_witness(dup[0:4], "dup_sphincs")
# ---- coverage ----
# Every one of the n_total slots is written exactly once: write-once memory
# rejects a second write (the value written is the running count, so two
# writes to one slot disagree), and the count below rejects a missed one. So
- # every declared signer is covered by a raw signature or by a verified
- # child, which is the whole security claim of the aggregate.
+ # every declared signer is covered by a signature of ITS OWN scheme or by a
+ # verified child, which is the whole security claim of the aggregate.
cover = HeapBuf(n_total_g)
- for xi in mul_range(1, n_raw_g):
+ for xi in mul_range(1, n_raw_x_g):
idx_hint = StackBuf(1)
hint_witness(idx_hint, "raw_index")
idx = idx_hint[0]
@@ -2542,10 +2978,16 @@ def main():
# discharged by the compile-time `assert log(n_total_g) < MAX_KEYS`
# above. Without it this degenerates to what DEREF alone gives and an
# index could reach past `cover`, which is the whole bijection.
- assert log(idx) < log(n_total_g)
+ assert log(idx) < log(xmss_slots_g)
cover[idx] = xi
- signer = all_pubkeys * (idx * idx)
- verify_sig(message, tweak_table, merkle_bits, signer)
+ signer = xmss_table * (idx * idx)
+ verify_sig(xmss_msg, tweak_table, merkle_bits, signer)
+ for xj in mul_range(1, n_raw_s_g):
+ off_hint = StackBuf(1)
+ hint_witness(off_hint, "sp_raw_index")
+ assert log(off_hint[0]) < log(sphincs_slots_g)
+ cover[xmss_slots_g * off_hint[0]] = n_raw_x_g * xj
+ verify_sig_sphincs(sphincs_table * (off_hint[0] ** 4))
# ---- children ----
g_logs_pow2, g_squares = exponent_tables()
@@ -2554,64 +2996,42 @@ def main():
child_carried = HeapBuf(n_children_g ** DEFER_STMT_CELLS)
# Loop-carried write count, one entry per child (the guest's chain idiom).
written = HeapBuf(n_children_g * GEN)
- written[GEN ** 0] = n_raw_g
+ written[GEN ** 0] = n_raw_x_g * n_raw_s_g
for xc in mul_range(1, n_children_g):
base = written[xc]
- nsub_hint = StackBuf(1)
+ nsub_hint = StackBuf(2)
hint_witness(nsub_hint, "child_n_keys")
- nsub_g = nsub_hint[0]
+ nsub_x_g = nsub_hint[0]
+ nsub_s_g = nsub_hint[1]
+ nsub_g = nsub_x_g * nsub_s_g
assert nsub_g != 1
- assert log(nsub_g) < MAX_KEYS
+ assert log(nsub_x_g) < MAX_KEYS
+ assert log(nsub_s_g) < MAX_KEYS
# Rebuild the child's signer-set digest from indices into the shared
- # table, absorbing each key exactly as the child did. The indices are
- # what tie the child's set into this node's coverage.
- # Two keys per iteration, as for this node's own set above: same chain,
- # half the loop frames.
+ # table, absorbing each key exactly as the child did, one list per
+ # scheme. Two keys per iteration, as for this node's own set above.
sub_halves = StackBuf(2)
hint_witness(sub_halves, "child_halves")
- sub_half_g = sub_halves[0]
- sub_odd_g = sub_halves[1]
- assert log(sub_odd_g) < 2
- assert log(sub_half_g) < MAX_KEYS
- assert sub_half_g * sub_half_g * sub_odd_g == nsub_g
+ sub_x_half_g = sub_halves[0]
+ sub_x_odd_g = sub_halves[1]
+ assert log(sub_x_odd_g) < 2
+ assert log(sub_x_half_g) < MAX_KEYS
+ assert sub_x_half_g * sub_x_half_g * sub_x_odd_g == nsub_x_g
sub_seed = StackBuf(4)
sub_seed[0] = PK_IV_0
sub_seed[1] = PK_IV_1
- sub_seed[2] = nsub_g
- sub_seed[3] = 0
- sub_chain = HeapBuf(nsub_g * nsub_g * GEN ** WORDS_PER_BLOCK)
- blake2s(sub_seed[0:2], sub_seed[2:4], sub_chain[0:2])
- for xp in mul_range(1, sub_half_g):
- two = StackBuf(2)
- hint_witness(two, "child_index")
- first = two[0]
- second = two[1]
- assert log(first) < log(n_total_g) # precondition as in the raw loop above
- assert log(second) < log(n_total_g)
- even = xp * xp
- cover[first] = base * even
- cover[second] = base * even * GEN
- state = sub_chain * (even * even)
- key_a = all_pubkeys * (first * first)
- key_b = all_pubkeys * (second * second)
- blake2s(state[0:2], key_a[0:2], state[2:4])
- blake2s(state[2:4], key_b[0:2], state[4:6])
- paired_end = sub_chain * (sub_half_g ** 4)
+ sub_seed[2] = nsub_x_g
+ sub_seed[3] = nsub_s_g
+ sub_iv = StackBuf(WORDS_PER_BLOCK)
+ blake2s(sub_seed[0:2], sub_seed[2:4], sub_iv)
+ sub_mid_0, sub_mid_1 = hash_child_keys(sub_iv[0], sub_iv[1], xmss_table, cover, base, xmss_slots_g, sub_x_half_g, sub_x_odd_g)
+ sub_hash_0, sub_hash_1 = hash_child_sphincs(sub_mid_0, sub_mid_1, sphincs_table, cover, base * nsub_x_g, xmss_slots_g, sphincs_slots_g, nsub_s_g)
sub_hash = HeapBuf(WORDS_PER_BLOCK)
- if sub_odd_g == 1:
- sub_hash[1] = paired_end[1]
- sub_hash[GEN] = paired_end[GEN]
- else:
- tail_hint = StackBuf(1)
- hint_witness(tail_hint, "child_index")
- tail_idx = tail_hint[0]
- assert log(tail_idx) < log(n_total_g)
- cover[tail_idx] = base * (sub_half_g * sub_half_g)
- key_last = all_pubkeys * (tail_idx * tail_idx)
- blake2s(paired_end[0:2], key_last[0:2], sub_hash[0:2])
+ sub_hash[1] = sub_hash_0
+ sub_hash[GEN] = sub_hash_1
xd = xc ** DEFER_STMT_CELLS
hint_witness(child_carried[xd:xd + DEFER_STMT_CELLS], "child_defer")
- pi_0, pi_1 = statement_digest(seed_0, seed_1, nsub_g, sub_hash, message, epoch, child_carried * xd)
+ pi_0, pi_1 = statement_digest(seed_0, seed_1, nsub_x_g, nsub_s_g, sub_hash, xmss_msg, xmss_epoch, child_carried * xd)
x2 = xc * xc
child_pi[x2] = pi_0
child_pi[x2 * GEN] = pi_1
@@ -2638,7 +3058,7 @@ def main():
else:
aggregate_claims(n_children_g, child_pi, child_fresh, child_carried, defer_stmt)
- own_0, own_1 = statement_digest(seed_0, seed_1, n_keys_g, pk_hash, message, epoch, defer_stmt)
+ own_0, own_1 = statement_digest(seed_0, seed_1, n_xmss_g, n_sphincs_g, pk_hash, xmss_msg, xmss_epoch, defer_stmt)
pub_ptr = GEN ** 0
own_pi_0 = pub_ptr[1]
own_pi_1 = pub_ptr[GEN]
diff --git a/crates/rec_aggregation/src/aggregation.rs b/crates/rec_aggregation/src/aggregation.rs
index a7d755b1f..807dbc6bc 100644
--- a/crates/rec_aggregation/src/aggregation.rs
+++ b/crates/rec_aggregation/src/aggregation.rs
@@ -1,12 +1,23 @@
-//! Recursive XMSS aggregation: one bytecode (`guests/aggregate.py`) for every
-//! node of an aggregation tree.
+//! Recursive aggregation of XMSS and SPHINCS signatures: one bytecode
+//! (`guests/aggregate.py`) for every node of an aggregation tree.
//!
-//! A node verifies `n_raw` XMSS signatures and `n_children` sub-proofs **of this
-//! same bytecode**, all against one shared `(message, epoch)`, and publishes the
-//! sorted deduplicated union of their signer sets. Coverage is what carries the
-//! security claim: a write-once slot per declared signer, written once by each
-//! raw signature and each child key, plus a final count, so every declared
-//! signer is backed by a real signature or a verified child.
+//! A node verifies `n_raw_xmss` XMSS signatures, `n_raw_sphincs` SPHINCS
+//! signatures and `n_children` sub-proofs **of this same bytecode**, and
+//! publishes the sorted deduplicated union of their signer sets as one list per
+//! scheme. The XMSS signers share one message and one epoch; a SPHINCS signer
+//! carries its own message, so that half of the statement is a list of
+//! `(key, message)` pairs. Coverage is what carries the security claim: a write-once slot per
+//! declared signer, written once by each raw signature and each child key, plus
+//! a final count, so every declared signer is backed by a real signature or a
+//! verified child.
+//!
+//! Those slots are one contiguous region per scheme, so the one
+//! range check a write already needs also keeps a signature of one scheme off
+//! the other's declared keys: that is what makes the split between the two
+//! published lists mean which scheme verified which key, at every level of the
+//! tree, a child's own statement carrying the same split. An XMSS slot holds the
+//! key's two cells and a SPHINCS slot four, its key and its message, so the
+//! guest reads each SPHINCS signature's message out of the slot it verifies.
//!
//! The bytecode is compiled to a fixed point on its own size
//! ([`unified_guest`]): the recursion placeholders depend on the inner bytecode
@@ -35,6 +46,12 @@ use primitives::field::{F64, F192, G, g_pow};
use primitives::multilinear::mle_eval_par;
use xmss::{XmssPublicKey, XmssSignature};
+use sphincs::{PublicKey as SphincsPublicKey, Signature as SphincsSignature};
+
+/// A SPHINCS claim: a key and the message it signed. Each SPHINCS signer carries
+/// its own message, where the XMSS half shares one.
+pub type SphincsSigner = (SphincsPublicKey, sphincs::Message);
+
/// Why the guest reads every `q_flock` slot claim's instance point off `chi`: a
/// virtual value column is referenced only by its own table's bus blocks, which
/// the table sumcheck settles, so no framework block can raise one at `zeta`.
@@ -44,8 +61,9 @@ const RECURSION_STATEMENT_LABEL: &[u8] = b"leanvm-b/recursive-statement/v1";
const EPOCH_LABEL: &[u8] = b"leanvm-b/aggregation-epoch/v1";
const PUBKEYS_LABEL: &[u8] = b"leanvm-b/aggregation-pubkeys/v1";
-/// The recursion arity, and the cap on `n_keys + n_dup` (exclusive: the guest
-/// proves `log(n_total) < MAX_KEYS`).
+/// The recursion arity, and the cap on the coverage table's slots, declared and
+/// duplicate, of both schemes (exclusive: the guest proves
+/// `log(n_total) < MAX_KEYS`).
///
/// `MAX_KEYS` is what the coverage indices' runtime range check needs to stay
/// below `2^MIN_LOG_MEM`, so that the bound means the same thing at every
@@ -69,6 +87,22 @@ const _: () = assert!(xmss::LOG_LIFETIME.is_multiple_of(4));
// The guest's `WOTS_PK_BLOCKS = (2 + V) / 4` truncates, so a bad `V` would drop
// the last tips.
const _: () = assert!((2 + xmss::V).is_multiple_of(4));
+// The SPHINCS side of the same shape. `SP_LEAF_BLOCKS = (2 + V) / 4` and
+// `SP_ROOT_BLOCKS = (2 + NUM_FTS_TREES) / 4` truncate, and a truncated loop
+// would leave the last tips or roots out of the hash while the signature still
+// carries them: revealed values no longer bound by the leaf they belong to.
+const _: () = assert!((2 + sphincs::V).is_multiple_of(4));
+const _: () = assert!((2 + sphincs::NUM_FTS_TREES).is_multiple_of(4));
+// The guest reads the message digest's bits out of three 64-bit lanes, and a
+// dynamically sized `HeapBuf` gets no compile-time index check, so a wider
+// digest would read leaf indices from cells nothing writes.
+const _: () = assert!(sphincs::DIGEST_BITS <= 3 * 64);
+// Every tweak field the guest packs must stay inside the byte range the native
+// `enc` gives it: `tau` at bit 16 below `p` at 48, `p` below the 64-bit lane
+// boundary, and `j` inside its four bytes at bit 80.
+const _: () = assert!(sphincs::H <= 32);
+const _: () = assert!(sphincs::CHAIN_LEN * sphincs::V < 1 << 16);
+const _: () = assert!(sphincs::A <= 32 && sphincs::HEIGHTS[0] <= 32);
/// A count as the guest carries it: in the exponent, `g^n`.
fn count(n: usize) -> F192 {
@@ -127,12 +161,25 @@ fn pack_16_bytes(bytes: &[u8]) -> F192 {
F192::new(word_at(0), word_at(8), 0)
}
-/// A public key as the two cells the guest hashes and `verify_sig` reads:
-/// the Merkle root then the public parameter.
+/// A public key as the two cells the guest hashes and `verify_sig` reads: the
+/// root then the public parameter. Both schemes lay a key out the same way, and
+/// the statement keeps them in separate lists rather than telling them apart by
+/// their bytes.
fn key_cells(pk: &XmssPublicKey) -> [F192; 2] {
[pack_16_bytes(&pk.merkle_root), pack_16_bytes(&pk.public_param)]
}
+/// A SPHINCS signer as the four cells the guest hashes and `verify_sig_sphincs`
+/// reads: the key, then the message that key signed.
+fn sphincs_signer_cells((pk, message): &SphincsSigner) -> [F192; 4] {
+ [
+ pack_16_bytes(&pk.root),
+ pack_16_bytes(&pk.public_param),
+ pack_16_bytes(&message[..16]),
+ pack_16_bytes(&message[16..]),
+ ]
+}
+
/// The 328-entry tweak table at `epoch`, in the order `verify_sig` indexes it:
/// encoding, then `V` chains of `CHAIN_LENGTH - 1` steps, then the WOTS-PK
/// tweak, then one per Merkle level. The Merkle parent index is `epoch >>
@@ -173,14 +220,23 @@ fn epoch_hash(epoch: u32) -> [F192; 2] {
tagged_hash(EPOCH_LABEL, pad.chain(cells.iter().flat_map(|c| [c.c0, c.c1])))
}
-/// The signer-set digest: the count, then one compression per key, in list
-/// order. Leading with the count makes the encoding prefix-free, so no digest is
-/// an extension of another and this binds its own length.
-fn pubkeys_hash(keys: &[XmssPublicKey]) -> [F192; 2] {
- let mut state = compress2(chain_iv(PUBKEYS_LABEL), [count(keys.len()), F192::ZERO]);
- for pk in keys {
+/// The signer-set digest: both counts, then one compression per key, the XMSS
+/// list then the SPHINCS one. Leading with the counts makes the encoding
+/// prefix-free, so no digest is an extension of another and this binds both its
+/// lengths and the split between them.
+/// A SPHINCS signer takes two compressions, its key then its message, the
+/// running state occupying the other half of each block.
+fn pubkeys_hash(xmss_keys: &[XmssPublicKey], sphincs_signers: &[SphincsSigner]) -> [F192; 2] {
+ let counts = [count(xmss_keys.len()), count(sphincs_signers.len())];
+ let mut state = compress2(chain_iv(PUBKEYS_LABEL), counts);
+ for pk in xmss_keys {
state = compress2(state, key_cells(pk));
}
+ for signer in sphincs_signers {
+ let cells = sphincs_signer_cells(signer);
+ state = compress2(state, [cells[0], cells[1]]);
+ state = compress2(state, [cells[2], cells[3]]);
+ }
state
}
@@ -257,9 +313,10 @@ impl DeferredClaim {
}
}
-/// The statement's fixed header, ahead of the deferred cells. The guest's
-/// `STMT_HEADER` is the same count.
-const STATEMENT_HEADER: usize = 9;
+/// The statement's fixed header, ahead of the deferred cells. Fed to the guest
+/// as `STMT_HEADER`, so the two cannot drift: both sides derive every offset
+/// below it from this one constant.
+const STATEMENT_HEADER: usize = 10;
/// A 32-byte domain tag: the label, zero-padded. A plain BLAKE2s separates in
/// the message, not in a custom IV, so any BLAKE2s reproduces the digest.
@@ -287,9 +344,10 @@ fn tagged_hash(label: &[u8], lanes: impl Iterator- ) -> [F192; 2] {
/// canonical cells it already is (two lanes each, whence the assert, the guest
/// being unable to hash a third), then all three lanes of each deferred cell.
fn statement_digest(
- n_keys: usize,
+ n_xmss: usize,
+ n_sphincs: usize,
pubkeys_hash: [F192; 2],
- message: &xmss::Message,
+ xmss_message: &xmss::Message,
epoch_hash: [F192; 2],
defer: &DeferredClaim,
) -> [F192; 2] {
@@ -297,11 +355,12 @@ fn statement_digest(
let header: [F192; STATEMENT_HEADER] = [
seed[0],
seed[1],
- count(n_keys),
+ count(n_xmss),
+ count(n_sphincs),
pubkeys_hash[0],
pubkeys_hash[1],
- pack_16_bytes(&message[..16]),
- pack_16_bytes(&message[16..]),
+ pack_16_bytes(&xmss_message[..16]),
+ pack_16_bytes(&xmss_message[16..]),
epoch_hash[0],
epoch_hash[1],
];
@@ -335,18 +394,37 @@ struct DeferredSubproof {
matrix_claim: F192,
}
-/// An aggregate signature: a proof that every key in `public_keys` signed
-/// `message` at `epoch`.
+/// An aggregate signature: a proof that every key in `xmss_keys` signed
+/// `xmss_message` at `epoch` under XMSS, and that every `(key, message)` pair in
+/// `sphincs_signers` is a valid SPHINCS signature.
+///
+/// Each list is strictly sorted and deduplicated, and their union is everything
+/// the aggregate covers, whether by a raw signature or through a child
+/// aggregate. The two lists are separate because the statement says which scheme
+/// verified each key: the guest holds a raw XMSS signature and a child's XMSS
+/// keys to the XMSS half of its coverage table, and likewise for SPHINCS.
///
-/// The signer set is strictly sorted and deduplicated, and it is the union of
-/// everything the aggregate covers, whether by a raw signature or through a
-/// child aggregate. [`Self::verify`] is the only acceptance path.
+/// **`sphincs_signers.len()` is a count of claims, not of signers.** Its
+/// ordering is on the whole `(key, message)` pair, so one key may appear several
+/// times with different messages, and nothing here forces a reader to
+/// deduplicate: a committee threshold has to count distinct keys itself.
+/// `epoch` binds the XMSS half only, SPHINCS being stateless: with no XMSS
+/// signer anywhere under it, an aggregate carries an `epoch` that no signature
+/// constrains, so the same signers can be aggregated into one valid aggregate
+/// per epoch. Re-emitting one under another epoch still costs a fresh proof, but
+/// a caller reading the signer lists as attestation of a statement has to pin
+/// that statement with [`Self::verify_against`], as it does for the message.
+/// [`Self::verify`] is the only acceptance path.
#[derive(Clone, Debug)]
pub struct AggregateSignature {
- pub message: xmss::Message,
- pub epoch: u32,
- /// Strictly sorted, deduplicated, non-empty, and strictly shorter than [`MAX_KEYS`].
- pub public_keys: Vec,
+ /// What every XMSS signer signed. The SPHINCS signers each carry their own.
+ pub xmss_message: xmss::Message,
+ pub xmss_epoch: u32,
+ /// Strictly sorted and deduplicated. May be empty, but not together with
+ /// `sphincs_signers`; the two together are strictly shorter than [`MAX_KEYS`].
+ pub xmss_keys: Vec,
+ /// Strictly sorted and deduplicated on the whole `(key, message)` pair.
+ pub sphincs_signers: Vec,
/// What this aggregate defers to whoever discharges it: its parent, in
/// circuit, or [`Self::verify`], natively.
defer: DeferredClaim,
@@ -387,6 +465,9 @@ pub enum AggregateError {
/// Everything but the signer set, which a receiver may already hold.
type WireCore = (xmss::Message, u32, Vec, Vec, lean_vm::cpu::Proof);
+/// The signer set on the wire: the two lists, in statement order.
+type WireKeys = (Vec, Vec);
+
/// The wire encoding: bincode's fixed-width integers, as the free functions use,
/// but rejecting trailing bytes, which they do not. Without that an accepted
/// aggregate has unboundedly many encodings, so anything downstream that dedupes
@@ -396,11 +477,17 @@ fn wire() -> impl bincode::Options {
}
/// Reject a signer set that the coverage argument does not cover: strict sorting
-/// is what makes "every declared key signed" mean `public_keys.len()` distinct
-/// signers rather than one signer counted many times. [`MAX_KEYS`] is exclusive
-/// here, as in the guest.
-fn check_signer_set(keys: &[XmssPublicKey]) -> Result<(), VerifyError> {
- if keys.is_empty() || keys.len() >= MAX_KEYS || !keys.windows(2).all(|w| w[0] < w[1]) {
+/// within each list is what stops one signer being counted many times: the XMSS
+/// list's length is a count of distinct keys, the SPHINCS list's of distinct
+/// `(key, message)` claims. Either list may be empty; both may not. [`MAX_KEYS`]
+/// is exclusive here, as in the guest.
+fn check_signer_set(xmss_keys: &[XmssPublicKey], sphincs_signers: &[SphincsSigner]) -> Result<(), VerifyError> {
+ let total = xmss_keys.len() + sphincs_signers.len();
+ if total == 0
+ || total >= MAX_KEYS
+ || !xmss_keys.windows(2).all(|w| w[0] < w[1])
+ || !sphincs_signers.windows(2).all(|w| w[0] < w[1])
+ {
return Err(VerifyError::MalformedSignerSet);
}
Ok(())
@@ -410,26 +497,36 @@ impl AggregateSignature {
/// This aggregate's own public statement, as the VM publishes it.
fn public_input(&self) -> [F192; 2] {
statement_digest(
- self.public_keys.len(),
- pubkeys_hash(&self.public_keys),
- &self.message,
- epoch_hash(self.epoch),
+ self.xmss_keys.len(),
+ self.sphincs_signers.len(),
+ pubkeys_hash(&self.xmss_keys, &self.sphincs_signers),
+ &self.xmss_message,
+ epoch_hash(self.xmss_epoch),
&self.defer,
)
}
+ /// The declared claims, as many as the coverage table's declared slots.
+ ///
+ /// NOT a count of distinct signers: a SPHINCS key may hold several claims,
+ /// one per message it signed (see the note on `sphincs_signers`). A caller
+ /// that wants signers has to deduplicate `sphincs_signers` by key itself.
+ pub fn n_claims(&self) -> usize {
+ self.xmss_keys.len() + self.sphincs_signers.len()
+ }
+
/// The wire format: the shared statement, the signer set, the two deferred
/// points, and the VM proof. The claim *values* are not transmitted;
/// [`Self::from_bytes`] recomputes them, so there is nothing to lie about.
pub fn to_bytes(&self) -> Vec {
wire()
- .serialize(&(&self.public_keys, self.core()))
+ .serialize(&((&self.xmss_keys, &self.sphincs_signers), self.core()))
.expect("an aggregate serializes")
}
pub fn from_bytes(bytes: &[u8]) -> Option {
- let (public_keys, core): (Vec, WireCore) = wire().deserialize(bytes).ok()?;
- Self::from_parts(public_keys, core)
+ let (keys, core): (WireKeys, WireCore) = wire().deserialize(bytes).ok()?;
+ Self::from_parts(keys, core)
}
/// Without the signer set, for a receiver that already knows it. A set other
@@ -442,45 +539,56 @@ impl AggregateSignature {
&self.proof
}
- pub fn from_bytes_without_pubkeys(bytes: &[u8], public_keys: Vec) -> Option {
- Self::from_parts(public_keys, wire().deserialize(bytes).ok()?)
+ pub fn from_bytes_without_pubkeys(bytes: &[u8], keys: WireKeys) -> Option {
+ Self::from_parts(keys, wire().deserialize(bytes).ok()?)
}
fn core(&self) -> WireCore {
(
- self.message,
- self.epoch,
+ self.xmss_message,
+ self.xmss_epoch,
self.defer.bytecode_point.clone(),
self.defer.matrix_point.clone(),
self.proof.clone(),
)
}
- fn from_parts(public_keys: Vec, core: WireCore) -> Option {
- let (message, epoch, bytecode_point, matrix_point, proof) = core;
+ fn from_parts(keys: WireKeys, core: WireCore) -> Option {
+ let (xmss_keys, sphincs_signers) = keys;
+ let (xmss_message, xmss_epoch, bytecode_point, matrix_point, proof) = core;
// Cheap rejections first. `recompute` below is a pass over the whole stacked
// bytecode plus a walk of the BLAKE2s circuit, on points a peer chose, so
// anything decidable without it has to be decided before it.
- check_signer_set(&public_keys).ok()?;
+ check_signer_set(&xmss_keys, &sphincs_signers).ok()?;
Some(Self {
- message,
- epoch,
- public_keys,
+ xmss_message,
+ xmss_epoch,
+ xmss_keys,
+ sphincs_signers,
defer: DeferredClaim::recompute(bytecode_point, matrix_point).ok()?,
proof,
})
}
- /// Verify the aggregate against the message and epoch the caller expects.
+ /// Verify the aggregate, pinning the XMSS half's statement to what the
+ /// caller expects.
///
- /// Prefer this to [`Self::verify`]. The prover supplies `message` and
- /// `epoch` along with everything else, so a bare `verify` establishes only
- /// that these signers signed *this object's* statement: an aggregate over
- /// the same keys from a different epoch, or over a different message,
- /// verifies just as well. Anything that reads `public_keys` as attestation
- /// of a particular statement has to pin that statement here.
- pub fn verify_against(&self, message: &xmss::Message, epoch: u32) -> Result<(), VerifyError> {
- if &self.message != message || self.epoch != epoch {
+ /// Prefer this to [`Self::verify`]. The prover supplies `xmss_message` and
+ /// `xmss_epoch` along with everything else, so a bare `verify` establishes
+ /// only that these signers signed *this object's* statement: an aggregate
+ /// over the same keys from a different epoch, or over a different message,
+ /// verifies just as well.
+ ///
+ /// **This pins the XMSS half only.** Each SPHINCS claim carries its own
+ /// message, and no argument here constrains those: a caller reading
+ /// `sphincs_signers` as attestation of anything must compare each
+ /// `(key, message)` pair against what it expected, and must not read
+ /// [`Self::n_claims`] as a signer count. With no XMSS signer under it, an
+ /// aggregate's `xmss_message` and `xmss_epoch` are prover-chosen and
+ /// constrained by nothing, so pinning them says nothing about the SPHINCS
+ /// claims either.
+ pub fn verify_against(&self, xmss_message: &xmss::Message, xmss_epoch: u32) -> Result<(), VerifyError> {
+ if &self.xmss_message != xmss_message || self.xmss_epoch != xmss_epoch {
return Err(VerifyError::UnexpectedStatement);
}
self.verify()
@@ -491,12 +599,13 @@ impl AggregateSignature {
/// transmitted points, and the VM proof satisfies the statement built from
/// all of it.
///
- /// This says "every key in `public_keys` signed `self.message` at
- /// `self.epoch`", with `self.message` and `self.epoch` chosen by whoever
- /// produced the aggregate. Use [`Self::verify_against`] unless the caller
- /// has already pinned those two some other way.
+ /// This says "every key in `xmss_keys` signed `self.xmss_message` at
+ /// `self.xmss_epoch`, and every `(key, message)` in `sphincs_signers` is a valid
+ /// SPHINCS signature", with `self.xmss_message` and `self.xmss_epoch` chosen by
+ /// whoever produced the aggregate. Use [`Self::verify_against`] unless the caller has already
+ /// pinned those two some other way.
pub fn verify(&self) -> Result<(), VerifyError> {
- check_signer_set(&self.public_keys)?;
+ check_signer_set(&self.xmss_keys, &self.sphincs_signers)?;
// Recomputing the values is what binds them: a claim carrying anything
// else yields a different statement, which the proof cannot satisfy.
let _s = tracing::info_span!("Recompute deferred claims").entered();
@@ -1458,27 +1567,54 @@ impl Hints {
}
}
-/// The signer index each write in the guest's coverage walk targets, in walk
-/// order: the raw signatures first, then each child's key list.
+/// The coverage slot each write in the guest's coverage walk targets, in walk
+/// order: the raw signatures first, then each child's key lists.
+///
+/// The table is four contiguous regions, `X = n_xmss + xmss_dups`:
+///
+/// | slots | holds |
+/// | --- | --- |
+/// | `[0, n_xmss)` | the declared XMSS keys |
+/// | `[n_xmss, X)` | XMSS duplicate slots |
+/// | `[X, X + n_sphincs)` | the declared SPHINCS keys |
+/// | `[X + n_sphincs, n_total)` | SPHINCS duplicate slots |
///
/// A key first seen takes its slot in the declared set; one seen again takes a
-/// fresh duplicate slot past it, so the walk hits every one of the
-/// `n_keys + n_dup` slots exactly once. That bijection, enforced in-circuit by
+/// fresh duplicate slot in its own scheme's region, so the walk hits every one
+/// of the `n_total` slots exactly once. That bijection, enforced in-circuit by
/// write-once memory plus the final count, is what makes every declared key
/// covered by a real signature or a verified child.
+///
+/// Keeping each scheme's slots contiguous is what binds the scheme: the guest
+/// bounds an XMSS writer by `X` and addresses a SPHINCS writer as an offset past
+/// it, one range check per write, so no XMSS signature can reach a declared
+/// SPHINCS key or the other way round.
struct Coverage {
- keys: Vec,
- duplicates: Vec,
- raw_indices: Vec,
- child_indices: Vec>,
+ xmss_keys: Vec,
+ xmss_dups: Vec,
+ sphincs_signers: Vec,
+ sphincs_dups: Vec,
+ /// Absolute slots in the XMSS region, all below `X`.
+ raw_xmss: Vec,
+ /// Offsets past `X`, in the SPHINCS region.
+ raw_sphincs: Vec,
+ child_xmss: Vec>,
+ child_sphincs: Vec>,
}
-fn take_slot(
- keys: &[XmssPublicKey],
- claimed: &mut [bool],
- duplicates: &mut Vec,
- pk: &XmssPublicKey,
-) -> usize {
+impl Coverage {
+ fn n_keys(&self) -> usize {
+ self.xmss_keys.len() + self.sphincs_signers.len()
+ }
+
+ fn n_total(&self) -> usize {
+ self.n_keys() + self.xmss_dups.len() + self.sphincs_dups.len()
+ }
+}
+
+/// The slot a key takes within its own scheme's region: its position in the
+/// declared list the first time, a fresh duplicate slot past that list after.
+fn take_slot(keys: &[K], claimed: &mut [bool], duplicates: &mut Vec, pk: &K) -> usize {
let pos = keys.binary_search(pk).expect("every covered key is in the union");
if claimed[pos] {
duplicates.push(pk.clone());
@@ -1489,39 +1625,69 @@ fn take_slot(
}
}
-fn plan_coverage(raw: &[XmssPublicKey], children: &[&[XmssPublicKey]]) -> Result {
- let mut keys: Vec = raw.to_vec();
- for c in children {
- keys.extend_from_slice(c);
- }
- keys.sort();
- keys.dedup();
- if keys.is_empty() {
+fn plan_coverage(
+ raw_xmss: &[XmssPublicKey],
+ raw_sphincs: &[SphincsSigner],
+ children: &[AggregateSignature],
+) -> Result {
+ let mut xmss_keys = raw_xmss.to_vec();
+ let mut sphincs_signers = raw_sphincs.to_vec();
+ for child in children {
+ xmss_keys.extend_from_slice(&child.xmss_keys);
+ sphincs_signers.extend_from_slice(&child.sphincs_signers);
+ }
+ xmss_keys.sort();
+ xmss_keys.dedup();
+ // On the whole pair, so one key signing two messages is two claims.
+ sphincs_signers.sort();
+ sphincs_signers.dedup();
+ if xmss_keys.is_empty() && sphincs_signers.is_empty() {
return Err(AggregateError::Empty);
}
- let mut claimed = vec![false; keys.len()];
- let mut duplicates = Vec::new();
- let raw_indices: Vec = raw
+ let mut xmss_claimed = vec![false; xmss_keys.len()];
+ let mut sphincs_claimed = vec![false; sphincs_signers.len()];
+ let mut xmss_dups = Vec::new();
+ let mut sphincs_dups = Vec::new();
+ let raw_xmss_slots: Vec = raw_xmss
.iter()
- .map(|pk| take_slot(&keys, &mut claimed, &mut duplicates, pk))
+ .map(|pk| take_slot(&xmss_keys, &mut xmss_claimed, &mut xmss_dups, pk))
.collect();
- let child_indices: Vec> = children
+ let raw_sphincs_slots: Vec = raw_sphincs
.iter()
- .map(|c| {
- c.iter()
- .map(|pk| take_slot(&keys, &mut claimed, &mut duplicates, pk))
- .collect()
- })
+ .map(|signer| take_slot(&sphincs_signers, &mut sphincs_claimed, &mut sphincs_dups, signer))
.collect();
- if keys.len() + duplicates.len() >= MAX_KEYS {
+ let mut child_xmss = Vec::with_capacity(children.len());
+ let mut child_sphincs = Vec::with_capacity(children.len());
+ for child in children {
+ child_xmss.push(
+ child
+ .xmss_keys
+ .iter()
+ .map(|pk| take_slot(&xmss_keys, &mut xmss_claimed, &mut xmss_dups, pk))
+ .collect(),
+ );
+ child_sphincs.push(
+ child
+ .sphincs_signers
+ .iter()
+ .map(|signer| take_slot(&sphincs_signers, &mut sphincs_claimed, &mut sphincs_dups, signer))
+ .collect(),
+ );
+ }
+ let cover = Coverage {
+ xmss_keys,
+ xmss_dups,
+ sphincs_signers,
+ sphincs_dups,
+ raw_xmss: raw_xmss_slots,
+ raw_sphincs: raw_sphincs_slots,
+ child_xmss,
+ child_sphincs,
+ };
+ if cover.n_total() >= MAX_KEYS {
return Err(AggregateError::TooLarge);
}
- Ok(Coverage {
- keys,
- duplicates,
- raw_indices,
- child_indices,
- })
+ Ok(cover)
}
/// One signature's witness: the WOTS randomness, the encoding digits (in the
@@ -1531,10 +1697,10 @@ fn push_signature_hints(
pk: &XmssPublicKey,
sig: &XmssSignature,
message: &xmss::Message,
- epoch: u32,
+ xmss_epoch: u32,
) -> Result<(), AggregateError> {
let wots = &sig.wots_signature;
- let encoding = xmss::wots_encode(message, epoch, &pk.public_param, &wots.randomness)
+ let encoding = xmss::wots_encode(message, xmss_epoch, &pk.public_param, &wots.randomness)
.ok_or(AggregateError::MalformedRawSignature)?;
let mut randomness = [0u8; xmss::STATE_LEN];
randomness[..xmss::RANDOMNESS_LEN].copy_from_slice(&wots.randomness);
@@ -1554,9 +1720,53 @@ fn push_signature_hints(
Ok(())
}
-/// Aggregate raw XMSS signatures and previously aggregated signatures into one
-/// proof, over the union of their signer sets, all against the same
-/// `(message, epoch)`.
+/// One SPHINCS signature's witness: the randomizer, the few-time opening, and
+/// per layer the encoding counter, the codeword digits (in the exponent), the
+/// chain values they start from, and the Merkle siblings.
+///
+/// The guest derives the index and the leaf indices from the digest itself, so
+/// nothing here carries them; what it does carry is the per-layer message, which
+/// this walk recomputes exactly as the guest will. The signer's own message is
+/// not hinted either: it rides its slot in the coverage table.
+fn push_sphincs_hints(
+ hints: &mut Hints,
+ (pk, message): &SphincsSigner,
+ sig: &SphincsSignature,
+) -> Result<(), AggregateError> {
+ let pp = &pk.public_param;
+ hints.push("sp_rand", vec![pack_16_bytes(&sig.randomizer)]);
+ let (idx, u) = sphincs::message_digest(pp, &pk.root, &sig.randomizer, message);
+ for kappa in 0..sphincs::NUM_FTS_TREES {
+ hints.push("sp_fts_secrets", vec![pack_16_bytes(&sig.fts.secrets[kappa])]);
+ for sibling in &sig.fts.paths[kappa] {
+ hints.push("sp_fts_paths", vec![pack_16_bytes(sibling)]);
+ }
+ }
+ let mut signed = sphincs::fts_recover(pp, idx, &u, &sig.fts);
+ for lay in (0..sphincs::D).rev() {
+ let pos = sphincs::Pos::new(lay, sphincs::tree_of(idx, lay), sphincs::leaf_of(idx, lay));
+ let counter = sig.counters[lay];
+ let codeword = sphincs::encode(pp, pos, &signed, counter).ok_or(AggregateError::MalformedRawSignature)?;
+ hints.push("sp_counter", vec![F192::new(u64::from(counter), 0, 0)]);
+ for (&digit, opened) in codeword.iter().zip(&sig.ots[lay]) {
+ hints.push("sp_digits", vec![count(digit as usize)]);
+ hints.push("sp_chain_starts", vec![pack_16_bytes(opened)]);
+ }
+ let path = &sig.paths[sphincs::path_range(lay)];
+ for sibling in path {
+ hints.push("sp_siblings", vec![pack_16_bytes(sibling)]);
+ }
+ let leaf =
+ sphincs::ots_leaf(pp, pos, &signed, counter, &sig.ots[lay]).ok_or(AggregateError::MalformedRawSignature)?;
+ signed = sphincs::tree_fold(pp, pos, leaf, path);
+ }
+ debug_assert_eq!(signed, pk.root, "the hinted walk reaches the public key");
+ Ok(())
+}
+
+/// Aggregate raw signatures of either scheme and previously aggregated
+/// signatures into one proof, over the union of their signer sets: the XMSS
+/// signers against `(message, epoch)`, the SPHINCS signers against `message`.
///
/// Children and raw signatures mix freely: no children is a leaf, no raw
/// signatures is a pure recursion step, and one child plus a few signatures
@@ -1565,23 +1775,33 @@ fn push_signature_hints(
/// per process.
pub fn aggregate(
children: &[AggregateSignature],
- raw: Vec<(XmssPublicKey, XmssSignature)>,
- message: xmss::Message,
- epoch: u32,
+ xmss_message: xmss::Message,
+ xmss_epoch: u32,
+ raw_xmss: Vec<(XmssPublicKey, XmssSignature)>,
+ raw_sphincs: Vec<(SphincsPublicKey, sphincs::Message, SphincsSignature)>,
log_inv_rate: usize,
) -> Result {
- aggregate_with_stats(children, raw, message, epoch, log_inv_rate).map(|(sig, _)| sig)
+ aggregate_with_stats(children, xmss_message, xmss_epoch, raw_xmss, raw_sphincs, log_inv_rate).map(|(sig, _)| sig)
}
/// [`aggregate`], keeping the prover statistics the benchmark reports.
pub(crate) fn aggregate_with_stats(
children: &[AggregateSignature],
- raw: Vec<(XmssPublicKey, XmssSignature)>,
- message: xmss::Message,
- epoch: u32,
+ xmss_message: xmss::Message,
+ xmss_epoch: u32,
+ raw_xmss: Vec<(XmssPublicKey, XmssSignature)>,
+ raw_sphincs: Vec<(SphincsPublicKey, sphincs::Message, SphincsSignature)>,
log_inv_rate: usize,
) -> Result<(AggregateSignature, lean_vm::cpu::Stats), AggregateError> {
- aggregate_tampered(children, raw, message, epoch, log_inv_rate, |_| {})
+ aggregate_tampered(
+ children,
+ xmss_message,
+ xmss_epoch,
+ raw_xmss,
+ raw_sphincs,
+ log_inv_rate,
+ |_| {},
+ )
}
/// [`aggregate`], with a hook to corrupt the witness before proving.
@@ -1592,22 +1812,30 @@ pub(crate) fn aggregate_with_stats(
/// (`aggregate_hints_bind`); with an empty hook this is the production path.
pub(crate) fn aggregate_tampered(
children: &[AggregateSignature],
- raw: Vec<(XmssPublicKey, XmssSignature)>,
- message: xmss::Message,
- epoch: u32,
+ xmss_message: xmss::Message,
+ xmss_epoch: u32,
+ raw_xmss: Vec<(XmssPublicKey, XmssSignature)>,
+ raw_sphincs: Vec<(SphincsPublicKey, sphincs::Message, SphincsSignature)>,
log_inv_rate: usize,
tamper: impl FnOnce(&mut Hints),
) -> Result<(AggregateSignature, lean_vm::cpu::Stats), AggregateError> {
if children.len() > MAX_CHILDREN {
return Err(AggregateError::TooLarge);
}
- if children.iter().any(|c| c.message != message || c.epoch != epoch) {
+ if children
+ .iter()
+ .any(|c| c.xmss_message != xmss_message || c.xmss_epoch != xmss_epoch)
+ {
return Err(AggregateError::InconsistentChildren);
}
let guest = unified_guest();
- let mut raw = raw;
- raw.sort_by(|(a, _), (b, _)| a.cmp(b));
- raw.dedup_by(|(a, _), (b, _)| a == b);
+ let mut raw_xmss = raw_xmss;
+ raw_xmss.sort_by(|(a, _), (b, _)| a.cmp(b));
+ raw_xmss.dedup_by(|(a, _), (b, _)| a == b);
+ // On the whole (key, message) pair, so a signer may appear once per message.
+ let mut raw_sphincs = raw_sphincs;
+ raw_sphincs.sort_by_key(|(pk, message, _)| (*pk, *message));
+ raw_sphincs.dedup_by(|(a, am, _), (b, bm, _)| (a, am) == (b, bm));
// Verifying a child here is not a courtesy: `gen_verify` derives the guest's
// whole witness for it from a real verification's summary. Its deferred
@@ -1618,7 +1846,7 @@ pub(crate) fn aggregate_tampered(
let mut verified = Vec::with_capacity(children.len());
let _span = tracing::info_span!("Verify children").entered();
for child in children {
- check_signer_set(&child.public_keys).map_err(AggregateError::InvalidChild)?;
+ check_signer_set(&child.xmss_keys, &child.sphincs_signers).map_err(AggregateError::InvalidChild)?;
let pi = child.public_input();
let summary =
verify(guest, &pi, &child.proof).map_err(|e| AggregateError::InvalidChild(VerifyError::Proof(e)))?;
@@ -1628,59 +1856,79 @@ pub(crate) fn aggregate_tampered(
drop(_span);
let _span = tracing::info_span!("Build witness").entered();
- let raw_keys: Vec = raw.iter().map(|(pk, _)| pk.clone()).collect();
- let child_keys: Vec<&[XmssPublicKey]> = children.iter().map(|c| c.public_keys.as_slice()).collect();
- let cover = plan_coverage(&raw_keys, &child_keys)?;
- let (n_keys, n_dup) = (cover.keys.len(), cover.duplicates.len());
+ let raw_xmss_keys: Vec = raw_xmss.iter().map(|(pk, _)| pk.clone()).collect();
+ let raw_sphincs_keys: Vec = raw_sphincs.iter().map(|(pk, message, _)| (*pk, *message)).collect();
+ let cover = plan_coverage(&raw_xmss_keys, &raw_sphincs_keys, children)?;
+ let (n_xmss, n_sphincs) = (cover.xmss_keys.len(), cover.sphincs_signers.len());
let mut hints = Hints::default();
hints.push(
"meta",
- vec![count(n_keys), count(n_dup), count(raw.len()), count(children.len())],
+ vec![
+ count(n_xmss),
+ count(cover.xmss_dups.len()),
+ count(n_sphincs),
+ count(cover.sphincs_dups.len()),
+ count(raw_xmss.len()),
+ count(raw_sphincs.len()),
+ count(children.len()),
+ ],
);
let fs_seed = lean_vm::cpu::fs_seed(guest);
hints.push("fs_seed", vec![fs_seed[0], fs_seed[1]]);
hints.push(
"message",
- vec![pack_16_bytes(&message[..16]), pack_16_bytes(&message[16..])],
+ vec![pack_16_bytes(&xmss_message[..16]), pack_16_bytes(&xmss_message[16..])],
);
// Four cells an entry: one hashed block of the epoch digest.
- for quad in tweak_table(epoch).as_chunks::<4>().0 {
+ for quad in tweak_table(xmss_epoch).as_chunks::<4>().0 {
hints.push("tweaks", quad.iter().map(|tweak| pack_16_bytes(tweak)).collect());
}
- for quad in merkle_bit_cells(epoch).as_chunks::<4>().0 {
+ for quad in merkle_bit_cells(xmss_epoch).as_chunks::<4>().0 {
hints.push("merkle_bits", quad.to_vec());
}
// Two keys per entry, so the guest can halve its loop frames; the odd key out
- // rides a final one-key entry. The digest itself is unchanged.
- hints.push(
- "pk_halves",
- vec![count(cover.keys.len() / 2), count(cover.keys.len() % 2)],
- );
- for pair in cover.keys.chunks(2) {
+ // of each list rides a final one-key entry. The digest itself is unchanged.
+ hints.push("pk_halves", vec![count(n_xmss / 2), count(n_xmss % 2)]);
+ for pair in cover.xmss_keys.chunks(2) {
let mut entry = key_cells(&pair[0]).to_vec();
if let Some(second) = pair.get(1) {
entry.extend_from_slice(&key_cells(second));
}
hints.push("pubkeys", entry);
}
- for pk in &cover.duplicates {
+ for signer in &cover.sphincs_signers {
+ hints.push("sphincs_signers", sphincs_signer_cells(signer).to_vec());
+ }
+ for pk in &cover.xmss_dups {
hints.push("dup_pubkeys", key_cells(pk).to_vec());
}
- for (&idx, (pk, sig)) in cover.raw_indices.iter().zip(&raw) {
+ for signer in &cover.sphincs_dups {
+ hints.push("dup_sphincs", sphincs_signer_cells(signer).to_vec());
+ }
+ for (&idx, (pk, sig)) in cover.raw_xmss.iter().zip(&raw_xmss) {
hints.push("raw_index", vec![count(idx)]);
- push_signature_hints(&mut hints, pk, sig, &message, epoch)?;
+ push_signature_hints(&mut hints, pk, sig, &xmss_message, xmss_epoch)?;
+ }
+ // A SPHINCS slot is hinted as an offset into the SPHINCS region, which is
+ // how one range check keeps the scheme's writers off the other's keys.
+ for (&offset, (pk, message, sig)) in cover.raw_sphincs.iter().zip(&raw_sphincs) {
+ hints.push("sp_raw_index", vec![count(offset)]);
+ push_sphincs_hints(&mut hints, &(*pk, *message), sig)?;
}
let mut subs = Vec::with_capacity(children.len());
let mut carried = Vec::with_capacity(children.len());
for (i, child) in children.iter().enumerate() {
- hints.push("child_n_keys", vec![count(child.public_keys.len())]);
- let n_sub = child.public_keys.len();
- hints.push("child_halves", vec![count(n_sub / 2), count(n_sub % 2)]);
- for pair in cover.child_indices[i].chunks(2) {
+ let (n_sub_xmss, n_sub_sphincs) = (child.xmss_keys.len(), child.sphincs_signers.len());
+ hints.push("child_n_keys", vec![count(n_sub_xmss), count(n_sub_sphincs)]);
+ hints.push("child_halves", vec![count(n_sub_xmss / 2), count(n_sub_xmss % 2)]);
+ for pair in cover.child_xmss[i].chunks(2) {
hints.push("child_index", pair.iter().map(|&idx| count(idx)).collect());
}
+ for &offset in &cover.child_sphincs[i] {
+ hints.push("child_sphincs_index", vec![count(offset)]);
+ }
hints.push("child_defer", child.defer.cells());
let (pi, summary) = &verified[i];
let (sub_hints, defer) = gen_verify(guest, *pi, summary)?;
@@ -1709,7 +1957,14 @@ pub(crate) fn aggregate_tampered(
reduced
};
- let public_input = statement_digest(n_keys, pubkeys_hash(&cover.keys), &message, epoch_hash(epoch), &defer);
+ let public_input = statement_digest(
+ n_xmss,
+ n_sphincs,
+ pubkeys_hash(&cover.xmss_keys, &cover.sphincs_signers),
+ &xmss_message,
+ epoch_hash(xmss_epoch),
+ &defer,
+ );
let mut program = guest.clone();
// Every aggregate is a potential child, and the guest has no opening arm below
// `2^MU_MIN`. A run smaller than that (a leaf of a few dozen signatures) grows
@@ -1720,9 +1975,10 @@ pub(crate) fn aggregate_tampered(
let (proof, stats) = prove(&program, public_input, log_inv_rate);
Ok((
AggregateSignature {
- message,
- epoch,
- public_keys: cover.keys,
+ xmss_message,
+ xmss_epoch,
+ xmss_keys: cover.xmss_keys,
+ sphincs_signers: cover.sphincs_signers,
defer,
proof,
},
@@ -2344,6 +2600,7 @@ fn placeholder_map(kbc: usize) -> BTreeMap {
ps("STMT_TAG_0", dsl_u128(pack_16_bytes(&tag[..16])).to_string());
ps("STMT_TAG_1", dsl_u128(pack_16_bytes(&tag[16..])).to_string());
let defer_cells = kbc + log2_bc_cols + 1 + 2 * flock::hash::K_LOG + 2;
+ ps("STMT_HEADER", STATEMENT_HEADER.to_string());
let (off, pairs) = (2 + STATEMENT_HEADER, defer_cells.div_ceil(2));
let blocks = (off + 3 * pairs).div_ceil(4);
ps("STMT_ODD", (defer_cells % 2).to_string());
@@ -2365,6 +2622,28 @@ fn placeholder_map(kbc: usize) -> BTreeMap {
ps("LOG_LIFETIME", xmss::LOG_LIFETIME.to_string());
ps("MAX_KEYS", MAX_KEYS.to_string());
ps("MAX_CHILDREN", MAX_CHILDREN.to_string());
+
+ // The SPHINCS instance. Its tweaks are derived in-circuit from the index the
+ // message digest picks, so unlike XMSS's epoch tables nothing about a
+ // SPHINCS position rides the statement, and the guest needs only the shape.
+ let dsl_list = |values: &[usize]| {
+ let inner: Vec = values.iter().map(usize::to_string).collect();
+ format!("[{}]", inner.join(", "))
+ };
+ ps("SP_V", sphincs::V.to_string());
+ ps("SP_W", sphincs::W.to_string());
+ ps("SP_TARGET_SUM", sphincs::TARGET_SUM.to_string());
+ ps("SP_D", sphincs::D.to_string());
+ ps("SP_A", sphincs::A.to_string());
+ ps("SP_K", sphincs::K.to_string());
+ ps("SP_H", sphincs::H.to_string());
+ ps("SP_HEIGHTS", dsl_list(&sphincs::HEIGHTS));
+ // One literal per Merkle level, since the guest cannot compute `level + 1`
+ // in a tweak: a value expression folds its constants in the field.
+ let deepest = sphincs::A.max(sphincs::HEIGHTS.iter().copied().max().expect("d >= 1"));
+ let p_levels: Vec = (0..=deepest).map(|level| level << 48).collect();
+ ps("SP_P_LEVEL", dsl_list(&p_levels));
+ ps("SP_SUFFIX", dsl_list(&sphincs::SUFFIX));
rep
}
@@ -2423,7 +2702,10 @@ fn compile_guest(kbc: usize) -> Program {
#[cfg(test)]
mod tests {
use super::*;
- use crate::signers_cache::{EPOCH, get_signers, message};
+ use rand::SeedableRng;
+ use rand::rngs::StdRng;
+
+ use crate::signers_cache::{XMSS_EPOCH, get_signers, get_sphincs_signers, message};
const SMALL_LEAF_SIZE: usize = 6;
const LOG_INV_RATE: usize = lean_vm::pcs::LOG_INV_RATE;
@@ -2439,18 +2721,47 @@ mod tests {
}
/// `MAX_KEYS` is exclusive at both host checks: one key short of it passes,
- /// the cap itself is the documented error. No proof involved.
+ /// the cap itself is the documented error. The cap counts both schemes, so
+ /// one XMSS key short of it plus one SPHINCS claim is already over. No proof
+ /// involved.
#[test]
fn max_keys_bound_is_exclusive() {
let full = signer_set(MAX_KEYS);
- check_signer_set(&full[..MAX_KEYS - 1]).expect("one short of the cap");
- assert_eq!(check_signer_set(&full), Err(VerifyError::MalformedSignerSet));
- plan_coverage(&full[..MAX_KEYS - 1], &[]).expect("one short of the cap");
- assert_eq!(plan_coverage(&full, &[]).err(), Some(AggregateError::TooLarge));
+ let claim = [(
+ SphincsPublicKey::from_bytes(&[0; sphincs::PUB_KEY_SIZE]),
+ [0; sphincs::MESSAGE_LEN],
+ )];
+ check_signer_set(&full[..MAX_KEYS - 1], &[]).expect("one short of the cap");
+ assert_eq!(check_signer_set(&full, &[]), Err(VerifyError::MalformedSignerSet));
+ assert_eq!(
+ check_signer_set(&full[..MAX_KEYS - 1], &claim),
+ Err(VerifyError::MalformedSignerSet)
+ );
+ plan_coverage(&full[..MAX_KEYS - 1], &[], &[]).expect("one short of the cap");
+ assert_eq!(plan_coverage(&full, &[], &[]).err(), Some(AggregateError::TooLarge));
+ assert_eq!(
+ plan_coverage(&full[..MAX_KEYS - 1], &claim, &[]).err(),
+ Some(AggregateError::TooLarge)
+ );
}
fn prove_leaf(signers: &[(XmssPublicKey, XmssSignature)]) -> AggregateSignature {
- aggregate(&[], signers.to_vec(), message(), EPOCH, LOG_INV_RATE).expect("leaf aggregates")
+ aggregate(&[], message(), XMSS_EPOCH, signers.to_vec(), vec![], LOG_INV_RATE).expect("leaf aggregates")
+ }
+
+ type RawSphincs = (SphincsPublicKey, sphincs::Message, SphincsSignature);
+
+ fn prove_sphincs_leaf(signers: &[RawSphincs]) -> AggregateSignature {
+ aggregate(&[], message(), XMSS_EPOCH, vec![], signers.to_vec(), LOG_INV_RATE).expect("leaf aggregates")
+ }
+
+ #[test]
+ fn aggregate_one_sphincs_signer() {
+ lean_vm::init_prover_pool();
+ let aggregate = prove_sphincs_leaf(&get_sphincs_signers(1));
+ aggregate.verify().expect("verifies");
+ assert!(aggregate.xmss_keys.is_empty());
+ assert_eq!(aggregate.sphincs_signers.len(), 1);
}
#[test]
@@ -2459,23 +2770,110 @@ mod tests {
let aggregate = prove_leaf(&get_signers(1));
aggregate.verify().expect("verifies");
aggregate
- .verify_against(&message(), EPOCH)
+ .verify_against(&message(), XMSS_EPOCH)
.expect("verifies against its statement");
assert_eq!(
- aggregate.verify_against(&message(), EPOCH + 1),
+ aggregate.verify_against(&message(), XMSS_EPOCH + 1),
Err(VerifyError::UnexpectedStatement)
);
}
+ /// An odd XMSS count, so its digest chain takes its odd-key-out branch and
+ /// the `pubkeys` stream ends in a short entry; the SPHINCS list has no parity
+ /// case, absorbing one entry a frame.
+ #[test]
+ fn aggregate_mixed_leaf() {
+ lean_vm::init_prover_pool();
+ let aggregate = aggregate(
+ &[],
+ message(),
+ XMSS_EPOCH,
+ get_signers(3),
+ get_sphincs_signers(3),
+ LOG_INV_RATE,
+ )
+ .expect("leaf aggregates");
+ aggregate.verify().expect("verifies");
+ assert_eq!((aggregate.xmss_keys.len(), aggregate.sphincs_signers.len()), (3, 3));
+ }
+
+ /// A node over children of both schemes, overlapping in one signer of each:
+ /// the coverage table then needs a duplicate slot in both regions, and each
+ /// child's two key lists have to land in their own.
+ #[test]
+ fn aggregate_mixed_two_to_one() {
+ lean_vm::init_prover_pool();
+ let xmss = get_signers(6);
+ let sphincs = get_sphincs_signers(4);
+ let leaf = |x: &[(XmssPublicKey, XmssSignature)], s: &[RawSphincs]| {
+ aggregate(&[], message(), XMSS_EPOCH, x.to_vec(), s.to_vec(), LOG_INV_RATE).expect("leaf aggregates")
+ };
+ let left = leaf(&xmss[..4], &sphincs[..3]);
+ let right = leaf(&xmss[3..], &sphincs[2..]);
+ let node =
+ aggregate(&[left, right], message(), XMSS_EPOCH, vec![], vec![], LOG_INV_RATE).expect("node aggregates");
+ node.verify().expect("node verifies");
+ assert_eq!((node.xmss_keys.len(), node.sphincs_signers.len()), (6, 4));
+ assert!(node.xmss_keys.windows(2).all(|w| w[0] < w[1]));
+ assert!(node.sphincs_signers.windows(2).all(|w| w[0] < w[1]));
+ }
+
+ /// A node whose children are each of one scheme only: every key list it
+ /// rebuilds is empty on one side, which is the only way the guest's
+ /// key-absorbing loops run over an empty range and its bound `log(x) <
+ /// log(g^0)` (unsatisfiable, so nothing may be written there) is reached.
+ #[test]
+ fn aggregate_one_scheme_per_child() {
+ lean_vm::init_prover_pool();
+ let xmss_child = prove_leaf(&get_signers(3));
+ let sphincs_child = prove_sphincs_leaf(&get_sphincs_signers(2));
+ let node = aggregate(
+ &[xmss_child, sphincs_child],
+ message(),
+ XMSS_EPOCH,
+ vec![],
+ vec![],
+ LOG_INV_RATE,
+ )
+ .expect("node aggregates");
+ node.verify().expect("node verifies");
+ assert_eq!((node.xmss_keys.len(), node.sphincs_signers.len()), (3, 2));
+ }
+
+ /// The repeat the statement allows: one key signing two messages is two
+ /// claims, ordered by the pair, each needing its own signature. Generated
+ /// here rather than cached, the cache holding one message per key.
+ #[test]
+ fn aggregate_one_key_two_messages() {
+ lean_vm::init_prover_pool();
+ let mut rng = StdRng::seed_from_u64(77);
+ let (secret_key, public_key) = sphincs::key_gen(&mut rng);
+ let raw: Vec = [3u8, 9]
+ .into_iter()
+ .map(|tag| {
+ let signed: sphincs::Message = std::array::from_fn(|i| tag.wrapping_mul(i as u8 + 1));
+ let signature = sphincs::sign(&mut rng, &secret_key, &signed).expect("signs");
+ (public_key, signed, signature)
+ })
+ .collect();
+ let aggregate = prove_sphincs_leaf(&raw);
+ aggregate.verify().expect("verifies");
+ assert_eq!(aggregate.sphincs_signers.len(), 2);
+ let (first, second) = (aggregate.sphincs_signers[0], aggregate.sphincs_signers[1]);
+ assert_eq!(first.0, second.0, "the same key, twice");
+ assert!(first.1 < second.1, "ordered by the message");
+ }
+
#[test]
fn aggregate_two_to_one() {
lean_vm::init_prover_pool();
let signers = get_signers(SMALL_LEAF_SIZE + 60);
let left = prove_leaf(&signers[..SMALL_LEAF_SIZE]);
let right = prove_leaf(&signers[SMALL_LEAF_SIZE..]);
- let node = aggregate(&[left, right], vec![], message(), EPOCH, LOG_INV_RATE).expect("node aggregates");
+ let node =
+ aggregate(&[left, right], message(), XMSS_EPOCH, vec![], vec![], LOG_INV_RATE).expect("node aggregates");
node.verify().expect("node verifies");
- assert_eq!(node.public_keys.len(), SMALL_LEAF_SIZE + 60);
+ assert_eq!(node.xmss_keys.len(), SMALL_LEAF_SIZE + 60);
}
#[test]
@@ -2484,24 +2882,55 @@ mod tests {
let signers = get_signers(40);
let left = prove_leaf(&signers[..25]);
let right = prove_leaf(&signers[15..]);
- let node = aggregate(&[left, right], vec![], message(), EPOCH, LOG_INV_RATE).expect("node aggregates");
+ let node =
+ aggregate(&[left, right], message(), XMSS_EPOCH, vec![], vec![], LOG_INV_RATE).expect("node aggregates");
node.verify().expect("node verifies");
- assert_eq!(node.public_keys.len(), 40);
- assert!(node.public_keys.windows(2).all(|w| w[0] < w[1]));
+ assert_eq!(node.xmss_keys.len(), 40);
+ assert!(node.xmss_keys.windows(2).all(|w| w[0] < w[1]));
}
+ /// Three levels, both schemes. The SPHINCS claims are rebuilt twice over, once
+ /// into each node and again into the root, and the two nodes share one claim,
+ /// so the root needs a SPHINCS duplicate slot for a claim it never saw
+ /// directly. The root also adds a raw signature of each scheme alongside its
+ /// children.
#[test]
#[ignore]
fn aggregate_three_levels() {
lean_vm::init_prover_pool();
let signers = get_signers(4 * SMALL_LEAF_SIZE + 2);
- let leaf = |index: usize| prove_leaf(&signers[index * SMALL_LEAF_SIZE..(index + 1) * SMALL_LEAF_SIZE]);
- let left = aggregate(&[leaf(0), leaf(1)], vec![], message(), EPOCH, LOG_INV_RATE).expect("left node");
- let right = aggregate(&[leaf(2), leaf(3)], vec![], message(), EPOCH, LOG_INV_RATE).expect("right node");
- let extra = signers[4 * SMALL_LEAF_SIZE..].to_vec();
- let root = aggregate(&[left, right], extra, message(), EPOCH, LOG_INV_RATE).expect("root aggregates");
+ let claims = get_sphincs_signers(5);
+ let leaf = |index: usize, sphincs: &[RawSphincs]| {
+ aggregate(
+ &[],
+ message(),
+ XMSS_EPOCH,
+ signers[index * SMALL_LEAF_SIZE..(index + 1) * SMALL_LEAF_SIZE].to_vec(),
+ sphincs.to_vec(),
+ LOG_INV_RATE,
+ )
+ .expect("leaf aggregates")
+ };
+ let node = |children: &[AggregateSignature]| {
+ aggregate(children, message(), XMSS_EPOCH, vec![], vec![], LOG_INV_RATE).expect("node aggregates")
+ };
+ // Claim 1 is under both nodes; claim 4 arrives raw at the root.
+ let left = node(&[leaf(0, &claims[..2]), leaf(1, &[])]);
+ let right = node(&[leaf(2, &claims[1..3]), leaf(3, &[])]);
+ let root = aggregate(
+ &[left, right],
+ message(),
+ XMSS_EPOCH,
+ signers[4 * SMALL_LEAF_SIZE..].to_vec(),
+ claims[4..].to_vec(),
+ LOG_INV_RATE,
+ )
+ .expect("root aggregates");
root.verify().expect("root verifies");
- assert_eq!(root.public_keys.len(), 4 * SMALL_LEAF_SIZE + 2);
+ assert_eq!(root.xmss_keys.len(), 4 * SMALL_LEAF_SIZE + 2);
+ assert_eq!(root.sphincs_signers.len(), 4, "claims 0, 1, 2 and 4, the repeat merged");
+ assert!(root.xmss_keys.windows(2).all(|w| w[0] < w[1]));
+ assert!(root.sphincs_signers.windows(2).all(|w| w[0] < w[1]));
}
#[test]
@@ -2511,7 +2940,17 @@ mod tests {
let signers = get_signers(2 * SMALL_LEAF_SIZE);
let left = prove_leaf(&signers[..SMALL_LEAF_SIZE]);
let right = prove_leaf(&signers[SMALL_LEAF_SIZE..]);
- let node = aggregate(&[left, right], vec![], message(), EPOCH, LOG_INV_RATE).expect("node");
+ // Mixed, so both published lists are non-empty and every tampering
+ // below has a SPHINCS counterpart.
+ let node = aggregate(
+ &[left, right],
+ message(),
+ XMSS_EPOCH,
+ vec![],
+ get_sphincs_signers(3),
+ LOG_INV_RATE,
+ )
+ .expect("node");
node.verify().expect("the honest node verifies");
assert_eq!(
@@ -2521,9 +2960,11 @@ mod tests {
node.to_bytes(),
"the wire format round-trips, recomputed claim values included"
);
- let without =
- AggregateSignature::from_bytes_without_pubkeys(&node.to_bytes_without_pubkeys(), node.public_keys.clone())
- .expect("round trip");
+ let without = AggregateSignature::from_bytes_without_pubkeys(
+ &node.to_bytes_without_pubkeys(),
+ (node.xmss_keys.clone(), node.sphincs_signers.clone()),
+ )
+ .expect("round trip");
without.verify().expect("a caller-supplied signer set verifies");
let tampered = |mutate: &dyn Fn(&mut AggregateSignature)| {
@@ -2531,18 +2972,38 @@ mod tests {
mutate(&mut bad);
assert!(bad.verify().is_err(), "a tampered aggregate must not verify");
};
- tampered(&|s| s.public_keys[0] = s.public_keys[1].clone());
+ tampered(&|s| s.xmss_keys[0] = s.xmss_keys[1].clone());
tampered(&|s| {
- s.public_keys.swap(0, 1);
+ s.xmss_keys.swap(0, 1);
});
tampered(&|s| {
- s.public_keys.pop();
+ s.xmss_keys.pop();
});
- tampered(&|s| s.epoch += 1);
- tampered(&|s| s.message[0] ^= 1);
+ tampered(&|s| s.sphincs_signers[0] = s.sphincs_signers[1]);
+ tampered(&|s| {
+ s.sphincs_signers.swap(0, 1);
+ });
+ tampered(&|s| {
+ s.sphincs_signers.pop();
+ });
+ // Relabelling a signer's scheme: the same 32 bytes moved to the other
+ // list. Both counts and the split between them are in the statement, and
+ // the guest holds each scheme's writers to its own region, so this is
+ // not a free relabelling of what the aggregate claims.
+ tampered(&|s| {
+ let moved = s.xmss_keys.remove(0);
+ let claimed = (SphincsPublicKey::from_bytes(&moved.flatten()), s.xmss_message);
+ s.sphincs_signers.push(claimed);
+ s.sphincs_signers.sort();
+ });
+ tampered(&|s| s.xmss_epoch += 1);
+ tampered(&|s| s.xmss_message[0] ^= 1);
+ // A signer's own message is in the statement too, so editing it is not a
+ // free re-attribution of that signature to another message.
+ tampered(&|s| s.sphincs_signers[0].1[0] ^= 1);
tampered(&|s| s.defer.bytecode_point[0] += F192::ONE);
tampered(&|s| s.defer.matrix_point[0] += F192::ONE);
- tampered(&|s| s.public_keys[0] = get_signers(2 * SMALL_LEAF_SIZE + 1)[2 * SMALL_LEAF_SIZE].0.clone());
+ tampered(&|s| s.xmss_keys[0] = get_signers(2 * SMALL_LEAF_SIZE + 1)[2 * SMALL_LEAF_SIZE].0.clone());
}
/// The all-zeros fast path in `DeferredClaim::recompute` must agree with the
@@ -2574,14 +3035,16 @@ mod tests {
let rejects = |children: &[AggregateSignature],
raw_signatures: Vec<(XmssPublicKey, XmssSignature)>,
+ raw_sphincs: Vec,
description: &str,
tamper: &dyn Fn(&mut Hints)| {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
aggregate_tampered(
children,
- raw_signatures,
statement_message,
- EPOCH,
+ XMSS_EPOCH,
+ raw_signatures,
+ raw_sphincs,
LOG_INV_RATE,
|hints| tamper(hints),
)
@@ -2603,11 +3066,11 @@ mod tests {
("raw_index (out of range)", &|h: &mut Hints| {
h.entries("raw_index")[0] = vec![count(SMALL_LEAF_SIZE)];
}),
- ("meta (n_keys inflated)", &|h: &mut Hints| {
+ ("meta (n_xmss inflated)", &|h: &mut Hints| {
h.entries("meta")[0][0] = count(SMALL_LEAF_SIZE + 1);
}),
- ("meta (n_raw understated)", &|h: &mut Hints| {
- h.entries("meta")[0][2] = count(SMALL_LEAF_SIZE - 1);
+ ("meta (n_raw_xmss understated)", &|h: &mut Hints| {
+ h.entries("meta")[0][4] = count(SMALL_LEAF_SIZE - 1);
}),
("meta (a spurious duplicate slot)", &|h: &mut Hints| {
h.entries("meta")[0][1] = count(1);
@@ -2626,13 +3089,82 @@ mod tests {
}),
];
for (description, tamper) in leaf_cases {
- rejects(&[], raw_signatures.clone(), description, *tamper);
+ rejects(&[], raw_signatures.clone(), vec![], description, *tamper);
+ }
+
+ // A mixed leaf: three XMSS signers then two SPHINCS ones, so the XMSS
+ // region is slots 0..3 and the SPHINCS region 3..5. Each scheme's
+ // witness has to bind, and neither scheme's signature may cover the
+ // other's declared key, which is what the statement's split claims.
+ let mixed_xmss = signers[..3].to_vec();
+ let mixed_sphincs = get_sphincs_signers(2);
+ aggregate(
+ &[],
+ statement_message,
+ XMSS_EPOCH,
+ mixed_xmss.clone(),
+ mixed_sphincs.clone(),
+ LOG_INV_RATE,
+ )
+ .expect("the honest mixed leaf aggregates");
+ let mixed_cases: &[Tamper] = &[
+ (
+ "raw_index (an XMSS signature reaching the SPHINCS region)",
+ &|h: &mut Hints| {
+ h.entries("raw_index")[0] = vec![count(3)];
+ },
+ ),
+ ("sp_raw_index (out of range)", &|h: &mut Hints| {
+ h.entries("sp_raw_index")[0] = vec![count(2)];
+ }),
+ ("sp_raw_index (duplicate slot)", &|h: &mut Hints| {
+ let entries = h.entries("sp_raw_index");
+ entries[1] = entries[0].clone();
+ }),
+ ("meta (n_sphincs inflated)", &|h: &mut Hints| {
+ h.entries("meta")[0][2] = count(3);
+ }),
+ ("meta (n_raw_sphincs understated)", &|h: &mut Hints| {
+ h.entries("meta")[0][5] = count(1);
+ }),
+ ("sphincs_signers (a message nobody signed)", &|h: &mut Hints| {
+ h.entries("sphincs_signers")[0][2] += F192::ONE;
+ }),
+ ("sphincs_signers (a key nobody signed for)", &|h: &mut Hints| {
+ h.entries("sphincs_signers")[0][0] += F192::ONE;
+ }),
+ ("sp_rand (another randomizer, so another index)", &|h: &mut Hints| {
+ h.entries("sp_rand")[0][0] += F192::ONE;
+ }),
+ ("sp_counter", &|h: &mut Hints| {
+ h.entries("sp_counter")[0][0] += F192::ONE;
+ }),
+ ("sp_digits", &|h: &mut Hints| {
+ let entries = h.entries("sp_digits");
+ entries[0][0] *= F192::from(primitives::field::G);
+ }),
+ ("sp_chain_starts", &|h: &mut Hints| {
+ h.entries("sp_chain_starts")[0][0] += F192::ONE;
+ }),
+ ("sp_fts_secrets", &|h: &mut Hints| {
+ h.entries("sp_fts_secrets")[0][0] += F192::ONE;
+ }),
+ ("sp_fts_paths", &|h: &mut Hints| {
+ h.entries("sp_fts_paths")[0][0] += F192::ONE;
+ }),
+ ("sp_siblings", &|h: &mut Hints| {
+ h.entries("sp_siblings")[0][0] += F192::ONE;
+ }),
+ ];
+ for (description, tamper) in mixed_cases {
+ rejects(&[], mixed_xmss.clone(), mixed_sphincs.clone(), description, *tamper);
}
let left = prove_leaf(&signers[..SMALL_LEAF_SIZE]);
let right = prove_leaf(&signers[SMALL_LEAF_SIZE..]);
let children = vec![left, right];
- aggregate(&children, vec![], statement_message, EPOCH, LOG_INV_RATE).expect("the honest node aggregates");
+ aggregate(&children, statement_message, XMSS_EPOCH, vec![], vec![], LOG_INV_RATE)
+ .expect("the honest node aggregates");
let node_cases: &[Tamper] = &[
("child_index (duplicate slot)", &|h: &mut Hints| {
let entries = h.entries("child_index");
@@ -2665,7 +3197,38 @@ mod tests {
}),
];
for (description, tamper) in node_cases {
- rejects(&children, vec![], description, *tamper);
+ rejects(&children, vec![], vec![], description, *tamper);
+ }
+
+ // The same discipline over a child's SPHINCS claims, which are rebuilt by
+ // their own loop (`hash_child_sphincs`) rather than the XMSS helper, so
+ // the cases above do not reach them: these children carry claims.
+ let sphincs = get_sphincs_signers(4);
+ let mixed_child = |x: &[(XmssPublicKey, XmssSignature)], s: &[RawSphincs]| {
+ aggregate(&[], statement_message, XMSS_EPOCH, x.to_vec(), s.to_vec(), LOG_INV_RATE)
+ .expect("the honest mixed child aggregates")
+ };
+ let mixed_children = vec![
+ mixed_child(&signers[..2], &sphincs[..2]),
+ mixed_child(&signers[2..4], &sphincs[2..]),
+ ];
+ let mixed_node_cases: &[Tamper] = &[
+ ("child_sphincs_index (duplicate slot)", &|h: &mut Hints| {
+ let entries = h.entries("child_sphincs_index");
+ entries[1] = entries[0].clone();
+ }),
+ ("child_sphincs_index (out of range)", &|h: &mut Hints| {
+ h.entries("child_sphincs_index")[0] = vec![count(4)];
+ }),
+ (
+ "child_n_keys (a child's SPHINCS count understated)",
+ &|h: &mut Hints| {
+ h.entries("child_n_keys")[0][1] = count(1);
+ },
+ ),
+ ];
+ for (description, tamper) in mixed_node_cases {
+ rejects(&mixed_children, vec![], vec![], description, *tamper);
}
}
@@ -2676,12 +3239,24 @@ mod tests {
let mut raw_signatures = get_signers(3);
raw_signatures[1].1.wots_signature.chain_tips[0][0] ^= 1;
let built = std::panic::catch_unwind(|| {
- aggregate(&[], raw_signatures, message(), EPOCH, LOG_INV_RATE).map(|signature| signature.verify().is_ok())
+ aggregate(&[], message(), XMSS_EPOCH, raw_signatures, vec![], LOG_INV_RATE)
+ .map(|signature| signature.verify().is_ok())
});
assert!(
!matches!(built, Ok(Ok(true))),
"a forged signature must not produce a verifying aggregate"
);
+
+ let mut raw_sphincs = get_sphincs_signers(2);
+ raw_sphincs[1].2.ots[2][0][0] ^= 1;
+ let built = std::panic::catch_unwind(|| {
+ aggregate(&[], message(), XMSS_EPOCH, vec![], raw_sphincs, LOG_INV_RATE)
+ .map(|signature| signature.verify().is_ok())
+ });
+ assert!(
+ !matches!(built, Ok(Ok(true))),
+ "a forged SPHINCS signature must not produce a verifying aggregate"
+ );
}
/// Randomness that does not decode to a target-sum encoding used to panic
@@ -2698,7 +3273,7 @@ mod tests {
.find_map(|byte| {
let mut randomness = [0; xmss::RANDOMNESS_LEN];
randomness[0] = byte;
- xmss::wots_encode(&message, EPOCH, &pk.public_param, &randomness)
+ xmss::wots_encode(&message, XMSS_EPOCH, &pk.public_param, &randomness)
.is_none()
.then_some(randomness)
})
@@ -2713,12 +3288,27 @@ mod tests {
let mut hints = Hints::default();
assert_eq!(
- push_signature_hints(&mut hints, &pk, &sig, &message, EPOCH),
+ push_signature_hints(&mut hints, &pk, &sig, &message, XMSS_EPOCH),
Err(AggregateError::MalformedRawSignature)
);
assert!(hints.0.is_empty());
assert_eq!(
- aggregate(&[], vec![(pk, sig)], message, EPOCH, LOG_INV_RATE).err(),
+ aggregate(&[], message, XMSS_EPOCH, vec![(pk, sig)], vec![], LOG_INV_RATE).err(),
+ Some(AggregateError::MalformedRawSignature)
+ );
+ }
+
+ /// The same for a SPHINCS claim, whose witness walk is equally fallible: a
+ /// counter that does not encode has no witness, and that is an error rather
+ /// than a panic inside the prover.
+ #[test]
+ fn malformed_raw_sphincs_signature_is_an_error() {
+ let (public_key, signed, mut signature) = get_sphincs_signers(1).pop().expect("one signer");
+ signature.counters[sphincs::D - 1] ^= 1;
+ assert!(sphincs::verify(&public_key, &signed, &signature).is_err());
+ let raw = vec![(public_key, signed, signature)];
+ assert_eq!(
+ aggregate(&[], message(), XMSS_EPOCH, vec![], raw, LOG_INV_RATE).err(),
Some(AggregateError::MalformedRawSignature)
);
}
diff --git a/crates/rec_aggregation/src/benchmark.rs b/crates/rec_aggregation/src/benchmark.rs
index cc00c833a..d878bcf01 100644
--- a/crates/rec_aggregation/src/benchmark.rs
+++ b/crates/rec_aggregation/src/benchmark.rs
@@ -1,6 +1,8 @@
-//! The two benchmarks: one leaf of the aggregation tree (`xmss`), and an n→1
-//! recursion step over leaves of that size (`recursion`). Both drive the same
-//! [`crate::aggregation::aggregate`] entry point the real API uses.
+//! The two benchmarks: one leaf of the aggregation tree (`aggregate`), and an
+//! n→1 recursion step over leaves of that size (`recursion`). Each takes a
+//! count per scheme, so either alone or a mix of both is one command, and both
+//! drive the same [`crate::aggregation::aggregate`] entry point the real API
+//! uses.
use primitives::bench::Plan;
use primitives::{pretty_f64, pretty_integer};
@@ -11,9 +13,21 @@ use crate::signers_cache;
/// Cached signers `[from, to)`, as the aggregation API takes them.
fn signers(from: usize, to: usize) -> Vec<(XmssPublicKey, XmssSignature)> {
+ if to == 0 {
+ return Vec::new();
+ }
signers_cache::get_signers(to)[from..to].to_vec()
}
+/// Each SPHINCS signer comes with the message it signed, unlike the XMSS ones,
+/// which share the statement's.
+fn sphincs_signers(from: usize, to: usize) -> Vec<(sphincs::PublicKey, sphincs::Message, sphincs::Signature)> {
+ if to == 0 {
+ return Vec::new();
+ }
+ signers_cache::get_sphincs_signers(to)[from..to].to_vec()
+}
+
/// Report the shape and cost of one aggregation node.
fn report(label: &str, stats: &lean_vm::cpu::Stats, sig: &AggregateSignature, prove_time: &primitives::bench::Timing) {
let base_cycles: usize = stats.base_counts.iter().sum();
@@ -26,47 +40,59 @@ fn report(label: &str, stats: &lean_vm::cpu::Stats, sig: &AggregateSignature, pr
pretty_integer(base_cycles),
crate::report::pow(base_cycles)
);
- println!(
- " proven rows : {} = {} (filled to powers of two)",
- pretty_integer(stats.cycles),
- crate::report::pow(stats.cycles)
- );
println!(" details : {}", stats.details());
- println!(
- " signers : {}",
- pretty_integer(sig.public_keys.len())
- );
crate::report::print_proof_size(sig.proof());
// The whole `aggregate` call, not just `cpu::prove`: for a node that also
// covers verifying each child and batching the deferred claims, which are
// real per-node costs. `--tracing` breaks it down.
println!(
- " aggregating : {} s{} peak memory {} GiB",
+ " proving time : {} s{} peak memory {} GiB",
pretty_f64(prove_time.mean()),
prove_time.spread(),
crate::report::peak_gib()
);
}
-/// Aggregate `n` XMSS signatures in one leaf and verify it.
+/// How a benchmark names a leaf of either scheme or of both.
+fn describe(n_xmss: usize, n_sphincs: usize) -> String {
+ match (n_xmss, n_sphincs) {
+ (x, 0) => format!("{} XMSS", pretty_integer(x)),
+ (0, s) => format!("{} SPHINCS", pretty_integer(s)),
+ (x, s) => format!("{} XMSS and {} SPHINCS", pretty_integer(x), pretty_integer(s)),
+ }
+}
+
+/// Aggregate `n_xmss` XMSS and `n_sphincs` SPHINCS signatures in one leaf and
+/// verify it. A SPHINCS verification is 531 compressions against XMSS's 144, so
+/// a leaf of a given proven size holds proportionally fewer of them.
///
/// Proving runs one discarded warmup pass followed by `plan.repeat` measured
/// passes; see [`primitives::bench`] for why the first pass is not
/// representative and why the cooldown matters.
-pub fn run_xmss_aggregation(n: usize, log_inv_rate: usize, plan: Plan) {
- let trace_span = tracing::info_span!("XMSS aggregation", n, log_inv_rate).entered();
+pub fn run_aggregation(n_xmss: usize, n_sphincs: usize, log_inv_rate: usize, plan: Plan) {
+ assert!(n_xmss + n_sphincs >= 1, "a leaf needs at least one signer");
+ let trace_span = tracing::info_span!("aggregation", n_xmss, n_sphincs, log_inv_rate).entered();
// Spawn the worker pool before any timed work, so no kernel pays the spawn
// cost. Opting into the arena is the calling *process's* decision (one region,
// one proof at a time), so it stays in `main`, not here.
lean_vm::init_prover_pool();
- let raw = signers(0, n);
- let (message, epoch) = (signers_cache::message(), signers_cache::EPOCH);
+ let raw_xmss = signers(0, n_xmss);
+ let raw_sphincs = sphincs_signers(0, n_sphincs);
+ let (xmss_message, xmss_epoch) = (signers_cache::message(), signers_cache::XMSS_EPOCH);
// Only the final measured pass of each stage is traced: the tree describes the
// proof the reported timings are about, instead of repeating itself per pass.
let ((sig, stats), prove_time) = plan.warm_then_measure(|last| {
let _quiet = (!last).then(primitives::suppress_tracing);
- aggregate_with_stats(&[], raw.clone(), message, epoch, log_inv_rate).expect("leaf aggregates")
+ aggregate_with_stats(
+ &[],
+ xmss_message,
+ xmss_epoch,
+ raw_xmss.clone(),
+ raw_sphincs.clone(),
+ log_inv_rate,
+ )
+ .expect("leaf aggregates")
});
let (_, verify_time) = Plan::new(plan.repeat, 0).measure_quiet(|last| {
let _quiet = (!last).then(primitives::suppress_tracing);
@@ -75,14 +101,14 @@ pub fn run_xmss_aggregation(n: usize, log_inv_rate: usize, plan: Plan) {
drop(trace_span);
report(
- &format!("\nXMSS aggregation, {} signatures", pretty_integer(n)),
+ &format!("\naggregation, {} signatures", describe(n_xmss, n_sphincs)),
&stats,
&sig,
&prove_time,
);
println!(
- " per signature : {} XMSS/s",
- pretty_f64(n as f64 / prove_time.mean())
+ " per signature : {} signatures/s",
+ pretty_f64((n_xmss + n_sphincs) as f64 / prove_time.mean())
);
println!(" verifying : {} s", pretty_f64(verify_time.mean()));
}
@@ -90,11 +116,20 @@ pub fn run_xmss_aggregation(n: usize, log_inv_rate: usize, plan: Plan) {
/// Prove `n` leaves of `per_leaf` signatures each, then aggregate them in one
/// recursion step and verify the result. The leaves are built once; only the
/// recursion step is measured.
-pub fn run_recursion(n: usize, per_leaf: usize, log_inv_rate: usize, enable_tracing: bool, plan: Plan) {
+pub fn run_recursion(
+ n: usize,
+ per_leaf: usize,
+ sphincs_per_leaf: usize,
+ log_inv_rate: usize,
+ enable_tracing: bool,
+ plan: Plan,
+) {
assert!(n >= 1, "a recursion step needs at least one child");
+ assert!(per_leaf + sphincs_per_leaf >= 1, "a leaf needs at least one signer");
lean_vm::init_prover_pool();
- let (message, epoch) = (signers_cache::message(), signers_cache::EPOCH);
+ let (xmss_message, xmss_epoch) = (signers_cache::message(), signers_cache::XMSS_EPOCH);
let all = signers(0, n * per_leaf);
+ let all_sphincs = sphincs_signers(0, n * sphincs_per_leaf);
let started = std::time::Instant::now();
let guest_instructions: usize = crate::aggregation::unified_guest()
.fn_ranges
@@ -107,9 +142,10 @@ pub fn run_recursion(n: usize, per_leaf: usize, log_inv_rate: usize, enable_trac
.map(|k| {
aggregate(
&[],
+ xmss_message,
+ xmss_epoch,
all[k * per_leaf..(k + 1) * per_leaf].to_vec(),
- message,
- epoch,
+ all_sphincs[k * sphincs_per_leaf..(k + 1) * sphincs_per_leaf].to_vec(),
log_inv_rate,
)
.expect("leaf aggregates")
@@ -121,7 +157,8 @@ pub fn run_recursion(n: usize, per_leaf: usize, log_inv_rate: usize, enable_trac
}
let ((sig, stats), prove_time) = plan.warm_then_measure(|last| {
let _quiet = (!last).then(primitives::suppress_tracing);
- aggregate_with_stats(&children, vec![], message, epoch, log_inv_rate).expect("node aggregates")
+ aggregate_with_stats(&children, xmss_message, xmss_epoch, vec![], vec![], log_inv_rate)
+ .expect("node aggregates")
});
let (_, verify_time) = Plan::new(plan.repeat, 0).measure_quiet(|last| {
let _quiet = (!last).then(primitives::suppress_tracing);
@@ -137,7 +174,7 @@ pub fn run_recursion(n: usize, per_leaf: usize, log_inv_rate: usize, enable_trac
report(
&format!(
"\nrecursion {n}\u{2192}1, over leaves of {} signatures",
- pretty_integer(per_leaf)
+ describe(per_leaf, sphincs_per_leaf)
),
&stats,
&sig,
diff --git a/crates/rec_aggregation/src/lib.rs b/crates/rec_aggregation/src/lib.rs
index 527a49ebc..e2b4d6bb0 100644
--- a/crates/rec_aggregation/src/lib.rs
+++ b/crates/rec_aggregation/src/lib.rs
@@ -1,6 +1,7 @@
-//! Recursive XMSS aggregation ([`aggregation`]) and the harnesses that measure
-//! it ([`benchmark`]), plus the Fibonacci demo. One zkDSL guest
-//! (`guests/aggregate.py`) serves every node of an aggregation tree.
+//! Recursive aggregation of XMSS and SPHINCS signatures ([`aggregation`]) and
+//! the harnesses that measure it ([`benchmark`]), plus the Fibonacci demo. One
+//! zkDSL guest (`guests/aggregate.py`) serves every node of an aggregation tree,
+//! and knows both schemes.
pub mod aggregation;
pub mod benchmark;
@@ -13,7 +14,7 @@ mod hash_chain;
pub mod signers_cache;
pub use aggregation::{AggregateError, AggregateSignature, VerifyError, aggregate};
-pub use benchmark::{run_recursion, run_xmss_aggregation};
+pub use benchmark::{run_aggregation, run_recursion};
pub use fibonacci::run_fibonacci;
/// The pieces every workload's benchmark report ends with.
diff --git a/crates/rec_aggregation/src/signers_cache.rs b/crates/rec_aggregation/src/signers_cache.rs
index 5839197ee..75b0e17dd 100644
--- a/crates/rec_aggregation/src/signers_cache.rs
+++ b/crates/rec_aggregation/src/signers_cache.rs
@@ -1,4 +1,5 @@
-//! Persistent cache for deterministic XMSS benchmark signatures.
+//! Persistent cache for deterministic benchmark signatures, one file per
+//! scheme.
//!
//! The cache grows as needed and is memoized in-process. Its filename binds the
//! parameters, hash construction, and encoding predicate. Loaded signatures are
@@ -21,7 +22,8 @@ type CachedSignature = (XmssPublicKey, XmssSignature);
const SCHEMA_VERSION: u32 = 2;
-pub const EPOCH: u32 = 7;
+/// The epoch every cached XMSS signature was made at. SPHINCS has none.
+pub const XMSS_EPOCH: u32 = 7;
const KEY_START: u32 = 0;
const KEY_END: u32 = 15;
@@ -36,7 +38,7 @@ fn compute_signer(index: usize) -> CachedSignature {
let mut seed = [10u8; 32];
seed[..8].copy_from_slice(&(index as u64).to_le_bytes());
let (sk, pk) = xmss_key_gen(seed, KEY_START, KEY_END).expect("keygen");
- let sig = xmss_sign(&mut StdRng::seed_from_u64(index as u64), &sk, &message(), EPOCH).expect("sign");
+ let sig = xmss_sign(&mut StdRng::seed_from_u64(index as u64), &sk, &message(), XMSS_EPOCH).expect("sign");
(pk, sig)
}
@@ -54,7 +56,7 @@ fn encoding_fingerprint() -> (u64, [u8; V]) {
for counter in 0u64.. {
let mut randomness = [0u8; RANDOMNESS_LEN];
randomness[..8].copy_from_slice(&counter.to_le_bytes());
- if let Some(digits) = wots_encode(&msg, EPOCH, &pp, &randomness) {
+ if let Some(digits) = wots_encode(&msg, XMSS_EPOCH, &pp, &randomness) {
return (counter, digits);
}
}
@@ -64,7 +66,7 @@ fn encoding_fingerprint() -> (u64, [u8; V]) {
fn footprint() -> u64 {
let mut hasher = DefaultHasher::new();
SCHEMA_VERSION.hash(&mut hasher);
- EPOCH.hash(&mut hasher);
+ XMSS_EPOCH.hash(&mut hasher);
KEY_START.hash(&mut hasher);
KEY_END.hash(&mut hasher);
message().hash(&mut hasher);
@@ -91,7 +93,7 @@ fn try_load_cache() -> Option> {
let msg = message();
let valid = signers
.iter()
- .take_while(|(pk, sig)| xmss_verify(pk, &msg, sig, EPOCH).is_ok())
+ .take_while(|(pk, sig)| xmss_verify(pk, &msg, sig, XMSS_EPOCH).is_ok())
.count();
if valid < signers.len() {
eprintln!(
@@ -155,6 +157,138 @@ pub fn get_signers(n: usize) -> Vec {
pool[..n].to_vec()
}
+/// A SPHINCS signer, generated the same way, with the message it signed: each
+/// SPHINCS signer carries its own, where the XMSS ones share one. Signing is
+/// stateless, so unlike XMSS there is no epoch and no key range: one key
+/// answers for every index.
+type CachedSphincsSignature = (sphincs::PublicKey, sphincs::Message, sphincs::Signature);
+
+/// Signer `index`'s own message, distinct from every other's and from the shared
+/// XMSS [`message`], so a test that mixed them up would fail rather than pass.
+pub fn sphincs_message(index: usize) -> sphincs::Message {
+ let mut msg = [0u8; sphincs::MESSAGE_LEN];
+ msg[..8].copy_from_slice(&(index as u64).to_le_bytes());
+ msg[8..].copy_from_slice(&[0xC5; sphincs::MESSAGE_LEN - 8]);
+ msg
+}
+
+/// One cached SPHINCS signer, as fixed-size bytes: the scheme's own
+/// serializations, so nothing here has to agree with a derived one.
+const SPHINCS_RECORD: usize = sphincs::PUB_KEY_SIZE + sphincs::MESSAGE_LEN + sphincs::SIG_SIZE;
+
+fn compute_sphincs_signer(index: usize) -> CachedSphincsSignature {
+ let mut rng = StdRng::seed_from_u64(0x5F1A_C500 ^ index as u64);
+ let (secret_key, public_key) = sphincs::key_gen(&mut rng);
+ let message = sphincs_message(index);
+ let signature = sphincs::sign(&mut rng, &secret_key, &message).expect("sign");
+ (public_key, message, signature)
+}
+
+fn sphincs_footprint() -> u64 {
+ let mut hasher = DefaultHasher::new();
+ SCHEMA_VERSION.hash(&mut hasher);
+ // The record layout and the per-signer messages, so a change to either
+ // invalidates the file rather than being read back as another scheme's.
+ SPHINCS_RECORD.hash(&mut hasher);
+ sphincs_message(0).hash(&mut hasher);
+ sphincs_message(1).hash(&mut hasher);
+ (
+ sphincs::V,
+ sphincs::W,
+ sphincs::TARGET_SUM,
+ sphincs::D,
+ sphincs::HEIGHTS,
+ sphincs::A,
+ sphincs::K,
+ )
+ .hash(&mut hasher);
+ // The tweakable hash itself, so a change to it invalidates the file.
+ sphincs::th(
+ &[0xA5; sphincs::PUBLIC_PARAM_LEN],
+ &sphincs::tweak(1, 2, 3, 4, 5),
+ &[0x3C; 16],
+ )
+ .hash(&mut hasher);
+ hasher.finish()
+}
+
+fn sphincs_cache_path() -> PathBuf {
+ cache_dir().join(format!("sphincs_signers_{:016x}.bin", sphincs_footprint()))
+}
+
+fn try_load_sphincs_cache() -> Option> {
+ let bytes = fs::read(sphincs_cache_path()).ok()?;
+ let mut signers = Vec::with_capacity(bytes.len() / SPHINCS_RECORD);
+ for record in bytes.as_chunks::().0 {
+ let (key_bytes, rest) = record.split_at(sphincs::PUB_KEY_SIZE);
+ let (message_bytes, signature_bytes) = rest.split_at(sphincs::MESSAGE_LEN);
+ let public_key = sphincs::PublicKey::from_bytes(key_bytes.try_into().unwrap());
+ let message: sphincs::Message = message_bytes.try_into().unwrap();
+ let signature = sphincs::Signature::from_bytes(signature_bytes.try_into().unwrap());
+ if sphincs::verify(&public_key, &message, &signature).is_err() {
+ eprintln!(
+ "warning: signers cache {} is stale (signer {} no longer verifies); regenerating from there",
+ sphincs_cache_path().display(),
+ signers.len()
+ );
+ break;
+ }
+ signers.push((public_key, message, signature));
+ }
+ Some(signers)
+}
+
+fn save_sphincs_cache(signers: &[CachedSphincsSignature]) {
+ let path = sphincs_cache_path();
+ if let Some(parent) = path.parent() {
+ let _ = fs::create_dir_all(parent);
+ }
+ let mut bytes = Vec::with_capacity(signers.len() * SPHINCS_RECORD);
+ for (public_key, message, signature) in signers {
+ bytes.extend_from_slice(&public_key.flatten());
+ bytes.extend_from_slice(message);
+ bytes.extend_from_slice(&signature.to_bytes());
+ }
+ if let Err(error) = fs::write(&path, &bytes) {
+ eprintln!("warning: could not write signers cache to {}: {error}", path.display());
+ }
+}
+
+static SPHINCS_POOL: Mutex> = Mutex::new(Vec::new());
+
+pub fn get_sphincs_signers(n: usize) -> Vec {
+ let mut pool = SPHINCS_POOL.lock().unwrap();
+ if pool.len() < n {
+ if let Some(disk) = try_load_sphincs_cache()
+ && disk.len() > pool.len()
+ {
+ *pool = disk;
+ }
+ // Key generation is one whole 2^12-leaf tree, which is the expensive
+ // part; it fans out internally, so this loop stays sequential.
+ let started = Instant::now();
+ let missing = n.saturating_sub(pool.len());
+ for index in pool.len()..n {
+ pool.push(compute_sphincs_signer(index));
+ print!(
+ "\r generating SPHINCS signers (one-time, then cached): {}/{}",
+ pretty_integer(index + 1 - (n - missing)),
+ pretty_integer(missing)
+ );
+ let _ = std::io::stdout().flush();
+ }
+ if missing > 0 {
+ println!(
+ "\r generated {} SPHINCS in {} s (cached to disk) ",
+ pretty_integer(missing),
+ pretty_f64(started.elapsed().as_secs_f64())
+ );
+ save_sphincs_cache(&pool);
+ }
+ }
+ pool[..n].to_vec()
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/rec_aggregation/tests/arena_prove.rs b/crates/rec_aggregation/tests/arena_prove.rs
index 8e50419f0..198b1bccb 100644
--- a/crates/rec_aggregation/tests/arena_prove.rs
+++ b/crates/rec_aggregation/tests/arena_prove.rs
@@ -12,7 +12,7 @@ fn repeated_proofs_survive_phase_resets() {
"this test is meaningless unless the arena is engaged"
);
- rec_aggregation::run_xmss_aggregation(3, lean_vm::pcs::LOG_INV_RATE, Plan::new(2, 0));
+ rec_aggregation::run_aggregation(3, 1, lean_vm::pcs::LOG_INV_RATE, Plan::new(2, 0));
let stats = zk_alloc::stats();
assert!(stats.phases >= 3, "expected one phase per proof, got {stats:?}");
diff --git a/crates/sphincs/Cargo.toml b/crates/sphincs/Cargo.toml
new file mode 100644
index 000000000..21f53fa30
--- /dev/null
+++ b/crates/sphincs/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "sphincs"
+version.workspace = true
+edition.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+primitives.workspace = true
+parallel.workspace = true
+rand.workspace = true
+serde.workspace = true
diff --git a/crates/sphincs/src/fts.rs b/crates/sphincs/src/fts.rs
new file mode 100644
index 000000000..f7fc428ac
--- /dev/null
+++ b/crates/sphincs/src/fts.rs
@@ -0,0 +1,86 @@
+//! The few-time signature: a forest of `k-1` Merkle trees of `2^a` secret
+//! leaves, one leaf opened per tree at an index the message digest picks
+//! (FORS+C).
+//!
+//! Reuse leaks rather than breaks: after `r` signatures on one instance an
+//! adversary holds `r` leaves per tree, and can sign a message only if it lands
+//! on that instance, has last index zero, and has every other index on a leaf it
+//! already holds.
+
+use crate::*;
+
+/// What a signature carries for the few-time key: the opened secret and the
+/// Merkle path of each of the `k-1` trees.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct FtsOpening {
+ pub secrets: [Digest; NUM_FTS_TREES],
+ pub paths: [[Digest; A]; NUM_FTS_TREES],
+}
+
+/// `s_{idx,kappa,j} = Th(P, tw_ftsprf(idx,kappa,j), S)`.
+fn fts_secret(pp: &PublicParam, master: &Digest, idx: u64, kappa: usize, j: usize) -> Digest {
+ th(pp, &tweak(TWEAK_FTS_PRF, kappa, idx as u32, 0, j as u32), master)
+}
+
+fn fts_leaf(pp: &PublicParam, idx: u64, kappa: usize, j: usize, secret: &Digest) -> Digest {
+ th(pp, &tweak(TWEAK_FTS_LEAF, kappa, idx as u32, 0, j as u32), secret)
+}
+
+fn fts_node(pp: &PublicParam, idx: u64, kappa: usize, level: usize, j: usize, left: &Digest, right: &Digest) -> Digest {
+ let tw = tweak(TWEAK_FTS_NODE, kappa, idx as u32, level as u32, j as u32);
+ th_digests(pp, &tw, &[*left, *right])
+}
+
+/// `Fts.key`: the few-time public key, `Th` over the `k-1` roots.
+fn fts_key_of_roots(pp: &PublicParam, idx: u64, roots: &[Digest; NUM_FTS_TREES]) -> Digest {
+ th_digests(pp, &tweak(TWEAK_FTS_ROOTS, 0, idx as u32, 0, 0), roots)
+}
+
+/// `Fts.key` and `Fts.open` together, the forest being built once. `u[k-1]` is
+/// ignored: its tree is the dropped one.
+pub fn fts_open(pp: &PublicParam, master: &Digest, idx: u64, u: &[u32; K]) -> (Digest, FtsOpening) {
+ let mut opening = FtsOpening {
+ secrets: [[0; N]; NUM_FTS_TREES],
+ paths: [[[0; N]; A]; NUM_FTS_TREES],
+ };
+ let mut roots = [[0; N]; NUM_FTS_TREES];
+ for kappa in 0..NUM_FTS_TREES {
+ let opened = u[kappa] as usize;
+ let mut nodes = Vec::with_capacity(1 << A);
+ for j in 0..1 << A {
+ let secret = fts_secret(pp, master, idx, kappa, j);
+ if j == opened {
+ opening.secrets[kappa] = secret;
+ }
+ nodes.push(fts_leaf(pp, idx, kappa, j, &secret));
+ }
+ for level in 0..A {
+ opening.paths[kappa][level] = nodes[(opened >> level) ^ 1];
+ nodes = (0..nodes.len() / 2)
+ .map(|j| fts_node(pp, idx, kappa, level + 1, j, &nodes[2 * j], &nodes[2 * j + 1]))
+ .collect();
+ }
+ roots[kappa] = nodes[0];
+ }
+ (fts_key_of_roots(pp, idx, &roots), opening)
+}
+
+/// `Fts.recover`: the few-time key an opening reaches, which is `Fts.key` on an
+/// opening of the leaves `u` of that instance and nothing else short of a
+/// collision.
+pub fn fts_recover(pp: &PublicParam, idx: u64, u: &[u32; K], opening: &FtsOpening) -> Digest {
+ let roots = std::array::from_fn(|kappa| {
+ let opened = u[kappa] as usize;
+ let leaf = fts_leaf(pp, idx, kappa, opened, &opening.secrets[kappa]);
+ (0..A).fold(leaf, |node, level| {
+ let sibling = &opening.paths[kappa][level];
+ let (left, right) = if (opened >> level) & 1 == 0 {
+ (node, *sibling)
+ } else {
+ (*sibling, node)
+ };
+ fts_node(pp, idx, kappa, level + 1, opened >> (level + 1), &left, &right)
+ })
+ });
+ fts_key_of_roots(pp, idx, &roots)
+}
diff --git a/crates/sphincs/src/hash.rs b/crates/sphincs/src/hash.rs
new file mode 100644
index 000000000..cdeb7e665
--- /dev/null
+++ b/crates/sphincs/src/hash.rs
@@ -0,0 +1,56 @@
+//! The tweakable hash `Th(P, tw, M) = Truncate_n(BLAKE2s(tw | P | M))`, and the
+//! 16-byte tweak that names one hash call in the whole structure.
+//!
+//! Compressions per call, the input including the 32 bytes of tweak and public
+//! parameter: 1 for a chain step, a Merkle node, a derived secret and an
+//! encoding, 2 for the message digest, 4 for the few-time roots, and 11 for a
+//! one-time leaf.
+
+use crate::*;
+
+pub const TWEAK_LEN: usize = 16;
+pub type Tweak = [u8; TWEAK_LEN];
+
+// Tweak types, the tweak's first byte, so no two kinds of call can alias.
+pub const TWEAK_PRF: u8 = 0;
+pub const TWEAK_CHAIN: u8 = 1;
+pub const TWEAK_LEAF: u8 = 2;
+pub const TWEAK_NODE: u8 = 3;
+pub const TWEAK_ENC: u8 = 4;
+pub const TWEAK_FTS_PRF: u8 = 5;
+pub const TWEAK_FTS_LEAF: u8 = 6;
+pub const TWEAK_FTS_NODE: u8 = 7;
+pub const TWEAK_FTS_ROOTS: u8 = 8;
+pub const TWEAK_MSG: u8 = 9;
+
+/// `enc(t, lay, tau, p, j)`: fourteen bytes of little-endian fields and two of
+/// padding. `lay` is a layer of the hypertree or a tree of a few-time forest,
+/// and is byte wide.
+pub fn tweak(t: u8, lay: usize, tau: u32, p: u32, j: u32) -> Tweak {
+ debug_assert!(lay < 256);
+ let mut tw = [0u8; TWEAK_LEN];
+ tw[0] = t;
+ tw[1] = lay as u8;
+ tw[2..6].copy_from_slice(&tau.to_le_bytes());
+ tw[6..10].copy_from_slice(&p.to_le_bytes());
+ tw[10..14].copy_from_slice(&j.to_le_bytes());
+ tw
+}
+
+/// `Th` over a byte payload: an encoding input or a derived secret.
+pub fn th(pp: &PublicParam, tw: &Tweak, payload: &[u8]) -> Digest {
+ let mut hasher = primitives::hash::Hasher::new();
+ hasher.update(tw).update(pp).update(payload);
+ hasher.finalize()[..N].try_into().unwrap()
+}
+
+/// `Th` over a concatenation of digests: a Merkle node, a one-time leaf, or the
+/// few-time roots.
+pub fn th_digests(pp: &PublicParam, tw: &Tweak, values: &[Digest]) -> Digest {
+ let mut hasher = primitives::hash::Hasher::new();
+ hasher.update(tw).update(pp);
+ for value in values {
+ hasher.update(value);
+ }
+ hasher.finalize()[..N].try_into().unwrap()
+}
diff --git a/crates/sphincs/src/lib.rs b/crates/sphincs/src/lib.rs
new file mode 100644
index 000000000..c4a1eafa7
--- /dev/null
+++ b/crates/sphincs/src/lib.rs
@@ -0,0 +1,97 @@
+//! SPHINCS+ over BLAKE2s: the stateless scheme specified in
+//! `doc/sphincs/main.tex`, with WOTS+C and FORS+C, at `2^24` signatures per key
+//! pair. A public key is 32 bytes, a signature 4924, and a verification 497 hash
+//! calls.
+//!
+//! That specification is the reference and every symbol here carries its name:
+//! `n`, `w`, `v`, `T`, `d`, `h_lay`, `a`, `k`. Every hash is standard BLAKE2s of
+//! the exact byte string `tweak | P | payload` truncated to `n = 128` bits (the
+//! `hash` module), and the tweak names one hash call in the whole structure.
+//!
+//! Secrets are the seed-derived implementation of the specification's "Seed
+//! derivation" remark: a key pair is one master secret, and a signer holds the
+//! 1024-byte layer-0 cache of its "Signer state" remark.
+
+#![cfg_attr(not(test), warn(unused_crate_dependencies))]
+
+mod hash;
+pub use hash::*;
+mod ots;
+pub use ots::*;
+mod fts;
+pub use fts::*;
+mod sphincs;
+pub use sphincs::*;
+
+/// `n`: hash value and Merkle node length, in bytes.
+pub const N: usize = 16;
+pub type Digest = [u8; N];
+
+/// The public parameter, sampled per key pair, which separates users.
+pub const PUBLIC_PARAM_LEN: usize = 16;
+pub type PublicParam = [u8; PUBLIC_PARAM_LEN];
+
+/// The per-signature randomizer the message digest is computed under.
+pub const RANDOMIZER_LEN: usize = 16;
+pub type Randomizer = [u8; RANDOMIZER_LEN];
+
+/// The message to sign (a 256-bit message hash).
+pub const MESSAGE_LEN: usize = 32;
+pub type Message = [u8; MESSAGE_LEN];
+
+/// The serialized width of an encoding counter.
+pub const COUNTER_LEN: usize = 4;
+
+// The one-time signature.
+/// `w`: chunk size in bits.
+pub const W: usize = 3;
+/// `2^w`: one more than the steps of a hash chain.
+pub const CHAIN_LEN: usize = 1 << W;
+/// `v`: code length, one hash chain per chunk.
+pub const V: usize = 42;
+/// `T`: the sum every codeword has. Above the mean `v(2^w-1)/2 = 147`, so
+/// verification walks fewer chain steps and the signer grinds a counter for it.
+pub const TARGET_SUM: usize = 191;
+
+// The hypertree.
+/// `d`: hypertree layers, numbered from the top.
+pub const D: usize = 3;
+/// `h_lay`: the Merkle tree height of each layer.
+pub const HEIGHTS: [usize; D] = [12, 7, 7];
+/// `h`: total height, so `2^h` few-time keys.
+pub const H: usize = 26;
+
+// The few-time signature.
+/// `a`: log2 of the leaves in one few-time tree.
+pub const A: usize = 10;
+/// `k`: digest index groups.
+pub const K: usize = 15;
+/// The forest holds `k-1` trees: the tree of the last digest index carries no
+/// information, that index being ground to zero (FORS$^+$C).
+pub const NUM_FTS_TREES: usize = K - 1;
+
+/// `A_max`: digest attempts per signature.
+pub const MAX_DIGEST_ATTEMPTS: u64 = 1 << 32;
+/// `C_max`: encoding attempts per layer.
+pub const MAX_ENCODING_ATTEMPTS: u64 = 1 << 32;
+
+/// `h + ka`: the message digest's width, all of it consumed by the index and the
+/// `k` leaf indices.
+pub const DIGEST_BITS: usize = H + K * A;
+pub const DIGEST_BYTES: usize = DIGEST_BITS / 8;
+
+pub const PUB_KEY_SIZE: usize = N + PUBLIC_PARAM_LEN;
+pub const SIG_SIZE: usize = RANDOMIZER_LEN + NUM_FTS_TREES * (1 + A) * N + D * (COUNTER_LEN + V * N) + H * N;
+
+/// Calls to the hash function one verification makes: the digest, `Fts.recover`,
+/// `d` times `Ots.leaf`, and `Tree.fold`.
+pub const VERIFY_HASHES: usize = 1 + (NUM_FTS_TREES * (1 + A) + 1) + D * (V * (CHAIN_LEN - 1) - TARGET_SUM + 2) + H;
+
+const _: () = assert!(H == HEIGHTS[0] + HEIGHTS[1] + HEIGHTS[2]);
+// Each half of an encoding digest holds `v/2` chunks and one pinned bit.
+const _: () = assert!(W * V / 2 + 1 == 64);
+const _: () = assert!(DIGEST_BITS == DIGEST_BYTES * 8);
+const _: () = assert!(TARGET_SUM < V * (CHAIN_LEN - 1));
+const _: () = assert!(PUB_KEY_SIZE == 32);
+const _: () = assert!(SIG_SIZE == 4924);
+const _: () = assert!(VERIFY_HASHES == 497);
diff --git a/crates/sphincs/src/ots.rs b/crates/sphincs/src/ots.rs
new file mode 100644
index 000000000..894c9966a
--- /dev/null
+++ b/crates/sphincs/src/ots.rs
@@ -0,0 +1,103 @@
+//! The one-time signature: `v` hash chains of `2^w - 1` steps, and the
+//! target-sum code that replaces the Winternitz checksum (WOTS+C).
+//!
+//! A codeword is `v` chunks summing to `T`. Two distinct words of equal sum
+//! cannot be ordered componentwise, so revealing chain position `x_i` on every
+//! chain gives a forger nothing: any other codeword needs a value above one of
+//! the revealed ones. The price is that most messages do not encode into the
+//! code at all, hence the counter the signer searches for and the signature
+//! carries.
+
+use crate::*;
+
+/// One one-time key's position: the layer, the tree within it, the leaf within
+/// that tree.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct Pos {
+ pub lay: usize,
+ pub tau: u32,
+ pub e: u32,
+}
+
+impl Pos {
+ pub const fn new(lay: usize, tau: u32, e: u32) -> Self {
+ Self { lay, tau, e }
+ }
+}
+
+/// `sk_{lay,tau,e,i} = Th(P, tw_prf(lay,tau,i,e), S)`.
+pub fn ots_secret(pp: &PublicParam, master: &Digest, pos: Pos, i: usize) -> Digest {
+ th(pp, &tweak(TWEAK_PRF, pos.lay, pos.tau, i as u32, pos.e), master)
+}
+
+/// `Chain_{lay,tau,e,i}(P, start, steps, value)`: the step onto position `s` is
+/// hashed under the tweak of the edge into it.
+pub fn chain(pp: &PublicParam, pos: Pos, i: usize, start: usize, steps: usize, value: Digest) -> Digest {
+ debug_assert!(start + steps < CHAIN_LEN);
+ (1..=steps).fold(value, |current, step| {
+ let p = (CHAIN_LEN * i + start + step - 1) as u32;
+ th(pp, &tweak(TWEAK_CHAIN, pos.lay, pos.tau, p, pos.e), ¤t)
+ })
+}
+
+/// The Merkle leaf of a one-time key: `Th` over its `v` chain tips.
+pub fn ots_leaf_hash(pp: &PublicParam, pos: Pos, tips: &[Digest; V]) -> Digest {
+ th_digests(pp, &tweak(TWEAK_LEAF, pos.lay, pos.tau, 0, pos.e), tips)
+}
+
+/// `Enc(P, lay, tau, e, M, c)`: the codeword, or `None` if the digest of that
+/// counter is not admissible.
+pub fn encode(pp: &PublicParam, pos: Pos, m: &Digest, c: u32) -> Option<[u8; V]> {
+ let mut payload = [0u8; N + COUNTER_LEN];
+ payload[..N].copy_from_slice(m);
+ payload[N..].copy_from_slice(&c.to_le_bytes());
+ codeword(&th(pp, &tweak(TWEAK_ENC, pos.lay, pos.tau, 0, pos.e), &payload))
+}
+
+/// Each 64-bit half of the digest holds `v/2` chunks of `w` bits and one pinned
+/// top bit; pinning it is what makes the codeword determine the digest.
+fn codeword(digest: &Digest) -> Option<[u8; V]> {
+ let mut x = [0u8; V];
+ let mut sum = 0;
+ for (q, half) in digest.chunks_exact(N / 2).enumerate() {
+ let d = u64::from_le_bytes(half.try_into().unwrap());
+ if d >> (W * V / 2) != 0 {
+ return None;
+ }
+ for r in 0..V / 2 {
+ let chunk = ((d >> (W * r)) & (CHAIN_LEN as u64 - 1)) as u8;
+ x[q * (V / 2) + r] = chunk;
+ sum += chunk as usize;
+ }
+ }
+ (sum == TARGET_SUM).then_some(x)
+}
+
+/// `Ots.sign`: the LEAST admissible counter, and the chain value each chunk
+/// opens. Deterministic in its inputs, which is what keeps one key to one
+/// codeword: a resumed or randomized search would leak two incomparable
+/// codewords and drop forgery to about `2^53`.
+pub fn ots_sign(pp: &PublicParam, master: &Digest, pos: Pos, m: &Digest) -> Option<(u32, [Digest; V])> {
+ let (c, x) = (0..MAX_ENCODING_ATTEMPTS).find_map(|c| encode(pp, pos, m, c as u32).map(|x| (c as u32, x)))?;
+ let signature = std::array::from_fn(|i| chain(pp, pos, i, 0, x[i] as usize, ots_secret(pp, master, pos, i)));
+ Some((c, signature))
+}
+
+/// `Ots.leaf`: the leaf a claimed signature recovers, or `None` if its counter
+/// is not admissible for `m`. Does not touch the secrets, which is why it is the
+/// verifier's half.
+pub fn ots_leaf(pp: &PublicParam, pos: Pos, m: &Digest, c: u32, signature: &[Digest; V]) -> Option {
+ let x = encode(pp, pos, m, c)?;
+ let tips = std::array::from_fn(|i| {
+ let start = x[i] as usize;
+ chain(pp, pos, i, start, CHAIN_LEN - 1 - start, signature[i])
+ });
+ Some(ots_leaf_hash(pp, pos, &tips))
+}
+
+/// The leaf of the one-time key at `pos`, from the master secret: what key
+/// generation and every tree rebuild spend their hashes on.
+pub fn ots_public_leaf(pp: &PublicParam, master: &Digest, pos: Pos) -> Digest {
+ let tips = std::array::from_fn(|i| chain(pp, pos, i, 0, CHAIN_LEN - 1, ots_secret(pp, master, pos, i)));
+ ots_leaf_hash(pp, pos, &tips)
+}
diff --git a/crates/sphincs/src/sphincs.rs b/crates/sphincs/src/sphincs.rs
new file mode 100644
index 000000000..00dbdd164
--- /dev/null
+++ b/crates/sphincs/src/sphincs.rs
@@ -0,0 +1,414 @@
+//! The hypertree and the three algorithms: `d` layers of Merkle trees over
+//! one-time leaves, the bottom layer signing few-time keys, layer 0's root being
+//! the public key.
+//!
+//! An index derived from the message digest says which few-time key signs, and
+//! with it which tree and which leaf are used on every layer. Nothing is
+//! reserved and nothing is spent: a key answers for all `2^h` indices, which is
+//! what makes the scheme stateless.
+
+use rand::{CryptoRng, Rng};
+use serde::{Deserialize, Serialize};
+
+use crate::*;
+
+/// `SUFFIX[lay] = sum_{j >= lay} h_j`, the height of everything at or below
+/// layer `lay`: the divisors of the index decomposition.
+const fn suffix_heights() -> [usize; D + 1] {
+ let mut suffix = [0; D + 1];
+ let mut lay = D;
+ while lay > 0 {
+ lay -= 1;
+ suffix[lay] = suffix[lay + 1] + HEIGHTS[lay];
+ }
+ suffix
+}
+pub const SUFFIX: [usize; D + 1] = suffix_heights();
+const _: () = assert!(SUFFIX[0] == H);
+
+/// The layer-0 depth whose nodes a signer caches, halfway up so that the subtree
+/// to rebuild and the nodes to refold are both `2^(h_0/2)`.
+pub const SPLIT_LEVEL: usize = HEIGHTS[0].div_ceil(2);
+pub const CACHE_LEN: usize = 1 << (HEIGHTS[0] - SPLIT_LEVEL);
+const _: () = assert!(CACHE_LEN * N == 1024);
+
+/// `tau_lay(idx)`: the tree used on layer `lay`.
+pub fn tree_of(idx: u64, lay: usize) -> u32 {
+ (idx >> SUFFIX[lay]) as u32
+}
+
+/// `e_lay(idx)`: the leaf used within that tree.
+pub fn leaf_of(idx: u64, lay: usize) -> u32 {
+ ((idx >> SUFFIX[lay + 1]) & ((1 << HEIGHTS[lay]) - 1)) as u32
+}
+
+/// Where layer `lay`'s siblings sit in a signature's flat path.
+pub fn path_range(lay: usize) -> std::ops::Range {
+ let start: usize = HEIGHTS[..lay].iter().sum();
+ start..start + HEIGHTS[lay]
+}
+
+/// Ordered lexicographically on [`Self::flatten`], which is what an aggregate's
+/// signer list is sorted and deduplicated by.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct PublicKey {
+ pub root: Digest,
+ pub public_param: PublicParam,
+}
+
+impl PublicKey {
+ pub fn flatten(&self) -> [u8; PUB_KEY_SIZE] {
+ let mut out = [0; PUB_KEY_SIZE];
+ out[..N].copy_from_slice(&self.root);
+ out[N..].copy_from_slice(&self.public_param);
+ out
+ }
+
+ pub fn from_bytes(bytes: &[u8; PUB_KEY_SIZE]) -> Self {
+ Self {
+ root: bytes[..N].try_into().unwrap(),
+ public_param: bytes[N..].try_into().unwrap(),
+ }
+ }
+}
+
+/// `P`, the root, and the master secret every secret is derived from, plus
+/// layer 0's nodes at [`SPLIT_LEVEL`]. Those nodes are a cache and not state: a
+/// deterministic function of the master secret, so losing them costs
+/// recomputation and nothing else.
+#[derive(Clone, Debug)]
+pub struct SecretKey {
+ pub public_param: PublicParam,
+ pub root: Digest,
+ master: Digest,
+ cache: [Digest; CACHE_LEN],
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Signature {
+ pub randomizer: Randomizer,
+ pub fts: FtsOpening,
+ pub counters: [u32; D],
+ pub ots: [[Digest; V]; D],
+ /// Layer 0's `h_0` siblings, then layer 1's, then layer 2's.
+ pub paths: [Digest; H],
+}
+
+impl Signature {
+ /// The specification's serialization, exactly [`SIG_SIZE`] bytes.
+ pub fn to_bytes(&self) -> [u8; SIG_SIZE] {
+ let mut out = [0; SIG_SIZE];
+ let mut at = 0;
+ let mut put = |bytes: &[u8]| {
+ out[at..at + bytes.len()].copy_from_slice(bytes);
+ at += bytes.len();
+ };
+ put(&self.randomizer);
+ for kappa in 0..NUM_FTS_TREES {
+ put(&self.fts.secrets[kappa]);
+ for sibling in &self.fts.paths[kappa] {
+ put(sibling);
+ }
+ }
+ for lay in 0..D {
+ put(&self.counters[lay].to_le_bytes());
+ for value in &self.ots[lay] {
+ put(value);
+ }
+ for sibling in &self.paths[path_range(lay)] {
+ put(sibling);
+ }
+ }
+ debug_assert_eq!(at, SIG_SIZE);
+ out
+ }
+
+ pub fn from_bytes(bytes: &[u8; SIG_SIZE]) -> Self {
+ let mut at = 0;
+ let mut take = |len: usize| {
+ at += len;
+ &bytes[at - len..at]
+ };
+ let randomizer = take(RANDOMIZER_LEN).try_into().unwrap();
+ let mut fts = FtsOpening {
+ secrets: [[0; N]; NUM_FTS_TREES],
+ paths: [[[0; N]; A]; NUM_FTS_TREES],
+ };
+ for kappa in 0..NUM_FTS_TREES {
+ fts.secrets[kappa] = take(N).try_into().unwrap();
+ for level in 0..A {
+ fts.paths[kappa][level] = take(N).try_into().unwrap();
+ }
+ }
+ let mut counters = [0; D];
+ let mut ots = [[[0; N]; V]; D];
+ let mut paths = [[0; N]; H];
+ for lay in 0..D {
+ counters[lay] = u32::from_le_bytes(take(COUNTER_LEN).try_into().unwrap());
+ for i in 0..V {
+ ots[lay][i] = take(N).try_into().unwrap();
+ }
+ for level in path_range(lay) {
+ paths[level] = take(N).try_into().unwrap();
+ }
+ }
+ debug_assert_eq!(at, SIG_SIZE);
+ Self {
+ randomizer,
+ fts,
+ counters,
+ ots,
+ paths,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum SignError {
+ /// `A_max` digests in a row had a nonzero last index.
+ NoAdmissibleDigest,
+ /// `C_max` counters in a row failed to encode.
+ NoAdmissibleEncoding,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum VerifyError {
+ /// The digest's last index is not zero.
+ InadmissibleDigest,
+ /// A layer's counter does not encode the message it signs.
+ InadmissibleEncoding,
+ RootMismatch,
+}
+
+/// The message digest, read as the index and the `k` leaf indices. `h + ka` bits
+/// of a random oracle output, so the index and the last leaf index are disjoint
+/// and grinding one does not bias the other.
+pub fn message_digest(pp: &PublicParam, root: &Digest, rho: &Randomizer, m: &Message) -> (u64, [u32; K]) {
+ let mut hasher = primitives::hash::Hasher::new();
+ hasher
+ .update(&tweak(TWEAK_MSG, 0, 0, 0, 0))
+ .update(pp)
+ .update(rho)
+ .update(root)
+ .update(m);
+ let digest = &hasher.finalize()[..DIGEST_BYTES];
+ let field = |offset: usize, len: usize| {
+ (0..len).fold(0u64, |value, bit| {
+ let position = offset + bit;
+ value | (u64::from(digest[position / 8] >> (position % 8) & 1) << bit)
+ })
+ };
+ (field(0, H), std::array::from_fn(|kappa| field(H + kappa * A, A) as u32))
+}
+
+fn node(pp: &PublicParam, lay: usize, tau: u32, level: usize, j: u64, left: &Digest, right: &Digest) -> Digest {
+ let tw = tweak(TWEAK_NODE, lay, tau, level as u32, j as u32);
+ th_digests(pp, &tw, &[*left, *right])
+}
+
+/// Merkle levels `from_level..=to_level` of layer `lay`'s tree `tau`, given
+/// `bottom`, the complete band of level-`from_level` nodes starting at index
+/// `first`. Level `l` is `layers[l - from_level]`.
+fn build_up(
+ pp: &PublicParam,
+ lay: usize,
+ tau: u32,
+ bottom: Vec,
+ from_level: usize,
+ to_level: usize,
+ first: u64,
+) -> Vec> {
+ let mut layers = vec![bottom];
+ for level in from_level + 1..=to_level {
+ let base = first >> (level - from_level);
+ let children = layers.last().unwrap();
+ layers.push(
+ (0..children.len() / 2)
+ .map(|j| {
+ node(
+ pp,
+ lay,
+ tau,
+ level,
+ base + j as u64,
+ &children[2 * j],
+ &children[2 * j + 1],
+ )
+ })
+ .collect(),
+ );
+ }
+ layers
+}
+
+/// `Gen`, on given `P` and master secret. Only layer 0 is built; the trees below
+/// it are built when a signature needs them.
+pub fn key_gen_from(public_param: PublicParam, master: Digest) -> (SecretKey, PublicKey) {
+ let leaves = parallel::map_collect(1 << HEIGHTS[0], |e| {
+ ots_public_leaf(&public_param, &master, Pos::new(0, 0, e as u32))
+ });
+ let layers = build_up(&public_param, 0, 0, leaves, 0, HEIGHTS[0], 0);
+ let root = layers[HEIGHTS[0]][0];
+ let cache = std::array::from_fn(|i| layers[SPLIT_LEVEL][i]);
+ (
+ SecretKey {
+ public_param,
+ root,
+ master,
+ cache,
+ },
+ PublicKey { root, public_param },
+ )
+}
+
+/// `Gen`: samples `P` and the master secret independently.
+pub fn key_gen(rng: &mut impl CryptoRng) -> (SecretKey, PublicKey) {
+ key_gen_from(rng.random(), rng.random())
+}
+
+impl SecretKey {
+ pub fn public_key(&self) -> PublicKey {
+ PublicKey {
+ root: self.root,
+ public_param: self.public_param,
+ }
+ }
+
+ /// Layer `lay`'s tree `tau` rebuilt whole: the siblings at `e` into `path`,
+ /// and the root.
+ fn tree_path_and_root(&self, lay: usize, tau: u32, e: u32, path: &mut [Digest]) -> Digest {
+ debug_assert_eq!(path.len(), HEIGHTS[lay]);
+ let leaves = (0..1 << HEIGHTS[lay])
+ .map(|leaf| ots_public_leaf(&self.public_param, &self.master, Pos::new(lay, tau, leaf)))
+ .collect();
+ let layers = build_up(&self.public_param, lay, tau, leaves, 0, HEIGHTS[lay], 0);
+ for (level, sibling) in path.iter_mut().enumerate() {
+ *sibling = layers[level][((e >> level) ^ 1) as usize];
+ }
+ layers[HEIGHTS[lay]][0]
+ }
+
+ /// Layer 0's siblings at `e`, from the cache: one `2^SPLIT_LEVEL`-leaf
+ /// subtree rebuilt below it, the cached nodes refolded above it. Returns the
+ /// root, which the cache reproduces.
+ fn cached_path_and_root(&self, e: u32, path: &mut [Digest]) -> Digest {
+ debug_assert_eq!(path.len(), HEIGHTS[0]);
+ let first = u64::from(e >> SPLIT_LEVEL) << SPLIT_LEVEL;
+ let leaves = (first..first + (1 << SPLIT_LEVEL))
+ .map(|leaf| ots_public_leaf(&self.public_param, &self.master, Pos::new(0, 0, leaf as u32)))
+ .collect();
+ let below = build_up(&self.public_param, 0, 0, leaves, 0, SPLIT_LEVEL, first);
+ let above = build_up(
+ &self.public_param,
+ 0,
+ 0,
+ self.cache.to_vec(),
+ SPLIT_LEVEL,
+ HEIGHTS[0],
+ 0,
+ );
+ debug_assert_eq!(below[SPLIT_LEVEL][0], self.cache[(first >> SPLIT_LEVEL) as usize]);
+ for (level, sibling) in path.iter_mut().enumerate() {
+ let index = u64::from(e >> level) ^ 1;
+ *sibling = if level < SPLIT_LEVEL {
+ below[level][(index - (first >> level)) as usize]
+ } else {
+ above[level - SPLIT_LEVEL][index as usize]
+ };
+ }
+ above[HEIGHTS[0] - SPLIT_LEVEL][0]
+ }
+}
+
+/// `Sig`. Stateless: it may be called on any message any number of times, but
+/// security degrades with that number, the specification's claim being stated at
+/// `2^24` signatures per key pair.
+pub fn sign(rng: &mut impl CryptoRng, sk: &SecretKey, message: &Message) -> Result {
+ // The digest is admissible when its last leaf index is zero, which is what
+ // drops that tree from the forest; it takes 2^a attempts on average.
+ let (randomizer, idx, u) = (0..MAX_DIGEST_ATTEMPTS)
+ .find_map(|_| {
+ let randomizer: Randomizer = rng.random();
+ let (idx, u) = message_digest(&sk.public_param, &sk.root, &randomizer, message);
+ (u[K - 1] == 0).then_some((randomizer, idx, u))
+ })
+ .ok_or(SignError::NoAdmissibleDigest)?;
+
+ let (fts_key, fts) = fts_open(&sk.public_param, &sk.master, idx, &u);
+
+ let mut message_of_layer = fts_key;
+ let mut counters = [0; D];
+ let mut ots = [[[0; N]; V]; D];
+ let mut paths = [[0; N]; H];
+ for lay in (0..D).rev() {
+ let (tau, e) = (tree_of(idx, lay), leaf_of(idx, lay));
+ let pos = Pos::new(lay, tau, e);
+ let (c, signature) =
+ ots_sign(&sk.public_param, &sk.master, pos, &message_of_layer).ok_or(SignError::NoAdmissibleEncoding)?;
+ counters[lay] = c;
+ ots[lay] = signature;
+ let path = &mut paths[path_range(lay)];
+ message_of_layer = if lay == 0 {
+ sk.cached_path_and_root(e, path)
+ } else {
+ sk.tree_path_and_root(lay, tau, e, path)
+ };
+ }
+ // Layer 0's root is discarded: it is the public key's whenever the signer is
+ // honest, which is also the only check the cache gets.
+ debug_assert_eq!(message_of_layer, sk.root);
+
+ Ok(Signature {
+ randomizer,
+ fts,
+ counters,
+ ots,
+ paths,
+ })
+}
+
+/// `Tree.fold`: the other half of a Merkle opening.
+pub fn tree_fold(pp: &PublicParam, pos: Pos, leaf: Digest, path: &[Digest]) -> Digest {
+ path.iter().enumerate().fold(leaf, |current, (level, sibling)| {
+ let (left, right) = if (pos.e >> level) & 1 == 0 {
+ (current, *sibling)
+ } else {
+ (*sibling, current)
+ };
+ node(
+ pp,
+ pos.lay,
+ pos.tau,
+ level + 1,
+ u64::from(pos.e >> (level + 1)),
+ &left,
+ &right,
+ )
+ })
+}
+
+/// `Ver`.
+pub fn verify(pk: &PublicKey, message: &Message, signature: &Signature) -> Result<(), VerifyError> {
+ let (idx, u) = message_digest(&pk.public_param, &pk.root, &signature.randomizer, message);
+ if u[K - 1] != 0 {
+ return Err(VerifyError::InadmissibleDigest);
+ }
+ let mut message_of_layer = fts_recover(&pk.public_param, idx, &u, &signature.fts);
+ for lay in (0..D).rev() {
+ let pos = Pos::new(lay, tree_of(idx, lay), leaf_of(idx, lay));
+ let leaf = ots_leaf(
+ &pk.public_param,
+ pos,
+ &message_of_layer,
+ signature.counters[lay],
+ &signature.ots[lay],
+ )
+ .ok_or(VerifyError::InadmissibleEncoding)?;
+ message_of_layer = tree_fold(&pk.public_param, pos, leaf, &signature.paths[path_range(lay)]);
+ }
+ if message_of_layer == pk.root {
+ Ok(())
+ } else {
+ Err(VerifyError::RootMismatch)
+ }
+}
diff --git a/crates/sphincs/tests/sphincs_tests.rs b/crates/sphincs/tests/sphincs_tests.rs
new file mode 100644
index 000000000..63739f059
--- /dev/null
+++ b/crates/sphincs/tests/sphincs_tests.rs
@@ -0,0 +1,171 @@
+use rand::{Rng, SeedableRng, rngs::StdRng};
+use sphincs::*;
+
+fn test_message() -> Message {
+ std::array::from_fn(|i| (i * 5 + 3) as u8)
+}
+
+fn test_key(seed: u64) -> (SecretKey, PublicKey) {
+ key_gen(&mut StdRng::seed_from_u64(seed))
+}
+
+#[test]
+fn keygen_sign_verify() {
+ let (sk, pk) = test_key(0);
+ assert_eq!(sk.public_key(), pk);
+ let message = test_message();
+ for round in 0..2 {
+ let signature = sign(&mut StdRng::seed_from_u64(round), &sk, &message).unwrap();
+ verify(&pk, &message, &signature).unwrap();
+ }
+}
+
+#[test]
+fn serialized_sizes_and_roundtrip() {
+ let (sk, pk) = test_key(1);
+ let message = test_message();
+ let signature = sign(&mut StdRng::seed_from_u64(7), &sk, &message).unwrap();
+
+ let public_key_bytes = pk.flatten();
+ assert_eq!(public_key_bytes.len(), 32);
+ assert_eq!(PublicKey::from_bytes(&public_key_bytes), pk);
+
+ let signature_bytes = signature.to_bytes();
+ assert_eq!(signature_bytes.len(), 4924);
+ let decoded = Signature::from_bytes(&signature_bytes);
+ assert_eq!(decoded, signature);
+ verify(&pk, &message, &decoded).unwrap();
+}
+
+#[test]
+fn tampered_signatures_rejected() {
+ let (sk, pk) = test_key(2);
+ let message = test_message();
+ let signature = sign(&mut StdRng::seed_from_u64(3), &sk, &message).unwrap();
+ verify(&pk, &message, &signature).unwrap();
+
+ let mut other_message = message;
+ other_message[0] ^= 1;
+ assert!(verify(&pk, &other_message, &signature).is_err());
+
+ let mut other_key = pk;
+ other_key.root[0] ^= 1;
+ assert!(verify(&other_key, &message, &signature).is_err());
+
+ // Verification recomputes the digest, so a tampered randomizer asks for
+ // another index, and asks it of a digest that is admissible only one time in
+ // 2^a.
+ let mut tampered = signature.clone();
+ tampered.randomizer[0] ^= 1;
+ assert_eq!(verify(&pk, &message, &tampered), Err(VerifyError::InadmissibleDigest));
+
+ // Everything the bottom layers carry feeds the message a layer above signs,
+ // and a counter is admissible for one message in 2^13.6, so tampering
+ // surfaces as an inadmissible encoding rather than as a wrong root.
+ for tamper in [
+ (|s: &mut Signature| s.fts.secrets[5][0] ^= 1) as fn(&mut Signature),
+ |s: &mut Signature| s.fts.paths[9][4][0] ^= 1,
+ |s: &mut Signature| s.counters[2] ^= 1,
+ |s: &mut Signature| s.ots[1][17][0] ^= 1,
+ |s: &mut Signature| s.paths[H - 1][0] ^= 1,
+ ] {
+ let mut tampered = signature.clone();
+ tamper(&mut tampered);
+ assert_eq!(verify(&pk, &message, &tampered), Err(VerifyError::InadmissibleEncoding));
+ }
+
+ // Layer 0's path is the exception: nothing is signed above it, so it can
+ // only fail the root comparison.
+ let mut tampered = signature.clone();
+ tampered.paths[0][0] ^= 1;
+ assert_eq!(verify(&pk, &message, &tampered), Err(VerifyError::RootMismatch));
+
+ let mut tampered = signature.clone();
+ tampered.ots[0][17][0] ^= 1;
+ assert_eq!(verify(&pk, &message, &tampered), Err(VerifyError::RootMismatch));
+}
+
+/// One key signs one codeword, on which the whole one-time argument rests: the
+/// counter is the least admissible one, not any admissible one.
+#[test]
+fn ots_counter_is_the_least_admissible() {
+ let mut rng = StdRng::seed_from_u64(4);
+ let public_param: PublicParam = rng.random();
+ let master: Digest = rng.random();
+ let pos = Pos::new(2, 1234, 56);
+ let message: Digest = rng.random();
+
+ let (counter, signature) = ots_sign(&public_param, &master, pos, &message).unwrap();
+ assert!((0..counter).all(|c| encode(&public_param, pos, &message, c).is_none()));
+ assert_eq!(
+ ots_leaf(&public_param, pos, &message, counter, &signature),
+ Some(ots_public_leaf(&public_param, &master, pos))
+ );
+}
+
+#[test]
+fn index_decomposition_is_a_bijection_onto_the_bottom_layer() {
+ let mut rng = StdRng::seed_from_u64(5);
+ for _ in 0..1000 {
+ let idx = rng.random::() % (1 << H);
+ // Every layer's tree is the one whose root sits at the leaf its parent
+ // layer uses.
+ for lay in 1..D {
+ let expected =
+ u64::from(tree_of(idx, lay - 1)) * (1 << HEIGHTS[lay - 1]) + u64::from(leaf_of(idx, lay - 1));
+ assert_eq!(u64::from(tree_of(idx, lay)), expected);
+ }
+ assert_eq!(tree_of(idx, 0), 0);
+ assert_eq!(
+ u64::from(tree_of(idx, D - 1)) * (1 << HEIGHTS[D - 1]) + u64::from(leaf_of(idx, D - 1)),
+ idx
+ );
+ }
+}
+
+/// The counter search and the digest resampling are the signer's two grinding
+/// loops; both costs are a property of the predicates, so a drift here is a
+/// change of scheme.
+#[test]
+#[ignore]
+fn grinding_bits() {
+ let mut rng = StdRng::seed_from_u64(6);
+ let public_param: PublicParam = rng.random();
+ let master: Digest = rng.random();
+
+ let samples = 200;
+ let counters: u64 = (0..samples)
+ .map(|i| {
+ let message: Digest = rng.random();
+ let pos = Pos::new(i % D, i as u32, i as u32);
+ u64::from(ots_sign(&public_param, &master, pos, &message).unwrap().0)
+ })
+ .sum();
+ // A codeword is one admissible digest, so 1/p is the number of them over
+ // 2^128: 2^13.60 for T = 191.
+ let encoding_bits = ((counters as f64 / samples as f64) + 1.0).log2();
+ println!("counter search: 2^{encoding_bits:.2} attempts");
+ assert!(
+ (12.6..14.6).contains(&encoding_bits),
+ "encoding cost moved: {encoding_bits:.2} bits"
+ );
+
+ let root: Digest = rng.random();
+ let message = test_message();
+ let mut attempts = 0u64;
+ for _ in 0..samples {
+ loop {
+ attempts += 1;
+ let randomizer: Randomizer = rng.random();
+ if message_digest(&public_param, &root, &randomizer, &message).1[K - 1] == 0 {
+ break;
+ }
+ }
+ }
+ let digest_bits = (attempts as f64 / samples as f64).log2();
+ println!("digest resampling: 2^{digest_bits:.2} attempts");
+ assert!(
+ ((A as f64 - 1.0)..(A as f64 + 1.0)).contains(&digest_bits),
+ "digest cost moved: {digest_bits:.2} bits"
+ );
+}
diff --git a/doc/leanvm/body/09-recursive-aggregation.tex b/doc/leanvm/body/09-recursive-aggregation.tex
index b91f4d39f..02ea65bbb 100644
--- a/doc/leanvm/body/09-recursive-aggregation.tex
+++ b/doc/leanvm/body/09-recursive-aggregation.tex
@@ -1,7 +1,9 @@
% !TeX root = ../drafts/09-recursive-aggregation.tex
\section{Recursion}\label{sec:recursion}
-A node proves it verified $n_{\mathrm{rec}}$ sub-proofs and $n_{\mathrm{raw}}$ XMSS signatures against one message and epoch, and publishes the sorted deduplicated union of their signer sets. The sub-proofs are proofs of the same bytecode, so recursion is self-reference: only the bytecode's \emph{size} needs a fixed point, its digest riding the public statement instead of the code. A parent rebuilds each child's statement, pinning it to the same bytecode, message and epoch, and writes one write-once cell per declared signer, holding a running count that is totalled at the end: every declared signer is therefore backed by a signature or by a verified child, and a key a child repeats takes a duplicate slot past the set, which merges overlapping committees to their union.
+A node proves it verified $n_{\mathrm{rec}}$ sub-proofs together with raw XMSS and SPHINCS signatures, and publishes the sorted deduplicated union of their signer sets as one list per scheme. The XMSS signers share a message and an epoch; a SPHINCS signer carries its own, so that list holds $(\text{key},\text{message})$ pairs, a key once per message it signed. The sub-proofs are proofs of the same bytecode, so recursion is self-reference: only the bytecode's \emph{size} needs a fixed point, its digest riding the public statement instead of the code. A parent rebuilds each child's statement, pinning it to the same bytecode, message and epoch, and writes one write-once cell per declared signer, holding a running count that is totalled at the end: every declared signer is therefore backed by a signature or by a verified child, and a key a child repeats takes a duplicate slot past its own scheme's declared keys, which merges overlapping committees to their union.
+
+XMSS's depend only on the public epoch, so its tweak table and Merkle decomposition bits are hinted once per proof and bound by a digest in the statement; SPHINCS's depend on the index its message digest picks, which is neither public nor shared between signers, so every tweak and Merkle bits has to be reconstructed.
\subsection{Deferred evaluation claims}\label{sec:deferred-claims}
diff --git a/doc/sphincs/latexmkrc b/doc/sphincs/latexmkrc
new file mode 100644
index 000000000..ec7c0e475
--- /dev/null
+++ b/doc/sphincs/latexmkrc
@@ -0,0 +1,4 @@
+$pdf_mode = 1;
+$out_dir = '.build';
+$bibtex_use = 2;
+$clean_ext = 'bbl synctex.gz';
diff --git a/doc/sphincs/main.tex b/doc/sphincs/main.tex
new file mode 100644
index 000000000..4f825a410
--- /dev/null
+++ b/doc/sphincs/main.tex
@@ -0,0 +1,467 @@
+% Build with: latexmk -pdf main.tex
+\documentclass[11pt]{article}
+
+\usepackage[T1]{fontenc}
+\usepackage{lmodern}
+\usepackage[margin=1in]{geometry}
+\usepackage{microtype}
+\usepackage{amsmath,amssymb,amsthm,mathtools}
+\usepackage{booktabs}
+\usepackage{enumitem}
+\usepackage{xcolor}
+\usepackage[colorlinks=true,linkcolor=blue!50!black,citecolor=blue!50!black,urlcolor=blue!50!black]{hyperref}
+
+\theoremstyle{definition}
+\newtheorem{definition}{Definition}[section]
+\theoremstyle{plain}
+\theoremstyle{remark}
+\newtheorem{remark}[definition]{Remark}
+
+\newcommand{\bits}[1]{\{0,1\}^{#1}}
+\newcommand{\getsr}{\stackrel{\$}{\gets}}
+\newcommand{\Th}{\mathsf{Th}}
+\newcommand{\Enc}{\mathsf{Enc}}
+\newcommand{\Digest}{\mathsf{Digest}}
+\newcommand{\Gen}{\mathsf{Gen}}
+\newcommand{\Sig}{\mathsf{Sig}}
+\newcommand{\Ver}{\mathsf{Ver}}
+\newcommand{\SIG}{\mathsf{SIG}}
+\newcommand{\Chain}{\mathsf{Chain}}
+\newcommand{\hash}{\mathsf{H}}
+\newcommand{\LE}{\mathsf{LE}}
+\newcommand{\Truncate}{\mathsf{Truncate}}
+\newcommand{\concat}{\mathbin\Vert}
+\newcommand{\sk}{\mathit{sk}}
+\newcommand{\pk}{\mathit{pk}}
+\newcommand{\rootnode}{\mathit{root}}
+\newcommand{\tw}{\mathit{tw}}
+\newcommand{\lmsg}{\ell_{\mathrm{msg}}}
+\newcommand{\lpar}{\ell_{\mathrm{p}}}
+\newcommand{\ltwk}{\ell_{\mathrm{t}}}
+\newcommand{\lrnd}{\ell_{\mathrm{rnd}}}
+\newcommand{\lctr}{\ell_{\mathrm{c}}}
+\newcommand{\qs}{q_{\mathrm{s}}}
+\newcommand{\amax}{A_{\max}}
+\newcommand{\cmax}{C_{\max}}
+\newcommand{\idx}{\mathit{idx}}
+\newcommand{\lay}{\mathit{lay}}
+\newcommand{\OtsSign}{\mathsf{Ots.sign}}
+\newcommand{\OtsLeaf}{\mathsf{Ots.leaf}}
+\newcommand{\TreeRoot}{\mathsf{Tree.root}}
+\newcommand{\TreePath}{\mathsf{Tree.path}}
+\newcommand{\TreeFold}{\mathsf{Tree.fold}}
+\newcommand{\FtsKey}{\mathsf{Fts.key}}
+\newcommand{\FtsOpen}{\mathsf{Fts.open}}
+\newcommand{\FtsRec}{\mathsf{Fts.recover}}
+
+\emergencystretch=1.5em
+
+\title{Example of a SPHINCS$^+$ variant}
+\author{}
+\date{}
+
+\begin{document}
+\maketitle
+
+\begin{abstract}
+
+We present, as an example, a SPHINCS$^+$-based signature with the following properties:
+
+\begin{itemize}
+ \item \textbf{stateless}: supporting up to $2^{24}$ signatures.
+ \item \textbf{NIST security level~1}~\cite{NISTPQC} (TODO prove it)
+ \item \textbf{public key: 32 bytes}.
+ \item \textbf{signature: 4924 bytes}.
+ \item \textbf{497 hashes per verification}.
+ \item signing costs 190K hashes with 1024 bytes of cached signer state, or 1.55M without.
+ \item \textbf{key generation costs 1.38M hashes}.
+\end{itemize}
+\end{abstract}
+
+The construction is SPHINCS$^+$~\cite{SPHINCSPLUS,FIPS205} with two of the optimizations surveyed in~\cite{KN25}, WOTS$^+$C and FORS$^+$C, both from~\cite{HK22C}; its third, PORS$^+$FP, is not used.
+
+\section{Definitions and notation}
+
+\begin{definition}[Signature scheme]
+A signature scheme is a tuple $\SIG=(\Gen,\Sig,\Ver)$, where $\Gen$ and $\Sig$ are randomized and $\Ver$ is deterministic:
+\[
+ \Gen\longrightarrow(\pk,\sk),\qquad
+ \Sig(\sk,m)\longrightarrow\sigma\in\Sigma\cup\{\bot\},\qquad
+ \Ver(\pk,m,\sigma)\longrightarrow\{0,1\},
+\]
+where $\Sigma$ is the signature space and $m\in\bits{\lmsg}$. Whenever $(\pk,\sk)$ is output by $\Gen$ and $\Sig(\sk,m)$ returns $\sigma\neq\bot$, correctness requires $\Ver(\pk,m,\sigma)=1$. $\Sig$ keeps no state and may be called on any message any number of times, but security degrades with that number: this specification is stated for at most $\qs$ signatures per key pair.
+\end{definition}
+
+Byte strings are concatenated with $\concat$. Bits and integer encodings are little endian. $\LE_r(a)$ is the unsigned $r$-bit encoding of $a$. All indices are zero based. Layers are numbered from the top: layer $0$ carries the public key, layer $d-1$ signs few-time keys.
+
+\begin{center}
+\begin{tabular}{@{}lll@{}}
+\toprule
+Symbol & Value & Meaning\\
+\midrule
+$n$ & $128$ bits & hash value and Merkle node length\\
+$\lpar$ & $128$ bits & public parameter length\\
+$\ltwk$ & $128$ bits & tweak length\\
+$\lmsg$ & $256$ bits & message length\\
+$\lrnd$ & $128$ bits & randomizer length\\
+$\lctr$ & $32$ bits & encoding counter length\\
+$w$ & $3$ & chunk size in bits\\
+$v$ & $42$ & code length\\
+$T$ & $191$ & target sum\\
+$d$ & $3$ & hypertree layers\\
+$(h_0,h_1,h_2)$ & $(12,7,7)$ & Merkle tree height of each layer\\
+$h$ & $26$ & total height, $h=\sum_\lay h_\lay$\\
+$a$ & $10$ & $\log_2$ of the leaves in one few-time tree\\
+$k$ & $15$ & digest index groups; the forest holds $k-1$ trees\\
+$\qs$ & $2^{24}$ & signatures per key pair\\
+$\amax$ & $2^{32}$ & maximum digest attempts per signature\\
+$\cmax$ & $2^{32}$ & maximum encoding attempts per layer\\
+\bottomrule
+\end{tabular}
+\end{center}
+
+Let $\hash:\bits{*}\to\bits{256}$ be a cryptographic hash function, and let $\Truncate_\nu$ keep the first $\nu$ bits of its output. The tweakable hash $\Th:\mathcal P\times\mathcal T\times\mathcal M\to\mathcal H$, with $\mathcal P=\bits{\lpar}$, $\mathcal T=\bits{\ltwk}$, $\mathcal M=\bits{*}$ and $\mathcal H=\bits{n}$, is
+\[
+ \Th(P,\tw,M)=\Truncate_n\!\left(\hash(\tw\concat P\concat M)\right).
+\]
+$\hash$ and the code $\mathcal C$ of Section~\ref{sec:ots} are those of~\cite{leanVMb}, with a different target sum.
+
+\begin{definition}[Tweak encoding]
+For one-byte $t$ and $\lay$, and unsigned 32-bit integers $\tau$, $p$ and $j$, define the 16-byte tweak
+\[
+ \mathsf{enc}(t,\lay,\tau,p,j)=\LE_8(t)\concat\LE_8(\lay)\concat\LE_{32}(\tau)\concat\LE_{32}(p)\concat\LE_{32}(j)\concat\LE_{16}(0),
+\]
+fourteen bytes of fields and two of padding. The byte-wide fields cap $d\leq256$ and $k\leq257$; the 32-bit fields are never near their range here.
+\end{definition}
+
+A tweak names one hash call in the whole structure, which is what lets a security argument treat each call separately. Inside the hypertree, $\lay$ is the layer and $\tau$ the tree within it; inside a few-time key, $\lay$ is the tree in the forest and $\tau$ the index $\idx$ that selects the instance. Define
+\[
+\begin{aligned}
+ \mathsf{tw}_{\mathrm{prf}}(\lay,\tau,i,e) &= \mathsf{enc}(0,\lay,\tau,i,e),\\
+ \mathsf{tw}_{\mathrm{chain}}(\lay,\tau,e,i,\mu) &= \mathsf{enc}(1,\lay,\tau,2^wi+\mu-1,e), &&0\leq i\lay}h_j}\right\rfloor\bmod 2^{h_\lay}.
+\]
+With $(h_0,h_1,h_2)=(12,7,7)$ the divisors are $2^{26},2^{14},2^{7}$ for $\tau$ and $2^{14},2^{7},2^{0}$ for $e$. Layer $0$ has $\tau_0=0$, its single tree being the public key, and layer $d-1$ has $e_{d-1}=\idx\bmod2^{h_{d-1}}$. The layers link through the same two functions,
+\[
+ \tau_\lay=\tau_{\lay-1}\cdot2^{h_{\lay-1}}+e_{\lay-1},
+\]
+so the tree used on layer $\lay$ is the one whose root sits at leaf $e_{\lay-1}$ of the tree used on layer $\lay-1$. Layer $\lay$ holds $2^{\sum_{j<\lay}h_j}$ trees of $2^{h_\lay}$ leaves, so $(\tau_\lay,e_\lay)$ takes $2^{\sum_{j\leq\lay}h_j}$ values, that is $2^{12}$, $2^{19}$ and $2^{26}$ here, the last putting the $2^h$ indices in bijection with the leaves of the bottom layer.
+
+\section{The one-time signature}
+\label{sec:ots}
+
+One position $(\lay,\tau,e)$ holds one one-time key: $v$ secret values, one per hash chain of $2^w-1$ steps. A signature on $M$ encodes $M$ into a codeword $x$ and reveals, on each chain, the value at position $x_i$; a verifier walks the remaining $2^w-1-x_i$ steps and recovers the public value. Forging on $M'\neq M$ needs a codeword $x'$ with $x'_i\geq x_i$ everywhere, so that every revealed value lies below what the forger needs, and otherwise a chain must be inverted. The codewords are therefore chosen to make that impossible:
+\[
+ \mathcal C=\left\{(x_0,\ldots,x_{v-1})\in\{0,\ldots,2^w - 1\}^{v}:\sum_{i=0}^{v-1}x_i=T\right\}.
+\]
+Two distinct words of equal sum cannot be ordered componentwise, so $x'\geq x$ forces $x'=x$: the code is incomparable. This is what removes the Winternitz checksum of the classical scheme, since the verifier checks the sum itself, and it is why a counter is needed, most messages not encoding into $\mathcal C$ at all. That counter is carried in the signature: WOTS$^+$C~\cite{HK22C}.
+
+\begin{definition}[Encoding]
+$\Enc(P,\lay,\tau,e,M,c)$ computes
+\[
+ D=\Th\!\left(P,\mathsf{tw}_{\mathrm{enc}}(\lay,\tau,e),M\concat\LE_{\lctr}(c)\right),
+\]
+writes $D=D_0\concat D_1$ with $D_0,D_1$ of $n/2$ bits, and lets $d_q$ be the integer encoded little endian by $D_q$. For $q\in\{0,1\}$ and $0\leq r {
- rec_aggregation::run_xmss_aggregation(n_signatures, cli.log_inv_rate, plan);
+ Command::Aggregate { xmss, sphincs } => {
+ rec_aggregation::run_aggregation(xmss, sphincs, cli.log_inv_rate, plan);
}
- Command::Recursion { n, xmss_per_leaf } => {
- rec_aggregation::run_recursion(n, xmss_per_leaf, cli.log_inv_rate, cli.tracing, plan);
+ Command::Recursion {
+ n,
+ xmss_per_leaf,
+ sphincs_per_leaf,
+ } => {
+ rec_aggregation::run_recursion(n, xmss_per_leaf, sphincs_per_leaf, cli.log_inv_rate, cli.tracing, plan);
}
Command::Fibonacci { n } => {
rec_aggregation::run_fibonacci(n, cli.log_inv_rate, plan);