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:
1 parent
ce349af414
commit
eeb2dde4ab
6 files changed
+209
-37
No files matched your search
@@ -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`
|
- Server: `./run-tests.sh` (or `cargo test`) + `cargo clippy --all-targets`
|
||||||
from `server/` — the build stays warning-clean, keep it that way.
|
from `server/` — the build stays warning-clean, keep it that way.
|
||||||
- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:compileDebugKotlin`;
|
- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:compileDebugKotlin`
|
||||||
`./run-android.sh` builds, installs, and launches on the emulator.
|
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
|
- 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
|
on this machine yet; the default fails closed). First run prints the
|
||||||
enrollment QR/URI with the token — capture it from the log.
|
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
|
- **CMP 1.11 deprecates the `compose.*` dependency accessors** — declare
|
||||||
`org.jetbrains.compose.<x>:<x>` directly (material3 has its own release
|
`org.jetbrains.compose.<x>:<x>` directly (material3 has its own release
|
||||||
train, separate from the CMP version).
|
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)
|
## Environment notes (this machine, learned in local-updater)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
- 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
|
`PinnedCert.kt` copied from local-updater, same idempotent-CA/reissued-leaf
|
||||||
scheme, same one-way-door caveat about regenerating the CA.
|
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
|
- **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
|
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
|
read-write virtiofs mount shared between the VM and the backend host, so
|
||||||
|
|||||||
@@ -4,6 +4,84 @@ plugins {
|
|||||||
alias(libs.plugins.composeCompiler)
|
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 {
|
android {
|
||||||
namespace = "com.example.aiapp"
|
namespace = "com.example.aiapp"
|
||||||
compileSdk = 37
|
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 {
|
dependencies {
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
|
|||||||
@@ -10,31 +10,19 @@ import javax.net.ssl.SSLSocketFactory
|
|||||||
import javax.net.ssl.TrustManagerFactory
|
import javax.net.ssl.TrustManagerFactory
|
||||||
import javax.net.ssl.X509TrustManager
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
/**
|
// PINNED_CA_PEM is generated at build time from the CA on the machine doing
|
||||||
* PEM-encoded dev CA certificate this repo's `gen-dev-cert.sh` generates --
|
// the build -- see the generatePinnedCert task in build.gradle.kts. It is
|
||||||
* the sole trust anchor for every request this app makes. The server's TLS
|
// deliberately not a checked-in constant: the private key that signs against
|
||||||
* listener presents a leaf certificate signed by this CA. Everything behind
|
// it must never be anywhere this repo is, and an APK should pin whatever CA
|
||||||
* that listener *is* remote code execution on the backend, so this app
|
// the backend it was built for actually serves.
|
||||||
* trusts exactly this CA and nothing else -- not even the system store.
|
//
|
||||||
*
|
// So the trust anchor follows the build machine. Built on the backend host,
|
||||||
* Regenerating that CA means updating this to match; its SHA-256
|
// the app trusts that host and nothing else -- not even the system store.
|
||||||
* fingerprint is printed by the script and saved to `certs/ca-sha256.txt`.
|
// Built in the dev VM, it trusts that VM's throwaway CA and is good only for
|
||||||
* The QR enrollment deliberately does not carry the CA: trust lives here in
|
// its emulator; never install one of those on a real phone.
|
||||||
* the APK, so photographing the terminal leaks only the (rotatable) token.
|
//
|
||||||
*/
|
// The QR enrollment deliberately carries no CA: trust lives here in the APK,
|
||||||
const val PINNED_CA_PEM = """-----BEGIN CERTIFICATE-----
|
// so photographing the terminal leaks only the (rotatable) token.
|
||||||
MIIBvzCCAWWgAwIBAgIUGCxNZPJIfzGQipZGxWVZ8vMYZXgwCgYIKoZIzj0EAwIw
|
|
||||||
LTETMBEGA1UECgwKYWktYXBwIGRldjEWMBQGA1UEAwwNYWktYXBwIGRldiBDQTAe
|
|
||||||
Fw0yNjA4MjUwMTI2MDRaFw0zNjA4MjIwMTI2MDRaMC0xEzARBgNVBAoMCmFpLWFw
|
|
||||||
cCBkZXYxFjAUBgNVBAMMDWFpLWFwcCBkZXYgQ0EwWTATBgcqhkjOPQIBBggqhkjO
|
|
||||||
PQMBBwNCAARE6qRKz1HeCzcvmdT6ztwTR2w4DGP97aaYJhp3z+es6dceNXdpP1qx
|
|
||||||
3DlazArgYLjOcNOHTqonj4H5NwHfeP4So2MwYTAdBgNVHQ4EFgQUL9VAISmEPDhJ
|
|
||||||
dHnl5iS10qLbcekwHwYDVR0jBBgwFoAUL9VAISmEPDhJdHnl5iS10qLbcekwDwYD
|
|
||||||
VR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwIDSAAwRQIh
|
|
||||||
AL7Z0wfT0pXD08J6GNbPVfy/PB3EoUtIwA9z9rYGPEhZAiBeLPiXCvIbWWTpsjRr
|
|
||||||
Uk5VTHJchx0xXCdTRSJo2BEh7Q==
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
"""
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trusts only [PINNED_CA_PEM], not the device's system trust store, so a
|
* Trusts only [PINNED_CA_PEM], not the device's system trust store, so a
|
||||||
@@ -43,8 +31,11 @@ Uk5VTHJchx0xXCdTRSJo2BEh7Q==
|
|||||||
* KeyStore/TrustManager setup from scratch.
|
* KeyStore/TrustManager setup from scratch.
|
||||||
*/
|
*/
|
||||||
val pinnedSslSocketFactory: SSLSocketFactory by lazy {
|
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")
|
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 {
|
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
||||||
load(null, null)
|
load(null, null)
|
||||||
setCertificateEntry("ai-app-dev-ca", caCert)
|
setCertificateEntry("ai-app-dev-ca", caCert)
|
||||||
|
|||||||
Executable
+68
@@ -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"
|
||||||
+8
-9
@@ -129,14 +129,13 @@ echo " CA fingerprint (base64): $CA_SHA256"
|
|||||||
echo " Leaf fingerprint (base64): $LEAF_SHA256"
|
echo " Leaf fingerprint (base64): $LEAF_SHA256"
|
||||||
echo
|
echo
|
||||||
echo " Only relevant if the CA was regenerated just now (i.e. ca.pem did"
|
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 " not already exist): any app already installed pins the *previous*"
|
||||||
echo " $CERTS_DIR/ca.pem contents pasted in, or it silently"
|
echo " CA and silently stops being able to reach this server. Rebuild and"
|
||||||
echo " stops being able to reach this server. The app installs via Local"
|
echo " reinstall it -- the APK embeds whatever ca.pem is here at build"
|
||||||
echo " Updater, so recovery is a reinstall through that -- but it's still"
|
echo " time, so there is nothing to paste:"
|
||||||
echo " a one-way door for the installed copy."
|
|
||||||
echo
|
echo
|
||||||
echo " app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt"
|
echo " ./app/build-apk.sh # then install via Local Updater"
|
||||||
echo
|
echo
|
||||||
echo " Only ca.pem is meant to leave this machine. Copy it by hand; do not"
|
echo " Nothing but ca.pem is meant to leave this machine, and it leaves"
|
||||||
echo " put the certs directory back in the repo, which is shared with the"
|
echo " only by being compiled into an APK built here. Don't put this"
|
||||||
echo " VM (see this script's header)."
|
echo " directory in the repo, which is shared with the VM (see the header)."
|
||||||
Reference in new issue
Block a user