Skip to content

Commit 6cf8e5c

Browse files
committed
fix: make BLE HELLO write-once and resolve duplicate links on Android (#29, #52)
The BLE link layer is unauthenticated, and HELLO was accepted at any time on both platforms, mutating an already-announced link's identity: - iOS (#29): handleHello set remoteTag and re-ran resolveDuplicateLinks unconditionally. An attacker holding a link to a victim could send a second HELLO claiming the victim's tag; the resolver's tie-break then tore the victim's real link down — a targeted, persistent link-starve. - Android (#29): remoteTag was assigned *above* the announced guard, so a re-HELLO silently re-labelled a live link and the attacker's later payloads surfaced to JS under a different peer's id (attribution spoof). Both now ignore HELLO once the link is announced: identity is written exactly once, and a stranger cannot re-label or kill a live link. Android also gained the duplicate-link resolver iOS already had (#52): the same lexicographic tie-break, adapted to Android's remoteTag id model (keeper announced before the loser is dropped, so dropLink's existing same-tag guard suppresses a spurious onDisconnected). The ble-mesh README now documents write-once HELLO and cross-platform duplicate resolution to match the code. iOS compiles clean. Kotlin reviewed by hand; needs a CI/maintainer compile and the two-phone validation tracked in #21.
1 parent be4fccd commit 6cf8e5c

3 files changed

Lines changed: 46 additions & 3 deletions

File tree

modules/ble-mesh/README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,14 @@ hold, or they discard opposite links and end up with none:
211211
> the device with the lexicographically **smaller** tag keeps the link it
212212
> **dialled out** on.
213213
214-
On the other phone that same wire is the inbound one, so both keep it.
214+
On the other phone that same wire is the inbound one, so both keep it. Both
215+
platforms run this resolver (`resolveDuplicateLinks` on iOS,
216+
`resolveDuplicateLink` on Android).
217+
218+
`HELLO` is accepted **once per link**: a second one on an already-announced link
219+
is ignored, never re-labelled. The wire is unauthenticated, so otherwise a
220+
stranger could claim a peer's tag to spoof payload attribution, or collide tags
221+
to make the resolver tear a victim's real link down.
215222

216223
A link that has not sent `HELLO` within 10 s is dropped. Android will only give
217224
you a handful of concurrent GATT connections — typically around seven, after which

modules/ble-mesh/android/src/main/java/expo/modules/blemesh/BleMeshModule.kt

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1058,13 +1058,23 @@ class BleMeshModule : Module() {
10581058
* advertised tag at all still resolves normally here.
10591059
*/
10601060
private fun handleHello(link: Link, tag: ByteArray) {
1061+
// HELLO is write-once. The tag was assigned above this guard before, so a
1062+
// re-HELLO on the unauthenticated wire re-attributed a live link (#29).
1063+
if (link.announced) return
10611064
if (tag.size != TAG_BYTES) {
10621065
dropLink(link.peerId, announce = false)
10631066
return
10641067
}
10651068
link.remoteTag = hex(tag)
10661069

1067-
if (link.announced) return
1070+
// Keep one link when both directions came up, same tie-break as iOS (#52).
1071+
val loser = resolveDuplicateLink(link)
1072+
if (loser == link.peerId) {
1073+
// Peer keeps the other direction; retire this one before it announced.
1074+
dropLink(link.peerId, announce = false)
1075+
return
1076+
}
1077+
10681078
link.announced = true
10691079
// A link that reached HELLO is proof the peer is dialable, so the backoff
10701080
// ladder for it starts from scratch next time.
@@ -1081,6 +1091,30 @@ class BleMeshModule : Module() {
10811091
"isIncoming" to link.isIncoming
10821092
)
10831093
)
1094+
1095+
// Drop the duplicate after announcing, so dropLink's same-tag guard sees a
1096+
// live link and stays quiet — JS keeps one peer, no drop/re-add.
1097+
if (loser != null) dropLink(loser, announce = true)
1098+
}
1099+
1100+
/**
1101+
* The peerId of the link to discard when [link] duplicates one to the same
1102+
* peer, else null. Same tie-break as the iOS resolver, computed identically
1103+
* on both phones: smaller tag keeps the link it dialled out on.
1104+
*/
1105+
private fun resolveDuplicateLink(link: Link): String? {
1106+
val tag = link.remoteTag ?: return null
1107+
val other = links.values.firstOrNull {
1108+
it.peerId != link.peerId && it.remoteTag == tag
1109+
} ?: return null
1110+
1111+
val weAreSmaller = hex(localTag) < tag
1112+
val keeper = if (weAreSmaller) {
1113+
if (link.isIncoming) other else link
1114+
} else {
1115+
if (link.isIncoming) link else other
1116+
}
1117+
return if (keeper.peerId == link.peerId) other.peerId else link.peerId
10841118
}
10851119

10861120
// -------------------------------------------------------------------------

modules/ble-mesh/ios/BleMeshModule.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,9 @@ private final class BleMeshRadio: NSObject {
791791
/// is in-band, full width, and symmetric, so a link that connects with no
792792
/// advertised tag at all still resolves normally here.
793793
private func handleHello(link: Link, tag: Data) {
794+
// HELLO is write-once. The wire is unauthenticated, so a HELLO on an
795+
// announced link is a stranger trying to re-label or starve it (#29).
796+
guard !link.announced else { return }
794797
guard tag.count == Wire.tagBytes else {
795798
dropLink(link.peerId, announce: false)
796799
return
@@ -802,7 +805,6 @@ private final class BleMeshRadio: NSObject {
802805
if loser == link.peerId { return }
803806
}
804807

805-
guard !link.announced else { return }
806808
link.announced = true
807809
emit("onConnected", [
808810
"id": link.peerId,

0 commit comments

Comments
 (0)