`crypto/tls` の `ConnectionState` に `LocalCertificate` フィールドを追加し、ハンドシェイクで自分が相手に提示した証明書チェーンを取得できるようにした
crypto/tls
概要
crypto/tls パッケージの ConnectionState 構造体に LocalCertificate フィールドが追加された。これはTLSハンドシェイク中に自分自身が接続相手へ提示した証明書チェーン(DERエンコード済みの[][]byte)を保持する。これまでConnectionStateからは相手側の証明書(PeerCertificates)は取得できたが、自分が実際にどの証明書を提示したかを知る標準的な手段がなく、GetCertificate/GetClientCertificateコールバック内で自前に保存するなどのワークアラウンドが必要だった。LocalCertificateはセッション再開(DidResumeがtrue)の接続では設定されない。
導入経緯
このproposalは2018年4月に提出され、当初は@FiloSottileや@rscらから、TLS 1.3では再ネゴシエーションやクライアント証明書要求が複数回発生し得ることなどを踏まえ、説得力のあるユースケースの提示が求められ長らく保留されていた。その後gRPC-Goチームがchannelzによる接続状態の可視化や、プロキシレスサービスメッシュ、Envoyの外部認可(ext_authz)連携のためにローカル証明書情報が必要であると継続的に要望し、2026年1月28日にproposalのactiveカラム入りとなった。2026年3月11日の週次proposal reviewミーティングで likely accept と判定され、1週間の意見募集期間を経て反対意見がなかったことから、2026年3月18日に正式に accepted となった。
議論タイムライン
- 2018-04: proposal提出。
LocalCertificate *Certificate案が最初に議論される。 - 2026-01-28: proposal-statusのactiveカラムに追加され、週次proposal reviewミーティングでのレビュー対象となる。
- 2026-03-11: 週次proposal reviewミーティングで likely accept と判定。
- 2026-03-18: 反対意見なく accepted。実装作業へ移行。
議論のハイライト
- gRPC-Goチームが2018年から2025年にかけて繰り返し要望を出し、channelzによる接続診断、プロキシレスサービスメッシュ、Envoyの外部認可(ext_authz)連携という具体的なユースケースを示したことが、長期間保留されていたproposalを前進させる決め手になった。
- 正式化された当初案は
LocalCertificate *Certificate(単一のtls.Certificateへのポインタ)だったが、実装段階で@FiloSottileが「ConnectionStateは概ねpublicな構造体であり、ログへ出力されることも珍しくないため、そこにCertificate.PrivateKeyを含む*Certificateを載せるのは危険」と指摘し、最終的に生の証明書チェーンのみを持つLocalCertificate [][]byteへ設計変更された。 - セッション再開時の扱いも論点になった。セッションチケットのサイズを増やしたくないという理由から、
DidResume == trueの接続ではLocalCertificateをnilのままにする(証明書を再送しない)方針で合意した。将来的にこの情報が必要になった場合は、Configにセッションチケットへ含めるかどうかを指定するオプションを追加する形での対応が検討されている。 - リリース後に Issue #79967 で問題が報告されたが、修正が小規模だったため revert して1.28へ先送りするのではなく fix forward する判断がなされた。
使用例
Before
package main
import (
"crypto/tls"
"fmt"
)
var lastCert *tls.Certificate
// GetCertificate コールバックで選択結果を自前で保持するワークアラウンド。
// NameToCertificate 等によるフォールバック選択までは捕捉できない。
func getCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
cert := &tls.Certificate{}
lastCert = cert
return cert, nil
}
func main() {
config := &tls.Config{
GetCertificate: getCertificate,
}
_ = config
fmt.Println("workaround: capture the certificate via GetCertificate")
}
After
package main
import (
"crypto/tls"
"fmt"
)
func printLocalCertificate(state tls.ConnectionState) {
if state.LocalCertificate == nil {
fmt.Println("no local certificate presented (e.g. resumed connection)")
return
}
fmt.Printf("local certificate chain: %d entries\n", len(state.LocalCertificate))
}
func main() {
var conn *tls.Conn
_ = conn
// ハンドシェイク後に conn.ConnectionState() を渡して呼び出す。
fmt.Println("call printLocalCertificate(conn.ConnectionState()) after the handshake")
}
移行時の注意
LocalCertificateは*x509.Certificateではなく[][]byte(DERエンコードされた証明書チェーン)であり、PeerCertificates ([]*x509.Certificate)とは型が非対称になっている。証明書の有効期限やSANを見たい場合はx509.ParseCertificateで自前にパースする必要がある。- セッション再開(
DidResume == true)された接続ではnilのままになる。監視や統計収集の目的で利用する場合は、再開接続を除外するか、初回接続時点の値を別途保持しておく必要がある。 - クライアント側でも、サーバーからクライアント証明書を要求されず何も送らなかった場合は
nilのままとなる(相互TLSでない通常の接続では設定されない)。
実装解説
ConnectionState.LocalCertificateはcommon.goで公開フィールドとして定義され、内部的にはConnが保持する非公開フィールドlocalCertificateに一度書き込まれたうえで、ConnectionState()呼び出し時にconn.goでコピーされる構造になっている。
localCertificateへの代入はTLSのバージョン・役割ごとに4箇所存在する。
- TLS 1.2 サーバー:
handshake_server.goでは、証明書選択時ではなく実際にcertificateMsgを書き込む直前に代入している。これはセッション再開時など証明書が実際には送信されない経路で誤って値が設定されないようにするための意図的な設計である。 - TLS 1.3 サーバー:
handshake_server_tls13.goではgetCertificateが返した証明書がnilでない場合のみ代入する。 - TLS 1.2 クライアント:
handshake_client.goでは、サーバーからCertificateRequestを受け取りgetClientCertificateが非nilの証明書を返した場合のみ代入する。 - TLS 1.3 クライアント:
handshake_client_tls13.goも同様に、クライアント証明書を実際に送る場合のみ代入する。
いずれの経路も「証明書を実際にピアへ送信したタイミングでのみ設定する」という共通の設計方針に従っており、これによってDidResume == trueの再開接続や、クライアント証明書を送らなかった通常のクライアント接続ではLocalCertificateが自然にnilのまま保たれる。