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 70% rename from utils/plot_concept_scatter.py rename to analysis/plot_concept_scatter.py index f87607c..2d65e0e 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,54 +73,42 @@ 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)", + ax.set_title("Size vs SigGen\n(Showing Vector to Globally Optimal Candidate)", fontsize=14, pad=15) - ax.set_xlabel(r" Signature Size ($X \cdot$ Std)", fontsize=12, fontweight='bold') - ax.set_ylabel(r" Signing Cost ($X \cdot$ Std)", fontsize=12, fontweight='bold') + ax.set_xlabel(r" Size ($X \cdot$ Std)", fontsize=12, fontweight='bold') + ax.set_ylabel(r" SigGen ($X \cdot$ Std)", fontsize=12, fontweight='bold') 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 91% rename from utils/sieve_search_draw.py rename to analysis/sieve_search_draw.py index aac112f..8ce5473 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): @@ -127,13 +128,13 @@ def apply_sequential_filters(df, minimax_val, X): def draw_sieve_stage(stage_index, pts, survived_indices, newly_killed_indices_list, filenames, winner_idx, df, euclidean_winner_idx, label_override=None, annotate_indices=None): stages_meta = [ - {"title": "Image 1: Initial Pool (128-bit Secure)", "color": "white", "label": "Security", "filter_txt": "Initial Data"}, - {"title": r"Image 2: Filter Size", "color": "#a0c4ff", "label": "Size Sieve", "filter_txt": "Signature Size Sieve"}, - {"title": r"Image 3: Filter KeyGen", "color": "#bdb2ff", "label": "KG Sieve", "filter_txt": "Key Generation Sieve"}, - {"title": r"Image 4: Filter SigGen", "color": "#ffadad", "label": "SG Sieve", "filter_txt": "Signature Generation Sieve"}, - {"title": r"Image 5: Filter Verification", "color": "#b9fbc0", "label": "VF Sieve", "filter_txt": "Signature Verification Sieve"}, - {"title": "Image 6: Filter Compression per Byte", "color": "#ffd6a5", "label": "C/B Sieve", "filter_txt": "Compression per Byte Sieve"}, - {"title": "Image 7: Weighted Euclidean Distance", "color": "#e2e2e2", "label": "Dist Sieve", "filter_txt": "Distance to Ideal Origin (0,0,0,0,0)"} + {"color": "white", "label": "Security", "filter_txt": "Initial Data"}, + {"color": "#a0c4ff", "label": "Size Sieve", "filter_txt": "Size Sieve"}, + {"color": "#bdb2ff", "label": "KeyGen Sieve", "filter_txt": "KeyGen Sieve"}, + {"color": "#ffadad", "label": "SigGen Sieve", "filter_txt": "SigGen Sieve"}, + {"color": "#b9fbc0", "label": "SigVer Sieve", "filter_txt": "SigVer Sieve"}, + {"color": "#ffd6a5", "label": "ρ Sieve", "filter_txt": "ρ Sieve"}, + {"color": "#e2e2e2", "label": "Dist Sieve", "filter_txt": "Distance to Ideal Origin (0,0,0,0,0)"} ] sns.set_theme(style="white", context="paper") @@ -294,14 +295,11 @@ 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" if args.distance == "normalized": - label = "Metric-Normalised Weighted Distance" + label = "Metric-Normalized Weighted Distance" sub = df.loc[final_survivors] acc = np.zeros(len(sub)) for col, wkey in (('size_X', 'size'), ('keygen_X', 'keygen'), 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/1-introduction.tex b/document/content/1-introduction.tex index 51d8a3a..1f8b24f 100644 --- a/document/content/1-introduction.tex +++ b/document/content/1-introduction.tex @@ -1,10 +1,10 @@ \section{Introduction} \label{sec:introduction} In the case of Bitcoin, hash-based signature schemes are the most compatible among all quantum-resistant signature algorithms. Still, the smallest stateless hash-based signature proposed by NIST (SLH-DSA-128s) is \emph{7,856} bytes \cite{nist}, which is more than $100\times$ larger than the current Schnorr signature. The question is whether we can have something smaller and more practical without significant deviation from the standard. -SHRINCS \cite{delving-post} proposes a hybrid signature consisting of a stateful and a stateless part, with signature size ranging from \emph{324} bytes (stateful) to \emph{X} bytes (stateless). This document attempts to answer the question of what the value of \emph{X} might be while keeping other signature metrics (KeyGen, SigGen, SigVer, etc.) practical. +SHRINCS \cite{delving-post} proposes a hybrid signature consisting of a stateful and a stateless part, with signature size ranging from \emph{548} bytes (the first stateful signature with $w = 16$, as in the reference implementation\footnote{\url{https://github.com/SHRINCS/shrincs-bip}}) to \emph{X} bytes (stateless). This document attempts to answer the question of what the value of \emph{X} might be while keeping other signature metrics (KeyGen, SigGen, SigVer, etc.) practical. In this document, we present the methodology for searching parameters of SLH-DSA-compatible signatures: how changes in the hyper-tree structure and other parameters affect the signature metrics. As mentioned above, we deliberately stay within very conservative bounds and avoid any changes to the signature sub-algorithms in order to keep the construction as compatible as possible with the SLH-DSA standard. \textit{Along the way, we will occasionally reference promising proposals such as hyper-tree pruning \cite{conduition-blog} and the +C compression technique \cite{mike-paper}, which allow for even better shrinking results. Although some of them can be considered partially compatible, we do not rely on them in the evaluation framework. We still think it is useful to outline the potential of such techniques and to let the reader tweak the parameters accordingly.} -The main contributions of this report are (1) a sweep script, (2) the filtering pipeline, (3) a recommended candidate tuple, and (4) a framework itself. \ No newline at end of file +The main contributions of this report are (1) a sweep script, (2) the filtering pipeline, (3) a recommended candidate tuple, and (4) the framework itself. \ No newline at end of file diff --git a/document/content/2-parameters.tex b/document/content/2-parameters.tex index ea76715..ea5617d 100644 --- a/document/content/2-parameters.tex +++ b/document/content/2-parameters.tex @@ -3,7 +3,7 @@ \section{Parameters Description} \subsection{Initial Bounds} As mentioned in the introduction, our objective is to find parameters that make the signature ``better'' than SLH-DSA. Before we can do so, we have to fix the strict boundaries that we will not cross: \begin{enumerate} - \item We obviously do not want a signature larger than SLH-DSA - otherwise, why bother in the first place? + \item We obviously do not want a signature larger than SLH-DSA — otherwise, why bother in the first place? \item For Bitcoin, we require at least a 128-bit security level. None of the parameters we select may degrade security below this floor. \item At least $q_{s} = 2^{40}$ supported signatures. SLH-DSA-128s defines the maximum number of signatures as $q_{s} = 2^{64}$, which is generous. For Bitcoin, we assume that $q_{s} = 2^{40}$ is a practical compromise, comfortably covering the foreseeable needs of on-chain operations and the Lightning Network. \item Sub-algorithms, hash, and addressing functions remain unchanged. @@ -22,7 +22,7 @@ \subsection{SLH-DSA Parameters} \begin{equation*} h' = \frac{h}{d}. \end{equation*} -Adjusting the ratio between $h$ and $d$ governs the balance between signature size and key-generation cost. Reducing the number of layers $d$ shrinks the total number of intermediate public keys and authentication paths in the signature payload, saving valuable bytes. However, this exponentially increases the height $h'$ of each constituent tree, requiring significantly more hash operations to compute the root node during key generation. +Adjusting the ratio between $h$ and $d$ governs the balance between signature size and key-generation cost. Reducing the number of layers $d$ shrinks the total number of intermediate public keys and authentication paths in the signature payload, saving valuable bytes. However, this increases the height $h'$ of each constituent tree, requiring exponentially more hash operations to compute the root node during key generation. \paragraph{Few-Time Signature Layer ($k$ and $a$).} The lowest layer of the hyper-tree authenticates the Forest of Random Subsets (FORS) few-time signature scheme, which directly signs the message digest. @@ -34,7 +34,7 @@ \subsection{SLH-DSA Parameters} Increasing $k$ and $a$ strengthens the scheme against multi-target subset-forgery attacks and allows the structure to support larger capacities safely. However, expanding these dimensions directly increases the byte footprint of the appended authentication paths. \paragraph{One-Time Signature Base ($w$).} -Internal tree roots are signed using the Winternitz One-Time Signature Plus (WOTS+) scheme. The base parameter $w$ defines the digit representation used to split the message digest during signing. Our framework evaluates candidate configurations across the SLH-DSA-compatible settings $w \in \{2, 4, 16, 256\}$: these are exactly the powers of two for which $\lg w$ divides $8$, so the \texttt{base\_2b} message digits remain byte-aligned and the SLH-DSA algorithm structure carries over unchanged (FIPS 205 standardises $w = 16$; the remaining values are exploratory). The non-divisor powers of two ($w \in \{8, 32, 64, 128\}$) are deliberately excluded. +Internal tree roots are signed using the Winternitz One-Time Signature Plus (WOTS+) scheme. The base parameter $w$ defines the digit representation used to split the message digest during signing. Our framework evaluates candidate configurations across the SLH-DSA-compatible settings $w \in \{2, 4, 16, 256\}$: these are exactly the powers of two for which $\lg w$ divides $8$, so the \texttt{base\_2b} message digits remain byte-aligned and the SLH-DSA algorithm structure carries over unchanged (FIPS 205 standardizes $w = 16$; the remaining values are exploratory). The non-divisor powers of two ($w \in \{8, 32, 64, 128\}$) are deliberately excluded. Selecting a larger base reduces the number of parallel hash chains $\ell$, which in turn reduces the overall WOTS+ payload size. This spatial efficiency comes at the cost of local compute: larger base parameters require longer hash chains, resulting in higher execution costs during key and signature generation. @@ -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 +\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..5f61d58 100644 --- a/document/content/3-search.tex +++ b/document/content/3-search.tex @@ -4,51 +4,44 @@ \subsection{Initial Search Conditions} Searching for the best parameters requires defining the conditions that the chosen parameter set must satisfy. Consequently, those conditions must rely on metrics that depend on the parameters. We use the following: \begin{itemize} - \item \textbf{Signature size}: the size of the signature in bytes; - \item \textbf{Key generation cost}: the number of SHA-256 compressions during the key-generation phase; - \item \textbf{Signature generation cost}: the number of compressions executed during signing; - \item \textbf{Signature verification cost}: the number of compressions during verification; - \item \textbf{Compressions per byte}: the ratio of verification cost to signature size. + \item \textbf{Size}: the size of the signature in bytes (B); + \item \textbf{KeyGen}: the number of SHA-256 compressions ($C$) to generate a key pair; + \item \textbf{SigGen}: the number of compressions to sign a message; + \item \textbf{SigVer}: the number of compressions to verify a signature; + \item \textbf{$\rho$}: SigVer per signature byte, in $C$/B. \end{itemize} We choose SLH-DSA-128s as the baseline for the search; this gives us an intuitive way to compare custom parameter sets against the standardized scheme on equal footing. -\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$. +Costs count SHA-256 compressions with the $\mathsf{PK.seed}$ block compressed once per key pair, as FIPS~205 allows and the SHRINCS reference implementation does; Appendix~\ref{app:hash-convention} details this convention and gives the SLH-DSA-128s costs under it. \subsection{Toolset} The methodology described here uses a set of SageMath scripts available at \url{https://github.com/BlockstreamResearch/SPHINCS-Parameters}. +All figures and tables in this report are generated by the accompanying pipeline (\texttt{make generate}). An interactive explorer that reproduces these searches — including the filter coefficients, decisional weights, and distance criteria used in this report — is publicly available at \url{https://blockstreamresearch.github.io/SPHINCS-Parameters/site/}. + \subsection{Sweep Strategy} \label{sec:sweep-strategy} -We start by enumerating all parameter tuples $(h, d, k, a, w)$. Applying the initial bounds (security level, size $\leq$ SLH-DSA, at least $2^{40}$ supported signatures) reduces the search space from \emph{34,580} candidates to \emph{7,570} - a $78\%$ cut. The remaining space is still too large to inspect by hand, so we need additional filters. +We start by enumerating all parameter tuples $(h, d, k, a, w)$. Applying the initial bounds (security level, size $\leq$ SLH-DSA, at least $2^{40}$ supported signatures) reduces the search space from \emph{34,580} candidates to \emph{7,570} — a $78\%$ cut. The remaining space is still too large to inspect by hand, so we need additional filters. We introduce the following filters\footnote{The coefficient for each filter, except the signature size, may be greater than 1 (meaning we accept some regression on that metric in exchange for gains elsewhere).}: \begin{itemize} - \item \textbf{Bound on signature size}: $\mathsf{target.size} \leq c_{\text{size}} \cdot \mathsf{std.size}$, where $c_{\text{size}}$ is a size coefficient ($0 \leq c_{\text{size}} \leq 1$). - \item \textbf{Bound on key-generation cost}: $\mathsf{target.KeyGen} \leq c_{\text{kg}} \cdot \mathsf{std.KeyGen}$, where $c_{\text{kg}} > 0$. - \item \textbf{Bound on signature-generation cost}: $\mathsf{target.SigGen} \leq c_{\text{sg}} \cdot \mathsf{std.SigGen}$, where $c_{\text{sg}} > 0$. - \item \textbf{Bound on signature-verification cost}: $\mathsf{target.SigVer} \leq c_{\text{sv}} \cdot \mathsf{std.SigVer}$, where $c_{\text{sv}} > 0$. - \item \textbf{Bound on the compressions-per-byte ratio}: $\mathsf{target.C/B} \leq c_{\text{cb}} \cdot \mathsf{std.C/B}$, where $c_{\text{cb}} > 0$. + \item \textbf{Bound on size}: $\mathsf{target.size} \leq c_{\text{size}} \cdot \mathsf{std.size}$, where $c_{\text{size}}$ is a size coefficient ($0 \leq c_{\text{size}} \leq 1$). + \item \textbf{Bound on KeyGen}: $\mathsf{target.KeyGen} \leq c_{\text{kg}} \cdot \mathsf{std.KeyGen}$, where $c_{\text{kg}} > 0$. + \item \textbf{Bound on SigGen}: $\mathsf{target.SigGen} \leq c_{\text{sg}} \cdot \mathsf{std.SigGen}$, where $c_{\text{sg}} > 0$. + \item \textbf{Bound on SigVer}: $\mathsf{target.SigVer} \leq c_{\text{sv}} \cdot \mathsf{std.SigVer}$, where $c_{\text{sv}} > 0$. + \item \textbf{Bound on $\rho$}: $\mathsf{target}.\rho \leq c_{\text{cb}} \cdot \mathsf{std}.\rho$, where $c_{\text{cb}} > 0$. \end{itemize} +Verification and signing costs are averages over uniformly random messages; Appendix~\ref{app:average-case} compares them with the worst case. -\paragraph{Note:} -All verification costs reported in this section are \emph{average-case}: they reflect the expected number of compressions for a uniformly random message. -The expectation is exact. The message chains of a WOTS signature are walked for $(w-1)/2$ steps on average, but the checksum chains are not, because the leading base-$w$ digit of the checksum is almost always $0$ or $1$: for $w = 16$ a signature takes $267.1$ chain steps on average, rather than the $262.5$ that $(w-1)/2$ steps per chain would give. -The \emph{worst-case} verification cost is approximately $1.78\times$ the average for the standard parameter set ($3{,}893$ vs.\ $2{,}193$ compressions): the WOTS chain-walk component alone grows by up to ${\approx}1.91\times$, while the FORS and authentication-path costs are message-independent. -The worst case depends solely on the message being signed — it cannot be influenced by the verifier. -We also use an \emph{average-case} signing cost, while the \emph{worst-case} does not significantly increase the number of compressions required to generate a signature. - These filters can be combined and parameterized in different ways to produce different result sets. Below, we consider several cases to illustrate how the choice of filter parameters affects the resulting candidate set. \subsubsection{Case 1: Best in Class} In this case, we find the most efficient signature for a single metric at a time, leaving the other metrics unbounded. The results are shown in Table~\ref{tab:minimal_metric_each}. -\begin{table}[h!] +\begin{table}[!ht] \small \raggedright \caption{Absolute Global Extremes} @@ -63,13 +56,13 @@ \subsubsection{Case 1: Best in Class} %--- Smallest Signature & $(50, 2, 6, 20, 256)$ & \textcolor{green}{$3408$} & \textcolor{green}{$-56.6\%$} & \textcolor{red}{$1.55 \times 10^{11}$} & \textcolor{red}{$5.30 \times 10^5$} & \textcolor{red}{$3.10 \times 10^{11}$} & \textcolor{red}{$1.40 \times 10^5$} & \textcolor{red}{$5022$} & \textcolor{red}{$2.29$} & \textcolor{red}{$1.47$} \\ %--- -Fastest Keygen & $(40, 8, 7, 20, 16)$ & \textcolor{red}{$7488$} & \textcolor{red}{$-4.7\%$} & \textcolor{green}{$1.83 \times 10^4$} & \textcolor{green}{$0.06$} & \textcolor{red}{$2.22 \times 10^7$} & \textcolor{red}{$9.99$} & \textcolor{orange}{$2411$} & \textcolor{orange}{$1.10$} & \textcolor{orange}{$0.32$} \\ +Fastest KeyGen & $(40, 8, 7, 20, 16)$ & \textcolor{red}{$7488$} & \textcolor{red}{$-4.7\%$} & \textcolor{green}{$1.83 \times 10^4$} & \textcolor{green}{$0.06$} & \textcolor{red}{$2.22 \times 10^7$} & \textcolor{red}{$9.99$} & \textcolor{orange}{$2411$} & \textcolor{orange}{$1.10$} & \textcolor{orange}{$0.32$} \\ %--- -Fastest Signing & $(42, 7, 20, 8, 16)$ & \textcolor{red}{$7488$} & \textcolor{red}{$-4.7\%$} & \textcolor{green}{$3.65 \times 10^4$} & \textcolor{green}{$0.12$} & \textcolor{green}{$2.71 \times 10^5$} & \textcolor{green}{$0.12$} & \textcolor{orange}{$2172$} & \textcolor{orange}{$0.99$} & \textcolor{orange}{$0.29$} \\ +Fastest SigGen & $(42, 7, 20, 8, 16)$ & \textcolor{red}{$7488$} & \textcolor{red}{$-4.7\%$} & \textcolor{green}{$3.65 \times 10^4$} & \textcolor{green}{$0.12$} & \textcolor{green}{$2.71 \times 10^5$} & \textcolor{green}{$0.12$} & \textcolor{orange}{$2172$} & \textcolor{orange}{$0.99$} & \textcolor{orange}{$0.29$} \\ %--- -Fastest Verification & $(50, 2, 6, 20, 2)$ & \textcolor{red}{$7184$} & \textcolor{red}{$-8.6\%$} & \textcolor{red}{$1.03 \times 10^{10}$} & \textcolor{red}{$3.54 \times 10^4$} & \textcolor{red}{$2.07 \times 10^{10}$} & \textcolor{red}{$9.33 \times 10^3$} & \textcolor{green}{$389$} & \textcolor{green}{$0.18$} & \textcolor{green}{$0.05$} \\ +Fastest SigVer & $(50, 2, 6, 20, 2)$ & \textcolor{red}{$7184$} & \textcolor{red}{$-8.6\%$} & \textcolor{red}{$1.03 \times 10^{10}$} & \textcolor{red}{$3.54 \times 10^4$} & \textcolor{red}{$2.07 \times 10^{10}$} & \textcolor{red}{$9.33 \times 10^3$} & \textcolor{green}{$389$} & \textcolor{green}{$0.18$} & \textcolor{green}{$0.05$} \\ %--- -Best Density Ratio (\texorpdfstring{$\rho$}{rho}) & $(50, 2, 6, 20, 2)$ & \textcolor{red}{$7184$} & \textcolor{red}{$-8.6\%$} & \textcolor{red}{$1.03 \times 10^{10}$} & \textcolor{red}{$3.54 \times 10^4$} & \textcolor{red}{$2.07 \times 10^{10}$} & \textcolor{red}{$9.33 \times 10^3$} & \textcolor{green}{$389$} & \textcolor{green}{$0.18$} & \textcolor{green}{$0.05$} \\ +Lowest $\rho$ & $(50, 2, 6, 20, 2)$ & \textcolor{red}{$7184$} & \textcolor{red}{$-8.6\%$} & \textcolor{red}{$1.03 \times 10^{10}$} & \textcolor{red}{$3.54 \times 10^4$} & \textcolor{red}{$2.07 \times 10^{10}$} & \textcolor{red}{$9.33 \times 10^3$} & \textcolor{green}{$389$} & \textcolor{green}{$0.18$} & \textcolor{green}{$0.05$} \\ \bottomrule \end{tabular}% } @@ -80,7 +73,7 @@ \subsubsection{Case 1: Best in Class} \subsubsection{Case 2: Balanced Metrics} In the next experiment, we look for ``average'' candidates whose metrics all stay below a uniform threshold $X \cdot \mathsf{std}$ (the filter is applied to every metric, with the exception of the maximum signature size $\leq \mathsf{std}$). By gradually increasing $X$, we obtain the following picture. -\begin{figure}[h!] +\begin{figure}[!ht] \centering \includegraphics[width=0.8\textwidth]{content/images/concentric_diagram.png} \caption{Global Minimax Expansion: Concentric Euler diagram illustrating the volume of candidates unlocked at expanding uniform multiplier thresholds.} @@ -89,7 +82,7 @@ \subsubsection{Case 2: Balanced Metrics} Naturally, the smaller the multiplier $X$, the fewer candidates survive the filter. A clear winner emerges at $X \approx 0.8666$; let us see how compelling it actually is (Table~\ref{tab:optimal_candidate_metrics}). -\begin{table}[h!] +\begin{table}[!ht] \centering \small \caption{Performance Comparison between the Standard Baseline and the Balanced Candidate} @@ -99,16 +92,16 @@ \subsubsection{Case 2: Balanced Metrics} \textbf{Metric} & \textbf{Standard Baseline} & \textbf{Optimal Candidate} & \textbf{Ratio} \\ \midrule Parameters $(h, d, k, a, w)$ & $(63, 7, 14, 12, 16)$ & $(40, 5, 19, 10, 16)$ & -- \\ - Signature Size (Bytes) & $7{,}856$ & $6{,}800$ & $0.8656\times$ \\ - Key Generation Cost & $292{,}351$ & $146{,}175$ & $0.5000\times$ \\ - Signing Cost & $2{,}218{,}486$ & $789{,}237$ & $0.3558\times$ \\ - Verification Cost & $2{,}193$ & $1{,}645$ & $0.7501\times$ \\ - Compression per Byte ($\rho$) & $0.2791$ & $0.2419$ & $0.8666\times$ \\ + Size (B) & $7{,}856$ & $6{,}800$ & $0.8656\times$ \\ + KeyGen ($C$) & $292{,}351$ & $146{,}175$ & $0.5000\times$ \\ + SigGen ($C$) & $2{,}218{,}486$ & $789{,}237$ & $0.3558\times$ \\ + SigVer ($C$) & $2{,}193$ & $1{,}645$ & $0.7501\times$ \\ + $\rho$ ($C$/B) & $0.2791$ & $0.2419$ & $0.8666\times$ \\ \bottomrule \end{tabular} \end{table} -One might call this a reasonable result. We are less impressed: a 12\% saving on signature size is not particularly attractive on its own (the goal is to materially close the gap with Schnorr). +One might call this a reasonable result. We are less impressed: a 13\% saving on signature size is not particularly attractive on its own (the goal is to materially close the gap with Schnorr). \subsubsection{Case 3: Adaptive Search} The strategy is straightforward: @@ -121,18 +114,18 @@ \subsubsection{Case 3: Adaptive Search} Each candidate is uniquely defined by its tuple of metric values, so the search for an optimal candidate can be cast as finding the point closest to the origin in a five-dimensional space (analogous to a shortest-vector problem, but mercifully confined to a small set of survivors), where each axis represents one of our metrics. That is almost the whole story. -To build intuition, suppose we care about only two metrics - signature size and signature generation cost. We require the signature size to be no larger than that of SLH-DSA, and we are willing to tolerate a signing cost of at most $2\times$ the standard. In this case all admissible candidates fit inside the green rectangle in Figure~\ref{fig:scatter_plot}, which makes brute-force inspection easy: the best candidate is simply the point closest to the origin $(0, 0)$. +To build intuition, suppose we care about only two metrics — signature size and signature generation cost. We require the signature size to be no larger than that of SLH-DSA, and we are willing to tolerate a signing cost of at most $2\times$ the standard. In this case all admissible candidates fit inside the green rectangle in Figure~\ref{fig:scatter_plot}, which makes brute-force inspection easy: the best candidate is simply the point closest to the origin $(0, 0)$. -\begin{figure}[h!] +\begin{figure}[!ht] \centering \includegraphics[width=0.7\textwidth]{content/images/scatter_plot.png} - \caption{Visualization of Candidates with Prioritization of Size and Signing Cost Metrics.} + \caption{Visualization of Candidates with Prioritization of Size and SigGen.} \label{fig:scatter_plot} \end{figure} We cannot directly visualize a 5-dimensional space, but we can show the filtering process in a different way. -First, we scatter the unbounded candidate set (subject only to the 128-bit security condition) on a plane, with positions chosen randomly - the layout carries no meaning, and each point is uniquely identified by its parameter tuple. After initialization, we apply the signature-size filter, visualized as a coloured ``sieve''. We then apply each remaining filter in turn until the last one. +First, we scatter the unbounded candidate set (subject only to the 128-bit security condition) on a plane, with positions chosen randomly — the layout carries no meaning, and each point is uniquely identified by its parameter tuple. After initialization, we apply the signature-size filter, visualized as a colored ``sieve''. We then apply each remaining filter in turn until the last one. The resulting set contains the candidates that survived every filter; this is the pool in which we search for the best one. @@ -143,7 +136,7 @@ \subsubsection{Case 3: Adaptive Search} The filtering process is shown in Figure~\ref{fig:full_filtering_process}. -\begin{figure}[h!] +\begin{figure}[!ht] \centering % Row 1 \begin{subfigure}[b]{0.32\textwidth} @@ -156,7 +149,7 @@ \subsubsection{Case 3: Adaptive Search} \begin{subfigure}[b]{0.32\textwidth} \centering \includegraphics[width=\textwidth]{content/images/sieve_step2_size.png} - \caption{SigSize} + \caption{Size} \label{fig:sieve_2} \end{subfigure} \hfill @@ -180,14 +173,14 @@ \subsubsection{Case 3: Adaptive Search} \begin{subfigure}[b]{0.32\textwidth} \centering \includegraphics[width=\textwidth]{content/images/sieve_step5_verify.png} - \caption{SigVerify} + \caption{SigVer} \label{fig:sieve_5} \end{subfigure} \hfill \begin{subfigure}[b]{0.32\textwidth} \centering \includegraphics[width=\textwidth]{content/images/sieve_step6_rho.png} - \caption{Comp/Byte} + \caption{$\rho$} \label{fig:sieve_6} \end{subfigure} @@ -195,7 +188,7 @@ \subsubsection{Case 3: Adaptive Search} \label{fig:full_filtering_process} \end{figure} -We start with \emph{25,916} candidates. The signature-size filter trims the pool to \emph{4,814} and eliminates every $w = 2$ candidate; the next filter (KeyGen) cuts it by roughly $9\times$ and removes the last $w = 4$ candidates. Each subsequent filter prunes further, leaving \emph{61} final candidates for inspection: the verification-cost bound eliminates every $w = 256$ candidate in this example, so all survivors use $w = 16$ and the surviving set is identical to the one obtained on the previous $w \in \{16, 32, 64, 128, 256\}$ grid. +We start with \emph{25,916} candidates. The signature-size filter trims the pool to \emph{4,814} and eliminates every $w = 2$ candidate; the next filter (KeyGen) cuts it by roughly $9\times$ and removes the last $w = 4$ candidates. Each subsequent filter prunes further, leaving \emph{61} final candidates for inspection: the verification-cost bound eliminates every $w = 256$ candidate in this example, so all survivors use $w = 16$. With the final 61 candidates in hand, we are ready to pick a winner. @@ -209,7 +202,7 @@ \subsubsection{Case 3: Adaptive Search} This treats every metric as equally important, which is rarely what we actually want. Signature size, for example, usually carries far more weight than key-generation cost in a Bitcoin context. The next subsection addresses this by attaching weights to each axis. \subsection{Decisional Weights} \label{sec:decisional-weights} -We can do better by applying the \emph{weighted} Euclidean distance. The motivation is that weights give us a principled way to encode the relative priority of each metric. Let $w_{\text{size}}, w_{\text{kg}}, w_{\text{sg}}, w_{\text{sv}}, w_{\text{cb}} \in \mathbb{R}^+$ denote the raw weight coefficients representing the relative importance of signature size, key generation, signing, verification, and compressions per byte, respectively. +We can do better by applying the \emph{weighted} Euclidean distance. The motivation is that weights give us a principled way to encode the relative priority of each metric. Let $w_{\text{size}}, w_{\text{kg}}, w_{\text{sg}}, w_{\text{sv}}, w_{\text{cb}} \in \mathbb{R}^+$ denote the raw weight coefficients representing the relative importance of size, KeyGen, SigGen, SigVer and $\rho$, respectively. To keep the calculation proportionally consistent regardless of the absolute scale of the chosen weights, we normalize them into coefficients $p_i$ (where $i \in \{\text{size}, \text{kg}, \text{sg}, \text{sv}, \text{cb}\}$) such that they sum to $1$: \begin{equation} @@ -225,7 +218,7 @@ \subsection{Decisional Weights} \label{sec:decisional-weights} As before, each $\widetilde{X}_i$ is the ratio of the candidate's metric to its standard counterpart, so the five axes remain on a common dimensionless scale. -To visualize the distances, we map each candidate's distance onto a colour scale: lower distances shade towards blue, higher distances towards red. As an example, consider the following two weight settings: +To visualize the distances, we map each candidate's distance onto a color scale: lower distances shade towards blue, higher distances towards red. As an example, consider the following two weight settings: \begin{itemize} \item Case 1: $w_{\text{size}} = w_{\text{kg}} = w_{\text{sg}} = w_{\text{sv}} = w_{\text{cb}} = 1$; \item Case 2: $w_{\text{size}} = 5,\ w_{\text{kg}} = 1,\ w_{\text{sg}} = 1.5,\ w_{\text{sv}} = 2,\ w_{\text{cb}} = 1$. @@ -236,7 +229,7 @@ \subsection{Decisional Weights} \label{sec:decisional-weights} \begin{figure}[htbp] \centering \includegraphics[width=0.75\textwidth]{content/images/sieve_step7_distance_1.png} - \caption{Visualization of Searching for the Case 1.} + \caption{Visualization of the Search for Case 1.} \label{fig:distance_plot_1} \end{figure} @@ -253,11 +246,11 @@ \subsection{Decisional Weights} \label{sec:decisional-weights} \textbf{Metric} & \textbf{Standard Baseline} & \textbf{Best Candidate} & \textbf{Ratio to Std.} \\ \midrule Parameters $(h, d, k, a, w)$ & $(63, 7, 14, 12, 16)$ & $(40, 5, 19, 9, 16)$ & -- \\ - Signature Size (Bytes) & $7,856$ & $6,496$ & $0.8269\times$ \\ - Key Generation Cost & $292,351$ & $146,175$ & $0.5000\times$ \\ - Signing Cost & $2,218,486$ & $760,053$ & $0.3426\times$ \\ - Verification Cost & $2,193$ & $1,626$ & $0.7415\times$ \\ - Compression per Byte ($\rho$) & $0.2791$ & $0.2503$ & $0.8967\times$ \\ + Size (B) & $7,856$ & $6,496$ & $0.8269\times$ \\ + KeyGen ($C$) & $292,351$ & $146,175$ & $0.5000\times$ \\ + SigGen ($C$) & $2,218,486$ & $760,053$ & $0.3426\times$ \\ + SigVer ($C$) & $2,193$ & $1,626$ & $0.7415\times$ \\ + $\rho$ ($C$/B) & $0.2791$ & $0.2503$ & $0.8967\times$ \\ \bottomrule \end{tabular} \end{table} @@ -269,7 +262,7 @@ \subsection{Decisional Weights} \label{sec:decisional-weights} \begin{figure}[htbp] \centering \includegraphics[width=0.75\textwidth]{content/images/sieve_step7_distance_2.png} - \caption{Visualization of Searching for the Case 2.} + \caption{Visualization of the Search for Case 2.} \label{fig:distance_plot_2} \end{figure} @@ -285,16 +278,16 @@ \subsection{Decisional Weights} \label{sec:decisional-weights} \textbf{Metric} & \textbf{Standard Baseline} & \textbf{Best Candidate} & \textbf{Ratio to Std.} \\ \midrule Parameters $(h, d, k, a, w)$ & $(63, 7, 14, 12, 16)$ & $(40, 5, 13, 12, 16)$ & -- \\ - Signature Size (Bytes) & $7,856$ & $6,160$ & $0.7841\times$ \\ - Key Generation Cost & $292,351$ & $146,175$ & $0.5000\times$ \\ - Signing Cost & $2,218,486$ & $890,617$ & $0.4015\times$ \\ - Verification Cost & $2,193$ & $1,603$ & $0.7310\times$ \\ - Compression per Byte ($\rho$) & $0.2791$ & $0.2602$ & $0.9322\times$ \\ + Size (B) & $7,856$ & $6,160$ & $0.7841\times$ \\ + KeyGen ($C$) & $292,351$ & $146,175$ & $0.5000\times$ \\ + SigGen ($C$) & $2,218,486$ & $890,617$ & $0.4015\times$ \\ + SigVer ($C$) & $2,193$ & $1,603$ & $0.7310\times$ \\ + $\rho$ ($C$/B) & $0.2791$ & $0.2602$ & $0.9322\times$ \\ \bottomrule \end{tabular} \end{table} -Comparing the two cases, the resulting metrics differ noticeably. The first case yields more balanced costs across the board, while the second case clearly favours signature size and verification at the expense of a heavier signing operation - exactly the trade-off we asked the weights to enforce. +Comparing the two cases, the resulting metrics differ noticeably. The first case yields more balanced costs across the board, while the second case clearly favors signature size and verification at the expense of a heavier signing operation — exactly the trade-off we asked the weights to enforce. \subsection{Metric-Normalized Weighted Distance} \label{sec:normalized-weights} @@ -337,27 +330,23 @@ \subsection{Metric-Normalized Weighted Distance} \label{sec:normalized-weights} \end{equation} where the $p_i$ are the normalized weight coefficients from Equation~\eqref{eq:weight_normalization}. -The winner is still the candidate minimising $d(P)$, but now the weight $p_i$ faithfully controls how much axis $i$ influences the ranking: because every $\widehat{X}_i$ spans the same $[0, 1]$ interval, a coefficient $p_{\text{size}} = 0.5$ means that signature size drives exactly half of the squared distance, independently of how tightly the size filter was set. - -\paragraph{Note:} -The normalization is computed fresh from the survivors after each change to the filter coefficients. -This means that the same weight vector $\mathbf{w}$ can produce a different winner when the filter is tightened, because the pool shrinks and the per-axis ranges change accordingly. -This behaviour is intentional: the weights express relative importance \emph{within the feasible region}, and that region is itself defined by the filter. +The winner is still the candidate minimizing $d(P)$, but now the weight $p_i$ faithfully controls how much axis $i$ influences the ranking: because every $\widehat{X}_i$ spans the same $[0, 1]$ interval, a coefficient $p_{\text{size}} = 0.5$ means that signature size drives exactly half of the squared distance, independently of how tightly the size filter was set. +The ranges come from the current survivors, so the same weights can select a different winner after the filter changes (Appendix~\ref{app:normalization}). -Consider the following filtering parameters and weights set for comparison of methods for finding the shortest vector with weights: +Consider the following filtering parameters and weights, chosen to compare the two methods of finding the shortest vector with weights: \begin{itemize} \item Filtering parameters: $X_{\text{size}} = 0.75, \ X_{\text{kg}} = 5, \ X_{\text{sg}} = 5, \ X_{\text{sv}} = 1.5, \ X_{\text{cb}} = 1$; - \item Weights coefficients: $w_{\text{size}} = 5, \ w_{\text{kg}} = 1.5, \ w_{\text{sg}} = 1.5, \ w_{\text{sv}} = 2, \ w_{\text{cb}} = 1$. + \item Weight coefficients: $w_{\text{size}} = 5, \ w_{\text{kg}} = 1.5, \ w_{\text{sg}} = 1.5, \ w_{\text{sv}} = 2, \ w_{\text{cb}} = 1$. \end{itemize} -The resulting best candidates for each method shown in the Table~\ref{tab:distance_method_comparison}. +The resulting best candidates for each method are shown in Table~\ref{tab:distance_method_comparison}. \begin{table}[htbp] \centering \small \raggedright -\caption{Comparison between the Best Candidates under the Regular Weighted and Metric-Normalised Weighted Distance Criteria} +\caption{Comparison between the Best Candidates under the Regular Weighted and Metric-Normalized Weighted Distance Criteria} \label{tab:distance_method_comparison} \resizebox{\textwidth}{!}{% \begin{tabular}{lrrrrr} @@ -370,11 +359,11 @@ \subsection{Metric-Normalized Weighted Distance} \label{sec:normalized-weights} \textbf{Ratio to Std.} \\ \midrule Parameters $(h, d, k, a, w)$ & $(63, 7, 14, 12, 16)$ & $(40, 4, 9, 16, 16)$ & -- & $(45, 5, 12, 11, 16)$ & -- \\ -Signature Size (Bytes) & $7{,}856$ & $5{,}344$ & $0.6802\times$ & $5{,}840$ & $0.7434\times$ \\ -Key Generation Cost & $292{,}351$ & $584{,}703$ & $2.0000\times$ & $292{,}351$ & $1.0000\times$ \\ -Signing Cost & $2{,}218{,}486$ & $4{,}108{,}285$ & $1.8518\times$ & $1{,}535{,}482$ & $0.6921\times$ \\ -Verification Cost & $2{,}193$ & $1{,}308$ & $0.5964\times$ & $1{,}583$ & $0.7218\times$ \\ -Compression per Byte ($\rho$) & $0.2791$ & $0.2448$ & $0.8768\times$ & $0.2711$ & $0.9710\times$ \\ +Size (B) & $7{,}856$ & $5{,}344$ & $0.6802\times$ & $5{,}840$ & $0.7434\times$ \\ +KeyGen ($C$) & $292{,}351$ & $584{,}703$ & $2.0000\times$ & $292{,}351$ & $1.0000\times$ \\ +SigGen ($C$) & $2{,}218{,}486$ & $4{,}108{,}285$ & $1.8518\times$ & $1{,}535{,}482$ & $0.6921\times$ \\ +SigVer ($C$) & $2{,}193$ & $1{,}308$ & $0.5964\times$ & $1{,}583$ & $0.7218\times$ \\ +$\rho$ ($C$/B) & $0.2791$ & $0.2448$ & $0.8768\times$ & $0.2711$ & $0.9710\times$ \\ \bottomrule \end{tabular}% } @@ -383,10 +372,10 @@ \subsection{Metric-Normalized Weighted Distance} \label{sec:normalized-weights} Comparing the two cases, the resulting metrics are different. The regular weighted distance chooses a more balanced best candidate, while the metric-normalized weighted distance selects a candidate with a noticeably smaller signature size, trading the key and signature generation costs. -The outcome of each distance method shown in the Figure~\ref{fig:distance_result_comparison}. +The outcome of each distance method is shown in Figure~\ref{fig:distance_result_comparison}. -\begin{figure}[h!] +\begin{figure}[!ht] \centering \begin{subfigure}[b]{0.49\textwidth} \centering @@ -398,77 +387,9 @@ \subsection{Metric-Normalized Weighted Distance} \label{sec:normalized-weights} \begin{subfigure}[b]{0.49\textwidth} \centering \includegraphics[width=\textwidth]{content/images/comparison_normalized.png} - \caption{Metric-Normalised Weighted Distance Result} + \caption{Metric-Normalized Weighted Distance Result} \label{fig:metric-normalised} \end{subfigure} \caption{Visualization of each Distance Method Result for Finding the Shortest Vector} \label{fig:distance_result_comparison} \end{figure} - - - -\section{Recommended Candidate Tuple} \label{sec:ideal-examples} - -To demonstrate the full pipeline on a concrete parameter set we selected, we fix the following filtering coefficients: -\begin{equation*} - X_{\text{size}} = 0.74, \quad X_{\text{kg}} = 2.0, \quad X_{\text{sg}} = 2.0, - \quad X_{\text{sv}} = 0.8, \quad X_{\text{cb}} = 1.0. -\end{equation*} -These coefficients say that we really care about the signature size (to make at least 25\% win) and the verification complexity. At the same time having the key and signature complexity 2$\times$ at most is an acceptable trade-off because (1) it's not very critical taking into account even hardware wallet takes 2-3 seconds to produce SLH-DSA signature, (2) we can apply pruning technique \cite{conduition-blog} to reduce it additionally. - -Applying these constraints to the full parameter grid leaves exactly two surviving configurations, whose metrics are compared against the standard baseline in Table~\ref{tab:euclidean_candidate_metrics_ideal}. - -\begin{table}[htbp] -\centering -\small -\raggedright -\caption{Performance Comparison between the Standard Baseline and the Two Evaluated Candidates} -\label{tab:euclidean_candidate_metrics_ideal} -\resizebox{\textwidth}{!}{% -\begin{tabular}{lrrrrr} -\toprule -\textbf{Metric} & \textbf{Standard Baseline} & \textbf{Candidate 1} & -\textbf{Ratio to Std.} & \textbf{Candidate 2} & \textbf{Ratio to Std.} \\ -\midrule -Parameters $(h, d, k, a, w)$ & $(63, 7, 14, 12, 16)$ & $(45, 5, 10, 13, 16)$ & -- & $(45, 5, 8, 16, 16)$ & -- \\ -Signature Size (Bytes) & $7{,}856$ & $5{,}776$ & $0.7352\times$ & $5{,}712$ & $0.7271\times$ \\ -Key Generation Cost & $292{,}351$ & $292{,}351$ & $1.0000\times$ & $292{,}351$ & $1.0000\times$ \\ -Signing Cost & $2{,}218{,}486$ & $1{,}707{,}515$ & $0.7697\times$ & $3{,}034{,}621$ & $1.3679\times$ \\ -Verification Cost & $2{,}193$ & $1{,}578$ & $0.7196\times$ & $1{,}574$ & $0.7177\times$ \\ -Compression per Byte ($\rho$) & $0.2791$ & $0.2732$ & $0.9787\times$ & $0.2756$ & $0.9871\times$ \\ -\bottomrule -\end{tabular} -} -\end{table} - -As the table makes clear, the two survivors represent distinct operational trade-offs. -\begin{itemize} - \item Candidate~1 offers a well-rounded improvement: signature size, signing cost, and verification cost all decrease relative to the standard. - \item Candidate~2 pushes further on size and verification, but at a concrete price - its signing cost exceeds the standard by roughly $1.37\times$, making it suitable only in contexts where signing latency is secondary. -\end{itemize} - -\paragraph{Note:} -The sizes in this report are those of the SLH-DSA signature itself. SHRINCS places a one-byte indicator in front of every signature, so a stateless SHRINCS signature under Candidate~1 occupies $5{,}777$ bytes. - -Under the regular weighted distance of Equation~\eqref{eq:weighted_euclidean_distance}, Candidate~1 attains the smallest distance for any weight vector that does not entirely discount signing cost: Candidate~2's advantages are marginal ($64$~B of size, $4$ compressions of verification) while its signing cost is roughly $1.8\times$ higher. Note that the metric-normalized distance of Section~\ref{sec:normalized-weights} is not informative in this specific case: with only two survivors, every axis collapses to its endpoints $\{0, 1\}$, and the ranking degenerates into a bare comparison of weight sums. -The results of the search are presented in Figure~\ref{fig:ideal_distance_plot}. -\begin{figure}[htbp] -\centering - \begin{subfigure}[b]{0.49\textwidth} - \centering - \includegraphics[width=\textwidth]{content/images/ideal_candidate_balanced.png} - \caption{Regular weighted distance: Candidate~1 wins} - \label{fig:ideal-balanced} - \end{subfigure} - \hfill - \begin{subfigure}[b]{0.49\textwidth} - \centering - \includegraphics[width=\textwidth]{content/images/ideal_candidate_best.png} - \caption{Metric-normalised distance degenerates to Candidate~2} - \label{fig:ideal-best} - \end{subfigure} -\caption{The Two Surviving Candidates under Both Distance Criteria} -\label{fig:ideal_distance_plot} -\end{figure} - -All figures and tables in this report are generated by the accompanying pipeline (\texttt{make generate}). An interactive explorer that reproduces these searches — including the filter coefficients, decisional weights, and distance criteria used above — is publicly available at \url{https://blockstreamresearch.github.io/SPHINCS-Parameters/site/}. diff --git a/document/content/4-candidate.tex b/document/content/4-candidate.tex new file mode 100644 index 0000000..8ffb896 --- /dev/null +++ b/document/content/4-candidate.tex @@ -0,0 +1,62 @@ +\section{Recommended Candidate Tuple} \label{sec:ideal-examples} + +To demonstrate the full pipeline on a concrete parameter set, we fix the following filtering coefficients: +\begin{equation*} + X_{\text{size}} = 0.74, \quad X_{\text{kg}} = 2.0, \quad X_{\text{sg}} = 2.0, + \quad X_{\text{sv}} = 0.8, \quad X_{\text{cb}} = 1.0. +\end{equation*} +These coefficients say that we really care about the signature size (a saving of at least 25\%) and the verification complexity. At the same time, key and signature generation of at most 2$\times$ the standard cost is an acceptable trade-off because (1) it is not very critical, given that even a hardware wallet takes 2--3 seconds to produce an SLH-DSA signature, and (2) we can apply the pruning technique \cite{conduition-blog} to reduce it further. + +Applying these constraints to the full parameter grid leaves exactly two surviving configurations, whose metrics are compared against the standard baseline in Table~\ref{tab:euclidean_candidate_metrics_ideal}. + +\begin{table}[htbp] +\centering +\small +\raggedright +\caption{Performance Comparison between the Standard Baseline and the Two Evaluated Candidates} +\label{tab:euclidean_candidate_metrics_ideal} +\resizebox{\textwidth}{!}{% +\begin{tabular}{lrrrrr} +\toprule +\textbf{Metric} & \textbf{Standard Baseline} & \textbf{Candidate 1} & +\textbf{Ratio to Std.} & \textbf{Candidate 2} & \textbf{Ratio to Std.} \\ +\midrule +Parameters $(h, d, k, a, w)$ & $(63, 7, 14, 12, 16)$ & $(45, 5, 10, 13, 16)$ & -- & $(45, 5, 8, 16, 16)$ & -- \\ +Size (B) & $7{,}856$ & $5{,}776$ & $0.7352\times$ & $5{,}712$ & $0.7271\times$ \\ +KeyGen ($C$) & $292{,}351$ & $292{,}351$ & $1.0000\times$ & $292{,}351$ & $1.0000\times$ \\ +SigGen ($C$) & $2{,}218{,}486$ & $1{,}707{,}515$ & $0.7697\times$ & $3{,}034{,}621$ & $1.3679\times$ \\ +SigVer ($C$) & $2{,}193$ & $1{,}578$ & $0.7196\times$ & $1{,}574$ & $0.7177\times$ \\ +$\rho$ ($C$/B) & $0.2791$ & $0.2732$ & $0.9787\times$ & $0.2756$ & $0.9871\times$ \\ +\bottomrule +\end{tabular} +} +\end{table} + +Sizes are those of the SLH-DSA signature; SHRINCS adds a one-byte indicator (Appendix~\ref{app:framing}). + +As the table makes clear, the two survivors represent distinct operational trade-offs. +\begin{itemize} + \item Candidate~1 offers a well-rounded improvement: signature size, signing cost, and verification cost all decrease relative to the standard. + \item Candidate~2 pushes further on size and verification, but at a concrete price — its signing cost exceeds the standard by roughly $1.37\times$, making it suitable only in contexts where signing latency is secondary. +\end{itemize} + +Under the regular weighted distance of Equation~\eqref{eq:weighted_euclidean_distance}, Candidate~1 attains the smallest distance for any weight vector that does not entirely discount signing cost: Candidate~2's advantages are marginal ($64$~B of size, $4$ compressions of verification) while its signing cost is roughly $1.8\times$ higher. Note that the metric-normalized distance of Section~\ref{sec:normalized-weights} is not informative in this specific case: with only two survivors, every axis collapses to its endpoints $\{0, 1\}$, and the ranking degenerates into a bare comparison of weight sums. +The results of the search are presented in Figure~\ref{fig:ideal_distance_plot}. +\begin{figure}[htbp] +\centering + \begin{subfigure}[b]{0.49\textwidth} + \centering + \includegraphics[width=\textwidth]{content/images/ideal_candidate_balanced.png} + \caption{Regular weighted distance: Candidate~1 wins} + \label{fig:ideal-balanced} + \end{subfigure} + \hfill + \begin{subfigure}[b]{0.49\textwidth} + \centering + \includegraphics[width=\textwidth]{content/images/ideal_candidate_best.png} + \caption{Metric-normalized distance degenerates to Candidate~2} + \label{fig:ideal-best} + \end{subfigure} +\caption{The Two Surviving Candidates under Both Distance Criteria} +\label{fig:ideal_distance_plot} +\end{figure} diff --git a/document/content/appendix.tex b/document/content/appendix.tex new file mode 100644 index 0000000..9f83d99 --- /dev/null +++ b/document/content/appendix.tex @@ -0,0 +1,19 @@ +\section{Hash-Cost Convention} \label{app: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 key pair 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 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$. + +\section{Average-Case and Worst-Case Costs} \label{app:average-case} +All verification costs in this report are \emph{average-case}: they reflect the expected number of compressions for a uniformly random message. +The expectation is exact. The message chains of a WOTS signature are walked for $(w-1)/2$ steps on average, but the checksum chains are not, because the leading base-$w$ digit of the checksum is almost always $0$ or $1$: for $w = 16$ a signature takes $267.1$ chain steps on average, rather than the $262.5$ that $(w-1)/2$ steps per chain would give. +The \emph{worst-case} verification cost is approximately $1.78\times$ the average for the standard parameter set ($3{,}893$ vs.\ $2{,}193$ compressions): the WOTS chain-walk component alone grows by up to ${\approx}1.91\times$, while the FORS and authentication-path costs are message-independent. +The worst case depends solely on the message being signed — it cannot be influenced by the verifier. +We also use an \emph{average-case} signing cost; the \emph{worst case} does not significantly increase the number of compressions required to generate a signature. + +\section{Normalization within the Feasible Region} \label{app:normalization} +The normalization is computed fresh from the survivors after each change to the filter coefficients. +This means that the same weight vector $\mathbf{w}$ can produce a different winner when the filter is tightened, because the pool shrinks and the per-axis ranges change accordingly. +This behavior is intentional: the weights express relative importance \emph{within the feasible region}, and that region is itself defined by the filter. + +\section{SHRINCS Framing} \label{app:framing} +The sizes in this report are those of the SLH-DSA signature itself. SHRINCS places a one-byte indicator in front of every signature, so a stateless SHRINCS signature under Candidate~1 occupies $5{,}777$ bytes. diff --git a/document/main.tex b/document/main.tex index 53a215b..6dea334 100644 --- a/document/main.tex +++ b/document/main.tex @@ -9,10 +9,8 @@ \usepackage{subcaption} \usepackage{booktabs} \usepackage{array} -\usepackage{tikz} - -\usepackage{framed} - +\usepackage{xcolor} +\usepackage{xurl} \usepackage[colorlinks=true, linkcolor=black, @@ -20,6 +18,7 @@ urlcolor=blue!60!black]{hyperref} \title{SHRINCS Stateless Parameter Search Methodology} +\author{Blockstream Research} \date{\today} \begin{document} @@ -32,10 +31,14 @@ \input{content/1-introduction} \input{content/2-parameters} \input{content/3-search} +\input{content/4-candidate} \newpage \bibliographystyle{plain} \bibliography{refs} +\appendix +\input{content/appendix} + \end{document} \ No newline at end of file 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..93ff5ad 100644 --- a/site/index.html +++ b/site/index.html @@ -8,163 +8,17 @@ - - +

SHRINCS pinned parameters

Compare alternatives in the Parameter Explorer and the Stateful Comparison. -

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

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

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

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

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

+

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

+ ${winner.swn ? `

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

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

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

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

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

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

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

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

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

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

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

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

Glossary

diff --git a/site/stateful.html b/site/stateful.html index 3acba48..6dade9f 100644 --- a/site/stateful.html +++ b/site/stateful.html @@ -6,309 +6,16 @@ SHRINCS Stateful Comparison - - +

SHRINCS Stateful Comparison

Compare stateful schemes with SLH-DSA-128s, or add your own with + Add candidate. -

+
@@ -369,7 +76,7 @@

SHRINCS Stateful Comparison

-

Comparison

+

Comparison

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

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

All stateful candidates

+
@@ -601,7 +35,6 @@

Scheme

-

Winternitz parameter

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

Winternitz parameter