IACR CiC 2/2/24  ·  Kelsey, Lang & Lucks  ·  Python + Web Crypto

Distributed
Hash-Based
Threshold
Signatures

k trustees contribute PRF shares across two rounds. No individual seed is sufficient to reconstruct the signing key — the aggregator holds a CRV-masked copy, and each trustee's share masks one row of the chain matrix. AggregatorVerify is standard OTS verification: the verifier is unaware of the distributed protocol.

N=128 bits W=8 · CHAIN_LEN=256 A+C=18 chains per key 288-byte signatures stdlib · 116 tests · zero deps
Paper ↗
Live SHA-256 counter
LIVE
0
crypto.subtle.digest — computed in this browser tab
Gen (Python, FAST)3.43 ms
Sign0.003 ms
Verify1.42 ms
AggSign k=2 D=87.28 ms
AggVerify k=21.53 ms
_xor_many() speedup5.9×
BLAKE2b vs SHA-2561.18× faster
00 HOW TO USE THIS SITE

Everything below either runs live in this tab, or is a real measured number. Nothing is faked.

Two kinds of numbers appear on this page. LIVE means your click just ran real SHA-256 via crypto.subtle.digest in this browser tab — different every time. MEASURED means a number recorded from an actual run of the Python implementation (benchmark.py, attacks.py) on real hardware, shown as a static reference point next to the live JS equivalent. Neither is a mockup.

Suggested order — each section builds on the lastprogress tracked below as you interact
#SectionWhat it doesKind
01Winternitz OTSKeygen → sign → verify → forge one Winternitz one-time key, step by stepLIVE
02Key Reuse AttackSign twice under one key; watch chain exposure computed liveLIVE
03Security PropertiesThe four properties the constructions above establishreference
04Threshold Signingk-of-n trustees; mark trustees compromised and watch signing get blockedLIVE
05Merkle TreeBuild a 4-leaf authentication tree; click a leaf to see its pathLIVE
06Weak Seed RecoveryBrute-force a narrowed-entropy PRF seed against its real public round-1 valueLIVE
07Timing Side ChannelMeasure whether chain-walk time correlates with the secret digit, liveLIVE
08BenchmarksMeasured timings for every primitive, plus a live JS vs. Python raceMEASURED + LIVE
All source referenced by function name is real — browse it on GitHub, or read README.md and THREAT_MODEL.md for the full write-up.
Your progress
0 / 7 demos run
01 WINTERNITZ OTS — KEYGEN · SIGN · VERIFY · FORGE

Winternitz chains computed live. Every key is unique.

hash_lms(tweak, *data) = SHA-256(tweak₀_be32 ‖ tweak₁_be32 ‖ … ‖ data)[:N_BYTES]. Keygen walks 18 chains of 255 steps each (4,590 calls). Sign looks up chain position b_all[i] — O(A+C) total. Verify continues from Z[i] for CHAIN_LEN−1−b_all[i] more steps, reconstructing the public key tip. The checksum digits prevent digit-increment forgery: increasing any message digit forces the checksum to decrease, requiring the adversary to also advance a checksum chain beyond the signed position.

1
KEYGEN
winternitz_keygen(keyId)
2
SIGN
winternitz_sign(M, R, seeds)
3
VERIFY
winternitz_verify(M, R, Z)
4
FORGE & FAIL
tamper Z[0], verify → INVALID
Protocol constants — utils.py
N_BYTES16
W8
CHAIN_LEN (2^W)256
A = N_BYTES (msg digits)16
C = ⌈log₂(A·(2^W−1))/W⌉2
A+C (chains per key)18
keyId (this run)—
PK = hash_lms([0, keyId], *tips)
—
Z[0..2] of 18 chain elements
—
Verification result
Run keygen → sign → verify.
winternitz_ots.py — crypto.subtle.digest('SHA-256', buf)
---# hash_lms(tweak, *data) = SHA-256(be32(tweak) || data)[:N_BYTES]
---# Values computed live in this tab — different every run.
---# Click winternitz_keygen() to begin.
02 KEY REUSE ATTACK — WHY OTS KEYS ARE ONE-TIME

Sign the same keyId twice. Watch the chain positions leak.

After observing Z1[i] at digit position b1[i] and Z2[i] at position b2[i], an adversary can compute every chain element from position min(b1[i], b2[i]) upward by forward-hashing from whichever Z has the lower index — using the same absolute step indices the keygen used. Only positions 0 … min(b1[i], b2[i])−1 remain preimage-hard. With two uniformly random messages, the expected minimum is ≈ 85, so ≈ 67% of each chain is exposed. The defence is used_keys tracking in KK_Sign1(): a trustee refuses to contribute to a second signing under the same keyId.

Generate a key to begin.
Message 1
Digit sequence b1[0..17]: —
Message 2 — same keyId ⚠
Digit sequence b2[0..17]: —
Key info
keyId: —
Seeds: 18 × 16B
CHAIN_LEN: 256
03 SECURITY PROPERTIES

Four properties. Each grounded in the construction.

Security reduces to second-preimage resistance of SHA-256 under the hash-based OTS proof of Buchmann et al., extended by Kelsey, Lang & Lucks to the distributed setting. No number-theoretic assumptions.

One-Time Security

A Winternitz key can sign exactly one message.

Signing at digit position b[i] exposes chain elements 0 … b[i]−1 of chain i (they are derivable from Z[i] by forward hashing). A second message with a smaller digit d' < b[i] at position i would require the adversary to produce Z'[i] at position d', which is a preimage of Z[i] under the chain function — computationally equivalent to SHA-256 second preimage.

Threshold Security

Any k−1 trustees learn nothing about the signing key.

Each trustee's PRF contribution PRF_Chain(seed_t, keyId, i, j) acts as a one-time pad over the aggregator's masked key CRV.SK[i][j]. A coalition of k−1 trustees observes a set of PRF images that are jointly uniform over the output space — the unmasked key SK[i][j] has negligible mutual information with their joint view.

Forgery Resistance

Advancing any digit forces the checksum to decrease.

The Winternitz checksum is csum = A×(CHAIN_LEN−1) − Σb[i]. Incrementing any message digit by δ decreases csum by δ, requiring the adversary to produce a chain element at a lower position for the checksum chains. That is a SHA-256 preimage — the Forge & Fail demo above demonstrates this directly: flipping one bit in Z[0] yields a reconstructed PK that does not match the stored value.

Verifier Transparency

AggregatorVerify is identical to standard OTS verification.

The verifier receives (R, Z, PATH) and calls lamport.Verify(R, Z, M, KeyID) followed by a Merkle path check — the same function used for non-distributed stateful HBS. The number of signing trustees k, the CRV structure, and the PRF contributions are invisible to the verifier. Verification cost is O(OTS verify + log D), independent of k.

04 KK_AGGREGATORSIGN — TWO-ROUND PRF PROTOCOL

k-of-n trustees. Click a trustee to mark it compromised.

Round 1: each active trustee t computes Rt = PRF_R(seed_t, keyId) and CHKt = PRF_Chk(seed_t, keyId), sends both to the aggregator. Aggregator derives CRV.R = XOR(R_base, R₀, … Rₙ) and per-trustee integrity tokens CRV.CHK[t] = XOR(PRF_Auth(seed_t, R), CHK₀, CHK₁, …). Round 2: trustee t receives R', verifies it against CHK_Prime (MAC check via KK_Auth), then contributes Zt[i] = WINTER(h, SK_share_t). Aggregator XOR-combines contributions: Z[i] = XOR(CRV.SK[i][b], Zt₀[i], Zt₁[i], …).

Threshold k = 2 of 3 trustees required
Round 1 — PRF contributions
Run KK_Aggregator_Sign() to see computed values.
⚡
Aggregator
Round 2 — XOR combination
CRV.R = XOR(R_base, R₀, R₁, …)
—
ADVANCED — the same protocol split across real, isolated browser tabs instead of one shared array
05 MT_CONSTRUCT — MERKLE AUTHENTICATION TREE

D=4 leaves. Internal nodes computed with real SHA-256.

Leaf i = Winternitz OTS public key for slot i, computed as hash_lms([0, i], *tips_i). Internal node at level ℓ, position p: hash_lms([3, ℓ, p], left, right). MT_MakePath(i) extracts the log₂(D) = 2 siblings needed to recompute the root. Verifier reconstructs: h₀₁ = hash_lms([3,0,0], leaf[0], leaf[1]), then root = hash_lms([3,1,0], h₀₁, h₂₃) — two SHA-256 calls regardless of which leaf signed. Click a leaf after building to see its authentication path highlighted.

Build to see computed hash values.
OTS pk (leaf)
Internal node
Root
Auth path
06 WEAK-RANDOMNESS SEED RECOVERY — LIVE BRUTE FORCE

Narrow the seed's entropy. Watch this tab find it by brute force.

Target: Rt = PRF_R(seed, keyId, N_BYTES) — not a secret. It's the real value a trustee sends the aggregator in round 1 of KK_Aggregator_Sign() (see KK_SetupContribution() / KK_GenSig1() in distributed_signing.py). If a seed's true entropy is only seed_bits instead of the full 128 (a broken RNG, a password-derived seed), an adversary who intercepts one round-1 message can search the narrowed space directly against it. At 128 bits this exact search is infeasible — the numbers below are measured on this CPU, then extrapolated.

Seed entropy = 12 bits (vs. 128 for a real seed)
Target Rt = PRF_R(seed, keyId) — public round-1 value
—
True seed (hidden from the search loop — shown here only for comparison)
—
Recovered seed
—
Pick an entropy width and click Brute-force recover.
07 TIMING SIDE CHANNEL — SignLazy() VS. Sign()

Does chain-walk time leak the digit it's walking to? Measured live, right here.

THREAT_MODEL.md lists timing side channels as out of scope: hash_lms gives no constant-time guarantee. SignLazy() walks each of 18 chains live from its seed for exactly b_all[i] hash calls — unlike eager Sign(), whose per-chain cost is a single O(1) table lookup regardless of the digit. Below: sign many random messages with both, and correlate elapsed time against the digit value that determined the work. This mirrors timing_analysis.py's measure_signlazy_timing() exactly, run against this browser's crypto.subtle.digest instead of Python's hashlib.

Samples = 60
SignLazy() — live chain walk
Run the measurement to see results.
Sign() — eager table lookup (control)
Run the measurement to see results.
08 BENCHMARKS — benchmark_results.csv

Measured on Apple M-series. FAST mode (sha256, stdlib).

AggregatorSign cost is O(k × (A+C) × CHAIN_LEN + k log D) — dominated by the k OTS signing operations, not the tree depth. AggregatorVerify cost is O(OTS verify + log D), independent of k: a verifier processing a distributed signature runs the same code as for a non-distributed stateful HBS signature.

Winternitz OTS primitives
Gen18 chains × 255 steps each
3.43 ms
Signchain lookup at b_all[i]
0.003 ms
Verifyforward hash + PK reconstruct
1.42 ms
Sign: O(A+C) table lookups. Verify: O((A+C)×CHAIN_LEN) forward hashes.
Stateful HBS (non-distributed baseline)
StatefulSignD=128
0.44 ms
StatefulVerifyD=128
1.67 ms
StatefulSign uses precomputed key chain (Gen() eager mode) — O(log D) Merkle path added.
AggregatorSign — distributed k trustees
k=2, D=8
7.28 ms
k=2, D=16
7.37 ms
k=3, D=8
11.02 ms
k=3, D=16
11.10 ms
Cost grows linearly with k. Doubling D (tree depth) adds <0.1 ms — tree contribution is negligible.
AggregatorVerify — independent of k
k=2, D=8
1.53 ms
k=2, D=32
1.66 ms
k=3, D=8
1.63 ms
k=3, D=32
1.74 ms
Verify cost is identical regardless of k — verifier is unaware of the number of signing trustees.
Asymptotic complexity — complexity_comparison.csv
OperationInputsTime complexity
Gensingle OTS keyO(A × CHAIN_LEN)
Signsingle OTS keyO(A + C)
Verifysingle OTS keyO((A+C) × CHAIN_LEN)
MT_ConstructD leavesO(D)
MT_MakePathD leavesO(D + log D)
MT_VerifyD leavesO(log D)
StatefulSignD leavesO(OTS sign + log D)
AggregatorSignD leaves, k trusteesO(k × (A+C) × CHAIN_LEN + k log D)
AggregatorVerifyD leavesO(OTS verify + log D)
09 _XOR_MANY() OPTIMISATION — MEASURED ON THIS MACHINE

int.from_bytes XOR dispatches once at C level. Measured live.

In CPython, int.from_bytes(v, 'big') ^ acc is a single bytecode instruction dispatched to C regardless of byte length. A byte-loop over 16 bytes dispatches 16 times. The JS equivalent: reinterpret the Uint8Array as a Uint32Array (same buffer, no copy) and XOR 4 words instead of 16 bytes. Click below to measure the actual ratio on this CPU.

_xor_many(10 × 16-byte inputs) — 5,000 iterations
Byte loop (16 iterations/call)—
Uint32Array view (4 iterations/call)—
—×
Click to measure on this machine
Python (CPython, M-series): 5.9× · 20,000 runs · int.from_bytes model
distributed_signing.py· _xor_many()
# int XOR: single C dispatch regardless of byte width
def _xor_many(values):
    if not values: return b""
    n = len(values[0])
    acc = int.from_bytes(values[0], "big")
    for v in values[1:]:
        if len(v) != n:
            raise ValueError("length mismatch")
        acc ^= int.from_bytes(v, "big")
    return acc.to_bytes(n, "big")

# JS: view same buffer as Uint32Array — no allocation
# const out = new Uint32Array(N_BYTES / 4);
# for (const a of inputs) {
#   const v = new Uint32Array(a.buffer, a.byteOffset);
#   for (let i = 0; i < 4; i++) out[i] ^= v[i];
# }
10 THREE MEASURED OPTIMISATIONS

Each one benchmarked. No theoretical claims.

01 · _xor_many()

Integer XOR fast path

Replaces a per-byte ^ loop with int.from_bytes + ^. CPython's integer XOR is a single C-level operation — bitwidth does not change dispatch cost. Measured over 20,000 runs with 10 × 16-byte inputs per call.

Byte loop
6.59 μs
for b in zip(*vals):
  acc ^= b
int XOR
1.10 μs
acc ^= int.from_bytes
(v, 'big')
5.9× faster — measured — 20,000 runs
02 · BLAKE2b backend

Hash function swap

Single-line change: HASH_BACKEND = "blake2b". BLAKE2b has lower per-call overhead on 16-byte inputs due to a smaller internal state and faster finalization on short messages. Security reduces to collision resistance — the hash-based OTS proof is hash-agnostic.

SHA-256 keygen
3.70 ms
HASH_BACKEND =
"sha256"
BLAKE2b keygen
3.14 ms
HASH_BACKEND =
"blake2b"
1.18× faster keygen — same security proof structure
03 · GenLazy()

Deferred chain evaluation

Gen() precomputes all 18 × 256 chain positions at keygen, storing a 73 KB key matrix. GenLazy() stores only the 18 × 16-byte seeds (288 bytes) and walks each chain at sign time, shifting the 4,590-call cost from keygen to sign. Same PK, same signature format, same verify path.

Gen() key size
73 KB
18 × 256 × 16 B
Sign: 0.003 ms
GenLazy() key size
288 B
18 × 16 B seeds
Sign: 1.62 ms
256× smaller private key — sign cost shifts to 1.62 ms
11 SOURCE — CRITICAL PATHS

The two functions that carry all protocol complexity.

hash.py· hash_lms() — domain-separated SHA-256
def hash_lms(tweak, *data):
    h = hashlib.sha256()
    for t in tweak:
        if isinstance(t, int):
            h.update(t.to_bytes(4, "big"))   # tweak ints → big-endian 4-byte prefix
        else:
            h.update(t)
    for d in data:
        h.update(d)
    return h.digest()                          # 32 B; callers slice [:N_BYTES]
distributed_signing.py· KK_SetupContribution() — each trustee calls this during setup
def KK_SetupContribution(seed, KeyID, R, path_lens, sk_shape):
    rows, cols = sk_shape
    return {
        "Rt":    PRF_R(seed, KeyID, N_BYTES),       # tweak (30, keyId) — masks R_base
        "CHKt":  PRF_Chk(seed, KeyID, N_BYTES),     # tweak (31, keyId) — round-2 integrity
        "Auth":  PRF_Auth(seed, KeyID, R),          # tweak (32, keyId) — binds R to seed
        "PATHt": [PRF_Path(seed, KeyID, idx, n)
                  for idx, n in enumerate(path_lens)],
        "SKt":   [[PRF_Chain(seed, KeyID, i, j, N_BYTES)
                   for j in range(cols)]      # one-time pad over CRV.SK[i][j]
                  for i in range(rows)],
    }
ALSO BUILT Shor's RSA Cracker — a from-scratch quantum simulator that factors real RSA keys with period-finding, run live against real IBM hardware. iabhi92.github.io/shors-rsa-cracker →