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
+39
-3
@@ -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
@@ -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") };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in new issue
Block a user