Skip to content

Chapter 17: SLH-DSA (FIPS 205) from scratch

Chapter 16 built FORS with bare SHA-256 calls: the hashes that formed its leaves, its Merkle nodes and its public key all called the same sha_n() function on their data alone. A hash used to derive a FORS leaf at position (j,i)(j, i) was indistinguishable from a hash used at a different position or in a different subtree, because the only difference was the data fed in. FIPS 205 closes this gap with two additions (National Institute of Standards and Technology, 2024b). First, a 32-byte address structure (ADRS) encodes the exact position of every address-dependent hash call. Second, tweakable hash functions take the ADRS as a tweak, so the same value at two different positions enters the hash as two different inputs.

The result is SLH-DSA: a stateless, hash-only digital signature scheme standardized by NIST in August 2024. The construction is the same FORS-at-the-bottom-of-a-hypertree architecture from Chapter 16, with domain separation added at every level. SLH-DSA’s security relies on preimage resistance and related hash-function properties, which Chapter 18 states precisely (National Institute of Standards and Technology, 2024b, sec. 1.1).

The diagram below shows the components of an SLH-DSA signature. The message enters at the top, passes through Hmsg\mathbf{H}_{msg} to produce the FORS digest and tree/leaf indices, then FORS signs the digest at the selected position. The FORS public key is authenticated upward through dd hypertree layers, each contributing a WOTS+ signature and a Merkle authentication path. ADRS annotations show which type is active at each level.

SLH-DSA signature components. A vertical flow diagram showing the SLH-DSA signing process. The message at the top flows through H_msg to produce a digest. The digest splits into idx_tree, idx_leaf, and FORS message md. FORS signs md producing SIG_FORS and PK_FORS. PK_FORS enters the hypertree where d layers of WOTS+ sign it upward. The signature components R, SIG_FORS, and SIG_HT are collected on the right. Message M H_msg(R, PK.seed, PK.root, M) idx_tree idx_leaf md FORS sign(md) k trees, depth a FORS_TREE / FORS_PRF SIG_FORS PK_FORS HT layer 0: WOTS+ sign PK_FORS XMSS tree at idx_tree, leaf idx_leaf WOTS_HASH HT layers 1..d-1: sign roots upward TREE SIG_HT PK.root (verify) R (n bytes) SIG = R || SIG_FORS || SIG_HT
Figure 17.1. SLH-DSA signature components. H_msg produces the FORS digest, the tree index, and the leaf index. FORS signs the digest. The FORS public key is authenticated through d hypertree layers, each contributing a WOTS+ signature and a Merkle path.

The opening’s claim, that the address turns one value at two positions into two different hash inputs, can be checked with one call to F at each of two positions. The block below takes the opening’s own example, a FORS leaf, and computes the leaf at index 5 and the leaf at index 6 from the same value, two ways. Chapter 16’s leaf hash, sha_n, takes the value alone. This chapter’s F also takes the public seed and an address, and the block sets two of the address’s fields: the type, FORS_TREE, and the tree index, which at tree height 0 says which leaf. The subsections after the block lay out the rest.

import hashlib, struct
n = 16 # hash output bytes
pk_seed = bytes.fromhex("f1e2d3c4b5a6f7e8d9c0b1a2f3e4d5c6")
value = hashlib.sha256(b"one value").digest()[:n]
def sha_n(data):
"""Chapter 16's leaf hash: SHA-256 truncated to n bytes, and no other input."""
return hashlib.sha256(data).digest()[:n]
def leaf_adrs(index):
ad = bytearray(32) # layer, tree and keypair address 0
struct.pack_into(">I", ad, 16, 3) # type 3, FORS_TREE; tree height 0
struct.pack_into(">I", ad, 28, index) # tree index: which leaf
return ad
def compress(ad): # the 22 of the address's 32 bytes that F hashes
return bytes([ad[3]]) + bytes(ad[8:16]) + bytes([ad[19]]) + bytes(ad[20:32])
def f_input(pk_s, ad, m):
"""What SHA-256 hashes inside F."""
return pk_s + b"\x00"*(64-n) + compress(ad) + m
def tw_F(pk_s, ad, m):
return hashlib.sha256(f_input(pk_s, ad, m)).digest()[:n]
# One value hashed into the leaf at index 5, and the same value at index 6
for index in (5, 6):
ad = leaf_adrs(index)
print(f"leaf {index}: sha_n {sha_n(value).hex()[:16]}... "
f"F {tw_F(pk_seed, ad, value).hex()[:16]}...")
in_5 = f_input(pk_seed, leaf_adrs(5), value)
in_6 = f_input(pk_seed, leaf_adrs(6), value)
print(f"F inputs differ: {in_5 != in_6}")
# ==> leaf 5: sha_n 821718ddb2e0d51e... F 479205c2592aeadd...
# ==> leaf 6: sha_n 821718ddb2e0d51e... F 78b2ea28bd060b80...
# ==> F inputs differ: True

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

The bare hash gives one output for both leaves, because nothing in its input says which leaf it is computing. F’s two inputs differ in the tree index alone. Distinct inputs for distinct positions are what ADRS guarantees: FIPS 205 uses a different address for each call to each function (National Institute of Standards and Technology, 2024b, sec. 4.2). The two different outputs are the observed result, not a guarantee.

Four of SLH-DSA’s six hash-function roles, PRF, F, H, and Tℓ\mathbf{T}_\ell, take a 32-byte address among their inputs. The ADRS encodes the exact position of the value being computed within the overall tree structure: which hypertree layer, which subtree, which WOTS+ key, which chain, and which step within the chain (National Institute of Standards and Technology, 2024b). The two message-hashing roles, PRFmsg\mathbf{PRF}_{msg} and Hmsg\mathbf{H}_{msg}, take no address; the tweakable hash functions section below returns to all six.

The 32-byte layout (all values big-endian) follows FIPS 205 Section 4.2:

BytesFieldMeaning
0 to 3layer addresswhich hypertree layer
4 to 15tree addresswhich subtree within that layer
16 to 19typewhich of the seven contexts below
20 to 23keypair addresswhich WOTS+ or FORS key, or padding
24 to 27chain addressor tree height, under TREE and FORS_TREE
28 to 31hash addressor tree index, under TREE and FORS_TREE

The seven type values are WOTS_HASH 0, WOTS_PK 1, TREE 2, FORS_TREE 3, FORS_ROOTS 4, WOTS_PRF 5, and FORS_PRF 6.

The diagram below shows the 32-byte ADRS with byte offsets and the type-dependent interpretation of bytes 20 through 31. Three common types are shown: WOTS_HASH (chain and hash addressing), TREE (height and index), and FORS_TREE (keypair, height, and index).

ADRS field layout. A horizontal bar showing the 32-byte ADRS structure. Bytes 0 to 3 are the layer address. Bytes 4 to 15 are the tree address (12 bytes). Bytes 16 to 19 are the type field. Bytes 20 to 31 change interpretation based on type. Three rows below show the type-dependent fields for WOTS_HASH, TREE, and FORS_TREE. ADRS (32 bytes, big-endian) layer 0-3 tree address (12 bytes) 4-15 type 16-19 context-dependent (12 bytes) 20-31 Type-dependent fields (bytes 20-31): WOTS_HASH (0): keypair 20-23 chain 24-27 hash addr 28-31 TREE (2): padding (0) 20-23 tree height 24-27 tree index 28-31 FORS_TREE (3): keypair 20-23 tree height 24-27 tree index 28-31
Figure 17.2. ADRS 32-byte field layout. Bytes 0 to 3 are the layer address, 4 to 15 the tree address, 16 to 19 the type, and 20 to 31 context-dependent. The three rows below show the type-dependent interpretation for WOTS_HASH, TREE, and FORS_TREE.

Setting the type field zeros bytes 20 through 31 (the context-dependent fields). This prevents stale values from a previous hash call from leaking into a new context.

import hashlib, struct
def new_adrs():
return bytearray(32)
def set_type_clear(ad, v):
struct.pack_into(">I", ad, 16, v)
ad[20:32] = b"\x00" * 12
def set_kp(ad, v):
struct.pack_into(">I", ad, 20, v)
ad = new_adrs()
set_kp(ad, 42)
print(f"Before set_type: kp = {struct.unpack_from('>I', ad, 20)[0]}")
set_type_clear(ad, 2) # TREE
print(f"After set_type: kp = {struct.unpack_from('>I', ad, 20)[0]}")
# ==> Before set_type: kp = 42
# ==> After set_type: kp = 0

For the SHA2 parameter sets, FIPS 205 compresses the 32-byte ADRS to 22 bytes; the SHAKE sets keep the full 32-byte address. The compressed form keeps byte 3 (layer LSB), bytes 8 through 15 (tree address), byte 19 (type LSB), and bytes 20 through 31 (context-dependent fields). In the SHA2 constructions, PK.seed and the zero padding fill the first hash-compression block; the compressed ADRS and the function input begin the next (National Institute of Standards and Technology, 2024b).

SLH-DSA defines six hash-function roles. Four of them, PRF, F, H, and Tℓ\mathbf{T}_\ell, take ADRS as a tweak; PRFmsg\mathbf{PRF}_{msg} and Hmsg\mathbf{H}_{msg} bind the randomizer, message, and public-key material instead. FIPS 205 does not use the word “tweakable”. The tweakable-hash formalism for F, H, and Tℓ\mathbf{T}_\ell comes from the SPHINCS+ framework (Aumasson et al., 2020). For the SHA2 parameter sets with n=16n = 16 (security category 1), all six use SHA-256. For n∈{24,32}n \in \{24, 32\} (categories 3 and 5), F and PRF use SHA-256 while H, Tℓ\mathbf{T}_\ell, Hmsg\mathbf{H}_{msg}, and PRFmsg\mathbf{PRF}_{msg} switch to SHA-512 (National Institute of Standards and Technology, 2024b).

The construction for F at n=16n = 16 is:

F(PK.seed,ADRS,M1)=Truncn(SHA-256(PK.seed∥toByte(0,64−n)∥ADRSc∥M1))\mathbf{F}(\text{PK.seed}, \text{ADRS}, M_1) = \text{Trunc}_n(\text{SHA-256}(\text{PK.seed} \| \text{toByte}(0, 64-n) \| \text{ADRS}^c \| M_1))

PK.seed and the zero padding together fill exactly 64 bytes (one SHA-256 block). The compressed ADRS and the input M1M_1 form the second block. Because the ADRS encodes the position, a chain step or a FORS leaf, the same M1M_1 at different positions gives SHA-256 different inputs.

The SHA2 H function for n≥24n \geq 24 switches to SHA-512:

H(PK.seed,ADRS,M1,M2)=Truncn(SHA-512(PK.seed∥toByte(0,128−n)∥ADRSc∥M1∥M2))\begin{aligned} \mathbf{H}(\text{PK.seed}, \text{ADRS}, M_1, M_2) = {} & \text{Trunc}_n(\text{SHA-512}(\text{PK.seed} \\ & {} \| \text{toByte}(0, 128-n) \| \text{ADRS}^c \| M_1 \| M_2)) \end{aligned}

This split is not an efficiency choice. FIPS 205 Appendix A records two separate changes. SHA-256 was replaced by SHA-512 in Hmsg\mathbf{H}_{msg}, PRFmsg\mathbf{PRF}_{msg}, H, and Tℓ\mathbf{T}_\ell for the category 3 and 5 SHA2 sets, after weaknesses were found in reaching category-5 security with SHA-256. Separately, R and PK.seed were added to the MGF1 input in the SHA2 Hmsg\mathbf{H}_{msg} construction, to mitigate multi-target long-message second-preimage attacks. The cryptanalysis section returns to why the 256-bit chaining value is the binding constraint (National Institute of Standards and Technology, 2024b).

Chapter 15 built WOTS+ with a simplified domain separation: seed || chain_index || step_index || value. SLH-DSA replaces this with the full ADRS machinery. Secret values come from PRF with type WOTS_PRF. Chain iterations use F with type WOTS_HASH. Public key compression uses Tℓ\mathbf{T}_\ell with type WOTS_PK.

import hashlib, struct
n, w = 16, 16
lg_w = w.bit_length() - 1 # log2(w), since w is a power of two
ell_1 = (8 * n + lg_w - 1) // lg_w # ceil(8n / lg_w)
mc = ell_1 * (w - 1)
ell_2 = (mc.bit_length() + lg_w - 1) // lg_w # bit_length() is floor(log2) + 1
ell = ell_1 + ell_2
def compress(ad):
return bytes([ad[3]]) + bytes(ad[8:16]) + bytes([ad[19]]) + bytes(ad[20:32])
def tw_F(pk_s, ad, m):
return hashlib.sha256(pk_s + b"\x00"*(64-n) + compress(ad) + m).digest()[:n]
def tw_T(pk_s, ad, m):
return hashlib.sha256(pk_s + b"\x00"*(64-n) + compress(ad) + m).digest()[:n]
def tw_PRF(pk_s, sk_s, ad):
return hashlib.sha256(pk_s + b"\x00"*(64-n) + compress(ad) + sk_s).digest()[:n]
def set_type_clear(ad, v):
struct.pack_into(">I", ad, 16, v)
ad[20:32] = b"\x00" * 12
def set_kp(ad, v):
struct.pack_into(">I", ad, 20, v)
def get_kp(ad):
return struct.unpack_from(">I", ad, 20)[0]
def set_chain(ad, v):
struct.pack_into(">I", ad, 24, v)
def set_hash_addr(ad, v):
struct.pack_into(">I", ad, 28, v)
pk_seed = bytes.fromhex("f1e2d3c4b5a6f7e8d9c0b1a2f3e4d5c6")
sk_seed = bytes.fromhex("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
ad = bytearray(32)
set_kp(ad, 0)
# Generate one WOTS+ secret and chain it to the endpoint
sk_ad = bytearray(ad); set_type_clear(sk_ad, 5); set_kp(sk_ad, 0)
set_chain(sk_ad, 0)
secret = tw_PRF(pk_seed, sk_seed, sk_ad)
w_ad = bytearray(ad); set_type_clear(w_ad, 0); set_kp(w_ad, 0)
set_chain(w_ad, 0)
val = secret
for j in range(w - 1):
set_hash_addr(w_ad, j)
val = tw_F(pk_seed, w_ad, val)
print(f"WOTS+ chain 0: secret {secret.hex()[:16]}... -> endpoint {val.hex()[:16]}...")
print(f"Chain length: {w - 1} F calls, ell = {ell} chains, sig = {ell * n} bytes")
# ==> WOTS+ chain 0: secret 36c81ce3666e22c6... -> endpoint 19f71540b3d7e140...
# ==> Chain length: 15 F calls, ell = 35 chains, sig = 560 bytes

Each WOTS+ key consists of ℓ=35\ell = 35 chains (ℓ1=32\ell_1 = 32 message chains plus ℓ2=3\ell_2 = 3 checksum chains). The signature is 35 chain values of 16 bytes each: 560 bytes. This matches Chapter 15’s WOTS+ construction, but every hash call now carries an ADRS that encodes (layer, tree, keypair, chain, step).

Chapter 16 hashed each secret into its leaf with a bare sha_n. SLH-DSA derives the secret with PRF (with type FORS_PRF) and hashes it with F(PK.seed, ADRS, secret), so the leaf is bound to its position as well as to its secret. The public leaf is the F output. The signature still reveals the raw secret, but only at the FORS indices the digest selects. An adversary who sees only the public leaf node must invert F to recover the secret (National Institute of Standards and Technology, 2024b).

import hashlib, struct
n = 16
def compress(ad):
return bytes([ad[3]]) + bytes(ad[8:16]) + bytes([ad[19]]) + bytes(ad[20:32])
def tw_F(pk_s, ad, m):
return hashlib.sha256(pk_s + b"\x00"*(64-n) + compress(ad) + m).digest()[:n]
def tw_PRF(pk_s, sk_s, ad):
return hashlib.sha256(pk_s + b"\x00"*(64-n) + compress(ad) + sk_s).digest()[:n]
def set_type_clear(ad, v):
struct.pack_into(">I", ad, 16, v)
ad[20:32] = b"\x00" * 12
def set_kp(ad, v):
struct.pack_into(">I", ad, 20, v)
def set_idx(ad, v):
struct.pack_into(">I", ad, 28, v)
def set_height(ad, v):
struct.pack_into(">I", ad, 24, v)
pk_seed = bytes.fromhex("f1e2d3c4b5a6f7e8d9c0b1a2f3e4d5c6")
sk_seed = bytes.fromhex("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
ad = bytearray(32)
# Generate FORS secret at tree 0, index 5
sk_ad = bytearray(ad); set_type_clear(sk_ad, 6); set_kp(sk_ad, 0) # FORS_PRF
set_idx(sk_ad, 5)
secret = tw_PRF(pk_seed, sk_seed, sk_ad)
# Hash through F to get the leaf node (F-function separation)
leaf_ad = bytearray(ad); set_type_clear(leaf_ad, 3); set_kp(leaf_ad, 0) # FORS_TREE
set_height(leaf_ad, 0); set_idx(leaf_ad, 5)
leaf_node = tw_F(pk_seed, leaf_ad, secret)
print(f"FORS secret: {secret.hex()[:16]}...")
print(f"FORS leaf: {leaf_node.hex()[:16]}...")
print(f"Different: {secret != leaf_node}")
# ==> FORS secret: ba9ea0e47b175e85...
# ==> FORS leaf: 7663b64f96a03367...
# ==> Different: True

The secret and the leaf node are different values. The signature reveals the secret (so the verifier can recompute the leaf by applying F), but an adversary who sees only the leaf node in the public tree cannot recover the secret without inverting F, which requires inverting SHA-256.

That is the first of two things SLH-DSA adds to Chapter 16’s FORS. The second sits above the trees. Chapter 16 formed the FORS public key by concatenating the kk roots and hashing them. FIPS 205 compresses them with a single Tk\mathbf{T}_k call under an address whose type is FORS_ROOTS (Algorithm 17, lines 21 to 24). That address carries the same keypair address as the FORS_TREE addresses below it, with zeros in the last two words (National Institute of Standards and Technology, 2024b). It is neither a plain concatenate-and-hash nor a further Merkle tree over the roots, and the dedicated type is what keeps that one call from colliding with the Tℓ\mathbf{T}_\ell call that compresses a WOTS+ public key.

The hypertree stacks dd layers of XMSS trees. Each XMSS tree has height h′=h/dh' = h/d. At the bottom layer (layer 0), WOTS+ keys sign FORS public keys. At each upper layer, WOTS+ keys sign the roots of subtrees from the layer below. The root of the top-layer tree (layer d−1d - 1) is PK.root.

Algorithm 9 of FIPS 205 (xmss_node) computes tree nodes recursively. Leaf nodes (height 0) are compressed WOTS+ public keys. Internal nodes combine two children via H with TREE-type ADRS (National Institute of Standards and Technology, 2024b).

Signing a message MM under SLH-DSA follows five steps (FIPS 205 Algorithm 19):

  • Compute the randomizer R=PRFmsg(SK.prf,opt_rand,M)R = \mathbf{PRF}_{msg}(\text{SK.prf}, opt\_rand, M). In the default hedged variant, opt_randopt\_rand is a fresh nn-byte random value. In the deterministic variant it is PK.seed, so signing the same message twice gives the same signature (National Institute of Standards and Technology, 2024b, sec. 9.2).
  • Compute the message digest =Hmsg(R,PK.seed,PK.root,M)= \mathbf{H}_{msg}(R, \text{PK.seed}, \text{PK.root}, M)
  • Extract (idxtree,idxleaf,md)(idx_{tree}, idx_{leaf}, md) from the digest: mdmd selects FORS indices, idxtreeidx_{tree} selects the bottom-layer subtree, idxleafidx_{leaf} selects the WOTS+ key within that subtree
  • Sign mdmd with FORS at position (idxtree,idxleaf)(idx_{tree}, idx_{leaf}), producing SIG_FORS and PK_FORS
  • Sign PK_FORS through dd hypertree layers, producing SIG_HT

The signature is R∥SIGFORS∥SIGHTR \| \text{SIG}_{FORS} \| \text{SIG}_{HT}.

Algorithm 19 is slh_sign_internal. FIPS 205 Section 10 wraps it in two application-facing interfaces, both of which prefix a domain separator and a caller-supplied context string before the internal signer sees the message. The pure interface, slh_sign(M, ctx, SK), signs

M′=toByte(0,1) ∥ toByte(∣ctx∣,1) ∥ ctx ∥ MM' = \text{toByte}(0, 1) \, \| \, \text{toByte}(|ctx|, 1) \, \| \, ctx \, \| \, M

The pre-hash interface, hash_slh_sign(M, ctx, PH, SK), signs

M′=toByte(1,1) ∥ toByte(∣ctx∣,1) ∥ ctx ∥ OID ∥ PH(M)M' = \text{toByte}(1, 1) \, \| \, \text{toByte}(|ctx|, 1) \, \| \, ctx \, \| \, OID \, \| \, PH(M)

where OIDOID identifies the pre-hash function PHPH. The context string is at most 255 bytes and empty by default. Verification reconstructs the same M′M' before calling slh_verify_internal (National Institute of Standards and Technology, 2024b).

FIPS 205 parameter sets and signature sizes

Section titled “FIPS 205 parameter sets and signature sizes”

The signature size formula has three components:

  • RR: nn bytes (the randomizer)
  • SIGFORS\text{SIG}_{FORS}: k(1+a)⋅nk(1 + a) \cdot n bytes (kk trees, each contributing one secret value and aa authentication-path nodes)
  • SIGHT\text{SIG}_{HT}: d(ℓ+h′)⋅nd(\ell + h') \cdot n bytes (dd layers, each contributing a WOTS+ signature of ℓ⋅n\ell \cdot n bytes and an authentication path of h′⋅nh' \cdot n bytes)
def sig_size(name, n, h, d, a, k, w):
hp = h // d
lg_w = w.bit_length() - 1 # log2(w), since w is a power of two
ell_1 = (8 * n + lg_w - 1) // lg_w # ceil(8n / lg_w)
mc = ell_1 * (w - 1)
ell_2 = (mc.bit_length() + lg_w - 1) // lg_w # bit_length() is floor(log2) + 1
ell = ell_1 + ell_2
r_bytes = n
fors_bytes = k * (1 + a) * n
ht_bytes = d * (ell + hp) * n
total = r_bytes + fors_bytes + ht_bytes
print(f"{name:24s} n={n:2d} sig={total:6,d} B "
f"(R={r_bytes}, FORS={fors_bytes:,d}, HT={ht_bytes:,d})")
sig_size("SLH-DSA-SHA2-128s", 16, 63, 7, 12, 14, 16)
sig_size("SLH-DSA-SHA2-128f", 16, 66, 22, 6, 33, 16)
sig_size("SLH-DSA-SHA2-192s", 24, 63, 7, 14, 17, 16)
sig_size("SLH-DSA-SHA2-192f", 24, 66, 22, 8, 33, 16)
sig_size("SLH-DSA-SHA2-256s", 32, 64, 8, 14, 22, 16)
sig_size("SLH-DSA-SHA2-256f", 32, 68, 17, 9, 35, 16)
# ==> SLH-DSA-SHA2-128s n=16 sig= 7,856 B (R=16, FORS=2,912, HT=4,928)
# ==> SLH-DSA-SHA2-128f n=16 sig=17,088 B (R=16, FORS=3,696, HT=13,376)
# ==> SLH-DSA-SHA2-192s n=24 sig=16,224 B (R=24, FORS=6,120, HT=10,080)
# ==> SLH-DSA-SHA2-192f n=24 sig=35,664 B (R=24, FORS=7,128, HT=28,512)
# ==> SLH-DSA-SHA2-256s n=32 sig=29,792 B (R=32, FORS=10,560, HT=19,200)
# ==> SLH-DSA-SHA2-256f n=32 sig=49,856 B (R=32, FORS=11,200, HT=38,624)

Each of these six has a SHAKE twin with the same parameters and sizes, so FIPS 205 approves twelve parameter sets, which its Table 2 lists (National Institute of Standards and Technology, 2024b, sec. 11). The “s” variants (small signatures) use deeper subtrees (h′=8h' = 8 or 99) and fewer hypertree layers (d=7d = 7 or 88), which concentrates more WOTS+ keys in each layer and reduces the total number of WOTS+ signatures in SIG_HT. The “f” variants (fast signing) use shallow subtrees (h′=3h' = 3 or 44) and many layers (d=17d = 17 or 2222), which makes each subtree cheaper to build but multiplies the number of WOTS+ signatures. SLH-DSA-SHA2-128s produces 7,856-byte signatures; SLH-DSA-SHA2-128f produces 17,088-byte signatures. Signing is faster with the “f” variant because each subtree has only 23=82^3 = 8 leaves instead of 29=5122^9 = 512 (National Institute of Standards and Technology, 2024b).

Public keys are 2n2n bytes for all parameter sets: PK.seed (nn bytes) and PK.root (nn bytes). Secret keys are 4n4n bytes: SK.seed, SK.prf, PK.seed, and PK.root.

The following code computes the public-key root of a toy SLH-DSA construction: it builds the WOTS+ public keys at each leaf of the top-layer XMSS tree and hashes them upward to PK.root. The sections above walked through the WOTS+, FORS, and hypertree signing components. The full keygen, sign, and verify pipeline lives in the companion package at solutions/ch17-slh-dsa/. WOTS+, FORS, XMSS, and the hypertree are SLH-DSA components, not FIPS 205-approved standalone signature schemes (National Institute of Standards and Technology, 2024b). The parameters are not from FIPS 205. They are small enough that the root computation runs in under a second, and a full toy signature would be about 2 KB. The structure is identical to the real thing: ADRS-tagged tweakable hashes, WOTS+ with domain-separated chains, FORS with the F-function leaf separation, and a hypertree assembling the pieces.

import hashlib, struct
# --- Toy parameters (NOT FIPS 205) ---
n = 16 # hash output bytes
h, d = 9, 3 # total height, layers -> hp = 3
hp = h // d # subtree height
a, k = 3, 3 # FORS: t = 2^a = 8 leaves, k trees
w = 16 # Winternitz parameter
# --- ADRS (32-byte mutable address) ---
def new_adrs():
return bytearray(32)
def set_layer(ad, v):
struct.pack_into(">I", ad, 0, v)
def set_tree(ad, v):
ad[4:16] = v.to_bytes(12, "big")
def set_type_clear(ad, v):
struct.pack_into(">I", ad, 16, v)
ad[20:32] = b"\x00" * 12
def set_kp(ad, v):
struct.pack_into(">I", ad, 20, v)
def get_kp(ad):
return struct.unpack_from(">I", ad, 20)[0]
def set_chain(ad, v):
struct.pack_into(">I", ad, 24, v)
def set_hash_addr(ad, v):
struct.pack_into(">I", ad, 28, v)
def set_height(ad, v):
struct.pack_into(">I", ad, 24, v)
def set_idx(ad, v):
struct.pack_into(">I", ad, 28, v)
def compress(ad):
return bytes([ad[3]]) + bytes(ad[8:16]) + bytes([ad[19]]) + bytes(ad[20:32])
# --- Tweakable hashes (SHA-256 for n=16) ---
def tw_F(pk_s, ad, m):
return hashlib.sha256(pk_s + b"\x00"*(64-n) + compress(ad) + m).digest()[:n]
def tw_H(pk_s, ad, m1, m2):
return hashlib.sha256(pk_s + b"\x00"*(64-n) + compress(ad) + m1 + m2).digest()[:n]
def tw_T(pk_s, ad, m):
return hashlib.sha256(pk_s + b"\x00"*(64-n) + compress(ad) + m).digest()[:n]
def tw_PRF(pk_s, sk_s, ad):
return hashlib.sha256(pk_s + b"\x00"*(64-n) + compress(ad) + sk_s).digest()[:n]
# --- Keygen: compute top XMSS tree root ---
lg_w = w.bit_length() - 1 # log2(w), since w is a power of two
ell_1 = (8 * n + lg_w - 1) // lg_w # ceil(8n / lg_w)
mc = ell_1 * (w - 1)
ell_2 = (mc.bit_length() + lg_w - 1) // lg_w # bit_length() is floor(log2) + 1
ell = ell_1 + ell_2
sk_seed = bytes.fromhex("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")
sk_prf = bytes.fromhex("01020304050607080910111213141516")
pk_seed = bytes.fromhex("f1e2d3c4b5a6f7e8d9c0b1a2f3e4d5c6")
def wots_pk(sk_s, pk_s, ad):
"""Compressed WOTS+ public key (n bytes)."""
kp = get_kp(ad)
sk_ad = bytearray(ad); set_type_clear(sk_ad, 5); set_kp(sk_ad, kp)
w_ad = bytearray(ad); set_type_clear(w_ad, 0); set_kp(w_ad, kp)
tmp = b""
for i in range(ell):
set_chain(sk_ad, i)
val = tw_PRF(pk_s, sk_s, sk_ad)
set_chain(w_ad, i)
for j in range(w - 1):
set_hash_addr(w_ad, j)
val = tw_F(pk_s, w_ad, val)
tmp += val
pk_ad = bytearray(ad); set_type_clear(pk_ad, 1); set_kp(pk_ad, kp)
return tw_T(pk_s, pk_ad, tmp)
def xmss_node(sk_s, i, z, pk_s, ad):
if z == 0:
set_type_clear(ad, 0); set_kp(ad, i)
return wots_pk(sk_s, pk_s, ad)
left = xmss_node(sk_s, 2*i, z-1, pk_s, bytearray(ad))
right = xmss_node(sk_s, 2*i+1, z-1, pk_s, bytearray(ad))
set_type_clear(ad, 2); set_height(ad, z); set_idx(ad, i)
return tw_H(pk_s, ad, left, right)
ad = new_adrs(); set_layer(ad, d - 1)
pk_root = xmss_node(sk_seed, 0, hp, pk_seed, ad)
pk = pk_seed + pk_root
print(f"Public key: {pk.hex()[:32]}... ({len(pk)} bytes)")
# ==> Public key: f1e2d3c4b5a6f7e8d9c0b1a2f3e4d5c6... (32 bytes)

The public key is 32 bytes: PK.seed (16 bytes) concatenated with PK.root (16 bytes). The keygen computed the root of the top-layer XMSS tree by recursively building WOTS+ public keys at each leaf and hashing them upward.

Without ADRS domain separation, an adversary who finds a preimage for any hash call in the tree can forge a signature, and every hash call across a key’s lifetime joins one large multi-target pool. ADRS does not take the analysis out of the multi-target setting; it makes the target domain explicit, which is what keeps the generic bound independent of the target count. Inverting the hash at one ADRS-tagged position does not help at another, because the tweaked inputs differ (Aumasson et al., 2020). The FIPS 205 parameter sets are chosen so the EUF-CMA claim holds for up to 2642^{64} signatures per key, accounting for the relevant multi-target and many-signature effects (National Institute of Standards and Technology, 2024b).

The “128” in SLH-DSA-SHA2-128s is the length in bits of the security parameter nn. FIPS 205 Section 11 defines the number in a parameter-set name as exactly that, so n=16n = 16 bytes. The claimed NIST security category is assigned separately in Table 2, and for both 128 sets it is category 1, not 128 (National Institute of Standards and Technology, 2024b). The standard is explicit that the two are different kinds of quantity: security strength under this approach “is not described by a single number, such as ‘128 bits of security’”, but as a claim that breaking the parameter set costs at least as much as breaking a block cipher with a prescribed key size (National Institute of Standards and Technology, 2024b). Nor is 8n8n the width of every digest inside the scheme. Hmsg\mathbf{H}_{msg} produces mm bytes, 30 for this parameter set, and the SHA-2 constructions truncate and expand around it (National Institute of Standards and Technology, 2024b). Chapter 18 carries the multi-target arithmetic and the residual proof loss that the parameter search absorbs.

Grover’s algorithm (Chapter 1) reduces the cost of brute-force search from O(2n)O(2^n) to O(2n/2)O(2^{n/2}). For preimage resistance, this reduces the effective security of an nn-bit hash from nn bits to roughly n/2n/2 bits against a quantum adversary. SLH-DSA-SHA2-128s uses n=128n = 128 bits (16 bytes), so quantum preimage attacks require approximately 2642^{64} quantum hash evaluations. This is consistent with the intuition behind NIST security category 1, which is defined by the computational resources needed to break AES-128, not by a bit count. The full SLH-DSA security claim is a parameter-set claim, not the statement that n/2=64n/2 = 64 (National Institute of Standards and Technology, 2024b). Chapter 18 develops the per-signature vs per-FORS-instance vs lifetime target-count taxonomy and the multi-target preimage arithmetic for each FIPS 205 parameter set.

For collision resistance, the birthday bound gives 2n/22^{n/2} classical security, reduced to 2n/32^{n/3} queries by the Brassard-Høyer-Tapp (BHT) algorithm from Chapter 3, in the query model with about 2n/32^{n/3} entries of quantum-accessible memory (Brassard et al., 1998). Generic collisions in SLH-DSA’s internal hash functions are not by themselves forgeries. Pre-hashing has a collision requirement of its own, and so do applications that need message-bound signatures, in which a signature cannot be valid for two different messages under the same key (National Institute of Standards and Technology, 2024b, sec. 10.2).

Those applications need an extra step. FIPS 205 Section 11 notes that the key owner could find an Hmsg\mathbf{H}_{msg} collision and reuse one signature for two messages. Such applications should either choose a parameter set with enough Hmsg\mathbf{H}_{msg} collision margin or apply a message-binding transformation such as BUFF (National Institute of Standards and Technology, 2024b). Chapter 18 also analyzes fault injection against WOTS+ chain computation, timing side channels, and grafting, in which a fault in a FORS or hypertree computation makes one WOTS+ key sign a second root. That reuse can let an adversary forge signatures with far less work than generic preimage search.

Hash function split for categories 3 and 5

Section titled “Hash function split for categories 3 and 5”

The SHA-256/SHA-512 split is specified in Section 11.2.2 of FIPS 205 and motivated in Appendix A. The H function processes two nn-byte inputs, which span multiple SHA-256 compression-function blocks. The 256-bit chaining value of SHA-256 limits the effective multi-target second-preimage resistance in the tweakable hash construction. For n=32n = 32 (category 5, targeting 256-bit classical security), this chaining-value width is insufficient. SHA-512 with its 512-bit chaining value provides the needed margin. F and PRF still use SHA-256 because they have single nn-byte inputs and the preimage resistance of SHA-256 is adequate for all categories (National Institute of Standards and Technology, 2024b).

  • Lamport + Merkle (Chapter 14): 8,192-byte Lamport one-time signature. 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. One secret per bit of the message hash.
  • WOTS+ + XMSS (Chapter 15): roughly 2,500-byte signature at h=10h = 10, n=32n = 32. Stateful. Roughly 4x compression over Lamport via w=16w = 16 hash chains. Leaf counter must never be reused.
  • FORS (Chapter 16): 960-byte signature at k=6k = 6, t=16t = 16, n=32n = 32. Few-time. No counter, but limited to a bounded number of signatures per key: a forgery needs every one of a target’s kk indices already revealed in its tree, the occupancy form of Chapter 16, and that probability grows with each signature.
  • SLH-DSA-SHA2-128s (FIPS 205): 7,856-byte signature. Stateless. Hmsg\mathbf{H}_{msg} derives a pseudorandom (idx_tree, idx_leaf) pair from the randomizer, the public key material, and the message, over 2h=2632^h = 2^{63} possible positions. Position reuse does happen: after qq signatures the chance of at least one repeat is about q(q−1)/264q(q-1) / 2^{64}, small for low-volume keys but not for arbitrarily high-volume ones. FORS is few-time, so occasional position reuse does not collapse the scheme, but repeated reuse of one position accumulates revealed FORS secret values. The EUF-CMA claim rests on the FIPS 205 parameterization and the 2642^{64}-signatures-per-key design target, not on positions never colliding (Chapter 16).
  • SLH-DSA-SHA2-128f (FIPS 205): 17,088-byte signature. Stateless. Faster signing (shallow subtrees) at the cost of larger signatures.
  • ML-DSA (FIPS 204): 2,420 to 4,627-byte signature depending on parameter set (National Institute of Standards and Technology, 2024a). Stateless. Relies on lattice assumptions (Module-LWE for key recovery, Module-SIS for unforgeability) in addition to hash properties. Smaller signatures but a fundamentally different security basis.

SLH-DSA’s large signatures are the cost of statelessness with hash-only security. Applications that cannot guarantee state synchronization across replicas (TLS servers, cloud HSMs, backup-restored keys) accept the size penalty. Applications with a single, tightly controlled signer (firmware signing, certificate roots) may prefer XMSS’s 2,500-byte signatures and the state management burden (Hülsing et al., 2018; National Institute of Standards and Technology, 2024b).

FIPS 205 approves twelve parameter sets: six SHA2 sets and six SHAKE sets. They collapse into six structural profiles across two axes: NIST security category (1, 3, 5) and signature-size profile (s for small, f for fast). The s sets minimize signature size at the cost of slower signing. The f sets minimize signing time at the cost of larger signatures and more verification work (National Institute of Standards and Technology, 2024b). For blockchain use, the two numbers that matter are signature bytes per transaction (block throughput) and verification cost per node (Ethereum gas).

The four corner cases of the FIPS 205 size table:

Parameter setSignaturePublic keyCategory
SLH-DSA-128s7,856 B32 B1
SLH-DSA-128f17,088 B32 B1
SLH-DSA-256s29,792 B64 B5
SLH-DSA-256f49,856 B64 B5

Transaction signing is one-shot per spend, so the s sets are the natural default: the smaller signature reduces per-block pressure and the lower per-verification hash count reduces the Ethereum gas envelope. The f sets win only when signer-side latency dominates, and they cost both larger transactions and more verification work. The signature-byte budget anchors back to the secp256k1 baseline from Chapter 4, where a 64-byte Schnorr signature pairs with a 32-byte x-only public key under BIP-340 / BIP-341 (Wuille, Nick, & Ruffing, 2020; Wuille, Nick, & Towns, 2020). Chapter 37 develops the per-block migration tax this byte-budget gap produces, alongside the draft BIP-360 proposal (Pay-to-Merkle-Root), a soft-fork draft that targets long-exposure quantum resistance for Bitcoin outputs rather than a full post-quantum signature (Beast et al., 2024).

Every number above is a size or a speed, which is what an operator picks a parameter set on. None of them is a security margin. Chapter 18 closes Part III by supplying the other half. It counts the hash targets one FORS instance’s position space holds, k⋅tk \cdot t FORS leaves plus d⋅ℓd \cdot \ell WOTS+ chain values, under the counterfactual that ADRS domain separation is absent, and works that multi-target preimage arithmetic through each FIPS 205 parameter set. The model is position-scoped: it is not a count of what one adversary collects per signature, and not the key-level proof term. It also takes Chapter 16’s exact occupancy form and finds the reuse thresholds for each parameter set, separating reuse of one fixed FORS instance from key-level security. It then steps outside the EUF-CMA model for the fault and grafting attacks, which FIPS 205 treats as implementation concerns rather than properties of the construction.

  1. Compressed ADRS computation. Construct an ADRS with layer address 3, tree address 100, type WOTS_HASH (0), keypair address 7, chain address 12, and hash address 5. Compute the 22-byte compressed form by extracting ADRS[3], ADRS[8:16], ADRS[19], and ADRS[20:32]. Verify that the compressed form is 22 bytes.

  2. Signature size derivation. Compute the SLH-DSA signature size for SLH-DSA-SHA2-192s (n=24n = 24, h=63h = 63, d=7d = 7, a=14a = 14, k=17k = 17, w=16w = 16) from first principles. Show the WOTS+ parameter derivation (ℓ1\ell_1, ℓ2\ell_2, ℓ\ell), the FORS component, the hypertree component, and the total.

  3. Type-zeroing rationale. Suppose changing ADRS’s type does not clear bytes 20 to 31. Show how a field left over from the previous operation can change the address used for the same logical hash call. Explain how a signer and a verifier following different operation histories could then disagree.

  4. Parameter tradeoff. Starting from the toy parameters (n=16n = 16, h=9h = 9, d=3d = 3, a=3a = 3, k=3k = 3, w=16w = 16), modify hh, dd, or ww to reduce the hypertree signature component. Compute the new component size and explain the cost: fewer signing positions, more hypertree layers, or longer and slower WOTS+ chains.

  5. When to choose s vs f. An application signs 100 messages per second on a server with 8 replicas. Each signature must be verified by mobile clients over a cellular connection. Argue whether SLH-DSA-SHA2-128s or SLH-DSA-SHA2-128f is the better choice, considering signing speed, verification speed, and bandwidth.

Worked solutions and editorial notes for these exercises are in Appendix D, Chapter 17. A separate track, for rebuilding rather than reading. The package exercises/ch17-slh-dsa arrives with the ADRS setters, the keypair getter, the compressed address, the WOTS+ public-key routine and the XMSS node recursion already implemented, because the chapter prints them. It stubs 25 functions, which the chapter explains without printing them in full: the other ADRS getters and the 32-byte form, the signature-size calculation, the four tweakable hashes, the two message hashes, the chain function at an arbitrary start index, WOTS+ signing and verification, FORS, the hypertree, and the three top-level SLH-DSA routines. Run PQC_IMPL=exercises pytest tests/ch17 to grade your version against the suite that proves the reference one.

Aumasson, J.-P., Bernstein, D. J., Beullens, W., Dobraunig, C., Eichlseder, M., Fluhrer, S., Gazdag, S.-L., Hülsing, A., Kampanakis, P., Kölbl, S., Lange, T., Lauridsen, M. M., Mendel, F., Niederhagen, R., Rechberger, C., Rijneveld, J., Schwabe, P., & Westerbaan, B. (2020). SPHINCS+: Submission to the NIST Post-Quantum Cryptography Standardization Process. NIST PQC Round 3 submission; SPHINCS+ specification v3, 1 October 2020. https://sphincs.org/data/sphincs+-round3-specification.pdf
Beast, H., Heilman, E., & Foxen Duke, I. (2024). BIP-360: Pay-to-Merkle-Root (P2MR). Bitcoin Improvement Proposal (Draft). https://github.com/bitcoin/bips/blob/master/bip-0360.mediawiki
Brassard, G., Høyer, P., & Tapp, A. (1998). Quantum Cryptanalysis of Hash and Claw-Free Functions. LATIN ’98: Theoretical Informatics, 1380, 163–169. https://doi.org/10.1007/bfb0054319
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
Moody, D., & Dang, Q. (2026). NIST SP 800-230 ipd: Additional SLH-DSA Parameter Sets for Limited-Signature Use Cases. National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-230.ipd
National Institute of Standards and Technology. (2024a). FIPS 204: Module-Lattice-Based Digital Signature Standard. Federal Information Processing Standards Publication. https://doi.org/10.6028/NIST.FIPS.204
National Institute of Standards and Technology. (2024b). FIPS 205: Stateless Hash-Based Digital Signature Standard. Federal Information Processing Standards Publication. https://doi.org/10.6028/NIST.FIPS.205
Wuille, P., Nick, J., & Ruffing, T. (2020). BIP-340: Schnorr Signatures for secp256k1. Bitcoin Improvement Proposal. https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki
Wuille, P., Nick, J., & Towns, A. (2020). BIP-341: Taproot: SegWit version 1 spending rules. Bitcoin Improvement Proposal. https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki

Last updated: