diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index f043f084ad..924fbbcdf3 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -12,6 +12,28 @@ on: installer_base_name: required: true type: string + android_identity_seed: + required: false + type: string + stealth_leakage_mode: + description: "Optional stealth leakage scan mode, for example stealth or stealth-novpn" + required: false + type: string + default: "" + obfuscate_go: + description: "Build Android Go/native libraries through garble" + required: false + type: boolean + default: false + secrets: + GRADLE_PROPERTIES: + required: true + APP_ENV: + required: true + KEYSTORE: + required: true + STEALTH_GARBLE_SEED: + required: false jobs: build-android: @@ -128,14 +150,58 @@ jobs: env: GOMOBILECACHE: ${{ env.GOMOBILECACHE }} + - name: Run stealth manifest filter tests + run: make stealth-manifest-filter-test + + - name: Install garble + if: ${{ inputs.obfuscate_go }} + run: make install-garble + + - name: Build Android obfuscated (APK + AAB) + if: ${{ inputs.obfuscate_go }} + run: make android-release-ci-obfuscated + env: + BUILD_TYPE: ${{ inputs.build_type }} + VERSION: ${{ inputs.version }} + INSTALLER_NAME: ${{ inputs.installer_base_name }} + ANDROID_IDENTITY_SEED: ${{ inputs.android_identity_seed }} + GOMOBILECACHE: ${{ env.GOMOBILECACHE }} + STEALTH_GARBLE_SEED: ${{ secrets.STEALTH_GARBLE_SEED }} + - name: Build Android (APK + AAB) + if: ${{ !inputs.obfuscate_go }} run: make android-release-ci env: BUILD_TYPE: ${{ inputs.build_type }} VERSION: ${{ inputs.version }} INSTALLER_NAME: ${{ inputs.installer_base_name }} + ANDROID_IDENTITY_SEED: ${{ inputs.android_identity_seed }} GOMOBILECACHE: ${{ env.GOMOBILECACHE }} + - name: Stealth leakage check + id: stealth_leakage_check + if: ${{ inputs.stealth_leakage_mode != '' }} + shell: bash + run: | + set -o pipefail + : > stealth-leakage-report.txt + make stealth-leakage-check 2>&1 | tee stealth-leakage-report.txt + env: + STEALTH_LEAKAGE_MODE: ${{ inputs.stealth_leakage_mode }} + STEALTH_LEAKAGE_MISSING_OK: "0" + STEALTH_LEAKAGE_PATHS: >- + ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }}.apk + ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }}.aab + + - name: Upload stealth leakage report + if: ${{ always() && inputs.stealth_leakage_mode != '' && steps.stealth_leakage_check.outcome != 'skipped' }} + uses: actions/upload-artifact@v4 + with: + name: stealth-leakage-report + path: stealth-leakage-report.txt + if-no-files-found: error + retention-days: 14 + - name: Upload Android APK uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0952a7b915..f0d670fc74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,6 +49,15 @@ on: required: false type: boolean default: false + stealth_leakage_mode: + description: "Run Android stealth leakage scanner for manual Android builds" + required: false + type: choice + options: + - none + - stealth + - stealth-novpn + default: none env: # Scheduled nightly store uploads run on these UTC weekdays. Configure via @@ -81,6 +90,7 @@ jobs: is_test_run: ${{ steps.meta.outputs.is_test_run }} publish_mobile_stores: ${{ steps.meta.outputs.publish_mobile_stores }} smoke_enable_ip_check: ${{ steps.meta.outputs.smoke_enable_ip_check }} + stealth_leakage_mode: ${{ steps.meta.outputs.stealth_leakage_mode }} steps: - name: Checkout repo uses: actions/checkout@v4 @@ -95,6 +105,7 @@ jobs: shell: bash env: GH_TOKEN: ${{ github.token }} + REQUESTED_STEALTH_LEAKAGE_MODE: ${{ github.event.inputs.stealth_leakage_mode }} run: | EVENT="${{ github.event_name }}" REQUEST_MOBILE_STORE_PUBLISH="false" @@ -257,6 +268,18 @@ jobs: fi SMOKE_ENABLE_IP_CHECK="false" + STEALTH_LEAKAGE_MODE="" + if [[ "$EVENT" == "workflow_dispatch" ]]; then + requested_stealth_leakage_mode="${REQUESTED_STEALTH_LEAKAGE_MODE:-none}" + case "$requested_stealth_leakage_mode" in + none|'') ;; + stealth|stealth-novpn) STEALTH_LEAKAGE_MODE="$requested_stealth_leakage_mode" ;; + *) + echo "Error: invalid stealth_leakage_mode '$requested_stealth_leakage_mode'" >&2 + exit 1 + ;; + esac + fi echo "Build Configuration:" echo " Event: $EVENT" @@ -277,6 +300,7 @@ jobs: echo "is_test_run=$IS_TEST_RUN" >> $GITHUB_OUTPUT echo "publish_mobile_stores=$PUBLISH_MOBILE_STORES" >> $GITHUB_OUTPUT echo "smoke_enable_ip_check=$SMOKE_ENABLE_IP_CHECK" >> $GITHUB_OUTPUT + echo "stealth_leakage_mode=$STEALTH_LEAKAGE_MODE" >> $GITHUB_OUTPUT - name: Update pubspec.yaml version shell: bash @@ -421,6 +445,7 @@ jobs: version: ${{ needs.set-metadata.outputs.version }} build_type: ${{ needs.set-metadata.outputs.build_type }} installer_base_name: ${{ needs.set-metadata.outputs.installer_base_name }} + stealth_leakage_mode: ${{ needs.set-metadata.outputs.stealth_leakage_mode }} release-create: needs: set-metadata diff --git a/.gitignore b/.gitignore index 00db28a9cd..437574dfe5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Miscellaneous *.class +*.apk +*.so *.log *.pyc *.swp diff --git a/Makefile b/Makefile index ddfec77cf4..482427f74d 100644 --- a/Makefile +++ b/Makefile @@ -73,6 +73,14 @@ ifeq ($(strip $(APP_VERSION_PUBSPEC)),) $(error APP_VERSION_PUBSPEC is empty; export APP_VERSION (e.g. "9.0.25+459") or ensure pubspec.yaml contains a `version:` line) endif EXTRA_LDFLAGS ?= -X '$(RADIANCE_REPO)/common.Version=$(APP_VERSION_PUBSPEC)' +STEALTH_GO_IMPORT_PATH := github.com/getlantern/lantern/lantern-core +STEALTH_GO_LOG_LEVEL ?= warn +# Stealth can be activated via a stealth BUILD_TYPE or via STEALTH_MODE/STEALTH_PROFILE +# (which leave BUILD_TYPE=production), so gate stealth flags on all three signals. +STEALTH_ACTIVE := $(strip $(filter stealth stealth-%,$(BUILD_TYPE))$(STEALTH_MODE)$(STEALTH_PROFILE)) +STEALTH_GO_LDFLAGS := $(if $(STEALTH_ACTIVE),-X '$(STEALTH_GO_IMPORT_PATH).StealthBuild=true' -X '$(STEALTH_GO_IMPORT_PATH).StealthLogLevel=$(STEALTH_GO_LOG_LEVEL)') +GO_EXTRA_LDFLAGS := $(strip $(EXTRA_LDFLAGS) $(STEALTH_GO_LDFLAGS)) +LANTERND_EXTRA_LDFLAGS := $(EXTRA_LDFLAGS) DARWIN_APP_NAME := $(CAPITALIZED_APP).app DARWIN_LIB := $(LANTERN_LIB_NAME).dylib @@ -105,6 +113,7 @@ LINUX_INSTALLER_RPM := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYP LINUX_INSTALLER_ARCH := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE))$(LINUX_PACKAGE_ARCH_SUFFIX).pkg.tar.zst LANTERND := lanternd LANTERND_SRC := $(RADIANCE_REPO)/cmd/lanternd +LANTERND_SERVICE_LOG_LEVEL ?= $(if $(STEALTH_ACTIVE),warn,trace) LANTERND_LINUX_AMD64 := $(BIN_DIR)/linux-amd64/$(LANTERND) LANTERND_LINUX_ARM64 := $(BIN_DIR)/linux-arm64/$(LANTERND) LINUX_BUNDLE_DIR_X64 := build/linux/x64/release/bundle @@ -118,10 +127,12 @@ ifeq ($(OS),Windows_NT) MKDIR_P = $(PS) "New-Item -ItemType Directory -Force -Path '$(1)' | Out-Null" COPY_FILE = $(PS) "Copy-Item -Force -LiteralPath '$(1)' -Destination '$(2)'" RM_RF = $(PS) "Remove-Item -Recurse -Force -LiteralPath '$(1)'" + WRITE_TEXT_FILE = $(PS) "Set-Content -LiteralPath '$(2)' -Value '$(1)' -Encoding ASCII" else MKDIR_P = mkdir -p -- '$(1)' COPY_FILE = cp -f -- '$(1)' '$(2)' RM_RF = rm -rf -- '$(1)' + WRITE_TEXT_FILE = printf '%s\n' '$(1)' > '$(2)' endif LANTERND_WINDOWS_AMD64 := $(BIN_DIR)/windows-amd64/$(LANTERND).exe @@ -157,8 +168,15 @@ ANDROID_AAB_TARGET_PLATFORMS := android-arm64 ANDROID_TARGET_PLATFORMS := $(ANDROID_AAB_TARGET_PLATFORMS) ANDROID_RELEASE_APK := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE)).apk ANDROID_RELEASE_AAB := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE)).aab +ANDROID_STEALTH_NOVPN_APK := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE))-stealth-novpn.apk +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 +STEALTH_LEAKAGE_MODE ?= stealth +STEALTH_LEAKAGE_CONFIG ?= scripts/stealth/forbidden_tokens.json +STEALTH_LEAKAGE_PATHS ?= $(ANDROID_RELEASE_APK) $(ANDROID_RELEASE_AAB) $(ANDROID_APK_RELEASE_BUILD) $(ANDROID_AAB_RELEASE_BUILD) +STEALTH_LEAKAGE_MISSING_OK ?= 1 ANDROID_NDK_VERSION ?= 28.2.13676358 ANDROID_CMAKE_VERSION ?= 3.31.5 ANDROID_BUILD_TOOLS_VERSION ?= 35.0.0 @@ -169,6 +187,13 @@ ANDROID_DEBUG_FLUTTER_FLAGS ?= --verbose ANDROID_PAGE_SIZE ?= 16384 # Android 15+ Play requirement: arm64 native libs must be linked for 16 KB page-size compatibility. ANDROID_GOMOBILE_LDFLAGS ?= -s -w -checklinkname=0 -extldflags=-Wl,-z,max-page-size=$(ANDROID_PAGE_SIZE),-z,common-page-size=$(ANDROID_PAGE_SIZE) +ANDROID_STEALTH_IDENTITY ?= $(if $(strip $(filter stealth stealth-%,$(BUILD_TYPE))$(STEALTH_MODE)$(STEALTH_PROFILE)),1,0) +ANDROID_GENERATE_IDENTITY_PROFILE ?= $(ANDROID_STEALTH_IDENTITY) +ANDROID_GENERATED_IDENTITY_PROFILE := $(BUILD_DIR)/stealth/android-identity.properties +ANDROID_IDENTITY_PROFILE ?= $(if $(filter 1 true yes,$(ANDROID_GENERATE_IDENTITY_PROFILE)),$(ANDROID_GENERATED_IDENTITY_PROFILE),) +ANDROID_IDENTITY_ENV = $(if $(strip $(ANDROID_IDENTITY_PROFILE)),ANDROID_IDENTITY_PROFILE="$(abspath $(ANDROID_IDENTITY_PROFILE))",) +ANDROID_AUTH_SCHEME = $(strip $(if $(ANDROID_IDENTITY_PROFILE),$(shell sed -n 's/^appAuthScheme=//p' "$(ANDROID_IDENTITY_PROFILE)" 2>/dev/null | tail -n 1),)) +ANDROID_IDENTITY_DART_DEFINES = $(if $(STEALTH_ENABLED),,$(if $(ANDROID_AUTH_SCHEME),--dart-define=APP_AUTH_SCHEME=$(ANDROID_AUTH_SCHEME))) IOS_INSTALLER := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE)).ipa IOS_DIR := ios/ @@ -200,6 +225,30 @@ GOMOBILE_REPOS = \ github.com/sagernet/sing-box/experimental/libbox \ ./lantern-core/mobile \ ./lantern-core/utils +# novpn now ships the FULL app over SOCKS (not the minimal proxy stub), so it +# binds the same packages as the regular/vpn build: libbox (sing-box, required by +# the real MainActivity's Libbox init + the SOCKS data path), mobile, utils. +GOMOBILE_REPOS_STEALTH_NOVPN = \ + github.com/sagernet/sing-box/experimental/libbox \ + ./lantern-core/mobile \ + ./lantern-core/utils + +GARBLE ?= garble +GARBLE_VERSION ?= v0.16.0 +GARBLE_SEED ?= $(STEALTH_GARBLE_SEED) +GARBLE_FLAGS ?= -literals +STEALTH_GOGARBLE_PACKAGES := github.com/getlantern/lantern,github.com/getlantern/radiance,github.com/getlantern/common,github.com/getlantern/lantern-box,github.com/getlantern/kindling,github.com/getlantern/netx,github.com/getlantern/flashlight,github.com/getlantern/golog,github.com/getlantern/sing-box-minimal,github.com/getlantern/amp,github.com/getlantern/broflake,github.com/getlantern/samizdat,github.com/getlantern/domainfront,github.com/getlantern/semconv,github.com/getlantern/publicip,github.com/getlantern/dnstt,github.com/getlantern/keepcurrent,github.com/getlantern/algeneva,github.com/getlantern/lantern-water,github.com/getlantern/water,github.com/getlantern/wazero,github.com/getlantern/wireguard-go,github.com/getlantern/sing,github.com/getlantern/osversion,github.com/getlantern/timezone,github.com/getlantern/pluriconfig,github.com/getlantern/appdir,github.com/getlantern/context,github.com/getlantern/fronted,github.com/getlantern/lantern-server-provisioner,github.com/getlantern/ops +GARBLE_GOGARBLE ?= $(if $(STEALTH_ENABLED),$(STEALTH_GOGARBLE_PACKAGES),github.com/getlantern/lantern) +GARBLE_LDFLAGS ?= -w -s -buildid= + +ifeq ($(OS),Windows_NT) +GARBLE_REAL_GO ?= $(shell powershell -NoProfile -ExecutionPolicy Bypass -Command '(Get-Command go -ErrorAction SilentlyContinue).Source') +GARBLE_ENV = $(if $(GARBLE_GOGARBLE),set GOGARBLE=$(GARBLE_GOGARBLE)&& ,set GOGARBLE=&& ) +else +GARBLE_REAL_GO ?= $(shell command -v go 2>/dev/null) +GARBLE_ENV = $(if $(GARBLE_GOGARBLE),GOGARBLE="$(GARBLE_GOGARBLE)",env -u GOGARBLE) +endif +GARBLE_BUILD = $(GARBLE) $(GARBLE_FLAGS) -seed="$(GARBLE_SEED)" build SIGN_ID="Developer ID Application: Brave New Software Project, Inc (ACZRKC3LQ9)" @@ -211,6 +260,56 @@ get-command = $(shell which="$$(which $(1) 2> /dev/null)" && if [[ ! -z "$$which APPDMG := $(call get-command,appdmg) DART_DEFINES := --dart-define=BUILD_TYPE=$(BUILD_TYPE) $(if $(VERSION),--dart-define=VERSION=$(VERSION),) +STEALTH_NOVPN_BUILD_VARS := BUILD_TYPE=stealth-novpn STEALTH_MODE=stealth-novpn STEALTH_LEAKAGE_MODE=stealth-novpn +STEALTH_VPN_BUILD_VARS := BUILD_TYPE=stealth-vpn STEALTH_MODE=stealth-vpn STEALTH_LEAKAGE_MODE=stealth-vpn +STEALTH_ICON_SEED ?= +STEALTH_ICON_RES_DIR ?= android/app/build/generated/icons/res +export STEALTH_ICON_SEED + +STEALTH_PROFILE_SCRIPT := scripts/stealth/generate_profile.py +STEALTH_PROFILE_TOOL := $(PYTHON) $(STEALTH_PROFILE_SCRIPT) +STEALTH_FLUTTER_BUILD_SCRIPT := scripts/stealth/run_flutter_build.py +STEALTH_ANDROID_ARTIFACT_SANITIZER := scripts/stealth/sanitize_android_artifact.py +STEALTH_ANDROID_ARTIFACT_SIGNING_FLAGS := $(if $(filter 1 true yes,$(STEALTH_ALLOW_DEBUG_KEYSTORE)),--allow-debug-keystore,) +# De-branded copy of the REAL Android Kotlin + res, compiled in place of the +# legacy foundation.bridge stub source set so stealth ships the real native bridge. +STEALTH_DEBRAND_KOTLIN_SCRIPT := scripts/stealth/debrand_kotlin.py +STEALTH_ANDROID_GEN_DIR := $(BUILD_DIR)/stealth/android +STEALTH_ANDROID_KOTLIN_OUT := $(STEALTH_ANDROID_GEN_DIR)/kotlin +STEALTH_ANDROID_RES_OUT := $(STEALTH_ANDROID_GEN_DIR)/res +STEALTH_PROFILE ?= +STEALTH_MODE ?= +STEALTH_PACKAGE_NAME ?= +STEALTH_APP_NAME ?= +STEALTH_SESSION_NAME ?= +STEALTH_OBFUSCATION_SEED ?= +STEALTH_GO_OBFUSCATION_SEED ?= $(STEALTH_OBFUSCATION_SEED) +STEALTH_DENYLIST_VERSION ?= +STEALTH_PROFILE_OUT ?= $(BUILD_DIR)/stealth/profile.json +STEALTH_DART_DEFINES_FILE ?= $(BUILD_DIR)/stealth/dart-defines.json +STEALTH_ARTIFACT_METADATA ?= $(BUILD_DIR)/stealth/artifact-metadata.json +STEALTH_GO_TAGS_FILE ?= $(BUILD_DIR)/stealth/go-tags-suffix.txt +STEALTH_PROFILE_STAMP ?= $(BUILD_DIR)/stealth/profile.stamp +STEALTH_PROFILE_INPUTS_FILE ?= $(BUILD_DIR)/stealth/profile.inputs +STEALTH_ENABLED := $(strip $(STEALTH_MODE)$(STEALTH_PROFILE)) +STEALTH_DART_DEFINES := $(if $(STEALTH_ENABLED),--dart-define-from-file=$(STEALTH_DART_DEFINES_FILE),) +STEALTH_GO_TAGS = $(if $(STEALTH_ENABLED),$(if $(wildcard $(STEALTH_GO_TAGS_FILE)),$(strip $(file <$(STEALTH_GO_TAGS_FILE))),$(error missing $(STEALTH_GO_TAGS_FILE); run make stealth-profile before building)),) +STEALTH_PROFILE_ENV := $(if $(STEALTH_ENABLED),ORG_GRADLE_PROJECT_stealthProfile="$(abspath $(STEALTH_PROFILE_OUT))",) +MAYBE_STEALTH_PROFILE := $(if $(STEALTH_ENABLED),$(STEALTH_PROFILE_STAMP),) +STEALTH_IS_NOVPN = $(if $(findstring novpn,$(STEALTH_GO_TAGS)),1,) +GOMOBILE_REPOS_EFFECTIVE = $(if $(STEALTH_IS_NOVPN),$(GOMOBILE_REPOS_STEALTH_NOVPN),$(GOMOBILE_REPOS)) +GOMOBILE_JAVAPKG = $(if $(STEALTH_ENABLED),foundation.engine,lantern.io) +STEALTH_FLUTTER_PREFIX = $(if $(STEALTH_ENABLED),$(PYTHON) $(STEALTH_FLUTTER_BUILD_SCRIPT) --profile "$(STEALTH_PROFILE_OUT)" --,) +# Stealth builds compile the REAL app entrypoint (lib/main.dart, Flutter's +# default target). De-branding is a pre-compile transform (run_flutter_build.py) +# plus the AppBuildInfo dart-define guards; the legacy lib/main_stealth.dart stub +# is no longer used. +STEALTH_FLUTTER_TARGET = +# Dart symbol obfuscation removes brand *identifiers* (e.g. LanternService) from +# the AOT-compiled libapp.so. Applied to stealth release builds only; requires +# --split-debug-info. The symbol map stays local and is NOT shipped in the APK. +STEALTH_FLUTTER_OBFUSCATE = $(if $(STEALTH_ENABLED),--obfuscate --split-debug-info=$(BUILD_DIR)/stealth/debug-symbols,) +FLUTTER_DART_DEFINES = $(if $(STEALTH_ENABLED),$(if $(VERSION),--dart-define=VERSION=$(VERSION),),$(DART_DEFINES)) INSTALLER_RESOURCES := installer-resources @@ -221,6 +320,109 @@ guard-%: check-gomobile: @command -v gomobile >/dev/null || (echo "gomobile not found. Run 'make install-android-deps'" && exit 1) +.PHONY: stealth-android-sources +stealth-android-sources: + $(PYTHON) $(STEALTH_DEBRAND_KOTLIN_SCRIPT) \ + --output $(STEALTH_ANDROID_KOTLIN_OUT) \ + --res-source android/app/src/main/res \ + --res-overlay android/app/src/stealth/res \ + --res-output $(STEALTH_ANDROID_RES_OUT) + +.PHONY: stealth-profile FORCE +stealth-profile: + $(MAKE) -B "$(STEALTH_PROFILE_STAMP)" + +$(STEALTH_PROFILE_STAMP): FORCE $(STEALTH_PROFILE_SCRIPT) + $(call MKDIR_P,$(dir $(STEALTH_PROFILE_STAMP))) + @set -e; { \ + printf 'STEALTH_PROFILE_SCRIPT_SHA256='; \ + $(PYTHON) -c 'import hashlib, pathlib, sys; print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())' "$(STEALTH_PROFILE_SCRIPT)"; \ + printf 'STEALTH_PROFILE=%s\n' "$(STEALTH_PROFILE)"; \ + printf 'STEALTH_PROFILE_SHA256='; \ + if [ -n "$(STEALTH_PROFILE)" ] && [ -f "$(STEALTH_PROFILE)" ]; then \ + $(PYTHON) -c 'import hashlib, pathlib, sys; print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())' "$(STEALTH_PROFILE)"; \ + else \ + printf '\n'; \ + fi; \ + printf 'STEALTH_MODE=%s\n' "$(STEALTH_MODE)"; \ + printf 'STEALTH_PACKAGE_NAME=%s\n' "$(STEALTH_PACKAGE_NAME)"; \ + printf 'STEALTH_APP_NAME=%s\n' "$(STEALTH_APP_NAME)"; \ + printf 'STEALTH_SESSION_NAME=%s\n' "$(STEALTH_SESSION_NAME)"; \ + printf 'STEALTH_GO_OBFUSCATION_SEED_SHA256='; \ + if [ -n "$(STEALTH_GO_OBFUSCATION_SEED)" ]; then \ + $(PYTHON) -c 'import hashlib, sys; print(hashlib.sha256(sys.argv[1].encode("utf-8")).hexdigest())' "$(STEALTH_GO_OBFUSCATION_SEED)"; \ + else \ + printf '\n'; \ + fi; \ + printf 'STEALTH_DENYLIST_VERSION=%s\n' "$(STEALTH_DENYLIST_VERSION)"; \ + } > "$(STEALTH_PROFILE_INPUTS_FILE).tmp" + @set -e; \ + tmp_profile="$(STEALTH_PROFILE_OUT).tmp"; \ + tmp_dart_defines="$(STEALTH_DART_DEFINES_FILE).tmp"; \ + tmp_artifact_metadata="$(STEALTH_ARTIFACT_METADATA).tmp"; \ + tmp_go_tags="$(STEALTH_GO_TAGS_FILE).tmp"; \ + trap 'rm -f "$(STEALTH_PROFILE_INPUTS_FILE).tmp" "$$tmp_profile" "$$tmp_dart_defines" "$$tmp_artifact_metadata" "$$tmp_go_tags"' EXIT; \ + if test -f "$(STEALTH_PROFILE_INPUTS_FILE)" && \ + cmp -s "$(STEALTH_PROFILE_INPUTS_FILE).tmp" "$(STEALTH_PROFILE_INPUTS_FILE)" && \ + test -s "$(STEALTH_PROFILE_OUT)" && \ + test -s "$(STEALTH_DART_DEFINES_FILE)" && \ + test -s "$(STEALTH_ARTIFACT_METADATA)" && \ + test -s "$(STEALTH_GO_TAGS_FILE)"; then \ + touch "$@"; \ + rm -f "$(STEALTH_PROFILE_INPUTS_FILE).tmp"; \ + else \ + $(STEALTH_PROFILE_TOOL) \ + $(if $(STEALTH_PROFILE),--input "$(STEALTH_PROFILE)",) \ + $(if $(STEALTH_MODE),--mode "$(STEALTH_MODE)",) \ + $(if $(STEALTH_PACKAGE_NAME),--package-name "$(STEALTH_PACKAGE_NAME)",) \ + $(if $(STEALTH_APP_NAME),--app-name "$(STEALTH_APP_NAME)",) \ + $(if $(STEALTH_SESSION_NAME),--session-name "$(STEALTH_SESSION_NAME)",) \ + $(if $(STEALTH_GO_OBFUSCATION_SEED),--go-obfuscation-seed "$(STEALTH_GO_OBFUSCATION_SEED)",) \ + $(if $(STEALTH_DENYLIST_VERSION),--denylist-version "$(STEALTH_DENYLIST_VERSION)",) \ + --output "$$tmp_profile" \ + --dart-defines-output "$$tmp_dart_defines" \ + --artifact-metadata-output "$$tmp_artifact_metadata"; \ + $(STEALTH_PROFILE_TOOL) --input "$$tmp_profile" --go-tags-suffix > "$$tmp_go_tags"; \ + mv "$(STEALTH_PROFILE_INPUTS_FILE).tmp" "$(STEALTH_PROFILE_INPUTS_FILE)"; \ + mv "$$tmp_profile" "$(STEALTH_PROFILE_OUT)"; \ + mv "$$tmp_dart_defines" "$(STEALTH_DART_DEFINES_FILE)"; \ + mv "$$tmp_artifact_metadata" "$(STEALTH_ARTIFACT_METADATA)"; \ + mv "$$tmp_go_tags" "$(STEALTH_GO_TAGS_FILE)"; \ + touch "$@"; \ + fi + +.PHONY: check-garble require-garble-seed check-garble-seed check-garble-go +check-garble: + @command -v $(GARBLE) >/dev/null 2>&1 || \ + { echo "garble not found. Run 'make install-garble' or set GARBLE=/path/to/garble."; exit 1; } + @command -v git >/dev/null 2>&1 || \ + { echo "git not found. garble requires git to patch the Go linker."; exit 1; } + @$(GARBLE) -h >/dev/null 2>&1 || \ + { echo "garble was found but could not run. Check GARBLE=$(GARBLE) and your Go toolchain."; exit 1; } + +require-garble-seed: + @if [ -z "$(GARBLE_SEED)" ]; then \ + echo "GARBLE_SEED is required for obfuscated builds."; \ + echo "Set GARBLE_SEED= or STEALTH_GARBLE_SEED=."; \ + echo "Use GARBLE_SEED=random only for unreproducible local smoke builds."; \ + exit 1; \ + fi + +check-garble-seed: check-garble require-garble-seed + @$(GARBLE) -seed="$(GARBLE_SEED)" version >/dev/null 2>&1 || \ + { echo "GARBLE_SEED must be 'random' or a base64-encoded seed accepted by garble."; exit 1; } + +check-garble-go: + @if [ -z "$(GARBLE_REAL_GO)" ]; then \ + echo "go not found. Install Go or set GARBLE_REAL_GO=/path/to/go for gomobile garble builds."; \ + exit 1; \ + fi + +.PHONY: stealth-android-icons +stealth-android-icons: guard-STEALTH_ICON_SEED + $(PYTHON) scripts/stealth/generate_android_icons.py \ + --output-res-dir "$(STEALTH_ICON_RES_DIR)" + .PHONY: require-appdmg require-appdmg: @@ -240,13 +442,22 @@ else endif .PHONY: desktop-lib -desktop-lib: +desktop-lib: $(MAYBE_STEALTH_PROFILE) $(SETENV) go build -v -trimpath -buildmode=c-shared \ - -tags="$(TAGS)" \ - -ldflags="-w -s $(EXTRA_LDFLAGS)" \ + -tags="$(TAGS)$(STEALTH_GO_TAGS)" \ + -ldflags="-w -s $(GO_EXTRA_LDFLAGS)" \ -o $(LIB_NAME) ./$(FFI_DIR) @echo "Built desktop library: $(LIB_NAME)" +.PHONY: desktop-lib-obfuscated +desktop-lib-obfuscated: check-garble-seed + $(call MKDIR_P,$(dir $(LIB_NAME))) + @$(SETENV) $(GARBLE_ENV) $(GARBLE_BUILD) -v -trimpath -buildmode=c-shared \ + -tags="$(TAGS)" \ + -ldflags="$(GARBLE_LDFLAGS) $(EXTRA_LDFLAGS)" \ + -o $(LIB_NAME) ./$(FFI_DIR) + @echo "Built obfuscated desktop library: $(LIB_NAME)" + # macOS build tools need to be installed when generating release builds, # but are not necessarily required for debug builds .PHONY: install-macos-deps @@ -260,14 +471,14 @@ install-macos-deps: install-gomobile .PHONY: macos macos: $(MACOS_FRAMEWORK_BUILD) -$(MACOS_FRAMEWORK_BUILD): $(GO_SOURCES) +$(MACOS_FRAMEWORK_BUILD): $(GO_SOURCES) $(MAYBE_STEALTH_PROFILE) @echo "Building macOS Framework.." rm -rf $(MACOS_FRAMEWORK_BUILD) && mkdir -p $(MACOS_FRAMEWORK_DIR) GOTOOLCHAIN=$(GO_VERSION) GOOS=darwin gomobile bind -v \ - -tags=$(TAGS),netgo -trimpath \ + -tags=$(TAGS),netgo$(STEALTH_GO_TAGS) -trimpath \ -target=macos \ -o $(MACOS_FRAMEWORK_BUILD) \ - -ldflags="-w -s -checklinkname=0 $(EXTRA_LDFLAGS)" \ + -ldflags="-w -s -checklinkname=0 $(GO_EXTRA_LDFLAGS)" \ $(GOMOBILE_REPOS) @echo "Built macOS Framework: $(MACOS_FRAMEWORK_BUILD)" rm -rf $(MACOS_FRAMEWORK_DIR)/$(MACOS_FRAMEWORK) @@ -280,14 +491,14 @@ macos-framework: $(MACOS_FRAMEWORK_BUILD) .PHONY: macos-debug macos-debug: $(DARWIN_DEBUG_BUILD) -$(DARWIN_DEBUG_BUILD): $(DARWIN_LIB_BUILD) +$(DARWIN_DEBUG_BUILD): $(DARWIN_LIB_BUILD) $(MAYBE_STEALTH_PROFILE) @echo "Building Flutter app (debug) for macOS..." - flutter build macos --debug + flutter build macos --debug $(DART_DEFINES) $(STEALTH_DART_DEFINES) .PHONY: macos-unit-tests -macos-unit-tests: $(MACOS_FRAMEWORK_BUILD) +macos-unit-tests: $(MACOS_FRAMEWORK_BUILD) $(MAYBE_STEALTH_PROFILE) @echo "Preparing macOS test project (building native assets)..." - flutter build macos --debug + flutter build macos --debug $(DART_DEFINES) $(STEALTH_DART_DEFINES) @echo "Running macOS Runner unit tests..." xcodebuild test \ -workspace macos/Runner.xcworkspace \ @@ -299,10 +510,10 @@ macos-unit-tests: $(MACOS_FRAMEWORK_BUILD) CODE_SIGNING_REQUIRED=NO \ CODE_SIGN_IDENTITY="" -$(DARWIN_RELEASE_BUILD): +$(DARWIN_RELEASE_BUILD): $(MAYBE_STEALTH_PROFILE) @echo "Building Flutter app (release) for macOS..." rm -vf $(MACOS_INSTALLER) - flutter build macos --release $(DART_DEFINES) + flutter build macos --release $(DART_DEFINES) $(STEALTH_DART_DEFINES) build-macos-release: $(DARWIN_RELEASE_BUILD) @@ -349,49 +560,79 @@ install-linux-deps: .PHONY: linux-arm64 linux-arm64: $(LINUX_LIB_ARM64) -$(LINUX_LIB_ARM64): $(GO_SOURCES) +$(LINUX_LIB_ARM64): $(GO_SOURCES) $(MAYBE_STEALTH_PROFILE) CC=$(LINUX_CC_ARM64) GOOS=linux GOARCH=arm64 LIB_NAME=$@ $(MAKE) desktop-lib +.PHONY: linux-arm64-obfuscated +linux-arm64-obfuscated: $(GO_SOURCES) + CC=$(LINUX_CC_ARM64) GOOS=linux GOARCH=arm64 LIB_NAME=$(LINUX_LIB_ARM64) $(MAKE) desktop-lib-obfuscated + .PHONY: linux-amd64 linux-amd64: $(LINUX_LIB_AMD64) -$(LINUX_LIB_AMD64): $(GO_SOURCES) +$(LINUX_LIB_AMD64): $(GO_SOURCES) $(MAYBE_STEALTH_PROFILE) CC=$(LINUX_CC_AMD64) GOOS=linux GOARCH=amd64 LIB_NAME=$@ $(MAKE) desktop-lib +.PHONY: linux-amd64-obfuscated +linux-amd64-obfuscated: $(GO_SOURCES) + CC=$(LINUX_CC_AMD64) GOOS=linux GOARCH=amd64 LIB_NAME=$(LINUX_LIB_AMD64) $(MAKE) desktop-lib-obfuscated + .PHONY: linux linux: linux-$(LINUX_TARGET_ARCH) mkdir -p $(BIN_DIR)/linux cp $(BIN_DIR)/linux-$(LINUX_TARGET_ARCH)/$(LINUX_LIB) $(LINUX_LIB_BUILD) -.PHONY: lanternd-linux-amd64 lanternd-linux-arm64 +.PHONY: linux-obfuscated +linux-obfuscated: linux-$(LINUX_TARGET_ARCH)-obfuscated + mkdir -p $(BIN_DIR)/linux + cp $(BIN_DIR)/linux-$(LINUX_TARGET_ARCH)/$(LINUX_LIB) $(LINUX_LIB_BUILD) + +.PHONY: lanternd-linux-amd64 lanternd-linux-arm64 \ + lanternd-linux-amd64-obfuscated lanternd-linux-arm64-obfuscated -lanternd-linux-amd64: $(GO_SOURCES) +lanternd-linux-amd64: $(GO_SOURCES) $(MAYBE_STEALTH_PROFILE) $(call MKDIR_P,$(dir $(LANTERND_LINUX_AMD64))) GOOS=linux GOARCH=amd64 CGO_ENABLED=1 \ - go build -mod=mod -v -trimpath -tags "$(TAGS)" \ - -ldflags "-w -s $(EXTRA_LDFLAGS)" \ + go build -mod=mod -v -trimpath -tags "$(TAGS)$(STEALTH_GO_TAGS)" \ + -ldflags "-w -s $(LANTERND_EXTRA_LDFLAGS)" \ -o $(LANTERND_LINUX_AMD64) $(LANTERND_SRC) @echo "Built lanternd (linux-amd64): $(LANTERND_LINUX_AMD64)" -lanternd-linux-arm64: $(GO_SOURCES) +lanternd-linux-arm64: $(GO_SOURCES) $(MAYBE_STEALTH_PROFILE) $(call MKDIR_P,$(dir $(LANTERND_LINUX_ARM64))) GOOS=linux GOARCH=arm64 CGO_ENABLED=1 \ - go build -mod=mod -v -trimpath -tags "$(TAGS)" \ - -ldflags "-w -s $(EXTRA_LDFLAGS)" \ + go build -mod=mod -v -trimpath -tags "$(TAGS)$(STEALTH_GO_TAGS)" \ + -ldflags "-w -s $(LANTERND_EXTRA_LDFLAGS)" \ -o $(LANTERND_LINUX_ARM64) $(LANTERND_SRC) @echo "Built lanternd (linux-arm64): $(LANTERND_LINUX_ARM64)" +lanternd-linux-amd64-obfuscated: check-garble-seed $(GO_SOURCES) + $(call MKDIR_P,$(dir $(LANTERND_LINUX_AMD64))) + @$(GARBLE_ENV) GOOS=linux GOARCH=amd64 CGO_ENABLED=1 \ + $(GARBLE_BUILD) -mod=mod -v -trimpath -tags "$(TAGS)" \ + -ldflags "$(GARBLE_LDFLAGS) $(EXTRA_LDFLAGS)" \ + -o $(LANTERND_LINUX_AMD64) $(LANTERND_SRC) + @echo "Built obfuscated lanternd (linux-amd64): $(LANTERND_LINUX_AMD64)" + +lanternd-linux-arm64-obfuscated: check-garble-seed $(GO_SOURCES) + $(call MKDIR_P,$(dir $(LANTERND_LINUX_ARM64))) + @$(GARBLE_ENV) GOOS=linux GOARCH=arm64 CGO_ENABLED=1 \ + $(GARBLE_BUILD) -mod=mod -v -trimpath -tags "$(TAGS)" \ + -ldflags "$(GARBLE_LDFLAGS) $(EXTRA_LDFLAGS)" \ + -o $(LANTERND_LINUX_ARM64) $(LANTERND_SRC) + @echo "Built obfuscated lanternd (linux-arm64): $(LANTERND_LINUX_ARM64)" + .PHONY: linux-debug -linux-debug: +linux-debug: $(MAYBE_STEALTH_PROFILE) @echo "Building Flutter app (debug) for Linux..." - flutter build linux --debug + flutter build linux --debug $(DART_DEFINES) $(STEALTH_DART_DEFINES) .PHONY: linux-release linux-release-ci linux-release: clean linux-release-ci -linux-release-ci: linux pubget gen +linux-release-ci: linux pubget gen $(MAYBE_STEALTH_PROFILE) @echo "Building Flutter app (release) for Linux..." - flutter build linux --release $(DART_DEFINES) + flutter build linux --release $(DART_DEFINES) $(STEALTH_DART_DEFINES) $(MAKE) lanternd-linux-$(LINUX_TARGET_ARCH) @if [ "$(LINUX_TARGET_ARCH)" = "arm64" ]; then \ @@ -406,6 +647,7 @@ linux-release-ci: linux pubget gen echo "Using Linux bundle dir: $$BUNDLE_DIR"; \ cp "$(LINUX_LIB_BUILD)" "$$BUNDLE_DIR"; \ cp "$(BIN_DIR)/linux-$(LINUX_TARGET_ARCH)/$(LANTERND)" "$$BUNDLE_DIR"; \ + printf '%s\n' "$(LANTERND_SERVICE_LOG_LEVEL)" > "$$BUNDLE_DIR/lanternd-log-level"; \ patchelf --set-rpath '$$ORIGIN/lib' "$$BUNDLE_DIR/lantern" || true; \ VERSION=$(APP_VERSION) GOARCH=$(LINUX_TARGET_ARCH) LINUX_BUNDLE_SRC="$$BUNDLE_DIR/" \ nfpm package -f $(LINUX_PKG_ROOT)/nfpm.yaml -p deb -t $(LINUX_INSTALLER_DEB); \ @@ -432,12 +674,12 @@ windows: windows-amd64 windows-amd64: WINDOWS_GOOS := windows windows-amd64: WINDOWS_GOARCH := amd64 -windows-amd64: +windows-amd64: $(MAYBE_STEALTH_PROFILE) $(call MKDIR_P,$(dir $(WINDOWS_LIB_AMD64))) $(MAKE) desktop-lib GOOS=$(WINDOWS_GOOS) GOARCH=$(WINDOWS_GOARCH) LIB_NAME=$(WINDOWS_LIB_AMD64) CGO_LDFLAGS="$(WINDOWS_CGO_LDFLAGS)" windows-arm64: WINDOWS_GOOS := windows windows-arm64: WINDOWS_GOARCH := arm64 -windows-arm64: +windows-arm64: $(MAYBE_STEALTH_PROFILE) $(call MKDIR_P,$(dir $(WINDOWS_LIB_ARM64))) $(MAKE) desktop-lib GOOS=$(WINDOWS_GOOS) GOARCH=$(WINDOWS_GOARCH) LIB_NAME=$(WINDOWS_LIB_ARM64) CGO_LDFLAGS="$(WINDOWS_CGO_LDFLAGS)" @@ -445,19 +687,19 @@ lanternd-windows-amd64: $(LANTERND_WINDOWS_AMD64) lanternd-windows-arm64: $(LANTERND_WINDOWS_ARM64) -$(LANTERND_WINDOWS_AMD64): +$(LANTERND_WINDOWS_AMD64): $(MAYBE_STEALTH_PROFILE) $(call MKDIR_P,$(dir $(LANTERND_WINDOWS_AMD64))) GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \ - go build -mod=mod -v -trimpath -tags "$(TAGS)" \ - -ldflags "$(EXTRA_LDFLAGS)" \ + go build -mod=mod -v -trimpath -tags "$(TAGS)$(STEALTH_GO_TAGS)" \ + -ldflags "$(LANTERND_EXTRA_LDFLAGS)" \ -o $(LANTERND_WINDOWS_AMD64) $(LANTERND_SRC) @echo "Built lanternd (windows-amd64): $(LANTERND_WINDOWS_AMD64)" -$(LANTERND_WINDOWS_ARM64): +$(LANTERND_WINDOWS_ARM64): $(MAYBE_STEALTH_PROFILE) $(call MKDIR_P,$(dir $(LANTERND_WINDOWS_ARM64))) GOOS=windows GOARCH=arm64 CGO_ENABLED=0 \ - go build -mod=mod -v -trimpath -tags "$(TAGS)" \ - -ldflags "$(EXTRA_LDFLAGS)" \ + go build -mod=mod -v -trimpath -tags "$(TAGS)$(STEALTH_GO_TAGS)" \ + -ldflags "$(LANTERND_EXTRA_LDFLAGS)" \ -o $(LANTERND_WINDOWS_ARM64) $(LANTERND_SRC) @echo "Built lanternd (windows-arm64): $(LANTERND_WINDOWS_ARM64)" @@ -477,16 +719,17 @@ copy-lanternd-debug: $(LANTERND_WINDOWS_AMD64) prepare-windows-release: lanternd-windows-amd64 lanternd-windows-arm64 $(MAKE) copy-lanternd-release $(MAKE) copy-lanternd-release-arm64 + $(call WRITE_TEXT_FILE,$(LANTERND_SERVICE_LOG_LEVEL),$(WINDOWS_RELEASE_DIR)/lanternd-log-level) .PHONY: windows-debug -windows-debug: windows +windows-debug: windows $(MAYBE_STEALTH_PROFILE) @echo "Building Flutter app (debug) for Windows..." - flutter build windows --debug + flutter build windows --debug $(DART_DEFINES) $(STEALTH_DART_DEFINES) .PHONY: build-windows-release -build-windows-release: +build-windows-release: $(MAYBE_STEALTH_PROFILE) @echo "Building Flutter app (release) for Windows..." - flutter build windows --release --verbose + flutter build windows --release --verbose $(DART_DEFINES) $(STEALTH_DART_DEFINES) .PHONY: windows-release windows-release: clean windows pubget gen build-windows-release prepare-windows-release @@ -504,6 +747,10 @@ install-gomobile: echo "Skipping gomobile init (cached for $(GO_VERSION))"; \ fi +.PHONY: install-garble +install-garble: + GOTOOLCHAIN=$(GO_VERSION) go install -v mvdan.cc/garble@$(GARBLE_VERSION) + # Android Build .PHONY: check-android-sdk check-android-sdk: @@ -531,13 +778,35 @@ android-env: check-android-sdk .PHONY: install-android-deps install-android-deps: install-gomobile +.PHONY: android-identity-profile +android-identity-profile: $(MAYBE_STEALTH_PROFILE) + @if [ "$(ANDROID_GENERATE_IDENTITY_PROFILE)" = "1" ] || [ "$(ANDROID_GENERATE_IDENTITY_PROFILE)" = "true" ] || [ "$(ANDROID_GENERATE_IDENTITY_PROFILE)" = "yes" ]; then \ + if [ -z "$(ANDROID_IDENTITY_PROFILE)" ]; then \ + echo "ANDROID_IDENTITY_PROFILE is empty"; \ + exit 1; \ + fi; \ + if [ ! -f "$(ANDROID_IDENTITY_PROFILE)" ] || [ -n "$(ANDROID_IDENTITY_SEED)" ] || [ "$(ANDROID_FORCE_IDENTITY_PROFILE)" = "1" ] || [ "$(ANDROID_FORCE_IDENTITY_PROFILE)" = "true" ] || [ "$(ANDROID_FORCE_IDENTITY_PROFILE)" = "yes" ]; then \ + mkdir -p "$$(dirname "$(ANDROID_IDENTITY_PROFILE)")"; \ + $(PYTHON) scripts/stealth/generate_android_identity.py \ + --output "$(ANDROID_IDENTITY_PROFILE)" \ + $(if $(STEALTH_ENABLED),--profile "$(STEALTH_PROFILE_OUT)",) \ + $(if $(ANDROID_IDENTITY_SEED),--seed "$(ANDROID_IDENTITY_SEED)",); \ + elif [ -n "$(STEALTH_ENABLED)" ]; then \ + $(PYTHON) scripts/stealth/generate_android_identity.py \ + --output "$(ANDROID_IDENTITY_PROFILE)" \ + --profile "$(STEALTH_PROFILE_OUT)"; \ + else \ + echo "Using Android identity profile: $(ANDROID_IDENTITY_PROFILE)"; \ + fi; \ + fi + .PHONY: android android: check-android-sdk check-gomobile $(ANDROID_LIB_BUILD) -$(ANDROID_LIB_BUILD): $(GO_SOURCES) +$(ANDROID_LIB_BUILD): $(GO_SOURCES) $(MAYBE_STEALTH_PROFILE) $(MAKE) build-android -build-android: check-android-sdk check-gomobile +build-android: check-android-sdk check-gomobile $(MAYBE_STEALTH_PROFILE) @echo "Building Android libraries..." rm -rf $(ANDROID_LIB_BUILD) $(ANDROID_LIBS_DIR)/$(ANDROID_LIB) mkdir -p $(dir $(ANDROID_LIB_BUILD)) $(ANDROID_LIBS_DIR) @@ -546,40 +815,111 @@ build-android: check-android-sdk check-gomobile GOTOOLCHAIN=$(GO_VERSION) GOOS=android gomobile bind -v \ -androidapi=23 \ -target="$(GOMOBILE_ANDROID_TARGET)" \ - -javapkg=lantern.io \ - -tags=$(TAGS) -trimpath \ + -javapkg=$(GOMOBILE_JAVAPKG) \ + -tags=$(TAGS)$(STEALTH_GO_TAGS) -trimpath \ -o=$(ANDROID_LIB_BUILD) \ - -ldflags="$(ANDROID_GOMOBILE_LDFLAGS) $(EXTRA_LDFLAGS)" \ - $(GOMOBILE_REPOS) + -ldflags="$(ANDROID_GOMOBILE_LDFLAGS) $(GO_EXTRA_LDFLAGS)" \ + $(GOMOBILE_REPOS_EFFECTIVE) cp $(ANDROID_LIB_BUILD) $(ANDROID_LIBS_DIR) @echo "Built Android library: $(ANDROID_LIBS_DIR)/$(ANDROID_LIB)" +.PHONY: android-obfuscated +android-obfuscated: build-android-obfuscated + +.PHONY: build-android-obfuscated +build-android-obfuscated: check-android-sdk check-gomobile check-garble-seed check-garble-go $(MAYBE_STEALTH_PROFILE) + @echo "Building obfuscated Android libraries..." + rm -rf $(ANDROID_LIB_BUILD) $(ANDROID_LIBS_DIR)/$(ANDROID_LIB) + mkdir -p $(dir $(ANDROID_LIB_BUILD)) $(ANDROID_LIBS_DIR) + + @PATH="$(CURDIR)/scripts/garble-go:$$PATH" \ + GARBLE_REAL_GO="$(GARBLE_REAL_GO)" \ + GARBLE_BIN="$(GARBLE)" \ + GARBLE_SEED="$(GARBLE_SEED)" \ + GARBLE_FLAGS="$(GARBLE_FLAGS)" \ + GARBLE_GOGARBLE="$(GARBLE_GOGARBLE)" \ + GOMOBILECACHE="$(GOMOBILECACHE)" \ + GOTOOLCHAIN=$(GO_VERSION) GOOS=android gomobile bind -v \ + -androidapi=23 \ + -target="$(GOMOBILE_ANDROID_TARGET)" \ + -javapkg=$(GOMOBILE_JAVAPKG) \ + -tags=$(TAGS)$(STEALTH_GO_TAGS) -trimpath \ + -o=$(ANDROID_LIB_BUILD) \ + -ldflags="$(ANDROID_GOMOBILE_LDFLAGS) $(GARBLE_LDFLAGS) $(EXTRA_LDFLAGS)" \ + $(GOMOBILE_REPOS_EFFECTIVE) + + cp $(ANDROID_LIB_BUILD) $(ANDROID_LIBS_DIR) + @echo "Built obfuscated Android library: $(ANDROID_LIBS_DIR)/$(ANDROID_LIB)" + $(if $(STEALTH_ENABLED),$(MAKE) ANDROID_LIBS_DIR="$(ANDROID_LIBS_DIR)" ANDROID_LIB="$(ANDROID_LIB)" verify-stealth-jni,) + +# verify-stealth-jni: hard gate on JNI symbol namespace in the stealth AAR. +# Extracts arm64-v8a/libgojni.so and asserts: +# - Java_lantern_io_* is ABSENT (GOMOBILE_JAVAPKG=foundation.engine applied) +# - Java_foundation_engine_* is PRESENT (gomobile binding succeeded) +# Called automatically by build-android-obfuscated when STEALTH_ENABLED is set. +# Can also be called standalone: make verify-stealth-jni +.PHONY: verify-stealth-jni +verify-stealth-jni: + @echo "=== verify-stealth-jni: checking JNI namespace in $(ANDROID_LIBS_DIR)/$(ANDROID_LIB) ===" + @tmpdir=$$(mktemp -d); \ + trap 'rm -rf "$$tmpdir"' EXIT; \ + aar="$(ANDROID_LIBS_DIR)/$(ANDROID_LIB)"; \ + if [ ! -f "$$aar" ]; then \ + echo "FAIL: AAR not found at $$aar — build stealth AAR first"; \ + exit 1; \ + fi; \ + unzip -q "$$aar" "jni/arm64-v8a/libgojni.so" -d "$$tmpdir" 2>/dev/null || \ + { echo "FAIL: libgojni.so not found in $$aar (expected jni/arm64-v8a/libgojni.so)"; exit 1; }; \ + so="$$tmpdir/jni/arm64-v8a/libgojni.so"; \ + bad=$$(nm -D "$$so" 2>/dev/null | grep -c "Java_lantern_io" || true); \ + if [ "$$bad" -gt 0 ]; then \ + echo "FAIL: $$bad Java_lantern_io_* symbol(s) found — GOMOBILE_JAVAPKG=foundation.engine not applied:"; \ + nm -D "$$so" | grep "Java_lantern_io"; \ + exit 1; \ + fi; \ + good=$$(nm -D "$$so" 2>/dev/null | grep -c "Java_foundation_engine" || true); \ + if [ "$$good" -eq 0 ]; then \ + echo "FAIL: no Java_foundation_engine_* symbols found — gomobile javapkg binding is missing"; \ + exit 1; \ + fi; \ + echo "PASS: JNI namespace gate — Java_lantern_io=0, Java_foundation_engine=$$good" + .PHONY: android-debug android-debug-ci android-debug: $(ANDROID_DEBUG_BUILD) android-debug-ci: ANDROID_DEBUG_FLUTTER_FLAGS := android-debug-ci: $(ANDROID_DEBUG_BUILD) -$(ANDROID_DEBUG_BUILD): $(ANDROID_LIB_BUILD) - flutter build apk --target-platform $(ANDROID_APK_TARGET_PLATFORMS) $(ANDROID_DEBUG_FLUTTER_FLAGS) --build-name=$(APP_VERSION_PUBSPEC) --debug -Plantern.sideloadUpdates=true +$(ANDROID_DEBUG_BUILD): $(ANDROID_LIB_BUILD) $(MAYBE_STEALTH_PROFILE) + $(MAKE) android-identity-profile + $(STEALTH_PROFILE_ENV) $(ANDROID_IDENTITY_ENV) $(STEALTH_FLUTTER_PREFIX) flutter build apk --target-platform $(ANDROID_APK_TARGET_PLATFORMS) $(ANDROID_DEBUG_FLUTTER_FLAGS) --build-name=$(APP_VERSION_PUBSPEC) --debug $(STEALTH_FLUTTER_TARGET) $(FLUTTER_DART_DEFINES) $(STEALTH_DART_DEFINES) $(ANDROID_IDENTITY_DART_DEFINES) -Plantern.sideloadUpdates=true # --target-platform restricts Flutter's libapp.so / libflutter.so to arm64. # abiFilters is arm64-only for all artifacts now (no thinAbi flag needed). # -Plantern.sideloadUpdates=true adds REQUEST_INSTALL_PACKAGES only to the # direct-download APK artifact; Play AAB builds intentionally omit it. .PHONY: android-apk-release -android-apk-release: - flutter build apk --target-platform $(ANDROID_APK_TARGET_PLATFORMS) --verbose --build-name=$(APP_VERSION_PUBSPEC) --release $(DART_DEFINES) -Plantern.sideloadUpdates=true +android-apk-release: android-identity-profile $(MAYBE_STEALTH_PROFILE) + $(STEALTH_PROFILE_ENV) $(ANDROID_IDENTITY_ENV) $(STEALTH_FLUTTER_PREFIX) flutter build apk --target-platform $(ANDROID_APK_TARGET_PLATFORMS) --verbose --build-name=$(APP_VERSION_PUBSPEC) --release $(STEALTH_FLUTTER_TARGET) $(STEALTH_FLUTTER_OBFUSCATE) $(FLUTTER_DART_DEFINES) $(STEALTH_DART_DEFINES) $(ANDROID_IDENTITY_DART_DEFINES) -Plantern.sideloadUpdates=true + $(if $(STEALTH_ENABLED),$(PYTHON) $(STEALTH_ANDROID_ARTIFACT_SANITIZER) --resign $(STEALTH_ANDROID_ARTIFACT_SIGNING_FLAGS) $(ANDROID_APK_RELEASE_BUILD),true) cp $(ANDROID_APK_RELEASE_BUILD) $(ANDROID_RELEASE_APK) # AAB is arm64-only too (armeabi-v7a dropped — golang/go#70495 SIGSYS on # 32-bit). 32-bit-only devices no longer get Play updates; they were # crash-looping on Android 8-10 regardless. .PHONY: android-aab-release -android-aab-release: - flutter build appbundle --target-platform $(ANDROID_AAB_TARGET_PLATFORMS) --verbose --build-name=$(APP_VERSION_PUBSPEC) --release $(DART_DEFINES) +android-aab-release: android-identity-profile $(MAYBE_STEALTH_PROFILE) + $(STEALTH_PROFILE_ENV) $(ANDROID_IDENTITY_ENV) $(STEALTH_FLUTTER_PREFIX) flutter build appbundle --target-platform $(ANDROID_AAB_TARGET_PLATFORMS) --verbose --build-name=$(APP_VERSION_PUBSPEC) --release $(STEALTH_FLUTTER_TARGET) $(STEALTH_FLUTTER_OBFUSCATE) $(FLUTTER_DART_DEFINES) $(STEALTH_DART_DEFINES) $(ANDROID_IDENTITY_DART_DEFINES) + $(if $(STEALTH_ENABLED),$(PYTHON) $(STEALTH_ANDROID_ARTIFACT_SANITIZER) --resign $(STEALTH_ANDROID_ARTIFACT_SIGNING_FLAGS) $(ANDROID_AAB_RELEASE_BUILD),true) cp $(ANDROID_AAB_RELEASE_BUILD) $(ANDROID_RELEASE_AAB) + $(MAKE) android-copy-play-artifacts + +.PHONY: android-copy-play-artifacts +android-copy-play-artifacts: +ifneq ($(strip $(STEALTH_ENABLED)),) + @echo "Skipping Play mapping and native symbols copy for stealth build" +else # Copy Play console artifacts @if [ -f "$(ANDROID_MAPPING_SRC)" ]; then \ cp "$(ANDROID_MAPPING_SRC)" mapping.txt; \ @@ -590,13 +930,59 @@ android-aab-release: elif [ -d "build/app/intermediates/merged_native_libs/release/out/lib" ]; then \ (cd build/app/intermediates/merged_native_libs/release/out && zip -r ../../../../../../debug-symbols.zip lib >/dev/null); \ fi +endif + +.PHONY: android-stealth-novpn-apk-release +android-stealth-novpn-apk-release: + $(MAKE) $(STEALTH_NOVPN_BUILD_VARS) stealth-android-sources android pubget gen android-apk-release + +.PHONY: android-stealth-novpn-aab-release +android-stealth-novpn-aab-release: + $(MAKE) $(STEALTH_NOVPN_BUILD_VARS) stealth-android-sources android pubget gen android-aab-release + +.PHONY: android-stealth-novpn-release +android-stealth-novpn-release: + $(MAKE) $(STEALTH_NOVPN_BUILD_VARS) stealth-android-sources android-release-ci + +.PHONY: android-stealth-vpn-apk-release +android-stealth-vpn-apk-release: + $(MAKE) $(STEALTH_VPN_BUILD_VARS) stealth-android-sources android pubget gen android-apk-release + +.PHONY: android-stealth-vpn-aab-release +android-stealth-vpn-aab-release: + $(MAKE) $(STEALTH_VPN_BUILD_VARS) stealth-android-sources android pubget gen android-aab-release + +.PHONY: android-stealth-vpn-release +android-stealth-vpn-release: + $(MAKE) $(STEALTH_VPN_BUILD_VARS) stealth-android-sources android-release-ci .PHONY: android-release -android-release: clean android pubget gen android-apk-release +android-release: clean android pubget gen android-identity-profile android-apk-release .PHONY: android-release-ci -android-release-ci: android pubget gen android-apk-release android-aab-release +android-release-ci: android pubget gen android-identity-profile android-apk-release android-aab-release + +.PHONY: stealth-leakage-check stealth-novpn-leakage-check +stealth-leakage-check: + $(PYTHON) scripts/stealth/check_leakage.py \ + --config "$(STEALTH_LEAKAGE_CONFIG)" \ + --mode "$(STEALTH_LEAKAGE_MODE)" \ + $(if $(filter 1 true yes,$(STEALTH_LEAKAGE_MISSING_OK)),--missing-ok,) \ + $(STEALTH_LEAKAGE_PATHS) + +stealth-novpn-leakage-check: + $(MAKE) stealth-leakage-check STEALTH_LEAKAGE_MODE=stealth-novpn + +.PHONY: android-release-obfuscated +android-release-obfuscated: clean android-obfuscated pubget gen android-identity-profile android-apk-release + +.PHONY: android-release-ci-obfuscated +android-release-ci-obfuscated: android-obfuscated pubget gen android-identity-profile android-apk-release android-aab-release + +.PHONY: stealth-manifest-filter-test +stealth-manifest-filter-test: + $(PYTHON) -m unittest discover -s scripts/stealth -p '*_test.py' # iOS Build .PHONY: install-ios-deps @@ -610,18 +996,18 @@ ios: $(IOS_FRAMEWORK_BUILD) .PHONY: ios ios: check-gomobile $(IOS_FRAMEWORK_BUILD) -$(IOS_FRAMEWORK_BUILD): $(GO_SOURCES) +$(IOS_FRAMEWORK_BUILD): $(GO_SOURCES) $(MAYBE_STEALTH_PROFILE) $(MAKE) build-ios -build-ios: +build-ios: $(MAYBE_STEALTH_PROFILE) @echo "Building iOS Framework.." rm -rf $(IOS_FRAMEWORK_BUILD) rm -rf $(IOS_FRAMEWORK_DIR) && mkdir -p $(IOS_FRAMEWORK_DIR) GOOS=ios gomobile bind -v \ - -tags=$(TAGS),with_low_memory, -trimpath \ + -tags=$(TAGS),with_low_memory$(STEALTH_GO_TAGS) -trimpath \ -target=ios \ -o $(IOS_FRAMEWORK_BUILD) \ - -ldflags="-w -s -checklinkname=0 $(EXTRA_LDFLAGS)" \ + -ldflags="-w -s -checklinkname=0 $(GO_EXTRA_LDFLAGS)" \ $(GOMOBILE_REPOS) @echo "Built iOS Framework: $(IOS_FRAMEWORK_BUILD)" mv $(IOS_FRAMEWORK_BUILD) $(IOS_FRAMEWORK_DIR) @@ -638,8 +1024,8 @@ format: @echo "Formatting Swift code..." $(MAKE) swift-format -ios-release: clean pubget ios - flutter build ipa --release --export-options-plist ./ExportOptions.plist $(DART_DEFINES) +ios-release: clean pubget ios $(MAYBE_STEALTH_PROFILE) + flutter build ipa --release --export-options-plist ./ExportOptions.plist $(DART_DEFINES) $(STEALTH_DART_DEFINES) @set -e; \ IPA_DIR="$(CURDIR)/build/ios/ipa"; \ IPA_SRC=$$(ls -t "$$IPA_DIR"/*.ipa 2>/dev/null | head -n 1); \ diff --git a/android/app/build.gradle b/android/app/build.gradle index df12038184..73047c27ab 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -53,9 +53,241 @@ def generateSideloadManifest = tasks.register("generateSideloadManifest") { // would break upgrades. def EPOCH_2009 = 1230768000L // 2009-01-01T00:00:00Z, in seconds def code = (int)((System.currentTimeMillis() / 1000L) - EPOCH_2009) +def stealthIconSeed = (findProperty("stealthIconSeed") ?: System.getenv("STEALTH_ICON_SEED"))?.toString()?.trim() +def sha256Hex = { value -> + java.security.MessageDigest.getInstance("SHA-256") + .digest(value.getBytes("UTF-8")) + .collect { String.format("%02x", it & 0xff) } + .join() +} +def stealthIconSeedSha256 = stealthIconSeed ? sha256Hex(stealthIconSeed) : "" +def generatedStealthIconResDir = layout.buildDirectory.dir("generated/icons/res") +def generatedStealthIconMetadata = layout.buildDirectory.file("generated/icons/stealth-icon-metadata.json") +def generatedStealthIconScript = file("${rootProject.projectDir.parentFile}/scripts/stealth/generate_android_icons.py") +def launcherIconResource = stealthIconSeed ? "@mipmap/ic_launcher_alt" : "@mipmap/ic_launcher" +def roundLauncherIconResource = stealthIconSeed ? "@mipmap/ic_launcher_alt_round" : "@mipmap/ic_launcher_round" + +def defaultStealthProfile = [ + mode : "normal", + packageName : "org.getlantern.lantern", + appName : "Lantern", + sessionName : "LanternVpn", + denylistVersion : 0, + directConnectionAppsEnabled : false, +] + +def stealthModeAliases = [ + vpn : "stealth-vpn", + novpn: "stealth-novpn", +] + +def normalizeStealthMode = { value -> + def mode = value.toString().trim() + return stealthModeAliases.get(mode, mode) +} + +def buildConfigString = { value -> + def escaped = value.toString() + .replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + .replace("\b", "\\b") + .replace("\f", "\\f") + .replace("\"", "\\\"") + return "\"${escaped}\"" +} + +def manifestPlaceholderString = { fieldName, value -> + def text = value.toString() + if (!text.trim()) { + throw new GradleException("Stealth profile ${fieldName} must not be empty") + } + for (int i = 0; i < text.length(); i++) { + def ch = text.charAt(i) + if (Character.isISOControl(ch) || ['"', "'", '&', '<', '>'].contains(String.valueOf(ch))) { + throw new GradleException( + "Stealth profile ${fieldName} contains XML-reserved or control characters" + ) + } + } + return text +} + +def nonNegativeInteger = { fieldName, value -> + try { + def parsed = Integer.parseInt(value.toString()) + if (parsed < 0) { + throw new NumberFormatException("negative") + } + return parsed + } catch (NumberFormatException ignored) { + throw new GradleException("Stealth profile ${fieldName} must be a non-negative integer") + } +} + +def androidApplicationId = { fieldName, value -> + if (!(value instanceof CharSequence)) { + throw new GradleException("Stealth profile ${fieldName} must be a string") + } + def text = value.toString().trim() + if (!(text ==~ /[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+/)) { + throw new GradleException( + "Stealth profile ${fieldName} must be a valid Android applicationId" + ) + } + return text +} + +def resolveRepoRelativeFile = { path -> + def candidate = new File(path.toString()) + if (candidate.isAbsolute()) { + return candidate + } + def fromRepoRoot = new File(rootProject.projectDir.parentFile, path.toString()) + if (fromRepoRoot.exists()) { + return fromRepoRoot + } + return file(path.toString()) +} + +def loadStealthProfile = { + def profilePath = (findProperty("stealthProfile") ?: System.getenv("STEALTH_PROFILE"))?.toString()?.trim() + if (!profilePath) { + return defaultStealthProfile + } + + def profileFile = resolveRepoRelativeFile(profilePath) + if (!profileFile.exists()) { + throw new GradleException("Stealth profile not found: ${profilePath}") + } + + def parsed = new groovy.json.JsonSlurper().parse(profileFile) + if (!(parsed instanceof Map)) { + throw new GradleException("Stealth profile must be a JSON object: ${profileFile}") + } + + def profile = defaultStealthProfile + parsed.findAll { it.value != null } + profile.mode = normalizeStealthMode(profile.mode) + if (!["stealth-vpn", "stealth-novpn"].contains(profile.mode)) { + throw new GradleException( + "Unsupported Stealth mode '${profile.mode}' in ${profileFile}; expected stealth-vpn, stealth-novpn, vpn, or novpn" + ) + } + profile.appName = manifestPlaceholderString("appName", profile.appName) + profile.sessionName = manifestPlaceholderString("sessionName", profile.sessionName) + profile.denylistVersion = nonNegativeInteger("denylistVersion", profile.denylistVersion) + profile.packageName = androidApplicationId("packageName", profile.packageName) + // directConnectionAppsEnabled: profile JSON stores a boolean; default true for stealth modes. + def rawDca = profile.get("directConnectionAppsEnabled") + if (rawDca == null) { + profile.directConnectionAppsEnabled = ["stealth-vpn", "stealth-novpn"].contains(profile.mode) + } else if (rawDca instanceof Boolean) { + profile.directConnectionAppsEnabled = rawDca + } else { + throw new GradleException("Stealth profile directConnectionAppsEnabled must be a boolean") + } + return profile +} + +def stealthProfile = loadStealthProfile() + +def rawStealthMode = stealthProfile.mode.toString() +if (!rawStealthMode || rawStealthMode == "normal") { + 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=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 one of: ${stealthModes.join(', ')}") +} +def stealthNoVpn = explicitStealthNoVpn || stealthMode == "novpn" +// Emit the resolved mode (e.g. "stealth-vpn"), not stealthProfile.mode, which +// stays "normal" when stealth is enabled via -PSTEALTH_MODE without a profile. +def resolvedStealthMode = stealthMode ? "${stealthModePrefix}${stealthMode}" : "normal" +def stealthManifest = layout.buildDirectory.file("generated/stealth/${stealthMode}/AndroidManifest.xml").get().asFile +def stealthManifestFilter = file("${rootProject.projectDir}/../scripts/stealth/android_manifest_filter.py") +def stealthPython = (project.findProperty("stealthPython") ?: System.getenv("PYTHON") ?: "python3").toString() +// Stealth compiles a de-branded COPY of the REAL Kotlin (generated by +// scripts/stealth/debrand_kotlin.py into build/stealth/android/) so the app +// ships its real native bridge, not the legacy foundation.bridge stub. +def stealthGeneratedKotlinDir = new File(rootProject.projectDir.parentFile, "build/stealth/android/kotlin") +def stealthGeneratedResDir = new File(rootProject.projectDir.parentFile, "build/stealth/android/res") +// src/main/java holds Flutter's generated io.flutter.plugins.GeneratedPluginRegistrant +// (brand-free) — required so plugins (path_provider, shared_preferences, ...) register; +// without it storage init throws and the app hangs on a black screen. +def stealthAndroidSourceDirs = [stealthGeneratedKotlinDir, 'src/main/java'] + +def androidIdentityDefaults = [ + applicationId: stealthProfile.packageName.toString(), + appLabel: stealthProfile.appName.toString(), + launcherLabel: stealthProfile.appName.toString(), + identityLabel: stealthProfile.appName.toString(), + identityProfileId: "standard", + identityMetadata: "{}", + vpnSessionName: stealthProfile.sessionName.toString(), + notificationChannelVpn: stealthMode ? "${stealthProfile.appName} Status" : "VPN", + notificationChannelDataUsage: "Data Usage", + notificationTitle: stealthProfile.appName.toString(), + notificationConnectedText: stealthMode ? "${stealthProfile.appName} is active" : "${stealthProfile.appName} VPN is running", + notificationStartingText: stealthMode ? "Starting ${stealthProfile.appName}..." : "Starting ${stealthProfile.appName} VPN...", + notificationDisconnectAction: "Disconnect", + quickTileActiveLabel: stealthMode ? "${stealthProfile.appName} on" : "VPN Connected", + quickTileInactiveLabel: stealthMode ? "${stealthProfile.appName} off" : "VPN Disconnected", + appIcon: stealthMode ? "@drawable/neutral_app_icon" : "@mipmap/ic_launcher", + appRoundIcon: stealthMode ? "@drawable/neutral_app_icon" : "@mipmap/ic_launcher_round", + notificationSmallIcon: stealthMode ? "@drawable/neutral_notification_icon" : "@drawable/lantern_notification_icon", + quickTileIcon: stealthMode ? "@drawable/neutral_notification_icon" : "@drawable/lantern_notification_icon", + appAuthScheme: stealthMode ? "mobileapp" : "lantern", +] + +def androidIdentityProfilePath = + (project.findProperty("androidIdentityProfile") ?: System.getenv("ANDROID_IDENTITY_PROFILE"))?.toString()?.trim() +def androidIdentity = new LinkedHashMap(androidIdentityDefaults) +if (androidIdentityProfilePath) { + def profileFile = new File(androidIdentityProfilePath) + if (!profileFile.isAbsolute()) { + profileFile = new File(rootProject.projectDir.parentFile, androidIdentityProfilePath) + } + if (!profileFile.exists()) { + throw new GradleException("Android identity profile not found: ${profileFile}") + } + def props = new java.util.Properties() + profileFile.withReader("UTF-8") { props.load(it) } + props.each { key, value -> + def name = key.toString() + if (!androidIdentity.containsKey(name)) { + throw new GradleException("Unknown Android identity profile key '${name}' in ${profileFile}") + } + androidIdentity[name] = value.toString() + } +} + +if (stealthIconSeed) { + androidIdentity.appIcon = launcherIconResource + androidIdentity.appRoundIcon = roundLauncherIconResource +} + +if (!(androidIdentity.applicationId ==~ /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/)) { + throw new GradleException("Invalid Android applicationId in identity profile: ${androidIdentity.applicationId}") +} android { - namespace = "org.getlantern.lantern" + namespace = stealthMode ? "foundation.bridge" : "org.getlantern.lantern" compileSdk = 36 ndkVersion "28.2.13676358" @@ -67,8 +299,17 @@ android { if (sideloadUpdates) { manifest.srcFile sideloadManifestPath } + if (stealthMode) { + manifest.srcFile stealthManifest + java.srcDirs = stealthAndroidSourceDirs + kotlin.srcDirs = stealthAndroidSourceDirs + res.srcDirs = [stealthGeneratedResDir] + } jniLibs.srcDirs = ['libs'] jniLibs.srcDirs += ['src/main/jniLibs'] + if (stealthIconSeed) { + res.srcDirs += [generatedStealthIconResDir.get().asFile] + } } } @@ -95,6 +336,10 @@ android { // (Makefile --target-platform), or installs crash with "Could not find // 'libflutter.so'". + buildFeatures { + buildConfig true + } + // Use legacy packaging to help reduce apk size packagingOptions { exclude "DebugProbesKt.bin" @@ -121,12 +366,27 @@ android { } defaultConfig { - applicationId = "org.getlantern.lantern" + applicationId = androidIdentity.applicationId minSdkVersion = 24 targetSdk = 36 versionCode = code versionName = flutter.versionName buildConfigField "String", "SIDELOAD_SIGNING_CERTIFICATE_SHA256", "\"${sideloadSigningCertificateSha256}\"" + manifestPlaceholders = [ + appName: androidIdentity.appLabel, + appLabel: androidIdentity.appLabel, + launcherLabel: androidIdentity.launcherLabel, + appIcon: androidIdentity.appIcon, + appRoundIcon: androidIdentity.appRoundIcon, + launcherIcon: androidIdentity.appIcon, + roundLauncherIcon: androidIdentity.appRoundIcon, + identityLabel: androidIdentity.identityLabel, + identityProfileId: androidIdentity.identityProfileId, + identityMetadata: androidIdentity.identityMetadata, + quickTileIcon: androidIdentity.quickTileIcon, + appAuthScheme: androidIdentity.appAuthScheme, + ] + resValue "string", "app_name", androidIdentity.appLabel ndk { // arm64 only (APK + AAB) — armeabi-v7a dropped, see the comment @@ -141,9 +401,33 @@ android { arguments "-DANDROID_ARM_NEON=TRUE", '-DANDROID_STL=c++_shared' } } - buildFeatures { - buildConfig true - } + buildConfigField "String", "ANDROID_IDENTITY_LABEL", buildConfigString(androidIdentity.identityLabel) + buildConfigField "String", "ANDROID_IDENTITY_PROFILE_ID", buildConfigString(androidIdentity.identityProfileId) + buildConfigField "String", "ANDROID_IDENTITY_METADATA", buildConfigString(androidIdentity.identityMetadata) + // The REAL app code reads these BuildConfig fields, so they must exist in + // BOTH stealth and regular builds (stealth now compiles the real handlers, + // not the legacy stub). Only STEALTH_ENABLED differs by build. + buildConfigField "boolean", "STEALTH_ENABLED", stealthMode ? "true" : "false" + buildConfigField "String", "STEALTH_MODE", buildConfigString(resolvedStealthMode) + buildConfigField "String", "STEALTH_PACKAGE_NAME", buildConfigString(stealthProfile.packageName) + buildConfigField "String", "STEALTH_APP_NAME", buildConfigString(stealthProfile.appName) + buildConfigField "String", "STEALTH_SESSION_NAME", buildConfigString(stealthProfile.sessionName) + buildConfigField "int", "STEALTH_DENYLIST_VERSION", stealthProfile.denylistVersion.toString() + buildConfigField "String", "VPN_SESSION_NAME", buildConfigString(androidIdentity.vpnSessionName) + buildConfigField "String", "NOTIFICATION_CHANNEL_VPN", buildConfigString(androidIdentity.notificationChannelVpn) + buildConfigField "String", "NOTIFICATION_CHANNEL_DATA_USAGE", buildConfigString(androidIdentity.notificationChannelDataUsage) + buildConfigField "String", "NOTIFICATION_TITLE", buildConfigString(androidIdentity.notificationTitle) + buildConfigField "String", "NOTIFICATION_CONNECTED_TEXT", buildConfigString(androidIdentity.notificationConnectedText) + buildConfigField "String", "NOTIFICATION_STARTING_TEXT", buildConfigString(androidIdentity.notificationStartingText) + buildConfigField "String", "NOTIFICATION_DISCONNECT_ACTION", buildConfigString(androidIdentity.notificationDisconnectAction) + buildConfigField "String", "QUICK_TILE_ACTIVE_LABEL", buildConfigString(androidIdentity.quickTileActiveLabel) + buildConfigField "String", "QUICK_TILE_INACTIVE_LABEL", buildConfigString(androidIdentity.quickTileInactiveLabel) + buildConfigField "String", "NOTIFICATION_SMALL_ICON", buildConfigString(androidIdentity.notificationSmallIcon) + buildConfigField "boolean", "STEALTH_DIRECT_CONNECTION_APPS", + stealthProfile.directConnectionAppsEnabled.toString() + 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" } signingConfigs { @@ -187,6 +471,49 @@ if (sideloadUpdates) { .configureEach { dependsOn(generateSideloadManifest) } } +if (stealthMode) { + afterEvaluate { + tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { task -> + task.setSource(files(stealthAndroidSourceDirs)) + task.exclude("org/getlantern/**") + } + } +} + +if (stealthMode) { + tasks.register("generateStealthManifest", Exec) { + inputs.file(file("src/main/AndroidManifest.xml")) + inputs.file(stealthManifestFilter) + inputs.property("stealthMode", stealthMode) + outputs.file(stealthManifest) + commandLine( + stealthPython, + stealthManifestFilter.absolutePath, + "--mode", stealthMode, + "--input", "${projectDir}/src/main/AndroidManifest.xml", + "--output", stealthManifest.absolutePath, + ) + } + + tasks.matching { it.name == "preBuild" }.configureEach { + dependsOn(tasks.named("generateStealthManifest")) + } +} + +tasks.register("generateStealthAndroidIcons", Exec) { + onlyIf { stealthIconSeed } + inputs.property("stealthIconSeedSha256", stealthIconSeedSha256) + inputs.file(generatedStealthIconScript) + outputs.dir(generatedStealthIconResDir) + outputs.file(generatedStealthIconMetadata) + environment "STEALTH_ICON_SEED", stealthIconSeed ?: "" + commandLine stealthPython, + generatedStealthIconScript.absolutePath, + "--output-res-dir", generatedStealthIconResDir.get().asFile.absolutePath +} + +preBuild.dependsOn("generateStealthAndroidIcons") + flutter { source = "../.." } @@ -194,6 +521,8 @@ flutter { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) implementation fileTree(dir: "libs", include: "liblantern.aar") + // Used by the real app code (VPN status, logs, background work) in both + // stealth and regular builds, so these are always included. implementation 'androidx.core:core-ktx:1.15.0' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.8.7' implementation 'androidx.work:work-runtime-ktx:2.10.0' diff --git a/android/app/src/main/AndroidManifest.novpn.xml b/android/app/src/main/AndroidManifest.novpn.xml new file mode 100644 index 0000000000..ae28717101 --- /dev/null +++ b/android/app/src/main/AndroidManifest.novpn.xml @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1948926bc3..73e09feed9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -18,16 +18,17 @@ + android:roundIcon="${appRoundIcon}"> - + - + - + @@ -122,7 +123,6 @@ android:name="flutterEmbedding" android:value="2" /> - diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/LanternApp.kt b/android/app/src/main/kotlin/org/getlantern/lantern/LanternApp.kt index 140b96164b..bca1d6db65 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/LanternApp.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/LanternApp.kt @@ -12,7 +12,7 @@ import androidx.core.content.getSystemService import lantern.io.mobile.Mobile -class LanternApp : Application() { +open class LanternApp : Application() { companion object { lateinit var application: LanternApp diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/notification/NotificationManager.kt b/android/app/src/main/kotlin/org/getlantern/lantern/notification/NotificationManager.kt index 1ccd7d6648..3e9114250d 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/notification/NotificationManager.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/notification/NotificationManager.kt @@ -11,6 +11,7 @@ import android.net.Uri import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.content.getSystemService +import org.getlantern.lantern.BuildConfig import org.getlantern.lantern.LanternApp import org.getlantern.lantern.MainActivity import org.getlantern.lantern.R @@ -28,8 +29,6 @@ class NotificationHelper { private const val CHANNEL_DATA_USAGE = "data_usage" const val OPEN_URL = "SERVICE_OPEN_URL" - private const val VPN_DESC = "VPN" - private const val DATA_USAGE_DESC = "Data Usage" var notificationManager = LanternApp.application.getSystemService()!! val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 @@ -45,6 +44,7 @@ class NotificationHelper { private lateinit var dataUsageNotificationChannel: NotificationChannel private lateinit var vpnNotificationChannel: NotificationChannel + private val notificationSmallIconId: Int by lazy { resolveNotificationSmallIcon() } init { @@ -59,14 +59,14 @@ class NotificationHelper { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { vpnNotificationChannel = NotificationChannel( CHANNEL_VPN, - VPN_DESC, + BuildConfig.NOTIFICATION_CHANNEL_VPN, NotificationManager.IMPORTANCE_HIGH, ) notificationManager.createNotificationChannel(vpnNotificationChannel) dataUsageNotificationChannel = NotificationChannel( CHANNEL_DATA_USAGE, - DATA_USAGE_DESC, + BuildConfig.NOTIFICATION_CHANNEL_DATA_USAGE, NotificationManager.IMPORTANCE_HIGH, ) notificationManager.createNotificationChannel(dataUsageNotificationChannel) @@ -96,14 +96,14 @@ class NotificationHelper { return NotificationCompat.Builder(LanternApp.application, CHANNEL_VPN) .setShowWhen(false) .setOngoing(true) - .setContentTitle("Lantern") - .setContentText("Lantern VPN is running") + .setContentTitle(BuildConfig.NOTIFICATION_TITLE) + .setContentText(BuildConfig.NOTIFICATION_CONNECTED_TEXT) .setOnlyAlertOnce(true) - .setSmallIcon(R.drawable.lantern_notification_icon) + .setSmallIcon(notificationSmallIconId) .addAction( NotificationCompat.Action.Builder( android.R.drawable.ic_menu_close_clear_cancel, - "Disconnect", + BuildConfig.NOTIFICATION_DISCONNECT_ACTION, disconnectVPN() ).build() ) @@ -123,10 +123,10 @@ class NotificationHelper { return NotificationCompat.Builder(LanternApp.application, CHANNEL_VPN) .setShowWhen(false) .setOngoing(true) - .setContentTitle("Lantern") - .setContentText("Starting Lantern VPN...") + .setContentTitle(BuildConfig.NOTIFICATION_TITLE) + .setContentText(BuildConfig.NOTIFICATION_STARTING_TEXT) .setOnlyAlertOnce(true) - .setSmallIcon(R.drawable.lantern_notification_icon) + .setSmallIcon(notificationSmallIconId) .setContentIntent(contentIntent) .setSilent(true) .build() @@ -209,7 +209,7 @@ class NotificationHelper { .setContentTitle(notification.title) .setContentText(notification.body) .setOnlyAlertOnce(true) - .setSmallIcon(R.drawable.lantern_notification_icon) + .setSmallIcon(notificationSmallIconId) .setCategory(NotificationCompat.CATEGORY_EVENT) .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) @@ -234,4 +234,25 @@ class NotificationHelper { notificationManager.notify(notification.typeID, builder.build()) } + private fun resolveNotificationSmallIcon(): Int { + val configured = BuildConfig.NOTIFICATION_SMALL_ICON.removePrefix("@") + val parts = configured.split("/", limit = 2) + if (parts.size == 2) { + val resolved = LanternApp.application.resources.getIdentifier( + parts[1], + parts[0], + LanternApp.application.packageName, + ) + if (resolved != 0) { + return resolved + } + } + // Stealth builds must never fall back to the branded icon, even when + // the configured resource fails to resolve. + return if (BuildConfig.STEALTH_ENABLED) { + R.drawable.neutral_notification_icon + } else { + R.drawable.lantern_notification_icon + } + } } diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/service/LanternVpnService.kt b/android/app/src/main/kotlin/org/getlantern/lantern/service/LanternVpnService.kt index 8eb9215287..c99d3164a7 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/service/LanternVpnService.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/service/LanternVpnService.kt @@ -45,12 +45,12 @@ import org.getlantern.lantern.utils.toIpPrefix * it should not include any logic that needs to be connected with any activity. * everything should be done in independent */ -class LanternVpnService : +open class LanternVpnService : VpnService(), PlatformInterfaceWrapper { companion object { private const val TAG = "LanternVpnService" - private const val sessionName = "LanternVpn" + private val sessionName = BuildConfig.VPN_SESSION_NAME const val ACTION_START_RADIANCE = "com.getlantern.START_RADIANCE" const val ACTION_START_VPN = "org.getlantern.START_VPN" const val ACTION_CONNECT_TO_SERVER = "org.getlantern.CONNECT_TO_SERVER" diff --git a/android/app/src/main/kotlin/org/getlantern/lantern/service/QuickTileService.kt b/android/app/src/main/kotlin/org/getlantern/lantern/service/QuickTileService.kt index e27a69f45f..ced30a759c 100644 --- a/android/app/src/main/kotlin/org/getlantern/lantern/service/QuickTileService.kt +++ b/android/app/src/main/kotlin/org/getlantern/lantern/service/QuickTileService.kt @@ -17,13 +17,14 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import lantern.io.mobile.Mobile +import org.getlantern.lantern.BuildConfig import org.getlantern.lantern.LanternApp import org.getlantern.lantern.service.LanternVpnService.Companion.ACTION_STOP_VPN import org.getlantern.lantern.utils.AppLogger import org.getlantern.lantern.utils.isServiceRunning @RequiresApi(24) -class QuickTileService : TileService() { +open class QuickTileService : TileService() { companion object { private const val TAG = "QuickTileService" @@ -39,6 +40,10 @@ class QuickTileService : TileService() { private val tileScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + // Regular-build-only source set (the stealth app has its own quick-tile under + // foundation.bridge); STEALTH_ENABLED is always false here. + private val vpnServiceClass: Class + get() = LanternVpnService::class.java /* * We need receiver to only when user interacts with the tile @@ -110,16 +115,16 @@ class QuickTileService : TileService() { private fun connectVPN() { isPermissionIntent()?.let { handleVpnPermissionRequest(it) } ?: ContextCompat.startForegroundService( - this, Intent(this, LanternVpnService::class.java).apply { + this, Intent(this, vpnServiceClass).apply { action = LanternVpnService.ACTION_TILE_START } ) } private fun connectService(action: String) { - if (!isServiceRunning(this, LanternVpnService::class.java)) { + if (!isServiceRunning(this, vpnServiceClass)) { runCatching { - startService(Intent(this, LanternVpnService::class.java).apply { + startService(Intent(this, vpnServiceClass).apply { this.action = action }) AppLogger.d(TAG, "$action service started") @@ -158,7 +163,11 @@ class QuickTileService : TileService() { private fun updateTile(state: Int) { qsTile?.apply { this.state = state - label = if (state == Tile.STATE_ACTIVE) "VPN Connected" else "VPN Disconnected" + label = if (state == Tile.STATE_ACTIVE) { + BuildConfig.QUICK_TILE_ACTIVE_LABEL + } else { + BuildConfig.QUICK_TILE_INACTIVE_LABEL + } updateTile() AppLogger.d( TAG, @@ -167,4 +176,3 @@ class QuickTileService : TileService() { } } } - diff --git a/android/app/src/main/res/drawable/neutral_app_icon.xml b/android/app/src/main/res/drawable/neutral_app_icon.xml new file mode 100644 index 0000000000..128b5b2b27 --- /dev/null +++ b/android/app/src/main/res/drawable/neutral_app_icon.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/neutral_notification_icon.xml b/android/app/src/main/res/drawable/neutral_notification_icon.xml new file mode 100644 index 0000000000..f925c12911 --- /dev/null +++ b/android/app/src/main/res/drawable/neutral_notification_icon.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/values/string.xml b/android/app/src/main/res/values/string.xml index 2a6eb7b002..82c8629614 100644 --- a/android/app/src/main/res/values/string.xml +++ b/android/app/src/main/res/values/string.xml @@ -1,7 +1,6 @@ - Lantern Disconnect \ No newline at end of file diff --git a/android/app/src/stealth/kotlin/foundation/bridge/AppHost.kt b/android/app/src/stealth/kotlin/foundation/bridge/AppHost.kt new file mode 100644 index 0000000000..f7c21ec182 --- /dev/null +++ b/android/app/src/stealth/kotlin/foundation/bridge/AppHost.kt @@ -0,0 +1,10 @@ +package foundation.bridge + +import android.app.Application + +class AppHost : Application() { + override fun onCreate() { + super.onCreate() + BridgeContext.application = this + } +} diff --git a/android/app/src/stealth/kotlin/foundation/bridge/BaseHomeActivity.kt b/android/app/src/stealth/kotlin/foundation/bridge/BaseHomeActivity.kt new file mode 100644 index 0000000000..0d68096986 --- /dev/null +++ b/android/app/src/stealth/kotlin/foundation/bridge/BaseHomeActivity.kt @@ -0,0 +1,61 @@ +package foundation.bridge + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +abstract class BaseHomeActivity : FlutterActivity() { + private val scope = MainScope() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + BridgeContext.application = application + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "foundation.bridge/control", + ).setMethodCallHandler { call, result -> + when (call.method) { + "connect" -> scope.launch { + runCatching { connect() } + .onSuccess { result.success(null) } + .onFailure { result.error("connect_failed", it.message, null) } + } + "disconnect" -> scope.launch { + runCatching { disconnect() } + .onSuccess { result.success(null) } + .onFailure { result.error("disconnect_failed", it.message, null) } + } + "state" -> result.success(BridgeState.get()) + "error" -> result.success(BridgeState.getError()) + else -> result.notImplemented() + } + } + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + protected fun startAction(service: Class<*>, action: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(Intent(this, service).setAction(action)) + } else { + startService(Intent(this, service).setAction(action)) + } + } + + protected abstract suspend fun connect() + + protected abstract suspend fun disconnect() +} diff --git a/android/app/src/stealth/kotlin/foundation/bridge/BridgeCommon.kt b/android/app/src/stealth/kotlin/foundation/bridge/BridgeCommon.kt new file mode 100644 index 0000000000..be4eb233f3 --- /dev/null +++ b/android/app/src/stealth/kotlin/foundation/bridge/BridgeCommon.kt @@ -0,0 +1,112 @@ +package foundation.bridge + +import android.app.Application +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Settings +import java.io.File +import java.util.Locale +import java.util.concurrent.atomic.AtomicReference + +object BridgeContext { + lateinit var application: Application +} + +object BridgeState { + private val current = AtomicReference("disconnected") + private val error = AtomicReference(null) + + fun get(): String = current.get() + fun getError(): String? = error.get() + + fun set(value: String) { + if (value != "error") error.set(null) + current.set(value) + } + + fun setError(message: String) { + error.set(message) + current.set("error") + } +} + +object BridgePaths { + fun dataDir(context: Context): String = ensure(context.filesDir, "data") + + fun logDir(context: Context): String = ensure(context.filesDir, "logs") + + fun deviceId(context: Context): String { + return Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ANDROID_ID, + ) ?: "android" + } + + fun locale(): String = Locale.getDefault().toLanguageTag() + + private fun ensure(root: File, name: String): String { + val dir = File(root, name) + dir.mkdirs() + return dir.absolutePath + } +} + +object BridgeNotification { + private const val CHANNEL_ID = "connection" + private const val NOTIFICATION_ID = 9842 + + fun show(service: Service, text: String) { + createChannel(service) + service.startForeground(NOTIFICATION_ID, build(service, text)) + } + + fun clear(service: Service) { + service.stopForeground(Service.STOP_FOREGROUND_REMOVE) + } + + private fun build(context: Context, text: String): android.app.Notification { + val intent = Intent(context, HomeActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(context, CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(context) + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + @Suppress("DEPRECATION") + builder.setPriority(Notification.PRIORITY_LOW) + } + return builder + .setSmallIcon(R.drawable.neutral_notification_icon) + .setContentTitle(context.applicationInfo.loadLabel(context.packageManager)) + .setContentText(text) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setCategory(Notification.CATEGORY_SERVICE) + .build() + } + + private fun createChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + val channel = NotificationChannel( + CHANNEL_ID, + "Connection", + NotificationManager.IMPORTANCE_LOW, + ) + context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } +} diff --git a/android/app/src/stealth/res/drawable/launch_background.xml b/android/app/src/stealth/res/drawable/launch_background.xml new file mode 100644 index 0000000000..5782842b81 --- /dev/null +++ b/android/app/src/stealth/res/drawable/launch_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/stealth/res/drawable/neutral_app_icon.xml b/android/app/src/stealth/res/drawable/neutral_app_icon.xml new file mode 100644 index 0000000000..43358fec62 --- /dev/null +++ b/android/app/src/stealth/res/drawable/neutral_app_icon.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/stealth/res/drawable/neutral_notification_icon.xml b/android/app/src/stealth/res/drawable/neutral_notification_icon.xml new file mode 100644 index 0000000000..b4220eba47 --- /dev/null +++ b/android/app/src/stealth/res/drawable/neutral_notification_icon.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/stealth/res/values-night/styles.xml b/android/app/src/stealth/res/values-night/styles.xml new file mode 100644 index 0000000000..46984cb4ef --- /dev/null +++ b/android/app/src/stealth/res/values-night/styles.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/android/app/src/stealth/res/values/styles.xml b/android/app/src/stealth/res/values/styles.xml new file mode 100644 index 0000000000..0b31491c74 --- /dev/null +++ b/android/app/src/stealth/res/values/styles.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/docs/stealth-android-icons.md b/docs/stealth-android-icons.md new file mode 100644 index 0000000000..4597d23915 --- /dev/null +++ b/docs/stealth-android-icons.md @@ -0,0 +1,49 @@ +# Stealth Android icons + +Stealth Android variants can generate a neutral launcher icon set from a +private per-variant seed. Normal builds keep the existing launcher icons. + +## Generate Locally + +```sh +make stealth-android-icons STEALTH_ICON_SEED="$PRIVATE_VARIANT_ICON_SEED" +``` + +This writes Android resources under +`android/app/build/generated/icons/res`: + +- adaptive launcher icon: `@mipmap/ic_launcher_alt` (in `mipmap-anydpi-v26`) +- adaptive round launcher icon: `@mipmap/ic_launcher_alt_round` +- pre-26 fallback launcher icons in `mipmap-anydpi` +- foreground vector: `@drawable/launcher_foreground_alt` +- monochrome vector: `@drawable/launcher_monochrome_alt` +- notification icon candidate: `@drawable/ic_notification_alt` + +Private metadata is written outside the resource tree at +`android/app/build/generated/icons/stealth-icon-metadata.json`. It +stores only the seed hash, not the raw seed, and is not packaged as an Android +resource. + +## Build Wiring + +Android Gradle reads `STEALTH_ICON_SEED` or `-PstealthIconSeed=...`. When a +seed is present, the manifest placeholders switch the app icon and round icon +to the generated resources: + +```sh +STEALTH_ICON_SEED="$PRIVATE_VARIANT_ICON_SEED" make android-apk-release +``` + +Without a seed, the placeholders resolve to the existing `@mipmap/ic_launcher` +and `@mipmap/ic_launcher_round` resources. + +## Release Policy + +Use a different private icon seed for every distributed stealth variant. Keep +the seed and `stealth-icon-metadata.json` with the private build profile so +support can correlate a report with the shipped visual identity. Do not publish +the seed or metadata with public artifacts. + +The generated icons are intentionally generic geometric utility icons. If design +provides a curated pool later, the same Gradle placeholder path can select +pre-rendered resources by variant ID instead of procedural output. diff --git a/docs/stealth-android-identity.md b/docs/stealth-android-identity.md new file mode 100644 index 0000000000..a953063ae8 --- /dev/null +++ b/docs/stealth-android-identity.md @@ -0,0 +1,128 @@ +# Stealth Android identity profiles + +Stealth Android builds can use a generated identity profile to change install-time +identity without a separate source tree. The profile is a Java properties file +consumed by `android/app/build.gradle`. + +Normal builds keep the current identity: + +- `applicationId`: `org.getlantern.lantern` +- app and launcher label: `Lantern` +- VPN session name: `LanternVpn` +- notification and quick settings tile labels: existing Lantern/VPN wording + +## Generate a profile + +Generate a fresh random identity: + +```sh +python3 scripts/stealth/generate_android_identity.py \ + --output build/stealth/android-identity.properties +``` + +Generate a deterministic identity from a seed: + +```sh +python3 scripts/stealth/generate_android_identity.py \ + --seed "release-2026-05-15" \ + --output build/stealth/android-identity.properties +``` + +The generated profile includes: + +- `applicationId` +- `appLabel` and `launcherLabel` +- `identityLabel`, `identityProfileId`, and `identityMetadata` +- `vpnSessionName` +- notification channel/title/body/action labels +- quick settings tile labels +- manifest icon resource names and custom auth scheme + +The generator defaults stealth profiles to the neutral Android resources +`@drawable/neutral_app_icon` and `@drawable/neutral_notification_icon`. + +Generated profiles are build inputs and should not be committed. + +## Build with a profile + +Pass a profile directly: + +```sh +ANDROID_IDENTITY_PROFILE=build/stealth/android-identity.properties \ + make android-apk-release +``` + +Or let `make` generate one automatically for a stealth build: + +```sh +BUILD_TYPE=stealth make android-release-ci +``` + +Use `ANDROID_IDENTITY_SEED` when the CI run must be reproducible: + +```sh +BUILD_TYPE=stealth ANDROID_IDENTITY_SEED="$GITHUB_RUN_ID" make android-release-ci +``` + +`BUILD_TYPE=stealth` defaults the generated profile path to +`build/stealth/android-identity.properties`, so the APK and AAB from the same +`make android-release-ci` invocation share one identity. Passing +`ANDROID_IDENTITY_SEED` regenerates that profile for the requested seed. For an +unseeded fresh random identity, pass `ANDROID_FORCE_IDENTITY_PROFILE=1`. + +Gradle also accepts the profile as a project property for Android-side +manifest/`BuildConfig` validation: + +```sh +cd android +./gradlew assembleRelease \ + -PandroidIdentityProfile=../build/stealth/android-identity.properties +``` + +Do not use the Gradle-only command as the release build path for randomized +auth schemes. It does not pass `APP_AUTH_SCHEME` into Dart, so +`AppBuildInfo.isAppAuthUri()` would still use its default scheme. Use the +Makefile Android targets, or pass the matching Flutter +`--dart-define=APP_AUTH_SCHEME=` value when invoking +`flutter build`. + +## Profile schema + +All keys are optional. Missing keys fall back to the normal Lantern values. To +install side by side with the normal app, set `applicationId`. + +```properties +applicationId=app.clearnotes.a1b2c3d4 +appLabel=Clear Notes +launcherLabel=Clear Notes +identityLabel=clear-notes-2f143e88f4 +identityProfileId=clear-notes-2f143e88f4 +identityMetadata={"profileId":"clear-notes-2f143e88f4","randomSeed":"2f143e88f4c0"} +vpnSessionName=Clear Notes Session +notificationChannelVpn=Clear Notes Status +notificationChannelDataUsage=Clear Notes Usage +notificationTitle=Clear Notes +notificationConnectedText=Clear Notes is active +notificationStartingText=Starting Clear Notes... +notificationDisconnectAction=Stop Clear Notes +quickTileActiveLabel=Clear Notes on +quickTileInactiveLabel=Clear Notes off +appIcon=@drawable/neutral_app_icon +appRoundIcon=@drawable/neutral_app_icon +notificationSmallIcon=@drawable/neutral_notification_icon +quickTileIcon=@drawable/neutral_notification_icon +appAuthScheme=clearnotes2f143e88 +``` + +For **non-stealth** Android release builds that set an identity profile, the +Makefile passes `appAuthScheme` to Flutter as `--dart-define=APP_AUTH_SCHEME=...` +so Dart deep-link handling accepts the same private scheme that Gradle writes +into the manifest. For **stealth** builds (`STEALTH_MODE`/`STEALTH_PROFILE` +set), `ANDROID_IDENTITY_DART_DEFINES` is empty — the Makefile does not inject +`APP_AUTH_SCHEME`, so `AppBuildInfo.isAppAuthUri()` keeps its default scheme. +Pass `--dart-define=APP_AUTH_SCHEME=` explicitly if a stealth +build needs Dart-side deep-link routing to match the manifest scheme. + +This ticket intentionally leaves broader manifest minimization to the related +manifest work. The identity profile exposes placeholders where this branch needs +them, but does not remove normal deep links or service declarations. diff --git a/docs/stealth-build-profile.md b/docs/stealth-build-profile.md new file mode 100644 index 0000000000..c79dd1cb2b --- /dev/null +++ b/docs/stealth-build-profile.md @@ -0,0 +1,121 @@ +# Stealth Build Profile + +Stealth builds use the normal Lantern source tree. The build is switched by +passing either a generated profile mode or a private profile JSON file into the +existing Makefile targets. + +Normal builds are unchanged when neither `STEALTH_MODE` nor `STEALTH_PROFILE` +is set. + +## Generate a Profile + +Generate a private profile under `build/stealth/`. Stealth modes require +non-branded app and session names (the generator rejects empty or +Lantern-branded values): + +```sh +make stealth-profile \ + STEALTH_MODE=stealth-vpn \ + STEALTH_APP_NAME=Client \ + STEALTH_SESSION_NAME=ClientVpn +``` + +Supported modes are: + +- `stealth-vpn` +- `stealth-novpn` + +The generated profile includes: + +- `mode` +- `packageName` +- `appName` +- `sessionName` +- `goObfuscationSeed` +- `denylistVersion` + +Override generated values with Make variables: + +```sh +make stealth-profile \ + STEALTH_MODE=stealth-vpn \ + STEALTH_PACKAGE_NAME=org.example.client.s123 \ + STEALTH_APP_NAME=Client \ + STEALTH_SESSION_NAME=ClientVpn \ + STEALTH_GO_OBFUSCATION_SEED=0123456789abcdef \ + STEALTH_DENYLIST_VERSION=1 +``` + +Use an existing private profile: + +```sh +make stealth-profile STEALTH_PROFILE=/secure/profiles/client.json +``` + +## Build With a Profile + +Android build targets generate or normalize the profile before invoking Flutter: + +```sh +make android-release \ + STEALTH_MODE=stealth-vpn \ + STEALTH_APP_NAME=Client \ + STEALTH_SESSION_NAME=ClientVpn +make android-release STEALTH_PROFILE=/secure/profiles/client.json +``` + +The Makefile writes: + +- `build/stealth/profile.json`: private normalized profile for support/debugging +- `build/stealth/profile.inputs`: private Make cache key for profile inputs +- `build/stealth/dart-defines.json`: Flutter `--dart-define-from-file` input +- `build/stealth/artifact-metadata.json`: private CI artifact metadata +- `build/stealth/go-tags-suffix.txt`: Go build tag suffix consumed by Make + +Treat the entire `build/stealth/` directory as private and do not publish these +files in public release artifacts. They contain profile values that identify a +build stream. Dart defines, metadata, and the Make cache key omit the raw Go +obfuscation seed; metadata and the cache key include seed hashes for change +detection and support correlation. + +## Android Plumbing + +`android/app/build.gradle` reads the normalized profile from a +`-PstealthProfile=/path/to/profile` Gradle property, or from +`ORG_GRADLE_PROJECT_stealthProfile` when invoked through Flutter/Make. The +legacy `STEALTH_PROFILE` environment variable remains a fallback for manual +non-daemon Gradle invocations. + +Gradle profile loading only affects Android manifest and `BuildConfig` values. +It does not populate Dart constants by itself. Use the Make targets, or pass +the generated `build/stealth/dart-defines.json` explicitly with +`flutter build --dart-define-from-file=build/stealth/dart-defines.json`, so +`AppBuildInfo` and `AppSecrets` see the same profile values as Gradle. + +The profile sets: + +- Android `applicationId` +- Android manifest app label +- `BuildConfig.STEALTH_*` constants + +For stealth builds the Android namespace becomes `foundation.bridge` (normal +builds keep `org.getlantern.lantern`); see the `namespace` assignment in +`android/app/build.gradle`. No separate source tree is needed. + +## Dart and Go Plumbing + +Flutter receives profile values through `--dart-define-from-file`, exposed in +`AppBuildInfo.stealth*` constants. Go builds receive a tag suffix from +`generate_profile.py` (`go_tags_suffix`): + +- `stealth` (all stealth builds) +- `novpn` (added only for `stealth-novpn` mode) + +## Package Name Migration Tradeoff + +Generated stealth profiles use a per-profile Android package name by default. +Android treats each package name as a different app, so a build with a new +`packageName` does not update an existing install and does not share that app's +data directory. Release and support workflows need to keep the private profile +for each distributed build so they can identify which package name, app label, +session name, denylist version, and Go obfuscation seed were used. diff --git a/docs/stealth-feature-gates.md b/docs/stealth-feature-gates.md new file mode 100644 index 0000000000..8e251166c7 --- /dev/null +++ b/docs/stealth-feature-gates.md @@ -0,0 +1,47 @@ +# Stealth feature gates + +Stealth artifacts disable high-identification product surfaces at compile time +with Dart environment defines. Normal builds remain unchanged. + +## Build flags + +Use either profile-style mode names or an explicit boolean flag: + +```sh +flutter build apk --dart-define=STEALTH_MODE=stealth-vpn +flutter build apk --dart-define=STEALTH_MODE=stealth-novpn +flutter build apk --dart-define=STEALTH_MODE=true +flutter build apk --dart-define=STEALTH_BUILD=true +flutter build apk --dart-define=STEALTH_NO_VPN=true +``` + +`STEALTH_MODE=true` is kept as a compatibility alias for generic stealth +artifacts; profile-specific builds should prefer `stealth-vpn` or +`stealth-novpn`. + +The app derives these gates from the stealth flag: + +- `enableOAuth` +- `enableAppLinks` +- `enableSocialLinks` +- `enableAutoUpdate` + +`STEALTH_NO_VPN=true` is the explicit no-VPN compatibility flag. It enables the +same feature gates as `STEALTH_BUILD=true` and is treated as stealth mode even +when `STEALTH_MODE` is not supplied. + +## Disabled surfaces + +Stealth builds hide or short-circuit: + +- OAuth login buttons, callbacks, and SSO account deletion verification. +- Runtime app-link handling in Flutter. +- Follow-us, forum, alternate download, referral/social, and project-link + surfaces. +- Desktop auto-update initialization, manual update checks, and appcast URL + resolution. + +Native manifest and entitlement removal is handled by the stealth manifest +filtering build step. Artifact leakage checks should still scan final APK/IPA +and desktop bundles for OAuth provider names, app-link hosts/schemes, billing +entry points, social URLs, and appcast/update URLs. diff --git a/lib/core/common/app_build_info.dart b/lib/core/common/app_build_info.dart index dd800e9920..84f0daf471 100644 --- a/lib/core/common/app_build_info.dart +++ b/lib/core/common/app_build_info.dart @@ -17,6 +17,77 @@ class AppBuildInfo { defaultValue: false, ); + static const String stealthMode = String.fromEnvironment( + 'STEALTH_MODE', + defaultValue: 'normal', + ); + + static const bool stealthNoVpn = bool.fromEnvironment( + 'STEALTH_NO_VPN', + defaultValue: false, + ); + + static const String stealthPackageName = String.fromEnvironment( + 'STEALTH_PACKAGE_NAME', + defaultValue: 'org.getlantern.lantern', + ); + + static const String stealthAppName = String.fromEnvironment( + 'STEALTH_APP_NAME', + defaultValue: 'Lantern', + ); + + static const String stealthSessionName = String.fromEnvironment( + 'STEALTH_SESSION_NAME', + defaultValue: 'LanternVpn', + ); + + static const int stealthDenylistVersion = int.fromEnvironment( + 'STEALTH_DENYLIST_VERSION', + defaultValue: 0, + ); + + static bool get isStealthBuild => + stealthBuild || + stealthNoVpn || + stealthMode.startsWith('stealth-') || + stealthMode == 'true'; + + static const bool stealthBuild = bool.fromEnvironment( + 'STEALTH_BUILD', + defaultValue: false, + ); + + /// Removes high-identification UI and runtime flows from stealth artifacts. + static const bool suppressIdentifyingFeatures = + stealthBuild || + stealthNoVpn || + stealthMode == 'stealth-vpn' || + stealthMode == 'stealth-novpn' || + stealthMode == 'true'; + + static const bool enableOAuth = !suppressIdentifyingFeatures; + + static const bool enableAppLinks = !suppressIdentifyingFeatures; + + static const bool enableSocialLinks = !suppressIdentifyingFeatures; + + static const bool enableAutoUpdate = !suppressIdentifyingFeatures; + + static const String appAuthScheme = String.fromEnvironment( + 'APP_AUTH_SCHEME', + defaultValue: 'lantern', + ); + + static bool isAppAuthUri(Uri uri) { + return uri.scheme == appAuthScheme; + } + + static const bool stealthDirectConnectionApps = bool.fromEnvironment( + 'STEALTH_DIRECT_CONNECTION_APPS', + defaultValue: false, + ); + /// Developer mode is exposed in debug and nightly builds only. static bool get isDevModeEnabled => kDebugMode || buildType == 'nightly'; } diff --git a/lib/core/common/app_secrets.dart b/lib/core/common/app_secrets.dart index 14c7468088..0b02139cf1 100644 --- a/lib/core/common/app_secrets.dart +++ b/lib/core/common/app_secrets.dart @@ -1,4 +1,5 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:lantern/core/common/app_build_info.dart'; class AppSecrets { static String get macosAppGroupId => dotenv.env['MACOS_APP_GROUP'] ?? ''; @@ -15,5 +16,5 @@ class AppSecrets { static String get windowsGuid => dotenv.env['WINDOWS_GUID'] ?? dotenv.env['WINDOWS_APP_GUID'] ?? ''; - static String get lanternPackageName => "org.getlantern.lantern"; + static String get lanternPackageName => AppBuildInfo.stealthPackageName; } diff --git a/lib/lantern_app.dart b/lib/lantern_app.dart index 1d43051484..3d02980248 100644 --- a/lib/lantern_app.dart +++ b/lib/lantern_app.dart @@ -16,6 +16,7 @@ import 'package:lantern/features/window/window_wrapper.dart'; import 'package:lantern/lantern/lantern_service_notifier.dart'; import 'package:loader_overlay/loader_overlay.dart'; +import 'core/common/app_build_info.dart'; import 'core/common/common.dart'; import 'core/services/injection_container.dart'; import 'core/utils/deeplink_utils.dart' show DeepLinkCallbackManager; @@ -100,7 +101,7 @@ class _LanternAppState extends ConsumerState final path = uri.path; if (path.startsWith('/report-issue') || - (uri.scheme == 'lantern' && uri.host == 'report-issue')) { + (AppBuildInfo.isAppAuthUri(uri) && uri.host == 'report-issue')) { final queryParams = uri.queryParameters; final foundType = queryParams.containsKey('type'); final fragment = uri.fragment; @@ -120,12 +121,12 @@ class _LanternAppState extends ConsumerState _pushWithHome(ReportIssue()); } } else if (path.startsWith('/auth') || - (uri.scheme == 'lantern' && uri.host == 'auth')) { + (AppBuildInfo.isAppAuthUri(uri) && uri.host == 'auth')) { if (uri.queryParameters.containsKey('token')) { sl().handleDeepLink(uri.queryParameters); } } else if (path.startsWith('/private-server') || - (uri.scheme == 'lantern' && uri.host == 'private-server')) { + (AppBuildInfo.isAppAuthUri(uri) && uri.host == 'private-server')) { final data = Map.of(uri.queryParameters); appLogger.debug("DeepLink private-server params: ${data.keys.toList()}"); data['accessKey'] = _buildPrivateServerAccessKey(uri); diff --git a/pubspec.lock b/pubspec.lock index 33ca9e8295..352a9bdcdb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1104,10 +1104,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1661,26 +1661,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.17" timezone: dependency: "direct main" description: diff --git a/scripts/stealth/android_manifest_filter.py b/scripts/stealth/android_manifest_filter.py new file mode 100644 index 0000000000..0042066e11 --- /dev/null +++ b/scripts/stealth/android_manifest_filter.py @@ -0,0 +1,365 @@ +#!/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", +} + +# De-branded REAL component classes (generated by debrand_kotlin.py: package +# org.getlantern.lantern -> foundation.bridge, brand class names Lantern -> Backend). +STEALTH_APPLICATION_NAME = "foundation.bridge.BackendApp" +STEALTH_ACTIVITY_NAME = "foundation.bridge.MainActivity" +STEALTH_VPN_SERVICE_NAME = "foundation.bridge.service.BackendVpnService" +STEALTH_TILE_SERVICE_NAME = "foundation.bridge.service.QuickTileService" +# Neutral name (no "Vpn"): debrand_kotlin renames NoVpnLanternService -> +# LocalConnectionService so the stealth no-VPN artifact's manifest component and +# log tag do not advertise VPN heritage. Keep in sync with debrand_kotlin.py. +STEALTH_NOVPN_SERVICE_NAME = "foundation.bridge.service.LocalConnectionService" +NOVPN_SPECIAL_USE_REASON = "User-controlled local proxy connection" +# Pre-existing no-VPN service names that may already be declared in the input +# manifest (e.g. android/app/src/main/AndroidManifest.novpn.xml). These are +# normalized to STEALTH_NOVPN_SERVICE_NAME so the filter never emits duplicate +# special-use foreground services. +LEGACY_NOVPN_SERVICE_NAMES = { + "foundation.bridge.SyncService", +} + +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: + novpn_names = {STEALTH_NOVPN_SERVICE_NAME} | LEGACY_NOVPN_SERVICE_NAMES + existing = [ + service + for service in application.findall("service") + if android_attr(service, ANDROID_NAME) in novpn_names + ] + + # Collapse any pre-existing no-VPN services (canonical or legacy alias) into + # a single canonical entry; drop the extras to avoid duplicate special-use + # foreground services. + service = existing[0] if existing else ET.SubElement(application, "service") + for extra in existing[1:]: + application.remove(extra) + + service.set(ANDROID_NAME, STEALTH_NOVPN_SERVICE_NAME) + service.set(ANDROID_EXPORTED, "false") + service.set(ANDROID_FOREGROUND_SERVICE_TYPE, "specialUse") + + # Rewrite the special-use property deterministically so re-runs and legacy + # entries converge on identical output (idempotency). + for prop in list(service.findall("property")): + if android_attr(prop, ANDROID_NAME) == "android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE": + service.remove(prop) + 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) + + +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..0447a7deae --- /dev/null +++ b/scripts/stealth/android_manifest_filter_test.py @@ -0,0 +1,475 @@ +#!/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 the neutral + local-connection foreground service (no "Vpn" token in its name). + +Expected component names are read from the android_manifest_filter module +constants (STEALTH_*) rather than hard-coded, so a rename in the source does not +silently leave these tests asserting a stale name. +""" + +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(f.STEALTH_ACTIVITY_NAME, activity_names) + + def test_launcher_filter_kept(self) -> None: + for activity in self.app.findall("activity"): + if activity.attrib.get(f"{ANDROID}name") == f.STEALTH_ACTIVITY_NAME: + 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(f"{f.STEALTH_ACTIVITY_NAME} not found") + + def test_deeplink_filters_stripped(self) -> None: + for activity in self.app.findall("activity"): + if activity.attrib.get(f"{ANDROID}name") == f.STEALTH_ACTIVITY_NAME: + 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(f"{f.STEALTH_ACTIVITY_NAME} not found") + + # ── Class-name rewrite applied ───────────────────────────────────────── + + def test_application_rewritten(self) -> None: + self.assertEqual( + self.app.attrib.get(f"{ANDROID}name"), f.STEALTH_APPLICATION_NAME + ) + + def test_vpn_service_rewritten(self) -> None: + service_names = _names(self.app.findall("service")) + self.assertIn(f.STEALTH_VPN_SERVICE_NAME, 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(f.STEALTH_TILE_SERVICE_NAME, 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(f.STEALTH_TILE_SERVICE_NAME, service_names) + + def test_local_connection_service_injected(self) -> None: + service_names = _names(self.app.findall("service")) + self.assertIn(f.STEALTH_NOVPN_SERVICE_NAME, service_names) + + def test_injected_service_name_has_no_vpn_token(self) -> None: + # The no-VPN foreground service must not advertise VPN heritage in its + # (logcat- and manifest-visible) component name. + self.assertNotIn("Vpn", f.STEALTH_NOVPN_SERVICE_NAME) + for name in _names(self.app.findall("service")): + self.assertNotIn("Vpn", name, + msg=f"service {name!r} carries a 'Vpn' token in novpn mode") + + 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"), + ) + + +# A merged manifest that already declares a legacy-named no-VPN special-use +# foreground service (as android/app/src/main/AndroidManifest.novpn.xml does). +LEGACY_NOVPN_MANIFEST = """\ + + + + + + + + + + + + + + +""" + + +class NoVpnLegacyServiceTest(unittest.TestCase): + """A pre-existing legacy no-VPN service is normalized, never duplicated.""" + + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + tmp = Path(self._tmp.name) + src = tmp / "AndroidManifest.xml" + src.write_text(LEGACY_NOVPN_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_legacy_alias_normalized(self) -> None: + names = _names(self.app.findall("service")) + self.assertIn(f.STEALTH_NOVPN_SERVICE_NAME, names) + self.assertNotIn("foundation.bridge.SyncService", names) + + def test_no_duplicate_novpn_service(self) -> None: + canonical = [ + s for s in self.app.findall("service") + if s.attrib.get(f"{ANDROID}name") == f.STEALTH_NOVPN_SERVICE_NAME + ] + self.assertEqual(len(canonical), 1) + + def test_single_special_use_property(self) -> None: + special = [ + p for p in self.app.findall("service/property") + if p.attrib.get(f"{ANDROID}name") + == "android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" + ] + self.assertEqual(len(special), 1) + + 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() diff --git a/scripts/stealth/debrand_kotlin.py b/scripts/stealth/debrand_kotlin.py new file mode 100644 index 0000000000..306d7713c6 --- /dev/null +++ b/scripts/stealth/debrand_kotlin.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Generate a de-branded COPY of the real Android Kotlin sources for Stealth. + +Stealth builds must ship the REAL app, so the real handler layer under +``android/app/src/main/kotlin/org/getlantern/lantern/**`` has to be compiled +(not the legacy ``foundation.bridge`` stub). This script copies that tree into a +build directory with a mechanical, brand-neutral transform applied: + +* package ``org.getlantern.lantern`` -> ``foundation.bridge`` (matches the + stealth Android ``namespace`` so generated ``R``/``BuildConfig`` resolve), in + both dotted (source) and slashed (path) forms; +* gomobile binding imports ``lantern.io.`` -> ``foundation.engine.`` (the + stealth gomobile ``javapkg``); +* residual brand tokens ``getlantern`` -> ``foundation`` and ``Lantern``/ + ``lantern`` -> ``Bridge``/``bridge`` (covers class names that R8 keeps because + they are referenced from the manifest, plus channel-name strings and log/dir + paths). ``Vpn``/``VPN``/``TUN`` are intentionally preserved -- they are + allowed in the stealth-vpn leakage policy. + +The committed sources are never modified; only the generated copy is. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import shutil +import sys + + +SOURCE_PACKAGE_DIR = "org/getlantern/lantern" +TARGET_PACKAGE_DIR = "foundation/bridge" + +# Ordered, longest-match-first. The full own-package FQN must be rewritten to the +# exact ``foundation.bridge`` namespace BEFORE the generic ``getlantern``/ +# ``lantern`` token rules run, otherwise piecewise substitution would yield +# ``org.foundation.bridge`` instead of ``foundation.bridge``. +TEXT_REPLACEMENTS: tuple[tuple[str, str], ...] = ( + # The no-VPN service class/log-tag must NOT advertise its VPN heritage in a + # stealth no-VPN build. Rename it to a neutral name BEFORE the brand rules + # (otherwise "Lantern" -> "Backend" would leave "NoVpnBackendService", whose + # "Vpn" token is visible in logcat and the manifest component name). Matches + # the service's existing "Local connection" notification wording. Applied in + # both modes; in stealth-vpn the (unused) class is renamed consistently. + ("NoVpnLanternService", "LocalConnectionService"), + ("org.getlantern.lantern", "foundation.bridge"), + ("org/getlantern/lantern", "foundation/bridge"), + ("lantern.io.", "foundation.engine."), + ("lantern/io/", "foundation/engine/"), + ("getlantern", "foundation"), + ("GETLANTERN", "FOUNDATION"), + # Generic brand tokens map to "Backend"/"backend" to match BOTH the Go ELF + # sanitizer (NATIVE_TEXT_REPLACEMENTS) and the Dart-side run_flutter_build + # mapping, so channel method names and any shared keys agree across all + # layers. The package/channel FQN above stays foundation.bridge. + ("Lantern", "Backend"), + ("lantern", "backend"), + ("LANTERN", "BACKEND"), +) + + +# OAuth gomobile exports are compiled out of stealth builds (//go:build !stealth +# in lantern-core/mobile), so these binding methods do not exist in the stealth +# AAR. The real MethodHandler calls them; OAuth is intentionally disabled in +# stealth (its Dart UI is removed by removeHighIdentificationUi), so the call +# sites are replaced with inert, correctly-typed stubs to keep Kotlin compiling. +OAUTH_STUB_REPLACEMENTS: tuple[tuple[str, str], ...] = ( + ("Mobile.oAuthLoginUrl(provider)", '("" /* oauth disabled in stealth */)'), + ("Mobile.oAuthLoginCallback(token)", '("{}" /* oauth disabled in stealth */)'), + ("Mobile.isOAuthLogin()", "false /* oauth disabled in stealth */"), + ("Mobile.getOAuthProvider()", '"" /* oauth disabled in stealth */'), +) + + +def debrand_text(text: str) -> str: + for old, new in OAUTH_STUB_REPLACEMENTS: + text = text.replace(old, new) + for old, new in TEXT_REPLACEMENTS: + text = text.replace(old, new) + return text + + +def debrand_path(relative: str) -> str: + # Apply the same token rules to the path so directory + filename are neutral + # (e.g. service/LanternVpnService.kt -> service/BridgeVpnService.kt). + return debrand_text(relative) + + +def generate(source_root: Path, output_root: Path) -> int: + if not source_root.is_dir(): + print(f"error: source not found: {source_root}", file=sys.stderr) + return 2 + if output_root.exists(): + shutil.rmtree(output_root) + output_root.mkdir(parents=True, exist_ok=True) + + count = 0 + for src in sorted(source_root.rglob("*.kt")): + relative = src.relative_to(source_root).as_posix() + target = output_root / debrand_path(relative) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(debrand_text(src.read_text(encoding="utf-8")), encoding="utf-8") + count += 1 + print(f"de-branded {count} Kotlin file(s) -> {output_root}") + return 0 + + +RES_TEXT_SUFFIXES = {".xml"} + + +def generate_res(res_source: Path, res_overlay: Path | None, res_output: Path) -> int: + """De-brand a copy of the real res tree, then overlay stealth res on top. + + The real handlers reference a small number of resources (e.g. the + notification icon). Stealth builds previously shipped only the 4-file + ``src/stealth/res`` overlay, which is insufficient for the real code. We + de-brand a full copy of ``src/main/res`` (renaming brand resource files and + substituting brand text in values) and then copy the stealth overlay over it + (last-writer-wins), avoiding duplicate-resource merge conflicts. + """ + if not res_source.is_dir(): + print(f"error: res source not found: {res_source}", file=sys.stderr) + return 2 + if res_output.exists(): + shutil.rmtree(res_output) + res_output.mkdir(parents=True, exist_ok=True) + + for src in sorted(res_source.rglob("*")): + if src.is_dir(): + continue + relative = debrand_path(src.relative_to(res_source).as_posix()) + target = res_output / relative + target.parent.mkdir(parents=True, exist_ok=True) + if src.suffix.lower() in RES_TEXT_SUFFIXES: + target.write_text(debrand_text(src.read_text(encoding="utf-8")), encoding="utf-8") + else: + shutil.copy2(src, target) + + if res_overlay and res_overlay.is_dir(): + for src in sorted(res_overlay.rglob("*")): + if src.is_dir(): + continue + relative = src.relative_to(res_overlay).as_posix() + target = res_output / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, target) # overlay wins, no de-brand (already neutral) + print(f"merged de-branded res -> {res_output}") + return 0 + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--source", + type=Path, + default=Path("android/app/src/main/kotlin") / SOURCE_PACKAGE_DIR, + help="Real Kotlin package root to de-brand.", + ) + parser.add_argument( + "--output", + type=Path, + required=True, + help="Output directory root for the de-branded copy (the foundation/bridge tree is written under here).", + ) + parser.add_argument("--res-source", type=Path, help="Real res tree to de-brand (e.g. android/app/src/main/res).") + parser.add_argument("--res-overlay", type=Path, help="Stealth res overlay copied on top (e.g. android/app/src/stealth/res).") + parser.add_argument("--res-output", type=Path, help="Output dir for the merged de-branded res tree.") + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + # Validate resource arguments before writing any output, rather than + # silently producing an incomplete (or no) de-branded resource tree. + if bool(args.res_source) != bool(args.res_output): + print( + "error: --res-source and --res-output must be provided together", + file=sys.stderr, + ) + return 2 + if args.res_overlay and not args.res_overlay.is_dir(): + print(f"error: res overlay not found: {args.res_overlay}", file=sys.stderr) + return 2 + output_pkg_root = args.output / TARGET_PACKAGE_DIR + rc = generate(args.source, output_pkg_root) + if rc != 0: + return rc + if args.res_source and args.res_output: + rc = generate_res(args.res_source, args.res_overlay, args.res_output) + return rc + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/stealth/generate_android_icons.py b/scripts/stealth/generate_android_icons.py new file mode 100644 index 0000000000..c111795ecc --- /dev/null +++ b/scripts/stealth/generate_android_icons.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Generate deterministic neutral Android icon resources for stealth variants.""" + +from __future__ import annotations + +import argparse +import colorsys +import hashlib +import json +import os +import secrets +from pathlib import Path + + +def hash_bytes(seed: str) -> bytes: + return hashlib.sha256(seed.encode("utf-8")).digest() + + +def color_from_hash(data: bytes, offset: int, sat: float, light: float) -> str: + hue = int.from_bytes(data[offset : offset + 2], "big") / 65536.0 + red, green, blue = colorsys.hls_to_rgb(hue, light, sat) + return "#{:02X}{:02X}{:02X}".format( + round(red * 255), + round(green * 255), + round(blue * 255), + ) + + +def shape_paths(data: bytes, primary: str, secondary: str, accent: str) -> str: + template = data[4] % 4 + if template == 0: + return f""" + + + +""" + if template == 1: + return f""" + + + +""" + if template == 2: + return f""" + + + +""" + return f""" + + + +""" + + +def write_text(path: Path, value: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(value.strip() + "\n", encoding="utf-8") + + +def generate(seed: str, output_res_dir: Path) -> dict[str, str]: + data = hash_bytes(seed) + background = color_from_hash(data, 0, 0.46, 0.30) + primary = color_from_hash(data, 6, 0.42, 0.74) + secondary = color_from_hash(data, 10, 0.36, 0.58) + accent = color_from_hash(data, 14, 0.56, 0.82) + + values_dir = output_res_dir / "values" + drawable_dir = output_res_dir / "drawable" + legacy_mipmap_dir = output_res_dir / "mipmap-anydpi" + mipmap_dir = output_res_dir / "mipmap-anydpi-v26" + + write_text( + values_dir / "icon_colors_alt.xml", + f""" + + {background} + +""", + ) + + write_text( + drawable_dir / "launcher_foreground_alt.xml", + f""" + +{shape_paths(data, primary, secondary, accent)} + +""", + ) + + write_text( + drawable_dir / "ic_notification_alt.xml", + """ + + + +""", + ) + + write_text( + drawable_dir / "launcher_monochrome_alt.xml", + f""" + +{shape_paths(data, "#FFFFFFFF", "#FFFFFFFF", "#FFFFFFFF")} + +""", + ) + + adaptive_icon = """ + + + + + +""" + write_text(mipmap_dir / "ic_launcher_alt.xml", adaptive_icon) + write_text(mipmap_dir / "ic_launcher_alt_round.xml", adaptive_icon) + + legacy_icon = f""" + + +{shape_paths(data, primary, secondary, accent)} + +""" + write_text(legacy_mipmap_dir / "ic_launcher_alt.xml", legacy_icon) + write_text(legacy_mipmap_dir / "ic_launcher_alt_round.xml", legacy_icon) + + metadata = { + "seedSha256": hashlib.sha256(seed.encode("utf-8")).hexdigest(), + "background": background, + "primary": primary, + "secondary": secondary, + "accent": accent, + "launcherIcon": "@mipmap/ic_launcher_alt", + "roundLauncherIcon": "@mipmap/ic_launcher_alt_round", + "monochromeIcon": "@drawable/launcher_monochrome_alt", + "notificationIcon": "@drawable/ic_notification_alt", + } + write_text( + output_res_dir.parent / "stealth-icon-metadata.json", + json.dumps(metadata, indent=2, sort_keys=True), + ) + return metadata + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--seed", + default="", + help="private per-variant seed; defaults to STEALTH_ICON_SEED; random if omitted", + ) + parser.add_argument( + "--output-res-dir", + type=Path, + required=True, + help="Android generated resource directory", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + seed = args.seed or os.environ.get("STEALTH_ICON_SEED", "") or secrets.token_urlsafe(24) + metadata = generate(seed, args.output_res_dir) + print( + "Generated stealth Android icons:", + args.output_res_dir, + metadata["seedSha256"], + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/stealth/generate_android_icons_test.py b/scripts/stealth/generate_android_icons_test.py new file mode 100644 index 0000000000..a5eee5055d --- /dev/null +++ b/scripts/stealth/generate_android_icons_test.py @@ -0,0 +1,103 @@ +import hashlib +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from io import StringIO +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import generate_android_icons + + +class GenerateAndroidIconsTest(unittest.TestCase): + def test_generates_expected_resource_files(self): + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "res" + + metadata = generate_android_icons.generate("variant-a", out) + + self.assertEqual(metadata["launcherIcon"], "@mipmap/ic_launcher_alt") + self.assertTrue((out / "values/icon_colors_alt.xml").exists()) + self.assertTrue((out / "drawable/launcher_foreground_alt.xml").exists()) + self.assertTrue((out / "drawable/launcher_monochrome_alt.xml").exists()) + self.assertTrue((out / "drawable/ic_notification_alt.xml").exists()) + self.assertNotIn( + "#00000000", + self.read(out, "drawable/launcher_foreground_alt.xml"), + ) + self.assertIn( + '@drawable/launcher_monochrome_alt', + self.read(out, "mipmap-anydpi-v26/ic_launcher_alt.xml"), + ) + self.assertNotIn( + "#00000000", + self.read(out, "drawable/ic_notification_alt.xml"), + ) + # No "stealth" token in any generated resource name + for f in out.rglob("*"): + self.assertNotIn("stealth", f.name.lower()) + self.assertTrue((out / "mipmap-anydpi/ic_launcher_alt.xml").exists()) + self.assertTrue( + (out / "mipmap-anydpi/ic_launcher_alt_round.xml").exists() + ) + self.assertTrue((out / "mipmap-anydpi-v26/ic_launcher_alt.xml").exists()) + self.assertTrue( + (out / "mipmap-anydpi-v26/ic_launcher_alt_round.xml").exists() + ) + self.assertFalse((out / "stealth-icon-metadata.json").exists()) + self.assertTrue((out.parent / "stealth-icon-metadata.json").exists()) + + def test_generation_is_deterministic_for_same_seed(self): + with tempfile.TemporaryDirectory() as tmp: + first = Path(tmp) / "first" + second = Path(tmp) / "second" + + first_metadata = generate_android_icons.generate("variant-a", first) + second_metadata = generate_android_icons.generate("variant-a", second) + + self.assertEqual(first_metadata, second_metadata) + self.assertEqual( + (first / "drawable/launcher_foreground_alt.xml").read_text(), + (second / "drawable/launcher_foreground_alt.xml").read_text(), + ) + + def test_generation_changes_by_seed(self): + with tempfile.TemporaryDirectory() as tmp: + first = Path(tmp) / "first" + second = Path(tmp) / "second" + + first_metadata = generate_android_icons.generate("variant-a", first) + second_metadata = generate_android_icons.generate("variant-b", second) + + self.assertNotEqual( + first_metadata["seedSha256"], + second_metadata["seedSha256"], + ) + self.assertNotEqual( + (first / "drawable/launcher_foreground_alt.xml").read_text(), + (second / "drawable/launcher_foreground_alt.xml").read_text(), + ) + + def test_main_reads_seed_from_environment(self): + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "res" + + with patch.dict("os.environ", {"STEALTH_ICON_SEED": "variant-env"}): + with redirect_stdout(StringIO()): + exit_code = generate_android_icons.main( + ["--output-res-dir", str(out)] + ) + + expected_sha = hashlib.sha256(b"variant-env").hexdigest() + metadata = (out.parent / "stealth-icon-metadata.json").read_text() + self.assertEqual(exit_code, 0) + self.assertIn(expected_sha, metadata) + + def read(self, root: Path, relative_path: str) -> str: + return (root / relative_path).read_text(encoding="utf-8") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/stealth/generate_android_identity.py b/scripts/stealth/generate_android_identity.py new file mode 100755 index 0000000000..ef1309a601 --- /dev/null +++ b/scripts/stealth/generate_android_identity.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +"""Generate Android identity profiles for Stealth builds.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import random +import re +import secrets +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + + +ADJECTIVES = ( + "Clear", + "Bright", + "Quiet", + "Swift", + "Plain", + "Fresh", + "Daily", + "Open", + "Simple", + "Calm", + "True", + "Soft", +) + +NOUNS = ( + "Notes", + "Tasks", + "Pages", + "Files", + "Board", + "List", + "Desk", + "Folder", + "Memo", + "Panel", + "Space", + "Stack", +) + +PACKAGE_ROOTS = ( + "app", + "tools", + "pages", + "notes", + "desk", + "files", +) + +ANDROID_APPLICATION_ID = re.compile( + r"^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$" +) + + +@dataclass(frozen=True) +class AndroidIdentity: + application_id: str + app_label: str + launcher_label: str + identity_label: str + identity_profile_id: str + identity_metadata: str + vpn_session_name: str + notification_channel_vpn: str + notification_channel_data_usage: str + notification_title: str + notification_connected_text: str + notification_starting_text: str + notification_disconnect_action: str + quick_tile_active_label: str + quick_tile_inactive_label: str + app_icon: str + app_round_icon: str + notification_small_icon: str + quick_tile_icon: str + app_auth_scheme: str + + def as_properties(self) -> dict[str, str]: + return { + "applicationId": self.application_id, + "appLabel": self.app_label, + "launcherLabel": self.launcher_label, + "identityLabel": self.identity_label, + "identityProfileId": self.identity_profile_id, + "identityMetadata": self.identity_metadata, + "vpnSessionName": self.vpn_session_name, + "notificationChannelVpn": self.notification_channel_vpn, + "notificationChannelDataUsage": self.notification_channel_data_usage, + "notificationTitle": self.notification_title, + "notificationConnectedText": self.notification_connected_text, + "notificationStartingText": self.notification_starting_text, + "notificationDisconnectAction": self.notification_disconnect_action, + "quickTileActiveLabel": self.quick_tile_active_label, + "quickTileInactiveLabel": self.quick_tile_inactive_label, + "appIcon": self.app_icon, + "appRoundIcon": self.app_round_icon, + "notificationSmallIcon": self.notification_small_icon, + "quickTileIcon": self.quick_tile_icon, + "appAuthScheme": self.app_auth_scheme, + } + + +def _slug(value: str) -> str: + return re.sub(r"[^a-z0-9]+", "", value.lower()) + + +def _token(rng: random.Random, size: int = 8) -> str: + alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + return rng.choice("abcdefghijklmnopqrstuvwxyz") + "".join( + rng.choice(alphabet) for _ in range(size - 1) + ) + + +def _seed_material(seed: str | None) -> tuple[str, bool]: + if seed: + return seed, False + return secrets.token_hex(32), True + + +def _safe_scheme(value: str, fallback: str) -> str: + scheme = re.sub(r"[^a-z0-9+.-]+", "", value.lower()) + if not scheme or not scheme[0].isalpha(): + return fallback + return scheme + + +def _load_profile(path: Path) -> dict: + with path.open("r", encoding="utf-8") as handle: + profile = json.load(handle) + if not isinstance(profile, dict): + raise ValueError(f"profile must be a JSON object: {path}") + return profile + + +def identity_from_profile( + profile_path: Path, + app_icon: str = "@drawable/neutral_app_icon", + app_round_icon: str = "@drawable/neutral_app_icon", + notification_small_icon: str = "@drawable/neutral_notification_icon", + quick_tile_icon: str = "@drawable/neutral_notification_icon", +) -> AndroidIdentity: + profile = _load_profile(profile_path) + application_id = str(profile.get("packageName", "")).strip() + if not ANDROID_APPLICATION_ID.match(application_id): + raise ValueError(f"invalid profile packageName: {application_id}") + + label = str(profile.get("appName", "")).strip() + if not label: + raise ValueError("profile appName is required") + session_name = str(profile.get("sessionName", "")).strip() + if not session_name: + raise ValueError("profile sessionName is required") + + digest = hashlib.sha256( + json.dumps(profile, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + profile_id = str(profile.get("profileId") or digest[:16]) + metadata = json.dumps( + { + "profileId": profile_id, + }, + sort_keys=True, + separators=(",", ":"), + ) + app_auth_scheme = _safe_scheme( + f"{_slug(label)}{digest[:8]}", + f"id{digest[:8]}", + ) + + return AndroidIdentity( + application_id=application_id, + app_label=label, + launcher_label=label, + identity_label=profile_id, + identity_profile_id=profile_id, + identity_metadata=metadata, + vpn_session_name=session_name, + notification_channel_vpn=f"{label} Status", + notification_channel_data_usage=f"{label} Usage", + notification_title=label, + notification_connected_text=f"{label} is active", + notification_starting_text=f"Starting {label}...", + notification_disconnect_action=f"Stop {label}", + quick_tile_active_label=f"{label} on", + quick_tile_inactive_label=f"{label} off", + app_icon=app_icon, + app_round_icon=app_round_icon, + notification_small_icon=notification_small_icon, + quick_tile_icon=quick_tile_icon, + app_auth_scheme=app_auth_scheme, + ) + + +def generate_identity( + seed: str | None = None, + package_root: str | None = None, + app_icon: str = "@drawable/neutral_app_icon", + app_round_icon: str = "@drawable/neutral_app_icon", + notification_small_icon: str = "@drawable/neutral_notification_icon", + quick_tile_icon: str = "@drawable/neutral_notification_icon", +) -> AndroidIdentity: + seed_value, random_seed = _seed_material(seed) + digest = hashlib.sha256(seed_value.encode("utf-8")).hexdigest() + rng = random.Random(int(digest, 16)) + + adjective = rng.choice(ADJECTIVES) + noun = rng.choice(NOUNS) + label = f"{adjective} {noun}" + adjective_slug = _slug(adjective) + noun_slug = _slug(noun) + suffix = digest[:10] + profile_id = f"{adjective_slug}-{noun_slug}-{suffix}" + package_prefix = package_root or rng.choice(PACKAGE_ROOTS) + application_id = f"{package_prefix}.{adjective_slug}{noun_slug}.{_token(rng, 6)}{suffix[:6]}" + + if not ANDROID_APPLICATION_ID.match(application_id): + raise ValueError(f"invalid generated applicationId: {application_id}") + + metadata = json.dumps( + { + "profileId": profile_id, + "randomSeed": random_seed, + }, + sort_keys=True, + separators=(",", ":"), + ) + + return AndroidIdentity( + application_id=application_id, + app_label=label, + launcher_label=label, + identity_label=profile_id, + identity_profile_id=profile_id, + identity_metadata=metadata, + vpn_session_name=f"{label} Session", + notification_channel_vpn=f"{label} Status", + notification_channel_data_usage=f"{label} Usage", + notification_title=label, + notification_connected_text=f"{label} is active", + notification_starting_text=f"Starting {label}...", + notification_disconnect_action=f"Stop {label}", + quick_tile_active_label=f"{label} on", + quick_tile_inactive_label=f"{label} off", + app_icon=app_icon, + app_round_icon=app_round_icon, + notification_small_icon=notification_small_icon, + quick_tile_icon=quick_tile_icon, + app_auth_scheme=f"{adjective_slug}{noun_slug}{suffix[:8]}", + ) + + +def write_properties(identity: AndroidIdentity, output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + lines = [ + "# Generated Android identity profile. Do not commit generated profiles.", + ] + for key, value in identity.as_properties().items(): + lines.append(f"{key}={_escape_properties(value)}") + output.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _escape_properties(value: str) -> str: + return ( + value.replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("\r", "\\r") + ) + + +def parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Generate a Stealth Android identity properties file." + ) + parser.add_argument( + "--output", + required=True, + type=Path, + help="Path to write the generated .properties profile.", + ) + parser.add_argument( + "--seed", + help="Optional deterministic seed. Omit to generate a fresh random identity.", + ) + parser.add_argument( + "--profile", + type=Path, + help="Optional stealth profile JSON to use as the Android package, app, and session identity source.", + ) + parser.add_argument( + "--package-root", + help="Optional first applicationId segment. Defaults to a neutral generated root.", + ) + parser.add_argument("--app-icon", default="@drawable/neutral_app_icon") + parser.add_argument("--app-round-icon", default="@drawable/neutral_app_icon") + parser.add_argument( + "--notification-small-icon", default="@drawable/neutral_notification_icon" + ) + parser.add_argument("--quick-tile-icon", default="@drawable/neutral_notification_icon") + return parser.parse_args(argv) + + +def main(argv: Iterable[str] | None = None) -> int: + args = parse_args(argv) + if args.profile: + identity = identity_from_profile( + profile_path=args.profile, + app_icon=args.app_icon, + app_round_icon=args.app_round_icon, + notification_small_icon=args.notification_small_icon, + quick_tile_icon=args.quick_tile_icon, + ) + else: + identity = generate_identity( + seed=args.seed, + package_root=args.package_root, + app_icon=args.app_icon, + app_round_icon=args.app_round_icon, + notification_small_icon=args.notification_small_icon, + quick_tile_icon=args.quick_tile_icon, + ) + write_properties(identity, args.output) + print(f"Wrote Android identity profile: {args.output}") + print(f"applicationId={identity.application_id}") + print(f"appLabel={identity.app_label}") + print(f"identityProfileId={identity.identity_profile_id}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/stealth/generate_android_identity_test.py b/scripts/stealth/generate_android_identity_test.py new file mode 100755 index 0000000000..9560bd6e1f --- /dev/null +++ b/scripts/stealth/generate_android_identity_test.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +import re +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import generate_android_identity as generator + + +class AndroidIdentityGeneratorTest(unittest.TestCase): + def test_seeded_generation_is_deterministic(self) -> None: + first = generator.generate_identity(seed="release-2026-05-15") + second = generator.generate_identity(seed="release-2026-05-15") + + self.assertEqual(first, second) + self.assertNotEqual(first.application_id, "org.getlantern.lantern") + self.assertNotIn("Lantern", first.app_label) + self.assertNotIn("VPN", first.notification_connected_text) + self.assertIn(first.app_label, first.notification_connected_text) + self.assertIn(first.app_label, first.quick_tile_active_label) + self.assertEqual(first.quick_tile_icon, "@drawable/neutral_notification_icon") + self.assertRegex( + first.application_id, + re.compile(r"^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$"), + ) + + def test_different_seeds_generate_different_install_identities(self) -> None: + first = generator.generate_identity(seed="one") + second = generator.generate_identity(seed="two") + + self.assertNotEqual(first.application_id, second.application_id) + self.assertNotEqual(first.identity_profile_id, second.identity_profile_id) + self.assertNotEqual(first.notification_connected_text, second.notification_connected_text) + self.assertNotEqual(first.quick_tile_active_label, second.quick_tile_active_label) + + def test_random_generation_changes_identity(self) -> None: + first = generator.generate_identity() + second = generator.generate_identity() + + self.assertNotEqual(first.application_id, second.application_id) + self.assertNotEqual(first.identity_profile_id, second.identity_profile_id) + + def test_writes_gradle_properties_profile(self) -> None: + identity = generator.generate_identity(seed="properties") + + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "identity.properties" + generator.write_properties(identity, output) + content = output.read_text(encoding="utf-8") + + self.assertIn(f"applicationId={identity.application_id}", content) + self.assertIn(f"appLabel={identity.app_label}", content) + self.assertIn(f"quickTileIcon={identity.quick_tile_icon}", content) + metadata_line = next( + line for line in content.splitlines() if line.startswith("identityMetadata=") + ) + metadata = json.loads(metadata_line.split("=", 1)[1]) + # generator and seedFingerprint removed (72c39f03c) to avoid leaking + # stealth-identifying metadata in the shipped identity bundle. + self.assertNotIn("generator", metadata) + self.assertNotIn("seedFingerprint", metadata) + self.assertEqual(metadata["profileId"], identity.identity_profile_id) + self.assertEqual(metadata["randomSeed"], False) + + def test_profile_controls_android_identity(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + profile_path = Path(tmp) / "profile.json" + profile_path.write_text( + json.dumps( + { + "profileId": "stl_reader_vault", + "mode": "stealth-novpn", + "packageName": "com.readervault.mobile.s202605181146", + "appName": "Reader Vault", + "sessionName": "ReaderVaultSession", + } + ), + encoding="utf-8", + ) + + identity = generator.identity_from_profile(profile_path) + + self.assertEqual( + identity.application_id, + "com.readervault.mobile.s202605181146", + ) + self.assertEqual(identity.app_label, "Reader Vault") + self.assertEqual(identity.vpn_session_name, "ReaderVaultSession") + self.assertEqual(identity.identity_profile_id, "stl_reader_vault") + self.assertNotIn("VPN", identity.notification_connected_text) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/stealth/generate_profile.py b/scripts/stealth/generate_profile.py new file mode 100644 index 0000000000..e5074f7594 --- /dev/null +++ b/scripts/stealth/generate_profile.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""Generate and validate Stealth Lantern build profiles.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import secrets +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +SCHEMA_VERSION = 1 +MAX_ANDROID_INT = 2_147_483_647 +VALID_MODES = ("stealth-vpn", "stealth-novpn") +MODE_ALIASES = { + "vpn": "stealth-vpn", + "novpn": "stealth-novpn", +} +DEFAULTS = { + "denylistVersion": 0, + "directConnectionAppsEnabled": False, +} +DART_DEFINE_KEYS = { + "mode": "STEALTH_MODE", + "packageName": "STEALTH_PACKAGE_NAME", + "appName": "STEALTH_APP_NAME", + "sessionName": "STEALTH_SESSION_NAME", + "denylistVersion": "STEALTH_DENYLIST_VERSION", + "directConnectionAppsEnabled": "STEALTH_DIRECT_CONNECTION_APPS", +} +ARTIFACT_METADATA_KEYS = ( + "appName", + "denylistVersion", + "directConnectionAppsEnabled", + "generatedAt", + "mode", + "packageName", + "profileId", + "schemaVersion", + "sessionName", +) +PACKAGE_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$") +XML_ATTRIBUTE_RESERVED = set("\"'&<>") +# De-branding privacy rule: stealth identity names must not leak Lantern +# branding into shipped artifacts (manifest labels, notification text, ...). +BRANDED_TOKEN_RE = re.compile(r"lantern", re.IGNORECASE) + + +class ProfileError(ValueError): + pass + + +def load_json(path: Path) -> dict[str, Any]: + try: + with path.open("r", encoding="utf-8") as handle: + value = json.load(handle) + except OSError as exc: + raise ProfileError(f"failed to read profile input {path}: {exc}") from exc + except json.JSONDecodeError as exc: + raise ProfileError(f"invalid JSON in {path}: {exc}") from exc + if not isinstance(value, dict): + raise ProfileError(f"profile input {path} must be a JSON object") + return value + + +def write_json(path: Path, value: dict[str, Any]) -> None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(value, handle, indent=2, sort_keys=True) + handle.write("\n") + except OSError as exc: + raise ProfileError(f"failed to write JSON output {path}: {exc}") from exc + + +def new_profile_id() -> str: + return f"stl_{secrets.token_hex(6)}" + + +def default_package_name(profile_id: str) -> str: + suffix = profile_id[4:] if profile_id.startswith("stl_") else profile_id + return f"com.s{suffix}.app" + + +def coerce_denylist_version(value: Any) -> int: + if isinstance(value, bool): + raise ProfileError("denylistVersion must be a non-negative integer") + if isinstance(value, int): + version = value + elif isinstance(value, str) and re.fullmatch(r"\d+", value.strip()): + version = int(value.strip()) + else: + raise ProfileError("denylistVersion must be a non-negative integer") + if version < 0: + raise ProfileError("denylistVersion must be a non-negative integer") + if version > MAX_ANDROID_INT: + raise ProfileError("denylistVersion must fit in a 32-bit signed integer") + return version + + +def coerce_schema_version(value: Any) -> int: + if isinstance(value, bool): + raise ProfileError("schemaVersion must be an integer") + if isinstance(value, int): + version = value + elif isinstance(value, str) and re.fullmatch(r"\d+", value.strip()): + version = int(value.strip()) + else: + raise ProfileError("schemaVersion must be an integer") + if version != SCHEMA_VERSION: + raise ProfileError( + f"unsupported schemaVersion {version}; expected {SCHEMA_VERSION}" + ) + return version + + +def validate_manifest_placeholder_text(field: str, value: str) -> str: + if not value: + raise ProfileError(f"{field} is required") + if any( + char in XML_ATTRIBUTE_RESERVED or ord(char) < 32 or 0x7F <= ord(char) <= 0x9F + for char in value + ): + raise ProfileError( + f"{field} must not contain XML-reserved or control characters" + ) + return value + + +def validate_non_branded_text(field: str, value: str) -> str: + if BRANDED_TOKEN_RE.search(value): + raise ProfileError(f"{field} must not contain Lantern-branded text") + return value + + +def normalize_mode(value: Any) -> str: + mode = str(value or "").strip() + return MODE_ALIASES.get(mode, mode) + + +def validate_profile(profile: dict[str, Any]) -> dict[str, Any]: + schema_version = coerce_schema_version(profile.get("schemaVersion", SCHEMA_VERSION)) + + mode = normalize_mode(profile.get("mode", "")) + if mode not in VALID_MODES: + raise ProfileError(f"mode must be one of: {', '.join(VALID_MODES)}") + + package_name = str(profile.get("packageName", "")).strip() + if not PACKAGE_RE.fullmatch(package_name): + raise ProfileError( + "packageName must be a valid Android application ID with at least two segments" + ) + + app_name = str(profile.get("appName", "")).strip() + validate_manifest_placeholder_text("appName", app_name) + validate_non_branded_text("appName", app_name) + + session_name = str(profile.get("sessionName", "")).strip() + validate_manifest_placeholder_text("sessionName", session_name) + validate_non_branded_text("sessionName", session_name) + + go_obfuscation_seed = str( + profile.get("goObfuscationSeed") or profile.get("obfuscationSeed", "") + ).strip() + if not go_obfuscation_seed: + raise ProfileError("goObfuscationSeed is required") + + # directConnectionAppsEnabled: on by default for all stealth modes (security + # gate — the RKS denylist must be active unless explicitly disabled). + raw_dca = profile.get("directConnectionAppsEnabled") + if raw_dca is None: + direct_connection_apps_enabled = mode in VALID_MODES + elif isinstance(raw_dca, bool): + direct_connection_apps_enabled = raw_dca + else: + raise ProfileError("directConnectionAppsEnabled must be a boolean") + + normalized = dict(profile) + normalized["mode"] = mode + normalized["packageName"] = package_name + normalized["appName"] = app_name + normalized["sessionName"] = session_name + normalized.pop("nativeLibraryName", None) + normalized.pop("obfuscationSeed", None) + normalized["goObfuscationSeed"] = go_obfuscation_seed + normalized["denylistVersion"] = coerce_denylist_version( + profile.get("denylistVersion", DEFAULTS["denylistVersion"]) + ) + normalized["directConnectionAppsEnabled"] = direct_connection_apps_enabled + normalized["schemaVersion"] = schema_version + return normalized + + +def build_profile(args: argparse.Namespace) -> dict[str, Any]: + loaded = {} + if args.input: + loaded = load_json(args.input) + elif args.output and args.output.exists(): + loaded = load_json(args.output) + + profile_id = str(loaded.get("profileId") or new_profile_id()) + generated_at = str( + loaded.get("generatedAt") + or datetime.now(timezone.utc).replace(microsecond=0).isoformat() + ) + + mode = normalize_mode(args.mode or loaded.get("mode", "")) + + app_name = args.app_name or loaded.get("appName") or "" + session_name = args.session_name or loaded.get("sessionName") or "" + + if mode in VALID_MODES: + if not app_name: + raise ProfileError( + "--app-name is required for stealth builds and must not be a Lantern-branded value" + ) + if not session_name: + raise ProfileError( + "--session-name is required for stealth builds and must not be a Lantern-branded value" + ) + + profile = { + "schemaVersion": loaded.get("schemaVersion", SCHEMA_VERSION), + "profileId": profile_id, + "generatedAt": generated_at, + "mode": mode or None, + "packageName": args.package_name + or loaded.get("packageName") + or default_package_name(profile_id), + "appName": app_name, + "sessionName": session_name, + "goObfuscationSeed": args.go_obfuscation_seed + or loaded.get("goObfuscationSeed") + or loaded.get("obfuscationSeed") + or secrets.token_hex(16), + "denylistVersion": ( + args.denylist_version + if args.denylist_version is not None + else loaded.get("denylistVersion", DEFAULTS["denylistVersion"]) + ), + } + + for key, value in loaded.items(): + profile.setdefault(key, value) + + return validate_profile(profile) + + +def dart_defines(profile: dict[str, Any]) -> dict[str, str]: + defines = {} + for key, define in DART_DEFINE_KEYS.items(): + value = profile[key] + # Emit booleans as lowercase strings for dart-defines / BuildConfig + defines[define] = "true" if value is True else ("false" if value is False else str(value)) + defines["STEALTH_NO_VPN"] = ( + "true" if profile["mode"] == "stealth-novpn" else "false" + ) + return defines + + +def artifact_metadata(profile: dict[str, Any]) -> dict[str, Any]: + metadata = {key: profile[key] for key in ARTIFACT_METADATA_KEYS if key in profile} + metadata["goObfuscationSeedSha256"] = hashlib.sha256( + str(profile["goObfuscationSeed"]).encode("utf-8") + ).hexdigest() + return metadata + + +def go_tags_suffix(profile: dict[str, Any]) -> str: + # `stealth` strips OAuth/brand exports in lantern-core. The no-VPN data path + # is selected in radiance by the `novpn` build tag (radiance gates its + # SOCKS-only inbounds/split-tunnel on `novpn` vs `!novpn`); the VPN build + # omits it so radiance compiles its default TUN tunnel. + tags = ["stealth"] + if str(profile["mode"]) == "stealth-novpn": + tags.append("novpn") + return "," + ",".join(tags) + + +def load_go_tags_profile(args: argparse.Namespace) -> dict[str, Any]: + if not args.input: + raise ProfileError("--input is required when using --go-tags-suffix") + return validate_profile(load_json(args.input)) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input", type=Path, help="optional input profile JSON") + parser.add_argument("--output", type=Path, help="normalized profile output path") + parser.add_argument("--dart-defines-output", type=Path) + parser.add_argument("--artifact-metadata-output", type=Path) + parser.add_argument("--go-tags-suffix", action="store_true") + parser.add_argument("--mode", choices=VALID_MODES + tuple(MODE_ALIASES)) + parser.add_argument("--package-name") + parser.add_argument("--app-name") + parser.add_argument("--session-name") + parser.add_argument("--go-obfuscation-seed") + parser.add_argument("--denylist-version", type=int) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + try: + profile = load_go_tags_profile(args) if args.go_tags_suffix else build_profile(args) + if args.go_tags_suffix: + print(go_tags_suffix(profile), end="") + return 0 + if not args.output: + raise ProfileError("--output is required when generating a profile") + write_json(args.output, profile) + if args.dart_defines_output: + write_json(args.dart_defines_output, dart_defines(profile)) + if args.artifact_metadata_output: + write_json(args.artifact_metadata_output, artifact_metadata(profile)) + print(f"Wrote stealth profile: {args.output}") + return 0 + except ProfileError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/stealth/generate_profile_test.py b/scripts/stealth/generate_profile_test.py new file mode 100644 index 0000000000..da1de6e461 --- /dev/null +++ b/scripts/stealth/generate_profile_test.py @@ -0,0 +1,295 @@ +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import generate_profile + + +class GenerateProfileTest(unittest.TestCase): + def test_generates_profile_outputs_and_redacts_seed_metadata(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + profile_path = root / "profile.json" + defines_path = root / "dart-defines.json" + metadata_path = root / "metadata.json" + + exit_code = generate_profile.main( + [ + "--mode", + "stealth-vpn", + "--package-name", + "org.example.safe.s123", + "--app-name", + "Beacon", + "--session-name", + "BeaconLink", + "--go-obfuscation-seed", + "seed-for-test", + "--denylist-version", + "7", + "--output", + str(profile_path), + "--dart-defines-output", + str(defines_path), + "--artifact-metadata-output", + str(metadata_path), + ] + ) + + self.assertEqual(exit_code, 0) + + profile = json.loads(profile_path.read_text()) + self.assertEqual(profile["mode"], "stealth-vpn") + self.assertEqual(profile["packageName"], "org.example.safe.s123") + self.assertEqual(profile["appName"], "Beacon") + self.assertEqual(profile["sessionName"], "BeaconLink") + self.assertNotIn("nativeLibraryName", profile) + self.assertEqual(profile["goObfuscationSeed"], "seed-for-test") + self.assertEqual(profile["denylistVersion"], 7) + + defines = json.loads(defines_path.read_text()) + self.assertEqual(defines["STEALTH_MODE"], "stealth-vpn") + self.assertEqual(defines["STEALTH_NO_VPN"], "false") + self.assertEqual( + defines["STEALTH_PACKAGE_NAME"], "org.example.safe.s123" + ) + # directConnectionAppsEnabled defaults to True for stealth-vpn + self.assertEqual(defines["STEALTH_DIRECT_CONNECTION_APPS"], "true") + self.assertNotIn("STEALTH_NATIVE_LIBRARY_NAME", defines) + self.assertNotIn("STEALTH_GO_OBFUSCATION_SEED", defines) + + metadata = json.loads(metadata_path.read_text()) + self.assertNotIn("goObfuscationSeed", metadata) + self.assertNotIn("unexpectedPrivateField", metadata) + self.assertIn("goObfuscationSeedSha256", metadata) + + def test_go_tags_suffix_from_profile(self): + profile = { + "mode": "stealth-novpn", + "packageName": "org.example.safe.s456", + "appName": "Beacon", + "sessionName": "BeaconLink", + "goObfuscationSeed": "seed-for-test", + "denylistVersion": 0, + "unexpectedPrivateField": "do-not-export", + } + + normalized = generate_profile.validate_profile(profile) + + self.assertEqual(normalized["mode"], "stealth-novpn") + defines = generate_profile.dart_defines(normalized) + self.assertEqual(defines["STEALTH_NO_VPN"], "true") + # directConnectionAppsEnabled defaults to True for stealth-novpn + self.assertEqual(defines["STEALTH_DIRECT_CONNECTION_APPS"], "true") + self.assertEqual( + generate_profile.go_tags_suffix(normalized), + ",stealth,novpn", + ) + metadata = generate_profile.artifact_metadata(normalized) + self.assertNotIn("unexpectedPrivateField", metadata) + self.assertIn("directConnectionAppsEnabled", metadata) + self.assertTrue(metadata["directConnectionAppsEnabled"]) + + def test_accepts_mode_aliases(self): + profile = { + "mode": "novpn", + "packageName": "org.example.safe.s456", + "appName": "Beacon", + "sessionName": "BeaconLink", + "goObfuscationSeed": "seed-for-test", + "denylistVersion": 0, + } + + normalized = generate_profile.validate_profile(profile) + + self.assertEqual(normalized["mode"], "stealth-novpn") + + def test_go_tags_suffix_requires_input(self): + exit_code = generate_profile.main(["--go-tags-suffix"]) + + self.assertEqual(exit_code, 2) + + def test_rejects_invalid_package_name(self): + profile = { + "mode": "stealth-vpn", + "packageName": "bad package", + "appName": "Beacon", + "sessionName": "BeaconLink", + "goObfuscationSeed": "seed-for-test", + "denylistVersion": 0, + } + + with self.assertRaises(generate_profile.ProfileError): + generate_profile.validate_profile(profile) + + def test_rejects_unsupported_schema_version(self): + profile = { + "schemaVersion": generate_profile.SCHEMA_VERSION + 1, + "mode": "stealth-vpn", + "packageName": "org.example.safe.s123", + "appName": "Beacon", + "sessionName": "BeaconLink", + "goObfuscationSeed": "seed-for-test", + "denylistVersion": 0, + } + + with self.assertRaises(generate_profile.ProfileError): + generate_profile.validate_profile(profile) + + def test_rejects_boolean_denylist_version(self): + profile = { + "mode": "stealth-vpn", + "packageName": "org.example.safe.s123", + "appName": "Beacon", + "sessionName": "BeaconLink", + "goObfuscationSeed": "seed-for-test", + "denylistVersion": True, + } + + with self.assertRaises(generate_profile.ProfileError): + generate_profile.validate_profile(profile) + + def test_rejects_float_denylist_version(self): + profile = { + "mode": "stealth-vpn", + "packageName": "org.example.safe.s123", + "appName": "Beacon", + "sessionName": "BeaconLink", + "goObfuscationSeed": "seed-for-test", + "denylistVersion": 1.9, + } + + with self.assertRaises(generate_profile.ProfileError): + generate_profile.validate_profile(profile) + + def test_rejects_denylist_version_outside_android_int_range(self): + profile = { + "mode": "stealth-vpn", + "packageName": "org.example.safe.s123", + "appName": "Beacon", + "sessionName": "BeaconLink", + "goObfuscationSeed": "seed-for-test", + "denylistVersion": generate_profile.MAX_ANDROID_INT + 1, + } + + with self.assertRaises(generate_profile.ProfileError): + generate_profile.validate_profile(profile) + + def test_write_json_wraps_os_errors(self): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(generate_profile.ProfileError): + generate_profile.write_json(Path(tmp), {}) + + def test_rejects_manifest_placeholder_xml_characters(self): + profile = { + "mode": "stealth-vpn", + "packageName": "org.example.safe.s123", + "appName": 'Bad "Label"', + "sessionName": "BeaconLink", + "goObfuscationSeed": "seed-for-test", + "denylistVersion": 0, + } + + with self.assertRaises(generate_profile.ProfileError): + generate_profile.validate_profile(profile) + + def test_rejects_manifest_placeholder_iso_control_characters(self): + profile = { + "mode": "stealth-vpn", + "packageName": "org.example.safe.s123", + "appName": "Bad\x7fLabel", + "sessionName": "BeaconLink", + "goObfuscationSeed": "seed-for-test", + "denylistVersion": 0, + } + + with self.assertRaises(generate_profile.ProfileError): + generate_profile.validate_profile(profile) + + def test_default_package_name_is_neutral(self): + """default_package_name must not embed 'lantern' or 'stealth' tokens.""" + pkg = generate_profile.default_package_name("stl_abcdef") + self.assertNotIn("lantern", pkg.lower()) + self.assertNotIn("stealth", pkg.lower()) + # Must still be a valid Android application ID + import re + self.assertRegex(pkg, r"^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$") + + def test_stealth_build_requires_app_name(self): + """build_profile must fail-fast when --app-name is absent for stealth modes.""" + with tempfile.TemporaryDirectory() as tmp: + exit_code = generate_profile.main( + [ + "--mode", + "stealth-vpn", + "--package-name", + "org.example.safe.s123", + "--session-name", + "BeaconLink", + "--output", + str(Path(tmp) / "profile.json"), + ] + ) + self.assertNotEqual(exit_code, 0) + + def test_stealth_build_requires_session_name(self): + """build_profile must fail-fast when --session-name is absent for stealth modes.""" + with tempfile.TemporaryDirectory() as tmp: + exit_code = generate_profile.main( + [ + "--mode", + "stealth-vpn", + "--package-name", + "org.example.safe.s123", + "--app-name", + "Beacon", + "--output", + str(Path(tmp) / "profile.json"), + ] + ) + self.assertNotEqual(exit_code, 0) + + def test_stealth_build_rejects_branded_app_name(self): + """De-branding rule: a Lantern-branded --app-name must fail validation.""" + with tempfile.TemporaryDirectory() as tmp: + exit_code = generate_profile.main( + [ + "--mode", + "stealth-vpn", + "--package-name", + "org.example.safe.s123", + "--app-name", + "Lantern", + "--session-name", + "BeaconLink", + "--output", + str(Path(tmp) / "profile.json"), + ] + ) + self.assertNotEqual(exit_code, 0) + + def test_stealth_build_rejects_branded_session_name(self): + """De-branding rule: a Lantern-branded --session-name must fail validation.""" + with tempfile.TemporaryDirectory() as tmp: + exit_code = generate_profile.main( + [ + "--mode", + "stealth-vpn", + "--package-name", + "org.example.safe.s123", + "--app-name", + "Beacon", + "--session-name", + "LanternVpn", + "--output", + str(Path(tmp) / "profile.json"), + ] + ) + self.assertNotEqual(exit_code, 0) + + +if __name__ == "__main__": + unittest.main()