KMS が ML-KEM 鍵をサポートしたので試してみた

KMS が ML-KEM 鍵をサポートしたので試してみた

AWS KMS の ML-KEM 鍵を、ECDH 鍵の置き換えとして試しました。入れ替わるのは共有秘密を作る部分だけで、HKDF と AES-256-GCM はそのまま使え、どちらも元の平文に復号できました。送信側の鍵共有は OpenSSL だけで完結します。
2026.10.28

はじめに

2026年10月、AWS KMS が ML-KEM(NIST FIPS 203)に対応しました。

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

KMS の鍵スペックは ML_KEM_512、ML_KEM_768、ML_KEM_1024 の3種で、API は Encapsulate と Decapsulate です。

この記事では、東京リージョンに ECDH 用(ECC_NIST_P256)と ML-KEM 用(ML_KEM_512)の KMS 鍵を1本ずつ作りました。同じダミーの平文を、共有秘密から導いた鍵で暗号化して復号する流れの確認を試みました。

キーのカプセル化(KEM)とは

AES-256-GCM のような共通鍵暗号でデータを暗号化するには、送信側と受信側が同じ鍵を持っている必要があります。この鍵(共有秘密)を、盗聴されうる経路越しに2者で確立する方式の1つが、キーのカプセル化(KEM: Key-Encapsulation Mechanism)です。ML-KEM は KEM の1つです。

KEM は次の3つの処理でできています。今回の検証では、受信側の鍵を KMS に置きました。

処理 実行する側 入力と出力 今回使った操作
鍵生成 受信側 公開鍵と秘密鍵を作る KMS の CreateKey(公開鍵は GetPublicKey で取り出す)
カプセル化 送信側 受信側の公開鍵から、共有秘密と暗号文を作る OpenSSL の pkeyutl -encap、または KMS の Encapsulate
デカプセル化 受信側 暗号文と秘密鍵から、同じ共有秘密を取り出す KMS の Decapsulate

送信側はカプセル化のたびに、ランダムな 32 B の共有秘密とその暗号文を作り、暗号文を受信側へ送ります。暗号文から共有秘密を取り出せるのは、秘密鍵を持つ受信側だけです。今回は秘密鍵が KMS にあるので、デカプセル化は KMS の Decapsulate で行います。カプセル化に要るのは公開鍵だけなので、KMS の外の OpenSSL などでも行えます。

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

ECDH も同じ形で使える

ECDH は鍵合意の方式です。2者がそれぞれ自分の秘密鍵と相手の公開鍵を組み合わせると、同じ共有秘密が得られます。

送信側が使い捨ての鍵ペアを作り、受信側の固定の公開鍵と鍵合意する形の ECDH は、KEM と同じ形で見られます(NIST SP 800-227)。使い捨ての鍵ペアの生成と鍵合意がカプセル化に当たり、送信側の公開鍵が暗号文の役をします。今回の ECDH の流れはこの形なので、受信側の KMS 鍵と送信側の処理を ML-KEM に替えても、流れの形は変わりません。

ML-KEM が量子計算機に強い理由

ECDH の安全性は、2つの公開鍵から共有秘密を計算する問題(楕円曲線上の Diffie-Hellman 問題)の難しさに基づきます。この問題は、十分な規模の量子計算機が実現すれば効率よく解けます。そのため、今記録された送信データから、将来、共有秘密を計算されうることになります。

ML-KEM の安全性は、ノイズを加えた多数の一次方程式から秘密の値を求める MLWE 問題の難しさに基づきます。FIPS 203 には、量子計算機を持つ相手に対しても現時点では安全だと考えられていると書かれています。

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

共有秘密から導いた鍵で使う AES-256-GCM は、KMS の開発者ガイドで量子耐性があると見なされている暗号です。そのため今回は、共有秘密を作る部分だけを ML-KEM に替えました。

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

検証の構成

処理の流れ

送信側は、受信側の公開鍵から共有秘密を作り、共有秘密から導いた鍵でダミーの平文を暗号化します。そして、送信側の公開鍵(ECDH)または暗号文(ML-KEM)と、暗号化したデータを受信側へ渡します。受信側は KMS で共有秘密を取り出して復号します。

送信側の処理はコンテナ内の OpenSSL で、受信側の共有秘密の取得はホストの AWS CLI から KMS に対して行います。コンテナにマウントするのは作業用ディレクトリと aead.py だけで、AWS の認証情報は渡しません。これは ECDH と ML-KEM の両方で同じです。

実行環境

  • ホスト:aws-cli/2.38.6、リージョン ap-northeast-1
  • コンテナイメージ:alpine:latest(linux/arm64)、Alpine 3.24.3
  • コンテナ内:apk add で入れた OpenSSL 3.5.9、python3 3.14.8、cryptography 47.0.0
  • 平文:hello, this is a dummy message for KMS verification(51 B、AES-256-GCM で暗号化すると 67 B)

alpine:latest と apk add のバージョンは固定していないため、上記は 2026-10-27 の実行時のものです。

コンテナは次の 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"

ctr.sh、aead.py、作業用ディレクトリ work/ を同じディレクトリに置き、そこで以降のコマンドを実行します。work/ は mkdir work で作り、ctr.sh には実行権限を付けます。コンテナ内で実行したいコマンドは ./ctr.sh '<コンテナ内のコマンド>' の形で引数に渡します。

暗号化と復号

aead.py は、共有秘密から HKDF-SHA256(salt なし、info は固定の文字列)で 32 B の鍵を導きます。この鍵で AES-256-GCM(nonce 12 B)の暗号化と復号をするスクリプトで、ECDH と ML-KEM で同じものを使います。encrypt は nonce と暗号文をファイルに書き、decrypt は復号した平文を標準出力へ出します。OpenSSL の enc コマンドは GCM に対応していないため、Python の cryptography を使いました。

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 の鍵共有

KMS 鍵と公開鍵

ECC_NIST_P256 で、キー使用法が KEY_AGREEMENT の KMS 鍵を作ります。

aws kms create-key --region ap-northeast-1 --key-spec ECC_NIST_P256 --key-usage KEY_AGREEMENT

応答の KeyId を、以降の <ecdh-key-id> に入れます。GetPublicKey は公開鍵を base64 で返すため、base64 -d で DER にして保存します。

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

送信側の処理

コンテナ内で使い捨ての P-256 鍵ペアを作り、その秘密鍵と受信側の公開鍵から共有秘密を導出して、平文を暗号化します。GetPublicKey で得た DER は、OpenSSL がそのまま読めました。受信側へ渡すのは、送信側の公開鍵 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

受信側の処理

送信側の公開鍵を DeriveSharedSecret に渡し、KMS から共有秘密を得ます。OpenSSL が作った DER を、そのまま受け付けました。

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

KMS から得た共有秘密が送信側と一致するかを確かめ、復号します。

./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 の鍵共有

KMS 鍵と公開鍵

ML_KEM_512 で、キー使用法が ENCAPSULATE_DECAPSULATE の KMS 鍵を作ります。

aws kms create-key --region ap-northeast-1 --key-spec ML_KEM_512 --key-usage ENCAPSULATE_DECAPSULATE

応答の KeyId を <mlkem-key-id> に入れ、ECDH と同じ手順で公開鍵を DER にして保存します。DER は 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

送信側の処理

受信側の公開鍵から、OpenSSL の pkeyutl -encap で共有秘密と暗号文を作ります。ECDH では ecparam と pkey で使い捨ての鍵ペアを作ってから -derive で共有秘密を導出しました。一方、-encap は乱数の生成から共有秘密と暗号文の出力までを1つのコマンドで行います。受信側へ渡すのは、送信側の公開鍵の代わりに暗号文 mlkem-ct.bin です。

./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
'

aead.py の出力は ECDH と同じ(共有秘密 32 B、暗号化したデータ 67 B)でした。

受信側の処理

暗号文を Decapsulate に渡し、KMS から共有秘密を得ます。OpenSSL が出力した暗号文(生のバイト列)を、そのまま受け付けました。

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

ECDH と同様に、共有秘密の一致を確かめて復号します。

./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
'

ECDH と同じく、shared secret: MATCH と元の平文が出力されました。

カプセル化のたびに共有秘密が変わる

同じ KMS 鍵で、今度は KMS の Encapsulate でカプセル化します。応答を JSON で保存し、jq で共有秘密と暗号文をファイルに取り出してから、暗号文を 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 は Encapsulate が返した共有秘密と同じ値を返し、cmp で一致を確認できました。一方で、この共有秘密と暗号文は、前の節で OpenSSL の -encap が作ったものとは別の値でした。同じ公開鍵でも、カプセル化するたびに新しい共有秘密が作られ、受信側はそれぞれの暗号文から、対応する共有秘密を取り出せます。

ECDH と ML-KEM の違い

項目 ECDH(ECC_NIST_P256) ML-KEM(ML_KEM_512)
安全性の根拠になる問題 楕円曲線上の Diffie-Hellman 問題。十分な規模の量子計算機が実現すれば効率よく解ける MLWE 問題。量子計算機に対しても現時点では安全と考えられている
KMS 鍵のキー使用法 KEY_AGREEMENT ENCAPSULATE_DECAPSULATE
受信側の公開鍵(DER) 91 B 822 B
送信側の OpenSSL の処理 使い捨て鍵ペアの生成(ecparam、pkey)と pkeyutl -derive pkeyutl -encap
送信側が受信側へ送るもの(暗号文の役) 送信側の公開鍵(91 B) 暗号文(768 B)
受信側の KMS API DeriveSharedSecret Decapsulate

共有秘密のサイズはどちらも 32 B で、送信側と KMS の共有秘密が一致し、元の平文に復号できました。共有秘密から HKDF-SHA256 で鍵を導き、AES-256-GCM で暗号化する部分は、aead.py をそのまま使えました。

まとめ

NIST IR 8547 では、楕円曲線の DH を 2030 年以降は非推奨、2035 年以降は使用不可とし、ML-KEM の既定のパラメータセットには ML-KEM-768 を推奨しています。

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

今回は ML_KEM_512 の鍵スペックで AWS KMS を試しました。ただし、NIST の移行ガイドラインへの対応に備える必要がある場合は、ML_KEM_768、ML_KEM_1024 などの鍵スペックでお試しください。

この記事をシェアする

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

関連記事