QUICConfigにClientHelloInfoConnフィールドを追加し、QUICスタックがハンドシェイク中のClientHelloInfo.Connへ独自のnet.Connを注入できるようにした。
crypto/tls
概要
crypto/tlsのQUICConfigに新しいフィールドClientHelloInfoConn(型net.Conn)が追加された。QUICサーバーのハンドシェイク中、GetCertificateやGetConfigForClientなどのコールバックに渡されるtls.ClientHelloInfoのConnフィールドへ、QUICスタック側が用意したnet.Connをそのまま設定できるようになる。
導入経緯
crypto/tlsのGetCertificate/GetConfigForClientコールバックが受け取るtls.ClientHelloInfoにはConn net.Connフィールドがあるが、QUICはUDPを使うためTCP的なRead/Write/Closeは意味をなさず、実質有用なのはLocalAddr/RemoteAddrのみだった。これまでquic-goなどのQUICスタックは、アドレスのみを返す偽のnet.Connをコールバックへ注入するためにtls.ConfigをClone()してコールバックをラップするワークアラウンドを取っていたが、Go 1.25.6のセキュリティ修正(#77113、CVE-2025-68121)でConfig.Clone()が自動生成セッションチケットキーをコピーしなくなったため、このクローンによってセッション再開(Session Resumption)が壊れるようになった。このissueは、クローンを不要にするためQUICConfigにコネクション情報を直接渡す口を追加する提案として立てられた。
議論タイムライン
- 2026-03-04: 週次Proposalレビュー会議でactiveカラムに追加され、正式レビュー対象に
- 2026-03-26: 具体的なAPI案(
QUICConfig.ClientHelloInfoConn net.Conn)が支持され、likely acceptに - 2026-04-08: 反対意見が追加されず、コンセンサスに変化なしとしてacceptedに確定
議論のハイライト
- 提案者は当初、
net.Connを丸ごと受け取るHandshakeConn net.Conn案(Option 1)と、LocalAddr/RemoteAddr net.Addrを個別に受け取る案(Option 2)の2案を提示していた。 - レビューの中で、Option 1の方向性を採りつつフィールド名を用途がより明確な
ClientHelloInfoConnにする案が出され、この名前が採用された。 net.Connをそのまま受け取る設計にしたのは、将来QUICレイヤーが偽のConnに追加のメタデータを持たせたくなった場合でも、APIを再度変更せずに済むという拡張性への配慮による。- 提案者は実装CL(CL 745720)をquic-go側(quic-go/quic-go#5571)で検証し、既存のテストスイートが通ることを確認した上でproposalを提出した。
- ハンドシェイク中はRFC 9000のコネクションマイグレーションが発生しない(5タプルが固定されている)ため、この変更がマイグレーションと衝突する懸念はないことが確認された。
使用例
Before
package main
import (
"crypto/tls"
"net"
)
type fakeConn struct {
net.Conn
local, remote net.Addr
}
func (c *fakeConn) LocalAddr() net.Addr { return c.local }
func (c *fakeConn) RemoteAddr() net.Addr { return c.remote }
// buildQUICConfig は、quic-go 等のQUICスタックがこれまで行っていたワークアラウンドを示す。
// tls.Config を Clone() し、GetCertificate をラップして偽の net.Conn を注入している。
func buildQUICConfig(base *tls.Config, local, remote net.Addr) *tls.QUICConfig {
cloned := base.Clone() // Go 1.25.6以降、自動生成セッションチケットキーがコピーされずセッション再開が壊れる
orig := cloned.GetCertificate
cloned.GetCertificate = func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
info.Conn = &fakeConn{local: local, remote: remote}
if orig != nil {
return orig(info)
}
return nil, nil
}
return &tls.QUICConfig{TLSConfig: cloned}
}
func main() {
base := &tls.Config{}
local := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 4433}
remote := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 5000}
_ = buildQUICConfig(base, local, remote)
}
After
package main
import (
"crypto/tls"
"net"
)
type fakeConn struct {
net.Conn
local, remote net.Addr
}
func (c *fakeConn) LocalAddr() net.Addr { return c.local }
func (c *fakeConn) RemoteAddr() net.Addr { return c.remote }
// buildQUICConfig は QUICConfig.ClientHelloInfoConn を使い、
// tls.Config のクローンなしにアドレス情報を渡す。
func buildQUICConfig(base *tls.Config, local, remote net.Addr) *tls.QUICConfig {
return &tls.QUICConfig{
TLSConfig: base,
ClientHelloInfoConn: &fakeConn{local: local, remote: remote},
}
}
func main() {
base := &tls.Config{}
local := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 4433}
remote := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 5000}
_ = buildQUICConfig(base, local, remote)
}
移行時の注意
既存の「tls.Configをクローンしてコールバックをラップし、偽のnet.Connを注入する」実装をしているQUICスタックは、QUICConfig.ClientHelloInfoConnに同等の偽net.Connを設定するよう置き換えることで、クローンとコールバックのラップを両方取り除ける。特にGo 1.25.6以降でセッション再開が壊れている場合、この移行によって解消される。
実装解説
QUICConfigへのClientHelloInfoConn net.Connフィールドの追加自体はquic.goへの1行の追加で、newQUICConnがこの値を内部のquicState.clientHelloInfoConnへコピーする(quic.go:203)。
実際にClientHelloInfo.Connを組み立てているのはhandshake_server.goのclientHelloInfo関数で、c.quic != nil(QUICコネクション)の場合はc.connの代わりにc.quic.clientHelloInfoConnを使うよう分岐している(handshake_server.go:1031)。QUICClient/QUICServerは内部でClient(nil, ...)/Server(nil, ...)を呼び出しており、QUICコネクションのc.connは常にnilである。つまり従来はQUIC接続でClientHelloInfo.Connが常にnilだったところ、今回の変更でQUICスタックが指定したnet.Connに差し替えられるようになった。