From ade572973a225ee0cedd9fd96eb33c88d4e47652 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 7 Sep 2026 16:34:12 -0400 Subject: [PATCH] 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 --- android-shell/Cargo.lock | 1 + client-core/Cargo.lock | 1 + client-core/Cargo.toml | 4 + client-core/src/config.rs | 248 ++++++++++++++++++++++++++++++--- iris/Cargo.lock | 1 + iris/desktop-app/src/config.rs | 123 ++-------------- iris/desktop-app/src/main.rs | 51 ++++--- server/src/main.rs | 49 ++++++- wg-app-link | 2 +- 9 files changed, 325 insertions(+), 155 deletions(-) diff --git a/android-shell/Cargo.lock b/android-shell/Cargo.lock index 7629fee..d8b4cdf 100644 --- a/android-shell/Cargo.lock +++ b/android-shell/Cargo.lock @@ -82,6 +82,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" name = "client-core" version = "0.1.0" dependencies = [ + "base64", "event-model", "log", "pulldown-cmark", diff --git a/client-core/Cargo.lock b/client-core/Cargo.lock index 08c65d7..7b60631 100644 --- a/client-core/Cargo.lock +++ b/client-core/Cargo.lock @@ -46,6 +46,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" name = "client-core" version = "0.1.0" dependencies = [ + "base64", "event-model", "log", "pulldown-cmark", diff --git a/client-core/Cargo.toml b/client-core/Cargo.toml index 79ec4b2..a442f16 100644 --- a/client-core/Cargo.toml +++ b/client-core/Cargo.toml @@ -37,6 +37,10 @@ ureq = { version = "3", features = ["json"] } # the same parser at the same version, rather than a hand-written splitter # that would drift from it. pulldown-cmark = "0.13.4" +# The enrollment link's `ca` parameter is base64url of the CA's DER +# (`config::parse_link`). Same version `wg-app-link` already pins for the +# minting half, so a workspace that has both resolves one copy. +base64 = "0.23" # The logging facade only -- `log_ring` implements a `log::Log` backend and # wraps whichever real one the platform installed (`android_logger` on the # phone, `env_logger` on the desktop), which is why neither of those is a diff --git a/client-core/src/config.rs b/client-core/src/config.rs index f525fa1..d54d3ca 100644 --- a/client-core/src/config.rs +++ b/client-core/src/config.rs @@ -6,33 +6,57 @@ //! the same text a phone would scan as a QR, with no second format //! invented for it (RUST.md's E4). //! -//! What this type deliberately does not decide: where it is persisted, and -//! under what file permissions. A phone seals its token in the Android -//! Keystore; a desktop client has its own `$XDG_CONFIG_HOME//` -//! directory and its own file-mode conventions (MACHINE.md: owner-only, -//! never in the repo). Both are caller-specific, so they stay out of this -//! crate per the code rules' "ask for the least you need" -- see -//! `iris/desktop-app/src/config.rs` for the desktop instance. +//! [`EnrollmentStore`] persists one of these as JSON, owner-only, in a +//! directory the caller names -- `$XDG_CONFIG_HOME/ai-app-desktop` for the +//! desktop app, the app-private files directory on Android. **Which** +//! directory is the only part left to the platform: the format, the file +//! mode and the "nothing saved yet is not an error" answer are the same on +//! both, and were written twice before this. +//! +//! JSON rather than the project's usual RON: `wg-app-link`'s RON house +//! rules (`format`) are for configs a person hand-edits, and this file +//! never is one -- only the app itself writes or reads it. +use base64::Engine; use serde::{Deserialize, Serialize}; +use std::io; +use std::path::{Path, PathBuf}; /// One enrolled server: reachable at `https://{host}:{port}`, authenticated -/// with `token` as a bearer header. Does not carry the pinned CA -- that is -/// a public certificate rather than a secret, and where to find it differs -/// by caller (a phone pins the one its APK was built against; a desktop -/// client is told a path). +/// with `token` as a bearer header. +/// +/// `ca_pem` is the trust anchor to pin, when the link carried one (the +/// `ca` parameter, `wg_app_link::enroll::ca_param`). It is optional +/// because an app built on the machine its server runs on pins the CA at +/// build time and needs nothing from the link; one built elsewhere -- the +/// iris Android client is cross-compiled in a VM and run against the +/// host's server -- has no other way to get it. A public certificate +/// rather than a secret, so it costs the link nothing but length. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct EnrolledServer { pub host: String, pub port: u16, pub token: String, + /// `#[serde(default)]` so an enrollment saved before this field + /// existed still loads, as the enrolled server it always was. + #[serde(default)] + pub ca_pem: Option, } impl EnrolledServer { - /// Parses `aiapp://enroll?host=H&port=P&token=T` (query order does not - /// matter; unrecognised keys are ignored). `token` is percent-decoded, - /// since `ui-sandbox.sh` encodes it precisely because a raw token can - /// contain `+`, which turns into a space if left to a naive splitter. + /// Parses `aiapp://enroll?host=H&port=P&token=T[&ca=B]` (query order + /// does not matter; unrecognised keys are ignored). `token` is + /// percent-decoded, since `ui-sandbox.sh` encodes it precisely because + /// a raw token can contain `+`, which turns into a space if left to a + /// naive splitter. + /// + /// `ca` is base64url of the certificate's DER and is rebuilt into PEM + /// here, because that is what every consumer of it wants + /// (`UreqTransport::new`, and the file a person points `curl --cacert` + /// at). A `ca` that does not decode fails the whole link rather than + /// enrolling a server with no trust anchor: the link said which + /// certificate to pin, and quietly not pinning it is the one outcome + /// nothing downstream could notice. pub fn parse_link(link: &str) -> Result { let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| { format!( @@ -44,6 +68,7 @@ impl EnrolledServer { let mut host = None; let mut port = None; let mut token = None; + let mut ca = None; for pair in query.split('&') { let Some((key, value)) = pair.split_once('=') else { continue; @@ -53,6 +78,7 @@ impl EnrolledServer { "host" => host = Some(value), "port" => port = Some(value), "token" => token = Some(value), + "ca" => ca = Some(value), _ => {} } } @@ -63,8 +89,14 @@ impl EnrolledServer { .parse() .map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?; let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?; + let ca_pem = ca.map(|ca| pem_from_link_param(&ca)).transpose()?; - Ok(Self { host, port, token }) + Ok(Self { + host, + port, + token, + ca_pem, + }) } /// Where a `client_core::api::UreqTransport` reaches this server. @@ -73,6 +105,80 @@ impl EnrolledServer { } } +/// The `ca` parameter (base64url of DER, unpadded) as a PEM certificate. +fn pem_from_link_param(ca: &str) -> Result { + let der = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(ca.as_bytes()) + .map_err(|e| format!("the link's 'ca' is not base64url ({e})"))?; + let body = base64::engine::general_purpose::STANDARD.encode(&der); + let mut pem = String::from("-----BEGIN CERTIFICATE-----\n"); + for line in body.as_bytes().chunks(64) { + pem.push_str(std::str::from_utf8(line).expect("base64 is ASCII")); + pem.push('\n'); + } + pem.push_str("-----END CERTIFICATE-----\n"); + Ok(pem) +} + +/// Where one client keeps the enrollment it should not have to be told +/// about a second time. `dir` is the caller's, because that is the only +/// part that differs by platform -- see this module's doc. +pub struct EnrollmentStore { + dir: PathBuf, +} + +impl EnrollmentStore { + pub fn new(dir: impl Into) -> Self { + Self { dir: dir.into() } + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + fn file(&self) -> PathBuf { + self.dir.join("enrollment.json") + } + + /// Writes `server` under `dir`, creating it if needed, and sets the + /// file owner-only -- it carries a bearer token, the same reason + /// `server/`'s own token store is 0600. + pub fn save(&self, server: &EnrolledServer) -> io::Result<()> { + std::fs::create_dir_all(&self.dir)?; + let path = self.file(); + let json = serde_json::to_vec_pretty(server) + .expect("EnrolledServer holds nothing that fails to serialise"); + std::fs::write(&path, json)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) + } + + /// `Ok(None)` when nothing has been enrolled yet, rather than an error + /// -- "not enrolled" is an ordinary first-run state, not a failure + /// (UI_RULES' "a deliberate choice is not a problem to report" applies + /// just as well to a file that simply hasn't been written yet). + pub fn load(&self) -> io::Result> { + let path = self.file(); + match std::fs::read(&path) { + Ok(bytes) => { + let server = serde_json::from_slice(&bytes).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("{} is not a valid enrollment ({e})", path.display()), + ) + })?; + Ok(Some(server)) + } + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } + } +} + fn percent_decode(s: &str) -> String { let bytes = s.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); @@ -108,6 +214,7 @@ mod tests { host: "127.0.0.1".to_string(), port: 8547, token: "abcDEF123".to_string(), + ca_pem: None, } ); assert_eq!(server.base_url(), "https://127.0.0.1:8547"); @@ -141,6 +248,115 @@ mod tests { ); } + /// The CA travels as base64url of the DER and comes back out as the + /// PEM every consumer of it wants -- the same round trip + /// `wg_app_link::enroll::ca_param` mints. + #[test] + fn a_ca_in_the_link_comes_back_as_pem() { + let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f]; + let param = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(der); + let server = + EnrolledServer::parse_link(&format!("aiapp://enroll?host=h&port=1&token=t&ca={param}")) + .unwrap(); + let pem = server.ca_pem.expect("the link carried a CA"); + assert!(pem.starts_with("-----BEGIN CERTIFICATE-----\n"), "{pem}"); + assert!( + pem.trim_end().ends_with("-----END CERTIFICATE-----"), + "{pem}" + ); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode( + pem.lines() + .filter(|l| !l.starts_with("-----")) + .collect::() + ) + .unwrap(), + der + ); + } + + /// A link with no `ca` is an ordinary link, not a broken one: an app + /// that pins at build time mints and reads exactly these. + #[test] + fn no_ca_parameter_is_none_not_an_error() { + let server = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t").unwrap(); + assert_eq!(server.ca_pem, None); + } + + /// The half that cannot be noticed later: a `ca` that does not decode + /// must fail the link rather than enrolling with nothing pinned. + #[test] + fn a_ca_that_does_not_decode_fails_the_link() { + let err = + EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t&ca=not!base64url") + .unwrap_err(); + assert!(err.contains("ca"), "{err}"); + } + + #[test] + fn a_saved_enrollment_reads_back_the_same() { + let dir = tempfile::tempdir().unwrap(); + let store = EnrollmentStore::new(dir.path()); + let server = EnrolledServer { + host: "127.0.0.1".to_string(), + port: 8547, + token: "tok".to_string(), + ca_pem: Some("-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n".into()), + }; + store.save(&server).unwrap(); + assert_eq!(store.load().unwrap(), Some(server)); + } + + #[test] + fn nothing_saved_yet_is_none_not_an_error() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(EnrollmentStore::new(dir.path()).load().unwrap(), None); + } + + /// An enrollment written before `ca_pem` existed still loads. + #[test] + fn an_enrollment_without_a_ca_still_loads() { + let dir = tempfile::tempdir().unwrap(); + let store = EnrollmentStore::new(dir.path()); + std::fs::create_dir_all(dir.path()).unwrap(); + std::fs::write( + dir.path().join("enrollment.json"), + br#"{"host":"h","port":1,"token":"t"}"#, + ) + .unwrap(); + assert_eq!(store.load().unwrap().unwrap().ca_pem, None); + } + + #[test] + #[cfg(unix)] + fn the_saved_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let store = EnrollmentStore::new(dir.path()); + store + .save(&EnrolledServer { + host: "h".to_string(), + port: 1, + token: "t".to_string(), + ca_pem: None, + }) + .unwrap(); + let mode = std::fs::metadata(dir.path().join("enrollment.json")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[test] + fn a_corrupt_file_is_named_in_the_error() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("enrollment.json"), b"not json").unwrap(); + let err = EnrollmentStore::new(dir.path()).load().unwrap_err(); + assert!(err.to_string().contains("enrollment.json")); + } + #[test] fn a_non_numeric_port_is_named_in_the_error() { let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err(); diff --git a/iris/Cargo.lock b/iris/Cargo.lock index 0981c68..4eb9490 100644 --- a/iris/Cargo.lock +++ b/iris/Cargo.lock @@ -720,6 +720,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" name = "client-core" version = "0.1.0" dependencies = [ + "base64", "event-model", "log", "pulldown-cmark", diff --git a/iris/desktop-app/src/config.rs b/iris/desktop-app/src/config.rs index 5a1228f..d5e3f20 100644 --- a/iris/desktop-app/src/config.rs +++ b/iris/desktop-app/src/config.rs @@ -1,19 +1,13 @@ -//! Where the desktop app keeps the enrollment it should not have to be -//! told about a second time: `client_core::config::EnrolledServer`, -//! persisted at `$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json`, -//! owner-only (0600) -- MACHINE.md's rule for anything holding a bearer -//! token, and the reason `client_core::config`'s own doc comment leaves -//! persistence and file mode to the caller. +//! Where the desktop app keeps its enrollment: `client_core::config`'s +//! [`EnrollmentStore`] pointed at `$XDG_CONFIG_HOME/ai-app-desktop`. //! -//! JSON rather than the project's usual RON: `wg-app-link`'s RON house -//! rules (`format`) are for configs a person hand-edits, and this file -//! never is one -- only this program ever writes or reads it, and -//! `serde_json` is already in the dependency graph through `client-core`, -//! so nothing new is added to reach for it. +//! Only the directory is this app's -- the file's name, its JSON, and its +//! owner-only mode (MACHINE.md's rule for anything holding a bearer token) +//! are the store's, shared with the Android client so the two cannot come +//! to disagree about them. -use client_core::config::EnrolledServer; -use std::io; -use std::path::{Path, PathBuf}; +use client_core::config::EnrollmentStore; +use std::path::PathBuf; /// `$XDG_CONFIG_HOME/ai-app-desktop`, falling back to `~/.config` the way /// the XDG basedir spec says to when the variable is unset -- the same @@ -32,103 +26,6 @@ pub fn config_dir() -> PathBuf { base.join("ai-app-desktop") } -fn enrollment_file(dir: &Path) -> PathBuf { - dir.join("enrollment.json") -} - -/// Persists `server` under `dir` (`config_dir()` for real use; a tempdir in -/// the tests below), creating it if needed, and sets the file owner-only -- -/// it carries a bearer token, the same reason `server/`'s own token store -/// is 0600. -pub fn save_enrollment_in(dir: &Path, server: &EnrolledServer) -> io::Result<()> { - std::fs::create_dir_all(dir)?; - let path = enrollment_file(dir); - let json = serde_json::to_vec_pretty(server) - .expect("EnrolledServer holds nothing that fails to serialise"); - std::fs::write(&path, json)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; - } - Ok(()) -} - -/// `Ok(None)` when nothing has been enrolled yet, rather than an error -- -/// "not enrolled" is an ordinary first-run state, not a failure (UI_RULES' -/// "a deliberate choice is not a problem to report" applies just as well -/// to a file that simply hasn't been written yet). -pub fn load_enrollment_in(dir: &Path) -> io::Result> { - let path = enrollment_file(dir); - match std::fs::read(&path) { - Ok(bytes) => { - let server = serde_json::from_slice(&bytes).map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("{} is not a valid enrollment ({e})", path.display()), - ) - })?; - Ok(Some(server)) - } - Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e), - } -} - -pub fn save_enrollment(server: &EnrolledServer) -> io::Result<()> { - save_enrollment_in(&config_dir(), server) -} - -pub fn load_enrollment() -> io::Result> { - load_enrollment_in(&config_dir()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_saved_enrollment_reads_back_the_same() { - let dir = tempfile::tempdir().unwrap(); - let server = EnrolledServer { - host: "127.0.0.1".to_string(), - port: 8547, - token: "tok".to_string(), - }; - save_enrollment_in(dir.path(), &server).unwrap(); - let read_back = load_enrollment_in(dir.path()).unwrap(); - assert_eq!(read_back, Some(server)); - } - - #[test] - fn nothing_saved_yet_is_none_not_an_error() { - let dir = tempfile::tempdir().unwrap(); - assert_eq!(load_enrollment_in(dir.path()).unwrap(), None); - } - - #[test] - #[cfg(unix)] - fn the_saved_file_is_owner_only() { - use std::os::unix::fs::PermissionsExt; - let dir = tempfile::tempdir().unwrap(); - let server = EnrolledServer { - host: "h".to_string(), - port: 1, - token: "t".to_string(), - }; - save_enrollment_in(dir.path(), &server).unwrap(); - let mode = std::fs::metadata(enrollment_file(dir.path())) - .unwrap() - .permissions() - .mode(); - assert_eq!(mode & 0o777, 0o600); - } - - #[test] - fn a_corrupt_file_is_named_in_the_error() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(enrollment_file(dir.path()), b"not json").unwrap(); - let err = load_enrollment_in(dir.path()).unwrap_err(); - assert!(err.to_string().contains("enrollment.json")); - } +pub fn store() -> EnrollmentStore { + EnrollmentStore::new(config_dir()) } diff --git a/iris/desktop-app/src/main.rs b/iris/desktop-app/src/main.rs index a9c9cc9..2208274 100644 --- a/iris/desktop-app/src/main.rs +++ b/iris/desktop-app/src/main.rs @@ -5,18 +5,20 @@ //! //! Usage: //! -//! desktop-app --ca /path/to/ca.pem --link 'aiapp://enroll?host=H&port=P&token=T' -//! desktop-app --ca /path/to/ca.pem # after the first run above +//! desktop-app --link 'aiapp://enroll?host=H&port=P&token=T&ca=B' +//! desktop-app # after the first run above +//! desktop-app --ca /path/to/ca.pem # a link that carries no CA //! //! `--link` is the same text `app/ui-sandbox.sh`'s banner prints and a //! phone would scan as a QR (DECISIONS.md, 2026-09-05) -- pasted rather //! than scanned, since a desktop has no camera to assume. It is parsed and -//! saved to `config::save_enrollment` once; later runs read it back and -//! `--link` is only needed again to enrol against a different server. The -//! CA is never persisted -- it is a public certificate whose path a -//! caller is expected to already know (`AGENTS.md`'s "prefer exercising -//! the server directly": the same `certs/ca.pem` a `curl --cacert` call -//! uses). +//! saved once; later runs read it back and `--link` is only needed again +//! to enrol against a different server. +//! +//! The CA comes with the link (`wg_app_link::enroll::ca_param`, which +//! `ai-server` now always includes) and is saved with it. `--ca` is the +//! override for a link that carries none, and names the same +//! `certs/ca.pem` a `curl --cacert` call uses. mod app; mod config; @@ -24,7 +26,7 @@ mod config; use client_core::config::EnrolledServer; struct Args { - ca_path: std::path::PathBuf, + ca_path: Option, link: Option, } @@ -43,13 +45,7 @@ fn parse_args() -> Result { other => return Err(format!("unrecognised argument '{other}'")), } } - Ok(Args { - ca_path: ca_path.ok_or( - "--ca PATH is required (the pinned CA's certificate, e.g. \ - ~/.config/ai-app/certs/ca.pem)", - )?, - link, - }) + Ok(Args { ca_path, link }) } /// What `app.rs`'s `Client::new` needs to talk to the server: the enrolled @@ -60,14 +56,17 @@ fn parse_args() -> Result { /// other way (`DefaultApp::run()` takes no payload). fn load_startup_config() -> Result<(EnrolledServer, Vec), String> { let args = parse_args()?; + let store = config::store(); let server = match args.link { Some(link) => { let server = EnrolledServer::parse_link(&link)?; - config::save_enrollment(&server) + store + .save(&server) .map_err(|e| format!("couldn't save the enrollment: {e}"))?; server } - None => config::load_enrollment() + None => store + .load() .map_err(|e| format!("couldn't read the saved enrollment: {e}"))? .ok_or_else(|| { format!( @@ -77,8 +76,20 @@ fn load_startup_config() -> Result<(EnrolledServer, Vec), String> { ) })?, }; - let ca_pem = std::fs::read(&args.ca_path) - .map_err(|e| format!("couldn't read the CA at {}: {e}", args.ca_path.display()))?; + // `--ca` wins where it was given, so a caller can point a link's + // server at a certificate it did not carry -- and so the flag still + // means what it did before the link could carry one. + let ca_pem = match (&args.ca_path, &server.ca_pem) { + (Some(path), _) => std::fs::read(path) + .map_err(|e| format!("couldn't read the CA at {}: {e}", path.display()))?, + (None, Some(pem)) => pem.clone().into_bytes(), + (None, None) => { + return Err("this enrollment carries no CA -- pass --ca PATH (e.g. \ + ~/.config/ai-app/certs/ca.pem), or enrol again with a link \ + minted by a server that includes one" + .to_string()); + } + }; Ok((server, ca_pem)) } diff --git a/server/src/main.rs b/server/src/main.rs index 3571b7b..e7c6e4c 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -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 { + 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 { + 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( diff --git a/wg-app-link b/wg-app-link index d35c880..22ec18f 160000 --- a/wg-app-link +++ b/wg-app-link @@ -1 +1 @@ -Subproject commit d35c880753b4a7ece0d542f0d49f1fbd140108c1 +Subproject commit 22ec18fcf27789f504f71e4c8ff449c554ee076d