Skip to contents

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", a bricklayer_signing_key from pqckg() for "xmss", or a bricklayer_fips_key from fipskg() for a standardised scheme (in which case scheme is taken from the key and ignored), or a key from fipsmkg(), which signs inside the FIPS 140-3 module that made it.

scheme

"xmss" (post-quantum, asymmetric) or "hmac" (symmetric). Inferred from key when 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, FALSE for the plain constant-time one; both give the same signature for the same randomness. The option rmoriebricklayer.masked sets 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 with options(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.

See also

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)