diff --git a/play-services-constellation/build.gradle b/play-services-constellation/build.gradle new file mode 100644 index 0000000000..f2bacc16a6 --- /dev/null +++ b/play-services-constellation/build.gradle @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +apply plugin: 'com.android.library' +apply plugin: 'maven-publish' +apply plugin: 'signing' + +android { + namespace "org.microg.gms.constellation" + + compileSdkVersion androidCompileSdk + buildToolsVersion "$androidBuildVersionTools" + + buildFeatures { + aidl = true + } + + defaultConfig { + versionName version + minSdkVersion androidMinSdk + targetSdkVersion androidTargetSdk + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +apply from: '../gradle/publish-android.gradle' + +description = 'microG implementation of play-services-constellation' + +dependencies { + implementation project(':play-services-basement') + + annotationProcessor project(":safe-parcel-processor") +} diff --git a/play-services-constellation/core/build.gradle b/play-services-constellation/core/build.gradle new file mode 100644 index 0000000000..cd7df4f4db --- /dev/null +++ b/play-services-constellation/core/build.gradle @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: 2026, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +apply plugin: 'com.android.library' +apply plugin: 'maven-publish' +apply plugin: 'kotlin-android' +apply plugin: 'signing' +apply plugin: 'com.squareup.wire' + +android { + namespace "org.microg.gms.constellation.core" + + compileSdkVersion androidCompileSdk + buildToolsVersion "$androidBuildVersionTools" + + defaultConfig { + versionName version + minSdkVersion androidMinSdk + targetSdkVersion androidTargetSdk + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = 1.8 + } +} + +wire { + kotlin { + rpcRole = 'client' + rpcCallStyle = 'suspending' + } +} + +apply from: '../../gradle/publish-android.gradle' + +description = 'microG service implementation for play-services-constellation' + +dependencies { + api project(':play-services-constellation') + + implementation project(':play-services-base-core') + implementation project(':play-services-iid') + implementation project(':play-services-auth-base') + + implementation project(':play-services-droidguard') + implementation project(':play-services-tasks-ktx') + + implementation "com.squareup.okhttp3:okhttp:$okHttpVersion" + api "com.squareup.wire:wire-runtime:$wireVersion" + api "com.squareup.wire:wire-grpc-client:$wireVersion" +} diff --git a/play-services-constellation/core/src/main/AndroidManifest.xml b/play-services-constellation/core/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..943034c187 --- /dev/null +++ b/play-services-constellation/core/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/play-services-constellation/core/src/main/kotlin/com/google/android/gms/constellation/GetPnvCapabilities.kt b/play-services-constellation/core/src/main/kotlin/com/google/android/gms/constellation/GetPnvCapabilities.kt new file mode 100644 index 0000000000..79565ca454 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/com/google/android/gms/constellation/GetPnvCapabilities.kt @@ -0,0 +1,29 @@ +package com.google.android.gms.constellation + +import com.google.android.gms.constellation.GetPnvCapabilitiesResponse.VerificationCapability + +enum class VerificationStatus(val value: Int) { + SUPPORTED(1), + UNSUPPORTED_CARRIER(2), + UNSUPPORTED_API_VERSION(3), + UNSUPPORTED_SIM_NOT_READY(4); + + companion object { + fun fromInt(value: Int): VerificationStatus = when (value) { + 2 -> UNSUPPORTED_CARRIER + 3 -> UNSUPPORTED_API_VERSION + 4 -> UNSUPPORTED_SIM_NOT_READY + else -> SUPPORTED + } + } +} + +fun verificationCapability( + verificationMethod: Int, + status: VerificationStatus +): VerificationCapability { + return VerificationCapability(verificationMethod, status.value) +} + +val VerificationCapability.status: VerificationStatus + get() = VerificationStatus.fromInt(statusValue) \ No newline at end of file diff --git a/play-services-constellation/core/src/main/kotlin/com/google/android/gms/constellation/VerifyPhoneNumber.kt b/play-services-constellation/core/src/main/kotlin/com/google/android/gms/constellation/VerifyPhoneNumber.kt new file mode 100644 index 0000000000..95c87e2688 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/com/google/android/gms/constellation/VerifyPhoneNumber.kt @@ -0,0 +1,6 @@ +package com.google.android.gms.constellation + +import org.microg.gms.constellation.core.proto.VerificationMethod + +val VerifyPhoneNumberRequest.verificationMethods: List + get() = verificationMethodsValues.mapNotNull { VerificationMethod.fromValue(it) } \ No newline at end of file diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/AuthManager.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/AuthManager.kt new file mode 100644 index 0000000000..6f2a151427 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/AuthManager.kt @@ -0,0 +1,120 @@ +package org.microg.gms.constellation.core + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.util.Base64 +import androidx.annotation.RequiresApi +import androidx.core.content.edit +import com.google.android.gms.iid.InstanceID +import com.squareup.wire.Instant +import java.nio.charset.StandardCharsets +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Signature +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec + +val Context.authManager: AuthManager get() = AuthManager.get(this) + +class AuthManager private constructor(context: Context) { + private val context = context.applicationContext + private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + companion object { + private const val PREFS_NAME = "constellation_prefs" + private const val KEY_PRIVATE = "private_key" + private const val KEY_PUBLIC = "public_key" + + // This is safe as the Context is immediately converted to the application context. + @SuppressLint("StaticFieldLeak") + @Volatile + private var instance: AuthManager? = null + + fun get(context: Context): AuthManager = instance ?: synchronized(this) { + instance ?: AuthManager(context).also { instance = it } + } + } + + // GMS signing format: {iidToken}:{seconds}:{nanos} + fun signIidTokenCompat(iidToken: String): Pair { + val currentTimeMillis = System.currentTimeMillis() + + val epochSecond = currentTimeMillis / 1000 + val nano = (currentTimeMillis % 1000) * 1_000_000 + + val content = "$iidToken:$epochSecond:$nano" + return sign(content) to currentTimeMillis + } + + @RequiresApi(Build.VERSION_CODES.O) + fun signIidToken(iidToken: String): Pair { + val (bytes, millis) = signIidTokenCompat(iidToken) + return bytes to Instant.ofEpochMilli(millis) + } + + fun getIidToken(projectNumber: String? = null): String { + return try { + val sender = projectNumber ?: IidTokenPhenotypes.DEFAULT_PROJECT_NUMBER + InstanceID.getInstance(context).getToken(sender, "GCM") + } catch (_: Exception) { + "" + } + } + + fun getOrCreateKeyPair(): KeyPair { + val privateKeyStr = sharedPrefs.getString(KEY_PRIVATE, null) + val publicKeyStr = sharedPrefs.getString(KEY_PUBLIC, null) + + if (privateKeyStr != null && publicKeyStr != null) { + try { + val kf = KeyFactory.getInstance("EC") + val privateKey = kf.generatePrivate( + PKCS8EncodedKeySpec( + Base64.decode( + privateKeyStr, + Base64.DEFAULT + ) + ) + ) + val publicKey = kf.generatePublic( + X509EncodedKeySpec( + Base64.decode( + publicKeyStr, + Base64.DEFAULT + ) + ) + ) + return KeyPair(publicKey, privateKey) + } catch (_: Exception) { + // Fall through to regeneration on failure + } + } + + val kpg = KeyPairGenerator.getInstance("EC") + kpg.initialize(256) + val kp = kpg.generateKeyPair() + + sharedPrefs.edit { + putString(KEY_PRIVATE, Base64.encodeToString(kp.private.encoded, Base64.NO_WRAP)) + putString(KEY_PUBLIC, Base64.encodeToString(kp.public.encoded, Base64.NO_WRAP)) + } + + return kp + } + + fun sign(content: String): ByteArray { + return try { + val kp = getOrCreateKeyPair() + val signature = Signature.getInstance("SHA256withECDSA") + signature.initSign(kp.private) + signature.update(content.toByteArray(StandardCharsets.UTF_8)) + signature.sign() + } catch (_: Exception) { + ByteArray(0) + } + } + + fun getFid(): String = InstanceID.getInstance(context).id +} \ No newline at end of file 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 new file mode 100644 index 0000000000..39334ec569 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/ConstellationApiService.kt @@ -0,0 +1,129 @@ +package org.microg.gms.constellation.core + +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.util.Log +import com.google.android.gms.common.Feature +import com.google.android.gms.common.api.ApiMetadata +import com.google.android.gms.common.internal.ConnectionInfo +import com.google.android.gms.common.internal.GetServiceRequest +import com.google.android.gms.common.internal.IGmsCallbacks +import com.google.android.gms.constellation.GetIidTokenRequest +import com.google.android.gms.constellation.GetPnvCapabilitiesRequest +import com.google.android.gms.constellation.VerifyPhoneNumberRequest +import com.google.android.gms.constellation.internal.IConstellationApiService +import com.google.android.gms.constellation.internal.IConstellationCallbacks +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.microg.gms.BaseService +import org.microg.gms.common.GmsService +import org.microg.gms.common.PackageUtils + +private const val TAG = "C11NApiService" + +class ConstellationApiService : BaseService(TAG, GmsService.CONSTELLATION) { + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun handleServiceRequest( + callback: IGmsCallbacks?, + request: GetServiceRequest?, + service: GmsService? + ) { + val packageName = PackageUtils.getAndCheckCallingPackage(this, request?.packageName) + if (!PackageUtils.isGooglePackage(this, packageName)) { + throw SecurityException("$packageName is not a Google package") + } + callback!!.onPostInitCompleteWithConnectionInfo( + 0, + ConstellationApiServiceImpl(this, packageName, serviceScope).asBinder(), + ConnectionInfo().apply { + features = arrayOf( + Feature("asterism_consent", 3), + Feature("one_time_verification", 1), + Feature("carrier_auth", 1), + Feature("verify_phone_number", 2), + Feature("get_iid_token", 1), + Feature("get_pnv_capabilities", 1), + Feature("ts43", 1), + Feature("verify_phone_number_local_read", 1) + ) + }) + } + + override fun onDestroy() { + super.onDestroy() + serviceScope.cancel("ConstellationApiService destroyed") + } +} + +class ConstellationApiServiceImpl( + private val context: Context, + private val packageName: String?, + private val serviceScope: CoroutineScope +) : IConstellationApiService.Stub() { + override fun verifyPhoneNumberV1( + cb: IConstellationCallbacks?, + bundle: Bundle?, + apiMetadata: ApiMetadata? + ) { + Log.i( + TAG, + "verifyPhoneNumberV1(): mode=${bundle?.getInt("verification_mode")}, policy=${ + bundle?.getString("policy_id") + }" + ) + if (cb == null || bundle == null) return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + serviceScope.launch { handleVerifyPhoneNumberV1(context, cb, bundle, packageName) } + } + + override fun verifyPhoneNumberSingleUse( + cb: IConstellationCallbacks?, + bundle: Bundle?, + apiMetadata: ApiMetadata? + ) { + Log.i(TAG, "verifyPhoneNumberSingleUse()") + if (cb == null || bundle == null) return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + serviceScope.launch { handleVerifyPhoneNumberSingleUse(context, cb, bundle, packageName) } + } + + override fun verifyPhoneNumber( + cb: IConstellationCallbacks?, + request: VerifyPhoneNumberRequest?, + apiMetadata: ApiMetadata? + ) { + Log.i( + TAG, + "verifyPhoneNumber(): apiVersion=${request?.apiVersion}, policy=${request?.policyId}" + ) + if (cb == null || request == null) return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + serviceScope.launch { handleVerifyPhoneNumberRequest(context, cb, request, packageName) } + } + + override fun getIidToken( + cb: IConstellationCallbacks?, + request: GetIidTokenRequest?, + apiMetadata: ApiMetadata?, + ) { + Log.i(TAG, "getIidToken(): $request") + if (cb == null || request == null) return + serviceScope.launch { handleGetIidToken(context, cb, request) } + } + + override fun getPnvCapabilities( + cb: IConstellationCallbacks?, + request: GetPnvCapabilitiesRequest?, + apiMetadata: ApiMetadata?, + ) { + Log.i(TAG, "getPnvCapabilities(): $request") + if (cb == null || request == null) return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) return + serviceScope.launch { handleGetPnvCapabilities(context, cb, request) } + } +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/ConstellationStateStore.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/ConstellationStateStore.kt new file mode 100644 index 0000000000..ef2ebe25d1 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/ConstellationStateStore.kt @@ -0,0 +1,185 @@ +@file:RequiresApi(Build.VERSION_CODES.O) + +package org.microg.gms.constellation.core + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.util.Base64 +import androidx.annotation.RequiresApi +import androidx.core.content.edit +import com.squareup.wire.Instant +import okio.ByteString.Companion.toByteString +import org.microg.gms.constellation.core.proto.AsterismConsent.DeviceConsentVersion +import org.microg.gms.constellation.core.proto.Consent +import org.microg.gms.constellation.core.proto.ConsentSource +import org.microg.gms.constellation.core.proto.DroidguardToken +import org.microg.gms.constellation.core.proto.ProceedResponse +import org.microg.gms.constellation.core.proto.ServerTimestamp +import org.microg.gms.constellation.core.proto.SyncResponse +import org.microg.gms.constellation.core.proto.VerificationToken + +private const val STATE_PREFS_NAME = "constellation_prefs" +private const val TOKEN_PREFS_NAME = "com.google.android.gms.constellation" +private const val KEY_VERIFICATION_TOKENS = "verification_tokens_v1" +private const val KEY_DROIDGUARD_TOKEN = "droidguard_token" +private const val KEY_DROIDGUARD_TOKEN_TTL = "droidguard_token_ttl" +private const val KEY_NEXT_SYNC_TIMESTAMP_MS = "next_sync_timestamp_in_millis" +private const val KEY_PUBLIC_KEY_ACKED = "is_public_key_acked" +private const val KEY_PNVR_NOTICE_CONSENT = "pnvr_notice_consent" +private const val KEY_PNVR_NOTICE_SOURCE = "pnvr_notice_source" +private const val KEY_PNVR_NOTICE_VERSION = "pnvr_notice_version" +private const val KEY_PNVR_NOTICE_UPDATED_AT_MS = "pnvr_notice_updated_at_ms" + +object ConstellationStateStore { + fun loadVerificationTokens(context: Context): List { + val prefs = tokenPrefs(context) + val serialized = prefs.getString(KEY_VERIFICATION_TOKENS, null) ?: return emptyList() + return serialized.split(",").mapNotNull { entry -> + val parts = entry.split("|", limit = 2) + if (parts.size != 2) return@mapNotNull null + val tokenBytes = runCatching { + Base64.decode(parts[0], Base64.DEFAULT).toByteString() + }.getOrNull() ?: return@mapNotNull null + val expirationMillis = parts[1].toLongOrNull() ?: return@mapNotNull null + if (expirationMillis <= System.currentTimeMillis()) return@mapNotNull null + VerificationToken( + token = tokenBytes, + expiration_time = Instant.ofEpochMilli(expirationMillis) + ) + } + } + + fun storeSyncResponse(context: Context, response: SyncResponse) { + storeVerificationTokens(context, response.verification_tokens) + storeDroidGuardToken(context, response.droidguard_token) + storeNextSyncTime(context, response.next_sync_time) + } + + fun storeProceedResponse(context: Context, response: ProceedResponse) { + storeDroidGuardToken(context, response.droidguard_token) + storeNextSyncTime(context, response.next_sync_time) + } + + fun loadDroidGuardToken(context: Context): String? { + val statePrefs = statePrefs(context) + var token = statePrefs.getString(KEY_DROIDGUARD_TOKEN, null) + var expiration = statePrefs.getLong(KEY_DROIDGUARD_TOKEN_TTL, 0L) + + if (token.isNullOrBlank() || expiration == 0L) { + val legacyPrefs = tokenPrefs(context) + val legacyToken = legacyPrefs.getString(KEY_DROIDGUARD_TOKEN, null) + val legacyExpiration = legacyPrefs.getLong(KEY_DROIDGUARD_TOKEN_TTL, 0L) + if (!legacyToken.isNullOrBlank() && legacyExpiration > 0L) { + statePrefs.edit { + putString(KEY_DROIDGUARD_TOKEN, legacyToken) + putLong(KEY_DROIDGUARD_TOKEN_TTL, legacyExpiration) + } + token = legacyToken + expiration = legacyExpiration + } + } + + if (!token.isNullOrBlank() && expiration > System.currentTimeMillis()) { + return token + } + + if (!token.isNullOrBlank() || expiration > 0L) { + clearDroidGuardToken(context) + } + return null + } + + private fun storeCachedDroidGuardToken( + context: Context, + token: String, + expirationMillis: Long + ) { + if (token.isBlank() || expirationMillis <= System.currentTimeMillis()) return + statePrefs(context).edit { + putString(KEY_DROIDGUARD_TOKEN, token) + putLong(KEY_DROIDGUARD_TOKEN_TTL, expirationMillis) + } + } + + fun clearDroidGuardToken(context: Context) { + statePrefs(context).edit { + remove(KEY_DROIDGUARD_TOKEN) + remove(KEY_DROIDGUARD_TOKEN_TTL) + } + tokenPrefs(context).edit { + remove(KEY_DROIDGUARD_TOKEN) + remove(KEY_DROIDGUARD_TOKEN_TTL) + } + } + + fun isPublicKeyAcked(context: Context): Boolean { + return statePrefs(context).getBoolean(KEY_PUBLIC_KEY_ACKED, false) + } + + @SuppressLint("ApplySharedPref") + fun setPublicKeyAcked(context: Context, acked: Boolean) { + statePrefs(context).edit { putBoolean(KEY_PUBLIC_KEY_ACKED, acked) } + } + + fun loadPnvrNoticeConsent(context: Context): Consent { + val value = + statePrefs(context).getInt(KEY_PNVR_NOTICE_CONSENT, Consent.CONSENT_UNKNOWN.value) + return Consent.fromValue(value) ?: Consent.CONSENT_UNKNOWN + } + + fun storePnvrNotice( + context: Context, + consent: Consent, + source: ConsentSource, + version: DeviceConsentVersion + ) { + statePrefs(context).edit { + putInt(KEY_PNVR_NOTICE_CONSENT, consent.value) + putInt(KEY_PNVR_NOTICE_SOURCE, source.value) + putInt(KEY_PNVR_NOTICE_VERSION, version.value) + putLong(KEY_PNVR_NOTICE_UPDATED_AT_MS, System.currentTimeMillis()) + } + } + + private fun storeVerificationTokens(context: Context, tokens: List) { + if (tokens.isEmpty()) return + val filtered = tokens.filter { + (it.expiration_time?.toEpochMilli() ?: 0L) > System.currentTimeMillis() + } + if (filtered.isEmpty()) return + + val serialized = filtered.joinToString(",") { token -> + val encoded = Base64.encodeToString(token.token.toByteArray(), Base64.NO_WRAP) + val expiration = token.expiration_time?.toEpochMilli() ?: 0L + "$encoded|$expiration" + } + tokenPrefs(context).edit { putString(KEY_VERIFICATION_TOKENS, serialized) } + } + + private fun storeDroidGuardToken(context: Context, token: DroidguardToken?) { + val tokenValue = token?.token?.takeIf { it.isNotEmpty() } ?: return + val expiration = token.ttl?.toEpochMilli() ?: return + storeCachedDroidGuardToken(context, tokenValue, expiration) + } + + private fun storeNextSyncTime(context: Context, timestamp: ServerTimestamp?) { + val nextSyncDelayMillis = timestamp?.let(::nextSyncDelayMillis) ?: return + statePrefs(context).edit { + // GMS stores the next sync deadline as an absolute wall-clock timestamp + putLong(KEY_NEXT_SYNC_TIMESTAMP_MS, System.currentTimeMillis() + nextSyncDelayMillis) + } + } + + private fun nextSyncDelayMillis(timestamp: ServerTimestamp): Long { + val serverMillis = timestamp.timestamp?.toEpochMilli() ?: 0L + val localMillis = timestamp.now?.toEpochMilli() ?: 0L + return serverMillis - localMillis + } + + private fun statePrefs(context: Context) = + context.getSharedPreferences(STATE_PREFS_NAME, Context.MODE_PRIVATE) + + private fun tokenPrefs(context: Context) = + context.getSharedPreferences(TOKEN_PREFS_NAME, Context.MODE_PRIVATE) +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GServices.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GServices.kt new file mode 100644 index 0000000000..a8c3c64481 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GServices.kt @@ -0,0 +1,33 @@ +package org.microg.gms.constellation.core + +import android.content.ContentResolver +import android.net.Uri +import androidx.core.net.toUri + +// TODO: This is taken from vending-app, can we have a common client for GServices please? + +object GServices { + private val CONTENT_URI: Uri = "content://com.google.android.gsf.gservices".toUri() + + fun getString(resolver: ContentResolver, key: String, defaultValue: String?): String? { + var result = defaultValue + val cursor = resolver.query(CONTENT_URI, null, null, arrayOf(key), null) + cursor?.use { + if (cursor.moveToNext()) { + result = cursor.getString(1) + } + } + return result + } + + fun getLong(resolver: ContentResolver, key: String, defaultValue: Long): Long { + val result = getString(resolver, key, null) + if (result != null) { + try { + return result.toLong() + } catch (_: NumberFormatException) { + } + } + return defaultValue + } +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetIidToken.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetIidToken.kt new file mode 100644 index 0000000000..5ee8a6ac71 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetIidToken.kt @@ -0,0 +1,35 @@ +package org.microg.gms.constellation.core + +import android.content.Context +import android.util.Log +import com.google.android.gms.common.api.ApiMetadata +import com.google.android.gms.common.api.Status +import com.google.android.gms.constellation.GetIidTokenRequest +import com.google.android.gms.constellation.GetIidTokenResponse +import com.google.android.gms.constellation.internal.IConstellationCallbacks +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val TAG = "GetIidToken" + +suspend fun handleGetIidToken( + context: Context, + callbacks: IConstellationCallbacks, + request: GetIidTokenRequest +) = withContext(Dispatchers.IO) { + try { + val authManager = context.authManager + val iidToken = authManager.getIidToken(request.projectNumber?.toString()) + val fid = authManager.getFid() + val (signature, timestamp) = authManager.signIidTokenCompat(iidToken) + + callbacks.onIidTokenGenerated( + Status.SUCCESS, + GetIidTokenResponse(iidToken, fid, signature, timestamp), + ApiMetadata.DEFAULT + ) + } catch (e: Exception) { + Log.e(TAG, "getIidToken failed", e) + callbacks.onIidTokenGenerated(Status.INTERNAL_ERROR, null, ApiMetadata.DEFAULT) + } +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetPnvCapabilities.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetPnvCapabilities.kt new file mode 100644 index 0000000000..5aeacfcf12 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetPnvCapabilities.kt @@ -0,0 +1,114 @@ +package org.microg.gms.constellation.core + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.telephony.SubscriptionManager +import android.telephony.TelephonyManager +import android.util.Base64 +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.content.getSystemService +import com.google.android.gms.common.api.ApiMetadata +import com.google.android.gms.common.api.Status +import com.google.android.gms.constellation.GetPnvCapabilitiesRequest +import com.google.android.gms.constellation.GetPnvCapabilitiesResponse +import com.google.android.gms.constellation.GetPnvCapabilitiesResponse.SimCapability +import com.google.android.gms.constellation.VerificationStatus +import com.google.android.gms.constellation.internal.IConstellationCallbacks +import com.google.android.gms.constellation.verificationCapability +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.security.MessageDigest + +private const val TAG = "GetPnvCapabilities" + +@SuppressLint("HardwareIds") +@RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1) +suspend fun handleGetPnvCapabilities( + context: Context, + callbacks: IConstellationCallbacks, + request: GetPnvCapabilitiesRequest +) = withContext(Dispatchers.IO) { + try { + val baseTelephonyManager = + context.getSystemService() + ?: throw IllegalStateException("TelephonyManager unavailable") + val subscriptionManager = + context.getSystemService() + ?: throw IllegalStateException("SubscriptionManager unavailable") + val simCapabilities = subscriptionManager.activeSubscriptionInfoList + .orEmpty() + .filter { request.simSlotIndices.isEmpty() || it.simSlotIndex in request.simSlotIndices } + .map { info -> + val telephonyManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + baseTelephonyManager.createForSubscriptionId(info.subscriptionId) + } else { + baseTelephonyManager + } + + val carrierId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + telephonyManager.simCarrierId + } else { + 0 + } + + // GMS hardcodes public verification method 9 for the Firebase PNV TS43 capability path. + val verificationCapabilities = if (9 in request.verificationTypes) { + listOf( + verificationCapability( + 9, + when { + !GetPnvCapabilitiesApiPhenotype.FPNV_ALLOWED_CARRIER_IDS.contains( + carrierId + ) -> + VerificationStatus.UNSUPPORTED_CARRIER + + telephonyManager.simState != TelephonyManager.SIM_STATE_READY -> + VerificationStatus.UNSUPPORTED_SIM_NOT_READY + + else -> VerificationStatus.SUPPORTED + } + ) + ) + } else { + emptyList() + } + + // TODO: Reflection should be used to call telephonyManager.getSubscriberId(it.subscriptionId) for SDK < N + val subscriberIdDigest = MessageDigest.getInstance("SHA-256") + .digest(telephonyManager.subscriberId.orEmpty().toByteArray()) + val subscriberIdDigestEncoded = + Base64.encodeToString(subscriberIdDigest, Base64.NO_WRAP) + + SimCapability( + info.simSlotIndex, + subscriberIdDigestEncoded, + carrierId, + // TODO: SDK < N is TelephonyManager.getSimOperatorNameForSubscription + telephonyManager.simOperatorName.orEmpty(), + verificationCapabilities + ) + } + + callbacks.onGetPnvCapabilitiesCompleted( + Status.SUCCESS, + GetPnvCapabilitiesResponse(simCapabilities), + ApiMetadata.DEFAULT + ) + } catch (e: SecurityException) { + Log.e(TAG, "getPnvCapabilities missing permission", e) + callbacks.onGetPnvCapabilitiesCompleted( + Status(5000), + GetPnvCapabilitiesResponse(emptyList()), + ApiMetadata.DEFAULT + ) + } catch (e: Exception) { + Log.e(TAG, "getPnvCapabilities failed", e) + callbacks.onGetPnvCapabilitiesCompleted( + Status.INTERNAL_ERROR, + GetPnvCapabilitiesResponse(emptyList()), + ApiMetadata.DEFAULT + ) + } +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetPnvCapabilitiesApiPhenotype.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetPnvCapabilitiesApiPhenotype.kt new file mode 100644 index 0000000000..9a7f8b21bd --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetPnvCapabilitiesApiPhenotype.kt @@ -0,0 +1,5 @@ +package org.microg.gms.constellation.core + +object GetPnvCapabilitiesApiPhenotype { + val FPNV_ALLOWED_CARRIER_IDS = ArrayList() +} \ No newline at end of file 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 new file mode 100644 index 0000000000..5f73229910 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/GetVerifiedPhoneNumbers.kt @@ -0,0 +1,149 @@ +@file:SuppressLint("NewApi") + +package org.microg.gms.constellation.core + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.util.Log +import com.google.android.gms.common.api.ApiMetadata +import com.google.android.gms.common.api.Status +import com.google.android.gms.constellation.PhoneNumberInfo +import com.google.android.gms.constellation.VerifyPhoneNumberResponse +import com.google.android.gms.constellation.VerifyPhoneNumberResponse.PhoneNumberVerification +import com.google.android.gms.constellation.internal.IConstellationCallbacks +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okio.ByteString.Companion.toByteString +import org.microg.gms.common.Constants +import org.microg.gms.constellation.core.proto.GetVerifiedPhoneNumbersRequest +import org.microg.gms.constellation.core.proto.GetVerifiedPhoneNumbersRequest.PhoneNumberSelection +import org.microg.gms.constellation.core.proto.IIDTokenAuth +import org.microg.gms.constellation.core.proto.TokenOption +import org.microg.gms.constellation.core.proto.VerifiedPhoneNumber +import java.util.UUID + +private const val TAG = "GetVerifiedPhoneNumbers" + +suspend fun handleGetVerifiedPhoneNumbers( + context: Context, + callbacks: IConstellationCallbacks, + bundle: Bundle +) = withContext(Dispatchers.IO) { + try { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + throw Exception("Unsupported SDK") + } + + val phoneNumbers = fetchVerifiedPhoneNumbers(context, bundle).map { it.toPhoneNumberInfo() } + + callbacks.onPhoneNumberVerified(Status.SUCCESS, phoneNumbers, ApiMetadata.DEFAULT) + } catch (e: Exception) { + Log.e(TAG, "Error in GetVerifiedPhoneNumbers (read-only)", e) + callbacks.onPhoneNumberVerified(Status.INTERNAL_ERROR, emptyList(), ApiMetadata.DEFAULT) + } +} + +internal suspend fun fetchVerifiedPhoneNumbers( + context: Context, + bundle: Bundle, + callingPackage: String = bundle.getString("calling_package") ?: Constants.GMS_PACKAGE_NAME +): List = withContext(Dispatchers.IO) { + val authManager = context.authManager + val sessionId = UUID.randomUUID().toString() + val selections = extractPhoneNumberSelections(bundle) + val certificateHash = bundle.getString("certificate_hash") ?: "" + val tokenNonce = bundle.getString("token_nonce") ?: "" + + val iidToken = authManager.getIidToken(IidTokenPhenotypes.READ_ONLY_PROJECT_NUMBER) + val iidTokenAuth = if (VerifyPhoneNumberApiPhenotypes.ENABLE_CLIENT_SIGNATURE) { + val (signatureBytes, signTimestamp) = authManager.signIidToken(iidToken) + IIDTokenAuth( + iid_token = iidToken, + client_sign = signatureBytes.toByteString(), + sign_timestamp = signTimestamp + ) + } else { + IIDTokenAuth(iid_token = iidToken) + } + + val getRequest = GetVerifiedPhoneNumbersRequest( + session_id = sessionId, + iid_token_auth = iidTokenAuth, + phone_number_selections = selections, + token_option = TokenOption( + certificate_hash = certificateHash, + token_nonce = tokenNonce, + package_name = callingPackage + ) + ) + + Log.d(TAG, "Calling GetVerifiedPhoneNumbers RPC (read-only mode)...") + val response = RpcClient.phoneNumberClient + .GetVerifiedPhoneNumbers() + .execute(getRequest) + Log.d(TAG, "GetVerifiedPhoneNumbers response: ${response.phone_numbers.size} numbers") + response.phone_numbers +} + +internal fun List.toVerifyPhoneNumberResponse(): VerifyPhoneNumberResponse { + return VerifyPhoneNumberResponse( + map { it.toPhoneNumberVerification() }.toTypedArray(), + Bundle.EMPTY + ) +} + +private fun VerifiedPhoneNumber.toPhoneNumberInfo(): PhoneNumberInfo { + val extras = Bundle().apply { + if (id_token.isNotEmpty()) { + putString("id_token", id_token) + } + putInt("rcs_state", rcs_state.value) + } + + return PhoneNumberInfo( + 1, + phone_number, + verification_time?.toEpochMilli() ?: 0L, + extras + ) +} + +private fun VerifiedPhoneNumber.toPhoneNumberVerification(): PhoneNumberVerification { + val extras = Bundle().apply { + putInt("rcs_state", rcs_state.value) + } + + // GMS read-only V2 leaves method/slot unset and returns a verified record directly. + return PhoneNumberVerification( + phone_number, + verification_time?.toEpochMilli() ?: 0L, + 0, + -1, + id_token.ifEmpty { null }, + extras, + 1, + -1L + ) +} + +private fun extractPhoneNumberSelections(bundle: Bundle): List { + val selections = mutableListOf() + val selectionInts = bundle.getIntegerArrayList("phone_number_selection") + + if (!selectionInts.isNullOrEmpty()) { + selections.addAll(selectionInts.mapNotNull { PhoneNumberSelection.fromValue(it) }) + } else { + when (bundle.getString("rcs_read_option", "")) { + "READ_PROVISIONED" -> { + selections.add(PhoneNumberSelection.CONSTELLATION) + selections.add(PhoneNumberSelection.RCS) + } + + "READ_PROVISIONED_ONLY" -> selections.add(PhoneNumberSelection.RCS) + else -> selections.add(PhoneNumberSelection.CONSTELLATION) + } + } + return selections +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/IidTokenPhenotypes.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/IidTokenPhenotypes.kt new file mode 100644 index 0000000000..4b42087420 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/IidTokenPhenotypes.kt @@ -0,0 +1,9 @@ +package org.microg.gms.constellation.core + +object IidTokenPhenotypes { + const val ASTERISM_PROJECT_NUMBER = "496232013492" + const val DEFAULT_PROJECT_NUMBER = "496232013492" + const val EXTERNAL_CONSENT_ACTIVITY_PROJECT_NUMBER = "496232013492" + const val MESSAGES_PROJECT_NUMBER = "496232013492" + const val READ_ONLY_PROJECT_NUMBER = "745476177629" +} \ No newline at end of file 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 new file mode 100644 index 0000000000..e5030bac66 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/RpcClient.kt @@ -0,0 +1,33 @@ +package org.microg.gms.constellation.core + +import com.squareup.wire.GrpcClient +import okhttp3.OkHttpClient +import org.microg.gms.common.Constants +import org.microg.gms.constellation.core.proto.PhoneDeviceVerificationClient +import org.microg.gms.constellation.core.proto.PhoneNumberClient + +object RpcClient { + private val client: OkHttpClient = OkHttpClient.Builder() + .addInterceptor { chain -> + val originalRequest = chain.request() + val builder = originalRequest.newBuilder() + .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()) + } + .build() + + private val grpcClient: GrpcClient = GrpcClient.Builder() + .client(client) + // Google's constellationserver does NOT like compressed requests + .minMessageToCompress(Long.MAX_VALUE) + .baseUrl("https://phonedeviceverification-pa.googleapis.com/") + .build() + + val phoneDeviceVerificationClient: PhoneDeviceVerificationClient = + grpcClient.create(PhoneDeviceVerificationClient::class) + + val phoneNumberClient: PhoneNumberClient = + grpcClient.create(PhoneNumberClient::class) +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerificationMappings.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerificationMappings.kt new file mode 100644 index 0000000000..edf9a5de0b --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerificationMappings.kt @@ -0,0 +1,158 @@ +package org.microg.gms.constellation.core + +import android.os.Build +import android.os.Bundle +import androidx.annotation.RequiresApi +import com.google.android.gms.constellation.VerifyPhoneNumberResponse.PhoneNumberVerification +import org.microg.gms.constellation.core.proto.Param +import org.microg.gms.constellation.core.proto.UnverifiedInfo +import org.microg.gms.constellation.core.proto.Verification +import org.microg.gms.constellation.core.proto.VerificationMethod + +fun UnverifiedInfo.Reason.toVerificationStatus(): Verification.Status { + return when (this) { + UnverifiedInfo.Reason.UNKNOWN_REASON -> Verification.Status.STATUS_UNKNOWN + UnverifiedInfo.Reason.THROTTLED -> Verification.Status.STATUS_THROTTLED + UnverifiedInfo.Reason.FAILED -> Verification.Status.STATUS_FAILED + UnverifiedInfo.Reason.SKIPPED -> Verification.Status.STATUS_SKIPPED + UnverifiedInfo.Reason.NOT_REQUIRED -> Verification.Status.STATUS_NOT_REQUIRED + UnverifiedInfo.Reason.PHONE_NUMBER_ENTRY_REQUIRED -> + Verification.Status.STATUS_PHONE_NUMBER_ENTRY_REQUIRED + + UnverifiedInfo.Reason.INELIGIBLE -> Verification.Status.STATUS_INELIGIBLE + UnverifiedInfo.Reason.DENIED -> Verification.Status.STATUS_DENIED + UnverifiedInfo.Reason.NOT_IN_SERVICE -> Verification.Status.STATUS_NOT_IN_SERVICE + } +} + +val Verification.state: Verification.State + get() = when { + verification_info != null -> Verification.State.VERIFIED + pending_verification_info != null -> Verification.State.PENDING + unverified_info != null -> Verification.State.NONE + else -> Verification.State.UNKNOWN + } + +val Verification.effectiveStatus: Verification.Status + get() = when (state) { + Verification.State.VERIFIED -> Verification.Status.STATUS_VERIFIED + Verification.State.PENDING -> + if (status != Verification.Status.STATUS_UNKNOWN) { + status + } else { + Verification.Status.STATUS_PENDING + } + + Verification.State.NONE -> + unverified_info?.reason?.toVerificationStatus() ?: Verification.Status.STATUS_UNKNOWN + + Verification.State.UNKNOWN -> Verification.Status.STATUS_UNKNOWN + } + +@RequiresApi(Build.VERSION_CODES.O) +fun Verification.toClientVerification(imsiToSlotMap: Map): PhoneNumberVerification { + val clientStatus = effectiveStatus.toClientStatus() + val extras = buildClientExtras() + val simImsi = association?.sim?.sim_info?.imsi?.firstOrNull() + val simSlot = if (simImsi != null) imsiToSlotMap[simImsi] ?: -1 else -1 + + var phoneNumber = "" + var timestampMillis = 0L + var verificationMethod = VerificationMethod.UNKNOWN + var verificationToken: String? = null + var retryAfterSeconds = -1L + + when (state) { + Verification.State.VERIFIED -> { + val info = verification_info + val verifiedPhoneNumber = info?.phone_number + require(!verifiedPhoneNumber.isNullOrEmpty()) { "Verified phone number is empty" } + phoneNumber = verifiedPhoneNumber + timestampMillis = info.verification_time?.toEpochMilli() ?: 0L + verificationMethod = info.challenge_method + verificationToken = extras.getString("id_token") + extras.remove("phone_number") + extras.remove("id_token") + extras.remove("verification_time_millis") + } + + Verification.State.PENDING -> { + verificationMethod = + pending_verification_info?.challenge?.type ?: VerificationMethod.UNKNOWN + } + + Verification.State.NONE -> { + val info = unverified_info + verificationMethod = info?.challenge_method ?: VerificationMethod.UNKNOWN + retryAfterSeconds = info?.retry_after_time?.let { ts -> + val now = System.currentTimeMillis() / 1000L + (ts.epochSecond - now).coerceAtLeast(0L) + } ?: -1L + } + + Verification.State.UNKNOWN -> Unit + } + + extras.remove("verification_method") + extras.remove("sim_slot_index") + + return PhoneNumberVerification( + phoneNumber, + timestampMillis, + verificationMethod.toClientMethod(), + simSlot, + verificationToken, + extras, + clientStatus, + retryAfterSeconds + ) +} + +private fun Verification.buildClientExtras(): Bundle { + val bundle = Bundle() + for (param in api_params) { + bundle.putParam(param) + } + + val slotIndex = association?.sim?.sim_slot?.slot_index + if (slotIndex != null && slotIndex >= 0) { + bundle.putString("sim_slot_index", slotIndex.toString()) + } + return bundle +} + +private fun Bundle.putParam(param: Param) { + if (param.key == "verification_method") { + param.value_.toIntOrNull()?.let { + putInt(param.key, it) + return + } + } + putString(param.key, param.value_) +} + +fun VerificationMethod.toClientMethod(): Int { + return when (this) { + VerificationMethod.UNKNOWN -> 0 + VerificationMethod.TS43 -> 9 + else -> value + } +} + +fun Verification.Status.toClientStatus(): Int { + return when (this) { + Verification.Status.STATUS_UNKNOWN, + Verification.Status.STATUS_NONE -> 0 + + Verification.Status.STATUS_PENDING -> 6 + Verification.Status.STATUS_VERIFIED -> 1 + Verification.Status.STATUS_THROTTLED -> 3 + Verification.Status.STATUS_FAILED -> 2 + Verification.Status.STATUS_SKIPPED -> 4 + Verification.Status.STATUS_NOT_REQUIRED -> 5 + Verification.Status.STATUS_PHONE_NUMBER_ENTRY_REQUIRED -> 7 + Verification.Status.STATUS_INELIGIBLE -> 8 + Verification.Status.STATUS_DENIED -> 9 + Verification.Status.STATUS_NOT_IN_SERVICE -> 10 + } +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerificationSettingsPhenotypes.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerificationSettingsPhenotypes.kt new file mode 100644 index 0000000000..f8deaa5a7f --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerificationSettingsPhenotypes.kt @@ -0,0 +1,6 @@ +package org.microg.gms.constellation.core + +object VerificationSettingsPhenotypes { + const val A2P_SMS_SIGNAL_GRANULARITY_HRS = 1L + const val A2P_HISTORY_WINDOW_HOURS = 168L +} 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 new file mode 100644 index 0000000000..848978f6c9 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerifyPhoneNumber.kt @@ -0,0 +1,467 @@ +@file:RequiresApi(Build.VERSION_CODES.O) + +package org.microg.gms.constellation.core + +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.telephony.SubscriptionInfo +import android.util.Log +import androidx.annotation.RequiresApi +import com.google.android.gms.common.api.ApiMetadata +import com.google.android.gms.common.api.Status +import com.google.android.gms.constellation.PhoneNumberInfo +import com.google.android.gms.constellation.VerifyPhoneNumberRequest +import com.google.android.gms.constellation.VerifyPhoneNumberRequest.IdTokenRequest +import com.google.android.gms.constellation.VerifyPhoneNumberResponse +import com.google.android.gms.constellation.VerifyPhoneNumberResponse.PhoneNumberVerification +import com.google.android.gms.constellation.internal.IConstellationCallbacks +import com.squareup.wire.GrpcException +import com.squareup.wire.GrpcStatus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.microg.gms.common.Constants +import org.microg.gms.constellation.core.proto.AsterismClient +import org.microg.gms.constellation.core.proto.Consent +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.SyncRequest +import org.microg.gms.constellation.core.proto.Verification +import org.microg.gms.constellation.core.proto.builder.RequestBuildContext +import org.microg.gms.constellation.core.proto.builder.buildImsiToSubscriptionInfoMap +import org.microg.gms.constellation.core.proto.builder.buildRequestContext +import org.microg.gms.constellation.core.proto.builder.invoke +import org.microg.gms.constellation.core.verification.ChallengeProcessor +import org.microg.gms.constellation.core.verification.MtSmsInboxRegistry +import java.util.UUID + +private const val TAG = "VerifyPhoneNumber" + +private enum class ReadCallbackMode { + NONE, + LEGACY, + TYPED +} + +@Suppress("DEPRECATION") +suspend fun handleVerifyPhoneNumberV1( + context: Context, + callbacks: IConstellationCallbacks, + bundle: Bundle, + packageName: String? +) { + val callingPackage = + packageName ?: bundle.getString("calling_package") ?: Constants.GMS_PACKAGE_NAME + val extras = Bundle(bundle).apply { + putString("calling_package", callingPackage) + putString("calling_api", "verifyPhoneNumber") + } + val timeout = when (val timeoutValue = extras.get("timeout")) { + is Long -> timeoutValue + is Int -> timeoutValue.toLong() + else -> 300L + } + val request = VerifyPhoneNumberRequest( + extras.getString("policy_id", ""), + timeout, + IdTokenRequest( + extras.getString("certificate_hash", ""), + extras.getString("token_nonce", "") + ), + extras, + emptyList(), + false, + 2, + emptyList() + ) + val policyId = bundle.getString("policy_id", "") + val mode = bundle.getInt("verification_mode", 0) + val useReadPath = when (mode) { + 0 -> policyId in VerifyPhoneNumberApiPhenotypes.READ_ONLY_POLICY_IDS + 2 -> VerifyPhoneNumberApiPhenotypes.ENABLE_READ_FLOW + + else -> false + } + + handleVerifyPhoneNumberRequest( + context, + callbacks, + request, + callingPackage, + if (useReadPath) ReadCallbackMode.LEGACY else ReadCallbackMode.NONE, + legacyCallbackOnFullFlow = true + ) +} + +@Suppress("DEPRECATION") +suspend fun handleVerifyPhoneNumberSingleUse( + context: Context, + callbacks: IConstellationCallbacks, + bundle: Bundle, + packageName: String? +) { + val callingPackage = + packageName ?: bundle.getString("calling_package") ?: Constants.GMS_PACKAGE_NAME + val extras = Bundle(bundle).apply { + putString("calling_package", callingPackage) + putString("calling_api", "verifyPhoneNumberSingleUse") + putString("one_time_verification", "True") + } + val timeout = when (val timeoutValue = extras.get("timeout")) { + is Long -> timeoutValue + is Int -> timeoutValue.toLong() + else -> 300L + } + val request = VerifyPhoneNumberRequest( + extras.getString("policy_id", ""), + timeout, + IdTokenRequest( + extras.getString("certificate_hash", ""), + extras.getString("token_nonce", "") + ), + extras, + emptyList(), + false, + 2, + emptyList() + ) + + handleVerifyPhoneNumberRequest( + context, + callbacks, + request, + callingPackage, + ReadCallbackMode.NONE, + legacyCallbackOnFullFlow = true + ) +} + +suspend fun handleVerifyPhoneNumberRequest( + context: Context, + callbacks: IConstellationCallbacks, + request: VerifyPhoneNumberRequest, + packageName: String? +) { + val callingPackage = packageName ?: Constants.GMS_PACKAGE_NAME + request.extras.putString("calling_api", "verifyPhoneNumber") + val useReadPath = when (request.apiVersion) { + 0 -> request.policyId in VerifyPhoneNumberApiPhenotypes.READ_ONLY_POLICY_IDS + 2 -> VerifyPhoneNumberApiPhenotypes.ENABLE_READ_FLOW + 3 -> request.policyId in VerifyPhoneNumberApiPhenotypes.POLICY_IDS_ALLOWED_FOR_LOCAL_READ + else -> false + } + + handleVerifyPhoneNumberRequest( + context, + callbacks, + request, + callingPackage, + if (useReadPath) ReadCallbackMode.TYPED else ReadCallbackMode.NONE, + localReadFallback = request.apiVersion == 3 && useReadPath, + legacyCallbackOnFullFlow = false + ) +} + +private suspend fun handleVerifyPhoneNumberRequest( + context: Context, + callbacks: IConstellationCallbacks, + request: VerifyPhoneNumberRequest, + callingPackage: String, + readCallbackMode: ReadCallbackMode, + localReadFallback: Boolean = false, + legacyCallbackOnFullFlow: Boolean = false +) { + try { + when (readCallbackMode) { + ReadCallbackMode.LEGACY -> { + Log.d(TAG, "Using read-only mode") + handleGetVerifiedPhoneNumbers(context, callbacks, request.extras) + } + + ReadCallbackMode.TYPED -> { + if (localReadFallback) { + Log.w(TAG, "Local-read mode not implemented, falling back to read-only RPC") + } else { + Log.d(TAG, "Using typed read-only mode") + } + val response = fetchVerifiedPhoneNumbers(context, request.extras, callingPackage) + .toVerifyPhoneNumberResponse() + callbacks.onPhoneNumberVerificationsCompleted( + Status.SUCCESS, + response, + ApiMetadata.DEFAULT + ) + } + + ReadCallbackMode.NONE -> { + Log.d(TAG, "Using full verification mode") + runVerificationFlow( + context, + request, + callingPackage, + callbacks, + legacyCallback = legacyCallbackOnFullFlow + ) + } + } + } catch (e: Exception) { + Log.e(TAG, "verifyPhoneNumber failed", e) + val status = when { + readCallbackMode != ReadCallbackMode.NONE -> Status.INTERNAL_ERROR + e is GrpcException -> handleRpcError(e) + e is NoConsentException -> Status(5001) + else -> Status.INTERNAL_ERROR + } + when (readCallbackMode) { + ReadCallbackMode.LEGACY -> { + callbacks.onPhoneNumberVerified( + status, + emptyList(), + ApiMetadata.DEFAULT + ) + } + + ReadCallbackMode.NONE -> { + if (legacyCallbackOnFullFlow) { + callbacks.onPhoneNumberVerified( + status, + emptyList(), + ApiMetadata.DEFAULT + ) + } else { + callbacks.onPhoneNumberVerificationsCompleted( + status, + VerifyPhoneNumberResponse(emptyArray(), Bundle()), + ApiMetadata.DEFAULT + ) + } + } + + ReadCallbackMode.TYPED -> { + callbacks.onPhoneNumberVerificationsCompleted( + status, + VerifyPhoneNumberResponse(emptyArray(), Bundle()), + ApiMetadata.DEFAULT + ) + } + } + } +} + +private fun handleRpcError(error: GrpcException): Status { + val statusCode = when (error.grpcStatus) { + GrpcStatus.RESOURCE_EXHAUSTED -> 5008 + GrpcStatus.DEADLINE_EXCEEDED, + GrpcStatus.ABORTED, + GrpcStatus.UNAVAILABLE -> 5007 + + GrpcStatus.PERMISSION_DENIED -> 5009 + else -> 5002 + } + return Status(statusCode, error.message) +} + +private class NoConsentException : Exception("No consent") + +private suspend fun runVerificationFlow( + context: Context, + request: VerifyPhoneNumberRequest, + callingPackage: String, + callbacks: IConstellationCallbacks, + legacyCallback: Boolean +) { + val sessionId = UUID.randomUUID().toString() + + val imsiToInfoMap = buildImsiToSubscriptionInfoMap(context) + val buildContext = buildRequestContext(context) + + val asterismClient = when (request.extras.getString("required_consumer_consent")) { + "RCS" -> AsterismClient.RCS + else -> AsterismClient.UNKNOWN_CLIENT + } + + if ( + request.extras.getString("one_time_verification") != "True" && + asterismClient != AsterismClient.UNKNOWN_CLIENT + ) { + val consent = getConsent( + context, + buildContext, + asterismClient, + sessionId, + ) + + val consented = consent.rcs_consent?.consent == Consent.CONSENTED || + consent.gaia_consents.any { + it.asterism_client == asterismClient && it.consent == Consent.CONSENTED + } + + if (!consented) { + Log.e(TAG, "Consent has not been set. Not running verification.") + throw NoConsentException() + } + } + + val syncRequest = SyncRequest( + context, + sessionId, + request, + buildContext, + imsiToInfoMap = imsiToInfoMap, + includeClientAuth = ConstellationStateStore.isPublicKeyAcked(context), + callingPackage = callingPackage + ) + + MtSmsInboxRegistry.prepare( + context.applicationContext, + imsiToInfoMap.values.map { it.subscriptionId }) + + val verifications = try { + executeSyncFlow( + context, + sessionId, + request, + syncRequest, + buildContext, + imsiToInfoMap + ) + } finally { + MtSmsInboxRegistry.dispose() + } + + if (legacyCallback) { + callbacks.onPhoneNumberVerified( + Status.SUCCESS, + verifications.mapNotNull { it.toLegacyPhoneNumberInfoOrNull() }, + ApiMetadata.DEFAULT + ) + } else { + callbacks.onPhoneNumberVerificationsCompleted( + Status.SUCCESS, + VerifyPhoneNumberResponse(verifications, Bundle()), + ApiMetadata.DEFAULT + ) + } +} + +private fun PhoneNumberVerification.toLegacyPhoneNumberInfoOrNull(): PhoneNumberInfo? { + if ( + verificationStatus != Verification.Status.STATUS_VERIFIED.toClientStatus() || + phoneNumber.isNullOrEmpty() + ) { + return null + } + val extras = Bundle(this.extras ?: Bundle.EMPTY).apply { + verificationToken?.let { putString("id_token", it) } + } + return PhoneNumberInfo(1, phoneNumber, timestampMillis, extras) +} + +private suspend fun executeSyncFlow( + context: Context, + sessionId: String, + request: VerifyPhoneNumberRequest, + syncRequest: SyncRequest, + buildContext: RequestBuildContext, + imsiToInfoMap: Map +): Array = withContext(Dispatchers.IO) { + Log.d(TAG, "Sending Sync request") + val syncResponse = try { + RpcClient.phoneDeviceVerificationClient.Sync().execute(syncRequest) + } catch (e: GrpcException) { + if (e.grpcStatus == GrpcStatus.PERMISSION_DENIED || + e.grpcStatus == GrpcStatus.UNAUTHENTICATED + ) { + Log.w( + TAG, + "Suspicious client status ${e.grpcStatus.name}. Clearing DroidGuard cache..." + ) + ConstellationStateStore.clearDroidGuardToken(context) + } + throw e + } + Log.d(TAG, "Sync response: ${syncResponse.responses.size} verifications") + ConstellationStateStore.storeSyncResponse(context, syncResponse) + + val isPublicKeyAcked = syncResponse.header_?.status?.code == 1 + val imsiToSlotMap = imsiToInfoMap.mapValues { it.value.simSlotIndex } + val requestedImsis = request.targetedSims.map { it.imsi }.toSet() + + val verifications = syncResponse.responses.mapNotNull { result -> + val verification = result.verification ?: Verification() + val verificationImsis = verification.association?.sim?.sim_info?.imsi.orEmpty() + if (requestedImsis.isNotEmpty() && verificationImsis.none { it in requestedImsis }) { + Log.w( + TAG, + "Skipping verification for IMSIs=$verificationImsis because it does not match requested IMSIs=$requestedImsis" + ) + return@mapNotNull null + } + + val finalVerification = if (verification.state == Verification.State.PENDING) { + ChallengeProcessor.process( + context, + sessionId, + imsiToInfoMap, + buildContext, + verification + ) + } else { + verification + } + + if (finalVerification.state != Verification.State.VERIFIED) { + Log.w(TAG, "Unverified. State: ${finalVerification.state}") + (finalVerification.pending_verification_info + ?: finalVerification.unverified_info)?.let { Log.w(TAG, it.toString()) } + + if (!request.includeUnverified) { + return@mapNotNull null + } + } + + finalVerification.toClientVerification(imsiToSlotMap) + }.toTypedArray() + + if (isPublicKeyAcked) { + Log.d(TAG, "Server acknowledged client public key") + ConstellationStateStore.setPublicKeyAcked(context, true) + } + + verifications +} + +suspend fun getConsent( + context: Context, + buildContext: RequestBuildContext, + asterismClient: AsterismClient, + sessionId: String = UUID.randomUUID().toString() +): GetConsentResponse { + val request = GetConsentRequest( + device_id = DeviceID(context, buildContext.iidToken), + header_ = RequestHeader( + context, + sessionId, + buildContext, + "getConsent" + ), + asterism_client = asterismClient + ) + + return try { + RpcClient.phoneDeviceVerificationClient.GetConsent().execute(request) + } catch (e: GrpcException) { + if (e.grpcStatus == GrpcStatus.PERMISSION_DENIED || + e.grpcStatus == GrpcStatus.UNAUTHENTICATED + ) { + Log.w( + TAG, + "Suspicious client status ${e.grpcStatus.name}. Clearing DroidGuard cache..." + ) + ConstellationStateStore.clearDroidGuardToken(context) + } + throw e + } +} \ No newline at end of file diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerifyPhoneNumberApiPhenotypes.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerifyPhoneNumberApiPhenotypes.kt new file mode 100644 index 0000000000..ceebfcb771 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/VerifyPhoneNumberApiPhenotypes.kt @@ -0,0 +1,37 @@ +package org.microg.gms.constellation.core + +object VerifyPhoneNumberApiPhenotypes { + val PACKAGES_ALLOWED_TO_CALL = listOf( + "com.google.android.gms", + "com.google.android.apps.messaging", + "com.google.android.ims", + "com.google.android.apps.tachyon", + "com.google.android.dialer", + "com.google.android.apps.nbu.paisa.user.dev", + "com.google.android.apps.nbu.paisa.user.qa", + "com.google.android.apps.nbu.paisa.user.teamfood2", + "com.google.android.apps.nbu.paisa.user.partner", + "com.google.android.apps.nbu.paisa.user", + "com.google.android.gms.constellation.getiidtoken", + "com.google.android.gms.constellation.ondemandconsent", + "com.google.android.gms.constellation.ondemandconsentv2", + "com.google.android.gms.constellation.readphonenumber", + "com.google.android.gms.constellation.verifyphonenumberlite", + "com.google.android.gms.constellation.verifyphonenumber", + "com.google.android.gms.test", + "com.google.android.apps.stargate", + "com.google.android.gms.firebase.fpnv", + "com.google.firebase.pnv.testapp", + "com.google.firebase.pnv" + ) + val POLICY_IDS_ALLOWED_FOR_LOCAL_READ = listOf("emergency_location") + val READ_ONLY_POLICY_IDS = listOf( + "business_voice", + "verifiedsmsconsent", + "hint", + "nearbysharing" + ) + const val ENABLE_CLIENT_SIGNATURE = true + const val ENABLE_LOCAL_READ_FLOW = true + const val ENABLE_READ_FLOW = true +} \ No newline at end of file diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/ClientInfoBuilder.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/ClientInfoBuilder.kt new file mode 100644 index 0000000000..b1644dc893 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/ClientInfoBuilder.kt @@ -0,0 +1,176 @@ +@file:RequiresApi(Build.VERSION_CODES.O) + +package org.microg.gms.constellation.core.proto.builder + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.os.Process +import android.os.UserManager +import android.telephony.SubscriptionManager +import android.telephony.TelephonyManager +import android.util.Base64 +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.content.edit +import androidx.core.content.getSystemService +import com.google.android.gms.droidguard.DroidGuard +import com.google.android.gms.tasks.await +import okio.ByteString.Companion.toByteString +import org.microg.gms.common.Constants +import org.microg.gms.constellation.core.ConstellationStateStore +import org.microg.gms.constellation.core.GServices +import org.microg.gms.constellation.core.authManager +import org.microg.gms.constellation.core.proto.ClientInfo +import org.microg.gms.constellation.core.proto.CountryInfo +import org.microg.gms.constellation.core.proto.DeviceID +import org.microg.gms.constellation.core.proto.DeviceType +import org.microg.gms.constellation.core.proto.DroidGuardSignals +import org.microg.gms.constellation.core.proto.GaiaSignals +import org.microg.gms.constellation.core.proto.NetworkSignal +import org.microg.gms.constellation.core.proto.SimOperatorInfo +import org.microg.gms.constellation.core.proto.UserProfileType +import java.security.MessageDigest +import java.util.Locale + +private const val TAG = "ClientInfoBuilder" +private const val PREFS_NAME = "constellation_prefs" + +@SuppressLint("HardwareIds") +suspend operator fun ClientInfo.Companion.invoke( + context: Context, + buildContext: RequestBuildContext, + rpc: String +): ClientInfo { + val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) + val locale = Locale.getDefault().let { "${it.language}_${it.country}" } + + return ClientInfo( + device_id = DeviceID(context, buildContext.iidToken), + client_public_key = context.authManager.getOrCreateKeyPair().public.encoded.toByteString(), + locale = locale, + gmscore_version_number = Constants.GMS_VERSION_CODE / 1000, + gmscore_version = packageInfo.versionName ?: "", + android_sdk_version = Build.VERSION.SDK_INT, + user_profile_type = UserProfileType.REGULAR_USER, + gaia_tokens = buildContext.gaiaTokens, + country_info = CountryInfo(context), + connectivity_infos = NetworkSignal.getList(context), + model = Build.MODEL, + manufacturer = Build.MANUFACTURER, + partial_sim_infos = SimOperatorInfo.getList(context), + device_type = DeviceType.DEVICE_TYPE_PHONE, + is_wearable_standalone = false, + gaia_signals = GaiaSignals(context), + device_fingerprint = Build.FINGERPRINT, + droidguard_signals = DroidGuardSignals(context, buildContext.iidToken, rpc), + ) +} + +@SuppressLint("HardwareIds") +operator fun DeviceID.Companion.invoke(context: Context, iidToken: String): DeviceID { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + var gmsAndroidId = prefs.getLong("gms_android_id", 0L) + if (gmsAndroidId == 0L) { + gmsAndroidId = GServices.getLong(context.contentResolver, "android_id", 0L) + if (gmsAndroidId == 0L) { + val androidIdStr = + android.provider.Settings.Secure.getString(context.contentResolver, "android_id") + gmsAndroidId = + androidIdStr?.toLongOrNull(16) ?: (Build.ID.hashCode() + .toLong() and 0x7FFFFFFFFFFFFFFFL) + } + prefs.edit { putLong("gms_android_id", gmsAndroidId) } + } + + val userSerial = try { + val userManager = context.getSystemService() + userManager?.getSerialNumberForUser(Process.myUserHandle()) ?: 0L + } catch (_: Exception) { + 0L + } + + var primaryDeviceId = prefs.getLong("primary_device_id", 0L) + val isSystemUser = try { + val userManager = context.getSystemService() + userManager?.isSystemUser ?: true + } catch (_: Exception) { + true + } + if (primaryDeviceId == 0L && isSystemUser) { + primaryDeviceId = gmsAndroidId + prefs.edit { putLong("primary_device_id", primaryDeviceId) } + } + + return DeviceID( + iid_token = iidToken, + primary_device_id = primaryDeviceId, + user_serial = userSerial, + gms_android_id = gmsAndroidId + ) +} + +operator fun CountryInfo.Companion.invoke(context: Context): CountryInfo { + val simCountries = mutableListOf() + val networkCountries = mutableListOf() + + try { + val sm = context.getSystemService() + val tm = context.getSystemService() + + sm?.activeSubscriptionInfoList?.forEach { info -> + val targetTM = + tm?.createForSubscriptionId(info.subscriptionId) + + targetTM?.networkCountryIso?.let { networkCountries.add(it.lowercase()) } + info.countryIso?.let { simCountries.add(it.lowercase()) } + } + } catch (e: SecurityException) { + Log.w(TAG, "No permission to access country info", e) + } + + if (simCountries.isEmpty()) { + val tm = context.getSystemService() + val simCountry = tm?.simCountryIso?.lowercase()?.takeIf { it.isNotBlank() } + if (simCountry != null) simCountries.add(simCountry) + } + if (networkCountries.isEmpty()) { + val tm = context.getSystemService() + val networkCountry = tm?.networkCountryIso?.lowercase()?.takeIf { it.isNotBlank() } + if (networkCountry != null) networkCountries.add(networkCountry) + } + + return CountryInfo(sim_countries = simCountries, network_countries = networkCountries) +} + +suspend operator fun DroidGuardSignals.Companion.invoke( + context: Context, + iidToken: String, + rpc: String +): DroidGuardSignals? { + val cachedToken = ConstellationStateStore.loadDroidGuardToken(context) + if (!cachedToken.isNullOrBlank()) { + return DroidGuardSignals(droidguard_token = cachedToken, droidguard_result = "") + } + + val md = MessageDigest.getInstance("SHA-256") + val digest = md.digest(iidToken.toByteArray(Charsets.UTF_8)) + val iidHash = Base64.encodeToString( + digest.copyOf(64), + Base64.NO_PADDING or Base64.NO_WRAP + ).substring(0, 32) + + return try { + val client = DroidGuard.getClient(context) + val data = mapOf( + "iidHash" to iidHash, + "rpc" to rpc + ) + val result = client.getResults("constellation_verify", data, null).await() + DroidGuardSignals(droidguard_result = result, droidguard_token = "") + } catch (e: Exception) { + Log.w(TAG, "DroidGuard generation failed", e) + null + } +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/CommonBuilders.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/CommonBuilders.kt new file mode 100644 index 0000000000..f93d8f21ed --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/CommonBuilders.kt @@ -0,0 +1,71 @@ +package org.microg.gms.constellation.core.proto.builder + +import android.content.Context +import android.os.Build +import android.os.Bundle +import androidx.annotation.RequiresApi +import okio.ByteString.Companion.toByteString +import org.microg.gms.constellation.core.authManager +import org.microg.gms.constellation.core.proto.AuditToken +import org.microg.gms.constellation.core.proto.AuditTokenMetadata +import org.microg.gms.constellation.core.proto.AuditUuid +import org.microg.gms.constellation.core.proto.ClientInfo +import org.microg.gms.constellation.core.proto.DeviceID +import org.microg.gms.constellation.core.proto.Param +import org.microg.gms.constellation.core.proto.RequestHeader +import org.microg.gms.constellation.core.proto.RequestTrigger + +fun Param.Companion.getList(extras: Bundle?): List { + if (extras == null) return emptyList() + val params = mutableListOf() + val ignoreKeys = setOf("consent_variant_key", "consent_trigger_key", "gaia_access_token") + for (key in extras.keySet()) { + if (key !in ignoreKeys) { + extras.getString(key)?.let { value -> + params.add(Param(key = key, value_ = value)) + } + } + } + return params +} + +fun AuditToken.Companion.generate(): AuditToken { + val uuid = java.util.UUID.randomUUID() + return AuditToken( + metadata = AuditTokenMetadata( + uuid = AuditUuid( + uuid_msb = uuid.mostSignificantBits, + uuid_lsb = uuid.leastSignificantBits + ) + ) + ) +} + +@RequiresApi(Build.VERSION_CODES.O) +suspend operator fun RequestHeader.Companion.invoke( + context: Context, + sessionId: String, + buildContext: RequestBuildContext, + rpc: String, + triggerType: RequestTrigger.Type = RequestTrigger.Type.TRIGGER_API_CALL, + includeClientAuth: Boolean = false, +): RequestHeader { + val authManager = if (includeClientAuth) context.authManager else null + val clientAuth = if (includeClientAuth) { + val (signature, timestamp) = authManager!!.signIidToken(buildContext.iidToken) + org.microg.gms.constellation.core.proto.ClientAuth( + device_id = DeviceID(context, buildContext.iidToken), + signature = signature.toByteString(), + sign_timestamp = timestamp + ) + } else { + null + } + + return RequestHeader( + client_info = ClientInfo(context, buildContext, rpc), + client_auth = clientAuth, + session_id = sessionId, + trigger = RequestTrigger(type = triggerType) + ) +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/GaiaInfoBuilder.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/GaiaInfoBuilder.kt new file mode 100644 index 0000000000..9c7c7134b0 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/GaiaInfoBuilder.kt @@ -0,0 +1,85 @@ +package org.microg.gms.constellation.core.proto.builder + +import android.accounts.AccountManager +import android.content.Context +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import com.squareup.wire.Instant +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.microg.gms.constellation.core.proto.GaiaAccountSignalType +import org.microg.gms.constellation.core.proto.GaiaSignalEntry +import org.microg.gms.constellation.core.proto.GaiaSignals +import org.microg.gms.constellation.core.proto.GaiaToken + +private const val TAG = "GaiaInfoBuilder" + + +@RequiresApi(Build.VERSION_CODES.O) +suspend operator fun GaiaSignals.Companion.invoke(context: Context): GaiaSignals? = + withContext(Dispatchers.IO) { + val entries = mutableListOf() + val accountManager = AccountManager.get(context) + + try { + val accounts = accountManager.getAccountsByType("com.google") + + for (account in accounts) { + var id = accountManager.getUserData(account, "GoogleUserId") + if (id == "") { + try { + val future = accountManager.getAuthToken( + account, + "^^_account_id_^^", + null, + false, + null, + null + ) + id = future.result?.getString(AccountManager.KEY_AUTHTOKEN) + accountManager.setUserData(account, "GoogleUserId", id) + } catch (e: Exception) { + Log.w(TAG, "Could not retrieve Gaia ID for account ${account.name}", e) + continue + } + } + entries.add( + GaiaSignalEntry( + gaia_id = id, + signal_type = GaiaAccountSignalType.GAIA_ACCOUNT_SIGNAL_AUTHENTICATED, + timestamp = Instant.now() + ) + ) + } + } catch (e: Exception) { + Log.w(TAG, "Could not build Gaia signals", e) + } + if (entries.isNotEmpty()) GaiaSignals(gaia_signals = entries) else null + } + +@Suppress("DEPRECATION") +suspend fun GaiaToken.Companion.getList(context: Context): List = + withContext(Dispatchers.IO) { + val accountManager = AccountManager.get(context) + val accounts = accountManager.getAccountsByType("com.google") + accounts.mapNotNull { account -> + try { + val future = accountManager.getAuthToken( + account, + "oauth2:https://www.googleapis.com/auth/numberer", + null, + false, + null, + null + ) + val token = future.result?.getString(AccountManager.KEY_AUTHTOKEN) + + if (!token.isNullOrBlank()) GaiaToken(token = token) else null + + } catch (e: Exception) { + Log.w(TAG, "Could not retrieve Gaia token for account ${account.name}", e) + null + } + } + } diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/RequestBuildContext.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/RequestBuildContext.kt new file mode 100644 index 0000000000..951255aa86 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/RequestBuildContext.kt @@ -0,0 +1,21 @@ +package org.microg.gms.constellation.core.proto.builder + +import android.content.Context +import org.microg.gms.constellation.core.AuthManager +import org.microg.gms.constellation.core.authManager +import org.microg.gms.constellation.core.proto.GaiaToken + +data class RequestBuildContext( + val iidToken: String, + val gaiaTokens: List +) + +suspend fun buildRequestContext( + context: Context, + authManager: AuthManager = context.authManager +): RequestBuildContext { + return RequestBuildContext( + iidToken = authManager.getIidToken(), + gaiaTokens = GaiaToken.getList(context) + ) +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/SyncRequestBuilder.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/SyncRequestBuilder.kt new file mode 100644 index 0000000000..b68f0d5991 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/SyncRequestBuilder.kt @@ -0,0 +1,205 @@ +@file:RequiresApi(Build.VERSION_CODES.O) +@file:SuppressLint("HardwareIds") + +package org.microg.gms.constellation.core.proto.builder + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.telephony.SubscriptionInfo +import android.telephony.SubscriptionManager +import android.telephony.TelephonyManager +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import com.google.android.gms.constellation.VerifyPhoneNumberRequest +import com.google.android.gms.constellation.verificationMethods +import org.microg.gms.common.Constants +import org.microg.gms.constellation.core.ConstellationStateStore +import org.microg.gms.constellation.core.proto.ChallengePreference +import org.microg.gms.constellation.core.proto.ChallengePreferenceMetadata +import org.microg.gms.constellation.core.proto.IdTokenRequest +import org.microg.gms.constellation.core.proto.Param +import org.microg.gms.constellation.core.proto.RequestHeader +import org.microg.gms.constellation.core.proto.RequestTrigger +import org.microg.gms.constellation.core.proto.SIMAssociation +import org.microg.gms.constellation.core.proto.SIMSlotInfo +import org.microg.gms.constellation.core.proto.SyncRequest +import org.microg.gms.constellation.core.proto.TelephonyInfo +import org.microg.gms.constellation.core.proto.TelephonyPhoneNumberType +import org.microg.gms.constellation.core.proto.Verification +import org.microg.gms.constellation.core.proto.VerificationAssociation +import org.microg.gms.constellation.core.proto.VerificationParam +import org.microg.gms.constellation.core.proto.VerificationPolicy + +private const val TAG = "SyncRequestBuilder" + +fun buildImsiToSubscriptionInfoMap(context: Context): Map { + val subscriptionManager = + context.getSystemService() ?: return emptyMap() + val telephonyManager = + context.getSystemService() ?: return emptyMap() + val map = mutableMapOf() + + try { + subscriptionManager.activeSubscriptionInfoList?.forEach { info -> + val subsTelephonyManager = + telephonyManager.createForSubscriptionId(info.subscriptionId) + subsTelephonyManager.subscriberId?.let { imsi -> + map[imsi] = info + } + } + } catch (e: SecurityException) { + Log.w(TAG, "No permission to read SIM info for SubscriptionInfo mapping", e) + } + return map +} + +fun getTelephonyPhoneNumbers( + context: Context, + subscriptionId: Int +): List { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return emptyList() + + val permissions = + listOf(Manifest.permission.READ_PHONE_NUMBERS, Manifest.permission.READ_PHONE_STATE) + if (permissions.all { + ContextCompat.checkSelfPermission( + context, + it + ) == PackageManager.PERMISSION_DENIED + }) return emptyList() + + val subscriptionManager = + context.getSystemService() ?: return emptyList() + + val sources = intArrayOf( + SubscriptionManager.PHONE_NUMBER_SOURCE_CARRIER, + SubscriptionManager.PHONE_NUMBER_SOURCE_UICC, + SubscriptionManager.PHONE_NUMBER_SOURCE_IMS + ) + + return try { + sources.map { source -> + val number = subscriptionManager.getPhoneNumber(subscriptionId, source) + if (number.isNotEmpty()) { + SIMAssociation.TelephonyPhoneNumber( + phone_number = number, + phone_number_type = when (source) { + SubscriptionManager.PHONE_NUMBER_SOURCE_CARRIER -> + TelephonyPhoneNumberType.PHONE_NUMBER_SOURCE_CARRIER + + SubscriptionManager.PHONE_NUMBER_SOURCE_UICC -> + TelephonyPhoneNumberType.PHONE_NUMBER_SOURCE_UICC + + SubscriptionManager.PHONE_NUMBER_SOURCE_IMS -> + TelephonyPhoneNumberType.PHONE_NUMBER_SOURCE_IMS + + else -> TelephonyPhoneNumberType.PHONE_NUMBER_SOURCE_UNSPECIFIED + } + ) + } else null + }.filterNotNull() + } catch (e: Exception) { + Log.w(TAG, "Error getting telephony phone numbers", e) + emptyList() + } +} + +suspend operator fun SyncRequest.Companion.invoke( + context: Context, + sessionId: String, + request: VerifyPhoneNumberRequest, + includeClientAuth: Boolean = false, + callingPackage: String = Constants.GMS_PACKAGE_NAME, + triggerType: RequestTrigger.Type = RequestTrigger.Type.TRIGGER_API_CALL +): SyncRequest { + val buildContext = buildRequestContext(context) + return SyncRequest( + context = context, + sessionId = sessionId, + request = request, + buildContext = buildContext, + includeClientAuth = includeClientAuth, + callingPackage = callingPackage, + triggerType = triggerType + ) +} + +@Suppress("DEPRECATION") +suspend operator fun SyncRequest.Companion.invoke( + context: Context, + sessionId: String, + request: VerifyPhoneNumberRequest, + buildContext: RequestBuildContext, + imsiToInfoMap: Map = buildImsiToSubscriptionInfoMap(context), + includeClientAuth: Boolean = false, + callingPackage: String = Constants.GMS_PACKAGE_NAME, + triggerType: RequestTrigger.Type = RequestTrigger.Type.TRIGGER_API_CALL +): SyncRequest { + val apiParamsList = Param.getList(request.extras) + + val verificationParams = request.targetedSims.map { + VerificationParam(key = it.imsi, value_ = it.phoneNumberHint) + } + + val structuredParams = VerificationPolicy( + policy_id = request.policyId, + max_verification_age_hours = request.timeout, + id_token_request = IdTokenRequest( + certificate_hash = request.idTokenRequest.idToken, + token_nonce = request.idTokenRequest.subscriberHash + ), + calling_package = callingPackage, + params = verificationParams + ) + + val verifications = imsiToInfoMap.map { (imsi, subscriptionInfo) -> + val subscriptionId = subscriptionInfo.subscriptionId + val slotIndex = subscriptionInfo.simSlotIndex + val phoneNumber = subscriptionInfo.number ?: "" + val iccid = subscriptionInfo.iccId ?: "" + + Verification( + status = Verification.Status.STATUS_NONE, + association = VerificationAssociation( + sim = SIMAssociation( + sim_info = SIMAssociation.SIMInfo( + imsi = listOf(imsi), + sim_readable_number = phoneNumber, + telephony_phone_number = getTelephonyPhoneNumbers(context, subscriptionId), + iccid = iccid + ), + gaia_tokens = buildContext.gaiaTokens, + sim_slot = SIMSlotInfo( + slot_index = slotIndex, + subscription_id = subscriptionId + ) + ) + ), + telephony_info = TelephonyInfo(context, subscriptionId), + structured_api_params = structuredParams, + api_params = apiParamsList, + challenge_preference = ChallengePreference( + capabilities = request.verificationMethods, + metadata = ChallengePreferenceMetadata() + ) + ) + } + + return SyncRequest( + verifications = verifications, + header_ = RequestHeader( + context, + sessionId, + buildContext, + "sync", + triggerType, + includeClientAuth, + ), + verification_tokens = ConstellationStateStore.loadVerificationTokens(context) + ) +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/TelephonyInfoBuilder.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/TelephonyInfoBuilder.kt new file mode 100644 index 0000000000..5bc26d6157 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/proto/builder/TelephonyInfoBuilder.kt @@ -0,0 +1,232 @@ +@file:RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1) +@file:SuppressLint("HardwareIds") +@file:Suppress("DEPRECATION") + +package org.microg.gms.constellation.core.proto.builder + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.os.Build +import android.os.UserManager +import android.telephony.ServiceState +import android.telephony.SubscriptionManager +import android.telephony.TelephonyManager +import android.util.Base64 +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import org.microg.gms.constellation.core.proto.NetworkSignal +import org.microg.gms.constellation.core.proto.SimNetworkInfo +import org.microg.gms.constellation.core.proto.SimOperatorInfo +import org.microg.gms.constellation.core.proto.TelephonyInfo +import java.security.MessageDigest + +private const val TAG = "TelephonyInfoBuilder" + +@SuppressLint("MissingPermission") +operator fun TelephonyInfo.Companion.invoke(context: Context, subscriptionId: Int): TelephonyInfo { + val tm = context.getSystemService() + val subscriptionManager = context.getSystemService() + val targetTm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && subscriptionId >= 0) { + tm?.createForSubscriptionId(subscriptionId) + } else tm + + val simState = when (targetTm?.simState) { + TelephonyManager.SIM_STATE_READY -> TelephonyInfo.SimState.SIM_STATE_READY + else -> TelephonyInfo.SimState.SIM_STATE_NOT_READY + } + + val phoneType = when (targetTm?.phoneType) { + TelephonyManager.PHONE_TYPE_GSM -> TelephonyInfo.PhoneType.PHONE_TYPE_GSM + TelephonyManager.PHONE_TYPE_CDMA -> TelephonyInfo.PhoneType.PHONE_TYPE_CDMA + TelephonyManager.PHONE_TYPE_SIP -> TelephonyInfo.PhoneType.PHONE_TYPE_SIP + else -> TelephonyInfo.PhoneType.PHONE_TYPE_UNKNOWN + } + + val networkRoaming = if (targetTm?.isNetworkRoaming == true) { + TelephonyInfo.RoamingState.ROAMING_ROAMING + } else { + TelephonyInfo.RoamingState.ROAMING_HOME + } + + val cm = context.getSystemService() + val activeNetwork = cm?.activeNetworkInfo + val connectivityState = when { + activeNetwork == null -> TelephonyInfo.ConnectivityState.CONNECTIVITY_UNKNOWN + activeNetwork.isRoaming -> TelephonyInfo.ConnectivityState.CONNECTIVITY_ROAMING + else -> TelephonyInfo.ConnectivityState.CONNECTIVITY_HOME + } + + val simInfo = SimNetworkInfo( + country_iso = targetTm?.simCountryIso?.lowercase() ?: "", + operator_ = targetTm?.simOperator ?: "", + operator_name = targetTm?.simOperatorName ?: "" + ) + + val networkInfo = SimNetworkInfo( + country_iso = targetTm?.networkCountryIso?.lowercase() ?: "", + operator_ = targetTm?.networkOperator ?: "", + operator_name = targetTm?.networkOperatorName ?: "" + ) + + val (activeSubCount, activeSubCountMax) = getActiveSubCounts(subscriptionManager) + + return TelephonyInfo( + phone_type = phoneType, + group_id_level1 = targetTm?.groupIdLevel1 ?: "", + sim_info = simInfo, + network_info = networkInfo, + network_roaming = networkRoaming, + connectivity_state = connectivityState, + sms_capability = getSmsCapability(context, targetTm), + active_sub_count = activeSubCount, + active_sub_count_max = activeSubCountMax, + sim_state = simState, + service_state = getServiceState(targetTm), + sim_carrier_id = getSimCarrierId(targetTm), + is_embedded = false + ) +} + +private fun getSmsCapability( + context: Context, + telephonyManager: TelephonyManager? +): TelephonyInfo.SmsCapability { + val hasReadSms = + ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) == + PackageManager.PERMISSION_GRANTED + val hasSendSms = + ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) == + PackageManager.PERMISSION_GRANTED + + if (!hasReadSms || !hasSendSms) { + return TelephonyInfo.SmsCapability.SMS_DEFAULT_CAPABILITY + } + + val isSmsRestricted = + context.getSystemService()?.userRestrictions?.getBoolean(UserManager.DISALLOW_SMS) == + true + if (isSmsRestricted) { + return TelephonyInfo.SmsCapability.SMS_RESTRICTED + } + + return if (telephonyManager?.isSmsCapable == true) { + TelephonyInfo.SmsCapability.SMS_CAPABLE + } else { + TelephonyInfo.SmsCapability.SMS_NOT_CAPABLE + } +} + +private fun getActiveSubCounts(subscriptionManager: SubscriptionManager?): Pair { + if (subscriptionManager == null) { + return 0 to 0 + } + + return try { + subscriptionManager.activeSubscriptionInfoCount to + subscriptionManager.activeSubscriptionInfoCountMax + } catch (e: SecurityException) { + Log.w(TAG, "Could not retrieve active subscription counts", e) + 0 to 0 + } +} + +private fun getServiceState(telephonyManager: TelephonyManager?): TelephonyInfo.ServiceState { + val state = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + telephonyManager?.serviceState?.state + } catch (e: SecurityException) { + Log.w(TAG, "Could not retrieve service state", e) + null + } + } else { + null + } + + return when (state) { + ServiceState.STATE_IN_SERVICE -> TelephonyInfo.ServiceState.SERVICE_STATE_IN_SERVICE + ServiceState.STATE_OUT_OF_SERVICE -> TelephonyInfo.ServiceState.SERVICE_STATE_OUT_OF_SERVICE + ServiceState.STATE_EMERGENCY_ONLY -> TelephonyInfo.ServiceState.SERVICE_STATE_EMERGENCY_ONLY + ServiceState.STATE_POWER_OFF -> TelephonyInfo.ServiceState.SERVICE_STATE_POWER_OFF + else -> TelephonyInfo.ServiceState.SERVICE_STATE_UNKNOWN + } +} + +private fun getSimCarrierId(telephonyManager: TelephonyManager?): Long { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + telephonyManager?.simCarrierId?.toLong() ?: -1L + } else { + -1L + } +} + +fun NetworkSignal.Companion.getList(context: Context): List { + val connectivityInfos = mutableListOf() + try { + val cm = context.getSystemService() + cm?.activeNetworkInfo?.let { networkInfo -> + val type = when (networkInfo.type) { + ConnectivityManager.TYPE_WIFI -> NetworkSignal.Type.TYPE_WIFI + ConnectivityManager.TYPE_MOBILE -> NetworkSignal.Type.TYPE_MOBILE + else -> NetworkSignal.Type.TYPE_UNKNOWN + } + val state = when { + networkInfo.isConnected -> NetworkSignal.State.STATE_CONNECTED + networkInfo.isConnectedOrConnecting && !networkInfo.isConnected -> NetworkSignal.State.STATE_CONNECTING + else -> NetworkSignal.State.STATE_DISCONNECTED + } + val availability = + if (networkInfo.isAvailable) NetworkSignal.Availability.AVAILABLE else NetworkSignal.Availability.NOT_AVAILABLE + + connectivityInfos.add( + NetworkSignal( + type = type, + availability = availability, + state = state + ) + ) + } + } catch (e: Exception) { + Log.w(TAG, "Could not retrieve network info", e) + } + return connectivityInfos +} + +fun SimOperatorInfo.Companion.getList(context: Context): List { + val infos = mutableListOf() + try { + val sm = context.getSystemService() + val tm = context.getSystemService() + + sm?.activeSubscriptionInfoList?.forEach { info -> + val targetTM = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + tm?.createForSubscriptionId(info.subscriptionId) + } else tm + + targetTM?.subscriberId?.let { imsi -> + val md = MessageDigest.getInstance("SHA-256") + val hash = Base64.encodeToString( + md.digest(imsi.toByteArray(Charsets.UTF_8)), + Base64.NO_WRAP + ) + + val simOperator = targetTM.simOperator ?: "" + infos.add( + SimOperatorInfo( + sim_operator = simOperator, + imsi_hash = hash + ) + ) + } + } + } catch (e: SecurityException) { + Log.e(TAG, "No permission to access SIM info for operator list", e) + } catch (e: Exception) { + Log.e(TAG, "Error hashing IMSI", e) + } + return infos +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/CarrierIdVerifier.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/CarrierIdVerifier.kt new file mode 100644 index 0000000000..42d73ac890 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/CarrierIdVerifier.kt @@ -0,0 +1,109 @@ +@file:RequiresApi(Build.VERSION_CODES.N) + +package org.microg.gms.constellation.core.verification + +import android.content.Context +import android.os.Build +import android.telephony.TelephonyManager +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.content.getSystemService +import org.microg.gms.constellation.core.proto.CarrierIdChallengeResponse +import org.microg.gms.constellation.core.proto.CarrierIdError +import org.microg.gms.constellation.core.proto.Challenge +import org.microg.gms.constellation.core.proto.ChallengeResponse + +private const val TAG = "CarrierIdVerifier" + +internal data class CarrierIdSession( + val challengeId: String, + val subId: Int, + var attempts: Int = 0, +) { + fun matches(challengeId: String, subId: Int): Boolean { + return this.challengeId == challengeId && this.subId == subId + } +} + +fun Challenge.verifyCarrierId(context: Context, subId: Int): ChallengeResponse { + val carrierChallenge = carrier_id_challenge ?: return failure( + CarrierIdError.CARRIER_ID_ERROR_UNKNOWN_ERROR, + "Carrier challenge data missing" + ) + val challengeData = carrierChallenge.isim_request.takeIf { it.isNotEmpty() } + ?: return failure( + CarrierIdError.CARRIER_ID_ERROR_UNKNOWN_ERROR, + "Carrier challenge data missing" + ) + if (subId == -1) return failure( + CarrierIdError.CARRIER_ID_ERROR_NO_SIM, + "No active subscription for carrier auth" + ) + + val telephonyManager = context.getSystemService() + ?: return failure( + CarrierIdError.CARRIER_ID_ERROR_NOT_SUPPORTED, + "TelephonyManager unavailable" + ) + val targetManager = + telephonyManager.createForSubscriptionId(subId) + if (challengeData.startsWith("[ts43]")) { + // Not supported for now, try to get the server to dispatch something different + return failure( + CarrierIdError.CARRIER_ID_ERROR_NOT_SUPPORTED, + "TS43-prefixed Carrier ID challenge not supported" + ) + } + + val appType = carrierChallenge.app_type.takeIf { it != 0 } ?: TelephonyManager.APPTYPE_USIM + + return try { + val response = + targetManager.getIccAuthentication(appType, carrierChallenge.auth_type, challengeData) + if (response.isNullOrEmpty()) { + failure(CarrierIdError.CARRIER_ID_ERROR_NULL_RESPONSE, "Null ISIM response") + } else { + ChallengeResponse( + carrier_id_response = CarrierIdChallengeResponse( + isim_response = response, + carrier_id_error = CarrierIdError.CARRIER_ID_ERROR_NO_ERROR + ) + ) + } + } catch (e: SecurityException) { + Log.w(TAG, "Unable to read subscription for carrier auth", e) + failure( + CarrierIdError.CARRIER_ID_ERROR_UNABLE_TO_READ_SUBSCRIPTION, + e.message ?: "SecurityException" + ) + } catch (e: UnsupportedOperationException) { + Log.w(TAG, "Carrier auth API unavailable", e) + failure( + CarrierIdError.CARRIER_ID_ERROR_NOT_SUPPORTED, + e.message ?: "UnsupportedOperationException" + ) + } catch (e: Exception) { + Log.e(TAG, "Carrier auth failed", e) + failure( + CarrierIdError.CARRIER_ID_ERROR_REFLECTION_ERROR, + e.message ?: "Reflection or platform error" + ) + } +} + +fun retryExceededCarrierId(): ChallengeResponse { + return failure( + CarrierIdError.CARRIER_ID_ERROR_RETRY_ATTEMPT_EXCEEDED, + "Carrier ID retry attempt exceeded" + ) +} + +private fun failure(status: CarrierIdError, message: String): ChallengeResponse { + Log.w(TAG, message) + return ChallengeResponse( + carrier_id_response = CarrierIdChallengeResponse( + isim_response = "", + carrier_id_error = status + ) + ) +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ChallengeProcessor.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ChallengeProcessor.kt new file mode 100644 index 0000000000..e121a44dfe --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ChallengeProcessor.kt @@ -0,0 +1,217 @@ +@file:RequiresApi(Build.VERSION_CODES.O) + +package org.microg.gms.constellation.core.verification + +import android.content.Context +import android.os.Build +import android.telephony.SubscriptionInfo +import android.util.Log +import androidx.annotation.RequiresApi +import com.squareup.wire.GrpcException +import com.squareup.wire.GrpcStatus +import kotlinx.coroutines.delay +import org.microg.gms.constellation.core.ConstellationStateStore +import org.microg.gms.constellation.core.RpcClient +import org.microg.gms.constellation.core.proto.ChallengeResponse +import org.microg.gms.constellation.core.proto.ProceedRequest +import org.microg.gms.constellation.core.proto.RequestHeader +import org.microg.gms.constellation.core.proto.Verification +import org.microg.gms.constellation.core.proto.VerificationMethod +import org.microg.gms.constellation.core.proto.builder.RequestBuildContext +import org.microg.gms.constellation.core.proto.builder.invoke +import org.microg.gms.constellation.core.state + +object ChallengeProcessor { + private const val TAG = "ChallengeProcessor" + private const val MAX_PROCEED_ROUNDS = 16 + + private fun challengeTimeRemainingMillis(verification: Verification): Long? { + val expiry = verification.pending_verification_info?.challenge?.expiry_time ?: return null + val targetMillis = expiry.timestamp?.toEpochMilli() ?: return null + val referenceMillis = expiry.now?.toEpochMilli() ?: return null + return targetMillis - referenceMillis + } + + suspend fun process( + context: Context, + sessionId: String, + imsiToInfoMap: Map, + buildContext: RequestBuildContext, + verification: Verification, + ): Verification { + var currentVerification = verification + var moSession: MoSmsSession? = null + var carrierIdSession: CarrierIdSession? = null + + for (attempt in 1..MAX_PROCEED_ROUNDS) { + if (currentVerification.state != Verification.State.PENDING) { + Log.d( + TAG, + "Verification state: ${currentVerification.state}. Stopping sequential verification." + ) + return currentVerification + } + + val challenge = currentVerification.pending_verification_info?.challenge + if (challenge == null) { + Log.w( + TAG, + "Attempt $attempt: Pending verification but no challenge found. Stopping." + ) + return currentVerification + } + + val challengeId = challenge.challenge_id?.id ?: "" + val remainingMillis = challengeTimeRemainingMillis(currentVerification) + if (remainingMillis != null && remainingMillis <= 0L) { + if (challenge.type == VerificationMethod.MT_SMS) { + Log.w( + TAG, + "Attempt $attempt: Challenge $challengeId expired before MT wait. Proceeding with empty MT response." + ) + } else { + Log.w(TAG, "Attempt $attempt: Challenge $challengeId expired before proceed") + return currentVerification + } + } + + Log.d( + TAG, + "Attempt $attempt: Solving challenge ID: $challengeId, Type: ${challenge.type}" + ) + + val challengeImsi = currentVerification.association?.sim?.sim_info?.imsi?.firstOrNull() + val info = imsiToInfoMap[challengeImsi] + val subId = info?.subscriptionId ?: -1 + + if (challenge.type != VerificationMethod.MO_SMS) { + moSession = null + } + if (challenge.type != VerificationMethod.CARRIER_ID || challenge.ts43_challenge != null) { + carrierIdSession = null + } + + val challengeResponse: ChallengeResponse? = when (challenge.type) { + VerificationMethod.TS43 -> challenge.ts43_challenge?.verify(context, subId) + + VerificationMethod.CARRIER_ID -> { + if (challenge.ts43_challenge != null) { + challenge.ts43_challenge.verify(context, subId) + } else { + val activeSession = + carrierIdSession?.takeIf { it.matches(challengeId, subId) } + ?: CarrierIdSession(challengeId, subId).also { + carrierIdSession = it + } + + activeSession.attempts += 1 + + if (activeSession.attempts >= 4) { + Log.w( + TAG, + "Attempt $attempt: Carrier ID challenge $challengeId is still pending after retry-exceeded response. Stopping." + ) + return currentVerification + } + + if (activeSession.attempts >= 3) { + Log.w( + TAG, + "Attempt $attempt: Carrier ID challenge $challengeId exceeded retry budget. Proceeding with retry-exceeded response." + ) + retryExceededCarrierId() + } else { + challenge.verifyCarrierId(context, subId) + } + } + } + + VerificationMethod.MT_SMS -> challenge.mt_challenge?.verify(subId, remainingMillis) + + VerificationMethod.MO_SMS -> challenge.mo_challenge?.let { moChallenge -> + val activeSession = moSession?.takeIf { it.matches(challengeId, subId) } + if (activeSession != null) { + val delayMillis = activeSession.nextPollingDelayMillis(remainingMillis) + if (delayMillis > 0L) { + Log.d( + TAG, + "Attempt $attempt: Challenge $challengeId still pending. Waiting ${delayMillis}ms before next MO proceed ping." + ) + delay(delayMillis) + } + activeSession.response + } else { + moChallenge.startSession(context, challengeId, subId).also { + moSession = it + }.response + } + } + + VerificationMethod.REGISTERED_SMS -> challenge.registered_sms_challenge?.verify( + context, + subId + ) + + else -> { + moSession = null + Log.w(TAG, "Unsupported verification method: ${challenge.type}") + null + } + } + + if (challengeResponse != null) { + Log.d(TAG, "Attempt $attempt: Challenge successfully solved. Proceeding...") + val proceedHeader = RequestHeader( + context, + sessionId, + buildContext, + "proceed", + includeClientAuth = true, + ) + val proceedRequest = ProceedRequest( + verification = currentVerification, + challenge_response = challengeResponse, + header_ = proceedHeader + ) + val proceedResponse = try { + RpcClient.phoneDeviceVerificationClient.Proceed().execute(proceedRequest) + } catch (e: GrpcException) { + if (e.grpcStatus == GrpcStatus.PERMISSION_DENIED || + e.grpcStatus == GrpcStatus.UNAUTHENTICATED + ) { + Log.w( + TAG, + "Suspicious client status ${e.grpcStatus.name}. Clearing DroidGuard cache..." + ) + ConstellationStateStore.clearDroidGuardToken(context) + } + throw e + } + ConstellationStateStore.storeProceedResponse(context, proceedResponse) + currentVerification = proceedResponse.verification ?: currentVerification + + if (challenge.type == VerificationMethod.CARRIER_ID) { + val nextChallenge = currentVerification.pending_verification_info?.challenge + val sameCarrierIdChallenge = + nextChallenge?.type == VerificationMethod.CARRIER_ID && + nextChallenge.challenge_id?.id == challengeId && + nextChallenge.ts43_challenge == null + + if (!sameCarrierIdChallenge) { + carrierIdSession = null + } + } + } else { + Log.w( + TAG, + "Attempt $attempt: Challenge verification failed or returned no response." + ) + // GMS continues looping if IMSI doesn't match or other issues, but here we return to avoid infinite retries if verifier is broken + return currentVerification + } + } + + Log.w(TAG, "Exhausted all $MAX_PROCEED_ROUNDS proceed rounds, record is still pending.") + return currentVerification + } +} \ No newline at end of file diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/MoSmsVerifier.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/MoSmsVerifier.kt new file mode 100644 index 0000000000..458712c4cb --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/MoSmsVerifier.kt @@ -0,0 +1,264 @@ +@file:RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1) + +package org.microg.gms.constellation.core.verification + +import android.Manifest +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.os.Build +import android.telephony.SmsManager +import android.telephony.SubscriptionManager +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import org.microg.gms.constellation.core.proto.ChallengeResponse +import org.microg.gms.constellation.core.proto.MOChallengeResponseData +import org.microg.gms.constellation.core.proto.MoChallenge +import java.util.UUID +import kotlin.coroutines.resume + +private const val TAG = "MoSmsVerifier" +private const val ACTION_MO_SMS_SENT = "org.microg.gms.constellation.core.MO_SMS_SENT" +private const val DEFAULT_MO_POLLING_INTERVALS_MILLIS = + "4000,1000,1000,3000,5000,5000,5000,5000,30000,30000,30000,240000,600000,300000" + +data class MoSmsSession( + val challengeId: String, + val subId: Int, + val response: ChallengeResponse, + private val pollingIntervalsMillis: List, + private var nextPollingIndex: Int = 0, +) { + fun matches(challengeId: String, subId: Int): Boolean { + return this.challengeId == challengeId && this.subId == subId + } + + fun nextPollingDelayMillis(remainingMillis: Long?): Long { + val configuredDelay = pollingIntervalsMillis.getOrNull(nextPollingIndex) + if (configuredDelay != null) { + nextPollingIndex += 1 + } + val delayMillis = configuredDelay ?: remainingMillis ?: 0L + return if (remainingMillis != null) { + delayMillis.coerceAtMost(remainingMillis.coerceAtLeast(0L)) + } else { + delayMillis.coerceAtLeast(0L) + } + } +} + +suspend fun MoChallenge.startSession( + context: Context, + challengeId: String, + subId: Int +): MoSmsSession { + return MoSmsSession( + challengeId = challengeId, + subId = subId, + response = sendOnce(context, subId), + pollingIntervalsMillis = pollingIntervalsMillis() + ) +} + +private suspend fun MoChallenge.sendOnce(context: Context, subId: Int): ChallengeResponse { + if (proxy_number.isEmpty() || sms.isEmpty()) { + return failedMoResponse() + } + if (context.checkCallingOrSelfPermission(Manifest.permission.SEND_SMS) != PackageManager.PERMISSION_GRANTED) { + Log.w(TAG, "SEND_SMS permission missing") + return failedMoResponse() + } + + val smsManager = resolveSmsManager(context, subId) ?: return ChallengeResponse( + mo_response = MOChallengeResponseData( + status = if (subId != -1 && !isActiveSubscription(context, subId)) { + MOChallengeResponseData.Status.NO_ACTIVE_SUBSCRIPTION + } else { + MOChallengeResponseData.Status.NO_SMS_MANAGER + } + ) + ) + + val port = data_sms_info?.destination_port ?: 0 + val isBinarySms = port > 0 + val action = ACTION_MO_SMS_SENT + val messageId = UUID.randomUUID().toString() + val sentIntent = Intent(action).apply { + `package` = context.packageName + putExtra("message_id", messageId) + } + + val pendingIntent = PendingIntent.getBroadcast( + context, + messageId.hashCode(), + sentIntent, + PendingIntent.FLAG_ONE_SHOT or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0) + ) + + Log.d(TAG, "Sending MO SMS to $proxy_number with messageId: $messageId") + + return withTimeoutOrNull(30_000L) { + suspendCancellableCoroutine { continuation -> + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == action) { + val receivedId = intent.getStringExtra("message_id") + if (receivedId != messageId) return + + val resultCode = resultCode + val errorCode = intent.getIntExtra("errorCode", -1) + + Log.d(TAG, "MO SMS sent result: $resultCode, error: $errorCode") + + val status = when (resultCode) { + -1 -> MOChallengeResponseData.Status.COMPLETED + else -> MOChallengeResponseData.Status.FAILED_TO_SEND_MO + } + + try { + context.unregisterReceiver(this) + } catch (_: Exception) { + } + + if (continuation.isActive) { + continuation.resume( + ChallengeResponse( + mo_response = MOChallengeResponseData( + status = status, + sms_result_code = resultCode.toLong(), + sms_error_code = errorCode.toLong() + ) + ) + ) + } + } + } + } + + ContextCompat.registerReceiver( + context, + receiver, + IntentFilter(action), + ContextCompat.RECEIVER_NOT_EXPORTED + ) + + continuation.invokeOnCancellation { + try { + context.unregisterReceiver(receiver) + } catch (_: Exception) { + } + } + + try { + if (isBinarySms) { + smsManager.sendDataMessage( + proxy_number, + null, + port.toShort(), + sms.encodeToByteArray(), + pendingIntent, + null + ) + } else { + smsManager.sendTextMessage( + proxy_number, + null, + sms, + pendingIntent, + null + ) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to initiate MO SMS send", e) + try { + context.unregisterReceiver(receiver) + } catch (_: Exception) { + } + if (continuation.isActive) { + continuation.resume(failedMoResponse()) + } + } + } + } ?: failedMoResponse() +} + +private fun MoChallenge.pollingIntervalsMillis(): List { + val configuredIntervals = polling_intervals.parsePollingIntervals() + return configuredIntervals.ifEmpty { + DEFAULT_MO_POLLING_INTERVALS_MILLIS.parsePollingIntervals() + } +} + +private fun String.parsePollingIntervals(): List { + return split(',') + .mapNotNull { it.trim().toLongOrNull() } + .filter { it > 0L } +} + +private fun failedMoResponse(): ChallengeResponse { + return ChallengeResponse( + mo_response = MOChallengeResponseData( + status = MOChallengeResponseData.Status.FAILED_TO_SEND_MO + ) + ) +} + +private fun isActiveSubscription(context: Context, subId: Int): Boolean { + if (subId == -1) return true + + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_PHONE_STATE + ) == PackageManager.PERMISSION_DENIED + ) { + Log.e(TAG, "Permission not granted") + return false + } + + return try { + val subManager = context.getSystemService() ?: return false + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + subManager.isActiveSubscriptionId(subId) + } else { + subManager.getActiveSubscriptionInfo(subId) != null + } + } catch (e: Exception) { + Log.w(TAG, "Failed to query active subscription for $subId", e) + false + } +} + +@Suppress("DEPRECATION") +private fun resolveSmsManager(context: Context, subId: Int): SmsManager? { + if (subId != -1 && !isActiveSubscription(context, subId)) { + return null + } + + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val manager = context.getSystemService() + if (subId != -1) { + manager?.createForSubscriptionId(subId) + } else { + manager + } + } else { + if (subId != -1) { + SmsManager.getSmsManagerForSubscriptionId(subId) + } else { + SmsManager.getDefault() + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to resolve SmsManager for subId: $subId", e) + null + } +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/MtSmsVerifier.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/MtSmsVerifier.kt new file mode 100644 index 0000000000..7a522c1e18 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/MtSmsVerifier.kt @@ -0,0 +1,176 @@ +package org.microg.gms.constellation.core.verification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.provider.Telephony +import android.telephony.SmsMessage +import android.util.Log +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import org.microg.gms.constellation.core.proto.ChallengeResponse +import org.microg.gms.constellation.core.proto.MTChallenge +import org.microg.gms.constellation.core.proto.MTChallengeResponseData +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume + +private const val TAG = "MtSmsVerifier" + +suspend fun MTChallenge.verify( + subId: Int, + timeoutMillis: Long? = null +): ChallengeResponse? { + val expectedBody = sms.takeIf { it.isNotEmpty() } ?: return null + val inbox = MtSmsInboxRegistry.get(subId) + val effectiveTimeoutMillis = timeoutMillis?.coerceAtLeast(0L) ?: TimeUnit.MINUTES.toMillis(30) + + Log.d(TAG, "Waiting for MT SMS containing challenge string") + + val match = withTimeoutOrNull(effectiveTimeoutMillis) { + inbox.awaitMatch(expectedBody) + } + + if (match == null) { + Log.w(TAG, "Timed out waiting for MT SMS, proceeding with empty response") + } + + val response = match ?: ReceivedSms(body = "", sender = "") + return ChallengeResponse( + mt_response = MTChallengeResponseData( + sms = response.body, + sender = response.sender + ) + ) +} + +internal object MtSmsInboxRegistry { + private val inboxes = ConcurrentHashMap() + + fun prepare(context: Context, subIds: Iterable) { + dispose() + + val effectiveSubIds = subIds.distinct().ifEmpty { listOf(-1) } + for (subId in effectiveSubIds) { + inboxes[subId] = MtSmsInbox(context.applicationContext, subId) + } + } + + fun get(subId: Int): MtSmsInbox { + return inboxes[subId] + ?: error("MT SMS inbox for subId=$subId was not initialized") + } + + fun dispose() { + val currentInboxes = inboxes.values.toList() + inboxes.clear() + for (inbox in currentInboxes) { + inbox.dispose() + } + } +} + +internal data class ReceivedSms( + val body: String, + val sender: String +) + +private data class PendingMatch( + val expectedBody: String, + val continuation: CancellableContinuation +) + +internal class MtSmsInbox( + context: Context, + private val subId: Int +) { + private val context = context.applicationContext + private val lock = Any() + private val bufferedMessages = mutableListOf() + private val pendingMatches = mutableListOf() + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return + + val receivedSubId = intent.getIntExtra( + "android.telephony.extra.SUBSCRIPTION_INDEX", + intent.getIntExtra("subscription", -1) + ) + if (subId != -1 && receivedSubId != subId) return + + onMessagesReceived(Telephony.Sms.Intents.getMessagesFromIntent(intent)) + } + } + + init { + val filter = IntentFilter(Telephony.Sms.Intents.SMS_RECEIVED_ACTION) + ContextCompat.registerReceiver( + context, + receiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED + ) + } + + suspend fun awaitMatch(expectedBody: String): ReceivedSms? { + return suspendCancellableCoroutine { continuation -> + synchronized(lock) { + bufferedMessages.firstOrNull { it.body.contains(expectedBody) }?.let { + continuation.resume(it) + return@suspendCancellableCoroutine + } + + val pendingMatch = PendingMatch(expectedBody, continuation) + pendingMatches += pendingMatch + continuation.invokeOnCancellation { + synchronized(lock) { + pendingMatches.remove(pendingMatch) + } + } + } + } + } + + private fun onMessagesReceived(messages: Array) { + val receivedMessages = messages.mapNotNull { message -> + val body = message.messageBody ?: return@mapNotNull null + ReceivedSms( + body = body, + sender = message.originatingAddress ?: "" + ) + } + if (receivedMessages.isEmpty()) return + + synchronized(lock) { + bufferedMessages += receivedMessages + for (receivedMessage in receivedMessages) { + val iterator = pendingMatches.iterator() + while (iterator.hasNext()) { + val pendingMatch = iterator.next() + if (!receivedMessage.body.contains(pendingMatch.expectedBody)) continue + + iterator.remove() + Log.d(TAG, "Matching MT SMS received from ${receivedMessage.sender}") + if (pendingMatch.continuation.isActive) { + pendingMatch.continuation.resume(receivedMessage) + } + } + } + } + } + + fun dispose() { + synchronized(lock) { + pendingMatches.clear() + bufferedMessages.clear() + } + try { + context.unregisterReceiver(receiver) + } catch (_: IllegalArgumentException) { + } + } +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/RegisteredSmsVerifier.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/RegisteredSmsVerifier.kt new file mode 100644 index 0000000000..b010277b41 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/RegisteredSmsVerifier.kt @@ -0,0 +1,184 @@ +@file:RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1) + +package org.microg.gms.constellation.core.verification + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.provider.Telephony +import android.telephony.SubscriptionManager +import android.telephony.TelephonyManager +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import okio.ByteString.Companion.toByteString +import org.microg.gms.constellation.core.VerificationSettingsPhenotypes +import org.microg.gms.constellation.core.proto.ChallengeResponse +import org.microg.gms.constellation.core.proto.RegisteredSmsChallenge +import org.microg.gms.constellation.core.proto.RegisteredSmsChallengeResponse +import org.microg.gms.constellation.core.proto.RegisteredSmsChallengeResponseItem +import org.microg.gms.constellation.core.proto.RegisteredSmsChallengeResponsePayload +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.concurrent.TimeUnit + +private const val TAG = "RegisteredSmsVerifier" + +fun RegisteredSmsChallenge.verify(context: Context, subId: Int): ChallengeResponse { + val expectedPayloads = verified_senders + .map { it.phone_number_id.toByteArray() } + .filter { it.isNotEmpty() } + if (expectedPayloads.isEmpty()) return ChallengeResponse( + registered_sms_response = RegisteredSmsChallengeResponse( + items = emptyList() + ) + ) + + val localNumbers = getLocalNumbers(context, subId) + if (localNumbers.isEmpty()) { + Log.w(TAG, "No local phone numbers available for registered SMS verification") + return ChallengeResponse(registered_sms_response = RegisteredSmsChallengeResponse(items = emptyList())) + } + + val historyStart = System.currentTimeMillis() - + TimeUnit.HOURS.toMillis(VerificationSettingsPhenotypes.A2P_HISTORY_WINDOW_HOURS) + val bucketSizeMillis = (TimeUnit.HOURS.toMillis( + VerificationSettingsPhenotypes.A2P_SMS_SIGNAL_GRANULARITY_HRS + ) / 2).coerceAtLeast(1L) + + val responseItems = mutableListOf() + + try { + val cursor = context.contentResolver.query( + Telephony.Sms.Inbox.CONTENT_URI, + arrayOf("date", "address", "body", "sub_id"), + "date > ?", + arrayOf(historyStart.toString()), + "date DESC" + ) ?: return ChallengeResponse( + registered_sms_response = RegisteredSmsChallengeResponse( + items = emptyList() + ) + ) + + cursor.use { + while (it.moveToNext()) { + val date = it.getLong(it.getColumnIndexOrThrow("date")) + val sender = it.getString(it.getColumnIndexOrThrow("address")) ?: continue + val body = it.getString(it.getColumnIndexOrThrow("body")) ?: continue + val messageSubId = runCatching { + it.getInt(it.getColumnIndexOrThrow("sub_id")) + }.getOrDefault(-1) + + val candidateNumbers = if (subId != -1 && messageSubId == subId) { + localNumbers + } else if (messageSubId == -1) { + localNumbers + } else { + getLocalNumbers(context, messageSubId).ifEmpty { localNumbers } + } + + val bucketStart = date - (date % bucketSizeMillis) + for (localNumber in candidateNumbers) { + val payload = computePayload(bucketStart, localNumber, sender, body) + if (expectedPayloads.any { expected -> expected.contentEquals(payload) }) { + responseItems += RegisteredSmsChallengeResponseItem( + payload = RegisteredSmsChallengeResponsePayload( + payload = payload.toByteString() + ) + ) + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Registered SMS verification failed", e) + return ChallengeResponse(registered_sms_response = RegisteredSmsChallengeResponse(items = emptyList())) + } + + return ChallengeResponse( + registered_sms_response = RegisteredSmsChallengeResponse(items = responseItems.distinct()) + ) +} + +@SuppressLint("HardwareIds") +@Suppress("DEPRECATION") +private fun getLocalNumbers(context: Context, targetSubId: Int): List { + val numbers = linkedSetOf() + val subscriptionManager = + context.getSystemService() + val telephonyManager = context.getSystemService() + + val hasState = ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_PHONE_STATE + ) == PackageManager.PERMISSION_GRANTED + val isCarrier = telephonyManager?.hasCarrierPrivileges() == true + val hasNumbers = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_PHONE_NUMBERS + ) == PackageManager.PERMISSION_GRANTED + } else { + hasState + } + + if (!isCarrier && (!hasState || !hasNumbers)) { + Log.e(TAG, "Permission not granted") + return emptyList() + } + + try { + subscriptionManager?.activeSubscriptionInfoList.orEmpty().forEach { info -> + if (targetSubId != -1 && info.subscriptionId != targetSubId) return@forEach + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && subscriptionManager != null) { + numbers += subscriptionManager.getPhoneNumber( + info.subscriptionId, + SubscriptionManager.PHONE_NUMBER_SOURCE_CARRIER + ) + numbers += subscriptionManager.getPhoneNumber( + info.subscriptionId, + SubscriptionManager.PHONE_NUMBER_SOURCE_UICC + ) + numbers += subscriptionManager.getPhoneNumber( + info.subscriptionId, + SubscriptionManager.PHONE_NUMBER_SOURCE_IMS + ) + } + val targetManager = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + telephonyManager?.createForSubscriptionId(info.subscriptionId) + } else { + telephonyManager + } + numbers += targetManager?.line1Number.orEmpty() + numbers += info.number.orEmpty() + } + } catch (e: Exception) { + Log.w(TAG, "Unable to collect local phone numbers", e) + } + + return numbers.filter { it.isNotBlank() }.distinct() +} + +private fun computePayload( + bucketStart: Long, + localNumber: String, + sender: String, + body: String +): ByteArray { + val digest = MessageDigest.getInstance("SHA-512") + digest.update(bucketStart.toString().toByteArray(StandardCharsets.UTF_8)) + digest.update(hashUtf8(localNumber)) + digest.update(hashUtf8(sender)) + digest.update(body.toByteArray(StandardCharsets.UTF_8)) + return digest.digest() +} + +private fun hashUtf8(value: String): ByteArray { + return MessageDigest.getInstance("SHA-512") + .digest(value.toByteArray(StandardCharsets.UTF_8)) +} 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 new file mode 100644 index 0000000000..a43ef69f95 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/Ts43Verifier.kt @@ -0,0 +1,417 @@ +@file:RequiresApi(Build.VERSION_CODES.O) +@file:SuppressLint("HardwareIds") + +package org.microg.gms.constellation.core.verification + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.telephony.TelephonyManager +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.content.getSystemService +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import org.microg.gms.constellation.core.proto.ChallengeResponse +import org.microg.gms.constellation.core.proto.ClientChallengeResponse +import org.microg.gms.constellation.core.proto.OdsaOperation +import org.microg.gms.constellation.core.proto.ServerChallengeResponse +import org.microg.gms.constellation.core.proto.ServiceEntitlementRequest +import org.microg.gms.constellation.core.proto.Ts43Challenge +import org.microg.gms.constellation.core.proto.Ts43ChallengeResponse +import org.microg.gms.constellation.core.proto.Ts43ChallengeResponseError +import org.microg.gms.constellation.core.proto.Ts43ChallengeResponseStatus +import org.microg.gms.constellation.core.verification.ts43.EapAkaService +import org.microg.gms.constellation.core.verification.ts43.builder +import org.microg.gms.constellation.core.verification.ts43.userAgent +import org.xml.sax.InputSource +import java.io.IOException +import java.io.StringReader +import java.util.Locale +import java.util.concurrent.TimeUnit +import javax.xml.parsers.DocumentBuilderFactory + +private const val TAG = "Ts43Verifier" + +fun Ts43Challenge.verify(context: Context, subId: Int): ChallengeResponse { + val verifier = InternalTs43Verifier(context, subId) + return verifier.verify(this) +} + +private class InternalTs43Verifier(private val context: Context, private val subId: Int) { + private val httpHistory = mutableListOf() + + private val telephonyManager: TelephonyManager by lazy { + val tm = requireNotNull(context.getSystemService()) { + "TelephonyManager unavailable" + } + if (subId >= 0) { + tm.createForSubscriptionId(subId) + } else tm + } + + private val eapAkaService by lazy { EapAkaService(telephonyManager) } + + fun verify(challenge: Ts43Challenge): ChallengeResponse { + httpHistory.clear() + + return try { + when { + challenge.client_challenge != null -> { + val op = challenge.client_challenge.operation + Log.d(TAG, "TS43: Client ODSA challenge route: ${op?.operation}") + if (op == null) { + buildFailureResponse( + challenge, + Ts43ChallengeResponseStatus.Code.TS43_STATUS_CHALLENGE_NOT_SET + ) + } else { + val requestPayload = buildOdsaRequestPayload( + challenge.entitlement_url, + challenge.eap_aka_realm, + challenge.service_entitlement_request, + challenge.app_id + ) + if (requestPayload == null) { + buildFailureResponse( + challenge, + Ts43ChallengeResponseStatus.Code.TS43_STATUS_INTERNAL_ERROR + ) + } else { + val responseBody = performOdsaRequest( + challenge.entitlement_url, + op, + requestPayload, + challenge.app_id, + Ts43ChallengeResponseError.RequestType.TS43_REQUEST_TYPE_GET_PHONE_NUMBER_API + ) + ChallengeResponse( + ts43_challenge_response = Ts43ChallengeResponse( + ts43_type = challenge.ts43_type, + client_challenge_response = ClientChallengeResponse( + get_phone_number_response = responseBody + ), + http_history = httpHistory.toList() + ) + ) + } + } + } + + challenge.server_challenge != null -> { + val op = challenge.server_challenge.operation + Log.d(TAG, "TS43: Server ODSA challenge route: ${op?.operation}") + if (op == null) { + buildFailureResponse( + challenge, + Ts43ChallengeResponseStatus.Code.TS43_STATUS_CHALLENGE_NOT_SET + ) + } else { + val requestPayload = buildOdsaRequestPayload( + challenge.entitlement_url, + challenge.eap_aka_realm, + challenge.service_entitlement_request, + challenge.app_id + ) + if (requestPayload == null) { + buildFailureResponse( + challenge, + Ts43ChallengeResponseStatus.Code.TS43_STATUS_INTERNAL_ERROR + ) + } else { + val responseBody = performOdsaRequest( + challenge.entitlement_url, + op, + requestPayload, + challenge.app_id, + Ts43ChallengeResponseError.RequestType.TS43_REQUEST_TYPE_ACQUIRE_TEMPORARY_TOKEN_API + ) + ChallengeResponse( + ts43_challenge_response = Ts43ChallengeResponse( + ts43_type = challenge.ts43_type, + server_challenge_response = ServerChallengeResponse( + acquire_temporary_token_response = responseBody + ), + http_history = httpHistory.toList() + ) + ) + } + } + } + + else -> buildFailureResponse( + challenge, + Ts43ChallengeResponseStatus.Code.TS43_STATUS_CHALLENGE_NOT_SET + ) + } + } catch (e: Ts43ApiException) { + Log.w(TAG, "TS43 API failure requestType=${e.requestType} http=${e.httpStatus}", e) + ChallengeResponse( + ts43_challenge_response = Ts43ChallengeResponse( + ts43_type = challenge.ts43_type, + status = Ts43ChallengeResponseStatus( + error = Ts43ChallengeResponseError( + error_code = e.errorCode, + http_status = e.httpStatus, + request_type = e.requestType + ) + ), + http_history = httpHistory.toList() + ) + ) + } catch (e: NullPointerException) { + Log.e(TAG, "TS43 null failure", e) + buildFailureResponse( + challenge, + Ts43ChallengeResponseStatus.Code.TS43_STATUS_INTERNAL_ERROR + ) + } catch (e: RuntimeException) { + Log.e(TAG, "TS43 runtime failure", e) + buildFailureResponse( + challenge, + Ts43ChallengeResponseStatus.Code.TS43_STATUS_RUNTIME_ERROR + ) + } + } + + private fun performOdsaRequest( + entitlementUrl: String, + op: OdsaOperation, + req: ServiceEntitlementRequest?, + challengeAppId: String?, + requestType: Ts43ChallengeResponseError.RequestType + ): String { + val builder = op.builder(telephonyManager, req, currentAppIds(challengeAppId)) + val url = builder.buildBaseUrl(entitlementUrl) + val userAgent = req?.userAgent(context) ?: "PRD-TS43 OS-Android/${Build.VERSION.RELEASE}" + val accept = req?.accept_content_type?.takeIf { it.isNotEmpty() } ?: "application/json" + val acceptLanguage = Locale.getDefault().toLanguageTag().ifEmpty { "en-US" } + httpHistory += "GET $url" + + val request = Request.Builder() + .url(url) + .header("Accept", accept) + .header("User-Agent", userAgent) + .header("Accept-Language", acceptLanguage) + .build() + + return try { + val response = okHttpClient.newCall(request).execute() + httpHistory += "RESP ${response.code} ${request.url}" + if (response.isSuccessful) { + response.body?.string() + ?: throw Ts43ApiException( + errorCode = errorCode(32), + httpStatus = response.code, + requestType = requestType + ) + } else { + Log.w(TAG, "ODSA request failed: ${response.code} for ${op.operation}") + throw Ts43ApiException( + errorCode = errorCode(31), + httpStatus = response.code, + requestType = requestType + ) + } + } catch (e: IOException) { + Log.e(TAG, " Network error in ODSA request", e) + throw Ts43ApiException( + errorCode = errorCode(30), + httpStatus = -1, + requestType = requestType, + cause = e + ) + } + } + + private fun buildOdsaRequestPayload( + entitlementUrl: String, + eapAkaRealm: String?, + req: ServiceEntitlementRequest?, + challengeAppId: String? + ): ServiceEntitlementRequest? { + if (req == null) return null + if (req.authentication_token.isNotEmpty() || req.temporary_token.isNotEmpty()) return req + + val mccMnc = telephonyManager.simOperator ?: "" + val imsi = telephonyManager.subscriberId ?: "" + if (mccMnc.length < 5 || imsi.isEmpty()) return null + + val eapId = eapAkaService.buildEapId(mccMnc, imsi, eapAkaRealm) + val builder = req.builder(telephonyManager, eapId, currentAppIds(challengeAppId)) + + val initialUrl = builder.buildBaseUrl(entitlementUrl) + val postUrl = entitlementUrl + val userAgent = req.userAgent(context) + val acceptLanguage = Locale.getDefault().toLanguageTag().ifEmpty { "en-US" } + + // 1. Initial Identity Probe (GET) + var currentRequest = Request.Builder() + .url(initialUrl) + .header("Accept", "application/vnd.gsma.eap-relay.v1.0+json") + .header("User-Agent", userAgent) + .header("Accept-Language", acceptLanguage) + .build() + httpHistory += "GET $initialUrl" + + // 2. EAP Rounds Loop + for (round in 1..3) { + val response = try { + okHttpClient.newCall(currentRequest).execute() + } catch (e: IOException) { + Log.e(TAG, "Network error in EAP round $round", e) + throw Ts43ApiException( + errorCode = errorCode(30), + httpStatus = -1, + requestType = Ts43ChallengeResponseError.RequestType.TS43_REQUEST_TYPE_AUTH_API, + cause = e + ) + } + + val body = response.body?.string() ?: return null + httpHistory += "RESP ${response.code} ${currentRequest.url}" + if (!response.isSuccessful) { + Log.w(TAG, "EAP round $round failed with code ${response.code}") + throw Ts43ApiException( + errorCode = errorCode(31), + httpStatus = response.code, + requestType = Ts43ChallengeResponseError.RequestType.TS43_REQUEST_TYPE_AUTH_API + ) + } + + val token = extractAuthToken(body) + if (token != null) { + return req.copy(authentication_token = token) + } + + val eapRelayPacket = extractEapRelayPacket(body) + ?: throw Ts43ApiException( + errorCode = errorCode(32), + httpStatus = response.code, + requestType = Ts43ChallengeResponseError.RequestType.TS43_REQUEST_TYPE_AUTH_API + ) + + val akaResponse = + eapAkaService.performSimAkaAuth(eapRelayPacket, imsi, mccMnc) ?: return null + + val postBody = JSONObject().put("eap-relay-packet", akaResponse).toString() + currentRequest = Request.Builder() + .url(postUrl) + .header( + "Accept", + "application/vnd.gsma.eap-relay.v1.0+json, text/vnd.wap.connectivity-xml" + ) + .header("User-Agent", userAgent) + .header("Content-Type", "application/vnd.gsma.eap-relay.v1.0+json") + .header("Accept-Language", acceptLanguage) + .post( + postBody.toByteArray().toRequestBody( + "application/vnd.gsma.eap-relay.v1.0+json".toMediaType() + ) + ) + .build() + httpHistory += "POST $postUrl" + } + + return null + } + + private fun currentAppIds(challengeAppId: String? = null): List { + return listOfNotNull( + challengeAppId?.takeIf { it.isNotBlank() } + ).ifEmpty { + listOf("ap2014") + } + } + + private fun extractEapRelayPacket(body: String): String? { + return runCatching { JSONObject(body).optString("eap-relay-packet") } + .getOrNull() + ?.takeIf { it.isNotEmpty() } + } + + private fun extractAuthToken(body: String): String? { + runCatching { + JSONObject(body).optJSONObject("Token")?.optString("token") + }.getOrNull()?.takeIf { it.isNotEmpty() }?.let { return it } + + return runCatching { + val normalized = body + .replace("&", "&") + .replace("&amp;", "&") + .replace("\r\n", "\n") + val doc = DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .parse(InputSource(StringReader(normalized))) + val parms = doc.getElementsByTagName("parm") + for (i in 0 until parms.length) { + val node = parms.item(i) + val attrs = node.attributes ?: continue + val name = attrs.getNamedItem("name")?.nodeValue ?: continue + if (name == "token") { + return@runCatching attrs.getNamedItem("value")?.nodeValue + } + } + null + }.getOrNull()?.takeIf { it.isNotEmpty() } + } + + private fun buildFailureResponse( + challenge: Ts43Challenge, + statusCode: Ts43ChallengeResponseStatus.Code + ): ChallengeResponse { + return ChallengeResponse( + ts43_challenge_response = Ts43ChallengeResponse( + ts43_type = challenge.ts43_type, + status = Ts43ChallengeResponseStatus(status_code = statusCode), + http_history = httpHistory.toList() + ) + ) + } +} + +private class Ts43ApiException( + val errorCode: Ts43ChallengeResponseError.Code, + val httpStatus: Int, + val requestType: Ts43ChallengeResponseError.RequestType, + cause: Throwable? = null +) : Exception(cause) + +private fun errorCode(rawCode: Int): Ts43ChallengeResponseError.Code = + Ts43ChallengeResponseError.Code.fromValue(rawCode) + ?: Ts43ChallengeResponseError.Code.TS43_ERROR_CODE_UNSPECIFIED + +private val okHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .followRedirects(false) + .followSslRedirects(false) + .cookieJar(object : CookieJar { + private val cookieStore = mutableMapOf>() + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val existing = cookieStore.getOrPut(url.host) { mutableListOf() } + for (cookie in cookies) { + existing.removeAll { + it.name == cookie.name && + it.domain == cookie.domain && + it.path == cookie.path + } + existing += cookie + } + } + + override fun loadForRequest(url: HttpUrl): List { + return cookieStore[url.host] + ?.filter { cookie -> cookie.matches(url) } + .orEmpty() + } + }) + .build() +} 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 new file mode 100644 index 0000000000..47fae2cb31 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/EapAkaService.kt @@ -0,0 +1,211 @@ +package org.microg.gms.constellation.core.verification.ts43 + +import android.os.Build +import android.telephony.TelephonyManager +import android.util.Base64 +import android.util.Log +import androidx.annotation.RequiresApi +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +private const val TAG = "EapAkaService" + +class EapAkaService(private val telephonyManager: TelephonyManager) { + + companion object { + private const val EAP_CODE_REQUEST = 1 + private const val EAP_CODE_RESPONSE = 2 + + private const val EAP_TYPE_AKA = 23 + + private const val EAP_AKA_SUBTYPE_CHALLENGE = 1 + private const val EAP_AKA_SUBTYPE_SYNC_FAILURE = 4 + + private const val AT_RAND = 1 + private const val AT_AUTN = 2 + private const val AT_RES = 3 + private const val AT_AUTS = 4 + private const val AT_MAC = 11 + + private const val SIM_RES_SUCCESS = 0xDB.toByte() + private const val SIM_RES_SYNC_FAIL = 0xDC.toByte() + } + + @RequiresApi(Build.VERSION_CODES.N) + fun performSimAkaAuth(eapRelayBase64: String, imsi: String, mccMnc: String): String? { + val eapPacket = Base64.decode(eapRelayBase64, Base64.DEFAULT) + if (eapPacket.size < 12) return null + + val code = eapPacket[0].toInt() + val eapId = eapPacket[1] + val type = eapPacket[4].toInt() + val subtype = eapPacket[5].toInt() + + if (code != EAP_CODE_REQUEST || type != EAP_TYPE_AKA || subtype != EAP_AKA_SUBTYPE_CHALLENGE) { + Log.w(TAG, "Unexpected EAP packet: code=$code, type=$type, subtype=$subtype") + return null + } + + // Parse attributes (starting at offset 8) + var rand: ByteArray? = null + var autn: ByteArray? = null + + var offset = 8 + while (offset + 2 <= eapPacket.size) { + val attrType = eapPacket[offset].toInt() and 0xFF + val attrLen = (eapPacket[offset + 1].toInt() and 0xFF) * 4 + if (offset + attrLen > eapPacket.size || attrLen < 4) break + + when (attrType) { + AT_RAND -> { + if (attrLen >= 20) { + rand = ByteArray(16) + System.arraycopy(eapPacket, offset + 4, rand, 0, 16) + } + } + + AT_AUTN -> { + if (attrLen >= 20) { + autn = ByteArray(16) + System.arraycopy(eapPacket, offset + 4, autn, 0, 16) + } + } + } + offset += attrLen + if (rand != null && autn != null) break + } + + if (rand == null || autn == null) { + Log.e(TAG, "Missing RAND or AUTN in EAP-AKA challenge") + return null + } + + val challengeBytes = byteArrayOf(16) + rand + byteArrayOf(16) + autn + val challengeB64 = Base64.encodeToString(challengeBytes, Base64.NO_WRAP) + + val iccAuthResult = telephonyManager.getIccAuthentication( + TelephonyManager.APPTYPE_USIM, TelephonyManager.AUTHTYPE_EAP_AKA, challengeB64 + ) ?: run { + Log.e(TAG, "SIM returned null for AKA auth") + return null + } + + val iccBytes = Base64.decode(iccAuthResult, Base64.DEFAULT) + if (iccBytes.isEmpty()) return null + + return when (iccBytes[0]) { + SIM_RES_SUCCESS -> { + val res = extractTlv(1, iccBytes) ?: return null + 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) + + val kAut = keys["K_aut"] ?: run { + Log.e(TAG, "Failed to derive K_aut") + return null + } + + val responsePacket = buildEapAkaResponse(eapId, res, kAut) ?: return null + Base64.encodeToString(responsePacket, Base64.NO_WRAP) + } + + SIM_RES_SYNC_FAIL -> { + val auts = extractTlv(1, iccBytes) ?: return null + val responsePacket = buildEapAkaSyncFailure(eapId, auts) + Base64.encodeToString(responsePacket, Base64.NO_WRAP) + } + + else -> { + Log.e(TAG, "Unknown SIM response tag: ${iccBytes[0]}") + null + } + } + } + + 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" + } + + private fun extractTlv(index: Int, data: ByteArray): ByteArray? { + if (index >= data.size) return null + val len = data[index].toInt() and 0xFF + if (index + 1 + len > data.size) return null + return data.copyOfRange(index + 1, index + 1 + len) + } + + private fun buildEapAkaResponse(id: Byte, res: ByteArray, kAut: ByteArray): ByteArray? { + val resAttrLen = ((res.size + 4 + 3) / 4) * 4 + val totalLen = 8 + resAttrLen + 20 + val buffer = ByteBuffer.allocate(totalLen) + + buffer.put(EAP_CODE_RESPONSE.toByte()) + buffer.put(id) + buffer.putShort(totalLen.toShort()) + buffer.put(EAP_TYPE_AKA.toByte()) + buffer.put(EAP_AKA_SUBTYPE_CHALLENGE.toByte()) + buffer.putShort(0) + + buffer.put(AT_RES.toByte()) + buffer.put((resAttrLen / 4).toByte()) + buffer.putShort((res.size * 8).toShort()) + buffer.put(res) + + val padding = resAttrLen - 4 - res.size + if (padding > 0) buffer.put(ByteArray(padding)) + + buffer.position() + buffer.put(AT_MAC.toByte()) + buffer.put(5) + buffer.putShort(0) + val macValueOffset = buffer.position() + buffer.put(ByteArray(16)) + + val packet = buffer.array() + val mac = hmacSha1(kAut, packet) ?: return null + + System.arraycopy(mac, 0, packet, macValueOffset, 16) + return packet + } + + private fun buildEapAkaSyncFailure(id: Byte, auts: ByteArray): ByteArray { + val attrLen = ((auts.size + 2 + 3) / 4) * 4 + val totalLen = 8 + attrLen + + return ByteBuffer.allocate(totalLen).apply { + put(EAP_CODE_RESPONSE.toByte()) + put(id) + putShort(totalLen.toShort()) + put(EAP_TYPE_AKA.toByte()) + put(EAP_AKA_SUBTYPE_SYNC_FAILURE.toByte()) + putShort(0) + + put(AT_AUTS.toByte()) + put((attrLen / 4).toByte()) + put(auts) + + val padding = attrLen - 2 - auts.size + if (padding > 0) put(ByteArray(padding)) + }.array() + } + + private fun hmacSha1(key: ByteArray, data: ByteArray): ByteArray? = try { + Mac.getInstance("HmacSHA1").apply { init(SecretKeySpec(key, "HmacSHA1")) }.doFinal(data) + } catch (_: Exception) { + null + } +} diff --git a/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/Fips186Prf.kt b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/Fips186Prf.kt new file mode 100644 index 0000000000..618cb719cb --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/Fips186Prf.kt @@ -0,0 +1,108 @@ +package org.microg.gms.constellation.core.verification.ts43 + +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +object Fips186Prf { + fun deriveKeys( + identityBytes: ByteArray, + ik: ByteArray, + ck: ByteArray + ): Map { + val xKey = try { + val md = MessageDigest.getInstance("SHA-1") + md.update(identityBytes) + md.update(ik) + md.update(ck) + md.digest() + } catch (_: NoSuchAlgorithmException) { + ByteArray(20) + } + + if (xKey.size != 20) return emptyMap() + + val result = ByteArray(160) + val xKeyWorking = xKey.copyOf() + var resultOffset = 0 + + repeat(8) { + val h = intArrayOf( + 0x67452301, + 0xEFCDAB89.toInt(), + 0x98BADCFE.toInt(), + 0x10325476, + 0xC3D2E1F0.toInt() + ) + val w = IntArray(80) + for (k in 0 until 16) { + val wordIdx = k * 4 + w[k] = if (wordIdx < 20) { + ((xKeyWorking.getOrElse(wordIdx) { 0 }.toInt() and 0xFF) shl 24) or + ((xKeyWorking.getOrElse(wordIdx + 1) { 0 } + .toInt() and 0xFF) shl 16) or + ((xKeyWorking.getOrElse(wordIdx + 2) { 0 } + .toInt() and 0xFF) shl 8) or + (xKeyWorking.getOrElse(wordIdx + 3) { 0 }.toInt() and 0xFF) + } else 0 + } + for (k in 16 until 80) { + val temp = w[k - 3] xor w[k - 8] xor w[k - 14] xor w[k - 16] + w[k] = (temp shl 1) or (temp ushr 31) + } + var a = h[0] + var b = h[1] + var c = h[2] + var d = h[3] + var e = h[4] + for (t in 0 until 80) { + val f: Int + val k: Int + when { + t <= 19 -> { + f = (b and c) or (b.inv() and d); k = 0x5A827999 + } + + t <= 39 -> { + f = b xor c xor d; k = 0x6ED9EBA1 + } + + t <= 59 -> { + f = (b and c) or (b and d) or (c and d); k = 0x8F1BBCDC.toInt() + } + + else -> { + f = b xor c xor d; k = 0xCA62C1D6.toInt() + } + } + val temp = ((a shl 5) or (a ushr 27)) + f + e + k + w[t] + e = d; d = c; c = (b shl 30) or (b ushr 2); b = a; a = temp + } + val block = IntArray(5) + block[0] = h[0] + a; block[1] = h[1] + b; block[2] = h[2] + c; block[3] = + h[3] + d; block[4] = h[4] + e + for (k in 0 until 5) { + val word = block[k] + result[resultOffset++] = (word shr 24).toByte() + result[resultOffset++] = (word shr 16).toByte() + result[resultOffset++] = (word shr 8).toByte() + result[resultOffset++] = word.toByte() + } + var carry = 1 + for (k in 19 downTo 0) { + val resByte = result[resultOffset - 20 + k].toInt() and 0xFF + val keyByte = xKeyWorking[k].toInt() and 0xFF + val sum = carry + keyByte + resByte + xKeyWorking[k] = sum.toByte() + carry = sum shr 8 + } + } + + // RFC 4187 Section 7: PRF output slicing + return mapOf( + "K_encr" to result.copyOfRange(0, 16), + "K_aut" to result.copyOfRange(16, 32), + "MSK" to result.copyOfRange(32, 96), + "EMSK" to result.copyOfRange(96, 160) + ) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..f5966f6691 --- /dev/null +++ b/play-services-constellation/core/src/main/kotlin/org/microg/gms/constellation/core/verification/ts43/ServiceEntitlementExtension.kt @@ -0,0 +1,205 @@ +@file:RequiresApi(Build.VERSION_CODES.O) +@file:SuppressLint("HardwareIds", "MissingPermission") + +package org.microg.gms.constellation.core.verification.ts43 + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.telephony.TelephonyManager +import androidx.annotation.RequiresApi +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.microg.gms.constellation.core.proto.OdsaOperation +import org.microg.gms.constellation.core.proto.ServiceEntitlementRequest + +fun ServiceEntitlementRequest.builder( + telephonyManager: TelephonyManager, + eapId: String, + appIds: List = emptyList() +) = ServiceEntitlementBuilder( + imsi = telephonyManager.subscriberId ?: "", + iccid = try { + telephonyManager.simSerialNumber + } catch (_: Exception) { + null + }, + terminalId = telephonyManager.imei, + groupIdLevel1 = runCatching { telephonyManager.groupIdLevel1 }.getOrNull(), + eapId = eapId, + appIds = appIds, + req = this +) + +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) + return "PRD-TS43 term-$vendor/$model /$packageVersion OS-Android/$swVersion" +} + +fun OdsaOperation.builder( + telephonyManager: TelephonyManager, + serviceEntitlementRequest: ServiceEntitlementRequest? = null, + appIds: List = emptyList() +) = ServiceEntitlementBuilder( + imsi = telephonyManager.subscriberId ?: "", + iccid = try { + telephonyManager.simSerialNumber + } catch (_: Exception) { + null + }, + terminalId = telephonyManager.imei, + groupIdLevel1 = runCatching { telephonyManager.groupIdLevel1 }.getOrNull(), + eapId = "", // Not needed for ODSA + appIds = appIds, + req = serviceEntitlementRequest ?: ServiceEntitlementRequest(), + odsa = this +) + +class ServiceEntitlementBuilder( + private val imsi: String, + private val iccid: String?, + private val terminalId: String?, + private val groupIdLevel1: String?, + private val eapId: String, + private val appIds: List, + private val req: ServiceEntitlementRequest, + private val odsa: OdsaOperation? = null +) { + fun buildBaseUrl(entitlementUrl: String): HttpUrl { + 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) + + return baseUrl.newBuilder().apply { + when { + req.authentication_token.isNotEmpty() -> { + addQueryParameter("token", req.authentication_token) + if (imsi.isNotEmpty()) addQueryParameter("IMSI", imsi) + } + + req.temporary_token.isNotEmpty() -> { + addQueryParameter("temporary_token", req.temporary_token) + } + + eapId.isNotEmpty() -> { + addQueryParameter("EAP_ID", eapId) + } + } + addQueryParameter("terminal_id", terminalId ?: req.terminal_id) + if (req.gid1.isNotEmpty()) { + addQueryParameter("GID1", req.gid1) + } else if ((req.entitlement_version.toBigDecimalOrNull()?.toInt() ?: 0) >= 12) { + groupIdLevel1?.takeIf { it.isNotEmpty() }?.let { addQueryParameter("GID1", it) } + } + if (req.app_version.isNotEmpty()) addQueryParameter("app_version", req.app_version) + addQueryParameter("terminal_vendor", vendor) + addQueryParameter("terminal_model", model) + addQueryParameter("terminal_sw_version", swVersion) + addQueryParameter("app_name", req.app_name) + if (req.boost_type.isNotEmpty()) addQueryParameter("boost_type", req.boost_type) + if (appIds.isNotEmpty()) { + appIds.forEach { addQueryParameter("app", it) } + } else { + addQueryParameter("app", "ap2014") + } + addQueryParameter("vers", req.configuration_version.toString()) + addQueryParameter("entitlement_version", req.entitlement_version) + if (req.notification_token.isNotEmpty()) { + addQueryParameter("notif_action", req.notification_action.toString()) + addQueryParameter("notif_token", req.notification_token) + } + + // Handle ODSA specific fields if present + odsa?.let { + addQueryParameter("operation", it.operation) + if (it.operation_type != -1) { + addQueryParameter("operation_type", it.operation_type.toString()) + } + if (it.operation_targets.isNotEmpty()) { + addQueryParameter("operation_targets", it.operation_targets.joinToString(",")) + } + if (it.terminal_iccid.isNotEmpty()) addQueryParameter( + "terminal_iccid", + it.terminal_iccid + ) + else iccid?.let { i -> addQueryParameter("terminal_iccid", i) } + + if (it.terminal_eid.isNotEmpty()) addQueryParameter("terminal_eid", it.terminal_eid) + if (it.target_terminal_id.isNotEmpty()) addQueryParameter( + "target_terminal_id", + it.target_terminal_id + ) + if (it.target_terminal_iccid.isNotEmpty()) addQueryParameter( + "target_terminal_iccid", + it.target_terminal_iccid + ) + if (it.target_terminal_eid.isNotEmpty()) addQueryParameter( + "target_terminal_eid", + it.target_terminal_eid + ) + if (it.target_terminal_model.isNotEmpty()) addQueryParameter( + "target_terminal_model", + it.target_terminal_model + ) + if (it.target_terminal_serial_number.isNotEmpty()) addQueryParameter( + "target_terminal_sn", + it.target_terminal_serial_number + ) + + it.target_terminal_ids.forEach { id -> + addQueryParameter("target_terminal_imeis", id) + } + + if (it.old_terminal_id.isNotEmpty()) addQueryParameter( + "old_terminal_id", + it.old_terminal_id + ) + if (it.old_terminal_iccid.isNotEmpty()) addQueryParameter( + "old_terminal_iccid", + it.old_terminal_iccid + ) + + // Companion fields + if (it.companion_terminal_id.isNotEmpty()) addQueryParameter( + "companion_terminal_id", + it.companion_terminal_id + ) + if (it.companion_terminal_vendor.isNotEmpty()) addQueryParameter( + "companion_terminal_vendor", + it.companion_terminal_vendor + ) + if (it.companion_terminal_model.isNotEmpty()) addQueryParameter( + "companion_terminal_model", + it.companion_terminal_model + ) + if (it.companion_terminal_software_version.isNotEmpty()) addQueryParameter( + "companion_terminal_sw_version", + it.companion_terminal_software_version + ) + if (it.companion_terminal_friendly_name.isNotEmpty()) addQueryParameter( + "companion_terminal_friendly_name", + it.companion_terminal_friendly_name + ) + if (it.companion_terminal_service.isNotEmpty()) addQueryParameter( + "companion_terminal_service", + it.companion_terminal_service + ) + if (it.companion_terminal_iccid.isNotEmpty()) addQueryParameter( + "companion_terminal_iccid", + it.companion_terminal_iccid + ) + if (it.companion_terminal_eid.isNotEmpty()) addQueryParameter( + "companion_terminal_eid", + it.companion_terminal_eid + ) + } + }.build() + } +} diff --git a/play-services-constellation/core/src/main/proto/constellation.proto b/play-services-constellation/core/src/main/proto/constellation.proto new file mode 100644 index 0000000000..98297b63b2 --- /dev/null +++ b/play-services-constellation/core/src/main/proto/constellation.proto @@ -0,0 +1,1080 @@ +syntax = "proto3"; +option java_package = "org.microg.gms.constellation.core.proto"; + +package google.internal.communications.phonedeviceverification.v1; + +import "google/protobuf/timestamp.proto"; + +service PhoneDeviceVerification { + rpc GetConsent(GetConsentRequest) returns (GetConsentResponse); + rpc SetConsent(SetConsentRequest) returns (SetConsentResponse); + rpc Sync(SyncRequest) returns (SyncResponse); + rpc Proceed(ProceedRequest) returns (ProceedResponse); +} + +service PhoneNumber { + rpc GetVerifiedPhoneNumbers(GetVerifiedPhoneNumbersRequest) returns (GetVerifiedPhoneNumbersResponse); +} + +message RequestHeader { + ClientInfo client_info = 1; + ClientAuth client_auth = 2; + string session_id = 3; + RequestTrigger trigger = 4; +} + +message ResponseHeader { + StatusProtoSimple status = 1; +} + +message StatusProtoSimple { + int32 code = 1; +} + +enum StatusCode { + STATUS_CODE_UNSPECIFIED = 0; + STATUS_CODE_OK = 1; + STATUS_CODE_ERROR = 2; +} + +message ClientInfo { + DeviceID device_id = 1; + bytes client_public_key = 2; + string locale = 3; + int32 gmscore_version_number = 4; + string gmscore_version = 5; + int32 android_sdk_version = 6; + DroidGuardSignals droidguard_signals = 8; + repeated Experiment experiments = 9; + UserProfileType user_profile_type = 11; + repeated GaiaToken gaia_tokens = 12; + CountryInfo country_info = 13; + repeated NetworkSignal connectivity_infos = 14; + string model = 15; + string manufacturer = 16; + repeated SimOperatorInfo partial_sim_infos = 17; + DeviceType device_type = 18; + bool is_wearable_standalone = 19; + GaiaSignals gaia_signals = 20; + string device_fingerprint = 21; +} + +enum UserProfileType { + UNKNOWN_PROFILE_TYPE = 0; + REGULAR_USER = 1; + MANAGED_PROFILE = 2; +} + +enum DeviceType { + DEVICE_TYPE_UNKNOWN = 0; + DEVICE_TYPE_PHONE = 1; + DEVICE_TYPE_PHONE_GO = 2; + DEVICE_TYPE_TV = 3; + DEVICE_TYPE_WEARABLE = 4; + DEVICE_TYPE_AUTOMOTIVE = 5; + DEVICE_TYPE_BATTLESTAR = 6; + DEVICE_TYPE_CHROME_OS = 7; + DEVICE_TYPE_XR = 8; + DEVICE_TYPE_DESKTOP = 9; + DEVICE_TYPE_XR_PERIPHERAL = 10; +} + +message ClientAuth { + DeviceID device_id = 1; + bytes signature = 2; + google.protobuf.Timestamp sign_timestamp = 3; +} + +message CountryInfo { + repeated string sim_countries = 1; + repeated string network_countries = 2; +} + +message DroidGuardSignals { + string droidguard_result = 1; + string droidguard_token = 2; +} + +message DeviceID { + string iid_token = 1; + int64 primary_device_id = 2; + int64 user_serial = 3; + int64 gms_android_id = 4; +} + +message RequestTrigger { + enum Type { + UNKNOWN = 0; + PERIODIC_CONSENT_CHECK = 1; + PERIODIC_REFRESH = 2; + SIM_STATE_CHANGED = 3; + GAIA_CHANGE_EVENT = 4; + USER_SETTINGS = 5; + DEBUG_SETTINGS = 6; + TRIGGER_API_CALL = 7; + REBOOT_CHECKER = 8; + SERVER_TRIGGER = 9; + FAILURE_RETRY = 10; + CONSENT_API_TRIGGER = 11; + PNVR_DEVICE_SETTINGS = 12; + } + Type type = 1; +} + +message DroidGuardAttestation { + string droidguard_result = 1; + string droidguard_token = 2; +} + +message Experiment { + string key = 1; + string value = 2; +} + +message GaiaID { + string id = 1; +} + +message GaiaToken { + string token = 1; +} + +message GaiaSignalEntry { + string gaia_id = 1; + GaiaAccountSignalType signal_type = 2; + google.protobuf.Timestamp timestamp = 3; +} + +enum GaiaAccountSignalType { + GAIA_ACCOUNT_SIGNAL_UNSPECIFIED = 0; + GAIA_ACCOUNT_SIGNAL_AUTHENTICATED = 1; + GAIA_ACCOUNT_SIGNAL_UNAUTHENTICATED = 2; + GAIA_ACCOUNT_SIGNAL_REMOVED_WITH_GMS_AUTH_BROADCAST = 3; +} + +message GaiaSignals { + repeated GaiaSignalEntry gaia_signals = 1; +} + +message Status { + int32 code = 1; +} + +message VerificationPolicy { + string policy_id = 1; + int64 max_verification_age_hours = 2; + IdTokenRequest id_token_request = 3; + string calling_package = 4; + repeated VerificationParam params = 5; +} + +message IdTokenRequest { + string certificate_hash = 1; + string token_nonce = 2; +} + +message VerificationParam { + string key = 1; + string value = 2; +} + +message SimOperatorInfo { + string imsi_hash = 1; + + string sim_operator = 3; +} + +message NetworkSignal { + enum Type { + TYPE_UNKNOWN = 0; + TYPE_WIFI = 1; + TYPE_MOBILE = 2; + } + enum Availability { + AVAILABILITY_UNKNOWN = 0; + AVAILABLE = 1; + NOT_AVAILABLE = 2; + } + enum State { + STATE_UNKNOWN = 0; + STATE_CONNECTING = 1; + STATE_CONNECTED = 2; + STATE_DISCONNECTING = 3; + STATE_DISCONNECTED = 4; + STATE_SUSPENDED = 5; + } + Type type = 1; + State state = 2; + Availability availability = 3; +} + +message MobileOperatorInfo { + string country_code = 1; + uint64 nil_since_usec = 2; + string operator = 3; + uint32 nil_since_micros = 4; + string operator_name = 5; +} + +message SyncRequest { + repeated Verification verifications = 3; + RequestHeader header = 4; + repeated VerificationToken verification_tokens = 5; +} + +message SyncResponse { + repeated VerificationResponse responses = 1; + ServerTimestamp next_sync_time = 2; + ResponseHeader header = 3; + DroidguardToken droidguard_token = 4; + repeated VerificationToken verification_tokens = 5; +} + +message ProceedRequest { + Verification verification = 2; + ChallengeResponse challenge_response = 3; + RequestHeader header = 4; +} + +message ProceedResponse { + Verification verification = 1; + ResponseHeader header = 2; + ServerTimestamp next_sync_time = 3; + DroidguardToken droidguard_token = 4; +} + +message ServerTimestamp { + google.protobuf.Timestamp timestamp = 1; + google.protobuf.Timestamp now = 2; +} + +message VerificationToken { + bytes token = 1; + google.protobuf.Timestamp expiration_time = 2; +} + +message Verification { + enum State { + UNKNOWN = 0; + NONE = 1; + PENDING = 2; + VERIFIED = 3; + } + + enum Status { + STATUS_UNKNOWN = 0; + STATUS_NONE = 1; + STATUS_PENDING = 2; + STATUS_VERIFIED = 3; + STATUS_THROTTLED = 4; + STATUS_FAILED = 5; + STATUS_SKIPPED = 6; + STATUS_NOT_REQUIRED = 7; + STATUS_PHONE_NUMBER_ENTRY_REQUIRED = 8; + STATUS_INELIGIBLE = 9; + STATUS_DENIED = 10; + STATUS_NOT_IN_SERVICE = 11; + } + + VerificationAssociation association = 1; + Status status = 2; + oneof info { + VerificationInfo verification_info = 3; + PendingVerificationInfo pending_verification_info = 4; + UnverifiedInfo unverified_info = 9; + } + TelephonyInfo telephony_info = 5; + repeated Param api_params = 6; + ChallengePreference challenge_preference = 7; + VerificationPolicy structured_api_params = 8; +} + +message VerificationResponse { + Verification verification = 1; + StatusProto error = 2; +} + +message StatusProto { + int32 code = 1; + string message = 3; +} + +message SIMSlotInfo { + int32 slot_index = 1; + int32 subscription_id = 2; +} + +message SIMAssociation { + + message SIMInfo { + repeated string imsi = 1; + string sim_readable_number = 2; + repeated TelephonyPhoneNumber telephony_phone_number = 3; + string iccid = 4; + } + + message TelephonyPhoneNumber { + string phone_number = 1; + TelephonyPhoneNumberType phone_number_type = 2; + } + + SIMInfo sim_info = 1; + repeated GaiaToken gaia_tokens = 2; + SIMSlotInfo sim_slot = 4; +} + +message GaiaAssociation { +} + +message VerificationAssociation { + oneof association { + SIMAssociation sim = 1; + GaiaAssociation gaia = 2; + } +} + +message VerificationInfo { + string phone_number = 1; + google.protobuf.Timestamp verification_time = 2; + VerificationMethod challenge_method = 6; +} + +message PendingVerificationInfo { + Challenge challenge = 2; +} + +message Challenge { + ChallengeID challenge_id = 1; + VerificationMethod type = 2; + MoChallenge mo_challenge = 3; + CarrierIdChallenge carrier_id_challenge = 4; + ServerTimestamp expiry_time = 5; + MTChallenge mt_challenge = 6; + RegisteredSmsChallenge registered_sms_challenge = 7; + FlashCallChallenge flash_call_challenge = 8; + Ts43Challenge ts43_challenge = 12; +} + +message CarrierIdChallenge { + string isim_request = 3; + int32 auth_type = 5; + int32 app_type = 6; +} + +message MTChallenge { + string sms = 1; +} + +message ChallengeID { + string id = 1; +} + +message Capabilities { + string droidguard_result = 1; + string droidguard_token = 2; +} + +message MoChallenge { + string proxy_number = 1; + DataSmsInfo data_sms_info = 3; + string sms = 4; + string polling_intervals = 5; + string sms_without_persisting = 6; +} + +message DataSmsInfo { + int32 destination_port = 1; +} + +message RegisteredSmsChallenge { + repeated PhoneNumberID verified_senders = 1; +} + +message PhoneNumberID { + bytes phone_number_id = 1; +} + +message FlashCallChallenge { + repeated PhoneRange phone_ranges = 1; + FlashCallState state = 2; + repeated ChallengeID previous_challenge_ids = 3; + repeated ChallengeResponse previous_challenge_responses = 4; + int64 millis_between_interceptions = 5; +} + +message PhoneRange { + string phone_number_prefix = 1; + string phone_number_suffix = 2; + string country_code = 3; + string carrier = 4; +} + +enum FlashCallState { + FLASH_CALL_STATE_UNKNOWN = 0; + FLASH_CALL_STATE_PREPARING = 1; + FLASH_CALL_STATE_VERIFYING = 2; + FLASH_CALL_STATE_VERIFIED = 3; + FLASH_CALL_STATE_FAILED = 4; +} + +message UnverifiedInfo { + enum Reason { + UNKNOWN_REASON = 0; + THROTTLED = 1; + FAILED = 2; + SKIPPED = 3; + NOT_REQUIRED = 4; + PHONE_NUMBER_ENTRY_REQUIRED = 5; + INELIGIBLE = 6; + DENIED = 7; + NOT_IN_SERVICE = 8; + } + Reason reason = 1; + google.protobuf.Timestamp retry_after_time = 2; + VerificationMethod challenge_method = 3; +} + +message ChallengeResponse { + MTChallengeResponseData mt_response = 1; + CarrierIdChallengeResponse carrier_id_response = 2; + MOChallengeResponseData mo_response = 3; + RegisteredSmsChallengeResponse registered_sms_response = 6; + FlashCallChallengeResponse flash_call_response = 7; + Ts43ChallengeResponse ts43_challenge_response = 9; +} + +message MTChallengeResponseData { + string sms = 1; + string sender = 2; +} + +message CarrierIdChallengeResponse { + string isim_response = 3; + CarrierIdError carrier_id_error = 4; +} + +enum CarrierIdError { + CARRIER_ID_ERROR_NO_ERROR = 0; + CARRIER_ID_ERROR_NOT_SUPPORTED = 1; + CARRIER_ID_ERROR_RETRY_ATTEMPT_EXCEEDED = 2; + CARRIER_ID_ERROR_NULL_RESPONSE = 3; + CARRIER_ID_ERROR_REFLECTION_ERROR = 4; + CARRIER_ID_ERROR_NO_SIM = 5; + CARRIER_ID_ERROR_UNABLE_TO_READ_SUBSCRIPTION = 6; + CARRIER_ID_ERROR_UNKNOWN_ERROR = 7; + CARRIER_ID_ERROR_ENTITLEMENT_SERVER_ERROR = 8; + CARRIER_ID_ERROR_JSON_PARSE_ERROR = 9; + CARRIER_ID_ERROR_INTERNAL_ERROR = 10; + CARRIER_ID_ERROR_INVALID_ARGUMENT = 11; +} + +message MOChallengeResponseData { + enum Status { + UNKNOWN_STATUS = 0; + COMPLETED = 1; + FAILED_TO_SEND_MO = 2; + NO_ACTIVE_SUBSCRIPTION = 3; + NO_SMS_MANAGER = 4; + } + Status status = 1; + int64 sms_result_code = 2; + int64 sms_error_code = 3; +} + +message RegisteredSmsChallengeResponse { + repeated RegisteredSmsChallengeResponseItem items = 1; +} + +message RegisteredSmsChallengeResponseItem { + RegisteredSmsChallengeResponsePayload payload = 2; +} + +message RegisteredSmsChallengeResponsePayload { + bytes payload = 1; +} + +message Ts43ChallengeResponse { + Ts43Type ts43_type = 1; + ClientChallengeResponse client_challenge_response = 2; + ServerChallengeResponse server_challenge_response = 3; + Ts43ChallengeResponseStatus status = 4; + repeated string http_history = 5; +} + +message Ts43ChallengeResponseStatus { + enum Code { + TS43_STATUS_UNSPECIFIED = 0; + TS43_STATUS_NOT_SUPPORTED = 1; + TS43_STATUS_CHALLENGE_NOT_SET = 2; + TS43_STATUS_INTERNAL_ERROR = 3; + TS43_STATUS_RUNTIME_ERROR = 4; + TS43_STATUS_JSON_PARSE_ERROR = 5; + } + + oneof result { + Code status_code = 1; + Ts43ChallengeResponseError error = 2; + } +} + +message Ts43ChallengeResponseError { + enum Code { + TS43_ERROR_CODE_UNSPECIFIED = 0; + TS43_ERROR_CODE_1 = 1; + TS43_ERROR_CODE_2 = 2; + TS43_ERROR_CODE_3 = 3; + TS43_ERROR_CODE_4 = 4; + TS43_ERROR_CODE_5 = 5; + TS43_ERROR_CODE_6 = 6; + TS43_ERROR_CODE_7 = 7; + TS43_ERROR_CODE_8 = 8; + TS43_ERROR_CODE_9 = 9; + TS43_ERROR_CODE_PHONE_UNAVAILABLE = 10; + TS43_ERROR_CODE_INVALID_IMSI_OR_MCCMNC = 11; + TS43_ERROR_CODE_EAP_AKA_CHALLENGE_FAILED = 20; + TS43_ERROR_CODE_EAP_AKA_SYNCHRONIZATION_FAILURE = 21; + TS43_ERROR_CODE_EAP_AKA_AUTHENTICATION_FAILED = 22; + TS43_ERROR_CODE_CONNECTIVITY_FAILURE = 30; + TS43_ERROR_CODE_HTTP_ERROR_STATUS = 31; + TS43_ERROR_CODE_HTTP_MALFORMED_RESPONSE = 32; + TS43_ERROR_CODE_TEMPORARY_TOKEN_UNAVAILABLE = 60; + } + + enum RequestType { + TS43_REQUEST_TYPE_UNSPECIFIED = 0; + TS43_REQUEST_TYPE_AUTH_API = 1; + TS43_REQUEST_TYPE_GET_PHONE_NUMBER_API = 2; + TS43_REQUEST_TYPE_ACQUIRE_TEMPORARY_TOKEN_API = 3; + TS43_REQUEST_TYPE_SERVICE_ENTITLEMENT_API = 4; + } + + Code error_code = 1; + int32 http_status = 2; + RequestType request_type = 3; +} + +message ServerChallengeResponse { + string acquire_temporary_token_response = 2; +} + +message ClientChallengeResponse { + string get_phone_number_response = 2; +} + +message FlashCallChallengeResponse { + enum Error { + NO_ERROR = 0; + UNSPECIFIED = 1; + TIMED_OUT = 2; + NETWORK_NOT_AVAILABLE = 3; + TOO_MANY_CALLS = 4; + CONCURRENT_REQUESTS = 5; + IN_ECBM = 6; + IN_EMERGENCY_CALL = 7; + PRECONDITIONS_FAILED = 8; + API_NOT_AVAILABLE = 9; + ERROR_PREVIOUS_INCOMING_CALL = 10; + STATE_NOT_PREPARING = 11; + STATE_NOT_VERIFYING = 12; + ERROR_PENDING_VERIFICATION = 13; + PROCEED_FAILED = 14; + INTERCEPTION_FAILED = 15; + } + string caller = 1; + Error error = 2; +} + +message Ts43Type { + enum Integrator { + TS43_INTEGRATOR_UNSPECIFIED = 0; + JIO = 1; + TELUS = 2; + ERICSSON = 3; + HPE = 4; + TMO = 5; + TMO_SERVER = 6; + TELENOR = 7; + RCS_CIS_PROXY = 8; + MOBI_US = 9; + SFR = 10; + SASKTEL_CANADA = 11; + DT = 13; + DT_SERVER = 14; + NETLYNC = 17; + ORANGE_FRANCE = 18; + AMDOCS = 19; + } + Integrator integrator = 1; +} + +message CellularNetworkEvent { + google.protobuf.Timestamp timestamp = 1; + bool mobile_data_enabled = 2; + bool airplane_mode_enabled = 3; + bool data_roaming_enabled = 4; + bool mobile_data_always_on = 5; + repeated NetworkState networks = 6; +} + +message NetworkState { + repeated int32 types = 1; + bool available = 2; +} + +message ServiceStateEvent { + google.protobuf.Timestamp timestamp = 1; + bool airplane_mode_enabled = 2; + bool mobile_data_enabled = 3; + int32 voice_registration_state = 4; + int32 data_registration_state = 5; + int32 voice_network_type = 6; + int32 data_network_type = 7; + int32 signal_strength = 8; +} + +message SMSEvent { + google.protobuf.Timestamp timestamp = 1; + EventDirection direction = 2; + EventPhoneNumberType number_type = 3; +} + +message CallEvent { + google.protobuf.Timestamp timestamp = 1; + EventDirection direction = 2; +} + +enum EventDirection { + UNKNOWN_DIRECTION = 0; + INCOMING = 1; + OUTGOING = 2; + MISSED = 3; +} + +enum EventPhoneNumberType { + UNKNOWN_TYPE = 0; + LONG_NUMBER = 1; + SHORT_CODE = 2; +} + +message TelephonyInfo { + enum PhoneType { + PHONE_TYPE_UNKNOWN = 0; + PHONE_TYPE_GSM = 1; + PHONE_TYPE_CDMA = 2; + PHONE_TYPE_SIP = 3; + } + enum SimState { + SIM_STATE_UNKNOWN = 0; + SIM_STATE_NOT_READY = 1; + SIM_STATE_READY = 2; + } + enum RoamingState { + ROAMING_UNKNOWN = 0; + ROAMING_HOME = 1; + ROAMING_ROAMING = 2; + } + + enum ConnectivityState { + CONNECTIVITY_UNKNOWN = 0; + CONNECTIVITY_HOME = 1; + CONNECTIVITY_ROAMING = 2; + } + enum SmsCapability { + SMS_UNKNOWN = 0; + SMS_NOT_CAPABLE = 1; + SMS_CAPABLE = 2; + SMS_DEFAULT_CAPABILITY = 3; + SMS_RESTRICTED = 4; + } + enum ToggleState { + TOGGLE_UNKNOWN = 0; + TOGGLE_DISABLED = 1; + TOGGLE_ENABLED = 2; + } + enum ServiceState { + SERVICE_STATE_UNKNOWN = 0; + SERVICE_STATE_IN_SERVICE = 1; + SERVICE_STATE_OUT_OF_SERVICE = 2; + SERVICE_STATE_EMERGENCY_ONLY = 3; + SERVICE_STATE_POWER_OFF = 4; + } + + PhoneType phone_type = 1; + string group_id_level1 = 2; + SimNetworkInfo sim_info = 3; + SimNetworkInfo network_info = 4; + RoamingState network_roaming = 5; + ConnectivityState connectivity_state = 6; + SmsCapability sms_capability = 7; + int32 active_sub_count = 8; + int32 active_sub_count_max = 9; + ToggleState vowifi_state = 11; + ToggleState sms_no_confirmation_state = 12; + SimState sim_state = 13; + string device_id = 15; + ServiceState service_state = 16; + repeated CellularNetworkEvent cellular_events = 17; + repeated CallEvent call_events = 18; + repeated SMSEvent sms_events = 19; + repeated ServiceStateEvent service_events = 20; + bool is_embedded = 21; + ToggleState carrier_id_capability = 23; + int64 sim_carrier_id = 25; +} + +message SimNetworkInfo { + string country_iso = 1; + string operator = 2; + string operator_name = 3; + int32 inactive_time_diff_ms = 4; +} + +message DroidguardToken { + string token = 1; + google.protobuf.Timestamp ttl = 2; +} + +message GetConsentRequest { + DeviceID device_id = 1; + repeated GaiaToken gaia_tokens = 2; + RequestHeader header = 4; + repeated VerificationToken verification_tokens = 5; + AsterismClient asterism_client = 6; + VerificationPolicy structured_api_params = 7; + bool force_refresh = 8; + string session_id = 9; + bool unknown_flag = 10; +} + +message GetConsentResponse { + RcsConsent rcs_consent = 1; + ServerTimestamp next_check_time = 5; + ConsentState device_consent = 6; + repeated GaiaConsent gaia_consents = 8; + DroidguardToken droidguard_token = 9; + PermissionState permission_state = 10; +} + +enum ConsentVersion { + CONSENT_VERSION_UNSPECIFIED = 0; + RCS_CONSENT = 1; + RCS_DEFAULT_ON_LEGAL_FYI = 2; + RCS_DEFAULT_ON_OUT_OF_BOX = 3; + RCS_SAMSUNG_UNFREEZE = 4; + RCS_DEFAULT_ON_LEGAL_FYI_IN_SETTINGS = 5; +} + +message SetConsentRequest { + RequestHeader header = 1; + RcsConsent rcs_consent = 3; + AsterismClient asterism_client = 4; + bytes audit_record = 6; + repeated Param api_params = 7; + OnDemandConsent on_demand_consent = 8; + ConsentVersion consent_version = 9; + oneof device_consent_oneof { + AsterismConsent device_consent = 10; + } +} + +message SetConsentResponse { +} + +enum Consent { + CONSENT_UNKNOWN = 0; + CONSENTED = 1; + NO_CONSENT = 2; + EXPIRED = 3; +} + +message AsterismConsent { + Consent consent = 1; + ConsentSource consent_source = 2; + DeviceConsentVersion consent_version = 3; + + enum DeviceConsentVersion { + UNKNOWN = 0; + PHONE_VERIFICATION_DEFAULT = 1; + PHONE_VERIFICATION_MESSAGES_CALLS_V1 = 2; + PHONE_VERIFICATION_INTL_SMS_CALLS = 3; + PHONE_VERIFICATION_REACHABILITY_INTL_SMS_CALLS = 4; + } +} + +message ConsentState { + Consent state = 2; +} + +message PermissionState { + int32 flags = 1; + PermissionType type = 2; +} + +enum PermissionType { + PERMISSION_TYPE_UNSPECIFIED = 0; + LEGACY_DPNV = 1; + PNVR = 2; + NOT_ALLOWED = 3; +} + +message RcsConsent { + Consent consent = 2; + ConsentVersion consent_version = 3; +} + +message GaiaConsent { + AsterismClient asterism_client = 1; + Consent consent = 2; + ConsentVersion consent_version = 3; +} + +message OnDemandConsent { + Consent consent = 1; + GaiaToken gaia_token = 2; + string consent_variant = 3; + string trigger = 4; +} + +enum ConsentSource { + SOURCE_UNSPECIFIED = 0; + ANDROID_DEVICE_SETTINGS = 1; + GAIA_USERNAME_RECOVERY = 2; + AOB_SETUP_WIZARD = 3; + MINUTEMAID_JS_BRIDGE = 4; + GAIA_WEB_JS_BRIDGE = 5; + AM_PROFILES = 6; + MEET_ON_DEMAND_CONSENT = 7; + GPAY_ON_DEMAND_CONSENT = 8; +} + +enum AsterismClient { + UNKNOWN_CLIENT = 0; + CONSTELLATION = 1; + RCS = 2; + ONE_TIME_VERIFICATION = 3; +} + +message GetVerifiedPhoneNumbersRequest { + enum PhoneNumberSelection { + SELECTION_UNSPECIFIED = 0; + CONSTELLATION = 1; + RCS = 2; + } + + string session_id = 1; + IIDTokenAuth iid_token_auth = 2; + repeated PhoneNumberSelection phone_number_selections = 3; + TokenOption token_option = 4; + string droidguard_result = 5; + ConsistencyOption consistency_option = 6; + RequestInfo request_info = 7; +} + +message GetVerifiedPhoneNumbersResponse { + repeated VerifiedPhoneNumber phone_numbers = 2; +} + +message IIDTokenAuth { + string iid_token = 1; + bytes client_sign = 2; + google.protobuf.Timestamp sign_timestamp = 3; +} + +message TokenOption { + string certificate_hash = 1; + string token_nonce = 2; + string package_name = 3; +} + +message VerifiedPhoneNumber { + string phone_number = 1; + google.protobuf.Timestamp verification_time = 2; + string id_token = 3; + RcsState rcs_state = 4; +} + +enum RcsState { + STATE_UNSPECIFIED = 0; + ACTIVE = 1; +} + +enum TelephonyPhoneNumberType { + PHONE_NUMBER_SOURCE_UNSPECIFIED = 0; + PHONE_NUMBER_SOURCE_CARRIER = 1; + PHONE_NUMBER_SOURCE_UICC = 2; + PHONE_NUMBER_SOURCE_IMS = 3; +} + +message ConsistencyOption { + enum Consistency { + CONSISTENCY_UNSPECIFIED = 0; + STALE = 1; + STRONG = 2; + } + Consistency consistency = 1; +} + +message RequestInfo { + string policy_id = 1; +} + +message Param { + string key = 1; + string value = 2; +} + +message ChallengePreference { + repeated VerificationMethod capabilities = 1; + ChallengePreferenceMetadata metadata = 2; +} + +message ChallengePreferenceMetadata { + string sms_signature = 2; +} + +enum VerificationMethod { + UNKNOWN = 0; + MO_SMS = 1; + MT_SMS = 2; + CARRIER_ID = 3; + IMSI_LOOKUP = 5; + REGISTERED_SMS = 7; + FLASH_CALL = 8; + TS43 = 11; +} + +message AuditToken { + AuditTokenMetadata metadata = 2; +} + +message AuditTokenMetadata { + AuditUuid uuid = 1; +} + +message AuditUuid { + int64 uuid_msb = 1; + int64 uuid_lsb = 2; +} + +message AuditPayload { + AuditDeviceInfo device_info = 1; + AuditDeviceId device_id = 2; + AuditEventMetadata event_metadata = 10; +} + +message AuditDeviceInfo { + oneof device_identifier { + string android_id_hash = 1; + string instance_id = 2; + } +} + +message AuditDeviceId { + string android_id_hash = 1; +} + +message AuditEventMetadata { + AuditEventType event_type = 1; + string session_id = 2; + int64 event_timestamp = 3; + AuditConsentDetails consent_details = 4; + string package_name = 5; + string tos_url = 6; + string tos_version = 7; + string language = 8; + string country = 9; + string gaia_id = 10; + AuditComponentInfo component_info = 11; + string trigger = 12; +} + +enum AuditEventType { + EVENT_TYPE_UNSPECIFIED = 0; + ASTERISM_CONSENT_CHANGE = 1; + RCS_CONSENT_CHANGE = 2; + VERIFICATION_COMPLETE = 3; +} + +message AuditConsentDetails { + repeated int32 sim_slot_ids = 1; +} + +message AuditComponentInfo { + AuditComponentType component_type = 1; +} + +enum AuditComponentType { + COMPONENT_UNSPECIFIED = 0; + ASTERISM_CONSTELLATION = 119; + ASTERISM_RCS = 120; +} + +message AuditConsentState { + Consent state = 1; +} + +enum AuditEventTypeValue { + EVENT_VALUE_UNSPECIFIED = 0; + ASTERISM_CLIENT_CONSENT_CHANGE = 187; +} + +message Ts43Challenge { + Ts43Type ts43_type = 1; + string entitlement_url = 2; + ServiceEntitlementRequest service_entitlement_request = 3; + ClientChallenge client_challenge = 4; + ServerChallenge server_challenge = 5; + string app_id = 6; + string eap_aka_realm = 7; +} + +message ServerChallenge { + OdsaOperation operation = 1; +} + +message ClientChallenge { + OdsaOperation operation = 1; +} + +message OdsaOperation { + string operation = 1; + int32 operation_type = 2; + repeated string operation_targets = 3; + string companion_terminal_id = 4; + string companion_terminal_vendor = 5; + string companion_terminal_model = 6; + string companion_terminal_software_version = 7; + string companion_terminal_friendly_name = 8; + string companion_terminal_service = 9; + string companion_terminal_iccid = 10; + string companion_terminal_eid = 11; + string terminal_iccid = 12; + string terminal_eid = 13; + string target_terminal_id = 14; + repeated string target_terminal_ids = 15; + string target_terminal_iccid = 16; + string target_terminal_eid = 17; + string target_terminal_serial_number = 18; + string target_terminal_model = 19; + string old_terminal_id = 20; + string old_terminal_iccid = 21; +} + +message ServiceEntitlementRequest { + int32 notification_action = 1; + string entitlement_version = 2; + string temporary_token = 3; + string authentication_token = 4; + string terminal_id = 5; + string terminal_vendor = 6; + string terminal_model = 7; + string terminal_software_version = 8; + string app_name = 9; + string app_version = 10; + string notification_token = 11; + int32 configuration_version = 12; + string accept_content_type = 13; + string boost_type = 14; + string gid1 = 15; +} + diff --git a/play-services-constellation/src/main/AndroidManifest.xml b/play-services-constellation/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..670e962e03 --- /dev/null +++ b/play-services-constellation/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetIidTokenRequest.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetIidTokenRequest.aidl new file mode 100644 index 0000000000..4621cec67a --- /dev/null +++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetIidTokenRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.constellation; + +parcelable GetIidTokenRequest; diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetIidTokenResponse.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetIidTokenResponse.aidl new file mode 100644 index 0000000000..832407aa6e --- /dev/null +++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetIidTokenResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.constellation; + +parcelable GetIidTokenResponse; diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.aidl new file mode 100644 index 0000000000..ffd7ecad31 --- /dev/null +++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.constellation; + +parcelable GetPnvCapabilitiesRequest; \ No newline at end of file diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.aidl new file mode 100644 index 0000000000..1b318c7ec7 --- /dev/null +++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.constellation; + +parcelable GetPnvCapabilitiesResponse; \ No newline at end of file diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/PhoneNumberInfo.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/PhoneNumberInfo.aidl new file mode 100644 index 0000000000..b6889c7bd4 --- /dev/null +++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/PhoneNumberInfo.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.constellation; + +parcelable PhoneNumberInfo; diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberRequest.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberRequest.aidl new file mode 100644 index 0000000000..f5ca759758 --- /dev/null +++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberRequest.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.constellation; + +parcelable VerifyPhoneNumberRequest; diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberResponse.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberResponse.aidl new file mode 100644 index 0000000000..31eb20049c --- /dev/null +++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/VerifyPhoneNumberResponse.aidl @@ -0,0 +1,3 @@ +package com.google.android.gms.constellation; + +parcelable VerifyPhoneNumberResponse; diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationApiService.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationApiService.aidl new file mode 100644 index 0000000000..912acc7371 --- /dev/null +++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationApiService.aidl @@ -0,0 +1,39 @@ +package com.google.android.gms.constellation.internal; + +import com.google.android.gms.common.api.ApiMetadata; +import com.google.android.gms.constellation.internal.IConstellationCallbacks; +import com.google.android.gms.constellation.GetIidTokenRequest; +import com.google.android.gms.constellation.GetPnvCapabilitiesRequest; +import com.google.android.gms.constellation.VerifyPhoneNumberRequest; + +interface IConstellationApiService { + void verifyPhoneNumberV1( + IConstellationCallbacks cb, + in Bundle bundle, + in ApiMetadata apiMetadata + ); + + void verifyPhoneNumberSingleUse( + IConstellationCallbacks cb, + in Bundle bundle, + in ApiMetadata apiMetadata + ); + + void verifyPhoneNumber( + IConstellationCallbacks cb, + in VerifyPhoneNumberRequest request, + in ApiMetadata apiMetadata + ); + + void getIidToken( + IConstellationCallbacks cb, + in GetIidTokenRequest request, + in ApiMetadata apiMetadata + ); + + void getPnvCapabilities( + IConstellationCallbacks cb, + in GetPnvCapabilitiesRequest request, + in ApiMetadata apiMetadata + ); +} diff --git a/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationCallbacks.aidl b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationCallbacks.aidl new file mode 100644 index 0000000000..74ce532b86 --- /dev/null +++ b/play-services-constellation/src/main/aidl/com/google/android/gms/constellation/internal/IConstellationCallbacks.aidl @@ -0,0 +1,15 @@ +package com.google.android.gms.constellation.internal; + +import com.google.android.gms.common.api.ApiMetadata; +import com.google.android.gms.common.api.Status; +import com.google.android.gms.constellation.GetIidTokenResponse; +import com.google.android.gms.constellation.GetPnvCapabilitiesResponse; +import com.google.android.gms.constellation.PhoneNumberInfo; +import com.google.android.gms.constellation.VerifyPhoneNumberResponse; + +oneway interface IConstellationCallbacks { + void onPhoneNumberVerified(in Status status, in List phoneNumbers, in ApiMetadata apiMetadata); + void onPhoneNumberVerificationsCompleted(in Status status, in VerifyPhoneNumberResponse response, in ApiMetadata apiMetadata); + void onIidTokenGenerated(in Status status, in GetIidTokenResponse response, in ApiMetadata apiMetadata); + void onGetPnvCapabilitiesCompleted(in Status status, in GetPnvCapabilitiesResponse response, in ApiMetadata apiMetadata); +} diff --git a/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetIidTokenRequest.java b/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetIidTokenRequest.java new file mode 100644 index 0000000000..c24de63130 --- /dev/null +++ b/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetIidTokenRequest.java @@ -0,0 +1,32 @@ +package com.google.android.gms.constellation; + +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +@SafeParcelable.Class +public class GetIidTokenRequest extends AbstractSafeParcelable { + public static SafeParcelableCreatorAndWriter CREATOR = + findCreator(GetIidTokenRequest.class); + + @Field(1) + @Nullable + public final Long projectNumber; + + @Constructor + public GetIidTokenRequest( + @Param(1) @Nullable Long projectNumber + ) { + this.projectNumber = projectNumber; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } +} \ No newline at end of file diff --git a/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetIidTokenResponse.java b/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetIidTokenResponse.java new file mode 100644 index 0000000000..19b97ec9cc --- /dev/null +++ b/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetIidTokenResponse.java @@ -0,0 +1,47 @@ +package com.google.android.gms.constellation; + +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +@SafeParcelable.Class +public class GetIidTokenResponse extends AbstractSafeParcelable { + public static SafeParcelableCreatorAndWriter CREATOR = + findCreator(GetIidTokenResponse.class); + + @Field(1) + public final String iidToken; + + @Field(2) + public final String fid; + + @Field(3) + @Nullable + public final byte[] signature; + + @Field(4) + public final long timestamp; + + @Constructor + public GetIidTokenResponse( + @Param(1) String iidToken, + @Param(2) String fid, + @Param(3) @Nullable byte[] signature, + @Param(4) long timestamp + ) { + this.iidToken = iidToken; + this.fid = fid; + this.signature = signature; + this.timestamp = timestamp; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } +} \ No newline at end of file diff --git a/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.java b/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.java new file mode 100644 index 0000000000..a0c13b3306 --- /dev/null +++ b/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesRequest.java @@ -0,0 +1,42 @@ +package com.google.android.gms.constellation; + +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +import java.util.List; + +@SafeParcelable.Class +public class GetPnvCapabilitiesRequest extends AbstractSafeParcelable { + public static SafeParcelableCreatorAndWriter CREATOR = + findCreator(GetPnvCapabilitiesRequest.class); + + @Field(1) + public final String policyId; + + @Field(2) + public final List verificationTypes; + + @Field(3) + public final List simSlotIndices; + + @Constructor + public GetPnvCapabilitiesRequest( + @Param(1) String policyId, + @Param(2) List verificationTypes, + @Param(3) List simSlotIndices + ) { + this.policyId = policyId; + this.verificationTypes = verificationTypes; + this.simSlotIndices = simSlotIndices; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } +} \ No newline at end of file diff --git a/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.java b/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.java new file mode 100644 index 0000000000..ee522b446e --- /dev/null +++ b/play-services-constellation/src/main/java/com/google/android/gms/constellation/GetPnvCapabilitiesResponse.java @@ -0,0 +1,94 @@ +package com.google.android.gms.constellation; + +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +import java.util.List; + +@SafeParcelable.Class +public class GetPnvCapabilitiesResponse extends AbstractSafeParcelable { + public static SafeParcelableCreatorAndWriter CREATOR = + findCreator(GetPnvCapabilitiesResponse.class); + + @Field(1) + public final List simCapabilities; + + @Constructor + public GetPnvCapabilitiesResponse( + @Param(1) List simCapabilities + ) { + this.simCapabilities = simCapabilities; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + @SafeParcelable.Class + public static class SimCapability extends AbstractSafeParcelable { + + public static SafeParcelableCreatorAndWriter CREATOR = + findCreator(SimCapability.class); + @Field(1) + public final int slotValue; + @Field(2) + public final String subscriberIdDigest; + @Field(3) + public final int carrierId; + @Field(4) + public final String operatorName; + @Field(5) + public final List verificationCapabilities; + + @Constructor + public SimCapability( + @Param(1) int slotValue, + @Param(2) String subscriberIdDigest, + @Param(3) int carrierId, + @Param(4) String operatorName, + @Param(5) List verificationCapabilities + ) { + this.slotValue = slotValue; + this.subscriberIdDigest = subscriberIdDigest; + this.carrierId = carrierId; + this.operatorName = operatorName; + this.verificationCapabilities = verificationCapabilities; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + } + + @SafeParcelable.Class + public static class VerificationCapability extends AbstractSafeParcelable { + + public static SafeParcelableCreatorAndWriter CREATOR = + findCreator(VerificationCapability.class); + @Field(1) + public final int verificationMethod; + @Field(2) + public final int statusValue; + + @Constructor + public VerificationCapability( + @Param(1) int verificationMethod, + @Param(2) int statusValue + ) { + this.verificationMethod = verificationMethod; + this.statusValue = statusValue; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + } +} \ No newline at end of file diff --git a/play-services-constellation/src/main/java/com/google/android/gms/constellation/PhoneNumberInfo.java b/play-services-constellation/src/main/java/com/google/android/gms/constellation/PhoneNumberInfo.java new file mode 100644 index 0000000000..77f4fd7753 --- /dev/null +++ b/play-services-constellation/src/main/java/com/google/android/gms/constellation/PhoneNumberInfo.java @@ -0,0 +1,49 @@ +package com.google.android.gms.constellation; + +import android.os.Bundle; +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +@SafeParcelable.Class +public class PhoneNumberInfo extends AbstractSafeParcelable { + public static SafeParcelableCreatorAndWriter CREATOR = + findCreator(PhoneNumberInfo.class); + + @Field(1) + public final int version; + + @Field(2) + @Nullable + public final String phoneNumber; + + @Field(3) + public final long verificationTime; + + @Field(4) + @Nullable + public final Bundle extras; + + @Constructor + public PhoneNumberInfo( + @SafeParcelable.Param(1) int version, + @SafeParcelable.Param(2) @Nullable String phoneNumber, + @SafeParcelable.Param(3) long verificationTime, + @SafeParcelable.Param(4) @Nullable Bundle extras + ) { + this.version = version; + this.phoneNumber = phoneNumber; + this.verificationTime = verificationTime; + this.extras = extras; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } +} \ No newline at end of file diff --git a/play-services-constellation/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberRequest.java b/play-services-constellation/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberRequest.java new file mode 100644 index 0000000000..a0ced6ac30 --- /dev/null +++ b/play-services-constellation/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberRequest.java @@ -0,0 +1,116 @@ +package com.google.android.gms.constellation; + +import android.os.Bundle; +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +import java.util.List; + +@SafeParcelable.Class +public class VerifyPhoneNumberRequest extends AbstractSafeParcelable { + public static SafeParcelableCreatorAndWriter CREATOR = + findCreator(VerifyPhoneNumberRequest.class); + + @Field(1) + public final String policyId; + + @Field(2) + public final long timeout; + + @Field(3) + public final IdTokenRequest idTokenRequest; + + @Field(4) + public final Bundle extras; + + @Field(5) + public final List targetedSims; + + @Field(6) + public final boolean includeUnverified; + + @Field(7) + public final int apiVersion; + + @Field(8) + public final List verificationMethodsValues; + + @Constructor + public VerifyPhoneNumberRequest( + @Param(1) String policyId, + @Param(2) long timeout, + @Param(3) IdTokenRequest idTokenRequest, + @Param(4) Bundle extras, + @Param(5) List targetedSims, + @Param(6) boolean includeUnverified, + @Param(7) int apiVersion, + @Param(8) List verificationMethodsValues + ) { + this.policyId = policyId; + this.timeout = timeout; + this.idTokenRequest = idTokenRequest; + this.extras = extras; + this.targetedSims = targetedSims; + this.includeUnverified = includeUnverified; + this.apiVersion = apiVersion; + this.verificationMethodsValues = verificationMethodsValues; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + @SafeParcelable.Class + public static class IdTokenRequest extends AbstractSafeParcelable { + public static SafeParcelableCreatorAndWriter CREATOR = + findCreator(IdTokenRequest.class); + @Field(1) + public final String idToken; + @Field(2) + public final String subscriberHash; + + @Constructor + public IdTokenRequest( + @Param(1) String idToken, + @Param(2) String subscriberHash + ) { + this.idToken = idToken; + this.subscriberHash = subscriberHash; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + } + + @SafeParcelable.Class + public static class ImsiRequest extends AbstractSafeParcelable { + public static SafeParcelableCreatorAndWriter CREATOR = + findCreator(ImsiRequest.class); + @Field(1) + public final String imsi; + @Field(2) + public final String phoneNumberHint; + + @Constructor + public ImsiRequest( + @Param(1) String imsi, + @Param(2) String phoneNumberHint + ) { + this.imsi = imsi; + this.phoneNumberHint = phoneNumberHint; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + } +} \ No newline at end of file diff --git a/play-services-constellation/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberResponse.java b/play-services-constellation/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberResponse.java new file mode 100644 index 0000000000..263461debf --- /dev/null +++ b/play-services-constellation/src/main/java/com/google/android/gms/constellation/VerifyPhoneNumberResponse.java @@ -0,0 +1,88 @@ +package com.google.android.gms.constellation; + +import android.os.Bundle; +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +@SafeParcelable.Class +public class VerifyPhoneNumberResponse extends AbstractSafeParcelable { + public static SafeParcelableCreatorAndWriter CREATOR = + findCreator(VerifyPhoneNumberResponse.class); + + @Field(1) + public final PhoneNumberVerification[] verifications; + + @Field(2) + public final Bundle extras; + + @Constructor + public VerifyPhoneNumberResponse( + @Param(1) PhoneNumberVerification[] verifications, + @Param(2) Bundle extras + ) { + this.verifications = verifications; + this.extras = extras; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + @SafeParcelable.Class + public static class PhoneNumberVerification extends AbstractSafeParcelable { + public static SafeParcelableCreatorAndWriter CREATOR = + findCreator(PhoneNumberVerification.class); + @Field(1) + @Nullable + public final String phoneNumber; + @Field(2) + public final long timestampMillis; + @Field(3) + public final int verificationMethod; + @Field(4) + public final int simSlot; + @Field(5) + @Nullable + public final String verificationToken; + @Field(6) + @Nullable + public final Bundle extras; + @Field(7) + public final int verificationStatus; + @Field(8) + public final long retryAfterSeconds; + + @Constructor + public PhoneNumberVerification( + @Param(1) @Nullable String phoneNumber, + @Param(2) long timestampMillis, + @Param(3) int verificationMethod, + @Param(4) int simSlot, + @Param(5) @Nullable String verificationToken, + @Param(6) @Nullable Bundle extras, + @Param(7) int verificationStatus, + @Param(8) long retryAfterSeconds + ) { + this.phoneNumber = phoneNumber; + this.timestampMillis = timestampMillis; + this.verificationMethod = verificationMethod; + this.simSlot = simSlot; + this.verificationToken = verificationToken; + this.extras = extras; + this.verificationStatus = verificationStatus; + this.retryAfterSeconds = retryAfterSeconds; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + } +} \ No newline at end of file diff --git a/play-services-core/build.gradle b/play-services-core/build.gradle index 8fc2896bbc..362e39c76a 100644 --- a/play-services-core/build.gradle +++ b/play-services-core/build.gradle @@ -32,6 +32,7 @@ dependencies { implementation project(':play-services-cast-core') implementation project(':play-services-cast-framework-core') implementation project(':play-services-conscrypt-provider-core') + implementation project(':play-services-constellation-core') implementation project(':play-services-cronet-core') implementation project(':play-services-droidguard-core') implementation project(':play-services-fido-core') diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml index 7efdc9bf29..a29fff819c 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -1223,7 +1223,6 @@ - diff --git a/play-services-core/src/main/java/org/microg/gms/ui/SelfCheckFragment.java b/play-services-core/src/main/java/org/microg/gms/ui/SelfCheckFragment.java index 8d809ca0a7..789db690b9 100644 --- a/play-services-core/src/main/java/org/microg/gms/ui/SelfCheckFragment.java +++ b/play-services-core/src/main/java/org/microg/gms/ui/SelfCheckFragment.java @@ -50,8 +50,11 @@ import static android.Manifest.permission.GET_ACCOUNTS; import static android.Manifest.permission.POST_NOTIFICATIONS; import static android.Manifest.permission.READ_EXTERNAL_STORAGE; +import static android.Manifest.permission.READ_PHONE_NUMBERS; import static android.Manifest.permission.READ_PHONE_STATE; +import static android.Manifest.permission.READ_SMS; import static android.Manifest.permission.RECEIVE_SMS; +import static android.Manifest.permission.SEND_SMS; import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE; import static android.os.Build.VERSION.SDK_INT; @@ -76,8 +79,13 @@ protected void prepareSelfCheckList(Context context, List checks if (SDK_INT >= 33) { permissions.add(POST_NOTIFICATIONS); } + if (SDK_INT >= 26) { + permissions.add(READ_PHONE_NUMBERS); + } permissions.add(READ_PHONE_STATE); + permissions.add(READ_SMS); permissions.add(RECEIVE_SMS); + permissions.add(SEND_SMS); checks.add(new PermissionCheckGroup(permissions.toArray(new String[0])) { @Override public void doChecks(Context context, ResultCollector collector) { diff --git a/play-services-iid/src/main/java/com/google/android/gms/iid/InstanceID.java b/play-services-iid/src/main/java/com/google/android/gms/iid/InstanceID.java index 327d8b1cc0..92df450a4f 100644 --- a/play-services-iid/src/main/java/com/google/android/gms/iid/InstanceID.java +++ b/play-services-iid/src/main/java/com/google/android/gms/iid/InstanceID.java @@ -246,8 +246,24 @@ public InstanceIdStore getStore() { } @PublicApi(exclude = true) - public String requestToken(String authorizedEntity, String scope, Bundle extras) { - throw new UnsupportedOperationException(); + public String requestToken(String authorizedEntity, String scope, Bundle extras) throws IOException { + if (extras == null) extras = new Bundle(); + + extras.putString(EXTRA_SENDER, authorizedEntity); + if (scope != null) { + extras.putString(EXTRA_SCOPE, scope); + } + + String actualSubtype = TextUtils.isEmpty(this.subtype) ? authorizedEntity : this.subtype; + + if (!extras.containsKey("legacy.register")) { + extras.putString(EXTRA_SUBSCIPTION, authorizedEntity); + extras.putString(EXTRA_SUBTYPE, actualSubtype); + extras.putString("X-" + EXTRA_SUBSCIPTION, authorizedEntity); + extras.putString("X-" + EXTRA_SUBTYPE, actualSubtype); + } + + return rpc.handleRegisterMessageResult(rpc.sendRegisterMessageBlocking(extras, getKeyPair())); } private synchronized KeyPair getKeyPair() { diff --git a/settings.gradle b/settings.gradle index 1ab99b9000..9fcbcea289 100644 --- a/settings.gradle +++ b/settings.gradle @@ -36,6 +36,7 @@ include ':play-services-basement' include ':play-services-cast' include ':play-services-cast-framework' include ':play-services-clearcut' +include ':play-services-constellation' include ':play-services-drive' include ':play-services-droidguard' include ':play-services-fido' @@ -92,6 +93,7 @@ sublude ':play-services-cast:core' sublude ':play-services-cast-framework:core' include ':play-services-chimera-core' include ':play-services-conscrypt-provider-core' +sublude ':play-services-constellation:core' sublude ':play-services-cronet:core' sublude ':play-services-droidguard:core' sublude ':play-services-fido:core'