package main import ( "crypto/mlkem" "crypto/subtle" "fmt" "log") func agree() { // Receiver: a key pair; only the encapsulation key bytes are handed out dk, err := mlkem.GenerateKey768() if err != nil { log.Fatal(err) } ekBytes := dk.EncapsulationKey().Bytes() // Sender: parse the encapsulation key, get a fresh secret and its ciphertext ek, err := mlkem.NewEncapsulationKey768(ekBytes) if err != nil { log.Fatal(err) } sharedKey, ciphertext := ek.Encapsulate() // Receiver: the same secret from the ciphertext received, err := dk.Decapsulate(ciphertext) if err != nil { log.Fatal(err) } fmt.Println("encapsulation key", len(ekBytes), "bytes, ciphertext", len(ciphertext), "bytes, shared secret", len(sharedKey), "bytes") fmt.Println("shared secret agreed:", subtle.ConstantTimeCompare(received, sharedKey) == 1)} package main import ( "crypto/mlkem" "crypto/x509/pkix" "encoding/asn1" "encoding/pem" "fmt") // id-alg-ml-kem-768 (RFC 9935)var oidMLKEM768 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 4, 2} // encapsulationKeyFromPEM reads an SPKI "PUBLIC KEY" file, e.g. written by OpenSSL 3.5.// crypto/x509 does not parse ML-KEM keys, so the SubjectPublicKeyInfo is unwrapped here.func encapsulationKeyFromPEM(data []byte) (*mlkem.EncapsulationKey768, error) { block, _ := pem.Decode(data) if block == nil || block.Type != "PUBLIC KEY" { return nil, fmt.Errorf("no PUBLIC KEY block") } var spki struct { Algorithm pkix.AlgorithmIdentifier PublicKey asn1.BitString } if rest, err := asn1.Unmarshal(block.Bytes, &spki); err != nil || len(rest) != 0 { return nil, fmt.Errorf("malformed SubjectPublicKeyInfo: %v", err) } if !spki.Algorithm.Algorithm.Equal(oidMLKEM768) { return nil, fmt.Errorf("not an ML-KEM-768 key: %v", spki.Algorithm.Algorithm) } return mlkem.NewEncapsulationKey768(spki.PublicKey.Bytes)} How it works
mlkem.GenerateKey768()returns a*DecapsulationKey768;EncapsulationKey().Bytes()is the 1184-byte key the sender needs.NewEncapsulationKey768(bytes)checks and parses that key;Encapsulate()returns(sharedKey, ciphertext)— 32 and 1088 bytes, in that order.Decapsulate(ciphertext)returns the same 32 bytes; compare withsubtle.ConstantTimeCompare.NewDecapsulationKey768(seed)takes the 64-byte seed (d ‖ z): the shared seed gives OpenSSL's key, decapsulates OpenSSL's ciphertext todee35810…30f9, and OpenSSL decapsulates Go's ciphertext.
Gotchas
- crypto/x509 does not know ML-KEM:
ParsePKCS8PrivateKeyfails withx509: PKCS#8 wrapping contained private key with unknown algorithm: 2.16.840.1.101.3.4.4.2, and SPKI files have to be unwrapped by hand withencoding/asn1, asencapsulationKeyFromPEMdoes. - A tampered ciphertext returns a different secret and a
nilerror (implicit rejection); only a ciphertext of the wrong length errors (mlkem: invalid ciphertext length). dk.Bytes()returns the 64-byte seed, not the expanded key: store that, because it is the only formNewDecapsulationKey768accepts.
Related recipes
- How to encapsulate and decapsulate a shared secret with ML-KEM-768 with OpenSSL
- How to encapsulate and decapsulate a shared secret with ML-KEM-768 in Python
- How to encapsulate and decapsulate a shared secret with ML-KEM-768 in Rust
- How to sign and verify with ML-DSA-65 in Go
- How to derive a shared key with ECDH and HKDF in Go