AES-GCM で同じ IV を使用すると、ある平文と暗号文の組み合わせから別の暗号文を復号できてしまう

## はじめに

暗号化アルゴリズムとして AES-GCM を使用する場合、IV (初期化ベクトル) は暗号化するたびに都度 nonce[^1] を生成して使用しなければいけません。

[GCM](https://ja.wikipedia.org/wiki/Galois/Counter_Mode) では鍵と IV を使用して生成した鍵ストリームと平文の XOR を暗号文とします。鍵は固定であるとして、IV も固定である場合、XOR の計算時に使用する鍵ストリームも固定になります。そうなると、攻撃者が平文と暗号文の組み合わせを 1 つでも知っている場合、他の暗号文を平文に復号することが可能になってしまいます。

## 再現コード

任意のデータを AES-256-GCM で暗号化するメソッドを定義します。

```rb
require 'openssl'
require 'base64'

# AES-256-GCM を使用して data を暗号化する。
# 暗号文は扱いやすいように Base64 エンコードする。
def encrypt(key:, iv:, data:)
  cipher = OpenSSL::Cipher.new('AES-256-GCM').encrypt
  cipher.key = key
  cipher.iv = iv
  ciphertext = "#{cipher.update(data)}#{cipher.final}"
  Base64.strict_encode64(ciphertext)
end
```

3 つの文字列を暗号化します。この時、鍵と IV は同じ値を使用します。

```rb
password = 'iloveyoyo'
salt = OpenSSL::Random.random_bytes(8)
iter = 2_000
key_iv = OpenSSL::PKCS5.pbkdf2_hmac_sha1(password, salt, iter, cipher.key_len + cipher.iv_len)
key, iv = key_iv[0, cipher.key_len], key_iv[cipher.key_len, cipher.iv_len]

encrypt(key: key, iv: iv, data: 'c3yoyodesign')
encrypt(key: key, iv: iv, data: 'yoyofactory')
encrypt(key: key, iv: iv, data: 'aufheben')
```

| 平文 | 暗号文 (Base64) |
|--|--|
| c3yoyodesign | 4FevqOwjEIIfUJVy |
| yoyofactory | +guvqPMtF5MDS4s= |
| aufheben | 4hGwr/AuEYk= |

ここで、攻撃者が平文 `c3yoyodesign` と暗号文 `4FevqOwjEIIfUJVy` の組み合わせを 1 つでも知ることができた場合、たとえ**暗号化の際に使用した鍵と IV の値を知らなくても[^2]他の暗号文を復号できてしまいます**。

```rb
# str1, str2 のバイト列の XOR を計算する。
def xor(str1, str2)
  str1.unpack('C*').zip(str2.unpack('C*'))
    .map { (_1 || 0) ^ (_2 || 0) }
    .pack('C*')
end

# 既知の平文と暗号文の組み合わせを使用して、任意の暗号文を悪意を持って復号する。
def decrypt_with_malicious_intent(ciphertext)
  xor(
    xor(Base64.strict_decode64(ciphertext), Base64.strict_decode64('4FevqOwjEIIfUJVy')),
    'c3yoyodesign'
  )
end
  
decrypt_with_malicious_intent('+guvqPMtF5MDS4s=')
#=> "yoyofactory"
decrypt_with_malicious_intent('4hGwr/AuEYk=')
#=> "aufheben"
```

暗号化するたびに都度 IV を生成していれば復号はできません。

```rb
nonce = OpenSSL::Random.random_bytes(12)
encrypt(key: key, iv: nonce, data: 'yoyofactory')
#=> "eBzfGP4uAeWbBmc="
decrypt_with_malicious_intent('eBzfGP4uAeWbBmc=')
#=> "\xFBx\t\xDFkbu\x02\xF7?\x95"
```

## おまけ: 決定論的な暗号化でも同じ IV にならないようにする実例

Ruby on Rails (以下 Rails) ではカラムの値を暗号化する際の暗号化アルゴリズムとして AES-GCM を使用しています。デフォルトでは、同じ平文を暗号化しても暗号化のたびに異なる暗号文が生成されるようになっています (非決定論的な暗号化) 。しかし、同じ平文からは同じ暗号文を生成したいという場合を考慮して、決定論的な暗号化もサポートしています。

https://railsguides.jp/active_record_encryption.html#%E6%B1%BA%E5%AE%9A%E8%AB%96%E7%9A%84%E6%9A%97%E5%8F%B7%E5%8C%96%E3%81%A8%E9%9D%9E%E6%B1%BA%E5%AE%9A%E8%AB%96%E7%9A%84%E6%9A%97%E5%8F%B7%E5%8C%96%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6

Rails では決定論的な暗号化を使用する場合でも IV が固定になりません。具体的には IV を生成する際のパラメータに暗号化しようとしている平文 `clear_text` を含めています。そうすることで、平文ごとに生成される IV が変わります。

```rb
# https://github.com/rails/rails/blob/v8.0.1/activerecord/lib/active_record/encryption/cipher/aes256_gcm.rb#L95-L97
def generate_deterministic_iv(clear_text)
  OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, @secret, clear_text)[0, ActiveRecord::Encryption.cipher.iv_length]
end
```

## 参考

### AES-GCM

https://www.mbsd.jp/research/20200901/aes-gcm/

https://jovi0608.hatenablog.com/entry/20160524/1464054882

### AES-GCM-SIV

同じ鍵と IV を使用してしまう場合を考慮した AES-GCM 派生の暗号化アルゴリズムあるようです。

https://engineering.linecorp.com/ja/blog/AES-GCM-SIV-optimization

[^1]:使い捨てのランダムかつユニークな値のことです。
[^2]:鍵は秘密にする必要がありますが、IV は秘密にする必要はありません。