Keep state and keys out of the shared repo

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
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-25 03:49:34 -04:00
1 parent fff1fb49e8
commit d2d2832ec8
8 files changed
+248 -37

No files matched your search

+38 -9
View File
@@ -1,7 +1,8 @@
#!/bin/sh
# Generates the self-signed dev CA and leaf certificate `server/` serves its
# TLS listener with. Run once before the first `cargo run`; the server exits
# with a clear message if `certs/` is missing.
# 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
@@ -9,6 +10,25 @@
# 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
@@ -40,7 +60,7 @@
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
CERTS_DIR="$SCRIPT_DIR/certs"
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}"
@@ -53,8 +73,13 @@ 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."
@@ -103,11 +128,15 @@ 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. certs/ca.pem"
echo " did not already exist): the app embeds PINNED_CA_PEM and needs the"
echo " new certs/ca.pem contents pasted in, or it silently stops being able"
echo " to reach this server. The app installs via Local Updater, so"
echo " recovery is a reinstall through that -- but it's still a one-way"
echo " door for the installed copy."
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)."