Keep state and keys out of the shared repo

The dev VM is treated as untrusted, and the repo is a read-write virtiofs
mount shared with the backend host -- so a CA private key sitting in it is
a key that machine can sign with, and a leaf signed by this CA is one the
phone's pinned app accepts without question. Pinning against a CA the
attacker holds is no pinning at all.

So certificates are now generated on the machine that serves them, into
$XDG_CONFIG_HOME/ai-app/certs at 0700 with 0600 keys (AI_APP_CERTS
overrides), and config.json and session transcripts move to the XDG config
and data directories. Transcripts move for a plainer reason than the keys:
they are whole conversations, and they were world-readable at 0644.

Two smaller things fall out. The host and VM stop sharing one config,
which had already put a test token on the production backend. And state
stops living where `git clean -xdf` would take the enrollment and every
transcript with it.

State that predates the move is still read from the repo, with a warning
naming where to move it, so an existing install keeps working rather than
silently coming up on an empty config -- the precedence is covered by a
test, since picking the wrong file would otherwise be silent.

Verified: 31 tests, clippy clean; the certificate script writing 0700/0600
into an overridden directory; and the server logging the fallback and
serving from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-25 03:49:34 -04:00
1 parent fff1fb49e8
commit d2d2832ec8
8 files changed
+248 -37

No files matched your search

+114 -13
View File
@@ -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<IpAddr>,
/// 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<PathBuf>,
/// 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<PathBuf>,
/// 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<PathBuf>,
@@ -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") };
}
}