diff --git a/.github/workflows/deploy-well-known-worker.yml b/.github/workflows/deploy-well-known-worker.yml new file mode 100644 index 0000000000..bad3fa6809 --- /dev/null +++ b/.github/workflows/deploy-well-known-worker.yml @@ -0,0 +1,91 @@ +name: Deploy well-known Worker + +# Deploys infra/well-known-worker, which serves +# https:///.well-known/assetlinks.json out of the private "well-known" R2 +# bucket. The object itself is written by signing-fingerprint.yml; this workflow +# owns only the code that reads it, and does not verify the served result - +# signing-fingerprint.yml already does that end to end when it deploys. +# +# A Cloudflare Origin Rule cannot do this job on the Free plan: host header, SNI +# and DNS record overrides are Enterprise-only, and R2 selects a bucket from the +# Host header. The Worker uses an R2 binding instead, so the bucket stays private. +# +# Requires a CLOUDFLARE_WORKERS_DEPLOY_TOKEN secret with: +# Account -> Workers Scripts -> Edit (upload the script) +# Account -> Workers R2 Storage -> Read (see below) +# Zone -> Workers Routes -> Edit (attach the routes on appdevforall.org) +# The R2 read scope is not optional: wrangler resolves the bucket named in the +# r2_buckets binding via GET /accounts//r2/buckets/well-known and fails the +# deploy with "Authentication error [code: 10000]" without it. Note that a scope +# added to an existing token takes a few minutes to take effect - that same error +# persists across an immediate re-run, so wait before concluding the scope is wrong. +# The existing CLOUDFLARE_KEY_ID / CLOUDFLARE_SECRET_ACCESS_KEY pair is an R2 +# S3-compatible credential and cannot deploy a Worker. + +on: + workflow_dispatch: + # Dispatch is only offered for workflows on the default branch, so run on push + # to let this work from a feature branch before it reaches stage. Note that a + # push on any branch therefore deploys the live Worker. + push: + paths: + - 'infra/well-known-worker/**' + - '.github/workflows/deploy-well-known-worker.yml' + +permissions: + contents: read + +# One deploy at a time: concurrent uploads of the same script race on the routes. +concurrency: + group: deploy-well-known-worker + cancel-in-progress: false + +jobs: + deploy: + name: Deploy Worker + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Check Cloudflare credentials + env: + CLOUDFLARE_WORKERS_DEPLOY_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_DEPLOY_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + + # wrangler reports a missing token as an opaque auth error, so name the + # actual gap here. Required scopes are listed at the top of this file. + for var in CLOUDFLARE_WORKERS_DEPLOY_TOKEN CLOUDFLARE_ACCOUNT_ID; do + if [ -z "${!var:-}" ]; then + echo "ERROR: $var is not set. See the header of this workflow." >&2 + exit 1 + fi + done + + - name: Deploy with Wrangler + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_WORKERS_DEPLOY_TOKEN }} + accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + workingDirectory: infra/well-known-worker + wranglerVersion: '4.124.0' + command: deploy + + - name: Write job summary + run: | + set -euo pipefail + { + echo "## well-known Worker deployed" + echo + echo "Routes now served from the \`well-known\` R2 bucket:" + echo + echo "- \`https://appdevforall.org/.well-known/assetlinks.json\`" + echo "- \`https://www.appdevforall.org/.well-known/assetlinks.json\`" + echo + echo "A route with no matching object falls through to the site origin." + echo "Run **Print release signing certificate fingerprint** with \`deploy\` enabled to publish the object and verify it end to end." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/signing-fingerprint.yml b/.github/workflows/signing-fingerprint.yml new file mode 100644 index 0000000000..d78b65fc8c --- /dev/null +++ b/.github/workflows/signing-fingerprint.yml @@ -0,0 +1,341 @@ +name: Print release signing certificate fingerprint + +# Emits the SHA-256 certificate fingerprint of the release signing key, plus a +# ready-to-deploy Digital Asset Links file for Android App Link verification. +# +# The keystore only exists inside CI: SigningKeyUtils.downloadSigningKey() +# base64-decodes IDE_SIGNING_KEY_BIN into build/signing/signing-key.jks. This +# workflow decodes the same secret directly, so no Gradle build is needed. +# +# A certificate fingerprint is public data - it is published in assetlinks.json. +# No private key material is printed. +# +# With deploy=true the file is written to the "well-known" R2 bucket at key +# .well-known/assetlinks.json. The Worker in infra/well-known-worker serves that +# bucket at https:///.well-known/assetlinks.json, deriving the object key +# from the request path, so the key has to match the path exactly. The Worker is +# deployed by deploy-well-known-worker.yml; this workflow owns only the object. + +on: + workflow_dispatch: + inputs: + hosts: + description: 'Hosts serving assetlinks.json (comma-separated). Drives the deploy checklist and, when deploy is enabled, which hosts are verified. The file itself is host-independent.' + required: false + default: 'appdevforall.org,www.appdevforall.org' + extra_fingerprints: + description: 'Additional SHA-256 fingerprints to include (comma-separated, colon-hex). Use for developer debug keys when testing App Links locally.' + required: false + default: '' + deploy: + description: 'Upload the generated file to R2 and verify it is served. Leave off to only produce the artifact.' + type: boolean + required: false + default: false + # Dispatch is only offered for workflows on the default branch, so run on push + # of this file to let it work from a feature branch before it reaches stage. + push: + paths: + - '.github/workflows/signing-fingerprint.yml' + +permissions: + contents: read + +jobs: + fingerprint: + name: Fingerprint release signing key + runs-on: ubuntu-latest + timeout-minutes: 10 + + env: + IDE_SIGNING_ALIAS: ${{ secrets.IDE_SIGNING_ALIAS }} + IDE_SIGNING_STORE_PASS: ${{ secrets.IDE_SIGNING_STORE_PASS }} + IDE_SIGNING_KEY_BIN: ${{ secrets.IDE_SIGNING_KEY_BIN }} + R2_BUCKET: well-known + R2_KEY: .well-known/assetlinks.json + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Materialize keystore from IDE_SIGNING_KEY_BIN + run: | + set -euo pipefail + + # The runner context is step-scoped, so derive these here rather than in + # the job-level env block, where ${{ runner.temp }} would be empty. + echo "KEYSTORE=$RUNNER_TEMP/signing-key.jks" >> "$GITHUB_ENV" + echo "ASSETLINKS=$RUNNER_TEMP/assetlinks.json" >> "$GITHUB_ENV" + KEYSTORE="$RUNNER_TEMP/signing-key.jks" + + for var in IDE_SIGNING_KEY_BIN IDE_SIGNING_ALIAS IDE_SIGNING_STORE_PASS; do + if [ -z "${!var:-}" ]; then + echo "ERROR: $var is not set. Check the repository secrets." >&2 + exit 1 + fi + done + + # Keystore lives outside the workspace so it cannot be picked up by an + # artifact upload or a later checkout. + printf '%s' "$IDE_SIGNING_KEY_BIN" | base64 -d > "$KEYSTORE" + chmod 600 "$KEYSTORE" + + if [ ! -s "$KEYSTORE" ]; then + echo "ERROR: decoded keystore is empty - IDE_SIGNING_KEY_BIN is not valid base64." >&2 + exit 1 + fi + echo "Decoded keystore: $(stat -c %s "$KEYSTORE") bytes" + + - name: Extract SHA-256 fingerprint + id: fp + run: | + set -euo pipefail + + if ! keytool -list -v \ + -keystore "$KEYSTORE" \ + -storepass "$IDE_SIGNING_STORE_PASS" \ + -alias "$IDE_SIGNING_ALIAS" > "$RUNNER_TEMP/keytool.txt" 2>&1; then + echo "ERROR: keytool failed. Alias in IDE_SIGNING_ALIAS may not match the keystore." >&2 + sed 's/^/ /' "$RUNNER_TEMP/keytool.txt" >&2 + echo "Entries present in the keystore:" >&2 + keytool -list -keystore "$KEYSTORE" -storepass "$IDE_SIGNING_STORE_PASS" \ + | grep -E 'Entry|entry' >&2 || true + exit 1 + fi + + fingerprint=$(awk '/SHA256:/ { print $2; exit }' "$RUNNER_TEMP/keytool.txt") + if [ -z "$fingerprint" ]; then + echo "ERROR: no SHA256 line in keytool output." >&2 + exit 1 + fi + + # Cross-check via OpenSSL against the exported certificate. A wrong + # fingerprint fails App Link verification silently, so verify it twice. + openssl_fp=$(keytool -exportcert -rfc \ + -keystore "$KEYSTORE" \ + -storepass "$IDE_SIGNING_STORE_PASS" \ + -alias "$IDE_SIGNING_ALIAS" \ + | openssl x509 -noout -fingerprint -sha256 \ + | cut -d= -f2) + + if [ "$fingerprint" != "$openssl_fp" ]; then + echo "ERROR: keytool and openssl disagree:" >&2 + echo " keytool: $fingerprint" >&2 + echo " openssl: $openssl_fp" >&2 + exit 1 + fi + + echo "SHA-256: $fingerprint" + echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT" + + # Certificate identity, useful for confirming this is the key you expect. + grep -E '^(Owner|Issuer|Valid from):' "$RUNNER_TEMP/keytool.txt" || true + + - name: Resolve application ID + id: pkg + run: | + set -euo pipefail + config=composite-builds/build-logic/common/src/main/java/com/itsaky/androidide/build/config/BuildConfig.kt + pkg=$(sed -n 's/.*PACKAGE_NAME[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$config" | head -1) + if [ -z "$pkg" ]; then + echo "ERROR: could not read PACKAGE_NAME from $config" >&2 + exit 1 + fi + echo "Application ID: $pkg" + echo "package=$pkg" >> "$GITHUB_OUTPUT" + + - name: Generate assetlinks.json + env: + PKG: ${{ steps.pkg.outputs.package }} + FINGERPRINT: ${{ steps.fp.outputs.fingerprint }} + EXTRA: ${{ inputs.extra_fingerprints }} + run: | + set -euo pipefail + + # Normalize to uppercase colon-separated hex, drop blanks and duplicates. + # Strip spaces/tabs/CR only - deleting newlines here would splice the + # fingerprints into one unmatchable string. + fingerprints=$(printf '%s,%s' "$FINGERPRINT" "$EXTRA" \ + | tr ',' '\n' \ + | tr -d ' \t\r' \ + | tr '[:lower:]' '[:upper:]' \ + | grep -E '^([0-9A-F]{2}:){31}[0-9A-F]{2}$' \ + | awk '!seen[$0]++' \ + | jq -R . | jq -s .) + + jq -n --arg pkg "$PKG" --argjson fps "$fingerprints" '[ + { + relation: ["delegate_permission/common.handle_all_urls"], + target: { + namespace: "android_app", + package_name: $pkg, + sha256_cert_fingerprints: $fps + } + } + ]' > "$ASSETLINKS" + + cat "$ASSETLINKS" + + - name: Validate assetlinks.json + env: + PKG: ${{ steps.pkg.outputs.package }} + run: | + set -euo pipefail + + # A malformed or mismatched file fails App Link verification silently on + # device, so assert the full shape here rather than discover it later. + jq -e 'type == "array" and length == 1' "$ASSETLINKS" > /dev/null \ + || { echo "ERROR: expected a single-entry array." >&2; exit 1; } + + jq -e --arg pkg "$PKG" ' + .[0] as $e + | ($e.relation | index("delegate_permission/common.handle_all_urls")) != null + and $e.target.namespace == "android_app" + and $e.target.package_name == $pkg + and ($e.target.sha256_cert_fingerprints | length) >= 1 + and ($e.target.sha256_cert_fingerprints + | all(test("^([0-9A-F]{2}:){31}[0-9A-F]{2}$"))) + ' "$ASSETLINKS" > /dev/null \ + || { echo "ERROR: entry does not describe $PKG with valid SHA-256 fingerprints." >&2; exit 1; } + + echo "Validated: $(jq -r '.[0].target.sha256_cert_fingerprints | length' "$ASSETLINKS") fingerprint(s) for $PKG" + + - name: Upload assetlinks.json + uses: actions/upload-artifact@v4 + with: + name: assetlinks + path: ${{ env.ASSETLINKS }} + if-no-files-found: error + + - name: Deploy assetlinks.json to R2 + if: github.event_name == 'workflow_dispatch' && inputs.deploy + env: + AWS_ACCESS_KEY_ID: ${{ vars.CLOUDFLARE_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + # AWS CLI v2 sends CRC32 integrity headers by default, which R2 rejects + # with "Header 'x-amz-checksum-algorithm' ... not implemented". + AWS_REQUEST_CHECKSUM_CALCULATION: when_required + AWS_RESPONSE_CHECKSUM_VALIDATION: when_required + run: | + set -euo pipefail + + # The Worker derives the object key from the request path, so the key + # must stay ".well-known/assetlinks.json". + aws s3 cp "$ASSETLINKS" "s3://$R2_BUCKET/$R2_KEY" \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ + --content-type application/json + + - name: Verify the deployed file is served + if: github.event_name == 'workflow_dispatch' && inputs.deploy + env: + HOSTS: ${{ inputs.hosts || 'appdevforall.org,www.appdevforall.org' }} + run: | + set -euo pipefail + + expected=$(jq -S -c . "$ASSETLINKS") + failed=0 + + for host in ${HOSTS//,/ }; do + url="https://$host/$R2_KEY" + hdr="$RUNNER_TEMP/hdr" body="$RUNNER_TEMP/body" + + # Cloudflare can take a moment to pick up a freshly written object. + served=0 + for _ in 1 2 3 4 5; do + if curl -fsS --max-time 20 -D "$hdr" -o "$body" "$url"; then + served=1 + break + fi + sleep 5 + done + + if [ "$served" -ne 1 ]; then + echo "FAIL $url did not return 200. The R2 upload succeeded, so the likely cause is the well-known Worker not being deployed or not routed for this host (see deploy-well-known-worker.yml)." >&2 + failed=1 + continue + fi + + if [ "$(jq -S -c . < "$body")" != "$expected" ]; then + echo "FAIL $url served content that differs from what was uploaded (stale edge cache, or the Worker route points elsewhere)." >&2 + failed=1 + continue + fi + + ctype=$(tr -d '\r' < "$hdr" | awk -F': ' 'tolower($1) == "content-type" { print tolower($2) }' | tail -1) + case "$ctype" in + application/json*) + echo "OK $url ($ctype)" + ;; + *) + echo "FAIL $url served Content-Type '$ctype'; Digital Asset Links requires application/json." >&2 + failed=1 + ;; + esac + done + + exit "$failed" + + - name: Write job summary + env: + PKG: ${{ steps.pkg.outputs.package }} + FINGERPRINT: ${{ steps.fp.outputs.fingerprint }} + HOSTS: ${{ inputs.hosts || 'appdevforall.org,www.appdevforall.org' }} + DEPLOYED: ${{ github.event_name == 'workflow_dispatch' && inputs.deploy }} + run: | + set -euo pipefail + { + echo "## Release signing certificate" + echo + echo "| | |" + echo "|---|---|" + echo "| Application ID | \`$PKG\` |" + echo "| SHA-256 | \`$FINGERPRINT\` |" + echo + echo "### assetlinks.json" + echo + echo '```json' + cat "$ASSETLINKS" + echo '```' + echo + echo "### Deploy" + echo + if [ "$DEPLOYED" = "true" ]; then + echo "Written to \`s3://$R2_BUCKET/$R2_KEY\` and confirmed served on:" + echo + for host in ${HOSTS//,/ }; do + echo "- \`https://$host/$R2_KEY\`" + done + else + echo "Not deployed. Re-run with **deploy** enabled, or publish the artifact by hand. The file is host-independent - the same bytes serve every host in the intent filter:" + echo + for host in ${HOSTS//,/ }; do + echo "- \`https://$host/$R2_KEY\` - HTTPS, \`Content-Type: application/json\`, no redirect" + done + fi + echo + echo "### Verify on device" + echo + echo '```bash' + for host in ${HOSTS//,/ }; do + echo "curl -sSI https://$host/$R2_KEY # expect 200, application/json, no 3xx" + done + echo "adb shell pm verify-app-links --re-verify $PKG" + echo "adb shell pm get-app-links $PKG" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Remove keystore + if: always() + run: | + # KEYSTORE may be unset if the decode step failed before exporting it. + ks="${KEYSTORE:-$RUNNER_TEMP/signing-key.jks}" + shred -u "$ks" 2>/dev/null || rm -f "$ks" + rm -f "$RUNNER_TEMP/keytool.txt" diff --git a/infra/well-known-worker/README.md b/infra/well-known-worker/README.md new file mode 100644 index 0000000000..ac5a01d6bf --- /dev/null +++ b/infra/well-known-worker/README.md @@ -0,0 +1,65 @@ +# well-known Worker + +Serves `https://appdevforall.org/.well-known/assetlinks.json` (and the `www` +host) out of the private `well-known` R2 bucket, for Android App Link +verification (ADFA-5067). + +## Why a Worker and not an Origin Rule + +R2 picks a bucket from the `Host` header, so pointing a path at it with an +Origin Rule needs a host header override plus a DNS record override. Both are +Enterprise-only; the Free plan exposes just the destination-port override. + +A Worker replaces the origin fetch instead of retargeting it. `env.WELL_KNOWN` +is an in-network binding rather than a URL, so no DNS, TLS or `Host` header is +involved and the bucket needs no public hostname at all. **Leave the bucket's +public access disabled** - it is reachable only through this Worker. + +A Redirect Rule to an R2 custom domain is not an alternative: Android's App Link +verifier does not follow redirects. + +## Shape + +- Routes match **exact paths**, not `/.well-known/*`. A wildcard would also + capture `/.well-known/acme-challenge/`, which the origin needs for certificate + renewal. +- A request whose object is missing falls through to the site origin, so the + Worker can never black-hole a path it does not own. +- `Content-Type` is replayed from the object's stored metadata. The uploader + sets `application/json`; re-uploading by hand without `--content-type` yields + `binary/octet-stream` and fails verification. +- No `Cache-Control`. `signing-fingerprint.yml` re-fetches the URL within seconds + of writing it and compares bytes, so a cached copy would fail that check on a + legitimate update. + +## Deploying + +CI only, via `.github/workflows/deploy-well-known-worker.yml` - it runs on any +push touching this directory, and on manual dispatch. + +Prerequisites: + +1. The `well-known` R2 bucket exists in the account. `wrangler deploy` does not + create it. +2. A `CLOUDFLARE_WORKERS_DEPLOY_TOKEN` repository secret, non-expiring, with: + - **Account -> Workers Scripts -> Edit** + - **Account -> Workers R2 Storage -> Read** - wrangler resolves the bucket + named in the binding via `GET /accounts//r2/buckets/well-known`, and + fails with `Authentication error [code: 10000]` without it. A scope added + to an existing token takes a few minutes to take effect, and the same + error persists until it does - wait before re-running + - **Zone -> Workers Routes -> Edit** on `appdevforall.org` + + The existing `CLOUDFLARE_KEY_ID` / `CLOUDFLARE_SECRET_ACCESS_KEY` pair is an + R2 S3-compatible credential and cannot deploy a Worker. + +## Verifying + +The object is published by the **Print release signing certificate fingerprint** +workflow with `deploy` enabled, which also verifies the served result. By hand: + +```bash +curl -sSI https://www.appdevforall.org/.well-known/assetlinks.json # 200, application/json, no 3xx +adb shell pm verify-app-links --re-verify com.itsaky.androidide +adb shell pm get-app-links com.itsaky.androidide +``` diff --git a/infra/well-known-worker/src/index.js b/infra/well-known-worker/src/index.js new file mode 100644 index 0000000000..110fff4824 --- /dev/null +++ b/infra/well-known-worker/src/index.js @@ -0,0 +1,41 @@ +/** + * Serves the private "well-known" R2 bucket at the request path. + * + * Cloudflare Origin Rules cannot retarget an origin on the Free plan - host + * header, SNI and DNS record overrides are all Enterprise-only - so the bucket + * is reached through an R2 binding instead. env.WELL_KNOWN is an in-network + * handle, not a URL, so the bucket needs no public hostname and its public + * access stays switched off. + */ +export default { + async fetch(request, env) { + // Anything this Worker does not own passes through to the site origin. + if (request.method !== "GET" && request.method !== "HEAD") { + return fetch(request); + } + + // R2 keys carry no leading slash: "/.well-known/assetlinks.json" is stored + // as ".well-known/assetlinks.json". The routes match exact paths, so this + // is the whole path-to-key mapping. + const key = new URL(request.url).pathname.slice(1); + const object = + request.method === "HEAD" + ? await env.WELL_KNOWN.head(key) + : await env.WELL_KNOWN.get(key); + + if (object === null) { + return fetch(request); + } + + const headers = new Headers(); + // Replays the Content-Type recorded at upload time. Digital Asset Links + // requires application/json, which the deploy step sets via --content-type. + object.writeHttpMetadata(headers); + headers.set("etag", object.httpEtag); + + // No Cache-Control on purpose: signing-fingerprint.yml re-fetches this URL + // within seconds of writing the object and compares bytes, so a cached copy + // would fail that check on a legitimate update. + return new Response(request.method === "HEAD" ? null : object.body, { headers }); + }, +}; diff --git a/infra/well-known-worker/wrangler.toml b/infra/well-known-worker/wrangler.toml new file mode 100644 index 0000000000..4a55f0f57c --- /dev/null +++ b/infra/well-known-worker/wrangler.toml @@ -0,0 +1,22 @@ +# Serves /.well-known/assetlinks.json for appdevforall.org out of the private +# "well-known" R2 bucket. Deployed by .github/workflows/deploy-well-known-worker.yml. + +name = "well-known" +main = "src/index.js" +compatibility_date = "2026-08-18" + +# Nothing should reach this Worker except through the routes below; a workers.dev +# URL would expose the bucket on a second, unverified hostname. +workers_dev = false + +# Exact paths, no trailing wildcard. A "/.well-known/*" route would also capture +# /.well-known/acme-challenge/, which the origin needs for certificate renewal. +routes = [ + { pattern = "appdevforall.org/.well-known/assetlinks.json", zone_name = "appdevforall.org" }, + { pattern = "www.appdevforall.org/.well-known/assetlinks.json", zone_name = "appdevforall.org" }, +] + +# binding must match env.WELL_KNOWN in src/index.js. +[[r2_buckets]] +binding = "WELL_KNOWN" +bucket_name = "well-known"