who_need_help/scripts/verify-play-installed-android.sh

185 lines
5.7 KiB
Bash
Executable File

#!/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"