あどけない話

Internet technologies

Encrypted Client Hello の実装

ECHをHaskell tlsライブラリに実装した話。先に「Encrypted Client Hello の仕様」を参照のこと。Haskellで実装した経験談なので、Haskellの知識を前提に書く。

ライブラリの構成

バックエンド・サーバの設定情報である ECHConfigList は、DNSを通じて提供され、TLSクライアントとTLSサーバで利用される。ECHConfigList の型や、その符号器/復号器をどのライブラリで実装するか決める必要がある。

tls ライブラリをECHに対応させると、ECHConfigList を利用するのは当然である。僕の希望としては、IIJが開発している DNS検索コマンドdugHTTPS RR を検索したときに、ech パラメータが16進数表記されるのではなく、ECHConfigList を利用してユーザに分かりやすく表示されるようにしたい。

dug は、HTTPS RR を提供する dnsext-svcb ライブラリに依存している。ECHConfigListtls から提供すると dnsext-svcbtls に依存するし、逆もまたしかりである。両者は本来独立であるので、この依存関係が発生するのは嬉しくない。そこで、依存するライブラリの数が少ない ech-config というライブラリを新たに作り、そこに格納することにした。

ECHConfigList には HPKE のパラメータに関する情報が含まれている。ech-config が、新たに作成する hpke ライブラリに依存すると、単なる型情報を提供する ech-config が、暗号ライブラリ crypton に依存してしまう。これは避けたい。

という訳で、ECHConfigList を適切な型で表現するのは諦めた。たとえば、hpkeライブラリから提供される AEAD_ID を使いたくなるが、それは我慢して Word16 を使うといった具合だ。

最終的なライブラリの依存関係は、以下の図の通り。

ライブラリの依存関係

HPKEの実装

HPKE用のモジュールは、crypton に収めてもおかしくないが、hpkeという別のライブラリにすることにした。hpkeの作成にあたっては、以下のようにcryptonを拡張する必要があった。

  • ChaCha20Poly1305 が AEAD として利用するには中途半端な状態だったので、これを直した。
  • ECDHEの秘密鍵から公開鍵を作る関数は内部で実装されているものの、公開されていなかったので、型クラス EllipticCurve のメソッドして提供するようにした。

大局的に言うと hpke ライブラリは、HPKEのパラメータと crypton から提供される関数をつなげる役割を果たす。最初はよい構成が発見できず紆余曲折したが、同僚の日比野さんと議論した結果、最終的には以下のように拡張可能な構成となった。

  • HPKE パラメータは、(拡張不可能な直和型ではなく) 拡張可能なように PatternSynonyms を使う
  • HPKE パラメータをキー、呼び出す関数を値とした、連想配列を渡せる内部関数を作り、Internal モジュールから提供して拡張可能とする。
  • デフォルトのAPIでは、この連想配列は渡さなくてよく、簡便に利用できる。
  • crypton の関数は、型クラスで提供されているものが多い。つまり関数名は同一だが、型が異なるので、そのままではリストに格納できない。そこで、存在型を使って同一の型にすることで、連想リストに収める。

RFC 9180の付録にはテストベクターが載っているが、定義されているAPIでは、これらを直接活用することはできない。なぜなら、APIでは、送信側の秘密鍵は内部で動的に生成されるので、秘密鍵を外から指定するのは無理である。そこで、秘密鍵を指定できる内部関数を作って、内部関数をテストの対象とすると共に、API では動的に生成した秘密鍵を内部関数に指定するようにした。

Haskellの型システムは、RFC 9180の仕様にマイナーなバグがあることを教えてくれた。一般的に、鍵を派生させる手続では、共有した鍵を extract して expand する。Exportexport_secret を expand するように定義してあるのだが、export_secret はすでに expand した値である。(型の弱いプログラミング言語で考えていると、何でもかんでも文字列で表現するので、よくこういう間違いを犯す。)

ECH Config の実装

下位の符号/復号ライブラリとして、最初は tls で利用されている cereal を利用したが、dnsext-scvb から ech-config を利用してみると、dnsext-scvb が依存している network-byte-order の方がよいことが分かり、そちらに移行した。

ech-config には、設定情報や秘密鍵を生成するためのコマンド ech-gen を収めた。設定情報や秘密鍵をサーバに渡す書式は標準化されていないので、ECH の各実装は独自の書式を採用している。ech-gen は僕が調べた限りの書式を生成する。

また Haskell tls のクライアント・コマンドで、オープンサーバと通信するには、対象サーバの HTTPS RR を引き、echパラメータを抽出するコマンドが必要である。dug に機能追加するという案も検討したが、大掛かりなプログラムなので改造するのは諦め、ech-lookup というコマンドを作成した。ech-lookupdnsext-svcb に依存するが、dnsext-svcbech-config に依存している。このため、ech-lookup は、ech-config 内でビルドすることは諦め、単にリポジトリにソースを追加している状態になっている。

ECH サーバの実装

TLSに対する相互接続性の検証で、僕が一番使っているのは picotls である。これは kazuho さんが作ったライブラリで、クライアントにもサーバにもなれるコマンド cli も提供されている。ビルドが簡単であることに加えて、kazuho さん作だけに信頼性が高い。kazuho さんは ECH 仕様の著者の一人であり、当然のように picotls は ECH を実装している。

調べてみると、ECH に対して cli クライアントを利用する方法が明快だったので、Haskell tls ではサーバ側から作り始めることにした。サーバは ECH 拡張を復号する側である。

Encrypted Client Hello の仕様」では、便宜上「クラアントに面したサーバ」と「バックエンド・サーバ」を分けて説明したが、これらは同一のサーバとして実装してもよい。Haskell tls の利用方法では、今のところ同一サーバ方式が適していると思われる。理屈の上では、同一サーバ方式では、ECH拡張を復号できたら、取り出せた内部 ClientHello を使い、復号に失敗したら外側の ClientHello を使えばよい。

復号するには、ECDHEの秘密鍵に加えて、「追加データ」として外側のClientHelloを加工する必要がある。面倒であるが時間をかけて実装すると、一番簡単なハンドシェーク・モードである full handshake で pictls と、内側の ClientHello を使って通信できるようになった。

次のモードは HRR(Hello Retry Request) であるが、この実装は困難を極めた。Haskell サーバが HRR を返すと、picotls は最初と同じ EHC 拡張を返してきた。正しいケースでは、暗号化し直した別の ECH 拡張を送ってこなければならない。picotls の ECH は、HRR に未対応なのかもと思い、boringssl を試したがまったく同じ挙動を示した。

ここで行き詰まってしまったが、IETFTLS メーリングリストに ECH の相互接続性検証レポートが投稿されているのを発見し、著者の Stephen さんにメールを出してみた。彼が進めている DEfO プロジェクトで作成された OpenSSL フォークは HRR に完全対応していると言う。そこで、そのフォークを使ってみたら、2回目の ECH は再び暗号化しているものの、ハンドシェークで同一の鍵を共有できなかった。

最後の希望である NSS を試すと、2回目の ECH は再び暗号化していて、しかも alert を送ってきた。この alert を生成しているソースの場所を特定したことで、謎が解けた。HRRでは、サーバがECHの受理情報を2回クライアントへ返す。両者が異なる、つまり一方が受理で、他方が拒否の場合、この alert が生成されていた。

分かった、分かった。ようやく分かった。すべての事象をうまく説明できる仮説は、HRRに埋め込む一回目の受理データの計算が間違っていることだ。仕様をよく読み返すと、HRRでECHが拒否された場合、2回目のECH拡張は、1回目と同じものを使うと定義されていた。picotlsboringssl の挙動の解釈に頭を抱えていたが、正しい振る舞いをしていたのだ。

受理データを正しく生成できなかった理由は、一連のハンドシェイク・メッセージのハッシュ値 (Transcript Hash) の計算が間違っていたからだった。これが契機となり、以前から作りたかったが手をつけてなかった、Transcript Hash のモニター機能を作成した。このおかげで、Transcript Hash に関する大胆なリファクタリングが可能となり、コードの見通しが劇的によくなった。

ECH クライアントの実装

次はクライアントである。もちろん最初は full handshake モードを実装する。当初の印象には反して、サーバよりもクライアントの方がロジックが難しかった。というのは、サーバでは内部と外部のどちらの ClientHello を使うかは、自分で決定できるので、採用しなかった方を忘れることができる。一方で、クライアントは、サーバが受理するか拒否するかは分からないので、内側のClientHello を保持しておく必要がある。

Haskell tls クライアントと pictls サーバは、早い段階で full handshake をできるようになった。

boringssl に関しては、公開サーバの存在も教えてもらったので、そちらを試したがうまくいかなかった。しかし、ローカルでビルドしたサーバとは、通信できた。これは一体どういうことだろう? 公開サーバに使われているコードは、リポジトリから入手できるものとは違うのだろうか?

公開サーバの設定情報を ech-config で復号し表示してみると、違和感があった。たとえば、Cloudflare で公開されている設定情報は、設定が1つしか入っていないのだが、boringsslの公開サーバの設定情報には、設定が複数個含まれていた。AEAD-128-GCM よりも、ChaCha20Poly1305が優先されているのも「そう言う趣味なのかな」という感じであったが、ech-config が逆順に返していることを気づいた。このバグを直したところ、公開サーバとも通信できるようになった。

すなわち、ECHの暗号化にAEAD-128-GCMを利用すると通信できるが、ChaCha20Poly1305だと失敗するのである。hpke では ChaCha20Poly1305 に対しテストベクターでテストしているので、間違いはないように思える。また、TLS自体の暗号スイートに ChaCha20Poly1305 が選ばれても、問題なく通信できる。今のところ、boringssl には、ECH 拡張をChaCha20Poly1305 で復号するコードにバグがあるのではないかと疑っている。

DEfO OpenSSLサーバは、設定情報と秘密鍵を PEM 形式で指定する必要があるとこが苦労した。NSSサーバは、書式が複雑過ぎるので、ech-gen で生成するのは諦めた。別のモードでは、NSSサーバは、これらを自動生成し、設定情報を表示する。相互接続性の検証には、そのモードを利用した。

Cloudflareは、すでに ECH をサービスインしている。適当なドメイン名のリストに対して、HTTPS RR の ech パラメータを検索させると、2.4% が対応済みであり、クライアントに面したサーバは、すべてcloudflare-ech.com であった。これらのドメインに対して、Haskell tls クライアントは ECH でコネクションを張ることができた。

開発者によく知られた Cloudflare の実験サーバは、crypto.cloudflare.com である。しかし、このドメインHTTPS RR を公開しておらず、サイトにも ECH の設定情報は書かれていない。頭を抱えていたが、試しに上記で得た設定情報を使ってみると、うまく通信できた。なお、cloudflare-ech.com に対して HTTPS RR を検索すると、答えが返ってくる。なんだかなぁ。

次は HRR である。これまでの大胆なリファクタリングおかげで、クライアントを HRR に対応させるのは、そんなに難しくなかった。

おわりに

相互接続性の検証に利用したクライアント・サーバの使い方は、ここにまとめてある。

メールでいろいろ教えてくれた Stephen さんに感謝します。