cryptography
cryptography copied to clipboard
ML-KEM API Design
Here's a strawman, let's discuss.
A few thoughts:
- I don't want to use public/private, but Encapsulation and Decapsulation are quite verbose. I've used Encap/Decap below but we can use the full words if people think it's better.
- I see no reason to support/allow anything other than seed only PKCS8 for both loading and serialization.
- Do we want to support bare seed loading? The current API proposal below does allow it similar to how we do it in Ed/X keys.
- If we support bare seed loading then we should support bare seed serialization. We can repurpose
PrivateFormat.Rawfor this I suppose. - How do we load PKCS8? We have
load_{pem,der}_{public,private}_keyfunctions, but if we don't want to use the public/private nomenclature we may want new functions. - This proposal makes the parameter set a property of the class rather than a value to be passed. We could choose to expose an enum of parameter sets (768 and 1024...maybe 512 if necessary, although it seems no one is implementing that) instead of this. Go has chosen separate concrete interfaces (https://github.com/golang/go/issues/70122) for this and I generally think separate types are a reasonable choice here, although a consequence is that code that supports multiple MLKEM sizes will need to have type signatures like
input: MLKEM768EncapKey | MLKEM1024EncapKey
class MLKEM768DecapKey:
@classmethod
def generate(cls) -> "MLKEM768DecapKey":
...
def decap(self, ciphertext: bytes) -> bytes:
...
def encap_key(self) -> "MLKEM768EncapKey":
...
def private_bytes(
self,
encoding: serialization.Encoding,
format: serialization.PrivateFormat,
encryption_algorithm: serialization.KeySerializationEncryption,
) -> bytes:
...
@classmethod
def from_seed_bytes(cls, data: bytes) -> "MLKEM768DecapKey":
...
class MLKEM768EncapKey:
def encap(self) -> Tuple[bytes, bytes]:
"""
Note that this tuple should be shared_secret, ciphertext to match the spec
"""
...
def public_bytes(
self,
encoding: serialization.Encoding,
format: serialization.PublicFormat,
) -> bytes:
...
Thoughts:
a) Overall API design looks fine
b) I'm a bit ambivalent on encap/decap vs. full words. I have easy arguments for both sides of it.
c) Is there a use case for bare seeds? If so, let's do it (since easy), but if there's no concrete usecase, skip for now (easy to add later). I guess there obviously is for encap keys?
d) Yes, seed-only for PKCS8, obviously :-)
e) I think we just reuse the private/public key loading APIs. It's not great, but what can you do.
f) I kind of think the _bytes() methods should still be public/private, since that's really saying "do you need to keep this secret"
Here's the API I'm currently using (which currently wraps around liboqs):
class PQDH:
"""A shim around liboqs for post-quantum key exchange algorithms"""
def __init__(self, alg_name: bytes):
"""Select the PQ algorithm to use, setting internal methods for keypair, encaps, and decaps"""
def keypair(self) -> Tuple[bytes, bytes]:
"""Make a new key pair, returning public and private key byte arrays"""
def encaps(self, pubkey: bytes) -> Tuple[bytes, bytes]:
"""Generate a random secret and encrypt it with a public key, returning secret and cipher text as byte arrays"""
def decaps(self, ciphertext: bytes, privkey: bytes) -> bytes:
"""Decrypt an encrypted secret using a private key, returning the decrypted secret as a byte array"""
What serialization options did you have in mind? I'd want raw bytes in my application, eventually concatenating these bytes with bytes from X25519 or ECDSA and then encoding the combination as an SSH "string" object.
I'm currently thinking we support both the PKCS8 serialization (seed only) as well as bare seed. Do you need the expanded key for your use case or is the seed sufficient 🤞?
I think the only two serialization formats on the table are: raw seed, and seed-only PKCS8.
On Tue, Apr 29, 2025 at 5:27 PM Ron Frederick @.***> wrote:
ronf left a comment (pyca/cryptography#12824) https://github.com/pyca/cryptography/issues/12824#issuecomment-2840290357
Here's the API I'm currently using (which currently wraps around liboqs):
class PQDH: """A shim around liboqs for post-quantum key exchange algorithms"""
def __init__(self, alg_name: bytes): """Select the PQ algorithm to use, setting internal methods for keypair, encaps, and decaps""" def keypair(self) -> Tuple[bytes, bytes]: """Make a new key pair, returning public and private key byte arrays""" def encaps(self, pubkey: bytes) -> Tuple[bytes, bytes]: """Generate a random secret and encrypt it with a public key, returning secret and cipher text as byte arrays""" def decaps(self, ciphertext: bytes, privkey: bytes) -> bytes: """Decrypt an encrypted secret using a private key, returning the decrypted secret as a byte array"""What serialization options did you have in mind? I'd want raw bytes in my application, eventually concatenating these bytes with bytes from X25519 or ECDSA and then encoding the combination as an SSH "string" object.
— Reply to this email directly, view it on GitHub https://github.com/pyca/cryptography/issues/12824#issuecomment-2840290357, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAAAGBFZPG4ZZXZMK4ZJWPL237VFHAVCNFSM6AAAAAB4DZIML2VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDQNBQGI4TAMZVG4 . You are receiving this because you commented.Message ID: @.***>
-- All that is necessary for evil to succeed is for good people to do nothing.
As far as I know, the liboqs library always generates expanded keys in the keypair() call and expects an expanded key in the decaps() call. I don't think it has any support for dealing with seeds. Since the private keys are ephemeral for DH, I don't think supporting seeds would really buy us much for that use case, but I could see later on wanting to have a function to expand a seed to an expanded key.
I still don't quite see how serialization fits in on your decap call. Shouldn't that always return a byte string which matches the byte string passed in to encap (if the key matches)? Neither the public nor private key is returned here.
There wouldn't be any serialization in decap. The current API proposal isn't stateless, it expects you will have loaded an MLKEM768DecapKey and then called decap on it with the ciphertext.
There is a strong desire to not expose expanded decap keys as a serializable type for a variety of reasons (see: https://datatracker.ietf.org/meeting/122/materials/slides-122-pquip-the-great-private-key-war-of-25-00 for a bit of context), so I'm interested in what the SSH protocol's requirements are for this. I'm guessing you need to be able to obtain encapsulation keys as raw bytes with no SPKI wrapper, but what else?
SSH requires access to the public key as bytes (1184 bytes long for mlkem768x25519-sha256), along with the 32 byte X25519 public key. It concatenates these and encodes the combination as an SSH string (4 bytes of length followed by concatenated raw key bytes) and sends this public key data in an MSG_KEX_ECDH_INIT message
When the server receives the INIT message, it extracts the public keys and calls encaps() on the PQ public key bytes and gets back a random secret and cipher text. The cipher text (1088 bytes) is combined with X25519 shared bytes (32 bytes) and sent as a concatenated SSH string in a MSG_KEX_ECDH_REPLY message.
The client decrypts the cipher text with decaps() using its private key and creates a shared key made of the random secret and EC shared bytes concatenated and passed into a SHA-256 hash. The server has the same secret bytes and EC shared bytes, and it does the same. There's some other stuff here related to signing with the SSH server's host key, but that's not relevant for this discussion.
I have to admit I don't know the exact formatting of the ML-KEM public key bytes in liboqs, but whatever liboqs returns from its keypair() function is exactly what SSH needed to put on the wire.
There wouldn't be any serialization in decap. The current API proposal isn't stateless, it expects you will have loaded an MLKEM768DecapKey and then called decap on it with the ciphertext.
That's fine, but I'm not clear on the encap_bytes() and decap_bytes() functions you proposed with serialization arguments. Are those for key serialization? If so, it's confusing to use the same encap/decap name for both purposes.
Dropping this here for my own reference:
- BoringSSL MLKEM API
- LibreSSL will be using the boring implementation, but as of 4.1.0 their release notes say
Imported ML-KEM 768 and 1024 from BoringSSL (not yet public API). - OpenSSL KEM API
- aws-lc KEM API
All of these will need to be supported in rust-openssl to move forward here.
Hello @reaperhulk, @alex, et al. I'm an engineer at Red Hat and I'm assigned to work toward ML-DSA and ML-KEM support in python-cryptography, aiming to deliver in the first half of 2026. I had a sync with @simo5 - I believe he was in some earlier discussions, and he conveyed (at a high level) some of the expectations in this project. These include:
-
delivering support for OpenSSL backend + one additional backend (e.g. BoringSSL or aws-lc). This is to ensure that the API design and implementation approach are compatible with multiple backends, and we don't accidentally do something OpenSSL-specific. In terms of which additional backend(s), does the project have any preference?
-
Proper and proactive agreement on APIs and implementation approach.
-
100% code coverage.
For Red Hat, ML-DSA is the higher priority and I want to work toward delivering that first. (I did not see a github issue for ML-DSA so I will make a new one). Is addition of ML-DSA separately and prior to ML-KEM acceptable for you? (ML-KEM design discussion can proceed in any case).
(For the avoidance of doubt, SLH-DSA is not a priority for us right now.)
Are there any other things that need to be discussed/agreed or that you want to make me aware of, before I dive into the work?
Thanks, Fraser
If your goal is ML-DSA, then my recommended steps are:
-
File an issue with a proposed ML-DSA API where we can agree on it
-
Implement ML-DSA for a non-OpenSSL backend (any of BoringSSL, AWS-LC, or LibreSSL) first
-
Then add the appropriate functionality to rust-openssl and implement for OpenSSL here