暗号化通信で使用される暗号化パラメータは、TLSのハンドシェイクプロトコルのやりとりによって決定します。 ハンドシェイクプロトコルで通信相手はTLSのバージョンと暗号化アルゴリズムを選択し、必要に応じてお互いを認証し、共有鍵の導出に必要な値を交換します。 ハンドシェイクが完了すると、通信相手は導出した共有鍵を使って、アプリケーション層の通信を保護します。
ハンドシェイク失敗時やその他のプロトコルエラーが発生した場合は、接続を終了し、アラートプロトコルにしたがってAlertメッセージを暗号化して通信相手に送信します。 レコードプロトコルでアプリケーションデータの送受信が終了する時も、アラートプロトコルにしたがって、警告 (Alert) メッセージの「close_notify」を暗号化して通信相手に送信します。 正常終了時にも警告メッセージを送るのは安全な通信が終了する前に攻撃者によってレコードの配送が妨害されてしまう強制遮断攻撃を検知できるようにするためです。
TLSプロトコルでメッセージはハンドシェイク、アプリケーションデータの送受信、Alertの送受信の順に進んでいきます。
- ハンドシェイク (Handshake) : 暗号スイートと共通鍵のマスターシークレット (Master Secret) を決定・導出します。また、証明書を使った通信相手の認証も行います。
- アプリケーションデータ (Application Data) : ハンドシェイクで導出した鍵を用いて共通鍵暗号で暗号化します。暗号化にはAEADを用いるため、暗号文に対する改ざんを検知することができます。
- 警告 (Alert) : 通信正常終了時には close_notify 警告を暗号化して送信します。通信異常終了時には対応するエラーメッセージを暗号化して送信します。 強制遮断攻撃を検知できるようにするために正常終了時にも警告を送信します。
フルハンドシェイク
TLSの接続はハンドシェイクから始まります。 クライアントがそれ以前にサーバと通信を確立したことがない場合、両者はTLSセッション (TLS session) を確立するためにフルハンドシェイク (full handshake) を行います。 フルハンドシェイクで行うことは主に次の4つです。
- クライアントが接続で使いたいパラメータの一覧を提示し、サーバは使うパラメータを選択する
- 提示された証明書を使って認証を行う
- TLSセッションで流れるレコードを暗号化するためのハンドシェイクシークレット (Handshake secret) とマスターシークレット (Master secret) を共有する
- ハンドシェイクでやりとりしたメッセージ全体に対して改ざんされていないか検証する
ステップ3について、共有鍵を作るために通信相手に公開する値(キーマテリアル : Key Material)を交換して、DHEやECDHEでマスターシークレットを共有することを鍵共有 (Key Exchange, KEX) と呼びます。
TLS 1.3のフルハンドシェイクの流れは次の図になります。図中の鍵交換、サーバパラメータの送信、認証のやりとりはハンドシェイクプロトコルの処理で、通信確立後の暗号化通信がレコードプロトコルの処理です。
「+」は重要なTLS拡張、「*」は省略可能なメッセージ、「{ }」は鍵交換から導出したハンドシェイク用の共有鍵で暗号化されたメッセージ、「[ ]」は鍵交換から導出したレコードプロトコル用の共有鍵で暗号化されたメッセージを表します。
ハンドシェイクでは鍵交換とサーバーパラメータの送信と認証を行います。 鍵交換ではお互いに共有鍵を作るための素材(keying material)を送受信してハンドシェイクシークレットとマスターシークレットを導出できるようにします。
まず、ハンドシェイクはクライアントからClientHelloを送信するところから始まります。 クライアントは、プロトコルのバージョン (ClientHello.version)、ランダムなナンス (ClientHello.random)、使用可能な暗号スイートの一覧 (ClientHello.cipher_suites)、TLS拡張の一覧 (ClientHello.extensions)を格納して送信します。 TLS 1.3ではClientHello.versionは常に0x0303で、「supported_versions」拡張の中に0x0304を含めることでTLS 1.3に対応していることを表します。
サーバはClientHelloを受信すると、暗号スイートの一覧から適切なものを1つ選びます。そしてServerHelloで応答します。 鍵交換のアルゴリズムは「supported_group」拡張の中から選ばれ、DHEやECDHEを使う場合は、「key_share」拡張で共有鍵を作るための素材を交換します。 この時点でClientHelloとServerHelloから共有鍵が導出されます。 以降のメッセージは共有鍵から導出したハンドシェイクシークレットで暗号化されます。
サーバは続けて2つのメッセージを送信してサーバパラメータを確立します。 1つ目はEncryptedExtensionで、名前の通り暗号化された拡張です。主にClientHelloの暗号化パラメータに関係しない拡張に応答するために使います。 2つ目はCertificateRequestです。クライアント認証が必要な際に送信されます。HTTP over SSL/TLS (HTTPS) のように、クライアント認証を使わない場合はこのメッセージは省略されます。
サーバとクライアントは最後に認証メッセージを交換します。 Certificateは証明書を送信するためのメッセージです。 サーバ側は証明書で認証しない場合は省略され、クライアントはCertificateRequestを受信しなかった場合は省略します。 主にサーバからクライアントへX.509証明書チェーンを送信するために使います。 証明書チェーンは、主にASN.1のDERでエンコードされたX.509証明書を順番に並べたものです。 CertificateVerifyは証明書の公開鍵に対応する秘密鍵を使って証明書を含むハンドシェイク全体の通信に対する署名を送信するためのメッセージです。 Finishedはハンドシェイクが完了したことを伝えるためのメッセージです。 Finished.verify_data フィールドには、クライアントとサーバが送受信した一連のハンドシェイクのハッシュ値を鍵導出関数のHKDFに通した結果を格納します。 Finishedを受信した側はハンドシェイク全体が改ざんされていないことを確認したら、ハンドシェイクは完了し、クライアントとサーバは認証された暗号化通信によって、アプリケーション層のデータを安全に送受信することができます。
鍵導出プロセス
ハンドシェイクプロトコルのClientHelloとServerHelloにはrandomというフィールドがあり、ここにDiffie-Hellman鍵共有で求めた公開値をのせて送信することで、クライアントとサーバは通信を傍受している攻撃者に漏洩することなく鍵を共有することができます。 この共有鍵は、すぐにパケットの暗号化に使われるわけではなく、鍵導出関数のHKDF-ExtractとDerive-Secretを使って実際にパケットを暗号化するための鍵を導出します。
導出される鍵は次のものがあります。
- binder_key : 事前共有鍵を保持していることの証明に使う鍵 (クライアント)
- client_early_traffic_secret : Early Dataの暗号化に使う鍵 (クライアントのみ)
- early_exporter_main_secret : 鍵素材をTLSの外部で利用するためのエクスポーター用の鍵 (0-RTTの場合)
- client/server_handshake_traffic_secret : ハンドシェイクのトラフィックの暗号化に使う鍵
- client/server_application_traffic_secret_N : アプリケーションのトラフィックの暗号化に使う鍵
- exporter_main_secret : 鍵素材をTLSの外部で利用するためのエクスポーター用の鍵
- resumption_main_secret : セッション再開で使われる事前共有鍵を生成する鍵
上記の鍵の中で、プロトコルで使う特に重要な鍵は以下の4つです。
- ハンドシェイクプロトコル
- client_handshake_traffic_secret : ハンドシェイクでクライアントから送信するメッセージを暗号化するために使う
- server_handshake_traffic_secret : ハンドシェイクでサーバから送信するメッセージを暗号化するために使う
- レコードプロトコル
- client_application_traffic_secret_N : レコードプロトコルでクライアントから送信するデータを暗号化するために使う(更新回数N)
- server_application_traffic_secret_N : レコードプロトコルでサーバから送信するデータを暗号化するために使う(更新回数N)
レコードプロトコルで使う鍵はKeyUpdateハンドシェイクメッセージを使って更新することができるため、更新回数 N を鍵名の末尾につけています。 ハンドシェイク完了直後は N = 0 です。 更新後を N+1 とすると、鍵の更新は次の式で求めます。 application_traffic_secret_N+1 = HKDF-Expand-Label(application_traffic_secret_N, "traffic upd", "", Hash.length)
暗号化プロセス
実際にはさらに、鍵導出プロセスで導出した鍵から暗号化鍵 (Key) と初期ベクタ (IV) を鍵導出間数の HKDF-Expand-Label を使って生成し、初期ベクタとレコードの送信番号(1番目なら0、2番目なら1、…)をXORした結果をナンス (Nonce) として、レコードの暗号化を行います。 以下は client_application_traffic_secret_0 または server_application_traffic_secret_0 でレコードを暗号化するための鍵とナンスを作成するときの流れです。
復号も暗号化と同じ手順で行います。
暗号化ではクライアントは client_*_secret
、サーバは server_*_secret
を使いましたが、それぞれ相手が暗号化したデータを復号するために相手の鍵も必要なので、両者は結局クライアントとサーバの両方の鍵を導出する必要があります。