RailsでActiveRecord Encryptionを使わずにカラム単位で暗号化・復号化する(AES-256-CBC編)
※この記事は「COUNTERWORKS Advent Calendar」の4日目の記事です。
はじめに
データベース内のデータの暗号化・復号化には様々な手法がありますよね。
弊社でも、過去に開発しているアプリケーションをお客様に導入いただく際に、導入の条件として「データベース内のデータが暗号化されて保存されている事」が挙げられていたようで、一部情報(個人情報など)がデータベースのカラム単位で共通鍵暗号であるAdvanced Encryption Standard(AES)の鍵長256ビット、CBCモードで暗号化して保存され、必要に応じて復号している箇所がありました。
参照: MySQL 12.14 暗号化関数と圧縮関数
(MySQL側のサンプルコードでもblock_encryption_mode
がaes-256-cbcになってますね)
Ruby on Railsで作られていたアプリケーション内では、当時ベタ書きでSQLが書かれていて、データベース側のマシンリソースを使って暗号化・復号化されていましたが、
- データベースではなくアプリケーション側でも暗号化・復号化をできるようにしたい
- データベースよりアプリケーションサーバーの方がスケールアウトの方が比較的容易なのでアプリケーション側のリソースを使いたい
- バックエンドの開発者があまり意識することなく使えるようにしたい
- 透過的に使えたら
- データ分析などRailsアプリケーション以外でも復号したい
- 引き続きSQLだけで復号できるようにしたい
-
ActiveRecord Encryption
を使ってRailsにロックインするのはしんどそうだし、1カラム内にJSONとして保存しているのは使いづらい
ということで、アプリケーション側で何とかならないか調べていた所、
MySQLのAES_ENCRYPT/AES_DECRYPT互換の方式でActiveRecordの属性を透過的に暗号化/復号する
という記事を見つけました。
これから上記記事のやり方をベースに AES-256-CBC
に対応させつつ、どのようなRuby/Rails上の実装で可能にしたのか、MySQLとPostgreSQL両方の例を紹介します。
MySQLとPostgreSQL共通の事前準備
- gem attr_encrypted をインストール
- 接頭辞に
encrypted_
をつけて暗号化したいカラムを追加する - 接頭辞に
encrypted_
、接尾辞に_iv
をつけて初期ベクトル用カラムを追加する - 鍵文字列を環境変数
DB_ENCRYPTION_KEY
に定義
MySQLにおける対応
事前準備
- MySQLにてblock_encryption_modeを
aes-256-cbc
に設定しておく
実装
ActiveRecordのモデル用の共通モジュール
module AttrEncryptedCipher
extend ActiveSupport::Concern
class_methods do
# モデル内の定義用メソッド
def attr_aes_encrypted(*fields)
fields.each do |field|
attr_encrypted field,
algorithm: 'aes-256-cbc',
key: :aes256cbc_encrypted_generate_key,
encode: false, # valueのBase64エンコードをしない
encode_iv: false # ivのBase64エンコードをしない
end
end
# LIKE検索用メソッド(インデックスは効かないので注意)
# ex.) search_like_match_by_encrypted_column(decrypted_field: :email, value: "%test%")
def search_like_match_by_encrypted_column(decrypted_field:, value:)
encryption_key = ENV.fetch('DB_ENCRYPTION_KEY') # 未設定の場合例外が出るように
encrypted_column = "encrypted_#{decrypted_field}" # カラム名がルール通りの場合のみ想定
sql = "CONVERT(AES_DECRYPT(`#{table_name}`.`#{encrypted_column}`, SHA2(?, 512), `#{table_name}`.`#{encrypted_column}_iv`) USING utf8mb4) LIKE ?"
where(sql, encryption_key, value)
end
end
# NOTE: Rails7 から組み込み(ActiveRecord::Encryption)で encrypted_attributes があるので注意
def decrypted_attributes
encrypted = encrypted_keys
decrypted = decrypted_keys.map { |attr| [attr.to_s, send(attr)] }.to_h
attributes.reject { |k| encrypted.member?(k.to_sym) }.merge(decrypted)
end
# to_jsonで復号されたattributeのみ出力されるようにas_jsonを上書き
# https://github.com/rails/rails/blob/v7.1.0/activemodel/lib/active_model/serializers/json.rb#L96 を真似した
def as_json(options = nil)
options ||= {}
hashed_options = serializable_hash(options).as_json
hashed_options[:except] ||= []
hashed_options[:except].push(*attr_encrypted_cipher_encrypted_keys) # binary型のカラムがあると as_json はできても to_json でエラーになるので除外する
hashed_options[:methods] ||= []
hashed_options[:methods].push(*attr_encrypted_cipher_decrypted_keys)
super(hashed_options)
end
private
def attr_encrypted_cipher_decrypted_keys
attr_encrypted_encrypted_attributes.keys
end
def attr_encrypted_cipher_encrypted_keys
attr_encrypted_encrypted_attributes.map { |_, v| [v[:attribute].to_s, "#{v[:attribute]}_iv"] }.flatten.map(&:to_sym)
end
def aes256cbc_encrypted_generate_key
key_length = 32 # algorithm: 'aes-256-cbc' と256bitなので32bytesとする
key = sha512_hexdigest_encryption_key
final_key = "\0" * key_length
key.length.times do |i|
final_key[i % key_length] = (final_key[i % key_length].ord ^ key[i].ord).chr
end
final_key
end
def sha512_hexdigest_encryption_key
encryption_key = ENV.fetch('DB_ENCRYPTION_KEY') # 未設定の場合例外が出るように
::Digest::SHA2.new(512).hexdigest(encryption_key) # MySQL公式ドキュメントに掲載されている SHA2(key, 512) を表現
end
end
PostgreSQLにおける対応
事前準備
- PostgreSQLにてpgcryptoモジュールをインストールして有効にしておく
実装
MySQLとの差分のみ記載します。
module AttrEncryptedCipher
extend ActiveSupport::Concern
class_methods do
# !!!!MySQLとの差分有り!!!!
def search_like_match_by_encrypted_column(decrypted_field:, value:)
encryption_key = ENV.fetch('DB_ENCRYPTION_KEY') # 未設定の場合例外が出るように
encrypted_column = "encrypted_#{decrypted_field}" # カラム名がルール通りの場合のみ想定
# NOTE: 鍵長が256bitなことは長さで自動的に判断してくれる
sql = <<~SQL.squish
CONVERT_FROM(
DECRYPT_IV(
"#{table_name}"."#{encrypted_column}",
SHA256(?),
"#{table_name}"."#{encrypted_column}_iv",
'aes-cbc'
),
'UTF-8'
) LIKE ?
SQL
where(sql, encryption_key, value)
end
end
private
# !!!!MySQLとの差分有り!!!!
# NOTE: MySQLのAES_ENCRYPTとAES_DECRYPTはPostgreSQLよりややこしい処理となっていたのでかなりシンプルになる
def aes256cbc_encrypted_generate_key
encryption_key = ENV.fetch('DB_ENCRYPTION_KEY') # 未設定の場合例外が出るように
::Digest::SHA2.new(256).digest(encryption_key) # algorithm: 'aes-256-cbc' と256bitなので合わせる
end
end
実際にモデルに組み込む
# カラムの定義
# **`id`** | `bigint` | `not null, primary key`
# **`encrypted_email`** | `binary` | `not null`
# **`encrypted_email_iv`** | `binary` | `not null`
class User < ApplicationRecord
include AttrEncryptedCipher
attr_aes_encrypted :email
end
とincludeすることで、
user = User.create(email: 'test@example.com')
# where()やfind_by()で、email: 'test@example.com'とはできないので注意
user = User.search_like_match_by_encrypted_column(decrypted_field: :email, value: "%est@%").first
puts user.email # => test@example.com
user.update(email: 'test2@example.com')
puts user.email # => test2@example.com
puts user.to_json # => {"id": 1, "email": "test2@example.com"}
のように暗号化されたカラムを透過的に使う事ができます。
終わりに
元々実装されていた内容をRubyで表現してみましたが、透過的なデータベースの暗号化の方法はいくつもあります。
本記事の内容を実装してから結構立つのですが、UNIQUE制約がかけられなかったり、絞り込むのに実装が必要だったり諸々制約あるので、今同様の要件でやるとしたらAmazon RDSなどで提供されているリソースの暗号化を選択するかなと思っています(それで導入要件を満たせられれば)。
技術の進化って素晴らしいですね!
最後に、株式会社カウンターワークスでは、既存の実装にリスペクトを持ちつつ、その時点でベターな方法で一緒に改善していけるメンバーを募集しています!
興味のある方はぜひ以下のリンクからご応募ください!
ポップアップストアや催事イベント向けの商業スペースを簡単に予約できる「SHOPCOUNTER」と商業施設向けリーシングDXシステム「SHOPCOUNTER Enterprise」を運営しています。エンジニア採用強化中ですので、興味ある方はお気軽にご連絡ください! counterworks.co.jp/
Discussion