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 @@ - - +

SHRINCS pinned parameters

@@ -176,7 +30,7 @@

Stateless `; + } else { + row += ``; + } + row += ''; + tbody.innerHTML += row; + } + + table.innerHTML = ''; + table.appendChild(thead); + table.appendChild(tbody); + + table.querySelectorAll('.row-remove').forEach(btn => { + btn.addEventListener('click', e => { + const id = e.target.dataset.id; + state.candidates = state.candidates.filter(c => c.id !== id); + render(); + }); + }); +} + +// The candidate list: every candidate smaller than SLH-DSA-128s, with a +// factory that adds a fresh copy to the comparison. +const SWEEP_FACTORIES = []; + +function buildSweepPool() { + const stdSize = state.candidates[0].metrics.size; + + if (SITE_DATA) { + const xi = fieldIndex(SITE_DATA.stateful.xmssmt); + for (const rec of SITE_DATA.stateful.xmssmt.rows) { + const c = candFromXmssMtRec(rec, xi); + if (c.metrics.size >= stdSize) continue; + SWEEP_FACTORIES.push({ + key: `xmssmt-${rec[xi.ots]}-${rec[xi.w]}-${rec[xi.h]}-${rec[xi.d]}`, + factory: () => candFromXmssMtRec(rec, xi), + cached: c, + }); + } + const ui = fieldIndex(SITE_DATA.stateful.uxmss); + for (const rec of SITE_DATA.stateful.uxmss.rows) { + const c = candFromUxmssRec(rec, ui); + if (c.metrics.size <= 0 || c.metrics.size >= stdSize) continue; + SWEEP_FACTORIES.push({ + key: `uxmss-${rec[ui.ots]}-${rec[ui.w]}`, + factory: () => candFromUxmssRec(rec, ui), + cached: c, + }); + } + return; + } + + const otsList = ['TW', 'WC']; + const wList = [16, 32, 64, 128, 256, 65536]; + + for (const ots of otsList) { + for (const w of wList) { + for (let h = 10; h <= 40; h++) { + for (let d = 1; d <= 10; d++) { + if (h % d !== 0) continue; + const c = buildXmssMt(ots, h, d, w); + if (c.metrics.size >= stdSize) continue; + SWEEP_FACTORIES.push({ + key: `xmssmt-${ots}-${w}-${h}-${d}`, + factory: () => buildXmssMt(ots, h, d, w), + cached: c, + }); + } + } + } + } + + for (const ots of otsList) { + for (const w of wList) { + const c = buildUxmss(ots, w); + if (c.metrics.size <= 0 || c.metrics.size >= stdSize) continue; + SWEEP_FACTORIES.push({ + key: `uxmss-${ots}-${w}`, + factory: () => buildUxmss(ots, w), + cached: c, + }); + } + } +} + +const SWEEP_DEFAULTS_X = { size: 1.00, kg: 20.00, sg: 20.00, sv: 20.00, cb: 20.00, minQs: 0 }; +const SWEEP_DEFAULTS_W = { size: 1.0, kg: 1.0, sg: 1.0, sv: 1.0, cb: 1.0 }; +const sweepState = { + X: { ...SWEEP_DEFAULTS_X }, + W: { ...SWEEP_DEFAULTS_W }, + xMax: { kg: 20, sg: 20, sv: 20, cb: 20 }, + sort: { col: 'distance', dir: 'asc' }, + lastRanges: Object.fromEntries(['size', 'kg', 'sg', 'sv', 'cb'].map(k => [k, { min: 0, max: 1 }])), + lastSurvivors: [], // in display order, for the CSV +}; + +const SWEEP_BOUND_FILTERS = [ + { key: 'size', label: 'Size', min: 0.10, max: 1.00, step: 0.01, dp: 2 }, + { key: 'kg', label: 'KeyGen', min: 0.10, log: true }, + { key: 'sg', label: 'SigGen', min: 0.10, log: true }, + { key: 'sv', label: 'SigVer', min: 0.10, log: true }, + { key: 'cb', label: 'ρ', min: 0.10, log: true }, + { key: 'minQs', label: 'Min. sigs (log₂)', min: 0, max: 40, step: 1, dp: 0 }, +]; +const SWEEP_WEIGHT_FILTERS = [ + { key: 'size', label: 'Size', min: 0.10, max: 10.00, step: 0.10, dp: 1 }, + { key: 'kg', label: 'KeyGen', min: 0.10, max: 10.00, step: 0.10, dp: 1 }, + { key: 'sg', label: 'SigGen', min: 0.10, max: 10.00, step: 0.10, dp: 1 }, + { key: 'sv', label: 'SigVer', min: 0.10, max: 10.00, step: 0.10, dp: 1 }, + { key: 'cb', label: 'ρ', min: 0.10, max: 10.00, step: 0.10, dp: 1 }, +]; + +function sweepRatios(m, std) { + return { + size: m.size / std.size, + kg: m.keygen / std.keygen, + sg: sigGenOf(m) / sigGenOf(std), + sv: m.sigver / std.sigver, + cb: (m.sigver / m.size) / (std.sigver / std.size), + }; +} + +function sweepDistance(m, std, W) { + return weightedDistance(sweepRatios(m, std), sweepState.lastRanges, W); +} + +// Bound slider caps: the largest ratio of each metric in the list, in both +// signer modes and verification cases, so every candidate stays reachable. +function computeSweepXMax() { + const std = state.candidates[0].metrics; + const maxes = { kg: 0, sg: 0, sv: 0, cb: 0 }; + for (const f of SWEEP_FACTORIES) { + const m = f.cached.metrics; + maxes.kg = Math.max(maxes.kg, m.keygen / std.keygen); + maxes.sg = Math.max(maxes.sg, m.siggen / std.siggen, m.siggenCold / std.siggenCold); + maxes.sv = Math.max(maxes.sv, m.sigver / std.sigver, m.sigverWorst / std.sigverWorst); + maxes.cb = Math.max(maxes.cb, (m.sigver / m.size) / (std.sigver / std.size), + (m.sigverWorst / m.size) / (std.sigverWorst / std.size)); + } + sweepState.xMax = { kg: niceCap(maxes.kg), sg: niceCap(maxes.sg), + sv: niceCap(maxes.sv), cb: niceCap(maxes.cb) }; +} + +function clampSweepXToMax() { + for (const k of ['kg', 'sg', 'sv', 'cb']) { + if (sweepState.X[k] > sweepState.xMax[k]) sweepState.X[k] = sweepState.xMax[k]; + } +} + +function renderSweepFilterGroup(hostId, group, filters) { + const host = document.getElementById(hostId); + const store = sweepState[group]; + host.innerHTML = filters.map(f => { + const max = f.log ? sweepState.xMax[f.key] : f.max; + const numVal = f.log ? fmtX(store[f.key]) : store[f.key].toFixed(f.dp); + const sliderAttrs = f.log + ? `min="0" max="${SLIDER_STEPS}" step="1" + value="${xToPos(store[f.key], f.min, max)}" data-key="${f.key}" data-log="1"` + : `min="${f.min}" max="${f.max}" step="${f.step}" + value="${store[f.key]}" data-key="${f.key}"`; + return ` +
+ + +
+ `; + }).join(''); + + bindSliderPairs(host, store, requestSweepRender); +} + +function renderSweepFilters() { + renderSweepFilterGroup('sweep-bounds', 'X', SWEEP_BOUND_FILTERS); + renderSweepFilterGroup('sweep-weights', 'W', SWEEP_WEIGHT_FILTERS); +} + +const SWEEP_COLS = [ + { field: 'size', label: 'Size (B)', value: m => sizeCell(m), sortFn: (m) => m.size, ratioFn: m => m.size, lowerIsBetter: true }, + { field: 'keygen', label: 'KeyGen (C)', value: m => fmtNum(m.keygen), sortFn: (m) => m.keygen, ratioFn: m => m.keygen, lowerIsBetter: true }, + { field: 'siggen', label: () => `SigGen, ${SIGNER_MODE} (C)`, + value: m => fmtNum(sigGenOf(m)), sortFn: (m) => sigGenOf(m), ratioFn: m => sigGenOf(m), lowerIsBetter: true }, + { field: 'state', label: 'State (B)', value: m => (SIGNER_MODE === 'cold' || m.state === undefined) ? '—' : fmtNum(m.state), + sortFn: (m) => m.state || 0 }, + { field: 'sigver', label: 'SigVer (C)', value: m => fmtNum(m.sigver), sortFn: (m) => m.sigver, ratioFn: m => m.sigver, lowerIsBetter: true }, + { field: 'cb', label: `${RHO} (C/B)`, value: (m, c) => rhoCell(m, c, m.sigver, 1, fmtRho), sortFn: (m) => m.sigver / m.size, ratioFn: m => m.sigver / m.size, lowerIsBetter: true }, + { field: 'cbSchnorr', label: `${RHO} ÷ Schnorr`, + value: (m, c) => rhoCell(m, c, m.sigver, SCHNORR.cb64, fmtRatio), sortFn: (m) => m.sigver / m.size }, + { field: 'distance', label: 'Distance', value: (m, c, std) => sweepDistance(m, std, sweepState.W).toFixed(4), + sortFn: (m, c, std) => sweepDistance(m, std, sweepState.W) }, + { field: 'qs', label: 'Max signatures', value: (_, c) => qsCell(c.metrics), sortFn: (m) => (m.qsLog2 !== null ? Math.pow(2, m.qsLog2) : m.qsExact) }, +]; + +function renderSweepTable() { + const std = state.candidates[0].metrics; + const minQsAbs = Math.pow(2, sweepState.X.minQs); + const survivors = []; + for (const f of SWEEP_FACTORIES) { + const m = f.cached.metrics; + if (m.size > sweepState.X.size * std.size) continue; + if (m.keygen > sweepState.X.kg * std.keygen) continue; + if (sigGenOf(m) > sweepState.X.sg * sigGenOf(std)) continue; + if (m.sigver > sweepState.X.sv * std.sigver) continue; + const cbTarget = m.sigver / m.size; + const cbStd = std.sigver / std.size; + if (cbTarget > sweepState.X.cb * cbStd) continue; + const candQs = m.qsLog2 !== null ? Math.pow(2, m.qsLog2) : m.qsExact; + if (candQs < minQsAbs) continue; + survivors.push(f); + } + sweepState.lastRanges = ratioRanges(survivors.map(f => sweepRatios(f.cached.metrics, std))); + const sortCol = SWEEP_COLS.find(c => c.field === sweepState.sort.col) || SWEEP_COLS[0]; + const sign = sweepState.sort.dir === 'asc' ? 1 : -1; + survivors.sort((a, b) => { + const av = sortCol.sortFn(a.cached.metrics, a.cached, std); + const bv = sortCol.sortFn(b.cached.metrics, b.cached, std); + if (av === bv) return 0; + return sign * (av < bv ? -1 : 1); + }); + sweepState.lastSurvivors = survivors; + scheduleHashWrite(); + + document.getElementById('sweep-info').innerHTML = + `${survivors.length.toLocaleString('en-US')} of ` + + `${SWEEP_FACTORIES.length.toLocaleString('en-US')} candidates within the bounds.`; + + const table = document.getElementById('sweep-table'); + table.innerHTML = ''; + + if (survivors.length === 0) { + table.innerHTML = `No candidate is within all bounds. Relax one.`; + return; + } + + const thead = document.createElement('thead'); + let hr = 'Candidate'; + for (const col of SWEEP_COLS) { + const isActive = sweepState.sort.col === col.field; + const arrow = isActive ? (sweepState.sort.dir === 'asc' ? '▲' : '▼') : ''; + const lbl = typeof col.label === 'function' ? col.label() : col.label; + hr += `${lbl}${arrow}`; + } + hr += ''; + thead.innerHTML = hr; + + const inComparison = new Set(); + for (const c of state.candidates) { + inComparison.add(c.label); + } + + const tbody = document.createElement('tbody'); + const MAX_ROWS = 200; + const shown = survivors.slice(0, MAX_ROWS); + // One innerHTML assignment, so the rows are parsed once. + const rowsHtml = []; + for (const f of shown) { + const c = f.cached; + const m = c.metrics; + let row = ``; + row += `${c.label}`; + for (const col of SWEEP_COLS) { + let cell = col.value(m, c, std); + if (col.ratioFn) { + const target = col.ratioFn(m); + const stdVal = col.ratioFn(std); + if (isFinite(target) && isFinite(stdVal) && stdVal > 0) { + const ratio = target / stdVal; + const cls = ratioClass(ratio, col.lowerIsBetter); + cell += `${ratioNote(target, stdVal)}`; + } + } + row += `${cell}`; + } + const disabled = inComparison.has(c.label) ? 'disabled' : ''; + row += ``; + row += ''; + rowsHtml.push(row); + } + if (survivors.length > MAX_ROWS) { + rowsHtml.push(`… and ${(survivors.length - MAX_ROWS).toLocaleString('en-US')} more; tighten a bound to see them.`); + } + tbody.innerHTML = rowsHtml.join(''); + + table.appendChild(thead); + table.appendChild(tbody); + + table.querySelectorAll('.row-add').forEach(btn => { + btn.addEventListener('click', e => { + const key = e.target.dataset.key; + const f = SWEEP_FACTORIES.find(x => x.key === key); + if (!f) return; + state.candidates.push(f.factory()); + render(); + renderSweepTable(); // disables its + button + }); + }); + + table.querySelectorAll('th.sortable').forEach(th => { + th.addEventListener('click', e => { + if (e.target.closest('.info')) return; + const col = th.dataset.sort; + if (sweepState.sort.col === col) { + sweepState.sort.dir = (sweepState.sort.dir === 'asc') ? 'desc' : 'asc'; + } else { + sweepState.sort.col = col; + sweepState.sort.dir = 'asc'; + } + renderSweepTable(); + }); + }); +} + +const DEFAULT_CAND_KEYS = ['uxmss-TW-16', 'uxmss-WC-16', 'xmssmt-TW-16-10-1', + 'xmssmt-WC-16-10-1', 'xmssmt-TW-16-20-1', 'xmssmt-WC-16-20-1']; +const HASH_X_KEYS = ['size', 'kg', 'sg', 'sv', 'cb', 'minQs']; +const HASH_W_KEYS = ['size', 'kg', 'sg', 'sv', 'cb']; + +// The candidate of a key: xmssmt---- or uxmss--. +function candidateFromKey(key) { + const p = String(key).split('-'); + if (p[0] === 'uxmss' && p.length === 3) { + const ots = p[1], w = parseInt(p[2], 10); + if (!['TW', 'WC'].includes(ots) || ![16, 32, 64, 128, 256, 65536].includes(w)) return null; + return buildUxmss(ots, w); + } + if (p[0] === 'xmssmt' && p.length === 5) { + const ots = p[1], w = parseInt(p[2], 10); + const h = parseInt(p[3], 10), d = parseInt(p[4], 10); + if (!['TW', 'WC'].includes(ots) || ![16, 32, 64, 128, 256, 65536].includes(w)) return null; + if (!Number.isInteger(h) || h < 2 || h > 60 || + !Number.isInteger(d) || d < 1 || h % d !== 0) return null; + return buildXmssMt(ots, h, d, w); + } + return null; +} + +function writeHash() { + const parts = []; + const keys = state.candidates.slice(1).map(c => c.key).filter(Boolean); + if (keys.join(',') !== DEFAULT_CAND_KEYS.join(',')) parts.push('cand=' + keys.join(',')); + if (HASH_X_KEYS.some(k => sweepState.X[k] !== SWEEP_DEFAULTS_X[k])) + parts.push('b=' + HASH_X_KEYS.map(k => sweepState.X[k]).join(',')); + if (HASH_W_KEYS.some(k => sweepState.W[k] !== SWEEP_DEFAULTS_W[k])) + parts.push('w=' + HASH_W_KEYS.map(k => sweepState.W[k]).join(',')); + if (sweepState.sort.col !== 'distance' || sweepState.sort.dir !== 'asc') + parts.push('sort=' + sweepState.sort.col + '.' + sweepState.sort.dir); + if (SIGNER_MODE !== 'cached') parts.push('m=' + SIGNER_MODE); + replaceHash(parts); +} + +const scheduleHashWrite = hashWriter(writeHash); +const requestSweepRender = oncePerFrame(renderSweepTable); + +function applyHash() { + const params = readHash(); + if (params.b) { + const v = params.b.split(',').map(parseFloat); + if (v.length === 6 && v.every(isFinite)) { + sweepState.X.size = clamp(v[0], 0.10, 1.00); + sweepState.X.kg = clamp(v[1], 0.10, 1e15); + sweepState.X.sg = clamp(v[2], 0.10, 1e15); + sweepState.X.sv = clamp(v[3], 0.10, 1e15); + sweepState.X.cb = clamp(v[4], 0.10, 1e15); + sweepState.X.minQs = clamp(Math.round(v[5]), 0, 40); + } + } + if (params.w) { + const v = params.w.split(',').map(parseFloat); + if (v.length === 5 && v.every(isFinite)) + HASH_W_KEYS.forEach((k, i) => sweepState.W[k] = clamp(v[i], 0.10, 10.00)); + } + if (params.sort) { + const [col, dir] = params.sort.split('.'); + if (SWEEP_COLS.some(c => c.field === col) && ['asc', 'desc'].includes(dir)) + sweepState.sort = { col, dir }; + } + if (params.m === 'cold') SIGNER_MODE = 'cold'; + if (params.cand !== undefined) { + const seen = new Set(); + const cands = []; + for (const key of params.cand.split(',').filter(Boolean)) { + if (seen.has(key)) continue; + seen.add(key); + const c = candidateFromKey(key); + if (c) cands.push(c); + } + state.candidates = [state.candidates[0], ...cands]; + } +} + +function exportSweepCSV() { + const std = state.candidates[0].metrics; + const header = ['scheme', 'label', 'max_sigs', 'size_B', 'keygen_C', + 'siggen_cached_C', 'siggen_cold_C', 'state_B', + 'sigver_C', 'sigver_worst_C', 'rho', 'rho_worst', + 'rho_vs_schnorr', 'rho_worst_vs_schnorr', 'schnorr_rho', + 'signer', 'distance']; + const lines = [header.join(',')]; + for (const f of sweepState.lastSurvivors) { + const c = f.cached, m = c.metrics; + lines.push([c.scheme, '"' + c.label + '"', + m.qsLog2 !== null ? '2^' + m.qsLog2 : m.qsExact, + m.size, m.keygen, m.siggen, m.siggenCold, m.state !== undefined ? m.state : '', + m.sigver, m.sigverWorst, + (m.sigver / m.size).toFixed(6), (m.sigverWorst / m.size).toFixed(6), + (m.sigver / m.size / SCHNORR.cb64).toFixed(6), + (m.sigverWorst / m.size / SCHNORR.cb64).toFixed(6), + SCHNORR.cb64.toFixed(6), + SIGNER_MODE, + sweepDistance(m, std, sweepState.W).toFixed(6)].join(',')); + } + downloadCSV('stateful_candidates', lines); +} + +function setupAddForm() { + const showBtn = document.getElementById('show-add'); + const form = document.getElementById('add-form'); + const cancelBtn = document.getElementById('cancel-add'); + const schemeSel = document.getElementById('f-scheme'); + const errBox = document.getElementById('f-error'); + + showBtn.addEventListener('click', () => { + form.hidden = false; + showBtn.hidden = true; + errBox.hidden = true; + }); + cancelBtn.addEventListener('click', () => { + form.hidden = true; + showBtn.hidden = false; + errBox.hidden = true; + }); + + function updateVisibility() { + const scheme = schemeSel.value; + form.querySelectorAll('[data-scheme]').forEach(el => { + el.style.display = (el.dataset.scheme === scheme) ? '' : 'none'; + }); + } + schemeSel.addEventListener('change', updateVisibility); + updateVisibility(); + + const hInput = document.getElementById('f-h'); + const dSel = document.getElementById('f-d'); + function refreshDOptions() { + const h = parseInt(hInput.value, 10); + const prev = parseInt(dSel.value, 10); + dSel.innerHTML = ''; + if (!Number.isInteger(h) || h < 2) return; + const divisors = []; + for (let d = 1; d <= h; d++) if (h % d === 0) divisors.push(d); + for (const d of divisors) { + const opt = document.createElement('option'); + opt.value = String(d); + opt.textContent = `${d} (h' = ${h / d})`; + dSel.appendChild(opt); + } + if (divisors.includes(prev)) dSel.value = String(prev); + else dSel.value = String(divisors[0] || 1); + } + hInput.addEventListener('input', refreshDOptions); + refreshDOptions(); + + form.addEventListener('submit', e => { + e.preventDefault(); + errBox.hidden = true; + const scheme = schemeSel.value; + const ots = document.getElementById('f-ots').value; + const w = parseInt(document.getElementById('f-w').value, 10); + + try { + let cand; + if (scheme === 'xmssmt') { + const h = parseInt(document.getElementById('f-h').value, 10); + const d = parseInt(document.getElementById('f-d').value, 10); + if (!Number.isInteger(h) || h < 2 || h > 60) throw new Error(`h must be an integer in [2, 60] (got ${h}).`); + if (!Number.isInteger(d) || d < 1) throw new Error(`d must be a positive integer (got ${d}).`); + if (h % d !== 0) throw new Error(`d (${d}) must divide h (${h}) evenly. Got h/d = ${(h/d).toFixed(3)}.`); + cand = buildXmssMt(ots, h, d, w); + } else { + cand = buildUxmss(ots, w); + if (cand.metrics.size <= 0) throw new Error(`UXMSS budget too tight for ${ots}/w=${w}: no signature fits under ${UXMSS_TARGET_SIZE} B.`); + } + state.candidates.push(cand); + render(); + form.hidden = true; + showBtn.hidden = false; + } catch (err) { + errBox.textContent = err.message; + errBox.hidden = false; + } + }); + + document.getElementById('reset-defaults').addEventListener('click', () => { + state.candidates = defaultCandidates(); + render(); + }); +} + +// Called after every change of state.candidates. +function render() { + renderComparison(); + if (SWEEP_FACTORIES.length > 0) renderSweepTable(); + else scheduleHashWrite(); +} + +function renderSchnorrNote() { + const s = SCHNORR; + const m = state.candidates[0].metrics; + const cb = m.sigver / m.size; + const src = s.live + ? `kappa.json${s.commit ? ` at secp256k1 ${s.commit}` : ''}${s.run ? `, ${s.run}` : ''}` + + `${s.machine ? `, ${s.machine}` : ''}${s.preliminary ? ', preliminary' : ''}` + : 'built-in value, the live kappa.json did not load'; + document.getElementById('about-schnorr').innerHTML = + `

The average ρ divided by the ρ of a BIP340 Schnorr verification, ${s.cb64.toFixed(2)} C/B ` + + `(${s.impl}, one 64-byte signature; ` + + `bip340-vs-sha256, ${src}). ` + + `SLH-DSA-128s has ρ = ${fmtRho(cb)} C/B, ${fmtRatio(cb / s.cb64)} Schnorr.

`; +} + +async function setup() { + const [siteData, schnorr] = await Promise.all([loadSiteData(), loadSchnorrRef()]); + SITE_DATA = siteData; + SCHNORR = schnorr; + if (SITE_DATA) selfCheckStateful(SITE_DATA); + state.candidates = defaultCandidates(); + applyHash(); + document.getElementById('signer-mode').value = SIGNER_MODE; + document.getElementById('signer-mode').addEventListener('change', e => { + SIGNER_MODE = e.target.value === 'cold' ? 'cold' : 'cached'; + render(); + }); + setupAddForm(); + render(); + renderSchnorrNote(); + buildSweepPool(); // needs the SLH-DSA-128s candidate + computeSweepXMax(); + clampSweepXToMax(); + renderSweepFilters(); + renderSweepTable(); + + bindShareButton(writeHash); + document.getElementById('sweep-export').addEventListener('click', exportSweepCSV); +} +// The tests load this file under node, without a document. +if (typeof document !== 'undefined') setup(); diff --git a/site/js/stateless.js b/site/js/stateless.js new file mode 100644 index 0000000..ab55e2e --- /dev/null +++ b/site/js/stateless.js @@ -0,0 +1,811 @@ +// The Parameter Explorer: filters the candidate pools of data.json by bounds +// relative to SLH-DSA-128s, ranks what is left by distance, and compares a +// candidate of your own. +'use strict'; + +// SLH-DSA-compatible Winternitz parameters: log2(w) divides 8, so message +// digits stay byte-aligned. +const ALLOWED_W = [2, 4, 16, 256]; + +// Exploratory w; a checkbox adds them to the SPX pool. +const OPTIONAL_W = [32, 64, 128]; +const UNIVERSE_W = [...ALLOWED_W, ...OPTIONAL_W]; + +// Explorer rows use data.json's field names and carry ρ. +function metrics(p) { + const m = slhMetrics(p); + return { + size: m.size, kg: m.kg, sg: m.sg, sv: m.sv, sv_worst: m.svWorst, + cb: m.sv / m.size, cb_worst: m.svWorst / m.size, + }; +} + +const STD_M = metrics(SLH); + +// The pool of a scheme, from data.json (export_site_data.sage). +function poolFromData(data, scheme) { + const p = data.pools[scheme]; + const idx = fieldIndex(p); + // A data.json made for another grid can hold other w. + const rows = p.rows.filter(r => UNIVERSE_W.includes(r[idx.w])); + if (rows.length !== p.rows.length) { + console.warn(`pool ${scheme}: dropped ${p.rows.length - rows.length} rows with ` + + `w outside {${UNIVERSE_W.join(', ')}} — regenerate data.json (stale grid).`); + } + return rows.map(r => { + const c = { + h: r[idx.h], d: r[idx.d], k: r[idx.k], a: r[idx.a], w: r[idx.w], + swn: r[idx.swn], mmax: r[idx.mmax], + size: r[idx.size], kg: r[idx.kg], sg: r[idx.sg], + sv: r[idx.sv], sv_worst: r[idx.sv_worst], + }; + c.cb = c.sv / c.size; + c.cb_worst = c.sv_worst / c.size; + return c; + }); +} + +// Field of metric k: worst-case mode switches SigVer and ρ to their worst case. +function mkey(k) { + return state.useWorst && (k === 'sv' || k === 'cb') ? k + '_worst' : k; +} + +const FILTER_STEPS = [ + { key: 'size', metric: 'size', label: 'Size' }, + { key: 'kg', metric: 'kg', label: 'KeyGen' }, + { key: 'sg', metric: 'sg', label: 'SigGen' }, + { key: 'sv', metric: 'sv', label: 'SigVer' }, + { key: 'cb', metric: 'cb', label: 'ρ' }, +]; + +// Applies the bounds X in turn. Returns the count after each step, the +// survivors, and the step that removed each candidate (null if none did). +function runFilters(pool, X) { + const eliminatedAt = new Map(); + pool.forEach(c => eliminatedAt.set(c, null)); + + const stepCounts = [{ name: 'Initial pool', count: pool.length }]; + let cur = pool; + FILTER_STEPS.forEach((step, idx) => { + const mk = mkey(step.metric); + const stdVal = STD_M[mk]; + const next = []; + for (const c of cur) { + if (c[mk] <= X[step.key] * stdVal) { + next.push(c); + } else { + if (eliminatedAt.get(c) === null) eliminatedAt.set(c, idx); + } + } + cur = next; + const worstTag = (mk !== step.metric) ? ', worst' : ''; + stepCounts.push({ + name: `${step.label}${worstTag} ≤ ${fmtX(X[step.key])}×`, + count: cur.length + }); + }); + + return { stepCounts, survivors: cur, eliminatedAt }; +} + +// Ratios to SLH-DSA-128s, with the worst-case SigVer and ρ in worst-case mode. +function ratiosOf(c) { + const r = {}; + for (const k of DISTANCE_KEYS) r[k] = c[mkey(k)] / STD_M[mkey(k)]; + return r; +} + +function distanceOf(c, W) { + return weightedDistance(ratiosOf(c), state.lastRanges, W); +} + +const DEFAULTS_X = { size: 1.00, kg: 1.00, sg: 1.00, sv: 1.00, cb: 1.00 }; +const DEFAULTS_W = { size: 1.0, kg: 1.0, sg: 1.0, sv: 1.0, cb: 1.0 }; + +const state = { + initialPool: [], + siteData: null, // parsed data.json, null if it did not load + pools: {}, // scheme -> candidate pool + scheme: 'SPX', + useWorst: false, // bounds and ranking on worst-case SigVer and ρ + showOptW: false, // include OPTIONAL_W + xMax: { kg: 20, sg: 20, sv: 20, cb: 20 }, + X: { ...DEFAULTS_X }, + W: { ...DEFAULTS_W }, + // results of the last update + lastEliminatedAt: null, + lastSurvivors: [], + lastWinner: null, + // scatter geometry, for hit-testing + scatterLayout: { pad: 12, w: 0, h: 0 }, + // ratio ranges over the survivors, for the distance + lastRanges: Object.fromEntries(['size', 'kg', 'sg', 'sv', 'cb'].map(k => [k, { min: 0, max: 1 }])), +}; + +// Winternitz parameters in the pool: OPTIONAL_W exist in the SPX pool only. +function activeWSet() { + const act = [...ALLOWED_W]; + if (state.showOptW) act.push(...OPTIONAL_W); + return act.sort((a, b) => a - b); +} + +function rebuildInitialPool() { + const act = activeWSet(); + state.initialPool = (state.pools[state.scheme] || []).filter(c => act.includes(c.w)); +} + +// Bound slider caps: the largest ratio of each metric in any pool, average or +// worst case, so every candidate stays reachable (KeyGen ratios of the d = 2 +// candidates reach tens of thousands). +function computeXMax() { + const maxes = { kg: 0, sg: 0, sv: 0, cb: 0 }; + for (const scheme of Object.keys(state.pools)) { + for (const c of state.pools[scheme]) { + if (c.kg / STD_M.kg > maxes.kg) maxes.kg = c.kg / STD_M.kg; + if (c.sg / STD_M.sg > maxes.sg) maxes.sg = c.sg / STD_M.sg; + maxes.sv = Math.max(maxes.sv, c.sv / STD_M.sv, c.sv_worst / STD_M.sv_worst); + maxes.cb = Math.max(maxes.cb, c.cb / STD_M.cb, c.cb_worst / STD_M.cb_worst); + } + } + state.xMax = { kg: niceCap(maxes.kg), sg: niceCap(maxes.sg), + sv: niceCap(maxes.sv), cb: niceCap(maxes.cb) }; +} + +function clampXToMax() { + for (const k of ['kg', 'sg', 'sv', 'cb']) { + if (state.X[k] > state.xMax[k]) state.X[k] = state.xMax[k]; + } +} + +// Tuples swept per w value: (h, d) pairs with d | h times the k and a ranges. +function consideredPerW(g) { + let hd = 0; + for (let h = g.h[0]; h <= g.h[1]; h++) + for (let d = g.d[0]; d <= g.d[1]; d++) if (h % d === 0) hd++; + return hd * (g.k[1] - g.k[0] + 1) * (g.a[1] - g.a[0] + 1); +} + +// Seeded random numbers, so the dots keep their places between visits. +function mulberry32(seed) { + let s = seed | 0; + return () => { + s = (s + 0x6D2B79F5) | 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function renderControls() { + const filterDefs = [ + { key: 'size', label: 'Size', min: 0.30, max: 1.00, step: 0.01, dp: 2 }, + { key: 'kg', label: 'KeyGen', min: 0.10, max: state.xMax.kg, log: true }, + { key: 'sg', label: 'SigGen', min: 0.10, max: state.xMax.sg, log: true }, + { key: 'sv', label: 'SigVer', min: 0.10, max: state.xMax.sv, log: true }, + { key: 'cb', label: 'ρ', min: 0.10, max: state.xMax.cb, log: true }, + ]; + document.getElementById('filter-controls').innerHTML = filterDefs.map(d => ` +
+ + +
+ `).join(''); + + const weightDefs = [ + { key: 'size', label: 'Size', min: 0.10, max: 20.00, step: 0.10, dp: 1 }, + { key: 'kg', label: 'KeyGen', min: 0.10, max: 20.00, step: 0.10, dp: 1 }, + { key: 'sg', label: 'SigGen', min: 0.10, max: 20.00, step: 0.10, dp: 1 }, + { key: 'sv', label: 'SigVer', min: 0.10, max: 20.00, step: 0.10, dp: 1 }, + { key: 'cb', label: 'ρ', min: 0.10, max: 20.00, step: 0.10, dp: 1 }, + ]; + document.getElementById('weight-controls').innerHTML = weightDefs.map(d => ` +
+ + +
+ `).join(''); + + bindSliderPairs(document.getElementById('filter-controls'), state.X, requestUpdate); + bindSliderPairs(document.getElementById('weight-controls'), state.W, requestUpdate); +} + +function renderFunnel(stepCounts) { + const max = stepCounts[0].count || 1; + const html = stepCounts.map((s, i) => { + const widthPct = (s.count / max) * 100; + const prev = i > 0 ? stepCounts[i - 1].count : null; + const eliminated = prev !== null && s.count === 0 ? 'eliminated' : ''; + return ` +
+
${s.name}
+
+
${s.count.toLocaleString('en-US')}
+
+`; + }).join(''); + document.getElementById('funnel').innerHTML = html; +} + +// Colour of a distance scaled to [0, 1]: blue for the closest, through purple +// to red. +function distanceColor(t) { + t = Math.max(0, Math.min(1, t)); + const hue = 220 - 220 * t; + return `hsl(${hue}, 70%, 50%)`; +} + +const STEP_COLORS = ['#f87171', '#fb923c', '#facc15', '#a3e635', '#22d3ee']; + +function renderScatter(eliminatedAt, survivors, winner) { + const canvas = document.getElementById('scatter'); + // Canvas pixels follow the device pixel ratio. + const dpr = window.devicePixelRatio || 1; + const cssW = canvas.clientWidth || 800; + const cssH = 500; + canvas.width = cssW * dpr; + canvas.height = cssH * dpr; + canvas.style.height = cssH + 'px'; + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, cssW, cssH); + + ctx.fillStyle = '#fafafa'; + ctx.fillRect(0, 0, cssW, cssH); + + const pad = 12; + const w = cssW - 2 * pad; + const h = cssH - 2 * pad; + state.scatterLayout = { pad, w, h, cssW, cssH }; + + const dists = survivors.map(c => distanceOf(c, state.W)); + const dmin = dists.length ? Math.min(...dists) : 0; + const dmax = dists.length ? Math.max(...dists) : 1; + const drange = (dmax - dmin) || 1; + + // Grey dots first, so the coloured ones and the winner stay on top. + for (const c of state.initialPool) { + const step = eliminatedAt.get(c); + if (step === null) continue; + const x = pad + c._sx * w; + const y = pad + c._sy * h; + ctx.fillStyle = '#d4d4d0'; + ctx.globalAlpha = 0.45; + ctx.beginPath(); + ctx.arc(x, y, 1.6, 0, 2 * Math.PI); + ctx.fill(); + } + ctx.globalAlpha = 1; + + for (let i = 0; i < survivors.length; i++) { + const c = survivors[i]; + const x = pad + c._sx * w; + const y = pad + c._sy * h; + const t = (dists[i] - dmin) / drange; + ctx.fillStyle = distanceColor(t); + ctx.beginPath(); + ctx.arc(x, y, 3.2, 0, 2 * Math.PI); + ctx.fill(); + } + + if (winner) { + const x = pad + winner._sx * w; + const y = pad + winner._sy * h; + ctx.strokeStyle = '#1a1a1a'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(x, y, 8, 0, 2 * Math.PI); + ctx.stroke(); + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(x, y, 6, 0, 2 * Math.PI); + ctx.stroke(); + } + + canvas.setAttribute('aria-label', `Candidate sieve: ${survivors.length.toLocaleString('en-US')} of ` + + `${state.initialPool.length.toLocaleString('en-US')} candidates within the bounds` + + (winner ? `, best (${winner.h}, ${winner.d}, ${winner.k}, ${winner.a}, ${winner.w})` : '')); + + const legendHTML = ` +
filtered out
+
closest
+
middle
+
farthest
+
best
+ `; + document.getElementById('scatter-legend').innerHTML = legendHTML; +} + +// Colour of a ratio to SLH-DSA-128s; lower is better. +function ratioTone(r) { + if (r <= 0.8) return 'good'; + if (r <= 1.05) return 'warn'; + return 'bad'; +} + +// Rows of the comparison tables: each metric next to its SLH-DSA-128s value. +function comparisonRows(c) { + const schnorr = SCHNORR.cb64; + const rows = [ + ['Size (B)', c.size, STD_M.size, fmtNum], + ['KeyGen (C)', c.kg, STD_M.kg, fmtNum], + ['SigGen (C)', c.sg, STD_M.sg, fmtNum], + ['SigVer (C)', c.sv, STD_M.sv, fmtNum], + ['SigVer, worst (C)', c.sv_worst, STD_M.sv_worst, fmtNum], + ['ρ (C/B)', c.cb, STD_M.cb, fmtRho], + ['ρ, worst (C/B)', c.cb_worst, STD_M.cb_worst, fmtRho], + ['ρ ÷ Schnorr', c.cb / schnorr, STD_M.cb / schnorr, fmtRatio], + ['ρ ÷ Schnorr, worst', c.cb_worst / schnorr, STD_M.cb_worst / schnorr, fmtRatio], + ]; + return rows.map(([name, value, std, fmtValue]) => { + const r = value / std; + return `${name}${fmtValue(value)}${fmtValue(std)}` + + `${fmtRatio(r)}`; + }).join(''); +} + +function comparisonTable(c, column) { + return ` + + ${comparisonRows(c)} +
Metric${column}SLH-DSA-128sRatio
`; +} + +function renderWinner(winner) { + const el = document.getElementById('winner'); + if (!winner) { + el.innerHTML = '
No candidate is within all bounds. Relax one.
'; + return; + } + const d = distanceOf(winner, state.W); + el.innerHTML = ` +
+
+

(${winner.h}, ${winner.d}, ${winner.k}, ${winner.a}, ${winner.w})

+

Distance ${d.toFixed(4)} · h′ = ${winner.h / winner.d}

+ ${winner.swn ? `

Swn = ${winner.swn}${winner.mmax ? ` · mmax = ${winner.mmax}` : ''}

` : ''} +
+
${comparisonTable(winner, 'Winner')}
+
+ `; +} + +// Filters, ranks and redraws after any change of the bounds, weights or pool. +function update() { + if (!state.siteData) { + document.getElementById('winner').innerHTML = '
No candidate pool.
'; + return; + } + const { stepCounts, survivors, eliminatedAt } = runFilters(state.initialPool, state.X); + renderFunnel(stepCounts); + state.lastRanges = ratioRanges(survivors.map(ratiosOf)); + let winner = null; + if (survivors.length > 0) { + winner = survivors[0]; + let bestD = distanceOf(winner, state.W); + for (let i = 1; i < survivors.length; i++) { + const d = distanceOf(survivors[i], state.W); + if (d < bestD) { bestD = d; winner = survivors[i]; } + } + } + state.lastEliminatedAt = eliminatedAt; + state.lastSurvivors = survivors; + state.lastWinner = winner; + renderScatter(eliminatedAt, survivors, winner); + renderWinner(winner); + scheduleHashWrite(); +} + +const requestUpdate = oncePerFrame(update); + +function findNearestCandidate(mx, my) { + const { pad, w, h } = state.scatterLayout; + let best = null, bestD = 12 * 12; // 12px hit radius (squared) + for (const c of state.initialPool) { + const x = pad + c._sx * w; + const y = pad + c._sy * h; + const dx = mx - x, dy = my - y; + const d2 = dx * dx + dy * dy; + if (d2 < bestD) { bestD = d2; best = c; } + } + return best; +} + +function statusFor(c) { + if (c === state.lastWinner) return { cls: 'winner', text: '★ Best candidate' }; + const elimStep = state.lastEliminatedAt ? state.lastEliminatedAt.get(c) : null; + if (elimStep === null || elimStep === undefined) { + return { cls: 'survivor', text: 'Within all bounds' }; + } + return { cls: 'eliminated', text: `Outside the ${FILTER_STEPS[elimStep].label} bound` }; +} + +function renderTooltip(c, clientX, clientY) { + const tt = document.getElementById('tooltip'); + if (!c) { tt.style.display = 'none'; document.getElementById('hover-ring').style.display = 'none'; return; } + const st = statusFor(c); + const isSurvivor = st.cls === 'survivor' || st.cls === 'winner'; + const distHTML = isSurvivor + ? `
Distance ${distanceOf(c, state.W).toFixed(4)}
` + : ''; + + tt.innerHTML = ` +

(${c.h}, ${c.d}, ${c.k}, ${c.a}, ${c.w})${c.swn ? ` Swn=${c.swn}${c.mmax ? `, mmax=${c.mmax}` : ''}` : ''}

+
${st.text}
+ + + ${comparisonRows(c)} +
candidateSLH-DSA-128sratio
+${distHTML} + `; + tt.style.display = 'block'; + + // Right of and below the cursor, flipped where that leaves the window. + const ttRect = tt.getBoundingClientRect(); + const pad = 12; + let x = clientX + pad; + let y = clientY + pad; + if (x + ttRect.width > window.innerWidth - 8) x = clientX - ttRect.width - pad; + if (y + ttRect.height > window.innerHeight - 8) y = clientY - ttRect.height - pad; + tt.style.left = Math.max(8, x) + 'px'; + tt.style.top = Math.max(8, y) + 'px'; + + const canvas = document.getElementById('scatter'); + const rect = canvas.getBoundingClientRect(); + const { pad: scPad, w, h } = state.scatterLayout; + const ring = document.getElementById('hover-ring'); + ring.style.left = (rect.left + scPad + c._sx * w) - canvas.parentElement.getBoundingClientRect().left + 'px'; + ring.style.top = (rect.top + scPad + c._sy * h) - canvas.parentElement.getBoundingClientRect().top + 'px'; + ring.style.display = 'block'; +} + +function validateCustom(p) { + const errors = []; + const warnings = []; + const isPosInt = v => Number.isFinite(v) && v > 0 && Math.floor(v) === v; + + if (!isPosInt(p.h)) errors.push('h must be a positive integer.'); + if (!isPosInt(p.d)) errors.push('d must be a positive integer.'); + if (!isPosInt(p.k)) errors.push('k must be a positive integer.'); + if (!isPosInt(p.a)) errors.push('a must be a positive integer.'); + if (!isPosInt(p.w) || p.w < 2) errors.push('w must be an integer ≥ 2.'); + + if (errors.length) return { valid: false, errors, warnings }; + + if (p.h % p.d !== 0) { + errors.push(`d must divide h (h/d = ${(p.h / p.d).toFixed(3)}).`); + } + if (!UNIVERSE_W.includes(p.w)) { + errors.push(`w must be one of ${UNIVERSE_W.join(', ')}.`); + } else if (OPTIONAL_W.includes(p.w)) { + warnings.push(`w = ${p.w} is exploratory: its digits do not fit whole bytes, as SLH-DSA requires.`); + } + + if (errors.length) return { valid: false, errors, warnings }; + + const secBits = computeSecurityBits(Q_S_LOG2, p.h, p.k, p.a); + if (secBits < 128) { + warnings.push(`Below 128-bit security: ${secBits.toFixed(1)} bits at qs = 240.`); + } + + return { valid: true, errors: [], warnings, secBits }; +} + +function renderCustom() { + const p = { + h: parseInt(document.getElementById('cust-h').value, 10), + d: parseInt(document.getElementById('cust-d').value, 10), + k: parseInt(document.getElementById('cust-k').value, 10), + a: parseInt(document.getElementById('cust-a').value, 10), + w: parseInt(document.getElementById('cust-w').value, 10), + }; + + const v = validateCustom(p); + const statusEl = document.getElementById('custom-status'); + const resultEl = document.getElementById('custom-result'); + scheduleHashWrite(); + + if (!v.valid) { + statusEl.className = 'custom-status invalid'; + statusEl.innerHTML = `Invalid.
    ${v.errors.map(e => `
  • ${e}
  • `).join('')}
`; + resultEl.innerHTML = ''; + return; + } + + const m = metrics(p); + + // 2^(h/d) overflows a double for h' above about 1024, and the model stops + // being meaningful long before that. + if (!isFinite(m.kg) || !isFinite(m.sg)) { + statusEl.className = 'custom-status invalid'; + statusEl.innerHTML = `Out of range. h′ = ${p.h / p.d} is too large for this model; ` + + `reduce h or increase d.`; + resultEl.innerHTML = ''; + return; + } + + if (v.warnings.length) { + statusEl.className = 'custom-status warn'; + statusEl.innerHTML = `Valid, with notes:
    ${v.warnings.map(w => `
  • ${w}
  • `).join('')}
`; + } else { + statusEl.className = 'custom-status ok'; + const sameAsStd = p.h === SLH.h && p.d === SLH.d && p.k === SLH.k && p.a === SLH.a && p.w === SLH.w; + statusEl.innerHTML = `Valid.${sameAsStd ? ' This is SLH-DSA-128s.' : ''}`; + } + + const hp = p.h / p.d; + const chains = wotsL(p.w, 'TW'); + const secRaw = forsForgeryExponent(Q_S_LOG2, p.h, p.k, p.a); + const rawTxt = secRaw > 300 ? '> 300' : secRaw.toFixed(1); + const secLabel = secRaw >= 128 + ? `128 bits ✓ ` + + `(${rawTxt} bits against FORS forgery, capped by the 2−128 preimage bound)` + : `${secRaw.toFixed(1)} bits`; + + resultEl.innerHTML = ` +
+
+

(${p.h}, ${p.d}, ${p.k}, ${p.a}, ${p.w})

+
+
Layer height h′ = ${hp}
+
WOTS chains: ${chains}
+
Security at qs = 240: ${secLabel}
+
+
+
${comparisonTable(m, 'Yours')}
+
+ `; +} + +function attachCustomCandidate() { + ['cust-h', 'cust-d', 'cust-k', 'cust-a', 'cust-w'].forEach(id => { + const el = document.getElementById(id); + el.addEventListener('input', renderCustom); + el.addEventListener('change', renderCustom); // the w select + }); + renderCustom(); +} + +function attachScatterHover() { + const canvas = document.getElementById('scatter'); + let last = null; + canvas.addEventListener('mousemove', (e) => { + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const c = findNearestCandidate(mx, my); + if (c !== last || c) { + last = c; + renderTooltip(c, e.clientX, e.clientY); + } + }); + canvas.addEventListener('mouseleave', () => { + last = null; + renderTooltip(null); + }); +} + +// Checks data.json against the model on the baseline and a sample of rows; a +// mismatch is a bug in one of the two. +function selfCheckData(data, pool) { + const KEYS = ['size', 'kg', 'sg', 'sv', 'sv_worst']; + let bad = 0; + const b = data.baseline; + if (KEYS.some(k => STD_M[k] !== b[k])) { + bad++; console.error('data.json baseline disagrees with local formulas:', b, STD_M); + } + const step = Math.max(1, Math.floor(pool.length / 25)); + for (let i = 0; i < pool.length; i += step) { + const c = pool[i]; + const m = metrics(c); + if (KEYS.some(k => m[k] !== c[k])) { + bad++; console.error('data.json row disagrees with local formulas:', c, m); + } + } + if (bad === 0) console.log('data.json self-check passed (baseline + sampled rows match local formulas).'); + return bad === 0; +} + +const SCHEME_LABELS = { 'SPX': 'SPX', 'W+C': 'W+C', 'W+C_F+C': 'FORS+C', 'W+C_P+FP': 'PORS+FP' }; +const SCHEME_IDS = { 'SPX': 'spx', 'W+C': 'wc', 'W+C_F+C': 'fc', 'W+C_P+FP': 'pfp' }; +const SCHEME_FROM_ID = Object.fromEntries(Object.entries(SCHEME_IDS).map(([k, v]) => [v, k])); + +function renderSchemeButtons() { + const host = document.getElementById('scheme-controls'); + const schemes = state.siteData ? state.siteData.meta.schemes : []; + host.innerHTML = schemes.map(s => + ``).join(''); + host.querySelectorAll('button').forEach(b => + b.addEventListener('click', () => activateScheme(b.dataset.scheme))); +} + +function schnorrNote() { + const s = SCHNORR; + const src = s.live + ? `kappa.json${s.commit ? ` at secp256k1 ${s.commit}` : ''}${s.run ? `, ${s.run}` : ''}` + + `${s.machine ? `, ${s.machine}` : ''}${s.preliminary ? ', preliminary' : ''}` + : 'built-in value, the live kappa.json did not load'; + return `

Schnorr reference: a BIP340 verification takes ${s.cb64.toFixed(2)} C/B ` + + `(${s.impl}, one 64-byte signature; ` + + `bip340-vs-sha256, ${src}). ` + + `SLH-DSA-128s has ρ = ${fmtRho(STD_M.cb)} C/B, ${fmtRatio(STD_M.cb / s.cb64)} Schnorr ` + + `(${fmtRatio(STD_M.cb_worst / s.cb64)} worst).

`; +} + +function renderInfoStrip() { + const d = state.siteData; + if (!d) { + document.getElementById('initial-info').textContent = 'No candidate pool: data.json did not load.'; + document.getElementById('about-pool').innerHTML = schnorrNote(); + return; + } + const pool = state.initialPool; + const act = activeWSet(); + const wtxt = state.scheme === 'SPX' + ? `w = {${act.join(', ')}}` + : `(w, Swn) ∈ {(16, 240), (256, 2040)}`; + const swept = state.scheme === 'SPX' + ? consideredPerW(d.meta.grid) * act.length + : d.pools[state.scheme].considered; + document.getElementById('initial-info').innerHTML = + `Scheme ${SCHEME_LABELS[state.scheme]} · ` + + `${swept.toLocaleString('en-US')} tuples swept · ` + + `${pool.length.toLocaleString('en-US')} pass the initial bounds`; + const source = `

Pool: data.json, generated ${d.meta.generated} from commit ${d.meta.commit}. ` + + `Security from the FORS and PORS+FP subset-forgery analysis in ` + + `security.sage.

`; + document.getElementById('about-pool').innerHTML = + `

Initial bounds: at least 128-bit security at qs = 240, ` + + `size below ${fmtNum(STD_M.size)} B, ${wtxt}.

` + source + schnorrNote(); +} + +function activateScheme(scheme) { + if (!state.pools[scheme] || scheme === state.scheme) return; + state.scheme = scheme; + rebuildInitialPool(); + renderSchemeButtons(); + renderInfoStrip(); + update(); +} + +const HASH_KEYS = ['size', 'kg', 'sg', 'sv', 'cb']; +const CUSTOM_IDS = ['cust-h', 'cust-d', 'cust-k', 'cust-a', 'cust-w']; +const CUSTOM_DEFAULTS = '63,7,14,12,16'; + +function writeHash() { + const parts = []; + if (state.scheme !== 'SPX') parts.push('s=' + SCHEME_IDS[state.scheme]); + if (HASH_KEYS.some(k => state.X[k] !== DEFAULTS_X[k])) + parts.push('x=' + HASH_KEYS.map(k => state.X[k]).join(',')); + if (HASH_KEYS.some(k => state.W[k] !== DEFAULTS_W[k])) + parts.push('w=' + HASH_KEYS.map(k => state.W[k]).join(',')); + if (state.useWorst) parts.push('wc=1'); + if (state.showOptW) parts.push('ow=1'); + const cust = CUSTOM_IDS.map(id => document.getElementById(id).value).join(','); + if (cust !== CUSTOM_DEFAULTS) parts.push('c=' + cust); + replaceHash(parts); +} + +const scheduleHashWrite = hashWriter(writeHash); + +function applyHash() { + const params = readHash(); + const setVec = (str, store, isX) => { + const vals = (str || '').split(',').map(parseFloat); + if (vals.length !== 5 || !vals.every(isFinite)) return; + // Bounds accept the whole pool range here; clampXToMax lowers them to the + // slider caps next. Weights stay within 20. + HASH_KEYS.forEach((k, i) => { + store[k] = (k === 'size' && isX) + ? clamp(vals[i], 0.30, 1.00) + : clamp(vals[i], 0.10, isX ? 1e9 : 20.00); + }); + }; + if (params.s && SCHEME_FROM_ID[params.s] && state.pools[SCHEME_FROM_ID[params.s]]) { + state.scheme = SCHEME_FROM_ID[params.s]; + } + if (params.x) setVec(params.x, state.X, true); + if (params.w) setVec(params.w, state.W, false); + if (params.wc === '1') state.useWorst = true; + if (params.ow === '1') state.showOptW = true; + if (params.c) { + const vals = params.c.split(',').map(v => parseInt(v, 10)); + if (vals.length === 5 && vals.every(Number.isFinite)) { + if (!UNIVERSE_W.includes(vals[4])) vals[4] = 16; + CUSTOM_IDS.forEach((id, i) => document.getElementById(id).value = vals[i]); + } + } +} + +function exportSurvivorsCSV() { + const header = ['scheme', 'h', 'd', 'k', 'a', 'w', 'swn', 'mmax', 'size_B', + 'keygen_C', 'siggen_C', 'sigver_C', 'sigver_worst_C', + 'rho', 'rho_worst', 'rho_vs_schnorr', 'rho_worst_vs_schnorr', 'schnorr_rho', 'distance']; + const lines = [header.join(',')]; + for (const c of state.lastSurvivors) { + lines.push([state.scheme, c.h, c.d, c.k, c.a, c.w, c.swn || 0, c.mmax || 0, + c.size, c.kg, c.sg, c.sv, c.sv_worst, + c.cb.toFixed(6), c.cb_worst.toFixed(6), + (c.cb / SCHNORR.cb64).toFixed(6), + (c.cb_worst / SCHNORR.cb64).toFixed(6), + SCHNORR.cb64.toFixed(6), + distanceOf(c, state.W).toFixed(6)].join(',')); + } + downloadCSV(`survivors_${SCHEME_IDS[state.scheme]}`, lines); +} + +async function setup() { + const [data, schnorr] = await Promise.all([loadSiteData(), loadSchnorrRef()]); + state.siteData = data; + SCHNORR = schnorr; + if (data) { + data.meta.schemes.forEach((s, si) => { + const pool = poolFromData(data, s); + const rand = mulberry32(69421 + si); // stable layout per scheme + for (const c of pool) { c._sx = rand(); c._sy = rand(); } + state.pools[s] = pool; + }); + selfCheckData(data, state.pools['SPX']); + } + computeXMax(); // slider caps from the loaded pools + applyHash(); // may switch scheme, X/W, worst mode, w extras, custom tuple + clampXToMax(); + rebuildInitialPool(); + renderControls(); + document.getElementById('worst-toggle').checked = state.useWorst; + document.getElementById('optw-toggle').checked = state.showOptW; + document.querySelectorAll('[data-std]').forEach(el => { el.innerHTML = fmtNum(STD_M[el.dataset.std]); }); + renderSchemeButtons(); + renderInfoStrip(); + update(); + attachScatterHover(); + attachCustomCandidate(); + + document.getElementById('worst-toggle').addEventListener('change', e => { + state.useWorst = e.target.checked; + update(); + }); + document.getElementById('optw-toggle').addEventListener('change', e => { + state.showOptW = e.target.checked; + rebuildInitialPool(); + renderInfoStrip(); + update(); + }); + document.getElementById('reset-btn').addEventListener('click', () => { + state.X = { ...DEFAULTS_X }; + state.W = { ...DEFAULTS_W }; + renderControls(); + update(); + }); + bindShareButton(writeHash); + document.getElementById('export-btn').addEventListener('click', exportSurvivorsCSV); + + // The canvas follows its width. + window.addEventListener('resize', () => { + if (state.lastEliminatedAt) { + renderScatter(state.lastEliminatedAt, state.lastSurvivors, state.lastWinner); + } + }); +} + +// The tests load this file under node, without a document. +if (typeof document !== 'undefined') setup(); diff --git a/site/js/ui.js b/site/js/ui.js index 1deb126..8d0c307 100644 --- a/site/js/ui.js +++ b/site/js/ui.js @@ -1,4 +1,5 @@ -// Number formats, popovers and the glossary. +// Helpers shared by the three site pages: number formats, popovers, sliders, +// the address bar, data.json and the glossary. 'use strict'; // Number formats @@ -40,6 +41,7 @@ function infoButton(id, label) { } (function () { + if (typeof document === 'undefined') return; // under node, in the tests if (!('popover' in HTMLElement.prototype)) return; // older browsers show the content inline let opener = null; document.addEventListener('click', e => { @@ -70,9 +72,156 @@ function infoButton(id, label) { addEventListener('scroll', placeOpen, true); })(); +// Sliders + +function clamp(v, lo, hi) { + return Math.max(lo, Math.min(hi, v)); +} + +// A log-scale slider holds a track position 0..SLIDER_STEPS that maps +// geometrically onto min..max. +const SLIDER_STEPS = 1000; + +function posToX(pos, min, max) { + return min * Math.pow(max / min, pos / SLIDER_STEPS); +} + +function xToPos(x, min, max) { + const t = Math.log(x / min) / Math.log(max / min); + return Math.round(SLIDER_STEPS * clamp(t, 0, 1)); +} + +// Short enough for the number inputs: two decimals below 100, integers below +// a million, exponential above. +function fmtX(v) { + if (v < 100) return v.toFixed(2); + if (v < 1e6) return String(Math.round(v)); + return v.toExponential(1); +} + +// Slider caps: the ratio rounded up to 1, 2 or 5 times a power of ten, at least 20. +function niceCap(m) { + if (!isFinite(m) || m <= 20) return 20; + const e = Math.pow(10, Math.floor(Math.log10(m))); + for (const f of [1, 2, 5, 10]) if (f * e >= m) return f * e; + return 10 * e; +} + +// Keeps store[key] in step with each range input in host and the number input +// with the same data-key. A range with data-log is a log-scale slider over the +// number input's min..max; a number input with data-dp shows that many decimals. +function bindSliderPairs(host, store, onChange) { + for (const slider of host.querySelectorAll('input[type="range"][data-key]')) { + const key = slider.dataset.key; + const num = host.querySelector(`input[type="number"][data-key="${key}"]`); + const show = v => { + num.value = num.dataset.dp ? v.toFixed(parseInt(num.dataset.dp, 10)) : fmtX(v); + }; + slider.addEventListener('input', () => { + const v = slider.dataset.log + ? posToX(parseFloat(slider.value), parseFloat(num.min), parseFloat(num.max)) + : parseFloat(slider.value); + store[key] = v; + show(v); + onChange(); + }); + num.addEventListener('input', () => { + const v = parseFloat(num.value); + if (!isFinite(v)) return; + const min = parseFloat(num.min), max = parseFloat(num.max); + store[key] = clamp(v, min, max); + slider.value = slider.dataset.log ? xToPos(store[key], min, max) : store[key]; + onChange(); + }); + num.addEventListener('blur', () => show(store[key])); + } +} + +// A slider drag fires an input event per step: run fn at most once per frame. +function oncePerFrame(fn) { + let queued = false; + return () => { + if (queued) return; + queued = true; + requestAnimationFrame(() => { queued = false; fn(); }); + }; +} + +// Address bar + +// The #key=value&... parameters. +function readHash() { + const params = {}; + for (const part of location.hash.replace(/^#/, '').split('&')) { + const eq = part.indexOf('='); + if (eq > 0) params[part.slice(0, eq)] = decodeURIComponent(part.slice(eq + 1)); + } + return params; +} + +function replaceHash(parts) { + const url = parts.length ? '#' + parts.join('&') : location.pathname + location.search; + // Safari throws once a page exceeds its history-update quota. + try { history.replaceState(null, '', url); } catch (err) { /* keep the page working */ } +} + +// Browsers rate-limit history.replaceState (Chromium drops calls beyond about +// 200 per 10 s), so the address bar is written once, shortly after the last +// change, rather than on every slider step. +const HASH_WRITE_DELAY_MS = 250; + +function hashWriter(write) { + let timer = null; + return () => { + clearTimeout(timer); + timer = setTimeout(write, HASH_WRITE_DELAY_MS); + }; +} + +function bindShareButton(writeHash) { + document.getElementById('share-btn').addEventListener('click', async e => { + writeHash(); + const btn = e.target; + try { + await navigator.clipboard.writeText(location.href); + btn.textContent = 'Link copied ✓'; + setTimeout(() => { btn.textContent = 'Copy shareable link'; }, 1500); + } catch (err) { + prompt('Copy this link:', location.href); + } + }); +} + +// Data + +async function loadSiteData() { + try { + const resp = await fetch('data.json', { cache: 'no-cache' }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + return await resp.json(); + } catch (e) { + console.warn('data.json unavailable (' + e.message + ')'); + return null; + } +} + +// Column of each field in a data.json table. +function fieldIndex(table) { + return Object.fromEntries(table.fields.map((f, i) => [f, i])); +} + +function downloadCSV(name, lines) { + const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/csv' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `${name}_${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(a.href); +} + // Glossary -document.body.insertAdjacentHTML('beforeend', ` +if (typeof document !== 'undefined') document.body.insertAdjacentHTML('beforeend', `

Glossary

diff --git a/site/stateful.html b/site/stateful.html index 3acba48..a063dd8 100644 --- a/site/stateful.html +++ b/site/stateful.html @@ -6,301 +6,8 @@ SHRINCS Stateful Comparison - - +
@@ -369,7 +76,7 @@

SHRINCS Stateful Comparison

-

Comparison

+

Comparison

Ratios under each value compare with SLH-DSA-128s.

Sizes follow the SHRINCS stateful layout: a one-byte indicator, a 16-byte randomizer and the leaf index, @@ -427,993 +134,8 @@

All stateful candidates

-

Winternitz parameter

@@ -610,7 +43,7 @@

Winternitz parameter