要約

  • TLS 1.3のCertificateVerifyは証明書秘密鍵で途中までのtranscriptに署名する。Finishedは送信側handshake traffic secretから得た鍵で、さらに進んだtranscriptをMACする独立した証明である。
  • 完了は方向ごとの状態である。サーバーFinishedが正しくても、クライアントFinished、0-RTT受理、プロキシ上流、アプリ権限、永続処理の成功までは証明しない。

成功通知より後に失敗が届いた

証明書チェーンはサービスの検証規則を満たし、CertificateVerifyの署名も正しかった。ここまでを受けたアダプターはhandshake_authenticated=trueを発行した。

しかしクライアントが次の暗号化握手メッセージを処理すると、Finished.verify_dataはローカル計算と一致しなかった。RFC 9846が求める結果は致命的なdecrypt_errorである。通常のアプリ通信へ進む余地はなかった。

失敗したのは証明書ではない。証明書鍵の所持という正しい主張が、現在の握手秘密を共有し同じ履歴を見ているという別の主張へ、無断で拡張されていた。

CertificateVerifyとFinishedは役割が違う

CertificateVerifyは、役割を区別する固定文字列とCertificateまでのtranscript hashを含む値に署名する。提示した資格情報に対応する秘密鍵を送信者が持つことを明示的に示す。証明書パスやサービス名の受入れは別判断であり、署名が正しくても証明書ポリシーは拒否できる。

Finishedは証明書署名ではない。送信側のhandshake traffic secretからfinished_keyを導き、存在する場合はCertificateVerifyまで含むtranscript hashへHMACを計算する。長期鍵ではなく、この接続の鍵材料と履歴への合意を確認する。

PSKだけの握手ではCertificateもCertificateVerifyも送られない。それでも双方のFinishedは必須である。逆にCertificateVerifyだけ正しく、その後のFinishedが壊れていれば、結果は部分成功ではなく握手失敗だ。

Transcriptはレコード列そのものではない

transcript hashには、各握手メッセージの種類と長さを含め、送信順に論理メッセージが入る。TLSレコードヘッダー、alert、アプリデータは入らない。レコードによる分割や結合は、論理的な履歴を変えない。

HelloRetryRequestでは最初のClientHelloを合成message_hashで置き換える特則がある。可視レコードの中身を単純連結しても、Finishedの入力は再現できない。TLS 1.3の後続握手は暗号化されるため、精密な検証には端点由来の診断用秘密、または実装内の同等証拠が要る。

暗号スイートがtranscriptとHKDFで用いるハッシュを選ぶ。TLS 1.3のverify_data長はそのハッシュ出力長で、TLS 1.2の固定12オクテットではない。旧世代の長さを検査規則へ残せば、正常接続を異常として数える。

どちら側が終わったのかを残す

クライアントとサーバーには別々のhandshake traffic secretがあり、それぞれ異なるFinishedを作る。送信時刻と対向値の検証時刻も一致しない。

サーバーは自分のFinished送信後、送信方向をapplication traffic keyへ切り替えられる。クライアントFinishedを受ける前にアプリデータを送ることも許されるが、その時点ではクライアントの身元も生存性も保証されない。ClientHelloが再送された可能性が残るためだ。

クライアントはサーバーFinishedを検証し、要求されていれば自分のCertificateとCertificateVerifyを送り、最後に自分のFinishedを送る。一個のtls_completeでは、誰の値が送られ、誰の値が検証され、クライアント認証が残っているかを表せない。

共通状態は小さく保てる。端点と方向ごとにfinished_sentpeer_finished_verified、実装状態機械の完了時刻を持つ。特定操作に必要な組合せはアプリが明示する。

0-RTTは例外であって先取り証明ではない

クライアントはサーバーFinishedを受ける前に0-RTTを送れる。以前のPSKに基づく弱い性質の通信で、接続をまたぐ再送可能性がある。早期データが見えたことは、受理も現在の握手完了も意味しない。

記録すべき状態は、早期データの提示・受理・拒否、サーバーFinishedの検証、通常データ開始である。「暗号化要求を受信」へまとめると、再送可能な要求に新しい完全握手の権限を与えてしまう。

EAP-TLSのような外側プロトコルには、さらに独自の接続済み状態がある。内側の署名一件が正しいからといって、外側状態をFinishedより先へ進めてはならない。

TLS終端ごとに履歴は切れる

クライアントとエッジプロキシが共有するtranscriptとFinishedは、プロキシとオリジンのものとは別物だ。両方がTLS 1.3でも、乱数、選択パラメータ、秘密、資格情報、完了時刻は一致しない。

下流のpeer_finished_verified=trueを上流要求へコピーすれば、存在しない暗号的連続性を作る。オリジンは上流握手自身を検証し、下流身份が必要なら、プロキシは別の認証済みアプリ層情報を渡す必要がある。

再試行や接続プールでも、要求が別接続へ移ればFinished状態は継承できない。相関IDは移動を説明できるが、二つの履歴を一つにはしない。

API名ではなく状態を検証する

OpenSSLのSSL_is_init_finished()は、完全保護されたアプリデータを扱える状態を返す。ただし早期データでは単純な開始前・実行中・完了の分類が成立しない。SSL_get_verify_result()は証明書検証を返すAPIであり、Finishedやアプリ権限の結果ではない。

key-log callbackは隔離環境での復号やtranscript再構成に役立つが、出力は秘密鍵材料である。一般監視のため本番全体で有効化するのは、新しい漏えい面を作る。通常は状態遷移、交渉識別子、エラー種別、限定相関を残し、秘密は期限付き試験に限るべきだ。

ライブラリ間で同名関数も同じ証拠を返すとは限らない。BoringSSLの現行公開ヘッダーはTLS 1.3でFinished accessorがゼロを返すと記す。GnuTLSは握手hookとFinished専用エラーを持ち、完了前の入力は中間者不在を保証できないと警告する。まず必要なプロトコル状態を定義し、各実装へ別々に対応付ける必要がある。

失敗も保存する台帳

接続記録には、役割、方向、TLS版、暗号スイート、transcript hash、認証方式、証明書ポリシー、CertificateVerify、双方のFinished、0-RTT処理、TLS終端の接続区間を分けて残す。

CertificateVerify後のdecrypt_errorは「証明書成功」に吸収すべき雑音ではない。後段の境界が接続を拒否した証拠そのものである。

適合試験では一度に一条件だけ変える。transcriptまたはFinishedを一バイト変えて致命的失敗を確認する。証明書と署名を正しいままにする。クライアントFinishedを送らない。証明書のないPSK再開を通す。HelloRetryRequestを発生させる。上流条件が違うプロキシでも繰り返す。

この結果だけが実行中の境界を証明する。RFC、API記号、設定済みcallbackは、能力が存在することまでしか示さない。