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(())
|
||||
|
||||
Reference in new issue
Block a user