diff --git a/docs/operations.md b/docs/operations.md index 25ef828..52660b8 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -167,6 +167,29 @@ Check an environment without printing its secret values: ./scripts/check-environment-readiness.sh .env --require-release ``` +Provider downloads can be imported into that same file without placing +secrets on a command line or printing them: + +```bash +chmod 600 /secure/downloads/google-oauth-client.json +./scripts/import-google-oauth-client.sh \ + .env /secure/downloads/google-oauth-client.json + +./scripts/import-firebase-android-config.sh \ + .env /secure/downloads/google-services.json + +chmod 600 /secure/downloads/fcm-service-account.json +./scripts/import-fcm-service-account.sh \ + .env /secure/downloads/fcm-service-account.json +``` + +The importers validate the exact OAuth callback, Android package, Firebase +project relationship, and required service-account fields before atomically +replacing existing keys. They preserve mode `0600` and never create another +permanent environment file. OAuth and service-account downloads still contain +private credentials after import; deliberately move them to protected backup +storage or remove them after verification. + The first command reports incomplete or local-only capabilities. The second is a blocking release preflight and exits nonzero until Google sign-in, external SMTP, browser Web Push, Android Firebase/FCM, App Links, support routing, diff --git a/scripts/import-fcm-service-account.sh b/scripts/import-fcm-service-account.sh new file mode 100755 index 0000000..11364b9 --- /dev/null +++ b/scripts/import-fcm-service-account.sh @@ -0,0 +1,66 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 ENV_FILE FCM_SERVICE_ACCOUNT_JSON" >&2 + exit 1 +fi + +env_file=$1 +service_account_file=$2 + +if [ ! -f "$service_account_file" ]; then + echo "FCM service-account JSON does not exist: $service_account_file" >&2 + exit 1 +fi + +case "$(stat -c '%a' "$service_account_file")" in + 400|600) ;; + *) + echo "FCM service-account JSON contains a private key and must have mode 0400 or 0600." >&2 + exit 1 + ;; +esac + +if ! jq --exit-status ' + .type == "service_account" + and (.project_id | type == "string" and length > 0) + and (.client_email | type == "string" and length > 0) + and (.private_key | type == "string" and length > 0) +' "$service_account_file" >/dev/null; then + echo "FCM service-account JSON is incomplete." >&2 + exit 1 +fi + +project_id=$(jq --raw-output '.project_id' "$service_account_file") +firebase_project_id=$( + awk -F= ' + $1 == "WNH_FIREBASE_PROJECT_ID" { + print substr($0, index($0, "=") + 1) + exit + } + ' "$env_file" +) + +if [ -n "$firebase_project_id" ] && [ "$firebase_project_id" != "$project_id" ]; then + echo "FCM service-account project does not match WNH_FIREBASE_PROJECT_ID." >&2 + exit 1 +fi + +values_file=$(mktemp "${TMPDIR:-/tmp}/wnh-fcm-service-account-values.XXXXXX") +trap 'rm -f "$values_file"' EXIT HUP INT TERM +chmod 600 "$values_file" + +{ + printf 'FCM_PROJECT_ID=%s\n' "$project_id" + printf 'FCM_SERVICE_ACCOUNT_FILE=\n' + printf 'FCM_SERVICE_ACCOUNT_JSON_BASE64=' + base64 -w 0 "$service_account_file" + printf '\n' +} >"$values_file" + +"$ROOT/scripts/set-env-values.sh" "$env_file" "$values_file" >/dev/null +echo "FCM service account imported without printing the private key." +echo "The downloaded JSON still contains the private key; store or remove it deliberately." diff --git a/scripts/import-firebase-android-config.sh b/scripts/import-firebase-android-config.sh new file mode 100755 index 0000000..d56cac4 --- /dev/null +++ b/scripts/import-firebase-android-config.sh @@ -0,0 +1,64 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 ENV_FILE GOOGLE_SERVICES_JSON" >&2 + exit 1 +fi + +env_file=$1 +client_file=$2 + +if [ ! -f "$client_file" ]; then + echo "Firebase Android configuration does not exist: $client_file" >&2 + exit 1 +fi + +package_name=$( + awk -F= ' + $1 == "ANDROID_APP_LINKS_PACKAGE_NAME" { + print substr($0, index($0, "=") + 1) + exit + } + ' "$env_file" +) + +if [ -z "$package_name" ]; then + echo "ANDROID_APP_LINKS_PACKAGE_NAME is missing from the selected environment." >&2 + exit 1 +fi + +if ! jq --exit-status --arg package "$package_name" ' + [ + .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 (.project_info.project_number | type == "string" and length > 0) + and ($clients[0].client_info.mobilesdk_app_id | type == "string" and length > 0) + and ($clients[0].api_key[0].current_key | type == "string" and length > 0) +' "$client_file" >/dev/null; then + echo "Firebase Android configuration does not contain exactly one complete client for package $package_name." >&2 + exit 1 +fi + +values_file=$(mktemp "${TMPDIR:-/tmp}/wnh-firebase-android-values.XXXXXX") +trap 'rm -f "$values_file"' EXIT HUP INT TERM +chmod 600 "$values_file" + +jq --raw-output --arg package "$package_name" ' + ( + .client[] + | select(.client_info.android_client_info.package_name == $package) + ) as $client + | "WNH_FIREBASE_APPLICATION_ID=\($client.client_info.mobilesdk_app_id)", + "WNH_FIREBASE_API_KEY=\($client.api_key[0].current_key)", + "WNH_FIREBASE_PROJECT_ID=\(.project_info.project_id)", + "WNH_FIREBASE_GCM_SENDER_ID=\(.project_info.project_number)" +' "$client_file" >"$values_file" + +"$ROOT/scripts/set-env-values.sh" "$env_file" "$values_file" >/dev/null +echo "Public Firebase Android values imported for package $package_name." diff --git a/scripts/import-google-oauth-client.sh b/scripts/import-google-oauth-client.sh new file mode 100755 index 0000000..e754af9 --- /dev/null +++ b/scripts/import-google-oauth-client.sh @@ -0,0 +1,67 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 ENV_FILE GOOGLE_OAUTH_CLIENT_JSON" >&2 + exit 1 +fi + +env_file=$1 +client_file=$2 + +if [ ! -f "$client_file" ]; then + echo "Google OAuth client JSON does not exist: $client_file" >&2 + exit 1 +fi + +case "$(stat -c '%a' "$client_file")" in + 400|600) ;; + *) + echo "Google OAuth client JSON contains a client secret and must have mode 0400 or 0600." >&2 + exit 1 + ;; +esac + +base_url=$( + awk -F= ' + $1 == "WNH_BASE_URL" { + print substr($0, index($0, "=") + 1) + exit + } + ' "$env_file" +) + +if [ -z "$base_url" ]; then + echo "WNH_BASE_URL is missing from the selected environment." >&2 + exit 1 +fi + +callback_url=${base_url%/}/auth/google/callback + +if ! jq --exit-status --arg callback "$callback_url" ' + .web as $web + | ($web | type == "object") + and ($web.client_id | type == "string" and length > 0 and test("^[^\r\n]+$")) + and ($web.client_secret | type == "string" and length > 0 and test("^[^\r\n]+$")) + and ($web.redirect_uris | type == "array") + and any($web.redirect_uris[]; . == $callback) +' "$client_file" >/dev/null; then + echo "Google OAuth JSON is incomplete or does not contain the exact callback: $callback_url" >&2 + exit 1 +fi + +values_file=$(mktemp "${TMPDIR:-/tmp}/wnh-google-oauth-values.XXXXXX") +trap 'rm -f "$values_file"' EXIT HUP INT TERM +chmod 600 "$values_file" + +jq --raw-output ' + .web + | "GOOGLE_OAUTH_CLIENT_ID=\(.client_id)", + "GOOGLE_OAUTH_CLIENT_SECRET=\(.client_secret)" +' "$client_file" >"$values_file" + +"$ROOT/scripts/set-env-values.sh" "$env_file" "$values_file" >/dev/null +echo "Google OAuth client imported without printing its ID or secret." +echo "The downloaded JSON still contains the client secret; store or remove it deliberately." diff --git a/scripts/quality.sh b/scripts/quality.sh index d2d9e64..85bbf37 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -94,6 +94,95 @@ grep -Fx '/.runner' .dockerignore >/dev/null grep -Fx '/act_runner' .dockerignore >/dev/null grep -Fx '/act_runner-data/' .dockerignore >/dev/null +echo "Checking atomic environment credential imports" +credential_env="$scan_dir/credentials.env" +cp .env.example "$credential_env" +chmod 600 "$credential_env" + +credential_values="$scan_dir/credential-values" +printf '%s\n' \ + 'GOOGLE_OAUTH_CLIENT_ID=quality-imported-client' \ + 'GOOGLE_OAUTH_CLIENT_SECRET=quality-imported-secret' >"$credential_values" +chmod 600 "$credential_values" +credential_output=$( + ./scripts/set-env-values.sh "$credential_env" "$credential_values" +) +if printf '%s' "$credential_output" | grep -F 'quality-imported-secret' >/dev/null; then + echo "Environment updater printed a secret value." >&2 + exit 1 +fi +test "$(stat -c '%a' "$credential_env")" = 600 +grep -Fx 'GOOGLE_OAUTH_CLIENT_ID=quality-imported-client' "$credential_env" >/dev/null +grep -Fx 'GOOGLE_OAUTH_CLIENT_SECRET=quality-imported-secret' "$credential_env" >/dev/null + +duplicate_env="$scan_dir/credentials-duplicate.env" +cp "$credential_env" "$duplicate_env" +printf '%s\n' 'GOOGLE_OAUTH_CLIENT_ID=duplicate' >>"$duplicate_env" +if ./scripts/set-env-values.sh \ + "$duplicate_env" "$credential_values" >/dev/null 2>&1; then + echo "Environment updater accepted a duplicate target key." >&2 + exit 1 +fi + +google_client="$scan_dir/google-oauth-client.json" +printf '%s\n' \ + '{"web":{"client_id":"quality-google-client","project_id":"quality-development","client_secret":"quality-google-secret","redirect_uris":["https://dev.help.test/auth/google/callback"]}}' \ + >"$google_client" +chmod 600 "$google_client" +sed -i 's|^WNH_BASE_URL=.*|WNH_BASE_URL=https://dev.help.test|' "$credential_env" +oauth_output=$( + ./scripts/import-google-oauth-client.sh "$credential_env" "$google_client" +) +if printf '%s' "$oauth_output" | grep -F 'quality-google-secret' >/dev/null; then + echo "Google OAuth importer printed a client secret." >&2 + exit 1 +fi +grep -Fx 'GOOGLE_OAUTH_CLIENT_ID=quality-google-client' "$credential_env" >/dev/null +grep -Fx 'GOOGLE_OAUTH_CLIENT_SECRET=quality-google-secret' "$credential_env" >/dev/null + +firebase_client="$scan_dir/google-services.json" +printf '%s\n' \ + '{"project_info":{"project_number":"123456789","project_id":"quality-development"},"client":[{"client_info":{"mobilesdk_app_id":"1:123456789:android:quality","android_client_info":{"package_name":"org.whoneedhelp.mobile.staging"}},"api_key":[{"current_key":"quality-firebase-api-key"}]}]}' \ + >"$firebase_client" +sed -i \ + 's|^ANDROID_APP_LINKS_PACKAGE_NAME=.*|ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.staging|' \ + "$credential_env" +./scripts/import-firebase-android-config.sh \ + "$credential_env" "$firebase_client" >/dev/null +grep -Fx 'WNH_FIREBASE_APPLICATION_ID=1:123456789:android:quality' "$credential_env" >/dev/null +grep -Fx 'WNH_FIREBASE_API_KEY=quality-firebase-api-key' "$credential_env" >/dev/null +grep -Fx 'WNH_FIREBASE_PROJECT_ID=quality-development' "$credential_env" >/dev/null +grep -Fx 'WNH_FIREBASE_GCM_SENDER_ID=123456789' "$credential_env" >/dev/null + +fcm_service_account="$scan_dir/fcm-service-account.json" +printf '%s\n' \ + '{"type":"service_account","project_id":"quality-development","client_email":"quality-fcm@quality-development.iam.gserviceaccount.com","private_key":"quality-private-key"}' \ + >"$fcm_service_account" +chmod 600 "$fcm_service_account" +fcm_output=$( + ./scripts/import-fcm-service-account.sh \ + "$credential_env" "$fcm_service_account" +) +if printf '%s' "$fcm_output" | grep -F 'quality-private-key' >/dev/null; then + echo "FCM importer printed a private key." >&2 + exit 1 +fi +grep -Fx 'FCM_PROJECT_ID=quality-development' "$credential_env" >/dev/null +grep -Fx 'FCM_SERVICE_ACCOUNT_FILE=' "$credential_env" >/dev/null +credential_fcm_base64=$( + awk -F= ' + $1 == "FCM_SERVICE_ACCOUNT_JSON_BASE64" { + print substr($0, index($0, "=") + 1) + exit + } + ' "$credential_env" +) +printf '%s' "$credential_fcm_base64" | + base64 -d | + jq --exit-status \ + '.project_id == "quality-development" and .private_key == "quality-private-key"' \ + >/dev/null + echo "Checking the existing load environment upgrade path" legacy_load_env="$scan_dir/legacy-load.env" printf '%s\n' \ diff --git a/scripts/set-env-values.sh b/scripts/set-env-values.sh new file mode 100755 index 0000000..aafaad7 --- /dev/null +++ b/scripts/set-env-values.sh @@ -0,0 +1,97 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 ENV_FILE VALUES_FILE" >&2 + exit 1 +fi + +env_file=$1 +values_file=$2 + +if [ ! -f "$env_file" ]; then + echo "Environment file does not exist: $env_file" >&2 + exit 1 +fi + +if [ ! -f "$values_file" ]; then + echo "Values file does not exist: $values_file" >&2 + exit 1 +fi + +if [ "$(stat -c '%a' "$env_file")" != 600 ]; then + echo "Environment file must have mode 0600: $env_file" >&2 + exit 1 +fi + +case "$(stat -c '%a' "$values_file")" in + 400|600) ;; + *) + echo "Values file must have mode 0400 or 0600: $values_file" >&2 + exit 1 + ;; +esac + +env_dir=$(CDPATH='' cd -- "$(dirname -- "$env_file")" && pwd) +env_name=$(basename -- "$env_file") +temporary_env=$(mktemp "$env_dir/$env_name.tmp.XXXXXX") +trap 'rm -f "$temporary_env"' EXIT HUP INT TERM +chmod 600 "$temporary_env" + +if ! awk ' + BEGIN { + FS = "=" + reading_values = 1 + } + + FNR == 1 && NR != 1 { + reading_values = 0 + } + + reading_values { + key = $1 + + if (key !~ /^[A-Z][A-Z0-9_]*$/ || key in replacement) { + exit 40 + } + + replacement[key] = substr($0, index($0, "=") + 1) + replacement_count++ + next + } + + { + separator = index($0, "=") + + if (separator > 1) { + key = substr($0, 1, separator - 1) + + if (key in replacement) { + seen[key]++ + print key "=" replacement[key] + next + } + } + + print + } + + END { + if (replacement_count == 0) { + exit 41 + } + + for (key in replacement) { + if (seen[key] != 1) { + exit 42 + } + } + } +' "$values_file" "$env_file" >"$temporary_env"; then + echo "Refusing to update the environment: values must use unique KEY=VALUE lines and every key must already occur exactly once." >&2 + exit 1 +fi + +mv "$temporary_env" "$env_file" +trap - EXIT HUP INT TERM +echo "Environment values updated without printing their contents: $env_file"