A 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, cpssign()
and cpsvrf().
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:
key <- fipskg("ML-DSA-65")
key$scheme
#> [1] "ML-DSA-65"
sig <- cpssign("a manifest digest", key)
cpsvrf("a manifest digest", sig, fipspubkey(key))
#> [1] TRUEThe verifier needs only the public key. Any change to the message is caught:
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:
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:
ML-DSA signs mu = H(tr || M'), a digest of the message
that depends on the public key but not on the secret.
fipsmu() computes it from the public key, so the
machine holding the secret never needs to see the message:
mu <- fipsmu(fipspubkey(key), "a manifest digest", ctx = "release")
sig3 <- fipssignmu(key, mu)
fipsvrfmu(key, mu, sig3)
#> [1] TRUE
# the same signature verifies the ordinary way
cpsvrf("a manifest digest", sig3, key, ctx = "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.
fipssz("ML-DSA-65")
#> public_key secret_key signature seed rnd
#> 1952 4032 3309 32 32
fipssz("SLH-DSA-SHAKE-128f")
#> public_key secret_key signature seed rnd
#> 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.
The calls are identical:
hkey <- fipskg("SLH-DSA-SHAKE-128f")
hsig <- cpssign("a manifest digest", hkey)
cpsvrf("a manifest digest", hsig, fipspubkey(hkey))
#> [1] TRUEHashML-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:
An XMSS key of height h signs 2^h messages. The package
stores the next index in the key and
xmssadv(key, signature) gives the advanced key after every
signature; reusing an index would break security, so always advance the
key with xmssadv() and save that:
xkey <- pqckg(height = 3)
xkey$capacity
#> [1] 8
xkey$next_index
#> [1] 0
s1 <- cpssign("manifest-1", xkey)
cpsvrf("manifest-1", s1, signpubkey(xkey))
#> [1] TRUE
xkey <- xmssadv(xkey, s1)
xkey$next_index
#> [1] 1
s2 <- cpssign("manifest-2", xkey)
cpsvrf("manifest-2", s2, signpubkey(xkey))
#> [1] TRUE
# a signature does not transfer to another message
cpsvrf("manifest-1", s2, signpubkey(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
(?xmssadv 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 xmssregkey(key) before it signs
from index 0.
RFC 10033 (Hash-Based Signatures: State and Backup
Management) separates the private key, which never changes, from
the state, the indices already used, which must be updated and saved
before a signature is released, and warns that a copied or restored
state signs used indices again. rmbl sign follows it: with
--scheme xmss-sha256 --key-file key.json the key file is
the state. It is rewritten atomically, with next_index
advanced and a used field, before any bundle is written,
and the run signs from the indices it reserved.
root <- tempfile("capsules-")
for (nm in c("one", "two")) {
d <- file.path(root, nm)
dir.create(d, recursive = TRUE)
utils::write.csv(data.frame(x = 1:3), file.path(d, "data.csv"), row.names = FALSE)
writeLines('{"resource": {"filename": "data.csv"}}', file.path(d, "data_provenance.json"))
}
kf <- tempfile(fileext = ".json")
writeLines(tojs(unclass(pqckg(height = 4)), auto_unbox = TRUE), kf)
copy <- tempfile(fileext = ".json")
invisible(file.copy(kf, copy)) # an old copy of the key, kept by mistake
status <- blcli(c("sign", root, "--scheme", "xmss-sha256", "--key-file", kf, "--json"),
out = function(s) NULL)
status
#> [1] 0
state <- fromjs(paste(readLines(kf), collapse = "\n"))
state$next_index # two capsules signed, two indices used
#> [1] 2
state$used$generation
#> [1] 1
# the old copy is refused: the bundles show indices it does not know are used
blcli(c("sign", root, "--scheme", "xmss-sha256", "--key-file", copy, "--overwrite"),
out = function(s) cat(s[grepl("stale", s)]))
#> rmoriebricklayer: stale copy of the key: index 1 of it was already signed (seen in /tmp/RtmpCRYZtI/capsules-1ce25f2efc00/two/capsule_bundle.json), but this state says the highest used in its range is -1. Signing would reuse an index: use the copy that made that signature, or generate a new key
xmssidxseen(root)[, c("index", "reused")]
#> index reused
#> 1 0 FALSE
#> 2 1 FALSE--state-file keeps the state in a file of its own.
--sectors K --sector-index I splits the indices into K
disjoint ranges so that K signers can share one key, each in its own
range (RFC 10033, section 5.3), and --reserve N marks N
indices used in one write and signs from them (section 5.9; the ones a
run does not use are lost). A key kept as a CI secret is a fixed copy
that every job starts from, so every job would sign the same indices: in
CI, sign with ML-DSA unless the XMSS state lives in a file that persists
between jobs. NIST SP 800-208 approves XMSS only inside a hardware
module that never exports the key, which a key file is not.
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 <- fipspubkey(key)
bad <- sig
bad$signature <- paste0("ff", substring(bad$signature, 3))
cpsvrf("a manifest digest", bad, pub)
#> [1] FALSE
trunc <- sig
trunc$signature <- substring(sig$signature, 1, 64)
cpsvrf("a manifest digest", trunc, pub)
#> [1] FALSE
cpsvrf("a manifest digest", sig, fipspubkey(fipskg("ML-DSA-65")))
#> [1] FALSEML-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.
A signature lets anyone check that a release came from its publisher and has not changed:
What a signature costs on the wire, from fipssz(): the
public key a verifier keeps and the signature attached to each release,
in bytes, for ML-DSA and two SLH-DSA sets. The scale is logarithmic,
because an SLH-DSA signature is several times larger.
sch <- c("ML-DSA-44", "ML-DSA-65", "ML-DSA-87", "SLH-DSA-SHA2-128f", "SLH-DSA-SHAKE-128s")
sz <- t(vapply(sch, function(s) fipssz(s)[c("public_key", "signature")], numeric(2)))
op <- par(mar = c(9, 5, 3, 1))
barplot(t(sz), beside = TRUE, log = "y", ylim = c(10, 2 * max(sz)), col = blpal()[1:2],
ylab = "bytes (log scale)", las = 2, cex.names = 0.8, legend.text = c("public key", "signature"),
args.legend = list(x = "topleft", bty = "n"), main = "Key and signature sizes")
#> Warning in yinch(0.1): y log scale: yinch() is nonsense