はじめに
前回の記事で書いたように、SORACOM Inventoryエージェント「inventoryd」の技術面の記事です。
SORACOM Inventoryのプロトコルスタック
SORACOM Inventoryは以下のようなプロトコルスタックになっております。
基本的にはOMA LightweightM2M(LwM2M)のサーバーであり、SORACOM Airと連携してデバイス登録が簡単にできること、SORACOMのコンソール、WebAPIを用いてデバイスへの操作をしやすくしてくれていることが特長です。
SORACOM Inventory対応のエージェントを作成するためには、
- LwM2M(OMA LightweightM2M)
- CoAP(Constrained Application Protocol)
- DTLS(Datagram Transport Layer Security)
を実装する必要があります。(UDPはさすがにあるものとします)
これらが実装されたプログラムとしてSORACOMから紹介されているのは以下の2つです。
- Eclipse Wakaama (C言語)
- Eclipse Leshan (Java)
もちろんこれらのライブラリを使ったり、改変して使う方法もありますが、今回は上のプロトコルを理解したかったのと、個人的にGo言語を使ってみたかったのでGo言語で1から作ってみることにしました(業務では普通に既存のライブラリ使った方がいいと思います)
対応の方針
これらの規格やプロトコルは、当然規格書、RFCにより仕様が明確に定義されているのですが、その仕様をフル実装すると僕の時間がいくらあっても足りなくなってしまいます。そのためこのエージェントでは、
- SORACOM Inventoryで実際に使用されているLwM2Mの仕様
- そのLwM2Mで使用されているCoAPの仕様
- そのCoAPで使用されているDTLSの仕様
のみを実装することとしています。ご了承ください。
プロトコルのライブラリの作成者様は実際にどのくらい使用されるかわからない仕様も含めて全ての仕様を実装されており、とてもありがたいです。このエージェントのように、特定の使い方に結びついた実装だと、実装の手間は大幅に削減されますが、例えばサーバー側の仕様が変わった時にはその影響を大きく受けることになるので、その点は注意が必要ですね。
一記事で全て説明しようと思ったのですが、かなり長くなってしまったため、DTLS編とCoAP、LwM2M編に分けて投稿します。今回はDTLS編です。
DTLS編
まずは接続できるようにならなければ話にならないので、DTLSでの接続を確立することから始めます。SORACOM Inventoryのエージェント作りやってみるか!となった人が最初にはまるところは多分ここです。ここさえ乗り越えれば、後は気合いでなんとかなります。
DTLS対応ライブラリについて
Go言語の標準ライブラリにあるのか?と思って調べましたが、このinventorydを開発した2019/5時点、および2020/1/29現在においても、Go言語の標準ライブラリにてDTLSは提供されていないように思われます。(OpenSSLがDTLS 1.2に対応しているので、他の言語ではそれを利用する形が多いようです)
標準以外のGo言語のライブラリでは、Pion DTLSがかなり頑張ってDTLSを実装しているようです。ところがこのライブラリでは、SORACOM Inventoryが使う暗号スイートのTLS_PSK_WITH_AES_128_CCM_8が提供されていません、と書こうとしたのですが、今見ると普通に対応してましたね。。コミット見る限り対応したのは2019/6/30か。これからGo言語でSORACOM Inventoryのエージェントを書きたい、という方は、このライブラリを使ってもいいと思います。今回はそのまま自分の独自実装について書きます。
対応する暗号スイートについて
DTLSを実装するにあたり、どの暗号スイートに対応するか、というのが大きな選択になります。DTLSでは(もちろんTLSも)どの暗号スイートで通信するかをハンドシェイク時(接続確立時)に決め、接続が確立したらその暗号スイートで通信をします。普通はどこまで対応すれば良いのか、という問題になりますが、SORACOM Inventory対応、というかLwM2M対応ではさほど深く考える必要なく、TLS_PSK_WITH_AES_128_CCM_8を選択することになります。これは僕が勝手に決めたのではなく、CoAPのRFC 7252に以下のように記載されているからです。
9.1.3.1. Pre-Shared Keys
When forming a connection to a new node, the system selects an appropriate key based on which nodes it is trying to reach and then forms a DTLS session using a PSK (Pre-Shared Key) mode of DTLS. Implementations in these modes MUST support the mandatory-to-implement cipher suite TLS_PSK_WITH_AES_128_CCM_8 as specified in [RFC6655].
CoAPをDTLSでPSKモードを選択する際、TLS_PSK_WITH_AES_128_CCM_8の実装は必須です。従って、TLS_PSK_WITH_AES_128_CCM_8に対応しておけば問題なさそうです。
色々なところで解説されているように、暗号スイートは、
- 鍵交換方式
- 認証方式
- 暗号化アルゴリズム
- 暗号利用モード
- メッセージ認証方式
などの要素によって成り立ちます。暗号スイートはIANAという団体に登録されています。この団体はたとえばIPアドレスとかドメインなどのIT資源の管理をしている団体ですね。暗号スイートのリストは以下のページで確認することができます。
もう少し詳しい説明が以下のページにありましたので、こちらも紹介します。
https://ciphersuite.info/cs/TLS_PSK_WITH_AES_128_CCM_8/
これによると、TLS_PSK_WITH_AES_128_CCM_8は
- 鍵交換方式: PSK
- 認証方式: PSK
- 暗号化アルゴリズム: AES 128
- 暗号利用モード: CCM 8
- メッセージ認証方式: CCM 8
となります。暗号利用モードとメッセージ認証方式はそう書いていないですが、CCMというのはCounter with CBC-MACというメッセージ認証つき暗号利用モードで、これを使うと暗号化と認証を同時に実現できるというものです。
この暗号利用モードを見ると、公開鍵暗号やDiffie-Hellmanの鍵交換を使わないなど、かなり計算量や通信量の削減に寄ったモードだということが見て取れますね。公開鍵は例えばRSA暗号だと暗号化に、
c = m^{e} mod N
(cは暗号文、mは平文、e=65537が一般的?、Nは2048bit以上の数字)
という計算が必要で、色々工夫はされていますがかなり重い計算であることは間違いないです。Diffie-Hellmanの鍵交換も大きな数の指数計算および割り算が発生することは同じなので、やはり計算量は大きいです。
また通信量の削減という面でCCMをわざわざ8にしているところなんて、涙ぐましいとさえ言えますね(AES 128で普通にCBC-MACを作ると当然128bit = 16byteになるので、わざわざ8byte捨ててます)
ためしに一般的なTLS接続、たとえばこのqiitaにChromeを使ってhttpsで接続する場合の暗号スイートを確認したところ、TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256でした。
- 鍵交換方式: ECDHE
- 認証方式: RSA
- 暗号化アルゴリズム: AES 128
- 暗号利用モード: GCM
- メッセージ認証方式: SHA256
こちらは不特定多数との通信を安全ではない通信環境、つまりインターネットで安全に実施するため、計算量や通信量は大きくても良い、という考えに寄っていると考えられます。鍵交換はDiffie-Hellmanの鍵交換で毎回作成して捨てるECDHEモードですし、認証方法は公開鍵暗号のRSAのサーバー証明書を使って認証する方法で、計算量、通信量はともに多いです。
採用する暗号スイート1つとっても、リソースの限られたデバイスでどの程度セキュリティを確保するのか、の難しさと工夫が感じられますね。
DTLS(TLS_PSK_WITH_AES_128_CCM_8)接続の概観
まずはDTLSがどんな通信をしているのか、いつものごとくWiresharkのパケットキャプチャの様子を見てみましょう。(WiresharkがPCにインストールされている人とは友達になれそうだと勝手に思っています)
こちらは、Wakaamaで通信した時の様子です。
やりとりの内容を図にするとこんな感じです。
Change Cipher Spec(暗号化アルゴリズムとパラメータの確定)以降は暗号通信になっていることがわかりますね。ここで知って得するWiresharkテクニックなんですが、DTLS(TLSも)のPSKモードの場合、Wiresharkの設定でPSKを指定するとデコードして表示してくれます。以下のようにDTLSのパケットに合わせてProtocol Preferencesメニューを開き、
PSKを入力すると、
デコードされたパケットが表示されましたね。
先ほどの「Encrypted Handshake Message」となっていたところは、Finishedというメッセージだったことがわかります。またその復号後のメッセージも出ていますし、Application Dataとなっていたところも、CoAPのパケットであることがわかりました。先ほどのシーケンス図はこうなります。
これは便利ですね!便利なのですが、逆にセキュリティ(機密性)的な危なさを感じませんか?これは「PSK(事前共有鍵)」が分かれば、暗号通信の内容が全て解読されることが表されています。
最近は前方秘匿性(Forward secrecy)といって、暗号文を全て盗聴・保存されていることを想定し、あとで秘密鍵(長期鍵)が漏洩した時に全てのメッセージが解読されないようにするセキュリティ特性が注目されています。TLS1.3で鍵交換にRSAが使えなくなるのもその一環ですね。(以前はTLSの暗号鍵パラメータはRSAなどの公開鍵で暗号化して渡す、というのが一般的でしたが、現在は前方秘匿性の観点で一時鍵を作ってDH法で渡す方法が取られています)この観点で言えば、同じPSKを使い続ける、さらにその鍵がデバイス側にあって直接取り出されてしまう可能性がある、というのは、ちょっと問題のようにも思えます。幸いSORACOM InventoryではLwM2Mのブートストラップでの鍵発行に対応しているので、必要に応じて定期的にPSKを更新する、といった使い方も考えられますね。
DTLSの基本
DTLS1.2はRFC6347にて記載されているUDPによる暗号通信プロトコルです。実際色々やってみた感触としては、「UDPを暗号化するプロトコル」というよりは、「TLSをUDPでもできるようにするプロトコル」という感じがします。UDPの特長であるコネクションレス、つまり「状態を持たない」「再送がない」「順序制御がない」ということはなく、「状態をもつ」「再送がある(できる)」「順序制御がある(できる)」プロトコルです。RFCにも以下のように記載されています。
DTLS is deliberately designed to be as similar to TLS as possible, both to minimize new security invention and to maximize the amount of code and infrastructure reuse.
セキュリティに関する新しい要素を極力減らし、TLSのコードとインフラを可能な限り再利用するため、意図的に可能な限りTLSと同じになるよう設計している、とのことです。
とはいえ、完全に同じにはできません。特にTLSのハンドシェイクはパケットがかならず同じ順番で確実に届くことが想定されているため、届く順序が違っていたり(UDP自体にはシーケンス番号がないため、どの順序が正しいか受信側には分からない)、パケットロスが発生していたり(UDP自体には再送がないため、送信したパケットが届かないことがあり得る)すると問題が発生します。
そのため、DTLSでは明示的なシーケンス番号をパケットごとに持たせて受信パケットを番号順に並べ替える、ハンドシェイク時には期待される次のメッセージが届かない場合には再送タイマーを使って再送する、ハンドシェイク時に断片化されることを想定してフラグメントオフセットをメッセージに含め受信後再構成する、などの対応が取られています。
データ構造としてはこのような違いがあります。(RFC6347 4.3.1 Record Layerから抜粋)
struct {
ContentType type;
ProtocolVersion version;
uint16 epoch; // New field
uint48 sequence_number; // New field
uint16 length;
opaque fragment[DTLSPlaintext.length];
} DTLSPlaintext;
struct {
ContentType type;
ProtocolVersion version;
uint16 epoch; // New field
uint48 sequence_number; // New field
uint16 length;
select (CipherSpec.cipher_type) {
case block: GenericBlockCipher;
case aead: GenericAEADCipher; // New field
} fragment;
} DTLSCiphertext;
struct {
HandshakeType msg_type;
uint24 length;
uint16 message_seq; // New field
uint24 fragment_offset; // New field
uint24 fragment_length; // New field
select (HandshakeType) {
case hello_request: HelloRequest;
case client_hello: ClientHello;
case server_hello: ServerHello;
case hello_verify_request: HelloVerifyRequest; // New field
case certificate:Certificate;
case server_key_exchange: ServerKeyExchange;
case certificate_request: CertificateRequest;
case server_hello_done:ServerHelloDone;
case certificate_verify: CertificateVerify;
case client_key_exchange: ClientKeyExchange;
case finished: Finished;
} body; } Handshake;
順序を表す番号はepochとsequence_numberにわかれ、epochはChange Cipher specごとに加算される数字、sequence_numberはChange Cipher specでクリアされ、パケットごとに加算される数字です。
sequence_numberとHandshakeのmessage_seqは異なり、message_seqはハンドシェイクごとに加算されます。(ハンドシェイクではないChange Cipher Specでは加算されない)
fragment_offset、fragment_lengthはハンドシェイクパケットが断片化された際にその内容を伝えるものです。
DTLSのハンドシェイク時にはDTLSPlaintextのContentTypeが22(Handshake)、fragment部分がHandshake構造体となっているパケットをUDPで送受信し、ハンドシェイク後の暗号通信はDTLSCiphertextのGenericAEADCipherに暗号化+認証されたデータとしたパケットをUDPで送受信します。
ここまで書いておいてなんですが、実は今回は再送タイマーや並べ替え、フラグメントの再構築は実装していません。TLS_PSK_WITH_AES_128_CCM_8では証明書の取得やDH法による鍵交換がないためハンドシェイクのデータが小さくパケットが断片化されることはありませんし、再送や並べ替えにしても仮にUDPがロスして通信確立が失敗し、ハンドシェイクを最初からやり直してもさほど通信、CPU負荷などのコストがかからないためです。需要があればそのうち対応するかも知れませんが。。
それではDTLSのハンドシェイクをクリアし、暗号化通信できるように実装していきましょう。
DLTSのハンドシェイクで何を決めるのか?
DTLSのハンドシェイクでは採用する暗号化スイートの他に、その暗号化スイートで使用するセキュリティパラメータを決めなければなりません。具体的には、TLS_PSK_WITH_AES_128_CCM_8で通信するためには、
- client_write_key
- server_write_key
- client_write_iv
- server_write_iv
が共有されている必要があります。(RFC 5246参照)
クライアントの立場では、client_write_keyとclient_write_ivで認証データ作成+暗号化して送信し、server_write_keyとserver_write_ivで復号+認証データ検証して受信します。
ざっくりどんな感じでそれらを作るかを図にまとめました。
ClientKeyExchangeにてデバイスのIdentityを伝えることによりPSKを共有します。(サーバー側にはこのIdentityにより特定されるPSKがあるはず)また、毎回同じ鍵にならないようランダムなパラメータをクライアント、サーバー双方で作成し、ClientHelloおよびServerHelloで伝えます。
このPSK、ClientRandom、ServerRandomをもとに、Pre Master Secret、Master Secret、そしてclient_write_keyなどを含んだKey Blockを作る、という処理になります。
以下、具体的な計算に入っていきましょう。
各パラメータの生成方法
まずClient Randomですが、RFC5246 7.4.1.2 Client Helloによると、32byteのうち最初の4byteはUNIX時間(sec)、残りの28byteはSecureRandomなバイト列とありますので、そのように生成しましょう。(tinydtlsではUNIX Time部が0だったりします。なぜ?)そしてClientHelloでサーバーに送ります。
次にServer Randomですが、これはServer Helloのメッセージの中に入っているので、それをそのまま使えばよいでしょう。
また、PSKの共有は、RFC4279 2. PSK Key Exchange Algorithmによると、ClientKeyExchangeメッセージ内に、Identityを直接指定すれば良いようです。
これでパラメータ作成の元がクライアント、サーバー双方でそろいます。ここからPre Master Secret、Master Secret、Key Blockと順次算出していきます。
まずPSKからPre Master Secretを作る方法ですが、RFC4279 2. PSK Key Exchange Algorithmによると、
The premaster secret is formed as follows: if the PSK is N octets long, concatenate a uint16 with the value N, N zero octets, a second uint16 with the value N, and the PSK itself.
という方法で作ります。つまりPSKのバイト長をNバイトとすると、
uint16(N) + 0をNバイト分 + uint16(N) + PSK (+はバイト連結)
ということです。この記述見つけるの結構苦労した上、なんじゃこれみたいな感じですね。。素直にPSKそのまま使うじゃダメだったのかな。。
つぎにPre Master SecretからMaster Secretを作る方法ですが、これにはPRF(Pseudorandom Function)という関数が必要になります。PRFは生成元が予測できないようなランダムに見える結果を生成する関数です。入力が同じであれば同じ出力になるため、本来の意味のランダムではありません。これはハッシュ関数を用いて、以下のように定義されています。(RFC5246 5. HMAC and the Pseudorandom Function)
PRF(secret, label, seed) = P_hash(secret, label + seed)
P_hash(secret, seed) = HMAC_hash(secret, A(1) + seed) + HMAC_hash(secret, A(2) + seed)...
A(0) = seed
A(i) = HMAC_hash(secret, A(i-1))
HMAC_hashはTLS_PSK_WITH_AES_128_CCM_8ではSHA-256を使ったHMACです。SHA-256での計算結果は32byteなので、1回の計算で32byte生成されます。例えばMaster Secretは48byteなので、2回計算して64byteとして、48byteを取得します。
Goのコードではこんな感じです。
func dtlsPrf(secret []byte, label []byte, seed []byte, length int) []byte {
ret := []byte{}
a := []([]byte){append(label, seed...)}
for i := 0; len(ret) < length; i++ {
hashA := hmac.New(sha256.New, secret)
hashA.Write(a[i])
a = append(a, make([]byte, 32))
a[i+1] = hashA.Sum(nil)
hashRet := hmac.New(sha256.New, secret)
hashRet.Write(append(a[i+1], a[0]...))
ret = append(ret, (hashRet.Sum(nil))...)
}
return ret[:length]
}
aなどという変数名を使っていて怒られそうですが、RFCにAって書いてあるので仕方がないのです。。
適切なsecret、label、seedを渡してこの関数を実行することで、Master Secret、Key Blockを取得できます。
Master SecretはRFC 5246 8.1. Computing the Master Secretより、
secret = Pre Master Secret
label = "master secret"
seed = Client Random + Server Random
を渡して48byte取得します。
Key BlockはRFC 5246 6.3. Key Calculationより、
secret = Master Secret
label = "key expansion"
seed = Server Random + Client Random
を渡して40byte取得します。(必要なバイト数は暗号スイートにより変わります)Client RandomとServer Randomの順番が変わっているのが地味に間違いやすいので注意です。
これを計算することで、暗号通信のパラメータがクライアント-サーバー間で共有できたはずです。この時点でChange Cipher Specを送って暗号通信に切り替え、Finishedというメッセージを送ってハンドシェイクは終了です。
続けて、実際にどのようにデータを暗号化・復号し、認証データを生成・検証するのかに進みましょう。
TLS_PSK_WITH_AES_128_CCM_8における暗号化・復号および認証データの生成・検証
TLS_PSK_WITH_AES_128_CCM_8での暗号化は共通鍵暗号であるAES(128bit)によるブロック暗号を、CCM(Counter with CBC-MAC)という暗号利用モードで使用するものです。
AESについて
AESはブロック暗号と呼ばれる共通鍵暗号で、送信側と受信側が同じ鍵を使用し、決まった長さの平文を同じ長さの暗号文に変換します。ブロック暗号はDES、3DESなどもありますが、現在は推奨されていません。(政府推奨暗号リストCRYPTRECから外されている)AESも鍵長によってAES-128、AES-192、AES-256があり、この順番に鍵が長くなっていき、安全性が高くなる代わりに処理に時間がかかります。(ちなみに鍵長が192、256bitになっても、平文、暗号文の長さは128bitのままです)現在はAES-128がよく使われているようです。AESは鍵を使用して平文に対して複雑なビット演算を施し、原型を留めないように変換します。これが暗号化です。同じ鍵を使って逆変換を施すと元の平文に戻ります。これが復号です。AESの処理の詳細は記載しませんが、複雑ではあるものの8bitずつのビット演算で処理できるように設計されているため、公開鍵暗号のようにCPU負荷が高くならずに済みます。
暗号利用モードについて
次に暗号利用モードですが、ブロック暗号は決まった長さの暗号化しかできません。例えばAES 128bitの場合、128bitのデータの暗号化しかできません。通常は任意の長さの平文を暗号化して送受信したいと思いますので、それでは困るわけですね。そこでデータを分割したものにブロック暗号を適用し、各ブロックを結合して任意長の暗号化にする方法を暗号利用モードといいます。
一番簡単なものは、平文を単純にブロック長ごとに分割してブロック暗号を適用しそのまま結合したECB(Electronic Codebook Mode)モードと言われる暗号利用モードです。Wikipediaの説明図では以下のようになっています。
これは単純で良いのですが、同じ内容の平文ブロックが必ず同じ暗号ブロックになってしまうため、暗号文に平文の特長が残ってしまいます。それをもとに解読される危険性があるため、現在は推奨されていません。(CRYPTRECから外されている)CTRモードや認証付き暗号のCCM、GCMモードなどが利用されることが多く、TLS_PSK_WITH_AES_128_CCM_8ではCCMモードが用いられます。
残念ながらGo言語の標準ライブラリにはCCMモードの暗号化は入っていません。GCMはあるのに。。なので自分で実装しましょう。CCMは暗号化をCTRモードで行い、認証データとしてCBC-MACをつけるものです。(RFC 3610参照)この2つを分けて実施すればさほど苦労しません。
CTR(Counter)モードは平文そのものを暗号化するのではなく、Nonce(1回限り使用されるランダム要素を持つ値)と組み合わせたカウンターを暗号化し、その暗号化結果と平文のXORしたものを連結する方法です。
カウンターは一周しなければ同じ値が使われないのでECBのように同じ暗号文が出てしまう問題はなく、カウンターを用意して暗号化するのは実装も分かりやすく並列処理できるため効率も良い、という方法で、現在は安全性と効率性の面でこの暗号利用モードが推奨されています。うまいこと考えたものだな、と思いますね。
Go言語にはCTRモードの暗号化は提供されているため、(https://golang.org/pkg/crypto/cipher/#NewCTR) 適切なivを与えてこれを呼び出せば良さそうです。
MACについて
CCMでは、平文とともにMAC(Message Authentication Code)というデータも暗号化します。MACとは、データの作成者が意図通りであること(正しく鍵を交換した相手であること)、およびそのデータが改ざんされていないことを確認する為の固定長のデータです。鍵とメッセージをもとにハッシュ関数を使ってMACを作成するHMACが使用されることが多い印象ですが、CCMではCBC-MACというMACを使います。
CBC-MACは名前の通りCBCモードという暗号利用モードを利用します。CBCモードは、平文をブロック長ごとに分割し、1つ目のブロックとIV(初期ベクタ:同じ平文を同じ鍵で暗号化しても毎回違う結果になるようにするためのランダムなデータ)をXORしたものを暗号化し、その結果と2つ目のブロックをXORしたものを暗号化し、といったように、1つのブロックの暗号化結果を次の結果と混ぜることで、同じ内容の平文ブロックが同じ結果の暗号ブロックにならないようにする暗号利用モードです。
ブロック暗号を順次適用しなければならず、並列処理のできるCTRモードに比べて処理速度には劣るものの、現在でもよく使用されるモードです。CBC-MACはこのモードでIVを0×16byteとして暗号化した最後の暗号ブロックをMACとして使用します。(MACは同じ結果が得られなければならないためIVは固定です)
例によって、CBC-MACはGoの標準ライブラリにはありません。ですがCBCモードの暗号化はありますので、IVを0×16byteとして暗号化して最後のブロックを取得すればよいでしょう。
CCMの認証付き暗号の生成
RFC3610 2.1 Inputsによると、CCMの暗号化の入力は以下の4つです。
- ブロック暗号のキー
- nonce(同じブロック暗号キーの暗号化では複数回使用してはいけない)
- 平文
- aad(aditional authenticated data:追加認証データ)
ブロック暗号キーはハンドシェイクによって取得したclient write key、平文は送信対象のデータなので自明です。
aadについては、CCMでは暗号のコンテキストを表すデータを追記することができ、(D)TLSにおいては
additional_data = seq_num + TLSCompressed.type + TLSCompressed.version + TLSCompressed.length;
を使用することになっています。(RFC5246 6.2.3.3. AEAD Ciphers参照)
seq_numはTLSではTCPのシーケンス番号ですが、DTLSの場合はDTLSのエポック + シーケンス番号が使用されます。typeはDTLSのContent-Type(ほぼApplicationData = 23です)、versionはDTLSのバージョン0xFEFD(DTLSではTLSと区別するため負号がついたようなバージョン形式になります。FEFDはDTLS 1.2を表します)、lengthは平文のデータ長です。
nonceの作り方はRFC 6655 3. RSA-Based AES-CCM Cipher Suitesに示されています。
struct {
uint32 client_write_IV; // low order 32-bits
uint64 seq_num; // TLS sequence number
} CCMClientNonce.
ハンドシェイクで取得したclient write IVはここで使用されるのですね。
この入力を用いて、まずCBC-MACの入力を組み立てます。
1バイト目は以下の内容のフラグです。
Bit Number Contents
---------- ----------------------
7 Reserved (always zero)
6 Adata (aadがあれば1、なければ0。DTLS 1.2では1)
5 ... 3 M' (MACのバイト長をMとすると M' = (M - 2) / 2。CCM-8ではM = 8)
2 ... 0 L' (メッセージ長フィールドのバイト長をLとするとL' = L - 1。L = 15 - nonceのバイト長 = 3)
2〜13バイト目はnonce、14〜16バイト目はメッセージ長です。この後aadの長さ、aadと続き、aadの長さによって分岐するのですが、上で見たとおりDTLS 1.2で使う際にはaadは必ず13byteになりますので、以下のケースの対応のみで問題ありません。
If 0 < l(a) < (2^16 - 2^8), then the length field is encoded as two octets which contain the value l(a) in most-significant-byte first order.
つまりaadの長さである13をビッグエンディアンで2byte連結します。その後aadを13byteを連結し、1byteを0パディングして32byteとします。
ここまでの32byteに平文(ブロック長で0パディング済)を連結したデータを、CBC-MACでMACを生成すると、CCMでのMACになります。MAC生成部分をGo言語で書くと以下のようになります。(CBC暗号化の最後の16byteを取得しています)
func dtlsGenerateMAC(aad []byte, nonce []byte, length uint16, paddedData []byte, key []byte) []byte {
flag := (1 << 6) + (((dtlsAesCcmMACLength)-2)/2)<<3 + ((dtlsAesCCMLength) - 1)
blocksForMAC := make([]byte, 2*aes.BlockSize)
blocksForMAC[0] = flag
copy(blocksForMAC[1:13], nonce)
binary.BigEndian.PutUint16(blocksForMAC[14:16], length)
binary.BigEndian.PutUint16(blocksForMAC[16:18], (uint16)(len(aad)))
copy(blocksForMAC[18:(18+len(aad))], aad)
blocksForMAC = append(blocksForMAC, paddedData...)
block, err := aes.NewCipher(key)
if err != nil {
return nil
}
// CBC-MACのIVは全て0の16byte
iv := make([]byte, aes.BlockSize)
cbc := cipher.NewCBCEncrypter(block, iv)
cipherText := make([]byte, len(blocksForMAC))
cbc.CryptBlocks(cipherText, []byte(blocksForMAC))
return cipherText[len(cipherText)-aes.BlockSize:]
}
MACができたらCTRモードで暗号化します。RFC 3610 2.3. Encrpitonより、CTRモードのカウンターは以下の形を取ります。
Octet Number Contents
------------ ---------
0 Flags
1 ... 15-L Nonce N (client write IV + sequence)
16-L ... 15 Counter i (i = 0, 1, 2...ブロック長分)
Flags
Bit Number Contents
---------- ----------------------
7 Reserved (always zero)
6 Reserved (always zero)
5 ... 3 Zero
2 ... 0 L' (MACの計算時と同じ: 2)
このカウンターブロック(i=0, 1, 2...)をそれぞれAESにて暗号化し、i = 0のブロックの先頭8byteををMACの先頭8byteと、i=1, 2...のブロック列を平文メッセージとその長さ分XORを取り、暗号文を先に、暗号化MACを後につないだものが最終的なAES-128-CCM-8の暗号文になります。
Go言語の実装としては、Flagsとnonceと3byteのパディングをしたブロックをivとし、client write keyを与えたAESブロックと合わせて、NewCTRを呼び出して、MACと平文の結合を暗号化すれば良いです。
plainText := append(mac, paddedData...)
block, err := aes.NewCipher(dtls.ClientWriteKey)
counterIV := make([]byte, aes.BlockSize)
counterIV[0] = dtlsAesCCMLength - 1
copy(counterIV[1:13], nonce)
cipherText := make([]byte, len(plainText))
stream := cipher.NewCTR(block, counterIV)
stream.XORKeyStream(cipherText, plainText)
encryptedMac := cipherText[0:dtlsAesCcmMACLength]
encryptedData := cipherText[aes.BlockSize:(aes.BlockSize + len(data))]
ここで生成した暗号文は、RFC5246 6.2.3.3. AEAD Ciphersに以下のように記載されているうちの、contentの部分です。
struct {
opaque nonce_explicit[SecurityParameters.record_iv_length];
aead-ciphered struct {
opaque content[TLSCompressed.length];
};
} GenericAEADCipher;
その直前にあるnonce_explicitというのは、シーケンス番号(DTLSの場合はエポック + シーケンス番号)のことです。
nonceの説明RFC 6655 3. RSA-Based AES-CCM Cipher Suitesに
The nonce_explicit MAY be the 64-bit sequence number
と記載されています。この部分を追加してGenericAEADCipher構造体(fragment)が生成できると、以下のDTLSCiphertext構造体がようやく作れます。
struct {
ContentType type;
ProtocolVersion version;
uint16 epoch;
uint48 sequence_number;
uint16 length;
GenericAEADCipher fragment;
} DTLSCiphertext;
これepochとsequence_numberがDTLSCiphertextに入っているのに、なぜGenericAEADCipherにもまた入れるのかが謎ですよね。でもこれはDTLSだから構造体の中にepochとsequence_numberが入っているのであって、TLSの場合は以下のようにsequence_numberは入っていません。
struct {
ContentType type;
ProtocolVersion version;
uint16 length;
GenericAEADCipher fragment;
} TLSCiphertext;
こうなるとfragmentの中にsequence_numberを入れておかないとTLSだけで復号できなくなるので、入っているのかなという理解です。
ともあれこれで暗号文を作ることができました。これをUDPで送信すれば良いですね。ここまでの流れが文章だけだとわかりにくいので、図にまとめました。矢印で見づらいのはご勘弁を。
CCMの認証付き暗号の復号
復号は暗号と逆の手順で行えば良いです。
struct {
ContentType type;
ProtocolVersion version;
uint16 epoch;
uint48 sequence_number;
uint16 length;
GenericAEADCipher fragment;
} DTLSCiphertext;
の形でサーバーからパケットが届きますので、fragmentを読み取り、fragmentの中の
struct {
opaque nonce_explicit[SecurityParameters.record_iv_length];
aead-ciphered struct {
opaque content[TLSCompressed.length];
};
} GenericAEADCipher;
nonce_explicitとserver write ivからnonceを生成します。
content部の末尾8byteは暗号化MACなのでこれを16byteにパディングして先頭に、残りの部分をその後に連結して16byteでパディングしたものに対し、作成したnonceをもとにしたCTRモードで暗号化すれば復号されます。(CTRの暗号化は復号と同じ処理)MAC、平文はパディングした分を取り除きます。これで平文は取得できます。その後、平文をもとに暗号化の際と同じ手順でMACを作成すると、そのMACの先頭8byteと、contentから復号したMACの先頭8byteは一致するはずです。一致しなければ、データの改ざんがあった、もしくは共通の鍵を持っていない相手からの通信だということがわかります。このように、復号と改ざん検知を同時にできるのが、認証付き暗号の特長です。素晴らしいですね。
Finishedメッセージの作成、送信と受信、検証
さて、ここまで2万文字以上の長きにわたり戦いを繰り広げてきて、そろそろいい加減にしろ、という感じだと思うのですが、まだ最後の戦いが残されています。最初の方で見た、Finished(Encrypted)の送受信がまだできていないのです。Finishedを双方送って、双方検証OKとなって始めて、DTLSのハンドシェイクは完遂(Finished)されるのですね。Finishedは何かというと、あまり長くないのでRFC6347 4.2.6. CertificateVerify and Finished Messagesをそのまま引用すると、
CertificateVerify and Finished messages have the same format as in TLS. Hash calculations include entire handshake messages, including DTLS-specific fields: message_seq, fragment_offset, and fragment_length. However, in order to remove sensitivity to handshake message fragmentation, the Finished MAC MUST be computed as if each handshake message had been sent as a single fragment. Note that in cases where the cookie exchange is used, the initial ClientHello and HelloVerifyRequest MUST NOT be included in the CertificateVerify or Finished MAC computations.
ハンドシェイクメッセージ全てを単一のフラグメントとして送信されたように考えて、TLSと同じようにFinishedメッセージを計算しろ、ただしCookieを使っている場合は、最初のClientHelloとHelloVerifyRequestは計算に入れるな、ということです。
Cookieというのは、最初のClientHelloメッセージに対し、HelloVerifyRequestが返ってくるのですが、そこでサーバーから送信されるものです。次のClientHelloはこのCookieをつけて送信します。逆に言えば、CookieのつかないClientHelloは次に進めないようになっています。この理由はDoS攻撃対策であり、RFC6347 4.2.1. Denial-of-Service Countermeasuresで説明されています。
- Cookieがないと次に進まないので、IPを偽装してCertificateメッセージのような大きなメッセージを攻撃相手に受信させる増幅攻撃が困難になる
- Stateless Cookieという、クライアントのIPアドレス、DTLSのクライアントパラメータ、および定期的に変更されるSecretから計算されるCookieを用いるため、Cookieの無いClientHelloを送るだけではサーバーにDTLS接続状態を持つ必要が無く、DoS攻撃によるサーバーのメモリ消費を抑えることができる
という特長を持つものです。DTLSサーバーでは実装すべきとされています。つまりサーバーはCookie付きのClientHelloが送られるまでのことは覚えていないので、その部分をFinishedの計算には入れないというのも頷けます。
「TLSと同じようにFinishedメッセージを作る」と書いているので、TLSの方(RFC 5246 7.4.9. Finished)を読むと、
struct {
opaque verify_data[verify_data_length];
} Finished;
verify_data
PRF(master_secret, finished_label, Hash(handshake_messages))[0..verify_data_length-1];
finished_label
For Finished messages sent by the client, the string "client finished".
For Finished messages sent by the server, the string "server finished".
ということなので、Finishedメッセージの内容はverify_dataという12byteのデータで、verify_dataはsecretをmaster_secret、labelが送信の場合は"client finished"、受信の場合は"server finished"とし、seedがhandshake_messagesをSHA-256でハッシュ化したものを入力としたPRF関数の結果です。
handshake_messagesについては、
The value handshake_messages includes all handshake messages starting at ClientHello up to, but not including, this Finished message.
とあるので、ClientHelloから始まって、「この」Finishedを含まない、すべてのハンドシェイクメッセージを連結したもの、ということです。ハンドシェイクメッセージとは以下の部分であり、DTLSのエポックやシーケンス番号などの部分は含みません。
struct {
HandshakeType msg_type;
uint24 length;
uint16 message_seq;
uint24 fragment_offset;
uint24 fragment_length;
select (HandshakeType) {
case hello_request: HelloRequest;
case client_hello: ClientHello;
case server_hello: ServerHello;
case hello_verify_request: HelloVerifyRequest;
case certificate:Certificate;
case server_key_exchange: ServerKeyExchange;
case certificate_request: CertificateRequest;
case server_hello_done:ServerHelloDone;
case certificate_verify: CertificateVerify;
case client_key_exchange: ClientKeyExchange;
case finished: Finished;
} body; } Handshake;
実際やってみたのですが、Finishedに使うハンドシェイクメッセージにはいくつかの罠があります。この図で説明すると、
- ClientのメッセージとServerのメッセージの両方を順に連結する
- 最初のClient Hello、Hello Verify Requestは含まない
- Change Cipher Specは含まない(これはハンドシェイクメッセージではない)
- 最後のFinished受信の計算には、直前のFinished送信のメッセージの平文時のメッセージを連結する(Finishedは含めない、ではなく「この」Finishedは含めない、と書かれている)
罠でも何でもなく全部書かれているのですが、RFC参照せずに実装していると間違いがち。
つまり、送信するFinishedに使うハンドシェイクメッセージは2つ目のClient Hello、Server Hello、Server Hello Done、Client Key Exchangeの連結、
受信したFinishedメッセージの検証に使うハンドシェイクメッセージは上のメッセージにFinished送信の平文のメッセージを連結したもの、ということになります。
verify_dataが作成できたら暗号化してFinishedメッセージを送信し、またサーバーからのFinishedメッセージを復号して検証してみましょう。無事Finished送信が受理され、Finished受信が検証できた時、かなりの感動が湧き上がってきますよ。ぜひ一度試してみてください。Goじゃない言語で試してみるのもいいかも。
これでDTLSの接続は終わりです。今度こそお疲れ様でした!
おわりに
DTLSの独自実装などという高い壁に無謀にも突っ込んでいったのですが、ゴールデンウイークのうち5日程度をつぶすくらいでなんとかなりました。僕はもともとハードウェア開発者なので、どんな高度なネットワークだろうが最終的には電圧が上がったり下がったりしてるだけだからなんとかなるだろ、という謎の信念があったためやり通せた面もあります。まあAESやSHA-256などを独自実装したわけではないので、全て自分でなどとは言えないのですが、それでもかなり暗号やネットワークの勉強になりました。暗号は本当に人類最高の叡智の一つだと思います。「暗号技術のすべて」などを読んで勉強しました。
記事を読んでいるとRFCを読んでそのまますんなり接続できたように見えますが、実際は全然そんなことはなく、何回も何回も失敗しまくりながら少しずつ進んでいき、ようやく接続できています。特に暗号化や復号がちゃんとできているかは最終的にFinishedが成功することでしか確かめられないので、そのFinishedを成功させるのにはとても苦労しました。必要だったものは、動かせて改変できる参照実装(今回はWakaama)、パケットキャプチャ(Wireshark)、接続相手の実際のサーバー(SORACOM Inventory(使っていいとは言われてない))の3つですね。作りながら参照実装とパケットが違うところを見たり、参照実装の方の途中経過を出力してみたり、CookieやClient Randomなどをむりやり固定した時にどんな出力になるかを検証してみたり、などしながら進めてました。
やってみてわかったのは、プロトコルの仕様と実装は違うもので、僕たちが使っているものは実装に過ぎないということですね。世界中で使われるOSやライブラリの開発者と言っても僕たちと同じ人間であって、仕様が読めていなかったり解釈が違っていたり、この仕様の実装をどうしようかと悩むところもあるはずです。仕様ができたらそれを完璧に実現した実装が自動的に現れる、というようなもんじゃないよな〜、ということを感じます。この実装があるのは当たり前ではない。プロトコルスタックを開発している皆さんには感謝しなければなりませんね。
SORACOM Inventoryと関係ないじゃん、と思われるかも知れませんが、実はこの暗号スイートTLS_PSK_WITH_AES_128_CCM_8において一番重要なのはPSKで、それを安全に渡しているのがSORACOM Inventoryのブートストラップサーバー、という関係があります。PSKが安全に共有できていることを根拠に、PSKをもとにサーバーとクライアントで同じ暗号鍵を作れるということをもって、サーバー認証、クライアント認証、鍵交換の全てをそこに任せています。そしてSIMがあればいつでも鍵が変更できる。いったん鍵が交換されれば、SORACOM Airのネットワーク外からでもSORACOM Inventoryを安全に使えるようになるのもいいところです。とはいえ、SORACOM Airネットワーク内であれば、SIM認証を使ってクライアント認証をしつつDTLSではないUDPを使った簡単なCoAPでSORACOM Inventoryが使えるようになると、だいぶ楽にInventoryのエージェントが作れるようになるので、そこは今後の期待ですね。
今回の記事は以上です!
終わってない
まだCoAP、LwM2Mの説明が残っているんだよな。。