🔐

RailsでActiveRecord Encryptionを使わずにカラム単位で暗号化・復号化する(AES-256-CBC編)

2024/12/04に公開

※この記事は「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における対応

事前準備

実装

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における対応

事前準備

実装

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などで提供されているリソースの暗号化を選択するかなと思っています(それで導入要件を満たせられれば)。
技術の進化って素晴らしいですね!

最後に、株式会社カウンターワークスでは、既存の実装にリスペクトを持ちつつ、その時点でベターな方法で一緒に改善していけるメンバーを募集しています!
興味のある方はぜひ以下のリンクからご応募ください!

COUNTERWORKS テックブログ

Discussion