From 693ba50d8f9c392efb404c4488d53537d69e4444 Mon Sep 17 00:00:00 2001 From: Falko Strenzke Date: Wed, 15 Apr 2026 16:39:45 +0200 Subject: [PATCH] add ML-DSA signature context --- doc/api_ref/pubkey.rst | 12 +- .../dilithium/dilithium_common/dilithium.cpp | 107 ++++++++++++++++-- .../dilithium_symmetric_primitives.cpp | 6 +- .../dilithium_symmetric_primitives.h | 10 +- src/lib/pubkey/dilithium/ml_dsa/ml_dsa_impl.h | 5 +- src/tests/test_dilithium.cpp | 62 ++++++++++ 6 files changed, 181 insertions(+), 21 deletions(-) diff --git a/doc/api_ref/pubkey.rst b/doc/api_ref/pubkey.rst index 28cbc28f811..c16cf27148c 100644 --- a/doc/api_ref/pubkey.rst +++ b/doc/api_ref/pubkey.rst @@ -1039,7 +1039,17 @@ Botan implements the following signature algorithms: - ``,`` #. ML-DSA (Dilithium). - Takes the optional parameter ``Deterministic`` (default) or ``Randomized``. + Takes and optional parameter string formed by comma-separated list of the following format ``[(Deterministic|Randomized),][Pure,][ctx_hex=]``, where the ordering of the comma-separated elements is arbitrary. The specification of ``Pure`` is redundant since currently only the pure variant of ML-DSA is implemented. The defaults of the other values are ``Randomized`` and an empty context parameter (`ctx_hex`). Non-empty context parameters are only supported by ML-DSA, but not by Dilithium. ``Deterministic`` or ``Randomized`` may also be specified in a verification operation thought it has no effect there. + +#. ML-DSA-composite (draft-ietf-lamps-pq-composite-sigs-15). + Takes no parameters. The following algorithms are defined and are accessed by the respective + string value: MLDSA44-RSA2048-PKCS15-SHA256, MLDSA65-RSA3072-PKCS15-SHA512, + MLDSA65-RSA4096-PKCS15-SHA512, MLDSA44-RSA2048-PSS-SHA256, MLDSA65-RSA3072-PSS-SHA512, + MLDSA65-RSA4096-PSS-SHA512, MLDSA87-RSA3072-PSS-SHA512, MLDSA87-RSA4096-PSS-SHA512, + MLDSA44-ECDSA-P256-SHA256, MLDSA65-ECDSA-P256-SHA512, MLDSA65-ECDSA-P384-SHA512, + MLDSA65-ECDSA-brainpoolP256r1-SHA512, MLDSA87-ECDSA-P384-SHA512, + MLDSA87-ECDSA-brainpoolP384r1-SHA512, MLDSA87-ECDSA-P521-SHA512, MLDSA44-Ed25519-SHA512, + MLDSA65-Ed25519-SHA512, MLDSA87-Ed448-SHAKE256 #. SLH-DSA. Takes the optional parameter ``Deterministic`` (default) or ``Randomized``. #. XMSS. Takes no parameter. diff --git a/src/lib/pubkey/dilithium/dilithium_common/dilithium.cpp b/src/lib/pubkey/dilithium/dilithium_common/dilithium.cpp index 05ec51410d4..4779c4db4de 100644 --- a/src/lib/pubkey/dilithium/dilithium_common/dilithium.cpp +++ b/src/lib/pubkey/dilithium/dilithium_common/dilithium.cpp @@ -8,6 +8,7 @@ * (C) 2021-2022 Manuel Glaser - Rohde & Schwarz Cybersecurity * (C) 2021-2023 Michael Boric, René Meusel - Rohde & Schwarz Cybersecurity * (C) 2024 René Meusel - Rohde & Schwarz Cybersecurity +* (C) 2026 Falko Strenzke – MTG AG * * Botan is released under the Simplified BSD License (see license.txt) */ @@ -15,17 +16,20 @@ #include #include +#include #include - #include #include #include #include #include #include +#include #include #include +#include + namespace Botan { namespace { @@ -61,6 +65,84 @@ DilithiumMode::Mode dilithium_mode_from_string(std::string_view str) { throw Invalid_Argument(fmt("'{}' is not a valid Dilithium mode name", str)); } +class MLDSA_Signing_Parameters { + public: + template + class Once_Settable { + public: + explicit Once_Settable(const T& default_value) : m_is_set(false), m_value(default_value) {} + + void set_value(const T& value) { + if(m_is_set) { + throw Invalid_Argument( + "signature/verification parameter contains value for same key (at least) twice"); + } + m_is_set = true; + m_value = value; + } + + const T& value() const { return m_value; } + + private: + bool m_is_set; + T m_value; + }; + + explicit MLDSA_Signing_Parameters(std::string_view param_str) : m_rndmzd(true), m_ctx({}) { + const char* error_tmpl = "Parameter string '{}' is not a valid ML-DSA or Dilithium parameter specification"; + if(param_str.empty()) { + // return the defaults + return; + } + const auto v = split_on(param_str, ','); + for(const auto& y : v) { + if(y == "Pure") { + continue; + } + if(y == "Deterministic") { + m_rndmzd.set_value(false); + continue; + } + if(y == "Randomized") { + m_rndmzd.set_value(true); + continue; + } + const char* ctx_key = "ctx_hex"; + const size_t prefix_len = std::char_traits::length(ctx_key); + if(y.starts_with(ctx_key)) { + if((y.size() > prefix_len && y[prefix_len] != '=')) { + throw Invalid_Argument(fmt(error_tmpl), param_str); + } + std::string z; + // check whether a non-empty context parameter string was specified + if(y.size() > prefix_len + 1) { + // no whitespace allowed + if(y.find(' ') != std::string::npos) { + throw Invalid_Argument(fmt(error_tmpl), param_str); + } + // get the value of the hex-encoded context string + z = y.substr(prefix_len + 1); + } + m_ctx.set_value(hex_decode(z)); + continue; + } + throw Invalid_Argument(Botan::fmt("invalid token parameter string for ML-DSA/Dilithium: ", y)); + } + } + + bool is_pure_signing() const { return m_pure_sign; } + + bool is_randomized_signing() const { return m_rndmzd.value(); } + + const std::vector& user_context() const { return m_ctx.value(); } + + private: + Once_Settable m_rndmzd; + Once_Settable> m_ctx; + + const bool m_pure_sign = true; +}; + } // namespace DilithiumMode::DilithiumMode(const OID& oid) : m_mode(dilithium_mode_from_string(oid.to_formatted_string())) {} @@ -129,10 +211,12 @@ bool DilithiumMode::is_available() const { class Dilithium_Signature_Operation final : public PK_Ops::Signature { public: - Dilithium_Signature_Operation(DilithiumInternalKeypair keypair, bool randomized) : + Dilithium_Signature_Operation(DilithiumInternalKeypair keypair, + bool randomized, + std::span user_context = {}) : m_keypair(std::move(keypair)), m_randomized(randomized), - m_h(m_keypair.second->mode().symmetric_primitives().get_message_hash(m_keypair.first->tr())), + m_h(m_keypair.second->mode().symmetric_primitives().get_message_hash(m_keypair.first->tr(), user_context)), m_s1(ntt(m_keypair.second->s1().clone())), m_s2(ntt(m_keypair.second->s2().clone())), m_t0(ntt(m_keypair.second->t0().clone())), @@ -249,11 +333,12 @@ class Dilithium_Signature_Operation final : public PK_Ops::Signature { class Dilithium_Verification_Operation final : public PK_Ops::Verification { public: - explicit Dilithium_Verification_Operation(std::shared_ptr pubkey) : + explicit Dilithium_Verification_Operation(std::shared_ptr pubkey, + std::span user_context = {}) : m_pub_key(std::move(pubkey)), m_A(Dilithium_Algos::expand_A(m_pub_key->rho(), m_pub_key->mode())), m_t1_ntt_shifted(ntt(m_pub_key->t1() << DilithiumConstants::D)), - m_h(m_pub_key->mode().symmetric_primitives().get_message_hash(m_pub_key->tr())) {} + m_h(m_pub_key->mode().symmetric_primitives().get_message_hash(m_pub_key->tr(), user_context)) {} void update(std::span input) override { m_h->update(input); } @@ -382,9 +467,9 @@ std::unique_ptr Dilithium_PublicKey::generate_another(RandomNumberG std::unique_ptr Dilithium_PublicKey::create_verification_op(std::string_view params, std::string_view provider) const { - BOTAN_ARG_CHECK(params.empty() || params == "Pure", "Unexpected parameters for verifying with Dilithium"); + const MLDSA_Signing_Parameters sig_par(params); if(provider.empty() || provider == "base") { - return std::make_unique(m_public); + return std::make_unique(m_public, sig_par.user_context()); } throw Provider_Not_Found(algo_name(), provider); } @@ -438,17 +523,15 @@ std::unique_ptr Dilithium_PrivateKey::create_signature_op(Ran std::string_view params, std::string_view provider) const { BOTAN_UNUSED(rng); - - BOTAN_ARG_CHECK(params.empty() || params == "Deterministic" || params == "Randomized", - "Unexpected parameters for signing with ML-DSA/Dilithium"); + const MLDSA_Signing_Parameters sig_par(params); // FIPS 204, Section 3.4 // By default, this standard specifies the signing algorithm to use both // types of randomness [fresh from the RNG and a value in the private key]. // This is referred to as the “hedged” variant of the signing procedure. - const bool randomized = (params.empty() || params == "Randomized"); if(provider.empty() || provider == "base") { - return std::make_unique(DilithiumInternalKeypair{m_public, m_private}, randomized); + return std::make_unique( + DilithiumInternalKeypair{m_public, m_private}, sig_par.is_randomized_signing(), sig_par.user_context()); } throw Provider_Not_Found(algo_name(), provider); } diff --git a/src/lib/pubkey/dilithium/dilithium_common/dilithium_symmetric_primitives.cpp b/src/lib/pubkey/dilithium/dilithium_common/dilithium_symmetric_primitives.cpp index 3a2aec6e798..1b5aaa9fe1b 100644 --- a/src/lib/pubkey/dilithium/dilithium_common/dilithium_symmetric_primitives.cpp +++ b/src/lib/pubkey/dilithium/dilithium_common/dilithium_symmetric_primitives.cpp @@ -27,8 +27,10 @@ namespace Botan { -DilithiumMessageHash::DilithiumMessageHash(DilithiumHashedPublicKey tr) : - m_tr(std::move(tr)), m_shake(XOF::create_or_throw("SHAKE-256")) {} +DilithiumMessageHash::DilithiumMessageHash(DilithiumHashedPublicKey tr, std::span user_context) : + m_tr(std::move(tr)), + m_user_context(user_context.begin(), user_context.end()), + m_shake(XOF::create_or_throw("SHAKE-256")) {} DilithiumMessageHash::~DilithiumMessageHash() = default; diff --git a/src/lib/pubkey/dilithium/dilithium_common/dilithium_symmetric_primitives.h b/src/lib/pubkey/dilithium/dilithium_common/dilithium_symmetric_primitives.h index d3a1c5533eb..8fb891bdd39 100644 --- a/src/lib/pubkey/dilithium/dilithium_common/dilithium_symmetric_primitives.h +++ b/src/lib/pubkey/dilithium/dilithium_common/dilithium_symmetric_primitives.h @@ -27,7 +27,7 @@ class RandomNumberGenerator; */ class DilithiumMessageHash /* NOLINT(*-special-member-functions) */ { public: - explicit DilithiumMessageHash(DilithiumHashedPublicKey tr); + explicit DilithiumMessageHash(DilithiumHashedPublicKey tr, std::span user_context = {}); virtual ~DilithiumMessageHash(); @@ -65,12 +65,13 @@ class DilithiumMessageHash /* NOLINT(*-special-member-functions) */ { void ensure_started() { if(!m_was_started) { // FIPS 204, page 17, footnote 4: By default, the context is the empty string [...] - start({}); + start(m_user_context); } } private: DilithiumHashedPublicKey m_tr; + std::vector m_user_context; bool m_was_started = false; std::unique_ptr m_shake; }; @@ -106,8 +107,9 @@ class Dilithium_Symmetric_Primitives_Base { Dilithium_Symmetric_Primitives_Base(Dilithium_Symmetric_Primitives_Base&&) = delete; Dilithium_Symmetric_Primitives_Base& operator=(Dilithium_Symmetric_Primitives_Base&&) = delete; - virtual std::unique_ptr get_message_hash(DilithiumHashedPublicKey tr) const { - return std::make_unique(std::move(tr)); + virtual std::unique_ptr get_message_hash(DilithiumHashedPublicKey tr, + std::span user_context) const { + return std::make_unique(std::move(tr), user_context); } /// Computes the private random seed rho prime used for signing diff --git a/src/lib/pubkey/dilithium/ml_dsa/ml_dsa_impl.h b/src/lib/pubkey/dilithium/ml_dsa/ml_dsa_impl.h index 7dee2e8146f..ac87e38f48d 100644 --- a/src/lib/pubkey/dilithium/ml_dsa/ml_dsa_impl.h +++ b/src/lib/pubkey/dilithium/ml_dsa/ml_dsa_impl.h @@ -85,8 +85,9 @@ class ML_DSA_Symmetric_Primitives final : public Dilithium_Symmetric_Primitives_ return seed; } - std::unique_ptr get_message_hash(DilithiumHashedPublicKey tr) const override { - return std::make_unique(std::move(tr)); + std::unique_ptr get_message_hash(DilithiumHashedPublicKey tr, + std::span user_context) const override { + return std::make_unique(std::move(tr), user_context); } std::optional> seed_expansion_domain_separator() const override { diff --git a/src/tests/test_dilithium.cpp b/src/tests/test_dilithium.cpp index 06d527347bc..83039ce22c3 100644 --- a/src/tests/test_dilithium.cpp +++ b/src/tests/test_dilithium.cpp @@ -16,6 +16,7 @@ #include #include #include + #include #include "test_pubkey.h" #include "test_rng.h" @@ -288,4 +289,65 @@ BOTAN_REGISTER_TEST("pubkey", "dilithium_keygen", Dilithium_Keygen_Tests); } // namespace +#if defined(BOTAN_HAS_DILITHIUM_COMMON) + +class MLDSA_Param_Tests final : public Test { + public: + struct Test_Case { + bool expect_success; + std::string param; + bool randomized_signature; + }; + + std::vector run() override { + const std::string test_name = "MLDSA_Param_Tests"; + std::vector results; + auto rng = Test::new_rng(test_name); + + const std::string msg = "The quick brown fox jumps over the lazy dog."; + const std::vector msgvec(msg.data(), msg.data() + msg.size()); + + const Botan::Dilithium_PrivateKey priv_key(*rng, Botan::DilithiumMode::ML_DSA_4x4); + + const std::vector sign_params = { + Test_Case{true, std::string(""), true}, + Test_Case{true, std::string("Randomized,Pure"), true}, + Test_Case{true, std::string("Deterministic,ctx_hex=00AABB"), false}, + Test_Case{false, std::string("ctx_hex=,ctx_hex="), true}, + Test_Case{false, std::string("Randomized,Deterministic"), false}, + Test_Case{false, std::string("ctx=AA"), true}, + Test_Case{false, std::string("X"), true}, + Test_Case{false, std::string("ctx=A"), true}}; + + for(const auto& [expect_success, param_str, random_sig] : sign_params) { + Test::Result result(Botan::fmt("{}: param string = '{}'", test_name, param_str)); + std::unique_ptr signer; + bool exc = false; + try { + signer = std::make_unique(Botan::PK_Signer(priv_key, *rng, param_str)); + } catch(Botan::Exception&) { + exc = true; + } + result.test_bool_eq( + Botan::fmt("parameters string '{}' for ML-DSA op was valid", param_str), !exc && signer, expect_success); + if(exc) { + results.push_back(result); + continue; + } + auto verifier = Botan::PK_Verifier(*priv_key.public_key(), param_str); + auto signature = signer->sign_message(msgvec, *rng); + auto signature2 = signer->sign_message(msgvec, *rng); + result.test_bool_eq( + Botan::fmt("signature randomization (param = '{}')", param_str), signature != signature2, random_sig); + result.test_is_true("signature verification", verifier.verify_message(msgvec, signature)); + results.push_back(result); + } + + return results; + } +}; + +BOTAN_REGISTER_TEST("pubkey", "mldsa_op_params", MLDSA_Param_Tests); +#endif + } // namespace Botan_Tests