fix(ble): Stop foreign same-model pods from hijacking a device's identity

This commit is contained in:
darken
2026-06-15 09:16:51 +02:00
committed by Matthias Urhahn
parent fd4a43de96
commit 1ed1199574
3 changed files with 124 additions and 4 deletions
@@ -16,6 +16,12 @@ data class KnownDevice(
val seenCounter: Int,
val history: List<ApplePods>,
val lastCaseBattery: Float?,
/**
* Profile id this device has ever IRK-resolved to, or null if it has only ever been seen as a
* keyless device. Durable — kept even when [history] is trimmed — so the weak history-recovery
* paths can reliably refuse to hand an IRK-backed identity to a foreign same-model frame.
*/
val boundProfileId: String? = null,
) {
val lastPayload: ProximityPayload
get() = history.last().payload
@@ -76,7 +76,8 @@ class PodHistoryRepo @Inject constructor(
val caseIgnored = if (current is DualApplePods) {
val target = current.getCaseMatchMarkings()
knownDevices.values.filter { known ->
known.history.filterIsInstance<DualApplePods>().any { it.getCaseMatchMarkings() == target }
known.history.filterIsInstance<DualApplePods>().any { it.getCaseMatchMarkings() == target } &&
known.isIrkConsistentWith(current)
}
} else {
emptySet()
@@ -143,7 +144,7 @@ class PodHistoryRepo @Inject constructor(
if (recognizedDevice == null) {
val currentMarkers = payload.getFuzzyIdentifier()
recognizedDevice = knownDevices.values
.firstOrNull { it.lastPayload.getFuzzyIdentifier() == currentMarkers }
.firstOrNull { it.lastPayload.getFuzzyIdentifier() == currentMarkers && it.isIrkConsistentWith(current) }
?.also { log(TAG, DEBUG) { "search1: Similarity match for device=${current.model}" } }
}
@@ -154,6 +155,24 @@ class PodHistoryRepo @Inject constructor(
return recognizedDevice
}
/**
* The weak history paths (fuzzy markers, case-ignored markings) match on low-entropy broadcast
* data — model + battery nibbles + colour — which a stranger's same-model AirPods at a similar
* battery satisfies. Those paths must not let a foreign frame inherit the identity of one of OUR
* keyed (IRK-backed) devices, or the foreign snapshot poisons that device's cache slot and the
* dashboard card glitches (Symptom B).
*
* A [KnownDevice] is "IRK-backed" if it has ever resolved against a profile's IRK
* ([KnownDevice.boundProfileId], latched durably so history trimming can't erode it). Such a
* device may only be claimed via the strong address/IRK paths, or by a current frame that
* resolved to the SAME profile. Keyless devices (never IRK-backed) are unaffected — fuzzy
* recovery still works for them exactly as before.
*/
private fun KnownDevice.isIrkConsistentWith(current: ApplePods): Boolean {
val bound = boundProfileId ?: return true // not IRK-backed -> keyless, weak match allowed
return current.meta.profile?.id == bound
}
fun updateHistory(device: ApplePods) {
val existing = knownDevices[device.identifier]
@@ -162,12 +181,16 @@ class PodHistoryRepo @Inject constructor(
.mapNotNull { it.batteryCasePercent }
.lastOrNull()
// Latch the IRK-resolved profile id (never cleared by an unbound frame), so it survives history trimming.
val boundProfileId = device.meta.profile?.id?.takeIf { device.meta.isIRKMatch }
knownDevices[device.identifier] = if (existing != null) {
val history = existing.history.plus(device)
existing.copy(
seenCounter = existing.seenCounter + 1,
history = history,
lastCaseBattery = history.determineLatestCaseBattery() ?: existing.lastCaseBattery
lastCaseBattery = history.determineLatestCaseBattery() ?: existing.lastCaseBattery,
boundProfileId = boundProfileId ?: existing.boundProfileId,
)
} else {
log(TAG, DEBUG) { "Creating new history for ${device.logSummary()}" }
@@ -177,7 +200,8 @@ class PodHistoryRepo @Inject constructor(
seenFirstAt = device.seenFirstAt,
seenCounter = 1,
history = history,
lastCaseBattery = history.determineLatestCaseBattery()
lastCaseBattery = history.determineLatestCaseBattery(),
boundProfileId = boundProfileId,
)
}
}
@@ -0,0 +1,90 @@
package eu.darken.capod.pods.core.apple.ble.history
import eu.darken.capod.common.fromHex
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.devices.BaseBlePodsTest
import eu.darken.capod.pods.core.apple.ble.devices.airpods.AirPodsPro2Usbc
import eu.darken.capod.profiles.core.AppleDeviceProfile
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
/**
* Reproduces Symptom B (the dashboard-card glitch): a FOREIGN same-model AirPods, whose BLE address
* does not resolve against our IRK, gets recovered as OUR device's stable identity purely via the
* fuzzy/case-ignored history fallback ([PodHistoryRepo.baseSearch]). The foreign snapshot carries
* `meta.profile == null` (IRK failed) but adopts our identifier, so it overwrites our BLE cache slot
* keyed by that identifier — and the merged device flaps to `ble = null` (cached-only) until our next
* real frame re-binds.
*
* The two frames use the SAME advertisement payload (so the low-entropy fuzzy markers — model bits,
* pod/case battery nibbles, colour — are identical) but DIFFERENT BLE addresses, mimicking another
* person's same-model AirPods at a similar battery level in a crowded environment.
*/
class PodHistoryFuzzyCollisionTest : BaseBlePodsTest() {
// IRK + a resolvable RPA pair lifted from RPACheckerTest.
private val irkHex = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-79"
private val myRpa = "5A:16:2B:91:D1:CD" // resolves against irkHex
private val foreignAddress = "77:49:4C:D8:25:0C" // does NOT resolve against irkHex
// A valid AirPods Pro 2 (USB-C) proximity advertisement (model 0x2420), reused for both frames.
private val payload = "07 19 01 24 20 0B 99 8F 11 00 04 BD A7 3B FF 2D 8A 3C AF 9B 1A 7C 74 B7 A9 D1 C3"
@Test
fun `foreign same-model frame must not adopt a keyed device's identity`() = runTest {
profileList.add(
AppleDeviceProfile(
label = "Mine",
model = PodModel.AIRPODS_PRO2_USBC,
identityKey = irkHex.fromHex(),
address = "AA:BB:CC:DD:EE:FF",
)
)
// 1) Our own frame: address resolves against our IRK -> IRK-bound, registers history.
lateinit var mine: AirPodsPro2Usbc
create<AirPodsPro2Usbc>(payload, address = myRpa) { mine = this }
mine.meta.isIRKMatch shouldBe true
mine.meta.profile shouldNotBe null
// 2) A foreign device's frame: same payload (identical fuzzy markers), different address.
lateinit var foreign: AirPodsPro2Usbc
create<AirPodsPro2Usbc>(payload, address = foreignAddress) { foreign = this }
// The foreign frame is correctly NOT IRK-bound...
foreign.meta.isIRKMatch shouldBe false
foreign.meta.profile shouldBe null
// ...and therefore must NOT inherit our stable identity. Before the gate this FAILED: the
// case-ignored/fuzzy fallback recovered our KnownDevice for the foreign frame, so it adopted
// our identifier and poisoned our cache slot -> the merged device glitched to ble=null.
foreign.identifier shouldNotBe mine.identifier
}
@Test
fun `our own keyed device keeps its identity across an RPA rotation`() = runTest {
// Guard: narrowing the weak paths must not fragment our own identity — across an RPA
// rotation our device is still recovered via the strong IRK path.
profileList.add(
AppleDeviceProfile(
label = "Mine",
model = PodModel.AIRPODS_PRO2_USBC,
identityKey = irkHex.fromHex(),
address = "AA:BB:CC:DD:EE:FF",
)
)
val myRotatedRpa = "45:23:51:E3:40:6E" // also resolves against irkHex (computed)
lateinit var first: AirPodsPro2Usbc
create<AirPodsPro2Usbc>(payload, address = myRpa) { first = this }
first.meta.isIRKMatch shouldBe true
lateinit var second: AirPodsPro2Usbc
create<AirPodsPro2Usbc>(payload, address = myRotatedRpa) { second = this }
second.meta.isIRKMatch shouldBe true
// Same physical device across rotation -> same stable identity (recovered via IRK).
second.identifier shouldBe first.identifier
}
}