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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-25 04:16:15 -04:00
1 parent ce349af414
commit eeb2dde4ab
6 files changed
+209 -37

No files matched your search

+89
View File
@@ -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<String>
/**
* 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>("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)
@@ -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)
+68
View File
@@ -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"