From 2ba93f20df62f413510edd0d7cf54c524cba7774 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Tue, 20 Jan 2026 15:41:23 +0200 Subject: [PATCH 1/6] TEMP: use WIP rust-openssl ML-DSA and ML-KEM branches This is a temporary solution until rust-openssl PRs merged Signed-off-by: Alexander Bokovoy --- Cargo.lock | 9 +++------ Cargo.toml | 4 ++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4ce7ef6ba9de..3fa45be1a086 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,8 +186,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openssl" version = "0.10.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +source = "git+https://github.com/abbra/rust-openssl.git?branch=pqc-prs#cafc14285f50b226635d763c406540b8b48ea7d1" dependencies = [ "bitflags", "cfg-if", @@ -201,8 +200,7 @@ dependencies = [ [[package]] name = "openssl-macros" version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +source = "git+https://github.com/abbra/rust-openssl.git?branch=pqc-prs#cafc14285f50b226635d763c406540b8b48ea7d1" dependencies = [ "proc-macro2", "quote", @@ -212,8 +210,7 @@ dependencies = [ [[package]] name = "openssl-sys" version = "0.9.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +source = "git+https://github.com/abbra/rust-openssl.git?branch=pqc-prs#cafc14285f50b226635d763c406540b8b48ea7d1" dependencies = [ "cc", "libc", diff --git a/Cargo.toml b/Cargo.toml index b499f8db5b7b..3c378bd450d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,3 +36,7 @@ self_cell = "1" [profile.release] overflow-checks = true + +[patch.crates-io] +openssl-sys = { git = "https://github.com/abbra/rust-openssl.git", branch = "pqc-prs" } +openssl = { git = "https://github.com/abbra/rust-openssl.git", branch = "pqc-prs" } From 0f80b62d2094a3c65c1573bbee41eb6aa3ab575c Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Tue, 20 Jan 2026 16:47:36 +0200 Subject: [PATCH 2/6] TEMP: enable CI workflow for abbra- branches Signed-off-by: Alexander Bokovoy --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e75c978a8a49..3433a039a730 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: - main - '*.*.x' + - 'abbra-*' permissions: contents: read From 4983a7dffc1e303a79f5122efe8e039d30f51734 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Sun, 18 Jan 2026 15:45:02 +0200 Subject: [PATCH 3/6] ML-DSA: Implement ML-DSA-44 (FIPS 204) signature algorithm. Changes: - Add mldsa44_supported() backend method - Implement MlDsa44PrivateKey and MlDsa44PublicKey classes - Add Rust backend implementation with OpenSSL 3.5+ support - Add PKCS8/SPKI key parsing and serialization - Add comprehensive test suite - Add API documentation ML-DSA-44 provides NIST Level 2 post-quantum security with the smallest signature and key sizes of the ML-DSA family. Assisted-by: Claude Sonnet 4.5 Signed-off-by: Alexander Bokovoy --- docs/hazmat/primitives/asymmetric/index.rst | 16 +- docs/hazmat/primitives/asymmetric/mldsa44.rst | 333 +++++++++++++ docs/spelling_wordlist.txt | 1 + src/cryptography/hazmat/_oid.py | 4 + .../hazmat/backends/openssl/backend.py | 3 + .../hazmat/primitives/asymmetric/mldsa44.py | 158 ++++++ .../hazmat/primitives/asymmetric/types.py | 6 + .../hazmat/primitives/serialization/pkcs7.py | 57 ++- src/cryptography/x509/base.py | 6 +- src/rust/cryptography-key-parsing/Cargo.toml | 2 +- src/rust/cryptography-key-parsing/build.rs | 7 + .../cryptography-key-parsing/src/pkcs8.rs | 82 ++++ src/rust/cryptography-key-parsing/src/spki.rs | 22 + src/rust/cryptography-x509/src/common.rs | 3 + src/rust/cryptography-x509/src/oid.rs | 2 + src/rust/src/backend/keys.rs | 25 + src/rust/src/backend/mldsa44.rs | 233 +++++++++ src/rust/src/backend/mod.rs | 2 + src/rust/src/lib.rs | 3 + src/rust/src/pkcs7.rs | 13 +- src/rust/src/types.rs | 9 + src/rust/src/x509/sign.rs | 25 +- tests/hazmat/primitives/test_mldsa44.py | 461 ++++++++++++++++++ 23 files changed, 1442 insertions(+), 31 deletions(-) create mode 100644 docs/hazmat/primitives/asymmetric/mldsa44.rst create mode 100644 src/cryptography/hazmat/primitives/asymmetric/mldsa44.py create mode 100644 src/rust/src/backend/mldsa44.rs create mode 100644 tests/hazmat/primitives/test_mldsa44.py diff --git a/docs/hazmat/primitives/asymmetric/index.rst b/docs/hazmat/primitives/asymmetric/index.rst index b0dd09aff24f..154e0ff15ad3 100644 --- a/docs/hazmat/primitives/asymmetric/index.rst +++ b/docs/hazmat/primitives/asymmetric/index.rst @@ -27,6 +27,7 @@ private key is able to decrypt it. x25519 ed448 x448 + mldsa44 ec rsa dh @@ -58,7 +59,8 @@ union type aliases can be used instead to reference a multitude of key types. :class:`~cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PublicKey`, :class:`~cryptography.hazmat.primitives.asymmetric.ed448.Ed448PublicKey`, :class:`~cryptography.hazmat.primitives.asymmetric.x25519.X25519PublicKey`, - :class:`~cryptography.hazmat.primitives.asymmetric.x448.X448PublicKey`. + :class:`~cryptography.hazmat.primitives.asymmetric.x448.X448PublicKey`, + :class:`~cryptography.hazmat.primitives.asymmetric.mldsa44.MlDsa44PublicKey`. .. data:: PrivateKeyTypes @@ -72,7 +74,8 @@ union type aliases can be used instead to reference a multitude of key types. :class:`~cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey`, :class:`~cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey`, :class:`~cryptography.hazmat.primitives.asymmetric.x25519.X25519PrivateKey`, - :class:`~cryptography.hazmat.primitives.asymmetric.x448.X448PrivateKey`. + :class:`~cryptography.hazmat.primitives.asymmetric.x448.X448PrivateKey`, + :class:`~cryptography.hazmat.primitives.asymmetric.mldsa44.MlDsa44PrivateKey`. .. data:: CertificatePublicKeyTypes @@ -86,7 +89,8 @@ union type aliases can be used instead to reference a multitude of key types. :class:`~cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PublicKey`, :class:`~cryptography.hazmat.primitives.asymmetric.ed448.Ed448PublicKey`, :class:`~cryptography.hazmat.primitives.asymmetric.x25519.X25519PublicKey`, - :class:`~cryptography.hazmat.primitives.asymmetric.x448.X448PublicKey`. + :class:`~cryptography.hazmat.primitives.asymmetric.x448.X448PublicKey`, + :class:`~cryptography.hazmat.primitives.asymmetric.mldsa44.MlDsa44PublicKey`. .. data:: CertificateIssuerPublicKeyTypes @@ -101,7 +105,8 @@ union type aliases can be used instead to reference a multitude of key types. :class:`~cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey`, :class:`~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey`, :class:`~cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PublicKey`, - :class:`~cryptography.hazmat.primitives.asymmetric.ed448.Ed448PublicKey`. + :class:`~cryptography.hazmat.primitives.asymmetric.ed448.Ed448PublicKey`, + :class:`~cryptography.hazmat.primitives.asymmetric.mldsa44.MlDsa44PublicKey`. .. data:: CertificateIssuerPrivateKeyTypes @@ -116,4 +121,5 @@ union type aliases can be used instead to reference a multitude of key types. :class:`~cryptography.hazmat.primitives.asymmetric.dsa.DSAPrivateKey`, :class:`~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey`, :class:`~cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey`, - :class:`~cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey`. + :class:`~cryptography.hazmat.primitives.asymmetric.ed448.Ed448PrivateKey`, + :class:`~cryptography.hazmat.primitives.asymmetric.mldsa44.MlDsa44PrivateKey`. diff --git a/docs/hazmat/primitives/asymmetric/mldsa44.rst b/docs/hazmat/primitives/asymmetric/mldsa44.rst new file mode 100644 index 000000000000..53cae886e2e2 --- /dev/null +++ b/docs/hazmat/primitives/asymmetric/mldsa44.rst @@ -0,0 +1,333 @@ +.. hazmat:: + +ML-DSA-44 signing +================= + +.. currentmodule:: cryptography.hazmat.primitives.asymmetric.mldsa44 + + +ML-DSA-44 is a post-quantum digital signature algorithm based on module +lattices, standardized in `FIPS 204`_. It provides NIST security level 2 +(comparable to 128-bit security) and is suitable for applications where smaller +key and signature sizes are important. ML-DSA-44 is designed to be secure +against attacks from both classical and quantum computers. + +Signing & Verification +~~~~~~~~~~~~~~~~~~~~~~~ + +.. doctest:: + + >>> from cryptography.hazmat.primitives.asymmetric.mldsa44 import MlDsa44PrivateKey + >>> private_key = MlDsa44PrivateKey.generate() + >>> signature = private_key.sign(b"my authenticated message") + >>> public_key = private_key.public_key() + >>> # Raises InvalidSignature if verification fails + >>> public_key.verify(signature, b"my authenticated message") + +Context-based Signing & Verification +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +ML-DSA-44 supports context strings to bind additional information to signatures. +The context can be up to 255 bytes and is used to differentiate signatures in +different contexts or protocols. This is useful for domain separation and +preventing cross-protocol attacks. + +.. doctest:: + + >>> from cryptography.hazmat.primitives.asymmetric.mldsa44 import MlDsa44PrivateKey + >>> private_key = MlDsa44PrivateKey.generate() + >>> context = b"email-signature-v1" + >>> signature = private_key.sign_with_context(b"my authenticated message", context) + >>> public_key = private_key.public_key() + >>> # Verification requires the same context + >>> public_key.verify_with_context(signature, b"my authenticated message", context) + +X.509 Certificate Usage +~~~~~~~~~~~~~~~~~~~~~~~~ + +ML-DSA-44 can be used to create and sign X.509 certificates. When signing +certificates with ML-DSA, the ``hash_algorithm`` parameter must be ``None`` +as ML-DSA uses pure signature mode without pre-hashing. + +.. doctest:: + + >>> import datetime + >>> from cryptography import x509 + >>> from cryptography.x509.oid import NameOID + >>> from cryptography.hazmat.primitives.asymmetric import mldsa44 + >>> from cryptography.hazmat.primitives import serialization + >>> # Generate ML-DSA-44 key + >>> private_key = mldsa44.MlDsa44PrivateKey.generate() + >>> public_key = private_key.public_key() + >>> # Create a self-signed certificate + >>> subject = issuer = x509.Name([ + ... x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), + ... x509.NameAttribute(NameOID.ORGANIZATION_NAME, "My Organization"), + ... x509.NameAttribute(NameOID.COMMON_NAME, "example.com"), + ... ]) + >>> cert = ( + ... x509.CertificateBuilder() + ... .subject_name(subject) + ... .issuer_name(issuer) + ... .public_key(public_key) + ... .serial_number(x509.random_serial_number()) + ... .not_valid_before(datetime.datetime.now(datetime.timezone.utc)) + ... .not_valid_after(datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=365)) + ... .sign(private_key, None) # hash_algorithm must be None for ML-DSA + ... ) + >>> # Verify the certificate signature + >>> cert_public_key = cert.public_key() + >>> cert_public_key.verify(cert.signature, cert.tbs_certificate_bytes) + +CMS/PKCS#7 Signed Data +~~~~~~~~~~~~~~~~~~~~~~~ + +ML-DSA-44 can be used to create CMS (Cryptographic Message Syntax) signed +messages, commonly used for S/MIME email and document signing. + +.. doctest:: + + >>> from cryptography.hazmat.primitives.serialization import pkcs7 + >>> # Create a signed message + >>> message = b"Important document content" + >>> builder = ( + ... pkcs7.PKCS7SignatureBuilder() + ... .set_data(message) + ... .add_signer(cert, private_key, None) # hash_algorithm must be None for ML-DSA + ... ) + >>> # Sign and serialize as PEM + >>> signed_data = builder.sign(serialization.Encoding.PEM, []) + >>> # The signed_data can now be transmitted and verified by recipients + +.. note:: + When using ML-DSA with CMS, the ``hash_algorithm`` parameter must be + ``None``. This is required by RFC 9882. The digestAlgorithm field in + the CMS structure will automatically use SHA-512 for compliance with + the standard. + +Key interfaces +~~~~~~~~~~~~~~ + +.. class:: MlDsa44PrivateKey + + .. versionadded:: 47.0 + + .. classmethod:: generate() + + Generate an ML-DSA-44 private key. + + :returns: :class:`MlDsa44PrivateKey` + + :raises cryptography.exceptions.UnsupportedAlgorithm: If ML-DSA-44 is + not supported by the OpenSSL version ``cryptography`` is using. + + .. classmethod:: from_seed_bytes(data) + + A class method for deterministically generating an ML-DSA-44 private key + from seed bytes. This is used for deterministic key generation, not for + loading serialized keys. To load serialized private keys, use + :func:`~cryptography.hazmat.primitives.serialization.load_pem_private_key` + or :func:`~cryptography.hazmat.primitives.serialization.load_der_private_key`. + + :param data: 32 byte seed. + :type data: :term:`bytes-like` + + :returns: :class:`MlDsa44PrivateKey` + + :raises ValueError: If the seed is not 32 bytes. + + :raises cryptography.exceptions.UnsupportedAlgorithm: If ML-DSA-44 is + not supported by the OpenSSL version ``cryptography`` is using. + + .. doctest:: + + >>> from cryptography.hazmat.primitives import serialization + >>> from cryptography.hazmat.primitives.asymmetric import mldsa44 + >>> private_key = mldsa44.MlDsa44PrivateKey.generate() + >>> # Serialize to PEM + >>> pem = private_key.private_bytes( + ... encoding=serialization.Encoding.PEM, + ... format=serialization.PrivateFormat.PKCS8, + ... encryption_algorithm=serialization.NoEncryption() + ... ) + >>> # Load from PEM + >>> loaded_private_key = serialization.load_pem_private_key(pem, password=None) + + + .. method:: public_key() + + :returns: :class:`MlDsa44PublicKey` + + .. method:: sign(data) + + Sign the data using ML-DSA-44. + + :param data: The data to sign. + :type data: :term:`bytes-like` + + :returns bytes: The signature (2420 bytes). + + .. method:: sign_with_context(data, context) + + Sign the data using ML-DSA-44 with an additional context string. + The context is used for domain separation and preventing cross-protocol + attacks. + + :param data: The data to sign. + :type data: :term:`bytes-like` + + :param context: The context string (up to 255 bytes). + :type context: :term:`bytes-like` + + :returns bytes: The signature (2420 bytes). + + :raises ValueError: If the context is longer than 255 bytes. + + .. method:: private_bytes(encoding, format, encryption_algorithm) + + Allows serialization of the key to bytes. Encoding ( + :attr:`~cryptography.hazmat.primitives.serialization.Encoding.PEM`, + :attr:`~cryptography.hazmat.primitives.serialization.Encoding.DER`, or + :attr:`~cryptography.hazmat.primitives.serialization.Encoding.Raw`) and + format ( + :attr:`~cryptography.hazmat.primitives.serialization.PrivateFormat.PKCS8` + or + :attr:`~cryptography.hazmat.primitives.serialization.PrivateFormat.Raw` + ) are chosen to define the exact serialization. + + :param encoding: A value from the + :class:`~cryptography.hazmat.primitives.serialization.Encoding` enum. + + :param format: A value from the + :class:`~cryptography.hazmat.primitives.serialization.PrivateFormat` + enum. If the ``encoding`` is + :attr:`~cryptography.hazmat.primitives.serialization.Encoding.Raw` + then ``format`` must be + :attr:`~cryptography.hazmat.primitives.serialization.PrivateFormat.Raw` + , otherwise it must be + :attr:`~cryptography.hazmat.primitives.serialization.PrivateFormat.PKCS8`. + + :param encryption_algorithm: An instance of an object conforming to the + :class:`~cryptography.hazmat.primitives.serialization.KeySerializationEncryption` + interface. + + :return bytes: Serialized key. + + .. method:: seed_bytes() + + Returns the 32-byte seed used to generate this private key. This seed + can be used with :meth:`from_seed_bytes` to deterministically recreate + the same private key. + + :return bytes: 32-byte seed. + + .. doctest:: + + >>> from cryptography.hazmat.primitives.asymmetric import mldsa44 + >>> private_key = mldsa44.MlDsa44PrivateKey.generate() + >>> seed = private_key.seed_bytes() + >>> len(seed) + 32 + >>> # Recreate the same key from the seed + >>> recreated_key = mldsa44.MlDsa44PrivateKey.from_seed_bytes(seed) + +.. class:: MlDsa44PublicKey + + .. versionadded:: 47.0 + + .. classmethod:: from_public_bytes(data) + + :param bytes data: 1312 byte public key. + + :returns: :class:`MlDsa44PublicKey` + + :raises ValueError: If the public key is not 1312 bytes. + + :raises cryptography.exceptions.UnsupportedAlgorithm: If ML-DSA-44 is + not supported by the OpenSSL version ``cryptography`` is using. + + .. doctest:: + + >>> from cryptography.hazmat.primitives import serialization + >>> from cryptography.hazmat.primitives.asymmetric import mldsa44 + >>> private_key = mldsa44.MlDsa44PrivateKey.generate() + >>> public_key = private_key.public_key() + >>> public_bytes = public_key.public_bytes( + ... encoding=serialization.Encoding.Raw, + ... format=serialization.PublicFormat.Raw + ... ) + >>> loaded_public_key = mldsa44.MlDsa44PublicKey.from_public_bytes(public_bytes) + + .. method:: public_bytes(encoding, format) + + Allows serialization of the key to bytes. Encoding ( + :attr:`~cryptography.hazmat.primitives.serialization.Encoding.PEM`, + :attr:`~cryptography.hazmat.primitives.serialization.Encoding.DER`, or + :attr:`~cryptography.hazmat.primitives.serialization.Encoding.Raw`) and + format ( + :attr:`~cryptography.hazmat.primitives.serialization.PublicFormat.SubjectPublicKeyInfo` + or + :attr:`~cryptography.hazmat.primitives.serialization.PublicFormat.Raw` + ) are chosen to define the exact serialization. + + :param encoding: A value from the + :class:`~cryptography.hazmat.primitives.serialization.Encoding` enum. + + :param format: A value from the + :class:`~cryptography.hazmat.primitives.serialization.PublicFormat` + enum. If the ``encoding`` is + :attr:`~cryptography.hazmat.primitives.serialization.Encoding.Raw` + then ``format`` must be + :attr:`~cryptography.hazmat.primitives.serialization.PublicFormat.Raw` + , otherwise it must be + :attr:`~cryptography.hazmat.primitives.serialization.PublicFormat.SubjectPublicKeyInfo`. + + :returns bytes: The public key bytes. + + .. method:: public_bytes_raw() + + Allows serialization of the key to raw bytes. This method is a + convenience shortcut for calling :meth:`public_bytes` with + :attr:`~cryptography.hazmat.primitives.serialization.Encoding.Raw` + encoding and + :attr:`~cryptography.hazmat.primitives.serialization.PublicFormat.Raw` + format. + + :return bytes: 1312-byte raw public key. + + .. method:: verify(signature, data) + + Verify a signature using ML-DSA-44. + + :param signature: The signature to verify. + :type signature: :term:`bytes-like` + + :param data: The data to verify. + :type data: :term:`bytes-like` + + :returns: None + :raises cryptography.exceptions.InvalidSignature: Raised when the + signature cannot be verified. + + .. method:: verify_with_context(signature, data, context) + + Verify a signature using ML-DSA-44 with the context string that was used + during signing. The same context must be provided for verification to + succeed. + + :param signature: The signature to verify. + :type signature: :term:`bytes-like` + + :param data: The data to verify. + :type data: :term:`bytes-like` + + :param context: The context string (up to 255 bytes) that was used during signing. + :type context: :term:`bytes-like` + + :returns: None + :raises cryptography.exceptions.InvalidSignature: Raised when the + signature cannot be verified. + :raises ValueError: If the context is longer than 255 bytes. + + +.. _`FIPS 204`: https://csrc.nist.gov/pubs/fips/204/final diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 6f015cf93bd6..f7ef05e82908 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -50,6 +50,7 @@ Deserialization deserializing Diffie Diffie +digestAlgorithm disambiguating Django Docstrings diff --git a/src/cryptography/hazmat/_oid.py b/src/cryptography/hazmat/_oid.py index 4bf138d4f80b..d9b57f930e94 100644 --- a/src/cryptography/hazmat/_oid.py +++ b/src/cryptography/hazmat/_oid.py @@ -122,6 +122,7 @@ class SignatureAlgorithmOID: GOSTR3411_94_WITH_3410_2001 = ObjectIdentifier("1.2.643.2.2.3") GOSTR3410_2012_WITH_3411_2012_256 = ObjectIdentifier("1.2.643.7.1.1.3.2") GOSTR3410_2012_WITH_3411_2012_512 = ObjectIdentifier("1.2.643.7.1.1.3.3") + ML_DSA_44 = ObjectIdentifier("2.16.840.1.101.3.4.3.17") _SIG_OIDS_TO_HASH: dict[ObjectIdentifier, hashes.HashAlgorithm | None] = { @@ -153,6 +154,7 @@ class SignatureAlgorithmOID: SignatureAlgorithmOID.GOSTR3411_94_WITH_3410_2001: None, SignatureAlgorithmOID.GOSTR3410_2012_WITH_3411_2012_256: None, SignatureAlgorithmOID.GOSTR3410_2012_WITH_3411_2012_512: None, + SignatureAlgorithmOID.ML_DSA_44: None, } @@ -181,6 +183,7 @@ class PublicKeyAlgorithmOID: X448 = ObjectIdentifier("1.3.101.111") ED25519 = ObjectIdentifier("1.3.101.112") ED448 = ObjectIdentifier("1.3.101.113") + ML_DSA_44 = ObjectIdentifier("2.16.840.1.101.3.4.3.17") class ExtendedKeyUsageOID: @@ -285,6 +288,7 @@ class AttributeOID: SignatureAlgorithmOID.GOSTR3410_2012_WITH_3411_2012_512: ( "GOST R 34.10-2012 with GOST R 34.11-2012 (512 bit)" ), + SignatureAlgorithmOID.ML_DSA_44: "ml-dsa-44", HashAlgorithmOID.SHA1: "sha1", HashAlgorithmOID.SHA224: "sha224", HashAlgorithmOID.SHA256: "sha256", diff --git a/src/cryptography/hazmat/backends/openssl/backend.py b/src/cryptography/hazmat/backends/openssl/backend.py index 1ac8335a653d..623c49aaa415 100644 --- a/src/cryptography/hazmat/backends/openssl/backend.py +++ b/src/cryptography/hazmat/backends/openssl/backend.py @@ -296,5 +296,8 @@ def poly1305_supported(self) -> bool: def pkcs7_supported(self) -> bool: return True + def mldsa44_supported(self) -> bool: + return rust_openssl.CRYPTOGRAPHY_OPENSSL_350_OR_GREATER + backend = Backend() diff --git a/src/cryptography/hazmat/primitives/asymmetric/mldsa44.py b/src/cryptography/hazmat/primitives/asymmetric/mldsa44.py new file mode 100644 index 000000000000..3bd2a80b9314 --- /dev/null +++ b/src/cryptography/hazmat/primitives/asymmetric/mldsa44.py @@ -0,0 +1,158 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +from __future__ import annotations + +import abc + +from cryptography.exceptions import UnsupportedAlgorithm, _Reasons +from cryptography.hazmat.bindings._rust import openssl as rust_openssl +from cryptography.hazmat.primitives import _serialization +from cryptography.utils import Buffer + + +class MlDsa44PublicKey(metaclass=abc.ABCMeta): + @classmethod + def from_public_bytes(cls, data: bytes) -> MlDsa44PublicKey: + from cryptography.hazmat.backends.openssl.backend import backend + + if not backend.mldsa44_supported(): + raise UnsupportedAlgorithm( + "ML-DSA-44 is not supported by this version of OpenSSL.", + _Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM, + ) + mldsa44 = getattr(rust_openssl, "mldsa44") + return mldsa44.from_public_bytes(data) + + @abc.abstractmethod + def public_bytes( + self, + encoding: _serialization.Encoding, + format: _serialization.PublicFormat, + ) -> bytes: + """ + The serialized bytes of the public key. + """ + + @abc.abstractmethod + def public_bytes_raw(self) -> bytes: + """ + The raw bytes of the public key. + Equivalent to public_bytes(Raw, Raw). + """ + + @abc.abstractmethod + def verify(self, signature: Buffer, data: Buffer) -> None: + """ + Verify the signature. + """ + + @abc.abstractmethod + def verify_with_context( + self, signature: Buffer, data: Buffer, context: Buffer + ) -> None: + """ + Verify the signature with context. + """ + + @abc.abstractmethod + def __eq__(self, other: object) -> bool: + """ + Checks equality. + """ + + @abc.abstractmethod + def __copy__(self) -> MlDsa44PublicKey: + """ + Returns a copy. + """ + + @abc.abstractmethod + def __deepcopy__(self, memo: dict) -> MlDsa44PublicKey: + """ + Returns a deep copy. + """ + + +if hasattr(rust_openssl, "mldsa44"): + MlDsa44PublicKey.register(rust_openssl.mldsa44.MlDsa44PublicKey) + + +class MlDsa44PrivateKey(metaclass=abc.ABCMeta): + @classmethod + def generate(cls) -> MlDsa44PrivateKey: + from cryptography.hazmat.backends.openssl.backend import backend + + if not backend.mldsa44_supported(): + raise UnsupportedAlgorithm( + "ML-DSA-44 is not supported by this version of OpenSSL.", + _Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM, + ) + + mldsa44 = getattr(rust_openssl, "mldsa44") + return mldsa44.generate_key() + + @classmethod + def from_seed_bytes(cls, data: Buffer) -> MlDsa44PrivateKey: + from cryptography.hazmat.backends.openssl.backend import backend + + if not backend.mldsa44_supported(): + raise UnsupportedAlgorithm( + "ML-DSA-44 is not supported by this version of OpenSSL.", + _Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM, + ) + + mldsa44 = getattr(rust_openssl, "mldsa44") + return mldsa44.from_seed_bytes(data) + + @abc.abstractmethod + def public_key(self) -> MlDsa44PublicKey: + """ + The MlDsa44PublicKey derived from the private key. + """ + + @abc.abstractmethod + def private_bytes( + self, + encoding: _serialization.Encoding, + format: _serialization.PrivateFormat, + encryption_algorithm: _serialization.KeySerializationEncryption, + ) -> bytes: + """ + The serialized bytes of the private key. + """ + + @abc.abstractmethod + def seed_bytes(self) -> bytes: + """ + The 32-byte seed used to generate this private key. + """ + + @abc.abstractmethod + def sign(self, data: Buffer) -> bytes: + """ + Signs the data. + """ + + @abc.abstractmethod + def sign_with_context(self, data: Buffer, context: Buffer) -> bytes: + """ + Signs the data with context. + """ + + @abc.abstractmethod + def __copy__(self) -> MlDsa44PrivateKey: + """ + Returns a copy. + """ + + @abc.abstractmethod + def __deepcopy__(self, memo: dict) -> MlDsa44PrivateKey: + """ + Returns a deep copy. + """ + + +if hasattr(rust_openssl, "mldsa44"): + MlDsa44PrivateKey.register(rust_openssl.mldsa44.MlDsa44PrivateKey) diff --git a/src/cryptography/hazmat/primitives/asymmetric/types.py b/src/cryptography/hazmat/primitives/asymmetric/types.py index 1fe4eaf51d85..f95746bcb02e 100644 --- a/src/cryptography/hazmat/primitives/asymmetric/types.py +++ b/src/cryptography/hazmat/primitives/asymmetric/types.py @@ -13,6 +13,7 @@ ec, ed448, ed25519, + mldsa44, rsa, x448, x25519, @@ -28,6 +29,7 @@ ed448.Ed448PublicKey, x25519.X25519PublicKey, x448.X448PublicKey, + mldsa44.MlDsa44PublicKey, ] PUBLIC_KEY_TYPES = PublicKeyTypes utils.deprecated( @@ -47,6 +49,7 @@ ec.EllipticCurvePrivateKey, x25519.X25519PrivateKey, x448.X448PrivateKey, + mldsa44.MlDsa44PrivateKey, ] PRIVATE_KEY_TYPES = PrivateKeyTypes utils.deprecated( @@ -64,6 +67,7 @@ rsa.RSAPrivateKey, dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey, + mldsa44.MlDsa44PrivateKey, ] CERTIFICATE_PRIVATE_KEY_TYPES = CertificateIssuerPrivateKeyTypes utils.deprecated( @@ -81,6 +85,7 @@ ec.EllipticCurvePublicKey, ed25519.Ed25519PublicKey, ed448.Ed448PublicKey, + mldsa44.MlDsa44PublicKey, ] CERTIFICATE_ISSUER_PUBLIC_KEY_TYPES = CertificateIssuerPublicKeyTypes utils.deprecated( @@ -100,6 +105,7 @@ ed448.Ed448PublicKey, x25519.X25519PublicKey, x448.X448PublicKey, + mldsa44.MlDsa44PublicKey, ] CERTIFICATE_PUBLIC_KEY_TYPES = CertificatePublicKeyTypes utils.deprecated( diff --git a/src/cryptography/hazmat/primitives/serialization/pkcs7.py b/src/cryptography/hazmat/primitives/serialization/pkcs7.py index 456dc5b0831c..01194527cbfd 100644 --- a/src/cryptography/hazmat/primitives/serialization/pkcs7.py +++ b/src/cryptography/hazmat/primitives/serialization/pkcs7.py @@ -16,7 +16,12 @@ from cryptography.exceptions import UnsupportedAlgorithm, _Reasons from cryptography.hazmat.bindings._rust import pkcs7 as rust_pkcs7 from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa +from cryptography.hazmat.primitives.asymmetric import ( + ec, + mldsa44, + padding, + rsa, +) from cryptography.hazmat.primitives.ciphers import ( algorithms, ) @@ -33,10 +38,13 @@ hashes.SHA256, hashes.SHA384, hashes.SHA512, + None, ] PKCS7PrivateKeyTypes = typing.Union[ - rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey + rsa.RSAPrivateKey, + ec.EllipticCurvePrivateKey, + mldsa44.MlDsa44PrivateKey, ] ContentEncryptionAlgorithm = typing.Union[ @@ -86,26 +94,43 @@ def add_signer( *, rsa_padding: padding.PSS | padding.PKCS1v15 | None = None, ) -> PKCS7SignatureBuilder: + if not isinstance(certificate, x509.Certificate): + raise TypeError("certificate must be a x509.Certificate") + if not isinstance( - hash_algorithm, + private_key, ( - hashes.SHA224, - hashes.SHA256, - hashes.SHA384, - hashes.SHA512, + rsa.RSAPrivateKey, + ec.EllipticCurvePrivateKey, + mldsa44.MlDsa44PrivateKey, ), ): - raise TypeError( - "hash_algorithm must be one of hashes.SHA224, " - "SHA256, SHA384, or SHA512" - ) - if not isinstance(certificate, x509.Certificate): - raise TypeError("certificate must be a x509.Certificate") + raise TypeError("Key must be RSA, EC, or ML-DSA") - if not isinstance( - private_key, (rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey) + # ML-DSA keys must use hash_algorithm=None (RFC 9882 Section 3.3) + if isinstance( + private_key, + (mldsa44.MlDsa44PrivateKey,), ): - raise TypeError("Only RSA & EC keys are supported at this time.") + if hash_algorithm is not None: + raise ValueError( + "hash_algorithm must be None when using ML-DSA keys" + ) + else: + # RSA and EC keys require a hash algorithm + if not isinstance( + hash_algorithm, + ( + hashes.SHA224, + hashes.SHA256, + hashes.SHA384, + hashes.SHA512, + ), + ): + raise TypeError( + "hash_algorithm must be one of hashes.SHA224, " + "SHA256, SHA384, or SHA512" + ) if rsa_padding is not None: if not isinstance(rsa_padding, (padding.PSS, padding.PKCS1v15)): diff --git a/src/cryptography/x509/base.py b/src/cryptography/x509/base.py index a11b8fe02b73..438ae0f68a80 100644 --- a/src/cryptography/x509/base.py +++ b/src/cryptography/x509/base.py @@ -17,6 +17,7 @@ ec, ed448, ed25519, + mldsa44, padding, rsa, x448, @@ -364,13 +365,14 @@ def public_key( ed448.Ed448PublicKey, x25519.X25519PublicKey, x448.X448PublicKey, + mldsa44.MlDsa44PublicKey, ), ): raise TypeError( "Expecting one of DSAPublicKey, RSAPublicKey," " EllipticCurvePublicKey, Ed25519PublicKey," - " Ed448PublicKey, X25519PublicKey, or " - "X448PublicKey." + " Ed448PublicKey, X25519PublicKey, X44PublicKey, or" + " MlDsa44PublicKey." ) if self._public_key is not None: raise ValueError("The public key may only be set once.") diff --git a/src/rust/cryptography-key-parsing/Cargo.toml b/src/rust/cryptography-key-parsing/Cargo.toml index 8f25462c096e..2e0b029165ab 100644 --- a/src/rust/cryptography-key-parsing/Cargo.toml +++ b/src/rust/cryptography-key-parsing/Cargo.toml @@ -18,4 +18,4 @@ openssl-sys.workspace = true pem.workspace = true [lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(CRYPTOGRAPHY_IS_LIBRESSL)', 'cfg(CRYPTOGRAPHY_IS_BORINGSSL)', 'cfg(CRYPTOGRAPHY_OSSLCONF, values("OPENSSL_NO_RC2", "OPENSSL_NO_RC4"))', 'cfg(CRYPTOGRAPHY_IS_AWSLC)'] } +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(CRYPTOGRAPHY_IS_LIBRESSL)', 'cfg(CRYPTOGRAPHY_IS_BORINGSSL)', 'cfg(CRYPTOGRAPHY_OSSLCONF, values("OPENSSL_NO_RC2", "OPENSSL_NO_RC4"))', 'cfg(CRYPTOGRAPHY_IS_AWSLC)', 'cfg(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER)'] } diff --git a/src/rust/cryptography-key-parsing/build.rs b/src/rust/cryptography-key-parsing/build.rs index 895d9c091a0b..c674cd92e2ca 100644 --- a/src/rust/cryptography-key-parsing/build.rs +++ b/src/rust/cryptography-key-parsing/build.rs @@ -17,6 +17,13 @@ fn main() { println!("cargo:rustc-cfg=CRYPTOGRAPHY_IS_AWSLC"); } + if let Ok(version) = env::var("DEP_OPENSSL_VERSION_NUMBER") { + let version = u64::from_str_radix(&version, 16).unwrap(); + if version >= 0x3050_0000 { + println!("cargo:rustc-cfg=CRYPTOGRAPHY_OPENSSL_350_OR_GREATER"); + } + } + if let Ok(vars) = env::var("DEP_OPENSSL_CONF") { for var in vars.split(',') { println!("cargo:rustc-cfg=CRYPTOGRAPHY_OSSLCONF=\"{var}\""); diff --git a/src/rust/cryptography-key-parsing/src/pkcs8.rs b/src/rust/cryptography-key-parsing/src/pkcs8.rs index 82078b38242e..c282e9dd98be 100644 --- a/src/rust/cryptography-key-parsing/src/pkcs8.rs +++ b/src/rust/cryptography-key-parsing/src/pkcs8.rs @@ -12,6 +12,31 @@ use cryptography_x509::pkcs8::EncryptedPrivateKeyInfo; use crate::MIN_DH_MODULUS_SIZE; use crate::{ec, pbe, rsa, KeyParsingError, KeyParsingResult}; +// RFC 9881 Section 6: ML-DSA Private Key Format +// ML-DSA-44-PrivateKey ::= CHOICE { +// seed [0] OCTET STRING (SIZE (32)), +// expandedKey OCTET STRING (SIZE (2560)), +// both SEQUENCE { +// seed OCTET STRING (SIZE (32)), +// expandedKey OCTET STRING (SIZE (2560)) +// } +// } +#[cfg(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER)] +#[derive(asn1::Asn1Read, asn1::Asn1Write)] +enum MlDsa44PrivateKeyValue<'a> { + #[implicit(0)] + Seed(&'a [u8]), + ExpandedKey(&'a [u8]), + Both(MlDsa44PrivateKeyBoth<'a>), +} + +#[cfg(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER)] +#[derive(asn1::Asn1Read, asn1::Asn1Write)] +struct MlDsa44PrivateKeyBoth<'a> { + seed: &'a [u8], + expanded_key: &'a [u8], +} + // RFC 5208 Section 5 #[derive(asn1::Asn1Read, asn1::Asn1Write)] pub struct PrivateKeyInfo<'a> { @@ -107,6 +132,47 @@ pub fn parse_private_key( openssl::pkey::Id::ED448, )?) } + #[cfg(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER)] + AlgorithmParameters::Mldsa44 => { + // RFC 9881 Section 6 defines three CHOICE formats for ML-DSA private keys: + // 1. seed [0] IMPLICIT OCTET STRING (SIZE (32)) - recommended + // 2. expandedKey OCTET STRING (SIZE (2560)) + // 3. both SEQUENCE { seed, expandedKey } + + let key_value = asn1::parse_single::>(k.private_key)?; + + let seed_bytes = match key_value { + MlDsa44PrivateKeyValue::Seed(seed) => { + // Validate seed size + if seed.len() != 32 { + return Err(KeyParsingError::InvalidKey); + } + seed + } + MlDsa44PrivateKeyValue::ExpandedKey(expanded) => { + // Validate expanded key size + if expanded.len() != 2560 { + return Err(KeyParsingError::InvalidKey); + } + // For now, we don't have a way to load from expanded key + // This would require OpenSSL API support + return Err(KeyParsingError::InvalidKey); + } + MlDsa44PrivateKeyValue::Both(both) => { + // Validate sizes + if both.seed.len() != 32 || both.expanded_key.len() != 2560 { + return Err(KeyParsingError::InvalidKey); + } + // Use the seed from the both format + both.seed + } + }; + + Ok(openssl::pkey::PKey::private_key_from_seed( + openssl::pkey_ml_dsa::Variant::MlDsa44, + seed_bytes, + )?) + } _ => Err(KeyParsingError::UnsupportedKeyType( k.algorithm.oid().clone(), @@ -441,6 +507,22 @@ pub fn serialize_private_key( (params, private_key_der) } _ => { + // If pkey type is implemented in a provider in OpenSSL, EVP_KEY_id() will return -1 + // meaning that the type is not really registered. Use different method to detect ML-DSA + #[cfg(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER)] + { + if let Some(ml_dsa_params) = pkey.ml_dsa(openssl::pkey_ml_dsa::Variant::MlDsa44)? { + // RFC 9881 Section 6: Use seed-only format (recommended for storage efficiency) + // Encode as [0] IMPLICIT OCTET STRING (SIZE (32)) + let seed = ml_dsa_params.private_key_seed()?; + let key_value = MlDsa44PrivateKeyValue::Seed(&seed); + let private_key_der = asn1::write_single(&key_value)?; + (AlgorithmParameters::Mldsa44, private_key_der) + } else { + unimplemented!("Unknown key type"); + } + } + #[cfg(not(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER))] unimplemented!("Unknown key type"); } }; diff --git a/src/rust/cryptography-key-parsing/src/spki.rs b/src/rust/cryptography-key-parsing/src/spki.rs index 7ce292b642d0..2cdf45afbd89 100644 --- a/src/rust/cryptography-key-parsing/src/spki.rs +++ b/src/rust/cryptography-key-parsing/src/spki.rs @@ -100,6 +100,12 @@ pub fn parse_public_key( Ok(openssl::pkey::PKey::from_dh(dh)?) } + #[cfg(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER)] + AlgorithmParameters::Mldsa44 => Ok(openssl::pkey::PKey::public_key_from_raw_bytes_ex( + k.subject_public_key.as_bytes(), + "ML-DSA-44", + ) + .map_err(|_| KeyParsingError::InvalidKey)?), _ => Err(KeyParsingError::UnsupportedKeyType( k.algorithm.oid().clone(), )), @@ -215,6 +221,22 @@ pub fn serialize_public_key( (params, pub_key_der) } _ => { + // If pkey type is implemented in a provider in OpenSSL, EVP_KEY_id() will return -1 + // meaning that the type is not really registered. Use different method to detect ML-DSA + #[cfg(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER)] + { + if pkey + .ml_dsa(openssl::pkey_ml_dsa::Variant::MlDsa44) + .ok() + .flatten() + .is_some() + { + (AlgorithmParameters::Mldsa44, pkey.raw_public_key()?) + } else { + unimplemented!("Unknown key type"); + } + } + #[cfg(not(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER))] unimplemented!("Unknown key type"); } }; diff --git a/src/rust/cryptography-x509/src/common.rs b/src/rust/cryptography-x509/src/common.rs index 6cec51bbfd05..2e61cf402f8c 100644 --- a/src/rust/cryptography-x509/src/common.rs +++ b/src/rust/cryptography-x509/src/common.rs @@ -182,6 +182,9 @@ pub enum AlgorithmParameters<'a> { #[defined_by(oid::PBE_WITH_SHA_AND_40_BIT_RC2_CBC)] PbeWithShaAnd40BitRc2Cbc(Pkcs12PbeParams<'a>), + #[defined_by(oid::ML_DSA_44)] + Mldsa44, + #[default] Other(asn1::ObjectIdentifier, Option>), } diff --git a/src/rust/cryptography-x509/src/oid.rs b/src/rust/cryptography-x509/src/oid.rs index edae48def631..3740e0c72f66 100644 --- a/src/rust/cryptography-x509/src/oid.rs +++ b/src/rust/cryptography-x509/src/oid.rs @@ -65,6 +65,8 @@ pub const EC_BRAINPOOLP512R1: asn1::ObjectIdentifier = asn1::oid!(1, 3, 36, 3, 3 pub const RSA_OID: asn1::ObjectIdentifier = asn1::oid!(1, 2, 840, 113549, 1, 1, 1); +pub const ML_DSA_44: asn1::ObjectIdentifier = asn1::oid!(2, 16, 840, 1, 101, 3, 4, 3, 17); + // Signing methods pub const ECDSA_WITH_SHA224_OID: asn1::ObjectIdentifier = asn1::oid!(1, 2, 840, 10045, 4, 3, 1); pub const ECDSA_WITH_SHA256_OID: asn1::ObjectIdentifier = asn1::oid!(1, 2, 840, 10045, 4, 3, 2); diff --git a/src/rust/src/backend/keys.rs b/src/rust/src/backend/keys.rs index b8fc6f247781..008a3df448f2 100644 --- a/src/rust/src/backend/keys.rs +++ b/src/rust/src/backend/keys.rs @@ -117,6 +117,18 @@ fn private_key_from_pkey<'p>( pkey: &openssl::pkey::PKeyRef, unsafe_skip_rsa_key_validation: bool, ) -> CryptographyResult> { + // Check for ML-DSA keys using the ml_dsa() method + #[cfg(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER)] + { + if pkey + .ml_dsa(openssl::pkey_ml_dsa::Variant::MlDsa44)? + .is_some() + { + return Ok(crate::backend::mldsa44::private_key_from_pkey(pkey) + .into_pyobject(py)? + .into_any()); + } + } match pkey.id() { openssl::pkey::Id::RSA => Ok(crate::backend::rsa::private_key_from_pkey( pkey, @@ -245,6 +257,19 @@ fn public_key_from_pkey<'p>( pkey: &openssl::pkey::PKeyRef, id: openssl::pkey::Id, ) -> CryptographyResult> { + // Check for ML-DSA keys using the ml_dsa() method + #[cfg(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER)] + { + if pkey + .ml_dsa(openssl::pkey_ml_dsa::Variant::MlDsa44)? + .is_some() + { + return Ok(crate::backend::mldsa44::public_key_from_pkey(pkey) + .into_pyobject(py)? + .into_any()); + } + } + // `id` is a separate argument so we can test this while passing something // unsupported. match id { diff --git a/src/rust/src/backend/mldsa44.rs b/src/rust/src/backend/mldsa44.rs new file mode 100644 index 000000000000..000f9da185b0 --- /dev/null +++ b/src/rust/src/backend/mldsa44.rs @@ -0,0 +1,233 @@ +// This file is dual licensed under the terms of the Apache License, Version +// 2.0, and the BSD License. See the LICENSE file in the root of this repository +// for complete details. + +use crate::backend::utils; +use crate::buf::CffiBuf; +use crate::error::{CryptographyError, CryptographyResult}; +use crate::exceptions; + +#[pyo3::pyclass(frozen, module = "cryptography.hazmat.bindings._rust.openssl.mldsa44")] +pub(crate) struct MlDsa44PrivateKey { + pkey: openssl::pkey::PKey, +} + +#[pyo3::pyclass(frozen, module = "cryptography.hazmat.bindings._rust.openssl.mldsa44")] +pub(crate) struct MlDsa44PublicKey { + pkey: openssl::pkey::PKey, +} + +#[pyo3::pyfunction] +fn generate_key() -> CryptographyResult { + Ok(MlDsa44PrivateKey { + pkey: openssl::pkey::PKey::generate_ml_dsa(openssl::pkey_ml_dsa::Variant::MlDsa44)?, + }) +} + +pub(crate) fn private_key_from_pkey( + pkey: &openssl::pkey::PKeyRef, +) -> MlDsa44PrivateKey { + MlDsa44PrivateKey { + pkey: pkey.to_owned(), + } +} + +pub(crate) fn public_key_from_pkey( + pkey: &openssl::pkey::PKeyRef, +) -> MlDsa44PublicKey { + MlDsa44PublicKey { + pkey: pkey.to_owned(), + } +} + +#[pyo3::pyfunction] +fn from_seed_bytes(data: CffiBuf<'_>) -> pyo3::PyResult { + let pkey = openssl::pkey::PKey::private_key_from_seed( + openssl::pkey_ml_dsa::Variant::MlDsa44, + data.as_bytes(), + ) + .map_err(|_| pyo3::exceptions::PyValueError::new_err("Invalid ML-DSA-44 private key"))?; + Ok(MlDsa44PrivateKey { pkey }) +} + +#[pyo3::pyfunction] +fn from_public_bytes(data: &[u8]) -> pyo3::PyResult { + let pkey = openssl::pkey::PKey::public_key_from_raw_bytes_ex(data, "ML-DSA-44") + .map_err(|_| pyo3::exceptions::PyValueError::new_err("Invalid ML-DSA-44 public key"))?; + Ok(MlDsa44PublicKey { pkey }) +} + +#[pyo3::pymethods] +impl MlDsa44PrivateKey { + fn sign<'p>( + &self, + py: pyo3::Python<'p>, + data: CffiBuf<'_>, + ) -> CryptographyResult> { + let mut signer = openssl::sign::Signer::new_without_digest(&self.pkey)?; + let len = signer.len()?; + Ok(pyo3::types::PyBytes::new_with(py, len, |b| { + let n = signer + .sign_oneshot(b, data.as_bytes()) + .map_err(CryptographyError::from)?; + assert_eq!(n, b.len()); + Ok(()) + })?) + } + + fn sign_with_context<'p>( + &self, + py: pyo3::Python<'p>, + data: CffiBuf<'_>, + context: CffiBuf<'_>, + ) -> CryptographyResult> { + let signature = openssl::pkey_ml_dsa::sign_with_context( + &self.pkey, + openssl::pkey_ml_dsa::Variant::MlDsa44, + data.as_bytes(), + context.as_bytes(), + )?; + Ok(pyo3::types::PyBytes::new(py, &signature)) + } + + fn public_key(&self) -> CryptographyResult { + let raw_bytes = self.pkey.raw_public_key()?; + Ok(MlDsa44PublicKey { + pkey: openssl::pkey::PKey::public_key_from_raw_bytes_ex(&raw_bytes, "ML-DSA-44")?, + }) + } + + fn seed_bytes<'p>( + &self, + py: pyo3::Python<'p>, + ) -> CryptographyResult> { + // Serialize to DER to extract the seed (RFC 9881 Section 6) + // The seed is stored in the privateKey OCTET STRING as [0] IMPLICIT OCTET STRING (SIZE (32)) + let der = self.pkey.private_key_to_der()?; + + // The seed is in the last 34 bytes of the DER encoding + // Structure: ... OCTET STRING { [0] tag (0x80) + length (0x20) + 32-byte seed } + if der.len() < 34 { + return Err(CryptographyError::from( + pyo3::exceptions::PyValueError::new_err( + "Invalid ML-DSA-44 private key DER encoding", + ), + )); + } + + // Skip the tag (0x80) and length (0x20) bytes to get the 32-byte seed + let seed = &der[der.len() - 32..]; + Ok(pyo3::types::PyBytes::new(py, seed)) + } + + fn private_bytes<'p>( + slf: &pyo3::Bound<'p, Self>, + py: pyo3::Python<'p>, + encoding: &pyo3::Bound<'p, pyo3::PyAny>, + format: &pyo3::Bound<'p, pyo3::PyAny>, + encryption_algorithm: &pyo3::Bound<'p, pyo3::PyAny>, + ) -> CryptographyResult> { + utils::pkey_private_bytes( + py, + slf, + &slf.borrow().pkey, + encoding, + format, + encryption_algorithm, + true, + true, + ) + } + + fn __copy__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyRef<'_, Self> { + slf + } + + fn __deepcopy__<'p>( + slf: pyo3::PyRef<'p, Self>, + _memo: &pyo3::Bound<'p, pyo3::PyAny>, + ) -> pyo3::PyRef<'p, Self> { + slf + } +} + +#[pyo3::pymethods] +impl MlDsa44PublicKey { + fn verify(&self, signature: CffiBuf<'_>, data: CffiBuf<'_>) -> CryptographyResult<()> { + let valid = openssl::sign::Verifier::new_without_digest(&self.pkey)? + .verify_oneshot(signature.as_bytes(), data.as_bytes()) + .unwrap_or(false); + + if !valid { + return Err(CryptographyError::from( + exceptions::InvalidSignature::new_err(()), + )); + } + + Ok(()) + } + + fn verify_with_context( + &self, + signature: CffiBuf<'_>, + data: CffiBuf<'_>, + context: CffiBuf<'_>, + ) -> CryptographyResult<()> { + let valid = openssl::pkey_ml_dsa::verify_with_context( + &self.pkey, + openssl::pkey_ml_dsa::Variant::MlDsa44, + data.as_bytes(), + signature.as_bytes(), + context.as_bytes(), + ) + .unwrap_or(false); + + if !valid { + return Err(CryptographyError::from( + exceptions::InvalidSignature::new_err(()), + )); + } + + Ok(()) + } + + fn public_bytes_raw<'p>( + &self, + py: pyo3::Python<'p>, + ) -> CryptographyResult> { + let raw_bytes = self.pkey.raw_public_key()?; + Ok(pyo3::types::PyBytes::new(py, &raw_bytes)) + } + + fn public_bytes<'p>( + slf: &pyo3::Bound<'p, Self>, + py: pyo3::Python<'p>, + encoding: &pyo3::Bound<'p, pyo3::PyAny>, + format: &pyo3::Bound<'p, pyo3::PyAny>, + ) -> CryptographyResult> { + utils::pkey_public_bytes(py, slf, &slf.borrow().pkey, encoding, format, true, true) + } + + fn __eq__(&self, other: pyo3::PyRef<'_, Self>) -> bool { + self.pkey.public_eq(&other.pkey) + } + + fn __copy__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyRef<'_, Self> { + slf + } + + fn __deepcopy__<'p>( + slf: pyo3::PyRef<'p, Self>, + _memo: &pyo3::Bound<'p, pyo3::PyAny>, + ) -> pyo3::PyRef<'p, Self> { + slf + } +} + +#[pyo3::pymodule(gil_used = false)] +pub(crate) mod mldsa44 { + #[pymodule_export] + use super::{ + from_public_bytes, from_seed_bytes, generate_key, MlDsa44PrivateKey, MlDsa44PublicKey, + }; +} diff --git a/src/rust/src/backend/mod.rs b/src/rust/src/backend/mod.rs index a9133cafb8c8..68b3cb0f4aba 100644 --- a/src/rust/src/backend/mod.rs +++ b/src/rust/src/backend/mod.rs @@ -21,6 +21,8 @@ pub(crate) mod hmac; pub(crate) mod hpke; pub(crate) mod kdf; pub(crate) mod keys; +#[cfg(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER)] +pub(crate) mod mldsa44; pub(crate) mod poly1305; pub(crate) mod rand; pub(crate) mod rsa; diff --git a/src/rust/src/lib.rs b/src/rust/src/lib.rs index 093e9ccf88ab..fd361e6dd475 100644 --- a/src/rust/src/lib.rs +++ b/src/rust/src/lib.rs @@ -241,6 +241,9 @@ mod _rust { use crate::backend::kdf::kdf; #[pymodule_export] use crate::backend::keys::keys; + #[cfg(CRYPTOGRAPHY_OPENSSL_350_OR_GREATER)] + #[pymodule_export] + use crate::backend::mldsa44::mldsa44; #[pymodule_export] use crate::backend::poly1305::poly1305; #[pymodule_export] diff --git a/src/rust/src/pkcs7.rs b/src/rust/src/pkcs7.rs index 06814f777d50..dfbfaf72af92 100644 --- a/src/rust/src/pkcs7.rs +++ b/src/rust/src/pkcs7.rs @@ -509,6 +509,15 @@ fn sign_and_serialize<'p>( let ka_vec = cryptography_keepalive::KeepAlive::new(); let ka_bytes = cryptography_keepalive::KeepAlive::new(); for (cert, py_private_key, py_hash_alg, rsa_padding) in py_signers.iter() { + // For ML-DSA, py_hash_alg is None, and we have to use SHA-512 for message digest + // per RFC 9882 Section 3.3 + let hash_alg_for_digest = if py_hash_alg.is_none() + && (py_private_key.is_instance(&types::ML_DSA_44_PRIVATE_KEY.get(py)?)?) + { + types::HASHES_MODULE.get(py)?.getattr("SHA512")?.call0()? + } else { + py_hash_alg.clone() + }; let (authenticated_attrs, signature) = if options.contains(&types::PKCS7_NO_ATTRIBUTES.get(py)?)? { ( @@ -538,7 +547,7 @@ fn sign_and_serialize<'p>( }, ]; - let digest = x509::ocsp::hash_data(py, py_hash_alg, &data_with_header)?; + let digest = x509::ocsp::hash_data(py, &hash_alg_for_digest, &data_with_header)?; let digest_wrapped = ka_vec.add(asn1::write_single(&digest.as_bytes())?); authenticated_attrs.push(Attribute { type_id: PKCS7_MESSAGE_DIGEST_OID, @@ -574,7 +583,7 @@ fn sign_and_serialize<'p>( ) }; - let digest_alg = x509::ocsp::HASH_NAME_TO_ALGORITHM_IDENTIFIERS[&*py_hash_alg + let digest_alg = x509::ocsp::HASH_NAME_TO_ALGORITHM_IDENTIFIERS[&*hash_alg_for_digest .getattr(pyo3::intern!(py, "name"))? .extract::()?] .clone(); diff --git a/src/rust/src/types.rs b/src/rust/src/types.rs index 872cae077a46..6b8019fa88f5 100644 --- a/src/rust/src/types.rs +++ b/src/rust/src/types.rs @@ -404,6 +404,15 @@ pub static ED448_PUBLIC_KEY: LazyPyImport = LazyPyImport::new( &["Ed448PublicKey"], ); +pub static ML_DSA_44_PRIVATE_KEY: LazyPyImport = LazyPyImport::new( + "cryptography.hazmat.primitives.asymmetric.mldsa44", + &["MlDsa44PrivateKey"], +); +pub static ML_DSA_44_PUBLIC_KEY: LazyPyImport = LazyPyImport::new( + "cryptography.hazmat.primitives.asymmetric.mldsa44", + &["MlDsa44PublicKey"], +); + pub static DSA_PRIVATE_KEY: LazyPyImport = LazyPyImport::new( "cryptography.hazmat.primitives.asymmetric.dsa", &["DSAPrivateKey"], diff --git a/src/rust/src/x509/sign.rs b/src/rust/src/x509/sign.rs index 981ae28684c3..250b63811e1b 100644 --- a/src/rust/src/x509/sign.rs +++ b/src/rust/src/x509/sign.rs @@ -30,6 +30,7 @@ static HASH_OIDS_TO_HASH: LazyLock> = Laz h.insert(&oid::SHA3_256_NIST_OID, "SHA3_256"); h.insert(&oid::SHA3_384_NIST_OID, "SHA3_384"); h.insert(&oid::SHA3_512_NIST_OID, "SHA3_512"); + h.insert(&oid::ML_DSA_44, "ML_DSA_44"); h }); @@ -40,6 +41,7 @@ pub(crate) enum KeyType { Ec, Ed25519, Ed448, + Mldsa44, } enum HashType { @@ -68,9 +70,11 @@ pub(crate) fn identify_key_type( Ok(KeyType::Ed25519) } else if private_key.is_instance(&types::ED448_PRIVATE_KEY.get(py)?)? { Ok(KeyType::Ed448) + } else if private_key.is_instance(&types::ML_DSA_44_PRIVATE_KEY.get(py)?)? { + Ok(KeyType::Mldsa44) } else { Err(pyo3::exceptions::PyTypeError::new_err( - "Key must be an rsa, dsa, ec, ed25519, or ed448 private key.", + "Key must be an rsa, dsa, ec, ed25519, ed448, or ml-dsa private key.", )) } } @@ -189,7 +193,13 @@ pub(crate) fn compute_signature_algorithm<'p>( (KeyType::Ed25519 | KeyType::Ed448, _) => Err(pyo3::exceptions::PyValueError::new_err( "Algorithm must be None when signing via ed25519 or ed448", )), - + (KeyType::Mldsa44, HashType::None) => Ok(common::AlgorithmIdentifier { + oid: asn1::DefinedByMarker::marker(), + params: common::AlgorithmParameters::Mldsa44, + }), + (KeyType::Mldsa44, _) => Err(pyo3::exceptions::PyValueError::new_err( + "Algorithm must be None when signing via ML-DSA", + )), (KeyType::Ec, HashType::Sha224) => Ok(common::AlgorithmIdentifier { oid: asn1::DefinedByMarker::marker(), params: common::AlgorithmParameters::EcDsaWithSha224(None), @@ -295,7 +305,7 @@ pub(crate) fn sign_data<'p>( let key_type = identify_key_type(py, private_key.clone())?; let signature = match key_type { - KeyType::Ed25519 | KeyType::Ed448 => { + KeyType::Ed25519 | KeyType::Ed448 | KeyType::Mldsa44 => { private_key.call_method1(pyo3::intern!(py, "sign"), (data,))? } KeyType::Ec => { @@ -338,7 +348,7 @@ pub(crate) fn verify_signature_with_signature_algorithm<'p>( identify_signature_algorithm_parameters(py, signature_algorithm)?; let py_signature_hash_algorithm = identify_signature_hash_algorithm(py, signature_algorithm)?; match key_type { - KeyType::Ed25519 | KeyType::Ed448 => { + KeyType::Ed25519 | KeyType::Ed448 | KeyType::Mldsa44 => { issuer_public_key.call_method1(pyo3::intern!(py, "verify"), (signature, data))? } KeyType::Ec => issuer_public_key.call_method1( @@ -376,9 +386,11 @@ pub(crate) fn identify_public_key_type( Ok(KeyType::Ed25519) } else if public_key.is_instance(&types::ED448_PUBLIC_KEY.get(py)?)? { Ok(KeyType::Ed448) + } else if public_key.is_instance(&types::ML_DSA_44_PUBLIC_KEY.get(py)?)? { + Ok(KeyType::Mldsa44) } else { Err(pyo3::exceptions::PyTypeError::new_err( - "Key must be an rsa, dsa, ec, ed25519, or ed448 public key.", + "Key must be an rsa, dsa, ec, ed25519, ed448, or ml-dsa-44 public key.", )) } } @@ -410,6 +422,7 @@ fn identify_key_type_for_algorithm_params( | common::AlgorithmParameters::DsaWithSha256(..) | common::AlgorithmParameters::DsaWithSha384(..) | common::AlgorithmParameters::DsaWithSha512(..) => Ok(KeyType::Dsa), + common::AlgorithmParameters::Mldsa44 => Ok(KeyType::Mldsa44), _ => Err(pyo3::exceptions::PyValueError::new_err( "Unsupported signature algorithm", )), @@ -461,6 +474,8 @@ pub(crate) fn identify_signature_hash_algorithm<'p>( })?; hash_oid_py_hash(py, pss.hash_algorithm.oid().clone()) } + // ML-DSA algorithms don't use hash algorithms + common::AlgorithmParameters::Mldsa44 => Ok(py.None().into_bound(py)), _ => { let py_sig_alg_oid = oid_to_py_oid(py, signature_algorithm.oid())?; let hash_alg = sig_oids_to_hash.get_item(py_sig_alg_oid); diff --git a/tests/hazmat/primitives/test_mldsa44.py b/tests/hazmat/primitives/test_mldsa44.py new file mode 100644 index 000000000000..6fdeaf453cbb --- /dev/null +++ b/tests/hazmat/primitives/test_mldsa44.py @@ -0,0 +1,461 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + + +import copy + +import pytest + +from cryptography.exceptions import InvalidSignature, _Reasons +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.mldsa44 import ( + MlDsa44PrivateKey, + MlDsa44PublicKey, +) + +from ...doubles import DummyKeySerializationEncryption +from ...utils import raises_unsupported_algorithm + + +@pytest.mark.supported( + only_if=lambda backend: not backend.mldsa44_supported(), + skip_message="Requires OpenSSL without ML-DSA-44 support", +) +def test_mldsa44_unsupported(backend): + with raises_unsupported_algorithm( + _Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM + ): + MlDsa44PublicKey.from_public_bytes(b"0" * 1312) + + with raises_unsupported_algorithm( + _Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM + ): + MlDsa44PrivateKey.from_seed_bytes(b"0" * 2560) + + with raises_unsupported_algorithm( + _Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM + ): + MlDsa44PrivateKey.generate() + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa44_supported(), + skip_message="Requires OpenSSL with ML-DSA-44 support", +) +class TestMlDsa44Signing: + def test_sign_verify(self, backend): + key = MlDsa44PrivateKey.generate() + message = b"test data" + signature = key.sign(message) + # ML-DSA-44 signatures are 2420 bytes according + # to FIPS 204 section 4 table 2 + assert len(signature) == 2420 + public_key = key.public_key() + public_key.verify(signature, message) + + def test_invalid_signature(self, backend): + key = MlDsa44PrivateKey.generate() + signature = key.sign(b"test data") + with pytest.raises(InvalidSignature): + key.public_key().verify(signature, b"wrong data") + + with pytest.raises(InvalidSignature): + key.public_key().verify(b"0" * len(signature), b"test data") + + def test_sign_verify_buffer(self, backend): + key = MlDsa44PrivateKey.generate() + data = bytearray(b"test data") + signature = key.sign(data) + key.public_key().verify(bytearray(signature), data) + + def test_sign_verify_with_context(self, backend): + key = MlDsa44PrivateKey.generate() + message = b"test data" + context = b"test context" + signature = key.sign_with_context(message, context) + # ML-DSA-44 signatures are 2420 bytes according + # to FIPS 204 section 4 table 2 + assert len(signature) == 2420 + public_key = key.public_key() + public_key.verify_with_context(signature, message, context) + + def test_sign_verify_with_empty_context(self, backend): + key = MlDsa44PrivateKey.generate() + message = b"test data" + context = b"" + signature = key.sign_with_context(message, context) + public_key = key.public_key() + public_key.verify_with_context(signature, message, context) + + def test_sign_verify_with_context_buffer(self, backend): + key = MlDsa44PrivateKey.generate() + data = bytearray(b"test data") + context = bytearray(b"test context") + signature = key.sign_with_context(data, context) + key.public_key().verify_with_context( + bytearray(signature), data, context + ) + + def test_invalid_signature_with_context(self, backend): + key = MlDsa44PrivateKey.generate() + signature = key.sign_with_context(b"test data", b"context") + # Wrong message + with pytest.raises(InvalidSignature): + key.public_key().verify_with_context( + signature, b"wrong data", b"context" + ) + # Wrong context + with pytest.raises(InvalidSignature): + key.public_key().verify_with_context( + signature, b"test data", b"wrong context" + ) + # Invalid signature bytes + with pytest.raises(InvalidSignature): + key.public_key().verify_with_context( + b"0" * len(signature), b"test data", b"context" + ) + + def test_context_not_interchangeable(self, backend): + key = MlDsa44PrivateKey.generate() + message = b"test data" + context = b"test context" + + # Sign with context + signature_with_context = key.sign_with_context(message, context) + + # Sign without context + signature_without_context = key.sign(message) + + public_key = key.public_key() + + # Signature with context should not verify without context + with pytest.raises(InvalidSignature): + public_key.verify(signature_with_context, message) + + # Signature without context should not verify with context + with pytest.raises(InvalidSignature): + public_key.verify_with_context( + signature_without_context, message, context + ) + + def test_generate(self, backend): + key = MlDsa44PrivateKey.generate() + assert key + assert key.public_key() + + def test_pub_priv_bytes_raw(self, backend): + key = MlDsa44PrivateKey.generate() + seed = key.seed_bytes() + public_raw = key.public_key().public_bytes_raw() + + # ML-DSA-44 key sizes + assert len(seed) == 32 + assert len(public_raw) == 1312 + + # Verify we can recreate the key from the seed + MlDsa44PrivateKey.from_seed_bytes(seed) + + # Verify we can load the public key back + loaded_public = MlDsa44PublicKey.from_public_bytes(public_raw) + + # Verify the loaded keys work + message = b"test" + sig = key.sign(message) + loaded_public.verify(sig, message) + + def test_load_public_bytes(self, backend): + public_key = MlDsa44PrivateKey.generate().public_key() + public_bytes = public_key.public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + public_key2 = MlDsa44PublicKey.from_public_bytes(public_bytes) + assert public_bytes == public_key2.public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + + def test_invalid_type_public_bytes(self, backend): + with pytest.raises(TypeError): + MlDsa44PublicKey.from_public_bytes( + object() # type: ignore[arg-type] + ) + + def test_invalid_type_private_bytes(self, backend): + with pytest.raises(TypeError): + MlDsa44PrivateKey.from_seed_bytes( + object() # type: ignore[arg-type] + ) + + def test_invalid_length_from_public_bytes(self, backend): + with pytest.raises(ValueError): + MlDsa44PublicKey.from_public_bytes(b"a" * 1311) + with pytest.raises(ValueError): + MlDsa44PublicKey.from_public_bytes(b"a" * 1313) + + def test_invalid_length_from_private_bytes(self, backend): + with pytest.raises(ValueError): + MlDsa44PrivateKey.from_seed_bytes(b"a" * 2559) + with pytest.raises(ValueError): + MlDsa44PrivateKey.from_seed_bytes(b"a" * 2561) + + def test_invalid_private_bytes(self, backend): + key = MlDsa44PrivateKey.generate() + with pytest.raises(TypeError): + key.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + None, # type: ignore[arg-type] + ) + with pytest.raises(ValueError): + key.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.Raw, + DummyKeySerializationEncryption(), + ) + + with pytest.raises(ValueError): + key.private_bytes( + serialization.Encoding.Raw, + serialization.PrivateFormat.PKCS8, + DummyKeySerializationEncryption(), + ) + + with pytest.raises(ValueError): + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.Raw, + serialization.NoEncryption(), + ) + + with pytest.raises(ValueError): + key.private_bytes( + serialization.Encoding.DER, + serialization.PrivateFormat.OpenSSH, + serialization.NoEncryption(), + ) + + def test_invalid_public_bytes(self, backend): + key = MlDsa44PrivateKey.generate().public_key() + with pytest.raises(ValueError): + key.public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + with pytest.raises(ValueError): + key.public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.PKCS1 + ) + + with pytest.raises(ValueError): + key.public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.Raw + ) + + with pytest.raises(ValueError): + key.public_bytes( + serialization.Encoding.DER, serialization.PublicFormat.OpenSSH + ) + + @pytest.mark.parametrize( + ("encoding", "fmt", "encryption", "passwd", "load_func"), + [ + ( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(b"password"), + b"password", + serialization.load_pem_private_key, + ), + ( + serialization.Encoding.DER, + serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(b"password"), + b"password", + serialization.load_der_private_key, + ), + ( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + None, + serialization.load_pem_private_key, + ), + ( + serialization.Encoding.DER, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + None, + serialization.load_der_private_key, + ), + ], + ) + def test_round_trip_private_serialization( + self, encoding, fmt, encryption, passwd, load_func, backend + ): + key = MlDsa44PrivateKey.generate() + serialized = key.private_bytes(encoding, fmt, encryption) + loaded_key = load_func(serialized, passwd, backend) + assert isinstance(loaded_key, MlDsa44PrivateKey) + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa44_supported(), + skip_message="Requires OpenSSL with ML-DSA-44 support", +) +def test_public_key_equality(backend): + key1 = MlDsa44PrivateKey.generate() + key2_priv = MlDsa44PrivateKey.generate() + + # Same key should be equal + pub1 = key1.public_key() + pub1_copy = key1.public_key() + assert pub1 == pub1_copy + + # Different keys should not be equal + pub2 = key2_priv.public_key() + assert pub1 != pub2 + assert pub1 != object() + + with pytest.raises(TypeError): + pub1 < pub2 # type: ignore[operator] + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa44_supported(), + skip_message="Requires OpenSSL with ML-DSA-44 support", +) +def test_public_key_copy(backend): + key1 = MlDsa44PrivateKey.generate().public_key() + key2 = copy.copy(key1) + assert key1 == key2 + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa44_supported(), + skip_message="Requires OpenSSL with ML-DSA-44 support", +) +def test_public_key_deepcopy(backend): + key1 = MlDsa44PrivateKey.generate().public_key() + key2 = copy.deepcopy(key1) + assert key1 == key2 + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa44_supported(), + skip_message="Requires OpenSSL with ML-DSA-44 support", +) +def test_private_key_copy(backend): + key1 = MlDsa44PrivateKey.generate() + key2 = copy.copy(key1) + # Verify both keys work correctly (ML-DSA signatures are randomized) + message = b"test" + sig1 = key1.sign(message) + sig2 = key2.sign(message) + # Verify each signature with the corresponding public key + key1.public_key().verify(sig1, message) + key2.public_key().verify(sig2, message) + # Verify cross-validation works (same key material) + key1.public_key().verify(sig2, message) + key2.public_key().verify(sig1, message) + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa44_supported(), + skip_message="Requires OpenSSL with ML-DSA-44 support", +) +def test_private_key_deepcopy(backend): + key1 = MlDsa44PrivateKey.generate() + key2 = copy.deepcopy(key1) + # Verify both keys work correctly (ML-DSA signatures are randomized) + message = b"test" + sig1 = key1.sign(message) + sig2 = key2.sign(message) + # Verify each signature with the corresponding public key + key1.public_key().verify(sig1, message) + key2.public_key().verify(sig2, message) + # Verify cross-validation works (same key material) + key1.public_key().verify(sig2, message) + key2.public_key().verify(sig1, message) + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa44_supported(), + skip_message="Requires OpenSSL with ML-DSA-44 support", +) +def test_rfc9881_seed_only_format(backend): + """ + RFC 9881 Section 6 defines ML-DSA-44-PrivateKey as a CHOICE with three + formats: + 1. seed [0] OCTET STRING (SIZE (32)) - recommended for storage efficiency + 2. expandedKey OCTET STRING (SIZE (2560)) + 3. both SEQUENCE { seed, expandedKey } + + This test verifies that serialization uses the recommended seed-only + format with [0] IMPLICIT tag. + """ + key = MlDsa44PrivateKey.generate() + + # Serialize to DER + der_bytes = key.private_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + # Parse PKCS#8 to extract the privateKey field + def extract_private_key_octets(der): + """Extract the privateKey OCTET STRING value from PKCS#8""" + offset = 0 + + # Skip SEQUENCE tag and length + assert der[offset] == 0x30 # SEQUENCE + offset += 1 + length_byte = der[offset] + if length_byte & 0x80: + num_octets = length_byte & 0x7F + offset += 1 + num_octets + else: + offset += 1 + + # Skip version INTEGER + assert der[offset] == 0x02 # INTEGER + offset += 1 + version_len = der[offset] + offset += 1 + version_len + + # Skip algorithm SEQUENCE + assert der[offset] == 0x30 # SEQUENCE + offset += 1 + alg_len = der[offset] + offset += 1 + alg_len + + # Extract privateKey OCTET STRING contents + assert der[offset] == 0x04 # OCTET STRING + offset += 1 + pk_len = der[offset] + offset += 1 + + return der[offset : offset + pk_len] + + mldsa_value = extract_private_key_octets(der_bytes) + + # Verify RFC 9881 seed-only format: [0] IMPLICIT OCTET STRING (SIZE (32)) + # Tag 0x80 = context-specific [0] primitive + # Length 0x20 = 32 bytes + assert mldsa_value[0] == 0x80, ( + "Expected context-specific [0] tag for seed-only format" + ) + assert mldsa_value[1] == 0x20, "Expected length of 32 bytes for seed" + assert len(mldsa_value) == 34, ( + "Expected 34 total bytes (tag + length + 32-byte seed)" + ) + + # Verify the key can be loaded back + loaded_key = serialization.load_der_private_key(der_bytes, password=None) + assert isinstance(loaded_key, MlDsa44PrivateKey) + + # Verify loaded key works + message = b"test message" + signature = key.sign(message) + loaded_key.public_key().verify(signature, message) From 0c90a8d0f1ad1bbd6416792714c307cd0833c37d Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Fri, 23 Jan 2026 21:58:31 +0200 Subject: [PATCH 4/6] ML-DSA-44: add wycheproof test vectors Signed-off-by: Alexander Bokovoy --- tests/wycheproof/test_mldsa.py | 67 ++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/wycheproof/test_mldsa.py diff --git a/tests/wycheproof/test_mldsa.py b/tests/wycheproof/test_mldsa.py new file mode 100644 index 000000000000..28285729cd69 --- /dev/null +++ b/tests/wycheproof/test_mldsa.py @@ -0,0 +1,67 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +import binascii + +import pytest + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.mldsa44 import ( + MlDsa44PrivateKey, + MlDsa44PublicKey, +) + +from .utils import wycheproof_tests + + +@pytest.mark.supported( + only_if=lambda backend: backend.mldsa44_supported(), + skip_message="Requires OpenSSL with ML-DSA-44 support", +) +@wycheproof_tests("mldsa_44_sign_seed_test.json") +def test_mldsa44_signature(backend, wycheproof): + if wycheproof.has_flag("Internal"): + alg = getattr(wycheproof.testfiledata, "algorithm", None) + pytest.skip(f"Internal implementation test for {alg}") + + assert wycheproof.testgroup["type"] == "MlDsaSign" + + private_key = MlDsa44PrivateKey.from_seed_bytes( + binascii.unhexlify(wycheproof.testgroup["privateSeed"]) + ) + public_key = MlDsa44PublicKey.from_public_bytes( + binascii.unhexlify(wycheproof.testgroup["publicKey"]) + ) + + pkey_pkcs8 = wycheproof.testgroup.get("privateKeyPkcs8", None) + if pkey_pkcs8 is not None: + serialization.load_der_private_key( + binascii.unhexlify(pkey_pkcs8), None + ) + + testkey = private_key.public_key() + + assert public_key.public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) == testkey.public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + + msg = binascii.unhexlify(wycheproof.testcase["msg"]) + expected_sig = binascii.unhexlify(wycheproof.testcase["sig"]) + context = wycheproof.testcase.get("ctx", None) + if wycheproof.valid: + if context is not None: + context = binascii.unhexlify(context) + testkey.verify_with_context(expected_sig, msg, context) + else: + public_key.verify(expected_sig, msg) + else: + with pytest.raises(InvalidSignature): + if context is not None: + context = binascii.unhexlify(context) + testkey.verify_with_context(expected_sig, msg, context) + else: + public_key.verify(expected_sig, msg) From 034cbc44fa2c1184ec04df4f971b8dfe5172c434 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Fri, 27 Feb 2026 09:24:52 +0200 Subject: [PATCH 5/6] ML-DSA-44: use typed encoding/format parameters Replace generic &pyo3::Bound<'_, pyo3::PyAny> with concrete crate::serialization::Encoding/PrivateFormat/PublicFormat types in private_bytes() and public_bytes() methods, consistent with how other key backends are implemented. Signed-off-by: Alexander Bokovoy --- src/rust/src/backend/mldsa44.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/rust/src/backend/mldsa44.rs b/src/rust/src/backend/mldsa44.rs index 000f9da185b0..d0f3225b2dd2 100644 --- a/src/rust/src/backend/mldsa44.rs +++ b/src/rust/src/backend/mldsa44.rs @@ -123,8 +123,8 @@ impl MlDsa44PrivateKey { fn private_bytes<'p>( slf: &pyo3::Bound<'p, Self>, py: pyo3::Python<'p>, - encoding: &pyo3::Bound<'p, pyo3::PyAny>, - format: &pyo3::Bound<'p, pyo3::PyAny>, + encoding: crate::serialization::Encoding, + format: crate::serialization::PrivateFormat, encryption_algorithm: &pyo3::Bound<'p, pyo3::PyAny>, ) -> CryptographyResult> { utils::pkey_private_bytes( @@ -202,8 +202,8 @@ impl MlDsa44PublicKey { fn public_bytes<'p>( slf: &pyo3::Bound<'p, Self>, py: pyo3::Python<'p>, - encoding: &pyo3::Bound<'p, pyo3::PyAny>, - format: &pyo3::Bound<'p, pyo3::PyAny>, + encoding: crate::serialization::Encoding, + format: crate::serialization::PublicFormat, ) -> CryptographyResult> { utils::pkey_public_bytes(py, slf, &slf.borrow().pkey, encoding, format, true, true) } From 3f9a6c8b2b9a3450a00fa72dd15f2f603caabb2d Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Fri, 27 Feb 2026 09:25:51 +0200 Subject: [PATCH 6/6] ML-DSA-44: pass seed by value in pkcs8 serialization Remove the unnecessary & reference when constructing MlDsa44PrivateKeyValue::Seed. Signed-off-by: Alexander Bokovoy --- src/rust/cryptography-key-parsing/src/pkcs8.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rust/cryptography-key-parsing/src/pkcs8.rs b/src/rust/cryptography-key-parsing/src/pkcs8.rs index c282e9dd98be..8c3ce5684762 100644 --- a/src/rust/cryptography-key-parsing/src/pkcs8.rs +++ b/src/rust/cryptography-key-parsing/src/pkcs8.rs @@ -515,7 +515,7 @@ pub fn serialize_private_key( // RFC 9881 Section 6: Use seed-only format (recommended for storage efficiency) // Encode as [0] IMPLICIT OCTET STRING (SIZE (32)) let seed = ml_dsa_params.private_key_seed()?; - let key_value = MlDsa44PrivateKeyValue::Seed(&seed); + let key_value = MlDsa44PrivateKeyValue::Seed(seed); let private_key_der = asn1::write_single(&key_value)?; (AlgorithmParameters::Mldsa44, private_key_der) } else {