あどけない話

Internet technologies

[TLS][Haskell]TLS 1.3 開発日記 その13 TLS 1.3を遮断する中継装置

原因

  • TLS 1.3は、TLS 1.2と区別のつかないClient Helloを送る。拡張でバージョンが1.3だと教える。
  • TLSの実装は、知らない拡張は単に無視してエラーにしてはいけないという鉄則により、TLSのバージョンアップは順調にいくはずだと信じられていた。
  • BlueCoatは、サーバからのCertificateの中身を見て、中継するか否かを決めるらしい。
  • TLS 1.3を知らないBlueCoatには、Client HelloがTLS 1.2に見える。
  • しかし、サーバから戻って来たハンドシェイクは暗号化されているから中身が読めない。
  • TLS 1.2じゃない」と言って、コネクションを遮断。

考察

  • 世の中にはいろんな実装がある。はぁ。
  • Firefoxは、TLS 1.2にフォールバックするらしい。
  • ChromeでもTLS 1.2にフォールバックすれば一応問題解決。
  • しかし、TLS 1.2でも1.3でもうまくいくようにClient Helloは設計されているので、TLS 1.2にフォールバックするのは、なんだかなぁという感じ。
  • BlueCoatよ、早く直して。

TLS 1.3 開発日記 その12 OCSP と SCT

TLS 1.2では Server Hello 拡張であった OCSP と SCT は、TLS 1.3ではハンドシェイクメッセージである Certificate の拡張となった。

OCSP

証明書は有効期限内であっても、失効している可能性がある。失効しているかを調べる伝統的なやり方は、CRL(Certificate Revocation List)であった。

CRLでは、クライアントが失効リストを取って来て、その中に対象があるかないかを調べる。これに対し、OCSPサーバに問い合わせると、証明書が(いつの時点で)有効か教えてくれるのが OCSP(Online Certificate Status Protocol) である。

TLSクライアントが、OCSPサーバに問い合わせるのは現在では非推奨である。その代わり、TLSサーバが定期的に問い合わせ、証明書と一緒にフレッシュな OCSP Response を送ってくるのが、OCSP stapling である。

証明書に対する OCSP サーバの情報は、証明書の中に入っている。Let's ecnrypt で取得した署名書では、以下のような感じ:

% openssl x509 -text -in cert.pem
Certificate:
    Data:
...
            Authority Information Access: 
                OCSP - URI:http://ocsp.int-x3.letsencrypt.org/
                CA Issuers - URI:http://cert.int-x3.letsencrypt.org/
...

OCSPサーバに問い合わせてみる:

% openssl ocsp \
  -noverify \
  -issuer chain.pem -cert cert.pem -CAfile chain.pem \
  -url http://ocsp.int-x3.letsencrypt.org \
  -header Host ocsp.int-x3.letsencrypt.org \
  -resp_text -respout resp.der
OCSP Response Data:
    OCSP Response Status: successful (0x0)
    Response Type: Basic OCSP Response
    Version: 1 (0x0)
    Responder Id: C = US, O = Let's Encrypt, CN = Let's Encrypt Authority X3
    Produced At: Feb 23 06:37:00 2017 GMT
    Responses:
    Certificate ID:
      Hash Algorithm: sha1
      Issuer Name Hash: 7EE66AE7729AB3FCF8A220646C16A12D6071085D
      Issuer Key Hash: A84A6A63047DDDBAE6D139B7A64565EFF3A8ECA1
      Serial Number: 03AF31CC447C2CEF256FC77839190A70DDE2
    Cert Status: good
    This Update: Feb 23 06:00:00 2017 GMT
    Next Update: Mar  2 06:00:00 2017 GMT
...

便利なラッパーとして fetch-ocsp-responseがある。

SCT

Certificate Transperancyは、(理想的にはすべての)証明書を登録するサービスである。ロガー呼ばれるサーバには、証明書の追加だけができるログを持っている。2つの使い方がある。

  • ある証明書のハッシュを送ると、ログの中に存在しているか確認できる。
  • ロガーから、証明書群の差分を取れる。つまり、ロガーが持っている証明書すべてを取得できるので、不正な証明書が発行されてないか調べられる。たとえば、自分のドメインを名乗る不正なサーバに対する証明書を発見できる。

ロガーには、サーバ名から証明書を検索する機能はない。そういうサービスは、上記二番目の機能を使って外部に作る。たとえば、crt.sh

ロガーは、新規に証明書を登録された場合、SCT (signed certificate timestamp)を返す。これは、ロガーの署名が付いた存在証明である。TLSクライアントは、SCTの署名をロガーの公開鍵で検証すれば、対応する証明書がログに入っていることを確認できる。つまり、ロガーに問い合わせる必要はないし、TLSクライアントがロガーに問い合わせるのは非推奨である。

TLSサーバからTLSクライアントにSCTを渡すには、2つの方法がある。

  • 証明書の拡張に入れる
  • TLS の Server Hello の拡張に入れる

CA から、サーバ管理者に SCT を配布するには、以下の方法がある。

  • 発行する証明書に入れる
  • OCSPの拡張で返す

Let's encrypt の CA は、証明書を発行する際、ct.googleapis.com/pilot と ct.googleapis.com/icarus に証明書を登録するようだ。つまり、Let's encrypt の CA は、SCT を知っているが、残念ながらそれをサーバ管理者には教えてくれない。

  • certbot renew しても SCT ファイルはできない
  • certbot renew して得られる証明書には SCT が入っていない
  • OCSP で問い合わせても SCT 拡張は入っていない

八方塞がりである。

実はロガーは、すでにある証明書が登録されようとした場合も、SCT を返す。この方法を実現してくれるのが、ct-submitである。(もちろん新規登録も可能。)

% ct-submit ct.googleapis.com/pilot < fullchain.pem > sct

TLS 1.3 開発日記 その11 NSS

NSSのビルド

以下を参照:

(変更:)ブランチ:

  • default は draft 22

NSSサーバの動かし方

共有ライブラリを使っている場合は、適当にパスを加える。nssroot ディレクトリで:

% ./dist/$PLATFORM/bin/selfserv -d tests_results/security/localhost.1/ssl_gtests -n rsa -p 13443 -V tls1.3:tls1.3 -u -Z
  • -u が PSK を有効にする
  • -Z が 0RTT を有効にする

ヘルプの表示:

% ./dist/$PLATFORM/bin/selfserv -h

selfserv のソース:

NSSクライアントの動かし方

共有ライブラリを使っている場合は、適当にパスを加える。nssroot ディレクトリで:

ヘルプの表示:

% ./dist/$PLATFORM/bin/tstclnt -\?

フルネゴシエーション

% ./dist/$PLATFORM/bin/tstclnt -D -V tls1.3:tls1.3 -h 127.0.0.1 -p 13443 -o

HRR:

% ./dist/$PLATFORM/bin/tstclnt -D -V tls1.3:tls1.3 -h 127.0.0.1 -p 13443 -o -I P521,x25519

PSK:

% ./dist/$PLATFORM/bin/tstclnt -D -V tls1.3:tls1.3 -h 127.0.0.1 -p 13443 -o -L 2 -A 

入力ファイルの例:

% cat $SOMEWHERE/early-data.txt
GET / HTTP/1.1
Host: 127.0.0.1
Connection: close

0RTT:

% ./dist/$PLATFORM/bin/tstclnt -D -V tls1.3:tls1.3 -h 127.0.0.1 -p 443 -o -L 2 -A $SOMEWHERE/early-data.txt -Z

tstclnt のソース:

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ハンドシェイクなどはできないようです。

追記:

Mozillaの人達と話したら、tls13 が NewSessionTicket を返さないのは、現在のNSSの実装では、ECDSA の証明書に対しそれを返せないからだそうです。証明書を RSA に変えて、NewSessionTicket を有効にしていただきました。めでたく、NewSessionTicket は届くようになりましたが、まだ PSK のハンドシェイクに成功していません。

これは mod_nss の問題のようです。ローカルでビルドした NSS とは、4つのハンドシェイク全部でつながることを確認できました。

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をあらかじめ送るのが筋がよさそうに思える。

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

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つのハンドシェイクすべてが理解できました。