Rails 7のActive Record暗号化機能(翻訳)
Rails 7のActive Recordに、実にクールな新機能が導入されることになりました。モデル内で使える強力なencrpyts
宣言によって呼び出される、アプリケーションレベルの暗号化機能です。この新機能は、アプリケーションコードとデータベースの間に暗号化の層を提供します。要するに、ActiveRecord::Encryptionを用いたデータがActive Recordオブジェクトに読み込まれると平文になり、データベースに置かれると暗号化されます。
本記事では、この新機能の使い方の概要を説明し、いくつかの優秀な機能を紹介するとともに、制限事項についても触れます。
本題に入る前に、Edgeガイドの素晴らしいドキュメントを紹介しておかないといけませんね。
参考: Active Record Encryption — Ruby on Rails Guides
本記事(英語版)では簡単のため、この新機能を単に「暗号化(encrypts)」と呼ぶことにします(他の呼び方を思いつかないので😉)。
暗号化機能登場の背景
暗号化機能は、@jorgemanrubiaによるプルリク#41659の形でRailsにマージされました。プルリクの説明では、HEY(訳注: Basecampのサービス)で用いられている暗号化機能を抽出したものだそうです。この機能が導入するまでの経緯に関心のある方は、Jorgeによる以下の興味深いブログ記事をどうぞ。
決定論的暗号化1について
ここで、決定論的暗号化(deterministic encryption)と非決定論的暗号化(non-deterministic encryption)について簡単に触れておきます。いたって単純な話ではありますが、暗号化機能の利用方法を理解するうえで重要なポイントとなります。
ここで言う暗号化とは、「何らかのテキスト入力(平文)に対してある関数を適用し、テキスト出力(暗号文)を得ること」とお考えください。
この関数が決定論的である場合、同じテキストに対してその関数を適用すると必ず同じ結果が得られます。
この関数が非決定論的である場合、ある値を暗号化したときの出力は予測できなくなります。理論上は1回目と2回目で同じ出力を得られる可能性もありますが、その確率は極めて小さくなります。非決定論的な暗号化がデフォルト設定となっている場合、同じ平文を暗号化するたびに、ほぼ確実に異なる暗号文が出力されます。
モデルの属性を暗号化する場合に決定論的暗号化を用いると、同じ平文をデータベース内で2つの行に保存すると、暗号文の値も同じになります。逆に非決定論的暗号化を用いると、同じ平文をデータベース内で2つの行に保存したときの暗号文の値は一般に異なります。後ほど説明しますが、この決定論的/非決定論的の違いは、暗号化済みデータをクエリできるかどうかに影響します。
セットアップ
暗号化を使うための設定はそれほど多くありませんが、注意しておきたい点がいくつかあります。
キー
主に必要となるのは、キーセットを生成してcredentialファイルに追加することです。bin/rails db:encryption:init
を実行すれば、ファイルに追加するためのキーが以下のように生成されます。
Add this entry to the credentials of the target environment:
active_record_encryption:
primary_key: zxMXS0hBbpa5BzRKPv9HOSF9etBySiHQ
deterministic_key: 0pM2UHHBQr1kf1irO6JgakcSOXu0r1Vn
key_derivation_salt: I5AkViD0UJhSqK3NY49Zvsls3ZoifyXx
primary key
は、非決定論的暗号化で用いるルート暗号化キーを導出するのに使われます。なお、credentialファイル内のprimary_key
の値には、キーをリストで複数書くこともできます。
deterministic_key
は、決定論的暗号化で用いられます。上述のとおり、このキーを用いて同じデータに対して暗号化を行うと何度やっても同じ結果が得られます。現時点の暗号化機能では、決定論的暗号化で用いるキーをリスト形式で複数書くことをサポートしていません。決定論的暗号化を完全に無効にしておきたければ、このキーを提供しないでおくのが確実です。
key_derivation_salt
は、暗号化キーの導出に使われます。
アプリの設定
暗号化APIではさまざまなオプションが用意されていて、どのオプションもconfig.active_record.encryption
名前空間の下で定義されています。これらのオプションを使う場合は、このAPIドキュメントを熟読することをおすすめします。一読すれば、ほとんどのオプションに合理的なデフォルト値が設定されていることがわかるでしょう。
config.active_record.encryption.extend_queries
について少し解説を加えます。このオプションはデフォルトではfalse
になっていますが、これをtrue
にすると以下が許可されます。
- 暗号化済みカラム内で平文データをクエリできるようになる(
config.active_record.encryption.support_unencrypted_data
も有効にする必要あり) - 暗号化スキームを複数サポートできるようになる
- uniquenessバリデーションのサポートが有効になる
データベース
暗号化された文字列やテキスト属性がデータベースに保存されるときには、通常の文字列やテキストではなく、書き込み時にシリアライズされ読み出し時にデシリアライズされる複雑なデータ構造として保存されます。このデータ構造のおかげで暗号化済みテキストに加えていくつかのメタ情報も保存でき、アプリがテキストの暗号化方式を知る手がかりをある程度得られるようになります。
メタ情報が追加されるため、ストレージで最大250バイトのオーバーヘッドが発生します。
edgeガイドでは、カラムを暗号化する場合は255バイトのstringフィールドを510バイト2に増やしておくことを推奨しています。textフィールドのオーバーヘッドについては、一般に無視できる範囲であるとされています。
呼び出し
いよいよ暗号化を使うときが来ました。
最も基本的なユースケースでは、あるカラムを暗号化するのに必要なのは、モデル内の暗号化したい属性にencrypts
宣言を追加することだけです。たとえばDog
モデルにあるtoy_location
というフィールドを暗号化したい場合は以下のように書きます(イヌはよくおもちゃを隠しますよね)。
class Dog < ApplicationRecord
encrypts :toy_location
簡単でしょう?
書き込み
暗号化された属性への書き込みは、完全に透過的に行なえます。いつもRailsでやっているように書き込めばよいのです。
> dog = Dog.create!(name: 'Bruno', toy_location: 'top secret')
データベース内に保存されている内容を直接表示してみると、次のようになります。
> result = Dog.connection.execute('SELECT toy_location FROM dogs LIMIT 1').first
(1.4ms) SELECT toy_location FROM dogs LIMIT 1
#=> {"toy_location"=>"{\"p\":\"oVgEJvRaX6DJvA==\",\"h\":{\"iv\":\"WYypcKysgBY05Tum\",\"at\":\"OaBswq+wyriuRQO8yCVD3w==\"}}"}
この値はシリアライズされたJSONなので、以下のようにparse
してみましょう。
> JSON.parse(result['toy_location'])
#=> {"p"=>"oVgEJvRaX6DJvA==", "h"=>{"iv"=>"WYypcKysgBY05Tum", "at"=>"OaBswq+wyriuRQO8yCVD3w=="}}
するとハッシュが得られました。ハッシュ内にあるキーのほとんどはActiveRecord::Encryption::Properties::DEFAULT_PROPERTIES
で定義されています。p
はペイロードで、平文を暗号化した暗号文を表します。h
は、暗号化操作に関連する情報を含むヘッダーのハッシュです。iv
は平文を暗号化したときの初期化ベクトル(initialization vector)です。詳しくは次のセクションで説明します。at
は、復号の際に暗号文が改変されていないことを確認するためのauth_tag
です。暗号化の設定や利用方法によっては、DEFAULT_PROPERTIES
ハッシュ以外にもヘッダーが追加されることがあります。
読み出し
暗号化された属性を持つモデルをRailsで読み込むと、暗号化された値がシームレスに復号されます。上で作成したDog
モデルを名前で検索してみましょう。
> Dog.find_by!(name: 'Bruno').toy_location
#=> <Dog id: 1, name: "Bruno", toy_location: "top secret", created_at: "2021-05-28 22:41:23.142635000 +0000", updated_at: "2021-05-28 22:41:23.142635000 +0000">
ご覧のように、暗号化された値がモデルインスタンス上で自動的に人間が読める形の属性に変換されます。なかなかよくできています。
検索
Brunoというイヌを、名前ではなくtoy_location
で検索したいときは、以下のようにフィールドが暗号化されていないときと同じように行なえます。
> dog = Dog.find_by!(toy_location: 'top secret')
Dog Load (2.1ms) SELECT "dogs".* FROM "dogs" WHERE "dogs"."toy_location" = ? LIMIT ? [["toy_location", "{\"p\":\"oVgEJvRaX6DJvA==\",\"h\":{\"iv\":\"WYypcKysgBY05Tum\",\"at\":\"OaBswq+wyriuRQO8yCVD3w==\"}}"], ["LIMIT", 1]]
#=> #<Dog id: 1, name: "Bruno", toy_location: "top secret", created_at: "2021-05-28 22:41:23.142635000 +0000", updated_at: "2021-05-28 22:41:23.142635000 +0000">
クエリの文字列が、先ほどデータベースの内容を覗いたときに見えた暗号化済みJSON文字列に自動的に変換されていることにご注目ください。
初期化ベクトルと決定論について
決定論的暗号化を使う場合、同じ平文値を持つすべてのレコードで、暗号化に同一の初期化ベクトルが使われます。これはActive Recordが同一入力に対して同じ暗号文を生成するためのものであり、暗号化済みデータを検索するにはこれが前提条件となります。Railsの内部では、決定論的に暗号化されたデータの場合は平文から初期化ベクトルを生成しますが、そうでない場合は初期化ベクトルをランダムに生成します。
同じ平文を持つ2つの行が、それぞれ異なる初期化ベクトルで暗号化されると、データベースに保存されるシリアライズ済みJSONはまったく異なるものになります。
暗号化済みデータを検索可能にするには、ここで保存される値を完全に同じにする必要があります。
つまり、同じ平文を持つ2つの行では、シリアライズされたハッシュに保存される値がすべて同一になる必要があります。そしてRailsはまったく同一のハッシュをその場で再計算して、検査したい文字列にマッチする行を見つけられます。
まさに決定論の最たるものですね。
平文の検索について
最初から暗号化しておく余裕を取れない場合はどうすればよいでしょう。たとえば、既に存在するDog
のテーブルにあるtoy_location
カラムが暗号化されていない場合はどうすればよいでしょうか。
上で生成したクエリを見ればわかるように、Dog
のレコードのtoy_location
カラムに「top secret」という平文がある場合、この平文はクエリで検索できません。また、平文が保存されているDog
のレコードをメモリに読み込もうとすると、平文への復号を試みるときに問題が発生する可能性が高いでしょう。
ひとつの方法は、平文データを事前に暗号化データに変換しておくことです。私にはこれが理想的な方法に思えます。しかし何らかの理由でそうしたデータ移行を避けたい事情が生じるかもしれません。
そのような場合は、平文値を引き続き平文のままで保存し、新規または更新データを暗号化するオプションを利用できます。暗号化データと平文データの共存サポートを有効にするには、Rails設定のconfig.active_record.encryption.support_unencrypted_data
オプションをオンにします。
この挙動を有効にすると、平文への復号を試みたときにエラーの発生を防止でき、カラムの平文と暗号文の間にデータのミスマッチがある場合にも検索できるようになります。
この設定を有効にして先ほどのクエリを再実行してみると以下のようになります。
Dog Load (0.3ms) SELECT "dogs".* FROM "dogs" WHERE "dogs"."toy_location" IN (?, ?) LIMIT ? [["toy_location", "{\"p\":\"Bd+/TzEysF2CCQ==\",\"h\":{\"iv\":\"R2IUJJ+EmnDnZvQP\",\"at\":\"zqG5WAJql1zgctRCPpoBkQ==\"}}"], ["toy_location", "top secret"], ["LIMIT", 1]]
これで、暗号化済みコンテンツを持つレコードや、そのコンテンツの平文を持つレコードを検索できるようになりました。完璧ですね。
大文字小文字を区別しない検索
デフォルトの検索では大文字小文字が区別されます。何らかの理由で大文字小文字を無視して検索したい場合は、いくつかのオプションがあります。
- オプション1
Dog.where(toy_location: ['Top secret', 'top secret'])
のように、マッチする必要のある大文字小文字のバリエーションをすべて含んだクエリを送信します。
- オプション2
encrypts
宣言でdowncase: true
を指定します。これによって、テキストを小文字(downcase)に変換してから保存するようになります。Active Recordは、クエリ実行時に検索テキストを自動的に小文字に変換します。この方法の欠点は、大文字の情報がすべて失われてしまうことです。下世話な話(downer)で恐縮です。
- オプション3
encrypts
宣言でignore_case: true
を指定し、さらにoriginal_カラム名
(original_toy_location
など)をデータベースに追加します。
以下のように、大文字を含む「Top secret」というテキストを登録したとします。
Dog.create!(name: 'Max', toy_location: 'Top secret')
このときtoy_location
カラムには小文字に変換された「top secret」が保存され、original_toy_location
カラムには大文字を含んだままの「Top secret」が保存されます。
これで常にtoy_location
カラムに対して検索が行われるようになり、toy_location
属性はoriginal_toy_location
からメモリに読み込まれて生成されるようになります。
ここでひとつ知っておきたい点があります。このtoy_location
カラムは決定論的に暗号化されています(だからこそ検索が効くわけです)が、original_toy_location
カラムは非決定論的に暗号化されるようです。original_toy_location
カラムの検索をサポートする必要はないので、これは理にかなっています。同じ平文値を持つ2つのレコードでtoy_location
カラムとoriginal_toy_location
カラムの値を比較してみると、このことを確認できます。以下のようにtoy_location
カラムには同じ値(初期化ベクトルやペイロードなど)が保存されていて、検索可能かつ小文字変換済みになっています。しかしoriginal_toy_location
カラムの値は互いに異なっており、検索不能かつ大文字小文字が維持されています。
{ "toy_location" => "{\"p\":\"Bd+/TzEysF2CCQ==\",\"h\":{\"iv\":\"R2IUJJ+EmnDnZvQP\",\"at\":\"zqG5WAJql1zgctRCPpoBkQ==\"}}",
"original_toy_location" => "{\"p\":\"5syLqDK6GCbBDw==\",\"h\":{\"iv\":\"KBGp4FrI7oL4/a3p\",\"at\":\"JnH6hxLX35cAwroImk2XqQ==\"}}" },
"toy_location" => "{\"p\":\"Bd+/TzEysF2CCQ==\",\"h\":{\"iv\":\"R2IUJJ+EmnDnZvQP\",\"at\":\"zqG5WAJql1zgctRCPpoBkQ==\"}}",
"original_toy_location" => "{\"p\":\"0246w4+SSqqlJw==\",\"h\":{\"iv\":\"1uEnjlCNot9sYNgR\",\"at\":\"UhkhK6YlOTxJg75juqIMGA==\"}}" }
その他のクールな機能
Railsの暗号化には、これまで紹介した機能以外にも多くの機能が備わっています。本を書いているわけではないので詳しくは解説しませんが、そうした機能もいくつか紹介しておきます。
これまで見てきたのは単純な文字列の暗号化でしたが、実はリッチテキスト属性も暗号化可能です。
また、以前利用していた暗号化スキームの利用についてもサポートされています。つまり、当初は非決定論的に暗号化していたカラムを、後で決定論的な暗号化に切り替えられるということです。この機能を使う前には、ぜひドキュメントの隅々まで目を通しておくことをおすすめします。
(非決定的な)キーのローテーションも可能です。素晴らしい機能ですが、現時点では決定論的暗号化には対応していない点にご注意ください。
キーのローテーションに関連する話として、暗号化に用いたキーへの参照を暗号化データ自身に保存する設定も可能です。
決定論的暗号化を使っている場合は、unique
制約がサポートされます。暗号化済みカラムの一意性を担保する必要がある場合はいくつか注意点がありますので、使う前に必ずガイドを読んでください。
暗号化済みカラムは、デフォルトでRailsのログから自動的に除外されます。この機能を無効にするオプションも提供されています。
ここで触れておきたいのは、この実装はモジュール化されていて、かなりのレベルでカスタマイズ可能な点です。暗号化オプションの多くは、属性単位でもグローバルなレベルでも設定可能です。
暗号化機能の制約
魅力たっぷりの暗号化機能にもいくつかの制約があります。私が気になった制約を以下にリストアップしました。暗号化の機能は多岐に渡っていて、適用可能なユースケースもさまざまなので、気になる制約も人によって変わってくるでしょう。
- あいまい検索: 暗号化が提供する検索機能では、検索テキストとの完全一致が必須になります。すなわち
LIKE
クエリなどが使えないということです。これは、暗号化済みカラムに対するクエリは、生SQLではなく、すべてRailsとActive Recordを経由する必要があるということでもあります。 - リッチテキストの検索: リッチテキストを暗号化できるのは明らかですが、現時点では非決定論的にしか暗号化できません。つまりリッチテキストは検索できません。
- 決定論的検索は複数キーをサポートしない: 決定論的な暗号化および検索を使う場合、同時に2つ以上のキーを利用できない点に注意が必要です。キーの変更が必要な場合は、何らかの特殊な操作が必要になりそうです。
- Railsコンソールでデータが見える: 自明のことと思われるかもしれませんが、万一悪意のある人がRailsコンソールにアクセスすれば、暗号化済みデータをオブジェクトに読み込んで一日中平文を見ることができてしまう可能性があります。Jorge氏のブログ記事によると、HEYでは暗号化に加え、コンソール拡張機能を用いてコンソールアクセスを保護および監査しているそうです。残念ながらこの拡張機能は同社のプライベートgemであり、現時点ではRailsで利用できません3。
- 決定論的暗号化はセキュリティが低下する: 実装そのものの問題ではないと思いますが、決定論的暗号化を用いれば、暗号化された属性に同じ値を持つ2つの行は、暗号を逆解析することはできなくても、一方の行の平文値を突き止められれば他方の行の平文値も判明します。非決定論的暗号化にはこのような弱点はありません。
まとめ
一言でいうと、Railsの暗号化は相当興味をそそられる機能です。他の人たちがこの暗号化機能をどう活用し、今後どう進化するかを見ていくのは素晴らしいことだと感じています。私の予感では、この暗号化機能を使い始める人は今後増え、その人たちがコードを深く調べていくうちに(決定論的暗号化で複数キーがサポートされていないなどの)さまざまな問題が解消されるのではないかと睨んでいます。
概要
原著者の許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。