Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/development/custom-vectors/mldsa.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
ML-DSA vector creation
======================

This page documents the code that was used to generate the ML-DSA test
vectors. These vectors are used to verify:

* Unsupported ML-DSA variants (i.e. variants other than ML-DSA-65) are
correctly rejected when loading keys.
* ML-DSA-65 private keys without a seed are correctly rejected.

The following Python script was run to generate the vector files.

.. literalinclude:: /development/custom-vectors/mldsa/generate_mldsa.py

Download link: :download:`generate_mldsa.py
</development/custom-vectors/mldsa/generate_mldsa.py>`

ML-DSA-44 public key
--------------------

The public key was derived from the private key using the OpenSSL CLI
(requires OpenSSL 3.5+):

.. code-block:: console

$ openssl pkey -in mldsa44_priv.der -inform DER -pubout -outform DER -out mldsa44_pub.der
74 changes: 74 additions & 0 deletions docs/development/custom-vectors/mldsa/generate_mldsa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# 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 os

from cryptography import x509
from cryptography.hazmat import asn1


@asn1.sequence
class AlgorithmIdentifier:
algorithm: x509.ObjectIdentifier


@asn1.sequence
class OneAsymmetricKey:
version: int
algorithm: AlgorithmIdentifier
private_key: bytes


# ML-DSA-PrivateKey ::= CHOICE {
# seed [0] IMPLICIT OCTET STRING (SIZE (32)),
# expandedKey OCTET STRING,
# both SEQUENCE { seed, expandedKey }
# }
MLDSA_SEED_BYTES = 32


def generate_mldsa44_unsupported_variant(output_dir: str) -> None:
seed = b"\x2a" * MLDSA_SEED_BYTES
# [0] IMPLICIT OCTET STRING: tag 0x80, length 0x20
seed_only_privkey = b"\x80\x20" + seed

# ML-DSA-44 OID: 2.16.840.1.101.3.4.3.17
obj = OneAsymmetricKey(
version=0,
algorithm=AlgorithmIdentifier(
algorithm=x509.ObjectIdentifier("2.16.840.1.101.3.4.3.17"),
),
private_key=seed_only_privkey,
)
with open(os.path.join(output_dir, "mldsa44_priv.der"), "wb") as f:
f.write(asn1.encode_der(obj))


def generate_mldsa65_noseed(output_dir: str) -> None:
# ML-DSA-65 OID: 2.16.840.1.101.3.4.3.18
# Generate an ML-DSA-65 PKCS#8 key whose inner privateKey is an
# empty SEQUENCE (0x30 0x00) — i.e. the "both" SEQUENCE form with
# no seed present. This exercises the InvalidKey error path in the
# Rust parser when seed is None.
obj = OneAsymmetricKey(
version=0,
algorithm=AlgorithmIdentifier(
algorithm=x509.ObjectIdentifier("2.16.840.1.101.3.4.3.18"),
),
private_key=b"\x30\x00",
)
with open(os.path.join(output_dir, "mldsa65_noseed_priv.der"), "wb") as f:
f.write(asn1.encode_der(obj))


def main():
output_dir = os.path.join(
"vectors", "cryptography_vectors", "asymmetric", "MLDSA"
)
generate_mldsa44_unsupported_variant(output_dir)
generate_mldsa65_noseed(output_dir)


if __name__ == "__main__":
main()
4 changes: 4 additions & 0 deletions docs/development/test-vectors.rst
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ Asymmetric ciphers
* ``asymmetric/PKCS8/ed25519-scrypt.pem`` a PKCS8 encoded Ed25519 key from
RustCrypto using scrypt as the KDF. The password is ``hunter42``.
* FIPS 204 ML-DSA-{44,65,87} KAT vectors from `post-quantum-cryptography/KAT`_.
* ML-DSA-44 PKCS#8 and SPKI DER test vectors generated by this project.
See :doc:`/development/custom-vectors/mldsa`

Custom asymmetric vectors
~~~~~~~~~~~~~~~~~~~~~~~~~
Expand All @@ -74,6 +76,7 @@ Custom asymmetric vectors

custom-vectors/secp256k1
custom-vectors/rsa-oaep-sha2
custom-vectors/mldsa

* ``asymmetric/PEM_Serialization/ec_private_key.pem`` and
``asymmetric/DER_Serialization/ec_private_key.der`` - Contains an Elliptic
Expand Down Expand Up @@ -1212,6 +1215,7 @@ Created Vectors
custom-vectors/idea
custom-vectors/seed
custom-vectors/hkdf
custom-vectors/mldsa
custom-vectors/rc2


Expand Down
3 changes: 3 additions & 0 deletions src/cryptography/hazmat/backends/openssl/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,9 @@ def x448_supported(self) -> bool:
and not rust_openssl.CRYPTOGRAPHY_IS_AWSLC
)

def mldsa_supported(self) -> bool:
return rust_openssl.CRYPTOGRAPHY_IS_AWSLC

def ed25519_supported(self) -> bool:
return not self._fips_enabled

Expand Down
2 changes: 2 additions & 0 deletions src/cryptography/hazmat/bindings/_rust/openssl/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ from cryptography.hazmat.bindings._rust.openssl import (
hpke,
kdf,
keys,
mldsa,
poly1305,
rsa,
x448,
Expand All @@ -38,6 +39,7 @@ __all__ = [
"hpke",
"kdf",
"keys",
"mldsa",
"openssl_version",
"openssl_version_text",
"poly1305",
Expand Down
13 changes: 13 additions & 0 deletions src/cryptography/hazmat/bindings/_rust/openssl/mldsa.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# 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 cryptography.hazmat.primitives.asymmetric import mldsa
from cryptography.utils import Buffer

class MlDsa65PrivateKey: ...
class MlDsa65PublicKey: ...

def generate_key() -> mldsa.MlDsa65PrivateKey: ...
def from_public_bytes(data: bytes) -> mldsa.MlDsa65PublicKey: ...
def from_seed_bytes(data: Buffer) -> mldsa.MlDsa65PrivateKey: ...
155 changes: 155 additions & 0 deletions src/cryptography/hazmat/primitives/asymmetric/mldsa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# 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 MlDsa65PublicKey(metaclass=abc.ABCMeta):
@classmethod
def from_public_bytes(cls, data: bytes) -> MlDsa65PublicKey:
from cryptography.hazmat.backends.openssl.backend import backend

if not backend.mldsa_supported():
raise UnsupportedAlgorithm(
"ML-DSA-65 is not supported by this backend.",
_Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM,
)

return rust_openssl.mldsa.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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raw bytes here means seed, I assume? We should probably say that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No - for the public key, it's always going to be the expanded version

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation needs to be much clearer about which of these we mean.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tried updating the docstring - let me know what you think.

IIUC:

  • the public key only has a single (expanded) form: 1952 bytes for MLDSA65
  • the private key has two forms:
    • a seed (32 bytes) form that can be used to derive the expanded form -> the preferred way of manipulating private keys
    • an expanded form (large) that cannot be used to go back to the seed

So for the public key, we have no choice, we must return the full bytes.

Equivalent to public_bytes(Raw, Raw).

The public key is 1,952 bytes for MLDSA-65.
"""

@abc.abstractmethod
def verify(
self,
signature: Buffer,
data: Buffer,
context: Buffer | None = None,
) -> None:
"""
Verify the signature.
"""

@abc.abstractmethod
def __eq__(self, other: object) -> bool:
"""
Checks equality.
"""

@abc.abstractmethod
def __copy__(self) -> MlDsa65PublicKey:
"""
Returns a copy.
"""

@abc.abstractmethod
def __deepcopy__(self, memo: dict) -> MlDsa65PublicKey:
"""
Returns a deep copy.
"""


if hasattr(rust_openssl, "mldsa"):
MlDsa65PublicKey.register(rust_openssl.mldsa.MlDsa65PublicKey)


class MlDsa65PrivateKey(metaclass=abc.ABCMeta):
@classmethod
def generate(cls) -> MlDsa65PrivateKey:
from cryptography.hazmat.backends.openssl.backend import backend

if not backend.mldsa_supported():
raise UnsupportedAlgorithm(
"ML-DSA-65 is not supported by this backend.",
_Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM,
)

return rust_openssl.mldsa.generate_key()

@classmethod
def from_seed_bytes(cls, data: Buffer) -> MlDsa65PrivateKey:
from cryptography.hazmat.backends.openssl.backend import backend

if not backend.mldsa_supported():
raise UnsupportedAlgorithm(
"ML-DSA-65 is not supported by this backend.",
_Reasons.UNSUPPORTED_PUBLIC_KEY_ALGORITHM,
)

return rust_openssl.mldsa.from_seed_bytes(data)

@abc.abstractmethod
def public_key(self) -> MlDsa65PublicKey:
"""
The MlDsa65PublicKey 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.

This method only returns the serialization of the seed form of the
private key, never the expanded one.
"""

@abc.abstractmethod
def private_bytes_raw(self) -> bytes:
"""
The raw bytes of the private key.
Equivalent to private_bytes(Raw, Raw, NoEncryption()).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same Q here about seed.


This method only returns the seed form of the private key (32 bytes).
"""

@abc.abstractmethod
def sign(self, data: Buffer, context: Buffer | None = None) -> bytes:
"""
Signs the data.
"""

@abc.abstractmethod
def __copy__(self) -> MlDsa65PrivateKey:
"""
Returns a copy.
"""

@abc.abstractmethod
def __deepcopy__(self, memo: dict) -> MlDsa65PrivateKey:
"""
Returns a deep copy.
"""


if hasattr(rust_openssl, "mldsa"):
MlDsa65PrivateKey.register(rust_openssl.mldsa.MlDsa65PrivateKey)
3 changes: 3 additions & 0 deletions src/cryptography/hazmat/primitives/asymmetric/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
ec,
ed448,
ed25519,
mldsa,
rsa,
x448,
x25519,
Expand All @@ -26,6 +27,7 @@
ec.EllipticCurvePublicKey,
ed25519.Ed25519PublicKey,
ed448.Ed448PublicKey,
mldsa.MlDsa65PublicKey,
x25519.X25519PublicKey,
x448.X448PublicKey,
]
Expand All @@ -42,6 +44,7 @@
dh.DHPrivateKey,
ed25519.Ed25519PrivateKey,
ed448.Ed448PrivateKey,
mldsa.MlDsa65PrivateKey,
rsa.RSAPrivateKey,
dsa.DSAPrivateKey,
ec.EllipticCurvePrivateKey,
Expand Down
Loading