
KMS Now Supports ML-KEM Keys, So I Tried It Out
This page has been translated by machine translation. View original
Introduction
In October 2026, AWS KMS added support for ML-KEM (NIST FIPS 203).
KMS offers three key specs, ML_KEM_512, ML_KEM_768, and ML_KEM_1024, and the APIs are Encapsulate and Decapsulate.
In this article, I created one KMS key for ECDH (ECC_NIST_P256) and one for ML-KEM (ML_KEM_512) in the Tokyo region. I tried to confirm the flow of encrypting and decrypting the same dummy plaintext with a key derived from the shared secret.
What is Key Encapsulation (KEM)?
To encrypt data with symmetric-key cryptography such as AES-256-GCM, the sender and the receiver must hold the same key. One way for two parties to establish this key (the shared secret) over a channel that may be eavesdropped is Key-Encapsulation Mechanism (KEM). ML-KEM is one kind of KEM.
A KEM consists of the following three operations. In this verification, I placed the receiver's key in KMS.
| Operation | Performed by | Input and output | Operation used here |
|---|---|---|---|
| Key generation | Receiver | Creates a public key and a private key | KMS CreateKey (the public key is retrieved with GetPublicKey) |
| Encapsulation | Sender | Creates a shared secret and a ciphertext from the receiver's public key | OpenSSL pkeyutl -encap, or KMS Encapsulate |
| Decapsulation | Receiver | Recovers the same shared secret from the ciphertext and the private key | KMS Decapsulate |
On each encapsulation, the sender creates a random 32 B shared secret and its ciphertext, and sends the ciphertext to the receiver. Only the receiver who holds the private key can recover the shared secret from the ciphertext. Here the private key is in KMS, so decapsulation is done with KMS Decapsulate. Encapsulation needs only the public key, so it can also be done outside KMS, for example with OpenSSL.
ECDH can be used in the same form
ECDH is a key agreement method. When two parties each combine their own private key with the other party's public key, they obtain the same shared secret.
ECDH in which the sender creates a throwaway key pair and performs key agreement with the receiver's fixed public key can be viewed in the same form as a KEM (NIST SP 800-227). Generating the throwaway key pair and performing the key agreement corresponds to encapsulation, and the sender's public key plays the role of the ciphertext. The ECDH flow in this article takes this form, so even if the receiver's KMS key and the sender's processing are replaced with ML-KEM, the shape of the flow does not change.
Why ML-KEM is resistant to quantum computers
The security of ECDH is based on the difficulty of computing a shared secret from two public keys (the Diffie-Hellman problem on elliptic curves). This problem can be solved efficiently once a sufficiently large quantum computer is realized. As a result, the shared secret could be computed in the future from data transmitted and recorded today.
The security of ML-KEM is based on the difficulty of the MLWE problem, which is to find a secret value from many linear equations with added noise. FIPS 203 states that it is currently considered secure even against adversaries with quantum computers.
AES-256-GCM, used with the key derived from the shared secret, is a cipher regarded as quantum-resistant in the KMS developer guide. For this reason, I replaced only the part that creates the shared secret with ML-KEM.
Verification setup
Flow of processing
The sender creates a shared secret from the receiver's public key and encrypts the dummy plaintext with a key derived from the shared secret. Then it passes the sender's public key (ECDH) or the ciphertext (ML-KEM), together with the encrypted data, to the receiver. The receiver obtains the shared secret with KMS and decrypts.
The sender's processing is done with OpenSSL inside a container, and obtaining the receiver's shared secret is done against KMS from the host's AWS CLI. Only the working directory and aead.py are mounted into the container, and no AWS credentials are passed. This is the same for both ECDH and ML-KEM.
Execution environment
- Host: aws-cli/2.38.6, region ap-northeast-1
- Container image: alpine:latest (linux/arm64), Alpine 3.24.3
- Inside the container: OpenSSL 3.5.9, python3 3.14.8, cryptography 47.0.0 installed with apk add
- Plaintext:
hello, this is a dummy message for KMS verification(51 B, 67 B when encrypted with AES-256-GCM)
The versions of alpine:latest and apk add are not pinned, so the above are those at the time of execution on 2026-10-27.
The container is started with the following ctr.sh.
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
docker run --rm --platform linux/arm64 \
-v "$DIR/work:/work" -v "$DIR/aead.py:/usr/local/bin/aead.py:ro" -w /work \
alpine:latest sh -c "apk add --no-cache openssl python3 py3-cryptography >/dev/null 2>&1; $1"
Place ctr.sh, aead.py, and the working directory work/ in the same directory, and run the following commands there. Create work/ with mkdir work, and give ctr.sh execute permission. Pass the command you want to run inside the container as an argument in the form ./ctr.sh '<command inside the container>'.
Encryption and decryption
aead.py derives a 32 B key from the shared secret with HKDF-SHA256 (no salt, a fixed string as info). This script encrypts and decrypts with AES-256-GCM (12 B nonce) using that key, and the same one is used for both ECDH and ML-KEM. encrypt writes the nonce and ciphertext to files, and decrypt outputs the decrypted plaintext to standard output. The OpenSSL enc command does not support GCM, so I used Python's cryptography.
Full aead.py
#!/usr/bin/env python3
"""共有秘密から HKDF-SHA256 で 32 バイトの鍵を導き、AES-256-GCM で暗号化・復号する。
使い方:
aead.py encrypt <shared_secret_file> <plaintext_string> <out_prefix>
-> <out_prefix>.nonce, <out_prefix>.ct を書く
aead.py decrypt <shared_secret_file> <in_prefix>
-> 復号した平文を標準出力へ出す
"""
import sys
import os
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
INFO = b"kms-ml-kem-demo"
def derive_key(secret: bytes) -> bytes:
return HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=INFO).derive(secret)
def main() -> None:
mode = sys.argv[1]
secret = open(sys.argv[2], "rb").read()
key = derive_key(secret)
if mode == "encrypt":
plaintext = sys.argv[3].encode()
prefix = sys.argv[4]
nonce = os.urandom(12)
ct = AESGCM(key).encrypt(nonce, plaintext, None)
open(prefix + ".nonce", "wb").write(nonce)
open(prefix + ".ct", "wb").write(ct)
print(f"secret={len(secret)}B key={len(key)}B nonce={len(nonce)}B ciphertext={len(ct)}B")
elif mode == "decrypt":
prefix = sys.argv[3]
nonce = open(prefix + ".nonce", "rb").read()
ct = open(prefix + ".ct", "rb").read()
print(AESGCM(key).decrypt(nonce, ct, None).decode())
else:
sys.exit("unknown mode")
main()
ECDH key sharing
KMS key and public key
Create a KMS key with ECC_NIST_P256 and the key usage KEY_AGREEMENT.
aws kms create-key --region ap-northeast-1 --key-spec ECC_NIST_P256 --key-usage KEY_AGREEMENT
Put the KeyId from the response into <ecdh-key-id> below. GetPublicKey returns the public key in base64, so convert it to DER with base64 -d and save it.
aws kms get-public-key --region ap-northeast-1 --key-id <ecdh-key-id> --query PublicKey --output text | base64 -d > work/recipient-ecdh.pub.der
Sender's processing
Inside the container, create a throwaway P-256 key pair, derive a shared secret from its private key and the receiver's public key, and encrypt the plaintext. OpenSSL could read the DER obtained from GetPublicKey as is. What is passed to the receiver is the sender's public key, sender-ecdh.pub.der.
./ctr.sh '
set -e
openssl ecparam -name prime256v1 -genkey -noout -out sender-ecdh.pem
openssl pkey -in sender-ecdh.pem -pubout -outform DER -out sender-ecdh.pub.der
openssl pkeyutl -derive -inkey sender-ecdh.pem -peerkey recipient-ecdh.pub.der -peerform DER -out sender-ss-ecdh.bin
python3 /usr/local/bin/aead.py encrypt sender-ss-ecdh.bin "hello, this is a dummy message for KMS verification" ecdh-msg
'
secret=32B key=32B nonce=12B ciphertext=67B
Receiver's processing
Pass the sender's public key to DeriveSharedSecret and obtain the shared secret from KMS. It accepted the DER created by OpenSSL as is.
aws kms derive-shared-secret --region ap-northeast-1 --key-id <ecdh-key-id> --key-agreement-algorithm ECDH --public-key fileb://work/sender-ecdh.pub.der --query SharedSecret --output text | base64 -d > work/kms-ss-ecdh.bin
Check that the shared secret obtained from KMS matches the sender's, then decrypt.
./ctr.sh '
set -e
cmp kms-ss-ecdh.bin sender-ss-ecdh.bin && echo "shared secret: MATCH"
python3 /usr/local/bin/aead.py decrypt kms-ss-ecdh.bin ecdh-msg
'
shared secret: MATCH
hello, this is a dummy message for KMS verification
ML-KEM key sharing
KMS key and public key
Create a KMS key with ML_KEM_512 and the key usage ENCAPSULATE_DECAPSULATE.
aws kms create-key --region ap-northeast-1 --key-spec ML_KEM_512 --key-usage ENCAPSULATE_DECAPSULATE
Put the KeyId from the response into <mlkem-key-id>, and save the public key as DER using the same steps as ECDH. The DER was 822 B.
aws kms get-public-key --region ap-northeast-1 --key-id <mlkem-key-id> --query PublicKey --output text | base64 -d > work/recipient-mlkem.pub.der
Sender's processing
Create a shared secret and a ciphertext from the receiver's public key with OpenSSL pkeyutl -encap. With ECDH, I created a throwaway key pair with ecparam and pkey and then derived the shared secret with -derive. In contrast, -encap does everything from generating randomness to outputting the shared secret and ciphertext in a single command. What is passed to the receiver is the ciphertext mlkem-ct.bin instead of the sender's public key.
./ctr.sh '
set -e
openssl pkeyutl -encap -pubin -keyform DER -inkey recipient-mlkem.pub.der -out mlkem-ct.bin -secret sender-ss-mlkem.bin
python3 /usr/local/bin/aead.py encrypt sender-ss-mlkem.bin "hello, this is a dummy message for KMS verification" mlkem-msg
'
The output of aead.py was the same as for ECDH (32 B shared secret, 67 B encrypted data).
Receiver's processing
Pass the ciphertext to Decapsulate and obtain the shared secret from KMS. It accepted the ciphertext (raw bytes) output by OpenSSL as is.
aws kms decapsulate --region ap-northeast-1 --key-id <mlkem-key-id> --encapsulation-algorithm ML_KEM --shared-secret-ciphertext-blob fileb://work/mlkem-ct.bin --query SharedSecret --output text | base64 -d > work/kms-ss-mlkem.bin
As with ECDH, check that the shared secrets match, then decrypt.
./ctr.sh '
set -e
cmp kms-ss-mlkem.bin sender-ss-mlkem.bin && echo "shared secret: MATCH"
python3 /usr/local/bin/aead.py decrypt kms-ss-mlkem.bin mlkem-msg
'
As with ECDH, shared secret: MATCH and the original plaintext were output.
The shared secret changes with every encapsulation
This time, with the same KMS key, I encapsulate using KMS Encapsulate. I save the response as JSON, extract the shared secret and ciphertext into files with jq, and then pass the ciphertext to Decapsulate.
aws kms encapsulate --region ap-northeast-1 --key-id <mlkem-key-id> --encapsulation-algorithm ML_KEM > work/encapsulate.json
jq -r .SharedSecretCiphertextBlob work/encapsulate.json | base64 -d > work/kms-enc-ct.bin
jq -r .SharedSecret work/encapsulate.json | base64 -d > work/kms-enc-ss.bin
aws kms decapsulate --region ap-northeast-1 --key-id <mlkem-key-id> --encapsulation-algorithm ML_KEM --shared-secret-ciphertext-blob fileb://work/kms-enc-ct.bin --query SharedSecret --output text | base64 -d > work/kms-dec-ss.bin
cmp work/kms-enc-ss.bin work/kms-dec-ss.bin
Decapsulate returned the same value as the shared secret returned by Encapsulate, and cmp confirmed they match. On the other hand, this shared secret and ciphertext were different values from those created by OpenSSL's -encap in the previous section. Even with the same public key, a new shared secret is created for each encapsulation, and the receiver can recover the corresponding shared secret from each ciphertext.
Differences between ECDH and ML-KEM
| Item | ECDH (ECC_NIST_P256) | ML-KEM (ML_KEM_512) |
|---|---|---|
| Problem underlying security | The Diffie-Hellman problem on elliptic curves. Can be solved efficiently once a sufficiently large quantum computer is realized | The MLWE problem. Currently considered secure even against quantum computers |
| Key usage of the KMS key | KEY_AGREEMENT | ENCAPSULATE_DECAPSULATE |
| Receiver's public key (DER) | 91 B | 822 B |
| Sender's OpenSSL processing | Generating a throwaway key pair (ecparam, pkey) and pkeyutl -derive | pkeyutl -encap |
| What the sender sends to the receiver (role of the ciphertext) | Sender's public key (91 B) | Ciphertext (768 B) |
| Receiver's KMS API | DeriveSharedSecret | Decapsulate |
The shared secret size was 32 B for both, the sender's and KMS's shared secrets matched, and the data could be decrypted to the original plaintext. For the part that derives a key from the shared secret with HKDF-SHA256 and encrypts with AES-256-GCM, I could use aead.py as is.
Summary
NIST IR 8547 deprecates elliptic-curve DH from 2030 and disallows it from 2035, and recommends ML-KEM-768 as the default parameter set for ML-KEM.
This time I tried AWS KMS with the ML_KEM_512 key spec. However, if you need to prepare for compliance with NIST's migration guidelines, please try key specs such as ML_KEM_768 and ML_KEM_1024.
