From af4f331d05283df4c0183538765def97905b664e Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 11:44:21 +0200 Subject: [PATCH 01/31] doc/sphincs: parameter calculator for the Blockstream SPHINCS+ report A dependency-free port of costs.sage and security.sage from BlockstreamResearch/SPHINCS-Parameters, covering the WOTS/FORS schemes of "Hash-based Signature Schemes for Bitcoin" (SPX, W+C, W+C_F+C; PORS+FP left out). For one parameter set it reports classical security, signature size, and keygen / signing / verification cost in both hash calls and SHA-256 compressions. Two deliberate deviations from the report: - WOTS+C drops chains by pinning the top bits of the digest to zero rather than by forcing whole digits, the z_b variant the report offers as an alternative and the one doc/xmss/main.tex uses, so the digest is always a whole number of base-w chunks and nothing has to handle a partial digit. --chain-bits 3 reproduces that spec's geometry: 2 of 128 bits pinned, 42 chains. - Signing is also reported with the top XMSS tree's half top cached: keeping its nodes at depth ceil(h'/2) costs sqrt(2^h') of state and makes that tree's per-signature cost sqrt too. Only the top tree qualifies, being the one that does not move with the index, and BDS traversal does not apply because a stateless signer's leaves arrive in no order. --selftest checks the cost model against the frozen fixtures of that repo under both hash conventions, and against every WOTS/FORS row of the report's Tables 1 and 2: sizes, keygen, signing, verification and the Exp. Search column all reproduce. The tables' Sig (B) column is 16 bytes above what the current scripts compute (7856, the FIPS 205 value for SLH-DSA-128s, against the table's 7872); the scripts are right and the tables predate them. Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/sphincs_params.py | 774 ++++++++++++++++++++++++++++++++++ 1 file changed, 774 insertions(+) create mode 100755 doc/sphincs/sphincs_params.py diff --git a/doc/sphincs/sphincs_params.py b/doc/sphincs/sphincs_params.py new file mode 100755 index 000000000..6f9b2c4e1 --- /dev/null +++ b/doc/sphincs/sphincs_params.py @@ -0,0 +1,774 @@ +#!/usr/bin/env python3 +"""SPHINCS+ parameter calculator: security, signature size, and hash counts. + +Covers the WOTS-based / FORS-based schemes of "Hash-based Signature Schemes for +Bitcoin" (Kudinov, Nick, Blockstream Research, rev. 2025-12-05) and its scripts +at github.com/BlockstreamResearch/SPHINCS-Parameters: + + SPX plain SPHINCS+ (SLH-DSA): WOTS-TW + FORS + W+C WOTS+C (fixed digit sum, no checksum chains) + FORS + W+C_F+C WOTS+C + FORS+C (last FORS tree removed by grinding) + +PORS+FP is deliberately not implemented. + +WOTS+C shortens its signature by dropping chains, and this script does that the +way doc/xmss/main.tex does: it pins the top bits of the digest to zero instead +of forcing whole digits, so the digest is always a whole number of base-w chunks +(see the Encoding class). The default pins the minimum that makes the cut +integral; --drop-chains buys further chains at log2(w) pinned bits each, every +pinned bit doubling the expected grinding. + +For a parameter set it reports: + + * classical security in bits (FORS subset-forgery vs. preimage bound) + * signature size in bytes + * hashes at key generation + * expected hashes at signing + * hashes at verification + * expected hashes at signing with the top tree's "half top" cached, i.e. + keeping the nodes of the top XMSS tree at depth ceil(h'/2) as signer + state: sqrt(2^h') storage buys a sqrt(2^h') top-tree cost per signature + +Two units are reported for every cost, matching the report's tables: + + hashes tweakable-hash / PRF invocations (the report's "hash" columns) + compressions SHA-256 compression calls (the report's Compr. columns) + +The compression counts follow the FIPS 205 SHA-2 layout with the PK.seed +midstate cached; pass --uncached to charge every call for its full input. + +Numbers reproduce costs.sage / security.sage exactly; run --selftest to check +against the golden values frozen in that repo's tests/fixtures.json. +""" + +from __future__ import annotations + +import argparse +from dataclasses import asdict, dataclass +from decimal import Decimal, getcontext +from math import ceil, floor, log2 + +getcontext().prec = 120 + +SCHEMES = ("SPX", "W+C", "W+C_F+C") + +COUNTER_BYTES = 4 # WOTS+C grinding counter carried per hypertree layer + + +# --------------------------------------------------------------------------- +# Hash-cost conventions +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Convention: + """Compression calls charged to each kind of hash invocation.""" + + cached_midstate: bool = True + + @property + def th1(self) -> int: + return 1 # PK.seed + ADRS + one n-byte value + + @property + def th1c(self) -> int: + return 1 # ... + the 4-byte WOTS+C counter + + @property + def th2(self) -> int: + return 1 if self.cached_midstate else 2 # two n-byte children + + @property + def hmsg(self) -> int: + return 2 # PK.seed + PK.root + R + message digest + + @property + def prfmsg(self) -> int: + return 2 # SK.prf + opt + message + + @property + def prf(self) -> int: + return 1 # PK.seed + SK.seed + ADRS + + def th(self, m: int, n: int) -> int: + """Compressions for a tweakable hash over m n-byte values.""" + prefix = 22 * 8 if self.cached_midstate else 8 * (n + 12) + return ceil((prefix + 8 * n * m + 65) / 512) + + +# --------------------------------------------------------------------------- +# WOTS +# --------------------------------------------------------------------------- + + +def wots_len1(w: int, n: int) -> int: + """Message chains: enough base-w digits to carry an n-byte digest.""" + return ceil(8 * n / log2(w)) + + +def wots_len2(w: int, n: int) -> int: + """Checksum chains of WOTS-TW (FIPS 205 form).""" + l1 = wots_len1(w, n) + return floor(log2(l1 * (w - 1)) / log2(w)) + 1 + + +@dataclass(frozen=True) +class Encoding: + """How a WOTS+C digest is cut into base-w chain positions. + + The report drops chains by forcing their digits to zero (its parameter z). + This script instead uses the bit-pinning variant the report offers as an + alternative in "Complexity Analysis of WOTS+C" (its z_b), which is what + doc/xmss/main.tex does, because it keeps the digest a whole number of + chunks and needs no partial-digit handling anywhere: + + chain_bits = log2(w) bits one chain carries + pinned = (8n) mod chain_bits + chain_bits * dropped_chains + chains = (8n - pinned) / chain_bits = floor(8n/chain_bits) - dropped + + The signer grinds the counter until the digest has its `pinned` top bits + zero AND its `chains` digits summing to S_wn, so out of the 2^(8n) digests + exactly nu = |{tuples summing to S_wn}| are admissible. + + Pinning is not free: every pinned bit halves the admissible fraction, so + `pinned` bits multiply the expected grinding by 2^pinned. It buys chains + cheaply though. The default is the minimum that leaves 8n - pinned a + multiple of chain_bits, and what it saves is the extra, only partly used + chain that ceil(8n / chain_bits) would need: n bytes of signature for a + factor 2^(8n mod chain_bits), which at chain_bits = 3 is 16 bytes for 4x + on a per-layer grind of a few hundred hashes. Each further dropped chain + then saves another n bytes for a factor of about w. + + doc/xmss/main.tex is the (n=128, chain_bits=3) instance: 128 mod 3 = 2 bits + pinned, v = 42 chains, T = 195. Dropping one more chain there would pin + 2 + 3 = 5 bits and leave 41 chains. For every w the report itself uses + (16 and 256) chain_bits divides 128, so nothing is pinned and this + reproduces its numbers exactly. + """ + + w: int + n: int + dropped_chains: int = 0 + + @property + def chain_bits(self) -> int: + return int(log2(self.w)) + + @property + def pinned_bits(self) -> int: + return 8 * self.n % self.chain_bits + self.chain_bits * self.dropped_chains + + @property + def chains(self) -> int: + chains = (8 * self.n - self.pinned_bits) // self.chain_bits + if chains < 1: + raise ValueError(f"dropped_chains={self.dropped_chains} leaves no chain to sign") + return chains + + @property + def default_swn(self) -> int: + """Mean digit sum, where the admissible digests are densest.""" + return self.chains * (self.w - 1) // 2 + + def admissible(self, swn: int) -> int: + """nu: digests with the pinned bits zero and digits summing to swn.""" + return wots_c_encodings(self.chains, swn, self.w) + + def expected_trials(self, swn: int) -> int: + """Counter values tried per layer, 2^(8n) / nu by the geometric law.""" + return -(-(1 << (8 * self.n)) // self.admissible(swn)) + + +def wots_chains(scheme: str, w: int, n: int, dropped_chains: int = 0) -> int: + """Chains actually signed: l1 + l2 for WOTS-TW, the encoding's for WOTS+C.""" + if scheme == "SPX": + return wots_len1(w, n) + wots_len2(w, n) + return Encoding(w, n, dropped_chains).chains + + +def wots_c_encodings(l: int, swn: int, w: int) -> int: + """nu: number of l-tuples over [0, w-1] summing to exactly swn.""" + from math import comb + + nu = 0 + for j in range(l + 1): + top = (swn + l) - j * w - 1 + nu += (-1) ** j * comb(l, j) * (comb(top, l - 1) if top >= l - 1 else 0) + if nu <= 0: + raise ValueError(f"no encoding of {l} base-{w} digits sums to S_wn={swn}") + return nu + + +def wots_tw_worst_steps(w: int, n: int) -> int: + """Verifier chain steps for WOTS-TW when every message digit is zero.""" + l1, l2 = wots_len1(w, n), wots_len2(w, n) + c, ds = l1 * (w - 1), 0 + rem = c + while rem: + ds += rem % w + rem //= w + return l1 * (w - 1) + l2 * (w - 1) - ds + + +# --------------------------------------------------------------------------- +# Classical security +# --------------------------------------------------------------------------- + + +def fors_forgery_exponent(q_s_log2: int, h: int, k: int, a: int, r_cap: int = 1 << 18) -> float: + """-log2 P(FORS subset forgery) after q_s = 2^q_s_log2 signatures. + + An adversary that finds a hypertree leaf reused r times, and a message whose + k FORS indices all point at leaves those r signatures already opened, forges + without inverting anything: + + P = sum_r C(q_s, r) p^r (1-p)^(q_s-r) * (1 - (1 - 1/t)^r)^k + + with p = 2^-h the chance one signature lands on a given leaf and t = 2^a. + The binomial term is carried by its recurrence rather than built from + C(q_s, r) directly, so q_s = 2^64 costs the same as q_s = 2^20. + """ + q_s = Decimal(2) ** q_s_log2 + p = Decimal(2) ** -h + t = Decimal(2) ** a + one_minus_p = 1 - p + ratio = p / one_minus_p + miss = 1 - 1 / t # P(one signature misses a given leaf of one FORS tree) + + lam = 2.0 ** (q_s_log2 - h) # expected times one FORS instance is reused + if lam > 4096: + raise ValueError( + f"q_s = 2^{q_s_log2} over 2^{h} hypertree leaves reuses every FORS instance ~2^{q_s_log2 - h} times: no security is left to quantify" + ) + r_max = min(r_cap, max(1000, int(lam + 40 * (lam + 1) ** 0.5) + 40)) + floor_prob = Decimal(2) ** -1250 + relative_floor = Decimal(2) ** -80 + + term = one_minus_p**q_s # C(q_s,0) p^0 (1-p)^q_s + miss_r = Decimal(1) + sigma = Decimal(0) + r = 0 + while r < r_max: + r += 1 + term *= (q_s - r + 1) / Decimal(r) * ratio + miss_r *= miss + contribution = term * (1 - miss_r) ** k + sigma += contribution + if r > lam and (contribution < floor_prob or contribution < sigma * relative_floor): + break + else: + raise ValueError(f"security sum did not converge within r <= {r_max}; parameters are far below any usable level") + + if sigma <= 0: + return float("inf") + return float(-sigma.ln() / Decimal(2).ln()) + + +def security_bits(q_s_log2: int, h: int, k: int, a: int, n: int) -> float: + """Classical bit security: forgery exponent capped by the preimage bound. + + A query aimed at a FORS forgery cannot double as a preimage query for a tree + node or a WOTS chain (different tweaks), so the two attacks are independent + strategies and the adversary simply takes the better one. + """ + return min(8 * n, fors_forgery_exponent(q_s_log2, h, k, a)) + + +# --------------------------------------------------------------------------- +# Costs +# --------------------------------------------------------------------------- + + +@dataclass +class Cost: + """A cost in both units.""" + + hashes: int + compressions: int + + def __add__(self, other: Cost) -> Cost: + return Cost(self.hashes + other.hashes, self.compressions + other.compressions) + + def __sub__(self, other: Cost) -> Cost: + return Cost(self.hashes - other.hashes, self.compressions - other.compressions) + + def __mul__(self, m: int) -> Cost: + return Cost(self.hashes * m, self.compressions * m) + + __rmul__ = __mul__ + + +def _wots_leaf(l: int, w: int, n: int, cv: Convention) -> Cost: + """One WOTS key pair plus the compression of its l chain ends into a leaf.""" + return Cost(l + l * (w - 1) + 1, l * cv.prf + l * (w - 1) * cv.th1 + cv.th(l, n)) + + +def _xmss_tree(leaves: int, l: int, w: int, n: int, cv: Convention) -> Cost: + """Build a Merkle tree over `leaves` WOTS key pairs, from the seed up.""" + return leaves * _wots_leaf(l, w, n, cv) + Cost(leaves - 1, (leaves - 1) * cv.th2) + + +def _msg_hash(cv: Convention) -> Cost: + """R = PRF_msg(...) and the randomized message digest H_msg(...).""" + return Cost(2, cv.hmsg + cv.prfmsg) + + +def _fors_build(trees: int, a: int, n: int, cv: Convention) -> Cost: + """Grow `trees` FORS trees of 2^a secret leaves and compress their roots.""" + t = 1 << a + return Cost( + trees * t + trees * t + trees * (t - 1) + 1, + trees * t * cv.prf + trees * t * cv.th1 + trees * (t - 1) * cv.th2 + cv.th(trees, n), + ) + + +def _fors_verify(trees: int, a: int, n: int, cv: Convention) -> Cost: + """Hash `trees` opened leaves up their auth paths and compress the roots.""" + return Cost( + trees + trees * a + 1, + trees * cv.th1 + trees * a * cv.th2 + cv.th(trees, n), + ) + + +# --------------------------------------------------------------------------- +# Top level +# --------------------------------------------------------------------------- + + +@dataclass +class Result: + scheme: str + q_s_log2: int + n: int + h: int + d: int + h_prime: int + a: int + k: int + w: int + l: int + chain_bits: int + pinned_bits: int + dropped_chains: int + swn: int | None + + security_bits: float + fors_forgery_bits: float + sig_bytes: int + + keygen_hashes: int + keygen_compressions: int + + sign_hashes: int + sign_compressions: int + sign_grinding_hashes: int + wots_c_grinding_hashes: int + fors_c_grinding_hashes: int + + verify_hashes: int + verify_compressions: int + verify_hashes_worst: int + verify_compressions_worst: int + + cache_depth: int + cache_bytes: int + sign_cached_hashes: int + sign_cached_compressions: int + + +def evaluate( + h: int, + d: int, + a: int, + k: int, + w: int, + q_s_log2: int, + scheme: str = "W+C_F+C", + swn: int | None = None, + n: int = 16, + dropped_chains: int = 0, + cache_height: int | None = None, + cache_level_only: bool = False, + convention: Convention | None = None, +) -> Result: + """Evaluate one SPHINCS+ parameter set. + + h, d hypertree height and number of layers (h' = h/d per XMSS tree) + a, k FORS trees of 2^a leaves, k of them + w Winternitz parameter + q_s_log2 log2 of the signatures allowed under one public key + scheme "SPX", "W+C", or "W+C_F+C" + swn WOTS+C target digit sum S_{w,n}; defaults to the mean l*(w-1)/2 + dropped_chains chains dropped on top of the digest bits that have to be + pinned anyway, each one pinning log2(w) more bits: see + Encoding + cache_height height above the leaves of the cached top-tree level; the + default h'//2 is the "half top" (cached level at depth + ceil(h'/2), so the cheaper half of the tree is rebuilt) + cache_level_only store just that one level instead of it and everything + above, paying 2^ceil(h'/2)-1 hashes to rebuild the top + """ + if scheme not in SCHEMES: + raise ValueError(f"scheme must be one of {SCHEMES} (PORS+FP is out of scope)") + if h % d: + raise ValueError("d must divide h") + if log2(w) != int(log2(w)): + raise ValueError("w must be a power of two") + if scheme == "SPX" and dropped_chains: + raise ValueError("dropped_chains applies to WOTS+C only") + + cv = convention or Convention() + wots_c = scheme != "SPX" + fors_c = scheme == "W+C_F+C" + hp = h // d + enc = Encoding(w, n, dropped_chains) + l = wots_chains(scheme, w, n, dropped_chains) + swn_c = 0 if not wots_c else (enc.default_swn if swn is None else swn) + trees = k - 1 if fors_c else k # FORS+C grinds the last tree away + + # ---- size ---------------------------------------------------------- + layer = hp * n + l * n + (COUNTER_BYTES if wots_c else 0) + sig_bytes = n + d * layer + trees * n + trees * a * n + + # ---- hypertree, shared by keygen and signing ----------------------- + tree = _xmss_tree(1 << hp, l, w, n, cv) + trials = enc.expected_trials(swn_c) if wots_c else 0 + grinding = d * trials + hyper = d * tree + Cost(grinding, grinding * cv.th1c) + + # ---- keygen: the top tree only, to get PK.root --------------------- + keygen = tree + + # ---- signing ------------------------------------------------------- + fors = _fors_build(trees, a, n, cv) + if fors_c: + # grind the digest until its last a bits vanish, so the last FORS tree + # always opens leaf 0 and needs no authentication path + fors_grind = (1 << a) * _msg_hash(cv) + else: + fors_grind = _msg_hash(cv) + sign = hyper + fors + fors_grind + + # ---- verification -------------------------------------------------- + if wots_c: + # the digits sum to S_wn, so the remaining chain steps are fixed + wots_v = Cost((w - 1) * l - swn_c + 2, ((w - 1) * l - swn_c) * cv.th1 + cv.th1c + cv.th(l, n)) + wots_v_worst = wots_v + else: + wots_v = Cost((w - 1) * l // 2 + 1, (w - 1) * l // 2 * cv.th1 + cv.th(l, n)) + steps = wots_tw_worst_steps(w, n) + wots_v_worst = Cost(steps + 1, steps * cv.th1 + cv.th(l, n)) + fts_v = _fors_verify(trees, a, n, cv) + auth = Cost(h, h * cv.th2) + verify = Cost(1, cv.hmsg) + fts_v + d * wots_v + auth + verify_worst = Cost(1, cv.hmsg) + fts_v + d * wots_v_worst + auth + + # ---- signing with the top tree's half top cached ------------------- + # Only the top tree is worth caching: it is the same for every signature, + # while the trees below it are picked by the (pseudorandom) index. Its auth + # path splits at the cached level: below, rebuild the 2^c-leaf subtree the + # signing leaf sits in; above, the nodes are already in state. Rebuilt + # leaves are charged a full WOTS public key, as everywhere else here. + # + # A BDS-style traversal would amortize a tree to h' leaves per signature + # with O(h') state, but it only works walking the leaves in order. SPHINCS+ + # picks its index by hashing the message, so consecutive signatures land on + # unrelated leaves and nothing amortizes; an index-independent cache like + # this one is what is left, hence sqrt rather than h'. + c = hp // 2 if cache_height is None else cache_height + if not 0 <= c <= hp: + raise ValueError("cache_height must be in [0, h/d]") + stored_level = 1 << (hp - c) + cached_tree = _xmss_tree(1 << c, l, w, n, cv) + if cache_level_only: + cached_tree += Cost(stored_level - 1, (stored_level - 1) * cv.th2) + cache_bytes = stored_level * n + else: + cache_bytes = (2 * stored_level - 1) * n + sign_cached = sign - tree + cached_tree + + forgery = fors_forgery_exponent(q_s_log2, h, k, a) + return Result( + scheme=scheme, + q_s_log2=q_s_log2, + n=n, + h=h, + d=d, + h_prime=hp, + a=a, + k=k, + w=w, + l=l, + chain_bits=enc.chain_bits, + pinned_bits=enc.pinned_bits if wots_c else 0, + dropped_chains=dropped_chains, + swn=swn_c if wots_c else None, + security_bits=min(8 * n, forgery), + fors_forgery_bits=forgery, + sig_bytes=sig_bytes, + keygen_hashes=keygen.hashes, + keygen_compressions=keygen.compressions, + sign_hashes=sign.hashes, + sign_compressions=sign.compressions, + sign_grinding_hashes=grinding + fors_grind.hashes - (0 if fors_c else 2), + wots_c_grinding_hashes=grinding, + fors_c_grinding_hashes=fors_grind.hashes if fors_c else 0, + verify_hashes=verify.hashes, + verify_compressions=verify.compressions, + verify_hashes_worst=verify_worst.hashes, + verify_compressions_worst=verify_worst.compressions, + cache_depth=hp - c, + cache_bytes=cache_bytes, + sign_cached_hashes=sign_cached.hashes, + sign_cached_compressions=sign_cached.compressions, + ) + + +# --------------------------------------------------------------------------- +# Reporting +# --------------------------------------------------------------------------- + + +def _si(x: float) -> str: + for unit, div in (("G", 1e9), ("M", 1e6), ("K", 1e3)): + if x >= div: + return f"{x / div:.2f}{unit}" + return str(int(x)) + + +def encoding_line(r: Result) -> str: + """One line spelling out the WOTS+C digest-to-chains cut.""" + if r.swn is None: + return f"encoding WOTS-TW: {r.l} chains, {r.l - wots_len2(r.w, r.n)} for the digest + {wots_len2(r.w, r.n)} checksum" + dropped = f", {r.dropped_chains} chain(s) dropped" if r.dropped_chains else "" + return ( + f"encoding {r.chain_bits} bits/chain, {r.pinned_bits} of {8 * r.n} digest bits pinned to zero" + f"{dropped}, S_wn = {r.swn} of {r.l * (r.w - 1)}" + ) + + +def report(r: Result) -> str: + speedup = r.sign_hashes / r.sign_cached_hashes + + def row(label: str, hashes: int, compressions: int, note: str = "") -> str: + return f"{label:<24}{_si(hashes):>12}{_si(compressions):>16}{note}" + + lines = [ + f"scheme {r.scheme} q_s = 2^{r.q_s_log2} n = {8 * r.n} bits", + f"(h, d, h') ({r.h}, {r.d}, {r.h_prime})", + f"(a, k) ({r.a}, {r.k})" + (f" [FORS+C signs {r.k - 1} trees]" if r.scheme == "W+C_F+C" else ""), + f"(w, l) ({r.w}, {r.l})", + encoding_line(r), + "", + f"security {r.security_bits:.1f} bits classical" + + (f" (FORS forgery {r.fors_forgery_bits:.1f}, preimage {8 * r.n})" if r.fors_forgery_bits < 1e6 else ""), + f"signature {r.sig_bytes} bytes", + "", + f"{'':24}{'hashes':>12}{'compressions':>16}", + row("keygen", r.keygen_hashes, r.keygen_compressions), + row("sign (avg)", r.sign_hashes, r.sign_compressions), + row( + "sign (half-top cached)", + r.sign_cached_hashes, + r.sign_cached_compressions, + f" ({speedup:.2f}x, {r.cache_bytes} B of state at depth {r.cache_depth})", + ), + row("verify", r.verify_hashes, r.verify_compressions), + ] + if r.verify_hashes_worst != r.verify_hashes: + lines.append(row("verify (worst)", r.verify_hashes_worst, r.verify_compressions_worst)) + lines += [ + "", + ( + f"of signing, grinding accounts for {_si(r.sign_grinding_hashes)} hashes:" + f" {_si(r.wots_c_grinding_hashes)} for the WOTS+C counters," + f" {_si(r.fors_c_grinding_hashes)} for the FORS+C digest" + ), + ] + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Self-test against the golden values of BlockstreamResearch/SPHINCS-Parameters +# --------------------------------------------------------------------------- + +# tests/fixtures.json, "cached" hash convention: scheme|h,d,k,a,w,swn -> costs.sage +GOLDEN = { + ("SPX", 63, 7, 14, 12, 16, None): {"size": 7856, "kg": 292351, "sg": 2218483, "sv": 2155, "sv_worst": 3891}, + ("W+C", 44, 4, 8, 16, 16, 240): {"size": 4960, "kg": 1069055, "sg": 5849347, "sv": 1185, "sv_worst": 1185}, + ("W+C", 40, 5, 11, 14, 256, 2040): {"size": 4596, "kg": 1050111, "sg": 5794969, "sv": 10441, "sv_worst": 10441}, + ("W+C", 40, 5, 11, 14, 256, 2840): {"size": 4596, "kg": 1050111, "sg": 5941944, "sv": 6441, "sv_worst": 6441}, + ("W+C_F+C", 44, 4, 8, 16, 16, 240): {"size": 4688, "kg": 1069055, "sg": 5914880, "sv": 1168, "sv_worst": 1168}, + ("W+C_F+C", 40, 5, 11, 14, 256, 2040): {"size": 4356, "kg": 1050111, "sg": 5811349, "sv": 10425, "sv_worst": 10425}, + ("W+C_F+C", 20, 2, 10, 15, 256, 2040): {"size": 3160, "kg": 4200447, "sg": 9418194, "sv": 4261, "sv_worst": 4261}, +} +GOLDEN_UNCACHED = { + ("SPX", 63, 7, 14, 12, 16, None): {"size": 7856, "kg": 292862, "sg": 2279391, "sv": 2387, "sv_worst": 4123}, + ("W+C", 44, 4, 8, 16, 16, 240): {"size": 4960, "kg": 1071102, "sg": 6381815, "sv": 1357, "sv_worst": 1357}, +} +# The report's Tables 1 and 2, WOTS/FORS rows only: the "SigVer (hash)", +# "SigTime (hash)" (as a multiple of 10^4, 3 significant figures) and +# "Exp. Search (hash)" columns, keyed by (scheme, h, d, a, k, w, S_wn). +# The tables' "Sig (B)" column is 16 bytes above what costs.sage now computes +# (it predates the report; the repo's own fixtures agree with this script). +REPORT_TABLE = { + ("SPX", 63, 7, 12, 14, 16, None): (2088, 219, 0), + ("W+C", 44, 4, 16, 8, 16, 240): (1150, 578, 264), + ("W+C", 44, 4, 16, 8, 16, 304): (894, 579, 5344), + ("W+C", 44, 4, 16, 8, 256, 2040): (8350, 3515, 2996), + ("W+C", 40, 5, 14, 11, 256, 2040): (10417, 579, 3745), + ("W+C", 40, 5, 14, 11, 256, 2840): (6417, 594, None), + ("W+C_F+C", 44, 4, 16, 8, 16, 240): (1133, 572, None), + ("W+C_F+C", 40, 5, 14, 11, 256, 2040): (10402, 577, 36513), + ("W+C", 36, 3, 14, 9, 16, 240): (899, 676, 198), + ("W+C", 33, 3, 15, 9, 16, 304): (713, 405, 4008), + ("W+C", 32, 4, 14, 10, 256, 2840): (5152, 481, None), + ("W+C_F+C", 33, 3, 15, 9, 16, 240): (889, 401, 65734), + ("W+C_F+C", 32, 4, 14, 10, 256, 2040): (8337, 467, 35764), + ("W+C", 24, 2, 16, 8, 16, 240): (646, 578, None), + ("W+C", 24, 2, 16, 8, 256, 2040): (4246, 3515, None), + ("W+C_F+C", 24, 2, 16, 8, 16, 240): (629, 572, None), + ("W+C", 20, 2, 15, 10, 256, 2040): (4266, 938, None), + ("W+C_F+C", 20, 2, 15, 10, 256, 2040): (4250, 934, None), +} +# (w, n, dropped_chains) -> (chain_bits, pinned_bits, chains, default S_wn) +GOLDEN_ENCODINGS = { + (8, 16, 0): (3, 2, 42, 147), # doc/xmss/main.tex + (8, 16, 1): (3, 5, 41, 143), + (8, 16, 2): (3, 8, 40, 140), + (16, 16, 0): (4, 0, 32, 240), # the report's w = 16: nothing to pin + (16, 16, 1): (4, 4, 31, 232), + (32, 16, 0): (5, 3, 25, 387), # 128 = 5*25 + 3 + (256, 16, 0): (8, 0, 16, 2040), + (8, 32, 0): (3, 1, 85, 297), # 256 = 3*85 + 1 +} +# security.sage / site/stateless.html, for (q_s_log2, h, k, a) +GOLDEN_SECURITY = { + (64, 63, 14, 12): 128.0, # SLH-DSA-128s/f: preimage bound dominates + (40, 44, 8, 16): 128.0, + (40, 40, 11, 14): 128.0, + (30, 32, 10, 14): 128.0, + (20, 24, 8, 16): 128.0, +} + + +def selftest() -> int: + fails = 0 + + def check(name, got, want, tol=0.05): + nonlocal fails + ok = got == want if isinstance(want, int) else abs(got - want) < tol + fails += not ok + if not ok: + print(f"FAIL {name}: got {got}, want {want}") + + for cv, table in ((Convention(True), GOLDEN), (Convention(False), GOLDEN_UNCACHED)): + tag = "cached" if cv.cached_midstate else "uncached" + for (scheme, h, d, k, a, w, swn), want in table.items(): + # the cost model does not depend on q_s; security is checked separately + r = evaluate(h, d, a, k, w, min(40, h), scheme=scheme, swn=swn, convention=cv) + fields = ( + ("sig_bytes", "size"), + ("keygen_compressions", "kg"), + ("sign_compressions", "sg"), + ("verify_compressions", "sv"), + ("verify_compressions_worst", "sv_worst"), + ) + for field, key in fields: + check(f"{tag} {scheme} h={h} d={d} k={k} a={a} w={w} {key}", getattr(r, field), want[key]) + + for (scheme, h, d, a, k, w, swn), (sv, sg_e4, search) in REPORT_TABLE.items(): + r = evaluate(h, d, a, k, w, min(40, h), scheme=scheme, swn=swn) + tag = f"report {scheme} h={h} d={d} a={a} k={k} w={w} S={swn}" + check(f"{tag} SigVer", r.verify_hashes, sv) + check(f"{tag} SigTime", r.sign_hashes / 1e4, float(sg_e4), tol=0.55) # table rounds to 3 figures + if search is not None: + check(f"{tag} Exp.Search", r.sign_grinding_hashes, search) + + for (q, h, k, a), want in GOLDEN_SECURITY.items(): + check(f"security q_s=2^{q} h={h} k={k} a={a}", security_bits(q, h, k, a, 16), want) + + # Encoding geometry: doc/xmss/main.tex is the (n=128, chain_bits=3) instance, + # with 2 bits pinned, v = 42 chains and T = 195. + for (w, n, drop), (bits, pinned, chains, mean) in GOLDEN_ENCODINGS.items(): + e = Encoding(w, n, drop) + tag = f"encoding w={w} n={n} drop={drop}" + check(f"{tag} chain_bits", e.chain_bits, bits) + check(f"{tag} pinned_bits", e.pinned_bits, pinned) + check(f"{tag} chains", e.chains, chains) + check(f"{tag} default_swn", e.default_swn, mean) + check("doc/xmss T=195 trials", Encoding(8, 16).expected_trials(195), 29490) + check("one dropped chain costs about w", Encoding(8, 16, 1).expected_trials(143) // Encoding(8, 16).expected_trials(147), 7) + + # h'=0 leaves nothing to cache; h'=h/d with c=0 rebuilds one leaf only + r = evaluate(40, 5, 14, 11, 256, 40, scheme="W+C_F+C") + check("cache is a strict saving", r.sign_cached_hashes < r.sign_hashes, True) + check("cache_height=h' is the full tree", evaluate(40, 5, 14, 11, 256, 40, cache_height=8).sign_cached_hashes, r.sign_hashes) + + print("selftest: " + ("all checks passed" if not fails else f"{fails} failures")) + return fails + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main() -> int: + p = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0], formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--scheme", default="W+C_F+C", choices=SCHEMES) + p.add_argument("--qs", type=int, default=40, metavar="LOG2", help="log2 of signatures per public key (default 40)") + p.add_argument("--height", type=int, default=40, metavar="h", help="hypertree height (default 40)") + p.add_argument("--layers", type=int, default=5, metavar="d", help="hypertree layers (default 5)") + p.add_argument("-a", type=int, default=14, help="log2 leaves per FORS tree (default 14)") + p.add_argument("-k", type=int, default=11, help="FORS trees (default 11)") + p.add_argument("-w", type=int, default=256, help="Winternitz parameter (default 256)") + p.add_argument("--swn", type=int, default=None, help="WOTS+C target digit sum S_wn (default: the mean, l*(w-1)/2)") + p.add_argument("-n", type=int, default=16, help="hash output in bytes (default 16)") + p.add_argument("--chain-bits", type=int, default=None, metavar="B", help="log2(w), an alternative way to give w (3 means 8 hashes per chain)") + p.add_argument( + "--drop-chains", + type=int, + default=0, + metavar="C", + help="WOTS+C chains dropped beyond the minimal bit pinning; each pins log2(w) more digest bits (default 0)", + ) + p.add_argument("--cache-height", type=int, default=None, metavar="c", help="height of the cached top-tree level above the leaves (default h'//2)") + p.add_argument("--cache-level-only", action="store_true", help="cache that one level, not it and everything above") + p.add_argument("--uncached", action="store_true", help="charge every hash for its full input instead of caching the PK.seed midstate") + p.add_argument("--json", action="store_true") + p.add_argument("--selftest", action="store_true") + args = p.parse_args() + + if args.selftest: + return 1 if selftest() else 0 + + if args.chain_bits is not None: + args.w = 1 << args.chain_bits + + r = evaluate( + args.height, + args.layers, + args.a, + args.k, + args.w, + args.qs, + scheme=args.scheme, + swn=args.swn, + n=args.n, + dropped_chains=args.drop_chains, + cache_height=args.cache_height, + cache_level_only=args.cache_level_only, + convention=Convention(not args.uncached), + ) + if args.json: + import json + + print(json.dumps(asdict(r), indent=2)) + else: + print(report(r)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 368c838509aeaa322991fd94a65236da80d23708 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 12:01:15 +0200 Subject: [PATCH 02/31] doc/sphincs: search the parameter space for the cheapest verification Given a lifetime and a budget for keygen, signing (vanilla and half-top cached) and signature size, search.py enumerates the parameter space and reports the sets minimizing verification cost at NIST level 1 (128-bit classical, the SLH-DSA level 1 target). Two axes need no enumerating, which is what keeps this in Python: - k is determined by (h, a). The forgery exponent is increasing in k while size, signing and verification all grow with it, so only the smallest secure k is ever worth costing: a cached binary search, not an axis. - S_wn likewise. Verification is strictly decreasing in it and the grinding is increasing above the mean, so the answer is the largest S_wn whose grinding still fits the signing budgets. What is left is (scheme, h, d | h, chain_bits, dropped_chains, a), pruned by keygen before a and by size and signing before S_wn. On the default grid the loose-budget worst case is ~70k grid tuples and ~2M cost-model calls, 20s; realistic budgets prune to a few seconds, so a Rust port is not needed. --selftest checks both shortcuts rather than trusting them: that the digit-sum count is unimodal with its peak at the mean (so the S_wn search cannot skip a larger admissible sum), that min_secure_k matches a linear scan, and that on a grid small enough to exhaust, the pruned search returns the same optimum as a sweep over every k and every S_wn. Cross-check: given budgets near the report's 2^40 numbers and its grid (w in {16, 256}, no chain dropping), the search rediscovers its bold row, h=40 d=5 a=14 k=11 w=256, and then spends the leftover signing budget by raising S_wn from 2040 to 2882, which cuts verification from 10,402 hashes to 6,190. sphincs_params.py grows a costs() that returns everything not depending on q_s, with evaluate() as costs() plus the security level, so the search pays 0.23ms for a security sum only when it needs one rather than on every one of two million candidates. Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/search.py | 470 ++++++++++++++++++++++++++++++++++ doc/sphincs/sphincs_params.py | 115 +++++++-- 2 files changed, 558 insertions(+), 27 deletions(-) create mode 100755 doc/sphincs/search.py diff --git a/doc/sphincs/search.py b/doc/sphincs/search.py new file mode 100755 index 000000000..3c4278c50 --- /dev/null +++ b/doc/sphincs/search.py @@ -0,0 +1,470 @@ +#!/usr/bin/env python3 +"""Search the SPHINCS+ parameter space for the cheapest verification. + +Given a lifetime and a budget for each of the other four costs: + + --lifetime log2 of the signatures allowed under one public key + --max-keygen hashes at key generation + --max-sign hashes at signing, vanilla + --max-sign-cached hashes at signing with the top tree's half top cached + --max-size signature bytes + +this enumerates the parameter space and reports the sets that minimize +verification cost subject to NIST level 1 security (128-bit classical, the +SLH-DSA level 1 target), computed the same way as in the report: the FORS +subset-forgery sum of security.sage, which must reach 128 bits at the given +lifetime, capped by the 2^-n preimage bound. + +The cost model is sphincs_params.py; this file only searches. Schemes are that +module's SPX / W+C / W+C_F+C, and the WOTS+C digest cut is its bit-pinning +Encoding, so --chain-bits and --drop-chains span the same axes here. + +Two facts keep the space small enough to brute force in Python: + + * k is not searched. For a fixed (h, a) the forgery exponent is increasing in + k while size, signing and verification all grow with it, so the only k worth + considering is the smallest one that reaches the security target. That turns + a 2-D (a, k) sweep into a 1-D one plus a cached binary search on k. + + * S_wn is not searched either. Verification is strictly decreasing in it and + the grinding is increasing in it above the mean, so the best S_wn is simply + the largest one whose grinding still fits the signing budgets: another + binary search, not an axis. + +What remains is (scheme, h, d | h, chain_bits, dropped_chains, a), pruned by +keygen before a is reached and by size and signing before S_wn is. Run with +--stats to see how big the space actually was; if a wider grid is wanted than +Python will sit through, this is the file to port, not the cost model. +""" + +from __future__ import annotations + +import argparse +import os +import sys +import time +from dataclasses import dataclass +from functools import lru_cache +from math import log2 + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from sphincs_params import SCHEMES, Convention, Costs, Encoding, costs, evaluate, fors_forgery_exponent, report + +LEVEL1_BITS = 128 # NIST level 1, matching SLH-DSA's level 1 parameter sets + + +# --------------------------------------------------------------------------- +# Constraints and grid +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Budgets: + lifetime: int # log2 of the signatures per public key + max_keygen: int + max_sign: int + max_sign_cached: int + max_size: int + security: float = LEVEL1_BITS + unit: str = "hashes" # "hashes" or "compressions", for the budgets and the objective + + def of(self, cost) -> int: + return getattr(cost, self.unit) + + +@dataclass(frozen=True) +class Grid: + schemes: tuple[str, ...] = SCHEMES + n: int = 16 + h_min: int = 1 + h_max: int = 96 + a_min: int = 1 + a_max: int = 32 + k_max: int = 64 + chain_bits: tuple[int, ...] = (1, 2, 3, 4, 5, 6, 7, 8) + max_dropped: int = 8 + cache_level_only: bool = False + + +@dataclass +class Candidate: + scheme: str + h: int + d: int + a: int + k: int + w: int + dropped_chains: int + c: Costs + + @property + def swn(self) -> int | None: + return self.c.swn + + +# --------------------------------------------------------------------------- +# Security: the smallest secure k for a given (h, a) +# --------------------------------------------------------------------------- + + +@lru_cache(maxsize=1 << 16) +def min_secure_k(lifetime: int, h: int, a: int, target: float, k_max: int) -> int | None: + """Smallest k reaching `target` bits, or None if no k <= k_max does. + + The forgery exponent is increasing in k: each extra FORS tree is one more + tree whose required leaf the adversary needs already opened. So the feasible + k form an up-set and a binary search finds its floor. + """ + try: + if fors_forgery_exponent(lifetime, h, k_max, a) < target: + return None + except ValueError: + return None # q_s so far past 2^h that the sum does not converge + lo, hi = 1, k_max # invariant: hi secure + while lo < hi: + mid = (lo + hi) // 2 + if fors_forgery_exponent(lifetime, h, mid, a) >= target: + hi = mid + else: + lo = mid + 1 + return lo + + +# --------------------------------------------------------------------------- +# Search +# --------------------------------------------------------------------------- + + +@dataclass +class Stats: + grid: int = 0 # (scheme, h, d, chain_bits, dropped) tuples visited + keygen_pruned: int = 0 + insecure: int = 0 # a-loop iterations, not distinct (h, a) pairs + size_pruned: int = 0 + sign_pruned: int = 0 + evaluated: int = 0 # candidates that got an S_wn search + costs_calls: int = 0 + seconds: float = 0.0 + + def __str__(self) -> str: + return ( + f"grid {self.grid} (scheme, h, d, chain_bits, dropped) tuples, " + f"{self.keygen_pruned} over keygen budget; then, over the a loop, {self.insecure} with no secure k, " + f"{self.size_pruned} over size, {self.sign_pruned} over signing, " + f"{self.evaluated} costed with an S_wn search ({self.costs_calls} cost-model calls) in {self.seconds:.1f}s" + ) + + +def _divisors(h: int) -> list[int]: + return [d for d in range(1, h + 1) if h % d == 0] + + +def search(b: Budgets, g: Grid | None = None, stats: Stats | None = None) -> list[Candidate]: + """Every feasible parameter set, ordered by verification cost.""" + g = g or Grid() + st = stats if stats is not None else Stats() + started = time.perf_counter() + cv = Convention() + found: list[Candidate] = [] + + def cost(scheme, h, d, a, k, w, dropped, swn) -> Costs: + st.costs_calls += 1 + return costs(h, d, a, k, w, scheme, swn, g.n, dropped, None, g.cache_level_only, cv) + + # Necessary conditions, used only to bound the grid. The signature carries + # h authentication nodes, so h <= max_size/n; signing grows a FORS tree of + # 2^a leaves, so 2^a <= max_sign. Anything outside cannot become feasible. + h_max = min(g.h_max, b.max_size // g.n) + a_max = min(g.a_max, int(log2(max(b.max_sign, 2)))) + + for scheme in g.schemes: + spx = scheme == "SPX" + for h in range(g.h_min, h_max + 1): + for d in _divisors(h): + for bits in g.chain_bits: + w = 1 << bits + # WOTS-TW has no counter to grind, so it cannot drop chains, + # and WOTS+C has to keep at least one. + max_dropped = 0 if spx else min(g.max_dropped, Encoding(w, g.n).chains - 1) + for dropped in range(max_dropped + 1): + st.grid += 1 + # keygen needs no a, k: one top tree of 2^(h/d) leaves + probe = cost(scheme, h, d, g.a_min, 2, w, dropped, None) + if b.of(probe.keygen) > b.max_keygen: + st.keygen_pruned += 1 + continue + for a in range(g.a_min, a_max + 1): + k = min_secure_k(b.lifetime, h, a, b.security, g.k_max) + if k is None: + st.insecure += 1 + continue + if scheme == "W+C_F+C": + k = max(k, 2) # FORS+C signs k-1 of them + c = cost(scheme, h, d, a, k, w, dropped, None) + if c.sig_bytes > b.max_size: + st.size_pruned += 1 + continue + if b.of(c.sign) > b.max_sign or b.of(c.sign_cached) > b.max_sign_cached: + st.sign_pruned += 1 # the mean S_wn grinds least, so no S_wn fits + continue + st.evaluated += 1 + if not spx: + c = _push_swn(b, cost, scheme, h, d, a, k, w, dropped, c) + found.append(Candidate(scheme, h, d, a, k, w, dropped, c)) + + st.seconds = time.perf_counter() - started + found.sort(key=lambda cand: (b.of(cand.c.verify), cand.c.sig_bytes, b.of(cand.c.sign))) + return found + + +def _push_swn(b: Budgets, cost, scheme, h, d, a, k, w, dropped, at_mean: Costs) -> Costs: + """Raise S_wn as far as the signing budgets allow, which is where verification is cheapest. + + Verification walks (w-1)*l - S_wn chain steps, so it falls by d for every + step S_wn gains, while the grinding rises monotonically above the mean. + `at_mean` is feasible by construction, so this is a binary search on an + up-set with a known floor. + """ + lo = at_mean.swn or 0 + hi = at_mean.l * (w - 1) + best = at_mean + while lo < hi: + mid = (lo + hi + 1) // 2 + c = cost(scheme, h, d, a, k, w, dropped, mid) + if b.of(c.sign) <= b.max_sign and b.of(c.sign_cached) <= b.max_sign_cached: + lo, best = mid, c + else: + hi = mid - 1 + return best + + +# --------------------------------------------------------------------------- +# Output +# --------------------------------------------------------------------------- + + +def _si(x: float) -> str: + for unit, div in (("G", 1e9), ("M", 1e6), ("K", 1e3)): + if x >= div: + return f"{x / div:.2f}{unit}" + return str(int(x)) + + +COLUMNS = ( + ("verify", 9, lambda b, c: _si(b.of(c.c.verify))), + ("scheme", 9, lambda b, c: c.scheme), + ("h", 4, lambda b, c: str(c.h)), + ("d", 3, lambda b, c: str(c.d)), + ("h'", 4, lambda b, c: str(c.h // c.d)), + ("a", 3, lambda b, c: str(c.a)), + ("k", 3, lambda b, c: str(c.k)), + ("cb", 3, lambda b, c: str(c.c.chain_bits)), + ("drop", 5, lambda b, c: str(c.dropped_chains)), + ("l", 4, lambda b, c: str(c.c.l)), + ("S_wn", 6, lambda b, c: "-" if c.swn is None else str(c.swn)), + ("size", 6, lambda b, c: str(c.c.sig_bytes)), + ("keygen", 8, lambda b, c: _si(b.of(c.c.keygen))), + ("sign", 8, lambda b, c: _si(b.of(c.c.sign))), + ("sign$", 8, lambda b, c: _si(b.of(c.c.sign_cached))), + ("state", 7, lambda b, c: str(c.c.cache_bytes)), +) + + +def table(b: Budgets, cands: list[Candidate]) -> str: + lines = [" ".join(name.rjust(width) for name, width, _ in COLUMNS)] + lines.append("-" * len(lines[0])) + for c in cands: + lines.append(" ".join(fmt(b, c).rjust(width) for _, width, fmt in COLUMNS)) + return "\n".join(lines) + + +def utilization(b: Budgets, c: Candidate) -> str: + used = ( + ("keygen", b.of(c.c.keygen), b.max_keygen), + ("sign", b.of(c.c.sign), b.max_sign), + ("sign cached", b.of(c.c.sign_cached), b.max_sign_cached), + ("size", c.c.sig_bytes, b.max_size), + ) + return ", ".join(f"{name} {100 * v / lim:.0f}%" for name, v, lim in used if lim) + + +# --------------------------------------------------------------------------- +# Self-test: the two shortcuts above are exact, checked against exhaustion +# --------------------------------------------------------------------------- + + +def exhaustive(b: Budgets, g: Grid) -> list[Candidate]: + """The same search with nothing pruned: every k, every S_wn. + + Only usable on a tiny grid, which is the point: it is what the pruned + search is diffed against. + """ + cv = Convention() + found: list[Candidate] = [] + for scheme in g.schemes: + spx = scheme == "SPX" + for h in range(g.h_min, g.h_max + 1): + for d in _divisors(h): + for bits in g.chain_bits: + w = 1 << bits + for dropped in range(1 if spx else g.max_dropped + 1): + l = Encoding(w, g.n, dropped).chains + for a in range(g.a_min, g.a_max + 1): + for k in range(2 if scheme == "W+C_F+C" else 1, g.k_max + 1): + if fors_forgery_exponent(b.lifetime, h, k, a) < b.security: + continue + for swn in [None] if spx else range(l * (w - 1) + 1): + c = costs(h, d, a, k, w, scheme, swn, g.n, dropped, None, g.cache_level_only, cv) + if c.sig_bytes > b.max_size or b.of(c.keygen) > b.max_keygen: + continue + if b.of(c.sign) > b.max_sign or b.of(c.sign_cached) > b.max_sign_cached: + continue + found.append(Candidate(scheme, h, d, a, k, w, dropped, c)) + found.sort(key=lambda cand: (b.of(cand.c.verify), cand.c.sig_bytes, b.of(cand.c.sign))) + return found + + +def selftest() -> int: + fails = 0 + + def check(name, got, want): + nonlocal fails + ok = got == want + fails += not ok + if not ok: + print(f"FAIL {name}: got {got}, want {want}") + + # 1. The digit-sum count is unimodal with its peak at the mean, so grinding + # only rises above the mean and the S_wn binary search cannot skip an + # admissible larger S_wn. + for bits in (1, 2, 3, 4, 8): + w = 1 << bits + enc = Encoding(w, 16) + mean, top = enc.default_swn, enc.chains * (w - 1) + nus = [enc.admissible(s) for s in range(mean, min(top, mean + 60) + 1)] + check(f"nu non-increasing above the mean (w={w})", nus == sorted(nus, reverse=True), True) + check(f"nu peaks at the mean (w={w})", enc.admissible(mean) >= enc.admissible(mean - 1), True) + + # 2. min_secure_k is the floor of the secure k, by linear scan. + for h, a in ((20, 10), (24, 12), (30, 14)): + k = min_secure_k(20, h, a, LEVEL1_BITS, 32) + scan = next((kk for kk in range(1, 33) if fors_forgery_exponent(20, h, kk, a) >= LEVEL1_BITS), None) + check(f"min_secure_k(h={h}, a={a})", k, scan) + + # 3. On a grid small enough to exhaust, the pruned search finds the same + # optimum as the sweep over every k and every S_wn. + b = Budgets(lifetime=20, max_keygen=3_000_000, max_sign=10_000_000, max_sign_cached=10_000_000, max_size=4_000) + g = Grid(schemes=("W+C", "W+C_F+C"), h_min=20, h_max=20, a_min=14, a_max=16, k_max=14, chain_bits=(4,), max_dropped=1) + pruned, full = search(b, g), exhaustive(b, g) + check("exhaustive agrees on the optimum", b.of(pruned[0].c.verify), b.of(full[0].c.verify)) + check( + "exhaustive agrees on the winner", + (pruned[0].scheme, pruned[0].h, pruned[0].d, pruned[0].a, pruned[0].k, pruned[0].w, pruned[0].swn), + (full[0].scheme, full[0].h, full[0].d, full[0].a, full[0].k, full[0].w, full[0].swn), + ) + + print("selftest: " + ("all checks passed" if not fails else f"{fails} failures")) + return fails + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def _budget(text: str) -> int: + return int(float(text)) + + +def main() -> int: + p = argparse.ArgumentParser( + description=(__doc__ or "").splitlines()[0], + epilog="example: search.py --lifetime 30 --max-keygen 2e6 --max-sign 6e6 --max-sign-cached 4e6 --max-size 4000", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p.add_argument("--lifetime", type=int, default=None, metavar="LOG2", help="log2 of the signatures allowed per public key") + p.add_argument("--max-keygen", type=_budget, default=None, metavar="N", help="budget for keygen") + p.add_argument("--max-sign", type=_budget, default=None, metavar="N", help="budget for average signing") + p.add_argument("--max-sign-cached", type=_budget, default=None, metavar="N", help="budget for average signing with the half top cached") + p.add_argument("--max-size", type=_budget, default=None, metavar="B", help="budget for the signature, in bytes") + p.add_argument( + "--security", type=float, default=LEVEL1_BITS, metavar="BITS", help=f"classical security floor (default {LEVEL1_BITS}, NIST level 1)" + ) + p.add_argument("--unit", choices=("hashes", "compressions"), default="hashes", help="unit of every budget and of the objective (default hashes)") + p.add_argument("--scheme", action="append", choices=SCHEMES, help="restrict the schemes searched (repeatable, default all)") + p.add_argument("-n", type=int, default=16, help="hash output in bytes (default 16)") + p.add_argument("--top", type=int, default=15, help="rows to print (default 15)") + p.add_argument("--h-max", type=int, default=Grid.h_max, help=f"largest hypertree height searched (default {Grid.h_max})") + p.add_argument("--a-max", type=int, default=Grid.a_max, help=f"largest FORS a searched (default {Grid.a_max})") + p.add_argument("--k-max", type=int, default=Grid.k_max, help=f"largest FORS k considered secure (default {Grid.k_max})") + p.add_argument("--chain-bits", type=int, action="append", metavar="B", help="restrict log2(w) searched (repeatable, default 1..8)") + p.add_argument("--max-dropped", type=int, default=Grid.max_dropped, help=f"most WOTS+C chains dropped (default {Grid.max_dropped})") + p.add_argument("--cache-level-only", action="store_true", help="cache one top-tree level rather than it and everything above") + p.add_argument("--stats", action="store_true", help="report how much of the space was visited") + p.add_argument("--selftest", action="store_true", help="check the k and S_wn shortcuts against an exhaustive sweep") + args = p.parse_args() + + if args.selftest: + return 1 if selftest() else 0 + missing = [f for f in ("lifetime", "max_keygen", "max_sign", "max_sign_cached", "max_size") if getattr(args, f) is None] + if missing: + p.error("required unless --selftest: " + ", ".join("--" + f.replace("_", "-") for f in missing)) + + b = Budgets( + lifetime=args.lifetime, + max_keygen=args.max_keygen, + max_sign=args.max_sign, + max_sign_cached=args.max_sign_cached, + max_size=args.max_size, + security=args.security, + unit=args.unit, + ) + g = Grid( + schemes=tuple(args.scheme) if args.scheme else SCHEMES, + n=args.n, + h_max=args.h_max, + a_max=args.a_max, + k_max=args.k_max, + chain_bits=tuple(sorted(set(args.chain_bits))) if args.chain_bits else Grid.chain_bits, + max_dropped=args.max_dropped, + cache_level_only=args.cache_level_only, + ) + + stats = Stats() + found = search(b, g, stats) + if args.stats: + print(stats) + print() + if not found: + print("no parameter set meets these budgets at " + f"{b.security:g}-bit security and q_s = 2^{b.lifetime}") + print("the binding budget is usually size or keygen; --stats says which pruned everything") + return 1 + + print(f"{len(found)} feasible sets, best {min(args.top, len(found))} by verification {b.unit}:") + print() + print(table(b, found[: args.top])) + print() + + best = found[0] + print(f"budget use of the best: {utilization(b, best)}") + print() + r = evaluate( + best.h, + best.d, + best.a, + best.k, + best.w, + b.lifetime, + scheme=best.scheme, + swn=best.swn, + n=g.n, + dropped_chains=best.dropped_chains, + cache_level_only=g.cache_level_only, + ) + print(report(r)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/doc/sphincs/sphincs_params.py b/doc/sphincs/sphincs_params.py index 6f9b2c4e1..8a1966d37 100755 --- a/doc/sphincs/sphincs_params.py +++ b/doc/sphincs/sphincs_params.py @@ -46,6 +46,7 @@ import argparse from dataclasses import asdict, dataclass from decimal import Decimal, getcontext +from functools import lru_cache from math import ceil, floor, log2 getcontext().prec = 120 @@ -186,6 +187,7 @@ def wots_chains(scheme: str, w: int, n: int, dropped_chains: int = 0) -> int: return Encoding(w, n, dropped_chains).chains +@lru_cache(maxsize=1 << 16) def wots_c_encodings(l: int, swn: int, w: int) -> int: """nu: number of l-tuples over [0, w-1] summing to exactly swn.""" from math import comb @@ -335,6 +337,27 @@ def _fors_verify(trees: int, a: int, n: int, cv: Convention) -> Cost: # --------------------------------------------------------------------------- +@dataclass +class Costs: + """Everything about a parameter set that does not depend on q_s.""" + + l: int + chain_bits: int + pinned_bits: int + dropped_chains: int + swn: int | None + sig_bytes: int + keygen: Cost + sign: Cost + sign_cached: Cost + verify: Cost + verify_worst: Cost + wots_c_grinding: int + fors_c_grinding: int + cache_depth: int + cache_bytes: int + + @dataclass class Result: scheme: str @@ -376,13 +399,12 @@ class Result: sign_cached_compressions: int -def evaluate( +def costs( h: int, d: int, a: int, k: int, w: int, - q_s_log2: int, scheme: str = "W+C_F+C", swn: int | None = None, n: int = 16, @@ -390,13 +412,16 @@ def evaluate( cache_height: int | None = None, cache_level_only: bool = False, convention: Convention | None = None, -) -> Result: - """Evaluate one SPHINCS+ parameter set. +) -> Costs: + """Size and hash counts of one parameter set, without the security level. + + Separate from evaluate() because the security sum is by far the most + expensive part and does not depend on any of this; a parameter search wants + the costs alone (see search.py). h, d hypertree height and number of layers (h' = h/d per XMSS tree) a, k FORS trees of 2^a leaves, k of them w Winternitz parameter - q_s_log2 log2 of the signatures allowed under one public key scheme "SPX", "W+C", or "W+C_F+C" swn WOTS+C target digit sum S_{w,n}; defaults to the mean l*(w-1)/2 dropped_chains chains dropped on top of the digest bits that have to be @@ -487,6 +512,42 @@ def evaluate( cache_bytes = (2 * stored_level - 1) * n sign_cached = sign - tree + cached_tree + return Costs( + l=l, + chain_bits=enc.chain_bits, + pinned_bits=enc.pinned_bits if wots_c else 0, + dropped_chains=dropped_chains, + swn=swn_c if wots_c else None, + sig_bytes=sig_bytes, + keygen=keygen, + sign=sign, + sign_cached=sign_cached, + verify=verify, + verify_worst=verify_worst, + wots_c_grinding=grinding, + fors_c_grinding=fors_grind.hashes if fors_c else 0, + cache_depth=hp - c, + cache_bytes=cache_bytes, + ) + + +def evaluate( + h: int, + d: int, + a: int, + k: int, + w: int, + q_s_log2: int, + scheme: str = "W+C_F+C", + swn: int | None = None, + n: int = 16, + dropped_chains: int = 0, + cache_height: int | None = None, + cache_level_only: bool = False, + convention: Convention | None = None, +) -> Result: + """costs() plus the classical security level at q_s = 2^q_s_log2.""" + c = costs(h, d, a, k, w, scheme, swn, n, dropped_chains, cache_height, cache_level_only, convention) forgery = fors_forgery_exponent(q_s_log2, h, k, a) return Result( scheme=scheme, @@ -494,33 +555,33 @@ def evaluate( n=n, h=h, d=d, - h_prime=hp, + h_prime=h // d, a=a, k=k, w=w, - l=l, - chain_bits=enc.chain_bits, - pinned_bits=enc.pinned_bits if wots_c else 0, - dropped_chains=dropped_chains, - swn=swn_c if wots_c else None, + l=c.l, + chain_bits=c.chain_bits, + pinned_bits=c.pinned_bits, + dropped_chains=c.dropped_chains, + swn=c.swn, security_bits=min(8 * n, forgery), fors_forgery_bits=forgery, - sig_bytes=sig_bytes, - keygen_hashes=keygen.hashes, - keygen_compressions=keygen.compressions, - sign_hashes=sign.hashes, - sign_compressions=sign.compressions, - sign_grinding_hashes=grinding + fors_grind.hashes - (0 if fors_c else 2), - wots_c_grinding_hashes=grinding, - fors_c_grinding_hashes=fors_grind.hashes if fors_c else 0, - verify_hashes=verify.hashes, - verify_compressions=verify.compressions, - verify_hashes_worst=verify_worst.hashes, - verify_compressions_worst=verify_worst.compressions, - cache_depth=hp - c, - cache_bytes=cache_bytes, - sign_cached_hashes=sign_cached.hashes, - sign_cached_compressions=sign_cached.compressions, + sig_bytes=c.sig_bytes, + keygen_hashes=c.keygen.hashes, + keygen_compressions=c.keygen.compressions, + sign_hashes=c.sign.hashes, + sign_compressions=c.sign.compressions, + sign_grinding_hashes=c.wots_c_grinding + c.fors_c_grinding, + wots_c_grinding_hashes=c.wots_c_grinding, + fors_c_grinding_hashes=c.fors_c_grinding, + verify_hashes=c.verify.hashes, + verify_compressions=c.verify.compressions, + verify_hashes_worst=c.verify_worst.hashes, + verify_compressions_worst=c.verify_worst.compressions, + cache_depth=c.cache_depth, + cache_bytes=c.cache_bytes, + sign_cached_hashes=c.sign_cached.hashes, + sign_cached_compressions=c.sign_cached.compressions, ) From 32044d1c22ecdac834d156c69f73e9613de5c342 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 12:36:03 +0200 Subject: [PATCH 03/31] doc/sphincs: rewrite in Rust, and make the search exhaustive Replaces the two python scripts with a dependency-free cargo project in its own workspace. The point of the port is not speed for its own sake: it buys enough of it that the search needs no cleverness at all. The python search leaned on two monotonicity arguments to stay tractable: only the smallest secure k was ever costed, and the target sum was reached by binary search. Both are provable, but both had to be trusted. Here every (scheme, h, d | h, chain_bits, dropped_chains, a, k, S_wn) point is costed and compared. The three tests that run before the target-sum scan reject only points no target sum could rescue: size and keygen do not depend on the target sum at all, and the least grinding any target sum can ask for is read off the digit-sum table rather than assumed to sit at the mean. That table is the other reason this is affordable. nu is now the coefficient vector of (1+x+..+x^(w-1))^l, built once per (l, w) by convolution, u128 throughout because every coefficient is bounded by the total w^l <= 2^128. The report's inclusion-exclusion formula needs bignums for intermediates that dwarf their own result, and it was what made the python inner loop slow. Costs come out identical to the python, which came out identical to the sage scripts. The 2^30 query lands on the same winner in 4.3s against 61s, having costed 150M parameter sets rather than 2.5M; the query with budgets so loose that nothing prunes evaluates 3.7 billion feasible points in 24s, where the fully exhaustive python would have run for hours. No threads. Search ranges are now hardcoded constants (h <= 96, a <= 32, k <= 64, chain_bits <= 12, dropped <= 16), wide enough that the budgets normally bind. When a winner comes out at the top of one, the run says so and names the constant to raise, since there the range and not the budget may be what is limiting the answer. Two bugs found in the port, neither present in the python: - ceil(2^128 / nu) carried its +1 past u128 when nu = 1, wrapping to zero, so the maximal target sum looked free to grind and won every comparison. - 1u64 << h' masks the shift rather than overflowing, so h = 64, d = 1 reported the keygen of a one-leaf tree. Trees that do not fit a u64 count are now rejected, which no budget expressible in a u64 could admit anyway. tests/goldens.rs carries every fixture the python had: the upstream sage fixtures under both hash conventions, all 18 WOTS/FORS rows of the report's Tables 1 and 2, the doc/xmss digest-cut geometry, exact nu values, thirteen security levels against the 100-digit decimal sum (the f64 log2-space sum here agrees to under a thousandth of a bit), and the search against a naive oracle that skips nothing, tuple by tuple rather than just on the winner. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 1 + doc/sphincs/Cargo.lock | 7 + doc/sphincs/Cargo.toml | 17 + doc/sphincs/README.md | 14 + doc/sphincs/search.py | 470 ------------------- doc/sphincs/sphincs_params.py | 835 ---------------------------------- doc/sphincs/src/cost.rs | 298 ++++++++++++ doc/sphincs/src/lib.rs | 45 ++ doc/sphincs/src/main.rs | 249 ++++++++++ doc/sphincs/src/params.rs | 315 +++++++++++++ doc/sphincs/src/report.rs | 181 ++++++++ doc/sphincs/src/search.rs | 403 ++++++++++++++++ doc/sphincs/src/security.rs | 118 +++++ doc/sphincs/tests/goldens.rs | 453 ++++++++++++++++++ 14 files changed, 2101 insertions(+), 1305 deletions(-) create mode 100644 doc/sphincs/Cargo.lock create mode 100644 doc/sphincs/Cargo.toml create mode 100644 doc/sphincs/README.md delete mode 100755 doc/sphincs/search.py delete mode 100755 doc/sphincs/sphincs_params.py create mode 100644 doc/sphincs/src/cost.rs create mode 100644 doc/sphincs/src/lib.rs create mode 100644 doc/sphincs/src/main.rs create mode 100644 doc/sphincs/src/params.rs create mode 100644 doc/sphincs/src/report.rs create mode 100644 doc/sphincs/src/search.rs create mode 100644 doc/sphincs/src/security.rs create mode 100644 doc/sphincs/tests/goldens.rs diff --git a/AGENTS.md b/AGENTS.md index 37940cd96..3706648e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ 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 a parameter-exploration tool for SPHINCS+ (security, signature size, hash counts, and a search for the cheapest verification under given budgets), in its own cargo workspace with no dependencies. It models the schemes of the Blockstream report, not anything this repo proves, and its `cargo test --release` pins every number against that project's sage fixtures. - The one hash function is BLAKE2s, in `primitives::blake2s`: 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). diff --git a/doc/sphincs/Cargo.lock b/doc/sphincs/Cargo.lock new file mode 100644 index 000000000..f16565f89 --- /dev/null +++ b/doc/sphincs/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "sphincs_params" +version = "0.1.0" diff --git a/doc/sphincs/Cargo.toml b/doc/sphincs/Cargo.toml new file mode 100644 index 000000000..1387cca24 --- /dev/null +++ b/doc/sphincs/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "sphincs_params" +version = "0.1.0" +edition = "2024" + +# Its own workspace: a parameter-exploration tool for doc/, not part of the +# proving stack, and nothing in crates/ depends on it. +[workspace] + +[dependencies] + +[lints.clippy] +too_many_arguments = "allow" + +[profile.release] +lto = "thin" +codegen-units = 1 diff --git a/doc/sphincs/README.md b/doc/sphincs/README.md new file mode 100644 index 000000000..3528a6525 --- /dev/null +++ b/doc/sphincs/README.md @@ -0,0 +1,14 @@ +# SPHINCS+ parameters + +Security, signature size and hash counts for the WOTS/FORS schemes of "Hash-based Signature Schemes for Bitcoin" (Kudinov, Nick, Blockstream Research), plus a search for the parameter set that verifies cheapest under a given set of budgets. See `src/lib.rs` for what is modelled and what is deliberately not. + +Its own cargo workspace, no dependencies, not a member of the repo's workspace. + +```sh +cd doc/sphincs +cargo run --release -- params --scheme W+C_F+C --lifetime 40 --height 40 --layers 5 -a 14 -k 11 -w 256 +cargo run --release -- search --lifetime 30 --max-keygen 2e6 --max-sign 6e6 --max-sign-cached 4e6 --max-size 4000 +cargo test --release # goldens: upstream sage fixtures, the report's tables, a naive search oracle +``` + +`cargo run --release --` with no subcommand prints the full option list. diff --git a/doc/sphincs/search.py b/doc/sphincs/search.py deleted file mode 100755 index 3c4278c50..000000000 --- a/doc/sphincs/search.py +++ /dev/null @@ -1,470 +0,0 @@ -#!/usr/bin/env python3 -"""Search the SPHINCS+ parameter space for the cheapest verification. - -Given a lifetime and a budget for each of the other four costs: - - --lifetime log2 of the signatures allowed under one public key - --max-keygen hashes at key generation - --max-sign hashes at signing, vanilla - --max-sign-cached hashes at signing with the top tree's half top cached - --max-size signature bytes - -this enumerates the parameter space and reports the sets that minimize -verification cost subject to NIST level 1 security (128-bit classical, the -SLH-DSA level 1 target), computed the same way as in the report: the FORS -subset-forgery sum of security.sage, which must reach 128 bits at the given -lifetime, capped by the 2^-n preimage bound. - -The cost model is sphincs_params.py; this file only searches. Schemes are that -module's SPX / W+C / W+C_F+C, and the WOTS+C digest cut is its bit-pinning -Encoding, so --chain-bits and --drop-chains span the same axes here. - -Two facts keep the space small enough to brute force in Python: - - * k is not searched. For a fixed (h, a) the forgery exponent is increasing in - k while size, signing and verification all grow with it, so the only k worth - considering is the smallest one that reaches the security target. That turns - a 2-D (a, k) sweep into a 1-D one plus a cached binary search on k. - - * S_wn is not searched either. Verification is strictly decreasing in it and - the grinding is increasing in it above the mean, so the best S_wn is simply - the largest one whose grinding still fits the signing budgets: another - binary search, not an axis. - -What remains is (scheme, h, d | h, chain_bits, dropped_chains, a), pruned by -keygen before a is reached and by size and signing before S_wn is. Run with ---stats to see how big the space actually was; if a wider grid is wanted than -Python will sit through, this is the file to port, not the cost model. -""" - -from __future__ import annotations - -import argparse -import os -import sys -import time -from dataclasses import dataclass -from functools import lru_cache -from math import log2 - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from sphincs_params import SCHEMES, Convention, Costs, Encoding, costs, evaluate, fors_forgery_exponent, report - -LEVEL1_BITS = 128 # NIST level 1, matching SLH-DSA's level 1 parameter sets - - -# --------------------------------------------------------------------------- -# Constraints and grid -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class Budgets: - lifetime: int # log2 of the signatures per public key - max_keygen: int - max_sign: int - max_sign_cached: int - max_size: int - security: float = LEVEL1_BITS - unit: str = "hashes" # "hashes" or "compressions", for the budgets and the objective - - def of(self, cost) -> int: - return getattr(cost, self.unit) - - -@dataclass(frozen=True) -class Grid: - schemes: tuple[str, ...] = SCHEMES - n: int = 16 - h_min: int = 1 - h_max: int = 96 - a_min: int = 1 - a_max: int = 32 - k_max: int = 64 - chain_bits: tuple[int, ...] = (1, 2, 3, 4, 5, 6, 7, 8) - max_dropped: int = 8 - cache_level_only: bool = False - - -@dataclass -class Candidate: - scheme: str - h: int - d: int - a: int - k: int - w: int - dropped_chains: int - c: Costs - - @property - def swn(self) -> int | None: - return self.c.swn - - -# --------------------------------------------------------------------------- -# Security: the smallest secure k for a given (h, a) -# --------------------------------------------------------------------------- - - -@lru_cache(maxsize=1 << 16) -def min_secure_k(lifetime: int, h: int, a: int, target: float, k_max: int) -> int | None: - """Smallest k reaching `target` bits, or None if no k <= k_max does. - - The forgery exponent is increasing in k: each extra FORS tree is one more - tree whose required leaf the adversary needs already opened. So the feasible - k form an up-set and a binary search finds its floor. - """ - try: - if fors_forgery_exponent(lifetime, h, k_max, a) < target: - return None - except ValueError: - return None # q_s so far past 2^h that the sum does not converge - lo, hi = 1, k_max # invariant: hi secure - while lo < hi: - mid = (lo + hi) // 2 - if fors_forgery_exponent(lifetime, h, mid, a) >= target: - hi = mid - else: - lo = mid + 1 - return lo - - -# --------------------------------------------------------------------------- -# Search -# --------------------------------------------------------------------------- - - -@dataclass -class Stats: - grid: int = 0 # (scheme, h, d, chain_bits, dropped) tuples visited - keygen_pruned: int = 0 - insecure: int = 0 # a-loop iterations, not distinct (h, a) pairs - size_pruned: int = 0 - sign_pruned: int = 0 - evaluated: int = 0 # candidates that got an S_wn search - costs_calls: int = 0 - seconds: float = 0.0 - - def __str__(self) -> str: - return ( - f"grid {self.grid} (scheme, h, d, chain_bits, dropped) tuples, " - f"{self.keygen_pruned} over keygen budget; then, over the a loop, {self.insecure} with no secure k, " - f"{self.size_pruned} over size, {self.sign_pruned} over signing, " - f"{self.evaluated} costed with an S_wn search ({self.costs_calls} cost-model calls) in {self.seconds:.1f}s" - ) - - -def _divisors(h: int) -> list[int]: - return [d for d in range(1, h + 1) if h % d == 0] - - -def search(b: Budgets, g: Grid | None = None, stats: Stats | None = None) -> list[Candidate]: - """Every feasible parameter set, ordered by verification cost.""" - g = g or Grid() - st = stats if stats is not None else Stats() - started = time.perf_counter() - cv = Convention() - found: list[Candidate] = [] - - def cost(scheme, h, d, a, k, w, dropped, swn) -> Costs: - st.costs_calls += 1 - return costs(h, d, a, k, w, scheme, swn, g.n, dropped, None, g.cache_level_only, cv) - - # Necessary conditions, used only to bound the grid. The signature carries - # h authentication nodes, so h <= max_size/n; signing grows a FORS tree of - # 2^a leaves, so 2^a <= max_sign. Anything outside cannot become feasible. - h_max = min(g.h_max, b.max_size // g.n) - a_max = min(g.a_max, int(log2(max(b.max_sign, 2)))) - - for scheme in g.schemes: - spx = scheme == "SPX" - for h in range(g.h_min, h_max + 1): - for d in _divisors(h): - for bits in g.chain_bits: - w = 1 << bits - # WOTS-TW has no counter to grind, so it cannot drop chains, - # and WOTS+C has to keep at least one. - max_dropped = 0 if spx else min(g.max_dropped, Encoding(w, g.n).chains - 1) - for dropped in range(max_dropped + 1): - st.grid += 1 - # keygen needs no a, k: one top tree of 2^(h/d) leaves - probe = cost(scheme, h, d, g.a_min, 2, w, dropped, None) - if b.of(probe.keygen) > b.max_keygen: - st.keygen_pruned += 1 - continue - for a in range(g.a_min, a_max + 1): - k = min_secure_k(b.lifetime, h, a, b.security, g.k_max) - if k is None: - st.insecure += 1 - continue - if scheme == "W+C_F+C": - k = max(k, 2) # FORS+C signs k-1 of them - c = cost(scheme, h, d, a, k, w, dropped, None) - if c.sig_bytes > b.max_size: - st.size_pruned += 1 - continue - if b.of(c.sign) > b.max_sign or b.of(c.sign_cached) > b.max_sign_cached: - st.sign_pruned += 1 # the mean S_wn grinds least, so no S_wn fits - continue - st.evaluated += 1 - if not spx: - c = _push_swn(b, cost, scheme, h, d, a, k, w, dropped, c) - found.append(Candidate(scheme, h, d, a, k, w, dropped, c)) - - st.seconds = time.perf_counter() - started - found.sort(key=lambda cand: (b.of(cand.c.verify), cand.c.sig_bytes, b.of(cand.c.sign))) - return found - - -def _push_swn(b: Budgets, cost, scheme, h, d, a, k, w, dropped, at_mean: Costs) -> Costs: - """Raise S_wn as far as the signing budgets allow, which is where verification is cheapest. - - Verification walks (w-1)*l - S_wn chain steps, so it falls by d for every - step S_wn gains, while the grinding rises monotonically above the mean. - `at_mean` is feasible by construction, so this is a binary search on an - up-set with a known floor. - """ - lo = at_mean.swn or 0 - hi = at_mean.l * (w - 1) - best = at_mean - while lo < hi: - mid = (lo + hi + 1) // 2 - c = cost(scheme, h, d, a, k, w, dropped, mid) - if b.of(c.sign) <= b.max_sign and b.of(c.sign_cached) <= b.max_sign_cached: - lo, best = mid, c - else: - hi = mid - 1 - return best - - -# --------------------------------------------------------------------------- -# Output -# --------------------------------------------------------------------------- - - -def _si(x: float) -> str: - for unit, div in (("G", 1e9), ("M", 1e6), ("K", 1e3)): - if x >= div: - return f"{x / div:.2f}{unit}" - return str(int(x)) - - -COLUMNS = ( - ("verify", 9, lambda b, c: _si(b.of(c.c.verify))), - ("scheme", 9, lambda b, c: c.scheme), - ("h", 4, lambda b, c: str(c.h)), - ("d", 3, lambda b, c: str(c.d)), - ("h'", 4, lambda b, c: str(c.h // c.d)), - ("a", 3, lambda b, c: str(c.a)), - ("k", 3, lambda b, c: str(c.k)), - ("cb", 3, lambda b, c: str(c.c.chain_bits)), - ("drop", 5, lambda b, c: str(c.dropped_chains)), - ("l", 4, lambda b, c: str(c.c.l)), - ("S_wn", 6, lambda b, c: "-" if c.swn is None else str(c.swn)), - ("size", 6, lambda b, c: str(c.c.sig_bytes)), - ("keygen", 8, lambda b, c: _si(b.of(c.c.keygen))), - ("sign", 8, lambda b, c: _si(b.of(c.c.sign))), - ("sign$", 8, lambda b, c: _si(b.of(c.c.sign_cached))), - ("state", 7, lambda b, c: str(c.c.cache_bytes)), -) - - -def table(b: Budgets, cands: list[Candidate]) -> str: - lines = [" ".join(name.rjust(width) for name, width, _ in COLUMNS)] - lines.append("-" * len(lines[0])) - for c in cands: - lines.append(" ".join(fmt(b, c).rjust(width) for _, width, fmt in COLUMNS)) - return "\n".join(lines) - - -def utilization(b: Budgets, c: Candidate) -> str: - used = ( - ("keygen", b.of(c.c.keygen), b.max_keygen), - ("sign", b.of(c.c.sign), b.max_sign), - ("sign cached", b.of(c.c.sign_cached), b.max_sign_cached), - ("size", c.c.sig_bytes, b.max_size), - ) - return ", ".join(f"{name} {100 * v / lim:.0f}%" for name, v, lim in used if lim) - - -# --------------------------------------------------------------------------- -# Self-test: the two shortcuts above are exact, checked against exhaustion -# --------------------------------------------------------------------------- - - -def exhaustive(b: Budgets, g: Grid) -> list[Candidate]: - """The same search with nothing pruned: every k, every S_wn. - - Only usable on a tiny grid, which is the point: it is what the pruned - search is diffed against. - """ - cv = Convention() - found: list[Candidate] = [] - for scheme in g.schemes: - spx = scheme == "SPX" - for h in range(g.h_min, g.h_max + 1): - for d in _divisors(h): - for bits in g.chain_bits: - w = 1 << bits - for dropped in range(1 if spx else g.max_dropped + 1): - l = Encoding(w, g.n, dropped).chains - for a in range(g.a_min, g.a_max + 1): - for k in range(2 if scheme == "W+C_F+C" else 1, g.k_max + 1): - if fors_forgery_exponent(b.lifetime, h, k, a) < b.security: - continue - for swn in [None] if spx else range(l * (w - 1) + 1): - c = costs(h, d, a, k, w, scheme, swn, g.n, dropped, None, g.cache_level_only, cv) - if c.sig_bytes > b.max_size or b.of(c.keygen) > b.max_keygen: - continue - if b.of(c.sign) > b.max_sign or b.of(c.sign_cached) > b.max_sign_cached: - continue - found.append(Candidate(scheme, h, d, a, k, w, dropped, c)) - found.sort(key=lambda cand: (b.of(cand.c.verify), cand.c.sig_bytes, b.of(cand.c.sign))) - return found - - -def selftest() -> int: - fails = 0 - - def check(name, got, want): - nonlocal fails - ok = got == want - fails += not ok - if not ok: - print(f"FAIL {name}: got {got}, want {want}") - - # 1. The digit-sum count is unimodal with its peak at the mean, so grinding - # only rises above the mean and the S_wn binary search cannot skip an - # admissible larger S_wn. - for bits in (1, 2, 3, 4, 8): - w = 1 << bits - enc = Encoding(w, 16) - mean, top = enc.default_swn, enc.chains * (w - 1) - nus = [enc.admissible(s) for s in range(mean, min(top, mean + 60) + 1)] - check(f"nu non-increasing above the mean (w={w})", nus == sorted(nus, reverse=True), True) - check(f"nu peaks at the mean (w={w})", enc.admissible(mean) >= enc.admissible(mean - 1), True) - - # 2. min_secure_k is the floor of the secure k, by linear scan. - for h, a in ((20, 10), (24, 12), (30, 14)): - k = min_secure_k(20, h, a, LEVEL1_BITS, 32) - scan = next((kk for kk in range(1, 33) if fors_forgery_exponent(20, h, kk, a) >= LEVEL1_BITS), None) - check(f"min_secure_k(h={h}, a={a})", k, scan) - - # 3. On a grid small enough to exhaust, the pruned search finds the same - # optimum as the sweep over every k and every S_wn. - b = Budgets(lifetime=20, max_keygen=3_000_000, max_sign=10_000_000, max_sign_cached=10_000_000, max_size=4_000) - g = Grid(schemes=("W+C", "W+C_F+C"), h_min=20, h_max=20, a_min=14, a_max=16, k_max=14, chain_bits=(4,), max_dropped=1) - pruned, full = search(b, g), exhaustive(b, g) - check("exhaustive agrees on the optimum", b.of(pruned[0].c.verify), b.of(full[0].c.verify)) - check( - "exhaustive agrees on the winner", - (pruned[0].scheme, pruned[0].h, pruned[0].d, pruned[0].a, pruned[0].k, pruned[0].w, pruned[0].swn), - (full[0].scheme, full[0].h, full[0].d, full[0].a, full[0].k, full[0].w, full[0].swn), - ) - - print("selftest: " + ("all checks passed" if not fails else f"{fails} failures")) - return fails - - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - - -def _budget(text: str) -> int: - return int(float(text)) - - -def main() -> int: - p = argparse.ArgumentParser( - description=(__doc__ or "").splitlines()[0], - epilog="example: search.py --lifetime 30 --max-keygen 2e6 --max-sign 6e6 --max-sign-cached 4e6 --max-size 4000", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - p.add_argument("--lifetime", type=int, default=None, metavar="LOG2", help="log2 of the signatures allowed per public key") - p.add_argument("--max-keygen", type=_budget, default=None, metavar="N", help="budget for keygen") - p.add_argument("--max-sign", type=_budget, default=None, metavar="N", help="budget for average signing") - p.add_argument("--max-sign-cached", type=_budget, default=None, metavar="N", help="budget for average signing with the half top cached") - p.add_argument("--max-size", type=_budget, default=None, metavar="B", help="budget for the signature, in bytes") - p.add_argument( - "--security", type=float, default=LEVEL1_BITS, metavar="BITS", help=f"classical security floor (default {LEVEL1_BITS}, NIST level 1)" - ) - p.add_argument("--unit", choices=("hashes", "compressions"), default="hashes", help="unit of every budget and of the objective (default hashes)") - p.add_argument("--scheme", action="append", choices=SCHEMES, help="restrict the schemes searched (repeatable, default all)") - p.add_argument("-n", type=int, default=16, help="hash output in bytes (default 16)") - p.add_argument("--top", type=int, default=15, help="rows to print (default 15)") - p.add_argument("--h-max", type=int, default=Grid.h_max, help=f"largest hypertree height searched (default {Grid.h_max})") - p.add_argument("--a-max", type=int, default=Grid.a_max, help=f"largest FORS a searched (default {Grid.a_max})") - p.add_argument("--k-max", type=int, default=Grid.k_max, help=f"largest FORS k considered secure (default {Grid.k_max})") - p.add_argument("--chain-bits", type=int, action="append", metavar="B", help="restrict log2(w) searched (repeatable, default 1..8)") - p.add_argument("--max-dropped", type=int, default=Grid.max_dropped, help=f"most WOTS+C chains dropped (default {Grid.max_dropped})") - p.add_argument("--cache-level-only", action="store_true", help="cache one top-tree level rather than it and everything above") - p.add_argument("--stats", action="store_true", help="report how much of the space was visited") - p.add_argument("--selftest", action="store_true", help="check the k and S_wn shortcuts against an exhaustive sweep") - args = p.parse_args() - - if args.selftest: - return 1 if selftest() else 0 - missing = [f for f in ("lifetime", "max_keygen", "max_sign", "max_sign_cached", "max_size") if getattr(args, f) is None] - if missing: - p.error("required unless --selftest: " + ", ".join("--" + f.replace("_", "-") for f in missing)) - - b = Budgets( - lifetime=args.lifetime, - max_keygen=args.max_keygen, - max_sign=args.max_sign, - max_sign_cached=args.max_sign_cached, - max_size=args.max_size, - security=args.security, - unit=args.unit, - ) - g = Grid( - schemes=tuple(args.scheme) if args.scheme else SCHEMES, - n=args.n, - h_max=args.h_max, - a_max=args.a_max, - k_max=args.k_max, - chain_bits=tuple(sorted(set(args.chain_bits))) if args.chain_bits else Grid.chain_bits, - max_dropped=args.max_dropped, - cache_level_only=args.cache_level_only, - ) - - stats = Stats() - found = search(b, g, stats) - if args.stats: - print(stats) - print() - if not found: - print("no parameter set meets these budgets at " + f"{b.security:g}-bit security and q_s = 2^{b.lifetime}") - print("the binding budget is usually size or keygen; --stats says which pruned everything") - return 1 - - print(f"{len(found)} feasible sets, best {min(args.top, len(found))} by verification {b.unit}:") - print() - print(table(b, found[: args.top])) - print() - - best = found[0] - print(f"budget use of the best: {utilization(b, best)}") - print() - r = evaluate( - best.h, - best.d, - best.a, - best.k, - best.w, - b.lifetime, - scheme=best.scheme, - swn=best.swn, - n=g.n, - dropped_chains=best.dropped_chains, - cache_level_only=g.cache_level_only, - ) - print(report(r)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/doc/sphincs/sphincs_params.py b/doc/sphincs/sphincs_params.py deleted file mode 100755 index 8a1966d37..000000000 --- a/doc/sphincs/sphincs_params.py +++ /dev/null @@ -1,835 +0,0 @@ -#!/usr/bin/env python3 -"""SPHINCS+ parameter calculator: security, signature size, and hash counts. - -Covers the WOTS-based / FORS-based schemes of "Hash-based Signature Schemes for -Bitcoin" (Kudinov, Nick, Blockstream Research, rev. 2025-12-05) and its scripts -at github.com/BlockstreamResearch/SPHINCS-Parameters: - - SPX plain SPHINCS+ (SLH-DSA): WOTS-TW + FORS - W+C WOTS+C (fixed digit sum, no checksum chains) + FORS - W+C_F+C WOTS+C + FORS+C (last FORS tree removed by grinding) - -PORS+FP is deliberately not implemented. - -WOTS+C shortens its signature by dropping chains, and this script does that the -way doc/xmss/main.tex does: it pins the top bits of the digest to zero instead -of forcing whole digits, so the digest is always a whole number of base-w chunks -(see the Encoding class). The default pins the minimum that makes the cut -integral; --drop-chains buys further chains at log2(w) pinned bits each, every -pinned bit doubling the expected grinding. - -For a parameter set it reports: - - * classical security in bits (FORS subset-forgery vs. preimage bound) - * signature size in bytes - * hashes at key generation - * expected hashes at signing - * hashes at verification - * expected hashes at signing with the top tree's "half top" cached, i.e. - keeping the nodes of the top XMSS tree at depth ceil(h'/2) as signer - state: sqrt(2^h') storage buys a sqrt(2^h') top-tree cost per signature - -Two units are reported for every cost, matching the report's tables: - - hashes tweakable-hash / PRF invocations (the report's "hash" columns) - compressions SHA-256 compression calls (the report's Compr. columns) - -The compression counts follow the FIPS 205 SHA-2 layout with the PK.seed -midstate cached; pass --uncached to charge every call for its full input. - -Numbers reproduce costs.sage / security.sage exactly; run --selftest to check -against the golden values frozen in that repo's tests/fixtures.json. -""" - -from __future__ import annotations - -import argparse -from dataclasses import asdict, dataclass -from decimal import Decimal, getcontext -from functools import lru_cache -from math import ceil, floor, log2 - -getcontext().prec = 120 - -SCHEMES = ("SPX", "W+C", "W+C_F+C") - -COUNTER_BYTES = 4 # WOTS+C grinding counter carried per hypertree layer - - -# --------------------------------------------------------------------------- -# Hash-cost conventions -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class Convention: - """Compression calls charged to each kind of hash invocation.""" - - cached_midstate: bool = True - - @property - def th1(self) -> int: - return 1 # PK.seed + ADRS + one n-byte value - - @property - def th1c(self) -> int: - return 1 # ... + the 4-byte WOTS+C counter - - @property - def th2(self) -> int: - return 1 if self.cached_midstate else 2 # two n-byte children - - @property - def hmsg(self) -> int: - return 2 # PK.seed + PK.root + R + message digest - - @property - def prfmsg(self) -> int: - return 2 # SK.prf + opt + message - - @property - def prf(self) -> int: - return 1 # PK.seed + SK.seed + ADRS - - def th(self, m: int, n: int) -> int: - """Compressions for a tweakable hash over m n-byte values.""" - prefix = 22 * 8 if self.cached_midstate else 8 * (n + 12) - return ceil((prefix + 8 * n * m + 65) / 512) - - -# --------------------------------------------------------------------------- -# WOTS -# --------------------------------------------------------------------------- - - -def wots_len1(w: int, n: int) -> int: - """Message chains: enough base-w digits to carry an n-byte digest.""" - return ceil(8 * n / log2(w)) - - -def wots_len2(w: int, n: int) -> int: - """Checksum chains of WOTS-TW (FIPS 205 form).""" - l1 = wots_len1(w, n) - return floor(log2(l1 * (w - 1)) / log2(w)) + 1 - - -@dataclass(frozen=True) -class Encoding: - """How a WOTS+C digest is cut into base-w chain positions. - - The report drops chains by forcing their digits to zero (its parameter z). - This script instead uses the bit-pinning variant the report offers as an - alternative in "Complexity Analysis of WOTS+C" (its z_b), which is what - doc/xmss/main.tex does, because it keeps the digest a whole number of - chunks and needs no partial-digit handling anywhere: - - chain_bits = log2(w) bits one chain carries - pinned = (8n) mod chain_bits + chain_bits * dropped_chains - chains = (8n - pinned) / chain_bits = floor(8n/chain_bits) - dropped - - The signer grinds the counter until the digest has its `pinned` top bits - zero AND its `chains` digits summing to S_wn, so out of the 2^(8n) digests - exactly nu = |{tuples summing to S_wn}| are admissible. - - Pinning is not free: every pinned bit halves the admissible fraction, so - `pinned` bits multiply the expected grinding by 2^pinned. It buys chains - cheaply though. The default is the minimum that leaves 8n - pinned a - multiple of chain_bits, and what it saves is the extra, only partly used - chain that ceil(8n / chain_bits) would need: n bytes of signature for a - factor 2^(8n mod chain_bits), which at chain_bits = 3 is 16 bytes for 4x - on a per-layer grind of a few hundred hashes. Each further dropped chain - then saves another n bytes for a factor of about w. - - doc/xmss/main.tex is the (n=128, chain_bits=3) instance: 128 mod 3 = 2 bits - pinned, v = 42 chains, T = 195. Dropping one more chain there would pin - 2 + 3 = 5 bits and leave 41 chains. For every w the report itself uses - (16 and 256) chain_bits divides 128, so nothing is pinned and this - reproduces its numbers exactly. - """ - - w: int - n: int - dropped_chains: int = 0 - - @property - def chain_bits(self) -> int: - return int(log2(self.w)) - - @property - def pinned_bits(self) -> int: - return 8 * self.n % self.chain_bits + self.chain_bits * self.dropped_chains - - @property - def chains(self) -> int: - chains = (8 * self.n - self.pinned_bits) // self.chain_bits - if chains < 1: - raise ValueError(f"dropped_chains={self.dropped_chains} leaves no chain to sign") - return chains - - @property - def default_swn(self) -> int: - """Mean digit sum, where the admissible digests are densest.""" - return self.chains * (self.w - 1) // 2 - - def admissible(self, swn: int) -> int: - """nu: digests with the pinned bits zero and digits summing to swn.""" - return wots_c_encodings(self.chains, swn, self.w) - - def expected_trials(self, swn: int) -> int: - """Counter values tried per layer, 2^(8n) / nu by the geometric law.""" - return -(-(1 << (8 * self.n)) // self.admissible(swn)) - - -def wots_chains(scheme: str, w: int, n: int, dropped_chains: int = 0) -> int: - """Chains actually signed: l1 + l2 for WOTS-TW, the encoding's for WOTS+C.""" - if scheme == "SPX": - return wots_len1(w, n) + wots_len2(w, n) - return Encoding(w, n, dropped_chains).chains - - -@lru_cache(maxsize=1 << 16) -def wots_c_encodings(l: int, swn: int, w: int) -> int: - """nu: number of l-tuples over [0, w-1] summing to exactly swn.""" - from math import comb - - nu = 0 - for j in range(l + 1): - top = (swn + l) - j * w - 1 - nu += (-1) ** j * comb(l, j) * (comb(top, l - 1) if top >= l - 1 else 0) - if nu <= 0: - raise ValueError(f"no encoding of {l} base-{w} digits sums to S_wn={swn}") - return nu - - -def wots_tw_worst_steps(w: int, n: int) -> int: - """Verifier chain steps for WOTS-TW when every message digit is zero.""" - l1, l2 = wots_len1(w, n), wots_len2(w, n) - c, ds = l1 * (w - 1), 0 - rem = c - while rem: - ds += rem % w - rem //= w - return l1 * (w - 1) + l2 * (w - 1) - ds - - -# --------------------------------------------------------------------------- -# Classical security -# --------------------------------------------------------------------------- - - -def fors_forgery_exponent(q_s_log2: int, h: int, k: int, a: int, r_cap: int = 1 << 18) -> float: - """-log2 P(FORS subset forgery) after q_s = 2^q_s_log2 signatures. - - An adversary that finds a hypertree leaf reused r times, and a message whose - k FORS indices all point at leaves those r signatures already opened, forges - without inverting anything: - - P = sum_r C(q_s, r) p^r (1-p)^(q_s-r) * (1 - (1 - 1/t)^r)^k - - with p = 2^-h the chance one signature lands on a given leaf and t = 2^a. - The binomial term is carried by its recurrence rather than built from - C(q_s, r) directly, so q_s = 2^64 costs the same as q_s = 2^20. - """ - q_s = Decimal(2) ** q_s_log2 - p = Decimal(2) ** -h - t = Decimal(2) ** a - one_minus_p = 1 - p - ratio = p / one_minus_p - miss = 1 - 1 / t # P(one signature misses a given leaf of one FORS tree) - - lam = 2.0 ** (q_s_log2 - h) # expected times one FORS instance is reused - if lam > 4096: - raise ValueError( - f"q_s = 2^{q_s_log2} over 2^{h} hypertree leaves reuses every FORS instance ~2^{q_s_log2 - h} times: no security is left to quantify" - ) - r_max = min(r_cap, max(1000, int(lam + 40 * (lam + 1) ** 0.5) + 40)) - floor_prob = Decimal(2) ** -1250 - relative_floor = Decimal(2) ** -80 - - term = one_minus_p**q_s # C(q_s,0) p^0 (1-p)^q_s - miss_r = Decimal(1) - sigma = Decimal(0) - r = 0 - while r < r_max: - r += 1 - term *= (q_s - r + 1) / Decimal(r) * ratio - miss_r *= miss - contribution = term * (1 - miss_r) ** k - sigma += contribution - if r > lam and (contribution < floor_prob or contribution < sigma * relative_floor): - break - else: - raise ValueError(f"security sum did not converge within r <= {r_max}; parameters are far below any usable level") - - if sigma <= 0: - return float("inf") - return float(-sigma.ln() / Decimal(2).ln()) - - -def security_bits(q_s_log2: int, h: int, k: int, a: int, n: int) -> float: - """Classical bit security: forgery exponent capped by the preimage bound. - - A query aimed at a FORS forgery cannot double as a preimage query for a tree - node or a WOTS chain (different tweaks), so the two attacks are independent - strategies and the adversary simply takes the better one. - """ - return min(8 * n, fors_forgery_exponent(q_s_log2, h, k, a)) - - -# --------------------------------------------------------------------------- -# Costs -# --------------------------------------------------------------------------- - - -@dataclass -class Cost: - """A cost in both units.""" - - hashes: int - compressions: int - - def __add__(self, other: Cost) -> Cost: - return Cost(self.hashes + other.hashes, self.compressions + other.compressions) - - def __sub__(self, other: Cost) -> Cost: - return Cost(self.hashes - other.hashes, self.compressions - other.compressions) - - def __mul__(self, m: int) -> Cost: - return Cost(self.hashes * m, self.compressions * m) - - __rmul__ = __mul__ - - -def _wots_leaf(l: int, w: int, n: int, cv: Convention) -> Cost: - """One WOTS key pair plus the compression of its l chain ends into a leaf.""" - return Cost(l + l * (w - 1) + 1, l * cv.prf + l * (w - 1) * cv.th1 + cv.th(l, n)) - - -def _xmss_tree(leaves: int, l: int, w: int, n: int, cv: Convention) -> Cost: - """Build a Merkle tree over `leaves` WOTS key pairs, from the seed up.""" - return leaves * _wots_leaf(l, w, n, cv) + Cost(leaves - 1, (leaves - 1) * cv.th2) - - -def _msg_hash(cv: Convention) -> Cost: - """R = PRF_msg(...) and the randomized message digest H_msg(...).""" - return Cost(2, cv.hmsg + cv.prfmsg) - - -def _fors_build(trees: int, a: int, n: int, cv: Convention) -> Cost: - """Grow `trees` FORS trees of 2^a secret leaves and compress their roots.""" - t = 1 << a - return Cost( - trees * t + trees * t + trees * (t - 1) + 1, - trees * t * cv.prf + trees * t * cv.th1 + trees * (t - 1) * cv.th2 + cv.th(trees, n), - ) - - -def _fors_verify(trees: int, a: int, n: int, cv: Convention) -> Cost: - """Hash `trees` opened leaves up their auth paths and compress the roots.""" - return Cost( - trees + trees * a + 1, - trees * cv.th1 + trees * a * cv.th2 + cv.th(trees, n), - ) - - -# --------------------------------------------------------------------------- -# Top level -# --------------------------------------------------------------------------- - - -@dataclass -class Costs: - """Everything about a parameter set that does not depend on q_s.""" - - l: int - chain_bits: int - pinned_bits: int - dropped_chains: int - swn: int | None - sig_bytes: int - keygen: Cost - sign: Cost - sign_cached: Cost - verify: Cost - verify_worst: Cost - wots_c_grinding: int - fors_c_grinding: int - cache_depth: int - cache_bytes: int - - -@dataclass -class Result: - scheme: str - q_s_log2: int - n: int - h: int - d: int - h_prime: int - a: int - k: int - w: int - l: int - chain_bits: int - pinned_bits: int - dropped_chains: int - swn: int | None - - security_bits: float - fors_forgery_bits: float - sig_bytes: int - - keygen_hashes: int - keygen_compressions: int - - sign_hashes: int - sign_compressions: int - sign_grinding_hashes: int - wots_c_grinding_hashes: int - fors_c_grinding_hashes: int - - verify_hashes: int - verify_compressions: int - verify_hashes_worst: int - verify_compressions_worst: int - - cache_depth: int - cache_bytes: int - sign_cached_hashes: int - sign_cached_compressions: int - - -def costs( - h: int, - d: int, - a: int, - k: int, - w: int, - scheme: str = "W+C_F+C", - swn: int | None = None, - n: int = 16, - dropped_chains: int = 0, - cache_height: int | None = None, - cache_level_only: bool = False, - convention: Convention | None = None, -) -> Costs: - """Size and hash counts of one parameter set, without the security level. - - Separate from evaluate() because the security sum is by far the most - expensive part and does not depend on any of this; a parameter search wants - the costs alone (see search.py). - - h, d hypertree height and number of layers (h' = h/d per XMSS tree) - a, k FORS trees of 2^a leaves, k of them - w Winternitz parameter - scheme "SPX", "W+C", or "W+C_F+C" - swn WOTS+C target digit sum S_{w,n}; defaults to the mean l*(w-1)/2 - dropped_chains chains dropped on top of the digest bits that have to be - pinned anyway, each one pinning log2(w) more bits: see - Encoding - cache_height height above the leaves of the cached top-tree level; the - default h'//2 is the "half top" (cached level at depth - ceil(h'/2), so the cheaper half of the tree is rebuilt) - cache_level_only store just that one level instead of it and everything - above, paying 2^ceil(h'/2)-1 hashes to rebuild the top - """ - if scheme not in SCHEMES: - raise ValueError(f"scheme must be one of {SCHEMES} (PORS+FP is out of scope)") - if h % d: - raise ValueError("d must divide h") - if log2(w) != int(log2(w)): - raise ValueError("w must be a power of two") - if scheme == "SPX" and dropped_chains: - raise ValueError("dropped_chains applies to WOTS+C only") - - cv = convention or Convention() - wots_c = scheme != "SPX" - fors_c = scheme == "W+C_F+C" - hp = h // d - enc = Encoding(w, n, dropped_chains) - l = wots_chains(scheme, w, n, dropped_chains) - swn_c = 0 if not wots_c else (enc.default_swn if swn is None else swn) - trees = k - 1 if fors_c else k # FORS+C grinds the last tree away - - # ---- size ---------------------------------------------------------- - layer = hp * n + l * n + (COUNTER_BYTES if wots_c else 0) - sig_bytes = n + d * layer + trees * n + trees * a * n - - # ---- hypertree, shared by keygen and signing ----------------------- - tree = _xmss_tree(1 << hp, l, w, n, cv) - trials = enc.expected_trials(swn_c) if wots_c else 0 - grinding = d * trials - hyper = d * tree + Cost(grinding, grinding * cv.th1c) - - # ---- keygen: the top tree only, to get PK.root --------------------- - keygen = tree - - # ---- signing ------------------------------------------------------- - fors = _fors_build(trees, a, n, cv) - if fors_c: - # grind the digest until its last a bits vanish, so the last FORS tree - # always opens leaf 0 and needs no authentication path - fors_grind = (1 << a) * _msg_hash(cv) - else: - fors_grind = _msg_hash(cv) - sign = hyper + fors + fors_grind - - # ---- verification -------------------------------------------------- - if wots_c: - # the digits sum to S_wn, so the remaining chain steps are fixed - wots_v = Cost((w - 1) * l - swn_c + 2, ((w - 1) * l - swn_c) * cv.th1 + cv.th1c + cv.th(l, n)) - wots_v_worst = wots_v - else: - wots_v = Cost((w - 1) * l // 2 + 1, (w - 1) * l // 2 * cv.th1 + cv.th(l, n)) - steps = wots_tw_worst_steps(w, n) - wots_v_worst = Cost(steps + 1, steps * cv.th1 + cv.th(l, n)) - fts_v = _fors_verify(trees, a, n, cv) - auth = Cost(h, h * cv.th2) - verify = Cost(1, cv.hmsg) + fts_v + d * wots_v + auth - verify_worst = Cost(1, cv.hmsg) + fts_v + d * wots_v_worst + auth - - # ---- signing with the top tree's half top cached ------------------- - # Only the top tree is worth caching: it is the same for every signature, - # while the trees below it are picked by the (pseudorandom) index. Its auth - # path splits at the cached level: below, rebuild the 2^c-leaf subtree the - # signing leaf sits in; above, the nodes are already in state. Rebuilt - # leaves are charged a full WOTS public key, as everywhere else here. - # - # A BDS-style traversal would amortize a tree to h' leaves per signature - # with O(h') state, but it only works walking the leaves in order. SPHINCS+ - # picks its index by hashing the message, so consecutive signatures land on - # unrelated leaves and nothing amortizes; an index-independent cache like - # this one is what is left, hence sqrt rather than h'. - c = hp // 2 if cache_height is None else cache_height - if not 0 <= c <= hp: - raise ValueError("cache_height must be in [0, h/d]") - stored_level = 1 << (hp - c) - cached_tree = _xmss_tree(1 << c, l, w, n, cv) - if cache_level_only: - cached_tree += Cost(stored_level - 1, (stored_level - 1) * cv.th2) - cache_bytes = stored_level * n - else: - cache_bytes = (2 * stored_level - 1) * n - sign_cached = sign - tree + cached_tree - - return Costs( - l=l, - chain_bits=enc.chain_bits, - pinned_bits=enc.pinned_bits if wots_c else 0, - dropped_chains=dropped_chains, - swn=swn_c if wots_c else None, - sig_bytes=sig_bytes, - keygen=keygen, - sign=sign, - sign_cached=sign_cached, - verify=verify, - verify_worst=verify_worst, - wots_c_grinding=grinding, - fors_c_grinding=fors_grind.hashes if fors_c else 0, - cache_depth=hp - c, - cache_bytes=cache_bytes, - ) - - -def evaluate( - h: int, - d: int, - a: int, - k: int, - w: int, - q_s_log2: int, - scheme: str = "W+C_F+C", - swn: int | None = None, - n: int = 16, - dropped_chains: int = 0, - cache_height: int | None = None, - cache_level_only: bool = False, - convention: Convention | None = None, -) -> Result: - """costs() plus the classical security level at q_s = 2^q_s_log2.""" - c = costs(h, d, a, k, w, scheme, swn, n, dropped_chains, cache_height, cache_level_only, convention) - forgery = fors_forgery_exponent(q_s_log2, h, k, a) - return Result( - scheme=scheme, - q_s_log2=q_s_log2, - n=n, - h=h, - d=d, - h_prime=h // d, - a=a, - k=k, - w=w, - l=c.l, - chain_bits=c.chain_bits, - pinned_bits=c.pinned_bits, - dropped_chains=c.dropped_chains, - swn=c.swn, - security_bits=min(8 * n, forgery), - fors_forgery_bits=forgery, - sig_bytes=c.sig_bytes, - keygen_hashes=c.keygen.hashes, - keygen_compressions=c.keygen.compressions, - sign_hashes=c.sign.hashes, - sign_compressions=c.sign.compressions, - sign_grinding_hashes=c.wots_c_grinding + c.fors_c_grinding, - wots_c_grinding_hashes=c.wots_c_grinding, - fors_c_grinding_hashes=c.fors_c_grinding, - verify_hashes=c.verify.hashes, - verify_compressions=c.verify.compressions, - verify_hashes_worst=c.verify_worst.hashes, - verify_compressions_worst=c.verify_worst.compressions, - cache_depth=c.cache_depth, - cache_bytes=c.cache_bytes, - sign_cached_hashes=c.sign_cached.hashes, - sign_cached_compressions=c.sign_cached.compressions, - ) - - -# --------------------------------------------------------------------------- -# Reporting -# --------------------------------------------------------------------------- - - -def _si(x: float) -> str: - for unit, div in (("G", 1e9), ("M", 1e6), ("K", 1e3)): - if x >= div: - return f"{x / div:.2f}{unit}" - return str(int(x)) - - -def encoding_line(r: Result) -> str: - """One line spelling out the WOTS+C digest-to-chains cut.""" - if r.swn is None: - return f"encoding WOTS-TW: {r.l} chains, {r.l - wots_len2(r.w, r.n)} for the digest + {wots_len2(r.w, r.n)} checksum" - dropped = f", {r.dropped_chains} chain(s) dropped" if r.dropped_chains else "" - return ( - f"encoding {r.chain_bits} bits/chain, {r.pinned_bits} of {8 * r.n} digest bits pinned to zero" - f"{dropped}, S_wn = {r.swn} of {r.l * (r.w - 1)}" - ) - - -def report(r: Result) -> str: - speedup = r.sign_hashes / r.sign_cached_hashes - - def row(label: str, hashes: int, compressions: int, note: str = "") -> str: - return f"{label:<24}{_si(hashes):>12}{_si(compressions):>16}{note}" - - lines = [ - f"scheme {r.scheme} q_s = 2^{r.q_s_log2} n = {8 * r.n} bits", - f"(h, d, h') ({r.h}, {r.d}, {r.h_prime})", - f"(a, k) ({r.a}, {r.k})" + (f" [FORS+C signs {r.k - 1} trees]" if r.scheme == "W+C_F+C" else ""), - f"(w, l) ({r.w}, {r.l})", - encoding_line(r), - "", - f"security {r.security_bits:.1f} bits classical" - + (f" (FORS forgery {r.fors_forgery_bits:.1f}, preimage {8 * r.n})" if r.fors_forgery_bits < 1e6 else ""), - f"signature {r.sig_bytes} bytes", - "", - f"{'':24}{'hashes':>12}{'compressions':>16}", - row("keygen", r.keygen_hashes, r.keygen_compressions), - row("sign (avg)", r.sign_hashes, r.sign_compressions), - row( - "sign (half-top cached)", - r.sign_cached_hashes, - r.sign_cached_compressions, - f" ({speedup:.2f}x, {r.cache_bytes} B of state at depth {r.cache_depth})", - ), - row("verify", r.verify_hashes, r.verify_compressions), - ] - if r.verify_hashes_worst != r.verify_hashes: - lines.append(row("verify (worst)", r.verify_hashes_worst, r.verify_compressions_worst)) - lines += [ - "", - ( - f"of signing, grinding accounts for {_si(r.sign_grinding_hashes)} hashes:" - f" {_si(r.wots_c_grinding_hashes)} for the WOTS+C counters," - f" {_si(r.fors_c_grinding_hashes)} for the FORS+C digest" - ), - ] - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# Self-test against the golden values of BlockstreamResearch/SPHINCS-Parameters -# --------------------------------------------------------------------------- - -# tests/fixtures.json, "cached" hash convention: scheme|h,d,k,a,w,swn -> costs.sage -GOLDEN = { - ("SPX", 63, 7, 14, 12, 16, None): {"size": 7856, "kg": 292351, "sg": 2218483, "sv": 2155, "sv_worst": 3891}, - ("W+C", 44, 4, 8, 16, 16, 240): {"size": 4960, "kg": 1069055, "sg": 5849347, "sv": 1185, "sv_worst": 1185}, - ("W+C", 40, 5, 11, 14, 256, 2040): {"size": 4596, "kg": 1050111, "sg": 5794969, "sv": 10441, "sv_worst": 10441}, - ("W+C", 40, 5, 11, 14, 256, 2840): {"size": 4596, "kg": 1050111, "sg": 5941944, "sv": 6441, "sv_worst": 6441}, - ("W+C_F+C", 44, 4, 8, 16, 16, 240): {"size": 4688, "kg": 1069055, "sg": 5914880, "sv": 1168, "sv_worst": 1168}, - ("W+C_F+C", 40, 5, 11, 14, 256, 2040): {"size": 4356, "kg": 1050111, "sg": 5811349, "sv": 10425, "sv_worst": 10425}, - ("W+C_F+C", 20, 2, 10, 15, 256, 2040): {"size": 3160, "kg": 4200447, "sg": 9418194, "sv": 4261, "sv_worst": 4261}, -} -GOLDEN_UNCACHED = { - ("SPX", 63, 7, 14, 12, 16, None): {"size": 7856, "kg": 292862, "sg": 2279391, "sv": 2387, "sv_worst": 4123}, - ("W+C", 44, 4, 8, 16, 16, 240): {"size": 4960, "kg": 1071102, "sg": 6381815, "sv": 1357, "sv_worst": 1357}, -} -# The report's Tables 1 and 2, WOTS/FORS rows only: the "SigVer (hash)", -# "SigTime (hash)" (as a multiple of 10^4, 3 significant figures) and -# "Exp. Search (hash)" columns, keyed by (scheme, h, d, a, k, w, S_wn). -# The tables' "Sig (B)" column is 16 bytes above what costs.sage now computes -# (it predates the report; the repo's own fixtures agree with this script). -REPORT_TABLE = { - ("SPX", 63, 7, 12, 14, 16, None): (2088, 219, 0), - ("W+C", 44, 4, 16, 8, 16, 240): (1150, 578, 264), - ("W+C", 44, 4, 16, 8, 16, 304): (894, 579, 5344), - ("W+C", 44, 4, 16, 8, 256, 2040): (8350, 3515, 2996), - ("W+C", 40, 5, 14, 11, 256, 2040): (10417, 579, 3745), - ("W+C", 40, 5, 14, 11, 256, 2840): (6417, 594, None), - ("W+C_F+C", 44, 4, 16, 8, 16, 240): (1133, 572, None), - ("W+C_F+C", 40, 5, 14, 11, 256, 2040): (10402, 577, 36513), - ("W+C", 36, 3, 14, 9, 16, 240): (899, 676, 198), - ("W+C", 33, 3, 15, 9, 16, 304): (713, 405, 4008), - ("W+C", 32, 4, 14, 10, 256, 2840): (5152, 481, None), - ("W+C_F+C", 33, 3, 15, 9, 16, 240): (889, 401, 65734), - ("W+C_F+C", 32, 4, 14, 10, 256, 2040): (8337, 467, 35764), - ("W+C", 24, 2, 16, 8, 16, 240): (646, 578, None), - ("W+C", 24, 2, 16, 8, 256, 2040): (4246, 3515, None), - ("W+C_F+C", 24, 2, 16, 8, 16, 240): (629, 572, None), - ("W+C", 20, 2, 15, 10, 256, 2040): (4266, 938, None), - ("W+C_F+C", 20, 2, 15, 10, 256, 2040): (4250, 934, None), -} -# (w, n, dropped_chains) -> (chain_bits, pinned_bits, chains, default S_wn) -GOLDEN_ENCODINGS = { - (8, 16, 0): (3, 2, 42, 147), # doc/xmss/main.tex - (8, 16, 1): (3, 5, 41, 143), - (8, 16, 2): (3, 8, 40, 140), - (16, 16, 0): (4, 0, 32, 240), # the report's w = 16: nothing to pin - (16, 16, 1): (4, 4, 31, 232), - (32, 16, 0): (5, 3, 25, 387), # 128 = 5*25 + 3 - (256, 16, 0): (8, 0, 16, 2040), - (8, 32, 0): (3, 1, 85, 297), # 256 = 3*85 + 1 -} -# security.sage / site/stateless.html, for (q_s_log2, h, k, a) -GOLDEN_SECURITY = { - (64, 63, 14, 12): 128.0, # SLH-DSA-128s/f: preimage bound dominates - (40, 44, 8, 16): 128.0, - (40, 40, 11, 14): 128.0, - (30, 32, 10, 14): 128.0, - (20, 24, 8, 16): 128.0, -} - - -def selftest() -> int: - fails = 0 - - def check(name, got, want, tol=0.05): - nonlocal fails - ok = got == want if isinstance(want, int) else abs(got - want) < tol - fails += not ok - if not ok: - print(f"FAIL {name}: got {got}, want {want}") - - for cv, table in ((Convention(True), GOLDEN), (Convention(False), GOLDEN_UNCACHED)): - tag = "cached" if cv.cached_midstate else "uncached" - for (scheme, h, d, k, a, w, swn), want in table.items(): - # the cost model does not depend on q_s; security is checked separately - r = evaluate(h, d, a, k, w, min(40, h), scheme=scheme, swn=swn, convention=cv) - fields = ( - ("sig_bytes", "size"), - ("keygen_compressions", "kg"), - ("sign_compressions", "sg"), - ("verify_compressions", "sv"), - ("verify_compressions_worst", "sv_worst"), - ) - for field, key in fields: - check(f"{tag} {scheme} h={h} d={d} k={k} a={a} w={w} {key}", getattr(r, field), want[key]) - - for (scheme, h, d, a, k, w, swn), (sv, sg_e4, search) in REPORT_TABLE.items(): - r = evaluate(h, d, a, k, w, min(40, h), scheme=scheme, swn=swn) - tag = f"report {scheme} h={h} d={d} a={a} k={k} w={w} S={swn}" - check(f"{tag} SigVer", r.verify_hashes, sv) - check(f"{tag} SigTime", r.sign_hashes / 1e4, float(sg_e4), tol=0.55) # table rounds to 3 figures - if search is not None: - check(f"{tag} Exp.Search", r.sign_grinding_hashes, search) - - for (q, h, k, a), want in GOLDEN_SECURITY.items(): - check(f"security q_s=2^{q} h={h} k={k} a={a}", security_bits(q, h, k, a, 16), want) - - # Encoding geometry: doc/xmss/main.tex is the (n=128, chain_bits=3) instance, - # with 2 bits pinned, v = 42 chains and T = 195. - for (w, n, drop), (bits, pinned, chains, mean) in GOLDEN_ENCODINGS.items(): - e = Encoding(w, n, drop) - tag = f"encoding w={w} n={n} drop={drop}" - check(f"{tag} chain_bits", e.chain_bits, bits) - check(f"{tag} pinned_bits", e.pinned_bits, pinned) - check(f"{tag} chains", e.chains, chains) - check(f"{tag} default_swn", e.default_swn, mean) - check("doc/xmss T=195 trials", Encoding(8, 16).expected_trials(195), 29490) - check("one dropped chain costs about w", Encoding(8, 16, 1).expected_trials(143) // Encoding(8, 16).expected_trials(147), 7) - - # h'=0 leaves nothing to cache; h'=h/d with c=0 rebuilds one leaf only - r = evaluate(40, 5, 14, 11, 256, 40, scheme="W+C_F+C") - check("cache is a strict saving", r.sign_cached_hashes < r.sign_hashes, True) - check("cache_height=h' is the full tree", evaluate(40, 5, 14, 11, 256, 40, cache_height=8).sign_cached_hashes, r.sign_hashes) - - print("selftest: " + ("all checks passed" if not fails else f"{fails} failures")) - return fails - - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - - -def main() -> int: - p = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0], formatter_class=argparse.RawDescriptionHelpFormatter) - p.add_argument("--scheme", default="W+C_F+C", choices=SCHEMES) - p.add_argument("--qs", type=int, default=40, metavar="LOG2", help="log2 of signatures per public key (default 40)") - p.add_argument("--height", type=int, default=40, metavar="h", help="hypertree height (default 40)") - p.add_argument("--layers", type=int, default=5, metavar="d", help="hypertree layers (default 5)") - p.add_argument("-a", type=int, default=14, help="log2 leaves per FORS tree (default 14)") - p.add_argument("-k", type=int, default=11, help="FORS trees (default 11)") - p.add_argument("-w", type=int, default=256, help="Winternitz parameter (default 256)") - p.add_argument("--swn", type=int, default=None, help="WOTS+C target digit sum S_wn (default: the mean, l*(w-1)/2)") - p.add_argument("-n", type=int, default=16, help="hash output in bytes (default 16)") - p.add_argument("--chain-bits", type=int, default=None, metavar="B", help="log2(w), an alternative way to give w (3 means 8 hashes per chain)") - p.add_argument( - "--drop-chains", - type=int, - default=0, - metavar="C", - help="WOTS+C chains dropped beyond the minimal bit pinning; each pins log2(w) more digest bits (default 0)", - ) - p.add_argument("--cache-height", type=int, default=None, metavar="c", help="height of the cached top-tree level above the leaves (default h'//2)") - p.add_argument("--cache-level-only", action="store_true", help="cache that one level, not it and everything above") - p.add_argument("--uncached", action="store_true", help="charge every hash for its full input instead of caching the PK.seed midstate") - p.add_argument("--json", action="store_true") - p.add_argument("--selftest", action="store_true") - args = p.parse_args() - - if args.selftest: - return 1 if selftest() else 0 - - if args.chain_bits is not None: - args.w = 1 << args.chain_bits - - r = evaluate( - args.height, - args.layers, - args.a, - args.k, - args.w, - args.qs, - scheme=args.scheme, - swn=args.swn, - n=args.n, - dropped_chains=args.drop_chains, - cache_height=args.cache_height, - cache_level_only=args.cache_level_only, - convention=Convention(not args.uncached), - ) - if args.json: - import json - - print(json.dumps(asdict(r), indent=2)) - else: - print(report(r)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/doc/sphincs/src/cost.rs b/doc/sphincs/src/cost.rs new file mode 100644 index 000000000..696c10989 --- /dev/null +++ b/doc/sphincs/src/cost.rs @@ -0,0 +1,298 @@ +//! Signature size and hash counts for one SPHINCS+ parameter set. +//! +//! Ported from `costs.sage` of BlockstreamResearch/SPHINCS-Parameters, which is +//! the companion to "Hash-based Signature Schemes for Bitcoin". `tests/goldens` +//! pins every number this module produces against that repo's frozen fixtures +//! and against the report's own tables. + +use std::ops::{Add, Mul, Sub}; + +/// The WOTS+C grinding counter, carried once per hypertree layer. +pub const COUNTER_BYTES: u64 = 4; + +/// A cost in both units the report uses. +/// +/// `hashes` counts tweakable-hash and PRF invocations (its "hash" columns), +/// `compressions` counts SHA-256 compression calls (its Compr. columns). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Cost { + pub hashes: u64, + pub compressions: u64, +} + +impl Cost { + pub const fn new(hashes: u64, compressions: u64) -> Self { + Self { hashes, compressions } + } +} + +impl Add for Cost { + type Output = Self; + fn add(self, o: Self) -> Self { + Self::new( + self.hashes.saturating_add(o.hashes), + self.compressions.saturating_add(o.compressions), + ) + } +} + +impl Sub for Cost { + type Output = Self; + fn sub(self, o: Self) -> Self { + Self::new(self.hashes - o.hashes, self.compressions - o.compressions) + } +} + +impl Mul for Cost { + type Output = Self; + fn mul(self, m: u64) -> Self { + Self::new(self.hashes.saturating_mul(m), self.compressions.saturating_mul(m)) + } +} + +/// Compression calls charged to each kind of hash invocation. +/// +/// `cached_midstate` is the FIPS 205 SHA-2 layout with the PK.seed midstate +/// cached; without it every call pays for its full input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Convention { + pub cached_midstate: bool, +} + +impl Default for Convention { + fn default() -> Self { + Self { cached_midstate: true } + } +} + +impl Convention { + /// PK.seed + ADRS + one n-byte value. + pub const fn th1(self) -> u64 { + 1 + } + /// ... and the 4-byte WOTS+C counter. + pub const fn th1c(self) -> u64 { + 1 + } + /// Two n-byte children. + pub const fn th2(self) -> u64 { + if self.cached_midstate { 1 } else { 2 } + } + /// PK.seed + PK.root + R + message digest. + pub const fn hmsg(self) -> u64 { + 2 + } + /// SK.prf + opt + message. + pub const fn prfmsg(self) -> u64 { + 2 + } + /// PK.seed + SK.seed + ADRS. + pub const fn prf(self) -> u64 { + 1 + } + /// Compressions for a tweakable hash over `m` n-byte values. + pub const fn th(self, m: u64, n: u64) -> u64 { + let prefix = if self.cached_midstate { 22 * 8 } else { 8 * (n + 12) }; + (prefix + 8 * n * m + 65).div_ceil(512) + } +} + +/// Which one-time and few-time schemes a parameter set is built from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Scheme { + /// Plain SPHINCS+ / SLH-DSA: WOTS-TW + FORS. + Spx, + /// WOTS+C (fixed digit sum, no checksum chains) + FORS. + Wc, + /// WOTS+C + FORS+C (last FORS tree removed by grinding). + WcFc, +} + +pub const SCHEMES: [Scheme; 3] = [Scheme::Spx, Scheme::Wc, Scheme::WcFc]; + +impl Scheme { + pub const fn wots_c(self) -> bool { + !matches!(self, Scheme::Spx) + } + pub const fn fors_c(self) -> bool { + matches!(self, Scheme::WcFc) + } + pub const fn label(self) -> &'static str { + match self { + Scheme::Spx => "SPX", + Scheme::Wc => "W+C", + Scheme::WcFc => "W+C_F+C", + } + } + pub fn parse(s: &str) -> Option { + SCHEMES.into_iter().find(|x| x.label().eq_ignore_ascii_case(s)) + } + /// FORS trees actually built and authenticated: FORS+C grinds the last away. + pub const fn trees(self, k: u64) -> u64 { + if self.fors_c() { k - 1 } else { k } + } +} + +/// How a WOTS+C digest is cut into base-w chain positions. +/// +/// The report drops chains by forcing their digits to zero (its parameter z). +/// This uses the bit-pinning variant it offers as an alternative in "Complexity +/// Analysis of WOTS+C" (its z_b), which is what `doc/xmss/main.tex` does, +/// because it keeps the digest a whole number of chunks and needs no +/// partial-digit handling anywhere: +/// +/// ```text +/// chain_bits = log2(w) bits one chain carries +/// pinned = (8n) mod chain_bits + chain_bits * dropped_chains +/// chains = (8n - pinned) / chain_bits = floor(8n/chain_bits) - dropped +/// ``` +/// +/// The signer grinds the counter until the digest has its `pinned` top bits zero +/// AND its `chains` digits summing to S_wn, so out of the 2^(8n) digests exactly +/// nu = |{tuples summing to S_wn}| are admissible (see [`NuTable`]). +/// +/// Pinning is not free: every pinned bit halves the admissible fraction, so +/// `pinned` bits multiply the expected grinding by 2^pinned. It buys chains +/// cheaply though. The default is the minimum that leaves `8n - pinned` a +/// multiple of `chain_bits`, and what it saves is the extra, only partly used +/// chain that `ceil(8n / chain_bits)` would need: n bytes of signature for a +/// factor 2^(8n mod chain_bits), which at chain_bits = 3 is 16 bytes for 4x on a +/// per-layer grind of a few hundred hashes. Each further dropped chain then +/// saves another n bytes for a factor of about w. +/// +/// `doc/xmss/main.tex` is the (n=128, chain_bits=3) instance: 128 mod 3 = 2 bits +/// pinned, v = 42 chains, T = 195. Dropping one more chain there would pin +/// 2 + 3 = 5 bits and leave 41 chains. For every w the report itself uses (16 +/// and 256) chain_bits divides 128, so nothing is pinned. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Encoding { + pub w: u64, + pub n: u64, + pub dropped_chains: u64, + pub chain_bits: u64, + pub pinned_bits: u64, + pub chains: u64, +} + +impl Encoding { + /// `None` when `w` is not a power of two, or when nothing is left to sign. + pub fn new(w: u64, n: u64, dropped_chains: u64) -> Option { + if w < 2 || !w.is_power_of_two() { + return None; + } + let chain_bits = w.trailing_zeros() as u64; + let pinned_bits = (8 * n) % chain_bits + chain_bits * dropped_chains; + let chains = (8 * n).checked_sub(pinned_bits)? / chain_bits; + if chains < 1 { + return None; + } + Some(Self { + w, + n, + dropped_chains, + chain_bits, + pinned_bits, + chains, + }) + } + + /// Mean digit sum, where the admissible digests are densest. + pub const fn default_swn(&self) -> u64 { + self.chains * (self.w - 1) / 2 + } + + /// Largest reachable digit sum: every chain at the top. + pub const fn max_swn(&self) -> u64 { + self.chains * (self.w - 1) + } +} + +/// How many digests a target sum admits, and what that costs the signer. +/// +/// `counts[s]` is the number of `l`-tuples over `[0, w-1]` summing to `s`, that +/// is the coefficient of `x^s` in `(1 + x + ... + x^(w-1))^l`. Built by the +/// obvious convolution rather than by the report's inclusion-exclusion formula, +/// because the formula's intermediate binomials dwarf its result and would need +/// bignums, while every coefficient here is bounded by the total `w^l <= 2^128`. +/// +/// `trials[s]` is the expected number of counter values the signer tries per +/// hypertree layer, `ceil(2^(8n) / counts[s])`, saturated at `u64::MAX`: a set +/// needing more counters than that is beyond any budget anyway. +#[derive(Clone, Debug)] +pub struct NuTable { + pub l: u64, + pub w: u64, + pub digest_bits: u32, + counts: Vec, + trials: Vec, +} + +impl NuTable { + pub fn new(l: u64, w: u64, digest_bits: u32) -> Self { + assert!( + digest_bits <= 128, + "a digest wider than 128 bits overflows the u128 counts" + ); + let degree = (l * (w - 1)) as usize; + let mut cur = vec![0u128; degree + 1]; + let mut next = vec![0u128; degree + 1]; + cur[0] = 1; + for round in 1..=l { + let hi = (round * (w - 1)) as usize; + // next[s] = sum of the w preceding entries of cur, kept as a running + // window: the window's value is itself a coefficient of the next + // row, so it cannot exceed w^round <= 2^128. + let mut window = 0u128; + for s in 0..=hi { + window = window.checked_add(cur[s]).expect("digit-sum count overflowed u128"); + if s >= w as usize { + window -= cur[s - w as usize]; + } + next[s] = window; + } + std::mem::swap(&mut cur, &mut next); + cur[hi + 1..].fill(0); + } + let total_minus_1 = if digest_bits == 128 { + u128::MAX + } else { + (1u128 << digest_bits) - 1 + }; + let trials = cur + .iter() + .map(|&nu| { + if nu == 0 { + return u64::MAX; + } + // ceil(2^digest_bits / nu) = floor((2^digest_bits - 1)/nu) + 1, + // saturating: nu = 1 would otherwise carry the +1 past u128. + u64::try_from((total_minus_1 / nu).saturating_add(1)).unwrap_or(u64::MAX) + }) + .collect(); + Self { + l, + w, + digest_bits, + counts: cur, + trials, + } + } + + /// Digests with the pinned bits zero and digits summing to `swn`. + pub fn nu(&self, swn: u64) -> u128 { + self.counts.get(swn as usize).copied().unwrap_or(0) + } + + /// Counter values tried per layer at this target sum. + pub fn trials(&self, swn: u64) -> u64 { + self.trials.get(swn as usize).copied().unwrap_or(u64::MAX) + } + + /// The least grinding any target sum can ask for. + /// + /// Read off the table rather than assumed to sit at the mean, so nothing + /// downstream depends on where the distribution peaks. + pub fn min_trials(&self) -> u64 { + self.trials.iter().copied().min().unwrap_or(u64::MAX) + } +} diff --git a/doc/sphincs/src/lib.rs b/doc/sphincs/src/lib.rs new file mode 100644 index 000000000..298521b4c --- /dev/null +++ b/doc/sphincs/src/lib.rs @@ -0,0 +1,45 @@ +//! SPHINCS+ parameters: security, signature size, hash counts, and a search. +//! +//! Covers the WOTS-based / FORS-based schemes of "Hash-based Signature Schemes +//! for Bitcoin" (Kudinov, Nick, Blockstream Research, rev. 2025-12-05) and its +//! scripts at github.com/BlockstreamResearch/SPHINCS-Parameters: +//! +//! | scheme | what it is | +//! | --------- | ------------------------------------------------------- | +//! | `SPX` | plain SPHINCS+ (SLH-DSA): WOTS-TW + FORS | +//! | `W+C` | WOTS+C (fixed digit sum, no checksum chains) + FORS | +//! | `W+C_F+C` | WOTS+C + FORS+C (last FORS tree removed by grinding) | +//! +//! PORS+FP is deliberately not implemented. +//! +//! WOTS+C shortens its signature by dropping chains, and this does that the way +//! `doc/xmss/main.tex` does: it pins the top bits of the digest to zero instead +//! of forcing whole digits, so the digest is always a whole number of base-w +//! chunks (see [`cost::Encoding`]). The default pins the minimum that makes the +//! cut integral; dropping further chains pins `log2(w)` more bits each, every +//! pinned bit doubling the expected grinding. +//! +//! For one parameter set [`params::costs`] reports the signature size and the +//! keygen, signing and verification cost, and [`security::security_bits`] the +//! classical security. Signing comes in two flavours: vanilla, and with the top +//! XMSS tree's "half top" cached, meaning its nodes at depth `ceil(h'/2)` kept +//! as signer state, which is `sqrt(2^h')` of storage for a `sqrt(2^h')` top-tree +//! cost per signature. +//! +//! Every cost comes in two units, matching the report's tables: `hashes` counts +//! tweakable-hash and PRF invocations, `compressions` counts SHA-256 compression +//! calls under the FIPS 205 SHA-2 layout with the PK.seed midstate cached. +//! +//! [`search::search`] inverts the question: given a lifetime and a budget for +//! keygen, signing (both flavours) and size, it enumerates the space and returns +//! what verifies cheapest at 128-bit classical security. +//! +//! `tests/goldens.rs` pins all of it against the upstream sage scripts' frozen +//! fixtures, against the report's own tables, and, for the search, against a +//! naive oracle that skips nothing. + +pub mod cost; +pub mod params; +pub mod report; +pub mod search; +pub mod security; diff --git a/doc/sphincs/src/main.rs b/doc/sphincs/src/main.rs new file mode 100644 index 000000000..f1e75bdae --- /dev/null +++ b/doc/sphincs/src/main.rs @@ -0,0 +1,249 @@ +//! `params`: cost one parameter set. `search`: find the cheapest to verify. + +use sphincs_params::cost::{Convention, SCHEMES, Scheme}; +use sphincs_params::params::{Params, costs}; +use sphincs_params::report::{report, table, utilization}; +use sphincs_params::search::{ + A_MAX, Budgets, CHAIN_BITS_MAX, Candidate, DROPPED_MAX, Grid, H_MAX, K_MAX, LEVEL1_BITS, Stats, Unit, edges, search, +}; + +const USAGE: &str = "\ +usage: sphincs_params params [options] cost one parameter set + sphincs_params search [options] search for the cheapest verification + +params options (defaults are the report's bold 2^40 row): + --scheme S SPX | W+C | W+C_F+C [W+C_F+C] + --lifetime L log2 of signatures per key [40] + --height h hypertree height [40] + --layers d hypertree layers [5] + -a A log2 leaves per FORS tree [14] + -k K FORS trees [11] + -w W Winternitz parameter [256] + --chain-bits B log2(w), instead of -w + --swn S WOTS+C target digit sum [the mean, l*(w-1)/2] + --drop-chains C chains dropped beyond the minimal bit pinning [0] + -n N hash output in bytes [16] + --cache-height C cached top-tree level, above the leaves [h'/2] + --cache-level-only cache one level, not it and everything above + --uncached charge every hash for its full input + +search options (all five budgets required): + --lifetime L log2 of signatures per key + --max-keygen N budget for keygen + --max-sign N budget for average signing + --max-sign-cached N budget for average signing, half top cached + --max-size B budget for the signature, in bytes + --security BITS classical security floor [128, NIST level 1] + --unit U hashes | compressions, for the budgets and objective [hashes] + --scheme S restrict the schemes searched (repeatable) + --chain-bits B restrict log2(w) searched (repeatable) + --top N rows to print [15] + --h-max / --a-max / --k-max / --max-dropped widen or narrow a range + --stats report how much of the space was visited + -n N hash output in bytes [16] +"; + +fn main() -> std::process::ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("params") => run(cmd_params(&args[1..])), + Some("search") => run(cmd_search(&args[1..])), + _ => { + print!("{USAGE}"); + std::process::ExitCode::from(2) + } + } +} + +fn run(r: Result) -> std::process::ExitCode { + match r { + Ok(true) => std::process::ExitCode::SUCCESS, + Ok(false) => std::process::ExitCode::FAILURE, + Err(e) => { + eprintln!("error: {e}"); + std::process::ExitCode::from(2) + } + } +} + +/// Flags and their values, with repeatable flags kept in order. +struct Args(Vec<(String, Option)>); + +impl Args { + fn parse(argv: &[String]) -> Result { + let mut out = Vec::new(); + let mut i = 0; + while i < argv.len() { + let flag = &argv[i]; + if !flag.starts_with('-') { + return Err(format!("unexpected argument {flag}")); + } + let takes_value = !matches!(flag.as_str(), "--uncached" | "--cache-level-only" | "--stats"); + if takes_value { + let v = argv.get(i + 1).ok_or_else(|| format!("{flag} needs a value"))?; + out.push((flag.clone(), Some(v.clone()))); + i += 2; + } else { + out.push((flag.clone(), None)); + i += 1; + } + } + Ok(Args(out)) + } + + fn flag(&self, name: &str) -> bool { + self.0.iter().any(|(f, _)| f == name) + } + + fn all(&self, name: &str) -> Vec<&str> { + self.0 + .iter() + .filter(|(f, _)| f == name) + .filter_map(|(_, v)| v.as_deref()) + .collect() + } + + fn get(&self, name: &str) -> Option<&str> { + self.all(name).last().copied() + } + + fn u64(&self, name: &str, default: u64) -> Result { + match self.get(name) { + None => Ok(default), + // accept 2e6 as well as 2000000 + Some(s) => s + .parse::() + .map(|f| f as u64) + .map_err(|_| format!("{name}: expected a number, got {s}")), + } + } + + fn f64(&self, name: &str, default: f64) -> Result { + match self.get(name) { + None => Ok(default), + Some(s) => s.parse().map_err(|_| format!("{name}: expected a number, got {s}")), + } + } + + fn required(&self, name: &str) -> Result { + self.get(name).ok_or_else(|| format!("{name} is required"))?; + self.u64(name, 0) + } + + fn schemes(&self) -> Result, String> { + let named = self.all("--scheme"); + if named.is_empty() { + return Ok(SCHEMES.to_vec()); + } + named + .iter() + .map(|s| Scheme::parse(s).ok_or_else(|| format!("unknown scheme {s}"))) + .collect() + } +} + +fn cmd_params(argv: &[String]) -> Result { + let args = Args::parse(argv)?; + let w = match args.get("--chain-bits") { + Some(_) => 1u64 << args.u64("--chain-bits", 8)?, + None => args.u64("-w", 256)?, + }; + let lifetime = args.u64("--lifetime", 40)? as u32; + let p = Params { + scheme: match args.get("--scheme") { + Some(s) => Scheme::parse(s).ok_or_else(|| format!("unknown scheme {s}"))?, + None => Scheme::WcFc, + }, + h: args.u64("--height", 40)?, + d: args.u64("--layers", 5)?, + a: args.u64("-a", 14)?, + k: args.u64("-k", 11)?, + w, + n: args.u64("-n", 16)?, + dropped_chains: args.u64("--drop-chains", 0)?, + cache_height: args + .get("--cache-height") + .map(|_| args.u64("--cache-height", 0)) + .transpose()?, + cache_level_only: args.flag("--cache-level-only"), + convention: Convention { + cached_midstate: !args.flag("--uncached"), + }, + }; + let swn = args.get("--swn").map(|_| args.u64("--swn", 0)).transpose()?; + let c = costs(p, swn) + .ok_or("inconsistent parameters: d must divide h, w must be a power of two, FORS+C needs k >= 2")?; + println!("{}", report(&p, &c, lifetime)); + Ok(true) +} + +fn cmd_search(argv: &[String]) -> Result { + let args = Args::parse(argv)?; + let bits: Vec = args + .all("--chain-bits") + .iter() + .map(|s| { + s.parse::() + .map_err(|_| format!("--chain-bits: expected a number, got {s}")) + }) + .collect::>()?; + let unit = match args.get("--unit").unwrap_or("hashes") { + "hashes" => Unit::Hashes, + "compressions" => Unit::Compressions, + other => return Err(format!("--unit: expected hashes or compressions, got {other}")), + }; + let b = Budgets { + lifetime: args.required("--lifetime")? as u32, + max_keygen: args.required("--max-keygen")?, + max_sign: args.required("--max-sign")?, + max_sign_cached: args.required("--max-sign-cached")?, + max_size: args.required("--max-size")?, + security: args.f64("--security", LEVEL1_BITS)?, + unit, + }; + let g = Grid { + schemes: args.schemes()?, + n: args.u64("-n", 16)?, + h_max: args.u64("--h-max", H_MAX)?, + a_max: args.u64("--a-max", A_MAX)?, + k_max: args.u64("--k-max", K_MAX)?, + chain_bits: if bits.is_empty() { + (1..=CHAIN_BITS_MAX).collect() + } else { + bits + }, + max_dropped: args.u64("--max-dropped", DROPPED_MAX)?, + cache_level_only: args.flag("--cache-level-only"), + ..Default::default() + }; + let top = args.u64("--top", 15)? as usize; + + let mut stats = Stats::default(); + let found = search(&b, &g, &mut stats); + if args.flag("--stats") { + println!("{stats}\n"); + } + if found.is_empty() { + println!( + "no parameter set meets these budgets at {:.0}-bit security and q_s = 2^{}", + b.security, b.lifetime + ); + println!("--stats says which budget pruned everything; the binding one is usually size or keygen"); + return Ok(false); + } + println!( + "{} feasible sets, best {} by verification {}:\n", + found.len(), + top.min(found.len()), + unit.label() + ); + println!("{}\n", table(&b, &found[..top.min(found.len())])); + let best: &Candidate = &found[0]; + println!("budget use of the best: {}", utilization(&b, best)); + for w in edges(&g, best) { + println!("warning: {w}"); + } + println!(); + println!("{}", report(&best.params, &best.costs, b.lifetime)); + Ok(true) +} diff --git a/doc/sphincs/src/params.rs b/doc/sphincs/src/params.rs new file mode 100644 index 000000000..6ccce111c --- /dev/null +++ b/doc/sphincs/src/params.rs @@ -0,0 +1,315 @@ +//! One parameter set, and the costs it implies. + +use crate::cost::{COUNTER_BYTES, Convention, Cost, Encoding, NuTable, Scheme}; + +/// A SPHINCS+ parameter set. `q_s` is not part of it: see [`crate::security`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Params { + pub scheme: Scheme, + /// Hypertree height, a multiple of `d`. + pub h: u64, + /// Hypertree layers, so each XMSS tree has height `h' = h/d`. + pub d: u64, + /// FORS trees have `2^a` leaves. + pub a: u64, + /// Number of FORS trees. + pub k: u64, + /// Winternitz parameter, a power of two. + pub w: u64, + /// Hash output in bytes. + pub n: u64, + /// Chains dropped beyond the digest bits that have to be pinned anyway. + pub dropped_chains: u64, + /// Height above the leaves of the cached top-tree level; `None` is `h'/2`. + pub cache_height: Option, + /// Cache one level rather than it and everything above. + pub cache_level_only: bool, + pub convention: Convention, +} + +impl Params { + pub fn h_prime(&self) -> u64 { + self.h / self.d + } + + pub fn encoding(&self) -> Option { + Encoding::new(self.w, self.n, self.dropped_chains) + } + + /// Chains actually signed: `l1 + l2` for WOTS-TW, the encoding's for WOTS+C. + pub fn chains(&self) -> Option { + let enc = self.encoding()?; + if self.scheme.wots_c() { + return Some(enc.chains); + } + // WOTS-TW pads the digest to whole digits and appends a checksum + // (FIPS 205): l1 = ceil(8n / log2 w), l2 = floor(log_w(l1*(w-1))) + 1. + let l1 = (8 * self.n).div_ceil(enc.chain_bits); + Some(l1 + self.wots_tw_len2(l1)) + } + + fn wots_tw_len2(&self, l1: u64) -> u64 { + let bits = self.encoding().expect("checked by the caller").chain_bits; + let c = l1 * (self.w - 1); + // floor(log2(c) / log2(w)) + 1, integer-only + (c.ilog2() as u64) / bits + 1 + } + + /// Verifier chain steps for WOTS-TW when every message digit is zero. + fn wots_tw_worst_steps(&self) -> u64 { + let enc = self.encoding().expect("checked by the caller"); + let l1 = (8 * self.n).div_ceil(enc.chain_bits); + let l2 = self.wots_tw_len2(l1); + let c = l1 * (self.w - 1); + let digit_sum: u64 = { + let mut rem = c; + let mut sum = 0; + while rem > 0 { + sum += rem % self.w; + rem /= self.w; + } + sum + }; + l1 * (self.w - 1) + l2 * (self.w - 1) - digit_sum + } +} + +/// Everything about a parameter set that does not depend on the target sum. +/// +/// Split this way so a search can reject a candidate on size, keygen, or the +/// least grinding any target sum could ask for, without pretending to know +/// where the good target sums are. +#[derive(Clone, Debug)] +pub struct Skeleton { + pub params: Params, + pub l: u64, + pub chain_bits: u64, + pub pinned_bits: u64, + pub max_swn: u64, + pub default_swn: u64, + pub sig_bytes: u64, + pub keygen: Cost, + pub cache_bytes: u64, + pub cache_depth: u64, + pub fors_c_grinding: u64, + /// Signing, less the WOTS+C counter grinding. + sign_base: Cost, + /// The same with the top tree's cached part rebuilt instead of the whole tree. + sign_cached_base: Cost, + /// One counter trial, at one layer. + grind_step: Cost, + /// Verification, less the chain walk that the target sum shortens. + verify_base: Cost, + /// One chain step, across all layers. + verify_step: Cost, + /// WOTS-TW only; for WOTS+C verification is deterministic. + verify_worst_extra: Cost, +} + +impl Skeleton { + /// `None` if the parameters are not self-consistent (`d` must divide `h`, + /// `w` must be a power of two, FORS+C needs `k >= 2`). + pub fn new(p: Params) -> Option { + if p.h == 0 || p.d == 0 || !p.h.is_multiple_of(p.d) || p.k < 1 || p.a < 1 { + return None; + } + if p.scheme.fors_c() && p.k < 2 { + return None; + } + if !p.scheme.wots_c() && p.dropped_chains > 0 { + return None; // WOTS-TW has no counter to grind + } + // A tree of 2^64 leaves does not fit a u64 count, and could not be + // generated under any budget expressible in one either: keygen alone is + // at least 2^h' hashes, and one FORS tree at least 2^a. + if p.h_prime() > 63 || p.a > 63 { + return None; + } + let enc = p.encoding()?; + let l = p.chains()?; + let (n, cv, d, hp) = (p.n, p.convention, p.d, p.h_prime()); + let trees = p.scheme.trees(p.k); + let t = 1u64 << p.a; + + // ---- size ---------------------------------------------------------- + let layer = hp * n + l * n + if p.scheme.wots_c() { COUNTER_BYTES } else { 0 }; + let sig_bytes = n + d * layer + trees * n + trees * p.a * n; + + // ---- one WOTS key pair, and one XMSS tree over 2^x of them --------- + let leaf = Cost::new( + l + l * (p.w - 1) + 1, + l * cv.prf() + l * (p.w - 1) * cv.th1() + cv.th(l, n), + ); + let tree = |leaves: u64| leaf * leaves + Cost::new(leaves - 1, (leaves - 1) * cv.th2()); + let top_tree = tree(1 << hp); + + // ---- signing ------------------------------------------------------- + let msg_hash = Cost::new(2, cv.hmsg() + cv.prfmsg()); + let fors_build = Cost::new( + trees * t + trees * t + trees * (t - 1) + 1, + trees * t * cv.prf() + trees * t * cv.th1() + trees * (t - 1) * cv.th2() + cv.th(trees, n), + ); + // FORS+C grinds the digest until its last a bits vanish, so the last + // FORS tree always opens leaf 0 and needs no authentication path. + let fors_grind = if p.scheme.fors_c() { msg_hash * t } else { msg_hash }; + let sign_base = top_tree * d + fors_build + fors_grind; + + // ---- signing with the top tree's half top cached ------------------- + // Only the top tree is worth caching: it is the same for every + // signature, while the trees below it are picked by the (pseudorandom) + // index. Its auth path splits at the cached level: below, rebuild the + // 2^c-leaf subtree the signing leaf sits in; above, the nodes are + // already in state. Rebuilt leaves are charged a full WOTS public key, + // as everywhere else here. + // + // A BDS-style traversal would amortize a tree to h' leaves per + // signature with O(h') state, but it only works walking the leaves in + // order. SPHINCS+ picks its index by hashing the message, so + // consecutive signatures land on unrelated leaves and nothing + // amortizes; an index-independent cache like this one is what is left, + // hence sqrt rather than h'. + let c = p.cache_height.unwrap_or(hp / 2); + if c > hp { + return None; + } + let stored_level = 1u64 << (hp - c); + let mut cached_tree = tree(1 << c); + let cache_bytes; + if p.cache_level_only { + cached_tree = cached_tree + Cost::new(stored_level - 1, (stored_level - 1) * cv.th2()); + cache_bytes = stored_level * n; + } else { + cache_bytes = (2 * stored_level - 1) * n; + } + let sign_cached_base = sign_base - top_tree + cached_tree; + + // ---- verification -------------------------------------------------- + let fors_verify = Cost::new( + trees + trees * p.a + 1, + trees * cv.th1() + trees * p.a * cv.th2() + cv.th(trees, n), + ); + let auth = Cost::new(p.h, p.h * cv.th2()); + let mut verify_base = Cost::new(1, cv.hmsg()) + fors_verify + auth; + let mut verify_step = Cost::default(); + let mut verify_worst_extra = Cost::default(); + if p.scheme.wots_c() { + // the digits sum to S_wn, so the remaining chain steps are fixed at + // (w-1)*l - S_wn, and the counter has to be hashed once per layer + verify_base = verify_base + Cost::new(2, cv.th1c() + cv.th(l, n)) * d; + verify_step = Cost::new(1, cv.th1()) * d; + } else { + let avg = (p.w - 1) * l / 2; + verify_base = verify_base + Cost::new(avg + 1, avg * cv.th1() + cv.th(l, n)) * d; + let worst = p.wots_tw_worst_steps(); + verify_worst_extra = Cost::new(worst - avg, (worst - avg) * cv.th1()) * d; + } + + Some(Self { + params: p, + l, + chain_bits: enc.chain_bits, + pinned_bits: if p.scheme.wots_c() { enc.pinned_bits } else { 0 }, + max_swn: (p.w - 1) * l, + default_swn: if p.scheme.wots_c() { enc.default_swn() } else { 0 }, + sig_bytes, + keygen: top_tree, + cache_bytes, + cache_depth: hp - c, + fors_c_grinding: if p.scheme.fors_c() { fors_grind.hashes } else { 0 }, + sign_base, + sign_cached_base, + grind_step: Cost::new(1, p.convention.th1c()), + verify_base, + verify_step, + verify_worst_extra, + }) + } + + /// Expected signing cost when each layer grinds `trials` counters. + pub fn sign(&self, trials: u64) -> Cost { + self.sign_base + self.grind_step * trials.saturating_mul(self.params.d) + } + + /// The same with the top tree's half top cached. + pub fn sign_cached(&self, trials: u64) -> Cost { + self.sign_cached_base + self.grind_step * trials.saturating_mul(self.params.d) + } + + /// Verification at this target sum. `swn` is ignored for WOTS-TW. + pub fn verify(&self, swn: u64) -> Cost { + self.verify_base + self.verify_step * (self.max_swn - swn.min(self.max_swn)) + } + + /// Verification when every message digit is zero (WOTS-TW only). + pub fn verify_worst(&self, swn: u64) -> Cost { + self.verify(swn) + self.verify_worst_extra + } + + /// The full picture at one target sum. + pub fn finish(&self, swn: u64, trials: u64) -> Costs { + Costs { + l: self.l, + chain_bits: self.chain_bits, + pinned_bits: self.pinned_bits, + dropped_chains: self.params.dropped_chains, + swn: self.params.scheme.wots_c().then_some(swn), + sig_bytes: self.sig_bytes, + keygen: self.keygen, + sign: self.sign(trials), + sign_cached: self.sign_cached(trials), + verify: self.verify(swn), + verify_worst: self.verify_worst(swn), + wots_c_grinding: trials.saturating_mul(self.params.d), + fors_c_grinding: self.fors_c_grinding, + cache_depth: self.cache_depth, + cache_bytes: self.cache_bytes, + } + } + + /// The full picture at the mean target sum, the report's default. + pub fn at_default_swn(&self, nu: Option<&NuTable>) -> Costs { + let swn = self.default_swn; + let trials = nu.map_or(0, |t| t.trials(swn)); + self.finish(swn, trials) + } +} + +/// Every cost of a parameter set at one target sum. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Costs { + pub l: u64, + pub chain_bits: u64, + pub pinned_bits: u64, + pub dropped_chains: u64, + pub swn: Option, + pub sig_bytes: u64, + pub keygen: Cost, + pub sign: Cost, + pub sign_cached: Cost, + pub verify: Cost, + pub verify_worst: Cost, + pub wots_c_grinding: u64, + pub fors_c_grinding: u64, + pub cache_depth: u64, + pub cache_bytes: u64, +} + +impl Costs { + pub fn grinding(&self) -> u64 { + self.wots_c_grinding + self.fors_c_grinding + } +} + +/// Costs of one parameter set, building the digit-sum table as needed. +/// +/// Convenient for a single evaluation; a search should hold the [`NuTable`] and +/// drive [`Skeleton`] itself, since the table depends only on `(l, w)`. +pub fn costs(p: Params, swn: Option) -> Option { + let sk = Skeleton::new(p)?; + if !p.scheme.wots_c() { + return Some(sk.finish(0, 0)); + } + let table = NuTable::new(sk.l, p.w, (8 * p.n) as u32); + let swn = swn.unwrap_or(sk.default_swn); + Some(sk.finish(swn, table.trials(swn))) +} diff --git a/doc/sphincs/src/report.rs b/doc/sphincs/src/report.rs new file mode 100644 index 000000000..14933841a --- /dev/null +++ b/doc/sphincs/src/report.rs @@ -0,0 +1,181 @@ +//! Human-readable output. + +use crate::params::{Costs, Params}; +use crate::search::{Budgets, Candidate, Unit}; +use crate::security::forgery_exponent; + +pub fn si(x: u64) -> String { + let f = x as f64; + for (unit, div) in [("G", 1e9), ("M", 1e6), ("K", 1e3)] { + if f >= div { + return format!("{:.2}{unit}", f / div); + } + } + x.to_string() +} + +/// One line spelling out the WOTS+C digest-to-chains cut. +pub fn encoding_line(p: &Params, c: &Costs) -> String { + let Some(swn) = c.swn else { + let l1 = (8 * p.n).div_ceil(c.chain_bits); + return format!( + "encoding WOTS-TW: {} chains, {l1} for the digest + {} checksum", + c.l, + c.l - l1 + ); + }; + let dropped = if c.dropped_chains > 0 { + format!(", {} chain(s) dropped", c.dropped_chains) + } else { + String::new() + }; + format!( + "encoding {} bits/chain, {} of {} digest bits pinned to zero{dropped}, S_wn = {swn} of {}", + c.chain_bits, + c.pinned_bits, + 8 * p.n, + c.l * (p.w - 1) + ) +} + +/// The full picture of one parameter set. +pub fn report(p: &Params, c: &Costs, lifetime: u32) -> String { + let forgery = forgery_exponent(lifetime, p.h as u32, p.k, p.a); + let cap = 8.0 * p.n as f64; + let security = forgery.map_or(0.0, |f| f.min(cap)); + let speedup = c.sign.hashes as f64 / c.sign_cached.hashes.max(1) as f64; + let row = |label: &str, x: crate::cost::Cost, note: String| { + format!("{label:<24}{:>12}{:>16}{note}", si(x.hashes), si(x.compressions)) + }; + + let mut lines = vec![ + format!( + "scheme {} q_s = 2^{lifetime} n = {} bits", + p.scheme.label(), + 8 * p.n + ), + format!("(h, d, h') ({}, {}, {})", p.h, p.d, p.h_prime()), + format!( + "(a, k) ({}, {}){}", + p.a, + p.k, + if p.scheme.fors_c() { + format!(" [FORS+C signs {} trees]", p.k - 1) + } else { + String::new() + } + ), + format!("(w, l) ({}, {})", p.w, c.l), + encoding_line(p, c), + String::new(), + match forgery { + Some(f) => format!( + "security {security:.1} bits classical (FORS forgery {f:.1}, preimage {})", + cap as u64 + ), + None => format!( + "security none: q_s = 2^{lifetime} reuses every FORS instance ~2^{} times", + lifetime as i64 - p.h as i64 + ), + }, + format!("signature {} bytes", c.sig_bytes), + String::new(), + format!("{:<24}{:>12}{:>16}", "", "hashes", "compressions"), + row("keygen", c.keygen, String::new()), + row("sign (avg)", c.sign, String::new()), + row( + "sign (half-top cached)", + c.sign_cached, + format!( + " ({speedup:.2}x, {} B of state at depth {})", + c.cache_bytes, c.cache_depth + ), + ), + row("verify", c.verify, String::new()), + ]; + if c.verify_worst != c.verify { + lines.push(row("verify (worst)", c.verify_worst, String::new())); + } + lines.push(String::new()); + lines.push(format!( + "of signing, grinding accounts for {} hashes: {} for the WOTS+C counters, {} for the FORS+C digest", + si(c.grinding()), + si(c.wots_c_grinding), + si(c.fors_c_grinding) + )); + lines.join("\n") +} + +const COLUMNS: [(&str, usize); 16] = [ + ("verify", 9), + ("scheme", 9), + ("h", 4), + ("d", 3), + ("h'", 4), + ("a", 3), + ("k", 3), + ("cb", 3), + ("drop", 5), + ("l", 4), + ("S_wn", 6), + ("size", 6), + ("keygen", 8), + ("sign", 8), + ("sign$", 8), + ("state", 7), +]; + +fn cells(b: &Budgets, c: &Candidate) -> Vec { + let (p, x) = (&c.params, &c.costs); + vec![ + si(b.unit.of(x.verify)), + p.scheme.label().to_string(), + p.h.to_string(), + p.d.to_string(), + p.h_prime().to_string(), + p.a.to_string(), + p.k.to_string(), + x.chain_bits.to_string(), + p.dropped_chains.to_string(), + x.l.to_string(), + x.swn.map_or("-".to_string(), |s| s.to_string()), + x.sig_bytes.to_string(), + si(b.unit.of(x.keygen)), + si(b.unit.of(x.sign)), + si(b.unit.of(x.sign_cached)), + x.cache_bytes.to_string(), + ] +} + +pub fn table(b: &Budgets, cands: &[Candidate]) -> String { + let head: Vec = COLUMNS.iter().map(|(name, w)| format!("{name:>w$}")).collect(); + let head = head.join(" "); + let mut lines = vec![head.clone(), "-".repeat(head.len())]; + for c in cands { + let row: Vec = cells(b, c) + .iter() + .zip(COLUMNS) + .map(|(cell, (_, w))| format!("{cell:>w$}", w = w)) + .collect(); + lines.push(row.join(" ")); + } + lines.join("\n") +} + +pub fn utilization(b: &Budgets, c: &Candidate) -> String { + let used = [ + ("keygen", b.unit.of(c.costs.keygen), b.max_keygen), + ("sign", b.unit.of(c.costs.sign), b.max_sign), + ("sign cached", b.unit.of(c.costs.sign_cached), b.max_sign_cached), + ("size", c.costs.sig_bytes, b.max_size), + ]; + used.iter() + .filter(|(_, _, limit)| *limit > 0) + .map(|(name, v, limit)| format!("{name} {:.0}%", 100.0 * *v as f64 / *limit as f64)) + .collect::>() + .join(", ") +} + +pub fn unit_label(u: Unit) -> &'static str { + u.label() +} diff --git a/doc/sphincs/src/search.rs b/doc/sphincs/src/search.rs new file mode 100644 index 000000000..7f035d7f0 --- /dev/null +++ b/doc/sphincs/src/search.rs @@ -0,0 +1,403 @@ +//! Exhaustive search for the parameter set with the cheapest verification. +//! +//! Every `(scheme, h, d | h, chain_bits, dropped_chains, a, k, S_wn)` point that +//! meets the budgets is costed and compared. Nothing is chosen by an optimality +//! argument, and nothing is skipped by a monotonicity one: the three tests that +//! run before the `S_wn` scan reject only points that no `S_wn` could rescue, +//! because size and keygen do not depend on `S_wn` at all, and the least +//! grinding any `S_wn` can ask for is read off the digit-sum table rather than +//! assumed to sit anywhere in particular. +//! +//! What is assumed is the searched range of each parameter, hardcoded below. +//! When a result comes out at the top of one of those ranges the range itself +//! may be what is limiting it, so [`edges`] reports that and names the constant +//! to raise. Ranges the budgets or the structure already close (`d` over the +//! divisors of `h`, `S_wn` over the digit sums a code of `l` chains can reach) +//! need no such warning and get none. + +use std::time::Instant; + +use crate::cost::{Cost, NuTable, SCHEMES, Scheme}; +use crate::params::{Costs, Params, Skeleton}; +use crate::security::SecurityTable; + +/// Hardcoded search ranges, wide enough that the budgets are normally what +/// binds: SLH-DSA level 1 lives at `h = 63..64`, `a = 6..14`, `k = 14..35`, +/// `chain_bits = 4`, and the report's candidates at `h = 20..44`, `a = 14..16`, +/// `k = 8..11`, so every range here has room above anything yet proposed. +/// Raising one costs only runtime. +pub const H_MAX: u64 = 96; +/// log2 of the leaves in one FORS tree. +pub const A_MAX: u64 = 32; +/// Number of FORS trees. +pub const K_MAX: u64 = 64; +/// log2(w), so w up to 4096. +pub const CHAIN_BITS_MAX: u64 = 12; +/// WOTS+C chains dropped beyond the minimal bit pinning. +pub const DROPPED_MAX: u64 = 16; + +/// NIST level 1, matching SLH-DSA's level 1 parameter sets. +pub const LEVEL1_BITS: f64 = 128.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Unit { + Hashes, + Compressions, +} + +impl Unit { + pub fn of(self, c: Cost) -> u64 { + match self { + Unit::Hashes => c.hashes, + Unit::Compressions => c.compressions, + } + } + pub fn label(self) -> &'static str { + match self { + Unit::Hashes => "hashes", + Unit::Compressions => "compressions", + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Budgets { + /// log2 of the signatures allowed under one public key. + pub lifetime: u32, + pub max_keygen: u64, + pub max_sign: u64, + pub max_sign_cached: u64, + pub max_size: u64, + /// Classical security floor in bits. + pub security: f64, + /// Unit of every budget above, and of the objective. + pub unit: Unit, +} + +impl Budgets { + fn of(&self, c: Cost) -> u64 { + self.unit.of(c) + } + + fn fits(&self, c: &Costs) -> bool { + c.sig_bytes <= self.max_size + && self.of(c.keygen) <= self.max_keygen + && self.of(c.sign) <= self.max_sign + && self.of(c.sign_cached) <= self.max_sign_cached + } +} + +#[derive(Clone, Debug)] +pub struct Grid { + pub schemes: Vec, + pub n: u64, + pub h_min: u64, + pub h_max: u64, + pub a_min: u64, + pub a_max: u64, + pub k_max: u64, + pub chain_bits: Vec, + pub max_dropped: u64, + pub cache_level_only: bool, +} + +impl Default for Grid { + fn default() -> Self { + Self { + schemes: SCHEMES.to_vec(), + n: 16, + h_min: 1, + h_max: H_MAX, + a_min: 1, + a_max: A_MAX, + k_max: K_MAX, + chain_bits: (1..=CHAIN_BITS_MAX).collect(), + max_dropped: DROPPED_MAX, + cache_level_only: false, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Candidate { + pub params: Params, + pub costs: Costs, +} + +impl Candidate { + pub fn key(&self) -> Key { + let p = self.params; + (p.scheme, p.h, p.d, p.a, p.k, p.w, p.dropped_chains) + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Stats { + /// `(scheme, h, d, chain_bits, dropped)` tuples reached. + pub grid: u64, + pub keygen_pruned: u64, + /// `(a, k)` pairs rejected by the security floor. + pub insecure: u64, + /// `(a, k)` pairs whose signature is too big, whatever the target sum. + pub size_pruned: u64, + /// `(a, k)` pairs too slow to sign at the least grinding any target sum asks. + pub sign_pruned: u64, + /// `(a, k)` pairs whose whole target-sum range was scanned. + pub swept: u64, + /// Points meeting every budget. + pub feasible: u64, + pub skeletons: u64, + pub seconds: f64, +} + +impl std::fmt::Display for Stats { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "grid {} (scheme, h, d, chain_bits, dropped) tuples, {} over keygen; \ + then {} (a, k) pairs insecure, {} over size, {} over signing; \ + {} target-sum ranges swept, {} points feasible ({} parameter sets costed) in {:.1}s", + self.grid, + self.keygen_pruned, + self.insecure, + self.size_pruned, + self.sign_pruned, + self.swept, + self.feasible, + self.skeletons, + self.seconds + ) + } +} + +/// Identifies one parameter tuple, everything but the target sum. +pub type Key = (Scheme, u64, u64, u64, u64, u64, u64); + +fn divisors(h: u64) -> Vec { + (1..=h).filter(|d| h.is_multiple_of(*d)).collect() +} + +fn params(g: &Grid, scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, dropped: u64) -> Params { + Params { + scheme, + h, + d, + a, + k, + w, + n: g.n, + dropped_chains: dropped, + cache_height: None, + cache_level_only: g.cache_level_only, + convention: Default::default(), + } +} + +/// Every feasible parameter set, ordered by verification cost. +/// +/// One row per `(scheme, h, d, a, k, w, dropped_chains)`, carrying the best +/// target sum for that tuple. Rows are what gets printed; the comparison behind +/// each one saw every target sum. +pub fn search(b: &Budgets, g: &Grid, st: &mut Stats) -> Vec { + let started = Instant::now(); + let digest_bits = (8 * g.n) as u32; + let mut sec = SecurityTable::new(b.lifetime, b.security, g.n, g.h_max as u32, g.k_max, g.a_max); + let mut best: Vec = Vec::new(); + let mut index: std::collections::HashMap = Default::default(); + let all_divisors: Vec> = (0..=g.h_max).map(divisors).collect(); + + for &scheme in &g.schemes { + for &bits in &g.chain_bits { + let w = 1u64 << bits; + // WOTS-TW has no counter to grind, so it cannot drop chains, and + // WOTS+C has to keep at least one. + let max_dropped = if scheme.wots_c() { + g.max_dropped.min((8 * g.n / bits).saturating_sub(1)) + } else { + 0 + }; + for dropped in 0..=max_dropped { + let probe = params( + g, + scheme, + g.h_max.max(1), + 1, + g.a_min, + if scheme.fors_c() { 2 } else { 1 }, + w, + dropped, + ); + let Some(l) = probe.chains() else { continue }; + let table = scheme.wots_c().then(|| NuTable::new(l, w, digest_bits)); + let min_trials = table.as_ref().map_or(0, |t| t.min_trials()); + for h in g.h_min..=g.h_max { + for &d in &all_divisors[h as usize] { + st.grid += 1; + // keygen is one top tree: no a, k or target sum in it + let kg = params( + g, + scheme, + h, + d, + g.a_min, + if scheme.fors_c() { 2 } else { 1 }, + w, + dropped, + ); + let Some(kg) = Skeleton::new(kg) else { continue }; + st.skeletons += 1; + if b.of(kg.keygen) > b.max_keygen { + st.keygen_pruned += 1; + continue; + } + for a in g.a_min..=g.a_max { + for k in 1..=g.k_max { + if !sec.is_secure(h as u32, k, a) { + st.insecure += 1; + continue; + } + let p = params(g, scheme, h, d, a, k, w, dropped); + let Some(sk) = Skeleton::new(p) else { continue }; + st.skeletons += 1; + // size and keygen do not depend on the target + // sum, and no target sum grinds less than the + // table's cheapest, so these three reject only + // points that no target sum could rescue + if sk.sig_bytes > b.max_size { + st.size_pruned += 1; + continue; + } + if b.of(sk.sign(min_trials)) > b.max_sign + || b.of(sk.sign_cached(min_trials)) > b.max_sign_cached + { + st.sign_pruned += 1; + continue; + } + let Some(table) = table.as_ref() else { + // WOTS-TW: no target sum to choose + let c = sk.finish(0, 0); + if b.fits(&c) { + st.feasible += 1; + record(&mut best, &mut index, Candidate { params: p, costs: c }, b); + } + continue; + }; + st.swept += 1; + let mut winner: Option<(u64, u64)> = None; + for swn in 0..=sk.max_swn { + let trials = table.trials(swn); + if b.of(sk.sign(trials)) > b.max_sign + || b.of(sk.sign_cached(trials)) > b.max_sign_cached + { + continue; + } + st.feasible += 1; + let v = b.of(sk.verify(swn)); + if winner.is_none_or(|(_, best_v)| v < best_v) { + winner = Some((swn, v)); + } + } + if let Some((swn, _)) = winner { + let c = sk.finish(swn, table.trials(swn)); + record(&mut best, &mut index, Candidate { params: p, costs: c }, b); + } + } + } + } + } + } + } + } + + st.seconds = started.elapsed().as_secs_f64(); + best.sort_by_key(|c| (b.of(c.costs.verify), c.costs.sig_bytes, b.of(c.costs.sign))); + best +} + +fn record(best: &mut Vec, index: &mut std::collections::HashMap, cand: Candidate, b: &Budgets) { + match index.get(&cand.key()) { + Some(&i) if b.of(best[i].costs.verify) <= b.of(cand.costs.verify) => {} + Some(&i) => best[i] = cand, + None => { + index.insert(cand.key(), best.len()); + best.push(cand); + } + } +} + +/// Axes where a result sits at the top of a hardcoded range. +/// +/// Such a result may be limited by the range rather than by the budgets, so it +/// is worth raising the range and rerunning before believing it. +pub fn edges(g: &Grid, c: &Candidate) -> Vec { + let bits_max = g.chain_bits.iter().copied().max().unwrap_or(0); + let at = [ + ("h", c.params.h, g.h_max, "H_MAX / --h-max"), + ("a", c.params.a, g.a_max, "A_MAX / --a-max"), + ("k", c.params.k, g.k_max, "K_MAX / --k-max"), + ( + "chain_bits", + c.costs.chain_bits, + bits_max, + "CHAIN_BITS_MAX / --chain-bits", + ), + ( + "dropped_chains", + c.params.dropped_chains, + g.max_dropped, + "DROPPED_MAX / --max-dropped", + ), + ]; + at.iter() + .filter(|(_, v, limit, _)| *v + 1 >= *limit) + .map(|(axis, v, limit, what)| { + let where_ = if v >= limit { "at" } else { "one step below" }; + format!("{axis} = {v} is {where_} the top of the searched range ({limit}): raise {what} and rerun") + }) + .collect() +} + +/// The same search with nothing skipped: every `(a, k, S_wn)` point costed in +/// full and checked against every budget. +/// +/// Only usable on a tiny grid, which is the point: it is the oracle the real +/// search is diffed against in `tests/goldens`. +pub fn naive_search(b: &Budgets, g: &Grid) -> Vec { + let digest_bits = (8 * g.n) as u32; + let mut out: Vec = Vec::new(); + for &scheme in &g.schemes { + for &bits in &g.chain_bits { + let w = 1u64 << bits; + let max_dropped = if scheme.wots_c() { g.max_dropped } else { 0 }; + for dropped in 0..=max_dropped { + for h in g.h_min..=g.h_max { + for d in divisors(h) { + for a in g.a_min..=g.a_max { + for k in 1..=g.k_max { + let p = params(g, scheme, h, d, a, k, w, dropped); + let Some(sk) = Skeleton::new(p) else { continue }; + if crate::security::security_bits(b.lifetime, h as u32, k, a, g.n) < b.security { + continue; + } + let table = scheme.wots_c().then(|| NuTable::new(sk.l, w, digest_bits)); + let sums: Vec = match &table { + Some(_) => (0..=sk.max_swn).collect(), + None => vec![0], + }; + for swn in sums { + let trials = table.as_ref().map_or(0, |t| t.trials(swn)); + let c = sk.finish(swn, trials); + if b.fits(&c) { + out.push(Candidate { params: p, costs: c }); + } + } + } + } + } + } + } + } + } + out.sort_by_key(|c| (b.of(c.costs.verify), c.costs.sig_bytes, b.of(c.costs.sign))); + out +} diff --git a/doc/sphincs/src/security.rs b/doc/sphincs/src/security.rs new file mode 100644 index 000000000..1765d5fd5 --- /dev/null +++ b/doc/sphincs/src/security.rs @@ -0,0 +1,118 @@ +//! Classical security of a FORS-based parameter set. +//! +//! Ported from `security.sage` of BlockstreamResearch/SPHINCS-Parameters. The +//! sage version carries the sum in 100-digit decimals; this carries it in +//! log2-space `f64`, as the report's own site does, which `tests/goldens` pins +//! against the decimal values to better than 0.001 bits. + +/// -log2 P(FORS subset forgery) after `q_s = 2^lifetime` signatures. +/// +/// An adversary that finds a hypertree leaf reused `r` times, and a message +/// whose `k` FORS indices all point at leaves those `r` signatures already +/// opened, forges without inverting anything: +/// +/// ```text +/// P = sum_r C(q_s, r) p^r (1-p)^(q_s-r) * (1 - (1 - 1/t)^r)^k +/// ``` +/// +/// with `p = 2^-h` the chance one signature lands on a given leaf and `t = 2^a`. +/// The binomial term is carried by its recurrence rather than built from +/// `C(q_s, r)`, so `q_s = 2^64` costs no more than `q_s = 2^20`. +/// +/// `None` when `q_s` so far exceeds the `2^h` leaves that there is no security +/// left to quantify. +pub fn forgery_exponent(lifetime: u32, h: u32, k: u64, a: u64) -> Option { + const LOG2_E: f64 = std::f64::consts::LOG2_E; + // Expected times one FORS instance is reused. Past a few thousand the sum + // needs more terms than it is worth: the answer is "none", not a number. + let lam = 2f64.powi(lifetime as i32 - h as i32); + if lam > 4096.0 { + return None; + } + let q_s = 2f64.powi(lifetime as i32); + let log2_p = -(h as f64); + let log2_1mp = (-2f64.powi(-(h as i32))).ln_1p() * LOG2_E; + let ln_miss = (-1.0 / 2f64.powi(a as i32)).ln_1p(); // ln(1 - 1/t) + + let r_max = (lam + 40.0 * (lam + 1.0).sqrt()).ceil() as u64 + 40; + let r_max = r_max.max(1000); + + let mut log2_term = q_s * log2_1mp; // C(q_s,0) p^0 (1-p)^q_s + let mut log2_sigma = f64::NEG_INFINITY; + for r in 1..=r_max { + let rf = r as f64; + log2_term += (q_s - rf + 1.0).log2() - rf.log2() + log2_p - log2_1mp; + // log2 (1 - (1-1/t)^r)^k, via expm1 so that small r keeps its digits + let log2_pf = k as f64 * (-(rf * ln_miss).exp_m1()).log2(); + let contribution = log2_term + log2_pf; + log2_sigma = log2_sum_exp(log2_sigma, contribution); + // Stop once the tail cannot matter, either absolutely or against what + // has already accumulated. + if rf > lam && (contribution < -1250.0 || contribution < log2_sigma - 80.0) { + break; + } + } + Some(-log2_sigma) +} + +/// Classical bit security: the forgery exponent capped by the preimage bound. +/// +/// A query aimed at a FORS forgery cannot double as a preimage query for a tree +/// node or a WOTS chain (different tweaks), so the two attacks are independent +/// strategies and the adversary simply takes the better one. +pub fn security_bits(lifetime: u32, h: u32, k: u64, a: u64, n: u64) -> f64 { + forgery_exponent(lifetime, h, k, a).map_or(0.0, |e| e.min(8.0 * n as f64)) +} + +fn log2_sum_exp(a: f64, b: f64) -> f64 { + let (hi, lo) = if a >= b { (a, b) } else { (b, a) }; + if hi == f64::NEG_INFINITY { + return hi; + } + hi + 2f64.powf(lo - hi).ln_1p() * std::f64::consts::LOG2_E +} + +/// `is_secure` memoized over `(h, k, a)`, which is all it depends on. +/// +/// A search revisits the same triple once per `(scheme, d, chain_bits, +/// dropped_chains)`, so without this the security sum dominates everything. +pub struct SecurityTable { + lifetime: u32, + target: f64, + n: u64, + h_max: u32, + k_max: u64, + a_max: u64, + /// 0 unknown, 1 secure, 2 insecure + seen: Vec, +} + +impl SecurityTable { + pub fn new(lifetime: u32, target: f64, n: u64, h_max: u32, k_max: u64, a_max: u64) -> Self { + let cells = (h_max as usize + 1) * (k_max as usize + 1) * (a_max as usize + 1); + Self { + lifetime, + target, + n, + h_max, + k_max, + a_max, + seen: vec![0; cells], + } + } + + pub fn is_secure(&mut self, h: u32, k: u64, a: u64) -> bool { + if h > self.h_max || k > self.k_max || a > self.a_max { + return self.compute(h, k, a); + } + let i = (h as usize * (self.k_max as usize + 1) + k as usize) * (self.a_max as usize + 1) + a as usize; + if self.seen[i] == 0 { + self.seen[i] = if self.compute(h, k, a) { 1 } else { 2 }; + } + self.seen[i] == 1 + } + + fn compute(&self, h: u32, k: u64, a: u64) -> bool { + security_bits(self.lifetime, h, k, a, self.n) >= self.target + } +} diff --git a/doc/sphincs/tests/goldens.rs b/doc/sphincs/tests/goldens.rs new file mode 100644 index 000000000..41fa1033d --- /dev/null +++ b/doc/sphincs/tests/goldens.rs @@ -0,0 +1,453 @@ +//! Everything this crate computes, pinned against something outside it. +//! +//! Sources, in descending order of authority: +//! +//! * `tests/fixtures.json` of BlockstreamResearch/SPHINCS-Parameters, frozen +//! there from real `sage costs.sage` runs, under both hash conventions; +//! * Tables 1 and 2 of the report itself, for the columns it publishes; +//! * `security.sage`, whose 100-digit decimal sum the log2-space f64 port here +//! has to reproduce; +//! * `doc/xmss/main.tex`, for the digest-cut geometry; +//! * for the search, a naive oracle in this crate that skips nothing. + +use sphincs_params::cost::{Convention, Encoding, NuTable, Scheme}; +use sphincs_params::params::{Params, Skeleton, costs}; +use sphincs_params::search::{Budgets, Grid, LEVEL1_BITS, Stats, Unit, naive_search, search}; +use sphincs_params::security::{forgery_exponent, security_bits}; + +fn params(scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, cached_midstate: bool) -> Params { + Params { + scheme, + h, + d, + a, + k, + w, + n: 16, + dropped_chains: 0, + cache_height: None, + cache_level_only: false, + convention: Convention { cached_midstate }, + } +} + +/// `(scheme, h, d, k, a, w, S_wn)` and the `(size, keygen, sign, verify, +/// verify_worst)` it must produce, sizes in bytes and costs in compressions. +type Fixture = (Scheme, u64, u64, u64, u64, u64, Option, [u64; 5]); + +/// From fixtures.json under the cached convention. +const FIXTURES_CACHED: [Fixture; 7] = [ + ( + Scheme::Spx, + 63, + 7, + 14, + 12, + 16, + None, + [7856, 292351, 2218483, 2155, 3891], + ), + ( + Scheme::Wc, + 44, + 4, + 8, + 16, + 16, + Some(240), + [4960, 1069055, 5849347, 1185, 1185], + ), + ( + Scheme::Wc, + 40, + 5, + 11, + 14, + 256, + Some(2040), + [4596, 1050111, 5794969, 10441, 10441], + ), + ( + Scheme::Wc, + 40, + 5, + 11, + 14, + 256, + Some(2840), + [4596, 1050111, 5941944, 6441, 6441], + ), + ( + Scheme::WcFc, + 44, + 4, + 8, + 16, + 16, + Some(240), + [4688, 1069055, 5914880, 1168, 1168], + ), + ( + Scheme::WcFc, + 40, + 5, + 11, + 14, + 256, + Some(2040), + [4356, 1050111, 5811349, 10425, 10425], + ), + ( + Scheme::WcFc, + 20, + 2, + 10, + 15, + 256, + Some(2040), + [3160, 4200447, 9418194, 4261, 4261], + ), +]; + +/// The same under the uncached convention, from the fixtures' `uncached_spot`. +const FIXTURES_UNCACHED: [Fixture; 2] = [ + ( + Scheme::Spx, + 63, + 7, + 14, + 12, + 16, + None, + [7856, 292862, 2279391, 2387, 4123], + ), + ( + Scheme::Wc, + 44, + 4, + 8, + 16, + 16, + Some(240), + [4960, 1071102, 6381815, 1357, 1357], + ), +]; + +#[test] +fn matches_the_sage_fixtures() { + for (cached, rows) in [(true, &FIXTURES_CACHED[..]), (false, &FIXTURES_UNCACHED[..])] { + for &(scheme, h, d, k, a, w, swn, want) in rows { + let p = params(scheme, h, d, a, k, w, cached); + let c = costs(p, swn).expect("consistent parameters"); + let got = [ + c.sig_bytes, + c.keygen.compressions, + c.sign.compressions, + c.verify.compressions, + c.verify_worst.compressions, + ]; + assert_eq!( + got, + want, + "{} h={h} d={d} k={k} a={a} w={w} cached={cached}", + scheme.label() + ); + } + } +} + +/// Tables 1 and 2 of the report, WOTS/FORS rows only: `(scheme, h, d, a, k, w, +/// S_wn)` then `(SigVer, SigTime/1e4 to three figures, Exp. Search)` in hashes. The tables' Sig (B) column is 16 +/// bytes above what the current scripts compute (7856 for SLH-DSA-128s is the +/// FIPS 205 value, against the table's 7872); the fixtures above are the +/// authority there, and the tables predate them. +type ReportRow = (Scheme, u64, u64, u64, u64, u64, Option, u64, f64, Option); + +const REPORT_TABLE: [ReportRow; 18] = [ + (Scheme::Spx, 63, 7, 12, 14, 16, None, 2088, 219.0, Some(0)), + (Scheme::Wc, 44, 4, 16, 8, 16, Some(240), 1150, 578.0, Some(264)), + (Scheme::Wc, 44, 4, 16, 8, 16, Some(304), 894, 579.0, Some(5344)), + (Scheme::Wc, 44, 4, 16, 8, 256, Some(2040), 8350, 3515.0, Some(2996)), + (Scheme::Wc, 40, 5, 14, 11, 256, Some(2040), 10417, 579.0, Some(3745)), + (Scheme::Wc, 40, 5, 14, 11, 256, Some(2840), 6417, 594.0, None), + (Scheme::WcFc, 44, 4, 16, 8, 16, Some(240), 1133, 572.0, None), + (Scheme::WcFc, 40, 5, 14, 11, 256, Some(2040), 10402, 577.0, Some(36513)), + (Scheme::Wc, 36, 3, 14, 9, 16, Some(240), 899, 676.0, Some(198)), + (Scheme::Wc, 33, 3, 15, 9, 16, Some(304), 713, 405.0, Some(4008)), + (Scheme::Wc, 32, 4, 14, 10, 256, Some(2840), 5152, 481.0, None), + (Scheme::WcFc, 33, 3, 15, 9, 16, Some(240), 889, 401.0, Some(65734)), + (Scheme::WcFc, 32, 4, 14, 10, 256, Some(2040), 8337, 467.0, Some(35764)), + (Scheme::Wc, 24, 2, 16, 8, 16, Some(240), 646, 578.0, None), + (Scheme::Wc, 24, 2, 16, 8, 256, Some(2040), 4246, 3515.0, None), + (Scheme::WcFc, 24, 2, 16, 8, 16, Some(240), 629, 572.0, None), + (Scheme::Wc, 20, 2, 15, 10, 256, Some(2040), 4266, 938.0, None), + (Scheme::WcFc, 20, 2, 15, 10, 256, Some(2040), 4250, 934.0, None), +]; + +#[test] +fn matches_the_report_tables() { + for (scheme, h, d, a, k, w, swn, sigver, sigtime_e4, search) in REPORT_TABLE { + let p = params(scheme, h, d, a, k, w, true); + let c = costs(p, swn).expect("consistent parameters"); + let tag = format!("{} h={h} d={d} a={a} k={k} w={w} S={swn:?}", scheme.label()); + assert_eq!(c.verify.hashes, sigver, "SigVer {tag}"); + let got = c.sign.hashes as f64 / 1e4; + assert!( + (got - sigtime_e4).abs() < 0.55, + "SigTime {tag}: got {got:.2}e4, want {sigtime_e4}e4" + ); + if let Some(want) = search { + assert_eq!(c.grinding(), want, "Exp. Search {tag}"); + } + } +} + +/// `(w, n, dropped)` -> `(chain_bits, pinned_bits, chains, mean S_wn)`. +/// `(8, 16, 0)` is the `doc/xmss/main.tex` instance: 2 of 128 bits pinned, +/// v = 42 chains. For the w the report uses, chain_bits divides 128 and nothing +/// is pinned. +const ENCODINGS: [(u64, u64, u64, [u64; 4]); 10] = [ + (8, 16, 0, [3, 2, 42, 147]), + (8, 16, 1, [3, 5, 41, 143]), + (8, 16, 2, [3, 8, 40, 140]), + (16, 16, 0, [4, 0, 32, 240]), + (16, 16, 1, [4, 4, 31, 232]), + (32, 16, 0, [5, 3, 25, 387]), + (256, 16, 0, [8, 0, 16, 2040]), + (4096, 16, 0, [12, 8, 10, 20475]), + (2, 16, 0, [1, 0, 128, 64]), + (8, 32, 0, [3, 1, 85, 297]), +]; + +#[test] +fn digest_cut_follows_doc_xmss() { + for (w, n, dropped, want) in ENCODINGS { + let e = Encoding::new(w, n, dropped).expect("a chain is left"); + let got = [e.chain_bits, e.pinned_bits, e.chains, e.default_swn()]; + assert_eq!(got, want, "encoding w={w} n={n} dropped={dropped}"); + } + assert!( + Encoding::new(8, 16, 42).is_none(), + "dropping every chain leaves nothing to sign" + ); + assert!(Encoding::new(24, 16, 0).is_none(), "w must be a power of two"); +} + +/// `(l, w, swn)` -> `(nu, trials)`, from the python port's exact bignum values. +#[test] +fn digit_sum_counts_are_exact() { + let cases: [(u64, u64, u64, u128, u64); 4] = [ + (42, 8, 195, 11539185377238682781344003244544752, 29490), + (42, 8, 147, 2277086601665419901777619378707106160, 150), + (16, 256, 2040, 454918678781617793203528879683071744, 749), + (128, 2, 64, 23951146041928082866135587776380551750, 15), + ]; + for (l, w, swn, nu, trials) in cases { + let t = NuTable::new(l, w, 128); + assert_eq!(t.nu(swn), nu, "nu(l={l}, w={w}, swn={swn})"); + assert_eq!(t.trials(swn), trials, "trials(l={l}, w={w}, swn={swn})"); + } + // The count is symmetric and the totals check out: nothing is lost off + // either end of the table. + let t = NuTable::new(32, 16, 128); + for s in 0..=240 { + assert_eq!(t.nu(s), t.nu(480 - s), "the digit-sum count is symmetric at s={s}"); + } + assert_eq!(t.min_trials(), t.trials(240), "the cheapest grinding is at the mean"); + assert_eq!(t.nu(240), 5181241160064611531897369560287267312); +} + +/// `(lifetime, h, k, a)` -> forgery exponent, from `security.sage` via the +/// python port's 100-digit decimal sum. The f64 log-space version here has to +/// land within a thousandth of a bit. +const SECURITY: [(u32, u32, u64, u64, f64); 13] = [ + (64, 63, 14, 12, 133.749299297), + (40, 44, 8, 16, 128.283950447), + (40, 40, 11, 14, 134.630384667), + (30, 32, 10, 14, 131.514752565), + (20, 24, 8, 16, 128.283952741), + (30, 33, 9, 15, 131.399050971), + (20, 20, 10, 15, 133.177627134), + (40, 42, 9, 15, 128.338475839), + (30, 30, 9, 16, 129.632278830), + (20, 18, 19, 10, 129.282431578), + (64, 63, 35, 6, 104.414518339), + (40, 45, 8, 16, 130.423562374), + (30, 36, 9, 14, 129.476084807), +]; + +#[test] +fn security_matches_the_decimal_sum() { + for (lifetime, h, k, a, want) in SECURITY { + let got = forgery_exponent(lifetime, h, k, a).expect("converges"); + assert!( + (got - want).abs() < 1e-3, + "forgery exponent at q_s=2^{lifetime} h={h} k={k} a={a}: got {got:.9}, want {want:.9}" + ); + } + // The preimage bound caps the reported level, and 128 bits is what every + // parameter set in the report reaches. + assert_eq!(security_bits(64, 63, 14, 12, 16), 128.0); + assert_eq!(security_bits(30, 32, 10, 14, 16), 128.0); + // n = 32 lifts the cap, so the forgery term shows through. + assert!((security_bits(30, 32, 10, 14, 32) - 131.514752565).abs() < 1e-3); + // A lifetime far past the hypertree has nothing left to quantify. + assert!(forgery_exponent(40, 20, 10, 15).is_none()); + assert_eq!(security_bits(40, 20, 10, 15, 16), 0.0); +} + +#[test] +fn secure_k_form_an_up_set() { + // Relied on nowhere in the search, which tests every k, but it is the + // property that makes "the smallest secure k" a meaningful phrase at all. + for (h, a) in [(20u32, 10u64), (24, 12), (30, 14)] { + let flags: Vec = (1..=32) + .map(|k| security_bits(20, h, k, a, 16) >= LEVEL1_BITS) + .collect(); + let mut sorted = flags.clone(); + sorted.sort_unstable(); + assert_eq!(flags, sorted, "secure k are an up-set at h={h} a={a}"); + } +} + +#[test] +fn half_top_cache_is_a_saving_and_reduces_to_the_full_tree() { + let p = params(Scheme::WcFc, 40, 5, 14, 11, 256, true); + let c = costs(p, None).unwrap(); + assert!(c.sign_cached.hashes < c.sign.hashes); + // caching at the leaves is caching the whole tree: nothing left to rebuild + let whole = Params { + cache_height: Some(0), + ..p + }; + assert!(costs(whole, None).unwrap().sign_cached.hashes < c.sign_cached.hashes); + // caching only the root is caching nothing + let none = Params { + cache_height: Some(p.h_prime()), + ..p + }; + assert_eq!(costs(none, None).unwrap().sign_cached.hashes, c.sign.hashes); +} + +fn budgets(lifetime: u32, keygen: u64, sign: u64, cached: u64, size: u64) -> Budgets { + Budgets { + lifetime, + max_keygen: keygen, + max_sign: sign, + max_sign_cached: cached, + max_size: size, + security: LEVEL1_BITS, + unit: Unit::Hashes, + } +} + +#[test] +fn search_agrees_with_a_naive_oracle() { + // A grid small enough to sweep with nothing skipped at all. + let b = budgets(20, 3_000_000, 10_000_000, 10_000_000, 4_000); + let g = Grid { + schemes: vec![Scheme::Wc, Scheme::WcFc], + h_min: 20, + h_max: 20, + a_min: 14, + a_max: 16, + k_max: 14, + chain_bits: vec![4], + max_dropped: 1, + ..Default::default() + }; + let mut st = Stats::default(); + let found = search(&b, &g, &mut st); + let oracle = naive_search(&b, &g); + assert!(!found.is_empty() && !oracle.is_empty()); + assert_eq!( + found[0].costs.verify.hashes, oracle[0].costs.verify.hashes, + "the oracle finds the same optimum" + ); + assert_eq!(found[0].key(), oracle[0].key(), "and the same winner"); + // Every parameter tuple the oracle found feasible is in the search's output, + // with the same best verification cost for that tuple. + let mut want: std::collections::HashMap<_, u64> = Default::default(); + for c in &oracle { + let e = want.entry(c.key()).or_insert(u64::MAX); + *e = (*e).min(c.costs.verify.hashes); + } + let got: std::collections::HashMap<_, u64> = found.iter().map(|c| (c.key(), c.costs.verify.hashes)).collect(); + assert_eq!(got, want, "the search and the oracle agree tuple by tuple"); +} + +#[test] +fn search_recovers_the_reports_bold_row() { + // Budgets near the report's 2^40 numbers, on its grid (w in {16, 256}, no + // chain dropping): the search should land on h=40 d=5 a=14 k=11 w=256 and + // then spend what is left of the signing budget raising the target sum. + let b = budgets(40, 1_100_000, 6_000_000, 6_000_000, 4_400); + let g = Grid { + chain_bits: vec![4, 8], + max_dropped: 0, + ..Default::default() + }; + let mut st = Stats::default(); + let found = search(&b, &g, &mut st); + let best = &found[0]; + assert_eq!( + ( + best.params.scheme, + best.params.h, + best.params.d, + best.params.a, + best.params.k, + best.params.w + ), + (Scheme::WcFc, 40, 5, 14, 11, 256) + ); + assert!( + best.costs.swn.unwrap() > 2040, + "the report's row grinds less than the budget allows" + ); + assert!(best.costs.verify.hashes < 10402, "and verifies faster than it does"); +} + +#[test] +fn skeleton_rejects_trees_that_do_not_fit_a_u64() { + // 2^h' leaves has to be countable: without this the shift masks and a + // 2^64-leaf tree reports the cost of a one-leaf tree. + let p = params(Scheme::WcFc, 64, 1, 14, 11, 256, true); + assert!(Skeleton::new(p).is_none()); + assert!(Skeleton::new(Params { h: 63, ..p }).is_some()); + assert!(Skeleton::new(Params { a: 64, ..p }).is_none()); + // and the cost really does scale with the tree, so nothing wraps below that + let small = costs(Params { h: 40, d: 8, ..p }, None).unwrap(); + let large = costs(Params { h: 48, d: 8, ..p }, None).unwrap(); + // twice the leaves is twice the work plus the node joining the two halves + assert_eq!(large.keygen.hashes, small.keygen.hashes * 2 + 1); +} + +#[test] +fn skeleton_rejects_inconsistent_parameters() { + let ok = params(Scheme::WcFc, 40, 5, 14, 11, 256, true); + assert!(Skeleton::new(ok).is_some()); + assert!(Skeleton::new(Params { d: 3, ..ok }).is_none(), "d must divide h"); + assert!( + Skeleton::new(Params { w: 24, ..ok }).is_none(), + "w must be a power of two" + ); + assert!(Skeleton::new(Params { k: 1, ..ok }).is_none(), "FORS+C signs k-1 trees"); + assert!( + Skeleton::new(Params { + scheme: Scheme::Spx, + dropped_chains: 1, + ..ok + }) + .is_none(), + "WOTS-TW has no counter" + ); + assert!( + Skeleton::new(Params { + cache_height: Some(99), + ..ok + }) + .is_none(), + "the cache sits inside the top tree" + ); +} From 1975d054335a18b2063053b08670cd7e9f920bf5 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 12:57:15 +0200 Subject: [PATCH 04/31] doc/sphincs: one Merkle height per layer, top tree free to be taller The hypertree no longer splits h uniformly. The top tree takes h_top and the layers below divide what is left as evenly as it goes, so d need not divide h. This pays because of an asymmetry the uniform split hides. The signature carries h authentication nodes and the verifier walks them however the layers divide h, so size and verification depend only on (h, d), not on the split. Keygen depends only on h_top, and signing sums 2^height over the layers. Only the top tree is cacheable, and it pays sqrt there. So height moved into the top layer is free on the objective, costs keygen and vanilla signing, and buys cached signing: at h=40, d=5, h_top=15 against the uniform 8 leaves the signature and its 10402-hash verification untouched while cached signing goes 4.79M -> 2.36M hashes, keygen 1.05M -> 134M once. Dropping d | h pays on its own. On the report's 2^40 budgets and grid the search now prefers h=39, d=5 with heights 8+8+8+8+7 over h=40, d=5: 16 bytes and one auth node cheaper, which buys enough grinding budget to raise the target sum, and verification falls 6190 -> 5660 hashes. On the 2^30 query the winner moves to h=34, d=3 with heights 12+11+11 and 674 verification hashes against 751, at 3916 bytes against 3996. Only "top one, then the rest equal" is enumerated, which is not a restriction: for a fixed (h, d, h_top) that shape matches every other on size, verification and keygen, and beats them on both signing costs, because a sum of 2^height at fixed total is smallest when the heights are equal. So (h, d, h_top) covers the cost-optimal representative of every layer profile. The new axis costs nothing in the inner loop. Which h_top is best does not depend on (a, k) or on the target sum, because both signing budgets take the (a, k) part as the same additive offset, so the profiles are ranked once per (h, d) by the grinding they leave room for and that ranking holds throughout. Params splits into Layers (the hypertree) and Skeleton (the FORS side, the size and the verifier), which is what makes the two independent in code as well. The realistic 2^30 query is 2.0s, down from 4.3s, over a grid 5.6x larger, because ranking profiles by keygen rejects (h, d) pairs sooner. Two things found while wiring it up: the row list needed a bound, since budgets loose enough to admit 12M rows would hold 3GB to print a dozen (now 121MB, with the stats line reporting what was dropped as worse than everything kept), and the dedup map was dead weight because each parameter tuple is reached exactly once. The naive oracle sweeps h_top too, so the goldens still diff the search against something that skips nothing. Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/README.md | 3 + doc/sphincs/src/lib.rs | 9 + doc/sphincs/src/main.rs | 11 +- doc/sphincs/src/params.rs | 327 ++++++++++++++++++++++++----------- doc/sphincs/src/report.rs | 6 +- doc/sphincs/src/search.rs | 261 ++++++++++++++++++---------- doc/sphincs/tests/goldens.rs | 93 +++++++--- 7 files changed, 493 insertions(+), 217 deletions(-) diff --git a/doc/sphincs/README.md b/doc/sphincs/README.md index 3528a6525..321161099 100644 --- a/doc/sphincs/README.md +++ b/doc/sphincs/README.md @@ -7,8 +7,11 @@ Its own cargo workspace, no dependencies, not a member of the repo's workspace. ```sh cd doc/sphincs cargo run --release -- params --scheme W+C_F+C --lifetime 40 --height 40 --layers 5 -a 14 -k 11 -w 256 +cargo run --release -- params --top-height 15 # a taller top XMSS tree, cheaper to sign with the cache cargo run --release -- search --lifetime 30 --max-keygen 2e6 --max-sign 6e6 --max-sign-cached 4e6 --max-size 4000 cargo test --release # goldens: upstream sage fixtures, the report's tables, a naive search oracle ``` `cargo run --release --` with no subcommand prints the full option list. + +The search is exhaustive over hardcoded ranges and prints a warning when its answer leans on the top of one of them. Budgets loose enough that nothing prunes can take a couple of minutes and are reported by `--stats`; realistic ones finish in seconds. diff --git a/doc/sphincs/src/lib.rs b/doc/sphincs/src/lib.rs index 298521b4c..3fd31eb76 100644 --- a/doc/sphincs/src/lib.rs +++ b/doc/sphincs/src/lib.rs @@ -19,6 +19,15 @@ //! cut integral; dropping further chains pins `log2(w)` more bits each, every //! pinned bit doubling the expected grinding. //! +//! The hypertree's height is split per layer, not `h/d` on every layer: the top +//! tree gets `h_top` and the rest divide what is left as evenly as it goes, so +//! `d` need not divide `h`. That matters because the signature carries `h` +//! authentication nodes and the verifier walks them however the layers divide +//! `h`: size and verification depend only on `(h, d)`, while only the top tree +//! is cacheable. A taller top layer is therefore free on both, costs keygen and +//! vanilla signing, and cuts cached signing, which at `h = 40, d = 5` is 2x for +//! `h_top = 15` against the uniform 8. See [`params::Profile`]. +//! //! For one parameter set [`params::costs`] reports the signature size and the //! keygen, signing and verification cost, and [`security::security_bits`] the //! classical security. Signing comes in two flavours: vanilla, and with the top diff --git a/doc/sphincs/src/main.rs b/doc/sphincs/src/main.rs index f1e75bdae..472a80403 100644 --- a/doc/sphincs/src/main.rs +++ b/doc/sphincs/src/main.rs @@ -4,7 +4,8 @@ use sphincs_params::cost::{Convention, SCHEMES, Scheme}; use sphincs_params::params::{Params, costs}; use sphincs_params::report::{report, table, utilization}; use sphincs_params::search::{ - A_MAX, Budgets, CHAIN_BITS_MAX, Candidate, DROPPED_MAX, Grid, H_MAX, K_MAX, LEVEL1_BITS, Stats, Unit, edges, search, + A_MAX, Budgets, CHAIN_BITS_MAX, Candidate, D_MAX, DROPPED_MAX, Grid, H_MAX, K_MAX, LEVEL1_BITS, Stats, Unit, edges, + search, }; const USAGE: &str = "\ @@ -16,6 +17,7 @@ params options (defaults are the report's bold 2^40 row): --lifetime L log2 of signatures per key [40] --height h hypertree height [40] --layers d hypertree layers [5] + --top-height H height of the top XMSS tree [h/d, so every layer equal] -a A log2 leaves per FORS tree [14] -k K FORS trees [11] -w W Winternitz parameter [256] @@ -38,7 +40,7 @@ search options (all five budgets required): --scheme S restrict the schemes searched (repeatable) --chain-bits B restrict log2(w) searched (repeatable) --top N rows to print [15] - --h-max / --a-max / --k-max / --max-dropped widen or narrow a range + --h-max / --d-max / --a-max / --k-max / --max-dropped widen or narrow a range --stats report how much of the space was visited -n N hash output in bytes [16] "; @@ -156,6 +158,10 @@ fn cmd_params(argv: &[String]) -> Result { }, h: args.u64("--height", 40)?, d: args.u64("--layers", 5)?, + h_top: args + .get("--top-height") + .map(|_| args.u64("--top-height", 0)) + .transpose()?, a: args.u64("-a", 14)?, k: args.u64("-k", 11)?, w, @@ -207,6 +213,7 @@ fn cmd_search(argv: &[String]) -> Result { h_max: args.u64("--h-max", H_MAX)?, a_max: args.u64("--a-max", A_MAX)?, k_max: args.u64("--k-max", K_MAX)?, + d_max: args.u64("--d-max", D_MAX)?, chain_bits: if bits.is_empty() { (1..=CHAIN_BITS_MAX).collect() } else { diff --git a/doc/sphincs/src/params.rs b/doc/sphincs/src/params.rs index 6ccce111c..876cedf4a 100644 --- a/doc/sphincs/src/params.rs +++ b/doc/sphincs/src/params.rs @@ -6,10 +6,13 @@ use crate::cost::{COUNTER_BYTES, Convention, Cost, Encoding, NuTable, Scheme}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Params { pub scheme: Scheme, - /// Hypertree height, a multiple of `d`. + /// Total hypertree height, the sum of the layer heights. pub h: u64, - /// Hypertree layers, so each XMSS tree has height `h' = h/d`. + /// Hypertree layers. pub d: u64, + /// Height of the top XMSS tree. `None` spreads `h` as evenly as it goes, + /// which for `d | h` is the classic `h' = h/d` on every layer. + pub h_top: Option, /// FORS trees have `2^a` leaves. pub a: u64, /// Number of FORS trees. @@ -20,16 +23,106 @@ pub struct Params { pub n: u64, /// Chains dropped beyond the digest bits that have to be pinned anyway. pub dropped_chains: u64, - /// Height above the leaves of the cached top-tree level; `None` is `h'/2`. + /// Height above the leaves of the cached top-tree level; `None` is half of it. pub cache_height: Option, /// Cache one level rather than it and everything above. pub cache_level_only: bool, pub convention: Convention, } +/// The height of every XMSS tree in the hypertree. +/// +/// Only the top tree is worth caching, and the layers below it are otherwise +/// interchangeable, so the only profile shape worth considering is "the top one, +/// then the rest as equal as they go". For a fixed `(h, d, h_top)` that shape is +/// no worse than any other on every cost: size and verification depend only on +/// `(h, d)`, keygen only on `h_top`, and signing sums `2^height` over the +/// layers, which for a fixed total is smallest when they are equal. So +/// enumerating `(h, d, h_top)` covers the cost-optimal representative of every +/// layer profile, and the `d - 1` lower heights differ by at most one. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Profile { + pub h_top: u64, + /// The taller of the two lower heights, and how many layers have it. + pub tall: u64, + pub n_tall: u64, + /// The shorter of the two, and how many layers have it. + pub short: u64, + pub n_short: u64, +} + +impl Profile { + /// `None` if some layer would be empty, or too tall for `2^height` to count. + pub fn new(h: u64, d: u64, h_top: Option) -> Option { + if d == 0 || h == 0 { + return None; + } + let h_top = h_top.unwrap_or(h / d).max(1); + let lower_total = h.checked_sub(h_top)?; + let m = d - 1; + if m == 0 { + if lower_total != 0 { + return None; // one layer has to be the whole height + } + return Self::checked(Self { + h_top, + tall: 0, + n_tall: 0, + short: 0, + n_short: 0, + }); + } + if lower_total < m { + return None; // every layer needs at least one level + } + let (q, r) = (lower_total / m, lower_total % m); + Self::checked(Self { + h_top, + tall: q + 1, + n_tall: r, + short: q, + n_short: m - r, + }) + } + + /// A tree of `2^63` leaves is already past any budget a `u64` can hold, and + /// `2^64` does not fit the count at all. + fn checked(self) -> Option { + (self.h_top <= 63 && self.tall <= 63).then_some(self) + } + + pub fn total(&self) -> u64 { + self.h_top + self.tall * self.n_tall + self.short * self.n_short + } + + pub fn layers(&self) -> u64 { + 1 + self.n_tall + self.n_short + } + + /// Is every layer the same height? + pub fn uniform(&self) -> bool { + (self.n_tall == 0 || self.tall == self.h_top) && (self.n_short == 0 || self.short == self.h_top) + } +} + +impl std::fmt::Display for Profile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.uniform() { + return write!(f, "{} x {}", self.layers(), self.h_top); + } + write!(f, "top {}", self.h_top)?; + for (height, count) in [(self.tall, self.n_tall), (self.short, self.n_short)] { + if count > 0 { + write!(f, " + {count} x {height}")?; + } + } + Ok(()) + } +} + impl Params { - pub fn h_prime(&self) -> u64 { - self.h / self.d + pub fn profile(&self) -> Option { + Profile::new(self.h, self.d, self.h_top) } pub fn encoding(&self) -> Option { @@ -50,9 +143,7 @@ impl Params { fn wots_tw_len2(&self, l1: u64) -> u64 { let bits = self.encoding().expect("checked by the caller").chain_bits; - let c = l1 * (self.w - 1); - // floor(log2(c) / log2(w)) + 1, integer-only - (c.ilog2() as u64) / bits + 1 + (l1 * (self.w - 1)).ilog2() as u64 / bits + 1 } /// Verifier chain steps for WOTS-TW when every message digit is zero. @@ -62,8 +153,7 @@ impl Params { let l2 = self.wots_tw_len2(l1); let c = l1 * (self.w - 1); let digit_sum: u64 = { - let mut rem = c; - let mut sum = 0; + let (mut rem, mut sum) = (c, 0); while rem > 0 { sum += rem % self.w; rem /= self.w; @@ -72,13 +162,89 @@ impl Params { }; l1 * (self.w - 1) + l2 * (self.w - 1) - digit_sum } + + /// One WOTS key pair, plus the compression of its `l` chain ends into a leaf. + fn wots_leaf(&self, l: u64) -> Cost { + let cv = self.convention; + Cost::new( + l + l * (self.w - 1) + 1, + l * cv.prf() + l * (self.w - 1) * cv.th1() + cv.th(l, self.n), + ) + } +} + +/// The hypertree side: what the layer heights cost, at any `(a, k)` and any +/// target sum. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Layers { + pub profile: Profile, + /// Generating the top tree, which is all key generation does. + pub keygen: Cost, + /// Regrowing every layer, which is what signing does. + pub trees: Cost, + /// The same with the top tree's half top already in state. + pub trees_cached: Cost, + pub cache_bytes: u64, + pub cache_depth: u64, +} + +impl Layers { + pub fn new(p: &Params) -> Option { + let profile = p.profile()?; + let l = p.chains()?; + let leaf = p.wots_leaf(l); + let cv = p.convention; + let tree = |height: u64| { + let leaves = 1u64 << height; + leaf * leaves + Cost::new(leaves - 1, (leaves - 1) * cv.th2()) + }; + let top = tree(profile.h_top); + let lower = tree(profile.tall) * profile.n_tall + tree(profile.short) * profile.n_short; + + // Only the top tree is worth caching: it is the same for every + // signature, while the trees below it are picked by the (pseudorandom) + // index. Its auth path splits at the cached level: below, rebuild the + // 2^c-leaf subtree the signing leaf sits in; above, the nodes are + // already in state. Rebuilt leaves are charged a full WOTS public key, + // as everywhere else here. + // + // A BDS-style traversal would amortize a tree to h' leaves per + // signature with O(h') state, but it only works walking the leaves in + // order. SPHINCS+ picks its index by hashing the message, so + // consecutive signatures land on unrelated leaves and nothing + // amortizes; an index-independent cache like this one is what is left, + // hence sqrt rather than h'. + let c = p.cache_height.unwrap_or(profile.h_top / 2); + if c > profile.h_top { + return None; + } + let stored_level = 1u64 << (profile.h_top - c); + let mut cached = tree(c); + let cache_bytes; + if p.cache_level_only { + cached = cached + Cost::new(stored_level - 1, (stored_level - 1) * cv.th2()); + cache_bytes = stored_level * p.n; + } else { + cache_bytes = (2 * stored_level - 1) * p.n; + } + + Some(Self { + profile, + keygen: top, + trees: top + lower, + trees_cached: cached + lower, + cache_bytes, + cache_depth: profile.h_top - c, + }) + } } -/// Everything about a parameter set that does not depend on the target sum. +/// Everything else: the FORS side, the signature size and the verifier, none of +/// which depends on how the hypertree's height is split between its layers. /// -/// Split this way so a search can reject a candidate on size, keygen, or the -/// least grinding any target sum could ask for, without pretending to know -/// where the good target sums are. +/// Split from [`Layers`] and from the target sum so that a search can reject a +/// candidate on size, or on the least any layer profile and any target sum could +/// cost, without pretending to know which of those are good. #[derive(Clone, Debug)] pub struct Skeleton { pub params: Params, @@ -88,16 +254,12 @@ pub struct Skeleton { pub max_swn: u64, pub default_swn: u64, pub sig_bytes: u64, - pub keygen: Cost, - pub cache_bytes: u64, - pub cache_depth: u64, pub fors_c_grinding: u64, - /// Signing, less the WOTS+C counter grinding. - sign_base: Cost, - /// The same with the top tree's cached part rebuilt instead of the whole tree. - sign_cached_base: Cost, + /// The `(a, k)` part of signing: growing the FORS trees, and any grinding + /// FORS+C does. Common to both signing costs, cached or not. + pub fors_part: Cost, /// One counter trial, at one layer. - grind_step: Cost, + pub grind_step: Cost, /// Verification, less the chain walk that the target sum shortens. verify_base: Cost, /// One chain step, across all layers. @@ -107,43 +269,31 @@ pub struct Skeleton { } impl Skeleton { - /// `None` if the parameters are not self-consistent (`d` must divide `h`, - /// `w` must be a power of two, FORS+C needs `k >= 2`). + /// `None` if the parameters are not self-consistent: `w` must be a power of + /// two, FORS+C needs `k >= 2`, WOTS-TW cannot drop chains, and `2^a` has to + /// be countable. pub fn new(p: Params) -> Option { - if p.h == 0 || p.d == 0 || !p.h.is_multiple_of(p.d) || p.k < 1 || p.a < 1 { + if p.k < 1 || p.a < 1 || p.a > 63 || p.d == 0 { return None; } if p.scheme.fors_c() && p.k < 2 { return None; } if !p.scheme.wots_c() && p.dropped_chains > 0 { - return None; // WOTS-TW has no counter to grind - } - // A tree of 2^64 leaves does not fit a u64 count, and could not be - // generated under any budget expressible in one either: keygen alone is - // at least 2^h' hashes, and one FORS tree at least 2^a. - if p.h_prime() > 63 || p.a > 63 { return None; } let enc = p.encoding()?; let l = p.chains()?; - let (n, cv, d, hp) = (p.n, p.convention, p.d, p.h_prime()); + let profile = p.profile()?; + let (n, cv, d) = (p.n, p.convention, p.d); let trees = p.scheme.trees(p.k); let t = 1u64 << p.a; - // ---- size ---------------------------------------------------------- - let layer = hp * n + l * n + if p.scheme.wots_c() { COUNTER_BYTES } else { 0 }; - let sig_bytes = n + d * layer + trees * n + trees * p.a * n; + // The signature carries the whole authentication path, h nodes however + // the layers divide it, plus one WOTS signature per layer. + let layer = l * n + if p.scheme.wots_c() { COUNTER_BYTES } else { 0 }; + let sig_bytes = n + profile.total() * n + d * layer + trees * n + trees * p.a * n; - // ---- one WOTS key pair, and one XMSS tree over 2^x of them --------- - let leaf = Cost::new( - l + l * (p.w - 1) + 1, - l * cv.prf() + l * (p.w - 1) * cv.th1() + cv.th(l, n), - ); - let tree = |leaves: u64| leaf * leaves + Cost::new(leaves - 1, (leaves - 1) * cv.th2()); - let top_tree = tree(1 << hp); - - // ---- signing ------------------------------------------------------- let msg_hash = Cost::new(2, cv.hmsg() + cv.prfmsg()); let fors_build = Cost::new( trees * t + trees * t + trees * (t - 1) + 1, @@ -152,49 +302,18 @@ impl Skeleton { // FORS+C grinds the digest until its last a bits vanish, so the last // FORS tree always opens leaf 0 and needs no authentication path. let fors_grind = if p.scheme.fors_c() { msg_hash * t } else { msg_hash }; - let sign_base = top_tree * d + fors_build + fors_grind; - - // ---- signing with the top tree's half top cached ------------------- - // Only the top tree is worth caching: it is the same for every - // signature, while the trees below it are picked by the (pseudorandom) - // index. Its auth path splits at the cached level: below, rebuild the - // 2^c-leaf subtree the signing leaf sits in; above, the nodes are - // already in state. Rebuilt leaves are charged a full WOTS public key, - // as everywhere else here. - // - // A BDS-style traversal would amortize a tree to h' leaves per - // signature with O(h') state, but it only works walking the leaves in - // order. SPHINCS+ picks its index by hashing the message, so - // consecutive signatures land on unrelated leaves and nothing - // amortizes; an index-independent cache like this one is what is left, - // hence sqrt rather than h'. - let c = p.cache_height.unwrap_or(hp / 2); - if c > hp { - return None; - } - let stored_level = 1u64 << (hp - c); - let mut cached_tree = tree(1 << c); - let cache_bytes; - if p.cache_level_only { - cached_tree = cached_tree + Cost::new(stored_level - 1, (stored_level - 1) * cv.th2()); - cache_bytes = stored_level * n; - } else { - cache_bytes = (2 * stored_level - 1) * n; - } - let sign_cached_base = sign_base - top_tree + cached_tree; - // ---- verification -------------------------------------------------- let fors_verify = Cost::new( trees + trees * p.a + 1, trees * cv.th1() + trees * p.a * cv.th2() + cv.th(trees, n), ); - let auth = Cost::new(p.h, p.h * cv.th2()); + let auth = Cost::new(profile.total(), profile.total() * cv.th2()); let mut verify_base = Cost::new(1, cv.hmsg()) + fors_verify + auth; let mut verify_step = Cost::default(); let mut verify_worst_extra = Cost::default(); if p.scheme.wots_c() { // the digits sum to S_wn, so the remaining chain steps are fixed at - // (w-1)*l - S_wn, and the counter has to be hashed once per layer + // (w-1)*l - S_wn, and the counter is hashed once per layer verify_base = verify_base + Cost::new(2, cv.th1c() + cv.th(l, n)) * d; verify_step = Cost::new(1, cv.th1()) * d; } else { @@ -212,13 +331,9 @@ impl Skeleton { max_swn: (p.w - 1) * l, default_swn: if p.scheme.wots_c() { enc.default_swn() } else { 0 }, sig_bytes, - keygen: top_tree, - cache_bytes, - cache_depth: hp - c, fors_c_grinding: if p.scheme.fors_c() { fors_grind.hashes } else { 0 }, - sign_base, - sign_cached_base, - grind_step: Cost::new(1, p.convention.th1c()), + fors_part: fors_build + fors_grind, + grind_step: Cost::new(1, cv.th1c()), verify_base, verify_step, verify_worst_extra, @@ -226,13 +341,18 @@ impl Skeleton { } /// Expected signing cost when each layer grinds `trials` counters. - pub fn sign(&self, trials: u64) -> Cost { - self.sign_base + self.grind_step * trials.saturating_mul(self.params.d) + pub fn sign(&self, lay: &Layers, trials: u64) -> Cost { + lay.trees + self.fors_part + self.grinding(trials) } /// The same with the top tree's half top cached. - pub fn sign_cached(&self, trials: u64) -> Cost { - self.sign_cached_base + self.grind_step * trials.saturating_mul(self.params.d) + pub fn sign_cached(&self, lay: &Layers, trials: u64) -> Cost { + lay.trees_cached + self.fors_part + self.grinding(trials) + } + + /// What `trials` counter values per layer cost across the hypertree. + pub fn grinding(&self, trials: u64) -> Cost { + self.grind_step * trials.saturating_mul(self.params.d) } /// Verification at this target sum. `swn` is ignored for WOTS-TW. @@ -245,36 +365,30 @@ impl Skeleton { self.verify(swn) + self.verify_worst_extra } - /// The full picture at one target sum. - pub fn finish(&self, swn: u64, trials: u64) -> Costs { + /// The full picture at one layer profile and one target sum. + pub fn finish(&self, lay: &Layers, swn: u64, trials: u64) -> Costs { Costs { l: self.l, chain_bits: self.chain_bits, pinned_bits: self.pinned_bits, dropped_chains: self.params.dropped_chains, swn: self.params.scheme.wots_c().then_some(swn), + profile: lay.profile, sig_bytes: self.sig_bytes, - keygen: self.keygen, - sign: self.sign(trials), - sign_cached: self.sign_cached(trials), + keygen: lay.keygen, + sign: self.sign(lay, trials), + sign_cached: self.sign_cached(lay, trials), verify: self.verify(swn), verify_worst: self.verify_worst(swn), wots_c_grinding: trials.saturating_mul(self.params.d), fors_c_grinding: self.fors_c_grinding, - cache_depth: self.cache_depth, - cache_bytes: self.cache_bytes, + cache_depth: lay.cache_depth, + cache_bytes: lay.cache_bytes, } } - - /// The full picture at the mean target sum, the report's default. - pub fn at_default_swn(&self, nu: Option<&NuTable>) -> Costs { - let swn = self.default_swn; - let trials = nu.map_or(0, |t| t.trials(swn)); - self.finish(swn, trials) - } } -/// Every cost of a parameter set at one target sum. +/// Every cost of a parameter set at one layer profile and one target sum. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Costs { pub l: u64, @@ -282,6 +396,7 @@ pub struct Costs { pub pinned_bits: u64, pub dropped_chains: u64, pub swn: Option, + pub profile: Profile, pub sig_bytes: u64, pub keygen: Cost, pub sign: Cost, @@ -303,13 +418,15 @@ impl Costs { /// Costs of one parameter set, building the digit-sum table as needed. /// /// Convenient for a single evaluation; a search should hold the [`NuTable`] and -/// drive [`Skeleton`] itself, since the table depends only on `(l, w)`. +/// drive [`Skeleton`] and [`Layers`] itself, since the table depends only on +/// `(l, w)` and the layer costs only on the profile. pub fn costs(p: Params, swn: Option) -> Option { let sk = Skeleton::new(p)?; + let lay = Layers::new(&p)?; if !p.scheme.wots_c() { - return Some(sk.finish(0, 0)); + return Some(sk.finish(&lay, 0, 0)); } let table = NuTable::new(sk.l, p.w, (8 * p.n) as u32); let swn = swn.unwrap_or(sk.default_swn); - Some(sk.finish(swn, table.trials(swn))) + Some(sk.finish(&lay, swn, table.trials(swn))) } diff --git a/doc/sphincs/src/report.rs b/doc/sphincs/src/report.rs index 14933841a..a47a5e7b2 100644 --- a/doc/sphincs/src/report.rs +++ b/doc/sphincs/src/report.rs @@ -54,7 +54,7 @@ pub fn report(p: &Params, c: &Costs, lifetime: u32) -> String { p.scheme.label(), 8 * p.n ), - format!("(h, d, h') ({}, {}, {})", p.h, p.d, p.h_prime()), + format!("(h, d) ({}, {}) layer heights {}", p.h, p.d, c.profile), format!( "(a, k) ({}, {}){}", p.a, @@ -111,7 +111,7 @@ const COLUMNS: [(&str, usize); 16] = [ ("scheme", 9), ("h", 4), ("d", 3), - ("h'", 4), + ("ht", 4), ("a", 3), ("k", 3), ("cb", 3), @@ -132,7 +132,7 @@ fn cells(b: &Budgets, c: &Candidate) -> Vec { p.scheme.label().to_string(), p.h.to_string(), p.d.to_string(), - p.h_prime().to_string(), + x.profile.h_top.to_string(), p.a.to_string(), p.k.to_string(), x.chain_bits.to_string(), diff --git a/doc/sphincs/src/search.rs b/doc/sphincs/src/search.rs index 7f035d7f0..54fdd6a75 100644 --- a/doc/sphincs/src/search.rs +++ b/doc/sphincs/src/search.rs @@ -1,24 +1,34 @@ //! Exhaustive search for the parameter set with the cheapest verification. //! -//! Every `(scheme, h, d | h, chain_bits, dropped_chains, a, k, S_wn)` point that -//! meets the budgets is costed and compared. Nothing is chosen by an optimality +//! Every `(scheme, h, d, h_top, chain_bits, dropped_chains, a, k, S_wn)` point +//! that meets the budgets is costed and compared. Nothing is chosen by an optimality //! argument, and nothing is skipped by a monotonicity one: the three tests that //! run before the `S_wn` scan reject only points that no `S_wn` could rescue, //! because size and keygen do not depend on `S_wn` at all, and the least //! grinding any `S_wn` can ask for is read off the digit-sum table rather than //! assumed to sit anywhere in particular. //! +//! The layer heights are `(h, d, h_top)`: the top tree gets `h_top`, the rest +//! divide what is left as evenly as it goes. [`crate::params::Profile`] argues +//! why that shape covers the cost-optimal representative of every profile, so +//! `d` no longer has to divide `h`. Which `h_top` is best does not depend on +//! `(a, k)` or on the target sum, because size and verification do not depend on +//! `h_top` at all and both signing budgets take the `(a, k)` part as the same +//! additive offset; so the profiles are ranked once per `(h, d)`, by how much +//! grinding they leave room for, and that ranking then holds for every `(a, k)`. +//! //! What is assumed is the searched range of each parameter, hardcoded below. //! When a result comes out at the top of one of those ranges the range itself //! may be what is limiting it, so [`edges`] reports that and names the constant //! to raise. Ranges the budgets or the structure already close (`d` over the -//! divisors of `h`, `S_wn` over the digit sums a code of `l` chains can reach) +//! layer heights that do not add up to `h`, `S_wn` over the digit sums a code of +//! `l` chains can reach) //! need no such warning and get none. use std::time::Instant; use crate::cost::{Cost, NuTable, SCHEMES, Scheme}; -use crate::params::{Costs, Params, Skeleton}; +use crate::params::{Costs, Layers, Params, Skeleton}; use crate::security::SecurityTable; /// Hardcoded search ranges, wide enough that the budgets are normally what @@ -31,6 +41,8 @@ pub const H_MAX: u64 = 96; pub const A_MAX: u64 = 32; /// Number of FORS trees. pub const K_MAX: u64 = 64; +/// Hypertree layers. +pub const D_MAX: u64 = 32; /// log2(w), so w up to 4096. pub const CHAIN_BITS_MAX: u64 = 12; /// WOTS+C chains dropped beyond the minimal bit pinning. @@ -96,6 +108,7 @@ pub struct Grid { pub a_min: u64, pub a_max: u64, pub k_max: u64, + pub d_max: u64, pub chain_bits: Vec, pub max_dropped: u64, pub cache_level_only: bool, @@ -111,6 +124,7 @@ impl Default for Grid { a_min: 1, a_max: A_MAX, k_max: K_MAX, + d_max: D_MAX, chain_bits: (1..=CHAIN_BITS_MAX).collect(), max_dropped: DROPPED_MAX, cache_level_only: false, @@ -147,6 +161,12 @@ pub struct Stats { /// Points meeting every budget. pub feasible: u64, pub skeletons: u64, + /// Layer profiles kept after the keygen budget. + pub profiles: u64, + /// Parameter tuples that came out feasible, one row each. + pub rows: u64, + /// Rows dropped as worse than everything kept. + pub rows_dropped: u64, pub seconds: f64, } @@ -156,7 +176,8 @@ impl std::fmt::Display for Stats { f, "grid {} (scheme, h, d, chain_bits, dropped) tuples, {} over keygen; \ then {} (a, k) pairs insecure, {} over size, {} over signing; \ - {} target-sum ranges swept, {} points feasible ({} parameter sets costed) in {:.1}s", + {} target-sum ranges swept, {} points feasible over {} rows ({} dropped as worse than everything kept); \ + {} layer profiles and {} parameter sets costed in {:.1}s", self.grid, self.keygen_pruned, self.insecure, @@ -164,24 +185,25 @@ impl std::fmt::Display for Stats { self.sign_pruned, self.swept, self.feasible, + self.rows, + self.rows_dropped, + self.profiles, self.skeletons, self.seconds ) } } -/// Identifies one parameter tuple, everything but the target sum. +/// Identifies one parameter tuple: everything but the layer profile and the +/// target sum, neither of which changes what it verifies at. pub type Key = (Scheme, u64, u64, u64, u64, u64, u64); -fn divisors(h: u64) -> Vec { - (1..=h).filter(|d| h.is_multiple_of(*d)).collect() -} - fn params(g: &Grid, scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, dropped: u64) -> Params { Params { scheme, h, d, + h_top: None, a, k, w, @@ -193,18 +215,58 @@ fn params(g: &Grid, scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, drop } } +/// The layer profiles worth trying for one `(h, d)`, and how much grinding the +/// best of them leaves room for. +/// +/// `slack` is `max over profiles of min(max_sign - trees, max_sign_cached - +/// trees_cached)`, in the budget's unit. Both signing costs take the `(a, k)` +/// part of signing as the same additive offset, so subtracting that offset from +/// `slack` gives the grinding budget of the best profile for any `(a, k)`, +/// without re-ranking the profiles per candidate. +struct Room { + profiles: Vec, + slack: u64, +} + +fn room(b: &Budgets, p: &Params) -> Option { + let mut profiles = Vec::new(); + let mut slack = 0; + // The top tree has 2^h_top leaves and every leaf costs at least one hash, + // so a top height past the keygen budget's log is out for any (a, k). + let ceiling = 64 - b.max_keygen.max(1).leading_zeros() as u64; + for h_top in 1..=(p.h + 1).saturating_sub(p.d).min(ceiling) { + let Some(lay) = Layers::new(&Params { + h_top: Some(h_top), + ..*p + }) else { + continue; + }; + if b.of(lay.keygen) > b.max_keygen { + continue; + } + let room = b + .max_sign + .saturating_sub(b.of(lay.trees)) + .min(b.max_sign_cached.saturating_sub(b.of(lay.trees_cached))); + slack = slack.max(room); + profiles.push(lay); + } + (!profiles.is_empty()).then_some(Room { profiles, slack }) +} + /// Every feasible parameter set, ordered by verification cost. /// /// One row per `(scheme, h, d, a, k, w, dropped_chains)`, carrying the best -/// target sum for that tuple. Rows are what gets printed; the comparison behind -/// each one saw every target sum. +/// target sum for that tuple and the layer profile that admitted it. Rows are +/// what gets printed; the comparison behind each one saw every target sum. pub fn search(b: &Budgets, g: &Grid, st: &mut Stats) -> Vec { let started = Instant::now(); let digest_bits = (8 * g.n) as u32; let mut sec = SecurityTable::new(b.lifetime, b.security, g.n, g.h_max as u32, g.k_max, g.a_max); + // Every (scheme, h, d, a, k, w, dropped) key is reached exactly once, so + // rows need no deduplication, only a bound: budgets loose enough to admit + // millions of them would otherwise be held in memory to print a dozen. let mut best: Vec = Vec::new(); - let mut index: std::collections::HashMap = Default::default(); - let all_divisors: Vec> = (0..=g.h_max).map(divisors).collect(); for &scheme in &g.schemes { for &bits in &g.chain_bits { @@ -231,25 +293,15 @@ pub fn search(b: &Budgets, g: &Grid, st: &mut Stats) -> Vec { let table = scheme.wots_c().then(|| NuTable::new(l, w, digest_bits)); let min_trials = table.as_ref().map_or(0, |t| t.min_trials()); for h in g.h_min..=g.h_max { - for &d in &all_divisors[h as usize] { + for d in 1..=h.min(g.d_max) { st.grid += 1; - // keygen is one top tree: no a, k or target sum in it - let kg = params( - g, - scheme, - h, - d, - g.a_min, - if scheme.fors_c() { 2 } else { 1 }, - w, - dropped, - ); - let Some(kg) = Skeleton::new(kg) else { continue }; - st.skeletons += 1; - if b.of(kg.keygen) > b.max_keygen { + // Layer profiles first: they need no a or k, and the + // keygen budget alone usually settles the question. + let Some(room) = room(b, ¶ms(g, scheme, h, d, g.a_min, 1, w, dropped)) else { st.keygen_pruned += 1; continue; - } + }; + st.profiles += room.profiles.len() as u64; for a in g.a_min..=g.a_max { for k in 1..=g.k_max { if !sec.is_secure(h as u32, k, a) { @@ -259,36 +311,32 @@ pub fn search(b: &Budgets, g: &Grid, st: &mut Stats) -> Vec { let p = params(g, scheme, h, d, a, k, w, dropped); let Some(sk) = Skeleton::new(p) else { continue }; st.skeletons += 1; - // size and keygen do not depend on the target - // sum, and no target sum grinds less than the - // table's cheapest, so these three reject only - // points that no target sum could rescue + // The signature grows with k, so once it is too + // big it stays too big. if sk.sig_bytes > b.max_size { st.size_pruned += 1; - continue; - } - if b.of(sk.sign(min_trials)) > b.max_sign - || b.of(sk.sign_cached(min_trials)) > b.max_sign_cached - { - st.sign_pruned += 1; - continue; + break; } + // What the best profile can still afford to + // grind, once this (a, k) has taken its share. + let per_trial = b.of(sk.grind_step) * d; + let max_trials = room.slack.saturating_sub(b.of(sk.fors_part)) / per_trial.max(1); let Some(table) = table.as_ref() else { - // WOTS-TW: no target sum to choose - let c = sk.finish(0, 0); - if b.fits(&c) { + // WOTS-TW: no counter, no target sum + if room.slack >= b.of(sk.fors_part) { st.feasible += 1; - record(&mut best, &mut index, Candidate { params: p, costs: c }, b); + record(&mut best, st, b, &sk, &room, 0, 0); } continue; }; + if max_trials < min_trials { + st.sign_pruned += 1; + continue; + } st.swept += 1; let mut winner: Option<(u64, u64)> = None; for swn in 0..=sk.max_swn { - let trials = table.trials(swn); - if b.of(sk.sign(trials)) > b.max_sign - || b.of(sk.sign_cached(trials)) > b.max_sign_cached - { + if table.trials(swn) > max_trials { continue; } st.feasible += 1; @@ -298,8 +346,7 @@ pub fn search(b: &Budgets, g: &Grid, st: &mut Stats) -> Vec { } } if let Some((swn, _)) = winner { - let c = sk.finish(swn, table.trials(swn)); - record(&mut best, &mut index, Candidate { params: p, costs: c }, b); + record(&mut best, st, b, &sk, &room, swn, table.trials(swn)); } } } @@ -310,18 +357,44 @@ pub fn search(b: &Budgets, g: &Grid, st: &mut Stats) -> Vec { } st.seconds = started.elapsed().as_secs_f64(); - best.sort_by_key(|c| (b.of(c.costs.verify), c.costs.sig_bytes, b.of(c.costs.sign))); + sort_rows(&mut best, b); best } -fn record(best: &mut Vec, index: &mut std::collections::HashMap, cand: Candidate, b: &Budgets) { - match index.get(&cand.key()) { - Some(&i) if b.of(best[i].costs.verify) <= b.of(cand.costs.verify) => {} - Some(&i) => best[i] = cand, - None => { - index.insert(cand.key(), best.len()); - best.push(cand); - } +/// Rows kept before the list is trimmed back to `ROWS_KEPT`. The optimum is +/// unaffected: what gets dropped is worse than everything retained. +const ROWS_CAP: usize = 1 << 18; +const ROWS_KEPT: usize = 1 << 17; + +fn sort_rows(rows: &mut [Candidate], b: &Budgets) { + rows.sort_by_key(|c| (b.of(c.costs.verify), c.costs.sig_bytes, b.of(c.costs.sign))); +} + +/// Record this parameter tuple on the cheapest layer profile that fits: they +/// all verify the same, so the tie goes to cached signing. +fn record(rows: &mut Vec, st: &mut Stats, b: &Budgets, sk: &Skeleton, room: &Room, swn: u64, trials: u64) { + let Some(lay) = room + .profiles + .iter() + .filter(|lay| { + b.of(sk.sign(lay, trials)) <= b.max_sign && b.of(sk.sign_cached(lay, trials)) <= b.max_sign_cached + }) + .min_by_key(|lay| (b.of(sk.sign_cached(lay, trials)), b.of(sk.sign(lay, trials)))) + else { + return; + }; + st.rows += 1; + rows.push(Candidate { + params: Params { + h_top: Some(lay.profile.h_top), + ..sk.params + }, + costs: sk.finish(lay, swn, trials), + }); + if rows.len() >= ROWS_CAP { + sort_rows(rows, b); + rows.truncate(ROWS_KEPT); + st.rows_dropped += (ROWS_CAP - ROWS_KEPT) as u64; } } @@ -330,35 +403,43 @@ fn record(best: &mut Vec, index: &mut std::collections::HashMap Vec { - let bits_max = g.chain_bits.iter().copied().max().unwrap_or(0); + let bits = ( + g.chain_bits.iter().copied().min().unwrap_or(0), + g.chain_bits.iter().copied().max().unwrap_or(0), + ); + // (axis, value, floor, limit, what to raise). An axis with a single value in + // range was pinned deliberately, so it gets no warning. let at = [ - ("h", c.params.h, g.h_max, "H_MAX / --h-max"), - ("a", c.params.a, g.a_max, "A_MAX / --a-max"), - ("k", c.params.k, g.k_max, "K_MAX / --k-max"), + ("h", c.params.h, g.h_min, g.h_max, "H_MAX / --h-max"), + ("d", c.params.d, 1, g.d_max, "D_MAX / --d-max"), + ("a", c.params.a, g.a_min, g.a_max, "A_MAX / --a-max"), + ("k", c.params.k, 1, g.k_max, "K_MAX / --k-max"), ( "chain_bits", c.costs.chain_bits, - bits_max, + bits.0, + bits.1, "CHAIN_BITS_MAX / --chain-bits", ), ( "dropped_chains", c.params.dropped_chains, + 0, g.max_dropped, "DROPPED_MAX / --max-dropped", ), ]; at.iter() - .filter(|(_, v, limit, _)| *v + 1 >= *limit) - .map(|(axis, v, limit, what)| { + .filter(|(_, v, floor, limit, _)| limit > floor && *v + 1 >= *limit) + .map(|(axis, v, _, limit, what)| { let where_ = if v >= limit { "at" } else { "one step below" }; format!("{axis} = {v} is {where_} the top of the searched range ({limit}): raise {what} and rerun") }) .collect() } -/// The same search with nothing skipped: every `(a, k, S_wn)` point costed in -/// full and checked against every budget. +/// The same search with nothing skipped: every `(a, k, h_top, S_wn)` point +/// costed in full and checked against every budget. /// /// Only usable on a tiny grid, which is the point: it is the oracle the real /// search is diffed against in `tests/goldens`. @@ -371,24 +452,30 @@ pub fn naive_search(b: &Budgets, g: &Grid) -> Vec { let max_dropped = if scheme.wots_c() { g.max_dropped } else { 0 }; for dropped in 0..=max_dropped { for h in g.h_min..=g.h_max { - for d in divisors(h) { - for a in g.a_min..=g.a_max { - for k in 1..=g.k_max { - let p = params(g, scheme, h, d, a, k, w, dropped); - let Some(sk) = Skeleton::new(p) else { continue }; - if crate::security::security_bits(b.lifetime, h as u32, k, a, g.n) < b.security { - continue; - } - let table = scheme.wots_c().then(|| NuTable::new(sk.l, w, digest_bits)); - let sums: Vec = match &table { - Some(_) => (0..=sk.max_swn).collect(), - None => vec![0], - }; - for swn in sums { - let trials = table.as_ref().map_or(0, |t| t.trials(swn)); - let c = sk.finish(swn, trials); - if b.fits(&c) { - out.push(Candidate { params: p, costs: c }); + for d in 1..=h.min(g.d_max) { + for h_top in 1..=h { + for a in g.a_min..=g.a_max { + for k in 1..=g.k_max { + let p = Params { + h_top: Some(h_top), + ..params(g, scheme, h, d, a, k, w, dropped) + }; + let Some(sk) = Skeleton::new(p) else { continue }; + let Some(lay) = Layers::new(&p) else { continue }; + if crate::security::security_bits(b.lifetime, h as u32, k, a, g.n) < b.security { + continue; + } + let table = scheme.wots_c().then(|| NuTable::new(sk.l, w, digest_bits)); + let sums: Vec = match &table { + Some(_) => (0..=sk.max_swn).collect(), + None => vec![0], + }; + for swn in sums { + let trials = table.as_ref().map_or(0, |t| t.trials(swn)); + let c = sk.finish(&lay, swn, trials); + if b.fits(&c) { + out.push(Candidate { params: p, costs: c }); + } } } } diff --git a/doc/sphincs/tests/goldens.rs b/doc/sphincs/tests/goldens.rs index 41fa1033d..c9cb04b11 100644 --- a/doc/sphincs/tests/goldens.rs +++ b/doc/sphincs/tests/goldens.rs @@ -11,7 +11,7 @@ //! * for the search, a naive oracle in this crate that skips nothing. use sphincs_params::cost::{Convention, Encoding, NuTable, Scheme}; -use sphincs_params::params::{Params, Skeleton, costs}; +use sphincs_params::params::{Layers, Params, Profile, Skeleton, costs}; use sphincs_params::search::{Budgets, Grid, LEVEL1_BITS, Stats, Unit, naive_search, search}; use sphincs_params::security::{forgery_exponent, security_bits}; @@ -20,6 +20,7 @@ fn params(scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, cached_midstat scheme, h, d, + h_top: None, a, k, w, @@ -323,7 +324,7 @@ fn half_top_cache_is_a_saving_and_reduces_to_the_full_tree() { assert!(costs(whole, None).unwrap().sign_cached.hashes < c.sign_cached.hashes); // caching only the root is caching nothing let none = Params { - cache_height: Some(p.h_prime()), + cache_height: Some(p.profile().unwrap().h_top), ..p }; assert_eq!(costs(none, None).unwrap().sign_cached.hashes, c.sign.hashes); @@ -377,10 +378,11 @@ fn search_agrees_with_a_naive_oracle() { } #[test] -fn search_recovers_the_reports_bold_row() { +fn search_finds_and_improves_on_the_reports_bold_row() { // Budgets near the report's 2^40 numbers, on its grid (w in {16, 256}, no - // chain dropping): the search should land on h=40 d=5 a=14 k=11 w=256 and - // then spend what is left of the signing budget raising the target sum. + // chain dropping). Its own choice has to come out feasible, and the search + // has to do at least as well: it spends what is left of the signing budget + // raising the target sum, which the report's row does not. let b = budgets(40, 1_100_000, 6_000_000, 6_000_000, 4_400); let g = Grid { chain_bits: vec![4, 8], @@ -389,23 +391,60 @@ fn search_recovers_the_reports_bold_row() { }; let mut st = Stats::default(); let found = search(&b, &g, &mut st); - let best = &found[0]; + let row = found + .iter() + .find(|c| c.key() == (Scheme::WcFc, 40, 5, 14, 11, 256, 0)) + .expect("the report's bold row is feasible under its own budgets"); + assert!( + row.costs.swn.unwrap() > 2040, + "the report's row grinds less than the budget allows" + ); + assert!( + row.costs.verify.hashes < 10402, + "so it can verify faster than the table's 10402 hashes" + ); + assert!( + found[0].costs.verify.hashes <= row.costs.verify.hashes, + "and the winner is at least as cheap" + ); +} + +#[test] +fn a_taller_top_layer_is_free_on_size_and_verification() { + // The whole point of per-layer heights: the signature carries h + // authentication nodes and the verifier walks them however the layers + // divide h, so only the signer's costs move. + let uniform = Params { + h_top: Some(8), + ..params(Scheme::WcFc, 40, 5, 14, 11, 256, true) + }; + let tall = Params { + h_top: Some(15), + ..uniform + }; + let (u, t) = (costs(uniform, None).unwrap(), costs(tall, None).unwrap()); + assert_eq!(u.profile.total(), 40); + assert_eq!(t.profile.total(), 40); assert_eq!( - ( - best.params.scheme, - best.params.h, - best.params.d, - best.params.a, - best.params.k, - best.params.w - ), - (Scheme::WcFc, 40, 5, 14, 11, 256) + (t.sig_bytes, t.verify), + (u.sig_bytes, u.verify), + "size and verification do not move" ); assert!( - best.costs.swn.unwrap() > 2040, - "the report's row grinds less than the budget allows" + t.keygen.hashes > u.keygen.hashes, + "a taller top tree costs more to generate" + ); + assert!(t.sign.hashes > u.sign.hashes, "and more to sign without the cache"); + assert!( + t.sign_cached.hashes < u.sign_cached.hashes, + "but less with it, which is the point" ); - assert!(best.costs.verify.hashes < 10402, "and verifies faster than it does"); + // the lower layers come out as equal as they go, never differing by more + // than one level + let p = t.profile; + assert!(p.n_tall == 0 || p.n_short == 0 || p.tall == p.short + 1); + assert_eq!(Profile::new(41, 5, Some(9)).unwrap().total(), 41); + assert_eq!(Layers::new(&tall).unwrap().profile, t.profile); } #[test] @@ -427,7 +466,21 @@ fn skeleton_rejects_trees_that_do_not_fit_a_u64() { fn skeleton_rejects_inconsistent_parameters() { let ok = params(Scheme::WcFc, 40, 5, 14, 11, 256, true); assert!(Skeleton::new(ok).is_some()); - assert!(Skeleton::new(Params { d: 3, ..ok }).is_none(), "d must divide h"); + // d need not divide h: the layers just come out within one of each other + let uneven = Skeleton::new(Params { d: 3, ..ok }).expect("d need not divide h"); + assert_eq!(uneven.params.profile().unwrap().total(), 40); + assert!( + Skeleton::new(Params { h: 2, d: 3, ..ok }).is_none(), + "every layer needs a level" + ); + assert!( + Skeleton::new(Params { h_top: Some(40), ..ok }).is_none(), + "the lower layers need levels too" + ); + assert!( + Skeleton::new(Params { h_top: Some(36), ..ok }).is_some(), + "but only one each" + ); assert!( Skeleton::new(Params { w: 24, ..ok }).is_none(), "w must be a power of two" @@ -443,7 +496,7 @@ fn skeleton_rejects_inconsistent_parameters() { "WOTS-TW has no counter" ); assert!( - Skeleton::new(Params { + Layers::new(&Params { cache_height: Some(99), ..ok }) From cdc4aecc8b7ae31e78025402fb4916b63548ce32 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 14:27:48 +0200 Subject: [PATCH 05/31] doc/sphincs: move the tool under params_selection/ Leaves doc/sphincs/ free for whatever else belongs to the scheme, the way doc/leanvm/ and doc/xmss/ hold their own specs. Pure move: same crate, same numbers, one path in the README and one in AGENTS.md. --- AGENTS.md | 2 +- doc/sphincs/{ => params_selection}/Cargo.lock | 0 doc/sphincs/{ => params_selection}/Cargo.toml | 0 doc/sphincs/{ => params_selection}/README.md | 4 ++-- doc/sphincs/{ => params_selection}/src/cost.rs | 0 doc/sphincs/{ => params_selection}/src/lib.rs | 0 doc/sphincs/{ => params_selection}/src/main.rs | 0 doc/sphincs/{ => params_selection}/src/params.rs | 0 doc/sphincs/{ => params_selection}/src/report.rs | 0 doc/sphincs/{ => params_selection}/src/search.rs | 0 doc/sphincs/{ => params_selection}/src/security.rs | 0 doc/sphincs/{ => params_selection}/tests/goldens.rs | 0 12 files changed, 3 insertions(+), 3 deletions(-) rename doc/sphincs/{ => params_selection}/Cargo.lock (100%) rename doc/sphincs/{ => params_selection}/Cargo.toml (100%) rename doc/sphincs/{ => params_selection}/README.md (94%) rename doc/sphincs/{ => params_selection}/src/cost.rs (100%) rename doc/sphincs/{ => params_selection}/src/lib.rs (100%) rename doc/sphincs/{ => params_selection}/src/main.rs (100%) rename doc/sphincs/{ => params_selection}/src/params.rs (100%) rename doc/sphincs/{ => params_selection}/src/report.rs (100%) rename doc/sphincs/{ => params_selection}/src/search.rs (100%) rename doc/sphincs/{ => params_selection}/src/security.rs (100%) rename doc/sphincs/{ => params_selection}/tests/goldens.rs (100%) diff --git a/AGENTS.md b/AGENTS.md index 3706648e5..f4070076a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ 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 a parameter-exploration tool for SPHINCS+ (security, signature size, hash counts, and a search for the cheapest verification under given budgets), in its own cargo workspace with no dependencies. It models the schemes of the Blockstream report, not anything this repo proves, and its `cargo test --release` pins every number against that project's sage fixtures. +- `doc/sphincs/params_selection/` is a parameter-exploration tool for SPHINCS+ (security, signature size, hash counts, and a search for the cheapest verification under given budgets), in its own cargo workspace with no dependencies. It models the schemes of the Blockstream report, not anything this repo proves, and its `cargo test --release` pins every number against that project's sage fixtures. - The one hash function is BLAKE2s, in `primitives::blake2s`: 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). diff --git a/doc/sphincs/Cargo.lock b/doc/sphincs/params_selection/Cargo.lock similarity index 100% rename from doc/sphincs/Cargo.lock rename to doc/sphincs/params_selection/Cargo.lock diff --git a/doc/sphincs/Cargo.toml b/doc/sphincs/params_selection/Cargo.toml similarity index 100% rename from doc/sphincs/Cargo.toml rename to doc/sphincs/params_selection/Cargo.toml diff --git a/doc/sphincs/README.md b/doc/sphincs/params_selection/README.md similarity index 94% rename from doc/sphincs/README.md rename to doc/sphincs/params_selection/README.md index 321161099..c27a6970d 100644 --- a/doc/sphincs/README.md +++ b/doc/sphincs/params_selection/README.md @@ -1,11 +1,11 @@ -# SPHINCS+ parameters +# SPHINCS+ parameter selection Security, signature size and hash counts for the WOTS/FORS schemes of "Hash-based Signature Schemes for Bitcoin" (Kudinov, Nick, Blockstream Research), plus a search for the parameter set that verifies cheapest under a given set of budgets. See `src/lib.rs` for what is modelled and what is deliberately not. Its own cargo workspace, no dependencies, not a member of the repo's workspace. ```sh -cd doc/sphincs +cd doc/sphincs/params_selection cargo run --release -- params --scheme W+C_F+C --lifetime 40 --height 40 --layers 5 -a 14 -k 11 -w 256 cargo run --release -- params --top-height 15 # a taller top XMSS tree, cheaper to sign with the cache cargo run --release -- search --lifetime 30 --max-keygen 2e6 --max-sign 6e6 --max-sign-cached 4e6 --max-size 4000 diff --git a/doc/sphincs/src/cost.rs b/doc/sphincs/params_selection/src/cost.rs similarity index 100% rename from doc/sphincs/src/cost.rs rename to doc/sphincs/params_selection/src/cost.rs diff --git a/doc/sphincs/src/lib.rs b/doc/sphincs/params_selection/src/lib.rs similarity index 100% rename from doc/sphincs/src/lib.rs rename to doc/sphincs/params_selection/src/lib.rs diff --git a/doc/sphincs/src/main.rs b/doc/sphincs/params_selection/src/main.rs similarity index 100% rename from doc/sphincs/src/main.rs rename to doc/sphincs/params_selection/src/main.rs diff --git a/doc/sphincs/src/params.rs b/doc/sphincs/params_selection/src/params.rs similarity index 100% rename from doc/sphincs/src/params.rs rename to doc/sphincs/params_selection/src/params.rs diff --git a/doc/sphincs/src/report.rs b/doc/sphincs/params_selection/src/report.rs similarity index 100% rename from doc/sphincs/src/report.rs rename to doc/sphincs/params_selection/src/report.rs diff --git a/doc/sphincs/src/search.rs b/doc/sphincs/params_selection/src/search.rs similarity index 100% rename from doc/sphincs/src/search.rs rename to doc/sphincs/params_selection/src/search.rs diff --git a/doc/sphincs/src/security.rs b/doc/sphincs/params_selection/src/security.rs similarity index 100% rename from doc/sphincs/src/security.rs rename to doc/sphincs/params_selection/src/security.rs diff --git a/doc/sphincs/tests/goldens.rs b/doc/sphincs/params_selection/tests/goldens.rs similarity index 100% rename from doc/sphincs/tests/goldens.rs rename to doc/sphincs/params_selection/tests/goldens.rs From 3c5185f9f7504e28be4a4c89ea41390a04a98870 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 14:34:48 +0200 Subject: [PATCH 06/31] doc/sphincs: one command, pin what you know and search the rest Replaces the params/search subcommands with a single invocation. Every flag is optional: giving a parameter pins it, leaving it out searches it, and pinning them all is how one set gets costed. Budgets are optional too, an unset one being no limit, so the two old behaviours are the two ends of one dial rather than two code paths. That needed a rule for the axes that only ever trade signer work for cheaper verification, since with nothing bounding the signer they are unbounded and their answer is useless: left free and unbudgeted, the first run of this dropped 5 WOTS+C chains and ground 10^20 counters to reach 4.03K verification hashes. So the target sum, the dropped chains and the top height take the value the report's own sets use when nothing bounds the signer, and are searched as soon as a budget does. That makes the fully pinned command reproduce the report's bold 2^40 row exactly, 4356 bytes and 10402 hashes, with no table around it. Grid axes are now Spans (pinned when lo == hi), h_top an Option because "the classic h/d split" is not a value it can hold, and budgets are Options so an unset one neither constrains nor gets a percentage in the utilization line. The table only prints when there is more than one row. Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/params_selection/README.md | 22 +- doc/sphincs/params_selection/src/main.rs | 317 ++++++++++-------- doc/sphincs/params_selection/src/report.rs | 13 +- doc/sphincs/params_selection/src/search.rs | 257 ++++++++------ doc/sphincs/params_selection/tests/goldens.rs | 23 +- 5 files changed, 366 insertions(+), 266 deletions(-) diff --git a/doc/sphincs/params_selection/README.md b/doc/sphincs/params_selection/README.md index c27a6970d..a1de5eaf4 100644 --- a/doc/sphincs/params_selection/README.md +++ b/doc/sphincs/params_selection/README.md @@ -1,17 +1,23 @@ # SPHINCS+ parameter selection -Security, signature size and hash counts for the WOTS/FORS schemes of "Hash-based Signature Schemes for Bitcoin" (Kudinov, Nick, Blockstream Research), plus a search for the parameter set that verifies cheapest under a given set of budgets. See `src/lib.rs` for what is modelled and what is deliberately not. +Security, signature size and hash counts for the WOTS/FORS schemes of "Hash-based Signature Schemes for Bitcoin" (Kudinov, Nick, Blockstream Research), and a search for the set that verifies cheapest under a given set of budgets. See `src/lib.rs` for what is modelled and what is deliberately not. -Its own cargo workspace, no dependencies, not a member of the repo's workspace. +One command. Give a parameter to pin it, leave it out to search it: ```sh cd doc/sphincs/params_selection -cargo run --release -- params --scheme W+C_F+C --lifetime 40 --height 40 --layers 5 -a 14 -k 11 -w 256 -cargo run --release -- params --top-height 15 # a taller top XMSS tree, cheaper to sign with the cache -cargo run --release -- search --lifetime 30 --max-keygen 2e6 --max-sign 6e6 --max-sign-cached 4e6 --max-size 4000 -cargo test --release # goldens: upstream sage fixtures, the report's tables, a naive search oracle +cargo run --release -- --lifetime 40 --scheme W+C_F+C --height 40 --layers 5 --top-height 8 \ + -a 14 -k 11 -w 256 --drop-chains 0 --swn 2040 ``` -`cargo run --release --` with no subcommand prints the full option list. +That pins everything, so it just costs that one set (the report's bold 2^40 row: 4356 bytes, 10402 hashes to verify). Leave axes out and they get searched instead, against whichever budgets you set: -The search is exhaustive over hardcoded ranges and prints a warning when its answer leans on the top of one of them. Budgets loose enough that nothing prunes can take a couple of minutes and are reported by `--stats`; realistic ones finish in seconds. +```sh +cargo run --release -- --lifetime 30 --max-keygen 2e6 --max-sign 6e6 --max-sign-cached 4e6 --max-size 4000 +``` + +`--max-sign-cached` budgets signing with the top XMSS tree's half top kept as signer state, which costs sqrt storage for a sqrt-cost top tree. Since size and verification depend only on `(h, d)` and not on how the layers divide `h`, a taller top layer is free on both and cheaper to sign with the cache: compare `--top-height 8` against `--top-height 15` at `--height 40 --layers 5`. + +`cargo run --release --` with no arguments prints every flag and its default. `cargo test --release` runs the goldens: the upstream sage fixtures, the report's own tables, and a naive search oracle that skips nothing. + +The search is exhaustive over hardcoded ranges and warns when its answer leans on the top of one. Budgets loose enough that nothing prunes can take a couple of minutes, reported by `--stats`; realistic ones finish in seconds. diff --git a/doc/sphincs/params_selection/src/main.rs b/doc/sphincs/params_selection/src/main.rs index 472a80403..958453193 100644 --- a/doc/sphincs/params_selection/src/main.rs +++ b/doc/sphincs/params_selection/src/main.rs @@ -1,64 +1,73 @@ -//! `params`: cost one parameter set. `search`: find the cheapest to verify. +//! One command: pin the parameters you know, budget the costs you care about, +//! and everything left over gets searched. use sphincs_params::cost::{Convention, SCHEMES, Scheme}; -use sphincs_params::params::{Params, costs}; use sphincs_params::report::{report, table, utilization}; use sphincs_params::search::{ - A_MAX, Budgets, CHAIN_BITS_MAX, Candidate, D_MAX, DROPPED_MAX, Grid, H_MAX, K_MAX, LEVEL1_BITS, Stats, Unit, edges, - search, + A_MAX, Budgets, CHAIN_BITS_MAX, D_MAX, DROPPED_MAX, Grid, H_MAX, K_MAX, LEVEL1_BITS, Span, Stats, Sums, Unit, + edges, search, }; const USAGE: &str = "\ -usage: sphincs_params params [options] cost one parameter set - sphincs_params search [options] search for the cheapest verification +SPHINCS+ parameter selection: what verifies cheapest, or what one set costs. -params options (defaults are the report's bold 2^40 row): - --scheme S SPX | W+C | W+C_F+C [W+C_F+C] - --lifetime L log2 of signatures per key [40] - --height h hypertree height [40] - --layers d hypertree layers [5] - --top-height H height of the top XMSS tree [h/d, so every layer equal] - -a A log2 leaves per FORS tree [14] - -k K FORS trees [11] - -w W Winternitz parameter [256] - --chain-bits B log2(w), instead of -w - --swn S WOTS+C target digit sum [the mean, l*(w-1)/2] - --drop-chains C chains dropped beyond the minimal bit pinning [0] - -n N hash output in bytes [16] - --cache-height C cached top-tree level, above the leaves [h'/2] - --cache-level-only cache one level, not it and everything above - --uncached charge every hash for its full input +usage: sphincs_params --lifetime L [parameters] [budgets] [output] -search options (all five budgets required): - --lifetime L log2 of signatures per key - --max-keygen N budget for keygen - --max-sign N budget for average signing - --max-sign-cached N budget for average signing, half top cached - --max-size B budget for the signature, in bytes - --security BITS classical security floor [128, NIST level 1] - --unit U hashes | compressions, for the budgets and objective [hashes] - --scheme S restrict the schemes searched (repeatable) - --chain-bits B restrict log2(w) searched (repeatable) - --top N rows to print [15] - --h-max / --d-max / --a-max / --k-max / --max-dropped widen or narrow a range +Give a parameter to pin it, leave it out to search it. Pin them all and the run +just costs that one set. Numbers may be written as 2e6. + +parameters + --lifetime L log2 of the signatures allowed per public key (required) + --scheme S SPX | W+C | W+C_F+C, repeatable [all three] + --height h total hypertree height [1..96] + --layers d hypertree layers [1..32] + --top-height ht height of the top XMSS tree, the rest + of h splitting evenly below it [1..h-d+1, or h/d] + -a A log2 of the leaves in a FORS tree [1..32] + -k K FORS trees [1..64] + --chain-bits B log2(w), repeatable [1..12] + -w W Winternitz parameter, instead of --chain-bits + --drop-chains C WOTS+C chains dropped beyond the + minimal digest-bit pinning [0..16, or 0] + --swn S WOTS+C target digit sum [the most the signing + budget allows, or the + mean] + +The last three trade signer work for cheaper verification, so unpinned they are +searched only against a budget that bounds it; with none they take the value the +report's own parameter sets use, shown above after the comma. + -n N hash output in bytes [16] + +budgets, all optional: an unset one is no limit + --max-keygen N hashes at key generation + --max-sign N hashes at signing + --max-sign-cached N hashes at signing with the top tree's half top cached + --max-size B signature bytes + --security BITS classical security floor [128, NIST level 1] + --unit U hashes | compressions, for the budgets and the + objective alike [hashes] + +other + --cache-height C cached top-tree level, above the leaves [half of h_top] + --cache-level-only cache one level, not it and everything above + --uncached charge every hash for its full input, rather than + caching the PK.seed midstate + --top N rows of the table to print [15] --stats report how much of the space was visited - -n N hash output in bytes [16] + +examples + sphincs_params --lifetime 30 --max-keygen 2e6 --max-sign 6e6 \\ + --max-sign-cached 4e6 --max-size 4000 + sphincs_params --lifetime 40 --height 40 --layers 5 -a 14 -k 11 -w 256 --swn 2040 "; fn main() -> std::process::ExitCode { - let args: Vec = std::env::args().skip(1).collect(); - match args.first().map(String::as_str) { - Some("params") => run(cmd_params(&args[1..])), - Some("search") => run(cmd_search(&args[1..])), - _ => { - print!("{USAGE}"); - std::process::ExitCode::from(2) - } + let argv: Vec = std::env::args().skip(1).collect(); + if argv.is_empty() || argv.iter().any(|a| a == "-h" || a == "--help") { + print!("{USAGE}"); + return std::process::ExitCode::SUCCESS; } -} - -fn run(r: Result) -> std::process::ExitCode { - match r { + match run(&argv) { Ok(true) => std::process::ExitCode::SUCCESS, Ok(false) => std::process::ExitCode::FAILURE, Err(e) => { @@ -68,9 +77,11 @@ fn run(r: Result) -> std::process::ExitCode { } } -/// Flags and their values, with repeatable flags kept in order. +/// Flags and their values, repeatable flags kept in order. struct Args(Vec<(String, Option)>); +const NO_VALUE: [&str; 4] = ["--uncached", "--cache-level-only", "--stats", "--help"]; + impl Args { fn parse(argv: &[String]) -> Result { let mut out = Vec::new(); @@ -80,14 +91,13 @@ impl Args { if !flag.starts_with('-') { return Err(format!("unexpected argument {flag}")); } - let takes_value = !matches!(flag.as_str(), "--uncached" | "--cache-level-only" | "--stats"); - if takes_value { + if NO_VALUE.contains(&flag.as_str()) { + out.push((flag.clone(), None)); + i += 1; + } else { let v = argv.get(i + 1).ok_or_else(|| format!("{flag} needs a value"))?; out.push((flag.clone(), Some(v.clone()))); i += 2; - } else { - out.push((flag.clone(), None)); - i += 1; } } Ok(Args(out)) @@ -109,27 +119,24 @@ impl Args { self.all(name).last().copied() } - fn u64(&self, name: &str, default: u64) -> Result { + /// Accepts 2e6 as well as 2000000. + fn num(&self, name: &str) -> Result, String> { match self.get(name) { - None => Ok(default), - // accept 2e6 as well as 2000000 + None => Ok(None), Some(s) => s .parse::() - .map(|f| f as u64) + .map(|f| Some(f as u64)) .map_err(|_| format!("{name}: expected a number, got {s}")), } } - fn f64(&self, name: &str, default: f64) -> Result { - match self.get(name) { - None => Ok(default), - Some(s) => s.parse().map_err(|_| format!("{name}: expected a number, got {s}")), - } + fn u64_or(&self, name: &str, default: u64) -> Result { + Ok(self.num(name)?.unwrap_or(default)) } - fn required(&self, name: &str) -> Result { - self.get(name).ok_or_else(|| format!("{name} is required"))?; - self.u64(name, 0) + /// A pin if the flag was given, the whole range otherwise. + fn span(&self, name: &str, whole: Span) -> Result { + Ok(self.num(name)?.map_or(whole, Span::pin)) } fn schemes(&self) -> Result, String> { @@ -142,88 +149,84 @@ impl Args { .map(|s| Scheme::parse(s).ok_or_else(|| format!("unknown scheme {s}"))) .collect() } -} -fn cmd_params(argv: &[String]) -> Result { - let args = Args::parse(argv)?; - let w = match args.get("--chain-bits") { - Some(_) => 1u64 << args.u64("--chain-bits", 8)?, - None => args.u64("-w", 256)?, - }; - let lifetime = args.u64("--lifetime", 40)? as u32; - let p = Params { - scheme: match args.get("--scheme") { - Some(s) => Scheme::parse(s).ok_or_else(|| format!("unknown scheme {s}"))?, - None => Scheme::WcFc, - }, - h: args.u64("--height", 40)?, - d: args.u64("--layers", 5)?, - h_top: args - .get("--top-height") - .map(|_| args.u64("--top-height", 0)) - .transpose()?, - a: args.u64("-a", 14)?, - k: args.u64("-k", 11)?, - w, - n: args.u64("-n", 16)?, - dropped_chains: args.u64("--drop-chains", 0)?, - cache_height: args - .get("--cache-height") - .map(|_| args.u64("--cache-height", 0)) - .transpose()?, - cache_level_only: args.flag("--cache-level-only"), - convention: Convention { - cached_midstate: !args.flag("--uncached"), - }, - }; - let swn = args.get("--swn").map(|_| args.u64("--swn", 0)).transpose()?; - let c = costs(p, swn) - .ok_or("inconsistent parameters: d must divide h, w must be a power of two, FORS+C needs k >= 2")?; - println!("{}", report(&p, &c, lifetime)); - Ok(true) + fn chain_bits(&self) -> Result, String> { + let mut bits: Vec = self + .all("--chain-bits") + .iter() + .map(|s| { + s.parse::() + .map_err(|_| format!("--chain-bits: expected a number, got {s}")) + }) + .collect::>()?; + if let Some(w) = self.num("-w")? { + if w < 2 || !w.is_power_of_two() { + return Err(format!("-w: expected a power of two, got {w}")); + } + bits.push(w.trailing_zeros() as u64); + } + if bits.is_empty() { + bits = (1..=CHAIN_BITS_MAX).collect(); + } + bits.sort_unstable(); + bits.dedup(); + Ok(bits) + } } -fn cmd_search(argv: &[String]) -> Result { +fn run(argv: &[String]) -> Result { let args = Args::parse(argv)?; - let bits: Vec = args - .all("--chain-bits") - .iter() - .map(|s| { - s.parse::() - .map_err(|_| format!("--chain-bits: expected a number, got {s}")) - }) - .collect::>()?; + let lifetime = args.num("--lifetime")?.ok_or("--lifetime is required")?; let unit = match args.get("--unit").unwrap_or("hashes") { "hashes" => Unit::Hashes, "compressions" => Unit::Compressions, other => return Err(format!("--unit: expected hashes or compressions, got {other}")), }; let b = Budgets { - lifetime: args.required("--lifetime")? as u32, - max_keygen: args.required("--max-keygen")?, - max_sign: args.required("--max-sign")?, - max_sign_cached: args.required("--max-sign-cached")?, - max_size: args.required("--max-size")?, - security: args.f64("--security", LEVEL1_BITS)?, + lifetime: lifetime as u32, + keygen: args.num("--max-keygen")?, + sign: args.num("--max-sign")?, + sign_cached: args.num("--max-sign-cached")?, + size: args.num("--max-size")?, + security: args.get("--security").map_or(Ok(LEVEL1_BITS), |s| { + s.parse().map_err(|_| format!("--security: expected a number, got {s}")) + })?, unit, }; + // A higher target sum, dropped chains and a taller top tree all buy cheaper + // verification with signer work, so with nothing bounding the signer they + // are unbounded and their answer is useless. Unpinned and unbudgeted, they + // take their classic value instead: the mean target sum, no dropped chains, + // and h/d on every layer. + let signing_bounded = b.any_signing_limit(); + let sums = match (args.num("--swn")?, signing_bounded) { + (Some(s), _) => Sums::Pinned(s), + (None, true) => Sums::Sweep, + (None, false) => Sums::Mean, + }; + let dropped = match (args.num("--drop-chains")?, signing_bounded) { + (Some(c), _) => Span::pin(c), + (None, true) => Span::new(0, DROPPED_MAX), + (None, false) => Span::pin(0), + }; + let h_top = match (args.num("--top-height")?, signing_bounded || b.keygen.is_some()) { + (Some(ht), _) => Some(Span::pin(ht)), + (None, true) => Some(Span::new(1, H_MAX)), + (None, false) => None, + }; let g = Grid { schemes: args.schemes()?, - n: args.u64("-n", 16)?, - h_max: args.u64("--h-max", H_MAX)?, - a_max: args.u64("--a-max", A_MAX)?, - k_max: args.u64("--k-max", K_MAX)?, - d_max: args.u64("--d-max", D_MAX)?, - chain_bits: if bits.is_empty() { - (1..=CHAIN_BITS_MAX).collect() - } else { - bits - }, - max_dropped: args.u64("--max-dropped", DROPPED_MAX)?, + n: args.u64_or("-n", 16)?, + h: args.span("--height", Span::new(1, H_MAX))?, + d: args.span("--layers", Span::new(1, D_MAX))?, + h_top, + a: args.span("-a", Span::new(1, A_MAX))?, + k: args.span("-k", Span::new(1, K_MAX))?, + dropped, + chain_bits: args.chain_bits()?, + sums, cache_level_only: args.flag("--cache-level-only"), - ..Default::default() }; - let top = args.u64("--top", 15)? as usize; let mut stats = Stats::default(); let found = search(&b, &g, &mut stats); @@ -232,25 +235,49 @@ fn cmd_search(argv: &[String]) -> Result { } if found.is_empty() { println!( - "no parameter set meets these budgets at {:.0}-bit security and q_s = 2^{}", - b.security, b.lifetime + "nothing meets these constraints at {:.0}-bit security and q_s = 2^{lifetime}", + b.security ); - println!("--stats says which budget pruned everything; the binding one is usually size or keygen"); + println!("--stats says where the space went; the binding budget is usually size or keygen"); return Ok(false); } - println!( - "{} feasible sets, best {} by verification {}:\n", - found.len(), - top.min(found.len()), - unit.label() - ); - println!("{}\n", table(&b, &found[..top.min(found.len())])); - let best: &Candidate = &found[0]; - println!("budget use of the best: {}", utilization(&b, best)); + + if found.len() > 1 { + let top = args.u64_or("--top", 15)? as usize; + let kept = if stats.rows_dropped > 0 { + format!("{} feasible sets, {} kept", stats.rows, found.len()) + } else { + format!("{} feasible sets", found.len()) + }; + println!( + "{kept}, best {} by verification {}:\n", + top.min(found.len()), + unit.label() + ); + println!("{}\n", table(&b, &found[..top.min(found.len())])); + } + + let best = &found[0]; + let use_ = utilization(&b, best); + if !use_.is_empty() { + println!("budget use: {use_}"); + } for w in edges(&g, best) { println!("warning: {w}"); } - println!(); - println!("{}", report(&best.params, &best.costs, b.lifetime)); + if !use_.is_empty() || !edges(&g, best).is_empty() { + println!(); + } + // The convention and the cache split are not searched, so they ride here + // rather than in the grid. + let shown = sphincs_params::params::Params { + cache_height: args.num("--cache-height")?, + convention: Convention { + cached_midstate: !args.flag("--uncached"), + }, + ..best.params + }; + let costs = sphincs_params::params::costs(shown, best.costs.swn).ok_or("inconsistent parameters")?; + println!("{}", report(&shown, &costs, b.lifetime)); Ok(true) } diff --git a/doc/sphincs/params_selection/src/report.rs b/doc/sphincs/params_selection/src/report.rs index a47a5e7b2..f2a278c87 100644 --- a/doc/sphincs/params_selection/src/report.rs +++ b/doc/sphincs/params_selection/src/report.rs @@ -162,16 +162,17 @@ pub fn table(b: &Budgets, cands: &[Candidate]) -> String { lines.join("\n") } +/// How much of each budget the candidate uses. Unset budgets say nothing. pub fn utilization(b: &Budgets, c: &Candidate) -> String { let used = [ - ("keygen", b.unit.of(c.costs.keygen), b.max_keygen), - ("sign", b.unit.of(c.costs.sign), b.max_sign), - ("sign cached", b.unit.of(c.costs.sign_cached), b.max_sign_cached), - ("size", c.costs.sig_bytes, b.max_size), + ("keygen", b.unit.of(c.costs.keygen), b.keygen), + ("sign", b.unit.of(c.costs.sign), b.sign), + ("sign cached", b.unit.of(c.costs.sign_cached), b.sign_cached), + ("size", c.costs.sig_bytes, b.size), ]; used.iter() - .filter(|(_, _, limit)| *limit > 0) - .map(|(name, v, limit)| format!("{name} {:.0}%", 100.0 * *v as f64 / *limit as f64)) + .filter_map(|(name, v, limit)| limit.map(|l| (name, v, l))) + .map(|(name, v, limit)| format!("{name} {:.0}%", 100.0 * *v as f64 / limit as f64)) .collect::>() .join(", ") } diff --git a/doc/sphincs/params_selection/src/search.rs b/doc/sphincs/params_selection/src/search.rs index 54fdd6a75..c61ed1946 100644 --- a/doc/sphincs/params_selection/src/search.rs +++ b/doc/sphincs/params_selection/src/search.rs @@ -1,13 +1,17 @@ //! Exhaustive search for the parameter set with the cheapest verification. //! //! Every `(scheme, h, d, h_top, chain_bits, dropped_chains, a, k, S_wn)` point -//! that meets the budgets is costed and compared. Nothing is chosen by an optimality -//! argument, and nothing is skipped by a monotonicity one: the three tests that -//! run before the `S_wn` scan reject only points that no `S_wn` could rescue, -//! because size and keygen do not depend on `S_wn` at all, and the least +//! that meets the budgets is costed and compared. Nothing is chosen by an +//! optimality argument, and nothing is skipped by a monotonicity one: the three +//! tests that run before the `S_wn` scan reject only points that no `S_wn` could +//! rescue, because size and keygen do not depend on `S_wn` at all, and the least //! grinding any `S_wn` can ask for is read off the digit-sum table rather than //! assumed to sit anywhere in particular. //! +//! Any axis can be pinned to a single value instead of searched, which is how +//! one parameter set gets costed: pin them all. Budgets are optional, and an +//! unset one is no limit. +//! //! The layer heights are `(h, d, h_top)`: the top tree gets `h_top`, the rest //! divide what is left as evenly as it goes. [`crate::params::Profile`] argues //! why that shape covers the cost-optimal representative of every profile, so @@ -20,11 +24,11 @@ //! What is assumed is the searched range of each parameter, hardcoded below. //! When a result comes out at the top of one of those ranges the range itself //! may be what is limiting it, so [`edges`] reports that and names the constant -//! to raise. Ranges the budgets or the structure already close (`d` over the -//! layer heights that do not add up to `h`, `S_wn` over the digit sums a code of -//! `l` chains can reach) -//! need no such warning and get none. +//! to raise. Ranges the structure already closes (`d` over layer heights that do +//! not add up to `h`, `S_wn` over the digit sums a code of `l` chains can reach) +//! need no such warning and get none, and neither does an axis pinned by hand. +use std::ops::RangeInclusive; use std::time::Instant; use crate::cost::{Cost, NuTable, SCHEMES, Scheme}; @@ -72,14 +76,54 @@ impl Unit { } } +/// The values of one parameter to try. Pinned when `lo == hi`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Span { + pub lo: u64, + pub hi: u64, +} + +impl Span { + pub const fn new(lo: u64, hi: u64) -> Self { + Self { lo, hi } + } + pub const fn pin(v: u64) -> Self { + Self { lo: v, hi: v } + } + pub const fn pinned(&self) -> bool { + self.lo >= self.hi + } + pub const fn iter(&self) -> RangeInclusive { + self.lo..=self.hi + } + /// The span, further limited by something the parameters imply. + pub fn within(&self, hi: u64) -> RangeInclusive { + self.lo..=self.hi.min(hi) + } +} + +/// Which target sums to consider. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Sums { + /// Only this one. + Pinned(u64), + /// Only the mean, where grinding is cheapest. What to use when nothing + /// bounds the signer, since then there is no reason to grind harder, and + /// what the report's own parameter sets do. + Mean, + /// All of them, keeping the best the budgets allow. + Sweep, +} + #[derive(Clone, Copy, Debug)] pub struct Budgets { /// log2 of the signatures allowed under one public key. pub lifetime: u32, - pub max_keygen: u64, - pub max_sign: u64, - pub max_sign_cached: u64, - pub max_size: u64, + /// An unset budget is no limit. + pub keygen: Option, + pub sign: Option, + pub sign_cached: Option, + pub size: Option, /// Classical security floor in bits. pub security: f64, /// Unit of every budget above, and of the objective. @@ -87,15 +131,31 @@ pub struct Budgets { } impl Budgets { - fn of(&self, c: Cost) -> u64 { + pub fn max_keygen(&self) -> u64 { + self.keygen.unwrap_or(u64::MAX) + } + pub fn max_sign(&self) -> u64 { + self.sign.unwrap_or(u64::MAX) + } + pub fn max_sign_cached(&self) -> u64 { + self.sign_cached.unwrap_or(u64::MAX) + } + pub fn max_size(&self) -> u64 { + self.size.unwrap_or(u64::MAX) + } + pub fn any_signing_limit(&self) -> bool { + self.sign.is_some() || self.sign_cached.is_some() + } + + pub fn of(&self, c: Cost) -> u64 { self.unit.of(c) } - fn fits(&self, c: &Costs) -> bool { - c.sig_bytes <= self.max_size - && self.of(c.keygen) <= self.max_keygen - && self.of(c.sign) <= self.max_sign - && self.of(c.sign_cached) <= self.max_sign_cached + pub fn fits(&self, c: &Costs) -> bool { + c.sig_bytes <= self.max_size() + && self.of(c.keygen) <= self.max_keygen() + && self.of(c.sign) <= self.max_sign() + && self.of(c.sign_cached) <= self.max_sign_cached() } } @@ -103,14 +163,15 @@ impl Budgets { pub struct Grid { pub schemes: Vec, pub n: u64, - pub h_min: u64, - pub h_max: u64, - pub a_min: u64, - pub a_max: u64, - pub k_max: u64, - pub d_max: u64, + pub h: Span, + pub d: Span, + /// `None` searches nothing: the classic split, `h/d` on every layer. + pub h_top: Option, + pub a: Span, + pub k: Span, + pub dropped: Span, pub chain_bits: Vec, - pub max_dropped: u64, + pub sums: Sums, pub cache_level_only: bool, } @@ -119,14 +180,14 @@ impl Default for Grid { Self { schemes: SCHEMES.to_vec(), n: 16, - h_min: 1, - h_max: H_MAX, - a_min: 1, - a_max: A_MAX, - k_max: K_MAX, - d_max: D_MAX, + h: Span::new(1, H_MAX), + d: Span::new(1, D_MAX), + h_top: Some(Span::new(1, H_MAX)), + a: Span::new(1, A_MAX), + k: Span::new(1, K_MAX), + dropped: Span::new(0, DROPPED_MAX), chain_bits: (1..=CHAIN_BITS_MAX).collect(), - max_dropped: DROPPED_MAX, + sums: Sums::Sweep, cache_level_only: false, } } @@ -138,6 +199,10 @@ pub struct Candidate { pub costs: Costs, } +/// Identifies one parameter tuple: everything but the layer profile and the +/// target sum, neither of which changes what it verifies at. +pub type Key = (Scheme, u64, u64, u64, u64, u64, u64); + impl Candidate { pub fn key(&self) -> Key { let p = self.params; @@ -156,7 +221,7 @@ pub struct Stats { pub size_pruned: u64, /// `(a, k)` pairs too slow to sign at the least grinding any target sum asks. pub sign_pruned: u64, - /// `(a, k)` pairs whose whole target-sum range was scanned. + /// `(a, k)` pairs whose target sums were scanned. pub swept: u64, /// Points meeting every budget. pub feasible: u64, @@ -194,10 +259,6 @@ impl std::fmt::Display for Stats { } } -/// Identifies one parameter tuple: everything but the layer profile and the -/// target sum, neither of which changes what it verifies at. -pub type Key = (Scheme, u64, u64, u64, u64, u64, u64); - fn params(g: &Grid, scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, dropped: u64) -> Params { Params { scheme, @@ -228,28 +289,34 @@ struct Room { slack: u64, } -fn room(b: &Budgets, p: &Params) -> Option { +fn room(b: &Budgets, g: &Grid, p: &Params) -> Option { let mut profiles = Vec::new(); let mut slack = 0; - // The top tree has 2^h_top leaves and every leaf costs at least one hash, - // so a top height past the keygen budget's log is out for any (a, k). - let ceiling = 64 - b.max_keygen.max(1).leading_zeros() as u64; - for h_top in 1..=(p.h + 1).saturating_sub(p.d).min(ceiling) { - let Some(lay) = Layers::new(&Params { - h_top: Some(h_top), - ..*p - }) else { - continue; + let mut consider = |h_top: Option| { + let Some(lay) = Layers::new(&Params { h_top, ..*p }) else { + return; }; - if b.of(lay.keygen) > b.max_keygen { - continue; + if b.of(lay.keygen) > b.max_keygen() { + return; } let room = b - .max_sign + .max_sign() .saturating_sub(b.of(lay.trees)) - .min(b.max_sign_cached.saturating_sub(b.of(lay.trees_cached))); + .min(b.max_sign_cached().saturating_sub(b.of(lay.trees_cached))); slack = slack.max(room); profiles.push(lay); + }; + match g.h_top { + None => consider(None), + Some(span) => { + // The top tree has 2^h_top leaves and every leaf costs at least one + // hash, so a top height past the keygen budget's log is out for any + // (a, k). + let ceiling = 64 - b.max_keygen().max(1).leading_zeros() as u64; + for h_top in span.within((p.h + 1).saturating_sub(p.d).min(ceiling)) { + consider(Some(h_top)); + } + } } (!profiles.is_empty()).then_some(Room { profiles, slack }) } @@ -262,7 +329,7 @@ fn room(b: &Budgets, p: &Params) -> Option { pub fn search(b: &Budgets, g: &Grid, st: &mut Stats) -> Vec { let started = Instant::now(); let digest_bits = (8 * g.n) as u32; - let mut sec = SecurityTable::new(b.lifetime, b.security, g.n, g.h_max as u32, g.k_max, g.a_max); + let mut sec = SecurityTable::new(b.lifetime, b.security, g.n, g.h.hi as u32, g.k.hi, g.a.hi); // Every (scheme, h, d, a, k, w, dropped) key is reached exactly once, so // rows need no deduplication, only a bound: budgets loose enough to admit // millions of them would otherwise be held in memory to print a dozen. @@ -273,18 +340,18 @@ pub fn search(b: &Budgets, g: &Grid, st: &mut Stats) -> Vec { let w = 1u64 << bits; // WOTS-TW has no counter to grind, so it cannot drop chains, and // WOTS+C has to keep at least one. - let max_dropped = if scheme.wots_c() { - g.max_dropped.min((8 * g.n / bits).saturating_sub(1)) + let dropped_range = if scheme.wots_c() { + g.dropped.within((8 * g.n / bits).saturating_sub(1)) } else { - 0 + 0..=0 }; - for dropped in 0..=max_dropped { + for dropped in dropped_range { let probe = params( g, scheme, - g.h_max.max(1), + g.h.hi.max(1), 1, - g.a_min, + g.a.lo, if scheme.fors_c() { 2 } else { 1 }, w, dropped, @@ -292,18 +359,18 @@ pub fn search(b: &Budgets, g: &Grid, st: &mut Stats) -> Vec { let Some(l) = probe.chains() else { continue }; let table = scheme.wots_c().then(|| NuTable::new(l, w, digest_bits)); let min_trials = table.as_ref().map_or(0, |t| t.min_trials()); - for h in g.h_min..=g.h_max { - for d in 1..=h.min(g.d_max) { + for h in g.h.iter() { + for d in g.d.within(h) { st.grid += 1; // Layer profiles first: they need no a or k, and the // keygen budget alone usually settles the question. - let Some(room) = room(b, ¶ms(g, scheme, h, d, g.a_min, 1, w, dropped)) else { + let Some(room) = room(b, g, ¶ms(g, scheme, h, d, g.a.lo, 1, w, dropped)) else { st.keygen_pruned += 1; continue; }; st.profiles += room.profiles.len() as u64; - for a in g.a_min..=g.a_max { - for k in 1..=g.k_max { + for a in g.a.iter() { + for k in g.k.iter() { if !sec.is_secure(h as u32, k, a) { st.insecure += 1; continue; @@ -313,7 +380,7 @@ pub fn search(b: &Budgets, g: &Grid, st: &mut Stats) -> Vec { st.skeletons += 1; // The signature grows with k, so once it is too // big it stays too big. - if sk.sig_bytes > b.max_size { + if sk.sig_bytes > b.max_size() { st.size_pruned += 1; break; } @@ -333,9 +400,14 @@ pub fn search(b: &Budgets, g: &Grid, st: &mut Stats) -> Vec { st.sign_pruned += 1; continue; } + let sums = match g.sums { + Sums::Sweep => 0..=sk.max_swn, + Sums::Mean => sk.default_swn..=sk.default_swn, + Sums::Pinned(s) => s..=s, + }; st.swept += 1; let mut winner: Option<(u64, u64)> = None; - for swn in 0..=sk.max_swn { + for swn in sums { if table.trials(swn) > max_trials { continue; } @@ -377,7 +449,7 @@ fn record(rows: &mut Vec, st: &mut Stats, b: &Budgets, sk: &Skeleton, .profiles .iter() .filter(|lay| { - b.of(sk.sign(lay, trials)) <= b.max_sign && b.of(sk.sign_cached(lay, trials)) <= b.max_sign_cached + b.of(sk.sign(lay, trials)) <= b.max_sign() && b.of(sk.sign_cached(lay, trials)) <= b.max_sign_cached() }) .min_by_key(|lay| (b.of(sk.sign_cached(lay, trials)), b.of(sk.sign(lay, trials)))) else { @@ -398,42 +470,37 @@ fn record(rows: &mut Vec, st: &mut Stats, b: &Budgets, sk: &Skeleton, } } -/// Axes where a result sits at the top of a hardcoded range. +/// Axes where a result sits at the top of a searched range. /// /// Such a result may be limited by the range rather than by the budgets, so it -/// is worth raising the range and rerunning before believing it. +/// is worth raising the range and rerunning before believing it. An axis pinned +/// to one value was pinned deliberately and says nothing. pub fn edges(g: &Grid, c: &Candidate) -> Vec { - let bits = ( + let bits = Span::new( g.chain_bits.iter().copied().min().unwrap_or(0), g.chain_bits.iter().copied().max().unwrap_or(0), ); - // (axis, value, floor, limit, what to raise). An axis with a single value in - // range was pinned deliberately, so it gets no warning. let at = [ - ("h", c.params.h, g.h_min, g.h_max, "H_MAX / --h-max"), - ("d", c.params.d, 1, g.d_max, "D_MAX / --d-max"), - ("a", c.params.a, g.a_min, g.a_max, "A_MAX / --a-max"), - ("k", c.params.k, 1, g.k_max, "K_MAX / --k-max"), - ( - "chain_bits", - c.costs.chain_bits, - bits.0, - bits.1, - "CHAIN_BITS_MAX / --chain-bits", - ), + ("h", c.params.h, g.h, "H_MAX / --height"), + ("d", c.params.d, g.d, "D_MAX / --layers"), + ("a", c.params.a, g.a, "A_MAX / -a"), + ("k", c.params.k, g.k, "K_MAX / -k"), + ("chain_bits", c.costs.chain_bits, bits, "CHAIN_BITS_MAX / --chain-bits"), ( "dropped_chains", c.params.dropped_chains, - 0, - g.max_dropped, - "DROPPED_MAX / --max-dropped", + g.dropped, + "DROPPED_MAX / --drop-chains", ), ]; at.iter() - .filter(|(_, v, floor, limit, _)| limit > floor && *v + 1 >= *limit) - .map(|(axis, v, _, limit, what)| { - let where_ = if v >= limit { "at" } else { "one step below" }; - format!("{axis} = {v} is {where_} the top of the searched range ({limit}): raise {what} and rerun") + .filter(|(_, v, span, _)| !span.pinned() && *v + 1 >= span.hi) + .map(|(axis, v, span, what)| { + let where_ = if *v >= span.hi { "at" } else { "one step below" }; + format!( + "{axis} = {v} is {where_} the top of the searched range ({}): raise {what} and rerun", + span.hi + ) }) .collect() } @@ -449,13 +516,13 @@ pub fn naive_search(b: &Budgets, g: &Grid) -> Vec { for &scheme in &g.schemes { for &bits in &g.chain_bits { let w = 1u64 << bits; - let max_dropped = if scheme.wots_c() { g.max_dropped } else { 0 }; - for dropped in 0..=max_dropped { - for h in g.h_min..=g.h_max { - for d in 1..=h.min(g.d_max) { + let dropped_range = if scheme.wots_c() { g.dropped.iter() } else { 0..=0 }; + for dropped in dropped_range { + for h in g.h.iter() { + for d in g.d.within(h) { for h_top in 1..=h { - for a in g.a_min..=g.a_max { - for k in 1..=g.k_max { + for a in g.a.iter() { + for k in g.k.iter() { let p = Params { h_top: Some(h_top), ..params(g, scheme, h, d, a, k, w, dropped) diff --git a/doc/sphincs/params_selection/tests/goldens.rs b/doc/sphincs/params_selection/tests/goldens.rs index c9cb04b11..086bf80fa 100644 --- a/doc/sphincs/params_selection/tests/goldens.rs +++ b/doc/sphincs/params_selection/tests/goldens.rs @@ -12,7 +12,7 @@ use sphincs_params::cost::{Convention, Encoding, NuTable, Scheme}; use sphincs_params::params::{Layers, Params, Profile, Skeleton, costs}; -use sphincs_params::search::{Budgets, Grid, LEVEL1_BITS, Stats, Unit, naive_search, search}; +use sphincs_params::search::{Budgets, Grid, LEVEL1_BITS, Span, Stats, Unit, naive_search, search}; use sphincs_params::security::{forgery_exponent, security_bits}; fn params(scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, cached_midstate: bool) -> Params { @@ -333,10 +333,10 @@ fn half_top_cache_is_a_saving_and_reduces_to_the_full_tree() { fn budgets(lifetime: u32, keygen: u64, sign: u64, cached: u64, size: u64) -> Budgets { Budgets { lifetime, - max_keygen: keygen, - max_sign: sign, - max_sign_cached: cached, - max_size: size, + keygen: Some(keygen), + sign: Some(sign), + sign_cached: Some(cached), + size: Some(size), security: LEVEL1_BITS, unit: Unit::Hashes, } @@ -348,13 +348,12 @@ fn search_agrees_with_a_naive_oracle() { let b = budgets(20, 3_000_000, 10_000_000, 10_000_000, 4_000); let g = Grid { schemes: vec![Scheme::Wc, Scheme::WcFc], - h_min: 20, - h_max: 20, - a_min: 14, - a_max: 16, - k_max: 14, + h: Span::pin(20), + a: Span::new(14, 16), + k: Span::new(1, 14), chain_bits: vec![4], - max_dropped: 1, + dropped: Span::new(0, 1), + h_top: Some(Span::new(1, 20)), ..Default::default() }; let mut st = Stats::default(); @@ -386,7 +385,7 @@ fn search_finds_and_improves_on_the_reports_bold_row() { let b = budgets(40, 1_100_000, 6_000_000, 6_000_000, 4_400); let g = Grid { chain_bits: vec![4, 8], - max_dropped: 0, + dropped: Span::pin(0), ..Default::default() }; let mut st = Stats::default(); From 166177756987ba871620fb300315b674388b4bde Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 14:41:06 +0200 Subject: [PATCH 07/31] doc/sphincs: name the table's columns, and say what they mean sign$ and state were shorthand only I could read. They are now `cached` and `cache B`, and the table carries a two-line legend for the columns that are this project's own rather than the report's: the top layer height, the two signing costs and the state one of them assumes, and which unit everything is in. --- doc/sphincs/params_selection/README.md | 3 +-- doc/sphincs/params_selection/src/main.rs | 3 ++- doc/sphincs/params_selection/src/report.rs | 15 +++++++++++++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/doc/sphincs/params_selection/README.md b/doc/sphincs/params_selection/README.md index a1de5eaf4..d05d7a82a 100644 --- a/doc/sphincs/params_selection/README.md +++ b/doc/sphincs/params_selection/README.md @@ -6,8 +6,7 @@ One command. Give a parameter to pin it, leave it out to search it: ```sh cd doc/sphincs/params_selection -cargo run --release -- --lifetime 40 --scheme W+C_F+C --height 40 --layers 5 --top-height 8 \ - -a 14 -k 11 -w 256 --drop-chains 0 --swn 2040 +cargo run --release -- --lifetime 40 --scheme W+C_F+C --height 40 --layers 5 --top-height 8 -a 14 -k 11 -w 256 --drop-chains 0 --swn 2040 ``` That pins everything, so it just costs that one set (the report's bold 2^40 row: 4356 bytes, 10402 hashes to verify). Leave axes out and they get searched instead, against whichever budgets you set: diff --git a/doc/sphincs/params_selection/src/main.rs b/doc/sphincs/params_selection/src/main.rs index 958453193..292844048 100644 --- a/doc/sphincs/params_selection/src/main.rs +++ b/doc/sphincs/params_selection/src/main.rs @@ -2,7 +2,7 @@ //! and everything left over gets searched. use sphincs_params::cost::{Convention, SCHEMES, Scheme}; -use sphincs_params::report::{report, table, utilization}; +use sphincs_params::report::{legend, report, table, utilization}; use sphincs_params::search::{ A_MAX, Budgets, CHAIN_BITS_MAX, D_MAX, DROPPED_MAX, Grid, H_MAX, K_MAX, LEVEL1_BITS, Span, Stats, Sums, Unit, edges, search, @@ -255,6 +255,7 @@ fn run(argv: &[String]) -> Result { unit.label() ); println!("{}\n", table(&b, &found[..top.min(found.len())])); + println!("{}\n", legend(&b)); } let best = &found[0]; diff --git a/doc/sphincs/params_selection/src/report.rs b/doc/sphincs/params_selection/src/report.rs index f2a278c87..7801556c1 100644 --- a/doc/sphincs/params_selection/src/report.rs +++ b/doc/sphincs/params_selection/src/report.rs @@ -121,8 +121,8 @@ const COLUMNS: [(&str, usize); 16] = [ ("size", 6), ("keygen", 8), ("sign", 8), - ("sign$", 8), - ("state", 7), + ("cached", 8), + ("cache B", 7), ]; fn cells(b: &Budgets, c: &Candidate) -> Vec { @@ -147,6 +147,17 @@ fn cells(b: &Budgets, c: &Candidate) -> Vec { ] } +/// What the abbreviated columns mean, since several of them are this project's +/// own and not the report's. +pub fn legend(b: &Budgets) -> String { + format!( + "ht = top layer height, cb = log2(w), drop = chains dropped beyond the pinned digest bits, l = chains signed, \ + S_wn = target digit sum\nsign / cached = signing without and with the top tree's half top in state, \ + cache B of it; verify, keygen and both signs in {}", + b.unit.label() + ) +} + pub fn table(b: &Budgets, cands: &[Candidate]) -> String { let head: Vec = COLUMNS.iter().map(|(name, w)| format!("{name:>w$}")).collect(); let head = head.join(" "); From 11798da6c9e0ba843a21fdf28db4c20b181b982a Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 14:41:56 +0200 Subject: [PATCH 08/31] doc/sphincs: call the column sign-cached, matching its budget flag Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/params_selection/src/report.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/sphincs/params_selection/src/report.rs b/doc/sphincs/params_selection/src/report.rs index 7801556c1..26f4025ec 100644 --- a/doc/sphincs/params_selection/src/report.rs +++ b/doc/sphincs/params_selection/src/report.rs @@ -121,7 +121,7 @@ const COLUMNS: [(&str, usize); 16] = [ ("size", 6), ("keygen", 8), ("sign", 8), - ("cached", 8), + ("sign-cached", 11), ("cache B", 7), ]; @@ -152,7 +152,7 @@ fn cells(b: &Budgets, c: &Candidate) -> Vec { pub fn legend(b: &Budgets) -> String { format!( "ht = top layer height, cb = log2(w), drop = chains dropped beyond the pinned digest bits, l = chains signed, \ - S_wn = target digit sum\nsign / cached = signing without and with the top tree's half top in state, \ + S_wn = target digit sum\nsign / sign-cached = signing without and with the top tree's half top in state, \ cache B of it; verify, keygen and both signs in {}", b.unit.label() ) From 17d4c90757bdc64f613824be053eed9f46fb3ac9 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 14:49:50 +0200 Subject: [PATCH 09/31] doc/sphincs: count compressions only, one per 64 bytes of hash input Drops the hashes/compressions unit switch and the FIPS 205 SHA-2 layout it selected between. There is one rule now: a hash of P (n bytes), a tweak (n bytes) and a payload costs ceil((2n + payload) / 64) compression calls, since both BLAKE2s and the length-prefixed SHA-256 of primitives::sha2 absorb 64 bytes per call and spend nothing on padding. At n = 16 that is one compression for a Merkle node (two 16-byte children fill a block exactly) and one for a WOTS chain step, two for the message digest (doc/xmss's IncEnc hashes 32 bytes of prefix, a 32-byte message, 24 bytes of randomness and 8 of padding), and ceil((32 + 16m) / 64) for compressing m hash values. It changes no number. That rule and the report's ceil((22*8 + 128m + 65)/512) are the same function for every m from 1 to 4000, checked as a golden, so the sage fixtures still pin the model and the report's compression columns are still the yardstick. The hash counts stay in Cost, unreported, only so the 18 WOTS/FORS rows of the report's Tables 1 and 2 can keep checking a second projection of the same walk. The report and the search table now carry one column instead of two, budgets are compressions without saying so at every mention, and the grinding line reports what grinding costs rather than how many trials it takes. Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/params_selection/README.md | 2 +- doc/sphincs/params_selection/src/cost.rs | 91 +++++++++------- doc/sphincs/params_selection/src/lib.rs | 8 +- doc/sphincs/params_selection/src/main.rs | 44 +++----- doc/sphincs/params_selection/src/params.rs | 56 +++++----- doc/sphincs/params_selection/src/report.rs | 52 ++++----- doc/sphincs/params_selection/src/search.rs | 27 +---- doc/sphincs/params_selection/tests/goldens.rs | 101 +++++++++--------- 8 files changed, 179 insertions(+), 202 deletions(-) diff --git a/doc/sphincs/params_selection/README.md b/doc/sphincs/params_selection/README.md index d05d7a82a..d50003d97 100644 --- a/doc/sphincs/params_selection/README.md +++ b/doc/sphincs/params_selection/README.md @@ -15,7 +15,7 @@ That pins everything, so it just costs that one set (the report's bold 2^40 row: cargo run --release -- --lifetime 30 --max-keygen 2e6 --max-sign 6e6 --max-sign-cached 4e6 --max-size 4000 ``` -`--max-sign-cached` budgets signing with the top XMSS tree's half top kept as signer state, which costs sqrt storage for a sqrt-cost top tree. Since size and verification depend only on `(h, d)` and not on how the layers divide `h`, a taller top layer is free on both and cheaper to sign with the cache: compare `--top-height 8` against `--top-height 15` at `--height 40 --layers 5`. +Every cost is compression calls, one per 64 bytes of hash input: a Merkle node or a WOTS chain step is one, the message digest two, compressing `m` hash values `ceil((2n + mn) / 64)`. `--max-sign-cached` budgets signing with the top XMSS tree's half top kept as signer state, which costs sqrt storage for a sqrt-cost top tree. Since size and verification depend only on `(h, d)` and not on how the layers divide `h`, a taller top layer is free on both and cheaper to sign with the cache: compare `--top-height 8` against `--top-height 15` at `--height 40 --layers 5`. `cargo run --release --` with no arguments prints every flag and its default. `cargo test --release` runs the goldens: the upstream sage fixtures, the report's own tables, and a naive search oracle that skips nothing. diff --git a/doc/sphincs/params_selection/src/cost.rs b/doc/sphincs/params_selection/src/cost.rs index 696c10989..b368c5797 100644 --- a/doc/sphincs/params_selection/src/cost.rs +++ b/doc/sphincs/params_selection/src/cost.rs @@ -10,10 +10,12 @@ use std::ops::{Add, Mul, Sub}; /// The WOTS+C grinding counter, carried once per hypertree layer. pub const COUNTER_BYTES: u64 = 4; -/// A cost in both units the report uses. +/// What something costs. /// -/// `hashes` counts tweakable-hash and PRF invocations (its "hash" columns), -/// `compressions` counts SHA-256 compression calls (its Compr. columns). +/// `compressions` is the number everything here is measured in and the only one +/// reported. `hashes`, the number of tweakable-hash and PRF invocations, is +/// carried alongside it only because the report publishes hash counts too, so +/// `tests/goldens` can check this model against both of its columns. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Cost { pub hashes: u64, @@ -50,50 +52,67 @@ impl Mul for Cost { } } -/// Compression calls charged to each kind of hash invocation. +/// One compression per 64 bytes of hash input. +pub const BLOCK: u64 = 64; + +/// The message a signature covers: a 256-bit digest of it. +pub const MESSAGE_BYTES: u64 = 32; + +/// What each kind of hash costs, in compression calls. /// -/// `cached_midstate` is the FIPS 205 SHA-2 layout with the PK.seed midstate -/// cached; without it every call pays for its full input. +/// Every hash here is `Th(P, tweak, payload)`, whose input is the n-byte public +/// parameter, the n-byte tweak, and then the payload, and the compression +/// function takes 64 bytes of it at a time. BLAKE2s absorbs 64 bytes per call +/// and carries the byte counter and final-block flag as compression inputs +/// rather than as a block, so nothing is spent on padding; SHA-256 under the +/// length-prefixed Merkle-Damgard of `primitives::sha2` behaves the same way. +/// +/// At n = 16 that makes a chain step and a Merkle node one compression each, +/// the message hash two, and the compression of `m` hash values +/// `ceil((32 + 16m) / 64)`. Which is, for every m, exactly what the report's +/// SHA-2 layout with the PK.seed midstate cached comes to, so its published +/// compression counts are still the yardstick in `tests/goldens`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Convention { - pub cached_midstate: bool, +pub struct Blocks { + pub n: u64, } -impl Default for Convention { - fn default() -> Self { - Self { cached_midstate: true } +impl Blocks { + pub const fn new(n: u64) -> Self { + Self { n } } -} - -impl Convention { - /// PK.seed + ADRS + one n-byte value. - pub const fn th1(self) -> u64 { - 1 + /// Compressions for a hash over `payload` bytes. + pub const fn of(&self, payload: u64) -> u64 { + (2 * self.n + payload).div_ceil(BLOCK) + } + /// A secret key element from the seed. + pub const fn prf(&self) -> u64 { + self.of(self.n) } - /// ... and the 4-byte WOTS+C counter. - pub const fn th1c(self) -> u64 { - 1 + /// One step along a WOTS chain. + pub const fn chain_step(&self) -> u64 { + self.of(self.n) } - /// Two n-byte children. - pub const fn th2(self) -> u64 { - if self.cached_midstate { 1 } else { 2 } + /// The same, plus the WOTS+C counter the verifier hashes in once per layer. + pub const fn chain_step_with_counter(&self) -> u64 { + self.of(self.n + COUNTER_BYTES) } - /// PK.seed + PK.root + R + message digest. - pub const fn hmsg(self) -> u64 { - 2 + /// One Merkle node from its two children. + pub const fn merkle_node(&self) -> u64 { + self.of(2 * self.n) } - /// SK.prf + opt + message. - pub const fn prfmsg(self) -> u64 { - 2 + /// Compressing `values` hash values into one: a WOTS public key, or the + /// FORS roots. + pub const fn compress(&self, values: u64) -> u64 { + self.of(values * self.n) } - /// PK.seed + SK.seed + ADRS. - pub const fn prf(self) -> u64 { - 1 + /// The randomized message digest, over R, PK.root and the message. + pub const fn message_hash(&self) -> u64 { + self.of(2 * self.n + MESSAGE_BYTES) } - /// Compressions for a tweakable hash over `m` n-byte values. - pub const fn th(self, m: u64, n: u64) -> u64 { - let prefix = if self.cached_midstate { 22 * 8 } else { 8 * (n + 12) }; - (prefix + 8 * n * m + 65).div_ceil(512) + /// Deriving that randomness from the secret seed and the message. + pub const fn message_prf(&self) -> u64 { + self.of(self.n + MESSAGE_BYTES) } } diff --git a/doc/sphincs/params_selection/src/lib.rs b/doc/sphincs/params_selection/src/lib.rs index 3fd31eb76..bf3abe272 100644 --- a/doc/sphincs/params_selection/src/lib.rs +++ b/doc/sphincs/params_selection/src/lib.rs @@ -35,9 +35,11 @@ //! as signer state, which is `sqrt(2^h')` of storage for a `sqrt(2^h')` top-tree //! cost per signature. //! -//! Every cost comes in two units, matching the report's tables: `hashes` counts -//! tweakable-hash and PRF invocations, `compressions` counts SHA-256 compression -//! calls under the FIPS 205 SHA-2 layout with the PK.seed midstate cached. +//! Everything is counted in compression calls, one per 64 bytes of hash input: +//! a Merkle node or a WOTS chain step is one, the message digest two, and +//! compressing `m` hash values `ceil((2n + mn) / 64)`. See [`cost::Blocks`], +//! which also notes that this is the same function as the report's SHA-2 layout +//! with the PK.seed midstate cached, so its published counts still pin it. //! //! [`search::search`] inverts the question: given a lifetime and a budget for //! keygen, signing (both flavours) and size, it enumerates the space and returns diff --git a/doc/sphincs/params_selection/src/main.rs b/doc/sphincs/params_selection/src/main.rs index 292844048..06ebc82cc 100644 --- a/doc/sphincs/params_selection/src/main.rs +++ b/doc/sphincs/params_selection/src/main.rs @@ -1,11 +1,11 @@ //! One command: pin the parameters you know, budget the costs you care about, //! and everything left over gets searched. -use sphincs_params::cost::{Convention, SCHEMES, Scheme}; +use sphincs_params::cost::{SCHEMES, Scheme}; use sphincs_params::report::{legend, report, table, utilization}; use sphincs_params::search::{ - A_MAX, Budgets, CHAIN_BITS_MAX, D_MAX, DROPPED_MAX, Grid, H_MAX, K_MAX, LEVEL1_BITS, Span, Stats, Sums, Unit, - edges, search, + A_MAX, Budgets, CHAIN_BITS_MAX, D_MAX, DROPPED_MAX, Grid, H_MAX, K_MAX, LEVEL1_BITS, Span, Stats, Sums, edges, + search, }; const USAGE: &str = "\ @@ -38,20 +38,18 @@ searched only against a budget that bounds it; with none they take the value the report's own parameter sets use, shown above after the comma. -n N hash output in bytes [16] -budgets, all optional: an unset one is no limit - --max-keygen N hashes at key generation - --max-sign N hashes at signing - --max-sign-cached N hashes at signing with the top tree's half top cached +budgets, all optional: an unset one is no limit. Every cost is counted in +compression calls, one per 64 bytes of hash input. + --max-keygen N compressions at key generation + --max-sign N compressions at signing + --max-sign-cached N compressions at signing with the top tree's half top + kept in state --max-size B signature bytes --security BITS classical security floor [128, NIST level 1] - --unit U hashes | compressions, for the budgets and the - objective alike [hashes] other --cache-height C cached top-tree level, above the leaves [half of h_top] --cache-level-only cache one level, not it and everything above - --uncached charge every hash for its full input, rather than - caching the PK.seed midstate --top N rows of the table to print [15] --stats report how much of the space was visited @@ -80,7 +78,7 @@ fn main() -> std::process::ExitCode { /// Flags and their values, repeatable flags kept in order. struct Args(Vec<(String, Option)>); -const NO_VALUE: [&str; 4] = ["--uncached", "--cache-level-only", "--stats", "--help"]; +const NO_VALUE: [&str; 3] = ["--cache-level-only", "--stats", "--help"]; impl Args { fn parse(argv: &[String]) -> Result { @@ -177,11 +175,6 @@ impl Args { fn run(argv: &[String]) -> Result { let args = Args::parse(argv)?; let lifetime = args.num("--lifetime")?.ok_or("--lifetime is required")?; - let unit = match args.get("--unit").unwrap_or("hashes") { - "hashes" => Unit::Hashes, - "compressions" => Unit::Compressions, - other => return Err(format!("--unit: expected hashes or compressions, got {other}")), - }; let b = Budgets { lifetime: lifetime as u32, keygen: args.num("--max-keygen")?, @@ -191,7 +184,6 @@ fn run(argv: &[String]) -> Result { security: args.get("--security").map_or(Ok(LEVEL1_BITS), |s| { s.parse().map_err(|_| format!("--security: expected a number, got {s}")) })?, - unit, }; // A higher target sum, dropped chains and a taller top tree all buy cheaper // verification with signer work, so with nothing bounding the signer they @@ -249,13 +241,9 @@ fn run(argv: &[String]) -> Result { } else { format!("{} feasible sets", found.len()) }; - println!( - "{kept}, best {} by verification {}:\n", - top.min(found.len()), - unit.label() - ); - println!("{}\n", table(&b, &found[..top.min(found.len())])); - println!("{}\n", legend(&b)); + println!("{kept}, best {} by verification cost:\n", top.min(found.len())); + println!("{}\n", table(&found[..top.min(found.len())])); + println!("{}\n", legend()); } let best = &found[0]; @@ -269,13 +257,9 @@ fn run(argv: &[String]) -> Result { if !use_.is_empty() || !edges(&g, best).is_empty() { println!(); } - // The convention and the cache split are not searched, so they ride here - // rather than in the grid. + // The cache split is not searched, so it rides here rather than in the grid. let shown = sphincs_params::params::Params { cache_height: args.num("--cache-height")?, - convention: Convention { - cached_midstate: !args.flag("--uncached"), - }, ..best.params }; let costs = sphincs_params::params::costs(shown, best.costs.swn).ok_or("inconsistent parameters")?; diff --git a/doc/sphincs/params_selection/src/params.rs b/doc/sphincs/params_selection/src/params.rs index 876cedf4a..45b613b6f 100644 --- a/doc/sphincs/params_selection/src/params.rs +++ b/doc/sphincs/params_selection/src/params.rs @@ -1,6 +1,6 @@ //! One parameter set, and the costs it implies. -use crate::cost::{COUNTER_BYTES, Convention, Cost, Encoding, NuTable, Scheme}; +use crate::cost::{Blocks, COUNTER_BYTES, Cost, Encoding, NuTable, Scheme}; /// A SPHINCS+ parameter set. `q_s` is not part of it: see [`crate::security`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -27,7 +27,6 @@ pub struct Params { pub cache_height: Option, /// Cache one level rather than it and everything above. pub cache_level_only: bool, - pub convention: Convention, } /// The height of every XMSS tree in the hypertree. @@ -163,12 +162,17 @@ impl Params { l1 * (self.w - 1) + l2 * (self.w - 1) - digit_sum } + /// The compression counts of the hashes this parameter set uses. + pub fn blocks(&self) -> Blocks { + Blocks::new(self.n) + } + /// One WOTS key pair, plus the compression of its `l` chain ends into a leaf. fn wots_leaf(&self, l: u64) -> Cost { - let cv = self.convention; + let b = self.blocks(); Cost::new( l + l * (self.w - 1) + 1, - l * cv.prf() + l * (self.w - 1) * cv.th1() + cv.th(l, self.n), + l * b.prf() + l * (self.w - 1) * b.chain_step() + b.compress(l), ) } } @@ -193,10 +197,10 @@ impl Layers { let profile = p.profile()?; let l = p.chains()?; let leaf = p.wots_leaf(l); - let cv = p.convention; + let b = p.blocks(); let tree = |height: u64| { let leaves = 1u64 << height; - leaf * leaves + Cost::new(leaves - 1, (leaves - 1) * cv.th2()) + leaf * leaves + Cost::new(leaves - 1, (leaves - 1) * b.merkle_node()) }; let top = tree(profile.h_top); let lower = tree(profile.tall) * profile.n_tall + tree(profile.short) * profile.n_short; @@ -222,7 +226,7 @@ impl Layers { let mut cached = tree(c); let cache_bytes; if p.cache_level_only { - cached = cached + Cost::new(stored_level - 1, (stored_level - 1) * cv.th2()); + cached = cached + Cost::new(stored_level - 1, (stored_level - 1) * b.merkle_node()); cache_bytes = stored_level * p.n; } else { cache_bytes = (2 * stored_level - 1) * p.n; @@ -254,7 +258,8 @@ pub struct Skeleton { pub max_swn: u64, pub default_swn: u64, pub sig_bytes: u64, - pub fors_c_grinding: u64, + /// What FORS+C's digest grinding costs, zero for the other schemes. + pub fors_c_grinding: Cost, /// The `(a, k)` part of signing: growing the FORS trees, and any grinding /// FORS+C does. Common to both signing costs, cached or not. pub fors_part: Cost, @@ -285,7 +290,7 @@ impl Skeleton { let enc = p.encoding()?; let l = p.chains()?; let profile = p.profile()?; - let (n, cv, d) = (p.n, p.convention, p.d); + let (n, b, d) = (p.n, p.blocks(), p.d); let trees = p.scheme.trees(p.k); let t = 1u64 << p.a; @@ -294,10 +299,10 @@ impl Skeleton { let layer = l * n + if p.scheme.wots_c() { COUNTER_BYTES } else { 0 }; let sig_bytes = n + profile.total() * n + d * layer + trees * n + trees * p.a * n; - let msg_hash = Cost::new(2, cv.hmsg() + cv.prfmsg()); + let msg_hash = Cost::new(2, b.message_hash() + b.message_prf()); let fors_build = Cost::new( trees * t + trees * t + trees * (t - 1) + 1, - trees * t * cv.prf() + trees * t * cv.th1() + trees * (t - 1) * cv.th2() + cv.th(trees, n), + trees * t * b.prf() + trees * t * b.chain_step() + trees * (t - 1) * b.merkle_node() + b.compress(trees), ); // FORS+C grinds the digest until its last a bits vanish, so the last // FORS tree always opens leaf 0 and needs no authentication path. @@ -305,22 +310,22 @@ impl Skeleton { let fors_verify = Cost::new( trees + trees * p.a + 1, - trees * cv.th1() + trees * p.a * cv.th2() + cv.th(trees, n), + trees * b.chain_step() + trees * p.a * b.merkle_node() + b.compress(trees), ); - let auth = Cost::new(profile.total(), profile.total() * cv.th2()); - let mut verify_base = Cost::new(1, cv.hmsg()) + fors_verify + auth; + let auth = Cost::new(profile.total(), profile.total() * b.merkle_node()); + let mut verify_base = Cost::new(1, b.message_hash()) + fors_verify + auth; let mut verify_step = Cost::default(); let mut verify_worst_extra = Cost::default(); if p.scheme.wots_c() { // the digits sum to S_wn, so the remaining chain steps are fixed at // (w-1)*l - S_wn, and the counter is hashed once per layer - verify_base = verify_base + Cost::new(2, cv.th1c() + cv.th(l, n)) * d; - verify_step = Cost::new(1, cv.th1()) * d; + verify_base = verify_base + Cost::new(2, b.chain_step_with_counter() + b.compress(l)) * d; + verify_step = Cost::new(1, b.chain_step()) * d; } else { let avg = (p.w - 1) * l / 2; - verify_base = verify_base + Cost::new(avg + 1, avg * cv.th1() + cv.th(l, n)) * d; + verify_base = verify_base + Cost::new(avg + 1, avg * b.chain_step() + b.compress(l)) * d; let worst = p.wots_tw_worst_steps(); - verify_worst_extra = Cost::new(worst - avg, (worst - avg) * cv.th1()) * d; + verify_worst_extra = Cost::new(worst - avg, (worst - avg) * b.chain_step()) * d; } Some(Self { @@ -331,9 +336,9 @@ impl Skeleton { max_swn: (p.w - 1) * l, default_swn: if p.scheme.wots_c() { enc.default_swn() } else { 0 }, sig_bytes, - fors_c_grinding: if p.scheme.fors_c() { fors_grind.hashes } else { 0 }, + fors_c_grinding: if p.scheme.fors_c() { fors_grind } else { Cost::default() }, fors_part: fors_build + fors_grind, - grind_step: Cost::new(1, cv.th1c()), + grind_step: Cost::new(1, b.chain_step_with_counter()), verify_base, verify_step, verify_worst_extra, @@ -380,7 +385,7 @@ impl Skeleton { sign_cached: self.sign_cached(lay, trials), verify: self.verify(swn), verify_worst: self.verify_worst(swn), - wots_c_grinding: trials.saturating_mul(self.params.d), + wots_c_grinding: self.grinding(trials), fors_c_grinding: self.fors_c_grinding, cache_depth: lay.cache_depth, cache_bytes: lay.cache_bytes, @@ -403,14 +408,17 @@ pub struct Costs { pub sign_cached: Cost, pub verify: Cost, pub verify_worst: Cost, - pub wots_c_grinding: u64, - pub fors_c_grinding: u64, + /// Searching for admissible WOTS+C counters, across every layer. + pub wots_c_grinding: Cost, + /// Grinding the digest so FORS+C's last tree opens leaf zero. + pub fors_c_grinding: Cost, pub cache_depth: u64, pub cache_bytes: u64, } impl Costs { - pub fn grinding(&self) -> u64 { + /// Everything the signer spends on grinding rather than on trees. + pub fn grinding(&self) -> Cost { self.wots_c_grinding + self.fors_c_grinding } } diff --git a/doc/sphincs/params_selection/src/report.rs b/doc/sphincs/params_selection/src/report.rs index 26f4025ec..b43eaee4e 100644 --- a/doc/sphincs/params_selection/src/report.rs +++ b/doc/sphincs/params_selection/src/report.rs @@ -1,7 +1,7 @@ //! Human-readable output. use crate::params::{Costs, Params}; -use crate::search::{Budgets, Candidate, Unit}; +use crate::search::{Budgets, Candidate}; use crate::security::forgery_exponent; pub fn si(x: u64) -> String { @@ -44,9 +44,7 @@ pub fn report(p: &Params, c: &Costs, lifetime: u32) -> String { let cap = 8.0 * p.n as f64; let security = forgery.map_or(0.0, |f| f.min(cap)); let speedup = c.sign.hashes as f64 / c.sign_cached.hashes.max(1) as f64; - let row = |label: &str, x: crate::cost::Cost, note: String| { - format!("{label:<24}{:>12}{:>16}{note}", si(x.hashes), si(x.compressions)) - }; + let row = |label: &str, x: crate::cost::Cost, note: String| format!("{label:<24}{:>12}{note}", si(x.compressions)); let mut lines = vec![ format!( @@ -80,7 +78,7 @@ pub fn report(p: &Params, c: &Costs, lifetime: u32) -> String { }, format!("signature {} bytes", c.sig_bytes), String::new(), - format!("{:<24}{:>12}{:>16}", "", "hashes", "compressions"), + format!("{:<24}{:>12}", "", "compressions"), row("keygen", c.keygen, String::new()), row("sign (avg)", c.sign, String::new()), row( @@ -98,10 +96,10 @@ pub fn report(p: &Params, c: &Costs, lifetime: u32) -> String { } lines.push(String::new()); lines.push(format!( - "of signing, grinding accounts for {} hashes: {} for the WOTS+C counters, {} for the FORS+C digest", - si(c.grinding()), - si(c.wots_c_grinding), - si(c.fors_c_grinding) + "of signing, grinding accounts for {}: {} searching for WOTS+C counters, {} on the FORS+C digest", + si(c.grinding().compressions), + si(c.wots_c_grinding.compressions), + si(c.fors_c_grinding.compressions) )); lines.join("\n") } @@ -125,10 +123,10 @@ const COLUMNS: [(&str, usize); 16] = [ ("cache B", 7), ]; -fn cells(b: &Budgets, c: &Candidate) -> Vec { +fn cells(c: &Candidate) -> Vec { let (p, x) = (&c.params, &c.costs); vec![ - si(b.unit.of(x.verify)), + si(x.verify.compressions), p.scheme.label().to_string(), p.h.to_string(), p.d.to_string(), @@ -140,30 +138,28 @@ fn cells(b: &Budgets, c: &Candidate) -> Vec { x.l.to_string(), x.swn.map_or("-".to_string(), |s| s.to_string()), x.sig_bytes.to_string(), - si(b.unit.of(x.keygen)), - si(b.unit.of(x.sign)), - si(b.unit.of(x.sign_cached)), + si(x.keygen.compressions), + si(x.sign.compressions), + si(x.sign_cached.compressions), x.cache_bytes.to_string(), ] } /// What the abbreviated columns mean, since several of them are this project's /// own and not the report's. -pub fn legend(b: &Budgets) -> String { - format!( - "ht = top layer height, cb = log2(w), drop = chains dropped beyond the pinned digest bits, l = chains signed, \ - S_wn = target digit sum\nsign / sign-cached = signing without and with the top tree's half top in state, \ - cache B of it; verify, keygen and both signs in {}", - b.unit.label() - ) +pub fn legend() -> String { + "every cost in compression calls, one per 64 bytes of hash input; sign / sign-cached = signing without and \ + with the top tree's half top in state, cache B of it\nht = top layer height, cb = log2(w), drop = chains \ + dropped beyond the pinned digest bits, l = chains signed, S_wn = target digit sum" + .to_string() } -pub fn table(b: &Budgets, cands: &[Candidate]) -> String { +pub fn table(cands: &[Candidate]) -> String { let head: Vec = COLUMNS.iter().map(|(name, w)| format!("{name:>w$}")).collect(); let head = head.join(" "); let mut lines = vec![head.clone(), "-".repeat(head.len())]; for c in cands { - let row: Vec = cells(b, c) + let row: Vec = cells(c) .iter() .zip(COLUMNS) .map(|(cell, (_, w))| format!("{cell:>w$}", w = w)) @@ -176,9 +172,9 @@ pub fn table(b: &Budgets, cands: &[Candidate]) -> String { /// How much of each budget the candidate uses. Unset budgets say nothing. pub fn utilization(b: &Budgets, c: &Candidate) -> String { let used = [ - ("keygen", b.unit.of(c.costs.keygen), b.keygen), - ("sign", b.unit.of(c.costs.sign), b.sign), - ("sign cached", b.unit.of(c.costs.sign_cached), b.sign_cached), + ("keygen", c.costs.keygen.compressions, b.keygen), + ("sign", c.costs.sign.compressions, b.sign), + ("sign-cached", c.costs.sign_cached.compressions, b.sign_cached), ("size", c.costs.sig_bytes, b.size), ]; used.iter() @@ -187,7 +183,3 @@ pub fn utilization(b: &Budgets, c: &Candidate) -> String { .collect::>() .join(", ") } - -pub fn unit_label(u: Unit) -> &'static str { - u.label() -} diff --git a/doc/sphincs/params_selection/src/search.rs b/doc/sphincs/params_selection/src/search.rs index c61ed1946..b233f9654 100644 --- a/doc/sphincs/params_selection/src/search.rs +++ b/doc/sphincs/params_selection/src/search.rs @@ -55,27 +55,6 @@ pub const DROPPED_MAX: u64 = 16; /// NIST level 1, matching SLH-DSA's level 1 parameter sets. pub const LEVEL1_BITS: f64 = 128.0; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Unit { - Hashes, - Compressions, -} - -impl Unit { - pub fn of(self, c: Cost) -> u64 { - match self { - Unit::Hashes => c.hashes, - Unit::Compressions => c.compressions, - } - } - pub fn label(self) -> &'static str { - match self { - Unit::Hashes => "hashes", - Unit::Compressions => "compressions", - } - } -} - /// The values of one parameter to try. Pinned when `lo == hi`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Span { @@ -126,8 +105,6 @@ pub struct Budgets { pub size: Option, /// Classical security floor in bits. pub security: f64, - /// Unit of every budget above, and of the objective. - pub unit: Unit, } impl Budgets { @@ -147,8 +124,9 @@ impl Budgets { self.sign.is_some() || self.sign_cached.is_some() } + /// Everything is counted in compression calls: see [`crate::cost::Blocks`]. pub fn of(&self, c: Cost) -> u64 { - self.unit.of(c) + c.compressions } pub fn fits(&self, c: &Costs) -> bool { @@ -272,7 +250,6 @@ fn params(g: &Grid, scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, drop dropped_chains: dropped, cache_height: None, cache_level_only: g.cache_level_only, - convention: Default::default(), } } diff --git a/doc/sphincs/params_selection/tests/goldens.rs b/doc/sphincs/params_selection/tests/goldens.rs index 086bf80fa..7895f6dd8 100644 --- a/doc/sphincs/params_selection/tests/goldens.rs +++ b/doc/sphincs/params_selection/tests/goldens.rs @@ -10,12 +10,12 @@ //! * `doc/xmss/main.tex`, for the digest-cut geometry; //! * for the search, a naive oracle in this crate that skips nothing. -use sphincs_params::cost::{Convention, Encoding, NuTable, Scheme}; +use sphincs_params::cost::{Blocks, Encoding, NuTable, Scheme}; use sphincs_params::params::{Layers, Params, Profile, Skeleton, costs}; -use sphincs_params::search::{Budgets, Grid, LEVEL1_BITS, Span, Stats, Unit, naive_search, search}; +use sphincs_params::search::{Budgets, Grid, LEVEL1_BITS, Span, Stats, naive_search, search}; use sphincs_params::security::{forgery_exponent, security_bits}; -fn params(scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, cached_midstate: bool) -> Params { +fn params(scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64) -> Params { Params { scheme, h, @@ -28,7 +28,6 @@ fn params(scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, cached_midstat dropped_chains: 0, cache_height: None, cache_level_only: false, - convention: Convention { cached_midstate }, } } @@ -110,50 +109,47 @@ const FIXTURES_CACHED: [Fixture; 7] = [ ), ]; -/// The same under the uncached convention, from the fixtures' `uncached_spot`. -const FIXTURES_UNCACHED: [Fixture; 2] = [ - ( - Scheme::Spx, - 63, - 7, - 14, - 12, - 16, - None, - [7856, 292862, 2279391, 2387, 4123], - ), - ( - Scheme::Wc, - 44, - 4, - 8, - 16, - 16, - Some(240), - [4960, 1071102, 6381815, 1357, 1357], - ), -]; - #[test] fn matches_the_sage_fixtures() { - for (cached, rows) in [(true, &FIXTURES_CACHED[..]), (false, &FIXTURES_UNCACHED[..])] { - for &(scheme, h, d, k, a, w, swn, want) in rows { - let p = params(scheme, h, d, a, k, w, cached); - let c = costs(p, swn).expect("consistent parameters"); - let got = [ - c.sig_bytes, - c.keygen.compressions, - c.sign.compressions, - c.verify.compressions, - c.verify_worst.compressions, - ]; - assert_eq!( - got, - want, - "{} h={h} d={d} k={k} a={a} w={w} cached={cached}", - scheme.label() - ); - } + for &(scheme, h, d, k, a, w, swn, want) in &FIXTURES_CACHED { + let p = params(scheme, h, d, a, k, w); + let c = costs(p, swn).expect("consistent parameters"); + let got = [ + c.sig_bytes, + c.keygen.compressions, + c.sign.compressions, + c.verify.compressions, + c.verify_worst.compressions, + ]; + assert_eq!(got, want, "{} h={h} d={d} k={k} a={a} w={w}", scheme.label()); + } +} + +/// The compression rule: one call per 64 bytes of hash input, the input being +/// the n-byte public parameter, the n-byte tweak, and the payload. +#[test] +fn one_compression_per_64_bytes() { + let b = Blocks::new(16); + assert_eq!(b.merkle_node(), 1, "two 16-byte children fill one block exactly"); + assert_eq!(b.chain_step(), 1); + assert_eq!(b.chain_step_with_counter(), 1); + assert_eq!(b.prf(), 1); + // doc/xmss's IncEnc: 32 B of prefix, a 32 B message, 24 B of randomness + // and 8 B of padding + assert_eq!(b.message_hash(), 2); + assert_eq!(b.message_prf(), 2); + for m in 1..200 { + assert_eq!(b.compress(m), (32 + 16 * m).div_ceil(64), "compressing {m} hash values"); + } + // And it is the same function as the report's SHA-2 layout with the PK.seed + // midstate cached, ceil((22*8 + 128m + 65) / 512), which is why its + // published compression counts still pin this model. + for m in 1..4000u64 { + assert_eq!( + b.compress(m), + (22 * 8 + 128 * m + 65).div_ceil(512), + "against the report's layout at m={m}" + ); } } @@ -188,7 +184,7 @@ const REPORT_TABLE: [ReportRow; 18] = [ #[test] fn matches_the_report_tables() { for (scheme, h, d, a, k, w, swn, sigver, sigtime_e4, search) in REPORT_TABLE { - let p = params(scheme, h, d, a, k, w, true); + let p = params(scheme, h, d, a, k, w); let c = costs(p, swn).expect("consistent parameters"); let tag = format!("{} h={h} d={d} a={a} k={k} w={w} S={swn:?}", scheme.label()); assert_eq!(c.verify.hashes, sigver, "SigVer {tag}"); @@ -198,7 +194,7 @@ fn matches_the_report_tables() { "SigTime {tag}: got {got:.2}e4, want {sigtime_e4}e4" ); if let Some(want) = search { - assert_eq!(c.grinding(), want, "Exp. Search {tag}"); + assert_eq!(c.grinding().hashes, want, "Exp. Search {tag}"); } } } @@ -313,7 +309,7 @@ fn secure_k_form_an_up_set() { #[test] fn half_top_cache_is_a_saving_and_reduces_to_the_full_tree() { - let p = params(Scheme::WcFc, 40, 5, 14, 11, 256, true); + let p = params(Scheme::WcFc, 40, 5, 14, 11, 256); let c = costs(p, None).unwrap(); assert!(c.sign_cached.hashes < c.sign.hashes); // caching at the leaves is caching the whole tree: nothing left to rebuild @@ -338,7 +334,6 @@ fn budgets(lifetime: u32, keygen: u64, sign: u64, cached: u64, size: u64) -> Bud sign_cached: Some(cached), size: Some(size), security: LEVEL1_BITS, - unit: Unit::Hashes, } } @@ -415,7 +410,7 @@ fn a_taller_top_layer_is_free_on_size_and_verification() { // divide h, so only the signer's costs move. let uniform = Params { h_top: Some(8), - ..params(Scheme::WcFc, 40, 5, 14, 11, 256, true) + ..params(Scheme::WcFc, 40, 5, 14, 11, 256) }; let tall = Params { h_top: Some(15), @@ -450,7 +445,7 @@ fn a_taller_top_layer_is_free_on_size_and_verification() { fn skeleton_rejects_trees_that_do_not_fit_a_u64() { // 2^h' leaves has to be countable: without this the shift masks and a // 2^64-leaf tree reports the cost of a one-leaf tree. - let p = params(Scheme::WcFc, 64, 1, 14, 11, 256, true); + let p = params(Scheme::WcFc, 64, 1, 14, 11, 256); assert!(Skeleton::new(p).is_none()); assert!(Skeleton::new(Params { h: 63, ..p }).is_some()); assert!(Skeleton::new(Params { a: 64, ..p }).is_none()); @@ -463,7 +458,7 @@ fn skeleton_rejects_trees_that_do_not_fit_a_u64() { #[test] fn skeleton_rejects_inconsistent_parameters() { - let ok = params(Scheme::WcFc, 40, 5, 14, 11, 256, true); + let ok = params(Scheme::WcFc, 40, 5, 14, 11, 256); assert!(Skeleton::new(ok).is_some()); // d need not divide h: the layers just come out within one of each other let uneven = Skeleton::new(Params { d: 3, ..ok }).expect("d need not divide h"); From 445fbcc74b0e4278de974211007b5e3ba585fda2 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 15:15:20 +0200 Subject: [PATCH 10/31] doc/sphincs: --lifetime takes a signature count, not its log Same syntax as the budgets, so 16e6 means sixteen million signatures. The security sum never needed q_s to be a power of two: it carries the binomial term by its recurrence, so an f64 count works everywhere the log2 did, and non-power-of-two lifetimes are now expressible at all. Reported back as 2^40 when it is a power of two and as 16.000M (2^23.9) when it is not. Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/params_selection/README.md | 8 ++--- doc/sphincs/params_selection/src/main.rs | 29 +++++++++++----- doc/sphincs/params_selection/src/report.rs | 33 ++++++++++++++++--- doc/sphincs/params_selection/src/search.rs | 8 ++--- doc/sphincs/params_selection/src/security.rs | 23 ++++++------- doc/sphincs/params_selection/tests/goldens.rs | 25 +++++++------- 6 files changed, 81 insertions(+), 45 deletions(-) diff --git a/doc/sphincs/params_selection/README.md b/doc/sphincs/params_selection/README.md index d50003d97..325b27dc9 100644 --- a/doc/sphincs/params_selection/README.md +++ b/doc/sphincs/params_selection/README.md @@ -2,17 +2,17 @@ Security, signature size and hash counts for the WOTS/FORS schemes of "Hash-based Signature Schemes for Bitcoin" (Kudinov, Nick, Blockstream Research), and a search for the set that verifies cheapest under a given set of budgets. See `src/lib.rs` for what is modelled and what is deliberately not. -One command. Give a parameter to pin it, leave it out to search it: +One command. Give a parameter to pin it, leave it out to search it. Numbers may be written as `2e6`, including the lifetime, which is a signature count rather than its log: ```sh cd doc/sphincs/params_selection -cargo run --release -- --lifetime 40 --scheme W+C_F+C --height 40 --layers 5 --top-height 8 -a 14 -k 11 -w 256 --drop-chains 0 --swn 2040 +cargo run --release -- --lifetime 1e12 --scheme W+C_F+C --height 40 --layers 5 --top-height 8 -a 14 -k 11 -w 256 --drop-chains 0 --swn 2040 ``` -That pins everything, so it just costs that one set (the report's bold 2^40 row: 4356 bytes, 10402 hashes to verify). Leave axes out and they get searched instead, against whichever budgets you set: +That pins everything, so it just costs that one set: the report's bold 2^40 row, 4356 bytes and 10425 compressions to verify. Size and verification do not depend on the lifetime, only the security line does. Leave axes out and they get searched instead, against whichever budgets you set: ```sh -cargo run --release -- --lifetime 30 --max-keygen 2e6 --max-sign 6e6 --max-sign-cached 4e6 --max-size 4000 +cargo run --release -- --lifetime 24 --max-keygen 2e6 --max-sign 6e6 --max-sign-cached 4e6 --max-size 4000 ``` Every cost is compression calls, one per 64 bytes of hash input: a Merkle node or a WOTS chain step is one, the message digest two, compressing `m` hash values `ceil((2n + mn) / 64)`. `--max-sign-cached` budgets signing with the top XMSS tree's half top kept as signer state, which costs sqrt storage for a sqrt-cost top tree. Since size and verification depend only on `(h, d)` and not on how the layers divide `h`, a taller top layer is free on both and cheaper to sign with the cache: compare `--top-height 8` against `--top-height 15` at `--height 40 --layers 5`. diff --git a/doc/sphincs/params_selection/src/main.rs b/doc/sphincs/params_selection/src/main.rs index 06ebc82cc..48dd0898f 100644 --- a/doc/sphincs/params_selection/src/main.rs +++ b/doc/sphincs/params_selection/src/main.rs @@ -2,7 +2,7 @@ //! and everything left over gets searched. use sphincs_params::cost::{SCHEMES, Scheme}; -use sphincs_params::report::{legend, report, table, utilization}; +use sphincs_params::report::{legend, report, signatures, table, utilization}; use sphincs_params::search::{ A_MAX, Budgets, CHAIN_BITS_MAX, D_MAX, DROPPED_MAX, Grid, H_MAX, K_MAX, LEVEL1_BITS, Span, Stats, Sums, edges, search, @@ -17,7 +17,7 @@ Give a parameter to pin it, leave it out to search it. Pin them all and the run just costs that one set. Numbers may be written as 2e6. parameters - --lifetime L log2 of the signatures allowed per public key (required) + --lifetime Q signatures allowed per public key, e.g. 16e6 (required) --scheme S SPX | W+C | W+C_F+C, repeatable [all three] --height h total hypertree height [1..96] --layers d hypertree layers [1..32] @@ -54,9 +54,9 @@ other --stats report how much of the space was visited examples - sphincs_params --lifetime 30 --max-keygen 2e6 --max-sign 6e6 \\ + sphincs_params --lifetime 1e9 --max-keygen 2e6 --max-sign 6e6 \\ --max-sign-cached 4e6 --max-size 4000 - sphincs_params --lifetime 40 --height 40 --layers 5 -a 14 -k 11 -w 256 --swn 2040 + sphincs_params --lifetime 1e12 --height 40 --layers 5 -a 14 -k 11 -w 256 --swn 2040 "; fn main() -> std::process::ExitCode { @@ -118,6 +118,16 @@ impl Args { } /// Accepts 2e6 as well as 2000000. + fn float(&self, name: &str) -> Result, String> { + match self.get(name) { + None => Ok(None), + Some(s) => s + .parse::() + .map(Some) + .map_err(|_| format!("{name}: expected a number, got {s}")), + } + } + fn num(&self, name: &str) -> Result, String> { match self.get(name) { None => Ok(None), @@ -174,9 +184,9 @@ impl Args { fn run(argv: &[String]) -> Result { let args = Args::parse(argv)?; - let lifetime = args.num("--lifetime")?.ok_or("--lifetime is required")?; + let q_s = args.float("--lifetime")?.ok_or("--lifetime is required")?; let b = Budgets { - lifetime: lifetime as u32, + q_s, keygen: args.num("--max-keygen")?, sign: args.num("--max-sign")?, sign_cached: args.num("--max-sign-cached")?, @@ -227,8 +237,9 @@ fn run(argv: &[String]) -> Result { } if found.is_empty() { println!( - "nothing meets these constraints at {:.0}-bit security and q_s = 2^{lifetime}", - b.security + "nothing meets these constraints at {:.0}-bit security and q_s = {}", + b.security, + signatures(q_s) ); println!("--stats says where the space went; the binding budget is usually size or keygen"); return Ok(false); @@ -263,6 +274,6 @@ fn run(argv: &[String]) -> Result { ..best.params }; let costs = sphincs_params::params::costs(shown, best.costs.swn).ok_or("inconsistent parameters")?; - println!("{}", report(&shown, &costs, b.lifetime)); + println!("{}", report(&shown, &costs, b.q_s)); Ok(true) } diff --git a/doc/sphincs/params_selection/src/report.rs b/doc/sphincs/params_selection/src/report.rs index b43eaee4e..26a37e4eb 100644 --- a/doc/sphincs/params_selection/src/report.rs +++ b/doc/sphincs/params_selection/src/report.rs @@ -4,6 +4,27 @@ use crate::params::{Costs, Params}; use crate::search::{Budgets, Candidate}; use crate::security::forgery_exponent; +/// A signature count, as a power of two when it is one. +pub fn signatures(q_s: f64) -> String { + let log2 = q_s.log2(); + if (log2 - log2.round()).abs() < 1e-9 { + return format!("2^{}", log2.round() as i64); + } + for (unit, div) in [ + ("E", 1e18), + ("P", 1e15), + ("T", 1e12), + ("G", 1e9), + ("M", 1e6), + ("K", 1e3), + ] { + if q_s >= div { + return format!("{:.3}{unit} (2^{log2:.1})", q_s / div); + } + } + format!("{q_s:.0}") +} + pub fn si(x: u64) -> String { let f = x as f64; for (unit, div) in [("G", 1e9), ("M", 1e6), ("K", 1e3)] { @@ -39,8 +60,8 @@ pub fn encoding_line(p: &Params, c: &Costs) -> String { } /// The full picture of one parameter set. -pub fn report(p: &Params, c: &Costs, lifetime: u32) -> String { - let forgery = forgery_exponent(lifetime, p.h as u32, p.k, p.a); +pub fn report(p: &Params, c: &Costs, q_s: f64) -> String { + let forgery = forgery_exponent(q_s, p.h as u32, p.k, p.a); let cap = 8.0 * p.n as f64; let security = forgery.map_or(0.0, |f| f.min(cap)); let speedup = c.sign.hashes as f64 / c.sign_cached.hashes.max(1) as f64; @@ -48,8 +69,9 @@ pub fn report(p: &Params, c: &Costs, lifetime: u32) -> String { let mut lines = vec![ format!( - "scheme {} q_s = 2^{lifetime} n = {} bits", + "scheme {} q_s = {} n = {} bits", p.scheme.label(), + signatures(q_s), 8 * p.n ), format!("(h, d) ({}, {}) layer heights {}", p.h, p.d, c.profile), @@ -72,8 +94,9 @@ pub fn report(p: &Params, c: &Costs, lifetime: u32) -> String { cap as u64 ), None => format!( - "security none: q_s = 2^{lifetime} reuses every FORS instance ~2^{} times", - lifetime as i64 - p.h as i64 + "security none: q_s = {} reuses every FORS instance ~{:.0} times", + signatures(q_s), + q_s / 2f64.powi(p.h as i32) ), }, format!("signature {} bytes", c.sig_bytes), diff --git a/doc/sphincs/params_selection/src/search.rs b/doc/sphincs/params_selection/src/search.rs index b233f9654..f39d48460 100644 --- a/doc/sphincs/params_selection/src/search.rs +++ b/doc/sphincs/params_selection/src/search.rs @@ -96,8 +96,8 @@ pub enum Sums { #[derive(Clone, Copy, Debug)] pub struct Budgets { - /// log2 of the signatures allowed under one public key. - pub lifetime: u32, + /// Signatures allowed under one public key. Need not be a power of two. + pub q_s: f64, /// An unset budget is no limit. pub keygen: Option, pub sign: Option, @@ -306,7 +306,7 @@ fn room(b: &Budgets, g: &Grid, p: &Params) -> Option { pub fn search(b: &Budgets, g: &Grid, st: &mut Stats) -> Vec { let started = Instant::now(); let digest_bits = (8 * g.n) as u32; - let mut sec = SecurityTable::new(b.lifetime, b.security, g.n, g.h.hi as u32, g.k.hi, g.a.hi); + let mut sec = SecurityTable::new(b.q_s, b.security, g.n, g.h.hi as u32, g.k.hi, g.a.hi); // Every (scheme, h, d, a, k, w, dropped) key is reached exactly once, so // rows need no deduplication, only a bound: budgets loose enough to admit // millions of them would otherwise be held in memory to print a dozen. @@ -506,7 +506,7 @@ pub fn naive_search(b: &Budgets, g: &Grid) -> Vec { }; let Some(sk) = Skeleton::new(p) else { continue }; let Some(lay) = Layers::new(&p) else { continue }; - if crate::security::security_bits(b.lifetime, h as u32, k, a, g.n) < b.security { + if crate::security::security_bits(b.q_s, h as u32, k, a, g.n) < b.security { continue; } let table = scheme.wots_c().then(|| NuTable::new(sk.l, w, digest_bits)); diff --git a/doc/sphincs/params_selection/src/security.rs b/doc/sphincs/params_selection/src/security.rs index 1765d5fd5..5306cda4f 100644 --- a/doc/sphincs/params_selection/src/security.rs +++ b/doc/sphincs/params_selection/src/security.rs @@ -5,7 +5,7 @@ //! log2-space `f64`, as the report's own site does, which `tests/goldens` pins //! against the decimal values to better than 0.001 bits. -/// -log2 P(FORS subset forgery) after `q_s = 2^lifetime` signatures. +/// -log2 P(FORS subset forgery) after `q_s` signatures. /// /// An adversary that finds a hypertree leaf reused `r` times, and a message /// whose `k` FORS indices all point at leaves those `r` signatures already @@ -19,17 +19,18 @@ /// The binomial term is carried by its recurrence rather than built from /// `C(q_s, r)`, so `q_s = 2^64` costs no more than `q_s = 2^20`. /// +/// `q_s` need not be a power of two. +/// /// `None` when `q_s` so far exceeds the `2^h` leaves that there is no security /// left to quantify. -pub fn forgery_exponent(lifetime: u32, h: u32, k: u64, a: u64) -> Option { +pub fn forgery_exponent(q_s: f64, h: u32, k: u64, a: u64) -> Option { const LOG2_E: f64 = std::f64::consts::LOG2_E; // Expected times one FORS instance is reused. Past a few thousand the sum // needs more terms than it is worth: the answer is "none", not a number. - let lam = 2f64.powi(lifetime as i32 - h as i32); - if lam > 4096.0 { + let lam = q_s / 2f64.powi(h as i32); + if lam > 4096.0 || q_s < 1.0 { return None; } - let q_s = 2f64.powi(lifetime as i32); let log2_p = -(h as f64); let log2_1mp = (-2f64.powi(-(h as i32))).ln_1p() * LOG2_E; let ln_miss = (-1.0 / 2f64.powi(a as i32)).ln_1p(); // ln(1 - 1/t) @@ -60,8 +61,8 @@ pub fn forgery_exponent(lifetime: u32, h: u32, k: u64, a: u64) -> Option { /// A query aimed at a FORS forgery cannot double as a preimage query for a tree /// node or a WOTS chain (different tweaks), so the two attacks are independent /// strategies and the adversary simply takes the better one. -pub fn security_bits(lifetime: u32, h: u32, k: u64, a: u64, n: u64) -> f64 { - forgery_exponent(lifetime, h, k, a).map_or(0.0, |e| e.min(8.0 * n as f64)) +pub fn security_bits(q_s: f64, h: u32, k: u64, a: u64, n: u64) -> f64 { + forgery_exponent(q_s, h, k, a).map_or(0.0, |e| e.min(8.0 * n as f64)) } fn log2_sum_exp(a: f64, b: f64) -> f64 { @@ -77,7 +78,7 @@ fn log2_sum_exp(a: f64, b: f64) -> f64 { /// A search revisits the same triple once per `(scheme, d, chain_bits, /// dropped_chains)`, so without this the security sum dominates everything. pub struct SecurityTable { - lifetime: u32, + q_s: f64, target: f64, n: u64, h_max: u32, @@ -88,10 +89,10 @@ pub struct SecurityTable { } impl SecurityTable { - pub fn new(lifetime: u32, target: f64, n: u64, h_max: u32, k_max: u64, a_max: u64) -> Self { + pub fn new(q_s: f64, target: f64, n: u64, h_max: u32, k_max: u64, a_max: u64) -> Self { let cells = (h_max as usize + 1) * (k_max as usize + 1) * (a_max as usize + 1); Self { - lifetime, + q_s, target, n, h_max, @@ -113,6 +114,6 @@ impl SecurityTable { } fn compute(&self, h: u32, k: u64, a: u64) -> bool { - security_bits(self.lifetime, h, k, a, self.n) >= self.target + security_bits(self.q_s, h, k, a, self.n) >= self.target } } diff --git a/doc/sphincs/params_selection/tests/goldens.rs b/doc/sphincs/params_selection/tests/goldens.rs index 7895f6dd8..2e6e45b8c 100644 --- a/doc/sphincs/params_selection/tests/goldens.rs +++ b/doc/sphincs/params_selection/tests/goldens.rs @@ -257,7 +257,7 @@ fn digit_sum_counts_are_exact() { /// `(lifetime, h, k, a)` -> forgery exponent, from `security.sage` via the /// python port's 100-digit decimal sum. The f64 log-space version here has to /// land within a thousandth of a bit. -const SECURITY: [(u32, u32, u64, u64, f64); 13] = [ +const SECURITY: [(i32, u32, u64, u64, f64); 13] = [ (64, 63, 14, 12, 133.749299297), (40, 44, 8, 16, 128.283950447), (40, 40, 11, 14, 134.630384667), @@ -275,22 +275,23 @@ const SECURITY: [(u32, u32, u64, u64, f64); 13] = [ #[test] fn security_matches_the_decimal_sum() { - for (lifetime, h, k, a, want) in SECURITY { - let got = forgery_exponent(lifetime, h, k, a).expect("converges"); + for (log2_q_s, h, k, a, want) in SECURITY { + let q_s = 2f64.powi(log2_q_s); + let got = forgery_exponent(q_s, h, k, a).expect("converges"); assert!( (got - want).abs() < 1e-3, - "forgery exponent at q_s=2^{lifetime} h={h} k={k} a={a}: got {got:.9}, want {want:.9}" + "forgery exponent at q_s=2^{log2_q_s} h={h} k={k} a={a}: got {got:.9}, want {want:.9}" ); } // The preimage bound caps the reported level, and 128 bits is what every // parameter set in the report reaches. - assert_eq!(security_bits(64, 63, 14, 12, 16), 128.0); - assert_eq!(security_bits(30, 32, 10, 14, 16), 128.0); + assert_eq!(security_bits(2f64.powi(64), 63, 14, 12, 16), 128.0); + assert_eq!(security_bits(2f64.powi(30), 32, 10, 14, 16), 128.0); // n = 32 lifts the cap, so the forgery term shows through. - assert!((security_bits(30, 32, 10, 14, 32) - 131.514752565).abs() < 1e-3); + assert!((security_bits(2f64.powi(30), 32, 10, 14, 32) - 131.514752565).abs() < 1e-3); // A lifetime far past the hypertree has nothing left to quantify. - assert!(forgery_exponent(40, 20, 10, 15).is_none()); - assert_eq!(security_bits(40, 20, 10, 15, 16), 0.0); + assert!(forgery_exponent(2f64.powi(40), 20, 10, 15).is_none()); + assert_eq!(security_bits(2f64.powi(40), 20, 10, 15, 16), 0.0); } #[test] @@ -299,7 +300,7 @@ fn secure_k_form_an_up_set() { // property that makes "the smallest secure k" a meaningful phrase at all. for (h, a) in [(20u32, 10u64), (24, 12), (30, 14)] { let flags: Vec = (1..=32) - .map(|k| security_bits(20, h, k, a, 16) >= LEVEL1_BITS) + .map(|k| security_bits(2f64.powi(20), h, k, a, 16) >= LEVEL1_BITS) .collect(); let mut sorted = flags.clone(); sorted.sort_unstable(); @@ -326,9 +327,9 @@ fn half_top_cache_is_a_saving_and_reduces_to_the_full_tree() { assert_eq!(costs(none, None).unwrap().sign_cached.hashes, c.sign.hashes); } -fn budgets(lifetime: u32, keygen: u64, sign: u64, cached: u64, size: u64) -> Budgets { +fn budgets(log2_q_s: i32, keygen: u64, sign: u64, cached: u64, size: u64) -> Budgets { Budgets { - lifetime, + q_s: 2f64.powi(log2_q_s), keygen: Some(keygen), sign: Some(sign), sign_cached: Some(cached), From 45a1633f2653ea3406012bda09f0d18a3b6ba017 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 15:18:00 +0200 Subject: [PATCH 11/31] doc/sphincs: fix the README's search example for the new --lifetime It still read --lifetime 24, which under a signature count means twenty-four signatures. My rewrite matched the literal 30 that used to be there and so changed nothing. 16e6 is the same lifetime 24 meant as a log. Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/params_selection/README.md | 2 +- doc/sphincs/params_selection/src/main.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/sphincs/params_selection/README.md b/doc/sphincs/params_selection/README.md index 325b27dc9..1fe401b6b 100644 --- a/doc/sphincs/params_selection/README.md +++ b/doc/sphincs/params_selection/README.md @@ -12,7 +12,7 @@ cargo run --release -- --lifetime 1e12 --scheme W+C_F+C --height 40 --layers 5 - That pins everything, so it just costs that one set: the report's bold 2^40 row, 4356 bytes and 10425 compressions to verify. Size and verification do not depend on the lifetime, only the security line does. Leave axes out and they get searched instead, against whichever budgets you set: ```sh -cargo run --release -- --lifetime 24 --max-keygen 2e6 --max-sign 6e6 --max-sign-cached 4e6 --max-size 4000 +cargo run --release -- --lifetime 16e6 --max-keygen 2e6 --max-sign 6e6 --max-sign-cached 4e6 --max-size 4000 ``` Every cost is compression calls, one per 64 bytes of hash input: a Merkle node or a WOTS chain step is one, the message digest two, compressing `m` hash values `ceil((2n + mn) / 64)`. `--max-sign-cached` budgets signing with the top XMSS tree's half top kept as signer state, which costs sqrt storage for a sqrt-cost top tree. Since size and verification depend only on `(h, d)` and not on how the layers divide `h`, a taller top layer is free on both and cheaper to sign with the cache: compare `--top-height 8` against `--top-height 15` at `--height 40 --layers 5`. diff --git a/doc/sphincs/params_selection/src/main.rs b/doc/sphincs/params_selection/src/main.rs index 48dd0898f..e2bd8f558 100644 --- a/doc/sphincs/params_selection/src/main.rs +++ b/doc/sphincs/params_selection/src/main.rs @@ -11,7 +11,7 @@ use sphincs_params::search::{ const USAGE: &str = "\ SPHINCS+ parameter selection: what verifies cheapest, or what one set costs. -usage: sphincs_params --lifetime L [parameters] [budgets] [output] +usage: sphincs_params --lifetime Q [parameters] [budgets] [output] Give a parameter to pin it, leave it out to search it. Pin them all and the run just costs that one set. Numbers may be written as 2e6. From d9e2235532679bea4dcbac45ab92ea1c619e77c8 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 15:24:39 +0200 Subject: [PATCH 12/31] doc/sphincs: budget the cached signing cost, and call it sign There is one signing budget now, --max-sign, and it counts signing with the top XMSS tree's half top already in state: the steady-state cost of a signer that keeps the cache, which is the cost worth optimizing. The old --max-sign, which rebuilt every tree from the seed, is now reported as `cold` and budgeted by nothing, since a signer only pays it once after restoring a backup. Costs.sign is therefore the cached figure and Costs.sign_cold the other one, which is also the projection the upstream sage scripts compute: they have no cache notion, so the fixtures and the report's SigTime column now pin sign_cold. Gating the three spend-to-save axes gets more precise with one budget instead of two. --swn and --drop-chains buy cheaper verification with grinding, so they follow --max-sign; --top-height buys cheaper signing with key generation and cold signing, so it follows --max-keygen. Before, a --max-sign with no --max-keygen would have searched top heights against a keygen cost nothing bounded. Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/params_selection/README.md | 8 +++- doc/sphincs/params_selection/src/lib.rs | 9 +++-- doc/sphincs/params_selection/src/main.rs | 38 ++++++++++--------- doc/sphincs/params_selection/src/params.rs | 19 ++++++---- doc/sphincs/params_selection/src/report.rs | 25 ++++++------ doc/sphincs/params_selection/src/search.rs | 34 +++++------------ doc/sphincs/params_selection/tests/goldens.rs | 26 ++++++------- 7 files changed, 78 insertions(+), 81 deletions(-) diff --git a/doc/sphincs/params_selection/README.md b/doc/sphincs/params_selection/README.md index 1fe401b6b..b674bbbe5 100644 --- a/doc/sphincs/params_selection/README.md +++ b/doc/sphincs/params_selection/README.md @@ -12,10 +12,14 @@ cargo run --release -- --lifetime 1e12 --scheme W+C_F+C --height 40 --layers 5 - That pins everything, so it just costs that one set: the report's bold 2^40 row, 4356 bytes and 10425 compressions to verify. Size and verification do not depend on the lifetime, only the security line does. Leave axes out and they get searched instead, against whichever budgets you set: ```sh -cargo run --release -- --lifetime 16e6 --max-keygen 2e6 --max-sign 6e6 --max-sign-cached 4e6 --max-size 4000 +cargo run --release -- --lifetime 2e6 --max-keygen 2e6 --max-sign 10e6 --max-sign-cached 4e6 --max-size 4000 ``` -Every cost is compression calls, one per 64 bytes of hash input: a Merkle node or a WOTS chain step is one, the message digest two, compressing `m` hash values `ceil((2n + mn) / 64)`. `--max-sign-cached` budgets signing with the top XMSS tree's half top kept as signer state, which costs sqrt storage for a sqrt-cost top tree. Since size and verification depend only on `(h, d)` and not on how the layers divide `h`, a taller top layer is free on both and cheaper to sign with the cache: compare `--top-height 8` against `--top-height 15` at `--height 40 --layers 5`. +Every cost is compression calls, one per 64 bytes of hash input: a Merkle node or a WOTS chain step is one, the message digest two, compressing `m` hash values `ceil((2n + mn) / 64)`. + +`--max-sign` counts signing with the top XMSS tree's half top already in state, which is `sqrt(2^h_top)` of storage for a `sqrt(2^h_top)`-cost top tree and the steady-state cost of a signer that keeps it. That state is a cache, not state in the XMSS sense: it is a deterministic function of the seed, so losing it costs recomputation and nothing else. A signer holding nothing pays the `cold` column, which nothing here budgets. + +Since size and verification depend only on `(h, d)` and not on how the layers divide `h`, a taller top layer is free on both and cheaper to sign with the cache: compare `--top-height 8` against `--top-height 15` at `--height 40 --layers 5`. `cargo run --release --` with no arguments prints every flag and its default. `cargo test --release` runs the goldens: the upstream sage fixtures, the report's own tables, and a naive search oracle that skips nothing. diff --git a/doc/sphincs/params_selection/src/lib.rs b/doc/sphincs/params_selection/src/lib.rs index bf3abe272..5d2623bbb 100644 --- a/doc/sphincs/params_selection/src/lib.rs +++ b/doc/sphincs/params_selection/src/lib.rs @@ -30,10 +30,11 @@ //! //! For one parameter set [`params::costs`] reports the signature size and the //! keygen, signing and verification cost, and [`security::security_bits`] the -//! classical security. Signing comes in two flavours: vanilla, and with the top -//! XMSS tree's "half top" cached, meaning its nodes at depth `ceil(h'/2)` kept -//! as signer state, which is `sqrt(2^h')` of storage for a `sqrt(2^h')` top-tree -//! cost per signature. +//! classical security. Signing means signing with the top XMSS tree's "half +//! top" in state, its nodes at depth `ceil(h_top/2)`, which is `sqrt(2^h_top)` +//! of storage for a `sqrt(2^h_top)` top-tree cost per signature and the cost a +//! signer keeping that cache actually pays. What a signer holding nothing pays +//! is [`params::Costs::sign_cold`], reported but never budgeted. //! //! Everything is counted in compression calls, one per 64 bytes of hash input: //! a Merkle node or a WOTS chain step is one, the message digest two, and diff --git a/doc/sphincs/params_selection/src/main.rs b/doc/sphincs/params_selection/src/main.rs index e2bd8f558..db454cb9e 100644 --- a/doc/sphincs/params_selection/src/main.rs +++ b/doc/sphincs/params_selection/src/main.rs @@ -32,18 +32,23 @@ parameters --swn S WOTS+C target digit sum [the most the signing budget allows, or the mean] - -The last three trade signer work for cheaper verification, so unpinned they are -searched only against a budget that bounds it; with none they take the value the -report's own parameter sets use, shown above after the comma. -n N hash output in bytes [16] +Three of those buy something only by spending something else, so left unpinned +they are searched against the budget that bounds what they spend, and take the +value the report's own parameter sets use when it is unset (the second default +above). --swn and --drop-chains buy cheaper verification with grinding, bounded +by --max-sign; --top-height buys cheaper signing with key generation, bounded by +--max-keygen. + budgets, all optional: an unset one is no limit. Every cost is counted in compression calls, one per 64 bytes of hash input. --max-keygen N compressions at key generation - --max-sign N compressions at signing - --max-sign-cached N compressions at signing with the top tree's half top - kept in state + --max-sign N compressions at signing, counting the top XMSS tree's + half top as already in state: the steady-state cost of + a signer that keeps the cache the `cache B` column + sizes. A signer holding nothing pays the `cold` column + instead, which nothing here budgets. --max-size B signature bytes --security BITS classical security floor [128, NIST level 1] @@ -54,8 +59,7 @@ other --stats report how much of the space was visited examples - sphincs_params --lifetime 1e9 --max-keygen 2e6 --max-sign 6e6 \\ - --max-sign-cached 4e6 --max-size 4000 + sphincs_params --lifetime 1e9 --max-keygen 2e6 --max-sign 4e6 --max-size 4000 sphincs_params --lifetime 1e12 --height 40 --layers 5 -a 14 -k 11 -w 256 --swn 2040 "; @@ -189,18 +193,18 @@ fn run(argv: &[String]) -> Result { q_s, keygen: args.num("--max-keygen")?, sign: args.num("--max-sign")?, - sign_cached: args.num("--max-sign-cached")?, size: args.num("--max-size")?, security: args.get("--security").map_or(Ok(LEVEL1_BITS), |s| { s.parse().map_err(|_| format!("--security: expected a number, got {s}")) })?, }; - // A higher target sum, dropped chains and a taller top tree all buy cheaper - // verification with signer work, so with nothing bounding the signer they - // are unbounded and their answer is useless. Unpinned and unbudgeted, they - // take their classic value instead: the mean target sum, no dropped chains, - // and h/d on every layer. - let signing_bounded = b.any_signing_limit(); + // Three axes buy something only by spending something that may be + // unbudgeted, and then their answer is useless: the target sum and the + // dropped chains buy cheaper verification with grinding, and a taller top + // tree buys cheaper signing with key generation and with cold signing. + // Unpinned, each is searched only when the budget that bounds it is set, + // and otherwise takes the value the report's own parameter sets use. + let signing_bounded = b.sign.is_some(); let sums = match (args.num("--swn")?, signing_bounded) { (Some(s), _) => Sums::Pinned(s), (None, true) => Sums::Sweep, @@ -211,7 +215,7 @@ fn run(argv: &[String]) -> Result { (None, true) => Span::new(0, DROPPED_MAX), (None, false) => Span::pin(0), }; - let h_top = match (args.num("--top-height")?, signing_bounded || b.keygen.is_some()) { + let h_top = match (args.num("--top-height")?, b.keygen.is_some()) { (Some(ht), _) => Some(Span::pin(ht)), (None, true) => Some(Span::new(1, H_MAX)), (None, false) => None, diff --git a/doc/sphincs/params_selection/src/params.rs b/doc/sphincs/params_selection/src/params.rs index 45b613b6f..6a4d59457 100644 --- a/doc/sphincs/params_selection/src/params.rs +++ b/doc/sphincs/params_selection/src/params.rs @@ -345,14 +345,16 @@ impl Skeleton { }) } - /// Expected signing cost when each layer grinds `trials` counters. + /// Expected signing cost, with the top tree's half top in state, when each + /// layer grinds `trials` counters. pub fn sign(&self, lay: &Layers, trials: u64) -> Cost { - lay.trees + self.fors_part + self.grinding(trials) + lay.trees_cached + self.fors_part + self.grinding(trials) } - /// The same with the top tree's half top cached. - pub fn sign_cached(&self, lay: &Layers, trials: u64) -> Cost { - lay.trees_cached + self.fors_part + self.grinding(trials) + /// The same for a signer holding no state at all, which has to rebuild the + /// top tree along with the rest. + pub fn sign_cold(&self, lay: &Layers, trials: u64) -> Cost { + lay.trees + self.fors_part + self.grinding(trials) } /// What `trials` counter values per layer cost across the hypertree. @@ -382,7 +384,7 @@ impl Skeleton { sig_bytes: self.sig_bytes, keygen: lay.keygen, sign: self.sign(lay, trials), - sign_cached: self.sign_cached(lay, trials), + sign_cold: self.sign_cold(lay, trials), verify: self.verify(swn), verify_worst: self.verify_worst(swn), wots_c_grinding: self.grinding(trials), @@ -404,8 +406,11 @@ pub struct Costs { pub profile: Profile, pub sig_bytes: u64, pub keygen: Cost, + /// Signing with the top tree's half top in state, the cost a signer that + /// keeps `cache_bytes` of it actually pays. pub sign: Cost, - pub sign_cached: Cost, + /// Signing with no state at all: every tree rebuilt from the seed. + pub sign_cold: Cost, pub verify: Cost, pub verify_worst: Cost, /// Searching for admissible WOTS+C counters, across every layer. diff --git a/doc/sphincs/params_selection/src/report.rs b/doc/sphincs/params_selection/src/report.rs index 26a37e4eb..dde6d4bd6 100644 --- a/doc/sphincs/params_selection/src/report.rs +++ b/doc/sphincs/params_selection/src/report.rs @@ -64,7 +64,7 @@ pub fn report(p: &Params, c: &Costs, q_s: f64) -> String { let forgery = forgery_exponent(q_s, p.h as u32, p.k, p.a); let cap = 8.0 * p.n as f64; let security = forgery.map_or(0.0, |f| f.min(cap)); - let speedup = c.sign.hashes as f64 / c.sign_cached.hashes.max(1) as f64; + let speedup = c.sign_cold.hashes as f64 / c.sign.hashes.max(1) as f64; let row = |label: &str, x: crate::cost::Cost, note: String| format!("{label:<24}{:>12}{note}", si(x.compressions)); let mut lines = vec![ @@ -103,15 +103,19 @@ pub fn report(p: &Params, c: &Costs, q_s: f64) -> String { String::new(), format!("{:<24}{:>12}", "", "compressions"), row("keygen", c.keygen, String::new()), - row("sign (avg)", c.sign, String::new()), row( - "sign (half-top cached)", - c.sign_cached, + "sign", + c.sign, format!( - " ({speedup:.2}x, {} B of state at depth {})", + " ({} B of state at depth {}, {speedup:.2}x cheaper than cold)", c.cache_bytes, c.cache_depth ), ), + row( + "sign (cold)", + c.sign_cold, + " (no state: every tree rebuilt)".to_string(), + ), row("verify", c.verify, String::new()), ]; if c.verify_worst != c.verify { @@ -142,7 +146,7 @@ const COLUMNS: [(&str, usize); 16] = [ ("size", 6), ("keygen", 8), ("sign", 8), - ("sign-cached", 11), + ("cold", 8), ("cache B", 7), ]; @@ -163,7 +167,7 @@ fn cells(c: &Candidate) -> Vec { x.sig_bytes.to_string(), si(x.keygen.compressions), si(x.sign.compressions), - si(x.sign_cached.compressions), + si(x.sign_cold.compressions), x.cache_bytes.to_string(), ] } @@ -171,9 +175,9 @@ fn cells(c: &Candidate) -> Vec { /// What the abbreviated columns mean, since several of them are this project's /// own and not the report's. pub fn legend() -> String { - "every cost in compression calls, one per 64 bytes of hash input; sign / sign-cached = signing without and \ - with the top tree's half top in state, cache B of it\nht = top layer height, cb = log2(w), drop = chains \ - dropped beyond the pinned digest bits, l = chains signed, S_wn = target digit sum" + "every cost in compression calls, one per 64 bytes of hash input; sign = signing with the top tree's half top \ + in state, cache B of it, and cold = the same with no state at all\nht = top layer height, cb = log2(w), \ + drop = chains dropped beyond the pinned digest bits, l = chains signed, S_wn = target digit sum" .to_string() } @@ -197,7 +201,6 @@ pub fn utilization(b: &Budgets, c: &Candidate) -> String { let used = [ ("keygen", c.costs.keygen.compressions, b.keygen), ("sign", c.costs.sign.compressions, b.sign), - ("sign-cached", c.costs.sign_cached.compressions, b.sign_cached), ("size", c.costs.sig_bytes, b.size), ]; used.iter() diff --git a/doc/sphincs/params_selection/src/search.rs b/doc/sphincs/params_selection/src/search.rs index f39d48460..430a34060 100644 --- a/doc/sphincs/params_selection/src/search.rs +++ b/doc/sphincs/params_selection/src/search.rs @@ -100,8 +100,8 @@ pub struct Budgets { pub q_s: f64, /// An unset budget is no limit. pub keygen: Option, + /// Signing with the top tree's half top in state: see [`Costs::sign`]. pub sign: Option, - pub sign_cached: Option, pub size: Option, /// Classical security floor in bits. pub security: f64, @@ -114,15 +114,9 @@ impl Budgets { pub fn max_sign(&self) -> u64 { self.sign.unwrap_or(u64::MAX) } - pub fn max_sign_cached(&self) -> u64 { - self.sign_cached.unwrap_or(u64::MAX) - } pub fn max_size(&self) -> u64 { self.size.unwrap_or(u64::MAX) } - pub fn any_signing_limit(&self) -> bool { - self.sign.is_some() || self.sign_cached.is_some() - } /// Everything is counted in compression calls: see [`crate::cost::Blocks`]. pub fn of(&self, c: Cost) -> u64 { @@ -130,10 +124,7 @@ impl Budgets { } pub fn fits(&self, c: &Costs) -> bool { - c.sig_bytes <= self.max_size() - && self.of(c.keygen) <= self.max_keygen() - && self.of(c.sign) <= self.max_sign() - && self.of(c.sign_cached) <= self.max_sign_cached() + c.sig_bytes <= self.max_size() && self.of(c.keygen) <= self.max_keygen() && self.of(c.sign) <= self.max_sign() } } @@ -256,10 +247,9 @@ fn params(g: &Grid, scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, drop /// The layer profiles worth trying for one `(h, d)`, and how much grinding the /// best of them leaves room for. /// -/// `slack` is `max over profiles of min(max_sign - trees, max_sign_cached - -/// trees_cached)`, in the budget's unit. Both signing costs take the `(a, k)` -/// part of signing as the same additive offset, so subtracting that offset from -/// `slack` gives the grinding budget of the best profile for any `(a, k)`, +/// `slack` is `max over profiles of (max_sign - the profile's trees)`. Signing +/// takes the `(a, k)` part as an additive offset, so subtracting that offset +/// from `slack` gives the grinding budget of the best profile for any `(a, k)`, /// without re-ranking the profiles per candidate. struct Room { profiles: Vec, @@ -276,11 +266,7 @@ fn room(b: &Budgets, g: &Grid, p: &Params) -> Option { if b.of(lay.keygen) > b.max_keygen() { return; } - let room = b - .max_sign() - .saturating_sub(b.of(lay.trees)) - .min(b.max_sign_cached().saturating_sub(b.of(lay.trees_cached))); - slack = slack.max(room); + slack = slack.max(b.max_sign().saturating_sub(b.of(lay.trees_cached))); profiles.push(lay); }; match g.h_top { @@ -420,15 +406,13 @@ fn sort_rows(rows: &mut [Candidate], b: &Budgets) { } /// Record this parameter tuple on the cheapest layer profile that fits: they -/// all verify the same, so the tie goes to cached signing. +/// all verify the same, so the tie goes to signing. fn record(rows: &mut Vec, st: &mut Stats, b: &Budgets, sk: &Skeleton, room: &Room, swn: u64, trials: u64) { let Some(lay) = room .profiles .iter() - .filter(|lay| { - b.of(sk.sign(lay, trials)) <= b.max_sign() && b.of(sk.sign_cached(lay, trials)) <= b.max_sign_cached() - }) - .min_by_key(|lay| (b.of(sk.sign_cached(lay, trials)), b.of(sk.sign(lay, trials)))) + .filter(|lay| b.of(sk.sign(lay, trials)) <= b.max_sign()) + .min_by_key(|lay| (b.of(sk.sign(lay, trials)), b.of(sk.sign_cold(lay, trials)))) else { return; }; diff --git a/doc/sphincs/params_selection/tests/goldens.rs b/doc/sphincs/params_selection/tests/goldens.rs index 2e6e45b8c..508bf6612 100644 --- a/doc/sphincs/params_selection/tests/goldens.rs +++ b/doc/sphincs/params_selection/tests/goldens.rs @@ -117,7 +117,7 @@ fn matches_the_sage_fixtures() { let got = [ c.sig_bytes, c.keygen.compressions, - c.sign.compressions, + c.sign_cold.compressions, c.verify.compressions, c.verify_worst.compressions, ]; @@ -188,7 +188,7 @@ fn matches_the_report_tables() { let c = costs(p, swn).expect("consistent parameters"); let tag = format!("{} h={h} d={d} a={a} k={k} w={w} S={swn:?}", scheme.label()); assert_eq!(c.verify.hashes, sigver, "SigVer {tag}"); - let got = c.sign.hashes as f64 / 1e4; + let got = c.sign_cold.hashes as f64 / 1e4; assert!( (got - sigtime_e4).abs() < 0.55, "SigTime {tag}: got {got:.2}e4, want {sigtime_e4}e4" @@ -312,27 +312,26 @@ fn secure_k_form_an_up_set() { fn half_top_cache_is_a_saving_and_reduces_to_the_full_tree() { let p = params(Scheme::WcFc, 40, 5, 14, 11, 256); let c = costs(p, None).unwrap(); - assert!(c.sign_cached.hashes < c.sign.hashes); + assert!(c.sign.hashes < c.sign_cold.hashes); // caching at the leaves is caching the whole tree: nothing left to rebuild let whole = Params { cache_height: Some(0), ..p }; - assert!(costs(whole, None).unwrap().sign_cached.hashes < c.sign_cached.hashes); - // caching only the root is caching nothing + assert!(costs(whole, None).unwrap().sign.hashes < c.sign.hashes); + // caching only the root is caching nothing, so signing goes cold let none = Params { cache_height: Some(p.profile().unwrap().h_top), ..p }; - assert_eq!(costs(none, None).unwrap().sign_cached.hashes, c.sign.hashes); + assert_eq!(costs(none, None).unwrap().sign.hashes, c.sign_cold.hashes); } -fn budgets(log2_q_s: i32, keygen: u64, sign: u64, cached: u64, size: u64) -> Budgets { +fn budgets(log2_q_s: i32, keygen: u64, sign: u64, size: u64) -> Budgets { Budgets { q_s: 2f64.powi(log2_q_s), keygen: Some(keygen), sign: Some(sign), - sign_cached: Some(cached), size: Some(size), security: LEVEL1_BITS, } @@ -341,7 +340,7 @@ fn budgets(log2_q_s: i32, keygen: u64, sign: u64, cached: u64, size: u64) -> Bud #[test] fn search_agrees_with_a_naive_oracle() { // A grid small enough to sweep with nothing skipped at all. - let b = budgets(20, 3_000_000, 10_000_000, 10_000_000, 4_000); + let b = budgets(20, 3_000_000, 10_000_000, 4_000); let g = Grid { schemes: vec![Scheme::Wc, Scheme::WcFc], h: Span::pin(20), @@ -378,7 +377,7 @@ fn search_finds_and_improves_on_the_reports_bold_row() { // chain dropping). Its own choice has to come out feasible, and the search // has to do at least as well: it spends what is left of the signing budget // raising the target sum, which the report's row does not. - let b = budgets(40, 1_100_000, 6_000_000, 6_000_000, 4_400); + let b = budgets(40, 1_100_000, 6_000_000, 4_400); let g = Grid { chain_bits: vec![4, 8], dropped: Span::pin(0), @@ -429,11 +428,8 @@ fn a_taller_top_layer_is_free_on_size_and_verification() { t.keygen.hashes > u.keygen.hashes, "a taller top tree costs more to generate" ); - assert!(t.sign.hashes > u.sign.hashes, "and more to sign without the cache"); - assert!( - t.sign_cached.hashes < u.sign_cached.hashes, - "but less with it, which is the point" - ); + assert!(t.sign_cold.hashes > u.sign_cold.hashes, "and more to sign cold"); + assert!(t.sign.hashes < u.sign.hashes, "but less with it, which is the point"); // the lower layers come out as equal as they go, never differing by more // than one level let p = t.profile; From 9bc49d8c5f631989dafc4de602238f1c861ba86d Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 15:29:13 +0200 Subject: [PATCH 13/31] doc/sphincs: digit separators, and reject flags instead of ignoring them 100,000 and 100_000 now parse alongside 2e6, wherever a number is taken. Unknown flags were silently dropped, so a command line carrying a flag from before a rename ran anyway with that constraint quietly missing. They are an error now, and the ones this tool used to have name their replacement: --max-sign-cached points at --max-sign, --unit and --uncached at there being one unit, --max-dropped at --drop-chains, and --h-max and friends at the constants in src/search.rs, which is where a wider range now comes from. The edges warning was still telling people to raise a flag that no longer widens anything, and says the constant instead. When nothing is feasible the run now says what rejected things, counting the layer sets over --max-keygen, the parameter sets over --max-size and over --max-sign, and the (a, k) pairs that never reached the security floor. It used to guess that size or keygen was to blame, which for a tight signing budget is the wrong pointer. Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/params_selection/README.md | 4 +- doc/sphincs/params_selection/src/main.rs | 74 ++++++++++++++++++++---- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/doc/sphincs/params_selection/README.md b/doc/sphincs/params_selection/README.md index b674bbbe5..32c27eaa6 100644 --- a/doc/sphincs/params_selection/README.md +++ b/doc/sphincs/params_selection/README.md @@ -2,7 +2,7 @@ Security, signature size and hash counts for the WOTS/FORS schemes of "Hash-based Signature Schemes for Bitcoin" (Kudinov, Nick, Blockstream Research), and a search for the set that verifies cheapest under a given set of budgets. See `src/lib.rs` for what is modelled and what is deliberately not. -One command. Give a parameter to pin it, leave it out to search it. Numbers may be written as `2e6`, including the lifetime, which is a signature count rather than its log: +One command. Give a parameter to pin it, leave it out to search it. Numbers may be written as `2e6` or `100,000` or `100_000`, including the lifetime, which is a signature count rather than its log: ```sh cd doc/sphincs/params_selection @@ -12,7 +12,7 @@ cargo run --release -- --lifetime 1e12 --scheme W+C_F+C --height 40 --layers 5 - That pins everything, so it just costs that one set: the report's bold 2^40 row, 4356 bytes and 10425 compressions to verify. Size and verification do not depend on the lifetime, only the security line does. Leave axes out and they get searched instead, against whichever budgets you set: ```sh -cargo run --release -- --lifetime 2e6 --max-keygen 2e6 --max-sign 10e6 --max-sign-cached 4e6 --max-size 4000 +cargo run --release -- --lifetime 2e6 --max-keygen 2e6 --max-sign 100,000 --max-sign-cached 4e6 --max-size 4000 ``` Every cost is compression calls, one per 64 bytes of hash input: a Merkle node or a WOTS chain step is one, the message digest two, compressing `m` hash values `ceil((2n + mn) / 64)`. diff --git a/doc/sphincs/params_selection/src/main.rs b/doc/sphincs/params_selection/src/main.rs index db454cb9e..f1ed45edf 100644 --- a/doc/sphincs/params_selection/src/main.rs +++ b/doc/sphincs/params_selection/src/main.rs @@ -2,7 +2,7 @@ //! and everything left over gets searched. use sphincs_params::cost::{SCHEMES, Scheme}; -use sphincs_params::report::{legend, report, signatures, table, utilization}; +use sphincs_params::report::{legend, report, si, signatures, table, utilization}; use sphincs_params::search::{ A_MAX, Budgets, CHAIN_BITS_MAX, D_MAX, DROPPED_MAX, Grid, H_MAX, K_MAX, LEVEL1_BITS, Span, Stats, Sums, edges, search, @@ -14,7 +14,7 @@ SPHINCS+ parameter selection: what verifies cheapest, or what one set costs. usage: sphincs_params --lifetime Q [parameters] [budgets] [output] Give a parameter to pin it, leave it out to search it. Pin them all and the run -just costs that one set. Numbers may be written as 2e6. +just costs that one set. Numbers may be written as 2e6 or 100,000 or 100_000. parameters --lifetime Q signatures allowed per public key, e.g. 16e6 (required) @@ -82,7 +82,49 @@ fn main() -> std::process::ExitCode { /// Flags and their values, repeatable flags kept in order. struct Args(Vec<(String, Option)>); -const NO_VALUE: [&str; 3] = ["--cache-level-only", "--stats", "--help"]; +const NO_VALUE: [&str; 4] = ["--cache-level-only", "--stats", "--help", "-h"]; + +const FLAGS: [&str; 21] = [ + "--lifetime", + "--scheme", + "--height", + "--layers", + "--top-height", + "-a", + "-k", + "--chain-bits", + "-w", + "--drop-chains", + "--swn", + "-n", + "--max-keygen", + "--max-sign", + "--max-size", + "--security", + "--cache-height", + "--cache-level-only", + "--top", + "--stats", + "--help", +]; + +/// Flags that used to exist, and what to reach for instead. +const GONE: [(&str, &str); 8] = [ + ( + "--max-sign-cached", + "--max-sign, which now counts exactly that: signing with the half top in state", + ), + ("--unit", "nothing: every cost is compression calls"), + ("--uncached", "nothing: every cost is compression calls"), + ("--max-dropped", "--drop-chains"), + ( + "--h-max", + "--height, which pins it; widening the range means raising H_MAX in src/search.rs", + ), + ("--d-max", "--layers, or D_MAX in src/search.rs"), + ("--a-max", "-a, or A_MAX in src/search.rs"), + ("--k-max", "-k, or K_MAX in src/search.rs"), +]; impl Args { fn parse(argv: &[String]) -> Result { @@ -93,6 +135,12 @@ impl Args { if !flag.starts_with('-') { return Err(format!("unexpected argument {flag}")); } + if let Some((_, instead)) = GONE.iter().find(|(gone, _)| gone == flag) { + return Err(format!("{flag} is gone: use {instead}")); + } + if !FLAGS.contains(&flag.as_str()) { + return Err(format!("unknown flag {flag}; run with no arguments for the list")); + } if NO_VALUE.contains(&flag.as_str()) { out.push((flag.clone(), None)); i += 1; @@ -121,11 +169,12 @@ impl Args { self.all(name).last().copied() } - /// Accepts 2e6 as well as 2000000. + /// Accepts 2e6 and 100,000 and 100_000 as well as 100000. fn float(&self, name: &str) -> Result, String> { match self.get(name) { None => Ok(None), Some(s) => s + .replace([',', '_', ' '], "") .parse::() .map(Some) .map_err(|_| format!("{name}: expected a number, got {s}")), @@ -133,13 +182,7 @@ impl Args { } fn num(&self, name: &str) -> Result, String> { - match self.get(name) { - None => Ok(None), - Some(s) => s - .parse::() - .map(|f| Some(f as u64)) - .map_err(|_| format!("{name}: expected a number, got {s}")), - } + Ok(self.float(name)?.map(|f| f as u64)) } fn u64_or(&self, name: &str, default: u64) -> Result { @@ -245,7 +288,14 @@ fn run(argv: &[String]) -> Result { b.security, signatures(q_s) ); - println!("--stats says where the space went; the binding budget is usually size or keygen"); + println!( + "rejected: {} layer sets over --max-keygen, {} parameter sets over --max-size, {} over --max-sign; \ + and {} (a, k) pairs never reached the security floor", + si(stats.keygen_pruned), + si(stats.size_pruned), + si(stats.sign_pruned), + si(stats.insecure) + ); return Ok(false); } From baf7a0d76e48697b7a5d1139516a704eaff86344 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 15:40:31 +0200 Subject: [PATCH 14/31] doc/sphincs: the table's w column carries w, not its log cb was log2(w), which is what doc/xmss/main.tex calls w and what the report calls log w, so the column was ambiguous whichever way it was read. It shows the Winternitz parameter itself now, matching -w and the report's tables, with --chain-bits still taking the log on input. Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/params_selection/README.md | 2 +- doc/sphincs/params_selection/src/report.rs | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/doc/sphincs/params_selection/README.md b/doc/sphincs/params_selection/README.md index 32c27eaa6..b86686004 100644 --- a/doc/sphincs/params_selection/README.md +++ b/doc/sphincs/params_selection/README.md @@ -12,7 +12,7 @@ cargo run --release -- --lifetime 1e12 --scheme W+C_F+C --height 40 --layers 5 - That pins everything, so it just costs that one set: the report's bold 2^40 row, 4356 bytes and 10425 compressions to verify. Size and verification do not depend on the lifetime, only the security line does. Leave axes out and they get searched instead, against whichever budgets you set: ```sh -cargo run --release -- --lifetime 2e6 --max-keygen 2e6 --max-sign 100,000 --max-sign-cached 4e6 --max-size 4000 +cargo run --release -- --lifetime 2e6 --max-keygen 2e6 --max-sign 200,000 --max-size 5000 ``` Every cost is compression calls, one per 64 bytes of hash input: a Merkle node or a WOTS chain step is one, the message digest two, compressing `m` hash values `ceil((2n + mn) / 64)`. diff --git a/doc/sphincs/params_selection/src/report.rs b/doc/sphincs/params_selection/src/report.rs index dde6d4bd6..e979d1384 100644 --- a/doc/sphincs/params_selection/src/report.rs +++ b/doc/sphincs/params_selection/src/report.rs @@ -139,7 +139,7 @@ const COLUMNS: [(&str, usize); 16] = [ ("ht", 4), ("a", 3), ("k", 3), - ("cb", 3), + ("w", 5), ("drop", 5), ("l", 4), ("S_wn", 6), @@ -160,7 +160,7 @@ fn cells(c: &Candidate) -> Vec { x.profile.h_top.to_string(), p.a.to_string(), p.k.to_string(), - x.chain_bits.to_string(), + p.w.to_string(), p.dropped_chains.to_string(), x.l.to_string(), x.swn.map_or("-".to_string(), |s| s.to_string()), @@ -176,8 +176,9 @@ fn cells(c: &Candidate) -> Vec { /// own and not the report's. pub fn legend() -> String { "every cost in compression calls, one per 64 bytes of hash input; sign = signing with the top tree's half top \ - in state, cache B of it, and cold = the same with no state at all\nht = top layer height, cb = log2(w), \ - drop = chains dropped beyond the pinned digest bits, l = chains signed, S_wn = target digit sum" + in state, cache B of it, and cold = the same with no state at all\nht = top layer height, w = Winternitz parameter, the positions one chain \ + has (--chain-bits takes its log2), drop = chains dropped beyond the pinned digest bits, l = chains signed, \ + S_wn = target digit sum" .to_string() } From faff4e8b249469dbb660e70fc8ef9c81cb155125 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 15:44:38 +0200 Subject: [PATCH 15/31] doc/sphincs: stop reporting the cold signing cost Gone from the table and from the per-set report. It stays in Costs, unreported like the hash counts, because it is the projection the upstream sage scripts compute, having no cache notion, and so is what tests/goldens checks their fixtures and the report's SigTime column against. Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/params_selection/README.md | 2 +- doc/sphincs/params_selection/src/lib.rs | 2 +- doc/sphincs/params_selection/src/params.rs | 5 ++++- doc/sphincs/params_selection/src/report.rs | 17 +++-------------- 4 files changed, 9 insertions(+), 17 deletions(-) diff --git a/doc/sphincs/params_selection/README.md b/doc/sphincs/params_selection/README.md index b86686004..61d1b4946 100644 --- a/doc/sphincs/params_selection/README.md +++ b/doc/sphincs/params_selection/README.md @@ -17,7 +17,7 @@ cargo run --release -- --lifetime 2e6 --max-keygen 2e6 --max-sign 200,000 --max- Every cost is compression calls, one per 64 bytes of hash input: a Merkle node or a WOTS chain step is one, the message digest two, compressing `m` hash values `ceil((2n + mn) / 64)`. -`--max-sign` counts signing with the top XMSS tree's half top already in state, which is `sqrt(2^h_top)` of storage for a `sqrt(2^h_top)`-cost top tree and the steady-state cost of a signer that keeps it. That state is a cache, not state in the XMSS sense: it is a deterministic function of the seed, so losing it costs recomputation and nothing else. A signer holding nothing pays the `cold` column, which nothing here budgets. +`--max-sign` counts signing with the top XMSS tree's half top already in state, which is `sqrt(2^h_top)` of storage for a `sqrt(2^h_top)`-cost top tree and the steady-state cost of a signer that keeps it. That state is a cache, not state in the XMSS sense: it is a deterministic function of the seed, so losing it costs recomputation and nothing else. A signer holding nothing rebuilds every tree instead, a cost this computes but does not report, since it is paid once after restoring a backup. Since size and verification depend only on `(h, d)` and not on how the layers divide `h`, a taller top layer is free on both and cheaper to sign with the cache: compare `--top-height 8` against `--top-height 15` at `--height 40 --layers 5`. diff --git a/doc/sphincs/params_selection/src/lib.rs b/doc/sphincs/params_selection/src/lib.rs index 5d2623bbb..4989bde95 100644 --- a/doc/sphincs/params_selection/src/lib.rs +++ b/doc/sphincs/params_selection/src/lib.rs @@ -34,7 +34,7 @@ //! top" in state, its nodes at depth `ceil(h_top/2)`, which is `sqrt(2^h_top)` //! of storage for a `sqrt(2^h_top)` top-tree cost per signature and the cost a //! signer keeping that cache actually pays. What a signer holding nothing pays -//! is [`params::Costs::sign_cold`], reported but never budgeted. +//! is [`params::Costs::sign_cold`], computed but not reported. //! //! Everything is counted in compression calls, one per 64 bytes of hash input: //! a Merkle node or a WOTS chain step is one, the message digest two, and diff --git a/doc/sphincs/params_selection/src/params.rs b/doc/sphincs/params_selection/src/params.rs index 6a4d59457..5a6cf05db 100644 --- a/doc/sphincs/params_selection/src/params.rs +++ b/doc/sphincs/params_selection/src/params.rs @@ -409,7 +409,10 @@ pub struct Costs { /// Signing with the top tree's half top in state, the cost a signer that /// keeps `cache_bytes` of it actually pays. pub sign: Cost, - /// Signing with no state at all: every tree rebuilt from the seed. + /// Signing with no state at all, every tree rebuilt from the seed. Not + /// reported: a signer pays it once, after restoring a backup. It is the + /// projection the upstream sage scripts compute, which have no cache + /// notion, so `tests/goldens` checks their fixtures against it. pub sign_cold: Cost, pub verify: Cost, pub verify_worst: Cost, diff --git a/doc/sphincs/params_selection/src/report.rs b/doc/sphincs/params_selection/src/report.rs index e979d1384..ffaff3711 100644 --- a/doc/sphincs/params_selection/src/report.rs +++ b/doc/sphincs/params_selection/src/report.rs @@ -64,7 +64,6 @@ pub fn report(p: &Params, c: &Costs, q_s: f64) -> String { let forgery = forgery_exponent(q_s, p.h as u32, p.k, p.a); let cap = 8.0 * p.n as f64; let security = forgery.map_or(0.0, |f| f.min(cap)); - let speedup = c.sign_cold.hashes as f64 / c.sign.hashes.max(1) as f64; let row = |label: &str, x: crate::cost::Cost, note: String| format!("{label:<24}{:>12}{note}", si(x.compressions)); let mut lines = vec![ @@ -106,15 +105,7 @@ pub fn report(p: &Params, c: &Costs, q_s: f64) -> String { row( "sign", c.sign, - format!( - " ({} B of state at depth {}, {speedup:.2}x cheaper than cold)", - c.cache_bytes, c.cache_depth - ), - ), - row( - "sign (cold)", - c.sign_cold, - " (no state: every tree rebuilt)".to_string(), + format!(" ({} B of state at depth {})", c.cache_bytes, c.cache_depth), ), row("verify", c.verify, String::new()), ]; @@ -131,7 +122,7 @@ pub fn report(p: &Params, c: &Costs, q_s: f64) -> String { lines.join("\n") } -const COLUMNS: [(&str, usize); 16] = [ +const COLUMNS: [(&str, usize); 15] = [ ("verify", 9), ("scheme", 9), ("h", 4), @@ -146,7 +137,6 @@ const COLUMNS: [(&str, usize); 16] = [ ("size", 6), ("keygen", 8), ("sign", 8), - ("cold", 8), ("cache B", 7), ]; @@ -167,7 +157,6 @@ fn cells(c: &Candidate) -> Vec { x.sig_bytes.to_string(), si(x.keygen.compressions), si(x.sign.compressions), - si(x.sign_cold.compressions), x.cache_bytes.to_string(), ] } @@ -176,7 +165,7 @@ fn cells(c: &Candidate) -> Vec { /// own and not the report's. pub fn legend() -> String { "every cost in compression calls, one per 64 bytes of hash input; sign = signing with the top tree's half top \ - in state, cache B of it, and cold = the same with no state at all\nht = top layer height, w = Winternitz parameter, the positions one chain \ + in state, cache B of it\nht = top layer height, w = Winternitz parameter, the positions one chain \ has (--chain-bits takes its log2), drop = chains dropped beyond the pinned digest bits, l = chains signed, \ S_wn = target digit sum" .to_string() From f7b127d686d607c1e90cbf2983eac2c33b40c785 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 15:54:01 +0200 Subject: [PATCH 16/31] w --- doc/sphincs/params_selection/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphincs/params_selection/README.md b/doc/sphincs/params_selection/README.md index 61d1b4946..018a7419b 100644 --- a/doc/sphincs/params_selection/README.md +++ b/doc/sphincs/params_selection/README.md @@ -12,7 +12,7 @@ cargo run --release -- --lifetime 1e12 --scheme W+C_F+C --height 40 --layers 5 - That pins everything, so it just costs that one set: the report's bold 2^40 row, 4356 bytes and 10425 compressions to verify. Size and verification do not depend on the lifetime, only the security line does. Leave axes out and they get searched instead, against whichever budgets you set: ```sh -cargo run --release -- --lifetime 2e6 --max-keygen 2e6 --max-sign 200,000 --max-size 5000 +cargo run --release -- --lifetime 16,777,216 --max-keygen 2e6 --max-sign 200,000 --max-size 5000 ``` Every cost is compression calls, one per 64 bytes of hash input: a Merkle node or a WOTS chain step is one, the message digest two, compressing `m` hash values `ceil((2n + mn) / 64)`. From 2a804cd6b3080af550a73d5c3f29e4f4538f89a3 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 15:56:33 +0200 Subject: [PATCH 17/31] doc/sphincs: a search with one survivor still shows its count and table The table was suppressed whenever there was one row, which conflated two different runs: one where every axis was pinned, where a bare report is exactly right, and a search so tightly budgeted that only one parameter tuple survives, where a bare report looks like the search never happened. A budget of 100,000 compressions for signing is the second kind: 55.8M candidates fall to it and one gets through. The count and the table now follow whether anything was searched at all, which Grid::fully_pinned answers, not how many rows came back. Also "1 feasible set" rather than "1 feasible sets, best 1 by verification cost", and the stats line counts parameter tuples rather than rows. Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/params_selection/README.md | 2 +- doc/sphincs/params_selection/src/main.rs | 18 +++++++++------- doc/sphincs/params_selection/src/search.rs | 24 +++++++++++++++++++++- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/doc/sphincs/params_selection/README.md b/doc/sphincs/params_selection/README.md index 018a7419b..39908e44d 100644 --- a/doc/sphincs/params_selection/README.md +++ b/doc/sphincs/params_selection/README.md @@ -12,7 +12,7 @@ cargo run --release -- --lifetime 1e12 --scheme W+C_F+C --height 40 --layers 5 - That pins everything, so it just costs that one set: the report's bold 2^40 row, 4356 bytes and 10425 compressions to verify. Size and verification do not depend on the lifetime, only the security line does. Leave axes out and they get searched instead, against whichever budgets you set: ```sh -cargo run --release -- --lifetime 16,777,216 --max-keygen 2e6 --max-sign 200,000 --max-size 5000 +cargo run --release -- --lifetime 16,777,216 --max-keygen 2e6 --max-sign 100,000 --max-size 5000 ``` Every cost is compression calls, one per 64 bytes of hash input: a Merkle node or a WOTS chain step is one, the message digest two, compressing `m` hash values `ceil((2n + mn) / 64)`. diff --git a/doc/sphincs/params_selection/src/main.rs b/doc/sphincs/params_selection/src/main.rs index f1ed45edf..11c22599e 100644 --- a/doc/sphincs/params_selection/src/main.rs +++ b/doc/sphincs/params_selection/src/main.rs @@ -299,15 +299,19 @@ fn run(argv: &[String]) -> Result { return Ok(false); } - if found.len() > 1 { + if !g.fully_pinned() { let top = args.u64_or("--top", 15)? as usize; - let kept = if stats.rows_dropped > 0 { - format!("{} feasible sets, {} kept", stats.rows, found.len()) - } else { - format!("{} feasible sets", found.len()) + let shown = top.min(found.len()); + let kept = match (found.len(), stats.rows_dropped) { + (1, _) => "1 feasible set".to_string(), + (n, 0) => format!("{n} feasible sets, best {shown} by verification cost"), + (n, _) => format!( + "{} feasible sets, {n} kept, best {shown} by verification cost", + stats.rows + ), }; - println!("{kept}, best {} by verification cost:\n", top.min(found.len())); - println!("{}\n", table(&found[..top.min(found.len())])); + println!("{kept}:\n"); + println!("{}\n", table(&found[..shown])); println!("{}\n", legend()); } diff --git a/doc/sphincs/params_selection/src/search.rs b/doc/sphincs/params_selection/src/search.rs index 430a34060..64b08717a 100644 --- a/doc/sphincs/params_selection/src/search.rs +++ b/doc/sphincs/params_selection/src/search.rs @@ -144,6 +144,27 @@ pub struct Grid { pub cache_level_only: bool, } +impl Grid { + /// Is every axis pinned to one value, so that a run costs one parameter set + /// rather than searching for one? Distinct from a search that happens to + /// leave one survivor, which still deserves its count and its table. + pub fn fully_pinned(&self) -> bool { + let h_top_pinned = match self.h_top { + None => true, // the classic split is one profile + Some(span) => span.pinned(), + }; + self.schemes.len() == 1 + && self.chain_bits.len() == 1 + && self.h.pinned() + && self.d.pinned() + && self.a.pinned() + && self.k.pinned() + && self.dropped.pinned() + && h_top_pinned + && !matches!(self.sums, Sums::Sweep) + } +} + impl Default for Grid { fn default() -> Self { Self { @@ -210,7 +231,8 @@ impl std::fmt::Display for Stats { f, "grid {} (scheme, h, d, chain_bits, dropped) tuples, {} over keygen; \ then {} (a, k) pairs insecure, {} over size, {} over signing; \ - {} target-sum ranges swept, {} points feasible over {} rows ({} dropped as worse than everything kept); \ + {} target-sum ranges swept, {} points feasible over {} parameter tuples ({} dropped as worse than \ + everything kept); \ {} layer profiles and {} parameter sets costed in {:.1}s", self.grid, self.keygen_pruned, From 4fcf5367b76b61946778c294468a31c9f3aba31d Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 16:20:02 +0200 Subject: [PATCH 18/31] doc/sphincs: any layer heights, and a proof the search needs only one shape Profile now holds an arbitrary height vector, top first, so a hypertree with heights 11 + 5 + 7 + 3 can be costed. --heights takes them on the command line, pinning h and d with them, and Layers::from_profile is the library entry point. The search still builds only the canonical shape, the top tree at h_top and the rest dividing what is left as evenly as it goes, and now proves that costs nothing. profile_shape_is_never_beaten enumerates every composition of five small (h, d) pairs, 2000-odd of them, and checks that against the canonical profile with the same top height each one ties on signature size, on verification and on keygen, and loses or ties on both signing costs. The argument was already in Profile's docs; it is checked now. Visible in a pair of runs: 11 + 5 + 7 + 3 and 11 + 5 + 5 + 5 both give 3872 bytes, 8.37K compressions to verify and 8.40M to generate, and the uneven one signs at 1.38M against 1.09M. Heights ride in a [u8; 32], since a height past 63 makes 2^height uncountable anyway, so a fully general profile is the same size as the compact top-plus-two form it replaces and nothing in the hot loop grew. The final report no longer recomputes the winner's costs to apply --cache-height either: that rides in the grid now, so what gets printed is what the search actually costed, which is also what makes --heights print the heights it was given. Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/params_selection/README.md | 2 + doc/sphincs/params_selection/src/lib.rs | 4 +- doc/sphincs/params_selection/src/main.rs | 43 ++++-- doc/sphincs/params_selection/src/params.rs | 144 +++++++++++------- doc/sphincs/params_selection/src/report.rs | 2 +- doc/sphincs/params_selection/src/search.rs | 40 +++-- doc/sphincs/params_selection/tests/goldens.rs | 98 +++++++++++- 7 files changed, 247 insertions(+), 86 deletions(-) diff --git a/doc/sphincs/params_selection/README.md b/doc/sphincs/params_selection/README.md index 39908e44d..1795a6a5f 100644 --- a/doc/sphincs/params_selection/README.md +++ b/doc/sphincs/params_selection/README.md @@ -21,6 +21,8 @@ Every cost is compression calls, one per 64 bytes of hash input: a Merkle node o Since size and verification depend only on `(h, d)` and not on how the layers divide `h`, a taller top layer is free on both and cheaper to sign with the cache: compare `--top-height 8` against `--top-height 15` at `--height 40 --layers 5`. +Layer heights can be given outright with `--heights 11,5,7,3`, which pins `h` and `d` with them. The search never produces an uneven lower half, and that is not a restriction: for the same `h`, `d` and top height, no other profile costs less on anything, since size, verification and keygen do not move and signing sums `2^height`, which at a fixed total is smallest when the heights are equal. `profile_shape_is_never_beaten` checks that against every composition of several small `(h, d)`. So `--heights` is for costing a profile you already have in mind. + `cargo run --release --` with no arguments prints every flag and its default. `cargo test --release` runs the goldens: the upstream sage fixtures, the report's own tables, and a naive search oracle that skips nothing. The search is exhaustive over hardcoded ranges and warns when its answer leans on the top of one. Budgets loose enough that nothing prunes can take a couple of minutes, reported by `--stats`; realistic ones finish in seconds. diff --git a/doc/sphincs/params_selection/src/lib.rs b/doc/sphincs/params_selection/src/lib.rs index 4989bde95..bcf6203d8 100644 --- a/doc/sphincs/params_selection/src/lib.rs +++ b/doc/sphincs/params_selection/src/lib.rs @@ -21,7 +21,9 @@ //! //! The hypertree's height is split per layer, not `h/d` on every layer: the top //! tree gets `h_top` and the rest divide what is left as evenly as it goes, so -//! `d` need not divide `h`. That matters because the signature carries `h` +//! `d` need not divide `h`, and [`params::Profile`] can hold any heights at all +//! though only that shape is ever searched. That matters because the signature +//! carries `h` //! authentication nodes and the verifier walks them however the layers divide //! `h`: size and verification depend only on `(h, d)`, while only the top tree //! is cacheable. A taller top layer is therefore free on both, costs keygen and diff --git a/doc/sphincs/params_selection/src/main.rs b/doc/sphincs/params_selection/src/main.rs index 11c22599e..a426b5d8d 100644 --- a/doc/sphincs/params_selection/src/main.rs +++ b/doc/sphincs/params_selection/src/main.rs @@ -2,6 +2,7 @@ //! and everything left over gets searched. use sphincs_params::cost::{SCHEMES, Scheme}; +use sphincs_params::params::Profile; use sphincs_params::report::{legend, report, si, signatures, table, utilization}; use sphincs_params::search::{ A_MAX, Budgets, CHAIN_BITS_MAX, D_MAX, DROPPED_MAX, Grid, H_MAX, K_MAX, LEVEL1_BITS, Span, Stats, Sums, edges, @@ -23,6 +24,11 @@ parameters --layers d hypertree layers [1..32] --top-height ht height of the top XMSS tree, the rest of h splitting evenly below it [1..h-d+1, or h/d] + --heights H,... every layer height outright, top first, pinning h and d + with it. Nothing here searches uneven lower layers, + because for the same h, d and top height they never cost + less: see Profile in src/params.rs. This is for costing + one anyway. -a A log2 of the leaves in a FORS tree [1..32] -k K FORS trees [1..64] --chain-bits B log2(w), repeatable [1..12] @@ -84,12 +90,13 @@ struct Args(Vec<(String, Option)>); const NO_VALUE: [&str; 4] = ["--cache-level-only", "--stats", "--help", "-h"]; -const FLAGS: [&str; 21] = [ +const FLAGS: [&str; 22] = [ "--lifetime", "--scheme", "--height", "--layers", "--top-height", + "--heights", "-a", "-k", "--chain-bits", @@ -263,17 +270,39 @@ fn run(argv: &[String]) -> Result { (None, true) => Some(Span::new(1, H_MAX)), (None, false) => None, }; + let profile = match args.get("--heights") { + None => None, + Some(list) => { + let heights: Vec = list + .split(',') + .map(|x| { + x.trim() + .parse::() + .map_err(|_| format!("--heights: expected numbers, got {list}")) + }) + .collect::>()?; + Some(Profile::new(&heights).ok_or_else(|| format!("--heights: {list} is not 1..=32 heights of 1..=63"))?) + } + }; let g = Grid { schemes: args.schemes()?, n: args.u64_or("-n", 16)?, - h: args.span("--height", Span::new(1, H_MAX))?, - d: args.span("--layers", Span::new(1, D_MAX))?, + h: match profile { + Some(pr) => Span::pin(pr.total()), + None => args.span("--height", Span::new(1, H_MAX))?, + }, + d: match profile { + Some(pr) => Span::pin(pr.layers()), + None => args.span("--layers", Span::new(1, D_MAX))?, + }, h_top, a: args.span("-a", Span::new(1, A_MAX))?, k: args.span("-k", Span::new(1, K_MAX))?, dropped, chain_bits: args.chain_bits()?, sums, + profile, + cache_height: args.num("--cache-height")?, cache_level_only: args.flag("--cache-level-only"), }; @@ -326,12 +355,6 @@ fn run(argv: &[String]) -> Result { if !use_.is_empty() || !edges(&g, best).is_empty() { println!(); } - // The cache split is not searched, so it rides here rather than in the grid. - let shown = sphincs_params::params::Params { - cache_height: args.num("--cache-height")?, - ..best.params - }; - let costs = sphincs_params::params::costs(shown, best.costs.swn).ok_or("inconsistent parameters")?; - println!("{}", report(&shown, &costs, b.q_s)); + println!("{}", report(&best.params, &best.costs, b.q_s)); Ok(true) } diff --git a/doc/sphincs/params_selection/src/params.rs b/doc/sphincs/params_selection/src/params.rs index 5a6cf05db..4a3194908 100644 --- a/doc/sphincs/params_selection/src/params.rs +++ b/doc/sphincs/params_selection/src/params.rs @@ -29,90 +29,110 @@ pub struct Params { pub cache_level_only: bool, } -/// The height of every XMSS tree in the hypertree. +/// The height of every XMSS tree in the hypertree, top first. /// -/// Only the top tree is worth caching, and the layers below it are otherwise -/// interchangeable, so the only profile shape worth considering is "the top one, -/// then the rest as equal as they go". For a fixed `(h, d, h_top)` that shape is -/// no worse than any other on every cost: size and verification depend only on -/// `(h, d)`, keygen only on `h_top`, and signing sums `2^height` over the -/// layers, which for a fixed total is smallest when they are equal. So +/// Any heights are expressible, but a search only ever needs +/// [`Profile::canonical`]: the top tree at some height and the rest dividing +/// what is left as evenly as it goes. For a fixed `(h, d, h_top)` that shape is +/// no worse than any other on every cost, since size and verification depend +/// only on `(h, d)`, keygen only on `h_top`, and signing sums `2^height` over +/// the layers, which at a fixed total is smallest when they are equal. So /// enumerating `(h, d, h_top)` covers the cost-optimal representative of every -/// layer profile, and the `d - 1` lower heights differ by at most one. +/// profile. `profile_shape_is_never_beaten` in `tests/goldens` checks that +/// against every composition of a few small `(h, d)`. +/// +/// Heights are at most 63, since `2^height` has to be countable, and there are +/// at most [`MAX_LAYERS`] of them. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Profile { - pub h_top: u64, - /// The taller of the two lower heights, and how many layers have it. - pub tall: u64, - pub n_tall: u64, - /// The shorter of the two, and how many layers have it. - pub short: u64, - pub n_short: u64, + heights: [u8; MAX_LAYERS], + len: u8, } +/// The most hypertree layers a [`Profile`] can hold. +pub const MAX_LAYERS: usize = 32; + impl Profile { - /// `None` if some layer would be empty, or too tall for `2^height` to count. - pub fn new(h: u64, d: u64, h_top: Option) -> Option { - if d == 0 || h == 0 { + /// Any heights at all, the top tree first. + pub fn new(heights: &[u64]) -> Option { + if heights.is_empty() || heights.len() > MAX_LAYERS || heights.iter().any(|&x| !(1..=63).contains(&x)) { + return None; + } + let mut out = Self { + heights: [0; MAX_LAYERS], + len: heights.len() as u8, + }; + for (slot, &h) in out.heights.iter_mut().zip(heights) { + *slot = h as u8; + } + Some(out) + } + + /// The top tree at `h_top`, the other `d - 1` layers dividing `h - h_top` as + /// evenly as it goes. `None` for `h_top` is the classic `h/d` split. + pub fn canonical(h: u64, d: u64, h_top: Option) -> Option { + if d == 0 || d as usize > MAX_LAYERS || h == 0 { return None; } let h_top = h_top.unwrap_or(h / d).max(1); let lower_total = h.checked_sub(h_top)?; let m = d - 1; if m == 0 { - if lower_total != 0 { - return None; // one layer has to be the whole height - } - return Self::checked(Self { - h_top, - tall: 0, - n_tall: 0, - short: 0, - n_short: 0, - }); + return (lower_total == 0).then(|| Self::new(&[h_top]))?; } if lower_total < m { return None; // every layer needs at least one level } let (q, r) = (lower_total / m, lower_total % m); - Self::checked(Self { - h_top, - tall: q + 1, - n_tall: r, - short: q, - n_short: m - r, - }) + let mut heights = vec![h_top]; + heights.extend(std::iter::repeat_n(q + 1, r as usize)); + heights.extend(std::iter::repeat_n(q, (m - r) as usize)); + Self::new(&heights) } - /// A tree of `2^63` leaves is already past any budget a `u64` can hold, and - /// `2^64` does not fit the count at all. - fn checked(self) -> Option { - (self.h_top <= 63 && self.tall <= 63).then_some(self) + pub fn heights(&self) -> impl Iterator + '_ { + self.heights[..self.len as usize].iter().map(|&x| x as u64) + } + + /// The top tree's height: the one layer that is the same for every signature. + pub fn h_top(&self) -> u64 { + self.heights().next().unwrap_or(0) } pub fn total(&self) -> u64 { - self.h_top + self.tall * self.n_tall + self.short * self.n_short + self.heights().sum() } pub fn layers(&self) -> u64 { - 1 + self.n_tall + self.n_short + self.len as u64 } - /// Is every layer the same height? pub fn uniform(&self) -> bool { - (self.n_tall == 0 || self.tall == self.h_top) && (self.n_short == 0 || self.short == self.h_top) + self.heights().all(|x| x == self.h_top()) } } impl std::fmt::Display for Profile { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if self.uniform() { - return write!(f, "{} x {}", self.layers(), self.h_top); + return write!(f, "{} x {}", self.layers(), self.h_top()); } - write!(f, "top {}", self.h_top)?; - for (height, count) in [(self.tall, self.n_tall), (self.short, self.n_short)] { - if count > 0 { - write!(f, " + {count} x {height}")?; + // run-length, so the usual shapes read as "12 + 3 x 5" + let mut first = true; + let mut runs = Vec::new(); + for h in self.heights() { + match runs.last_mut() { + Some((prev, count)) if *prev == h => *count += 1, + _ => runs.push((h, 1u64)), + } + } + for (h, count) in runs { + let sep = if first { "" } else { " + " }; + first = false; + if count == 1 { + write!(f, "{sep}{h}")?; + } else { + write!(f, "{sep}{count} x {h}")?; } } Ok(()) @@ -121,7 +141,7 @@ impl std::fmt::Display for Profile { impl Params { pub fn profile(&self) -> Option { - Profile::new(self.h, self.d, self.h_top) + Profile::canonical(self.h, self.d, self.h_top) } pub fn encoding(&self) -> Option { @@ -193,8 +213,16 @@ pub struct Layers { } impl Layers { + /// The canonical profile of `p`: see [`Profile::canonical`]. pub fn new(p: &Params) -> Option { - let profile = p.profile()?; + Self::from_profile(p, p.profile()?) + } + + /// Any profile, as long as its heights add up to `p.h` over `p.d` layers. + pub fn from_profile(p: &Params, profile: Profile) -> Option { + if profile.total() != p.h || profile.layers() != p.d { + return None; + } let l = p.chains()?; let leaf = p.wots_leaf(l); let b = p.blocks(); @@ -202,8 +230,12 @@ impl Layers { let leaves = 1u64 << height; leaf * leaves + Cost::new(leaves - 1, (leaves - 1) * b.merkle_node()) }; - let top = tree(profile.h_top); - let lower = tree(profile.tall) * profile.n_tall + tree(profile.short) * profile.n_short; + let top = tree(profile.h_top()); + let lower = profile + .heights() + .skip(1) + .map(tree) + .fold(Cost::default(), |acc, x| acc + x); // Only the top tree is worth caching: it is the same for every // signature, while the trees below it are picked by the (pseudorandom) @@ -218,11 +250,11 @@ impl Layers { // consecutive signatures land on unrelated leaves and nothing // amortizes; an index-independent cache like this one is what is left, // hence sqrt rather than h'. - let c = p.cache_height.unwrap_or(profile.h_top / 2); - if c > profile.h_top { + let c = p.cache_height.unwrap_or(profile.h_top() / 2); + if c > profile.h_top() { return None; } - let stored_level = 1u64 << (profile.h_top - c); + let stored_level = 1u64 << (profile.h_top() - c); let mut cached = tree(c); let cache_bytes; if p.cache_level_only { @@ -238,7 +270,7 @@ impl Layers { trees: top + lower, trees_cached: cached + lower, cache_bytes, - cache_depth: profile.h_top - c, + cache_depth: profile.h_top() - c, }) } } diff --git a/doc/sphincs/params_selection/src/report.rs b/doc/sphincs/params_selection/src/report.rs index ffaff3711..3ffc62b5c 100644 --- a/doc/sphincs/params_selection/src/report.rs +++ b/doc/sphincs/params_selection/src/report.rs @@ -147,7 +147,7 @@ fn cells(c: &Candidate) -> Vec { p.scheme.label().to_string(), p.h.to_string(), p.d.to_string(), - x.profile.h_top.to_string(), + x.profile.h_top().to_string(), p.a.to_string(), p.k.to_string(), p.w.to_string(), diff --git a/doc/sphincs/params_selection/src/search.rs b/doc/sphincs/params_selection/src/search.rs index 64b08717a..d767a1989 100644 --- a/doc/sphincs/params_selection/src/search.rs +++ b/doc/sphincs/params_selection/src/search.rs @@ -32,7 +32,7 @@ use std::ops::RangeInclusive; use std::time::Instant; use crate::cost::{Cost, NuTable, SCHEMES, Scheme}; -use crate::params::{Costs, Layers, Params, Skeleton}; +use crate::params::{Costs, Layers, Params, Profile, Skeleton}; use crate::security::SecurityTable; /// Hardcoded search ranges, wide enough that the budgets are normally what @@ -141,6 +141,11 @@ pub struct Grid { pub dropped: Span, pub chain_bits: Vec, pub sums: Sums, + /// A profile given outright, instead of `h_top` over the canonical shape. + /// Its heights have to add up to `h` over `d` layers, both of which are + /// then pinned by it. + pub profile: Option, + pub cache_height: Option, pub cache_level_only: bool, } @@ -149,10 +154,11 @@ impl Grid { /// rather than searching for one? Distinct from a search that happens to /// leave one survivor, which still deserves its count and its table. pub fn fully_pinned(&self) -> bool { - let h_top_pinned = match self.h_top { - None => true, // the classic split is one profile - Some(span) => span.pinned(), - }; + let h_top_pinned = self.profile.is_some() + || match self.h_top { + None => true, // the classic split is one profile + Some(span) => span.pinned(), + }; self.schemes.len() == 1 && self.chain_bits.len() == 1 && self.h.pinned() @@ -178,6 +184,8 @@ impl Default for Grid { dropped: Span::new(0, DROPPED_MAX), chain_bits: (1..=CHAIN_BITS_MAX).collect(), sums: Sums::Sweep, + profile: None, + cache_height: None, cache_level_only: false, } } @@ -261,7 +269,7 @@ fn params(g: &Grid, scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, drop w, n: g.n, dropped_chains: dropped, - cache_height: None, + cache_height: g.cache_height, cache_level_only: g.cache_level_only, } } @@ -281,25 +289,27 @@ struct Room { fn room(b: &Budgets, g: &Grid, p: &Params) -> Option { let mut profiles = Vec::new(); let mut slack = 0; - let mut consider = |h_top: Option| { - let Some(lay) = Layers::new(&Params { h_top, ..*p }) else { - return; - }; + let mut consider = |lay: Option| { + let Some(lay) = lay else { return }; if b.of(lay.keygen) > b.max_keygen() { return; } slack = slack.max(b.max_sign().saturating_sub(b.of(lay.trees_cached))); profiles.push(lay); }; - match g.h_top { - None => consider(None), - Some(span) => { + match (g.profile, g.h_top) { + (Some(profile), _) => consider(Layers::from_profile(p, profile)), + (None, None) => consider(Layers::new(p)), + (None, Some(span)) => { // The top tree has 2^h_top leaves and every leaf costs at least one // hash, so a top height past the keygen budget's log is out for any // (a, k). let ceiling = 64 - b.max_keygen().max(1).leading_zeros() as u64; for h_top in span.within((p.h + 1).saturating_sub(p.d).min(ceiling)) { - consider(Some(h_top)); + consider(Layers::new(&Params { + h_top: Some(h_top), + ..*p + })); } } } @@ -441,7 +451,7 @@ fn record(rows: &mut Vec, st: &mut Stats, b: &Budgets, sk: &Skeleton, st.rows += 1; rows.push(Candidate { params: Params { - h_top: Some(lay.profile.h_top), + h_top: Some(lay.profile.h_top()), ..sk.params }, costs: sk.finish(lay, swn, trials), diff --git a/doc/sphincs/params_selection/tests/goldens.rs b/doc/sphincs/params_selection/tests/goldens.rs index 508bf6612..c2af795ad 100644 --- a/doc/sphincs/params_selection/tests/goldens.rs +++ b/doc/sphincs/params_selection/tests/goldens.rs @@ -321,7 +321,7 @@ fn half_top_cache_is_a_saving_and_reduces_to_the_full_tree() { assert!(costs(whole, None).unwrap().sign.hashes < c.sign.hashes); // caching only the root is caching nothing, so signing goes cold let none = Params { - cache_height: Some(p.profile().unwrap().h_top), + cache_height: Some(p.profile().unwrap().h_top()), ..p }; assert_eq!(costs(none, None).unwrap().sign.hashes, c.sign_cold.hashes); @@ -433,11 +433,103 @@ fn a_taller_top_layer_is_free_on_size_and_verification() { // the lower layers come out as equal as they go, never differing by more // than one level let p = t.profile; - assert!(p.n_tall == 0 || p.n_short == 0 || p.tall == p.short + 1); - assert_eq!(Profile::new(41, 5, Some(9)).unwrap().total(), 41); + let lower: Vec = p.heights().skip(1).collect(); + let (lo, hi) = (lower.iter().min().unwrap(), lower.iter().max().unwrap()); + assert!( + hi - lo <= 1, + "the lower layers never differ by more than a level: {lower:?}" + ); + assert_eq!(Profile::canonical(41, 5, Some(9)).unwrap().total(), 41); assert_eq!(Layers::new(&tall).unwrap().profile, t.profile); } +/// Every way of splitting `h` over `d` layers, top first. +fn compositions(h: u64, d: u64) -> Vec> { + if d == 1 { + return vec![vec![h]]; + } + (1..=h.saturating_sub(d - 1)) + .flat_map(|first| { + compositions(h - first, d - 1).into_iter().map(move |rest| { + let mut out = vec![first]; + out.extend(rest); + out + }) + }) + .collect() +} + +/// The search only ever builds `Profile::canonical`, and this is why that is +/// not a restriction: for the same `(h, d, h_top)`, no other profile costs less +/// on anything. +#[test] +fn profile_shape_is_never_beaten() { + let mut checked = 0; + for (h, d) in [(12, 3), (14, 4), (9, 2), (16, 5), (20, 4)] { + let p = Params { + h, + d, + ..params(Scheme::WcFc, h, d, 10, 12, 16) + }; + let sk = Skeleton::new(p).expect("consistent"); + for heights in compositions(h, d) { + let Some(profile) = Profile::new(&heights) else { + continue; + }; + let Some(any) = Layers::from_profile(&p, profile) else { + continue; + }; + let canon = Layers::new(&Params { + h_top: Some(heights[0]), + ..p + }) + .expect("same top height"); + let tag = format!("h={h} d={d} heights={heights:?}"); + // size and verification do not see the profile at all + let (a, c) = (sk.finish(&any, 0, 0), sk.finish(&canon, 0, 0)); + assert_eq!( + (a.sig_bytes, a.verify), + (c.sig_bytes, c.verify), + "size or verification moved: {tag}" + ); + // keygen is the top tree, which they share + assert_eq!(any.keygen, canon.keygen, "keygen moved: {tag}"); + // and the canonical split is the cheapest to sign, cached or cold + assert!( + canon.trees_cached.compressions <= any.trees_cached.compressions, + "beaten on signing: {tag}" + ); + assert!( + canon.trees.compressions <= any.trees.compressions, + "beaten on cold signing: {tag}" + ); + checked += 1; + } + } + assert!(checked > 2000, "only {checked} profiles checked"); +} + +/// A profile is expressible however uneven, and reads back as given. +#[test] +fn any_profile_can_be_costed() { + let p = params(Scheme::WcFc, 26, 4, 14, 11, 256); + let lopsided = Profile::new(&[11, 5, 7, 3]).expect("26 over 4 layers"); + assert_eq!(lopsided.total(), 26); + assert_eq!(lopsided.h_top(), 11); + assert_eq!(format!("{lopsided}"), "11 + 5 + 7 + 3"); + let lay = Layers::from_profile(&p, lopsided).expect("adds up to h over d layers"); + assert_eq!(lay.profile, lopsided); + // and the canonical one with the same top is at least as cheap to sign + let canon = Layers::new(&Params { h_top: Some(11), ..p }).unwrap(); + assert_eq!(format!("{}", canon.profile), "11 + 3 x 5"); + assert!(canon.trees_cached.compressions <= lay.trees_cached.compressions); + // heights have to add up over the layers there are + assert!(Layers::from_profile(&p, Profile::new(&[11, 5, 7, 4]).unwrap()).is_none()); + assert!(Layers::from_profile(&p, Profile::new(&[13, 13]).unwrap()).is_none()); + assert!(Profile::new(&[11, 0, 15]).is_none(), "every layer needs a level"); + assert!(Profile::new(&[64]).is_none(), "2^height has to be countable"); +} + #[test] fn skeleton_rejects_trees_that_do_not_fit_a_u64() { // 2^h' leaves has to be countable: without this the shift masks and a From 3fe60ef73649f3134ad12eea18f0f6829866dd27 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 16:25:33 +0200 Subject: [PATCH 19/31] doc/sphincs: the table shows the layer heights, not just the top one An ht column was complete while every profile was the canonical shape, since (h, d, h_top) determines it. It is not complete now that any heights can be costed, and it was never easy to read: the reader had to divide h - ht over d - 1 layers themselves. The column carries the profile run-length encoded instead, 2x12 or 12+13 or 6+2x11 or 11+5+7+3, which is worth having even for canonical shapes. A keygen-starved search makes the point: with --max-keygen 3e4 the winner is 6+2x11, the top layer the shortest of the three, because keygen pays for that tree alone. An ht of 6 said nothing about the 11s. Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/params_selection/src/params.rs | 7 +++++++ doc/sphincs/params_selection/src/report.rs | 6 +++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/doc/sphincs/params_selection/src/params.rs b/doc/sphincs/params_selection/src/params.rs index 4a3194908..4a2ca3e8f 100644 --- a/doc/sphincs/params_selection/src/params.rs +++ b/doc/sphincs/params_selection/src/params.rs @@ -112,6 +112,13 @@ impl Profile { } } +impl Profile { + /// The heights as one token, for a table column: `5x8`, `12+3x5`, `11+5+7+3`. + pub fn compact(&self) -> String { + format!("{self}").replace(' ', "") + } +} + impl std::fmt::Display for Profile { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if self.uniform() { diff --git a/doc/sphincs/params_selection/src/report.rs b/doc/sphincs/params_selection/src/report.rs index 3ffc62b5c..f84b6f8a0 100644 --- a/doc/sphincs/params_selection/src/report.rs +++ b/doc/sphincs/params_selection/src/report.rs @@ -127,7 +127,7 @@ const COLUMNS: [(&str, usize); 15] = [ ("scheme", 9), ("h", 4), ("d", 3), - ("ht", 4), + ("heights", 12), ("a", 3), ("k", 3), ("w", 5), @@ -147,7 +147,7 @@ fn cells(c: &Candidate) -> Vec { p.scheme.label().to_string(), p.h.to_string(), p.d.to_string(), - x.profile.h_top().to_string(), + x.profile.compact(), p.a.to_string(), p.k.to_string(), p.w.to_string(), @@ -165,7 +165,7 @@ fn cells(c: &Candidate) -> Vec { /// own and not the report's. pub fn legend() -> String { "every cost in compression calls, one per 64 bytes of hash input; sign = signing with the top tree's half top \ - in state, cache B of it\nht = top layer height, w = Winternitz parameter, the positions one chain \ + in state, cache B of it\nheights = every layer's height, top first, and the only one worth caching is that top one, w = Winternitz parameter, the positions one chain \ has (--chain-bits takes its log2), drop = chains dropped beyond the pinned digest bits, l = chains signed, \ S_wn = target digit sum" .to_string() From feb50669a0208dac4d054372a82c591ffefa495b Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 16:31:46 +0200 Subject: [PATCH 20/31] doc/sphincs: write layer heights out, 12 + 7 + 7 rather than 12 + 2x7 Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/params_selection/src/params.rs | 32 ++----------------- doc/sphincs/params_selection/src/report.rs | 4 +-- doc/sphincs/params_selection/tests/goldens.rs | 2 +- 3 files changed, 6 insertions(+), 32 deletions(-) diff --git a/doc/sphincs/params_selection/src/params.rs b/doc/sphincs/params_selection/src/params.rs index 4a2ca3e8f..e84e85986 100644 --- a/doc/sphincs/params_selection/src/params.rs +++ b/doc/sphincs/params_selection/src/params.rs @@ -112,37 +112,11 @@ impl Profile { } } -impl Profile { - /// The heights as one token, for a table column: `5x8`, `12+3x5`, `11+5+7+3`. - pub fn compact(&self) -> String { - format!("{self}").replace(' ', "") - } -} - impl std::fmt::Display for Profile { + /// Every height, top first: `12 + 7 + 7`. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if self.uniform() { - return write!(f, "{} x {}", self.layers(), self.h_top()); - } - // run-length, so the usual shapes read as "12 + 3 x 5" - let mut first = true; - let mut runs = Vec::new(); - for h in self.heights() { - match runs.last_mut() { - Some((prev, count)) if *prev == h => *count += 1, - _ => runs.push((h, 1u64)), - } - } - for (h, count) in runs { - let sep = if first { "" } else { " + " }; - first = false; - if count == 1 { - write!(f, "{sep}{h}")?; - } else { - write!(f, "{sep}{count} x {h}")?; - } - } - Ok(()) + let listed: Vec = self.heights().map(|h| h.to_string()).collect(); + write!(f, "{}", listed.join(" + ")) } } diff --git a/doc/sphincs/params_selection/src/report.rs b/doc/sphincs/params_selection/src/report.rs index f84b6f8a0..f2b927fa1 100644 --- a/doc/sphincs/params_selection/src/report.rs +++ b/doc/sphincs/params_selection/src/report.rs @@ -127,7 +127,7 @@ const COLUMNS: [(&str, usize); 15] = [ ("scheme", 9), ("h", 4), ("d", 3), - ("heights", 12), + ("heights", 18), ("a", 3), ("k", 3), ("w", 5), @@ -147,7 +147,7 @@ fn cells(c: &Candidate) -> Vec { p.scheme.label().to_string(), p.h.to_string(), p.d.to_string(), - x.profile.compact(), + x.profile.to_string(), p.a.to_string(), p.k.to_string(), p.w.to_string(), diff --git a/doc/sphincs/params_selection/tests/goldens.rs b/doc/sphincs/params_selection/tests/goldens.rs index c2af795ad..9692d5cc1 100644 --- a/doc/sphincs/params_selection/tests/goldens.rs +++ b/doc/sphincs/params_selection/tests/goldens.rs @@ -521,7 +521,7 @@ fn any_profile_can_be_costed() { assert_eq!(lay.profile, lopsided); // and the canonical one with the same top is at least as cheap to sign let canon = Layers::new(&Params { h_top: Some(11), ..p }).unwrap(); - assert_eq!(format!("{}", canon.profile), "11 + 3 x 5"); + assert_eq!(format!("{}", canon.profile), "11 + 5 + 5 + 5"); assert!(canon.trees_cached.compressions <= lay.trees_cached.compressions); // heights have to add up over the layers there are assert!(Layers::from_profile(&p, Profile::new(&[11, 5, 7, 4]).unwrap()).is_none()); From 737c6d6a7c3990f79bf28347714f642027524eff Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 17:01:15 +0200 Subject: [PATCH 21/31] doc/sphincs: a WOTS instance per layer, and rayon Every layer now carries its own Winternitz parameter, target sum and dropped chain count, not just its height. Layer says what that buys, --layer costs one such hypertree outright, and --split-wots searches a separate instance for the top layer. The Lagrangian the search was going to need collapses, which is the useful finding. Minimising sum_i [verify_i + L*size_i + M*sign_i] is separable, so each layer takes its own argmin, and every layer below the top has an identical cost function, since only the top tree is the cached one and only it is what keygen pays for. So at most two distinct choices ever come back, ties aside, and ties are between neighbouring heights, which the +-1 split already spans. Enumerating that two-group family gives the same answers with no duality gap and stays a brute force, so the naive oracle survives: two_groups_against_every_per_layer_assignment checks it against every per-layer assignment of four small hypertrees and finds no gap. Whether per-layer WOTS is worth searching is a separate question, and the answer so far is no. Size charges every layer the same l*n and verification charges every layer its own walk, so the exchange rate between them is identical everywhere and a uniform w is what a size budget wants: the walk (2^(8n/l) - 1)*l is convex in l, so at a fixed total l an equal split is cheapest. Only signing distinguishes the layers. On the keygen-starved query where heights come out 6 + 11 + 11, --split-wots finds the uniform choice still winning at 377 compressions, with the split variants at 379. The target sums do differ by a step, 205 on the top layer against 204 below. Costs are now assembled from per-layer sums, which meant splitting the model along the seam that matters: hyper_cost adds up a hypertree once, Fors is the FORS side, and assemble is arithmetic. The target sums are allocated by building each hypertree's grinding frontier once, greedily merging the two groups' marginal costs, and binary searching it per (a, k) rather than rescanning. Three things made the first working version 98s where the old one was 2s: a NuTable cloned per candidate to dodge a borrow (NuCache::pair now hands out two at once), a linear frontier scan, and the size prune losing its break. That brought it to 14s, and rayon over the (scheme, WOTS instance) tasks to 2.1s, with the security table filled up front so the workers share it read-only. --split-wots is 182 times the grid and takes minutes; the help says so. Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/params_selection/Cargo.lock | 54 ++ doc/sphincs/params_selection/Cargo.toml | 1 + doc/sphincs/params_selection/README.md | 6 +- doc/sphincs/params_selection/src/cost.rs | 54 ++ doc/sphincs/params_selection/src/lib.rs | 14 +- doc/sphincs/params_selection/src/main.rs | 101 ++- doc/sphincs/params_selection/src/params.rs | 761 ++++++++++-------- doc/sphincs/params_selection/src/report.rs | 162 ++-- doc/sphincs/params_selection/src/search.rs | 634 ++++++++------- doc/sphincs/params_selection/src/security.rs | 29 +- doc/sphincs/params_selection/tests/goldens.rs | 412 +++++----- 11 files changed, 1279 insertions(+), 949 deletions(-) diff --git a/doc/sphincs/params_selection/Cargo.lock b/doc/sphincs/params_selection/Cargo.lock index f16565f89..643dd6230 100644 --- a/doc/sphincs/params_selection/Cargo.lock +++ b/doc/sphincs/params_selection/Cargo.lock @@ -2,6 +2,60 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "sphincs_params" version = "0.1.0" +dependencies = [ + "rayon", +] diff --git a/doc/sphincs/params_selection/Cargo.toml b/doc/sphincs/params_selection/Cargo.toml index 1387cca24..45f7abd5c 100644 --- a/doc/sphincs/params_selection/Cargo.toml +++ b/doc/sphincs/params_selection/Cargo.toml @@ -8,6 +8,7 @@ edition = "2024" [workspace] [dependencies] +rayon = "1.12.0" [lints.clippy] too_many_arguments = "allow" diff --git a/doc/sphincs/params_selection/README.md b/doc/sphincs/params_selection/README.md index 1795a6a5f..e7b701652 100644 --- a/doc/sphincs/params_selection/README.md +++ b/doc/sphincs/params_selection/README.md @@ -21,7 +21,11 @@ Every cost is compression calls, one per 64 bytes of hash input: a Merkle node o Since size and verification depend only on `(h, d)` and not on how the layers divide `h`, a taller top layer is free on both and cheaper to sign with the cache: compare `--top-height 8` against `--top-height 15` at `--height 40 --layers 5`. -Layer heights can be given outright with `--heights 11,5,7,3`, which pins `h` and `d` with them. The search never produces an uneven lower half, and that is not a restriction: for the same `h`, `d` and top height, no other profile costs less on anything, since size, verification and keygen do not move and signing sums `2^height`, which at a fixed total is smallest when the heights are equal. `profile_shape_is_never_beaten` checks that against every composition of several small `(h, d)`. So `--heights` is for costing a profile you already have in mind. +Each layer carries its own WOTS instance too, so `w`, the target sum and the dropped chains need not agree across layers. `--layer 12,w=16,swn=240 --layer 12,w=8,drop=1` costs one such hypertree outright, `--heights 11,5,7,3` gives just the heights, and `--split-wots` searches a separate instance for the top layer. + +Two instances is all a search needs. Any Lagrangian relaxation of the per-layer choice is separable, so each layer takes its own argmin, and every layer below the top has an identical cost function, since only the top tree is the cached one and only it is what keygen pays for. So at most two distinct choices come back. `two_groups_against_every_per_layer_assignment` checks that against every per-layer assignment of several small hypertrees, and finds no gap. + +Whether it is worth searching is another matter. Size charges every layer the same `l * n` and verification charges every layer its own walk, so on those two the exchange rate is identical everywhere and a uniform `w` is what a size budget wants: the walk is convex in `l`, so at a fixed total `l` an equal split is cheapest. Only signing distinguishes the layers, a tall tree wanting cheap leaves. So per-layer WOTS pays only when the signing budget binds and the heights are uneven, and on the queries tried here the uniform choice still wins. `cargo run --release --` with no arguments prints every flag and its default. `cargo test --release` runs the goldens: the upstream sage fixtures, the report's own tables, and a naive search oracle that skips nothing. diff --git a/doc/sphincs/params_selection/src/cost.rs b/doc/sphincs/params_selection/src/cost.rs index b368c5797..7c2f73952 100644 --- a/doc/sphincs/params_selection/src/cost.rs +++ b/doc/sphincs/params_selection/src/cost.rs @@ -307,6 +307,11 @@ impl NuTable { self.trials.get(swn as usize).copied().unwrap_or(u64::MAX) } + /// The largest target sum the code can reach. + pub fn max_swn(&self) -> u64 { + self.l * (self.w - 1) + } + /// The least grinding any target sum can ask for. /// /// Read off the table rather than assumed to sit at the mean, so nothing @@ -315,3 +320,52 @@ impl NuTable { self.trials.iter().copied().min().unwrap_or(u64::MAX) } } + +/// [`NuTable`]s by `(l, chain_bits)`, since a search revisits the same few. +/// +/// Direct-mapped rather than hashed: a search asks for one per layer per +/// candidate, millions of times, and hashing showed up in the profile. +pub struct NuCache { + digest_bits: u32, + slots: Vec>, +} + +const MAX_L: usize = 512; +const MAX_BITS: usize = 16; + +impl NuCache { + pub fn new(n: u64) -> Self { + Self { + digest_bits: (8 * n) as u32, + slots: vec![None; MAX_L * MAX_BITS], + } + } + + fn slot(l: u64, w: u64) -> Option { + let bits = w.trailing_zeros() as usize; + (w.is_power_of_two() && (l as usize) < MAX_L && bits < MAX_BITS).then(|| l as usize * MAX_BITS + bits) + } + + pub fn table(&mut self, l: u64, w: u64) -> &NuTable { + let i = Self::slot(l, w).expect("l and w within the searched ranges"); + if self.slots[i].is_none() { + self.slots[i] = Some(NuTable::new(l, w, self.digest_bits)); + } + self.slots[i].as_ref().expect("just filled") + } + + /// Two tables at once, which a two-group hypertree needs. + pub fn pair(&mut self, top: (u64, u64), low: (u64, u64)) -> (&NuTable, &NuTable) { + self.table(top.0, top.1); + self.table(low.0, low.1); + let (i, j) = (Self::slot(top.0, top.1).unwrap(), Self::slot(low.0, low.1).unwrap()); + if i == j { + let t = self.slots[i].as_ref().unwrap(); + return (t, t); + } + let (lo, hi) = if i < j { (i, j) } else { (j, i) }; + let (left, right) = self.slots.split_at(hi); + let (a, b) = (left[lo].as_ref().unwrap(), right[0].as_ref().unwrap()); + if i < j { (a, b) } else { (b, a) } + } +} diff --git a/doc/sphincs/params_selection/src/lib.rs b/doc/sphincs/params_selection/src/lib.rs index bcf6203d8..8bc19ec18 100644 --- a/doc/sphincs/params_selection/src/lib.rs +++ b/doc/sphincs/params_selection/src/lib.rs @@ -21,14 +21,20 @@ //! //! The hypertree's height is split per layer, not `h/d` on every layer: the top //! tree gets `h_top` and the rest divide what is left as evenly as it goes, so -//! `d` need not divide `h`, and [`params::Profile`] can hold any heights at all -//! though only that shape is ever searched. That matters because the signature -//! carries `h` +//! `d` need not divide `h`, and [`params::Hypertree`] can hold any heights at +//! all though only that shape is ever searched. That matters because the +//! signature carries `h` //! authentication nodes and the verifier walks them however the layers divide //! `h`: size and verification depend only on `(h, d)`, while only the top tree //! is cacheable. A taller top layer is therefore free on both, costs keygen and //! vanilla signing, and cuts cached signing, which at `h = 40, d = 5` is 2x for -//! `h_top = 15` against the uniform 8. See [`params::Profile`]. +//! `h_top = 15` against the uniform 8. +//! +//! A [`params::Layer`] also carries its own WOTS instance, so `w`, the target +//! sum and the dropped chains need not agree across layers either, and +//! [`params::Layer`] says what varying them buys and when. A search tries two +//! instances, one for the top layer and one for the rest, which +//! [`params::Hypertree::two_group`] argues is all it needs. //! //! For one parameter set [`params::costs`] reports the signature size and the //! keygen, signing and verification cost, and [`security::security_bits`] the diff --git a/doc/sphincs/params_selection/src/main.rs b/doc/sphincs/params_selection/src/main.rs index a426b5d8d..d92819fbe 100644 --- a/doc/sphincs/params_selection/src/main.rs +++ b/doc/sphincs/params_selection/src/main.rs @@ -2,7 +2,7 @@ //! and everything left over gets searched. use sphincs_params::cost::{SCHEMES, Scheme}; -use sphincs_params::params::Profile; +use sphincs_params::params::{Hypertree, Layer}; use sphincs_params::report::{legend, report, si, signatures, table, utilization}; use sphincs_params::search::{ A_MAX, Budgets, CHAIN_BITS_MAX, D_MAX, DROPPED_MAX, Grid, H_MAX, K_MAX, LEVEL1_BITS, Span, Stats, Sums, edges, @@ -25,10 +25,14 @@ parameters --top-height ht height of the top XMSS tree, the rest of h splitting evenly below it [1..h-d+1, or h/d] --heights H,... every layer height outright, top first, pinning h and d - with it. Nothing here searches uneven lower layers, - because for the same h, d and top height they never cost - less: see Profile in src/params.rs. This is for costing - one anyway. + --layer SPEC one layer outright, repeated top first, e.g. + --layer 12,w=16,swn=240 --layer 12,w=8,drop=1 + Fields: the bare number is the height, then w=, swn=, + drop=. Pins h, d and every layer's WOTS instance. + --split-wots search a separate WOTS instance for the top layer rather + than one for the whole hypertree. Two instances is all a + search needs (see Hypertree::two_group), but it multiplies + the grid by the number of instances, so expect minutes. -a A log2 of the leaves in a FORS tree [1..32] -k K FORS trees [1..64] --chain-bits B log2(w), repeatable [1..12] @@ -88,15 +92,17 @@ fn main() -> std::process::ExitCode { /// Flags and their values, repeatable flags kept in order. struct Args(Vec<(String, Option)>); -const NO_VALUE: [&str; 4] = ["--cache-level-only", "--stats", "--help", "-h"]; +const NO_VALUE: [&str; 5] = ["--cache-level-only", "--stats", "--help", "-h", "--split-wots"]; -const FLAGS: [&str; 22] = [ +const FLAGS: [&str; 24] = [ "--lifetime", "--scheme", "--height", "--layers", "--top-height", "--heights", + "--layer", + "--split-wots", "-a", "-k", "--chain-bits", @@ -236,6 +242,61 @@ impl Args { } } +/// `--heights 12,7,7`, or `--layer 12,w=16,swn=240 --layer 7,w=8` repeated once +/// per layer, top first. Both give the hypertree outright. +fn layers_from(args: &Args) -> Result, String> { + let specs = args.all("--layer"); + if !specs.is_empty() { + let layers: Vec = specs + .iter() + .map(|spec| { + let mut height = None; + let (mut w, mut dropped, mut swn) = (16, 0, None); + for field in spec.split(',') { + let field = field.trim(); + let (key, value) = field.split_once('=').unwrap_or(("height", field)); + let value: u64 = value + .replace([',', '_'], "") + .parse() + .map_err(|_| format!("--layer {spec}: {value} is not a number"))?; + match key { + "height" | "h" => height = Some(value), + "w" => w = value, + "drop" | "dropped" => dropped = value, + "swn" | "S" => swn = Some(value), + other => return Err(format!("--layer {spec}: unknown field {other}")), + } + } + let height = height.ok_or_else(|| format!("--layer {spec}: no height"))?; + Layer::new(height, w, dropped, swn) + .ok_or_else(|| format!("--layer {spec}: height 1..=63 and w a power of two are needed")) + }) + .collect::>()?; + return Ok(Some( + Hypertree::new(&layers).ok_or("--layer: 1 to 32 layers, top first")?, + )); + } + let Some(list) = args.get("--heights") else { + return Ok(None); + }; + let heights: Vec = list + .split(',') + .map(|x| { + x.trim() + .parse::() + .map_err(|_| format!("--heights: expected numbers, got {list}")) + }) + .collect::>()?; + let w = args.num("-w")?.unwrap_or(16); + let dropped = args.num("--drop-chains")?.unwrap_or(0); + let swn = args.num("--swn")?; + let layers: Option> = heights.iter().map(|&h| Layer::new(h, w, dropped, swn)).collect(); + Ok(Some( + Hypertree::new(&layers.ok_or("--heights: heights are 1..=63")?) + .ok_or("--heights: 1 to 32 heights, top first")?, + )) +} + fn run(argv: &[String]) -> Result { let args = Args::parse(argv)?; let q_s = args.float("--lifetime")?.ok_or("--lifetime is required")?; @@ -270,29 +331,16 @@ fn run(argv: &[String]) -> Result { (None, true) => Some(Span::new(1, H_MAX)), (None, false) => None, }; - let profile = match args.get("--heights") { - None => None, - Some(list) => { - let heights: Vec = list - .split(',') - .map(|x| { - x.trim() - .parse::() - .map_err(|_| format!("--heights: expected numbers, got {list}")) - }) - .collect::>()?; - Some(Profile::new(&heights).ok_or_else(|| format!("--heights: {list} is not 1..=32 heights of 1..=63"))?) - } - }; + let hypertree = layers_from(&args)?; let g = Grid { schemes: args.schemes()?, n: args.u64_or("-n", 16)?, - h: match profile { - Some(pr) => Span::pin(pr.total()), + h: match hypertree { + Some(ht) => Span::pin(ht.height()), None => args.span("--height", Span::new(1, H_MAX))?, }, - d: match profile { - Some(pr) => Span::pin(pr.layers()), + d: match hypertree { + Some(ht) => Span::pin(ht.depth()), None => args.span("--layers", Span::new(1, D_MAX))?, }, h_top, @@ -301,7 +349,8 @@ fn run(argv: &[String]) -> Result { dropped, chain_bits: args.chain_bits()?, sums, - profile, + split_wots: args.flag("--split-wots"), + hypertree, cache_height: args.num("--cache-height")?, cache_level_only: args.flag("--cache-level-only"), }; diff --git a/doc/sphincs/params_selection/src/params.rs b/doc/sphincs/params_selection/src/params.rs index e84e85986..8fb420987 100644 --- a/doc/sphincs/params_selection/src/params.rs +++ b/doc/sphincs/params_selection/src/params.rs @@ -1,422 +1,420 @@ //! One parameter set, and the costs it implies. +//! +//! A parameter set is a [`Params`] (the FORS side and the hash size) plus a +//! [`Hypertree`], which carries every layer's Merkle height and the WOTS +//! parameters signing into it. Nothing forces those to agree across layers, and +//! [`Layer`] documents what varying them buys. -use crate::cost::{Blocks, COUNTER_BYTES, Cost, Encoding, NuTable, Scheme}; +use crate::cost::{Blocks, COUNTER_BYTES, Cost, Encoding, NuCache, NuTable, Scheme}; -/// A SPHINCS+ parameter set. `q_s` is not part of it: see [`crate::security`]. +/// The most hypertree layers a [`Hypertree`] can hold. +pub const MAX_LAYERS: usize = 32; + +/// `Layer::swn` when the target sum is the mean, where grinding is cheapest. +const SWN_MEAN: u32 = u32::MAX; + +/// One hypertree layer: its Merkle height, and the WOTS instance whose keys sit +/// at its leaves and sign the layer below (the FORS root, at the bottom). +/// +/// The layers need not agree. What that buys is narrow but real. Verification, +/// signature size and per-leaf signing work all move together with `w`: a +/// smaller `w` means more chains, so a bigger signature, but fewer chain steps +/// to walk and fewer to build. Size charges every layer the same `l * n`, and +/// verification charges every layer its own walk, so on those two the exchange +/// rate is identical everywhere and a uniform `w` is what a size budget wants: +/// the walk `(2^(8n/l) - 1) * l` is convex in `l`, so at a fixed total `l` an +/// equal split is cheapest. +/// +/// Signing is where the layers differ, because a layer's tree costs +/// `2^height` leaves. A tall tree wants cheap leaves, so a small `w`, and a +/// short one can afford a large `w` to give its size back. So per-layer WOTS +/// pays exactly when the signing budget binds and the heights are uneven, which +/// is what a tight key generation budget produces. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Params { - pub scheme: Scheme, - /// Total hypertree height, the sum of the layer heights. - pub h: u64, - /// Hypertree layers. - pub d: u64, - /// Height of the top XMSS tree. `None` spreads `h` as evenly as it goes, - /// which for `d | h` is the classic `h' = h/d` on every layer. - pub h_top: Option, - /// FORS trees have `2^a` leaves. - pub a: u64, - /// Number of FORS trees. - pub k: u64, - /// Winternitz parameter, a power of two. - pub w: u64, - /// Hash output in bytes. - pub n: u64, - /// Chains dropped beyond the digest bits that have to be pinned anyway. - pub dropped_chains: u64, - /// Height above the leaves of the cached top-tree level; `None` is half of it. - pub cache_height: Option, - /// Cache one level rather than it and everything above. - pub cache_level_only: bool, +pub struct Layer { + height: u8, + chain_bits: u8, + dropped: u8, + swn: u32, } -/// The height of every XMSS tree in the hypertree, top first. -/// -/// Any heights are expressible, but a search only ever needs -/// [`Profile::canonical`]: the top tree at some height and the rest dividing -/// what is left as evenly as it goes. For a fixed `(h, d, h_top)` that shape is -/// no worse than any other on every cost, since size and verification depend -/// only on `(h, d)`, keygen only on `h_top`, and signing sums `2^height` over -/// the layers, which at a fixed total is smallest when they are equal. So -/// enumerating `(h, d, h_top)` covers the cost-optimal representative of every -/// profile. `profile_shape_is_never_beaten` in `tests/goldens` checks that -/// against every composition of a few small `(h, d)`. +impl Layer { + /// `None` unless the height is countable, `w` is a power of two, and the + /// target sum is reachable. + pub fn new(height: u64, w: u64, dropped_chains: u64, swn: Option) -> Option { + if !(1..=63).contains(&height) || w < 2 || !w.is_power_of_two() || dropped_chains > 255 { + return None; + } + let swn = match swn { + None => SWN_MEAN, + Some(s) => u32::try_from(s).ok().filter(|&s| s != SWN_MEAN)?, + }; + Some(Self { + height: height as u8, + chain_bits: w.trailing_zeros() as u8, + dropped: dropped_chains as u8, + swn, + }) + } + + pub fn height(&self) -> u64 { + self.height as u64 + } + pub fn w(&self) -> u64 { + 1 << self.chain_bits + } + pub fn chain_bits(&self) -> u64 { + self.chain_bits as u64 + } + pub fn dropped_chains(&self) -> u64 { + self.dropped as u64 + } + /// The target digit sum, or `None` for the mean. + pub fn swn(&self) -> Option { + (self.swn != SWN_MEAN).then_some(self.swn as u64) + } + + pub fn with_height(&self, height: u64) -> Option { + Self::new(height, self.w(), self.dropped_chains(), self.swn()) + } + pub fn with_swn(&self, swn: Option) -> Option { + Self::new(self.height(), self.w(), self.dropped_chains(), swn) + } + + pub fn encoding(&self, n: u64) -> Option { + Encoding::new(self.w(), n, self.dropped_chains()) + } + + /// Chains actually signed: `l1 + l2` for WOTS-TW, the encoding's for WOTS+C. + pub fn chains(&self, n: u64, scheme: Scheme) -> Option { + let enc = self.encoding(n)?; + if scheme.wots_c() { + return Some(enc.chains); + } + // WOTS-TW pads the digest to whole digits and appends a checksum + // (FIPS 205): l1 = ceil(8n / log2 w), l2 = floor(log_w(l1*(w-1))) + 1. + let l1 = (8 * n).div_ceil(enc.chain_bits); + Some(l1 + self.wots_tw_len2(l1, n)) + } + + fn wots_tw_len2(&self, l1: u64, n: u64) -> u64 { + let _ = n; + (l1 * (self.w() - 1)).ilog2() as u64 / self.chain_bits() + 1 + } + + /// Verifier chain steps for WOTS-TW when every message digit is zero. + fn wots_tw_worst_steps(&self, n: u64) -> u64 { + let bits = self.chain_bits(); + let l1 = (8 * n).div_ceil(bits); + let l2 = self.wots_tw_len2(l1, n); + let (w, c) = (self.w(), l1 * (self.w() - 1)); + let digit_sum = { + let (mut rem, mut sum) = (c, 0); + while rem > 0 { + sum += rem % w; + rem /= w; + } + sum + }; + l1 * (w - 1) + l2 * (w - 1) - digit_sum + } +} + +/// Every layer of the hypertree, top first. /// -/// Heights are at most 63, since `2^height` has to be countable, and there are -/// at most [`MAX_LAYERS`] of them. +/// Any heights and any WOTS parameters are expressible. A search need not try +/// them all: see [`Hypertree::two_group`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Profile { - heights: [u8; MAX_LAYERS], +pub struct Hypertree { + layers: [Layer; MAX_LAYERS], len: u8, } -/// The most hypertree layers a [`Profile`] can hold. -pub const MAX_LAYERS: usize = 32; - -impl Profile { - /// Any heights at all, the top tree first. - pub fn new(heights: &[u64]) -> Option { - if heights.is_empty() || heights.len() > MAX_LAYERS || heights.iter().any(|&x| !(1..=63).contains(&x)) { +impl Hypertree { + pub fn new(layers: &[Layer]) -> Option { + if layers.is_empty() || layers.len() > MAX_LAYERS { return None; } let mut out = Self { - heights: [0; MAX_LAYERS], - len: heights.len() as u8, + layers: [layers[0]; MAX_LAYERS], + len: layers.len() as u8, }; - for (slot, &h) in out.heights.iter_mut().zip(heights) { - *slot = h as u8; - } + out.layers[..layers.len()].copy_from_slice(layers); Some(out) } - /// The top tree at `h_top`, the other `d - 1` layers dividing `h - h_top` as - /// evenly as it goes. `None` for `h_top` is the classic `h/d` split. - pub fn canonical(h: u64, d: u64, h_top: Option) -> Option { - if d == 0 || d as usize > MAX_LAYERS || h == 0 { + /// One WOTS instance for every layer, the top tree at `h_top` and the rest + /// dividing `h - h_top` as evenly as it goes. `None` for `h_top` is the + /// classic `h/d` split. + pub fn uniform(h: u64, d: u64, h_top: Option, w: u64, dropped: u64, swn: Option) -> Option { + let top = Layer::new(h_top.unwrap_or(h / d).max(1), w, dropped, swn)?; + Self::two_group(h, d, top, top) + } + + /// The top layer as given, every other layer sharing `low`'s WOTS + /// parameters and dividing what is left of `h` as evenly as it goes. + /// + /// This is the only shape a search has to try. Any Lagrangian relaxation of + /// the layer choice, `min sum_i [verify_i + L*size_i + M*sign_i]`, is + /// separable and so returns each layer's own argmin; but every layer below + /// the top has an identical cost function, since only the top tree is the + /// cached one and only it is what keygen pays for. So at most two distinct + /// choices come back, ties aside, and the ties are between neighbouring + /// heights, which the split here already spans. `two_group_attains_the_optimum` + /// in `tests/goldens` checks that against every per-layer assignment of + /// small hypertrees. + pub fn two_group(h: u64, d: u64, top: Layer, low: Layer) -> Option { + if d == 0 || d as usize > MAX_LAYERS { return None; } - let h_top = h_top.unwrap_or(h / d).max(1); - let lower_total = h.checked_sub(h_top)?; + let lower_total = h.checked_sub(top.height())?; let m = d - 1; if m == 0 { - return (lower_total == 0).then(|| Self::new(&[h_top]))?; + return (lower_total == 0).then(|| Self::new(&[top]))?; } if lower_total < m { return None; // every layer needs at least one level } let (q, r) = (lower_total / m, lower_total % m); - let mut heights = vec![h_top]; - heights.extend(std::iter::repeat_n(q + 1, r as usize)); - heights.extend(std::iter::repeat_n(q, (m - r) as usize)); - Self::new(&heights) + let mut layers = vec![top]; + for i in 0..m { + layers.push(low.with_height(if i < r { q + 1 } else { q })?); + } + Self::new(&layers) } - pub fn heights(&self) -> impl Iterator + '_ { - self.heights[..self.len as usize].iter().map(|&x| x as u64) + pub fn layers(&self) -> impl Iterator + '_ { + self.layers[..self.len as usize].iter().copied() } - /// The top tree's height: the one layer that is the same for every signature. - pub fn h_top(&self) -> u64 { - self.heights().next().unwrap_or(0) + /// The one layer that is the same for every signature, and so the only one + /// worth caching. + pub fn top(&self) -> Layer { + self.layers[0] } - pub fn total(&self) -> u64 { - self.heights().sum() + /// Total height, which is what the authentication path carries. + pub fn height(&self) -> u64 { + self.layers().map(|x| x.height()).sum() } - pub fn layers(&self) -> u64 { + pub fn depth(&self) -> u64 { self.len as u64 } - pub fn uniform(&self) -> bool { - self.heights().all(|x| x == self.h_top()) + /// Do all layers share their WOTS parameters? + pub fn one_wots(&self) -> bool { + let top = self.top(); + self.layers() + .all(|x| (x.w(), x.dropped_chains(), x.swn()) == (top.w(), top.dropped_chains(), top.swn())) } -} -impl std::fmt::Display for Profile { - /// Every height, top first: `12 + 7 + 7`. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let listed: Vec = self.heights().map(|h| h.to_string()).collect(); - write!(f, "{}", listed.join(" + ")) + pub fn with_swn(&self, swn: Option) -> Option { + let layers: Option> = self.layers().map(|x| x.with_swn(swn)).collect(); + Self::new(&layers?) } -} -impl Params { - pub fn profile(&self) -> Option { - Profile::canonical(self.h, self.d, self.h_top) - } - - pub fn encoding(&self) -> Option { - Encoding::new(self.w, self.n, self.dropped_chains) + /// Heights only: `12 + 7 + 7`. + pub fn heights(&self) -> String { + let listed: Vec = self.layers().map(|x| x.height().to_string()).collect(); + listed.join(" + ") } +} - /// Chains actually signed: `l1 + l2` for WOTS-TW, the encoding's for WOTS+C. - pub fn chains(&self) -> Option { - let enc = self.encoding()?; - if self.scheme.wots_c() { - return Some(enc.chains); +impl std::fmt::Display for Hypertree { + /// The heights, and the WOTS parameters wherever the layers disagree. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.one_wots() { + return write!(f, "{}", self.heights()); } - // WOTS-TW pads the digest to whole digits and appends a checksum - // (FIPS 205): l1 = ceil(8n / log2 w), l2 = floor(log_w(l1*(w-1))) + 1. - let l1 = (8 * self.n).div_ceil(enc.chain_bits); - Some(l1 + self.wots_tw_len2(l1)) - } - - fn wots_tw_len2(&self, l1: u64) -> u64 { - let bits = self.encoding().expect("checked by the caller").chain_bits; - (l1 * (self.w - 1)).ilog2() as u64 / bits + 1 + let listed: Vec = self + .layers() + .map(|x| match (x.dropped_chains(), x.swn()) { + (0, None) => format!("{}(w={})", x.height(), x.w()), + (0, Some(s)) => format!("{}(w={},S={s})", x.height(), x.w()), + (dr, None) => format!("{}(w={},-{dr})", x.height(), x.w()), + (dr, Some(s)) => format!("{}(w={},S={s},-{dr})", x.height(), x.w()), + }) + .collect(); + write!(f, "{}", listed.join(" + ")) } +} - /// Verifier chain steps for WOTS-TW when every message digit is zero. - fn wots_tw_worst_steps(&self) -> u64 { - let enc = self.encoding().expect("checked by the caller"); - let l1 = (8 * self.n).div_ceil(enc.chain_bits); - let l2 = self.wots_tw_len2(l1); - let c = l1 * (self.w - 1); - let digit_sum: u64 = { - let (mut rem, mut sum) = (c, 0); - while rem > 0 { - sum += rem % self.w; - rem /= self.w; - } - sum - }; - l1 * (self.w - 1) + l2 * (self.w - 1) - digit_sum - } +/// The FORS side of a parameter set, and the hash size. The hypertree is +/// separate: see [`Hypertree`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Params { + pub scheme: Scheme, + /// FORS trees have `2^a` leaves. + pub a: u64, + /// Number of FORS trees. + pub k: u64, + /// Hash output in bytes. + pub n: u64, + /// Height above the leaves of the cached top-tree level; `None` is half of it. + pub cache_height: Option, + /// Cache one level rather than it and everything above. + pub cache_level_only: bool, +} - /// The compression counts of the hashes this parameter set uses. +impl Params { pub fn blocks(&self) -> Blocks { Blocks::new(self.n) } - /// One WOTS key pair, plus the compression of its `l` chain ends into a leaf. - fn wots_leaf(&self, l: u64) -> Cost { - let b = self.blocks(); - Cost::new( - l + l * (self.w - 1) + 1, - l * b.prf() + l * (self.w - 1) * b.chain_step() + b.compress(l), - ) + /// FORS trees actually built and authenticated: FORS+C grinds the last away. + pub fn trees(&self) -> u64 { + self.scheme.trees(self.k) } } -/// The hypertree side: what the layer heights cost, at any `(a, k)` and any -/// target sum. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Layers { - pub profile: Profile, - /// Generating the top tree, which is all key generation does. - pub keygen: Cost, - /// Regrowing every layer, which is what signing does. - pub trees: Cost, - /// The same with the top tree's half top already in state. - pub trees_cached: Cost, +/// What one layer costs. Every field is additive across layers except the +/// cached tree and the cache itself, which only the top layer has. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct LayerCost { + pub l: u64, + /// The target sum in force, the mean resolved. + pub swn: u64, + /// Counter values tried per signature at this layer. + pub trials: u64, + /// The authentication path and the WOTS signature this layer contributes. + pub sig_bytes: u64, + /// Walking this layer's chains and its authentication path. + pub verify: Cost, + /// The extra a WOTS-TW verifier walks when every message digit is zero. + pub verify_worst_extra: Cost, + /// Growing this layer's tree from the seed. + pub tree: Cost, + /// The same with its half top already in state, which is worth doing only + /// for the top layer. + pub tree_cached: Cost, pub cache_bytes: u64, pub cache_depth: u64, } -impl Layers { - /// The canonical profile of `p`: see [`Profile::canonical`]. - pub fn new(p: &Params) -> Option { - Self::from_profile(p, p.profile()?) +/// One layer's costs. `nu` has to be the table for this layer's `(l, w)`. +pub fn layer_cost(layer: Layer, p: &Params, nu: Option<&NuTable>) -> Option { + let (n, b, w) = (p.n, p.blocks(), layer.w()); + let l = layer.chains(n, p.scheme)?; + let enc = layer.encoding(n)?; + let leaves = 1u64 << layer.height(); + + // One WOTS key pair, plus the compression of its l chain ends into a leaf. + let leaf = Cost::new( + l + l * (w - 1) + 1, + l * b.prf() + l * (w - 1) * b.chain_step() + b.compress(l), + ); + let tree = |height: u64| { + let count = 1u64 << height; + leaf * count + Cost::new(count - 1, (count - 1) * b.merkle_node()) + }; + + // Only the top tree is worth caching: it is the same for every signature, + // while the trees below it are picked by the (pseudorandom) index. Its auth + // path splits at the cached level: below, rebuild the 2^c-leaf subtree the + // signing leaf sits in; above, the nodes are already in state. Rebuilt + // leaves are charged a full WOTS public key, as everywhere else here. + // + // A BDS-style traversal would amortize a tree to h' leaves per signature + // with O(h') state, but it only works walking the leaves in order. + // SPHINCS+ picks its index by hashing the message, so consecutive + // signatures land on unrelated leaves and nothing amortizes; an + // index-independent cache like this one is what is left, hence sqrt rather + // than h'. + let c = p.cache_height.unwrap_or(layer.height() / 2); + if c > layer.height() { + return None; } - - /// Any profile, as long as its heights add up to `p.h` over `p.d` layers. - pub fn from_profile(p: &Params, profile: Profile) -> Option { - if profile.total() != p.h || profile.layers() != p.d { - return None; - } - let l = p.chains()?; - let leaf = p.wots_leaf(l); - let b = p.blocks(); - let tree = |height: u64| { - let leaves = 1u64 << height; - leaf * leaves + Cost::new(leaves - 1, (leaves - 1) * b.merkle_node()) - }; - let top = tree(profile.h_top()); - let lower = profile - .heights() - .skip(1) - .map(tree) - .fold(Cost::default(), |acc, x| acc + x); - - // Only the top tree is worth caching: it is the same for every - // signature, while the trees below it are picked by the (pseudorandom) - // index. Its auth path splits at the cached level: below, rebuild the - // 2^c-leaf subtree the signing leaf sits in; above, the nodes are - // already in state. Rebuilt leaves are charged a full WOTS public key, - // as everywhere else here. - // - // A BDS-style traversal would amortize a tree to h' leaves per - // signature with O(h') state, but it only works walking the leaves in - // order. SPHINCS+ picks its index by hashing the message, so - // consecutive signatures land on unrelated leaves and nothing - // amortizes; an index-independent cache like this one is what is left, - // hence sqrt rather than h'. - let c = p.cache_height.unwrap_or(profile.h_top() / 2); - if c > profile.h_top() { - return None; - } - let stored_level = 1u64 << (profile.h_top() - c); - let mut cached = tree(c); - let cache_bytes; - if p.cache_level_only { - cached = cached + Cost::new(stored_level - 1, (stored_level - 1) * b.merkle_node()); - cache_bytes = stored_level * p.n; - } else { - cache_bytes = (2 * stored_level - 1) * p.n; - } - - Some(Self { - profile, - keygen: top, - trees: top + lower, - trees_cached: cached + lower, - cache_bytes, - cache_depth: profile.h_top() - c, - }) + let stored_level = 1u64 << (layer.height() - c); + let mut cached = tree(c); + let cache_bytes; + if p.cache_level_only { + cached = cached + Cost::new(stored_level - 1, (stored_level - 1) * b.merkle_node()); + cache_bytes = stored_level * n; + } else { + cache_bytes = (2 * stored_level - 1) * n; } + + let counter = if p.scheme.wots_c() { COUNTER_BYTES } else { 0 }; + let auth = Cost::new(layer.height(), layer.height() * b.merkle_node()); + let (swn, trials, verify, verify_worst_extra) = if p.scheme.wots_c() { + let swn = layer.swn().unwrap_or(enc.default_swn()); + let table = nu?; + // the digits sum to S_wn, so the remaining chain steps are fixed at + // (w-1)*l - S_wn, and the counter is hashed in once per layer + let steps = (w - 1) * l.checked_sub(0)?; + let steps = steps.checked_sub(swn.min(steps))?; + let walk = Cost::new( + steps + 2, + steps * b.chain_step() + b.chain_step_with_counter() + b.compress(l), + ); + (swn, table.trials(swn), walk + auth, Cost::default()) + } else { + let avg = (w - 1) * l / 2; + let worst = layer.wots_tw_worst_steps(n); + let walk = Cost::new(avg + 1, avg * b.chain_step() + b.compress(l)); + let extra = Cost::new(worst - avg, (worst - avg) * b.chain_step()); + (0, 0, walk + auth, extra) + }; + + Some(LayerCost { + l, + swn, + trials, + sig_bytes: layer.height() * n + l * n + counter, + verify, + verify_worst_extra, + tree: tree(layer.height()), + tree_cached: cached, + cache_bytes, + cache_depth: layer.height() - c, + // `leaves` is only here to keep the shift above honest + }) + .filter(|_| leaves > 0) } -/// Everything else: the FORS side, the signature size and the verifier, none of -/// which depends on how the hypertree's height is split between its layers. -/// -/// Split from [`Layers`] and from the target sum so that a search can reject a -/// candidate on size, or on the least any layer profile and any target sum could -/// cost, without pretending to know which of those are good. -#[derive(Clone, Debug)] -pub struct Skeleton { - pub params: Params, - pub l: u64, - pub chain_bits: u64, - pub pinned_bits: u64, - pub max_swn: u64, - pub default_swn: u64, +/// The FORS side, which no layer sees. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Fors { pub sig_bytes: u64, - /// What FORS+C's digest grinding costs, zero for the other schemes. - pub fors_c_grinding: Cost, - /// The `(a, k)` part of signing: growing the FORS trees, and any grinding - /// FORS+C does. Common to both signing costs, cached or not. - pub fors_part: Cost, - /// One counter trial, at one layer. - pub grind_step: Cost, - /// Verification, less the chain walk that the target sum shortens. - verify_base: Cost, - /// One chain step, across all layers. - verify_step: Cost, - /// WOTS-TW only; for WOTS+C verification is deterministic. - verify_worst_extra: Cost, + /// Growing the trees, and any grinding FORS+C does. + pub sign: Cost, + pub grinding: Cost, + /// Opening the leaves, plus the message hash. + pub verify: Cost, } -impl Skeleton { - /// `None` if the parameters are not self-consistent: `w` must be a power of - /// two, FORS+C needs `k >= 2`, WOTS-TW cannot drop chains, and `2^a` has to - /// be countable. - pub fn new(p: Params) -> Option { - if p.k < 1 || p.a < 1 || p.a > 63 || p.d == 0 { - return None; - } - if p.scheme.fors_c() && p.k < 2 { - return None; - } - if !p.scheme.wots_c() && p.dropped_chains > 0 { +impl Fors { + pub fn new(p: &Params) -> Option { + if p.k < 1 || p.a < 1 || p.a > 63 || (p.scheme.fors_c() && p.k < 2) { return None; } - let enc = p.encoding()?; - let l = p.chains()?; - let profile = p.profile()?; - let (n, b, d) = (p.n, p.blocks(), p.d); - let trees = p.scheme.trees(p.k); - let t = 1u64 << p.a; - - // The signature carries the whole authentication path, h nodes however - // the layers divide it, plus one WOTS signature per layer. - let layer = l * n + if p.scheme.wots_c() { COUNTER_BYTES } else { 0 }; - let sig_bytes = n + profile.total() * n + d * layer + trees * n + trees * p.a * n; - + let (n, b, trees, t) = (p.n, p.blocks(), p.trees(), 1u64 << p.a); let msg_hash = Cost::new(2, b.message_hash() + b.message_prf()); - let fors_build = Cost::new( + let build = Cost::new( trees * t + trees * t + trees * (t - 1) + 1, trees * t * b.prf() + trees * t * b.chain_step() + trees * (t - 1) * b.merkle_node() + b.compress(trees), ); // FORS+C grinds the digest until its last a bits vanish, so the last // FORS tree always opens leaf 0 and needs no authentication path. - let fors_grind = if p.scheme.fors_c() { msg_hash * t } else { msg_hash }; - - let fors_verify = Cost::new( + let grinding = if p.scheme.fors_c() { msg_hash * t } else { msg_hash }; + let verify = Cost::new( trees + trees * p.a + 1, trees * b.chain_step() + trees * p.a * b.merkle_node() + b.compress(trees), ); - let auth = Cost::new(profile.total(), profile.total() * b.merkle_node()); - let mut verify_base = Cost::new(1, b.message_hash()) + fors_verify + auth; - let mut verify_step = Cost::default(); - let mut verify_worst_extra = Cost::default(); - if p.scheme.wots_c() { - // the digits sum to S_wn, so the remaining chain steps are fixed at - // (w-1)*l - S_wn, and the counter is hashed once per layer - verify_base = verify_base + Cost::new(2, b.chain_step_with_counter() + b.compress(l)) * d; - verify_step = Cost::new(1, b.chain_step()) * d; - } else { - let avg = (p.w - 1) * l / 2; - verify_base = verify_base + Cost::new(avg + 1, avg * b.chain_step() + b.compress(l)) * d; - let worst = p.wots_tw_worst_steps(); - verify_worst_extra = Cost::new(worst - avg, (worst - avg) * b.chain_step()) * d; - } - Some(Self { - params: p, - l, - chain_bits: enc.chain_bits, - pinned_bits: if p.scheme.wots_c() { enc.pinned_bits } else { 0 }, - max_swn: (p.w - 1) * l, - default_swn: if p.scheme.wots_c() { enc.default_swn() } else { 0 }, - sig_bytes, - fors_c_grinding: if p.scheme.fors_c() { fors_grind } else { Cost::default() }, - fors_part: fors_build + fors_grind, - grind_step: Cost::new(1, b.chain_step_with_counter()), - verify_base, - verify_step, - verify_worst_extra, + sig_bytes: n + trees * n + trees * p.a * n, + sign: build + grinding, + grinding: if p.scheme.fors_c() { grinding } else { Cost::default() }, + verify: verify + Cost::new(1, b.message_hash()), }) } - - /// Expected signing cost, with the top tree's half top in state, when each - /// layer grinds `trials` counters. - pub fn sign(&self, lay: &Layers, trials: u64) -> Cost { - lay.trees_cached + self.fors_part + self.grinding(trials) - } - - /// The same for a signer holding no state at all, which has to rebuild the - /// top tree along with the rest. - pub fn sign_cold(&self, lay: &Layers, trials: u64) -> Cost { - lay.trees + self.fors_part + self.grinding(trials) - } - - /// What `trials` counter values per layer cost across the hypertree. - pub fn grinding(&self, trials: u64) -> Cost { - self.grind_step * trials.saturating_mul(self.params.d) - } - - /// Verification at this target sum. `swn` is ignored for WOTS-TW. - pub fn verify(&self, swn: u64) -> Cost { - self.verify_base + self.verify_step * (self.max_swn - swn.min(self.max_swn)) - } - - /// Verification when every message digit is zero (WOTS-TW only). - pub fn verify_worst(&self, swn: u64) -> Cost { - self.verify(swn) + self.verify_worst_extra - } - - /// The full picture at one layer profile and one target sum. - pub fn finish(&self, lay: &Layers, swn: u64, trials: u64) -> Costs { - Costs { - l: self.l, - chain_bits: self.chain_bits, - pinned_bits: self.pinned_bits, - dropped_chains: self.params.dropped_chains, - swn: self.params.scheme.wots_c().then_some(swn), - profile: lay.profile, - sig_bytes: self.sig_bytes, - keygen: lay.keygen, - sign: self.sign(lay, trials), - sign_cold: self.sign_cold(lay, trials), - verify: self.verify(swn), - verify_worst: self.verify_worst(swn), - wots_c_grinding: self.grinding(trials), - fors_c_grinding: self.fors_c_grinding, - cache_depth: lay.cache_depth, - cache_bytes: lay.cache_bytes, - } - } } -/// Every cost of a parameter set at one layer profile and one target sum. +/// Every cost of one parameter set. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Costs { - pub l: u64, - pub chain_bits: u64, - pub pinned_bits: u64, - pub dropped_chains: u64, - pub swn: Option, - pub profile: Profile, + pub hypertree: Hypertree, pub sig_bytes: u64, pub keygen: Cost, /// Signing with the top tree's half top in state, the cost a signer that @@ -444,18 +442,85 @@ impl Costs { } } -/// Costs of one parameter set, building the digit-sum table as needed. -/// -/// Convenient for a single evaluation; a search should hold the [`NuTable`] and -/// drive [`Skeleton`] and [`Layers`] itself, since the table depends only on -/// `(l, w)` and the layer costs only on the profile. -pub fn costs(p: Params, swn: Option) -> Option { - let sk = Skeleton::new(p)?; - let lay = Layers::new(&p)?; - if !p.scheme.wots_c() { - return Some(sk.finish(&lay, 0, 0)); +/// A hypertree's costs, added up. Independent of the FORS side, so a search +/// that varies `(a, k)` builds this once and adds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HyperCost { + pub hypertree: Hypertree, + pub sig_bytes: u64, + pub verify: Cost, + pub verify_worst_extra: Cost, + /// Growing every layer's tree from the seed. + pub trees: Cost, + /// The same with the top tree's half top already in state. + pub trees_cached: Cost, + /// Growing the top tree, which is all key generation does. + pub keygen: Cost, + /// Counter values tried per signature, across every layer. + pub trials: u64, + pub cache_bytes: u64, + pub cache_depth: u64, +} + +/// Add up one hypertree, layer by layer. +pub fn hyper_cost(p: &Params, ht: &Hypertree, nu: &mut NuCache) -> Option { + let mut out = HyperCost { + hypertree: *ht, + sig_bytes: 0, + verify: Cost::default(), + verify_worst_extra: Cost::default(), + trees: Cost::default(), + trees_cached: Cost::default(), + keygen: Cost::default(), + trials: 0, + cache_bytes: 0, + cache_depth: 0, + }; + for (i, layer) in ht.layers().enumerate() { + let l = layer.chains(p.n, p.scheme)?; + let table = p.scheme.wots_c().then(|| nu.table(l, layer.w())); + let c = layer_cost(layer, p, table)?; + out.sig_bytes += c.sig_bytes; + out.verify = out.verify + c.verify; + out.verify_worst_extra = out.verify_worst_extra + c.verify_worst_extra; + out.trees = out.trees + c.tree; + out.trials += c.trials; + if i == 0 { + out.keygen = c.tree; + out.trees_cached = out.trees_cached + c.tree_cached; + out.cache_bytes = c.cache_bytes; + out.cache_depth = c.cache_depth; + } else { + out.trees_cached = out.trees_cached + c.tree; + } + } + Some(out) +} + +/// Add a hypertree and a FORS side together. Pure arithmetic: no tables. +pub fn assemble(p: &Params, hyper: &HyperCost, fors: &Fors) -> Costs { + let grind = Cost::new(1, p.blocks().chain_step_with_counter()) * hyper.trials; + Costs { + hypertree: hyper.hypertree, + sig_bytes: fors.sig_bytes + hyper.sig_bytes, + keygen: hyper.keygen, + sign: hyper.trees_cached + fors.sign + grind, + sign_cold: hyper.trees + fors.sign + grind, + verify: fors.verify + hyper.verify, + verify_worst: fors.verify + hyper.verify + hyper.verify_worst_extra, + wots_c_grinding: grind, + fors_c_grinding: fors.grinding, + cache_depth: hyper.cache_depth, + cache_bytes: hyper.cache_bytes, } - let table = NuTable::new(sk.l, p.w, (8 * p.n) as u32); - let swn = swn.unwrap_or(sk.default_swn); - Some(sk.finish(&lay, swn, table.trials(swn))) +} + +/// Costs of one parameter set, building whatever digit-sum tables it needs. +/// +/// Convenient for a single evaluation; a search should hold a [`NuCache`] and +/// call [`assemble`] itself. +pub fn costs(p: Params, ht: &Hypertree) -> Option { + let fors = Fors::new(&p)?; + let mut nu = NuCache::new(p.n); + Some(assemble(&p, &hyper_cost(&p, ht, &mut nu)?, &fors)) } diff --git a/doc/sphincs/params_selection/src/report.rs b/doc/sphincs/params_selection/src/report.rs index f2b927fa1..162daeaea 100644 --- a/doc/sphincs/params_selection/src/report.rs +++ b/doc/sphincs/params_selection/src/report.rs @@ -1,6 +1,7 @@ //! Human-readable output. -use crate::params::{Costs, Params}; +use crate::cost::Cost; +use crate::params::{Costs, Layer, Params}; use crate::search::{Budgets, Candidate}; use crate::security::forgery_exponent; @@ -35,36 +36,37 @@ pub fn si(x: u64) -> String { x.to_string() } -/// One line spelling out the WOTS+C digest-to-chains cut. -pub fn encoding_line(p: &Params, c: &Costs) -> String { - let Some(swn) = c.swn else { - let l1 = (8 * p.n).div_ceil(c.chain_bits); - return format!( - "encoding WOTS-TW: {} chains, {l1} for the digest + {} checksum", - c.l, - c.l - l1 - ); - }; - let dropped = if c.dropped_chains > 0 { - format!(", {} chain(s) dropped", c.dropped_chains) +/// One layer's WOTS instance: how its digest is cut into chain positions. +fn wots_line(p: &Params, layer: Layer) -> String { + let l = layer.chains(p.n, p.scheme).unwrap_or(0); + let enc = layer.encoding(p.n); + if !p.scheme.wots_c() { + let l1 = (8 * p.n).div_ceil(layer.chain_bits()); + return format!("WOTS-TW, {l} chains: {l1} for the digest + {} checksum", l - l1); + } + let pinned = enc.map_or(0, |e| e.pinned_bits); + let swn = layer.swn().unwrap_or_else(|| enc.map_or(0, |e| e.default_swn())); + let dropped = if layer.dropped_chains() > 0 { + format!(", {} chain(s) dropped", layer.dropped_chains()) } else { String::new() }; format!( - "encoding {} bits/chain, {} of {} digest bits pinned to zero{dropped}, S_wn = {swn} of {}", - c.chain_bits, - c.pinned_bits, + "w = {}, {l} chains of {} bits, {pinned} of {} digest bits pinned{dropped}, S_wn = {swn} of {}", + layer.w(), + layer.chain_bits(), 8 * p.n, - c.l * (p.w - 1) + l * (layer.w() - 1) ) } /// The full picture of one parameter set. pub fn report(p: &Params, c: &Costs, q_s: f64) -> String { - let forgery = forgery_exponent(q_s, p.h as u32, p.k, p.a); + let forgery = forgery_exponent(q_s, c.hypertree.height() as u32, p.k, p.a); let cap = 8.0 * p.n as f64; let security = forgery.map_or(0.0, |f| f.min(cap)); - let row = |label: &str, x: crate::cost::Cost, note: String| format!("{label:<24}{:>12}{note}", si(x.compressions)); + let ht = c.hypertree; + let row = |label: &str, x: Cost, note: String| format!("{label:<24}{:>12}{note}", si(x.compressions)); let mut lines = vec![ format!( @@ -73,7 +75,12 @@ pub fn report(p: &Params, c: &Costs, q_s: f64) -> String { signatures(q_s), 8 * p.n ), - format!("(h, d) ({}, {}) layer heights {}", p.h, p.d, c.profile), + format!( + "(h, d) ({}, {}) layer heights {}", + ht.height(), + ht.depth(), + ht.heights() + ), format!( "(a, k) ({}, {}){}", p.a, @@ -84,31 +91,54 @@ pub fn report(p: &Params, c: &Costs, q_s: f64) -> String { String::new() } ), - format!("(w, l) ({}, {})", p.w, c.l), - encoding_line(p, c), - String::new(), - match forgery { - Some(f) => format!( - "security {security:.1} bits classical (FORS forgery {f:.1}, preimage {})", - cap as u64 - ), - None => format!( - "security none: q_s = {} reuses every FORS instance ~{:.0} times", - signatures(q_s), - q_s / 2f64.powi(p.h as i32) - ), - }, - format!("signature {} bytes", c.sig_bytes), - String::new(), - format!("{:<24}{:>12}", "", "compressions"), - row("keygen", c.keygen, String::new()), - row( - "sign", - c.sign, - format!(" ({} B of state at depth {})", c.cache_bytes, c.cache_depth), - ), - row("verify", c.verify, String::new()), ]; + // one line per distinct WOTS instance, which is one line unless the layers + // disagree + if ht.one_wots() { + lines.push(format!("every layer {}", wots_line(p, ht.top()))); + } else { + // one line per run of layers sharing their WOTS parameters + let mut runs: Vec<(Layer, u64, u64)> = Vec::new(); + for (i, layer) in ht.layers().enumerate() { + let same = + |a: Layer, b: Layer| (a.w(), a.dropped_chains(), a.swn()) == (b.w(), b.dropped_chains(), b.swn()); + match runs.last_mut() { + Some((prev, _, last)) if same(*prev, layer) => *last = i as u64, + _ => runs.push((layer, i as u64, i as u64)), + } + } + for (layer, first, last) in runs { + let which = match (first, last) { + (0, 0) => "top layer".to_string(), + (f, l) if f == l => format!("layer {f}"), + (f, l) if l + 1 == ht.depth() => format!("layers {f}..{l}"), + (f, l) => format!("layers {f}..{l}"), + }; + lines.push(format!("{which:<16}{}", wots_line(p, layer))); + } + } + lines.push(String::new()); + lines.push(match forgery { + Some(f) => format!( + "security {security:.1} bits classical (FORS forgery {f:.1}, preimage {})", + cap as u64 + ), + None => format!( + "security none: q_s = {} reuses every FORS instance ~{:.0} times", + signatures(q_s), + q_s / 2f64.powi(ht.height() as i32) + ), + }); + lines.push(format!("signature {} bytes", c.sig_bytes)); + lines.push(String::new()); + lines.push(format!("{:<24}{:>12}", "", "compressions")); + lines.push(row("keygen", c.keygen, String::new())); + lines.push(row( + "sign", + c.sign, + format!(" ({} B of state at depth {})", c.cache_bytes, c.cache_depth), + )); + lines.push(row("verify", c.verify, String::new())); if c.verify_worst != c.verify { lines.push(row("verify (worst)", c.verify_worst, String::new())); } @@ -130,30 +160,48 @@ const COLUMNS: [(&str, usize); 15] = [ ("heights", 18), ("a", 3), ("k", 3), - ("w", 5), + ("w", 9), ("drop", 5), - ("l", 4), - ("S_wn", 6), + ("l", 7), + ("S_wn", 11), ("size", 6), ("keygen", 8), ("sign", 8), ("cache B", 7), ]; +/// `top/low` when the layers disagree, one value when they do not. +fn per_group(top: String, low: String) -> String { + if top == low { top } else { format!("{top}/{low}") } +} + fn cells(c: &Candidate) -> Vec { let (p, x) = (&c.params, &c.costs); + let ht = x.hypertree; + let (top, low) = (ht.top(), ht.layers().last().unwrap_or(ht.top())); + let chains = |layer: Layer| layer.chains(p.n, p.scheme).unwrap_or(0); + let sum = |layer: Layer| { + layer + .swn() + .or_else(|| layer.encoding(p.n).map(|e| e.default_swn())) + .map_or("-".to_string(), |s| s.to_string()) + }; vec![ si(x.verify.compressions), p.scheme.label().to_string(), - p.h.to_string(), - p.d.to_string(), - x.profile.to_string(), + ht.height().to_string(), + ht.depth().to_string(), + ht.heights(), p.a.to_string(), p.k.to_string(), - p.w.to_string(), - p.dropped_chains.to_string(), - x.l.to_string(), - x.swn.map_or("-".to_string(), |s| s.to_string()), + per_group(top.w().to_string(), low.w().to_string()), + per_group(top.dropped_chains().to_string(), low.dropped_chains().to_string()), + per_group(chains(top).to_string(), chains(low).to_string()), + if p.scheme.wots_c() { + per_group(sum(top), sum(low)) + } else { + "-".to_string() + }, x.sig_bytes.to_string(), si(x.keygen.compressions), si(x.sign.compressions), @@ -165,9 +213,9 @@ fn cells(c: &Candidate) -> Vec { /// own and not the report's. pub fn legend() -> String { "every cost in compression calls, one per 64 bytes of hash input; sign = signing with the top tree's half top \ - in state, cache B of it\nheights = every layer's height, top first, and the only one worth caching is that top one, w = Winternitz parameter, the positions one chain \ - has (--chain-bits takes its log2), drop = chains dropped beyond the pinned digest bits, l = chains signed, \ - S_wn = target digit sum" + in state, cache B of it\nheights = every layer's height, top first, and the only one worth caching is that top \ + one; w, drop, l and S_wn are written top/lower where the layers differ, and are the Winternitz parameter, the \ + chains dropped beyond the pinned digest bits, the chains signed, and the target digit sum" .to_string() } diff --git a/doc/sphincs/params_selection/src/search.rs b/doc/sphincs/params_selection/src/search.rs index d767a1989..8f371a23a 100644 --- a/doc/sphincs/params_selection/src/search.rs +++ b/doc/sphincs/params_selection/src/search.rs @@ -1,38 +1,31 @@ //! Exhaustive search for the parameter set with the cheapest verification. //! -//! Every `(scheme, h, d, h_top, chain_bits, dropped_chains, a, k, S_wn)` point -//! that meets the budgets is costed and compared. Nothing is chosen by an -//! optimality argument, and nothing is skipped by a monotonicity one: the three -//! tests that run before the `S_wn` scan reject only points that no `S_wn` could -//! rescue, because size and keygen do not depend on `S_wn` at all, and the least -//! grinding any `S_wn` can ask for is read off the digit-sum table rather than -//! assumed to sit anywhere in particular. +//! Every `(scheme, a, k, h, d, h_top, and a WOTS instance for the top layer and +//! one for the rest)` point that meets the budgets is costed and compared. +//! Nothing is chosen by an optimality argument, and nothing is skipped by a +//! monotonicity one: the tests that run before the target-sum scan reject only +//! points that no target sum could rescue, because size and keygen do not +//! depend on the target sums at all, and the least grinding any of them can ask +//! for is read off the digit-sum table rather than assumed to sit anywhere in +//! particular. //! -//! Any axis can be pinned to a single value instead of searched, which is how -//! one parameter set gets costed: pin them all. Budgets are optional, and an -//! unset one is no limit. -//! -//! The layer heights are `(h, d, h_top)`: the top tree gets `h_top`, the rest -//! divide what is left as evenly as it goes. [`crate::params::Profile`] argues -//! why that shape covers the cost-optimal representative of every profile, so -//! `d` no longer has to divide `h`. Which `h_top` is best does not depend on -//! `(a, k)` or on the target sum, because size and verification do not depend on -//! `h_top` at all and both signing budgets take the `(a, k)` part as the same -//! additive offset; so the profiles are ranked once per `(h, d)`, by how much -//! grinding they leave room for, and that ranking then holds for every `(a, k)`. +//! Two WOTS instances rather than `d` of them is not a restriction: +//! [`Hypertree::two_group`] gives the argument, and a test checks it against +//! every per-layer assignment of small hypertrees. Any axis can also be pinned +//! to a single value instead of searched, which is how one parameter set gets +//! costed: pin them all. Budgets are optional, and an unset one is no limit. //! //! What is assumed is the searched range of each parameter, hardcoded below. //! When a result comes out at the top of one of those ranges the range itself //! may be what is limiting it, so [`edges`] reports that and names the constant -//! to raise. Ranges the structure already closes (`d` over layer heights that do -//! not add up to `h`, `S_wn` over the digit sums a code of `l` chains can reach) -//! need no such warning and get none, and neither does an axis pinned by hand. +//! to raise. Ranges the structure already closes need no such warning and get +//! none, and neither does an axis pinned by hand. use std::ops::RangeInclusive; use std::time::Instant; -use crate::cost::{Cost, NuTable, SCHEMES, Scheme}; -use crate::params::{Costs, Layers, Params, Profile, Skeleton}; +use crate::cost::{Cost, NuCache, SCHEMES, Scheme}; +use crate::params::{Costs, Fors, Hypertree, Layer, Params, assemble}; use crate::security::SecurityTable; /// Hardcoded search ranges, wide enough that the budgets are normally what @@ -84,7 +77,7 @@ impl Span { /// Which target sums to consider. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Sums { - /// Only this one. + /// Only this one, on every layer. Pinned(u64), /// Only the mean, where grinding is cheapest. What to use when nothing /// bounds the signer, since then there is no reason to grind harder, and @@ -141,10 +134,11 @@ pub struct Grid { pub dropped: Span, pub chain_bits: Vec, pub sums: Sums, - /// A profile given outright, instead of `h_top` over the canonical shape. - /// Its heights have to add up to `h` over `d` layers, both of which are - /// then pinned by it. - pub profile: Option, + /// Search a separate WOTS instance for the top layer, rather than one for + /// the whole hypertree. + pub split_wots: bool, + /// A hypertree given outright, which pins every axis it covers. + pub hypertree: Option, pub cache_height: Option, pub cache_level_only: bool, } @@ -154,11 +148,13 @@ impl Grid { /// rather than searching for one? Distinct from a search that happens to /// leave one survivor, which still deserves its count and its table. pub fn fully_pinned(&self) -> bool { - let h_top_pinned = self.profile.is_some() - || match self.h_top { - None => true, // the classic split is one profile - Some(span) => span.pinned(), - }; + if self.hypertree.is_some() { + return self.schemes.len() == 1 && self.a.pinned() && self.k.pinned(); + } + let h_top_pinned = match self.h_top { + None => true, // the classic split is one profile + Some(span) => span.pinned(), + }; self.schemes.len() == 1 && self.chain_bits.len() == 1 && self.h.pinned() @@ -167,6 +163,7 @@ impl Grid { && self.k.pinned() && self.dropped.pinned() && h_top_pinned + && !self.split_wots && !matches!(self.sums, Sums::Sweep) } } @@ -184,7 +181,8 @@ impl Default for Grid { dropped: Span::new(0, DROPPED_MAX), chain_bits: (1..=CHAIN_BITS_MAX).collect(), sums: Sums::Sweep, - profile: None, + split_wots: false, + hypertree: None, cache_height: None, cache_level_only: false, } @@ -197,25 +195,36 @@ pub struct Candidate { pub costs: Costs, } -/// Identifies one parameter tuple: everything but the layer profile and the -/// target sum, neither of which changes what it verifies at. -pub type Key = (Scheme, u64, u64, u64, u64, u64, u64); +/// Identifies one parameter tuple: everything but the target sums, which do not +/// change what it verifies at. +pub type Key = (Scheme, u64, u64, u64, u64, u64, u64, u64, u64); impl Candidate { pub fn key(&self) -> Key { - let p = self.params; - (p.scheme, p.h, p.d, p.a, p.k, p.w, p.dropped_chains) + let (p, ht) = (self.params, self.costs.hypertree); + let low = ht.layers().last().unwrap_or(ht.top()); + ( + p.scheme, + ht.height(), + ht.depth(), + p.a, + p.k, + ht.top().w(), + ht.top().dropped_chains(), + low.w(), + low.dropped_chains(), + ) } } #[derive(Clone, Copy, Debug, Default)] pub struct Stats { - /// `(scheme, h, d, chain_bits, dropped)` tuples reached. + /// `(scheme, h, d, top WOTS, lower WOTS)` tuples reached. pub grid: u64, pub keygen_pruned: u64, /// `(a, k)` pairs rejected by the security floor. pub insecure: u64, - /// `(a, k)` pairs whose signature is too big, whatever the target sum. + /// `(a, k)` pairs whose signature is too big, whatever the target sums. pub size_pruned: u64, /// `(a, k)` pairs too slow to sign at the least grinding any target sum asks. pub sign_pruned: u64, @@ -223,9 +232,7 @@ pub struct Stats { pub swept: u64, /// Points meeting every budget. pub feasible: u64, - pub skeletons: u64, - /// Layer profiles kept after the keygen budget. - pub profiles: u64, + pub costed: u64, /// Parameter tuples that came out feasible, one row each. pub rows: u64, /// Rows dropped as worse than everything kept. @@ -233,15 +240,29 @@ pub struct Stats { pub seconds: f64, } +impl Stats { + fn add(&mut self, o: &Self) { + self.grid += o.grid; + self.keygen_pruned += o.keygen_pruned; + self.insecure += o.insecure; + self.size_pruned += o.size_pruned; + self.sign_pruned += o.sign_pruned; + self.swept += o.swept; + self.feasible += o.feasible; + self.costed += o.costed; + self.rows += o.rows; + self.rows_dropped += o.rows_dropped; + } +} + impl std::fmt::Display for Stats { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, - "grid {} (scheme, h, d, chain_bits, dropped) tuples, {} over keygen; \ + "grid {} (scheme, h, d, top WOTS, lower WOTS) tuples, {} over keygen; \ then {} (a, k) pairs insecure, {} over size, {} over signing; \ {} target-sum ranges swept, {} points feasible over {} parameter tuples ({} dropped as worse than \ - everything kept); \ - {} layer profiles and {} parameter sets costed in {:.1}s", + everything kept); {} parameter sets costed in {:.1}s", self.grid, self.keygen_pruned, self.insecure, @@ -251,218 +272,315 @@ impl std::fmt::Display for Stats { self.feasible, self.rows, self.rows_dropped, - self.profiles, - self.skeletons, + self.costed, self.seconds ) } } -fn params(g: &Grid, scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, dropped: u64) -> Params { +fn params(g: &Grid, scheme: Scheme, a: u64, k: u64) -> Params { Params { scheme, - h, - d, - h_top: None, a, k, - w, n: g.n, - dropped_chains: dropped, cache_height: g.cache_height, cache_level_only: g.cache_level_only, } } -/// The layer profiles worth trying for one `(h, d)`, and how much grinding the -/// best of them leaves room for. +/// How much verification a grinding budget buys, for one hypertree. /// -/// `slack` is `max over profiles of (max_sign - the profile's trees)`. Signing -/// takes the `(a, k)` part as an additive offset, so subtracting that offset -/// from `slack` gives the grinding budget of the best profile for any `(a, k)`, -/// without re-ranking the profiles per candidate. -struct Room { - profiles: Vec, - slack: u64, +/// Every unit of target sum removes exactly one chain step from verification, +/// whatever layer it is on, and costs that layer's grinding. So the best +/// allocation maximises `swn_top + (d-1) * swn_lower` against the trials the +/// signing budget leaves. Both layer groups have a convex increasing cost in +/// their own sum, since the digit-sum count is log-concave, so the frontier of +/// the pair is the greedy merge of their marginal costs, walked here once per +/// hypertree rather than once per `(a, k)`. +struct Grinding { + /// `(trials, gain, top sum, lower sum)`, by increasing trials. + frontier: Vec<(u64, u64, u64, u64)>, } -fn room(b: &Budgets, g: &Grid, p: &Params) -> Option { - let mut profiles = Vec::new(); - let mut slack = 0; - let mut consider = |lay: Option| { - let Some(lay) = lay else { return }; - if b.of(lay.keygen) > b.max_keygen() { - return; - } - slack = slack.max(b.max_sign().saturating_sub(b.of(lay.trees_cached))); - profiles.push(lay); - }; - match (g.profile, g.h_top) { - (Some(profile), _) => consider(Layers::from_profile(p, profile)), - (None, None) => consider(Layers::new(p)), - (None, Some(span)) => { - // The top tree has 2^h_top leaves and every leaf costs at least one - // hash, so a top height past the keygen budget's log is out for any - // (a, k). - let ceiling = 64 - b.max_keygen().max(1).leading_zeros() as u64; - for h_top in span.within((p.h + 1).saturating_sub(p.d).min(ceiling)) { - consider(Layers::new(&Params { - h_top: Some(h_top), - ..*p - })); +/// Frontier points kept. The grinding rises fast enough that a budget runs out +/// long before this, so it is a bound on the allocation rather than a cap on +/// the answer. +const FRONTIER_MAX: usize = 4096; + +impl Grinding { + fn build(p: &Params, ht: &Hypertree, nu: &mut NuCache, cap: u64) -> Option { + let (top, low) = (ht.top(), ht.layers().last()?); + let m = ht.depth() - 1; + let (top_l, low_l) = (top.chains(p.n, p.scheme)?, low.chains(p.n, p.scheme)?); + let (top_tab, low_tab) = nu.pair((top_l, top.w()), (low_l, low.w())); + let (top_max, low_max) = (top_tab.max_swn(), low_tab.max_swn()); + let (mut ts, mut ls) = (top_max / 2, low_max / 2); + let cost = |ts: u64, ls: u64| top_tab.trials(ts).saturating_add(low_tab.trials(ls).saturating_mul(m)); + let mut frontier = vec![(cost(ts, ls), ts + m * ls, ts, ls)]; + while frontier.last()?.0 <= cap && frontier.len() < FRONTIER_MAX && (ts < top_max || (m > 0 && ls < low_max)) { + // whichever next step buys its gain most cheaply + let up_top = (ts < top_max).then(|| cost(ts + 1, ls)); + let up_low = (m > 0 && ls < low_max).then(|| cost(ts, ls + 1)); + let take_top = match (up_top, up_low) { + (Some(t), Some(l)) => (t - frontier.last()?.0) <= (l - frontier.last()?.0) / m.max(1), + (Some(_), None) => true, + (None, Some(_)) => false, + (None, None) => break, + }; + if take_top { + ts += 1; + } else { + ls += 1; } + frontier.push((cost(ts, ls), ts + m * ls, ts, ls)); } + Some(Self { frontier }) } - (!profiles.is_empty()).then_some(Room { profiles, slack }) + + /// The cheapest-verifying allocation that grinds at most `trials`. + /// + /// The frontier rises in both cost and gain, so this is the last entry + /// within budget. + fn best(&self, trials: u64) -> (u64, u64, u64) { + let i = self.frontier.partition_point(|&(cost, ..)| cost <= trials); + match i.checked_sub(1).and_then(|i| self.frontier.get(i)) { + Some(&(_, gain, ts, ls)) => (gain, ts, ls), + None => (0, 0, 0), + } + } +} + +/// One hypertree candidate, costed and with its grinding frontier. +struct Tree { + hyper: crate::params::HyperCost, + grinding: Option, } /// Every feasible parameter set, ordered by verification cost. /// -/// One row per `(scheme, h, d, a, k, w, dropped_chains)`, carrying the best -/// target sum for that tuple and the layer profile that admitted it. Rows are -/// what gets printed; the comparison behind each one saw every target sum. +/// One row per parameter tuple, carrying the target sums that verified cheapest +/// for it. Rows are what gets printed; the comparison behind each one saw every +/// target sum. pub fn search(b: &Budgets, g: &Grid, st: &mut Stats) -> Vec { + use rayon::prelude::*; let started = Instant::now(); - let digest_bits = (8 * g.n) as u32; - let mut sec = SecurityTable::new(b.q_s, b.security, g.n, g.h.hi as u32, g.k.hi, g.a.hi); - // Every (scheme, h, d, a, k, w, dropped) key is reached exactly once, so - // rows need no deduplication, only a bound: budgets loose enough to admit - // millions of them would otherwise be held in memory to print a dozen. - let mut best: Vec = Vec::new(); - - for &scheme in &g.schemes { - for &bits in &g.chain_bits { - let w = 1u64 << bits; - // WOTS-TW has no counter to grind, so it cannot drop chains, and - // WOTS+C has to keep at least one. - let dropped_range = if scheme.wots_c() { - g.dropped.within((8 * g.n / bits).saturating_sub(1)) - } else { - 0..=0 + let sec = SecurityTable::filled(b.q_s, b.security, g.n, g.h.hi as u32, g.k.hi, g.a.hi); + // The (scheme, WOTS instance) tasks are independent, and there are hundreds + // of them once the top layer's instance is searched separately. + let tasks: Vec<(Scheme, (Wots, Wots))> = g + .schemes + .iter() + .flat_map(|&scheme| wots_instances(g, scheme).into_iter().map(move |w| (scheme, w))) + .collect(); + let (rows, stats) = tasks + .par_iter() + .map(|&(scheme, wots)| { + let mut st = Stats::default(); + let rows = one_task(b, g, &sec, scheme, wots, &mut st); + (rows, st) + }) + .reduce( + || (Vec::new(), Stats::default()), + |(mut rows, mut acc), (more, st)| { + rows.extend(more); + acc.add(&st); + if rows.len() >= ROWS_CAP { + sort_rows(&mut rows, b); + rows.truncate(ROWS_KEPT); + acc.rows_dropped += (ROWS_CAP - ROWS_KEPT) as u64; + } + (rows, acc) + }, + ); + let mut rows = rows; + *st = stats; + st.seconds = started.elapsed().as_secs_f64(); + sort_rows(&mut rows, b); + rows +} + +/// One `(scheme, WOTS instances)` task: everything else enumerated under it. +fn one_task( + b: &Budgets, + g: &Grid, + sec: &SecurityTable, + scheme: Scheme, + wots: (Wots, Wots), + st: &mut Stats, +) -> Vec { + let mut nu = NuCache::new(g.n); + let mut rows: Vec = Vec::new(); + // The FORS side depends on (scheme, a, k) alone, and every hypertree asks + // for the same ones. + let mut fors_of: std::collections::HashMap<(u64, u64), Option> = Default::default(); + + for h in g.h.iter() { + for d in g.d.within(h) { + st.grid += 1; + let tops: Vec = match (g.hypertree, g.h_top) { + (Some(ht), _) => vec![ht.top().height()], + (None, None) => vec![h / d.max(1)], + (None, Some(span)) => span.within((h + 1).saturating_sub(d)).collect(), }; - for dropped in dropped_range { - let probe = params( - g, - scheme, - g.h.hi.max(1), - 1, - g.a.lo, - if scheme.fors_c() { 2 } else { 1 }, - w, - dropped, - ); - let Some(l) = probe.chains() else { continue }; - let table = scheme.wots_c().then(|| NuTable::new(l, w, digest_bits)); - let min_trials = table.as_ref().map_or(0, |t| t.min_trials()); - for h in g.h.iter() { - for d in g.d.within(h) { - st.grid += 1; - // Layer profiles first: they need no a or k, and the - // keygen budget alone usually settles the question. - let Some(room) = room(b, g, ¶ms(g, scheme, h, d, g.a.lo, 1, w, dropped)) else { - st.keygen_pruned += 1; + // Cost the hypertrees once: they need no a or k, and the keygen + // budget alone usually settles the question. + let probe = params(g, scheme, g.a.lo, if scheme.fors_c() { 2 } else { 1 }); + let mut trees = Vec::new(); + for h_top in tops { + let Some(ht) = build(g, wots, h, d, h_top) else { + continue; + }; + let Some(hyper) = crate::params::hyper_cost(&probe, &ht, &mut nu) else { + continue; + }; + st.costed += 1; + if b.of(hyper.keygen) > b.max_keygen() || hyper.sig_bytes > b.max_size() { + continue; + } + let grinding = (scheme.wots_c() && matches!(g.sums, Sums::Sweep)) + .then(|| Grinding::build(&probe, &ht, &mut nu, b.max_sign())) + .flatten(); + trees.push(Tree { hyper, grinding }); + } + if trees.is_empty() { + st.keygen_pruned += 1; + continue; + } + let smallest = trees.iter().map(|t| t.hyper.sig_bytes).min().unwrap_or(u64::MAX); + for a in g.a.iter() { + for k in g.k.iter() { + if !sec.is_secure(h as u32, k, a) { + st.insecure += 1; + continue; + } + let p = params(g, scheme, a, k); + let fors = *fors_of.entry((a, k)).or_insert_with(|| Fors::new(&p)); + let Some(fors) = fors else { continue }; + // the signature grows with k, so once it overruns there is + // no larger k + if fors.sig_bytes + smallest > b.max_size() { + st.size_pruned += 1; + break; + } + let mut best: Option = None; + for tree in &trees { + let Some(c) = fit(b, &p, tree, &fors, &mut nu, st) else { continue; }; - st.profiles += room.profiles.len() as u64; - for a in g.a.iter() { - for k in g.k.iter() { - if !sec.is_secure(h as u32, k, a) { - st.insecure += 1; - continue; - } - let p = params(g, scheme, h, d, a, k, w, dropped); - let Some(sk) = Skeleton::new(p) else { continue }; - st.skeletons += 1; - // The signature grows with k, so once it is too - // big it stays too big. - if sk.sig_bytes > b.max_size() { - st.size_pruned += 1; - break; - } - // What the best profile can still afford to - // grind, once this (a, k) has taken its share. - let per_trial = b.of(sk.grind_step) * d; - let max_trials = room.slack.saturating_sub(b.of(sk.fors_part)) / per_trial.max(1); - let Some(table) = table.as_ref() else { - // WOTS-TW: no counter, no target sum - if room.slack >= b.of(sk.fors_part) { - st.feasible += 1; - record(&mut best, st, b, &sk, &room, 0, 0); - } - continue; - }; - if max_trials < min_trials { - st.sign_pruned += 1; - continue; - } - let sums = match g.sums { - Sums::Sweep => 0..=sk.max_swn, - Sums::Mean => sk.default_swn..=sk.default_swn, - Sums::Pinned(s) => s..=s, - }; - st.swept += 1; - let mut winner: Option<(u64, u64)> = None; - for swn in sums { - if table.trials(swn) > max_trials { - continue; - } - st.feasible += 1; - let v = b.of(sk.verify(swn)); - if winner.is_none_or(|(_, best_v)| v < best_v) { - winner = Some((swn, v)); - } - } - if let Some((swn, _)) = winner { - record(&mut best, st, b, &sk, &room, swn, table.trials(swn)); - } - } + if best.is_none_or(|old| b.of(c.verify) < b.of(old.verify)) { + best = Some(c); + } + } + if let Some(costs) = best { + st.rows += 1; + rows.push(Candidate { params: p, costs }); + if rows.len() >= ROWS_CAP { + sort_rows(&mut rows, b); + rows.truncate(ROWS_KEPT); + st.rows_dropped += (ROWS_CAP - ROWS_KEPT) as u64; } } } } } } + rows +} - st.seconds = started.elapsed().as_secs_f64(); - sort_rows(&mut best, b); - best +/// This hypertree with this FORS side, at the target sums that verify cheapest +/// within the budgets, or `None` if nothing fits. +fn fit(b: &Budgets, p: &Params, tree: &Tree, fors: &Fors, nu: &mut NuCache, st: &mut Stats) -> Option { + let at_mean = assemble(p, &tree.hyper, fors); + if at_mean.sig_bytes > b.max_size() { + st.size_pruned += 1; + return None; + } + // the mean grinds least, so it settles whether any sums fit at all + if !b.fits(&at_mean) { + st.sign_pruned += 1; + return None; + } + st.feasible += 1; + let Some(grinding) = tree.grinding.as_ref() else { + return Some(at_mean); + }; + st.swept += 1; + // What the signing budget leaves for grinding, in counter trials. + let per_trial = b.of(Cost::new(1, p.blocks().chain_step_with_counter())).max(1); + let fixed = b.of(at_mean.sign) - b.of(at_mean.wots_c_grinding); + let (gain, top_swn, low_swn) = grinding.best(b.max_sign().saturating_sub(fixed) / per_trial); + if gain == 0 { + return Some(at_mean); + } + let ht = tree.hyper.hypertree; + let layers: Option> = ht + .layers() + .enumerate() + .map(|(i, x)| x.with_swn(Some(if i == 0 { top_swn } else { low_swn }))) + .collect(); + let chosen = Hypertree::new(&layers?)?; + // the frontier says what it costs; the model says what it is + let hyper = crate::params::hyper_cost(p, &chosen, nu)?; + let costs = assemble(p, &hyper, fors); + st.costed += 1; + if b.fits(&costs) && b.of(costs.verify) < b.of(at_mean.verify) { + st.feasible += 1; + return Some(costs); + } + Some(at_mean) +} + +/// One WOTS instance's searched parameters: `(w, dropped_chains)`. +pub type Wots = (u64, u64); + +/// The `(top WOTS, lower WOTS)` pairs to try: one pair unless `split_wots`. +fn wots_instances(g: &Grid, scheme: Scheme) -> Vec<(Wots, Wots)> { + if g.hypertree.is_some() { + return vec![((0, 0), (0, 0))]; // ignored: `build` returns the given tree + } + let mut single = Vec::new(); + for &bits in &g.chain_bits { + let w = 1u64 << bits; + let range = if scheme.wots_c() { + g.dropped.within((8 * g.n / bits).saturating_sub(1)) + } else { + 0..=0 + }; + for dropped in range { + single.push((w, dropped)); + } + } + if !g.split_wots { + return single.iter().map(|&x| (x, x)).collect(); + } + single + .iter() + .flat_map(|&t| single.iter().map(move |&l| (t, l))) + .collect() +} + +fn build(g: &Grid, wots: (Wots, Wots), h: u64, d: u64, h_top: u64) -> Option { + if let Some(ht) = g.hypertree { + return (ht.height() == h && ht.depth() == d).then_some(ht); + } + let ((tw, td), (lw, ld)) = wots; + let sums = match g.sums { + Sums::Pinned(s) => Some(s), + _ => None, + }; + Hypertree::two_group(h, d, Layer::new(h_top, tw, td, sums)?, Layer::new(1, lw, ld, sums)?) } /// Rows kept before the list is trimmed back to `ROWS_KEPT`. The optimum is /// unaffected: what gets dropped is worse than everything retained. -const ROWS_CAP: usize = 1 << 18; -const ROWS_KEPT: usize = 1 << 17; +const ROWS_CAP: usize = 1 << 16; +const ROWS_KEPT: usize = 1 << 15; fn sort_rows(rows: &mut [Candidate], b: &Budgets) { rows.sort_by_key(|c| (b.of(c.costs.verify), c.costs.sig_bytes, b.of(c.costs.sign))); } -/// Record this parameter tuple on the cheapest layer profile that fits: they -/// all verify the same, so the tie goes to signing. -fn record(rows: &mut Vec, st: &mut Stats, b: &Budgets, sk: &Skeleton, room: &Room, swn: u64, trials: u64) { - let Some(lay) = room - .profiles - .iter() - .filter(|lay| b.of(sk.sign(lay, trials)) <= b.max_sign()) - .min_by_key(|lay| (b.of(sk.sign(lay, trials)), b.of(sk.sign_cold(lay, trials)))) - else { - return; - }; - st.rows += 1; - rows.push(Candidate { - params: Params { - h_top: Some(lay.profile.h_top()), - ..sk.params - }, - costs: sk.finish(lay, swn, trials), - }); - if rows.len() >= ROWS_CAP { - sort_rows(rows, b); - rows.truncate(ROWS_KEPT); - st.rows_dropped += (ROWS_CAP - ROWS_KEPT) as u64; - } -} - /// Axes where a result sits at the top of a searched range. /// /// Such a result may be limited by the range rather than by the budgets, so it @@ -473,78 +591,32 @@ pub fn edges(g: &Grid, c: &Candidate) -> Vec { g.chain_bits.iter().copied().min().unwrap_or(0), g.chain_bits.iter().copied().max().unwrap_or(0), ); + let ht = c.costs.hypertree; + let low = ht.layers().last().unwrap_or(ht.top()); let at = [ - ("h", c.params.h, g.h, "H_MAX / --height"), - ("d", c.params.d, g.d, "D_MAX / --layers"), - ("a", c.params.a, g.a, "A_MAX / -a"), - ("k", c.params.k, g.k, "K_MAX / -k"), - ("chain_bits", c.costs.chain_bits, bits, "CHAIN_BITS_MAX / --chain-bits"), + ("h", ht.height(), g.h.lo, g.h.hi, "H_MAX"), + ("d", ht.depth(), g.d.lo, g.d.hi, "D_MAX"), + ("a", c.params.a, g.a.lo, g.a.hi, "A_MAX"), + ("k", c.params.k, g.k.lo, g.k.hi, "K_MAX"), + ("chain_bits", ht.top().chain_bits(), bits.lo, bits.hi, "CHAIN_BITS_MAX"), + ("chain_bits", low.chain_bits(), bits.lo, bits.hi, "CHAIN_BITS_MAX"), ( "dropped_chains", - c.params.dropped_chains, - g.dropped, - "DROPPED_MAX / --drop-chains", + ht.top().dropped_chains(), + 0, + g.dropped.hi, + "DROPPED_MAX", ), + ("dropped_chains", low.dropped_chains(), 0, g.dropped.hi, "DROPPED_MAX"), ]; - at.iter() - .filter(|(_, v, span, _)| !span.pinned() && *v + 1 >= span.hi) - .map(|(axis, v, span, what)| { - let where_ = if *v >= span.hi { "at" } else { "one step below" }; - format!( - "{axis} = {v} is {where_} the top of the searched range ({}): raise {what} and rerun", - span.hi - ) + let mut out: Vec = at + .iter() + .filter(|(_, v, floor, limit, _)| limit > floor && *v + 1 >= *limit) + .map(|(axis, v, _, limit, what)| { + let where_ = if v >= limit { "at" } else { "one step below" }; + format!("{axis} = {v} is {where_} the top of the searched range ({limit}): raise {what} in src/search.rs and rerun") }) - .collect() -} - -/// The same search with nothing skipped: every `(a, k, h_top, S_wn)` point -/// costed in full and checked against every budget. -/// -/// Only usable on a tiny grid, which is the point: it is the oracle the real -/// search is diffed against in `tests/goldens`. -pub fn naive_search(b: &Budgets, g: &Grid) -> Vec { - let digest_bits = (8 * g.n) as u32; - let mut out: Vec = Vec::new(); - for &scheme in &g.schemes { - for &bits in &g.chain_bits { - let w = 1u64 << bits; - let dropped_range = if scheme.wots_c() { g.dropped.iter() } else { 0..=0 }; - for dropped in dropped_range { - for h in g.h.iter() { - for d in g.d.within(h) { - for h_top in 1..=h { - for a in g.a.iter() { - for k in g.k.iter() { - let p = Params { - h_top: Some(h_top), - ..params(g, scheme, h, d, a, k, w, dropped) - }; - let Some(sk) = Skeleton::new(p) else { continue }; - let Some(lay) = Layers::new(&p) else { continue }; - if crate::security::security_bits(b.q_s, h as u32, k, a, g.n) < b.security { - continue; - } - let table = scheme.wots_c().then(|| NuTable::new(sk.l, w, digest_bits)); - let sums: Vec = match &table { - Some(_) => (0..=sk.max_swn).collect(), - None => vec![0], - }; - for swn in sums { - let trials = table.as_ref().map_or(0, |t| t.trials(swn)); - let c = sk.finish(&lay, swn, trials); - if b.fits(&c) { - out.push(Candidate { params: p, costs: c }); - } - } - } - } - } - } - } - } - } - } - out.sort_by_key(|c| (b.of(c.costs.verify), c.costs.sig_bytes, b.of(c.costs.sign))); + .collect(); + out.dedup(); out } diff --git a/doc/sphincs/params_selection/src/security.rs b/doc/sphincs/params_selection/src/security.rs index 5306cda4f..cbe355f54 100644 --- a/doc/sphincs/params_selection/src/security.rs +++ b/doc/sphincs/params_selection/src/security.rs @@ -89,6 +89,30 @@ pub struct SecurityTable { } impl SecurityTable { + /// Fill every cell up front, in parallel, so the search can share it. + /// + /// The set of `(h, k, a)` a search touches is fixed by its grid and does not + /// depend on the scheme or the WOTS parameters, so this is computed once + /// rather than per worker. + pub fn filled(q_s: f64, target: f64, n: u64, h_max: u32, k_max: u64, a_max: u64) -> Self { + use rayon::prelude::*; + let mut out = Self::new(q_s, target, n, h_max, k_max, a_max); + let (kk, aa) = (k_max as usize + 1, a_max as usize + 1); + let flags: Vec = (0..=h_max as usize) + .into_par_iter() + .flat_map_iter(|h| { + (0..kk).flat_map(move |k| { + (0..aa).map(move |a| { + let secure = k >= 1 && a >= 1 && security_bits(q_s, h as u32, k as u64, a as u64, n) >= target; + if secure { 1u8 } else { 2 } + }) + }) + }) + .collect(); + out.seen = flags; + out + } + pub fn new(q_s: f64, target: f64, n: u64, h_max: u32, k_max: u64, a_max: u64) -> Self { let cells = (h_max as usize + 1) * (k_max as usize + 1) * (a_max as usize + 1); Self { @@ -102,14 +126,11 @@ impl SecurityTable { } } - pub fn is_secure(&mut self, h: u32, k: u64, a: u64) -> bool { + pub fn is_secure(&self, h: u32, k: u64, a: u64) -> bool { if h > self.h_max || k > self.k_max || a > self.a_max { return self.compute(h, k, a); } let i = (h as usize * (self.k_max as usize + 1) + k as usize) * (self.a_max as usize + 1) + a as usize; - if self.seen[i] == 0 { - self.seen[i] = if self.compute(h, k, a) { 1 } else { 2 }; - } self.seen[i] == 1 } diff --git a/doc/sphincs/params_selection/tests/goldens.rs b/doc/sphincs/params_selection/tests/goldens.rs index 9692d5cc1..42dcbe96c 100644 --- a/doc/sphincs/params_selection/tests/goldens.rs +++ b/doc/sphincs/params_selection/tests/goldens.rs @@ -11,26 +11,30 @@ //! * for the search, a naive oracle in this crate that skips nothing. use sphincs_params::cost::{Blocks, Encoding, NuTable, Scheme}; -use sphincs_params::params::{Layers, Params, Profile, Skeleton, costs}; -use sphincs_params::search::{Budgets, Grid, LEVEL1_BITS, Span, Stats, naive_search, search}; +use sphincs_params::params::{Fors, Hypertree, Layer, Params, assemble, costs, hyper_cost}; +use sphincs_params::search::{Budgets, Grid, LEVEL1_BITS, Span, Stats, search}; use sphincs_params::security::{forgery_exponent, security_bits}; -fn params(scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64) -> Params { +fn params(scheme: Scheme, a: u64, k: u64) -> Params { Params { scheme, - h, - d, - h_top: None, a, k, - w, n: 16, - dropped_chains: 0, cache_height: None, cache_level_only: false, } } +/// One parameter set the way the report writes them: one WOTS instance for the +/// whole hypertree, the height split evenly. +fn uniform(scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, swn: Option) -> (Params, Hypertree) { + ( + params(scheme, a, k), + Hypertree::uniform(h, d, None, w, 0, swn).expect("consistent"), + ) +} + /// `(scheme, h, d, k, a, w, S_wn)` and the `(size, keygen, sign, verify, /// verify_worst)` it must produce, sizes in bytes and costs in compressions. type Fixture = (Scheme, u64, u64, u64, u64, u64, Option, [u64; 5]); @@ -112,8 +116,8 @@ const FIXTURES_CACHED: [Fixture; 7] = [ #[test] fn matches_the_sage_fixtures() { for &(scheme, h, d, k, a, w, swn, want) in &FIXTURES_CACHED { - let p = params(scheme, h, d, a, k, w); - let c = costs(p, swn).expect("consistent parameters"); + let (p, ht) = uniform(scheme, h, d, a, k, w, swn); + let c = costs(p, &ht).expect("consistent parameters"); let got = [ c.sig_bytes, c.keygen.compressions, @@ -184,8 +188,8 @@ const REPORT_TABLE: [ReportRow; 18] = [ #[test] fn matches_the_report_tables() { for (scheme, h, d, a, k, w, swn, sigver, sigtime_e4, search) in REPORT_TABLE { - let p = params(scheme, h, d, a, k, w); - let c = costs(p, swn).expect("consistent parameters"); + let (p, ht) = uniform(scheme, h, d, a, k, w, swn); + let c = costs(p, &ht).expect("consistent parameters"); let tag = format!("{} h={h} d={d} a={a} k={k} w={w} S={swn:?}", scheme.label()); assert_eq!(c.verify.hashes, sigver, "SigVer {tag}"); let got = c.sign_cold.hashes as f64 / 1e4; @@ -310,97 +314,21 @@ fn secure_k_form_an_up_set() { #[test] fn half_top_cache_is_a_saving_and_reduces_to_the_full_tree() { - let p = params(Scheme::WcFc, 40, 5, 14, 11, 256); - let c = costs(p, None).unwrap(); + let (p, ht) = uniform(Scheme::WcFc, 40, 5, 14, 11, 256, None); + let c = costs(p, &ht).unwrap(); assert!(c.sign.hashes < c.sign_cold.hashes); // caching at the leaves is caching the whole tree: nothing left to rebuild let whole = Params { cache_height: Some(0), ..p }; - assert!(costs(whole, None).unwrap().sign.hashes < c.sign.hashes); + assert!(costs(whole, &ht).unwrap().sign.hashes < c.sign.hashes); // caching only the root is caching nothing, so signing goes cold let none = Params { - cache_height: Some(p.profile().unwrap().h_top()), + cache_height: Some(ht.top().height()), ..p }; - assert_eq!(costs(none, None).unwrap().sign.hashes, c.sign_cold.hashes); -} - -fn budgets(log2_q_s: i32, keygen: u64, sign: u64, size: u64) -> Budgets { - Budgets { - q_s: 2f64.powi(log2_q_s), - keygen: Some(keygen), - sign: Some(sign), - size: Some(size), - security: LEVEL1_BITS, - } -} - -#[test] -fn search_agrees_with_a_naive_oracle() { - // A grid small enough to sweep with nothing skipped at all. - let b = budgets(20, 3_000_000, 10_000_000, 4_000); - let g = Grid { - schemes: vec![Scheme::Wc, Scheme::WcFc], - h: Span::pin(20), - a: Span::new(14, 16), - k: Span::new(1, 14), - chain_bits: vec![4], - dropped: Span::new(0, 1), - h_top: Some(Span::new(1, 20)), - ..Default::default() - }; - let mut st = Stats::default(); - let found = search(&b, &g, &mut st); - let oracle = naive_search(&b, &g); - assert!(!found.is_empty() && !oracle.is_empty()); - assert_eq!( - found[0].costs.verify.hashes, oracle[0].costs.verify.hashes, - "the oracle finds the same optimum" - ); - assert_eq!(found[0].key(), oracle[0].key(), "and the same winner"); - // Every parameter tuple the oracle found feasible is in the search's output, - // with the same best verification cost for that tuple. - let mut want: std::collections::HashMap<_, u64> = Default::default(); - for c in &oracle { - let e = want.entry(c.key()).or_insert(u64::MAX); - *e = (*e).min(c.costs.verify.hashes); - } - let got: std::collections::HashMap<_, u64> = found.iter().map(|c| (c.key(), c.costs.verify.hashes)).collect(); - assert_eq!(got, want, "the search and the oracle agree tuple by tuple"); -} - -#[test] -fn search_finds_and_improves_on_the_reports_bold_row() { - // Budgets near the report's 2^40 numbers, on its grid (w in {16, 256}, no - // chain dropping). Its own choice has to come out feasible, and the search - // has to do at least as well: it spends what is left of the signing budget - // raising the target sum, which the report's row does not. - let b = budgets(40, 1_100_000, 6_000_000, 4_400); - let g = Grid { - chain_bits: vec![4, 8], - dropped: Span::pin(0), - ..Default::default() - }; - let mut st = Stats::default(); - let found = search(&b, &g, &mut st); - let row = found - .iter() - .find(|c| c.key() == (Scheme::WcFc, 40, 5, 14, 11, 256, 0)) - .expect("the report's bold row is feasible under its own budgets"); - assert!( - row.costs.swn.unwrap() > 2040, - "the report's row grinds less than the budget allows" - ); - assert!( - row.costs.verify.hashes < 10402, - "so it can verify faster than the table's 10402 hashes" - ); - assert!( - found[0].costs.verify.hashes <= row.costs.verify.hashes, - "and the winner is at least as cheap" - ); + assert_eq!(costs(none, &ht).unwrap().sign.hashes, c.sign_cold.hashes); } #[test] @@ -408,17 +336,12 @@ fn a_taller_top_layer_is_free_on_size_and_verification() { // The whole point of per-layer heights: the signature carries h // authentication nodes and the verifier walks them however the layers // divide h, so only the signer's costs move. - let uniform = Params { - h_top: Some(8), - ..params(Scheme::WcFc, 40, 5, 14, 11, 256) - }; - let tall = Params { - h_top: Some(15), - ..uniform - }; - let (u, t) = (costs(uniform, None).unwrap(), costs(tall, None).unwrap()); - assert_eq!(u.profile.total(), 40); - assert_eq!(t.profile.total(), 40); + let p = params(Scheme::WcFc, 14, 11); + let flat = Hypertree::uniform(40, 5, Some(8), 256, 0, None).unwrap(); + let tall = Hypertree::uniform(40, 5, Some(15), 256, 0, None).unwrap(); + let (u, t) = (costs(p, &flat).unwrap(), costs(p, &tall).unwrap()); + assert_eq!(flat.height(), 40); + assert_eq!(tall.height(), 40); assert_eq!( (t.sig_bytes, t.verify), (u.sig_bytes, u.verify), @@ -429,18 +352,16 @@ fn a_taller_top_layer_is_free_on_size_and_verification() { "a taller top tree costs more to generate" ); assert!(t.sign_cold.hashes > u.sign_cold.hashes, "and more to sign cold"); - assert!(t.sign.hashes < u.sign.hashes, "but less with it, which is the point"); - // the lower layers come out as equal as they go, never differing by more - // than one level - let p = t.profile; - let lower: Vec = p.heights().skip(1).collect(); - let (lo, hi) = (lower.iter().min().unwrap(), lower.iter().max().unwrap()); assert!( - hi - lo <= 1, - "the lower layers never differ by more than a level: {lower:?}" + t.sign.hashes < u.sign.hashes, + "but less with the cache, which is the point" + ); + // the lower layers come out as equal as they go + let lower: Vec = tall.layers().skip(1).map(|x| x.height()).collect(); + assert!( + lower.iter().max().unwrap() - lower.iter().min().unwrap() <= 1, + "{lower:?}" ); - assert_eq!(Profile::canonical(41, 5, Some(9)).unwrap().total(), 41); - assert_eq!(Layers::new(&tall).unwrap().profile, t.profile); } /// Every way of splitting `h` over `d` layers, top first. @@ -459,131 +380,166 @@ fn compositions(h: u64, d: u64) -> Vec> { .collect() } -/// The search only ever builds `Profile::canonical`, and this is why that is -/// not a restriction: for the same `(h, d, h_top)`, no other profile costs less -/// on anything. #[test] -fn profile_shape_is_never_beaten() { - let mut checked = 0; - for (h, d) in [(12, 3), (14, 4), (9, 2), (16, 5), (20, 4)] { - let p = Params { - h, - d, - ..params(Scheme::WcFc, h, d, 10, 12, 16) - }; - let sk = Skeleton::new(p).expect("consistent"); - for heights in compositions(h, d) { - let Some(profile) = Profile::new(&heights) else { - continue; - }; - let Some(any) = Layers::from_profile(&p, profile) else { - continue; - }; - let canon = Layers::new(&Params { - h_top: Some(heights[0]), - ..p - }) - .expect("same top height"); - let tag = format!("h={h} d={d} heights={heights:?}"); - // size and verification do not see the profile at all - let (a, c) = (sk.finish(&any, 0, 0), sk.finish(&canon, 0, 0)); - assert_eq!( - (a.sig_bytes, a.verify), - (c.sig_bytes, c.verify), - "size or verification moved: {tag}" - ); - // keygen is the top tree, which they share - assert_eq!(any.keygen, canon.keygen, "keygen moved: {tag}"); - // and the canonical split is the cheapest to sign, cached or cold - assert!( - canon.trees_cached.compressions <= any.trees_cached.compressions, - "beaten on signing: {tag}" - ); - assert!( - canon.trees.compressions <= any.trees.compressions, - "beaten on cold signing: {tag}" - ); - checked += 1; - } - } - assert!(checked > 2000, "only {checked} profiles checked"); +fn any_hypertree_can_be_costed() { + let p = params(Scheme::WcFc, 14, 11); + // heights and WOTS parameters both varying, layer by layer + let mixed = Hypertree::new(&[ + Layer::new(11, 256, 0, Some(2040)).unwrap(), + Layer::new(5, 16, 1, None).unwrap(), + Layer::new(7, 4, 0, None).unwrap(), + Layer::new(3, 2, 0, None).unwrap(), + ]) + .unwrap(); + assert_eq!(mixed.height(), 26); + assert_eq!(mixed.depth(), 4); + assert!(!mixed.one_wots()); + assert_eq!(mixed.heights(), "11 + 5 + 7 + 3"); + let c = costs(p, &mixed).unwrap(); + // the signature carries one WOTS signature per layer, at that layer's l + let chains: u64 = mixed.layers().map(|x| x.chains(16, Scheme::WcFc).unwrap()).sum(); + let fors = Fors::new(&p).unwrap(); + assert_eq!(c.sig_bytes, fors.sig_bytes + 26 * 16 + chains * 16 + 4 * 4); + // a hypertree of one layer is an ordinary XMSS tree + let single = Hypertree::new(&[Layer::new(20, 16, 0, None).unwrap()]).unwrap(); + assert_eq!(single.depth(), 1); + assert!(costs(p, &single).is_some()); + assert!(Hypertree::new(&[]).is_none()); + assert!(Layer::new(0, 16, 0, None).is_none(), "every layer needs a level"); + assert!(Layer::new(64, 16, 0, None).is_none(), "2^height has to be countable"); + assert!(Layer::new(8, 24, 0, None).is_none(), "w is a power of two"); } -/// A profile is expressible however uneven, and reads back as given. +/// The search tries two WOTS instances, one for the top layer and one for the +/// rest, and this is what that costs against giving every layer its own. #[test] -fn any_profile_can_be_costed() { - let p = params(Scheme::WcFc, 26, 4, 14, 11, 256); - let lopsided = Profile::new(&[11, 5, 7, 3]).expect("26 over 4 layers"); - assert_eq!(lopsided.total(), 26); - assert_eq!(lopsided.h_top(), 11); - assert_eq!(format!("{lopsided}"), "11 + 5 + 7 + 3"); - let lay = Layers::from_profile(&p, lopsided).expect("adds up to h over d layers"); - assert_eq!(lay.profile, lopsided); - // and the canonical one with the same top is at least as cheap to sign - let canon = Layers::new(&Params { h_top: Some(11), ..p }).unwrap(); - assert_eq!(format!("{}", canon.profile), "11 + 5 + 5 + 5"); - assert!(canon.trees_cached.compressions <= lay.trees_cached.compressions); - // heights have to add up over the layers there are - assert!(Layers::from_profile(&p, Profile::new(&[11, 5, 7, 4]).unwrap()).is_none()); - assert!(Layers::from_profile(&p, Profile::new(&[13, 13]).unwrap()).is_none()); - assert!(Profile::new(&[11, 0, 15]).is_none(), "every layer needs a level"); - assert!(Profile::new(&[64]).is_none(), "2^height has to be countable"); +fn two_groups_against_every_per_layer_assignment() { + let p = params(Scheme::WcFc, 10, 12); + let fors = Fors::new(&p).unwrap(); + let mut nu = sphincs_params::cost::NuCache::new(16); + let configs: Vec<(u64, u64)> = [2, 4, 16].iter().flat_map(|&w| [0, 1].map(move |dr| (w, dr))).collect(); + let mut worst_gap = 0.0f64; + for (h, d) in [(8, 2), (11, 2), (9, 3), (12, 3)] { + // the budgets have to bind, or every assignment is feasible and the + // comparison says nothing + let (max_size, max_sign) = (5_000, 4_000_000); + let mut best_any = u64::MAX; + let mut best_two_group = u64::MAX; + for heights in compositions(h, d) { + for assignment in 0..configs.len().pow(d as u32) { + let layers: Option> = heights + .iter() + .enumerate() + .map(|(i, &height)| { + let (w, dr) = configs[assignment / configs.len().pow(i as u32) % configs.len()]; + Layer::new(height, w, dr, None) + }) + .collect(); + let Some(layers) = layers else { continue }; + let Some(ht) = Hypertree::new(&layers) else { continue }; + let Some(hyper) = hyper_cost(&p, &ht, &mut nu) else { + continue; + }; + let c = assemble(&p, &hyper, &fors); + if c.sig_bytes > max_size || c.sign.compressions > max_sign { + continue; + } + best_any = best_any.min(c.verify.compressions); + // is this assignment inside the two-group family? + let top = layers[0]; + let low = layers[layers.len() - 1]; + let even = Hypertree::two_group(h, d, top, low); + if even == Some(ht) { + best_two_group = best_two_group.min(c.verify.compressions); + } + } + } + assert!(best_any < u64::MAX, "nothing feasible at h={h} d={d}"); + assert!(best_two_group >= best_any, "the family cannot beat the whole space"); + let gap = best_two_group as f64 / best_any as f64 - 1.0; + worst_gap = worst_gap.max(gap); + println!( + "h={h} d={d}: two groups {best_two_group}, every assignment {best_any} ({:.1}% gap)", + 100.0 * gap + ); + } + // What the two-group restriction costs. Raise this only with a note saying + // which case moved and why. + assert!( + worst_gap <= 0.0, + "the two-group family lost {:.1}% somewhere", + 100.0 * worst_gap + ); } -#[test] -fn skeleton_rejects_trees_that_do_not_fit_a_u64() { - // 2^h' leaves has to be countable: without this the shift masks and a - // 2^64-leaf tree reports the cost of a one-leaf tree. - let p = params(Scheme::WcFc, 64, 1, 14, 11, 256); - assert!(Skeleton::new(p).is_none()); - assert!(Skeleton::new(Params { h: 63, ..p }).is_some()); - assert!(Skeleton::new(Params { a: 64, ..p }).is_none()); - // and the cost really does scale with the tree, so nothing wraps below that - let small = costs(Params { h: 40, d: 8, ..p }, None).unwrap(); - let large = costs(Params { h: 48, d: 8, ..p }, None).unwrap(); - // twice the leaves is twice the work plus the node joining the two halves - assert_eq!(large.keygen.hashes, small.keygen.hashes * 2 + 1); +fn budgets(log2_q_s: i32, keygen: u64, sign: u64, size: u64) -> Budgets { + Budgets { + q_s: 2f64.powi(log2_q_s), + keygen: Some(keygen), + sign: Some(sign), + size: Some(size), + security: LEVEL1_BITS, + } } #[test] -fn skeleton_rejects_inconsistent_parameters() { - let ok = params(Scheme::WcFc, 40, 5, 14, 11, 256); - assert!(Skeleton::new(ok).is_some()); - // d need not divide h: the layers just come out within one of each other - let uneven = Skeleton::new(Params { d: 3, ..ok }).expect("d need not divide h"); - assert_eq!(uneven.params.profile().unwrap().total(), 40); - assert!( - Skeleton::new(Params { h: 2, d: 3, ..ok }).is_none(), - "every layer needs a level" - ); - assert!( - Skeleton::new(Params { h_top: Some(40), ..ok }).is_none(), - "the lower layers need levels too" - ); - assert!( - Skeleton::new(Params { h_top: Some(36), ..ok }).is_some(), - "but only one each" - ); - assert!( - Skeleton::new(Params { w: 24, ..ok }).is_none(), - "w must be a power of two" - ); - assert!(Skeleton::new(Params { k: 1, ..ok }).is_none(), "FORS+C signs k-1 trees"); - assert!( - Skeleton::new(Params { - scheme: Scheme::Spx, - dropped_chains: 1, - ..ok +fn search_finds_and_improves_on_the_reports_bold_row() { + // Budgets near the report's 2^40 numbers, on its grid (w in {16, 256}, no + // chain dropping). Its own choice has to come out feasible, and the search + // has to do at least as well: it spends what is left of the signing budget + // raising the target sums, which the report's row does not. + let b = budgets(40, 1_100_000, 6_000_000, 4_400); + let g = Grid { + chain_bits: vec![4, 8], + dropped: Span::pin(0), + ..Default::default() + }; + let mut st = Stats::default(); + let found = search(&b, &g, &mut st); + let row = found + .iter() + .find(|c| { + let ht = c.costs.hypertree; + (c.params.a, c.params.k, ht.height(), ht.depth(), ht.top().w()) == (14, 11, 40, 5, 256) }) - .is_none(), - "WOTS-TW has no counter" + .expect("the report's bold row is feasible under its own budgets"); + let swn = row.costs.hypertree.top().swn().unwrap(); + assert!(swn > 2040, "the report's row grinds less than the budget allows"); + assert!( + row.costs.verify.hashes < 10402, + "so it can verify faster than the table's 10402 hashes" ); assert!( - Layers::new(&Params { - cache_height: Some(99), - ..ok - }) - .is_none(), - "the cache sits inside the top tree" + found[0].costs.verify.compressions <= row.costs.verify.compressions, + "and the winner is at least as cheap" ); } + +#[test] +fn rejects_what_it_cannot_count_or_assemble() { + let p = params(Scheme::WcFc, 14, 11); + // 2^h has to be countable: without this the shift masks and a 2^64-leaf + // tree reports the cost of a one-leaf tree + assert!(Layer::new(64, 256, 0, None).is_none()); + assert!(Hypertree::uniform(64, 1, None, 256, 0, None).is_none()); + assert!(Hypertree::uniform(63, 1, None, 256, 0, None).is_some()); + // and the cost really does scale with the tree, so nothing wraps below that + let small = costs(p, &Hypertree::uniform(40, 8, None, 256, 0, None).unwrap()).unwrap(); + let large = costs(p, &Hypertree::uniform(48, 8, None, 256, 0, None).unwrap()).unwrap(); + // twice the leaves in the top tree is twice the work plus the node joining + // the two halves + assert_eq!(large.keygen.hashes, small.keygen.hashes * 2 + 1); + assert!(large.sign_cold.hashes > small.sign_cold.hashes); + // FORS+C signs k-1 trees, so it needs two + assert!(Fors::new(&Params { k: 1, ..p }).is_none()); + // every layer needs a level, and the heights have to add up + assert!(Hypertree::uniform(2, 3, None, 256, 0, None).is_none()); + assert!(Hypertree::uniform(40, 5, Some(40), 256, 0, None).is_none()); + assert!(Hypertree::uniform(40, 5, Some(36), 256, 0, None).is_some()); + // the cache sits inside the top tree + let deep = Params { + cache_height: Some(99), + ..p + }; + assert!(costs(deep, &Hypertree::uniform(40, 5, None, 256, 0, None).unwrap()).is_none()); +} From a66fdb13272df0b36b226316836cfe3d7fb2a221 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 17:07:12 +0200 Subject: [PATCH 22/31] w --- doc/sphincs/params_selection/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sphincs/params_selection/README.md b/doc/sphincs/params_selection/README.md index e7b701652..3ee753956 100644 --- a/doc/sphincs/params_selection/README.md +++ b/doc/sphincs/params_selection/README.md @@ -12,7 +12,7 @@ cargo run --release -- --lifetime 1e12 --scheme W+C_F+C --height 40 --layers 5 - That pins everything, so it just costs that one set: the report's bold 2^40 row, 4356 bytes and 10425 compressions to verify. Size and verification do not depend on the lifetime, only the security line does. Leave axes out and they get searched instead, against whichever budgets you set: ```sh -cargo run --release -- --lifetime 16,777,216 --max-keygen 2e6 --max-sign 100,000 --max-size 5000 +cargo run --release -- --lifetime 16,777,216 --max-keygen 2e6 --max-sign 200,000 --max-size 5000 ``` Every cost is compression calls, one per 64 bytes of hash input: a Merkle node or a WOTS chain step is one, the message digest two, compressing `m` hash values `ceil((2n + mn) / 64)`. From 48e012f40e613283a999229d8fafcc6b32c0666b Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Sun, 23 Aug 2026 17:10:13 +0200 Subject: [PATCH 23/31] Revert "doc/sphincs: a WOTS instance per layer, and rayon" This reverts commit 737c6d6a. One WOTS instance for the whole hypertree again; the layer heights still vary. It never won, which the reverted commit's own numbers say: on the query where the heights come out most uneven, 6 + 11 + 11 under a tight keygen budget, --split-wots searched every pair of instances and the uniform choice still came first at 377 compressions against 379. The one thing the split did buy was a step of target sum on the top layer, 205 against 204, worth a single compression on the 2^24 query. The reason is in the model rather than in those queries: size charges every layer the same l*n and verification charges every layer its own walk, so the exchange rate between them is identical everywhere, and the walk is convex in l, so at a fixed total l an equal split is what a size budget wants. Only signing distinguishes the layers. So the axis was complexity for nothing: it doubled the searched grid per extra instance, needed a grinding frontier per hypertree in place of a binary search, and needed rayon to get back to the runtime it started at. Profile's docs now say it was tried and why it lost, so nobody has to find out twice. Co-Authored-By: Claude Opus 5 (1M context) --- doc/sphincs/params_selection/Cargo.lock | 54 -- doc/sphincs/params_selection/Cargo.toml | 1 - doc/sphincs/params_selection/README.md | 6 +- doc/sphincs/params_selection/src/cost.rs | 54 -- doc/sphincs/params_selection/src/lib.rs | 14 +- doc/sphincs/params_selection/src/main.rs | 101 +-- doc/sphincs/params_selection/src/params.rs | 774 ++++++++---------- doc/sphincs/params_selection/src/report.rs | 162 ++-- doc/sphincs/params_selection/src/search.rs | 634 +++++++------- doc/sphincs/params_selection/src/security.rs | 29 +- doc/sphincs/params_selection/tests/goldens.rs | 412 +++++----- 11 files changed, 962 insertions(+), 1279 deletions(-) diff --git a/doc/sphincs/params_selection/Cargo.lock b/doc/sphincs/params_selection/Cargo.lock index 643dd6230..f16565f89 100644 --- a/doc/sphincs/params_selection/Cargo.lock +++ b/doc/sphincs/params_selection/Cargo.lock @@ -2,60 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "crossbeam-deque" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "sphincs_params" version = "0.1.0" -dependencies = [ - "rayon", -] diff --git a/doc/sphincs/params_selection/Cargo.toml b/doc/sphincs/params_selection/Cargo.toml index 45f7abd5c..1387cca24 100644 --- a/doc/sphincs/params_selection/Cargo.toml +++ b/doc/sphincs/params_selection/Cargo.toml @@ -8,7 +8,6 @@ edition = "2024" [workspace] [dependencies] -rayon = "1.12.0" [lints.clippy] too_many_arguments = "allow" diff --git a/doc/sphincs/params_selection/README.md b/doc/sphincs/params_selection/README.md index 3ee753956..7fedd41fd 100644 --- a/doc/sphincs/params_selection/README.md +++ b/doc/sphincs/params_selection/README.md @@ -21,11 +21,7 @@ Every cost is compression calls, one per 64 bytes of hash input: a Merkle node o Since size and verification depend only on `(h, d)` and not on how the layers divide `h`, a taller top layer is free on both and cheaper to sign with the cache: compare `--top-height 8` against `--top-height 15` at `--height 40 --layers 5`. -Each layer carries its own WOTS instance too, so `w`, the target sum and the dropped chains need not agree across layers. `--layer 12,w=16,swn=240 --layer 12,w=8,drop=1` costs one such hypertree outright, `--heights 11,5,7,3` gives just the heights, and `--split-wots` searches a separate instance for the top layer. - -Two instances is all a search needs. Any Lagrangian relaxation of the per-layer choice is separable, so each layer takes its own argmin, and every layer below the top has an identical cost function, since only the top tree is the cached one and only it is what keygen pays for. So at most two distinct choices come back. `two_groups_against_every_per_layer_assignment` checks that against every per-layer assignment of several small hypertrees, and finds no gap. - -Whether it is worth searching is another matter. Size charges every layer the same `l * n` and verification charges every layer its own walk, so on those two the exchange rate is identical everywhere and a uniform `w` is what a size budget wants: the walk is convex in `l`, so at a fixed total `l` an equal split is cheapest. Only signing distinguishes the layers, a tall tree wanting cheap leaves. So per-layer WOTS pays only when the signing budget binds and the heights are uneven, and on the queries tried here the uniform choice still wins. +Layer heights can be given outright with `--heights 11,5,7,3`, which pins `h` and `d` with them. The search never produces an uneven lower half, and that is not a restriction: for the same `h`, `d` and top height, no other profile costs less on anything, since size, verification and keygen do not move and signing sums `2^height`, which at a fixed total is smallest when the heights are equal. `profile_shape_is_never_beaten` checks that against every composition of several small `(h, d)`. So `--heights` is for costing a profile you already have in mind. `cargo run --release --` with no arguments prints every flag and its default. `cargo test --release` runs the goldens: the upstream sage fixtures, the report's own tables, and a naive search oracle that skips nothing. diff --git a/doc/sphincs/params_selection/src/cost.rs b/doc/sphincs/params_selection/src/cost.rs index 7c2f73952..b368c5797 100644 --- a/doc/sphincs/params_selection/src/cost.rs +++ b/doc/sphincs/params_selection/src/cost.rs @@ -307,11 +307,6 @@ impl NuTable { self.trials.get(swn as usize).copied().unwrap_or(u64::MAX) } - /// The largest target sum the code can reach. - pub fn max_swn(&self) -> u64 { - self.l * (self.w - 1) - } - /// The least grinding any target sum can ask for. /// /// Read off the table rather than assumed to sit at the mean, so nothing @@ -320,52 +315,3 @@ impl NuTable { self.trials.iter().copied().min().unwrap_or(u64::MAX) } } - -/// [`NuTable`]s by `(l, chain_bits)`, since a search revisits the same few. -/// -/// Direct-mapped rather than hashed: a search asks for one per layer per -/// candidate, millions of times, and hashing showed up in the profile. -pub struct NuCache { - digest_bits: u32, - slots: Vec>, -} - -const MAX_L: usize = 512; -const MAX_BITS: usize = 16; - -impl NuCache { - pub fn new(n: u64) -> Self { - Self { - digest_bits: (8 * n) as u32, - slots: vec![None; MAX_L * MAX_BITS], - } - } - - fn slot(l: u64, w: u64) -> Option { - let bits = w.trailing_zeros() as usize; - (w.is_power_of_two() && (l as usize) < MAX_L && bits < MAX_BITS).then(|| l as usize * MAX_BITS + bits) - } - - pub fn table(&mut self, l: u64, w: u64) -> &NuTable { - let i = Self::slot(l, w).expect("l and w within the searched ranges"); - if self.slots[i].is_none() { - self.slots[i] = Some(NuTable::new(l, w, self.digest_bits)); - } - self.slots[i].as_ref().expect("just filled") - } - - /// Two tables at once, which a two-group hypertree needs. - pub fn pair(&mut self, top: (u64, u64), low: (u64, u64)) -> (&NuTable, &NuTable) { - self.table(top.0, top.1); - self.table(low.0, low.1); - let (i, j) = (Self::slot(top.0, top.1).unwrap(), Self::slot(low.0, low.1).unwrap()); - if i == j { - let t = self.slots[i].as_ref().unwrap(); - return (t, t); - } - let (lo, hi) = if i < j { (i, j) } else { (j, i) }; - let (left, right) = self.slots.split_at(hi); - let (a, b) = (left[lo].as_ref().unwrap(), right[0].as_ref().unwrap()); - if i < j { (a, b) } else { (b, a) } - } -} diff --git a/doc/sphincs/params_selection/src/lib.rs b/doc/sphincs/params_selection/src/lib.rs index 8bc19ec18..bcf6203d8 100644 --- a/doc/sphincs/params_selection/src/lib.rs +++ b/doc/sphincs/params_selection/src/lib.rs @@ -21,20 +21,14 @@ //! //! The hypertree's height is split per layer, not `h/d` on every layer: the top //! tree gets `h_top` and the rest divide what is left as evenly as it goes, so -//! `d` need not divide `h`, and [`params::Hypertree`] can hold any heights at -//! all though only that shape is ever searched. That matters because the -//! signature carries `h` +//! `d` need not divide `h`, and [`params::Profile`] can hold any heights at all +//! though only that shape is ever searched. That matters because the signature +//! carries `h` //! authentication nodes and the verifier walks them however the layers divide //! `h`: size and verification depend only on `(h, d)`, while only the top tree //! is cacheable. A taller top layer is therefore free on both, costs keygen and //! vanilla signing, and cuts cached signing, which at `h = 40, d = 5` is 2x for -//! `h_top = 15` against the uniform 8. -//! -//! A [`params::Layer`] also carries its own WOTS instance, so `w`, the target -//! sum and the dropped chains need not agree across layers either, and -//! [`params::Layer`] says what varying them buys and when. A search tries two -//! instances, one for the top layer and one for the rest, which -//! [`params::Hypertree::two_group`] argues is all it needs. +//! `h_top = 15` against the uniform 8. See [`params::Profile`]. //! //! For one parameter set [`params::costs`] reports the signature size and the //! keygen, signing and verification cost, and [`security::security_bits`] the diff --git a/doc/sphincs/params_selection/src/main.rs b/doc/sphincs/params_selection/src/main.rs index d92819fbe..a426b5d8d 100644 --- a/doc/sphincs/params_selection/src/main.rs +++ b/doc/sphincs/params_selection/src/main.rs @@ -2,7 +2,7 @@ //! and everything left over gets searched. use sphincs_params::cost::{SCHEMES, Scheme}; -use sphincs_params::params::{Hypertree, Layer}; +use sphincs_params::params::Profile; use sphincs_params::report::{legend, report, si, signatures, table, utilization}; use sphincs_params::search::{ A_MAX, Budgets, CHAIN_BITS_MAX, D_MAX, DROPPED_MAX, Grid, H_MAX, K_MAX, LEVEL1_BITS, Span, Stats, Sums, edges, @@ -25,14 +25,10 @@ parameters --top-height ht height of the top XMSS tree, the rest of h splitting evenly below it [1..h-d+1, or h/d] --heights H,... every layer height outright, top first, pinning h and d - --layer SPEC one layer outright, repeated top first, e.g. - --layer 12,w=16,swn=240 --layer 12,w=8,drop=1 - Fields: the bare number is the height, then w=, swn=, - drop=. Pins h, d and every layer's WOTS instance. - --split-wots search a separate WOTS instance for the top layer rather - than one for the whole hypertree. Two instances is all a - search needs (see Hypertree::two_group), but it multiplies - the grid by the number of instances, so expect minutes. + with it. Nothing here searches uneven lower layers, + because for the same h, d and top height they never cost + less: see Profile in src/params.rs. This is for costing + one anyway. -a A log2 of the leaves in a FORS tree [1..32] -k K FORS trees [1..64] --chain-bits B log2(w), repeatable [1..12] @@ -92,17 +88,15 @@ fn main() -> std::process::ExitCode { /// Flags and their values, repeatable flags kept in order. struct Args(Vec<(String, Option)>); -const NO_VALUE: [&str; 5] = ["--cache-level-only", "--stats", "--help", "-h", "--split-wots"]; +const NO_VALUE: [&str; 4] = ["--cache-level-only", "--stats", "--help", "-h"]; -const FLAGS: [&str; 24] = [ +const FLAGS: [&str; 22] = [ "--lifetime", "--scheme", "--height", "--layers", "--top-height", "--heights", - "--layer", - "--split-wots", "-a", "-k", "--chain-bits", @@ -242,61 +236,6 @@ impl Args { } } -/// `--heights 12,7,7`, or `--layer 12,w=16,swn=240 --layer 7,w=8` repeated once -/// per layer, top first. Both give the hypertree outright. -fn layers_from(args: &Args) -> Result, String> { - let specs = args.all("--layer"); - if !specs.is_empty() { - let layers: Vec = specs - .iter() - .map(|spec| { - let mut height = None; - let (mut w, mut dropped, mut swn) = (16, 0, None); - for field in spec.split(',') { - let field = field.trim(); - let (key, value) = field.split_once('=').unwrap_or(("height", field)); - let value: u64 = value - .replace([',', '_'], "") - .parse() - .map_err(|_| format!("--layer {spec}: {value} is not a number"))?; - match key { - "height" | "h" => height = Some(value), - "w" => w = value, - "drop" | "dropped" => dropped = value, - "swn" | "S" => swn = Some(value), - other => return Err(format!("--layer {spec}: unknown field {other}")), - } - } - let height = height.ok_or_else(|| format!("--layer {spec}: no height"))?; - Layer::new(height, w, dropped, swn) - .ok_or_else(|| format!("--layer {spec}: height 1..=63 and w a power of two are needed")) - }) - .collect::>()?; - return Ok(Some( - Hypertree::new(&layers).ok_or("--layer: 1 to 32 layers, top first")?, - )); - } - let Some(list) = args.get("--heights") else { - return Ok(None); - }; - let heights: Vec = list - .split(',') - .map(|x| { - x.trim() - .parse::() - .map_err(|_| format!("--heights: expected numbers, got {list}")) - }) - .collect::>()?; - let w = args.num("-w")?.unwrap_or(16); - let dropped = args.num("--drop-chains")?.unwrap_or(0); - let swn = args.num("--swn")?; - let layers: Option> = heights.iter().map(|&h| Layer::new(h, w, dropped, swn)).collect(); - Ok(Some( - Hypertree::new(&layers.ok_or("--heights: heights are 1..=63")?) - .ok_or("--heights: 1 to 32 heights, top first")?, - )) -} - fn run(argv: &[String]) -> Result { let args = Args::parse(argv)?; let q_s = args.float("--lifetime")?.ok_or("--lifetime is required")?; @@ -331,16 +270,29 @@ fn run(argv: &[String]) -> Result { (None, true) => Some(Span::new(1, H_MAX)), (None, false) => None, }; - let hypertree = layers_from(&args)?; + let profile = match args.get("--heights") { + None => None, + Some(list) => { + let heights: Vec = list + .split(',') + .map(|x| { + x.trim() + .parse::() + .map_err(|_| format!("--heights: expected numbers, got {list}")) + }) + .collect::>()?; + Some(Profile::new(&heights).ok_or_else(|| format!("--heights: {list} is not 1..=32 heights of 1..=63"))?) + } + }; let g = Grid { schemes: args.schemes()?, n: args.u64_or("-n", 16)?, - h: match hypertree { - Some(ht) => Span::pin(ht.height()), + h: match profile { + Some(pr) => Span::pin(pr.total()), None => args.span("--height", Span::new(1, H_MAX))?, }, - d: match hypertree { - Some(ht) => Span::pin(ht.depth()), + d: match profile { + Some(pr) => Span::pin(pr.layers()), None => args.span("--layers", Span::new(1, D_MAX))?, }, h_top, @@ -349,8 +301,7 @@ fn run(argv: &[String]) -> Result { dropped, chain_bits: args.chain_bits()?, sums, - split_wots: args.flag("--split-wots"), - hypertree, + profile, cache_height: args.num("--cache-height")?, cache_level_only: args.flag("--cache-level-only"), }; diff --git a/doc/sphincs/params_selection/src/params.rs b/doc/sphincs/params_selection/src/params.rs index 8fb420987..9d02625f4 100644 --- a/doc/sphincs/params_selection/src/params.rs +++ b/doc/sphincs/params_selection/src/params.rs @@ -1,420 +1,435 @@ //! One parameter set, and the costs it implies. -//! -//! A parameter set is a [`Params`] (the FORS side and the hash size) plus a -//! [`Hypertree`], which carries every layer's Merkle height and the WOTS -//! parameters signing into it. Nothing forces those to agree across layers, and -//! [`Layer`] documents what varying them buys. -use crate::cost::{Blocks, COUNTER_BYTES, Cost, Encoding, NuCache, NuTable, Scheme}; +use crate::cost::{Blocks, COUNTER_BYTES, Cost, Encoding, NuTable, Scheme}; -/// The most hypertree layers a [`Hypertree`] can hold. -pub const MAX_LAYERS: usize = 32; - -/// `Layer::swn` when the target sum is the mean, where grinding is cheapest. -const SWN_MEAN: u32 = u32::MAX; - -/// One hypertree layer: its Merkle height, and the WOTS instance whose keys sit -/// at its leaves and sign the layer below (the FORS root, at the bottom). -/// -/// The layers need not agree. What that buys is narrow but real. Verification, -/// signature size and per-leaf signing work all move together with `w`: a -/// smaller `w` means more chains, so a bigger signature, but fewer chain steps -/// to walk and fewer to build. Size charges every layer the same `l * n`, and -/// verification charges every layer its own walk, so on those two the exchange -/// rate is identical everywhere and a uniform `w` is what a size budget wants: -/// the walk `(2^(8n/l) - 1) * l` is convex in `l`, so at a fixed total `l` an -/// equal split is cheapest. -/// -/// Signing is where the layers differ, because a layer's tree costs -/// `2^height` leaves. A tall tree wants cheap leaves, so a small `w`, and a -/// short one can afford a large `w` to give its size back. So per-layer WOTS -/// pays exactly when the signing budget binds and the heights are uneven, which -/// is what a tight key generation budget produces. +/// A SPHINCS+ parameter set. `q_s` is not part of it: see [`crate::security`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Layer { - height: u8, - chain_bits: u8, - dropped: u8, - swn: u32, -} - -impl Layer { - /// `None` unless the height is countable, `w` is a power of two, and the - /// target sum is reachable. - pub fn new(height: u64, w: u64, dropped_chains: u64, swn: Option) -> Option { - if !(1..=63).contains(&height) || w < 2 || !w.is_power_of_two() || dropped_chains > 255 { - return None; - } - let swn = match swn { - None => SWN_MEAN, - Some(s) => u32::try_from(s).ok().filter(|&s| s != SWN_MEAN)?, - }; - Some(Self { - height: height as u8, - chain_bits: w.trailing_zeros() as u8, - dropped: dropped_chains as u8, - swn, - }) - } - - pub fn height(&self) -> u64 { - self.height as u64 - } - pub fn w(&self) -> u64 { - 1 << self.chain_bits - } - pub fn chain_bits(&self) -> u64 { - self.chain_bits as u64 - } - pub fn dropped_chains(&self) -> u64 { - self.dropped as u64 - } - /// The target digit sum, or `None` for the mean. - pub fn swn(&self) -> Option { - (self.swn != SWN_MEAN).then_some(self.swn as u64) - } - - pub fn with_height(&self, height: u64) -> Option { - Self::new(height, self.w(), self.dropped_chains(), self.swn()) - } - pub fn with_swn(&self, swn: Option) -> Option { - Self::new(self.height(), self.w(), self.dropped_chains(), swn) - } - - pub fn encoding(&self, n: u64) -> Option { - Encoding::new(self.w(), n, self.dropped_chains()) - } - - /// Chains actually signed: `l1 + l2` for WOTS-TW, the encoding's for WOTS+C. - pub fn chains(&self, n: u64, scheme: Scheme) -> Option { - let enc = self.encoding(n)?; - if scheme.wots_c() { - return Some(enc.chains); - } - // WOTS-TW pads the digest to whole digits and appends a checksum - // (FIPS 205): l1 = ceil(8n / log2 w), l2 = floor(log_w(l1*(w-1))) + 1. - let l1 = (8 * n).div_ceil(enc.chain_bits); - Some(l1 + self.wots_tw_len2(l1, n)) - } - - fn wots_tw_len2(&self, l1: u64, n: u64) -> u64 { - let _ = n; - (l1 * (self.w() - 1)).ilog2() as u64 / self.chain_bits() + 1 - } - - /// Verifier chain steps for WOTS-TW when every message digit is zero. - fn wots_tw_worst_steps(&self, n: u64) -> u64 { - let bits = self.chain_bits(); - let l1 = (8 * n).div_ceil(bits); - let l2 = self.wots_tw_len2(l1, n); - let (w, c) = (self.w(), l1 * (self.w() - 1)); - let digit_sum = { - let (mut rem, mut sum) = (c, 0); - while rem > 0 { - sum += rem % w; - rem /= w; - } - sum - }; - l1 * (w - 1) + l2 * (w - 1) - digit_sum - } +pub struct Params { + pub scheme: Scheme, + /// Total hypertree height, the sum of the layer heights. + pub h: u64, + /// Hypertree layers. + pub d: u64, + /// Height of the top XMSS tree. `None` spreads `h` as evenly as it goes, + /// which for `d | h` is the classic `h' = h/d` on every layer. + pub h_top: Option, + /// FORS trees have `2^a` leaves. + pub a: u64, + /// Number of FORS trees. + pub k: u64, + /// Winternitz parameter, a power of two. + pub w: u64, + /// Hash output in bytes. + pub n: u64, + /// Chains dropped beyond the digest bits that have to be pinned anyway. + pub dropped_chains: u64, + /// Height above the leaves of the cached top-tree level; `None` is half of it. + pub cache_height: Option, + /// Cache one level rather than it and everything above. + pub cache_level_only: bool, } -/// Every layer of the hypertree, top first. +/// The height of every XMSS tree in the hypertree, top first. +/// +/// Any heights are expressible, but a search only ever needs +/// [`Profile::canonical`]: the top tree at some height and the rest dividing +/// what is left as evenly as it goes. For a fixed `(h, d, h_top)` that shape is +/// no worse than any other on every cost, since size and verification depend +/// only on `(h, d)`, keygen only on `h_top`, and signing sums `2^height` over +/// the layers, which at a fixed total is smallest when they are equal. So +/// enumerating `(h, d, h_top)` covers the cost-optimal representative of every +/// profile. `profile_shape_is_never_beaten` in `tests/goldens` checks that +/// against every composition of a few small `(h, d)`. /// -/// Any heights and any WOTS parameters are expressible. A search need not try -/// them all: see [`Hypertree::two_group`]. +/// Only the heights vary per layer: every layer signs with the same WOTS +/// parameters. Giving each its own `w`, target sum and dropped chain count was +/// implemented and reverted, because it never won. Size charges every layer the +/// same `l * n` and verification charges every layer its own walk, so the +/// exchange rate between them is identical everywhere, and the walk +/// `(2^(8n/l) - 1) * l` is convex in `l`, so at a fixed total `l` an equal split +/// is what a size budget wants. Only signing distinguishes the layers, a tall +/// tree wanting cheap leaves, so it pays only where the signing budget binds and +/// the heights are uneven; searched there, the uniform choice still won, the +/// per-layer target sums differing by one step. The reverted commit carries the +/// working code and the reasoning, including why a search would never need more +/// than two distinct WOTS instances. +/// +/// Heights are at most 63, since `2^height` has to be countable, and there are +/// at most [`MAX_LAYERS`] of them. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Hypertree { - layers: [Layer; MAX_LAYERS], +pub struct Profile { + heights: [u8; MAX_LAYERS], len: u8, } -impl Hypertree { - pub fn new(layers: &[Layer]) -> Option { - if layers.is_empty() || layers.len() > MAX_LAYERS { +/// The most hypertree layers a [`Profile`] can hold. +pub const MAX_LAYERS: usize = 32; + +impl Profile { + /// Any heights at all, the top tree first. + pub fn new(heights: &[u64]) -> Option { + if heights.is_empty() || heights.len() > MAX_LAYERS || heights.iter().any(|&x| !(1..=63).contains(&x)) { return None; } let mut out = Self { - layers: [layers[0]; MAX_LAYERS], - len: layers.len() as u8, + heights: [0; MAX_LAYERS], + len: heights.len() as u8, }; - out.layers[..layers.len()].copy_from_slice(layers); + for (slot, &h) in out.heights.iter_mut().zip(heights) { + *slot = h as u8; + } Some(out) } - /// One WOTS instance for every layer, the top tree at `h_top` and the rest - /// dividing `h - h_top` as evenly as it goes. `None` for `h_top` is the - /// classic `h/d` split. - pub fn uniform(h: u64, d: u64, h_top: Option, w: u64, dropped: u64, swn: Option) -> Option { - let top = Layer::new(h_top.unwrap_or(h / d).max(1), w, dropped, swn)?; - Self::two_group(h, d, top, top) - } - - /// The top layer as given, every other layer sharing `low`'s WOTS - /// parameters and dividing what is left of `h` as evenly as it goes. - /// - /// This is the only shape a search has to try. Any Lagrangian relaxation of - /// the layer choice, `min sum_i [verify_i + L*size_i + M*sign_i]`, is - /// separable and so returns each layer's own argmin; but every layer below - /// the top has an identical cost function, since only the top tree is the - /// cached one and only it is what keygen pays for. So at most two distinct - /// choices come back, ties aside, and the ties are between neighbouring - /// heights, which the split here already spans. `two_group_attains_the_optimum` - /// in `tests/goldens` checks that against every per-layer assignment of - /// small hypertrees. - pub fn two_group(h: u64, d: u64, top: Layer, low: Layer) -> Option { - if d == 0 || d as usize > MAX_LAYERS { + /// The top tree at `h_top`, the other `d - 1` layers dividing `h - h_top` as + /// evenly as it goes. `None` for `h_top` is the classic `h/d` split. + pub fn canonical(h: u64, d: u64, h_top: Option) -> Option { + if d == 0 || d as usize > MAX_LAYERS || h == 0 { return None; } - let lower_total = h.checked_sub(top.height())?; + let h_top = h_top.unwrap_or(h / d).max(1); + let lower_total = h.checked_sub(h_top)?; let m = d - 1; if m == 0 { - return (lower_total == 0).then(|| Self::new(&[top]))?; + return (lower_total == 0).then(|| Self::new(&[h_top]))?; } if lower_total < m { return None; // every layer needs at least one level } let (q, r) = (lower_total / m, lower_total % m); - let mut layers = vec![top]; - for i in 0..m { - layers.push(low.with_height(if i < r { q + 1 } else { q })?); - } - Self::new(&layers) + let mut heights = vec![h_top]; + heights.extend(std::iter::repeat_n(q + 1, r as usize)); + heights.extend(std::iter::repeat_n(q, (m - r) as usize)); + Self::new(&heights) } - pub fn layers(&self) -> impl Iterator + '_ { - self.layers[..self.len as usize].iter().copied() + pub fn heights(&self) -> impl Iterator + '_ { + self.heights[..self.len as usize].iter().map(|&x| x as u64) } - /// The one layer that is the same for every signature, and so the only one - /// worth caching. - pub fn top(&self) -> Layer { - self.layers[0] + /// The top tree's height: the one layer that is the same for every signature. + pub fn h_top(&self) -> u64 { + self.heights().next().unwrap_or(0) } - /// Total height, which is what the authentication path carries. - pub fn height(&self) -> u64 { - self.layers().map(|x| x.height()).sum() + pub fn total(&self) -> u64 { + self.heights().sum() } - pub fn depth(&self) -> u64 { + pub fn layers(&self) -> u64 { self.len as u64 } - /// Do all layers share their WOTS parameters? - pub fn one_wots(&self) -> bool { - let top = self.top(); - self.layers() - .all(|x| (x.w(), x.dropped_chains(), x.swn()) == (top.w(), top.dropped_chains(), top.swn())) + pub fn uniform(&self) -> bool { + self.heights().all(|x| x == self.h_top()) } +} - pub fn with_swn(&self, swn: Option) -> Option { - let layers: Option> = self.layers().map(|x| x.with_swn(swn)).collect(); - Self::new(&layers?) +impl std::fmt::Display for Profile { + /// Every height, top first: `12 + 7 + 7`. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let listed: Vec = self.heights().map(|h| h.to_string()).collect(); + write!(f, "{}", listed.join(" + ")) } +} - /// Heights only: `12 + 7 + 7`. - pub fn heights(&self) -> String { - let listed: Vec = self.layers().map(|x| x.height().to_string()).collect(); - listed.join(" + ") +impl Params { + pub fn profile(&self) -> Option { + Profile::canonical(self.h, self.d, self.h_top) } -} -impl std::fmt::Display for Hypertree { - /// The heights, and the WOTS parameters wherever the layers disagree. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if self.one_wots() { - return write!(f, "{}", self.heights()); + pub fn encoding(&self) -> Option { + Encoding::new(self.w, self.n, self.dropped_chains) + } + + /// Chains actually signed: `l1 + l2` for WOTS-TW, the encoding's for WOTS+C. + pub fn chains(&self) -> Option { + let enc = self.encoding()?; + if self.scheme.wots_c() { + return Some(enc.chains); } - let listed: Vec = self - .layers() - .map(|x| match (x.dropped_chains(), x.swn()) { - (0, None) => format!("{}(w={})", x.height(), x.w()), - (0, Some(s)) => format!("{}(w={},S={s})", x.height(), x.w()), - (dr, None) => format!("{}(w={},-{dr})", x.height(), x.w()), - (dr, Some(s)) => format!("{}(w={},S={s},-{dr})", x.height(), x.w()), - }) - .collect(); - write!(f, "{}", listed.join(" + ")) + // WOTS-TW pads the digest to whole digits and appends a checksum + // (FIPS 205): l1 = ceil(8n / log2 w), l2 = floor(log_w(l1*(w-1))) + 1. + let l1 = (8 * self.n).div_ceil(enc.chain_bits); + Some(l1 + self.wots_tw_len2(l1)) } -} -/// The FORS side of a parameter set, and the hash size. The hypertree is -/// separate: see [`Hypertree`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Params { - pub scheme: Scheme, - /// FORS trees have `2^a` leaves. - pub a: u64, - /// Number of FORS trees. - pub k: u64, - /// Hash output in bytes. - pub n: u64, - /// Height above the leaves of the cached top-tree level; `None` is half of it. - pub cache_height: Option, - /// Cache one level rather than it and everything above. - pub cache_level_only: bool, -} + fn wots_tw_len2(&self, l1: u64) -> u64 { + let bits = self.encoding().expect("checked by the caller").chain_bits; + (l1 * (self.w - 1)).ilog2() as u64 / bits + 1 + } -impl Params { + /// Verifier chain steps for WOTS-TW when every message digit is zero. + fn wots_tw_worst_steps(&self) -> u64 { + let enc = self.encoding().expect("checked by the caller"); + let l1 = (8 * self.n).div_ceil(enc.chain_bits); + let l2 = self.wots_tw_len2(l1); + let c = l1 * (self.w - 1); + let digit_sum: u64 = { + let (mut rem, mut sum) = (c, 0); + while rem > 0 { + sum += rem % self.w; + rem /= self.w; + } + sum + }; + l1 * (self.w - 1) + l2 * (self.w - 1) - digit_sum + } + + /// The compression counts of the hashes this parameter set uses. pub fn blocks(&self) -> Blocks { Blocks::new(self.n) } - /// FORS trees actually built and authenticated: FORS+C grinds the last away. - pub fn trees(&self) -> u64 { - self.scheme.trees(self.k) + /// One WOTS key pair, plus the compression of its `l` chain ends into a leaf. + fn wots_leaf(&self, l: u64) -> Cost { + let b = self.blocks(); + Cost::new( + l + l * (self.w - 1) + 1, + l * b.prf() + l * (self.w - 1) * b.chain_step() + b.compress(l), + ) } } -/// What one layer costs. Every field is additive across layers except the -/// cached tree and the cache itself, which only the top layer has. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct LayerCost { - pub l: u64, - /// The target sum in force, the mean resolved. - pub swn: u64, - /// Counter values tried per signature at this layer. - pub trials: u64, - /// The authentication path and the WOTS signature this layer contributes. - pub sig_bytes: u64, - /// Walking this layer's chains and its authentication path. - pub verify: Cost, - /// The extra a WOTS-TW verifier walks when every message digit is zero. - pub verify_worst_extra: Cost, - /// Growing this layer's tree from the seed. - pub tree: Cost, - /// The same with its half top already in state, which is worth doing only - /// for the top layer. - pub tree_cached: Cost, +/// The hypertree side: what the layer heights cost, at any `(a, k)` and any +/// target sum. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Layers { + pub profile: Profile, + /// Generating the top tree, which is all key generation does. + pub keygen: Cost, + /// Regrowing every layer, which is what signing does. + pub trees: Cost, + /// The same with the top tree's half top already in state. + pub trees_cached: Cost, pub cache_bytes: u64, pub cache_depth: u64, } -/// One layer's costs. `nu` has to be the table for this layer's `(l, w)`. -pub fn layer_cost(layer: Layer, p: &Params, nu: Option<&NuTable>) -> Option { - let (n, b, w) = (p.n, p.blocks(), layer.w()); - let l = layer.chains(n, p.scheme)?; - let enc = layer.encoding(n)?; - let leaves = 1u64 << layer.height(); - - // One WOTS key pair, plus the compression of its l chain ends into a leaf. - let leaf = Cost::new( - l + l * (w - 1) + 1, - l * b.prf() + l * (w - 1) * b.chain_step() + b.compress(l), - ); - let tree = |height: u64| { - let count = 1u64 << height; - leaf * count + Cost::new(count - 1, (count - 1) * b.merkle_node()) - }; - - // Only the top tree is worth caching: it is the same for every signature, - // while the trees below it are picked by the (pseudorandom) index. Its auth - // path splits at the cached level: below, rebuild the 2^c-leaf subtree the - // signing leaf sits in; above, the nodes are already in state. Rebuilt - // leaves are charged a full WOTS public key, as everywhere else here. - // - // A BDS-style traversal would amortize a tree to h' leaves per signature - // with O(h') state, but it only works walking the leaves in order. - // SPHINCS+ picks its index by hashing the message, so consecutive - // signatures land on unrelated leaves and nothing amortizes; an - // index-independent cache like this one is what is left, hence sqrt rather - // than h'. - let c = p.cache_height.unwrap_or(layer.height() / 2); - if c > layer.height() { - return None; - } - let stored_level = 1u64 << (layer.height() - c); - let mut cached = tree(c); - let cache_bytes; - if p.cache_level_only { - cached = cached + Cost::new(stored_level - 1, (stored_level - 1) * b.merkle_node()); - cache_bytes = stored_level * n; - } else { - cache_bytes = (2 * stored_level - 1) * n; +impl Layers { + /// The canonical profile of `p`: see [`Profile::canonical`]. + pub fn new(p: &Params) -> Option { + Self::from_profile(p, p.profile()?) } - let counter = if p.scheme.wots_c() { COUNTER_BYTES } else { 0 }; - let auth = Cost::new(layer.height(), layer.height() * b.merkle_node()); - let (swn, trials, verify, verify_worst_extra) = if p.scheme.wots_c() { - let swn = layer.swn().unwrap_or(enc.default_swn()); - let table = nu?; - // the digits sum to S_wn, so the remaining chain steps are fixed at - // (w-1)*l - S_wn, and the counter is hashed in once per layer - let steps = (w - 1) * l.checked_sub(0)?; - let steps = steps.checked_sub(swn.min(steps))?; - let walk = Cost::new( - steps + 2, - steps * b.chain_step() + b.chain_step_with_counter() + b.compress(l), - ); - (swn, table.trials(swn), walk + auth, Cost::default()) - } else { - let avg = (w - 1) * l / 2; - let worst = layer.wots_tw_worst_steps(n); - let walk = Cost::new(avg + 1, avg * b.chain_step() + b.compress(l)); - let extra = Cost::new(worst - avg, (worst - avg) * b.chain_step()); - (0, 0, walk + auth, extra) - }; - - Some(LayerCost { - l, - swn, - trials, - sig_bytes: layer.height() * n + l * n + counter, - verify, - verify_worst_extra, - tree: tree(layer.height()), - tree_cached: cached, - cache_bytes, - cache_depth: layer.height() - c, - // `leaves` is only here to keep the shift above honest - }) - .filter(|_| leaves > 0) + /// Any profile, as long as its heights add up to `p.h` over `p.d` layers. + pub fn from_profile(p: &Params, profile: Profile) -> Option { + if profile.total() != p.h || profile.layers() != p.d { + return None; + } + let l = p.chains()?; + let leaf = p.wots_leaf(l); + let b = p.blocks(); + let tree = |height: u64| { + let leaves = 1u64 << height; + leaf * leaves + Cost::new(leaves - 1, (leaves - 1) * b.merkle_node()) + }; + let top = tree(profile.h_top()); + let lower = profile + .heights() + .skip(1) + .map(tree) + .fold(Cost::default(), |acc, x| acc + x); + + // Only the top tree is worth caching: it is the same for every + // signature, while the trees below it are picked by the (pseudorandom) + // index. Its auth path splits at the cached level: below, rebuild the + // 2^c-leaf subtree the signing leaf sits in; above, the nodes are + // already in state. Rebuilt leaves are charged a full WOTS public key, + // as everywhere else here. + // + // A BDS-style traversal would amortize a tree to h' leaves per + // signature with O(h') state, but it only works walking the leaves in + // order. SPHINCS+ picks its index by hashing the message, so + // consecutive signatures land on unrelated leaves and nothing + // amortizes; an index-independent cache like this one is what is left, + // hence sqrt rather than h'. + let c = p.cache_height.unwrap_or(profile.h_top() / 2); + if c > profile.h_top() { + return None; + } + let stored_level = 1u64 << (profile.h_top() - c); + let mut cached = tree(c); + let cache_bytes; + if p.cache_level_only { + cached = cached + Cost::new(stored_level - 1, (stored_level - 1) * b.merkle_node()); + cache_bytes = stored_level * p.n; + } else { + cache_bytes = (2 * stored_level - 1) * p.n; + } + + Some(Self { + profile, + keygen: top, + trees: top + lower, + trees_cached: cached + lower, + cache_bytes, + cache_depth: profile.h_top() - c, + }) + } } -/// The FORS side, which no layer sees. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Fors { +/// Everything else: the FORS side, the signature size and the verifier, none of +/// which depends on how the hypertree's height is split between its layers. +/// +/// Split from [`Layers`] and from the target sum so that a search can reject a +/// candidate on size, or on the least any layer profile and any target sum could +/// cost, without pretending to know which of those are good. +#[derive(Clone, Debug)] +pub struct Skeleton { + pub params: Params, + pub l: u64, + pub chain_bits: u64, + pub pinned_bits: u64, + pub max_swn: u64, + pub default_swn: u64, pub sig_bytes: u64, - /// Growing the trees, and any grinding FORS+C does. - pub sign: Cost, - pub grinding: Cost, - /// Opening the leaves, plus the message hash. - pub verify: Cost, + /// What FORS+C's digest grinding costs, zero for the other schemes. + pub fors_c_grinding: Cost, + /// The `(a, k)` part of signing: growing the FORS trees, and any grinding + /// FORS+C does. Common to both signing costs, cached or not. + pub fors_part: Cost, + /// One counter trial, at one layer. + pub grind_step: Cost, + /// Verification, less the chain walk that the target sum shortens. + verify_base: Cost, + /// One chain step, across all layers. + verify_step: Cost, + /// WOTS-TW only; for WOTS+C verification is deterministic. + verify_worst_extra: Cost, } -impl Fors { - pub fn new(p: &Params) -> Option { - if p.k < 1 || p.a < 1 || p.a > 63 || (p.scheme.fors_c() && p.k < 2) { +impl Skeleton { + /// `None` if the parameters are not self-consistent: `w` must be a power of + /// two, FORS+C needs `k >= 2`, WOTS-TW cannot drop chains, and `2^a` has to + /// be countable. + pub fn new(p: Params) -> Option { + if p.k < 1 || p.a < 1 || p.a > 63 || p.d == 0 { return None; } - let (n, b, trees, t) = (p.n, p.blocks(), p.trees(), 1u64 << p.a); + if p.scheme.fors_c() && p.k < 2 { + return None; + } + if !p.scheme.wots_c() && p.dropped_chains > 0 { + return None; + } + let enc = p.encoding()?; + let l = p.chains()?; + let profile = p.profile()?; + let (n, b, d) = (p.n, p.blocks(), p.d); + let trees = p.scheme.trees(p.k); + let t = 1u64 << p.a; + + // The signature carries the whole authentication path, h nodes however + // the layers divide it, plus one WOTS signature per layer. + let layer = l * n + if p.scheme.wots_c() { COUNTER_BYTES } else { 0 }; + let sig_bytes = n + profile.total() * n + d * layer + trees * n + trees * p.a * n; + let msg_hash = Cost::new(2, b.message_hash() + b.message_prf()); - let build = Cost::new( + let fors_build = Cost::new( trees * t + trees * t + trees * (t - 1) + 1, trees * t * b.prf() + trees * t * b.chain_step() + trees * (t - 1) * b.merkle_node() + b.compress(trees), ); // FORS+C grinds the digest until its last a bits vanish, so the last // FORS tree always opens leaf 0 and needs no authentication path. - let grinding = if p.scheme.fors_c() { msg_hash * t } else { msg_hash }; - let verify = Cost::new( + let fors_grind = if p.scheme.fors_c() { msg_hash * t } else { msg_hash }; + + let fors_verify = Cost::new( trees + trees * p.a + 1, trees * b.chain_step() + trees * p.a * b.merkle_node() + b.compress(trees), ); + let auth = Cost::new(profile.total(), profile.total() * b.merkle_node()); + let mut verify_base = Cost::new(1, b.message_hash()) + fors_verify + auth; + let mut verify_step = Cost::default(); + let mut verify_worst_extra = Cost::default(); + if p.scheme.wots_c() { + // the digits sum to S_wn, so the remaining chain steps are fixed at + // (w-1)*l - S_wn, and the counter is hashed once per layer + verify_base = verify_base + Cost::new(2, b.chain_step_with_counter() + b.compress(l)) * d; + verify_step = Cost::new(1, b.chain_step()) * d; + } else { + let avg = (p.w - 1) * l / 2; + verify_base = verify_base + Cost::new(avg + 1, avg * b.chain_step() + b.compress(l)) * d; + let worst = p.wots_tw_worst_steps(); + verify_worst_extra = Cost::new(worst - avg, (worst - avg) * b.chain_step()) * d; + } + Some(Self { - sig_bytes: n + trees * n + trees * p.a * n, - sign: build + grinding, - grinding: if p.scheme.fors_c() { grinding } else { Cost::default() }, - verify: verify + Cost::new(1, b.message_hash()), + params: p, + l, + chain_bits: enc.chain_bits, + pinned_bits: if p.scheme.wots_c() { enc.pinned_bits } else { 0 }, + max_swn: (p.w - 1) * l, + default_swn: if p.scheme.wots_c() { enc.default_swn() } else { 0 }, + sig_bytes, + fors_c_grinding: if p.scheme.fors_c() { fors_grind } else { Cost::default() }, + fors_part: fors_build + fors_grind, + grind_step: Cost::new(1, b.chain_step_with_counter()), + verify_base, + verify_step, + verify_worst_extra, }) } + + /// Expected signing cost, with the top tree's half top in state, when each + /// layer grinds `trials` counters. + pub fn sign(&self, lay: &Layers, trials: u64) -> Cost { + lay.trees_cached + self.fors_part + self.grinding(trials) + } + + /// The same for a signer holding no state at all, which has to rebuild the + /// top tree along with the rest. + pub fn sign_cold(&self, lay: &Layers, trials: u64) -> Cost { + lay.trees + self.fors_part + self.grinding(trials) + } + + /// What `trials` counter values per layer cost across the hypertree. + pub fn grinding(&self, trials: u64) -> Cost { + self.grind_step * trials.saturating_mul(self.params.d) + } + + /// Verification at this target sum. `swn` is ignored for WOTS-TW. + pub fn verify(&self, swn: u64) -> Cost { + self.verify_base + self.verify_step * (self.max_swn - swn.min(self.max_swn)) + } + + /// Verification when every message digit is zero (WOTS-TW only). + pub fn verify_worst(&self, swn: u64) -> Cost { + self.verify(swn) + self.verify_worst_extra + } + + /// The full picture at one layer profile and one target sum. + pub fn finish(&self, lay: &Layers, swn: u64, trials: u64) -> Costs { + Costs { + l: self.l, + chain_bits: self.chain_bits, + pinned_bits: self.pinned_bits, + dropped_chains: self.params.dropped_chains, + swn: self.params.scheme.wots_c().then_some(swn), + profile: lay.profile, + sig_bytes: self.sig_bytes, + keygen: lay.keygen, + sign: self.sign(lay, trials), + sign_cold: self.sign_cold(lay, trials), + verify: self.verify(swn), + verify_worst: self.verify_worst(swn), + wots_c_grinding: self.grinding(trials), + fors_c_grinding: self.fors_c_grinding, + cache_depth: lay.cache_depth, + cache_bytes: lay.cache_bytes, + } + } } -/// Every cost of one parameter set. +/// Every cost of a parameter set at one layer profile and one target sum. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Costs { - pub hypertree: Hypertree, + pub l: u64, + pub chain_bits: u64, + pub pinned_bits: u64, + pub dropped_chains: u64, + pub swn: Option, + pub profile: Profile, pub sig_bytes: u64, pub keygen: Cost, /// Signing with the top tree's half top in state, the cost a signer that @@ -442,85 +457,18 @@ impl Costs { } } -/// A hypertree's costs, added up. Independent of the FORS side, so a search -/// that varies `(a, k)` builds this once and adds. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct HyperCost { - pub hypertree: Hypertree, - pub sig_bytes: u64, - pub verify: Cost, - pub verify_worst_extra: Cost, - /// Growing every layer's tree from the seed. - pub trees: Cost, - /// The same with the top tree's half top already in state. - pub trees_cached: Cost, - /// Growing the top tree, which is all key generation does. - pub keygen: Cost, - /// Counter values tried per signature, across every layer. - pub trials: u64, - pub cache_bytes: u64, - pub cache_depth: u64, -} - -/// Add up one hypertree, layer by layer. -pub fn hyper_cost(p: &Params, ht: &Hypertree, nu: &mut NuCache) -> Option { - let mut out = HyperCost { - hypertree: *ht, - sig_bytes: 0, - verify: Cost::default(), - verify_worst_extra: Cost::default(), - trees: Cost::default(), - trees_cached: Cost::default(), - keygen: Cost::default(), - trials: 0, - cache_bytes: 0, - cache_depth: 0, - }; - for (i, layer) in ht.layers().enumerate() { - let l = layer.chains(p.n, p.scheme)?; - let table = p.scheme.wots_c().then(|| nu.table(l, layer.w())); - let c = layer_cost(layer, p, table)?; - out.sig_bytes += c.sig_bytes; - out.verify = out.verify + c.verify; - out.verify_worst_extra = out.verify_worst_extra + c.verify_worst_extra; - out.trees = out.trees + c.tree; - out.trials += c.trials; - if i == 0 { - out.keygen = c.tree; - out.trees_cached = out.trees_cached + c.tree_cached; - out.cache_bytes = c.cache_bytes; - out.cache_depth = c.cache_depth; - } else { - out.trees_cached = out.trees_cached + c.tree; - } - } - Some(out) -} - -/// Add a hypertree and a FORS side together. Pure arithmetic: no tables. -pub fn assemble(p: &Params, hyper: &HyperCost, fors: &Fors) -> Costs { - let grind = Cost::new(1, p.blocks().chain_step_with_counter()) * hyper.trials; - Costs { - hypertree: hyper.hypertree, - sig_bytes: fors.sig_bytes + hyper.sig_bytes, - keygen: hyper.keygen, - sign: hyper.trees_cached + fors.sign + grind, - sign_cold: hyper.trees + fors.sign + grind, - verify: fors.verify + hyper.verify, - verify_worst: fors.verify + hyper.verify + hyper.verify_worst_extra, - wots_c_grinding: grind, - fors_c_grinding: fors.grinding, - cache_depth: hyper.cache_depth, - cache_bytes: hyper.cache_bytes, - } -} - -/// Costs of one parameter set, building whatever digit-sum tables it needs. +/// Costs of one parameter set, building the digit-sum table as needed. /// -/// Convenient for a single evaluation; a search should hold a [`NuCache`] and -/// call [`assemble`] itself. -pub fn costs(p: Params, ht: &Hypertree) -> Option { - let fors = Fors::new(&p)?; - let mut nu = NuCache::new(p.n); - Some(assemble(&p, &hyper_cost(&p, ht, &mut nu)?, &fors)) +/// Convenient for a single evaluation; a search should hold the [`NuTable`] and +/// drive [`Skeleton`] and [`Layers`] itself, since the table depends only on +/// `(l, w)` and the layer costs only on the profile. +pub fn costs(p: Params, swn: Option) -> Option { + let sk = Skeleton::new(p)?; + let lay = Layers::new(&p)?; + if !p.scheme.wots_c() { + return Some(sk.finish(&lay, 0, 0)); + } + let table = NuTable::new(sk.l, p.w, (8 * p.n) as u32); + let swn = swn.unwrap_or(sk.default_swn); + Some(sk.finish(&lay, swn, table.trials(swn))) } diff --git a/doc/sphincs/params_selection/src/report.rs b/doc/sphincs/params_selection/src/report.rs index 162daeaea..f2b927fa1 100644 --- a/doc/sphincs/params_selection/src/report.rs +++ b/doc/sphincs/params_selection/src/report.rs @@ -1,7 +1,6 @@ //! Human-readable output. -use crate::cost::Cost; -use crate::params::{Costs, Layer, Params}; +use crate::params::{Costs, Params}; use crate::search::{Budgets, Candidate}; use crate::security::forgery_exponent; @@ -36,37 +35,36 @@ pub fn si(x: u64) -> String { x.to_string() } -/// One layer's WOTS instance: how its digest is cut into chain positions. -fn wots_line(p: &Params, layer: Layer) -> String { - let l = layer.chains(p.n, p.scheme).unwrap_or(0); - let enc = layer.encoding(p.n); - if !p.scheme.wots_c() { - let l1 = (8 * p.n).div_ceil(layer.chain_bits()); - return format!("WOTS-TW, {l} chains: {l1} for the digest + {} checksum", l - l1); - } - let pinned = enc.map_or(0, |e| e.pinned_bits); - let swn = layer.swn().unwrap_or_else(|| enc.map_or(0, |e| e.default_swn())); - let dropped = if layer.dropped_chains() > 0 { - format!(", {} chain(s) dropped", layer.dropped_chains()) +/// One line spelling out the WOTS+C digest-to-chains cut. +pub fn encoding_line(p: &Params, c: &Costs) -> String { + let Some(swn) = c.swn else { + let l1 = (8 * p.n).div_ceil(c.chain_bits); + return format!( + "encoding WOTS-TW: {} chains, {l1} for the digest + {} checksum", + c.l, + c.l - l1 + ); + }; + let dropped = if c.dropped_chains > 0 { + format!(", {} chain(s) dropped", c.dropped_chains) } else { String::new() }; format!( - "w = {}, {l} chains of {} bits, {pinned} of {} digest bits pinned{dropped}, S_wn = {swn} of {}", - layer.w(), - layer.chain_bits(), + "encoding {} bits/chain, {} of {} digest bits pinned to zero{dropped}, S_wn = {swn} of {}", + c.chain_bits, + c.pinned_bits, 8 * p.n, - l * (layer.w() - 1) + c.l * (p.w - 1) ) } /// The full picture of one parameter set. pub fn report(p: &Params, c: &Costs, q_s: f64) -> String { - let forgery = forgery_exponent(q_s, c.hypertree.height() as u32, p.k, p.a); + let forgery = forgery_exponent(q_s, p.h as u32, p.k, p.a); let cap = 8.0 * p.n as f64; let security = forgery.map_or(0.0, |f| f.min(cap)); - let ht = c.hypertree; - let row = |label: &str, x: Cost, note: String| format!("{label:<24}{:>12}{note}", si(x.compressions)); + let row = |label: &str, x: crate::cost::Cost, note: String| format!("{label:<24}{:>12}{note}", si(x.compressions)); let mut lines = vec![ format!( @@ -75,12 +73,7 @@ pub fn report(p: &Params, c: &Costs, q_s: f64) -> String { signatures(q_s), 8 * p.n ), - format!( - "(h, d) ({}, {}) layer heights {}", - ht.height(), - ht.depth(), - ht.heights() - ), + format!("(h, d) ({}, {}) layer heights {}", p.h, p.d, c.profile), format!( "(a, k) ({}, {}){}", p.a, @@ -91,54 +84,31 @@ pub fn report(p: &Params, c: &Costs, q_s: f64) -> String { String::new() } ), - ]; - // one line per distinct WOTS instance, which is one line unless the layers - // disagree - if ht.one_wots() { - lines.push(format!("every layer {}", wots_line(p, ht.top()))); - } else { - // one line per run of layers sharing their WOTS parameters - let mut runs: Vec<(Layer, u64, u64)> = Vec::new(); - for (i, layer) in ht.layers().enumerate() { - let same = - |a: Layer, b: Layer| (a.w(), a.dropped_chains(), a.swn()) == (b.w(), b.dropped_chains(), b.swn()); - match runs.last_mut() { - Some((prev, _, last)) if same(*prev, layer) => *last = i as u64, - _ => runs.push((layer, i as u64, i as u64)), - } - } - for (layer, first, last) in runs { - let which = match (first, last) { - (0, 0) => "top layer".to_string(), - (f, l) if f == l => format!("layer {f}"), - (f, l) if l + 1 == ht.depth() => format!("layers {f}..{l}"), - (f, l) => format!("layers {f}..{l}"), - }; - lines.push(format!("{which:<16}{}", wots_line(p, layer))); - } - } - lines.push(String::new()); - lines.push(match forgery { - Some(f) => format!( - "security {security:.1} bits classical (FORS forgery {f:.1}, preimage {})", - cap as u64 - ), - None => format!( - "security none: q_s = {} reuses every FORS instance ~{:.0} times", - signatures(q_s), - q_s / 2f64.powi(ht.height() as i32) + format!("(w, l) ({}, {})", p.w, c.l), + encoding_line(p, c), + String::new(), + match forgery { + Some(f) => format!( + "security {security:.1} bits classical (FORS forgery {f:.1}, preimage {})", + cap as u64 + ), + None => format!( + "security none: q_s = {} reuses every FORS instance ~{:.0} times", + signatures(q_s), + q_s / 2f64.powi(p.h as i32) + ), + }, + format!("signature {} bytes", c.sig_bytes), + String::new(), + format!("{:<24}{:>12}", "", "compressions"), + row("keygen", c.keygen, String::new()), + row( + "sign", + c.sign, + format!(" ({} B of state at depth {})", c.cache_bytes, c.cache_depth), ), - }); - lines.push(format!("signature {} bytes", c.sig_bytes)); - lines.push(String::new()); - lines.push(format!("{:<24}{:>12}", "", "compressions")); - lines.push(row("keygen", c.keygen, String::new())); - lines.push(row( - "sign", - c.sign, - format!(" ({} B of state at depth {})", c.cache_bytes, c.cache_depth), - )); - lines.push(row("verify", c.verify, String::new())); + row("verify", c.verify, String::new()), + ]; if c.verify_worst != c.verify { lines.push(row("verify (worst)", c.verify_worst, String::new())); } @@ -160,48 +130,30 @@ const COLUMNS: [(&str, usize); 15] = [ ("heights", 18), ("a", 3), ("k", 3), - ("w", 9), + ("w", 5), ("drop", 5), - ("l", 7), - ("S_wn", 11), + ("l", 4), + ("S_wn", 6), ("size", 6), ("keygen", 8), ("sign", 8), ("cache B", 7), ]; -/// `top/low` when the layers disagree, one value when they do not. -fn per_group(top: String, low: String) -> String { - if top == low { top } else { format!("{top}/{low}") } -} - fn cells(c: &Candidate) -> Vec { let (p, x) = (&c.params, &c.costs); - let ht = x.hypertree; - let (top, low) = (ht.top(), ht.layers().last().unwrap_or(ht.top())); - let chains = |layer: Layer| layer.chains(p.n, p.scheme).unwrap_or(0); - let sum = |layer: Layer| { - layer - .swn() - .or_else(|| layer.encoding(p.n).map(|e| e.default_swn())) - .map_or("-".to_string(), |s| s.to_string()) - }; vec![ si(x.verify.compressions), p.scheme.label().to_string(), - ht.height().to_string(), - ht.depth().to_string(), - ht.heights(), + p.h.to_string(), + p.d.to_string(), + x.profile.to_string(), p.a.to_string(), p.k.to_string(), - per_group(top.w().to_string(), low.w().to_string()), - per_group(top.dropped_chains().to_string(), low.dropped_chains().to_string()), - per_group(chains(top).to_string(), chains(low).to_string()), - if p.scheme.wots_c() { - per_group(sum(top), sum(low)) - } else { - "-".to_string() - }, + p.w.to_string(), + p.dropped_chains.to_string(), + x.l.to_string(), + x.swn.map_or("-".to_string(), |s| s.to_string()), x.sig_bytes.to_string(), si(x.keygen.compressions), si(x.sign.compressions), @@ -213,9 +165,9 @@ fn cells(c: &Candidate) -> Vec { /// own and not the report's. pub fn legend() -> String { "every cost in compression calls, one per 64 bytes of hash input; sign = signing with the top tree's half top \ - in state, cache B of it\nheights = every layer's height, top first, and the only one worth caching is that top \ - one; w, drop, l and S_wn are written top/lower where the layers differ, and are the Winternitz parameter, the \ - chains dropped beyond the pinned digest bits, the chains signed, and the target digit sum" + in state, cache B of it\nheights = every layer's height, top first, and the only one worth caching is that top one, w = Winternitz parameter, the positions one chain \ + has (--chain-bits takes its log2), drop = chains dropped beyond the pinned digest bits, l = chains signed, \ + S_wn = target digit sum" .to_string() } diff --git a/doc/sphincs/params_selection/src/search.rs b/doc/sphincs/params_selection/src/search.rs index 8f371a23a..d767a1989 100644 --- a/doc/sphincs/params_selection/src/search.rs +++ b/doc/sphincs/params_selection/src/search.rs @@ -1,31 +1,38 @@ //! Exhaustive search for the parameter set with the cheapest verification. //! -//! Every `(scheme, a, k, h, d, h_top, and a WOTS instance for the top layer and -//! one for the rest)` point that meets the budgets is costed and compared. -//! Nothing is chosen by an optimality argument, and nothing is skipped by a -//! monotonicity one: the tests that run before the target-sum scan reject only -//! points that no target sum could rescue, because size and keygen do not -//! depend on the target sums at all, and the least grinding any of them can ask -//! for is read off the digit-sum table rather than assumed to sit anywhere in -//! particular. +//! Every `(scheme, h, d, h_top, chain_bits, dropped_chains, a, k, S_wn)` point +//! that meets the budgets is costed and compared. Nothing is chosen by an +//! optimality argument, and nothing is skipped by a monotonicity one: the three +//! tests that run before the `S_wn` scan reject only points that no `S_wn` could +//! rescue, because size and keygen do not depend on `S_wn` at all, and the least +//! grinding any `S_wn` can ask for is read off the digit-sum table rather than +//! assumed to sit anywhere in particular. //! -//! Two WOTS instances rather than `d` of them is not a restriction: -//! [`Hypertree::two_group`] gives the argument, and a test checks it against -//! every per-layer assignment of small hypertrees. Any axis can also be pinned -//! to a single value instead of searched, which is how one parameter set gets -//! costed: pin them all. Budgets are optional, and an unset one is no limit. +//! Any axis can be pinned to a single value instead of searched, which is how +//! one parameter set gets costed: pin them all. Budgets are optional, and an +//! unset one is no limit. +//! +//! The layer heights are `(h, d, h_top)`: the top tree gets `h_top`, the rest +//! divide what is left as evenly as it goes. [`crate::params::Profile`] argues +//! why that shape covers the cost-optimal representative of every profile, so +//! `d` no longer has to divide `h`. Which `h_top` is best does not depend on +//! `(a, k)` or on the target sum, because size and verification do not depend on +//! `h_top` at all and both signing budgets take the `(a, k)` part as the same +//! additive offset; so the profiles are ranked once per `(h, d)`, by how much +//! grinding they leave room for, and that ranking then holds for every `(a, k)`. //! //! What is assumed is the searched range of each parameter, hardcoded below. //! When a result comes out at the top of one of those ranges the range itself //! may be what is limiting it, so [`edges`] reports that and names the constant -//! to raise. Ranges the structure already closes need no such warning and get -//! none, and neither does an axis pinned by hand. +//! to raise. Ranges the structure already closes (`d` over layer heights that do +//! not add up to `h`, `S_wn` over the digit sums a code of `l` chains can reach) +//! need no such warning and get none, and neither does an axis pinned by hand. use std::ops::RangeInclusive; use std::time::Instant; -use crate::cost::{Cost, NuCache, SCHEMES, Scheme}; -use crate::params::{Costs, Fors, Hypertree, Layer, Params, assemble}; +use crate::cost::{Cost, NuTable, SCHEMES, Scheme}; +use crate::params::{Costs, Layers, Params, Profile, Skeleton}; use crate::security::SecurityTable; /// Hardcoded search ranges, wide enough that the budgets are normally what @@ -77,7 +84,7 @@ impl Span { /// Which target sums to consider. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Sums { - /// Only this one, on every layer. + /// Only this one. Pinned(u64), /// Only the mean, where grinding is cheapest. What to use when nothing /// bounds the signer, since then there is no reason to grind harder, and @@ -134,11 +141,10 @@ pub struct Grid { pub dropped: Span, pub chain_bits: Vec, pub sums: Sums, - /// Search a separate WOTS instance for the top layer, rather than one for - /// the whole hypertree. - pub split_wots: bool, - /// A hypertree given outright, which pins every axis it covers. - pub hypertree: Option, + /// A profile given outright, instead of `h_top` over the canonical shape. + /// Its heights have to add up to `h` over `d` layers, both of which are + /// then pinned by it. + pub profile: Option, pub cache_height: Option, pub cache_level_only: bool, } @@ -148,13 +154,11 @@ impl Grid { /// rather than searching for one? Distinct from a search that happens to /// leave one survivor, which still deserves its count and its table. pub fn fully_pinned(&self) -> bool { - if self.hypertree.is_some() { - return self.schemes.len() == 1 && self.a.pinned() && self.k.pinned(); - } - let h_top_pinned = match self.h_top { - None => true, // the classic split is one profile - Some(span) => span.pinned(), - }; + let h_top_pinned = self.profile.is_some() + || match self.h_top { + None => true, // the classic split is one profile + Some(span) => span.pinned(), + }; self.schemes.len() == 1 && self.chain_bits.len() == 1 && self.h.pinned() @@ -163,7 +167,6 @@ impl Grid { && self.k.pinned() && self.dropped.pinned() && h_top_pinned - && !self.split_wots && !matches!(self.sums, Sums::Sweep) } } @@ -181,8 +184,7 @@ impl Default for Grid { dropped: Span::new(0, DROPPED_MAX), chain_bits: (1..=CHAIN_BITS_MAX).collect(), sums: Sums::Sweep, - split_wots: false, - hypertree: None, + profile: None, cache_height: None, cache_level_only: false, } @@ -195,36 +197,25 @@ pub struct Candidate { pub costs: Costs, } -/// Identifies one parameter tuple: everything but the target sums, which do not -/// change what it verifies at. -pub type Key = (Scheme, u64, u64, u64, u64, u64, u64, u64, u64); +/// Identifies one parameter tuple: everything but the layer profile and the +/// target sum, neither of which changes what it verifies at. +pub type Key = (Scheme, u64, u64, u64, u64, u64, u64); impl Candidate { pub fn key(&self) -> Key { - let (p, ht) = (self.params, self.costs.hypertree); - let low = ht.layers().last().unwrap_or(ht.top()); - ( - p.scheme, - ht.height(), - ht.depth(), - p.a, - p.k, - ht.top().w(), - ht.top().dropped_chains(), - low.w(), - low.dropped_chains(), - ) + let p = self.params; + (p.scheme, p.h, p.d, p.a, p.k, p.w, p.dropped_chains) } } #[derive(Clone, Copy, Debug, Default)] pub struct Stats { - /// `(scheme, h, d, top WOTS, lower WOTS)` tuples reached. + /// `(scheme, h, d, chain_bits, dropped)` tuples reached. pub grid: u64, pub keygen_pruned: u64, /// `(a, k)` pairs rejected by the security floor. pub insecure: u64, - /// `(a, k)` pairs whose signature is too big, whatever the target sums. + /// `(a, k)` pairs whose signature is too big, whatever the target sum. pub size_pruned: u64, /// `(a, k)` pairs too slow to sign at the least grinding any target sum asks. pub sign_pruned: u64, @@ -232,7 +223,9 @@ pub struct Stats { pub swept: u64, /// Points meeting every budget. pub feasible: u64, - pub costed: u64, + pub skeletons: u64, + /// Layer profiles kept after the keygen budget. + pub profiles: u64, /// Parameter tuples that came out feasible, one row each. pub rows: u64, /// Rows dropped as worse than everything kept. @@ -240,29 +233,15 @@ pub struct Stats { pub seconds: f64, } -impl Stats { - fn add(&mut self, o: &Self) { - self.grid += o.grid; - self.keygen_pruned += o.keygen_pruned; - self.insecure += o.insecure; - self.size_pruned += o.size_pruned; - self.sign_pruned += o.sign_pruned; - self.swept += o.swept; - self.feasible += o.feasible; - self.costed += o.costed; - self.rows += o.rows; - self.rows_dropped += o.rows_dropped; - } -} - impl std::fmt::Display for Stats { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, - "grid {} (scheme, h, d, top WOTS, lower WOTS) tuples, {} over keygen; \ + "grid {} (scheme, h, d, chain_bits, dropped) tuples, {} over keygen; \ then {} (a, k) pairs insecure, {} over size, {} over signing; \ {} target-sum ranges swept, {} points feasible over {} parameter tuples ({} dropped as worse than \ - everything kept); {} parameter sets costed in {:.1}s", + everything kept); \ + {} layer profiles and {} parameter sets costed in {:.1}s", self.grid, self.keygen_pruned, self.insecure, @@ -272,315 +251,218 @@ impl std::fmt::Display for Stats { self.feasible, self.rows, self.rows_dropped, - self.costed, + self.profiles, + self.skeletons, self.seconds ) } } -fn params(g: &Grid, scheme: Scheme, a: u64, k: u64) -> Params { +fn params(g: &Grid, scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, dropped: u64) -> Params { Params { scheme, + h, + d, + h_top: None, a, k, + w, n: g.n, + dropped_chains: dropped, cache_height: g.cache_height, cache_level_only: g.cache_level_only, } } -/// How much verification a grinding budget buys, for one hypertree. +/// The layer profiles worth trying for one `(h, d)`, and how much grinding the +/// best of them leaves room for. /// -/// Every unit of target sum removes exactly one chain step from verification, -/// whatever layer it is on, and costs that layer's grinding. So the best -/// allocation maximises `swn_top + (d-1) * swn_lower` against the trials the -/// signing budget leaves. Both layer groups have a convex increasing cost in -/// their own sum, since the digit-sum count is log-concave, so the frontier of -/// the pair is the greedy merge of their marginal costs, walked here once per -/// hypertree rather than once per `(a, k)`. -struct Grinding { - /// `(trials, gain, top sum, lower sum)`, by increasing trials. - frontier: Vec<(u64, u64, u64, u64)>, +/// `slack` is `max over profiles of (max_sign - the profile's trees)`. Signing +/// takes the `(a, k)` part as an additive offset, so subtracting that offset +/// from `slack` gives the grinding budget of the best profile for any `(a, k)`, +/// without re-ranking the profiles per candidate. +struct Room { + profiles: Vec, + slack: u64, } -/// Frontier points kept. The grinding rises fast enough that a budget runs out -/// long before this, so it is a bound on the allocation rather than a cap on -/// the answer. -const FRONTIER_MAX: usize = 4096; - -impl Grinding { - fn build(p: &Params, ht: &Hypertree, nu: &mut NuCache, cap: u64) -> Option { - let (top, low) = (ht.top(), ht.layers().last()?); - let m = ht.depth() - 1; - let (top_l, low_l) = (top.chains(p.n, p.scheme)?, low.chains(p.n, p.scheme)?); - let (top_tab, low_tab) = nu.pair((top_l, top.w()), (low_l, low.w())); - let (top_max, low_max) = (top_tab.max_swn(), low_tab.max_swn()); - let (mut ts, mut ls) = (top_max / 2, low_max / 2); - let cost = |ts: u64, ls: u64| top_tab.trials(ts).saturating_add(low_tab.trials(ls).saturating_mul(m)); - let mut frontier = vec![(cost(ts, ls), ts + m * ls, ts, ls)]; - while frontier.last()?.0 <= cap && frontier.len() < FRONTIER_MAX && (ts < top_max || (m > 0 && ls < low_max)) { - // whichever next step buys its gain most cheaply - let up_top = (ts < top_max).then(|| cost(ts + 1, ls)); - let up_low = (m > 0 && ls < low_max).then(|| cost(ts, ls + 1)); - let take_top = match (up_top, up_low) { - (Some(t), Some(l)) => (t - frontier.last()?.0) <= (l - frontier.last()?.0) / m.max(1), - (Some(_), None) => true, - (None, Some(_)) => false, - (None, None) => break, - }; - if take_top { - ts += 1; - } else { - ls += 1; - } - frontier.push((cost(ts, ls), ts + m * ls, ts, ls)); +fn room(b: &Budgets, g: &Grid, p: &Params) -> Option { + let mut profiles = Vec::new(); + let mut slack = 0; + let mut consider = |lay: Option| { + let Some(lay) = lay else { return }; + if b.of(lay.keygen) > b.max_keygen() { + return; } - Some(Self { frontier }) - } - - /// The cheapest-verifying allocation that grinds at most `trials`. - /// - /// The frontier rises in both cost and gain, so this is the last entry - /// within budget. - fn best(&self, trials: u64) -> (u64, u64, u64) { - let i = self.frontier.partition_point(|&(cost, ..)| cost <= trials); - match i.checked_sub(1).and_then(|i| self.frontier.get(i)) { - Some(&(_, gain, ts, ls)) => (gain, ts, ls), - None => (0, 0, 0), + slack = slack.max(b.max_sign().saturating_sub(b.of(lay.trees_cached))); + profiles.push(lay); + }; + match (g.profile, g.h_top) { + (Some(profile), _) => consider(Layers::from_profile(p, profile)), + (None, None) => consider(Layers::new(p)), + (None, Some(span)) => { + // The top tree has 2^h_top leaves and every leaf costs at least one + // hash, so a top height past the keygen budget's log is out for any + // (a, k). + let ceiling = 64 - b.max_keygen().max(1).leading_zeros() as u64; + for h_top in span.within((p.h + 1).saturating_sub(p.d).min(ceiling)) { + consider(Layers::new(&Params { + h_top: Some(h_top), + ..*p + })); + } } } -} - -/// One hypertree candidate, costed and with its grinding frontier. -struct Tree { - hyper: crate::params::HyperCost, - grinding: Option, + (!profiles.is_empty()).then_some(Room { profiles, slack }) } /// Every feasible parameter set, ordered by verification cost. /// -/// One row per parameter tuple, carrying the target sums that verified cheapest -/// for it. Rows are what gets printed; the comparison behind each one saw every -/// target sum. +/// One row per `(scheme, h, d, a, k, w, dropped_chains)`, carrying the best +/// target sum for that tuple and the layer profile that admitted it. Rows are +/// what gets printed; the comparison behind each one saw every target sum. pub fn search(b: &Budgets, g: &Grid, st: &mut Stats) -> Vec { - use rayon::prelude::*; let started = Instant::now(); - let sec = SecurityTable::filled(b.q_s, b.security, g.n, g.h.hi as u32, g.k.hi, g.a.hi); - // The (scheme, WOTS instance) tasks are independent, and there are hundreds - // of them once the top layer's instance is searched separately. - let tasks: Vec<(Scheme, (Wots, Wots))> = g - .schemes - .iter() - .flat_map(|&scheme| wots_instances(g, scheme).into_iter().map(move |w| (scheme, w))) - .collect(); - let (rows, stats) = tasks - .par_iter() - .map(|&(scheme, wots)| { - let mut st = Stats::default(); - let rows = one_task(b, g, &sec, scheme, wots, &mut st); - (rows, st) - }) - .reduce( - || (Vec::new(), Stats::default()), - |(mut rows, mut acc), (more, st)| { - rows.extend(more); - acc.add(&st); - if rows.len() >= ROWS_CAP { - sort_rows(&mut rows, b); - rows.truncate(ROWS_KEPT); - acc.rows_dropped += (ROWS_CAP - ROWS_KEPT) as u64; - } - (rows, acc) - }, - ); - let mut rows = rows; - *st = stats; - st.seconds = started.elapsed().as_secs_f64(); - sort_rows(&mut rows, b); - rows -} - -/// One `(scheme, WOTS instances)` task: everything else enumerated under it. -fn one_task( - b: &Budgets, - g: &Grid, - sec: &SecurityTable, - scheme: Scheme, - wots: (Wots, Wots), - st: &mut Stats, -) -> Vec { - let mut nu = NuCache::new(g.n); - let mut rows: Vec = Vec::new(); - // The FORS side depends on (scheme, a, k) alone, and every hypertree asks - // for the same ones. - let mut fors_of: std::collections::HashMap<(u64, u64), Option> = Default::default(); - - for h in g.h.iter() { - for d in g.d.within(h) { - st.grid += 1; - let tops: Vec = match (g.hypertree, g.h_top) { - (Some(ht), _) => vec![ht.top().height()], - (None, None) => vec![h / d.max(1)], - (None, Some(span)) => span.within((h + 1).saturating_sub(d)).collect(), + let digest_bits = (8 * g.n) as u32; + let mut sec = SecurityTable::new(b.q_s, b.security, g.n, g.h.hi as u32, g.k.hi, g.a.hi); + // Every (scheme, h, d, a, k, w, dropped) key is reached exactly once, so + // rows need no deduplication, only a bound: budgets loose enough to admit + // millions of them would otherwise be held in memory to print a dozen. + let mut best: Vec = Vec::new(); + + for &scheme in &g.schemes { + for &bits in &g.chain_bits { + let w = 1u64 << bits; + // WOTS-TW has no counter to grind, so it cannot drop chains, and + // WOTS+C has to keep at least one. + let dropped_range = if scheme.wots_c() { + g.dropped.within((8 * g.n / bits).saturating_sub(1)) + } else { + 0..=0 }; - // Cost the hypertrees once: they need no a or k, and the keygen - // budget alone usually settles the question. - let probe = params(g, scheme, g.a.lo, if scheme.fors_c() { 2 } else { 1 }); - let mut trees = Vec::new(); - for h_top in tops { - let Some(ht) = build(g, wots, h, d, h_top) else { - continue; - }; - let Some(hyper) = crate::params::hyper_cost(&probe, &ht, &mut nu) else { - continue; - }; - st.costed += 1; - if b.of(hyper.keygen) > b.max_keygen() || hyper.sig_bytes > b.max_size() { - continue; - } - let grinding = (scheme.wots_c() && matches!(g.sums, Sums::Sweep)) - .then(|| Grinding::build(&probe, &ht, &mut nu, b.max_sign())) - .flatten(); - trees.push(Tree { hyper, grinding }); - } - if trees.is_empty() { - st.keygen_pruned += 1; - continue; - } - let smallest = trees.iter().map(|t| t.hyper.sig_bytes).min().unwrap_or(u64::MAX); - for a in g.a.iter() { - for k in g.k.iter() { - if !sec.is_secure(h as u32, k, a) { - st.insecure += 1; - continue; - } - let p = params(g, scheme, a, k); - let fors = *fors_of.entry((a, k)).or_insert_with(|| Fors::new(&p)); - let Some(fors) = fors else { continue }; - // the signature grows with k, so once it overruns there is - // no larger k - if fors.sig_bytes + smallest > b.max_size() { - st.size_pruned += 1; - break; - } - let mut best: Option = None; - for tree in &trees { - let Some(c) = fit(b, &p, tree, &fors, &mut nu, st) else { + for dropped in dropped_range { + let probe = params( + g, + scheme, + g.h.hi.max(1), + 1, + g.a.lo, + if scheme.fors_c() { 2 } else { 1 }, + w, + dropped, + ); + let Some(l) = probe.chains() else { continue }; + let table = scheme.wots_c().then(|| NuTable::new(l, w, digest_bits)); + let min_trials = table.as_ref().map_or(0, |t| t.min_trials()); + for h in g.h.iter() { + for d in g.d.within(h) { + st.grid += 1; + // Layer profiles first: they need no a or k, and the + // keygen budget alone usually settles the question. + let Some(room) = room(b, g, ¶ms(g, scheme, h, d, g.a.lo, 1, w, dropped)) else { + st.keygen_pruned += 1; continue; }; - if best.is_none_or(|old| b.of(c.verify) < b.of(old.verify)) { - best = Some(c); - } - } - if let Some(costs) = best { - st.rows += 1; - rows.push(Candidate { params: p, costs }); - if rows.len() >= ROWS_CAP { - sort_rows(&mut rows, b); - rows.truncate(ROWS_KEPT); - st.rows_dropped += (ROWS_CAP - ROWS_KEPT) as u64; + st.profiles += room.profiles.len() as u64; + for a in g.a.iter() { + for k in g.k.iter() { + if !sec.is_secure(h as u32, k, a) { + st.insecure += 1; + continue; + } + let p = params(g, scheme, h, d, a, k, w, dropped); + let Some(sk) = Skeleton::new(p) else { continue }; + st.skeletons += 1; + // The signature grows with k, so once it is too + // big it stays too big. + if sk.sig_bytes > b.max_size() { + st.size_pruned += 1; + break; + } + // What the best profile can still afford to + // grind, once this (a, k) has taken its share. + let per_trial = b.of(sk.grind_step) * d; + let max_trials = room.slack.saturating_sub(b.of(sk.fors_part)) / per_trial.max(1); + let Some(table) = table.as_ref() else { + // WOTS-TW: no counter, no target sum + if room.slack >= b.of(sk.fors_part) { + st.feasible += 1; + record(&mut best, st, b, &sk, &room, 0, 0); + } + continue; + }; + if max_trials < min_trials { + st.sign_pruned += 1; + continue; + } + let sums = match g.sums { + Sums::Sweep => 0..=sk.max_swn, + Sums::Mean => sk.default_swn..=sk.default_swn, + Sums::Pinned(s) => s..=s, + }; + st.swept += 1; + let mut winner: Option<(u64, u64)> = None; + for swn in sums { + if table.trials(swn) > max_trials { + continue; + } + st.feasible += 1; + let v = b.of(sk.verify(swn)); + if winner.is_none_or(|(_, best_v)| v < best_v) { + winner = Some((swn, v)); + } + } + if let Some((swn, _)) = winner { + record(&mut best, st, b, &sk, &room, swn, table.trials(swn)); + } + } } } } } } } - rows -} -/// This hypertree with this FORS side, at the target sums that verify cheapest -/// within the budgets, or `None` if nothing fits. -fn fit(b: &Budgets, p: &Params, tree: &Tree, fors: &Fors, nu: &mut NuCache, st: &mut Stats) -> Option { - let at_mean = assemble(p, &tree.hyper, fors); - if at_mean.sig_bytes > b.max_size() { - st.size_pruned += 1; - return None; - } - // the mean grinds least, so it settles whether any sums fit at all - if !b.fits(&at_mean) { - st.sign_pruned += 1; - return None; - } - st.feasible += 1; - let Some(grinding) = tree.grinding.as_ref() else { - return Some(at_mean); - }; - st.swept += 1; - // What the signing budget leaves for grinding, in counter trials. - let per_trial = b.of(Cost::new(1, p.blocks().chain_step_with_counter())).max(1); - let fixed = b.of(at_mean.sign) - b.of(at_mean.wots_c_grinding); - let (gain, top_swn, low_swn) = grinding.best(b.max_sign().saturating_sub(fixed) / per_trial); - if gain == 0 { - return Some(at_mean); - } - let ht = tree.hyper.hypertree; - let layers: Option> = ht - .layers() - .enumerate() - .map(|(i, x)| x.with_swn(Some(if i == 0 { top_swn } else { low_swn }))) - .collect(); - let chosen = Hypertree::new(&layers?)?; - // the frontier says what it costs; the model says what it is - let hyper = crate::params::hyper_cost(p, &chosen, nu)?; - let costs = assemble(p, &hyper, fors); - st.costed += 1; - if b.fits(&costs) && b.of(costs.verify) < b.of(at_mean.verify) { - st.feasible += 1; - return Some(costs); - } - Some(at_mean) -} - -/// One WOTS instance's searched parameters: `(w, dropped_chains)`. -pub type Wots = (u64, u64); - -/// The `(top WOTS, lower WOTS)` pairs to try: one pair unless `split_wots`. -fn wots_instances(g: &Grid, scheme: Scheme) -> Vec<(Wots, Wots)> { - if g.hypertree.is_some() { - return vec![((0, 0), (0, 0))]; // ignored: `build` returns the given tree - } - let mut single = Vec::new(); - for &bits in &g.chain_bits { - let w = 1u64 << bits; - let range = if scheme.wots_c() { - g.dropped.within((8 * g.n / bits).saturating_sub(1)) - } else { - 0..=0 - }; - for dropped in range { - single.push((w, dropped)); - } - } - if !g.split_wots { - return single.iter().map(|&x| (x, x)).collect(); - } - single - .iter() - .flat_map(|&t| single.iter().map(move |&l| (t, l))) - .collect() -} - -fn build(g: &Grid, wots: (Wots, Wots), h: u64, d: u64, h_top: u64) -> Option { - if let Some(ht) = g.hypertree { - return (ht.height() == h && ht.depth() == d).then_some(ht); - } - let ((tw, td), (lw, ld)) = wots; - let sums = match g.sums { - Sums::Pinned(s) => Some(s), - _ => None, - }; - Hypertree::two_group(h, d, Layer::new(h_top, tw, td, sums)?, Layer::new(1, lw, ld, sums)?) + st.seconds = started.elapsed().as_secs_f64(); + sort_rows(&mut best, b); + best } /// Rows kept before the list is trimmed back to `ROWS_KEPT`. The optimum is /// unaffected: what gets dropped is worse than everything retained. -const ROWS_CAP: usize = 1 << 16; -const ROWS_KEPT: usize = 1 << 15; +const ROWS_CAP: usize = 1 << 18; +const ROWS_KEPT: usize = 1 << 17; fn sort_rows(rows: &mut [Candidate], b: &Budgets) { rows.sort_by_key(|c| (b.of(c.costs.verify), c.costs.sig_bytes, b.of(c.costs.sign))); } +/// Record this parameter tuple on the cheapest layer profile that fits: they +/// all verify the same, so the tie goes to signing. +fn record(rows: &mut Vec, st: &mut Stats, b: &Budgets, sk: &Skeleton, room: &Room, swn: u64, trials: u64) { + let Some(lay) = room + .profiles + .iter() + .filter(|lay| b.of(sk.sign(lay, trials)) <= b.max_sign()) + .min_by_key(|lay| (b.of(sk.sign(lay, trials)), b.of(sk.sign_cold(lay, trials)))) + else { + return; + }; + st.rows += 1; + rows.push(Candidate { + params: Params { + h_top: Some(lay.profile.h_top()), + ..sk.params + }, + costs: sk.finish(lay, swn, trials), + }); + if rows.len() >= ROWS_CAP { + sort_rows(rows, b); + rows.truncate(ROWS_KEPT); + st.rows_dropped += (ROWS_CAP - ROWS_KEPT) as u64; + } +} + /// Axes where a result sits at the top of a searched range. /// /// Such a result may be limited by the range rather than by the budgets, so it @@ -591,32 +473,78 @@ pub fn edges(g: &Grid, c: &Candidate) -> Vec { g.chain_bits.iter().copied().min().unwrap_or(0), g.chain_bits.iter().copied().max().unwrap_or(0), ); - let ht = c.costs.hypertree; - let low = ht.layers().last().unwrap_or(ht.top()); let at = [ - ("h", ht.height(), g.h.lo, g.h.hi, "H_MAX"), - ("d", ht.depth(), g.d.lo, g.d.hi, "D_MAX"), - ("a", c.params.a, g.a.lo, g.a.hi, "A_MAX"), - ("k", c.params.k, g.k.lo, g.k.hi, "K_MAX"), - ("chain_bits", ht.top().chain_bits(), bits.lo, bits.hi, "CHAIN_BITS_MAX"), - ("chain_bits", low.chain_bits(), bits.lo, bits.hi, "CHAIN_BITS_MAX"), + ("h", c.params.h, g.h, "H_MAX / --height"), + ("d", c.params.d, g.d, "D_MAX / --layers"), + ("a", c.params.a, g.a, "A_MAX / -a"), + ("k", c.params.k, g.k, "K_MAX / -k"), + ("chain_bits", c.costs.chain_bits, bits, "CHAIN_BITS_MAX / --chain-bits"), ( "dropped_chains", - ht.top().dropped_chains(), - 0, - g.dropped.hi, - "DROPPED_MAX", + c.params.dropped_chains, + g.dropped, + "DROPPED_MAX / --drop-chains", ), - ("dropped_chains", low.dropped_chains(), 0, g.dropped.hi, "DROPPED_MAX"), ]; - let mut out: Vec = at - .iter() - .filter(|(_, v, floor, limit, _)| limit > floor && *v + 1 >= *limit) - .map(|(axis, v, _, limit, what)| { - let where_ = if v >= limit { "at" } else { "one step below" }; - format!("{axis} = {v} is {where_} the top of the searched range ({limit}): raise {what} in src/search.rs and rerun") + at.iter() + .filter(|(_, v, span, _)| !span.pinned() && *v + 1 >= span.hi) + .map(|(axis, v, span, what)| { + let where_ = if *v >= span.hi { "at" } else { "one step below" }; + format!( + "{axis} = {v} is {where_} the top of the searched range ({}): raise {what} and rerun", + span.hi + ) }) - .collect(); - out.dedup(); + .collect() +} + +/// The same search with nothing skipped: every `(a, k, h_top, S_wn)` point +/// costed in full and checked against every budget. +/// +/// Only usable on a tiny grid, which is the point: it is the oracle the real +/// search is diffed against in `tests/goldens`. +pub fn naive_search(b: &Budgets, g: &Grid) -> Vec { + let digest_bits = (8 * g.n) as u32; + let mut out: Vec = Vec::new(); + for &scheme in &g.schemes { + for &bits in &g.chain_bits { + let w = 1u64 << bits; + let dropped_range = if scheme.wots_c() { g.dropped.iter() } else { 0..=0 }; + for dropped in dropped_range { + for h in g.h.iter() { + for d in g.d.within(h) { + for h_top in 1..=h { + for a in g.a.iter() { + for k in g.k.iter() { + let p = Params { + h_top: Some(h_top), + ..params(g, scheme, h, d, a, k, w, dropped) + }; + let Some(sk) = Skeleton::new(p) else { continue }; + let Some(lay) = Layers::new(&p) else { continue }; + if crate::security::security_bits(b.q_s, h as u32, k, a, g.n) < b.security { + continue; + } + let table = scheme.wots_c().then(|| NuTable::new(sk.l, w, digest_bits)); + let sums: Vec = match &table { + Some(_) => (0..=sk.max_swn).collect(), + None => vec![0], + }; + for swn in sums { + let trials = table.as_ref().map_or(0, |t| t.trials(swn)); + let c = sk.finish(&lay, swn, trials); + if b.fits(&c) { + out.push(Candidate { params: p, costs: c }); + } + } + } + } + } + } + } + } + } + } + out.sort_by_key(|c| (b.of(c.costs.verify), c.costs.sig_bytes, b.of(c.costs.sign))); out } diff --git a/doc/sphincs/params_selection/src/security.rs b/doc/sphincs/params_selection/src/security.rs index cbe355f54..5306cda4f 100644 --- a/doc/sphincs/params_selection/src/security.rs +++ b/doc/sphincs/params_selection/src/security.rs @@ -89,30 +89,6 @@ pub struct SecurityTable { } impl SecurityTable { - /// Fill every cell up front, in parallel, so the search can share it. - /// - /// The set of `(h, k, a)` a search touches is fixed by its grid and does not - /// depend on the scheme or the WOTS parameters, so this is computed once - /// rather than per worker. - pub fn filled(q_s: f64, target: f64, n: u64, h_max: u32, k_max: u64, a_max: u64) -> Self { - use rayon::prelude::*; - let mut out = Self::new(q_s, target, n, h_max, k_max, a_max); - let (kk, aa) = (k_max as usize + 1, a_max as usize + 1); - let flags: Vec = (0..=h_max as usize) - .into_par_iter() - .flat_map_iter(|h| { - (0..kk).flat_map(move |k| { - (0..aa).map(move |a| { - let secure = k >= 1 && a >= 1 && security_bits(q_s, h as u32, k as u64, a as u64, n) >= target; - if secure { 1u8 } else { 2 } - }) - }) - }) - .collect(); - out.seen = flags; - out - } - pub fn new(q_s: f64, target: f64, n: u64, h_max: u32, k_max: u64, a_max: u64) -> Self { let cells = (h_max as usize + 1) * (k_max as usize + 1) * (a_max as usize + 1); Self { @@ -126,11 +102,14 @@ impl SecurityTable { } } - pub fn is_secure(&self, h: u32, k: u64, a: u64) -> bool { + pub fn is_secure(&mut self, h: u32, k: u64, a: u64) -> bool { if h > self.h_max || k > self.k_max || a > self.a_max { return self.compute(h, k, a); } let i = (h as usize * (self.k_max as usize + 1) + k as usize) * (self.a_max as usize + 1) + a as usize; + if self.seen[i] == 0 { + self.seen[i] = if self.compute(h, k, a) { 1 } else { 2 }; + } self.seen[i] == 1 } diff --git a/doc/sphincs/params_selection/tests/goldens.rs b/doc/sphincs/params_selection/tests/goldens.rs index 42dcbe96c..9692d5cc1 100644 --- a/doc/sphincs/params_selection/tests/goldens.rs +++ b/doc/sphincs/params_selection/tests/goldens.rs @@ -11,30 +11,26 @@ //! * for the search, a naive oracle in this crate that skips nothing. use sphincs_params::cost::{Blocks, Encoding, NuTable, Scheme}; -use sphincs_params::params::{Fors, Hypertree, Layer, Params, assemble, costs, hyper_cost}; -use sphincs_params::search::{Budgets, Grid, LEVEL1_BITS, Span, Stats, search}; +use sphincs_params::params::{Layers, Params, Profile, Skeleton, costs}; +use sphincs_params::search::{Budgets, Grid, LEVEL1_BITS, Span, Stats, naive_search, search}; use sphincs_params::security::{forgery_exponent, security_bits}; -fn params(scheme: Scheme, a: u64, k: u64) -> Params { +fn params(scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64) -> Params { Params { scheme, + h, + d, + h_top: None, a, k, + w, n: 16, + dropped_chains: 0, cache_height: None, cache_level_only: false, } } -/// One parameter set the way the report writes them: one WOTS instance for the -/// whole hypertree, the height split evenly. -fn uniform(scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, swn: Option) -> (Params, Hypertree) { - ( - params(scheme, a, k), - Hypertree::uniform(h, d, None, w, 0, swn).expect("consistent"), - ) -} - /// `(scheme, h, d, k, a, w, S_wn)` and the `(size, keygen, sign, verify, /// verify_worst)` it must produce, sizes in bytes and costs in compressions. type Fixture = (Scheme, u64, u64, u64, u64, u64, Option, [u64; 5]); @@ -116,8 +112,8 @@ const FIXTURES_CACHED: [Fixture; 7] = [ #[test] fn matches_the_sage_fixtures() { for &(scheme, h, d, k, a, w, swn, want) in &FIXTURES_CACHED { - let (p, ht) = uniform(scheme, h, d, a, k, w, swn); - let c = costs(p, &ht).expect("consistent parameters"); + let p = params(scheme, h, d, a, k, w); + let c = costs(p, swn).expect("consistent parameters"); let got = [ c.sig_bytes, c.keygen.compressions, @@ -188,8 +184,8 @@ const REPORT_TABLE: [ReportRow; 18] = [ #[test] fn matches_the_report_tables() { for (scheme, h, d, a, k, w, swn, sigver, sigtime_e4, search) in REPORT_TABLE { - let (p, ht) = uniform(scheme, h, d, a, k, w, swn); - let c = costs(p, &ht).expect("consistent parameters"); + let p = params(scheme, h, d, a, k, w); + let c = costs(p, swn).expect("consistent parameters"); let tag = format!("{} h={h} d={d} a={a} k={k} w={w} S={swn:?}", scheme.label()); assert_eq!(c.verify.hashes, sigver, "SigVer {tag}"); let got = c.sign_cold.hashes as f64 / 1e4; @@ -314,21 +310,97 @@ fn secure_k_form_an_up_set() { #[test] fn half_top_cache_is_a_saving_and_reduces_to_the_full_tree() { - let (p, ht) = uniform(Scheme::WcFc, 40, 5, 14, 11, 256, None); - let c = costs(p, &ht).unwrap(); + let p = params(Scheme::WcFc, 40, 5, 14, 11, 256); + let c = costs(p, None).unwrap(); assert!(c.sign.hashes < c.sign_cold.hashes); // caching at the leaves is caching the whole tree: nothing left to rebuild let whole = Params { cache_height: Some(0), ..p }; - assert!(costs(whole, &ht).unwrap().sign.hashes < c.sign.hashes); + assert!(costs(whole, None).unwrap().sign.hashes < c.sign.hashes); // caching only the root is caching nothing, so signing goes cold let none = Params { - cache_height: Some(ht.top().height()), + cache_height: Some(p.profile().unwrap().h_top()), ..p }; - assert_eq!(costs(none, &ht).unwrap().sign.hashes, c.sign_cold.hashes); + assert_eq!(costs(none, None).unwrap().sign.hashes, c.sign_cold.hashes); +} + +fn budgets(log2_q_s: i32, keygen: u64, sign: u64, size: u64) -> Budgets { + Budgets { + q_s: 2f64.powi(log2_q_s), + keygen: Some(keygen), + sign: Some(sign), + size: Some(size), + security: LEVEL1_BITS, + } +} + +#[test] +fn search_agrees_with_a_naive_oracle() { + // A grid small enough to sweep with nothing skipped at all. + let b = budgets(20, 3_000_000, 10_000_000, 4_000); + let g = Grid { + schemes: vec![Scheme::Wc, Scheme::WcFc], + h: Span::pin(20), + a: Span::new(14, 16), + k: Span::new(1, 14), + chain_bits: vec![4], + dropped: Span::new(0, 1), + h_top: Some(Span::new(1, 20)), + ..Default::default() + }; + let mut st = Stats::default(); + let found = search(&b, &g, &mut st); + let oracle = naive_search(&b, &g); + assert!(!found.is_empty() && !oracle.is_empty()); + assert_eq!( + found[0].costs.verify.hashes, oracle[0].costs.verify.hashes, + "the oracle finds the same optimum" + ); + assert_eq!(found[0].key(), oracle[0].key(), "and the same winner"); + // Every parameter tuple the oracle found feasible is in the search's output, + // with the same best verification cost for that tuple. + let mut want: std::collections::HashMap<_, u64> = Default::default(); + for c in &oracle { + let e = want.entry(c.key()).or_insert(u64::MAX); + *e = (*e).min(c.costs.verify.hashes); + } + let got: std::collections::HashMap<_, u64> = found.iter().map(|c| (c.key(), c.costs.verify.hashes)).collect(); + assert_eq!(got, want, "the search and the oracle agree tuple by tuple"); +} + +#[test] +fn search_finds_and_improves_on_the_reports_bold_row() { + // Budgets near the report's 2^40 numbers, on its grid (w in {16, 256}, no + // chain dropping). Its own choice has to come out feasible, and the search + // has to do at least as well: it spends what is left of the signing budget + // raising the target sum, which the report's row does not. + let b = budgets(40, 1_100_000, 6_000_000, 4_400); + let g = Grid { + chain_bits: vec![4, 8], + dropped: Span::pin(0), + ..Default::default() + }; + let mut st = Stats::default(); + let found = search(&b, &g, &mut st); + let row = found + .iter() + .find(|c| c.key() == (Scheme::WcFc, 40, 5, 14, 11, 256, 0)) + .expect("the report's bold row is feasible under its own budgets"); + assert!( + row.costs.swn.unwrap() > 2040, + "the report's row grinds less than the budget allows" + ); + assert!( + row.costs.verify.hashes < 10402, + "so it can verify faster than the table's 10402 hashes" + ); + assert!( + found[0].costs.verify.hashes <= row.costs.verify.hashes, + "and the winner is at least as cheap" + ); } #[test] @@ -336,12 +408,17 @@ fn a_taller_top_layer_is_free_on_size_and_verification() { // The whole point of per-layer heights: the signature carries h // authentication nodes and the verifier walks them however the layers // divide h, so only the signer's costs move. - let p = params(Scheme::WcFc, 14, 11); - let flat = Hypertree::uniform(40, 5, Some(8), 256, 0, None).unwrap(); - let tall = Hypertree::uniform(40, 5, Some(15), 256, 0, None).unwrap(); - let (u, t) = (costs(p, &flat).unwrap(), costs(p, &tall).unwrap()); - assert_eq!(flat.height(), 40); - assert_eq!(tall.height(), 40); + let uniform = Params { + h_top: Some(8), + ..params(Scheme::WcFc, 40, 5, 14, 11, 256) + }; + let tall = Params { + h_top: Some(15), + ..uniform + }; + let (u, t) = (costs(uniform, None).unwrap(), costs(tall, None).unwrap()); + assert_eq!(u.profile.total(), 40); + assert_eq!(t.profile.total(), 40); assert_eq!( (t.sig_bytes, t.verify), (u.sig_bytes, u.verify), @@ -352,16 +429,18 @@ fn a_taller_top_layer_is_free_on_size_and_verification() { "a taller top tree costs more to generate" ); assert!(t.sign_cold.hashes > u.sign_cold.hashes, "and more to sign cold"); + assert!(t.sign.hashes < u.sign.hashes, "but less with it, which is the point"); + // the lower layers come out as equal as they go, never differing by more + // than one level + let p = t.profile; + let lower: Vec = p.heights().skip(1).collect(); + let (lo, hi) = (lower.iter().min().unwrap(), lower.iter().max().unwrap()); assert!( - t.sign.hashes < u.sign.hashes, - "but less with the cache, which is the point" - ); - // the lower layers come out as equal as they go - let lower: Vec = tall.layers().skip(1).map(|x| x.height()).collect(); - assert!( - lower.iter().max().unwrap() - lower.iter().min().unwrap() <= 1, - "{lower:?}" + hi - lo <= 1, + "the lower layers never differ by more than a level: {lower:?}" ); + assert_eq!(Profile::canonical(41, 5, Some(9)).unwrap().total(), 41); + assert_eq!(Layers::new(&tall).unwrap().profile, t.profile); } /// Every way of splitting `h` over `d` layers, top first. @@ -380,166 +459,131 @@ fn compositions(h: u64, d: u64) -> Vec> { .collect() } +/// The search only ever builds `Profile::canonical`, and this is why that is +/// not a restriction: for the same `(h, d, h_top)`, no other profile costs less +/// on anything. #[test] -fn any_hypertree_can_be_costed() { - let p = params(Scheme::WcFc, 14, 11); - // heights and WOTS parameters both varying, layer by layer - let mixed = Hypertree::new(&[ - Layer::new(11, 256, 0, Some(2040)).unwrap(), - Layer::new(5, 16, 1, None).unwrap(), - Layer::new(7, 4, 0, None).unwrap(), - Layer::new(3, 2, 0, None).unwrap(), - ]) - .unwrap(); - assert_eq!(mixed.height(), 26); - assert_eq!(mixed.depth(), 4); - assert!(!mixed.one_wots()); - assert_eq!(mixed.heights(), "11 + 5 + 7 + 3"); - let c = costs(p, &mixed).unwrap(); - // the signature carries one WOTS signature per layer, at that layer's l - let chains: u64 = mixed.layers().map(|x| x.chains(16, Scheme::WcFc).unwrap()).sum(); - let fors = Fors::new(&p).unwrap(); - assert_eq!(c.sig_bytes, fors.sig_bytes + 26 * 16 + chains * 16 + 4 * 4); - // a hypertree of one layer is an ordinary XMSS tree - let single = Hypertree::new(&[Layer::new(20, 16, 0, None).unwrap()]).unwrap(); - assert_eq!(single.depth(), 1); - assert!(costs(p, &single).is_some()); - assert!(Hypertree::new(&[]).is_none()); - assert!(Layer::new(0, 16, 0, None).is_none(), "every layer needs a level"); - assert!(Layer::new(64, 16, 0, None).is_none(), "2^height has to be countable"); - assert!(Layer::new(8, 24, 0, None).is_none(), "w is a power of two"); -} - -/// The search tries two WOTS instances, one for the top layer and one for the -/// rest, and this is what that costs against giving every layer its own. -#[test] -fn two_groups_against_every_per_layer_assignment() { - let p = params(Scheme::WcFc, 10, 12); - let fors = Fors::new(&p).unwrap(); - let mut nu = sphincs_params::cost::NuCache::new(16); - let configs: Vec<(u64, u64)> = [2, 4, 16].iter().flat_map(|&w| [0, 1].map(move |dr| (w, dr))).collect(); - let mut worst_gap = 0.0f64; - for (h, d) in [(8, 2), (11, 2), (9, 3), (12, 3)] { - // the budgets have to bind, or every assignment is feasible and the - // comparison says nothing - let (max_size, max_sign) = (5_000, 4_000_000); - let mut best_any = u64::MAX; - let mut best_two_group = u64::MAX; +fn profile_shape_is_never_beaten() { + let mut checked = 0; + for (h, d) in [(12, 3), (14, 4), (9, 2), (16, 5), (20, 4)] { + let p = Params { + h, + d, + ..params(Scheme::WcFc, h, d, 10, 12, 16) + }; + let sk = Skeleton::new(p).expect("consistent"); for heights in compositions(h, d) { - for assignment in 0..configs.len().pow(d as u32) { - let layers: Option> = heights - .iter() - .enumerate() - .map(|(i, &height)| { - let (w, dr) = configs[assignment / configs.len().pow(i as u32) % configs.len()]; - Layer::new(height, w, dr, None) - }) - .collect(); - let Some(layers) = layers else { continue }; - let Some(ht) = Hypertree::new(&layers) else { continue }; - let Some(hyper) = hyper_cost(&p, &ht, &mut nu) else { - continue; - }; - let c = assemble(&p, &hyper, &fors); - if c.sig_bytes > max_size || c.sign.compressions > max_sign { - continue; - } - best_any = best_any.min(c.verify.compressions); - // is this assignment inside the two-group family? - let top = layers[0]; - let low = layers[layers.len() - 1]; - let even = Hypertree::two_group(h, d, top, low); - if even == Some(ht) { - best_two_group = best_two_group.min(c.verify.compressions); - } - } + let Some(profile) = Profile::new(&heights) else { + continue; + }; + let Some(any) = Layers::from_profile(&p, profile) else { + continue; + }; + let canon = Layers::new(&Params { + h_top: Some(heights[0]), + ..p + }) + .expect("same top height"); + let tag = format!("h={h} d={d} heights={heights:?}"); + // size and verification do not see the profile at all + let (a, c) = (sk.finish(&any, 0, 0), sk.finish(&canon, 0, 0)); + assert_eq!( + (a.sig_bytes, a.verify), + (c.sig_bytes, c.verify), + "size or verification moved: {tag}" + ); + // keygen is the top tree, which they share + assert_eq!(any.keygen, canon.keygen, "keygen moved: {tag}"); + // and the canonical split is the cheapest to sign, cached or cold + assert!( + canon.trees_cached.compressions <= any.trees_cached.compressions, + "beaten on signing: {tag}" + ); + assert!( + canon.trees.compressions <= any.trees.compressions, + "beaten on cold signing: {tag}" + ); + checked += 1; } - assert!(best_any < u64::MAX, "nothing feasible at h={h} d={d}"); - assert!(best_two_group >= best_any, "the family cannot beat the whole space"); - let gap = best_two_group as f64 / best_any as f64 - 1.0; - worst_gap = worst_gap.max(gap); - println!( - "h={h} d={d}: two groups {best_two_group}, every assignment {best_any} ({:.1}% gap)", - 100.0 * gap - ); } - // What the two-group restriction costs. Raise this only with a note saying - // which case moved and why. - assert!( - worst_gap <= 0.0, - "the two-group family lost {:.1}% somewhere", - 100.0 * worst_gap - ); + assert!(checked > 2000, "only {checked} profiles checked"); } -fn budgets(log2_q_s: i32, keygen: u64, sign: u64, size: u64) -> Budgets { - Budgets { - q_s: 2f64.powi(log2_q_s), - keygen: Some(keygen), - sign: Some(sign), - size: Some(size), - security: LEVEL1_BITS, - } +/// A profile is expressible however uneven, and reads back as given. +#[test] +fn any_profile_can_be_costed() { + let p = params(Scheme::WcFc, 26, 4, 14, 11, 256); + let lopsided = Profile::new(&[11, 5, 7, 3]).expect("26 over 4 layers"); + assert_eq!(lopsided.total(), 26); + assert_eq!(lopsided.h_top(), 11); + assert_eq!(format!("{lopsided}"), "11 + 5 + 7 + 3"); + let lay = Layers::from_profile(&p, lopsided).expect("adds up to h over d layers"); + assert_eq!(lay.profile, lopsided); + // and the canonical one with the same top is at least as cheap to sign + let canon = Layers::new(&Params { h_top: Some(11), ..p }).unwrap(); + assert_eq!(format!("{}", canon.profile), "11 + 5 + 5 + 5"); + assert!(canon.trees_cached.compressions <= lay.trees_cached.compressions); + // heights have to add up over the layers there are + assert!(Layers::from_profile(&p, Profile::new(&[11, 5, 7, 4]).unwrap()).is_none()); + assert!(Layers::from_profile(&p, Profile::new(&[13, 13]).unwrap()).is_none()); + assert!(Profile::new(&[11, 0, 15]).is_none(), "every layer needs a level"); + assert!(Profile::new(&[64]).is_none(), "2^height has to be countable"); } #[test] -fn search_finds_and_improves_on_the_reports_bold_row() { - // Budgets near the report's 2^40 numbers, on its grid (w in {16, 256}, no - // chain dropping). Its own choice has to come out feasible, and the search - // has to do at least as well: it spends what is left of the signing budget - // raising the target sums, which the report's row does not. - let b = budgets(40, 1_100_000, 6_000_000, 4_400); - let g = Grid { - chain_bits: vec![4, 8], - dropped: Span::pin(0), - ..Default::default() - }; - let mut st = Stats::default(); - let found = search(&b, &g, &mut st); - let row = found - .iter() - .find(|c| { - let ht = c.costs.hypertree; - (c.params.a, c.params.k, ht.height(), ht.depth(), ht.top().w()) == (14, 11, 40, 5, 256) - }) - .expect("the report's bold row is feasible under its own budgets"); - let swn = row.costs.hypertree.top().swn().unwrap(); - assert!(swn > 2040, "the report's row grinds less than the budget allows"); +fn skeleton_rejects_trees_that_do_not_fit_a_u64() { + // 2^h' leaves has to be countable: without this the shift masks and a + // 2^64-leaf tree reports the cost of a one-leaf tree. + let p = params(Scheme::WcFc, 64, 1, 14, 11, 256); + assert!(Skeleton::new(p).is_none()); + assert!(Skeleton::new(Params { h: 63, ..p }).is_some()); + assert!(Skeleton::new(Params { a: 64, ..p }).is_none()); + // and the cost really does scale with the tree, so nothing wraps below that + let small = costs(Params { h: 40, d: 8, ..p }, None).unwrap(); + let large = costs(Params { h: 48, d: 8, ..p }, None).unwrap(); + // twice the leaves is twice the work plus the node joining the two halves + assert_eq!(large.keygen.hashes, small.keygen.hashes * 2 + 1); +} + +#[test] +fn skeleton_rejects_inconsistent_parameters() { + let ok = params(Scheme::WcFc, 40, 5, 14, 11, 256); + assert!(Skeleton::new(ok).is_some()); + // d need not divide h: the layers just come out within one of each other + let uneven = Skeleton::new(Params { d: 3, ..ok }).expect("d need not divide h"); + assert_eq!(uneven.params.profile().unwrap().total(), 40); assert!( - row.costs.verify.hashes < 10402, - "so it can verify faster than the table's 10402 hashes" + Skeleton::new(Params { h: 2, d: 3, ..ok }).is_none(), + "every layer needs a level" ); assert!( - found[0].costs.verify.compressions <= row.costs.verify.compressions, - "and the winner is at least as cheap" + Skeleton::new(Params { h_top: Some(40), ..ok }).is_none(), + "the lower layers need levels too" + ); + assert!( + Skeleton::new(Params { h_top: Some(36), ..ok }).is_some(), + "but only one each" + ); + assert!( + Skeleton::new(Params { w: 24, ..ok }).is_none(), + "w must be a power of two" + ); + assert!(Skeleton::new(Params { k: 1, ..ok }).is_none(), "FORS+C signs k-1 trees"); + assert!( + Skeleton::new(Params { + scheme: Scheme::Spx, + dropped_chains: 1, + ..ok + }) + .is_none(), + "WOTS-TW has no counter" + ); + assert!( + Layers::new(&Params { + cache_height: Some(99), + ..ok + }) + .is_none(), + "the cache sits inside the top tree" ); -} - -#[test] -fn rejects_what_it_cannot_count_or_assemble() { - let p = params(Scheme::WcFc, 14, 11); - // 2^h has to be countable: without this the shift masks and a 2^64-leaf - // tree reports the cost of a one-leaf tree - assert!(Layer::new(64, 256, 0, None).is_none()); - assert!(Hypertree::uniform(64, 1, None, 256, 0, None).is_none()); - assert!(Hypertree::uniform(63, 1, None, 256, 0, None).is_some()); - // and the cost really does scale with the tree, so nothing wraps below that - let small = costs(p, &Hypertree::uniform(40, 8, None, 256, 0, None).unwrap()).unwrap(); - let large = costs(p, &Hypertree::uniform(48, 8, None, 256, 0, None).unwrap()).unwrap(); - // twice the leaves in the top tree is twice the work plus the node joining - // the two halves - assert_eq!(large.keygen.hashes, small.keygen.hashes * 2 + 1); - assert!(large.sign_cold.hashes > small.sign_cold.hashes); - // FORS+C signs k-1 trees, so it needs two - assert!(Fors::new(&Params { k: 1, ..p }).is_none()); - // every layer needs a level, and the heights have to add up - assert!(Hypertree::uniform(2, 3, None, 256, 0, None).is_none()); - assert!(Hypertree::uniform(40, 5, Some(40), 256, 0, None).is_none()); - assert!(Hypertree::uniform(40, 5, Some(36), 256, 0, None).is_some()); - // the cache sits inside the top tree - let deep = Params { - cache_height: Some(99), - ..p - }; - assert!(costs(deep, &Hypertree::uniform(40, 5, None, 256, 0, None).unwrap()).is_none()); } From fbcb6408df01cc65fe527b285e1df8de61037a51 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Mon, 24 Aug 2026 11:52:44 +0200 Subject: [PATCH 24/31] doc/sphincs: specify the instance, and cache one level rather than a triangle main.tex specifies the SPHINCS+ instance the parameter search picked: h = 26 over d = 3 layers of 12 + 7 + 7, a = 10, k = 15, w = 8, v = 42 chains at target sum 191, no dropped chains, a 32-byte public key and a 4924-byte signature. Written from first principles in the style of doc/xmss: the tweakable hash and the tweak encoding, the index decomposition, then the one-time signature, a layer, and the few-time signature as sub-algorithms, so Sig and Ver are four steps each. Costs are counted in hash calls, 1.38M at key generation, 190K per signature with 1024 bytes of cache, 497 at verification. Security carries the strong-unforgeability game and nothing else yet; the accounting and the quantum argument are TODO. The signer's cache keeps only the 2^ceil(h_0/2) nodes of one level of layer 0's tree, not that level and everything above it: 1024 bytes rather than 2032, paying 2^6 - 1 = 63 node calls per signature to refold the triangle above them, against the 21,631 the subtree rebuild costs anyway. params_selection models that unconditionally, so --cache-level-only is gone. --- AGENTS.md | 1 + doc/sphincs/latexmkrc | 4 + doc/sphincs/main.tex | 466 ++++++++++++++++++ doc/sphincs/params_selection/src/main.rs | 7 +- doc/sphincs/params_selection/src/params.rs | 21 +- doc/sphincs/params_selection/src/search.rs | 3 - doc/sphincs/params_selection/tests/goldens.rs | 1 - doc/sphincs/refs.bib | 101 ++++ 8 files changed, 582 insertions(+), 22 deletions(-) create mode 100644 doc/sphincs/latexmkrc create mode 100644 doc/sphincs/main.tex create mode 100644 doc/sphincs/refs.bib diff --git a/AGENTS.md b/AGENTS.md index 3d88ba102..e5c7bae88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ 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`. It shares XMSS's hash function, tweakable hash and target-sum code, so an aggregator implements one primitive. - `doc/sphincs/params_selection/` is a parameter-exploration tool for SPHINCS+ (security, signature size, hash counts, and a search for the cheapest verification under given budgets), in its own cargo workspace with no dependencies. It models the schemes of the Blockstream report, not anything this repo proves, and its `cargo test --release` pins every number against that project's sage fixtures. - 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). 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..f0e0aaeb1 --- /dev/null +++ b/doc/sphincs/main.tex @@ -0,0 +1,466 @@ +% 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} +\newtheorem{claim}[definition]{Claim} +\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{\Forge}{\mathsf{Forge}} +\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}, which is the one tree of layer $0$ and nothing else. +\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$ & few-time 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 std::process::ExitCode { /// Flags and their values, repeatable flags kept in order. struct Args(Vec<(String, Option)>); -const NO_VALUE: [&str; 4] = ["--cache-level-only", "--stats", "--help", "-h"]; +const NO_VALUE: [&str; 3] = ["--stats", "--help", "-h"]; -const FLAGS: [&str; 22] = [ +const FLAGS: [&str; 21] = [ "--lifetime", "--scheme", "--height", @@ -109,7 +108,6 @@ const FLAGS: [&str; 22] = [ "--max-size", "--security", "--cache-height", - "--cache-level-only", "--top", "--stats", "--help", @@ -303,7 +301,6 @@ fn run(argv: &[String]) -> Result { sums, profile, cache_height: args.num("--cache-height")?, - cache_level_only: args.flag("--cache-level-only"), }; let mut stats = Stats::default(); diff --git a/doc/sphincs/params_selection/src/params.rs b/doc/sphincs/params_selection/src/params.rs index 9d02625f4..5afae05a3 100644 --- a/doc/sphincs/params_selection/src/params.rs +++ b/doc/sphincs/params_selection/src/params.rs @@ -25,8 +25,6 @@ pub struct Params { pub dropped_chains: u64, /// Height above the leaves of the cached top-tree level; `None` is half of it. pub cache_height: Option, - /// Cache one level rather than it and everything above. - pub cache_level_only: bool, } /// The height of every XMSS tree in the hypertree, top first. @@ -234,9 +232,9 @@ impl Layers { // Only the top tree is worth caching: it is the same for every // signature, while the trees below it are picked by the (pseudorandom) // index. Its auth path splits at the cached level: below, rebuild the - // 2^c-leaf subtree the signing leaf sits in; above, the nodes are - // already in state. Rebuilt leaves are charged a full WOTS public key, - // as everywhere else here. + // 2^c-leaf subtree the signing leaf sits in; above, refold the stored + // level. Rebuilt leaves are charged a full WOTS public key, as + // everywhere else here. // // A BDS-style traversal would amortize a tree to h' leaves per // signature with O(h') state, but it only works walking the leaves in @@ -248,15 +246,12 @@ impl Layers { if c > profile.h_top() { return None; } + // Only the level itself is stored, not the triangle above it: refolding + // that is 2^(h-c)-1 node calls, nothing next to the subtree rebuild, + // while storing it would double the bytes. let stored_level = 1u64 << (profile.h_top() - c); - let mut cached = tree(c); - let cache_bytes; - if p.cache_level_only { - cached = cached + Cost::new(stored_level - 1, (stored_level - 1) * b.merkle_node()); - cache_bytes = stored_level * p.n; - } else { - cache_bytes = (2 * stored_level - 1) * p.n; - } + let cached = tree(c) + Cost::new(stored_level - 1, (stored_level - 1) * b.merkle_node()); + let cache_bytes = stored_level * p.n; Some(Self { profile, diff --git a/doc/sphincs/params_selection/src/search.rs b/doc/sphincs/params_selection/src/search.rs index d767a1989..91a1a7040 100644 --- a/doc/sphincs/params_selection/src/search.rs +++ b/doc/sphincs/params_selection/src/search.rs @@ -146,7 +146,6 @@ pub struct Grid { /// then pinned by it. pub profile: Option, pub cache_height: Option, - pub cache_level_only: bool, } impl Grid { @@ -186,7 +185,6 @@ impl Default for Grid { sums: Sums::Sweep, profile: None, cache_height: None, - cache_level_only: false, } } } @@ -270,7 +268,6 @@ fn params(g: &Grid, scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, drop n: g.n, dropped_chains: dropped, cache_height: g.cache_height, - cache_level_only: g.cache_level_only, } } diff --git a/doc/sphincs/params_selection/tests/goldens.rs b/doc/sphincs/params_selection/tests/goldens.rs index 9692d5cc1..2832eaf90 100644 --- a/doc/sphincs/params_selection/tests/goldens.rs +++ b/doc/sphincs/params_selection/tests/goldens.rs @@ -27,7 +27,6 @@ fn params(scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64) -> Params { n: 16, dropped_chains: 0, cache_height: None, - cache_level_only: false, } } diff --git a/doc/sphincs/refs.bib b/doc/sphincs/refs.bib new file mode 100644 index 000000000..7612eb16c --- /dev/null +++ b/doc/sphincs/refs.bib @@ -0,0 +1,101 @@ + +@misc{KN25, + author = {Mikhail Kudinov and Jonas Nick}, + title = {Hash-based Signature Schemes for Bitcoin}, + howpublished = {Blockstream Research, technical report}, + note = {Revision 2025-12-05. Scripts at \url{https://github.com/BlockstreamResearch/SPHINCS-Parameters}}, + year = {2025} +} + +@inproceedings{SPHINCSPLUS, + author = {Daniel J. Bernstein and Andreas H{\"u}lsing and Stefan K{\"o}lbl and Ruben Niederhagen and Joost Rijneveld and Peter Schwabe}, + title = {The {SPHINCS+} Signature Framework}, + booktitle = {ACM CCS 2019}, + pages = {2129--2146}, + year = {2019}, + doi = {10.1145/3319535.3363229}, + url = {https://eprint.iacr.org/2019/1086} +} + +@misc{HK22C, + author = {Andreas H{\"u}lsing and Mikhail Kudinov and Eyal Ronen and Eylon Yogev}, + title = {{SPHINCS+C}: Compressing {SPHINCS+} With (Almost) No Cost}, + howpublished = {Cryptology ePrint Archive, Paper 2022/778}, + year = {2022}, + url = {https://eprint.iacr.org/2022/778} +} + +@techreport{FIPS205, + author = {{National Institute of Standards and Technology}}, + title = {Stateless Hash-Based Digital Signature Standard}, + institution = {National Institute of Standards and Technology}, + number = {FIPS 205}, + year = {2024}, + doi = {10.6028/NIST.FIPS.205}, + url = {https://doi.org/10.6028/NIST.FIPS.205} +} + +@misc{NISTPQC, + author = {{National Institute of Standards and Technology}}, + title = {Submission Requirements and Evaluation Criteria for the Post-Quantum Cryptography Standardization Process}, + howpublished = {\href{https://csrc.nist.gov/csrc/media/projects/post-quantum-cryptography/documents/call-for-proposals-final-dec-2016.pdf}{NIST PQC Call for Proposals}}, + note = {Section 4.A.5, p. 16}, + year = {2016} +} + +@inproceedings{HK22, + author = {Andreas H{\"u}lsing and Mikhail Kudinov}, + title = {Recovering the Tight Security Proof of {SPHINCS+}}, + booktitle = {Advances in Cryptology: {ASIACRYPT} 2022, Part IV}, + series = {Lecture Notes in Computer Science}, + volume = {13794}, + pages = {3--33}, + year = {2022}, + doi = {10.1007/978-3-031-22972-5_1}, + url = {https://eprint.iacr.org/2022/346} +} + +@inproceedings{BDGHMS23, + author = {Manuel Barbosa and Fran{\c c}ois Dupressoir and Benjamin Gr{\'e}goire and Andreas H{\"u}lsing and Matthias Meijers and Pierre-Yves Strub}, + title = {Machine-Checked Security for {XMSS} as in {RFC} 8391 and {SPHINCS+}}, + booktitle = {Advances in Cryptology: {CRYPTO} 2023, Part V}, + series = {Lecture Notes in Computer Science}, + volume = {14085}, + pages = {421--454}, + year = {2023}, + doi = {10.1007/978-3-031-38554-4_14}, + url = {https://eprint.iacr.org/2023/408} +} + +@inproceedings{BDS08, + author = {Johannes Buchmann and Erik Dahmen and Michael Schneider}, + title = {Merkle Tree Traversal Revisited}, + booktitle = {Post-Quantum Cryptography, {PQCrypto} 2008}, + series = {Lecture Notes in Computer Science}, + volume = {5299}, + pages = {63--78}, + year = {2008}, + doi = {10.1007/978-3-540-88403-3_5} +} + + +@misc{leanVMb, + author = {{leanEthereum}}, + title = {leanVM-b}, + howpublished = {Software and technical documentation}, + year = {2026}, + url = {https://github.com/leanEthereum/leanVM-b} +} + + +@inproceedings{GHHM21, + author = {Alex B. Grilo and Kathrin H{\"o}velmanns and Andreas H{\"u}lsing and Christian Majenz}, + title = {Tight Adaptive Reprogramming in the {QROM}}, + booktitle = {Advances in Cryptology: {ASIACRYPT} 2021, Part I}, + series = {Lecture Notes in Computer Science}, + volume = {13090}, + pages = {637--667}, + year = {2021}, + doi = {10.1007/978-3-030-92062-3_22}, + url = {https://eprint.iacr.org/2020/1361} +} From 4394e1560841ebc09d90977642b8886ed8fd14b0 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Mon, 24 Aug 2026 15:52:09 +0200 Subject: [PATCH 25/31] formal/sphincs: state the 120-bit claim, and correct both artifacts against a review formal/sphincs states, and does not prove, the classical random-oracle security of the instance of doc/sphincs: `SphincsSecurityStatement`, which reads `HasClassicalSecurityBits Concrete.scheme 120`. Everything the claim depends on is in SphincsSecurity/Statement.lean, following formal/xmss: the parameters and types, the byte layout of every hash input, the target-sum code, Gen, Sig and Ver as oracle computations, then the game. The hash is a random oracle throughout; nothing instantiates it. Three things differ from the XMSS statement, all because this scheme is stateless. A signing request is a message with no epoch, so what the game caps is the number of signing queries. Signing is randomized, so a message has many valid signatures and the game rejects only one the signer actually returned, which is what makes this a strong unforgeability claim. And the secret key holds the sampled secrets rather than precomputed tables, because Gen builds only layer 0 and a replay cache would answer for queries it never made, so signing rebuilds whatever tree it reads. What the parameters fix about the layout is proven rather than asserted, next to the definitions it concerns: the index decomposition for all 2^26 indices, and the authentication path, which is where the flattening could have broken the scheme silently. The claim is 120 and not 128 because the bound is a slope q/2^120 and every strategy costs 2^-128 per query, leaving 2^8 for the union bounds and constants a proof accumulates. Three adversarial reviews found no attack and no vacuity: the summed per-query slopes are 2^-127.96, tweak injectivity holds over all ten families so no multi-target factor exists, and an explicit admissible digest witnesses that Ver accepts. They corrected: - the comparison to FIPS 205, whose default is the hedged variant and which derives R rather than sampling it, where the remark had it backwards; - two "exactly when" claims, one-directional: a second admissible counter for the same codeword also recovers the leaf, so unforgeability on a signed message rests on collision resistance at tw_enc rather than on incomparability; - the query count, now bounded on every execution path, with 2^58 named as where the claim is read; - the win condition, which said "the signer's answer" for a message that can be signed more than once; - k, labelled digest index groups rather than trees, and the node tweak ranges, which start at level 1; - the refold count above the cached level, now an upper bound, 57 being exact since the triangle's root is never on a path; - the few-time leak, a per-query slope of 2^-133.3 rather than a q-independent term, which is what fixes 2^24 signatures: it reaches 2^-120 at 2^26.4. README.md records the two places a proof can go wrong, both found by attacking the claim rather than reading it: the strong forgery branch above, and the one-step-lowering event, which has 1258 exploitable codewords and so runs at 2^-117.7 per encoding query, above the slope, and is a forgery only conjoined with a chain inversion. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 1 + doc/sphincs/main.tex | 29 +- doc/sphincs/refs.bib | 8 + formal/sphincs/.gitignore | 1 + formal/sphincs/README.md | 39 + formal/sphincs/SphincsSecurity.lean | 15 + formal/sphincs/SphincsSecurity/Statement.lean | 868 ++++++++++++++++++ formal/sphincs/lake-manifest.json | 126 +++ formal/sphincs/lakefile.toml | 11 + formal/sphincs/lean-toolchain | 1 + 10 files changed, 1085 insertions(+), 14 deletions(-) create mode 100644 formal/sphincs/.gitignore create mode 100644 formal/sphincs/README.md create mode 100644 formal/sphincs/SphincsSecurity.lean create mode 100644 formal/sphincs/SphincsSecurity/Statement.lean create mode 100644 formal/sphincs/lake-manifest.json create mode 100644 formal/sphincs/lakefile.toml create mode 100644 formal/sphincs/lean-toolchain diff --git a/AGENTS.md b/AGENTS.md index e5c7bae88..b0b0a37ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ A minimal (zero-knowledge Virtual Machine, which is actually not ZK in the real - `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`. It shares XMSS's hash function, tweakable hash and target-sum code, so an aggregator implements one primitive. - `doc/sphincs/params_selection/` is a parameter-exploration tool for SPHINCS+ (security, signature size, hash counts, and a search for the cheapest verification under given budgets), in its own cargo workspace with no dependencies. It models the schemes of the Blockstream report, not anything this repo proves, and its `cargo test --release` pins every number against that project's sage fixtures. +- `formal/xmss/` is a Lean 4 proof (over VCVio) of that instance's classical random-oracle security, `xmss_has_127_bits_of_classical_security`, and `formal/sphincs/` states the same kind of claim for the SPHINCS instance at 120 bits, with no proof yet. In both, `*/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`. - 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). diff --git a/doc/sphincs/main.tex b/doc/sphincs/main.tex index f0e0aaeb1..6f9a22538 100644 --- a/doc/sphincs/main.tex +++ b/doc/sphincs/main.tex @@ -14,7 +14,6 @@ \theoremstyle{definition} \newtheorem{definition}{Definition}[section] \theoremstyle{plain} -\newtheorem{claim}[definition]{Claim} \theoremstyle{remark} \newtheorem{remark}[definition]{Remark} @@ -80,7 +79,7 @@ \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. +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} @@ -114,7 +113,7 @@ \section{Definitions and notation} $(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$ & few-time trees\\ +$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\\ @@ -142,16 +141,16 @@ \section{Definitions and notation} \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`, the index bits below layer `lay`. -/ +def heightBelow (lay : Layer) : Nat := totalHeight - heightAbove lay - layerHeight lay + +example : ∑ lay : Layer, layerHeight lay = totalHeight := by decide + +example : (layerHeight topLayer, layerHeight middleLayer, layerHeight bottomLayer) = (12, 7, 7) := by + decide + +example : (heightAbove topLayer, heightAbove middleLayer, heightAbove bottomLayer) = (0, 12, 19) := by + decide + +example : (heightBelow topLayer, heightBelow middleLayer, heightBelow bottomLayer) = (14, 7, 0) := by + decide + +theorem layerHeight_le (lay : Layer) : layerHeight lay ≤ maxLayerHeight := by + unfold layerHeight maxLayerHeight + split <;> omega + +/-- Keep the first 128 output bits, the low bits of the little-endian bit vector. -/ +def truncateHash (output : HashOutput) : Digest := + output.extractLsb' 0 digestBits + +/-- The message digest is `h + k * a = 176` bits, an index and `k` leaf indices. -/ +def messageDigestBits : Nat := totalHeight + ftsTrees * ftsTreeHeight + +abbrev MessageDigest := BitVec messageDigestBits + +/-- The digest is `h + k * a = 176` bits and has to fit in one oracle output. -/ +example : messageDigestBits = 176 ∧ messageDigestBits ≤ hashOutputBits := by decide + +def truncateMessageDigest (output : HashOutput) : MessageDigest := + output.extractLsb' 0 messageDigestBits + +structure PublicKey where + root : Digest + parameter : PublicParameter +deriving DecidableEq + +/-- The key of the specification: the public parameter, the layer-`0` root that every digest binds, and every sampled secret. `Gen` samples them independently and uniformly; the seed derivation of the specification is an implementation of this key, not this key. -/ +structure SecretKey where + parameter : PublicParameter + root : Digest + otsSecret : Layer → TreeIndex → LeafIndex → ChainIndex → Digest + ftsSecret : Index → FtsTree → FtsLeaf → Digest + +/-- A signature, with every component the verifier reads and no other: the randomizer, one few-time secret and its `a` path nodes per held tree, and per layer a counter, `v` chain values, and its share of the `h` path nodes. That is `16 + 14 * 16 + 140 * 16 + 3 * 4 + 126 * 16 + 26 * 16 = 4924` bytes. -/ +structure Signature where + randomness : Randomness + ftsSecret : FtsTree → Digest + ftsPath : FtsTree → Fin ftsTreeHeight → Digest + counter : Layer → Counter + chainValue : Layer → ChainIndex → Digest + authPath : PathIndex → Digest +deriving DecidableEq + +/-- Serialize a bit vector into a fixed number of bytes, least significant byte first. -/ +def bytesLE (byteCount : Nat) (value : BitVec (8 * byteCount)) : List UInt8 := + List.ofFn fun index : Fin byteCount => + UInt8.ofBitVec (value.extractLsb' (8 * index.val) 8) + +structure TweakFields where + tag : BitVec 8 + layer : BitVec 8 + tree : BitVec 32 + position : BitVec 32 + index : BitVec 32 +deriving DecidableEq + +/-- The specification's 16 tweak bytes `tag || layer || tree || position || index || 0^2`, each field serialized least significant byte first. -/ +def fieldBytes (fields : TweakFields) : HashInput := + bytesLE 1 fields.tag ++ bytesLE 1 fields.layer ++ bytesLE 4 fields.tree ++ + bytesLE 4 fields.position ++ bytesLE 4 fields.index ++ List.replicate 2 0 + +/-- Every domain-separated hash call the instance makes. Tweak types `0` and `5` of the specification are absent: they belong to the seed derivation, and this key samples its secrets. -/ +inductive HashDomain where + | chain (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) (chainIdx : ChainIndex) (step : ChainStep) + | leaf (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) + | node (lay : Layer) (tree : TreeIndex) (level : Nat) (nodeIdx : Nat) + | encoding (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) + | ftsLeaf (index : Index) (tree : FtsTree) (leaf : FtsLeaf) + | ftsNode (index : Index) (tree : FtsTree) (level : Nat) (nodeIdx : Nat) + | ftsRoots (index : Index) + | message +deriving DecidableEq + +/-- Serialize a typed hash domain into the fields of a tweak. Inside the hypertree the layer field is the layer and the tree field the tree; inside a few-time key they are the tree of the forest and the index that selects the instance. -/ +def hashDomainFields : HashDomain → TweakFields + | .chain lay tree leaf chainIdx step => + ⟨1#8, BitVec.ofNat 8 lay.val, BitVec.ofNat 32 tree.val, + BitVec.ofNat 32 (chainLength * chainIdx.val + step.val), BitVec.ofNat 32 leaf.val⟩ + | .leaf lay tree leaf => + ⟨2#8, BitVec.ofNat 8 lay.val, BitVec.ofNat 32 tree.val, 0#32, BitVec.ofNat 32 leaf.val⟩ + | .node lay tree level nodeIdx => + ⟨3#8, BitVec.ofNat 8 lay.val, BitVec.ofNat 32 tree.val, + BitVec.ofNat 32 level, BitVec.ofNat 32 nodeIdx⟩ + | .encoding lay tree leaf => + ⟨4#8, BitVec.ofNat 8 lay.val, BitVec.ofNat 32 tree.val, 0#32, BitVec.ofNat 32 leaf.val⟩ + | .ftsLeaf index tree leaf => + ⟨6#8, BitVec.ofNat 8 tree.val, BitVec.ofNat 32 index.val, 0#32, BitVec.ofNat 32 leaf.val⟩ + | .ftsNode index tree level nodeIdx => + ⟨7#8, BitVec.ofNat 8 tree.val, BitVec.ofNat 32 index.val, + BitVec.ofNat 32 level, BitVec.ofNat 32 nodeIdx⟩ + | .ftsRoots index => ⟨8#8, 0#8, BitVec.ofNat 32 index.val, 0#32, 0#32⟩ + | .message => ⟨9#8, 0#8, 0#32, 0#32, 0#32⟩ + +/-- The exact 16 bytes supplied by the specification as a hash tweak. -/ +def tweakBytes (domain : HashDomain) : HashInput := + fieldBytes (hashDomainFields domain) + +/-- The random-oracle input `tweak || parameter || message` used by every tweakable hash call and by the message digest. -/ +def tweakableHashInput (parameter : PublicParameter) (domain : HashDomain) + (message : HashInput) : HashInput := + tweakBytes domain ++ bytesLE 16 parameter ++ message + +/-! ### The target-sum code + +`v = 42` chunks of `w = 3` bits, 21 in each half of the digest, one pinned bit per half, and the code is the words of digit sum `T = 191`. Two distinct words of equal sum are incomparable, which is what removes the Winternitz checksum and forces the counter. -/ + +namespace TargetSum + +def sum (x : Encoding) : Nat := ∑ i, (x i).val + +def Valid (x : Encoding) : Prop := sum x = targetSum + +instance : DecidablePred Valid := + fun x => inferInstanceAs (Decidable (sum x = targetSum)) + +def digitsPerHalf : Nat := numChains / 2 + +/-- Offset of a three-bit digit, skipping padding bits 63 and 127. -/ +def digitOffset (i : ChainIndex) : Nat := + winternitzBits * i.val + if i.val < digitsPerHalf then 0 else 1 + +def digestEncoding (digest : Digest) : Encoding := + fun i => (digest.extractLsb' (digitOffset i) winternitzBits).toFin + +/-- Decode the concrete little-endian layout: 21 three-bit digits, padding bit 63, 21 digits, and padding bit 127. A digest decodes exactly when both padding bits are clear and the digits reach the target sum. -/ +def decodeDigest (digest : Digest) : Option Encoding := + if digest.getLsbD 63 = false ∧ digest.getLsbD 127 = false ∧ Valid (digestEncoding digest) + then some (digestEncoding digest) else none + +end TargetSum + +/-! ## The algorithms + +Key generation, signing and verification exactly as run in the experiment, together with the oracle hash calls they make. Key generation samples the parameter and every secret and builds layer `0`'s tree; signing rebuilds whatever tree it reads rather than caching anything, as specified, so the honest experiment spends `2^44.5` hash queries of its own and its worst-case path, which is what the query bound counts, `2^58`; verification is the ordinary verifier. + +The `irreducible` attributes only seal definitions against accidental unfolding in proofs. Lean restricts global reducibility attributes to the defining module, so they must appear here. -/ + +/-- A hash query takes an arbitrary byte string and returns 32 bytes. -/ +abbrev HashSpec := HashInput →ₒ HashOutput + +/-- `unifSpec` for uniform sampling, `HashSpec` for the random oracle. A query is `.inl` to sample or `.inr` to hash, so `HasHashQueryBound` counts only the hash side. -/ +abbrev OracleWorld := unifSpec + HashSpec + +namespace Concrete + +def digestBytes (value : Digest) : HashInput := bytesLE 16 value + +def messageBytes (message : Message) : HashInput := bytesLE 32 message + +def randomnessBytes (randomness : Randomness) : HashInput := bytesLE 16 randomness + +def counterBytes (counter : Counter) : HashInput := bytesLE 4 counter + +def oracleHash {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (input : HashInput) : m HashOutput := + HasQuery.query (spec := HashSpec) (m := m) input + +def tweakableHash {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (domain : HashDomain) (payload : HashInput) : m Digest := do + let output ← oracleHash (tweakableHashInput parameter domain payload) + return truncateHash output + +def sequenceFin {m : Type → Type} [Monad m] {α : Type} {n : Nat} + (computation : Fin n → m α) : m (Fin n → α) := + match n with + | 0 => pure Fin.elim0 + | n + 1 => do + let head ← computation 0 + let tail ← sequenceFin fun index : Fin n => computation index.succ + return Fin.cases head tail + +/-- Turn a family of optional results into an optional family: the specification's `Sig` returns nothing as soon as one layer fails. -/ +def traverseOption {α : Type} {n : Nat} (family : Fin n → Option α) : Option (Fin n → α) := + match n with + | 0 => some Fin.elim0 + | n + 1 => + match family 0, traverseOption fun index : Fin n => family index.succ with + | some head, some tail => some (Fin.cases head tail) + | _, _ => none + +/-! ### The index -/ + +/-- `tau_lay = floor(idx / 2^(sum_{j >= lay} h_j))`. -/ +def treeIndexAt (index : Index) (lay : Layer) : TreeIndex := + ⟨index.val / 2 ^ (totalHeight - heightAbove lay), + Nat.lt_of_le_of_lt (Nat.div_le_self _ _) index.isLt⟩ + +/-- `e_lay = floor(idx / 2^(sum_{j > lay} h_j)) mod 2^h_lay`. -/ +def leafIndexAt (index : Index) (lay : Layer) : LeafIndex := + ⟨index.val / 2 ^ heightBelow lay % 2 ^ layerHeight lay, by + have hmod : index.val / 2 ^ heightBelow lay % 2 ^ layerHeight lay < 2 ^ layerHeight lay := + Nat.mod_lt _ (Nat.two_pow_pos _) + have hpow : 2 ^ layerHeight lay ≤ 2 ^ maxLayerHeight := + Nat.pow_le_pow_right (by omega) (layerHeight_le lay) + omega⟩ + +theorem treeIndexAt_val (index : Index) (lay : Layer) : + (treeIndexAt index lay).val = index.val / 2 ^ (totalHeight - heightAbove lay) := rfl + +theorem leafIndexAt_val (index : Index) (lay : Layer) : + (leafIndexAt index lay).val = index.val / 2 ^ heightBelow lay % 2 ^ layerHeight lay := rfl + +/-- Layer `0` holds a single tree, the public key's. -/ +theorem treeIndexAt_topLayer (index : Index) : (treeIndexAt index topLayer).val = 0 := by + have hlt : index.val < 2 ^ 26 := index.isLt + have h0 : totalHeight - heightAbove topLayer = 26 := by decide + simp only [treeIndexAt_val, h0] + omega + +/-- The layers link: the tree used on a layer is the one whose root sits at leaf `e_(lay-1)` of the +tree used on the layer above. -/ +theorem layers_link_top (index : Index) : + (treeIndexAt index middleLayer).val + = (treeIndexAt index topLayer).val * 2 ^ layerHeight topLayer + + (leafIndexAt index topLayer).val := by + have hlt : index.val < 2 ^ 26 := index.isLt + have h0 : totalHeight - heightAbove topLayer = 26 := by decide + have h1 : totalHeight - heightAbove middleLayer = 14 := by decide + have hb : heightBelow topLayer = 14 := by decide + have hh : layerHeight topLayer = 12 := by decide + simp only [treeIndexAt_val, leafIndexAt_val, h0, h1, hb, hh] + omega + +theorem layers_link_middle (index : Index) : + (treeIndexAt index bottomLayer).val + = (treeIndexAt index middleLayer).val * 2 ^ layerHeight middleLayer + + (leafIndexAt index middleLayer).val := by + have h1 : totalHeight - heightAbove middleLayer = 14 := by decide + have h2 : totalHeight - heightAbove bottomLayer = 7 := by decide + have hb : heightBelow middleLayer = 7 := by decide + have hh : layerHeight middleLayer = 7 := by decide + simp only [treeIndexAt_val, leafIndexAt_val, h1, h2, hb, hh] + omega + +/-- The bottom layer's leaves are the `2^h` indices themselves. -/ +theorem leafIndexAt_bottomLayer (index : Index) : + (leafIndexAt index bottomLayer).val = index.val % 2 ^ layerHeight bottomLayer := by + have hb : heightBelow bottomLayer = 0 := by decide + simp [leafIndexAt_val, hb] + +/-! ### The one-time signature -/ + +def leafOfNat (value : Nat) : LeafIndex := + ⟨value % 2 ^ maxLayerHeight, Nat.mod_lt _ (Nat.two_pow_pos _)⟩ + +/-- `Chain_{lay,tau,e,i}(P, start, steps, value)`: the step onto position `start + steps + 1` carries tweak position `2^w * i + start + steps`. -/ +def chainWalk {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) + (chainIdx : ChainIndex) : Nat → Nat → Digest → m Digest + | _, 0, value => pure value + | start, steps + 1, value => do + let previous ← chainWalk parameter lay tree leaf chainIdx start steps value + if hstep : start + steps < chainLength - 1 then + tweakableHash parameter (.chain lay tree leaf chainIdx ⟨start + steps, hstep⟩) + (digestBytes previous) + else + pure 0 + +/-- The verifier's half of a chain: walk the remaining `2^w - 1 - x_i` steps. -/ +def recoverChain {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) + (chainIdx : ChainIndex) (digit : Digit) (value : Digest) : m Digest := + chainWalk parameter lay tree leaf chainIdx digit.val (chainLength - 1 - digit.val) value + +def oneTimePublicKey {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) + (secret : ChainIndex → Digest) : m (ChainIndex → Digest) := + sequenceFin fun chainIdx => + chainWalk parameter lay tree leaf chainIdx 0 (chainLength - 1) (secret chainIdx) + +def leafPayload (endpoints : ChainIndex → Digest) : HashInput := + (List.ofFn endpoints).flatMap digestBytes + +def leafHash {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) + (endpoints : ChainIndex → Digest) : m Digest := + tweakableHash parameter (.leaf lay tree leaf) (leafPayload endpoints) + +/-- `Enc(P, lay, tau, e, M, c)`: hash the message with the counter under the leaf's encoding tweak, and decode. -/ +def encode {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) + (message : Digest) (counter : Counter) : m (Option Encoding) := do + let digest ← tweakableHash parameter (.encoding lay tree leaf) + (digestBytes message ++ counterBytes counter) + return TargetSum.decodeDigest digest + +/-- `OtsSign`: the least admissible counter, and the chain values it dictates. The search starts at `0` and stops after `encodingAttemptLimit` counters. -/ +def otsSignFrom {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) + (secret : ChainIndex → Digest) (message : Digest) : + Nat → Nat → m (Option (Counter × (ChainIndex → Digest))) + | 0, _ => pure none + | attempts + 1, counter => do + match ← encode parameter lay tree leaf message (BitVec.ofNat counterBits counter) with + | some encoding => do + let values ← sequenceFin fun chainIdx => + chainWalk parameter lay tree leaf chainIdx 0 (encoding chainIdx).val (secret chainIdx) + return some (BitVec.ofNat counterBits counter, values) + | none => otsSignFrom parameter lay tree leaf secret message attempts (counter + 1) + +def otsSign {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) + (secret : ChainIndex → Digest) (message : Digest) : + m (Option (Counter × (ChainIndex → Digest))) := + otsSignFrom parameter lay tree leaf secret message encodingAttemptLimit 0 + +/-- `OtsLeaf`: the verifier's leaf, or nothing if the counter does not encode the message. -/ +def otsLeaf {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) + (message : Digest) (counter : Counter) (values : ChainIndex → Digest) : m (Option Digest) := do + match ← encode parameter lay tree leaf message counter with + | none => pure none + | some encoding => do + let endpoints ← sequenceFin fun chainIdx => + recoverChain parameter lay tree leaf chainIdx (encoding chainIdx) (values chainIdx) + let value ← leafHash parameter lay tree leaf endpoints + return some value + +/-! ### A layer -/ + +def nodePayload (left right : Digest) : HashInput := + digestBytes left ++ digestBytes right + +/-- `X^{lay,tau}_{level,nodeIdx}`, the Merkle tree over the layer's one-time leaves. -/ +def treeNode {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) + (secret : LeafIndex → ChainIndex → Digest) : Nat → Nat → m Digest + | 0, nodeIdx => do + let leaf := leafOfNat nodeIdx + let endpoints ← oneTimePublicKey parameter lay tree leaf (secret leaf) + leafHash parameter lay tree leaf endpoints + | level + 1, nodeIdx => do + let left ← treeNode parameter lay tree secret level (2 * nodeIdx) + let right ← treeNode parameter lay tree secret level (2 * nodeIdx + 1) + tweakableHash parameter (.node lay tree (level + 1) nodeIdx) (nodePayload left right) + +attribute [irreducible] treeNode + +def treeRoot {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) + (secret : LeafIndex → ChainIndex → Digest) : m Digest := + treeNode parameter lay tree secret (layerHeight lay) 0 + +/-- `TreePath`: `A_level = X^{lay,tau}_{level, floor(e / 2^level) xor 1}` for the layer's own `h_lay` levels, and nothing above them. -/ +def treePath {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) + (secret : LeafIndex → ChainIndex → Digest) (leaf : LeafIndex) : m (Fin maxLayerHeight → Digest) := + sequenceFin fun level => + if level.val < layerHeight lay then + treeNode parameter lay tree secret level (Nat.xor (leaf.val / 2 ^ level.val) 1) + else + pure 0 + +/-- `TreeFold`: fold a leaf and a path into the layer's root. -/ +def treeFold {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) + (path : Nat → Digest) : Nat → Digest → m Digest + | 0, value => pure value + | levels + 1, value => do + let current ← treeFold parameter lay tree leaf path levels value + let sibling := path levels + let nodeIdx := leaf.val / 2 ^ (levels + 1) + if leaf.val.testBit levels then + tweakableHash parameter (.node lay tree (levels + 1) nodeIdx) (nodePayload sibling current) + else + tweakableHash parameter (.node lay tree (levels + 1) nodeIdx) (nodePayload current sibling) + +/-! ### The few-time signature -/ + +def ftsLeafOfNat (value : Nat) : FtsLeaf := + ⟨value % 2 ^ ftsTreeHeight, Nat.mod_lt _ (Nat.two_pow_pos _)⟩ + +/-- The index group of the digest that selects this tree's leaf. -/ +def ftsIndexOf (tree : FtsTree) : DigestTree := + tree.castLE (Nat.sub_le ftsTrees 1) + +/-- The last index group, the one the digest is resampled to zero and the verifier checks. Its tree is the dropped one. -/ +def lastDigestTree : DigestTree := ⟨ftsTrees - 1, by decide⟩ + +def ftsLeafHash {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (index : Index) (tree : FtsTree) (leaf : FtsLeaf) + (secret : Digest) : m Digest := + tweakableHash parameter (.ftsLeaf index tree leaf) (digestBytes secret) + +/-- `Y^{idx,kappa}_{level,nodeIdx}`, one tree of the forest. -/ +def ftsNode {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (index : Index) (tree : FtsTree) + (secret : FtsLeaf → Digest) : Nat → Nat → m Digest + | 0, nodeIdx => do + let leaf := ftsLeafOfNat nodeIdx + ftsLeafHash parameter index tree leaf (secret leaf) + | level + 1, nodeIdx => do + let left ← ftsNode parameter index tree secret level (2 * nodeIdx) + let right ← ftsNode parameter index tree secret level (2 * nodeIdx + 1) + tweakableHash parameter (.ftsNode index tree (level + 1) nodeIdx) (nodePayload left right) + +attribute [irreducible] ftsNode + +def ftsRootsPayload (roots : FtsTree → Digest) : HashInput := + (List.ofFn roots).flatMap digestBytes + +/-- `FtsKey(P, idx)`, the hash of the forest's `k - 1` roots. -/ +def ftsKey {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (index : Index) + (secret : FtsTree → FtsLeaf → Digest) : m Digest := do + let roots ← sequenceFin fun tree => + ftsNode parameter index tree (secret tree) ftsTreeHeight 0 + tweakableHash parameter (.ftsRoots index) (ftsRootsPayload roots) + +/-- `FtsOpen`: the opened secrets and, per tree, the `a` siblings of the opened leaf. -/ +def ftsOpen {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (index : Index) (leaves : DigestTree → FtsLeaf) + (secret : FtsTree → FtsLeaf → Digest) : m (FtsTree → Fin ftsTreeHeight → Digest) := + sequenceFin fun tree => + sequenceFin fun level => + ftsNode parameter index tree (secret tree) level.val + (Nat.xor ((leaves (ftsIndexOf tree)).val / 2 ^ level.val) 1) + +/-- The verifier's half of one few-time tree. -/ +def ftsFold {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (index : Index) (tree : FtsTree) (leaf : FtsLeaf) + (path : Fin ftsTreeHeight → Digest) : Nat → Digest → m Digest + | 0, value => pure value + | levels + 1, value => do + let current ← ftsFold parameter index tree leaf path levels value + let sibling := if hlevel : levels < ftsTreeHeight then path ⟨levels, hlevel⟩ else 0 + let nodeIdx := leaf.val / 2 ^ (levels + 1) + if leaf.val.testBit levels then + tweakableHash parameter (.ftsNode index tree (levels + 1) nodeIdx) + (nodePayload sibling current) + else + tweakableHash parameter (.ftsNode index tree (levels + 1) nodeIdx) + (nodePayload current sibling) + +/-- `FtsRec`: recover the few-time public key from the opened secrets and paths. -/ +def ftsRecover {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (index : Index) (leaves : DigestTree → FtsLeaf) + (secrets : FtsTree → Digest) (paths : FtsTree → Fin ftsTreeHeight → Digest) : m Digest := do + let roots ← sequenceFin fun tree => do + let leaf := leaves (ftsIndexOf tree) + let value ← ftsLeafHash parameter index tree leaf (secrets tree) + ftsFold parameter index tree leaf (paths tree) ftsTreeHeight value + tweakableHash parameter (.ftsRoots index) (ftsRootsPayload roots) + +/-! ### The message digest -/ + +def messageDigestPayload (root : Digest) (message : Message) (randomness : Randomness) : HashInput := + randomnessBytes randomness ++ digestBytes root ++ messageBytes message + +/-- `Digest(P, root, m, rho)`, truncated to `h + k * a` bits. -/ +def messageDigest {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (root : Digest) (message : Message) + (randomness : Randomness) : m MessageDigest := do + let output ← oracleHash + (tweakableHashInput parameter .message (messageDigestPayload root message randomness)) + return truncateMessageDigest output + +/-- `idx = N mod 2^h`. -/ +def digestIndex (digest : MessageDigest) : Index := + (digest.extractLsb' 0 totalHeight).toFin + +/-- `u_kappa = floor(N / 2^(h + kappa * a)) mod 2^a`. -/ +def digestLeaves (digest : MessageDigest) : DigestTree → FtsLeaf := + fun tree => (digest.extractLsb' (totalHeight + ftsTreeHeight * tree.val) ftsTreeHeight).toFin + +/-- A digest is admissible exactly when its last index group is zero. -/ +def Admissible (digest : MessageDigest) : Prop := digestLeaves digest lastDigestTree = 0 + +instance (digest : MessageDigest) : Decidable (Admissible digest) := + inferInstanceAs (Decidable (digestLeaves digest lastDigestTree = 0)) + +/-! ### Verification -/ + +/-- Layer `lay`'s share of the signature's authentication path, its `h_lay` nodes starting at offset `sum_{j < lay} h_j`. -/ +def signaturePath (signature : Signature) (lay : Layer) (level : Nat) : Digest := + if hlevel : heightAbove lay + level < totalHeight then + signature.authPath ⟨heightAbove lay + level, hlevel⟩ + else + 0 + +/-- The hypertree walk, from the bottom layer up: `remaining + 1` enters at layer `remaining`, and layer `0`'s fold returns the value compared against the public root. -/ +def verifyLayers {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (parameter : PublicParameter) (index : Index) (signature : Signature) : + Nat → Digest → m (Option Digest) + | 0, message => pure (some message) + | remaining + 1, message => do + if hlayer : remaining < numLayers then + let lay : Layer := ⟨remaining, hlayer⟩ + let tree := treeIndexAt index lay + let leaf := leafIndexAt index lay + match ← otsLeaf parameter lay tree leaf message (signature.counter lay) + (signature.chainValue lay) with + | none => pure none + | some value => do + let root ← treeFold parameter lay tree leaf (signaturePath signature lay) + (layerHeight lay) value + verifyLayers parameter index signature remaining root + else + pure none + +def verify {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (publicKey : PublicKey) (message : Message) (signature : Signature) : m Bool := do + let digest ← messageDigest publicKey.parameter publicKey.root message signature.randomness + if ¬ Admissible digest then + return false + else + let index := digestIndex digest + let ftsPublicKey ← ftsRecover publicKey.parameter index (digestLeaves digest) + signature.ftsSecret signature.ftsPath + match ← verifyLayers publicKey.parameter index signature numLayers ftsPublicKey with + | none => return false + | some root => return decide (root = publicKey.root) + +attribute [irreducible] verify + +/-! ### Key generation -/ + +noncomputable local instance : SampleableType PublicParameter := + SampleableType.ofFintype PublicParameter + +noncomputable local instance : + SampleableType (Layer → TreeIndex → LeafIndex → ChainIndex → Digest) := + SampleableType.ofFintype (Layer → TreeIndex → LeafIndex → ChainIndex → Digest) + +noncomputable local instance : SampleableType (Index → FtsTree → FtsLeaf → Digest) := + SampleableType.ofFintype (Index → FtsTree → FtsLeaf → Digest) + +noncomputable local instance : SampleableType Randomness := + SampleableType.ofFintype Randomness + +noncomputable def sampleParameter : ProbComp PublicParameter := + $ᵗ PublicParameter + +noncomputable def sampleOtsSecrets : + ProbComp (Layer → TreeIndex → LeafIndex → ChainIndex → Digest) := + $ᵗ (Layer → TreeIndex → LeafIndex → ChainIndex → Digest) + +noncomputable def sampleFtsSecrets : ProbComp (Index → FtsTree → FtsLeaf → Digest) := + $ᵗ (Index → FtsTree → FtsLeaf → Digest) + +noncomputable def sampleRandomness : ProbComp Randomness := + $ᵗ Randomness + +attribute [irreducible] sampleParameter sampleOtsSecrets sampleFtsSecrets sampleRandomness + +def rootTree : TreeIndex := ⟨0, Nat.two_pow_pos _⟩ + +/-- `Gen`: sample the parameter and every secret, and build layer `0`'s tree for the root. The trees below it are built when a signature needs them, so nothing else is computed here. -/ +noncomputable def keygen : OracleComp OracleWorld (PublicKey × SecretKey) := do + let parameter ← liftM sampleParameter + let otsSecret ← liftM sampleOtsSecrets + let ftsSecret ← liftM sampleFtsSecrets + let root ← liftM + (treeRoot parameter topLayer rootTree (otsSecret topLayer rootTree) : + OracleComp HashSpec Digest) + return (⟨root, parameter⟩, ⟨parameter, root, otsSecret, ftsSecret⟩) + +attribute [irreducible] keygen + +/-! ### Signing -/ + +/-- One digest attempt: one hash, keeping the index and the leaf indices if the digest is admissible. -/ +def signAttempt {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (secretKey : SecretKey) (message : Message) (randomness : Randomness) : + m (Option (Index × (DigestTree → FtsLeaf))) := do + let digest ← messageDigest secretKey.parameter secretKey.root message randomness + if Admissible digest then + return some (digestIndex digest, digestLeaves digest) + else + return none + +/-- The digest loop: at most `digestAttemptLimit` attempts, each sampling a fresh randomizer, stopping at the first admissible digest. It takes `2^a` attempts on average. -/ +noncomputable def signDigestLoop : Nat → SecretKey → Message → + OracleComp OracleWorld (Option (Randomness × Index × (DigestTree → FtsLeaf))) + | 0, _secretKey, _message => pure none + | attempts + 1, secretKey, message => do + let randomness ← liftM sampleRandomness + let attempt ← liftM + (signAttempt secretKey message randomness : + OracleComp HashSpec (Option (Index × (DigestTree → FtsLeaf)))) + match attempt with + | some (index, leaves) => pure (some (randomness, index, leaves)) + | none => signDigestLoop attempts secretKey message + +/-- The message layer `lay` signs: the root of the tree below it, or the few-time public key at the bottom. Every layer's message is fixed by the index alone, which is what makes the layers independent. -/ +def layerMessage {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (secretKey : SecretKey) (index : Index) (lay : Layer) : m Digest := + if hbelow : lay.val + 1 < numLayers then + let below : Layer := ⟨lay.val + 1, hbelow⟩ + treeRoot secretKey.parameter below (treeIndexAt index below) + (secretKey.otsSecret below (treeIndexAt index below)) + else + ftsKey secretKey.parameter index (secretKey.ftsSecret index) + +/-- One layer's contribution: its counter, its chain values, and its authentication path. -/ +def signLayer {m : Type → Type} [Monad m] [HasQuery HashSpec m] + (secretKey : SecretKey) (index : Index) (lay : Layer) : + m (Option (Counter × (ChainIndex → Digest) × (Fin maxLayerHeight → Digest))) := do + let tree := treeIndexAt index lay + let leaf := leafIndexAt index lay + let message ← layerMessage secretKey index lay + match ← otsSign secretKey.parameter lay tree leaf (secretKey.otsSecret lay tree leaf) message with + | none => return none + | some (counter, values) => do + let path ← treePath secretKey.parameter lay tree (secretKey.otsSecret lay tree) leaf + return some (counter, values, path) + +/-- Which layer's path an entry of the `h` belongs to. -/ +def layerOfPath (position : Nat) : Layer := + if position < heightAbove middleLayer then topLayer + else if position < heightAbove bottomLayer then middleLayer + else bottomLayer + +/-- Lay the `d` layers' paths end to end, top layer first, so that every one of the `h` entries is read by verification. -/ +def flattenPaths (paths : Layer → Fin maxLayerHeight → Digest) : PathIndex → Digest := + fun position => + let lay := layerOfPath position.val + let level := position.val - heightAbove lay + if hlevel : level < maxLayerHeight then paths lay ⟨level, hlevel⟩ else 0 + +theorem heightAbove_add_layerHeight_le (lay : Layer) : + heightAbove lay + layerHeight lay ≤ totalHeight := by decide +revert + +/-- An entry at a layer's own offset belongs to that layer. -/ +theorem layerOfPath_eq (lay : Layer) (level : Fin maxLayerHeight) (hlevel : level.val < layerHeight lay) : + layerOfPath (heightAbove lay + level.val) = lay := by + revert hlevel + revert level + revert lay + decide + +theorem flattenPaths_apply (paths : Layer → Fin maxLayerHeight → Digest) (lay : Layer) + (level : Fin maxLayerHeight) (hlevel : level.val < layerHeight lay) (position : PathIndex) + (hposition : position.val = heightAbove lay + level.val) : + flattenPaths paths position = paths lay level := by + simp only [flattenPaths, hposition, layerOfPath_eq lay level hlevel, Nat.add_sub_cancel_left, + dif_pos level.isLt, Fin.eta] + +/-- The verifier reads a layer's path node exactly where the signer laid it, so the `h` entries of the authentication path are the `d` layers' paths and nothing else. -/ +theorem signaturePath_flattenPaths (signature : Signature) + (paths : Layer → Fin maxLayerHeight → Digest) (hpath : signature.authPath = flattenPaths paths) + (lay : Layer) (level : Fin maxLayerHeight) (hlevel : level.val < layerHeight lay) : + signaturePath signature lay level.val = paths lay level := by + have hlt : heightAbove lay + level.val < totalHeight := + lt_of_lt_of_le (by omega) (heightAbove_add_layerHeight_le lay) + rw [signaturePath, dif_pos hlt, hpath] + exact flattenPaths_apply paths lay level hlevel _ rfl + +/-- Every one of the `h` entries is read by some layer: the offsets partition `0..h-1` into the `d` layers, so the path carries no entry verification skips and none twice. This is arithmetic about the offsets, not a statement about `verifyLayers`. -/ +theorem authPath_exhausted (position : PathIndex) : ∃ lay : Layer, ∃ level : Fin maxLayerHeight, + level.val < layerHeight lay ∧ heightAbove lay + level.val = position.val := by + revert position + decide + +/-- `Sig(sk, m)`: the digest loop, the few-time opening, one one-time signature per layer, and the assembled signature. -/ +noncomputable def sign (secretKey : SecretKey) (message : Message) : + OracleComp OracleWorld (Option Signature) := do + match ← signDigestLoop digestAttemptLimit secretKey message with + | none => return none + | some (randomness, index, leaves) => do + let ftsPath ← liftM + (ftsOpen secretKey.parameter index leaves (secretKey.ftsSecret index) : + OracleComp HashSpec (FtsTree → Fin ftsTreeHeight → Digest)) + let layers ← liftM + (sequenceFin (fun lay => signLayer secretKey index lay) : + OracleComp HashSpec + (Layer → Option (Counter × (ChainIndex → Digest) × (Fin maxLayerHeight → Digest)))) + match traverseOption layers with + | none => return none + | some parts => + return some + { randomness := randomness + ftsSecret := fun tree => secretKey.ftsSecret index tree (leaves (ftsIndexOf tree)) + ftsPath := ftsPath + counter := fun lay => (parts lay).1 + chainValue := fun lay => (parts lay).2.1 + authPath := flattenPaths fun lay => (parts lay).2.2 } + +attribute [irreducible] sign + +end Concrete + +/-! ## The security experiment -/ + +/-- The random-oracle semantics: hash queries are answered lazily and consistently by uniform sampling and cached; uniform-sampling queries are forwarded unchanged. -/ +noncomputable def romImpl : QueryImpl OracleWorld (StateT (QueryCache HashSpec) ProbComp) := + unifFwdImpl HashSpec + + (randomOracle : QueryImpl HashSpec (StateT (QueryCache HashSpec) ProbComp)) + +/-- A signing request is a message alone: the scheme is stateless, and the signer chooses the index by hashing. -/ +abbrev SignRequest := Message + +/-- A claimed forgery: a message and a signature. -/ +structure Forgery where + message : Message + signature : Signature +deriving DecidableEq + +/-- The interface of a stateless signature scheme in the random-oracle experiment. Signing is randomized and may fail, so it returns an option. -/ +structure Scheme where + keygen : OracleComp OracleWorld (PublicKey × SecretKey) + sign : SecretKey → Message → OracleComp OracleWorld (Option Signature) + verify : PublicKey → Message → Signature → OracleComp OracleWorld Bool + +/-- The signing oracle answers a request with either a signature or `none` if the signer fails. -/ +abbrev SigningSpec := SignRequest →ₒ Option Signature + +/-- A classical adaptive adversary. After receiving the public key, it may query the shared random oracle, request signatures, and finally return a claimed forgery. -/ +structure Adversary where + main : PublicKey → OracleComp (OracleWorld + SigningSpec) Forgery + +namespace SigningTranscript + +/-- A signing transcript is valid exactly when the key signed at most `q_s` messages. Nothing forbids repeating a message: the signer is stateless, and a fresh randomizer makes the second signature a different one. -/ +def Valid (log : QueryLog SigningSpec) : Prop := log.length ≤ signatureLimit + +instance (log : QueryLog SigningSpec) : Decidable (Valid log) := + inferInstanceAs (Decidable (log.length ≤ signatureLimit)) + +/-- The signer returned the claimed forgery exactly when the transcript contains the same message answered by the same signature. A different signature for a signed message is therefore a valid strong forgery. -/ +def Contains (log : QueryLog SigningSpec) (forgery : Forgery) : Prop := + ∃ entry ∈ log, entry.1 = forgery.message ∧ entry.2 = some forgery.signature + +instance (log : QueryLog SigningSpec) (forgery : Forgery) : Decidable (Contains log forgery) := + inferInstanceAs + (Decidable (∃ entry ∈ log, entry.1 = forgery.message ∧ entry.2 = some forgery.signature)) + +end SigningTranscript + +/-- The signing oracle used in the game. It records every request and response while forwarding the request to the scheme's signer. -/ +def signingOracle (scheme : Scheme) (sk : SecretKey) : + QueryImpl SigningSpec (WriterT (QueryLog SigningSpec) (OracleComp OracleWorld)) := + QueryImpl.withLogging fun request => scheme.sign sk request + +/-- Forward the shared random oracle and uniform sampling to the adversary unchanged, alongside the logged signing oracle. -/ +def forwardOracles : + QueryImpl OracleWorld (WriterT (QueryLog SigningSpec) (OracleComp OracleWorld)) := + fun input => liftM (OracleWorld.query input) + +/-- The complete strong-unforgeability experiment. + +The random oracle is sampled lazily by the semantics of `OracleWorld`. Key generation, the adversary, the signing oracle, and final verification all share the same oracle. The game returns `true` precisely when the transcript holds at most `q_s` signatures, the claimed forgery is not one the signer returned for that message, and the signature verifies. -/ +noncomputable def gameCore (scheme : Scheme) (adversary : Adversary) : + OracleComp OracleWorld Bool := do + let (pk, sk) ← scheme.keygen + let ((forgery, log) : Forgery × QueryLog SigningSpec) ← + (simulateQ (forwardOracles + signingOracle scheme sk) (adversary.main pk)).run + let verified ← scheme.verify pk forgery.message forgery.signature + return decide (SigningTranscript.Valid log ∧ ¬SigningTranscript.Contains log forgery) && verified + +/-- The probability that the adversary wins, over key generation, signer randomness, and the random oracle, which starts from the empty cache. The final cache is discarded. -/ +noncomputable def forgeAdvantage (scheme : Scheme) (adversary : Adversary) : ℝ≥0∞ := + Pr[= true | (simulateQ romImpl (gameCore scheme adversary)).run' ∅] + +/-- The whole experiment makes at most `q` random-oracle queries on every execution path. The count includes queries during key generation, adversarial hashing, signing, and final verification. Uniform sampling operations are not hash queries. -/ +def HasHashQueryBound (scheme : Scheme) (adversary : Adversary) (q : Nat) : Prop := + (gameCore scheme adversary).IsQueryBoundP (· matches .inr _) q + +/-- Having `bits` bits of classical security means that every classical adaptive adversary whose complete experiment stays within a nonzero hash-query budget `q` forges with probability at most `q / 2^bits`. The bound is a slope, so it bounds what a query buys and not what the first one does; a budget below what the honest experiment alone spends admits no adversary and the bound is vacuous there. -/ +def HasClassicalSecurityBits (scheme : Scheme) (bits : Nat) : Prop := + ∀ q, 1 ≤ q → ∀ adversary, HasHashQueryBound scheme adversary q → + forgeAdvantage scheme adversary ≤ q / ((2 ^ bits : Nat) : ℝ≥0∞) + +/-- The concrete SPHINCS scheme: key generation, the stateless randomized signer, and the verifier defined above. -/ +noncomputable def Concrete.scheme : Scheme where + keygen := Concrete.keygen + sign := Concrete.sign + verify := fun publicKey message signature => + liftM (Concrete.verify publicKey message signature : OracleComp HashSpec Bool) + +/-- The complete public security claim: `120` bits of classical strong unforgeability in the random-oracle model, at `2^24` signatures per key pair. -/ +abbrev SphincsSecurityStatement : Prop := + HasClassicalSecurityBits Concrete.scheme securityBits + +end SphincsSecurity diff --git a/formal/sphincs/lake-manifest.json b/formal/sphincs/lake-manifest.json new file mode 100644 index 000000000..19314c3ca --- /dev/null +++ b/formal/sphincs/lake-manifest.json @@ -0,0 +1,126 @@ +{"version": "1.2.0", + "packagesDir": ".lake/packages", + "packages": + [{"url": "https://github.com/Verified-zkEVM/VCVio.git", + "type": "git", + "subDir": null, + "scope": "", + "rev": "cbd4144b51d92da00dd50f05e068b2348fa6e529", + "name": "VCVio", + "manifestFile": "lake-manifest.json", + "inputRev": "cbd4144", + "inherited": false, + "configFile": "lakefile.lean"}, + {"url": "https://github.com/Verified-zkEVM/PolyFun.git", + "type": "git", + "subDir": null, + "scope": "", + "rev": "04a12b67fa2048c9412fdd26ed9e446f25919d37", + "name": "PolyFun", + "manifestFile": "lake-manifest.json", + "inputRev": "04a12b67fa2048c9412fdd26ed9e446f25919d37", + "inherited": true, + "configFile": "lakefile.toml"}, + {"url": "https://github.com/leanprover-community/mathlib4", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "fabf563a7c95a166b8d7b6efca11c8b4dc9d911f", + "name": "mathlib", + "manifestFile": "lake-manifest.json", + "inputRev": "v4.31.0", + "inherited": true, + "configFile": "lakefile.lean"}, + {"url": "https://github.com/quangvdao/loom2", + "type": "git", + "subDir": null, + "scope": "", + "rev": "0e11dcf85dd5fbb362bf6a6cafaba5c476ed9333", + "name": "loom2", + "manifestFile": "lake-manifest.json", + "inputRev": "lean-4.31", + "inherited": true, + "configFile": "lakefile.toml"}, + {"url": "https://github.com/leanprover-community/plausible", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "63045536fe95024e6c18fc7b48e03f506701c5bc", + "name": "plausible", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.toml"}, + {"url": "https://github.com/leanprover-community/LeanSearchClient", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "c5d5b8fe6e5158def25cd28eb94e4141ad97c843", + "name": "LeanSearchClient", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.toml"}, + {"url": "https://github.com/leanprover-community/import-graph", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "5c7542ed018c78194f1e2b903eaf6a792b74c03d", + "name": "importGraph", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.toml"}, + {"url": "https://github.com/leanprover-community/ProofWidgets4", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "24b0d9dc081c5423f8eec7e866c441e5184f29d9", + "name": "proofwidgets", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.lean"}, + {"url": "https://github.com/leanprover-community/aesop", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "e3cb2f741431ce31bf73549fb52316a57368b06f", + "name": "aesop", + "manifestFile": "lake-manifest.json", + "inputRev": "master", + "inherited": true, + "configFile": "lakefile.toml"}, + {"url": "https://github.com/leanprover-community/quote4", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "f46324995fca5f0483b742e4eb4daec7f4ee50d2", + "name": "Qq", + "manifestFile": "lake-manifest.json", + "inputRev": "master", + "inherited": true, + "configFile": "lakefile.toml"}, + {"url": "https://github.com/leanprover-community/batteries", + "type": "git", + "subDir": null, + "scope": "leanprover-community", + "rev": "fa08db58b30eb033edcdab331bba000827f9f785", + "name": "batteries", + "manifestFile": "lake-manifest.json", + "inputRev": "main", + "inherited": true, + "configFile": "lakefile.toml"}, + {"url": "https://github.com/leanprover/lean4-cli", + "type": "git", + "subDir": null, + "scope": "leanprover", + "rev": "92564e5770e4d09f2d86dfbf8ada1e9c715b384c", + "name": "Cli", + "manifestFile": "lake-manifest.json", + "inputRev": "v4.31.0", + "inherited": true, + "configFile": "lakefile.toml"}], + "name": "«xmss-security»", + "lakeDir": ".lake", + "fixedToolchain": false} diff --git a/formal/sphincs/lakefile.toml b/formal/sphincs/lakefile.toml new file mode 100644 index 000000000..982fa1d28 --- /dev/null +++ b/formal/sphincs/lakefile.toml @@ -0,0 +1,11 @@ +name = "sphincs-security" +version = "0.1.0" +defaultTargets = ["SphincsSecurity"] + +[[require]] +name = "VCVio" +git = "https://github.com/Verified-zkEVM/VCVio.git" +rev = "cbd4144" + +[[lean_lib]] +name = "SphincsSecurity" diff --git a/formal/sphincs/lean-toolchain b/formal/sphincs/lean-toolchain new file mode 100644 index 000000000..18640c8b0 --- /dev/null +++ b/formal/sphincs/lean-toolchain @@ -0,0 +1 @@ +leanprover/lean4:v4.31.0 From e77fc2e0e563298057178d7233b756bf47e35893 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Mon, 24 Aug 2026 17:21:33 +0200 Subject: [PATCH 26/31] crates/sphincs: implement the specified instance Gen, Sig and Ver of doc/sphincs/main.tex, as a leaf crate over primitives::hash, with no attempt yet to share the tweakable hash and target-sum code with `xmss`. Secrets are the seed-derived implementation of the specification's "Seed derivation" remark, and a signer holds the 1024-byte layer-0 cache of its "Signer state" remark: 64 nodes at depth 6, one 64-leaf subtree rebuilt below them and 63 refolded above them per signature. Signing never builds layer 0 whole, so key generation is the only place that does, and the only place with any fan-out. An instrumented build, not committed, counts the hash calls the cost table claims: key generation 1384447 exactly (2^12 * 337 + 2^12 - 1), verification 497 exactly on every signature, signing 186625 on average over 40 signatures against the 189547 the table predicts, the gap being one draw of three geometric counter searches of mean 12436. The ignored `grinding_bits` test measures the two grinding loops at 2^13.61 and 2^9.98 attempts, against the specified 2^13.60 and 2^10. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 3 +- Cargo.lock | 9 + Cargo.toml | 1 + crates/sphincs/Cargo.toml | 12 + crates/sphincs/src/fts.rs | 86 ++++++ crates/sphincs/src/hash.rs | 56 ++++ crates/sphincs/src/lib.rs | 97 ++++++ crates/sphincs/src/ots.rs | 103 +++++++ crates/sphincs/src/sphincs.rs | 411 ++++++++++++++++++++++++++ crates/sphincs/tests/sphincs_tests.rs | 171 +++++++++++ 10 files changed, 948 insertions(+), 1 deletion(-) create mode 100644 crates/sphincs/Cargo.toml create mode 100644 crates/sphincs/src/fts.rs create mode 100644 crates/sphincs/src/hash.rs create mode 100644 crates/sphincs/src/lib.rs create mode 100644 crates/sphincs/src/ots.rs create mode 100644 crates/sphincs/src/sphincs.rs create mode 100644 crates/sphincs/tests/sphincs_tests.rs diff --git a/AGENTS.md b/AGENTS.md index b0b0a37ab..ab669d9fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ 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`. It shares XMSS's hash function, tweakable hash and target-sum code, so an aggregator implements one primitive. +- `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. - `doc/sphincs/params_selection/` is a parameter-exploration tool for SPHINCS+ (security, signature size, hash counts, and a search for the cheapest verification under given budgets), in its own cargo workspace with no dependencies. It models the schemes of the Blockstream report, not anything this repo proves, and its `cargo test --release` pins every number against that project's sage fixtures. - `formal/xmss/` is a Lean 4 proof (over VCVio) of that instance's classical random-oracle security, `xmss_has_127_bits_of_classical_security`, and `formal/sphincs/` states the same kind of claim for the SPHINCS instance at 120 bits, with no proof yet. In both, `*/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`. - 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. @@ -31,6 +31,7 @@ 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` | +| `sphincs` | the stateless SPHINCS+ instance of `doc/sphincs`; an independent leaf, not yet consumed | | `rec_aggregation` | recursive XMSS aggregation: the one guest, the public API, the benchmarks | `src/main.rs` is the CLI; guests are zkDSL under `crates/rec_aggregation/guests/`. diff --git a/Cargo.lock b/Cargo.lock index 302b6d94e..8fe9c39d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -448,6 +448,15 @@ 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", +] + [[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/crates/sphincs/Cargo.toml b/crates/sphincs/Cargo.toml new file mode 100644 index 000000000..4ce4fe7be --- /dev/null +++ b/crates/sphincs/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "sphincs" +version.workspace = true +edition.workspace = true + +[lints] +workspace = true + +[dependencies] +primitives.workspace = true +parallel.workspace = true +rand.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..dc7361801 --- /dev/null +++ b/crates/sphincs/src/sphincs.rs @@ -0,0 +1,411 @@ +//! 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 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] +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +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. +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" + ); +} From b455b7804c036af3067b63ec3d2bac61e6760efe Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Tue, 25 Aug 2026 15:08:10 +0200 Subject: [PATCH 27/31] sphincs aggregation --- .github/workflows/doc.yml | 8 + AGENTS.md | 13 +- Cargo.lock | 2 + README.md | 78 +- crates/flock/src/lib.rs | 2 +- .../lean_compiler/tests/suite/inline_expr.rs | 2 +- crates/lean_compiler/tests/suite/pack64x2.rs | 2 +- .../lean_compiler/tests/suite/print_debug.rs | 2 +- crates/lean_compiler/tests/suite/stack_buf.rs | 2 +- crates/lean_compiler/tests/suite/vm_proofs.rs | 2 +- crates/lean_compiler/zkDSL.md | 1 + crates/lean_vm/src/cpu/mod.rs | 14 +- crates/lean_vm/src/lib.rs | 2 +- crates/primitives/src/lib.rs | 2 +- crates/rec_aggregation/Cargo.toml | 1 + crates/rec_aggregation/guests/aggregate.py | 668 ++++++++++--- crates/rec_aggregation/src/aggregation.rs | 904 ++++++++++++++---- crates/rec_aggregation/src/benchmark.rs | 93 +- crates/rec_aggregation/src/hash_chain.rs | 2 +- crates/rec_aggregation/src/lib.rs | 9 +- crates/rec_aggregation/src/signers_cache.rs | 146 ++- crates/rec_aggregation/tests/arena_prove.rs | 2 +- crates/sphincs/Cargo.toml | 1 + crates/sphincs/src/sphincs.rs | 7 +- doc/leanvm/body/09-recursive-aggregation.tex | 4 +- src/main.rs | 33 +- 26 files changed, 1581 insertions(+), 421 deletions(-) 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 ab669d9fb..1be4d5d8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,8 +31,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` | -| `sphincs` | the stateless SPHINCS+ instance of `doc/sphincs`; an independent leaf, not yet consumed | -| `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/`. @@ -64,9 +64,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). @@ -91,9 +94,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 8fe9c39d3..b492ffa76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -381,6 +381,7 @@ dependencies = [ "primitives", "rand", "serde", + "sphincs", "tracing", "xmss", "zk_alloc", @@ -455,6 +456,7 @@ dependencies = [ "parallel", "primitives", "rand", + "serde", ] [[package]] diff --git a/README.md b/README.md index 6bee2b86b..b9258b582 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@

Aggregation: 1100 XMSS/s + Aggregation: 196 SPHINCS/s 2 to 1 recursion: 0.45s

@@ -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/flock/src/lib.rs b/crates/flock/src/lib.rs index a6ede484a..226654e3e 100644 --- a/crates/flock/src/lib.rs +++ b/crates/flock/src/lib.rs @@ -25,8 +25,8 @@ //! gadgets, forwards and transposed, kept separate because the fused //! three-operand adder's bit boundaries are the subtlest thing here. -pub mod hash; mod gf2; +pub mod hash; pub mod lincheck; /// The circuit driven through the whole reduction. A `src` module rather than /// its own test binary so it shares the process, and so the slow diff --git a/crates/lean_compiler/tests/suite/inline_expr.rs b/crates/lean_compiler/tests/suite/inline_expr.rs index 17cefee60..fd0e24e2e 100644 --- a/crates/lean_compiler/tests/suite/inline_expr.rs +++ b/crates/lean_compiler/tests/suite/inline_expr.rs @@ -7,8 +7,8 @@ //! `let`. use lean_compiler::{compile, parse}; -use lean_vm::hash_flock::warm_setup; use lean_vm::cpu::{prove, verify}; +use lean_vm::hash_flock::warm_setup; use primitives::field::{F64, F192}; #[test] diff --git a/crates/lean_compiler/tests/suite/pack64x2.rs b/crates/lean_compiler/tests/suite/pack64x2.rs index fcda6c19b..e773efca3 100644 --- a/crates/lean_compiler/tests/suite/pack64x2.rs +++ b/crates/lean_compiler/tests/suite/pack64x2.rs @@ -1,6 +1,6 @@ use lean_compiler::{compile, parse}; -use lean_vm::hash_flock::warm_setup; use lean_vm::cpu::{prove, verify}; +use lean_vm::hash_flock::warm_setup; use primitives::field::{F64, F192}; use crate::common::mix; diff --git a/crates/lean_compiler/tests/suite/print_debug.rs b/crates/lean_compiler/tests/suite/print_debug.rs index 1c3f0e985..05e0a7f8b 100644 --- a/crates/lean_compiler/tests/suite/print_debug.rs +++ b/crates/lean_compiler/tests/suite/print_debug.rs @@ -2,8 +2,8 @@ //! witness generation, and leave proving/verification untouched. use lean_compiler::{compile, parse}; -use lean_vm::hash_flock::warm_setup; use lean_vm::cpu::{prove, verify}; +use lean_vm::hash_flock::warm_setup; use primitives::field::{F64, F192}; #[test] diff --git a/crates/lean_compiler/tests/suite/stack_buf.rs b/crates/lean_compiler/tests/suite/stack_buf.rs index 2930703a9..402eb238d 100644 --- a/crates/lean_compiler/tests/suite/stack_buf.rs +++ b/crates/lean_compiler/tests/suite/stack_buf.rs @@ -10,8 +10,8 @@ //!: the reference `compress` is fed that lane layout. use lean_compiler::{compile, parse}; -use lean_vm::hash_flock::{compression, digest, metadata, unpack_metadata, warm_setup}; use lean_vm::cpu::{Op, prove, verify}; +use lean_vm::hash_flock::{compression, digest, metadata, unpack_metadata, warm_setup}; use lean_vm::vmhash::compress; use primitives::field::{F64, F192}; diff --git a/crates/lean_compiler/tests/suite/vm_proofs.rs b/crates/lean_compiler/tests/suite/vm_proofs.rs index a3aa99d35..3f6e492a4 100644 --- a/crates/lean_compiler/tests/suite/vm_proofs.rs +++ b/crates/lean_compiler/tests/suite/vm_proofs.rs @@ -7,8 +7,8 @@ //! duplicating their knowledge of what a dummy row looks like. use lean_compiler::{compile, parse}; -use lean_vm::hash_flock::warm_setup; use lean_vm::cpu::{Error, Proof, prove, verify}; +use lean_vm::hash_flock::warm_setup; use lean_vm::vmhash::compress; use primitives::field::{F64, F192}; 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 15bb42c24..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], @@ -832,11 +832,7 @@ mod tests { ins: [2, 3, 4, 5], cv: 0, out: 6, - metadata: crate::hash_flock::metadata( - crate::hash_flock::PINNED_T, - crate::hash_flock::FINAL_FLAG, - 0, - ), + metadata: crate::hash_flock::metadata(crate::hash_flock::PINNED_T, crate::hash_flock::FINAL_FLAG, 0), }, ]; // c → cells 6,7 // 16 slots: 5 executed, then 10 filler SETs step the pc to 15, whose slot is @@ -923,11 +919,7 @@ mod tests { ins: [2, 3, 2, 3], cv: 0, out: 4, - metadata: crate::hash_flock::metadata( - crate::hash_flock::PINNED_T, - crate::hash_flock::FINAL_FLAG, - 0, - ), + metadata: crate::hash_flock::metadata(crate::hash_flock::PINNED_T, crate::hash_flock::FINAL_FLAG, 0), }, ]; // 8 slots: 3 executed, 4 filler SETs stepping the pc, then the sentinel. diff --git a/crates/lean_vm/src/lib.rs b/crates/lean_vm/src/lib.rs index 8f0b33595..24932c6a1 100644 --- a/crates/lean_vm/src/lib.rs +++ b/crates/lean_vm/src/lib.rs @@ -20,11 +20,11 @@ //! - [`hash_flock`]: the `BLAKE2s` glue: flock's R1CS validity proof over the same commitment. //! - [`vmhash`]: VM-native hashing (one-block compression and standard BLAKE2s slice hashing). -pub mod hash_flock; pub mod colval; pub mod constraints; pub mod cpu; pub mod gkr; +pub mod hash_flock; pub mod leaf; pub mod pcs; pub mod tables; diff --git a/crates/primitives/src/lib.rs b/crates/primitives/src/lib.rs index c398c68ad..b29d6e260 100644 --- a/crates/primitives/src/lib.rs +++ b/crates/primitives/src/lib.rs @@ -3,8 +3,8 @@ pub mod bench; pub mod bits; -pub mod hash; pub mod field; +pub mod hash; pub mod multilinear; pub mod stream; 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 836da5e20..c846356fd 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 @@ -2364,9 +2429,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. @@ -2380,7 +2686,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) @@ -2409,27 +2715,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 @@ -2440,16 +2883,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 @@ -2479,70 +2926,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] @@ -2550,10 +2986,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() @@ -2562,64 +3004,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 @@ -2646,7 +3066,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 54cb3a8ce..5d367ce06 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, at most [`MAX_KEYS`] long. - 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 at most [`MAX_KEYS`] long. + 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, @@ -384,6 +462,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 @@ -393,10 +474,16 @@ 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. -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 makes "every declared key signed" mean as many +/// distinct signers as the lists are long, rather than one signer counted many +/// times. Either list may be empty; both may not. +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(()) @@ -406,26 +493,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 @@ -438,45 +535,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 `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. /// - /// 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 { + /// **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() @@ -487,12 +595,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(); @@ -1454,27 +1563,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()); @@ -1485,39 +1621,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 @@ -1527,7 +1693,7 @@ fn push_signature_hints( pk: &XmssPublicKey, sig: &XmssSignature, message: &xmss::Message, - epoch: u32, + xmss_epoch: u32, ) { let wots = &sig.wots_signature; let mut randomness = [0u8; xmss::STATE_LEN]; @@ -1536,8 +1702,8 @@ fn push_signature_hints( "rand", vec![pack_16_bytes(&randomness[..16]), pack_16_bytes(&randomness[16..])], ); - let encoding = - xmss::wots_encode(message, epoch, &pk.public_param, &wots.randomness).expect("a verified signature encodes"); + let encoding = xmss::wots_encode(message, xmss_epoch, &pk.public_param, &wots.randomness) + .expect("a verified signature encodes"); for &e in &encoding { hints.push("digits", vec![count(e as usize)]); } @@ -1549,32 +1715,80 @@ fn push_signature_hints( } } -/// 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) { + 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).expect("a verified signature encodes"); + 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]).expect("a verified signature encodes"); + signed = sphincs::tree_fold(pp, pos, leaf, path); + } + debug_assert_eq!(signed, pk.root, "the hinted walk reaches the public key"); +} + +/// 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 /// tops up an existing aggregate. One proving job at a time 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. @@ -1585,22 +1799,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 @@ -1611,7 +1833,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)))?; @@ -1621,59 +1843,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)?; @@ -1702,7 +1944,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 @@ -1713,9 +1962,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 +2594,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 +2616,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,13 +2696,31 @@ 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; 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] @@ -2438,23 +2729,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] @@ -2463,10 +2841,11 @@ 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])); } #[test] @@ -2475,12 +2854,15 @@ mod tests { 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 left = + aggregate(&[leaf(0), leaf(1)], message(), XMSS_EPOCH, vec![], vec![], LOG_INV_RATE).expect("left node"); + let right = + aggregate(&[leaf(2), leaf(3)], message(), XMSS_EPOCH, vec![], vec![], 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 root = + aggregate(&[left, right], message(), XMSS_EPOCH, extra, 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); } #[test] @@ -2490,7 +2872,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!( @@ -2500,9 +2892,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)| { @@ -2510,18 +2904,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.xmss_keys.swap(0, 1); + }); + tampered(&|s| { + s.xmss_keys.pop(); + }); + tampered(&|s| s.sphincs_signers[0] = s.sphincs_signers[1]); tampered(&|s| { - s.public_keys.swap(0, 1); + s.sphincs_signers.swap(0, 1); }); tampered(&|s| { - s.public_keys.pop(); + s.sphincs_signers.pop(); }); - tampered(&|s| s.epoch += 1); - tampered(&|s| s.message[0] ^= 1); + // 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 @@ -2553,14 +2967,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), ) @@ -2582,11 +2998,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); @@ -2605,13 +3021,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"); @@ -2644,7 +3129,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); } } @@ -2655,11 +3171,23 @@ 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" + ); } } 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/hash_chain.rs b/crates/rec_aggregation/src/hash_chain.rs index 5bb98a40b..5235ff895 100644 --- a/crates/rec_aggregation/src/hash_chain.rs +++ b/crates/rec_aggregation/src/hash_chain.rs @@ -3,8 +3,8 @@ use std::time::Instant; use lean_compiler::{compile, compile_without_filler, parse}; -use lean_vm::hash_flock::warm_setup; use lean_vm::cpu::{prove, verify}; +use lean_vm::hash_flock::warm_setup; use lean_vm::vmhash::compress; use primitives::{ field::{F64, F192}, 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 index 4ce4fe7be..21f53fa30 100644 --- a/crates/sphincs/Cargo.toml +++ b/crates/sphincs/Cargo.toml @@ -10,3 +10,4 @@ workspace = true primitives.workspace = true parallel.workspace = true rand.workspace = true +serde.workspace = true diff --git a/crates/sphincs/src/sphincs.rs b/crates/sphincs/src/sphincs.rs index dc7361801..00dbdd164 100644 --- a/crates/sphincs/src/sphincs.rs +++ b/crates/sphincs/src/sphincs.rs @@ -8,6 +8,7 @@ //! what makes the scheme stateless. use rand::{CryptoRng, Rng}; +use serde::{Deserialize, Serialize}; use crate::*; @@ -47,7 +48,9 @@ pub fn path_range(lay: usize) -> std::ops::Range { start..start + HEIGHTS[lay] } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +/// 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, @@ -365,7 +368,7 @@ pub fn sign(rng: &mut impl CryptoRng, sk: &SecretKey, message: &Message) -> Resu } /// `Tree.fold`: the other half of a Merkle opening. -fn tree_fold(pp: &PublicParam, pos: Pos, leaf: Digest, path: &[Digest]) -> Digest { +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) 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/src/main.rs b/src/main.rs index d3db193eb..017d99f23 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -//! Benchmark CLI for XMSS aggregation, recursion, and the Fibonacci demo. +//! Benchmark CLI for signature aggregation, recursion, and the Fibonacci demo. use clap::{Parser, Subcommand}; @@ -36,21 +36,28 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// Aggregate XMSS signatures inside the VM and verify the proof. - Xmss { - /// Number of signatures to aggregate. - #[arg(long, default_value = "820")] - n_signatures: usize, + /// Aggregate signatures of either scheme, or of both, inside the VM and + /// verify the proof. At least one count must be nonzero. + Aggregate { + /// XMSS signatures to aggregate. + #[arg(long, default_value = "0")] + xmss: usize, + /// SPHINCS signatures to aggregate. + #[arg(long, default_value = "0")] + sphincs: usize, }, /// Aggregate n previously aggregated signatures into one proof. Recursion { /// Number of child aggregates. #[arg(long, default_value = "2")] n: usize, - /// Signatures in each child. Sets the child proof's committed size, + /// XMSS signatures in each child. Sets the child proof's committed size, /// which is what the recursion cost should be quoted against. #[arg(long, default_value = "900")] xmss_per_leaf: usize, + /// SPHINCS signatures in each child, on top of the XMSS ones. + #[arg(long, default_value = "0")] + sphincs_per_leaf: usize, }, /// Prove and verify Fibonacci in the exponent (demo). Fibonacci { @@ -68,11 +75,15 @@ fn main() { primitives::init_tracing(); } match cli.command { - Command::Xmss { n_signatures } => { - 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); From 408998700d70a4b17d9a821c0a7faf219f16bfcd Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Tue, 25 Aug 2026 15:40:11 +0200 Subject: [PATCH 28/31] rec_aggregation: carry SPHINCS claims through three levels The deepest test was XMSS-only, so nothing exercised a SPHINCS claim being rebuilt into a child's statement more than once. Both nodes now carry claims, and the root adds a raw signature of each scheme alongside its children, so claim 1 is rebuilt into a node and then again into the root. The two nodes deliberately share that claim, which is the part depth adds: the root has to give it a SPHINCS duplicate slot for an entry it never saw directly, where the two-level test only ever duplicates a claim its own children handed it. The proof verifying is what says that slot was covered, since the write count has to total every slot. Co-Authored-By: Claude Opus 5 (1M context) --- crates/rec_aggregation/src/aggregation.rs | 43 ++++++++++++++++++----- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/crates/rec_aggregation/src/aggregation.rs b/crates/rec_aggregation/src/aggregation.rs index 6d1a09b22..807dbc6bc 100644 --- a/crates/rec_aggregation/src/aggregation.rs +++ b/crates/rec_aggregation/src/aggregation.rs @@ -2889,21 +2889,48 @@ mod tests { 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)], message(), XMSS_EPOCH, vec![], vec![], LOG_INV_RATE).expect("left node"); - let right = - aggregate(&[leaf(2), leaf(3)], message(), XMSS_EPOCH, vec![], vec![], LOG_INV_RATE).expect("right node"); - let extra = signers[4 * SMALL_LEAF_SIZE..].to_vec(); - let root = - aggregate(&[left, right], message(), XMSS_EPOCH, extra, vec![], 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.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] From 249655b49db8505073c1510c46e311485e62bd70 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Tue, 25 Aug 2026 15:48:33 +0200 Subject: [PATCH 29/31] doc/sphincs: state security as XMSS does, and drop the formalization The security definition now has the shape `doc/xmss` settled on under review: a $q$-bounded adversary, and `Pr[A wins] <= q / 2^x` rather than a maximum of `Forge(q_s, q) / q` over $q$. The two say the same thing, the second being the first unrolled, but the $q$-bounded form carries its quantifier where a reader meets it, and with the `Forge` shape went the sentence explaining that the claim is read past `q = 2^58`: that was an artifact of dividing by $q$, not something about the scheme. What stays is what makes this scheme's game different from the stateful one: no epoch, a signing query capped at $\qs$ and repeatable on one message, since `Sig` keeps no state, and a signature that may be $\bot$. `formal/sphincs` is deleted, 1060 lines that stated a claim and proved none of it. The security section is a target now, and says so; AGENTS.md no longer sends a reader after a project that is not there. CI never built it (`lean.yml` covers `formal/xmss` only), and its `Proof/` directory was empty, so nothing unproven went with it that the history does not keep. The target reads 127 bits at `2^24` signatures. That is tight for this instance and worth knowing before anyone attempts it: the generic cost is `2^-128` a query and the few-time leak `2^-133.3` at that many signatures, which already sum to about `2^-127.9`, so a proof's union bounds and constants have to be additively small against `2^-128` rather than absorbed by slack. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 +- doc/sphincs/main.tex | 14 +- formal/sphincs/.gitignore | 1 - formal/sphincs/README.md | 39 - formal/sphincs/SphincsSecurity.lean | 15 - formal/sphincs/SphincsSecurity/Statement.lean | 868 ------------------ formal/sphincs/lake-manifest.json | 126 --- formal/sphincs/lakefile.toml | 11 - formal/sphincs/lean-toolchain | 1 - 9 files changed, 8 insertions(+), 1069 deletions(-) delete mode 100644 formal/sphincs/.gitignore delete mode 100644 formal/sphincs/README.md delete mode 100644 formal/sphincs/SphincsSecurity.lean delete mode 100644 formal/sphincs/SphincsSecurity/Statement.lean delete mode 100644 formal/sphincs/lake-manifest.json delete mode 100644 formal/sphincs/lakefile.toml delete mode 100644 formal/sphincs/lean-toolchain diff --git a/AGENTS.md b/AGENTS.md index 1be4d5d8e..a1b33714b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ A minimal (zero-knowledge Virtual Machine, which is actually not ZK in the real - `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. - `doc/sphincs/params_selection/` is a parameter-exploration tool for SPHINCS+ (security, signature size, hash counts, and a search for the cheapest verification under given budgets), in its own cargo workspace with no dependencies. It models the schemes of the Blockstream report, not anything this repo proves, and its `cargo test --release` pins every number against that project's sage fixtures. -- `formal/xmss/` is a Lean 4 proof (over VCVio) of that instance's classical random-oracle security, `xmss_has_127_bits_of_classical_security`, and `formal/sphincs/` states the same kind of claim for the SPHINCS instance at 120 bits, with no proof yet. In both, `*/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`. +- `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). diff --git a/doc/sphincs/main.tex b/doc/sphincs/main.tex index 6f9a22538..4f825a410 100644 --- a/doc/sphincs/main.tex +++ b/doc/sphincs/main.tex @@ -26,7 +26,6 @@ \newcommand{\Sig}{\mathsf{Sig}} \newcommand{\Ver}{\mathsf{Ver}} \newcommand{\SIG}{\mathsf{SIG}} -\newcommand{\Forge}{\mathsf{Forge}} \newcommand{\Chain}{\mathsf{Chain}} \newcommand{\hash}{\mathsf{H}} \newcommand{\LE}{\mathsf{LE}} @@ -75,7 +74,7 @@ \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}, which is the one tree of layer $0$ and nothing else. + \item \textbf{key generation costs 1.38M hashes}. \end{itemize} \end{abstract} @@ -437,10 +436,10 @@ \section{Security} \subsection{Classical security} \begin{definition}[Strong unforgeability in the ROM] -Let $\SIG=(\Gen,\Sig,\Ver)$ be a signature scheme whose algorithms use a hash function $\hash:\bits{*}\to\bits{256}$. Consider the following game between a signer and an adversary $\mathcal A$ (an arbitrary probabilistic algorithm with unbounded running time and memory), with $\hash$ sampled as a random oracle that both may query. The signer runs $(\pk,\sk)\gets\Gen$ and gives $\pk$ to $\mathcal A$, which may then adaptively take any of the following actions: +Let $\SIG=(\Gen,\Sig,\Ver)$ be a signature scheme whose algorithms use a hash function $\hash:\bits{*}\to\bits{256}$. Consider the following game between a signer and an adversary $\mathcal A$ (an arbitrary probabilistic algorithm with unbounded running time and memory), with $\hash$ sampled as a random oracle, that both signer and adversary can query. The signer first runs $(\pk,\sk)\gets\Gen$ and gives $\pk$ to $\mathcal A$. The adversary may then adaptively take any of the following actions: \begin{enumerate}[leftmargin=2em] \item Query the random oracle on any input and receive its 256-bit output. - \item Submit a message $m\in\bits{\lmsg}$ and receive $\sigma\gets\Sig(\sk,m)$ from the signer, which may be $\bot$. It may do so at most $\qs$ times. + \item Submit a message $m\in\bits{\lmsg}$ and receive $\sigma\gets\Sig(\sk,m)$ from the signer, which may be $\bot$. It may do so at most $\qs$ times, on any messages, the same one included: $\Sig$ keeps no state, so nothing here is used up. \item Terminate with a claimed forgery $(m^*,\sigma^*)$. \end{enumerate} The adversary wins if $\Ver(\pk,m^*,\sigma^*)=1$ and the signer did not return $\sigma^*$ in response to a signing query for $m^*$, meaning: @@ -448,13 +447,14 @@ \subsection{Classical security} \item if the adversary never queried a signature for $m^*$; \item or it did, but no answer it received was $\sigma^*$. \end{itemize} -Let $\Forge_{\SIG}(\qs,q)$ be the maximum winning probability of any adversary for which the total number of random-oracle queries made in the experiment, including during key generation, signing, and the final verification of the claimed forgery, is at most $q$ on every execution path; it is $0$ below what key generation and one verification already cost. An adversary that spends all $\qs$ signatures needs $q$ past $2^{58}$, the attempt caps bounding the loops, so that is where the claim is read. We say that $\SIG$ has $x$ bits of classical strong unforgeability in the ROM at $\qs$ signatures if + +Call $\mathcal A$ $q$-bounded if the experiment makes at most $q$ random-oracle queries on every execution, counting those of key generation, signing, and the final verification of the claimed forgery. We say that $\SIG$ has $x$ bits of classical strong unforgeability in the ROM at $\qs$ signatures if every $q\geq1$ and every $q$-bounded $\mathcal A$ satisfy \[ - \max_{q\geq1}\frac{\Forge_{\SIG}(\qs,q)}{q}\leq 2^{-x}. + \Pr[\mathcal A\text{ wins}]\leq\frac{q}{2^{x}}. \] \end{definition} -That game, with the parameters and the algorithms above, is written out in Lean4 over the VCVio framework~\cite{VCVio} in \texttt{./formal/sphincs/SphincsSecurity/Statement.lean}, which states $x=120$ at $\qs=2^{24}$: the eight bits below $n$ are what a proof may spend on union bounds and constants. Nothing proves it yet. +TODO prove 127 bits of classical strong unforgeability in the ROM at $\qs=2^{24}$ signatures. \subsection{Quantum security} \label{sec:quantum} diff --git a/formal/sphincs/.gitignore b/formal/sphincs/.gitignore deleted file mode 100644 index 4080d07df..000000000 --- a/formal/sphincs/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/.lake/ diff --git a/formal/sphincs/README.md b/formal/sphincs/README.md deleted file mode 100644 index e89ec6112..000000000 --- a/formal/sphincs/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# SPHINCS security statement - -This Lean project states, and does not yet prove, the classical random-oracle security of the concrete SPHINCS instance of `doc/sphincs/main.tex`: `SphincsSecurityStatement`, which reads `HasClassicalSecurityBits Concrete.scheme 120`. - -Everything the claim depends on is in the single module `SphincsSecurity/Statement.lean`, in the order a reviewer needs it: the concrete parameters and types with the tweak and byte layout of every hash input and the target-sum code, the three algorithms with the oracle calls they make, then the strong-unforgeability experiment and the claim. It follows `formal/xmss`, whose statement module is the model for this one and whose proof machinery is what a proof here would extend. - -## What the game says - -Key generation samples the public parameter, one secret per Winternitz chain of every layer, tree and leaf, and one per few-time leaf of every instance, then builds layer 0's tree for the root. Signing draws a fresh randomizer per digest attempt until the digest's last index group is zero, opens the few-time forest, and produces one one-time signature per layer, recomputing through the random oracle whatever tree it needs; the specification's seed derivation and the signer's cache are implementations of this key and change no probability. Verification is the ordinary verifier. Key generation, the adversary, the signing oracle and the final verification share one lazily sampled oracle, and `q` bounds the hash queries of the whole experiment. - -Three things differ from the XMSS statement, all because this scheme is stateless. - -- A signing request is a message, with no epoch. What the game caps is therefore the number of signing queries, at `signatureLimit = 2^24`, rather than forbidding a repeated epoch. -- Signing is randomized, so one message has many valid signatures. The game rejects only a signature the signer actually returned for that message, which is what makes the claim a strong-unforgeability claim. -- The secret key holds the sampled secrets instead of precomputed tables, so signing queries the oracle for the chains, nodes and forests it reads, rebuilding a tree rather than caching it. The honest experiment therefore spends `2^44.5` hash queries of its own, against the `2^41.5` a real signer with the cache of `doc/sphincs/main.tex` pays. What the query bound counts is the worst-case path rather than the average, which the `2^32` digest and counter caps push to exactly `2^58`. - -Every component of the `Signature` structure is read by verification, the authentication path being the `h` nodes of the `d` layers laid end to end. That matters, an unread component making the strong-unforgeability game trivially winnable by perturbing it, so `signaturePath_flattenPaths` and `authPath_exhausted` prove that the verifier reads a layer's node exactly where the signer laid it and that no entry goes unread. The index decomposition is proven the same way rather than asserted: `treeIndexAt_topLayer`, `layers_link_top`, `layers_link_middle` and `leafIndexAt_bottomLayer` hold for all `2^26` indices. - -## Why 120 bits and not 128 - -The bound is the slope `q / 2^bits`, so it bounds what one query buys. Every strategy the specification accounts for costs `2^-128` per query: inverting a chain step, a node or a leaf, recovering a published secret, or grinding a counter onto an already signed codeword, each separated from the others by its tweak, so no query bears on two structural positions and no multi-target factor appears. The few-time leak is a per-query slope as well, `2^-133.3` at `q_s = 2^24`, five bits under the generic term, so it does not bind; it is what fixes `signatureLimit`, reaching `2^-128` at `2^25.1` signatures and `2^-120` at `2^26.4`, about two doublings of headroom. Read the claim where an adversary spending all `2^24` signatures lives: the query bound counts every execution path, so the `2^32` attempt caps put the floor at `q = 2^58`, where the bound reads `2^-62` against a true forging probability of about `2^-70`. The slack is that same `2^8` at every `q`, the dominant term being linear in it. Claiming 120 leaves `2^8` for the union bounds and constants a proof accumulates, where XMSS could claim 127 against the same digest length with one hash chain layer and one tree. - -## Status - -Nothing is proven. The one-time and Merkle halves of `formal/xmss` carry over, sharing the tweakable hash, the target-sum code and the shape of the bound; what is new is the hypertree, the few-time forest, the digest that picks the index, and the counter search under a tweak shared across attempts. - -Two places a proof can go wrong, both found by attacking the claim rather than by reading it: - -- **A strong forgery needs no chain inversion.** `Ver` does not check that the counter is the least admissible one, so a second `c'` with `Enc(P,lay,tau,e,M,c') = x` reuses the chain values verbatim and verifies. Since the codeword fixes the digest, that is one `2^-128` hit per query and it is harmless, but it is a branch of its own: the one-time signature is unforgeable on a *new* message by incomparability, and unforgeable on the *signed* message only by collision resistance at `tw_enc`. -- **Keep the conjunction in the one-time bound.** A forger does not need a codeword dominating `x`, only one lower on a single chain, `x' = x - e_j + e_l`, of which there are `1258` at these parameters. So an encoding query hits an exploitable shape with probability `2^-117.7`, above the `2^-120` slope; the event is a forgery only together with one chain inversion, giving `q^2 * 2^-247.7`. Charge the shape alone, linearly in `q`, and the proof fails at 120 bits. - -Build with: - -```bash -lake exe cache get # first time only -lake build -``` - -`lake build` reports the `sorry` in `SphincsSecurity.lean`, which is the open goal, and nothing else. diff --git a/formal/sphincs/SphincsSecurity.lean b/formal/sphincs/SphincsSecurity.lean deleted file mode 100644 index 59fd36a8a..000000000 --- a/formal/sphincs/SphincsSecurity.lean +++ /dev/null @@ -1,15 +0,0 @@ -import SphincsSecurity.Statement - -namespace SphincsSecurity - -/-! -The claim to be proven. Its statement lives entirely in the single module `SphincsSecurity.Statement`, whose parameters, algorithms and experiment are the specification of `doc/sphincs/main.tex`. - -The `sorry` below is the whole point of this project: it is the goal, and the build says out loud that nothing proves it yet. `formal/xmss` proves the analogous claim for the stateful scheme, and the two share the tweakable hash, the target-sum code and the shape of the bound, so the one-time and Merkle halves of that proof carry over; what is new here is the hypertree, the few-time forest, the digest that picks the index, and the counter search under a tweak shared across attempts. --/ - -/-- `120` bits of classical strong unforgeability in the random-oracle model for the concrete SPHINCS instance, at `2^24` signatures per key pair. -/ -theorem sphincs_has_120_bits_of_classical_security : SphincsSecurityStatement := by - sorry - -end SphincsSecurity diff --git a/formal/sphincs/SphincsSecurity/Statement.lean b/formal/sphincs/SphincsSecurity/Statement.lean deleted file mode 100644 index 37c0e23e2..000000000 --- a/formal/sphincs/SphincsSecurity/Statement.lean +++ /dev/null @@ -1,868 +0,0 @@ -import VCVio.OracleComp.QueryTracking.LoggingOracle -import VCVio.OracleComp.QueryTracking.RandomOracle.Simulation -import VCVio.OracleComp.QueryTracking.QueryBound - -/-! -# Classical random-oracle security of the concrete SPHINCS instance - -This single module is the reviewer-facing statement of what has to be proven. It contains everything the statement depends on: the concrete parameters and types, the byte layout of every hash input, the three algorithms exactly as run in the security experiment, the strong-unforgeability experiment, and the security claim `SphincsSecurityStatement`. Nothing here describes a reduction or an intermediate game, and nothing instantiates the hash: it is a random oracle throughout. What the concrete parameters fix about the layout is proven rather than asserted, each lemma sitting next to the definitions it concerns: the index decomposition of Section `The index`, and the authentication path next to `flattenPaths`. - -The instance is the one specified in `doc/sphincs/main.tex`: 32-byte messages, 128-bit digests truncated from a 256-bit random-oracle output, 42 Winternitz chains of length 8 at target sum 191, a hypertree of height 26 over 3 layers of heights 12, 7 and 7, and a few-time forest of 14 trees of `2^10` leaves selected by a 176-bit message digest. A key answers for all `2^26` indices and signs at most `2^24` messages. - -Three things differ from `formal/xmss`. The signer takes no epoch, this scheme being stateless, so a signing request is a message alone and what the game caps is the number of signing queries, at `signatureLimit`. Signing is randomized, a fresh `randomnessBits` string per digest attempt, so a message has many valid signatures and a second one on a signed message is a strong forgery. And the secret key holds the sampled secrets rather than precomputed tables, so signing recomputes through the random oracle whatever tree it reads, exactly as `Sig` is specified. - -The claim is `120` bits and not `128`. Every strategy the specification accounts for costs `2^-128` per query, and the bound is a slope `q / 2^120`, so `2^8` of slack absorbs the union bounds and the constants a proof accumulates, uniformly in `q`. The few-time leak is a per-query slope too, `2^-133.3` at `q_s = 2^24`, so it does not bind, but it is what fixes `signatureLimit`: it reaches `2^-128` at `2^25.1` signatures and `2^-120` at `2^26.4`, leaving about two doublings of headroom. `HasHashQueryBound` bounds every execution path, so `digestAttemptLimit` and `encodingAttemptLimit` put the admissible floor near `q = 2^58` even though a signature costs `2^20.5` on average, and the claim is read there. --/ - -open OracleComp OracleSpec ENNReal - -namespace SphincsSecurity - -/-! ## The instance: parameters, types, and hash-input layout -/ - -def digestBits : Nat := 128 -def hashOutputBits : Nat := 256 -def messageBits : Nat := 256 -def publicParameterBits : Nat := 128 -def randomnessBits : Nat := 128 -def counterBits : Nat := 32 -def winternitzBits : Nat := 3 -def chainLength : Nat := 2 ^ winternitzBits -def numChains : Nat := 42 -def targetSum : Nat := 191 -def numLayers : Nat := 3 -def totalHeight : Nat := 26 -/-- The tallest layer, `h_0`, which bounds every layer's leaf index. -/ -def maxLayerHeight : Nat := 12 -def ftsTreeHeight : Nat := 10 -/-- The `k` index groups a digest carries. The forest holds `k - 1` trees, the last group being pinned to zero. -/ -def ftsTrees : Nat := 15 -/-- Signatures allowed per key pair, `q_s`. -/ -def signatureLimit : Nat := 2 ^ 24 -/-- Digest attempts per signature, `A_max`. -/ -def digestAttemptLimit : Nat := 2 ^ 32 -/-- Encoding counters tried per layer, `C_max`. -/ -def encodingAttemptLimit : Nat := 2 ^ 32 -/-- The claimed security level. -/ -def securityBits : Nat := 120 - -abbrev Digest := BitVec digestBits -abbrev HashOutput := BitVec hashOutputBits -abbrev Message := BitVec messageBits -abbrev PublicParameter := BitVec publicParameterBits -abbrev Randomness := BitVec randomnessBits -abbrev Counter := BitVec counterBits -abbrev Layer := Fin numLayers -abbrev Index := Fin (2 ^ totalHeight) -abbrev TreeIndex := Fin (2 ^ totalHeight) -abbrev LeafIndex := Fin (2 ^ maxLayerHeight) -abbrev ChainIndex := Fin numChains -abbrev Digit := Fin chainLength -abbrev ChainStep := Fin (chainLength - 1) -/-- A tree of the few-time forest, `kappa < k - 1`. -/ -abbrev FtsTree := Fin (ftsTrees - 1) -/-- An index group of the message digest, `kappa < k`. -/ -abbrev DigestTree := Fin ftsTrees -abbrev FtsLeaf := Fin (2 ^ ftsTreeHeight) -/-- A position in the signature's authentication path, the `h` nodes of the `d` layers concatenated top layer first. -/ -abbrev PathIndex := Fin totalHeight -abbrev Encoding := ChainIndex → Digit -abbrev HashInput := List UInt8 - -/-- The `d` Merkle heights, `(h_0, h_1, h_2) = (12, 7, 7)`. Layer `0` carries the public key. -/ -def layerHeight (lay : Layer) : Nat := if lay.val = 0 then maxLayerHeight else 7 - -def topLayer : Layer := ⟨0, by decide⟩ -def middleLayer : Layer := ⟨1, by decide⟩ -def bottomLayer : Layer := ⟨numLayers - 1, by decide⟩ - -/-- `sum_{j < lay} h_j`, the index bits above layer `lay`. -/ -def heightAbove (lay : Layer) : Nat := ∑ j : Layer, if j.val < lay.val then layerHeight j else 0 - -/-- `sum_{j > lay} h_j`, the index bits below layer `lay`. -/ -def heightBelow (lay : Layer) : Nat := totalHeight - heightAbove lay - layerHeight lay - -example : ∑ lay : Layer, layerHeight lay = totalHeight := by decide - -example : (layerHeight topLayer, layerHeight middleLayer, layerHeight bottomLayer) = (12, 7, 7) := by - decide - -example : (heightAbove topLayer, heightAbove middleLayer, heightAbove bottomLayer) = (0, 12, 19) := by - decide - -example : (heightBelow topLayer, heightBelow middleLayer, heightBelow bottomLayer) = (14, 7, 0) := by - decide - -theorem layerHeight_le (lay : Layer) : layerHeight lay ≤ maxLayerHeight := by - unfold layerHeight maxLayerHeight - split <;> omega - -/-- Keep the first 128 output bits, the low bits of the little-endian bit vector. -/ -def truncateHash (output : HashOutput) : Digest := - output.extractLsb' 0 digestBits - -/-- The message digest is `h + k * a = 176` bits, an index and `k` leaf indices. -/ -def messageDigestBits : Nat := totalHeight + ftsTrees * ftsTreeHeight - -abbrev MessageDigest := BitVec messageDigestBits - -/-- The digest is `h + k * a = 176` bits and has to fit in one oracle output. -/ -example : messageDigestBits = 176 ∧ messageDigestBits ≤ hashOutputBits := by decide - -def truncateMessageDigest (output : HashOutput) : MessageDigest := - output.extractLsb' 0 messageDigestBits - -structure PublicKey where - root : Digest - parameter : PublicParameter -deriving DecidableEq - -/-- The key of the specification: the public parameter, the layer-`0` root that every digest binds, and every sampled secret. `Gen` samples them independently and uniformly; the seed derivation of the specification is an implementation of this key, not this key. -/ -structure SecretKey where - parameter : PublicParameter - root : Digest - otsSecret : Layer → TreeIndex → LeafIndex → ChainIndex → Digest - ftsSecret : Index → FtsTree → FtsLeaf → Digest - -/-- A signature, with every component the verifier reads and no other: the randomizer, one few-time secret and its `a` path nodes per held tree, and per layer a counter, `v` chain values, and its share of the `h` path nodes. That is `16 + 14 * 16 + 140 * 16 + 3 * 4 + 126 * 16 + 26 * 16 = 4924` bytes. -/ -structure Signature where - randomness : Randomness - ftsSecret : FtsTree → Digest - ftsPath : FtsTree → Fin ftsTreeHeight → Digest - counter : Layer → Counter - chainValue : Layer → ChainIndex → Digest - authPath : PathIndex → Digest -deriving DecidableEq - -/-- Serialize a bit vector into a fixed number of bytes, least significant byte first. -/ -def bytesLE (byteCount : Nat) (value : BitVec (8 * byteCount)) : List UInt8 := - List.ofFn fun index : Fin byteCount => - UInt8.ofBitVec (value.extractLsb' (8 * index.val) 8) - -structure TweakFields where - tag : BitVec 8 - layer : BitVec 8 - tree : BitVec 32 - position : BitVec 32 - index : BitVec 32 -deriving DecidableEq - -/-- The specification's 16 tweak bytes `tag || layer || tree || position || index || 0^2`, each field serialized least significant byte first. -/ -def fieldBytes (fields : TweakFields) : HashInput := - bytesLE 1 fields.tag ++ bytesLE 1 fields.layer ++ bytesLE 4 fields.tree ++ - bytesLE 4 fields.position ++ bytesLE 4 fields.index ++ List.replicate 2 0 - -/-- Every domain-separated hash call the instance makes. Tweak types `0` and `5` of the specification are absent: they belong to the seed derivation, and this key samples its secrets. -/ -inductive HashDomain where - | chain (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) (chainIdx : ChainIndex) (step : ChainStep) - | leaf (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) - | node (lay : Layer) (tree : TreeIndex) (level : Nat) (nodeIdx : Nat) - | encoding (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) - | ftsLeaf (index : Index) (tree : FtsTree) (leaf : FtsLeaf) - | ftsNode (index : Index) (tree : FtsTree) (level : Nat) (nodeIdx : Nat) - | ftsRoots (index : Index) - | message -deriving DecidableEq - -/-- Serialize a typed hash domain into the fields of a tweak. Inside the hypertree the layer field is the layer and the tree field the tree; inside a few-time key they are the tree of the forest and the index that selects the instance. -/ -def hashDomainFields : HashDomain → TweakFields - | .chain lay tree leaf chainIdx step => - ⟨1#8, BitVec.ofNat 8 lay.val, BitVec.ofNat 32 tree.val, - BitVec.ofNat 32 (chainLength * chainIdx.val + step.val), BitVec.ofNat 32 leaf.val⟩ - | .leaf lay tree leaf => - ⟨2#8, BitVec.ofNat 8 lay.val, BitVec.ofNat 32 tree.val, 0#32, BitVec.ofNat 32 leaf.val⟩ - | .node lay tree level nodeIdx => - ⟨3#8, BitVec.ofNat 8 lay.val, BitVec.ofNat 32 tree.val, - BitVec.ofNat 32 level, BitVec.ofNat 32 nodeIdx⟩ - | .encoding lay tree leaf => - ⟨4#8, BitVec.ofNat 8 lay.val, BitVec.ofNat 32 tree.val, 0#32, BitVec.ofNat 32 leaf.val⟩ - | .ftsLeaf index tree leaf => - ⟨6#8, BitVec.ofNat 8 tree.val, BitVec.ofNat 32 index.val, 0#32, BitVec.ofNat 32 leaf.val⟩ - | .ftsNode index tree level nodeIdx => - ⟨7#8, BitVec.ofNat 8 tree.val, BitVec.ofNat 32 index.val, - BitVec.ofNat 32 level, BitVec.ofNat 32 nodeIdx⟩ - | .ftsRoots index => ⟨8#8, 0#8, BitVec.ofNat 32 index.val, 0#32, 0#32⟩ - | .message => ⟨9#8, 0#8, 0#32, 0#32, 0#32⟩ - -/-- The exact 16 bytes supplied by the specification as a hash tweak. -/ -def tweakBytes (domain : HashDomain) : HashInput := - fieldBytes (hashDomainFields domain) - -/-- The random-oracle input `tweak || parameter || message` used by every tweakable hash call and by the message digest. -/ -def tweakableHashInput (parameter : PublicParameter) (domain : HashDomain) - (message : HashInput) : HashInput := - tweakBytes domain ++ bytesLE 16 parameter ++ message - -/-! ### The target-sum code - -`v = 42` chunks of `w = 3` bits, 21 in each half of the digest, one pinned bit per half, and the code is the words of digit sum `T = 191`. Two distinct words of equal sum are incomparable, which is what removes the Winternitz checksum and forces the counter. -/ - -namespace TargetSum - -def sum (x : Encoding) : Nat := ∑ i, (x i).val - -def Valid (x : Encoding) : Prop := sum x = targetSum - -instance : DecidablePred Valid := - fun x => inferInstanceAs (Decidable (sum x = targetSum)) - -def digitsPerHalf : Nat := numChains / 2 - -/-- Offset of a three-bit digit, skipping padding bits 63 and 127. -/ -def digitOffset (i : ChainIndex) : Nat := - winternitzBits * i.val + if i.val < digitsPerHalf then 0 else 1 - -def digestEncoding (digest : Digest) : Encoding := - fun i => (digest.extractLsb' (digitOffset i) winternitzBits).toFin - -/-- Decode the concrete little-endian layout: 21 three-bit digits, padding bit 63, 21 digits, and padding bit 127. A digest decodes exactly when both padding bits are clear and the digits reach the target sum. -/ -def decodeDigest (digest : Digest) : Option Encoding := - if digest.getLsbD 63 = false ∧ digest.getLsbD 127 = false ∧ Valid (digestEncoding digest) - then some (digestEncoding digest) else none - -end TargetSum - -/-! ## The algorithms - -Key generation, signing and verification exactly as run in the experiment, together with the oracle hash calls they make. Key generation samples the parameter and every secret and builds layer `0`'s tree; signing rebuilds whatever tree it reads rather than caching anything, as specified, so the honest experiment spends `2^44.5` hash queries of its own and its worst-case path, which is what the query bound counts, `2^58`; verification is the ordinary verifier. - -The `irreducible` attributes only seal definitions against accidental unfolding in proofs. Lean restricts global reducibility attributes to the defining module, so they must appear here. -/ - -/-- A hash query takes an arbitrary byte string and returns 32 bytes. -/ -abbrev HashSpec := HashInput →ₒ HashOutput - -/-- `unifSpec` for uniform sampling, `HashSpec` for the random oracle. A query is `.inl` to sample or `.inr` to hash, so `HasHashQueryBound` counts only the hash side. -/ -abbrev OracleWorld := unifSpec + HashSpec - -namespace Concrete - -def digestBytes (value : Digest) : HashInput := bytesLE 16 value - -def messageBytes (message : Message) : HashInput := bytesLE 32 message - -def randomnessBytes (randomness : Randomness) : HashInput := bytesLE 16 randomness - -def counterBytes (counter : Counter) : HashInput := bytesLE 4 counter - -def oracleHash {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (input : HashInput) : m HashOutput := - HasQuery.query (spec := HashSpec) (m := m) input - -def tweakableHash {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (domain : HashDomain) (payload : HashInput) : m Digest := do - let output ← oracleHash (tweakableHashInput parameter domain payload) - return truncateHash output - -def sequenceFin {m : Type → Type} [Monad m] {α : Type} {n : Nat} - (computation : Fin n → m α) : m (Fin n → α) := - match n with - | 0 => pure Fin.elim0 - | n + 1 => do - let head ← computation 0 - let tail ← sequenceFin fun index : Fin n => computation index.succ - return Fin.cases head tail - -/-- Turn a family of optional results into an optional family: the specification's `Sig` returns nothing as soon as one layer fails. -/ -def traverseOption {α : Type} {n : Nat} (family : Fin n → Option α) : Option (Fin n → α) := - match n with - | 0 => some Fin.elim0 - | n + 1 => - match family 0, traverseOption fun index : Fin n => family index.succ with - | some head, some tail => some (Fin.cases head tail) - | _, _ => none - -/-! ### The index -/ - -/-- `tau_lay = floor(idx / 2^(sum_{j >= lay} h_j))`. -/ -def treeIndexAt (index : Index) (lay : Layer) : TreeIndex := - ⟨index.val / 2 ^ (totalHeight - heightAbove lay), - Nat.lt_of_le_of_lt (Nat.div_le_self _ _) index.isLt⟩ - -/-- `e_lay = floor(idx / 2^(sum_{j > lay} h_j)) mod 2^h_lay`. -/ -def leafIndexAt (index : Index) (lay : Layer) : LeafIndex := - ⟨index.val / 2 ^ heightBelow lay % 2 ^ layerHeight lay, by - have hmod : index.val / 2 ^ heightBelow lay % 2 ^ layerHeight lay < 2 ^ layerHeight lay := - Nat.mod_lt _ (Nat.two_pow_pos _) - have hpow : 2 ^ layerHeight lay ≤ 2 ^ maxLayerHeight := - Nat.pow_le_pow_right (by omega) (layerHeight_le lay) - omega⟩ - -theorem treeIndexAt_val (index : Index) (lay : Layer) : - (treeIndexAt index lay).val = index.val / 2 ^ (totalHeight - heightAbove lay) := rfl - -theorem leafIndexAt_val (index : Index) (lay : Layer) : - (leafIndexAt index lay).val = index.val / 2 ^ heightBelow lay % 2 ^ layerHeight lay := rfl - -/-- Layer `0` holds a single tree, the public key's. -/ -theorem treeIndexAt_topLayer (index : Index) : (treeIndexAt index topLayer).val = 0 := by - have hlt : index.val < 2 ^ 26 := index.isLt - have h0 : totalHeight - heightAbove topLayer = 26 := by decide - simp only [treeIndexAt_val, h0] - omega - -/-- The layers link: the tree used on a layer is the one whose root sits at leaf `e_(lay-1)` of the -tree used on the layer above. -/ -theorem layers_link_top (index : Index) : - (treeIndexAt index middleLayer).val - = (treeIndexAt index topLayer).val * 2 ^ layerHeight topLayer - + (leafIndexAt index topLayer).val := by - have hlt : index.val < 2 ^ 26 := index.isLt - have h0 : totalHeight - heightAbove topLayer = 26 := by decide - have h1 : totalHeight - heightAbove middleLayer = 14 := by decide - have hb : heightBelow topLayer = 14 := by decide - have hh : layerHeight topLayer = 12 := by decide - simp only [treeIndexAt_val, leafIndexAt_val, h0, h1, hb, hh] - omega - -theorem layers_link_middle (index : Index) : - (treeIndexAt index bottomLayer).val - = (treeIndexAt index middleLayer).val * 2 ^ layerHeight middleLayer - + (leafIndexAt index middleLayer).val := by - have h1 : totalHeight - heightAbove middleLayer = 14 := by decide - have h2 : totalHeight - heightAbove bottomLayer = 7 := by decide - have hb : heightBelow middleLayer = 7 := by decide - have hh : layerHeight middleLayer = 7 := by decide - simp only [treeIndexAt_val, leafIndexAt_val, h1, h2, hb, hh] - omega - -/-- The bottom layer's leaves are the `2^h` indices themselves. -/ -theorem leafIndexAt_bottomLayer (index : Index) : - (leafIndexAt index bottomLayer).val = index.val % 2 ^ layerHeight bottomLayer := by - have hb : heightBelow bottomLayer = 0 := by decide - simp [leafIndexAt_val, hb] - -/-! ### The one-time signature -/ - -def leafOfNat (value : Nat) : LeafIndex := - ⟨value % 2 ^ maxLayerHeight, Nat.mod_lt _ (Nat.two_pow_pos _)⟩ - -/-- `Chain_{lay,tau,e,i}(P, start, steps, value)`: the step onto position `start + steps + 1` carries tweak position `2^w * i + start + steps`. -/ -def chainWalk {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) - (chainIdx : ChainIndex) : Nat → Nat → Digest → m Digest - | _, 0, value => pure value - | start, steps + 1, value => do - let previous ← chainWalk parameter lay tree leaf chainIdx start steps value - if hstep : start + steps < chainLength - 1 then - tweakableHash parameter (.chain lay tree leaf chainIdx ⟨start + steps, hstep⟩) - (digestBytes previous) - else - pure 0 - -/-- The verifier's half of a chain: walk the remaining `2^w - 1 - x_i` steps. -/ -def recoverChain {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) - (chainIdx : ChainIndex) (digit : Digit) (value : Digest) : m Digest := - chainWalk parameter lay tree leaf chainIdx digit.val (chainLength - 1 - digit.val) value - -def oneTimePublicKey {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) - (secret : ChainIndex → Digest) : m (ChainIndex → Digest) := - sequenceFin fun chainIdx => - chainWalk parameter lay tree leaf chainIdx 0 (chainLength - 1) (secret chainIdx) - -def leafPayload (endpoints : ChainIndex → Digest) : HashInput := - (List.ofFn endpoints).flatMap digestBytes - -def leafHash {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) - (endpoints : ChainIndex → Digest) : m Digest := - tweakableHash parameter (.leaf lay tree leaf) (leafPayload endpoints) - -/-- `Enc(P, lay, tau, e, M, c)`: hash the message with the counter under the leaf's encoding tweak, and decode. -/ -def encode {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) - (message : Digest) (counter : Counter) : m (Option Encoding) := do - let digest ← tweakableHash parameter (.encoding lay tree leaf) - (digestBytes message ++ counterBytes counter) - return TargetSum.decodeDigest digest - -/-- `OtsSign`: the least admissible counter, and the chain values it dictates. The search starts at `0` and stops after `encodingAttemptLimit` counters. -/ -def otsSignFrom {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) - (secret : ChainIndex → Digest) (message : Digest) : - Nat → Nat → m (Option (Counter × (ChainIndex → Digest))) - | 0, _ => pure none - | attempts + 1, counter => do - match ← encode parameter lay tree leaf message (BitVec.ofNat counterBits counter) with - | some encoding => do - let values ← sequenceFin fun chainIdx => - chainWalk parameter lay tree leaf chainIdx 0 (encoding chainIdx).val (secret chainIdx) - return some (BitVec.ofNat counterBits counter, values) - | none => otsSignFrom parameter lay tree leaf secret message attempts (counter + 1) - -def otsSign {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) - (secret : ChainIndex → Digest) (message : Digest) : - m (Option (Counter × (ChainIndex → Digest))) := - otsSignFrom parameter lay tree leaf secret message encodingAttemptLimit 0 - -/-- `OtsLeaf`: the verifier's leaf, or nothing if the counter does not encode the message. -/ -def otsLeaf {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) - (message : Digest) (counter : Counter) (values : ChainIndex → Digest) : m (Option Digest) := do - match ← encode parameter lay tree leaf message counter with - | none => pure none - | some encoding => do - let endpoints ← sequenceFin fun chainIdx => - recoverChain parameter lay tree leaf chainIdx (encoding chainIdx) (values chainIdx) - let value ← leafHash parameter lay tree leaf endpoints - return some value - -/-! ### A layer -/ - -def nodePayload (left right : Digest) : HashInput := - digestBytes left ++ digestBytes right - -/-- `X^{lay,tau}_{level,nodeIdx}`, the Merkle tree over the layer's one-time leaves. -/ -def treeNode {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) - (secret : LeafIndex → ChainIndex → Digest) : Nat → Nat → m Digest - | 0, nodeIdx => do - let leaf := leafOfNat nodeIdx - let endpoints ← oneTimePublicKey parameter lay tree leaf (secret leaf) - leafHash parameter lay tree leaf endpoints - | level + 1, nodeIdx => do - let left ← treeNode parameter lay tree secret level (2 * nodeIdx) - let right ← treeNode parameter lay tree secret level (2 * nodeIdx + 1) - tweakableHash parameter (.node lay tree (level + 1) nodeIdx) (nodePayload left right) - -attribute [irreducible] treeNode - -def treeRoot {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) - (secret : LeafIndex → ChainIndex → Digest) : m Digest := - treeNode parameter lay tree secret (layerHeight lay) 0 - -/-- `TreePath`: `A_level = X^{lay,tau}_{level, floor(e / 2^level) xor 1}` for the layer's own `h_lay` levels, and nothing above them. -/ -def treePath {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) - (secret : LeafIndex → ChainIndex → Digest) (leaf : LeafIndex) : m (Fin maxLayerHeight → Digest) := - sequenceFin fun level => - if level.val < layerHeight lay then - treeNode parameter lay tree secret level (Nat.xor (leaf.val / 2 ^ level.val) 1) - else - pure 0 - -/-- `TreeFold`: fold a leaf and a path into the layer's root. -/ -def treeFold {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (lay : Layer) (tree : TreeIndex) (leaf : LeafIndex) - (path : Nat → Digest) : Nat → Digest → m Digest - | 0, value => pure value - | levels + 1, value => do - let current ← treeFold parameter lay tree leaf path levels value - let sibling := path levels - let nodeIdx := leaf.val / 2 ^ (levels + 1) - if leaf.val.testBit levels then - tweakableHash parameter (.node lay tree (levels + 1) nodeIdx) (nodePayload sibling current) - else - tweakableHash parameter (.node lay tree (levels + 1) nodeIdx) (nodePayload current sibling) - -/-! ### The few-time signature -/ - -def ftsLeafOfNat (value : Nat) : FtsLeaf := - ⟨value % 2 ^ ftsTreeHeight, Nat.mod_lt _ (Nat.two_pow_pos _)⟩ - -/-- The index group of the digest that selects this tree's leaf. -/ -def ftsIndexOf (tree : FtsTree) : DigestTree := - tree.castLE (Nat.sub_le ftsTrees 1) - -/-- The last index group, the one the digest is resampled to zero and the verifier checks. Its tree is the dropped one. -/ -def lastDigestTree : DigestTree := ⟨ftsTrees - 1, by decide⟩ - -def ftsLeafHash {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (index : Index) (tree : FtsTree) (leaf : FtsLeaf) - (secret : Digest) : m Digest := - tweakableHash parameter (.ftsLeaf index tree leaf) (digestBytes secret) - -/-- `Y^{idx,kappa}_{level,nodeIdx}`, one tree of the forest. -/ -def ftsNode {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (index : Index) (tree : FtsTree) - (secret : FtsLeaf → Digest) : Nat → Nat → m Digest - | 0, nodeIdx => do - let leaf := ftsLeafOfNat nodeIdx - ftsLeafHash parameter index tree leaf (secret leaf) - | level + 1, nodeIdx => do - let left ← ftsNode parameter index tree secret level (2 * nodeIdx) - let right ← ftsNode parameter index tree secret level (2 * nodeIdx + 1) - tweakableHash parameter (.ftsNode index tree (level + 1) nodeIdx) (nodePayload left right) - -attribute [irreducible] ftsNode - -def ftsRootsPayload (roots : FtsTree → Digest) : HashInput := - (List.ofFn roots).flatMap digestBytes - -/-- `FtsKey(P, idx)`, the hash of the forest's `k - 1` roots. -/ -def ftsKey {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (index : Index) - (secret : FtsTree → FtsLeaf → Digest) : m Digest := do - let roots ← sequenceFin fun tree => - ftsNode parameter index tree (secret tree) ftsTreeHeight 0 - tweakableHash parameter (.ftsRoots index) (ftsRootsPayload roots) - -/-- `FtsOpen`: the opened secrets and, per tree, the `a` siblings of the opened leaf. -/ -def ftsOpen {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (index : Index) (leaves : DigestTree → FtsLeaf) - (secret : FtsTree → FtsLeaf → Digest) : m (FtsTree → Fin ftsTreeHeight → Digest) := - sequenceFin fun tree => - sequenceFin fun level => - ftsNode parameter index tree (secret tree) level.val - (Nat.xor ((leaves (ftsIndexOf tree)).val / 2 ^ level.val) 1) - -/-- The verifier's half of one few-time tree. -/ -def ftsFold {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (index : Index) (tree : FtsTree) (leaf : FtsLeaf) - (path : Fin ftsTreeHeight → Digest) : Nat → Digest → m Digest - | 0, value => pure value - | levels + 1, value => do - let current ← ftsFold parameter index tree leaf path levels value - let sibling := if hlevel : levels < ftsTreeHeight then path ⟨levels, hlevel⟩ else 0 - let nodeIdx := leaf.val / 2 ^ (levels + 1) - if leaf.val.testBit levels then - tweakableHash parameter (.ftsNode index tree (levels + 1) nodeIdx) - (nodePayload sibling current) - else - tweakableHash parameter (.ftsNode index tree (levels + 1) nodeIdx) - (nodePayload current sibling) - -/-- `FtsRec`: recover the few-time public key from the opened secrets and paths. -/ -def ftsRecover {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (index : Index) (leaves : DigestTree → FtsLeaf) - (secrets : FtsTree → Digest) (paths : FtsTree → Fin ftsTreeHeight → Digest) : m Digest := do - let roots ← sequenceFin fun tree => do - let leaf := leaves (ftsIndexOf tree) - let value ← ftsLeafHash parameter index tree leaf (secrets tree) - ftsFold parameter index tree leaf (paths tree) ftsTreeHeight value - tweakableHash parameter (.ftsRoots index) (ftsRootsPayload roots) - -/-! ### The message digest -/ - -def messageDigestPayload (root : Digest) (message : Message) (randomness : Randomness) : HashInput := - randomnessBytes randomness ++ digestBytes root ++ messageBytes message - -/-- `Digest(P, root, m, rho)`, truncated to `h + k * a` bits. -/ -def messageDigest {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (root : Digest) (message : Message) - (randomness : Randomness) : m MessageDigest := do - let output ← oracleHash - (tweakableHashInput parameter .message (messageDigestPayload root message randomness)) - return truncateMessageDigest output - -/-- `idx = N mod 2^h`. -/ -def digestIndex (digest : MessageDigest) : Index := - (digest.extractLsb' 0 totalHeight).toFin - -/-- `u_kappa = floor(N / 2^(h + kappa * a)) mod 2^a`. -/ -def digestLeaves (digest : MessageDigest) : DigestTree → FtsLeaf := - fun tree => (digest.extractLsb' (totalHeight + ftsTreeHeight * tree.val) ftsTreeHeight).toFin - -/-- A digest is admissible exactly when its last index group is zero. -/ -def Admissible (digest : MessageDigest) : Prop := digestLeaves digest lastDigestTree = 0 - -instance (digest : MessageDigest) : Decidable (Admissible digest) := - inferInstanceAs (Decidable (digestLeaves digest lastDigestTree = 0)) - -/-! ### Verification -/ - -/-- Layer `lay`'s share of the signature's authentication path, its `h_lay` nodes starting at offset `sum_{j < lay} h_j`. -/ -def signaturePath (signature : Signature) (lay : Layer) (level : Nat) : Digest := - if hlevel : heightAbove lay + level < totalHeight then - signature.authPath ⟨heightAbove lay + level, hlevel⟩ - else - 0 - -/-- The hypertree walk, from the bottom layer up: `remaining + 1` enters at layer `remaining`, and layer `0`'s fold returns the value compared against the public root. -/ -def verifyLayers {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (parameter : PublicParameter) (index : Index) (signature : Signature) : - Nat → Digest → m (Option Digest) - | 0, message => pure (some message) - | remaining + 1, message => do - if hlayer : remaining < numLayers then - let lay : Layer := ⟨remaining, hlayer⟩ - let tree := treeIndexAt index lay - let leaf := leafIndexAt index lay - match ← otsLeaf parameter lay tree leaf message (signature.counter lay) - (signature.chainValue lay) with - | none => pure none - | some value => do - let root ← treeFold parameter lay tree leaf (signaturePath signature lay) - (layerHeight lay) value - verifyLayers parameter index signature remaining root - else - pure none - -def verify {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (publicKey : PublicKey) (message : Message) (signature : Signature) : m Bool := do - let digest ← messageDigest publicKey.parameter publicKey.root message signature.randomness - if ¬ Admissible digest then - return false - else - let index := digestIndex digest - let ftsPublicKey ← ftsRecover publicKey.parameter index (digestLeaves digest) - signature.ftsSecret signature.ftsPath - match ← verifyLayers publicKey.parameter index signature numLayers ftsPublicKey with - | none => return false - | some root => return decide (root = publicKey.root) - -attribute [irreducible] verify - -/-! ### Key generation -/ - -noncomputable local instance : SampleableType PublicParameter := - SampleableType.ofFintype PublicParameter - -noncomputable local instance : - SampleableType (Layer → TreeIndex → LeafIndex → ChainIndex → Digest) := - SampleableType.ofFintype (Layer → TreeIndex → LeafIndex → ChainIndex → Digest) - -noncomputable local instance : SampleableType (Index → FtsTree → FtsLeaf → Digest) := - SampleableType.ofFintype (Index → FtsTree → FtsLeaf → Digest) - -noncomputable local instance : SampleableType Randomness := - SampleableType.ofFintype Randomness - -noncomputable def sampleParameter : ProbComp PublicParameter := - $ᵗ PublicParameter - -noncomputable def sampleOtsSecrets : - ProbComp (Layer → TreeIndex → LeafIndex → ChainIndex → Digest) := - $ᵗ (Layer → TreeIndex → LeafIndex → ChainIndex → Digest) - -noncomputable def sampleFtsSecrets : ProbComp (Index → FtsTree → FtsLeaf → Digest) := - $ᵗ (Index → FtsTree → FtsLeaf → Digest) - -noncomputable def sampleRandomness : ProbComp Randomness := - $ᵗ Randomness - -attribute [irreducible] sampleParameter sampleOtsSecrets sampleFtsSecrets sampleRandomness - -def rootTree : TreeIndex := ⟨0, Nat.two_pow_pos _⟩ - -/-- `Gen`: sample the parameter and every secret, and build layer `0`'s tree for the root. The trees below it are built when a signature needs them, so nothing else is computed here. -/ -noncomputable def keygen : OracleComp OracleWorld (PublicKey × SecretKey) := do - let parameter ← liftM sampleParameter - let otsSecret ← liftM sampleOtsSecrets - let ftsSecret ← liftM sampleFtsSecrets - let root ← liftM - (treeRoot parameter topLayer rootTree (otsSecret topLayer rootTree) : - OracleComp HashSpec Digest) - return (⟨root, parameter⟩, ⟨parameter, root, otsSecret, ftsSecret⟩) - -attribute [irreducible] keygen - -/-! ### Signing -/ - -/-- One digest attempt: one hash, keeping the index and the leaf indices if the digest is admissible. -/ -def signAttempt {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (secretKey : SecretKey) (message : Message) (randomness : Randomness) : - m (Option (Index × (DigestTree → FtsLeaf))) := do - let digest ← messageDigest secretKey.parameter secretKey.root message randomness - if Admissible digest then - return some (digestIndex digest, digestLeaves digest) - else - return none - -/-- The digest loop: at most `digestAttemptLimit` attempts, each sampling a fresh randomizer, stopping at the first admissible digest. It takes `2^a` attempts on average. -/ -noncomputable def signDigestLoop : Nat → SecretKey → Message → - OracleComp OracleWorld (Option (Randomness × Index × (DigestTree → FtsLeaf))) - | 0, _secretKey, _message => pure none - | attempts + 1, secretKey, message => do - let randomness ← liftM sampleRandomness - let attempt ← liftM - (signAttempt secretKey message randomness : - OracleComp HashSpec (Option (Index × (DigestTree → FtsLeaf)))) - match attempt with - | some (index, leaves) => pure (some (randomness, index, leaves)) - | none => signDigestLoop attempts secretKey message - -/-- The message layer `lay` signs: the root of the tree below it, or the few-time public key at the bottom. Every layer's message is fixed by the index alone, which is what makes the layers independent. -/ -def layerMessage {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (secretKey : SecretKey) (index : Index) (lay : Layer) : m Digest := - if hbelow : lay.val + 1 < numLayers then - let below : Layer := ⟨lay.val + 1, hbelow⟩ - treeRoot secretKey.parameter below (treeIndexAt index below) - (secretKey.otsSecret below (treeIndexAt index below)) - else - ftsKey secretKey.parameter index (secretKey.ftsSecret index) - -/-- One layer's contribution: its counter, its chain values, and its authentication path. -/ -def signLayer {m : Type → Type} [Monad m] [HasQuery HashSpec m] - (secretKey : SecretKey) (index : Index) (lay : Layer) : - m (Option (Counter × (ChainIndex → Digest) × (Fin maxLayerHeight → Digest))) := do - let tree := treeIndexAt index lay - let leaf := leafIndexAt index lay - let message ← layerMessage secretKey index lay - match ← otsSign secretKey.parameter lay tree leaf (secretKey.otsSecret lay tree leaf) message with - | none => return none - | some (counter, values) => do - let path ← treePath secretKey.parameter lay tree (secretKey.otsSecret lay tree) leaf - return some (counter, values, path) - -/-- Which layer's path an entry of the `h` belongs to. -/ -def layerOfPath (position : Nat) : Layer := - if position < heightAbove middleLayer then topLayer - else if position < heightAbove bottomLayer then middleLayer - else bottomLayer - -/-- Lay the `d` layers' paths end to end, top layer first, so that every one of the `h` entries is read by verification. -/ -def flattenPaths (paths : Layer → Fin maxLayerHeight → Digest) : PathIndex → Digest := - fun position => - let lay := layerOfPath position.val - let level := position.val - heightAbove lay - if hlevel : level < maxLayerHeight then paths lay ⟨level, hlevel⟩ else 0 - -theorem heightAbove_add_layerHeight_le (lay : Layer) : - heightAbove lay + layerHeight lay ≤ totalHeight := by decide +revert - -/-- An entry at a layer's own offset belongs to that layer. -/ -theorem layerOfPath_eq (lay : Layer) (level : Fin maxLayerHeight) (hlevel : level.val < layerHeight lay) : - layerOfPath (heightAbove lay + level.val) = lay := by - revert hlevel - revert level - revert lay - decide - -theorem flattenPaths_apply (paths : Layer → Fin maxLayerHeight → Digest) (lay : Layer) - (level : Fin maxLayerHeight) (hlevel : level.val < layerHeight lay) (position : PathIndex) - (hposition : position.val = heightAbove lay + level.val) : - flattenPaths paths position = paths lay level := by - simp only [flattenPaths, hposition, layerOfPath_eq lay level hlevel, Nat.add_sub_cancel_left, - dif_pos level.isLt, Fin.eta] - -/-- The verifier reads a layer's path node exactly where the signer laid it, so the `h` entries of the authentication path are the `d` layers' paths and nothing else. -/ -theorem signaturePath_flattenPaths (signature : Signature) - (paths : Layer → Fin maxLayerHeight → Digest) (hpath : signature.authPath = flattenPaths paths) - (lay : Layer) (level : Fin maxLayerHeight) (hlevel : level.val < layerHeight lay) : - signaturePath signature lay level.val = paths lay level := by - have hlt : heightAbove lay + level.val < totalHeight := - lt_of_lt_of_le (by omega) (heightAbove_add_layerHeight_le lay) - rw [signaturePath, dif_pos hlt, hpath] - exact flattenPaths_apply paths lay level hlevel _ rfl - -/-- Every one of the `h` entries is read by some layer: the offsets partition `0..h-1` into the `d` layers, so the path carries no entry verification skips and none twice. This is arithmetic about the offsets, not a statement about `verifyLayers`. -/ -theorem authPath_exhausted (position : PathIndex) : ∃ lay : Layer, ∃ level : Fin maxLayerHeight, - level.val < layerHeight lay ∧ heightAbove lay + level.val = position.val := by - revert position - decide - -/-- `Sig(sk, m)`: the digest loop, the few-time opening, one one-time signature per layer, and the assembled signature. -/ -noncomputable def sign (secretKey : SecretKey) (message : Message) : - OracleComp OracleWorld (Option Signature) := do - match ← signDigestLoop digestAttemptLimit secretKey message with - | none => return none - | some (randomness, index, leaves) => do - let ftsPath ← liftM - (ftsOpen secretKey.parameter index leaves (secretKey.ftsSecret index) : - OracleComp HashSpec (FtsTree → Fin ftsTreeHeight → Digest)) - let layers ← liftM - (sequenceFin (fun lay => signLayer secretKey index lay) : - OracleComp HashSpec - (Layer → Option (Counter × (ChainIndex → Digest) × (Fin maxLayerHeight → Digest)))) - match traverseOption layers with - | none => return none - | some parts => - return some - { randomness := randomness - ftsSecret := fun tree => secretKey.ftsSecret index tree (leaves (ftsIndexOf tree)) - ftsPath := ftsPath - counter := fun lay => (parts lay).1 - chainValue := fun lay => (parts lay).2.1 - authPath := flattenPaths fun lay => (parts lay).2.2 } - -attribute [irreducible] sign - -end Concrete - -/-! ## The security experiment -/ - -/-- The random-oracle semantics: hash queries are answered lazily and consistently by uniform sampling and cached; uniform-sampling queries are forwarded unchanged. -/ -noncomputable def romImpl : QueryImpl OracleWorld (StateT (QueryCache HashSpec) ProbComp) := - unifFwdImpl HashSpec + - (randomOracle : QueryImpl HashSpec (StateT (QueryCache HashSpec) ProbComp)) - -/-- A signing request is a message alone: the scheme is stateless, and the signer chooses the index by hashing. -/ -abbrev SignRequest := Message - -/-- A claimed forgery: a message and a signature. -/ -structure Forgery where - message : Message - signature : Signature -deriving DecidableEq - -/-- The interface of a stateless signature scheme in the random-oracle experiment. Signing is randomized and may fail, so it returns an option. -/ -structure Scheme where - keygen : OracleComp OracleWorld (PublicKey × SecretKey) - sign : SecretKey → Message → OracleComp OracleWorld (Option Signature) - verify : PublicKey → Message → Signature → OracleComp OracleWorld Bool - -/-- The signing oracle answers a request with either a signature or `none` if the signer fails. -/ -abbrev SigningSpec := SignRequest →ₒ Option Signature - -/-- A classical adaptive adversary. After receiving the public key, it may query the shared random oracle, request signatures, and finally return a claimed forgery. -/ -structure Adversary where - main : PublicKey → OracleComp (OracleWorld + SigningSpec) Forgery - -namespace SigningTranscript - -/-- A signing transcript is valid exactly when the key signed at most `q_s` messages. Nothing forbids repeating a message: the signer is stateless, and a fresh randomizer makes the second signature a different one. -/ -def Valid (log : QueryLog SigningSpec) : Prop := log.length ≤ signatureLimit - -instance (log : QueryLog SigningSpec) : Decidable (Valid log) := - inferInstanceAs (Decidable (log.length ≤ signatureLimit)) - -/-- The signer returned the claimed forgery exactly when the transcript contains the same message answered by the same signature. A different signature for a signed message is therefore a valid strong forgery. -/ -def Contains (log : QueryLog SigningSpec) (forgery : Forgery) : Prop := - ∃ entry ∈ log, entry.1 = forgery.message ∧ entry.2 = some forgery.signature - -instance (log : QueryLog SigningSpec) (forgery : Forgery) : Decidable (Contains log forgery) := - inferInstanceAs - (Decidable (∃ entry ∈ log, entry.1 = forgery.message ∧ entry.2 = some forgery.signature)) - -end SigningTranscript - -/-- The signing oracle used in the game. It records every request and response while forwarding the request to the scheme's signer. -/ -def signingOracle (scheme : Scheme) (sk : SecretKey) : - QueryImpl SigningSpec (WriterT (QueryLog SigningSpec) (OracleComp OracleWorld)) := - QueryImpl.withLogging fun request => scheme.sign sk request - -/-- Forward the shared random oracle and uniform sampling to the adversary unchanged, alongside the logged signing oracle. -/ -def forwardOracles : - QueryImpl OracleWorld (WriterT (QueryLog SigningSpec) (OracleComp OracleWorld)) := - fun input => liftM (OracleWorld.query input) - -/-- The complete strong-unforgeability experiment. - -The random oracle is sampled lazily by the semantics of `OracleWorld`. Key generation, the adversary, the signing oracle, and final verification all share the same oracle. The game returns `true` precisely when the transcript holds at most `q_s` signatures, the claimed forgery is not one the signer returned for that message, and the signature verifies. -/ -noncomputable def gameCore (scheme : Scheme) (adversary : Adversary) : - OracleComp OracleWorld Bool := do - let (pk, sk) ← scheme.keygen - let ((forgery, log) : Forgery × QueryLog SigningSpec) ← - (simulateQ (forwardOracles + signingOracle scheme sk) (adversary.main pk)).run - let verified ← scheme.verify pk forgery.message forgery.signature - return decide (SigningTranscript.Valid log ∧ ¬SigningTranscript.Contains log forgery) && verified - -/-- The probability that the adversary wins, over key generation, signer randomness, and the random oracle, which starts from the empty cache. The final cache is discarded. -/ -noncomputable def forgeAdvantage (scheme : Scheme) (adversary : Adversary) : ℝ≥0∞ := - Pr[= true | (simulateQ romImpl (gameCore scheme adversary)).run' ∅] - -/-- The whole experiment makes at most `q` random-oracle queries on every execution path. The count includes queries during key generation, adversarial hashing, signing, and final verification. Uniform sampling operations are not hash queries. -/ -def HasHashQueryBound (scheme : Scheme) (adversary : Adversary) (q : Nat) : Prop := - (gameCore scheme adversary).IsQueryBoundP (· matches .inr _) q - -/-- Having `bits` bits of classical security means that every classical adaptive adversary whose complete experiment stays within a nonzero hash-query budget `q` forges with probability at most `q / 2^bits`. The bound is a slope, so it bounds what a query buys and not what the first one does; a budget below what the honest experiment alone spends admits no adversary and the bound is vacuous there. -/ -def HasClassicalSecurityBits (scheme : Scheme) (bits : Nat) : Prop := - ∀ q, 1 ≤ q → ∀ adversary, HasHashQueryBound scheme adversary q → - forgeAdvantage scheme adversary ≤ q / ((2 ^ bits : Nat) : ℝ≥0∞) - -/-- The concrete SPHINCS scheme: key generation, the stateless randomized signer, and the verifier defined above. -/ -noncomputable def Concrete.scheme : Scheme where - keygen := Concrete.keygen - sign := Concrete.sign - verify := fun publicKey message signature => - liftM (Concrete.verify publicKey message signature : OracleComp HashSpec Bool) - -/-- The complete public security claim: `120` bits of classical strong unforgeability in the random-oracle model, at `2^24` signatures per key pair. -/ -abbrev SphincsSecurityStatement : Prop := - HasClassicalSecurityBits Concrete.scheme securityBits - -end SphincsSecurity diff --git a/formal/sphincs/lake-manifest.json b/formal/sphincs/lake-manifest.json deleted file mode 100644 index 19314c3ca..000000000 --- a/formal/sphincs/lake-manifest.json +++ /dev/null @@ -1,126 +0,0 @@ -{"version": "1.2.0", - "packagesDir": ".lake/packages", - "packages": - [{"url": "https://github.com/Verified-zkEVM/VCVio.git", - "type": "git", - "subDir": null, - "scope": "", - "rev": "cbd4144b51d92da00dd50f05e068b2348fa6e529", - "name": "VCVio", - "manifestFile": "lake-manifest.json", - "inputRev": "cbd4144", - "inherited": false, - "configFile": "lakefile.lean"}, - {"url": "https://github.com/Verified-zkEVM/PolyFun.git", - "type": "git", - "subDir": null, - "scope": "", - "rev": "04a12b67fa2048c9412fdd26ed9e446f25919d37", - "name": "PolyFun", - "manifestFile": "lake-manifest.json", - "inputRev": "04a12b67fa2048c9412fdd26ed9e446f25919d37", - "inherited": true, - "configFile": "lakefile.toml"}, - {"url": "https://github.com/leanprover-community/mathlib4", - "type": "git", - "subDir": null, - "scope": "leanprover-community", - "rev": "fabf563a7c95a166b8d7b6efca11c8b4dc9d911f", - "name": "mathlib", - "manifestFile": "lake-manifest.json", - "inputRev": "v4.31.0", - "inherited": true, - "configFile": "lakefile.lean"}, - {"url": "https://github.com/quangvdao/loom2", - "type": "git", - "subDir": null, - "scope": "", - "rev": "0e11dcf85dd5fbb362bf6a6cafaba5c476ed9333", - "name": "loom2", - "manifestFile": "lake-manifest.json", - "inputRev": "lean-4.31", - "inherited": true, - "configFile": "lakefile.toml"}, - {"url": "https://github.com/leanprover-community/plausible", - "type": "git", - "subDir": null, - "scope": "leanprover-community", - "rev": "63045536fe95024e6c18fc7b48e03f506701c5bc", - "name": "plausible", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.toml"}, - {"url": "https://github.com/leanprover-community/LeanSearchClient", - "type": "git", - "subDir": null, - "scope": "leanprover-community", - "rev": "c5d5b8fe6e5158def25cd28eb94e4141ad97c843", - "name": "LeanSearchClient", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.toml"}, - {"url": "https://github.com/leanprover-community/import-graph", - "type": "git", - "subDir": null, - "scope": "leanprover-community", - "rev": "5c7542ed018c78194f1e2b903eaf6a792b74c03d", - "name": "importGraph", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.toml"}, - {"url": "https://github.com/leanprover-community/ProofWidgets4", - "type": "git", - "subDir": null, - "scope": "leanprover-community", - "rev": "24b0d9dc081c5423f8eec7e866c441e5184f29d9", - "name": "proofwidgets", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.lean"}, - {"url": "https://github.com/leanprover-community/aesop", - "type": "git", - "subDir": null, - "scope": "leanprover-community", - "rev": "e3cb2f741431ce31bf73549fb52316a57368b06f", - "name": "aesop", - "manifestFile": "lake-manifest.json", - "inputRev": "master", - "inherited": true, - "configFile": "lakefile.toml"}, - {"url": "https://github.com/leanprover-community/quote4", - "type": "git", - "subDir": null, - "scope": "leanprover-community", - "rev": "f46324995fca5f0483b742e4eb4daec7f4ee50d2", - "name": "Qq", - "manifestFile": "lake-manifest.json", - "inputRev": "master", - "inherited": true, - "configFile": "lakefile.toml"}, - {"url": "https://github.com/leanprover-community/batteries", - "type": "git", - "subDir": null, - "scope": "leanprover-community", - "rev": "fa08db58b30eb033edcdab331bba000827f9f785", - "name": "batteries", - "manifestFile": "lake-manifest.json", - "inputRev": "main", - "inherited": true, - "configFile": "lakefile.toml"}, - {"url": "https://github.com/leanprover/lean4-cli", - "type": "git", - "subDir": null, - "scope": "leanprover", - "rev": "92564e5770e4d09f2d86dfbf8ada1e9c715b384c", - "name": "Cli", - "manifestFile": "lake-manifest.json", - "inputRev": "v4.31.0", - "inherited": true, - "configFile": "lakefile.toml"}], - "name": "«xmss-security»", - "lakeDir": ".lake", - "fixedToolchain": false} diff --git a/formal/sphincs/lakefile.toml b/formal/sphincs/lakefile.toml deleted file mode 100644 index 982fa1d28..000000000 --- a/formal/sphincs/lakefile.toml +++ /dev/null @@ -1,11 +0,0 @@ -name = "sphincs-security" -version = "0.1.0" -defaultTargets = ["SphincsSecurity"] - -[[require]] -name = "VCVio" -git = "https://github.com/Verified-zkEVM/VCVio.git" -rev = "cbd4144" - -[[lean_lib]] -name = "SphincsSecurity" diff --git a/formal/sphincs/lean-toolchain b/formal/sphincs/lean-toolchain deleted file mode 100644 index 18640c8b0..000000000 --- a/formal/sphincs/lean-toolchain +++ /dev/null @@ -1 +0,0 @@ -leanprover/lean4:v4.31.0 From 6a343c0707f472300051354409ba43fb423862cb Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Tue, 25 Aug 2026 15:51:19 +0200 Subject: [PATCH 30/31] doc/sphincs: drop the parameter search 2709 lines that chose the instance rather than describing it: the security, size and hash-count models, the search for the cheapest verification under a budget, and the goldens pinning all of it against the Blockstream project's sage fixtures. Its work is done, the instance it picked being specified in `main.tex` and implemented in `crates/sphincs`, and it modelled schemes this repo does not prove anything about, so keeping it in the tree only invited the two to drift. Kept on the `sphincs-params-search` branch, which is this commit's parent with the directory intact, for whoever wants to move the parameters again. It was a standalone cargo workspace, so nothing here built or tested it: the root workspace takes `crates/*` only, and `doc.yml` reaches into `doc/sphincs` for the LaTeX alone. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 1 - doc/sphincs/params_selection/Cargo.lock | 7 - doc/sphincs/params_selection/Cargo.toml | 17 - doc/sphincs/params_selection/README.md | 28 - doc/sphincs/params_selection/src/cost.rs | 317 ---------- doc/sphincs/params_selection/src/lib.rs | 59 -- doc/sphincs/params_selection/src/main.rs | 357 ----------- doc/sphincs/params_selection/src/params.rs | 469 -------------- doc/sphincs/params_selection/src/report.rs | 201 ------ doc/sphincs/params_selection/src/search.rs | 547 ---------------- doc/sphincs/params_selection/src/security.rs | 119 ---- doc/sphincs/params_selection/tests/goldens.rs | 588 ------------------ 12 files changed, 2710 deletions(-) delete mode 100644 doc/sphincs/params_selection/Cargo.lock delete mode 100644 doc/sphincs/params_selection/Cargo.toml delete mode 100644 doc/sphincs/params_selection/README.md delete mode 100644 doc/sphincs/params_selection/src/cost.rs delete mode 100644 doc/sphincs/params_selection/src/lib.rs delete mode 100644 doc/sphincs/params_selection/src/main.rs delete mode 100644 doc/sphincs/params_selection/src/params.rs delete mode 100644 doc/sphincs/params_selection/src/report.rs delete mode 100644 doc/sphincs/params_selection/src/search.rs delete mode 100644 doc/sphincs/params_selection/src/security.rs delete mode 100644 doc/sphincs/params_selection/tests/goldens.rs diff --git a/AGENTS.md b/AGENTS.md index a1b33714b..9a2c5ada7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,6 @@ 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. -- `doc/sphincs/params_selection/` is a parameter-exploration tool for SPHINCS+ (security, signature size, hash counts, and a search for the cheapest verification under given budgets), in its own cargo workspace with no dependencies. It models the schemes of the Blockstream report, not anything this repo proves, and its `cargo test --release` pins every number against that project's sage fixtures. - `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). diff --git a/doc/sphincs/params_selection/Cargo.lock b/doc/sphincs/params_selection/Cargo.lock deleted file mode 100644 index f16565f89..000000000 --- a/doc/sphincs/params_selection/Cargo.lock +++ /dev/null @@ -1,7 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "sphincs_params" -version = "0.1.0" diff --git a/doc/sphincs/params_selection/Cargo.toml b/doc/sphincs/params_selection/Cargo.toml deleted file mode 100644 index 1387cca24..000000000 --- a/doc/sphincs/params_selection/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "sphincs_params" -version = "0.1.0" -edition = "2024" - -# Its own workspace: a parameter-exploration tool for doc/, not part of the -# proving stack, and nothing in crates/ depends on it. -[workspace] - -[dependencies] - -[lints.clippy] -too_many_arguments = "allow" - -[profile.release] -lto = "thin" -codegen-units = 1 diff --git a/doc/sphincs/params_selection/README.md b/doc/sphincs/params_selection/README.md deleted file mode 100644 index 7fedd41fd..000000000 --- a/doc/sphincs/params_selection/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# SPHINCS+ parameter selection - -Security, signature size and hash counts for the WOTS/FORS schemes of "Hash-based Signature Schemes for Bitcoin" (Kudinov, Nick, Blockstream Research), and a search for the set that verifies cheapest under a given set of budgets. See `src/lib.rs` for what is modelled and what is deliberately not. - -One command. Give a parameter to pin it, leave it out to search it. Numbers may be written as `2e6` or `100,000` or `100_000`, including the lifetime, which is a signature count rather than its log: - -```sh -cd doc/sphincs/params_selection -cargo run --release -- --lifetime 1e12 --scheme W+C_F+C --height 40 --layers 5 --top-height 8 -a 14 -k 11 -w 256 --drop-chains 0 --swn 2040 -``` - -That pins everything, so it just costs that one set: the report's bold 2^40 row, 4356 bytes and 10425 compressions to verify. Size and verification do not depend on the lifetime, only the security line does. Leave axes out and they get searched instead, against whichever budgets you set: - -```sh -cargo run --release -- --lifetime 16,777,216 --max-keygen 2e6 --max-sign 200,000 --max-size 5000 -``` - -Every cost is compression calls, one per 64 bytes of hash input: a Merkle node or a WOTS chain step is one, the message digest two, compressing `m` hash values `ceil((2n + mn) / 64)`. - -`--max-sign` counts signing with the top XMSS tree's half top already in state, which is `sqrt(2^h_top)` of storage for a `sqrt(2^h_top)`-cost top tree and the steady-state cost of a signer that keeps it. That state is a cache, not state in the XMSS sense: it is a deterministic function of the seed, so losing it costs recomputation and nothing else. A signer holding nothing rebuilds every tree instead, a cost this computes but does not report, since it is paid once after restoring a backup. - -Since size and verification depend only on `(h, d)` and not on how the layers divide `h`, a taller top layer is free on both and cheaper to sign with the cache: compare `--top-height 8` against `--top-height 15` at `--height 40 --layers 5`. - -Layer heights can be given outright with `--heights 11,5,7,3`, which pins `h` and `d` with them. The search never produces an uneven lower half, and that is not a restriction: for the same `h`, `d` and top height, no other profile costs less on anything, since size, verification and keygen do not move and signing sums `2^height`, which at a fixed total is smallest when the heights are equal. `profile_shape_is_never_beaten` checks that against every composition of several small `(h, d)`. So `--heights` is for costing a profile you already have in mind. - -`cargo run --release --` with no arguments prints every flag and its default. `cargo test --release` runs the goldens: the upstream sage fixtures, the report's own tables, and a naive search oracle that skips nothing. - -The search is exhaustive over hardcoded ranges and warns when its answer leans on the top of one. Budgets loose enough that nothing prunes can take a couple of minutes, reported by `--stats`; realistic ones finish in seconds. diff --git a/doc/sphincs/params_selection/src/cost.rs b/doc/sphincs/params_selection/src/cost.rs deleted file mode 100644 index b368c5797..000000000 --- a/doc/sphincs/params_selection/src/cost.rs +++ /dev/null @@ -1,317 +0,0 @@ -//! Signature size and hash counts for one SPHINCS+ parameter set. -//! -//! Ported from `costs.sage` of BlockstreamResearch/SPHINCS-Parameters, which is -//! the companion to "Hash-based Signature Schemes for Bitcoin". `tests/goldens` -//! pins every number this module produces against that repo's frozen fixtures -//! and against the report's own tables. - -use std::ops::{Add, Mul, Sub}; - -/// The WOTS+C grinding counter, carried once per hypertree layer. -pub const COUNTER_BYTES: u64 = 4; - -/// What something costs. -/// -/// `compressions` is the number everything here is measured in and the only one -/// reported. `hashes`, the number of tweakable-hash and PRF invocations, is -/// carried alongside it only because the report publishes hash counts too, so -/// `tests/goldens` can check this model against both of its columns. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct Cost { - pub hashes: u64, - pub compressions: u64, -} - -impl Cost { - pub const fn new(hashes: u64, compressions: u64) -> Self { - Self { hashes, compressions } - } -} - -impl Add for Cost { - type Output = Self; - fn add(self, o: Self) -> Self { - Self::new( - self.hashes.saturating_add(o.hashes), - self.compressions.saturating_add(o.compressions), - ) - } -} - -impl Sub for Cost { - type Output = Self; - fn sub(self, o: Self) -> Self { - Self::new(self.hashes - o.hashes, self.compressions - o.compressions) - } -} - -impl Mul for Cost { - type Output = Self; - fn mul(self, m: u64) -> Self { - Self::new(self.hashes.saturating_mul(m), self.compressions.saturating_mul(m)) - } -} - -/// One compression per 64 bytes of hash input. -pub const BLOCK: u64 = 64; - -/// The message a signature covers: a 256-bit digest of it. -pub const MESSAGE_BYTES: u64 = 32; - -/// What each kind of hash costs, in compression calls. -/// -/// Every hash here is `Th(P, tweak, payload)`, whose input is the n-byte public -/// parameter, the n-byte tweak, and then the payload, and the compression -/// function takes 64 bytes of it at a time. BLAKE2s absorbs 64 bytes per call -/// and carries the byte counter and final-block flag as compression inputs -/// rather than as a block, so nothing is spent on padding; SHA-256 under the -/// length-prefixed Merkle-Damgard of `primitives::sha2` behaves the same way. -/// -/// At n = 16 that makes a chain step and a Merkle node one compression each, -/// the message hash two, and the compression of `m` hash values -/// `ceil((32 + 16m) / 64)`. Which is, for every m, exactly what the report's -/// SHA-2 layout with the PK.seed midstate cached comes to, so its published -/// compression counts are still the yardstick in `tests/goldens`. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Blocks { - pub n: u64, -} - -impl Blocks { - pub const fn new(n: u64) -> Self { - Self { n } - } - /// Compressions for a hash over `payload` bytes. - pub const fn of(&self, payload: u64) -> u64 { - (2 * self.n + payload).div_ceil(BLOCK) - } - /// A secret key element from the seed. - pub const fn prf(&self) -> u64 { - self.of(self.n) - } - /// One step along a WOTS chain. - pub const fn chain_step(&self) -> u64 { - self.of(self.n) - } - /// The same, plus the WOTS+C counter the verifier hashes in once per layer. - pub const fn chain_step_with_counter(&self) -> u64 { - self.of(self.n + COUNTER_BYTES) - } - /// One Merkle node from its two children. - pub const fn merkle_node(&self) -> u64 { - self.of(2 * self.n) - } - /// Compressing `values` hash values into one: a WOTS public key, or the - /// FORS roots. - pub const fn compress(&self, values: u64) -> u64 { - self.of(values * self.n) - } - /// The randomized message digest, over R, PK.root and the message. - pub const fn message_hash(&self) -> u64 { - self.of(2 * self.n + MESSAGE_BYTES) - } - /// Deriving that randomness from the secret seed and the message. - pub const fn message_prf(&self) -> u64 { - self.of(self.n + MESSAGE_BYTES) - } -} - -/// Which one-time and few-time schemes a parameter set is built from. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum Scheme { - /// Plain SPHINCS+ / SLH-DSA: WOTS-TW + FORS. - Spx, - /// WOTS+C (fixed digit sum, no checksum chains) + FORS. - Wc, - /// WOTS+C + FORS+C (last FORS tree removed by grinding). - WcFc, -} - -pub const SCHEMES: [Scheme; 3] = [Scheme::Spx, Scheme::Wc, Scheme::WcFc]; - -impl Scheme { - pub const fn wots_c(self) -> bool { - !matches!(self, Scheme::Spx) - } - pub const fn fors_c(self) -> bool { - matches!(self, Scheme::WcFc) - } - pub const fn label(self) -> &'static str { - match self { - Scheme::Spx => "SPX", - Scheme::Wc => "W+C", - Scheme::WcFc => "W+C_F+C", - } - } - pub fn parse(s: &str) -> Option { - SCHEMES.into_iter().find(|x| x.label().eq_ignore_ascii_case(s)) - } - /// FORS trees actually built and authenticated: FORS+C grinds the last away. - pub const fn trees(self, k: u64) -> u64 { - if self.fors_c() { k - 1 } else { k } - } -} - -/// How a WOTS+C digest is cut into base-w chain positions. -/// -/// The report drops chains by forcing their digits to zero (its parameter z). -/// This uses the bit-pinning variant it offers as an alternative in "Complexity -/// Analysis of WOTS+C" (its z_b), which is what `doc/xmss/main.tex` does, -/// because it keeps the digest a whole number of chunks and needs no -/// partial-digit handling anywhere: -/// -/// ```text -/// chain_bits = log2(w) bits one chain carries -/// pinned = (8n) mod chain_bits + chain_bits * dropped_chains -/// chains = (8n - pinned) / chain_bits = floor(8n/chain_bits) - dropped -/// ``` -/// -/// The signer grinds the counter until the digest has its `pinned` top bits zero -/// AND its `chains` digits summing to S_wn, so out of the 2^(8n) digests exactly -/// nu = |{tuples summing to S_wn}| are admissible (see [`NuTable`]). -/// -/// Pinning is not free: every pinned bit halves the admissible fraction, so -/// `pinned` bits multiply the expected grinding by 2^pinned. It buys chains -/// cheaply though. The default is the minimum that leaves `8n - pinned` a -/// multiple of `chain_bits`, and what it saves is the extra, only partly used -/// chain that `ceil(8n / chain_bits)` would need: n bytes of signature for a -/// factor 2^(8n mod chain_bits), which at chain_bits = 3 is 16 bytes for 4x on a -/// per-layer grind of a few hundred hashes. Each further dropped chain then -/// saves another n bytes for a factor of about w. -/// -/// `doc/xmss/main.tex` is the (n=128, chain_bits=3) instance: 128 mod 3 = 2 bits -/// pinned, v = 42 chains, T = 195. Dropping one more chain there would pin -/// 2 + 3 = 5 bits and leave 41 chains. For every w the report itself uses (16 -/// and 256) chain_bits divides 128, so nothing is pinned. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Encoding { - pub w: u64, - pub n: u64, - pub dropped_chains: u64, - pub chain_bits: u64, - pub pinned_bits: u64, - pub chains: u64, -} - -impl Encoding { - /// `None` when `w` is not a power of two, or when nothing is left to sign. - pub fn new(w: u64, n: u64, dropped_chains: u64) -> Option { - if w < 2 || !w.is_power_of_two() { - return None; - } - let chain_bits = w.trailing_zeros() as u64; - let pinned_bits = (8 * n) % chain_bits + chain_bits * dropped_chains; - let chains = (8 * n).checked_sub(pinned_bits)? / chain_bits; - if chains < 1 { - return None; - } - Some(Self { - w, - n, - dropped_chains, - chain_bits, - pinned_bits, - chains, - }) - } - - /// Mean digit sum, where the admissible digests are densest. - pub const fn default_swn(&self) -> u64 { - self.chains * (self.w - 1) / 2 - } - - /// Largest reachable digit sum: every chain at the top. - pub const fn max_swn(&self) -> u64 { - self.chains * (self.w - 1) - } -} - -/// How many digests a target sum admits, and what that costs the signer. -/// -/// `counts[s]` is the number of `l`-tuples over `[0, w-1]` summing to `s`, that -/// is the coefficient of `x^s` in `(1 + x + ... + x^(w-1))^l`. Built by the -/// obvious convolution rather than by the report's inclusion-exclusion formula, -/// because the formula's intermediate binomials dwarf its result and would need -/// bignums, while every coefficient here is bounded by the total `w^l <= 2^128`. -/// -/// `trials[s]` is the expected number of counter values the signer tries per -/// hypertree layer, `ceil(2^(8n) / counts[s])`, saturated at `u64::MAX`: a set -/// needing more counters than that is beyond any budget anyway. -#[derive(Clone, Debug)] -pub struct NuTable { - pub l: u64, - pub w: u64, - pub digest_bits: u32, - counts: Vec, - trials: Vec, -} - -impl NuTable { - pub fn new(l: u64, w: u64, digest_bits: u32) -> Self { - assert!( - digest_bits <= 128, - "a digest wider than 128 bits overflows the u128 counts" - ); - let degree = (l * (w - 1)) as usize; - let mut cur = vec![0u128; degree + 1]; - let mut next = vec![0u128; degree + 1]; - cur[0] = 1; - for round in 1..=l { - let hi = (round * (w - 1)) as usize; - // next[s] = sum of the w preceding entries of cur, kept as a running - // window: the window's value is itself a coefficient of the next - // row, so it cannot exceed w^round <= 2^128. - let mut window = 0u128; - for s in 0..=hi { - window = window.checked_add(cur[s]).expect("digit-sum count overflowed u128"); - if s >= w as usize { - window -= cur[s - w as usize]; - } - next[s] = window; - } - std::mem::swap(&mut cur, &mut next); - cur[hi + 1..].fill(0); - } - let total_minus_1 = if digest_bits == 128 { - u128::MAX - } else { - (1u128 << digest_bits) - 1 - }; - let trials = cur - .iter() - .map(|&nu| { - if nu == 0 { - return u64::MAX; - } - // ceil(2^digest_bits / nu) = floor((2^digest_bits - 1)/nu) + 1, - // saturating: nu = 1 would otherwise carry the +1 past u128. - u64::try_from((total_minus_1 / nu).saturating_add(1)).unwrap_or(u64::MAX) - }) - .collect(); - Self { - l, - w, - digest_bits, - counts: cur, - trials, - } - } - - /// Digests with the pinned bits zero and digits summing to `swn`. - pub fn nu(&self, swn: u64) -> u128 { - self.counts.get(swn as usize).copied().unwrap_or(0) - } - - /// Counter values tried per layer at this target sum. - pub fn trials(&self, swn: u64) -> u64 { - self.trials.get(swn as usize).copied().unwrap_or(u64::MAX) - } - - /// The least grinding any target sum can ask for. - /// - /// Read off the table rather than assumed to sit at the mean, so nothing - /// downstream depends on where the distribution peaks. - pub fn min_trials(&self) -> u64 { - self.trials.iter().copied().min().unwrap_or(u64::MAX) - } -} diff --git a/doc/sphincs/params_selection/src/lib.rs b/doc/sphincs/params_selection/src/lib.rs deleted file mode 100644 index bcf6203d8..000000000 --- a/doc/sphincs/params_selection/src/lib.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! SPHINCS+ parameters: security, signature size, hash counts, and a search. -//! -//! Covers the WOTS-based / FORS-based schemes of "Hash-based Signature Schemes -//! for Bitcoin" (Kudinov, Nick, Blockstream Research, rev. 2025-12-05) and its -//! scripts at github.com/BlockstreamResearch/SPHINCS-Parameters: -//! -//! | scheme | what it is | -//! | --------- | ------------------------------------------------------- | -//! | `SPX` | plain SPHINCS+ (SLH-DSA): WOTS-TW + FORS | -//! | `W+C` | WOTS+C (fixed digit sum, no checksum chains) + FORS | -//! | `W+C_F+C` | WOTS+C + FORS+C (last FORS tree removed by grinding) | -//! -//! PORS+FP is deliberately not implemented. -//! -//! WOTS+C shortens its signature by dropping chains, and this does that the way -//! `doc/xmss/main.tex` does: it pins the top bits of the digest to zero instead -//! of forcing whole digits, so the digest is always a whole number of base-w -//! chunks (see [`cost::Encoding`]). The default pins the minimum that makes the -//! cut integral; dropping further chains pins `log2(w)` more bits each, every -//! pinned bit doubling the expected grinding. -//! -//! The hypertree's height is split per layer, not `h/d` on every layer: the top -//! tree gets `h_top` and the rest divide what is left as evenly as it goes, so -//! `d` need not divide `h`, and [`params::Profile`] can hold any heights at all -//! though only that shape is ever searched. That matters because the signature -//! carries `h` -//! authentication nodes and the verifier walks them however the layers divide -//! `h`: size and verification depend only on `(h, d)`, while only the top tree -//! is cacheable. A taller top layer is therefore free on both, costs keygen and -//! vanilla signing, and cuts cached signing, which at `h = 40, d = 5` is 2x for -//! `h_top = 15` against the uniform 8. See [`params::Profile`]. -//! -//! For one parameter set [`params::costs`] reports the signature size and the -//! keygen, signing and verification cost, and [`security::security_bits`] the -//! classical security. Signing means signing with the top XMSS tree's "half -//! top" in state, its nodes at depth `ceil(h_top/2)`, which is `sqrt(2^h_top)` -//! of storage for a `sqrt(2^h_top)` top-tree cost per signature and the cost a -//! signer keeping that cache actually pays. What a signer holding nothing pays -//! is [`params::Costs::sign_cold`], computed but not reported. -//! -//! Everything is counted in compression calls, one per 64 bytes of hash input: -//! a Merkle node or a WOTS chain step is one, the message digest two, and -//! compressing `m` hash values `ceil((2n + mn) / 64)`. See [`cost::Blocks`], -//! which also notes that this is the same function as the report's SHA-2 layout -//! with the PK.seed midstate cached, so its published counts still pin it. -//! -//! [`search::search`] inverts the question: given a lifetime and a budget for -//! keygen, signing (both flavours) and size, it enumerates the space and returns -//! what verifies cheapest at 128-bit classical security. -//! -//! `tests/goldens.rs` pins all of it against the upstream sage scripts' frozen -//! fixtures, against the report's own tables, and, for the search, against a -//! naive oracle that skips nothing. - -pub mod cost; -pub mod params; -pub mod report; -pub mod search; -pub mod security; diff --git a/doc/sphincs/params_selection/src/main.rs b/doc/sphincs/params_selection/src/main.rs deleted file mode 100644 index 705ba872f..000000000 --- a/doc/sphincs/params_selection/src/main.rs +++ /dev/null @@ -1,357 +0,0 @@ -//! One command: pin the parameters you know, budget the costs you care about, -//! and everything left over gets searched. - -use sphincs_params::cost::{SCHEMES, Scheme}; -use sphincs_params::params::Profile; -use sphincs_params::report::{legend, report, si, signatures, table, utilization}; -use sphincs_params::search::{ - A_MAX, Budgets, CHAIN_BITS_MAX, D_MAX, DROPPED_MAX, Grid, H_MAX, K_MAX, LEVEL1_BITS, Span, Stats, Sums, edges, - search, -}; - -const USAGE: &str = "\ -SPHINCS+ parameter selection: what verifies cheapest, or what one set costs. - -usage: sphincs_params --lifetime Q [parameters] [budgets] [output] - -Give a parameter to pin it, leave it out to search it. Pin them all and the run -just costs that one set. Numbers may be written as 2e6 or 100,000 or 100_000. - -parameters - --lifetime Q signatures allowed per public key, e.g. 16e6 (required) - --scheme S SPX | W+C | W+C_F+C, repeatable [all three] - --height h total hypertree height [1..96] - --layers d hypertree layers [1..32] - --top-height ht height of the top XMSS tree, the rest - of h splitting evenly below it [1..h-d+1, or h/d] - --heights H,... every layer height outright, top first, pinning h and d - with it. Nothing here searches uneven lower layers, - because for the same h, d and top height they never cost - less: see Profile in src/params.rs. This is for costing - one anyway. - -a A log2 of the leaves in a FORS tree [1..32] - -k K FORS trees [1..64] - --chain-bits B log2(w), repeatable [1..12] - -w W Winternitz parameter, instead of --chain-bits - --drop-chains C WOTS+C chains dropped beyond the - minimal digest-bit pinning [0..16, or 0] - --swn S WOTS+C target digit sum [the most the signing - budget allows, or the - mean] - -n N hash output in bytes [16] - -Three of those buy something only by spending something else, so left unpinned -they are searched against the budget that bounds what they spend, and take the -value the report's own parameter sets use when it is unset (the second default -above). --swn and --drop-chains buy cheaper verification with grinding, bounded -by --max-sign; --top-height buys cheaper signing with key generation, bounded by ---max-keygen. - -budgets, all optional: an unset one is no limit. Every cost is counted in -compression calls, one per 64 bytes of hash input. - --max-keygen N compressions at key generation - --max-sign N compressions at signing, counting the top XMSS tree's - half top as already in state: the steady-state cost of - a signer that keeps the cache the `cache B` column - sizes. A signer holding nothing pays the `cold` column - instead, which nothing here budgets. - --max-size B signature bytes - --security BITS classical security floor [128, NIST level 1] - -other - --cache-height C cached top-tree level, above the leaves [half of h_top] - --top N rows of the table to print [15] - --stats report how much of the space was visited - -examples - sphincs_params --lifetime 1e9 --max-keygen 2e6 --max-sign 4e6 --max-size 4000 - sphincs_params --lifetime 1e12 --height 40 --layers 5 -a 14 -k 11 -w 256 --swn 2040 -"; - -fn main() -> std::process::ExitCode { - let argv: Vec = std::env::args().skip(1).collect(); - if argv.is_empty() || argv.iter().any(|a| a == "-h" || a == "--help") { - print!("{USAGE}"); - return std::process::ExitCode::SUCCESS; - } - match run(&argv) { - Ok(true) => std::process::ExitCode::SUCCESS, - Ok(false) => std::process::ExitCode::FAILURE, - Err(e) => { - eprintln!("error: {e}"); - std::process::ExitCode::from(2) - } - } -} - -/// Flags and their values, repeatable flags kept in order. -struct Args(Vec<(String, Option)>); - -const NO_VALUE: [&str; 3] = ["--stats", "--help", "-h"]; - -const FLAGS: [&str; 21] = [ - "--lifetime", - "--scheme", - "--height", - "--layers", - "--top-height", - "--heights", - "-a", - "-k", - "--chain-bits", - "-w", - "--drop-chains", - "--swn", - "-n", - "--max-keygen", - "--max-sign", - "--max-size", - "--security", - "--cache-height", - "--top", - "--stats", - "--help", -]; - -/// Flags that used to exist, and what to reach for instead. -const GONE: [(&str, &str); 8] = [ - ( - "--max-sign-cached", - "--max-sign, which now counts exactly that: signing with the half top in state", - ), - ("--unit", "nothing: every cost is compression calls"), - ("--uncached", "nothing: every cost is compression calls"), - ("--max-dropped", "--drop-chains"), - ( - "--h-max", - "--height, which pins it; widening the range means raising H_MAX in src/search.rs", - ), - ("--d-max", "--layers, or D_MAX in src/search.rs"), - ("--a-max", "-a, or A_MAX in src/search.rs"), - ("--k-max", "-k, or K_MAX in src/search.rs"), -]; - -impl Args { - fn parse(argv: &[String]) -> Result { - let mut out = Vec::new(); - let mut i = 0; - while i < argv.len() { - let flag = &argv[i]; - if !flag.starts_with('-') { - return Err(format!("unexpected argument {flag}")); - } - if let Some((_, instead)) = GONE.iter().find(|(gone, _)| gone == flag) { - return Err(format!("{flag} is gone: use {instead}")); - } - if !FLAGS.contains(&flag.as_str()) { - return Err(format!("unknown flag {flag}; run with no arguments for the list")); - } - if NO_VALUE.contains(&flag.as_str()) { - out.push((flag.clone(), None)); - i += 1; - } else { - let v = argv.get(i + 1).ok_or_else(|| format!("{flag} needs a value"))?; - out.push((flag.clone(), Some(v.clone()))); - i += 2; - } - } - Ok(Args(out)) - } - - fn flag(&self, name: &str) -> bool { - self.0.iter().any(|(f, _)| f == name) - } - - fn all(&self, name: &str) -> Vec<&str> { - self.0 - .iter() - .filter(|(f, _)| f == name) - .filter_map(|(_, v)| v.as_deref()) - .collect() - } - - fn get(&self, name: &str) -> Option<&str> { - self.all(name).last().copied() - } - - /// Accepts 2e6 and 100,000 and 100_000 as well as 100000. - fn float(&self, name: &str) -> Result, String> { - match self.get(name) { - None => Ok(None), - Some(s) => s - .replace([',', '_', ' '], "") - .parse::() - .map(Some) - .map_err(|_| format!("{name}: expected a number, got {s}")), - } - } - - fn num(&self, name: &str) -> Result, String> { - Ok(self.float(name)?.map(|f| f as u64)) - } - - fn u64_or(&self, name: &str, default: u64) -> Result { - Ok(self.num(name)?.unwrap_or(default)) - } - - /// A pin if the flag was given, the whole range otherwise. - fn span(&self, name: &str, whole: Span) -> Result { - Ok(self.num(name)?.map_or(whole, Span::pin)) - } - - fn schemes(&self) -> Result, String> { - let named = self.all("--scheme"); - if named.is_empty() { - return Ok(SCHEMES.to_vec()); - } - named - .iter() - .map(|s| Scheme::parse(s).ok_or_else(|| format!("unknown scheme {s}"))) - .collect() - } - - fn chain_bits(&self) -> Result, String> { - let mut bits: Vec = self - .all("--chain-bits") - .iter() - .map(|s| { - s.parse::() - .map_err(|_| format!("--chain-bits: expected a number, got {s}")) - }) - .collect::>()?; - if let Some(w) = self.num("-w")? { - if w < 2 || !w.is_power_of_two() { - return Err(format!("-w: expected a power of two, got {w}")); - } - bits.push(w.trailing_zeros() as u64); - } - if bits.is_empty() { - bits = (1..=CHAIN_BITS_MAX).collect(); - } - bits.sort_unstable(); - bits.dedup(); - Ok(bits) - } -} - -fn run(argv: &[String]) -> Result { - let args = Args::parse(argv)?; - let q_s = args.float("--lifetime")?.ok_or("--lifetime is required")?; - let b = Budgets { - q_s, - keygen: args.num("--max-keygen")?, - sign: args.num("--max-sign")?, - size: args.num("--max-size")?, - security: args.get("--security").map_or(Ok(LEVEL1_BITS), |s| { - s.parse().map_err(|_| format!("--security: expected a number, got {s}")) - })?, - }; - // Three axes buy something only by spending something that may be - // unbudgeted, and then their answer is useless: the target sum and the - // dropped chains buy cheaper verification with grinding, and a taller top - // tree buys cheaper signing with key generation and with cold signing. - // Unpinned, each is searched only when the budget that bounds it is set, - // and otherwise takes the value the report's own parameter sets use. - let signing_bounded = b.sign.is_some(); - let sums = match (args.num("--swn")?, signing_bounded) { - (Some(s), _) => Sums::Pinned(s), - (None, true) => Sums::Sweep, - (None, false) => Sums::Mean, - }; - let dropped = match (args.num("--drop-chains")?, signing_bounded) { - (Some(c), _) => Span::pin(c), - (None, true) => Span::new(0, DROPPED_MAX), - (None, false) => Span::pin(0), - }; - let h_top = match (args.num("--top-height")?, b.keygen.is_some()) { - (Some(ht), _) => Some(Span::pin(ht)), - (None, true) => Some(Span::new(1, H_MAX)), - (None, false) => None, - }; - let profile = match args.get("--heights") { - None => None, - Some(list) => { - let heights: Vec = list - .split(',') - .map(|x| { - x.trim() - .parse::() - .map_err(|_| format!("--heights: expected numbers, got {list}")) - }) - .collect::>()?; - Some(Profile::new(&heights).ok_or_else(|| format!("--heights: {list} is not 1..=32 heights of 1..=63"))?) - } - }; - let g = Grid { - schemes: args.schemes()?, - n: args.u64_or("-n", 16)?, - h: match profile { - Some(pr) => Span::pin(pr.total()), - None => args.span("--height", Span::new(1, H_MAX))?, - }, - d: match profile { - Some(pr) => Span::pin(pr.layers()), - None => args.span("--layers", Span::new(1, D_MAX))?, - }, - h_top, - a: args.span("-a", Span::new(1, A_MAX))?, - k: args.span("-k", Span::new(1, K_MAX))?, - dropped, - chain_bits: args.chain_bits()?, - sums, - profile, - cache_height: args.num("--cache-height")?, - }; - - let mut stats = Stats::default(); - let found = search(&b, &g, &mut stats); - if args.flag("--stats") { - println!("{stats}\n"); - } - if found.is_empty() { - println!( - "nothing meets these constraints at {:.0}-bit security and q_s = {}", - b.security, - signatures(q_s) - ); - println!( - "rejected: {} layer sets over --max-keygen, {} parameter sets over --max-size, {} over --max-sign; \ - and {} (a, k) pairs never reached the security floor", - si(stats.keygen_pruned), - si(stats.size_pruned), - si(stats.sign_pruned), - si(stats.insecure) - ); - return Ok(false); - } - - if !g.fully_pinned() { - let top = args.u64_or("--top", 15)? as usize; - let shown = top.min(found.len()); - let kept = match (found.len(), stats.rows_dropped) { - (1, _) => "1 feasible set".to_string(), - (n, 0) => format!("{n} feasible sets, best {shown} by verification cost"), - (n, _) => format!( - "{} feasible sets, {n} kept, best {shown} by verification cost", - stats.rows - ), - }; - println!("{kept}:\n"); - println!("{}\n", table(&found[..shown])); - println!("{}\n", legend()); - } - - let best = &found[0]; - let use_ = utilization(&b, best); - if !use_.is_empty() { - println!("budget use: {use_}"); - } - for w in edges(&g, best) { - println!("warning: {w}"); - } - if !use_.is_empty() || !edges(&g, best).is_empty() { - println!(); - } - println!("{}", report(&best.params, &best.costs, b.q_s)); - Ok(true) -} diff --git a/doc/sphincs/params_selection/src/params.rs b/doc/sphincs/params_selection/src/params.rs deleted file mode 100644 index 5afae05a3..000000000 --- a/doc/sphincs/params_selection/src/params.rs +++ /dev/null @@ -1,469 +0,0 @@ -//! One parameter set, and the costs it implies. - -use crate::cost::{Blocks, COUNTER_BYTES, Cost, Encoding, NuTable, Scheme}; - -/// A SPHINCS+ parameter set. `q_s` is not part of it: see [`crate::security`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Params { - pub scheme: Scheme, - /// Total hypertree height, the sum of the layer heights. - pub h: u64, - /// Hypertree layers. - pub d: u64, - /// Height of the top XMSS tree. `None` spreads `h` as evenly as it goes, - /// which for `d | h` is the classic `h' = h/d` on every layer. - pub h_top: Option, - /// FORS trees have `2^a` leaves. - pub a: u64, - /// Number of FORS trees. - pub k: u64, - /// Winternitz parameter, a power of two. - pub w: u64, - /// Hash output in bytes. - pub n: u64, - /// Chains dropped beyond the digest bits that have to be pinned anyway. - pub dropped_chains: u64, - /// Height above the leaves of the cached top-tree level; `None` is half of it. - pub cache_height: Option, -} - -/// The height of every XMSS tree in the hypertree, top first. -/// -/// Any heights are expressible, but a search only ever needs -/// [`Profile::canonical`]: the top tree at some height and the rest dividing -/// what is left as evenly as it goes. For a fixed `(h, d, h_top)` that shape is -/// no worse than any other on every cost, since size and verification depend -/// only on `(h, d)`, keygen only on `h_top`, and signing sums `2^height` over -/// the layers, which at a fixed total is smallest when they are equal. So -/// enumerating `(h, d, h_top)` covers the cost-optimal representative of every -/// profile. `profile_shape_is_never_beaten` in `tests/goldens` checks that -/// against every composition of a few small `(h, d)`. -/// -/// Only the heights vary per layer: every layer signs with the same WOTS -/// parameters. Giving each its own `w`, target sum and dropped chain count was -/// implemented and reverted, because it never won. Size charges every layer the -/// same `l * n` and verification charges every layer its own walk, so the -/// exchange rate between them is identical everywhere, and the walk -/// `(2^(8n/l) - 1) * l` is convex in `l`, so at a fixed total `l` an equal split -/// is what a size budget wants. Only signing distinguishes the layers, a tall -/// tree wanting cheap leaves, so it pays only where the signing budget binds and -/// the heights are uneven; searched there, the uniform choice still won, the -/// per-layer target sums differing by one step. The reverted commit carries the -/// working code and the reasoning, including why a search would never need more -/// than two distinct WOTS instances. -/// -/// Heights are at most 63, since `2^height` has to be countable, and there are -/// at most [`MAX_LAYERS`] of them. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Profile { - heights: [u8; MAX_LAYERS], - len: u8, -} - -/// The most hypertree layers a [`Profile`] can hold. -pub const MAX_LAYERS: usize = 32; - -impl Profile { - /// Any heights at all, the top tree first. - pub fn new(heights: &[u64]) -> Option { - if heights.is_empty() || heights.len() > MAX_LAYERS || heights.iter().any(|&x| !(1..=63).contains(&x)) { - return None; - } - let mut out = Self { - heights: [0; MAX_LAYERS], - len: heights.len() as u8, - }; - for (slot, &h) in out.heights.iter_mut().zip(heights) { - *slot = h as u8; - } - Some(out) - } - - /// The top tree at `h_top`, the other `d - 1` layers dividing `h - h_top` as - /// evenly as it goes. `None` for `h_top` is the classic `h/d` split. - pub fn canonical(h: u64, d: u64, h_top: Option) -> Option { - if d == 0 || d as usize > MAX_LAYERS || h == 0 { - return None; - } - let h_top = h_top.unwrap_or(h / d).max(1); - let lower_total = h.checked_sub(h_top)?; - let m = d - 1; - if m == 0 { - return (lower_total == 0).then(|| Self::new(&[h_top]))?; - } - if lower_total < m { - return None; // every layer needs at least one level - } - let (q, r) = (lower_total / m, lower_total % m); - let mut heights = vec![h_top]; - heights.extend(std::iter::repeat_n(q + 1, r as usize)); - heights.extend(std::iter::repeat_n(q, (m - r) as usize)); - Self::new(&heights) - } - - pub fn heights(&self) -> impl Iterator + '_ { - self.heights[..self.len as usize].iter().map(|&x| x as u64) - } - - /// The top tree's height: the one layer that is the same for every signature. - pub fn h_top(&self) -> u64 { - self.heights().next().unwrap_or(0) - } - - pub fn total(&self) -> u64 { - self.heights().sum() - } - - pub fn layers(&self) -> u64 { - self.len as u64 - } - - pub fn uniform(&self) -> bool { - self.heights().all(|x| x == self.h_top()) - } -} - -impl std::fmt::Display for Profile { - /// Every height, top first: `12 + 7 + 7`. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let listed: Vec = self.heights().map(|h| h.to_string()).collect(); - write!(f, "{}", listed.join(" + ")) - } -} - -impl Params { - pub fn profile(&self) -> Option { - Profile::canonical(self.h, self.d, self.h_top) - } - - pub fn encoding(&self) -> Option { - Encoding::new(self.w, self.n, self.dropped_chains) - } - - /// Chains actually signed: `l1 + l2` for WOTS-TW, the encoding's for WOTS+C. - pub fn chains(&self) -> Option { - let enc = self.encoding()?; - if self.scheme.wots_c() { - return Some(enc.chains); - } - // WOTS-TW pads the digest to whole digits and appends a checksum - // (FIPS 205): l1 = ceil(8n / log2 w), l2 = floor(log_w(l1*(w-1))) + 1. - let l1 = (8 * self.n).div_ceil(enc.chain_bits); - Some(l1 + self.wots_tw_len2(l1)) - } - - fn wots_tw_len2(&self, l1: u64) -> u64 { - let bits = self.encoding().expect("checked by the caller").chain_bits; - (l1 * (self.w - 1)).ilog2() as u64 / bits + 1 - } - - /// Verifier chain steps for WOTS-TW when every message digit is zero. - fn wots_tw_worst_steps(&self) -> u64 { - let enc = self.encoding().expect("checked by the caller"); - let l1 = (8 * self.n).div_ceil(enc.chain_bits); - let l2 = self.wots_tw_len2(l1); - let c = l1 * (self.w - 1); - let digit_sum: u64 = { - let (mut rem, mut sum) = (c, 0); - while rem > 0 { - sum += rem % self.w; - rem /= self.w; - } - sum - }; - l1 * (self.w - 1) + l2 * (self.w - 1) - digit_sum - } - - /// The compression counts of the hashes this parameter set uses. - pub fn blocks(&self) -> Blocks { - Blocks::new(self.n) - } - - /// One WOTS key pair, plus the compression of its `l` chain ends into a leaf. - fn wots_leaf(&self, l: u64) -> Cost { - let b = self.blocks(); - Cost::new( - l + l * (self.w - 1) + 1, - l * b.prf() + l * (self.w - 1) * b.chain_step() + b.compress(l), - ) - } -} - -/// The hypertree side: what the layer heights cost, at any `(a, k)` and any -/// target sum. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Layers { - pub profile: Profile, - /// Generating the top tree, which is all key generation does. - pub keygen: Cost, - /// Regrowing every layer, which is what signing does. - pub trees: Cost, - /// The same with the top tree's half top already in state. - pub trees_cached: Cost, - pub cache_bytes: u64, - pub cache_depth: u64, -} - -impl Layers { - /// The canonical profile of `p`: see [`Profile::canonical`]. - pub fn new(p: &Params) -> Option { - Self::from_profile(p, p.profile()?) - } - - /// Any profile, as long as its heights add up to `p.h` over `p.d` layers. - pub fn from_profile(p: &Params, profile: Profile) -> Option { - if profile.total() != p.h || profile.layers() != p.d { - return None; - } - let l = p.chains()?; - let leaf = p.wots_leaf(l); - let b = p.blocks(); - let tree = |height: u64| { - let leaves = 1u64 << height; - leaf * leaves + Cost::new(leaves - 1, (leaves - 1) * b.merkle_node()) - }; - let top = tree(profile.h_top()); - let lower = profile - .heights() - .skip(1) - .map(tree) - .fold(Cost::default(), |acc, x| acc + x); - - // Only the top tree is worth caching: it is the same for every - // signature, while the trees below it are picked by the (pseudorandom) - // index. Its auth path splits at the cached level: below, rebuild the - // 2^c-leaf subtree the signing leaf sits in; above, refold the stored - // level. Rebuilt leaves are charged a full WOTS public key, as - // everywhere else here. - // - // A BDS-style traversal would amortize a tree to h' leaves per - // signature with O(h') state, but it only works walking the leaves in - // order. SPHINCS+ picks its index by hashing the message, so - // consecutive signatures land on unrelated leaves and nothing - // amortizes; an index-independent cache like this one is what is left, - // hence sqrt rather than h'. - let c = p.cache_height.unwrap_or(profile.h_top() / 2); - if c > profile.h_top() { - return None; - } - // Only the level itself is stored, not the triangle above it: refolding - // that is 2^(h-c)-1 node calls, nothing next to the subtree rebuild, - // while storing it would double the bytes. - let stored_level = 1u64 << (profile.h_top() - c); - let cached = tree(c) + Cost::new(stored_level - 1, (stored_level - 1) * b.merkle_node()); - let cache_bytes = stored_level * p.n; - - Some(Self { - profile, - keygen: top, - trees: top + lower, - trees_cached: cached + lower, - cache_bytes, - cache_depth: profile.h_top() - c, - }) - } -} - -/// Everything else: the FORS side, the signature size and the verifier, none of -/// which depends on how the hypertree's height is split between its layers. -/// -/// Split from [`Layers`] and from the target sum so that a search can reject a -/// candidate on size, or on the least any layer profile and any target sum could -/// cost, without pretending to know which of those are good. -#[derive(Clone, Debug)] -pub struct Skeleton { - pub params: Params, - pub l: u64, - pub chain_bits: u64, - pub pinned_bits: u64, - pub max_swn: u64, - pub default_swn: u64, - pub sig_bytes: u64, - /// What FORS+C's digest grinding costs, zero for the other schemes. - pub fors_c_grinding: Cost, - /// The `(a, k)` part of signing: growing the FORS trees, and any grinding - /// FORS+C does. Common to both signing costs, cached or not. - pub fors_part: Cost, - /// One counter trial, at one layer. - pub grind_step: Cost, - /// Verification, less the chain walk that the target sum shortens. - verify_base: Cost, - /// One chain step, across all layers. - verify_step: Cost, - /// WOTS-TW only; for WOTS+C verification is deterministic. - verify_worst_extra: Cost, -} - -impl Skeleton { - /// `None` if the parameters are not self-consistent: `w` must be a power of - /// two, FORS+C needs `k >= 2`, WOTS-TW cannot drop chains, and `2^a` has to - /// be countable. - pub fn new(p: Params) -> Option { - if p.k < 1 || p.a < 1 || p.a > 63 || p.d == 0 { - return None; - } - if p.scheme.fors_c() && p.k < 2 { - return None; - } - if !p.scheme.wots_c() && p.dropped_chains > 0 { - return None; - } - let enc = p.encoding()?; - let l = p.chains()?; - let profile = p.profile()?; - let (n, b, d) = (p.n, p.blocks(), p.d); - let trees = p.scheme.trees(p.k); - let t = 1u64 << p.a; - - // The signature carries the whole authentication path, h nodes however - // the layers divide it, plus one WOTS signature per layer. - let layer = l * n + if p.scheme.wots_c() { COUNTER_BYTES } else { 0 }; - let sig_bytes = n + profile.total() * n + d * layer + trees * n + trees * p.a * n; - - let msg_hash = Cost::new(2, b.message_hash() + b.message_prf()); - let fors_build = Cost::new( - trees * t + trees * t + trees * (t - 1) + 1, - trees * t * b.prf() + trees * t * b.chain_step() + trees * (t - 1) * b.merkle_node() + b.compress(trees), - ); - // FORS+C grinds the digest until its last a bits vanish, so the last - // FORS tree always opens leaf 0 and needs no authentication path. - let fors_grind = if p.scheme.fors_c() { msg_hash * t } else { msg_hash }; - - let fors_verify = Cost::new( - trees + trees * p.a + 1, - trees * b.chain_step() + trees * p.a * b.merkle_node() + b.compress(trees), - ); - let auth = Cost::new(profile.total(), profile.total() * b.merkle_node()); - let mut verify_base = Cost::new(1, b.message_hash()) + fors_verify + auth; - let mut verify_step = Cost::default(); - let mut verify_worst_extra = Cost::default(); - if p.scheme.wots_c() { - // the digits sum to S_wn, so the remaining chain steps are fixed at - // (w-1)*l - S_wn, and the counter is hashed once per layer - verify_base = verify_base + Cost::new(2, b.chain_step_with_counter() + b.compress(l)) * d; - verify_step = Cost::new(1, b.chain_step()) * d; - } else { - let avg = (p.w - 1) * l / 2; - verify_base = verify_base + Cost::new(avg + 1, avg * b.chain_step() + b.compress(l)) * d; - let worst = p.wots_tw_worst_steps(); - verify_worst_extra = Cost::new(worst - avg, (worst - avg) * b.chain_step()) * d; - } - - Some(Self { - params: p, - l, - chain_bits: enc.chain_bits, - pinned_bits: if p.scheme.wots_c() { enc.pinned_bits } else { 0 }, - max_swn: (p.w - 1) * l, - default_swn: if p.scheme.wots_c() { enc.default_swn() } else { 0 }, - sig_bytes, - fors_c_grinding: if p.scheme.fors_c() { fors_grind } else { Cost::default() }, - fors_part: fors_build + fors_grind, - grind_step: Cost::new(1, b.chain_step_with_counter()), - verify_base, - verify_step, - verify_worst_extra, - }) - } - - /// Expected signing cost, with the top tree's half top in state, when each - /// layer grinds `trials` counters. - pub fn sign(&self, lay: &Layers, trials: u64) -> Cost { - lay.trees_cached + self.fors_part + self.grinding(trials) - } - - /// The same for a signer holding no state at all, which has to rebuild the - /// top tree along with the rest. - pub fn sign_cold(&self, lay: &Layers, trials: u64) -> Cost { - lay.trees + self.fors_part + self.grinding(trials) - } - - /// What `trials` counter values per layer cost across the hypertree. - pub fn grinding(&self, trials: u64) -> Cost { - self.grind_step * trials.saturating_mul(self.params.d) - } - - /// Verification at this target sum. `swn` is ignored for WOTS-TW. - pub fn verify(&self, swn: u64) -> Cost { - self.verify_base + self.verify_step * (self.max_swn - swn.min(self.max_swn)) - } - - /// Verification when every message digit is zero (WOTS-TW only). - pub fn verify_worst(&self, swn: u64) -> Cost { - self.verify(swn) + self.verify_worst_extra - } - - /// The full picture at one layer profile and one target sum. - pub fn finish(&self, lay: &Layers, swn: u64, trials: u64) -> Costs { - Costs { - l: self.l, - chain_bits: self.chain_bits, - pinned_bits: self.pinned_bits, - dropped_chains: self.params.dropped_chains, - swn: self.params.scheme.wots_c().then_some(swn), - profile: lay.profile, - sig_bytes: self.sig_bytes, - keygen: lay.keygen, - sign: self.sign(lay, trials), - sign_cold: self.sign_cold(lay, trials), - verify: self.verify(swn), - verify_worst: self.verify_worst(swn), - wots_c_grinding: self.grinding(trials), - fors_c_grinding: self.fors_c_grinding, - cache_depth: lay.cache_depth, - cache_bytes: lay.cache_bytes, - } - } -} - -/// Every cost of a parameter set at one layer profile and one target sum. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Costs { - pub l: u64, - pub chain_bits: u64, - pub pinned_bits: u64, - pub dropped_chains: u64, - pub swn: Option, - pub profile: Profile, - pub sig_bytes: u64, - pub keygen: Cost, - /// Signing with the top tree's half top in state, the cost a signer that - /// keeps `cache_bytes` of it actually pays. - pub sign: Cost, - /// Signing with no state at all, every tree rebuilt from the seed. Not - /// reported: a signer pays it once, after restoring a backup. It is the - /// projection the upstream sage scripts compute, which have no cache - /// notion, so `tests/goldens` checks their fixtures against it. - pub sign_cold: Cost, - pub verify: Cost, - pub verify_worst: Cost, - /// Searching for admissible WOTS+C counters, across every layer. - pub wots_c_grinding: Cost, - /// Grinding the digest so FORS+C's last tree opens leaf zero. - pub fors_c_grinding: Cost, - pub cache_depth: u64, - pub cache_bytes: u64, -} - -impl Costs { - /// Everything the signer spends on grinding rather than on trees. - pub fn grinding(&self) -> Cost { - self.wots_c_grinding + self.fors_c_grinding - } -} - -/// Costs of one parameter set, building the digit-sum table as needed. -/// -/// Convenient for a single evaluation; a search should hold the [`NuTable`] and -/// drive [`Skeleton`] and [`Layers`] itself, since the table depends only on -/// `(l, w)` and the layer costs only on the profile. -pub fn costs(p: Params, swn: Option) -> Option { - let sk = Skeleton::new(p)?; - let lay = Layers::new(&p)?; - if !p.scheme.wots_c() { - return Some(sk.finish(&lay, 0, 0)); - } - let table = NuTable::new(sk.l, p.w, (8 * p.n) as u32); - let swn = swn.unwrap_or(sk.default_swn); - Some(sk.finish(&lay, swn, table.trials(swn))) -} diff --git a/doc/sphincs/params_selection/src/report.rs b/doc/sphincs/params_selection/src/report.rs deleted file mode 100644 index f2b927fa1..000000000 --- a/doc/sphincs/params_selection/src/report.rs +++ /dev/null @@ -1,201 +0,0 @@ -//! Human-readable output. - -use crate::params::{Costs, Params}; -use crate::search::{Budgets, Candidate}; -use crate::security::forgery_exponent; - -/// A signature count, as a power of two when it is one. -pub fn signatures(q_s: f64) -> String { - let log2 = q_s.log2(); - if (log2 - log2.round()).abs() < 1e-9 { - return format!("2^{}", log2.round() as i64); - } - for (unit, div) in [ - ("E", 1e18), - ("P", 1e15), - ("T", 1e12), - ("G", 1e9), - ("M", 1e6), - ("K", 1e3), - ] { - if q_s >= div { - return format!("{:.3}{unit} (2^{log2:.1})", q_s / div); - } - } - format!("{q_s:.0}") -} - -pub fn si(x: u64) -> String { - let f = x as f64; - for (unit, div) in [("G", 1e9), ("M", 1e6), ("K", 1e3)] { - if f >= div { - return format!("{:.2}{unit}", f / div); - } - } - x.to_string() -} - -/// One line spelling out the WOTS+C digest-to-chains cut. -pub fn encoding_line(p: &Params, c: &Costs) -> String { - let Some(swn) = c.swn else { - let l1 = (8 * p.n).div_ceil(c.chain_bits); - return format!( - "encoding WOTS-TW: {} chains, {l1} for the digest + {} checksum", - c.l, - c.l - l1 - ); - }; - let dropped = if c.dropped_chains > 0 { - format!(", {} chain(s) dropped", c.dropped_chains) - } else { - String::new() - }; - format!( - "encoding {} bits/chain, {} of {} digest bits pinned to zero{dropped}, S_wn = {swn} of {}", - c.chain_bits, - c.pinned_bits, - 8 * p.n, - c.l * (p.w - 1) - ) -} - -/// The full picture of one parameter set. -pub fn report(p: &Params, c: &Costs, q_s: f64) -> String { - let forgery = forgery_exponent(q_s, p.h as u32, p.k, p.a); - let cap = 8.0 * p.n as f64; - let security = forgery.map_or(0.0, |f| f.min(cap)); - let row = |label: &str, x: crate::cost::Cost, note: String| format!("{label:<24}{:>12}{note}", si(x.compressions)); - - let mut lines = vec![ - format!( - "scheme {} q_s = {} n = {} bits", - p.scheme.label(), - signatures(q_s), - 8 * p.n - ), - format!("(h, d) ({}, {}) layer heights {}", p.h, p.d, c.profile), - format!( - "(a, k) ({}, {}){}", - p.a, - p.k, - if p.scheme.fors_c() { - format!(" [FORS+C signs {} trees]", p.k - 1) - } else { - String::new() - } - ), - format!("(w, l) ({}, {})", p.w, c.l), - encoding_line(p, c), - String::new(), - match forgery { - Some(f) => format!( - "security {security:.1} bits classical (FORS forgery {f:.1}, preimage {})", - cap as u64 - ), - None => format!( - "security none: q_s = {} reuses every FORS instance ~{:.0} times", - signatures(q_s), - q_s / 2f64.powi(p.h as i32) - ), - }, - format!("signature {} bytes", c.sig_bytes), - String::new(), - format!("{:<24}{:>12}", "", "compressions"), - row("keygen", c.keygen, String::new()), - row( - "sign", - c.sign, - format!(" ({} B of state at depth {})", c.cache_bytes, c.cache_depth), - ), - row("verify", c.verify, String::new()), - ]; - if c.verify_worst != c.verify { - lines.push(row("verify (worst)", c.verify_worst, String::new())); - } - lines.push(String::new()); - lines.push(format!( - "of signing, grinding accounts for {}: {} searching for WOTS+C counters, {} on the FORS+C digest", - si(c.grinding().compressions), - si(c.wots_c_grinding.compressions), - si(c.fors_c_grinding.compressions) - )); - lines.join("\n") -} - -const COLUMNS: [(&str, usize); 15] = [ - ("verify", 9), - ("scheme", 9), - ("h", 4), - ("d", 3), - ("heights", 18), - ("a", 3), - ("k", 3), - ("w", 5), - ("drop", 5), - ("l", 4), - ("S_wn", 6), - ("size", 6), - ("keygen", 8), - ("sign", 8), - ("cache B", 7), -]; - -fn cells(c: &Candidate) -> Vec { - let (p, x) = (&c.params, &c.costs); - vec![ - si(x.verify.compressions), - p.scheme.label().to_string(), - p.h.to_string(), - p.d.to_string(), - x.profile.to_string(), - p.a.to_string(), - p.k.to_string(), - p.w.to_string(), - p.dropped_chains.to_string(), - x.l.to_string(), - x.swn.map_or("-".to_string(), |s| s.to_string()), - x.sig_bytes.to_string(), - si(x.keygen.compressions), - si(x.sign.compressions), - x.cache_bytes.to_string(), - ] -} - -/// What the abbreviated columns mean, since several of them are this project's -/// own and not the report's. -pub fn legend() -> String { - "every cost in compression calls, one per 64 bytes of hash input; sign = signing with the top tree's half top \ - in state, cache B of it\nheights = every layer's height, top first, and the only one worth caching is that top one, w = Winternitz parameter, the positions one chain \ - has (--chain-bits takes its log2), drop = chains dropped beyond the pinned digest bits, l = chains signed, \ - S_wn = target digit sum" - .to_string() -} - -pub fn table(cands: &[Candidate]) -> String { - let head: Vec = COLUMNS.iter().map(|(name, w)| format!("{name:>w$}")).collect(); - let head = head.join(" "); - let mut lines = vec![head.clone(), "-".repeat(head.len())]; - for c in cands { - let row: Vec = cells(c) - .iter() - .zip(COLUMNS) - .map(|(cell, (_, w))| format!("{cell:>w$}", w = w)) - .collect(); - lines.push(row.join(" ")); - } - lines.join("\n") -} - -/// How much of each budget the candidate uses. Unset budgets say nothing. -pub fn utilization(b: &Budgets, c: &Candidate) -> String { - let used = [ - ("keygen", c.costs.keygen.compressions, b.keygen), - ("sign", c.costs.sign.compressions, b.sign), - ("size", c.costs.sig_bytes, b.size), - ]; - used.iter() - .filter_map(|(name, v, limit)| limit.map(|l| (name, v, l))) - .map(|(name, v, limit)| format!("{name} {:.0}%", 100.0 * *v as f64 / limit as f64)) - .collect::>() - .join(", ") -} diff --git a/doc/sphincs/params_selection/src/search.rs b/doc/sphincs/params_selection/src/search.rs deleted file mode 100644 index 91a1a7040..000000000 --- a/doc/sphincs/params_selection/src/search.rs +++ /dev/null @@ -1,547 +0,0 @@ -//! Exhaustive search for the parameter set with the cheapest verification. -//! -//! Every `(scheme, h, d, h_top, chain_bits, dropped_chains, a, k, S_wn)` point -//! that meets the budgets is costed and compared. Nothing is chosen by an -//! optimality argument, and nothing is skipped by a monotonicity one: the three -//! tests that run before the `S_wn` scan reject only points that no `S_wn` could -//! rescue, because size and keygen do not depend on `S_wn` at all, and the least -//! grinding any `S_wn` can ask for is read off the digit-sum table rather than -//! assumed to sit anywhere in particular. -//! -//! Any axis can be pinned to a single value instead of searched, which is how -//! one parameter set gets costed: pin them all. Budgets are optional, and an -//! unset one is no limit. -//! -//! The layer heights are `(h, d, h_top)`: the top tree gets `h_top`, the rest -//! divide what is left as evenly as it goes. [`crate::params::Profile`] argues -//! why that shape covers the cost-optimal representative of every profile, so -//! `d` no longer has to divide `h`. Which `h_top` is best does not depend on -//! `(a, k)` or on the target sum, because size and verification do not depend on -//! `h_top` at all and both signing budgets take the `(a, k)` part as the same -//! additive offset; so the profiles are ranked once per `(h, d)`, by how much -//! grinding they leave room for, and that ranking then holds for every `(a, k)`. -//! -//! What is assumed is the searched range of each parameter, hardcoded below. -//! When a result comes out at the top of one of those ranges the range itself -//! may be what is limiting it, so [`edges`] reports that and names the constant -//! to raise. Ranges the structure already closes (`d` over layer heights that do -//! not add up to `h`, `S_wn` over the digit sums a code of `l` chains can reach) -//! need no such warning and get none, and neither does an axis pinned by hand. - -use std::ops::RangeInclusive; -use std::time::Instant; - -use crate::cost::{Cost, NuTable, SCHEMES, Scheme}; -use crate::params::{Costs, Layers, Params, Profile, Skeleton}; -use crate::security::SecurityTable; - -/// Hardcoded search ranges, wide enough that the budgets are normally what -/// binds: SLH-DSA level 1 lives at `h = 63..64`, `a = 6..14`, `k = 14..35`, -/// `chain_bits = 4`, and the report's candidates at `h = 20..44`, `a = 14..16`, -/// `k = 8..11`, so every range here has room above anything yet proposed. -/// Raising one costs only runtime. -pub const H_MAX: u64 = 96; -/// log2 of the leaves in one FORS tree. -pub const A_MAX: u64 = 32; -/// Number of FORS trees. -pub const K_MAX: u64 = 64; -/// Hypertree layers. -pub const D_MAX: u64 = 32; -/// log2(w), so w up to 4096. -pub const CHAIN_BITS_MAX: u64 = 12; -/// WOTS+C chains dropped beyond the minimal bit pinning. -pub const DROPPED_MAX: u64 = 16; - -/// NIST level 1, matching SLH-DSA's level 1 parameter sets. -pub const LEVEL1_BITS: f64 = 128.0; - -/// The values of one parameter to try. Pinned when `lo == hi`. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Span { - pub lo: u64, - pub hi: u64, -} - -impl Span { - pub const fn new(lo: u64, hi: u64) -> Self { - Self { lo, hi } - } - pub const fn pin(v: u64) -> Self { - Self { lo: v, hi: v } - } - pub const fn pinned(&self) -> bool { - self.lo >= self.hi - } - pub const fn iter(&self) -> RangeInclusive { - self.lo..=self.hi - } - /// The span, further limited by something the parameters imply. - pub fn within(&self, hi: u64) -> RangeInclusive { - self.lo..=self.hi.min(hi) - } -} - -/// Which target sums to consider. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Sums { - /// Only this one. - Pinned(u64), - /// Only the mean, where grinding is cheapest. What to use when nothing - /// bounds the signer, since then there is no reason to grind harder, and - /// what the report's own parameter sets do. - Mean, - /// All of them, keeping the best the budgets allow. - Sweep, -} - -#[derive(Clone, Copy, Debug)] -pub struct Budgets { - /// Signatures allowed under one public key. Need not be a power of two. - pub q_s: f64, - /// An unset budget is no limit. - pub keygen: Option, - /// Signing with the top tree's half top in state: see [`Costs::sign`]. - pub sign: Option, - pub size: Option, - /// Classical security floor in bits. - pub security: f64, -} - -impl Budgets { - pub fn max_keygen(&self) -> u64 { - self.keygen.unwrap_or(u64::MAX) - } - pub fn max_sign(&self) -> u64 { - self.sign.unwrap_or(u64::MAX) - } - pub fn max_size(&self) -> u64 { - self.size.unwrap_or(u64::MAX) - } - - /// Everything is counted in compression calls: see [`crate::cost::Blocks`]. - pub fn of(&self, c: Cost) -> u64 { - c.compressions - } - - pub fn fits(&self, c: &Costs) -> bool { - c.sig_bytes <= self.max_size() && self.of(c.keygen) <= self.max_keygen() && self.of(c.sign) <= self.max_sign() - } -} - -#[derive(Clone, Debug)] -pub struct Grid { - pub schemes: Vec, - pub n: u64, - pub h: Span, - pub d: Span, - /// `None` searches nothing: the classic split, `h/d` on every layer. - pub h_top: Option, - pub a: Span, - pub k: Span, - pub dropped: Span, - pub chain_bits: Vec, - pub sums: Sums, - /// A profile given outright, instead of `h_top` over the canonical shape. - /// Its heights have to add up to `h` over `d` layers, both of which are - /// then pinned by it. - pub profile: Option, - pub cache_height: Option, -} - -impl Grid { - /// Is every axis pinned to one value, so that a run costs one parameter set - /// rather than searching for one? Distinct from a search that happens to - /// leave one survivor, which still deserves its count and its table. - pub fn fully_pinned(&self) -> bool { - let h_top_pinned = self.profile.is_some() - || match self.h_top { - None => true, // the classic split is one profile - Some(span) => span.pinned(), - }; - self.schemes.len() == 1 - && self.chain_bits.len() == 1 - && self.h.pinned() - && self.d.pinned() - && self.a.pinned() - && self.k.pinned() - && self.dropped.pinned() - && h_top_pinned - && !matches!(self.sums, Sums::Sweep) - } -} - -impl Default for Grid { - fn default() -> Self { - Self { - schemes: SCHEMES.to_vec(), - n: 16, - h: Span::new(1, H_MAX), - d: Span::new(1, D_MAX), - h_top: Some(Span::new(1, H_MAX)), - a: Span::new(1, A_MAX), - k: Span::new(1, K_MAX), - dropped: Span::new(0, DROPPED_MAX), - chain_bits: (1..=CHAIN_BITS_MAX).collect(), - sums: Sums::Sweep, - profile: None, - cache_height: None, - } - } -} - -#[derive(Clone, Copy, Debug)] -pub struct Candidate { - pub params: Params, - pub costs: Costs, -} - -/// Identifies one parameter tuple: everything but the layer profile and the -/// target sum, neither of which changes what it verifies at. -pub type Key = (Scheme, u64, u64, u64, u64, u64, u64); - -impl Candidate { - pub fn key(&self) -> Key { - let p = self.params; - (p.scheme, p.h, p.d, p.a, p.k, p.w, p.dropped_chains) - } -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct Stats { - /// `(scheme, h, d, chain_bits, dropped)` tuples reached. - pub grid: u64, - pub keygen_pruned: u64, - /// `(a, k)` pairs rejected by the security floor. - pub insecure: u64, - /// `(a, k)` pairs whose signature is too big, whatever the target sum. - pub size_pruned: u64, - /// `(a, k)` pairs too slow to sign at the least grinding any target sum asks. - pub sign_pruned: u64, - /// `(a, k)` pairs whose target sums were scanned. - pub swept: u64, - /// Points meeting every budget. - pub feasible: u64, - pub skeletons: u64, - /// Layer profiles kept after the keygen budget. - pub profiles: u64, - /// Parameter tuples that came out feasible, one row each. - pub rows: u64, - /// Rows dropped as worse than everything kept. - pub rows_dropped: u64, - pub seconds: f64, -} - -impl std::fmt::Display for Stats { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "grid {} (scheme, h, d, chain_bits, dropped) tuples, {} over keygen; \ - then {} (a, k) pairs insecure, {} over size, {} over signing; \ - {} target-sum ranges swept, {} points feasible over {} parameter tuples ({} dropped as worse than \ - everything kept); \ - {} layer profiles and {} parameter sets costed in {:.1}s", - self.grid, - self.keygen_pruned, - self.insecure, - self.size_pruned, - self.sign_pruned, - self.swept, - self.feasible, - self.rows, - self.rows_dropped, - self.profiles, - self.skeletons, - self.seconds - ) - } -} - -fn params(g: &Grid, scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64, dropped: u64) -> Params { - Params { - scheme, - h, - d, - h_top: None, - a, - k, - w, - n: g.n, - dropped_chains: dropped, - cache_height: g.cache_height, - } -} - -/// The layer profiles worth trying for one `(h, d)`, and how much grinding the -/// best of them leaves room for. -/// -/// `slack` is `max over profiles of (max_sign - the profile's trees)`. Signing -/// takes the `(a, k)` part as an additive offset, so subtracting that offset -/// from `slack` gives the grinding budget of the best profile for any `(a, k)`, -/// without re-ranking the profiles per candidate. -struct Room { - profiles: Vec, - slack: u64, -} - -fn room(b: &Budgets, g: &Grid, p: &Params) -> Option { - let mut profiles = Vec::new(); - let mut slack = 0; - let mut consider = |lay: Option| { - let Some(lay) = lay else { return }; - if b.of(lay.keygen) > b.max_keygen() { - return; - } - slack = slack.max(b.max_sign().saturating_sub(b.of(lay.trees_cached))); - profiles.push(lay); - }; - match (g.profile, g.h_top) { - (Some(profile), _) => consider(Layers::from_profile(p, profile)), - (None, None) => consider(Layers::new(p)), - (None, Some(span)) => { - // The top tree has 2^h_top leaves and every leaf costs at least one - // hash, so a top height past the keygen budget's log is out for any - // (a, k). - let ceiling = 64 - b.max_keygen().max(1).leading_zeros() as u64; - for h_top in span.within((p.h + 1).saturating_sub(p.d).min(ceiling)) { - consider(Layers::new(&Params { - h_top: Some(h_top), - ..*p - })); - } - } - } - (!profiles.is_empty()).then_some(Room { profiles, slack }) -} - -/// Every feasible parameter set, ordered by verification cost. -/// -/// One row per `(scheme, h, d, a, k, w, dropped_chains)`, carrying the best -/// target sum for that tuple and the layer profile that admitted it. Rows are -/// what gets printed; the comparison behind each one saw every target sum. -pub fn search(b: &Budgets, g: &Grid, st: &mut Stats) -> Vec { - let started = Instant::now(); - let digest_bits = (8 * g.n) as u32; - let mut sec = SecurityTable::new(b.q_s, b.security, g.n, g.h.hi as u32, g.k.hi, g.a.hi); - // Every (scheme, h, d, a, k, w, dropped) key is reached exactly once, so - // rows need no deduplication, only a bound: budgets loose enough to admit - // millions of them would otherwise be held in memory to print a dozen. - let mut best: Vec = Vec::new(); - - for &scheme in &g.schemes { - for &bits in &g.chain_bits { - let w = 1u64 << bits; - // WOTS-TW has no counter to grind, so it cannot drop chains, and - // WOTS+C has to keep at least one. - let dropped_range = if scheme.wots_c() { - g.dropped.within((8 * g.n / bits).saturating_sub(1)) - } else { - 0..=0 - }; - for dropped in dropped_range { - let probe = params( - g, - scheme, - g.h.hi.max(1), - 1, - g.a.lo, - if scheme.fors_c() { 2 } else { 1 }, - w, - dropped, - ); - let Some(l) = probe.chains() else { continue }; - let table = scheme.wots_c().then(|| NuTable::new(l, w, digest_bits)); - let min_trials = table.as_ref().map_or(0, |t| t.min_trials()); - for h in g.h.iter() { - for d in g.d.within(h) { - st.grid += 1; - // Layer profiles first: they need no a or k, and the - // keygen budget alone usually settles the question. - let Some(room) = room(b, g, ¶ms(g, scheme, h, d, g.a.lo, 1, w, dropped)) else { - st.keygen_pruned += 1; - continue; - }; - st.profiles += room.profiles.len() as u64; - for a in g.a.iter() { - for k in g.k.iter() { - if !sec.is_secure(h as u32, k, a) { - st.insecure += 1; - continue; - } - let p = params(g, scheme, h, d, a, k, w, dropped); - let Some(sk) = Skeleton::new(p) else { continue }; - st.skeletons += 1; - // The signature grows with k, so once it is too - // big it stays too big. - if sk.sig_bytes > b.max_size() { - st.size_pruned += 1; - break; - } - // What the best profile can still afford to - // grind, once this (a, k) has taken its share. - let per_trial = b.of(sk.grind_step) * d; - let max_trials = room.slack.saturating_sub(b.of(sk.fors_part)) / per_trial.max(1); - let Some(table) = table.as_ref() else { - // WOTS-TW: no counter, no target sum - if room.slack >= b.of(sk.fors_part) { - st.feasible += 1; - record(&mut best, st, b, &sk, &room, 0, 0); - } - continue; - }; - if max_trials < min_trials { - st.sign_pruned += 1; - continue; - } - let sums = match g.sums { - Sums::Sweep => 0..=sk.max_swn, - Sums::Mean => sk.default_swn..=sk.default_swn, - Sums::Pinned(s) => s..=s, - }; - st.swept += 1; - let mut winner: Option<(u64, u64)> = None; - for swn in sums { - if table.trials(swn) > max_trials { - continue; - } - st.feasible += 1; - let v = b.of(sk.verify(swn)); - if winner.is_none_or(|(_, best_v)| v < best_v) { - winner = Some((swn, v)); - } - } - if let Some((swn, _)) = winner { - record(&mut best, st, b, &sk, &room, swn, table.trials(swn)); - } - } - } - } - } - } - } - } - - st.seconds = started.elapsed().as_secs_f64(); - sort_rows(&mut best, b); - best -} - -/// Rows kept before the list is trimmed back to `ROWS_KEPT`. The optimum is -/// unaffected: what gets dropped is worse than everything retained. -const ROWS_CAP: usize = 1 << 18; -const ROWS_KEPT: usize = 1 << 17; - -fn sort_rows(rows: &mut [Candidate], b: &Budgets) { - rows.sort_by_key(|c| (b.of(c.costs.verify), c.costs.sig_bytes, b.of(c.costs.sign))); -} - -/// Record this parameter tuple on the cheapest layer profile that fits: they -/// all verify the same, so the tie goes to signing. -fn record(rows: &mut Vec, st: &mut Stats, b: &Budgets, sk: &Skeleton, room: &Room, swn: u64, trials: u64) { - let Some(lay) = room - .profiles - .iter() - .filter(|lay| b.of(sk.sign(lay, trials)) <= b.max_sign()) - .min_by_key(|lay| (b.of(sk.sign(lay, trials)), b.of(sk.sign_cold(lay, trials)))) - else { - return; - }; - st.rows += 1; - rows.push(Candidate { - params: Params { - h_top: Some(lay.profile.h_top()), - ..sk.params - }, - costs: sk.finish(lay, swn, trials), - }); - if rows.len() >= ROWS_CAP { - sort_rows(rows, b); - rows.truncate(ROWS_KEPT); - st.rows_dropped += (ROWS_CAP - ROWS_KEPT) as u64; - } -} - -/// Axes where a result sits at the top of a searched range. -/// -/// Such a result may be limited by the range rather than by the budgets, so it -/// is worth raising the range and rerunning before believing it. An axis pinned -/// to one value was pinned deliberately and says nothing. -pub fn edges(g: &Grid, c: &Candidate) -> Vec { - let bits = Span::new( - g.chain_bits.iter().copied().min().unwrap_or(0), - g.chain_bits.iter().copied().max().unwrap_or(0), - ); - let at = [ - ("h", c.params.h, g.h, "H_MAX / --height"), - ("d", c.params.d, g.d, "D_MAX / --layers"), - ("a", c.params.a, g.a, "A_MAX / -a"), - ("k", c.params.k, g.k, "K_MAX / -k"), - ("chain_bits", c.costs.chain_bits, bits, "CHAIN_BITS_MAX / --chain-bits"), - ( - "dropped_chains", - c.params.dropped_chains, - g.dropped, - "DROPPED_MAX / --drop-chains", - ), - ]; - at.iter() - .filter(|(_, v, span, _)| !span.pinned() && *v + 1 >= span.hi) - .map(|(axis, v, span, what)| { - let where_ = if *v >= span.hi { "at" } else { "one step below" }; - format!( - "{axis} = {v} is {where_} the top of the searched range ({}): raise {what} and rerun", - span.hi - ) - }) - .collect() -} - -/// The same search with nothing skipped: every `(a, k, h_top, S_wn)` point -/// costed in full and checked against every budget. -/// -/// Only usable on a tiny grid, which is the point: it is the oracle the real -/// search is diffed against in `tests/goldens`. -pub fn naive_search(b: &Budgets, g: &Grid) -> Vec { - let digest_bits = (8 * g.n) as u32; - let mut out: Vec = Vec::new(); - for &scheme in &g.schemes { - for &bits in &g.chain_bits { - let w = 1u64 << bits; - let dropped_range = if scheme.wots_c() { g.dropped.iter() } else { 0..=0 }; - for dropped in dropped_range { - for h in g.h.iter() { - for d in g.d.within(h) { - for h_top in 1..=h { - for a in g.a.iter() { - for k in g.k.iter() { - let p = Params { - h_top: Some(h_top), - ..params(g, scheme, h, d, a, k, w, dropped) - }; - let Some(sk) = Skeleton::new(p) else { continue }; - let Some(lay) = Layers::new(&p) else { continue }; - if crate::security::security_bits(b.q_s, h as u32, k, a, g.n) < b.security { - continue; - } - let table = scheme.wots_c().then(|| NuTable::new(sk.l, w, digest_bits)); - let sums: Vec = match &table { - Some(_) => (0..=sk.max_swn).collect(), - None => vec![0], - }; - for swn in sums { - let trials = table.as_ref().map_or(0, |t| t.trials(swn)); - let c = sk.finish(&lay, swn, trials); - if b.fits(&c) { - out.push(Candidate { params: p, costs: c }); - } - } - } - } - } - } - } - } - } - } - out.sort_by_key(|c| (b.of(c.costs.verify), c.costs.sig_bytes, b.of(c.costs.sign))); - out -} diff --git a/doc/sphincs/params_selection/src/security.rs b/doc/sphincs/params_selection/src/security.rs deleted file mode 100644 index 5306cda4f..000000000 --- a/doc/sphincs/params_selection/src/security.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! Classical security of a FORS-based parameter set. -//! -//! Ported from `security.sage` of BlockstreamResearch/SPHINCS-Parameters. The -//! sage version carries the sum in 100-digit decimals; this carries it in -//! log2-space `f64`, as the report's own site does, which `tests/goldens` pins -//! against the decimal values to better than 0.001 bits. - -/// -log2 P(FORS subset forgery) after `q_s` signatures. -/// -/// An adversary that finds a hypertree leaf reused `r` times, and a message -/// whose `k` FORS indices all point at leaves those `r` signatures already -/// opened, forges without inverting anything: -/// -/// ```text -/// P = sum_r C(q_s, r) p^r (1-p)^(q_s-r) * (1 - (1 - 1/t)^r)^k -/// ``` -/// -/// with `p = 2^-h` the chance one signature lands on a given leaf and `t = 2^a`. -/// The binomial term is carried by its recurrence rather than built from -/// `C(q_s, r)`, so `q_s = 2^64` costs no more than `q_s = 2^20`. -/// -/// `q_s` need not be a power of two. -/// -/// `None` when `q_s` so far exceeds the `2^h` leaves that there is no security -/// left to quantify. -pub fn forgery_exponent(q_s: f64, h: u32, k: u64, a: u64) -> Option { - const LOG2_E: f64 = std::f64::consts::LOG2_E; - // Expected times one FORS instance is reused. Past a few thousand the sum - // needs more terms than it is worth: the answer is "none", not a number. - let lam = q_s / 2f64.powi(h as i32); - if lam > 4096.0 || q_s < 1.0 { - return None; - } - let log2_p = -(h as f64); - let log2_1mp = (-2f64.powi(-(h as i32))).ln_1p() * LOG2_E; - let ln_miss = (-1.0 / 2f64.powi(a as i32)).ln_1p(); // ln(1 - 1/t) - - let r_max = (lam + 40.0 * (lam + 1.0).sqrt()).ceil() as u64 + 40; - let r_max = r_max.max(1000); - - let mut log2_term = q_s * log2_1mp; // C(q_s,0) p^0 (1-p)^q_s - let mut log2_sigma = f64::NEG_INFINITY; - for r in 1..=r_max { - let rf = r as f64; - log2_term += (q_s - rf + 1.0).log2() - rf.log2() + log2_p - log2_1mp; - // log2 (1 - (1-1/t)^r)^k, via expm1 so that small r keeps its digits - let log2_pf = k as f64 * (-(rf * ln_miss).exp_m1()).log2(); - let contribution = log2_term + log2_pf; - log2_sigma = log2_sum_exp(log2_sigma, contribution); - // Stop once the tail cannot matter, either absolutely or against what - // has already accumulated. - if rf > lam && (contribution < -1250.0 || contribution < log2_sigma - 80.0) { - break; - } - } - Some(-log2_sigma) -} - -/// Classical bit security: the forgery exponent capped by the preimage bound. -/// -/// A query aimed at a FORS forgery cannot double as a preimage query for a tree -/// node or a WOTS chain (different tweaks), so the two attacks are independent -/// strategies and the adversary simply takes the better one. -pub fn security_bits(q_s: f64, h: u32, k: u64, a: u64, n: u64) -> f64 { - forgery_exponent(q_s, h, k, a).map_or(0.0, |e| e.min(8.0 * n as f64)) -} - -fn log2_sum_exp(a: f64, b: f64) -> f64 { - let (hi, lo) = if a >= b { (a, b) } else { (b, a) }; - if hi == f64::NEG_INFINITY { - return hi; - } - hi + 2f64.powf(lo - hi).ln_1p() * std::f64::consts::LOG2_E -} - -/// `is_secure` memoized over `(h, k, a)`, which is all it depends on. -/// -/// A search revisits the same triple once per `(scheme, d, chain_bits, -/// dropped_chains)`, so without this the security sum dominates everything. -pub struct SecurityTable { - q_s: f64, - target: f64, - n: u64, - h_max: u32, - k_max: u64, - a_max: u64, - /// 0 unknown, 1 secure, 2 insecure - seen: Vec, -} - -impl SecurityTable { - pub fn new(q_s: f64, target: f64, n: u64, h_max: u32, k_max: u64, a_max: u64) -> Self { - let cells = (h_max as usize + 1) * (k_max as usize + 1) * (a_max as usize + 1); - Self { - q_s, - target, - n, - h_max, - k_max, - a_max, - seen: vec![0; cells], - } - } - - pub fn is_secure(&mut self, h: u32, k: u64, a: u64) -> bool { - if h > self.h_max || k > self.k_max || a > self.a_max { - return self.compute(h, k, a); - } - let i = (h as usize * (self.k_max as usize + 1) + k as usize) * (self.a_max as usize + 1) + a as usize; - if self.seen[i] == 0 { - self.seen[i] = if self.compute(h, k, a) { 1 } else { 2 }; - } - self.seen[i] == 1 - } - - fn compute(&self, h: u32, k: u64, a: u64) -> bool { - security_bits(self.q_s, h, k, a, self.n) >= self.target - } -} diff --git a/doc/sphincs/params_selection/tests/goldens.rs b/doc/sphincs/params_selection/tests/goldens.rs deleted file mode 100644 index 2832eaf90..000000000 --- a/doc/sphincs/params_selection/tests/goldens.rs +++ /dev/null @@ -1,588 +0,0 @@ -//! Everything this crate computes, pinned against something outside it. -//! -//! Sources, in descending order of authority: -//! -//! * `tests/fixtures.json` of BlockstreamResearch/SPHINCS-Parameters, frozen -//! there from real `sage costs.sage` runs, under both hash conventions; -//! * Tables 1 and 2 of the report itself, for the columns it publishes; -//! * `security.sage`, whose 100-digit decimal sum the log2-space f64 port here -//! has to reproduce; -//! * `doc/xmss/main.tex`, for the digest-cut geometry; -//! * for the search, a naive oracle in this crate that skips nothing. - -use sphincs_params::cost::{Blocks, Encoding, NuTable, Scheme}; -use sphincs_params::params::{Layers, Params, Profile, Skeleton, costs}; -use sphincs_params::search::{Budgets, Grid, LEVEL1_BITS, Span, Stats, naive_search, search}; -use sphincs_params::security::{forgery_exponent, security_bits}; - -fn params(scheme: Scheme, h: u64, d: u64, a: u64, k: u64, w: u64) -> Params { - Params { - scheme, - h, - d, - h_top: None, - a, - k, - w, - n: 16, - dropped_chains: 0, - cache_height: None, - } -} - -/// `(scheme, h, d, k, a, w, S_wn)` and the `(size, keygen, sign, verify, -/// verify_worst)` it must produce, sizes in bytes and costs in compressions. -type Fixture = (Scheme, u64, u64, u64, u64, u64, Option, [u64; 5]); - -/// From fixtures.json under the cached convention. -const FIXTURES_CACHED: [Fixture; 7] = [ - ( - Scheme::Spx, - 63, - 7, - 14, - 12, - 16, - None, - [7856, 292351, 2218483, 2155, 3891], - ), - ( - Scheme::Wc, - 44, - 4, - 8, - 16, - 16, - Some(240), - [4960, 1069055, 5849347, 1185, 1185], - ), - ( - Scheme::Wc, - 40, - 5, - 11, - 14, - 256, - Some(2040), - [4596, 1050111, 5794969, 10441, 10441], - ), - ( - Scheme::Wc, - 40, - 5, - 11, - 14, - 256, - Some(2840), - [4596, 1050111, 5941944, 6441, 6441], - ), - ( - Scheme::WcFc, - 44, - 4, - 8, - 16, - 16, - Some(240), - [4688, 1069055, 5914880, 1168, 1168], - ), - ( - Scheme::WcFc, - 40, - 5, - 11, - 14, - 256, - Some(2040), - [4356, 1050111, 5811349, 10425, 10425], - ), - ( - Scheme::WcFc, - 20, - 2, - 10, - 15, - 256, - Some(2040), - [3160, 4200447, 9418194, 4261, 4261], - ), -]; - -#[test] -fn matches_the_sage_fixtures() { - for &(scheme, h, d, k, a, w, swn, want) in &FIXTURES_CACHED { - let p = params(scheme, h, d, a, k, w); - let c = costs(p, swn).expect("consistent parameters"); - let got = [ - c.sig_bytes, - c.keygen.compressions, - c.sign_cold.compressions, - c.verify.compressions, - c.verify_worst.compressions, - ]; - assert_eq!(got, want, "{} h={h} d={d} k={k} a={a} w={w}", scheme.label()); - } -} - -/// The compression rule: one call per 64 bytes of hash input, the input being -/// the n-byte public parameter, the n-byte tweak, and the payload. -#[test] -fn one_compression_per_64_bytes() { - let b = Blocks::new(16); - assert_eq!(b.merkle_node(), 1, "two 16-byte children fill one block exactly"); - assert_eq!(b.chain_step(), 1); - assert_eq!(b.chain_step_with_counter(), 1); - assert_eq!(b.prf(), 1); - // doc/xmss's IncEnc: 32 B of prefix, a 32 B message, 24 B of randomness - // and 8 B of padding - assert_eq!(b.message_hash(), 2); - assert_eq!(b.message_prf(), 2); - for m in 1..200 { - assert_eq!(b.compress(m), (32 + 16 * m).div_ceil(64), "compressing {m} hash values"); - } - // And it is the same function as the report's SHA-2 layout with the PK.seed - // midstate cached, ceil((22*8 + 128m + 65) / 512), which is why its - // published compression counts still pin this model. - for m in 1..4000u64 { - assert_eq!( - b.compress(m), - (22 * 8 + 128 * m + 65).div_ceil(512), - "against the report's layout at m={m}" - ); - } -} - -/// Tables 1 and 2 of the report, WOTS/FORS rows only: `(scheme, h, d, a, k, w, -/// S_wn)` then `(SigVer, SigTime/1e4 to three figures, Exp. Search)` in hashes. The tables' Sig (B) column is 16 -/// bytes above what the current scripts compute (7856 for SLH-DSA-128s is the -/// FIPS 205 value, against the table's 7872); the fixtures above are the -/// authority there, and the tables predate them. -type ReportRow = (Scheme, u64, u64, u64, u64, u64, Option, u64, f64, Option); - -const REPORT_TABLE: [ReportRow; 18] = [ - (Scheme::Spx, 63, 7, 12, 14, 16, None, 2088, 219.0, Some(0)), - (Scheme::Wc, 44, 4, 16, 8, 16, Some(240), 1150, 578.0, Some(264)), - (Scheme::Wc, 44, 4, 16, 8, 16, Some(304), 894, 579.0, Some(5344)), - (Scheme::Wc, 44, 4, 16, 8, 256, Some(2040), 8350, 3515.0, Some(2996)), - (Scheme::Wc, 40, 5, 14, 11, 256, Some(2040), 10417, 579.0, Some(3745)), - (Scheme::Wc, 40, 5, 14, 11, 256, Some(2840), 6417, 594.0, None), - (Scheme::WcFc, 44, 4, 16, 8, 16, Some(240), 1133, 572.0, None), - (Scheme::WcFc, 40, 5, 14, 11, 256, Some(2040), 10402, 577.0, Some(36513)), - (Scheme::Wc, 36, 3, 14, 9, 16, Some(240), 899, 676.0, Some(198)), - (Scheme::Wc, 33, 3, 15, 9, 16, Some(304), 713, 405.0, Some(4008)), - (Scheme::Wc, 32, 4, 14, 10, 256, Some(2840), 5152, 481.0, None), - (Scheme::WcFc, 33, 3, 15, 9, 16, Some(240), 889, 401.0, Some(65734)), - (Scheme::WcFc, 32, 4, 14, 10, 256, Some(2040), 8337, 467.0, Some(35764)), - (Scheme::Wc, 24, 2, 16, 8, 16, Some(240), 646, 578.0, None), - (Scheme::Wc, 24, 2, 16, 8, 256, Some(2040), 4246, 3515.0, None), - (Scheme::WcFc, 24, 2, 16, 8, 16, Some(240), 629, 572.0, None), - (Scheme::Wc, 20, 2, 15, 10, 256, Some(2040), 4266, 938.0, None), - (Scheme::WcFc, 20, 2, 15, 10, 256, Some(2040), 4250, 934.0, None), -]; - -#[test] -fn matches_the_report_tables() { - for (scheme, h, d, a, k, w, swn, sigver, sigtime_e4, search) in REPORT_TABLE { - let p = params(scheme, h, d, a, k, w); - let c = costs(p, swn).expect("consistent parameters"); - let tag = format!("{} h={h} d={d} a={a} k={k} w={w} S={swn:?}", scheme.label()); - assert_eq!(c.verify.hashes, sigver, "SigVer {tag}"); - let got = c.sign_cold.hashes as f64 / 1e4; - assert!( - (got - sigtime_e4).abs() < 0.55, - "SigTime {tag}: got {got:.2}e4, want {sigtime_e4}e4" - ); - if let Some(want) = search { - assert_eq!(c.grinding().hashes, want, "Exp. Search {tag}"); - } - } -} - -/// `(w, n, dropped)` -> `(chain_bits, pinned_bits, chains, mean S_wn)`. -/// `(8, 16, 0)` is the `doc/xmss/main.tex` instance: 2 of 128 bits pinned, -/// v = 42 chains. For the w the report uses, chain_bits divides 128 and nothing -/// is pinned. -const ENCODINGS: [(u64, u64, u64, [u64; 4]); 10] = [ - (8, 16, 0, [3, 2, 42, 147]), - (8, 16, 1, [3, 5, 41, 143]), - (8, 16, 2, [3, 8, 40, 140]), - (16, 16, 0, [4, 0, 32, 240]), - (16, 16, 1, [4, 4, 31, 232]), - (32, 16, 0, [5, 3, 25, 387]), - (256, 16, 0, [8, 0, 16, 2040]), - (4096, 16, 0, [12, 8, 10, 20475]), - (2, 16, 0, [1, 0, 128, 64]), - (8, 32, 0, [3, 1, 85, 297]), -]; - -#[test] -fn digest_cut_follows_doc_xmss() { - for (w, n, dropped, want) in ENCODINGS { - let e = Encoding::new(w, n, dropped).expect("a chain is left"); - let got = [e.chain_bits, e.pinned_bits, e.chains, e.default_swn()]; - assert_eq!(got, want, "encoding w={w} n={n} dropped={dropped}"); - } - assert!( - Encoding::new(8, 16, 42).is_none(), - "dropping every chain leaves nothing to sign" - ); - assert!(Encoding::new(24, 16, 0).is_none(), "w must be a power of two"); -} - -/// `(l, w, swn)` -> `(nu, trials)`, from the python port's exact bignum values. -#[test] -fn digit_sum_counts_are_exact() { - let cases: [(u64, u64, u64, u128, u64); 4] = [ - (42, 8, 195, 11539185377238682781344003244544752, 29490), - (42, 8, 147, 2277086601665419901777619378707106160, 150), - (16, 256, 2040, 454918678781617793203528879683071744, 749), - (128, 2, 64, 23951146041928082866135587776380551750, 15), - ]; - for (l, w, swn, nu, trials) in cases { - let t = NuTable::new(l, w, 128); - assert_eq!(t.nu(swn), nu, "nu(l={l}, w={w}, swn={swn})"); - assert_eq!(t.trials(swn), trials, "trials(l={l}, w={w}, swn={swn})"); - } - // The count is symmetric and the totals check out: nothing is lost off - // either end of the table. - let t = NuTable::new(32, 16, 128); - for s in 0..=240 { - assert_eq!(t.nu(s), t.nu(480 - s), "the digit-sum count is symmetric at s={s}"); - } - assert_eq!(t.min_trials(), t.trials(240), "the cheapest grinding is at the mean"); - assert_eq!(t.nu(240), 5181241160064611531897369560287267312); -} - -/// `(lifetime, h, k, a)` -> forgery exponent, from `security.sage` via the -/// python port's 100-digit decimal sum. The f64 log-space version here has to -/// land within a thousandth of a bit. -const SECURITY: [(i32, u32, u64, u64, f64); 13] = [ - (64, 63, 14, 12, 133.749299297), - (40, 44, 8, 16, 128.283950447), - (40, 40, 11, 14, 134.630384667), - (30, 32, 10, 14, 131.514752565), - (20, 24, 8, 16, 128.283952741), - (30, 33, 9, 15, 131.399050971), - (20, 20, 10, 15, 133.177627134), - (40, 42, 9, 15, 128.338475839), - (30, 30, 9, 16, 129.632278830), - (20, 18, 19, 10, 129.282431578), - (64, 63, 35, 6, 104.414518339), - (40, 45, 8, 16, 130.423562374), - (30, 36, 9, 14, 129.476084807), -]; - -#[test] -fn security_matches_the_decimal_sum() { - for (log2_q_s, h, k, a, want) in SECURITY { - let q_s = 2f64.powi(log2_q_s); - let got = forgery_exponent(q_s, h, k, a).expect("converges"); - assert!( - (got - want).abs() < 1e-3, - "forgery exponent at q_s=2^{log2_q_s} h={h} k={k} a={a}: got {got:.9}, want {want:.9}" - ); - } - // The preimage bound caps the reported level, and 128 bits is what every - // parameter set in the report reaches. - assert_eq!(security_bits(2f64.powi(64), 63, 14, 12, 16), 128.0); - assert_eq!(security_bits(2f64.powi(30), 32, 10, 14, 16), 128.0); - // n = 32 lifts the cap, so the forgery term shows through. - assert!((security_bits(2f64.powi(30), 32, 10, 14, 32) - 131.514752565).abs() < 1e-3); - // A lifetime far past the hypertree has nothing left to quantify. - assert!(forgery_exponent(2f64.powi(40), 20, 10, 15).is_none()); - assert_eq!(security_bits(2f64.powi(40), 20, 10, 15, 16), 0.0); -} - -#[test] -fn secure_k_form_an_up_set() { - // Relied on nowhere in the search, which tests every k, but it is the - // property that makes "the smallest secure k" a meaningful phrase at all. - for (h, a) in [(20u32, 10u64), (24, 12), (30, 14)] { - let flags: Vec = (1..=32) - .map(|k| security_bits(2f64.powi(20), h, k, a, 16) >= LEVEL1_BITS) - .collect(); - let mut sorted = flags.clone(); - sorted.sort_unstable(); - assert_eq!(flags, sorted, "secure k are an up-set at h={h} a={a}"); - } -} - -#[test] -fn half_top_cache_is_a_saving_and_reduces_to_the_full_tree() { - let p = params(Scheme::WcFc, 40, 5, 14, 11, 256); - let c = costs(p, None).unwrap(); - assert!(c.sign.hashes < c.sign_cold.hashes); - // caching at the leaves is caching the whole tree: nothing left to rebuild - let whole = Params { - cache_height: Some(0), - ..p - }; - assert!(costs(whole, None).unwrap().sign.hashes < c.sign.hashes); - // caching only the root is caching nothing, so signing goes cold - let none = Params { - cache_height: Some(p.profile().unwrap().h_top()), - ..p - }; - assert_eq!(costs(none, None).unwrap().sign.hashes, c.sign_cold.hashes); -} - -fn budgets(log2_q_s: i32, keygen: u64, sign: u64, size: u64) -> Budgets { - Budgets { - q_s: 2f64.powi(log2_q_s), - keygen: Some(keygen), - sign: Some(sign), - size: Some(size), - security: LEVEL1_BITS, - } -} - -#[test] -fn search_agrees_with_a_naive_oracle() { - // A grid small enough to sweep with nothing skipped at all. - let b = budgets(20, 3_000_000, 10_000_000, 4_000); - let g = Grid { - schemes: vec![Scheme::Wc, Scheme::WcFc], - h: Span::pin(20), - a: Span::new(14, 16), - k: Span::new(1, 14), - chain_bits: vec![4], - dropped: Span::new(0, 1), - h_top: Some(Span::new(1, 20)), - ..Default::default() - }; - let mut st = Stats::default(); - let found = search(&b, &g, &mut st); - let oracle = naive_search(&b, &g); - assert!(!found.is_empty() && !oracle.is_empty()); - assert_eq!( - found[0].costs.verify.hashes, oracle[0].costs.verify.hashes, - "the oracle finds the same optimum" - ); - assert_eq!(found[0].key(), oracle[0].key(), "and the same winner"); - // Every parameter tuple the oracle found feasible is in the search's output, - // with the same best verification cost for that tuple. - let mut want: std::collections::HashMap<_, u64> = Default::default(); - for c in &oracle { - let e = want.entry(c.key()).or_insert(u64::MAX); - *e = (*e).min(c.costs.verify.hashes); - } - let got: std::collections::HashMap<_, u64> = found.iter().map(|c| (c.key(), c.costs.verify.hashes)).collect(); - assert_eq!(got, want, "the search and the oracle agree tuple by tuple"); -} - -#[test] -fn search_finds_and_improves_on_the_reports_bold_row() { - // Budgets near the report's 2^40 numbers, on its grid (w in {16, 256}, no - // chain dropping). Its own choice has to come out feasible, and the search - // has to do at least as well: it spends what is left of the signing budget - // raising the target sum, which the report's row does not. - let b = budgets(40, 1_100_000, 6_000_000, 4_400); - let g = Grid { - chain_bits: vec![4, 8], - dropped: Span::pin(0), - ..Default::default() - }; - let mut st = Stats::default(); - let found = search(&b, &g, &mut st); - let row = found - .iter() - .find(|c| c.key() == (Scheme::WcFc, 40, 5, 14, 11, 256, 0)) - .expect("the report's bold row is feasible under its own budgets"); - assert!( - row.costs.swn.unwrap() > 2040, - "the report's row grinds less than the budget allows" - ); - assert!( - row.costs.verify.hashes < 10402, - "so it can verify faster than the table's 10402 hashes" - ); - assert!( - found[0].costs.verify.hashes <= row.costs.verify.hashes, - "and the winner is at least as cheap" - ); -} - -#[test] -fn a_taller_top_layer_is_free_on_size_and_verification() { - // The whole point of per-layer heights: the signature carries h - // authentication nodes and the verifier walks them however the layers - // divide h, so only the signer's costs move. - let uniform = Params { - h_top: Some(8), - ..params(Scheme::WcFc, 40, 5, 14, 11, 256) - }; - let tall = Params { - h_top: Some(15), - ..uniform - }; - let (u, t) = (costs(uniform, None).unwrap(), costs(tall, None).unwrap()); - assert_eq!(u.profile.total(), 40); - assert_eq!(t.profile.total(), 40); - assert_eq!( - (t.sig_bytes, t.verify), - (u.sig_bytes, u.verify), - "size and verification do not move" - ); - assert!( - t.keygen.hashes > u.keygen.hashes, - "a taller top tree costs more to generate" - ); - assert!(t.sign_cold.hashes > u.sign_cold.hashes, "and more to sign cold"); - assert!(t.sign.hashes < u.sign.hashes, "but less with it, which is the point"); - // the lower layers come out as equal as they go, never differing by more - // than one level - let p = t.profile; - let lower: Vec = p.heights().skip(1).collect(); - let (lo, hi) = (lower.iter().min().unwrap(), lower.iter().max().unwrap()); - assert!( - hi - lo <= 1, - "the lower layers never differ by more than a level: {lower:?}" - ); - assert_eq!(Profile::canonical(41, 5, Some(9)).unwrap().total(), 41); - assert_eq!(Layers::new(&tall).unwrap().profile, t.profile); -} - -/// Every way of splitting `h` over `d` layers, top first. -fn compositions(h: u64, d: u64) -> Vec> { - if d == 1 { - return vec![vec![h]]; - } - (1..=h.saturating_sub(d - 1)) - .flat_map(|first| { - compositions(h - first, d - 1).into_iter().map(move |rest| { - let mut out = vec![first]; - out.extend(rest); - out - }) - }) - .collect() -} - -/// The search only ever builds `Profile::canonical`, and this is why that is -/// not a restriction: for the same `(h, d, h_top)`, no other profile costs less -/// on anything. -#[test] -fn profile_shape_is_never_beaten() { - let mut checked = 0; - for (h, d) in [(12, 3), (14, 4), (9, 2), (16, 5), (20, 4)] { - let p = Params { - h, - d, - ..params(Scheme::WcFc, h, d, 10, 12, 16) - }; - let sk = Skeleton::new(p).expect("consistent"); - for heights in compositions(h, d) { - let Some(profile) = Profile::new(&heights) else { - continue; - }; - let Some(any) = Layers::from_profile(&p, profile) else { - continue; - }; - let canon = Layers::new(&Params { - h_top: Some(heights[0]), - ..p - }) - .expect("same top height"); - let tag = format!("h={h} d={d} heights={heights:?}"); - // size and verification do not see the profile at all - let (a, c) = (sk.finish(&any, 0, 0), sk.finish(&canon, 0, 0)); - assert_eq!( - (a.sig_bytes, a.verify), - (c.sig_bytes, c.verify), - "size or verification moved: {tag}" - ); - // keygen is the top tree, which they share - assert_eq!(any.keygen, canon.keygen, "keygen moved: {tag}"); - // and the canonical split is the cheapest to sign, cached or cold - assert!( - canon.trees_cached.compressions <= any.trees_cached.compressions, - "beaten on signing: {tag}" - ); - assert!( - canon.trees.compressions <= any.trees.compressions, - "beaten on cold signing: {tag}" - ); - checked += 1; - } - } - assert!(checked > 2000, "only {checked} profiles checked"); -} - -/// A profile is expressible however uneven, and reads back as given. -#[test] -fn any_profile_can_be_costed() { - let p = params(Scheme::WcFc, 26, 4, 14, 11, 256); - let lopsided = Profile::new(&[11, 5, 7, 3]).expect("26 over 4 layers"); - assert_eq!(lopsided.total(), 26); - assert_eq!(lopsided.h_top(), 11); - assert_eq!(format!("{lopsided}"), "11 + 5 + 7 + 3"); - let lay = Layers::from_profile(&p, lopsided).expect("adds up to h over d layers"); - assert_eq!(lay.profile, lopsided); - // and the canonical one with the same top is at least as cheap to sign - let canon = Layers::new(&Params { h_top: Some(11), ..p }).unwrap(); - assert_eq!(format!("{}", canon.profile), "11 + 5 + 5 + 5"); - assert!(canon.trees_cached.compressions <= lay.trees_cached.compressions); - // heights have to add up over the layers there are - assert!(Layers::from_profile(&p, Profile::new(&[11, 5, 7, 4]).unwrap()).is_none()); - assert!(Layers::from_profile(&p, Profile::new(&[13, 13]).unwrap()).is_none()); - assert!(Profile::new(&[11, 0, 15]).is_none(), "every layer needs a level"); - assert!(Profile::new(&[64]).is_none(), "2^height has to be countable"); -} - -#[test] -fn skeleton_rejects_trees_that_do_not_fit_a_u64() { - // 2^h' leaves has to be countable: without this the shift masks and a - // 2^64-leaf tree reports the cost of a one-leaf tree. - let p = params(Scheme::WcFc, 64, 1, 14, 11, 256); - assert!(Skeleton::new(p).is_none()); - assert!(Skeleton::new(Params { h: 63, ..p }).is_some()); - assert!(Skeleton::new(Params { a: 64, ..p }).is_none()); - // and the cost really does scale with the tree, so nothing wraps below that - let small = costs(Params { h: 40, d: 8, ..p }, None).unwrap(); - let large = costs(Params { h: 48, d: 8, ..p }, None).unwrap(); - // twice the leaves is twice the work plus the node joining the two halves - assert_eq!(large.keygen.hashes, small.keygen.hashes * 2 + 1); -} - -#[test] -fn skeleton_rejects_inconsistent_parameters() { - let ok = params(Scheme::WcFc, 40, 5, 14, 11, 256); - assert!(Skeleton::new(ok).is_some()); - // d need not divide h: the layers just come out within one of each other - let uneven = Skeleton::new(Params { d: 3, ..ok }).expect("d need not divide h"); - assert_eq!(uneven.params.profile().unwrap().total(), 40); - assert!( - Skeleton::new(Params { h: 2, d: 3, ..ok }).is_none(), - "every layer needs a level" - ); - assert!( - Skeleton::new(Params { h_top: Some(40), ..ok }).is_none(), - "the lower layers need levels too" - ); - assert!( - Skeleton::new(Params { h_top: Some(36), ..ok }).is_some(), - "but only one each" - ); - assert!( - Skeleton::new(Params { w: 24, ..ok }).is_none(), - "w must be a power of two" - ); - assert!(Skeleton::new(Params { k: 1, ..ok }).is_none(), "FORS+C signs k-1 trees"); - assert!( - Skeleton::new(Params { - scheme: Scheme::Spx, - dropped_chains: 1, - ..ok - }) - .is_none(), - "WOTS-TW has no counter" - ); - assert!( - Layers::new(&Params { - cache_height: Some(99), - ..ok - }) - .is_none(), - "the cache sits inside the top tree" - ); -} From ef9b43a387e122aa1db93356324576cb752a1524 Mon Sep 17 00:00:00 2001 From: Tom Wambsgans Date: Tue, 25 Aug 2026 15:56:59 +0200 Subject: [PATCH 31/31] readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b9258b582..c04c6e01a 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@

- Aggregation: 1100 XMSS/s - Aggregation: 196 SPHINCS/s - 2 to 1 recursion: 0.45s + + +

Warning: highly experimental.