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(())