From 22ec18fcf27789f504f71e4c8ff449c554ee076d Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 7 Sep 2026 16:30:10 -0400 Subject: [PATCH] enroll: the link may carry the CA, for an app not built on the server's machine An app that pins the CA at build time has to be built on the machine its server runs on. ai-app's iris client is cross-compiled in a VM and run against the host's ai-server, so it cannot be -- the CA travels with the enrollment link instead, as base64url of its DER under an optional 'ca' parameter. Optional per call rather than always present because it is not free: on ai-app's P-256 CA the link goes from 89 bytes to 652 and print_enrollment's terminal QR from 45 to 93 columns, which a project whose app already pins at build time should not pay. Unrecognised keys are ignored by every reader of this format, so a link carrying it still enrolls an app that predates it. Co-Authored-By: Claude Fable 5.1 --- server/src/enroll.rs | 126 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 117 insertions(+), 9 deletions(-) diff --git a/server/src/enroll.rs b/server/src/enroll.rs index 028aa0c..a9a6163 100644 --- a/server/src/enroll.rs +++ b/server/src/enroll.rs @@ -71,8 +71,62 @@ pub fn token_matches(presented: &str, stored_hashes: &[String]) -> bool { /// The scheme is the caller's because it is what routes the scan back to /// the right app -- `devupdater`, `aiapp` -- and it is the only part of /// enrollment that is per-project. -pub fn enrollment_uri(scheme: &str, host: IpAddr, port: u16, token: &str) -> String { - format!("{scheme}://enroll?host={host}&port={port}&token={token}") +/// +/// `ca_pem` is the trust anchor, and is optional because the two projects +/// answer "where does the app get the CA?" differently. An app built on +/// the machine its server runs on pins the CA at build time and needs +/// nothing here (pass `None`); one built somewhere else -- ai-app's iris +/// client is cross-compiled in a VM and run against the host's server -- +/// cannot, so the CA travels with the link instead. See [`ca_param`] for +/// the encoding and what it costs a QR code. +pub fn enrollment_uri( + scheme: &str, + host: IpAddr, + port: u16, + token: &str, + ca_pem: Option<&str>, +) -> Result { + let mut uri = format!("{scheme}://enroll?host={host}&port={port}&token={token}"); + if let Some(ca_pem) = ca_pem { + uri.push_str("&ca="); + uri.push_str(&ca_param(ca_pem)?); + } + Ok(uri) +} + +/// The `ca` parameter's value for one PEM certificate: its DER, base64url +/// without padding, so it needs no percent-encoding and survives every +/// splitter a link passes through. +/// +/// **The parameter is optional and unrecognised keys are ignored**, so a +/// link carrying it still enrolls an app that predates it -- which is what +/// makes adding it a compatible change to a format three languages parse +/// (this crate mints it; `client_core::config` and `ServerStore.kt` read +/// it). +/// +/// Costs a QR code real estate: measured on ai-app's own P-256 CA, a link +/// goes from 89 bytes to 652 and its terminal QR from 45 to 93 columns. +/// That is why the parameter is the minter's choice per call rather than +/// always present. +pub fn ca_param(ca_pem: &str) -> Result { + let der = pem_der(ca_pem)?; + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(der)) +} + +/// The DER inside a PEM certificate: everything between the BEGIN and END +/// lines, whitespace removed, base64-decoded. +fn pem_der(pem: &str) -> Result> { + const BEGIN: &str = "-----BEGIN CERTIFICATE-----"; + const END: &str = "-----END CERTIFICATE-----"; + let body = pem + .split_once(BEGIN) + .and_then(|(_, rest)| rest.split_once(END)) + .map(|(body, _)| body) + .context("not a PEM certificate (no BEGIN/END CERTIFICATE lines)")?; + let body: String = body.chars().filter(|c| !c.is_whitespace()).collect(); + base64::engine::general_purpose::STANDARD + .decode(body.as_bytes()) + .context("a PEM certificate's body is not base64") } /// Prints the one-time enrollment QR, and the URI under it for a person @@ -82,11 +136,17 @@ pub fn enrollment_uri(scheme: &str, host: IpAddr, port: u16, token: &str) -> Str /// at the terminal, once, and a log line is the wrong shape for something /// that has to be photographed. /// -/// The QR carries no trust material. The CA is embedded in the app at -/// build time, so photographing the terminal leaks only the token, which -/// is rotatable. -pub fn print_enrollment(scheme: &str, host: IpAddr, port: u16, token: &str) -> Result<()> { - let uri = enrollment_uri(scheme, host, port, token); +/// The QR carries no *secret* beyond the token, which is rotatable: +/// `ca_pem`, where the caller passes one, is a public certificate, so +/// photographing the terminal still leaks only the token. +pub fn print_enrollment( + scheme: &str, + host: IpAddr, + port: u16, + token: &str, + ca_pem: Option<&str>, +) -> Result<()> { + let uri = enrollment_uri(scheme, host, port, token, ca_pem)?; let code = qrcode::QrCode::new(uri.as_bytes()).context("render enrollment QR")?; let rendered = code .render::() @@ -206,15 +266,63 @@ mod tests { /// back -- so the shape is a contract, not a formatting choice. #[test] fn the_enrollment_uri_carries_scheme_host_port_and_token() { - let uri = enrollment_uri("devupdater", "10.66.0.1".parse().unwrap(), 8090, "tok"); + let uri = enrollment_uri( + "devupdater", + "10.66.0.1".parse().unwrap(), + 8090, + "tok", + None, + ) + .unwrap(); assert_eq!( uri, "devupdater://enroll?host=10.66.0.1&port=8090&token=tok" ); - let other = enrollment_uri("aiapp", "10.66.0.1".parse().unwrap(), 8443, "tok"); + let other = + enrollment_uri("aiapp", "10.66.0.1".parse().unwrap(), 8443, "tok", None).unwrap(); assert!(other.starts_with("aiapp://enroll?")); } + /// The CA rides as base64url of the DER, appended to the same link -- + /// so a reader that ignores unknown keys sees exactly the link above. + #[test] + fn a_ca_rides_in_the_link_as_url_safe_base64_der() { + let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f]; + let pem = format!( + "-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----\n", + base64::engine::general_purpose::STANDARD.encode(der) + ); + let uri = enrollment_uri( + "aiapp", + "10.66.0.1".parse().unwrap(), + 8443, + "tok", + Some(&pem), + ) + .unwrap(); + let (base, ca) = uri.split_once("&ca=").expect("the ca parameter"); + assert_eq!(base, "aiapp://enroll?host=10.66.0.1&port=8443&token=tok"); + assert_eq!( + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(ca) + .unwrap(), + der, + "the parameter is the certificate's DER, url-safe and unpadded" + ); + assert!( + !ca.contains(['+', '/', '=']), + "nothing in it needs percent-encoding: {ca}" + ); + } + + /// Something that is not a certificate is refused where it is read, + /// rather than minting a link an app can only fail on. + #[test] + fn a_ca_that_is_not_a_certificate_is_named_in_the_error() { + let err = ca_param("hello").unwrap_err().to_string(); + assert!(err.contains("PEM certificate"), "{err}"); + } + fn scratch_dir() -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("wg-app-link-enroll-{}", generate_token())); fs::create_dir_all(&dir).unwrap();