diff --git a/AGENTS.md b/AGENTS.md index 73f0fe7..79aced0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,11 +22,11 @@ session-type branch in shared code (routes, transcript, app screens). ## Layout -Mirrors `../local-updater` deliberately — same stack (axum 0.8 + +Mirrors `../dev-updater` deliberately — same stack (axum 0.8 + axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single `:androidApp` module), same cert scheme, same registry pattern (every session mutation funnels through the manager so in-memory and on-disk state -can't come apart). Read local-updater's `README.md` and `AGENTS.md` for the +can't come apart). Read dev-updater's `README.md` and `AGENTS.md` for the conventions before diverging from them; module-by-module intent for this repo is in PLAN.md's "Backend layout" section. @@ -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 @@ -59,8 +60,15 @@ real-phone/WireGuard bring-up, which is operational rather than code. - Server: `./run-tests.sh` (or `cargo test`) + `cargo clippy --all-targets` from `server/` — the build stays warning-clean, keep it that way. -- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:compileDebugKotlin`; - `./run-android.sh` builds, installs, and launches on the emulator. +- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:compileDebugKotlin` + to typecheck; `./build-apk.sh` to produce the APK to install on a phone + (through Dev Updater); `./run-android.sh` to build, install, and launch + 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 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 on this machine yet; the default fails closed). First run prints the enrollment QR/URI with the token — capture it from the log. @@ -75,7 +83,7 @@ real-phone/WireGuard bring-up, which is operational rather than code. Established 2026-08-25, and it decides more than it looks like: - **The host (192.168.1.168) is the backend machine.** It runs - local-updater's server today and is where `ai-server` belongs in + dev-updater's server today and is where `ai-server` belongs in production: it has the LAN address the phone can reach, and it's where WireGuard terminates. `wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it there with `sudo WG_ENDPOINT=`. @@ -115,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 @@ -138,11 +148,19 @@ Established 2026-08-25, and it decides more than it looks like: - **CMP 1.11 deprecates the `compose.*` dependency accessors** — declare `org.jetbrains.compose.:` directly (material3 has its own release train, separate from the CMP version). +- **A PEM constant must start at the opening quotes.** A generated + `"""\n-----BEGIN CERTIFICATE-----` costs Android's `CertificateFactory` + its preamble sniff, so it tries DER instead and fails at runtime with + `ASN.1 ... DECODE_ERROR` — nowhere near the code that produced it. +- **AGP 9 refuses `Provider`s in the source-set API**: generated sources go + through `androidComponents.onVariants { it.sources.java?.addGenerated + SourceDirectory(task, Task::outputDir) }`, which also carries the task + dependency. -## Environment notes (this machine, learned in local-updater) +## Environment notes (this machine, learned in dev-updater) - Android SDK is at `~/Android/Sdk`, not the root-owned `/opt/android-sdk` - the ambient `$ANDROID_HOME` may point at; copy local-updater's + the ambient `$ANDROID_HOME` may point at; copy dev-updater's `android-env.sh` override pattern. - Each agent command runs in a fresh shell — exported environment does not carry over. Chain: `cd app && . ./android-env.sh && ./gradlew …`. Never @@ -153,5 +171,5 @@ Established 2026-08-25, and it decides more than it looks like: (`setsid nohup … & disown -h`, verify `PPID 1`), and every `pgrep -f`/`pkill -f` pattern needs its first character bracketed (`[a]i-server`) in **every** occurrence in the command, or the pattern - matches the shell running it. Full explanation in local-updater's + matches the shell running it. Full explanation in dev-updater's `AGENTS.md` — it bites exactly the same way here. diff --git a/PLAN.md b/PLAN.md index fc0d283..a92f7f7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -7,7 +7,7 @@ the official app gets wrong (e.g. it won't deliver a typed message until the session fully finishes its turn, where the TUI injects it at the next tool boundary). -Same shape as `../local-updater`: a Rust (Axum) backend on the desktop, a +Same shape as `../dev-updater`: a Rust (Axum) backend on the desktop, a Kotlin/Compose Android app, pinned self-signed TLS between them. ## The one idea everything hangs off @@ -81,12 +81,12 @@ backend (Rust/Axum, desktop) ### Backend layout (`server/`) -Mirroring local-updater's stack: axum 0.8, axum-server + rustls, tokio, serde, +Mirroring dev-updater's stack: axum 0.8, axum-server + rustls, tokio, serde, clap, tracing. Rust edition 2024, warning-clean, clippy in CI habit. - `main.rs` — bootstrap, TLS listener. - `routes.rs` — the whole HTTP table in one module doc comment (as in - local-updater). + dev-updater). - `session/mod.rs` — `SessionManager`: the live session registry, every mutation funnels through it (the `registry.rs` pattern: in-memory and on-disk state can't come apart). @@ -259,9 +259,24 @@ 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 dev-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 dev-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 + `app/androidApp/build.gradle.kts`; `AI_APP_CA` overrides). Decided + 2026-08-25, and it does three things at once — the trust anchor follows + the build machine, so 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 only good + for its emulator; there is no second anchor to add for development and + forget to remove; and regenerating a CA needs a rebuild rather than a + paste, so a stale constant can't quietly disagree with the server. - **The dev VM is untrusted** (decided 2026-08-25): a machine that isn't malicious but could become so. It matters because the repo is a read-write virtiofs mount shared between the VM and the backend host, so @@ -377,12 +392,12 @@ complete path out of everything spawning one created. a deliberate flag, never a fallback, so the fail-closed default is untouched (2026-08-24). - The bootstrap-over-HTTP trick from the updater is unnecessary here — the - app installs via Local Updater. + app installs via Dev Updater. ## App (`app/`) Kotlin + Compose Multiplatform, single `:androidApp` module, same versions as -local-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens: +dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens: 1. **Session list** — cards: kind icon, title, host, model, status (running / awaiting answer / idle / exited), last activity. Spawn FAB; @@ -405,7 +420,7 @@ local-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens: 5. **Settings** — server address + token, hosts editor, llama model list editor. -Networking mirrors local-updater's app layer (`AppsApi.kt` style thin client + +Networking mirrors dev-updater's app layer (`AppsApi.kt` style thin client + pinned transport), plus an SSE client with `after=` resume driven by connectivity/lifecycle. The app keeps no persistent transcript store — the backend's transcript is the source of truth; the app caches only for the @@ -486,7 +501,7 @@ window just fills. Each phase ends runnable and verified against the real thing (rule 22); the backend gets tests where logic is pure (event normalization, transcript cursors, config persistence, refcounting) — the app is UI over the API and is -verified by running it, matching local-updater's posture. +verified by running it, matching dev-updater's posture. ## Open questions / risks @@ -524,6 +539,6 @@ installed versions when each phase starts): ≥180 s polling; wrong User-Agent → aggressive 429 bucket): https://github.com/anthropics/claude-code/issues/31637 and https://github.com/Maciek-roboblog/Claude-Code-Usage-Monitor/issues/202 -- Sibling project this repo's conventions mirror: `../local-updater` +- Sibling project this repo's conventions mirror: `../dev-updater` (README.md + AGENTS.md — server/registry/routes layout, cert scheme, testing posture, Android env notes). diff --git a/app/androidApp/build.gradle.kts b/app/androidApp/build.gradle.kts index 35a5a9d..e0697c2 100644 --- a/app/androidApp/build.gradle.kts +++ b/app/androidApp/build.gradle.kts @@ -4,6 +4,84 @@ plugins { alias(libs.plugins.composeCompiler) } +// The CA this app pins is baked in at build time from the certificates on +// the machine doing the build -- `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`, +// which the server generates on first start. AI_APP_CA overrides the path. +// +// Reading it rather than keeping a pasted copy in the source is what makes +// the trust boundary follow the build: an APK built on the backend host +// pins the host's CA and never sees any other, while one built in the dev +// VM pins that VM's throwaway CA and is only good for its emulator. There +// is no second trust anchor to get wrong, and no stale paste to notice +// three days later. It also means the private key never has to exist +// anywhere near this repo. +val pinnedCaPath: String = System.getenv("AI_APP_CA") + ?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" + + "/ai-app/certs/ca.pem" + +abstract class GeneratePinnedCert : DefaultTask() { + /** Where the certificate is looked for, reported in failures. */ + @get:Input + abstract val caPath: Property + + /** + * The certificate itself, set only when it exists -- so a missing one + * produces this task's own instructions rather than Gradle's "no such + * input file", which doesn't say what to run. + */ + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + abstract val caCertificate: RegularFileProperty + + /** Wired by AGP through `addGeneratedSourceDirectory`. */ + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val path = caPath.get() + val ca = File(path) + if (!ca.isFile) { + throw GradleException( + "No CA certificate at $path.\n" + + "Start ai-server once on this machine first -- it generates the CA the " + + "app pins, and the certificate has to exist before an APK can embed it.\n" + + "Set AI_APP_CA=/path/to/ca.pem to build against a different one.", + ) + } + val pem = ca.readText().trim() + if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) { + throw GradleException("$path is not a PEM certificate.") + } + val file = outputDir.get().file("PinnedCaCertificate.kt").asFile + file.parentFile.mkdirs() + // The PEM must start immediately after the opening quotes: a + // leading newline makes Android's CertificateFactory stop + // recognising the "-----BEGIN" preamble and try to parse the whole + // thing as DER, which fails with an ASN.1 decode error at runtime + // rather than anywhere near this file. + file.writeText( + """ + |// Generated from $path by the generatePinnedCert task. Do not edit. + |package com.example.aiapp + | + |const val PINNED_CA_PEM = ""${'"'}$pem + |""${'"'} + | + """.trimMargin(), + ) + } +} + +val generatePinnedCert = tasks.register("generatePinnedCert") { + val ca = file(pinnedCaPath) + caPath.set(pinnedCaPath) + if (ca.isFile) { + caCertificate.set(ca) + } +} + android { namespace = "com.example.aiapp" compileSdk = 37 @@ -31,6 +109,17 @@ android { } } +// AGP 9 wants generated sources registered through the variant API rather +// than added to a source set, so the task dependency is carried properly. +androidComponents { + onVariants { variant -> + variant.sources.java?.addGeneratedSourceDirectory( + generatePinnedCert, + GeneratePinnedCert::outputDir, + ) + } +} + dependencies { implementation(libs.compose.runtime) implementation(libs.compose.foundation) diff --git a/app/androidApp/src/main/AndroidManifest.xml b/app/androidApp/src/main/AndroidManifest.xml index c2e43e1..775dd36 100644 --- a/app/androidApp/src/main/AndroidManifest.xml +++ b/app/androidApp/src/main/AndroidManifest.xml @@ -6,7 +6,7 @@ network address, including a plain socket to a LAN IP literal. Without it the traffic is silently dropped, surfacing only as a connect timeout. See MainActivity.kt's runtime request, and - local-updater's manifest for the full story. --> + dev-updater's manifest for the full story. --> Using ANDROID_HOME=$ANDROID_HOME" +elif [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "${ANDROID_SDK_ROOT}" ]; then + ANDROID_HOME="$ANDROID_SDK_ROOT" + export ANDROID_HOME + echo "==> Using ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" +elif [ -d "$HOME/Android/Sdk" ]; then + ANDROID_HOME="$HOME/Android/Sdk" + ANDROID_SDK_ROOT="$ANDROID_HOME" + export ANDROID_HOME ANDROID_SDK_ROOT + echo "==> Using $ANDROID_HOME" +else + echo "No Android SDK found. Set ANDROID_HOME to it, or install one" >&2 + echo "(Android Studio's default location is ~/Android/Sdk)." >&2 + exit 1 +fi + +CA="${AI_APP_CA:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs/ca.pem}" +if [ -f "$CA" ]; then + # Printed so a wrong or stale certificate is visible here rather than as + # a handshake failure on the phone. Compare with the server's own + # the CA the server is actually presenting. + FINGERPRINT=$(openssl x509 -in "$CA" -pubkey -noout 2>/dev/null \ + | openssl pkey -pubin -outform der 2>/dev/null \ + | openssl dgst -sha256 -binary 2>/dev/null \ + | openssl base64 2>/dev/null || echo "(openssl unavailable)") + echo "==> Pinning the CA at $CA" + echo " fingerprint: $FINGERPRINT" +else + echo "No CA certificate at $CA -- start ai-server once on this machine" >&2 + echo "(it generates them), or set AI_APP_CA. The APK embeds it at build time." >&2 + exit 1 +fi + +echo "==> Building" +./gradlew :androidApp:assembleDebug + +APK="$SCRIPT_DIR/androidApp/build/outputs/apk/debug/androidApp-debug.apk" +echo +echo "==> Built $APK" +[ -f "$APK" ] && ls -lh "$APK" | awk '{print " " $5}' +echo +echo "To get it onto the phone: add this project to Dev Updater (or hit" +echo "Update on it if it's already there) and install from there." +echo "Then start the backend and scan the enrollment QR it prints:" +echo " ./server/target/release/ai-server --rotate-token" diff --git a/app/run-android.sh b/app/run-android.sh index c85e9f5..3bc9693 100755 --- a/app/run-android.sh +++ b/app/run-android.sh @@ -1,6 +1,6 @@ #!/bin/sh # Builds and runs this app on an emulator, creating/booting the AVD first if -# it isn't already up. Same flow as local-updater's run-android.sh; see that +# it isn't already up. Same flow as dev-updater's run-android.sh; see that # script for the reasoning behind the avd handling. # # Environment setup (SDK location, PATH, ...) lives in ./android-env.sh, diff --git a/gen-dev-cert.sh b/gen-dev-cert.sh deleted file mode 100755 index fb20982..0000000 --- a/gen-dev-cert.sh +++ /dev/null @@ -1,142 +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): 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)." 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..4225eaa --- /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(|| "dev-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..77a29fa 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 Dev 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() diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 7984557..75289b0 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -1,7 +1,7 @@ //! The live session registry. Every session mutation -- spawn, delete, //! token changes -- funnels through [`SessionManager`] under one lock, so //! in-memory state and `config.json` can't come apart (the same pattern as -//! local-updater's `registry.rs`). +//! dev-updater's `registry.rs`). //! //! A live session is a driver plus one event pump: the driver reports //! [`Event`]s into an mpsc channel; the pump assigns each a sequence diff --git a/test-wg-tunnel.sh b/test-wg-tunnel.sh index b7ac7ad..e0e2fd4 100755 --- a/test-wg-tunnel.sh +++ b/test-wg-tunnel.sh @@ -14,8 +14,8 @@ # The veth pair stands in for "the internet" carrying WireGuard's UDP; the # wg interfaces are real, with a real handshake and real keys. 10.66.0.1 is # deliberately the same address the leaf certificate carries a SAN for -# (gen-dev-cert.sh), so a client inside the tunnel completes the same -# pinned-TLS handshake a phone will. +# (certs.rs covers every local address), so a client inside the tunnel +# completes the same pinned-TLS handshake a phone will. # # ./test-wg-tunnel.sh up create the tunnel (needs sudo) # ./test-wg-tunnel.sh test run the server on wg0 and reach it from "phone" @@ -80,8 +80,9 @@ up() { } test_tunnel() { - if [ ! -f "$REPO/certs/leaf.pem" ]; then - echo "No certs/ -- run ./gen-dev-cert.sh first." >&2 + CERTS="${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs" + if [ ! -f "$CERTS/leaf.pem" ]; then + echo "No certificates in $CERTS -- start ai-server once; it makes them." >&2 exit 1 fi if [ ! -x "$REPO/server/target/debug/ai-server" ]; then @@ -98,13 +99,12 @@ test_tunnel() { ss -tlnp 2>/dev/null | grep 8443 | sed 's/^/ /' || echo " (nothing on 8443)" echo "==> From inside the tunnel: GET /sessions through wg1 -> wg0" - TOKEN=$(sudo cat "$REPO/config.json" 2>/dev/null | sed -n 's/.*"sha256": "\(.*\)".*/\1/p' | head -1) if [ -z "${AI_TOKEN:-}" ]; then echo " (set AI_TOKEN= to test an authorized call;" echo " without it this only proves reachability + TLS, via a 401)" fi sudo ip netns exec "$NS" curl -s -o /dev/null -w " HTTP %{http_code} (TLS ok, pinned CA)\n" \ - --cacert "$REPO/certs/ca.pem" \ + --cacert "$CERTS/ca.pem" \ ${AI_TOKEN:+-H "Authorization: Bearer $AI_TOKEN"} \ "https://$SERVER_WG_IP:8443/sessions" || echo " UNREACHABLE"