From f3b5e632222243e9a01127b588bb698f43cb90f3 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Sat, 8 Aug 2026 18:52:52 +0300 Subject: [PATCH] Verify Play-delivered Android installs --- android/play-store/closed-test.md | 17 +++ android/play-store/release-checklist.md | 5 + docs/operations.md | 18 +++ scripts/quality.sh | 95 ++++++++++++ scripts/verify-play-installed-android.sh | 184 +++++++++++++++++++++++ 5 files changed, 319 insertions(+) create mode 100755 scripts/verify-play-installed-android.sh diff --git a/android/play-store/closed-test.md b/android/play-store/closed-test.md index 9ec3ccc..0f51943 100644 --- a/android/play-store/closed-test.md +++ b/android/play-store/closed-test.md @@ -19,6 +19,23 @@ before production access can be requested. 7. Start with an internal test on the owner’s device, then promote the verified build to the closed track. +After installing from the internal-track opt-in link, verify the delivery +boundary before testing authenticated flows: + +```bash +./scripts/verify-play-installed-android.sh \ + /secure/downloads/play-identities.json \ + DEVICE_SERIAL \ + 1 \ + 0.1.0 +``` + +The verifier is read-only. It requires the Google Play installer, one of the +recorded Play App Signing SHA-256 identities, the exact expected version, and a +verified `whoneedhelp.com` App Link that resolves to `MainActivity`. A locally +sideloaded APK intentionally fails this gate even if its UI and package name +look correct. + ## Tester cohort - Recruit at least 12 real people with Google or Google Workspace accounts. diff --git a/android/play-store/release-checklist.md b/android/play-store/release-checklist.md index 62bc019..ca57ed2 100644 --- a/android/play-store/release-checklist.md +++ b/android/play-store/release-checklist.md @@ -98,6 +98,11 @@ ## Testing - [ ] Internal track smoke test passed. + Before exercising product flows, run + `scripts/verify-play-installed-android.sh` with the protected Play + identity document, physical-device serial, and exact expected version. + It must confirm the Google Play installer, Play signing identity, + verified production App Link, and `MainActivity` resolution. - [ ] Closed track created and opt-in link tested. - [ ] At least 12 testers continuously opted in for 14 days. - [ ] Tester feedback and fixes documented. diff --git a/docs/operations.md b/docs/operations.md index 2bbdd61..d9c97c6 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -301,6 +301,24 @@ an unmatched or duplicate certificate, and a Firebase project inconsistent with the configured FCM service account. Provider files remain on disk after the import and must be stored or removed deliberately. +Once the production association has been deployed and the internal-track build +has been installed from Google Play, prove that the physical device is not +still running a sideloaded upload-key build: + +```bash +./scripts/verify-play-installed-android.sh \ + /secure/downloads/play-identities.json \ + DEVICE_SERIAL \ + EXPECTED_VERSION_CODE \ + EXPECTED_VERSION_NAME +``` + +The command does not launch, install, uninstall, clear, or reconfigure the app. +It checks the installer, installed version, Play signing identity, Android's +domain-verification state, and implicit production App Link resolution. Passing +it is a prerequisite for the later authenticated Play-delivered smoke test, not +a substitute for that test. + The VAPID helper runs the exact locked `web_push_elixir` generator in an isolated, network-disabled container, imports the result atomically, removes its one-run image tag and temporary files, and never prints either key. It diff --git a/scripts/quality.sh b/scripts/quality.sh index 13e5048..5331e62 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -528,6 +528,101 @@ if ./scripts/import-play-android-config.sh \ fi test "$(sha256sum "$play_env" | awk '{print $1}')" = "$play_hash_after" +echo "Checking Google Play installed Android verification" +fake_play_adb="$scan_dir/fake-play-adb" +cat >"$fake_play_adb" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [[ ${1:-} != -s || ${2:-} != quality-play-device ]]; then + echo "unexpected device selection" >&2 + exit 1 +fi +shift 2 + +case "${1:-} ${2:-} ${3:-}" in + "get-state ") + printf 'device\n' + ;; + "shell pm path") + printf 'package:/data/app/quality/base.apk\n' + ;; + "shell dumpsys package") + cat <&2 + exit 1 + ;; +esac +EOF +chmod 700 "$fake_play_adb" + +play_device_output=$( + WNH_ADB_BIN="$fake_play_adb" \ + FAKE_PLAY_SIGNATURE="$play_sha256_two_colon" \ + ./scripts/verify-play-installed-android.sh \ + "$play_identities" quality-play-device 1 0.1.0 +) +printf '%s' "$play_device_output" | + grep -F 'Google Play installed Android verification passed.' >/dev/null +if printf '%s' "$play_device_output" | + grep -F "$play_sha256_two_colon" >/dev/null; then + echo "Play-installed verifier printed a signing fingerprint." >&2 + exit 1 +fi + +if WNH_ADB_BIN="$fake_play_adb" \ + FAKE_PLAY_SIGNATURE="$play_sha256_two_colon" \ + FAKE_PLAY_INSTALLER=null \ + ./scripts/verify-play-installed-android.sh \ + "$play_identities" quality-play-device 1 0.1.0 >/dev/null 2>&1; then + echo "Play-installed verifier accepted a sideloaded package." >&2 + exit 1 +fi + +if WNH_ADB_BIN="$fake_play_adb" \ + FAKE_PLAY_SIGNATURE="$play_upload_colon" \ + ./scripts/verify-play-installed-android.sh \ + "$play_identities" quality-play-device 1 0.1.0 >/dev/null 2>&1; then + echo "Play-installed verifier accepted the upload certificate as a Play identity." >&2 + exit 1 +fi + +if WNH_ADB_BIN="$fake_play_adb" \ + FAKE_PLAY_SIGNATURE="$play_sha256_one_colon" \ + FAKE_PLAY_DOMAIN_STATE=none \ + ./scripts/verify-play-installed-android.sh \ + "$play_identities" quality-play-device 1 0.1.0 >/dev/null 2>&1; then + echo "Play-installed verifier accepted an unverified production App Link." >&2 + exit 1 +fi + +if WNH_ADB_BIN="$fake_play_adb" \ + FAKE_PLAY_SIGNATURE="$play_sha256_one_colon" \ + FAKE_PLAY_ACTIVITY=com.android.browser/.BrowserActivity \ + ./scripts/verify-play-installed-android.sh \ + "$play_identities" quality-play-device 1 0.1.0 >/dev/null 2>&1; then + echo "Play-installed verifier accepted browser App Link resolution." >&2 + exit 1 +fi + echo "Checking Android environment isolation" ./scripts/android-play-policy-check.sh >/dev/null android_fingerprint=D7:C4:F1:12:4D:F4:68:E5:B3:54:DE:D8:96:E8:01:51:29:41:F1:8A:71:0C:18:B0:E7:98:AA:2B:81:DA:11:DF diff --git a/scripts/verify-play-installed-android.sh b/scripts/verify-play-installed-android.sh new file mode 100755 index 0000000..6d69dd6 --- /dev/null +++ b/scripts/verify-play-installed-android.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) + +usage() { + cat >&2 <<'EOF' +Usage: verify-play-installed-android.sh PLAY_IDENTITIES_JSON DEVICE_SERIAL EXPECTED_VERSION_CODE EXPECTED_VERSION_NAME + +Verifies, without changing the device, that org.whoneedhelp.mobile was +installed by Google Play, is signed by one of the supplied Play App Signing +SHA-256 identities, has the expected version, and owns the verified production +App Link. +EOF +} + +if [[ $# -ne 4 ]]; then + usage + exit 2 +fi + +identities_file=$1 +device_serial=$2 +expected_version_code=$3 +expected_version_name=$4 +package_name=org.whoneedhelp.mobile +app_link_host=whoneedhelp.com +app_link_url=https://whoneedhelp.com/safety +expected_activity=org.whoneedhelp.mobile/.MainActivity +adb_bin=${WNH_ADB_BIN:-adb} + +if [[ "$identities_file" != /* ]]; then + identities_file="$ROOT/$identities_file" +fi + +for command in jq sed tr; do + command -v "$command" >/dev/null 2>&1 || { + echo "Required command is unavailable: $command" >&2 + exit 1 + } +done +command -v "$adb_bin" >/dev/null 2>&1 || { + echo "adb is unavailable: $adb_bin" >&2 + exit 1 +} + +[[ -f "$identities_file" && ! -L "$identities_file" ]] || { + echo "Play identities must be a regular non-symlink file: $identities_file" >&2 + exit 1 +} +case "$(stat -c '%a' "$identities_file")" in + 400 | 600) ;; + *) + echo "Play identities must have mode 0400 or 0600: $identities_file" >&2 + exit 1 + ;; +esac + +[[ -n "$device_serial" && "$device_serial" != *$'\n'* && "$device_serial" != *$'\r'* ]] || { + echo "DEVICE_SERIAL must be a non-empty single-line value." >&2 + exit 1 +} +[[ "$expected_version_code" =~ ^[1-9][0-9]*$ ]] || { + echo "EXPECTED_VERSION_CODE must be a positive integer." >&2 + exit 1 +} +[[ -n "$expected_version_name" && "$expected_version_name" != *$'\n'* && "$expected_version_name" != *$'\r'* ]] || { + echo "EXPECTED_VERSION_NAME must be a non-empty single-line value." >&2 + exit 1 +} + +if ! jq --exit-status --arg package "$package_name" ' + def valid_sha256: + test("^[0-9A-Fa-f]{64}$|^([0-9A-Fa-f]{2}:){31}[0-9A-Fa-f]{2}$"); + def normalized_sha256: + ascii_upcase | gsub(":"; ""); + + (.package_name == $package) + and (.identities | type == "array" and length > 0) + and all( + .identities[]; + (.sha256 | type == "string" and valid_sha256) + ) + and (([.identities[].sha256 | normalized_sha256] | unique | length) + == (.identities | length)) +' "$identities_file" >/dev/null; then + echo "Play identities are incomplete, malformed, duplicated, or belong to another package." >&2 + exit 1 +fi + +mapfile -t expected_fingerprints < <( + jq --raw-output '.identities[].sha256 | ascii_upcase | gsub(":"; "")' \ + "$identities_file" +) + +adb_device() { + "$adb_bin" -s "$device_serial" "$@" +} + +[[ "$(adb_device get-state 2>/dev/null | tr -d '\r')" == device ]] || { + echo "The selected Android device is not connected and authorised." >&2 + exit 1 +} + +package_path=$(adb_device shell pm path "$package_name" 2>/dev/null | tr -d '\r') +[[ "$package_path" == package:* ]] || { + echo "$package_name is not installed on the selected device." >&2 + exit 1 +} + +package_report=$(adb_device shell dumpsys package "$package_name") +observed_version_code=$( + sed -n 's/.*versionCode=\([0-9][0-9]*\).*/\1/p' <<<"$package_report" | head -n 1 +) +observed_version_name=$( + sed -n 's/^[[:space:]]*versionName=\(.*\)$/\1/p' <<<"$package_report" | head -n 1 | tr -d '\r' +) +installer=$( + sed -n 's/^[[:space:]]*installerPackageName=\(.*\)$/\1/p' <<<"$package_report" | head -n 1 | tr -d '\r' +) + +[[ "$observed_version_code" == "$expected_version_code" ]] || { + echo "Installed versionCode does not match the expected Play release." >&2 + exit 1 +} +[[ "$observed_version_name" == "$expected_version_name" ]] || { + echo "Installed versionName does not match the expected Play release." >&2 + exit 1 +} +[[ "$installer" == com.android.vending ]] || { + echo "The installed package was not delivered by Google Play." >&2 + exit 1 +} + +links_report=$(adb_device shell pm get-app-links "$package_name") +signature_line=$( + sed -n 's/^[[:space:]]*Signatures: \[\(.*\)\][[:space:]]*$/\1/p' \ + <<<"$links_report" | head -n 1 +) +[[ -n "$signature_line" ]] || { + echo "Android did not report a signing identity for the installed package." >&2 + exit 1 +} + +signature_match=false +IFS=',' read -r -a observed_signatures <<<"$signature_line" +for observed_signature in "${observed_signatures[@]}"; do + observed_compact=$(printf '%s' "$observed_signature" | tr '[:lower:]' '[:upper:]' | tr -d ':[:space:]') + for expected_fingerprint in "${expected_fingerprints[@]}"; do + if [[ "$observed_compact" == "$expected_fingerprint" ]]; then + signature_match=true + break 2 + fi + done +done +[[ "$signature_match" == true ]] || { + echo "The installed package is not signed by a supplied Play App Signing identity." >&2 + exit 1 +} + +if ! grep -Eq "^[[:space:]]+$app_link_host:[[:space:]]+verified[[:space:]]*$" \ + <<<"$links_report"; then + echo "The production Android App Link domain is not verified on the device." >&2 + exit 1 +fi + +resolved_activity=$( + adb_device shell cmd package resolve-activity --brief \ + -a android.intent.action.VIEW \ + -c android.intent.category.BROWSABLE \ + -d "$app_link_url" | + tr -d '\r' +) +if ! grep -Fx "$expected_activity" <<<"$resolved_activity" >/dev/null; then + echo "The production App Link does not resolve to Who Need Help MainActivity." >&2 + exit 1 +fi + +echo "Google Play installed Android verification passed." +echo "Package: $package_name" +echo "Version: $observed_version_name ($observed_version_code)" +echo "Installer: Google Play" +echo "Signing identity: supplied Play App Signing set member" +echo "App Link: $app_link_host verified and resolved to MainActivity"