From d2a6b75bbcd79b7aa59dbbaa74953b375fd287d4 Mon Sep 17 00:00:00 2001 From: Anusha Date: Sat, 12 Sep 2026 18:01:30 +0530 Subject: [PATCH] fix(rcs): Spatula header, Asterism consent fallback, and TS.43 identity Bind AppCertService for X-Goog-Spatula from constellation-core, use rcs_consent when Gaia consent is missing, and keep TS.43 AKA identity aligned with EAP_ID. --- play-services-asterism/core/build.gradle | 7 ++ .../gms/asterism/core/AsterismApiService.kt | 6 + .../core/GetAsterismConsentHandler.kt | 33 +++-- .../core/AsterismConsentResolverTest.kt | 115 ++++++++++++++++++ play-services-constellation/core/build.gradle | 1 + .../core/ConstellationApiService.kt | 5 + .../gms/constellation/core/RpcClient.kt | 30 ++++- .../core/SpatulaHeaderProvider.kt | 91 ++++++++++++++ .../core/verification/Ts43Verifier.kt | 2 +- .../core/verification/ts43/EapAkaService.kt | 32 ++--- .../ts43/ServiceEntitlementExtension.kt | 31 +++-- .../gms/constellation/core/RpcClientTest.kt | 37 ++++++ .../verification/ts43/EapAkaIdentityTest.kt | 47 +++++++ .../ts43/ServiceEntitlementBuilderTest.kt | 47 +++++++ .../ServiceEntitlementTerminalMetadataTest.kt | 55 +++++++++ play-services-core/build.gradle | 5 + .../microg/gms/auth/appcert/AppCertManager.kt | 15 ++- .../gms/auth/appcert/AppCertManagerTest.kt | 22 ++++ 18 files changed, 544 insertions(+), 37 deletions(-) create mode 100644 play-services-asterism/core/src/test/kotlin/org/microg/gms/asterism/core/AsterismConsentResolverTest.kt create mode 100644 play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/SpatulaHeaderProvider.kt create mode 100644 play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/RpcClientTest.kt create mode 100644 play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/verification/ts43/EapAkaIdentityTest.kt create mode 100644 play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/verification/ts43/ServiceEntitlementBuilderTest.kt create mode 100644 play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/verification/ts43/ServiceEntitlementTerminalMetadataTest.kt create mode 100644 play-services-core/src/test/kotlin/org/microg/gms/auth/appcert/AppCertManagerTest.kt diff --git a/play-services-asterism/core/build.gradle b/play-services-asterism/core/build.gradle index e37fe498f4..362d4adf51 100644 --- a/play-services-asterism/core/build.gradle +++ b/play-services-asterism/core/build.gradle @@ -22,6 +22,7 @@ android { sourceSets { main.java.srcDirs += 'src/main/kotlin' + test.java.srcDirs += 'src/test/kotlin' } compileOptions { @@ -32,6 +33,10 @@ android { kotlinOptions { jvmTarget = 1.8 } + + testOptions { + unitTests.returnDefaultValues = true + } } apply from: '../../gradle/publish-android.gradle' @@ -43,4 +48,6 @@ dependencies { implementation project(':play-services-base-core') implementation project(':play-services-constellation-core') + + testImplementation 'junit:junit:4.13.2' } diff --git a/play-services-asterism/core/src/main/kotlin/org/microg/gms/asterism/core/AsterismApiService.kt b/play-services-asterism/core/src/main/kotlin/org/microg/gms/asterism/core/AsterismApiService.kt index 4917d9070f..6652a4a8fe 100644 --- a/play-services-asterism/core/src/main/kotlin/org/microg/gms/asterism/core/AsterismApiService.kt +++ b/play-services-asterism/core/src/main/kotlin/org/microg/gms/asterism/core/AsterismApiService.kt @@ -19,12 +19,18 @@ import kotlinx.coroutines.launch import org.microg.gms.BaseService import org.microg.gms.common.GmsService import org.microg.gms.common.PackageUtils +import org.microg.gms.constellation.core.RpcClient private const val TAG = "AsterismApiService" class AsterismApiService : BaseService(TAG, GmsService.ASTERISM) { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + override fun onCreate() { + super.onCreate() + RpcClient.initialize(this) + } + override fun handleServiceRequest( callback: IGmsCallbacks?, request: GetServiceRequest?, diff --git a/play-services-asterism/core/src/main/kotlin/org/microg/gms/asterism/core/GetAsterismConsentHandler.kt b/play-services-asterism/core/src/main/kotlin/org/microg/gms/asterism/core/GetAsterismConsentHandler.kt index 415c40d1ab..e493c3e2a0 100644 --- a/play-services-asterism/core/src/main/kotlin/org/microg/gms/asterism/core/GetAsterismConsentHandler.kt +++ b/play-services-asterism/core/src/main/kotlin/org/microg/gms/asterism/core/GetAsterismConsentHandler.kt @@ -16,10 +16,12 @@ import kotlinx.coroutines.withContext import org.microg.gms.constellation.core.ConstellationStateStore import org.microg.gms.constellation.core.RpcClient import org.microg.gms.constellation.core.authManager +import org.microg.gms.constellation.core.proto.AsterismClient import org.microg.gms.constellation.core.proto.Consent import org.microg.gms.constellation.core.proto.ConsentVersion import org.microg.gms.constellation.core.proto.DeviceID import org.microg.gms.constellation.core.proto.GetConsentRequest +import org.microg.gms.constellation.core.proto.GetConsentResponse import org.microg.gms.constellation.core.proto.RequestHeader import org.microg.gms.constellation.core.proto.RequestTrigger import org.microg.gms.constellation.core.proto.builder.buildRequestContext @@ -51,14 +53,10 @@ suspend fun handleGetAsterismConsent( ) ) - val gaiaConsent = response.gaia_consents.find { - it.asterism_client == request.asterismClient - } - val (consentValue, consentVersion) = if (gaiaConsent != null) { - gaiaConsent.consent to gaiaConsent.consent_version - } else { - Consent.NO_CONSENT to ConsentVersion.CONSENT_VERSION_UNSPECIFIED - } + val (consentValue, consentVersion) = resolveAsterismConsent( + response, + request.asterismClient + ) callbacks.onConsentFetched( Status.SUCCESS, @@ -85,6 +83,25 @@ suspend fun handleGetAsterismConsent( } } +internal fun resolveAsterismConsent( + response: GetConsentResponse, + asterismClient: AsterismClient +): Pair { + response.gaia_consents.firstOrNull { + it.asterism_client == asterismClient + }?.let { + return it.consent to it.consent_version + } + + if (asterismClient == AsterismClient.RCS) { + response.rcs_consent?.takeIf { it.consent != Consent.CONSENT_UNKNOWN }?.let { + return it.consent to it.consent_version + } + } + + return Consent.NO_CONSENT to ConsentVersion.CONSENT_VERSION_UNSPECIFIED +} + suspend fun handleGetIsPnvrConstellationDevice( context: Context, callbacks: IAsterismCallbacks diff --git a/play-services-asterism/core/src/test/kotlin/org/microg/gms/asterism/core/AsterismConsentResolverTest.kt b/play-services-asterism/core/src/test/kotlin/org/microg/gms/asterism/core/AsterismConsentResolverTest.kt new file mode 100644 index 0000000000..8ec6a6c71b --- /dev/null +++ b/play-services-asterism/core/src/test/kotlin/org/microg/gms/asterism/core/AsterismConsentResolverTest.kt @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.asterism.core + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.microg.gms.constellation.core.proto.AsterismClient +import org.microg.gms.constellation.core.proto.Consent +import org.microg.gms.constellation.core.proto.ConsentVersion +import org.microg.gms.constellation.core.proto.GaiaConsent +import org.microg.gms.constellation.core.proto.GetConsentResponse +import org.microg.gms.constellation.core.proto.RcsConsent + +class AsterismConsentResolverTest { + + @Test + fun matchingGaiaConsentTakesPrecedence() { + val response = GetConsentResponse( + rcs_consent = RcsConsent( + consent = Consent.CONSENTED, + consent_version = ConsentVersion.RCS_DEFAULT_ON_OUT_OF_BOX + ), + gaia_consents = listOf( + GaiaConsent( + asterism_client = AsterismClient.RCS, + consent = Consent.NO_CONSENT, + consent_version = ConsentVersion.RCS_CONSENT + ) + ) + ) + + assertEquals( + Consent.NO_CONSENT to ConsentVersion.RCS_CONSENT, + resolveAsterismConsent(response, AsterismClient.RCS) + ) + } + + @Test + fun rcsConsentIsUsedWhenMatchingGaiaConsentIsAbsent() { + val response = GetConsentResponse( + rcs_consent = RcsConsent( + consent = Consent.CONSENTED, + consent_version = ConsentVersion.RCS_DEFAULT_ON_OUT_OF_BOX + ) + ) + + assertEquals( + Consent.CONSENTED to ConsentVersion.RCS_DEFAULT_ON_OUT_OF_BOX, + resolveAsterismConsent(response, AsterismClient.RCS) + ) + } + + @Test + fun unrelatedGaiaConsentDoesNotMaskRcsConsent() { + val response = GetConsentResponse( + rcs_consent = RcsConsent( + consent = Consent.CONSENTED, + consent_version = ConsentVersion.RCS_CONSENT + ), + gaia_consents = listOf( + GaiaConsent( + asterism_client = AsterismClient.CONSTELLATION, + consent = Consent.NO_CONSENT, + consent_version = ConsentVersion.CONSENT_VERSION_UNSPECIFIED + ) + ) + ) + + assertEquals( + Consent.CONSENTED to ConsentVersion.RCS_CONSENT, + resolveAsterismConsent(response, AsterismClient.RCS) + ) + } + + @Test + fun rcsConsentIsNotAppliedToNonRcsClients() { + val response = GetConsentResponse( + rcs_consent = RcsConsent( + consent = Consent.CONSENTED, + consent_version = ConsentVersion.RCS_CONSENT + ) + ) + + assertEquals( + Consent.NO_CONSENT to ConsentVersion.CONSENT_VERSION_UNSPECIFIED, + resolveAsterismConsent(response, AsterismClient.CONSTELLATION) + ) + } + + @Test + fun noConsentDataFallsBackToNoConsent() { + assertEquals( + Consent.NO_CONSENT to ConsentVersion.CONSENT_VERSION_UNSPECIFIED, + resolveAsterismConsent(GetConsentResponse(), AsterismClient.RCS) + ) + } + + @Test + fun unknownRcsConsentIsTreatedAsMissing() { + val response = GetConsentResponse( + rcs_consent = RcsConsent( + consent = Consent.CONSENT_UNKNOWN, + consent_version = ConsentVersion.CONSENT_VERSION_UNSPECIFIED + ) + ) + + assertEquals( + Consent.NO_CONSENT to ConsentVersion.CONSENT_VERSION_UNSPECIFIED, + resolveAsterismConsent(response, AsterismClient.RCS) + ) + } +} diff --git a/play-services-constellation/core/build.gradle b/play-services-constellation/core/build.gradle index 348cd202cf..3e06645580 100644 --- a/play-services-constellation/core/build.gradle +++ b/play-services-constellation/core/build.gradle @@ -57,6 +57,7 @@ dependencies { api project(':play-services-constellation') implementation project(':play-services-base-core') + implementation project(':play-services-api') implementation project(':play-services-iid') implementation project(':play-services-auth-base') diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/ConstellationApiService.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/ConstellationApiService.kt index e0c76a4b42..fabfddfdc2 100644 --- a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/ConstellationApiService.kt +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/ConstellationApiService.kt @@ -28,6 +28,11 @@ private const val TAG = "C11NApiService" class ConstellationApiService : BaseService(TAG, GmsService.CONSTELLATION) { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + override fun onCreate() { + super.onCreate() + RpcClient.initialize(this) + } + override fun handleServiceRequest( callback: IGmsCallbacks?, request: GetServiceRequest?, diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/RpcClient.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/RpcClient.kt index 5513630793..4d5ebe7ead 100644 --- a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/RpcClient.kt +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/RpcClient.kt @@ -1,13 +1,35 @@ package org.microg.gms.constellation.core +import android.content.Context +import android.util.Log import com.squareup.wire.GrpcClient import okhttp3.OkHttpClient +import okhttp3.Request import org.microg.gms.common.Constants import org.microg.gms.constellation.core.proto.PhoneDeviceVerificationClient import org.microg.gms.constellation.core.proto.PhoneNumberClient import java.util.concurrent.TimeUnit +private const val TAG = "ConstellationRpcClient" + +internal fun addSpatulaHeader(request: Request, spatulaHeader: String?): Request { + if (spatulaHeader.isNullOrBlank()) return request + return request.newBuilder().header("X-Goog-Spatula", spatulaHeader).build() +} + object RpcClient { + @Volatile + private var spatulaHeaderProvider: SpatulaHeaderProvider? = null + + fun initialize(context: Context) { + if (spatulaHeaderProvider != null) return + synchronized(this) { + if (spatulaHeaderProvider == null) { + spatulaHeaderProvider = AppCertSpatulaHeaderProvider(context.applicationContext) + } + } + } + private val client: OkHttpClient = OkHttpClient.Builder() .readTimeout(60, TimeUnit.SECONDS) .addInterceptor { chain -> @@ -16,7 +38,13 @@ object RpcClient { .header("X-Goog-Api-Key", "AIzaSyAP-gfH3qvi6vgHZbSYwQ_XHqV_mXHhzIk") .header("X-Android-Package", Constants.GMS_PACKAGE_NAME) .header("X-Android-Cert", Constants.GMS_PACKAGE_SIGNATURE_SHA1.uppercase()) - chain.proceed(builder.build()) + val spatulaHeader = try { + spatulaHeaderProvider?.getSpatulaHeader(Constants.GMS_PACKAGE_NAME) + } catch (e: Exception) { + Log.w(TAG, "Unable to obtain X-Goog-Spatula", e) + null + } + chain.proceed(addSpatulaHeader(builder.build(), spatulaHeader)) } .build() diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/SpatulaHeaderProvider.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/SpatulaHeaderProvider.kt new file mode 100644 index 0000000000..4a52366759 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/SpatulaHeaderProvider.kt @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation.core + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.os.SystemClock +import android.util.Log +import com.google.android.gms.auth.appcert.IAppCertService +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +internal const val APP_CERT_SERVICE_ACTION = "com.google.android.gms.auth.be.appcert.AppCertService" +private const val TAG = "SpatulaHeaderProvider" +private const val BIND_TIMEOUT_SECONDS = 60L +private const val CACHE_TTL_MS = 30L * 60L * 1000L + +internal interface SpatulaHeaderProvider { + fun getSpatulaHeader(packageName: String): String? +} + +// Bind AppCertService instead of depending on play-services-core. +internal class AppCertSpatulaHeaderProvider( + private val context: Context +) : SpatulaHeaderProvider { + @Volatile + private var cachedHeader: String? = null + + @Volatile + private var cachedAtElapsedMs: Long = 0L + + private val lock = Any() + + override fun getSpatulaHeader(packageName: String): String? { + val cached = cachedHeader + if (!cached.isNullOrBlank() && !isCacheExpired()) { + return cached + } + synchronized(lock) { + val lockedCache = cachedHeader + if (!lockedCache.isNullOrBlank() && !isCacheExpired()) { + return lockedCache + } + val header = fetchFromAppCertService(packageName) + if (!header.isNullOrBlank()) { + cachedHeader = header + cachedAtElapsedMs = SystemClock.elapsedRealtime() + } + return header + } + } + + private fun isCacheExpired(): Boolean { + return SystemClock.elapsedRealtime() - cachedAtElapsedMs >= CACHE_TTL_MS + } + + private fun fetchFromAppCertService(packageName: String): String? { + val serviceQueue = LinkedBlockingQueue(1) + val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + service?.let { serviceQueue.offer(IAppCertService.Stub.asInterface(it)) } + } + + override fun onServiceDisconnected(name: ComponentName?) = Unit + } + val intent = Intent(APP_CERT_SERVICE_ACTION).setPackage(context.packageName) + val bound = try { + context.bindService(intent, connection, Context.BIND_AUTO_CREATE) + } catch (e: Exception) { + Log.w(TAG, "Unable to bind AppCertService", e) + false + } + if (!bound) return null + + return try { + val service = serviceQueue.poll(BIND_TIMEOUT_SECONDS, TimeUnit.SECONDS) ?: return null + service.getSpatulaHeader(packageName) + } catch (e: Exception) { + Log.w(TAG, "AppCertService.getSpatulaHeader failed", e) + null + } finally { + runCatching { context.unbindService(connection) } + } + } +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/Ts43Verifier.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/Ts43Verifier.kt index e9b309d7d1..50f108d143 100644 --- a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/Ts43Verifier.kt +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/Ts43Verifier.kt @@ -298,7 +298,7 @@ private fun buildOdsaRequestPayload( requestType = Ts43ChallengeResponseError.RequestType.TS43_REQUEST_TYPE_AUTH_API ) - val akaResponse = eapAkaService.performSimAkaAuth(eapRelayPacket, imsi, mccMnc) + val akaResponse = eapAkaService.performSimAkaAuth(eapRelayPacket, eapId) ?: return null val postBody = JSONObject().put("eap-relay-packet", akaResponse).toString() diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/EapAkaService.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/EapAkaService.kt index 47fae2cb31..ed64dcf00e 100644 --- a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/EapAkaService.kt +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/EapAkaService.kt @@ -34,7 +34,7 @@ class EapAkaService(private val telephonyManager: TelephonyManager) { } @RequiresApi(Build.VERSION_CODES.N) - fun performSimAkaAuth(eapRelayBase64: String, imsi: String, mccMnc: String): String? { + fun performSimAkaAuth(eapRelayBase64: String, identity: String): String? { val eapPacket = Base64.decode(eapRelayBase64, Base64.DEFAULT) if (eapPacket.size < 12) return null @@ -101,7 +101,6 @@ class EapAkaService(private val telephonyManager: TelephonyManager) { val ck = extractTlv(1 + res.size + 1, iccBytes) ?: return null val ik = extractTlv(1 + res.size + 1 + ck.size + 1, iccBytes) ?: return null - val identity = buildEapId(mccMnc, imsi) val identityBytes = identity.toByteArray(StandardCharsets.UTF_8) val keys = Fips186Prf.deriveKeys(identityBytes, ik, ck) @@ -127,19 +126,8 @@ class EapAkaService(private val telephonyManager: TelephonyManager) { } } - fun buildEapId(mccMnc: String, imsi: String, realm: String? = null): String { - val mcc = mccMnc.substring(0, 3) - var mnc = mccMnc.substring(3) - if (mnc.length == 2) mnc = "0$mnc" // Zero-pad 2-digit MNCs - val defaultRealm = "nai.epc.mnc$mnc.mcc$mcc.3gppnetwork.org" - val resolvedRealm = when { - realm.isNullOrBlank() -> defaultRealm - realm == "nai.epc" -> defaultRealm - realm.contains(".mnc") && realm.contains(".mcc") && realm.contains("3gppnetwork.org") -> realm - else -> realm - } - return "0$imsi@$resolvedRealm" - } + fun buildEapId(mccMnc: String, imsi: String, realm: String? = null): String = + buildEapIdentity(mccMnc, imsi, realm) private fun extractTlv(index: Int, data: ByteArray): ByteArray? { if (index >= data.size) return null @@ -209,3 +197,17 @@ class EapAkaService(private val telephonyManager: TelephonyManager) { null } } + +internal fun buildEapIdentity(mccMnc: String, imsi: String, realm: String? = null): String { + val mcc = mccMnc.substring(0, 3) + var mnc = mccMnc.substring(3) + if (mnc.length == 2) mnc = "0$mnc" // Zero-pad 2-digit MNCs + val defaultRealm = "nai.epc.mnc$mnc.mcc$mcc.3gppnetwork.org" + val resolvedRealm = when { + realm.isNullOrBlank() -> defaultRealm + realm == "nai.epc" -> defaultRealm + realm.contains(".mnc") && realm.contains(".mcc") && realm.contains("3gppnetwork.org") -> realm + else -> realm + } + return "0$imsi@$resolvedRealm" +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/ServiceEntitlementExtension.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/ServiceEntitlementExtension.kt index f5966f6691..6fc5060b46 100644 --- a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/ServiceEntitlementExtension.kt +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/ServiceEntitlementExtension.kt @@ -25,6 +25,9 @@ fun ServiceEntitlementRequest.builder( null }, terminalId = telephonyManager.imei, + terminalVendor = Build.MANUFACTURER.orEmpty(), + terminalModel = Build.MODEL.orEmpty(), + terminalSoftwareVersion = Build.VERSION.RELEASE.orEmpty(), groupIdLevel1 = runCatching { telephonyManager.groupIdLevel1 }.getOrNull(), eapId = eapId, appIds = appIds, @@ -34,9 +37,9 @@ fun ServiceEntitlementRequest.builder( fun ServiceEntitlementRequest.userAgent(context: Context): String { val packageVersion = context.packageManager.getPackageInfo(context.packageName, 0).versionName.orEmpty() - val vendor = terminal_vendor.take(4) - val model = terminal_model.take(10) - val swVersion = terminal_software_version.take(20) + val vendor = resolveTerminalValue(terminal_vendor, Build.MANUFACTURER.orEmpty(), 4) + val model = resolveTerminalValue(terminal_model, Build.MODEL.orEmpty(), 10) + val swVersion = resolveTerminalValue(terminal_software_version, Build.VERSION.RELEASE.orEmpty(), 20) return "PRD-TS43 term-$vendor/$model /$packageVersion OS-Android/$swVersion" } @@ -52,6 +55,9 @@ fun OdsaOperation.builder( null }, terminalId = telephonyManager.imei, + terminalVendor = Build.MANUFACTURER.orEmpty(), + terminalModel = Build.MODEL.orEmpty(), + terminalSoftwareVersion = Build.VERSION.RELEASE.orEmpty(), groupIdLevel1 = runCatching { telephonyManager.groupIdLevel1 }.getOrNull(), eapId = "", // Not needed for ODSA appIds = appIds, @@ -63,6 +69,9 @@ class ServiceEntitlementBuilder( private val imsi: String, private val iccid: String?, private val terminalId: String?, + private val terminalVendor: String, + private val terminalModel: String, + private val terminalSoftwareVersion: String, private val groupIdLevel1: String?, private val eapId: String, private val appIds: List, @@ -73,9 +82,9 @@ class ServiceEntitlementBuilder( val baseUrl = entitlementUrl.toHttpUrl() // GMS truncates these fields: vendor (4), model (10), sw_version (20) - val vendor = req.terminal_vendor.take(4) - val model = req.terminal_model.take(10) - val swVersion = req.terminal_software_version.take(20) + val vendor = resolveTerminalValue(req.terminal_vendor, terminalVendor, 4) + val model = resolveTerminalValue(req.terminal_model, terminalModel, 10) + val swVersion = resolveTerminalValue(req.terminal_software_version, terminalSoftwareVersion, 20) return baseUrl.newBuilder().apply { when { @@ -92,7 +101,8 @@ class ServiceEntitlementBuilder( addQueryParameter("EAP_ID", eapId) } } - addQueryParameter("terminal_id", terminalId ?: req.terminal_id) + val resolvedTerminalId = req.terminal_id.takeIf { it.isNotEmpty() } ?: terminalId.orEmpty() + addQueryParameter("terminal_id", resolvedTerminalId) if (req.gid1.isNotEmpty()) { addQueryParameter("GID1", req.gid1) } else if ((req.entitlement_version.toBigDecimalOrNull()?.toInt() ?: 0) >= 12) { @@ -203,3 +213,10 @@ class ServiceEntitlementBuilder( }.build() } } + +internal fun resolveTerminalValue(requestValue: String, deviceFallback: String, maxLength: Int): String { + return requestValue + .takeIf { it.isNotEmpty() } + ?.take(maxLength) + ?: deviceFallback.take(maxLength) +} diff --git a/play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/RpcClientTest.kt b/play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/RpcClientTest.kt new file mode 100644 index 0000000000..ffadd5a22b --- /dev/null +++ b/play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/RpcClientTest.kt @@ -0,0 +1,37 @@ +package org.microg.gms.constellation.core + +import okhttp3.Request +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class RpcClientTest { + @Test + fun usesExistingAppCertServiceAction() { + assertEquals("com.google.android.gms.auth.be.appcert.AppCertService", APP_CERT_SERVICE_ACTION) + } + + @Test + fun addsSpatulaHeaderWhenAvailable() { + val request = Request.Builder().url("https://example.invalid/").build() + val result = addSpatulaHeader(request, "spatula-value") + + assertEquals("spatula-value", result.header("X-Goog-Spatula")) + } + + @Test + fun omitsSpatulaHeaderWhenUnavailable() { + val request = Request.Builder().url("https://example.invalid/").build() + val result = addSpatulaHeader(request, null) + + assertNull(result.header("X-Goog-Spatula")) + } + + @Test + fun omitsBlankSpatulaHeader() { + val request = Request.Builder().url("https://example.invalid/").build() + val result = addSpatulaHeader(request, " ") + + assertNull(result.header("X-Goog-Spatula")) + } +} diff --git a/play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/verification/ts43/EapAkaIdentityTest.kt b/play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/verification/ts43/EapAkaIdentityTest.kt new file mode 100644 index 0000000000..ba3bcdb3c9 --- /dev/null +++ b/play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/verification/ts43/EapAkaIdentityTest.kt @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation.core.verification.ts43 + +import org.junit.Assert.assertEquals +import org.junit.Test + +class EapAkaIdentityTest { + @Test + fun buildEapIdentity_usesDefaultRealm() { + assertEquals( + "0234107813240779@nai.epc.mnc010.mcc234.3gppnetwork.org", + buildEapIdentity("23410", "234107813240779") + ) + } + + @Test + fun buildEapIdentity_zeroPadsTwoDigitMnc() { + assertEquals( + "0310123456789012@nai.epc.mnc010.mcc310.3gppnetwork.org", + buildEapIdentity("31010", "310123456789012") + ) + } + + @Test + fun buildEapIdentity_preservesChallengeRealm() { + assertEquals( + "0234107813240779@wlan.mnc010.mcc234.3gppnetwork.org", + buildEapIdentity( + "23410", + "234107813240779", + "wlan.mnc010.mcc234.3gppnetwork.org" + ) + ) + } + + @Test + fun buildEapIdentity_expandsBareNaiEpcRealm() { + assertEquals( + "0234107813240779@nai.epc.mnc010.mcc234.3gppnetwork.org", + buildEapIdentity("23410", "234107813240779", "nai.epc") + ) + } +} diff --git a/play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/verification/ts43/ServiceEntitlementBuilderTest.kt b/play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/verification/ts43/ServiceEntitlementBuilderTest.kt new file mode 100644 index 0000000000..ebc347f3e1 --- /dev/null +++ b/play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/verification/ts43/ServiceEntitlementBuilderTest.kt @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation.core.verification.ts43 + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.microg.gms.constellation.core.proto.ServiceEntitlementRequest + +class ServiceEntitlementBuilderTest { + + @Test + fun buildBaseUrl_explicitTerminalIdOverridesDeviceFallback() { + val request = ServiceEntitlementRequest(terminal_id = "request-terminal-id") + val url = builder(request, deviceTerminalId = "device-imei") + .buildBaseUrl("https://example.com/entitlement") + + assertEquals("request-terminal-id", url.queryParameter("terminal_id")) + } + + @Test + fun buildBaseUrl_emptyTerminalIdUsesDeviceFallback() { + val request = ServiceEntitlementRequest(terminal_id = "") + val url = builder(request, deviceTerminalId = "device-imei") + .buildBaseUrl("https://example.com/entitlement") + + assertEquals("device-imei", url.queryParameter("terminal_id")) + } + + private fun builder( + request: ServiceEntitlementRequest, + deviceTerminalId: String? + ) = ServiceEntitlementBuilder( + imsi = "", + iccid = null, + terminalId = deviceTerminalId, + terminalVendor = "", + terminalModel = "", + terminalSoftwareVersion = "", + groupIdLevel1 = null, + eapId = "", + appIds = listOf("ap2014"), + req = request + ) +} diff --git a/play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/verification/ts43/ServiceEntitlementTerminalMetadataTest.kt b/play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/verification/ts43/ServiceEntitlementTerminalMetadataTest.kt new file mode 100644 index 0000000000..383334f74f --- /dev/null +++ b/play-services-constellation/core/src/test/kotlin/org/microg/gms/constellation/core/verification/ts43/ServiceEntitlementTerminalMetadataTest.kt @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.constellation.core.verification.ts43 + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.microg.gms.constellation.core.proto.ServiceEntitlementRequest + +class ServiceEntitlementTerminalMetadataTest { + + @Test + fun buildBaseUrl_usesDeviceMetadataWhenRequestOmitsIt() { + val url = builder(ServiceEntitlementRequest()).buildBaseUrl("https://example.com/entitlement") + + assertEquals("Acme", url.queryParameter("terminal_vendor")) + assertEquals("Model-1234", url.queryParameter("terminal_model")) + assertEquals("Android-Release-1234", url.queryParameter("terminal_sw_version")) + } + + @Test + fun buildBaseUrl_explicitRequestMetadataTakesPrecedenceAndIsTruncated() { + val request = ServiceEntitlementRequest( + terminal_vendor = "RequestVendor", + terminal_model = "RequestModelLong", + terminal_software_version = "RequestSoftwareVersionThatIsLong" + ) + val url = builder(request).buildBaseUrl("https://example.com/entitlement") + + assertEquals("Requ", url.queryParameter("terminal_vendor")) + assertEquals("RequestMod", url.queryParameter("terminal_model")) + assertEquals("RequestSoftwareVersi", url.queryParameter("terminal_sw_version")) + } + + @Test + fun resolveTerminalValue_fallsBackBeforeTruncating() { + assertEquals("Acme", resolveTerminalValue("", "AcmeDevices", 4)) + assertEquals("Requ", resolveTerminalValue("RequestVendor", "AcmeDevices", 4)) + } + + private fun builder(request: ServiceEntitlementRequest) = ServiceEntitlementBuilder( + imsi = "", + iccid = null, + terminalId = "device-imei", + terminalVendor = "AcmeDevices", + terminalModel = "Model-123456789", + terminalSoftwareVersion = "Android-Release-123456789", + groupIdLevel1 = null, + eapId = "", + appIds = listOf("ap2014"), + req = request + ) +} diff --git a/play-services-core/build.gradle b/play-services-core/build.gradle index e7464322db..085b80a2e1 100644 --- a/play-services-core/build.gradle +++ b/play-services-core/build.gradle @@ -113,6 +113,8 @@ dependencies { implementation "com.google.guava:guava:$guavaVersion" implementation "com.google.crypto.tink:tink-android:$tinkVersion" implementation "io.coil-kt:coil-compose:2.4.0" + + testImplementation 'junit:junit:4.13.2' } android { @@ -156,6 +158,9 @@ android { main { java.srcDirs += 'src/main/kotlin' } + test { + java.srcDirs += 'src/test/kotlin' + } } lintOptions { diff --git a/play-services-core/src/main/kotlin/org/microg/gms/auth/appcert/AppCertManager.kt b/play-services-core/src/main/kotlin/org/microg/gms/auth/appcert/AppCertManager.kt index 96658b4849..56e78a8244 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/auth/appcert/AppCertManager.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/auth/appcert/AppCertManager.kt @@ -39,6 +39,15 @@ import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec import kotlin.random.Random +internal fun buildFallbackSpatulaHeaderProto( + packageName: String, + packageCertificateHash: String?, + androidId: Long +): SpatulaHeaderProto = SpatulaHeaderProto( + packageInfo = SpatulaHeaderProto.PackageInfo(packageName, packageCertificateHash), + deviceId = androidId +) + class AppCertManager(private val context: Context) { private val queue = singleInstanceOf { Volley.newRequestQueue(context.applicationContext) } @@ -171,11 +180,7 @@ class AppCertManager(private val context: Context) { } else { Log.d(TAG, "Using fallback spatula header based on Android ID") val androidId = getSettings(context, CheckIn.getContentUri(context), arrayOf(CheckIn.ANDROID_ID)) { cursor: Cursor -> cursor.getLong(0) } - SpatulaHeaderProto( - packageInfo = SpatulaHeaderProto.PackageInfo(packageName, packageCertificateHash), - deviceId = androidId - ) - return null // TODO + buildFallbackSpatulaHeaderProto(packageName, packageCertificateHash, androidId) } Log.d(TAG, "Spatula Header: $proto") return Base64.encodeToString(proto.encode(), Base64.NO_WRAP) diff --git a/play-services-core/src/test/kotlin/org/microg/gms/auth/appcert/AppCertManagerTest.kt b/play-services-core/src/test/kotlin/org/microg/gms/auth/appcert/AppCertManagerTest.kt new file mode 100644 index 0000000000..e15c3f3b33 --- /dev/null +++ b/play-services-core/src/test/kotlin/org/microg/gms/auth/appcert/AppCertManagerTest.kt @@ -0,0 +1,22 @@ +package org.microg.gms.auth.appcert + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AppCertManagerTest { + @Test + fun androidIdFallbackProducesSpatulaProto() { + val proto = buildFallbackSpatulaHeaderProto( + packageName = "com.google.android.gms", + packageCertificateHash = "certificate-hash", + androidId = 0x1122334455667788L + ) + + assertEquals("com.google.android.gms", proto.packageInfo?.packageName) + assertEquals("certificate-hash", proto.packageInfo?.packageCertificateHash) + assertEquals(0x1122334455667788L, proto.deviceId) + assertNull(proto.hmac) + assertNull(proto.keyCert) + } +}