Files
ai-app/gen-dev-cert.sh
T
irisandClaude Fable 5 eeb2dde4ab 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
2026-08-25 04:16:15 -04:00

142 lines
6.6 KiB
Bash
Executable File

#!/bin/sh
# Generates the self-signed dev CA and leaf certificate `server/` serves its
# TLS listener with. Run it once, ON THE MACHINE THAT RUNS THE BACKEND, before
# the first start; the server exits with a clear message if the certificates
# are missing.
#
# Same scheme as ../local-updater's: nothing on a device trusts this
# automatically -- the app embeds the CA certificate verbatim and pins to it
# (`PinnedCert.kt`), rather than relying on the device's system trust store.
# This server's API *is* remote code execution (it spawns AI sessions on
# request), so a MITM on it would be as bad as it gets -- hence pinning.
#
# WHERE THE KEYS LIVE, AND WHY NOT IN THE REPO
#
# Output goes to $XDG_CONFIG_HOME/ai-app/certs (0700), deliberately *not*
# beside this script. The repo is a virtiofs mount shared with the dev VM,
# and that VM is treated as untrusted -- a machine that isn't malicious but
# could become so. A CA private key it can read is a CA private key it can
# sign with, and a leaf signed by this CA is one the phone's pinned app
# accepts without question. Keeping the key off the shared mount is what
# makes pinning mean anything.
#
# The same reasoning says the CA key doesn't belong on the backend either,
# strictly: the server only ever reads leaf.pem and leaf-key.pem, and the CA
# key is needed solely to reissue a leaf. Moving ca-key.pem somewhere offline
# once the setup is stable costs nothing but having it to hand at reissue.
#
# Running this inside the VM is fine and expected for emulator work -- it
# just produces a *different*, throwaway CA there. Never install a build
# pinning that dev CA on the real phone.
#
# The CA is idempotent -- skipped if `certs/ca.pem` already exists, so
# re-running this doesn't invalidate the certificate the installed app has
# pinned against without a reason to. The leaf is cheap and reissued on
# every run (still signed by that same, unchanged CA), so adding another SAN
# entry only means rerunning this script, not touching anything pinned.
#
# The leaf's SANs must cover every address a device reaches this server at.
# In production that is exactly one: the backend's WireGuard address, which
# the phone uses from everywhere (see PLAN.md's off-network section).
# Override with SERVER_IP=... if your wg0 address differs.
#
# Outputs into `certs/` (gitignored -- private key material, and the whole
# thing is trivially regeneratable anyway):
# ca.pem the CA certificate (not its private key) -- what the
# app embeds and pins against.
# ca-key.pem the CA's private key -- only this script needs it, to
# sign the leaf below. Never shipped anywhere.
# leaf.pem the server's own certificate (CA-signed), presented on
# every TLS handshake.
# leaf-key.pem the leaf's private key -- what the server loads to
# terminate TLS.
# ca-sha256.txt the CA certificate's SPKI SHA-256 fingerprint, printed
# below too. Informational -- the app embeds the whole
# `ca.pem`, not this digest.
# leaf-sha256.txt the leaf's SPKI SHA-256 fingerprint. Informational --
# nothing pins the leaf; the app pins the CA and
# validates the chain.
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
CERTS_DIR="${AI_APP_CERTS:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs}"
# The backend's WireGuard address -- the one address the phone ever dials in
# production (PLAN.md: single-path addressing, no home/away distinction).
SERVER_IP="${SERVER_IP:-10.66.0.1}"
# Also covered so development before the tunnel exists can complete a real
# handshake against the same pinned CA:
# 127.0.0.1 curl from the machine itself, and tests binding loopback
# 10.0.2.2 the Android emulator's alias for the host's loopback
# LAN_IP a real phone on the same LAN, pre-WireGuard
LOOPBACK_IP="127.0.0.1"
EMULATOR_HOST_IP="10.0.2.2"
LAN_IP="${LAN_IP:-192.168.1.168}"
# Private key material: owner-only from the moment it exists, rather than
# created world-readable and chmod'ed a beat later.
umask 077
mkdir -p "$CERTS_DIR"
chmod 700 "$CERTS_DIR"
cd "$CERTS_DIR"
echo "==> Writing certificates to $CERTS_DIR"
if [ -f ca.pem ]; then
echo "==> ca.pem already exists, reusing existing CA."
else
echo "==> Generating CA key + self-signed CA certificate"
openssl ecparam -name prime256v1 -genkey -noout -out ca-key.pem
# Explicit keyUsage: strict verifiers (e.g. Python 3.14's ssl) reject a
# CA without it, and Android could follow -- cheap to be proper now,
# expensive to regenerate after phones have pinned it.
openssl req -new -x509 -key ca-key.pem -out ca.pem -days 3650 \
-subj "/O=ai-app dev/CN=ai-app dev CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
fi
echo "==> Generating leaf key + CSR for $SERVER_IP (+ dev addresses)"
openssl ecparam -name prime256v1 -genkey -noout -out leaf-key.pem
openssl req -new -key leaf-key.pem -out leaf.csr \
-subj "/O=ai-app dev/CN=$SERVER_IP"
echo "==> Signing leaf certificate with the dev CA"
cat > leaf.ext <<EOF
subjectAltName = IP:$SERVER_IP,IP:$LOOPBACK_IP,IP:$EMULATOR_HOST_IP,IP:$LAN_IP
basicConstraints = CA:FALSE
keyUsage = digitalSignature
extendedKeyUsage = serverAuth
EOF
openssl x509 -req -in leaf.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out leaf.pem -days 3650 -extfile leaf.ext
rm -f leaf.csr leaf.ext ca.srl
echo "==> Computing certificate fingerprints (SPKI SHA-256)"
CA_SHA256=$(openssl x509 -in ca.pem -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| openssl base64)
echo "$CA_SHA256" > ca-sha256.txt
LEAF_SHA256=$(openssl x509 -in leaf.pem -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| openssl base64)
echo "$LEAF_SHA256" > leaf-sha256.txt
echo
echo "==> Done."
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): 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/build-apk.sh # then install via Local Updater"
echo
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)."