Go 1.27のポスト量子署名ML-DSA—TLSハンドシェイクが2.75倍に

Go 1.27のポスト量子署名ML-DSA—TLSハンドシェイクが2.75倍に | mohablog

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 / MLDSA87SignatureScheme が追加され、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.PrivateKeycrypto.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-44ML-DSA-65ECDSA P-256倍率(44/ECDSA)
公開鍵1312 B1952 B65 B20.2倍
署名2420 B3309 B約71 B34.1倍
自己署名証明書(DER)3988 B5517 B392 B10.2倍
秘密鍵(PKCS#8 DER)54 B54 B138 B0.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 では MLKEM1024tls.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 で測れます。切り替え前に確認すべきは、自分のチェーンが何枚で、初回ハンドシェイクが何バイトになるか。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次