あどけない話

Internet technologies

Encrypted Client Hello の仕様

ECH(Encrypted Client Hello) とは何か

TLS 1.3のハンドシェイクは、EncryptedExtensionsから暗号化されるが、それより前のClientHello と ServerHelloは平文のままで交換される。ClientHelloに含まれるSNI(Server Name Indication、サーバ名)は、特にプライバシ性が高い。単にこのSNIを暗号化する方法として、初期はESNI(Encrypted Server Name Indication)が提案されていた。しかし、その後ClientHello自体を暗号化するECH(Encrypted Client Hello) となった。

ECHには2つのサーバが登場する。「クライアントに面したサーバ」と「バックエンド・サーバ」だ。保護したいのはバックエンド・サーバのSNIである。前提として ECHでは、バックエンド・サーバの「設定情報」をDNSHTTPS RRに登録する。このプライバシ性の高いSNIに対する HTTPS RRを検索すると、バックエンド・サーバの設定情報が得られる。設定情報には、「クラアントに面したサーバの公開鍵」や「クラアントに面したサーバのSNI」が入ってる。これらの検索には、DoH (DNS over HTTPS)などを利用することで、バックエンド・サーバのSNIが観察されることを防ぐ。

設定情報を得ると、ECHを利用できるようになる。まず、バックエンド・サーバのSNIを含むClientHelloを作成して暗号化する。そしてクライアントに面したサーバのSNIを含む別のClientHelloを作成し、その中にECH拡張として暗号文を挿入する。

たとえば、バックエンドのSNIが private.example.jp で、クライアントに面したサーバのSNIが public.example.jpの場合、内側のClientHelloに private.example.jpが、外側のClientHelloにpublic.example.jpが入る。ClientHello全体を暗号化するため、将来定義されるさまざまな拡張を必要に応じて暗号化できる。柔軟な設計であるが、それ故に、仕様は目眩がするほど大きい。

僕は1月の終わりに実装を開始して、リリースできたのは3月の半ばであった。ようやく時間ができたので、仕様の解説と実装の体験記を記そうと思う。

HPKE

内側のClientHelloの暗号化には、RFC 9180で定められているHPKE(Hybrid Public Key Encryption)を使う。TLSが「通信の暗号化」であるのに対して、HPKEは「オブジェクトの暗号化」である。ClientHello自体は、オブジェクトだからね。

オブジェクトの暗号化と言えば、PGPS/MIMEが有名だ。「乱数的に生成した鍵」と共通鍵暗号でオブジェクトを暗号化し、鍵を公開鍵暗号で暗号化する。大雑把に言えば、HPKEはこの方式をモダンにしたものであると言える。しかし細かい点は異なる:

  • 以前は鍵を共有するためにRSAが主に使われていたが、現在の鍵交換の主役といえば ECDHEである。HPKEでも、ECDHEが使われる。
  • 共有した鍵は、「標準化された鍵派生関数」(KDF)により加工される
  • OpenPGPが標準された時代とは違って、共通鍵暗号に許されるのはAEADモードのみである

TLSとHPKEを比較すると、以下のような点が決定的に違う:

  • TLSには前方秘匿性がある。HPKEでも ECDHEが使われているので、前方秘匿性を期待したくなるが、前方秘匿性はない。なぜなら、クラアントに面したサーバが公開する公開鍵は静的だからだ。
  • TLSでは初手のClientHelloを暗号化できない。暗号化を始められるのはサーバ側である。一方で、HPKEではクライアントが初手から暗号化を使える。(ClientHelloを暗号化するのが目的だからね。)

HPKEは、前方秘匿性を諦める代わりに、初手からの暗号化という機能を手に入れているとも言える。

HPKEでは、APIが定義されている。以下に暗号化関数Sealの例を示す。

def Seal<MODE>(pkR, info, aad, pt, ...):
  enc, ctx = Setup<MODE>S(pkR, info, ...)
  ct = ctx.Seal(aad, pt)
  return enc, ct

大雑把に言うと、「受信者の公開鍵」(pkR)と「文字列」(info)でAEADを初期化し、「追加データ」(aad)と「平文」(pt)をAEADに入力して、「暗号文」(ct)を得る。encは、送信者の公開鍵である。

ECHクライアント

クライアントは、ECH拡張を含む ClientHello を以下のように作成する。

  • バックエンド・サーバの設定情報から、クライアントに面したサーバのSNIや公開鍵を得る
  • バックエンドサーバのSNIを含んだ内側のClientHelloを作成する。(このClientHelloがバックエンドサーバに平文で届けば、TLSコネクションが張れる。)
  • クライアント向けサーバのSNIを含んだ外側のClientHelloを作成する。
  • 内側と外側の ClientHello の拡張リストを走査しながら、必要であれば参照を使って、内側の拡張リストを圧縮する (※1)
  • 内側の ClientHello の大きさから、暗号文の大きさを予想し、外側の ClientHelloに「本体を0で埋めた ECH 拡張」を挿入する (※2)
  • ※2を追加データとして、※1の平文を暗号化する (※3)
  • ※3の0 で埋めらた部分を暗号文で置き換える

外側のClientHelloを追加データとして使うのは、外側のClientHelloに対する改ざんを検知するためである。

一般的に、暗号が絡んだプログラミングでは、すべてが正しくなければ、まったく正常に動作しない。ECHの場合、追加データの作成も難しく、実装をより困難にしている。

ECHサーバ

クライアントに面したサーバが、ECH拡張を含んだClientHelloを受け取ると、上記の逆の操作を施して、内側のClientHelloの復元を試みる。

復元に失敗した場合、外側の ClientHello を使って、クライアントと TLS のハンドシェイクを実行する。後述の検知方法により、クライアントはバックエンドサーバとハンドシェイクしてないことが分かるので、ハンドシェイクが終わった時点で、alert を送ってコネクションを終了させる。

復元できた場合、クライアントに面したサーバは、平文の ClientHello をバックエンドサーバに送る。この後、クライアントに面したサーバは、単なるリレー役となる。内側の ClientHello を受け取ったバックエンドサーバは、クライアントとハンドシェイクを開始する。

バックエンドサーバは、内側の ClientHello を受け取ったことを示すために、8バイトの特殊な値を生成し、ServerHello の Server Random に埋め込む。この値の生成には、ClientHello +該当8バイトを0で埋めた ServerHello のハッシュ値が使われるので、これまたややこしい。

さらに HelloRetryRequest のケースでは、最初の ClientHello はハッシュ値ハッシュ値になるというルールがあるので、正しい値を導くのは大変である。

以上は、あくまでECHの概要である。実際の仕様には、ここで説明した以外にも面倒な部分があり、実装の進捗具合は亀の歩みよりも遅い。