mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
+83
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user