はじめに
こんにちは。現在関わっているプロジェクトで、パスワードに限らずメールアドレスや電話番号・住所などの個人情報は暗号化してデータベースに格納してほしい、という要件がありました。
Laravelでは認証時どのようにパスワードを扱っているのか、暗号化と復号はどのように実装するか、パフォーマンスにどのくらい影響はあるのか…など、いざ考えてみると、きちんと把握できていない点が多かったことに気付かされます。
コードを追ったり実際に書いてみたりして、Laravelにおける自分なりの暗号化の実装パターンを考察してみました。
環境
- PHP 7.3.6
- Laravel 5.8.26
- MariaDB 10.4.6
Laravelでの暗号化
チュートリアルでおなじみ(?)の
make:auth
で生成されるユーザ登録処理は以下のような実装になっています。
1 2 3 4 5 6 7 8 | 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/BcryptHasher
の
make()
メソッドで、こんな実装になっています。
1 2 3 4 5 6 7 8 9 10 11 12 | 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
であることが分かります。
なお
$options
に
cost
というオプションが設定されており、configのデフォルト値「10」が渡されています。
password_hash()
の挙動について詳しくは公式ドキュメントをご覧ください。
ハッシュ関数に渡す設定値はLaravel内の
config/hashing.php
に記述されていますので、気になる方はご覧になってみてください。
実際に登録されるレコードはこのようになります。
1 2 3 4 5 6 | 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検索のしようがありません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | $ 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()
を使います。
1 2 3 4 5 6 7 8 9 10 11 | $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進数の文字列で格納されています。
1 2 3 4 5 6 | 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
を噛ませる必要があります。メールアドレスなどの半角英数であれば不要となります。
1 2 3 4 5 6 | 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を書くことになってしまうので、あまりスマートな解決方法ではない気がしています。また、検索時は複合処理を毎回行うことになりますので、パフォーマンス面でも若干不安です。
大量のクエリをさばく必要がある場合はまた別の方法を模索することになると思いますが「性能より安全面」という要件であれば、今回紹介した方法も、実装例のひとつになるかと考えています。