who_need_help/scripts/import-play-android-config.sh

328 lines
11 KiB
Bash
Executable File

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