Add environment-specific verified Android App Links

This commit is contained in:
SimpleTest 2026-07-23 19:31:44 +03:00
parent 0dab9873b3
commit f18e76b201
23 changed files with 508 additions and 22 deletions

View File

@ -90,10 +90,21 @@ WNH_FIREBASE_APPLICATION_ID=
WNH_FIREBASE_API_KEY= WNH_FIREBASE_API_KEY=
WNH_FIREBASE_PROJECT_ID= WNH_FIREBASE_PROJECT_ID=
WNH_FIREBASE_GCM_SENDER_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 # Public identifier of the locally held Google Play upload key. The private
# keystore and its randomized password live outside the repository under # keystore and its randomized password live outside the repository under
# ~/.config/who_need_help/android-release/. # ~/.config/who_need_help/android-release/.
WNH_ANDROID_SIGNING_KEY_ALIAS=who-need-help-upload 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_API_MATRIX="24 30 34 37.0"
WNH_ANDROID_TEST_DATA_PARTITION_SIZE=1G WNH_ANDROID_TEST_DATA_PARTITION_SIZE=1G
# Public raster tile template used by MapLibre. Use a provider whose policy and # Public raster tile template used by MapLibre. Use a provider whose policy and

View File

@ -84,8 +84,11 @@ local Codex CLI authenticated with their ChatGPT subscription.
location foreground service. Its persistent location foreground service. Its persistent
notification exposes Stop, it continues while the Activity is minimized, and notification exposes Stop, it continues while the Activity is minimized, and
it retains only the current point. Reproducible Docker targets export it retains only the current point. Reproducible Docker targets export
distinct local and public-staging debug APKs; production signing and store distinct debug and stable-signed staging APKs plus a production-signed APK
publication are not configured. 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 - 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. Codex CLI. It receives a PII-free export and never writes to the database.
- One immutable release image with `web`, `worker`, combined `app`, and - 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. replicas by default.
Additional social providers, background PWA or unattended location tracking, 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 automatic punitive fraud decisions, and jurisdiction-specific public-launch
policies are deliberately not claimed as complete. policies are deliberately not claimed as complete.

View File

@ -149,6 +149,7 @@ COPY --from=android-sdk \
FROM android-base AS android-staging-sdk FROM android-base AS android-staging-sdk
USER gradle USER gradle
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
WORKDIR /workspace/android WORKDIR /workspace/android
COPY --chown=gradle:gradle . . 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 \ 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=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 \ gradle --no-daemon \
"-PWNH_BASE_URL=${WNH_BASE_URL}" \ "-PWNH_BASE_URL=${WNH_BASE_URL}" \
"-PWNH_DEBUG_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_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID}" \
"-PWNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID}" \ "-PWNH_FIREBASE_GCM_SENDER_ID=${WNH_FIREBASE_GCM_SENDER_ID}" \
"-PWNH_TEST_BUILD_TYPE=staging" \ "-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 FROM scratch AS staging-artifact
@ -192,6 +208,12 @@ COPY --from=android-staging-sdk \
COPY --from=android-staging-sdk \ COPY --from=android-staging-sdk \
/workspace/android/app/build/reports/lint-results-staging.html \ /workspace/android/app/build/reports/lint-results-staging.html \
/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 FROM android-base AS android-release-base
@ -212,6 +234,7 @@ USER gradle
FROM android-release-base AS android-release-sdk FROM android-release-base AS android-release-sdk
USER gradle USER gradle
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
WORKDIR /workspace/android WORKDIR /workspace/android
COPY --chown=gradle:gradle . . COPY --chown=gradle:gradle . .
@ -248,6 +271,12 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
verify --verbose --print-certs \ verify --verbose --print-certs \
app/build/outputs/apk/release/app-release.apk \ app/build/outputs/apk/release/app-release.apk \
>app/build/outputs/apk/release/signing-certificate.txt \ >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 \ && LC_ALL=C jarsigner -verify -verbose -certs \
app/build/outputs/bundle/release/app-release.aab \ app/build/outputs/bundle/release/app-release.aab \
>app/build/outputs/bundle/release/signing-verification.txt \ >app/build/outputs/bundle/release/signing-verification.txt \
@ -270,6 +299,9 @@ COPY --from=android-release-sdk \
COPY --from=android-release-sdk \ COPY --from=android-release-sdk \
/workspace/android/app/build/outputs/apk/release/signing-certificate.txt \ /workspace/android/app/build/outputs/apk/release/signing-certificate.txt \
/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 \ COPY --from=android-release-sdk \
/workspace/android/app/build/outputs/bundle/release/signing-verification.txt \ /workspace/android/app/build/outputs/bundle/release/signing-verification.txt \
/bundle-signing-verification.txt /bundle-signing-verification.txt

View File

@ -170,6 +170,9 @@ android {
initWith(getByName("debug")) initWith(getByName("debug"))
applicationIdSuffix = ".staging" applicationIdSuffix = ".staging"
versionNameSuffix = "-staging" versionNameSuffix = "-staging"
if (releaseSigningConfigured) {
signingConfig = signingConfigs.getByName("release")
}
buildConfigField( buildConfigField(
"String", "String",
"BASE_URL", "BASE_URL",
@ -221,12 +224,12 @@ android {
tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.configureEach { tasks.matching { it.name == "preReleaseBuild" || it.name == "preStagingBuild" }.configureEach {
doFirst { doFirst {
validateFirebaseConfiguration() validateFirebaseConfiguration()
if (name == "preReleaseBuild" && !releaseSigningConfigured) { if (!releaseSigningConfigured) {
val detail = val detail =
if (releaseSigningPartiallyConfigured) { if (releaseSigningPartiallyConfigured) {
"Release signing is only partially configured" "Android signing is only partially configured"
} else { } else {
"Release signing is not configured" "Android signing is not configured"
} }
throw GradleException( throw GradleException(
"$detail; set WNH_ANDROID_SIGNING_STORE_FILE, " "$detail; set WNH_ANDROID_SIGNING_STORE_FILE, "

View File

@ -36,7 +36,7 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter android:autoVerify="false"> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />

View File

@ -56,6 +56,8 @@ x-app-environment: &app-environment
FCM_PROJECT_ID: ${FCM_PROJECT_ID:-} FCM_PROJECT_ID: ${FCM_PROJECT_ID:-}
FCM_SERVICE_ACCOUNT_FILE: ${FCM_SERVICE_ACCOUNT_FILE:-} FCM_SERVICE_ACCOUNT_FILE: ${FCM_SERVICE_ACCOUNT_FILE:-}
FCM_SERVICE_ACCOUNT_JSON_BASE64: ${FCM_SERVICE_ACCOUNT_JSON_BASE64:-} 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_MAINTENANCE_CONCURRENCY: ${OBAN_MAINTENANCE_CONCURRENCY:-2}
OBAN_PUSH_CONCURRENCY: ${OBAN_PUSH_CONCURRENCY:-1} OBAN_PUSH_CONCURRENCY: ${OBAN_PUSH_CONCURRENCY:-1}

View File

@ -35,6 +35,7 @@ config :who_need_help,
secure_cookies: false, secure_cookies: false,
rate_limit_policies: %{}, rate_limit_policies: %{},
map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
android_app_links: nil,
web_push_public_key: nil, web_push_public_key: nil,
fcm_goth_source: nil, fcm_goth_source: nil,
device_delivery_options: %{ device_delivery_options: %{

View File

@ -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 if config_env() == :prod and app_role in [:web, :worker, :combined] do
metrics_token = metrics_token =
System.get_env("METRICS_TOKEN") || System.get_env("METRICS_TOKEN") ||

View File

@ -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-production:4000` and `test.whoneedhelp.com` to
`who-need-help-test:4000`. `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: Create the test configuration inside the test checkout:
```bash ```bash

View File

@ -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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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 health endpoints, migrations, Google callback, and authentication-email flow
after that promotion; the current test origin still depends on its configured after that promotion; the current test origin still depends on its configured
workstation/VPN/gateway path. workstation/VPN/gateway path.
- Confirm the final Android application ID before creating its Play Console - The final Android application ID is `org.whoneedhelp.mobile`. The application
listing, publish `/.well-known/assetlinks.json` for that ID and the final now publishes environment-specific `/.well-known/assetlinks.json`, and the
signing fingerprint if verified App Links are wanted, and complete store stable-signed dev APK was checked against its HTTPS response. Before a Play
policy/release work. A dedicated upload key and signed APK/AAB have been release, register the application, add the Play App Signing certificate
created and verified locally, but no Play application has been registered. 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, - Operate PostgreSQL/PostGIS with off-site backups, recovery testing, monitoring,
and the availability model selected for real usage. and the availability model selected for real usage.
- After provider approval, verify that delivered MIME contains neither open nor - After provider approval, verify that delivered MIME contains neither open nor

View File

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

View File

@ -46,6 +46,12 @@ defmodule WhoNeedHelpWeb.Router do
get "/ready", HealthController, :ready get "/ready", HealthController, :ready
end end
scope "/.well-known", WhoNeedHelpWeb do
pipe_through :api
get "/assetlinks.json", AndroidAppLinksController, :show
end
scope "/", WhoNeedHelpWeb do scope "/", WhoNeedHelpWeb do
get "/metrics", MetricsController, :show get "/metrics", MetricsController, :show
end end

View File

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

View File

@ -74,6 +74,7 @@ for artifact in \
"$OUTPUT_DIR/who-need-help-release.apk" \ "$OUTPUT_DIR/who-need-help-release.apk" \
"$OUTPUT_DIR/who-need-help-release.aab" \ "$OUTPUT_DIR/who-need-help-release.aab" \
"$OUTPUT_DIR/signing-certificate.txt" \ "$OUTPUT_DIR/signing-certificate.txt" \
"$OUTPUT_DIR/package-name.txt" \
"$OUTPUT_DIR/bundle-signing-verification.txt" \ "$OUTPUT_DIR/bundle-signing-verification.txt" \
"$OUTPUT_DIR/bundletool-validation.txt" \ "$OUTPUT_DIR/bundletool-validation.txt" \
"$OUTPUT_DIR/lint-results-release.html"; do "$OUTPUT_DIR/lint-results-release.html"; do
@ -83,6 +84,11 @@ for artifact in \
fi fi
done 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 \ sha256sum \
"$OUTPUT_DIR/who-need-help-release.apk" \ "$OUTPUT_DIR/who-need-help-release.apk" \
"$OUTPUT_DIR/who-need-help-release.aab" "$OUTPUT_DIR/who-need-help-release.aab"

View File

@ -3,6 +3,10 @@ set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE="$ROOT/.env" 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" "$ROOT/scripts/ensure-local-public-origin.sh"
@ -14,11 +18,36 @@ set +a
: "${WNH_BASE_URL:?Set WNH_BASE_URL in .env}" : "${WNH_BASE_URL:?Set WNH_BASE_URL in .env}"
: "${WNH_TRACKING_MIN_TIME_MS:?Set WNH_TRACKING_MIN_TIME_MS 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_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_BASE_URL=$WNH_BASE_URL" \
--build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \ --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_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_APPLICATION_ID=${WNH_FIREBASE_APPLICATION_ID:-}" \
--build-arg "WNH_FIREBASE_CLIENT_VALUE=${WNH_FIREBASE_API_KEY:-}" \ --build-arg "WNH_FIREBASE_CLIENT_VALUE=${WNH_FIREBASE_API_KEY:-}" \
--build-arg "WNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID:-}" \ --build-arg "WNH_FIREBASE_PROJECT_ID=${WNH_FIREBASE_PROJECT_ID:-}" \
@ -26,3 +55,8 @@ exec docker build \
--target staging-artifact \ --target staging-artifact \
--output "type=local,dest=$ROOT/android/dist-staging" \ --output "type=local,dest=$ROOT/android/dist-staging" \
"$ROOT/android" "$ROOT/android"
"$ROOT/scripts/android-app-links-verify.sh" \
"$ENV_FILE" \
"$ROOT/android/dist-staging" \
--online

View File

@ -5,13 +5,15 @@ umask 077
config_home=${XDG_CONFIG_HOME:-"$HOME/.config"} config_home=${XDG_CONFIG_HOME:-"$HOME/.config"}
SIGNING_DIR=${WNH_ANDROID_SIGNING_DIR:-"$config_home/who_need_help/android-release"} SIGNING_DIR=${WNH_ANDROID_SIGNING_DIR:-"$config_home/who_need_help/android-release"}
KEYSTORE="$SIGNING_DIR/who-need-help-upload.p12" SIGNING_BASENAME=${WNH_ANDROID_SIGNING_BASENAME:-who-need-help-upload}
PASSWORD_FILE="$SIGNING_DIR/who-need-help-upload.password" KEYSTORE="$SIGNING_DIR/$SIGNING_BASENAME.p12"
PASSWORD_FILE="$SIGNING_DIR/$SIGNING_BASENAME.password"
KEY_ALIAS=${WNH_ANDROID_SIGNING_KEY_ALIAS:-who-need-help-upload} 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" KEY_IMAGE="gradle:9.6.1-jdk17@sha256:7364ce528f33bb6038672bcef990d524f1ad8fbc292935819c235db886d0fae7"
run_id="$$-$(openssl rand -hex 4)" run_id="$$-$(openssl rand -hex 4)"
temporary_keystore="$SIGNING_DIR/.who-need-help-upload.$run_id.p12" temporary_keystore="$SIGNING_DIR/.$SIGNING_BASENAME.$run_id.p12"
temporary_password="$SIGNING_DIR/.who-need-help-upload.$run_id.password" temporary_password="$SIGNING_DIR/.$SIGNING_BASENAME.$run_id.password"
cleanup() { cleanup() {
if [ -e "$temporary_keystore" ]; then if [ -e "$temporary_keystore" ]; then
@ -30,6 +32,13 @@ case "$KEY_ALIAS" in
;; ;;
esac 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 if [ -L "$SIGNING_DIR" ]; then
echo "Refusing to use a symlink as the Android signing directory: $SIGNING_DIR" >&2 echo "Refusing to use a symlink as the Android signing directory: $SIGNING_DIR" >&2
exit 1 exit 1
@ -61,7 +70,7 @@ docker run --rm \
-keyalg RSA \ -keyalg RSA \
-keysize 2048 \ -keysize 2048 \
-validity 10000 \ -validity 10000 \
-dname "CN=Who Need Help upload key" -dname "$SUBJECT"
docker run --rm \ docker run --rm \
--user "$(id -u):$(id -g)" \ --user "$(id -u):$(id -g)" \
@ -78,7 +87,7 @@ chmod 600 "$temporary_keystore"
mv "$temporary_keystore" "$KEYSTORE" mv "$temporary_keystore" "$KEYSTORE"
mv "$temporary_password" "$PASSWORD_FILE" 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 "Private keystore: $KEYSTORE"
echo "Password file: $PASSWORD_FILE" 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."

View File

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

View File

@ -53,6 +53,8 @@ public_upstream_name=${PRODUCTION_PUBLIC_UPSTREAM_NAME:-who-need-help-production
codex_session_id=${PRODUCTION_CODEX_SESSION_ID:-} codex_session_id=${PRODUCTION_CODEX_SESSION_ID:-}
google_oauth_client_id=${PRODUCTION_GOOGLE_OAUTH_CLIENT_ID:-} google_oauth_client_id=${PRODUCTION_GOOGLE_OAUTH_CLIENT_ID:-}
google_oauth_client_secret=${PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET:-} 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_domain=${PRODUCTION_TEST_DOMAIN:-"test.$domain"}
test_upstream=${PRODUCTION_TEST_UPSTREAM:-who-need-help-test:4000} test_upstream=${PRODUCTION_TEST_UPSTREAM:-who-need-help-test:4000}
edge_compose_project_name=${PRODUCTION_EDGE_COMPOSE_PROJECT_NAME:-who_need_help_edge} 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 exit 1
fi 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 case "$compose_project_name" in
*[!a-zA-Z0-9_-]* | '') *[!a-zA-Z0-9_-]* | '')
echo "PRODUCTION_COMPOSE_PROJECT_NAME must contain only letters, numbers, underscores, or hyphens." >&2 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 \ GIT_SHA_VALUE=$git_sha \
GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \ GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \
GOOGLE_OAUTH_CLIENT_SECRET_VALUE=$google_oauth_client_secret \ 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 \ EDGE_COMPOSE_PROJECT_NAME_VALUE=$edge_compose_project_name \
PRIMARY_UPSTREAM_VALUE="$public_upstream_name:4000" \ PRIMARY_UPSTREAM_VALUE="$public_upstream_name:4000" \
TEST_DOMAIN_VALUE=$test_domain \ TEST_DOMAIN_VALUE=$test_domain \
@ -234,6 +244,8 @@ TEST_UPSTREAM_VALUE=$test_upstream \
replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"] replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"]
replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"]
replacement["GOOGLE_OAUTH_CLIENT_SECRET"] = ENVIRON["GOOGLE_OAUTH_CLIENT_SECRET_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"] 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 postgres_password secret_key_base handover_secret release_cookie metrics_token
unset smtp_password unset smtp_password
unset google_oauth_client_secret unset google_oauth_client_secret
unset android_app_links_fingerprints
echo "Generated independent deployment secrets without printing them." echo "Generated independent deployment secrets without printing them."
echo "Created the single mode-0600 production configuration: $target" echo "Created the single mode-0600 production configuration: $target"

View File

@ -53,6 +53,8 @@ mailpit_port=${TEST_MAILPIT_PORT:-8027}
codex_session_id=${TEST_CODEX_SESSION_ID:-} codex_session_id=${TEST_CODEX_SESSION_ID:-}
google_oauth_client_id=${TEST_GOOGLE_OAUTH_CLIENT_ID:-} google_oauth_client_id=${TEST_GOOGLE_OAUTH_CLIENT_ID:-}
google_oauth_client_secret=${TEST_GOOGLE_OAUTH_CLIENT_SECRET:-} 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:-} support_inbox_address=${TEST_SUPPORT_INBOX_ADDRESS:-}
git_sha=$(git -C "$ROOT" rev-parse --short=12 HEAD) 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 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 for pair in "HTTP:$http_port" "MAILPIT:$mailpit_port"; do
value=${pair#*:} value=${pair#*:}
if [[ ! "$value" =~ ^[0-9]+$ ]] || ((value < 1 || value > 65535)); then if [[ ! "$value" =~ ^[0-9]+$ ]] || ((value < 1 || value > 65535)); then
@ -123,6 +132,8 @@ RELEASE_COOKIE_VALUE=$release_cookie \
METRICS_TOKEN_VALUE=$metrics_token \ METRICS_TOKEN_VALUE=$metrics_token \
GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \ GOOGLE_OAUTH_CLIENT_ID_VALUE=$google_oauth_client_id \
GOOGLE_OAUTH_CLIENT_SECRET_VALUE=$google_oauth_client_secret \ 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 \ SUPPORT_INBOX_ADDRESS_VALUE=$support_inbox_address \
CODEX_SESSION_ID_VALUE=$codex_session_id \ CODEX_SESSION_ID_VALUE=$codex_session_id \
awk ' awk '
@ -174,6 +185,8 @@ CODEX_SESSION_ID_VALUE=$codex_session_id \
replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"] replacement["SUPPORT_INBOX_ADDRESS"] = ENVIRON["SUPPORT_INBOX_ADDRESS_VALUE"]
replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"] replacement["GOOGLE_OAUTH_CLIENT_ID"] = ENVIRON["GOOGLE_OAUTH_CLIENT_ID_VALUE"]
replacement["GOOGLE_OAUTH_CLIENT_SECRET"] = ENVIRON["GOOGLE_OAUTH_CLIENT_SECRET_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"] 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 postgres_password secret_key_base handover_secret release_cookie metrics_token
unset google_oauth_client_secret unset google_oauth_client_secret
unset android_app_links_fingerprints
"$ROOT/scripts/compose.sh" "$target" config --quiet "$ROOT/scripts/compose.sh" "$target" config --quiet
echo "Generated independent test secrets without printing them." echo "Generated independent test secrets without printing them."

View File

@ -125,6 +125,8 @@ fi
TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
TEST_GOOGLE_OAUTH_CLIENT_ID=quality-test-client \ TEST_GOOGLE_OAUTH_CLIENT_ID=quality-test-client \
TEST_GOOGLE_OAUTH_CLIENT_SECRET=quality-test-secret \ 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 ./scripts/init-test-env.sh test.help.test "$test_env" >/dev/null
test "$(stat -c '%a' "$test_env")" = 600 test "$(stat -c '%a' "$test_env")" = 600
grep -Fx 'DEPLOYMENT_ENV=test' "$test_env" >/dev/null 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 'SMTP_RELAY=mailpit' "$test_env" >/dev/null
grep -Fx 'EMAIL_FROM_NAME="Who Need Help Test"' "$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 '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 ./scripts/validate-test-env.sh "$test_env" test.help.test >/dev/null
if TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \ if TEST_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
TEST_GOOGLE_OAUTH_CLIENT_ID=quality-test-client \ 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_CODEX_SESSION_ID=00000000-0000-0000-0000-000000000001 \
PRODUCTION_GOOGLE_OAUTH_CLIENT_ID=quality-production-client \ PRODUCTION_GOOGLE_OAUTH_CLIENT_ID=quality-production-client \
PRODUCTION_GOOGLE_OAUTH_CLIENT_SECRET=quality-production-secret \ 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 ./scripts/init-production-env.sh help.test "$production_env" >/dev/null
test "$(stat -c '%a' "$production_env")" = 600 test "$(stat -c '%a' "$production_env")" = 600
./scripts/validate-production-env.sh "$production_env" help.test >/dev/null ./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 'EDGE_COMPOSE_PROJECT_NAME=who_need_help_edge' "$production_env" >/dev/null
grep -Fx 'PRIMARY_DOMAIN=help.test' "$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 '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 ./scripts/validate-edge-env.sh "$production_env" >/dev/null
test_checkout="$scan_dir/test-checkout" test_checkout="$scan_dir/test-checkout"

View File

@ -110,6 +110,8 @@ email_from_address=$(require_value EMAIL_FROM_ADDRESS)
support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS) support_inbox_address=$(optional_value SUPPORT_INBOX_ADDRESS)
google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID) google_oauth_client_id=$(optional_value GOOGLE_OAUTH_CLIENT_ID)
google_oauth_client_secret=$(optional_value GOOGLE_OAUTH_CLIENT_SECRET) 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) codex_session_id=$(require_value CODEX_SESSION_ID)
edge_compose_project_name=$(require_value EDGE_COMPOSE_PROJECT_NAME) edge_compose_project_name=$(require_value EDGE_COMPOSE_PROJECT_NAME)
caddy_image=$(require_value CADDY_IMAGE) 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" reject_marker GOOGLE_OAUTH_CLIENT_SECRET "$google_oauth_client_secret"
fi 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 case "$codex_session_id" in
not-configured | copy-the-main-local-codex-session-id) not-configured | copy-the-main-local-codex-session-id)
echo "CODEX_SESSION_ID must identify the Build Week Codex session." >&2 echo "CODEX_SESSION_ID must identify the Build Week Codex session." >&2

View File

@ -124,6 +124,29 @@ if [[ -n "$google_id" || -n "$google_secret" ]]; then
} }
fi 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=( secrets=(
"$(require_value POSTGRES_PASSWORD)" "$(require_value POSTGRES_PASSWORD)"
"$(require_value SECRET_KEY_BASE)" "$(require_value SECRET_KEY_BASE)"

View File

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