fix(device): Resolve identity keys against reversed address octets

On at least one vendor stack the address handed up by the BLE scan callback
and the identity key stored for a profile disagree on octet order: the key
resolves the address only when its octets are reversed. Identity resolution
then fails on every advertisement, so no frame is attributed to the profile
and the case popup, connection popup, encrypted 1% battery granularity and
session reconnection all go with it.

RPAChecker gains resolve(), which reports the order that resolved. The
standard-order attempt is unchanged and ungated, so no currently-resolving
device can start failing. The reversed attempt only runs when the reversed
form carries the resolvable-private-address type marker (top bits 01), which
skips roughly three quarters of the extra comparisons for generic random
addresses. verify() is now a thin wrapper over resolve().

Address parsing is validated explicitly: exactly six components, each in
0..255. A seven-component string used to be silently mis-sliced, and
"15A:..." truncated to a valid octet — both cases could resolve.

The failure log no longer prints the identity key. A malformed address or
key wrote an identity-tracking secret into exactly the debug logs users mail
to support; the event and its level stay, the key is reduced to its length
and the address goes through redactedForLogs().

The history lookup that recovers a device by key now skips candidates bound
to a different profile. A 24-bit forward collision could already select the
wrong history; attempting two orders roughly doubles that exposure.

Test vectors are synthetic and derived from the key already committed in
RPACheckerTest. Two of them are complementary: one has an RPA-shaped
reversed form that fails the hash (proving the comparison runs), the other
has a reversed form that resolves cryptographically but carries marker 00
(proving the gate runs).
This commit is contained in:
darken
2026-08-31 15:54:51 +02:00
committed by Matthias Urhahn
parent b66c756741
commit cc99ad59ab
5 changed files with 268 additions and 14 deletions
@@ -70,9 +70,12 @@ class AppleFactory @Inject constructor(
val factory = podFactories.firstOrNull { it.isResponsible(proximityMessage) } ?: unknownAppleFactory
val profiles = profilesRepo.currentProfiles().filterIsInstance<AppleDeviceProfile>()
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
@@ -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()}" } }
}
@@ -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")
}
}
@@ -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<Pair<Logging.Priority, String>>()
val capturingLogger = object : Logging.Logger {
override fun log(
priority: Logging.Priority,
tag: String,
message: String,
metaData: Map<String, Any>?,
) {
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
}
}
@@ -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<AirPodsPro2Usbc>(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<AirPodsPro2Usbc>(payload, address = myRpa.reversedOctets()) { first = this }
first.meta.isIRKMatch shouldBe true
first.meta.profile shouldNotBe null
lateinit var second: AirPodsPro2Usbc
create<AirPodsPro2Usbc>(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<AirPodsPro2Usbc>(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<AirPodsPro2Usbc>(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<AirPodsPro2Usbc>(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<AirPodsPro2Usbc>(payload, address = myRotatedRpa) { second = this }
second.meta.profile?.id shouldBe profileTwo.id
second.identifier shouldNotBe first.identifier
}
}