Skip to content

MLDSA65 support for AWS-LC#14404

Open
DarkaMaul wants to merge 30 commits intopyca:mainfrom
trail-of-forks:dm/mldsa65-aws-lc
Open

MLDSA65 support for AWS-LC#14404
DarkaMaul wants to merge 30 commits intopyca:mainfrom
trail-of-forks:dm/mldsa65-aws-lc

Conversation

@DarkaMaul
Copy link
Copy Markdown
Contributor

This PR adds ML-DSA-65 support when using the AWS-LC backend.

I know this PR is quite massive (+1000 lines), but I've already excluded the documentation from it. I can split it even further by:

  1. only add the Python tests and gate them behind the mldsa_supported() -> bool = False so the test suite continue to pass (would be about 500 lines)
  2. only add the Rust code to exercise the tests

I could also try to remove the x509/pkcs8 support, but in my tests, that led to a weird API state where some functions would not work.

I'm also happy to also split it in any other potential better ways if you see any.

// Note: private_key_to_pkcs8() (i2d_PKCS8PrivateKey_bio) must be used
// instead of private_key_to_der() (i2d_PrivateKey), because AWS-LC's
// i2d_PrivateKey doesn't support PQDSA keys.
let pkcs8_der = self.pkey.private_key_to_pkcs8()?;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

FLAG: This is not elegant here - but moving this to another layer (like the cryptography-openssl crate would induce a new dependency on asn1)

@alex
Copy link
Copy Markdown
Member

alex commented Mar 2, 2026

Can you take a look at the missing coverage before I review? (If there's questions about idioms for covering certain things, let me know)

@DarkaMaul DarkaMaul force-pushed the dm/mldsa65-aws-lc branch from 49220b3 to 4f4bc0b Compare March 3, 2026 13:25
@DarkaMaul DarkaMaul force-pushed the dm/mldsa65-aws-lc branch from 4f4bc0b to 65ab941 Compare March 3, 2026 13:38
@abbra
Copy link
Copy Markdown
Contributor

abbra commented Mar 5, 2026

@DarkaMaul any reason you were not basing on my branches?

@alex
Copy link
Copy Markdown
Member

alex commented Mar 5, 2026

@abbra -- please do not nag contributors about this. As I last checked, your branch is far too large to be reviewable, is targeting OpenSSL rather than the backends we intend to start with, and you have not submitted any of it for review as a PR. In contrast, this pull request is moving towards a mergable state, so attempting to rebase it on top of your work would be a hinderance, rather than a help.

@abbra
Copy link
Copy Markdown
Contributor

abbra commented Mar 5, 2026

@alex I have splitted the code into three different branches: for ML-DSA-44, ML-DSA-65, ML-DSA-87, as you and Paul did request. I haven't submitted any of them individually because you asked not to submit them without non-OpenSSL backend.

I'd like to at least have higher level API agreed. Do you want me to submit those branches now?

@reaperhulk
Copy link
Copy Markdown
Member

@abbra It’s great that you split them, but you didn’t submit anything to us for review. Our development happens in the open on this issue tracker and at this time other contributors have stepped forward and have submitted things we’re actively reviewing. Their work is going to take priority, but hopefully you’ll be able to rebase on top of them to submit going forward.

@abbra
Copy link
Copy Markdown
Contributor

abbra commented Mar 5, 2026

@reaperhulk looks like we misunderstood each other then.
I have opened #14430 for your consideration. Please help to improve higher level API PyCA eventually would like to see for ML-DSA operations.

Copy link
Copy Markdown
Member

@alex alex left a comment

Choose a reason for hiding this comment

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

if we're adding new vectors they need to be documented in test-vectors.rst

def private_bytes_raw(self) -> bytes:
"""
The raw bytes of the private key (32-byte seed).
Equivalent to private_bytes(Raw, Raw, NoEncryption()).
Copy link
Copy Markdown
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

(replied about this one in public_bytes_raw comment)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I wonder if we should be using the noun seed here, rather than raw -- wdyt @reaperhulk ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@reaperhulk still pending :-)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hmm, well a seed only key can have two forms. The raw/bare seed or one wrapped in a PKCS8 structure. This parses the raw seed so I think I'm fine with it being called raw?

fn test_serialize_public_key_unsupported_mldsa_variant() {
// Load an ML-DSA-44 public key from a Wycheproof test vector DER.
let der = include_bytes!(
"../../../../vectors/cryptography_vectors/asymmetric/MLDSA/mldsa44_pub.der"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Tests like this should go through the Python interface -- we generally only do rust tests if the code is actually unreachable from python

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That's actually unreachable from Python at the moment because we can't construct an ML-DSA-44 key object in the Python layer. The serialize_public_key path is only called from public_bytes() on an MlDsa65PublicKey, and all constructors that produce that type validate the key size upfront.

def private_bytes_raw(self) -> bytes:
"""
The raw bytes of the private key (32-byte seed).
Equivalent to private_bytes(Raw, Raw, NoEncryption()).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I wonder if we should be using the noun seed here, rather than raw -- wdyt @reaperhulk ?

}

#[cfg(CRYPTOGRAPHY_IS_AWSLC)]
AlgorithmParameters::MlDsa65 => parse_mldsa_private_key(k.private_key),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we need a length check here? Or is something lower in the stack verifying that the length matches the mldsa type?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

What about open-coding the parsing? Check if the length is correct and that the prefix is the expected value. Then return the suffix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There is a length check deeper in the chain:

  // Get PQDSA instance and validate lengths
  const PQDSA *pqdsa = PQDSA_KEY_get0_dsa(ret->pkey.pqdsa_key);
  if (len != pqdsa->private_key_len && len != pqdsa->keygen_seed_len) {
    OPENSSL_PUT_ERROR(EVP, EVP_R_INVALID_BUFFER_SIZE);
    goto err;
  }

source

I have written the following test to confirm it.

    #[cfg(CRYPTOGRAPHY_IS_AWSLC)]
    #[test]
    fn test_parse_mldsa_private_key_wrong_size() {
        let bad_seed = asn1::write_single(&super::MlDsaPrivateKey::Seed(&[0u8; 33])).unwrap();
        assert!(super::parse_mldsa_private_key(&bad_seed).is_err());
    }

.into_any()),

#[cfg(CRYPTOGRAPHY_IS_AWSLC)]
id if id == openssl::pkey::Id::from_raw(cryptography_openssl::mldsa::NID_PQDSA) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is released now.

)
@wycheproof_tests("mldsa_65_sign_seed_test.json")
def test_mldsa65_sign_seed(backend, wycheproof):
# Skip "Internal" tests
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Err, what's an internal test?

Copy link
Copy Markdown
Contributor Author

@DarkaMaul DarkaMaul Mar 13, 2026

Choose a reason for hiding this comment

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

A test that uses Sign_internal (Algorithm 7) instead of Sign (Algorithm 2)

IIUC, the Sign_internal method is the inner signing method, that is called by Sign using an already computed mix of (msg, ctx, public_key).

FIPS 204

(Definition is here)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can you expand this comment very slightly.

@alex
Copy link
Copy Markdown
Member

alex commented Mar 12, 2026

This is making good progress, I think we're close.

Copy link
Copy Markdown
Member

@alex alex left a comment

Choose a reason for hiding this comment

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

pretty close!

self,
encoding: _serialization.Encoding,
format: _serialization.PrivateFormat,
encryption_algorithm: (_serialization.KeySerializationEncryption),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
encryption_algorithm: (_serialization.KeySerializationEncryption),
encryption_algorithm: _serialization.KeySerializationEncryption,

def private_bytes_raw(self) -> bytes:
"""
The raw bytes of the private key (32-byte seed).
Equivalent to private_bytes(Raw, Raw, NoEncryption()).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@reaperhulk still pending :-)

#[cfg(CRYPTOGRAPHY_IS_AWSLC)]
// NO-COVERAGE-START
#[derive(asn1::Asn1Read, asn1::Asn1Write)]
// NO-COVERAGE-END
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We've never needed these markers on an asn1derive, strongly suggests there's some relevant path that we're never using -- do we not use both read/write on this type?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We do both use the read and write operation here, but the macro generates more code there.

I used cargo expand on the macro to generate a new coverage run on the expanded version:

The CI fails with the following missing lines:

src/rust/cryptography-key-parsing/src/pkcs8.rs                      383     14      0      0    96%   39-40, 48-53, 79-84
  • L39-40: this could be exercised by having a test that calls asn1::write_single(&MlDsaPrivateKey::Seed(wrong_seed)) but in practice, that would mean the raw_private_key returned an invalid seed, and that should not be doable.
        .map_err(|e| {
            e.add_location(asn1::ParseLocation::Field("MlDsaPrivateKey::Seed"))
        })?
  • L48-53: not sure how to test this
    fn can_parse(tag: asn1::Tag) -> bool {
        if asn1::Implicit::<&'a [u8], 0>::can_parse(tag) {
            return true;
        }
        false
    }
  • L79-84: same here, we are using the impl for MlDsaPrivateKey not &MlDsaPrivateKey
impl<'a> asn1::Asn1Writable for &MlDsaPrivateKey<'a> {
    type Error = asn1::WriteError;
    fn write(&self, w: &mut asn1::Writer<'_>) -> asn1::WriteResult {
        (*self).write(w)
    }
    fn encoded_length(&self) -> Option<usize> {
        (*self).encoded_length()
    }
}

(source)

What's the best/recommended practice here?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Huh... I am not sure. I'm baffled how we've never run into these with any other enums before...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not required anymore thanks to our new runner!

)
@wycheproof_tests("mldsa_65_sign_seed_test.json")
def test_mldsa65_sign_seed(backend, wycheproof):
# Skip "Internal" tests
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can you expand this comment very slightly.

Copy link
Copy Markdown
Member

@alex alex left a comment

Choose a reason for hiding this comment

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

Final mile!

#[cfg(CRYPTOGRAPHY_IS_AWSLC)]
// NO-COVERAGE-START
#[derive(asn1::Asn1Read, asn1::Asn1Write)]
// NO-COVERAGE-END
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Huh... I am not sure. I'm baffled how we've never run into these with any other enums before...

// NO-COVERAGE-END
pub enum MlDsaPrivateKey<'a> {
#[implicit(0)]
Seed(&'a [u8]),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this be a [u8; 32]? the RFC defines it as SIZE 32.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes! And narrowing it surfaced a bug because this was happily serializing the expanded private key instead of the seed.

I've also modified the test_round_trip_private_serialization to add the missing equality...

&self,
py: pyo3::Python<'p>,
) -> CryptographyResult<pyo3::Bound<'p, pyo3::types::PyBytes>> {
// AWS-LC's raw_private_key() returns the expanded key, not the seed.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this still true? IIUC this was changed.

encryption_algorithm: &pyo3::Bound<'p, pyo3::PyAny>,
) -> CryptographyResult<pyo3::Bound<'p, pyo3::types::PyBytes>> {
// Intercept Raw/Raw/NoEncryption so we return the seed.
// The generic pkey_private_bytes raw path calls raw_private_key()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

5 participants