Use decrypted proximity data if available

This change introduces the capability to decrypt the last 16 bytes of the proximity pairing message if an encryption key is available.

The decrypted payload provides more precise battery information (0-100% instead of 0-10%) and charging status for each pod and the case.

This information is now used by `DualApplePods` to enhance battery reporting accuracy.

The `RPAChecker` has also been updated with improved error logging.
This commit is contained in:
darken
2025-06-06 06:03:41 +02:00
committed by Matthias Urhahn
parent df4ec3501b
commit 33ca43eaf1
31 changed files with 245 additions and 42 deletions
@@ -19,6 +19,7 @@ import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodFactory
import eu.darken.capod.monitor.core.RPAChecker
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
@@ -1,7 +1,12 @@
package eu.darken.capod.monitor.core
import android.annotation.SuppressLint
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
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 eu.darken.capod.pods.core.PodDevice
import okio.ByteString.Companion.toByteString
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject
@@ -9,12 +14,15 @@ import javax.inject.Inject
class RPAChecker @Inject constructor() {
// Resolvable-Private-Address
fun verify(dev: PodDevice, irk: ByteArray): Boolean {
fun verify(dev: PodDevice, irk: ByteArray): Boolean = try {
val rpa = dev.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)
return hash.contentEquals(computedHash)
hash.contentEquals(computedHash)
} catch (e: Exception) {
log(TAG, ERROR) { "Failed to verify RPA\nPod=${dev}\nIRK=${irk.toByteString()}\n${e.asLog()}" }
false
}
// E function (Encryption function):
@@ -34,4 +42,8 @@ class RPAChecker @Inject constructor() {
}
return e(k, rPadded).copyOfRange(0, 3)
}
companion object {
private val TAG = logTag("Monitor", "PodMonitor", "RPAChecker")
}
}
@@ -5,9 +5,11 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
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 eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.misc.UnknownAppleDevice
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.apple.protocol.MessageDecrypter
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -20,6 +22,8 @@ class AppleFactory @Inject constructor(
private val proximityPairingDecoder: ProximityPairing.Decoder,
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory<out ApplePods>>,
private val unknownAppleFactory: UnknownAppleDevice.Factory,
private val generalSettings: GeneralSettings,
private val messageDecrypter: MessageDecrypter,
) {
private val lock = Mutex()
@@ -54,9 +58,13 @@ class AppleFactory @Inject constructor(
val factory = podFactories.firstOrNull { it.isResponsible(pm) }
val decryptedData = generalSettings.mainDeviceEncryptionKey.value
?.let { messageDecrypter.decrypt(pm, it) }
return@withLock (factory ?: unknownAppleFactory).create(
scanResult = scanResult,
message = pm,
decrypted = decryptedData,
)
}
@@ -10,6 +10,9 @@ interface ApplePods : PodDevice {
val proximityMessage: ProximityPairing.Message
val decryptedPayload: UByteArray?
get() = null
// We start counting at the airpods prefix byte
val rawPrefix: UByte
get() = proximityMessage.data[0]
@@ -202,5 +202,6 @@ abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
abstract fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?,
): ApplePods
}
@@ -4,8 +4,12 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.isBitSet
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.*
import eu.darken.capod.pods.core.DualPodDevice
import eu.darken.capod.pods.core.DualPodDevice.Pod
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
import eu.darken.capod.pods.core.HasDualMicrophone
import eu.darken.capod.pods.core.HasEarDetectionDual
interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasEarDetectionDual, HasCase,
HasDualMicrophone, HasAppleColor {
@@ -25,6 +29,10 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasE
override val batteryLeftPodPercent: Float?
get() {
decryptedPayload?.get(if (areValuesFlipped) 2 else 1)?.let { raw ->
val level = (raw and 0x7Fu).toInt() / 100f
if (level <= 1.0f) return level
}
val value = when (areValuesFlipped) {
true -> rawPodsBattery.upperNibble.toInt()
false -> rawPodsBattery.lowerNibble.toInt()
@@ -42,6 +50,10 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasE
override val batteryRightPodPercent: Float?
get() {
decryptedPayload?.get(if (areValuesFlipped) 1 else 2)?.let { raw ->
val level = (raw and 0x7Fu).toInt() / 100f
if (level <= 1.0f) return level
}
val value = when (areValuesFlipped) {
true -> rawPodsBattery.lowerNibble.toInt()
false -> rawPodsBattery.upperNibble.toInt()
@@ -93,30 +105,55 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualPodDevice, HasE
get() = !rawStatus.isBitSet(5) xor isThisPodInThecase
override val isLeftPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
get() {
decryptedPayload?.get(if (areValuesFlipped) 2 else 1)?.let { raw ->
val isCharging = (raw and 0x80u).toInt() != 0
return isCharging
}
return when (areValuesFlipped) {
false -> rawFlags.isBitSet(0)
true -> rawFlags.isBitSet(1)
}
}
override val isRightPodCharging: Boolean
get() = when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
get() {
decryptedPayload?.get(if (areValuesFlipped) 1 else 2)?.let { raw ->
val isCharging = (raw and 0x80u).toInt() != 0
return isCharging
}
return when (areValuesFlipped) {
false -> rawFlags.isBitSet(1)
true -> rawFlags.isBitSet(0)
}
}
override val batteryCasePercent: Float?
get() = when (val value = rawCaseBattery.toInt()) {
15 -> null
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
get() {
decryptedPayload?.get(3)?.let { raw ->
val level = (raw and 0x7Fu).toInt() / 100f
if (level <= 1.0f) return level
}
return when (val value = rawCaseBattery.toInt()) {
15 -> null
else -> if (value > 10) {
log { "Case: Above 100% battery: $value" }
1.0f
} else {
value / 10f
}
}
}
override val isCaseCharging: Boolean
get() = rawFlags.isBitSet(2)
get() {
decryptedPayload?.get(3)?.let { raw ->
val isCharging = (raw and 0x80u).toInt() != 0
if (batteryCasePercent != null) return isCharging
}
return rawFlags.isBitSet(2)
}
val caseLidState: LidState
get() {
@@ -41,7 +41,11 @@ data class AirPodsGen1 constructor(
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = AirPodsGen1(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -40,7 +40,11 @@ data class AirPodsGen2 constructor(
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = AirPodsGen2(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -40,7 +40,11 @@ data class AirPodsGen3 constructor(
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = AirPodsGen3(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -40,7 +40,11 @@ data class AirPodsGen4(
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = AirPodsGen4(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -40,7 +40,11 @@ data class AirPodsGen4Anc(
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = AirPodsGen4Anc(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -42,7 +42,11 @@ data class AirPodsMax(
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = AirPodsMax(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -42,7 +42,11 @@ data class AirPodsMaxUsbc(
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = AirPodsMaxUsbc(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -20,6 +20,7 @@ data class AirPodsPro(
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val decryptedPayload: UByteArray?,
override val reliability: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
@@ -59,8 +60,16 @@ data class AirPodsPro(
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
var basic = AirPodsPro(scanResult = scanResult, proximityMessage = message)
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = AirPodsPro(
scanResult = scanResult,
proximityMessage = message,
decryptedPayload = decrypted
)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
@@ -79,7 +88,6 @@ data class AirPodsPro(
cachedCaseState = result.getLatestCaseLidState(basic)
)
}
}
companion object {
@@ -59,7 +59,11 @@ data class AirPodsPro2(
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = AirPodsPro2(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -59,7 +59,11 @@ data class AirPodsPro2Usbc(
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = AirPodsPro2Usbc(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -40,7 +40,11 @@ data class BeatsFitPro(
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = BeatsFitPro(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -32,7 +32,11 @@ data class BeatsFlex(
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = BeatsFlex(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -31,7 +31,11 @@ data class BeatsSolo3(
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = BeatsSolo3(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -29,7 +29,11 @@ data class BeatsStudio3(
getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = BeatsStudio3(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -32,7 +32,11 @@ data class BeatsX(
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = BeatsX(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -32,7 +32,11 @@ data class PowerBeats3(
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = PowerBeats3(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -33,7 +33,11 @@ data class PowerBeats4(
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = PowerBeats4(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -40,7 +40,11 @@ data class PowerBeatsPro(
getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = PowerBeatsPro(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -124,7 +124,11 @@ data class FakeAirPodsGen1 constructor(
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = FakeAirPodsGen1(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -122,7 +122,11 @@ data class FakeAirPodsGen2 constructor(
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = FakeAirPodsGen2(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -124,7 +124,11 @@ data class FakeAirPodsGen3 constructor(
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = FakeAirPodsGen3(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -142,7 +142,11 @@ data class FakeAirPodsPro constructor(
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = FakeAirPodsPro(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -139,7 +139,11 @@ data class FakeAirPodsPro2 constructor(
getModelInfo().full == DEVICE_CODE && length == 19
}
override fun create(scanResult: BleScanResult, message: ProximityPairing.Message): ApplePods {
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?
): ApplePods {
var basic = FakeAirPodsPro2(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -35,6 +35,7 @@ data class UnknownAppleDevice(
override fun create(
scanResult: BleScanResult,
message: ProximityPairing.Message,
decrypted: UByteArray?,
): ApplePods {
var basic = UnknownAppleDevice(scanResult = scanResult, proximityMessage = message)
val result = searchHistory(basic)
@@ -0,0 +1,44 @@
package eu.darken.capod.pods.core.apple.protocol
import android.annotation.SuppressLint
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 MessageDecrypter @Inject constructor() {
@SuppressLint("GetInstance")
fun decrypt(message: ProximityPairing.Message, key: ByteArray): UByteArray? {
val data = message.data.toByteArray()
if (data.size < 16) return null
val decryptedData = try {
val cipher = Cipher.getInstance("AES/ECB/NoPadding").apply {
val secretKey = SecretKeySpec(key, "AES")
init(Cipher.DECRYPT_MODE, secretKey)
}
cipher.doFinal(data.copyOfRange(data.size - 16, data.size))
} catch (e: Exception) {
log(TAG, Logging.Priority.ERROR) { "Failed to decrypt $message with ${key.toByteString()}\n${e.asLog()}" }
null
}
log(
TAG,
Logging.Priority.VERBOSE
) { "Decrypted $message with ${key.toByteString()} to ${decryptedData?.toByteString()}" }
if (decryptedData == null || decryptedData.size != 16) return null
return decryptedData.toUByteArray()
}
companion object {
private val TAG = logTag("Pod", "Factory", "Decrypter")
}
}