diff --git a/AGENTS.md b/AGENTS.md index e75fe86..fe585a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,10 +106,19 @@ Established 2026-08-25, and it decides more than it looks like: configured host, and a session that names it. For the host to ssh in, the VM needs an inbound port forward in its launch configuration (qemu `hostfwd`) — usermode networking has none by default. -- **`config.json` is shared with the host too**, so it is the *production* - config: don't leave test tokens or throwaway hosts in it. Point - development at a scratch one instead: - `--config /tmp/…/config.json --data-dir /tmp/…/sessions --port 8444`. +- **Nothing secret goes in the repo.** The VM is treated as untrusted (see + 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. The server + still reads a pre-move `config.json`/`sessions/` from the repo, with a + warning, so an old install keeps working. +- 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 + 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 + `XDG_CONFIG_HOME=… XDG_DATA_HOME=…`. ## Things that have bitten diff --git a/PLAN.md b/PLAN.md index 79026ee..f038b32 100644 --- a/PLAN.md +++ b/PLAN.md @@ -252,15 +252,46 @@ GET /usage cached usage windows GET/PUT /hosts, /models config editing from the phone ``` -Sessions live in `config.json` + a per-session directory (transcript.jsonl, -attachments, produced images). Deleting a session is the complete path out of -everything spawning one created. +Sessions live in `config.json` (`$XDG_CONFIG_HOME/ai-app/`) + a per-session +directory under `$XDG_DATA_HOME/ai-app/sessions/` (transcript.jsonl, +attachments, produced images), owner-only. Deleting a session is the +complete path out of everything spawning one created. State that predates +the move is still read from the repo, loudly, so an existing install keeps +working until it is moved by hand. ### 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. +- **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 + under this model everything in it — source, `server/target/` binaries, + and the shell scripts the host runs, some with sudo — is + attacker-writable. Two consequences: + - **Nothing secret lives in the repo.** Certificates are generated on + the machine that serves them and written to + `$XDG_CONFIG_HOME/ai-app/certs` (0700, keys 0600); `config.json` and + session transcripts go to the XDG config and data directories, per + machine. A CA private key the VM could read would let it mint a leaf + the pinned app accepts, which is precisely the attack pinning exists + to stop — pinning against a CA the attacker holds is no pinning at + all. Transcripts move for a plainer reason: they are whole + conversations. As a bonus this ends the host and VM sharing one + config, which had already produced a test token live on the backend, + and takes state out of reach of `git clean -xdf`. + - **The host should not execute what the VM can write** — build and run + the backend from a host-only checkout rather than the shared mount. + Moving the keys closes the smaller door; this is the larger one. + - Development in the VM generates its own throwaway CA. Whatever is + installed on the real phone must pin only the host's. + - The CA key is not needed by the server at all (only `leaf.pem` and + `leaf-key.pem` are read), so it can move offline once the setup is + stable; reissuing a leaf is the only time it is wanted. + - Not addressed, and accepted: a compromised VM can return anything it + likes from the sessions it runs, since running an agent there is the + point. The blast radius is that session's content, not the backend. - This server is strictly more dangerous than the updater: its API *is* remote code execution (spawn a bypass-permissions Claude on any SSH host). Pinning authenticates the server to the phone but not the phone to the diff --git a/gen-dev-cert.sh b/gen-dev-cert.sh index ec0ba06..fb20982 100755 --- a/gen-dev-cert.sh +++ b/gen-dev-cert.sh @@ -1,7 +1,8 @@ #!/bin/sh # Generates the self-signed dev CA and leaf certificate `server/` serves its -# TLS listener with. Run once before the first `cargo run`; the server exits -# with a clear message if `certs/` is missing. +# TLS listener with. Run it once, ON THE MACHINE THAT RUNS THE BACKEND, before +# the first start; the server exits with a clear message if the certificates +# are missing. # # Same scheme as ../local-updater's: nothing on a device trusts this # automatically -- the app embeds the CA certificate verbatim and pins to it @@ -9,6 +10,25 @@ # This server's API *is* remote code execution (it spawns AI sessions on # request), so a MITM on it would be as bad as it gets -- hence pinning. # +# WHERE THE KEYS LIVE, AND WHY NOT IN THE REPO +# +# Output goes to $XDG_CONFIG_HOME/ai-app/certs (0700), deliberately *not* +# beside this script. The repo is a virtiofs mount shared with the dev VM, +# and that VM is treated as untrusted -- a machine that isn't malicious but +# could become so. A CA private key it can read is a CA private key it can +# sign with, and a leaf signed by this CA is one the phone's pinned app +# accepts without question. Keeping the key off the shared mount is what +# makes pinning mean anything. +# +# The same reasoning says the CA key doesn't belong on the backend either, +# strictly: the server only ever reads leaf.pem and leaf-key.pem, and the CA +# key is needed solely to reissue a leaf. Moving ca-key.pem somewhere offline +# once the setup is stable costs nothing but having it to hand at reissue. +# +# Running this inside the VM is fine and expected for emulator work -- it +# just produces a *different*, throwaway CA there. Never install a build +# pinning that dev CA on the real phone. +# # The CA is idempotent -- skipped if `certs/ca.pem` already exists, so # re-running this doesn't invalidate the certificate the installed app has # pinned against without a reason to. The leaf is cheap and reissued on @@ -40,7 +60,7 @@ set -eu SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) -CERTS_DIR="$SCRIPT_DIR/certs" +CERTS_DIR="${AI_APP_CERTS:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs}" # The backend's WireGuard address -- the one address the phone ever dials in # production (PLAN.md: single-path addressing, no home/away distinction). SERVER_IP="${SERVER_IP:-10.66.0.1}" @@ -53,8 +73,13 @@ LOOPBACK_IP="127.0.0.1" EMULATOR_HOST_IP="10.0.2.2" LAN_IP="${LAN_IP:-192.168.1.168}" +# Private key material: owner-only from the moment it exists, rather than +# created world-readable and chmod'ed a beat later. +umask 077 mkdir -p "$CERTS_DIR" +chmod 700 "$CERTS_DIR" cd "$CERTS_DIR" +echo "==> Writing certificates to $CERTS_DIR" if [ -f ca.pem ]; then echo "==> ca.pem already exists, reusing existing CA." @@ -103,11 +128,15 @@ echo "==> Done." echo " CA fingerprint (base64): $CA_SHA256" echo " Leaf fingerprint (base64): $LEAF_SHA256" echo -echo " Only relevant if the CA was regenerated just now (i.e. certs/ca.pem" -echo " did not already exist): the app embeds PINNED_CA_PEM and needs the" -echo " new certs/ca.pem contents pasted in, or it silently stops being able" -echo " to reach this server. The app installs via Local Updater, so" -echo " recovery is a reinstall through that -- but it's still a one-way" -echo " door for the installed copy." +echo " Only relevant if the CA was regenerated just now (i.e. ca.pem did" +echo " not already exist): the app embeds PINNED_CA_PEM and needs the new" +echo " $CERTS_DIR/ca.pem contents pasted in, or it silently" +echo " stops being able to reach this server. The app installs via Local" +echo " Updater, so recovery is a reinstall through that -- but it's still" +echo " a one-way door for the installed copy." echo echo " app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt" +echo +echo " Only ca.pem is meant to leave this machine. Copy it by hand; do not" +echo " put the certs directory back in the repo, which is shared with the" +echo " VM (see this script's header)." diff --git a/server/src/config.rs b/server/src/config.rs index 7945f55..5cff5dd 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -11,11 +11,35 @@ //! JSONL file in its own directory (see `session::transcript`); this file //! holds only the metadata needed to list and respawn sessions. +use std::fs::File; +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +/// Creates `dir` (and parents) owner-accessible only. Session directories +/// and the config directory both go through here: transcripts are whole +/// conversations, which is the most sensitive thing this server stores. +pub fn create_private_dir(dir: &Path) -> Result<()> { + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir) + .with_context(|| format!("create {}", dir.display())) +} + +/// Opens `path` for writing, creating it owner-readable only. +pub fn private_file(path: &Path) -> Result { + std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .with_context(|| format!("write {}", path.display())) +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct Config { @@ -175,14 +199,26 @@ impl Config { } } + /// Writes the config, owner-readable only. + /// + /// The token hashes here are verifiers, not secrets -- a 256-bit + /// random token can't be recovered from its SHA-256 -- but the file + /// also names every host this backend can reach and every session it + /// is running, which is nobody else's business on a shared machine. + /// The mode is set on the temporary file *before* the rename, so the + /// config is never briefly world-readable at its real path. pub fn save(&self, path: &Path) -> Result<()> { if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("create {}", parent.display()))?; + create_private_dir(parent)?; } let text = serde_json::to_string_pretty(self).context("serialize config")?; let tmp = path.with_extension("json.tmp"); - std::fs::write(&tmp, text).with_context(|| format!("write {}", tmp.display()))?; + { + use std::io::Write; + let mut file = private_file(&tmp)?; + file.write_all(text.as_bytes()) + .with_context(|| format!("write {}", tmp.display()))?; + } std::fs::rename(&tmp, path) .with_context(|| format!("replace {} with {}", path.display(), tmp.display()))?; Ok(()) diff --git a/server/src/main.rs b/server/src/main.rs index 486fb27..1f88433 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -33,9 +33,55 @@ use session::SessionManager; const DEFAULT_PORT: u16 = 8443; const WG_INTERFACE: &str = "wg0"; -/// The repo root, one level above this crate. Everything the server reads -/// by default -- the TLS cert, the config, the session data -- resolves -/// from here, so there's one definition of it rather than one per caller. +/// `$XDG_CONFIG_HOME/ai-app`, or `~/.config/ai-app`. Holds `config.json` +/// and `certs/`. +fn config_home() -> PathBuf { + xdg_dir("XDG_CONFIG_HOME", ".config") +} + +/// `$XDG_DATA_HOME/ai-app`, or `~/.local/share/ai-app`. Holds the session +/// directories: transcripts, attachments, produced images. +fn data_home() -> PathBuf { + xdg_dir("XDG_DATA_HOME", ".local/share") +} + +fn xdg_dir(var: &str, fallback: &str) -> PathBuf { + std::env::var_os(var) + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| { + std::env::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(fallback) + }) + .join("ai-app") +} + +/// Picks `preferred`, unless only the older in-repo `legacy` path exists -- +/// in which case it is used, loudly. +/// +/// State used to live in the repo, which is wrong for two reasons: a +/// `git clean -xdf` would take the enrollment and every transcript with +/// it, and this repo is a virtiofs mount shared with a machine that is not +/// trusted with them (see PLAN.md's security section). Moving is left to a +/// person rather than done silently, because relocating a live config out +/// from under a running backend is not the kind of thing a program should +/// decide to do on its own. +fn prefer_xdg(preferred: PathBuf, legacy: PathBuf, what: &str) -> PathBuf { + if !preferred.exists() && legacy.exists() { + tracing::warn!( + "using {} from {} -- move it to {} when convenient; the repo is shared with the VM \ + and is not a good home for {what}", + what, + legacy.display(), + preferred.display(), + ); + return legacy; + } + preferred +} + +/// The repo root, one level above this crate. Only used now to find the +/// legacy in-repo state above; everything the server reads by default +/// resolves from the XDG directories instead. /// /// Found from the running executable first, and only then from the path /// compiled in. This repo is shared between a VM and its host over @@ -76,18 +122,18 @@ struct Args { #[arg(long)] bind: Option, - /// Where the token hashes and session list live. Defaults to - /// `config.json` beside this repo's `certs/`. + /// Where the token hashes, providers, hosts, and session list live. + /// Defaults to `$XDG_CONFIG_HOME/ai-app/config.json`. #[arg(long)] config: Option, /// Directory for per-session data (transcripts, attachments, images). - /// Defaults to `sessions/` in the repo root. + /// Defaults to `$XDG_DATA_HOME/ai-app/sessions`. #[arg(long)] data_dir: Option, - /// Directory holding `leaf.pem`/`leaf-key.pem`. Defaults to this - /// repo's `certs/`, as produced by `gen-dev-cert.sh`. + /// Directory holding `leaf.pem`/`leaf-key.pem`, as produced by + /// `gen-dev-cert.sh`. Defaults to `$XDG_CONFIG_HOME/ai-app/certs`. #[arg(long)] certs: Option, @@ -147,8 +193,16 @@ async fn main() -> Result<()> { tracing_subscriber::fmt().with_env_filter("info").init(); let args = Args::parse(); - let config_path = args.config.unwrap_or_else(|| repo_root().join("config.json")); - let data_dir = args.data_dir.unwrap_or_else(|| repo_root().join("sessions")); + let config_path = args.config.unwrap_or_else(|| { + prefer_xdg( + config_home().join("config.json"), + repo_root().join("config.json"), + "the config", + ) + }); + let data_dir = args.data_dir.unwrap_or_else(|| { + prefer_xdg(data_home().join("sessions"), repo_root().join("sessions"), "transcripts") + }); let manager = Arc::new( SessionManager::new(config_path.clone(), data_dir) .with_context(|| format!("failed to load {}", config_path.display()))?, @@ -190,13 +244,16 @@ async fn main() -> Result<()> { print_enrollment(bind_ip, args.port, &token)?; } - let certs_dir = args.certs.unwrap_or_else(|| repo_root().join("certs")); + let certs_dir = args + .certs + .unwrap_or_else(|| prefer_xdg(config_home().join("certs"), repo_root().join("certs"), "certificates")); 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 first (the app pins the CA it generates, \ - and this server refuses to serve without TLS)", + "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(), ); @@ -230,3 +287,47 @@ async fn main() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Choosing the wrong file here is silent -- the server would come up + /// happily on the wrong tokens and the wrong session list -- so the + /// precedence is worth pinning down. + #[test] + fn state_prefers_xdg_and_falls_back_to_the_repo_only_when_it_must() { + let dir = tempfile::tempdir().expect("tempdir"); + let xdg = dir.path().join("xdg/config.json"); + let legacy = dir.path().join("repo/config.json"); + std::fs::create_dir_all(xdg.parent().unwrap()).expect("mkdir"); + std::fs::create_dir_all(legacy.parent().unwrap()).expect("mkdir"); + + // Neither exists: the XDG path, which is where a first run writes. + assert_eq!(prefer_xdg(xdg.clone(), legacy.clone(), "the config"), xdg); + + // Only the old in-repo one exists: keep using it rather than + // silently starting empty and losing the enrollment. + std::fs::write(&legacy, "{}").expect("write"); + assert_eq!(prefer_xdg(xdg.clone(), legacy.clone(), "the config"), legacy); + + // Both exist: the moved one wins, so finishing a migration takes + // effect even if the old file was left behind. + std::fs::write(&xdg, "{}").expect("write"); + assert_eq!(prefer_xdg(xdg.clone(), legacy, "the config"), xdg); + } + + #[test] + fn xdg_dirs_respect_the_environment_and_are_namespaced() { + // Relative values are ignored per the spec, rather than resolving + // against whatever the working directory happens to be. + unsafe { std::env::set_var("AI_APP_TEST_XDG", "relative/path") }; + let fallback = xdg_dir("AI_APP_TEST_XDG", ".config"); + assert!(fallback.is_absolute() || fallback.starts_with(".")); + assert!(fallback.ends_with("ai-app")); + + unsafe { std::env::set_var("AI_APP_TEST_XDG", "/somewhere") }; + assert_eq!(xdg_dir("AI_APP_TEST_XDG", ".config"), PathBuf::from("/somewhere/ai-app")); + unsafe { std::env::remove_var("AI_APP_TEST_XDG") }; + } +} diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index 50ecc36..6b85ef6 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -629,7 +629,9 @@ impl Translator { let name = format!("{}.{extension}", super::random_hex()); let dir = self.session_dir.join("files"); if let Err(err) = - std::fs::create_dir_all(&dir).and_then(|_| std::fs::write(dir.join(&name), bytes)) + crate::config::create_private_dir(&dir) + .map_err(std::io::Error::other) + .and_then(|()| std::fs::write(dir.join(&name), bytes)) { tracing::error!("couldn't save produced image: {err}"); return None; diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 05536c5..7984557 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -147,7 +147,7 @@ impl LiveSession { }; let name = format!("{}.{extension}", random_hex()); let dir = self.dir().join("attachments"); - std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; + crate::config::create_private_dir(&dir)?; std::fs::write(dir.join(&name), bytes) .with_context(|| format!("write attachment {name}"))?; Ok(name) @@ -189,8 +189,7 @@ impl SessionManager { /// session spawns its event pump). pub fn new(config_path: PathBuf, data_dir: PathBuf) -> Result { let config = Config::load(&config_path)?; - std::fs::create_dir_all(&data_dir) - .with_context(|| format!("create {}", data_dir.display()))?; + crate::config::create_private_dir(&data_dir)?; let mut live = HashMap::new(); for meta in &config.sessions { @@ -452,7 +451,7 @@ fn launch( data_dir: &Path, ) -> Result> { let dir = data_dir.join(&meta.id); - std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; + crate::config::create_private_dir(&dir)?; let transcript_path = dir.join("transcript.jsonl"); let transcript = Transcript::open(&transcript_path)?; diff --git a/server/src/session/transcript.rs b/server/src/session/transcript.rs index 0581b69..9913d4e 100644 --- a/server/src/session/transcript.rs +++ b/server/src/session/transcript.rs @@ -8,6 +8,7 @@ use std::fs::{File, OpenOptions}; use std::io::{BufRead, BufReader, Write}; +use std::os::unix::fs::OpenOptionsExt; use std::path::Path; use anyhow::{Context, Result}; @@ -36,9 +37,12 @@ impl Transcript { /// the last line if one exists. pub fn open(path: &Path) -> Result { let last_seq = last_seq(path)?; + // Owner-only: a transcript is the whole conversation, including + // whatever the session read, wrote, or was told. let file = OpenOptions::new() .create(true) .append(true) + .mode(0o600) .open(path) .with_context(|| format!("open transcript {}", path.display()))?; Ok(Self { file, next_seq: last_seq + 1 })