enrolment carries the CA, and one store holds it on every platform

An APK built in this VM pins this VM's CA, so it can never reach the
host's ai-server -- which is exactly the iris Android client's situation
(cross-compiled here, run against the host). So ai-server now puts the CA
in every enrollment link it mints, base64url of its DER under the 'ca'
parameter wg-app-link just learned to add, and client_core parses it back
out as PEM. Nothing has to be built on the machine it talks to.

Refused rather than ignored where 'ca' does not decode: a link that named
a certificate and then pinned nothing is the one outcome nothing
downstream could notice.

EnrollmentStore moves out of desktop-app into client_core::config, since
the Android client needs the same file for the same reason and only the
directory differs by platform (AGENTS.md's sharing rule). desktop-app's
--ca becomes the override for a link that carried none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-07 16:34:12 -04:00
1 parent 9b27e858b5
commit ade572973a
9 files changed
+325 -155

No files matched your search

+44 -5
View File
@@ -120,6 +120,35 @@ struct Args {
throwaway_sessions: bool,
}
/// Where this run keeps its certificates: `--certs`, else the XDG default.
/// One reader because `--enroll-link` returns before the rest of startup
/// gets there, and a link minted against a different directory's CA than
/// the server presents is a handshake failure with nothing on screen
/// saying why.
fn certs_dir(certs: &Option<std::path::PathBuf>) -> std::path::PathBuf {
certs
.clone()
.unwrap_or_else(|| config_home("ai-app").join("certs"))
}
/// The CA every enrollment link carries (`wg_app_link::enroll::ca_param`),
/// so an app that was not built on this machine can still pin it -- the
/// iris client is cross-compiled in a VM and run against this server.
///
/// `--enroll-link` reads it before the server has been anywhere near
/// `certs::ensure`, so the file may genuinely not exist yet; the message
/// says what makes it exist rather than reporting a bare ENOENT.
fn read_ca(certs_dir: &std::path::Path) -> Result<String> {
let path = certs_dir.join("ca.pem");
std::fs::read_to_string(&path).with_context(|| {
format!(
"no CA certificate at {} -- start ai-server once so it generates one, \
or point --certs at the directory that has it",
path.display()
)
})
}
#[tokio::main]
async fn main() -> Result<()> {
// Both rustls crypto providers are in the dependency graph (ureq brings
@@ -160,7 +189,13 @@ async fn main() -> Result<()> {
)?;
println!(
"{}",
enroll::enrollment_uri("aiapp", bind_ip, args.port, &token)
enroll::enrollment_uri(
"aiapp",
bind_ip,
args.port,
&token,
Some(&read_ca(&certs_dir(&args.certs))?)
)?
);
return Ok(());
}
@@ -216,9 +251,7 @@ async fn main() -> Result<()> {
// Before the interface check below, deliberately: the certificates are also
// what the phone app embeds at build time, so they need to be obtainable on
// a machine whose tunnel isn't up yet. The leaf is reissued on every start.
let certs_dir = args
.certs
.unwrap_or_else(|| config_home("ai-app").join("certs"));
let certs_dir = certs_dir(&args.certs);
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 {
@@ -252,7 +285,13 @@ async fn main() -> Result<()> {
if rotating {
tracing::info!("rotated the enrolled token; the previous one is now invalid");
}
enroll::print_enrollment("aiapp", bind_ip, args.port, &token)?;
enroll::print_enrollment(
"aiapp",
bind_ip,
args.port,
&token,
Some(&read_ca(&certs_dir)?),
)?;
}
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(