From 38e297f83b523ecf2d92b50d92696001db280225 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Sat, 8 Aug 2026 18:34:53 +0300 Subject: [PATCH] Add safe Play signing identity import --- android/play-store/release-checklist.md | 3 + docs/operations.md | 44 ++++ scripts/import-play-android-config.sh | 327 ++++++++++++++++++++++++ scripts/quality.sh | 129 ++++++++++ 4 files changed, 503 insertions(+) create mode 100755 scripts/import-play-android-config.sh diff --git a/android/play-store/release-checklist.md b/android/play-store/release-checklist.md index 15d8d6d..62bc019 100644 --- a/android/play-store/release-checklist.md +++ b/android/play-store/release-checklist.md @@ -26,6 +26,9 @@ - [ ] Publish and verify `https://whoneedhelp.com/.well-known/assetlinks.json` for the Play certificate identities used to sign delivered APKs. + Use `scripts/import-play-android-config.sh` in plan mode first, then + `--apply`; it must match every Play SHA-1 to the production Firebase + Android OAuth client and preserve the upload identity. ## Build diff --git a/docs/operations.md b/docs/operations.md index 123ef10..2bbdd61 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -257,6 +257,50 @@ chmod 600 /secure/downloads/fcm-service-account.json .env /secure/downloads/fcm-service-account.json ``` +After the first AAB has made Google Play generate its delivery identities, +download a fresh production `google-services.json` after registering every +Play App Signing SHA-1 in the production Firebase Android application. Record +the SHA-1/SHA-256 pairs shown by Play in a protected temporary JSON document: + +```json +{ + "package_name": "org.whoneedhelp.mobile", + "identities": [ + {"sha1": "PLAY_SHA1", "sha256": "PLAY_SHA256"} + ] +} +``` + +Keep both provider downloads at mode `0400` or `0600`. First run the importer +without `--apply`: this validates the package, every SHA-1-to-Android-OAuth +client match, Firebase/FCM project consistency, the resulting App Links set, +and the production Android environment while leaving `.env` byte-for-byte +unchanged. Apply the same validated candidate only after reviewing the counts: + +```bash +chmod 600 /secure/downloads/google-services.json \ + /secure/downloads/play-identities.json + +./scripts/import-play-android-config.sh \ + .env \ + /secure/downloads/google-services.json \ + /secure/downloads/play-identities.json + +./scripts/import-play-android-config.sh \ + .env \ + /secure/downloads/google-services.json \ + /secure/downloads/play-identities.json \ + --apply +``` + +The apply step is atomic. It preserves the existing upload certificate and +Android OAuth client, adds every Play delivery identity, refreshes the four +public Firebase Android values, and never prints OAuth client IDs or the +Firebase API key. It refuses a test/development environment, another package, +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. + 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/import-play-android-config.sh b/scripts/import-play-android-config.sh new file mode 100755 index 0000000..809236d --- /dev/null +++ b/scripts/import-play-android-config.sh @@ -0,0 +1,327 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) + +usage() { + cat >&2 <<'EOF' +Usage: import-play-android-config.sh ENV_FILE GOOGLE_SERVICES_JSON PLAY_IDENTITIES_JSON [--apply] + +The command validates a production Google/Firebase Android client against the +Play App Signing certificate identities without changing ENV_FILE by default. +Use --apply only after reviewing the plan. PLAY_IDENTITIES_JSON must contain: + +{ + "package_name": "org.whoneedhelp.mobile", + "identities": [ + {"sha1": "AA:...", "sha256": "BB:..."} + ] +} +EOF +} + +if [[ $# -lt 3 || $# -gt 4 ]]; then + usage + exit 2 +fi + +env_file=$1 +google_services_file=$2 +identities_file=$3 +mode=${4:-} + +if [[ -n "$mode" && "$mode" != --apply ]]; then + usage + exit 2 +fi + +for path_variable in env_file google_services_file identities_file; do + path=${!path_variable} + if [[ "$path" != /* ]]; then + printf -v "$path_variable" '%s/%s' "$ROOT" "$path" + fi +done + +for command in awk jq mktemp stat; do + command -v "$command" >/dev/null 2>&1 || { + echo "Required command is unavailable: $command" >&2 + exit 1 + } +done + +[[ -f "$env_file" ]] || { + echo "Production environment does not exist: $env_file" >&2 + exit 1 +} +[[ "$(stat -c '%a' "$env_file")" == 600 ]] || { + echo "Production environment must have mode 0600: $env_file" >&2 + exit 1 +} + +for provider_file in "$google_services_file" "$identities_file"; do + [[ -f "$provider_file" ]] || { + echo "Required provider input does not exist: $provider_file" >&2 + exit 1 + } + case "$(stat -c '%a' "$provider_file")" in + 400 | 600) ;; + *) + echo "Provider inputs must have mode 0400 or 0600: $provider_file" >&2 + exit 1 + ;; + esac +done + +read_unique() { + local key=$1 + + awk -v key="$key" ' + index($0, key "=") == 1 { + count += 1 + value = substr($0, length(key) + 2) + } + END { + if (count != 1) exit 1 + print value + } + ' "$env_file" || { + echo "$key must occur exactly once in $env_file." >&2 + exit 1 + } +} + +deployment_env=$(read_unique DEPLOYMENT_ENV) +package_name=$(read_unique ANDROID_APP_LINKS_PACKAGE_NAME) +existing_app_links=$(read_unique ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS) +existing_play_fingerprints=$(read_unique ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS) +existing_authorized_parties=$(read_unique GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS) +existing_firebase_project=$(read_unique WNH_FIREBASE_PROJECT_ID) +existing_fcm_project=$(read_unique FCM_PROJECT_ID) + +[[ "$deployment_env" == production ]] || { + echo "Play App Signing identities may only be imported into DEPLOYMENT_ENV=production." >&2 + exit 1 +} +[[ "$package_name" == org.whoneedhelp.mobile ]] || { + echo "The production Android package must be org.whoneedhelp.mobile." >&2 + exit 1 +} +[[ -n "$existing_app_links" ]] || { + echo "The production App Links list must already contain the measured upload certificate." >&2 + exit 1 +} +[[ -n "$existing_authorized_parties" ]] || { + echo "The production environment must already contain its Android OAuth authorized party." >&2 + exit 1 +} + +normalized_identities=$(mktemp "${TMPDIR:-/tmp}/wnh-play-identities.XXXXXX") +matched_oauth_ids=$(mktemp "${TMPDIR:-/tmp}/wnh-play-oauth-ids.XXXXXX") +values_file=$(mktemp "${TMPDIR:-/tmp}/wnh-play-values.XXXXXX") +env_dir=$(CDPATH='' cd -- "$(dirname -- "$env_file")" && pwd) +env_name=$(basename -- "$env_file") +candidate_env=$(mktemp "$env_dir/$env_name.play-candidate.XXXXXX") +cleanup() { + rm -f "$normalized_identities" "$matched_oauth_ids" "$values_file" "$candidate_env" +} +trap cleanup EXIT HUP INT TERM +chmod 600 "$normalized_identities" "$matched_oauth_ids" "$values_file" "$candidate_env" + +if ! jq --exit-status --arg package "$package_name" ' + def normalize_sha1: + ascii_upcase | gsub(":"; ""); + def normalize_sha256: + ascii_upcase | gsub(":"; ""); + def valid_sha1: + test("^[0-9A-Fa-f]{40}$|^([0-9A-Fa-f]{2}:){19}[0-9A-Fa-f]{2}$"); + def valid_sha256: + test("^[0-9A-Fa-f]{64}$|^([0-9A-Fa-f]{2}:){31}[0-9A-Fa-f]{2}$"); + + (.package_name == $package) + and (.identities | type == "array" and length > 0) + and all( + .identities[]; + (.sha1 | type == "string" and valid_sha1) + and (.sha256 | type == "string" and valid_sha256) + ) + and (([.identities[].sha1 | normalize_sha1] | unique | length) == (.identities | length)) + and (([.identities[].sha256 | normalize_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 + +jq --compact-output ' + { + package_name, + identities: [ + .identities[] + | { + sha1: (.sha1 | ascii_upcase | gsub(":"; "")), + sha256: (.sha256 | ascii_upcase | gsub(":"; "")) + } + ] + } +' "$identities_file" >"$normalized_identities" + +if ! jq --exit-status --arg package "$package_name" ' + (.project_info.project_number) as $project_number + | [ + .client[]? + | select(.client_info.android_client_info.package_name == $package) + ] as $clients + | ($clients | length == 1) + and (.project_info.project_id + | type == "string" and length > 0 and test("^[^\r\n]+$")) + and (.project_info.project_number + | type == "string" and length > 0 and test("^[0-9]+$")) + and ($clients[0].client_info.mobilesdk_app_id + | type == "string" and length > 0 + and startswith("1:" + $project_number + ":android:")) + and ($clients[0].api_key[0].current_key + | type == "string" and length > 0 and test("^[^\r\n]+$")) +' "$google_services_file" >/dev/null; then + echo "Firebase configuration does not contain exactly one complete production Android client." >&2 + exit 1 +fi + +if ! jq --exit-status --raw-output \ + --slurpfile identity_documents "$normalized_identities" \ + --arg package "$package_name" ' + def normalize_sha1: + ascii_upcase | gsub(":"; ""); + $identity_documents[0] as $identities + | + [ + .client[] + | select(.client_info.android_client_info.package_name == $package) + | .oauth_client[]? + | select(.client_type == 1) + | select(.android_info.package_name == $package) + | { + client_id, + sha1: (.android_info.certificate_hash | normalize_sha1) + } + ] as $android_clients + | [ + $identities.identities[] + | . as $identity + | [$android_clients[] | select(.sha1 == $identity.sha1)] as $matches + | if ($matches | length) == 1 + then $matches[0].client_id + else error("each Play SHA-1 must match exactly one Android OAuth client") + end + ] as $matched + | if (($matched | length) == ($identities.identities | length)) + and (($matched | unique | length) == ($matched | length)) + then $matched[] + else error("Play Android OAuth clients are incomplete or duplicated") + end +' "$google_services_file" >"$matched_oauth_ids"; then + echo "Firebase configuration does not contain one distinct Android OAuth client for every Play SHA-1." >&2 + exit 1 +fi + +firebase_project=$(jq --raw-output '.project_info.project_id' "$google_services_file") +if [[ -n "$existing_firebase_project" && "$existing_firebase_project" != "$firebase_project" ]]; then + echo "Firebase download belongs to a different project than WNH_FIREBASE_PROJECT_ID." >&2 + exit 1 +fi +if [[ -n "$existing_fcm_project" && "$existing_fcm_project" != "$firebase_project" ]]; then + echo "Firebase download belongs to a different project than FCM_PROJECT_ID." >&2 + exit 1 +fi + +jq --null-input --raw-output \ + --arg existing_app_links "$existing_app_links" \ + --arg existing_play "$existing_play_fingerprints" \ + --arg existing_authorized "$existing_authorized_parties" \ + --slurpfile identity_documents "$normalized_identities" \ + --rawfile matched_oauth "$matched_oauth_ids" \ + --slurpfile firebase_documents "$google_services_file" ' + def stable_unique: + reduce .[] as $item ([]; if index($item) then . else . + [$item] end); + def compact_fingerprint: + ascii_upcase | gsub(":"; ""); + def colonize: + [range(0; length; 2) as $offset | .[$offset:$offset + 2]] | join(":"); + def trim: + gsub("^[[:space:]]+|[[:space:]]+$"; ""); + + $identity_documents[0] as $identities + | $firebase_documents[0] as $firebase + | ($existing_app_links + | split(",") + | map(trim | compact_fingerprint) + ) as $published + | if all($published[]; test("^[0-9A-F]{64}$")) + then . + else error("existing App Links fingerprints are malformed") + end + | ($existing_play + | if length == 0 then [] else split(",") | map(trim | compact_fingerprint) end + ) as $existing_play_values + | if all($existing_play_values[]; test("^[0-9A-F]{64}$")) + then . + else error("existing Play fingerprints are malformed") + end + | ($existing_authorized | split(",") | map(trim)) as $authorized + | if all($authorized[]; length > 0 and test("^[^\r\n,]+$")) + then . + else error("existing Android OAuth clients are malformed") + end + | ($identities.identities | map(.sha256)) as $new_play + | ($matched_oauth | split("\n") | map(select(length > 0))) as $new_oauth + | (($published + $new_play) | stable_unique | map(colonize)) as $all_published + | (($existing_play_values + $new_play) | stable_unique | map(colonize)) as $all_play + | (($authorized + $new_oauth) | stable_unique) as $all_authorized + | ($firebase.client[] + | select(.client_info.android_client_info.package_name == "org.whoneedhelp.mobile")) as $client + | "ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=\($all_published | join(","))", + "ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS=\($all_play | join(","))", + "GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS=\($all_authorized | join(","))", + "WNH_FIREBASE_APPLICATION_ID=\($client.client_info.mobilesdk_app_id)", + "WNH_FIREBASE_API_KEY=\($client.api_key[0].current_key)", + "WNH_FIREBASE_PROJECT_ID=\($firebase.project_info.project_id)", + "WNH_FIREBASE_GCM_SENDER_ID=\($firebase.project_info.project_number)" +' >"$values_file" + +cp "$env_file" "$candidate_env" +chmod 600 "$candidate_env" +"$ROOT/scripts/set-env-values.sh" "$candidate_env" "$values_file" >/dev/null +"$ROOT/scripts/validate-android-environment.sh" "$candidate_env" production >/dev/null + +identity_count=$(jq '.identities | length' "$normalized_identities") +published_count=$( + awk -F= ' + $1 == "ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS" { + value = substr($0, index($0, "=") + 1) + print split(value, fingerprints, ",") + exit + } + ' "$candidate_env" +) +authorized_count=$( + awk -F= ' + $1 == "GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS" { + value = substr($0, index($0, "=") + 1) + print split(value, clients, ",") + exit + } + ' "$candidate_env" +) + +echo "Validated production Play Android identity import." +echo "Target environment: $env_file" +echo "Package: $package_name" +echo "Play signing identities supplied: $identity_count" +echo "Published App Links identities after import: $published_count" +echo "Authorized Android OAuth clients after import: $authorized_count" +echo "Firebase/FCM project relationship: verified" + +if [[ "$mode" == --apply ]]; then + mv "$candidate_env" "$env_file" + echo "Applied the validated values atomically without printing credentials." +else + echo "Plan only: no files were changed. Re-run with --apply after reviewing these counts." +fi diff --git a/scripts/quality.sh b/scripts/quality.sh index 3670288..13e5048 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -399,6 +399,135 @@ if ./scripts/import-firebase-android-config.sh \ fi test "$(sha256sum "$credential_env" | awk '{print $1}')" = "$credential_hash" +echo "Checking production Play Android identity import" +play_env="$scan_dir/play-production.env" +cp .env.example "$play_env" +chmod 600 "$play_env" +play_upload_sha256=BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB +play_sha1_one=1111111111111111111111111111111111111111 +play_sha1_two=2222222222222222222222222222222222222222 +play_sha256_one=D7C4F1124DF468E5B354DED896E801512941F18A710C18B0E798AA2B81DA11DF +play_sha256_two=A5742BAE70C6D034E37544B62E37A375C0E005647450F40F29B2A984F9FDB8FB +play_upload_colon=$(printf '%s' "$play_upload_sha256" | sed 's/../&:/g; s/:$//') +play_sha256_one_colon=$(printf '%s' "$play_sha256_one" | sed 's/../&:/g; s/:$//') +play_sha256_two_colon=$(printf '%s' "$play_sha256_two" | sed 's/../&:/g; s/:$//') +sed -i \ + -e 's|^DEPLOYMENT_ENV=.*|DEPLOYMENT_ENV=production|' \ + -e 's|^PHX_HOST=.*|PHX_HOST=help.test|' \ + -e 's|^PHX_SCHEME=.*|PHX_SCHEME=https|' \ + -e 's|^PHX_URL_PORT=.*|PHX_URL_PORT=443|' \ + -e 's|^WNH_BASE_URL=.*|WNH_BASE_URL=https://help.test|' \ + -e 's|^GOOGLE_OAUTH_CLIENT_ID=.*|GOOGLE_OAUTH_CLIENT_ID=quality-production-web-client|' \ + -e 's|^GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS=.*|GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS=quality-upload-android-client|' \ + -e 's|^ANDROID_APP_LINKS_PACKAGE_NAME=.*|ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile|' \ + -e "s|^ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=.*|ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=$play_upload_colon|" \ + -e 's|^ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS=.*|ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS=|' \ + -e 's|^WNH_FIREBASE_PROJECT_ID=.*|WNH_FIREBASE_PROJECT_ID=quality-production|' \ + -e 's|^FCM_PROJECT_ID=.*|FCM_PROJECT_ID=quality-production|' \ + "$play_env" + +play_google_services="$scan_dir/play-google-services.json" +jq --null-input \ + --arg sha1_one "$play_sha1_one" \ + --arg sha1_two "$play_sha1_two" \ + '{ + project_info: { + project_number: "987654321", + project_id: "quality-production" + }, + client: [ + { + client_info: { + mobilesdk_app_id: "1:987654321:android:quality-production", + android_client_info: {package_name: "org.whoneedhelp.mobile"} + }, + oauth_client: [ + { + client_id: "quality-play-android-client-one", + client_type: 1, + android_info: { + package_name: "org.whoneedhelp.mobile", + certificate_hash: $sha1_one + } + }, + { + client_id: "quality-play-android-client-two", + client_type: 1, + android_info: { + package_name: "org.whoneedhelp.mobile", + certificate_hash: $sha1_two + } + } + ], + api_key: [{current_key: "quality-production-firebase-api-key"}] + } + ] + }' >"$play_google_services" +chmod 600 "$play_google_services" + +play_identities="$scan_dir/play-identities.json" +jq --null-input \ + --arg sha1_one "$play_sha1_one" \ + --arg sha1_two "$play_sha1_two" \ + --arg sha256_one "$play_sha256_one" \ + --arg sha256_two "$play_sha256_two" \ + '{ + package_name: "org.whoneedhelp.mobile", + identities: [ + {sha1: $sha1_one, sha256: $sha256_one}, + {sha1: $sha1_two, sha256: $sha256_two} + ] + }' >"$play_identities" +chmod 600 "$play_identities" + +play_hash_before=$(sha256sum "$play_env" | awk '{print $1}') +play_plan_output=$( + ./scripts/import-play-android-config.sh \ + "$play_env" "$play_google_services" "$play_identities" +) +test "$(sha256sum "$play_env" | awk '{print $1}')" = "$play_hash_before" +printf '%s' "$play_plan_output" | grep -F 'Plan only: no files were changed.' >/dev/null +if printf '%s' "$play_plan_output" | + grep -E 'quality-production-firebase-api-key|quality-play-android-client' >/dev/null; then + echo "Play Android identity plan printed a provider value." >&2 + exit 1 +fi + +./scripts/import-play-android-config.sh \ + "$play_env" "$play_google_services" "$play_identities" --apply >/dev/null +grep -Fx \ + "ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=$play_upload_colon,$play_sha256_one_colon,$play_sha256_two_colon" \ + "$play_env" >/dev/null +grep -Fx \ + "ANDROID_PLAY_APP_SIGNING_SHA256_CERT_FINGERPRINTS=$play_sha256_one_colon,$play_sha256_two_colon" \ + "$play_env" >/dev/null +grep -Fx \ + 'GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS=quality-upload-android-client,quality-play-android-client-one,quality-play-android-client-two' \ + "$play_env" >/dev/null +grep -Fx 'WNH_FIREBASE_APPLICATION_ID=1:987654321:android:quality-production' \ + "$play_env" >/dev/null +grep -Fx 'WNH_FIREBASE_API_KEY=quality-production-firebase-api-key' \ + "$play_env" >/dev/null +grep -Fx 'WNH_FIREBASE_PROJECT_ID=quality-production' "$play_env" >/dev/null +grep -Fx 'WNH_FIREBASE_GCM_SENDER_ID=987654321' "$play_env" >/dev/null + +play_hash_after=$(sha256sum "$play_env" | awk '{print $1}') +./scripts/import-play-android-config.sh \ + "$play_env" "$play_google_services" "$play_identities" --apply >/dev/null +test "$(sha256sum "$play_env" | awk '{print $1}')" = "$play_hash_after" + +play_bad_identities="$scan_dir/play-identities-unmatched.json" +jq '.identities[0].sha1 = "3333333333333333333333333333333333333333"' \ + "$play_identities" >"$play_bad_identities" +chmod 600 "$play_bad_identities" +if ./scripts/import-play-android-config.sh \ + "$play_env" "$play_google_services" "$play_bad_identities" --apply \ + >/dev/null 2>&1; then + echo "Play Android identity import accepted an unmatched Play SHA-1." >&2 + exit 1 +fi +test "$(sha256sum "$play_env" | awk '{print $1}')" = "$play_hash_after" + 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