From 1ed1199574f450312be73878a1108a1f38b84077 Mon Sep 17 00:00:00 2001 From: darken Date: Sun, 14 Jun 2026 23:12:48 +0200 Subject: [PATCH] fix(ble): Stop foreign same-model pods from hijacking a device's identity --- .../core/apple/ble/history/KnownDevice.kt | 6 ++ .../core/apple/ble/history/PodHistoryRepo.kt | 32 ++++++- .../history/PodHistoryFuzzyCollisionTest.kt | 90 +++++++++++++++++++ 3 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/eu/darken/capod/pods/core/apple/ble/history/PodHistoryFuzzyCollisionTest.kt diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/history/KnownDevice.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/history/KnownDevice.kt index 62d35754..c2990f62 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/history/KnownDevice.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/history/KnownDevice.kt @@ -16,6 +16,12 @@ data class KnownDevice( val seenCounter: Int, val history: List, 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 diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/history/PodHistoryRepo.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/history/PodHistoryRepo.kt index be90a939..ba2070be 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/history/PodHistoryRepo.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/history/PodHistoryRepo.kt @@ -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().any { it.getCaseMatchMarkings() == target } + known.history.filterIsInstance().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, ) } } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/ble/history/PodHistoryFuzzyCollisionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/ble/history/PodHistoryFuzzyCollisionTest.kt new file mode 100644 index 00000000..1574fc39 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/ble/history/PodHistoryFuzzyCollisionTest.kt @@ -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(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(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(payload, address = myRpa) { first = this } + first.meta.isIRKMatch shouldBe true + + lateinit var second: AirPodsPro2Usbc + create(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 + } +}