Railsアプリケーションで既存データの暗号化をした話

こんにちは、法人開発のnakamoriです。本エントリでは健康診断データを安全に扱うために、それらに暗号化を施しどう運用していったかを紹介しています。
ただし、暗号化のアルゴリズムやモードに関しては本エントリでは扱いません。

暗号化の背景

以前にも紹介しましたが、「FiNC INSIGHT」では様々なヘルスケア関連のデータを扱っています。その中には暗号化して保持しておいた方が良いものも存在します。
その中でも特に健康診断データは個人情報やヘルスケア情報を扱っています。
これらのデータをより安全に運用していくために、健康診断データを論理レベルで暗号化することにしました。

attr_encryptedによる暗号化の紹介

特定のカラムを暗号化するには、attr_encryptedというgemを使うと便利です。
ここではUserモデルのemailフィールドを暗号化する場合を例にします。

attr_encryptedではemailに対してencrypted_emailとencrypted_email_ivの2つのカラムで暗号データを扱います。
encrypte_emailが暗号化されたデータ、encrypte_email_ivが同じ平文が同じ暗号データにならないようにするための初期化ベクトルです。

まずはこの2つのカラムをmigrationで追加します。

class AddEncryptedColumnIntoUser < ActiveRecord::Migration
def change
add_column :users, :encrypted_email, :string
add_column :users, :encrypted_email_iv, :string
end
end

次にemailアクセサでemailの暗号化/復号するように設定を記述します。

class User < ApplicationRecord
attr_encrypted :email, key: ’32 byte string’
end

これにより user.email とすることで、復号されたemailが取得でき、user.email = ‘user@example.com’ とすることで暗号化されたemailが格納されるようになります。
既にemailフィールドが存在していて、後からattr_encryptedで暗号化を施した場合、既存のemailには何も入らなくなります。カラムのデフォルト値が指定されていればそれが入ります。

大量のデータに後から暗号化する場合の課題

上記で説明した通り、attr_encryptedを使うことでお手軽に暗号化を扱うことができます。
しかし、既存の平文データに後からattr_encryptedを用いて暗号化を行う場合に1つ問題が発生します。
それは、encrypte_email, encrypte_email_ivカラムを追加した直後は全てのデータが空なので、この時点でattr_encrytpedの記述を施し、参照を暗号データに向けると全てのemailがnilを返すようになります。
内部的にはemailのフィールドは生きているので、

User.all.each do |user|
user.email = user[:email]
user.save
end

のように暗号化アクセサに平文を代入して保存してやればattr_encryptedを設定したemailも正しいデータを返すようになります。
しかし、レコード数が膨大になると全レコードに対する暗号化もレコード数に応じた時間がかかり、その間は上記のように正常にデータを返せない時間が発生することになります。
そのため、このようなダウンタイムを伴わないためには適切な移行作業が必要になります。

既存データに暗号化を適用する場合の移行プロセス

ダウンタイムを伴わず、平文データを保持しつつ暗号化を行なっていく場合、次のような移行作業が必要になります。

1. カラムの追加
2. new_emailアクセサをencrypted_emailに紐づける
3. 全レコードの暗号化
4. 参照・更新をencrypted_emailに向ける
5. 平文カラムの削除

ここからは各作業について説明します。

1.カラムの追加

暗号化用のカラムを追加します。これは単純にマイグレーションでencrypted_emailとencrypted_email_ivを追加するのみです。

class AddEncryptedColumnIntoUser < ActiveRecord::Migration
def change
add_column :users, :encrypted_email, :string
add_column :users, :encrypted_email_iv, :string
end
end

2.new_emailアクセサをencrypted_emailに紐づける

attr_encryptedにより暗号化を施す場合、下記のような記述で設定をしてしまうと、通常の平文データへのアクセサを上書きし、暗号化データへの参照・更新が向いてしまいます。

 class User < ApplicationRecord
attr_encrypted :email, key: ’32 byte string’
end

カラムを追加し、全てのレコードの暗号化、つまりencrypted_emailとencrypted_email_ivへの値の格納が完了するまでは、既存通りemailアクセサを通してemailの平文データに参照・更新を向けて、別のアクセサ(ここではnew_emailとします)を、暗号化データに参照・更新が向けることができれば、アプリケーションの挙動を変えず、裏側で安全にゆっくり暗号化を行うことができます。
これを実現するためには、attr_encryptedのattributeオプションを利用します。
attributeオプションではアクセサがどの暗号カラムに紐づくかを指定することができます。

class User < ApplicationRecord
attr_encrypted :new_email, key: ’32 byte string’, attribute: ‘encrypted_email’
end

このようにすることで、user.emailへの参照・更新は引き続き平文カラムに向く一方で、user.new_emailへの参照・更新は暗号カラムに向きます。user.new_emailへの代入でencrypted_emailとencrypted_email_ivにデータが格納されていきます。
この時点でのModelのアクセサとTableのカラムの対応は以下のようになります。

平文データと暗号データを更新時に同期を取り、内容の乖離を防ぐ必要があります。これはbefore_saveコールバックで平文データを暗号データに代入するようにしておきます。

class User < ApplicationRecord
before_save do
self.new_email = email
end
end

3.全レコードの暗号化

上記1, 2で暗号化を行う準備が整いました。あとは全レコードを走査して平文データを暗号化していきます。
1行ずつeachでループしながら代入してもよいですが、より高速に処理したい場合は、以前公開したエントリ 大量のデータをCSVでインポートする でも述べたActiveRecord::BulkInsertを使うと良いです。

User.all.find_in_batches do |users|
users.each { |user| user.new_email = user.email }
User.import users, on_duplicate_key_update: [:encrypted_email, :encrypted_email_iv]
end

on_duplicate_key_update 指定しておくとPRIMARY KEYやUNIQUE KEYのユニーク制約に抵触する場合にUPDATE文になります。この場合は全てPRIMARY KEYが同じなので、UPDATEでの更新になります。
バルクでまとめての更新になるので1行ずつUPDATEするより高速です。

4. 参照・更新をencrypted_emailに向ける

全レコードの暗号化が完了したら、emailアクセサでの参照・更新をencrypted_emailに向けます。

class User < ApplicationRecord
attr_encrypted :email, key: ’32 byte string’
end

これは単純に new_emailだったところをemailに変更するのみです。attributeは残しておいても良いですが、不要なので記述を削除しています。
これにより、参照・更新は全て暗号カラムから行われることになります。更新時にはemailの平文カラムは特に何もなされません。既存レコードにはそのままの値が、新規レコードにはNULLが入ることになります。
平文レコードにNOT NULL制約などが存在する場合はcallbackで暗号化前のデータか適当なダミーデータを入れて、制約を回避します。

5. 平文カラムの削除

4.までで平文カラムは不要になります。そのまま残しておくと暗号化した意味がないので、マイグレーションでカラムを削除します。

class RemoveRawColumnFromUser < ActiveRecord::Migration
def change
remove_column :users, :email
end
end

暗号化されたデータでの検索の実現

健康診断データでは保険証情報も扱っており、指定した保険証が登録されているかを照会する要件があります。
今回、保険証情報も暗号化していますが、この照会の機能を実装するにあたって、暗号化されたデータで検索を実現する必要があります。
attr_encryptedでは mode オプションに single_iv_and_salt を指定した場合には、find_by_email などの動的に追加されたメソッドによる暗号化したデータでの検索ができるようになります。
しかし、 single_iv_and_salt は名前の通り全レコードで初期化ベクトルを共有するため、同じ平文では同じ暗号文になってしまうため、出現頻度による推定が可能になってしまいます。

今回の保険証を検索するケースでは、保険証に記載されている、記号・番号・保険者番号・生年月日の属性を指定しての検索しかありませんでした。そこで、保険証情報を登録時にこれらの属性からハッシュ値を算出し、保険証情報に付与しておきます。

class Card < ApplicationRecord
before_save do
self.search_key = generate_hash(symbol, number, union_number, birth_date)
end

def generate_hash(*args)
Digest::SHA3.hexdigest(args.join(‘’) + SALT, HASH_LENGTH)
end
end

照会時には入力のあった上記の属性値でハッシュ値を算出し、同一のハッシュ値を持つ保険証情報を検索結果として提示します。

class Card < ApplicationRecord
def find_by_card_params(symbol, number, union_number, birth_date)
find_by(search_key: generate_hash(symbol, number, union_number, birth_date))
end
end

番号や生年月日などユーザーごとに変わりやすいものを含めた複数の属性でハッシュ値を算出することで、同一のハッシュ値が生成される可能性を下げ、出現頻度による推定を難しくしています。
このようにして、暗号化されたデータに対する特定のケースでのみの検索を実現しています。

逆に検索するための属性が限定されない場合、例えばあるときは記号と番号で検索したいが、別のときには氏名と誕生日で検索したいといった、検索条件が動的に決まる場合は、暗号化すること自体を見直す必要があります。

現実の移行作業で気をつけたこと

ここからは大量の健康診断結果や健保のデータを暗号化していく際に直面した課題について紹介します。

暗号化が漏れていないかの検証

アプリケーションの規模が大きいと、思わぬところにイレギュラーな更新処理が存在する場合があります。
特に多人数開発していたり前任者から引き継ぎをした場合、暗号化の対応者が全ての処理を把握することは困難です。
そこで、一度暗号化をして平文カラムを削除する前に、ある程度の時間をおきながら何度か平文データと復号されたデータが一致するかを検証するのが望ましいです。
検証自体は下記のような単純なコードでよいです。

User.find_each do |user|
raise “Found invalid record: id=#{user.id}” if user.email == user[:email]
end

大量のデータの変更は思わぬ見落としがあるものです。このような簡単な検証を行なっておくことが見落としを防ぎ、総合的なコストを抑えます。

型の扱い

attr_encryptedでは、通常、復号した結果は文字列になります。
そのため、血圧や脈拍など元々数値だったものに対してattr_encryptedを適用すると、復号した際に文字列データとして返ってきてしまいます。今回のように元々データを扱う実装が既にされていて、暗号化をした際に型情報が変わると思わぬ不具合を引き起こしかねません。そのため、復号した結果の型をちゃんと定義しておきたい要求があります。

この問題を解決するため、attr_encryptedではmashalerを指定できるのでそれを利用しました。
例えば体重を扱うモデルでは以下のようにmarshalerを指定します。

module Result
class Height < ApplicationRecord
attr_encrypted :value,
key: ’32 byte string’,
marshal: true,
marshaler: Result::IntegerMarshaler
end
end

attr_encryptedのdecryptメソッド内で、marshalerのloadメソッドが呼ばれます。今回は単純にActiveModel::TypeへのAdapterとして実装しています。これにより値を取得する境界面で指定した型にキャストしています。

module Result
class IntegerMarshaler
def load(value)
ActiveModel::Type.lookup(:integer).cast(value)
end
end
end

attributesやpluckへの対応

ActiveRecordのattributesメソッドはモデルの全てのカラムとそれに対応した値の入ったhashを返します。
しかし、attr_encryptedを使って暗号化した場合には、平文のデータは消え、暗号化したデータが入ったhashを返します。

 user.attributes
=> { encrypted_email: ‘…’, encrypted_email_iv: ‘…’ }

これに対応するため、元のattributesと同じ挙動を示す decrypted_attributes を下記のように実装しました。

def decrypted_attributes
encrypted_keys = encrypted_attributes.map { |_, v| [v[:attribute].to_s, “#{v[:attribute]}_iv”] }.flatten
decrypted = encrypted_attributes.keys.map { |attr| [attr.to_s, send(attr)] }.to_h
attributes.reject { |k| encrypted_keys.member?(k) }
.merge(decrypted)
end

encrypted_attributes で暗号化のカラムを取得し、attributesからそれらを削除して、復号したkeyとvalueを追加しています。

また、暗号化対象のカラムに対してpluckで取得している場合も、正しく取得できなくなります。そのため、モデルのアクセサを全て通す必要があります。パフォーマンスを気にする場合は、取得するカラムをselectで限定します。

emails = User.all.select(:encrypted_email, :encrypted_email_iv).map(&:email)

まとめ

本エントリでは、既に運用されているサービスにattr_encryptedでデータを暗号化していく作業・運用について紹介しました。

ただ、今回の一連の作業を通して思ったことは、データを暗号化するのであれば最初から検討すべきということです。
今回紹介した作業を読んでいただければわかるかと思いますが、サービスが長期間止められない制約の上で後から暗号化を施したい場合は「カラムの追加と平文データの暗号化」「参照更新を暗号データに向ける」「平文データの削除」といった段階的な作業が伴い、最初から暗号化を行なっていた場合に比べてコストが余計にかかります。
また、暗号化していない前提で作られたロジック全てに対して、その存在自体を把握し挙動が変わらないことを保証しながら安全に暗号データに移行していくことはとても困難です。
あと、当然のことですが、平文で読めなくなるために、分析や調査のために2次利用することは難しくなります。そのため、実はサービスとは直結しない分析環境にデータを流し込んで分析していた、という場合に被害を生んでしまうことがあります。

上記のような課題を認識つつ、それでも現在運用しているサービスのデータを安全に守りたいという要求が発生し、それがサービスの価値に繋がるのであれば、本エントリで紹介したような暗号化の作業・運用は有効だと考えています。

募集

株式会社FiNCでは、安全にデータを運用し価値提供を行なっていきたいサーバーエンジニア、SREメンバーを絶賛募集しています。
興味がある方は下記のWantedlyからご連絡ください。

ヘルスケアサービスの急成長を支えるRailsエンジニアをWanted!
ヘルスケアアプリを支えるSREメンバーをWanted!