課題と成果の概要
個人会社で開発している別のアプリを素材に、匿名の投稿を集計する仕組みのプライバシー設計を紹介します。このアプリには、匿名の投稿を集約した文章を公開する機能があり、投稿した人を追跡できないようにしながら、1台の端末の連投による数の水増しも防ぐ必要がありました。
この記事では、まず端末ごとの仮名を二層のハッシュで生成する方法を説明します。次に、集約した文章を公開してよいかを決めるしきい値も説明します。このしきい値は導入の直後に一度、端末の数のつもりで投稿の件数を数えていて、機能していませんでした。最後に、この設計が何を守れないかを設計文書がどう書き分けているかを紹介します。素材は、約3週間の設計と実装と修正の記録と、その後も更新が続いた設計文書です。
逆向きの二つの要求と最終形
このアプリには、ある対象に集まった匿名の投稿を、一つの文章に集約して公開する機能があります。この機能には逆向きの要求が二つ同時にかかります。投稿した人を追跡できてはいけません。それでいて、1台の端末が連投して、複数の端末の支持を装うことも防がなければいけません。追跡を防ぐには端末を識別できない方が安全で、水増しを防ぐには端末を識別できた方が確実なので、二つの要求は正面からぶつかります。
最終形を先に示します。この設計は、不正の検出に必要な最小の識別力だけを残す形に落ち着きました。端末ごとに集計用の仮名を二層のハッシュで生成し、その仮名で異なる端末の数だけを数えます。集約した文章を公開するかどうかは、異なる端末の支持数としきい値の判定だけで決めます。仮名は集計にしか使わず、読者に見えるのは端末の数だけです。
仮名を生成する二層のハッシュ
端末は初回起動のときに、乱数の識別子を一つだけ生成します。この識別子はUUID v4で、128bitのうち122bitが乱数です。UUID v4の定義はRFC 9562にあります。識別子は端末の安全な保管領域にだけ置き、どのアカウント情報とも結びつけず、サーバへは送りません。
投稿を送るとき、端末はこの識別子と対象のIDを連結し、SHA-256で一方向ハッシュして、ハッシュ値だけをサーバへ渡します。生の識別子は端末の中だけで使い、外へは出しません。サーバは受け取った値に、秘密の管理場所にだけ置いた鍵で、鍵つきハッシュのHMAC-SHA256をかけ、その出力を集計用の仮名として保存します。HMACの定義はRFC 2104にあります。処理の流れを式で示すと次の形です。
端末側: p = hash(端末ID + ":" + 対象ID) ここは SHA-256
サーバ側: 仮名 = keyed_hash(秘密鍵, p) ここは HMAC-SHA256
端末側のハッシュは生の識別子を隠し、サーバ側の鍵つきハッシュは仮名の偽造を防ぎます。HMACの出力は鍵を知らない者には計算できないので、攻撃者が仮名を自前で生成するには、鍵そのものを手に入れる必要があります。鍵はDBにも端末にも置きません。
この分担は、パスワード保存の定石にあるsaltとpepperの応用です。saltは値ごとに変えて付け足すランダムな値で、pepperは全体で共有してDBの外に置く秘密です。この区別の整理はOWASPのPassword Storage Cheat Sheetにまとまっています。この設計では、saltの役割を端末側で混ぜる対象のIDが担い、pepperの役割をサーバ側の鍵が担います。端末とサーバの二か所で導出の計算が食い違うと、同じ端末の照合が壊れます。だからサーバ側の導出は、一つの共有モジュールに集約してあります。
照合できる範囲を一つの対象に閉じる設計
この設計の要点は、対象のIDを端末側で混ぜていることです。同じ端末から出た投稿でも、対象が違えば仮名は別の値になります。だから、対象Aへの投稿と対象Bへの投稿が同じ端末から来たのかどうかを、サーバもDBの閲覧者も照合できません。同じ端末から来たと分かる範囲を、意図して一つの対象の中に閉じています。
全対象で共通の安定した端末ハッシュを使う案は採りませんでした。それだと対象をまたいで同じ端末を照合できてしまい、匿名のはずの投稿から、端末ごとの行動の履歴を組み立てられるからです。一方で、この仮名の入力には、ローテーションされうるサーバ配布のsaltを混ぜません。対象の軸で値が安定しないと、数える用途が破綻するからです。秘匿はpepperが担うと、実装の注記に明記してあります。
公開を決めるしきい値
集約した文章には、どの端末の投稿を素材に使ったかが残ります。その端末を、文章を支持する端末として数えます。公開の条件は二つあります。異なる端末の支持が5以上であることと、支持が最も多い1台の端末が全体に占める割合が0.8未満であることです。どちらか一方でも欠けたら公開しません。DBの列の既定値も非公開です。
テスト運用では、しきい値の5を2へ下げます。ただし1にはしません。支持が1端末だと割合が必ず1.0になって構造的に公開できませんし、複数の端末から集まった文章だけを公開するという約束も壊れるからです。割合の上限0.8は、テスト運用でも緩めません。ほかに、仮名一つにつき素材へ入れる投稿を3件までに制限して、連投による内容の占拠を防ぎます。仮名を計算できなかった投稿は端末の数に数えず、すべての投稿の仮名が不明なら、端末数0と割合1.0を返して必ず非公開になります。この判定は入出力だけの純関数に切り出し、単体テストが既定値とテスト運用値の両方を固定しています。
端末の数のつもりで投稿の件数を数えていた不具合
このしきい値は、導入の直後に一度、実質的に機能していませんでした。数えていたのが、異なる端末の数ではなく投稿の件数だったからです。原因は取り違えでした。端末は、投稿1件ごとに値の変わる別目的のハッシュも送っていて、サーバがそちらを仮名の素材に使っていました。同じ端末が同じ対象へ投稿しても毎回別の仮名が生まれるので、1台で連投するだけで複数の端末の支持を装えます。修正では、端末側で生成する安定した値をサーバがHMACする形へ一本化しました。この修正は、テスト運用の段階、一般公開の前に済んでいます。
なぜ別のハッシュと取り違えたのかには、理由がありました。同じアプリには、別のハッシュもあります。このハッシュは、端末の識別子にサーバ配布のsaltと行ごとの乱数と日付を混ぜて、投稿1件ごとにわざと値を散らします。利用者の単位で投稿を束ねる集計を、DBの構造ごと成立させないための道具です。値を散らすハッシュと、値が安定する集計用の仮名は、同じハッシュに見えて要求される性質が正反対です。片方をもう片方の場所へ置いた瞬間、数える仕組みはエラーも出さずに誤動作します。
設計文書が書き分ける限界
この設計文書は、守れる範囲と同じ粒度で、守れない範囲を書き分けています。漏洩の場面ごとの整理を表にすると次のとおりです。
| 漏洩の場面 | 攻撃者が得るもの | できないことと理由 |
|---|---|---|
| DBだけが漏れた | HMACの出力の仮名 | 秘密鍵が無いので、再計算も照合も偽造もできない |
| DBと秘密鍵の両方が漏れた | 仮名と鍵 | 元の入力に122bitの乱数の識別子が要るので、総当たりが成立しない |
| サーバの処理系が侵害された | 端末でハッシュ済みの値 | 生の識別子は端末の外に無いので、復元できない |
| 端末そのものが侵害された | 端末の中のデータ | データは端末から直接読めるので、サーバ経由の攻撃に意味が無い |
pepperを入れた理由も、設計文書は率直に書いています。入力が122bitの乱数なので、低エントロピーの秘密を守るというpepperの古典的な動機は、直接には当てはまりません。それでも入れるのは、計算がHMAC1回と安く、DB単体の漏洩への耐性と秘密の分離が得られ、乱数の前提が崩れたときの保険にもなるという、多層防御の判断です。
守れないものも、三つ名指しで挙げてあります。第一に、異なる端末がN台でも、異なる人間がN人だとは言えません。第二に、多数の端末やエミュレータを量産して身元を偽る攻撃は残ります。これは、一つの主体が多数の身元を名乗るSybil攻撃と呼ばれる型です。新しい端末の支持の重みを初期は低くする措置とレート制限で攻撃の費用は上げられますが、偽装そのものをゼロにはできません。第三に、実在の別々の人間が示し合わせて同じ虚偽を投稿してきた場合、匿名のまま集計する仕組みには、それを見分ける原理がありません。ここは規約と通報と人手のレビューで補うと割り切ってあります。
秘密鍵のローテーションの扱いも決めてあります。鍵を回すと、過去の仮名との照合が切れて、追跡の材料を断てます。その代わり、鍵を替えた後は同じ端末を過去の仮名と照合できないので、過去の分の端末の数は正しく数え直せなくなります。だから公開済みの支持数は確定した記録として保ち、再計算で取り下げません。
設計の中心にある限界の書き方
この設計で効いているのは、個々のハッシュの選び方よりも、限界の書き方です。この設計文書は、追跡不可能だと宣言していません。DBだけが漏れたら、鍵まで漏れたら、と場面を割って、守れる範囲と守れない範囲の両方を書き切っています。そこが設計の中心です。多数の端末による偽装は費用を上げて受け止め、示し合わせた虚偽は人手のレビューへ回すという、守れない側の二行まで、設計文書には書いてあります。
どんな設計にも、機械に任せきれずに人手のレビューへ返す判断が最後に残ります。この設計では、示し合わせた虚偽の見分けがそれでした。私がずっとやりたいのは、こういう最後に残る判断の型を機械へ教えて、人がレビューする役そのものを少しずつ無くしていくことの方です。いずれは自分はデータを残すだけにして、そのデータすらAIが生み出すようにし、最後には私の写し鏡になるAIを作りたいと考えています。人でなければ見分けられないと今書いた判断も、その型を一つずつ渡していければ、人へ返す一行はいつか消せるはずです。そこを目指してやっていきます。