From c332e5b70d44807dc46d700334e0bd98f9eebab8 Mon Sep 17 00:00:00 2001 From: Davinci9196 Date: Tue, 4 Aug 2026 20:19:05 +0800 Subject: [PATCH 1/2] Recaptcha: Add QR code verification flow --- play-services-recaptcha/core/build.gradle | 16 ++ .../core/src/main/AndroidManifest.xml | 32 ++- .../recaptcha/RecaptchaDeepLinkActivity.kt | 114 ++++++++ .../gms/recaptcha/RecaptchaDeepLinkScreen.kt | 156 +++++++++++ .../modac/DefaultRecaptchaClientWorkflow.kt | 255 ++++++++++++++++++ .../gms/recaptcha/modac/RecaptchaError.kt | 34 +++ .../modac/RecaptchaProtoTransport.kt | 110 ++++++++ .../modac/RecaptchaProtocolClient.kt | 138 ++++++++++ .../gms/recaptcha/modac/RecaptchaQrClient.kt | 90 +++++++ .../recaptcha/modac/RecaptchaQrVerifier.kt | 82 ++++++ .../signals/DefaultSignalCollectionManager.kt | 49 ++++ .../modac/signals/SignalCollectionManager.kt | 23 ++ .../modac/signals/SignalEnvelopeBuilder.kt | 43 +++ .../recaptcha/modac/storage/ModacKeystore.kt | 61 +++++ .../modac/storage/ModacSignerCache.kt | 67 +++++ .../modac/storage/ModacSignerCrypto.kt | 90 +++++++ .../modac/storage/RecaptchaCredentialStore.kt | 53 ++++ .../core/src/main/proto/recaptcha_qr.proto | 67 +++++ .../src/main/proto/recaptcha_signals.proto | 47 ++++ .../src/main/proto/recaptcha_storage.proto | 14 + .../res/drawable/ic_recaptcha_qr_confirm.xml | 14 + .../res/drawable/ic_recaptcha_qr_failed.xml | 14 + .../res/drawable/ic_recaptcha_qr_progress.xml | 14 + .../res/drawable/ic_recaptcha_qr_success.xml | 14 + .../src/main/res/values-zh-rCN/strings.xml | 23 ++ .../src/main/res/values-zh-rTW/strings.xml | 23 ++ .../core/src/main/res/values/strings.xml | 23 ++ 27 files changed, 1661 insertions(+), 5 deletions(-) create mode 100644 play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/RecaptchaDeepLinkActivity.kt create mode 100644 play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/RecaptchaDeepLinkScreen.kt create mode 100644 play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/DefaultRecaptchaClientWorkflow.kt create mode 100644 play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaError.kt create mode 100644 play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaProtoTransport.kt create mode 100644 play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaProtocolClient.kt create mode 100644 play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaQrClient.kt create mode 100644 play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaQrVerifier.kt create mode 100644 play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/DefaultSignalCollectionManager.kt create mode 100644 play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/SignalCollectionManager.kt create mode 100644 play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/SignalEnvelopeBuilder.kt create mode 100644 play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacKeystore.kt create mode 100644 play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacSignerCache.kt create mode 100644 play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacSignerCrypto.kt create mode 100644 play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/RecaptchaCredentialStore.kt create mode 100644 play-services-recaptcha/core/src/main/proto/recaptcha_qr.proto create mode 100644 play-services-recaptcha/core/src/main/proto/recaptcha_signals.proto create mode 100644 play-services-recaptcha/core/src/main/proto/recaptcha_storage.proto create mode 100644 play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_confirm.xml create mode 100644 play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_failed.xml create mode 100644 play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_progress.xml create mode 100644 play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_success.xml create mode 100644 play-services-recaptcha/core/src/main/res/values-zh-rCN/strings.xml create mode 100644 play-services-recaptcha/core/src/main/res/values-zh-rTW/strings.xml create mode 100644 play-services-recaptcha/core/src/main/res/values/strings.xml diff --git a/play-services-recaptcha/core/build.gradle b/play-services-recaptcha/core/build.gradle index 08fcb0e049..2b3971c9d8 100644 --- a/play-services-recaptcha/core/build.gradle +++ b/play-services-recaptcha/core/build.gradle @@ -6,6 +6,7 @@ apply plugin: 'com.android.library' apply plugin: 'com.squareup.wire' apply plugin: 'kotlin-android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' apply plugin: 'maven-publish' apply plugin: 'signing' @@ -20,6 +21,7 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutineVersion" + implementation "androidx.appcompat:appcompat:$appcompatVersion" implementation "androidx.core:core-ktx:$coreVersion" implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-service:$lifecycleVersion" @@ -27,6 +29,12 @@ dependencies { implementation "com.android.volley:volley:$volleyVersion" implementation "com.squareup.wire:wire-runtime:$wireVersion" + + implementation platform('androidx.compose:compose-bom:2024.04.00') + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.ui:ui' + implementation 'androidx.activity:activity-compose:1.8.2' + } wire { @@ -39,6 +47,14 @@ android { compileSdkVersion androidCompileSdk buildToolsVersion "$androidBuildVersionTools" + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.10" + } + defaultConfig { versionName version minSdkVersion androidMinSdk diff --git a/play-services-recaptcha/core/src/main/AndroidManifest.xml b/play-services-recaptcha/core/src/main/AndroidManifest.xml index d4b5be9305..01a8850779 100644 --- a/play-services-recaptcha/core/src/main/AndroidManifest.xml +++ b/play-services-recaptcha/core/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - - @@ -8,11 +7,34 @@ - + - + + + + + + + + + + + + + + + diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/RecaptchaDeepLinkActivity.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/RecaptchaDeepLinkActivity.kt new file mode 100644 index 0000000000..b9ddc542fe --- /dev/null +++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/RecaptchaDeepLinkActivity.kt @@ -0,0 +1,114 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.recaptcha + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.microg.gms.profile.ProfileManager +import org.microg.gms.recaptcha.modac.RecaptchaQrVerifier +import org.microg.gms.recaptcha.modac.VerificationOutcome + +private const val TAG = "RecaptchaDeepLink" +private const val QR_TOKEN_SEPARATOR = "/qr/" +private const val MAX_QR_TOKEN_LENGTH = 4096 +private val ALLOWED_HOSTS = setOf("recaptcha.net", "recaptcha.google.com") + +class RecaptchaDeepLinkActivity : AppCompatActivity() { + + private var status by mutableStateOf(VerificationStatus.CONFIRM) + private var qrToken: String? = null + private var verificationJob: Job? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + ProfileManager.ensureInitialized(this) + RecaptchaQrVerifier.init(this, signalUpdateScope = lifecycleScope) + if (!acceptDeepLink(intent)) { + finish() + return + } + setContent { + RecaptchaDeepLinkScreen( + status = status, + onPrimaryButtonClick = { onPrimaryButtonClick(status) }, + onSecondaryButtonClick = ::finish, + ) + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + verificationJob?.cancel() + verificationJob = null + qrToken = null + if (!acceptDeepLink(intent)) finish() + } + + private fun acceptDeepLink(intent: Intent?): Boolean { + val data = intent?.data + val token = data?.let(::extractQrToken) + if (intent?.action != Intent.ACTION_VIEW || data == null || data.scheme != "https" || + ALLOWED_HOSTS.none { it.equals(data.host, ignoreCase = true) } || token == null + ) { + Log.d(TAG, "Rejecting invalid reCAPTCHA QR deep link") + return false + } + qrToken = token + status = VerificationStatus.CONFIRM + return true + } + + private fun onPrimaryButtonClick(status: VerificationStatus) { + when (status) { + VerificationStatus.CONFIRM -> startVerification() + VerificationStatus.LOADING, + VerificationStatus.FAILED, + VerificationStatus.VERIFIED -> finish() + } + } + + private fun startVerification() { + val token = qrToken + if (token == null) { + Log.d(TAG, "Verification requested without an accepted token") + finish() + return + } + qrToken = null + status = VerificationStatus.LOADING + verificationJob?.cancel() + verificationJob = lifecycleScope.launch { + val outcome = withContext(Dispatchers.IO) { + RecaptchaQrVerifier.verifyToken(token) + } + status = when (outcome) { + VerificationOutcome.Verified -> VerificationStatus.VERIFIED + is VerificationOutcome.Failed -> VerificationStatus.FAILED + } + } + } + + private fun extractQrToken(uri: Uri): String? { + if (!isAllowedPath(uri.path)) return null + val token = uri.pathSegments.drop(1).joinToString("/") + return token.takeIf { it.isNotEmpty() && it.length <= MAX_QR_TOKEN_LENGTH } + } + + private fun isAllowedPath(path: String?): Boolean = path?.startsWith(QR_TOKEN_SEPARATOR) == true +} diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/RecaptchaDeepLinkScreen.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/RecaptchaDeepLinkScreen.kt new file mode 100644 index 0000000000..eaf46fafd0 --- /dev/null +++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/RecaptchaDeepLinkScreen.kt @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.recaptcha + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.microg.gms.recaptcha.core.R + +private val FilledButtonColor = Color(0xFF1A73E8) +private val LinkButtonColor = Color(0xFF1967D2) + +internal enum class VerificationStatus { + CONFIRM, + LOADING, + FAILED, + VERIFIED, +} + +private fun VerificationStatus.titleRes(): Int = when (this) { + VerificationStatus.CONFIRM -> R.string.recaptcha_qr_confirm_title + VerificationStatus.LOADING -> R.string.recaptcha_qr_progress_title + VerificationStatus.FAILED -> R.string.recaptcha_qr_failed_title + VerificationStatus.VERIFIED -> R.string.recaptcha_qr_success_title +} + +private fun VerificationStatus.descriptionRes(): Int = when (this) { + VerificationStatus.CONFIRM -> R.string.recaptcha_qr_confirm_description + VerificationStatus.LOADING -> R.string.recaptcha_qr_progress_description + VerificationStatus.FAILED -> R.string.recaptcha_qr_failed_description + VerificationStatus.VERIFIED -> R.string.recaptcha_qr_success_description +} + +private fun VerificationStatus.primaryButtonRes(): Int = when (this) { + VerificationStatus.CONFIRM -> R.string.recaptcha_qr_confirm_button + VerificationStatus.LOADING -> R.string.recaptcha_qr_progress_button + VerificationStatus.FAILED -> R.string.recaptcha_qr_failed_button + VerificationStatus.VERIFIED -> R.string.recaptcha_qr_success_button +} + +private fun VerificationStatus.illustrationRes(): Int = when (this) { + VerificationStatus.CONFIRM -> R.drawable.ic_recaptcha_qr_confirm + VerificationStatus.LOADING -> R.drawable.ic_recaptcha_qr_progress + VerificationStatus.FAILED -> R.drawable.ic_recaptcha_qr_failed + VerificationStatus.VERIFIED -> R.drawable.ic_recaptcha_qr_success +} + +@Composable +internal fun RecaptchaDeepLinkScreen( + status: VerificationStatus, + onPrimaryButtonClick: () -> Unit, + onSecondaryButtonClick: () -> Unit, +) { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + painter = painterResource(status.illustrationRes()), + contentDescription = null, + modifier = Modifier.size(96.dp), + ) + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(status.titleRes()), + fontSize = 20.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(status.descriptionRes()), + fontSize = 16.sp, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(32.dp)) + PrimaryButton(status, onPrimaryButtonClick) + if (status == VerificationStatus.CONFIRM) { + TextButton(onClick = onSecondaryButtonClick) { + Text( + text = stringResource(R.string.recaptcha_qr_confirm_cancel_button), + color = LinkButtonColor, + ) + } + } + } + } +} + +@Composable +private fun PrimaryButton(status: VerificationStatus, onClick: () -> Unit) { + val label = stringResource(status.primaryButtonRes()) + when (status) { + VerificationStatus.CONFIRM, + VerificationStatus.VERIFIED -> Button( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = FilledButtonColor), + ) { + Text(label, color = Color.White) + } + + VerificationStatus.FAILED -> OutlinedButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + ) { + Text(label, color = LinkButtonColor) + } + + VerificationStatus.LOADING -> TextButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + ) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = LinkButtonColor, + strokeWidth = 2.dp, + ) + Spacer(Modifier.size(8.dp)) + Text(label, color = LinkButtonColor) + } + } +} diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/DefaultRecaptchaClientWorkflow.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/DefaultRecaptchaClientWorkflow.kt new file mode 100644 index 0000000000..dca16d6a99 --- /dev/null +++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/DefaultRecaptchaClientWorkflow.kt @@ -0,0 +1,255 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.recaptcha.modac + +import android.content.Context +import android.os.SystemClock +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import org.microg.gms.common.Constants +import org.microg.gms.profile.Build +import org.microg.gms.recaptcha.modac.signals.SignalCollectionManager +import org.microg.gms.recaptcha.modac.signals.SignalCollectionRequest +import org.microg.gms.recaptcha.modac.storage.RecaptchaCredentialStore +import org.microg.gms.recaptcha.qr.CachedInitCredential +import org.microg.gms.recaptcha.qr.InitializationRequest +import org.microg.gms.recaptcha.qr.InitializationResponse +import org.microg.gms.recaptcha.qr.RecaptchaSignals +import org.microg.gms.recaptcha.qr.SignalUpdateRequest +import org.microg.gms.recaptcha.qr.VerificationRequest +import org.microg.gms.recaptcha.qr.VerificationResponse + +private const val TAG = "RecaptchaQr" +private const val SIGNAL_UPDATE_TIMEOUT_MS = 10_000L +private const val RECAPTCHA_SDK_VERSION = "18.9.0-beta02" +private const val RECAPTCHA_BUILD_ID = "BQDhGLUE" +private const val CLIENT_TYPE_FIRST_PARTY = 1 +private const val CLIENT_TYPE_OTHER = 2 +private const val PERSISTENT_CREDENTIAL_KEY = "_GRECAPTCHA_KC" + +private data class InitializationState( + val response: InitializationResponse, + val credential: CachedInitCredential?, +) + +private class VerificationDeadline(timeoutMs: Long) { + private val deadlineMs = SystemClock.elapsedRealtime() + timeoutMs + + fun remainingMs(): Long { + val remaining = deadlineMs - SystemClock.elapsedRealtime() + if (remaining <= 0L) { + throw RecaptchaError( + RecaptchaErrorKind.NETWORK, + RecaptchaErrorSubKind.UNKNOWN, + "verification_timeout", + ) + } + return remaining + } +} + +internal class DefaultRecaptchaClientWorkflow( + context: Context, + private val protocolClient: RecaptchaProtocolClient, + private val credentialStore: RecaptchaCredentialStore, + private val signalUpdateScope: CoroutineScope, +) : RecaptchaClientWorkflow { + private val context = context.applicationContext + private val initMutex = Mutex() + private var sessionInitialization: InitializationState? = null + + override suspend fun execute( + request: RecaptchaExecutionRequest, + signalCollectionManager: SignalCollectionManager, + ): String { + val deadline = VerificationDeadline(request.timeoutMs) + val initialization = getOrInitialize(request, deadline) + val signals = signalCollectionManager.collectSignals( + initialization.toSignalRequest(request.action.name, deadline.remainingMs()), + ) + val verificationResponse = executeChallenge( + request = request, + initialization = initialization, + signals = signals, + timeoutMs = deadline.remainingMs(), + ) + postExecute( + verificationResponse = verificationResponse, + siteKey = request.siteKey, + action = request.action.name, + initialization = initialization, + signalCollectionManager = signalCollectionManager, + ) + return verificationResponse.challengeToken + } + + private suspend fun getOrInitialize( + request: RecaptchaExecutionRequest, + deadline: VerificationDeadline, + ): InitializationState { + val sessionResult = initMutex.withLock { + sessionInitialization ?: initialize(request, deadline).also { sessionInitialization = it } + } + return sessionResult.copy(credential = credentialStore.read(request.siteKey)) + } + + private suspend fun initialize( + request: RecaptchaExecutionRequest, + deadline: VerificationDeadline, + ): InitializationState { + val isFirstParty = request.packageName == Constants.GMS_PACKAGE_NAME + val baseRequest = InitializationRequest( + siteKey = request.siteKey, + packageName = request.packageName, + sdkVersion = RECAPTCHA_SDK_VERSION, + clientType = if (isFirstParty) CLIENT_TYPE_FIRST_PARTY else CLIENT_TYPE_OTHER, + requestId = request.sessionRequestId, + sdkInt = Build.VERSION.SDK_INT.toString(), + initSignal = "", + playServicesAvailable = isFirstParty, + playStoreInstalled = isPackageInstalled(Constants.VENDING_PACKAGE_NAME), + installerPackage = gmsInstallerPackage(), + buildId = RECAPTCHA_BUILD_ID, + ) + val cachedCredential = credentialStore.read(request.siteKey) + val initializationRequest = baseRequest.copy(initSignal = cachedCredential?.credential.orEmpty()) + val initializationResponse = protocolClient.initialize(initializationRequest, deadline.remainingMs()) + return InitializationState(initializationResponse, cachedCredential) + } + + private suspend fun executeChallenge( + request: RecaptchaExecutionRequest, + initialization: InitializationState, + signals: RecaptchaSignals, + timeoutMs: Long, + ): VerificationResponse { + val response = protocolClient.verify( + VerificationRequest( + nonce = initialization.response.nonce, + landingToken = initialization.response.landingToken, + siteKey = request.siteKey, + action = request.action.name, + signals = signals, + qrToken = request.qrToken, + ), + timeoutMs, + ) + if (response.challengeToken.isEmpty()) { + throw RecaptchaError( + RecaptchaErrorKind.INTERNAL, + RecaptchaErrorSubKind.EMPTY_CHALLENGE_TOKEN, + "verification_rejected", + "errorCode=${response.errorCode}", + ) + } + return response + } + + private suspend fun postExecute( + verificationResponse: VerificationResponse, + siteKey: String, + action: String, + initialization: InitializationState, + signalCollectionManager: SignalCollectionManager, + ) { + val kcEntry = verificationResponse.persistentStorage.firstOrNull { it.name == PERSISTENT_CREDENTIAL_KEY } + ?: return + if (kcEntry.storedValue.isEmpty()) return + + val refreshedCredential = CachedInitCredential( + credential = kcEntry.storedValue, + nonceUuid = verificationResponse.nonceUuid, + droidGuardNonce = verificationResponse.droidGuardNonce, + ) + val stored = credentialStore.write(siteKey, refreshedCredential) + if (stored && refreshedCredential.credential != initialization.credential?.credential) { + scheduleSignalUpdate( + siteKey = siteKey, + action = action, + initialization = initialization, + credential = refreshedCredential, + signalCollectionManager = signalCollectionManager, + ) + } + } + + private fun scheduleSignalUpdate( + siteKey: String, + action: String, + initialization: InitializationState, + credential: CachedInitCredential, + signalCollectionManager: SignalCollectionManager, + ) { + signalUpdateScope.launch { + try { + withTimeout(SIGNAL_UPDATE_TIMEOUT_MS) { + val deadline = VerificationDeadline(SIGNAL_UPDATE_TIMEOUT_MS) + val signals = signalCollectionManager.refreshSignals( + initialization.copy(credential = credential) + .toSignalRequest(action, deadline.remainingMs()), + ) + val response = protocolClient.updateSignals( + SignalUpdateRequest( + landingToken = initialization.response.landingToken, + siteKey = siteKey, + signals = signals, + ), + deadline.remainingMs(), + ) + val replacement = CachedInitCredential( + credential = response.credential, + nonceUuid = response.nonceUuid, + droidGuardNonce = credential.droidGuardNonce, + ) + credentialStore.replaceIfCurrent( + siteKey = siteKey, + expectedCredential = credential.credential, + replacement = replacement, + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Background signal update failed", e) + } + } + } + + private fun isPackageInstalled(packageName: String): Boolean = try { + context.packageManager.getPackageInfo(packageName, 0) + true + } catch (e: Exception) { + false + } + + private fun gmsInstallerPackage(): String = try { + if (Build.VERSION.SDK_INT >= 30) { + context.packageManager.getInstallSourceInfo(Constants.GMS_PACKAGE_NAME) + .initiatingPackageName + .orEmpty() + } else { + @Suppress("DEPRECATION") + context.packageManager.getInstallerPackageName(Constants.GMS_PACKAGE_NAME).orEmpty() + } + } catch (e: Exception) { + "" + } + + private fun InitializationState.toSignalRequest( + action: String, + timeoutMs: Long, + ) = SignalCollectionRequest( + action = action, + initializationResponse = response, + cachedCredential = credential, + timeoutMs = timeoutMs, + ) +} diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaError.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaError.kt new file mode 100644 index 0000000000..2337e43262 --- /dev/null +++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaError.kt @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.recaptcha.modac + +internal object RecaptchaErrorKind { + const val INTERNAL = 1 + const val NETWORK = 3 + const val SERVER = 10 +} + +internal object RecaptchaErrorSubKind { + const val RUNTIME_ERROR = 8 + const val NOT_FOUND = 17 + const val SERVICE_UNAVAILABLE = 47 + const val HTTP_ERROR = 48 + const val UNKNOWN = 64 + const val BAD_REQUEST = 91 + const val EMPTY_CHALLENGE_TOKEN = 121 + const val INVALID_SERVER_RESPONSE = 140 +} + +internal class RecaptchaError( + val kindCode: Int, + val subKindCode: Int, + val label: String, + val detail: String? = null, + cause: Throwable? = null, +) : RuntimeException( + "RecaptchaError(kind=$kindCode, subKind=$subKindCode) [$label]${detail?.let { ": $it" }.orEmpty()}", + cause, +) diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaProtoTransport.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaProtoTransport.kt new file mode 100644 index 0000000000..515a43c61e --- /dev/null +++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaProtoTransport.kt @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.recaptcha.modac + +import android.content.Context +import android.os.LocaleList +import com.android.volley.DefaultRetryPolicy +import com.android.volley.NetworkResponse +import com.android.volley.ParseError +import com.android.volley.Request +import com.android.volley.Response +import com.android.volley.VolleyError +import com.android.volley.toolbox.HttpHeaderParser +import com.android.volley.toolbox.Volley +import java.util.Locale +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine +import org.microg.gms.profile.Build +import org.microg.gms.utils.singleInstanceOf + +private const val PROTO_CONTENT_TYPE = "application/x-protobuffer" +private const val MAX_PROTO_RESPONSE_BYTES = 1024 * 1024 + +internal data class RecaptchaProtoResponse(val statusCode: Int, val body: ByteArray) + +internal class RecaptchaTransportException( + val statusCode: Int?, + cause: Throwable, +) : Exception("reCAPTCHA transport failed${statusCode?.let { " with HTTP $it" }.orEmpty()}", cause) + +internal interface RecaptchaProtoTransport { + suspend fun post(endpoint: String, body: ByteArray, timeoutMs: Long): RecaptchaProtoResponse +} + +internal class VolleyRecaptchaProtoTransport(context: Context) : RecaptchaProtoTransport { + private val queue = singleInstanceOf { Volley.newRequestQueue(context.applicationContext) } + + override suspend fun post( + endpoint: String, + body: ByteArray, + timeoutMs: Long, + ): RecaptchaProtoResponse = suspendCancellableCoroutine { continuation -> + val request = RawProtoRequest( + endpoint = endpoint, + requestBody = body, + onSuccess = { response -> + if (continuation.isActive) continuation.resume(response) + }, + onError = { error -> + if (continuation.isActive) { + continuation.resumeWithException( + RecaptchaTransportException(error.networkResponse?.statusCode, error), + ) + } + }, + ).apply { + retryPolicy = DefaultRetryPolicy( + timeoutMs.coerceIn(1L, Int.MAX_VALUE.toLong()).toInt(), + 0, + 1.0f, + ) + setShouldCache(false) + } + continuation.invokeOnCancellation { request.cancel() } + try { + queue.add(request) + } catch (e: Exception) { + if (continuation.isActive) continuation.resumeWithException(e) + } + } +} + +private class RawProtoRequest( + endpoint: String, + private val requestBody: ByteArray, + private val onSuccess: (RecaptchaProtoResponse) -> Unit, + onError: (VolleyError) -> Unit, +) : Request(Method.POST, endpoint, onError) { + + override fun getHeaders(): Map = mapOf( + "Accept" to PROTO_CONTENT_TYPE, + "Accept-Language" to if (Build.VERSION.SDK_INT >= 24) { + LocaleList.getDefault().toLanguageTags() + } else { + Locale.getDefault().language + }, + ) + + override fun getBody(): ByteArray = requestBody + + override fun getBodyContentType(): String = PROTO_CONTENT_TYPE + + override fun parseNetworkResponse(response: NetworkResponse): Response { + if (response.data.size > MAX_PROTO_RESPONSE_BYTES) { + return Response.error(ParseError(IllegalStateException("reCAPTCHA response exceeds size limit"))) + } + return Response.success( + RecaptchaProtoResponse(response.statusCode, response.data), + HttpHeaderParser.parseCacheHeaders(response), + ) + } + + override fun deliverResponse(response: RecaptchaProtoResponse) { + onSuccess(response) + } +} diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaProtocolClient.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaProtocolClient.kt new file mode 100644 index 0000000000..04e389e7e1 --- /dev/null +++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaProtocolClient.kt @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.recaptcha.modac + +import com.squareup.wire.ProtoAdapter +import kotlinx.coroutines.CancellationException +import org.microg.gms.recaptcha.qr.InitializationRequest +import org.microg.gms.recaptcha.qr.InitializationResponse +import org.microg.gms.recaptcha.qr.SignalUpdateRequest +import org.microg.gms.recaptcha.qr.SignalUpdateResponse +import org.microg.gms.recaptcha.qr.VerificationRequest +import org.microg.gms.recaptcha.qr.VerificationResponse + +private const val INITIALIZATION_ENDPOINT = "https://www.recaptcha.net/recaptcha/api3/mri" +private const val VERIFICATION_ENDPOINT = "https://www.recaptcha.net/recaptcha/api3/mrr" +private const val SIGNAL_UPDATE_ENDPOINT = "https://www.recaptcha.net/recaptcha/api3/mrs" + +internal class RecaptchaProtocolClient(private val transport: RecaptchaProtoTransport) { + + suspend fun initialize(request: InitializationRequest, timeoutMs: Long): InitializationResponse { + val response = postAndDecode( + stage = "initialization", + endpoint = INITIALIZATION_ENDPOINT, + body = request.encode(), + adapter = InitializationResponse.ADAPTER, + timeoutMs = timeoutMs, + ) + if (response.landingToken.isEmpty()) { + throw RecaptchaError( + RecaptchaErrorKind.INTERNAL, + RecaptchaErrorSubKind.INVALID_SERVER_RESPONSE, + "init_missing_landing_token", + ) + } + return response + } + + suspend fun verify(request: VerificationRequest, timeoutMs: Long): VerificationResponse = postAndDecode( + stage = "verification", + endpoint = VERIFICATION_ENDPOINT, + body = request.encode(), + adapter = VerificationResponse.ADAPTER, + timeoutMs = timeoutMs, + ) + + suspend fun updateSignals(request: SignalUpdateRequest, timeoutMs: Long): SignalUpdateResponse { + val response = postAndDecode( + stage = "signal_update", + endpoint = SIGNAL_UPDATE_ENDPOINT, + body = request.encode(), + adapter = SignalUpdateResponse.ADAPTER, + timeoutMs = timeoutMs, + ) + if (response.credential.isEmpty()) { + throw RecaptchaError( + RecaptchaErrorKind.INTERNAL, + RecaptchaErrorSubKind.INVALID_SERVER_RESPONSE, + "signal_update_missing_credential", + ) + } + return response + } + + private suspend fun postAndDecode( + stage: String, + endpoint: String, + body: ByteArray, + adapter: ProtoAdapter, + timeoutMs: Long, + ): T { + val response = postProto(stage, endpoint, body, timeoutMs) + return try { + adapter.decode(response) + } catch (e: Exception) { + throw RecaptchaError( + RecaptchaErrorKind.INTERNAL, + RecaptchaErrorSubKind.RUNTIME_ERROR, + "${stage}_decode_failed", + cause = e, + ) + } + } + + private suspend fun postProto( + stage: String, + endpoint: String, + body: ByteArray, + timeoutMs: Long, + ): ByteArray { + val response = try { + transport.post(endpoint, body, timeoutMs) + } catch (e: CancellationException) { + throw e + } catch (e: RecaptchaTransportException) { + throw e.statusCode?.let { httpStatusError(stage, it, e) } + ?: RecaptchaError( + RecaptchaErrorKind.NETWORK, + RecaptchaErrorSubKind.UNKNOWN, + "${stage}_transport_failed", + cause = e, + ) + } catch (e: Exception) { + throw RecaptchaError( + RecaptchaErrorKind.NETWORK, + RecaptchaErrorSubKind.UNKNOWN, + "${stage}_transport_failed", + cause = e, + ) + } + if (response.statusCode != 200) { + throw httpStatusError(stage, response.statusCode) + } + return response.body + } + + private fun httpStatusError( + stage: String, + statusCode: Int, + cause: Throwable? = null, + ): RecaptchaError { + val (kindCode, subKindCode) = when (statusCode) { + 400 -> RecaptchaErrorKind.NETWORK to RecaptchaErrorSubKind.BAD_REQUEST + 403, 503 -> RecaptchaErrorKind.SERVER to RecaptchaErrorSubKind.SERVICE_UNAVAILABLE + 404 -> RecaptchaErrorKind.NETWORK to RecaptchaErrorSubKind.NOT_FOUND + else -> RecaptchaErrorKind.NETWORK to RecaptchaErrorSubKind.HTTP_ERROR + } + return RecaptchaError( + kindCode = kindCode, + subKindCode = subKindCode, + label = "${stage}_http_status", + detail = "HTTP $statusCode", + cause = cause, + ) + } +} diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaQrClient.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaQrClient.kt new file mode 100644 index 0000000000..7f42a79025 --- /dev/null +++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaQrClient.kt @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.recaptcha.modac + +import java.util.UUID +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import org.microg.gms.recaptcha.modac.signals.SignalCollectionManager + +private const val MINIMUM_VERIFY_TIMEOUT_MS = 5_000L +private val SUPPORTED_ACTIONS = setOf("modacVerify") + +internal class RecaptchaSession(val packageName: String) + +internal class RecaptchaAction(val name: String) { + companion object { + val MODAC_VERIFY = RecaptchaAction("modacVerify") + } +} + +internal class RecaptchaExecutionRequest( + val siteKey: String, + val packageName: String, + val sessionRequestId: String, + val action: RecaptchaAction, + val timeoutMs: Long, + val qrToken: String, +) + +internal interface RecaptchaClientWorkflow { + suspend fun execute( + request: RecaptchaExecutionRequest, + signalCollectionManager: SignalCollectionManager, + ): String +} + +internal class RecaptchaQrClient( + private val workflow: RecaptchaClientWorkflow, + private val siteKey: String, + private val session: RecaptchaSession, + private val signalCollectionManager: SignalCollectionManager, +) { + private val verificationMutex = Mutex() + + suspend fun verify(action: RecaptchaAction, qrToken: String, timeoutMs: Long): String { + if (action.name !in SUPPORTED_ACTIONS) { + throw RecaptchaError( + RecaptchaErrorKind.INTERNAL, + RecaptchaErrorSubKind.RUNTIME_ERROR, + "unsupported_action", + ) + } + if (timeoutMs < MINIMUM_VERIFY_TIMEOUT_MS) { + throw RecaptchaError( + RecaptchaErrorKind.INTERNAL, + RecaptchaErrorSubKind.UNKNOWN, + "timeout_too_short", + ) + } + return try { + withTimeout(timeoutMs) { + verificationMutex.withLock { + workflow.execute( + request = RecaptchaExecutionRequest( + siteKey = siteKey, + packageName = session.packageName, + sessionRequestId = UUID.randomUUID().toString(), + action = action, + timeoutMs = timeoutMs, + qrToken = qrToken, + ), + signalCollectionManager = signalCollectionManager, + ) + } + } + } catch (e: TimeoutCancellationException) { + throw RecaptchaError( + RecaptchaErrorKind.NETWORK, + RecaptchaErrorSubKind.UNKNOWN, + "verification_timeout", + cause = e, + ) + } + } +} diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaQrVerifier.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaQrVerifier.kt new file mode 100644 index 0000000000..a7256be9f1 --- /dev/null +++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/RecaptchaQrVerifier.kt @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.recaptcha.modac + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import org.microg.gms.common.Constants +import org.microg.gms.recaptcha.modac.signals.DefaultSignalCollectionManager +import org.microg.gms.recaptcha.modac.signals.SignalCollectionManager +import org.microg.gms.recaptcha.modac.storage.RecaptchaCredentialStore + +private const val TAG = "RecaptchaQr" +private const val DEFAULT_VERIFY_TIMEOUT_MS = 25_000L + +internal const val MODAC_SITE_KEY = "6LfuGqkrAAAAAOcfldxrFN7IZFkus_70m0tcbHNa" + +internal sealed interface VerificationOutcome { + data object Verified : VerificationOutcome + data class Failed(val cause: Throwable) : VerificationOutcome +} + +internal object RecaptchaQrVerifier { + @Volatile + private var client: RecaptchaQrClient? = null + + @Synchronized + fun init( + context: Context, + signalUpdateScope: CoroutineScope, + siteKey: String = MODAC_SITE_KEY, + packageName: String = Constants.GMS_PACKAGE_NAME, + signalCollectionManager: SignalCollectionManager? = null, + ) { + val signalManager = signalCollectionManager ?: DefaultSignalCollectionManager(context) + val workflow = DefaultRecaptchaClientWorkflow( + context = context, + protocolClient = RecaptchaProtocolClient(VolleyRecaptchaProtoTransport(context)), + credentialStore = RecaptchaCredentialStore(context), + signalUpdateScope = signalUpdateScope, + ) + client = RecaptchaQrClient( + workflow = workflow, + siteKey = siteKey, + session = RecaptchaSession(packageName), + signalCollectionManager = signalManager, + ) + } + + suspend fun verifyToken(qrToken: String): VerificationOutcome { + val activeClient = client ?: return VerificationOutcome.Failed( + RecaptchaError( + RecaptchaErrorKind.INTERNAL, + RecaptchaErrorSubKind.UNKNOWN, + "verifier_not_initialized", + ), + ) + return try { + activeClient.verify(RecaptchaAction.MODAC_VERIFY, qrToken, DEFAULT_VERIFY_TIMEOUT_MS) + VerificationOutcome.Verified + } catch (e: CancellationException) { + throw e + } catch (e: RecaptchaError) { + Log.w(TAG, "Verification failed: kind=${e.kindCode}, subKind=${e.subKindCode}, label=${e.label}") + VerificationOutcome.Failed(e) + } catch (e: Exception) { + Log.w(TAG, "Verification failed with an unexpected error", e) + VerificationOutcome.Failed( + RecaptchaError( + RecaptchaErrorKind.INTERNAL, + RecaptchaErrorSubKind.RUNTIME_ERROR, + "unexpected_error", + cause = e, + ), + ) + } + } +} diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/DefaultSignalCollectionManager.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/DefaultSignalCollectionManager.kt new file mode 100644 index 0000000000..e6d3f0eb68 --- /dev/null +++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/DefaultSignalCollectionManager.kt @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.recaptcha.modac.signals + +import android.content.Context +import android.util.Log +import com.google.android.gms.droidguard.DroidGuardClient +import com.google.android.gms.tasks.await +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout +import org.microg.gms.recaptcha.qr.RecaptchaSignals + +private const val TAG = "RecaptchaQr" +private const val DROID_GUARD_FLOW_NAME = "recaptchabase-modac" +private const val MAX_DROID_GUARD_TIMEOUT_MS = 5_000L + +internal class DefaultSignalCollectionManager(context: Context) : SignalCollectionManager { + private val context = context.applicationContext + + override suspend fun collectSignals(request: SignalCollectionRequest): RecaptchaSignals { + val droidGuardNonce = request.cachedCredential?.droidGuardNonce?.utf8().orEmpty() + val droidGuardEnabled = request.initializationResponse.droidGuardConfig?.payload?.size?.let { it > 0 } == true + if (!droidGuardEnabled || droidGuardNonce.isEmpty()) { + return SignalEnvelopeBuilder.build(action = request.action) + } + val result = try { + withTimeout(request.timeoutMs.coerceAtMost(MAX_DROID_GUARD_TIMEOUT_MS)) { + DroidGuardClient.getResults( + context, + DROID_GUARD_FLOW_NAME, + mapOf("token" to droidGuardNonce), + ).await() + } + } catch (e: TimeoutCancellationException) { + Log.w(TAG, "DroidGuard signal collection timed out") + null + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "DroidGuard signal collection failed", e) + null + } + return SignalEnvelopeBuilder.build(action = request.action, droidGuardResult = result) + } +} diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/SignalCollectionManager.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/SignalCollectionManager.kt new file mode 100644 index 0000000000..fb9eead3af --- /dev/null +++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/SignalCollectionManager.kt @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.recaptcha.modac.signals + +import org.microg.gms.recaptcha.qr.CachedInitCredential +import org.microg.gms.recaptcha.qr.InitializationResponse +import org.microg.gms.recaptcha.qr.RecaptchaSignals + +internal data class SignalCollectionRequest( + val action: String, + val initializationResponse: InitializationResponse, + val cachedCredential: CachedInitCredential?, + val timeoutMs: Long, +) + +internal interface SignalCollectionManager { + suspend fun collectSignals(request: SignalCollectionRequest): RecaptchaSignals + + suspend fun refreshSignals(request: SignalCollectionRequest): RecaptchaSignals = collectSignals(request) +} diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/SignalEnvelopeBuilder.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/SignalEnvelopeBuilder.kt new file mode 100644 index 0000000000..9a4008a951 --- /dev/null +++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/signals/SignalEnvelopeBuilder.kt @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.recaptcha.modac.signals + +import okio.ByteString.Companion.toByteString +import org.microg.gms.recaptcha.qr.DroidGuardSignals +import org.microg.gms.recaptcha.qr.EncodedSignal +import org.microg.gms.recaptcha.qr.EncodedSignalBundle +import org.microg.gms.recaptcha.qr.RecaptchaSignals +import org.microg.gms.recaptcha.qr.SignalEncryption +import org.microg.gms.recaptcha.qr.SignalValue +import org.microg.gms.recaptcha.qr.SignalValueList + +private const val DROID_GUARD_FORMAT_CODE = 33 + +internal object SignalEnvelopeBuilder { + + fun build(action: String, droidGuardResult: String? = null): RecaptchaSignals { + val droidGuardSignals = droidGuardResult + ?.takeIf { it.isNotEmpty() } + ?.let { buildDroidGuardSignals(action, it) } + return RecaptchaSignals(action = action, droidGuardSignals = droidGuardSignals) + } + + private fun buildDroidGuardSignals(action: String, result: String): DroidGuardSignals { + val values = SignalValueList( + values = listOf( + SignalValue(textValue = result), + SignalValue(booleanValue = true), + ), + formatCode = DROID_GUARD_FORMAT_CODE, + ) + val signal = EncodedSignal( + encryption = SignalEncryption.NONE, + payload = values.encode().toByteString(), + ) + val bundle = EncodedSignalBundle(signals = listOf(signal), action = action) + return DroidGuardSignals(signals = bundle) + } +} diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacKeystore.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacKeystore.kt new file mode 100644 index 0000000000..235fd9d746 --- /dev/null +++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacKeystore.kt @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.recaptcha.modac.storage + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Log +import androidx.annotation.RequiresApi +import org.microg.gms.profile.Build +import java.security.KeyStore +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +private const val TAG = "RecaptchaQr" +private const val KEY_ALIAS = "recck" +private const val ANDROID_KEYSTORE = "AndroidKeyStore" + +internal object ModacKeystore { + + @Synchronized + fun getOrCreateKey(): SecretKey? { + if (Build.VERSION.SDK_INT < 23) { + Log.w(TAG, "AES/GCM Keystore requires Android 6.0 or newer") + return null + } + return Api23Impl.getOrCreateKey() + } + + @RequiresApi(23) + private object Api23Impl { + fun getOrCreateKey(): SecretKey? = try { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + (keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry) + ?.secretKey + ?: generateKey() + } catch (e: Exception) { + Log.w(TAG, "AndroidKeyStore unavailable", e) + null + } + + private fun generateKey(): SecretKey? = try { + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + keyGenerator.init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ).setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + keyGenerator.generateKey() + } catch (e: Exception) { + Log.w(TAG, "AndroidKeyStore key generation failed", e) + null + } + } +} diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacSignerCache.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacSignerCache.kt new file mode 100644 index 0000000000..53569c9da3 --- /dev/null +++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacSignerCache.kt @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.recaptcha.modac.storage + +import android.content.Context +import android.util.AtomicFile +import android.util.Log +import java.io.File +import java.io.FileOutputStream +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +private const val TAG = "RecaptchaQr" +private const val FILE_PREFIX = "rce_" +private val FILE_LOCK = Any() + +internal class ModacSignerCache(private val context: Context) { + + fun withExclusiveAccess(block: () -> T): T = synchronized(FILE_LOCK, block) + + fun read(key: String): String? = synchronized(FILE_LOCK) { + val file = cacheFile(key) + if (!file.exists()) return@synchronized null + try { + String(AtomicFile(file).readFully(), StandardCharsets.UTF_8) + } catch (e: Exception) { + Log.w(TAG, "Credential cache read failed", e) + null + } + } + + fun write(key: String, value: String): Boolean = synchronized(FILE_LOCK) { + val atomicFile = AtomicFile(cacheFile(key)) + var stream: FileOutputStream? = null + try { + stream = atomicFile.startWrite() + stream.write(value.toByteArray(StandardCharsets.UTF_8)) + atomicFile.finishWrite(stream) + true + } catch (e: Exception) { + stream?.let(atomicFile::failWrite) + Log.w(TAG, "Credential cache write failed", e) + false + } + } + + fun delete(key: String): Boolean = synchronized(FILE_LOCK) { + val file = cacheFile(key) + if (!file.exists()) return@synchronized true + try { + AtomicFile(file).delete() + true + } catch (e: Exception) { + Log.w(TAG, "Credential cache delete failed", e) + false + } + } + + private fun cacheFile(key: String): File = File(context.cacheDir, FILE_PREFIX + key.sha256()) + + private fun String.sha256(): String = MessageDigest.getInstance("SHA-256") + .digest(toByteArray(StandardCharsets.UTF_8)) + .joinToString(separator = "") { (it.toInt() and 0xff).toString(16).padStart(2, '0') } +} diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacSignerCrypto.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacSignerCrypto.kt new file mode 100644 index 0000000000..b30ee9cfed --- /dev/null +++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/ModacSignerCrypto.kt @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.recaptcha.modac.storage + +import android.util.Base64 +import android.util.Log +import androidx.annotation.RequiresApi +import org.microg.gms.profile.Build +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +private const val TAG = "RecaptchaQr" +private const val GCM_TAG_BITS = 128 +private const val GCM_IV_BYTES = 12 +private const val BASE64_FLAGS = Base64.URL_SAFE +private const val AES_TRANSFORMATION = "AES/GCM/NoPadding" + +internal object ModacSignerCrypto { + + fun fetchAndDecrypt(key: String, cache: ModacSignerCache): ByteArray? { + if (Build.VERSION.SDK_INT < 23) { + Log.d(TAG, "Encrypted credential cache requires Android 6.0 or newer") + return null + } + val payload = cache.read(key) ?: return null + val cipherBytes = try { + Base64.decode(payload, BASE64_FLAGS) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Credential cache Base64 decode failed", e) + cache.delete(key) + return null + } + if (cipherBytes.size <= GCM_IV_BYTES) { + Log.w(TAG, "Credential cache payload is too short (${cipherBytes.size}B)") + cache.delete(key) + return null + } + + val secretKey = ModacKeystore.getOrCreateKey() ?: run { + Log.w(TAG, "Credential cache AES key unavailable") + return null + } + return Api23Impl.decrypt(secretKey, cipherBytes).also { decrypted -> + if (decrypted == null) cache.delete(key) + } + } + + fun encryptAndStore(key: String, cache: ModacSignerCache, plainBytes: ByteArray): Boolean { + if (Build.VERSION.SDK_INT < 23) { + Log.d(TAG, "Encrypted credential cache requires Android 6.0 or newer") + return false + } + val secretKey = ModacKeystore.getOrCreateKey() ?: run { + Log.w(TAG, "Credential cache AES key unavailable") + return false + } + val encoded = Api23Impl.encrypt(secretKey, plainBytes) ?: return false + return cache.write(key, encoded) + } + + @RequiresApi(23) + private object Api23Impl { + fun decrypt(secretKey: SecretKey, cipherBytes: ByteArray): ByteArray? = try { + val cipher = Cipher.getInstance(AES_TRANSFORMATION) + cipher.init( + Cipher.DECRYPT_MODE, + secretKey, + GCMParameterSpec(GCM_TAG_BITS, cipherBytes, 0, GCM_IV_BYTES), + ) + cipher.doFinal(cipherBytes, GCM_IV_BYTES, cipherBytes.size - GCM_IV_BYTES) + } catch (e: Exception) { + Log.w(TAG, "Credential cache AES/GCM decrypt failed", e) + null + } + + fun encrypt(secretKey: SecretKey, plainBytes: ByteArray): String? = try { + val cipher = Cipher.getInstance(AES_TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + val cipherBytes = cipher.doFinal(plainBytes) + Base64.encodeToString(cipher.iv + cipherBytes, BASE64_FLAGS or Base64.NO_WRAP) + } catch (e: Exception) { + Log.w(TAG, "Credential cache AES/GCM encrypt failed", e) + null + } + } +} diff --git a/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/RecaptchaCredentialStore.kt b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/RecaptchaCredentialStore.kt new file mode 100644 index 0000000000..82c04ce08e --- /dev/null +++ b/play-services-recaptcha/core/src/main/kotlin/org/microg/gms/recaptcha/modac/storage/RecaptchaCredentialStore.kt @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.recaptcha.modac.storage + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.microg.gms.recaptcha.qr.CachedInitCredential + +private const val TAG = "RecaptchaQr" +private const val CACHE_KEY_SUFFIX = "_init" + +internal class RecaptchaCredentialStore(context: Context) { + private val cache = ModacSignerCache(context.applicationContext) + + suspend fun read(siteKey: String): CachedInitCredential? = withContext(Dispatchers.IO) { + readCredential(cacheKey(siteKey)) + } + + suspend fun write(siteKey: String, credential: CachedInitCredential): Boolean = withContext(Dispatchers.IO) { + ModacSignerCrypto.encryptAndStore(cacheKey(siteKey), cache, credential.encode()) + } + + suspend fun replaceIfCurrent( + siteKey: String, + expectedCredential: String, + replacement: CachedInitCredential, + ): Boolean = withContext(Dispatchers.IO) { + val key = cacheKey(siteKey) + cache.withExclusiveAccess { + val current = readCredential(key, " during update") ?: return@withExclusiveAccess false + if (current.credential != expectedCredential) return@withExclusiveAccess false + ModacSignerCrypto.encryptAndStore(key, cache, replacement.encode()) + } + } + + private fun readCredential(key: String, logContext: String = ""): CachedInitCredential? { + val decrypted = ModacSignerCrypto.fetchAndDecrypt(key, cache) ?: return null + return try { + CachedInitCredential.ADAPTER.decode(decrypted) + } catch (e: Exception) { + Log.w(TAG, "Cached credential decode failed$logContext", e) + cache.delete(key) + null + } + } + + private fun cacheKey(siteKey: String): String = siteKey + CACHE_KEY_SUFFIX +} diff --git a/play-services-recaptcha/core/src/main/proto/recaptcha_qr.proto b/play-services-recaptcha/core/src/main/proto/recaptcha_qr.proto new file mode 100644 index 0000000000..040f188238 --- /dev/null +++ b/play-services-recaptcha/core/src/main/proto/recaptcha_qr.proto @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ +syntax = "proto3"; + +import "recaptcha_signals.proto"; + +option java_package = "org.microg.gms.recaptcha.qr"; + +message VerificationRequest { + string nonce = 1; + string landingToken = 2; + string siteKey = 3; + string action = 4; + RecaptchaSignals signals = 8; + string qrToken = 9; +} + +message VerificationResponse { + string challengeToken = 1; + int32 errorCode = 4; + repeated PersistentStorageEntry persistentStorage = 5; + bytes nonceUuid = 6; + bytes droidGuardNonce = 9; +} + +message PersistentStorageEntry { + string name = 1; + string storedValue = 2; +} + +message InitializationRequest { + string siteKey = 1; + string packageName = 2; + string sdkVersion = 3; + int32 clientType = 4; + string requestId = 6; + string sdkInt = 7; + string initSignal = 8; + bool playServicesAvailable = 9; + bool playStoreInstalled = 10; + string installerPackage = 11; + string buildId = 12; +} + +message InitializationResponse { + string nonce = 3; + string landingToken = 5; + reserved 11, 13 to 16; + DroidGuardConfig droidGuardConfig = 12; +} + +message DroidGuardConfig { + bytes payload = 1; +} + +message SignalUpdateRequest { + string landingToken = 2; + string siteKey = 3; + RecaptchaSignals signals = 4; +} + +message SignalUpdateResponse { + string credential = 1; + bytes nonceUuid = 2; +} diff --git a/play-services-recaptcha/core/src/main/proto/recaptcha_signals.proto b/play-services-recaptcha/core/src/main/proto/recaptcha_signals.proto new file mode 100644 index 0000000000..b4e5c0bb33 --- /dev/null +++ b/play-services-recaptcha/core/src/main/proto/recaptcha_signals.proto @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ +syntax = "proto3"; + +option java_package = "org.microg.gms.recaptcha.qr"; + +message RecaptchaSignals { + string action = 1; + reserved 7, 10; + DroidGuardSignals droidGuardSignals = 11; +} + +message DroidGuardSignals { + EncodedSignalBundle signals = 1; +} + +message EncodedSignalBundle { + repeated EncodedSignal signals = 1; + string action = 2; +} + +message EncodedSignal { + reserved 1, 7, 8; + optional SignalEncryption encryption = 4; + optional bytes payload = 6; +} + +enum SignalEncryption { + reserved 2, 3; + UNKNOWN = 0; + NONE = 1; +} + +message SignalValueList { + repeated SignalValue values = 1; + uint32 formatCode = 2; +} + +message SignalValue { + reserved 2 to 10; + oneof value { + bool booleanValue = 1; + string textValue = 11; + } +} diff --git a/play-services-recaptcha/core/src/main/proto/recaptcha_storage.proto b/play-services-recaptcha/core/src/main/proto/recaptcha_storage.proto new file mode 100644 index 0000000000..e82e34b9cd --- /dev/null +++ b/play-services-recaptcha/core/src/main/proto/recaptcha_storage.proto @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ +syntax = "proto3"; + +option java_package = "org.microg.gms.recaptcha.qr"; + +message CachedInitCredential { + reserved 3, 4; + string credential = 1; + bytes nonceUuid = 2; + bytes droidGuardNonce = 5; +} diff --git a/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_confirm.xml b/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_confirm.xml new file mode 100644 index 0000000000..49bdf01962 --- /dev/null +++ b/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_confirm.xml @@ -0,0 +1,14 @@ + + + + diff --git a/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_failed.xml b/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_failed.xml new file mode 100644 index 0000000000..0ac62e804a --- /dev/null +++ b/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_failed.xml @@ -0,0 +1,14 @@ + + + + diff --git a/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_progress.xml b/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_progress.xml new file mode 100644 index 0000000000..c5f6fb6111 --- /dev/null +++ b/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_progress.xml @@ -0,0 +1,14 @@ + + + + diff --git a/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_success.xml b/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_success.xml new file mode 100644 index 0000000000..535d0f8169 --- /dev/null +++ b/play-services-recaptcha/core/src/main/res/drawable/ic_recaptcha_qr_success.xml @@ -0,0 +1,14 @@ + + + + diff --git a/play-services-recaptcha/core/src/main/res/values-zh-rCN/strings.xml b/play-services-recaptcha/core/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000000..fd94534fd0 --- /dev/null +++ b/play-services-recaptcha/core/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,23 @@ + + + + 确认验证 + 有一个网站正在请求使用您的设备进行验证 + 确认验证 + 取消 + + 正在设备上验证 + reCAPTCHA 正在此设备上验证请求,请稍候 + 取消 + + 验证失败 + 验证未能完成,请返回网站后重试 + 关闭 + + 验证完成 + 现在,您可以关闭此界面并返回到相应网页或应用了 + 完成 + diff --git a/play-services-recaptcha/core/src/main/res/values-zh-rTW/strings.xml b/play-services-recaptcha/core/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000000..2c2e805833 --- /dev/null +++ b/play-services-recaptcha/core/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,23 @@ + + + + 確認驗證 + 有一個網站要求使用您的裝置進行驗證 + 確認驗證 + 取消 + + 正在裝置上進行驗證 + reCAPTCHA 正在此裝置上驗證這項要求,請稍候 + 取消 + + 驗證失敗 + 無法完成驗證,請返回網站並再試一次 + 關閉 + + 驗證完成 + 您現在可以關閉此畫面,然後返回網站或應用程式 + 完成 + diff --git a/play-services-recaptcha/core/src/main/res/values/strings.xml b/play-services-recaptcha/core/src/main/res/values/strings.xml new file mode 100644 index 0000000000..06595e4b94 --- /dev/null +++ b/play-services-recaptcha/core/src/main/res/values/strings.xml @@ -0,0 +1,23 @@ + + + + Confirm verification + A website is asking to use your device for verification. + Confirm verification + Cancel + + Verifying on device + reCAPTCHA is verifying the request on this device. Please wait. + Cancel + + Verification failed + Verification could not be completed. Return to the website and try again. + Close + + Verification complete + You can now close this screen and return to the website or app. + Done + From afd02afc7d0fb8b852409cdbfdf689b91c0bec13 Mon Sep 17 00:00:00 2001 From: Davinci9196 Date: Wed, 5 Aug 2026 09:44:11 +0800 Subject: [PATCH 2/2] update --- play-services-recaptcha/core/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/play-services-recaptcha/core/build.gradle b/play-services-recaptcha/core/build.gradle index 2b3971c9d8..5185279133 100644 --- a/play-services-recaptcha/core/build.gradle +++ b/play-services-recaptcha/core/build.gradle @@ -67,6 +67,7 @@ android { lintOptions { disable 'MissingTranslation' + disable 'CoroutineCreationDuringComposition' } compileOptions {