暗号化と検索という二律背反
Laravelフレームワークを使用していると、テーブル上の個人情報カラムについてはencryptedでキャストする要件が出てくる。こんな感じで。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Users extends Model {
protected $fillable = [
'id',
'name',
'email',
];
protected function casts(): array {
return [
'name' => 'encrypted', // 氏名カラムを暗号化
'email' => 'encrypted', // メールカラムを暗号化
];
}
}
だがLaravelのencryptedは毎回異なる初期ベクトルIVを持つ非決定的暗号なため、同じ文字列でも暗号化のたびに値が異なる。これが該当カラムでの検索を不可能にする。
| 保存された回数 | name(平文) | name(暗号化後) | 備考 |
|---|---|---|---|
| 1回目 | 鈴木 太郎 | U2FsdGVkX19aP9b/K+... |
|
| 2回目 | 鈴木 太郎 | U2FsdGVkX18xL2c/M+... |
同じ平文でも違う結果になる |
select * from users where name like '鈴木%' ;
select * from users where name = '鈴木太郎' ;
-- 取得結果0件
select * from users where name = '{鈴木太郎を暗号化した文字列}' ;
-- {...}内をアプリ側で生成することはできない(まったく別の暗号化した文字列が生成されるため)
対応方法としては以下のようなものを考慮した。
| 対応方法 | 対応例 | 課題点 |
|---|---|---|
| DBディスクを暗号化しデータは平文で保存 | Cloud SQL 等 | 物理盗難には強いが、稼働中のDBへのアクセスや論理バックアップ流出時には、DB上の平文が流出する |
| 外部検索エンジンへの委託 | Elasticsearch 等 | インフラコスト増大と、データ同期が運用負荷になる |
| アプリケーション側で全件フェッチして復号してから検索 | 対応アプリ内 | データが数千件を超えると破綻 |
| DB上の関数で暗号化(決定論的暗号化) | DBMS_CRYPTO 等 | クラウドDB上に保存するときはネットワークを平文が通る |
今回はGoogle Cloud SQLを使用し、アプリもコンテナごと同じところにデプロイするため、厳密には最初の方法でも良いが、設定不備が見つかった際は一気に漏洩する不安が残り続ける。(セキュリティは複数の層で構成した方がいいという話もあるし)
そのため、暗号化を実行すると検索は出来ず、検索を優先するとデータの保存に苦労するor課題が残るという二律背反に陥ってしまった。
第三の矢・検索ハッシュトークンという解決方法
そこで、いっそのこと検索用の別テーブルを作成し、DBにはLaravel側で暗号化した値を保存するブラインドインデックス手法を取ることにした。
テーブルの内容はこんな感じ。PostgreSQLの前提だ。
| カラム | 内容 |
|---|---|
| id | 検索ハッシュトークンテーブル自体のid(AutoIncrement) |
| table_name | 参照先のテーブル名 |
| table_id | 参照先のテーブルの主キー |
| category | 検索するカラムのカテゴリ name | furigana | nickname | email | phone_number...etc |
| tokens | 検索ハッシュトークンのJSON配列 |
※本システムでは多様性(外国籍やミドルネーム等)を考慮し、氏名をフルネーム1カラムで保持する前提があるため、姓と名を分割せず一つのnameカラムとした。
仕組みはこう。
- 保存・検索したい値を小文字に正規化する
- 以下のルールに沿って分割・変換する
- 入力文字列そのまま(氏名・フリガナ・email・電話番号)
- 入力文字列のスペースを全角・半角にしたもの(氏名)
- 入力文字列の記号(スペース含む)を取り除いたもの(氏名・電話番号)
- 入力文字列を記号(スペース含む)ごとに分割したものそれぞれ(氏名)
- 生成した各値を
hash_hmac等でハッシュ化し、検索トークンを生成する - 検索トークンを、カテゴリをキーとしたJSON配列にまとめ、1モデルにつき1レコードとして検索ハッシュトークンテーブルへ保存(UPSERT)する
生成される検索トークンの例:
| 入力文字列 | トークン生成ルール | トークン | 期待される検索パターン |
|---|---|---|---|
| 山田 太郎 | 入力文字列そのまま | 山田 太郎 | 完全一致 |
| 入力文字列の記号(スペース含む)を取り除いたもの | 山田太郎 | スペースなしでの検索 | |
| 入力文字列を記号(スペース含む)ごとに分割したものそれぞれ | 山田 | 部分一致検索 | |
| 太郎 | 部分一致検索 | ||
| John F. Kennedy-Smith | 入力文字列そのまま | John F. Kennedy-Smith | 完全一致 |
| 記号・スペースで分割 | John | 部分一致検索 | |
| F | 部分一致検索 | ||
| Kennedy | 部分一致検索 | ||
| Smith | 部分一致検索 |
※DBに保存する際は上記をhash_hmacでハッシュ化する
保存されるJSON(tokensカラム)のイメージ:
{
"name": [
"2fd4e1c67a2d28fced849ee1bb76e7391b93eb12",
"de9f2c7fd25e1b3afad3e85a0bd17d9b100db4b3",
"624a0d8e8b9195b6c31a7ffcd19f71c4c9e836fb"
],
"email": [
"c15364204a80fe80e9039455cc8be661f31f9fc0"
]
}
検索時には上記手順の1.~3.までを行い、ユーザーが入力したキーワードをアプリ側で全く同じ手順(小文字化・記号除去など)で処理・ハッシュ化し、その文字列を使って検索ハッシュトークンテーブルへIN句で問い合わせます。
-- 氏名だけで検索 (PostgreSQLのJSONB演算子 @> を使用)
SELECT
table_name, table_id
FROM
search_hash_tokens
WHERE
-- tokensカラム内の 'name' 配列に指定ハッシュが含まれるか検索
tokens->'name' ? '"2fd4e1c67a2d28fced849ee1bb76e7391b93eb12"'
OR tokens->'name' ? '"de9f2c7fd25e1b3afad3e85a0bd17d9b100db4b3"'
OR tokens->'name' ? '"624a0d8e8b9195b6c31a7ffcd19f71c4c9e836fb"'
;
/*
カテゴリを横断して検索する場合は各カテゴリのパスに対してOR検索を行う
*/
SELECT
table_name, table_id
FROM
search_hash_tokens
WHERE
tokens->'name' ? '"c15364204a80fe80e9039455cc8be661f31f9fc0"'
OR tokens->'email' ? '"c15364204a80fe80e9039455cc8be661f31f9fc0"'
;
table_nameとtable_idがわかれば、あとは対象テーブルの対象レコードを主キーから取得し、復号するだけである。
Laravel(Eloquent)上で実現する場合は非常にシンプルで、whereJsonContainsを利用して以下のように記述できる。さらに、各モデルに検索ハッシュトークンテーブルへのmorphMany()(またはmorphOne())を指定しておけば、遅延読み込みすることでN+1問題も解消できる。
// Laravelでの検索実装イメージ
$hashes = [
'2fd4e1c67a2d28fced849ee1bb76e7391b93eb12',
'de9f2c7fd25e1b3afad3e85a0bd17d9b100db4b3'
];
$query = SearchToken::query();
foreach ($hashes as $hash) {
$query->orWhereJsonContains('tokens->name', $hash);
}
$results = $query->get();
銀の弾丸にはならないということ
個人情報の暗号化と検索性の両立は果たされたが、すべてのシステムにおいてこのアルゴリズムが適応できるわけではない。ざっと考えるだけでも以下の課題は残る。
- ストレージとインデックスの肥大化:1ユーザーにつき複数レコードを生成するため、データ量が爆発的に増加する
- 書き込み性能の劣化:更新のたびに対象ハッシュのDELETE/INSERTが発生し、書き込みヘビーな要件には不向き
- 予測不可能な表記揺れ(タイポ等)や、ルール外の曖昧検索には一切ヒットしない
- 運用コストの増大:ハッシュ生成ルールの追加や鍵のローテーション時には、全件復号・再生成の重厚なバッチ処理が必須となる
上記のような課題が無視できるかどうかは要件によるだろう。
結論
究極の機密性を求めるならば検索機能(ハッシュテーブル)自体を諦めるべきだが、実務において「柔軟な名前検索」は必須要件である。
本手法は、多様性を担保したフルネーム設計の制約下において、コンプライアンス要件(暗号化)と業務要件(検索性)を限られたリソースで両立させる、地に足の着いた最適解であると考える。
実装例:Laravelにおける検索トークンの生成と保存
-
氏名などの入力値から、記号の除去や分割を行った複数パターンの配列を生成し、システム固有のキーでHMACハッシュ化します
<?php namespace App\Services; class SearchTokenService { /** * システム固有のキーでハッシュ化 */ public function hash(string $value): string { return hash_hmac('sha256', $value, config('app.key')); } /** * 氏名用:複数パターンのプレーンテキストを生成 */ public function generateNameTokens(string $input): array { $tokens = [$input]; $normalized = mb_strtolower(mb_convert_kana($input, 'as')); $tokens[] = $normalized; // 半角スペース版 $tokens[] = str_replace(' ', ' ', $normalized); // 全角スペース版 // 記号(スペース等)で分割した要素 $pattern = '/[\s \'==・ー\-_]+/u'; $parts = preg_split($pattern, $normalized, -1, PREG_SPLIT_NO_EMPTY); if (!empty($parts)) { $tokens = array_merge($tokens, $parts); } // 記号を完全に取り除いた文字列 $noSymbol = preg_replace($pattern, '', $normalized); if (!empty($noSymbol)) { $tokens[] = $noSymbol; } return array_unique($tokens); } } -
生成した各カテゴリのハッシュ配列をまとめ、ポリモーフィックリレーションを用いて1つのレコードのJSONカラムにUPSERT(更新または作成)します
<?php namespace App\Actions; use App\Models\SearchToken; use Illuminate\Database\Eloquent\Model; class UpdateSearchTokensAction { public function __construct(private SearchTokenService $tokenService) {} public function execute(Model $model, array $searchData): void { $tokensJson = []; // カテゴリごとにハッシュ済みトークン配列を生成し、多次元配列にまとめる foreach ($searchData as $category => $value) { if (empty($value)) continue; $rawTokens = $this->tokenService->generateNameTokens((string) $value); $hashedTokens = array_map(fn($t) => $this->tokenService->hash($t), $rawTokens); $tokensJson[$category] = array_values($hashedTokens); } // ポリモーフィックリレーションを利用して1レコードにUPSERT保存 SearchToken::updateOrCreate( [ 'model_type' => $model->getMorphClass(), 'model_id' => $model->getKey(), ], ['tokens' => $tokensJson] ); } } -
ユーザー情報を保存(暗号化)した直後に、平文のデータをアクションへ渡してハッシュテーブルを同期させます
namespace App\UseCases; use App\Models\User; class CreateUserUseCase { public function __construct(private UpdateSearchTokensAction $updateTokensAction) {} public function execute(array $input) { $user = new User(); // モデルの $casts 設定により、自動的に暗号化されて保存される $user->name = $input['name']; $user->email = $input['email']; $user->save(); // 平文を渡して検索用ハッシュトークンを同期登録 $this->updateTokensAction->execute($user, [ 'name' => $input['name'], 'email' => $input['email'], ]); return $user; } }
参考文献
パトリック・ミッケンジー
プログラマの抱いている名前についての誤謬 - emptypage.jp
Paragon Initiative Enterprises
CipherSweet