The dev VM is treated as untrusted, and the repo is a read-write virtiofs mount shared with the backend host -- so a CA private key sitting in it is a key that machine can sign with, and a leaf signed by this CA is one the phone's pinned app accepts without question. Pinning against a CA the attacker holds is no pinning at all. So certificates are now generated on the machine that serves them, into $XDG_CONFIG_HOME/ai-app/certs at 0700 with 0600 keys (AI_APP_CERTS overrides), and config.json and session transcripts move to the XDG config and data directories. Transcripts move for a plainer reason than the keys: they are whole conversations, and they were world-readable at 0644. Two smaller things fall out. The host and VM stop sharing one config, which had already put a test token on the production backend. And state stops living where `git clean -xdf` would take the enrollment and every transcript with it. State that predates the move is still read from the repo, with a warning naming where to move it, so an existing install keeps working rather than silently coming up on an empty config -- the precedence is covered by a test, since picking the wrong file would otherwise be silent. Verified: 31 tests, clippy clean; the certificate script writing 0700/0600 into an overridden directory; and the server logging the fallback and serving from it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
143 lines
6.7 KiB
Bash
Executable File
143 lines
6.7 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): the app embeds PINNED_CA_PEM and needs the new"
|
|
echo " $CERTS_DIR/ca.pem contents pasted in, or it silently"
|
|
echo " stops being able to reach this server. The app installs via Local"
|
|
echo " Updater, so recovery is a reinstall through that -- but it's still"
|
|
echo " a one-way door for the installed copy."
|
|
echo
|
|
echo " app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt"
|
|
echo
|
|
echo " Only ca.pem is meant to leave this machine. Copy it by hand; do not"
|
|
echo " put the certs directory back in the repo, which is shared with the"
|
|
echo " VM (see this script's header)."
|