はじめに
こんにちは。現在関わっているプロジェクトで、パスワードに限らずメールアドレスや電話番号・住所などの個人情報は暗号化してデータベースに格納してほしい、という要件がありました。
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 | protectedfunctioncreate(array$data) { returnUser::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 | publicfunctionmake($value,array$options=[]) { $hash=password_hash($value,PASSWORD_BCRYPT,[ 'cost'=>$this->cost($options), ]); if($hash===false){ thrownewRuntimeException('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-2613:39:16|2019-06-2613: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を書くことになってしまうので、あまりスマートな解決方法ではない気がしています。また、検索時は複合処理を毎回行うことになりますので、パフォーマンス面でも若干不安です。
大量のクエリをさばく必要がある場合はまた別の方法を模索することになると思いますが「性能より安全面」という要件であれば、今回紹介した方法も、実装例のひとつになるかと考えています。