diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index e1c9b34b29..91a1585aac 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -148,6 +148,9 @@ jobs: env: GOMOBILECACHE: ${{ env.GOMOBILECACHE }} + - name: Run stealth manifest filter tests + run: make stealth-manifest-filter-test + - name: Build Android (APK + AAB) run: make android-release-ci env: diff --git a/Makefile b/Makefile index aea7cf9264..2b0759f5f8 100644 --- a/Makefile +++ b/Makefile @@ -124,6 +124,7 @@ ANDROID_STEALTH_NOVPN_APK := $(INSTALLER_NAME)$(if $(filter-out production,$(BUI ANDROID_STEALTH_NOVPN_AAB := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE))-stealth-novpn.aab ANDROID_MAPPING_SRC := build/app/outputs/mapping/release/mapping.txt ANDROID_SYMBOLS_SRC := build/app/outputs/native-debug-symbols/release/native-debug-symbols.zip +PYTHON ?= python3 ANDROID_NDK_VERSION ?= 28.2.13676358 ANDROID_CMAKE_VERSION ?= 3.22.1 ANDROID_BUILD_TOOLS_VERSION ?= 35.0.0 @@ -564,6 +565,9 @@ android-stealth-novpn-aab-release: .PHONY: android-stealth-novpn-release android-stealth-novpn-release: android pubget gen android-stealth-novpn-apk-release android-stealth-novpn-aab-release +.PHONY: stealth-manifest-filter-test +stealth-manifest-filter-test: + $(PYTHON) -m unittest discover -s scripts/stealth -p '*_test.py' .PHONY: android-release android-release: clean android pubget gen android-apk-release diff --git a/android/app/build.gradle b/android/app/build.gradle index a6e83a7743..777b45184a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,3 +1,48 @@ +import com.android.build.api.artifact.SingleArtifact +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction + +/** + * Gradle task that runs the stealth manifest filter on a single input manifest + * file and writes the result to an output file. Participates in AGP's artifact + * transform pipeline via SingleArtifact.MERGED_MANIFEST so the filter sees the + * fully-merged, placeholder-resolved manifest (library AARs already contributed). + */ +abstract class StealthManifestFilterTask extends DefaultTask { + @InputFile + abstract RegularFileProperty getInputManifest() + + @OutputFile + abstract RegularFileProperty getOutputManifest() + + @Input + abstract Property getStealthMode() + + @Input + abstract Property getStealthPython() + + @InputFile + abstract RegularFileProperty getFilterScript() + + @TaskAction + void filter() { + project.exec { + commandLine( + stealthPython.get(), + filterScript.asFile.get().absolutePath, + "--mode", stealthMode.get(), + "--input", inputManifest.asFile.get().absolutePath, + "--output", outputManifest.asFile.get().absolutePath, + ) + } + } +} + plugins { id "com.android.application" id "kotlin-android" @@ -17,7 +62,36 @@ def sideloadSigningCertificateSha256 = : "108f612ae55354078ec12b10bb705362840d48fa78b9262c11b6d0adeff6f289" def sideloadUpdates = project.findProperty("lantern.sideloadUpdates") == "true" def sideloadManifestPath = "$buildDir/generated/lantern/sideload/AndroidManifest.xml" -def stealthNoVpn = project.findProperty("stealthNoVpn")?.toString()?.toBoolean() ?: false +// ── Stealth manifest mode ──────────────────────────────────────────────────── +// STEALTH_MODE accepts "stealth-vpn" or "stealth-novpn" (the "stealth-" prefix +// is stripped internally). The legacy -PstealthNoVpn=true flag is kept for +// backward compat with older automation; it implies novpn when STEALTH_MODE +// is not set. +def rawStealthMode = (project.findProperty("STEALTH_MODE") ?: "").toString() +rawStealthMode = rawStealthMode.trim().toLowerCase(java.util.Locale.ROOT) +def stealthModePrefix = "stealth-" +def stealthMode = rawStealthMode.startsWith(stealthModePrefix) + ? rawStealthMode.substring(stealthModePrefix.length()) + : rawStealthMode +if (rawStealthMode.startsWith(stealthModePrefix) && !stealthMode) { + throw new GradleException("Unsupported STEALTH_MODE '${rawStealthMode}'. Expected stealth-vpn or stealth-novpn") +} +def explicitStealthNoVpn = project.findProperty("stealthNoVpn")?.toString()?.toBoolean() ?: false +if (stealthMode == "vpn" && explicitStealthNoVpn) { + throw new GradleException("Conflicting stealth inputs: STEALTH_MODE=stealth-vpn cannot be combined with -PstealthNoVpn=true") +} +if (!stealthMode && explicitStealthNoVpn) { + stealthMode = "novpn" +} +def stealthModes = ["vpn", "novpn"] +if (stealthMode && !stealthModes.contains(stealthMode)) { + throw new GradleException("Unsupported STEALTH_MODE '${stealthMode}'. Expected stealth-vpn or stealth-novpn") +} +def stealthNoVpn = explicitStealthNoVpn || stealthMode == "novpn" +def stealthManifestFilter = file("${rootProject.projectDir}/../scripts/stealth/android_manifest_filter.py") +def stealthPython = (project.findProperty("stealthPython") ?: System.getenv("PYTHON") ?: "python3").toString() +// ───────────────────────────────────────────────────────────────────────────── + def generateSideloadManifest = tasks.register("generateSideloadManifest") { // The stealth no-VPN manifest does not include QUERY_ALL_PACKAGES, so // sideload updates are only supported for the normal (VPN) manifest. @@ -53,14 +127,14 @@ android { sourceSets { main { // Manifest variant selection: - // - stealthNoVpn=true → stealth no-VPN manifest (no VPN service, restricted permissions) - // - sideloadUpdates=true → normal manifest with REQUEST_INSTALL_PACKAGES injected - // - neither → normal manifest (default, implicit) - // Note: sideloadUpdates is not supported for stealthNoVpn builds because the - // no-VPN manifest omits QUERY_ALL_PACKAGES (the anchor used for sideload injection). - if (stealthNoVpn) { - manifest.srcFile 'src/main/AndroidManifest.novpn.xml' - } else if (sideloadUpdates) { + // - STEALTH_MODE set → src/main manifest used as-is; the stealth + // filter runs on the AGP-merged manifest in + // afterEvaluate (post-library-merge). + // - sideloadUpdates=true (non-stealth only) → source manifest with + // REQUEST_INSTALL_PACKAGES injected pre-merge. + // - neither → normal manifest (default, implicit). + // Note: sideload updates are not supported for stealth builds. + if (sideloadUpdates && !stealthMode) { manifest.srcFile sideloadManifestPath } jniLibs.srcDirs = ['libs'] @@ -123,6 +197,7 @@ android { versionCode = code versionName = flutter.versionName buildConfigField "String", "SIDELOAD_SIGNING_CERTIFICATE_SHA256", "\"${sideloadSigningCertificateSha256}\"" + buildConfigField "boolean", "STEALTH_ENABLED", (stealthMode ? "true" : "false") buildConfigField "boolean", "STEALTH_NO_VPN", stealthNoVpn.toString() buildConfigField "String", "STEALTH_NO_VPN_PROXY_HOST", '"127.0.0.1"' buildConfigField "int", "STEALTH_NO_VPN_PROXY_PORT", "14986" @@ -199,11 +274,34 @@ android { } -if (sideloadUpdates) { +if (sideloadUpdates && !stealthMode) { tasks.matching { it.name.startsWith("process") && it.name.endsWith("MainManifest") } .configureEach { dependsOn(generateSideloadManifest) } } +// Wire the stealth manifest filter into AGP's artifact transform pipeline. +// SingleArtifact.MERGED_MANIFEST is the post-library-merge, post-placeholder- +// resolved manifest — the only point where library-injected entries (Stripe, +// Google Play Billing, Firebase/MLKit) are present for removal. +if (stealthMode) { + androidComponents { + onVariants { variant -> + def filterTask = tasks.register( + "filter${variant.name.capitalize()}Manifest", + StealthManifestFilterTask + ) { task -> + task.stealthMode.set(stealthMode) + task.stealthPython.set(stealthPython) + task.filterScript.set(stealthManifestFilter) + } + variant.artifacts + .use(filterTask) + .wiredWithFiles({ it.getInputManifest() }, { it.getOutputManifest() }) + .toTransform(SingleArtifact.MERGED_MANIFEST) + } + } +} + flutter { source = "../.." } diff --git a/android/app/cpp/ndk-stl-config.cmake b/android/app/cpp/ndk-stl-config.cmake index f9b176248d..dc7318e71d 100644 --- a/android/app/cpp/ndk-stl-config.cmake +++ b/android/app/cpp/ndk-stl-config.cmake @@ -1,18 +1,44 @@ -if(NOT ${ANDROID_STL} MATCHES "_shared") +if(NOT "${ANDROID_STL}" MATCHES "_shared") return() endif() -function(configure_shared_stl lib_path so_base) +set(NDK_PREBUILT_DIR "${ANDROID_NDK}/toolchains/llvm/prebuilt") + +if(DEFINED ANDROID_HOST_TAG AND EXISTS "${NDK_PREBUILT_DIR}/${ANDROID_HOST_TAG}") + set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/${ANDROID_HOST_TAG}") +elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux" AND EXISTS "${NDK_PREBUILT_DIR}/linux-x86_64") + set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/linux-x86_64") +elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") + if(CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "^(arm64|aarch64)$" AND EXISTS "${NDK_PREBUILT_DIR}/darwin-arm64") + set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/darwin-arm64") + elseif(EXISTS "${NDK_PREBUILT_DIR}/darwin-x86_64") + set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/darwin-x86_64") + elseif(EXISTS "${NDK_PREBUILT_DIR}/darwin-arm64") + set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/darwin-arm64") + endif() +elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows" AND EXISTS "${NDK_PREBUILT_DIR}/windows-x86_64") + set(NDK_PREBUILT_ROOT "${NDK_PREBUILT_DIR}/windows-x86_64") +endif() + +if(NOT NDK_PREBUILT_ROOT) + message(FATAL_ERROR "No Android NDK prebuilt toolchain found for ${CMAKE_HOST_SYSTEM_NAME}/${CMAKE_HOST_SYSTEM_PROCESSOR} under ${NDK_PREBUILT_DIR}; set ANDROID_HOST_TAG if needed") +endif() + +if(NOT EXISTS "${NDK_PREBUILT_ROOT}") + message(FATAL_ERROR "Android NDK prebuilt toolchain does not exist: ${NDK_PREBUILT_ROOT}") +endif() + +function(configure_shared_stl so_base) message("Configuring STL ${so_base} for ${ANDROID_ABI}") - if(${ANDROID_ABI} STREQUAL "arm64-v8a") - set(LIBCXX_PATH "${ANDROID_NDK}/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/aarch64-linux-android/lib${so_base}.so") - elseif(${ANDROID_ABI} STREQUAL "armeabi-v7a") - set(LIBCXX_PATH "${ANDROID_NDK}/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/arm-linux-androideabi/lib${so_base}.so") - elseif(${ANDROID_ABI} STREQUAL "x86") - set(LIBCXX_PATH "${ANDROID_NDK}/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/i686-linux-android/lib${so_base}.so") - elseif(${ANDROID_ABI} STREQUAL "x86_64") - set(LIBCXX_PATH "${ANDROID_NDK}/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/x86_64-linux-android/lib${so_base}.so") + if("${ANDROID_ABI}" STREQUAL "arm64-v8a") + set(LIBCXX_PATH "${NDK_PREBUILT_ROOT}/sysroot/usr/lib/aarch64-linux-android/lib${so_base}.so") + elseif("${ANDROID_ABI}" STREQUAL "armeabi-v7a") + set(LIBCXX_PATH "${NDK_PREBUILT_ROOT}/sysroot/usr/lib/arm-linux-androideabi/lib${so_base}.so") + elseif("${ANDROID_ABI}" STREQUAL "x86") + set(LIBCXX_PATH "${NDK_PREBUILT_ROOT}/sysroot/usr/lib/i686-linux-android/lib${so_base}.so") + elseif("${ANDROID_ABI}" STREQUAL "x86_64") + set(LIBCXX_PATH "${NDK_PREBUILT_ROOT}/sysroot/usr/lib/x86_64-linux-android/lib${so_base}.so") else() message(FATAL_ERROR "Unsupported ABI: ${ANDROID_ABI}") endif() @@ -33,13 +59,13 @@ elseif("${ANDROID_STL}" STREQUAL "gabi++_shared") message(FATAL_ERROR "gabi++_shared was not configured by ndk-stl package") elseif("${ANDROID_STL}" STREQUAL "stlport_shared") # The STLport runtime (shared). - configure_shared_stl("stlport" "stlport_shared") + configure_shared_stl("stlport_shared") elseif("${ANDROID_STL}" STREQUAL "gnustl_shared") # The GNU STL (shared). - configure_shared_stl("gnu-libstdc++/4.9" "gnustl_shared") + configure_shared_stl("gnustl_shared") elseif("${ANDROID_STL}" STREQUAL "c++_shared") - # The LLVM libc++ runtime (static). - configure_shared_stl("llvm-libc++" "c++_shared") + # The LLVM libc++ runtime (shared). + configure_shared_stl("c++_shared") else() message(FATAL_ERROR "STL configuration ANDROID_STL=${ANDROID_STL} is not supported") -endif() \ No newline at end of file +endif() diff --git a/android/app/src/main/kotlin/foundation/bridge/StealthComponents.kt b/android/app/src/main/kotlin/foundation/bridge/StealthComponents.kt new file mode 100644 index 0000000000..0cb4005d00 --- /dev/null +++ b/android/app/src/main/kotlin/foundation/bridge/StealthComponents.kt @@ -0,0 +1,16 @@ +package foundation.bridge + +import androidx.annotation.RequiresApi +import org.getlantern.lantern.LanternApp +import org.getlantern.lantern.MainActivity +import org.getlantern.lantern.service.LanternVpnService +import org.getlantern.lantern.service.QuickTileService + +class AppHost : LanternApp() + +class HomeActivity : MainActivity() + +class NetworkService : LanternVpnService() + +@RequiresApi(24) +class ControlTile : QuickTileService() diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt b/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt index 061af1a0f6..d8e1ddec44 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/MainActivity.kt @@ -11,6 +11,7 @@ import android.os.Looper import android.util.Log import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import foundation.bridge.NetworkService import foundation.bridge.SyncService import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine @@ -32,7 +33,7 @@ import org.getlantern.lantern.utils.logDir import org.getlantern.lantern.utils.setupDirs -class MainActivity : FlutterFragmentActivity() { +open class MainActivity : FlutterFragmentActivity() { companion object { const val TAG = "A/MainActivity" lateinit var instance: MainActivity @@ -54,6 +55,9 @@ class MainActivity : FlutterFragmentActivity() { private val serviceStartHandler = Handler(Looper.getMainLooper()) + private val vpnServiceClass: Class + get() = if (BuildConfig.STEALTH_ENABLED) NetworkService::class.java else LanternVpnService::class.java + private val noVpnServiceClass: Class get() = if (BuildConfig.STEALTH_NO_VPN) SyncService::class.java else NoVpnLanternService::class.java @@ -112,12 +116,12 @@ class MainActivity : FlutterFragmentActivity() { retryCountResume = 0 return } - if (isServiceRunning(this, LanternVpnService::class.java)) { + if (isServiceRunning(this, vpnServiceClass)) { AppLogger.d(TAG, "LanternService is already running") return } try { - val radianceIntent = Intent(this, LanternVpnService::class.java).apply { + val radianceIntent = Intent(this, vpnServiceClass).apply { action = LanternVpnService.ACTION_START_RADIANCE } startService(radianceIntent) @@ -212,7 +216,7 @@ class MainActivity : FlutterFragmentActivity() { } try { - val vpnIntent = Intent(this, LanternVpnService::class.java).apply { + val vpnIntent = Intent(this, vpnServiceClass).apply { action = LanternVpnService.ACTION_START_VPN } ContextCompat.startForegroundService(this, vpnIntent) @@ -248,7 +252,7 @@ class MainActivity : FlutterFragmentActivity() { } try { - val vpnIntent = Intent(this, LanternVpnService::class.java).apply { + val vpnIntent = Intent(this, vpnServiceClass).apply { action = LanternVpnService.ACTION_CONNECT_TO_SERVER putExtra("tag", tag) } @@ -276,7 +280,7 @@ class MainActivity : FlutterFragmentActivity() { } return } - if (isServiceRunning(this, LanternVpnService::class.java)) { + if (isServiceRunning(this, vpnServiceClass)) { LanternApp.application.sendBroadcast( Intent(LanternVpnService.ACTION_STOP_VPN) .setPackage(LanternApp.application.packageName) diff --git a/docs/stealth-builds.md b/docs/stealth-builds.md new file mode 100644 index 0000000000..ca4a32e6fb --- /dev/null +++ b/docs/stealth-builds.md @@ -0,0 +1,32 @@ +# Stealth build notes + +Android stealth manifest minimization is opt-in through the Gradle project +property `STEALTH_MODE`. The Gradle task uses `-PstealthPython`, then `PYTHON`, +then `python3` to generate the filtered manifest, so Android stealth builds +require Python 3.8 or newer through one of those paths. + +```sh +gradle -p android :app:assembleRelease -PSTEALTH_MODE=stealth-vpn +gradle -p android :app:assembleRelease -PSTEALTH_MODE=stealth-novpn +``` + +The filter runs on the AGP-merged manifest (after library AARs such as Stripe +and Google Play Billing have contributed their activities and services) so that +all library-injected manifest entries are available for removal. + +`-PstealthNoVpn=true` is kept as a compatibility switch for older automation. +It only selects `novpn` mode when `STEALTH_MODE` is **not** set; if both +`STEALTH_MODE` and `-PstealthNoVpn` are unset, the build is a normal +non-stealth build. When `-PstealthNoVpn=true` and `STEALTH_MODE=stealth-vpn` +are both set, Gradle fails fast because the two inputs conflict. Prefer +`-PSTEALTH_MODE=stealth-novpn` for new build scripts. + +`vpn` keeps the Android `VpnService` surface but removes app links, broad +package visibility, write-settings access, payment query declarations, wallet +metadata, boot receiver, and cleartext traffic allowance from the generated +manifest. + +`novpn` applies the same filtering and also removes Android VPN service +components, quick-tile VPN controls, and VPN-related permissions. +Runtime code must still be compiled or gated separately so no-vpn builds do not +attempt to start removed services. diff --git a/scripts/stealth/android_manifest_filter.py b/scripts/stealth/android_manifest_filter.py new file mode 100644 index 0000000000..e3bb10d176 --- /dev/null +++ b/scripts/stealth/android_manifest_filter.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +"""Generate minimized Android manifests for stealth build modes. + +This filter runs on the AGP-MERGED manifest (post-library-merge, +post-placeholder-substitution). At this stage, library-contributed entries +(Stripe/billing activities, Firebase/MLKit services, GMS metadata, …) are +already present in the input. + +Because the merge step has already completed, ``tools:node`` directives have +no effect — they are merger instructions, not packager instructions. aapt2 +would strip the ``tools:`` namespace and keep any stub element as a bare +entry, re-adding what we just removed. This filter therefore uses direct +``parent.remove(child)`` removal exclusively; no ``tools:node`` stubs are +written. +""" + +from __future__ import annotations + +import argparse +import io +from pathlib import Path +import xml.etree.ElementTree as ET + + +ANDROID_URI = "http://schemas.android.com/apk/res/android" +ANDROID_NAME = f"{{{ANDROID_URI}}}name" +ANDROID_CLEAR_TEXT = f"{{{ANDROID_URI}}}usesCleartextTraffic" +ANDROID_PERMISSION = f"{{{ANDROID_URI}}}permission" +ANDROID_EXPORTED = f"{{{ANDROID_URI}}}exported" +ANDROID_FOREGROUND_SERVICE_TYPE = f"{{{ANDROID_URI}}}foregroundServiceType" +ANDROID_VALUE = f"{{{ANDROID_URI}}}value" + +STEALTH_MODES = {"vpn", "novpn"} + +STEALTH_REMOVE_PERMISSIONS = { + "android.permission.CAMERA", + "android.permission.QUERY_ALL_PACKAGES", + "android.permission.RECEIVE_BOOT_COMPLETED", + "android.permission.WRITE_SETTINGS", + "com.android.vending.BILLING", +} + +STEALTH_REMOVE_FEATURES = { + "android.hardware.camera", +} + +NOVPN_REMOVE_PERMISSIONS = STEALTH_REMOVE_PERMISSIONS | { + "android.permission.ACCESS_WIFI_STATE", + "android.permission.CHANGE_NETWORK_STATE", + "android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED", +} + +NOVPN_REQUIRED_PERMISSIONS = { + "android.permission.FOREGROUND_SERVICE", + "android.permission.FOREGROUND_SERVICE_SPECIAL_USE", +} + +STEALTH_REMOVE_META_DATA = { + "aia-compat-api-min-version", + "com.google.android.gms.version", + "com.google.android.gms.wallet.api.enabled", + "com.google.android.play.billingclient.version", +} + +STEALTH_REMOVE_ACTIVITIES = { + "com.android.billingclient.api.ProxyBillingActivity", + "com.android.billingclient.api.ProxyBillingActivityV2", + "com.google.android.gms.common.api.GoogleApiActivity", + "com.google.android.play.core.common.PlayCoreDialogWrapperActivity", + "com.stripe.android.attestation.AttestationActivity", + "com.stripe.android.challenge.confirmation.IntentConfirmationChallengeActivity", + "com.stripe.android.challenge.passive.PassiveChallengeActivity", + "com.stripe.android.challenge.passive.warmer.activity.PassiveChallengeWarmerActivity", + "com.stripe.android.customersheet.CustomerSheetActivity", + "com.stripe.android.financialconnections.FinancialConnectionsSheetActivity", + "com.stripe.android.financialconnections.FinancialConnectionsSheetRedirectActivity", + "com.stripe.android.financialconnections.lite.FinancialConnectionsSheetLiteActivity", + "com.stripe.android.financialconnections.lite.FinancialConnectionsSheetLiteRedirectActivity", + "com.stripe.android.financialconnections.ui.FinancialConnectionsSheetNativeActivity", + "com.stripe.android.googlepaylauncher.GooglePayLauncherActivity", + "com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncherActivity", + "com.stripe.android.link.LinkActivity", + "com.stripe.android.link.LinkForegroundActivity", + "com.stripe.android.link.LinkRedirectHandlerActivity", + "com.stripe.android.paymentelement.confirmation.cpms.CustomPaymentMethodProxyActivity", + "com.stripe.android.paymentelement.embedded.form.FormActivity", + "com.stripe.android.paymentelement.embedded.manage.ManageActivity", + "com.stripe.android.payments.StripeBrowserLauncherActivity", + "com.stripe.android.payments.StripeBrowserProxyReturnActivity", + "com.stripe.android.payments.bankaccount.ui.CollectBankAccountActivity", + "com.stripe.android.payments.core.authentication.threeds2.Stripe3ds2TransactionActivity", + "com.stripe.android.payments.paymentlauncher.PaymentLauncherConfirmationActivity", + "com.stripe.android.paymentsheet.ExternalPaymentMethodProxyActivity", + "com.stripe.android.paymentsheet.PaymentOptionsActivity", + "com.stripe.android.paymentsheet.PaymentSheetActivity", + "com.stripe.android.paymentsheet.addresselement.AddressElementActivity", + "com.stripe.android.paymentsheet.addresselement.AutocompleteActivity", + "com.stripe.android.paymentsheet.paymentdatacollection.bacs.BacsMandateConfirmationActivity", + "com.stripe.android.paymentsheet.paymentdatacollection.cvcrecollection.CvcRecollectionActivity", + "com.stripe.android.paymentsheet.paymentdatacollection.polling.PollingActivity", + "com.stripe.android.paymentsheet.ui.SepaMandateActivity", + "com.stripe.android.shoppay.ShopPayActivity", + "com.stripe.android.stripe3ds2.views.ChallengeActivity", + "com.stripe.android.view.PaymentAuthWebViewActivity", + "com.stripe.android.view.PaymentRelayActivity", +} + +# Prefix-based removal catches new SDK versions without requiring a list update. +# The hardcoded set above remains the primary gate; prefixes are defence-in-depth. +STEALTH_REMOVE_ACTIVITY_PREFIXES = ( + "com.stripe.", + "com.android.billingclient.", +) + +STEALTH_REMOVE_SERVICES = { + "androidx.camera.core.impl.MetadataHolderService", + "com.google.android.datatransport.runtime.backends.TransportBackendDiscovery", + "com.google.android.datatransport.runtime.scheduling.jobscheduling.JobInfoSchedulerService", + "com.google.mlkit.common.internal.MlKitComponentDiscoveryService", +} + +STEALTH_REMOVE_PROVIDERS = { + "com.google.mlkit.common.internal.MlKitInitProvider", +} + +STEALTH_REMOVE_RECEIVERS = { + "com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver", +} + +STEALTH_APPLICATION_NAME = "foundation.bridge.AppHost" +STEALTH_ACTIVITY_NAME = "foundation.bridge.HomeActivity" +STEALTH_VPN_SERVICE_NAME = "foundation.bridge.NetworkService" +STEALTH_TILE_SERVICE_NAME = "foundation.bridge.ControlTile" +STEALTH_NOVPN_SERVICE_NAME = "foundation.bridge.SyncService" +NOVPN_SPECIAL_USE_REASON = "User-controlled local proxy connection" + +BASE_APPLICATION_NAMES = {".LanternApp", "org.getlantern.lantern.LanternApp"} +BASE_ACTIVITY_NAMES = {".MainActivity", "org.getlantern.lantern.MainActivity"} +BASE_VPN_SERVICE_NAMES = { + ".service.LanternVpnService", + "org.getlantern.lantern.service.LanternVpnService", +} +BASE_TILE_SERVICE_NAMES = { + ".service.QuickTileService", + "org.getlantern.lantern.service.QuickTileService", +} + + +def android_attr(element: ET.Element, attr_name: str) -> str: + return element.attrib.get(attr_name, "") + + +def has_vpn_intent_filter(service: ET.Element) -> bool: + for intent_filter in service.findall("intent-filter"): + for action in intent_filter.findall("action"): + if android_attr(action, ANDROID_NAME) == "android.net.VpnService": + return True + return False + + +def is_quick_tile_service(service: ET.Element) -> bool: + return android_attr(service, ANDROID_PERMISSION) == "android.permission.BIND_QUICK_SETTINGS_TILE" + + +def is_deeplink_intent_filter(intent_filter: ET.Element) -> bool: + has_view_action = any( + android_attr(action, ANDROID_NAME) == "android.intent.action.VIEW" + for action in intent_filter.findall("action") + ) + if not has_view_action: + return False + return bool(intent_filter.findall("data")) + + +def remove_matching(parent: ET.Element, predicate) -> None: + for child in list(parent): + if predicate(child): + parent.remove(child) + + +def ensure_permission(root: ET.Element, permission: str) -> None: + if any( + el.tag == "uses-permission" and android_attr(el, ANDROID_NAME) == permission + for el in root.findall("uses-permission") + ): + return + permission_element = ET.Element("uses-permission") + permission_element.set(ANDROID_NAME, permission) + root.insert(0, permission_element) + + +def rewrite_stealth_components(application: ET.Element) -> None: + if android_attr(application, ANDROID_NAME) in BASE_APPLICATION_NAMES: + application.set(ANDROID_NAME, STEALTH_APPLICATION_NAME) + + for activity in application.findall("activity"): + if android_attr(activity, ANDROID_NAME) in BASE_ACTIVITY_NAMES: + activity.set(ANDROID_NAME, STEALTH_ACTIVITY_NAME) + + for service in application.findall("service"): + service_name = android_attr(service, ANDROID_NAME) + if service_name in BASE_VPN_SERVICE_NAMES: + service.set(ANDROID_NAME, STEALTH_VPN_SERVICE_NAME) + elif service_name in BASE_TILE_SERVICE_NAMES: + service.set(ANDROID_NAME, STEALTH_TILE_SERVICE_NAME) + + +def ensure_novpn_service(application: ET.Element) -> None: + if any( + android_attr(service, ANDROID_NAME) == STEALTH_NOVPN_SERVICE_NAME + for service in application.findall("service") + ): + return + + service = ET.Element("service") + service.set(ANDROID_NAME, STEALTH_NOVPN_SERVICE_NAME) + service.set(ANDROID_EXPORTED, "false") + service.set(ANDROID_FOREGROUND_SERVICE_TYPE, "specialUse") + + special_use = ET.SubElement(service, "property") + special_use.set(ANDROID_NAME, "android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE") + special_use.set(ANDROID_VALUE, NOVPN_SPECIAL_USE_REASON) + application.append(service) + + +def filter_manifest(input_path: Path, output_path: Path, mode: str) -> None: + if mode not in STEALTH_MODES: + raise ValueError(f"unsupported stealth mode {mode!r}") + + ET.register_namespace("android", ANDROID_URI) + + tree = ET.parse(input_path) + root = tree.getroot() + + remove_permissions = STEALTH_REMOVE_PERMISSIONS + if mode == "novpn": + remove_permissions = NOVPN_REMOVE_PERMISSIONS + + # Remove forbidden permissions and features. No tools:node stubs are + # written: this filter runs post-merge, so aapt2 would strip the tools: + # namespace and re-add the bare element, defeating the removal. + remove_matching( + root, + lambda el: el.tag == "uses-permission" + and android_attr(el, ANDROID_NAME) in remove_permissions, + ) + remove_matching( + root, + lambda el: el.tag == "uses-feature" + and android_attr(el, ANDROID_NAME) in STEALTH_REMOVE_FEATURES, + ) + if mode == "novpn": + for permission in sorted(NOVPN_REQUIRED_PERMISSIONS): + ensure_permission(root, permission) + + # Remove the entire block — package-visibility declarations are a + # deanonymization surface; stealth builds must not enumerate partner packages. + queries = root.find("queries") + if queries is not None: + root.remove(queries) + + application = root.find("application") + if application is not None: + application.set(ANDROID_CLEAR_TEXT, "false") + rewrite_stealth_components(application) + + for activity in application.findall("activity"): + remove_matching( + activity, + lambda el: el.tag == "intent-filter" and is_deeplink_intent_filter(el), + ) + + remove_matching( + application, + lambda el: el.tag == "meta-data" + and android_attr(el, ANDROID_NAME) in STEALTH_REMOVE_META_DATA, + ) + # Remove forbidden activities: exact-name set + prefix sweep for + # SDK-version resilience (e.g. future com.stripe.* additions). + remove_matching( + application, + lambda el: el.tag == "activity" + and ( + android_attr(el, ANDROID_NAME) in STEALTH_REMOVE_ACTIVITIES + or any( + android_attr(el, ANDROID_NAME).startswith(p) + for p in STEALTH_REMOVE_ACTIVITY_PREFIXES + ) + ), + ) + remove_matching( + application, + lambda el: el.tag == "service" + and android_attr(el, ANDROID_NAME) in STEALTH_REMOVE_SERVICES, + ) + remove_matching( + application, + lambda el: el.tag == "provider" + and android_attr(el, ANDROID_NAME) in STEALTH_REMOVE_PROVIDERS, + ) + remove_matching( + application, + lambda el: el.tag == "receiver" + and android_attr(el, ANDROID_NAME) in STEALTH_REMOVE_RECEIVERS, + ) + + if mode == "novpn": + remove_matching( + application, + lambda el: el.tag == "service" + and ( + android_attr(el, ANDROID_PERMISSION) + == "android.permission.BIND_VPN_SERVICE" + or has_vpn_intent_filter(el) + or is_quick_tile_service(el) + ), + ) + ensure_novpn_service(application) + + if hasattr(ET, "indent"): + ET.indent(tree, space=" ") + output_path.parent.mkdir(parents=True, exist_ok=True) + output = io.BytesIO() + tree.write(output, encoding="utf-8", xml_declaration=False) + xml = output.getvalue().decode("utf-8") + if not xml.endswith("\n"): + xml += "\n" + output_path.write_text(xml, encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--mode", required=True, choices=sorted(STEALTH_MODES)) + parser.add_argument("--input", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + + filter_manifest(args.input, args.output, args.mode) + + +if __name__ == "__main__": + main() diff --git a/scripts/stealth/android_manifest_filter_test.py b/scripts/stealth/android_manifest_filter_test.py new file mode 100644 index 0000000000..8dfdfb3c49 --- /dev/null +++ b/scripts/stealth/android_manifest_filter_test.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +"""Tests for android_manifest_filter.py. + +These tests operate on a synthetic merged manifest that includes entries +normally contributed by library AARs (Stripe, Google Play Billing, +Firebase/MLKit). This mirrors what AGP injects during the merge step, which +is the only point at which the filter runs. + +Security invariants tested: +- Every explicitly forbidden permission is absent from the output. +- Every explicitly forbidden activity / service / provider / receiver is absent. +- The block is removed entirely (package-visibility deanonymisation). +- No tools:node="remove" stubs appear in the output (post-merge stubs would + survive aapt2 as bare elements, re-adding forbidden entries). +- The launcher activity is preserved and its deep-link intent-filters are + stripped while the MAIN/LAUNCHER filter is kept. +- Foundation-bridge class aliases replace the base Lantern classes. +- Running the filter twice (idempotency) produces identical output. +- novpn additionally removes VpnService / QuickTile and injects SyncService. +""" + +from __future__ import annotations + +import sys +import tempfile +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import android_manifest_filter as f + + +ANDROID_URI = "http://schemas.android.com/apk/res/android" +ANDROID = f"{{{ANDROID_URI}}}" +TOOLS = "{http://schemas.android.com/tools}" + +# A synthetic AGP-merged manifest containing one entry from every category the +# filter is expected to remove. +BASE_MANIFEST = """\ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + + +def _parse(path: Path) -> ET.Element: + return ET.parse(path).getroot() + + +def _names(elements) -> set[str]: + return {el.attrib.get(f"{ANDROID}name", "") for el in elements} + + +def _permissions(root: ET.Element) -> set[str]: + return _names(root.findall("uses-permission")) + + +def _features(root: ET.Element) -> set[str]: + return _names(root.findall("uses-feature")) + + +def _app(root: ET.Element) -> ET.Element: + app = root.find("application") + assert app is not None + return app + + +def _no_tools_node_stubs(root: ET.Element) -> bool: + """Return True iff no element in the manifest carries a tools:node attribute. + + Post-merge, any tools:node="remove" stub would flow to aapt2 which strips + the tools: namespace but keeps the bare element — re-introducing every + forbidden entry the filter was supposed to delete. + """ + tools_node_attr = f"{TOOLS}node" + for el in root.iter(): + if tools_node_attr in el.attrib: + return False + return True + + +class VpnModeTest(unittest.TestCase): + """Assertions for --mode vpn.""" + + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + tmp = Path(self._tmp.name) + src = tmp / "AndroidManifest.xml" + src.write_text(BASE_MANIFEST, encoding="utf-8") + self.out = tmp / "out.xml" + f.filter_manifest(src, self.out, "vpn") + self.root = _parse(self.out) + self.app = _app(self.root) + + def tearDown(self) -> None: + self._tmp.cleanup() + + # ── Security: no tools:node stubs ────────────────────────────────────── + + def test_no_tools_node_stubs(self) -> None: + """filter must never emit tools:node attributes — they survive aapt2 as bare elements.""" + self.assertTrue(_no_tools_node_stubs(self.root)) + + # ── Forbidden permissions absent ─────────────────────────────────────── + + def test_camera_permission_removed(self) -> None: + self.assertNotIn("android.permission.CAMERA", _permissions(self.root)) + + def test_query_all_packages_removed(self) -> None: + self.assertNotIn("android.permission.QUERY_ALL_PACKAGES", _permissions(self.root)) + + def test_receive_boot_removed(self) -> None: + self.assertNotIn("android.permission.RECEIVE_BOOT_COMPLETED", _permissions(self.root)) + + def test_write_settings_removed(self) -> None: + self.assertNotIn("android.permission.WRITE_SETTINGS", _permissions(self.root)) + + def test_billing_permission_removed(self) -> None: + self.assertNotIn("com.android.vending.BILLING", _permissions(self.root)) + + def test_internet_permission_kept(self) -> None: + self.assertIn("android.permission.INTERNET", _permissions(self.root)) + + # ── Forbidden features absent ────────────────────────────────────────── + + def test_camera_feature_removed(self) -> None: + self.assertNotIn("android.hardware.camera", _features(self.root)) + + # ── Forbidden activities absent ──────────────────────────────────────── + + def test_billing_activity_removed(self) -> None: + activity_names = _names(self.app.findall("activity")) + self.assertNotIn("com.android.billingclient.api.ProxyBillingActivity", activity_names) + self.assertNotIn("com.android.billingclient.api.ProxyBillingActivityV2", activity_names) + + def test_stripe_activities_removed(self) -> None: + activity_names = _names(self.app.findall("activity")) + self.assertNotIn("com.stripe.android.paymentsheet.PaymentSheetActivity", activity_names) + self.assertNotIn("com.stripe.android.paymentsheet.PaymentOptionsActivity", activity_names) + + def test_gms_activity_removed(self) -> None: + activity_names = _names(self.app.findall("activity")) + self.assertNotIn("com.google.android.gms.common.api.GoogleApiActivity", activity_names) + + # ── Forbidden services absent ────────────────────────────────────────── + + def test_mlkit_service_removed(self) -> None: + service_names = _names(self.app.findall("service")) + self.assertNotIn( + "com.google.mlkit.common.internal.MlKitComponentDiscoveryService", service_names + ) + + def test_datatransport_service_removed(self) -> None: + service_names = _names(self.app.findall("service")) + self.assertNotIn( + "com.google.android.datatransport.runtime.backends.TransportBackendDiscovery", + service_names, + ) + + # ── Forbidden providers absent ───────────────────────────────────────── + + def test_mlkit_provider_removed(self) -> None: + provider_names = _names(self.app.findall("provider")) + self.assertNotIn("com.google.mlkit.common.internal.MlKitInitProvider", provider_names) + + # ── Forbidden receivers absent ───────────────────────────────────────── + + def test_boot_receiver_removed(self) -> None: + receiver_names = _names(self.app.findall("receiver")) + self.assertNotIn( + "com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver", + receiver_names, + ) + + # ── Forbidden metadata absent ────────────────────────────────────────── + + def test_wallet_metadata_removed(self) -> None: + meta_names = _names(self.app.findall("meta-data")) + self.assertNotIn("com.google.android.gms.wallet.api.enabled", meta_names) + self.assertNotIn("com.google.android.gms.version", meta_names) + + # ── queries block removed ────────────────────────────────────────────── + + def test_queries_block_removed(self) -> None: + self.assertIsNone(self.root.find("queries")) + + # ── Launcher activity preserved, deep-links stripped ────────────────── + + def test_launcher_activity_present(self) -> None: + activity_names = _names(self.app.findall("activity")) + self.assertIn("foundation.bridge.HomeActivity", activity_names) + + def test_launcher_filter_kept(self) -> None: + for activity in self.app.findall("activity"): + if activity.attrib.get(f"{ANDROID}name") == "foundation.bridge.HomeActivity": + filters = activity.findall("intent-filter") + actions = { + action.attrib.get(f"{ANDROID}name", "") + for fil in filters + for action in fil.findall("action") + } + self.assertIn("android.intent.action.MAIN", actions) + return + self.fail("HomeActivity not found") + + def test_deeplink_filters_stripped(self) -> None: + for activity in self.app.findall("activity"): + if activity.attrib.get(f"{ANDROID}name") == "foundation.bridge.HomeActivity": + for fil in activity.findall("intent-filter"): + for data in fil.findall("data"): + scheme = data.attrib.get(f"{ANDROID}scheme", "") + self.assertNotIn(scheme, ("lantern", "https"), + msg=f"deep-link intent-filter with scheme={scheme!r} survived") + return + self.fail("HomeActivity not found") + + # ── Class-name rewrite applied ───────────────────────────────────────── + + def test_application_rewritten(self) -> None: + self.assertEqual( + self.app.attrib.get(f"{ANDROID}name"), "foundation.bridge.AppHost" + ) + + def test_vpn_service_rewritten(self) -> None: + service_names = _names(self.app.findall("service")) + self.assertIn("foundation.bridge.NetworkService", service_names) + self.assertNotIn(".service.LanternVpnService", service_names) + self.assertNotIn("org.getlantern.lantern.service.LanternVpnService", service_names) + + def test_tile_service_rewritten(self) -> None: + service_names = _names(self.app.findall("service")) + self.assertIn("foundation.bridge.ControlTile", service_names) + self.assertNotIn(".service.QuickTileService", service_names) + + # ── cleartext traffic disabled ───────────────────────────────────────── + + def test_cleartext_disabled(self) -> None: + self.assertEqual( + self.app.attrib.get(f"{ANDROID}usesCleartextTraffic"), "false" + ) + + # ── Idempotency ──────────────────────────────────────────────────────── + + def test_idempotent(self) -> None: + tmp2 = tempfile.mkdtemp() + out2 = Path(tmp2) / "out2.xml" + f.filter_manifest(self.out, out2, "vpn") + self.assertEqual( + self.out.read_text(encoding="utf-8"), + out2.read_text(encoding="utf-8"), + ) + + +class NoVpnModeTest(unittest.TestCase): + """Assertions for --mode novpn (superset of vpn removals).""" + + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + tmp = Path(self._tmp.name) + src = tmp / "AndroidManifest.xml" + src.write_text(BASE_MANIFEST, encoding="utf-8") + self.out = tmp / "out.xml" + f.filter_manifest(src, self.out, "novpn") + self.root = _parse(self.out) + self.app = _app(self.root) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_no_tools_node_stubs(self) -> None: + self.assertTrue(_no_tools_node_stubs(self.root)) + + def test_vpn_permission_removed(self) -> None: + # ACCESS_WIFI_STATE and CHANGE_NETWORK_STATE are novpn-only removals. + permissions = _permissions(self.root) + self.assertNotIn("android.permission.ACCESS_WIFI_STATE", permissions) + self.assertNotIn("android.permission.CHANGE_NETWORK_STATE", permissions) + self.assertNotIn("android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED", permissions) + + def test_vpn_service_removed(self) -> None: + service_names = _names(self.app.findall("service")) + for name in service_names: + self.assertNotIn("VpnService", name, + msg=f"VPN service {name!r} survived novpn filter") + + def test_quick_tile_removed(self) -> None: + service_names = _names(self.app.findall("service")) + self.assertNotIn("foundation.bridge.ControlTile", service_names) + + def test_sync_service_injected(self) -> None: + service_names = _names(self.app.findall("service")) + self.assertIn("foundation.bridge.SyncService", service_names) + + def test_foreground_service_permissions_added(self) -> None: + permissions = _permissions(self.root) + self.assertIn("android.permission.FOREGROUND_SERVICE", permissions) + self.assertIn("android.permission.FOREGROUND_SERVICE_SPECIAL_USE", permissions) + + def test_queries_block_removed(self) -> None: + self.assertIsNone(self.root.find("queries")) + + def test_billing_permission_removed(self) -> None: + self.assertNotIn("com.android.vending.BILLING", _permissions(self.root)) + + def test_idempotent(self) -> None: + tmp2 = tempfile.mkdtemp() + out2 = Path(tmp2) / "out2.xml" + f.filter_manifest(self.out, out2, "novpn") + self.assertEqual( + self.out.read_text(encoding="utf-8"), + out2.read_text(encoding="utf-8"), + ) + + +class NormalBuildUnaffectedTest(unittest.TestCase): + """Ensure the filter is not called for normal builds (smoke-test the guard).""" + + def test_invalid_mode_raises(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + src = Path(tmp) / "in.xml" + src.write_text(BASE_MANIFEST, encoding="utf-8") + out = Path(tmp) / "out.xml" + with self.assertRaises(ValueError): + f.filter_manifest(src, out, "") + with self.assertRaises(ValueError): + f.filter_manifest(src, out, "stealth-vpn") + + +if __name__ == "__main__": + unittest.main()