💨

VanishChat - DBなし・完全ステートレスなメッセンジャーをCloudflare Workersで作った

に公開

はじめに

Web Pushの仕様を調べていたときのこと。

「受信者がオフラインの場合、メッセージはブラウザベンダーのPush Service(GoogleやApple、Mozillaが運営)に一時的に保管される」

この一文を見て、ピンときました。

「これ、一時的なDBとして使えるのでは?」

メッセンジャーアプリを作ろうとすると、相手がオフラインのときにメッセージを保存しておく仕組みが必要になります。普通はDBを用意するわけですが、Push Serviceが勝手にキューイングしてくれるなら、サーバー側にDBは要らないはず。

そう思って作ったのがVanishChatです。

せっかくサーバーにデータを保存しないなら、E2E暗号化してサーバーが中身を読めないようにしたら面白いのでは?さらに、セルフホストできるようにして、異なるサーバー間でも通信できたら面白いのでは?

そんな好奇心ドリブンで開発を進めました。

ちなみにVanishChatという名前は、Push Serviceの仕様次第でメッセージがふわっと消えてしまう(vanish)ところから。配信保証がないので、文字通り「消えるチャット」になる可能性があるわけです。

🔗 デモ: https://vanishchat.karakuri-maker.com/
📦 リポジトリ: https://github.com/kiyo-e/vanishchat

対象読者

  • Cloudflare Workersで何か作りたい人
  • DBの管理から解放されたい人
  • Web Pushの実用的な使い方に興味がある人
  • E2E暗号化の実装例を見たい人

アーキテクチャの全体像

まず全体像から。

┌─────────────────┐     Web Push      ┌─────────────────┐
│   Browser A     │◄──────────────────│   Browser B     │
│  (IndexedDB)    │                   │  (IndexedDB)    │
└────────┬────────┘                   └────────┬────────┘
         │                                     │
         │ E2E Encrypted                       │
         │ Payload                             │
         │                                     │
         ▼                                     ▼
┌──────────────────────────────────────────────────────────┐
│              Cloudflare Workers (Relay Only)             │
│                                                          │
│  • Verifies capability tokens                            │
│  • Forwards encrypted payloads to Web Push endpoints     │
│  • Cannot decrypt message contents                       │
└──────────────────────────────────────────────────────────┘

ポイントは3つ。

  1. サーバーはリレーのみ - メッセージを受け取って転送するだけ。保存しない
  2. データはすべてブラウザのIndexedDB - 鍵も連絡先もメッセージ履歴もローカル
  3. E2E暗号化 - サーバーもPush Serviceも中身を読めない

Web PushをDBとして使う発想

通常、リアルタイムメッセージングを実装しようとすると、相手がオフラインの場合に備えてメッセージをどこかに保存しておく必要があります。これがDBを使いたくなる最大の理由。

でもWeb Pushの仕様を見ると、オフライン時のメッセージ保持はPush Serviceがやってくれる。GoogleのFCMやAppleのAPNsが、デバイスがオンラインになるまでメッセージをキューイングしてくれるわけです。

つまり、Push Serviceを「他人が運営してくれる一時DB」として使える。

ただし、Push Serviceの保管期間は送信時に指定するTTL(Time To Live)に依存します。VanishChatではttl: 86400(24時間)を設定しているので、最大24時間のベストエフォート配信という形。それを過ぎると消えます。

この割り切りがあるからこそ、Cloudflare Workersの無料枠だけで動くメッセンジャーが成立しています。

Capability Token - DBなしで認可する仕組み

DBがないということは、「誰が誰にメッセージを送れるか」をサーバー側で管理できないということ。

これを解決するのがCapability Tokenです。

トークンの中身

Capability Tokenは、JWT風のHS256署名付きトークンで、中に受信者のPush Subscription情報がまるごと入っています。

{
  "v": 1,
  "iat": 1706000000,
  "exp": 1706086400,
  "sub": {
    "endpoint": "https://fcm.googleapis.com/fcm/send/xxx...",
    "keys": {
      "p256dh": "...",
      "auth": "..."
    }
  }
}

トークンを持っている=その人にPushを送る能力(capability)を持っている、という考え方。

サーバーは署名を検証して、トークンのsubからPush Subscription情報を取り出し、そのままWeb Pushを送るだけ。DBルックアップは一切不要です。

2種類のトークン

トークンは有効期限で2種類に分けています。

種類 有効期限 用途
Invite Token 24時間 ペアリングリンク用。短命なので漏れても被害は限定的
Long Token 10年 ペアリング完了後の連絡先間通信用

招待リンクには短命トークンを使い、ペアリングが完了したら長期トークンを交換する。これでセキュリティと利便性のバランスを取っています。

ペアリングフロー

友達追加の流れを見てみましょう。

1. 招待リンクの生成

招待する側は、以下の情報を含むリンクを生成します。

  • ホームサーバーURL
  • 短命Capability Token(24時間)
  • E2E暗号化用の公開鍵
  • 署名検証用の公開鍵
  • 表示名(任意)

2. リンクを開いて連絡先追加

招待された側がリンクを開くと、相手が連絡先に追加されます。この時点では一方通行。

3. システムメッセージで双方向化

連絡先追加時に、招待された側から自動的に「cap-update」というシステムメッセージが送られます。

{
  "type": "cap-update",
  "capToken": "長期トークン",
  "expiresAt": "...",
  "hs": "ホームサーバーURL",
  "encPubRawB64": "暗号化公開鍵",
  "name": "表示名(任意)",
  "reply": true
}

招待した側はこのメッセージを受け取って、自動的に相手を連絡先に追加。reply: trueなので、お返しに自分の長期トークンも送ります。

これで双方向の通信が確立。サーバーには何も保存せず、クライアント同士でトークン交換が完結するのがミソです。

セルフホスト間の通信

このペアリングフローの面白いところは、異なるホームサーバー間でも通信できること。

招待リンクにはホームサーバーURLが含まれているので、AさんがサーバーX、BさんがサーバーYを使っていても問題なし。メッセージ送信時は相手のホームサーバーに直接リクエストを投げるだけ。

ActivityPubのような複雑なフェデレーションプロトコルなしで、シンプルにサーバー間通信が実現できています。

E2E暗号化の実装

メッセージの暗号化はすべてブラウザ側で行います。WebCrypto APIを使って以下を実装。

暗号化

  • 鍵交換: ECDH P-256
  • 鍵導出: HKDF-SHA256
  • 暗号化: AES-256-GCM

送信時は毎回エフェメラル鍵を生成して、受信者の公開鍵と組み合わせて共通鍵を導出。ペイロードは以下の形式。

{
  "v": 1,
  "s": "ソルト(base64url)",
  "e": "エフェメラル公開鍵(base64url)",
  "c": "暗号文(base64url)"
}

署名

暗号化に加えて、送信者の真正性を保証するためにECDSA P-256で署名もつけています。

復号後の平文メッセージには署名が含まれていて、受信側で送信者の公開鍵を使って検証。なりすましを防げます。

ハマりポイント

ペイロードサイズの壁

Web Pushのペイロードサイズ制限がブラウザやPush Serviceによってバラバラで、これが地獄でした。

結局、安全マージンを取って以下の制限を設けています。

  • サーバー側ハードリミット: 2048バイト
  • クライアント側ハードリミット: 1800バイト
  • 警告表示: 約700バイト以上

チャンキング(分割送信)も検討しましたが、複雑さに対してメリットが薄いので見送り。「長いメッセージは短くしてね」という割り切りにしました。

iOS対応とpostMessageの罠

iOSでもWeb Pushを使うにはPWAとしてホーム画面に追加してもらう必要があります。なのでPWA対応を進めたのですが、ここで予想外の壁にぶつかりました。

Service Workerとメインスレッド間のpostMessageやBroadcastChannelの動作がiOSだと不安定。Pushを受信したときにService WorkerからクライアントにメッセージIDを通知して、クライアント側でIndexedDBから詳細を取得する、という流れを想定していたのですが、iOSだとこの通知がたまに届かない。

結局、iOS対策としてIndexedDBのポーリング(1.5秒間隔)を常時併用する形に落ち着きました。Service Worker側ではpostMessageとBroadcastChannelも送っているので、対応しているブラウザではそちらで即時反映されます。エレガントではないですが、動くことが正義。

未知の送信者からの通知

ペアリングが完了していない相手(未承認の送信者)からのメッセージをどう扱うか。通知を出すとスパムの温床になるし、完全に無視するとペアリングフローが成立しない。

結局、システムメッセージ(cap-update)は処理するけど、通常メッセージは未承認送信者からは通知を出さない、という方針に落ち着きました。

所感

Web Pushの仕様を調べていたら「これDBの代わりに使えるのでは?」と思いついて、そのまま勢いで作ったプロジェクトでした。

実際やってみると、Push Serviceの挙動はブラウザごとに微妙に違うし、iOSのPWA対応は癖が強いし、想定外のハマりポイントは多かったです。でも、DBなしでメッセンジャーが成立することは証明できました。

Capability Tokenの考え方は他のユースケースにも応用できそうな気がします。トークンに必要な情報を全部埋め込んでしまえば、サーバー側のステートを減らせる。一時的なファイル共有リンクとか、招待制のイベントページとか。

あと、セルフホスト可能なメッセンジャーをこんなにシンプルに作れるのは意外でした。ActivityPubみたいな複雑なプロトコルなしでも、トークンにホームサーバーURLを含めるだけでサーバー間通信ができる。

まとめ

VanishChatは、以下の技術を組み合わせてDBなしメッセンジャーを実現しています。

  • Web Push - メッセージ配信とオフラインキューイング(Push ServiceをDBとして活用、TTL 24時間)
  • Capability Token - DBレスな認可
  • E2E暗号化 - プライバシー保護
  • IndexedDB - クライアントサイドストレージ

デモは https://vanishchat.karakuri-maker.com/ で公開しています。友達と試してみてください。

リポジトリは https://github.com/kiyo-e/vanishchat で公開中。Cloudflare Workersの無料枠でセルフホストできるので、興味があればぜひ。

Discussion