8. Putting it together¶
Where we are
Every component is built. This chapter assembles them into FIPS 205's three operations and accounts for every byte.
Key generation¶
FIPS 205 §10.1 (slh_keygen) draws three n-byte values from an approved RBG,
then calls §9.1 Algorithm 18 (slh_keygen_internal):
SK.seed ← random # derives every WOTS+ and FORS secret in the structure
SK.prf ← random # keys the message randomiser
PK.seed ← random # public; tweaks every hash in this keypair
PK.root ← root of the top-layer XMSS tree (layer d-1, tree 0)
Layout:
Two things worth noticing. The private key contains the public key, so the
public key can always be recovered from it. And the entire structure — billions
of one-time keys — is compressed into 4n bytes: 64 for the 128-bit sets.
Everything else is regenerated from SK.seed on demand
(chapter 3).
Computing PK.root is the only expensive part, and it costs 2^h' leaves, not
2^h (chapter 7).
Entropy is not optional
This library's KeyPair.generate uses Io.randomSecure — fresh OS entropy
with no fallback — rather than Io.random, which silently degrades to a
best-effort process-state seed when the OS source is unavailable. A weak
SK.seed compromises every signature the key will ever make, so entropy
failure surfaces as error.IoError instead of producing a usable-looking
key. See src/slh_dsa.zig.
Signing¶
FIPS 205 §9.2 Algorithm 19 (slh_sign_internal), with the §10.2 wrapper on top:
flowchart TD
M["message M, context ctx"] --> MP["M' = 0x00 ‖ len(ctx) ‖ ctx ‖ M"]
MP --> R["R = PRF_msg(SK.prf, opt_rand ?? PK.seed, M')"]
R --> DIG["digest = H_msg(R, PK.seed, PK.root, M')<br/>m bytes"]
DIG --> MD["md<br/>ceil(k·a/8) B"]
DIG --> IT["idx_tree<br/>ceil((h-h')/8) B"]
DIG --> IL["idx_leaf<br/>ceil(h'/8) B"]
MD --> FS["FORS_SIG = fors_sign(md, …)"]
FS --> FPK["FORS pk = fors_pkFromSig(FORS_SIG, md, …)"]
FPK --> HS["HT_SIG = ht_sign(FORS pk, idx_tree, idx_leaf, …)"]
IT -.-> FS
IL -.-> FS
IT -.-> HS
IL -.-> HS
R --> OUT["signature = R ‖ FORS_SIG ‖ HT_SIG"]
FS --> OUT
HS --> OUT
style OUT fill:#2d7d46,color:#fff
Step by step:
- Domain-separate. Prepend
0x00 ‖ len(ctx) ‖ ctxto the message. The0x00distinguishes pure SLH-DSA from the pre-hash variant (0x01). - Randomise.
R = PRF_msg(SK.prf, opt_rand ?? PK.seed, M'). Withopt_rand = nullthis is deterministic. - Digest and parse.
H_msgproducesmbytes, sliced intomd,idx_tree,idx_leaf. - FORS signs
md. - Recover the FORS public key from the signature just produced. Note that
signing calls
fors_pkFromSig— it is cheaper than recomputing thekroots directly, and it guarantees the value certified is exactly the one a verifier will reconstruct. - The hypertree signs the FORS public key.
The output is the concatenation R ‖ FORS_SIG ‖ HT_SIG.
Verification¶
FIPS 205 §9.3 Algorithm 20, §10.3 wrapper:
- Split the signature into
R,FORS_SIG,HT_SIG. - Rebuild
M'from the message and context. - Recompute
digest = H_msg(R, PK.seed, PK.root, M')— using theRfrom the signature — and parse outmd,idx_tree,idx_leaf. fors_pkFromSig→ candidate FORS public key.ht_verifyfoldsdlayers and compares the final root againstPK.root.
Any tampering — signature, message, context, or key — changes the digest or a reconstructed root, and the final comparison fails. There is exactly one accept condition.
Verification never sees a secret
Every input to verification is public. That makes the verify path the natural
fuzzing target — random bytes as
signature and public key must always yield error.InvalidSignature, never a
panic and never an accept — and it means verification has no constant-time
obligations with respect to key material.
Accounting for every byte¶
For SLH-DSA-SHAKE-128s (n=16, h=63, d=7, h'=9, a=12, k=14, len=35):
| Part | Arithmetic | Bytes | Share |
|---|---|---|---|
R |
16 | 16 | 0.2% |
FORS_SIG |
14 × 13 × 16 | 2,912 | 37% |
HT_SIG |
(63 + 7×35) × 16 = 308 × 16 | 4,928 | 63% |
| Total | 7,856 |
This relation is not merely documented here — src/params.zig asserts it at
compile time for all twelve sets, so a mistranscribed Table 2 value becomes a
build error rather than a silent interoperability failure.
Context strings¶
FIPS 205 §10.2/§10.3 define the external interface, which real callers use. It takes a context string of up to 255 bytes:
try Scheme.signWithContext(&sig, msg, "my-app-v1", &sk, &rnd);
try Scheme.verifyWithContext(&sig, msg, "my-app-v1", &pk);
The context is domain-separation material: a signature made under one context will not verify under another. Use it to stop a signature minted for one purpose in your protocol from being replayed as authorisation for a different one — cheap insurance that costs two bytes on the wire.
ctx.len > 255 returns error.ContextTooLong, checked before any hashing.
The internal interface (§9.2/§9.3, signInternal/verifyInternal) signs M
with no prefix at all. It exists because ACVP tests each interface separately;
application code should use one of the external ones.
The pre-hash interface¶
FIPS 205 also defines pre-hash variants (hash_slh_sign / hash_slh_verify,
§10.2.2 Algorithm 23 and §10.3 Algorithm 25), exposed here as signPreHash /
verifyPreHash. They sign a digest of the content instead of the content:
Two things change relative to the pure external interface. The separator is
0x01 rather than 0x00, so the same bytes can never be read as both. And an
OID — the DER encoding of the pre-hash function's identifier — is signed
alongside the digest, which is what binds the choice of hash into the
signature. Without it, a 32-byte digest would be just 32 bytes, and a signature
made over a SHA2-256 digest could be presented as one over a SHA3-256 digest.
The verifier must be told which function was used; it is not recoverable from
the signature. Supplying the wrong one produces a different M' and therefore
error.InvalidSignature.
Why sign a digest at all?
Because sometimes the signer cannot see the whole message. A protocol may
have only a digest to hand, or the content may be too large to stream
through the signing module twice. FIPS 205 §10.2.2 notes that in that case
the hash must still be computed inside a FIPS 140-validated module — though
not necessarily the same one that runs slh_sign_internal.
The complete picture¶
flowchart BT
MSG["message"] --> FORS["FORS<br/>k trees × 2^a leaves<br/>few-time"]
FORS --> L0["hypertree layer 0<br/>XMSS, height h'"]
L0 --> LDOTS["… d layers …"]
LDOTS --> LTOP["layer d-1<br/>XMSS, height h'"]
LTOP --> PK["PK.root"]:::pk
SEED["SK.seed"] -.->|"PRF derives every secret"| FORS
SEED -.-> L0
SEED -.-> LDOTS
SEED -.-> LTOP
classDef pk fill:#2d7d46,color:#fff
Reading it bottom-up: a few-time signature on the message, certified by a chain
of d one-time-signature trees, rooted at a 32-byte public key, with every
secret in the structure derived from one 16-byte seed.
Every layer exists because of a specific problem:
| Layer | Exists because |
|---|---|
| Hash chains (WOTS+) | Lamport wasted half its key material |
| Checksum | Chains can be walked forward for free |
ADRS tweaks |
2^66 targets would otherwise be attacked in parallel |
| Merkle tree | One public key must cover many one-time keys |
| Hypertree | A height-63 root is not computable |
| Message-derived index | A counter cannot survive backups |
| FORS | Message-derived indices collide, and OTS reuse is fatal |
You have finished the ladder
That is SLH-DSA, complete. Nothing in it is decorative.
-
The same material again, but as FIPS 205 algorithm numbers mapped onto functions in
src/. -
Sixty terms, for when a word stops meaning anything.