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 を知っているが、残念ながらそれをサーバ管理者には教えてくれない。
八方塞がりである。
実はロガーは、すでにある証明書が登録されようとした場合も、SCT を返す。この方法を実現してくれるのが、ct-submitである。(もちろん新規登録も可能。)
% ct-submit ct.googleapis.com/pilot < fullchain.pem > sct
TLS 1.3 開発日記 その11 NSS
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日目の記事です。
この記事では、HaskellでTLS 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つの拡張を再利用しています:
- 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つのハンドシェイクすべてが理解できました。