Skip to content

Chapter 15: Many-time signatures

Lamport’s signature is 8,192 bytes because it treats each of 256 digest bits independently: one secret revealed per bit, one hash check per bit. The Winternitz one-time signature (WOTS+) compresses this by encoding the digest in base ww instead of base 2 (Hülsing, 2013). At w=16w = 16, the 256-bit digest becomes 64 hexadecimal digits. Each digit selects one of the 16 positions along a hash chain, and the signature is one chain value per digit: 67 values instead of 256, totaling 2,144 bytes. The compression costs computation (up to 15 hash evaluations per chain instead of 1), but the size reduction is roughly 4x.

The catch is that WOTS+ is still one-time. The Merkle tree from Chapter 14 fixes this: place 2h2^h WOTS+ public keys as leaves of a tree of height hh, publish only the root, and sign with one leaf per message. XMSS (RFC 8391) is this construction, with a public key that carries a public seed and a parameter identifier alongside the root (Hülsing et al., 2018). The signer maintains a counter that increments after each signature. The counter is the scheme’s central operational burden: reusing a leaf degrades WOTS+ security, and exhausting all leaves disables the key entirely. NIST approved the stateful schemes LMS/HSS and XMSS/XMSSMT in SP 800-208 (Cooper et al., 2020). NIST later standardized the stateless SLH-DSA scheme separately in FIPS 205 (National Institute of Standards and Technology, 2024), which eliminates the counter at the cost of larger signatures.

The walkthrough below uses a toy far smaller than any FIPS 205 parameter set, and the note under “The chain function” lists where its parameters and chain function depart from the standards.

A WOTS+ key at w=4w = 4 and n=4n = 4 bytes (truncated SHA-256) is small enough to walk by hand. WOTS+ uses two independent seeds: a secret seed that derives the chain start values via a PRF, and a public seed that domain-separates the chain function. RFC 8391 keeps these separate, and so must any implementation: the verifier needs the public seed, so anything derivable from it must not include a secret value (Hülsing et al., 2018). The chain function FF hashes the concatenation of the public seed, a chain address, a step index, and the current value:

F(x,seed,addr,step)=H(seed∥addr∥step∥x)F(x, \text{seed}, \text{addr}, \text{step}) = H(\text{seed} \| \text{addr} \| \text{step} \| x)

where seed\text{seed} is the public seed and HH is SHA-256 truncated to nn bytes. Each chain has w−1=3w - 1 = 3 steps, so the secret sits at the bottom (position 0), the public key sits at the top (position 3), and a signature for digit dd reveals the value at position dd.

import hashlib
def chain_f(x, start, steps, pk_seed, addr, n=4):
"""Iterate the chain function F for *steps* from position *start*."""
value = x
for i in range(start, start + steps):
value = hashlib.sha256(
pk_seed + addr.to_bytes(4, "big") + i.to_bytes(4, "big") + value
).digest()[:n]
return value
sk_seed = b"ch15-tiny-sk" # secret: derives the chain start value
pk_seed = b"ch15-tiny-pk" # public: domain-separates the chain
sk_0 = hashlib.sha256(sk_seed + b"sk" + (0).to_bytes(4, "big")).digest()[:4]
print(sk_0.hex())
# ==> 2c85517f
pk_0 = chain_f(sk_0, 0, 3, pk_seed, 0)
print(pk_0.hex())
# ==> 33b2bbda

Every Python block this chapter prints is also a standalone file in the companion repository, under chapter-code/ch15/, one file per block. Appendix C covers the clone and the environment they run on.

The secret 2c85517f hashes through three steps to the public-key endpoint 33b2bbda. The sk_seed-prefixed SHA-256 here is a toy stand-in for a keyed derivation. In FIPS 205 the secret seed SK.seed derives the private chain values through a specified PRF, while the public seed PK.seed feeds the tweakable hash functions (National Institute of Standards and Technology, 2024). A signature for digit d=1d = 1 reveals the value at position 1:

import hashlib
def chain_f(x, start, steps, pk_seed, addr, n=4):
value = x
for i in range(start, start + steps):
value = hashlib.sha256(
pk_seed + addr.to_bytes(4, "big") + i.to_bytes(4, "big") + value
).digest()[:n]
return value
sk_seed = b"ch15-tiny-sk"
pk_seed = b"ch15-tiny-pk"
sk_0 = hashlib.sha256(sk_seed + b"sk" + (0).to_bytes(4, "big")).digest()[:4]
sig_0 = chain_f(sk_0, 0, 1, pk_seed, 0)
print(sig_0.hex())
# ==> 33fb9d07
# The verifier hashes forward w - 1 - d = 3 - 1 = 2 steps.
recomputed = chain_f(sig_0, 1, 2, pk_seed, 0)
pk_0 = chain_f(sk_0, 0, 3, pk_seed, 0)
print(recomputed == pk_0)
# ==> True

The verifier does not need the secret. It takes the signature value, hashes forward the remaining w−1−dw - 1 - d steps, and checks whether it reaches the public-key endpoint. If the chain function is preimage-resistant, no one can produce the value at position dd without knowing the secret or a value at position ≤d\leq d.

The diagram below shows a single WOTS+ hash chain at w=16w = 16. The secret key value sits at position 0 (bottom). Each arrow represents one application of the chain function FF. The public key endpoint sits at position 15 (top). A signature for digit d=7d = 7 reveals the chain value at position 7 (marked in teal). The verifier hashes forward 8 steps from position 7 to reach position 15 and compares against the public key.

A WOTS+ hash chain at w = 16. A vertical chain of 16 nodes labeled position 0 through position 15. Position 0 at the bottom holds the secret key value. Position 15 at the top holds the public key endpoint. Each node connects to the next by an arrow, and each arrow stands for one application of the chaining function F. The arrows carry no visible label. Position 7 is highlighted in teal as the signature value for digit d=7. The verifier hashes forward 8 steps from position 7 to position 15. pos 0 sk pos 1 pos 2 pos 3 pos 4 pos 5 pos 6 pos 7 sig (d=7) pos 8 pos 9 pos 10 pos 11 pos 12 pos 13 pos 14 pos 15 pk verify: 8 steps secret key signature public key
Figure 15.1. A single WOTS+ hash chain at w=16w = 16. The secret value sits at position 0. Each step applies the chain function FF. A signature for digit d=7d = 7 reveals the chain value at position 7 (teal). The verifier hashes forward 8 steps to recover the public-key endpoint at position 15.

The chain has w=16w = 16 positions. A signature for digit dd reveals position dd. The verifier hashes forward w−1−d=15−dw - 1 - d = 15 - d steps and compares. Lower digit values reveal positions closer to the secret; higher values reveal positions closer to the public key. The checksum ensures that no adversary can shift all digits toward the public-key end of their chains without simultaneously shifting a checksum digit toward the secret-key end.

Base-w encoding, checksums, and the chain function

Section titled “Base-w encoding, checksums, and the chain function”

A WOTS+ signature encodes a message digest as a sequence of base-ww digits. For a hash output of nn bytes, the number of message digits is:

ℓ1=⌈8nlog⁡2w⌉\ell_1 = \left\lceil \frac{8n}{\log_2 w} \right\rceil

Each digit takes values in {0,1,…,w−1}\{0, 1, \ldots, w - 1\}. For w=16w = 16 and n=32n = 32: ℓ1=⌈256/4⌉=64\ell_1 = \lceil 256 / 4 \rceil = 64. Each byte of the digest yields two hexadecimal digits, extracted MSB-first.

import hashlib
def base_w(data, w, out_len):
"""Encode *data* as *out_len* base-*w* digits (MSB-first per byte)."""
lg_w = w.bit_length() - 1 # log2(w), since w is a power of two
digits = []
for byte in data:
for shift in range(8 - lg_w, -1, -lg_w):
digits.append((byte >> shift) & (w - 1))
if len(digits) == out_len:
return digits
return digits[:out_len]
digest = hashlib.sha256(b"XMSS test message").digest()
msg_digits = base_w(digest, 16, 64)
print(msg_digits[:8])
# ==> [12, 1, 11, 7, 15, 0, 13, 3]
print(len(msg_digits))
# ==> 64

The first byte of the digest is 0xC1 (binary 11000001), which in base 16 is [12, 1]. The encoding is deterministic and reversible.

Without a checksum, an adversary who sees a signature can forge signatures on messages whose digits are component-wise larger. If the original digit at position ii is di=7d_i = 7, the adversary holds the chain value at position 7. Hashing forward one step gives position 8, which is a valid signature for a message with digit 8 at that position. The adversary can only hash forward (increasing the digit), never backward (decreasing it).

The checksum closes this gap. Define:

C=∑i=0ℓ1−1(w−1−di)C = \sum_{i=0}^{\ell_1 - 1} (w - 1 - d_i)

This sum is always non-negative. For w=16w = 16 and ℓ1=64\ell_1 = 64, the maximum is 64×15=96064 \times 15 = 960. Encode CC in base ww as ℓ2\ell_2 additional digits, where:

ℓ2=⌈⌊log⁡2(ℓ1⋅(w−1))⌋+1log⁡2w⌉\ell_2 = \left\lceil \frac{\lfloor \log_2(\ell_1 \cdot (w-1)) \rfloor + 1}{\log_2 w} \right\rceil

For w=16w = 16, ℓ1=64\ell_1 = 64: ⌊log⁡2960⌋+1=10\lfloor \log_2 960 \rfloor + 1 = 10, so ℓ2=⌈10/4⌉=3\ell_2 = \lceil 10/4 \rceil = 3. The total number of chains is ℓ=ℓ1+ℓ2=67\ell = \ell_1 + \ell_2 = 67.

The checksum prevents forgery by digit increase: if the adversary increases any message digit did_i by δ\delta, the checksum CC decreases by δ\delta. To forge the checksum chains, the adversary would need to hash backward on at least one checksum chain (a smaller CC has at least one smaller base-ww digit), which requires inverting the hash function.

import hashlib
def base_w(data, w, out_len):
lg_w = w.bit_length() - 1
digits = []
for byte in data:
for shift in range(8 - lg_w, -1, -lg_w):
digits.append((byte >> shift) & (w - 1))
if len(digits) == out_len:
return digits
return digits[:out_len]
w = 16
ell_1 = 64
digest = hashlib.sha256(b"XMSS test message").digest()
msg_digits = base_w(digest, w, ell_1)
c = sum(w - 1 - d for d in msg_digits)
print(c)
# ==> 481
# Left-shift c so the ell_2 digits fill the MSB of the byte encoding,
# matching RFC 8391 Section 3.1.5, Algorithm 5.
ell_2 = 3
lg_w = 4
total_bits = ell_2 * lg_w # 12 bits
num_bytes = 2 # ceil(12 / 8)
shift = 8 * num_bytes - total_bits # 4
c_shifted = c << shift
c_bytes = c_shifted.to_bytes(num_bytes, "big")
csum_digits = base_w(c_bytes, w, ell_2)
print(csum_digits)
# ==> [1, 14, 1]

Without the left-shift, the low 4 bits of the 12-bit checksum would fall off the end of the 16-bit byte array. The base_w routine reads 3 base-16 digits from the most significant byte, so checksums need to occupy the high 12 bits, not the low 12. The shift places the significant digits at the most significant positions of the byte representation.

The full digit string has 67 entries: 64 message digits followed by 3 checksum digits. Each digit indexes a chain position in one of 67 hash chains.

WOTS+ uses a randomized chain function rather than bare hash iteration (Hülsing, 2013). Each step incorporates a public seed (shared across the key, and safe to publish) and an address (unique per chain and step):

F(x)=H(seed∥addr∥step∥x)F(x) = H(\text{seed} \| \text{addr} \| \text{step} \| x)

The public seed provides key-level domain separation. The address and step index ensure that no two hash calls within a key share the same input domain.

  • w=16w = 16, n=32n = 32 bytes (SHA-256)
  • ℓ1=64\ell_1 = 64 message chains
  • ℓ2=3\ell_2 = 3 checksum chains
  • ℓ=67\ell = 67 total chains
  • WOTS+ signature size: 67×32=2,14467 \times 32 = 2{,}144 bytes
  • WOTS+ public key (raw): 67×32=2,14467 \times 32 = 2{,}144 bytes
  • XMSS leaf after L-tree compression: 32 bytes
  • XMSS public key core material (root + public seed): 2×32=642 \times 32 = 64 bytes

WOTS+ keygen, sign, and verify at standard dimensions

Section titled “WOTS+ keygen, sign, and verify at standard dimensions”

Key generation derives ℓ=67\ell = 67 secret values from a secret seed via a PRF, then chains each one forward w−1=15w - 1 = 15 steps to its public-key endpoint using the public seed. The dimensions (w=16w = 16, n=32n = 32, ℓ=67\ell = 67) match RFC 8391; the chain function is still the simplified concatenation form from above, not the RFC 8391 ADRS-and-bitmask construction:

import hashlib
def base_w(data, w, out_len):
lg_w = w.bit_length() - 1
digits = []
for byte in data:
for shift in range(8 - lg_w, -1, -lg_w):
digits.append((byte >> shift) & (w - 1))
if len(digits) == out_len:
return digits
return digits[:out_len]
def chain_f(x, start, steps, pk_seed, addr):
value = x
for i in range(start, start + steps):
value = hashlib.sha256(
pk_seed + addr.to_bytes(4, "big") + i.to_bytes(4, "big") + value
).digest()
return value
sk_seed = b"ch15-full-sk" # secret: derives the WOTS+ secret values
pk_seed = b"ch15-full-pk" # public: domain-separates the chain
w, n = 16, 32
# Integer arithmetic only: FIPS 205 forbids floating point in SLH-DSA,
# whose WOTS+ derives the same counts (Chapter 16).
lg_w = w.bit_length() - 1
ell_1 = (8 * n + lg_w - 1) // lg_w # ceil(8n / lg_w)
max_c = ell_1 * (w - 1)
ell_2 = (max_c.bit_length() + lg_w - 1) // lg_w # bit_length() is floor(log2) + 1
ell = ell_1 + ell_2
sk = [hashlib.sha256(sk_seed + b"sk" + i.to_bytes(4, "big")).digest() for i in range(ell)]
pk = [chain_f(sk[i], 0, w - 1, pk_seed, i) for i in range(ell)]
print(ell)
# ==> 67
print(sk[0].hex()[:16])
# ==> 52c38cfc379ebaaf
print(pk[0].hex()[:16])
# ==> c4608f81e170d3b7

Signing hashes the message, encodes the digest in base 16, computes the checksum, and chains each secret forward by the corresponding digit value:

import hashlib
def base_w(data, w, out_len):
lg_w = w.bit_length() - 1
digits = []
for byte in data:
for shift in range(8 - lg_w, -1, -lg_w):
digits.append((byte >> shift) & (w - 1))
if len(digits) == out_len:
return digits
return digits[:out_len]
def chain_f(x, start, steps, pk_seed, addr):
value = x
for i in range(start, start + steps):
value = hashlib.sha256(
pk_seed + addr.to_bytes(4, "big") + i.to_bytes(4, "big") + value
).digest()
return value
sk_seed = b"ch15-full-sk"
pk_seed = b"ch15-full-pk"
w, n = 16, 32
lg_w = w.bit_length() - 1
ell_1 = (8 * n + lg_w - 1) // lg_w
max_c = ell_1 * (w - 1)
ell_2 = (max_c.bit_length() + lg_w - 1) // lg_w
ell = ell_1 + ell_2
sk = [hashlib.sha256(sk_seed + b"sk" + i.to_bytes(4, "big")).digest() for i in range(ell)]
pk = [chain_f(sk[i], 0, w - 1, pk_seed, i) for i in range(ell)]
message = b"XMSS test message"
digest = hashlib.sha256(message).digest()
msg_digits = base_w(digest, w, ell_1)
c = sum(w - 1 - d for d in msg_digits)
c_shifted = c << (8 * 2 - ell_2 * lg_w)
c_bytes = c_shifted.to_bytes(2, "big")
csum_digits = base_w(c_bytes, w, ell_2)
all_digits = msg_digits + csum_digits
sig = [chain_f(sk[i], 0, all_digits[i], pk_seed, i) for i in range(ell)]
print(sig[0].hex()[:16])
# ==> fcf28dd604c58b8f

Verification recomputes the digits, chains each signature value forward the remaining steps, and compares against the public key:

import hashlib
def base_w(data, w, out_len):
lg_w = w.bit_length() - 1
digits = []
for byte in data:
for shift in range(8 - lg_w, -1, -lg_w):
digits.append((byte >> shift) & (w - 1))
if len(digits) == out_len:
return digits
return digits[:out_len]
def chain_f(x, start, steps, pk_seed, addr):
value = x
for i in range(start, start + steps):
value = hashlib.sha256(
pk_seed + addr.to_bytes(4, "big") + i.to_bytes(4, "big") + value
).digest()
return value
sk_seed = b"ch15-full-sk"
pk_seed = b"ch15-full-pk"
w, n = 16, 32
lg_w = w.bit_length() - 1
ell_1 = (8 * n + lg_w - 1) // lg_w
max_c = ell_1 * (w - 1)
ell_2 = (max_c.bit_length() + lg_w - 1) // lg_w
ell = ell_1 + ell_2
sk = [hashlib.sha256(sk_seed + b"sk" + i.to_bytes(4, "big")).digest() for i in range(ell)]
pk = [chain_f(sk[i], 0, w - 1, pk_seed, i) for i in range(ell)]
message = b"XMSS test message"
digest = hashlib.sha256(message).digest()
msg_digits = base_w(digest, w, ell_1)
c = sum(w - 1 - d for d in msg_digits)
c_shifted = c << (8 * 2 - ell_2 * lg_w)
c_bytes = c_shifted.to_bytes(2, "big")
csum_digits = base_w(c_bytes, w, ell_2)
all_digits = msg_digits + csum_digits
sig = [chain_f(sk[i], 0, all_digits[i], pk_seed, i) for i in range(ell)]
ok = all(
chain_f(sig[i], all_digits[i], w - 1 - all_digits[i], pk_seed, i) == pk[i]
for i in range(ell)
)
print(ok)
# ==> True

The signature is 67 chain values of 32 bytes each: 2,144 bytes total. Lamport at n=256n = 256 produces 256 revealed secrets of 32 bytes: 8,192 bytes. The roughly 4x compression (8,192/2,144≈3.88{,}192 / 2{,}144 \approx 3.8) comes from treating the digest in base 16 instead of base 2, with a minor overhead for the 3 checksum chains.

Without the checksum, an adversary who sees a valid signature can forge signatures on messages with larger digit values at any position. The attack works because hashing forward along a chain is easy, but hashing backward requires inverting HH.

Suppose the original message has digit d0=7d_0 = 7 at position 0. The adversary holds the chain value at position 7. Hashing forward one step produces the value at position 8:

import hashlib, math
def chain_f(x, start, steps, pk_seed, addr):
value = x
for i in range(start, start + steps):
value = hashlib.sha256(
pk_seed + addr.to_bytes(4, "big") + i.to_bytes(4, "big") + value
).digest()
return value
sk_seed = b"ch15-forgery-sk"
pk_seed = b"ch15-forgery-pk"
w = 16
sk_0 = hashlib.sha256(sk_seed + b"sk" + (0).to_bytes(4, "big")).digest()
# Legitimate signature value at position d=7.
sig_at_7 = chain_f(sk_0, 0, 7, pk_seed, 0)
# Adversary hashes forward one step to get position 8.
forged_at_8 = chain_f(sig_at_7, 7, 1, pk_seed, 0)
# This matches what signing with digit 8 would produce.
expected_at_8 = chain_f(sk_0, 0, 8, pk_seed, 0)
print(forged_at_8 == expected_at_8)
# ==> True

The forged value verifies correctly at position 8 because the chain is deterministic. Without a checksum, the adversary can increase any digit at any position by hashing forward.

With the checksum, this attack fails. Increasing a message digit by 1 decreases the checksum by 1. To forge the checksum chains, the adversary would need to hash backward by 1 step, which requires computing F−1F^{-1}:

import hashlib
w = 16
msg_digits = [7, 3, 15, 0, 10, 5, 12, 8]
c_original = sum(w - 1 - d for d in msg_digits)
print(c_original)
# ==> 60
# Increase digit 0 from 7 to 8.
modified = list(msg_digits)
modified[0] = 8
c_modified = sum(w - 1 - d for d in modified)
print(c_modified)
# ==> 59
print(c_original - c_modified)
# ==> 1

The checksum dropped from 60 to 59. The checksum digit encoding changed, and at least one checksum chain position decreased. The adversary holds a chain value at the original checksum position and needs the value at a lower position, which is a preimage of the value it holds. The checksum transforms a “hash forward” attack (easy) into a “hash backward” attack (hard).

A WOTS+ public key has ℓ=67\ell = 67 chain endpoints, each 32 bytes: 2,144 bytes total. XMSS does not hash these by simple concatenation. Instead, it uses an L-tree: a binary hash tree over the ℓ\ell endpoints that compresses them into a single 32-byte leaf (Hülsing et al., 2018).

The L-tree handles non-power-of-2 counts by promoting the last node at each level when the count is odd. For ℓ=67\ell = 67:

LevelNodes inPairs hashedPromotedNodes out
06733134
13417017
217819
39415
45213
53112
62101

The pairs column sums to 66, so compressing one WOTS+ public key at ℓ=67\ell = 67 costs 66 hash evaluations.

import hashlib
def ltree(pk_values, seed):
"""Compress a list of values into a single hash via an L-tree."""
nodes = list(pk_values)
level = 0
while len(nodes) > 1:
next_level = []
i = 0
pair_index = 0
while i + 1 < len(nodes):
combined = hashlib.sha256(
seed
+ level.to_bytes(4, "big")
+ pair_index.to_bytes(4, "big")
+ nodes[i]
+ nodes[i + 1]
).digest()
next_level.append(combined)
i += 2
pair_index += 1
if i < len(nodes):
next_level.append(nodes[i])
nodes = next_level
level += 1
return nodes[0]
seed = b"ch15-ltree"
values = [hashlib.sha256(f"pk-{i}".encode()).digest() for i in range(67)]
root = ltree(values, seed)
print(root.hex()[:16])
# ==> 6df796321be29a7e

The L-tree compresses 2,144 bytes of WOTS+ public key into a single 32-byte hash. This compressed hash becomes the leaf of the XMSS Merkle tree. SLH-DSA (Chapter 17) does not reuse this L-tree. FIPS 205 compresses the WOTS+ chain endpoints with a single tweakable-hash call and builds every tree over a power-of-two number of leaves, so odd-node promotion is specific to RFC 8391 XMSS (National Institute of Standards and Technology, 2024).

XMSS places 2h2^h WOTS+ public keys as leaves of a Merkle tree of height hh and publishes the root and the public seed, together with the parameter identifier: RFC 8391 §4.1.7 gives the public key as OID || root || SEED (Hülsing et al., 2018). RFC 8391 uses hh for the height of a single XMSS tree. FIPS 205 instead uses hh for the total SLH-DSA hypertree height, the sum of its per-layer XMSS subtree heights. This chapter keeps the RFC 8391 convention because it builds a single XMSS tree. The construction is the same as the Merkle signature scheme from Chapter 14, with two changes: WOTS+ replaces Lamport as the leaf OTS, and L-tree compression replaces direct public-key hashing.

The diagram below shows an XMSS tree at h=3h = 3 (8 leaves). Each leaf is the L-tree compression of a WOTS+ public key. Leaf L2L_2 (the most recently consumed leaf) is expanded to show the L-tree and the WOTS+ chain endpoints feeding into it. The state counter currently points to leaf 3 (the next leaf to be used).

XMSS tree at height 3 with a WOTS+ leaf expanded. A complete binary Merkle tree of height 3 with 8 leaves. The root is at the top. Each leaf is labeled L0 through L7. Leaf L2 is expanded downward to show the L-tree compression. Below the L-tree, 67 WOTS+ chain endpoints fan out as a row. A state counter annotation shows next_leaf = 3. Leaves L0 and L1 are grayed to indicate they have been consumed. Leaf L2 was also consumed and is the most recently used leaf, shown expanded in teal to reveal its L-tree and WOTS+ chain endpoints. The state counter next_leaf = 3 points to L3, the next unused leaf. Root N3 N2 N1 N0 N4 N5 L0 L1 L2 L3 next L4 L5 L6 L7 used L-tree c0 c1 c2 ... c66 67 WOTS+ chain endpoints used used expanded leaf chain endpoints
Figure 15.2. XMSS tree at height 3, leaf L2 expanded. Each leaf is the L-tree root of 67 WOTS+ chain endpoints. State counter next = 3; leaves L0, L1, and L2 are consumed (L2 is the most recently used leaf, shown expanded in teal); L3 is next.

Each leaf in the XMSS tree is the L-tree root of 67 WOTS+ public-key chain endpoints. The state counter (next = 3) indicates that leaves 0, 1, and 2 have been consumed. The next signature will use leaf 3. After leaf 7 is consumed, the key is exhausted.

An XMSS signature at leaf index jj consists of four components (Hülsing et al., 2018):

  • The leaf index jj (4 bytes)
  • A randomness value rr (nn bytes) used for randomized message hashing, which defends against multi-target attacks
  • The WOTS+ signature (67 chain values, 2,144 bytes)
  • The Merkle authentication path (hh sibling hashes, 32h32h bytes)

The WOTS+ public key is not part of the signature. The verifier recomputes it: for each digit did_i, the verifier chains the signature value forward w−1−diw - 1 - d_i steps to recover the public-key endpoint. It then L-tree-compresses the recovered endpoints to get the leaf hash and verifies the authentication path from the leaf to the published root.

The pedagogical code below passes the public key directly for clarity. In a production XMSS implementation, the verifier reconstructs the WOTS+ public key from the signature, the message digest, the leaf index, and the public seed, L-tree-compresses it, and checks the authentication path against the published XMSS root.

import hashlib
def base_w(data, w, out_len):
lg_w = w.bit_length() - 1
digits = []
for byte in data:
for shift in range(8 - lg_w, -1, -lg_w):
digits.append((byte >> shift) & (w - 1))
if len(digits) == out_len:
return digits
return digits[:out_len]
def chain_f(x, start, steps, pk_seed, addr):
value = x
for i in range(start, start + steps):
value = hashlib.sha256(
pk_seed + addr.to_bytes(4, "big") + i.to_bytes(4, "big") + value
).digest()
return value
def ltree(pk_values, pk_seed):
nodes = list(pk_values)
level = 0
while len(nodes) > 1:
next_level = []
i = 0
pair_index = 0
while i + 1 < len(nodes):
combined = hashlib.sha256(
pk_seed + level.to_bytes(4, "big") + pair_index.to_bytes(4, "big")
+ nodes[i] + nodes[i + 1]
).digest()
next_level.append(combined)
i += 2
pair_index += 1
if i < len(nodes):
next_level.append(nodes[i])
nodes = next_level
level += 1
return nodes[0]
sk_seed = b"ch15-xmss-sk"
pk_seed = b"ch15-xmss-pk"
w, n, h = 16, 32, 3
lg_w = w.bit_length() - 1
ell_1 = (8 * n + lg_w - 1) // lg_w
max_c = ell_1 * (w - 1)
ell_2 = (max_c.bit_length() + lg_w - 1) // lg_w
ell = ell_1 + ell_2
num_leaves = 1 << h
# Generate all WOTS+ keypairs and build the Merkle tree.
all_sk, all_pk, leaves = [], [], []
for li in range(num_leaves):
sk_leaf = sk_seed + b"leaf" + li.to_bytes(4, "big")
pk_leaf = pk_seed + b"leaf" + li.to_bytes(4, "big")
sk_i = [hashlib.sha256(sk_leaf + b"sk" + j.to_bytes(4, "big")).digest() for j in range(ell)]
pk_i = [chain_f(sk_i[j], 0, w - 1, pk_leaf, j) for j in range(ell)]
all_sk.append(sk_i)
all_pk.append(pk_i)
leaves.append(ltree(pk_i, pk_leaf))
tree = [b""] * (2 * num_leaves)
for i in range(num_leaves):
tree[num_leaves + i] = leaves[i]
for i in range(num_leaves - 1, 0, -1):
tree[i] = hashlib.sha256(tree[2 * i] + tree[2 * i + 1]).digest()
root = tree[1]
print(root.hex()[:16])
# ==> 95751d240dbaaed6

Signing consumes leaf 0. The state counter advances to 1:

import hashlib
def base_w(data, w, out_len):
lg_w = w.bit_length() - 1
digits = []
for byte in data:
for shift in range(8 - lg_w, -1, -lg_w):
digits.append((byte >> shift) & (w - 1))
if len(digits) == out_len:
return digits
return digits[:out_len]
def chain_f(x, start, steps, pk_seed, addr):
value = x
for i in range(start, start + steps):
value = hashlib.sha256(
pk_seed + addr.to_bytes(4, "big") + i.to_bytes(4, "big") + value
).digest()
return value
def ltree(pk_values, pk_seed):
nodes = list(pk_values)
level = 0
while len(nodes) > 1:
next_level = []
i = 0
pair_index = 0
while i + 1 < len(nodes):
combined = hashlib.sha256(
pk_seed + level.to_bytes(4, "big") + pair_index.to_bytes(4, "big")
+ nodes[i] + nodes[i + 1]
).digest()
next_level.append(combined)
i += 2
pair_index += 1
if i < len(nodes):
next_level.append(nodes[i])
nodes = next_level
level += 1
return nodes[0]
sk_seed = b"ch15-xmss-sk"
pk_seed = b"ch15-xmss-pk"
w, n, h = 16, 32, 3
lg_w = w.bit_length() - 1
ell_1 = (8 * n + lg_w - 1) // lg_w
max_c = ell_1 * (w - 1)
ell_2 = (max_c.bit_length() + lg_w - 1) // lg_w
ell = ell_1 + ell_2
num_leaves = 1 << h
all_sk, all_pk, leaves = [], [], []
for li in range(num_leaves):
sk_leaf = sk_seed + b"leaf" + li.to_bytes(4, "big")
pk_leaf = pk_seed + b"leaf" + li.to_bytes(4, "big")
sk_i = [hashlib.sha256(sk_leaf + b"sk" + j.to_bytes(4, "big")).digest() for j in range(ell)]
pk_i = [chain_f(sk_i[j], 0, w - 1, pk_leaf, j) for j in range(ell)]
all_sk.append(sk_i)
all_pk.append(pk_i)
leaves.append(ltree(pk_i, pk_leaf))
tree = [b""] * (2 * num_leaves)
for i in range(num_leaves):
tree[num_leaves + i] = leaves[i]
for i in range(num_leaves - 1, 0, -1):
tree[i] = hashlib.sha256(tree[2 * i] + tree[2 * i + 1]).digest()
root = tree[1]
# Sign with leaf 0. Signing needs only the public seed for the chain;
# the secret values are already in all_sk.
state = {"next_leaf": 0}
leaf_idx = state["next_leaf"]
pk_leaf = pk_seed + b"leaf" + leaf_idx.to_bytes(4, "big")
message = b"first XMSS message"
digest = hashlib.sha256(message).digest()
msg_digits = base_w(digest, w, ell_1)
c = sum(w - 1 - di for di in msg_digits)
c_shifted = c << (8 * 2 - ell_2 * lg_w)
csum_digits = base_w(c_shifted.to_bytes(2, "big"), w, ell_2)
all_digits = msg_digits + csum_digits
wots_sig = [chain_f(all_sk[leaf_idx][j], 0, all_digits[j], pk_leaf, j) for j in range(ell)]
# Authentication path for leaf 0.
node = num_leaves + leaf_idx
path = []
for _ in range(h):
path.append(tree[node ^ 1])
node //= 2
state["next_leaf"] = leaf_idx + 1
print(state["next_leaf"])
# ==> 1
# Verify: WOTS+ check, then Merkle path check.
ok_wots = all(
chain_f(wots_sig[j], all_digits[j], w - 1 - all_digits[j], pk_leaf, j)
== all_pk[leaf_idx][j]
for j in range(ell)
)
leaf_hash = ltree(all_pk[leaf_idx], pk_leaf)
current = leaf_hash
idx = leaf_idx
for sibling in path:
if idx % 2 == 0:
current = hashlib.sha256(current + sibling).digest()
else:
current = hashlib.sha256(sibling + current).digest()
idx //= 2
print(ok_wots and current == root)
# ==> True

A height-hh XMSS tree supports exactly 2h2^h signatures. After the last leaf is consumed, the key is exhausted. At h=10h = 10, the limit is 1,024 signatures. At h=20h = 20, the limit is 1,048,576.

The signer must never reuse a leaf index. NIST SP 800-208 requires implementations to maintain the state counter in non-volatile storage and update it before releasing the signature, not after (Cooper et al., 2020). SP 800-208 also requires key generation and signing to run inside a hardware module that does not export private keying material, which blocks the clone-and-replay path entirely (Cooper et al., 2020). If the signer crashes between signing and updating the counter, the safe behavior is to skip that leaf (waste one slot) rather than risk reuse.

The state counter creates a backup hazard. Exercise 4 in Chapter 6 asked why a stateful signature scheme cannot be safely backed up across two machines. The answer: a backup freezes the counter. If the original signs message AA using leaf 4 (advancing the counter to 5) and the backup, restored from an earlier snapshot, signs message BB using leaf 4 again, both signatures are valid individually. But the WOTS+ key at leaf 4 has now been used twice, and an adversary who sees both signatures gains partial knowledge of the secret chains. The adversary can hash forward from the lower of the two revealed positions at each chain, reconstructing chain values that were never intended to be public.

XMSS signature sizes at w=16w = 16, n=32n = 32 (leaf index + randomness rr + WOTS+ signature + authentication path):

  • h=10h = 10 (1,024 signatures): 4+32+2,144+320=2,5004 + 32 + 2{,}144 + 320 = 2{,}500 bytes
  • h=20h = 20 (1,048,576 signatures): 4+32+2,144+640=2,8204 + 32 + 2{,}144 + 640 = 2{,}820 bytes

The WOTS+ signature dominates. The authentication path adds only 32h32h bytes. The Merkle root is 32 bytes for n=32n = 32 regardless of hh. The RFC 8391 XMSS public key carries both the root and the public seed, so its core material is 64 bytes before any algorithm identifier or container encoding (Hülsing et al., 2018). Both totals come from RFC 8391 Section 4.1.8, which gives an XMSS signature as 4+n+(ℓ+h)n4 + n + (\ell + h)n bytes, evaluated at the Table 2 parameters for XMSS-SHA2_10_256 and XMSS-SHA2_20_256 (Hülsing et al., 2018, sec. 5.3). Those parameter sets fix n=32n = 32, w=16w = 16 and ℓ=67\ell = 67. Table 2 carries the parameters; the sizes are tabulated separately, in Table 3 of the parameter guide, which gives the same 2,5002{,}500 and 2,8202{,}820 bytes (Hülsing et al., 2018, sec. 5.3.1).

FIPS 205 standardizes a stateless hash-based alternative, SLH-DSA, which removes the mutable counter and the whole operational burden that comes with it (National Institute of Standards and Technology, 2024). SLH-DSA replaces the flat Merkle tree with a hypertree of WOTS+ trees and uses FORS (a few-time signature scheme) at the leaves, eliminating the counter entirely. Chapter 16 builds FORS and the hypertree. Chapter 17 assembles them into SLH-DSA.

A blockchain seed may live on multiple devices, and a chain hosts many independent signers who never coordinate counters. SP 800-208 treats state management as part of the security boundary: an approved module must prevent OTS-key reuse, store the incremented leaf index in non-volatile storage before releasing a signature, and never export private keying material (Cooper et al., 2020). SP 800-208 Section 7 gives two ways to spread signing across modules, and handing pre-generated one-time keys to each module is not one of them: the export prohibition rules it out. One option is a separate independent key per module, with verifiers accepting a signature under any of them. The other splits a single two-level HSS or XMSSMT key, so the top tree signs the roots of bottom trees held on separate modules (Cooper et al., 2020). Neither is a shared counter. Against a stateful scheme, the stateless SLH-DSA construction is the more natural target for blockchain transaction signing. Chapter 38 walks the wallet-rotation cadence and rates the primitives against custody shapes.

The checksum is the only defense against forgery by digit increase. Without it, a revealed WOTS+ signature value can always be shifted forward along its chain. A complete message forgery then succeeds for any target digest whose base-ww digits are component-wise ≥\geq the signed digest’s digits. The probability that a random target satisfies this across all ℓ1=64\ell_1 = 64 positions is negligible, but an adversary can search for such targets. The checksum removes this monotone forgery class by forcing at least one checksum digit to move backward, which requires inverting the chain function.

With the checksum, the adversary has two ways to forge. One is inverting the chain function: producing a chain value at a lower position from a value at a higher position, which informally requires computing F−1F^{-1} and reduces to preimage search on the hash function. The other is a second preimage: a forged value at the lower position whose chain merges into the legitimate chain at some later step, so that verification reaches the public key without the forger ever inverting anything. The formal security reduction in Hülsing (Hülsing, 2013) covers both. Its Theorem 1 bounds the forgery probability by separate one-wayness, second-preimage-resistance and undetectability terms for the keyed function family used with its bitmask chaining, so the proof rests on more than preimage resistance of the bare hash. For SHA-256, the classical preimage cost is O(2256)O(2^{256}) and the quantum cost under Grover search is O(2128)O(2^{128}) ideal serial hash queries (Grover, 1996).

Two WOTS+ signatures on different messages under the same key leak intermediate chain values. If message AA has digit 7 at position ii and message BB has digit 11 at position ii, the adversary holds the chain value at position 7 (from signature AA) and can hash forward to positions 8 through 15. From signature BB, the adversary holds position 11 and can reach positions 12 through 15. Combined, the adversary knows chain values at every position from 7 to 15 and can forge any message whose digit at position ii is ≥7\geq 7.

The checksum still applies: forging a complete signature requires matching both message and checksum digits, and increasing any message digit forces a checksum digit to decrease. But the leakage narrows the remaining secret at each chain. After kk signatures on distinct messages, the adversary learns the minimum digit ever signed at each position, and the set of forgeable messages grows. This is analogous to the Lamport two-signature forgery in Chapter 14, where each additional signature exposes more secret halves.

The block below builds the whole toy key from the start of the chapter, 19 chains at w=4w = 4 and n=4n = 4 bytes, and signs two messages with it, which is what a reused XMSS leaf does. It shows WOTS+ chain reuse, not an attack on an intact FIPS 205 signer, whose WOTS+ keys sign the same value whenever a hypertree position repeats (Chapter 16). The adversary keeps, at each chain, the lowest position it was given, and tries candidate messages against one signature and then against both:

import hashlib
def chain_f(x, start, steps, pk_seed, addr, n=4):
value = x
for i in range(start, start + steps):
value = hashlib.sha256(
pk_seed + addr.to_bytes(4, "big") + i.to_bytes(4, "big") + value
).digest()[:n]
return value
def base_w(data, w, out_len):
lg_w = w.bit_length() - 1
digits = []
for byte in data:
for shift in range(8 - lg_w, -1, -lg_w):
digits.append((byte >> shift) & (w - 1))
if len(digits) == out_len:
return digits
return digits[:out_len]
# The toy from the start of the chapter: w = 4 and n = 4 bytes.
w, n = 4, 4
lg_w = w.bit_length() - 1
ell_1 = (8 * n + lg_w - 1) // lg_w # 16 message digits
max_c = ell_1 * (w - 1)
ell_2 = (max_c.bit_length() + lg_w - 1) // lg_w # 3 checksum digits
def csum_digits(c): # 6 bits, left-aligned in one byte
return base_w((c << (8 - ell_2 * lg_w)).to_bytes(1, "big"), w, ell_2)
def all_digits(message):
d = base_w(hashlib.sha256(message).digest()[:n], w, ell_1)
return d + csum_digits(max_c - sum(d))
# The signer holds sk: 19 secret values, derived as at the start.
sk_seed, pk_seed = b"ch15-tiny-sk", b"ch15-tiny-pk"
sk = [hashlib.sha256(sk_seed + b"sk" + i.to_bytes(4, "big")).digest()[:n]
for i in range(ell_1 + ell_2)]
pk = [chain_f(s, 0, w - 1, pk_seed, i) for i, s in enumerate(sk)]
print(sk[0].hex(), pk[0].hex())
# ==> 2c85517f 33b2bbda
def sign(message):
return [chain_f(sk[i], 0, d, pk_seed, i) for i, d in enumerate(all_digits(message))]
def verify(message, sig):
return all(chain_f(sig[i], d, w - 1 - d, pk_seed, i) == pk[i]
for i, d in enumerate(all_digits(message)))
# The adversary holds pk, pk_seed and the signatures it has seen, never sk.
# At each chain it keeps the lowest (position, value) pair it was given,
# and from there it can only hash forward.
def forge(held, message):
sig = []
for i, ((pos, value), d) in enumerate(zip(held, all_digits(message))):
if d < pos:
return None # would need a preimage
sig.append(chain_f(value, pos, d - pos, pk_seed, i))
return sig
m_a, m_b = b"first message", b"second message"
one = list(zip(all_digits(m_a), sign(m_a)))
two = [min(a, b) for a, b in zip(one, zip(all_digits(m_b), sign(m_b)))]
candidates = [b"forge-%d" % j for j in range(1 << 16)]
print(sum(forge(one, m) is not None for m in candidates))
# ==> 0
target = next(m for m in candidates if forge(two, m) is not None)
print(target.decode(), verify(target, forge(two, target)))
# ==> forge-2254 True
# How many of the 4^16 digests can the adversary sign? ways[s] counts the
# message-digit vectors with each digit at or above its held position and
# digit sum s. The checksum of that sum must clear the held checksum digits.
ways = {0: 1}
for pos, _ in two[:ell_1]:
nxt = {}
for s, k in ways.items():
for d in range(pos, w):
nxt[s + d] = nxt.get(s + d, 0) + k
ways = nxt
def clears(s):
return all(d >= pos for d, (pos, _) in zip(csum_digits(max_c - s), two[ell_1:]))
print(sum(k for s, k in ways.items() if clears(s)), w ** ell_1)
# ==> 351788 4294967296

The first chain’s secret and endpoint are the ones the chapter opened with. From one signature, none of the 65,536 candidates can be signed. A candidate needs every message digit at or above the signed one, and raising any of them lowers the checksum, which forces a checksum chain backward, as the section on the checksum showed. Only a candidate with the signed message’s 32-bit digest would pass, a chance of 4−16=2−324^{-16} = 2^{-32} per candidate. From both signatures the 2,255th candidate, forge-2254, passes, and its forged signature verifies under the public key. For this pair 351,788 of the 4164^{16} digests can be signed, a chance of about 2−13.62^{-13.6} per random digest.

The same count at the standard dimensions, w=16w = 16 and n=32n = 32, depends on which two digests were signed. Over 1,000 random pairs, the 64 message digits alone admit between 2−502^{-50} and 2−332^{-33} of all digests for nine pairs in ten, and with the three checksum chains the median pair admits about one digest in 2602^{60}. An adversary who chooses the forged message searches that many candidates, far fewer than the O(2128)O(2^{128}) Grover queries that inverting a chain costs, so one reuse ends the key’s one-time security even though the forgery is not universal.

NIST SP 800-208 §8.1 prohibits leaf reuse outright: a conforming cryptographic module shall not use a one-time key more than once, and it shall store the incremented leaf index in nonvolatile storage before the signature leaves the module (Cooper et al., 2020).

A standalone WOTS+ public key exposes ℓ=67\ell = 67 chain endpoints. In XMSS the WOTS+ public key is not a standing long-term key. The verifier reconstructs it from a signature, so the endpoints for a leaf become visible only once that leaf is used. If those endpoints were outputs of one untweaked hash function, a multi-target preimage attack, which seeks a preimage of any one of them, would gain log⁡267≈6\log_2 67 \approx 6 bits classically, taking single-target preimage security from 22562^{256} to about 22502^{250}, and about half that in the exponent under Grover (21282^{128} to about 21252^{125}), because the Grover speedup is square-root. The chain function above binds every call to a chain address and a step, so the toy does not pay this. It is the hypothetical Chapters 16 and 18 price to show what the binding is worth.

SLH-DSA’s multi-target analysis is more subtle than standalone WOTS+ because randomized message hashing, FORS, the hypertree, and the parameter choices all interact. This chapter only gives the WOTS+/XMSS intuition; Chapter 18 treats the full target-counting model.

The Winternitz parameter ww controls a tradeoff between signature size and computation cost. Larger ww means fewer chains (shorter signatures) but longer chains (more hashing per sign and verify):

wwℓ1\ell_1ℓ2\ell_2ℓ\ellSignatureChain ops
412851334,256 bytes199
16643672,144 bytes499
256322341,088 bytes4,223

Signature size is ℓ×32\ell \times 32 bytes at n=32n = 32. The chain-operation column is the expected number of chain steps a signature costs over uniformly random digests. The ℓ1\ell_1 message digits are uniform, and a uniform digit sits halfway along its chain, so they contribute ℓ1(w−1)/2\ell_1 (w - 1)/2: 192192, 480480, and 4,0804{,}080. The ℓ2\ell_2 checksum digits are not uniform. They are the base-ww digits of the checksum C=ℓ1(w−1)−SC = \ell_1 (w - 1) - S, where SS is the sum of the message digits. Algorithm 5 of RFC 8391 defines that encoding for w=4w = 4 and w=16w = 16, the two values the RFC allows, and the w=256w = 256 row encodes CC the same way. Their expected digit sum, taken exactly over the distribution of SS, is 6.986.98, 19.0819.08, and 142.94142.94. The shortcut that treats every digit as uniform, ℓ(w−1)/2\ell (w - 1)/2, gives 200200, 503503, and 4,3354{,}335 instead, at most 3 percent high. Each halving of the signature costs more than the last. Moving from w=4w = 4 to w=16w = 16 roughly halves the signature for 2.5 times the hashing; w=16w = 16 to w=256w = 256 halves it again for 8.5 times.

RFC 8391 specifies parameter sets only for w=16w = 16, and Table 9 of NIST SP 800-208 fixes w=16w = 16 in every approved WOTS+ parameter set (Cooper et al., 2020; Hülsing et al., 2018). RFC 8391 §6 does permit w=4w = 4 generically, so the constraint is the specified and approved sets rather than conformance in the abstract. The tradeoff above is a design intuition, not a selectable parameter within those sets.

Across the Part III progression:

  • Lamport + Merkle (Chapter 14): 8,192-byte Lamport one-time signature (256 revealed values). The complete Merkle signature Chapter 14 verifies also carries the 16,384-byte Lamport public key and the authentication path, 25,216 bytes at d=20d = 20. Stateful. Simplest construction, largest signatures.
  • WOTS+ + XMSS (this chapter): WOTS+ signature is 2,144 bytes (67 chain values); full XMSS signature is 2,500 bytes at h=10h = 10 (including leaf index, randomness rr, and authentication path). Still stateful. Roughly 4x compression over Lamport at the cost of chain computation.
  • SLH-DSA (Chapters 16 and 17): stateless. Replaces the flat Merkle tree with a hypertree and FORS. Signatures range from 7,856 bytes (SLH-DSA-128s) to 49,856 bytes (SLH-DSA-256f) depending on the parameter set (National Institute of Standards and Technology, 2024). The security argument reduces entirely to hash function properties.
  1. WOTS+ parameters at n=24n = 24. Compute ℓ1\ell_1, ℓ2\ell_2, and ℓ\ell for w=16w = 16 and n=24n = 24 bytes (the SLH-DSA Category 3 hash size). State the signature size in bytes. Compare with the w=16w = 16, n=32n = 32 set used in this chapter, and explain what drives the size difference.

  2. Checksum forgery attempt. A WOTS+ signature at w=16w = 16 has digit value 9 at position 3. The adversary wants to forge a signature where position 3 has digit value 11. Explain the two steps the adversary can perform on the message chain at position 3. Then explain what happens to the checksum and why the adversary cannot complete the forgery.

  3. L-tree for 5 values. Draw the L-tree level progression for 5 input values. At each level, mark which nodes are paired and which node (if any) is promoted. Count the total number of hash evaluations.

  4. XMSS leaf budget. An XMSS key at h=20h = 20 supports 2202^{20} signatures. If the signer averages 100 signatures per day, how many days until the key is exhausted? Suppose the signer’s HSM is backed up weekly and a backup is restored after a crash. Explain what can go wrong and how SP 800-208 mitigates it.

  5. Signature size comparison. Compute the full XMSS signature size (leaf index + randomness rr + WOTS+ signature + authentication path) at w=16w = 16, n=32n = 32, and h=20h = 20. Compare with a Lamport + Merkle signature at the same depth (Chapter 14’s d=20d = 20).

Worked solutions and editorial notes for these exercises are in Appendix D, Chapter 15. A separate track, for rebuilding rather than reading. The package exercises/ch15-xmss arrives with most of what the chapter prints already implemented, and stubs five functions for you to write, the checksum among them, because its reference version generalises what the page hardcodes. Run PQC_IMPL=exercises pytest tests/ch15 to grade your version against the suite that proves the reference one.

Cooper, D., Apon, D., Dang, Q., Davidson, M., Dworkin, M., & Miller, C. (2020). Recommendation for Stateful Hash-Based Signature Schemes. NIST Special Publication 800-208. https://doi.org/10.6028/nist.sp.800-208
Grover, L. K. (1996). A fast quantum mechanical algorithm for database search. Proceedings of the 28th Annual ACM Symposium on Theory of Computing (STOC), 212–219. https://doi.org/10.1145/237814.237866
Hülsing, A. (2013). W-OTS+ – Shorter Signatures for Hash-Based Signature Schemes. Progress in Cryptology – AFRICACRYPT 2013, 7918, 173–188. https://doi.org/10.1007/978-3-642-38553-7_10
Hülsing, A., Butin, D., Gazdag, S., Rijneveld, J., & Mohaisen, A. (2018). XMSS: eXtended Merkle Signature Scheme. IETF RFC 8391. https://doi.org/10.17487/rfc8391
National Institute of Standards and Technology. (2024). FIPS 205: Stateless Hash-Based Digital Signature Standard. Federal Information Processing Standards Publication. https://doi.org/10.6028/NIST.FIPS.205

Last updated: