diff --git a/.env.example b/.env.example index c53c784..7c9bbfd 100644 --- a/.env.example +++ b/.env.example @@ -90,10 +90,21 @@ WNH_FIREBASE_APPLICATION_ID= WNH_FIREBASE_API_KEY= WNH_FIREBASE_PROJECT_ID= WNH_FIREBASE_GCM_SENDER_ID= +# Verified Android App Links are configured by the web deployment rather than +# embedded as secrets in the application. Use org.whoneedhelp.mobile.staging +# with the staging signing certificate on the dev checkout and +# org.whoneedhelp.mobile with every active Play signing certificate on +# production. Keep both empty until the matching signed APK/AAB is available. +ANDROID_APP_LINKS_PACKAGE_NAME= +ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS= # Public identifier of the locally held Google Play upload key. The private # keystore and its randomized password live outside the repository under # ~/.config/who_need_help/android-release/. WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-upload +# The dev-domain staging APK uses a different stable signing identity under +# ~/.config/who_need_help/android-staging/. This keeps App Link verification +# reproducible without reusing the future production upload key. +WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS=who-need-help-staging WNH_ANDROID_TEST_API_MATRIX="24 30 34 37.0" WNH_ANDROID_TEST_DATA_PARTITION_SIZE=1G # Public raster tile template used by MapLibre. Use a provider whose policy and diff --git a/README.md b/README.md index d12bc58..1b4a1d5 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,11 @@ local Codex CLI authenticated with their ChatGPT subscription. location foreground service. Its persistent notification exposes Stop, it continues while the Activity is minimized, and it retains only the current point. Reproducible Docker targets export - distinct local and public-staging debug APKs; production signing and store - publication are not configured. + distinct debug and stable-signed staging APKs plus a production-signed APK + and Play AAB. The web app publishes environment-specific verified Android + App Links metadata and the build verifies package/certificate agreement. + Play registration, Play App Signing identity, store review, and physical + device FCM delivery are still external release steps. - Local, advisory Codex category review through the user's ChatGPT-authenticated Codex CLI. It receives a PII-free export and never writes to the database. - One immutable release image with `web`, `worker`, combined `app`, and @@ -94,7 +97,7 @@ local Codex CLI authenticated with their ChatGPT subscription. replicas by default. Additional social providers, background PWA or unattended location tracking, -platform payments, production Android signing/store publication, iOS, +platform payments, Android store publication, iOS, automatic punitive fraud decisions, and jurisdiction-specific public-launch policies are deliberately not claimed as complete. diff --git a/android/Dockerfile b/android/Dockerfile index ce025fd..c1f34a4 100644 --- a/android/Dockerfile +++ b/android/Dockerfile @@ -149,6 +149,7 @@ COPY --from=android-sdk \ FROM android-base AS android-staging-sdk USER gradle +SHELL ["/bin/bash", "-o", "pipefail", "-c"] WORKDIR /workspace/android COPY --chown=gradle:gradle . . @@ -165,6 +166,11 @@ ARG WNH_FIREBASE_GCM_SENDER_ID RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ --mount=type=cache,target=/home/gradle/.android,uid=1000,gid=1000 \ + --mount=type=secret,id=android_staging_keystore,required=true,uid=1000,gid=1000,mode=0400 \ + --mount=type=secret,id=android_staging_password,required=true,uid=1000,gid=1000,mode=0400 \ + --mount=type=secret,id=android_staging_alias,required=true,env=WNH_ANDROID_SIGNING_KEY_ALIAS \ + WNH_ANDROID_SIGNING_STORE_FILE=/run/secrets/android_staging_keystore \ + WNH_ANDROID_SIGNING_PASSWORD_FILE=/run/secrets/android_staging_password \ gradle --no-daemon \ "-PWNH_BASE_URL=${WNH_BASE_URL}" \ "-PWNH_DEBUG_BASE_URL=${WNH_BASE_URL}" \ @@ -177,7 +183,17 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ "-PWNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID}" \ "-PWNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID}" \ "-PWNH_TEST_BUILD_TYPE=staging" \ - testStagingUnitTest lintStaging assembleStaging assembleStagingAndroidTest + testStagingUnitTest lintStaging assembleStaging assembleStagingAndroidTest \ + && "${ANDROID_HOME}/build-tools/37.0.0/apksigner" \ + verify --verbose --print-certs \ + app/build/outputs/apk/staging/app-staging.apk \ + >app/build/outputs/apk/staging/signing-certificate.txt \ + && "${ANDROID_HOME}/build-tools/37.0.0/aapt2" dump badging \ + app/build/outputs/apk/staging/app-staging.apk \ + | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p" \ + >app/build/outputs/apk/staging/package-name.txt \ + && grep -Fx "org.whoneedhelp.mobile.staging" \ + app/build/outputs/apk/staging/package-name.txt FROM scratch AS staging-artifact @@ -192,6 +208,12 @@ COPY --from=android-staging-sdk \ COPY --from=android-staging-sdk \ /workspace/android/app/build/reports/lint-results-staging.html \ /lint-results-staging.html +COPY --from=android-staging-sdk \ + /workspace/android/app/build/outputs/apk/staging/signing-certificate.txt \ + /signing-certificate.txt +COPY --from=android-staging-sdk \ + /workspace/android/app/build/outputs/apk/staging/package-name.txt \ + /package-name.txt FROM android-base AS android-release-base @@ -212,6 +234,7 @@ USER gradle FROM android-release-base AS android-release-sdk USER gradle +SHELL ["/bin/bash", "-o", "pipefail", "-c"] WORKDIR /workspace/android COPY --chown=gradle:gradle . . @@ -248,6 +271,12 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ verify --verbose --print-certs \ app/build/outputs/apk/release/app-release.apk \ >app/build/outputs/apk/release/signing-certificate.txt \ + && "${ANDROID_HOME}/build-tools/37.0.0/aapt2" dump badging \ + app/build/outputs/apk/release/app-release.apk \ + | sed -n "s/^package: name='\\([^']*\\)'.*/\\1/p" \ + >app/build/outputs/apk/release/package-name.txt \ + && grep -Fx "org.whoneedhelp.mobile" \ + app/build/outputs/apk/release/package-name.txt \ && LC_ALL=C jarsigner -verify -verbose -certs \ app/build/outputs/bundle/release/app-release.aab \ >app/build/outputs/bundle/release/signing-verification.txt \ @@ -270,6 +299,9 @@ COPY --from=android-release-sdk \ COPY --from=android-release-sdk \ /workspace/android/app/build/outputs/apk/release/signing-certificate.txt \ /signing-certificate.txt +COPY --from=android-release-sdk \ + /workspace/android/app/build/outputs/apk/release/package-name.txt \ + /package-name.txt COPY --from=android-release-sdk \ /workspace/android/app/build/outputs/bundle/release/signing-verification.txt \ /bundle-signing-verification.txt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f138649..3ac24f0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -170,6 +170,9 @@ android { initWith(getByName("debug")) applicationIdSuffix = ".staging" versionNameSuffix = "-staging" + if (releaseSigningConfigured) { + signingConfig = signingConfigs.getByName("release") + } buildConfigField( "String", "BASE_URL", @@ -221,12 +224,12 @@ android { tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.configureEach { doFirst { validateFirebaseConfiguration() - if (name == "preReleaseBuild" && !releaseSigningConfigured) { + if (!releaseSigningConfigured) { val detail = if (releaseSigningPartiallyConfigured) { - "Release signing is only partially configured" + "Android signing is only partially configured" } else { - "Release signing is not configured" + "Android signing is not configured" } throw GradleException( "$detail; set WNH_ANDROID_SIGNING_STORE_FILE, " diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1b31d69..fa9f0f2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -36,7 +36,7 @@ - + diff --git a/compose.yaml b/compose.yaml index 06f369e..c15cfe9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -56,6 +56,8 @@ x-app-environment: &app-environment FCM_PROJECT_ID: ${FCM_PROJECT_ID:-} FCM_SERVICE_ACCOUNT_FILE: ${FCM_SERVICE_ACCOUNT_FILE:-} FCM_SERVICE_ACCOUNT_JSON_BASE64: ${FCM_SERVICE_ACCOUNT_JSON_BASE64:-} + ANDROID_APP_LINKS_PACKAGE_NAME: ${ANDROID_APP_LINKS_PACKAGE_NAME:-} + ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS: ${ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS:-} OBAN_MAINTENANCE_CONCURRENCY: ${OBAN_MAINTENANCE_CONCURRENCY:-2} OBAN_PUSH_CONCURRENCY: ${OBAN_PUSH_CONCURRENCY:-1} diff --git a/config/config.exs b/config/config.exs index 255ca30..9fd10ca 100644 --- a/config/config.exs +++ b/config/config.exs @@ -35,6 +35,7 @@ config :who_need_help, secure_cookies: false, rate_limit_policies: %{}, map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + android_app_links: nil, web_push_public_key: nil, fcm_goth_source: nil, device_delivery_options: %{ diff --git a/config/runtime.exs b/config/runtime.exs index c9d4058..de03c98 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -371,6 +371,57 @@ config :who_need_help, ) } +android_app_links = + case { + System.get_env("ANDROID_APP_LINKS_PACKAGE_NAME"), + System.get_env("ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS") + } do + {package_name, fingerprints} + when is_binary(package_name) and package_name != "" and is_binary(fingerprints) and + fingerprints != "" -> + unless Regex.match?( + ~r/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/, + package_name + ) do + raise "ANDROID_APP_LINKS_PACKAGE_NAME must be a valid Android application ID." + end + + normalized_fingerprints = + fingerprints + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.map(fn fingerprint -> + hex = fingerprint |> String.replace(":", "") |> String.upcase() + + unless Regex.match?(~r/^[0-9A-F]{64}$/, hex) do + raise """ + ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS must contain comma-separated \ + SHA-256 certificate fingerprints. + """ + end + + hex + |> String.graphemes() + |> Enum.chunk_every(2) + |> Enum.map_join(":", &Enum.join/1) + end) + |> Enum.uniq() + + %{package_name: package_name, sha256_cert_fingerprints: normalized_fingerprints} + + {package_name, fingerprints} + when package_name in [nil, ""] and fingerprints in [nil, ""] -> + nil + + _partial_configuration -> + raise """ + ANDROID_APP_LINKS_PACKAGE_NAME and \ + ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS must either both be set or both be empty. + """ + end + +config :who_need_help, :android_app_links, android_app_links + if config_env() == :prod and app_role in [:web, :worker, :combined] do metrics_token = System.get_env("METRICS_TOKEN") || diff --git a/docs/operations.md b/docs/operations.md index 4336144..692e01c 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -71,6 +71,51 @@ file. Both applications intentionally share only the external `who-need-help-production:4000` and `test.whoneedhelp.com` to `who-need-help-test:4000`. +### Environment-specific Android builds and App Links + +Android build inputs belong in the same ignored mode-`0600` `.env` as the web +checkout they target. Do not create `.env.android-release`, +`.env.production`, or another permanent environment file: + +```dotenv +WNH_BASE_URL=https://dev.example.com +WNH_ANDROID_VERSION_CODE=1 +WNH_ANDROID_VERSION_NAME=0.1.0 +WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS=who-need-help-staging +ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.staging +ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=AA:BB:... +``` + +The dev checkout uses package `org.whoneedhelp.mobile.staging` and a dedicated +stable key under `~/.config/who_need_help/android-staging/`. Generate it once: + +```bash +./scripts/init-android-staging-signing.sh +./scripts/android-staging-build.sh +``` + +The build exports the package and certificate reports, verifies that both match +the checkout `.env`, and checks the HTTPS +`/.well-known/assetlinks.json` response. Losing this key changes the staging +certificate and breaks previously installed App Links, so back it up. + +The production checkout instead uses package `org.whoneedhelp.mobile`, the +separate upload material under +`~/.config/who_need_help/android-release/`, and its own `.env`: + +```dotenv +WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-upload +ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile +ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=UPLOAD_OR_PLAY_SHA256 +``` + +Run `./scripts/android-release-build.sh` from that production release checkout. +It produces an APK, Play AAB, package report, signing report, and lint report. +After Play App Signing is enabled, add the Play signing certificate fingerprint +to the comma-separated App Links value; the upload certificate alone does not +describe Play-delivered APKs. The production environment validator accepts +multiple SHA-256 fingerprints and rejects partial or malformed configuration. + Create the test configuration inside the test checkout: ```bash diff --git a/docs/verification.md b/docs/verification.md index bd71b2d..f424f76 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -131,7 +131,7 @@ this audit. | Social profiles | Manual links implemented; optional GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record. The local protocol drill also performs real HTTP token/user exchanges without returning an access token to the application. | GitHub OAuth credentials are intentionally absent and are not required for registration or the help flow. The real external provider redirect/callback remains disabled and unverified; other providers remain manual/unverified. | | Support and content removal | Implemented and browser-verified | Public support, account deletion, general removal, and TAKE IT DOWN forms create separate audited queues; private email status links verify public contacts; authenticated submissions use the account email; moderator-only operations can update status and notify verified contacts. TAKE IT DOWN accepts URLs/text only and records a 48-hour review due time. Authenticated users can download an allowlisted JSON export, and moderators can run a read-only deletion relationship preflight. | The current product hosts no user media and does not claim TAKE IT DOWN coverage. Staffing, jurisdiction-specific legal classification, final retention rules, destructive account erasure/anonymisation, and identical-media-copy handling remain operational/legal work. | | Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. | -| Android client | Local and public-staging clients implemented and emulator-verified | The native packages `org.whoneedhelp.mobile.debug` and `org.whoneedhelp.mobile.staging` launch the same authenticated LiveView app. Existing lifecycle, permission, deep-link, foreground tracking, recoverable main-page failure, notification-Stop, and Activity-destruction tests remain in place. The final local build also covers consent-based FCM token registration, data-only notification routing, and request/notification deep links. | Production signing, Play Store publication, verified Android App Links, physical-device FCM delivery, unattended/background-permission tracking, and iOS are not implemented. | +| Android client | Local and public-staging clients implemented and emulator-verified | The native packages `org.whoneedhelp.mobile.debug` and `org.whoneedhelp.mobile.staging` launch the same authenticated LiveView app. Existing lifecycle, permission, deep-link, foreground tracking, recoverable main-page failure, notification-Stop, and Activity-destruction tests remain in place. A stable staging certificate now signs the dev APK, the HTTPS deployment publishes the matching App Links statement, and the build checks its package and SHA-256 certificate. A separate upload key produces a signed production APK and Play AAB. | Play registration/App Signing, on-device domain-verification observation, physical-device FCM delivery, unattended/background-permission tracking, and iOS are not complete. | | Multiple web/worker instances | Implemented and locally failure/rollout-verified | The final isolated Compose drill passed BEAM crashes and sequential replacement with 3 web/2 worker replicas: all five nodes joined, PubSub passed, and 744/744 readiness requests succeeded. The project-owned kind cluster replaced all 2 web/2 worker pod UIDs under `maxUnavailable=0`; all four replacement pods joined, PubSub passed, and 363/363 samples ultimately succeeded. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. | | Local observability | Implemented and protocol-verified | Pinned Prometheus scraped the exact 3 web and 2 worker targets with a file Bearer credential; Grafana provisioned a healthy datasource and ten-panel web/worker/BEAM/Ecto/Oban dashboard; Alertmanager delivered firing and resolved webhooks for an induced scoped replica stop. | Local delivery does not establish production retention, notification-provider reliability, on-call policy, or measured alert thresholds. | | Encrypted local backup | Implemented and failure-verified | Pinned Restic streamed PostgreSQL custom format into pinned local MinIO with no host plaintext dump, passed full-data checking and a fresh-database restore, rejected a corrupted repository, and published no snapshot for an interrupted upload. The one-run MinIO project and volume were removed after retaining the non-secret evidence. | The drill proves the local mechanism, not off-site durability, database HA, or a production RPO/RTO/retention policy. | @@ -1300,11 +1300,14 @@ None of the observations below describe the current delivery path. health endpoints, migrations, Google callback, and authentication-email flow after that promotion; the current test origin still depends on its configured workstation/VPN/gateway path. -- Confirm the final Android application ID before creating its Play Console - listing, publish `/.well-known/assetlinks.json` for that ID and the final - signing fingerprint if verified App Links are wanted, and complete store - policy/release work. A dedicated upload key and signed APK/AAB have been - created and verified locally, but no Play application has been registered. +- The final Android application ID is `org.whoneedhelp.mobile`. The application + now publishes environment-specific `/.well-known/assetlinks.json`, and the + stable-signed dev APK was checked against its HTTPS response. Before a Play + release, register the application, add the Play App Signing certificate + fingerprint alongside any sideload/upload fingerprint, repeat Android's + domain verification on a device, and complete store policy/release work. A + dedicated upload key and signed APK/AAB exist, but no Play application has + been registered. - Operate PostgreSQL/PostGIS with off-site backups, recovery testing, monitoring, and the availability model selected for real usage. - After provider approval, verify that delivered MIME contains neither open nor diff --git a/lib/who_need_help_web/controllers/android_app_links_controller.ex b/lib/who_need_help_web/controllers/android_app_links_controller.ex new file mode 100644 index 0000000..b7752f9 --- /dev/null +++ b/lib/who_need_help_web/controllers/android_app_links_controller.ex @@ -0,0 +1,29 @@ +defmodule WhoNeedHelpWeb.AndroidAppLinksController do + use WhoNeedHelpWeb, :controller + + def show(conn, _params) do + case Application.get_env(:who_need_help, :android_app_links) do + %{ + package_name: package_name, + sha256_cert_fingerprints: fingerprints + } -> + conn + |> put_resp_header("cache-control", "public, max-age=300") + |> json([ + %{ + relation: ["delegate_permission/common.handle_all_urls"], + target: %{ + namespace: "android_app", + package_name: package_name, + sha256_cert_fingerprints: fingerprints + } + } + ]) + + _not_configured -> + conn + |> put_status(:not_found) + |> json(%{error: "android_app_links_not_configured"}) + end + end +end diff --git a/lib/who_need_help_web/router.ex b/lib/who_need_help_web/router.ex index 59dc002..7f1b0ab 100644 --- a/lib/who_need_help_web/router.ex +++ b/lib/who_need_help_web/router.ex @@ -46,6 +46,12 @@ defmodule WhoNeedHelpWeb.Router do get "/ready", HealthController, :ready end + scope "/.well-known", WhoNeedHelpWeb do + pipe_through :api + + get "/assetlinks.json", AndroidAppLinksController, :show + end + scope "/", WhoNeedHelpWeb do get "/metrics", MetricsController, :show end diff --git a/scripts/android-app-links-verify.sh b/scripts/android-app-links-verify.sh new file mode 100755 index 0000000..36d0d8c --- /dev/null +++ b/scripts/android-app-links-verify.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +env_file=${1:-"$ROOT/.env"} +artifact_dir=${2:-"$ROOT/android/dist-staging"} +online_mode=${3:-} + +if [[ "$env_file" != /* ]]; then + env_file="$ROOT/$env_file" +fi +if [[ "$artifact_dir" != /* ]]; then + artifact_dir="$ROOT/$artifact_dir" +fi + +[[ -f "$env_file" ]] || { + echo "Android App Links environment does not exist: $env_file" >&2 + exit 1 +} +[[ -f "$artifact_dir/package-name.txt" ]] || { + echo "Android package report does not exist: $artifact_dir/package-name.txt" >&2 + exit 1 +} +[[ -f "$artifact_dir/signing-certificate.txt" ]] || { + echo "Android signing report does not exist: $artifact_dir/signing-certificate.txt" >&2 + exit 1 +} + +read_env_value() { + local key=$1 + + awk -v key="$key" ' + index($0, key "=") == 1 { + print substr($0, length(key) + 2) + found = 1 + exit + } + END { if (!found) exit 1 } + ' "$env_file" +} + +expected_package=$(read_env_value ANDROID_APP_LINKS_PACKAGE_NAME 2>/dev/null || true) +expected_fingerprints=$( + read_env_value ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS 2>/dev/null || true +) +base_url=$(read_env_value WNH_BASE_URL 2>/dev/null || true) +observed_package=$(tr -d '\r\n' <"$artifact_dir/package-name.txt") +observed_fingerprint=$( + awk -F': ' ' + /certificate SHA-256 digest:/ { + print $NF + found = 1 + exit + } + END { if (!found) exit 1 } + ' "$artifact_dir/signing-certificate.txt" | + tr '[:lower:]' '[:upper:]' +) +observed_fingerprint=$( + printf '%s' "$observed_fingerprint" | + sed 's/../&:/g; s/:$//' +) + +[[ -n "$expected_package" && -n "$expected_fingerprints" ]] || { + echo "Android App Links package and fingerprints are not configured in $env_file." >&2 + exit 1 +} +[[ "$observed_package" == "$expected_package" ]] || { + echo "The signed APK package does not match ANDROID_APP_LINKS_PACKAGE_NAME." >&2 + exit 1 +} + +fingerprint_found=false +IFS=',' read -r -a fingerprints <<<"$expected_fingerprints" +for fingerprint in "${fingerprints[@]}"; do + compact=${fingerprint//:/} + compact=${compact//[[:space:]]/} + normalized=$(printf '%s' "$compact" | tr '[:lower:]' '[:upper:]' | sed 's/../&:/g; s/:$//') + if [[ "$normalized" == "$observed_fingerprint" ]]; then + fingerprint_found=true + break + fi +done +[[ "$fingerprint_found" == true ]] || { + echo "The signed APK certificate is absent from the configured App Links fingerprints." >&2 + exit 1 +} + +if [[ "$online_mode" == --online ]]; then + [[ "$base_url" == https://* ]] || { + echo "Online Android App Links verification requires an HTTPS WNH_BASE_URL." >&2 + exit 1 + } + + payload=$(curl --fail --silent --show-error \ + "$base_url/.well-known/assetlinks.json") + jq -e \ + --arg package "$observed_package" \ + --arg fingerprint "$observed_fingerprint" \ + ' + any( + .[]; + .target.namespace == "android_app" and + .target.package_name == $package and + (.relation | index("delegate_permission/common.handle_all_urls")) != null and + (.target.sha256_cert_fingerprints | index($fingerprint)) != null + ) + ' <<<"$payload" >/dev/null +fi + +echo "Android package, signing certificate, and App Links configuration agree." diff --git a/scripts/android-release-build.sh b/scripts/android-release-build.sh index 8594cab..67c853a 100755 --- a/scripts/android-release-build.sh +++ b/scripts/android-release-build.sh @@ -74,6 +74,7 @@ for artifact in \ "$OUTPUT_DIR/who-need-help-release.apk" \ "$OUTPUT_DIR/who-need-help-release.aab" \ "$OUTPUT_DIR/signing-certificate.txt" \ + "$OUTPUT_DIR/package-name.txt" \ "$OUTPUT_DIR/bundle-signing-verification.txt" \ "$OUTPUT_DIR/bundletool-validation.txt" \ "$OUTPUT_DIR/lint-results-release.html"; do @@ -83,6 +84,11 @@ for artifact in \ fi done +if [ -n "${ANDROID_APP_LINKS_PACKAGE_NAME:-}" ] || + [ -n "${ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS:-}" ]; then + "$ROOT/scripts/android-app-links-verify.sh" "$ENV_FILE" "$OUTPUT_DIR" +fi + sha256sum \ "$OUTPUT_DIR/who-need-help-release.apk" \ "$OUTPUT_DIR/who-need-help-release.aab" diff --git a/scripts/android-staging-build.sh b/scripts/android-staging-build.sh index d7a9e66..f379c46 100755 --- a/scripts/android-staging-build.sh +++ b/scripts/android-staging-build.sh @@ -3,6 +3,10 @@ set -eu ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ENV_FILE="$ROOT/.env" +config_home=${XDG_CONFIG_HOME:-"$HOME/.config"} +SIGNING_DIR=${WNH_ANDROID_STAGING_SIGNING_DIR:-"$config_home/who_need_help/android-staging"} +KEYSTORE="$SIGNING_DIR/who-need-help-staging.p12" +PASSWORD_FILE="$SIGNING_DIR/who-need-help-staging.password" "$ROOT/scripts/ensure-local-public-origin.sh" @@ -14,11 +18,36 @@ set +a : "${WNH_BASE_URL:?Set WNH_BASE_URL in .env}" : "${WNH_TRACKING_MIN_TIME_MS:?Set WNH_TRACKING_MIN_TIME_MS in .env}" : "${WNH_TRACKING_HTTP_TIMEOUT_MS:?Set WNH_TRACKING_HTTP_TIMEOUT_MS in .env}" +: "${WNH_ANDROID_VERSION_CODE:?Set WNH_ANDROID_VERSION_CODE in .env}" +: "${WNH_ANDROID_VERSION_NAME:?Set WNH_ANDROID_VERSION_NAME in .env}" +: "${WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS:?Set WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS in .env}" -exec docker build \ +for secret_file in "$KEYSTORE" "$PASSWORD_FILE"; do + if [ ! -f "$secret_file" ]; then + echo "Missing Android staging signing file: $secret_file" >&2 + echo "Run scripts/init-android-staging-signing.sh once." >&2 + exit 1 + fi + + mode=$(stat -c '%a' "$secret_file") + case "$mode" in + 400|600) ;; + *) + echo "Android staging signing file must have mode 0400 or 0600: $secret_file" >&2 + exit 1 + ;; + esac +done + +docker build \ + --secret "id=android_staging_keystore,src=$KEYSTORE" \ + --secret "id=android_staging_password,src=$PASSWORD_FILE" \ + --secret "id=android_staging_alias,env=WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS" \ --build-arg "WNH_BASE_URL=$WNH_BASE_URL" \ --build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \ --build-arg "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_MS" \ + --build-arg "WNH_ANDROID_VERSION_CODE=$WNH_ANDROID_VERSION_CODE" \ + --build-arg "WNH_ANDROID_VERSION_NAME=$WNH_ANDROID_VERSION_NAME" \ --build-arg "WNH_FIREBASE_APPLICATION_ID=${WNH_FIREBASE_APPLICATION_ID:-}" \ --build-arg "WNH_FIREBASE_CLIENT_VALUE=${WNH_FIREBASE_API_KEY:-}" \ --build-arg "WNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID:-}" \ @@ -26,3 +55,8 @@ exec docker build \ --target staging-artifact \ --output "type=local,dest=$ROOT/android/dist-staging" \ "$ROOT/android" + +"$ROOT/scripts/android-app-links-verify.sh" \ + "$ENV_FILE" \ + "$ROOT/android/dist-staging" \ + --online diff --git a/scripts/init-android-release-signing.sh b/scripts/init-android-release-signing.sh index 3d70cc8..bc46898 100755 --- a/scripts/init-android-release-signing.sh +++ b/scripts/init-android-release-signing.sh @@ -5,13 +5,15 @@ umask 077 config_home=${XDG_CONFIG_HOME:-"$HOME/.config"} SIGNING_DIR=${WNH_ANDROID_SIGNING_DIR:-"$config_home/who_need_help/android-release"} -KEYSTORE="$SIGNING_DIR/who-need-help-upload.p12" -PASSWORD_FILE="$SIGNING_DIR/who-need-help-upload.password" +SIGNING_BASENAME=${WNH_ANDROID_SIGNING_BASENAME:-who-need-help-upload} +KEYSTORE="$SIGNING_DIR/$SIGNING_BASENAME.p12" +PASSWORD_FILE="$SIGNING_DIR/$SIGNING_BASENAME.password" KEY_ALIAS=${WNH_ANDROID_SIGNING_KEY_ALIAS:-who-need-help-upload} +SUBJECT=${WNH_ANDROID_SIGNING_SUBJECT:-"CN=Who Need Help upload key"} KEY_IMAGE="gradle:9.6.1-jdk17@sha256:7364ce528f33bb6038672bcef990d524f1ad8fbc292935819c235db886d0fae7" run_id="$$-$(openssl rand -hex 4)" -temporary_keystore="$SIGNING_DIR/.who-need-help-upload.$run_id.p12" -temporary_password="$SIGNING_DIR/.who-need-help-upload.$run_id.password" +temporary_keystore="$SIGNING_DIR/.$SIGNING_BASENAME.$run_id.p12" +temporary_password="$SIGNING_DIR/.$SIGNING_BASENAME.$run_id.password" cleanup() { if [ -e "$temporary_keystore" ]; then @@ -30,6 +32,13 @@ case "$KEY_ALIAS" in ;; esac +case "$SIGNING_BASENAME" in + ''|*[!A-Za-z0-9._-]*) + echo "WNH_ANDROID_SIGNING_BASENAME must use only letters, digits, dot, underscore, and dash." >&2 + exit 1 + ;; +esac + if [ -L "$SIGNING_DIR" ]; then echo "Refusing to use a symlink as the Android signing directory: $SIGNING_DIR" >&2 exit 1 @@ -61,7 +70,7 @@ docker run --rm \ -keyalg RSA \ -keysize 2048 \ -validity 10000 \ - -dname "CN=Who Need Help upload key" + -dname "$SUBJECT" docker run --rm \ --user "$(id -u):$(id -g)" \ @@ -78,7 +87,7 @@ chmod 600 "$temporary_keystore" mv "$temporary_keystore" "$KEYSTORE" mv "$temporary_password" "$PASSWORD_FILE" -echo "Generated a dedicated Android upload key without placing secrets in the repository." +echo "Generated dedicated Android signing material without placing secrets in the repository." echo "Private keystore: $KEYSTORE" echo "Password file: $PASSWORD_FILE" -echo "Back up both files before the first Play Console upload." +echo "Back up both files before distributing an application signed with this identity." diff --git a/scripts/init-android-staging-signing.sh b/scripts/init-android-staging-signing.sh new file mode 100755 index 0000000..d3ec116 --- /dev/null +++ b/scripts/init-android-staging-signing.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +config_home=${XDG_CONFIG_HOME:-"$HOME/.config"} + +WNH_ANDROID_SIGNING_DIR=${WNH_ANDROID_STAGING_SIGNING_DIR:-"$config_home/who_need_help/android-staging"} \ +WNH_ANDROID_SIGNING_BASENAME=who-need-help-staging \ +WNH_ANDROID_SIGNING_KEY_ALIAS=${WNH_ANDROID_STAGING_SIGNING_KEY_ALIAS:-who-need-help-staging} \ +WNH_ANDROID_SIGNING_SUBJECT="CN=Who Need Help staging key" \ + exec "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)/init-android-release-signing.sh" diff --git a/scripts/init-production-env.sh b/scripts/init-production-env.sh index 6da31c7..b5878ee 100755 --- a/scripts/init-production-env.sh +++ b/scripts/init-production-env.sh @@ -53,6 +53,8 @@ public_upstream_name=${PRODUCTION_PUBLIC_UPSTREAM_NAME:-who-need-help-production codex_session_id=${PRODUCTION_CODEX_SESSION_ID:-} google_oauth_client_id=${PRODUCTION_GOOGLE_OAUTH_CLIENT_ID:-} google_oauth_client_secret=${PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET:-} +android_app_links_package_name=${PRODUCTION_ANDROID_APP_LINKS_PACKAGE_NAME:-} +android_app_links_fingerprints=${PRODUCTION_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS:-} test_domain=${PRODUCTION_TEST_DOMAIN:-"test.$domain"} test_upstream=${PRODUCTION_TEST_UPSTREAM:-who-need-help-test:4000} edge_compose_project_name=${PRODUCTION_EDGE_COMPOSE_PROJECT_NAME:-who_need_help_edge} @@ -69,6 +71,12 @@ if { [ -n "$google_oauth_client_id" ] || [ -n "$google_oauth_client_secret" ]; } exit 1 fi +if { [ -n "$android_app_links_package_name" ] || [ -n "$android_app_links_fingerprints" ]; } && + { [ -z "$android_app_links_package_name" ] || [ -z "$android_app_links_fingerprints" ]; }; then + echo "Production Android App Links package and fingerprints must either both be set or both be empty." >&2 + exit 1 +fi + case "$compose_project_name" in *[!a-zA-Z0-9_-]* | '') echo "PRODUCTION_COMPOSE_PROJECT_NAME must contain only letters, numbers, underscores, or hyphens." >&2 @@ -179,6 +187,8 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ GIT_SHA_VALUE=$git_sha \ GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \ GOOGLE_OAUTH_CLIENT_SECRET_VALUE=$google_oauth_client_secret \ +ANDROID_APP_LINKS_PACKAGE_NAME_VALUE=$android_app_links_package_name \ +ANDROID_APP_LINKS_FINGERPRINTS_VALUE=$android_app_links_fingerprints \ EDGE_COMPOSE_PROJECT_NAME_VALUE=$edge_compose_project_name \ PRIMARY_UPSTREAM_VALUE="$public_upstream_name:4000" \ TEST_DOMAIN_VALUE=$test_domain \ @@ -234,6 +244,8 @@ TEST_UPSTREAM_VALUE=$test_upstream \ replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_SECRET"] = ENVIRON["GOOGLE_OAUTH_CLIENT_SECRET_VALUE"] + replacement["ANDROID_APP_LINKS_PACKAGE_NAME"] = ENVIRON["ANDROID_APP_LINKS_PACKAGE_NAME_VALUE"] + replacement["ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS"] = ENVIRON["ANDROID_APP_LINKS_FINGERPRINTS_VALUE"] replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"] } { @@ -255,6 +267,7 @@ trap - EXIT HUP INT TERM unset postgres_password secret_key_base handover_secret release_cookie metrics_token unset smtp_password unset google_oauth_client_secret +unset android_app_links_fingerprints echo "Generated independent deployment secrets without printing them." echo "Created the single mode-0600 production configuration: $target" diff --git a/scripts/init-test-env.sh b/scripts/init-test-env.sh index 317b4cb..5b76b0e 100755 --- a/scripts/init-test-env.sh +++ b/scripts/init-test-env.sh @@ -53,6 +53,8 @@ mailpit_port=${TEST_MAILPIT_PORT:-8027} codex_session_id=${TEST_CODEX_SESSION_ID:-} google_oauth_client_id=${TEST_GOOGLE_OAUTH_CLIENT_ID:-} google_oauth_client_secret=${TEST_GOOGLE_OAUTH_CLIENT_SECRET:-} +android_app_links_package_name=${TEST_ANDROID_APP_LINKS_PACKAGE_NAME:-} +android_app_links_fingerprints=${TEST_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS:-} support_inbox_address=${TEST_SUPPORT_INBOX_ADDRESS:-} git_sha=$(git -C "$ROOT" rev-parse --short=12 HEAD) @@ -79,6 +81,13 @@ if [[ -n "$google_oauth_client_id" || -n "$google_oauth_client_secret" ]]; then } fi +if [[ -n "$android_app_links_package_name" || -n "$android_app_links_fingerprints" ]]; then + [[ -n "$android_app_links_package_name" && -n "$android_app_links_fingerprints" ]] || { + echo "Test Android App Links package and fingerprints must either both be set or both be empty." >&2 + exit 1 + } +fi + for pair in "HTTP:$http_port" "MAILPIT:$mailpit_port"; do value=${pair#*:} if [[ ! "$value" =~ ^[0-9]+$ ]] || ((value < 1 || value > 65535)); then @@ -123,6 +132,8 @@ RELEASE_COOKIE_VALUE=$release_cookie \ METRICS_TOKEN_VALUE=$metrics_token \ GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \ GOOGLE_OAUTH_CLIENT_SECRET_VALUE=$google_oauth_client_secret \ +ANDROID_APP_LINKS_PACKAGE_NAME_VALUE=$android_app_links_package_name \ +ANDROID_APP_LINKS_FINGERPRINTS_VALUE=$android_app_links_fingerprints \ SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \ CODEX_SESSION_ID_VALUE=$codex_session_id \ awk ' @@ -174,6 +185,8 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \ replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_SECRET"] = ENVIRON["GOOGLE_OAUTH_CLIENT_SECRET_VALUE"] + replacement["ANDROID_APP_LINKS_PACKAGE_NAME"] = ENVIRON["ANDROID_APP_LINKS_PACKAGE_NAME_VALUE"] + replacement["ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS"] = ENVIRON["ANDROID_APP_LINKS_FINGERPRINTS_VALUE"] replacement["CODEX_SESSION_ID"] = ENVIRON["CODEX_SESSION_ID_VALUE"] } { @@ -189,6 +202,7 @@ trap - EXIT HUP INT TERM unset postgres_password secret_key_base handover_secret release_cookie metrics_token unset google_oauth_client_secret +unset android_app_links_fingerprints "$ROOT/scripts/compose.sh" "$target" config --quiet echo "Generated independent test secrets without printing them." diff --git a/scripts/quality.sh b/scripts/quality.sh index 1105ec7..455346c 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -125,6 +125,8 @@ fi TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ TEST_GOOGLE_OAUTH_CLIENT_ID=quality-test-client \ TEST_GOOGLE_OAUTH_CLIENT_SECRET=quality-test-secret \ +TEST_ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.staging \ +TEST_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=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 \ ./scripts/init-test-env.sh test.help.test "$test_env" >/dev/null test "$(stat -c '%a' "$test_env")" = 600 grep -Fx 'DEPLOYMENT_ENV=test' "$test_env" >/dev/null @@ -142,6 +144,8 @@ grep -Fx 'EMAIL_DELIVERY_PROVIDER=smtp' "$test_env" >/dev/null grep -Fx 'SMTP_RELAY=mailpit' "$test_env" >/dev/null grep -Fx 'EMAIL_FROM_NAME="Who Need Help Test"' "$test_env" >/dev/null grep -Fx 'GOOGLE_OAUTH_CLIENT_ID=quality-test-client' "$test_env" >/dev/null +grep -Fx 'ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile.staging' "$test_env" >/dev/null +grep -Fx 'ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=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' "$test_env" >/dev/null ./scripts/validate-test-env.sh "$test_env" test.help.test >/dev/null if TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ TEST_GOOGLE_OAUTH_CLIENT_ID=quality-test-client \ @@ -182,6 +186,8 @@ PRODUCTION_EMAIL_FROM_ADDRESS=contact@help.test \ PRODUCTION_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ PRODUCTION_GOOGLE_OAUTH_CLIENT_ID=quality-production-client \ PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET=quality-production-secret \ +PRODUCTION_ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile \ +PRODUCTION_ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=A5:74:2B:AE:70:C6:D0:34:E3:75:44:B6:2E:37:A3:75:C0:E0:05:64:74:50:F4:0F:29:B2:A9:84:F9:FD:B8:FB \ ./scripts/init-production-env.sh help.test "$production_env" >/dev/null test "$(stat -c '%a' "$production_env")" = 600 ./scripts/validate-production-env.sh "$production_env" help.test >/dev/null @@ -190,6 +196,8 @@ grep -E '^APP_IMAGE=who-need-help:production-[0-9a-f]{12}$' "$production_env" >/ grep -Fx 'EDGE_COMPOSE_PROJECT_NAME=who_need_help_edge' "$production_env" >/dev/null grep -Fx 'PRIMARY_DOMAIN=help.test' "$production_env" >/dev/null grep -Fx 'TEST_DOMAIN=test.help.test' "$production_env" >/dev/null +grep -Fx 'ANDROID_APP_LINKS_PACKAGE_NAME=org.whoneedhelp.mobile' "$production_env" >/dev/null +grep -Fx 'ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS=A5:74:2B:AE:70:C6:D0:34:E3:75:44:B6:2E:37:A3:75:C0:E0:05:64:74:50:F4:0F:29:B2:A9:84:F9:FD:B8:FB' "$production_env" >/dev/null ./scripts/validate-edge-env.sh "$production_env" >/dev/null test_checkout="$scan_dir/test-checkout" diff --git a/scripts/validate-production-env.sh b/scripts/validate-production-env.sh index 4b836d9..6cd5868 100755 --- a/scripts/validate-production-env.sh +++ b/scripts/validate-production-env.sh @@ -110,6 +110,8 @@ email_from_address=$(require_value EMAIL_FROM_ADDRESS) support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS) google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID) google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET) +android_app_links_package_name=$(optional_value ANDROID_APP_LINKS_PACKAGE_NAME) +android_app_links_fingerprints=$(optional_value ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS) codex_session_id=$(require_value CODEX_SESSION_ID) edge_compose_project_name=$(require_value EDGE_COMPOSE_PROJECT_NAME) caddy_image=$(require_value CADDY_IMAGE) @@ -316,6 +318,31 @@ if [[ -n "$google_oauth_client_id" || -n "$google_oauth_client_secret" ]]; then reject_marker GOOGLE_OAUTH_CLIENT_SECRET "$google_oauth_client_secret" fi +if [[ -n "$android_app_links_package_name" || -n "$android_app_links_fingerprints" ]]; then + [[ -n "$android_app_links_package_name" && -n "$android_app_links_fingerprints" ]] || { + echo "Android App Links package and fingerprints must either both be set or both be empty." >&2 + exit 1 + } + [[ "$android_app_links_package_name" =~ ^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$ ]] || { + echo "ANDROID_APP_LINKS_PACKAGE_NAME is not a valid Android application ID." >&2 + exit 1 + } + + IFS=',' read -r -a android_fingerprints <<<"$android_app_links_fingerprints" + [[ ${#android_fingerprints[@]} -gt 0 ]] || { + echo "ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS is empty." >&2 + exit 1 + } + for fingerprint in "${android_fingerprints[@]}"; do + compact_fingerprint=${fingerprint//:/} + compact_fingerprint=${compact_fingerprint//[[:space:]]/} + [[ "$compact_fingerprint" =~ ^[0-9A-Fa-f]{64}$ ]] || { + echo "ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS contains an invalid SHA-256 fingerprint." >&2 + exit 1 + } + done +fi + case "$codex_session_id" in not-configured | copy-the-main-local-codex-session-id) echo "CODEX_SESSION_ID must identify the Build Week Codex session." >&2 diff --git a/scripts/validate-test-env.sh b/scripts/validate-test-env.sh index 3b8312b..058d3b5 100755 --- a/scripts/validate-test-env.sh +++ b/scripts/validate-test-env.sh @@ -124,6 +124,29 @@ if [[ -n "$google_id" || -n "$google_secret" ]]; then } fi +android_package=$(read_value ANDROID_APP_LINKS_PACKAGE_NAME 2>/dev/null || true) +android_fingerprints=$(read_value ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS 2>/dev/null || true) +if [[ -n "$android_package" || -n "$android_fingerprints" ]]; then + [[ -n "$android_package" && -n "$android_fingerprints" ]] || { + echo "Test Android App Links package and fingerprints must be configured together." >&2 + exit 1 + } + [[ "$android_package" =~ ^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$ ]] || { + echo "ANDROID_APP_LINKS_PACKAGE_NAME is not a valid Android application ID." >&2 + exit 1 + } + + IFS=',' read -r -a android_fingerprint_values <<<"$android_fingerprints" + for fingerprint in "${android_fingerprint_values[@]}"; do + compact_fingerprint=${fingerprint//:/} + compact_fingerprint=${compact_fingerprint//[[:space:]]/} + [[ "$compact_fingerprint" =~ ^[0-9A-Fa-f]{64}$ ]] || { + echo "ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS contains an invalid SHA-256 fingerprint." >&2 + exit 1 + } + done +fi + secrets=( "$(require_value POSTGRES_PASSWORD)" "$(require_value SECRET_KEY_BASE)" diff --git a/test/who_need_help_web/controllers/android_app_links_controller_test.exs b/test/who_need_help_web/controllers/android_app_links_controller_test.exs new file mode 100644 index 0000000..df3ddf4 --- /dev/null +++ b/test/who_need_help_web/controllers/android_app_links_controller_test.exs @@ -0,0 +1,45 @@ +defmodule WhoNeedHelpWeb.AndroidAppLinksControllerTest do + use WhoNeedHelpWeb.ConnCase, async: false + + setup do + previous = Application.get_env(:who_need_help, :android_app_links) + + on_exit(fn -> + Application.put_env(:who_need_help, :android_app_links, previous) + end) + + :ok + end + + test "returns 404 when no signed Android application is configured", %{conn: conn} do + Application.put_env(:who_need_help, :android_app_links, nil) + + conn = get(conn, ~p"/.well-known/assetlinks.json") + + assert json_response(conn, 404) == %{"error" => "android_app_links_not_configured"} + end + + test "publishes the configured Android package and signing fingerprints", %{conn: conn} do + 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" + + Application.put_env(:who_need_help, :android_app_links, %{ + package_name: "org.whoneedhelp.mobile.staging", + sha256_cert_fingerprints: [fingerprint] + }) + + conn = get(conn, ~p"/.well-known/assetlinks.json") + + assert [statement] = json_response(conn, 200) + assert statement["relation"] == ["delegate_permission/common.handle_all_urls"] + + assert statement["target"] == %{ + "namespace" => "android_app", + "package_name" => "org.whoneedhelp.mobile.staging", + "sha256_cert_fingerprints" => [fingerprint] + } + + assert get_resp_header(conn, "cache-control") == ["public, max-age=300"] + end +end