ECHをHaskell tlsライブラリに実装した話。先に「Encrypted Client Hello の仕様」を参照のこと。Haskellで実装した経験談なので、Haskellの知識を前提に書く。
ライブラリの構成
バックエンド・サーバの設定情報である ECHConfigList
は、DNSを通じて提供され、TLSクライアントとTLSサーバで利用される。ECHConfigList
の型や、その符号器/復号器をどのライブラリで実装するか決める必要がある。
tls
ライブラリをECHに対応させると、ECHConfigList
を利用するのは当然である。僕の希望としては、IIJが開発している DNS検索コマンドdug
で HTTPS RR を検索したときに、ech パラメータが16進数表記されるのではなく、ECHConfigList
を利用してユーザに分かりやすく表示されるようにしたい。
dug
は、HTTPS RR を提供する dnsext-svcb
ライブラリに依存している。ECHConfigList
を tls
から提供すると dnsext-svcb
が tls
に依存するし、逆もまたしかりである。両者は本来独立であるので、この依存関係が発生するのは嬉しくない。そこで、依存するライブラリの数が少ない 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 する。Export
は export_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-lookup
は dnsext-svcb
に依存するが、dnsext-svcb
は ech-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
を試したがまったく同じ挙動を示した。
ここで行き詰まってしまったが、IETF の TLS メーリングリストに ECH の相互接続性検証レポートが投稿されているのを発見し、著者の Stephen さんにメールを出してみた。彼が進めている DEfO プロジェクトで作成された OpenSSL フォークは HRR に完全対応していると言う。そこで、そのフォークを使ってみたら、2回目の ECH は再び暗号化しているものの、ハンドシェークで同一の鍵を共有できなかった。
最後の希望である NSS を試すと、2回目の ECH は再び暗号化していて、しかも alert を送ってきた。この alert を生成しているソースの場所を特定したことで、謎が解けた。HRRでは、サーバがECHの受理情報を2回クライアントへ返す。両者が異なる、つまり一方が受理で、他方が拒否の場合、この alert が生成されていた。
分かった、分かった。ようやく分かった。すべての事象をうまく説明できる仮説は、HRRに埋め込む一回目の受理データの計算が間違っていることだ。仕様をよく読み返すと、HRRでECHが拒否された場合、2回目のECH拡張は、1回目と同じものを使うと定義されていた。picotls
や boringssl
の挙動の解釈に頭を抱えていたが、正しい振る舞いをしていたのだ。
受理データを正しく生成できなかった理由は、一連のハンドシェイク・メッセージのハッシュ値 (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 さんに感謝します。