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では、バックエンド・サーバの「設定情報」をDNSのHTTPS 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自体は、オブジェクトだからね。
オブジェクトの暗号化と言えば、PGPやS/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の概要である。実際の仕様には、ここで説明した以外にも面倒な部分があり、実装の進捗具合は亀の歩みよりも遅い。