Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ internal suspend fun fetchVerifiedPhoneNumbers(
response.phone_numbers
}

internal fun findMatchingVerifiedNumber(
numbers: List<VerifiedPhoneNumber>,
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()) {
Expand All @@ -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)
}
Expand All @@ -99,7 +110,7 @@ internal fun VerifiedPhoneNumber.toPhoneNumberVerification(): PhoneNumberVerific
phone_number,
verification_time?.toEpochMilli() ?: 0L,
0,
-1,
simSlot,
id_token.ifEmpty { null },
extras,
1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, SubscriptionInfo>
): Array<PhoneNumberVerification>? {
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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}