go1.27rc2 で TLS サーバーを立て、go1.26.4 でビルドしたクライアントから繋いだら remote error: tls: handshake failure で落ちました。原因はサーバー証明書の署名アルゴリズム。Go 1.27 で追加された crypto/mldsa をポスト量子署名に切り替えた結果です。
鍵交換は Go 1.24 から、署名が Go 1.27 で揃った
Go 1.27 のリリースノートには “New crypto/mldsa package” というセクションが立っています。
The new crypto/mldsa package implements the post-quantum ML-DSA signature scheme specified in FIPS 204.
パッケージドキュメントの冒頭も同じ一文です。FIPS 204 が規定する ML-DSA、つまり格子ベースのデジタル署名。TLS 側にも MLDSA44 / MLDSA65 / MLDSA87 の SignatureScheme が追加され、crypto/x509 は ML-DSA の秘密鍵・公開鍵・署名を扱えるようになりました。
ML-KEM が守るもの、ML-DSA が守るもの
ポスト量子化は鍵交換から入りました。攻撃が成立する時期が違うからです。
- 鍵交換(ML-KEM): 今日の暗号文を保存しておき、量子計算機が実用化された将来に復号する HNDL(Harvest Now, Decrypt Later)が成立する。今日の通信が今日のうちに危険
- 署名(ML-DSA): 証明書が偽造されるのは量子計算機が動き出した後。危険になるのは将来の通信だけ
鍵交換の crypto/mlkem は Go 1.24 で公開され、TLS のデフォルト鍵交換に X25519MLKEM768 が入りました。署名はそこから3リリース遅れて到着。
パラメータセットは 44 / 65 / 87
ML-DSA は強度別に3種類。Go では mldsa.MLDSA44() のように関数で Parameters を取得します。定数ではありません。
署名して検証するまでのコード
package main
import (
"crypto/mldsa"
"crypto/rand"
"fmt"
)
func main() {
msg := []byte("hello")
for _, p := range []mldsa.Parameters{mldsa.MLDSA44(), mldsa.MLDSA65(), mldsa.MLDSA87()} {
sk, err := mldsa.GenerateKey(p)
if err != nil {
panic(err)
}
sig, err := sk.Sign(rand.Reader, msg, &mldsa.Options{})
if err != nil {
panic(err)
}
pk := sk.PublicKey()
if err := mldsa.Verify(pk, msg, sig, &mldsa.Options{}); err != nil {
panic(err)
}
fmt.Printf("%-9s seed=%4dB pub=%4dB sig=%4dB\n",
p.String(), len(sk.Bytes()), len(pk.Bytes()), len(sig))
}
}
go1.27rc2 での実行結果:
$ go run .
ML-DSA-44 seed= 32B pub=1312B sig=2420B
ML-DSA-65 seed= 32B pub=1952B sig=3309B
ML-DSA-87 seed= 32B pub=2592B sig=4627B
Sign はメソッド、Verify はパッケージ関数
*mldsa.PrivateKey は crypto.Signer を実装しています。Sign(io.Reader, []byte, crypto.SignerOpts) のシグネチャなので、x509.CreateCertificate にそのまま渡せます。一方の検証は mldsa.Verify(pk, message, signature, opts) というパッケージ関数。PublicKey にメソッドは生えていません。
第1引数の io.Reader は署名のランダム化に使われます。同じメッセージを2回署名すると別のバイト列が返る。決定的な出力が要るなら SignDeterministic を呼びます。
d1, _ := sk.SignDeterministic(msg, &mldsa.Options{})
d2, _ := sk.SignDeterministic(msg, &mldsa.Options{})
r1, _ := sk.Sign(rand.Reader, msg, &mldsa.Options{})
r2, _ := sk.Sign(rand.Reader, msg, &mldsa.Options{})
fmt.Printf("SignDeterministic 同一=%v / Sign 同一=%v\n",
bytes.Equal(d1, d2), bytes.Equal(r1, r2))
SignDeterministic 同一=true / Sign 同一=false
Options.Context で署名の用途を分ける
mldsa.Options のフィールドは Context string ひとつだけ。最大255バイトで、署名時と検証時で一致しないと検証が落ちます。同じ鍵を複数の用途に使い回すとき、用途をまたいだ署名の転用を止められる。
sigA, _ := sk.Sign(rand.Reader, msg, &mldsa.Options{Context: "payment-v1"})
fmt.Println(mldsa.Verify(sk.PublicKey(), msg, sigA, &mldsa.Options{Context: "payment-v1"}))
fmt.Println(mldsa.Verify(sk.PublicKey(), msg, sigA, &mldsa.Options{Context: "payment-v2"}))
<nil>
mldsa: invalid signature
JWT の alg を検証せずに通してしまう事故と同じ構図を、署名層で塞ぐ仕組み。関連してGoでJWT認証ミドルウェアを書く—golang-jwt v5とalg=none対策も書いています。
大きいのは公開鍵と署名だけ
ECDSA P-256 と並べた実測値。
| 項目 | ML-DSA-44 | ML-DSA-65 | ECDSA P-256 | 倍率(44/ECDSA) |
|---|---|---|---|---|
| 公開鍵 | 1312 B | 1952 B | 65 B | 20.2倍 |
| 署名 | 2420 B | 3309 B | 約71 B | 34.1倍 |
| 自己署名証明書(DER) | 3988 B | 5517 B | 392 B | 10.2倍 |
| 秘密鍵(PKCS#8 DER) | 54 B | 54 B | 138 B | 0.39倍 |
秘密鍵だけ逆転します。ML-DSA の秘密鍵は 32バイトのシードで、そこから展開する設計。PrivateKey.Bytes() が返すのもシードで、mldsa.NewPrivateKey(params, seed) で復元できます。PEM に落とすと ML-DSA-44 が 128 バイト、ECDSA P-256 が 241 バイト。鍵管理システムに保存するデータは減ります。
TLSハンドシェイクは 3387B から 9333B へ
証明書 DER の10倍が、そのままネットワークの10倍にはなりません。ハンドシェイク全体で測った実測値を出します。
net.Conn をラップしてバイト数を数える
type countConn struct {
net.Conn
read, written *int64
}
func (c countConn) Read(b []byte) (int, error) {
n, err := c.Conn.Read(b)
atomic.AddInt64(c.read, int64(n))
return n, err
}
func (c countConn) Write(b []byte) (int, error) {
n, err := c.Conn.Write(b)
atomic.AddInt64(c.written, int64(n))
return n, err
}
// クライアント側だけ包んで Handshake() までのバイト数を取る
raw, _ := net.Dial("tcp", ln.Addr().String())
cc := countConn{Conn: raw, read: &read, written: &written}
cl := tls.Client(cc, &tls.Config{
InsecureSkipVerify: true,
ServerName: "localhost",
MinVersion: tls.VersionTLS13,
})
cl.Handshake()
サーバー証明書の種類だけ変えた結果:
ML-DSA-44 handshake in= 7784B out=1549B total= 9333B curve=X25519MLKEM768 peerSig=ML-DSA-44
ML-DSA-65 handshake in=10202B out=1549B total=11751B curve=X25519MLKEM768 peerSig=ML-DSA-65
ECDSA-P256 handshake in= 1839B out=1549B total= 3387B curve=X25519MLKEM768 peerSig=ECDSA-SHA256
受信が 1839B から 7784B へ 5945B 増、往復合計で 2.75倍。増分はほぼ証明書と CertificateVerify の署名分です。送信側 1549B は変わりません。クライアントが送るのは鍵交換の共有鍵とハンドシェイクメッセージだけなので、署名アルゴリズムの影響を受けない。
中間CAを挟むと 13302B
実運用の証明書チェーンはリーフ1枚では終わりません。中間CA を1枚足して、root → intermediate → leaf の2段にした場合:
mldsa chain: leaf=3976B inter=3976B (sent=7952B)
mldsa 2-cert chain handshake in=11753B out=1549B total=13302B elapsed=973µs
ecdsa chain: leaf=380B inter=381B (sent=761B)
ecdsa 2-cert chain handshake in=2213B out=1549B total=3762B elapsed=701µs
13302B 対 3762B で 3.54倍。証明書が1枚増えるたびに ML-DSA-44 なら約4KBずつ積み上がります。TCP の初期輻輳ウィンドウは RFC 6928 で 10セグメント、MSS 1460 換算で約 14600 バイト。ML-DSA-44 の2段チェーンで 13302B なので、中間CA をもう1枚足した時点で初期ウィンドウを超えて RTT が1往復増えます。
鍵交換だけで 2312B 使っている
ハンドシェイクの肥大は署名だけが原因ではありません。GODEBUG=tlsmlkem=0 で鍵交換を古典に戻して比較します。
$ GODEBUG=tlsmlkem=0 go run .
ML-DSA-44 handshake in= 6696B out= 323B total= 7019B curve=X25519
ECDSA-P256 handshake in= 752B out= 323B total= 1075B curve=X25519
ECDSA + X25519 の組み合わせは 1075B。デフォルト設定の 3387B との差 2312B が ML-KEM のカプセル化鍵と暗号文です。クライアント送信量も 1549B から 323B に落ちる。
Go 1.24 以降でビルドしたサーバーは、何も設定しなくても既にハンドシェイクが 3.1倍に膨らんでいる状態。そこへ ML-DSA を足すと 1075B → 9333B で 8.7倍になります。
セッション再開なら差が消える
膨らむのは初回接続だけです。TLS 1.3 のセッションチケットで再開すると証明書もCertificateVerifyも飛びません。ClientSessionCache を持たせて同じサーバーに2回繋ぎ、2バイトのアプリケーションデータを往復させた実測:
cache := tls.NewLRUClientSessionCache(4)
cl := tls.Client(cc, &tls.Config{
InsecureSkipVerify: true,
ServerName: "localhost",
MinVersion: tls.VersionTLS13,
ClientSessionCache: cache, // これが無いと毎回フルハンドシェイク
})
ML-DSA-44 初回= 9559B (resumed=false) 再接続=3240B (resumed=true)
ECDSA-P256 初回= 3612B (resumed=false) 再接続=3240B (resumed=true)
再接続はどちらも 3240B で同一。ML-DSA のコストは初回の1回に閉じます。コネクションを使い回す常駐サービスなら影響は初回接続分だけ。増分がそのまま乗るのは、接続を都度張り直す構成です。http.Client をリクエストごとに生成しているコードは、リクエスト回数だけ 5945B を払い続けます。
Go 1.26 のクライアントは接続できない
冒頭の handshake failure はここで起きます。同じサーバーに go1.27rc2 と go1.26.4 のクライアントバイナリで接続した結果:
=== server cert: ML-DSA-44 ===
[go1.27rc2] ok curve=X25519MLKEM768 serverCertSig=ML-DSA-44
[go1.26.4] handshake failed: remote error: tls: handshake failure
--- server stderr ---
server handshake error: tls: peer doesn't support any of the certificate's signature algorithms
=== server cert: ECDSA-P256 ===
[go1.27rc2] ok curve=X25519MLKEM768 serverCertSig=ECDSA-SHA256
[go1.26.4] ok curve=X25519MLKEM768 serverCertSig=ECDSA-SHA256
エラーはサーバー側に出る
クライアントが受け取るのは remote error: tls: handshake failure だけ。何が原因かは書いてありません。サーバー側の tls: peer doesn't support any of the certificate's signature algorithms を見て初めて分かる。クライアントが ClientHello の signature_algorithms 拡張に MLDSA44 を載せないため、サーバーが提示できる証明書が無くなった状態です。
エラーの出る場所が運用で問題になります。ロードバランサの証明書を差し替えると、古い Go でビルドされたバッチやマイクロサービスの接続が一斉に失敗し、原因はサーバーログにしか出ません。
鍵交換とは互換性の性質が違う
鍵交換のポスト量子化はネゴシエーションで安全に縮退します。go1.26.4 クライアントも curve=X25519MLKEM768 で繋がっている通り、対応していない側がいれば X25519 に落ちるだけ。
署名は落ちません。サーバーが提示できる証明書はビルド時に決まっていて、クライアントが理解できなければ接続そのものが失敗する。ML-DSA 証明書1枚だけへの切り替えは、全クライアントが Go 1.27 以降だと確認できるまで打てない手です。
デュアル証明書なら混在環境でも通る
tls.Config.Certificates はスライスです。ML-DSA と ECDSA を両方入れておくと、クライアントが提示した signature_algorithms に合う方をサーバーが選びます。
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
// 並び順が優先度。先頭を ML-DSA にすると対応クライアントには ML-DSA を返す
Certificates: []tls.Certificate{mldsaCert, ecdsaCert},
MinVersion: tls.VersionTLS13,
})
=== Certificates: {ML-DSA-44, ECDSA-P256} ===
[go1.27rc2] ok curve=X25519MLKEM768 serverCertSig=ML-DSA-44
[go1.26.4] ok curve=X25519MLKEM768 serverCertSig=ECDSA-SHA256
=== Certificates: {ECDSA-P256, ML-DSA-44} ===
[go1.27rc2] ok curve=X25519MLKEM768 serverCertSig=ECDSA-SHA256
[go1.26.4] ok curve=X25519MLKEM768 serverCertSig=ECDSA-SHA256
並び順を入れ替えると go1.27rc2 クライアントにも ECDSA が返ります。スライスの先頭が優先。混在環境ではまず ECDSA を先頭にしたデュアル構成で証明書配布だけ済ませ、クライアントの Go バージョンが揃ってから並び順を入れ替える、という段階移行が取れます。ハンドシェイクの増加も対応クライアントに限定されます。
署名は12倍遅い、検証は1.45倍
b.Loop で計測した結果(Apple M5 Pro, darwin/arm64, go1.27rc2):
BenchmarkMLDSA44Sign-18 6997 167147 ns/op 2688 B/op 1 allocs/op
BenchmarkMLDSA44Verify-18 26319 45566 ns/op 0 B/op 0 allocs/op
BenchmarkMLDSA44KeyGen-18 18897 63408 ns/op 196608 B/op 2 allocs/op
BenchmarkECDSAP256Sign-18 87949 13649 ns/op 6032 B/op 58 allocs/op
BenchmarkECDSAP256Verify-18 38199 31411 ns/op 544 B/op 9 allocs/op
BenchmarkECDSAP256KeyGen-18 188754 6447 ns/op 984 B/op 16 allocs/op
署名は 13649ns → 167147ns で 12.2倍。ところが検証は 31411ns → 45566ns の 1.45倍にとどまります。アロケーションは検証が 0 allocs/op、ECDSA の 9 allocs/op より少ない。
TLS で署名を作るのはサーバー側で接続ごとに1回、検証はクライアント側でチェーンの枚数分。この配分だと CPU はボトルネックになりません。効いてくるのはバイト数のほうです。
CurvePreferences で鍵交換を選ぶ
Go 1.27 では MLKEM1024 が tls.CurveID に加わりました。ただしデフォルトの候補セットには入っていません。Config.CurvePreferences に明示指定した状態で試すと、クライアントだけ指定しても繋がらない。
default (CurvePreferences nil) curve=X25519MLKEM768
MLKEM1024 (client only) FAILED: remote error: tls: handshake failure
MLKEM1024 (both sides) curve=MLKEM1024
X25519 only curve=X25519
SecP256r1MLKEM768 curve=SecP256r1MLKEM768
リリースノートには tlsmlkem=0 の適用範囲についてこう書かれています。
Post-quantum hybrid key exchanges can now be explicitly enabled in Config.CurvePreferences even if the tlsmlkem=0 or tlssecpmlkem=0 GODEBUG options are used. Those options were always meant to only apply to the default set used when Config.CurvePreferences is nil.
実際 GODEBUG=tlsmlkem=0 を付けても、両側で MLKEM1024 を明示すれば curve=MLKEM1024 で確立します。GODEBUG が効くのは CurvePreferences が nil のときのデフォルトセットだけ。
まとめ
crypto/mldsaは FIPS 204 の ML-DSA 実装。GenerateKey/Sign/mldsa.Verifyの3つで完結し、crypto.Signerなのでx509.CreateCertificateにそのまま渡せる- ML-DSA-44 は公開鍵 1312B、署名 2420B。証明書 DER は ECDSA P-256 の 392B に対して 3988B
- 秘密鍵は 32B のシードのみ。PKCS#8 で 54B と ECDSA の 138B より小さい
- TLS ハンドシェイクは ECDSA の 3387B に対し ML-DSA-44 で 9333B。中間CA を1枚挟むと 13302B
- 署名は ECDSA の 12.2倍遅いが検証は 1.45倍。制約になるのは CPU ではなくバイト数
- セッション再開時は証明書が飛ばないため 3240B で同一。コストは初回接続に閉じる
- Go 1.26 以前のクライアントは ML-DSA 証明書のサーバーに接続できず、エラー内容はサーバー側にしか出ない
Certificatesに ML-DSA と ECDSA を並べたデュアル構成なら混在環境でも通る。スライスの先頭が優先される- 鍵交換の X25519MLKEM768 は Go 1.24 からデフォルト有効。
GODEBUG=tlsmlkem=0で古典に戻せる
Go 1.27 は 2026年8月時点で go1.27rc2 が公開されている段階で、安定版は go1.26.5。crypto/mldsa の本番投入はリリース後になりますが、ハンドシェイクが何バイト増えるかは今の rc で測れます。切り替え前に確認すべきは、自分のチェーンが何枚で、初回ハンドシェイクが何バイトになるか。
