Compare commits

..
2 Commits
Author SHA1 Message Date
irisandClaude Fable 5.1 22ec18fcf2 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>
2026-09-07 16:30:10 -04:00
irisandClaude Fable 5.1 d35c880753 enroll: spool a token minted outside the server, adopted on first use
A second process cannot append a token to the config: the server holds
its config in memory and writes it back whole, so the append loses the
race with the next save, silently. spool_pending writes one file per
token, named by the hash, into a private directory; take_pending lets
the running server move it into its own config the first time the phone
presents it, and sweeps anything older than an hour unused. This is what
lets a tool -- Dev Updater -- ask for an enrolment link without being at
the terminal the QR is printed on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 05:41:55 -04:00
2 changed files with 241 additions and 13 deletions

No files matched your search

+1 -4
View File
@@ -155,10 +155,7 @@ mod tests {
} }
fn addresses() -> Vec<IpAddr> { fn addresses() -> Vec<IpAddr> {
vec![ vec!["192.168.1.5".parse().unwrap(), "127.0.0.1".parse().unwrap()]
"192.168.1.5".parse().unwrap(),
"127.0.0.1".parse().unwrap(),
]
} }
#[test] #[test]
+240 -9
View File
@@ -24,7 +24,10 @@
//! middleware, which stays in each project because it is generic over //! middleware, which stays in each project because it is generic over
//! that project's state. //! that project's state.
use std::fs;
use std::net::IpAddr; use std::net::IpAddr;
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use base64::Engine; use base64::Engine;
@@ -68,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
@@ -79,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>()
@@ -97,6 +160,74 @@ pub fn print_enrollment(scheme: &str, host: IpAddr, port: u16, token: &str) -> R
Ok(()) Ok(())
} }
/// How long a spooled enrolment stays valid unused. The link is meant to be
/// opened straight away, by the tool that asked for it; one that was never
/// opened should not stay a valid credential on disk.
pub const PENDING_TTL: Duration = Duration::from_secs(60 * 60);
/// Records a token minted by another process for the running server to
/// adopt on first use -- see [`take_pending`].
///
/// Why a spool rather than writing the config: the server holds its config
/// in memory and writes it back whole, so a second process appending a
/// token to the file loses the race with the next save, silently. Here the
/// other process writes only into `dir` (created private to the user), one
/// file per token, named by the hash and holding the device name; the
/// server owns the config as before and moves the entry across itself.
/// Only the hash touches disk, as with every stored token.
pub fn spool_pending(dir: &Path, name: &str, token: &str) -> Result<()> {
fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
.with_context(|| format!("restrict {}", dir.display()))?;
}
let path = dir.join(token_hash_hex(token));
fs::write(&path, name).with_context(|| format!("write {}", path.display()))?;
Ok(())
}
/// Adopts a spooled token if `presented` is one: returns the device name
/// it was spooled under and removes the entry, so a spooled token is
/// consumed exactly once and belongs to the config from then on. Anything
/// older than [`PENDING_TTL`] is removed rather than honoured.
///
/// A missing directory is the common case -- nothing has ever been
/// spooled -- and answers `None` like an empty one.
pub fn take_pending(dir: &Path, presented: &str) -> Result<Option<String>> {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error).with_context(|| format!("read {}", dir.display())),
};
let wanted = token_hash_hex(presented);
let mut found = None;
for entry in entries {
let entry = entry.with_context(|| format!("read {}", dir.display()))?;
let path = entry.path();
let fresh = entry
.metadata()
.and_then(|meta| meta.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age < PENDING_TTL);
if !fresh {
let _ = fs::remove_file(&path);
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
if bool::from(name.as_bytes().ct_eq(wanted.as_bytes())) {
found = Some(path);
}
}
let Some(path) = found else { return Ok(None) };
let device = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
Ok(Some(device))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -135,12 +266,112 @@ 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 {
let dir = std::env::temp_dir().join(format!("wg-app-link-enroll-{}", generate_token()));
fs::create_dir_all(&dir).unwrap();
dir
}
/// A spooled token is adopted once, under the name it was spooled with,
/// and by nothing but that token.
#[test]
fn a_spooled_token_is_taken_exactly_once() {
let dir = scratch_dir();
let token = generate_token();
spool_pending(&dir, "tablet", &token).unwrap();
assert_eq!(take_pending(&dir, "wrong").unwrap(), None);
assert_eq!(
take_pending(&dir, &token).unwrap().as_deref(),
Some("tablet")
);
assert_eq!(take_pending(&dir, &token).unwrap(), None, "consumed");
assert!(
fs::read_dir(&dir).unwrap().next().is_none(),
"nothing left behind"
);
fs::remove_dir_all(dir).unwrap();
}
/// Nothing spooled -- not even the directory -- is an ordinary miss.
#[test]
fn no_spool_is_a_miss() {
let dir = scratch_dir().join("never-made");
assert_eq!(take_pending(&dir, "anything").unwrap(), None);
}
/// An entry past its age is swept rather than honoured.
#[test]
fn a_stale_entry_is_swept_not_honoured() {
let dir = scratch_dir();
let token = generate_token();
spool_pending(&dir, "old", &token).unwrap();
let path = dir.join(token_hash_hex(&token));
let past = std::time::SystemTime::now() - PENDING_TTL - Duration::from_secs(1);
fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_modified(past)
.unwrap();
assert_eq!(take_pending(&dir, &token).unwrap(), None);
assert!(!path.exists(), "swept");
fs::remove_dir_all(dir).unwrap();
}
} }