目次
- なぜペアリングが必要なのか
- 信頼モデルと暗号
- コンポーネントマップ
- ディスカバリフェーズ
- ペアリングシーケンス (ハッピーパス)
- 鍵交換の詳細
- レスポンダの承諾 / 拒否フロー
- キャンセルフロー
- デュアルチャネル配信 (LAN + BLE)
- セッションとピアの保存
- セキュリティ特性
- ステートマシンのまとめ
なぜペアリングが必要なのか {#why-pairing-exists}
PlainApp には中央アカウントサーバーが存在しません。そのため、デバイス同士が通信する前には以下の 2 つの問いに答える必要があります。
- 「あなたは誰ですか?」 — すべてのデバイスは初回起動時に安定した
clientIdを生成します (Ed25519 鍵素材から派生した 13 文字の id)。これがルーティング・プレゼンス・チャネルメンバーシップに使われる唯一の識別子です。 - 「あなたを信頼できるか?」 — サーバーが身元を保証しない以上、ピアが自称どおりの相手であることを確信するには、人間が両デバイスでペアリングを確認し、プロトコルが暗号署名を検証するしかありません。
ペアリングが生成する成果物は 1 つだけです。データベース上の DPeer 行で、status="paired" と ChaCha20 key (共通トランスポート秘密鍵)、そして相手の Ed25519 public_key (今後のメッセージ署名を検証するため) を持ちます。チャットサブシステムの以降のプロトコルはすべて、これら 2 つのフィールドが存在することを前提としています。
信頼モデルと暗号 {#trust-model--cryptography}
ペアリングは 2 つの独立した暗号プリミティブを用います。
| プリミティブ | 目的 | ライフサイクル |
|---|---|---|
| Ed25519 (署名) | ペアリングリクエストとレススポンスを認証する。「これが本当に送信を名乗るデバイスから来たものか」を検証し、リプレイ防止のためにタイムスタンプを紐付ける。 | 署名鍵はデバイスの長期アイデンティティ鍵です。その公開鍵半分は DPeer.public_key として保存され、後で PeerChatParser.decrypt がすべてのチャットメッセージ署名の検証に使用します。 |
| X25519 方式 ECDH (鍵合意) | 共通秘密鍵を生成し、それが ChaCha20 トランスポート鍵になる。2 台のデバイスは送信せずに同じ秘密鍵を計算する。 | ペアリングセッションごとに一時鍵ペアを生成し、共通鍵の計算直後に破棄する。結果の 32 バイト秘密鍵は DPeer.key として保存され、ペアリングの有効期間中再利用される。 |
PIN も QR コードも帯域外コードも存在しません。信頼は次のように確立されます。
- 人間がレスポンダ側デバイスで Accept をタップする (ユーザーが「はい、これがペアリングしたいデバイスだ」と主張する)。
- 双方がリクエスト/レスポンス上で相手の Ed25519 署名を検証する (レスポンダがセッションを開始したデバイスと通信していること、その逆も証明する)。
- 両メッセージに ±5 分のタイムスタンプ窓を持たせる (古いキャプチャ済みハンドシェイクのリプレイを防ぐ)。
この非対称性が重要です。人間の確認だけでは man-in-the-middle 攻撃に対して脆弱です (攻撃者は双方と別々にペアリングできてしまう)。ECDH 公開鍵に対する Ed25519 署名がこれを防ぎます — レスポンダはリクエストがセッションを開始した Ed25519 鍵と同じ鍵で署名されていることを検証し、その逆も同様です。したがって MITM は長期署名鍵を制御せずに自身の ECDH 鍵を透明に差し替えることはできません。
コンポーネントマップ
ペアリングのコードはすべて discover/ パッケージにあります (chat/peer/pair/ ではありません)。
ファイルの場所
| コンポーネント | パス (shared/src/commonMain/kotlin/com/ismartcoding/plain/ 配下) |
|---|---|
LANDiscoverManager | discover/LANDiscoverManager.kt |
PairingCore | discover/PairingCore.kt |
PairingInitiator | discover/PairingInitiator.kt |
PairingResponder | discover/PairingResponder.kt |
PairingSecurity | discover/PairingSecurity.kt |
PairingSessionStore | discover/PairingSessionStore.kt |
PairingPeerStore | discover/PairingPeerStore.kt |
PairingMessenger | discover/PairingMessenger.kt |
ディスカバリフェーズ {#discovery-phase}
ペアリングを行う前に、デバイス同士が互いを見つける必要があります。LANDiscoverManager はアプリ起動後、継続的に動作します。
なぜダイレクトディスカバリが暗号化されているのか
ブロードキャストの DISCOVER は機密性のあるものを公開しません (fromId=clientId だけ) ので、LAN 上のどのデバイスが見ても問題ありません。しかしダイレクト方式は、あるデバイスがすでに別のデバイスの clientId を知っていて (例: ペアリング済みだがピアの IP が変わった)、相手をウェイクアップしたい場合に使われます。対象の clientId をピアの共通鍵で暗号化することで、次の意味を持ちます。
- 正しいピアだけが
toIdを復号し、自身を認識して返信できる。 - LAN 上のその他のデバイスは暗号文しか見えない — 送信者がどの
clientIdに到達しようとしているかを列挙できない。
これは小さいですが、実在するプライバシー特性です。受動的な LAN 観測者は誰が誰とペアリングしているかのグラフを構築できません。
返信内の Aware フラグ
DISCOVER_REPLY は awareSupported と awareRunning を運びます。これらはデータベースには永続化されません — PeerCacher のメモリ上に保存され、毎回の返信 (および BLE スキャンレスポンスの serviceData) で更新されます。トランスポート層はこれらを参照し、Wi-Fi Aware リンクを試みるか BLE に直接進むかを判断します。
ペアリングシーケンス (ハッピーパス) {#pairing-sequence-happy-path}
両デバイスが同じ LAN 上にあり、ユーザーがペアリングを承諾した場合のエンドツーエンドフローです。
なぜ双方が独立してピアを保存するのか
イニシエータ (ステップ 9) とレスポンダ (ステップ 7) の両方が、相手デバイスに対して PairingPeerStore.save(...) を呼び出すことに注意してください。これは意図的です。各デバイスは、相手の clientId をキーとする DPeer 行を持ち、自身の共通 ChaCha20 鍵と相手の Ed25519 公開鍵のコピーを格納して完結します。中央のレジストリは存在しません — ペアリングは対称的で自己完結しています。
なぜレスポンダが先に鍵を計算するのか
レスポンダの acceptPairingRequest は、承諾と同時に共通鍵を計算して永続化します。つまり、レスポンダはレスポンスがイニシエータに戻る前に暗号化トラフィックを受信する準備が整います。もしレスポンスが転送中に失われた場合でも、レスポンダはペアリング済みのままです — 再試行が必要なのはイニシエータだけです。
鍵交換の詳細 {#key-exchange-details}
ペアリングの暗号コアは標準的な X25519 方式 ECDH 鍵合意ですが、その上に Ed25519 署名を重ねて認証を行っています。
署名が実際に保護するもの
署名対象のペイロード (toSignatureData()) は、リクエストの安定フィールドを正規に連結したものです: fromId、fromName、port、deviceType、ecdhPublicKey、signaturePublicKey、timestamp、ips。ecdhPublicKey を長期の signaturePublicKey と一緒に署名することで、プロトコルは一時鍵をデバイスのアイデンティティに紐付けます。攻撃者が途中で独自の ECDH 公開鍵に差し替えるには署名を無効化するしかなく、長期 Ed25519 鍵を制御せずに署名を偽造することはできません。
これが man-in-the-middle を退ける理由です。攻撃者が両デバイス間のすべてのパケットを中継できたとしても、暗号化トラフィックを読むことはできません (どちら側の ECDH 秘密鍵も持たないため) し、自身の ECDH 鍵に差し替えることもできません (署名が壊れるため)。
レスポンダの承諾 / 拒否フロー {#responder-acceptdecline-flow}
レスポンダ側は PAIR_REQUEST が到着すると UI ダイアログを表示します。ユーザーは承諾もしくは拒否できます。
なぜレスポンダは承諾と同時に PairingSuccessEvent を発火するのか
レスポンダの acceptPairingRequest は PairingPeerStore.save(...) を呼び、レスポンスを送る前に PairingSuccessEvent を発火します。これは意図的です。もしレスポンスがイニシエータに届かなくても (ネットワークの不具合など)、レスポンダはペアリング済みのままです — 次回イニシエータがペアリングを試みた際、レスポンダにすでに存在する DPeer 行がプレゼンスシステムに拾われます。イニシエータは単に再試行するだけでよく、レスポンダの再確認は不要です。
キャンセルフロー
どちら側でも進行中のペアリングをキャンセルできます。
DPairingCancel は LAN ユニキャストのみで送信されます (イニシエータはディスカバリフェーズでレスポンダの IP をすでに把握しているため)。一方、拒否レスポンスは LAN と BLE の両方で送信されます。レスポンダはイニシエータがどちらのトランスポートで到達可能か確信できないためです。
デュアルチャネル配信 (LAN + BLE)
レスポンダは DPairingResponse を送る際、LAN と BLE を同時に送信します。イニシエータは最初のコピーを受け入れ、重複したコピーは暗黙に破棄します。
なぜ BlePairingSessionStore が存在するのか
PAIR_REQUEST が BLE 経由で到着した場合、レスポンダはイニシエータの LAN IP を持っていません — BLE MAC アドレスだけです。BlePairingSessionStore は peerId → MAC のマッピングを保持し、必要に応じてレスポンスを BLE 経由でルーティングできるようにします。これは小さなメモリ上の一時マップで、BLE ルーティングされたリクエストにのみ追加され、レスポンス送信後にクリアされます。
セッションとピアの保存
ペアリングには 2 つのストアが関与し、それぞれライフタイムが大きく異なります。
なぜ clientId のみが永続化される識別子なのか
Android は BLE MAC アドレスを接続ごとにランダム化するため、保存しても無意味です。clientId はデバイスの長期 Ed25519 鍵素材から派生するため、次の性質を持ちます。
- アプリ再インストールをまたいで安定 (鍵はプラットフォームの keystore にある)。
- 自己認証的 —
clientIdを主張する者は対応する Ed25519 秘密鍵を保持していることを証明する必要がある (すべての署名付きメッセージで検証される)。 - プライバシー保護 — BLE でディスカバリのためにブロードキャストされるのは 8 バイトの SHA-256 プレフィックス (
shortId) のみ。完全なclientIdは実際にペアリングしたデバイスにのみ開示されます。
セキュリティ特性 {#security-properties}
| 特性 | 達成方法 |
|---|---|
| 機密性 | すべてのトランスポートは ECDH 派生の共通鍵で ChaCha20 暗号化される。鍵はペアリング後に 2 台のデバイスから出ることがない。 |
| 認証 | すべての署名付きメッセージ (ペアリングリクエスト/レスポンス、チャットの createChatItem、チャネルの invite/update/kick) は送信者の public_key に対して Ed25519 検証される。 |
| 完全性 | Ed25519 署名はリクエストボディ全体をカバーする。改ざんは署名を無効化する。 |
| リプレイ耐性 | ±5 分のタイムスタンプ窓 (PeerChatParser と PairingSecurity が強制)。ChatMessageReceiver.seenSignatures が窓内で重複排除する。 |
| Man-in-the-middle 耐性 | 一時 ECDH 公開鍵を長期 Ed25519 公開鍵と一緒に署名する。MITM は署名を壊さずに独自の ECDH 鍵を差し替えられない。 |
| 前方秘匿性 (限定的) | ECDH 鍵ペアはペアリングセッションごとに一時的。後から長期 Ed25519 鍵が漏洩しても過去のトラフィックは復号できない (共通鍵も依然必要 — しかし ECDH 秘密鍵と保存された DPeer.key が両方とも消去されれば、過去のキャプチャは復号不能)。 |
| DoS 耐性 | onDatagram はすべてのメッセージを try/catch で包むため、不正なパケットがディスカバリ受信者を落とすことはない。PeerCircuitBreaker は 2 回の失敗後 30 秒間、不安定なトランスポートをスキップする。 |
| プライバシー (ダイレクトディスカバリ) | LANDiscoverManager.discoverSpecificDevice は対象の clientId をピアの鍵で暗号化する — 受動的な LAN 観測者は誰が誰とペアリングしているかを列挙できない。 |
| アイデンティティの安定性 | clientId はプラットフォーム keystore 内の長期 Ed25519 鍵素材から派生する — 再インストール後も安定、自己認証的、電話番号やメールに紐付かない。 |
ペアリングが防御しないもの
- 物理的なデバイス侵害。 攻撃者がペアリング済みデバイスで root を取得した場合、データベースから共通鍵を読み出し、そのピアを偽装できます。共通トランスポート鍵にはハードウェア保護の keystore 強制がありません (Ed25519 署名鍵のみ、
SignatureHelper経由で強制されます)。 - アクティブなリレー攻撃。 攻撃者が BLE と LAN トラフィックを、互いにペアリングしていると思っている 2 台のデバイス間で同時にリレーできれば、理論上中間に位置できます — ただし ECDH 公開鍵に対する Ed25519 署名があるためトラフィックを読むことはできず、リレーするだけです。これは numeric comparison を伴わない Bluetooth ペアリングと同じトレードオフです。
- ネットワークレベルのブロック。 ファイアウォールが UDP マルチキャストをブロックでき、BLE はジャミングでき、Wi-Fi Aware は利用不可になり得ます。システムは段階的に劣化します (ペアリング済みピアには BLE が保証されたフォールバック) が、積極的に敵対的なネットワークを迂回することはできません。
ステートマシンのまとめ {#state-machine-recap}
関連記事
- Chat Architecture — 共通鍵が何に使われるか: ピアチャットの送受信、チャネルの fan-out、プレゼンス、ファイルダウンロード。
apitest/groups/discovery.sh— ディスカバリおよびペアリング API をエンドツーエンドで実行するテスト計画。