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 <noreply@anthropic.com>
This commit is contained in:
1 parent
d35c880753
commit
22ec18fcf2
1 file changed
+117
-9
+117
-9
@@ -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 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
|
/// the right app -- `devupdater`, `aiapp` -- and it is the only part of
|
||||||
/// enrollment that is per-project.
|
/// 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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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
|
/// 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
|
/// at the terminal, once, and a log line is the wrong shape for something
|
||||||
/// that has to be photographed.
|
/// that has to be photographed.
|
||||||
///
|
///
|
||||||
/// The QR carries no trust material. The CA is embedded in the app at
|
/// The QR carries no *secret* beyond the token, which is rotatable:
|
||||||
/// build time, so photographing the terminal leaks only the token, which
|
/// `ca_pem`, where the caller passes one, is a public certificate, so
|
||||||
/// is rotatable.
|
/// photographing the terminal still leaks only the token.
|
||||||
pub fn print_enrollment(scheme: &str, host: IpAddr, port: u16, token: &str) -> Result<()> {
|
pub fn print_enrollment(
|
||||||
let uri = enrollment_uri(scheme, host, port, token);
|
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 code = qrcode::QrCode::new(uri.as_bytes()).context("render enrollment QR")?;
|
||||||
let rendered = code
|
let rendered = code
|
||||||
.render::<qrcode::render::unicode::Dense1x2>()
|
.render::<qrcode::render::unicode::Dense1x2>()
|
||||||
@@ -206,15 +266,63 @@ mod tests {
|
|||||||
/// back -- so the shape is a contract, not a formatting choice.
|
/// back -- so the shape is a contract, not a formatting choice.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_enrollment_uri_carries_scheme_host_port_and_token() {
|
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!(
|
assert_eq!(
|
||||||
uri,
|
uri,
|
||||||
"devupdater://enroll?host=10.66.0.1&port=8090&token=tok"
|
"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?"));
|
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 {
|
fn scratch_dir() -> std::path::PathBuf {
|
||||||
let dir = std::env::temp_dir().join(format!("wg-app-link-enroll-{}", generate_token()));
|
let dir = std::env::temp_dir().join(format!("wg-app-link-enroll-{}", generate_token()));
|
||||||
fs::create_dir_all(&dir).unwrap();
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
|||||||
Reference in new issue
Block a user