Hatena::ブログ(Diary)

あどけない話

2017-01-20

TLS 1.3 開発日記 その10 NSSサーバ

TLS 1.3 のテスト用に公開されているNSSサーバは2つあります。

Haskell TLS 1.3 client では、前者とフルハンドシェイクできるのですが、後者は handshake error を返してきます。一方で、Firefox Nightly や Chrome Canary は、後者に問題なくアクセスできます。

必要な拡張が足らないのかと思い、Firefox Nightly が付けている拡張をすべて付けてみたりしましたがダメでした。この相互接続性は長い間の課題だったのですが、他の作業が落ち着いたこともあり、重い腰を上げて真面目に解析してみました。

採った方法は、Firefox Nightlyが出力する Client Hello のバイト列をそのまま送りつけるユーティリティを作り、徐々に拡張などを削っていて、handshake error を返す要因を特定することです。

驚くべきことにTLS 1.3に関係のない拡張を全部削除しても、tls13はServer Hello を返してきました。そこで、TLS 1.3に必須の拡張から値を徐々に消していきました。そして、Signature Scheme から ecdsa_secp384r1_sha384 を消したときに、handshake error になることを突き止めました。

tls13のサーバ証明書は ECDSAだったのです!同じ NSSサーバでも、franziskuskieferではうまくいくのに tls13ではうまくいかない理由が氷解しました。そこで、Haskell TLS 1.3 にECDSAのコードを入れたところ、めでたく tls13とハンドシェクできるようになりました。

NSSサーバは、NewSessionTicketを送ってきませんので、PSKハンドシェイクなどはできないようです。

2017-01-12

TLS 1.3 開発日記 その9 NewSessionTicket

kazuho さんと議論したメモ:

サーバが送るNewSessionTicketは、当然セッションチケットを入れないければならない。以下の構造では ticket がそれにあたる:

struct {
  uint32 ticket_lifetime;
  uint32 ticket_age_add;
  opaque ticket<1..2^16-1>;
  Extension extensions<0..2^16-2>;
} NewSessionTicket;

この ticket は、キースケジュールの最後に出てきた resumption_secret をサーバ自身だけが復号化できるように暗号化したものである。resumption_secret を算出するには、Client Finished が必要である。

というわけで、普通に考えるとNewSessionTicketを送れるのは、Client Finishedを受け取った後になる。しかし、Client Finishedは予測できるので、Server Finished を送った直後に、予測を元に生成した NewSessionTicket を送ることもできる。

サーバは、どちらのタイミングで NewSessionTicket を送るべきだろうか?

TLS 1.3 の API として、以下の3つが用意されているとしよう:

  • handshake()
  • sendAppData()
  • recvAppData()

サーバが handshake() を呼ぶ場合、Client Finished の到着を待つのではなく、Server Finished を送った直後に戻って来てほしい。なぜなら、メール系のプロトコルでは、サーバから先に greeting を送るからだ。greeting は 0.5 RTT で送りたい。

Web系のサーバでは、クライアントから先にデータがやってくるので、handshake() の後にすぐに recvAppData() を呼ぶだろう。recvAppData() は、Client Finished を受信して検証した後、アプリケーションのデータを受信して戻る。

recvAppData()は、データを受信することだけが期待されており、何かを送信するとユーザが驚くと思われる。だから、recvAppData() は NewSessionTicket を送ってはいけない。

そいうわけで、送受信できる handshake() が予測を元にNewSessionTicketをあらかじめ送るのが筋がよさそうに思える。

2016-12-25

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で利用されてない部品が必要です:

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

拡張の再利用

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

当初は、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 というブランチに保存してあります。

2016-12-24

TLS 1.3 開発日記 その7 0RTT

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

この記事では、TLS 1.3 の4番目のハンドシェイクである 0RTT について説明します。

0RTTとは、アプリケーションが目的の通信を始めるまでに、下位の層でパケットのやりとりがないことを意味します。準備にかかる round trip time の回数が0回ということです。

TLS 1.3 では、0RTT が PSK ハンドシェイクの拡張として実現されています。TLS 1.3の最新のドラフトから0RTTの図を抜粋します:

Client                                               Server

ClientHello
 + early_data
 + key_share*
 + psk_key_exchange_modes
 + pre_shared_key
(Application Data*)     -------->
                                                ServerHello
                                           + pre_shared_key
                                               + key_share*
                                      {EncryptedExtensions}
                                                 {Finished}
                        <--------       [Application Data*]
(EndOfEarlyData)
{Finished}              -------->

[Application Data]      <------->        [Application Data]

他のハンドシェクと比べると分かりますが、波カッコ、角カッコに加えて、丸カッコが登場しています。丸カッコは、PSKをタネにして生成した共通の鍵で暗号化されていることを意味します。Client Hello を送ると同時に、アプリケーションがデータを暗号化して送っていることが分かるでしょう。

early_data 拡張が 0RTT を使っているという目標であり、EndOfEarlyDataというハンドシェイクメッセージが 0RTT によって送られるデータの終わりを示しています。

key_shareを用いて前方秘匿性のある(EC)DHEで鍵を交換する前に暗号化していますので、丸カッコの部分には前方秘匿性がありません。また、リプレイ攻撃も完全には防げません。

このようにセキュリティが弱いために、0RTTには通常とは別のAPIを提供せよとドラフトには書かれています。クライアント側に0RTT専用のAPIを用意するのは簡単そうですが、サーバ側はどうすべきなのか、現時点の僕には分かりません。僕が思い付くのは、サーバが0RTT を受け取るか否かの設定項目を設けるぐらいです。

PSKハンドシェイクで、サーバがPSKは正当だが受け入れられないと判断した場合は、フルハンドシェイクにフォールバックします。0RTTでも同様ですが、その場合サーバは、クライアントが0RTTで送って来たアプリケーションデータを読み捨てる必要があります。また、クライアントサーバが pre_shared_key を返さない、つまり、フルハンドシェイクへのフォールバックを選んだことを検知すると、アプリケーションデータを再送する必要があります。

丸カッコ、波カッコ、角カッコが出揃ったので、ようやくキースケジュールの図を出せるときがやってきました:

                 0
                 |
                 v
   PSK ->  HKDF-Extract
                 |
                 v
           Early Secret
                 |
                 +-----> Derive-Secret(.,
                 |                     "external psk binder key" |
                 |                     "resumption psk binder key",
                 |                     "")
                 |                     = binder_key
                 |
                 +-----> Derive-Secret(., "client early traffic secret",
                 |                     ClientHello)
                 |                     = client_early_traffic_secret
                 |
                 +-----> Derive-Secret(., "early exporter master secret",
                 |                     ClientHello)
                 |                     = early_exporter_secret
                 v
(EC)DHE -> HKDF-Extract
                 |
                 v
         Handshake Secret
                 |
                 +-----> Derive-Secret(., "client handshake traffic secret",
                 |                     ClientHello...ServerHello)
                 |                     = client_handshake_traffic_secret
                 |
                 +-----> Derive-Secret(., "server handshake traffic secret",
                 |                     ClientHello...ServerHello)
                 |                     = server_handshake_traffic_secret
                 |
                 v
      0 -> HKDF-Extract
                 |
                 v
            Master Secret
                 |
                 +-----> Derive-Secret(., "client application traffic secret",
                 |                     ClientHello...Server Finished)
                 |                     = client_traffic_secret_0
                 |
                 +-----> Derive-Secret(., "server application traffic secret",
                 |                     ClientHello...Server Finished)
                 |                     = server_traffic_secret_0
                 |
                 +-----> Derive-Secret(., "exporter master secret",
                 |                     ClientHello...Server Finished)
                 |                     = exporter_secret
                 |
                 +-----> Derive-Secret(., "resumption master secret",
                                       ClientHello...Client Finished)
                                       = resumption_master_secret
  • HKDF-Extract や Drive-Secret は単なる関数であり、ドラフトを読めば定義が分かります
  • "0" は、cipher suite で決定したハッシュの出力の大きさ分の 0 の列です
  • 左上の PSK は、前回のセッションで共有した resumption_master_secret のことです
  • "(EC)DHE"は、(EC)DHEで共有した鍵のことです

鍵は以下のように使われます:

  • 丸カッコ:client_early_traffic_secret
  • 波カッコ:client_handshake_traffic_secret, erver_handshake_traffic_secret
  • 角カッコ:client_traffic_secret_0, server_traffic_secret_0

これで、4つのハンドシェイクすべてが理解できました。

2016-12-13

TLS 1.3 開発日記 その6 Pre Shared Key

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

今日は、TLS 1.3 の第三番目のハンドシェイクである PSK (Pre Shared Key)について説明します。

みなさんは、Pre Shared Key という言葉から何をイメージしますか? 多くの方は、通信路の暗号化に使う鍵をあらかじめ共有することだと思うのではないでしょうか?

TLS 1.2 での Pre Shared Key は、RFC4279で定められています。なんとなんと、共有している鍵の主目的は、通信路の暗号化ではなく、通信相手の認証です。ここを間違っていると、全然話が理解できないので、間違っていた人は認識を新たにして下さい。

TLS 1.3 での PSK は、セッションの再開(resumption)と統合されました。ややこしいのですが、TLS 1.3 で PSK という場合、次の2つの意味があります。

この理解を間違うと、まったく意味不明なハンドシェイクなので、注意して下さいね。

Resumption PSKの場合、最初はフルハンドシェイクする必要があります。そのやりとりをドラフトから抜粋します。

ClientHello
 + key_share              -------->
                                                ServerHello
                                                + key_share
                                      {EncryptedExtensions}
                                      {CertificateRequest*}
                                             {Certificate*}
                                       {CertificateVerify*}
                                                 {Finished}
                          <--------     [Application Data*]
{Certificate*}
{CertificateVerify*}
{Finished}                -------->
                          <--------      [NewSessionTicket]
[Application Data]        <------->      [Application Data]

ここで、大切なのは NewSessionTicket です。Application Data用の暗号路が確立したら、いつでもサーバからクライアントへ送ることができます。

TLS 1.3 では、以下の2つも統合されています。

NewSessionTicketには、十分な長さのデータが格納できますので、それが Session ID であっても、Session ticket であってもよい訳です。

PSK resumption の手続きもドラフトから抜粋します。

ClientHello
 + key_share*
 + psk_key_exchange_modes
 + pre_shared_key         -------->
                                                ServerHello
                                           + pre_shared_key
                                               + key_share*
                                      {EncryptedExtensions}
                                                 {Finished}
                          <--------     [Application Data*]
{Finished}                -------->
[Application Data]        <------->      [Application Data]

サーバが証明書を送ってないこと、つまりクライアントサーバの認証を省略することがわかるでしょう。

PSKの情報を送る pre_shared_key 拡張は構造が複雑なので抜粋しませんが、クライアントは PSK のリストを送り、サーバが選んだ PSK が何番目かを返すと理解していれば十分です。クライアントが送る PSK のリストですが、ほとんど用途の場合その長さは1であると思われます。

Cipherスイートは、ClientHelloに入っていますので、前のセッションと異なる値を選んでもいいのですが、Hashが変わると Session ticket の検証に失敗します。

鍵交換は、psk_key_exchange_modes 拡張で制御します。以下の2つのモードがあります。

  • PSK only - 共有している鍵だけを種に使って暗号路の鍵を生成
  • PSK with (EC)DHE - 共有している鍵に加えて、新たに (EC)DHE で共有した鍵を種に使って暗号路の鍵を生成。こちらは前方秘匿性を持つ

ここまで読むと、TLS 1.3 開発日記 その2で述べた cipher スイートからサーバ認証と鍵交換が切り離された理由が納得できるのではないでしょうか?