Authenticates msg – normally a manifest digest, or the whole
manifest text – so a verifier can tell that it came from the holder of
the key and has not been altered since. A digest alone shows that data
were not corrupted; a signature also shows who vouched for them, because
only the key holder can make it. Verify with
cpsvrf().
Usage
cpssign(
msg,
key,
scheme = NULL,
ctx = NULL,
determ = FALSE,
prehash = "none",
masked = getOption("rmoriebricklayer.masked", TRUE)
)Arguments
- msg
Length-1 character vector (or raw vector) to sign.
- key
A shared secret (character/raw) for
"hmac", abricklayer_signing_keyfrompqckg()for"xmss", or abricklayer_fips_keyfromfipskg()for a standardised scheme (in which caseschemeis taken from the key and ignored), or a key fromfipsmkg(), which signs inside the FIPS 140-3 module that made it.- scheme
"xmss"(post-quantum, asymmetric) or"hmac"(symmetric). Inferred fromkeywhen not given.- ctx
Optional context string (character or raw, at most 255 bytes) for the standardised schemes, and ignored by the others. FIPS 204 and FIPS 205 both bind it into the message encoding, so a signature made under one context does not verify under another – which is how the same key is safely used for two purposes.
- determ
For the standardised schemes, use the deterministic variant rather than drawing fresh randomness per signature. The signature then depends only on the key, message and context, which is what the standards' test vectors rely on.
- prehash
For the standardised schemes, sign a digest of the message rather than the message itself – HashML-DSA (FIPS 204 section 5.4) or HashSLH-DSA (FIPS 205 section 10.2.2). One of
"none"(the default, the pure variants) or any hash function the two standards approve. The SHA-2 hashes are"sha224","sha256","sha384","sha512","sha512_224"and"sha512_256"; the SHA-3 hashes are"sha3_224","sha3_256","sha3_384"and"sha3_512"; the SHAKE ones are"shake128"(256-bit output) and"shake256"(512-bit output). The identifier of the pre-hash is bound into the signature, so a pre-hashed signature is never interchangeable with a pure one over the same digest.- masked
TRUE(the default) for the first-order masked ML-DSA signer,FALSEfor the plain constant-time one; both give the same signature for the same randomness. The optionrmoriebricklayer.maskedsets the default for a session, so chain- and bundle-heavy code that has no physical attacker in its threat model can take the plain path everywhere withoptions(rmoriebricklayer.masked = FALSE). SLH-DSA and XMSS signing are not masked and ignore it. Masking costs about 2.3 to 3.4 times the plain time on ML-KEM decapsulation (4 ms for ML-KEM-768 on this package's test machine) and 7 to 10 times on ML-DSA signing (26 ms for ML-DSA-65), depending on the CPU.
Value
A list of class bricklayer_signature: scheme,
signature, and for XMSS also auth, index,
root, height, randomizer (the
per-signature R of RFC 8391, which a verifier needs and which
therefore travels with the signature) and wire – the signature
in the RFC's own byte order, index || R || WOTS || auth,
hex-encoded. wire is byte-identical to what the XMSS reference
implementation produces from the same key material, so it can be handed
to another implementation as bytes.
Details
Three kinds of key are accepted: a shared secret (HMAC), an XMSS key from
pqckg(), or a standardised ML-DSA or SLH-DSA key from
fipskg(). For new work the standardised keys are the
simplest:
they sign any number of messages with no state to keep.
With a standardised key the signature is FIPS 204 ML-DSA.Sign or
FIPS 205 slh_sign over the message with its context (hedged by
default, deterministic on request). ML-DSA signs in milliseconds;
SLH-DSA from a few hundred milliseconds (the f sets) to several
seconds (the s sets at 192 and 256 bits); an XMSS signature rebuilds
the key's tree, about 3 s at the default height 10. Slow signatures show
a spinner on stderr after 0.75 s when a person is watching (an
interactive session or options(morie.progress = TRUE);
options(morie.quiet = TRUE) silences it), and Ctrl-C stops them.
Refusals: a message that is not one string or a raw vector; a key of the
wrong kind for the scheme; a context over 255 bytes; an unknown pre-hash;
an exhausted XMSS key, an index the used-index record shows as used, a
key whose record cannot be read or written, or an XMSS key the record
does not know (see xmssadv() and
xmssregkey()).
With scheme = "hmac" the key is a shared secret string and
the result is an HMAC-SHA-256 tag. Symmetric, so anyone who can verify
can also sign.
With scheme = "xmss" the key is a
pqckg() object and the result is a
post-quantum one-time signature under the key's Merkle root. Asymmetric:
a verifier holding only the public root cannot forge.
The key must be advanced after every signature
An XMSS signature consumes a leaf, and subsequent signing must use the
advanced key – reusing an index breaks the scheme. The signature object
carries no key material (it is what verifiers are handed); the advanced
key is xmssadv(key, signature), and that is what to save. The key is
an R value, so cpssign() cannot advance the caller's copy; instead it
records, in the session and in the index file described under
xmssadv(),
the highest index each key has signed with, and
refuses that index or an earlier one. Passing an exhausted key is an error,
not a silent wrap-around.
Security
The implementation is hand-written. Its standardised schemes are checked
byte for byte against OpenSSL 3.5 and NIST known-answer vectors in the test
suite. Side channels are checked, not assumed: inst/ctcheck runs every
operation that touches a secret under valgrind memcheck with the secret
marked undefined (the ctgrind method), so a branch or a memory address
that depends on it is a reported error, and afterwards scans the dead
stack for copies of the secret. Both checks run in CI on every change,
with GCC and with Clang. Timing is also measured on x86-64 and arm64
hardware (inst/dudect), power leakage is assessed in simulation under
the value and the transition models (inst/tvla), and ML-KEM
decapsulation and ML-DSA signing are first-order masked by default. These
are checks of this code on those compilers and under those leakage
models; the README's security section lists what each check does and
does not cover.
Examples
# keep this example's
op <- options(rmoriebricklayer.xmssidxfile = tempfile())
# one-time index record out of your configuration directory; leave the
# default in use Symmetric: one shared secret.
sig <- cpssign("sha256:abc123", key = "shared-secret", scheme = "hmac")
cpsvrf("sha256:abc123", sig, "shared-secret")
#> [1] TRUE
cpsvrf("sha256:TAMPERED", sig, "shared-secret")
#> [1] FALSE
# Post-quantum: the verifier needs only the public root.
key <- pqckg(height = 2)
s1 <- cpssign("manifest-1", key)
cpsvrf("manifest-1", s1, signpubkey(key))
#> [1] TRUE
# Advance the key past the signature before signing again.
key <- xmssadv(key, s1)
key$next_index
#> [1] 1
s2 <- cpssign("manifest-2", key)
cpsvrf("manifest-2", s2, signpubkey(key))
#> [1] TRUE
# A signature does not transfer to another message.
cpsvrf("manifest-1", s2, signpubkey(key))
#> [1] FALSE
# A standardised key: no state, any number of signatures.
fk <- fipskg("ML-DSA-44")
s3 <- cpssign("manifest-3", fk, ctx = "release", determ = TRUE)
cpsvrf("manifest-3", s3, fipspubkey(fk), ctx = "release")
#> [1] TRUE
# An XMSS signature needs an XMSS key.
try(cpssign("manifest-4", "shared-secret", scheme = "xmss"))
#> Error : `scheme = "xmss"` needs a key from pqckg()
options(op)
