人とAIが同じroomで会話するSakuraを作っている。以前書いたcc-sakuraの運用をクラウド版へ持ってきて、ブラウザとAIクライアントから使えるようにしている。
開発の会話には、未公開の仕様、調査ログ、まだ直っていない不具合の情報が入る。認証と参加権限は必要だが、それらの実装が正しくても、DBのダンプやバックアップが外へ出る事故は別に考える必要がある。
そこで、DBの内容だけを取得されても投稿本文を復号できないようにした。現在のシステムバージョンはv0.1.0。この記事では、2026年11月12日時点の保存形式と、その設計で守れる範囲を書く。
対象は保存データの流出
今回対象にしているのは、D1の内容やR2のオブジェクトがコピーされた状況だ。攻撃者が利用者の端末や復号鍵までは取得しておらず、配信するJavaScriptも改変できないことを前提にする。
DBには本文だけでなく、ユーザー、参加権限、公開鍵なども入る。すべての情報を隠す設計ではない。目標は、保存データの流出から会話本文やOGP画像を読まれることを防ぐことだ。
また、DBを読み取られることと、稼働中のDBを書き換えられることは分けている。権限情報を改変された場合の影響まで、本文の暗号化だけで解決できるとは考えていない。
本文は端末で暗号化してから送る
投稿は、ブラウザでJSONにした本文をAES-256-GCMで暗号化してからAPIへ送る。APIは暗号文をD1に保存する。読む側も暗号文を取得し、端末で復号して表示する。
投稿する端末: 本文 → 暗号化 → API → D1
読む端末: D1 → API → 暗号文 → 復号 → 表示
暗号化にはブラウザのWeb Crypto APIを使っている。本文の暗号化では、サーバーID、プロジェクトID、投稿者のメンバーID、操作ID、鍵IDを追加認証データ(AAD)に含める。暗号文だけを別のroomや別の投稿へ移しても、その文脈で正しく復号できないようにするためだ。
AIクライアントも同じ形式を扱う。公開しているクライアントの復号処理では、鍵と投稿の情報を使って復号する。AIが投稿を読む場合は、そのAIが動く環境にも平文が渡る。E2EEだからAI側に内容が見えない、という意味ではない。
ストレージ側の暗号化も必要だが、SQLで読み出した結果やDBダンプの本文が平文なら、今回の目標には届かない。アプリケーションがDBへ渡す前に暗号化することにした。
復号鍵をDBへ平文で置かない
部屋の本文を暗号化する鍵を、同じDBに平文で保存してしまうと、DB流出時に本文と鍵が一緒に渡る。
Sakuraでは、端末ごとに鍵配送用の鍵を持たせている。サーバーには端末の公開鍵と、その端末向けに暗号化した部屋の鍵を保存する。復号に使う端末の秘密鍵はサーバーへ送らない。
鍵配送はML-KEM-768とP-256 ECDHの共有秘密を組み合わせ、HKDF-SHA-256で配送用の鍵を導出し、AES-256-GCMで部屋の鍵を包む。これは現在の実装方式の説明で、独立した暗号監査を受けたという意味ではない。
鍵配送の実装では、アルゴリズムだけでなく配送先の扱いにも手間がかかった。パスキーでログインできた端末へ、無条件に部屋の鍵を渡すわけにはいかない。
新しいブラウザには、本文を読める既存ブラウザからの承認を必要とする経路を用意した。通知にはブラウザ・OS・登録時刻を出し、承認か拒否を選べる。ただし、ブラウザ情報は本人確認の証拠にはならない。自分が追加した端末かを利用者が確認するための補助情報として扱う。
OGPも暗号化対象に含めた
URLを貼ったときのカードを実装すると、本文以外にも保存したい情報が増えた。ページのタイトル、説明、画像にも、会話の話題やリンク先の情報が含まれる。
タイトルと説明は本文のJSONへ入れて、一緒に暗号化することにした。画像も最初は縮小して本文へ含めたが、画像付きの投稿が増えると、DB容量と履歴取得のデータ量が増える。そこで画像本体はR2へ分けた。
| 保存先 | 保存するもの |
|---|---|
| D1 | 暗号化された本文。復号するとOGPのタイトル・説明、画像の参照ID・復号鍵・IVも含まれる |
| 非公開R2 | 暗号化された縮小画像。画像の復号鍵は含めない |
| 端末 | 端末の秘密鍵、利用できる部屋の鍵、復号した本文 |
現在の実装では、画像を320×180へ縮小し、Base64表現で16,000文字以下になるようJPEG化する。画像ごとにランダムな256ビット鍵と96ビットIVを生成し、AES-256-GCMで暗号化してR2へアップロードする。R2に保存する暗号文の上限は12,016バイトにしている。
画像の復号鍵はアップロードAPIへ送らず、投稿本文の内側に入れて暗号化する。画像の暗号化では、プロジェクトIDと画像IDをAADに含める。D1とR2を両方コピーされても、その保存データだけでは画像の復号鍵を取り出せない構成になる。
R2のオブジェクト名にはプロジェクトIDと画像IDを使っているので、どの画像が同じ部屋に属するかや、おおよそのサイズまでは隠していない。既存の本文内画像は互換表示を残し、自動移行はしていない。
OGP取得時にはURLがサーバーへ伝わる
保存データを暗号化していても、OGPの取得時にはリンク先URLがサーバーへ伝わる。
ブラウザから任意のサイトのHTMLを取得して読めるとは限らないため、SakuraのAPIがページ情報と画像を取得し、ブラウザへ返す。その後、ブラウザで暗号化して保存する。リンク先サイトにもリクエストは届く。
そのため、機密情報や認証用の値を含むURLを貼る場合、この取得経路は無視できない。サーバーが一度もURLを知れない方式ではないことを、利用者向けの説明にも書いている。閲覧者が投稿を開くたびにリンク先へ再取得することはしない。
取得APIには、認証、投稿権限、ユーザー単位のレート制限を付けた。送信先を公開HTTPSに制限し、リダイレクト先も検査する。時間とサイズの上限も設け、取得できない場合はカードや画像を省略して投稿を続ける。
暗号文の取得にも参加権限を要求する
画像は非公開R2に置き、Workerのバインディングを通じて取得する。公開バケットのURLを投稿へ埋め込む方式にはしていない。
読む側のブラウザが画像の表示位置に近づいたら、同一オリジンのAPIへ取りに行く。APIはセッションとroomの参加権限を検査する。R2の読み取り待ちの間に権限が失効する場合もあるので、応答を返す前にも検査する。画像APIの応答はCache-Control: no-storeにしている。
roomを削除したときは、そのプロジェクトIDのプレフィックスにあるR2オブジェクトもバックグラウンドで削除する。今回の実装では、画像をアップロードした後で投稿を取り消すなどして、未参照画像がroom削除まで残る場合がある。サーバーは暗号化された本文から参照を拾えないので、自動回収は別途設計する必要がある。
テストで確認したことと、残る制約
R2化はPR #52で実装した。端末の承認・拒否はPR #46で追加している。サーバー側リポジトリは現時点では非公開なので、これらの実装リンクはリポジトリへアクセスできる場合だけ読める。
画像については、暗号化と復号、改ざんされた暗号文の拒否、違うプロジェクトIDでの復号失敗、他roomからの取得拒否、readerによるアップロード拒否、退出後の取得拒否をテストした。R2を読んでいる途中でメンバーを無効化し、データを返さないケースも入れた。ブラウザ側では、永続化する送信待ちデータに画像本体や画像の復号鍵が平文で残らないことを確認している。
本番でも画像付きURLを投稿し、表示できることと、テスト用roomを削除した際にR2の画像も消えることを確認した。ただし、これらは実装した条件の検証であり、システム全体の安全性を証明するものではない。
本文を暗号化しても、ユーザー名、room名、参加関係、投稿日時、通信量などのメタデータは残る。すでに本文や鍵を取得した参加者から、それらを回収することもできない。現在は新しい参加者へ過去の鍵も引き継いで過去の投稿を見せる設計なので、部屋の鍵を得た端末が侵害された場合、過去の投稿も影響を受ける。
また、配信サーバーを侵害されて悪意あるJavaScriptを届けられた場合や、鍵配送時にサーバーが偽の公開鍵を提示するような攻撃は、保存データだけの流出とは別に考える必要がある。この実装を「サーバーを一切信用しなくてよい」という保証にはしていない。
v0.1.0では、本文とOGP画像を端末で暗号化し、サーバー側へ平文の復号鍵を保存しないところまで実装した。未参照画像の回収や、端末・鍵配送の検証をどこまで強めるかは、残る作業として扱っている。