OpenSSLで暗号化したDBデータのデータ移行した話し

@amyroi です。
フリーランスでRailsプロジェクトの開発基盤のお仕事をしています。

## 何の話？
* RubyのOpenSSLで暗号化し、ActiveRecordのserializeでMysqlに保存したデータがRuby2.4アップデートによるOpenSSLの仕様変更によりデータ移行をすることになった話し。

### この記事はこんな話について書いています。
* OpenSSL(Ruby2.4.0)
* Mysql DBカラム暗号化
* 暗号化データのデータ移行
* ActiveRecord::Base#serialize(Rails5.1)

## 問題の発生
1. RailsプロジェクトをRuby 2.3系からRuby 2.4.0にUpgradeをしようとしたらエラーが発生した。
> `key must be 32 bytes`

2. 調べてみたらOpenSSLの仕様変更が発生した。
3. プロジェクト側のコードを読んだらRubyのOpenSSLで暗号化したデータをActiveRecord::Base#serializeメソッドを通してDBに保存していた。その際に使用しているkeyの長さが暗号化方式が定めるbyte数を超えていた。
4. OpenSSLの仕様変更どおりにkey lengthを変更すると復号化できなくなる。

## Ruby2.4 OpenSSLの仕様変更
* [openssl: make Cipher#key= and #iv= reject too long values](https://github.com/ruby/ruby/commit/ce635262f53b760284d56bb1027baebaaec175d1)

* 暗号化方式によって推奨されているkeyの長さがあるが、長すぎた場合2.3系では許容していたが2.4から暗号方式に対応するkey lengthを正確に見るようになった。
* 対応は暗号化方式に対応したkey lengthにしてあげればこの問題自体は解決する。

[APIの例](http://api.rubyonrails.org/classes/ActiveSupport/MessageEncryptor.html)からサンプルを書いてみるとこんな感じ

```ruby
CIPHER = "aes-256-cbc"
salt  = SecureRandom.random_bytes(64)
key_length = ActiveSupport::MessageEncryptor.key_len(CIPHER)
key   = ActiveSupport::KeyGenerator.new('password').generate_key(salt, key_length) # => "\x89\xE0\x156\xAC..."
crypt = ActiveSupport::MessageEncryptor.new(key)                           # => #<ActiveSupport::MessageEncryptor ...>
encrypted_data = crypt.encrypt_and_sign('my secret data')                  # => "NlFBTTMwOUV5UlA1QlNEN2xkY2d6eThYWWh..."
crypt.decrypt_and_verify(encrypted_data)  
``` 

## ActiveRecord serializeで暗号化データ保存
* Railsのシリアライズ機構を使ってカラムデータをOpenSSLで暗号化しDBに保存。read時に復号化している。
* この問題についてRails側での対応がありますが、 https://github.com/rails/rails/issues/28401
前述した通り、切り詰めて暗号化すると既存のデータは複合は不可。

## データベースカラム暗号化について
* そもそもDBカラム暗号化は通例なのか。
* 通常検索が発生するカラムに対して暗号化する事は個人的な考えでは避けるべきだと思います。
* [WEBサービスにおけるDBの暗号化について](https://teratail.com/questions/11012)
で言及されているように各システムの脆弱性の問題をクリアにして行くのが先決に思います。

* [データベース暗号化ガイドライン](http://www.db-security.org/report/dbsc_cg_ver1.0.pdf)
を参考にすると、アプリケーション側で暗号化するメリットはカラム事に暗号化キーをアプリケーション側で管理する事ができる事という事が分かりました。


## どう対応したか
1. 既存シリアライズ用クラスのテスト用意
 * 現行仕様で暗号化しているプロジェクト側のテストを用意します。
2. 新仕様に合わせ暗号化方式に対応するkeyを生成させる実装
 * `ActiveSupport::KeyGenerator`でkey lengthを新仕様に合わせ、実装を修正します。ここで 1.のテストが落ることを確認します。
3. データ移行タスク実装
 * データ移行用のスクリプトとデータ移行後の復号化、また再暗号化を確認できるテストの実装をします。

## 実際のコードサンプル
以下のような条件でモジュールを作成し、移行用のActiveRecord::Baseを継承したモデルにincludeさせ、直感的にデータ移行できるようにしました。

* ActiveRecord#find時(SELECT)に旧仕様での復号化
* ActiveRecord#save時(UPDATE)に新仕様での暗号化をさせる

```ruby
# 前提としてOpenSSLでの暗号化復号化用クラスを下記とします。
# 中身は標準的な使い方なので省略

# 新仕様のシリアライズクラス
EncryptionSerializer

# 旧仕様のシリアライズクラスは移行用のネームスペースに移動
CipherConverter::EncryptionSerializer
```

```ruby
module CipherConverter
  module Encrypter
    extend ActiveSupport::Concern
    included do
      after_initialize :decrypt_column
      before_save :encrypt
    end

    # 旧仕様での復号化をかなえる
    # SERIALZE_COLUMNSに格納されたシリアライズ対象のカラム名を動的に生成
    # callされた時に復号化
    def decrypt_column
      self.class::SERIALZE_COLUMNS.map do |column|
        class_eval <<-METHOD
          def #{column}
            CipherConverter::EncryptionSerializer.new(self.class::SECRET).load(read_attribute(:#{column}))
          end
        METHOD
      end
    end

    # 新仕様での暗号化
    def encrypt
      self.class::SERIALZE_COLUMNS.map do |column|
        # self.send(column)で動的に生成されたメソッドをcallし、復号化
        # dumpメソッドで暗号化
        self[column] = ::EncryptionSerializer.new(self.class::SECRET).dump(self.send(column))
      end
    end
  end
end
```

モジュールをincludeするクラス

```ruby
module CipherConverter
  class User < ApplicationRecord
    include Encrypter

    SECRET = "secret_keyxxxxxxxxxxxxxxxx"
    SERIALZE_COLUMNS = %w(name email).freeze
  end
end
```

```ruby
# 実行例

# 特定のレコード
CipherConverter::User.find(1).save

# 全件の場合
CipherConverter::User.all{ |r| r.save }
```

これを移行するモデルに対してtransactionをかけて実行させて実現させました。