本文へスキップ
ecdsa.com
← すべての記事

開発者のための ML-DSA:FIPS 204 の鍵、署名、コード

実際にデプロイすることになるポスト量子署名です。3 つのパラメーターセットがバイト数でどれだけの負担になるか、署名を変えるオプションは何か、あるライブラリの鍵が別のライブラリで読み込めないのはなぜかを解説し、6 つのスタック向けの動作確認済みコードを紹介します。

要点:ML-DSA は、FIPS 204(2024年8月)で標準化された格子暗号の署名方式で、旧称は CRYSTALS-Dilithium です。パラメーターセットは ML-DSA-44、ML-DSA-65、ML-DSA-87 の 3 つです。署名はそれぞれ 2420、3309、4627 バイトで、これに対して Ed25519 や ECDSA P-256 の署名は 64 バイトです。署名はデフォルトでヘッジ型(ランダム化)で、決定的モードが変えるのは署名者が出力するものだけです。最大 255 バイトの任意のコンテキスト文字列と、別個の事前ハッシュ版(HashML-DSA)は、検証者が行うべき処理を変えるため、署名側と検証側で合わせておく必要があります。OpenSSL 3.5、Go 1.27、Java 24 以降、.NET 10、BouncyCastle、@noble/post-quantum のいずれも ML-DSA を実装しています。実装間で最も食い違うのは秘密鍵の扱いです。32 バイトのシードを保存してください。

なぜ ML-DSA なのか、なぜ今なのか

十分に大規模な量子コンピューターを持つ者なら誰でも、ECDSA、EdDSA、RSA の署名を偽造できます。暗号化された通信とは異なり、署名を今のうちに収集して後で破っても、過去に遡って被害を生むことはありません。偽造された署名が問題になるのは、誰かがそれを信頼したその時点です。だからこそ、急を要する移行は鍵交換のほうであり、署名にはまだ時間があります。ただし、「時間がある」といっても限度があります。

  • ファームウェアの署名鍵、CA のルート、コード署名や文書署名の鍵といった長期にわたる信頼の基点は、作成後 10 年、20 年と信頼され続けなければなりません。
  • NIST の移行計画のドラフト IR 8547 では、セキュリティ強度 112 ビットの従来型署名を 2030年より後は非推奨とし、量子コンピューターに対して脆弱なすべての署名を 2035年より後は使用禁止とする予定です。期限とそれに合わせた計画の立て方は、2026年のポスト量子暗号への移行で解説しています。

ML-DSA は、NIST が用意した汎用の解決策です。兄弟にあたる方式は、それぞれ特定の用途を担います。FIPS 205 の SLH-DSA はハッシュベースの方式で、安全性の仮定が保守的で鍵が小さい一方、署名は非常に大きいか、署名に時間がかかります。ステートフルなハッシュベース署名である LMS と XMSS(SP 800-208)は、ファームウェアに使われています。Falcon をベースとする FN-DSA は、FIPS 206 として策定される予定です。2026年10月の時点で、NIST は csrc.nist.gov にそのドラフトを公開していませんでした。

3 つのパラメーターセット

ML-DSA-44ML-DSA-65ML-DSA-87
NIST のセキュリティカテゴリ235
公開鍵1312 B1952 B2592 B
署名2420 B3309 B4627 B
展開済みの秘密鍵2560 B4032 B4896 B
秘密鍵のシード32 B32 B32 B
SPKI(DER)形式の公開鍵1334 B1974 B2614 B
OID(RFC 9881)2.16.840.1.101.3.4.3.17…3.18…3.19

生のサイズは FIPS 204 の表 2 によるものです。SPKI のサイズは、新たに生成した鍵を OpenSSL 3.5.7 が書き出したときの値です。比較のために挙げると、Ed25519 の公開鍵は 32 バイト、署名は 64 バイトです。RSA-2048 の公開鍵は SPKI で 294 バイト、署名は 256 バイトです。

どのセットを選ぶかの目安は次のとおりです。

  • ML-DSA-65 は一般的なデフォルトです。JEP 497 により、パラメーターセットを指定しない場合の Java のデフォルトはこのセットになっており、ほとんどのガイダンスもカテゴリ 3 を無理のない中間の選択肢として扱っています。
  • ML-DSA-87 は、NSA の CNSA 2.0 が米国の国家安全保障システムに要求しているセットです(すべての機密区分に対して「Level V」のパラメーター)。
  • ML-DSA-44 は最も小さいセットです。すべてのバイトが重要で、データの寿命が短い用途に適しています。Go のドキュメントには「ほとんどのアプリケーションでは MLDSA44 を使用すべきです」とまで書かれています。

実際には、速度よりもバイト数のほうが重要です。この記事で使用した arm64 の Linux VM で、シングルコアで 2 秒間の openssl speed(OpenSSL 3.5.7)を実行したところ、次の速度になりました。

アルゴリズム署名/秒検証/秒
ML-DSA-651,4888,019
RSA-20481,82574,119
ECDSA P-25665,46524,446

ML-DSA-65 の署名速度は RSA-2048 とほぼ同じで、検証は RSA より一桁遅いものの、ほとんどのサービスには十分な速さです。より大きな変化はサイズです。証明書チェーン、JWT、署名付きのログエントリは、キロバイト単位で大きくなります。今回のテストでは、ML-DSA-65 の CA が署名した ML-DSA-65 のリーフ証明書は、DER で 5514 バイトになりました。

ヘッジ型、決定的、コンテキスト:署名を左右するオプション

FIPS 204 の 3 つのオプションによって、Sign から出力されるバイト列と、Verify に伝えなければならない情報が決まります。

ヘッジ型か決定的か:ML-DSA の内部では、署名処理で、秘密鍵、メッセージ、32 バイトの値 rnd から署名ごとの秘密値を導出します。デフォルト(ヘッジ型)では、rnd は新しい乱数です。決定的バリアントでは、32 バイトのゼロです。FIPS 204 の §3.4 がこのトレードオフを説明しています。事前に計算される部分は不具合のある乱数生成器から保護し、新しい乱数の部分はサイドチャネル攻撃や故障利用攻撃を難しくします。さらに、決定的バリアントは「サイドチャネル攻撃が懸念されるプラットフォームでは使用すべきではない」とも述べています。検証はどちらでも同じなので、どちらを選ぶかは署名者側だけの問題です。OpenSSL では違いを直接確認できます。

OpenSSL 3.5.7:ML-DSA-65 のヘッジ型署名と決定的署名
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt | sha256sum
f5c30f1347b231f64f7adf48a4dfeb77d1c259cfc568420ad1f2238335e28616  -
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt | sha256sum
c0eada83bdaa8e278defdd974cbd849fac583f7519a075ee53ec2a3d93adc6ba  -
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt -pkeyopt deterministic:1 | sha256sum
c179822bec24d34a38c16257b41624cfb3a9ed40b7d7c79a5b5f2bdf0b87d4bf  -
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt -pkeyopt deterministic:1 | sha256sum
c179822bec24d34a38c16257b41624cfb3a9ed40b7d7c79a5b5f2bdf0b87d4bf  -

決定的に署名するのでない限り、新しく作成した署名を保存済みの署名と比較するテストは書かないでください。ヘッジ型の署名は、設計上、呼び出しのたびに異なります。

コンテキスト文字列:Sign と Verify はどちらも、最大 255 バイトのコンテキスト文字列 ctx を受け取ります。デフォルトは空です。ML-DSA が署名するのは M だけではなく 0x00 ‖ len(ctx) ‖ ctx ‖ M なので、あるコンテキストで作成した署名は、別のコンテキストでは検証に通りません。コンテキストは、ドメイン分離のための手軽なツールです。鍵を共有する 2 つのプロトコルがそれぞれ独自のコンテキストを使っていれば、互いの署名を受け入れるように仕向けられることはありません。一方で、片方だけがコンテキストを設定し、もう片方が設定していないと、原因のわかりにくい形で検証が失敗します。

OpenSSL 3.5.7:コンテキスト付きで署名し、コンテキストなしで検証
$ openssl pkeyutl -sign -rawin -inkey k65.pem -in msg.txt -pkeyopt context-string:invoice-v1 -out sigctx.bin
$ openssl pkeyutl -verify -rawin -pubin -inkey p65.pem -in msg.txt -sigfile sigctx.bin -pkeyopt context-string:invoice-v1
Signature Verified Successfully
$ openssl pkeyutl -verify -rawin -pubin -inkey p65.pem -in msg.txt -sigfile sigctx.bin
...:error:030000EA:digital envelope routines:EVP_DigestVerify:provider signature failure:../crypto/evp/m_sigver.c:781:ML-DSA-65 digest_verify:
Signature Verification Failure

コンテキストはプロトコルが固定するので、自分で選ぶことはまずありません。X.509 の ML-DSA(RFC 9881)も、JOSE と COSE の ML-DSA(RFC 9964、2026年5月)も、空のコンテキストを使います。RFC 9964 は「ctx パラメーターは空文字列でなければなりません(MUST)」と規定しています。

HashML-DSA:FIPS 204 の §5.4 では、事前ハッシュ版も定義されています。メッセージをまず SHA-512、SHA-256、SHAKE128 などの承認済みの関数でハッシュし、署名はそのダイジェストに加えて、ハッシュ関数の OID と、通常とは異なるドメイン分離用のバイト(0 ではなく 1)を対象とします。これは独自の OID を持つ別個の署名方式です。HashML-DSA の署名は、同じメッセージに対する有効な ML-DSA 署名にはなりません。FIPS 204 には「通常(pure)版の ML-DSA のほうが望ましい」とあり、RFC 9881 は証明書での HashML-DSA の使用を認めていません。RFC 9964 も、JOSE や COSE 向けには HashML-DSA を定義していません。プロトコルで必須とされている場合にのみ使用してください。

非常に大きなメッセージには、3 つ目の方法として外部 μ があります。署名者は 64 バイトのメッセージ代表値 μ を署名モジュールの外で計算し、その値だけをモジュールに渡します。これは RFC 9881 に記載されています。Go 1.27(crypto.MLDSAMu)と .NET 10(SignMu)がこの方式を API として提供しています。生成されるのは通常の pure ML-DSA の署名なので、検証者側では特別な対応は必要ありません。

秘密鍵の形式:シードを保存する

ML-DSA の鍵ペアは、32 バイトのシード ξ から生成されます。2560〜4896 バイトの「展開済み」秘密鍵はこのシードから計算され、公開鍵も同様です。RFC 9881 は 3 種類の PKCS#8 エンコーディングを定義しています。

  • seed:32 バイトのシードのみで、[0] IMPLICIT OCTET STRING です。これが推奨される形式です。PKCS#8 としては 54 バイトになります。
  • expandedKey:展開済みの鍵のみです。
  • both:シードと展開済みの鍵を含むシーケンスです。

この RFC は、実装が「シードを保持してエクスポートする」ことを推奨しています。展開は元に戻せないため、シードなしで保存された鍵を小さな形式に戻すことは決してできません。ライブラリごとにデフォルトが異なり、それがインポートの失敗を招いています。OpenSSL 3.5 はデフォルトで both を書き出します。

openssl asn1parse:OpenSSL のデフォルトの ML-DSA-65 鍵、4098 バイト(16 進ダンプは短縮)
    0:d=0  hl=4 l=4094 cons: SEQUENCE
    4:d=1  hl=2 l=   1 prim: INTEGER           :00
    7:d=1  hl=2 l=  11 cons: SEQUENCE
    9:d=2  hl=2 l=   9 prim: OBJECT            :ML-DSA-65
   20:d=1  hl=4 l=4074 prim: OCTET STRING      [HEX DUMP]:30820FE60420A58A79B197C3...

Go 1.27 はこの形式を拒否し、どうすればよいかを教えてくれます。受け付けるのはシードのみです。

Go 1.27.3:OpenSSL の出力に x509.ParsePKCS8PrivateKey を適用
both.pem (4098 bytes): x509: ML-DSA private keys with both seed and expanded key are not supported, use e.g. "openssl pkey -provparam ml-dsa.output_formats=seed-only" to convert to a seed-only key
seed-only.pem (54 bytes): *mldsa.PrivateKey
expanded-only.pem (4060 bytes): x509: semi-expanded ML-DSA private keys without seed are not supported

今回のテストでは、Java 25(JEP 497)、.NET 10、Go のいずれも、54 バイトのシードのみの PKCS#8 鍵をエクスポートしました。鍵を作る段階でシード形式を選んでください。

OpenSSL 3.5:シードのみの秘密鍵を生成または変換
openssl genpkey -algorithm ML-DSA-65 -provparam ml-dsa.output_formats=seed-only -out key.pem
openssl pkey -in both.pem -provparam ml-dsa.output_formats=seed-only -out key.pem

HSM や KMS 製品でも同じ選択が必要です。長期間使う鍵を生成する前に、シードをエクスポートまたはバックアップできることを確認してください。展開済みの形式しかない鍵では、展開済みの鍵を受け付ける実装に縛られます。@noble/post-quantum がその好例です。その secretKey は展開済みのエンコーディング(ML-DSA-65 では 4032 バイト)で、keygen(seed) は 32 バイトのシードを受け取ります。シードは自分で保管してください。

鍵や証明書に実際に何が含まれているかを確認するには、ポスト量子証明書インスペクターで ML-DSA と SLH-DSA の鍵や証明書をデコードできます。また ASN.1 エクスプローラーでは、手元の秘密鍵が 3 つの形式のどれなのかを確認できます。

今すぐ動くコード

以下のスニペットはすべて、2026年10月20日に、それぞれの直前の段落に記載したコンテナで実行したものです。いずれも ML-DSA-65 で "hello" に署名し、その結果を検証します。ライブラリが対応している場合は、コンテキスト文字列も設定しています。何もインストールせずにこの方式を試すなら、ポスト量子署名ラボでブラウザー内で ML-DSA、SLH-DSA、Falcon による署名と検証ができます。

OpenSSL 3.5(node:24.22.0-trixie、OpenSSL 3.5.7):ML-DSA では -rawin が必要です。指定しないと、pkeyutl は事前に計算されたダイジェストが渡されるものと想定するためです。

OpenSSL 3.5:鍵、署名、検証
openssl genpkey -algorithm ML-DSA-65 -provparam ml-dsa.output_formats=seed-only -out key.pem
openssl pkey -in key.pem -pubout -out pub.pem
openssl pkeyutl -sign -rawin -inkey key.pem -in msg.txt -out sig.bin
openssl pkeyutl -verify -rawin -pubin -inkey pub.pem -in msg.txt -sigfile sig.bin
# Signature Verified Successfully

Go 1.27(golang:1.27.3-trixie):新しい crypto/mldsa パッケージを使います。crypto/x509 が鍵の解析とマーシャリングを行い、crypto/tls は TLS 1.3 で ML-DSA 証明書を受け付けます。パッケージのドキュメントには、FIPS 140-3 の Go 暗号モジュール v1.0.0 のもとでは利用できないという注意書きがあります。

Go 1.27:コンテキスト付きの crypto/mldsa
sk, err := mldsa.GenerateKey(mldsa.MLDSA65())
if err != nil {
	log.Fatal(err)
}
opts := &mldsa.Options{Context: "invoice-v1"}
sig, err := sk.Sign(nil, []byte("hello"), opts) // 3309 bytes; the io.Reader is ignored
if err != nil {
	log.Fatal(err)
}
fmt.Println(mldsa.Verify(sk.PublicKey(), []byte("hello"), sig, opts)) // <nil>
fmt.Println(mldsa.Verify(sk.PublicKey(), []byte("hello"), sig, nil))  // mldsa: invalid signature

決定的バリアントは sk.SignDeterministic で得られます。sk.Bytes() は 32 バイトのシードを返します。

Java、JDK 24 以降(eclipse-temurin:25.0.4.1_1-jdk-noble):JEP 497 で標準プロバイダーに ML-DSA が追加されました。この JEP ではコンテキスト文字列と HashML-DSA が明示的に対象外とされているため、JDK は空のコンテキストでしか署名しません。

Java 25:組み込みプロバイダーで ML-DSA を使用
KeyPair kp = KeyPairGenerator.getInstance("ML-DSA-65").generateKeyPair();
Signature signer = Signature.getInstance("ML-DSA");
signer.initSign(kp.getPrivate());
signer.update("hello".getBytes(StandardCharsets.UTF_8));
byte[] sig = signer.sign();                         // 3309 bytes
// kp.getPublic().getEncoded().length == 1974, kp.getPrivate().getEncoded().length == 54

BouncyCastle 1.86(同じ JDK イメージ):ContextParameterSpec でコンテキストに対応し、独自のアルゴリズム名で HashML-DSA にも対応しています。今回のテストで、HashML-DSA の署名が pure ML-DSA の検証に通らないことを確認しました。

BouncyCastle 1.86:コンテキスト文字列と HashML-DSA
Signature signer = Signature.getInstance("ML-DSA", "BC");
signer.initSign(kp.getPrivate());
signer.setParameter(new ContextParameterSpec("invoice-v1".getBytes(StandardCharsets.UTF_8)));
signer.update(msg);
byte[] sig = signer.sign();                         // verifies only with the same context

Signature pre = Signature.getInstance("HASH-ML-DSA", "BC");  // SHA-512 pre-hash
pre.initSign(kp.getPrivate());
pre.update(msg);
byte[] preSig = pre.sign();                         // "ML-DSA".verify(preSig) == false

.NET 10(mcr.microsoft.com/dotnet/sdk:10.0.402-alpine3.24):System.Security.Cryptography.MLDsa はプラットフォームの暗号ライブラリを使います。Linux では OpenSSL 3.5 以降、Windows では PQC に対応した CNG です。同じプログラムが、Ubuntu 24.04 の SDK イメージ(10.0.402-noble、OpenSSL 3.0.13)では MLDsa.IsSupported: False を、Alpine 3.24(OpenSSL 3.5.9)では True を出力しました。リリース前に IsSupported とベースイメージを確認してください。SDK 10.0.402 では、鍵の生成、署名、検証は問題なくコンパイルできますが、鍵のエクスポートメソッドは、診断を抑制しない限り、今も error SYSLIB5006: ... is for evaluation purposes only でビルドが失敗します。

.NET 10:コンテキスト付きの MLDsa
using MLDsa key = MLDsa.GenerateKey(MLDsaAlgorithm.MLDsa65);
byte[] data = Encoding.UTF8.GetBytes("hello");
byte[] context = Encoding.UTF8.GetBytes("invoice-v1");
byte[] signature = key.SignData(data, context);               // 3309 bytes
Console.WriteLine(key.VerifyData(data, signature, context));  // True
Console.WriteLine(key.VerifyData(data, signature));           // False

#pragma warning disable SYSLIB5006 // the key export methods are still [Experimental] in .NET 10
byte[] pkcs8 = key.ExportPkcs8PrivateKey();                   // 54 bytes: the seed form

JavaScript と TypeScript:ネイティブ対応が進みつつあります。Node.js 24.22 は、同梱の OpenSSL 3.5 を通じて crypto.generateKeyPairSync("ml-dsa-65") で鍵を生成し、crypto.sign(null, data, privateKey) で署名できます。その WebCrypto も、ExperimentalWarning を出しつつ { name: "ML-DSA-65" } を受け付けます。Chromium 156(Playwright 1.64.1 に含まれるビルド)は、HTTPS ページ上で crypto.subtle を通じて鍵と 3309 バイトの署名を生成しました。これに依存する前に、ほかのブラウザーエンジンの対応状況を確認してください。どこでも動く必要があるコードには、@noble/post-quantum 0.7.0 が使えます。Node.js でも任意のブラウザーでも動作する純粋な JS 実装です(node:24.22.0-trixie で実行)。

@noble/post-quantum 0.7.0
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
import { randomBytes } from "node:crypto";

const { secretKey, publicKey } = ml_dsa65.keygen(randomBytes(32)); // keep the 32-byte seed, not secretKey
const msg = new TextEncoder().encode("hello");
const context = new TextEncoder().encode("invoice-v1");
const sig = ml_dsa65.sign(msg, secretKey, { context });
ml_dsa65.verify(sig, msg, publicKey, { context });       // true
ml_dsa65.verify(sig, msg, publicKey);                    // false
ml_dsa65.sign(msg, secretKey, { extraEntropy: false });  // deterministic variant

ML-DSA 証明書と TLS

OpenSSL 3.5 では、ML-DSA 証明書を発行して TLS 1.3 で使うことが今すぐできます。このテストでは ML-DSA-65 のルート証明書とリーフ証明書を作成し、s_client で接続します。従来の署名アルゴリズムしか提示しないクライアントは拒否されます。

OpenSSL 3.5.7:ML-DSA-65 証明書による TLS 1.3
leaf certificate: 5514 bytes DER
Protocol version: TLSv1.3
Signature type: mldsa65
Verification: OK
Negotiated TLS1.3 group: X25519MLKEM768
--- a classic-only client
error:0A000410:SSL routines:ssl3_read_bytes:ssl/tls alert handshake failure

TLS のコードポイント(mldsa44 0x0904、mldsa65 0x0905、mldsa87 0x0906)は draft-ietf-tls-mldsa で定義されています。このドラフトは、2026年10月の時点で RFC Editor のキューに入っていました。Go 1.27 も同じ 3 つの方式に対応しています。

公開 Web では、これはまだ使えません。ブラウザーは、公開 CA が発行した ML-DSA 証明書を受け付けません。Firefox は、デフォルトが false の security.tls.enable_mldsa 設定を有効にしない限り受け付けません。Chrome は、ポスト量子の鍵を持つ X.509 証明書をルートストアに追加する当面の計画はないと 2026年2月に表明しました。代わりに取り組んでいるのが Merkle Tree Certificates で、これによりハンドシェイクのたびにキロバイト単位の署名を複数送らずに済みます。ML-DSA 証明書が現時点で現実的なのは、プライベート PKI です。つまり、サービスメッシュ、デバイスの ID、社内の mTLS、両端を自分で管理できるコード署名や文書署名です。

よくある間違い

  • 署名が毎回同じになると想定する:上記のどのライブラリでもヘッジ型署名がデフォルトです(OpenSSL、Java、noble では 2 回署名して確認しました)。そのため、同じ入力でも毎回異なるバイト列になります。署名のバイト列ではなく、検証結果を比較してください。
  • pure ML-DSA と HashML-DSA を混同する:両者は OID の異なる別々のアルゴリズムです。一方向けに設定した検証者はもう一方を拒否します。また FIPS 204 は、1 つの鍵ペアをどちらか一方だけに使うことを推奨しています。
  • 片側だけでコンテキストを設定する:この失敗は、無効な署名とまったく同じに見えます。プロトコルで使うコンテキストを、アルゴリズム名と並べてログに記録してください。
  • シードを捨てる:展開済みの鍵だけをエクスポートしても、Go などシードのみを受け付ける実装に読み込む必要が出てくるまでは問題ありません。変換できるうちに -provparam ml-dsa.output_formats=seed-only で変換しておいてください。
  • ランタイムが対応していると思い込む:.NET をはじめ、システムライブラリをラップする実装は、そのライブラリのバージョンを引き継ぎます。OpenSSL 3.5 を搭載した開発者のノート PC で動くアプリケーションでも、古いベースイメージで作られたコンテナでは失敗することがあります。
  • サイズの上限を忘れる:JWT ヘッダー、Cookie、DNS レコード、X.509 拡張、データベースのカラム、QR コード、HSM のバッファーなど、64 バイトの ECDSA 署名を前提にサイズが決められているものはすべて、3309 バイト以上を収められなければなりません。

出典

コマンドの出力は、2026年10月20日に node:24.22.0-trixie(OpenSSL 3.5.7、Node.js 24.22.0)、golang:1.27.3-trixie、eclipse-temurin:25.0.4.1_1-jdk-noble(bcprov-jdk18on 1.86 を使用)、mcr.microsoft.com/dotnet/sdk:10.0.402-alpine3.24、mcr.microsoft.com/dotnet/sdk:10.0.402-noble、mcr.microsoft.com/playwright:v1.64.1-noble(Chromium 156)で取得したものです。