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();