diff --git a/README.md b/README.md index 247e553..c33e455 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,6 @@ Scripts for exploring SPHINCS+ parameter tradeoffs, computing security levels, s - [utils/](utils/) helper scripts for searching and visualising the parameter space - [tests/](tests/) regression suite, run `python3 tests/test_regression.py` - [document/](document/) LaTeX source for the accompanying technical report -- [site/](site/) interactive pages hosted on GitHub Pages: the SHRINCS pinned-parameters main page ([index.html](site/index.html)), stateless parameter search over all report schemes (SPX / W+C / FORS+C / PORS+FP) with worst-case mode, shareable URL state and CSV export ([stateless.html](site/stateless.html)), and stateful scheme comparison ([stateful.html](site/stateful.html)); the stateful cost model shared by index and the stateful page lives in [formulas.js](site/formulas.js). The report PDF is not tracked in git: CI compiles [document/](document/) and deploys it as `site/report.pdf` (also attached to releases on tags) +- [site/](site/) interactive pages hosted on GitHub Pages: the SHRINCS pinned-parameters main page ([index.html](site/index.html)), stateless parameter search over all report schemes (SPX / W+C / FORS+C / PORS+FP) with worst-case mode, shareable URL state and CSV export ([stateless.html](site/stateless.html)), and stateful scheme comparison ([stateful.html](site/stateful.html)); the cost and security model shared by the three pages lives in [js/model.js](site/js/model.js). The report PDF is not tracked in git: CI compiles [document/](document/) and deploys it as `site/report.pdf` (also attached to releases on tags) Contributing: run `make check-local` before pushing. \ No newline at end of file diff --git a/docs/model.md b/docs/model.md new file mode 100644 index 0000000..28aab9f --- /dev/null +++ b/docs/model.md @@ -0,0 +1,187 @@ +# Cost and security model + +Sizes are in bytes and costs in SHA-256 compressions (C). The hash output is n = 16 bytes. +The pages compute the model in `site/js/model.js`, the Sage scripts in `costs.sage`, +`stateful.sage` and `security.sage`, and `tests/model.py` ports it to check both. + +## 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). + +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. + +## 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 SPHINCS+ parameter script, this uses the exact forgery factor +instead of the bound (r/t)^k, and the maximum of the two attacks instead of their sum, since a +query cannot serve both. `security.sage` also covers PORS+FP. + +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 up to 255 for which the last signature stays below +5,712 B, the size of the report's candidate 2, (45, 5, 8, 16, 16). + + 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/export_site_data.sage b/export_site_data.sage index b3a5b18..461140a 100644 --- a/export_site_data.sage +++ b/export_site_data.sage @@ -14,9 +14,10 @@ Single source of truth: - 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. +The pages load data.json at startup (without it the explorer has no pool and +the stateful page computes its candidates in the browser) and self-check the +loaded numbers against their local formulas, logging any divergence to the +console. Usage: sage export_site_data.sage # all schemes (a few minutes) @@ -58,7 +59,7 @@ 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). +# defaults). 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 diff --git a/site/index.html b/site/index.html index 6ac4701..c0a2cbc 100644 --- a/site/index.html +++ b/site/index.html @@ -8,155 +8,9 @@ - - +

SHRINCS pinned parameters

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

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

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

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

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

+

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

+ ${winner.swn ? `

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

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

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

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

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

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

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

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

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

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

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

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

Glossary

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

SHRINCS Stateful Comparison

-

Comparison

+

Comparison

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

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

All stateful candidates

-

Winternitz parameter

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

Winternitz parameter