Upgrade to Pro — share decks privately, control downloads, hide ads and more …

インターネットなしで、その場のみんなとつながる──CoreBluetoothで作るメッシュ型チャット

Avatar for ZOZO Developers ZOZO Developers PRO
September 12, 2026
12

 インターネットなしで、その場のみんなとつながる──CoreBluetoothで作るメッシュ型チャット

2026/9/12に iOSDC Japan 2026 で発表した登壇資料です。
https://iosdc.jp/2026/
株式会社ZOZO
ZOZOTOWN開発本部
ZOZOTOWN開発2部 iOSブロック
續橋 涼
#iosdc

Avatar for ZOZO Developers

ZOZO Developers PRO

September 12, 2026

More Decks by ZOZO Developers

Transcript

  1. iOSDC Japan 2026 / Track C / 9月12日 13:30 インターネットなしで、その場のみんなと

    つながる CoreBluetoothで作るメッシュ型チャット 續橋 涼 @tsuzuki817 / 株式会社ZOZO Copyright © ZOZO, Inc.
  2. 自己紹介 續橋 涼 @tsuzuki817 • 株式会社ZOZO iOSエンジニア • 個人開発10年 App

    Store に74本 • 会場で「うんちくん」のステッカー配布中 Copyright © ZOZO, Inc. 74 4.3 2,195 10 Apps Rating Reviews Years 9
  3. 今日の流れ 1 BLEでつながるまで 2 対等に送り合う 3 つないだ瞬間に相手が現れる 4 隣の隣まで届ける 5

    バイト数の上限に収める 6 鍵を配る ECDHとAES-GCM 7 アプリが落ちても切れない State Restoration Copyright © ZOZO, Inc. 用語と仕組み デュアルロール 自動ハンドシェイク 中継とループの止め方 31バイトとMTU 10
  4. 1 BLEでつながるまで BLE = Bluetooth Low Energy 日本語だと低消費電力Bluetooth 省電力 ボタン電池ひとつで数ヶ月動く

    少量 1回に運べるのは数十バイトから数百バイト 近距離 見通しで数十メートル、屋内なら十数メートル Copyright © ZOZO, Inc. 12
  5. 1 BLEでつながるまで イヤホンのBluetoothとは別物 Bluetooth Classic BLE 流し続ける たまに、少しだけ送る イヤホン、スピーカー、キーボード 途切れさ

    体重計、鍵、タグ、心拍計 そして今日のチャ せない代わりに電気を食う ット Copyright © ZOZO, Inc. 13
  6. 1 BLEでつながるまで セントラル Central は探す側 Central 探す側 CBCentralManager Peripheral 見つけてもらう側

    スキャンして相手を見つけ、接続を仕掛ける Copyright © ZOZO, Inc. 16
  7. 1 BLEでつながるまで 見つけたらつないで探しに行く func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,

    ...) { discovered[peripheral.identifier] = peripheral // 参照を持つ central.connect(peripheral) } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { peripheral.discoverServices([serviceUUID]) } この「参照を持つ」を忘れると事故る Copyright © ZOZO, Inc. 26
  8. 1 BLEでつながるまで ガット GATT は住所と郵便受け つながったあとにデータをやり取りする仕組み 相手のiPhone Peripheral 接続した直後はここまでしか分からない ↓

    discoverServices([serviceUUID]) Service 建物の住所 このアプリはここだと場所を決める A1B2C3D4-…-0001 ↓ Characteristic discoverCharacteristics([messageUUID], for:) 郵便受け メッセージはここを通る A1B2C3D4-…-0002 上から順に降りていかないと 郵便受けには辿り着けない Copyright © ZOZO, Inc. 27
  9. 1 BLEでつながるまで 決めたのはこの2行 RelayChatを入れた全員が同じ値を持つ アプリの合言葉 enum BluetoothConstants { static let

    serviceUUID = CBUUID(string: "A1B2C3D4-...-0001") // 住所 static let messageUUID = CBUUID(string: "A1B2C3D4-...-0002") // 郵便受け } この2つが揃った端末どうしだけが出会える Copyright © ZOZO, Inc. 31
  10. 1 BLEでつながるまで つながるまでの6ステップ 呼ぶ 1 Advertise 返ってくる peripheralManagerDidStartAdvertising(_:error:) startAdvertising(_:) 2

    Scan centralManager(_:didDiscover:advertisementData:rssi:) scanForPeripherals(withServices:options:) 3 Connect centralManager(_:didConnect:) connect(_:options:) 4 DiscoverServices peripheral(_:didDiscoverServices:) discoverServices(_:) 5 DiscoverCharacteristics peripheral(_:didDiscoverCharacteristicsFor:error:) discoverCharacteristics(_:for:) 6 Subscribe peripheral(_:didUpdateValueFor:error:) setNotifyValue(_:for:) Copyright © ZOZO, Inc. 33
  11. 1 BLEでつながるまで 購読も1行 // Central側 ── 購読する peripheral.setNotifyValue(true, for: characteristic)

    // Peripheral側 ── 購読されたら、送れる相手として持つ func peripheralManager(_ pm: CBPeripheralManager, central: CBCentral, didSubscribeTo ch: CBCharacteristic) { subscribers.insert(central) } ここまで来てやっとデータが流せる Copyright © ZOZO, Inc. 34
  12. 1 BLEでつながるまで ログにも同じ順で出る ✅ DISC: Scanning started ✅ DISC: Found

    RelayChat: 893fbfc2 (-58dBm) ✅ CONN: Connected: D609F158 Copyright © ZOZO, Inc. 35
  13. 1 BLEでつながるまで ここまでが基礎 覚えて帰る言葉は4つ Central 探す側 見つけて、読みにいく つながるまでの6ステップ 1 Advertise

    2 Scan 3 Connect Service UUID 4 DiscoverServices 住所 同じ値の端末どうしが出会える 5 DiscoverCharacteristics Characteristic UUID 6 Subscribe Peripheral 見つけてもらう側 名札を掲げて待つ 郵便受け ここにデータを出し入れする Copyright © ZOZO, Inc. 36
  14. 2 対等に送り合う 初期化を2つ並べるだけ centralManager = CBCentralManager(delegate: self, queue: nil )

    peripheralManager = CBPeripheralManager(delegate: self, queue: nil) つまずいたのは 送るところから Copyright © ZOZO, Inc. 45
  15. 2 対等に送り合う Peerがこうなった struct Peer { let id: UUID var

    peripheral: CBPeripheral ? var central: CBCentral ? } Copyright © ZOZO, Inc. 53
  16. 2 対等に送り合う 同じ相手に同じUUIDが割り当てられる peripheral.identifier と central.identifier が同じ値 CBPeripheral A1B2 =

    同じ相手なのだから iOS から見れば当然の話 CBCentral A1B2 Copyright © ZOZO, Inc. 56
  17. 2 対等に送り合う Setで持っていた 新しく繋がったらinsertする var connectedPeers: Set<Peer > = []

    connectedPeers. insert(Peer(id: id, peripheral: peripheral)) Copyright © ZOZO, Inc. 59
  18. 2 対等に送り合う 新しい参照が捨てられる 新しいPeer Set(既存) insert Peer A1B2 central のみ

    Peer A1B2 peripheral 入り CBPeripheral の参照も一緒に 消える ✕ 捨てられる Copyright © ZOZO, Inc. 61
  19. 強制切断されて また見つかる iOSが「誰も持っていないペリフェラル」を切る 切断された回数 見つける › つなぐ › 参照が消える ›

    切られる ↺ 経過 1回 0:02 API MISUSE: Forcing disconnection of unused peripheral ✅ DISC: Found RelayChat: no name (-45dBm) ✅ CONN: Connected: C7B919BE Copyright © ZOZO, Inc. 64
  20. これが延々と続く 切断と再接続だけで5分が過ぎた 切断された回数 見つける › つなぐ › 参照が消える › 切られる

    ↺ 34回 経過 4:58 API MISUSE: Forcing disconnection of unused peripheral ✅ DISC: Found RelayChat: no name (-43dBm) ✅ CONN: Connected: F1B2730C API MISUSE: Forcing disconnection of unused peripheral ✅ DISC: Found RelayChat: no name (-50dBm) ✅ CONN: Connected: C7B919BE API MISUSE: Forcing disconnection of unused peripheral ✅ DISC: Found RelayChat: no name (-57dBm) ✅ CONN: Connected: 49AC0E62 Copyright © ZOZO, Inc. 65
  21. 2 対等に送り合う Setをやめて辞書にする // before var connectedPeers: Set<Peer> = []

    // after var peers: [UUID: Peer] = [:] Copyright © ZOZO, Inc. 68
  22. 3 つないだ瞬間に相手が現れる Central側 購読できたら すぐ送る func peripheral(_ p: CBPeripheral, didUpdateNotificationStateFor

    ch: CBCharacteristic, error: Error?) { guard ch.isNotifying else { return } send(myProfile(), to: p) // ここで名刺を出す } Copyright © ZOZO, Inc. 80
  23. 3 つないだ瞬間に相手が現れる Peripheral側 購読されたら すぐ送る func peripheralManager(_ pm: CBPeripheralManager, central:

    CBCentral, didSubscribeTo ch: CBCharacteristic) { subscribers.insert(central) send(myProfile(), to: central) } Copyright © ZOZO, Inc. 81
  24. 3 つないだ瞬間に相手が現れる 受け取ったら 画面に出す ここで「近くにいる人」に相手が並ぶ case .profile(let profile): peers[profile.id]?.name =

    profile.name peers[profile.id]?.icon = profile.icon peers[profile.id]?.publicKey = profile.publicKey notifyPeersChanged() Copyright © ZOZO, Inc. 82
  25. 3 つないだ瞬間に相手が現れる 自分の郵便受けには 投函できない Peripheral側が持つ CBMutableCharacteristic に write は通らない //

    ❌ Peripheral側の characteristic に write はできない peripheral.writeValue(data, for: mutableCh, type: .withResponse) // ✅ 値を更新して通知を飛ばす peripheralManager.updateValue(data, for: mutableCh, onSubscribedCentrals: [central]) 自分の郵便受けは 値を更新して通知を飛ばす Copyright © ZOZO, Inc. 83
  26. 4 隣の隣まで届ける AからCには直接届かない A → C 届かない A B C

    送りたい人 両方に届く Aからは見えない Aの電波が届く範囲 Copyright © ZOZO, Inc. 86
  27. 4 隣の隣まで届ける ① TTL 3回で打ち切る Time To Live あと何回中継できるかの残り回数 ①

    TTL ② 既読メッセージID ③ 経路記録 Copyright © ZOZO, Inc. 何回中継したかを数えて、3回で打ち切る 90
  28. 4 隣の隣まで届ける 3ホップより遠い人には届かない 本文が届くのは A から数えて4台目まで A 1 B 2

    C 3 D 屋内 見通しがよければ 40〜50 m 100 m 超 届かない E その場にいる範囲で使う アプリとしての割り切り Copyright © ZOZO, Inc. 92
  29. 4 隣の隣まで届ける ② 既読メッセージID 二度目は捨てる ① TTL ② 既読メッセージID ③

    経路記録 Copyright © ZOZO, Inc. 一度見たIDは覚えておき、二度目は捨てる 93
  30. 4 隣の隣まで届ける ③ 経路記録 どこを通ってきたか ① TTL ② 既読メッセージID ③

    経路記録 Copyright © ZOZO, Inc. 通ってきた端末をメッセージに書き込む 95
  31. 4 隣の隣まで届ける コードだとこの2つ var hops: Int = 0 // 中継回数

    var route: [String] = [] // 通ってきた経路 Copyright © ZOZO, Inc. 96
  32. 4 隣の隣まで届ける 直したコード guard !msg.route.contains(myPersistentId) else { return } guard

    msg.hops < ttl(for: msg.kind) else { return } var relay = msg relay.hops += 1 relay.route.append(myPersistentId) send(relay, excludingPeerId: senderId) Copyright © ZOZO, Inc. 110
  33. 4 隣の隣まで届ける 流しているのは本文だけじゃない メッセージは3種類ある 本文 ユーザーが打ったメッセージ 参加通知 新しく入ってきた人を知らせる 入ってきたときだけ 生存確認

    まだ近くにいるかを確かめる 10秒ごとに ずっと 打ったときだけ 生存確認だけは 誰も打っていなくても飛び続ける Copyright © ZOZO, Inc. 112
  34. 4 隣の隣まで届ける TTLは種別ごとに分ける 種別ごとに 届く範囲を変える 生存確認 1 ホップ 中継しない 直接つながっている

    相手だけに届く Copyright © ZOZO, Inc. 参加通知 本文 2 ホップ 3 ホップ 隣の隣まで 3台先まで 1回だけ中継される ここだけ遠くまで運ぶ 114
  35. 5 バイト数の上限に収める / 壁② 本文 上限を取るAPIは呼んでいた この「1回で運べる上限」を MTU と呼ぶ let

    mtu = peripheral.maximumWriteValueLength(for: .withResponse) print("MTU: \(mtu)") Copyright © ZOZO, Inc. 128
  36. 5 バイト数の上限に収める / 壁② 本文 1351バイト / 1フレーム503バイト → 3分割

    ℹ MSG: Sending user_message to 2 peer(s) (1351B) Copyright © ZOZO, Inc. 138
  37. 6 鍵を配る 2枚の封 どこまで守るかが違う 開けられる範囲が違う 外側の封 内側の封 1ホップぶん 宛先まで 隣同士で作ったリンク鍵で封をする

    全メッセー 宛先の人とだけ作った鍵で封をする 個別メッセ ジが対象 ージだけが対象 Copyright © ZOZO, Inc. 150
  38. 6 鍵を配る 圧縮してから 暗号化する zlib で縮めて AES-GCM で封をする // 送信

    let compressed = try payload.compressed(using: .zlib) let sealed = try AES.GCM.seal(compressed, using: sessionKey) let out = sealed.combined! // 受信 let box = try AES.GCM.SealedBox(combined: data) let opened = try AES.GCM.open(box, using: sessionKey) let payload = try opened.decompressed(using: .zlib) 順番を逆にすると 圧縮が効かない Copyright © ZOZO, Inc. 151
  39. 6 鍵を配る なぜ この順番なのか 圧縮 → 暗号化 日本語のメッセージがよく縮む 暗号化 →

    圧縮 暗号文はランダムなので まったく縮まない MTUに収めるために 縮めるのが先 Copyright © ZOZO, Inc. 153
  40. 7 アプリが落ちても切れない 復元用のIDを渡して初期化する 次に起きたとき このIDで状態を返してもらう centralManager = CBCentralManager( delegate: self,

    queue: nil, options: [CBCentralManagerOptionRestoreIdentifierKey: "relaychat.central"]) peripheralManager = CBPeripheralManager( delegate: self, queue: nil, options: [CBPeripheralManagerOptionRestoreIdentifierKey: "relaychat.peripheral"]) Info.plist に bluetooth-central / bluetooth-peripheral も必要 Copyright © ZOZO, Inc. 157
  41. 7 アプリが落ちても切れない バックグラウンドから復帰すると 2つ呼ばれる State Restoration = 接続状態をシステムに預けて 復帰時に返してもらう仕組み willRestoreState

    預けておいた接続の状態が返ってくる poweredOn Bluetoothが使えるようになった合図 問題は 呼ばれる順番だった Copyright © ZOZO, Inc. 160
  42. 7 アプリが落ちても切れない そこでサービスを探し直していた func centralManager(_ c: CBCentralManager , willRestoreState dict:

    [ String: Any ]) { for p in restored(dict) { p.delegate = self p.discoverServices([svcUUID]) // ← まだ poweredOn じゃない } } Copyright © ZOZO, Inc. 162
  43. 7 アプリが落ちても切れない 連鎖して鍵が作れなくなる 弾かれる discoverが通らない 参照が取れない › 郵便受けがない 渡せない ›

    公開鍵を渡せない 鍵が作れない › 開けられない つながっているのに、開けられない Copyright © ZOZO, Inc. 164
  44. [20:14:59] ⚠ Cannot send key exchange - no transport [20:17:31]

    ⚠ Cannot send key exchange - no transport [20:20:01] ⚠ Cannot send key exchange - no transport Copyright © ZOZO, Inc. 165
  45. 7 アプリが落ちても切れない 5分の沈黙が消えた ℹ BT: Resuming 3 restored peripheral(s) ✅

    CRYPTO: 🔐 Link key established with cd7a673e Copyright © ZOZO, Inc. 174
  46. 今日話したこと どれも「対等」にしたことから生まれている 1 BLEでつながるまで UUIDを2つ決めて順番に呼ぶ 2 対等に送り合う 役割が2つ乗るから同じIDが衝突する 3 つないだ瞬間に相手が現れる

    接続直後に名刺を交換する 4 隣の隣まで届ける 全員に配るから経路を記録する 5 バイト数の上限に収める 31バイトとMTUに合わせて切る 6 鍵を配る 中央がないから隣同士で作る 7 アプリが落ちても切れない 預けて 起きたら順番に気をつける Copyright © ZOZO, Inc. 177
  47. 実機との付き合い方 全部は無理でも 壊れやすいところだけはテストできる 型は作れない 判断は切り出せる CBPeripheral / CBCentral 「片方が切れたら相手を残すか」 「もう一度鍵

    イニシャライザが非公開なので、実機で確かめ を送るか」 るしかない 値型にすればテストが書ける 今日話した失敗は 全部その判断ミス Copyright © ZOZO, Inc. 179
  48. Appendix A-1 検証環境 今日お見せした実機のログは すべてこの環境 端末 iPhone 15 / iPhone

    16 / iPhone 14 Pro Max / iPhone SE(第2世代) iOS iOS 26 / iOS 18 最大台数 同時に4台 場所 屋内 開発期間 1.5ヶ月 Copyright © ZOZO, Inc. 183
  49. Appendix A-2 なぜCoreBluetoothなのか 事前ペアリング/見知らぬ相手/多段中継/バックグラウンド/帯域 事前ペアリング 見知らぬ相手 多段中継 バックグラウンド 帯域 CoreBluetooth(本作)

    不要 ◦ 自前実装 ◦ 小 MultipeerConnectivity 不要 ◦ 自動(最大8台) △制限あり 大 Wi-Fi Aware(iOS 26) 必要 × × ◦ 大 Bluetooth Mesh (SIG標準) 事前設定が必要 × ◦ — 小 NearbyInteraction 別に探索が必要 △ × △ 測距のみ Copyright © ZOZO, Inc. 184
  50. Appendix A-3 Wi-Fi Aware(iOS 26)ではダメなのか iOS 26 で WiFiAware フレームワークが入った

    ペアリングが必須 接続前に AccessorySetupKit / DeviceDiscoveryUI での操作が要る 見知らぬ相手が不可 「誰か分からない相手とその場でつながる」ができない 想定はアクセサリ ドキュメント上も Wi-Fi Aware certified accessories との接続が前提 対応機種 iPhone 12 以降 / iPad 第10世代以降 「相手が誰か分からない状態から始められる」── これがBLEを選んだ理由 Copyright © ZOZO, Inc. 185
  51. Appendix A-4 守れるもの と 守れないもの その場の雑談アプリとして どこまでを守る設計にしたか 攻撃者 防げる 理由

    電波を傍受するだけの第三者 ◦ 外側の封(リンク鍵) 中継端末が本文を読む(個別メッセージ) ◦ 内側の封 中継端末が本文を読む(全体メッセージ) × 内側の封をかけていない 中継端末による公開鍵のすり替え × 公開鍵に署名・検証がない 永続鍵が漏れたあとの過去ログの復号 × 前方秘匿性がない 他人のIDを名乗るなりすまし × 認証がない Copyright © ZOZO, Inc. 186
  52. Appendix A-5 3ホップ先の公開鍵はどこから来るのか A ── B ── C AとCは直接つながっていない ①

    A—B と B—C は 接続直後の名刺交換で手に入る ② Cの参加通知が B を経由して A に届く ③ A は C の公開鍵で内側の封を作る ! ②を運ぶのは B なので B が鍵をすり替えられる A-4 の「公開鍵のすり替え」がここ Copyright © ZOZO, Inc. 187
  53. Appendix A-6 永続鍵から対称鍵まで // 1. 永続鍵(Keychain, P256.KeyAgreement.PrivateKey) // 2. ECDH

    で共有秘密 let shared = try myPrivateKey.sharedSecretFromKeyAgreement( with: peerPublicKey) // 3. HKDF で対称鍵に let sc-camel-link-key = shared.hkdfDerivedSymmetricKey( using: SHA256.self, salt: ..., sharedInfo: ..., outputByteCount: 32) // 4. AES-GCM で封をする nonce は seal が自動生成し combined に同梱 両端で同じ鍵になるのは ECDH の性質そのもの Copyright © ZOZO, Inc. 188
  54. Appendix B-1 フレームフォーマット 種別 転送ID 連番 総数 1B 4B 2B

    2B ペイロード 0x01 プロフィール 0x04 生存確認 0x02 本文 0x05 鍵交換 0x03 参加通知 Copyright © ZOZO, Inc. 189
  55. Appendix B-2 種別ごとのパラメータ 種別 TTL 送信契機 暗号化 本文 3ホップ ユーザー入力時

    外側+内側 参加通知 2ホップ 新規参加時 外側 生存確認 中継なし 10秒ごと 外側 鍵交換 中継なし 接続直後・受信時 なし(公開鍵) Copyright © ZOZO, Inc. 190
  56. Appendix B-5 API MISUSE 対応表 ログ Forcing disconnection of unused

    peripheral can only accept this command while in the powered on state Copyright © ZOZO, Inc. 意味 CBPeripheral の参照が誰にも保持されて いない poweredOn の前にコマンドを呼んだ 対処 強参照で保持する poweredOn まで待つ 193
  57. Appendix B-6 実機デバッグの道具 PacketLogger HCIレベルのログを取る Additional Tools for Xcode に同梱

    Bluetooth Explorer 同じく Additional Tools for Xcode に同梱 Console.app 複数台のログを同時に見る シミュレータ CoreBluetooth は動かない 実機しかない Copyright © ZOZO, Inc. 194