From 480fee684fceb980a8c6a4d6fa1499f718c0c966 Mon Sep 17 00:00:00 2001 From: Paulcake Date: Thu, 17 Sep 2026 20:07:04 +0100 Subject: [PATCH] Constellation: reuse cached verification state for RCS When an RCS verification request has existing verification state, read the server-issued verified number and token before starting a new Sync/PNV flow. Keep this path behind the existing fail-closed RCS consent gate, require an exact target match when a phone hint is available, reject empty tokens, and preserve the resolved SIM slot. --- .../core/GetVerifiedPhoneNumbers.kt | 15 +++++- .../constellation/core/VerifyPhoneNumber.kt | 53 +++++++++++++++++++ .../core/CachedGpnvFastPathTest.kt | 53 +++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/CachedGpnvFastPathTest.kt diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetVerifiedPhoneNumbers.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetVerifiedPhoneNumbers.kt index b61fb07142..97742b7d8a 100644 --- a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetVerifiedPhoneNumbers.kt +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetVerifiedPhoneNumbers.kt @@ -73,6 +73,17 @@ internal suspend fun fetchVerifiedPhoneNumbers( response.phone_numbers } +internal fun findMatchingVerifiedNumber( + numbers: List, + targetPhone: String? +): VerifiedPhoneNumber? { + if (numbers.isEmpty()) return null + if (!targetPhone.isNullOrEmpty()) { + return numbers.firstOrNull { it.phone_number == targetPhone } + } + return numbers.firstOrNull() +} + internal fun VerifiedPhoneNumber.toPhoneNumberInfo(): PhoneNumberInfo { val extras = Bundle().apply { if (id_token.isNotEmpty()) { @@ -89,7 +100,7 @@ internal fun VerifiedPhoneNumber.toPhoneNumberInfo(): PhoneNumberInfo { ) } -internal fun VerifiedPhoneNumber.toPhoneNumberVerification(): PhoneNumberVerification { +internal fun VerifiedPhoneNumber.toPhoneNumberVerification(simSlot: Int = -1): PhoneNumberVerification { val extras = Bundle().apply { putInt("rcs_state", rcs_state.value) } @@ -99,7 +110,7 @@ internal fun VerifiedPhoneNumber.toPhoneNumberVerification(): PhoneNumberVerific phone_number, verification_time?.toEpochMilli() ?: 0L, 0, - -1, + simSlot, id_token.ifEmpty { null }, extras, 1, diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerifyPhoneNumber.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerifyPhoneNumber.kt index d8baf124c2..87b6608251 100644 --- a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerifyPhoneNumber.kt +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerifyPhoneNumber.kt @@ -285,6 +285,54 @@ private fun handleRpcError(error: GrpcException): Status { return Status(statusCode, error.message) } +internal fun shouldTryCachedGpnvFastPath( + requiredConsumerConsent: String?, + verificationTokenCount: Int, + targetedSimCount: Int +): Boolean { + return requiredConsumerConsent == "RCS" && + verificationTokenCount > 0 && + targetedSimCount <= 1 +} + +private suspend fun tryCachedGpnvVerification( + context: Context, + request: VerifyPhoneNumberRequest, + callingPackage: String, + imsiToInfoMap: Map +): Array? { + val verificationTokenCount = ConstellationStateStore.loadVerificationTokens(context).size + if (!shouldTryCachedGpnvFastPath( + request.extras.getString("required_consumer_consent"), + verificationTokenCount, + request.targetedSims.size + ) + ) { + return null + } + + val targetPhone = request.targetedSims.firstOrNull() + ?.phoneNumberHint + ?.takeIf { it.isNotEmpty() } + val gpnvBundle = Bundle().apply { + putString("certificate_hash", request.idTokenRequest.idToken ?: "") + putString("token_nonce", request.idTokenRequest.subscriberHash ?: "") + putString("calling_package", callingPackage) + } + + Log.i(TAG, "Using cached verification state for RCS GetVerifiedPhoneNumbers") + val numbers = fetchVerifiedPhoneNumbers(context, gpnvBundle, callingPackage) + val matchingNumber = findMatchingVerifiedNumber(numbers, targetPhone) + ?: throw IllegalStateException("cached GPNV: no exact target-number match") + if (matchingNumber.id_token.isEmpty()) { + throw IllegalStateException("cached GPNV: empty JWT") + } + + val targetImsi = request.targetedSims.firstOrNull()?.imsi?.takeIf { it.isNotEmpty() } + val simSlot = targetImsi?.let { imsiToInfoMap[it]?.simSlotIndex } ?: -1 + return arrayOf(matchingNumber.toPhoneNumberVerification(simSlot)) +} + internal fun hasRequiredVerificationConsent( response: GetConsentResponse, asterismClient: AsterismClient @@ -328,6 +376,11 @@ private suspend fun runVerificationFlow( } } + // Keep the cached read path behind the fail-closed consent gate above. + tryCachedGpnvVerification(context, request, callingPackage, imsiToInfoMap)?.let { + return it + } + val syncRequest = SyncRequest( context, sessionId, diff --git a/play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/CachedGpnvFastPathTest.kt b/play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/CachedGpnvFastPathTest.kt new file mode 100644 index 0000000000..d4c7e0e647 --- /dev/null +++ b/play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/CachedGpnvFastPathTest.kt @@ -0,0 +1,53 @@ +package org.microg.gms.constellation.core + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.microg.gms.constellation.core.proto.VerifiedPhoneNumber + +class CachedGpnvFastPathTest { + @Test + fun cachedFastPathRequiresRcsCachedStateAndAtMostOneTarget() { + assertTrue(shouldTryCachedGpnvFastPath("RCS", 1, 1)) + assertTrue(shouldTryCachedGpnvFastPath("RCS", 1, 0)) + assertTrue(!shouldTryCachedGpnvFastPath("RCS", 0, 1)) + assertTrue(!shouldTryCachedGpnvFastPath(null, 1, 1)) + assertTrue(!shouldTryCachedGpnvFastPath("OTHER", 1, 1)) + assertTrue(!shouldTryCachedGpnvFastPath("RCS", 1, 2)) + } + + @Test + fun exactPhoneMatchWinsWhenTargetIsKnown() { + val first = VerifiedPhoneNumber(phone_number = "+441", id_token = "jwt-first") + val exact = VerifiedPhoneNumber(phone_number = "+442", id_token = "jwt-exact") + assertEquals(exact, findMatchingVerifiedNumber(listOf(first, exact), "+442")) + } + + @Test + fun wrongPhoneIsRejectedWhenTargetIsKnown() { + val first = VerifiedPhoneNumber(phone_number = "+441", id_token = "jwt-first") + val second = VerifiedPhoneNumber(phone_number = "+442", id_token = "jwt-second") + assertNull(findMatchingVerifiedNumber(listOf(first, second), "+449")) + } + + @Test + fun firstRecordIsRetainedWhenNoTargetIsKnown() { + val first = VerifiedPhoneNumber(phone_number = "+441", id_token = "jwt-first") + val second = VerifiedPhoneNumber(phone_number = "+442", id_token = "jwt-second") + assertEquals(first, findMatchingVerifiedNumber(listOf(first, second), null)) + } + + @Test + fun emptyResponseHasNoMatch() { + assertNull(findMatchingVerifiedNumber(emptyList(), "+441")) + } + + @Test + fun cachedResultPreservesResolvedSimSlotAndJwt() { + val result = VerifiedPhoneNumber(phone_number = "+441", id_token = "jwt") + .toPhoneNumberVerification(simSlot = 2) + assertEquals(2, result.simSlot) + assertEquals("jwt", result.verificationToken) + } +}