Compare commits
1
Commits
d35c880753
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22ec18fcf2 |
+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 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<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
|
||||
@@ -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::<qrcode::render::unicode::Dense1x2>()
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user