あどけない話

インターネットに関する技術的な話など

TLS 1.3 開発日記 その8 開発メモ

これは、http2 Advent Calendar 2016の25日目の記事です。

この記事では、HaskellTLS 1.3を開発した際に難しかった点をまとめます。自分のための覚書です。TLS 1.3のみをフルスクラッチで書くと、そこまで難しくないのかもしれませんが、TLS 1.2以前と共存させるのは大変です。

足らない部品

TLS 1.2のコードが存在しても、TLS 1.3では TLS 1.2で利用されてない部品が必要です:

  • RSA PSS
    • PSSPKCS#1 は異なるパディング方式の署名。RSAの公開鍵/秘密鍵自体は流用できる
  • HKDF
  • X25519 と X448

これらは、Haskell の cryptonite にすべて揃っていたので、少し(かなり?)手を入れるだけで利用できるようになりました。

拡張の再利用

TLS 1.3では、TLS 1.2 の2つの拡張を再利用しています:

  • SignatureAndHashAlgorithm を SignatureSchemeList として再利用
  • EllipticCurveList を NamedGroupList として再利用

当初は、TLS 1.2のパーサ/シリアライザを TLS 1.3 用に書き換えていました。しかし、SignatureAndHashAlgorithmの値などは、コンフィグで設定できるようにすでにユーザに公開しています。TLS 1.3の仕様で書き換えてしまうと、ユーザの意図通りにパラメータが指定されなくなってしまいます。

随分考えましたが、結局単に拡張IDがたまたま同じだけの別の拡張だと割り切ることにしました。TLS 1.2 用のパーサ/シリアライザはまったく変更せず、TLS 1.3 用のパーサ/シリアライザを追加しました。

Client Hello の生成では、ユーザの指定に TLS 1.3 が含まれている場合は TLS 1.3用を、そうでなければ TLS 1.2用を使います。Client Hello の解釈では、TLS 1.3 を選んだ場合にTLS 1.3用を、そうでなければ TLS 1.2用を使います。

この方針で、これまでの挙動を変えることなしに、コードをすっきりさせることができました。

Server Helloの解釈

Client Hello は、TLS 1.2 と TLS 1.3 で書式が(ほぼ)同じです。ですので、Client Hello の解釈では、まずパースした後に分岐できます。

しかし、TLS 1.2 と TLS 1.3 では、Server Hello の書式が異なります。よって、パーサの中で分岐が必要です。Haskellのクライアントでは、パーサを利用する関数が、パーサ内で分岐することを考えて作られていません。

関数プログラミング的に分岐を実現するには、たくさんの関数を書き換える必要がありました。この方法は、やりたいことに比べてコストが高過ぎると判断し、採用しませんでした。結局、パーサが TLS 1.3 だと判断した場合は、命令プログラミング的に例外を挙げてパーサを利用する関数から飛び出し、TLS 1.3 へ分岐することにしました。

メッセージによって構造が異なる拡張

TLS 1.2 ではなかったことですが、TLS 1.3の拡張の中には、ハンドシェイクのメッセージによって構造が異なるものがあります。たとえば、KeyShare 拡張です。

struct {
  select (Handshake.msg_type) {
    case client_hello: KeyShareEntry client_shares<0..2^16-1>;
    case hello_retry_request: NamedGroup selected_group;
    case server_hello: KeyShareEntry server_share;
  };
} KeyShare;

これを表現する直和型のデータ構造を用意するとしましょう。シリアライザは、直和型のタグを見て符号化を変えればよいだけです。問題は、パーサです。パーサは、ハンドシェイクメッセージの型を見ないと正しくパースできません。

これは、関数プログラミング的には、拡張用のすべてのパーサに引数を増やさなければならないことを意味しています。幸運にも、Haskell tls ライブラリには、role (サーバかクライアントか)という引数がすでに渡されていたので、ここをハンドシェイクのメッセージの型に変更することで対応できました。

Google grease

プロトコルのバージョンアップは、いつも大変です。(IPv6関係者の発言なので、説得力があるでしょう?) TLS の場合、生命線は拡張です。拡張のパーサが、知らない値を無視する、つまり、エラーにしないように作ってあれば、将来のバージョンアップへの道が担保されることになります。

将来知らない値が来たときに試すのではなく、いつも知らない値を送ることで、パーサをテストし続けるというのが Applying GREASE to TLS Extensibility のアイディアです。実際、Chrome Canaryは、この grease を送って来ます。

僕が拡張のパーサを最初に書いたときは、知らない値を無視すべきだとは分かってはいたのですが、面倒なので fixme マークを付けながら、エラーにしていました。

僕が実装した Haskell TLS サーバは、すぐに Firefox Nightly とはお話できるようになりました。しかし、TLS 1.3 ID 18 をサポートしたという Canary は、いくら最新をダウンロードしても supported_versions 拡張を送ってこないのです。

しばらくの間、Canary に TLS 1.3 ID 18 を喋らせる方法がまったく分かりませんでした。ある時、念のため tcpdump でパケットをキャプチャしてみたところ、Client Hello に supported_versions が存在するではありませんか。Canary は設定通りに TLS 1.3 ID 18 を喋っていたのです。

そのキャプチャを見た瞬間、grease という言葉が頭を駆け巡りました。はい、悪いのは僕なんです。パーサを直した途端に、Canary と TLS 1.3 で通信できるようになりました。

この話にはオチがあります。後日 grease の提唱者からメールが来ました。どうやら僕の commit log を見たらしく、「grease の効果を調査しているんだけど、君のコードで役に立ったのなら、具体的な話を教えて欲しい」と書かれていました。もちろん、「とっても役に立った」と即答しました。

僕の実装は、差分を最小にするために現在では一つの大きなパッチになっています(git rebase -i + stash したという意味)。しかし、上記の commit log は残したかったので、古い実装は tls13-old というブランチに保存してあります。