From eeb2dde4abdd568dd3f988b36c7f228160a5da58 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Tue, 25 Aug 2026 04:16:15 -0400 Subject: [PATCH] App embeds the CA of the machine that builds it PinnedCert.kt no longer carries a pasted certificate. The build reads $XDG_CONFIG_HOME/ai-app/certs/ca.pem (AI_APP_CA overrides) and generates the constant, so the trust anchor follows the build machine: an APK built on the backend host pins that host, and one built in the dev VM pins the VM's throwaway CA and is good only for its emulator. That removes the reason to add a second trust anchor for development -- there is nothing to add and then forget to remove -- and it means the private key never has to exist near this repo, which the VM can write. Regenerating a CA now needs a rebuild instead of a paste, so a stale constant can't quietly disagree with the server. build-apk.sh is the missing counterpart to run-android.sh: it produces the APK to install through Local Updater and touches no emulator. It finds the SDK from ANDROID_HOME/ANDROID_SDK_ROOT before falling back to ~/Android/Sdk, since the host doesn't share the VM's layout, and prints the fingerprint of the CA being pinned so a wrong one is visible there rather than as a handshake failure on the phone. Verified end to end on the emulator against a server using a freshly generated CA -- which is how the first attempt was caught: the generated constant began with a newline, so CertificateFactory lost the "-----BEGIN" sniff, tried DER, and failed at runtime with an ASN.1 decode error. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw --- AGENTS.md | 19 +++- PLAN.md | 10 +++ app/androidApp/build.gradle.kts | 89 +++++++++++++++++++ .../kotlin/com/example/aiapp/PinnedCert.kt | 43 ++++----- app/build-apk.sh | 68 ++++++++++++++ gen-dev-cert.sh | 17 ++-- 6 files changed, 209 insertions(+), 37 deletions(-) create mode 100755 app/build-apk.sh diff --git a/AGENTS.md b/AGENTS.md index 73f0fe7..574d335 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,8 +59,15 @@ real-phone/WireGuard bring-up, which is operational rather than code. - Server: `./run-tests.sh` (or `cargo test`) + `cargo clippy --all-targets` from `server/` — the build stays warning-clean, keep it that way. -- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:compileDebugKotlin`; - `./run-android.sh` builds, installs, and launches on the emulator. +- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:compileDebugKotlin` + to typecheck; `./build-apk.sh` to produce the APK to install on a phone + (through Local Updater); `./run-android.sh` to build, install, and launch + on the emulator. +- **The APK pins the CA of the machine that builds it**, read at build time + from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides) and + generated into a constant. So `./gen-dev-cert.sh` must have run on that + machine first — the build stops with that instruction otherwise — and an + APK built in this VM only works against a server in this VM. - Run the server for development with `--bind 127.0.0.1` (wg0 doesn't exist on this machine yet; the default fails closed). First run prints the enrollment QR/URI with the token — capture it from the log. @@ -138,6 +145,14 @@ Established 2026-08-25, and it decides more than it looks like: - **CMP 1.11 deprecates the `compose.*` dependency accessors** — declare `org.jetbrains.compose.:` directly (material3 has its own release train, separate from the CMP version). +- **A PEM constant must start at the opening quotes.** A generated + `"""\n-----BEGIN CERTIFICATE-----` costs Android's `CertificateFactory` + its preamble sniff, so it tries DER instead and fails at runtime with + `ASN.1 ... DECODE_ERROR` — nowhere near the code that produced it. +- **AGP 9 refuses `Provider`s in the source-set API**: generated sources go + through `androidComponents.onVariants { it.sources.java?.addGenerated + SourceDirectory(task, Task::outputDir) }`, which also carries the task + dependency. ## Environment notes (this machine, learned in local-updater) diff --git a/PLAN.md b/PLAN.md index fc0d283..9b77a8d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -262,6 +262,16 @@ complete path out of everything spawning one created. - TLS with a self-signed CA, pinned in the app — `gen-dev-cert.sh` and `PinnedCert.kt` copied from local-updater, same idempotent-CA/reissued-leaf scheme, same one-way-door caveat about regenerating the CA. + - Unlike local-updater, the pinned CA is **not a constant in the source**: + the build reads `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` from the machine + doing the build and generates the constant (`generatePinnedCert` in + `app/androidApp/build.gradle.kts`; `AI_APP_CA` overrides). Decided + 2026-08-25, and it does three things at once — the trust anchor follows + the build machine, so an APK built on the backend host pins that host + and one built in the dev VM pins the VM's throwaway CA and is only good + for its emulator; there is no second anchor to add for development and + forget to remove; and regenerating a CA needs a rebuild rather than a + paste, so a stale constant can't quietly disagree with the server. - **The dev VM is untrusted** (decided 2026-08-25): a machine that isn't malicious but could become so. It matters because the repo is a read-write virtiofs mount shared between the VM and the backend host, so diff --git a/app/androidApp/build.gradle.kts b/app/androidApp/build.gradle.kts index 35a5a9d..09b73dc 100644 --- a/app/androidApp/build.gradle.kts +++ b/app/androidApp/build.gradle.kts @@ -4,6 +4,84 @@ plugins { alias(libs.plugins.composeCompiler) } +// The CA this app pins is baked in at build time from the certificates on +// the machine doing the build -- `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`, +// as written by ../gen-dev-cert.sh. AI_APP_CA overrides the path. +// +// Reading it rather than keeping a pasted copy in the source is what makes +// the trust boundary follow the build: an APK built on the backend host +// pins the host's CA and never sees any other, while one built in the dev +// VM pins that VM's throwaway CA and is only good for its emulator. There +// is no second trust anchor to get wrong, and no stale paste to notice +// three days later. It also means the private key never has to exist +// anywhere near this repo. +val pinnedCaPath: String = System.getenv("AI_APP_CA") + ?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" + + "/ai-app/certs/ca.pem" + +abstract class GeneratePinnedCert : DefaultTask() { + /** Where the certificate is looked for, reported in failures. */ + @get:Input + abstract val caPath: Property + + /** + * The certificate itself, set only when it exists -- so a missing one + * produces this task's own instructions rather than Gradle's "no such + * input file", which doesn't say what to run. + */ + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + abstract val caCertificate: RegularFileProperty + + /** Wired by AGP through `addGeneratedSourceDirectory`. */ + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val path = caPath.get() + val ca = File(path) + if (!ca.isFile) { + throw GradleException( + "No CA certificate at $path.\n" + + "Run ./gen-dev-cert.sh on this machine first -- the app pins the CA it " + + "generates, and the certificate has to exist before an APK can embed it.\n" + + "Set AI_APP_CA=/path/to/ca.pem to build against a different one.", + ) + } + val pem = ca.readText().trim() + if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) { + throw GradleException("$path is not a PEM certificate.") + } + val file = outputDir.get().file("PinnedCaCertificate.kt").asFile + file.parentFile.mkdirs() + // The PEM must start immediately after the opening quotes: a + // leading newline makes Android's CertificateFactory stop + // recognising the "-----BEGIN" preamble and try to parse the whole + // thing as DER, which fails with an ASN.1 decode error at runtime + // rather than anywhere near this file. + file.writeText( + """ + |// Generated from $path by the generatePinnedCert task. Do not edit. + |package com.example.aiapp + | + |const val PINNED_CA_PEM = ""${'"'}$pem + |""${'"'} + | + """.trimMargin(), + ) + } +} + +val generatePinnedCert = tasks.register("generatePinnedCert") { + val ca = file(pinnedCaPath) + caPath.set(pinnedCaPath) + if (ca.isFile) { + caCertificate.set(ca) + } +} + android { namespace = "com.example.aiapp" compileSdk = 37 @@ -31,6 +109,17 @@ android { } } +// AGP 9 wants generated sources registered through the variant API rather +// than added to a source set, so the task dependency is carried properly. +androidComponents { + onVariants { variant -> + variant.sources.java?.addGeneratedSourceDirectory( + generatePinnedCert, + GeneratePinnedCert::outputDir, + ) + } +} + dependencies { implementation(libs.compose.runtime) implementation(libs.compose.foundation) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt index a3cf058..43f7d79 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt @@ -10,31 +10,19 @@ import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager -/** - * PEM-encoded dev CA certificate this repo's `gen-dev-cert.sh` generates -- - * the sole trust anchor for every request this app makes. The server's TLS - * listener presents a leaf certificate signed by this CA. Everything behind - * that listener *is* remote code execution on the backend, so this app - * trusts exactly this CA and nothing else -- not even the system store. - * - * Regenerating that CA means updating this to match; its SHA-256 - * fingerprint is printed by the script and saved to `certs/ca-sha256.txt`. - * The QR enrollment deliberately does not carry the CA: trust lives here in - * the APK, so photographing the terminal leaks only the (rotatable) token. - */ -const val PINNED_CA_PEM = """-----BEGIN CERTIFICATE----- -MIIBvzCCAWWgAwIBAgIUGCxNZPJIfzGQipZGxWVZ8vMYZXgwCgYIKoZIzj0EAwIw -LTETMBEGA1UECgwKYWktYXBwIGRldjEWMBQGA1UEAwwNYWktYXBwIGRldiBDQTAe -Fw0yNjA4MjUwMTI2MDRaFw0zNjA4MjIwMTI2MDRaMC0xEzARBgNVBAoMCmFpLWFw -cCBkZXYxFjAUBgNVBAMMDWFpLWFwcCBkZXYgQ0EwWTATBgcqhkjOPQIBBggqhkjO -PQMBBwNCAARE6qRKz1HeCzcvmdT6ztwTR2w4DGP97aaYJhp3z+es6dceNXdpP1qx -3DlazArgYLjOcNOHTqonj4H5NwHfeP4So2MwYTAdBgNVHQ4EFgQUL9VAISmEPDhJ -dHnl5iS10qLbcekwHwYDVR0jBBgwFoAUL9VAISmEPDhJdHnl5iS10qLbcekwDwYD -VR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwIDSAAwRQIh -AL7Z0wfT0pXD08J6GNbPVfy/PB3EoUtIwA9z9rYGPEhZAiBeLPiXCvIbWWTpsjRr -Uk5VTHJchx0xXCdTRSJo2BEh7Q== ------END CERTIFICATE----- -""" +// PINNED_CA_PEM is generated at build time from the CA on the machine doing +// the build -- see the generatePinnedCert task in build.gradle.kts. It is +// deliberately not a checked-in constant: the private key that signs against +// it must never be anywhere this repo is, and an APK should pin whatever CA +// the backend it was built for actually serves. +// +// So the trust anchor follows the build machine. Built on the backend host, +// the app trusts that host and nothing else -- not even the system store. +// Built in the dev VM, it trusts that VM's throwaway CA and is good only for +// its emulator; never install one of those on a real phone. +// +// The QR enrollment deliberately carries no CA: trust lives here in the APK, +// so photographing the terminal leaks only the (rotatable) token. /** * Trusts only [PINNED_CA_PEM], not the device's system trust store, so a @@ -43,8 +31,11 @@ Uk5VTHJchx0xXCdTRSJo2BEh7Q== * KeyStore/TrustManager setup from scratch. */ val pinnedSslSocketFactory: SSLSocketFactory by lazy { + // Trimmed because CertificateFactory only recognises PEM when the + // "-----BEGIN" preamble is the very first thing it sees; surrounding + // whitespace sends it down the DER path instead. val caCert = CertificateFactory.getInstance("X.509") - .generateCertificate(ByteArrayInputStream(PINNED_CA_PEM.encodeToByteArray())) + .generateCertificate(ByteArrayInputStream(PINNED_CA_PEM.trim().encodeToByteArray())) val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) setCertificateEntry("ai-app-dev-ca", caCert) diff --git a/app/build-apk.sh b/app/build-apk.sh new file mode 100755 index 0000000..3064607 --- /dev/null +++ b/app/build-apk.sh @@ -0,0 +1,68 @@ +#!/bin/sh +# Builds the app's APK, ready to install on a phone through Local Updater. +# +# ./build-apk.sh +# +# The APK pins the CA on *this* machine ($XDG_CONFIG_HOME/ai-app/certs/ca.pem, +# or AI_APP_CA), so build it on the machine that runs the backend: an app +# built somewhere else trusts a CA that backend can't present, and simply +# won't connect. Run ../gen-dev-cert.sh first if there are no certificates +# yet; the build stops with that instruction if it can't find one. +# +# Unlike ./run-android.sh, this touches no emulator: it only produces the +# file. Installing on a real phone goes through Local Updater, which serves +# whatever is under this project's build directory. +set -eu + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +cd "$SCRIPT_DIR" + +# Prefer an SDK this machine has already configured -- the host and the dev +# VM don't keep it in the same place, and android-env.sh is written for the +# VM's layout (it also installs missing packages, which isn't wanted here). +if [ -n "${ANDROID_HOME:-}" ] && [ -d "${ANDROID_HOME}" ]; then + echo "==> Using ANDROID_HOME=$ANDROID_HOME" +elif [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "${ANDROID_SDK_ROOT}" ]; then + ANDROID_HOME="$ANDROID_SDK_ROOT" + export ANDROID_HOME + echo "==> Using ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" +elif [ -d "$HOME/Android/Sdk" ]; then + ANDROID_HOME="$HOME/Android/Sdk" + ANDROID_SDK_ROOT="$ANDROID_HOME" + export ANDROID_HOME ANDROID_SDK_ROOT + echo "==> Using $ANDROID_HOME" +else + echo "No Android SDK found. Set ANDROID_HOME to it, or install one" >&2 + echo "(Android Studio's default location is ~/Android/Sdk)." >&2 + exit 1 +fi + +CA="${AI_APP_CA:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs/ca.pem}" +if [ -f "$CA" ]; then + # Printed so a wrong or stale certificate is visible here rather than as + # a handshake failure on the phone. Compare with the server's own + # ca-sha256.txt, which gen-dev-cert.sh writes beside it. + FINGERPRINT=$(openssl x509 -in "$CA" -pubkey -noout 2>/dev/null \ + | openssl pkey -pubin -outform der 2>/dev/null \ + | openssl dgst -sha256 -binary 2>/dev/null \ + | openssl base64 2>/dev/null || echo "(openssl unavailable)") + echo "==> Pinning the CA at $CA" + echo " fingerprint: $FINGERPRINT" +else + echo "No CA certificate at $CA -- run ../gen-dev-cert.sh on this machine" >&2 + echo "first, or set AI_APP_CA to one. The APK has to embed it at build time." >&2 + exit 1 +fi + +echo "==> Building" +./gradlew :androidApp:assembleDebug + +APK="$SCRIPT_DIR/androidApp/build/outputs/apk/debug/androidApp-debug.apk" +echo +echo "==> Built $APK" +[ -f "$APK" ] && ls -lh "$APK" | awk '{print " " $5}' +echo +echo "To get it onto the phone: add this project to Local Updater (or hit" +echo "Update on it if it's already there) and install from there." +echo "Then start the backend and scan the enrollment QR it prints:" +echo " ./server/target/release/ai-server --rotate-token" diff --git a/gen-dev-cert.sh b/gen-dev-cert.sh index fb20982..ceb76a1 100755 --- a/gen-dev-cert.sh +++ b/gen-dev-cert.sh @@ -129,14 +129,13 @@ echo " CA fingerprint (base64): $CA_SHA256" echo " Leaf fingerprint (base64): $LEAF_SHA256" echo echo " Only relevant if the CA was regenerated just now (i.e. ca.pem did" -echo " not already exist): the app embeds PINNED_CA_PEM and needs the new" -echo " $CERTS_DIR/ca.pem contents pasted in, or it silently" -echo " stops being able to reach this server. The app installs via Local" -echo " Updater, so recovery is a reinstall through that -- but it's still" -echo " a one-way door for the installed copy." +echo " not already exist): any app already installed pins the *previous*" +echo " CA and silently stops being able to reach this server. Rebuild and" +echo " reinstall it -- the APK embeds whatever ca.pem is here at build" +echo " time, so there is nothing to paste:" echo -echo " app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt" +echo " ./app/build-apk.sh # then install via Local Updater" echo -echo " Only ca.pem is meant to leave this machine. Copy it by hand; do not" -echo " put the certs directory back in the repo, which is shared with the" -echo " VM (see this script's header)." +echo " Nothing but ca.pem is meant to leave this machine, and it leaves" +echo " only by being compiled into an APK built here. Don't put this" +echo " directory in the repo, which is shared with the VM (see the header)."