diff --git a/.github/workflows/build_and_deploy.yml b/.github/workflows/build_and_deploy.yml
index ec45e97..4af037e 100644
--- a/.github/workflows/build_and_deploy.yml
+++ b/.github/workflows/build_and_deploy.yml
@@ -51,7 +51,7 @@ jobs:
uses: actions/cache@v4
with:
path: ${{ env.GENERATED_PATHS }}
- key: generated-v1-${{ hashFiles('costs.sage', 'security.sage', 'slhdsa-2to40.sage', 'stateful.sage', 'export_site_data.sage', 'octopus_pmf.py', 'utils/*.py', 'Makefile') }}
+ key: generated-v1-${{ hashFiles('costs.sage', 'security.sage', 'slhdsa-2to40.sage', 'stateful.sage', 'export_site_data.sage', 'octopus_pmf.py', 'utils/*.py', 'Makefile', 'parameters.json') }}
- name: Generate (sweeps, derived tables, data.json, figures)
if: steps.cache.outputs.cache-hit != 'true'
diff --git a/.gitignore b/.gitignore
index 043428f..5b102d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,7 +3,6 @@
*.sage.py
# Generated artifacts
-utils/outputs_specialized/
candidates.csv
utils/all_size_capped_candidates.csv
utils/all_unbound_candidates.csv
diff --git a/Makefile b/Makefile
index a7f1bc3..32fc1d3 100644
--- a/Makefile
+++ b/Makefile
@@ -25,7 +25,7 @@ CSV_UNBOUND := $(UTILS)/all_unbound_candidates.csv
generate: sweeps derived data figures test
sweeps:
- $(SAGE) slhdsa-2to40.sage --max-size 7856 > /dev/null
+ $(SAGE) slhdsa-2to40.sage > /dev/null
mv candidates.csv $(CSV_CAPPED)
$(SAGE) slhdsa-2to40.sage --max-size 99999999 > /dev/null
mv candidates.csv $(CSV_UNBOUND)
diff --git a/README.md b/README.md
index 247e553..7300773 100644
--- a/README.md
+++ b/README.md
@@ -4,13 +4,13 @@ Scripts for exploring SPHINCS+ parameter tradeoffs, computing security levels, s
- [original-sphincs-parameter-search.sage](original-sphincs-parameter-search.sage) script from https://sphincs.org/data/sphincs+-specification.pdf (updated for SageMath 9.0)
- [costs.sage](costs.sage) computes signature sizes and signing/verification times for SPHINCS+ variants
- [stateful.sage](stateful.sage) evaluates stateful alternatives (XMSS, XMSS-MT, SHRINCS/UXMSS) against the SLH-DSA-128s baseline
+- [parameters.json](parameters.json) holds the search grids and fixed parameters that the Sage scripts, the tests and `site/data.json` share
- [octopus_pmf.py](octopus_pmf.py) computes the PMF of the Octopus authentication set size (MIT license, from https://github.com/MehdiAbri/PORS-FP)
- [slhdsa-2to40.sage](slhdsa-2to40.sage) sweeps SLH-DSA parameter tuples and filters them by security level and signature size
-- [run_experiments.py](run_experiments.py) runs the full parameter sweep and writes results to CSV
- [export_site_data.sage](export_site_data.sage) exports `site/data.json`, the dataset both site pages load
- [utils/](utils/) helper scripts for searching and visualising the parameter space
- [tests/](tests/) regression suite, run `python3 tests/test_regression.py`
- [document/](document/) LaTeX source for the accompanying technical report
-- [site/](site/) interactive pages hosted on GitHub Pages: the SHRINCS pinned-parameters main page ([index.html](site/index.html)), stateless parameter search over all report schemes (SPX / W+C / FORS+C / PORS+FP) with worst-case mode, shareable URL state and CSV export ([stateless.html](site/stateless.html)), and stateful scheme comparison ([stateful.html](site/stateful.html)); the stateful cost model shared by index and the stateful page lives in [formulas.js](site/formulas.js). The report PDF is not tracked in git: CI compiles [document/](document/) and deploys it as `site/report.pdf` (also attached to releases on tags)
+- [site/](site/) interactive pages hosted on GitHub Pages: the SHRINCS pinned-parameters main page ([index.html](site/index.html)), stateless parameter search over all report schemes (SPX / W+C / FORS+C / PORS+FP) with worst-case mode, shareable URL state and CSV export ([stateless.html](site/stateless.html)), and stateful scheme comparison ([stateful.html](site/stateful.html)); the cost and security model shared by the three pages lives in [js/model.js](site/js/model.js). The report PDF is not tracked in git: CI compiles [document/](document/) and deploys it as `site/report.pdf` (also attached to releases on tags)
Contributing: run `make check-local` before pushing.
\ No newline at end of file
diff --git a/costs.sage b/costs.sage
index a29c73a..97cbaee 100644
--- a/costs.sage
+++ b/costs.sage
@@ -1,107 +1,65 @@
#!/usr/bin/env sage
"""
-SPHINCS+ Parameter Efficiency and Size Calculator
-
-Computes signature sizes and signing/verification times for SPHINCS+ variants:
-- SPX: Plain SPHINCS+ (WOTS-TW + FORS)
-- W+C: SPHINCS+ with WOTS+C (counter-based WOTS, no checksum chains)
-- W+C_F+C: SPHINCS+ with WOTS+C and FORS+C (grinding to remove last FORS tree)
-- W+C_P+FP: SPHINCS+ with WOTS+C and PORS+FP (single tree with Octopus auth)
-
-Notation:
-- h: hypertree height (total)
-- d: number of layers in the hypertree
-- h' = h/d: height of each XMSS tree layer
-- n: hash output size in bits (fixed at 128 = hashbytes*8)
-- a: log2 of leaves per FORS tree (t = 2^a leaves per tree)
-- k: number of FORS trees (or leaf indices for PORS)
-- w: Winternitz parameter
-- l: number of WOTS chains
-- S_{w,n} (swn): target chain sum for WOTS+C
-- q_s: max number of signatures supported
+Sizes in bytes and costs in SHA-256 compressions of SLH-DSA (SPX) and the
+report's variants W+C, W+C_F+C and W+C_P+FP for (h, d, k, a, w), and the sweep
+that slhdsa-2to40.sage and export_site_data.sage share. docs/model.md has the
+model; PARAMETER_SETS holds the parameter sets of the report's tables.
+
+ sage costs.sage [--table [log2 q_s]] (those sets as CSV, or as tables)
+ sage costs.sage --params scheme log2_q_s k a h d w swn
"""
from sage.all import *
-from scipy.stats import binom
+from functools import lru_cache
+import json
import os
from octopus_pmf import interleave_cost_table
-# Load security.sage from the same directory
_dir = os.path.dirname(os.path.abspath(__file__))
load(os.path.join(_dir, "security.sage"))
-# =============================================================================
-# Constants
-# =============================================================================
-
-hashbytes = 16 # 16 bytes = 128 bits
-counter_size = 4 # 4 bytes = 32 bits
-randomness_size = 16 # 16 bytes = 128 bits
-
-# -----------------------------------------------------------------------------
-# Hash-cost convention
-# -----------------------------------------------------------------------------
-# Two SHA-256 compression-counting conventions are supported, selected via the
-# HASH_CONVENTION environment variable ('cached' or 'uncached'):
-#
-# cached (default): FIPS 205 SHA-2 layout with the PK.seed midstate cached.
-# This is the instantiation implemented by SHRINCS (github.com/SHRINCS/shrincs-bip),
-# and the counts below are the ones its reference implementation executes.
-#
-# uncached: the original convention of this script, kept verbatim so that
-# earlier numbers stay reproducible: every call pays for its full input
-# (a node hash costs 2 compressions), the message hashes are single
-# 2-block hashes, and WOTS-TW verification is averaged as (w-1)/2 steps
-# per chain.
-#
-# Usage: HASH_CONVENTION=uncached sage costs.sage --params ...
-#
-HASH_CONVENTION = os.environ.get('HASH_CONVENTION', 'cached').lower()
-assert HASH_CONVENTION in ('cached', 'uncached'), (
- "HASH_CONVENTION must be 'cached' or 'uncached', got %r" % HASH_CONVENTION)
-
-C_Th1 = 1 # Tweakable hash, 1-block: PKseed (128) + Tweak (96) + m1 (128)
-C_Th1c = 1 # Tweakable hash, 1-block + counter: PKseed + Tweak + m1 + counter (32)
-C_Th2 = 1 if HASH_CONVENTION == 'cached' else 2 # Tweakable hash with hash convention
-C_PRF = 1 # PRF: PKseed + SKseed + Tweak
-
-# Message hash and message PRF, for a 32-byte message and an empty context.
-#
-# cached:
-# H_msg = MGF1-SHA-256(R || PK.seed || SHA-256(R || PK.seed || PK.root || M), m)
-# is two nested SHA-256 calls (98- and 68-byte inputs in SHRINCS), 2
-# compressions each. Its input starts with the randomizer R, so nothing
-# can be cached. -> 4
-# PRF_msg = HMAC-SHA-256(SK.prf, opt_rand || M) is two SHA-256 calls of
-# 3 and 2 compressions. Their first blocks are the fixed HMAC key pads,
-# cached like the PK.seed block (it would be 5 otherwise). -> 3
-# uncached (original model): one 2-block hash each
-# H_msg: PKseed + PKroot (128) + R (256) + m (256) -> 2
-# PRF_msg: SKprf (128) + Opt + m (256) + counter -> 2
-C_Hmsg = 4 if HASH_CONVENTION == 'cached' else 2
-C_PRFmsg = 3 if HASH_CONVENTION == 'cached' else 2
-
-def compute_Th(n):
- """
- Compression calls for a tweakable hash over n hash-sized values, under the active HASH_CONVENTION
-
- cached: ceil((22*8 + 128*n + 65) / 512)
- uncached: ceil((128 + 96 + 128*n + 65) / 512)
- """
- if HASH_CONVENTION == 'cached':
- return ceil((22*8 + 128*n + 65)/512)
- return ceil((128 + 96 + 128*n + 65)/512)
-
-# =============================================================================
-# Parameter Sets
-# =============================================================================
-
-# Single consolidated list in table output order
-# Format: (scheme, q_s, h, d, a, k, w, l, swn, bold)
-# q_s: log2 of max signatures (e.g., 40 means 2^40 signatures)
-# swn: S_{w,n} target chain sum for WOTS+C
-# bold=True for rows highlighted in the paper tables
+# Search grids and fixed parameters. Integers become Sage Integers, as if they
+# were written in this file.
+def _sage_numbers(x):
+ if isinstance(x, bool):
+ return x
+ if isinstance(x, int):
+ return Integer(x)
+ if isinstance(x, list):
+ return [_sage_numbers(v) for v in x]
+ if isinstance(x, dict):
+ return {key: _sage_numbers(v) for key, v in x.items()}
+ return x
+
+with open(os.path.join(_dir, "parameters.json")) as _f:
+ PARAMETERS = _sage_numbers(json.load(_f))
+SWN = {Integer(w): swn for w, swn in PARAMETERS['swn'].items()} # WOTS+C target digit sum per w
+
+# Sizes in bytes
+
+hashbytes = 16 # n
+counter_size = 4 # the WOTS+C counter of the stateless variants
+randomness_size = 16 # the randomizer R
+
+# Compressions per hash call: SHA-256 as SHRINCS (github.com/SHRINCS/shrincs-bip)
+# runs it, with the PK.seed block compressed once per key (docs/model.md, Hash calls)
+
+C_Th1 = 1 # tweakable hash of one value: a chain step or a leaf
+C_Th1c = 1 # tweakable hash of one value and the WOTS+C counter
+C_Th2 = 1 # tweakable hash of two values: a tree node
+C_PRF = 1 # PRF of an address with SK.seed
+C_Hmsg = 4 # for a 32-byte message and an empty context
+C_PRFmsg = 3
+
+def compute_Th(m):
+ """Compressions for a tweakable hash of m values: a 22-byte address, the
+ values and the SHA-256 padding."""
+ return ceil((22*8 + 128*m + 65)/512)
+
+# The parameter sets of the report's tables, in their order:
+# (scheme, log2 q_s, h, d, a, k, w, l, S_wn, bold), bold for the rows the
+# report highlights. The code computes l again from w.
PARAMETER_SETS = [
# Table 1: 2^64 signatures (SPX)
("SPX", 64, 63, 7, 12, 14, 16, 35, 0, True),
@@ -150,50 +108,25 @@ PARAMETER_SETS = [
("W+C_P+FP", 20, 20, 2, 15, 10, 256, 16, 2040, False),
]
-# =============================================================================
-# Helper Functions
-# =============================================================================
-
-# High precision arithmetic
-F = RealField(100)
+# WOTS
-# -----------------------------------------------------------------------------
-# WOTS helpers
-# -----------------------------------------------------------------------------
+@lru_cache(maxsize=None) # the symbolic logs are slow, and w takes few values
+def wots_lengths(w):
+ """(l1, l2): the message and checksum chains of WOTS-TW (FIPS 205,
+ Section 5). l1 rounds up, so for w = 32 the 26 chains carry 130 bits."""
+ l1 = ceil(hashbytes*8/log(w, 2))
+ l2 = floor(log(l1*(w-1), 2)/log(w, 2)) + 1
+ return l1, l2
def compute_wots_l(scheme, w):
- """
- Compute WOTS chain count l based on scheme type.
-
- For WOTS-TW (plain): l = l1 + l2
- l1 = ceil(8n / log2(w)) -- message chains (FIPS 205 Sec. 5)
- l2 = floor(log_w(l1*(w-1))) + 1 -- checksum chains (FIPS 205 Sec. 5)
-
- For WOTS+C: l = l1 (no checksum chains, replaced by counter)
-
- Note: l1 must round UP. For w=16 and w=256 the division is exact,
- but w=32 needs 26 chains (26*5 = 130 >= 128 bits); floor division
- would give 25 chains, which cannot encode a 128-bit digest.
- """
- if scheme == "SPX":
- l1 = ceil(hashbytes*8/log(w,2))
- l2 = floor(log(l1*(w-1), 2)/log(w, 2)) + 1
- return l1 + l2
- else:
- return ceil(hashbytes*8/log(w,2))
+ """Number of WOTS chains: l1 + l2 for WOTS-TW (SPX), l1 for WOTS+C,
+ which grinds a counter instead of signing a checksum."""
+ l1, l2 = wots_lengths(w)
+ return l1 + l2 if scheme == "SPX" else l1
def compute_wots_tw_worst_steps(l1, l2, w):
- """
- Worst-case WOTS-TW verification steps per hypertree layer.
-
- Worst case: all l1 message digits = 0, so the verifier traverses w-1
- steps on every message chain. The checksum C = l1*(w-1) is then encoded
- in base w as l2 digits c_j; the verifier traverses w-1-c_j steps on each
- checksum chain. Total = l1*(w-1) + l2*(w-1) - digit_sum(C, w).
-
- This is the correct model for WOTS-TW. The old average-case formula
- (w-1)*l/2 was only valid for WOTS+C where chains sum to a constant.
- """
+ """Chain steps to verify one WOTS-TW signature in the worst case, when
+ every message digit is 0 (docs/model.md, WOTS-TW verification)."""
C = l1 * (w - 1)
ds, rem = 0, int(C)
while rem > 0:
@@ -208,15 +141,8 @@ def _round_div(num, den):
_wots_tw_floor_cache = {}
def _wots_tw_floor_sum(l1, l2, w):
- """
- w^l1 * sum_{j=1}^{l2-1} E[floor(S / w^j)], as an exact integer, where S is
- the sum of l1 independent uniform base-w digits.
-
- E[floor(S / w^j)] = sum_{m>=1} P(S >= m*w^j), and the number of digit
- strings with sum <= t is, by inclusion-exclusion,
-
- sum_i (-1)^i * C(l1, i) * C(t - i*w + l1, l1).
- """
+ """w^l1 * sum_{j=1}^{l2-1} E[floor(S / w^j)] as an exact integer, S the sum
+ of l1 uniform base-w digits (docs/model.md, WOTS-TW verification)."""
l1, l2, w = int(l1), int(l2), int(w)
key = (l1, l2, w)
if key in _wots_tw_floor_cache:
@@ -239,47 +165,15 @@ def _wots_tw_floor_sum(l1, l2, w):
return acc
def compute_wots_tw_mean_steps(l1, l2, w, count=1):
- """
- Mean number of chain steps to verify `count` WOTS-TW signatures on
- uniformly random digests, rounded to the nearest integer.
-
- A message digit m costs w-1-m steps, so the l1 message chains average
- (w-1)/2 steps each. The l2 checksum chains do not: the checksum
- C = l1*(w-1) - S (S = sum of the message digits) is concentrated around
- l1*(w-1)/2, so its base-w digits are far from uniform (the leading one
- is almost always 0 or 1) and the verifier walks more of those chains
- than (w-1)/2. Exactly:
-
- E[steps] = l1*(w-1)/2 + l2*(w-1) - E[digit_sum(C)]
- = (w-1) * (l2 + sum_{j=1}^{l2-1} E[floor(S / w^j)]),
-
- using that C is distributed like S (map every digit m to w-1-m) and
- digit_sum(x) = x - (w-1) * sum_{j>=1} floor(x / w^j).
-
- For w = 16 this is 267.12 steps rather than the 262 that (w-1)*l/2
- gives, and the gap grows with w (2415.03 against 2295 for w = 256).
-
- Under the original convention (HASH_CONVENTION=uncached) the (w-1)*l/2
- approximation is kept.
- """
- if HASH_CONVENTION != 'cached':
- return count * ((w - 1) * (l1 + l2) // 2)
+ """Mean chain steps to verify count WOTS-TW signatures of uniform digests,
+ rounded once to the nearest integer (docs/model.md, WOTS-TW verification)."""
W = int(w)**int(l1)
T = _wots_tw_floor_sum(l1, l2, w)
- # count * (w-1) * (l2 + T/W), rounded to the nearest integer
- return _round_div(count * (w - 1) * (l2*W + T), W)
+ return _round_div(count * (w - 1) * (l2*W + T), W) # count*(w-1)*(l2 + T/W)
def compute_nu(l: int, paramsum: int, w: int) -> int:
- """
- Compute ν (nu), the number of valid WOTS+C encodings for a given target sum.
-
- For WOTS+C, we need message digests whose base-w digits sum to exactly S_{w,n}.
- ν counts how many such encodings exist out of w^l total possibilities.
-
- ν = Σ_{j=0}^{l} (-1)^j * C(l,j) * C((S_{w,n} + l) - j*w - 1, l-1)
-
- Returns integer ν. If ν is zero, returns 1 and prints a warning.
- """
+ """ν, the number of strings of l base-w digits that sum to paramsum
+ (docs/model.md, WOTS+C). 1, with a warning, where there are none."""
nu = 0
for j in range(l + 1):
sign = (-1) ** j
@@ -292,19 +186,12 @@ def compute_nu(l: int, paramsum: int, w: int) -> int:
nu = 1 # Avoid division by zero
return nu
-# -----------------------------------------------------------------------------
-# PORS helpers
-# -----------------------------------------------------------------------------
+# PORS+FP
def compute_pors_tree_geometry(k, a):
- """
- Compute PORS tree geometry.
-
- Returns (t, subtree_height, extra_leaves) where:
- - t: total number of PORS leaves (k * 2^a)
- - subtree_height: height of the largest power-of-2 subtree
- - extra_leaves: leaves beyond the power-of-2 subtree
- """
+ """(t, subtree_height, extra_leaves) of the PORS+FP tree: its t = k*2^a
+ leaves, the height of its largest complete subtree, and the leaves
+ outside that subtree."""
t = k * (2**a)
subtree_height = floor(log(t, 2))
extra_leaves = t - 2**subtree_height
@@ -313,12 +200,9 @@ def compute_pors_tree_geometry(k, a):
_interleave_table_cache = {}
def log2_exp_work_from_mmax(t, k, mmax):
- """Look up log2 of expected work for PORS+FP grinding given mmax.
-
- The (t, k) tables are cached: compute_mmax probes several mmax values per
- parameter set, and sweeps revisit the same (k, a) pairs for every (h, d) —
- without the cache a full PORS+FP sweep rebuilds each table thousands of times.
- """
+ """log2 of the expected attempts for PORS+FP grinding to an authentication
+ set of at most mmax nodes. The (t, k) tables are cached: a sweep asks for
+ the same (k, a) at every (h, d), and compute_mmax for several mmax."""
key = (int(t), int(k))
table = _interleave_table_cache.get(key)
if table is None:
@@ -332,105 +216,41 @@ def log2_exp_work_from_mmax(t, k, mmax):
return table[max(lowers)]
def exp_work_from_mmax(t, k, mmax):
- """Compute expected work (attempts) for PORS+FP grinding given mmax."""
+ """Expected attempts for PORS+FP grinding to at most mmax nodes."""
return 2.0 ** log2_exp_work_from_mmax(t, k, mmax)
-# -----------------------------------------------------------------------------
-# Search/Grinding helpers
-# -----------------------------------------------------------------------------
-
-def worst_case(p, q, d, max_k=10**11):
- """
- Find minimum trials k such that P(fewer than d successes in k trials) < q.
-
- Used to compute worst-case search time: how many attempts until we're
- confident (with probability 1-q) that we've found d valid encodings.
-
- P(X < d) = Σ_{i=0}^{d-1} C(k,i) * p^i * (1-p)^(k-i), where X ~ Binomial(k, p)
-
- Returns smallest k where P(X < d) < q.
- """
- # Convert Sage types to Python native types
- p_float = float(p)
- q_float = float(q)
- d_int = int(d)
-
- def tail_prob(k):
- if k < d_int:
- return 1.0
- # binom.cdf(d-1, k, p) = P(X <= d-1) = P(X < d)
- return binom.cdf(d_int - 1, int(k), p_float)
- # Find an upper bound for k
- k_low = d_int - 1
- k_high = max(d_int, 1)
- while tail_prob(k_high) >= q_float:
- k_high *= 2
- if k_high > max_k:
- raise RuntimeError("Could not find k below given max_k; try increasing max_k.")
- # Binary search between k_low and k_high
- while k_low + 1 < k_high:
- k_mid = (k_low + k_high) // 2
- if tail_prob(k_mid) < q_float:
- k_high = k_mid
- else:
- k_low = k_mid
- return k_high
-
-# =============================================================================
-# Core Computation Functions
-# =============================================================================
+# Sizes and costs (docs/model.md, SLH-DSA)
def compute_size(h, d, a, k, w, scheme, mmax=0):
- """
- Compute signature size in bytes.
-
- Signature structure:
- - Randomness R (randomness_size bytes)
- - d XMSS layers, each containing:
- - WOTS signature: l hash values
- - Auth path: h' = h/d hash values
- - Counter (for W+C variants): counter_size bytes
- - Few-time signature (FTS):
- - FORS: k leaves + k*a auth path nodes
- - FORS+C: (k-1) leaves + (k-1)*a auth path nodes (last tree omitted)
- - PORS+FP: k leaves + mmax auth set nodes
-
- mmax: for PORS+FP, max size of the authentication set
- """
+ """Signature size in bytes: R, then per layer the authentication path, the
+ WOTS signature and, for WOTS+C, the counter, then the FORS, FORS+C or
+ PORS+FP signature; mmax bounds the PORS+FP authentication set."""
assert h % d == 0, "h must be divisible by d"
has_wc = (scheme != "SPX")
l = compute_wots_l(scheme, w)
h_prime = h // d
- # Each XMSS layer: auth path (h') + WOTS sig (l) + optional counter
hyper_tree_size = d*(h_prime*hashbytes + l*hashbytes + counter_size*int(has_wc))
- # fts = few-time signature (FORS, FORS+C, or PORS+FP)
if scheme == "W+C_P+FP":
- # k revealed leaves + mmax authentication nodes
fts_size = (k + mmax)*hashbytes
elif scheme == "W+C_F+C":
- # k-1 trees (last tree omitted via grinding)
fts_size = (k-1)*hashbytes + (k-1)*a*hashbytes
- else: # Plain FORS (SPX or W+C)
- # k trees, each with 1 leaf + a auth path nodes
+ else: # FORS
fts_size = k*hashbytes + k*a*hashbytes
return hyper_tree_size + fts_size + randomness_size
def compute_mmax(h_prime, l, w, d_wots_expected_search, d, k, a):
- """Compute mmax for PORS+FP such that signing time is close to FORS+C.
-
- mmax is the max size of the authentication set.
- """
+ """The PORS+FP authentication-set bound: from (k-1)*a - ceil(350/n) up,
+ the first for which signing costs less than 1.11 times signing with
+ FORS+C, and at most 19 steps up (docs/model.md, W+C, FORS+C and PORS+FP)."""
Thl = compute_Th(l)
- Thk1 = compute_Th(k-1) # FORS+C has k-1 roots
+ Thk1 = compute_Th(k-1)
merkle_tree_fixed_part_time = (2**h_prime * (l*C_PRF + l*(w-1)*C_Th1 + Thl)) + (2**h_prime-1)*C_Th2
hyper_tree_expected_time = d*merkle_tree_fixed_part_time + d_wots_expected_search*C_Th1c
- # Compute FORS+C time to relate PORS time in compression calls
fors_c_fixed_part_time = (k-1)*(2**a)*C_PRF + (k-1)*(2**a)*C_Th1 + (k-1)*(2**a-1)*C_Th2 + Thk1
fors_c_expected_search_time = 2**a*(C_Hmsg + C_PRFmsg)
fors_c_expected_total_time = fors_c_fixed_part_time + fors_c_expected_search_time
spx_fc_expected_total_time = hyper_tree_expected_time + fors_c_expected_total_time
- # Search for mmax such that PORS time is close to FORS+C time
t, subtree_height, extra_leaves = compute_pors_tree_geometry(k, a)
pors_fixed_part_time = t*(C_PRF) + t*(C_Th1) + ((2**subtree_height-1) + extra_leaves)*(C_Th2)
mmax = (k-1)*a - ceil(350/hashbytes)
@@ -447,22 +267,8 @@ def compute_mmax(h_prime, l, w, d_wots_expected_search, d, k, a):
def compute_keygen_time(h, d, w, scheme):
- """
- Compute keygen time in compression function calls.
-
- Keygen builds the top-level XMSS tree (height h' = h/d) to derive PK.root.
- No WOTS+C counter search occurs during keygen: the WOTS keypairs at the
- leaves of the top tree are used as verification keys, not to sign anything.
-
- One XMSS tree with 2^h' leaves:
- - 2^h' WOTS keypairs, each requiring:
- - l PRF calls to generate secret keys
- - l*(w-1) chain hashes (Th1) to derive the public chain values
- - 1 hash to compress l public values into a leaf (Thl)
- - 2^h' - 1 internal Merkle tree nodes (Th2)
-
- Returns the keygen time as a number of compression function calls.
- """
+ """Compressions for KeyGen: the top tree, 2^h' WOTS public keys and
+ 2^h' - 1 nodes. Nothing is ground, since KeyGen signs nothing."""
assert h % d == 0, "h must be divisible by d"
h_prime = h // d
l = compute_wots_l(scheme, w)
@@ -472,16 +278,8 @@ def compute_keygen_time(h, d, w, scheme):
def compute_signing_time(h, d, a, k, w, swn, scheme):
- """
- Compute signing time in both hash calls and compression function calls.
-
- Returns a dict with keys:
- - 'hashes': expected signing time in hash calls
- - 'compressions': expected signing time in compression calls
- - 'exp_search': expected search attempts
- - 'worst_search': worst-case search attempts
- - 'mmax': mmax value for PORS+FP (max authentication set size)
- """
+ """{'compressions': mean compressions for SigGen, 'mmax': the PORS+FP
+ authentication-set bound, 0 for the other schemes}."""
assert h % d == 0, "h must be divisible by d"
has_wc = (scheme != "SPX")
h_prime = h // d
@@ -490,167 +288,145 @@ def compute_signing_time(h, d, a, k, w, swn, scheme):
Thk = compute_Th(k)
if has_wc:
- # WOTS+C search: find counter such that digest sums to S_{w,n}
- # Success probability p_ν = ν / w^l
- # By geometric distribution, expected attempts until success = 1/p_ν = w^l / ν
- # We need d successful searches (one per hypertree layer)
+ # One counter search per layer, w^l / ν attempts each
nu = compute_nu(l, swn, w)
d_wots_expected_search = d*ceil((w**l) / nu)
- d_wots_worst_search = worst_case(nu/(w**l), F(2)**(-30), d)
else:
d_wots_expected_search = 0
- d_wots_worst_search = 0
-
- # Compute times for both hash calls (h_) and compressions (c_)
- # One XMSS tree layer with 2^h' leaves:
- # - 2^h' WOTS keypairs, each requiring:
- # - l PRF calls to generate secret keys
- # - l*(w-1) chain hashes (Th1)
- # - 1 hash to compress l public key values (Thl)
- # - 2^h' - 1 internal Merkle tree nodes (Th2, hashing 2 children)
- h_merkle = 2**h_prime * (l + l*(w-1) + 1) + (2**h_prime - 1)
- c_merkle = 2**h_prime * (l*C_PRF + l*(w-1)*C_Th1 + Thl) + (2**h_prime - 1)*C_Th2
- # Full hypertree: d layers + WOTS+C counter search overhead
- h_hyper_exp = d*h_merkle + d_wots_expected_search
+ # One tree per layer: 2^h' WOTS public keys and 2^h' - 1 nodes
+ c_merkle = 2**h_prime * (l*C_PRF + l*(w-1)*C_Th1 + Thl) + (2**h_prime - 1)*C_Th2
c_hyper_exp = d*c_merkle + d_wots_expected_search*C_Th1c
-
- # Message hash cost (2 hash calls: Hmsg + PRFmsg)
- h_msg = 2
c_msg = C_Hmsg + C_PRFmsg
mmax = 0
if scheme == "W+C_F+C":
- # FORS+C: k-1 trees (last tree omitted, grinding ensures first leaf is selected)
- # Each tree has 2^a leaves:
- # - 2^a PRF calls to generate leaves
- # - 2^a hashes to compute leaf nodes (Th1)
- # - 2^a - 1 internal nodes (Th2)
- # Plus 1 hash to compress k-1 roots
- # Grinding: search for message hash with last a bits = 0, expected 2^a attempts
- Thk1 = compute_Th(k-1) # k-1 roots to compress
- h_fors_fixed = (k-1)*(2**a) + (k-1)*(2**a) + (k-1)*(2**a - 1) + 1
+ # k - 1 trees; about 2^a message hashes until the last tree's index is 0
+ Thk1 = compute_Th(k-1)
c_fors_fixed = (k-1)*(2**a)*C_PRF + (k-1)*(2**a)*C_Th1 + (k-1)*(2**a - 1)*C_Th2 + Thk1
fors_exp_search = 2**a
- fors_worst_search = worst_case(F(1)/F(2**a), F(2)**(-30), 1)
- h_fors_search = fors_exp_search * h_msg
c_fors_search = fors_exp_search * c_msg
return {
- 'hashes': h_hyper_exp + h_fors_fixed + h_fors_search,
'compressions': c_hyper_exp + c_fors_fixed + c_fors_search,
- 'exp_search': d_wots_expected_search + h_fors_search,
- 'worst_search': d_wots_worst_search + fors_worst_search * h_msg,
'mmax': mmax,
}
elif scheme == "W+C_P+FP":
- # PORS+FP: single tree with t = k * 2^a leaves
- # Tree structure: largest power-of-2 subtree + extra leaves
- # Grinding: search for message hash where Octopus auth set size <= mmax
+ # One tree of t leaves; message hashes until the Octopus authentication
+ # set has at most mmax nodes
t, subtree_height, extra_leaves = compute_pors_tree_geometry(k, a)
- # t PRF calls + t leaf hashes + internal nodes
- h_pors_fixed = 2*t + (2**subtree_height - 1) + extra_leaves
c_pors_fixed = t*C_PRF + t*C_Th1 + ((2**subtree_height - 1) + extra_leaves)*C_Th2
mmax = compute_mmax(h_prime, l, w, d_wots_expected_search, d, k, a)
pors_search_attempts = ceil(exp_work_from_mmax(t, k, mmax))
- h_pors_search = pors_search_attempts * h_msg
c_pors_search = pors_search_attempts * c_msg
- pors_worst_search = worst_case(F(1)/pors_search_attempts, F(2)**(-30), 1)
return {
- 'hashes': h_hyper_exp + h_pors_fixed + h_pors_search,
'compressions': c_hyper_exp + c_pors_fixed + c_pors_search,
- 'exp_search': d_wots_expected_search + h_pors_search,
- 'worst_search': d_wots_worst_search + pors_worst_search * h_msg,
'mmax': mmax,
}
- else:
- # Plain FORS (SPX or W+C): k trees, each with 2^a leaves
- # No grinding, message hash directly selects k leaf indices
- h_fors_fixed = k*(2**a) + k*(2**a) + k*(2**a - 1) + 1
+ else: # FORS
c_fors_fixed = k*(2**a)*C_PRF + k*(2**a)*C_Th1 + k*(2**a - 1)*C_Th2 + Thk
return {
- 'hashes': h_fors_fixed + h_msg + h_hyper_exp,
'compressions': c_fors_fixed + c_msg + c_hyper_exp,
- 'exp_search': d_wots_expected_search,
- 'worst_search': d_wots_worst_search,
'mmax': mmax,
}
def compute_verification_time(h, d, a, k, w, swn, scheme, mmax=0):
- """
- Compute verification time in both hash calls and compression function calls.
-
- Verification involves:
- 1. Hash the message (Hmsg)
- 2. Verify FTS (FORS/FORS+C/PORS+FP)
- 3. For each of d hypertree layers: verify WOTS signature
- 4. Verify h auth path nodes up the hypertree
-
- mmax: for PORS+FP, max size of the authentication set
- """
+ """{'compressions': mean compressions for SigVer, 'compressions_worst':
+ the most for any message}; mmax bounds the PORS+FP authentication set."""
has_wc = (scheme != "SPX")
l = compute_wots_l(scheme, w)
Thl = compute_Th(l)
Thk = compute_Th(k)
- # WOTS verification: complete each chain from signature value to public key.
- # h_wots / c_wots are totals over all d hypertree layers.
+ # WOTS over all d layers; WOTS+C costs the same for every message
if has_wc:
- # WOTS+C: chain positions sum to S_{w,n}, so remaining steps = (w-1)*l - S_{w,n}
- # Plus: verify counter hash (Th1c) and compress public key (Thl)
- # Deterministic: worst case == average case.
- h_wots = d*((w-1)*l - swn + 2)
c_wots = d*(((w-1)*l - swn)*C_Th1 + C_Th1c + Thl)
c_wots_worst = c_wots
- else: # Plain WOTS
- l1 = ceil(hashbytes*8/log(w,2))
- l2 = floor(log(l1*(w-1), 2)/log(w, 2)) + 1
- # Average case: expected chain steps for a uniformly random digest,
- # see compute_wots_tw_mean_steps.
- avg_steps = compute_wots_tw_mean_steps(l1, l2, w, d)
- h_wots = avg_steps + d
- c_wots = avg_steps*C_Th1 + d*Thl
- # Worst case (all message digits 0): see compute_wots_tw_worst_steps.
+ else: # WOTS-TW
+ l1, l2 = wots_lengths(w)
+ c_wots = compute_wots_tw_mean_steps(l1, l2, w, d)*C_Th1 + d*Thl
c_wots_worst = d*(compute_wots_tw_worst_steps(l1, l2, w)*C_Th1 + Thl)
- # FTS verification
if scheme == "W+C_F+C":
- # FORS+C: k-1 leaves to hash, (k-1)*a auth path nodes, 1 root compression
- Thk1 = compute_Th(k-1) # k-1 roots to compress
- h_fts = (k-1) + (k-1)*a + 1
+ Thk1 = compute_Th(k-1)
c_fts = (k-1)*C_Th1 + (k-1)*a*C_Th2 + Thk1
elif scheme == "W+C_P+FP":
- # PORS+FP: k leaves to hash, mmax auth set nodes
- h_fts = k + mmax
c_fts = k*C_Th1 + mmax*C_Th2
- else: # Plain FORS (SPX or W+C)
- # FORS: k leaves, k*a auth path nodes, 1 root compression
- h_fts = k + k*a + 1
+ else: # FORS
c_fts = k*C_Th1 + k*a*C_Th2 + Thk
- # Total: Hmsg + FTS + d*WOTS + h auth path nodes
- # The FTS and auth-path costs are message-independent, so the worst case
- # differs from the average only in the WOTS-TW chain walks.
return {
- 'hashes': 1 + h_fts + h_wots + h,
'compressions': C_Hmsg + c_fts + c_wots + h*C_Th2,
'compressions_worst': C_Hmsg + c_fts + c_wots_worst + h*C_Th2,
}
-# =============================================================================
-# CSV Output
-# =============================================================================
+# Sweep
+
+def security_model(scheme):
+ """The model compute_security uses for scheme."""
+ return "PORS+FP" if scheme == "W+C_P+FP" else "FORS"
+
+
+_security_cache = {}
+
+def cached_security(q_s_log2, h, k, a, model):
+ """compute_security for 2^q_s_log2 signatures, once per (h, k, a): a sweep
+ asks again for every d and w."""
+ key = (int(q_s_log2), int(h), int(k), int(a), model)
+ if key not in _security_cache:
+ _security_cache[key] = compute_security(2**q_s_log2, h, k, a, model, hashbytes)
+ return _security_cache[key]
+
+
+def evaluate(q_s_log2, h, d, k, a, w, swn, scheme):
+ """Security (bits), size (bytes) and costs (compressions) of one parameter
+ set for 2^q_s_log2 signatures. None below the security target, and for
+ PORS+FP where the grinding cost table has no entry for mmax."""
+ security = cached_security(q_s_log2, h, k, a, security_model(scheme))
+ if security < PARAMETERS['security_bits']:
+ return None
+ try:
+ sign = compute_signing_time(h, d, a, k, w, swn, scheme)
+ except ValueError:
+ return None
+ mmax = sign['mmax']
+ verify = compute_verification_time(h, d, a, k, w, swn, scheme, mmax)
+ return {
+ 'h': h, 'd': d, 'k': k, 'a': a, 'w': w, 'swn': swn, 'mmax': mmax,
+ 'l': compute_wots_l(scheme, w),
+ 'security': security,
+ 'size': compute_size(h, d, a, k, w, scheme, mmax),
+ 'keygen': compute_keygen_time(h, d, w, scheme),
+ 'sign': sign['compressions'],
+ 'verify': verify['compressions'],
+ 'verify_worst': verify['compressions_worst'],
+ }
+
+
+def sweep(scheme, w_swn_pairs, h_values, d_values, k_values, a_values, q_s_log2):
+ """evaluate() for every parameter set of a grid, (w, swn) outermost and a
+ innermost, skipping the d that do not divide h. Yields
+ ((w, swn, h, d, k, a), result)."""
+ for w, swn in w_swn_pairs:
+ for h in h_values:
+ for d in d_values:
+ if h % d != 0:
+ continue
+ for k in k_values:
+ for a in a_values:
+ yield ((w, swn, h, d, k, a),
+ evaluate(q_s_log2, h, d, k, a, w, swn, scheme))
+
+# The report's parameter sets: CSV and tables
def compute_all_results():
- """Compute results for all parameter sets. Returns list of dicts."""
+ """A dict of sizes and costs for each of PARAMETER_SETS."""
results = []
for scheme, q_s, h, d, a, k, w, _, swn, bold in PARAMETER_SETS:
- # Compute l dynamically based on scheme
l = compute_wots_l(scheme, w)
- # Verify security level is at least 128 bits
- scheme_type = "PORS+FP" if scheme == "W+C_P+FP" else "FORS"
- security = compute_security(2**q_s, h, k, a, scheme_type)
- assert security >= 128, f"Security {security:.1f} < 128 for {scheme} q_s=2^{q_s} h={h} a={a} k={k}"
+ security = compute_security(2**q_s, h, k, a, security_model(scheme), hashbytes)
+ target = PARAMETERS['security_bits']
+ assert security >= target, f"Security {security:.1f} < {target} for {scheme} q_s=2^{q_s} h={h} a={a} k={k}"
sign = compute_signing_time(h, d, a, k, w, swn, scheme)
verify = compute_verification_time(h, d, a, k, w, swn, scheme, sign['mmax'])
@@ -671,11 +447,7 @@ def compute_all_results():
'swn': swn,
'size': size,
'keygen_compressions': keygen_compressions,
- 'sign_hashes': sign['hashes'],
'sign_compressions': sign['compressions'],
- 'exp_search': sign['exp_search'],
- 'worst_search': sign['worst_search'],
- 'verify_hashes': verify['hashes'],
'verify_compressions': verify['compressions'],
'verify_compressions_worst': verify['compressions_worst'],
'compressions_per_byte': compressions_per_byte,
@@ -686,17 +458,16 @@ def compute_all_results():
def generate_csv():
- """Generate CSV output for all parameter sets."""
- print("scheme,q_s,h,d,a,k,w,l,paramsum,size,keygen_compressions,sign_hashes,sign_compressions,exp_search,worst_search,verify_hashes,verify_compressions,verify_compressions_worst,compressions_per_byte,compressions_per_byte_worst,bold")
+ print("scheme,q_s,h,d,a,k,w,l,paramsum,size,keygen_compressions,sign_compressions,verify_compressions,verify_compressions_worst,compressions_per_byte,compressions_per_byte_worst,bold")
for r in compute_all_results():
bold_str = "True" if r['bold'] else "False"
- print(f"{r['scheme']},2^{r['q_s']},{r['h']},{r['d']},{r['a']},{r['k']},{r['w']},{r['l']},{r['swn']},{r['size']},{r['keygen_compressions']},{r['sign_hashes']},{r['sign_compressions']},{r['exp_search']},{r['worst_search']},{r['verify_hashes']},{r['verify_compressions']},{r['verify_compressions_worst']},{r['compressions_per_byte']:.2f},{r['compressions_per_byte_worst']:.2f},{bold_str}")
+ print(f"{r['scheme']},2^{r['q_s']},{r['h']},{r['d']},{r['a']},{r['k']},{r['w']},{r['l']},{r['swn']},{r['size']},{r['keygen_compressions']},{r['sign_compressions']},{r['verify_compressions']},{r['verify_compressions_worst']},{r['compressions_per_byte']:.2f},{r['compressions_per_byte_worst']:.2f},{bold_str}")
def format_num(n):
- """Format large numbers with K/M suffixes for readability."""
- n = float(n) # Convert Sage types to Python float
+ """n with a K or M suffix."""
+ n = float(n)
if n >= 1_000_000:
return f"{n/1_000_000:.1f}M"
elif n >= 1_000:
@@ -706,11 +477,7 @@ def format_num(n):
def generate_table(q_s_filter=None):
- """Generate pretty ASCII table output for parameter sets.
-
- Args:
- q_s_filter: If specified, only show rows with this q_s value (e.g., 40 for 2^40 sigs)
- """
+ """Prints the tables, only for 2^q_s_filter signatures if given."""
results = compute_all_results()
if q_s_filter is not None:
@@ -771,16 +538,11 @@ def generate_table(q_s_filter=None):
print("Legend: Size=bytes, Keygen(C)/Sign(C)/Verify(C)=compression calls, C/byte=verify compressions per signature byte")
-# =============================================================================
-# Main
-# =============================================================================
-
def compute_single(scheme, q_s_log2, h, d, a, k, w, swn):
- """Compute and display results for a single parameter set."""
+ """Prints the size, costs and security of one parameter set."""
q_s = 2**q_s_log2
l = compute_wots_l(scheme, w)
- scheme_type = "PORS+FP" if scheme == "W+C_P+FP" else "FORS"
- security = compute_security(q_s, h, k, a, scheme_type)
+ security = compute_security(q_s, h, k, a, security_model(scheme), hashbytes)
sign = compute_signing_time(h, d, a, k, w, swn, scheme)
mmax = sign['mmax']
verify = compute_verification_time(h, d, a, k, w, swn, scheme, mmax)
@@ -803,9 +565,8 @@ def compute_single(scheme, q_s_log2, h, d, a, k, w, swn):
print("C/byte: " + "{:.2f}".format(c_per_byte) + " (worst: " + "{:.2f}".format(float(verify['compressions_worst'])/float(size)) + ")")
-# The env flag lets other scripts (stateful.sage, export_site_data.sage) load
-# this file for its functions without triggering the CLI output (sage's
-# load() executes in the caller's namespace, where __name__ is '__main__').
+# Scripts that load this file for its functions set COSTS_SAGE_NO_MAIN: load()
+# runs it in their namespace, where __name__ is '__main__'.
if __name__ == "__main__" and not os.environ.get("COSTS_SAGE_NO_MAIN"):
import sys
diff --git a/docs/model.md b/docs/model.md
new file mode 100644
index 0000000..5c59d4d
--- /dev/null
+++ b/docs/model.md
@@ -0,0 +1,220 @@
+# Cost and security model
+
+Sizes are in bytes and costs in SHA-256 compressions (C). The hash output is n = 16 bytes.
+The pages compute the model in `site/js/model.js`, the Sage scripts in `costs.sage`,
+`stateful.sage` and `security.sage`, and `tests/model.py` ports it to check both. The search grids
+and the fixed parameters are in `parameters.json`.
+
+## Hash calls
+
+Hashing follows the FIPS 205 SHA-2 instantiation as the SHRINCS reference implementation runs it:
+the block that holds PK.seed is compressed once per key and its state reused, so each call pays
+only for the rest of its input. A tweakable hash over m values of n bytes takes
+
+ Th(m) = ceil((176 + 128·m + 65) / 512)
+
+compressions: a 22-byte compressed address, the values, and the SHA-256 padding (one bit and a
+64-bit length). A chain step, a chain step with the WOTS+C counter, a tree node and a PRF call each
+fit in one block, so `C_Th1 = C_Th1c = C_Th2 = C_PRF = 1`. Compressing the l chain ends of a WOTS
+key into a leaf takes Th(l), and the k FORS roots Th(k).
+
+For a 32-byte message and an empty context:
+
+- H_msg is two nested SHA-256 calls of 2 compressions each. Its input starts with the randomizer R,
+ so no state can be reused: `C_Hmsg = 4`.
+- PRF_msg is HMAC-SHA-256, two SHA-256 calls of 3 and 2 compressions. Their first blocks, the fixed
+ key pads, are reused like the PK.seed block: `C_PRFmsg = 3`.
+
+## WOTS
+
+A WOTS key has l1 = ceil(8n / log2 w) message chains. WOTS-TW, the variant in SLH-DSA, adds
+l2 = floor(log_w(l1·(w − 1))) + 1 checksum chains (FIPS 205); WOTS+C has none and grinds a counter
+instead. With l chains, a public key costs
+
+ pk = l·PRF + l·(w − 1)·Th1 + Th(l)
+
+### WOTS-TW verification
+
+A message digit m costs the verifier w − 1 − m chain steps, so the message chains average
+(w − 1)/2 steps each. The checksum chains do not: the checksum C = l1·(w − 1) − S, with S the sum
+of the message digits, is concentrated around l1·(w − 1)/2, so its leading base-w digit is almost
+always 0 or 1 and the verifier walks most of that chain. With digit_sum(x) = x − (w − 1)·Σ_{j≥1}
+floor(x / w^j), and C distributed like S (map every digit m to w − 1 − m),
+
+ E[steps] = l1·(w − 1)/2 + l2·(w − 1) − E[digit_sum(C)]
+ = (w − 1)·(l2 + Σ_{j=1}^{l2−1} E[floor(S / w^j)])
+
+where E[floor(S / w^j)] = Σ_{m≥1} P(S ≥ m·w^j), and the number of digit strings with sum at most t
+is Σ_i (−1)^i·C(l1, i)·C(t − i·w + l1, l1). The code evaluates this with exact integers and rounds
+the total for all d layers of a signature once, to the nearest integer. For w = 16 the mean is
+267.12 steps rather than the 262 of (w − 1)·l/2; for w = 256 it is 2415.03 rather than 2295.
+
+The verifier walks the most when every message digit is 0. The checksum is then l1·(w − 1), so
+
+ worst = l1·(w − 1) + l2·(w − 1) − digit_sum(l1·(w − 1))
+
+Any other message saves at least as many steps on the message chains as it can cost on the
+checksum chains.
+
+The signer walks the part of each chain the verifier does not: l PRF calls and
+l·(w − 1) − E[steps] chain steps.
+
+### WOTS+C
+
+The signer grinds a counter until the l = l1 digits of the digest sum to S_wn, which makes
+verification cost the same for every message. Of the w^l digit strings,
+
+ ν = Σ_{j=0}^{l} (−1)^j·C(l, j)·C(S_wn + l − j·w − 1, l − 1)
+
+have that sum, so signing takes ceil(w^l / ν) attempts of `C_Th1c` on average, then l PRF calls and
+S_wn chain steps. Verification takes l·(w − 1) − S_wn chain steps, one `C_Th1c` and Th(l).
+
+`parameters.json` sets S_wn = floor(l1·(w − 1)/2), the middle of the digit-sum distribution, where
+ν is largest: 240 for w = 16 and 2040 for w = 256. For w = 128 the product is odd, and
+ν(1206) = ν(1207).
+
+SHRINCS stores the counter in 2 bytes: 2^16 counters miss the target sum with probability below
+2^−126 for every w ≤ 256. w = 65536 needs about 137,000 attempts on average and keeps a 4-byte
+counter.
+
+## SLH-DSA
+
+For (h, d, k, a, w), the hypertree has d layers of trees of height h′ = h/d.
+
+ size = n·(1 + k·(a + 1) + h + d·l)
+ tree = 2^h′·pk + (2^h′ − 1)·Th2
+ KeyGen = tree
+ SigGen = FORS + H_msg + PRF_msg + d·tree
+ FORS = k·2^a·(PRF + Th1) + k·(2^a − 1)·Th2 + Th(k)
+ SigVer = H_msg + k·Th1 + k·a·Th2 + Th(k) + d WOTS-TW verifications + h·Th2
+
+The size counts the randomizer, k FORS leaves with a authentication nodes each, the h nodes of the
+hypertree authentication path and d WOTS signatures: 7,856 B for SLH-DSA-128s, (63, 7, 14, 12, 16).
+KeyGen builds the top tree; a signature rebuilds one tree per layer. SigVer takes the mean of
+[WOTS-TW verification](#wots-tw-verification), SigVer worst its worst case in every layer. A SHRINCS
+stateless signature adds a one-byte indicator.
+
+### W+C, FORS+C and PORS+FP
+
+W+C signs every layer with WOTS+C instead of WOTS-TW: a layer holds l = l1 chain values and a
+4-byte counter, signing adds d counter searches, and verification costs the same for every
+message. W+C_F+C also replaces FORS with FORS+C, and W+C_P+FP with PORS+FP. Both grind the message
+hash, each attempt costing H_msg + PRF_msg; the last attempt is the signature's message hash.
+
+- FORS+C grinds until the index in the last tree is 0, about 2^a attempts, and leaves that tree
+ out: k − 1 trees in the signature, in signing and in verification.
+- PORS+FP builds one tree of t = k·2^a leaves, t·(PRF + Th1) and t − 1 node hashes, opens k of
+ its leaves and grinds until their Octopus authentication set has at most mmax nodes;
+ `octopus_pmf.py` gives the expected attempts. mmax starts at (k − 1)·a − ceil(350/n) and grows by
+ one, at most 19 times, until signing costs less than 1.11 times signing with FORS+C.
+
+For FORS, FORS+C and PORS+FP in turn,
+
+ size = n·(1 + h + d·l) + 4·d + n·k·(a + 1), n·(k − 1)·(a + 1) or n·(k + mmax)
+ SigVer = H_msg + V + d·(((w − 1)·l − S_wn)·Th1 + Th1c + Th(l)) + h·Th2
+ V = k·Th1 + k·a·Th2 + Th(k), (k − 1)·Th1 + (k − 1)·a·Th2 + Th(k − 1) or k·Th1 + mmax·Th2
+
+## FORS security
+
+The stateless schemes are evaluated at q_s = 2^40 signatures. Each signature uses one of 2^h FORS
+instances, so the number r of signatures that land on a given instance follows Binomial(q_s, 2^−h).
+After r signatures each of the k trees of t = 2^a leaves has revealed up to r leaves, and a forger
+needs the leaf it must open revealed in every tree:
+
+ P(forgery | r) = (1 − (1 − 1/t)^r)^k
+ P(forgery) = Σ_r C(q_s, r)·2^(−h·r)·(1 − 2^−h)^(q_s − r)·P(forgery | r)
+ security = −log2(max(2^(−8n), P(forgery)))
+
+so 128 bits at most. Unlike the parameter script of the SPHINCS+ specification
+(`original-sphincs-parameter-search.sage`), this uses the exact forgery factor instead of the bound
+(r/t)^k, and the maximum of the two attacks instead of their sum: a query cannot serve both, since
+their tweaks differ.
+
+PORS+FP signs with k distinct leaves of one tree of t = k·2^a leaves, so r signatures reveal at most
+min(r·k, t) of them:
+
+ P(forgery | r) = C(min(r·k, t), k) / C(t, k)
+
+`security.sage` sums from r = 1 with 100-bit floats and stops past the mean once a term falls below
+2^−1250. The page sums in log2 to avoid underflow. Only r within 30·sqrt(λ + 1) + 30 of the mean
+λ = q_s / 2^h is summed: further out the binomial weight is below e^−450 of its peak. That keeps
+the work at O(sqrt(λ)); when even that exceeds 2^16 terms, the sum takes every s-th term and
+weights it by s. For the parameter grid λ ≤ 1, and every term from r = 1 is summed.
+
+## Stateful schemes
+
+Every stateful scheme uses the SHRINCS stateful signature layout, so sizes compare like for like:
+
+ indicator (1 B) | R (16 B) | leaf index | per layer: [counter] + l·n chain values + h′·n auth path
+
+The leaf index takes max(1, ceil(h / 8)) bytes in XMSS and XMSS-MT. In UXMSS it takes
+ceil(min(depth, 64) / 8) bytes for a leaf at that depth: SHRINCS sizes the field by the depth of
+the signing leaf.
+
+### XMSS and XMSS-MT
+
+d layers of trees of height h′ = h/d give 2^h signatures; XMSS is d = 1.
+
+ KeyGen = 2^h′·pk + (2^h′ − 1)·Th2 the top tree
+ SigVer = H_msg + d·(WOTS verification + h′·Th2)
+
+A cached signer keeps a BDS traversal state for every layer (Buchmann, Dahmen and Schneider,
+"Merkle Tree Traversal Revisited", PQCrypto 2008) and the WOTS signatures of the upper layers, so
+it computes one WOTS signature and one BDS update per layer. K is 2 for even h′ and 3 for odd, the
+smallest K ≥ 2 with h′ − K even. An update takes at most (h′ − K)/2 + 1 leaf computations and
+floor(3·(h′ − K − 1)/2) + 1 node hashes; the long-run average is about one leaf computation lower.
+
+ SigGen, cached = H_msg + PRF_msg + WOTS signature + d·(leaves·pk + hashes·Th2)
+ State = d·(h′ + floor(h′/2) + 2^K − K − 1 + (h′ − K) + (h′ − K − 1))·n + (d − 1)·l·n
+
+The state counts what the SHRINCS signer caches hold: the authentication path, the keep nodes,
+the retained nodes, one completed node per treehash instance and their stack of partial results,
+plus the upper-layer WOTS signatures.
+
+A cold signer keeps no state and rebuilds the d trees for every signature. In the top tree it needs
+only the authentication path, so it skips the signing leaf and the h′ nodes on its path to the root:
+
+ SigGen, cold = H_msg + PRF_msg + d·(tree + WOTS signature) − pk − h′·Th2
+
+### UXMSS
+
+The SHRINCS unbalanced tree has a spine of height hsf with hsf + 1 leaves. The q-th signature
+(q = 1 is the first) carries min(q, hsf) authentication nodes, so each signature is one node longer
+than the one before. hsf is the largest value for which the last signature stays below 5,712 B,
+the size of the report's candidate 2, (45, 5, 8, 16, 16), and at most 255: SHRINCS encodes the tree
+height in one byte.
+
+ size(q) = 1 + 16 + index + counter + l·n + min(q, hsf)·n
+ KeyGen = (hsf + 1)·pk + hsf·Th2
+ SigVer = H_msg + WOTS verification + min(q, hsf)·Th2
+ State = (hsf + 1)·n
+
+A cached signer keeps the hsf + 1 leaf public keys. Every authentication node is one of them,
+except the sibling of the signing leaf on the spine, which it recombines from the cached leaves
+below it with hsf − q node hashes (none for the last two signatures):
+
+ SigGen, cached = H_msg + PRF_msg + WOTS signature + (hsf − min(q, hsf))·Th2
+
+A cold signer first rebuilds the hsf other leaves. Its first signature, the most expensive, takes
+SigGen, cached for q = 1, plus hsf·pk.
+
+## Distance
+
+The explorer and the stateful page rank the candidates within the bounds by the report's
+metric-normalised weighted distance (Section 3, Metric-Normalized Weighted Distance). For each
+metric i (size, KeyGen, SigGen, SigVer and ρ), X_i is the candidate's ratio to SLH-DSA-128s,
+rescaled over the candidates within the bounds, and W_i is its weight:
+
+ X̂_i = (X_i − min_i) / (max_i − min_i) max_i = min_i + 1 if all X_i are equal
+ d = sqrt(Σ_i p_i·X̂_i²), p_i = W_i / Σ_j W_j
+
+Because every X̂_i spans [0, 1], a weight sets its metric's share of the distance however loose the
+bounds are. The explorer's worst-case mode uses the worst-case SigVer and ρ; on the stateful page
+SigGen follows the signer mode.
+
+## Schnorr reference
+
+ρ ÷ Schnorr divides ρ by the compressions per byte of a BIP340 Schnorr verification with
+libsecp256k1 for a 64-byte signature, read from `results/kappa.json` of
+[bip340-vs-sha256](https://github.com/mjthatch/bip340-vs-sha256) when a page loads. If that fails,
+the pages use 1.98 C/B.
diff --git a/document/content/3-search.tex b/document/content/3-search.tex
index dad1173..e4d3119 100644
--- a/document/content/3-search.tex
+++ b/document/content/3-search.tex
@@ -16,7 +16,7 @@ \subsection{Initial Search Conditions}
\subsection{Hash-Cost Convention} \label{sec:hash-convention}
All compression counts in this report use the \emph{midstate-cached} convention. The SHA-2 instantiation of FIPS~205 zero-pads $\mathsf{PK.seed}$ to a full 64-byte block precisely so that implementations can precompute the SHA-256 compression state after that block once per keypair and reuse it for every tweakable-hash call. Under this convention, each call therefore costs only its \emph{marginal} compressions over the compressed address $\mathsf{ADRS}_c$ (22 bytes) plus the payload and padding: a WOTS chain step or FORS leaf ($22 + 16$ bytes) costs one compression, and — importantly — a two-child Merkle node hash ($22 + 32$ bytes $= 497$ bits) also costs exactly \emph{one} compression. The two message functions are counted as the SHRINCS reference implementation\footnote{\url{https://github.com/SHRINCS/shrincs-bip}} executes them for a 32-byte message. The midstate trick does not apply to the message hash $H_{\mathsf{msg}}$, whose input begins with the randomizer $R$: it consists of two nested SHA-256 calls and costs four compressions. $\mathsf{PRF}_{\mathsf{msg}}$ is HMAC-SHA-256 and costs three compressions once its two key-pad blocks are cached in the same way as the $\mathsf{PK.seed}$ block.
-Under this convention the SLH-DSA-128s baseline costs are: KeyGen $292{,}351$, SigGen $2{,}218{,}486$, and SigVer $2{,}193$ (average) / $3{,}893$ (worst-case) compressions. The original convention of the accompanying scripts remains available via the \texttt{HASH\_CONVENTION=uncached} environment flag: every call pays for its full input (a node hash then costs two compressions), the two message functions are single two-block hashes, and WOTS chain walks are averaged as $(w-1)/2$ steps per chain, giving the baseline SigVer $2{,}387$.
+Under this convention the SLH-DSA-128s baseline costs are: KeyGen $292{,}351$, SigGen $2{,}218{,}486$, and SigVer $2{,}193$ (average) / $3{,}893$ (worst-case) compressions. The original convention of the accompanying scripts is kept at the repository tag \texttt{uncached-convention}: every call pays for its full input (a node hash then costs two compressions), the two message functions are single two-block hashes, and WOTS chain walks are averaged as $(w-1)/2$ steps per chain, giving the baseline SigVer $2{,}387$.
\subsection{Toolset}
The methodology described here uses a set of SageMath scripts available at \url{https://github.com/BlockstreamResearch/SPHINCS-Parameters}.
diff --git a/export_site_data.sage b/export_site_data.sage
index b3a5b18..42a1548 100644
--- a/export_site_data.sage
+++ b/export_site_data.sage
@@ -1,28 +1,13 @@
#!/usr/bin/env sage
"""
-Export the canonical dataset consumed by both interactive pages: site/data.json.
-
-Single source of truth:
- - SPX pool : core rows read from utils/all_size_capped_candidates.csv
- (regenerate first if needed:
- sage slhdsa-2to40.sage --max-size 7856 &&
- mv candidates.csv utils/all_size_capped_candidates.csv),
- plus optional-w rows (W_OPTIONAL)
- - variant pools : W+C, W+C_F+C, W+C_P+FP swept over the same canonical
- grid with (w, swn) in {(16, 240), (256, 2040)}, using
- costs.sage / security.sage directly
- - stateful grid, UXMSS variants, SLH baseline
- : computed by loading stateful.sage
-
-The pages load data.json at startup and fall back to in-browser computation
-(SPX only) if the file is unavailable; they also self-check the loaded
-numbers against their local formulas and log any divergence to the console.
-
-Usage:
- sage export_site_data.sage # all schemes (a few minutes)
- sage export_site_data.sage --schemes SPX,W+C # subset (pages adapt)
-Verify:
- python3 tests/test_regression.py
+Writes site/data.json for the explorer and the stateful page: the SPX pool from
+utils/all_size_capped_candidates.csv (make sweeps) with the optional w added,
+the W+C, W+C_F+C and W+C_P+FP pools from the sweep of costs.sage, and the
+XMSS-MT and UXMSS rows of stateful.sage. The pages check samples of it against
+their own model.
+
+ sage export_site_data.sage (all schemes, a few minutes)
+ sage export_site_data.sage --schemes SPX,W+C (a subset; the pages adapt)
"""
import os
import sys
@@ -31,25 +16,21 @@ import json
import subprocess
import datetime
+_DOC = __doc__ # loading stateful.sage replaces __doc__
_dir = os.path.dirname(os.path.abspath(__file__))
-os.environ['STATEFUL_SAGE_NO_MAIN'] = '1'
-os.environ['COSTS_SAGE_NO_MAIN'] = '1'
-
-_saved_argv = sys.argv
-sys.argv = ['export_site_data.sage']
-load(os.path.join(_dir, "stateful.sage")) # also loads costs.sage + security.sage
-sys.argv = _saved_argv
+os.environ['STATEFUL_SAGE_NO_MAIN'] = '1' # its functions, without its CLI
+load(os.path.join(_dir, "stateful.sage")) # and through it costs.sage and security.sage
ALL_SCHEMES = ['SPX', 'W+C', 'W+C_F+C', 'W+C_P+FP']
schemes = ALL_SCHEMES
-_args = _saved_argv[1:]
+_args = sys.argv[1:]
i = 0
while i < len(_args):
if _args[i] == '--schemes' and i + 1 < len(_args):
schemes = [s for s in _args[i + 1].split(',') if s]
i += 2
elif _args[i] == '--help':
- print(__doc__)
+ print(_DOC)
sys.exit(0)
else:
print("Unknown argument: {}".format(_args[i]), file=sys.stderr)
@@ -57,27 +38,20 @@ while i < len(_args):
for s in schemes:
assert s in ALL_SCHEMES, "unknown scheme: %s" % s
-# Canonical stateless grid (report Sec. 2; must match slhdsa-2to40.sage
-# defaults and the explorer's fallback sweep).
-GRID = {'h': [40, 50], 'd': [2, 25], 'k': [6, 24], 'a': [8, 20],
- 'w': [2, 4, 16, 256], 'q_s_log2': 40, 'max_size': 7856}
-# Optional Winternitz extras for the SPX pool
-W_OPTIONAL = [32, 64, 128]
-WC_PAIRS = [(16, 240), (256, 2040)] # (w, swn) for WOTS+C schemes
+GRID = PARAMETERS['stateless']['grid']
+W_OPTIONAL = PARAMETERS['stateless']['w_optional'] # SPX only
+WC_PAIRS = [(w, SWN[w]) for w in PARAMETERS['stateless']['wc_w']]
POOL_FIELDS = ['h', 'd', 'k', 'a', 'w', 'swn', 'mmax',
'size', 'kg', 'sg', 'sv', 'sv_worst']
-def _hd_pairs():
- return [(h, d)
- for h in range(GRID['h'][0], GRID['h'][1] + 1)
- for d in range(GRID['d'][0], GRID['d'][1] + 1)
- if h % d == 0]
+H_VALUES = range(GRID['h'][0], GRID['h'][1] + 1)
+D_VALUES = range(GRID['d'][0], GRID['d'][1] + 1)
+K_VALUES = range(GRID['k'][0], GRID['k'][1] + 1)
+A_VALUES = range(GRID['a'][0], GRID['a'][1] + 1)
-# ---------------------------------------------------------------------------
-# SPX pool: straight from the canonical sweep CSV
-# ---------------------------------------------------------------------------
+# SPX pool: the rows of the sweep CSV
csv_path = os.path.join(_dir, 'utils', 'all_size_capped_candidates.csv')
spx_rows, baseline = [], None
with open(csv_path) as f:
@@ -87,99 +61,39 @@ with open(csv_path) as f:
int(r['size']), int(float(r['keygen_C'])), int(float(r['sign_C'])),
int(float(r['verify_C'])), int(float(r['verify_worst_C']))]
if r['label'] == 'STANDARD':
- baseline = {'params': {'h': 63, 'd': 7, 'k': 14, 'a': 12, 'w': 16},
+ baseline = {'params': {x: PARAMETERS['baseline'][x] for x in ('h', 'd', 'k', 'a', 'w')},
'size': rec[7], 'kg': rec[8], 'sg': rec[9],
'sv': rec[10], 'sv_worst': rec[11]}
else:
spx_rows.append(rec)
assert baseline is not None, "STANDARD row missing from %s" % csv_path
-# ---------------------------------------------------------------------------
-# Variant pools: computed with costs.sage functions (security cached)
-# ---------------------------------------------------------------------------
-_sec_cache = {}
-
-
-def _security_ok(model, h, k, a):
- key = (model, int(h), int(k), int(a))
- if key not in _sec_cache:
- _sec_cache[key] = float(compute_security(
- 2**GRID['q_s_log2'], h, k, a, model, hashbytes=16)) >= 128.0
- return _sec_cache[key]
-
-
-def sweep_spx_extra(w_values):
- """SPX rows for the optional w values (same initial bounds as the CSV)."""
- rows = []
- pairs = _hd_pairs()
- for w in w_values:
- for pi, (h, d) in enumerate(pairs):
- print(" SPX extras: w={} {}/{}".format(w, pi + 1, len(pairs)),
- file=sys.stderr, end='\r')
- for k in range(GRID['k'][0], GRID['k'][1] + 1):
- for a in range(GRID['a'][0], GRID['a'][1] + 1):
- if not _security_ok("FORS", h, k, a):
- continue
- try:
- sign = compute_signing_time(h, d, a, k, w, 0, 'SPX')
- mmax = sign['mmax']
- verify = compute_verification_time(h, d, a, k, w, 0, 'SPX', mmax)
- size = compute_size(h, d, a, k, w, 'SPX', mmax)
- except Exception:
- continue
- if size >= GRID['max_size']:
- continue
- kg = compute_keygen_time(h, d, w, 'SPX')
- rows.append([int(h), int(d), int(k), int(a), int(w),
- 0, 0, int(size), int(kg),
- int(sign['compressions']),
- int(verify['compressions']),
- int(verify['compressions_worst'])])
- print(file=sys.stderr)
- return rows
-
-
-def sweep_scheme(scheme):
- model = "PORS+FP" if scheme == "W+C_P+FP" else "FORS"
- rows, considered = [], 0
- pairs = _hd_pairs()
- for w, swn in WC_PAIRS:
- for pi, (h, d) in enumerate(pairs):
- print(" {}: (w={}, swn={}) {}/{}".format(scheme, w, swn, pi + 1, len(pairs)),
- file=sys.stderr, end='\r')
- for k in range(GRID['k'][0], GRID['k'][1] + 1):
- for a in range(GRID['a'][0], GRID['a'][1] + 1):
- considered += 1
- if not _security_ok(model, h, k, a):
- continue
- try:
- sign = compute_signing_time(h, d, a, k, w, swn, scheme)
- mmax = sign['mmax']
- verify = compute_verification_time(h, d, a, k, w, swn, scheme, mmax)
- size = compute_size(h, d, a, k, w, scheme, mmax)
- except Exception:
- continue
- if size >= GRID['max_size']:
- continue
- kg = compute_keygen_time(h, d, w, scheme)
- rows.append([int(h), int(d), int(k), int(a), int(w),
- int(swn), int(mmax), int(size), int(kg),
- int(sign['compressions']),
- int(verify['compressions']),
- int(verify['compressions_worst'])])
+# The optional w of SPX and the variant pools: the sweep of costs.sage
+def pool_rows(scheme, w_swn_pairs):
+ """Pool rows (POOL_FIELDS) of the grid below max_size, and the number of
+ parameter sets considered."""
+ rows, considered, at = [], 0, None
+ for (w, swn, h, d, k, a), r in sweep(scheme, w_swn_pairs, H_VALUES, D_VALUES,
+ K_VALUES, A_VALUES, GRID['q_s_log2']):
+ considered += 1
+ if (w, h, d) != at:
+ at = (w, h, d)
+ print(" {}: w={}, h={}, d={}".format(scheme, w, h, d), file=sys.stderr, end='\r')
+ if r is None or r['size'] >= GRID['max_size']:
+ continue
+ rows.append([int(r[f]) for f in ('h', 'd', 'k', 'a', 'w', 'swn', 'mmax', 'size',
+ 'keygen', 'sign', 'verify', 'verify_worst')])
print(file=sys.stderr)
return rows, considered
pools = {}
if 'SPX' in schemes:
- pairs = _hd_pairs()
# 'considered' counts the core grid only; the page derives the count for
# whichever w set is active from meta.grid.
- spx_considered = (len(pairs) * len(GRID['w'])
- * (GRID['k'][1] - GRID['k'][0] + 1)
- * (GRID['a'][1] - GRID['a'][0] + 1))
- spx_extra = sweep_spx_extra(W_OPTIONAL)
+ hd_pairs = sum(1 for h in H_VALUES for d in D_VALUES if h % d == 0)
+ spx_considered = hd_pairs * len(GRID['w']) * len(K_VALUES) * len(A_VALUES)
+ spx_extra, _ = pool_rows('SPX', [(w, 0) for w in W_OPTIONAL])
all_spx = spx_rows + spx_extra
pools['SPX'] = {'fields': POOL_FIELDS, 'considered': spx_considered,
'count': len(all_spx), 'rows': all_spx}
@@ -188,25 +102,21 @@ if 'SPX' in schemes:
for scheme in schemes:
if scheme == 'SPX':
continue
- rows, considered = sweep_scheme(scheme)
+ rows, considered = pool_rows(scheme, WC_PAIRS)
pools[scheme] = {'fields': POOL_FIELDS, 'considered': considered,
'count': len(rows), 'rows': rows}
print("{}: {} rows of {} considered".format(scheme, len(rows), considered),
file=sys.stderr)
-# ---------------------------------------------------------------------------
-# Stateful: XMSS/XMSS-MT grid + UXMSS variants (same functions as the CLI)
-# ---------------------------------------------------------------------------
-_SWN = {16: 240, 32: 403, 64: 693, 128: 1206, 256: 2040, 65536: 262140}
-STATEFUL_W = (16, 32, 64, 128, 256, 65536)
-UXMSS_REF = 5712 # Report Candidate 2 — reference bound for max stateful size
+# Stateful: XMSS-MT (XMSS is d = 1) and UXMSS, from stateful.sage
+STATEFUL = PARAMETERS['stateful']
xmssmt_rows = []
for ots_label, ots_type in (('TW', OTS_TW), ('WC', OTS_WC)):
- for w in STATEFUL_W:
- swn = _SWN[w] if ots_type == OTS_WC else 0
- for h in range(10, 41):
- for d in range(1, 11):
+ for w in STATEFUL['w']:
+ swn = SWN[w] if ots_type == OTS_WC else 0
+ for h in range(STATEFUL['h'][0], STATEFUL['h'][1] + 1):
+ for d in range(STATEFUL['d'][0], STATEFUL['d'][1] + 1):
if h % d != 0:
continue
xmssmt_rows.append([
@@ -222,9 +132,9 @@ for ots_label, ots_type in (('TW', OTS_TW), ('WC', OTS_WC)):
uxmss_rows = []
for ots_label, ots_type in (('TW', OTS_TW), ('WC', OTS_WC)):
- for w in STATEFUL_W:
- swn = _SWN[w] if ots_type == OTS_WC else 0
- hsf = find_max_hsf(w, ots_type, UXMSS_REF)
+ for w in STATEFUL['w']:
+ swn = SWN[w] if ots_type == OTS_WC else 0
+ hsf = find_max_hsf(w, ots_type, STATEFUL['uxmss_ref_size'])
uxmss_rows.append([
ots_label, w, int(hsf),
int(uxmss_size(1, hsf, w, ots_type)),
@@ -258,16 +168,17 @@ data = {
'w_optional': W_OPTIONAL,
'wc_pairs': WC_PAIRS,
'schemes': [s for s in ALL_SCHEMES if s in pools],
- 'uxmss_ref_size': UXMSS_REF,
+ 'uxmss_ref_size': STATEFUL['uxmss_ref_size'],
'hsf_max': int(HSF_MAX),
- 'cost_convention': HASH_CONVENTION,
+ 'cost_convention': 'cached',
},
'baseline': baseline,
'pools': pools,
'stateful': {
'slh': {'size': baseline['size'], 'kg': baseline['kg'],
'sg': baseline['sg'], 'sv': baseline['sv'],
- 'sv_worst': baseline['sv_worst'], 'qs_log2': 64},
+ 'sv_worst': baseline['sv_worst'],
+ 'qs_log2': PARAMETERS['baseline']['q_s_log2']},
'xmssmt': {
'fields': ['ots', 'h', 'd', 'w', 'size', 'kg', 'sg', 'sg_cold',
'state', 'sv', 'sv_worst'],
diff --git a/parameters.json b/parameters.json
new file mode 100644
index 0000000..b6f0924
--- /dev/null
+++ b/parameters.json
@@ -0,0 +1,22 @@
+{
+ "_about": [
+ "Search grids and fixed parameters, read by the Sage scripts, tests/model.py and tests/synth_generated.py.",
+ "export_site_data.sage copies the stateless grid and the UXMSS bounds into site/data.json; docs/model.md explains the values."
+ ],
+ "baseline": {"h": 63, "d": 7, "k": 14, "a": 12, "w": 16, "q_s_log2": 64},
+ "security_bits": 128,
+ "swn": {"16": 240, "32": 403, "64": 693, "128": 1206, "256": 2040, "65536": 262140},
+ "stateless": {
+ "grid": {"h": [40, 50], "d": [2, 25], "k": [6, 24], "a": [8, 20], "w": [2, 4, 16, 256],
+ "q_s_log2": 40, "max_size": 7856},
+ "w_optional": [32, 64, 128],
+ "wc_w": [16, 256]
+ },
+ "stateful": {
+ "h": [10, 40],
+ "d": [1, 10],
+ "w": [16, 32, 64, 128, 256, 65536],
+ "uxmss_ref_size": 5712,
+ "hsf_max": 255
+ }
+}
diff --git a/run_experiments.py b/run_experiments.py
deleted file mode 100644
index 6d23ac3..0000000
--- a/run_experiments.py
+++ /dev/null
@@ -1,292 +0,0 @@
-#!/usr/bin/env python3
-"""
-Executes specialized SLH-DSA parameter sweeps using basic WOTS-TW + FORS architecture.
-UNIVERSAL CONSTRAINT: Signature sizes in ALL categories strictly cannot exceed the Standard Baseline (7856B).
-
-Selective execution flags:
- --cat1 Run Category 1: Absolute Extremes
- --cat2 Run Category 2: Time-Constrained Signatures
- --cat3 Run Category 3: Speed & CPB Optimized Tracks
- --only-new-coeffs Generate data strictly for new sub-standard coefficients
- (If no category flags are provided, all requested categories are executed by default.)
-"""
-
-import os
-import subprocess
-import shutil
-import argparse
-import pandas as pd
-import matplotlib.pyplot as plt
-import seaborn as sns
-
-STD_SIZE = 7856 # FIPS 205 SLH-DSA-128s signature size
-# NOTE: the C/byte cap is passed to the Sage script as a RATIO
-# (--max-cpb-ratio), which the script resolves against the standard
-# baseline it computes itself — no hardcoded std C/byte constant here.
-
-# =============================================================================
-# Global Configuration Toggles
-# =============================================================================
-# Set to True to execute sweeps strictly for newly added sub-standard coefficients [0.3, 0.5, 0.7, 0.9]
-ONLY_NEW_COEFFS = False
-
-BASE_DIR = os.path.join("utils", "outputs_specialized")
-CAT1_DIR = os.path.join(BASE_DIR, "cat1_extremes")
-CAT2_DIR = os.path.join(BASE_DIR, "cat2_time_constrained")
-CAT3_DIR = os.path.join(BASE_DIR, "cat3_speed_optimized")
-
-for d in [CAT1_DIR, CAT2_DIR, CAT3_DIR]:
- os.makedirs(d, exist_ok=True)
-
-def run_basic_sweep(max_size=STD_SIZE, kg_r="inf", sg_r="inf", sv_r="inf", cpb_r="inf"):
- """Runs the Sage script strictly for basic wots-tw and fors architecture under given filter caps."""
- cmd = [
- "sage", "slhdsa-2to40.sage",
- "--ots", "wots-tw",
- "--fts", "fors",
- "--max-size", str(max_size),
- "--max-keygen-ratio", str(kg_r),
- "--max-sign-ratio", str(sg_r),
- "--max-verify-ratio", str(sv_r),
- "--max-cpb-ratio", str(cpb_r)
- ]
-
- res = subprocess.run(cmd, stdout=subprocess.DEVNULL)
- if res.returncode != 0 or not os.path.exists("candidates.csv"):
- return pd.DataFrame()
-
- df = pd.read_csv("candidates.csv")
- os.remove("candidates.csv")
- return df.drop_duplicates(subset=['label', 'h', 'd', 'k', 'a', 'w']).reset_index(drop=True)
-
-def make_label(row):
- lbl = str(row['label'])
- if lbl == 'STANDARD':
- return f"STANDARD Baseline\n(h={row['h']},d={row['d']})"
- return f"{lbl}\n(h={row['h']},d={row['d']},k={row['k']},a={row['a']})"
-
-def generate_five_metric_plots(df_plot, folder_path, filter_title):
- """
- Takes a focused DataFrame (Standard + Top Candidates) and generates
- exactly 5 comparison graphs saved into the specified folder.
- """
- if 'disp_name' not in df_plot.columns:
- df_plot['disp_name'] = df_plot.apply(make_label, axis=1)
-
- metrics = [
- {"file": "1_size.png", "col": "size", "title": "Signature Size", "ylabel": "Bytes", "log": False},
- {"file": "2_keygen.png", "col": "keygen_C", "title": "Key Generation Cost", "ylabel": "SHA-256 Compressions", "log": True},
- {"file": "3_signing.png", "col": "sign_C", "title": "Signing Cost", "ylabel": "SHA-256 Compressions", "log": True},
- {"file": "4_verification.png", "col": "verify_C", "title": "Verification Cost", "ylabel": "SHA-256 Compressions", "log": False},
- {"file": "5_compression_per_byte.png", "col": "c_per_byte", "title": "Compressions per Byte Ratio", "ylabel": "Ratio (C / Byte)", "log": False}
- ]
-
- sns.set_theme(style="whitegrid")
- colors = ['#d9534f' if 'STANDARD' in name else '#0275d8' for name in df_plot['disp_name']]
-
- for m in metrics:
- fig, ax = plt.subplots(figsize=(11, 6), constrained_layout=True)
-
- ax.bar(df_plot['disp_name'], df_plot[m["col"]], color=colors, width=0.5)
-
- ax.set_title(f"{filter_title}\n{m['title']} Comparison", fontsize=13, pad=12)
- ax.set_ylabel(m["ylabel"], fontsize=11)
- ax.set_xlabel("Candidate Parameter Sets", fontsize=11)
-
- ax.set_xticks(range(len(df_plot)))
- ax.set_xticklabels(df_plot['disp_name'], rotation=45, ha="right")
-
- if m["log"]:
- ax.set_yscale("log")
-
- out_path = os.path.join(folder_path, m["file"])
- plt.savefig(out_path, dpi=300, bbox_inches='tight')
- plt.close()
-
-# =============================================================================
-# CATEGORY 1: Absolute Extremes
-# =============================================================================
-def run_category_1():
- print("\n" + "="*70)
- print("Category 1: Absolute Extremes (Capped at Standard Size <= 7856B)")
- print("="*70)
-
- print("-> Sweeping parameter space strictly within standard signature footprint...")
- df_pool = run_basic_sweep(max_size=STD_SIZE)
- if df_pool.empty or len(df_pool) <= 1:
- print(" [!] Candidate pool empty or contains only standard baseline.")
- return
-
- df_pool.to_csv(os.path.join(CAT1_DIR, "all_size_capped_candidates.csv"), index=False)
-
- std_row = df_pool[df_pool['label'] == 'STANDARD'].iloc[0]
- custom_df = df_pool[df_pool['label'] != 'STANDARD']
-
- extremes = [
- {"folder": "1_smallest_signature", "col": "size", "desc": "Smallest Signature"},
- {"folder": "2_fastest_keygen", "col": "keygen_C", "desc": "Fastest Keygen"},
- {"folder": "3_fastest_signing", "col": "sign_C", "desc": "Fastest Signing"},
- {"folder": "4_fastest_verification", "col": "verify_C", "desc": "Fastest Verification"},
- {"folder": "5_best_compression_ratio", "col": "c_per_byte", "desc": "Best C/Byte Ratio"}
- ]
-
- master_summary = [std_row]
-
- for ex in extremes:
- ex_dir = os.path.join(CAT1_DIR, ex["folder"])
- os.makedirs(ex_dir, exist_ok=True)
-
- winner = custom_df.sort_values(by=ex["col"], ascending=True).iloc[0].copy()
- winner['cat1_award'] = ex["desc"]
- master_summary.append(winner)
-
- df_focused = pd.DataFrame([std_row, winner]).reset_index(drop=True)
- df_focused.to_csv(os.path.join(ex_dir, "winner_vs_standard.csv"), index=False)
-
- generate_five_metric_plots(df_focused, ex_dir, f"Cat 1: {ex['desc']} Winner vs Standard")
- print(f" [v] Populated extreme folder: '{ex_dir}/'")
-
- pd.DataFrame(master_summary).to_csv(os.path.join(CAT1_DIR, "master_extremes_summary.csv"), index=False)
-
-# =============================================================================
-# CATEGORY 2: Smallest Signatures Under Strictly Capped Times
-# =============================================================================
-def run_category_2(factors):
- print("\n" + "="*70)
- print("Category 2: Smallest Signatures Capped by Execution Time")
- print("="*70)
-
- for x in factors:
- folder_path = os.path.join(CAT2_DIR, f"time_cap_{x}x")
- os.makedirs(folder_path, exist_ok=True)
-
- print(f"-> Sweeping for Smallest Size (Size <= Std) with All Times <= {x}x Standard...")
- df_pool = run_basic_sweep(max_size=STD_SIZE, kg_r=x, sg_r=x, sv_r=x, cpb_r=x)
-
- if len(df_pool) <= 1:
- print(f" [!] No custom candidates pass operational caps at {x}x std.")
- continue
-
- df_pool.to_csv(os.path.join(folder_path, "all_passing_candidates.csv"), index=False)
-
- std_row = df_pool[df_pool['label'] == 'STANDARD'].iloc[0]
- custom_df = df_pool[df_pool['label'] != 'STANDARD']
-
- top10_custom = custom_df.sort_values(by="size", ascending=True).head(10)
- df_plot = pd.concat([pd.DataFrame([std_row]), top10_custom]).reset_index(drop=True)
-
- generate_five_metric_plots(df_plot, folder_path, f"Cat 2: Smallest Signatures (Times <= {x}x Std)")
- print(f" [v] Populated folder: '{folder_path}/'")
-
-# =============================================================================
-# CATEGORY 3: 4 Tracks Optimized One-by-One (Remaining Overheads Scaled)
-# =============================================================================
-def run_category_3(factors):
- print("\n" + "="*70)
- print("Category 3: 4 Tracks Optimized One-by-One Under Scaled Overheads")
- print("="*70)
-
- tracks = [
- {
- "sub_dir": "fastest_keygen",
- "target_col": "keygen_C",
- "title": "Fastest Keygen",
- "kg": "inf", "sg": lambda x: x, "sv": lambda x: x, "cpb": lambda x: x
- },
- {
- "sub_dir": "fastest_signing",
- "target_col": "sign_C",
- "title": "Fastest Signing",
- "kg": lambda x: x, "sg": "inf", "sv": lambda x: x, "cpb": lambda x: x
- },
- {
- "sub_dir": "fastest_verification",
- "target_col": "verify_C",
- "title": "Fastest Verification",
- "kg": lambda x: x, "sg": lambda x: x, "sv": "inf", "cpb": lambda x: x
- },
- {
- "sub_dir": "best_compression_ratio",
- "target_col": "c_per_byte",
- "title": "Best Compression per Byte",
- "kg": lambda x: x, "sg": lambda x: x, "sv": lambda x: x, "cpb": "inf"
- }
- ]
-
- for t in tracks:
- track_base_dir = os.path.join(CAT3_DIR, t["sub_dir"])
- os.makedirs(track_base_dir, exist_ok=True)
-
- print(f"\n--- Executing Track: {t['title']} ---")
-
- for x in factors:
- folder_path = os.path.join(track_base_dir, f"other_caps_{x}x")
- os.makedirs(folder_path, exist_ok=True)
-
- kg_ratio = t["kg"] if isinstance(t["kg"], str) else t["kg"](x)
- sg_ratio = t["sg"] if isinstance(t["sg"], str) else t["sg"](x)
- sv_ratio = t["sv"] if isinstance(t["sv"], str) else t["sv"](x)
- cpb_ratio = t["cpb"] if isinstance(t["cpb"], str) else t["cpb"](x)
-
- print(f"-> Optimizing {t['title']} | Size <= Std, Other Overheads <= {x}x...")
- df_pool = run_basic_sweep(
- max_size=STD_SIZE,
- kg_r=kg_ratio,
- sg_r=sg_ratio,
- sv_r=sv_ratio,
- cpb_r=cpb_ratio
- )
-
- if len(df_pool) <= 1:
- print(f" [!] No custom candidates found passing overhead limits at {x}x std.")
- continue
-
- df_pool.to_csv(os.path.join(folder_path, "all_passing_candidates.csv"), index=False)
-
- std_row = df_pool[df_pool['label'] == 'STANDARD'].iloc[0]
- custom_df = df_pool[df_pool['label'] != 'STANDARD']
-
- top10_custom = custom_df.sort_values(by=t["target_col"], ascending=True).head(10)
- df_plot = pd.concat([pd.DataFrame([std_row]), top10_custom]).reset_index(drop=True)
-
- generate_five_metric_plots(df_plot, folder_path, f"Cat 3: {t['title']} Optimized (Caps <= {x}x)")
- print(f" [v] Populated folder: '{folder_path}/'")
-
-def main():
- parser = argparse.ArgumentParser(description="Specialized SLH-DSA parameter sweep orchestrator.")
- parser.add_argument("--cat1", action="store_true", help="Execute strictly Category 1: Absolute Extremes")
- parser.add_argument("--cat2", action="store_true", help="Execute strictly Category 2: Time-Constrained Signatures")
- parser.add_argument("--cat3", action="store_true", help="Execute strictly Category 3: Individual Operational Tracks")
- parser.add_argument("--only-new-coeffs", action="store_true", help="Generate data strictly for new sub-standard coefficients [0.3, 0.5, 0.7, 0.9]")
- args = parser.parse_args()
-
- run_all = not (args.cat1 or args.cat2 or args.cat3)
-
- sweep_only_new = ONLY_NEW_COEFFS or args.only_new_coeffs
- if sweep_only_new:
- factors_list = [0.8 , 0.85]
- print("\n[*] Execution Mode: Restricted to Newly Integrated Coefficients")
- else:
- factors_list = [0.3, 0.5, 0.7, 0.8 , 0.85, 0.9, 1.0, 1.5, 2.0, 5.0, 10.0]
-
- if not shutil.which("sage"):
- print("Critical Error: 'sage' engine is inaccessible. Initialize Sage environment first.")
- return
-
- print("\n" + "="*70)
- print("Initializing Specialized SLH-DSA Execution Suite")
- print("="*70)
-
- if run_all or args.cat1:
- run_category_1()
- if run_all or args.cat2:
- run_category_2(factors_list)
- if run_all or args.cat3:
- run_category_3(factors_list)
-
- print("\n" + "="*70)
- print(f"Requested execution scope successfully finished! Workspace stored in: '{BASE_DIR}/'")
- print("="*70)
-
-if __name__ == "__main__":
- main()
\ No newline at end of file
diff --git a/security.sage b/security.sage
index f6cbd47..bd7298f 100644
--- a/security.sage
+++ b/security.sage
@@ -1,119 +1,37 @@
#!/usr/bin/env sage
-from sage.all import *
-
"""
-SPHINCS+ FORS / PORS+FP Security Analysis
-
-This script computes the classical security level for FORS and PORS+FP schemes.
-
-Methodology follows the original SPHINCS+ submission:
-https://sphincs.org/data/sphincs+-specification.pdf (Appendix A)
-
-Differences from the original SPHINCS+ parameter script:
- 1. The original uses upper bound (r/t)^k for P(FORS forgery | r signatures hit
- instance). This script uses the exact formula (1 - (1 - 1/t)^r)^k.
- 2. To compute total security, the original sums attack probabilities:
- -log2(2^-n + P(forgery))
- This script takes the max:
- -log2(max(2^-n, P(forgery)))
- Rationale: A query targeting forgery cannot simultaneously serve as a
- preimage query for tree nodes or WOTS chains, because the tweaks are
- different. These are independent attack strategies.
-
-Attack Model:
--------------
-The adversary can win by making a hash query that either:
- 1. Finds a preimage (probability per query ≈ 2^-n), or
- 2. Results in a FORS/PORS+FP forgery
-
-FORS/PORS+FP Forgery Attack:
-----------------------------
-SPHINCS+ uses 2^h instances (one per hypertree leaf). After q_s signatures,
-some instances will have been used multiple times (birthday paradox). The
-adversary forges by finding an instance that was used r times, then creating
-a message that maps to only revealed leaves in that instance.
-
-Total FORS/PORS+FP forgery probability:
- P(FORS/PORS+FP forgery) = Σ_r P(r signatures hit instance and FORS/PORS+FP forgery)
- = Σ_r P(r signatures hit instance) × P(FORS/PORS+FP forgery | r signatures hit instance)
-
-where:
- - P(r signatures hit instance) follows a binomial distribution with q_s trials
- and success probability 2^-h. Therefore:
- P(r signatures hit instance) = C(q_s, r) × (2^-h)^r × (1-2^-h)^(q_s-r)
- where C(q_s, r) is the binomial coefficient
-
- - P(FORS/PORS+FP forgery | r signatures hit instance) is scheme-specific (see below)
-
-FORS Forgery:
--------------
- P(FORS forgery | r signatures hit instance) = (1 - (1 - 1/t)^r)^k, where t = 2^a
-
-Each of the k FORS trees has t leaves. After r signatures, each tree has r
-leaves revealed (one per signature). For forgery, all k trees must have their
-required leaf already revealed.
- P(required leaf NOT revealed in single tree) = (1 - 1/t)^r
- (each of r signatures has probability (1 - 1/t) of missing the required leaf)
- P(required leaf revealed in single tree) = 1 - (1 - 1/t)^r
- P(all k trees have required leaf revealed) = (1 - (1 - 1/t)^r)^k
-
-PORS+FP Forgery:
-----------------
- P(PORS+FP forgery | r signatures hit instance) = C(min(r*k, t), k) / C(t, k), where t = k * 2^a
-
-PORS+FP has a single tree with t = k * 2^a leaves. Each signature reveals k
-distinct leaves. After r signatures, at most min(r*k, t) leaves are revealed.
-For forgery, the adversary must find a message whose k indices all fall within
-the revealed set. The number of such "good" messages is C(min(r*k, t), k), out
-of C(t, k) total possible index selections.
-
-Total Security:
----------------
--log2(max(2^-n, P(FORS/PORS+FP forgery)))
+Security in bits of FORS and PORS+FP after q_s signatures: the forgery
+probability summed over how often the attacked instance was used, capped by
+the 8n-bit preimage bound (docs/model.md, FORS security). costs.sage loads
+this file; run alone, it prints the security of SLH-DSA-128s at 2^64 signatures.
+
+ sage security.sage
"""
+from sage.all import *
+import sys
-# High precision arithmetic
-F = RealField(100)
+F = RealField(100) # the terms reach 2^-1250, below what a double holds
def powF(p, e):
- """Power function with high precision"""
return F(p)**e
def qhitprob(q_s, r, leaves):
- """
- Probability that exactly r out of q_s signatures hit the same few-time signature instance.
- Follows binomial distribution with q_s trials and success probability p = 1/leaves
- """
+ """P(a given instance among `leaves` is used by exactly r of q_s signatures)."""
p = F(1/leaves)
return binomial(q_s, r) * powF(p, r) * powF(1-p, q_s-r)
def p_forge_fors(r, k, t):
- """
- P(FORS forgery | r signatures hit instance) = (1 - (1 - 1/t)^r)^k
- """
+ """P(FORS forgery | r signatures on the instance) = (1 - (1 - 1/t)^r)^k."""
return powF(1 - powF(1 - F(1)/F(t), r), k)
def p_forge_pors_fp(r, k, t):
- """
- P(PORS+FP forgery | r signatures hit instance) = C(min(r*k, t), k) / C(t, k)
- """
+ """P(PORS+FP forgery | r signatures on the instance) = C(min(r*k, t), k) / C(t, k)."""
return F(binomial(min(r*k, t), k)) / F(binomial(t, k))
def compute_security(q_s, h, k, a, scheme_type, hashbytes=16):
- """
- Compute security level for given FORS or PORS+FP parameters.
-
- Args:
- q_s: Number of signatures
- h: Hypertree height (number of instances = 2^h)
- k: Number of trees (FORS) or leaf indices per signature (PORS+FP)
- a: Tree height parameter
- scheme_type: "FORS" or "PORS+FP"
- hashbytes: Hash output size in bytes (default 16 = 128 bits)
-
- Returns:
- total_security_bits (float)
- """
+ """Security in bits, at most 8*hashbytes, of 2^h instances of FORS (k trees
+ of t = 2^a leaves) or PORS+FP (one tree of t = k*2^a leaves) after q_s
+ signatures. scheme_type is "FORS" or "PORS+FP"."""
leaves = 2**h
if scheme_type == "FORS":
@@ -123,27 +41,19 @@ def compute_security(q_s, h, k, a, scheme_type, hashbytes=16):
t = k * 2**a
p_forge = p_forge_pors_fp
- # Compute sigma
- # = Σ_r P(r signatures hit instance) × P(forgery | r signatures hit instance)
sigma = F(0)
r = 1
while True:
- # Probability of exactly r collisions to any specific instance
- p_r_collisions = qhitprob(q_s, r, leaves)
-
- # Contribution to total attack probability
- contribution = p_r_collisions * p_forge(r, k, t)
+ contribution = qhitprob(q_s, r, leaves) * p_forge(r, k, t)
sigma += contribution
r += 1
- # Stop when contribution is negligible (well below target security level)
- # Also require r > q_s/leaves to ensure we're past the peak contribution
+ # Past the mean q_s/leaves, stop at the first term below 2^-1250.
if r > q_s/leaves and contribution < F(2)**(-1250):
break
- # Compute security bits accounting for hash preimage attack
preimage_attack_prob = F(1) / F(2**(8*hashbytes))
total_attack_prob = max(preimage_attack_prob, sigma)
@@ -152,10 +62,9 @@ def compute_security(q_s, h, k, a, scheme_type, hashbytes=16):
return float(security_bits)
-# Run demo when invoked directly (not when loaded by another script via load())
-import sys
+# Runs only when invoked directly, not when another script load()s this file.
if sys.argv[0].endswith('security.sage') or sys.argv[0].endswith('security.sage.py'):
- # SPHINCS+-128f parameters
+ # SLH-DSA-128s
q_s = 2**64
h = 63
a = 12
diff --git a/site/index.html b/site/index.html
index 6ac4701..c0a2cbc 100644
--- a/site/index.html
+++ b/site/index.html
@@ -8,155 +8,9 @@
-
-