@amyroi です。
フリーランスでRailsプロジェクトの開発基盤のお仕事をしています。
何の話?
- RubyのOpenSSLで暗号化し、ActiveRecordのserializeでMysqlに保存したデータがRuby2.4アップデートによるOpenSSLの仕様変更によりデータ移行をすることになった話し。
この記事はこんな話について書いています。
- OpenSSL(Ruby2.4.0)
- Mysql DBカラム暗号化
- 暗号化データのデータ移行
- ActiveRecord::Base#serialize(Rails5.1)
問題の発生
- RailsプロジェクトをRuby 2.3系からRuby 2.4.0にUpgradeをしようとしたらエラーが発生した。
key must be 32 bytes
- 調べてみたらOpenSSLの仕様変更が発生した。
- プロジェクト側のコードを読んだらRubyのOpenSSLで暗号化したデータをActiveRecord::Base#serializeメソッドを通してDBに保存していた。その際に使用しているkeyの長さが暗号化方式が定めるbyte数を超えていた。
- OpenSSLの仕様変更どおりにkey lengthを変更すると復号化できなくなる。
Ruby2.4 OpenSSLの仕様変更
-
暗号化方式によって推奨されているkeyの長さがあるが、長すぎた場合2.3系では許容していたが2.4から暗号方式に対応するkey lengthを正確に見るようになった。
-
対応は暗号化方式に対応したkey lengthにしてあげればこの問題自体は解決する。
APIの例からサンプルを書いてみるとこんな感じ
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の暗号化について
で言及されているように各システムの脆弱性の問題をクリアにして行くのが先決に思います。 -
データベース暗号化ガイドライン
を参考にすると、アプリケーション側で暗号化するメリットはカラム事に暗号化キーをアプリケーション側で管理する事ができる事という事が分かりました。
どう対応したか
- 既存シリアライズ用クラスのテスト用意
- 現行仕様で暗号化しているプロジェクト側のテストを用意します。
- 新仕様に合わせ暗号化方式に対応するkeyを生成させる実装
-
ActiveSupport::KeyGenerator
でkey lengthを新仕様に合わせ、実装を修正します。ここで 1.のテストが落ることを確認します。
- データ移行タスク実装
- データ移行用のスクリプトとデータ移行後の復号化、また再暗号化を確認できるテストの実装をします。
実際のコードサンプル
以下のような条件でモジュールを作成し、移行用のActiveRecord::Baseを継承したモデルにincludeさせ、直感的にデータ移行できるようにしました。
- ActiveRecord#find時(SELECT)に旧仕様での復号化
- ActiveRecord#save時(UPDATE)に新仕様での暗号化をさせる
# 前提としてOpenSSLでの暗号化復号化用クラスを下記とします。
# 中身は標準的な使い方なので省略
# 新仕様のシリアライズクラス
EncryptionSerializer
# 旧仕様のシリアライズクラスは移行用のネームスペースに移動
CipherConverter::EncryptionSerializer
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するクラス
module CipherConverter
class User < ApplicationRecord
include Encrypter
SECRET = "secret_keyxxxxxxxxxxxxxxxx"
SERIALZE_COLUMNS = %w(name email).freeze
end
end
# 実行例
# 特定のレコード
CipherConverter::User.find(1).save
# 全件の場合
CipherConverter::User.all{ |r| r.save }
これを移行するモデルに対してtransactionをかけて実行させて実現させました。