カテゴリー: BackEnd

[Laravel]データベースの暗号化について考えてみる

はじめに

こんにちは。現在関わっているプロジェクトで、パスワードに限らずメールアドレスや電話番号・住所などの個人情報は暗号化してデータベースに格納してほしい、という要件がありました。
Laravelでは認証時どのようにパスワードを扱っているのか、暗号化と復号はどのように実装するか、パフォーマンスにどのくらい影響はあるのか…など、いざ考えてみると、きちんと把握できていない点が多かったことに気付かされます。
コードを追ったり実際に書いてみたりして、Laravelにおける自分なりの暗号化の実装パターンを考察してみました。

環境

  • PHP 7.3.6
  • Laravel 5.8.26
  • MariaDB 10.4.6

Laravelでの暗号化

チュートリアルでおなじみ(?)のmake:authで生成されるユーザ登録処理は以下のような実装になっています。

protected function create(array $data)
{
    return User::create([
        'name' => $data['name'],
        'email' => $data['email'],
        'password' => Hash::make($data['password']),
    ]);
}

パスワード暗号化はHash::make()ですね。LaravelのFacadeによる呼び出し方なので、Hashというクラスは存在するものの、makeというstaticメソッドはありません。
実際に呼び出されているのはIlluminate/Hashing/BcryptHashermake()メソッドで、こんな実装になっています。

public function make($value, array $options = [])
{
    $hash = password_hash($value, PASSWORD_BCRYPT, [
        'cost' => $this->cost($options),
    ]);

    if ($hash === false) {
        throw new RuntimeException('Bcrypt hashing not supported.');
    }

    return $hash;
}

PHP推奨のハッシュ関数であるpassword_hash()が使われていて、アルゴリズムはCRYPT_BLOWFISHであることが分かります。
なお$optionscostというオプションが設定されており、configのデフォルト値「10」が渡されています。
password_hash()の挙動について詳しくは公式ドキュメントをご覧ください。
ハッシュ関数に渡す設定値はLaravel内のconfig/hashing.phpに記述されていますので、気になる方はご覧になってみてください。

実際に登録されるレコードはこのようになります。

MariaDB [testdb]> select * from users;
+----+--------+-----------------+-------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+
| id | name   | email           | email_verified_at | password                                                     | remember_token | created_at          | updated_at          |
+----+--------+-----------------+-------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+
|  1 | nomura | nomura@test.com | NULL              | $2y$10$mZuwERGdfaOxCJkTtwoBe.PNiXBRU1OTE/TFvjWVL1448no9uDBXm | NULL           | 2019-06-26 13:39:16 | 2019-06-26 13:39:16 |
+----+--------+-----------------+-------------------+--------------------------------------------------------------+----------------+---------------------+---------------------+

暗号化されたカラムは、そのままではLIKE検索ができない

問題点

今回あれこれと調べたり考えたりしたきっかけはこの点です。パスワードはデータの性格上、文字列の完全一致検索ができれば問題ありません。…が、メールアドレスや住所はそうもいかず、部分一致(LIKE)検索をする必要が出てきます。
上述したHash::make()では常に60文字のハッシュを生成します。例えば「東京都中央区日本橋」でも「東京」でも異なる60文字の、しかも毎回異なるハッシュ値を返します。これではLIKE検索のしようがありません。

$ php -a
Interactive shell

php > echo password_hash("東京都中央区日本橋", PASSWORD_BCRYPT, ['cost'=>10]);
$2y$10$QRpQWfL0yvlV6n8xrw8JHeaZwqjYrkxwVyH11TnzrMfD49Nt1CENu

php > echo password_hash("東京都中央区日本橋", PASSWORD_BCRYPT, ['cost'=>10]);
$2y$10$nlc3lzg/s2XtY6QJhZGnbefy0TWvbEUFXfUnnQURj2Oq0kabgYgpO

php > echo password_hash("東京", PASSWORD_BCRYPT, ['cost'=>10]);
$2y$10$UCtZPT9q1LIVN6bEqNRsP.avdDWgvyy6MCALBrIUofu5b0C4gOfoS

php > echo password_hash("東京", PASSWORD_BCRYPT, ['cost'=>10]);
$2y$10$GZr.P5N9tB0ooACVXiJeBu48ths7lzLQut2uwC/1b.zpLyoAS1qYi

なので$users = User::where('email', 'LIKE', "%{Hash::make($email)}%")->get();みたいな検索はできません。

解決案1:全件取得してPHP側でがんばって検索する

ボツ案です。正確には「DBからいったん全件取得してPHP側で復号・文字列の部分検索処理を実装する」という方法でした。
当然ながら、毎回全件取得してたらパフォーマンス劣化が激しいです。件数が増えるほど激しく劣化するでしょう。データベース-PHP間の通信コストも、PHPの処理コストも恐らく結構なことになると思います。

解決案2:暗号化も復号もMySQL側が担う

もっとスマートなやり方があるんじゃないかとは思うのですが、ひとまずこの方法に落ち着いています。
MySQLには暗号化用のAES_ENCRYPT()、復号用のAES_DECRYPTがありますので、これらを使用して、実装例としては以下のようになります。

  • 登録時はemailの値をSQL文として評価させるため\DB::raw()を使っています。
  • 検索時にもSQL文のまま評価させるためにwhereRaw()を使います。
$app_key = env('APP_KEY');

// 登録
User::create([
    'name' => \DB::raw("HEX(AES_ENCRYPT('{$data['name']}', '{$app_key}'))"),
    'email' => $data['email'],
    'password' => Hash::make($data['password']),
]);

// 検索
$user = User::whereRaw("CONVERT(AES_DECRYPT(UNHEX(`name`), '{$app_key}') USING utf8) LIKE '%太郎%'")->get();

名前が16進数の文字列で格納されています。

MariaDB [testdb]> select id,name,email from users;
+----+----------------------------------+-----------------+
| id | name                             | email           |
+----+----------------------------------+-----------------+
|  1 | 6C5530618B4333D3F397B83272C054F5 | nomura@test.com |
+----+----------------------------------+-----------------+

補足1:HEX/UNHEX

AES_ENCRYPT()は通常の文字列ではなくバイナリを返すため、そのままではvarchar型のカラムに格納できません。そのため何かしらの文字列に変換する必要があるので、ここではHEXを使っています。カラムがVARBINARY型になっていれば、文字列化は不要です。
検索時はまずUNHEXでバイナリに戻してからAES_DECRYPTで復号することでLIKE検索が可能になります。

補足2:CONVERT

検索はAES_DECRYPT(UNHEX())で可能ですが、表示時はそのままだと16進数表記になってしまいます(マルチバイト文字限定)ので、CONVERTを噛ませる必要があります。メールアドレスなどの半角英数であれば不要となります。

MariaDB [testdb]> select id,CONVERT(AES_DECRYPT(UNHEX(name), 'base64:hOwRL/UOK7Lhlbjl8nlNZObbJgQsYNal0CfKCuZCdyI=') USING utf8) as `decrypted`, email FROM users WHERE AES_DECRYPT(UNHEX(name), 'base64:hOwRL/UOK7Lhlbjl8nlNZObbJgQsYNal0CfKCuZCdyI=') like '%太郎%';
+----+-----------------+--------------------+
| id | decrypted       | email              |
+----+-----------------+--------------------+
|  1 | てすと太郎      | nomura@example.com |
+----+-----------------+--------------------+

AES_DECRYPTの第2引数で渡しているハッシュ値は、Laravelの.envファイルに保存されているAPP_KEYです。

さいごに

フレームワークに任せず、アプリケーションに生SQLを書くことになってしまうので、あまりスマートな解決方法ではない気がしています。また、検索時は複合処理を毎回行うことになりますので、パフォーマンス面でも若干不安です。
大量のクエリをさばく必要がある場合はまた別の方法を模索することになると思いますが「性能より安全面」という要件であれば、今回紹介した方法も、実装例のひとつになるかと考えています。

おすすめ書籍

nomura

シェア
執筆者:
nomura

最近の投稿

フロントエンドで動画デコレーション&レンダリング

はじめに 今回は、以下のように…

2週間 前

Goのクエリビルダー goqu を使ってみる

はじめに 最近携わっているとあ…

4週間 前

【Xcode15】プライバシーマニフェスト対応に備えて

はじめに こんにちは、suzu…

2か月 前

FSMを使った状態管理をGoで実装する

はじめに 一般的なアプリケーシ…

3か月 前