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

+39 -3
View File
@@ -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<File> {
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(())
+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") };
}
}
+3 -1
View File
@@ -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;
+3 -4
View File
@@ -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<Self> {
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<Arc<LiveSession>> {
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)?;
+4
View File
@@ -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<Self> {
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 })