SHL-DSA: A Practical Introduction to the Post-Quantum Signature Algorithm
Post Quantum Cryptography
Introduction
Digital signatures are foundational to modern computing: TLS, software updates, secure messaging, blockchains, and more all depend on signatures. Large-scale quantum computers threaten many number-theory-based schemes (RSA, DSA, ECDSA) due to Shor’s algorithm. Hash-based signatures — notably the SPHINCS family — remain a conservative, well-understood option because their security reduces to the hash function.
This post uses the label “SHL-DSA” to refer to stateless, SPHINCS-like hash-based signatures. Below you’ll find precise algorithmic steps, parameter guidance, signature layout, and implementation notes to help understand, evaluate, or adopt these schemes.
1. Why choose stateless hash-based signatures?
- Minimal assumptions: security reduces to hash properties (preimage, second-preimage, collision resistance).
- Quantum resistance: no known quantum algorithm comparable to Shor’s algorithm; best generic quantum attack against hash preimages is Grover’s algorithm (square-root speedup).
- Stateless operation: unlike traditional stateful hash schemes, SPHINCS-style schemes are designed so the signer does not need to keep per-signature state.
Trade-off: larger signatures and heavier signing work in exchange for conservative, well-understood security.
2. Core components (concise)
- WOTS+ — Winternitz one-time signature (hash-chain based); signs short values using chains of hash function applications.
- FORS — Forest of Random Subsets; compresses a message digest into a small number of tree roots with small signatures.
- Hypertree — stacked Merkle trees that enable many WOTS+ keypairs to be authenticated under a single small public root.
These components are composed so that a message is first compressed with FORS, then the resulting roots are signed with WOTS+, and Merkle auth paths connect WOTS+ leaves to the public hypertree root.
3. Algorithmic pseudocode
The following is a concise, implementation-oriented pseudocode description of a stateless hash-based signature (SPHINCS-like). It is written to be explicit about seeds, role-specific derivations, and the data flowing through FORS, WOTS+, and the hypertree.
Assumptions and notation:
- $H$ : cryptographic hash or XOF (e.g. SHA-256 or SHAKE256)
- $\mathrm{PRF}$ : PRF derived from $H$ used for deterministic randomizers
- $n$ : hash output size in bytes
- $w$ : Winternitz parameter
- $k,a$ : FORS parameters (k trees of height a)
- $L$ : number of hypertree layers
- $h[0..L-1]$ : per-layer Merkle heights
The pseudocode below uses helper routines that are defined informally:
- KDF(seed) -> (sk_seed, sk_prf, pk_seed)
- DeriveWOTSSeed(sk_seed, pk_seed, leaf_index) -> wots_seed
- WOTS_GenPK(wots_seed, pk_seed) -> wots_pk
- WOTS_Sign(wots_seed, message_digest) -> wots_signature
- WOTS_Verify(wots_pk, message_digest, wots_signature) -> leaf_value
- BuildMerkleTree(leaves[]) -> (root, auth_paths[])
- FORS_Sign(mhash, sk_seed, pk_seed) -> (sig_fors, root_fors)
- FORS_Verify(sig_fors, mhash, pk_seed) -> root_fors
function KeyGen(seed):
(sk_seed, sk_prf, pk_seed) = KDF(seed)
# --- build leaf values for the bottom layer ---
for leaf_index in 0 .. (2^{sum(h)} - 1):
wots_seed = DeriveWOTSSeed(sk_seed, pk_seed, leaf_index)
wots_pk = WOTS_GenPK(wots_seed, pk_seed)
leaf[leaf_index] = H_leaf(pk_seed || wots_pk) # domain-separated leaf hash
# --- build hypertree: layer-by-layer Merkle roots ---
for layer in 0 .. L-1:
(root[layer], auth_paths[layer]) = BuildMerkleTree(leafs_for_layer(layer))
# prepare leaves for next layer (e.g., root values of groups)
leafs_for_layer(layer+1) = derive_parent_leaves(root[layer])
R = root[L-1] # hypertree root (public)
sk = (sk_seed, sk_prf, pk_seed, R)
pk = (pk_seed, R)
return (sk, pk)
function Sign(sk, message):
(sk_seed, sk_prf, pk_seed, R) = sk
optrand = PRF(sk_prf, message) # optional deterministic randomizer (n bytes)
mhash = H(optrand || pk_seed || message) # message digest input to FORS
(sig_fors, root_0) = FORS_Sign(mhash, sk_seed, pk_seed)
signature = []
append(signature, optrand)
append(signature, sig_fors)
# Choose a leaf index deterministically (stateless derivation)
leaf_index = DeriveLeafIndex(sk_prf, message, optrand)
current_root = root_0
idx = leaf_index
for layer in 0 .. L-1:
wots_seed = DeriveWOTSSeed(sk_seed, pk_seed, idx)
sig_wots = WOTS_Sign(wots_seed, current_root)
auth_path = GetAuthPath(idx, layer) # precomputed or on-demand
append(signature, sig_wots)
append(signature, auth_path)
# compute parent root from wots public key and auth_path
leaf_value = WOTS_GenPK(wots_seed, pk_seed) # or reconstruct via verify
current_root = ComputeRootFromLeafAndPath(leaf_value, auth_path, idx)
idx = idx >> h[layer] # move to index of parent in next layer
return serialize(signature)
function Verify(pk, message, signature_bytes):
(pk_seed, R) = pk
(optrand, sig_fors, per_layer_blocks) = parse(signature_bytes)
mhash = H(optrand || pk_seed || message)
root_0 = FORS_Verify(sig_fors, mhash, pk_seed)
current_root = root_0
idx = DeriveLeafIndexFromSignatureInfo(signature_bytes) # depends on derivation
for layer in 0 .. L-1:
(sig_wots, auth_path) = extract_block(per_layer_blocks, layer)
leaf_value = WOTS_Verify(sig_wots, current_root, pk_seed)
current_root = ComputeRootFromLeafAndPath(leaf_value, auth_path, idx)
idx = idx >> h[layer]
# final check
if current_root == R:
return VALID
else:
return INVALID
Notes:
DeriveLeafIndexmust be deterministic and stateless (derived from sk_prf, message, and/or optrand) so the signer can compute which leaves to use without state.GetAuthPathmay be computed on-demand fromsk_seed(recomputing leaves) or read from a precomputed tree; implementations often use memory/time trade-offs here.- All hash inputs should use explicit domain separation tags (PRF vs chain vs leaf vs tree node) to avoid cross-role collisions.
This pseudocode is intentionally implementation-oriented and omits low-level optimizations (parallelism, incremental tree builds, cacheing). It can be used as the basis for a reference implementation or to map library-specific parameter sets into concrete buffer sizes and sequence ordering.
3.1 Detailed architecture diagram
The diagram below shows the three main phases — Key Generation, Signing, and Verification — and the data flow between FORS, WOTS+, and the hypertree layers. Node labels include common size/operation annotations (e.g. WOTS+ and auth-path sizes) to help reason about costs.
Notes:
- $n$ is the hash output length in bytes (e.g. 32 for SHA-256 variants).
- $len$ is the WOTS+ length (depends on $w$ and $N$).
- $k$, $a$ are FORS parameters (number of trees and per-tree height).
- $h_i$ are per-layer Merkle heights; $L$ is the number of layers.
4. Signature format and size considerations
Typical signature structure:
- $optrand$ or message randomizer (optional/deterministic $\mathrm{PRF}$ output)
- $\mathrm{sig}_{\text{fors}}$ (FORS signatures)
- Repeated blocks per hypertree layer: $\mathrm{sig}_{\text{wots},i}$ + $\text{auth_path}_i$
Size drivers:
- WOTS+ length (depends on Winternitz parameter $w$).
- Number and size of FORS trees.
- Hypertree height and number of layers (L).
Approximate signature sizes depend on chosen parameters and hash output length (32 bytes for SHA-256 variants). Implementations usually document sizes for preset parameter choices; expect signatures in the single- to multi-kilobyte range (commonly 8–40 KB depending on security level and compactness goals).
5. Parameter trade-offs (practical guidance)
- Winternitz $w$: larger $w$ → smaller signatures, slower signing.
- FORS configuration: more trees of shallower height reduce signing time but increase signature length.
- Hypertree layering: increasing L (more layers) reduces per-layer tree height (and memory pressure during tree generation) but increases number of WOTS+ signatures and auth paths in the final signature.
Practical advice: prefer library-provided parameter sets (these are tuned for balanced trade-offs and security margins) rather than creating custom sets from scratch.
6. Implementation tips and performance
- Key generation can be expensive (building many leaves). Use incremental or on-demand leaf computation when possible.
- Parallelize leaf and chain computations to improve keygen and signing throughput.
- Use constant-time operations for secret-dependent steps; protect WOTS+ secret chains and PRF outputs from side channels.
- Consider deterministic signing (PRF-based randomizer) for reproducibility and to avoid reliance on runtime entropy.
- Domain separation for different hash usages (PRF vs chain vs leaf) is important to prevent unintended interactions.
7. Security sketch
Forging a SHL‑DSA signature generally implies breaking one of the following:
- FORS (forge a FORS signature / find preimages for those roots),
- WOTS+ (invert a hash chain or produce a signature for a public key without the secret), or
- Hash collisions that break Merkle or PRF binding.
Quantumly, Grover’s algorithm reduces the effective security of hash preimage resistance by ~1/2 of the bit-length, so choose parameters accordingly to preserve desired post-quantum security levels.
8. Practical example (liboqs / pyoqs)
This section explains the typical byte layout of a SPHINCS-style signature and shows a small Python helper that computes offsets and slices the opaque signature returned by liboqs/pyoqs for inspection or debugging. Exact sizes depend on the parameter set; the code below computes sizes from the core parameters so you can adapt it to any profile.
Typical signature field sequence (byte order):
- $optrand$ (optional randomizer): $n$ bytes
- $\mathrm{sig}_{\text{fors}}$ (FORS secret leaves + auth paths): approximately $k(1+a)\cdot n$ bytes
- For each hypertree layer $i=0,\ldots,L-1$:
- $\mathrm{sig}_{\text{wots},i}$ (WOTS+ signature): $\text{len}\cdot n$ bytes
- $\text{auth_path}_i$ (Merkle auth path): $h_i\cdot n$ bytes
The helper below computes $\text{len}$ (WOTS+ length) and the offsets for each piece.
import math
import oqs
def compute_wots_len(N, w):
# N: hash output length in bits, w: Winternitz parameter
len1 = math.ceil(N / math.log2(w))
len2 = math.floor(math.log2(len1 * (w - 1)) / math.log2(w)) + 1
return int(len1 + len2)
def compute_signature_layout(N_bits, w, k, a, L, h_list):
# N_bits: hash output length in bits (e.g., 256),
# w: Winternitz parameter, k: FORS trees, a: FORS tree height,
# L: number of hypertree layers, h_list: list of h_i for each layer
n = N_bits // 8
wots_len = compute_wots_len(N_bits, w)
offset = 0
layout = []
# optrand (randomizer)
layout.append(('optrand', offset, offset + n))
offset += n
# FORS signature
fors_size = k * (1 + a) * n
layout.append(('sig_fors', offset, offset + fors_size))
offset += fors_size
# Per-layer WOTS+ + auth path
for i in range(L):
wots_size = wots_len * n
auth_size = h_list[i] * n
layout.append((f'sig_wots_{i}', offset, offset + wots_size))
offset += wots_size
layout.append((f'auth_path_{i}', offset, offset + auth_size))
offset += auth_size
return layout
# Example usage: adapt parameters to the chosen SPHINCS+ profile
N_bits = 256 # SHA-256 based variants
w = 16 # example Winternitz parameter
k = 12 # example FORS number of trees
a = 5 # example FORS height
L = 4 # example number of hypertree layers
h_list = [10, 10, 10, 10] # example per-layer heights
layout = compute_signature_layout(N_bits, w, k, a, L, h_list)
print('Layout fields and byte ranges:')
for name, start, end in layout:
print(f"{name}: offset {start} .. {end} (len={end-start} bytes)")
# Sign and parse with pyoqs/liboqs (algorithm must match chosen params)
alg = 'SPHINCS+-sha2-128s-robust' # replace as appropriate
message = b'Example message for parsing demo'
with oqs.Signature(alg) as signer:
pk = signer.generate_keypair()
signature = signer.sign(message)
print('\nTotal signature length:', len(signature))
for name, start, end in layout:
field = signature[start:end]
print(f"{name}: {len(field)} bytes (hex-prefix: {field[:8].hex()})")
# Note: the offsets computed above assume the chosen params match the
# algorithm used by `liboqs`. If you pick a liboqs algorithm, consult its
# parameter documentation or the SPHINCS+ spec to map alg -> (N,w,k,a,L,h_list).
This parsing approach is useful for debugging, educational inspection, or validating that the signature contains the expected number of WOTS+ and FORS blocks. For production code, avoid parsing signatures manually unless you fully control and trust the parameter mapping; instead use the library’s verify function which interprets the format correctly.
9. Adoption checklist
- Use a vetted implementation (liboqs, upstream SPHINCS+ reference code, or audited libraries).
- Select a standard parameter set provided by the implementation.
- Measure signature sizes and signing/verification throughput on your hardware.
- For transition, consider hybrid signatures (classical + post-quantum).
- Include domain separation, side-channel protections, and deterministic signing where appropriate.
Appendix: Mathematical description
Below are mathematical descriptions of the main components and a compact formula for signature size. In what follows let $N$ be the hash/XOF output length in bits and let $n = N/8$ be the hash output length in bytes. Let $w$ be the Winternitz parameter, $L$ the number of hypertree layers, and let $h_i$ be the Merkle tree height for layer $i$ (so $\sum_{i=0}^{L-1} h_i$ is the total hypertree height). Let FORS use $k$ trees each of height $a$.
WOTS+ parameters and lengths: \(\begin{aligned} ext{len}_1 &= \left\lceil\frac{N}{\log_2 w}\right\rceil, \\ ext{len}_2 &= \left\lfloor\frac{\log_2\big(\text{len}_1\,(w-1)\big)}{\log_2 w}\right\rfloor + 1, \\ ext{len} &= \text{len}_1 + \text{len}_2. \end{aligned}\)
Each WOTS+ signature consists of $\text{len}$ hash outputs (each $n$ bytes). Thus the WOTS+ signature size in bytes is \(S_{\mathrm{WOTS+}} = n\cdot\text{len}.\)
WOTS+ chain function and signature elements (compact): \(\begin{aligned} ext{chain}(x, t) &= F^{t}(x) \quad(\text{$t$ iterations of the tweakable hash }F), \\ ext{pk}_i &= F^{w-1}(\text{sk}_i), \\ ext{sig}_i &= F^{a_i}(\text{sk}_i)\quad\text{where } a_i\in[0,w-1]\text{ from base-}w\text{ encoding.} \end{aligned}\)
Base-$w$ encoding (message digest to chain lengths): if $m$ is the $N$-bit digest then represent $m$ in base $w$ to produce digits $c_0,\dots,c_{\text{len}_1-1}$ and compute a checksum over those digits to produce the $\text{len}_2$ checksum digits.
FORS (forest of $k$ trees of height $a$): For each tree $j\in{0,\dots,k-1}$ the signer reveals one secret leaf value $s_{j}$ (size $n$ bytes) and the authentication path of length $a$. The verifier recomputes the tree root $r_j$ from the revealed leaf and the path. The FORS public key is then \(\mathrm{PK}_{\mathrm{FORS}} = H\big(r_0\,\|\,r_1\,\|\,\cdots\,\|\,r_{k-1}\big)\) where $|$ denotes byte concatenation and $H(\cdot)$ includes domain separation.
FORS signature size in bytes (approx): \(S_{\mathrm{FORS}} = k\cdot (1 + a)\cdot n\) since each tree contributes one secret leaf ($n$ bytes) plus $a$ hashes for the auth path ($a\cdot n$ bytes).
Hypertree (stacked Merkle trees) contribution to signature: For each layer $i$ the signature includes one WOTS+ signature and an authentication path of length $h_i$ (each node hashed to $n$ bytes). Thus layer $i$ contributes \(S_{\text{layer }i} = n\cdot\text{len} + n\cdot h_i = n\,(\text{len} + h_i).\)
Total signature size (bytes), including an $n$-byte randomizer $r$ (if used), is therefore approximately: \(\begin{aligned} S_{\text{sig}} &= n \; \Big(1 \;+\; k(1+a) \; + \; \sum_{i=0}^{L-1} (\text{len} + h_i) \Big) \\ &= n \; \Big(1 \;+\; k(1+a) \; + \; L\,\text{len} \; + \; \sum_{i=0}^{L-1} h_i \Big). \end{aligned}\)
Public key size (typical): one hash output (the hypertree root) plus the public seed; in practice $\approx n$ bytes.
Merkle parent and domain separation (formal): let $\mathrm{DOM}$ denote a fixed domain-separation tag for tree-node hashing, then \(h_{\text{parent}} = H\big(\mathrm{DOM}\,\|\,h_{\text{left}}\,\|\,h_{\text{right}}\big).\)
Quantum security adjustment (Grover): if the hash/XOF has output length $N$ bits, generic quantum preimage search gives an attacker advantage analogous to $2^{N/2}$ classical effort. To achieve $r$ bits of security against quantum attackers, choose $N$ such that \(\frac{N}{2} \ge r \quad\Longrightarrow\quad N \ge 2r.\) Example: $r=128$ bits quantum security requires $N\ge 256$ (i.e. $n\ge 32$ bytes).
Side-channel and domain-separation notes (mathematical): denote distinct hash primitives for different roles using tags $\tau_{\mathrm{PRF}},\tau_{\mathrm{chain}},\tau_{\mathrm{leaf}},\tau_{\mathrm{tree}}$. Define role-specific hashes \(H_{\tau}(x) := H\big(\tau\,\|\,x\big)\) so that each use of the hash/XOF is provably separated in proofs and avoids cross-role collisions.
Summary: the formulas above let you compute expected sizes for any parameter choice $(N,w,k,a,L,{h_i})$ and reason about signing cost (chain evaluations) versus signature size (number of hash outputs transmitted). Use library-provided parameter profiles (SPHINCS+ presets) to avoid error-prone manual tuning.