Posted at
NIJIBOXDay 3

個人情報を守るためのデータの暗号化とユーザビリティ

More than 1 year has passed since last update.

皆さんこんにちは

KKKプロジェクトについて書こうと思ったのですが、闇が深すぎてかくと色々やばいと思い、もう少し平和的なものを書こうと思い、今回のネタにしました。

個人情報の流出リスクに対する企業の対応が急がれていますが、ニジボックスでも例外ではなく、これまでのセキュリティの確認と見直し、さらなる仕組みを模索しているところであり、その一環としてデータベースにある情報の暗号化が行われています。

ベネッセの個人情報流出事件に見られるように、流出リスクは外部からの攻撃だけではなく、内部の人間によるダンプファイルなどの流出がありえます。

こう書くと、我々のような社員や協力会社の方を疑うわけですから、いい気はしませんが、実際に事件が起こってしまう以上、対策しなければならず、そのためにデータベースを暗号化し、たとえダンプデータを丸ごと取られても、個人情報に容易にアクセス出来ないようにする必要があります。

そんなわけで、セキュリティ対策のためにデータの暗号化を導入してみたことと、それによってどんな問題が発生したかについて、述べようと思います。

検証コードは基本的にPHP7.0でのLaravelを使用しています。

(実際に実施した環境はバージョンもフレームワークも違いますが、やってることはだいたい同じです)


TL;DR


  1. データはどこから漏れるかわからない

  2. データベースにある個人情報を暗号化する

  3. エンジニアサイドでの暗号化それ自体はそんなに難しくない

  4. 暗号化したらユーザビリティに影響が出るかも


データベースの暗号化


個人情報の流出リスク

内部の悪意ある人間が個人情報を不正に取得するケースが有ることは先に述べました。

内部の人間、特にエンジニアであれば、データベースにアクセスするケースがありうるため、その部分でリスクが発生します。

また、データベース自体は権限管理されており、容易にアクセス出来ないようになっていたとしても、別の経路からアクセスされる場合があります。

以下のフォームを見てください。



流石にこんな簡単な感じのフォームで個人情報を入力することはないかもしれませんが、とりあえずこれで登録したとしましょう。

httpsで通信は保護されているし、DBへのアクセスも制限しているので、一見問題なさそうですが。。。

[2016-12-01 23:41:53] local.INFO: SQL 

{"statement":"insert into `people` (`first_name`, `family_name`, `address`, `updated_at`, `created_at`)
values (?, ?, ?, ?, ?)",
"binds":["太郎","ヤマダ","大阪府めし処市お好み焼き横丁","2016-12-01 23:41:53","2016-12-01 23:41:53"],"time":25.24}

これはSQLログを出力しているところです。

不具合調査やユーザーの問い合わせに対応するために、SQLのログを取るのはよくやることですが、無造作に置かれたログに個人情報が記載されてしまいました。

ログへのアクセスは普通はDBへのアクセスよりも簡単ですが、一方でこのログにアクセスされてしまうと、個人情報が読み取られてしまう可能性があるわけです。


暗号化する

このような問題を解消するためには、個人情報のアクセスをアプリを通してのみに制限するのが妥当です。

暗号化するためのキーを別で作成し、一部の管理者以外ではそのキーを参照できないようにしておけば、安全性が増します。

さらに、最近のフレームワークであれば、暗号化はそんなに難しくありません。DAOなりORMなり使っていれば、大概その中で暗号化処理を完結させることができますし、暗号化処理自体もフレームワークが提供している場合が多いです。私はあまり暗号化のアルゴリズムに詳しくないので、暗号化はフレームワークのものを使うようにしています。

ここではLaravelのEloquentを使っていることを想定し、mutatorを利用して暗号化を実現してみましょう。


app/Person.php

<?php

namespace App;
use Crypt;

use Illuminate\Database\Eloquent\Model;

class Person extends Model
{
public function getFamilyNameAttribute($value)
{
return Crypt::decrypt($value);
}

public function setFamilyNameAttribute($value)
{
$this->attributes['family_name'] = Crypt::encrypt($value);
}
// 以下略


要するにゲッタ・セッタを作ってあげて、プロパティを参照しているようでその実、メソッドが走っているように作ります。こうすることで、プログラム上ではいちいち暗号化を意識せずにデータを使用することができます。

そして以下のようにフォームに入力して登録すると、



次のようなログが出ます

[2016-12-02 00:00:22] local.INFO: SQL 

{"statement":"insert into `people`
(`first_name`, `family_name`, `address`, `updated_at`, `created_at`) values (?, ?, ?, ?, ?)",
"binds":["eyJpdiI6Illhcmt2XC9seHpWVTFyY2U5MSsxWlRnPT0iLCJ2YWx1ZSI6IlRoV0RHc2hTcUFmXC9pZXNXbUhoZmZtem9HZzBqQUFPaWk3eXlXc3I5ZGNBPSIsIm1hYyI6IjI3ODU3NmUwYTAyODJmZjJhOTFlODMxNzM3ZGRkYzQyYjgyMDE2NzNlMWQ1YjNmYzI2M2Q3NTAyOTAzZmFkMTgifQ==","eyJpdiI6ImxzRHhkajFmTlRMZ0d3YWxEU3phVVE9PSIsInZhbHVlIjoiUDhrYzFGSG1CQlVON0ZWeUFSTWpLZz09IiwibWFjIjoiODZjNjBhNzAxNTg0NmE1NjM4YTA3NDEzYWRjOWU5ZjI2YzdjNGIzZWFkMDZjNGFmM2Y3MTcwZjhmYjA3MDk5MyJ9","eyJpdiI6Ik9cLzNBSHZsUk9SdnIwdnNuQWxzM0V3PT0iLCJ2YWx1ZSI6Inlva3A3ODRFT2w2Mmp6alNuRzhNazBTeEFOU3VuaWpuZFAyd1wvcTRidWRXVnJxUG5iMnZcLzNEaGFhOGFGRDkxcyIsIm1hYyI6ImMwNzU0M2Q4MDE4ODM0YjU4ZDYzM2U4YzhjY2FkNjg3MTFkNzNiOGMzNTdjMTcwYjYyZDNkZGYyOTFlN2Y3YjMifQ==","2016-12-02 00:00:22","2016-12-02 00:00:22"],"time":29.23}

各データが暗号化され、ログを見ても何がなんだかさっぱりわからなくなっています。

ちなみに、mutatorを使っているので、暗号化していようがしていまいが例えば以下のコードで情報にアクセスできます。


person/show.blade.php

<div class="container">

<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="col-md-8">
名字: {{ $person->family_name }}
</div>
<div class="col-md-8">
名前: {{ $person->first_name }}
</div>
<div class="col-md-8">
住所: {{ $person->address }}
</div>
</div>
</div>
</div>

注意すべき点として、既に既存のデータが有る中でこの変更を入れると、暗号化されているデータとされていないデータが混在してしまうので、一旦移行処理を挟む必要があります。


既存データを暗号化する

すでにデータが存在している場合でも移行処理はそんなに難しくありません。

DAOやORMを使用して、暗号化処理をその中で完結しているようであれば、適当なバッチ処理を作ることで、既存データを暗号化することはできます。

先程作ったEloquentを使ってデータ移行のシミュレートをしてみましょう。

まず、mutatorのgetの方を少しだけ変えてみて、メソッドを追加しましょう


app/Person.php

class Person extends Model

{

public static $is_crypt = true;// 追加

public function getFamilyNameAttribute($value)
{
return $this->decrypt($value);// ここが変更されている
}

public function setFamilyNameAttribute($value)
{
$this->attributes['family_name'] = Crypt::encrypt($value);
}
// 中略---------------------------------------------------------------
// ここから追加部分
private function decrypt($value)
{
if (self::$is_crypt) {
return Crypt::decrypt($value);
}

return $value;
}


復号化処理を別メソッドに追い出して、フラグを追加して、フラグが立っていたら復号化を実施しています。

基本的にこの$is_cryptはいじらないので、普段はtrueなのですが、バッチ処理時に一次的にfalseにすることで、復号化処理をスキップします。

バッチ処理の作成はLaravelの場合はartisanが使えるのでそれを使っていきましょう

$ php artisan make:console CryptMigrate --command=cryptm

できたクラスApp\Console\Commands\CryptMigrateの実コードは以下のとおりです。

    public function handle()

{
Person::$is_crypt = false;
$persons = Person::all();
DB::transaction(function () use ($persons){
foreach ($persons as $person) {
$this->crypt($person);
}
});
}

private function crypt(Person $person)
{
foreach (['family_name', 'first_name', 'address'] as $field) {
$person->{$field} = $person->{$field};
}
$person->save();
}

バッチの中でPerson::$is_cryptfalseにして、復号化処理をスキップするようにし、cryptメソッドの中で、データを代入し直しています。

外見上は同じ値を入れているだけの無意味なコードですが、ナマのデータをゲッタで取り出し、セッタで暗号化を実施しています。

なので、バッチを走らせると

$ php artisan cryptm

以下のようなログが出ます(ムダに長いので、中略してます)

[2016-12-02 02:42:26] local.INFO: SQL {"statement":"update `people` set `family_name` = ?, `first_name` = ?, `address` = ?, `updated_at` = ? where `id` = ?","binds":["eyJpdiI6Iit0aVptMU9WSHdWVzBaNnpqbkJtZEE9PSIsInZhbHVlIjoicVh6czVEbGtEZlg5cFBpZHRpWW5ub3FvQTBBZHRoamh2eURHZ0VqOG9cL0U9IiwibWFjIjoiOTZlNTM4NTE4YmQ4NGY5MDJkYzBlOThjMzFhMzNjMzc0MDlhZDU4N2UxNzU4ZWQ0MjNkYjExNTYzMzdhMGNiMyJ9","(中略)","2016-12-02 02:42:26",1],"time":0.93}

[2016-12-02 02:42:26] local.INFO: SQL {"statement":"update `people` set `family_name` = ?, `first_name` = ?, `address` = ?, `updated_at` = ? where `id` = ?","binds":["eyJpdiI6ImJnd2RWd1JxQXBJbmNGWEppQlN3XC9RPT0iLCJ2YWx1ZSI6ImdxUEU5WmNWTUZjeTV4K1o0aHRmT2t0T2w0bnRyTFhWUWVxM1N1K0R4aTA9IiwibWFjIjoiOTFjMGY3MmM2OTMyMjU4MGU0MGM5ZTc4N2MzMTJkM2VlZTFlYmQ5OTMzNmJiOWI2MjBmZjY4M2VlZGNhMmY1YSJ9","(中略)","2016-12-02 02:42:26",2],"time":0.74}

暗号化されていることが確認できます。

このように、暗号化するだけであれば、移行も含めてそんなに難しくないです。

まあ、DAOもORMも使ってない、生SQLがあちこちに飛び散っているような場合は大変ですが、暗号化・復号化処理自体は一つにまとめることができるでしょう。


ユーザビリティとのせめぎあい


検索と暗号化

暗号化は個人情報を扱うに当たり、流出リスクを大きく減らすことができますが、一方でユーザビリティに制限をかけるところが出てきます。

特に検索に関しては影響が大きく、場合によっては完全一致検索すらも受け付けなくなります

LaravelのCryptの動作テストを見てみましょう。

    public function testEncrypt()

{
$str = 'あいうえおかきくけこさしすせそ';
$old = Crypt::encrypt($str);
for ($i = 0; $i < 10; $i++) {
$new = Crypt::encrypt($str);
$this->assertFalse($new == $old);// 同じ文言を暗号化しても、前と同じにはならない
$this->assertTrue(Crypt::decrypt($old) == $str);// 復号化はできる
}
}

これを見てもわかるようにLaravelでは暗号化するたびにその文字列が変わってしまうため、暗号化した値で検索をかけても完全一致すらせずに弾かれてしまいます。

つまり、暗号化したフィールドは検索できないものとみなさなければなりません。

例えば、一覧にある沢山の人から特定の人のデータを取り出したいときは、その人の名前もしくはその一部分を使用して検索したくなるわけですが、その場合は一度全てのデータを取得した後、検索条件に合うデータだけにフィルタリングするという処理を、サーバかフロントのどちらかに入れなければなりません。

これは実装が大変であることと、大量データを扱わなければならないという点で負荷が心配になりますし、実装しないとなると、ユーザビリティが下がります。


代替案

暗号化によって検索できなくなってしまった場合、代替案として別の検索軸を増やして上げる方法があります。

例えば、会社内の社員を管理する場合、社員の名前や住所は個人情報となるため暗号化しておくとして、社員が所属する部署などのIDは、別に個人情報ではないので暗号化せずに社員のテーブルに保持して置けるでしょう。

また、部署に関しては会社の情報なので、重要であれば部署のマスタを暗号化しておくこともあると思うので、部署での絞込はセレクトボックスなどを使うと、良いかとも思います。

例えば以下のような感じです

なにはともあれ、個人情報に限らず様々な重要情報を暗号化して情報漏えいのリスクを低減したいという欲望が出てきますが、それによるユーザビリティへの影響と、その対策は常にセットにして考えるべきでしょう。


まとめ

いつもとは毛色を変えて暗号化について述べてみました。

暗号化をするだけであれば、よほど整理されていないクソコードでもない限り、そんなに時間を書けることなくできます。

既存データの移行もそこまで難しいものではありません。

しかし、それにより発生するユーザービリティへの影響をしっかり考えなければ、闇雲に暗号化してもアプリを劣化させ衰退させてしまう危険がありますので、影響範囲やそれに対する対応策をしっかりと練った上で実施するのが良いと思います。

今回はこんな感じです。


参考

LaravelのCrypt

LaravelのEloquentのmutator