Add safe Play signing identity import

This commit is contained in:
SimpleTest 2026-08-08 18:34:53 +03:00
parent 897a943815
commit 38e297f83b
4 changed files with 503 additions and 0 deletions

View File

@ -26,6 +26,9 @@
- [ ] Publish and verify - [ ] Publish and verify
`https://whoneedhelp.com/.well-known/assetlinks.json` for the Play `https://whoneedhelp.com/.well-known/assetlinks.json` for the Play
certificate identities used to sign delivered APKs. 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 ## Build

View File

@ -257,6 +257,50 @@ chmod 600 /secure/downloads/fcm-service-account.json
.env /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 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

View File

@ -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

View File

@ -399,6 +399,135 @@ if ./scripts/import-firebase-android-config.sh \
fi fi
test "$(sha256sum "$credential_env" | awk '{print $1}')" = "$credential_hash" 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" 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