PSA APIs 08 · AEAD — 暗号化と改ざん検出を 1 つの操作で

Chapter 08

AEAD — 暗号化と改ざん検出を 1 つの操作で

この章のゴール.

psa_aead_encrypt() / psa_aead_decrypt() の引数(ノンス・追加データ・平文・タグ)の役割を理解し、 ノンスの管理を設計でき、分割版の set_lengths の意味を説明できるようになること。 データを暗号化する場面で、迷わずこの章の関数を選べるようになること。

この章で使う既出の用語(定義は各リンク先). PSA(01 章 1 節)、ビット(02 章 4 節)、algorithm(03 章 11 節)、type(03 章 11 節)、場所(03 章 7 節)、generate(04 章 10 節)、import(04 章 10 節)、MAC(06 章 1 節)、AES(07 章 1 節)、CTR(07 章 9 節)、対称暗号(07 章 1 節)

1. AEAD とは何か

AEAD(Authenticated Encryption with Associated Data、関連データ付き認証暗号)は、 暗号化(機密性)と MAC(完全性・認証)を 1 つの操作にまとめた暗号方式である。 07 章で述べた「対称暗号を単独で使うな」に対する、標準の答えがこれである。

AEAD は次の 4 つを入力に取る。

入力役割秘密か出力に含まれるか
鍵対称鍵(AES など)秘密含まれない
ノンス(nonce、number used once)一度きりの値。対称暗号の IV に相当公開でよい自分で送る(PSA の一発関数は出力に含めない)
追加データ(AD、associated data)暗号化はしないが改ざんは検出したいデータ。ヘッダ、宛先、バージョン番号など公開含まれない(受信側も同じものを持っている前提)
平文暗号化して守りたいデータ秘密暗号文として

出力は 暗号文 ∥ タグ(tag。MAC に相当する 16 バイトの認証値)である。 復号側は鍵・ノンス・追加データ・暗号文・タグをすべて揃えて渡し、1 ビットでも違えば復号は PSA_ERROR_INVALID_SIGNATURE で失敗し、平文は出てこない。

AEAD の 4 つの入力と 2 つの出力鍵秘密。出力に含まれないノンス一度きり。公開でよい追加データ AD暗号化しない。改ざん検出する平文秘密AES-GCMpsa_aead_encrypt暗号文(平文と同じ長さ)タグ(16 バイト)AD と暗号文の両方に対する認証値PSA の出力は「暗号文 ∥ タグ」の 1 本復号側は 4 つの入力とタグをすべて揃える。1 ビット違えば INVALID_SIGNATURE で平文は出ない
追加データは「暗号化しないが書き換えられると困る」ヘッダ類。宛先やバージョンをここに入れる

「追加データ」という概念が初心者には分かりにくい。 例えば無線パケットの宛先アドレスは、中継器がルーティング(次にどこへ送るかの判断)に使うので暗号化できない。 しかし攻撃者に宛先を書き換えられては困る。 そこで宛先を追加データとしてタグの計算に含める——暗号化されないが、書き換えればタグが合わなくなる。 これが「関連データ付き」の意味である。

2. アルゴリズム

アルゴリズム定数鍵種別ノンス長タグ長特徴
PSA_ALG_GCMAES12 バイト推奨16標準。TLS 1.3 の既定。ハードウェア AES で高速。ノンスの繰り返しに極端に弱い
PSA_ALG_CCMAES7〜13 バイト(通常 13)16(4〜16 の偶数)無線規格(Zigbee、Bluetooth LE、Thread——いずれも IoT 向けの低消費電力無線)の標準。AES の暗号化方向だけで実装できるので小さい。平文長を先に知る必要がある
PSA_ALG_CHACHA20_POLY1305PSA_KEY_TYPE_CHACHA201216AES ハードウェアがない CPU で高速。ソフトウェアでもサイドチャネルに強い

タグを短くしたいときは PSA_ALG_AEAD_WITH_SHORTENED_TAG(PSA_ALG_CCM, 8) のように書く(MAC の切り詰めと同じ考え。4 バイト未満は避ける)。 ノンス長は PSA_AEAD_NONCE_LENGTH(key_type, alg)、タグ長は PSA_AEAD_TAG_LENGTH(key_type, key_bits, alg) で得られる。

鍵の属性は対称暗号と同じく PSA_KEY_USAGE_ENCRYPT | PSA_KEY_USAGE_DECRYPT と、アルゴリズム定数である。

3. 一発関数

psa_status_t psa_aead_encrypt(psa_key_id_t key, psa_algorithm_t alg,
                              const uint8_t *nonce, size_t nonce_length,
                              const uint8_t *additional_data, size_t additional_data_length,
                              const uint8_t *plaintext, size_t plaintext_length,
                              uint8_t *ciphertext, size_t ciphertext_size, size_t *ciphertext_length);

psa_status_t psa_aead_decrypt(psa_key_id_t key, psa_algorithm_t alg,
                              const uint8_t *nonce, size_t nonce_length,
                              const uint8_t *additional_data, size_t additional_data_length,
                              const uint8_t *ciphertext, size_t ciphertext_length,
                              uint8_t *plaintext, size_t plaintext_size, size_t *plaintext_length);

引数は多いが、1 節の表の順に並んでいるだけである。 対称暗号との最大の違いは、ノンスを呼ぶ側が渡すことである(一発関数でも自動生成しない)。 これは、AEAD ではノンスの管理方法(乱数かカウンタか)がプロトコルごとに違い、ライブラリが決められないからである。

#define NONCE_LEN PSA_AEAD_NONCE_LENGTH(PSA_KEY_TYPE_AES, PSA_ALG_GCM)   /* 12 */
uint8_t nonce[NONCE_LEN];
uint8_t header[4] = { 0x01, 0x00, dst_id, msg_type };                     /* 追加データ */
uint8_t ct[PSA_AEAD_ENCRYPT_OUTPUT_SIZE(PSA_KEY_TYPE_AES, PSA_ALG_GCM, plen)];  /* plen + 16 */
size_t ct_len;

PSA_CHECK(psa_generate_random(nonce, sizeof nonce));
PSA_CHECK(psa_aead_encrypt(key, PSA_ALG_GCM,
                           nonce, sizeof nonce,
                           header, sizeof header,
                           plain, plen,
                           ct, sizeof ct, &ct_len));
/* 送るもの: header ∥ nonce ∥ ct(ct の末尾 16 バイトがタグ) */

受信側:

uint8_t pt[PSA_AEAD_DECRYPT_OUTPUT_SIZE(PSA_KEY_TYPE_AES, PSA_ALG_GCM, ct_len)];   /* ct_len - 16 */
size_t pt_len;
st = psa_aead_decrypt(key, PSA_ALG_GCM, nonce, 12, header, 4, ct, ct_len, pt, sizeof pt, &pt_len);
if (st == PSA_ERROR_INVALID_SIGNATURE) {
    /* 改ざん・鍵違い・ノンス違い・追加データ違いのどれか。pt は使わない */
}

復号が失敗したら平文バッファの中身を一切使ってはいけない。 仕様は「失敗時は出力バッファに平文の一部も残さない」ことを実装に求めているが、それに頼らず、アプリケーション側でも使わないのが作法である。

送るフレームの形header(AD)平文。宛先・種別・番号nonce 12乱数またはカウンタciphertext平文と同じ長さtag 16psa_aead_encrypt の出力末尾psa_aead_decrypt(key, GCM, nonce, 12, header, hlen, ciphertext∥tag, clen+16, plain, size, &plen)カウンタノンスなら nonce フィールドは省略できるheader のシーケンス番号から受信側がノンスを復元する。その場合も AD に header を含める
暗号文 ∥ タグは psa_aead_encrypt の出力そのもの。復号にはヘッダとノンスも必要

4. ノンスの管理 — AEAD で唯一難しいところ

AEAD の安全性は、同じ鍵で同じノンスを二度使わないことにかかっている。 GCM でノンスが繰り返されると、認証鍵が漏れて以後すべてのメッセージが偽造できるようになる。 CTR の IV 繰り返し(07 章)よりさらに悪い。

ノンスの決め方は 2 通りある。

方式やり方長所短所
乱数psa_generate_random() で 12 バイト作り、暗号文と一緒に送る状態を持たなくてよい96 ビットなら 2^32 回(43 億回)程度で衝突の心配が出る。同じ鍵の寿命を制限する
カウンタ送信カウンタ(例: 4 バイトの装置 ID + 8 バイトの通し番号)をノンスにする衝突しない。ノンスを送らなくてもよい場合があるカウンタを不揮発に保存しないと、再起動で繰り返す。複数の送信者で同じ鍵を使うなら装置 ID で区別する

マイコンで最も多い事故は、カウンタ方式で再起動後にカウンタが 0 に戻ることである。 対策は、(a) 起動ごとに乱数の「世代番号」をノンスの一部に入れる、(b) カウンタを一定間隔(例: 1000 回ごと)で不揮発ストレージ(13 章)に保存し、起動時に「保存値 + 1000」から始める、のどちらかである。

ノンスの 2 つの決め方乱数psa_generate_random 12 バイト状態不要。暗号文と一緒に送る。2^32 回程度で衝突の心配(鍵の寿命を制限)カウンタ装置 ID 4起動世代 2通し番号 6衝突しない。ただし再起動で戻ると致命的再起動対策(カウンタ方式)(a) 起動ごとに乱数の世代番号を入れる (b) 1000 回ごとに ITS に保存し、起動時は「保存値 + 1000」から再開するGCM でノンスが繰り返されると認証鍵が漏れ、以後すべてのメッセージが偽造できるCTR の IV 繰り返しよりさらに悪い
乱数は簡単だが鍵の寿命に上限、カウンタは再起動対策が必須

鍵の寿命.

同じ鍵で暗号化してよい回数には上限がある。 GCM で乱数ノンスなら 2^32 メッセージ、カウンタノンスでも 2^32 ブロック(64 GB)あたりが目安である。 IoT 機器の一生では届かないことが多いが、「鍵を定期的に更新する」設計(10 章の鍵導出でセッション鍵を作る)が本来の形である。

5. 分割関数

psa_aead_operation_t op = PSA_AEAD_OPERATION_INIT;
psa_status_t psa_aead_encrypt_setup(psa_aead_operation_t *op, psa_key_id_t key, psa_algorithm_t alg);
psa_status_t psa_aead_decrypt_setup(psa_aead_operation_t *op, psa_key_id_t key, psa_algorithm_t alg);
psa_status_t psa_aead_set_lengths(psa_aead_operation_t *op, size_t ad_length, size_t plaintext_length);
psa_status_t psa_aead_generate_nonce(psa_aead_operation_t *op, uint8_t *nonce, size_t nonce_size, size_t *nonce_length);
psa_status_t psa_aead_set_nonce(psa_aead_operation_t *op, const uint8_t *nonce, size_t nonce_length);
psa_status_t psa_aead_update_ad(psa_aead_operation_t *op, const uint8_t *input, size_t input_length);
psa_status_t psa_aead_update(psa_aead_operation_t *op, const uint8_t *input, size_t input_length,
                             uint8_t *output, size_t output_size, size_t *output_length);
psa_status_t psa_aead_finish(psa_aead_operation_t *op, uint8_t *ciphertext, size_t ciphertext_size, size_t *ciphertext_length,
                             uint8_t *tag, size_t tag_size, size_t *tag_length);
psa_status_t psa_aead_verify(psa_aead_operation_t *op, uint8_t *plaintext, size_t plaintext_size, size_t *plaintext_length,
                             const uint8_t *tag, size_t tag_length);
psa_status_t psa_aead_abort(psa_aead_operation_t *op);

関数が多いが、順番は決まっている。

  1. encrypt_setup または decrypt_setup
  2. set_lengths(CCM では必須、GCM では省略可)——追加データと平文の合計長を先に宣言する。CCM はノンスと長さをヘッダに含めて計算を始めるため、長さが先に要る
  3. generate_nonce(暗号化のみ)または set_nonce
  4. update_ad を 0 回以上——追加データを流し込む。update の前に全部済ませる
  5. update を 0 回以上——平文(または暗号文)を流し込み、暗号文(または平文)を受け取る
  6. finish(暗号化: 残りの暗号文とタグを返す)または verify(復号: 残りの平文を返し、タグを照合)
psa_aead_operation_t op = PSA_AEAD_OPERATION_INIT;
uint8_t nonce[12], tag[16]; size_t nonce_len, tag_len, n, total = 0;

PSA_CHECK(psa_aead_encrypt_setup(&op, key, PSA_ALG_GCM));
PSA_CHECK(psa_aead_set_lengths(&op, hdr_len, body_len));
PSA_CHECK(psa_aead_generate_nonce(&op, nonce, sizeof nonce, &nonce_len));
PSA_CHECK(psa_aead_update_ad(&op, hdr, hdr_len));
for (each chunk of body) {
    PSA_CHECK(psa_aead_update(&op, chunk, chunk_len, out + total, out_size - total, &n));
    total += n;
}
PSA_CHECK(psa_aead_finish(&op, out + total, out_size - total, &n, tag, sizeof tag, &tag_len));
total += n;

復号の分割版には、決定的な落とし穴がある。 psa_aead_update() は、タグを照合する前に平文を返す。 つまり、verify で失敗するまで、アプリケーションは「改ざんされているかもしれない平文」をすでに受け取っている。 仕様は「verify が成功するまで、update の出力を信用してはならない」と明記している。 大きなファームウェアイメージを分割復号してフラッシュに書く場合、verify が失敗したら書いた領域を無効化する手順が必須である。 (一部の実装は、この危険を避けるために update の出力を内部に溜めて verify まで返さない。その場合は update の出力長が 0 になることがある。)

AEAD の分割関数の順序encrypt_setup / decrypt_setupset_lengths(ad, pt)CCM は必須generate_nonce / set_nonceupdate_ad × nAD を先に全部update × n平文 → 暗号文(または逆)finish残りの暗号文 + タグverify残りの平文、タグを照合abortエラー時は必ず復号の分割版: verify が成功するまで update の出力を信用しないタグの照合前に平文が返る。フラッシュに書きながら復号するなら、verify 失敗時に書いた領域を無効化する
AD を先に全部、次に本文、最後に finish(暗号化)または verify(復号)

6. AEAD を使う場面の設計例

6.1 フレーム形式

[header (AD)] [nonce] [ciphertext] [tag]

6.2 フラッシュ上の設定データ

「暗号化しない MAC 付き設定」(06 章)を「AEAD で暗号化した設定」にすると、内容も隠せる。 ノンスは書き換えごとに新しく乱数で作り、データと一緒に保存する。 AD にはストレージ上の位置やバージョンを入れておくと、「古い設定を別の場所にコピーして戻す」攻撃(ロールバック)を検出できる。

6.3 鍵の包み込み

「鍵を別の鍵で暗号化して持つ」ときは AEAD を使う。 改ざんされた鍵を復号して使ってしまう事故を防げる。 PSA には 1.3 で psa_wrap_key() が入ったが、AEAD で psa_export_key() の出力を包み、psa_import_key() で戻す手順でも同等のことができる(EXPORT 用途が要る点に注意)。

7. 手を動かす

AEAD の 4 つの入力を触る

8. 仕様書に逃がす

関数・定数用途
PSA_ALG_AEAD_WITH_AT_LEAST_THIS_LENGTH_TAG(alg, n)鍵の方針で「タグ長 n 以上なら許す」
PSA_ALG_AEAD_WITH_DEFAULT_LENGTH_TAG(alg)短縮タグの定数から既定長の定数に戻す
PSA_AEAD_UPDATE_OUTPUT_SIZE / PSA_AEAD_FINISH_OUTPUT_SIZE / PSA_AEAD_VERIFY_OUTPUT_SIZE分割版のバッファ見積もり
PSA_ALG_XCHACHA20_POLY130524 バイトノンス版(1.3 で追加、乱数ノンスの衝突余裕が大きい)

この章のポイント