diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/AppleFactory.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/AppleFactory.kt index 23b1da34..dc01da35 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/AppleFactory.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/AppleFactory.kt @@ -70,9 +70,12 @@ class AppleFactory @Inject constructor( val factory = podFactories.firstOrNull { it.isResponsible(proximityMessage) } ?: unknownAppleFactory val profiles = profilesRepo.currentProfiles().filterIsInstance() - var profile = profiles.firstOrNull { - it.identityKey != null && rpaChecker.verify(scanResult.address, it.identityKey) + val irkMatch = profiles.firstNotNullOfOrNull { candidate -> + val identityKey = candidate.identityKey ?: return@firstNotNullOfOrNull null + rpaChecker.resolve(scanResult.address, identityKey)?.let { candidate to it } } + var profile = irkMatch?.first + val irkOrder = irkMatch?.second val isIrkMatch = profile != null if (isIrkMatch) { @@ -115,7 +118,10 @@ class AppleFactory @Inject constructor( .filter { it.identityKey != null && (it.model == PodModel.UNKNOWN || it.model == tempDevice.model) } .firstOrNull { it.minimumSignalQuality <= tempDevice.signalQuality } if (legacyCandidate != null) { - log(TAG, WARN) { "Keyed profile ${legacyCandidate.id} would match via old fallback (IRK failed) — stale key?" } + log(TAG, WARN) { + "Keyed profile ${legacyCandidate.id} would match via old fallback, " + + "its key resolved neither address order" + } } } } @@ -136,7 +142,8 @@ class AppleFactory @Inject constructor( val publicHex = payload.public.data.joinToString(" ") { "%02X".format(it.toInt()) } val privateHex = payload.private?.data?.joinToString(" ") { "%02X".format(it.toInt()) } ?: "-" "Apple decoded: model=${device.model}, addr=${scanResult.address.redactedForLogs()}, " + - "irkMatch=$isIrkMatch, raw=[$rawHex], public=[$publicHex], private=[$privateHex]" + "irkMatch=$isIrkMatch, irkOrder=${irkOrder?.name ?: "-"}, " + + "raw=[$rawHex], public=[$publicHex], private=[$privateHex]" } device 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 ba2070be..2c169d3b 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 @@ -136,6 +136,7 @@ class PodHistoryRepo @Inject constructor( if (profile != null) { recognizedDevice = knownDevices.values + .filter { it.boundProfileId == null || it.boundProfileId == profile.id } .firstOrNull { rpaChecker.verify(it.lastAddress, profile.identityKey!!) } .also { log(TAG, VERBOSE) { "search1: Recovered via IRK: ${it?.logSummary()}" } } } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/protocol/RPAChecker.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/protocol/RPAChecker.kt index 1d5678bf..c3db959e 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/protocol/RPAChecker.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/protocol/RPAChecker.kt @@ -2,30 +2,68 @@ package eu.darken.capod.pods.core.apple.ble.protocol import android.annotation.SuppressLint import eu.darken.capod.common.bluetooth.BluetoothAddress +import eu.darken.capod.common.bluetooth.redactedForLogs import eu.darken.capod.common.debug.logging.Logging import eu.darken.capod.common.debug.logging.asLog import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag -import okio.ByteString.Companion.toByteString import javax.crypto.Cipher import javax.crypto.spec.SecretKeySpec import javax.inject.Inject class RPAChecker @Inject constructor() { + enum class AddressOrder { + STANDARD, + REVERSED, + } + // Resolvable-Private-Address - fun verify(address: BluetoothAddress, irk: IdentityResolvingKey): Boolean = try { - val rpa = address.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray() - val prand = rpa.copyOfRange(3, 6) - val hash = rpa.copyOfRange(0, 3) - val computedHash = ah(irk, prand) - hash.contentEquals(computedHash) + fun verify(address: BluetoothAddress, irk: IdentityResolvingKey): Boolean = resolve(address, irk) != null + + /** + * Returns the octet order the [address] resolved in, or null if it resolves in neither. + * + * The reversed attempt is gated on the address being RPA-shaped in that order, the standard + * attempt is not: a device that resolves today must keep resolving byte-for-byte the same way. + */ + fun resolve(address: BluetoothAddress, irk: IdentityResolvingKey): AddressOrder? = try { + val octets = address.parseOctets() + when { + octets == null -> null + matchesHash(octets, irk) -> AddressOrder.STANDARD + else -> octets.reversedArray() + .takeIf { it.isRpaShaped() && matchesHash(it, irk) } + ?.let { AddressOrder.REVERSED } + } } catch (e: Exception) { log( TAG, Logging.Priority.ERROR - ) { "Failed to verify RPA\naddress=${address}\nIRK=${irk.toByteString()}\n${e.asLog()}" } - false + ) { "Failed to resolve RPA\naddress=${address.redactedForLogs()}\nIRK=${irk.size}B\n${e.asLog()}" } + null + } + + /** Six octets, most-significant first, as printed. Null if [this] isn't a well-formed address. */ + private fun BluetoothAddress.parseOctets(): ByteArray? { + val parts = split(":") + if (parts.size != ADDRESS_OCTETS) return null + val octets = ByteArray(ADDRESS_OCTETS) + parts.forEachIndexed { index, part -> + val value = part.toIntOrNull(16) ?: return null + if (value !in 0..255) return null + octets[index] = value.toByte() + } + return octets + } + + private fun ByteArray.isRpaShaped(): Boolean = (this[0].toInt() and 0xC0) == 0x40 + + private fun matchesHash(octets: ByteArray, irk: IdentityResolvingKey): Boolean { + val rpa = octets.reversedArray() + val prand = rpa.copyOfRange(3, 6) + val hash = rpa.copyOfRange(0, 3) + return hash.contentEquals(ah(irk, prand)) } // E function (Encryption function): @@ -47,6 +85,7 @@ class RPAChecker @Inject constructor() { } companion object { + private const val ADDRESS_OCTETS = 6 private val TAG = logTag("Monitor", "BlePodMonitor", "RPAChecker") } } \ No newline at end of file diff --git a/app/src/test/java/eu/darken/capod/monitor/core/RPACheckerTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/RPACheckerTest.kt index cb301093..366c18fb 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/RPACheckerTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/RPACheckerTest.kt @@ -1,13 +1,20 @@ package eu.darken.capod.monitor.core +import eu.darken.capod.common.debug.logging.Logging import eu.darken.capod.common.fromHex +import eu.darken.capod.common.toHex import eu.darken.capod.pods.core.apple.ble.protocol.RPAChecker +import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe import org.junit.jupiter.api.Test import testhelpers.BaseTest +import testhelpers.logging.JUnitLogger class RPACheckerTest : BaseTest() { + private val irkHex = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-79" + private val resolvingAddress = "5A:16:2B:91:D1:CD" + @Test fun `test check`() { val checker = RPAChecker() @@ -37,4 +44,122 @@ class RPACheckerTest : BaseTest() { irk = "".fromHex(), ) shouldBe false } + + @Test + fun `resolve reports the standard octet order`() { + val checker = RPAChecker() + checker.resolve( + address = resolvingAddress, + irk = irkHex.fromHex(), + ) shouldBe RPAChecker.AddressOrder.STANDARD + checker.verify( + address = resolvingAddress, + irk = irkHex.fromHex(), + ) shouldBe true + } + + @Test + fun `resolve reports the reversed octet order`() { + val checker = RPAChecker() + // The resolving address with its octets reversed, as a vendor stack delivering them backwards. + checker.resolve( + address = "CD:D1:91:2B:16:5A", + irk = irkHex.fromHex(), + ) shouldBe RPAChecker.AddressOrder.REVERSED + checker.verify( + address = "CD:D1:91:2B:16:5A", + irk = irkHex.fromHex(), + ) shouldBe true + } + + @Test + fun `a reversed candidate still has to pass the hash comparison`() { + val checker = RPAChecker() + // Reversed form 5B:16:2B:91:D1:CD is RPA-shaped, so the marker gate passes and only the + // hash comparison can reject it. + checker.resolve( + address = "CD:D1:91:2B:16:5B", + irk = irkHex.fromHex(), + ).shouldBeNull() + checker.verify( + address = "CD:D1:91:2B:16:5B", + irk = irkHex.fromHex(), + ) shouldBe false + } + + @Test + fun `a reversed candidate is gated on the RPA type marker`() { + val checker = RPAChecker() + // Reversed form 1A:16:2B:85:33:DC resolves cryptographically, but its type marker is 00, + // so only the gate rejects it. + checker.resolve( + address = "DC:33:85:2B:16:1A", + irk = irkHex.fromHex(), + ).shouldBeNull() + checker.verify( + address = "DC:33:85:2B:16:1A", + irk = irkHex.fromHex(), + ) shouldBe false + } + + @Test + fun `malformed addresses are rejected instead of mis-sliced`() { + val checker = RPAChecker() + // Seven components whose tail is the resolving address — sliced rather than rejected, this + // resolves. + checker.resolve( + address = "00:$resolvingAddress", + irk = irkHex.fromHex(), + ).shouldBeNull() + checker.verify( + address = "00:$resolvingAddress", + irk = irkHex.fromHex(), + ) shouldBe false + // 0x15A truncates to the resolving octet 0x5A. + checker.resolve( + address = "15A:16:2B:91:D1:CD", + irk = irkHex.fromHex(), + ).shouldBeNull() + checker.verify( + address = "15A:16:2B:91:D1:CD", + irk = irkHex.fromHex(), + ) shouldBe false + checker.resolve( + address = "5A:16:2B:91:D1", + irk = irkHex.fromHex(), + ).shouldBeNull() + } + + @Test + fun `the failure log carries neither the identity key nor the full address`() { + val malformedIrk = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE".fromHex() + val captured = mutableListOf>() + val capturingLogger = object : Logging.Logger { + override fun log( + priority: Logging.Priority, + tag: String, + message: String, + metaData: Map?, + ) { + captured.add(priority to message) + } + } + + Logging.clearAll() + Logging.install(capturingLogger) + try { + RPAChecker().verify(address = resolvingAddress, irk = malformedIrk) shouldBe false + } finally { + Logging.clearAll() + Logging.install(JUnitLogger()) + } + + captured.any { (priority, message) -> + priority == Logging.Priority.ERROR && message.contains("Failed to resolve RPA") + } shouldBe true + + val keyHex = malformedIrk.toHex(separator = "") + captured.any { (_, message) -> message.contains(keyHex, ignoreCase = true) } shouldBe false + captured.any { (_, message) -> message.contains(resolvingAddress) } shouldBe false + } } \ No newline at end of file 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 index 1574fc39..a2fb5f00 100644 --- 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 @@ -27,8 +27,11 @@ 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 myRotatedRpa = "45:23:51:E3:40:6E" // also resolves against irkHex (computed) private val foreignAddress = "77:49:4C:D8:25:0C" // does NOT resolve against irkHex + private fun String.reversedOctets(): String = split(":").reversed().joinToString(":") + // 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" @@ -75,7 +78,6 @@ class PodHistoryFuzzyCollisionTest : BaseBlePodsTest() { 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 } @@ -87,4 +89,84 @@ class PodHistoryFuzzyCollisionTest : BaseBlePodsTest() { // Same physical device across rotation -> same stable identity (recovered via IRK). second.identifier shouldBe first.identifier } + + @Test + fun `a device whose address arrives octet-reversed keeps one identity across a rotation`() = runTest { + profileList.add( + AppleDeviceProfile( + label = "Mine", + model = PodModel.AIRPODS_PRO2_USBC, + identityKey = irkHex.fromHex(), + address = "AA:BB:CC:DD:EE:FF", + ) + ) + + lateinit var first: AirPodsPro2Usbc + create(payload, address = myRpa.reversedOctets()) { first = this } + first.meta.isIRKMatch shouldBe true + first.meta.profile shouldNotBe null + + lateinit var second: AirPodsPro2Usbc + create(payload, address = myRotatedRpa.reversedOctets()) { second = this } + second.meta.isIRKMatch shouldBe true + second.identifier shouldBe first.identifier + } + + @Test + fun `a reversed foreign address must not inherit 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", + ) + ) + + lateinit var mine: AirPodsPro2Usbc + create(payload, address = myRpa) { mine = this } + mine.meta.isIRKMatch shouldBe true + + // The reversed foreign address is RPA-shaped in its reversed form, so the alternate-order + // path runs its hash comparison on it — and must still refuse to attribute it. + lateinit var foreign: AirPodsPro2Usbc + create(payload, address = foreignAddress.reversedOctets()) { foreign = this } + foreign.meta.isIRKMatch shouldBe false + foreign.meta.profile shouldBe null + foreign.identifier shouldNotBe mine.identifier + } + + @Test + fun `a history bound to another profile is not claimed by identity recovery`() = runTest { + // Both profiles carry the SAME key: with two independent keys the candidate would already + // fail the key check and the binding check would never be reached. + val profileOne = AppleDeviceProfile( + label = "First", + model = PodModel.AIRPODS_PRO2_USBC, + identityKey = irkHex.fromHex(), + address = "AA:BB:CC:DD:EE:F1", + ) + val profileTwo = AppleDeviceProfile( + label = "Second", + model = PodModel.AIRPODS_PRO2_USBC, + identityKey = irkHex.fromHex(), + address = "AA:BB:CC:DD:EE:F2", + ) + profileList.add(profileOne) + profileList.add(profileTwo) + + // The first resolving profile wins, so this frame binds the history to profileOne. + lateinit var first: AirPodsPro2Usbc + create(payload, address = myRpa) { first = this } + first.meta.profile?.id shouldBe profileOne.id + + // profileOne is gone, so the next frame resolves to profileTwo — which must not be handed + // the history that is already bound to profileOne. + profileList.remove(profileOne) + + lateinit var second: AirPodsPro2Usbc + create(payload, address = myRotatedRpa) { second = this } + second.meta.profile?.id shouldBe profileTwo.id + second.identifier shouldNotBe first.identifier + } }