
Post-quantum signatures: ML-DSA, SLH-DSA and XMSS
Source:vignettes/post-quantum-signatures.Rmd
post-quantum-signatures.RmdA capsule is only as trustworthy as the signature on its manifest.
rmoriebricklayer implements the three NIST post-quantum
signature standards natively, with no system cryptography library:
ML-DSA (FIPS 204, lattice-based),
SLH-DSA (FIPS 205, hash-based, stateless) and
XMSS (RFC 8391, hash-based, stateful). All three are
used through the same two calls, capsule_sign() and
capsule_verify().
Why post-quantum, and why three schemes
A signature made today may need to verify in twenty years, when a large quantum computer could break RSA and ECDSA with Shor’s algorithm. The three schemes rest on different assumptions, so a break of one does not take the others:
- ML-DSA (Module-Lattice Digital Signature Algorithm, formerly Dilithium) rests on the hardness of Module-LWE and Module-SIS. Small keys and signatures, fast. The default.
- SLH-DSA (Stateless Hash-based, formerly SPHINCS+) rests only on the security of a hash function. Conservative; its signatures are large.
- XMSS is also hash-based but stateful: every key signs a fixed number of times and an index must never be reused. The package carries the state forward for you.
ML-DSA: the default
key <- fips_keygen("ML-DSA-65")
key$scheme
#> [1] "ML-DSA-65"
sig <- capsule_sign("a manifest digest", key)
capsule_verify("a manifest digest", sig, fips_public_key(key))
#> [1] TRUEThe verifier needs only the public key. Any change to the message is caught:
capsule_verify("a manifest digest (edited)", sig, fips_public_key(key))
#> [1] FALSEContext strings bind a signature to its purpose
FIPS 204 lets a signature carry a context: a short string hashed in with the message. The same message signed for one purpose does not verify under another, which stops a signature made for “staging” being replayed as a “release” approval:
sig2 <- capsule_sign("a manifest digest", key, context = "release")
capsule_verify("a manifest digest", sig2, key, context = "release")
#> [1] TRUE
capsule_verify("a manifest digest", sig2, key, context = "staging")
#> [1] FALSEHedged and deterministic signing
By default ML-DSA signing is hedged: fresh randomness enters
every signature, so two signatures of one message differ (FIPS 204
recommends this; it also protects against fault attacks on the nonce).
deterministic = TRUE gives the variant where the signature
depends only on the key and the message:
s1 <- capsule_sign("same message", key, deterministic = TRUE)
s2 <- capsule_sign("same message", key, deterministic = TRUE)
identical(s1$signature, s2$signature)
#> [1] TRUE
h1 <- capsule_sign("same message", key)
h2 <- capsule_sign("same message", key)
identical(h1$signature, h2$signature)
#> [1] FALSESigning a digest computed elsewhere
ML-DSA signs mu = H(tr || M'), a digest of the message
that depends on the public key but not on the secret.
fips_mu() computes it from the public key, so the
machine holding the secret never needs to see the message:
mu <- fips_mu(fips_public_key(key), "a manifest digest", context = "release")
sig3 <- fips_sign_mu(key, mu)
fips_verify_mu(key, mu, sig3)
#> [1] TRUE
# the same signature verifies the ordinary way
capsule_verify("a manifest digest", sig3, key, context = "release")
#> [1] TRUESince 0.5.10, ML-DSA signing runs first-order masked by default (see the Side-channel assurance vignette): every secret-dependent value is held as two random shares. The signatures are byte-identical to the unmasked computation; only the cost differs.
SLH-DSA: hash-based, stateless
fips_sizes("ML-DSA-65")
#> public_key secret_key signature seed opt_rand
#> 1952 4032 3309 32 32
fips_sizes("SLH-DSA-SHAKE-128f")
#> public_key secret_key signature seed opt_rand
#> 32 64 17088 48 16The price of dropping the lattice assumption is size: an SLH-DSA signature is far larger than an ML-DSA signature at the same security level.
fips_sizes("SLH-DSA-SHAKE-128f")[["signature"]] > fips_sizes("ML-DSA-44")[["signature"]]
#> [1] TRUEThe calls are identical:
hkey <- fips_keygen("SLH-DSA-SHAKE-128f")
hsig <- capsule_sign("a manifest digest", hkey)
capsule_verify("a manifest digest", hsig, fips_public_key(hkey))
#> [1] TRUEPre-hash variants
HashML-DSA and HashSLH-DSA sign a digest of the message instead of the message itself, for very large inputs or when the message is hashed on another machine. The hash function’s OID is bound into the signature, so two digests of the same length never verify for each other:
p <- capsule_sign("a large artifact", key, prehash = "sha512")
capsule_verify("a large artifact", p, key, prehash = "sha512")
#> [1] TRUE
capsule_verify("a large artifact", p, key, prehash = "sha3_512")
#> [1] FALSEXMSS: hash-based, stateful
An XMSS key of height h signs 2^h messages. The package
stores the next index in the key and
xmss_advance(key, signature) gives the advanced key after
every signature; reusing an index would break security, so always
advance the key with xmss_advance() and save that:
xkey <- pqc_keygen(height = 3)
xkey$capacity
#> [1] 8
xkey$next_index
#> [1] 0
s1 <- capsule_sign("manifest-1", xkey)
capsule_verify("manifest-1", s1, signing_public_key(xkey))
#> [1] TRUE
xkey <- xmss_advance(xkey, s1)
xkey$next_index
#> [1] 1
s2 <- capsule_sign("manifest-2", xkey)
capsule_verify("manifest-2", s2, signing_public_key(xkey))
#> [1] TRUE
# a signature does not transfer to another message
capsule_verify("manifest-1", s2, signing_public_key(xkey))
#> [1] FALSEThe key is a value, so an old copy of it still says an old
next_index. The package therefore also keeps a record of
the highest index each key has used on this machine
(?xmss_advance says where), and refuses a used index even
from a stale copy. A key generated here is entered at birth; a key from
another machine, one rebuilt from its seeds, or one used in a CI job or
a container with a fresh home directory is unknown to the record and
must be vouched for once with xmss_register_key(key) before
it signs from index 0.
Every way of being wrong returns FALSE
Verification returns FALSE for every signature of the
right shape that does not verify: a truncated or edited signature, a
foreign key and an edited message all fail the same way. (An object that
is not a signature at all, a key of the wrong class or a missing message
is an error, because that is a programming mistake rather than a
tampered artefact.)
pub <- fips_public_key(key)
bad <- sig
bad$signature <- paste0("ff", substring(bad$signature, 3))
capsule_verify("a manifest digest", bad, pub)
#> [1] FALSE
trunc <- sig
trunc$signature <- substring(sig$signature, 1, 64)
capsule_verify("a manifest digest", trunc, pub)
#> [1] FALSE
capsule_verify("a manifest digest", sig, fips_public_key(fips_keygen("ML-DSA-65")))
#> [1] FALSEHow these implementations are checked
ML-DSA and SLH-DSA are compared byte for byte with OpenSSL 3.5 and
run against the NIST ACVP and C2SP Wycheproof vector sets on every
change (6,848 checks, 0 failures at 0.5.10), and fuzzed differentially
against OpenSSL; XMSS, which OpenSSL does not implement, is checked
against the RFC 8391 vectors. Every signer runs under valgrind with the
secret key marked undefined, a dynamic check that no executed branch or
memory address depends on it. SECURITY.md in the package
repository has the full table of what each check catches and what it
does not.