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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user