diff --git a/AGENTS.md b/AGENTS.md index 574d335..151e194 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/PLAN.md b/PLAN.md index 9b77a8d..02ac20a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/gen-dev-cert.sh b/gen-dev-cert.sh deleted file mode 100755 index ceb76a1..0000000 --- a/gen-dev-cert.sh +++ /dev/null @@ -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 < 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)." diff --git a/server/Cargo.lock b/server/Cargo.lock index 939ba92..bc66b4c 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -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" diff --git a/server/Cargo.toml b/server/Cargo.toml index a509cab..e84db2a 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -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. diff --git a/server/src/certs.rs b/server/src/certs.rs new file mode 100644 index 0000000..7f7b5ed --- /dev/null +++ b/server/src/certs.rs @@ -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 { + 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 { + 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"); + } +} diff --git a/server/src/config.rs b/server/src/config.rs index 5cff5dd..d51f616 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -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. diff --git a/server/src/main.rs b/server/src/main.rs index 4ba52f2..ee2d5ed 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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, - /// 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, @@ -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 { + 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,21 +224,12 @@ 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) - .await - .context("failed to load TLS cert/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")?; let monitor = Arc::new(usage::UsageMonitor::new(vec![Box::new(usage::ClaudeUsage { credentials_path: std::env::home_dir()