Plan 9(9legacy)は、安定版のパッチを当ててもTLS_RSA_WITH_AES_128_CBC_SHA256までしか対応してなく、そろそろ古くなってきています。9frontはもっと強い暗号スイートに対応しているので、必要なものを移植しようと思いました。とはいえTLSについて詳しくないので、何がどう関連しているのかを中心に既存実装を読んだメモです。
TLSの概要
TLSは、以下の要素(暗号スイート)で構成されている。
- 鍵交換(暗号化で利用する共通鍵の交換方法)
- 認証(なりすまし防止)
- これで証明書に含まれる公開鍵の種類が決まる
- 暗号化
- メッセージ認証(改ざん防止、ハッシュ)
RFC 5246 - TLS Protocol Version 1.2では、これらを一つの文字列に連結して、例えばTLS_RSA_WITH_AES_128_CBC_SHA256のようなIDとして表現している。このIDはTLS 1.2の場合、
// TLS 1.2 TLS_[鍵交換]_[認証]_WITH_[共通鍵暗号]_[メッセージ認証]
のように、それぞれの位置に対応するアルゴリズムを当てはめる。このとき鍵交換と認証がどちらもRSAの場合はTLS_RSA_WITH_とまとめられるようだった。この記事ではそこまで触れないが、TLS 1.3の場合は大幅に簡素化されて
// TLS 1.3 TLS_[AEAD]_[HASH]
となった。AEADは雑にいうと共通鍵暗号とメッセージ認証を同時に行うアルゴリズムで、AES_128_GCMなどがある。そのためTLS 1.3では、具体的にはTLS_AES_128_GCM_SHA256のようなIDとなる。
TLS一般的な話はこの記事が分かりやすかった。
Plan 9での実際
Plan 9では、複数のコンポーネントが関わってTLSを実現している。具体的には以下の3つ。
- カーネル(devtlsドライバ)
- libsec
- factotum
まずは簡単にTLS接続の流れを追う。
事前準備
サーバは、サーバ証明書をファイルシステム(例えば/sys/lib/tls/cert.pem)に保存しておき、対応する秘密鍵をホストオーナー(普通はbootes)のfactotumにロードしておく。factotumのデータはホストの再起動によって揮発するので、一般的には、再起動時にsecstoreから読み込むように構成する。
% auth/secstore -G factotum % cat /mnt/factotum/ctl key proto=rsa service=tls !p=... !q=...
鍵交換とシークレット生成
TLS接続を開始するとき、クライアントでもサーバでも、libsecの関数を使う。自身がサーバとなる場合はtlsServerを使い、クライアントとなる場合はtlsClientを使う。
#include <mp.h> #include <libsec.h> /* fdはTLS通信相手とのコネクション */ int tlsClient(int fd, TLSconn *conn); int tlsServer(int fd, TLSconn *conn);
上記どちらの関数も、渡したfdの内容をTLSで包んだ新しいファイルディスクリプタを返す。関数から返されたファイルディスクリプタは、戻った時点で暗号通信を開始した状態なので、呼び出した側はそのままTLSに乗せるプロトコル*1を喋れば良い。
tlsClientとtlsServerがやっていることをもう少し追っていくと、サーバでもクライアントでも、渡されたfdを介したTLSハンドシェイクで暗号スイートの決定と、それに従った鍵交換を行う。また、サーバなら「事前準備」で用意しておいた秘密鍵をfactotumから取り出してシークレットの生成に利用する。これで、暗号スイートのうち鍵交換と認証は完了していて、暗号とメッセージ認証のアルゴリズムも決まっていて、暗号化で使うシークレットも用意できたことになる。
次に、tlsClientまたはtlsServer関数はカーネルに処理を引き渡す。
カーネルの役割
Plan 9では、TLSレコードプロトコルはカーネルで実装している。上記で暗号に必要な値は全て決まったので、tlsClientまたはtlsServerはカーネルが提供するファイルに必要な値を書き込む。実際はCで書かれているが、擬似的には以下のような処理を行う。
n=`{cat '#a'/tls/clone} echo fd $fd >'#a'/tls/$n/ctl echo version $protoVersion >'#a'/tls/$n/ctl echo secret aes_128_cbc sha256 $isclient $secret >'#a'/tls/$n/ctl # '#a'/tls/encalgsと'#a'/tls/hashalgsを読むと利用可能な暗号関数、ハッシュ関数がわかる cat '#a'/tls/$n/hand # TLSハンドシェイクプロトコルする場合はこのファイルを読み書きする cat '#a'/tls/$n/data # TLSレコードプロトコルする場合はこのファイルを読み書きする
カーネルに実装されたdevtlsドライバは、最初は暗号化しないが、ctlファイルにシークレットが書き込まれた後は全ての通過するデータを暗号化または複合する。
強い暗号に対応するには
最初に書いたように、9legacyでサポートされている暗号スイートは、まだ禁止されてはいないものの推奨されなくなっている。なのでこのままではまずいのだが、どこを改善すると良いのか。暗号に関わるもののデバッグは困難なので、まずは簡単なところから対応すると良いのだろう。最初はTLS_RSA_WITH_AES_128_GCM_SHA256でAES_128_GCMを確認して、次にTLS_DHE_RSA_WITH_AES_128_GCM_SHA256でDHEに対応する方針が妥当に思える。意外とTLS_DHE_RSA_WITH_AES_256_GCM_SHA256の組み合わせは存在しなかった。
鍵交換
DHEまたはECDHEはTLS 1.3でも認められているので、この辺りなら良いだろうと思う。鍵交換はlibsecの鍵交換処理に追加すれば良い。やればいいだけなんだけれど、DHEとAES_128_CBCの組み合わせはなさそうだったので、先にAES_128_GCM対応が必要だと思う。
認証(証明書)
これは、factotumに新しくprotoを追加する必要があって、そうすると仮に9legacyへパッチを送ってもマージされるかどうかわからない。なので、やるとしても最後にやる。
AEAD
AEADは認証付き暗号と呼ばれるもので、AES_128_GCMもその一つ。
TLS 1.2プロトコル Appendixによると、以前までのTLSでは、ストリーム暗号とブロック暗号が考慮されていたところに、新しくAEAD暗号が追加されたらしい。
TLS 1.2のレコード層では、
struct ProtocolVersion { uint8 major; uint8 minor; }; enum { ChangeCipherSpec = 20, Alert = 21, Handshake = 22, ApplicationData = 23, } ContentType; /* 最初の平文 */ struct TLSPlaintext { ContentType type; ProtocolVersion version; uint16 length; uchar opaque[]; }; /* 暗号テキストは3つに分岐 */ struct GenericStreamCipher { /* あまり重要ではないので省略 */ }; struct GenericBlockCipher { uchar iv[]; /* データの長さはアルゴリズムによって決まる */ uchar content[]; /* データの長さはTLSCiphertext.length */ uchar mac[]; /* データの長さはアルゴリズムによって決まる */ uint8 padding[]; uint8 padding_length; }; struct GenericAEADCipher { uchar nonce[]; /* データの長さはアルゴリズムによって決まる */ uchar content[]; /* データの長さはTLSCiphertext.length */ }; struct TLSCiphertext { ContentType type; ProtocolVersion version; uint16 length; union { GenericStreamCipher stream; GenericBlockCipher block; GenericAEADCipher aead; }; };
のように分岐していて、これまでのブロック暗号では、暗号化とメッセージ認証を分けて計算していたが、分けて計算することによる問題があるらしく*2、AEADでは暗号化と同時に認証タグと呼ばれる値も生成する。この認証タグを、複合時にも使うものらしい。
ブロック暗号の場合
AEADと比べるために、最初にブロック暗号をみる。ブロック暗号で暗号化する場合、カーネルは例えば以下のようにデータを暗号化する。ここでは、暗号化はaes128_cbcで、メッセージ認証はsha256を使うと仮定する。
TLSCiphertext b; /* TLS 1.2 */ b.type = ApplicationData; b.version.major = 3; b.version.minor = 3; b.length = len(body); b.block.iv = (最初は空); b.block.content = body; b.block.mac = (最初は空); /* 面倒なのでパディングは省略 */ /* 未暗号化(ivとmacは空)の状態でハッシュ値を計算する */ b.block.mac = hmac_sha256(64bitシーケンス番号 + b); b.length += len(b.block.mac); /* 暗号化 */ b.block.iv = (乱数生成); /* 長さは暗号関数に依存する; 例えばAESは16バイト */ b.length += len(b.block.iv); b.length = aes128_cbc(&b.block, b.length);
これで、最終的にb.block
は暗号化されて、b.length
は暗号化されたb.block
の長さを持つ。ここで重要なのは平文をSHA256したハッシュ値を平文の末尾に加えて、それを暗号化しているところで、AEADの場合はここが異なる。
AEAD(認証付き暗号)の場合
ここではAES_128_GCMを使うと仮定して具体的な動きをみる。
まず前提として、AES128-GCMでは
- 平文
- 初期化ベクトル(IV)
- 追加データ(aad)
- 認証には利用されるが暗号化はしないデータ
を与えると、
- 暗号文
- 認証タグ
を返す。Plan 9(9front)の場合は以下の関数プロトタイプを持つ。
#include <mp.h> #include <libsec.h> void setupAESGCMstate(AESGCMstate *s, uchar *key, int keylen, uchar *iv, int ivlen); void aesgcm_setiv(AESGCMstate *s, uchar *iv, int ivlen); void aesgcm_encrypt(uchar *p, ulong n, uchar *aad, ulong naad, uchar tag[16], AESGCMstate *s); int aesgcm_decrypt(uchar *p, ulong n, uchar *aad, ulong naad, uchar tag[16], AESGCMstate *s);
もう少し具体的なコードでみると、
TLSCiphertext b; /* TLS 1.2 */ b.type = ApplicationData; b.version.major = 3; b.version.minor = 3; b.length = len(body); aad = 64bitシーケンス番号 + b; /* [seq:8][type:1][major:1][minor:1][len:2]で13byte */ iv[4:12] ^= aad[0:8]; /* IVの上位4バイトはそのまま残して、後ろ8バイトをNonceで埋める */ aesgcm_setiv(state, iv, len(iv)); aesgcm_encrypt(body, len(body), aad, len(aad), &tag, state); b.nonce = iv[4:12]; /* 暗号化に使ったivの末尾8バイトをメッセージに含める */ b.content = body + tag; /* 暗号化したbodyの後ろに認証タグを加える */ b.length = len(b.nonce) + len(body) + len(tag);
これをブロックごとに計算する。AEADに関する記事はこの辺りが面白い。
メッセージ認証の意味
ところで、上記でみたようにAEAD暗号ではメッセージ認証を計算しなくなっているのがわかる。なのでPlan 9(9front)のdevtlsでは、aes_128_gcm_aeadで暗号化する場合のハッシュ関数はclearを使うようになっている。だけどもTLSの暗号スイートには依然としてTLS_RSA_WITH_AES_128_GCM_SHA256のようにSHA256という名前が残っているが、これはどこで使っているのか。
RFC 5288 - AES Galois Counter Mode (GCM) Cipher Suites for TLSによると、TLS 1.2の場合はPRF(Pseudo Random Function)でだけ使うと書かれていた。なので鍵交換が終わってしまえば、それ以降使われることはないらしい。
TLS 1.3の場合は、HMACベース鍵導出関数(hkdf)に使うらしいが詳しくは調べていない。