From eda81822aa7899c75460df4fc9cad1d8c50db240 Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 4 Jun 2025 16:15:42 +0200 Subject: [PATCH] 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. --- .../capod/pods/core/apple/AppleFactory.kt | 2 +- .../pods/core/apple/airpods/AirPodsGen1.kt | 1 - .../capod/monitor/core/RPACheckerTest.kt | 20 ++++++ .../capod/pods/core/apple/BaseAirPodsTest.kt | 70 ++++++++++--------- .../pods/core/apple/airpods/AirPodsProTest.kt | 26 +++++++ 5 files changed, 85 insertions(+), 34 deletions(-) create mode 100644 app-common/src/test/java/eu/darken/capod/monitor/core/RPACheckerTest.kt diff --git a/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactory.kt b/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactory.kt index b6d4637c..b71acf7a 100644 --- a/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactory.kt +++ b/app-common/src/main/java/eu/darken/capod/pods/core/apple/AppleFactory.kt @@ -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, diff --git a/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen1.kt b/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen1.kt index 93bbf023..56beb10c 100644 --- a/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen1.kt +++ b/app-common/src/main/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen1.kt @@ -42,7 +42,6 @@ data class AirPodsGen1( getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH } - override fun create( scanResult: BleScanResult, payload: ProximityPayload diff --git a/app-common/src/test/java/eu/darken/capod/monitor/core/RPACheckerTest.kt b/app-common/src/test/java/eu/darken/capod/monitor/core/RPACheckerTest.kt new file mode 100644 index 00000000..a089fc5a --- /dev/null +++ b/app-common/src/test/java/eu/darken/capod/monitor/core/RPACheckerTest.kt @@ -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 + } +} \ No newline at end of file diff --git a/app-common/src/test/java/eu/darken/capod/pods/core/apple/BaseAirPodsTest.kt b/app-common/src/test/java/eu/darken/capod/pods/core/apple/BaseAirPodsTest.kt index 24c5981d..dfeb0444 100644 --- a/app-common/src/test/java/eu/darken/capod/pods/core/apple/BaseAirPodsTest.kt +++ b/app-common/src/test/java/eu/darken/capod/pods/core/apple/BaseAirPodsTest.kt @@ -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().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 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 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().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().apply { - this[ContinuityProtocol.APPLE_COMPANY_IDENTIFIER] = data - } - ) } \ No newline at end of file diff --git a/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsProTest.kt b/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsProTest.kt index e66d47f4..7b05a351 100644 --- a/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsProTest.kt +++ b/app-common/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsProTest.kt @@ -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(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(data, address) { + batteryLeftPodPercent shouldBe 0.98f + isLeftPodCharging shouldBe false + batteryRightPodPercent shouldBe 1.0f + isRightPodCharging shouldBe true + + batteryCasePercent shouldBe 0.86f + isCaseCharging shouldBe false + } + } } \ No newline at end of file