Verify Play-delivered Android installs
This commit is contained in:
parent
38e297f83b
commit
f3b5e63222
|
|
@ -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
|
7. Start with an internal test on the owner’s device, then promote the verified
|
||||||
build to the closed track.
|
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
|
## Tester cohort
|
||||||
|
|
||||||
- Recruit at least 12 real people with Google or Google Workspace accounts.
|
- Recruit at least 12 real people with Google or Google Workspace accounts.
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,11 @@
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- [ ] Internal track smoke test passed.
|
- [ ] 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.
|
- [ ] Closed track created and opt-in link tested.
|
||||||
- [ ] At least 12 testers continuously opted in for 14 days.
|
- [ ] At least 12 testers continuously opted in for 14 days.
|
||||||
- [ ] Tester feedback and fixes documented.
|
- [ ] Tester feedback and fixes documented.
|
||||||
|
|
|
||||||
|
|
@ -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
|
with the configured FCM service account. Provider files remain on disk after
|
||||||
the import and must be stored or removed deliberately.
|
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
|
The VAPID helper runs the exact locked `web_push_elixir` generator in an
|
||||||
isolated, network-disabled container, imports the result atomically, removes
|
isolated, network-disabled container, imports the result atomically, removes
|
||||||
its one-run image tag and temporary files, and never prints either key. It
|
its one-run image tag and temporary files, and never prints either key. It
|
||||||
|
|
|
||||||
|
|
@ -528,6 +528,101 @@ if ./scripts/import-play-android-config.sh \
|
||||||
fi
|
fi
|
||||||
test "$(sha256sum "$play_env" | awk '{print $1}')" = "$play_hash_after"
|
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 <<REPORT
|
||||||
|
Package [org.whoneedhelp.mobile] (quality):
|
||||||
|
versionCode=${FAKE_PLAY_VERSION_CODE:-1} minSdk=24 targetSdk=37
|
||||||
|
versionName=${FAKE_PLAY_VERSION_NAME:-0.1.0}
|
||||||
|
installerPackageName=${FAKE_PLAY_INSTALLER:-com.android.vending}
|
||||||
|
REPORT
|
||||||
|
;;
|
||||||
|
"shell pm get-app-links")
|
||||||
|
cat <<REPORT
|
||||||
|
org.whoneedhelp.mobile:
|
||||||
|
Signatures: [${FAKE_PLAY_SIGNATURE:?Set FAKE_PLAY_SIGNATURE}]
|
||||||
|
Domain verification state:
|
||||||
|
whoneedhelp.com: ${FAKE_PLAY_DOMAIN_STATE:-verified}
|
||||||
|
REPORT
|
||||||
|
;;
|
||||||
|
"shell cmd package")
|
||||||
|
printf '%s\n' "${FAKE_PLAY_ACTIVITY:-org.whoneedhelp.mobile/.MainActivity}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'unexpected adb command: %s\n' "$*" >&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"
|
echo "Checking Android environment isolation"
|
||||||
./scripts/android-play-policy-check.sh >/dev/null
|
./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
|
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
|
||||||
|
|
|
||||||
184
scripts/verify-play-installed-android.sh
Executable file
184
scripts/verify-play-installed-android.sh
Executable file
|
|
@ -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"
|
||||||
Loading…
Reference in New Issue
Block a user