From 1c034b20d8ec5935263336b88ddcd1e28f0cdd18 Mon Sep 17 00:00:00 2001 From: Martijn de Milliano Date: Tue, 24 Mar 2026 21:22:39 +0100 Subject: [PATCH 1/2] ML-DSA: Support (re-)generating MlDsaPrivate from seed As specified in FIPS 204, implementations can store the seed from which the key can be deterministically generated. --- scripts/build_ffi.py | 1 + tests/test_mldsa.py | 34 ++++++++++++++++++++++++++++++++++ wolfcrypt/ciphers.py | 25 +++++++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/scripts/build_ffi.py b/scripts/build_ffi.py index 4d5ad22..d0ff8b2 100644 --- a/scripts/build_ffi.py +++ b/scripts/build_ffi.py @@ -1026,6 +1026,7 @@ def build_ffi(local_wolfssl, features): int wc_dilithium_set_level(dilithium_key* key, byte level); void wc_dilithium_free(dilithium_key* key); int wc_dilithium_make_key(dilithium_key* key, WC_RNG* rng); + int wc_dilithium_make_key_from_seed(dilithium_key* key, const byte* seed); int wc_dilithium_export_private(dilithium_key* key, byte* out, word32* outLen); int wc_dilithium_import_private(const byte* priv, word32 privSz, dilithium_key* key); int wc_dilithium_export_public(dilithium_key* key, byte* out, word32* outLen); diff --git a/tests/test_mldsa.py b/tests/test_mldsa.py index e664c8b..57bda7e 100644 --- a/tests/test_mldsa.py +++ b/tests/test_mldsa.py @@ -28,6 +28,8 @@ from wolfcrypt.ciphers import MlDsaPrivate, MlDsaPublic, MlDsaType from wolfcrypt.random import Random + ML_DSA_SEED_LENGTH = 32 + @pytest.fixture def rng(): return Random() @@ -134,3 +136,35 @@ def test_sign_verify(mldsa_type, rng): # Verify with wrong message wrong_message = b"This is a wrong message for ML-DSA signature" assert not mldsa_pub.verify(signature, wrong_message) + + def test_generate_from_seed(mldsa_type, rng): + private_key_seed = rng.bytes(ML_DSA_SEED_LENGTH) + mldsa_priv = MlDsaPrivate.make_key_from_seed(mldsa_type, private_key_seed) + pub_key = mldsa_priv.encode_pub_key() + + # Import public key + mldsa_pub = MlDsaPublic(mldsa_type) + mldsa_pub.decode_key(pub_key) + + # Sign a message + message = b"This is a test message for ML-DSA signature" + signature = mldsa_priv.sign(message, rng) + assert len(signature) == mldsa_priv.sig_size + + # Verify the signature using public key + assert mldsa_pub.verify(signature, message) + + # re-generate from the same seed: + mldsa_priv_regenerated = MlDsaPrivate.make_key_from_seed(mldsa_type, private_key_seed) + assert mldsa_priv_regenerated.encode_priv_key() == mldsa_priv.encode_priv_key() + assert mldsa_priv_regenerated.encode_pub_key() == mldsa_priv.encode_pub_key() + + # test that the seed is checked: + with pytest.raises(AssertionError): + mldsa_priv = MlDsaPrivate.make_key_from_seed(mldsa_type, private_key_seed[:-1]) + + with pytest.raises(AssertionError): + mldsa_priv = MlDsaPrivate.make_key_from_seed(mldsa_type, private_key_seed + bytes(1)) + + with pytest.raises(AssertionError): + mldsa_priv = MlDsaPrivate.make_key_from_seed(mldsa_type, 'a' * 32) diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index 105224e..4132012 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -2152,6 +2152,9 @@ def verify(self, signature, message): return res[0] == 1 class MlDsaPrivate(_MlDsaBase): + _SEED_LENGTH = 32 + """The length of a private key generation seed.""" + @classmethod def make_key(cls, mldsa_type, rng=Random()): """ @@ -2172,6 +2175,28 @@ def make_key(cls, mldsa_type, rng=Random()): return mldsa_priv + @classmethod + def make_key_from_seed(cls, mldsa_type, seed): + """ + Deterministically generate the key from a seed. + + :param mldsa_type: ML-DSA type + :type mldsa_type: MlDsaType + :param seed: the (32 byte) seed from which to deterministically create the key + :type seed: bytes + """ + mldsa_priv = cls(mldsa_type) + assert isinstance(seed, bytes) and len(seed) == MlDsaPrivate._SEED_LENGTH, \ + f"Seed for generating ML-DSA key must be {MlDsaPrivate._SEED_LENGTH} bytes" + + ret = _lib.wc_dilithium_make_key_from_seed(mldsa_priv.native_object, + _ffi.from_buffer(seed)) + + if ret < 0: # pragma: no cover + raise WolfCryptError("wc_dilithium_make_key_from_seed() error (%d)" % ret) + + return mldsa_priv + @property def pub_key_size(self): """ From 44efa4b4659297bd311b085d4c6436eb9bb3afc3 Mon Sep 17 00:00:00 2001 From: Martijn de Milliano Date: Fri, 10 Apr 2026 17:30:10 +0200 Subject: [PATCH 2/2] Process Copilot comments - Move+rename constant for seed length and use it in test_mldsa - Replace assert by raising TypeError and ValueError in make_key_from_seed, and change type check to use memoryview. --- tests/test_mldsa.py | 23 ++++++++++------------- wolfcrypt/ciphers.py | 19 ++++++++++++++----- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/tests/test_mldsa.py b/tests/test_mldsa.py index 57bda7e..de8589f 100644 --- a/tests/test_mldsa.py +++ b/tests/test_mldsa.py @@ -25,11 +25,9 @@ if _lib.ML_DSA_ENABLED: import pytest - from wolfcrypt.ciphers import MlDsaPrivate, MlDsaPublic, MlDsaType + from wolfcrypt.ciphers import MlDsaPrivate, MlDsaPublic, MlDsaType, ML_DSA_KEYGEN_SEED_LENGTH from wolfcrypt.random import Random - ML_DSA_SEED_LENGTH = 32 - @pytest.fixture def rng(): return Random() @@ -138,7 +136,7 @@ def test_sign_verify(mldsa_type, rng): assert not mldsa_pub.verify(signature, wrong_message) def test_generate_from_seed(mldsa_type, rng): - private_key_seed = rng.bytes(ML_DSA_SEED_LENGTH) + private_key_seed = rng.bytes(ML_DSA_KEYGEN_SEED_LENGTH) mldsa_priv = MlDsaPrivate.make_key_from_seed(mldsa_type, private_key_seed) pub_key = mldsa_priv.encode_pub_key() @@ -159,12 +157,11 @@ def test_generate_from_seed(mldsa_type, rng): assert mldsa_priv_regenerated.encode_priv_key() == mldsa_priv.encode_priv_key() assert mldsa_priv_regenerated.encode_pub_key() == mldsa_priv.encode_pub_key() - # test that the seed is checked: - with pytest.raises(AssertionError): - mldsa_priv = MlDsaPrivate.make_key_from_seed(mldsa_type, private_key_seed[:-1]) - - with pytest.raises(AssertionError): - mldsa_priv = MlDsaPrivate.make_key_from_seed(mldsa_type, private_key_seed + bytes(1)) - - with pytest.raises(AssertionError): - mldsa_priv = MlDsaPrivate.make_key_from_seed(mldsa_type, 'a' * 32) + # test that the seed length is checked: + with pytest.raises(ValueError): + mldsa_priv = MlDsaPrivate.make_key_from_seed(mldsa_type, bytes(ML_DSA_KEYGEN_SEED_LENGTH - 1)) + with pytest.raises(ValueError): + mldsa_priv = MlDsaPrivate.make_key_from_seed(mldsa_type, bytes(ML_DSA_KEYGEN_SEED_LENGTH + 1)) + # test that the seed type is checked (should be bytes-like, not a string) + with pytest.raises(TypeError): + mldsa_priv = MlDsaPrivate.make_key_from_seed(mldsa_type, 'a' * ML_DSA_KEYGEN_SEED_LENGTH) diff --git a/wolfcrypt/ciphers.py b/wolfcrypt/ciphers.py index 4132012..6191236 100644 --- a/wolfcrypt/ciphers.py +++ b/wolfcrypt/ciphers.py @@ -2031,6 +2031,9 @@ def decapsulate(self, ct): if _lib.ML_DSA_ENABLED: + ML_DSA_KEYGEN_SEED_LENGTH = 32 + """The length of a private key generation seed.""" + class MlDsaType(IntEnum): """ `MlDsaType` specifies supported ML-DSA types. @@ -2152,8 +2155,6 @@ def verify(self, signature, message): return res[0] == 1 class MlDsaPrivate(_MlDsaBase): - _SEED_LENGTH = 32 - """The length of a private key generation seed.""" @classmethod def make_key(cls, mldsa_type, rng=Random()): @@ -2186,11 +2187,19 @@ def make_key_from_seed(cls, mldsa_type, seed): :type seed: bytes """ mldsa_priv = cls(mldsa_type) - assert isinstance(seed, bytes) and len(seed) == MlDsaPrivate._SEED_LENGTH, \ - f"Seed for generating ML-DSA key must be {MlDsaPrivate._SEED_LENGTH} bytes" + try: + seed_view = memoryview(seed) + except TypeError as exception: + raise TypeError( + "seed must support the buffer protocol, such as `bytes` or `bytearray`" + ) from exception + if len(seed_view) != ML_DSA_KEYGEN_SEED_LENGTH: + raise ValueError( + f"Seed for generating ML-DSA key must be {ML_DSA_KEYGEN_SEED_LENGTH} bytes" + ) ret = _lib.wc_dilithium_make_key_from_seed(mldsa_priv.native_object, - _ffi.from_buffer(seed)) + _ffi.from_buffer(seed_view)) if ret < 0: # pragma: no cover raise WolfCryptError("wc_dilithium_make_key_from_seed() error (%d)" % ret)