KMS Now Supports ML-KEM Keys, So I Tried It Out

KMS Now Supports ML-KEM Keys, So I Tried It Out

I tried AWS KMS ML-KEM keys as a replacement for ECDH keys. Only the shared-secret step changes; HKDF and AES-256-GCM work as before, and both decrypted to the original plaintext. Sender-side key agreement needs only OpenSSL.
2026.10.28

This page has been translated by machine translation. View original

Introduction

In October 2026, AWS KMS added support for ML-KEM (NIST FIPS 203).

https://aws.amazon.com/jp/about-aws/whats-new/2026/10/kms-ml-kem-post-quantum/

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.

https://csrc.nist.gov/pubs/sp/800/227/final

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.

https://csrc.nist.gov/pubs/fips/203/final

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.

https://docs.aws.amazon.com/kms/latest/developerguide/symm-asymm-choose-key-spec.html

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.

https://csrc.nist.gov/pubs/ir/8547/final

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.

Share this article

AWSのお困り事はクラスメソッドへ