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

+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)?;