diff --git a/docs/hazmat/primitives/hpke.rst b/docs/hazmat/primitives/hpke.rst index 32c119b5101a..4ec8aeb39580 100644 --- a/docs/hazmat/primitives/hpke.rst +++ b/docs/hazmat/primitives/hpke.rst @@ -110,6 +110,13 @@ specifying auxiliary authenticated information. Public and private keys are :class:`MLKEM768X25519PublicKey` and :class:`MLKEM768X25519PrivateKey`. + .. attribute:: MLKEM1024_P384 + + A hybrid KEM combining ML-KEM-1024 with P-384. Post-quantum secure. + Only available on backends that support ML-KEM. Public and private + keys are :class:`MLKEM1024P384PublicKey` and + :class:`MLKEM1024P384PrivateKey`. + .. class:: MLKEM768X25519PrivateKey(mlkem_key, x25519_key) .. versionadded:: 47.0.0 @@ -148,6 +155,44 @@ specifying auxiliary authenticated information. :param x25519_key: The X25519 public key component. :type x25519_key: :class:`~cryptography.hazmat.primitives.asymmetric.x25519.X25519PublicKey` +.. class:: MLKEM1024P384PrivateKey(mlkem_key, p384_key) + + .. versionadded:: 47.0.0 + + A hybrid ML-KEM-1024 / P-384 private key for use with + :attr:`KEM.MLKEM1024_P384`. Combines an + :class:`~cryptography.hazmat.primitives.asymmetric.mlkem.MLKEM1024PrivateKey` + and an + :class:`~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey` + on the SECP384R1 curve into a single recipient key. + + :param mlkem_key: The ML-KEM-1024 private key component. + :type mlkem_key: :class:`~cryptography.hazmat.primitives.asymmetric.mlkem.MLKEM1024PrivateKey` + + :param p384_key: The P-384 private key component. + :type p384_key: :class:`~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey` + + .. method:: public_key() + + :returns: :class:`MLKEM1024P384PublicKey` + +.. class:: MLKEM1024P384PublicKey(mlkem_key, p384_key) + + .. versionadded:: 47.0.0 + + A hybrid ML-KEM-1024 / P-384 public key for use with + :attr:`KEM.MLKEM1024_P384`. Combines an + :class:`~cryptography.hazmat.primitives.asymmetric.mlkem.MLKEM1024PublicKey` + and an + :class:`~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey` + on the SECP384R1 curve into a single recipient key. + + :param mlkem_key: The ML-KEM-1024 public key component. + :type mlkem_key: :class:`~cryptography.hazmat.primitives.asymmetric.mlkem.MLKEM1024PublicKey` + + :param p384_key: The P-384 public key component. + :type p384_key: :class:`~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey` + .. class:: KDF An enumeration of key derivation functions. diff --git a/src/cryptography/hazmat/bindings/_rust/openssl/hpke.pyi b/src/cryptography/hazmat/bindings/_rust/openssl/hpke.pyi index 96045e91b455..a9d8a1167efc 100644 --- a/src/cryptography/hazmat/bindings/_rust/openssl/hpke.pyi +++ b/src/cryptography/hazmat/bindings/_rust/openssl/hpke.pyi @@ -13,6 +13,7 @@ class KEM: MLKEM768: KEM MLKEM1024: KEM MLKEM768_X25519: KEM + MLKEM1024_P384: KEM class KDF: HKDF_SHA256: KDF @@ -41,6 +42,21 @@ class MLKEM768X25519PublicKey: x25519_key: x25519.X25519PublicKey, ) -> None: ... +class MLKEM1024P384PrivateKey: + def __init__( + self, + mlkem_key: mlkem.MLKEM1024PrivateKey, + p384_key: ec.EllipticCurvePrivateKey, + ) -> None: ... + def public_key(self) -> MLKEM1024P384PublicKey: ... + +class MLKEM1024P384PublicKey: + def __init__( + self, + mlkem_key: mlkem.MLKEM1024PublicKey, + p384_key: ec.EllipticCurvePublicKey, + ) -> None: ... + class Suite: def __init__(self, kem: KEM, kdf: KDF, aead: AEAD) -> None: ... def encrypt( @@ -50,7 +66,8 @@ class Suite: | ec.EllipticCurvePublicKey | mlkem.MLKEM768PublicKey | mlkem.MLKEM1024PublicKey - | MLKEM768X25519PublicKey, + | MLKEM768X25519PublicKey + | MLKEM1024P384PublicKey, info: Buffer | None = None, ) -> bytes: ... def decrypt( @@ -60,7 +77,8 @@ class Suite: | ec.EllipticCurvePrivateKey | mlkem.MLKEM768PrivateKey | mlkem.MLKEM1024PrivateKey - | MLKEM768X25519PrivateKey, + | MLKEM768X25519PrivateKey + | MLKEM1024P384PrivateKey, info: Buffer | None = None, ) -> bytes: ... @@ -71,7 +89,8 @@ def _encrypt_with_aad( | ec.EllipticCurvePublicKey | mlkem.MLKEM768PublicKey | mlkem.MLKEM1024PublicKey - | MLKEM768X25519PublicKey, + | MLKEM768X25519PublicKey + | MLKEM1024P384PublicKey, info: Buffer | None = None, aad: Buffer | None = None, ) -> bytes: ... @@ -82,7 +101,8 @@ def _decrypt_with_aad( | ec.EllipticCurvePrivateKey | mlkem.MLKEM768PrivateKey | mlkem.MLKEM1024PrivateKey - | MLKEM768X25519PrivateKey, + | MLKEM768X25519PrivateKey + | MLKEM1024P384PrivateKey, info: Buffer | None = None, aad: Buffer | None = None, ) -> bytes: ... diff --git a/src/cryptography/hazmat/primitives/hpke.py b/src/cryptography/hazmat/primitives/hpke.py index 78b1bb779688..c80280881f55 100644 --- a/src/cryptography/hazmat/primitives/hpke.py +++ b/src/cryptography/hazmat/primitives/hpke.py @@ -11,6 +11,8 @@ KEM = rust_openssl.hpke.KEM MLKEM768X25519PrivateKey = rust_openssl.hpke.MLKEM768X25519PrivateKey MLKEM768X25519PublicKey = rust_openssl.hpke.MLKEM768X25519PublicKey +MLKEM1024P384PrivateKey = rust_openssl.hpke.MLKEM1024P384PrivateKey +MLKEM1024P384PublicKey = rust_openssl.hpke.MLKEM1024P384PublicKey Suite = rust_openssl.hpke.Suite __all__ = [ @@ -19,5 +21,7 @@ "KEM", "MLKEM768X25519PrivateKey", "MLKEM768X25519PublicKey", + "MLKEM1024P384PrivateKey", + "MLKEM1024P384PublicKey", "Suite", ] diff --git a/src/rust/src/backend/hpke.rs b/src/rust/src/backend/hpke.rs index c1194a6fe0ab..f77429e91421 100644 --- a/src/rust/src/backend/hpke.rs +++ b/src/rust/src/backend/hpke.rs @@ -53,6 +53,10 @@ mod kem_params { pub const MLKEM768_X25519_ID: u16 = 0x647A; pub const MLKEM768_X25519_NSECRET: usize = 32; pub const MLKEM768_X25519_NENC: usize = 1120; + + pub const MLKEM1024_P384_ID: u16 = 0x0051; + pub const MLKEM1024_P384_NSECRET: usize = 32; + pub const MLKEM1024_P384_NENC: usize = 1665; } mod kdf_params { @@ -100,6 +104,7 @@ pub(crate) enum KEM { MLKEM768, MLKEM1024, MLKEM768_X25519, + MLKEM1024_P384, } impl KEM { @@ -166,6 +171,7 @@ impl KEM { KEM::MLKEM768 => kem_params::MLKEM768_ID, KEM::MLKEM1024 => kem_params::MLKEM1024_ID, KEM::MLKEM768_X25519 => kem_params::MLKEM768_X25519_ID, + KEM::MLKEM1024_P384 => kem_params::MLKEM1024_P384_ID, } } @@ -178,6 +184,7 @@ impl KEM { KEM::MLKEM768 => kem_params::MLKEM768_NSECRET, KEM::MLKEM1024 => kem_params::MLKEM1024_NSECRET, KEM::MLKEM768_X25519 => kem_params::MLKEM768_X25519_NSECRET, + KEM::MLKEM1024_P384 => kem_params::MLKEM1024_P384_NSECRET, } } @@ -190,6 +197,7 @@ impl KEM { KEM::MLKEM768 => kem_params::MLKEM768_NENC, KEM::MLKEM1024 => kem_params::MLKEM1024_NENC, KEM::MLKEM768_X25519 => kem_params::MLKEM768_X25519_NENC, + KEM::MLKEM1024_P384 => kem_params::MLKEM1024_P384_NENC, } } @@ -256,6 +264,15 @@ impl KEM { )); } } + KEM::MLKEM1024_P384 => { + if !key.is_instance_of::() { + return Err(CryptographyError::from( + pyo3::exceptions::PyTypeError::new_err( + "Expected MLKEM1024P384PublicKey for KEM.MLKEM1024_P384", + ), + )); + } + } } Ok(()) } @@ -323,6 +340,15 @@ impl KEM { )); } } + KEM::MLKEM1024_P384 => { + if !key.is_instance_of::() { + return Err(CryptographyError::from( + pyo3::exceptions::PyTypeError::new_err( + "Expected MLKEM1024P384PrivateKey for KEM.MLKEM1024_P384", + ), + )); + } + } } Ok(()) } @@ -345,6 +371,10 @@ impl KEM { let hybrid = pk_r.cast::()?; hybrid.borrow().encapsulate(py) } + KEM::MLKEM1024_P384 => { + let hybrid = pk_r.cast::()?; + hybrid.borrow().encapsulate(py) + } KEM::X25519 | KEM::P256 | KEM::P384 | KEM::P521 => { self.dhkem_encap(py, pk_r, kem_suite_id) } @@ -369,6 +399,10 @@ impl KEM { let hybrid = sk_r.cast::()?; hybrid.borrow().decapsulate(py, enc) } + KEM::MLKEM1024_P384 => { + let hybrid = sk_r.cast::()?; + hybrid.borrow().decapsulate(py, enc) + } KEM::X25519 | KEM::P256 | KEM::P384 | KEM::P521 => { self.dhkem_decap(py, enc, sk_r, kem_suite_id) } @@ -507,7 +541,7 @@ impl KEM { .into_any(), ) } - KEM::MLKEM768 | KEM::MLKEM1024 | KEM::MLKEM768_X25519 => { + KEM::MLKEM768 | KEM::MLKEM1024 | KEM::MLKEM768_X25519 | KEM::MLKEM1024_P384 => { unreachable!("ML-KEM does not generate an ephemeral DH key") } } @@ -531,7 +565,7 @@ impl KEM { ), )? .extract()?), - KEM::MLKEM768 | KEM::MLKEM1024 | KEM::MLKEM768_X25519 => { + KEM::MLKEM768 | KEM::MLKEM1024 | KEM::MLKEM768_X25519 | KEM::MLKEM1024_P384 => { unreachable!("ML-KEM public keys are not serialized via this path") } } @@ -556,7 +590,7 @@ impl KEM { let secp521r1 = types::SECP521R1.get(py)?.call0()?; Ok(pyo3::Bound::new(py, ec::from_public_bytes(py, secp521r1, data)?)?.into_any()) } - KEM::MLKEM768 | KEM::MLKEM1024 | KEM::MLKEM768_X25519 => { + KEM::MLKEM768 | KEM::MLKEM1024 | KEM::MLKEM768_X25519 | KEM::MLKEM1024_P384 => { unreachable!("ML-KEM encapsulated key is a ciphertext, not a public key") } } @@ -576,7 +610,7 @@ impl KEM { let ecdh = types::ECDH.get(py)?.call0()?; Ok(private_key.call_method1(pyo3::intern!(py, "exchange"), (&ecdh, public_key))?) } - KEM::MLKEM768 | KEM::MLKEM1024 | KEM::MLKEM768_X25519 => { + KEM::MLKEM768 | KEM::MLKEM1024 | KEM::MLKEM768_X25519 | KEM::MLKEM1024_P384 => { unreachable!("ML-KEM does not perform a Diffie-Hellman exchange") } } @@ -590,7 +624,7 @@ impl KEM { KEM::X25519 | KEM::P256 => Ok(types::SHA256.get(py)?.call0()?), KEM::P384 => Ok(types::SHA384.get(py)?.call0()?), KEM::P521 => Ok(types::SHA512.get(py)?.call0()?), - KEM::MLKEM768 | KEM::MLKEM1024 | KEM::MLKEM768_X25519 => { + KEM::MLKEM768 | KEM::MLKEM1024 | KEM::MLKEM768_X25519 | KEM::MLKEM1024_P384 => { unreachable!("ML-KEM does not use a KEM hash algorithm") } } @@ -1036,28 +1070,34 @@ fn _decrypt_with_aad<'p>( suite.decrypt_inner(py, ciphertext, private_key, info, aad) } -// MLKEM768-X25519 hybrid KEM (also known as X-Wing) used by HPKE KEM ID -// 0x647A, specified in draft-connolly-cfrg-xwing-kem and draft-ietf-hpke-pq. -// Only a constructor is exposed to Python; all encap/decap logic lives here. +// MLKEM768-X25519 (HPKE KEM ID 0x647A, specified in +// draft-connolly-cfrg-xwing-kem and draft-ietf-hpke-pq) and MLKEM1024-P384 +// (HPKE KEM ID 0x0051, specified in draft-ietf-hpke-pq) are both constructed +// by the same QSF-style SHA3-256 combiner over (ss_PQ || ss_T || ct_T || ek_T +// || Label), and only the recipient-key constructors are exposed to Python; +// the encap/decap logic lives in this module. const MLKEM768_X25519_MLKEM_CT_LENGTH: usize = 1088; +const MLKEM1024_P384_MLKEM_CT_LENGTH: usize = 1568; // `\./` + `/^\` — the X-Wing combiner label. -const MLKEM768_X25519_LABEL: &[u8; 6] = b"\\.//^\\"; +const MLKEM768_X25519_LABEL: &[u8] = b"\\.//^\\"; +const MLKEM1024_P384_LABEL: &[u8] = b"MLKEM1024-P384"; -fn mlkem768_x25519_combine<'p>( +fn hybrid_kem_combine<'p>( py: pyo3::Python<'p>, - ss_m: &[u8], - ss_x: &[u8], - ct_x: &[u8], - pk_x: &[u8], + ss_pq: &[u8], + ss_t: &[u8], + ct_t: &[u8], + ek_t: &[u8], + label: &[u8], ) -> CryptographyResult> { let algorithm = types::SHA3_256.get(py)?.call0()?; let mut hash = Hash::new(py, &algorithm, None)?; - hash.update_bytes(ss_m)?; - hash.update_bytes(ss_x)?; - hash.update_bytes(ct_x)?; - hash.update_bytes(pk_x)?; - hash.update_bytes(MLKEM768_X25519_LABEL)?; + hash.update_bytes(ss_pq)?; + hash.update_bytes(ss_t)?; + hash.update_bytes(ct_t)?; + hash.update_bytes(ek_t)?; + hash.update_bytes(label)?; hash.finalize(py) } @@ -1101,7 +1141,14 @@ impl MlKem768X25519PrivateKey { .call_method0(pyo3::intern!(py, "public_bytes_raw"))? .extract::>()?; - mlkem768_x25519_combine(py, ss_m.as_bytes(), ss_x.as_bytes(), ct_x, pk_x.as_bytes()) + hybrid_kem_combine( + py, + ss_m.as_bytes(), + ss_x.as_bytes(), + ct_x, + pk_x.as_bytes(), + MLKEM768_X25519_LABEL, + ) } } @@ -1193,12 +1240,13 @@ impl MlKem768X25519PublicKey { .call_method0(pyo3::intern!(py, "public_bytes_raw"))? .extract::>()?; - let shared_secret = mlkem768_x25519_combine( + let shared_secret = hybrid_kem_combine( py, ss_m.as_bytes(), ss_x.as_bytes(), ct_x.as_bytes(), pk_x.as_bytes(), + MLKEM768_X25519_LABEL, )?; let ct_m_bytes = ct_m.as_bytes(); @@ -1244,13 +1292,219 @@ impl MlKem768X25519PublicKey { } } +fn serialize_p384_public_key<'p>( + py: pyo3::Python<'p>, + pk: &pyo3::Bound<'p, pyo3::PyAny>, +) -> CryptographyResult> { + Ok(pk + .call_method1( + pyo3::intern!(py, "public_bytes"), + ( + crate::serialization::Encoding::X962, + crate::serialization::PublicFormat::UncompressedPoint, + ), + )? + .extract()?) +} + +// NO-COVERAGE-START +#[pyo3::pyclass( + frozen, + module = "cryptography.hazmat.bindings._rust.openssl.hpke", + name = "MLKEM1024P384PrivateKey" +)] +// NO-COVERAGE-END +pub(crate) struct MlKem1024P384PrivateKey { + mlkem_key: pyo3::Py, + p384_key: pyo3::Py, +} + +impl MlKem1024P384PrivateKey { + fn decapsulate<'p>( + &self, + py: pyo3::Python<'p>, + enc: &[u8], + ) -> CryptographyResult> { + // `enc` is guaranteed by Suite::decrypt_inner to be exactly + // `enc_length()` bytes (1665), so we can split without a length check. + let (ct_pq, ct_t) = enc.split_at(MLKEM1024_P384_MLKEM_CT_LENGTH); + + let mlkem_key = self.mlkem_key.bind(py); + let ss_pq = mlkem_key + .call_method1( + pyo3::intern!(py, "decapsulate"), + (pyo3::types::PyBytes::new(py, ct_pq),), + )? + .extract::>()?; + + let p384_key = self.p384_key.bind(py); + let secp384r1 = types::SECP384R1.get(py)?.call0()?; + let ct_t_pk = pyo3::Bound::new(py, ec::from_public_bytes(py, secp384r1, ct_t)?)?; + let ecdh = types::ECDH.get(py)?.call0()?; + let ss_t = p384_key + .call_method1(pyo3::intern!(py, "exchange"), (&ecdh, ct_t_pk))? + .extract::>()?; + let ek_t_pk = p384_key.call_method0(pyo3::intern!(py, "public_key"))?; + let ek_t = serialize_p384_public_key(py, &ek_t_pk)?; + + hybrid_kem_combine( + py, + ss_pq.as_bytes(), + ss_t.as_bytes(), + ct_t, + ek_t.as_bytes(), + MLKEM1024_P384_LABEL, + ) + } +} + +#[pyo3::pymethods] +impl MlKem1024P384PrivateKey { + #[new] + fn new( + py: pyo3::Python<'_>, + mlkem_key: pyo3::Py, + p384_key: pyo3::Py, + ) -> CryptographyResult { + if !mlkem_key + .bind(py) + .is_instance(&types::MLKEM1024_PRIVATE_KEY.get(py)?)? + { + return Err(CryptographyError::from( + pyo3::exceptions::PyTypeError::new_err( + "Expected MLKEM1024PrivateKey for mlkem_key", + ), + )); + } + KEM::check_ec_private_key( + py, + p384_key.bind(py), + &types::SECP384R1.get(py)?, + "p384_key", + "secp384r1", + )?; + Ok(MlKem1024P384PrivateKey { + mlkem_key, + p384_key, + }) + } + + fn public_key(&self, py: pyo3::Python<'_>) -> CryptographyResult { + let mlkem_pub = self + .mlkem_key + .bind(py) + .call_method0(pyo3::intern!(py, "public_key"))? + .unbind(); + let p384_pub = self + .p384_key + .bind(py) + .call_method0(pyo3::intern!(py, "public_key"))? + .unbind(); + Ok(MlKem1024P384PublicKey { + mlkem_key: mlkem_pub, + p384_key: p384_pub, + }) + } +} + +#[pyo3::pyclass( + frozen, + module = "cryptography.hazmat.bindings._rust.openssl.hpke", + name = "MLKEM1024P384PublicKey" +)] +pub(crate) struct MlKem1024P384PublicKey { + mlkem_key: pyo3::Py, + p384_key: pyo3::Py, +} + +impl MlKem1024P384PublicKey { + fn encapsulate<'p>( + &self, + py: pyo3::Python<'p>, + ) -> CryptographyResult<( + pyo3::Bound<'p, pyo3::types::PyBytes>, + pyo3::Bound<'p, pyo3::types::PyBytes>, + )> { + let (ss_pq, ct_pq) = self + .mlkem_key + .bind(py) + .call_method0(pyo3::intern!(py, "encapsulate"))? + .extract::<( + pyo3::Bound<'_, pyo3::types::PyBytes>, + pyo3::Bound<'_, pyo3::types::PyBytes>, + )>()?; + + let p384_key = self.p384_key.bind(py); + let secp384r1 = types::SECP384R1.get(py)?.call0()?; + let ephemeral = pyo3::Bound::new(py, ec::generate_private_key(py, secp384r1, None)?)?; + let ephemeral_pk = ephemeral.call_method0(pyo3::intern!(py, "public_key"))?; + let ct_t = serialize_p384_public_key(py, &ephemeral_pk)?; + let ecdh = types::ECDH.get(py)?.call0()?; + let ss_t = ephemeral + .call_method1(pyo3::intern!(py, "exchange"), (&ecdh, p384_key))? + .extract::>()?; + let ek_t = serialize_p384_public_key(py, p384_key)?; + + let shared_secret = hybrid_kem_combine( + py, + ss_pq.as_bytes(), + ss_t.as_bytes(), + ct_t.as_bytes(), + ek_t.as_bytes(), + MLKEM1024_P384_LABEL, + )?; + + let ct_pq_bytes = ct_pq.as_bytes(); + let ct_t_bytes = ct_t.as_bytes(); + let enc = + pyo3::types::PyBytes::new_with(py, ct_pq_bytes.len() + ct_t_bytes.len(), |buf| { + buf[..ct_pq_bytes.len()].copy_from_slice(ct_pq_bytes); + buf[ct_pq_bytes.len()..].copy_from_slice(ct_t_bytes); + Ok(()) + })?; + + Ok((shared_secret, enc)) + } +} + +#[pyo3::pymethods] +impl MlKem1024P384PublicKey { + #[new] + fn new( + py: pyo3::Python<'_>, + mlkem_key: pyo3::Py, + p384_key: pyo3::Py, + ) -> CryptographyResult { + if !mlkem_key + .bind(py) + .is_instance(&types::MLKEM1024_PUBLIC_KEY.get(py)?)? + { + return Err(CryptographyError::from( + pyo3::exceptions::PyTypeError::new_err("Expected MLKEM1024PublicKey for mlkem_key"), + )); + } + KEM::check_ec_public_key( + py, + p384_key.bind(py), + &types::SECP384R1.get(py)?, + "p384_key", + "secp384r1", + )?; + Ok(MlKem1024P384PublicKey { + mlkem_key, + p384_key, + }) + } +} + #[pyo3::pymodule(gil_used = false)] pub(crate) mod hpke { // stable and nightly rustfmt disagree on import ordering #[rustfmt::skip] #[pymodule_export] use super::{ - _decrypt_with_aad, _encrypt_with_aad, MlKem768X25519PrivateKey, + _decrypt_with_aad, _encrypt_with_aad, MlKem1024P384PrivateKey, + MlKem1024P384PublicKey, MlKem768X25519PrivateKey, MlKem768X25519PublicKey, Suite, AEAD, KDF, KEM, }; } @@ -1281,6 +1535,14 @@ mod tests { ); } + #[test] + fn test_mlkem1024_p384_secret_length() { + assert_eq!( + KEM::MLKEM1024_P384.secret_length(), + kem_params::MLKEM1024_P384_NSECRET + ); + } + #[test] #[should_panic(expected = "ML-KEM does not generate an ephemeral DH key")] fn test_mlkem768_generate_key_unreachable() { diff --git a/tests/hazmat/primitives/test_hpke.py b/tests/hazmat/primitives/test_hpke.py index 5ac92afc135a..66683b3e250e 100644 --- a/tests/hazmat/primitives/test_hpke.py +++ b/tests/hazmat/primitives/test_hpke.py @@ -21,6 +21,8 @@ KEM, MLKEM768X25519PrivateKey, MLKEM768X25519PublicKey, + MLKEM1024P384PrivateKey, + MLKEM1024P384PublicKey, Suite, ) @@ -37,6 +39,18 @@ def _hybrid_from_xwing_seed( return MLKEM768X25519PrivateKey(mlkem_sk, x25519_sk) +def _hybrid_from_mlkem1024_p384_seed( + seed: bytes, +) -> MLKEM1024P384PrivateKey: + # MLKEM1024-P384 seed expansion: SHAKE256(seed, 112) -> (seed_PQ (64) || + # seed_T (48)). + expanded = hashlib.shake_256(seed).digest(112) + mlkem_sk = mlkem.MLKEM1024PrivateKey.from_seed_bytes(expanded[:64]) + p384_value = int.from_bytes(expanded[64:112], "big") + p384_sk = ec.derive_private_key(p384_value, ec.SECP384R1()) + return MLKEM1024P384PrivateKey(mlkem_sk, p384_sk) + + X25519_ENC_LENGTH = 32 P256_ENC_LENGTH = 65 P384_ENC_LENGTH = 97 @@ -44,6 +58,7 @@ def _hybrid_from_xwing_seed( MLKEM768_ENC_LENGTH = 1088 MLKEM1024_ENC_LENGTH = 1568 MLKEM768_X25519_ENC_LENGTH = 1120 +MLKEM1024_P384_ENC_LENGTH = 1665 SUPPORTED_SUITES = list( itertools.product( @@ -55,6 +70,7 @@ def _hybrid_from_xwing_seed( KEM.MLKEM768, KEM.MLKEM1024, KEM.MLKEM768_X25519, + KEM.MLKEM1024_P384, ], [ KDF.HKDF_SHA256, @@ -96,13 +112,20 @@ def test_roundtrip(self, backend, kem, kdf, aead): ): pytest.skip("SHAKE256 not supported") if ( - kem in [KEM.MLKEM768, KEM.MLKEM1024, KEM.MLKEM768_X25519] + kem + in [ + KEM.MLKEM768, + KEM.MLKEM1024, + KEM.MLKEM768_X25519, + KEM.MLKEM1024_P384, + ] and not backend.mlkem_supported() ): pytest.skip("ML-KEM not supported") - if kem == KEM.MLKEM768_X25519 and not backend.hash_supported( - hashes.SHA3_256() - ): + if kem in [ + KEM.MLKEM768_X25519, + KEM.MLKEM1024_P384, + ] and not backend.hash_supported(hashes.SHA3_256()): pytest.skip("SHA3-256 not supported") suite = Suite(kem, kdf, aead) @@ -112,6 +135,7 @@ def test_roundtrip(self, backend, kem, kdf, aead): | mlkem.MLKEM768PrivateKey | mlkem.MLKEM1024PrivateKey | MLKEM768X25519PrivateKey + | MLKEM1024P384PrivateKey ) if kem == KEM.X25519: sk_r = x25519.X25519PrivateKey.generate() @@ -125,11 +149,16 @@ def test_roundtrip(self, backend, kem, kdf, aead): sk_r = mlkem.MLKEM768PrivateKey.generate() elif kem == KEM.MLKEM1024: sk_r = mlkem.MLKEM1024PrivateKey.generate() - else: + elif kem == KEM.MLKEM768_X25519: sk_r = MLKEM768X25519PrivateKey( mlkem.MLKEM768PrivateKey.generate(), x25519.X25519PrivateKey.generate(), ) + else: + sk_r = MLKEM1024P384PrivateKey( + mlkem.MLKEM1024PrivateKey.generate(), + ec.generate_private_key(ec.SECP384R1()), + ) pk_r = sk_r.public_key() ciphertext = suite.encrypt(b"Hello, HPKE!", pk_r, info=b"test") @@ -148,13 +177,20 @@ def test_roundtrip_no_info(self, backend, kem, kdf, aead): ): pytest.skip("SHAKE256 not supported") if ( - kem in [KEM.MLKEM768, KEM.MLKEM1024, KEM.MLKEM768_X25519] + kem + in [ + KEM.MLKEM768, + KEM.MLKEM1024, + KEM.MLKEM768_X25519, + KEM.MLKEM1024_P384, + ] and not backend.mlkem_supported() ): pytest.skip("ML-KEM not supported") - if kem == KEM.MLKEM768_X25519 and not backend.hash_supported( - hashes.SHA3_256() - ): + if kem in [ + KEM.MLKEM768_X25519, + KEM.MLKEM1024_P384, + ] and not backend.hash_supported(hashes.SHA3_256()): pytest.skip("SHA3-256 not supported") suite = Suite(kem, kdf, aead) @@ -164,6 +200,7 @@ def test_roundtrip_no_info(self, backend, kem, kdf, aead): | mlkem.MLKEM768PrivateKey | mlkem.MLKEM1024PrivateKey | MLKEM768X25519PrivateKey + | MLKEM1024P384PrivateKey ) if kem == KEM.X25519: sk_r = x25519.X25519PrivateKey.generate() @@ -177,11 +214,16 @@ def test_roundtrip_no_info(self, backend, kem, kdf, aead): sk_r = mlkem.MLKEM768PrivateKey.generate() elif kem == KEM.MLKEM1024: sk_r = mlkem.MLKEM1024PrivateKey.generate() - else: + elif kem == KEM.MLKEM768_X25519: sk_r = MLKEM768X25519PrivateKey( mlkem.MLKEM768PrivateKey.generate(), x25519.X25519PrivateKey.generate(), ) + else: + sk_r = MLKEM1024P384PrivateKey( + mlkem.MLKEM1024PrivateKey.generate(), + ec.generate_private_key(ec.SECP384R1()), + ) pk_r = sk_r.public_key() ciphertext = suite.encrypt(b"Hello!", pk_r) @@ -610,6 +652,121 @@ def test_mlkem768_x25519_constructor_type_errors(self): with pytest.raises(TypeError): MLKEM768X25519PublicKey(mlkem_pk, mlkem_pk) # type: ignore[arg-type] + @pytest.mark.supported( + only_if=lambda backend: ( + backend.mlkem_supported() + and backend.hash_supported(hashes.SHA3_256()) + ), + skip_message="Requires ML-KEM and SHA3-256 support", + ) + def test_ciphertext_format_mlkem1024_p384(self): + suite = Suite(KEM.MLKEM1024_P384, KDF.HKDF_SHA256, AEAD.AES_128_GCM) + + mlkem_sk = mlkem.MLKEM1024PrivateKey.generate() + p384_sk = ec.generate_private_key(ec.SECP384R1()) + pk_r = MLKEM1024P384PublicKey( + mlkem_sk.public_key(), p384_sk.public_key() + ) + + ciphertext = suite.encrypt(b"test", pk_r) + + # enc (1665 bytes) + ct (4 bytes pt + 16 bytes tag) + assert len(ciphertext) == MLKEM1024_P384_ENC_LENGTH + 4 + 16 + + @pytest.mark.supported( + only_if=lambda backend: ( + backend.mlkem_supported() + and backend.hash_supported(hashes.SHA3_256()) + ), + skip_message="Requires ML-KEM and SHA3-256 support", + ) + def test_wrong_key_mlkem1024_p384(self): + suite = Suite(KEM.MLKEM1024_P384, KDF.HKDF_SHA256, AEAD.AES_128_GCM) + mlkem_sk = mlkem.MLKEM1024PrivateKey.generate() + p384_sk = ec.generate_private_key(ec.SECP384R1()) + sk_r = MLKEM1024P384PrivateKey(mlkem_sk, p384_sk) + pk_r = MLKEM1024P384PublicKey( + mlkem_sk.public_key(), p384_sk.public_key() + ) + ciphertext = suite.encrypt(b"test", pk_r) + # Correct key decrypts successfully. + assert suite.decrypt(ciphertext, sk_r) == b"test" + + # Wrong key of correct type + sk_wrong = MLKEM1024P384PrivateKey( + mlkem.MLKEM1024PrivateKey.generate(), + ec.generate_private_key(ec.SECP384R1()), + ) + with pytest.raises(InvalidTag): + suite.decrypt(ciphertext, sk_wrong) + + # Wrong key type for encrypt + stray_p384_pk = ec.generate_private_key(ec.SECP384R1()).public_key() + with pytest.raises(TypeError): + suite.encrypt(b"test", stray_p384_pk) + + # Wrong key type for decrypt + stray_p384_sk = ec.generate_private_key(ec.SECP384R1()) + with pytest.raises(TypeError): + suite.decrypt(ciphertext, stray_p384_sk) + + # ML-KEM-1024 key with hybrid suite should fail + mlkem1024_pk = mlkem.MLKEM1024PrivateKey.generate().public_key() + with pytest.raises(TypeError): + suite.encrypt(b"test", mlkem1024_pk) + + mlkem1024_sk = mlkem.MLKEM1024PrivateKey.generate() + with pytest.raises(TypeError): + suite.decrypt(ciphertext, mlkem1024_sk) + + @pytest.mark.supported( + only_if=lambda backend: backend.mlkem_supported(), + skip_message="Requires ML-KEM support", + ) + def test_mlkem1024_p384_wrong_kem_with_ec(self): + # Hybrid public key with EC-based KEM suite should fail + suite = Suite(KEM.P256, KDF.HKDF_SHA256, AEAD.AES_128_GCM) + mlkem_sk = mlkem.MLKEM1024PrivateKey.generate() + p384_sk = ec.generate_private_key(ec.SECP384R1()) + hybrid_pk = MLKEM1024P384PublicKey( + mlkem_sk.public_key(), p384_sk.public_key() + ) + with pytest.raises(TypeError): + suite.encrypt(b"test", hybrid_pk) + + @pytest.mark.supported( + only_if=lambda backend: backend.mlkem_supported(), + skip_message="Requires ML-KEM support", + ) + def test_mlkem1024_p384_constructor_type_errors(self): + mlkem_sk = mlkem.MLKEM1024PrivateKey.generate() + mlkem_pk = mlkem_sk.public_key() + p384_sk = ec.generate_private_key(ec.SECP384R1()) + p384_pk = p384_sk.public_key() + # Wrong-curve EC keys for curve validation paths. + p256_sk = ec.generate_private_key(ec.SECP256R1()) + p256_pk = p256_sk.public_key() + + # Wrong type for mlkem_key in private constructor. + with pytest.raises(TypeError): + MLKEM1024P384PrivateKey(p384_sk, p384_sk) # type: ignore[arg-type] + # Wrong type for p384_key in private constructor. + with pytest.raises(TypeError): + MLKEM1024P384PrivateKey(mlkem_sk, mlkem_sk) # type: ignore[arg-type] + # Wrong EC curve for p384_key in private constructor. + with pytest.raises(TypeError): + MLKEM1024P384PrivateKey(mlkem_sk, p256_sk) + + # Wrong type for mlkem_key in public constructor. + with pytest.raises(TypeError): + MLKEM1024P384PublicKey(p384_pk, p384_pk) # type: ignore[arg-type] + # Wrong type for p384_key in public constructor. + with pytest.raises(TypeError): + MLKEM1024P384PublicKey(mlkem_pk, mlkem_pk) # type: ignore[arg-type] + # Wrong EC curve for p384_key in public constructor. + with pytest.raises(TypeError): + MLKEM1024P384PublicKey(mlkem_pk, p256_pk) + def test_empty_plaintext(self): suite = Suite(KEM.X25519, KDF.HKDF_SHA256, AEAD.AES_128_GCM) @@ -758,6 +915,7 @@ def test_vector_decryption(self, backend, subtests): 0x0020: KEM.X25519, 0x0041: KEM.MLKEM768, 0x0042: KEM.MLKEM1024, + 0x0051: KEM.MLKEM1024_P384, 0x647A: KEM.MLKEM768_X25519, } kdf_map = { @@ -796,13 +954,20 @@ def test_vector_decryption(self, backend, subtests): ): continue if ( - kem in [KEM.MLKEM768, KEM.MLKEM1024, KEM.MLKEM768_X25519] + kem + in [ + KEM.MLKEM768, + KEM.MLKEM1024, + KEM.MLKEM768_X25519, + KEM.MLKEM1024_P384, + ] and not backend.mlkem_supported() ): continue - if kem == KEM.MLKEM768_X25519 and not backend.hash_supported( - hashes.SHA3_256() - ): + if kem in [ + KEM.MLKEM768_X25519, + KEM.MLKEM1024_P384, + ] and not backend.hash_supported(hashes.SHA3_256()): continue suite = Suite(kem, kdf, aead) @@ -814,6 +979,7 @@ def test_vector_decryption(self, backend, subtests): | mlkem.MLKEM768PrivateKey | mlkem.MLKEM1024PrivateKey | MLKEM768X25519PrivateKey + | MLKEM1024P384PrivateKey ) if kem == KEM.X25519: sk_r = x25519.X25519PrivateKey.from_private_bytes( @@ -834,8 +1000,10 @@ def test_vector_decryption(self, backend, subtests): sk_r = mlkem.MLKEM1024PrivateKey.from_seed_bytes( sk_r_bytes ) - else: + elif kem == KEM.MLKEM768_X25519: sk_r = _hybrid_from_xwing_seed(sk_r_bytes) + else: + sk_r = _hybrid_from_mlkem1024_p384_seed(sk_r_bytes) enc = bytes.fromhex(vector["enc"]) info = bytes.fromhex(vector["info"])