Railsで簡単可逆暗号(ActiveSupport::MessageEncryptor)

# Railsで簡単に可逆暗号をする方法

情報が古くなったため、rails4.2, rails5.0, rails5.1, rails5.2, rails6.0 で変更点確認し更新

## 使い方

`ActiveSupport::MessageEncryptor` を利用することで簡単に可逆暗号ができる

```rb
key_len = ActiveSupport::MessageEncryptor.key_len
secret = Rails.application.key_generator.generate_key('salt', key_len)
crypt = ActiveSupport::MessageEncryptor.new(secret)
encrypted = crypt.encrypt_and_sign('Qiitaきーたキータ')
# => "Adp1SPOkYRqLgTMoAlcyEganoimCdT8k/tj4zQXZiUiN--9A34IcmwD9pG4ysj--+drve0A05vHPXYF33aw4hg=="
crypt.decrypt_and_verify(encrypted)
# => 'Qiitaきーたキータ'
```

### 注意事項

- 同一アプリケーション内で完結する処理(DB/Redisに暗号化した値をいれて同一アプリケーションで複合するなど)であれば、`Rails.application.key_generator` を使うこと
  - `secret_key_base` の値がデータ交換先で異なる場合は `ActiveSupport::KeyGenerator.new(secret, iterations: 10_000)` を使う
  - `new` で生成する場合は `iterations` の値も合わせておくこと(Default: 65536)
  - ただし、`Rails.application.key_generator` の `iterations` は 1000
- `generate_key('salt', key_len)` の `salt` は処理の目的ごとに変えること 
  - 例) ユーザIDごとに異なるsecretを作りたい場合: `generate_key("user_#{@user.id}")`

## Rails 5.1 以下から Rails 5.2 以上にする場合

デフォルトの暗号化方式が `aes-256-cbc` から `aes-256-gcm` へ変化している
その為、暗号化済みのデータはそのまま複合することができない

### Rails 5.1 で作った暗号化データをRails 5.2で復号

```rb
key_len = ActiveSupport::MessageEncryptor.key_len
secret = Rails.application.key_generator.generate_key('salt', key_len)
crypt = ActiveSupport::MessageEncryptor.new(secret)
message = crypt.encrypt_and_sign('Qiitaきーたキータ')
=> "SGVOekxRajVRUDh1cGNFeEIwRzNwMjdycWwyQkZZR0QvWU5sM3lNcnVSUFUwSUE3WFpTR1ZPbFZBMTFIbUZ2dS0tWG03Z0ordFUxeUJOaXY4bkIzVU5Odz09--8803cdabfddfe05668780d2b942ee431a25536d7"
```

```rb
# NOTE: 5.1で作ったsecret を `secret.unpack('H*').first` で HexString化
secret_hex = 'ae0eaf200c046e08f52c32c0501d129dff016f47bf68fa3b6a38929b58e7467e'
secret = [secret_hex].pack('H*')
message = "SGVOekxRajVRUDh1cGNFeEIwRzNwMjdycWwyQkZZR0QvWU5sM3lNcnVSUFUwSUE3WFpTR1ZPbFZBMTFIbUZ2dS0tWG03Z0ordFUxeUJOaXY4bkIzVU5Odz09--8803cdabfddfe05668780d2b942ee431a25536d7"
crypt = ActiveSupport::MessageEncryptor.new(secret)
crypt.decrypt_and_verify(message)
# => ActiveSupport::MessageEncryptor::InvalidMessage
```

### 例外が出ないようにするには

```
crypt.rotate cipher: 'aes-256-cbc'
crypt.decrypt_and_verify(message)
# => "Qiitaきーたキータ"
```

`on_rotation` を渡すとローテーションされたことの検知もできる

```
rotation = false
callback = lambda {
  rotation = true
  puts "rotation!"
}
crypt.decrypt_and_verify(message, on_rotation: callback)
rotation!
=> "Qiitaきーたキータ"
rotation
# => true
```

`secret` と `cipher` 両方変わった場合も同様の方法で対応できる

```rb
crypt.rotate secret2, cipher: 'aes-256-cbc'
```

## Rails 5.2 以降の新機能

PR: https://github.com/rails/rails/pull/29599

### 暗号化データの有効期限を指定する

`expires_at` または `expires_in` を渡すことでメッセージの有効期限を決めることができる

有効期限を過ぎた場合には `nil` が返るようになる

- `expires_in` は何秒後を数値で指定する
  - `3600` や `1.year` など
- `expires_at` は期限切れとなる時刻を直接指定する
  - `3600.seconds.since` や `1.year.since` など

```rb
secret = Rails.application.key_generator.generate_key('salt', 32)
crypt = ActiveSupport::MessageEncryptor.new(secret)
message = 'Qiitaきーたキータ'

now = Time.zone.now
Timecop.freeze now
# `expires_in: 10.seconds` と同じ
encrypt = crypt.encrypt_and_sign(message, expires_at: now + 10.seconds)
```

```rb
Timecop.freeze now + 9.seconds
crypt.decrypt_and_verify(encrypt)
# => "Qiitaきーたキータ"
```

```rb
Timecop.freeze now + 10.seconds
crypt.decrypt_and_verify(encrypt)
# => nil
```

### `purpose` オプション

`purpose` の値が一致していない場合に `nil` を返す

指定されていない場合は `purpose: nil` が指定されているのと同様の挙動となる

```rb
secret = Rails.application.key_generator.generate_key('salt', 32)
crypt = ActiveSupport::MessageEncryptor.new(secret)
message = 'Qiitaきーたキータ'
```

```rb
encrypt = crypt.encrypt_and_sign(message, purpose: 'signup')
crypt.decrypt_and_verify(encrypt)
=> nil
crypt.decrypt_and_verify(encrypt, purpose: 'signup')
=> "Qiitaきーたキータ"
```

## おまけ: 他プラットフォームと交換する場合

正直 `ActiveSupport::MessageEncryptor` を使わないで `OpenSSL` から書いていったほうがわかりやすい...

### 暗号化側

`serializer: JSON` にしておくと読みやすい

```rb
secret = Rails.application.key_generator.generate_key('salt', 32)
crypt = ActiveSupport::MessageEncryptor.new(secret, serializer: JSON)
crypt.encrypt_and_sign(message)
# => "77RDuITdVMcceSBImUQxfyl0PWPfLgb6sw==--Cxh9TgrT+vQjOH/r--MY4Ymi5d4RLUQ4tBfjMYlQ=="
secret.unpack('H*').first
# => "e961542bbc011a568d77bf12c7f4bbb1c83dfee0148f1688d95d97de4779992f"
```

### 復号側(PHPの場合)

`secret.unpack('H*').first` の結果を事前に渡しておく必要がある

```php
// 渡されたHexString
$hex_key = "e961542bbc011a568d77bf12c7f4bbb1c83dfee0148f1688d95d97de4779992f";
// binary変換
$key = hex2bin($hex_key);
// 送られてきたメッセージ
$message = "77RDuITdVMcceSBImUQxfyl0PWPfLgb6sw==--Cxh9TgrT+vQjOH/r--MY4Ymi5d4RLUQ4tBfjMYlQ==";
// `--` で分割(0: 暗号化データ, 1: iv, 2: authtag)
// authtagは aes-256-gcm の場合に必要
$messages = explode('--', $message);
$encrypted_data = base64_decode($messages[0], true);
$iv = base64_decode($messages[1], true);
$auth_tag = base64_decode($messages[2], true);

// 復号処理
$decypted_data = openssl_decrypt($encrypted_data, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $auth_tag);
echo $decypted_data;
// => "Qiitaきーたキータ"
```