diff --git a/.github/workflows/build_and_deploy.yml b/.github/workflows/build_and_deploy.yml
index ec45e97..750bf6f 100644
--- a/.github/workflows/build_and_deploy.yml
+++ b/.github/workflows/build_and_deploy.yml
@@ -8,7 +8,7 @@ name: Build report and deploy site
on:
push:
- branches: [main, stateful]
+ branches: [main]
tags: ['v*']
workflow_dispatch:
@@ -23,10 +23,10 @@ concurrency:
env:
GENERATED_PATHS: |
- utils/all_size_capped_candidates.csv
- utils/all_unbound_candidates.csv
- utils/global_X_thresholds.csv
- utils/master_extremes_summary.csv
+ analysis/all_size_capped_candidates.csv
+ analysis/all_unbound_candidates.csv
+ analysis/global_X_thresholds.csv
+ analysis/master_extremes_summary.csv
site/data.json
document/content/images/sieve_step*.png
document/content/images/concentric_diagram.png
@@ -44,14 +44,14 @@ jobs:
image: sagemath/sagemath:10.4 # pinned; bump deliberately and re-verify
options: --user root # image's default user cannot write the runner workspace
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Restore generated artifacts (keyed on generator sources)
id: cache
- uses: actions/cache@v4
+ uses: actions/cache@v6
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('model/*.sage', 'scripts/*.sage', 'third_party/*.py', 'analysis/*.py', 'Makefile', 'parameters.json') }}
- name: Generate (sweeps, derived tables, data.json, figures)
if: steps.cache.outputs.cache-hit != 'true'
@@ -65,7 +65,7 @@ jobs:
make sweeps derived data figures PYTHON="sage -python"
- name: Upload generated artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: generated
path: ${{ env.GENERATED_PATHS }}
@@ -77,22 +77,22 @@ jobs:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v7
- name: Place generated artifacts into the tree
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: generated
- - uses: actions/setup-python@v5
+ - uses: actions/setup-python@v7
with:
python-version: '3.12'
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v7
with:
node-version: '22'
- name: Regression suite (generated artifacts required)
- run: GENERATED_REQUIRED=1 python3 tests/test_regression.py
+ run: GENERATED_REQUIRED=1 python3 tests/run.py
- name: Compile report
uses: xu-cheng/latex-action@v3
@@ -104,20 +104,20 @@ jobs:
run: cp document/main.pdf site/report.pdf
- name: Upload Pages artifact
- uses: actions/upload-pages-artifact@v3
+ uses: actions/upload-pages-artifact@v5
with:
path: . # whole tree, preserving the /site/... URL structure
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@v4
+ uses: actions/deploy-pages@v5
- name: Attach data + report to release (tags only)
if: startsWith(github.ref, 'refs/tags/')
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v3
with:
files: |
site/report.pdf
site/data.json
- utils/all_size_capped_candidates.csv
- utils/all_unbound_candidates.csv
+ analysis/all_size_capped_candidates.csv
+ analysis/all_unbound_candidates.csv
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 913a02d..a1a05a8 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -2,21 +2,21 @@ name: tests
on:
push:
- branches: [main, stateful]
+ branches: [main]
pull_request:
jobs:
regression:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-python@v5
+ - uses: actions/checkout@v7
+ - uses: actions/setup-python@v7
with:
python-version: '3.12'
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v7
with:
node-version: '22'
- name: Synthesize generated artifacts from the reference model
run: python3 tests/synth_generated.py
- name: Run regression suite
- run: GENERATED_REQUIRED=1 python3 tests/test_regression.py
+ run: GENERATED_REQUIRED=1 python3 tests/run.py
diff --git a/.gitignore b/.gitignore
index 043428f..7e77f75 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,359 +1,28 @@
.DS_Store
+__pycache__/
+node_modules/
+# Sage's preparsed copy of every script it runs
*.sage.py
-# Generated artifacts
-utils/outputs_specialized/
+# make generate
candidates.csv
-utils/all_size_capped_candidates.csv
-utils/all_unbound_candidates.csv
-utils/global_X_thresholds.csv
-utils/master_extremes_summary.csv
+analysis/all_size_capped_candidates.csv
+analysis/all_unbound_candidates.csv
+analysis/global_X_thresholds.csv
+analysis/master_extremes_summary.csv
site/data.json
-document/main.pdf
-document/content/images/sieve_step*.png
-document/content/images/concentric_diagram.png
-document/content/images/scatter_plot.png
-document/content/images/comparison_regular.png
-document/content/images/comparison_normalized.png
-document/content/images/ideal_candidate_balanced.png
-document/content/images/ideal_candidate_best.png
+document/content/images/
-# Compiled report — built and deployed by CI (.github/workflows/build_and_deploy.yml)
-site/report.pdf
-
-## Core latex/pdflatex auxiliary files:
+# The report's LaTeX build; CI publishes the PDF as site/report.pdf
*.aux
-*.lof
-*.log
-*.lot
-*.fls
-*.out
-*.toc
-*.fmt
-*.fot
-*.cb
-*.cb2
-.*.lb
-
-## Intermediate documents:
-*.dvi
-*.xdv
-*-converted-to.*
-# these rules might exclude image files for figures etc.
-# *.ps
-# *.eps
-# *.pdf
-
-## Generated if empty string is given at "Please type another file name for output:"
-.pdf
-
-## Bibliography auxiliary files (bibtex/biblatex/biber):
*.bbl
-*.bbl-SAVE-ERROR
-*.bcf
-*.bcf-SAVE-ERROR
*.blg
-*-blx.aux
-*-blx.bib
-*.run.xml
-
-## Build tool auxiliary files:
*.fdb_latexmk
-*.synctex
-*.synctex(busy)
+*.fls
+*.log
+*.out
*.synctex.gz
-*.synctex.gz(busy)
-*.pdfsync
-*.rubbercache
-rubber.cache
-
-## Build tool directories for auxiliary files
-# latexrun
-latex.out/
-
-## Auxiliary and intermediate files from other packages:
-# algorithms
-*.alg
-*.loa
-
-# achemso
-acs-*.bib
-
-# amsthm
-*.thm
-
-# attachfile2
-*.atfi
-
-# beamer
-*.nav
-*.pre
-*.snm
-*.vrb
-
-# changes
-*.soc
-*.loc
-
-# comment
-*.cut
-
-# cprotect
-*.cpt
-
-# elsarticle (documentclass of Elsevier journals)
-*.spl
-
-# endnotes
-*.ent
-
-# fixme
-*.lox
-
-# feynmf/feynmp
-*.mf
-*.mp
-*.t[1-9]
-*.t[1-9][0-9]
-*.tfm
-
-#(r)(e)ledmac/(r)(e)ledpar
-*.end
-*.?end
-*.[1-9]
-*.[1-9][0-9]
-*.[1-9][0-9][0-9]
-*.[1-9]R
-*.[1-9][0-9]R
-*.[1-9][0-9][0-9]R
-*.eledsec[1-9]
-*.eledsec[1-9]R
-*.eledsec[1-9][0-9]
-*.eledsec[1-9][0-9]R
-*.eledsec[1-9][0-9][0-9]
-*.eledsec[1-9][0-9][0-9]R
-
-# glossaries
-*.acn
-*.acr
-*.glg
-*.glg-abr
-*.glo
-*.glo-abr
-*.gls
-*.gls-abr
-*.glsdefs
-*.lzo
-*.lzs
-*.slg
-*.slo
-*.sls
-
-# uncomment this for glossaries-extra (will ignore makeindex's style files!)
-# *.ist
-
-# gnuplot
-*.gnuplot
-*.table
-
-# gnuplottex
-*-gnuplottex-*
-
-# gregoriotex
-*.gaux
-*.glog
-*.gtex
-
-# htlatex
-*.4ct
-*.4tc
-*.idv
-*.lg
-*.trc
-*.xref
-
-# hypdoc
-*.hd
-
-# hyperref
-*.brf
-
-# knitr
-*-concordance.tex
-# TODO Uncomment the next line if you use knitr and want to ignore its generated tikz files
-# *.tikz
-*-tikzDictionary
-
-# latexindent will create succesive backup files by default
-#*.bak*
-
-# listings
-*.lol
-
-# luatexja-ruby
-*.ltjruby
-
-# makeidx
-*.idx
-*.ilg
-*.ind
-
-# minitoc
-*.maf
-*.mlf
-*.mlt
-*.mtc[0-9]*
-*.slf[0-9]*
-*.slt[0-9]*
-*.stc[0-9]*
-
-# minted
-_minted*
-*.data.minted
-*.pyg
-
-# morewrites
-*.mw
-
-# newpax
-*.newpax
-
-# nomencl
-*.nlg
-*.nlo
-*.nls
-
-# pax
-*.pax
-
-# pdfpcnotes
-*.pdfpc
-
-# sagetex
-*.sagetex.sage
-*.sagetex.py
-*.sagetex.scmd
-
-# scrwfile
-*.wrt
-
-# spelling
-*.spell.bad
-*.spell.txt
-
-# svg
-svg-inkscape/
-
-# sympy
-*.sout
-*.sympy
-sympy-plots-for-*.tex/
-
-# pdfcomment
-*.upa
-*.upb
-
-# pythontex
-*.pytxcode
-pythontex-files-*/
-
-# tcolorbox
-*.listing
-
-# thmtools
-*.loe
-
-# TikZ & PGF
-*.dpth
-*.md5
-*.auxlock
-
-# titletoc
-*.ptc
-
-# todonotes
-*.tdo
-
-# vhistory
-*.hst
-*.ver
-
-# easy-todo
-*.lod
-
-# xcolor
-*.xcp
-
-# xmpincl
-*.xmpi
-
-# xindy
-*.xdy
-
-# xypic precompiled matrices and outlines
-*.xyc
-*.xyd
-
-# endfloat
-*.ttt
-*.fff
-
-# Latexian
-TSWLatexianTemp*
-
-## Editors:
-# WinEdt
-*.bak
-*.sav
-
-# latexindent.pl
-*.bak[0-9]*
-
-# Texpad
-.texpadtmp
-
-# LyX
-*.lyx~
-
-# Kile
-*.backup
-
-# gummi
-.*.swp
-
-# KBibTeX
-*~[0-9]*
-
-# TeXnicCenter
-*.tps
-
-# auto folder when using emacs and auctex
-./auto/*
-*.el
-
-# expex forward references with \gathertags
-*-tags.tex
-
-# standalone packages
-*.sta
-
-# Makeindex log files
-*.lpz
-
-# xwatermark package
-*.xwm
-
-# REVTeX puts footnotes in the bibliography by default, unless the nofootinbib
-# option is specified. Footnotes are the stored in a file with suffix Notes.bib.
-# Uncomment the next line to have this generated file ignored.
-#*Notes.bib
-# Python bytecode
-__pycache__/
-*.pyc
-
-# Local audit notes
-AUDIT.md
-
-# Node packages
-node_modules/
+*.toc
+document/main.pdf
+site/report.pdf
diff --git a/Makefile b/Makefile
index a7f1bc3..189e87b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,7 @@
# Single entrypoint for regenerating every derived artifact.
#
# make generate — everything below, in dependency order
-# make sweeps — the two parameter sweeps -> utils/*.csv (sage)
+# make sweeps — the two parameter sweeps -> analysis/*.csv (sage)
# make derived — thresholds + extremes from the sweep CSVs (python)
# make data — site/data.json for both site pages (sage)
# make figures — report figures from the generated data (python)
@@ -14,64 +14,64 @@
SAGE ?= sage
PYTHON ?= python3
-UTILS := utils
-IMAGES := document/content/images
+ANALYSIS := analysis
+IMAGES := document/content/images
-CSV_CAPPED := $(UTILS)/all_size_capped_candidates.csv
-CSV_UNBOUND := $(UTILS)/all_unbound_candidates.csv
+CSV_CAPPED := $(ANALYSIS)/all_size_capped_candidates.csv
+CSV_UNBOUND := $(ANALYSIS)/all_unbound_candidates.csv
.PHONY: generate sweeps derived data figures test check-local fixtures site-snapshot clean-generated
generate: sweeps derived data figures test
sweeps:
- $(SAGE) slhdsa-2to40.sage --max-size 7856 > /dev/null
+ $(SAGE) scripts/slhdsa-2to40.sage > /dev/null
mv candidates.csv $(CSV_CAPPED)
- $(SAGE) slhdsa-2to40.sage --max-size 99999999 > /dev/null
+ $(SAGE) scripts/slhdsa-2to40.sage --max-size 99999999 > /dev/null
mv candidates.csv $(CSV_UNBOUND)
derived:
- cd $(UTILS) && $(PYTHON) min_mult_search.py
- $(PYTHON) $(UTILS)/extremes_summary.py
+ cd $(ANALYSIS) && $(PYTHON) min_mult_search.py
+ $(PYTHON) $(ANALYSIS)/extremes_summary.py
data:
- $(SAGE) export_site_data.sage
+ $(SAGE) scripts/export_site_data.sage
figures:
mkdir -p $(IMAGES) # holds only generated files, so fresh checkouts lack it
- cd $(IMAGES) && $(PYTHON) ../../../$(UTILS)/sieve_search_draw.py \
+ cd $(IMAGES) && $(PYTHON) ../../../$(ANALYSIS)/sieve_search_draw.py \
--input ../../../$(CSV_UNBOUND) --weights 1,1,1,1,1 --suffix _1
- cd $(IMAGES) && $(PYTHON) ../../../$(UTILS)/sieve_search_draw.py \
+ cd $(IMAGES) && $(PYTHON) ../../../$(ANALYSIS)/sieve_search_draw.py \
--input ../../../$(CSV_UNBOUND) --weights 5,1,1.5,2,1 --suffix _2
- cd $(UTILS) && $(PYTHON) venn_diagram_draw.py
- mv $(UTILS)/euler_custom_minimax.png $(IMAGES)/concentric_diagram.png
- cd $(IMAGES) && $(PYTHON) ../../../$(UTILS)/plot_concept_scatter.py
+ cd $(ANALYSIS) && $(PYTHON) venn_diagram_draw.py
+ mv $(ANALYSIS)/euler_custom_minimax.png $(IMAGES)/concentric_diagram.png
+ cd $(IMAGES) && $(PYTHON) ../../../$(ANALYSIS)/plot_concept_scatter.py
mv $(IMAGES)/conceptual_bounded_zones_origin_vector.png $(IMAGES)/scatter_plot.png
# Report Sec. "Metric-Normalized Weighted Distance": method comparison
- cd $(IMAGES) && $(PYTHON) ../../../$(UTILS)/sieve_search_draw.py \
+ cd $(IMAGES) && $(PYTHON) ../../../$(ANALYSIS)/sieve_search_draw.py \
--input ../../../$(CSV_UNBOUND) --figure focus --distance regular \
--x-size 0.75 --x-kg 5 --x-sg 5 --x-sv 1.5 --x-cb 1 \
--weights 5,1.5,1.5,2,1 --out comparison_regular.png
- cd $(IMAGES) && $(PYTHON) ../../../$(UTILS)/sieve_search_draw.py \
+ cd $(IMAGES) && $(PYTHON) ../../../$(ANALYSIS)/sieve_search_draw.py \
--input ../../../$(CSV_UNBOUND) --figure focus --distance normalized \
--x-size 0.75 --x-kg 5 --x-sg 5 --x-sv 1.5 --x-cb 1 \
--weights 5,1.5,1.5,2,1 --out comparison_normalized.png
# Report Sec. "Recommended Candidate Tuple": the two surviving candidates
- cd $(IMAGES) && $(PYTHON) ../../../$(UTILS)/sieve_search_draw.py \
+ cd $(IMAGES) && $(PYTHON) ../../../$(ANALYSIS)/sieve_search_draw.py \
--input ../../../$(CSV_UNBOUND) --figure focus --distance regular --annotate \
--x-size 0.74 --x-kg 2 --x-sg 2 --x-sv 0.8 --x-cb 1 \
--weights 5,1,1,2,1 --out ideal_candidate_balanced.png
- cd $(IMAGES) && $(PYTHON) ../../../$(UTILS)/sieve_search_draw.py \
+ cd $(IMAGES) && $(PYTHON) ../../../$(ANALYSIS)/sieve_search_draw.py \
--input ../../../$(CSV_UNBOUND) --figure focus --distance normalized --annotate \
--x-size 0.74 --x-kg 2 --x-sg 2 --x-sv 0.8 --x-cb 1 \
--weights 5,1,1,2,1 --out ideal_candidate_best.png
test:
- $(PYTHON) tests/test_regression.py
+ $(PYTHON) tests/run.py
check-local:
$(PYTHON) tests/synth_generated.py
- GENERATED_REQUIRED=1 $(PYTHON) tests/test_regression.py
+ GENERATED_REQUIRED=1 $(PYTHON) tests/run.py
fixtures:
$(PYTHON) tests/regen_fixtures.py $(if $(WRITE),--write)
@@ -82,7 +82,7 @@ site-snapshot:
clean-generated:
rm -f $(CSV_CAPPED) $(CSV_UNBOUND)
- rm -f $(UTILS)/global_X_thresholds.csv $(UTILS)/master_extremes_summary.csv
+ rm -f $(ANALYSIS)/global_X_thresholds.csv $(ANALYSIS)/master_extremes_summary.csv
rm -f site/data.json site/report.pdf
rm -f $(IMAGES)/sieve_step*.png $(IMAGES)/concentric_diagram.png $(IMAGES)/scatter_plot.png
rm -f $(IMAGES)/comparison_regular.png $(IMAGES)/comparison_normalized.png
diff --git a/README.md b/README.md
index 247e553..79acfa0 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,77 @@
# SPHINCS-Parameters
-Scripts for exploring SPHINCS+ parameter tradeoffs, computing security levels, signature sizes, and signing/verification costs.
-- [security.sage](security.sage) computes the security level for FORS and PORS+FP parameter sets
-- [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
-- [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)
-
-Contributing: run `make check-local` before pushing.
\ No newline at end of file
+
+Which hash-based signature parameters give Bitcoin the smallest post-quantum signatures that are
+still cheap to sign and verify? This repository holds the model, the parameter searches and the
+report behind the parameters of SHRINCS. It computes the size, costs and security of SLH-DSA and
+the variants W+C, W+C_F+C and W+C_P+FP, searches their parameters, and compares stateful schemes:
+XMSS, XMSS-MT and UXMSS, the SHRINCS stateful tree.
+
+- Site: [SHRINCS pinned parameters, Parameter Explorer and Stateful Comparison](https://blockstreamresearch.github.io/SPHINCS-Parameters/site/)
+- Report: [report.pdf](https://blockstreamresearch.github.io/SPHINCS-Parameters/site/report.pdf), from [document/](document/)
+- Background: [Searching for SHRINCS parameters](https://blog.blockstream.com/searching-for-shrincs-parameters/), Blockstream blog, August 2026
+
+## Units and hash model
+
+Sizes are in bytes and costs in SHA-256 compressions (C); ρ is SigVer compressions per signature
+byte. The hash output is n = 16 bytes. Hashing follows the FIPS 205 SHA-2 instantiation as SHRINCS
+runs it: the block that holds PK.seed is compressed once per key, so a chain step, a tree node or
+a PRF call costs one compression, H_msg four and PRF_msg three. [docs/model.md](docs/model.md)
+derives every formula.
+
+## Quick start
+
+Without Sage, with Python 3.12 and Node 22 as in CI:
+
+ make check-local
+
+This writes the sweep CSVs and `site/data.json` from `tests/model.py`, a Python port of the model,
+and runs the tests. It leaves out the W+C_P+FP pool, which needs Sage.
+
+With SageMath 10.4, pandas and seaborn (`sage -pip install pandas seaborn`):
+
+ make generate PYTHON="sage -python"
+
+This runs the steps of CI's generate job, then the tests, in a few minutes:
+
+1. `make sweeps`: `scripts/slhdsa-2to40.sage` evaluates the grid of `parameters.json` with the
+ model in `model/` and writes `analysis/all_size_capped_candidates.csv` and
+ `analysis/all_unbound_candidates.csv`.
+2. `make derived`: `analysis/min_mult_search.py` and `analysis/extremes_summary.py` write the
+ derived tables next to them.
+3. `make data`: `scripts/export_site_data.sage` writes `site/data.json` from the capped CSV, its
+ own sweeps of the optional w and the variants, and `model/stateful.sage`.
+4. `make figures`: the other `analysis/` scripts draw the report's figures into
+ `document/content/images/`.
+5. `make test`: `tests/run.py`.
+
+To open the pages, serve the repository (the pages fetch `site/data.json`), for example with
+`python3 -m http.server`, and go to http://localhost:8000/site/.
+
+## Layout
+
+- `parameters.json`: the search grids, the S_wn table, the UXMSS bounds, the security target and
+ the SLH-DSA-128s baseline.
+- `model/`: the model in Sage. `costs.sage` covers SLH-DSA and its variants, `security.sage` FORS
+ and PORS+FP, `stateful.sage` XMSS, XMSS-MT and UXMSS.
+- `scripts/`: the parameter sweep and the export of `site/data.json`.
+- `analysis/`: the derived tables and the report's figures.
+- `site/`: the three pages; `site/js/model.js` computes the same model in the browser.
+- `document/`: the report in LaTeX. CI builds it and publishes it with the site; a tag also
+ attaches it, `site/data.json` and the sweep CSVs to a release.
+- `tests/`: `model.py`, the tests by area (`python3 tests/run.py [model] [generated] [site] [sage]`),
+ the fixtures and the site snapshot.
+- `third_party/octopus_pmf.py`: the size distribution of the Octopus authentication set, for
+ PORS+FP (MIT, from [PORS-FP](https://github.com/MehdiAbri/PORS-FP)).
+- `reference/`: the parameter search script of the
+ [SPHINCS+ specification](https://sphincs.org/data/sphincs+-specification.pdf), updated for
+ SageMath 9.
+
+## Changing the model or the pages
+
+- `tests/fixtures.json` pins values of the model. `make fixtures` lists those that differ from the
+ code; after an intended change, `make fixtures WRITE=1` updates them.
+- Before pushing a change to `site/`, run `make site-snapshot`, which compares the numbers the
+ pages show with `tests/site_snapshot.json`. It needs `site/data.json` from either make target
+ above and, once, `npm install && npx playwright install chromium`. After an intended change,
+ `make site-snapshot UPDATE=1` records the new numbers.
+- Run `make check-local` before pushing.
diff --git a/utils/extremes_summary.py b/analysis/extremes_summary.py
similarity index 100%
rename from utils/extremes_summary.py
rename to analysis/extremes_summary.py
diff --git a/utils/min_mult_search.py b/analysis/min_mult_search.py
similarity index 74%
rename from utils/min_mult_search.py
rename to analysis/min_mult_search.py
index 84dbbd4..1c59a7c 100644
--- a/utils/min_mult_search.py
+++ b/analysis/min_mult_search.py
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
-"""
-Sweeps a global uniform filter X across 4 operational metrics.
-Finds the absolute minimum global X required to yield the smallest
-possible set of valid candidates.
-Aligned with run_experiments.py folder structure and venn_diagram_draw.py keys.
+"""For every candidate of a sweep CSV, the smallest X for which each of its
+metrics (size, KeyGen, SigGen, SigVer and C/byte) is at most X times that of
+the STANDARD row, and the metric that sets X. Writes the candidates by
+increasing X to global_X_thresholds.csv, which venn_diagram_draw.py reads.
+
+ cd analysis && python3 min_mult_search.py [--input CSV] [--output CSV]
"""
import argparse
@@ -27,26 +28,21 @@ def find_global_x_thresholds(csv_path, output_csv):
candidates = df[df['label'] != 'STANDARD'].copy()
- # 1. Calculate ratios against standard for the 4 metrics
for col in METRICS:
candidates[f'{col}_ratio'] = candidates[col] / std_row[col]
- # 2. Determine the minimum global X required for each candidate
ratio_columns = [f'{col}_ratio' for col in METRICS]
candidates['required_global_X'] = candidates[ratio_columns].max(axis=1)
- # 3. Identify the specific bottleneck metric that forced X to be this high
candidates['bottleneck_metric'] = candidates[ratio_columns].idxmax(axis=1).str.replace('_ratio', '')
- # 4. Sort by the required global X to find our expanding thresholds
candidates = candidates.sort_values(by='required_global_X', ascending=True).reset_index(drop=True)
- # 5. Build the output dataset with the explicit "cumulative_total" key
results = []
for i, row in candidates.iterrows():
- cumulative_total = i + 1 # 1 candidate, 2 candidates, 3 candidates...
+ cumulative_total = i + 1 # candidates with at most this X
results.append({
- 'cumulative_total': cumulative_total, # Renamed to match Venn Diagram script
+ 'cumulative_total': cumulative_total,
'required_global_X': round(row['required_global_X'], 4),
'bottleneck_metric': row['bottleneck_metric'],
'h': row['h'],
@@ -64,19 +60,16 @@ def find_global_x_thresholds(csv_path, output_csv):
results_df = pd.DataFrame(results)
- # 6. Ensure the target directory exists (Matching run_experiments behavior)
out_dir = os.path.dirname(output_csv)
if out_dir:
os.makedirs(out_dir, exist_ok=True)
- # 7. Save to CSV
results_df.to_csv(output_csv, index=False)
print("=========================================================================================")
print(f" GLOBAL X FILTER THRESHOLDS SAVED TO: {output_csv}")
print("=========================================================================================\n")
- # Print the first 10 rows to the terminal as a preview
print(results_df.head(10).to_string(index=False))
if __name__ == "__main__":
diff --git a/utils/plot_concept_scatter.py b/analysis/plot_concept_scatter.py
similarity index 74%
rename from utils/plot_concept_scatter.py
rename to analysis/plot_concept_scatter.py
index f87607c..1a72234 100644
--- a/utils/plot_concept_scatter.py
+++ b/analysis/plot_concept_scatter.py
@@ -1,8 +1,10 @@
#!/usr/bin/env python3
-"""
-Generates a conceptual scatter plot with explicit constraint zones.
-Includes a vector pointing to the valid candidate with the shortest Euclidean
-distance from the absolute origin (0, 0).
+"""Draws the report's conceptual scatter plot,
+conceptual_bounded_zones_origin_vector.png: random candidates over two metrics
+relative to SLH-DSA-128s, the zone within both bounds, and an arrow from the
+origin to the nearest candidate in it. No sweep data is read.
+
+ python3 plot_concept_scatter.py
"""
import matplotlib.pyplot as plt
@@ -14,39 +16,34 @@ def generate_bounded_plot():
sns.set_theme(style="whitegrid", context="paper")
fig, ax = plt.subplots(figsize=(10, 8))
- # 1. Define Constraints and Maximum Plot Area
anchor_x, anchor_y = 1.0, 1.0
limit_x = 3.0
limit_y = 2.0
max_val = 5.5
- # 2. Draw the Zones (Backgrounds)
- # Zone: Fails Y, Passes X (Yellow)
+ # Within one bound only: yellow
rect_top_left = patches.Rectangle((0, limit_y), limit_x, max_val - limit_y,
facecolor='#ffecb3', alpha=0.5, zorder=1)
ax.add_patch(rect_top_left)
- # Zone: Fails X, Passes Y (Yellow)
rect_bottom_right = patches.Rectangle((limit_x, 0), max_val - limit_x, limit_y,
facecolor='#ffecb3', alpha=0.5, zorder=1)
ax.add_patch(rect_bottom_right)
- # Zone: Valid Bounding Box (Green)
+ # Within both: green
rect_desired = patches.Rectangle((0, 0), limit_x, limit_y,
facecolor='#d4edda', edgecolor=None,
linewidth=2.5, alpha=0.6, zorder=2)
ax.add_patch(rect_desired)
- # Dashed threshold lines
ax.axvline(x=limit_x, color='black', linestyle='--', linewidth=1.5, zorder=3)
ax.axhline(y=limit_y, color='black', linestyle='--', linewidth=1.5, zorder=3)
- # 1.0x Standard Square
+ # SLH-DSA-128s, 1.0x on both axes
rect_standard = patches.Rectangle((0, 0), 1.0, 1.0, fill=False,
edgecolor='gray', linestyle=':', linewidth=1.5, zorder=1)
ax.add_patch(rect_standard)
- # 3. Generate the Scatter Points
np.random.seed(42)
utopian_x = np.random.uniform(0.4, 0.95, 15)
@@ -64,7 +61,7 @@ def generate_bounded_plot():
if x <= 1.0 and y <= 1.0:
pass
else:
- # Create a conceptual pareto frontier curve so points don't collapse too close to (0,0)
+ # Keep the points above a frontier, away from the origin
min_y = 1.0 / (x + 0.1) + 0.15
if y < min_y:
y = min_y + np.random.uniform(0.1, 0.3)
@@ -76,41 +73,30 @@ def generate_bounded_plot():
invalid_x.append(x)
invalid_y.append(y)
- # 4. Plot the Regular Points
ax.scatter(invalid_x, invalid_y, alpha=0.4, color='#d9534f', edgecolor='white',
s=70, linewidth=1.0, label="Invalid Candidates", zorder=4)
ax.scatter(valid_x, valid_y, alpha=0.9, color='#0275d8', edgecolor='white',
s=70, linewidth=1.0, label="Valid Candidates (Acceptable)", zorder=4)
- # 5. Plot the Standard Anchor
ax.scatter(anchor_x, anchor_y, color='#f0ad4e', marker='*', s=250,
edgecolor='black', linewidth=1.5, zorder=5, label="Standard Anchor (1.0x)")
- # =========================================================================
- # NEW LOGIC: FIND SHORTEST DISTANCE VECTOR FROM (0,0)
- # =========================================================================
origin_x, origin_y = 0.0, 0.0
-
+
if valid_x:
- # Calculate Euclidean distances from the Absolute Origin (0, 0)
dists = [np.sqrt((x - origin_x)**2 + (y - origin_y)**2) for x, y in zip(valid_x, valid_y)]
- # Isolate the point with the absolute minimum distance
min_idx = np.argmin(dists)
closest_x, closest_y = valid_x[min_idx], valid_y[min_idx]
- # Draw a bold vector (arrow) pointing from (0,0) to this nearest candidate
ax.annotate('', xy=(closest_x, closest_y), xytext=(origin_x, origin_y),
arrowprops=dict(facecolor='black', edgecolor='black', width=1.5, headwidth=8, shrink=0.0),
zorder=6)
- # Highlight this specific candidate to make the shortest-distance end point pop
ax.scatter(closest_x, closest_y, color='#5cb85c', s=100, edgecolor='black',
linewidth=1.5, zorder=7, label=f"Optimal Point (Closest to Origin)")
- # =========================================================================
- # 6. Formatting
ax.set_title("Signature Size vs Signing Cost\n(Showing Vector to Globally Optimal Candidate)",
fontsize=14, pad=15)
ax.set_xlabel(r" Signature Size ($X \cdot$ Std)", fontsize=12, fontweight='bold')
@@ -119,11 +105,10 @@ def generate_bounded_plot():
ax.set_xlim(0, max_val)
ax.set_ylim(0, max_val)
- # Move legend to not overlap with the new vector
+ # Upper right keeps the legend off the arrow
ax.legend(loc='upper right', fontsize=11, frameon=True, shadow=True)
plt.tight_layout()
- # Save output
filename = "conceptual_bounded_zones_origin_vector.png"
plt.savefig(filename, dpi=300, bbox_inches='tight')
print(f"Successfully generated bounded zone diagram: {filename}")
diff --git a/utils/sieve_search_draw.py b/analysis/sieve_search_draw.py
similarity index 96%
rename from utils/sieve_search_draw.py
rename to analysis/sieve_search_draw.py
index aac112f..66851d9 100644
--- a/utils/sieve_search_draw.py
+++ b/analysis/sieve_search_draw.py
@@ -1,5 +1,12 @@
#!/usr/bin/env python3
+"""Draws the report's sieve figures from a sweep CSV: the candidates filtered
+metric by metric against X times SLH-DSA-128s (--x-*), one image per stage,
+and their weighted distance to the origin (--weights). --figure focus draws
+only the distance view of the survivors, regular or metric-normalised. The
+Makefile's figures target runs it from document/content/images.
+ python3 sieve_search_draw.py --input CSV [--x-size X ...] [--weights W,W,W,W,W]
+"""
import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
@@ -9,15 +16,9 @@
import argparse
from scipy.spatial.distance import pdist, squareform
-np.random.seed(42)
+np.random.seed(42) # the same placement on every run
-METRIC_WEIGHTS = {
- 'size': 15.0,
- 'keygen': 0.1,
- 'sign': 0.1,
- 'verify': 15.0,
- 'rho': 0.1
-}
+METRIC_WEIGHTS = None # from --weights
def load_and_preprocess_data(csv_path):
if not os.path.exists(csv_path):
@@ -294,9 +295,6 @@ def main():
print("\n -> No survivors left to calculate Euclidean optimum.")
if args.figure == "focus":
- # Single distance view of the final survivors (replaces the manual
- # site screenshots previously used for the report's comparison and
- # candidate figures).
assert args.out, "--figure focus requires --out"
assert len(final_survivors) > 0, "no survivors under these filters"
label = "Regular Weighted Distance"
diff --git a/utils/venn_diagram_draw.py b/analysis/venn_diagram_draw.py
similarity index 72%
rename from utils/venn_diagram_draw.py
rename to analysis/venn_diagram_draw.py
index 1a07d87..36f6a47 100644
--- a/utils/venn_diagram_draw.py
+++ b/analysis/venn_diagram_draw.py
@@ -1,8 +1,9 @@
#!/usr/bin/env python3
-"""
-Reads the exact required_global_X multipliers from global_X_thresholds.csv
-and generates a Concentric Euler Diagram strictly mapping the requested bounds:
-[Smallest X, 0.9x, 1.0x, 2.0x, 5.0x, 10.0x, 15.0x]
+"""Draws the report's concentric diagram, euler_custom_minimax.png: how many
+candidates of global_X_thresholds.csv (min_mult_search.py) have all metrics
+within X times SLH-DSA-128s, for the smallest X and for TARGET_MULTIPLIERS.
+
+ cd analysis && python3 venn_diagram_draw.py
"""
import os
@@ -11,11 +12,11 @@
import matplotlib.pyplot as plt
import matplotlib.patches as patches
-# The target bounds you requested
+# The rings after the smallest X
TARGET_MULTIPLIERS = [0.9, 1.0, 2.0, 5.0, 10.0, 15.0]
def draw_custom_euler(factors, counts):
- """Renders the Concentric Euler Diagram for the calculated thresholds."""
+ """Draws one ring per factor, the innermost first in factors."""
fig, ax = plt.subplots(figsize=(12, 10))
ax.set_aspect('equal')
@@ -23,7 +24,7 @@ def draw_custom_euler(factors, counts):
max_radius = 0.45
radii = [max_radius * (i + 1) / N for i in range(N)]
- # 7-layer sequential palette (Light Yellow -> Deep Orange/Brown)
+ # Light yellow on the outside to dark brown at the centre
palette = ['#fff7bc', '#fee391', '#fec44f', '#fe9929', '#ec7014', '#cc4c02', '#8c2d04']
for i in range(N - 1, -1, -1):
@@ -39,7 +40,7 @@ def draw_custom_euler(factors, counts):
)
ax.add_patch(circle)
- # Calculate how many NEW candidates were unlocked in this specific ring
+ # Candidates this ring adds
added = counts[i] if i == 0 else counts[i] - counts[i-1]
txt = f"{factors[i]} Cap | Total: {counts[i]} candidates (+{added} new)"
@@ -47,7 +48,6 @@ def draw_custom_euler(factors, counts):
x_target = 0.64 if i == 0 else 0.64 + (radii[i] + radii[i-1]) / 6
y_target = 0.525 if i == 0 else 0.5 + (radii[i] + radii[i-1]) / 2.05
- # Point the arrow slightly inwards to the left
ax.annotate(
txt,
xy=(0.51, y_target),
@@ -61,10 +61,7 @@ def draw_custom_euler(factors, counts):
ax.set_xlim(0, 1.35)
ax.set_ylim(0, 1)
ax.axis('off')
-
- #ax.set_title("Global Minimax Expansion: Candidates Unlocked by Uniform Bounding",
- # fontsize=16, fontweight='bold', pad=20)
-
+
out_path = "euler_custom_minimax.png"
plt.savefig(out_path, dpi=300, bbox_inches='tight')
plt.close()
@@ -79,29 +76,23 @@ def main():
df = pd.read_csv(csv_file)
- # 1. Find the absolute minimum X in the dataset
min_x = df['required_global_X'].min()
print(f"[*] Smallest possible X discovered: {min_x:.4f}x")
- # 2. Build our full list of target thresholds
thresholds = [min_x] + TARGET_MULTIPLIERS
factors = []
counts = []
- # 3. Calculate how many candidates survive at each expanding threshold
for target in thresholds:
- # Count all rows where the required X is less than or equal to our target bound
surviving_count = len(df[df['required_global_X'] <= target])
- # Format the label nicely
label = f"{target:.4f}x" if target == min_x else f"{target}x"
factors.append(label)
counts.append(surviving_count)
print(f" -> At <= {label}: {surviving_count} candidates")
- # 4. Draw it!
draw_custom_euler(factors, counts)
if __name__ == "__main__":
diff --git a/costs.sage b/costs.sage
deleted file mode 100644
index a29c73a..0000000
--- a/costs.sage
+++ /dev/null
@@ -1,845 +0,0 @@
-#!/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
-"""
-
-from sage.all import *
-from scipy.stats import binom
-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
-PARAMETER_SETS = [
- # Table 1: 2^64 signatures (SPX)
- ("SPX", 64, 63, 7, 12, 14, 16, 35, 0, True),
-
- # Table 1: 2^40 signatures
- # W+C (5 rows)
- ("W+C", 40, 44, 4, 16, 8, 16, 32, 240, False),
- ("W+C", 40, 44, 4, 16, 8, 16, 32, 304, False),
- ("W+C", 40, 44, 4, 16, 8, 256, 16, 2040, False),
- ("W+C", 40, 40, 5, 14, 11, 256, 16, 2040, False),
- ("W+C", 40, 40, 5, 14, 11, 256, 16, 2840, False),
- # W+C F+C (2 rows)
- ("W+C_F+C", 40, 44, 4, 16, 8, 16, 32, 240, False),
- ("W+C_F+C", 40, 40, 5, 14, 11, 256, 16, 2040, True),
- # W+C P+FP (2 rows)
- ("W+C_P+FP", 40, 44, 4, 16, 8, 16, 32, 240, False),
- ("W+C_P+FP", 40, 40, 5, 14, 11, 256, 16, 2040, True),
-
- # Table 1: 2^30 signatures
- # W+C (6 rows)
- ("W+C", 30, 36, 3, 14, 9, 16, 32, 240, False),
- ("W+C", 30, 33, 3, 15, 9, 16, 32, 240, False),
- ("W+C", 30, 33, 3, 15, 9, 16, 32, 304, False),
- ("W+C", 30, 33, 3, 15, 9, 256, 16, 2040, False),
- ("W+C", 30, 32, 4, 14, 10, 256, 16, 2040, False),
- ("W+C", 30, 32, 4, 14, 10, 256, 16, 2840, False),
- # W+C F+C (4 rows)
- ("W+C_F+C", 30, 36, 3, 14, 9, 16, 32, 240, False),
- ("W+C_F+C", 30, 33, 3, 15, 9, 16, 32, 240, False),
- ("W+C_F+C", 30, 33, 3, 15, 9, 256, 16, 2040, False),
- ("W+C_F+C", 30, 32, 4, 14, 10, 256, 16, 2040, True),
- # W+C P+FP (3 rows)
- ("W+C_P+FP", 30, 36, 3, 14, 9, 16, 32, 240, False),
- ("W+C_P+FP", 30, 33, 3, 15, 9, 16, 32, 240, False),
- ("W+C_P+FP", 30, 32, 4, 14, 10, 256, 16, 2040, True),
-
- # Table 2: 2^20 signatures (grouped by h)
- # h=24
- ("W+C", 20, 24, 2, 16, 8, 16, 32, 240, False),
- ("W+C", 20, 24, 2, 16, 8, 256, 16, 2040, False),
- ("W+C_F+C", 20, 24, 2, 16, 8, 16, 32, 240, False),
- ("W+C_P+FP", 20, 24, 2, 16, 8, 16, 32, 240, False),
- # h=20
- ("W+C", 20, 20, 2, 15, 10, 256, 16, 2040, False),
- ("W+C_F+C", 20, 20, 2, 15, 10, 256, 16, 2040, False),
- ("W+C_P+FP", 20, 20, 2, 15, 10, 256, 16, 2040, False),
-]
-
-# =============================================================================
-# Helper Functions
-# =============================================================================
-
-# High precision arithmetic
-F = RealField(100)
-
-# -----------------------------------------------------------------------------
-# WOTS helpers
-# -----------------------------------------------------------------------------
-
-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))
-
-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.
- """
- C = l1 * (w - 1)
- ds, rem = 0, int(C)
- while rem > 0:
- ds += rem % int(w)
- rem //= int(w)
- return int(l1) * (int(w) - 1) + int(l2) * (int(w) - 1) - ds
-
-def _round_div(num, den):
- """Nearest integer to num/den for non-negative integers (ties round up)."""
- return (2*num + den) // (2*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).
- """
- l1, l2, w = int(l1), int(l2), int(w)
- key = (l1, l2, w)
- if key in _wots_tw_floor_cache:
- return _wots_tw_floor_cache[key]
- top = l1 * (w - 1)
- W = w**l1
-
- def count_le(t):
- total = 0
- for i in range(min(l1, t // w) + 1):
- total += (-1)**i * binomial(l1, i) * binomial(t - i*w + l1, l1)
- return total
-
- acc = 0
- for j in range(1, l2):
- step = w**j
- for m in range(1, top // step + 1):
- acc += W - count_le(m*step - 1)
- _wots_tw_floor_cache[key] = acc
- 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)
- 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)
-
-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.
- """
- nu = 0
- for j in range(l + 1):
- sign = (-1) ** j
- binom1 = binomial(l, j)
- n = (paramsum + l) - j * w - 1
- binom2 = binomial(n, l - 1) if n >= l - 1 and l - 1 >= 0 else 0
- nu += sign * binom1 * binom2
- if nu == 0:
- print(f"Warning: ν computed as zero for l={l}, paramsum={paramsum}, w={w}. This may lead to division by zero.")
- nu = 1 # Avoid division by zero
- return nu
-
-# -----------------------------------------------------------------------------
-# PORS helpers
-# -----------------------------------------------------------------------------
-
-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 = k * (2**a)
- subtree_height = floor(log(t, 2))
- extra_leaves = t - 2**subtree_height
- return t, subtree_height, extra_leaves
-
-_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.
- """
- key = (int(t), int(k))
- table = _interleave_table_cache.get(key)
- if table is None:
- table = dict(interleave_cost_table(*key))
- _interleave_table_cache[key] = table
- if mmax in table:
- return table[mmax]
- lowers = [m for m in table if m <= mmax]
- if not lowers:
- raise ValueError("mmax below supported range for these (t,k).")
- return table[max(lowers)]
-
-def exp_work_from_mmax(t, k, mmax):
- """Compute expected work (attempts) for PORS+FP grinding given mmax."""
- 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
-# =============================================================================
-
-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
- """
- 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
- 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.
- """
- Thl = compute_Th(l)
- Thk1 = compute_Th(k-1) # FORS+C has k-1 roots
- 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)
- for i in range(20):
- pors_search_attempts = ceil(exp_work_from_mmax(t, k, mmax))
- pors_expected_search_time = pors_search_attempts*(C_Hmsg + C_PRFmsg)
- pors_expected_total_time = pors_fixed_part_time + pors_expected_search_time
- spx_pors_expected_total_time = hyper_tree_expected_time + pors_expected_total_time
- ratio = spx_pors_expected_total_time/spx_fc_expected_total_time
- if (ratio < 1.11 or i == 19):
- return mmax
- mmax += 1
- return mmax
-
-
-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.
- """
- assert h % d == 0, "h must be divisible by d"
- h_prime = h // d
- l = compute_wots_l(scheme, w)
- Thl = compute_Th(l)
-
- return 2**h_prime * (l*C_PRF + l*(w-1)*C_Th1 + Thl) + (2**h_prime - 1)*C_Th2
-
-
-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)
- """
- assert h % d == 0, "h must be divisible by d"
- has_wc = (scheme != "SPX")
- h_prime = h // d
- l = compute_wots_l(scheme, w)
- Thl = compute_Th(l)
- 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)
- 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
- 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
- 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
- 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
- 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
- """
- 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.
- 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.
- 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
- 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
- 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
-# =============================================================================
-
-def compute_all_results():
- """Compute results for all parameter sets. Returns list of dicts."""
- 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}"
-
- sign = compute_signing_time(h, d, a, k, w, swn, scheme)
- verify = compute_verification_time(h, d, a, k, w, swn, scheme, sign['mmax'])
- keygen_compressions = compute_keygen_time(h, d, w, scheme)
- size = compute_size(h, d, a, k, w, scheme, sign['mmax'])
- compressions_per_byte = float(verify['compressions']) / float(size)
- compressions_per_byte_worst = float(verify['compressions_worst']) / float(size)
-
- results.append({
- 'scheme': scheme,
- 'q_s': q_s,
- 'h': h,
- 'd': d,
- 'a': a,
- 'k': k,
- 'w': w,
- 'l': l,
- '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,
- 'compressions_per_byte_worst': compressions_per_byte_worst,
- 'bold': bold,
- })
- return 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")
-
- 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}")
-
-
-def format_num(n):
- """Format large numbers with K/M suffixes for readability."""
- n = float(n) # Convert Sage types to Python float
- if n >= 1_000_000:
- return f"{n/1_000_000:.1f}M"
- elif n >= 1_000:
- return f"{n/1_000:.1f}K"
- else:
- return str(int(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)
- """
- results = compute_all_results()
-
- if q_s_filter is not None:
- results = [r for r in results if r['q_s'] == q_s_filter]
-
- if not results:
- print(f"No parameter sets found for q_s=2^{q_s_filter}")
- return
-
- # Group results by q_s for section headers
- q_s_groups = {}
- for r in results:
- q_s_groups.setdefault(r['q_s'], []).append(r)
-
- # Column definitions: (header, key, width, format_func)
- columns = [
- ("Scheme", 'scheme', 10, str),
- ("h", 'h', 4, str),
- ("d", 'd', 3, str),
- ("a", 'a', 3, str),
- ("k", 'k', 3, str),
- ("w", 'w', 4, str),
- ("l", 'l', 3, str),
- ("S_wn", 'swn', 5, str),
- ("Size", 'size', 6, str),
- ("Keygen(C)", 'keygen_compressions', 10, format_num),
- ("Sign(C)", 'sign_compressions', 9, format_num),
- ("Verify(C)", 'verify_compressions', 10, format_num),
- ("C/byte", 'compressions_per_byte', 6, lambda x: f"{x:.2f}"),
- ]
-
- # Calculate total width
- total_width = sum(w for _, _, w, _ in columns) + len(columns) - 1
-
- for q_s in sorted(q_s_groups.keys(), reverse=True):
- group = q_s_groups[q_s]
-
- # Section header
- print()
- print("=" * total_width)
- print(f" 2^{q_s} signatures ".center(total_width, "="))
- print("=" * total_width)
-
- # Column headers
- header_line = " ".join(h.center(w) for h, _, w, _ in columns)
- print(header_line)
- print("-" * total_width)
-
- # Data rows
- for r in group:
- row_parts = []
- for _, key, width, fmt in columns:
- val = fmt(r[key])
- row_parts.append(val.rjust(width) if key != 'scheme' else val.ljust(width))
- print(" ".join(row_parts))
-
- print()
- 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."""
- 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)
- 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)
- keygen_compressions = compute_keygen_time(h, d, w, scheme)
- size = compute_size(h, d, a, k, w, scheme, mmax)
- c_per_byte = float(verify['compressions']) / float(size)
-
- print("Scheme: " + scheme)
- print("q_s: 2^" + str(q_s_log2))
- print("(k,a,H,d): (" + str(int(k)) + ", " + str(int(a)) + ", " + str(int(h)) + ", " + str(int(d)) + ")")
- print("w: " + str(int(w)))
- print("S_wn: " + str(int(swn)))
- print("l: " + str(int(l)))
- print("mmax: " + str(int(mmax)))
- print("Security: " + "{:.1f}".format(security) + " bits")
- print("Size: " + str(int(size)) + " bytes")
- print("Keygen(C): " + format_num(keygen_compressions))
- print("Sign(C): " + format_num(sign['compressions']))
- print("Verify(C): " + format_num(verify['compressions']) + " (worst: " + format_num(verify['compressions_worst']) + ")")
- 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__').
-if __name__ == "__main__" and not os.environ.get("COSTS_SAGE_NO_MAIN"):
- import sys
-
- if len(sys.argv) > 1 and sys.argv[1] == "--table":
- # Optional: --table 40 to filter by q_s
- q_s_filter = int(sys.argv[2]) if len(sys.argv) > 2 else None
- generate_table(q_s_filter)
- elif len(sys.argv) > 1 and sys.argv[1] == "--params":
- # Usage: sage costs.sage --params scheme q_s k a h d w swn
- if len(sys.argv) != 10:
- print("Usage: sage costs.sage --params scheme q_s k a h d w swn")
- print(" scheme: SPX, W+C, W+C_F+C, W+C_P+FP")
- print(" q_s: log2 of max signatures (e.g., 10 for 2^10)")
- print("Example: sage costs.sage --params W+C_P+FP 10 8 17 12 1 16 240")
- sys.exit(1)
- scheme = sys.argv[2]
- q_s_log2 = int(sys.argv[3])
- k = int(sys.argv[4])
- a = int(sys.argv[5])
- h = int(sys.argv[6])
- d = int(sys.argv[7])
- w = int(sys.argv[8])
- swn = int(sys.argv[9])
- compute_single(scheme, q_s_log2, h, d, a, k, w, swn)
- elif len(sys.argv) > 1 and sys.argv[1] == "--help":
- print("Usage: sage costs.sage [OPTIONS]")
- print()
- print("Options:")
- print(" (no args) Output CSV format")
- print(" --table [N] Output pretty ASCII table [filtered to 2^N]")
- print(" --params scheme q_s k a h d w swn Compute single parameter set")
- print(" --help Show this help message")
- print()
- print("Example:")
- print(" sage costs.sage --params W+C_P+FP 10 8 17 12 1 16 240")
- else:
- generate_csv()
diff --git a/docs/model.md b/docs/model.md
new file mode 100644
index 0000000..bf4356c
--- /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 `model/costs.sage`,
+`model/stateful.sage` and `model/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;
+ `third_party/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
+(`reference/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)
+
+`model/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/2-parameters.tex b/document/content/2-parameters.tex
index ea76715..08946b6 100644
--- a/document/content/2-parameters.tex
+++ b/document/content/2-parameters.tex
@@ -43,6 +43,6 @@ \subsection{SLH-DSA Parameters}
(h, d, k, a, w) \in \{40, \dots, 50\} \times \{2, \dots, 25\} \times \{6, \dots, 24\} \times \{8, \dots, 20\} \times \{2, 4, 16, 256\}.
\end{equation*}
-The lower bound $k \geq 6$ discards only insecure configurations: with $a \leq 20$ and $h \leq 50$, any $k \leq 5$ falls short of the 128-bit security floor at $q_s = 2^{40}$, so smaller values of $k$ never survive the security filter. The same grid is used by the sweep script (\texttt{slhdsa-2to40.sage}) and the interactive explorer, so the counts reported below are reproducible in both.
+The lower bound $k \geq 6$ discards only insecure configurations: with $a \leq 20$ and $h \leq 50$, any $k \leq 5$ falls short of the 128-bit security floor at $q_s = 2^{40}$, so smaller values of $k$ never survive the security filter. The same grid is used by the sweep script (\texttt{scripts/slhdsa-2to40.sage}) and the interactive explorer, so the counts reported below are reproducible in both.
\textit{The SLH-DSA-128s baseline tuple (63, 7, 14, 12, 16) sits outside this range and is included for comparison}
\ No newline at end of file
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
deleted file mode 100644
index b3a5b18..0000000
--- a/export_site_data.sage
+++ /dev/null
@@ -1,291 +0,0 @@
-#!/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
-"""
-import os
-import sys
-import csv as _csvmod
-import json
-import subprocess
-import datetime
-
-_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
-
-ALL_SCHEMES = ['SPX', 'W+C', 'W+C_F+C', 'W+C_P+FP']
-schemes = ALL_SCHEMES
-_args = _saved_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__)
- sys.exit(0)
- else:
- print("Unknown argument: {}".format(_args[i]), file=sys.stderr)
- sys.exit(2)
-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
-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]
-
-
-# ---------------------------------------------------------------------------
-# SPX pool: straight from the canonical sweep CSV
-# ---------------------------------------------------------------------------
-csv_path = os.path.join(_dir, 'utils', 'all_size_capped_candidates.csv')
-spx_rows, baseline = [], None
-with open(csv_path) as f:
- for r in _csvmod.DictReader(f):
- rec = [int(r['h']), int(r['d']), int(r['k']), int(r['a']), int(r['w']),
- 0, 0,
- 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},
- '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'])])
- 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)
- all_spx = spx_rows + spx_extra
- pools['SPX'] = {'fields': POOL_FIELDS, 'considered': spx_considered,
- 'count': len(all_spx), 'rows': all_spx}
- print("SPX: {} rows ({} core from CSV + {} optional-w)".format(
- len(all_spx), len(spx_rows), len(spx_extra)), file=sys.stderr)
-for scheme in schemes:
- if scheme == 'SPX':
- continue
- rows, considered = sweep_scheme(scheme)
- 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
-
-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):
- if h % d != 0:
- continue
- xmssmt_rows.append([
- ots_label, h, d, w,
- int(xmssmt_size_h(h, d, w, ots_type)),
- int(xmssmt_keygen_C_h(h, d, w, ots_type)),
- int(xmssmt_sign_bds_C_h(h, d, w, swn, ots_type)),
- int(xmssmt_sign_cold_C_h(h, d, w, swn, ots_type)),
- int(xmssmt_state_bytes(h, d, w, ots_type)),
- int(xmssmt_verify_C_h(h, d, w, swn, ots_type, worst_case=False)),
- int(xmssmt_verify_C_h(h, d, w, swn, ots_type, worst_case=True)),
- ])
-
-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)
- uxmss_rows.append([
- ots_label, w, int(hsf),
- int(uxmss_size(1, hsf, w, ots_type)),
- int(uxmss_size(hsf, hsf, w, ots_type)),
- int(uxmss_keygen_C(hsf, w, ots_type)),
- int(uxmss_sign_C(1, hsf, w, swn, ots_type)),
- int(uxmss_sign_cold_C(hsf, w, swn, ots_type)),
- int(uxmss_state_bytes(hsf)),
- int(uxmss_verify_C(hsf, hsf, w, swn, ots_type, worst_case=False)),
- int(uxmss_verify_C(hsf, hsf, w, swn, ots_type, worst_case=True)),
- ])
-
-
-def _commit():
- try:
- return subprocess.check_output(
- ['git', 'rev-parse', '--short', 'HEAD'],
- cwd=_dir, stderr=subprocess.DEVNULL).decode().strip()
- except Exception:
- sha = os.environ.get('GITHUB_SHA', '')
- return sha[:7] if sha else 'unknown'
-
-
-data = {
- 'meta': {
- 'version': 3,
- 'generator': 'export_site_data.sage',
- 'generated': datetime.date.today().isoformat(),
- 'commit': _commit(),
- 'grid': GRID,
- 'w_optional': W_OPTIONAL,
- 'wc_pairs': WC_PAIRS,
- 'schemes': [s for s in ALL_SCHEMES if s in pools],
- 'uxmss_ref_size': UXMSS_REF,
- 'hsf_max': int(HSF_MAX),
- 'cost_convention': HASH_CONVENTION,
- },
- '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},
- 'xmssmt': {
- 'fields': ['ots', 'h', 'd', 'w', 'size', 'kg', 'sg', 'sg_cold',
- 'state', 'sv', 'sv_worst'],
- 'rows': xmssmt_rows,
- },
- 'uxmss': {
- 'fields': ['ots', 'w', 'hsf', 'sz_q1', 'sz_max', 'kg', 'sg',
- 'sg_cold', 'state', 'sv_max', 'sv_max_worst'],
- 'rows': uxmss_rows,
- },
- },
-}
-
-out_path = os.path.join(_dir, 'site', 'data.json')
-with open(out_path, 'w') as f:
- # default=int: the sage preparser turns integer literals into sage
- # Integers, which the json module cannot serialize directly.
- json.dump(data, f, separators=(',', ':'), default=int)
-print("Wrote {} — schemes: {} — commit {}".format(
- out_path, ', '.join('%s:%d' % (s, pools[s]['count']) for s in pools),
- data['meta']['commit']))
diff --git a/model/costs.sage b/model/costs.sage
new file mode 100644
index 0000000..434bba6
--- /dev/null
+++ b/model/costs.sage
@@ -0,0 +1,610 @@
+#!/usr/bin/env sage
+"""
+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 model/costs.sage [--table [log2 q_s]] (those sets as CSV, or as tables)
+ sage model/costs.sage --params scheme log2_q_s k a h d w swn
+"""
+
+from sage.all import *
+from functools import lru_cache
+import json
+import os
+import sys
+
+# Every script sits one folder below the repository root, and load() keeps the
+# __file__ of the script that was started, so this holds in every script.
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(ROOT, "third_party"))
+from octopus_pmf import interleave_cost_table
+
+load(os.path.join(ROOT, "model", "security.sage"))
+
+# 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(ROOT, "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),
+
+ # Table 1: 2^40 signatures
+ # W+C (5 rows)
+ ("W+C", 40, 44, 4, 16, 8, 16, 32, 240, False),
+ ("W+C", 40, 44, 4, 16, 8, 16, 32, 304, False),
+ ("W+C", 40, 44, 4, 16, 8, 256, 16, 2040, False),
+ ("W+C", 40, 40, 5, 14, 11, 256, 16, 2040, False),
+ ("W+C", 40, 40, 5, 14, 11, 256, 16, 2840, False),
+ # W+C F+C (2 rows)
+ ("W+C_F+C", 40, 44, 4, 16, 8, 16, 32, 240, False),
+ ("W+C_F+C", 40, 40, 5, 14, 11, 256, 16, 2040, True),
+ # W+C P+FP (2 rows)
+ ("W+C_P+FP", 40, 44, 4, 16, 8, 16, 32, 240, False),
+ ("W+C_P+FP", 40, 40, 5, 14, 11, 256, 16, 2040, True),
+
+ # Table 1: 2^30 signatures
+ # W+C (6 rows)
+ ("W+C", 30, 36, 3, 14, 9, 16, 32, 240, False),
+ ("W+C", 30, 33, 3, 15, 9, 16, 32, 240, False),
+ ("W+C", 30, 33, 3, 15, 9, 16, 32, 304, False),
+ ("W+C", 30, 33, 3, 15, 9, 256, 16, 2040, False),
+ ("W+C", 30, 32, 4, 14, 10, 256, 16, 2040, False),
+ ("W+C", 30, 32, 4, 14, 10, 256, 16, 2840, False),
+ # W+C F+C (4 rows)
+ ("W+C_F+C", 30, 36, 3, 14, 9, 16, 32, 240, False),
+ ("W+C_F+C", 30, 33, 3, 15, 9, 16, 32, 240, False),
+ ("W+C_F+C", 30, 33, 3, 15, 9, 256, 16, 2040, False),
+ ("W+C_F+C", 30, 32, 4, 14, 10, 256, 16, 2040, True),
+ # W+C P+FP (3 rows)
+ ("W+C_P+FP", 30, 36, 3, 14, 9, 16, 32, 240, False),
+ ("W+C_P+FP", 30, 33, 3, 15, 9, 16, 32, 240, False),
+ ("W+C_P+FP", 30, 32, 4, 14, 10, 256, 16, 2040, True),
+
+ # Table 2: 2^20 signatures (grouped by h)
+ # h=24
+ ("W+C", 20, 24, 2, 16, 8, 16, 32, 240, False),
+ ("W+C", 20, 24, 2, 16, 8, 256, 16, 2040, False),
+ ("W+C_F+C", 20, 24, 2, 16, 8, 16, 32, 240, False),
+ ("W+C_P+FP", 20, 24, 2, 16, 8, 16, 32, 240, False),
+ # h=20
+ ("W+C", 20, 20, 2, 15, 10, 256, 16, 2040, False),
+ ("W+C_F+C", 20, 20, 2, 15, 10, 256, 16, 2040, False),
+ ("W+C_P+FP", 20, 20, 2, 15, 10, 256, 16, 2040, False),
+]
+
+# WOTS
+
+@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):
+ """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):
+ """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:
+ ds += rem % int(w)
+ rem //= int(w)
+ return int(l1) * (int(w) - 1) + int(l2) * (int(w) - 1) - ds
+
+def _round_div(num, den):
+ """Nearest integer to num/den for non-negative integers (ties round up)."""
+ return (2*num + den) // (2*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, 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:
+ return _wots_tw_floor_cache[key]
+ top = l1 * (w - 1)
+ W = w**l1
+
+ def count_le(t):
+ total = 0
+ for i in range(min(l1, t // w) + 1):
+ total += (-1)**i * binomial(l1, i) * binomial(t - i*w + l1, l1)
+ return total
+
+ acc = 0
+ for j in range(1, l2):
+ step = w**j
+ for m in range(1, top // step + 1):
+ acc += W - count_le(m*step - 1)
+ _wots_tw_floor_cache[key] = acc
+ return acc
+
+def compute_wots_tw_mean_steps(l1, l2, w, count=1):
+ """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)
+ 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:
+ """ν, 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
+ binom1 = binomial(l, j)
+ n = (paramsum + l) - j * w - 1
+ binom2 = binomial(n, l - 1) if n >= l - 1 and l - 1 >= 0 else 0
+ nu += sign * binom1 * binom2
+ if nu == 0:
+ print(f"Warning: ν computed as zero for l={l}, paramsum={paramsum}, w={w}. This may lead to division by zero.")
+ nu = 1 # Avoid division by zero
+ return nu
+
+# PORS+FP
+
+def compute_pors_tree_geometry(k, a):
+ """(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
+ return t, subtree_height, extra_leaves
+
+_interleave_table_cache = {}
+
+def log2_exp_work_from_mmax(t, k, mmax):
+ """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:
+ table = dict(interleave_cost_table(*key))
+ _interleave_table_cache[key] = table
+ if mmax in table:
+ return table[mmax]
+ lowers = [m for m in table if m <= mmax]
+ if not lowers:
+ raise ValueError("mmax below supported range for these (t,k).")
+ return table[max(lowers)]
+
+def exp_work_from_mmax(t, k, mmax):
+ """Expected attempts for PORS+FP grinding to at most mmax nodes."""
+ return 2.0 ** log2_exp_work_from_mmax(t, k, mmax)
+
+# Sizes and costs (docs/model.md, SLH-DSA)
+
+def compute_size(h, d, a, k, w, scheme, mmax=0):
+ """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
+ hyper_tree_size = d*(h_prime*hashbytes + l*hashbytes + counter_size*int(has_wc))
+ if scheme == "W+C_P+FP":
+ fts_size = (k + mmax)*hashbytes
+ elif scheme == "W+C_F+C":
+ fts_size = (k-1)*hashbytes + (k-1)*a*hashbytes
+ 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):
+ """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)
+ 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
+ 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
+ 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)
+ for i in range(20):
+ pors_search_attempts = ceil(exp_work_from_mmax(t, k, mmax))
+ pors_expected_search_time = pors_search_attempts*(C_Hmsg + C_PRFmsg)
+ pors_expected_total_time = pors_fixed_part_time + pors_expected_search_time
+ spx_pors_expected_total_time = hyper_tree_expected_time + pors_expected_total_time
+ ratio = spx_pors_expected_total_time/spx_fc_expected_total_time
+ if (ratio < 1.11 or i == 19):
+ return mmax
+ mmax += 1
+ return mmax
+
+
+def compute_keygen_time(h, d, w, scheme):
+ """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)
+ Thl = compute_Th(l)
+
+ return 2**h_prime * (l*C_PRF + l*(w-1)*C_Th1 + Thl) + (2**h_prime - 1)*C_Th2
+
+
+def compute_signing_time(h, d, a, k, w, swn, scheme):
+ """{'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
+ l = compute_wots_l(scheme, w)
+ Thl = compute_Th(l)
+ Thk = compute_Th(k)
+
+ if has_wc:
+ # One counter search per layer, w^l / ν attempts each
+ nu = compute_nu(l, swn, w)
+ d_wots_expected_search = d*ceil((w**l) / nu)
+ else:
+ d_wots_expected_search = 0
+
+ # 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
+ c_msg = C_Hmsg + C_PRFmsg
+
+ mmax = 0
+ if scheme == "W+C_F+C":
+ # 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
+ c_fors_search = fors_exp_search * c_msg
+ return {
+ 'compressions': c_hyper_exp + c_fors_fixed + c_fors_search,
+ 'mmax': mmax,
+ }
+ elif scheme == "W+C_P+FP":
+ # 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)
+ 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))
+ c_pors_search = pors_search_attempts * c_msg
+ return {
+ 'compressions': c_hyper_exp + c_pors_fixed + c_pors_search,
+ 'mmax': mmax,
+ }
+ else: # FORS
+ c_fors_fixed = k*(2**a)*C_PRF + k*(2**a)*C_Th1 + k*(2**a - 1)*C_Th2 + Thk
+ return {
+ 'compressions': c_fors_fixed + c_msg + c_hyper_exp,
+ 'mmax': mmax,
+ }
+
+def compute_verification_time(h, d, a, k, w, swn, scheme, mmax=0):
+ """{'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 over all d layers; WOTS+C costs the same for every message
+ if has_wc:
+ c_wots = d*(((w-1)*l - swn)*C_Th1 + C_Th1c + Thl)
+ c_wots_worst = c_wots
+ 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)
+
+ if scheme == "W+C_F+C":
+ Thk1 = compute_Th(k-1)
+ c_fts = (k-1)*C_Th1 + (k-1)*a*C_Th2 + Thk1
+ elif scheme == "W+C_P+FP":
+ c_fts = k*C_Th1 + mmax*C_Th2
+ else: # FORS
+ c_fts = k*C_Th1 + k*a*C_Th2 + Thk
+
+ return {
+ 'compressions': C_Hmsg + c_fts + c_wots + h*C_Th2,
+ 'compressions_worst': C_Hmsg + c_fts + c_wots_worst + h*C_Th2,
+ }
+
+# 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():
+ """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:
+ l = compute_wots_l(scheme, w)
+
+ 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'])
+ keygen_compressions = compute_keygen_time(h, d, w, scheme)
+ size = compute_size(h, d, a, k, w, scheme, sign['mmax'])
+ compressions_per_byte = float(verify['compressions']) / float(size)
+ compressions_per_byte_worst = float(verify['compressions_worst']) / float(size)
+
+ results.append({
+ 'scheme': scheme,
+ 'q_s': q_s,
+ 'h': h,
+ 'd': d,
+ 'a': a,
+ 'k': k,
+ 'w': w,
+ 'l': l,
+ 'swn': swn,
+ 'size': size,
+ 'keygen_compressions': keygen_compressions,
+ 'sign_compressions': sign['compressions'],
+ 'verify_compressions': verify['compressions'],
+ 'verify_compressions_worst': verify['compressions_worst'],
+ 'compressions_per_byte': compressions_per_byte,
+ 'compressions_per_byte_worst': compressions_per_byte_worst,
+ 'bold': bold,
+ })
+ return results
+
+
+def generate_csv():
+ 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_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):
+ """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:
+ return f"{n/1_000:.1f}K"
+ else:
+ return str(int(n))
+
+
+def generate_table(q_s_filter=None):
+ """Prints the tables, only for 2^q_s_filter signatures if given."""
+ results = compute_all_results()
+
+ if q_s_filter is not None:
+ results = [r for r in results if r['q_s'] == q_s_filter]
+
+ if not results:
+ print(f"No parameter sets found for q_s=2^{q_s_filter}")
+ return
+
+ # Group results by q_s for section headers
+ q_s_groups = {}
+ for r in results:
+ q_s_groups.setdefault(r['q_s'], []).append(r)
+
+ # Column definitions: (header, key, width, format_func)
+ columns = [
+ ("Scheme", 'scheme', 10, str),
+ ("h", 'h', 4, str),
+ ("d", 'd', 3, str),
+ ("a", 'a', 3, str),
+ ("k", 'k', 3, str),
+ ("w", 'w', 4, str),
+ ("l", 'l', 3, str),
+ ("S_wn", 'swn', 5, str),
+ ("Size", 'size', 6, str),
+ ("Keygen(C)", 'keygen_compressions', 10, format_num),
+ ("Sign(C)", 'sign_compressions', 9, format_num),
+ ("Verify(C)", 'verify_compressions', 10, format_num),
+ ("C/byte", 'compressions_per_byte', 6, lambda x: f"{x:.2f}"),
+ ]
+
+ # Calculate total width
+ total_width = sum(w for _, _, w, _ in columns) + len(columns) - 1
+
+ for q_s in sorted(q_s_groups.keys(), reverse=True):
+ group = q_s_groups[q_s]
+
+ # Section header
+ print()
+ print("=" * total_width)
+ print(f" 2^{q_s} signatures ".center(total_width, "="))
+ print("=" * total_width)
+
+ # Column headers
+ header_line = " ".join(h.center(w) for h, _, w, _ in columns)
+ print(header_line)
+ print("-" * total_width)
+
+ # Data rows
+ for r in group:
+ row_parts = []
+ for _, key, width, fmt in columns:
+ val = fmt(r[key])
+ row_parts.append(val.rjust(width) if key != 'scheme' else val.ljust(width))
+ print(" ".join(row_parts))
+
+ print()
+ print("Legend: Size=bytes, Keygen(C)/Sign(C)/Verify(C)=compression calls, C/byte=verify compressions per signature byte")
+
+
+def compute_single(scheme, q_s_log2, h, d, a, k, w, swn):
+ """Prints the size, costs and security of one parameter set."""
+ q_s = 2**q_s_log2
+ l = compute_wots_l(scheme, w)
+ 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)
+ keygen_compressions = compute_keygen_time(h, d, w, scheme)
+ size = compute_size(h, d, a, k, w, scheme, mmax)
+ c_per_byte = float(verify['compressions']) / float(size)
+
+ print("Scheme: " + scheme)
+ print("q_s: 2^" + str(q_s_log2))
+ print("(k,a,H,d): (" + str(int(k)) + ", " + str(int(a)) + ", " + str(int(h)) + ", " + str(int(d)) + ")")
+ print("w: " + str(int(w)))
+ print("S_wn: " + str(int(swn)))
+ print("l: " + str(int(l)))
+ print("mmax: " + str(int(mmax)))
+ print("Security: " + "{:.1f}".format(security) + " bits")
+ print("Size: " + str(int(size)) + " bytes")
+ print("Keygen(C): " + format_num(keygen_compressions))
+ print("Sign(C): " + format_num(sign['compressions']))
+ print("Verify(C): " + format_num(verify['compressions']) + " (worst: " + format_num(verify['compressions_worst']) + ")")
+ print("C/byte: " + "{:.2f}".format(c_per_byte) + " (worst: " + "{:.2f}".format(float(verify['compressions_worst'])/float(size)) + ")")
+
+
+# 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
+
+ if len(sys.argv) > 1 and sys.argv[1] == "--table":
+ # Optional: --table 40 to filter by q_s
+ q_s_filter = int(sys.argv[2]) if len(sys.argv) > 2 else None
+ generate_table(q_s_filter)
+ elif len(sys.argv) > 1 and sys.argv[1] == "--params":
+ # Usage: sage model/costs.sage --params scheme q_s k a h d w swn
+ if len(sys.argv) != 10:
+ print("Usage: sage model/costs.sage --params scheme q_s k a h d w swn")
+ print(" scheme: SPX, W+C, W+C_F+C, W+C_P+FP")
+ print(" q_s: log2 of max signatures (e.g., 10 for 2^10)")
+ print("Example: sage model/costs.sage --params W+C_P+FP 10 8 17 12 1 16 240")
+ sys.exit(1)
+ scheme = sys.argv[2]
+ q_s_log2 = int(sys.argv[3])
+ k = int(sys.argv[4])
+ a = int(sys.argv[5])
+ h = int(sys.argv[6])
+ d = int(sys.argv[7])
+ w = int(sys.argv[8])
+ swn = int(sys.argv[9])
+ compute_single(scheme, q_s_log2, h, d, a, k, w, swn)
+ elif len(sys.argv) > 1 and sys.argv[1] == "--help":
+ print("Usage: sage model/costs.sage [OPTIONS]")
+ print()
+ print("Options:")
+ print(" (no args) Output CSV format")
+ print(" --table [N] Output pretty ASCII table [filtered to 2^N]")
+ print(" --params scheme q_s k a h d w swn Compute single parameter set")
+ print(" --help Show this help message")
+ print()
+ print("Example:")
+ print(" sage model/costs.sage --params W+C_P+FP 10 8 17 12 1 16 240")
+ else:
+ generate_csv()
diff --git a/model/security.sage b/model/security.sage
new file mode 100644
index 0000000..3fffc0a
--- /dev/null
+++ b/model/security.sage
@@ -0,0 +1,79 @@
+#!/usr/bin/env sage
+"""
+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 model/security.sage
+"""
+from sage.all import *
+import sys
+
+F = RealField(100) # the terms reach 2^-1250, below what a double holds
+
+def powF(p, e):
+ return F(p)**e
+
+def qhitprob(q_s, r, 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 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 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):
+ """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":
+ t = 2**a
+ p_forge = p_forge_fors
+ else: # PORS+FP
+ t = k * 2**a
+ p_forge = p_forge_pors_fp
+
+ sigma = F(0)
+ r = 1
+
+ while True:
+ contribution = qhitprob(q_s, r, leaves) * p_forge(r, k, t)
+ sigma += contribution
+
+ r += 1
+
+ # 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
+
+ preimage_attack_prob = F(1) / F(2**(8*hashbytes))
+ total_attack_prob = max(preimage_attack_prob, sigma)
+
+ security_bits = -log(total_attack_prob, 2)
+
+ return float(security_bits)
+
+
+# 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'):
+ # SLH-DSA-128s
+ q_s = 2**64
+ h = 63
+ a = 12
+ k = 14
+ scheme_type = "FORS"
+
+ security = compute_security(q_s, h, k, a, scheme_type)
+
+ print("=" * 40)
+ print(f"Parameters: q_s=2^64, h={h}, a={a}, k={k}, type={scheme_type}")
+ print(f"Security level: {security:.1f} bits")
+ print("=" * 40)
diff --git a/model/stateful.sage b/model/stateful.sage
new file mode 100644
index 0000000..a1bebad
--- /dev/null
+++ b/model/stateful.sage
@@ -0,0 +1,546 @@
+"""
+XMSS and XMSS-MT for 2^20 and 2^40 signatures and UXMSS, the SHRINCS stateful
+tree, with WOTS-TW and WOTS+C: sizes in bytes, costs in SHA-256 compressions and
+signer state. docs/model.md has the model, parameters.json the w set, S_wn and
+the UXMSS bounds. scripts/export_site_data.sage loads it for site/data.json.
+
+ sage model/stateful.sage [--xmss-only | --xmssmt-only | --uxmss-only] [--detail]
+ sage model/stateful.sage --csv (the CSV instead of the tables)
+ sage model/stateful.sage --output FILE.csv (the tables, and the CSV in FILE.csv)
+"""
+import csv
+import math
+import os
+import sys
+
+_DOC = __doc__ # loading costs.sage replaces __doc__
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # see model/costs.sage
+os.environ['COSTS_SAGE_NO_MAIN'] = '1' # its functions, without its CLI
+load(os.path.join(ROOT, "model", "costs.sage"))
+
+
+def _parse_args(argv):
+ do_csv = xmss_only = xmssmt_only = uxmss_only = show_detail = False
+ output_file = None
+ i = 1
+ while i < len(argv):
+ a = argv[i]
+ if a == "--csv": do_csv = True; i += 1
+ elif a == "--xmss-only": xmss_only = True; i += 1
+ elif a == "--xmssmt-only": xmssmt_only = True; i += 1
+ elif a == "--uxmss-only": uxmss_only = True; i += 1
+ elif a == "--detail": show_detail = True; i += 1
+ elif a == "--output" and i + 1 < len(argv):
+ output_file = argv[i + 1]; i += 2
+ elif a == "--help":
+ print(_DOC); sys.exit(0)
+ else:
+ print("Unknown argument: {}".format(a), file=sys.stderr); sys.exit(2)
+ return do_csv, xmss_only, xmssmt_only, uxmss_only, show_detail, output_file
+
+
+N = hashbytes
+R_SIZE = randomness_size
+IND_SIZE = 1 # SHRINCS indicator byte: the leaf height on the stateful path
+
+H_VALS = [20, 40] # XMSS and XMSS-MT for 2^20 and 2^40 signatures
+UXMSS_H_VALS = [40] # the UXMSS bound is the size of a 2^40 stateless signature
+
+OTS_TW = "tw"
+OTS_WC = "wc"
+
+WOTS_W_VALS = PARAMETERS['stateful']['w']
+WOTS_C_PAIRS = [(w, SWN[w]) for w in WOTS_W_VALS]
+
+PARAM_SETS = (
+ [(OTS_WC, w, swn) for w, swn in WOTS_C_PAIRS] +
+ [(OTS_TW, w, 0) for w in WOTS_W_VALS]
+)
+
+HSF_MAX = PARAMETERS['stateful']['hsf_max']
+
+
+def ots_label(ots_type, w, swn):
+ if ots_type == OTS_WC: return "WOTS+C(w={:3d},S={:4d})".format(w, swn)
+ return "WOTS-TW(w={:3d})".format(w)
+
+
+def counter_bytes(w):
+ """Bytes of the WOTS+C counter: 2 as in SHRINCS, 4 for w = 65536
+ (docs/model.md, WOTS+C)."""
+ return 2 if w <= 256 else 4
+
+
+def idx_bytes(h):
+ """Bytes of the leaf index in XMSS and XMSS-MT of total height h."""
+ return max(1, int(ceil(h / 8)))
+
+
+# WOTS
+
+def wots_l(w, ots_type):
+ """Number of WOTS chains: l1 for WOTS+C, l1 + l2 for WOTS-TW."""
+ l1, l2 = wots_lengths(w)
+ return int(l1) if ots_type == OTS_WC else int(l1 + l2)
+
+
+def wots_pk_C(w, ots_type):
+ """Compressions to compute one WOTS public key."""
+ l = wots_l(w, ots_type)
+ return l * C_PRF + l * (w - 1) * C_Th1 + compute_Th(l)
+
+
+def wots_tw_verify_steps(w, count=1):
+ """Mean chain steps to verify count WOTS-TW signatures, rounded once."""
+ l1, l2 = wots_lengths(w)
+ return int(compute_wots_tw_mean_steps(l1, l2, w, count))
+
+
+def wots_tw_sign_steps(w):
+ """Mean chain steps to produce one WOTS-TW signature: the part of each
+ chain the verifier does not walk."""
+ return wots_l(w, OTS_TW) * (w - 1) - wots_tw_verify_steps(w)
+
+
+def wots_sign_C(w, swn, ots_type):
+ """Mean compressions to produce one WOTS signature."""
+ l = wots_l(w, ots_type)
+ if ots_type == OTS_WC:
+ nu = compute_nu(l, swn, w)
+ search = int((w**l + nu - 1) // nu)
+ return search * C_Th1c + l * C_PRF + swn * C_Th1
+ return l * C_PRF + wots_tw_sign_steps(w) * C_Th1
+
+
+def wots_verify_C(w, swn, ots_type, worst_case=False, count=1):
+ """Compressions to verify count WOTS signatures: the chain steps, the
+ public-key compression and, for WOTS+C, the counter hash."""
+ l = wots_l(w, ots_type)
+ Thl = compute_Th(l)
+
+ if ots_type == OTS_WC:
+ return count * (((w - 1) * l - swn) * C_Th1 + C_Th1c + Thl)
+
+ if worst_case:
+ l1, l2 = wots_lengths(w)
+ return count * (compute_wots_tw_worst_steps(l1, l2, w) * C_Th1 + Thl)
+
+ return wots_tw_verify_steps(w, count) * C_Th1 + count * Thl
+
+
+# XMSS and XMSS-MT: d layers of trees of height h' = h/d, XMSS is d = 1.
+# BDS bounds and state: docs/model.md, XMSS and XMSS-MT.
+
+def bds_k(h_prime):
+ """Smallest BDS parameter K for a tree of height h_prime."""
+ if h_prime < 2:
+ return h_prime
+ return 2 if h_prime % 2 == 0 else 3
+
+
+def bds_leaves(h_prime):
+ """Leaf computations per BDS update, at most."""
+ return (h_prime - bds_k(h_prime)) // 2 + 1
+
+
+def bds_hashes(h_prime):
+ """Node hashes per BDS update, at most."""
+ return (3 * max(0, h_prime - bds_k(h_prime) - 1)) // 2 + 1
+
+
+def bds_state_nodes(h_prime):
+ """Nodes a BDS signer stores, at most: authentication path, keep, retain,
+ and the treehash instances with their stacks."""
+ k = bds_k(h_prime)
+ return (h_prime + h_prime // 2 + (2**k - k - 1)
+ + max(0, h_prime - k) + max(0, h_prime - k - 1))
+
+
+def tree_size_per_layer(h_prime, w, ots_type):
+ l = wots_l(w, ots_type)
+ ctr = counter_bytes(w) if ots_type == OTS_WC else 0
+ return h_prime * N + l * N + ctr
+
+
+def tree_keygen_C(h_prime, w, ots_type):
+ pk = wots_pk_C(w, ots_type)
+ sz = 2**h_prime
+ return sz * pk + (sz - 1) * C_Th2
+
+
+def tree_verify_C(h_prime, w, swn, ots_type, worst_case=False, count=1):
+ return (wots_verify_C(w, swn, ots_type, worst_case, count)
+ + count * h_prime * C_Th2)
+
+
+def xmssmt_size_h(h, d, w, ots_type):
+ h_prime = h // d
+ return (IND_SIZE + R_SIZE + idx_bytes(h)
+ + d * tree_size_per_layer(h_prime, w, ots_type))
+
+
+def xmssmt_keygen_C_h(h, d, w, ots_type):
+ h_prime = h // d
+ return int(tree_keygen_C(h_prime, w, ots_type))
+
+
+def xmssmt_sign_bds_C_h(h, d, w, swn, ots_type):
+ """Cached signer: one BDS update per layer and signature, at most."""
+ h_prime = h // d
+ pk = wots_pk_C(w, ots_type)
+ c_sg = wots_sign_C(w, swn, ots_type)
+
+ return float(C_Hmsg + C_PRFmsg + c_sg
+ + d * (bds_leaves(h_prime) * pk + bds_hashes(h_prime) * C_Th2))
+
+
+def xmssmt_sign_cold_C_h(h, d, w, swn, ots_type):
+ """Cold signer: 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 above it."""
+ h_prime = h // d
+ return int(C_Hmsg + C_PRFmsg +
+ d * (int(tree_keygen_C(h_prime, w, ots_type)) +
+ wots_sign_C(w, swn, ots_type))
+ - wots_pk_C(w, ots_type) - h_prime * C_Th2)
+
+
+def xmssmt_state_bytes(h, d, w, ots_type):
+ """Cached-signer state: the BDS state of every layer and the WOTS
+ signatures of the upper layers."""
+ h_prime = h // d
+ l = wots_l(w, ots_type)
+ return int(d * bds_state_nodes(h_prime) * N + (d - 1) * l * N)
+
+
+def xmssmt_verify_C_h(h, d, w, swn, ots_type, worst_case=False):
+ h_prime = h // d
+ return float(C_Hmsg + tree_verify_C(h_prime, w, swn, ots_type, worst_case, d))
+
+
+def compute_tree_rows(scheme, h, d_values):
+ rows = []
+ for ots_type, w, swn in PARAM_SETS:
+ for d in d_values:
+ rows.append(dict(
+ scheme = scheme,
+ h=h, q_s_log2=h, d=d, h_prime=h // d,
+ label = ots_label(ots_type, w, swn),
+ ots_type = ots_type, w=w, swn=swn,
+ l = wots_l(w, ots_type),
+ size = xmssmt_size_h(h, d, w, ots_type),
+ keygen_C = xmssmt_keygen_C_h(h, d, w, ots_type),
+ sign_C = xmssmt_sign_bds_C_h(h, d, w, swn, ots_type),
+ sign_cold_C = xmssmt_sign_cold_C_h(h, d, w, swn, ots_type),
+ state_bytes = xmssmt_state_bytes(h, d, w, ots_type),
+ verify_avg_C = xmssmt_verify_C_h(h, d, w, swn, ots_type, worst_case=False),
+ verify_worst_C = xmssmt_verify_C_h(h, d, w, swn, ots_type, worst_case=True),
+ ))
+ return rows
+
+
+# UXMSS: a spine of height hsf with hsf + 1 leaves (docs/model.md, UXMSS)
+
+def _uxmss_idx_bytes(depth):
+ """Bytes of the leaf index, ceil(min(depth, 64) / 8): SHRINCS sizes the
+ field by the depth of the signing leaf, although an unbalanced-tree
+ index is only ever 0 or 1."""
+ return max(1, (min(int(depth), 64) + 7) // 8)
+
+
+def uxmss_size(q, hsf, w, ots_type):
+ """Size of the q-th UXMSS signature (q = 1 is the first)."""
+ l = wots_l(w, ots_type)
+ ctr = counter_bytes(w) if ots_type == OTS_WC else 0
+ depth = min(q, hsf)
+ return IND_SIZE + R_SIZE + _uxmss_idx_bytes(depth) + ctr + l * N + depth * N
+
+
+def find_max_hsf(w, ots_type, target_size):
+ """Largest hsf up to HSF_MAX whose last signature is below target_size."""
+ for hsf in range(HSF_MAX, 0, -1):
+ if uxmss_size(hsf, hsf, w, ots_type) < target_size:
+ return hsf
+ return 0
+
+
+def uxmss_keygen_C(hsf, w, ots_type):
+ return float((hsf + 1) * wots_pk_C(w, ots_type) + hsf * C_Th2)
+
+
+def uxmss_sign_C(q, hsf, w, swn, ots_type):
+ """Cached signer, q-th signature. It keeps the hsf + 1 leaf public keys;
+ the spine sibling of the signing leaf takes hsf - q node hashes."""
+ return float(C_Hmsg + C_PRFmsg + wots_sign_C(w, swn, ots_type)
+ + (hsf - min(q, hsf)) * C_Th2)
+
+
+def uxmss_sign_cold_C(hsf, w, swn, ots_type):
+ """Cold signer, first signature (the most expensive): the hsf other
+ leaves, then as the cached signer."""
+ return float(uxmss_sign_C(1, hsf, w, swn, ots_type)
+ + hsf * wots_pk_C(w, ots_type))
+
+
+def uxmss_state_bytes(hsf):
+ """Cached-signer state: the hsf + 1 leaf public keys."""
+ return int((hsf + 1) * N)
+
+
+def uxmss_verify_C(q, hsf, w, swn, ots_type, worst_case=False):
+ return float(C_Hmsg + wots_verify_C(w, swn, ots_type, worst_case) + min(q, hsf) * C_Th2)
+
+
+def compute_uxmss_rows(target_size, q_s_log2):
+ rows = []
+ for ots_type, w, swn in PARAM_SETS:
+ hsf = find_max_hsf(w, ots_type, target_size)
+ nsig = hsf + 1
+ rows.append(dict(
+ scheme = "UXMSS",
+ q_s_log2 = q_s_log2,
+ ref_size = target_size,
+ label = ots_label(ots_type, w, swn),
+ ots_type = ots_type, w=w, swn=swn,
+ l = wots_l(w, ots_type),
+ hsf = hsf, nsig=nsig,
+ sz_q1 = uxmss_size(1, hsf, w, ots_type),
+ sz_max = uxmss_size(hsf, hsf, w, ots_type),
+ keygen_C = uxmss_keygen_C(hsf, w, ots_type),
+ sign_q1_C = uxmss_sign_C(1, hsf, w, swn, ots_type),
+ sign_cold_C = uxmss_sign_cold_C(hsf, w, swn, ots_type),
+ state_bytes = uxmss_state_bytes(hsf),
+ verify_max_avg_C = uxmss_verify_C(hsf, hsf, w, swn, ots_type, worst_case=False),
+ verify_max_worst_C = uxmss_verify_C(hsf, hsf, w, swn, ots_type, worst_case=True),
+ ))
+ return rows
+
+
+# CSV
+
+_CSV_HEADER = [
+ "scheme", "q_s_log2", "h_total", "d", "h_prime",
+ "ots_type", "w", "swn", "l",
+ "size_bytes",
+ "keygen_C", "sign_bds_C", "sign_cold_C", "state_bytes",
+ "verify_avg_C", "verify_worst_C",
+ "ref_size", "hsf", "num_sigs",
+ "sz_q1", "sz_max", "sign_q1_C", "verify_max_avg_C", "verify_max_worst_C",
+]
+
+
+def _fmt_f(v):
+ """A float for the CSV, empty for NaN."""
+ if isinstance(v, float) and math.isnan(v):
+ return ""
+ return "{:.6e}".format(float(v))
+
+
+def _tree_csv_row(r):
+ return [r['scheme'], r['q_s_log2'], r['h'], r['d'], r['h_prime'],
+ r['ots_type'], r['w'], r['swn'], r['l'],
+ r['size'],
+ _fmt_f(r['keygen_C']), _fmt_f(r['sign_C']),
+ _fmt_f(r['sign_cold_C']), r['state_bytes'],
+ _fmt_f(r['verify_avg_C']), _fmt_f(r['verify_worst_C']),
+ "", "", "", "", "", "", "", ""]
+
+
+def _uxmss_csv_row(r):
+ return [r['scheme'], r['q_s_log2'], "", "", "",
+ r['ots_type'], r['w'], r['swn'], r['l'],
+ "",
+ _fmt_f(r['keygen_C']), "", _fmt_f(r['sign_cold_C']), r['state_bytes'], "", "",
+ r['ref_size'], r['hsf'], r['nsig'],
+ r['sz_q1'], r['sz_max'],
+ _fmt_f(r['sign_q1_C']), _fmt_f(r['verify_max_avg_C']), _fmt_f(r['verify_max_worst_C'])]
+
+
+def write_csv(dest, xmss_by_h, xmssmt_by_h, uxmss_by_h):
+ """Writes the rows to dest (a file or sys.stdout) and returns their number."""
+ writer = csv.writer(dest)
+ writer.writerow(_CSV_HEADER)
+ total = 0
+ for by_h in (xmss_by_h, xmssmt_by_h):
+ for h in H_VALS:
+ for r in by_h.get(h, []):
+ writer.writerow(_tree_csv_row(r)); total += 1
+ for h in UXMSS_H_VALS:
+ for r in uxmss_by_h.get(h, []):
+ writer.writerow(_uxmss_csv_row(r)); total += 1
+ return total
+
+
+def save_csv_file(path, xmss_by_h, xmssmt_by_h, uxmss_by_h):
+ with open(path, 'w', newline='') as f:
+ n = write_csv(f, xmss_by_h, xmssmt_by_h, uxmss_by_h)
+ print("Saved {} data rows to {!r}".format(n, path), file=sys.stderr)
+
+
+# Tables
+
+def fmt_c(n):
+ """A compression count with a K, M, G or T suffix."""
+ if isinstance(n, float) and math.isnan(n):
+ return "—"
+ n = float(n)
+ if n >= 1e15: return "{:.2e}".format(n)
+ if n >= 1e12: return "{:.2f}T".format(n / 1e12)
+ if n >= 1e9: return "{:.2f}G".format(n / 1e9)
+ if n >= 1e6: return "{:.2f}M".format(n / 1e6)
+ if n >= 1e3: return "{:.1f}K".format(n / 1e3)
+ return "{:d}".format(int(n))
+
+
+def _row(vals, cols):
+ return " ".join("{:{a}{w}}".format(str(v), a=a, w=w)
+ for v, (_, w, a) in zip(vals, cols))
+
+
+def _hdr(cols):
+ return " ".join("{:{a}{w}}".format(h, a=a, w=w) for h, w, a in cols)
+
+
+def _sep_line(cols, char="-"):
+ return char * (sum(c[1] for c in cols) + len(cols) - 1)
+
+
+OTS_LINE = " OTS: WOTS-TW / WOTS+C | w ∈ {{{}}} ".format(",".join(str(w) for w in WOTS_W_VALS))
+
+
+def print_xmss_table(rows_by_h):
+ cols = [("q_s", 4, ">"), ("OTS Variant", 26, "<"), ("l", 3, ">"),
+ ("Size(B)", 7, ">"), ("Keygen(C)", 11, ">"),
+ ("Sign-BDS(C)", 11, ">"), ("Vfy-avg(C)", 10, ">"), ("Vfy-wrst(C)", 11, ">")]
+ W = sum(c[1] for c in cols) + len(cols) - 1
+ print()
+ print("="*W)
+ print(" XMSS — Single-Layer Balanced Tree ".center(W, "="))
+ print(OTS_LINE.center(W, "="))
+ print(" Auth path = h nodes (fixed per sig); Sign-BDS = bound per sig ".center(W, "="))
+ print("="*W)
+ print(_hdr(cols)); print(_sep_line(cols))
+ for h in H_VALS:
+ for r in rows_by_h.get(h, []):
+ infeas = r['keygen_C'] > 1e11
+ kg = fmt_c(r['keygen_C']) + ("*" if infeas else " ")
+ print(_row(["2^{}".format(h), r['label'], r['l'], r['size'],
+ kg, fmt_c(r['sign_C']),
+ fmt_c(r['verify_avg_C']), fmt_c(r['verify_worst_C'])], cols))
+ print()
+ print(_sep_line(cols))
+ print("* Keygen for h=40 requires 2^40 ≈ 1.1·10^12 WOTS keypairs — infeasible.")
+ print(" h=20 keygen ≈ 2^20 ≈ 1M WOTS keys — feasible (~seconds on modern CPU).")
+ print()
+
+
+def print_xmssmt_table(rows_by_h):
+ cols = [("q_s", 4, ">"), ("OTS Variant", 26, "<"), ("d", 2, ">"),
+ ("h'", 3, ">"), ("l", 3, ">"), ("Size(B)", 7, ">"),
+ ("Keygen(C)", 10, ">"), ("Sign-BDS(C)", 11, ">"),
+ ("Sign-cold(C)", 12, ">"), ("Vfy-avg(C)", 10, ">"), ("Vfy-wrst(C)", 11, ">")]
+ W = sum(c[1] for c in cols) + len(cols) - 1
+ print()
+ print("="*W)
+ print(" XMSS-MT — Multi-Tree Hypertree (no FORS needed; stateful) ".center(W, "="))
+ print(OTS_LINE.center(W, "="))
+ print(" Keygen builds top tree only (2^h' keys); Sign-BDS = bound per sig ".center(W, "="))
+ print("="*W)
+ print(_hdr(cols)); print(_sep_line(cols))
+ cur_label = None
+ for h in H_VALS:
+ for r in rows_by_h.get(h, []):
+ tag = "2^{}".format(h)
+ new_label = (h, r['label'])
+ if new_label != cur_label:
+ if cur_label is not None:
+ print()
+ cur_label = new_label
+ print(_row([tag, r['label'], r['d'], r['h_prime'], r['l'], r['size'],
+ fmt_c(r['keygen_C']), fmt_c(r['sign_C']), fmt_c(r['sign_cold_C']),
+ fmt_c(r['verify_avg_C']), fmt_c(r['verify_worst_C'])], cols))
+ print()
+ print(_sep_line(cols))
+ print("Sign-cold: rebuild d trees per sig (worst-case). BDS: at most d·((h'-K)/2 + 1) PK evals per sig.")
+ print()
+
+
+def print_uxmss_table(rows_by_h, ref_size):
+ cols = [("OTS Variant", 26, "<"), ("l", 3, ">"),
+ ("Ref(B)", 6, ">"), ("hsf", 5, ">"), ("Sigs", 7, ">"),
+ ("Sz(q=1)", 8, ">"), ("Sz(max)", 8, ">"),
+ ("Keygen(C)", 10, ">"), ("Sign(q=1)", 9, ">"),
+ ("VfyAvg(mx)", 10, ">"), ("VfyWrst(mx)", 11, ">")]
+ W = sum(c[1] for c in cols) + len(cols) - 1
+ print()
+ print("="*W)
+ print(" SHRINCS/UXMSS — Left-leaning Stateful Tree ".center(W, "="))
+ print(OTS_LINE.center(W, "="))
+ print(" max stateful sig must be strictly < Ref ".center(W, "="))
+ print("="*W)
+ print(_hdr(cols)); print(_sep_line(cols))
+ for h in UXMSS_H_VALS:
+ for r in rows_by_h.get(h, []):
+ print(_row([r['label'], r['l'], ref_size,
+ r['hsf'], r['nsig'], r['sz_q1'], r['sz_max'],
+ fmt_c(r['keygen_C']), fmt_c(r['sign_q1_C']),
+ fmt_c(r['verify_max_avg_C']), fmt_c(r['verify_max_worst_C'])], cols))
+ print()
+ print(_sep_line(cols))
+ print("Ref: the size of the report's candidate 2, (h, d, k, a, w) = (45, 5, 8, 16, 16).")
+ print("hsf = max auth-path nodes; Sigs = hsf+1; Sz(max) must be < Ref.")
+ print()
+
+
+def print_uxmss_detail(uxmss_by_h):
+ for h in UXMSS_H_VALS:
+ print("\n --- UXMSS Per-Signature Progression (Bounded by Ref Size for 2^{} target) ---".format(h))
+ for r in uxmss_by_h.get(h, []):
+ hsf = r['hsf']
+ w, swn, ots_type = r['w'], r['swn'], r['ots_type']
+ if hsf <= 0: continue
+ print("\n {} (l={}, hsf={}, {} sigs)".format(
+ r['label'], r['l'], hsf, hsf + 1))
+ print(" {:>6} {:>8} {:>12} {:>12} {:>12}".format(
+ "q", "Size(B)", "Sign(C)", "Vfy-avg(C)", "Vfy-wrst(C)"))
+ print(" " + "-" * 56)
+ qpts = sorted(set(filter(lambda q: 1 <= q <= hsf + 1,
+ [1, 2, 5, 10, hsf // 4, hsf // 2, 3 * hsf // 4,
+ max(1, hsf - 1), hsf, hsf + 1])))
+ for q in qpts:
+ sz = uxmss_size(q, hsf, w, ots_type)
+ sc = uxmss_sign_C(q, hsf, w, swn, ots_type)
+ vc_avg = uxmss_verify_C(q, hsf, w, swn, ots_type, worst_case=False)
+ vc_wrst = uxmss_verify_C(q, hsf, w, swn, ots_type, worst_case=True)
+ flag = " ← max" if q == hsf else (" ← last" if q == hsf + 1 else "")
+ print(" {:>6} {:>8} {:>12} {:>12} {:>12}{}".format(
+ q, sz, fmt_c(sc), fmt_c(vc_avg), fmt_c(vc_wrst), flag))
+ print()
+
+
+# export_site_data.sage sets STATEFUL_SAGE_NO_MAIN: load() runs this file with
+# __name__ == '__main__'.
+if __name__ == "__main__" and not os.environ.get("STATEFUL_SAGE_NO_MAIN"):
+ do_csv, xmss_only, xmssmt_only, uxmss_only, show_detail, output_file = _parse_args(sys.argv)
+ ref_size = PARAMETERS['stateful']['uxmss_ref_size']
+
+ xmss_by_h = {h: compute_tree_rows("XMSS", h, [1]) for h in H_VALS}
+ xmssmt_by_h = {h: compute_tree_rows("XMSS-MT", h, [d for d in range(2, h + 1) if h % d == 0])
+ for h in H_VALS}
+ uxmss_by_h = {h: compute_uxmss_rows(ref_size, h) for h in UXMSS_H_VALS}
+
+ if do_csv:
+ write_csv(sys.stdout, xmss_by_h, xmssmt_by_h, uxmss_by_h)
+
+ if output_file:
+ save_csv_file(output_file, xmss_by_h, xmssmt_by_h, uxmss_by_h)
+
+ if not do_csv:
+ if not xmssmt_only and not uxmss_only:
+ print_xmss_table(xmss_by_h)
+ if not xmss_only and not uxmss_only:
+ print_xmssmt_table(xmssmt_by_h)
+ if not xmss_only and not xmssmt_only:
+ print_uxmss_table(uxmss_by_h, ref_size)
+ if show_detail and not xmss_only and not xmssmt_only:
+ print_uxmss_detail(uxmss_by_h)
diff --git a/parameters.json b/parameters.json
new file mode 100644
index 0000000..54cee9f
--- /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.",
+ "scripts/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/original-sphincs-parameter-search.sage b/reference/original-sphincs-parameter-search.sage
similarity index 100%
rename from original-sphincs-parameter-search.sage
rename to reference/original-sphincs-parameter-search.sage
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/scripts/export_site_data.sage b/scripts/export_site_data.sage
new file mode 100644
index 0000000..220e43b
--- /dev/null
+++ b/scripts/export_site_data.sage
@@ -0,0 +1,202 @@
+#!/usr/bin/env sage
+"""
+Writes site/data.json for the explorer and the stateful page: the SPX pool from
+analysis/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 scripts/export_site_data.sage (all schemes, a few minutes)
+ sage scripts/export_site_data.sage --schemes SPX,W+C (a subset; the pages adapt)
+"""
+import os
+import sys
+import csv as _csvmod
+import json
+import subprocess
+import datetime
+
+_DOC = __doc__ # loading stateful.sage replaces __doc__
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # see model/costs.sage
+os.environ['STATEFUL_SAGE_NO_MAIN'] = '1' # its functions, without its CLI
+load(os.path.join(ROOT, "model", "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 = 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)
+ sys.exit(0)
+ else:
+ print("Unknown argument: {}".format(_args[i]), file=sys.stderr)
+ sys.exit(2)
+for s in schemes:
+ assert s in ALL_SCHEMES, "unknown scheme: %s" % s
+
+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']
+
+
+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: the rows of the sweep CSV
+csv_path = os.path.join(ROOT, 'analysis', 'all_size_capped_candidates.csv')
+spx_rows, baseline = [], None
+with open(csv_path) as f:
+ for r in _csvmod.DictReader(f):
+ rec = [int(r['h']), int(r['d']), int(r['k']), int(r['a']), int(r['w']),
+ 0, 0,
+ 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': {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
+
+# 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:
+ # 'considered' counts the core grid only; the page derives the count for
+ # whichever w set is active from meta.grid.
+ 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}
+ print("SPX: {} rows ({} core from CSV + {} optional-w)".format(
+ len(all_spx), len(spx_rows), len(spx_extra)), file=sys.stderr)
+for scheme in schemes:
+ if scheme == 'SPX':
+ continue
+ 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-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(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([
+ ots_label, h, d, w,
+ int(xmssmt_size_h(h, d, w, ots_type)),
+ int(xmssmt_keygen_C_h(h, d, w, ots_type)),
+ int(xmssmt_sign_bds_C_h(h, d, w, swn, ots_type)),
+ int(xmssmt_sign_cold_C_h(h, d, w, swn, ots_type)),
+ int(xmssmt_state_bytes(h, d, w, ots_type)),
+ int(xmssmt_verify_C_h(h, d, w, swn, ots_type, worst_case=False)),
+ int(xmssmt_verify_C_h(h, d, w, swn, ots_type, worst_case=True)),
+ ])
+
+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, STATEFUL['uxmss_ref_size'])
+ uxmss_rows.append([
+ ots_label, w, int(hsf),
+ int(uxmss_size(1, hsf, w, ots_type)),
+ int(uxmss_size(hsf, hsf, w, ots_type)),
+ int(uxmss_keygen_C(hsf, w, ots_type)),
+ int(uxmss_sign_C(1, hsf, w, swn, ots_type)),
+ int(uxmss_sign_cold_C(hsf, w, swn, ots_type)),
+ int(uxmss_state_bytes(hsf)),
+ int(uxmss_verify_C(hsf, hsf, w, swn, ots_type, worst_case=False)),
+ int(uxmss_verify_C(hsf, hsf, w, swn, ots_type, worst_case=True)),
+ ])
+
+
+def _commit():
+ try:
+ return subprocess.check_output(
+ ['git', 'rev-parse', '--short', 'HEAD'],
+ cwd=ROOT, stderr=subprocess.DEVNULL).decode().strip()
+ except Exception:
+ sha = os.environ.get('GITHUB_SHA', '')
+ return sha[:7] if sha else 'unknown'
+
+
+data = {
+ 'meta': {
+ 'version': 3,
+ 'generator': 'export_site_data.sage',
+ 'generated': datetime.date.today().isoformat(),
+ 'commit': _commit(),
+ 'grid': GRID,
+ 'w_optional': W_OPTIONAL,
+ 'wc_pairs': WC_PAIRS,
+ 'schemes': [s for s in ALL_SCHEMES if s in pools],
+ 'uxmss_ref_size': STATEFUL['uxmss_ref_size'],
+ 'hsf_max': int(HSF_MAX),
+ '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': PARAMETERS['baseline']['q_s_log2']},
+ 'xmssmt': {
+ 'fields': ['ots', 'h', 'd', 'w', 'size', 'kg', 'sg', 'sg_cold',
+ 'state', 'sv', 'sv_worst'],
+ 'rows': xmssmt_rows,
+ },
+ 'uxmss': {
+ 'fields': ['ots', 'w', 'hsf', 'sz_q1', 'sz_max', 'kg', 'sg',
+ 'sg_cold', 'state', 'sv_max', 'sv_max_worst'],
+ 'rows': uxmss_rows,
+ },
+ },
+}
+
+out_path = os.path.join(ROOT, 'site', 'data.json')
+with open(out_path, 'w') as f:
+ # default=int: the sage preparser turns integer literals into sage
+ # Integers, which the json module cannot serialize directly.
+ json.dump(data, f, separators=(',', ':'), default=int)
+print("Wrote {} — schemes: {} — commit {}".format(
+ out_path, ', '.join('%s:%d' % (s, pools[s]['count']) for s in pools),
+ data['meta']['commit']))
diff --git a/slhdsa-2to40.sage b/scripts/slhdsa-2to40.sage
similarity index 68%
rename from slhdsa-2to40.sage
rename to scripts/slhdsa-2to40.sage
index 51ad48f..a2e7c50 100644
--- a/slhdsa-2to40.sage
+++ b/scripts/slhdsa-2to40.sage
@@ -1,47 +1,42 @@
#!/usr/bin/env sage
"""
-Standard SLH-DSA-128s VS custom variants sweeping (h, d, k, a) for various OTS/FTS combinations.
-
-Variant is selected via --ots and --fts flags:
- --ots {wots-tw, wots+c} (default: wots-tw)
- --fts {fors, fors+c, pors+fp} (default: fors)
-
-Supported (ots, fts) combinations:
- (wots-tw, fors) -> SPX (plain SLH-DSA / SPHINCS+)
- (wots+c, fors) -> W+C
- (wots+c, fors+c) -> W+C_F+C
- (wots+c, pors+fp) -> W+C_P+FP
-
-Sweep behavior per OTS:
- - wots-tw : w in {2, 4, 16, 256}, swn = 0
- - wots+c : (w, swn) in {(16, 240), (256, 2040)}
-
-Filters (defaults; override via CLI):
- --max-size BYTES Reject if size >= BYTES (default: 7856, std SLH-DSA-128s size)
- --max-keygen-ratio X Reject if keygen > X * std keygen (default: no limit)
- --max-sign-ratio X Reject if sign > X * std sign (default: no limit)
- --max-verify-ratio X Reject if verify > X * std verify (default: no limit)
- --max-c-per-byte X Reject if verify_C / size > X (default: no limit)
- --max-cpb-ratio X Reject if verify_C / size > X * std C/byte (default: no limit)
-
-Sweep ranges (comma-separated lists):
- --h N1,N2,... Hypertree heights to sweep (default: 40..50 inclusive)
- --d N1,N2,... Layer counts to sweep (default: 2..25 inclusive)
- Combinations where h % d != 0 are auto-skipped.
-
-Usage:
- sage slhdsa-2to40.sage # default (SPX, h=40)
- sage slhdsa-2to40.sage --ots wots+c --fts pors+fp # W+C_P+FP variant
- sage slhdsa-2to40.sage --ots wots+c --fts fors+c # W+C_F+C variant
- sage slhdsa-2to40.sage --max-size 4500 --max-keygen-ratio 10 --max-sign-ratio 10
- sage slhdsa-2to40.sage --h 40,42,45,48 --d 2,3,4,5,6,8
+Sweeps the stateless grid of parameters.json for one scheme, keeps the parameter
+sets that reach the security target and pass the filters, prints them against
+SLH-DSA-128s, and writes them to candidates.csv, SLH-DSA-128s first as STANDARD.
+make sweeps runs it twice for analysis/all_size_capped_candidates.csv and
+analysis/all_unbound_candidates.csv.
+
+ sage scripts/slhdsa-2to40.sage [--ots wots-tw|wots+c] [--fts fors|fors+c|pors+fp] [filters]
+ sage scripts/slhdsa-2to40.sage --help
"""
+import csv
import os
import sys
-# -----------------------------------------------------------------------------
-# CLI parsing (parse before loading costs.sage so we can suppress its CSV)
-# -----------------------------------------------------------------------------
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # see model/costs.sage
+os.environ['COSTS_SAGE_NO_MAIN'] = '1' # its functions, without its CLI
+load(os.path.join(ROOT, "model", "costs.sage"))
+
+GRID = PARAMETERS['stateless']['grid']
+
+USAGE = """usage: sage scripts/slhdsa-2to40.sage [options]
+
+ --ots wots-tw|wots+c the one-time signature (default wots-tw); wots+c
+ sweeps the w of wc_w with their S_wn
+ --fts fors|fors+c|pors+fp the few-time signature (default fors); wots-tw
+ goes with fors only
+ --h N1,N2,... hypertree heights (default: the grid)
+ --d N1,N2,... layers (default: the grid); d must divide h
+ --max-size BYTES keep sizes below BYTES (default: the grid's max_size)
+ --max-keygen-ratio X keep KeyGen up to X times that of SLH-DSA-128s
+ --max-sign-ratio X the same for SigGen
+ --max-verify-ratio X the same for SigVer
+ --max-c-per-byte X keep SigVer compressions per byte up to X
+ --max-cpb-ratio X keep them up to X times those of SLH-DSA-128s
+
+The filters other than the size have no limit by default.
+"""
+
def _parse_int_list(s):
return [int(x) for x in s.split(",") if x.strip()]
@@ -49,14 +44,14 @@ def _parse_int_list(s):
def _parse_args(argv):
ots = "wots-tw"
fts = "fors"
- max_size = 7856 # std SLH-DSA-128s signature size
+ max_size = GRID['max_size']
kg_ratio = float('inf')
sg_ratio = float('inf')
sv_ratio = float('inf')
cpb_max = float('inf')
cpb_ratio = float('inf')
- h_values = list(range(40, 51)) # 40..50 inclusive
- d_values = list(range(2, 26)) # 2..25 inclusive
+ h_values = list(range(GRID['h'][0], GRID['h'][1] + 1))
+ d_values = list(range(GRID['d'][0], GRID['d'][1] + 1))
i = 1
while i < len(argv):
a = argv[i]
@@ -91,7 +86,7 @@ def _parse_args(argv):
d_values = _parse_int_list(argv[i + 1])
i += 2
elif a == "--help":
- print(__doc__)
+ print(USAGE)
sys.exit(0)
else:
print("Unknown argument: {}".format(a), file=sys.stderr)
@@ -115,95 +110,57 @@ if (OTS, FTS) not in SCHEME_MAP:
sys.exit(2)
SCHEME = SCHEME_MAP[(OTS, FTS)]
-# (w, swn) sweep pairs depend on OTS
if OTS == "wots-tw":
- W_SWN_PAIRS = [(2, 0), (4, 0), (16, 0), (256, 0)]
+ W_SWN_PAIRS = [(w, 0) for w in GRID['w']]
else: # wots+c
- W_SWN_PAIRS = [(16, 240), (256, 2040)]
-
-# Security model: PORS+FP for that FTS, FORS otherwise
-SECURITY_MODEL = "PORS+FP" if FTS == "pors+fp" else "FORS"
+ W_SWN_PAIRS = [(w, SWN[w]) for w in PARAMETERS['stateless']['wc_w']]
-# -----------------------------------------------------------------------------
-# Load costs.sage (suppress its auto-CSV)
-# -----------------------------------------------------------------------------
-_saved_argv = sys.argv
-sys.argv = ['big_comparison.sage', '--no-auto']
-load(os.path.join(os.path.dirname(os.path.abspath(__file__)), "costs.sage"))
-sys.argv = _saved_argv
-
-# -----------------------------------------------------------------------------
-# Sweep parameters
-# -----------------------------------------------------------------------------
-
-TARGET = 128
-
-# Standard baseline is plain SLH-DSA-128s (SPX) for fair comparison
+# Every scheme is compared with SLH-DSA-128s
STD_SCHEME = "SPX"
STD_SWN = 0
-K_RANGE = range(6, 25)
-A_RANGE = range(8, 21)
-# H_VALUES and D_VALUES come from CLI flags (parsed at top of file)
-
-
-def evaluate(q_s_log2, h, d, k, a, w, swn, scheme, security_model):
- if h % d != 0:
- return None
- q_s = 2**q_s_log2
- try:
- security = compute_security(q_s, h, k, a, security_model, hashbytes=16)
- except Exception:
- return None
- if float(security) < TARGET:
- return None
-
- try:
- sign = compute_signing_time(h, d, a, k, w, swn, scheme)
- except Exception:
- return None
- mmax = sign['mmax']
- verify = compute_verification_time(h, d, a, k, w, swn, scheme, mmax)
- keygen_C = compute_keygen_time(h, d, w, scheme)
- size = compute_size(h, d, a, k, w, scheme, mmax)
- l = compute_wots_l(scheme, w)
+K_RANGE = range(GRID['k'][0], GRID['k'][1] + 1)
+A_RANGE = range(GRID['a'][0], GRID['a'][1] + 1)
+
+def csv_row(q_s_log2, r):
+ """The CSV fields of an evaluate() result."""
return {
'q_s_log2': int(q_s_log2),
- 'h': int(h),
- 'd': int(d),
- 'h_prime': int(h // d),
- 'k': int(k),
- 'a': int(a),
- 'w': int(w),
- 'l': int(l),
- 'swn': int(swn),
- 'mmax': int(mmax),
- 'security': float(security),
- 'size': int(size),
- 'keygen_C': float(keygen_C),
- 'sign_C': float(sign['compressions']),
- 'verify_C': float(verify['compressions']),
- 'verify_worst_C': float(verify['compressions_worst']),
- 'c_per_byte': float(verify['compressions']) / float(size),
- 'c_per_byte_worst': float(verify['compressions_worst']) / float(size),
+ 'h': int(r['h']),
+ 'd': int(r['d']),
+ 'h_prime': int(r['h'] // r['d']),
+ 'k': int(r['k']),
+ 'a': int(r['a']),
+ 'w': int(r['w']),
+ 'l': int(r['l']),
+ 'swn': int(r['swn']),
+ 'mmax': int(r['mmax']),
+ 'security': float(r['security']),
+ 'size': int(r['size']),
+ 'keygen_C': float(r['keygen']),
+ 'sign_C': float(r['sign']),
+ 'verify_C': float(r['verify']),
+ 'verify_worst_C': float(r['verify_worst']),
+ 'c_per_byte': float(r['verify']) / float(r['size']),
+ 'c_per_byte_worst': float(r['verify_worst']) / float(r['size']),
'label': '',
}
print("Variant: ots={}, fts={} -> scheme={}".format(OTS, FTS, SCHEME), file=sys.stderr)
print("Computing standard SLH-DSA-128s baseline (SPX)...", file=sys.stderr)
-std = evaluate(64, 63, 7, 14, 12, 16, STD_SWN, STD_SCHEME, "FORS")
+B = PARAMETERS['baseline']
+std = evaluate(B['q_s_log2'], B['h'], B['d'], B['k'], B['a'], B['w'], STD_SWN, STD_SCHEME)
if std is None:
print("ERROR: failed to compute standard baseline.", file=sys.stderr)
sys.exit(1)
+std = csv_row(B['q_s_log2'], std)
std['label'] = 'STANDARD'
KEYGEN_LIMIT = std['keygen_C'] * MAX_KEYGEN_RATIO
SIGN_LIMIT = std['sign_C'] * MAX_SIGN_RATIO
VERIFY_LIMIT = std['verify_C'] * MAX_VERIFY_RATIO
-# Effective C/byte cap: min of the absolute cap and the ratio cap (relative to
-# the std baseline computed above — avoids hardcoding a stale constant).
CPB_LIMIT = min(MAX_C_PER_BYTE, std['c_per_byte'] * MAX_CPB_RATIO)
print("Standard baseline (compressions):", file=sys.stderr)
@@ -249,18 +206,9 @@ def label_for(w, swn):
return "w={},swn={}".format(w, swn)
-for w, swn in W_SWN_PAIRS:
- label = label_for(w, swn)
- for h in H_VALUES:
- for d in D_VALUES:
- if h % d != 0:
- continue
- for k in K_RANGE:
- for a in A_RANGE:
- consider(
- evaluate(40, h, d, k, a, w, swn, SCHEME, SECURITY_MODEL),
- label,
- )
+for (w, swn, h, d, k, a), r in sweep(SCHEME, W_SWN_PAIRS, H_VALUES, D_VALUES,
+ K_RANGE, A_RANGE, GRID['q_s_log2']):
+ consider(None if r is None else csv_row(GRID['q_s_log2'], r), label_for(w, swn))
all_results.sort(key=lambda x: x['size'])
@@ -285,7 +233,7 @@ def fmt_ratio(r, std_val):
return "{:.2f}x".format(float(r) / float(std_val))
-# Use a wider Label column when swn is included; show S_wn column only for W+C
+# Only WOTS+C labels and rows carry S_wn
label_w = 12 if OTS == "wots+c" else 10
columns = [
@@ -362,7 +310,6 @@ print(" Total: {} configurations passing all filters ({})".format(
len(all_results), " + ".join(parts)))
print()
-# Filter rejection summary
print("=" * total_width)
print(" FILTER REJECTION SUMMARY ".center(total_width))
print("=" * total_width)
@@ -373,7 +320,7 @@ def _rej_label(name, ratio):
return " Rejected ({} > {:g}x std): ".format(name, ratio)
print(" Considered configurations: {}".format(considered))
-print(" Rejected (security < 128): {}".format(rejected['security']))
+print(" Rejected (security < {}): {}".format(PARAMETERS['security_bits'], rejected['security']))
print(" Rejected (size >= {} B): {}".format(MAX_SIZE, rejected['size']))
print("{}{}".format(_rej_label("keygen", MAX_KEYGEN_RATIO), rejected['keygen']))
print("{}{}".format(_rej_label("sign", MAX_SIGN_RATIO), rejected['sign']))
@@ -385,7 +332,6 @@ else:
print(" Passed all filters (custom): {}".format(len(all_results) - 1))
print()
-# Summary
print("=" * total_width)
print(" SUMMARY ".center(total_width))
print("=" * total_width)
@@ -431,7 +377,7 @@ for w, swn in W_SWN_PAIRS:
if OTS == "wots-tw" and w == 16:
note = " (FIPS 205-compatible algorithm structure)"
elif OTS == "wots-tw":
- note = " (modified SPHINCS+, NOT FIPS 205)"
+ note = " (modified SPHINCS+, not FIPS 205)"
else:
note = ""
subset = [r for r in all_results if r['label'] == label]
@@ -444,16 +390,13 @@ print(" - All cost metrics in SHA-256 compression-function calls (C).")
print(" - All ratios (KG/std, SG/std, SV/std) are relative to standard SLH-DSA-128s.")
print(" - Sec(bits) is capped at 128.0 by the n=128 preimage bound.")
if OTS == "wots+c":
- print(" - swn is the WOTS+C target chain sum S_{w,n}; values mirror PARAMETER_SETS in costs.sage.")
+ print(" - swn is the WOTS+C target chain sum S_{w,n}, from parameters.json.")
print(" - Sweep ranges: h in {}, d in {}, k in [{}, {}], a in [{}, {}], (w,swn) in {}.".format(
H_VALUES, D_VALUES, K_RANGE.start, K_RANGE.stop - 1, A_RANGE.start, A_RANGE.stop - 1, W_SWN_PAIRS))
-import csv
-
csv_filename = "candidates.csv"
print("\nSaving candidate configurations to {}...".format(csv_filename))
-# Define the exact dictionary keys returned by evaluate()
fieldnames = [
'label', 'h', 'd', 'h_prime', 'k', 'a', 'w', 'l',
'swn', 'mmax', 'security', 'size', 'keygen_C',
diff --git a/security.sage b/security.sage
deleted file mode 100644
index f6cbd47..0000000
--- a/security.sage
+++ /dev/null
@@ -1,170 +0,0 @@
-#!/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)))
-"""
-
-# High precision arithmetic
-F = RealField(100)
-
-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 = 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
- """
- 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)
- """
- 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)
- """
- leaves = 2**h
-
- if scheme_type == "FORS":
- t = 2**a
- p_forge = p_forge_fors
- else: # PORS+FP
- 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)
- 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
- 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)
-
- security_bits = -log(total_attack_prob, 2)
-
- return float(security_bits)
-
-
-# Run demo when invoked directly (not when loaded by another script via load())
-import sys
-if sys.argv[0].endswith('security.sage') or sys.argv[0].endswith('security.sage.py'):
- # SPHINCS+-128f parameters
- q_s = 2**64
- h = 63
- a = 12
- k = 14
- scheme_type = "FORS"
-
- security = compute_security(q_s, h, k, a, scheme_type)
-
- print("=" * 40)
- print(f"Parameters: q_s=2^64, h={h}, a={a}, k={k}, type={scheme_type}")
- print(f"Security level: {security:.1f} bits")
- print("=" * 40)
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