Slide 1

Slide 1 text

iOSDC Japan 2026 / Track C / 9月12日 13:30 インターネットなしで、その場のみんなと つながる CoreBluetoothで作るメッシュ型チャット 續橋 涼 @tsuzuki817 / 株式会社ZOZO Copyright © ZOZO, Inc.

Slide 2

Slide 2 text

突然ですが Wi-Fiつながっていますか? Copyright © ZOZO, Inc. 2

Slide 3

Slide 3 text

スタッフさんの技術力で 快適につながっていると思います Copyright © ZOZO, Inc. 3

Slide 4

Slide 4 text

ではネットがない場所で iPhoneだけでつながれるか Copyright © ZOZO, Inc. 4

Slide 5

Slide 5 text

DEMO 3台とも機内モード Copyright © ZOZO, Inc. 5

Slide 6

Slide 6 text

サーバーも インターネットも使っていない Copyright © ZOZO, Inc. 6

Slide 7

Slide 7 text

真ん中のBが中継した A B C 打つ 預かって、渡す 届く AとCは直接電波が届かない距離にある Copyright © ZOZO, Inc. 7

Slide 8

Slide 8 text

RelayChat App Store で配信中 App Store Copyright © ZOZO, Inc. 8

Slide 9

Slide 9 text

自己紹介 續橋 涼 @tsuzuki817 ● 株式会社ZOZO iOSエンジニア ● 個人開発10年 App Store に74本 ● 会場で「うんちくん」のステッカー配布中 Copyright © ZOZO, Inc. 74 4.3 2,195 10 Apps Rating Reviews Years 9

Slide 10

Slide 10 text

今日の流れ 1 BLEでつながるまで 2 対等に送り合う 3 つないだ瞬間に相手が現れる 4 隣の隣まで届ける 5 バイト数の上限に収める 6 鍵を配る ECDHとAES-GCM 7 アプリが落ちても切れない State Restoration Copyright © ZOZO, Inc. 用語と仕組み デュアルロール 自動ハンドシェイク 中継とループの止め方 31バイトとMTU 10

Slide 11

Slide 11 text

Chapter 1 BLEでつながるまで BLEの用語とつながるまでの流れ Copyright © ZOZO, Inc. 11

Slide 12

Slide 12 text

1 BLEでつながるまで BLE = Bluetooth Low Energy 日本語だと低消費電力Bluetooth 省電力 ボタン電池ひとつで数ヶ月動く 少量 1回に運べるのは数十バイトから数百バイト 近距離 見通しで数十メートル、屋内なら十数メートル Copyright © ZOZO, Inc. 12

Slide 13

Slide 13 text

1 BLEでつながるまで イヤホンのBluetoothとは別物 Bluetooth Classic BLE 流し続ける たまに、少しだけ送る イヤホン、スピーカー、キーボード 途切れさ 体重計、鍵、タグ、心拍計 そして今日のチャ せない代わりに電気を食う ット Copyright © ZOZO, Inc. 13

Slide 14

Slide 14 text

1 BLEでつながるまで なぜチャットにBLEなのか ① 相手が誰か分からない状態から始められる ② アプリを閉じてもつながりを保てる ③ iOSの標準APIだけで完結する Copyright © ZOZO, Inc. 14

Slide 15

Slide 15 text

役割は2つだけ Copyright © ZOZO, Inc. 15

Slide 16

Slide 16 text

1 BLEでつながるまで セントラル Central は探す側 Central 探す側 CBCentralManager Peripheral 見つけてもらう側 スキャンして相手を見つけ、接続を仕掛ける Copyright © ZOZO, Inc. 16

Slide 17

Slide 17 text

1 BLEでつながるまで ペリフェラル Peripheral は見つけてもらう側 Peripheral Central 探す側 見つけてもらう側 CBPeripheralManager iPhoneとワイヤレスイヤホンなら、イヤホン 側 Copyright © ZOZO, Inc. 17

Slide 18

Slide 18 text

1 BLEでつながるまで 同じiPhoneが どちらにもなれる 端末の種類ではなく その場での役割 AirPodsを探すとき Central 体重計から見られるとき Peripheral 探す側 見つけてもらう側 役割は 相手とのあいだで決まる Copyright © ZOZO, Inc. 18

Slide 19

Slide 19 text

1 BLEでつながるまで Advertise 名札を掲げて立つ Peripheral側がやること RelayChat 「ここに RelayChat がいます」 名札を掲げて、立って待つ Peripheral 見つけてもらう側 Copyright © ZOZO, Inc. 19

Slide 20

Slide 20 text

1 BLEでつながるまで 名札を掲げて立つ Copyright © ZOZO, Inc. 20

Slide 21

Slide 21 text

1 BLEでつながるまで コードだとこれだけ peripheralManager.startAdvertising([ CBAdvertisementDataServiceUUIDsKey: [serviceUUID], CBAdvertisementDataLocalNameKey: myName ]) アドバタイズに載せられる量には上限がある Copyright © ZOZO, Inc. 21

Slide 22

Slide 22 text

1 BLEでつながるまで Scan 名札を探して歩く Central側がやること 名札? Central 探す側 RelayChat 発見 名札? Copyright © ZOZO, Inc. 22

Slide 23

Slide 23 text

1 BLEでつながるまで 名札を探して歩く Copyright © ZOZO, Inc. 23

Slide 24

Slide 24 text

1 BLEでつながるまで こちらもほぼ1行 centralManager.scanForPeripherals( withServices: [serviceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: true] ) 指定したUUIDの名札だけが引っかかる Copyright © ZOZO, Inc. 24

Slide 25

Slide 25 text

見つけたらConnect Copyright © ZOZO, Inc. 25

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

1 BLEでつながるまで ガット GATT は住所と郵便受け つながったあとにデータをやり取りする仕組み 相手のiPhone Peripheral 接続した直後はここまでしか分からない ↓ discoverServices([serviceUUID]) Service 建物の住所 このアプリはここだと場所を決める A1B2C3D4-…-0001 ↓ Characteristic discoverCharacteristics([messageUUID], for:) 郵便受け メッセージはここを通る A1B2C3D4-…-0002 上から順に降りていかないと 郵便受けには辿り着けない Copyright © ZOZO, Inc. 27

Slide 28

Slide 28 text

1 BLEでつながるまで 表札を確かめて 郵便受けに入れる Copyright © ZOZO, Inc. 28

Slide 29

Slide 29 text

1 BLEでつながるまで Service UUIDが住所 A7E0C9B8-1234-5678-9ABC-DEF012345678 Copyright © ZOZO, Inc. 29

Slide 30

Slide 30 text

1 BLEでつながるまで Characteristic UUIDが郵便受け B8F1D2A3-2345-6789-ABCD-EF0123456789 Copyright © ZOZO, Inc. 30

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

UUIDを2つ決めれば始められる Copyright © ZOZO, Inc. 32

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

1 BLEでつながるまで ログにも同じ順で出る ✅ DISC: Scanning started ✅ DISC: Found RelayChat: 893fbfc2 (-58dBm) ✅ CONN: Connected: D609F158 Copyright © ZOZO, Inc. 35

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

Chapter 2 対等に送り合う デュアルロールと 対等にした代償 Copyright © ZOZO, Inc. 37

Slide 38

Slide 38 text

チャットは対等 どっちがCentralで どっちがPeripheral? Copyright © ZOZO, Inc. 38

Slide 39

Slide 39 text

2 対等に送り合う よく出てくるのはiPhoneと心拍計 役割がはっきりしている データを持つのは機器の側 iPhone 心拍計 Central Peripheral 取りにいく側 Copyright © ZOZO, Inc. データを読む データを持つ側 39

Slide 40

Slide 40 text

2 対等に送り合う RelayChatはiPhoneとiPhone どっちも喋るし、どっちも聞く iPhone iPhone ? ? 役割が決められない Copyright © ZOZO, Inc. どっちも喋る/どっちも聞く 役割が決められない 40

Slide 41

Slide 41 text

2 対等に送り合う 片方だけか 両方ともか Copyright © ZOZO, Inc. 41

Slide 42

Slide 42 text

両方やる Copyright © ZOZO, Inc. 42

Slide 43

Slide 43 text

2 対等に送り合う デュアルロール 両方のマネージャを持つ 探しながら、同時に見つけてもらう iPhone A CBCentralManager iPhone B Scan CBCentralManager Advertise CBPeripheralManager Copyright © ZOZO, Inc. CBPeripheralManager 43

Slide 44

Slide 44 text

2 対等に送り合う 名札を掲げながら 虫めがねで探す Copyright © ZOZO, Inc. 44

Slide 45

Slide 45 text

2 対等に送り合う 初期化を2つ並べるだけ centralManager = CBCentralManager(delegate: self, queue: nil ) peripheralManager = CBPeripheralManager(delegate: self, queue: nil) つまずいたのは 送るところから Copyright © ZOZO, Inc. 45

Slide 46

Slide 46 text

2 対等に送り合う Write 相手の郵便受けに投函する 送り方その1 自分 投函する 相手の郵便受け Central Characteristic Copyright © ZOZO, Inc. 46

Slide 47

Slide 47 text

2 対等に送り合う Notify 自分の郵便受けを購読してもらう 送り方その2 中身を更新すると通知が飛ぶ 自分の郵便受け 更新すると通知 Copyright © ZOZO, Inc. 通知が飛ぶ あらかじめ購読してもらう 相手 Central 47

Slide 48

Slide 48 text

2 対等に送り合う 自分の郵便受けを覗いてもらう Copyright © ZOZO, Inc. 48

Slide 49

Slide 49 text

では 送ってみます Copyright © ZOZO, Inc. 49

Slide 50

Slide 50 text

2 対等に送り合う 自分がCentral側のとき peripheral.writeValue(frame, for: characteristic, type: .withResponse) Copyright © ZOZO, Inc. 50

Slide 51

Slide 51 text

2 対等に送り合う 自分がPeripheral側のとき peripheralManager.updateValue (frame, for: ch, onSubscribedCentrals: [central]) Copyright © ZOZO, Inc. 51

Slide 52

Slide 52 text

APIも引数も別物 送るたびに 役割を見て呼び分ける Copyright © ZOZO, Inc. 52

Slide 53

Slide 53 text

2 対等に送り合う Peerがこうなった struct Peer { let id: UUID var peripheral: CBPeripheral ? var central: CBCentral ? } Copyright © ZOZO, Inc. 53

Slide 54

Slide 54 text

2 対等に送り合う 両方をOptionalで持つ 相手が同じ1台でも、どっちの役割で繋がっているかで使う側が変わる peripheral central 相手がPeripheral役のとき使う 相手がCentral役のとき使う writeValue(...) updateValue(...) Copyright © ZOZO, Inc. 54

Slide 55

Slide 55 text

対等にするために 送信の実装が2つ残る Copyright © ZOZO, Inc. 55

Slide 56

Slide 56 text

2 対等に送り合う 同じ相手に同じUUIDが割り当てられる peripheral.identifier と central.identifier が同じ値 CBPeripheral A1B2 = 同じ相手なのだから iOS から見れば当然の話 CBCentral A1B2 Copyright © ZOZO, Inc. 56

Slide 57

Slide 57 text

1つのidに 2つの 役割が乗る デュアルロールにした瞬間に起きる Copyright © ZOZO, Inc. 57

Slide 58

Slide 58 text

2 対等に送り合う IDは1つなのに 役割が2つ Copyright © ZOZO, Inc. 58

Slide 59

Slide 59 text

2 対等に送り合う Setで持っていた 新しく繋がったらinsertする var connectedPeers: Set = [] connectedPeers. insert(Peer(id: id, peripheral: peripheral)) Copyright © ZOZO, Inc. 59

Slide 60

Slide 60 text

insertは何もしない 上書きもしない Copyright © ZOZO, Inc. 60

Slide 61

Slide 61 text

2 対等に送り合う 新しい参照が捨てられる 新しいPeer Set(既存) insert Peer A1B2 central のみ Peer A1B2 peripheral 入り CBPeripheral の参照も一緒に 消える ✕ 捨てられる Copyright © ZOZO, Inc. 61

Slide 62

Slide 62 text

2 対等に送り合う 手を離すと 掴み直せない Copyright © ZOZO, Inc. 62

Slide 63

Slide 63 text

未使用と判断される 誰も持っていないペリフェラルとして Copyright © ZOZO, Inc. 63

Slide 64

Slide 64 text

強制切断されて また見つかる iOSが「誰も持っていないペリフェラル」を切る 切断された回数 見つける › つなぐ › 参照が消える › 切られる ↺ 経過 1回 0:02 API MISUSE: Forcing disconnection of unused peripheral ✅ DISC: Found RelayChat: no name (-45dBm) ✅ CONN: Connected: C7B919BE Copyright © ZOZO, Inc. 64

Slide 65

Slide 65 text

これが延々と続く 切断と再接続だけで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

Slide 66

Slide 66 text

2 対等に送り合う 輪が閉じたまま 回り続ける Copyright © ZOZO, Inc. 66

Slide 67

Slide 67 text

ここから 直します Copyright © ZOZO, Inc. 67

Slide 68

Slide 68 text

2 対等に送り合う Setをやめて辞書にする // before var connectedPeers: Set = [] // after var peers: [UUID: Peer] = [:] Copyright © ZOZO, Inc. 68

Slide 69

Slide 69 text

2 対等に送り合う 箱ひとつか 引き出しごとか Copyright © ZOZO, Inc. 69

Slide 70

Slide 70 text

2 対等に送り合う 辞書なら上書きされる だから参照が捨てられない peers[id] = peer Copyright © ZOZO, Inc. // ← 必ず反映される 70

Slide 71

Slide 71 text

2 対等に送り合う 役割は上書きせず、足し込む 単純な上書きだと逆側の役割が消える peripheralが来たら centralが来たら peripheralだけ埋める centralはそのまま残す centralだけ埋める peripheralはそのまま残す Copyright © ZOZO, Inc. 71

Slide 72

Slide 72 text

2 対等に送り合う 切断も役割単位で外す 片方が切れただけで相手ごと消すと、何が失われるか ✕ 生きているほうの経路も消える ✕ そこに持っていた鍵も一緒に失う ✓ 切れた役割だけを外し、相手は残す 切断のたびに通信できなくなる しばらく原因がわからなかった Copyright © ZOZO, Inc. 72

Slide 73

Slide 73 text

実機でしか 確かめられないものがある Copyright © ZOZO, Inc. 73

Slide 74

Slide 74 text

2 対等に送り合う 判断を値型に切り出す CBPeripheralもCBCentralも、テストコードから作れない(イニシャライザが非公開) テストできない CBPeripheral / CBCentral 実機でしか確認できない領域 Copyright © ZOZO, Inc. テストが書ける PeerRoles 「片方が切れても相手を残すか」という判断だ け 74

Slide 75

Slide 75 text

Chapter 3 つないだ瞬間に相手が現れる 接続直後の自動ハンドシェイク Copyright © ZOZO, Inc. 75

Slide 76

Slide 76 text

つながっても 相手が誰か分からない 分かるのはUUIDだけ Copyright © ZOZO, Inc. 76

Slide 77

Slide 77 text

3 つないだ瞬間に相手が現れる 線はつながっているのに 中身が空 Copyright © ZOZO, Inc. 77

Slide 78

Slide 78 text

3 つないだ瞬間に相手が現れる つないだ直後に 名刺を交換する 接続成立をトリガーに ユーザー情報を送り合う 名前 画面に出す表示名 アイコン 絵文字1文字 公開鍵 このあとの暗号化に使う メッセージを送る前に これを済ませておく Copyright © ZOZO, Inc. 78

Slide 79

Slide 79 text

3 つないだ瞬間に相手が現れる 両方から同時に差し出す Copyright © ZOZO, Inc. 79

Slide 80

Slide 80 text

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

Slide 81

Slide 81 text

3 つないだ瞬間に相手が現れる Peripheral側 購読されたら すぐ送る func peripheralManager(_ pm: CBPeripheralManager, central: CBCentral, didSubscribeTo ch: CBCharacteristic) { subscribers.insert(central) send(myProfile(), to: central) } Copyright © ZOZO, Inc. 81

Slide 82

Slide 82 text

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

Slide 83

Slide 83 text

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

Slide 84

Slide 84 text

メッセージを送る前に 相手の名前が並んでいる Copyright © ZOZO, Inc. 84

Slide 85

Slide 85 text

Chapter 4 隣の隣まで届ける 中継とループの止め方 Copyright © ZOZO, Inc. 85

Slide 86

Slide 86 text

4 隣の隣まで届ける AからCには直接届かない A → C 届かない A B C 送りたい人 両方に届く Aからは見えない Aの電波が届く範囲 Copyright © ZOZO, Inc. 86

Slide 87

Slide 87 text

4 隣の隣まで届ける 預かって 渡す Copyright © ZOZO, Inc. 87

Slide 88

Slide 88 text

経路探索はしていない 最短経路の計算もしていない Copyright © ZOZO, Inc. 88

Slide 89

Slide 89 text

4 隣の隣まで届ける 全員にばら撒いて、止める仕組みで制御する やること 制御はどこで 受け取ったら全員に配り直す 受信側の止める仕組みで いわゆるフラッディング 止める仕組みは3つある Copyright © ZOZO, Inc. 89

Slide 90

Slide 90 text

4 隣の隣まで届ける ① TTL 3回で打ち切る Time To Live あと何回中継できるかの残り回数 ① TTL ② 既読メッセージID ③ 経路記録 Copyright © ZOZO, Inc. 何回中継したかを数えて、3回で打ち切る 90

Slide 91

Slide 91 text

4 隣の隣まで届ける スタンプが3つ溜まったら捨てる Copyright © ZOZO, Inc. 91

Slide 92

Slide 92 text

4 隣の隣まで届ける 3ホップより遠い人には届かない 本文が届くのは A から数えて4台目まで A 1 B 2 C 3 D 屋内 見通しがよければ 40〜50 m 100 m 超 届かない E その場にいる範囲で使う アプリとしての割り切り Copyright © ZOZO, Inc. 92

Slide 93

Slide 93 text

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

Slide 94

Slide 94 text

4 隣の隣まで届ける ノートにあれば捨てる Copyright © ZOZO, Inc. 94

Slide 95

Slide 95 text

4 隣の隣まで届ける ③ 経路記録 どこを通ってきたか ① TTL ② 既読メッセージID ③ 経路記録 Copyright © ZOZO, Inc. 通ってきた端末をメッセージに書き込む 95

Slide 96

Slide 96 text

4 隣の隣まで届ける コードだとこの2つ var hops: Int = 0 // 中継回数 var route: [String] = [] // 通ってきた経路 Copyright © ZOZO, Inc. 96

Slide 97

Slide 97 text

4 隣の隣まで届ける Aが発信する A B C 発信 待機 Aからは見えない hops: 0 Copyright © ZOZO, Inc. route: [A] 97

Slide 98

Slide 98 text

4 隣の隣まで届ける Bが受け取る A B C 送信済み 受信 まだ知らない hops: 1 Copyright © ZOZO, Inc. route: [A, B] 98

Slide 99

Slide 99 text

4 隣の隣まで届ける 自分宛じゃなくてもとりあえず隣に配る A B C 送信済み 配り直す 受信待ち hops: 1 → 2 Copyright © ZOZO, Inc. 送信先: 受け取った相手を除く全員 99

Slide 100

Slide 100 text

4 隣の隣まで届ける Cに届いた A B C 送信元 中継済み 届いた hops: 2 Copyright © ZOZO, Inc. route: [A, B, C] 100

Slide 101

Slide 101 text

4 隣の隣まで届ける hop 2/3 2回目の中継で、上限は3回 🔧 MSG: Relaying user_message to 1 peer(s) (hop 2/3) Copyright © ZOZO, Inc. 101

Slide 102

Slide 102 text

3つめは経路記録 効いていなかった Copyright © ZOZO, Inc. 102

Slide 103

Slide 103 text

4 隣の隣まで届ける route(経由してきた経路)に積んでいたもの // 端末に永続保存している自分のID(36文字) 6709210f-3d3c-4a1e-9c22-8b41f0c5d7a3 Copyright © ZOZO, Inc. 103

Slide 104

Slide 104 text

4 隣の隣まで届ける 比べていたもの CoreBluetoothが振ってくるUUIDはまったく別の世界のID // CoreBluetoothが接続ごとに振るUUID D609F158-2b7c-41aa-b0f3-5e8d9a2c4471 // 同じ String 型なので、コンパイルは通る Copyright © ZOZO, Inc. 104

Slide 105

Slide 105 text

一生マッチしない Copyright © ZOZO, Inc. 105

Slide 106

Slide 106 text

それでも動いていた たまたま止まっていただけ 3つのうち1つが完全に効いていなくても気づけなかった Copyright © ZOZO, Inc. 106

Slide 107

Slide 107 text

そもそも route を 見る必要がなかった Copyright © ZOZO, Inc. 107

Slide 108

Slide 108 text

4 隣の隣まで届ける 来た道に戻さないだけなら 1行で済む 受け取った相手を除外すればいいだけ send(msg, excludingPeerId: senderId) Copyright © ZOZO, Inc. // これだけで足りる 108

Slide 109

Slide 109 text

4 隣の隣まで届ける route が役に立つのはこっち 自分がすでに中継したメッセージかの判定 ① 中継するときのrouteに自分のIDを積む ② 受け取ったときのrouteに自分がいたら捨てる ③ 積むIDと比べるIDが同じID空間になる Copyright © ZOZO, Inc. 109

Slide 110

Slide 110 text

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

Slide 111

Slide 111 text

台数が増えると 詰まる Copyright © ZOZO, Inc. 111

Slide 112

Slide 112 text

4 隣の隣まで届ける 流しているのは本文だけじゃない メッセージは3種類ある 本文 ユーザーが打ったメッセージ 参加通知 新しく入ってきた人を知らせる 入ってきたときだけ 生存確認 まだ近くにいるかを確かめる 10秒ごとに ずっと 打ったときだけ 生存確認だけは 誰も打っていなくても飛び続ける Copyright © ZOZO, Inc. 112

Slide 113

Slide 113 text

4 隣の隣まで届ける 原因は10秒ごとの生存確認 これも本文と同じ3ホップまで中継していた 10秒ごとに飛ぶ 生存確認は全員に撒き続ける 3ホップまで中継 受け取った全員が さらに撒き直す 台数の二乗 10台で 10秒ごとに数百通 Copyright © ZOZO, Inc. 113

Slide 114

Slide 114 text

4 隣の隣まで届ける TTLは種別ごとに分ける 種別ごとに 届く範囲を変える 生存確認 1 ホップ 中継しない 直接つながっている 相手だけに届く Copyright © ZOZO, Inc. 参加通知 本文 2 ホップ 3 ホップ 隣の隣まで 3台先まで 1回だけ中継される ここだけ遠くまで運ぶ 114

Slide 115

Slide 115 text

Chapter 5 バイト数の上限に収める 31バイトとMTU Copyright © ZOZO, Inc. 115

Slide 116

Slide 116 text

バイト数の壁は2種類 壁① 壁② アドバタイズ 本文 名札に書ける量は31バイトしかない 1回で運べる量は接続ごとに変わる Copyright © ZOZO, Inc. 116

Slide 117

Slide 117 text

5 バイト数の上限に収める / 壁① アドバタイズ 名札に書けるのは31バイト アドバタイズパケットの上限 31 バイト ここに全部を詰め込む Copyright © ZOZO, Inc. 117

Slide 118

Slide 118 text

5 バイト数の上限に収める / 壁① アドバタイズ 名札が小さすぎる Copyright © ZOZO, Inc. 118

Slide 119

Slide 119 text

5 バイト数の上限に収める / 壁① アドバタイズ まずフラグに3バイト 残り 28バイト 3B 残り 28バイト ■ Flags Copyright © ZOZO, Inc. 119

Slide 120

Slide 120 text

5 バイト数の上限に収める / 壁① アドバタイズ サービスUUIDで18バイト 128ビットのUUID 残り 10バイト Service UUID 18B 3B ■ Flags ■ Service UUID Copyright © ZOZO, Inc. 10B ■ 残り 120

Slide 121

Slide 121 text

5 バイト数の上限に収める / 壁① アドバタイズ 名前に使えるのは実質8文字 残り10バイトからヘッダを引いた分 Service UUID 18B 3B ■ Flags ■ Service UUID Copyright © ZOZO, Inc. ■ ヘッダ hdr 名前 8文字 ■ 名前 121

Slide 122

Slide 122 text

5 バイト数の上限に収める / 壁① アドバタイズ 残っているのは右端の細い区画 Copyright © ZOZO, Inc. 122

Slide 123

Slide 123 text

5 バイト数の上限に収める / 壁① アドバタイズ 載せていた名前 RelayChat-6709210f 18文字 / 上限は8文字 入らない Copyright © ZOZO, Inc. 123

Slide 124

Slide 124 text

5 バイト数の上限に収める / 壁① アドバタイズ no name ✅ DISC: Found RelayChat: no name (-52dBm) Copyright © ZOZO, Inc. 124

Slide 125

Slide 125 text

5 バイト数の上限に収める / 壁① アドバタイズ 切り詰められるのではなく名前ごと落ちる 入りきらないときの挙動 ✕ 8文字に切られるのではない 名前そのものが消える Copyright © ZOZO, Inc. 125

Slide 126

Slide 126 text

5 バイト数の上限に収める / 壁① アドバタイズ 切り詰められるのではなく名前ごと落ちる 入りきらないときの挙動 ✕ 8文字に切られるのではない 名前そのものが消える ✕ この名前は二重接続を防ぐ目印に使っていた Copyright © ZOZO, Inc. 126

Slide 127

Slide 127 text

5 バイト数の上限に収める / 壁① アドバタイズ 切り詰められるのではなく名前ごと落ちる 入りきらないときの挙動 ✕ 8文字に切られるのではない 名前そのものが消える ✕ この名前は二重接続を防ぐ目印に使っていた ✕ その判定がまるごと動いていなかった Copyright © ZOZO, Inc. 127

Slide 128

Slide 128 text

5 バイト数の上限に収める / 壁② 本文 上限を取るAPIは呼んでいた この「1回で運べる上限」を MTU と呼ぶ let mtu = peripheral.maximumWriteValueLength(for: .withResponse) print("MTU: \(mtu)") Copyright © ZOZO, Inc. 128

Slide 129

Slide 129 text

取った上限と 送る長さを比べていなかった ログに出して そのまま渡していた Copyright © ZOZO, Inc. 129

Slide 130

Slide 130 text

超えた分はエラーにならない 黙って消える iPhone同士だと上限が大きめなので短い文では気づけなかった Copyright © ZOZO, Inc. 130

Slide 131

Slide 131 text

5 バイト数の上限に収める / 壁② 本文 ちぎれて落ちるだけ Copyright © ZOZO, Inc. 131

Slide 132

Slide 132 text

2つの壁を 直します Copyright © ZOZO, Inc. 132

Slide 133

Slide 133 text

5 バイト数の上限に収める / 壁① アドバタイズ 接頭辞を捨てて8文字だけにする before RelayChat-6709210f after 6709210f これでパケットに収まる Copyright © ZOZO, Inc. 133

Slide 134

Slide 134 text

本文は分割するしかない Copyright © ZOZO, Inc. 134

Slide 135

Slide 135 text

5 バイト数の上限に収める / 壁② 本文 切り分けて 送って 貼り合わせる Copyright © ZOZO, Inc. 135

Slide 136

Slide 136 text

5 バイト数の上限に収める / 壁② 本文 先頭に9バイトのヘッダをつける 受け取った側が 何番目の何枚中かを知るため 種別 転送ID 連番 総数 1B 4B 2B 2B 本文の断片 ヘッダは合計 9バイト Copyright © ZOZO, Inc. 136

Slide 137

Slide 137 text

5 バイト数の上限に収める / 壁② 本文 上限のサイズに切って 番号順に戻す さっき取ってきたMTUが ここで効いてくる 1/3 1351B の本文 2/3 全文に戻る 3/3 Copyright © ZOZO, Inc. 137

Slide 138

Slide 138 text

5 バイト数の上限に収める / 壁② 本文 1351バイト / 1フレーム503バイト → 3分割 ℹ MSG: Sending user_message to 2 peer(s) (1351B) Copyright © ZOZO, Inc. 138

Slide 139

Slide 139 text

受け取った側に全文が出た 分割して送って組み立て直した結果 Copyright © ZOZO, Inc. 139

Slide 140

Slide 140 text

Chapter 6 鍵を配る 中央がない世界の暗号 Copyright © ZOZO, Inc. 140

Slide 141

Slide 141 text

6 鍵を配る やりたかったのはエンドツーエンド暗号化 送った人と受け取った人だけが読める状態 読める 送った人と 宛先の人 読めない あいだを中継した端末 Copyright © ZOZO, Inc. 141

Slide 142

Slide 142 text

6 鍵を配る 全端末で同じ鍵 let keyString = "RelayChatSessionKey2025" Copyright © ZOZO, Inc. 142

Slide 143

Slide 143 text

6 鍵を配る 全員に同じ鍵を配っていた Copyright © ZOZO, Inc. 143

Slide 144

Slide 144 text

暗号化しているつもりで 鍵を全員に配っていた バイナリから抜けば周囲のRelayChatの通信が全部読める Copyright © ZOZO, Inc. 144

Slide 145

Slide 145 text

6 鍵を配る まず隣同士で鍵を作る お互いに公開鍵を渡し合って、共通の鍵を導出する(ECDH) A 公開鍵を交換 B 共通のリンク鍵 秘密鍵 a / 公開鍵 A Copyright © ZOZO, Inc. CryptoKitで数行 秘密鍵 b / 公開鍵 B 145

Slide 146

Slide 146 text

通りすがりに電波を拾っただけの人には 読めなくなる Copyright © ZOZO, Inc. 146

Slide 147

Slide 147 text

6 鍵を配る 中継する端末は必ず中身を開けられる 開けないと次に配れないから Copyright © ZOZO, Inc. A B C 送信元 中身が読める 宛先 147

Slide 148

Slide 148 text

6 鍵を配る 宛先とのあいだでもう一枚封をする 中継する端末は外側しか開けられない 外側の封 隣同士の鍵で封をする 内側の封 宛先の人とだけ作った鍵で封をする 本文 Copyright © ZOZO, Inc. 148

Slide 149

Slide 149 text

6 鍵を配る 外側だけ開けられる封 Copyright © ZOZO, Inc. 149

Slide 150

Slide 150 text

6 鍵を配る 2枚の封 どこまで守るかが違う 開けられる範囲が違う 外側の封 内側の封 1ホップぶん 宛先まで 隣同士で作ったリンク鍵で封をする 全メッセー 宛先の人とだけ作った鍵で封をする 個別メッセ ジが対象 ージだけが対象 Copyright © ZOZO, Inc. 150

Slide 151

Slide 151 text

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

Slide 152

Slide 152 text

6 鍵を配る たたんで 入れて 封をする Copyright © ZOZO, Inc. 152

Slide 153

Slide 153 text

6 鍵を配る なぜ この順番なのか 圧縮 → 暗号化 日本語のメッセージがよく縮む 暗号化 → 圧縮 暗号文はランダムなので まったく縮まない MTUに収めるために 縮めるのが先 Copyright © ZOZO, Inc. 153

Slide 154

Slide 154 text

6 鍵を配る 封をしてからでは 縮まない Copyright © ZOZO, Inc. 154

Slide 155

Slide 155 text

Chapter 7 アプリが落ちても切れない State Restoration ── 最大の難所 Copyright © ZOZO, Inc. 155

Slide 156

Slide 156 text

7 アプリが落ちても切れない つないだ状態を預けて 起きたら返してもらう Copyright © ZOZO, Inc. 156

Slide 157

Slide 157 text

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

Slide 158

Slide 158 text

これで安心だと思った Copyright © ZOZO, Inc. 158

Slide 159

Slide 159 text

バックグラウンドから戻ったら メッセージが届かなくなっていた Copyright © ZOZO, Inc. 159

Slide 160

Slide 160 text

7 アプリが落ちても切れない バックグラウンドから復帰すると 2つ呼ばれる State Restoration = 接続状態をシステムに預けて 復帰時に返してもらう仕組み willRestoreState 預けておいた接続の状態が返ってくる poweredOn Bluetoothが使えるようになった合図 問題は 呼ばれる順番だった Copyright © ZOZO, Inc. 160

Slide 161

Slide 161 text

7 アプリが落ちても切れない willRestoreState は poweredOn より先に来る Bluetoothが使える状態になる前に呼ばれる willRestoreState 先に来る Copyright © ZOZO, Inc. state == .poweredOn あとで来る この順番を知らなかっ た 161

Slide 162

Slide 162 text

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

Slide 163

Slide 163 text

7 アプリが落ちても切れない 電源が入った状態でしか受け付けない API MISUSE: can only accept this command while in the powered on state Copyright © ZOZO, Inc. 163

Slide 164

Slide 164 text

7 アプリが落ちても切れない 連鎖して鍵が作れなくなる 弾かれる discoverが通らない 参照が取れない › 郵便受けがない 渡せない › 公開鍵を渡せない 鍵が作れない › 開けられない つながっているのに、開けられない Copyright © ZOZO, Inc. 164

Slide 165

Slide 165 text

[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

Slide 166

Slide 166 text

5分間 完全に無反応 直ったのは古い接続が自然に切れて張り直されたとき Copyright © ZOZO, Inc. 166

Slide 167

Slide 167 text

この鍵は どこから来ているのか Copyright © ZOZO, Inc. 167

Slide 168

Slide 168 text

7 アプリが落ちても切れない Keychainの鍵から計算している 両方ともアプリを消さないかぎり変わらない Aの永続鍵 Keychain Copyright © ZOZO, Inc. + Bの永続鍵 = リンク鍵 Keychain 168

Slide 169

Slide 169 text

この鍵は何度計算しても 同じ値になる Copyright © ZOZO, Inc. 169

Slide 170

Slide 170 text

7 アプリが落ちても切れない 同じ型から 同じ鍵ができる Copyright © ZOZO, Inc. 170

Slide 171

Slide 171 text

鍵そのものを守る必要はない 公開鍵をもらい直せば元に戻れる Copyright © ZOZO, Inc. 171

Slide 172

Slide 172 text

7 アプリが落ちても切れない 鍵交換を「受け取ったら返す」形にする どちら側からでも始められて、どちら側からでも復旧できる 公開鍵 A 受け取ったら返す B 必ず2通で収束する 延々と返し合わないよう「直前に送っていたら見送る」歯止めを入れ、その判断だけ値型に切り出してテスト した(2章と同じやり方) Copyright © ZOZO, Inc. 172

Slide 173

Slide 173 text

7 アプリが落ちても切れない 鍵交換に上下がなくなる Copyright © ZOZO, Inc. 173

Slide 174

Slide 174 text

7 アプリが落ちても切れない 5分の沈黙が消えた ℹ BT: Resuming 3 restored peripheral(s) ✅ CRYPTO: 🔐 Link key established with cd7a673e Copyright © ZOZO, Inc. 174

Slide 175

Slide 175 text

BLEそのものは難しくない UUIDを2つ決めて順番にメソッドを呼べばつながる Copyright © ZOZO, Inc. 175

Slide 176

Slide 176 text

難しかったのは 「対等」を作ること Copyright © ZOZO, Inc. 176

Slide 177

Slide 177 text

今日話したこと どれも「対等」にしたことから生まれている 1 BLEでつながるまで UUIDを2つ決めて順番に呼ぶ 2 対等に送り合う 役割が2つ乗るから同じIDが衝突する 3 つないだ瞬間に相手が現れる 接続直後に名刺を交換する 4 隣の隣まで届ける 全員に配るから経路を記録する 5 バイト数の上限に収める 31バイトとMTUに合わせて切る 6 鍵を配る 中央がないから隣同士で作る 7 アプリが落ちても切れない 預けて 起きたら順番に気をつける Copyright © ZOZO, Inc. 177

Slide 178

Slide 178 text

もうひとつ 話しておきたいこと Copyright © ZOZO, Inc. 178

Slide 179

Slide 179 text

実機との付き合い方 全部は無理でも 壊れやすいところだけはテストできる 型は作れない 判断は切り出せる CBPeripheral / CBCentral 「片方が切れたら相手を残すか」 「もう一度鍵 イニシャライザが非公開なので、実機で確かめ を送るか」 るしかない 値型にすればテストが書ける 今日話した失敗は 全部その判断ミス Copyright © ZOZO, Inc. 179

Slide 180

Slide 180 text

今この場で入れれば隣の人とつながる RelayChat App Store で配信中 ● 機内モードのままでも動く ● 登録不要・完全匿名 ● 質問・感想は @tsuzuki817 まで Copyright © ZOZO, Inc. 180

Slide 181

Slide 181 text

ありがとうございました 續橋 涼 @tsuzuki817 / 株式会社ZOZO Copyright © ZOZO, Inc.

Slide 182

Slide 182 text

Appendix 補足資料 質疑用 Copyright © ZOZO, Inc. 182

Slide 183

Slide 183 text

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

Slide 184

Slide 184 text

Appendix A-2 なぜCoreBluetoothなのか 事前ペアリング/見知らぬ相手/多段中継/バックグラウンド/帯域 事前ペアリング 見知らぬ相手 多段中継 バックグラウンド 帯域 CoreBluetooth(本作) 不要 ○ 自前実装 ○ 小 MultipeerConnectivity 不要 ○ 自動(最大8台) △制限あり 大 Wi-Fi Aware(iOS 26) 必要 × × ○ 大 Bluetooth Mesh (SIG標準) 事前設定が必要 × ○ — 小 NearbyInteraction 別に探索が必要 △ × △ 測距のみ Copyright © ZOZO, Inc. 184

Slide 185

Slide 185 text

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

Slide 186

Slide 186 text

Appendix A-4 守れるもの と 守れないもの その場の雑談アプリとして どこまでを守る設計にしたか 攻撃者 防げる 理由 電波を傍受するだけの第三者 ○ 外側の封(リンク鍵) 中継端末が本文を読む(個別メッセージ) ○ 内側の封 中継端末が本文を読む(全体メッセージ) × 内側の封をかけていない 中継端末による公開鍵のすり替え × 公開鍵に署名・検証がない 永続鍵が漏れたあとの過去ログの復号 × 前方秘匿性がない 他人のIDを名乗るなりすまし × 認証がない Copyright © ZOZO, Inc. 186

Slide 187

Slide 187 text

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

Slide 188

Slide 188 text

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

Slide 189

Slide 189 text

Appendix B-1 フレームフォーマット 種別 転送ID 連番 総数 1B 4B 2B 2B ペイロード 0x01 プロフィール 0x04 生存確認 0x02 本文 0x05 鍵交換 0x03 参加通知 Copyright © ZOZO, Inc. 189

Slide 190

Slide 190 text

Appendix B-2 種別ごとのパラメータ 種別 TTL 送信契機 暗号化 本文 3ホップ ユーザー入力時 外側+内側 参加通知 2ホップ 新規参加時 外側 生存確認 中継なし 10秒ごと 外側 鍵交換 中継なし 接続直後・受信時 なし(公開鍵) Copyright © ZOZO, Inc. 190

Slide 191

Slide 191 text

Appendix B-3 断片が欠けたらどうなるか 3枚中2枚しか来なかったら バッファの持ち方 転送IDごとに断片を溜める タイムアウト 一定時間そろわなければ捨てる 再送 なし 次の発言で送り直してもらう メモリ対策 タイムアウトしたバッファは破棄する Copyright © ZOZO, Inc. 191

Slide 192

Slide 192 text

Appendix B-4 バックグラウンドの制約 名前が載らない CBAdvertisementDataLocalNameKey がアドバタイズに含まれない UUIDがオーバーフロー領域へ 同じくバックグラウンドの端末からは見つけにくくなる 重複が間引かれる AllowDuplicatesKey が効かない 発見が遅くなる スキャン間隔が延びる Info.plist bluetooth-central と bluetooth-peripheral の両方が必要 Copyright © ZOZO, Inc. 192

Slide 193

Slide 193 text

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

Slide 194

Slide 194 text

Appendix B-6 実機デバッグの道具 PacketLogger HCIレベルのログを取る Additional Tools for Xcode に同梱 Bluetooth Explorer 同じく Additional Tools for Xcode に同梱 Console.app 複数台のログを同時に見る シミュレータ CoreBluetooth は動かない 実機しかない Copyright © ZOZO, Inc. 194

Slide 195

Slide 195 text

Appendix B-7 匿名性の限界 永続IDは固定 アプリを消さないかぎり同じ値のまま 追跡できる 同じ場に何度も現れると同一人物として辿れる 名前は自由 表示名は毎回変えられる IDとは別 登録不要ではあるが 完全な匿名ではない Copyright © ZOZO, Inc. 195