Take the link from wg-app-link instead of keeping a second copy

The five modules underneath this backend that were never about AI
sessions -- the pinned CA and leaf, QR enrollment and the bearer token,
wg0 binding and the certificate's SANs, owner-only files, and the RON
house rules -- were written twice, once here and once in dev-updater,
and had drifted. They now come from the submodule, as a path dependency
so both projects stay locked to one commit.

What stayed is what makes this project itself: the routes, the drivers,
the config schema, and the auth middleware, which is generic over this
server's state. Sharing a transport is worth doing; sharing an API would
mean inventing a vocabulary neither project wants.

Four dependencies go with the code -- rcgen, qrcode, subtle and if-addrs
are no longer named here at all -- and the three that remain are now
described by what still uses them rather than by what used to.

Verified by running it, not only by building: a fresh server generates
its CA, prints an `aiapp://enroll` QR with the scheme now passed as a
parameter, covers 127.0.0.1, 10.0.2.2 and wg0's 10.66.0.1 in the leaf,
answers an enrolled token and returns 401 without one, and writes
config.ron in the house rules with every file owner-only. 36 tests pass,
clippy is silent, rustfmt is clean.
This commit is contained in:
iris committed 2026-08-28 17:14:33 -04:00
1 parent a83dbcff6a
commit aa05ff9336
13 files changed
+76 -526

No files matched your search

+18 -4
View File
@@ -26,16 +26,12 @@ dependencies = [
"axum-server",
"base64 0.23.1",
"clap",
"if-addrs",
"qrcode",
"rand",
"rcgen",
"ron",
"rustls",
"serde",
"serde_json",
"sha2",
"subtle",
"tempfile",
"thiserror",
"tokio",
@@ -44,6 +40,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"ureq",
"wg-app-link",
]
[[package]]
@@ -1868,6 +1865,23 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "wg-app-link"
version = "0.1.0"
dependencies = [
"anyhow",
"base64 0.23.1",
"if-addrs",
"qrcode",
"rand",
"rcgen",
"ron",
"serde",
"sha2",
"subtle",
"tracing",
]
[[package]]
name = "windows-link"
version = "0.2.1"
+11 -19
View File
@@ -8,6 +8,11 @@ name = "ai-server"
path = "src/main.rs"
[dependencies]
# The link both this and dev-updater need in order to be reached from a
# phone: wg binding, the pinned CA, QR enrollment, owner-only files, and
# the RON house rules. Extracted from the two copies that had drifted --
# see that repo's README for the evidence and the bug the extraction found.
wg-app-link = { path = "../wg-app-link/server" }
axum = { version = "0.8", features = ["json", "multipart"] }
axum-server = { version = "0.8", features = ["tls-rustls"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time", "process", "io-util", "signal"] }
@@ -17,32 +22,19 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# The config file's format. Not JSON, because this file is written and read
# by hand and RON says a sum type as syntax -- the same choice, and the same
# house rules, as the sibling dev-updater project's config.
# by hand and RON says a sum type as syntax. The two house rules both
# projects write it under live in wg-app-link; this is here for the error
# types the schema's own signatures name.
ron = "0.12.2"
clap = { version = "4", features = ["derive"] }
anyhow = "1"
thiserror = "2"
# Token auth: hash for storage, constant-time compare for verification,
# CSPRNG-backed generation, base64url for the enrollment string.
# Verifying a downloaded model against HuggingFace's published digest.
sha2 = "0.11"
subtle = "2"
# Naming a session directory, and an attachment inside one.
rand = "0.10"
# Decoding the images a phone attaches, and encoding them for a driver.
base64 = "0.23"
# Renders the enrollment QR straight to the terminal; no image output needed.
qrcode = { version = "0.14", default-features = false }
# The wg0-bound listener needs the interface's address; the stdlib has no
# getifaddrs. This is the smallest crate that wraps just that.
if-addrs = "0.15"
# Generates this server's TLS certificates on first start, replacing a
# setup script that shelled out to whatever openssl happened to be
# installed. In process means one place decides the extensions, the file
# modes, and which addresses the leaf covers. x509-parser so the issuer is
# read back from the CA actually on disk: reconstructing it from the same
# parameters would work only as long as nothing ever changed them, and a
# mismatched issuer name yields a chain that fails to validate rather than
# anything that looks wrong at generation time.
rcgen = { version = "0.14", features = ["pem", "x509-parser"] }
# Outbound HTTPS for the usage endpoint. A small blocking client fits an
# every-few-minutes poll better than pulling in reqwest's tower stack;
# rustls-backed like the rest of the TLS here.
+3 -45
View File
@@ -21,9 +21,7 @@ use axum::extract::{ConnectInfo, Request, State};
use axum::http::{StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use base64::Engine;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use wg_app_link::enroll::token_matches;
use crate::session::SessionManager;
@@ -32,35 +30,6 @@ use crate::session::SessionManager;
/// drip rather than a fast one.
const REJECT_DELAY: Duration = Duration::from_millis(300);
/// 256 bits from the OS CSPRNG, base64url. A machine credential carried by
/// a QR code, never typed, so unguessable costs nothing.
pub fn generate_token() -> String {
use rand::Rng;
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
/// What `config.ron` stores instead of the token: hex SHA-256. A plain
/// hash is enough for high-entropy random input, and buys that a leaked
/// config doesn't leak the credential.
pub fn token_hash_hex(token: &str) -> String {
Sha256::digest(token.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
/// Hash-then-constant-time-compare against every enrolled hash. The fold
/// visits every entry regardless of match so the timing doesn't say which
/// entry (if any) matched.
fn token_matches(presented: &str, stored_hashes: &[String]) -> bool {
let presented = token_hash_hex(presented);
stored_hashes.iter().fold(false, |matched, stored| {
matched | bool::from(presented.as_bytes().ct_eq(stored.as_bytes()))
})
}
pub async fn require_token(
State(manager): State<Arc<SessionManager>>,
request: Request,
@@ -104,6 +73,8 @@ mod tests {
use axum::routing::get;
use tower::ServiceExt;
use wg_app_link::enroll::{generate_token, token_hash_hex};
use crate::config::TokenEntry;
fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc<SessionManager> {
@@ -139,19 +110,6 @@ mod tests {
builder.body(Body::empty()).expect("request")
}
#[test]
fn hashing_is_stable_and_tokens_verify() {
let token = generate_token();
assert_eq!(token_hash_hex(&token), token_hash_hex(&token));
assert_ne!(token, generate_token(), "tokens must not repeat");
let hashes = vec![token_hash_hex(&token), token_hash_hex("other")];
assert!(token_matches(&token, &hashes));
assert!(token_matches("other", &hashes));
assert!(!token_matches("wrong", &hashes));
assert!(!token_matches(&token, &[]));
}
/// One test rather than separate gating and logging tests,
/// deliberately: tracing caches callsite interest process-wide, so a
/// test that hits the rejection path with no subscriber installed can
-198
View File
@@ -1,198 +0,0 @@
//! The TLS certificates this server presents, generated in process on
//! first start.
//!
//! There used to be a `gen-dev-cert.sh` calling openssl, which meant a
//! setup step to remember, a second place for the "which SANs?" answer to
//! live, and a dependency on whatever openssl was installed. Doing it here
//! means the server can simply ensure its own certificates exist, with the
//! file modes and extensions it wants, and with the address it is actually
//! about to bind already in the leaf.
//!
//! The split that matters is between the two:
//!
//! - The **CA** is generated once and then left alone. The app
//! pins it, so replacing it strands every installed copy -- recovery is
//! a reinstall over the plain-HTTP bootstrap port. It is the one thing
//! here that is a one-way door.
//! - The **leaf** is cheap and reissued on every start, signed by that
//! same unchanged CA. Nothing pins it, so covering a new address is just
//! a restart rather than anything the phone has to be told about.
//!
//! Everything is written owner-only into a directory outside the repo (see
//! `config_home`): the repo is a mount shared with a VM that is not
//! trusted, and a CA private key that VM can read is one it can sign with
//! -- a certificate signed by a pinned CA is accepted without question,
//! which is exactly the attack pinning exists to stop.
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use rcgen::{
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
};
use crate::private;
/// Where the leaf lives, for handing to the TLS listener.
pub struct Certificates {
pub leaf_cert: PathBuf,
pub leaf_key: PathBuf,
/// True when the CA was created just now, i.e. anything already
/// installed pins the wrong one and has to be reinstalled.
pub ca_is_new: bool,
}
/// Ensures `dir` holds a CA and a leaf covering `addresses`, creating what
/// is missing. Safe to call on every start.
pub fn ensure(dir: &Path, addresses: &[IpAddr]) -> Result<Certificates> {
private::create_dir(dir)?;
let ca_cert_path = dir.join("ca.pem");
let ca_key_path = dir.join("ca-key.pem");
let ca_is_new = !ca_cert_path.is_file() || !ca_key_path.is_file();
let (ca_pem, ca_key_pem) = if ca_is_new {
let (pem, key) = generate_ca()?;
private::write_file(&ca_key_path, key.as_bytes())?;
private::write_file(&ca_cert_path, pem.as_bytes())?;
tracing::info!("generated a new CA in {}", dir.display());
(pem, key)
} else {
(
std::fs::read_to_string(&ca_cert_path)
.with_context(|| format!("read {}", ca_cert_path.display()))?,
std::fs::read_to_string(&ca_key_path)
.with_context(|| format!("read {}", ca_key_path.display()))?,
)
};
let (leaf_pem, leaf_key_pem) = generate_leaf(&ca_pem, &ca_key_pem, addresses)?;
let leaf_cert = dir.join("leaf.pem");
let leaf_key = dir.join("leaf-key.pem");
private::write_file(&leaf_key, leaf_key_pem.as_bytes())?;
private::write_file(&leaf_cert, leaf_pem.as_bytes())?;
Ok(Certificates {
leaf_cert,
leaf_key,
ca_is_new,
})
}
fn generate_ca() -> Result<(String, String)> {
let key = KeyPair::generate().context("generate CA key")?;
let mut params = CertificateParams::default();
params
.distinguished_name
.push(DnType::OrganizationName, "ai-app dev");
params
.distinguished_name
.push(DnType::CommonName, "ai-app dev CA");
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
// Explicit, because strict verifiers reject a CA without them -- and
// that rejection surfaces as an opaque handshake failure on a phone.
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let certificate = params.self_signed(&key).context("self-sign CA")?;
Ok((certificate.pem(), key.serialize_pem()))
}
fn generate_leaf(ca_pem: &str, ca_key_pem: &str, addresses: &[IpAddr]) -> Result<(String, String)> {
let ca_key = KeyPair::from_pem(ca_key_pem).context("read CA key")?;
let issuer = Issuer::from_ca_cert_pem(ca_pem, ca_key).context("read CA certificate")?;
let key = KeyPair::generate().context("generate leaf key")?;
let mut params = CertificateParams::default();
params
.distinguished_name
.push(DnType::OrganizationName, "ai-app dev");
params.distinguished_name.push(
DnType::CommonName,
addresses
.first()
.map(|a| a.to_string())
.unwrap_or_else(|| "ai-app".to_string()),
);
params.subject_alt_names = addresses.iter().map(|a| SanType::IpAddress(*a)).collect();
params.is_ca = IsCa::ExplicitNoCa;
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
params.use_authority_key_identifier_extension = true;
let certificate = params.signed_by(&key, &issuer).context("sign leaf")?;
Ok((certificate.pem(), key.serialize_pem()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn addresses() -> Vec<IpAddr> {
vec!["10.66.0.1".parse().unwrap(), "127.0.0.1".parse().unwrap()]
}
#[test]
fn generates_once_then_keeps_the_ca_and_reissues_the_leaf() {
let dir = tempfile::tempdir().expect("tempdir");
let first = ensure(dir.path(), &addresses()).expect("generate");
assert!(first.ca_is_new);
let ca = std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca");
let leaf = std::fs::read_to_string(&first.leaf_cert).expect("leaf");
assert!(ca.starts_with("-----BEGIN CERTIFICATE-----"));
let second = ensure(dir.path(), &addresses()).expect("regenerate");
// The CA is the pinned one: replacing it would strand every
// installed app, so it must survive a restart untouched.
assert!(!second.ca_is_new);
assert_eq!(
ca,
std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca")
);
// The leaf is not pinned, and is reissued so a new address is just
// a restart away.
assert_ne!(
leaf,
std::fs::read_to_string(&second.leaf_cert).expect("leaf")
);
}
#[test]
fn everything_is_owner_only() {
let dir = tempfile::tempdir().expect("tempdir");
let certs = ensure(dir.path(), &addresses()).expect("generate");
assert_eq!(
std::fs::metadata(dir.path())
.expect("dir")
.permissions()
.mode()
& 0o777,
0o700,
);
for file in ["ca.pem", "ca-key.pem", "leaf.pem", "leaf-key.pem"] {
let mode = std::fs::metadata(dir.path().join(file))
.expect(file)
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "{file} is not owner-only");
}
assert!(certs.leaf_key.is_file());
}
/// The pair has to be loadable by the TLS stack that will actually
/// serve it -- a "file exists" check wouldn't catch a key that doesn't
/// match its certificate, which fails at the first handshake instead.
#[tokio::test]
async fn the_leaf_loads_into_the_real_tls_config() {
// main() installs this; tests don't run main. Both rustls crypto
// providers are in the graph (ureq brings ring, axum-server
// aws-lc-rs), so rustls refuses to pick one on its own. Ignoring
// the result because another test may have installed it first.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let dir = tempfile::tempdir().expect("tempdir");
let certs = ensure(dir.path(), &addresses()).expect("generate");
axum_server::tls_rustls::RustlsConfig::from_pem_file(&certs.leaf_cert, &certs.leaf_key)
.await
.expect("the generated leaf and key should load as a TLS identity");
}
}
+4 -60
View File
@@ -7,9 +7,10 @@
//! funnels through `SessionManager` (the registry pattern), so in-memory
//! and on-disk state can't come apart.
//!
//! The file is RON, in the shape the [`mod@format`] module describes -- the
//! The file is RON, in the shape [`wg_app_link::format`] describes -- the
//! same format, and the same two house rules, as the sibling dev-updater
//! project's config, because both are written and read by hand.
//! project's config, because both are written and read by hand, and both
//! now read and write them through the one module.
//!
//! Transcripts do NOT live here -- each session's events are an append-only
//! JSONL file in its own directory (see `session::transcript`); this file
@@ -21,64 +22,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::private;
/// Reading and writing the RON this file is in.
///
/// Two things are house rules rather than plain RON, and they are here
/// together because they are inverses of each other -- change one and the
/// other stops round-tripping.
///
/// **No outer parentheses.** A file *is* the body of the struct, so nothing
/// in it is indented for the sake of a wrapper. RON has no implicit
/// top-level struct (`de/mod.rs` requires the `(`), so [`format::parse`]
/// adds it and [`format::render`] takes it back off. The opening paren is
/// not followed by a newline, so a parse error's line number still points
/// at the real line.
///
/// **`Some` is implicit.** Enabled on the deserializer rather than by a
/// `#![enable(implicit_some)]` header the file would have to carry, and
/// matched on the writing side by `skip_serializing_if` on every optional
/// field so nothing writes back a `Some(...)` a person didn't type.
pub(crate) mod format {
use serde::{Serialize, de::DeserializeOwned};
fn options() -> ron::Options {
ron::Options::default().with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME)
}
pub fn parse<T: DeserializeOwned>(text: &str) -> Result<T, ron::error::SpannedError> {
options().from_str(&format!("({text})"))
}
pub fn render<T: Serialize>(value: &T) -> Result<String, ron::Error> {
let pretty = ron::ser::PrettyConfig::new();
let text = options().to_string_pretty(value, pretty)?;
Ok(unwrap_outer(&text))
}
/// Strips the outer `(`/`)` the writer always emits and removes the
/// indent level they cost. Deliberately narrow: it accepts only the
/// exact shape `PrettyConfig` produces, and leaves anything else alone
/// rather than guessing -- a file with stray parentheses is better than
/// one silently mangled. `parse` round-trips either way, since a
/// wrapped body parses the same as an unwrapped one re-wrapped.
fn unwrap_outer(text: &str) -> String {
let Some(body) = text
.strip_prefix("(\n")
.and_then(|rest| rest.strip_suffix("\n)"))
else {
return text.to_string();
};
let mut out: String = body
.lines()
.map(|line| line.strip_prefix(" ").unwrap_or(line))
.collect::<Vec<_>>()
.join("\n");
out.push('\n');
out
}
}
use wg_app_link::{format, private};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
+8 -70
View File
@@ -14,11 +14,9 @@
//! unencrypted by misconfiguration -- even inside the tunnel.
mod auth;
mod certs;
mod config;
mod media;
mod models;
mod private;
mod routes;
mod session;
mod setups;
@@ -33,11 +31,13 @@ use anyhow::{Context, Result};
use clap::Parser;
use tokio::signal::unix::{SignalKind, signal};
use wg_app_link::enroll;
use wg_app_link::netif::{self, WG_INTERFACE};
use config::TokenEntry;
use session::SessionManager;
const DEFAULT_PORT: u16 = 8443;
const WG_INTERFACE: &str = "wg0";
/// `$XDG_CONFIG_HOME/ai-app`, or `~/.config/ai-app`. Holds `config.ron`
/// and `certs/`.
@@ -107,68 +107,6 @@ struct Args {
rotate_token: bool,
}
/// Every address this machine answers on, for the leaf's SANs -- so the
/// certificate covers whatever the phone actually dials without anyone
/// maintaining a hardcoded IP. In production that is the WireGuard
/// address; loopback is included for curl and tests, and 10.0.2.2 is the
/// alias an Android emulator reaches its host by, which is not a real
/// interface anywhere.
fn local_addresses() -> Vec<IpAddr> {
let mut addresses = vec![IpAddr::from([127, 0, 0, 1]), IpAddr::from([10, 0, 2, 2])];
match if_addrs::get_if_addrs() {
Ok(interfaces) => {
for interface in interfaces {
let ip = interface.ip();
if ip.is_ipv4() && !addresses.contains(&ip) {
addresses.push(ip);
}
}
}
// Not fatal: the certificate still covers loopback, which is
// enough to start and to diagnose from the machine itself.
Err(err) => tracing::warn!("couldn't enumerate interfaces for the certificate: {err}"),
}
addresses
}
/// The IPv4 address on the WireGuard interface, or a refusal to start.
/// Failing closed here (rather than falling back to a wider bind) is part
/// of the security posture -- see the module doc comment.
fn wg_address() -> Result<IpAddr> {
let interfaces = if_addrs::get_if_addrs().context("enumerate network interfaces")?;
interfaces
.into_iter()
.find(|iface| iface.name == WG_INTERFACE && iface.ip().is_ipv4())
.map(|iface| iface.ip())
.ok_or_else(|| {
anyhow::anyhow!(
"no IPv4 address on interface {WG_INTERFACE} -- this server binds only to the \
WireGuard tunnel and refuses to fall back to a wider address. Bring the tunnel \
up, or pass --bind <ip> explicitly for development."
)
})
}
/// Prints the one-time enrollment QR: an `aiapp://enroll` URI carrying
/// where to connect and the bearer token. The CA stays embedded in the APK,
/// so this carries no trust material -- photographing the terminal leaks
/// only the token, which is rotatable (`--rotate-token`). Printed to
/// stdout, not the log: it is for the human at the terminal, once.
fn print_enrollment(host: IpAddr, port: u16, token: &str) -> Result<()> {
let uri = format!("aiapp://enroll?host={host}&port={port}&token={token}");
let code = qrcode::QrCode::new(uri.as_bytes()).context("render enrollment QR")?;
let rendered = code
.render::<qrcode::render::unicode::Dense1x2>()
.quiet_zone(true)
.build();
println!("\n{rendered}\n");
println!("Scan with the phone's camera to enroll (or paste into the app's settings):");
println!(" {uri}");
println!("The token is not stored in the clear and won't be shown again;");
println!("a lost phone means `--rotate-token`.\n");
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
// Both rustls crypto providers are in the dependency graph (ureq
@@ -226,7 +164,7 @@ async fn main() -> Result<()> {
// obtainable on a machine whose tunnel isn't up yet. The leaf is
// reissued on every start, so once wg0 exists the next start covers it.
let certs_dir = args.certs.unwrap_or_else(|| config_home().join("certs"));
let certificates = certs::ensure(&certs_dir, &local_addresses())
let certificates = wg_app_link::certs::ensure("ai-app", &certs_dir, &netif::local_addresses())
.with_context(|| format!("failed to prepare certificates in {}", certs_dir.display()))?;
if certificates.ca_is_new {
tracing::warn!(
@@ -244,7 +182,7 @@ async fn main() -> Result<()> {
);
ip
}
None => wg_address()?,
None => netif::wg_address("ai-server")?,
};
// Token bootstrap: first run generates one; --rotate-token replaces
@@ -252,15 +190,15 @@ async fn main() -> Result<()> {
// the QR printed here.
if args.rotate_token || manager.tokens().is_empty() {
let rotating = args.rotate_token && !manager.tokens().is_empty();
let token = auth::generate_token();
let token = enroll::generate_token();
manager.set_tokens(vec![TokenEntry {
name: "phone".to_string(),
sha256: auth::token_hash_hex(&token),
sha256: enroll::token_hash_hex(&token),
}])?;
if rotating {
tracing::info!("rotated the enrolled token; the previous one is now invalid");
}
print_enrollment(bind_ip, args.port, &token)?;
enroll::print_enrollment("aiapp", bind_ip, args.port, &token)?;
}
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(
+3 -3
View File
@@ -33,7 +33,7 @@ use std::sync::{Arc, Mutex};
use anyhow::{Context, Result, bail};
use serde::Serialize;
use crate::private;
use wg_app_link::private;
/// Identifies this client to HuggingFace. They ask for one, and a request
/// without it is more likely to be rate-limited.
@@ -471,8 +471,8 @@ fn sha256_of(path: &Path) -> Result<String> {
}
hasher.update(&buffer[..read]);
}
// Hex the same way auth.rs does, since this sha2 version's output
// type does not implement LowerHex.
// Hex by hand, as wg_app_link::enroll::token_hash_hex also has to,
// since this sha2 version's output type does not implement LowerHex.
Ok(hasher
.finalize()
.iter()
-116
View File
@@ -1,116 +0,0 @@
//! Creating files and directories this server alone can read.
//!
//! Everything the server writes outside the repo goes through here: the
//! config (token hashes, hosts, sessions), the TLS private keys, and the
//! session directories holding whole transcripts. One module owns the
//! modes so "owner-only" is a property that can be checked in one place
//! rather than re-argued at every `create`.
use std::fs::File;
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
use std::path::Path;
use anyhow::{Context, Result};
/// Creates `dir` and its parents, owner-accessible only.
pub fn create_dir(dir: &Path) -> Result<()> {
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
.with_context(|| format!("create {}", dir.display()))?;
// Set explicitly as well: `mode` applies only when the directory is
// created, so one that already existed -- made by hand, or by a
// version that didn't do this -- would otherwise keep whatever
// permissions it had while holding secrets.
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("restrict {}", dir.display()))
}
/// Opens `path` for writing, truncating it, owner-readable only.
///
/// `mode` covers the file this call creates; [`restrict`] covers the one
/// that was already there. Both are needed, and the second is the one that
/// is easy to miss: the leaf certificate's private key is rewritten on
/// every start, so a key that ever existed with loose permissions would
/// keep them for the rest of its life.
pub fn create_file(path: &Path) -> Result<File> {
let file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("write {}", path.display()))?;
restrict(&file, path)?;
Ok(file)
}
/// Narrows an already-open file to owner-only.
///
/// Through the handle rather than the path, deliberately: `set_permissions`
/// on a path re-resolves it, so between opening and chmod-ing something
/// could put a different file -- or a symlink to one -- where this one was,
/// and the mode would land on that instead. The handle cannot be
/// redirected.
fn restrict(file: &File, path: &Path) -> Result<()> {
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("restrict {}", path.display()))
}
/// Writes `contents` to `path`, owner-readable only.
pub fn write_file(path: &Path, contents: &[u8]) -> Result<()> {
use std::io::Write;
create_file(path)?
.write_all(contents)
.with_context(|| format!("write {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// The case `mode` alone does not cover: a file that already exists
/// keeps whatever permissions it had, because `mode` applies only to a
/// file this call creates. `create_dir` three functions up has carried
/// a comment about exactly this since it was written; the file path
/// did not, until dev-updater's session read the module as a unit and
/// noticed the same line was wrong twice.
fn rewriting_a_file_narrows_permissions_it_did_not_set() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("secret");
// As an older version, or a hand-edit, might have left it.
std::fs::write(&path, b"old").expect("plant");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("loosen");
write_file(&path, b"new").expect("rewrite");
let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "rewriting left it at {mode:o}");
}
#[test]
fn a_new_file_is_owner_only_from_the_start() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("fresh");
write_file(&path, b"x").expect("write");
let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn an_existing_directory_is_narrowed_too() {
let dir = tempfile::tempdir().expect("tempdir");
let nested = dir.path().join("state");
std::fs::create_dir(&nested).expect("create");
std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o755)).expect("loosen");
create_dir(&nested).expect("recreate");
let mode = std::fs::metadata(&nested)
.expect("stat")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o700, "left at {mode:o}");
}
}
+1 -1
View File
@@ -341,7 +341,7 @@ impl Translator {
.unwrap_or("png");
let name = format!("{}.{extension}", super::super::random_hex());
let dir = self.session_dir.join("files");
if let Err(err) = crate::private::create_dir(&dir)
if let Err(err) = wg_app_link::private::create_dir(&dir)
.map_err(std::io::Error::other)
.and_then(|()| std::fs::write(dir.join(&name), bytes))
{
+3 -3
View File
@@ -167,7 +167,7 @@ impl LiveSession {
let extension = crate::media::extension_for(content_type).unwrap_or("jpg");
let name = format!("{}.{extension}", random_hex());
let dir = self.dir().join("attachments");
crate::private::create_dir(&dir)?;
wg_app_link::private::create_dir(&dir)?;
std::fs::write(dir.join(&name), bytes)
.with_context(|| format!("write attachment {name}"))?;
Ok(name)
@@ -216,7 +216,7 @@ impl SessionManager {
/// session spawns its event pump).
pub fn new(config_path: PathBuf, data_dir: PathBuf, models_dir: PathBuf) -> Result<Self> {
let config = Config::load(&config_path)?;
crate::private::create_dir(&data_dir)?;
wg_app_link::private::create_dir(&data_dir)?;
let mut live = HashMap::new();
for meta in &config.sessions {
@@ -625,7 +625,7 @@ fn launch(
models_dir: &Path,
) -> Result<Arc<LiveSession>> {
let dir = data_dir.join(&meta.id);
crate::private::create_dir(&dir)?;
wg_app_link::private::create_dir(&dir)?;
let transcript_path = dir.join("transcript.jsonl");
let transcript = Transcript::open(&transcript_path)?;