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