🔐

[Rails] Active Record Encryptionを試してみる

2022/12/29に公開

Active Record と暗号化 - Railsガイド
Active Record Encryption — Ruby on Rails Guides

アプリケーションレイヤで暗号化する仕組みとしてActiveRecordEncryptionがRails7.0から導入されたようなので試してみようかと思います。
今まで自前実装でやっていたので、楽になるのかな?と

環境

  • Docker: 20.10.22
  • Ruby: 3.1.2
  • Rails: 7.0.4
  • postgres: 15.1

まずは普通にモデルを作ってみる

class CreateUsers < ActiveRecord::Migration[7.0]
  def change
    create_table :users do |t|
      t.string :name
      t.string :address

      t.timestamps
    end
  end
end

こんな感じで name address を持った User モデルを追加してみました。

irb(main):001:0> User.create(name: "test-1", address: "東京都xxx")
  TRANSACTION (0.1ms)  BEGIN
  User Create (0.7ms)  INSERT INTO "users" ("name", "address", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["name", "test-1"], ["address", "東京都xxx"], ["created_at", "2022-12-25 13:12:36.001606"], ["updated_at", "2022-12-25 13:12:36.001606"]]
  TRANSACTION (2.4ms)  COMMIT
=> #<User:0x00007fc12250fce0 id: 1, name: "test-1", address: "東京都xxx", created_at: Sun, 25 Dec 2022 13:12:36.001606000 UTC +00:00, updated_at: Sun, 25 Dec 2022 13:12:36.001606000 UTC +00:00>

irb(main):002:0> User.create(name: "test-2", address: "神奈川県xxx")
  TRANSACTION (0.3ms)  BEGIN
  User Create (0.4ms)  INSERT INTO "users" ("name", "address", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["name", "test-2"], ["address", "神奈川県xxx"], ["created_at", "2022-12-25 13:12:49.463373"], ["updated_at", "2022-12-25 13:12:49.463373"]]
  TRANSACTION (2.5ms)  COMMIT
=> #<User:0x00007fc124550400 id: 2, name: "test-2", address: "神奈川県xxx", created_at: Sun, 25 Dec 2022 13:12:49.463373000 UTC +00:00, updated_at: Sun, 25 Dec 2022 13:12:49.463373000 UTC +00:00>

ActiveRecord Encryptionを設定する

Active Record と暗号化 - Railsガイド

このあたりを見ながら設定してみます

Rails credentialsを初期化する

> bin/rails db:encryption:init
Add this entry to the credentials of the target environment:

active_record_encryption:
  primary_key: Ot8iSvgBTpaNsAkLFhMQsrf1EWDtmWHK
  deterministic_key: 0jefyhajq3hnr96sldPoqu8ISFpI9zRZ
  key_derivation_salt: q6o3RhCV1hoiGqWnQEgVKGwZQieFrgcF

なるほど、ここで生成したキーを自分でcredentialsに追加しないとイケナイっぽいですね?
見てみましょう

> EDITOR=emacs bin/rails credentials:edit
# aws:
#   access_key_id: 123
#   secret_access_key: 345

# Used as the base secret for all MessageVerifiers in Rails, including the one protecting cookies.
secret_key_base: 0e61dd7f9eb28fa38b111013b5def8d164211ae01b107b258a44fb8e813a3aca2e40bd2803f19f8061d280bda58e39e21e1df74e4469e40758e8aca9b918bf4e

入ってないですね

developmentにのみ追加してみましょう

> EDITOR=emacs bin/rails credentials:edit --environment development
active_record_encryption:
  primary_key: Ot8iSvgBTpaNsAkLFhMQsrf1EWDtmWHK
  deterministic_key: 0jefyhajq3hnr96sldPoqu8ISFpI9zRZ
  key_derivation_salt: q6o3RhCV1hoiGqWnQEgVKGwZQieFrgcF
> bin/rails c

irb(main):003:0> Rails.application.credentials[:active_record_encryption]
=> {:primary_key=>"Ot8iSvgBTpaNsAkLFhMQsrf1EWDtmWHK", :deterministic_key=>"0jefyhajq3hnr96sldPoqu8ISFpI9zRZ", :key_derivation_salt=>"q6o3RhCV1hoiGqWnQEgVKGwZQieFrgcF"}

設定できました。

Modelの設定

User#address を暗号化するようにします。

app/models/user.rb
class User < ApplicationRecord
  encrypts :address
end

rails consoleから既存データを読み出してみます

irb(main):006:0> u = User.first
  User Load (0.4ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1]]
(Object doesn't support #inspect)
=>

irb(main):007:0> u.name
=> "test-1"

irb(main):008:0> u.address
/usr/local/bundle/gems/activerecord-7.0.4/lib/active_record/encryption/encryptor.rb:58:in `rescue in decrypt': ActiveRecord::Encryption::Errors::Decryption (ActiveRecord::Encryption::Errors::Decryption)
/usr/local/bundle/gems/activerecord-7.0.4/lib/active_record/encryption/message_serializer.rb:26:in `rescue in load': ActiveRecord::Encryption::Errors::Encoding (ActiveRecord::Encryption::Errors::Encoding)
/usr/local/lib/ruby/3.1.0/json/common.rb:216:in `parse': 859: unexpected token at '\xE6\x9D\xB1\xE4\xBA\xAC\xE9\x83\xBDxxx' (JSON::ParserError)

復号化エラーになりました。

暗号化されていないデータも扱えるようにする

3.5 暗号化されていないデータのサポート

config.active_record.encryption.support_unencrypted_data = true

にすれば良いようなのでやってみます

config/application.rb
require_relative "boot"
require "rails/all"
Bundler.require(*Rails.groups)

module ArEncExample
  class Application < Rails::Application
    config.load_defaults 7.0
    config.api_only = true

    config.active_record.encryption.support_unencrypted_data = true
  end
end
irb(main):003:0> u = User.first
  User Load (0.4ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> #<User:0x00007fc71a9b1278 id: 1, name: "test-1", address: "東京都xxx", created_at: Wed, 28 Dec 2022 11:38:50.746083000 UTC +00:00, updated_at: Wed, 28 Dec 2022 11:38:50.746083000 UTC +00:00>

irb(main):004:0> u.name
=> "test-1"

irb(main):005:0> u.address
=> "東京都xxx"

読み出せるようになりました

このオプションは、非暗号化データと暗号化済みデータの共存が避けられないので、あくまで過渡期の利用が目的です。
デフォルトでは上記2つのオプションはどちらもfalseに設定されます。そしてこの設定は、あらゆるアプリケーションで推奨される目標でもあります。

と書かれている通り、暗号化への移行時にのみ使うべきオプションですね。

この状態で新しいデータを追加してみます

irb(main):006:0> u = User.create(name: "test-2", address: "神奈川県xxx")
  TRANSACTION (0.3ms)  BEGIN
  User Create (0.7ms)  INSERT INTO "users" ("name", "address", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["name", "test-2"], ["address", "{\"p\":\"szVyhlY63H/AlQ2q223d\",\"h\":{\"iv\":\"gM1jzas7EJ+1WelR\",\"at\":\"7mR17c1ZT0yAyQS4VskWTQ==\"}}"], ["created_at", "2022-12-28 11:54:11.311483"], ["updated_at", "2022-12-28 11:54:11.311483"]]
  TRANSACTION (2385.7ms)  COMMIT
=> #<User:0x00007fc71a098038 id: 2, name: "test-2", address: "神奈川県xxx", created_at: Wed, 28 Dec 2022 11:54:11.311483000 UTC +00:00, updated_at: Wed, 28 Dec 2022 11:54:11.311483000 UTC +00:00>

irb(main):007:0> u.name
=> "test-2"

irb(main):008:0> u.address
=> "神奈川県xxx"

irb(main):010:0> User.all.map(&:attributes)
  User Load (0.5ms)  SELECT "users".* FROM "users"
=>
[{"id"=>1, "name"=>"test-1", "address"=>"東京都xxx", "created_at"=>Wed, 28 Dec 2022 11:38:50.746083000 UTC +00:00, "updated_at"=>Wed, 28 Dec 2022 11:38:50.746083000 UTC +00:00},
 {"id"=>2, "name"=>"test-2", "address"=>"神奈川県xxx", "created_at"=>Wed, 28 Dec 2022 11:54:11.311483000 UTC +00:00, "updated_at"=>Wed, 28 Dec 2022 11:54:11.311483000 UTC +00:00}]

生データを見てみましょう

ar_enc_example_development> select * from users;
+----+--------+--------------------------------------------------------------------------------------------+----------------------------+----------------------------+
| id | name   | address                                                                                    | created_at                 | updated_at                 |
|----+--------+--------------------------------------------------------------------------------------------+----------------------------+----------------------------|
| 1  | test-1 | 東京都xxx                                                                                  | 2022-12-28 11:38:50.746083 | 2022-12-28 11:38:50.746083 |
| 2  | test-2 | {"p":"szVyhlY63H/AlQ2q223d","h":{"iv":"gM1jzas7EJ+1WelR","at":"7mR17c1ZT0yAyQS4VskWTQ=="}} | 2022-12-28 11:54:11.311483 | 2022-12-28 11:54:11.311483 |
+----+--------+--------------------------------------------------------------------------------------------+----------------------------+----------------------------+
SELECT 2

こんな感じで暗号化データと非暗号化データが共存しています

既存データを暗号化する

irb(main):015:0> User.all.map(&:encrypt)
  User Load (0.5ms)  SELECT "users".* FROM "users"
  TRANSACTION (0.2ms)  BEGIN
  User Update (0.5ms)  UPDATE "users" SET "address" = $1 WHERE "users"."id" = $2  [["address", "{\"p\":\"icXAQWW0Cf/tgniI\",\"h\":{\"iv\":\"nGGWAr3jiKU0ZdKM\",\"at\":\"l4TZw3MUGMm8X7WBrVnYnw==\"}}"], ["id", 1]]
  TRANSACTION (2385.4ms)  COMMIT
  TRANSACTION (0.2ms)  BEGIN
  User Update (0.3ms)  UPDATE "users" SET "address" = $1 WHERE "users"."id" = $2  [["address", "{\"p\":\"VBAsDhBntKVjwKZLMTgt\",\"h\":{\"iv\":\"JLIG3OBmybJIxqcj\",\"at\":\"danhHvkqdkmGmH4trnu+Qg==\"}}"], ["id", 2]]
  TRANSACTION (1.0ms)  COMMIT
=> [nil, nil]
ar_enc_example_development> select * from users;
+----+--------+--------------------------------------------------------------------------------------------+----------------------------+----------------------------+
| id | name   | address                                                                                    | created_at                 | updated_at                 |
|----+--------+--------------------------------------------------------------------------------------------+----------------------------+----------------------------|
| 1  | test-1 | {"p":"icXAQWW0Cf/tgniI","h":{"iv":"nGGWAr3jiKU0ZdKM","at":"l4TZw3MUGMm8X7WBrVnYnw=="}}     | 2022-12-28 11:38:50.746083 | 2022-12-28 11:38:50.746083 |
| 2  | test-2 | {"p":"VBAsDhBntKVjwKZLMTgt","h":{"iv":"JLIG3OBmybJIxqcj","at":"danhHvkqdkmGmH4trnu+Qg=="}} | 2022-12-28 11:54:11.311483 | 2022-12-28 11:54:11.311483 |
+----+--------+--------------------------------------------------------------------------------------------+----------------------------+----------------------------+
SELECT 2

id:1 も暗号化されました。

irb(main):016:0> User.pluck(:address)
  User Pluck (0.5ms)  SELECT "users"."address" FROM "users"
=> ["東京都xxx", "神奈川県xxx"]

復号化もできてますね

暗号化したカラムをクエリしたい

決定論的暗号化と非決定論的暗号化

2.3 決定論的暗号化と非決定論的暗号化について

ActiveRecord暗号化では、デフォルトで非決定論的な(non-deterministic)暗号化を用います。
ここで言う非決定論的とは、同じコンテンツを同じパスワードで暗号化しても、暗号化のたびに異なる暗号文が生成されるという意味です。
非決定論的な暗号化手法によって、暗号解析の難易度を高めてデータベースへのクエリを不可能にすることで、セキュリティを向上させます。

deterministic:オプションを指定することで、初期化ベクトルを決定論的な手法で生成できるようになり、暗号化データへのクエリを効率よく行えるようになります。

なるほど。

  • 決定論的暗号化
    • 毎回同じ値になる
    • クエリ可能
  • 非決定論的暗号化
    • 毎回違う値になる
    • クエリ不可

ということですかね。
デフォルトでは非決定論的暗号化になっている、と。
試してみます。

irb(main):021:0> ActiveRecord::Encryption.without_encryption { puts User.find(1).address }
  User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
{"p":"BvQLA2z+h1EXQ/qt","h":{"iv":"uKaRSBsOtbTtnyPH","at":"vOl94wHv1th3Qs8mAzUTsg=="}}
=> nil

irb(main):022:0> User.find(1).encrypt
  User Load (0.5ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  TRANSACTION (0.2ms)  BEGIN
  User Update (0.4ms)  UPDATE "users" SET "address" = $1 WHERE "users"."id" = $2  [["address", "{\"p\":\"CyRSVrp2PXlOkkJU\",\"h\":{\"iv\":\"v3CW3ypq6hhiuSaD\",\"at\":\"8HQi285sq9QCxnBQYF/k6g==\"}}"], ["id", 1]]
  TRANSACTION (2.8ms)  COMMIT
=> nil

irb(main):023:0> ActiveRecord::Encryption.without_encryption { puts User.find(1).address }
  User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
{"p":"CyRSVrp2PXlOkkJU","h":{"iv":"v3CW3ypq6hhiuSaD","at":"8HQi285sq9QCxnBQYF/k6g=="}}
=> nil

たしかに、User#encrypt を呼ぶと違う結果になってますね。

決定論的暗号化に変更してみる

現状はデフォルト設定なので非決定論的暗号化になっています。
非決定論的暗号化で暗号化されたデータがある状態で決定論的暗号化に変更してみます。

app/models/user.rb
class User < ApplicationRecord
  encrypts :address, deterministic: true
end

※グローバルな設定で変更する方法は無いっぽい?(見つからなかった)

irb(main):004:0> User.find(1).address
  User Load (0.5ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> "{\"p\":\"OsyJDjD/ul3qjzkT\",\"h\":{\"iv\":\"L5D8NnSvJk2Ehkyq\",\"at\":\"Lj2VJeOJJW9o8pxtAglSRA==\"}}"

復号化できないっぽいですね?

config/application.rb
    config.active_record.encryption.support_unencrypted_data = false

にすると

irb(main):001:0> User.find(1).address
  User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
/usr/local/bundle/gems/activerecord-7.0.4/lib/active_record/encryption/encryptor.rb:58:in `rescue in decrypt': ActiveRecord::Encryption::Errors::Decryption (ActiveRecord::Encryption::Errors::Decryption)
/usr/local/bundle/gems/activerecord-7.0.4/lib/active_record/encryption/cipher/aes256_gcm.rb:80:in `rescue in decrypt': ActiveRecord::Encryption::Errors::Decryption (ActiveRecord::Encryption::Errors::Decryption)
/usr/local/bundle/gems/activerecord-7.0.4/lib/active_record/encryption/cipher/aes256_gcm.rb:76:in `final': OpenSSL::Cipher::CipherError

エラーになりました。

3.6.2 以前の暗号化スキームを属性ごとに設定する

これを読むと変更前の設定をpreviousとして設定できるっぽいですね。
やってみます。

app/models/user.rb
class User < ApplicationRecord
  encrypts :address, deterministic: true, previous: { deterministic: false }
end
irb(main):003:0> User.find(1).address
  User Load (0.3ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> "東京都xxx"

irb(main):004:0> ActiveRecord::Encryption.without_encryption { puts User.find(1).address }
  User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
{"p":"OsyJDjD/ul3qjzkT","h":{"iv":"L5D8NnSvJk2Ehkyq","at":"Lj2VJeOJJW9o8pxtAglSRA=="}}
=> nil

復号化できるようになりました。

もう一度暗号化してみます。

irb(main):012:0> User.find(1).encrypt; ActiveRecord::Encryption.without_encryption { puts User.find(1).address }
  User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  TRANSACTION (0.2ms)  BEGIN
  User Update (0.4ms)  UPDATE "users" SET "address" = $1 WHERE "users"."id" = $2  [["address", "{\"p\":\"tzBwWlQfQh47o/ut\",\"h\":{\"iv\":\"4zExRkIqKxJ139uf\",\"at\":\"eTHXcQThFotUeIqA8aeTMQ==\"}}"], ["id", 1]]
  TRANSACTION (2.5ms)  COMMIT
  User Load (0.3ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
{"p":"tzBwWlQfQh47o/ut","h":{"iv":"4zExRkIqKxJ139uf","at":"eTHXcQThFotUeIqA8aeTMQ=="}}
=> nil


irb(main):013:0> User.find(1).encrypt; ActiveRecord::Encryption.without_encryption { puts User.find(1).address }
  User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  TRANSACTION (0.2ms)  BEGIN
  User Update (0.4ms)  UPDATE "users" SET "address" = $1 WHERE "users"."id" = $2  [["address", "{\"p\":\"z4kSdPawN8SxiPRN\",\"h\":{\"iv\":\"MMyvg4HqUKc6N7nb\",\"at\":\"b1dY44p+HXsOdbZdX7iMVg==\"}}"], ["id", 1]]
  TRANSACTION (2.5ms)  COMMIT
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
{"p":"z4kSdPawN8SxiPRN","h":{"iv":"MMyvg4HqUKc6N7nb","at":"b1dY44p+HXsOdbZdX7iMVg=="}}
=> nil

毎回結果が変わってますね?
deterministic: true にしているけど、非決定論的暗号化のままになっていそうですね。

3.6.3 暗号化スキームと決定論的な属性

以前の暗号化スキームを追加する場合、以下のように非決定論的/決定論的によって違いが生じます。

・非決定論的暗号化: 新しい情報は、常に最新の(すなわち現在の)暗号化スキームによって暗号化される
・決定論的暗号化: 新しい情報は、常にデフォルトで最も古い暗号化スキームによって暗号化される

決定的暗号化をあえて利用する場合は、暗号文を変えたくないのが普通です。この振る舞いはdeterministic: { fixed: false }で変更可能です。これにより、新しいデータを暗号化するときに最新の暗号化スキームが用いられるようになります。

ふむ...
つまり

  • current: 決定論的暗号化
  • previous: 非決定論的暗号化

の場合は
決定論的暗号化: 新しい情報は、常にデフォルトで最も古い暗号化スキームによって暗号化される
に該当し、 最も古い暗号化スキーム つまり previous: 非決定論的暗号化 で暗号化される
ということですかね?

この振る舞いはdeterministic: { fixed: false }で変更可能です。これにより、新しいデータを暗号化するときに最新の暗号化スキームが用いられるようになります。

と書いてあるので、やってみます。

app/models/user.rb
class User < ApplicationRecord
  encrypts :address, deterministic: true, previous: { deterministic: { fixed: false } }
end
irb(main):001:0> User.find(1).address
  User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> "{\"p\":\"z4kSdPawN8SxiPRN\",\"h\":{\"iv\":\"MMyvg4HqUKc6N7nb\",\"at\":\"b1dY44p+HXsOdbZdX7iMVg==\"}}"

復号化できませんでした。
previousじゃなくてcurrentの方を直さないといけない?

app/models/user.rb
class User < ApplicationRecord
  encrypts :address, deterministic: { fixed: false }, previous: { deterministic: false }
end

こうですかね?

irb(main):003:0> User.find(1).address
  User Load (0.3ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> "東京都xxx"

復号化できました。

irb(main):005:0> User.find(1).encrypt; ActiveRecord::Encryption.without_encryption { puts User.find(1).address }
  User Load (0.5ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  TRANSACTION (0.2ms)  BEGIN
  User Update (0.4ms)  UPDATE "users" SET "address" = $1 WHERE "users"."id" = $2  [["address", "{\"p\":\"4RKD+6fMtoGmrnbh\",\"h\":{\"iv\":\"r0wPnvWqbyV5pZ8q\",\"at\":\"Uwo/SseAguaw+adyeqcMZg==\"}}"], ["id", 1]]
  TRANSACTION (2.5ms)  COMMIT
  User Load (0.3ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
{"p":"4RKD+6fMtoGmrnbh","h":{"iv":"r0wPnvWqbyV5pZ8q","at":"Uwo/SseAguaw+adyeqcMZg=="}}
=> nil

irb(main):006:0> User.find(1).encrypt; ActiveRecord::Encryption.without_encryption { puts User.find(1).address }
  User Load (0.5ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  TRANSACTION (0.2ms)  BEGIN
  User Update (0.4ms)  UPDATE "users" SET "address" = $1 WHERE "users"."id" = $2  [["address", "{\"p\":\"4RKD+6fMtoGmrnbh\",\"h\":{\"iv\":\"r0wPnvWqbyV5pZ8q\",\"at\":\"Uwo/SseAguaw+adyeqcMZg==\"}}"], ["id", 1]]
  TRANSACTION (2.5ms)  COMMIT
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
{"p":"4RKD+6fMtoGmrnbh","h":{"iv":"r0wPnvWqbyV5pZ8q","at":"Uwo/SseAguaw+adyeqcMZg=="}}
=> nil

お、何回暗号化しても結果が変わらなくなりました。

irb(main):007:0> User.find(1).address
  User Load (0.3ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> "東京都xxx"

復号化もできました。

クエリしてみる

irb(main):030:0> User.where("address LIKE ?", "%bh%").count
  User Count (0.7ms)  SELECT COUNT(*) FROM "users" WHERE (address LIKE '%bh%')
=> 1

irb(main):031:0> User.where("address LIKE ?", "%東京都%").count
  User Count (0.5ms)  SELECT COUNT(*) FROM "users" WHERE (address LIKE '%東京都%')
=> 0

irb(main):032:0> User.where(address: "東京都xxx").count
  User Count (0.5ms)  SELECT COUNT(*) FROM "users" WHERE "users"."address" = $1  [["address", "{\"p\":\"4RKD+6fMtoGmrnbh\",\"h\":{\"iv\":\"r0wPnvWqbyV5pZ8q\",\"at\":\"Uwo/SseAguaw+adyeqcMZg==\"}}"]]
=> 1

なるほど

  • Hashで条件を渡すと暗号化してくれる
  • ? を使う場合は暗号化してくれない
  • 暗号化済み文字列に対してLIKE検索はできてしまう(良いか悪いかは別)

joinした場合はどうなる?

class CreatePosts < ActiveRecord::Migration[7.0]
  def change
    create_table :posts do |t|
      t.belongs_to :user
      t.string :title

      t.timestamps
    end
  end
end
app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
end

こんなテーブル/モデルを作ってみました

データを作って、クエリしてみます

irb(main):002:0> Post.create(title: "Title Test 1", user: User.find(1))
  User Load (0.5ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  TRANSACTION (0.1ms)  BEGIN
  Post Create (0.8ms)  INSERT INTO "posts" ("user_id", "title", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["user_id", 1], ["title", "Title Test 1"], ["created_at", "2022-12-28 13:19:48.317981"], ["updated_at", "2022-12-28 13:19:48.317981"]]
  TRANSACTION (2391.0ms)  COMMIT
=> #<Post:0x00007f18ebae8a20 id: 1, user_id: 1, title: "Title Test 1", created_at: Wed, 28 Dec 2022 13:19:48.317981000 UTC +00:00, updated_at: Wed, 28 Dec 2022 13:19:48.317981000 UTC +00:00>
irb(main):005:0> Post.joins(:user).where("users.address" => "東京都xxx").count
  Post Count (1.9ms)  SELECT COUNT(*) FROM "posts" INNER JOIN "users" ON "users"."id" = "posts"."user_id" WHERE "users"."address" = $1  [["address", "{\"p\":\"4RKD+6fMtoGmrnbh\",\"h\":{\"iv\":\"r0wPnvWqbyV5pZ8q\",\"at\":\"Uwo/SseAguaw+adyeqcMZg==\"}}"]]
=> 1

irb(main):006:0> Post.joins(:user).where(user: { address: "東京都xxx" }).count
  Post Count (0.7ms)  SELECT COUNT(*) FROM "posts" INNER JOIN "users" "user" ON "user"."id" = "posts"."user_id" WHERE "user"."address" = $1  [["address", "{\"p\":\"4RKD+6fMtoGmrnbh\",\"h\":{\"iv\":\"r0wPnvWqbyV5pZ8q\",\"at\":\"Uwo/SseAguaw+adyeqcMZg==\"}}"]]
=> 1

おぉ。すごい。ちゃんと暗号化してくれてますね。

Ransackを使った検索はできる?

やってみます

Gemfile
gem "ransack"
irb(main):003:0> User.ransack(address_eq: "東京都xxx").result
  User Load (1.2ms)  SELECT "users".* FROM "users" WHERE "users"."address" = '{"p":"4RKD+6fMtoGmrnbh","h":{"iv":"r0wPnvWqbyV5pZ8q","at":"Uwo/SseAguaw+adyeqcMZg==","i":"YjQ0OQ=="}}'
=> [#<User:0x00007f5d761e1308 id: 1, name: "test-1", address: "東京都xxx", created_at: Wed, 28 Dec 2022 13:43:52.526214000 UTC +00:00, updated_at: Wed, 28 Dec 2022 14:02:51.261095000 UTC +00:00>]

irb(main):004:0> User.ransack(address_cont: "東京都xxx").result
  User Load (0.5ms)  SELECT "users".* FROM "users" WHERE "users"."address" ILIKE '{"p":"ZnroLeCWxv6+Pspap00=","h":{"iv":"0cktJDXBlk0/lFY2","at":"aBa0WOLOthh1JJL57gDz7g==","i":"YjQ0OQ=="}}'
=> []

irb(main):005:0> User.ransack(name_or_address_eq: "東京都xxx").result
  User Load (0.5ms)  SELECT "users".* FROM "users" WHERE ("users"."name" = '東京都xxx' OR "users"."address" = '{"p":"4RKD+6fMtoGmrnbh","h":{"iv":"r0wPnvWqbyV5pZ8q","at":"Uwo/SseAguaw+adyeqcMZg==","i":"YjQ0OQ=="}}')
=> [#<User:0x00007f5d74d587d8 id: 1, name: "test-1", address: "東京都xxx", created_at: Wed, 28 Dec 2022 13:43:52.526214000 UTC +00:00, updated_at: Wed, 28 Dec 2022 14:02:51.261095000 UTC +00:00>]

cont はさすがにできませんが、 eq の場合はちゃんと暗号化してくれました。

暗号化キーのローテーション

任意のタイミングでキーを変えたいときにローテーションできると便利ですね

4.5 キーのローテーション

その辺もサポートされてるみたいです。

ただし、制約もあるみたい

キーローテーションは、決定論的暗号化では現在サポートされていません。
Active Record暗号化は、キーローテーション処理の自動管理機能をまだ提供していません(実装は可能ですが未実装の状態です)。

なるほど。

一旦、非決定論的暗号化に戻しておきます。

app/models/user.rb
class User < ApplicationRecord
  encrypts :address
end

primary_keyを複数設定してみます。

まずは1個ランダムなキーを生成しておきます

irb(main):002:0> SecureRandom.hex
=> "e9afd0341ae5673010bda098db826ce3"

このキーを追加します

> EDITOR=emacs bin/rails credentials:edit --environment development
active_record_encryption:
  primary_key:
    - Ot8iSvgBTpaNsAkLFhMQsrf1EWDtmWHK
    - e9afd0341ae5673010bda098db826ce3
  deterministic_key: 0jefyhajq3hnr96sldPoqu8ISFpI9zRZ
  key_derivation_salt: q6o3RhCV1hoiGqWnQEgVKGwZQieFrgcF

この状態で、復号化してみます

irb(main):001:0> User.find(1).address
  User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> "東京都xxx"

できました。
では、もう一度暗号化してみます。

irb(main):002:0> User.find(1).encrypt; ActiveRecord::Encryption.without_encryption { puts User.find(1).address }
  User Load (0.5ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  TRANSACTION (0.2ms)  BEGIN
  User Update (0.5ms)  UPDATE "users" SET "address" = $1 WHERE "users"."id" = $2  [["address", "{\"p\":\"2Dsm44vxRx7XW5xi\",\"h\":{\"iv\":\"0duQxq0uwnblGuRc\",\"at\":\"BJ3I/E2URhiDustzIv3vOg==\"}}"], ["id", 1]]
  TRANSACTION (2383.0ms)  COMMIT
  User Load (0.3ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
{"p":"2Dsm44vxRx7XW5xi","h":{"iv":"0duQxq0uwnblGuRc","at":"BJ3I/E2URhiDustzIv3vOg=="}}
=> nil

irb(main):003:0> User.find(1).address
  User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> "東京都xxx"

では新しいキーを一度消して、復号化してみます

active_record_encryption:
  primary_key:
    - Ot8iSvgBTpaNsAkLFhMQsrf1EWDtmWHK
  deterministic_key: 0jefyhajq3hnr96sldPoqu8ISFpI9zRZ
  key_derivation_salt: q6o3RhCV1hoiGqWnQEgVKGwZQieFrgcF
irb(main):001:0> User.find(1).address
  User Load (0.5ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> "{\"p\":\"2Dsm44vxRx7XW5xi\",\"h\":{\"iv\":\"0duQxq0uwnblGuRc\",\"at\":\"BJ3I/E2URhiDustzIv3vOg==\"}}"

復号化できないですね。

キーの参照を保存する

4.6 キー参照の保存

この設定を有効にすると、システムがキーのリストを探索せずにキーを直接見つけられるようになり、復号のパフォーマンスが向上します。その代わり、暗号化データのサイズがやや肥大化します。

なるほど。
これがないとどのキーで暗号化されたデータなのかわからないので、全部試すけど参照が保存されていればどれを使ったのかわかるので速くなる、と。

以下の手順でやってみます。

  1. キー参照を保存する設定を追加
  2. primary_keyが1個の状態で暗号化
  3. primary_keyを2個に増やして復号化
  4. 再度暗号化(2個目のキーで暗号化)
  5. 1個目のキーを削除して復号化

1. キー参照を保存する設定を追加

config/application.rb
config.active_record.encryption.store_key_references = true

2. primary_keyが1個の状態で暗号化

active_record_encryption:
  primary_key:
    - Ot8iSvgBTpaNsAkLFhMQsrf1EWDtmWHK
  deterministic_key: 0jefyhajq3hnr96sldPoqu8ISFpI9zRZ
  key_derivation_salt: q6o3RhCV1hoiGqWnQEgVKGwZQieFrgcF
irb(main):001:0> User.find(1).encrypt; ActiveRecord::Encryption.without_encryption { puts User.find(1).address }
  User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  TRANSACTION (0.1ms)  BEGIN
  User Update (0.3ms)  UPDATE "users" SET "address" = $1 WHERE "users"."id" = $2  [["address", "{\"p\":\"z7JMNJCWItb8UBZx\",\"h\":{\"iv\":\"wG0bFED5BAjR2Eyw\",\"at\":\"mGXzZOnnd0Twl+hooPkurg==\",\"i\":\"ZTdhNA==\"}}"], ["id", 1]]
  TRANSACTION (2.7ms)  COMMIT
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
{"p":"z7JMNJCWItb8UBZx","h":{"iv":"wG0bFED5BAjR2Eyw","at":"mGXzZOnnd0Twl+hooPkurg==","i":"ZTdhNA=="}}
=> nil

i が増えましたね。

3. primary_keyを2個に増やして復号化

active_record_encryption:
  primary_key:
    - Ot8iSvgBTpaNsAkLFhMQsrf1EWDtmWHK
    - e9afd0341ae5673010bda098db826ce3
  deterministic_key: 0jefyhajq3hnr96sldPoqu8ISFpI9zRZ
  key_derivation_salt: q6o3RhCV1hoiGqWnQEgVKGwZQieFrgcF
irb(main):004:0> User.find(1).address
  User Load (0.5ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> "東京都xxx"

irb(main):005:0> User.find(1).address_before_type_cast
  User Load (0.5ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> "{\"p\":\"z7JMNJCWItb8UBZx\",\"h\":{\"iv\":\"wG0bFED5BAjR2Eyw\",\"at\":\"mGXzZOnnd0Twl+hooPkurg==\",\"i\":\"ZTdhNA==\"}}"

4. 再度暗号化(2個目のキーで暗号化)

irb(main):006:0> User.find(1).encrypt; User.find(1).address_before_type_cast
  User Load (0.5ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  TRANSACTION (0.1ms)  BEGIN
  User Update (0.4ms)  UPDATE "users" SET "address" = $1 WHERE "users"."id" = $2  [["address", "{\"p\":\"Uh0MVyUU2snhbF7A\",\"h\":{\"iv\":\"4qyRCEEQRWU61GzV\",\"at\":\"3khLmx4IgjloxzpN5+hEDg==\",\"i\":\"N2E4NQ==\"}}"], ["id", 1]]
  TRANSACTION (2383.2ms)  COMMIT
  User Load (0.3ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> "{\"p\":\"Uh0MVyUU2snhbF7A\",\"h\":{\"iv\":\"4qyRCEEQRWU61GzV\",\"at\":\"3khLmx4IgjloxzpN5+hEDg==\",\"i\":\"N2E4NQ==\"}}"

i の値が変わりました

5. 1個目のキーを削除して復号化

active_record_encryption:
  primary_key:
    - e9afd0341ae5673010bda098db826ce3
  deterministic_key: 0jefyhajq3hnr96sldPoqu8ISFpI9zRZ
  key_derivation_salt: q6o3RhCV1hoiGqWnQEgVKGwZQieFrgcF

1個目のキーを削除しました
(2個目を残した)

irb(main):001:0> User.find(1).address
  User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> "東京都xxx"

復号化できました。
これでローテーション完了ですね。

キー参照の i はどういう値なのか?

https://github.com/rails/rails/blob/0c97d1db023de4df6d2df8829e5ee311ff0d0e28/activerecord/lib/active_record/encryption/properties.rb#L25

      DEFAULT_PROPERTIES = {
        encrypted_data_key: "k",
        encrypted_data_key_id: "i",
        compressed: "c",
        iv: "iv",
        auth_tag: "at",
        encoding: "e"
      }

encrypted_data_key_id というプロパティらしい

https://github.com/rails/rails/blob/f95c0b7e96eb36bc3efc0c5beffbb9e84ea664e4/activerecord/lib/active_record/encryption/key_provider.rb#L22

        @encryption_key ||= @keys.last.tap do |key|
          key.public_tags.encrypted_data_key_id = key.id if ActiveRecord::Encryption.config.store_key_references
        end

key.id が正体っぽいですね

https://github.com/rails/rails/blob/f95c0b7e96eb36bc3efc0c5beffbb9e84ea664e4/activerecord/lib/active_record/encryption/key.rb#L23-L25

なるほど、 sha1の先頭4文字 みたいですね。

https://github.com/rails/rails/blob/f95c0b7e96eb36bc3efc0c5beffbb9e84ea664e4/activerecord/lib/active_record/encryption/key_provider.rb#L32-L38

ここで使われていました。

先程のデータの iN2E4NQ== になっていたので
これをbase64 decodeすると 7a85 になるので、そういうことみたいですね。

暗号化のキーを環境変数から読み込みたい

Rails Credentialsを使いたくないケースもあると思うので
SecretManagerなどで管理しているキーを使う方法も考えてみます。
※個人的にはアプリケーションと設定値は切り離したいのでcredentialsはあまり積極的には使っていなかったりします。

6.1 設定オプション

ちゃんと用意されてますね。
KeyProviderを拡張する方法もあるみたいですが、設定でやるほうが楽なので今回はそっちでやってみます。

  • config.active_record.encryption.primary_key
  • config.active_record.encryption.deterministic_key
  • config.active_record.encryption.key_derivation_salt

この3つを設定すればrails credentialsは削除できそうです。

config/application.rb
...
    config.active_record.encryption.primary_key         = ENV["AR_ENC_PRIMARY_KEY"]
    config.active_record.encryption.deterministic_key   = ENV["AR_ENC_DETERMINISTIC_KEY"]
    config.active_record.encryption.key_derivation_salt = ENV["AR_ENC_KEY_DERIVATION_SALT"]
...
docker-compose.yml
...
  app:
    container_name: app
    build:
      context: .
      target: dev
      args:
        bundle_install_options: --jobs 10
      cache_from:
        - ar-enc-example-app
    volumes:
      - ./:/app:cached
    environment:
      RAILS_LOG_TO_STDOUT: 1
      TZ: Asia/Tokyo
      # 以下追加
      AR_ENC_PRIMARY_KEY: e9afd0341ae5673010bda098db826ce3
      AR_ENC_DETERMINISTIC_KEY: 0jefyhajq3hnr96sldPoqu8ISFpI9zRZ
      AR_ENC_KEY_DERIVATION_SALT: q6o3RhCV1hoiGqWnQEgVKGwZQieFrgcF
    ports:
      - 3001:3000
    tty: true
    command: /bin/sh -l -c 'bundle && bin/rails s'
    depends_on:
      - postgres
...

credential系を削除します

rm -rf config/credentials

❯ rm -rf config/credentials.yml.enc

再起動します

docker compose up -d
[+] Running 2/2
 ⠿ Container postgres  Running
 ⠿ Container app       Started

rails consoleからデータを参照してみます

irb(main):001:0> User.find(1).address
  User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
=> "東京都xxx"

できましたね。

キーローテーションしたい場合は

    config.active_record.encryption.primary_key         = [ENV["AR_ENC_PRIMARY_KEY"], ENV["AR_ENC_PRIMARY_KEY_NEW"]]
    config.active_record.encryption.deterministic_key   = [ENV["AR_ENC_DETERMINISTIC_KEY"], ENV["AR_ENC_DETERMINISTIC_KEY_NEW"]]

配列で設定すれば大丈夫そうです。

まとめ

  • ActiveRecord Encryption思ったより楽に使えそう
  • 途中で暗号化スキームを変更する(非決定論的 -> 決定論的)をやると少し面倒
    • encrypts :address, deterministic: { fixed: false }, previous: { deterministic: false }
      • これが直感的ではないので、コードから意味を理解しにくい気がする
    • でもこれができるように設計されているのはすごい
  • 決定論的暗号化を使う場面
    • クエリしたい時
      • 検索は全文検索エンジンにまかせてしまう、みたいな方針であれば困らないかも
    • ユニーク制約をかけたい時
    • 基本的にはデフォルトでもある非決定論的暗号化の方が安全で良い
  • キーローテーションができるの良い
    • これ意外と面倒なので良い
    • 決定論的暗号化でも出来ると嬉しい

Discussion