cryptofoundry

cryptofoundry へのお問い合わせ

構築または自動化したい内容をお聞かせください。

Ecosystem & Integrations

クライアント・サーバー間チャネルを介さないプッシュ通知:設計、トレードオフ、および既知の登録解除レースコンディション

プライベートメッセンジャーにおいて、プッシュ通知は最も妥協を迫られる箇所です。メッセージの着信を誰かに伝える必要があり、iOSではその役割をAppleが担っています。本稿では、ADAMANTが通知サービスから得られる情報を最小限に抑えるための仕組み、その設計上のコスト、そしてプロジェクトとして隠蔽せずに許容することを選択した既知の障害モードについて解説します。

システムの構成

ユーザーのデバイス、ADAMANTノード、Apple Push Notification service (APNs)、そしてcryptofoundryが運営するADAMANT Notification Service (ANS) の4者が関与します。

登録はAPIではなくブロックチェーンを介して行われます。デバイスはまずAPNsにプッシュトークンを要求します。次に、アプリは {token, provider, action} をANSの公開鍵で暗号化し、ユーザーが選択したノードを通じて、ANSアカウントのADMアドレス宛にシグナルメッセージ(チャットタイプ3、AIP-6)として送信します。ANSは自身宛のトランザクションをノードからポーリングし、復号して ADMアドレス → プッシュトークン のペアを保存します。

配信はその逆のプロセスです。ANSは転送(タイプ0)およびチャット(タイプ8)のトランザクションをポーリングし、受信者がレジストリに存在するかを確認した上で、APNsにプッシュ配信を要求します。

各当事者が学習する情報

通知のペイロードにはメッセージ内容は含まれません:

{
  "aps": {
    "alert": { "loc-key": "NotificationsService.NewMessage.BodySingle" },
    "badge": 1,
    "mutable-content": 1,
    "sound": "notification.mp3"
  },
  "push-recipient": "U1234567890123456",
  "txn-id": "7175005690801347553"
}

本文はメッセージではなくローカライゼーションキーです。アプリのNotification Service Extensionは txn-id を受け取ると、ノードからトランザクションを取得し、ユーザーの秘密鍵でローカルに復号してから通知を表示します。Appleが確認できるのは、デバイスのトークン、ADMアドレス、トランザクションID、およびタイミングのみであり、コンテンツは一切含まれません。これは明確に述べておくべき事実です。Appleにプッシュを依頼するということは、Appleが「そのアドレスに何かが届いた」という事実と、その時刻を知ることを意味します。

より興味深いのはサーバー側の特性です。アプリはANSへの接続を一切行いません。iOSクライアントのトークンサービスは、ADMノードに対してのみ2種類のネットワーク呼び出しを行うだけであり、アプリ内のどこにもプッシュサービスのホスト名は出現しません。したがって、ANSはブロックチェーン上で公開されている情報のみを確認し、重要な点として、デバイスのIPアドレスを一切取得しません。独自のノードやTorを経由してルーティングするユーザーは、cryptofoundryのインフラに一切触れることがありません。

この特性こそが本質であり、非常に繊細なものです。利便性を高めるための明らかな機能、例えばクライアントが「私のトークンはまだ登録されていますか?」と問い合わせるためのANS上の小さなHTTPSエンドポイントなどは、この特性を静かに破壊してしまいます。それはANSにデバイスのIPアドレスを渡すことになり、アプリの起動ごとにデバイスごとの生存確認信号を送るようなものであり、ユーザーによるノード選択の自由を奪い、現在のようなプラグイン可能なノードリストではなく、単一のブロック可能なホスト名を作り出すことになります。そのため、そのようなエンドポイントは追加されません。

登録のライフサイクルと問題点

チャネルがブロックチェーンであるため、add(登録)と remove(解除)はトランザクションとして扱われます。各シグナルメッセージには通常のチャット料金がかかります:constants.fees.chat_message = 100000(fixedPoint = 1e8、つまり 0.001 ADM)。これは価格以上に重要です。残高がゼロのユーザーはメッセージを送信できないため、「定期的に再登録する」といった堅牢化戦略は採用できません。

クライアントは登録済みと見なすトークンのコピーをローカルに保持し、iOSから異なるトークンが渡された場合にのみ再登録を行います。プライバシー保護の特性を損なうことなくサーバーに問い合わせることはできないため、登録が維持されているかをサーバーに確認することはありません。

この仕組みにより、サーバー側でデータ損失を伴わない、既知かつ再現可能な障害モードが発生します。ユーザーがオフライン中、あるいは不安定なノードに接続している間にサインアウトすると、クライアントはキャッシュされたトークンをクリアして remove(T) を送信しますが、送信が失敗するため、トランザクションは保持され、アプリの起動ごとに再試行されます。その後、ユーザーが同じデバイスでサインインし直すと、iOSは同じトークン T を提供します。ローカルキャッシュは空であるため、クライアントは add(T) を送信し、これは成功します(サーバーの状態は正しくなります)。しかし、キューに入れられていた remove(T) が最終的に成功し、add の後にチェーンに記録されるため、ANSは今作成したばかりの登録を削除してしまいます。クライアントのキャッシュは T のままであり、iOSは引き続き T を提供し続けるため、「トークンが変更されたか?」というチェックは二度と実行されません。デバイスは登録解除された状態となり、それに気づく術もありません。

通知は静かに停止します。唯一の復旧方法は、アプリ内の Notifications → Off → Push トグルを切り替えることであり、これによりローカルキャッシュがクリアされ、強制的に再登録が行われます。

なぜ急いで修正しないのか

これは現実のバグですが、発生条件は限定的です。登録解除の失敗に続いて、同じトークンの再登録が発生する必要があります。この問題を隠蔽する2つの近道は、どちらもバグよりも深刻な問題を引き起こします。HTTPチェックは、稀なサイレント障害を、恒久的かつ普遍的なメタデータ漏洩と引き換えることになります。定期的な再登録はユーザーの資金を消費し、ADM残高がゼロのユーザーには機能しません。

正しい修正はクライアント側での順序制御であり、ブロックチェーンを介した設計の範囲内で完結します。再試行キューをトークン対応にし、後の add(T) が成功した時点で保留中の remove(T) を破棄するようにし、「キャッシュが空であること」を登録状態と見なすのをやめ、remove と同様の注意を払って add を永続化することです。その作業はクライアント側で行われるべきものです。

同じ非対称性から生じる関連する結果として、unregister はアプリが記憶しているトークンに対してのみ送信できるため、再インストールを行うと以前の登録が永久に孤立してしまいます。現在、レジストリには約1,700の異なるアドレスにわたって約2,600行が保持されており、1つのアドレスがそのうち251行を占めています。APNsがトークンを無効と報告した際に削除されるため、プッシュを受け取っているユーザーについてはレジストリは自己浄化されますが、誰も書き込まないアドレスは利用されることがなく、クリーンアップもされません。

今後の展望

ADAMANT通知と統合する、あるいはクライアントを構築する方々への2つの教訓です。第一に、デバイスから通知サービスへのコールバックを追加しないでください。それは自然な設計に見えますが、保証を破壊する唯一の要因となります。第二に、登録(register)と登録解除(unregister)を順序付けられたペアとして扱ってください。これらは非同期で再試行可能であり、オンチェーンで処理されるため、順序が入れ替わる可能性があります。ローカルキャッシュから状態を推測するのではなく、明示的にシーケンスを管理してください。