Generate the TLS certificates in process

gen-dev-cert.sh is gone. The server ensures its own certificates on start,
which removes a setup step to remember, a dependency on whatever openssl
was installed, and a second place for the "which addresses?" answer to
live -- the leaf now covers every local IPv4 plus loopback and the
emulator's host alias, so nobody maintains a hardcoded IP.

The split that mattered in the script is kept and now enforced by tests:
the CA is generated once and left alone, because the app pins it and
replacing it strands every installed copy; the leaf is cheap and reissued
every start, so covering a new address is a restart. Both are written
owner-only into a directory outside the repo.

Two things the tests caught. DirBuilder's mode applies only when the
directory is created, so a directory that already existed kept whatever
permissions it had while holding a private key -- the mode is now set
explicitly, in the session directories too. And loading the leaf into the
real RustlsConfig needs the crypto provider installed, which main does but
tests don't.

Verified end to end: deleted the certs, started the server, watched it
generate a CA and warn that installed apps now pin the wrong one, rebuilt
the APK against the new CA, and reinstalled -- the emulator connects over
a certificate that never existed as a pasted constant.

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:36:54 -04:00
1 parent eeb2dde4ab
commit 65743b899d
8 files changed
+548 -173

No files matched your search

+11 -8
View File
@@ -40,10 +40,11 @@ repo is in PLAN.md's "Backend layout" section.
`when`; `Api.kt`/`EventStream.kt` the REST + SSE clients; `Events.kt` the
event model mirror; `ServerConfig.kt` settings + Keystore-sealed token;
screens in `SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`.
- `gen-dev-cert.sh` / `certs/`copied from local-updater's scheme
(idempotent CA, reissued leaf; regenerating the CA strands the installed
app — same one-way door). Dev SANs cover 127.0.0.1, 10.0.2.2 (emulator →
host), and the LAN IP alongside the WireGuard address.
- `server/src/certs.rs`the TLS certificates, generated in process on
first start into `$XDG_CONFIG_HOME/ai-app/certs`: idempotent CA, leaf
reissued every start covering every local IPv4 plus 127.0.0.1 and
10.0.2.2 (emulator → host). Regenerating the CA strands the installed
app — the one-way door.
## Status
@@ -65,7 +66,7 @@ real-phone/WireGuard bring-up, which is operational rather than code.
on the emulator.
- **The APK pins the CA of the machine that builds it**, read at build time
from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides) and
generated into a constant. So `./gen-dev-cert.sh` must have run on that
generated into a constant. So the server must have started once on that
machine first — the build stops with that instruction otherwise — and an
APK built in this VM only works against a server in this VM.
- Run the server for development with `--bind 127.0.0.1` (wg0 doesn't exist
@@ -122,9 +123,11 @@ Established 2026-08-25, and it decides more than it looks like:
PLAN.md's security section), and the repo is shared read-write with the
host, so state lives outside it: `$XDG_CONFIG_HOME/ai-app/config.json`
and `certs/`, `$XDG_DATA_HOME/ai-app/sessions/`, owner-only.
- Certificates are generated **on the machine that serves them**
(`./gen-dev-cert.sh`, honours `AI_APP_CERTS`). Running it in the VM makes
a separate throwaway dev CA for emulator work — never install a build
- Certificates are generated **by the server, on first start**, into
`$XDG_CONFIG_HOME/ai-app/certs` (`--certs` overrides). The CA is created
once and then left alone; the leaf is reissued every start, so covering a
new address is a restart. Starting the server in the VM therefore makes a
separate throwaway dev CA for emulator work — never install a build
pinning that on the real phone.
- Point development at a scratch state directory rather than the real one:
`--config /tmp/…/config.json --data-dir /tmp/…/sessions --port 8444`, or
+8 -3
View File
@@ -259,9 +259,14 @@ complete path out of everything spawning one created.
### Security
- TLS with a self-signed CA, pinned in the app — `gen-dev-cert.sh` and
`PinnedCert.kt` copied from local-updater, same idempotent-CA/reissued-leaf
scheme, same one-way-door caveat about regenerating the CA.
- TLS with a self-signed CA, pinned in the app — same
idempotent-CA/reissued-leaf scheme as local-updater, same one-way-door
caveat about regenerating the CA, but generated **in process on first
start** (`certs.rs`) rather than by a shell script calling openssl
(2026-08-25). One place then decides the extensions, the file modes, and
which addresses the leaf covers — every local IPv4 plus loopback and the
emulator's host alias, so nobody maintains a hardcoded IP — and there is
no setup step to forget.
- Unlike local-updater, the pinned CA is **not a constant in the source**:
the build reads `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` from the machine
doing the build and generates the constant (`generatePinnedCert` in
-141
View File
@@ -1,141 +0,0 @@
#!/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)."
+262 -3
View File
@@ -24,11 +24,12 @@ dependencies = [
"anyhow",
"axum",
"axum-server",
"base64",
"base64 0.23.1",
"clap",
"if-addrs",
"qrcode",
"rand",
"rcgen",
"rustls",
"serde",
"serde_json",
@@ -109,6 +110,45 @@ dependencies = [
"rustversion",
]
[[package]]
name = "asn1-rs"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
"thiserror",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -219,12 +259,27 @@ dependencies = [
"tower-service",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bit-vec"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "2.13.1"
@@ -363,6 +418,32 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "der-parser"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "digest"
version = "0.11.3"
@@ -374,6 +455,17 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "dunce"
version = "1.0.5"
@@ -740,6 +832,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -778,6 +876,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -787,6 +895,49 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
dependencies = [
"asn1-rs",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -799,6 +950,16 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -817,6 +978,12 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -864,6 +1031,20 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rcgen"
version = "0.14.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"x509-parser",
"yasna",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
@@ -895,6 +1076,15 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]]
name = "rustix"
version = "1.1.4"
@@ -1133,6 +1323,17 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -1175,6 +1376,36 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tokio"
version = "1.53.1"
@@ -1351,7 +1582,7 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
dependencies = [
"base64",
"base64 0.23.1",
"flate2",
"log",
"percent-encoding",
@@ -1368,7 +1599,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
dependencies = [
"base64",
"base64 0.23.1",
"http",
"httparse",
"log",
@@ -1501,6 +1732,34 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "x509-parser"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom",
"oid-registry",
"ring",
"rusticata-macros",
"thiserror",
"time",
]
[[package]]
name = "yasna"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
dependencies = [
"bit-vec",
"time",
]
[[package]]
name = "zeroize"
version = "1.9.0"
+9
View File
@@ -30,6 +30,15 @@ qrcode = { version = "0.14", default-features = false }
# The wg0-bound listener needs the interface's address; the stdlib has no
# getifaddrs. This is the smallest crate that wraps just that.
if-addrs = "0.15"
# Generates this server's TLS certificates on first start, replacing a
# setup script that shelled out to whatever openssl happened to be
# installed. In process means one place decides the extensions, the file
# modes, and which addresses the leaf covers. x509-parser so the issuer is
# read back from the CA actually on disk: reconstructing it from the same
# parameters would work only as long as nothing ever changed them, and a
# mismatched issuer name yields a chain that fails to validate rather than
# anything that looks wrong at generation time.
rcgen = { version = "0.14", features = ["pem", "x509-parser"] }
# Outbound HTTPS for the usage endpoint. A small blocking client fits an
# every-few-minutes poll better than pulling in reqwest's tower stack;
# rustls-backed like the rest of the TLS here.
+202
View File
@@ -0,0 +1,202 @@
//! The TLS certificates this server presents, generated in process on
//! first start.
//!
//! There used to be a `gen-dev-cert.sh` calling openssl, which meant a
//! setup step to remember, a second place for the "which SANs?" answer to
//! live, and a dependency on whatever openssl was installed. Doing it here
//! means the server can simply ensure its own certificates exist, with the
//! file modes and extensions it wants, and with the address it is actually
//! about to bind already in the leaf.
//!
//! The split that matters is between the two:
//!
//! - The **CA** is generated once and then left alone. The app
//! pins it, so replacing it strands every installed copy -- recovery is
//! a reinstall over the plain-HTTP bootstrap port. It is the one thing
//! here that is a one-way door.
//! - The **leaf** is cheap and reissued on every start, signed by that
//! same unchanged CA. Nothing pins it, so covering a new address is just
//! a restart rather than anything the phone has to be told about.
//!
//! Everything is written owner-only into a directory outside the repo (see
//! `config_home`): the repo is a mount shared with a VM that is not
//! trusted, and a CA private key that VM can read is one it can sign with
//! -- a certificate signed by a pinned CA is accepted without question,
//! which is exactly the attack pinning exists to stop.
use std::net::IpAddr;
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use rcgen::{
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
};
/// Where the leaf lives, for handing to the TLS listener.
pub struct Certificates {
pub leaf_cert: PathBuf,
pub leaf_key: PathBuf,
/// True when the CA was created just now, i.e. anything already
/// installed pins the wrong one and has to be reinstalled.
pub ca_is_new: bool,
}
/// Ensures `dir` holds a CA and a leaf covering `addresses`, creating what
/// is missing. Safe to call on every start.
pub fn ensure(dir: &Path, addresses: &[IpAddr]) -> Result<Certificates> {
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
.with_context(|| format!("create {}", dir.display()))?;
// Set explicitly as well: `mode` applies only when the directory is
// created, so a directory that already existed -- made by hand, or by
// an older version -- would otherwise keep whatever permissions it had
// while holding a private key.
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("restrict {}", dir.display()))?;
let ca_cert_path = dir.join("ca.pem");
let ca_key_path = dir.join("ca-key.pem");
let ca_is_new = !ca_cert_path.is_file() || !ca_key_path.is_file();
let (ca_pem, ca_key_pem) = if ca_is_new {
let (pem, key) = generate_ca()?;
write_private(&ca_key_path, &key)?;
write_private(&ca_cert_path, &pem)?;
tracing::info!("generated a new CA in {}", dir.display());
(pem, key)
} else {
(
std::fs::read_to_string(&ca_cert_path)
.with_context(|| format!("read {}", ca_cert_path.display()))?,
std::fs::read_to_string(&ca_key_path)
.with_context(|| format!("read {}", ca_key_path.display()))?,
)
};
let (leaf_pem, leaf_key_pem) = generate_leaf(&ca_pem, &ca_key_pem, addresses)?;
let leaf_cert = dir.join("leaf.pem");
let leaf_key = dir.join("leaf-key.pem");
write_private(&leaf_key, &leaf_key_pem)?;
write_private(&leaf_cert, &leaf_pem)?;
Ok(Certificates { leaf_cert, leaf_key, ca_is_new })
}
fn generate_ca() -> Result<(String, String)> {
let key = KeyPair::generate().context("generate CA key")?;
let mut params = CertificateParams::default();
params.distinguished_name.push(DnType::OrganizationName, "ai-app dev");
params.distinguished_name.push(DnType::CommonName, "ai-app dev CA");
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
// Explicit, because strict verifiers reject a CA without them -- and
// that rejection surfaces as an opaque handshake failure on a phone.
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let certificate = params.self_signed(&key).context("self-sign CA")?;
Ok((certificate.pem(), key.serialize_pem()))
}
fn generate_leaf(
ca_pem: &str,
ca_key_pem: &str,
addresses: &[IpAddr],
) -> Result<(String, String)> {
let ca_key = KeyPair::from_pem(ca_key_pem).context("read CA key")?;
let issuer = Issuer::from_ca_cert_pem(ca_pem, ca_key).context("read CA certificate")?;
let key = KeyPair::generate().context("generate leaf key")?;
let mut params = CertificateParams::default();
params.distinguished_name.push(DnType::OrganizationName, "ai-app dev");
params.distinguished_name.push(
DnType::CommonName,
addresses.first().map(|a| a.to_string()).unwrap_or_else(|| "local-updater".to_string()),
);
params.subject_alt_names = addresses.iter().map(|a| SanType::IpAddress(*a)).collect();
params.is_ca = IsCa::ExplicitNoCa;
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
params.use_authority_key_identifier_extension = true;
let certificate = params.signed_by(&key, &issuer).context("sign leaf")?;
Ok((certificate.pem(), key.serialize_pem()))
}
/// Writes owner-readable only, from the moment the file exists rather than
/// a `chmod` afterwards.
fn write_private(path: &Path, contents: &str) -> Result<()> {
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("write {}", path.display()))?;
file.write_all(contents.as_bytes())
.with_context(|| format!("write {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn addresses() -> Vec<IpAddr> {
vec!["10.66.0.1".parse().unwrap(), "127.0.0.1".parse().unwrap()]
}
#[test]
fn generates_once_then_keeps_the_ca_and_reissues_the_leaf() {
let dir = tempfile::tempdir().expect("tempdir");
let first = ensure(dir.path(), &addresses()).expect("generate");
assert!(first.ca_is_new);
let ca = std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca");
let leaf = std::fs::read_to_string(&first.leaf_cert).expect("leaf");
assert!(ca.starts_with("-----BEGIN CERTIFICATE-----"));
let second = ensure(dir.path(), &addresses()).expect("regenerate");
// The CA is the pinned one: replacing it would strand every
// installed app, so it must survive a restart untouched.
assert!(!second.ca_is_new);
assert_eq!(ca, std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca"));
// The leaf is not pinned, and is reissued so a new address is just
// a restart away.
assert_ne!(leaf, std::fs::read_to_string(&second.leaf_cert).expect("leaf"));
}
#[test]
fn everything_is_owner_only() {
let dir = tempfile::tempdir().expect("tempdir");
let certs = ensure(dir.path(), &addresses()).expect("generate");
assert_eq!(
std::fs::metadata(dir.path()).expect("dir").permissions().mode() & 0o777,
0o700,
);
for file in ["ca.pem", "ca-key.pem", "leaf.pem", "leaf-key.pem"] {
let mode = std::fs::metadata(dir.path().join(file))
.expect(file)
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "{file} is not owner-only");
}
assert!(certs.leaf_key.is_file());
}
/// The pair has to be loadable by the TLS stack that will actually
/// serve it -- a "file exists" check wouldn't catch a key that doesn't
/// match its certificate, which fails at the first handshake instead.
#[tokio::test]
async fn the_leaf_loads_into_the_real_tls_config() {
// main() installs this; tests don't run main. Both rustls crypto
// providers are in the graph (ureq brings ring, axum-server
// aws-lc-rs), so rustls refuses to pick one on its own. Ignoring
// the result because another test may have installed it first.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let dir = tempfile::tempdir().expect("tempdir");
let certs = ensure(dir.path(), &addresses()).expect("generate");
axum_server::tls_rustls::RustlsConfig::from_pem_file(&certs.leaf_cert, &certs.leaf_key)
.await
.expect("the generated leaf and key should load as a TLS identity");
}
}
+8 -2
View File
@@ -12,7 +12,7 @@
//! holds only the metadata needed to list and respawn sessions.
use std::fs::File;
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
@@ -26,7 +26,13 @@ pub fn create_private_dir(dir: &Path) -> Result<()> {
.recursive(true)
.mode(0o700)
.create(dir)
.with_context(|| format!("create {}", dir.display()))
.with_context(|| format!("create {}", dir.display()))?;
// Set explicitly as well: `mode` applies only when the directory is
// created, so one that already existed -- made by hand, or by a
// version that didn't do this -- would otherwise keep whatever
// permissions it had while holding transcripts.
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("restrict {}", dir.display()))
}
/// Opens `path` for writing, creating it owner-readable only.
+48 -16
View File
@@ -14,6 +14,7 @@
//! unencrypted by misconfiguration -- even inside the tunnel.
mod auth;
mod certs;
mod config;
mod routes;
mod session;
@@ -24,7 +25,7 @@ use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use anyhow::{Context, Result};
use clap::Parser;
use config::TokenEntry;
@@ -79,8 +80,8 @@ struct Args {
#[arg(long)]
data_dir: Option<PathBuf>,
/// Directory holding `leaf.pem`/`leaf-key.pem`, as produced by
/// `gen-dev-cert.sh`. Defaults to `$XDG_CONFIG_HOME/ai-app/certs`.
/// Directory holding the TLS certificates, generated here on first
/// start. Defaults to `$XDG_CONFIG_HOME/ai-app/certs`.
#[arg(long)]
certs: Option<PathBuf>,
@@ -90,6 +91,30 @@ struct Args {
rotate_token: bool,
}
/// Every address this machine answers on, for the leaf's SANs -- so the
/// certificate covers whatever the phone actually dials without anyone
/// maintaining a hardcoded IP. In production that is the WireGuard
/// address; loopback is included for curl and tests, and 10.0.2.2 is the
/// alias an Android emulator reaches its host by, which is not a real
/// interface anywhere.
fn local_addresses() -> Vec<IpAddr> {
let mut addresses = vec![IpAddr::from([127, 0, 0, 1]), IpAddr::from([10, 0, 2, 2])];
match if_addrs::get_if_addrs() {
Ok(interfaces) => {
for interface in interfaces {
let ip = interface.ip();
if ip.is_ipv4() && !addresses.contains(&ip) {
addresses.push(ip);
}
}
}
// Not fatal: the certificate still covers loopback, which is
// enough to start and to diagnose from the machine itself.
Err(err) => tracing::warn!("couldn't enumerate interfaces for the certificate: {err}"),
}
addresses
}
/// The IPv4 address on the WireGuard interface, or a refusal to start.
/// Failing closed here (rather than falling back to a wider bind) is part
/// of the security posture -- see the module doc comment.
@@ -157,6 +182,22 @@ async fn main() -> Result<()> {
tracing::info!(" session {} ({}, {:?})", info.id, info.provider, info.status);
}
// Before the interface check below, deliberately: the certificates are
// also what the phone app embeds at build time, so they need to be
// obtainable on a machine whose tunnel isn't up yet. The leaf is
// reissued on every start, so once wg0 exists the next start covers it.
let certs_dir = args.certs.unwrap_or_else(|| config_home().join("certs"));
let certificates = certs::ensure(&certs_dir, &local_addresses())
.with_context(|| format!("failed to prepare certificates in {}", certs_dir.display()))?;
if certificates.ca_is_new {
tracing::warn!(
"a new CA was generated in {} -- any installed app pins the previous one and can no \
longer reach this server. Rebuild it with app/build-apk.sh, which embeds this CA, \
and reinstall through Local Updater.",
certs_dir.display(),
);
}
let bind_ip = match args.bind {
Some(ip) => {
tracing::warn!(
@@ -183,19 +224,10 @@ async fn main() -> Result<()> {
print_enrollment(bind_ip, args.port, &token)?;
}
let certs_dir = args.certs.unwrap_or_else(|| config_home().join("certs"));
let leaf_cert = certs_dir.join("leaf.pem");
let leaf_key = certs_dir.join("leaf-key.pem");
if !leaf_cert.is_file() || !leaf_key.is_file() {
bail!(
"missing {} / {} -- run ./gen-dev-cert.sh on this machine first. The app pins the CA \
it generates and this server refuses to serve without TLS, so the private keys must \
be generated here and stay here.",
leaf_cert.display(),
leaf_key.display(),
);
}
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(&leaf_cert, &leaf_key)
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(
&certificates.leaf_cert,
&certificates.leaf_key,
)
.await
.context("failed to load TLS cert/key")?;