はじめに
前回の記事で、PASMO・Suica をタッチした瞬間に iPhone から位置情報を送り、交通費の明細にバス停名を載せる仕組みを紹介しました。
この仕組みは、言い換えると 「タッチのたびに、いつ・どこにいたか」を預かる ということです。自分ひとりで使うなら気にしなくてよいことも、他の人の記録を預かるとなると話が変わります。
今回は、社員 1 人の会社で運営している個人開発のサービスとして、位置情報を守るためにやったこと、検討したうえでやらなかったこと、そして今後やることを書きます。客先や現場に駆けつけることの多いエンジニアの方にも使ってほしいサービスなので、「預けて大丈夫か」を判断する材料にもしていただければと思います。
何を、誰から守るか
最初に、守るものを書き出しました。
- 位置情報のログ: いつ・どこにいたかが分かります。漏れたときの影響が最も大きいものです
- 利用明細(PDF から取り込んだ駅名・金額など)
- アカウント(メールアドレス、ログインの方法、ショートカット用のトークン)
- 記録の正しさ: 勤務先などに出す明細の元になるので、本人や第三者が書き換えられないこと
次に、それぞれに対して起こりうることと、主な対策を表にしました(簡略化しています)。
| 起こりうること | 主な対策 |
|---|---|
| データベース・バックアップ・管理用の鍵が漏れる | 場所に関する列を、データベースの外にある鍵で暗号化する |
| 他人のデータを読む・書き換える | RLS。RLS を通らない処理は、すべて本人の id で絞り込む |
| アカウントを乗っ取られ、記録を消される | 取り返しのつかない操作の前の本人確認と、メールでのお知らせ |
| ショートカット用のトークンが漏れる | ハッシュ値だけを保存し、発行し直せば無効にする |
| ロボットによる総当たり・大量送信 | Cloudflare Turnstile |
| 依存パッケージの脆弱性 |
npm audit・Dependabot |
以下、主なものを順に書きます。
やったこと
1. そもそも持たない
いちばん確実な対策は、持たないことです。
- タッチしたときだけ記録する。常に位置を追跡すれば、乗ったバス停をもっと確実に判定できるかもしれません。しかし、交通費の精算に要らない移動まで預かることになるので、採りませんでした
- 物販(コンビニなどでの支払い)の時刻と位置を残さない設定を用意しました。ウォレットのオートメーションは物販のタッチでも動くので、どの店にいつ行ったかまで残ってしまいます。交通費には要らない情報なので、本人が選べるようにしています
- アクセス解析は、Cookie を使わない方式(Vercel Web Analytics)にして、ログイン前の紹介ページだけに置く。ログインした後の画面には、解析を一切入れていません。画面の URL やタイトルから、利用の状況が分かってしまうためです
あわせて、預かる記録そのものを減らせるように、記録を暗号化したまま手元に書き出し、必要なときに取り込み直せる機能も検討しています。
2. 場所に関する列を、アプリ側で暗号化する
データベースには Supabase を使っています(データは東京リージョンに置いています)。Supabase は、標準でディスクを暗号化しています。
最初は「それなら大丈夫」と考えていました。しかし、よく考えると、ディスクの暗号化が効くのは、ディスクやバックアップの媒体そのものが盗まれた場合だけです。次のような場合には効きません。
- Supabase の管理用の鍵(RLS を通らずに全員のデータを読める鍵)が漏れた
- RLS の設定を誤り、他人の行が読める状態になっていた
- 運営者の Supabase のアカウントが乗っ取られた
どれも、データベースが「正規の手順で」中身を返してしまうからです。
そこで、座標・バス停名・駅名など、場所に関する列は、アプリケーション(Next.js のサーバー側)で暗号化してから保存することにしました。暗号化の鍵は、Supabase の外に置いています。データベースの中にあるのは暗号文だけなので、上の 3 つの場合でも、場所は読めません。
方式は AES-256-GCM です。暗号化のたびにランダムな IV(初期化ベクトル)を使います。これは、同じバス停でも毎回違う暗号文にするためです。同じバス停が同じ暗号文になると、暗号文の出てくる回数を数えるだけで「この人がよく行く場所」が推測できてしまいます。
効くもの・効かないもの
暗号化すればすべて安心、というわけではありません。正直に書くと、次のようになります。
| 起こりうること | アプリ側の暗号化は |
|---|---|
| データベース・バックアップが漏れる | 効く(暗号文しか取れない) |
| 管理用の鍵が漏れる | 効く |
| RLS の設定の誤りで、他人の行が読める | 効く |
| アプリを動かしているサーバー(Vercel)が侵害される | 効かない(アプリが復号するため) |
| アプリのバグで、他人のデータを返してしまう | 効かない |
効かないものは、RLS と、次に書く「本人の id で絞り込む」ことで防ぎます。暗号化は、あくまで守りの層を 1 枚増やすものです。
代わりに失ったもの
データベースの中では場所が読めないので、データベースで「バス停ごとに集計する」「近くのバス停を探す」といった処理ができなくなりました。
前回の記事で、PostGIS で近くのバス停を引く話を書きました。当初は、ログを保存するときにデータベースのトリガーでバス停を判定していました。暗号化に合わせて、サーバーで受け取ったときに判定し、結果を暗号化して保存する形に作り直しています。
3. RLS と、RLS を通らない処理の棚卸し
Supabase では、RLS(行単位のアクセス制御)で「本人の行だけを読み書きできる」ようにしています。
ただし、退会やごみ箱からの完全な削除など、あえてユーザーの権限では実行できないようにしている処理があります。こうした処理は、RLS を通らない管理用の鍵を使って、サーバーから実行します。
管理用の鍵を使う処理は、書き方を誤ると他人のデータに触れてしまいます。そこで、管理用の鍵を使っている場所をすべて一覧にし、それぞれが次のどれに当たるかを確かめました。
- ログイン中の本人の id で絞り込んでいる
- 運営者(管理者)しか使えない
- トークンで本人を確かめている
- そもそも利用者のデータではない(バス停の一覧など)
新しく管理用の鍵を使う処理を作ったときは、この一覧に追記する決まりにしています。
4. 消すときは本当に消す
削除は、物理削除(データベースから本当に消す)にしました。「削除済み」の印を付けるだけの論理削除は採っていません。
- 退会した人のデータが残っていると、利用者に不信感を与えます
- 漏れたときに、消したはずのデータまで漏れてしまいます
利用者のデータを持つテーブルは、すべてアカウント(auth.users)が消えたときに一緒に消えるようにしています(on delete cascade)。新しいテーブルを作るときも守る決まりです。
一方で、誤って消したときに戻せないのは困ります。そこで、本人が消した記録は 30 日間ごみ箱に入れ、その間は元に戻せるようにしました。30 日を過ぎると自動で消えます。退会したときは、猶予を設けずにすぐに消します。
ただし、例外が 1 つあります。無料期間を取り直す目的の再登録を防ぐため、退会の後も「確認用の値」だけは 2 年間残します(下の 7 で書きます)。
5. 取り返しのつかない操作の前に、もう一度本人確認する
アカウントを乗っ取られて退会されると、運営者にも戻せません(バックアップから戻すと、データベース全体が巻き戻ってしまいます)。
そこで、退会やごみ箱からの完全な削除の前には、パスワードの再入力(または Google での再ログイン)を求めます。直前に本人確認をしたかどうかは、画面だけでなくサーバーの側でも確かめています。パスワードの変更など、ログインの方法を変えるときにも本人確認を求めます。
また、こうした操作をしたときは、登録したメールアドレスにお知らせのメールを送ります。身に覚えのない操作に、すぐ気付けるようにするためです。
6. ショートカット用のトークン
iPhone のショートカットからの送信には、利用者ごとのトークンを使っています。
- データベースにはハッシュ値だけを保存し、トークンそのものは持ちません
- 画面に表示するのは発行したときの一度だけです
- 発行し直すと、前のトークンは使えなくなります
ショートカットを配布用に共有するときに、自分のトークンが入ったままになっていないかも確かめています(前回の記事を参照)。
7. 再登録の確認には、元に戻せない値だけを残す
有料プランを始めるにあたり、退会と再登録を繰り返して無料期間を取り直せないようにする必要がありました。しかし、退会した人のメールアドレスをそのまま持ち続けるのは、「消すときは本当に消す」という方針に反します。
そこで、メールアドレスの代わりに、鍵付きのハッシュ値(HMAC-SHA256)だけを残すことにしました。
- 再登録の確認に必要なのは「一致するかどうか」だけなので、元の値に戻せる暗号化は要りません
- 鍵はデータベースの外に置いています。データベースだけが漏れても、値を照合できません
- 保存期間は 2 年で、過ぎると自動で消えます。プライバシーポリシーにも書いています
カードについても、種類と番号の下 4 桁から同じように値を作っています。ただ、利用明細にはカード番号が伏せて載っていて、分かるのは下 4 桁だけです。別の人とも一致するので、今は記録するだけで、判定には使っていません。悪用を防ぐために、正しい利用者を巻き込まないようにしました。
やらなかったこと
端末側での暗号化(エンドツーエンド暗号化)
「運営者にも読めないようにすれば、いちばん安全では?」と考え、検討しました。採らなかった理由は 3 つです。
- iOS のショートカットには、暗号化の操作がありません
- サーバーで座標を読めないと、バス停の判定も、利用明細との突き合わせもできません
- 利用者が鍵をなくすと、何年も残しておくべき交通費の記録が読めなくなります
Supabase Vault・pgsodium
Supabase には、データベースの中で暗号化するための仕組みがあります。しかし、鍵が同じデータベースの中にあるため、管理用の鍵が漏れたときには役に立ちません。鍵をデータベースの外に置くために、アプリ側で暗号化することにしました。
Google Analytics
当初は Google Analytics を入れるつもりでした。しかし、Cookie を使う解析を入れると、プライバシーポリシーに「解析用の Cookie は使っていない」と書けなくなります。また、位置情報を預かるサービスに Google の追跡のタグがあると、勤務先の経理・総務の方に不安を与えかねません。そのため、前に書いた Vercel Web Analytics にしました。
論理削除
前に書いたとおりです。
今すぐの脆弱性診断
いずれは、セキュリティ会社の脆弱性診断を受けるつもりです。ただし、企業単位で導入するための機能を作り終えてから受けることにしました。企業に導入してもらううえで最も大事なのは「他社のデータが見えないこと」です。その機能を作る前に診断を受けると、肝心な部分が診断の範囲から漏れてしまうためです。
お金をかけずに続ける仕組み
社員 1 人の会社なので、セキュリティにかけられるお金も人手も限られます。そこで、無料の道具で自動的に確かめる仕組みを先に作りました。
| 道具 | 確かめること | タイミング |
|---|---|---|
| GitHub Actions | lint、型チェック、npm audit(依存パッケージの既知の脆弱性) |
push のたび |
| gitleaks | 鍵やパスワードがコミットに混ざっていないか(過去のコミットも含む) | push のたび・毎週 |
| Semgrep(OSS 版) | 危ない書き方をしていないか | push のたび・毎週 |
| Dependabot | 依存パッケージの更新の提案 | 毎週 |
点検の基準には、OWASP ASVS(レベル 1 と、レベル 2 の一部)を使っています。これに Next.js・Supabase に固有の点を加えてチェックリストを作り、1 項目ずつ確かめています。
実際に見つかったこと
無料の道具でも、ちゃんと役に立ちました。
Semgrep を入れてすぐに、暗号化の処理で 「復号のときに、認証タグの長さを指定していない」 と指摘されました。AES-GCM の復号では、受け取る認証タグの長さを明示しておくのが正しい書き方です。
実際には、暗号文の最後の決まった長さを必ずタグとして切り出していたので、問題が起きる経路はありませんでした。それでも、ライブラリの側でも長さを固定するのが正しいので、すぐに直しました。
自分では気付きにくい「念のための一行」を拾ってくれるので、個人開発こそ入れておく価値があると感じています。
今後やること
テスト版の期間中に、次のことを進めます。
- 二段階認証(パスワードに加えて、スマートフォンの確認コードを使うログイン)の導入
- アプリを動かしているサーバーの場所の確認と、国内に寄せるかの検討
- 万一のときの対応手順を、文書として整えること
テスト版のご案内
この記事で書いた考え方で作っている「バスログ」は、テスト版を公開しています。メールアドレスまたは Google アカウントで、すぐに使い始められます。
- 対応: iPhone の PASMO・Suica(Apple Pay)
- 未対応: カード版(物理カード)と Android
- 無料期間: 登録した月の 3 か月後の月末まで無料です。クレジットカードなどの登録は要らず、自動で料金が発生することもありません
- 有料プラン: 通常料金は月額 500 円(税込)の予定です。2027 年 1 月 31 日までに登録した方は、半額(月額 250 円)でお使いいただけます(有料プランを解約して改めて契約する場合や、退会した場合は対象外です)
客先への訪問や保守・障害対応などで、行き先が毎回違う方ほど、バス停名を後から思い出すのは大変だと思います。そうした方に、特に試していただけるとうれしいです。
個人開発ですが、位置情報という大切なデータを預かるサービスなので、私が代表を務める会社(社員は私 1 人です)の名義で運営しています。
「PASMO」は株式会社パスモの登録商標です。「Suica」は東日本旅客鉄道株式会社の登録商標です。バスログは、これらの会社とは関係のない、独立したサービスです。
おわりに
セキュリティは「これで完璧」という終わりがありません。それでも、「何を、誰から守るか」を最初に書き出したことで、何をやって何をやらないかを決めやすくなりました。
「ここは甘いのでは」というご指摘があれば、ぜひコメントで教えてください。