Add more tests for AirPods data decryption

This commit adds a test case to `AirPodsProTest` to verify the decryption of AirPods Pro data with and without encryption/IRK keys. It also introduces a new test class `RPACheckerTest` to validate the RPA checking mechanism.

Helper functions for converting hex strings to byte arrays and setting mock keys have been added to `BaseAirPodsTest` to simplify test setup.
This commit is contained in:
darken
2025-06-06 06:03:41 +02:00
committed by Matthias Urhahn
parent 3b00613dde
commit eda81822aa
5 changed files with 85 additions and 34 deletions
@@ -58,10 +58,10 @@ class AppleFactory @Inject constructor(
suspend fun create(scanResult: BleScanResult): PodDevice? = lock.withLock {
val proximityMessage = getMessage(scanResult) ?: return@withLock null
val payload = getPayload(scanResult, proximityMessage)
val factory = podFactories.firstOrNull { it.isResponsible(proximityMessage) }
val payload = getPayload(scanResult, proximityMessage)
return@withLock (factory ?: unknownAppleFactory).create(
scanResult = scanResult,
@@ -42,7 +42,6 @@ data class AirPodsGen1(
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
}
override fun create(
scanResult: BleScanResult,
payload: ProximityPayload
@@ -0,0 +1,20 @@
package eu.darken.capod.monitor.core
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class RPACheckerTest : BaseTest() {
@Test
fun `test check`() {
val checker = RPAChecker()
checker.verify(
address = "5A:16:2B:91:D1:CD",
irk = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-79"
.replace("-", "")
.also { require(it.length % 2 == 0) { "Not a HEX string" } }
.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
) shouldBe true
}
}
@@ -33,19 +33,34 @@ abstract class BaseAirPodsTest : BaseTest() {
}
}
private val baseBleScanResult = BleScanResult(
receivedAt = Instant.now(),
address = "77:49:4C:D8:25:0C",
rssi = -66,
generatedAtNanos = 136136027721826,
manufacturerSpecificData = emptyMap()
)
val generalSettings = mockk<GeneralSettings>().apply {
every { mainDeviceIdentityKey } returns mockFlowPreference(null)
every { mainDeviceEncryptionKey } returns mockFlowPreference(null)
}
private fun hexToByteArray(hex: String): ByteArray = hex
.replace(" ", "")
.replace(">", "")
.replace("<", "")
.replace("-", "")
.also { require(it.length % 2 == 0) { "Not a HEX string" } }
.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private fun cleanKey(key: String): ByteArray = hexToByteArray(key)
.also { require(it.size == 16) { "Not a valid key: ${it.size} byte" } }
fun setKeyIRK(key: String?) {
generalSettings.apply {
every { mainDeviceIdentityKey } returns mockFlowPreference(key?.let { cleanKey(it) })
}
}
fun setKeyEnc(key: String?) {
generalSettings.apply {
every { mainDeviceEncryptionKey } returns mockFlowPreference(key?.let { cleanKey(it) })
}
}
val factory: AppleFactory = DaggerBaseAirPodsTest_AppleFactoryTestComponent.factory().create(
generalSettings = generalSettings
).appleFactory
@@ -58,30 +73,21 @@ abstract class BaseAirPodsTest : BaseTest() {
every { SystemClockWrap.elapsedRealtimeNanos } returns 1000L
}
suspend inline fun <reified T : PodDevice?> create(hex: String, block: T.() -> Unit) {
val trimmed = hex
.replace(" ", "")
.replace(">", "")
.replace("<", "")
require(trimmed.length % 2 == 0) { "Not a HEX string" }
val bytes = trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val result = mockData(bytes)
internal suspend inline fun <reified T : PodDevice?> create(
hex: String,
address: String = "77:49:4C:D8:25:0C",
block: T.() -> Unit
) {
val result = BleScanResult(
receivedAt = Instant.now(),
address = address,
rssi = -66,
generatedAtNanos = 136136027721826,
manufacturerSpecificData = mutableMapOf<Int, ByteArray>().apply {
this[ContinuityProtocol.APPLE_COMPANY_IDENTIFIER] = hexToByteArray(hex)
}
)
block.invoke(factory.create(result) as T)
}
fun mockData(hex: String): BleScanResult {
val trimmed = hex
.replace(" ", "")
.replace(">", "")
.replace("<", "")
require(trimmed.length % 2 == 0) { "Not a HEX string" }
val bytes = trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
return mockData(bytes)
}
fun mockData(data: ByteArray): BleScanResult = baseBleScanResult.copy(
manufacturerSpecificData = mutableMapOf<Int, ByteArray>().apply {
this[ContinuityProtocol.APPLE_COMPANY_IDENTIFIER] = data
}
)
}
@@ -188,4 +188,30 @@ class AirPodsProTest : BaseAirPodsTest() {
batteryCasePercent shouldBe null
}
}
@Test
fun `decrypt data`() = runTest {
val data = "07 19 01 0E 20 51 9A 98 33 00 04 0C 14 E0 EB 43 3F 4B 22 C0 A9 ED CB 33 E7 09 71"
val address = "5A:16:2B:91:D1:CD"
create<AirPodsPro>(data, address) {
batteryLeftPodPercent shouldBe 0.9f
isLeftPodCharging shouldBe false
batteryRightPodPercent shouldBe 1.0f
isRightPodCharging shouldBe true
batteryCasePercent shouldBe 0.8f
isCaseCharging shouldBe false
}
setKeyIRK("79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-79")
setKeyEnc("3B-9C-80-57-E6-45-7F-F2-1B-8E-07-63-6C-99-E0-29")
create<AirPodsPro>(data, address) {
batteryLeftPodPercent shouldBe 0.98f
isLeftPodCharging shouldBe false
batteryRightPodPercent shouldBe 1.0f
isRightPodCharging shouldBe true
batteryCasePercent shouldBe 0.86f
isCaseCharging shouldBe false
}
}
}