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:
1 parent
fff1fb49e8
commit
d2d2832ec8
8 files changed
+248
-37
No files matched your search
+114
-13
@@ -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") };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user