diff --git a/.github/workflows/build_and_deploy.yml b/.github/workflows/build_and_deploy.yml index ec45e97..750bf6f 100644 --- a/.github/workflows/build_and_deploy.yml +++ b/.github/workflows/build_and_deploy.yml @@ -8,7 +8,7 @@ name: Build report and deploy site on: push: - branches: [main, stateful] + branches: [main] tags: ['v*'] workflow_dispatch: @@ -23,10 +23,10 @@ concurrency: env: GENERATED_PATHS: | - utils/all_size_capped_candidates.csv - utils/all_unbound_candidates.csv - utils/global_X_thresholds.csv - utils/master_extremes_summary.csv + analysis/all_size_capped_candidates.csv + analysis/all_unbound_candidates.csv + analysis/global_X_thresholds.csv + analysis/master_extremes_summary.csv site/data.json document/content/images/sieve_step*.png document/content/images/concentric_diagram.png @@ -44,14 +44,14 @@ jobs: image: sagemath/sagemath:10.4 # pinned; bump deliberately and re-verify options: --user root # image's default user cannot write the runner workspace steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Restore generated artifacts (keyed on generator sources) id: cache - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ env.GENERATED_PATHS }} - key: generated-v1-${{ hashFiles('costs.sage', 'security.sage', 'slhdsa-2to40.sage', 'stateful.sage', 'export_site_data.sage', 'octopus_pmf.py', 'utils/*.py', 'Makefile') }} + key: generated-v1-${{ hashFiles('model/*.sage', 'scripts/*.sage', 'third_party/*.py', 'analysis/*.py', 'Makefile', 'parameters.json') }} - name: Generate (sweeps, derived tables, data.json, figures) if: steps.cache.outputs.cache-hit != 'true' @@ -65,7 +65,7 @@ jobs: make sweeps derived data figures PYTHON="sage -python" - name: Upload generated artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: generated path: ${{ env.GENERATED_PATHS }} @@ -77,22 +77,22 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Place generated artifacts into the tree - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: generated - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: '3.12' - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: '22' - name: Regression suite (generated artifacts required) - run: GENERATED_REQUIRED=1 python3 tests/test_regression.py + run: GENERATED_REQUIRED=1 python3 tests/run.py - name: Compile report uses: xu-cheng/latex-action@v3 @@ -104,20 +104,20 @@ jobs: run: cp document/main.pdf site/report.pdf - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: . # whole tree, preserving the /site/... URL structure - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 - name: Attach data + report to release (tags only) if: startsWith(github.ref, 'refs/tags/') - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: files: | site/report.pdf site/data.json - utils/all_size_capped_candidates.csv - utils/all_unbound_candidates.csv + analysis/all_size_capped_candidates.csv + analysis/all_unbound_candidates.csv diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 913a02d..a1a05a8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,21 +2,21 @@ name: tests on: push: - branches: [main, stateful] + branches: [main] pull_request: jobs: regression: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: '3.12' - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: '22' - name: Synthesize generated artifacts from the reference model run: python3 tests/synth_generated.py - name: Run regression suite - run: GENERATED_REQUIRED=1 python3 tests/test_regression.py + run: GENERATED_REQUIRED=1 python3 tests/run.py diff --git a/.gitignore b/.gitignore index 043428f..7e77f75 100644 --- a/.gitignore +++ b/.gitignore @@ -1,359 +1,28 @@ .DS_Store +__pycache__/ +node_modules/ +# Sage's preparsed copy of every script it runs *.sage.py -# Generated artifacts -utils/outputs_specialized/ +# make generate candidates.csv -utils/all_size_capped_candidates.csv -utils/all_unbound_candidates.csv -utils/global_X_thresholds.csv -utils/master_extremes_summary.csv +analysis/all_size_capped_candidates.csv +analysis/all_unbound_candidates.csv +analysis/global_X_thresholds.csv +analysis/master_extremes_summary.csv site/data.json -document/main.pdf -document/content/images/sieve_step*.png -document/content/images/concentric_diagram.png -document/content/images/scatter_plot.png -document/content/images/comparison_regular.png -document/content/images/comparison_normalized.png -document/content/images/ideal_candidate_balanced.png -document/content/images/ideal_candidate_best.png +document/content/images/ -# Compiled report — built and deployed by CI (.github/workflows/build_and_deploy.yml) -site/report.pdf - -## Core latex/pdflatex auxiliary files: +# The report's LaTeX build; CI publishes the PDF as site/report.pdf *.aux -*.lof -*.log -*.lot -*.fls -*.out -*.toc -*.fmt -*.fot -*.cb -*.cb2 -.*.lb - -## Intermediate documents: -*.dvi -*.xdv -*-converted-to.* -# these rules might exclude image files for figures etc. -# *.ps -# *.eps -# *.pdf - -## Generated if empty string is given at "Please type another file name for output:" -.pdf - -## Bibliography auxiliary files (bibtex/biblatex/biber): *.bbl -*.bbl-SAVE-ERROR -*.bcf -*.bcf-SAVE-ERROR *.blg -*-blx.aux -*-blx.bib -*.run.xml - -## Build tool auxiliary files: *.fdb_latexmk -*.synctex -*.synctex(busy) +*.fls +*.log +*.out *.synctex.gz -*.synctex.gz(busy) -*.pdfsync -*.rubbercache -rubber.cache - -## Build tool directories for auxiliary files -# latexrun -latex.out/ - -## Auxiliary and intermediate files from other packages: -# algorithms -*.alg -*.loa - -# achemso -acs-*.bib - -# amsthm -*.thm - -# attachfile2 -*.atfi - -# beamer -*.nav -*.pre -*.snm -*.vrb - -# changes -*.soc -*.loc - -# comment -*.cut - -# cprotect -*.cpt - -# elsarticle (documentclass of Elsevier journals) -*.spl - -# endnotes -*.ent - -# fixme -*.lox - -# feynmf/feynmp -*.mf -*.mp -*.t[1-9] -*.t[1-9][0-9] -*.tfm - -#(r)(e)ledmac/(r)(e)ledpar -*.end -*.?end -*.[1-9] -*.[1-9][0-9] -*.[1-9][0-9][0-9] -*.[1-9]R -*.[1-9][0-9]R -*.[1-9][0-9][0-9]R -*.eledsec[1-9] -*.eledsec[1-9]R -*.eledsec[1-9][0-9] -*.eledsec[1-9][0-9]R -*.eledsec[1-9][0-9][0-9] -*.eledsec[1-9][0-9][0-9]R - -# glossaries -*.acn -*.acr -*.glg -*.glg-abr -*.glo -*.glo-abr -*.gls -*.gls-abr -*.glsdefs -*.lzo -*.lzs -*.slg -*.slo -*.sls - -# uncomment this for glossaries-extra (will ignore makeindex's style files!) -# *.ist - -# gnuplot -*.gnuplot -*.table - -# gnuplottex -*-gnuplottex-* - -# gregoriotex -*.gaux -*.glog -*.gtex - -# htlatex -*.4ct -*.4tc -*.idv -*.lg -*.trc -*.xref - -# hypdoc -*.hd - -# hyperref -*.brf - -# knitr -*-concordance.tex -# TODO Uncomment the next line if you use knitr and want to ignore its generated tikz files -# *.tikz -*-tikzDictionary - -# latexindent will create succesive backup files by default -#*.bak* - -# listings -*.lol - -# luatexja-ruby -*.ltjruby - -# makeidx -*.idx -*.ilg -*.ind - -# minitoc -*.maf -*.mlf -*.mlt -*.mtc[0-9]* -*.slf[0-9]* -*.slt[0-9]* -*.stc[0-9]* - -# minted -_minted* -*.data.minted -*.pyg - -# morewrites -*.mw - -# newpax -*.newpax - -# nomencl -*.nlg -*.nlo -*.nls - -# pax -*.pax - -# pdfpcnotes -*.pdfpc - -# sagetex -*.sagetex.sage -*.sagetex.py -*.sagetex.scmd - -# scrwfile -*.wrt - -# spelling -*.spell.bad -*.spell.txt - -# svg -svg-inkscape/ - -# sympy -*.sout -*.sympy -sympy-plots-for-*.tex/ - -# pdfcomment -*.upa -*.upb - -# pythontex -*.pytxcode -pythontex-files-*/ - -# tcolorbox -*.listing - -# thmtools -*.loe - -# TikZ & PGF -*.dpth -*.md5 -*.auxlock - -# titletoc -*.ptc - -# todonotes -*.tdo - -# vhistory -*.hst -*.ver - -# easy-todo -*.lod - -# xcolor -*.xcp - -# xmpincl -*.xmpi - -# xindy -*.xdy - -# xypic precompiled matrices and outlines -*.xyc -*.xyd - -# endfloat -*.ttt -*.fff - -# Latexian -TSWLatexianTemp* - -## Editors: -# WinEdt -*.bak -*.sav - -# latexindent.pl -*.bak[0-9]* - -# Texpad -.texpadtmp - -# LyX -*.lyx~ - -# Kile -*.backup - -# gummi -.*.swp - -# KBibTeX -*~[0-9]* - -# TeXnicCenter -*.tps - -# auto folder when using emacs and auctex -./auto/* -*.el - -# expex forward references with \gathertags -*-tags.tex - -# standalone packages -*.sta - -# Makeindex log files -*.lpz - -# xwatermark package -*.xwm - -# REVTeX puts footnotes in the bibliography by default, unless the nofootinbib -# option is specified. Footnotes are the stored in a file with suffix Notes.bib. -# Uncomment the next line to have this generated file ignored. -#*Notes.bib -# Python bytecode -__pycache__/ -*.pyc - -# Local audit notes -AUDIT.md - -# Node packages -node_modules/ +*.toc +document/main.pdf +site/report.pdf diff --git a/Makefile b/Makefile index a7f1bc3..189e87b 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Single entrypoint for regenerating every derived artifact. # # make generate — everything below, in dependency order -# make sweeps — the two parameter sweeps -> utils/*.csv (sage) +# make sweeps — the two parameter sweeps -> analysis/*.csv (sage) # make derived — thresholds + extremes from the sweep CSVs (python) # make data — site/data.json for both site pages (sage) # make figures — report figures from the generated data (python) @@ -14,64 +14,64 @@ SAGE ?= sage PYTHON ?= python3 -UTILS := utils -IMAGES := document/content/images +ANALYSIS := analysis +IMAGES := document/content/images -CSV_CAPPED := $(UTILS)/all_size_capped_candidates.csv -CSV_UNBOUND := $(UTILS)/all_unbound_candidates.csv +CSV_CAPPED := $(ANALYSIS)/all_size_capped_candidates.csv +CSV_UNBOUND := $(ANALYSIS)/all_unbound_candidates.csv .PHONY: generate sweeps derived data figures test check-local fixtures site-snapshot clean-generated generate: sweeps derived data figures test sweeps: - $(SAGE) slhdsa-2to40.sage --max-size 7856 > /dev/null + $(SAGE) scripts/slhdsa-2to40.sage > /dev/null mv candidates.csv $(CSV_CAPPED) - $(SAGE) slhdsa-2to40.sage --max-size 99999999 > /dev/null + $(SAGE) scripts/slhdsa-2to40.sage --max-size 99999999 > /dev/null mv candidates.csv $(CSV_UNBOUND) derived: - cd $(UTILS) && $(PYTHON) min_mult_search.py - $(PYTHON) $(UTILS)/extremes_summary.py + cd $(ANALYSIS) && $(PYTHON) min_mult_search.py + $(PYTHON) $(ANALYSIS)/extremes_summary.py data: - $(SAGE) export_site_data.sage + $(SAGE) scripts/export_site_data.sage figures: mkdir -p $(IMAGES) # holds only generated files, so fresh checkouts lack it - cd $(IMAGES) && $(PYTHON) ../../../$(UTILS)/sieve_search_draw.py \ + cd $(IMAGES) && $(PYTHON) ../../../$(ANALYSIS)/sieve_search_draw.py \ --input ../../../$(CSV_UNBOUND) --weights 1,1,1,1,1 --suffix _1 - cd $(IMAGES) && $(PYTHON) ../../../$(UTILS)/sieve_search_draw.py \ + cd $(IMAGES) && $(PYTHON) ../../../$(ANALYSIS)/sieve_search_draw.py \ --input ../../../$(CSV_UNBOUND) --weights 5,1,1.5,2,1 --suffix _2 - cd $(UTILS) && $(PYTHON) venn_diagram_draw.py - mv $(UTILS)/euler_custom_minimax.png $(IMAGES)/concentric_diagram.png - cd $(IMAGES) && $(PYTHON) ../../../$(UTILS)/plot_concept_scatter.py + cd $(ANALYSIS) && $(PYTHON) venn_diagram_draw.py + mv $(ANALYSIS)/euler_custom_minimax.png $(IMAGES)/concentric_diagram.png + cd $(IMAGES) && $(PYTHON) ../../../$(ANALYSIS)/plot_concept_scatter.py mv $(IMAGES)/conceptual_bounded_zones_origin_vector.png $(IMAGES)/scatter_plot.png # Report Sec. "Metric-Normalized Weighted Distance": method comparison - cd $(IMAGES) && $(PYTHON) ../../../$(UTILS)/sieve_search_draw.py \ + cd $(IMAGES) && $(PYTHON) ../../../$(ANALYSIS)/sieve_search_draw.py \ --input ../../../$(CSV_UNBOUND) --figure focus --distance regular \ --x-size 0.75 --x-kg 5 --x-sg 5 --x-sv 1.5 --x-cb 1 \ --weights 5,1.5,1.5,2,1 --out comparison_regular.png - cd $(IMAGES) && $(PYTHON) ../../../$(UTILS)/sieve_search_draw.py \ + cd $(IMAGES) && $(PYTHON) ../../../$(ANALYSIS)/sieve_search_draw.py \ --input ../../../$(CSV_UNBOUND) --figure focus --distance normalized \ --x-size 0.75 --x-kg 5 --x-sg 5 --x-sv 1.5 --x-cb 1 \ --weights 5,1.5,1.5,2,1 --out comparison_normalized.png # Report Sec. "Recommended Candidate Tuple": the two surviving candidates - cd $(IMAGES) && $(PYTHON) ../../../$(UTILS)/sieve_search_draw.py \ + cd $(IMAGES) && $(PYTHON) ../../../$(ANALYSIS)/sieve_search_draw.py \ --input ../../../$(CSV_UNBOUND) --figure focus --distance regular --annotate \ --x-size 0.74 --x-kg 2 --x-sg 2 --x-sv 0.8 --x-cb 1 \ --weights 5,1,1,2,1 --out ideal_candidate_balanced.png - cd $(IMAGES) && $(PYTHON) ../../../$(UTILS)/sieve_search_draw.py \ + cd $(IMAGES) && $(PYTHON) ../../../$(ANALYSIS)/sieve_search_draw.py \ --input ../../../$(CSV_UNBOUND) --figure focus --distance normalized --annotate \ --x-size 0.74 --x-kg 2 --x-sg 2 --x-sv 0.8 --x-cb 1 \ --weights 5,1,1,2,1 --out ideal_candidate_best.png test: - $(PYTHON) tests/test_regression.py + $(PYTHON) tests/run.py check-local: $(PYTHON) tests/synth_generated.py - GENERATED_REQUIRED=1 $(PYTHON) tests/test_regression.py + GENERATED_REQUIRED=1 $(PYTHON) tests/run.py fixtures: $(PYTHON) tests/regen_fixtures.py $(if $(WRITE),--write) @@ -82,7 +82,7 @@ site-snapshot: clean-generated: rm -f $(CSV_CAPPED) $(CSV_UNBOUND) - rm -f $(UTILS)/global_X_thresholds.csv $(UTILS)/master_extremes_summary.csv + rm -f $(ANALYSIS)/global_X_thresholds.csv $(ANALYSIS)/master_extremes_summary.csv rm -f site/data.json site/report.pdf rm -f $(IMAGES)/sieve_step*.png $(IMAGES)/concentric_diagram.png $(IMAGES)/scatter_plot.png rm -f $(IMAGES)/comparison_regular.png $(IMAGES)/comparison_normalized.png diff --git a/README.md b/README.md index 247e553..79acfa0 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,77 @@ # SPHINCS-Parameters -Scripts for exploring SPHINCS+ parameter tradeoffs, computing security levels, signature sizes, and signing/verification costs. -- [security.sage](security.sage) computes the security level for FORS and PORS+FP parameter sets -- [original-sphincs-parameter-search.sage](original-sphincs-parameter-search.sage) script from https://sphincs.org/data/sphincs+-specification.pdf (updated for SageMath 9.0) -- [costs.sage](costs.sage) computes signature sizes and signing/verification times for SPHINCS+ variants -- [stateful.sage](stateful.sage) evaluates stateful alternatives (XMSS, XMSS-MT, SHRINCS/UXMSS) against the SLH-DSA-128s baseline -- [octopus_pmf.py](octopus_pmf.py) computes the PMF of the Octopus authentication set size (MIT license, from https://github.com/MehdiAbri/PORS-FP) -- [slhdsa-2to40.sage](slhdsa-2to40.sage) sweeps SLH-DSA parameter tuples and filters them by security level and signature size -- [run_experiments.py](run_experiments.py) runs the full parameter sweep and writes results to CSV -- [export_site_data.sage](export_site_data.sage) exports `site/data.json`, the dataset both site pages load -- [utils/](utils/) helper scripts for searching and visualising the parameter space -- [tests/](tests/) regression suite, run `python3 tests/test_regression.py` -- [document/](document/) LaTeX source for the accompanying technical report -- [site/](site/) interactive pages hosted on GitHub Pages: the SHRINCS pinned-parameters main page ([index.html](site/index.html)), stateless parameter search over all report schemes (SPX / W+C / FORS+C / PORS+FP) with worst-case mode, shareable URL state and CSV export ([stateless.html](site/stateless.html)), and stateful scheme comparison ([stateful.html](site/stateful.html)); the stateful cost model shared by index and the stateful page lives in [formulas.js](site/formulas.js). The report PDF is not tracked in git: CI compiles [document/](document/) and deploys it as `site/report.pdf` (also attached to releases on tags) - -Contributing: run `make check-local` before pushing. \ No newline at end of file + +Which hash-based signature parameters give Bitcoin the smallest post-quantum signatures that are +still cheap to sign and verify? This repository holds the model, the parameter searches and the +report behind the parameters of SHRINCS. It computes the size, costs and security of SLH-DSA and +the variants W+C, W+C_F+C and W+C_P+FP, searches their parameters, and compares stateful schemes: +XMSS, XMSS-MT and UXMSS, the SHRINCS stateful tree. + +- Site: [SHRINCS pinned parameters, Parameter Explorer and Stateful Comparison](https://blockstreamresearch.github.io/SPHINCS-Parameters/site/) +- Report: [report.pdf](https://blockstreamresearch.github.io/SPHINCS-Parameters/site/report.pdf), from [document/](document/) +- Background: [Searching for SHRINCS parameters](https://blog.blockstream.com/searching-for-shrincs-parameters/), Blockstream blog, August 2026 + +## Units and hash model + +Sizes are in bytes and costs in SHA-256 compressions (C); ρ is SigVer compressions per signature +byte. The hash output is n = 16 bytes. Hashing follows the FIPS 205 SHA-2 instantiation as SHRINCS +runs it: the block that holds PK.seed is compressed once per key, so a chain step, a tree node or +a PRF call costs one compression, H_msg four and PRF_msg three. [docs/model.md](docs/model.md) +derives every formula. + +## Quick start + +Without Sage, with Python 3.12 and Node 22 as in CI: + + make check-local + +This writes the sweep CSVs and `site/data.json` from `tests/model.py`, a Python port of the model, +and runs the tests. It leaves out the W+C_P+FP pool, which needs Sage. + +With SageMath 10.4, pandas and seaborn (`sage -pip install pandas seaborn`): + + make generate PYTHON="sage -python" + +This runs the steps of CI's generate job, then the tests, in a few minutes: + +1. `make sweeps`: `scripts/slhdsa-2to40.sage` evaluates the grid of `parameters.json` with the + model in `model/` and writes `analysis/all_size_capped_candidates.csv` and + `analysis/all_unbound_candidates.csv`. +2. `make derived`: `analysis/min_mult_search.py` and `analysis/extremes_summary.py` write the + derived tables next to them. +3. `make data`: `scripts/export_site_data.sage` writes `site/data.json` from the capped CSV, its + own sweeps of the optional w and the variants, and `model/stateful.sage`. +4. `make figures`: the other `analysis/` scripts draw the report's figures into + `document/content/images/`. +5. `make test`: `tests/run.py`. + +To open the pages, serve the repository (the pages fetch `site/data.json`), for example with +`python3 -m http.server`, and go to http://localhost:8000/site/. + +## Layout + +- `parameters.json`: the search grids, the S_wn table, the UXMSS bounds, the security target and + the SLH-DSA-128s baseline. +- `model/`: the model in Sage. `costs.sage` covers SLH-DSA and its variants, `security.sage` FORS + and PORS+FP, `stateful.sage` XMSS, XMSS-MT and UXMSS. +- `scripts/`: the parameter sweep and the export of `site/data.json`. +- `analysis/`: the derived tables and the report's figures. +- `site/`: the three pages; `site/js/model.js` computes the same model in the browser. +- `document/`: the report in LaTeX. CI builds it and publishes it with the site; a tag also + attaches it, `site/data.json` and the sweep CSVs to a release. +- `tests/`: `model.py`, the tests by area (`python3 tests/run.py [model] [generated] [site] [sage]`), + the fixtures and the site snapshot. +- `third_party/octopus_pmf.py`: the size distribution of the Octopus authentication set, for + PORS+FP (MIT, from [PORS-FP](https://github.com/MehdiAbri/PORS-FP)). +- `reference/`: the parameter search script of the + [SPHINCS+ specification](https://sphincs.org/data/sphincs+-specification.pdf), updated for + SageMath 9. + +## Changing the model or the pages + +- `tests/fixtures.json` pins values of the model. `make fixtures` lists those that differ from the + code; after an intended change, `make fixtures WRITE=1` updates them. +- Before pushing a change to `site/`, run `make site-snapshot`, which compares the numbers the + pages show with `tests/site_snapshot.json`. It needs `site/data.json` from either make target + above and, once, `npm install && npx playwright install chromium`. After an intended change, + `make site-snapshot UPDATE=1` records the new numbers. +- Run `make check-local` before pushing. diff --git a/utils/extremes_summary.py b/analysis/extremes_summary.py similarity index 100% rename from utils/extremes_summary.py rename to analysis/extremes_summary.py diff --git a/utils/min_mult_search.py b/analysis/min_mult_search.py similarity index 74% rename from utils/min_mult_search.py rename to analysis/min_mult_search.py index 84dbbd4..1c59a7c 100644 --- a/utils/min_mult_search.py +++ b/analysis/min_mult_search.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 -""" -Sweeps a global uniform filter X across 4 operational metrics. -Finds the absolute minimum global X required to yield the smallest -possible set of valid candidates. -Aligned with run_experiments.py folder structure and venn_diagram_draw.py keys. +"""For every candidate of a sweep CSV, the smallest X for which each of its +metrics (size, KeyGen, SigGen, SigVer and C/byte) is at most X times that of +the STANDARD row, and the metric that sets X. Writes the candidates by +increasing X to global_X_thresholds.csv, which venn_diagram_draw.py reads. + + cd analysis && python3 min_mult_search.py [--input CSV] [--output CSV] """ import argparse @@ -27,26 +28,21 @@ def find_global_x_thresholds(csv_path, output_csv): candidates = df[df['label'] != 'STANDARD'].copy() - # 1. Calculate ratios against standard for the 4 metrics for col in METRICS: candidates[f'{col}_ratio'] = candidates[col] / std_row[col] - # 2. Determine the minimum global X required for each candidate ratio_columns = [f'{col}_ratio' for col in METRICS] candidates['required_global_X'] = candidates[ratio_columns].max(axis=1) - # 3. Identify the specific bottleneck metric that forced X to be this high candidates['bottleneck_metric'] = candidates[ratio_columns].idxmax(axis=1).str.replace('_ratio', '') - # 4. Sort by the required global X to find our expanding thresholds candidates = candidates.sort_values(by='required_global_X', ascending=True).reset_index(drop=True) - # 5. Build the output dataset with the explicit "cumulative_total" key results = [] for i, row in candidates.iterrows(): - cumulative_total = i + 1 # 1 candidate, 2 candidates, 3 candidates... + cumulative_total = i + 1 # candidates with at most this X results.append({ - 'cumulative_total': cumulative_total, # Renamed to match Venn Diagram script + 'cumulative_total': cumulative_total, 'required_global_X': round(row['required_global_X'], 4), 'bottleneck_metric': row['bottleneck_metric'], 'h': row['h'], @@ -64,19 +60,16 @@ def find_global_x_thresholds(csv_path, output_csv): results_df = pd.DataFrame(results) - # 6. Ensure the target directory exists (Matching run_experiments behavior) out_dir = os.path.dirname(output_csv) if out_dir: os.makedirs(out_dir, exist_ok=True) - # 7. Save to CSV results_df.to_csv(output_csv, index=False) print("=========================================================================================") print(f" GLOBAL X FILTER THRESHOLDS SAVED TO: {output_csv}") print("=========================================================================================\n") - # Print the first 10 rows to the terminal as a preview print(results_df.head(10).to_string(index=False)) if __name__ == "__main__": diff --git a/utils/plot_concept_scatter.py b/analysis/plot_concept_scatter.py similarity index 74% rename from utils/plot_concept_scatter.py rename to analysis/plot_concept_scatter.py index f87607c..1a72234 100644 --- a/utils/plot_concept_scatter.py +++ b/analysis/plot_concept_scatter.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 -""" -Generates a conceptual scatter plot with explicit constraint zones. -Includes a vector pointing to the valid candidate with the shortest Euclidean -distance from the absolute origin (0, 0). +"""Draws the report's conceptual scatter plot, +conceptual_bounded_zones_origin_vector.png: random candidates over two metrics +relative to SLH-DSA-128s, the zone within both bounds, and an arrow from the +origin to the nearest candidate in it. No sweep data is read. + + python3 plot_concept_scatter.py """ import matplotlib.pyplot as plt @@ -14,39 +16,34 @@ def generate_bounded_plot(): sns.set_theme(style="whitegrid", context="paper") fig, ax = plt.subplots(figsize=(10, 8)) - # 1. Define Constraints and Maximum Plot Area anchor_x, anchor_y = 1.0, 1.0 limit_x = 3.0 limit_y = 2.0 max_val = 5.5 - # 2. Draw the Zones (Backgrounds) - # Zone: Fails Y, Passes X (Yellow) + # Within one bound only: yellow rect_top_left = patches.Rectangle((0, limit_y), limit_x, max_val - limit_y, facecolor='#ffecb3', alpha=0.5, zorder=1) ax.add_patch(rect_top_left) - # Zone: Fails X, Passes Y (Yellow) rect_bottom_right = patches.Rectangle((limit_x, 0), max_val - limit_x, limit_y, facecolor='#ffecb3', alpha=0.5, zorder=1) ax.add_patch(rect_bottom_right) - # Zone: Valid Bounding Box (Green) + # Within both: green rect_desired = patches.Rectangle((0, 0), limit_x, limit_y, facecolor='#d4edda', edgecolor=None, linewidth=2.5, alpha=0.6, zorder=2) ax.add_patch(rect_desired) - # Dashed threshold lines ax.axvline(x=limit_x, color='black', linestyle='--', linewidth=1.5, zorder=3) ax.axhline(y=limit_y, color='black', linestyle='--', linewidth=1.5, zorder=3) - # 1.0x Standard Square + # SLH-DSA-128s, 1.0x on both axes rect_standard = patches.Rectangle((0, 0), 1.0, 1.0, fill=False, edgecolor='gray', linestyle=':', linewidth=1.5, zorder=1) ax.add_patch(rect_standard) - # 3. Generate the Scatter Points np.random.seed(42) utopian_x = np.random.uniform(0.4, 0.95, 15) @@ -64,7 +61,7 @@ def generate_bounded_plot(): if x <= 1.0 and y <= 1.0: pass else: - # Create a conceptual pareto frontier curve so points don't collapse too close to (0,0) + # Keep the points above a frontier, away from the origin min_y = 1.0 / (x + 0.1) + 0.15 if y < min_y: y = min_y + np.random.uniform(0.1, 0.3) @@ -76,41 +73,30 @@ def generate_bounded_plot(): invalid_x.append(x) invalid_y.append(y) - # 4. Plot the Regular Points ax.scatter(invalid_x, invalid_y, alpha=0.4, color='#d9534f', edgecolor='white', s=70, linewidth=1.0, label="Invalid Candidates", zorder=4) ax.scatter(valid_x, valid_y, alpha=0.9, color='#0275d8', edgecolor='white', s=70, linewidth=1.0, label="Valid Candidates (Acceptable)", zorder=4) - # 5. Plot the Standard Anchor ax.scatter(anchor_x, anchor_y, color='#f0ad4e', marker='*', s=250, edgecolor='black', linewidth=1.5, zorder=5, label="Standard Anchor (1.0x)") - # ========================================================================= - # NEW LOGIC: FIND SHORTEST DISTANCE VECTOR FROM (0,0) - # ========================================================================= origin_x, origin_y = 0.0, 0.0 - + if valid_x: - # Calculate Euclidean distances from the Absolute Origin (0, 0) dists = [np.sqrt((x - origin_x)**2 + (y - origin_y)**2) for x, y in zip(valid_x, valid_y)] - # Isolate the point with the absolute minimum distance min_idx = np.argmin(dists) closest_x, closest_y = valid_x[min_idx], valid_y[min_idx] - # Draw a bold vector (arrow) pointing from (0,0) to this nearest candidate ax.annotate('', xy=(closest_x, closest_y), xytext=(origin_x, origin_y), arrowprops=dict(facecolor='black', edgecolor='black', width=1.5, headwidth=8, shrink=0.0), zorder=6) - # Highlight this specific candidate to make the shortest-distance end point pop ax.scatter(closest_x, closest_y, color='#5cb85c', s=100, edgecolor='black', linewidth=1.5, zorder=7, label=f"Optimal Point (Closest to Origin)") - # ========================================================================= - # 6. Formatting ax.set_title("Signature Size vs Signing Cost\n(Showing Vector to Globally Optimal Candidate)", fontsize=14, pad=15) ax.set_xlabel(r" Signature Size ($X \cdot$ Std)", fontsize=12, fontweight='bold') @@ -119,11 +105,10 @@ def generate_bounded_plot(): ax.set_xlim(0, max_val) ax.set_ylim(0, max_val) - # Move legend to not overlap with the new vector + # Upper right keeps the legend off the arrow ax.legend(loc='upper right', fontsize=11, frameon=True, shadow=True) plt.tight_layout() - # Save output filename = "conceptual_bounded_zones_origin_vector.png" plt.savefig(filename, dpi=300, bbox_inches='tight') print(f"Successfully generated bounded zone diagram: {filename}") diff --git a/utils/sieve_search_draw.py b/analysis/sieve_search_draw.py similarity index 96% rename from utils/sieve_search_draw.py rename to analysis/sieve_search_draw.py index aac112f..66851d9 100644 --- a/utils/sieve_search_draw.py +++ b/analysis/sieve_search_draw.py @@ -1,5 +1,12 @@ #!/usr/bin/env python3 +"""Draws the report's sieve figures from a sweep CSV: the candidates filtered +metric by metric against X times SLH-DSA-128s (--x-*), one image per stage, +and their weighted distance to the origin (--weights). --figure focus draws +only the distance view of the survivors, regular or metric-normalised. The +Makefile's figures target runs it from document/content/images. + python3 sieve_search_draw.py --input CSV [--x-size X ...] [--weights W,W,W,W,W] +""" import matplotlib.pyplot as plt import matplotlib.patches as patches import numpy as np @@ -9,15 +16,9 @@ import argparse from scipy.spatial.distance import pdist, squareform -np.random.seed(42) +np.random.seed(42) # the same placement on every run -METRIC_WEIGHTS = { - 'size': 15.0, - 'keygen': 0.1, - 'sign': 0.1, - 'verify': 15.0, - 'rho': 0.1 -} +METRIC_WEIGHTS = None # from --weights def load_and_preprocess_data(csv_path): if not os.path.exists(csv_path): @@ -294,9 +295,6 @@ def main(): print("\n -> No survivors left to calculate Euclidean optimum.") if args.figure == "focus": - # Single distance view of the final survivors (replaces the manual - # site screenshots previously used for the report's comparison and - # candidate figures). assert args.out, "--figure focus requires --out" assert len(final_survivors) > 0, "no survivors under these filters" label = "Regular Weighted Distance" diff --git a/utils/venn_diagram_draw.py b/analysis/venn_diagram_draw.py similarity index 72% rename from utils/venn_diagram_draw.py rename to analysis/venn_diagram_draw.py index 1a07d87..36f6a47 100644 --- a/utils/venn_diagram_draw.py +++ b/analysis/venn_diagram_draw.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -""" -Reads the exact required_global_X multipliers from global_X_thresholds.csv -and generates a Concentric Euler Diagram strictly mapping the requested bounds: -[Smallest X, 0.9x, 1.0x, 2.0x, 5.0x, 10.0x, 15.0x] +"""Draws the report's concentric diagram, euler_custom_minimax.png: how many +candidates of global_X_thresholds.csv (min_mult_search.py) have all metrics +within X times SLH-DSA-128s, for the smallest X and for TARGET_MULTIPLIERS. + + cd analysis && python3 venn_diagram_draw.py """ import os @@ -11,11 +12,11 @@ import matplotlib.pyplot as plt import matplotlib.patches as patches -# The target bounds you requested +# The rings after the smallest X TARGET_MULTIPLIERS = [0.9, 1.0, 2.0, 5.0, 10.0, 15.0] def draw_custom_euler(factors, counts): - """Renders the Concentric Euler Diagram for the calculated thresholds.""" + """Draws one ring per factor, the innermost first in factors.""" fig, ax = plt.subplots(figsize=(12, 10)) ax.set_aspect('equal') @@ -23,7 +24,7 @@ def draw_custom_euler(factors, counts): max_radius = 0.45 radii = [max_radius * (i + 1) / N for i in range(N)] - # 7-layer sequential palette (Light Yellow -> Deep Orange/Brown) + # Light yellow on the outside to dark brown at the centre palette = ['#fff7bc', '#fee391', '#fec44f', '#fe9929', '#ec7014', '#cc4c02', '#8c2d04'] for i in range(N - 1, -1, -1): @@ -39,7 +40,7 @@ def draw_custom_euler(factors, counts): ) ax.add_patch(circle) - # Calculate how many NEW candidates were unlocked in this specific ring + # Candidates this ring adds added = counts[i] if i == 0 else counts[i] - counts[i-1] txt = f"{factors[i]} Cap | Total: {counts[i]} candidates (+{added} new)" @@ -47,7 +48,6 @@ def draw_custom_euler(factors, counts): x_target = 0.64 if i == 0 else 0.64 + (radii[i] + radii[i-1]) / 6 y_target = 0.525 if i == 0 else 0.5 + (radii[i] + radii[i-1]) / 2.05 - # Point the arrow slightly inwards to the left ax.annotate( txt, xy=(0.51, y_target), @@ -61,10 +61,7 @@ def draw_custom_euler(factors, counts): ax.set_xlim(0, 1.35) ax.set_ylim(0, 1) ax.axis('off') - - #ax.set_title("Global Minimax Expansion: Candidates Unlocked by Uniform Bounding", - # fontsize=16, fontweight='bold', pad=20) - + out_path = "euler_custom_minimax.png" plt.savefig(out_path, dpi=300, bbox_inches='tight') plt.close() @@ -79,29 +76,23 @@ def main(): df = pd.read_csv(csv_file) - # 1. Find the absolute minimum X in the dataset min_x = df['required_global_X'].min() print(f"[*] Smallest possible X discovered: {min_x:.4f}x") - # 2. Build our full list of target thresholds thresholds = [min_x] + TARGET_MULTIPLIERS factors = [] counts = [] - # 3. Calculate how many candidates survive at each expanding threshold for target in thresholds: - # Count all rows where the required X is less than or equal to our target bound surviving_count = len(df[df['required_global_X'] <= target]) - # Format the label nicely label = f"{target:.4f}x" if target == min_x else f"{target}x" factors.append(label) counts.append(surviving_count) print(f" -> At <= {label}: {surviving_count} candidates") - # 4. Draw it! draw_custom_euler(factors, counts) if __name__ == "__main__": diff --git a/costs.sage b/costs.sage deleted file mode 100644 index a29c73a..0000000 --- a/costs.sage +++ /dev/null @@ -1,845 +0,0 @@ -#!/usr/bin/env sage -""" -SPHINCS+ Parameter Efficiency and Size Calculator - -Computes signature sizes and signing/verification times for SPHINCS+ variants: -- SPX: Plain SPHINCS+ (WOTS-TW + FORS) -- W+C: SPHINCS+ with WOTS+C (counter-based WOTS, no checksum chains) -- W+C_F+C: SPHINCS+ with WOTS+C and FORS+C (grinding to remove last FORS tree) -- W+C_P+FP: SPHINCS+ with WOTS+C and PORS+FP (single tree with Octopus auth) - -Notation: -- h: hypertree height (total) -- d: number of layers in the hypertree -- h' = h/d: height of each XMSS tree layer -- n: hash output size in bits (fixed at 128 = hashbytes*8) -- a: log2 of leaves per FORS tree (t = 2^a leaves per tree) -- k: number of FORS trees (or leaf indices for PORS) -- w: Winternitz parameter -- l: number of WOTS chains -- S_{w,n} (swn): target chain sum for WOTS+C -- q_s: max number of signatures supported -""" - -from sage.all import * -from scipy.stats import binom -import os - -from octopus_pmf import interleave_cost_table - -# Load security.sage from the same directory -_dir = os.path.dirname(os.path.abspath(__file__)) -load(os.path.join(_dir, "security.sage")) - -# ============================================================================= -# Constants -# ============================================================================= - -hashbytes = 16 # 16 bytes = 128 bits -counter_size = 4 # 4 bytes = 32 bits -randomness_size = 16 # 16 bytes = 128 bits - -# ----------------------------------------------------------------------------- -# Hash-cost convention -# ----------------------------------------------------------------------------- -# Two SHA-256 compression-counting conventions are supported, selected via the -# HASH_CONVENTION environment variable ('cached' or 'uncached'): -# -# cached (default): FIPS 205 SHA-2 layout with the PK.seed midstate cached. -# This is the instantiation implemented by SHRINCS (github.com/SHRINCS/shrincs-bip), -# and the counts below are the ones its reference implementation executes. -# -# uncached: the original convention of this script, kept verbatim so that -# earlier numbers stay reproducible: every call pays for its full input -# (a node hash costs 2 compressions), the message hashes are single -# 2-block hashes, and WOTS-TW verification is averaged as (w-1)/2 steps -# per chain. -# -# Usage: HASH_CONVENTION=uncached sage costs.sage --params ... -# -HASH_CONVENTION = os.environ.get('HASH_CONVENTION', 'cached').lower() -assert HASH_CONVENTION in ('cached', 'uncached'), ( - "HASH_CONVENTION must be 'cached' or 'uncached', got %r" % HASH_CONVENTION) - -C_Th1 = 1 # Tweakable hash, 1-block: PKseed (128) + Tweak (96) + m1 (128) -C_Th1c = 1 # Tweakable hash, 1-block + counter: PKseed + Tweak + m1 + counter (32) -C_Th2 = 1 if HASH_CONVENTION == 'cached' else 2 # Tweakable hash with hash convention -C_PRF = 1 # PRF: PKseed + SKseed + Tweak - -# Message hash and message PRF, for a 32-byte message and an empty context. -# -# cached: -# H_msg = MGF1-SHA-256(R || PK.seed || SHA-256(R || PK.seed || PK.root || M), m) -# is two nested SHA-256 calls (98- and 68-byte inputs in SHRINCS), 2 -# compressions each. Its input starts with the randomizer R, so nothing -# can be cached. -> 4 -# PRF_msg = HMAC-SHA-256(SK.prf, opt_rand || M) is two SHA-256 calls of -# 3 and 2 compressions. Their first blocks are the fixed HMAC key pads, -# cached like the PK.seed block (it would be 5 otherwise). -> 3 -# uncached (original model): one 2-block hash each -# H_msg: PKseed + PKroot (128) + R (256) + m (256) -> 2 -# PRF_msg: SKprf (128) + Opt + m (256) + counter -> 2 -C_Hmsg = 4 if HASH_CONVENTION == 'cached' else 2 -C_PRFmsg = 3 if HASH_CONVENTION == 'cached' else 2 - -def compute_Th(n): - """ - Compression calls for a tweakable hash over n hash-sized values, under the active HASH_CONVENTION - - cached: ceil((22*8 + 128*n + 65) / 512) - uncached: ceil((128 + 96 + 128*n + 65) / 512) - """ - if HASH_CONVENTION == 'cached': - return ceil((22*8 + 128*n + 65)/512) - return ceil((128 + 96 + 128*n + 65)/512) - -# ============================================================================= -# Parameter Sets -# ============================================================================= - -# Single consolidated list in table output order -# Format: (scheme, q_s, h, d, a, k, w, l, swn, bold) -# q_s: log2 of max signatures (e.g., 40 means 2^40 signatures) -# swn: S_{w,n} target chain sum for WOTS+C -# bold=True for rows highlighted in the paper tables -PARAMETER_SETS = [ - # Table 1: 2^64 signatures (SPX) - ("SPX", 64, 63, 7, 12, 14, 16, 35, 0, True), - - # Table 1: 2^40 signatures - # W+C (5 rows) - ("W+C", 40, 44, 4, 16, 8, 16, 32, 240, False), - ("W+C", 40, 44, 4, 16, 8, 16, 32, 304, False), - ("W+C", 40, 44, 4, 16, 8, 256, 16, 2040, False), - ("W+C", 40, 40, 5, 14, 11, 256, 16, 2040, False), - ("W+C", 40, 40, 5, 14, 11, 256, 16, 2840, False), - # W+C F+C (2 rows) - ("W+C_F+C", 40, 44, 4, 16, 8, 16, 32, 240, False), - ("W+C_F+C", 40, 40, 5, 14, 11, 256, 16, 2040, True), - # W+C P+FP (2 rows) - ("W+C_P+FP", 40, 44, 4, 16, 8, 16, 32, 240, False), - ("W+C_P+FP", 40, 40, 5, 14, 11, 256, 16, 2040, True), - - # Table 1: 2^30 signatures - # W+C (6 rows) - ("W+C", 30, 36, 3, 14, 9, 16, 32, 240, False), - ("W+C", 30, 33, 3, 15, 9, 16, 32, 240, False), - ("W+C", 30, 33, 3, 15, 9, 16, 32, 304, False), - ("W+C", 30, 33, 3, 15, 9, 256, 16, 2040, False), - ("W+C", 30, 32, 4, 14, 10, 256, 16, 2040, False), - ("W+C", 30, 32, 4, 14, 10, 256, 16, 2840, False), - # W+C F+C (4 rows) - ("W+C_F+C", 30, 36, 3, 14, 9, 16, 32, 240, False), - ("W+C_F+C", 30, 33, 3, 15, 9, 16, 32, 240, False), - ("W+C_F+C", 30, 33, 3, 15, 9, 256, 16, 2040, False), - ("W+C_F+C", 30, 32, 4, 14, 10, 256, 16, 2040, True), - # W+C P+FP (3 rows) - ("W+C_P+FP", 30, 36, 3, 14, 9, 16, 32, 240, False), - ("W+C_P+FP", 30, 33, 3, 15, 9, 16, 32, 240, False), - ("W+C_P+FP", 30, 32, 4, 14, 10, 256, 16, 2040, True), - - # Table 2: 2^20 signatures (grouped by h) - # h=24 - ("W+C", 20, 24, 2, 16, 8, 16, 32, 240, False), - ("W+C", 20, 24, 2, 16, 8, 256, 16, 2040, False), - ("W+C_F+C", 20, 24, 2, 16, 8, 16, 32, 240, False), - ("W+C_P+FP", 20, 24, 2, 16, 8, 16, 32, 240, False), - # h=20 - ("W+C", 20, 20, 2, 15, 10, 256, 16, 2040, False), - ("W+C_F+C", 20, 20, 2, 15, 10, 256, 16, 2040, False), - ("W+C_P+FP", 20, 20, 2, 15, 10, 256, 16, 2040, False), -] - -# ============================================================================= -# Helper Functions -# ============================================================================= - -# High precision arithmetic -F = RealField(100) - -# ----------------------------------------------------------------------------- -# WOTS helpers -# ----------------------------------------------------------------------------- - -def compute_wots_l(scheme, w): - """ - Compute WOTS chain count l based on scheme type. - - For WOTS-TW (plain): l = l1 + l2 - l1 = ceil(8n / log2(w)) -- message chains (FIPS 205 Sec. 5) - l2 = floor(log_w(l1*(w-1))) + 1 -- checksum chains (FIPS 205 Sec. 5) - - For WOTS+C: l = l1 (no checksum chains, replaced by counter) - - Note: l1 must round UP. For w=16 and w=256 the division is exact, - but w=32 needs 26 chains (26*5 = 130 >= 128 bits); floor division - would give 25 chains, which cannot encode a 128-bit digest. - """ - if scheme == "SPX": - l1 = ceil(hashbytes*8/log(w,2)) - l2 = floor(log(l1*(w-1), 2)/log(w, 2)) + 1 - return l1 + l2 - else: - return ceil(hashbytes*8/log(w,2)) - -def compute_wots_tw_worst_steps(l1, l2, w): - """ - Worst-case WOTS-TW verification steps per hypertree layer. - - Worst case: all l1 message digits = 0, so the verifier traverses w-1 - steps on every message chain. The checksum C = l1*(w-1) is then encoded - in base w as l2 digits c_j; the verifier traverses w-1-c_j steps on each - checksum chain. Total = l1*(w-1) + l2*(w-1) - digit_sum(C, w). - - This is the correct model for WOTS-TW. The old average-case formula - (w-1)*l/2 was only valid for WOTS+C where chains sum to a constant. - """ - C = l1 * (w - 1) - ds, rem = 0, int(C) - while rem > 0: - ds += rem % int(w) - rem //= int(w) - return int(l1) * (int(w) - 1) + int(l2) * (int(w) - 1) - ds - -def _round_div(num, den): - """Nearest integer to num/den for non-negative integers (ties round up).""" - return (2*num + den) // (2*den) - -_wots_tw_floor_cache = {} - -def _wots_tw_floor_sum(l1, l2, w): - """ - w^l1 * sum_{j=1}^{l2-1} E[floor(S / w^j)], as an exact integer, where S is - the sum of l1 independent uniform base-w digits. - - E[floor(S / w^j)] = sum_{m>=1} P(S >= m*w^j), and the number of digit - strings with sum <= t is, by inclusion-exclusion, - - sum_i (-1)^i * C(l1, i) * C(t - i*w + l1, l1). - """ - l1, l2, w = int(l1), int(l2), int(w) - key = (l1, l2, w) - if key in _wots_tw_floor_cache: - return _wots_tw_floor_cache[key] - top = l1 * (w - 1) - W = w**l1 - - def count_le(t): - total = 0 - for i in range(min(l1, t // w) + 1): - total += (-1)**i * binomial(l1, i) * binomial(t - i*w + l1, l1) - return total - - acc = 0 - for j in range(1, l2): - step = w**j - for m in range(1, top // step + 1): - acc += W - count_le(m*step - 1) - _wots_tw_floor_cache[key] = acc - return acc - -def compute_wots_tw_mean_steps(l1, l2, w, count=1): - """ - Mean number of chain steps to verify `count` WOTS-TW signatures on - uniformly random digests, rounded to the nearest integer. - - A message digit m costs w-1-m steps, so the l1 message chains average - (w-1)/2 steps each. The l2 checksum chains do not: the checksum - C = l1*(w-1) - S (S = sum of the message digits) is concentrated around - l1*(w-1)/2, so its base-w digits are far from uniform (the leading one - is almost always 0 or 1) and the verifier walks more of those chains - than (w-1)/2. Exactly: - - E[steps] = l1*(w-1)/2 + l2*(w-1) - E[digit_sum(C)] - = (w-1) * (l2 + sum_{j=1}^{l2-1} E[floor(S / w^j)]), - - using that C is distributed like S (map every digit m to w-1-m) and - digit_sum(x) = x - (w-1) * sum_{j>=1} floor(x / w^j). - - For w = 16 this is 267.12 steps rather than the 262 that (w-1)*l/2 - gives, and the gap grows with w (2415.03 against 2295 for w = 256). - - Under the original convention (HASH_CONVENTION=uncached) the (w-1)*l/2 - approximation is kept. - """ - if HASH_CONVENTION != 'cached': - return count * ((w - 1) * (l1 + l2) // 2) - W = int(w)**int(l1) - T = _wots_tw_floor_sum(l1, l2, w) - # count * (w-1) * (l2 + T/W), rounded to the nearest integer - return _round_div(count * (w - 1) * (l2*W + T), W) - -def compute_nu(l: int, paramsum: int, w: int) -> int: - """ - Compute ν (nu), the number of valid WOTS+C encodings for a given target sum. - - For WOTS+C, we need message digests whose base-w digits sum to exactly S_{w,n}. - ν counts how many such encodings exist out of w^l total possibilities. - - ν = Σ_{j=0}^{l} (-1)^j * C(l,j) * C((S_{w,n} + l) - j*w - 1, l-1) - - Returns integer ν. If ν is zero, returns 1 and prints a warning. - """ - nu = 0 - for j in range(l + 1): - sign = (-1) ** j - binom1 = binomial(l, j) - n = (paramsum + l) - j * w - 1 - binom2 = binomial(n, l - 1) if n >= l - 1 and l - 1 >= 0 else 0 - nu += sign * binom1 * binom2 - if nu == 0: - print(f"Warning: ν computed as zero for l={l}, paramsum={paramsum}, w={w}. This may lead to division by zero.") - nu = 1 # Avoid division by zero - return nu - -# ----------------------------------------------------------------------------- -# PORS helpers -# ----------------------------------------------------------------------------- - -def compute_pors_tree_geometry(k, a): - """ - Compute PORS tree geometry. - - Returns (t, subtree_height, extra_leaves) where: - - t: total number of PORS leaves (k * 2^a) - - subtree_height: height of the largest power-of-2 subtree - - extra_leaves: leaves beyond the power-of-2 subtree - """ - t = k * (2**a) - subtree_height = floor(log(t, 2)) - extra_leaves = t - 2**subtree_height - return t, subtree_height, extra_leaves - -_interleave_table_cache = {} - -def log2_exp_work_from_mmax(t, k, mmax): - """Look up log2 of expected work for PORS+FP grinding given mmax. - - The (t, k) tables are cached: compute_mmax probes several mmax values per - parameter set, and sweeps revisit the same (k, a) pairs for every (h, d) — - without the cache a full PORS+FP sweep rebuilds each table thousands of times. - """ - key = (int(t), int(k)) - table = _interleave_table_cache.get(key) - if table is None: - table = dict(interleave_cost_table(*key)) - _interleave_table_cache[key] = table - if mmax in table: - return table[mmax] - lowers = [m for m in table if m <= mmax] - if not lowers: - raise ValueError("mmax below supported range for these (t,k).") - return table[max(lowers)] - -def exp_work_from_mmax(t, k, mmax): - """Compute expected work (attempts) for PORS+FP grinding given mmax.""" - return 2.0 ** log2_exp_work_from_mmax(t, k, mmax) - -# ----------------------------------------------------------------------------- -# Search/Grinding helpers -# ----------------------------------------------------------------------------- - -def worst_case(p, q, d, max_k=10**11): - """ - Find minimum trials k such that P(fewer than d successes in k trials) < q. - - Used to compute worst-case search time: how many attempts until we're - confident (with probability 1-q) that we've found d valid encodings. - - P(X < d) = Σ_{i=0}^{d-1} C(k,i) * p^i * (1-p)^(k-i), where X ~ Binomial(k, p) - - Returns smallest k where P(X < d) < q. - """ - # Convert Sage types to Python native types - p_float = float(p) - q_float = float(q) - d_int = int(d) - - def tail_prob(k): - if k < d_int: - return 1.0 - # binom.cdf(d-1, k, p) = P(X <= d-1) = P(X < d) - return binom.cdf(d_int - 1, int(k), p_float) - # Find an upper bound for k - k_low = d_int - 1 - k_high = max(d_int, 1) - while tail_prob(k_high) >= q_float: - k_high *= 2 - if k_high > max_k: - raise RuntimeError("Could not find k below given max_k; try increasing max_k.") - # Binary search between k_low and k_high - while k_low + 1 < k_high: - k_mid = (k_low + k_high) // 2 - if tail_prob(k_mid) < q_float: - k_high = k_mid - else: - k_low = k_mid - return k_high - -# ============================================================================= -# Core Computation Functions -# ============================================================================= - -def compute_size(h, d, a, k, w, scheme, mmax=0): - """ - Compute signature size in bytes. - - Signature structure: - - Randomness R (randomness_size bytes) - - d XMSS layers, each containing: - - WOTS signature: l hash values - - Auth path: h' = h/d hash values - - Counter (for W+C variants): counter_size bytes - - Few-time signature (FTS): - - FORS: k leaves + k*a auth path nodes - - FORS+C: (k-1) leaves + (k-1)*a auth path nodes (last tree omitted) - - PORS+FP: k leaves + mmax auth set nodes - - mmax: for PORS+FP, max size of the authentication set - """ - assert h % d == 0, "h must be divisible by d" - has_wc = (scheme != "SPX") - l = compute_wots_l(scheme, w) - h_prime = h // d - # Each XMSS layer: auth path (h') + WOTS sig (l) + optional counter - hyper_tree_size = d*(h_prime*hashbytes + l*hashbytes + counter_size*int(has_wc)) - # fts = few-time signature (FORS, FORS+C, or PORS+FP) - if scheme == "W+C_P+FP": - # k revealed leaves + mmax authentication nodes - fts_size = (k + mmax)*hashbytes - elif scheme == "W+C_F+C": - # k-1 trees (last tree omitted via grinding) - fts_size = (k-1)*hashbytes + (k-1)*a*hashbytes - else: # Plain FORS (SPX or W+C) - # k trees, each with 1 leaf + a auth path nodes - fts_size = k*hashbytes + k*a*hashbytes - return hyper_tree_size + fts_size + randomness_size - - -def compute_mmax(h_prime, l, w, d_wots_expected_search, d, k, a): - """Compute mmax for PORS+FP such that signing time is close to FORS+C. - - mmax is the max size of the authentication set. - """ - Thl = compute_Th(l) - Thk1 = compute_Th(k-1) # FORS+C has k-1 roots - merkle_tree_fixed_part_time = (2**h_prime * (l*C_PRF + l*(w-1)*C_Th1 + Thl)) + (2**h_prime-1)*C_Th2 - hyper_tree_expected_time = d*merkle_tree_fixed_part_time + d_wots_expected_search*C_Th1c - # Compute FORS+C time to relate PORS time in compression calls - fors_c_fixed_part_time = (k-1)*(2**a)*C_PRF + (k-1)*(2**a)*C_Th1 + (k-1)*(2**a-1)*C_Th2 + Thk1 - fors_c_expected_search_time = 2**a*(C_Hmsg + C_PRFmsg) - fors_c_expected_total_time = fors_c_fixed_part_time + fors_c_expected_search_time - spx_fc_expected_total_time = hyper_tree_expected_time + fors_c_expected_total_time - # Search for mmax such that PORS time is close to FORS+C time - t, subtree_height, extra_leaves = compute_pors_tree_geometry(k, a) - pors_fixed_part_time = t*(C_PRF) + t*(C_Th1) + ((2**subtree_height-1) + extra_leaves)*(C_Th2) - mmax = (k-1)*a - ceil(350/hashbytes) - for i in range(20): - pors_search_attempts = ceil(exp_work_from_mmax(t, k, mmax)) - pors_expected_search_time = pors_search_attempts*(C_Hmsg + C_PRFmsg) - pors_expected_total_time = pors_fixed_part_time + pors_expected_search_time - spx_pors_expected_total_time = hyper_tree_expected_time + pors_expected_total_time - ratio = spx_pors_expected_total_time/spx_fc_expected_total_time - if (ratio < 1.11 or i == 19): - return mmax - mmax += 1 - return mmax - - -def compute_keygen_time(h, d, w, scheme): - """ - Compute keygen time in compression function calls. - - Keygen builds the top-level XMSS tree (height h' = h/d) to derive PK.root. - No WOTS+C counter search occurs during keygen: the WOTS keypairs at the - leaves of the top tree are used as verification keys, not to sign anything. - - One XMSS tree with 2^h' leaves: - - 2^h' WOTS keypairs, each requiring: - - l PRF calls to generate secret keys - - l*(w-1) chain hashes (Th1) to derive the public chain values - - 1 hash to compress l public values into a leaf (Thl) - - 2^h' - 1 internal Merkle tree nodes (Th2) - - Returns the keygen time as a number of compression function calls. - """ - assert h % d == 0, "h must be divisible by d" - h_prime = h // d - l = compute_wots_l(scheme, w) - Thl = compute_Th(l) - - return 2**h_prime * (l*C_PRF + l*(w-1)*C_Th1 + Thl) + (2**h_prime - 1)*C_Th2 - - -def compute_signing_time(h, d, a, k, w, swn, scheme): - """ - Compute signing time in both hash calls and compression function calls. - - Returns a dict with keys: - - 'hashes': expected signing time in hash calls - - 'compressions': expected signing time in compression calls - - 'exp_search': expected search attempts - - 'worst_search': worst-case search attempts - - 'mmax': mmax value for PORS+FP (max authentication set size) - """ - assert h % d == 0, "h must be divisible by d" - has_wc = (scheme != "SPX") - h_prime = h // d - l = compute_wots_l(scheme, w) - Thl = compute_Th(l) - Thk = compute_Th(k) - - if has_wc: - # WOTS+C search: find counter such that digest sums to S_{w,n} - # Success probability p_ν = ν / w^l - # By geometric distribution, expected attempts until success = 1/p_ν = w^l / ν - # We need d successful searches (one per hypertree layer) - nu = compute_nu(l, swn, w) - d_wots_expected_search = d*ceil((w**l) / nu) - d_wots_worst_search = worst_case(nu/(w**l), F(2)**(-30), d) - else: - d_wots_expected_search = 0 - d_wots_worst_search = 0 - - # Compute times for both hash calls (h_) and compressions (c_) - # One XMSS tree layer with 2^h' leaves: - # - 2^h' WOTS keypairs, each requiring: - # - l PRF calls to generate secret keys - # - l*(w-1) chain hashes (Th1) - # - 1 hash to compress l public key values (Thl) - # - 2^h' - 1 internal Merkle tree nodes (Th2, hashing 2 children) - h_merkle = 2**h_prime * (l + l*(w-1) + 1) + (2**h_prime - 1) - c_merkle = 2**h_prime * (l*C_PRF + l*(w-1)*C_Th1 + Thl) + (2**h_prime - 1)*C_Th2 - - # Full hypertree: d layers + WOTS+C counter search overhead - h_hyper_exp = d*h_merkle + d_wots_expected_search - c_hyper_exp = d*c_merkle + d_wots_expected_search*C_Th1c - - # Message hash cost (2 hash calls: Hmsg + PRFmsg) - h_msg = 2 - c_msg = C_Hmsg + C_PRFmsg - - mmax = 0 - if scheme == "W+C_F+C": - # FORS+C: k-1 trees (last tree omitted, grinding ensures first leaf is selected) - # Each tree has 2^a leaves: - # - 2^a PRF calls to generate leaves - # - 2^a hashes to compute leaf nodes (Th1) - # - 2^a - 1 internal nodes (Th2) - # Plus 1 hash to compress k-1 roots - # Grinding: search for message hash with last a bits = 0, expected 2^a attempts - Thk1 = compute_Th(k-1) # k-1 roots to compress - h_fors_fixed = (k-1)*(2**a) + (k-1)*(2**a) + (k-1)*(2**a - 1) + 1 - c_fors_fixed = (k-1)*(2**a)*C_PRF + (k-1)*(2**a)*C_Th1 + (k-1)*(2**a - 1)*C_Th2 + Thk1 - fors_exp_search = 2**a - fors_worst_search = worst_case(F(1)/F(2**a), F(2)**(-30), 1) - h_fors_search = fors_exp_search * h_msg - c_fors_search = fors_exp_search * c_msg - return { - 'hashes': h_hyper_exp + h_fors_fixed + h_fors_search, - 'compressions': c_hyper_exp + c_fors_fixed + c_fors_search, - 'exp_search': d_wots_expected_search + h_fors_search, - 'worst_search': d_wots_worst_search + fors_worst_search * h_msg, - 'mmax': mmax, - } - elif scheme == "W+C_P+FP": - # PORS+FP: single tree with t = k * 2^a leaves - # Tree structure: largest power-of-2 subtree + extra leaves - # Grinding: search for message hash where Octopus auth set size <= mmax - t, subtree_height, extra_leaves = compute_pors_tree_geometry(k, a) - # t PRF calls + t leaf hashes + internal nodes - h_pors_fixed = 2*t + (2**subtree_height - 1) + extra_leaves - c_pors_fixed = t*C_PRF + t*C_Th1 + ((2**subtree_height - 1) + extra_leaves)*C_Th2 - mmax = compute_mmax(h_prime, l, w, d_wots_expected_search, d, k, a) - pors_search_attempts = ceil(exp_work_from_mmax(t, k, mmax)) - h_pors_search = pors_search_attempts * h_msg - c_pors_search = pors_search_attempts * c_msg - pors_worst_search = worst_case(F(1)/pors_search_attempts, F(2)**(-30), 1) - return { - 'hashes': h_hyper_exp + h_pors_fixed + h_pors_search, - 'compressions': c_hyper_exp + c_pors_fixed + c_pors_search, - 'exp_search': d_wots_expected_search + h_pors_search, - 'worst_search': d_wots_worst_search + pors_worst_search * h_msg, - 'mmax': mmax, - } - else: - # Plain FORS (SPX or W+C): k trees, each with 2^a leaves - # No grinding, message hash directly selects k leaf indices - h_fors_fixed = k*(2**a) + k*(2**a) + k*(2**a - 1) + 1 - c_fors_fixed = k*(2**a)*C_PRF + k*(2**a)*C_Th1 + k*(2**a - 1)*C_Th2 + Thk - return { - 'hashes': h_fors_fixed + h_msg + h_hyper_exp, - 'compressions': c_fors_fixed + c_msg + c_hyper_exp, - 'exp_search': d_wots_expected_search, - 'worst_search': d_wots_worst_search, - 'mmax': mmax, - } - -def compute_verification_time(h, d, a, k, w, swn, scheme, mmax=0): - """ - Compute verification time in both hash calls and compression function calls. - - Verification involves: - 1. Hash the message (Hmsg) - 2. Verify FTS (FORS/FORS+C/PORS+FP) - 3. For each of d hypertree layers: verify WOTS signature - 4. Verify h auth path nodes up the hypertree - - mmax: for PORS+FP, max size of the authentication set - """ - has_wc = (scheme != "SPX") - l = compute_wots_l(scheme, w) - Thl = compute_Th(l) - Thk = compute_Th(k) - - # WOTS verification: complete each chain from signature value to public key. - # h_wots / c_wots are totals over all d hypertree layers. - if has_wc: - # WOTS+C: chain positions sum to S_{w,n}, so remaining steps = (w-1)*l - S_{w,n} - # Plus: verify counter hash (Th1c) and compress public key (Thl) - # Deterministic: worst case == average case. - h_wots = d*((w-1)*l - swn + 2) - c_wots = d*(((w-1)*l - swn)*C_Th1 + C_Th1c + Thl) - c_wots_worst = c_wots - else: # Plain WOTS - l1 = ceil(hashbytes*8/log(w,2)) - l2 = floor(log(l1*(w-1), 2)/log(w, 2)) + 1 - # Average case: expected chain steps for a uniformly random digest, - # see compute_wots_tw_mean_steps. - avg_steps = compute_wots_tw_mean_steps(l1, l2, w, d) - h_wots = avg_steps + d - c_wots = avg_steps*C_Th1 + d*Thl - # Worst case (all message digits 0): see compute_wots_tw_worst_steps. - c_wots_worst = d*(compute_wots_tw_worst_steps(l1, l2, w)*C_Th1 + Thl) - - # FTS verification - if scheme == "W+C_F+C": - # FORS+C: k-1 leaves to hash, (k-1)*a auth path nodes, 1 root compression - Thk1 = compute_Th(k-1) # k-1 roots to compress - h_fts = (k-1) + (k-1)*a + 1 - c_fts = (k-1)*C_Th1 + (k-1)*a*C_Th2 + Thk1 - elif scheme == "W+C_P+FP": - # PORS+FP: k leaves to hash, mmax auth set nodes - h_fts = k + mmax - c_fts = k*C_Th1 + mmax*C_Th2 - else: # Plain FORS (SPX or W+C) - # FORS: k leaves, k*a auth path nodes, 1 root compression - h_fts = k + k*a + 1 - c_fts = k*C_Th1 + k*a*C_Th2 + Thk - - # Total: Hmsg + FTS + d*WOTS + h auth path nodes - # The FTS and auth-path costs are message-independent, so the worst case - # differs from the average only in the WOTS-TW chain walks. - return { - 'hashes': 1 + h_fts + h_wots + h, - 'compressions': C_Hmsg + c_fts + c_wots + h*C_Th2, - 'compressions_worst': C_Hmsg + c_fts + c_wots_worst + h*C_Th2, - } - -# ============================================================================= -# CSV Output -# ============================================================================= - -def compute_all_results(): - """Compute results for all parameter sets. Returns list of dicts.""" - results = [] - for scheme, q_s, h, d, a, k, w, _, swn, bold in PARAMETER_SETS: - # Compute l dynamically based on scheme - l = compute_wots_l(scheme, w) - - # Verify security level is at least 128 bits - scheme_type = "PORS+FP" if scheme == "W+C_P+FP" else "FORS" - security = compute_security(2**q_s, h, k, a, scheme_type) - assert security >= 128, f"Security {security:.1f} < 128 for {scheme} q_s=2^{q_s} h={h} a={a} k={k}" - - sign = compute_signing_time(h, d, a, k, w, swn, scheme) - verify = compute_verification_time(h, d, a, k, w, swn, scheme, sign['mmax']) - keygen_compressions = compute_keygen_time(h, d, w, scheme) - size = compute_size(h, d, a, k, w, scheme, sign['mmax']) - compressions_per_byte = float(verify['compressions']) / float(size) - compressions_per_byte_worst = float(verify['compressions_worst']) / float(size) - - results.append({ - 'scheme': scheme, - 'q_s': q_s, - 'h': h, - 'd': d, - 'a': a, - 'k': k, - 'w': w, - 'l': l, - 'swn': swn, - 'size': size, - 'keygen_compressions': keygen_compressions, - 'sign_hashes': sign['hashes'], - 'sign_compressions': sign['compressions'], - 'exp_search': sign['exp_search'], - 'worst_search': sign['worst_search'], - 'verify_hashes': verify['hashes'], - 'verify_compressions': verify['compressions'], - 'verify_compressions_worst': verify['compressions_worst'], - 'compressions_per_byte': compressions_per_byte, - 'compressions_per_byte_worst': compressions_per_byte_worst, - 'bold': bold, - }) - return results - - -def generate_csv(): - """Generate CSV output for all parameter sets.""" - print("scheme,q_s,h,d,a,k,w,l,paramsum,size,keygen_compressions,sign_hashes,sign_compressions,exp_search,worst_search,verify_hashes,verify_compressions,verify_compressions_worst,compressions_per_byte,compressions_per_byte_worst,bold") - - for r in compute_all_results(): - bold_str = "True" if r['bold'] else "False" - print(f"{r['scheme']},2^{r['q_s']},{r['h']},{r['d']},{r['a']},{r['k']},{r['w']},{r['l']},{r['swn']},{r['size']},{r['keygen_compressions']},{r['sign_hashes']},{r['sign_compressions']},{r['exp_search']},{r['worst_search']},{r['verify_hashes']},{r['verify_compressions']},{r['verify_compressions_worst']},{r['compressions_per_byte']:.2f},{r['compressions_per_byte_worst']:.2f},{bold_str}") - - -def format_num(n): - """Format large numbers with K/M suffixes for readability.""" - n = float(n) # Convert Sage types to Python float - if n >= 1_000_000: - return f"{n/1_000_000:.1f}M" - elif n >= 1_000: - return f"{n/1_000:.1f}K" - else: - return str(int(n)) - - -def generate_table(q_s_filter=None): - """Generate pretty ASCII table output for parameter sets. - - Args: - q_s_filter: If specified, only show rows with this q_s value (e.g., 40 for 2^40 sigs) - """ - results = compute_all_results() - - if q_s_filter is not None: - results = [r for r in results if r['q_s'] == q_s_filter] - - if not results: - print(f"No parameter sets found for q_s=2^{q_s_filter}") - return - - # Group results by q_s for section headers - q_s_groups = {} - for r in results: - q_s_groups.setdefault(r['q_s'], []).append(r) - - # Column definitions: (header, key, width, format_func) - columns = [ - ("Scheme", 'scheme', 10, str), - ("h", 'h', 4, str), - ("d", 'd', 3, str), - ("a", 'a', 3, str), - ("k", 'k', 3, str), - ("w", 'w', 4, str), - ("l", 'l', 3, str), - ("S_wn", 'swn', 5, str), - ("Size", 'size', 6, str), - ("Keygen(C)", 'keygen_compressions', 10, format_num), - ("Sign(C)", 'sign_compressions', 9, format_num), - ("Verify(C)", 'verify_compressions', 10, format_num), - ("C/byte", 'compressions_per_byte', 6, lambda x: f"{x:.2f}"), - ] - - # Calculate total width - total_width = sum(w for _, _, w, _ in columns) + len(columns) - 1 - - for q_s in sorted(q_s_groups.keys(), reverse=True): - group = q_s_groups[q_s] - - # Section header - print() - print("=" * total_width) - print(f" 2^{q_s} signatures ".center(total_width, "=")) - print("=" * total_width) - - # Column headers - header_line = " ".join(h.center(w) for h, _, w, _ in columns) - print(header_line) - print("-" * total_width) - - # Data rows - for r in group: - row_parts = [] - for _, key, width, fmt in columns: - val = fmt(r[key]) - row_parts.append(val.rjust(width) if key != 'scheme' else val.ljust(width)) - print(" ".join(row_parts)) - - print() - print("Legend: Size=bytes, Keygen(C)/Sign(C)/Verify(C)=compression calls, C/byte=verify compressions per signature byte") - - -# ============================================================================= -# Main -# ============================================================================= - -def compute_single(scheme, q_s_log2, h, d, a, k, w, swn): - """Compute and display results for a single parameter set.""" - q_s = 2**q_s_log2 - l = compute_wots_l(scheme, w) - scheme_type = "PORS+FP" if scheme == "W+C_P+FP" else "FORS" - security = compute_security(q_s, h, k, a, scheme_type) - sign = compute_signing_time(h, d, a, k, w, swn, scheme) - mmax = sign['mmax'] - verify = compute_verification_time(h, d, a, k, w, swn, scheme, mmax) - keygen_compressions = compute_keygen_time(h, d, w, scheme) - size = compute_size(h, d, a, k, w, scheme, mmax) - c_per_byte = float(verify['compressions']) / float(size) - - print("Scheme: " + scheme) - print("q_s: 2^" + str(q_s_log2)) - print("(k,a,H,d): (" + str(int(k)) + ", " + str(int(a)) + ", " + str(int(h)) + ", " + str(int(d)) + ")") - print("w: " + str(int(w))) - print("S_wn: " + str(int(swn))) - print("l: " + str(int(l))) - print("mmax: " + str(int(mmax))) - print("Security: " + "{:.1f}".format(security) + " bits") - print("Size: " + str(int(size)) + " bytes") - print("Keygen(C): " + format_num(keygen_compressions)) - print("Sign(C): " + format_num(sign['compressions'])) - print("Verify(C): " + format_num(verify['compressions']) + " (worst: " + format_num(verify['compressions_worst']) + ")") - print("C/byte: " + "{:.2f}".format(c_per_byte) + " (worst: " + "{:.2f}".format(float(verify['compressions_worst'])/float(size)) + ")") - - -# The env flag lets other scripts (stateful.sage, export_site_data.sage) load -# this file for its functions without triggering the CLI output (sage's -# load() executes in the caller's namespace, where __name__ is '__main__'). -if __name__ == "__main__" and not os.environ.get("COSTS_SAGE_NO_MAIN"): - import sys - - if len(sys.argv) > 1 and sys.argv[1] == "--table": - # Optional: --table 40 to filter by q_s - q_s_filter = int(sys.argv[2]) if len(sys.argv) > 2 else None - generate_table(q_s_filter) - elif len(sys.argv) > 1 and sys.argv[1] == "--params": - # Usage: sage costs.sage --params scheme q_s k a h d w swn - if len(sys.argv) != 10: - print("Usage: sage costs.sage --params scheme q_s k a h d w swn") - print(" scheme: SPX, W+C, W+C_F+C, W+C_P+FP") - print(" q_s: log2 of max signatures (e.g., 10 for 2^10)") - print("Example: sage costs.sage --params W+C_P+FP 10 8 17 12 1 16 240") - sys.exit(1) - scheme = sys.argv[2] - q_s_log2 = int(sys.argv[3]) - k = int(sys.argv[4]) - a = int(sys.argv[5]) - h = int(sys.argv[6]) - d = int(sys.argv[7]) - w = int(sys.argv[8]) - swn = int(sys.argv[9]) - compute_single(scheme, q_s_log2, h, d, a, k, w, swn) - elif len(sys.argv) > 1 and sys.argv[1] == "--help": - print("Usage: sage costs.sage [OPTIONS]") - print() - print("Options:") - print(" (no args) Output CSV format") - print(" --table [N] Output pretty ASCII table [filtered to 2^N]") - print(" --params scheme q_s k a h d w swn Compute single parameter set") - print(" --help Show this help message") - print() - print("Example:") - print(" sage costs.sage --params W+C_P+FP 10 8 17 12 1 16 240") - else: - generate_csv() diff --git a/docs/model.md b/docs/model.md new file mode 100644 index 0000000..bf4356c --- /dev/null +++ b/docs/model.md @@ -0,0 +1,220 @@ +# Cost and security model + +Sizes are in bytes and costs in SHA-256 compressions (C). The hash output is n = 16 bytes. +The pages compute the model in `site/js/model.js`, the Sage scripts in `model/costs.sage`, +`model/stateful.sage` and `model/security.sage`, and `tests/model.py` ports it to check both. The search grids +and the fixed parameters are in `parameters.json`. + +## Hash calls + +Hashing follows the FIPS 205 SHA-2 instantiation as the SHRINCS reference implementation runs it: +the block that holds PK.seed is compressed once per key and its state reused, so each call pays +only for the rest of its input. A tweakable hash over m values of n bytes takes + + Th(m) = ceil((176 + 128·m + 65) / 512) + +compressions: a 22-byte compressed address, the values, and the SHA-256 padding (one bit and a +64-bit length). A chain step, a chain step with the WOTS+C counter, a tree node and a PRF call each +fit in one block, so `C_Th1 = C_Th1c = C_Th2 = C_PRF = 1`. Compressing the l chain ends of a WOTS +key into a leaf takes Th(l), and the k FORS roots Th(k). + +For a 32-byte message and an empty context: + +- H_msg is two nested SHA-256 calls of 2 compressions each. Its input starts with the randomizer R, + so no state can be reused: `C_Hmsg = 4`. +- PRF_msg is HMAC-SHA-256, two SHA-256 calls of 3 and 2 compressions. Their first blocks, the fixed + key pads, are reused like the PK.seed block: `C_PRFmsg = 3`. + +## WOTS + +A WOTS key has l1 = ceil(8n / log2 w) message chains. WOTS-TW, the variant in SLH-DSA, adds +l2 = floor(log_w(l1·(w − 1))) + 1 checksum chains (FIPS 205); WOTS+C has none and grinds a counter +instead. With l chains, a public key costs + + pk = l·PRF + l·(w − 1)·Th1 + Th(l) + +### WOTS-TW verification + +A message digit m costs the verifier w − 1 − m chain steps, so the message chains average +(w − 1)/2 steps each. The checksum chains do not: the checksum C = l1·(w − 1) − S, with S the sum +of the message digits, is concentrated around l1·(w − 1)/2, so its leading base-w digit is almost +always 0 or 1 and the verifier walks most of that chain. With digit_sum(x) = x − (w − 1)·Σ_{j≥1} +floor(x / w^j), and C distributed like S (map every digit m to w − 1 − m), + + E[steps] = l1·(w − 1)/2 + l2·(w − 1) − E[digit_sum(C)] + = (w − 1)·(l2 + Σ_{j=1}^{l2−1} E[floor(S / w^j)]) + +where E[floor(S / w^j)] = Σ_{m≥1} P(S ≥ m·w^j), and the number of digit strings with sum at most t +is Σ_i (−1)^i·C(l1, i)·C(t − i·w + l1, l1). The code evaluates this with exact integers and rounds +the total for all d layers of a signature once, to the nearest integer. For w = 16 the mean is +267.12 steps rather than the 262 of (w − 1)·l/2; for w = 256 it is 2415.03 rather than 2295. + +The verifier walks the most when every message digit is 0. The checksum is then l1·(w − 1), so + + worst = l1·(w − 1) + l2·(w − 1) − digit_sum(l1·(w − 1)) + +Any other message saves at least as many steps on the message chains as it can cost on the +checksum chains. + +The signer walks the part of each chain the verifier does not: l PRF calls and +l·(w − 1) − E[steps] chain steps. + +### WOTS+C + +The signer grinds a counter until the l = l1 digits of the digest sum to S_wn, which makes +verification cost the same for every message. Of the w^l digit strings, + + ν = Σ_{j=0}^{l} (−1)^j·C(l, j)·C(S_wn + l − j·w − 1, l − 1) + +have that sum, so signing takes ceil(w^l / ν) attempts of `C_Th1c` on average, then l PRF calls and +S_wn chain steps. Verification takes l·(w − 1) − S_wn chain steps, one `C_Th1c` and Th(l). + +`parameters.json` sets S_wn = floor(l1·(w − 1)/2), the middle of the digit-sum distribution, where +ν is largest: 240 for w = 16 and 2040 for w = 256. For w = 128 the product is odd, and +ν(1206) = ν(1207). + +SHRINCS stores the counter in 2 bytes: 2^16 counters miss the target sum with probability below +2^−126 for every w ≤ 256. w = 65536 needs about 137,000 attempts on average and keeps a 4-byte +counter. + +## SLH-DSA + +For (h, d, k, a, w), the hypertree has d layers of trees of height h′ = h/d. + + size = n·(1 + k·(a + 1) + h + d·l) + tree = 2^h′·pk + (2^h′ − 1)·Th2 + KeyGen = tree + SigGen = FORS + H_msg + PRF_msg + d·tree + FORS = k·2^a·(PRF + Th1) + k·(2^a − 1)·Th2 + Th(k) + SigVer = H_msg + k·Th1 + k·a·Th2 + Th(k) + d WOTS-TW verifications + h·Th2 + +The size counts the randomizer, k FORS leaves with a authentication nodes each, the h nodes of the +hypertree authentication path and d WOTS signatures: 7,856 B for SLH-DSA-128s, (63, 7, 14, 12, 16). +KeyGen builds the top tree; a signature rebuilds one tree per layer. SigVer takes the mean of +[WOTS-TW verification](#wots-tw-verification), SigVer worst its worst case in every layer. A SHRINCS +stateless signature adds a one-byte indicator. + +### W+C, FORS+C and PORS+FP + +W+C signs every layer with WOTS+C instead of WOTS-TW: a layer holds l = l1 chain values and a +4-byte counter, signing adds d counter searches, and verification costs the same for every +message. W+C_F+C also replaces FORS with FORS+C, and W+C_P+FP with PORS+FP. Both grind the message +hash, each attempt costing H_msg + PRF_msg; the last attempt is the signature's message hash. + +- FORS+C grinds until the index in the last tree is 0, about 2^a attempts, and leaves that tree + out: k − 1 trees in the signature, in signing and in verification. +- PORS+FP builds one tree of t = k·2^a leaves, t·(PRF + Th1) and t − 1 node hashes, opens k of + its leaves and grinds until their Octopus authentication set has at most mmax nodes; + `third_party/octopus_pmf.py` gives the expected attempts. mmax starts at (k − 1)·a − ceil(350/n) and grows by + one, at most 19 times, until signing costs less than 1.11 times signing with FORS+C. + +For FORS, FORS+C and PORS+FP in turn, + + size = n·(1 + h + d·l) + 4·d + n·k·(a + 1), n·(k − 1)·(a + 1) or n·(k + mmax) + SigVer = H_msg + V + d·(((w − 1)·l − S_wn)·Th1 + Th1c + Th(l)) + h·Th2 + V = k·Th1 + k·a·Th2 + Th(k), (k − 1)·Th1 + (k − 1)·a·Th2 + Th(k − 1) or k·Th1 + mmax·Th2 + +## FORS security + +The stateless schemes are evaluated at q_s = 2^40 signatures. Each signature uses one of 2^h FORS +instances, so the number r of signatures that land on a given instance follows Binomial(q_s, 2^−h). +After r signatures each of the k trees of t = 2^a leaves has revealed up to r leaves, and a forger +needs the leaf it must open revealed in every tree: + + P(forgery | r) = (1 − (1 − 1/t)^r)^k + P(forgery) = Σ_r C(q_s, r)·2^(−h·r)·(1 − 2^−h)^(q_s − r)·P(forgery | r) + security = −log2(max(2^(−8n), P(forgery))) + +so 128 bits at most. Unlike the parameter script of the SPHINCS+ specification +(`reference/original-sphincs-parameter-search.sage`), this uses the exact forgery factor instead of the bound +(r/t)^k, and the maximum of the two attacks instead of their sum: a query cannot serve both, since +their tweaks differ. + +PORS+FP signs with k distinct leaves of one tree of t = k·2^a leaves, so r signatures reveal at most +min(r·k, t) of them: + + P(forgery | r) = C(min(r·k, t), k) / C(t, k) + +`model/security.sage` sums from r = 1 with 100-bit floats and stops past the mean once a term falls below +2^−1250. The page sums in log2 to avoid underflow. Only r within 30·sqrt(λ + 1) + 30 of the mean +λ = q_s / 2^h is summed: further out the binomial weight is below e^−450 of its peak. That keeps +the work at O(sqrt(λ)); when even that exceeds 2^16 terms, the sum takes every s-th term and +weights it by s. For the parameter grid λ ≤ 1, and every term from r = 1 is summed. + +## Stateful schemes + +Every stateful scheme uses the SHRINCS stateful signature layout, so sizes compare like for like: + + indicator (1 B) | R (16 B) | leaf index | per layer: [counter] + l·n chain values + h′·n auth path + +The leaf index takes max(1, ceil(h / 8)) bytes in XMSS and XMSS-MT. In UXMSS it takes +ceil(min(depth, 64) / 8) bytes for a leaf at that depth: SHRINCS sizes the field by the depth of +the signing leaf. + +### XMSS and XMSS-MT + +d layers of trees of height h′ = h/d give 2^h signatures; XMSS is d = 1. + + KeyGen = 2^h′·pk + (2^h′ − 1)·Th2 the top tree + SigVer = H_msg + d·(WOTS verification + h′·Th2) + +A cached signer keeps a BDS traversal state for every layer (Buchmann, Dahmen and Schneider, +"Merkle Tree Traversal Revisited", PQCrypto 2008) and the WOTS signatures of the upper layers, so +it computes one WOTS signature and one BDS update per layer. K is 2 for even h′ and 3 for odd, the +smallest K ≥ 2 with h′ − K even. An update takes at most (h′ − K)/2 + 1 leaf computations and +floor(3·(h′ − K − 1)/2) + 1 node hashes; the long-run average is about one leaf computation lower. + + SigGen, cached = H_msg + PRF_msg + WOTS signature + d·(leaves·pk + hashes·Th2) + State = d·(h′ + floor(h′/2) + 2^K − K − 1 + (h′ − K) + (h′ − K − 1))·n + (d − 1)·l·n + +The state counts what the SHRINCS signer caches hold: the authentication path, the keep nodes, +the retained nodes, one completed node per treehash instance and their stack of partial results, +plus the upper-layer WOTS signatures. + +A cold signer keeps no state and rebuilds the d trees for every signature. In the top tree it needs +only the authentication path, so it skips the signing leaf and the h′ nodes on its path to the root: + + SigGen, cold = H_msg + PRF_msg + d·(tree + WOTS signature) − pk − h′·Th2 + +### UXMSS + +The SHRINCS unbalanced tree has a spine of height hsf with hsf + 1 leaves. The q-th signature +(q = 1 is the first) carries min(q, hsf) authentication nodes, so each signature is one node longer +than the one before. hsf is the largest value for which the last signature stays below 5,712 B, +the size of the report's candidate 2, (45, 5, 8, 16, 16), and at most 255: SHRINCS encodes the tree +height in one byte. + + size(q) = 1 + 16 + index + counter + l·n + min(q, hsf)·n + KeyGen = (hsf + 1)·pk + hsf·Th2 + SigVer = H_msg + WOTS verification + min(q, hsf)·Th2 + State = (hsf + 1)·n + +A cached signer keeps the hsf + 1 leaf public keys. Every authentication node is one of them, +except the sibling of the signing leaf on the spine, which it recombines from the cached leaves +below it with hsf − q node hashes (none for the last two signatures): + + SigGen, cached = H_msg + PRF_msg + WOTS signature + (hsf − min(q, hsf))·Th2 + +A cold signer first rebuilds the hsf other leaves. Its first signature, the most expensive, takes +SigGen, cached for q = 1, plus hsf·pk. + +## Distance + +The explorer and the stateful page rank the candidates within the bounds by the report's +metric-normalised weighted distance (Section 3, Metric-Normalized Weighted Distance). For each +metric i (size, KeyGen, SigGen, SigVer and ρ), X_i is the candidate's ratio to SLH-DSA-128s, +rescaled over the candidates within the bounds, and W_i is its weight: + + X̂_i = (X_i − min_i) / (max_i − min_i) max_i = min_i + 1 if all X_i are equal + d = sqrt(Σ_i p_i·X̂_i²), p_i = W_i / Σ_j W_j + +Because every X̂_i spans [0, 1], a weight sets its metric's share of the distance however loose the +bounds are. The explorer's worst-case mode uses the worst-case SigVer and ρ; on the stateful page +SigGen follows the signer mode. + +## Schnorr reference + +ρ ÷ Schnorr divides ρ by the compressions per byte of a BIP340 Schnorr verification with +libsecp256k1 for a 64-byte signature, read from `results/kappa.json` of +[bip340-vs-sha256](https://github.com/mjthatch/bip340-vs-sha256) when a page loads. If that fails, +the pages use 1.98 C/B. diff --git a/document/content/2-parameters.tex b/document/content/2-parameters.tex index ea76715..08946b6 100644 --- a/document/content/2-parameters.tex +++ b/document/content/2-parameters.tex @@ -43,6 +43,6 @@ \subsection{SLH-DSA Parameters} (h, d, k, a, w) \in \{40, \dots, 50\} \times \{2, \dots, 25\} \times \{6, \dots, 24\} \times \{8, \dots, 20\} \times \{2, 4, 16, 256\}. \end{equation*} -The lower bound $k \geq 6$ discards only insecure configurations: with $a \leq 20$ and $h \leq 50$, any $k \leq 5$ falls short of the 128-bit security floor at $q_s = 2^{40}$, so smaller values of $k$ never survive the security filter. The same grid is used by the sweep script (\texttt{slhdsa-2to40.sage}) and the interactive explorer, so the counts reported below are reproducible in both. +The lower bound $k \geq 6$ discards only insecure configurations: with $a \leq 20$ and $h \leq 50$, any $k \leq 5$ falls short of the 128-bit security floor at $q_s = 2^{40}$, so smaller values of $k$ never survive the security filter. The same grid is used by the sweep script (\texttt{scripts/slhdsa-2to40.sage}) and the interactive explorer, so the counts reported below are reproducible in both. \textit{The SLH-DSA-128s baseline tuple (63, 7, 14, 12, 16) sits outside this range and is included for comparison} \ No newline at end of file diff --git a/document/content/3-search.tex b/document/content/3-search.tex index dad1173..e4d3119 100644 --- a/document/content/3-search.tex +++ b/document/content/3-search.tex @@ -16,7 +16,7 @@ \subsection{Initial Search Conditions} \subsection{Hash-Cost Convention} \label{sec:hash-convention} All compression counts in this report use the \emph{midstate-cached} convention. The SHA-2 instantiation of FIPS~205 zero-pads $\mathsf{PK.seed}$ to a full 64-byte block precisely so that implementations can precompute the SHA-256 compression state after that block once per keypair and reuse it for every tweakable-hash call. Under this convention, each call therefore costs only its \emph{marginal} compressions over the compressed address $\mathsf{ADRS}_c$ (22 bytes) plus the payload and padding: a WOTS chain step or FORS leaf ($22 + 16$ bytes) costs one compression, and — importantly — a two-child Merkle node hash ($22 + 32$ bytes $= 497$ bits) also costs exactly \emph{one} compression. The two message functions are counted as the SHRINCS reference implementation\footnote{\url{https://github.com/SHRINCS/shrincs-bip}} executes them for a 32-byte message. The midstate trick does not apply to the message hash $H_{\mathsf{msg}}$, whose input begins with the randomizer $R$: it consists of two nested SHA-256 calls and costs four compressions. $\mathsf{PRF}_{\mathsf{msg}}$ is HMAC-SHA-256 and costs three compressions once its two key-pad blocks are cached in the same way as the $\mathsf{PK.seed}$ block. -Under this convention the SLH-DSA-128s baseline costs are: KeyGen $292{,}351$, SigGen $2{,}218{,}486$, and SigVer $2{,}193$ (average) / $3{,}893$ (worst-case) compressions. The original convention of the accompanying scripts remains available via the \texttt{HASH\_CONVENTION=uncached} environment flag: every call pays for its full input (a node hash then costs two compressions), the two message functions are single two-block hashes, and WOTS chain walks are averaged as $(w-1)/2$ steps per chain, giving the baseline SigVer $2{,}387$. +Under this convention the SLH-DSA-128s baseline costs are: KeyGen $292{,}351$, SigGen $2{,}218{,}486$, and SigVer $2{,}193$ (average) / $3{,}893$ (worst-case) compressions. The original convention of the accompanying scripts is kept at the repository tag \texttt{uncached-convention}: every call pays for its full input (a node hash then costs two compressions), the two message functions are single two-block hashes, and WOTS chain walks are averaged as $(w-1)/2$ steps per chain, giving the baseline SigVer $2{,}387$. \subsection{Toolset} The methodology described here uses a set of SageMath scripts available at \url{https://github.com/BlockstreamResearch/SPHINCS-Parameters}. diff --git a/export_site_data.sage b/export_site_data.sage deleted file mode 100644 index b3a5b18..0000000 --- a/export_site_data.sage +++ /dev/null @@ -1,291 +0,0 @@ -#!/usr/bin/env sage -""" -Export the canonical dataset consumed by both interactive pages: site/data.json. - -Single source of truth: - - SPX pool : core rows read from utils/all_size_capped_candidates.csv - (regenerate first if needed: - sage slhdsa-2to40.sage --max-size 7856 && - mv candidates.csv utils/all_size_capped_candidates.csv), - plus optional-w rows (W_OPTIONAL) - - variant pools : W+C, W+C_F+C, W+C_P+FP swept over the same canonical - grid with (w, swn) in {(16, 240), (256, 2040)}, using - costs.sage / security.sage directly - - stateful grid, UXMSS variants, SLH baseline - : computed by loading stateful.sage - -The pages load data.json at startup and fall back to in-browser computation -(SPX only) if the file is unavailable; they also self-check the loaded -numbers against their local formulas and log any divergence to the console. - -Usage: - sage export_site_data.sage # all schemes (a few minutes) - sage export_site_data.sage --schemes SPX,W+C # subset (pages adapt) -Verify: - python3 tests/test_regression.py -""" -import os -import sys -import csv as _csvmod -import json -import subprocess -import datetime - -_dir = os.path.dirname(os.path.abspath(__file__)) -os.environ['STATEFUL_SAGE_NO_MAIN'] = '1' -os.environ['COSTS_SAGE_NO_MAIN'] = '1' - -_saved_argv = sys.argv -sys.argv = ['export_site_data.sage'] -load(os.path.join(_dir, "stateful.sage")) # also loads costs.sage + security.sage -sys.argv = _saved_argv - -ALL_SCHEMES = ['SPX', 'W+C', 'W+C_F+C', 'W+C_P+FP'] -schemes = ALL_SCHEMES -_args = _saved_argv[1:] -i = 0 -while i < len(_args): - if _args[i] == '--schemes' and i + 1 < len(_args): - schemes = [s for s in _args[i + 1].split(',') if s] - i += 2 - elif _args[i] == '--help': - print(__doc__) - sys.exit(0) - else: - print("Unknown argument: {}".format(_args[i]), file=sys.stderr) - sys.exit(2) -for s in schemes: - assert s in ALL_SCHEMES, "unknown scheme: %s" % s - -# Canonical stateless grid (report Sec. 2; must match slhdsa-2to40.sage -# defaults and the explorer's fallback sweep). -GRID = {'h': [40, 50], 'd': [2, 25], 'k': [6, 24], 'a': [8, 20], - 'w': [2, 4, 16, 256], 'q_s_log2': 40, 'max_size': 7856} -# Optional Winternitz extras for the SPX pool -W_OPTIONAL = [32, 64, 128] -WC_PAIRS = [(16, 240), (256, 2040)] # (w, swn) for WOTS+C schemes -POOL_FIELDS = ['h', 'd', 'k', 'a', 'w', 'swn', 'mmax', - 'size', 'kg', 'sg', 'sv', 'sv_worst'] - - -def _hd_pairs(): - return [(h, d) - for h in range(GRID['h'][0], GRID['h'][1] + 1) - for d in range(GRID['d'][0], GRID['d'][1] + 1) - if h % d == 0] - - -# --------------------------------------------------------------------------- -# SPX pool: straight from the canonical sweep CSV -# --------------------------------------------------------------------------- -csv_path = os.path.join(_dir, 'utils', 'all_size_capped_candidates.csv') -spx_rows, baseline = [], None -with open(csv_path) as f: - for r in _csvmod.DictReader(f): - rec = [int(r['h']), int(r['d']), int(r['k']), int(r['a']), int(r['w']), - 0, 0, - int(r['size']), int(float(r['keygen_C'])), int(float(r['sign_C'])), - int(float(r['verify_C'])), int(float(r['verify_worst_C']))] - if r['label'] == 'STANDARD': - baseline = {'params': {'h': 63, 'd': 7, 'k': 14, 'a': 12, 'w': 16}, - 'size': rec[7], 'kg': rec[8], 'sg': rec[9], - 'sv': rec[10], 'sv_worst': rec[11]} - else: - spx_rows.append(rec) -assert baseline is not None, "STANDARD row missing from %s" % csv_path - -# --------------------------------------------------------------------------- -# Variant pools: computed with costs.sage functions (security cached) -# --------------------------------------------------------------------------- -_sec_cache = {} - - -def _security_ok(model, h, k, a): - key = (model, int(h), int(k), int(a)) - if key not in _sec_cache: - _sec_cache[key] = float(compute_security( - 2**GRID['q_s_log2'], h, k, a, model, hashbytes=16)) >= 128.0 - return _sec_cache[key] - - -def sweep_spx_extra(w_values): - """SPX rows for the optional w values (same initial bounds as the CSV).""" - rows = [] - pairs = _hd_pairs() - for w in w_values: - for pi, (h, d) in enumerate(pairs): - print(" SPX extras: w={} {}/{}".format(w, pi + 1, len(pairs)), - file=sys.stderr, end='\r') - for k in range(GRID['k'][0], GRID['k'][1] + 1): - for a in range(GRID['a'][0], GRID['a'][1] + 1): - if not _security_ok("FORS", h, k, a): - continue - try: - sign = compute_signing_time(h, d, a, k, w, 0, 'SPX') - mmax = sign['mmax'] - verify = compute_verification_time(h, d, a, k, w, 0, 'SPX', mmax) - size = compute_size(h, d, a, k, w, 'SPX', mmax) - except Exception: - continue - if size >= GRID['max_size']: - continue - kg = compute_keygen_time(h, d, w, 'SPX') - rows.append([int(h), int(d), int(k), int(a), int(w), - 0, 0, int(size), int(kg), - int(sign['compressions']), - int(verify['compressions']), - int(verify['compressions_worst'])]) - print(file=sys.stderr) - return rows - - -def sweep_scheme(scheme): - model = "PORS+FP" if scheme == "W+C_P+FP" else "FORS" - rows, considered = [], 0 - pairs = _hd_pairs() - for w, swn in WC_PAIRS: - for pi, (h, d) in enumerate(pairs): - print(" {}: (w={}, swn={}) {}/{}".format(scheme, w, swn, pi + 1, len(pairs)), - file=sys.stderr, end='\r') - for k in range(GRID['k'][0], GRID['k'][1] + 1): - for a in range(GRID['a'][0], GRID['a'][1] + 1): - considered += 1 - if not _security_ok(model, h, k, a): - continue - try: - sign = compute_signing_time(h, d, a, k, w, swn, scheme) - mmax = sign['mmax'] - verify = compute_verification_time(h, d, a, k, w, swn, scheme, mmax) - size = compute_size(h, d, a, k, w, scheme, mmax) - except Exception: - continue - if size >= GRID['max_size']: - continue - kg = compute_keygen_time(h, d, w, scheme) - rows.append([int(h), int(d), int(k), int(a), int(w), - int(swn), int(mmax), int(size), int(kg), - int(sign['compressions']), - int(verify['compressions']), - int(verify['compressions_worst'])]) - print(file=sys.stderr) - return rows, considered - - -pools = {} -if 'SPX' in schemes: - pairs = _hd_pairs() - # 'considered' counts the core grid only; the page derives the count for - # whichever w set is active from meta.grid. - spx_considered = (len(pairs) * len(GRID['w']) - * (GRID['k'][1] - GRID['k'][0] + 1) - * (GRID['a'][1] - GRID['a'][0] + 1)) - spx_extra = sweep_spx_extra(W_OPTIONAL) - all_spx = spx_rows + spx_extra - pools['SPX'] = {'fields': POOL_FIELDS, 'considered': spx_considered, - 'count': len(all_spx), 'rows': all_spx} - print("SPX: {} rows ({} core from CSV + {} optional-w)".format( - len(all_spx), len(spx_rows), len(spx_extra)), file=sys.stderr) -for scheme in schemes: - if scheme == 'SPX': - continue - rows, considered = sweep_scheme(scheme) - pools[scheme] = {'fields': POOL_FIELDS, 'considered': considered, - 'count': len(rows), 'rows': rows} - print("{}: {} rows of {} considered".format(scheme, len(rows), considered), - file=sys.stderr) - -# --------------------------------------------------------------------------- -# Stateful: XMSS/XMSS-MT grid + UXMSS variants (same functions as the CLI) -# --------------------------------------------------------------------------- -_SWN = {16: 240, 32: 403, 64: 693, 128: 1206, 256: 2040, 65536: 262140} -STATEFUL_W = (16, 32, 64, 128, 256, 65536) -UXMSS_REF = 5712 # Report Candidate 2 — reference bound for max stateful size - -xmssmt_rows = [] -for ots_label, ots_type in (('TW', OTS_TW), ('WC', OTS_WC)): - for w in STATEFUL_W: - swn = _SWN[w] if ots_type == OTS_WC else 0 - for h in range(10, 41): - for d in range(1, 11): - if h % d != 0: - continue - xmssmt_rows.append([ - ots_label, h, d, w, - int(xmssmt_size_h(h, d, w, ots_type)), - int(xmssmt_keygen_C_h(h, d, w, ots_type)), - int(xmssmt_sign_bds_C_h(h, d, w, swn, ots_type)), - int(xmssmt_sign_cold_C_h(h, d, w, swn, ots_type)), - int(xmssmt_state_bytes(h, d, w, ots_type)), - int(xmssmt_verify_C_h(h, d, w, swn, ots_type, worst_case=False)), - int(xmssmt_verify_C_h(h, d, w, swn, ots_type, worst_case=True)), - ]) - -uxmss_rows = [] -for ots_label, ots_type in (('TW', OTS_TW), ('WC', OTS_WC)): - for w in STATEFUL_W: - swn = _SWN[w] if ots_type == OTS_WC else 0 - hsf = find_max_hsf(w, ots_type, UXMSS_REF) - uxmss_rows.append([ - ots_label, w, int(hsf), - int(uxmss_size(1, hsf, w, ots_type)), - int(uxmss_size(hsf, hsf, w, ots_type)), - int(uxmss_keygen_C(hsf, w, ots_type)), - int(uxmss_sign_C(1, hsf, w, swn, ots_type)), - int(uxmss_sign_cold_C(hsf, w, swn, ots_type)), - int(uxmss_state_bytes(hsf)), - int(uxmss_verify_C(hsf, hsf, w, swn, ots_type, worst_case=False)), - int(uxmss_verify_C(hsf, hsf, w, swn, ots_type, worst_case=True)), - ]) - - -def _commit(): - try: - return subprocess.check_output( - ['git', 'rev-parse', '--short', 'HEAD'], - cwd=_dir, stderr=subprocess.DEVNULL).decode().strip() - except Exception: - sha = os.environ.get('GITHUB_SHA', '') - return sha[:7] if sha else 'unknown' - - -data = { - 'meta': { - 'version': 3, - 'generator': 'export_site_data.sage', - 'generated': datetime.date.today().isoformat(), - 'commit': _commit(), - 'grid': GRID, - 'w_optional': W_OPTIONAL, - 'wc_pairs': WC_PAIRS, - 'schemes': [s for s in ALL_SCHEMES if s in pools], - 'uxmss_ref_size': UXMSS_REF, - 'hsf_max': int(HSF_MAX), - 'cost_convention': HASH_CONVENTION, - }, - 'baseline': baseline, - 'pools': pools, - 'stateful': { - 'slh': {'size': baseline['size'], 'kg': baseline['kg'], - 'sg': baseline['sg'], 'sv': baseline['sv'], - 'sv_worst': baseline['sv_worst'], 'qs_log2': 64}, - 'xmssmt': { - 'fields': ['ots', 'h', 'd', 'w', 'size', 'kg', 'sg', 'sg_cold', - 'state', 'sv', 'sv_worst'], - 'rows': xmssmt_rows, - }, - 'uxmss': { - 'fields': ['ots', 'w', 'hsf', 'sz_q1', 'sz_max', 'kg', 'sg', - 'sg_cold', 'state', 'sv_max', 'sv_max_worst'], - 'rows': uxmss_rows, - }, - }, -} - -out_path = os.path.join(_dir, 'site', 'data.json') -with open(out_path, 'w') as f: - # default=int: the sage preparser turns integer literals into sage - # Integers, which the json module cannot serialize directly. - json.dump(data, f, separators=(',', ':'), default=int) -print("Wrote {} — schemes: {} — commit {}".format( - out_path, ', '.join('%s:%d' % (s, pools[s]['count']) for s in pools), - data['meta']['commit'])) diff --git a/model/costs.sage b/model/costs.sage new file mode 100644 index 0000000..434bba6 --- /dev/null +++ b/model/costs.sage @@ -0,0 +1,610 @@ +#!/usr/bin/env sage +""" +Sizes in bytes and costs in SHA-256 compressions of SLH-DSA (SPX) and the +report's variants W+C, W+C_F+C and W+C_P+FP for (h, d, k, a, w), and the sweep +that slhdsa-2to40.sage and export_site_data.sage share. docs/model.md has the +model; PARAMETER_SETS holds the parameter sets of the report's tables. + + sage model/costs.sage [--table [log2 q_s]] (those sets as CSV, or as tables) + sage model/costs.sage --params scheme log2_q_s k a h d w swn +""" + +from sage.all import * +from functools import lru_cache +import json +import os +import sys + +# Every script sits one folder below the repository root, and load() keeps the +# __file__ of the script that was started, so this holds in every script. +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(ROOT, "third_party")) +from octopus_pmf import interleave_cost_table + +load(os.path.join(ROOT, "model", "security.sage")) + +# Search grids and fixed parameters. Integers become Sage Integers, as if they +# were written in this file. +def _sage_numbers(x): + if isinstance(x, bool): + return x + if isinstance(x, int): + return Integer(x) + if isinstance(x, list): + return [_sage_numbers(v) for v in x] + if isinstance(x, dict): + return {key: _sage_numbers(v) for key, v in x.items()} + return x + +with open(os.path.join(ROOT, "parameters.json")) as _f: + PARAMETERS = _sage_numbers(json.load(_f)) +SWN = {Integer(w): swn for w, swn in PARAMETERS['swn'].items()} # WOTS+C target digit sum per w + +# Sizes in bytes + +hashbytes = 16 # n +counter_size = 4 # the WOTS+C counter of the stateless variants +randomness_size = 16 # the randomizer R + +# Compressions per hash call: SHA-256 as SHRINCS (github.com/SHRINCS/shrincs-bip) +# runs it, with the PK.seed block compressed once per key (docs/model.md, Hash calls) + +C_Th1 = 1 # tweakable hash of one value: a chain step or a leaf +C_Th1c = 1 # tweakable hash of one value and the WOTS+C counter +C_Th2 = 1 # tweakable hash of two values: a tree node +C_PRF = 1 # PRF of an address with SK.seed +C_Hmsg = 4 # for a 32-byte message and an empty context +C_PRFmsg = 3 + +def compute_Th(m): + """Compressions for a tweakable hash of m values: a 22-byte address, the + values and the SHA-256 padding.""" + return ceil((22*8 + 128*m + 65)/512) + +# The parameter sets of the report's tables, in their order: +# (scheme, log2 q_s, h, d, a, k, w, l, S_wn, bold), bold for the rows the +# report highlights. The code computes l again from w. +PARAMETER_SETS = [ + # Table 1: 2^64 signatures (SPX) + ("SPX", 64, 63, 7, 12, 14, 16, 35, 0, True), + + # Table 1: 2^40 signatures + # W+C (5 rows) + ("W+C", 40, 44, 4, 16, 8, 16, 32, 240, False), + ("W+C", 40, 44, 4, 16, 8, 16, 32, 304, False), + ("W+C", 40, 44, 4, 16, 8, 256, 16, 2040, False), + ("W+C", 40, 40, 5, 14, 11, 256, 16, 2040, False), + ("W+C", 40, 40, 5, 14, 11, 256, 16, 2840, False), + # W+C F+C (2 rows) + ("W+C_F+C", 40, 44, 4, 16, 8, 16, 32, 240, False), + ("W+C_F+C", 40, 40, 5, 14, 11, 256, 16, 2040, True), + # W+C P+FP (2 rows) + ("W+C_P+FP", 40, 44, 4, 16, 8, 16, 32, 240, False), + ("W+C_P+FP", 40, 40, 5, 14, 11, 256, 16, 2040, True), + + # Table 1: 2^30 signatures + # W+C (6 rows) + ("W+C", 30, 36, 3, 14, 9, 16, 32, 240, False), + ("W+C", 30, 33, 3, 15, 9, 16, 32, 240, False), + ("W+C", 30, 33, 3, 15, 9, 16, 32, 304, False), + ("W+C", 30, 33, 3, 15, 9, 256, 16, 2040, False), + ("W+C", 30, 32, 4, 14, 10, 256, 16, 2040, False), + ("W+C", 30, 32, 4, 14, 10, 256, 16, 2840, False), + # W+C F+C (4 rows) + ("W+C_F+C", 30, 36, 3, 14, 9, 16, 32, 240, False), + ("W+C_F+C", 30, 33, 3, 15, 9, 16, 32, 240, False), + ("W+C_F+C", 30, 33, 3, 15, 9, 256, 16, 2040, False), + ("W+C_F+C", 30, 32, 4, 14, 10, 256, 16, 2040, True), + # W+C P+FP (3 rows) + ("W+C_P+FP", 30, 36, 3, 14, 9, 16, 32, 240, False), + ("W+C_P+FP", 30, 33, 3, 15, 9, 16, 32, 240, False), + ("W+C_P+FP", 30, 32, 4, 14, 10, 256, 16, 2040, True), + + # Table 2: 2^20 signatures (grouped by h) + # h=24 + ("W+C", 20, 24, 2, 16, 8, 16, 32, 240, False), + ("W+C", 20, 24, 2, 16, 8, 256, 16, 2040, False), + ("W+C_F+C", 20, 24, 2, 16, 8, 16, 32, 240, False), + ("W+C_P+FP", 20, 24, 2, 16, 8, 16, 32, 240, False), + # h=20 + ("W+C", 20, 20, 2, 15, 10, 256, 16, 2040, False), + ("W+C_F+C", 20, 20, 2, 15, 10, 256, 16, 2040, False), + ("W+C_P+FP", 20, 20, 2, 15, 10, 256, 16, 2040, False), +] + +# WOTS + +@lru_cache(maxsize=None) # the symbolic logs are slow, and w takes few values +def wots_lengths(w): + """(l1, l2): the message and checksum chains of WOTS-TW (FIPS 205, + Section 5). l1 rounds up, so for w = 32 the 26 chains carry 130 bits.""" + l1 = ceil(hashbytes*8/log(w, 2)) + l2 = floor(log(l1*(w-1), 2)/log(w, 2)) + 1 + return l1, l2 + +def compute_wots_l(scheme, w): + """Number of WOTS chains: l1 + l2 for WOTS-TW (SPX), l1 for WOTS+C, + which grinds a counter instead of signing a checksum.""" + l1, l2 = wots_lengths(w) + return l1 + l2 if scheme == "SPX" else l1 + +def compute_wots_tw_worst_steps(l1, l2, w): + """Chain steps to verify one WOTS-TW signature in the worst case, when + every message digit is 0 (docs/model.md, WOTS-TW verification).""" + C = l1 * (w - 1) + ds, rem = 0, int(C) + while rem > 0: + ds += rem % int(w) + rem //= int(w) + return int(l1) * (int(w) - 1) + int(l2) * (int(w) - 1) - ds + +def _round_div(num, den): + """Nearest integer to num/den for non-negative integers (ties round up).""" + return (2*num + den) // (2*den) + +_wots_tw_floor_cache = {} + +def _wots_tw_floor_sum(l1, l2, w): + """w^l1 * sum_{j=1}^{l2-1} E[floor(S / w^j)] as an exact integer, S the sum + of l1 uniform base-w digits (docs/model.md, WOTS-TW verification).""" + l1, l2, w = int(l1), int(l2), int(w) + key = (l1, l2, w) + if key in _wots_tw_floor_cache: + return _wots_tw_floor_cache[key] + top = l1 * (w - 1) + W = w**l1 + + def count_le(t): + total = 0 + for i in range(min(l1, t // w) + 1): + total += (-1)**i * binomial(l1, i) * binomial(t - i*w + l1, l1) + return total + + acc = 0 + for j in range(1, l2): + step = w**j + for m in range(1, top // step + 1): + acc += W - count_le(m*step - 1) + _wots_tw_floor_cache[key] = acc + return acc + +def compute_wots_tw_mean_steps(l1, l2, w, count=1): + """Mean chain steps to verify count WOTS-TW signatures of uniform digests, + rounded once to the nearest integer (docs/model.md, WOTS-TW verification).""" + W = int(w)**int(l1) + T = _wots_tw_floor_sum(l1, l2, w) + return _round_div(count * (w - 1) * (l2*W + T), W) # count*(w-1)*(l2 + T/W) + +def compute_nu(l: int, paramsum: int, w: int) -> int: + """ν, the number of strings of l base-w digits that sum to paramsum + (docs/model.md, WOTS+C). 1, with a warning, where there are none.""" + nu = 0 + for j in range(l + 1): + sign = (-1) ** j + binom1 = binomial(l, j) + n = (paramsum + l) - j * w - 1 + binom2 = binomial(n, l - 1) if n >= l - 1 and l - 1 >= 0 else 0 + nu += sign * binom1 * binom2 + if nu == 0: + print(f"Warning: ν computed as zero for l={l}, paramsum={paramsum}, w={w}. This may lead to division by zero.") + nu = 1 # Avoid division by zero + return nu + +# PORS+FP + +def compute_pors_tree_geometry(k, a): + """(t, subtree_height, extra_leaves) of the PORS+FP tree: its t = k*2^a + leaves, the height of its largest complete subtree, and the leaves + outside that subtree.""" + t = k * (2**a) + subtree_height = floor(log(t, 2)) + extra_leaves = t - 2**subtree_height + return t, subtree_height, extra_leaves + +_interleave_table_cache = {} + +def log2_exp_work_from_mmax(t, k, mmax): + """log2 of the expected attempts for PORS+FP grinding to an authentication + set of at most mmax nodes. The (t, k) tables are cached: a sweep asks for + the same (k, a) at every (h, d), and compute_mmax for several mmax.""" + key = (int(t), int(k)) + table = _interleave_table_cache.get(key) + if table is None: + table = dict(interleave_cost_table(*key)) + _interleave_table_cache[key] = table + if mmax in table: + return table[mmax] + lowers = [m for m in table if m <= mmax] + if not lowers: + raise ValueError("mmax below supported range for these (t,k).") + return table[max(lowers)] + +def exp_work_from_mmax(t, k, mmax): + """Expected attempts for PORS+FP grinding to at most mmax nodes.""" + return 2.0 ** log2_exp_work_from_mmax(t, k, mmax) + +# Sizes and costs (docs/model.md, SLH-DSA) + +def compute_size(h, d, a, k, w, scheme, mmax=0): + """Signature size in bytes: R, then per layer the authentication path, the + WOTS signature and, for WOTS+C, the counter, then the FORS, FORS+C or + PORS+FP signature; mmax bounds the PORS+FP authentication set.""" + assert h % d == 0, "h must be divisible by d" + has_wc = (scheme != "SPX") + l = compute_wots_l(scheme, w) + h_prime = h // d + hyper_tree_size = d*(h_prime*hashbytes + l*hashbytes + counter_size*int(has_wc)) + if scheme == "W+C_P+FP": + fts_size = (k + mmax)*hashbytes + elif scheme == "W+C_F+C": + fts_size = (k-1)*hashbytes + (k-1)*a*hashbytes + else: # FORS + fts_size = k*hashbytes + k*a*hashbytes + return hyper_tree_size + fts_size + randomness_size + + +def compute_mmax(h_prime, l, w, d_wots_expected_search, d, k, a): + """The PORS+FP authentication-set bound: from (k-1)*a - ceil(350/n) up, + the first for which signing costs less than 1.11 times signing with + FORS+C, and at most 19 steps up (docs/model.md, W+C, FORS+C and PORS+FP).""" + Thl = compute_Th(l) + Thk1 = compute_Th(k-1) + merkle_tree_fixed_part_time = (2**h_prime * (l*C_PRF + l*(w-1)*C_Th1 + Thl)) + (2**h_prime-1)*C_Th2 + hyper_tree_expected_time = d*merkle_tree_fixed_part_time + d_wots_expected_search*C_Th1c + fors_c_fixed_part_time = (k-1)*(2**a)*C_PRF + (k-1)*(2**a)*C_Th1 + (k-1)*(2**a-1)*C_Th2 + Thk1 + fors_c_expected_search_time = 2**a*(C_Hmsg + C_PRFmsg) + fors_c_expected_total_time = fors_c_fixed_part_time + fors_c_expected_search_time + spx_fc_expected_total_time = hyper_tree_expected_time + fors_c_expected_total_time + t, subtree_height, extra_leaves = compute_pors_tree_geometry(k, a) + pors_fixed_part_time = t*(C_PRF) + t*(C_Th1) + ((2**subtree_height-1) + extra_leaves)*(C_Th2) + mmax = (k-1)*a - ceil(350/hashbytes) + for i in range(20): + pors_search_attempts = ceil(exp_work_from_mmax(t, k, mmax)) + pors_expected_search_time = pors_search_attempts*(C_Hmsg + C_PRFmsg) + pors_expected_total_time = pors_fixed_part_time + pors_expected_search_time + spx_pors_expected_total_time = hyper_tree_expected_time + pors_expected_total_time + ratio = spx_pors_expected_total_time/spx_fc_expected_total_time + if (ratio < 1.11 or i == 19): + return mmax + mmax += 1 + return mmax + + +def compute_keygen_time(h, d, w, scheme): + """Compressions for KeyGen: the top tree, 2^h' WOTS public keys and + 2^h' - 1 nodes. Nothing is ground, since KeyGen signs nothing.""" + assert h % d == 0, "h must be divisible by d" + h_prime = h // d + l = compute_wots_l(scheme, w) + Thl = compute_Th(l) + + return 2**h_prime * (l*C_PRF + l*(w-1)*C_Th1 + Thl) + (2**h_prime - 1)*C_Th2 + + +def compute_signing_time(h, d, a, k, w, swn, scheme): + """{'compressions': mean compressions for SigGen, 'mmax': the PORS+FP + authentication-set bound, 0 for the other schemes}.""" + assert h % d == 0, "h must be divisible by d" + has_wc = (scheme != "SPX") + h_prime = h // d + l = compute_wots_l(scheme, w) + Thl = compute_Th(l) + Thk = compute_Th(k) + + if has_wc: + # One counter search per layer, w^l / ν attempts each + nu = compute_nu(l, swn, w) + d_wots_expected_search = d*ceil((w**l) / nu) + else: + d_wots_expected_search = 0 + + # One tree per layer: 2^h' WOTS public keys and 2^h' - 1 nodes + c_merkle = 2**h_prime * (l*C_PRF + l*(w-1)*C_Th1 + Thl) + (2**h_prime - 1)*C_Th2 + c_hyper_exp = d*c_merkle + d_wots_expected_search*C_Th1c + c_msg = C_Hmsg + C_PRFmsg + + mmax = 0 + if scheme == "W+C_F+C": + # k - 1 trees; about 2^a message hashes until the last tree's index is 0 + Thk1 = compute_Th(k-1) + c_fors_fixed = (k-1)*(2**a)*C_PRF + (k-1)*(2**a)*C_Th1 + (k-1)*(2**a - 1)*C_Th2 + Thk1 + fors_exp_search = 2**a + c_fors_search = fors_exp_search * c_msg + return { + 'compressions': c_hyper_exp + c_fors_fixed + c_fors_search, + 'mmax': mmax, + } + elif scheme == "W+C_P+FP": + # One tree of t leaves; message hashes until the Octopus authentication + # set has at most mmax nodes + t, subtree_height, extra_leaves = compute_pors_tree_geometry(k, a) + c_pors_fixed = t*C_PRF + t*C_Th1 + ((2**subtree_height - 1) + extra_leaves)*C_Th2 + mmax = compute_mmax(h_prime, l, w, d_wots_expected_search, d, k, a) + pors_search_attempts = ceil(exp_work_from_mmax(t, k, mmax)) + c_pors_search = pors_search_attempts * c_msg + return { + 'compressions': c_hyper_exp + c_pors_fixed + c_pors_search, + 'mmax': mmax, + } + else: # FORS + c_fors_fixed = k*(2**a)*C_PRF + k*(2**a)*C_Th1 + k*(2**a - 1)*C_Th2 + Thk + return { + 'compressions': c_fors_fixed + c_msg + c_hyper_exp, + 'mmax': mmax, + } + +def compute_verification_time(h, d, a, k, w, swn, scheme, mmax=0): + """{'compressions': mean compressions for SigVer, 'compressions_worst': + the most for any message}; mmax bounds the PORS+FP authentication set.""" + has_wc = (scheme != "SPX") + l = compute_wots_l(scheme, w) + Thl = compute_Th(l) + Thk = compute_Th(k) + + # WOTS over all d layers; WOTS+C costs the same for every message + if has_wc: + c_wots = d*(((w-1)*l - swn)*C_Th1 + C_Th1c + Thl) + c_wots_worst = c_wots + else: # WOTS-TW + l1, l2 = wots_lengths(w) + c_wots = compute_wots_tw_mean_steps(l1, l2, w, d)*C_Th1 + d*Thl + c_wots_worst = d*(compute_wots_tw_worst_steps(l1, l2, w)*C_Th1 + Thl) + + if scheme == "W+C_F+C": + Thk1 = compute_Th(k-1) + c_fts = (k-1)*C_Th1 + (k-1)*a*C_Th2 + Thk1 + elif scheme == "W+C_P+FP": + c_fts = k*C_Th1 + mmax*C_Th2 + else: # FORS + c_fts = k*C_Th1 + k*a*C_Th2 + Thk + + return { + 'compressions': C_Hmsg + c_fts + c_wots + h*C_Th2, + 'compressions_worst': C_Hmsg + c_fts + c_wots_worst + h*C_Th2, + } + +# Sweep + +def security_model(scheme): + """The model compute_security uses for scheme.""" + return "PORS+FP" if scheme == "W+C_P+FP" else "FORS" + + +_security_cache = {} + +def cached_security(q_s_log2, h, k, a, model): + """compute_security for 2^q_s_log2 signatures, once per (h, k, a): a sweep + asks again for every d and w.""" + key = (int(q_s_log2), int(h), int(k), int(a), model) + if key not in _security_cache: + _security_cache[key] = compute_security(2**q_s_log2, h, k, a, model, hashbytes) + return _security_cache[key] + + +def evaluate(q_s_log2, h, d, k, a, w, swn, scheme): + """Security (bits), size (bytes) and costs (compressions) of one parameter + set for 2^q_s_log2 signatures. None below the security target, and for + PORS+FP where the grinding cost table has no entry for mmax.""" + security = cached_security(q_s_log2, h, k, a, security_model(scheme)) + if security < PARAMETERS['security_bits']: + return None + try: + sign = compute_signing_time(h, d, a, k, w, swn, scheme) + except ValueError: + return None + mmax = sign['mmax'] + verify = compute_verification_time(h, d, a, k, w, swn, scheme, mmax) + return { + 'h': h, 'd': d, 'k': k, 'a': a, 'w': w, 'swn': swn, 'mmax': mmax, + 'l': compute_wots_l(scheme, w), + 'security': security, + 'size': compute_size(h, d, a, k, w, scheme, mmax), + 'keygen': compute_keygen_time(h, d, w, scheme), + 'sign': sign['compressions'], + 'verify': verify['compressions'], + 'verify_worst': verify['compressions_worst'], + } + + +def sweep(scheme, w_swn_pairs, h_values, d_values, k_values, a_values, q_s_log2): + """evaluate() for every parameter set of a grid, (w, swn) outermost and a + innermost, skipping the d that do not divide h. Yields + ((w, swn, h, d, k, a), result).""" + for w, swn in w_swn_pairs: + for h in h_values: + for d in d_values: + if h % d != 0: + continue + for k in k_values: + for a in a_values: + yield ((w, swn, h, d, k, a), + evaluate(q_s_log2, h, d, k, a, w, swn, scheme)) + +# The report's parameter sets: CSV and tables + +def compute_all_results(): + """A dict of sizes and costs for each of PARAMETER_SETS.""" + results = [] + for scheme, q_s, h, d, a, k, w, _, swn, bold in PARAMETER_SETS: + l = compute_wots_l(scheme, w) + + security = compute_security(2**q_s, h, k, a, security_model(scheme), hashbytes) + target = PARAMETERS['security_bits'] + assert security >= target, f"Security {security:.1f} < {target} for {scheme} q_s=2^{q_s} h={h} a={a} k={k}" + + sign = compute_signing_time(h, d, a, k, w, swn, scheme) + verify = compute_verification_time(h, d, a, k, w, swn, scheme, sign['mmax']) + keygen_compressions = compute_keygen_time(h, d, w, scheme) + size = compute_size(h, d, a, k, w, scheme, sign['mmax']) + compressions_per_byte = float(verify['compressions']) / float(size) + compressions_per_byte_worst = float(verify['compressions_worst']) / float(size) + + results.append({ + 'scheme': scheme, + 'q_s': q_s, + 'h': h, + 'd': d, + 'a': a, + 'k': k, + 'w': w, + 'l': l, + 'swn': swn, + 'size': size, + 'keygen_compressions': keygen_compressions, + 'sign_compressions': sign['compressions'], + 'verify_compressions': verify['compressions'], + 'verify_compressions_worst': verify['compressions_worst'], + 'compressions_per_byte': compressions_per_byte, + 'compressions_per_byte_worst': compressions_per_byte_worst, + 'bold': bold, + }) + return results + + +def generate_csv(): + print("scheme,q_s,h,d,a,k,w,l,paramsum,size,keygen_compressions,sign_compressions,verify_compressions,verify_compressions_worst,compressions_per_byte,compressions_per_byte_worst,bold") + + for r in compute_all_results(): + bold_str = "True" if r['bold'] else "False" + print(f"{r['scheme']},2^{r['q_s']},{r['h']},{r['d']},{r['a']},{r['k']},{r['w']},{r['l']},{r['swn']},{r['size']},{r['keygen_compressions']},{r['sign_compressions']},{r['verify_compressions']},{r['verify_compressions_worst']},{r['compressions_per_byte']:.2f},{r['compressions_per_byte_worst']:.2f},{bold_str}") + + +def format_num(n): + """n with a K or M suffix.""" + n = float(n) + if n >= 1_000_000: + return f"{n/1_000_000:.1f}M" + elif n >= 1_000: + return f"{n/1_000:.1f}K" + else: + return str(int(n)) + + +def generate_table(q_s_filter=None): + """Prints the tables, only for 2^q_s_filter signatures if given.""" + results = compute_all_results() + + if q_s_filter is not None: + results = [r for r in results if r['q_s'] == q_s_filter] + + if not results: + print(f"No parameter sets found for q_s=2^{q_s_filter}") + return + + # Group results by q_s for section headers + q_s_groups = {} + for r in results: + q_s_groups.setdefault(r['q_s'], []).append(r) + + # Column definitions: (header, key, width, format_func) + columns = [ + ("Scheme", 'scheme', 10, str), + ("h", 'h', 4, str), + ("d", 'd', 3, str), + ("a", 'a', 3, str), + ("k", 'k', 3, str), + ("w", 'w', 4, str), + ("l", 'l', 3, str), + ("S_wn", 'swn', 5, str), + ("Size", 'size', 6, str), + ("Keygen(C)", 'keygen_compressions', 10, format_num), + ("Sign(C)", 'sign_compressions', 9, format_num), + ("Verify(C)", 'verify_compressions', 10, format_num), + ("C/byte", 'compressions_per_byte', 6, lambda x: f"{x:.2f}"), + ] + + # Calculate total width + total_width = sum(w for _, _, w, _ in columns) + len(columns) - 1 + + for q_s in sorted(q_s_groups.keys(), reverse=True): + group = q_s_groups[q_s] + + # Section header + print() + print("=" * total_width) + print(f" 2^{q_s} signatures ".center(total_width, "=")) + print("=" * total_width) + + # Column headers + header_line = " ".join(h.center(w) for h, _, w, _ in columns) + print(header_line) + print("-" * total_width) + + # Data rows + for r in group: + row_parts = [] + for _, key, width, fmt in columns: + val = fmt(r[key]) + row_parts.append(val.rjust(width) if key != 'scheme' else val.ljust(width)) + print(" ".join(row_parts)) + + print() + print("Legend: Size=bytes, Keygen(C)/Sign(C)/Verify(C)=compression calls, C/byte=verify compressions per signature byte") + + +def compute_single(scheme, q_s_log2, h, d, a, k, w, swn): + """Prints the size, costs and security of one parameter set.""" + q_s = 2**q_s_log2 + l = compute_wots_l(scheme, w) + security = compute_security(q_s, h, k, a, security_model(scheme), hashbytes) + sign = compute_signing_time(h, d, a, k, w, swn, scheme) + mmax = sign['mmax'] + verify = compute_verification_time(h, d, a, k, w, swn, scheme, mmax) + keygen_compressions = compute_keygen_time(h, d, w, scheme) + size = compute_size(h, d, a, k, w, scheme, mmax) + c_per_byte = float(verify['compressions']) / float(size) + + print("Scheme: " + scheme) + print("q_s: 2^" + str(q_s_log2)) + print("(k,a,H,d): (" + str(int(k)) + ", " + str(int(a)) + ", " + str(int(h)) + ", " + str(int(d)) + ")") + print("w: " + str(int(w))) + print("S_wn: " + str(int(swn))) + print("l: " + str(int(l))) + print("mmax: " + str(int(mmax))) + print("Security: " + "{:.1f}".format(security) + " bits") + print("Size: " + str(int(size)) + " bytes") + print("Keygen(C): " + format_num(keygen_compressions)) + print("Sign(C): " + format_num(sign['compressions'])) + print("Verify(C): " + format_num(verify['compressions']) + " (worst: " + format_num(verify['compressions_worst']) + ")") + print("C/byte: " + "{:.2f}".format(c_per_byte) + " (worst: " + "{:.2f}".format(float(verify['compressions_worst'])/float(size)) + ")") + + +# Scripts that load this file for its functions set COSTS_SAGE_NO_MAIN: load() +# runs it in their namespace, where __name__ is '__main__'. +if __name__ == "__main__" and not os.environ.get("COSTS_SAGE_NO_MAIN"): + import sys + + if len(sys.argv) > 1 and sys.argv[1] == "--table": + # Optional: --table 40 to filter by q_s + q_s_filter = int(sys.argv[2]) if len(sys.argv) > 2 else None + generate_table(q_s_filter) + elif len(sys.argv) > 1 and sys.argv[1] == "--params": + # Usage: sage model/costs.sage --params scheme q_s k a h d w swn + if len(sys.argv) != 10: + print("Usage: sage model/costs.sage --params scheme q_s k a h d w swn") + print(" scheme: SPX, W+C, W+C_F+C, W+C_P+FP") + print(" q_s: log2 of max signatures (e.g., 10 for 2^10)") + print("Example: sage model/costs.sage --params W+C_P+FP 10 8 17 12 1 16 240") + sys.exit(1) + scheme = sys.argv[2] + q_s_log2 = int(sys.argv[3]) + k = int(sys.argv[4]) + a = int(sys.argv[5]) + h = int(sys.argv[6]) + d = int(sys.argv[7]) + w = int(sys.argv[8]) + swn = int(sys.argv[9]) + compute_single(scheme, q_s_log2, h, d, a, k, w, swn) + elif len(sys.argv) > 1 and sys.argv[1] == "--help": + print("Usage: sage model/costs.sage [OPTIONS]") + print() + print("Options:") + print(" (no args) Output CSV format") + print(" --table [N] Output pretty ASCII table [filtered to 2^N]") + print(" --params scheme q_s k a h d w swn Compute single parameter set") + print(" --help Show this help message") + print() + print("Example:") + print(" sage model/costs.sage --params W+C_P+FP 10 8 17 12 1 16 240") + else: + generate_csv() diff --git a/model/security.sage b/model/security.sage new file mode 100644 index 0000000..3fffc0a --- /dev/null +++ b/model/security.sage @@ -0,0 +1,79 @@ +#!/usr/bin/env sage +""" +Security in bits of FORS and PORS+FP after q_s signatures: the forgery +probability summed over how often the attacked instance was used, capped by +the 8n-bit preimage bound (docs/model.md, FORS security). costs.sage loads +this file; run alone, it prints the security of SLH-DSA-128s at 2^64 signatures. + + sage model/security.sage +""" +from sage.all import * +import sys + +F = RealField(100) # the terms reach 2^-1250, below what a double holds + +def powF(p, e): + return F(p)**e + +def qhitprob(q_s, r, leaves): + """P(a given instance among `leaves` is used by exactly r of q_s signatures).""" + p = F(1/leaves) + return binomial(q_s, r) * powF(p, r) * powF(1-p, q_s-r) + +def p_forge_fors(r, k, t): + """P(FORS forgery | r signatures on the instance) = (1 - (1 - 1/t)^r)^k.""" + return powF(1 - powF(1 - F(1)/F(t), r), k) + +def p_forge_pors_fp(r, k, t): + """P(PORS+FP forgery | r signatures on the instance) = C(min(r*k, t), k) / C(t, k).""" + return F(binomial(min(r*k, t), k)) / F(binomial(t, k)) + +def compute_security(q_s, h, k, a, scheme_type, hashbytes=16): + """Security in bits, at most 8*hashbytes, of 2^h instances of FORS (k trees + of t = 2^a leaves) or PORS+FP (one tree of t = k*2^a leaves) after q_s + signatures. scheme_type is "FORS" or "PORS+FP".""" + leaves = 2**h + + if scheme_type == "FORS": + t = 2**a + p_forge = p_forge_fors + else: # PORS+FP + t = k * 2**a + p_forge = p_forge_pors_fp + + sigma = F(0) + r = 1 + + while True: + contribution = qhitprob(q_s, r, leaves) * p_forge(r, k, t) + sigma += contribution + + r += 1 + + # Past the mean q_s/leaves, stop at the first term below 2^-1250. + if r > q_s/leaves and contribution < F(2)**(-1250): + break + + preimage_attack_prob = F(1) / F(2**(8*hashbytes)) + total_attack_prob = max(preimage_attack_prob, sigma) + + security_bits = -log(total_attack_prob, 2) + + return float(security_bits) + + +# Runs only when invoked directly, not when another script load()s this file. +if sys.argv[0].endswith('security.sage') or sys.argv[0].endswith('security.sage.py'): + # SLH-DSA-128s + q_s = 2**64 + h = 63 + a = 12 + k = 14 + scheme_type = "FORS" + + security = compute_security(q_s, h, k, a, scheme_type) + + print("=" * 40) + print(f"Parameters: q_s=2^64, h={h}, a={a}, k={k}, type={scheme_type}") + print(f"Security level: {security:.1f} bits") + print("=" * 40) diff --git a/model/stateful.sage b/model/stateful.sage new file mode 100644 index 0000000..a1bebad --- /dev/null +++ b/model/stateful.sage @@ -0,0 +1,546 @@ +""" +XMSS and XMSS-MT for 2^20 and 2^40 signatures and UXMSS, the SHRINCS stateful +tree, with WOTS-TW and WOTS+C: sizes in bytes, costs in SHA-256 compressions and +signer state. docs/model.md has the model, parameters.json the w set, S_wn and +the UXMSS bounds. scripts/export_site_data.sage loads it for site/data.json. + + sage model/stateful.sage [--xmss-only | --xmssmt-only | --uxmss-only] [--detail] + sage model/stateful.sage --csv (the CSV instead of the tables) + sage model/stateful.sage --output FILE.csv (the tables, and the CSV in FILE.csv) +""" +import csv +import math +import os +import sys + +_DOC = __doc__ # loading costs.sage replaces __doc__ +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # see model/costs.sage +os.environ['COSTS_SAGE_NO_MAIN'] = '1' # its functions, without its CLI +load(os.path.join(ROOT, "model", "costs.sage")) + + +def _parse_args(argv): + do_csv = xmss_only = xmssmt_only = uxmss_only = show_detail = False + output_file = None + i = 1 + while i < len(argv): + a = argv[i] + if a == "--csv": do_csv = True; i += 1 + elif a == "--xmss-only": xmss_only = True; i += 1 + elif a == "--xmssmt-only": xmssmt_only = True; i += 1 + elif a == "--uxmss-only": uxmss_only = True; i += 1 + elif a == "--detail": show_detail = True; i += 1 + elif a == "--output" and i + 1 < len(argv): + output_file = argv[i + 1]; i += 2 + elif a == "--help": + print(_DOC); sys.exit(0) + else: + print("Unknown argument: {}".format(a), file=sys.stderr); sys.exit(2) + return do_csv, xmss_only, xmssmt_only, uxmss_only, show_detail, output_file + + +N = hashbytes +R_SIZE = randomness_size +IND_SIZE = 1 # SHRINCS indicator byte: the leaf height on the stateful path + +H_VALS = [20, 40] # XMSS and XMSS-MT for 2^20 and 2^40 signatures +UXMSS_H_VALS = [40] # the UXMSS bound is the size of a 2^40 stateless signature + +OTS_TW = "tw" +OTS_WC = "wc" + +WOTS_W_VALS = PARAMETERS['stateful']['w'] +WOTS_C_PAIRS = [(w, SWN[w]) for w in WOTS_W_VALS] + +PARAM_SETS = ( + [(OTS_WC, w, swn) for w, swn in WOTS_C_PAIRS] + + [(OTS_TW, w, 0) for w in WOTS_W_VALS] +) + +HSF_MAX = PARAMETERS['stateful']['hsf_max'] + + +def ots_label(ots_type, w, swn): + if ots_type == OTS_WC: return "WOTS+C(w={:3d},S={:4d})".format(w, swn) + return "WOTS-TW(w={:3d})".format(w) + + +def counter_bytes(w): + """Bytes of the WOTS+C counter: 2 as in SHRINCS, 4 for w = 65536 + (docs/model.md, WOTS+C).""" + return 2 if w <= 256 else 4 + + +def idx_bytes(h): + """Bytes of the leaf index in XMSS and XMSS-MT of total height h.""" + return max(1, int(ceil(h / 8))) + + +# WOTS + +def wots_l(w, ots_type): + """Number of WOTS chains: l1 for WOTS+C, l1 + l2 for WOTS-TW.""" + l1, l2 = wots_lengths(w) + return int(l1) if ots_type == OTS_WC else int(l1 + l2) + + +def wots_pk_C(w, ots_type): + """Compressions to compute one WOTS public key.""" + l = wots_l(w, ots_type) + return l * C_PRF + l * (w - 1) * C_Th1 + compute_Th(l) + + +def wots_tw_verify_steps(w, count=1): + """Mean chain steps to verify count WOTS-TW signatures, rounded once.""" + l1, l2 = wots_lengths(w) + return int(compute_wots_tw_mean_steps(l1, l2, w, count)) + + +def wots_tw_sign_steps(w): + """Mean chain steps to produce one WOTS-TW signature: the part of each + chain the verifier does not walk.""" + return wots_l(w, OTS_TW) * (w - 1) - wots_tw_verify_steps(w) + + +def wots_sign_C(w, swn, ots_type): + """Mean compressions to produce one WOTS signature.""" + l = wots_l(w, ots_type) + if ots_type == OTS_WC: + nu = compute_nu(l, swn, w) + search = int((w**l + nu - 1) // nu) + return search * C_Th1c + l * C_PRF + swn * C_Th1 + return l * C_PRF + wots_tw_sign_steps(w) * C_Th1 + + +def wots_verify_C(w, swn, ots_type, worst_case=False, count=1): + """Compressions to verify count WOTS signatures: the chain steps, the + public-key compression and, for WOTS+C, the counter hash.""" + l = wots_l(w, ots_type) + Thl = compute_Th(l) + + if ots_type == OTS_WC: + return count * (((w - 1) * l - swn) * C_Th1 + C_Th1c + Thl) + + if worst_case: + l1, l2 = wots_lengths(w) + return count * (compute_wots_tw_worst_steps(l1, l2, w) * C_Th1 + Thl) + + return wots_tw_verify_steps(w, count) * C_Th1 + count * Thl + + +# XMSS and XMSS-MT: d layers of trees of height h' = h/d, XMSS is d = 1. +# BDS bounds and state: docs/model.md, XMSS and XMSS-MT. + +def bds_k(h_prime): + """Smallest BDS parameter K for a tree of height h_prime.""" + if h_prime < 2: + return h_prime + return 2 if h_prime % 2 == 0 else 3 + + +def bds_leaves(h_prime): + """Leaf computations per BDS update, at most.""" + return (h_prime - bds_k(h_prime)) // 2 + 1 + + +def bds_hashes(h_prime): + """Node hashes per BDS update, at most.""" + return (3 * max(0, h_prime - bds_k(h_prime) - 1)) // 2 + 1 + + +def bds_state_nodes(h_prime): + """Nodes a BDS signer stores, at most: authentication path, keep, retain, + and the treehash instances with their stacks.""" + k = bds_k(h_prime) + return (h_prime + h_prime // 2 + (2**k - k - 1) + + max(0, h_prime - k) + max(0, h_prime - k - 1)) + + +def tree_size_per_layer(h_prime, w, ots_type): + l = wots_l(w, ots_type) + ctr = counter_bytes(w) if ots_type == OTS_WC else 0 + return h_prime * N + l * N + ctr + + +def tree_keygen_C(h_prime, w, ots_type): + pk = wots_pk_C(w, ots_type) + sz = 2**h_prime + return sz * pk + (sz - 1) * C_Th2 + + +def tree_verify_C(h_prime, w, swn, ots_type, worst_case=False, count=1): + return (wots_verify_C(w, swn, ots_type, worst_case, count) + + count * h_prime * C_Th2) + + +def xmssmt_size_h(h, d, w, ots_type): + h_prime = h // d + return (IND_SIZE + R_SIZE + idx_bytes(h) + + d * tree_size_per_layer(h_prime, w, ots_type)) + + +def xmssmt_keygen_C_h(h, d, w, ots_type): + h_prime = h // d + return int(tree_keygen_C(h_prime, w, ots_type)) + + +def xmssmt_sign_bds_C_h(h, d, w, swn, ots_type): + """Cached signer: one BDS update per layer and signature, at most.""" + h_prime = h // d + pk = wots_pk_C(w, ots_type) + c_sg = wots_sign_C(w, swn, ots_type) + + return float(C_Hmsg + C_PRFmsg + c_sg + + d * (bds_leaves(h_prime) * pk + bds_hashes(h_prime) * C_Th2)) + + +def xmssmt_sign_cold_C_h(h, d, w, swn, ots_type): + """Cold signer: rebuilds the d trees for every signature. In the top tree + it needs only the authentication path, so it skips the signing leaf and + the h' nodes above it.""" + h_prime = h // d + return int(C_Hmsg + C_PRFmsg + + d * (int(tree_keygen_C(h_prime, w, ots_type)) + + wots_sign_C(w, swn, ots_type)) + - wots_pk_C(w, ots_type) - h_prime * C_Th2) + + +def xmssmt_state_bytes(h, d, w, ots_type): + """Cached-signer state: the BDS state of every layer and the WOTS + signatures of the upper layers.""" + h_prime = h // d + l = wots_l(w, ots_type) + return int(d * bds_state_nodes(h_prime) * N + (d - 1) * l * N) + + +def xmssmt_verify_C_h(h, d, w, swn, ots_type, worst_case=False): + h_prime = h // d + return float(C_Hmsg + tree_verify_C(h_prime, w, swn, ots_type, worst_case, d)) + + +def compute_tree_rows(scheme, h, d_values): + rows = [] + for ots_type, w, swn in PARAM_SETS: + for d in d_values: + rows.append(dict( + scheme = scheme, + h=h, q_s_log2=h, d=d, h_prime=h // d, + label = ots_label(ots_type, w, swn), + ots_type = ots_type, w=w, swn=swn, + l = wots_l(w, ots_type), + size = xmssmt_size_h(h, d, w, ots_type), + keygen_C = xmssmt_keygen_C_h(h, d, w, ots_type), + sign_C = xmssmt_sign_bds_C_h(h, d, w, swn, ots_type), + sign_cold_C = xmssmt_sign_cold_C_h(h, d, w, swn, ots_type), + state_bytes = xmssmt_state_bytes(h, d, w, ots_type), + verify_avg_C = xmssmt_verify_C_h(h, d, w, swn, ots_type, worst_case=False), + verify_worst_C = xmssmt_verify_C_h(h, d, w, swn, ots_type, worst_case=True), + )) + return rows + + +# UXMSS: a spine of height hsf with hsf + 1 leaves (docs/model.md, UXMSS) + +def _uxmss_idx_bytes(depth): + """Bytes of the leaf index, ceil(min(depth, 64) / 8): SHRINCS sizes the + field by the depth of the signing leaf, although an unbalanced-tree + index is only ever 0 or 1.""" + return max(1, (min(int(depth), 64) + 7) // 8) + + +def uxmss_size(q, hsf, w, ots_type): + """Size of the q-th UXMSS signature (q = 1 is the first).""" + l = wots_l(w, ots_type) + ctr = counter_bytes(w) if ots_type == OTS_WC else 0 + depth = min(q, hsf) + return IND_SIZE + R_SIZE + _uxmss_idx_bytes(depth) + ctr + l * N + depth * N + + +def find_max_hsf(w, ots_type, target_size): + """Largest hsf up to HSF_MAX whose last signature is below target_size.""" + for hsf in range(HSF_MAX, 0, -1): + if uxmss_size(hsf, hsf, w, ots_type) < target_size: + return hsf + return 0 + + +def uxmss_keygen_C(hsf, w, ots_type): + return float((hsf + 1) * wots_pk_C(w, ots_type) + hsf * C_Th2) + + +def uxmss_sign_C(q, hsf, w, swn, ots_type): + """Cached signer, q-th signature. It keeps the hsf + 1 leaf public keys; + the spine sibling of the signing leaf takes hsf - q node hashes.""" + return float(C_Hmsg + C_PRFmsg + wots_sign_C(w, swn, ots_type) + + (hsf - min(q, hsf)) * C_Th2) + + +def uxmss_sign_cold_C(hsf, w, swn, ots_type): + """Cold signer, first signature (the most expensive): the hsf other + leaves, then as the cached signer.""" + return float(uxmss_sign_C(1, hsf, w, swn, ots_type) + + hsf * wots_pk_C(w, ots_type)) + + +def uxmss_state_bytes(hsf): + """Cached-signer state: the hsf + 1 leaf public keys.""" + return int((hsf + 1) * N) + + +def uxmss_verify_C(q, hsf, w, swn, ots_type, worst_case=False): + return float(C_Hmsg + wots_verify_C(w, swn, ots_type, worst_case) + min(q, hsf) * C_Th2) + + +def compute_uxmss_rows(target_size, q_s_log2): + rows = [] + for ots_type, w, swn in PARAM_SETS: + hsf = find_max_hsf(w, ots_type, target_size) + nsig = hsf + 1 + rows.append(dict( + scheme = "UXMSS", + q_s_log2 = q_s_log2, + ref_size = target_size, + label = ots_label(ots_type, w, swn), + ots_type = ots_type, w=w, swn=swn, + l = wots_l(w, ots_type), + hsf = hsf, nsig=nsig, + sz_q1 = uxmss_size(1, hsf, w, ots_type), + sz_max = uxmss_size(hsf, hsf, w, ots_type), + keygen_C = uxmss_keygen_C(hsf, w, ots_type), + sign_q1_C = uxmss_sign_C(1, hsf, w, swn, ots_type), + sign_cold_C = uxmss_sign_cold_C(hsf, w, swn, ots_type), + state_bytes = uxmss_state_bytes(hsf), + verify_max_avg_C = uxmss_verify_C(hsf, hsf, w, swn, ots_type, worst_case=False), + verify_max_worst_C = uxmss_verify_C(hsf, hsf, w, swn, ots_type, worst_case=True), + )) + return rows + + +# CSV + +_CSV_HEADER = [ + "scheme", "q_s_log2", "h_total", "d", "h_prime", + "ots_type", "w", "swn", "l", + "size_bytes", + "keygen_C", "sign_bds_C", "sign_cold_C", "state_bytes", + "verify_avg_C", "verify_worst_C", + "ref_size", "hsf", "num_sigs", + "sz_q1", "sz_max", "sign_q1_C", "verify_max_avg_C", "verify_max_worst_C", +] + + +def _fmt_f(v): + """A float for the CSV, empty for NaN.""" + if isinstance(v, float) and math.isnan(v): + return "" + return "{:.6e}".format(float(v)) + + +def _tree_csv_row(r): + return [r['scheme'], r['q_s_log2'], r['h'], r['d'], r['h_prime'], + r['ots_type'], r['w'], r['swn'], r['l'], + r['size'], + _fmt_f(r['keygen_C']), _fmt_f(r['sign_C']), + _fmt_f(r['sign_cold_C']), r['state_bytes'], + _fmt_f(r['verify_avg_C']), _fmt_f(r['verify_worst_C']), + "", "", "", "", "", "", "", ""] + + +def _uxmss_csv_row(r): + return [r['scheme'], r['q_s_log2'], "", "", "", + r['ots_type'], r['w'], r['swn'], r['l'], + "", + _fmt_f(r['keygen_C']), "", _fmt_f(r['sign_cold_C']), r['state_bytes'], "", "", + r['ref_size'], r['hsf'], r['nsig'], + r['sz_q1'], r['sz_max'], + _fmt_f(r['sign_q1_C']), _fmt_f(r['verify_max_avg_C']), _fmt_f(r['verify_max_worst_C'])] + + +def write_csv(dest, xmss_by_h, xmssmt_by_h, uxmss_by_h): + """Writes the rows to dest (a file or sys.stdout) and returns their number.""" + writer = csv.writer(dest) + writer.writerow(_CSV_HEADER) + total = 0 + for by_h in (xmss_by_h, xmssmt_by_h): + for h in H_VALS: + for r in by_h.get(h, []): + writer.writerow(_tree_csv_row(r)); total += 1 + for h in UXMSS_H_VALS: + for r in uxmss_by_h.get(h, []): + writer.writerow(_uxmss_csv_row(r)); total += 1 + return total + + +def save_csv_file(path, xmss_by_h, xmssmt_by_h, uxmss_by_h): + with open(path, 'w', newline='') as f: + n = write_csv(f, xmss_by_h, xmssmt_by_h, uxmss_by_h) + print("Saved {} data rows to {!r}".format(n, path), file=sys.stderr) + + +# Tables + +def fmt_c(n): + """A compression count with a K, M, G or T suffix.""" + if isinstance(n, float) and math.isnan(n): + return "—" + n = float(n) + if n >= 1e15: return "{:.2e}".format(n) + if n >= 1e12: return "{:.2f}T".format(n / 1e12) + if n >= 1e9: return "{:.2f}G".format(n / 1e9) + if n >= 1e6: return "{:.2f}M".format(n / 1e6) + if n >= 1e3: return "{:.1f}K".format(n / 1e3) + return "{:d}".format(int(n)) + + +def _row(vals, cols): + return " ".join("{:{a}{w}}".format(str(v), a=a, w=w) + for v, (_, w, a) in zip(vals, cols)) + + +def _hdr(cols): + return " ".join("{:{a}{w}}".format(h, a=a, w=w) for h, w, a in cols) + + +def _sep_line(cols, char="-"): + return char * (sum(c[1] for c in cols) + len(cols) - 1) + + +OTS_LINE = " OTS: WOTS-TW / WOTS+C | w ∈ {{{}}} ".format(",".join(str(w) for w in WOTS_W_VALS)) + + +def print_xmss_table(rows_by_h): + cols = [("q_s", 4, ">"), ("OTS Variant", 26, "<"), ("l", 3, ">"), + ("Size(B)", 7, ">"), ("Keygen(C)", 11, ">"), + ("Sign-BDS(C)", 11, ">"), ("Vfy-avg(C)", 10, ">"), ("Vfy-wrst(C)", 11, ">")] + W = sum(c[1] for c in cols) + len(cols) - 1 + print() + print("="*W) + print(" XMSS — Single-Layer Balanced Tree ".center(W, "=")) + print(OTS_LINE.center(W, "=")) + print(" Auth path = h nodes (fixed per sig); Sign-BDS = bound per sig ".center(W, "=")) + print("="*W) + print(_hdr(cols)); print(_sep_line(cols)) + for h in H_VALS: + for r in rows_by_h.get(h, []): + infeas = r['keygen_C'] > 1e11 + kg = fmt_c(r['keygen_C']) + ("*" if infeas else " ") + print(_row(["2^{}".format(h), r['label'], r['l'], r['size'], + kg, fmt_c(r['sign_C']), + fmt_c(r['verify_avg_C']), fmt_c(r['verify_worst_C'])], cols)) + print() + print(_sep_line(cols)) + print("* Keygen for h=40 requires 2^40 ≈ 1.1·10^12 WOTS keypairs — infeasible.") + print(" h=20 keygen ≈ 2^20 ≈ 1M WOTS keys — feasible (~seconds on modern CPU).") + print() + + +def print_xmssmt_table(rows_by_h): + cols = [("q_s", 4, ">"), ("OTS Variant", 26, "<"), ("d", 2, ">"), + ("h'", 3, ">"), ("l", 3, ">"), ("Size(B)", 7, ">"), + ("Keygen(C)", 10, ">"), ("Sign-BDS(C)", 11, ">"), + ("Sign-cold(C)", 12, ">"), ("Vfy-avg(C)", 10, ">"), ("Vfy-wrst(C)", 11, ">")] + W = sum(c[1] for c in cols) + len(cols) - 1 + print() + print("="*W) + print(" XMSS-MT — Multi-Tree Hypertree (no FORS needed; stateful) ".center(W, "=")) + print(OTS_LINE.center(W, "=")) + print(" Keygen builds top tree only (2^h' keys); Sign-BDS = bound per sig ".center(W, "=")) + print("="*W) + print(_hdr(cols)); print(_sep_line(cols)) + cur_label = None + for h in H_VALS: + for r in rows_by_h.get(h, []): + tag = "2^{}".format(h) + new_label = (h, r['label']) + if new_label != cur_label: + if cur_label is not None: + print() + cur_label = new_label + print(_row([tag, r['label'], r['d'], r['h_prime'], r['l'], r['size'], + fmt_c(r['keygen_C']), fmt_c(r['sign_C']), fmt_c(r['sign_cold_C']), + fmt_c(r['verify_avg_C']), fmt_c(r['verify_worst_C'])], cols)) + print() + print(_sep_line(cols)) + print("Sign-cold: rebuild d trees per sig (worst-case). BDS: at most d·((h'-K)/2 + 1) PK evals per sig.") + print() + + +def print_uxmss_table(rows_by_h, ref_size): + cols = [("OTS Variant", 26, "<"), ("l", 3, ">"), + ("Ref(B)", 6, ">"), ("hsf", 5, ">"), ("Sigs", 7, ">"), + ("Sz(q=1)", 8, ">"), ("Sz(max)", 8, ">"), + ("Keygen(C)", 10, ">"), ("Sign(q=1)", 9, ">"), + ("VfyAvg(mx)", 10, ">"), ("VfyWrst(mx)", 11, ">")] + W = sum(c[1] for c in cols) + len(cols) - 1 + print() + print("="*W) + print(" SHRINCS/UXMSS — Left-leaning Stateful Tree ".center(W, "=")) + print(OTS_LINE.center(W, "=")) + print(" max stateful sig must be strictly < Ref ".center(W, "=")) + print("="*W) + print(_hdr(cols)); print(_sep_line(cols)) + for h in UXMSS_H_VALS: + for r in rows_by_h.get(h, []): + print(_row([r['label'], r['l'], ref_size, + r['hsf'], r['nsig'], r['sz_q1'], r['sz_max'], + fmt_c(r['keygen_C']), fmt_c(r['sign_q1_C']), + fmt_c(r['verify_max_avg_C']), fmt_c(r['verify_max_worst_C'])], cols)) + print() + print(_sep_line(cols)) + print("Ref: the size of the report's candidate 2, (h, d, k, a, w) = (45, 5, 8, 16, 16).") + print("hsf = max auth-path nodes; Sigs = hsf+1; Sz(max) must be < Ref.") + print() + + +def print_uxmss_detail(uxmss_by_h): + for h in UXMSS_H_VALS: + print("\n --- UXMSS Per-Signature Progression (Bounded by Ref Size for 2^{} target) ---".format(h)) + for r in uxmss_by_h.get(h, []): + hsf = r['hsf'] + w, swn, ots_type = r['w'], r['swn'], r['ots_type'] + if hsf <= 0: continue + print("\n {} (l={}, hsf={}, {} sigs)".format( + r['label'], r['l'], hsf, hsf + 1)) + print(" {:>6} {:>8} {:>12} {:>12} {:>12}".format( + "q", "Size(B)", "Sign(C)", "Vfy-avg(C)", "Vfy-wrst(C)")) + print(" " + "-" * 56) + qpts = sorted(set(filter(lambda q: 1 <= q <= hsf + 1, + [1, 2, 5, 10, hsf // 4, hsf // 2, 3 * hsf // 4, + max(1, hsf - 1), hsf, hsf + 1]))) + for q in qpts: + sz = uxmss_size(q, hsf, w, ots_type) + sc = uxmss_sign_C(q, hsf, w, swn, ots_type) + vc_avg = uxmss_verify_C(q, hsf, w, swn, ots_type, worst_case=False) + vc_wrst = uxmss_verify_C(q, hsf, w, swn, ots_type, worst_case=True) + flag = " ← max" if q == hsf else (" ← last" if q == hsf + 1 else "") + print(" {:>6} {:>8} {:>12} {:>12} {:>12}{}".format( + q, sz, fmt_c(sc), fmt_c(vc_avg), fmt_c(vc_wrst), flag)) + print() + + +# export_site_data.sage sets STATEFUL_SAGE_NO_MAIN: load() runs this file with +# __name__ == '__main__'. +if __name__ == "__main__" and not os.environ.get("STATEFUL_SAGE_NO_MAIN"): + do_csv, xmss_only, xmssmt_only, uxmss_only, show_detail, output_file = _parse_args(sys.argv) + ref_size = PARAMETERS['stateful']['uxmss_ref_size'] + + xmss_by_h = {h: compute_tree_rows("XMSS", h, [1]) for h in H_VALS} + xmssmt_by_h = {h: compute_tree_rows("XMSS-MT", h, [d for d in range(2, h + 1) if h % d == 0]) + for h in H_VALS} + uxmss_by_h = {h: compute_uxmss_rows(ref_size, h) for h in UXMSS_H_VALS} + + if do_csv: + write_csv(sys.stdout, xmss_by_h, xmssmt_by_h, uxmss_by_h) + + if output_file: + save_csv_file(output_file, xmss_by_h, xmssmt_by_h, uxmss_by_h) + + if not do_csv: + if not xmssmt_only and not uxmss_only: + print_xmss_table(xmss_by_h) + if not xmss_only and not uxmss_only: + print_xmssmt_table(xmssmt_by_h) + if not xmss_only and not xmssmt_only: + print_uxmss_table(uxmss_by_h, ref_size) + if show_detail and not xmss_only and not xmssmt_only: + print_uxmss_detail(uxmss_by_h) diff --git a/parameters.json b/parameters.json new file mode 100644 index 0000000..54cee9f --- /dev/null +++ b/parameters.json @@ -0,0 +1,22 @@ +{ + "_about": [ + "Search grids and fixed parameters, read by the Sage scripts, tests/model.py and tests/synth_generated.py.", + "scripts/export_site_data.sage copies the stateless grid and the UXMSS bounds into site/data.json; docs/model.md explains the values." + ], + "baseline": {"h": 63, "d": 7, "k": 14, "a": 12, "w": 16, "q_s_log2": 64}, + "security_bits": 128, + "swn": {"16": 240, "32": 403, "64": 693, "128": 1206, "256": 2040, "65536": 262140}, + "stateless": { + "grid": {"h": [40, 50], "d": [2, 25], "k": [6, 24], "a": [8, 20], "w": [2, 4, 16, 256], + "q_s_log2": 40, "max_size": 7856}, + "w_optional": [32, 64, 128], + "wc_w": [16, 256] + }, + "stateful": { + "h": [10, 40], + "d": [1, 10], + "w": [16, 32, 64, 128, 256, 65536], + "uxmss_ref_size": 5712, + "hsf_max": 255 + } +} diff --git a/original-sphincs-parameter-search.sage b/reference/original-sphincs-parameter-search.sage similarity index 100% rename from original-sphincs-parameter-search.sage rename to reference/original-sphincs-parameter-search.sage diff --git a/run_experiments.py b/run_experiments.py deleted file mode 100644 index 6d23ac3..0000000 --- a/run_experiments.py +++ /dev/null @@ -1,292 +0,0 @@ -#!/usr/bin/env python3 -""" -Executes specialized SLH-DSA parameter sweeps using basic WOTS-TW + FORS architecture. -UNIVERSAL CONSTRAINT: Signature sizes in ALL categories strictly cannot exceed the Standard Baseline (7856B). - -Selective execution flags: - --cat1 Run Category 1: Absolute Extremes - --cat2 Run Category 2: Time-Constrained Signatures - --cat3 Run Category 3: Speed & CPB Optimized Tracks - --only-new-coeffs Generate data strictly for new sub-standard coefficients - (If no category flags are provided, all requested categories are executed by default.) -""" - -import os -import subprocess -import shutil -import argparse -import pandas as pd -import matplotlib.pyplot as plt -import seaborn as sns - -STD_SIZE = 7856 # FIPS 205 SLH-DSA-128s signature size -# NOTE: the C/byte cap is passed to the Sage script as a RATIO -# (--max-cpb-ratio), which the script resolves against the standard -# baseline it computes itself — no hardcoded std C/byte constant here. - -# ============================================================================= -# Global Configuration Toggles -# ============================================================================= -# Set to True to execute sweeps strictly for newly added sub-standard coefficients [0.3, 0.5, 0.7, 0.9] -ONLY_NEW_COEFFS = False - -BASE_DIR = os.path.join("utils", "outputs_specialized") -CAT1_DIR = os.path.join(BASE_DIR, "cat1_extremes") -CAT2_DIR = os.path.join(BASE_DIR, "cat2_time_constrained") -CAT3_DIR = os.path.join(BASE_DIR, "cat3_speed_optimized") - -for d in [CAT1_DIR, CAT2_DIR, CAT3_DIR]: - os.makedirs(d, exist_ok=True) - -def run_basic_sweep(max_size=STD_SIZE, kg_r="inf", sg_r="inf", sv_r="inf", cpb_r="inf"): - """Runs the Sage script strictly for basic wots-tw and fors architecture under given filter caps.""" - cmd = [ - "sage", "slhdsa-2to40.sage", - "--ots", "wots-tw", - "--fts", "fors", - "--max-size", str(max_size), - "--max-keygen-ratio", str(kg_r), - "--max-sign-ratio", str(sg_r), - "--max-verify-ratio", str(sv_r), - "--max-cpb-ratio", str(cpb_r) - ] - - res = subprocess.run(cmd, stdout=subprocess.DEVNULL) - if res.returncode != 0 or not os.path.exists("candidates.csv"): - return pd.DataFrame() - - df = pd.read_csv("candidates.csv") - os.remove("candidates.csv") - return df.drop_duplicates(subset=['label', 'h', 'd', 'k', 'a', 'w']).reset_index(drop=True) - -def make_label(row): - lbl = str(row['label']) - if lbl == 'STANDARD': - return f"STANDARD Baseline\n(h={row['h']},d={row['d']})" - return f"{lbl}\n(h={row['h']},d={row['d']},k={row['k']},a={row['a']})" - -def generate_five_metric_plots(df_plot, folder_path, filter_title): - """ - Takes a focused DataFrame (Standard + Top Candidates) and generates - exactly 5 comparison graphs saved into the specified folder. - """ - if 'disp_name' not in df_plot.columns: - df_plot['disp_name'] = df_plot.apply(make_label, axis=1) - - metrics = [ - {"file": "1_size.png", "col": "size", "title": "Signature Size", "ylabel": "Bytes", "log": False}, - {"file": "2_keygen.png", "col": "keygen_C", "title": "Key Generation Cost", "ylabel": "SHA-256 Compressions", "log": True}, - {"file": "3_signing.png", "col": "sign_C", "title": "Signing Cost", "ylabel": "SHA-256 Compressions", "log": True}, - {"file": "4_verification.png", "col": "verify_C", "title": "Verification Cost", "ylabel": "SHA-256 Compressions", "log": False}, - {"file": "5_compression_per_byte.png", "col": "c_per_byte", "title": "Compressions per Byte Ratio", "ylabel": "Ratio (C / Byte)", "log": False} - ] - - sns.set_theme(style="whitegrid") - colors = ['#d9534f' if 'STANDARD' in name else '#0275d8' for name in df_plot['disp_name']] - - for m in metrics: - fig, ax = plt.subplots(figsize=(11, 6), constrained_layout=True) - - ax.bar(df_plot['disp_name'], df_plot[m["col"]], color=colors, width=0.5) - - ax.set_title(f"{filter_title}\n{m['title']} Comparison", fontsize=13, pad=12) - ax.set_ylabel(m["ylabel"], fontsize=11) - ax.set_xlabel("Candidate Parameter Sets", fontsize=11) - - ax.set_xticks(range(len(df_plot))) - ax.set_xticklabels(df_plot['disp_name'], rotation=45, ha="right") - - if m["log"]: - ax.set_yscale("log") - - out_path = os.path.join(folder_path, m["file"]) - plt.savefig(out_path, dpi=300, bbox_inches='tight') - plt.close() - -# ============================================================================= -# CATEGORY 1: Absolute Extremes -# ============================================================================= -def run_category_1(): - print("\n" + "="*70) - print("Category 1: Absolute Extremes (Capped at Standard Size <= 7856B)") - print("="*70) - - print("-> Sweeping parameter space strictly within standard signature footprint...") - df_pool = run_basic_sweep(max_size=STD_SIZE) - if df_pool.empty or len(df_pool) <= 1: - print(" [!] Candidate pool empty or contains only standard baseline.") - return - - df_pool.to_csv(os.path.join(CAT1_DIR, "all_size_capped_candidates.csv"), index=False) - - std_row = df_pool[df_pool['label'] == 'STANDARD'].iloc[0] - custom_df = df_pool[df_pool['label'] != 'STANDARD'] - - extremes = [ - {"folder": "1_smallest_signature", "col": "size", "desc": "Smallest Signature"}, - {"folder": "2_fastest_keygen", "col": "keygen_C", "desc": "Fastest Keygen"}, - {"folder": "3_fastest_signing", "col": "sign_C", "desc": "Fastest Signing"}, - {"folder": "4_fastest_verification", "col": "verify_C", "desc": "Fastest Verification"}, - {"folder": "5_best_compression_ratio", "col": "c_per_byte", "desc": "Best C/Byte Ratio"} - ] - - master_summary = [std_row] - - for ex in extremes: - ex_dir = os.path.join(CAT1_DIR, ex["folder"]) - os.makedirs(ex_dir, exist_ok=True) - - winner = custom_df.sort_values(by=ex["col"], ascending=True).iloc[0].copy() - winner['cat1_award'] = ex["desc"] - master_summary.append(winner) - - df_focused = pd.DataFrame([std_row, winner]).reset_index(drop=True) - df_focused.to_csv(os.path.join(ex_dir, "winner_vs_standard.csv"), index=False) - - generate_five_metric_plots(df_focused, ex_dir, f"Cat 1: {ex['desc']} Winner vs Standard") - print(f" [v] Populated extreme folder: '{ex_dir}/'") - - pd.DataFrame(master_summary).to_csv(os.path.join(CAT1_DIR, "master_extremes_summary.csv"), index=False) - -# ============================================================================= -# CATEGORY 2: Smallest Signatures Under Strictly Capped Times -# ============================================================================= -def run_category_2(factors): - print("\n" + "="*70) - print("Category 2: Smallest Signatures Capped by Execution Time") - print("="*70) - - for x in factors: - folder_path = os.path.join(CAT2_DIR, f"time_cap_{x}x") - os.makedirs(folder_path, exist_ok=True) - - print(f"-> Sweeping for Smallest Size (Size <= Std) with All Times <= {x}x Standard...") - df_pool = run_basic_sweep(max_size=STD_SIZE, kg_r=x, sg_r=x, sv_r=x, cpb_r=x) - - if len(df_pool) <= 1: - print(f" [!] No custom candidates pass operational caps at {x}x std.") - continue - - df_pool.to_csv(os.path.join(folder_path, "all_passing_candidates.csv"), index=False) - - std_row = df_pool[df_pool['label'] == 'STANDARD'].iloc[0] - custom_df = df_pool[df_pool['label'] != 'STANDARD'] - - top10_custom = custom_df.sort_values(by="size", ascending=True).head(10) - df_plot = pd.concat([pd.DataFrame([std_row]), top10_custom]).reset_index(drop=True) - - generate_five_metric_plots(df_plot, folder_path, f"Cat 2: Smallest Signatures (Times <= {x}x Std)") - print(f" [v] Populated folder: '{folder_path}/'") - -# ============================================================================= -# CATEGORY 3: 4 Tracks Optimized One-by-One (Remaining Overheads Scaled) -# ============================================================================= -def run_category_3(factors): - print("\n" + "="*70) - print("Category 3: 4 Tracks Optimized One-by-One Under Scaled Overheads") - print("="*70) - - tracks = [ - { - "sub_dir": "fastest_keygen", - "target_col": "keygen_C", - "title": "Fastest Keygen", - "kg": "inf", "sg": lambda x: x, "sv": lambda x: x, "cpb": lambda x: x - }, - { - "sub_dir": "fastest_signing", - "target_col": "sign_C", - "title": "Fastest Signing", - "kg": lambda x: x, "sg": "inf", "sv": lambda x: x, "cpb": lambda x: x - }, - { - "sub_dir": "fastest_verification", - "target_col": "verify_C", - "title": "Fastest Verification", - "kg": lambda x: x, "sg": lambda x: x, "sv": "inf", "cpb": lambda x: x - }, - { - "sub_dir": "best_compression_ratio", - "target_col": "c_per_byte", - "title": "Best Compression per Byte", - "kg": lambda x: x, "sg": lambda x: x, "sv": lambda x: x, "cpb": "inf" - } - ] - - for t in tracks: - track_base_dir = os.path.join(CAT3_DIR, t["sub_dir"]) - os.makedirs(track_base_dir, exist_ok=True) - - print(f"\n--- Executing Track: {t['title']} ---") - - for x in factors: - folder_path = os.path.join(track_base_dir, f"other_caps_{x}x") - os.makedirs(folder_path, exist_ok=True) - - kg_ratio = t["kg"] if isinstance(t["kg"], str) else t["kg"](x) - sg_ratio = t["sg"] if isinstance(t["sg"], str) else t["sg"](x) - sv_ratio = t["sv"] if isinstance(t["sv"], str) else t["sv"](x) - cpb_ratio = t["cpb"] if isinstance(t["cpb"], str) else t["cpb"](x) - - print(f"-> Optimizing {t['title']} | Size <= Std, Other Overheads <= {x}x...") - df_pool = run_basic_sweep( - max_size=STD_SIZE, - kg_r=kg_ratio, - sg_r=sg_ratio, - sv_r=sv_ratio, - cpb_r=cpb_ratio - ) - - if len(df_pool) <= 1: - print(f" [!] No custom candidates found passing overhead limits at {x}x std.") - continue - - df_pool.to_csv(os.path.join(folder_path, "all_passing_candidates.csv"), index=False) - - std_row = df_pool[df_pool['label'] == 'STANDARD'].iloc[0] - custom_df = df_pool[df_pool['label'] != 'STANDARD'] - - top10_custom = custom_df.sort_values(by=t["target_col"], ascending=True).head(10) - df_plot = pd.concat([pd.DataFrame([std_row]), top10_custom]).reset_index(drop=True) - - generate_five_metric_plots(df_plot, folder_path, f"Cat 3: {t['title']} Optimized (Caps <= {x}x)") - print(f" [v] Populated folder: '{folder_path}/'") - -def main(): - parser = argparse.ArgumentParser(description="Specialized SLH-DSA parameter sweep orchestrator.") - parser.add_argument("--cat1", action="store_true", help="Execute strictly Category 1: Absolute Extremes") - parser.add_argument("--cat2", action="store_true", help="Execute strictly Category 2: Time-Constrained Signatures") - parser.add_argument("--cat3", action="store_true", help="Execute strictly Category 3: Individual Operational Tracks") - parser.add_argument("--only-new-coeffs", action="store_true", help="Generate data strictly for new sub-standard coefficients [0.3, 0.5, 0.7, 0.9]") - args = parser.parse_args() - - run_all = not (args.cat1 or args.cat2 or args.cat3) - - sweep_only_new = ONLY_NEW_COEFFS or args.only_new_coeffs - if sweep_only_new: - factors_list = [0.8 , 0.85] - print("\n[*] Execution Mode: Restricted to Newly Integrated Coefficients") - else: - factors_list = [0.3, 0.5, 0.7, 0.8 , 0.85, 0.9, 1.0, 1.5, 2.0, 5.0, 10.0] - - if not shutil.which("sage"): - print("Critical Error: 'sage' engine is inaccessible. Initialize Sage environment first.") - return - - print("\n" + "="*70) - print("Initializing Specialized SLH-DSA Execution Suite") - print("="*70) - - if run_all or args.cat1: - run_category_1() - if run_all or args.cat2: - run_category_2(factors_list) - if run_all or args.cat3: - run_category_3(factors_list) - - print("\n" + "="*70) - print(f"Requested execution scope successfully finished! Workspace stored in: '{BASE_DIR}/'") - print("="*70) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/scripts/export_site_data.sage b/scripts/export_site_data.sage new file mode 100644 index 0000000..220e43b --- /dev/null +++ b/scripts/export_site_data.sage @@ -0,0 +1,202 @@ +#!/usr/bin/env sage +""" +Writes site/data.json for the explorer and the stateful page: the SPX pool from +analysis/all_size_capped_candidates.csv (make sweeps) with the optional w added, +the W+C, W+C_F+C and W+C_P+FP pools from the sweep of costs.sage, and the +XMSS-MT and UXMSS rows of stateful.sage. The pages check samples of it against +their own model. + + sage scripts/export_site_data.sage (all schemes, a few minutes) + sage scripts/export_site_data.sage --schemes SPX,W+C (a subset; the pages adapt) +""" +import os +import sys +import csv as _csvmod +import json +import subprocess +import datetime + +_DOC = __doc__ # loading stateful.sage replaces __doc__ +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # see model/costs.sage +os.environ['STATEFUL_SAGE_NO_MAIN'] = '1' # its functions, without its CLI +load(os.path.join(ROOT, "model", "stateful.sage")) # and through it costs.sage and security.sage + +ALL_SCHEMES = ['SPX', 'W+C', 'W+C_F+C', 'W+C_P+FP'] +schemes = ALL_SCHEMES +_args = sys.argv[1:] +i = 0 +while i < len(_args): + if _args[i] == '--schemes' and i + 1 < len(_args): + schemes = [s for s in _args[i + 1].split(',') if s] + i += 2 + elif _args[i] == '--help': + print(_DOC) + sys.exit(0) + else: + print("Unknown argument: {}".format(_args[i]), file=sys.stderr) + sys.exit(2) +for s in schemes: + assert s in ALL_SCHEMES, "unknown scheme: %s" % s + +GRID = PARAMETERS['stateless']['grid'] +W_OPTIONAL = PARAMETERS['stateless']['w_optional'] # SPX only +WC_PAIRS = [(w, SWN[w]) for w in PARAMETERS['stateless']['wc_w']] +POOL_FIELDS = ['h', 'd', 'k', 'a', 'w', 'swn', 'mmax', + 'size', 'kg', 'sg', 'sv', 'sv_worst'] + + +H_VALUES = range(GRID['h'][0], GRID['h'][1] + 1) +D_VALUES = range(GRID['d'][0], GRID['d'][1] + 1) +K_VALUES = range(GRID['k'][0], GRID['k'][1] + 1) +A_VALUES = range(GRID['a'][0], GRID['a'][1] + 1) + + +# SPX pool: the rows of the sweep CSV +csv_path = os.path.join(ROOT, 'analysis', 'all_size_capped_candidates.csv') +spx_rows, baseline = [], None +with open(csv_path) as f: + for r in _csvmod.DictReader(f): + rec = [int(r['h']), int(r['d']), int(r['k']), int(r['a']), int(r['w']), + 0, 0, + int(r['size']), int(float(r['keygen_C'])), int(float(r['sign_C'])), + int(float(r['verify_C'])), int(float(r['verify_worst_C']))] + if r['label'] == 'STANDARD': + baseline = {'params': {x: PARAMETERS['baseline'][x] for x in ('h', 'd', 'k', 'a', 'w')}, + 'size': rec[7], 'kg': rec[8], 'sg': rec[9], + 'sv': rec[10], 'sv_worst': rec[11]} + else: + spx_rows.append(rec) +assert baseline is not None, "STANDARD row missing from %s" % csv_path + +# The optional w of SPX and the variant pools: the sweep of costs.sage +def pool_rows(scheme, w_swn_pairs): + """Pool rows (POOL_FIELDS) of the grid below max_size, and the number of + parameter sets considered.""" + rows, considered, at = [], 0, None + for (w, swn, h, d, k, a), r in sweep(scheme, w_swn_pairs, H_VALUES, D_VALUES, + K_VALUES, A_VALUES, GRID['q_s_log2']): + considered += 1 + if (w, h, d) != at: + at = (w, h, d) + print(" {}: w={}, h={}, d={}".format(scheme, w, h, d), file=sys.stderr, end='\r') + if r is None or r['size'] >= GRID['max_size']: + continue + rows.append([int(r[f]) for f in ('h', 'd', 'k', 'a', 'w', 'swn', 'mmax', 'size', + 'keygen', 'sign', 'verify', 'verify_worst')]) + print(file=sys.stderr) + return rows, considered + + +pools = {} +if 'SPX' in schemes: + # 'considered' counts the core grid only; the page derives the count for + # whichever w set is active from meta.grid. + hd_pairs = sum(1 for h in H_VALUES for d in D_VALUES if h % d == 0) + spx_considered = hd_pairs * len(GRID['w']) * len(K_VALUES) * len(A_VALUES) + spx_extra, _ = pool_rows('SPX', [(w, 0) for w in W_OPTIONAL]) + all_spx = spx_rows + spx_extra + pools['SPX'] = {'fields': POOL_FIELDS, 'considered': spx_considered, + 'count': len(all_spx), 'rows': all_spx} + print("SPX: {} rows ({} core from CSV + {} optional-w)".format( + len(all_spx), len(spx_rows), len(spx_extra)), file=sys.stderr) +for scheme in schemes: + if scheme == 'SPX': + continue + rows, considered = pool_rows(scheme, WC_PAIRS) + pools[scheme] = {'fields': POOL_FIELDS, 'considered': considered, + 'count': len(rows), 'rows': rows} + print("{}: {} rows of {} considered".format(scheme, len(rows), considered), + file=sys.stderr) + +# Stateful: XMSS-MT (XMSS is d = 1) and UXMSS, from stateful.sage +STATEFUL = PARAMETERS['stateful'] + +xmssmt_rows = [] +for ots_label, ots_type in (('TW', OTS_TW), ('WC', OTS_WC)): + for w in STATEFUL['w']: + swn = SWN[w] if ots_type == OTS_WC else 0 + for h in range(STATEFUL['h'][0], STATEFUL['h'][1] + 1): + for d in range(STATEFUL['d'][0], STATEFUL['d'][1] + 1): + if h % d != 0: + continue + xmssmt_rows.append([ + ots_label, h, d, w, + int(xmssmt_size_h(h, d, w, ots_type)), + int(xmssmt_keygen_C_h(h, d, w, ots_type)), + int(xmssmt_sign_bds_C_h(h, d, w, swn, ots_type)), + int(xmssmt_sign_cold_C_h(h, d, w, swn, ots_type)), + int(xmssmt_state_bytes(h, d, w, ots_type)), + int(xmssmt_verify_C_h(h, d, w, swn, ots_type, worst_case=False)), + int(xmssmt_verify_C_h(h, d, w, swn, ots_type, worst_case=True)), + ]) + +uxmss_rows = [] +for ots_label, ots_type in (('TW', OTS_TW), ('WC', OTS_WC)): + for w in STATEFUL['w']: + swn = SWN[w] if ots_type == OTS_WC else 0 + hsf = find_max_hsf(w, ots_type, STATEFUL['uxmss_ref_size']) + uxmss_rows.append([ + ots_label, w, int(hsf), + int(uxmss_size(1, hsf, w, ots_type)), + int(uxmss_size(hsf, hsf, w, ots_type)), + int(uxmss_keygen_C(hsf, w, ots_type)), + int(uxmss_sign_C(1, hsf, w, swn, ots_type)), + int(uxmss_sign_cold_C(hsf, w, swn, ots_type)), + int(uxmss_state_bytes(hsf)), + int(uxmss_verify_C(hsf, hsf, w, swn, ots_type, worst_case=False)), + int(uxmss_verify_C(hsf, hsf, w, swn, ots_type, worst_case=True)), + ]) + + +def _commit(): + try: + return subprocess.check_output( + ['git', 'rev-parse', '--short', 'HEAD'], + cwd=ROOT, stderr=subprocess.DEVNULL).decode().strip() + except Exception: + sha = os.environ.get('GITHUB_SHA', '') + return sha[:7] if sha else 'unknown' + + +data = { + 'meta': { + 'version': 3, + 'generator': 'export_site_data.sage', + 'generated': datetime.date.today().isoformat(), + 'commit': _commit(), + 'grid': GRID, + 'w_optional': W_OPTIONAL, + 'wc_pairs': WC_PAIRS, + 'schemes': [s for s in ALL_SCHEMES if s in pools], + 'uxmss_ref_size': STATEFUL['uxmss_ref_size'], + 'hsf_max': int(HSF_MAX), + 'cost_convention': 'cached', + }, + 'baseline': baseline, + 'pools': pools, + 'stateful': { + 'slh': {'size': baseline['size'], 'kg': baseline['kg'], + 'sg': baseline['sg'], 'sv': baseline['sv'], + 'sv_worst': baseline['sv_worst'], + 'qs_log2': PARAMETERS['baseline']['q_s_log2']}, + 'xmssmt': { + 'fields': ['ots', 'h', 'd', 'w', 'size', 'kg', 'sg', 'sg_cold', + 'state', 'sv', 'sv_worst'], + 'rows': xmssmt_rows, + }, + 'uxmss': { + 'fields': ['ots', 'w', 'hsf', 'sz_q1', 'sz_max', 'kg', 'sg', + 'sg_cold', 'state', 'sv_max', 'sv_max_worst'], + 'rows': uxmss_rows, + }, + }, +} + +out_path = os.path.join(ROOT, 'site', 'data.json') +with open(out_path, 'w') as f: + # default=int: the sage preparser turns integer literals into sage + # Integers, which the json module cannot serialize directly. + json.dump(data, f, separators=(',', ':'), default=int) +print("Wrote {} — schemes: {} — commit {}".format( + out_path, ', '.join('%s:%d' % (s, pools[s]['count']) for s in pools), + data['meta']['commit'])) diff --git a/slhdsa-2to40.sage b/scripts/slhdsa-2to40.sage similarity index 68% rename from slhdsa-2to40.sage rename to scripts/slhdsa-2to40.sage index 51ad48f..a2e7c50 100644 --- a/slhdsa-2to40.sage +++ b/scripts/slhdsa-2to40.sage @@ -1,47 +1,42 @@ #!/usr/bin/env sage """ -Standard SLH-DSA-128s VS custom variants sweeping (h, d, k, a) for various OTS/FTS combinations. - -Variant is selected via --ots and --fts flags: - --ots {wots-tw, wots+c} (default: wots-tw) - --fts {fors, fors+c, pors+fp} (default: fors) - -Supported (ots, fts) combinations: - (wots-tw, fors) -> SPX (plain SLH-DSA / SPHINCS+) - (wots+c, fors) -> W+C - (wots+c, fors+c) -> W+C_F+C - (wots+c, pors+fp) -> W+C_P+FP - -Sweep behavior per OTS: - - wots-tw : w in {2, 4, 16, 256}, swn = 0 - - wots+c : (w, swn) in {(16, 240), (256, 2040)} - -Filters (defaults; override via CLI): - --max-size BYTES Reject if size >= BYTES (default: 7856, std SLH-DSA-128s size) - --max-keygen-ratio X Reject if keygen > X * std keygen (default: no limit) - --max-sign-ratio X Reject if sign > X * std sign (default: no limit) - --max-verify-ratio X Reject if verify > X * std verify (default: no limit) - --max-c-per-byte X Reject if verify_C / size > X (default: no limit) - --max-cpb-ratio X Reject if verify_C / size > X * std C/byte (default: no limit) - -Sweep ranges (comma-separated lists): - --h N1,N2,... Hypertree heights to sweep (default: 40..50 inclusive) - --d N1,N2,... Layer counts to sweep (default: 2..25 inclusive) - Combinations where h % d != 0 are auto-skipped. - -Usage: - sage slhdsa-2to40.sage # default (SPX, h=40) - sage slhdsa-2to40.sage --ots wots+c --fts pors+fp # W+C_P+FP variant - sage slhdsa-2to40.sage --ots wots+c --fts fors+c # W+C_F+C variant - sage slhdsa-2to40.sage --max-size 4500 --max-keygen-ratio 10 --max-sign-ratio 10 - sage slhdsa-2to40.sage --h 40,42,45,48 --d 2,3,4,5,6,8 +Sweeps the stateless grid of parameters.json for one scheme, keeps the parameter +sets that reach the security target and pass the filters, prints them against +SLH-DSA-128s, and writes them to candidates.csv, SLH-DSA-128s first as STANDARD. +make sweeps runs it twice for analysis/all_size_capped_candidates.csv and +analysis/all_unbound_candidates.csv. + + sage scripts/slhdsa-2to40.sage [--ots wots-tw|wots+c] [--fts fors|fors+c|pors+fp] [filters] + sage scripts/slhdsa-2to40.sage --help """ +import csv import os import sys -# ----------------------------------------------------------------------------- -# CLI parsing (parse before loading costs.sage so we can suppress its CSV) -# ----------------------------------------------------------------------------- +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # see model/costs.sage +os.environ['COSTS_SAGE_NO_MAIN'] = '1' # its functions, without its CLI +load(os.path.join(ROOT, "model", "costs.sage")) + +GRID = PARAMETERS['stateless']['grid'] + +USAGE = """usage: sage scripts/slhdsa-2to40.sage [options] + + --ots wots-tw|wots+c the one-time signature (default wots-tw); wots+c + sweeps the w of wc_w with their S_wn + --fts fors|fors+c|pors+fp the few-time signature (default fors); wots-tw + goes with fors only + --h N1,N2,... hypertree heights (default: the grid) + --d N1,N2,... layers (default: the grid); d must divide h + --max-size BYTES keep sizes below BYTES (default: the grid's max_size) + --max-keygen-ratio X keep KeyGen up to X times that of SLH-DSA-128s + --max-sign-ratio X the same for SigGen + --max-verify-ratio X the same for SigVer + --max-c-per-byte X keep SigVer compressions per byte up to X + --max-cpb-ratio X keep them up to X times those of SLH-DSA-128s + +The filters other than the size have no limit by default. +""" + def _parse_int_list(s): return [int(x) for x in s.split(",") if x.strip()] @@ -49,14 +44,14 @@ def _parse_int_list(s): def _parse_args(argv): ots = "wots-tw" fts = "fors" - max_size = 7856 # std SLH-DSA-128s signature size + max_size = GRID['max_size'] kg_ratio = float('inf') sg_ratio = float('inf') sv_ratio = float('inf') cpb_max = float('inf') cpb_ratio = float('inf') - h_values = list(range(40, 51)) # 40..50 inclusive - d_values = list(range(2, 26)) # 2..25 inclusive + h_values = list(range(GRID['h'][0], GRID['h'][1] + 1)) + d_values = list(range(GRID['d'][0], GRID['d'][1] + 1)) i = 1 while i < len(argv): a = argv[i] @@ -91,7 +86,7 @@ def _parse_args(argv): d_values = _parse_int_list(argv[i + 1]) i += 2 elif a == "--help": - print(__doc__) + print(USAGE) sys.exit(0) else: print("Unknown argument: {}".format(a), file=sys.stderr) @@ -115,95 +110,57 @@ if (OTS, FTS) not in SCHEME_MAP: sys.exit(2) SCHEME = SCHEME_MAP[(OTS, FTS)] -# (w, swn) sweep pairs depend on OTS if OTS == "wots-tw": - W_SWN_PAIRS = [(2, 0), (4, 0), (16, 0), (256, 0)] + W_SWN_PAIRS = [(w, 0) for w in GRID['w']] else: # wots+c - W_SWN_PAIRS = [(16, 240), (256, 2040)] - -# Security model: PORS+FP for that FTS, FORS otherwise -SECURITY_MODEL = "PORS+FP" if FTS == "pors+fp" else "FORS" + W_SWN_PAIRS = [(w, SWN[w]) for w in PARAMETERS['stateless']['wc_w']] -# ----------------------------------------------------------------------------- -# Load costs.sage (suppress its auto-CSV) -# ----------------------------------------------------------------------------- -_saved_argv = sys.argv -sys.argv = ['big_comparison.sage', '--no-auto'] -load(os.path.join(os.path.dirname(os.path.abspath(__file__)), "costs.sage")) -sys.argv = _saved_argv - -# ----------------------------------------------------------------------------- -# Sweep parameters -# ----------------------------------------------------------------------------- - -TARGET = 128 - -# Standard baseline is plain SLH-DSA-128s (SPX) for fair comparison +# Every scheme is compared with SLH-DSA-128s STD_SCHEME = "SPX" STD_SWN = 0 -K_RANGE = range(6, 25) -A_RANGE = range(8, 21) -# H_VALUES and D_VALUES come from CLI flags (parsed at top of file) - - -def evaluate(q_s_log2, h, d, k, a, w, swn, scheme, security_model): - if h % d != 0: - return None - q_s = 2**q_s_log2 - try: - security = compute_security(q_s, h, k, a, security_model, hashbytes=16) - except Exception: - return None - if float(security) < TARGET: - return None - - try: - sign = compute_signing_time(h, d, a, k, w, swn, scheme) - except Exception: - return None - mmax = sign['mmax'] - verify = compute_verification_time(h, d, a, k, w, swn, scheme, mmax) - keygen_C = compute_keygen_time(h, d, w, scheme) - size = compute_size(h, d, a, k, w, scheme, mmax) - l = compute_wots_l(scheme, w) +K_RANGE = range(GRID['k'][0], GRID['k'][1] + 1) +A_RANGE = range(GRID['a'][0], GRID['a'][1] + 1) + +def csv_row(q_s_log2, r): + """The CSV fields of an evaluate() result.""" return { 'q_s_log2': int(q_s_log2), - 'h': int(h), - 'd': int(d), - 'h_prime': int(h // d), - 'k': int(k), - 'a': int(a), - 'w': int(w), - 'l': int(l), - 'swn': int(swn), - 'mmax': int(mmax), - 'security': float(security), - 'size': int(size), - 'keygen_C': float(keygen_C), - 'sign_C': float(sign['compressions']), - 'verify_C': float(verify['compressions']), - 'verify_worst_C': float(verify['compressions_worst']), - 'c_per_byte': float(verify['compressions']) / float(size), - 'c_per_byte_worst': float(verify['compressions_worst']) / float(size), + 'h': int(r['h']), + 'd': int(r['d']), + 'h_prime': int(r['h'] // r['d']), + 'k': int(r['k']), + 'a': int(r['a']), + 'w': int(r['w']), + 'l': int(r['l']), + 'swn': int(r['swn']), + 'mmax': int(r['mmax']), + 'security': float(r['security']), + 'size': int(r['size']), + 'keygen_C': float(r['keygen']), + 'sign_C': float(r['sign']), + 'verify_C': float(r['verify']), + 'verify_worst_C': float(r['verify_worst']), + 'c_per_byte': float(r['verify']) / float(r['size']), + 'c_per_byte_worst': float(r['verify_worst']) / float(r['size']), 'label': '', } print("Variant: ots={}, fts={} -> scheme={}".format(OTS, FTS, SCHEME), file=sys.stderr) print("Computing standard SLH-DSA-128s baseline (SPX)...", file=sys.stderr) -std = evaluate(64, 63, 7, 14, 12, 16, STD_SWN, STD_SCHEME, "FORS") +B = PARAMETERS['baseline'] +std = evaluate(B['q_s_log2'], B['h'], B['d'], B['k'], B['a'], B['w'], STD_SWN, STD_SCHEME) if std is None: print("ERROR: failed to compute standard baseline.", file=sys.stderr) sys.exit(1) +std = csv_row(B['q_s_log2'], std) std['label'] = 'STANDARD' KEYGEN_LIMIT = std['keygen_C'] * MAX_KEYGEN_RATIO SIGN_LIMIT = std['sign_C'] * MAX_SIGN_RATIO VERIFY_LIMIT = std['verify_C'] * MAX_VERIFY_RATIO -# Effective C/byte cap: min of the absolute cap and the ratio cap (relative to -# the std baseline computed above — avoids hardcoding a stale constant). CPB_LIMIT = min(MAX_C_PER_BYTE, std['c_per_byte'] * MAX_CPB_RATIO) print("Standard baseline (compressions):", file=sys.stderr) @@ -249,18 +206,9 @@ def label_for(w, swn): return "w={},swn={}".format(w, swn) -for w, swn in W_SWN_PAIRS: - label = label_for(w, swn) - for h in H_VALUES: - for d in D_VALUES: - if h % d != 0: - continue - for k in K_RANGE: - for a in A_RANGE: - consider( - evaluate(40, h, d, k, a, w, swn, SCHEME, SECURITY_MODEL), - label, - ) +for (w, swn, h, d, k, a), r in sweep(SCHEME, W_SWN_PAIRS, H_VALUES, D_VALUES, + K_RANGE, A_RANGE, GRID['q_s_log2']): + consider(None if r is None else csv_row(GRID['q_s_log2'], r), label_for(w, swn)) all_results.sort(key=lambda x: x['size']) @@ -285,7 +233,7 @@ def fmt_ratio(r, std_val): return "{:.2f}x".format(float(r) / float(std_val)) -# Use a wider Label column when swn is included; show S_wn column only for W+C +# Only WOTS+C labels and rows carry S_wn label_w = 12 if OTS == "wots+c" else 10 columns = [ @@ -362,7 +310,6 @@ print(" Total: {} configurations passing all filters ({})".format( len(all_results), " + ".join(parts))) print() -# Filter rejection summary print("=" * total_width) print(" FILTER REJECTION SUMMARY ".center(total_width)) print("=" * total_width) @@ -373,7 +320,7 @@ def _rej_label(name, ratio): return " Rejected ({} > {:g}x std): ".format(name, ratio) print(" Considered configurations: {}".format(considered)) -print(" Rejected (security < 128): {}".format(rejected['security'])) +print(" Rejected (security < {}): {}".format(PARAMETERS['security_bits'], rejected['security'])) print(" Rejected (size >= {} B): {}".format(MAX_SIZE, rejected['size'])) print("{}{}".format(_rej_label("keygen", MAX_KEYGEN_RATIO), rejected['keygen'])) print("{}{}".format(_rej_label("sign", MAX_SIGN_RATIO), rejected['sign'])) @@ -385,7 +332,6 @@ else: print(" Passed all filters (custom): {}".format(len(all_results) - 1)) print() -# Summary print("=" * total_width) print(" SUMMARY ".center(total_width)) print("=" * total_width) @@ -431,7 +377,7 @@ for w, swn in W_SWN_PAIRS: if OTS == "wots-tw" and w == 16: note = " (FIPS 205-compatible algorithm structure)" elif OTS == "wots-tw": - note = " (modified SPHINCS+, NOT FIPS 205)" + note = " (modified SPHINCS+, not FIPS 205)" else: note = "" subset = [r for r in all_results if r['label'] == label] @@ -444,16 +390,13 @@ print(" - All cost metrics in SHA-256 compression-function calls (C).") print(" - All ratios (KG/std, SG/std, SV/std) are relative to standard SLH-DSA-128s.") print(" - Sec(bits) is capped at 128.0 by the n=128 preimage bound.") if OTS == "wots+c": - print(" - swn is the WOTS+C target chain sum S_{w,n}; values mirror PARAMETER_SETS in costs.sage.") + print(" - swn is the WOTS+C target chain sum S_{w,n}, from parameters.json.") print(" - Sweep ranges: h in {}, d in {}, k in [{}, {}], a in [{}, {}], (w,swn) in {}.".format( H_VALUES, D_VALUES, K_RANGE.start, K_RANGE.stop - 1, A_RANGE.start, A_RANGE.stop - 1, W_SWN_PAIRS)) -import csv - csv_filename = "candidates.csv" print("\nSaving candidate configurations to {}...".format(csv_filename)) -# Define the exact dictionary keys returned by evaluate() fieldnames = [ 'label', 'h', 'd', 'h_prime', 'k', 'a', 'w', 'l', 'swn', 'mmax', 'security', 'size', 'keygen_C', diff --git a/security.sage b/security.sage deleted file mode 100644 index f6cbd47..0000000 --- a/security.sage +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env sage -from sage.all import * - -""" -SPHINCS+ FORS / PORS+FP Security Analysis - -This script computes the classical security level for FORS and PORS+FP schemes. - -Methodology follows the original SPHINCS+ submission: -https://sphincs.org/data/sphincs+-specification.pdf (Appendix A) - -Differences from the original SPHINCS+ parameter script: - 1. The original uses upper bound (r/t)^k for P(FORS forgery | r signatures hit - instance). This script uses the exact formula (1 - (1 - 1/t)^r)^k. - 2. To compute total security, the original sums attack probabilities: - -log2(2^-n + P(forgery)) - This script takes the max: - -log2(max(2^-n, P(forgery))) - Rationale: A query targeting forgery cannot simultaneously serve as a - preimage query for tree nodes or WOTS chains, because the tweaks are - different. These are independent attack strategies. - -Attack Model: -------------- -The adversary can win by making a hash query that either: - 1. Finds a preimage (probability per query ≈ 2^-n), or - 2. Results in a FORS/PORS+FP forgery - -FORS/PORS+FP Forgery Attack: ----------------------------- -SPHINCS+ uses 2^h instances (one per hypertree leaf). After q_s signatures, -some instances will have been used multiple times (birthday paradox). The -adversary forges by finding an instance that was used r times, then creating -a message that maps to only revealed leaves in that instance. - -Total FORS/PORS+FP forgery probability: - P(FORS/PORS+FP forgery) = Σ_r P(r signatures hit instance and FORS/PORS+FP forgery) - = Σ_r P(r signatures hit instance) × P(FORS/PORS+FP forgery | r signatures hit instance) - -where: - - P(r signatures hit instance) follows a binomial distribution with q_s trials - and success probability 2^-h. Therefore: - P(r signatures hit instance) = C(q_s, r) × (2^-h)^r × (1-2^-h)^(q_s-r) - where C(q_s, r) is the binomial coefficient - - - P(FORS/PORS+FP forgery | r signatures hit instance) is scheme-specific (see below) - -FORS Forgery: -------------- - P(FORS forgery | r signatures hit instance) = (1 - (1 - 1/t)^r)^k, where t = 2^a - -Each of the k FORS trees has t leaves. After r signatures, each tree has r -leaves revealed (one per signature). For forgery, all k trees must have their -required leaf already revealed. - P(required leaf NOT revealed in single tree) = (1 - 1/t)^r - (each of r signatures has probability (1 - 1/t) of missing the required leaf) - P(required leaf revealed in single tree) = 1 - (1 - 1/t)^r - P(all k trees have required leaf revealed) = (1 - (1 - 1/t)^r)^k - -PORS+FP Forgery: ----------------- - P(PORS+FP forgery | r signatures hit instance) = C(min(r*k, t), k) / C(t, k), where t = k * 2^a - -PORS+FP has a single tree with t = k * 2^a leaves. Each signature reveals k -distinct leaves. After r signatures, at most min(r*k, t) leaves are revealed. -For forgery, the adversary must find a message whose k indices all fall within -the revealed set. The number of such "good" messages is C(min(r*k, t), k), out -of C(t, k) total possible index selections. - -Total Security: ---------------- --log2(max(2^-n, P(FORS/PORS+FP forgery))) -""" - -# High precision arithmetic -F = RealField(100) - -def powF(p, e): - """Power function with high precision""" - return F(p)**e - -def qhitprob(q_s, r, leaves): - """ - Probability that exactly r out of q_s signatures hit the same few-time signature instance. - Follows binomial distribution with q_s trials and success probability p = 1/leaves - """ - p = F(1/leaves) - return binomial(q_s, r) * powF(p, r) * powF(1-p, q_s-r) - -def p_forge_fors(r, k, t): - """ - P(FORS forgery | r signatures hit instance) = (1 - (1 - 1/t)^r)^k - """ - return powF(1 - powF(1 - F(1)/F(t), r), k) - -def p_forge_pors_fp(r, k, t): - """ - P(PORS+FP forgery | r signatures hit instance) = C(min(r*k, t), k) / C(t, k) - """ - return F(binomial(min(r*k, t), k)) / F(binomial(t, k)) - -def compute_security(q_s, h, k, a, scheme_type, hashbytes=16): - """ - Compute security level for given FORS or PORS+FP parameters. - - Args: - q_s: Number of signatures - h: Hypertree height (number of instances = 2^h) - k: Number of trees (FORS) or leaf indices per signature (PORS+FP) - a: Tree height parameter - scheme_type: "FORS" or "PORS+FP" - hashbytes: Hash output size in bytes (default 16 = 128 bits) - - Returns: - total_security_bits (float) - """ - leaves = 2**h - - if scheme_type == "FORS": - t = 2**a - p_forge = p_forge_fors - else: # PORS+FP - t = k * 2**a - p_forge = p_forge_pors_fp - - # Compute sigma - # = Σ_r P(r signatures hit instance) × P(forgery | r signatures hit instance) - sigma = F(0) - r = 1 - - while True: - # Probability of exactly r collisions to any specific instance - p_r_collisions = qhitprob(q_s, r, leaves) - - # Contribution to total attack probability - contribution = p_r_collisions * p_forge(r, k, t) - sigma += contribution - - r += 1 - - # Stop when contribution is negligible (well below target security level) - # Also require r > q_s/leaves to ensure we're past the peak contribution - if r > q_s/leaves and contribution < F(2)**(-1250): - break - - # Compute security bits accounting for hash preimage attack - preimage_attack_prob = F(1) / F(2**(8*hashbytes)) - total_attack_prob = max(preimage_attack_prob, sigma) - - security_bits = -log(total_attack_prob, 2) - - return float(security_bits) - - -# Run demo when invoked directly (not when loaded by another script via load()) -import sys -if sys.argv[0].endswith('security.sage') or sys.argv[0].endswith('security.sage.py'): - # SPHINCS+-128f parameters - q_s = 2**64 - h = 63 - a = 12 - k = 14 - scheme_type = "FORS" - - security = compute_security(q_s, h, k, a, scheme_type) - - print("=" * 40) - print(f"Parameters: q_s=2^64, h={h}, a={a}, k={k}, type={scheme_type}") - print(f"Security level: {security:.1f} bits") - print("=" * 40) diff --git a/site/index.html b/site/index.html index 6ac4701..c0a2cbc 100644 --- a/site/index.html +++ b/site/index.html @@ -8,155 +8,9 @@ - - +

SHRINCS pinned parameters

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

Stateless `; + } 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..8d0c307 100644 --- a/site/js/ui.js +++ b/site/js/ui.js @@ -1,4 +1,5 @@ -// Number formats, popovers and the glossary. +// Helpers shared by the three site pages: number formats, popovers, sliders, +// the address bar, data.json and the glossary. 'use strict'; // Number formats @@ -40,6 +41,7 @@ function infoButton(id, label) { } (function () { + if (typeof document === 'undefined') return; // under node, in the tests if (!('popover' in HTMLElement.prototype)) return; // older browsers show the content inline let opener = null; document.addEventListener('click', e => { @@ -70,9 +72,156 @@ function infoButton(id, label) { addEventListener('scroll', placeOpen, true); })(); +// Sliders + +function clamp(v, lo, hi) { + return Math.max(lo, Math.min(hi, v)); +} + +// A log-scale slider holds a track position 0..SLIDER_STEPS that maps +// geometrically onto min..max. +const SLIDER_STEPS = 1000; + +function posToX(pos, min, max) { + return min * Math.pow(max / min, pos / SLIDER_STEPS); +} + +function xToPos(x, min, max) { + const t = Math.log(x / min) / Math.log(max / min); + return Math.round(SLIDER_STEPS * clamp(t, 0, 1)); +} + +// Short enough for the number inputs: two decimals below 100, integers below +// a million, exponential above. +function fmtX(v) { + if (v < 100) return v.toFixed(2); + if (v < 1e6) return String(Math.round(v)); + return v.toExponential(1); +} + +// Slider caps: the ratio rounded up to 1, 2 or 5 times a power of ten, at least 20. +function niceCap(m) { + if (!isFinite(m) || m <= 20) return 20; + const e = Math.pow(10, Math.floor(Math.log10(m))); + for (const f of [1, 2, 5, 10]) if (f * e >= m) return f * e; + return 10 * e; +} + +// Keeps store[key] in step with each range input in host and the number input +// with the same data-key. A range with data-log is a log-scale slider over the +// number input's min..max; a number input with data-dp shows that many decimals. +function bindSliderPairs(host, store, onChange) { + for (const slider of host.querySelectorAll('input[type="range"][data-key]')) { + const key = slider.dataset.key; + const num = host.querySelector(`input[type="number"][data-key="${key}"]`); + const show = v => { + num.value = num.dataset.dp ? v.toFixed(parseInt(num.dataset.dp, 10)) : fmtX(v); + }; + slider.addEventListener('input', () => { + const v = slider.dataset.log + ? posToX(parseFloat(slider.value), parseFloat(num.min), parseFloat(num.max)) + : parseFloat(slider.value); + store[key] = v; + show(v); + onChange(); + }); + num.addEventListener('input', () => { + const v = parseFloat(num.value); + if (!isFinite(v)) return; + const min = parseFloat(num.min), max = parseFloat(num.max); + store[key] = clamp(v, min, max); + slider.value = slider.dataset.log ? xToPos(store[key], min, max) : store[key]; + onChange(); + }); + num.addEventListener('blur', () => show(store[key])); + } +} + +// A slider drag fires an input event per step: run fn at most once per frame. +function oncePerFrame(fn) { + let queued = false; + return () => { + if (queued) return; + queued = true; + requestAnimationFrame(() => { queued = false; fn(); }); + }; +} + +// Address bar + +// The #key=value&... parameters. +function readHash() { + const params = {}; + for (const part of location.hash.replace(/^#/, '').split('&')) { + const eq = part.indexOf('='); + if (eq > 0) params[part.slice(0, eq)] = decodeURIComponent(part.slice(eq + 1)); + } + return params; +} + +function replaceHash(parts) { + const url = parts.length ? '#' + parts.join('&') : location.pathname + location.search; + // Safari throws once a page exceeds its history-update quota. + try { history.replaceState(null, '', url); } catch (err) { /* keep the page working */ } +} + +// Browsers rate-limit history.replaceState (Chromium drops calls beyond about +// 200 per 10 s), so the address bar is written once, shortly after the last +// change, rather than on every slider step. +const HASH_WRITE_DELAY_MS = 250; + +function hashWriter(write) { + let timer = null; + return () => { + clearTimeout(timer); + timer = setTimeout(write, HASH_WRITE_DELAY_MS); + }; +} + +function bindShareButton(writeHash) { + document.getElementById('share-btn').addEventListener('click', async e => { + writeHash(); + const btn = e.target; + try { + await navigator.clipboard.writeText(location.href); + btn.textContent = 'Link copied ✓'; + setTimeout(() => { btn.textContent = 'Copy shareable link'; }, 1500); + } catch (err) { + prompt('Copy this link:', location.href); + } + }); +} + +// Data + +async function loadSiteData() { + try { + const resp = await fetch('data.json', { cache: 'no-cache' }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + return await resp.json(); + } catch (e) { + console.warn('data.json unavailable (' + e.message + ')'); + return null; + } +} + +// Column of each field in a data.json table. +function fieldIndex(table) { + return Object.fromEntries(table.fields.map((f, i) => [f, i])); +} + +function downloadCSV(name, lines) { + const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/csv' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `${name}_${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(a.href); +} + // Glossary -document.body.insertAdjacentHTML('beforeend', ` +if (typeof document !== 'undefined') document.body.insertAdjacentHTML('beforeend', `

Glossary

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

SHRINCS Stateful Comparison

-

Comparison

+

Comparison

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

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

All stateful candidates

-

Winternitz parameter

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

Winternitz parameter