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

@@ -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)