Compare commits
11
Commits
main
..
90a77386dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90a77386dc | ||
|
|
db4552f4e4 | ||
|
|
4de8bff5f2 | ||
|
|
4e423bbfb0 | ||
|
|
73a32cb897 | ||
|
|
841a3a8372 | ||
|
|
592114bfc9 | ||
|
|
d6d802c3c7 | ||
|
|
861e6a329b | ||
|
|
7651d491ac | ||
|
|
995b29f10d |
No files matched your search
+4
-1
@@ -155,7 +155,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn addresses() -> Vec<IpAddr> {
|
fn addresses() -> Vec<IpAddr> {
|
||||||
vec!["192.168.1.5".parse().unwrap(), "127.0.0.1".parse().unwrap()]
|
vec![
|
||||||
|
"192.168.1.168".parse().unwrap(),
|
||||||
|
"127.0.0.1".parse().unwrap(),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+9
-240
@@ -24,10 +24,7 @@
|
|||||||
//! 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;
|
||||||
@@ -71,62 +68,8 @@ 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 {
|
||||||
/// `ca_pem` is the trust anchor, and is optional because the two projects
|
format!("{scheme}://enroll?host={host}&port={port}&token={token}")
|
||||||
/// 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
|
||||||
@@ -136,17 +79,11 @@ fn pem_der(pem: &str) -> Result<Vec<u8>> {
|
|||||||
/// 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 *secret* beyond the token, which is rotatable:
|
/// The QR carries no trust material. The CA is embedded in the app at
|
||||||
/// `ca_pem`, where the caller passes one, is a public certificate, so
|
/// build time, so photographing the terminal leaks only the token, which
|
||||||
/// photographing the terminal still leaks only the token.
|
/// is rotatable.
|
||||||
pub fn print_enrollment(
|
pub fn print_enrollment(scheme: &str, host: IpAddr, port: u16, token: &str) -> Result<()> {
|
||||||
scheme: &str,
|
let uri = enrollment_uri(scheme, host, port, token);
|
||||||
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>()
|
||||||
@@ -160,74 +97,6 @@ pub fn print_enrollment(
|
|||||||
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::*;
|
||||||
@@ -266,112 +135,12 @@ 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(
|
let uri = enrollment_uri("devupdater", "10.66.0.1".parse().unwrap(), 8090, "tok");
|
||||||
"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 =
|
let other = enrollment_uri("aiapp", "10.66.0.1".parse().unwrap(), 8443, "tok");
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+1
-1
@@ -114,7 +114,7 @@ mod tests {
|
|||||||
/// and nothing may appear twice, since these become certificate SANs.
|
/// and nothing may appear twice, since these become certificate SANs.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_certificate_always_covers_loopback_and_the_emulator_alias() {
|
fn the_certificate_always_covers_loopback_and_the_emulator_alias() {
|
||||||
let found = [ip("192.168.1.5"), ip("10.66.0.1"), ip("192.168.1.5")];
|
let found = [ip("192.168.1.168"), ip("10.66.0.1"), ip("192.168.1.168")];
|
||||||
let addresses = addresses_among(found);
|
let addresses = addresses_among(found);
|
||||||
|
|
||||||
assert!(addresses.contains(&ip("127.0.0.1")));
|
assert!(addresses.contains(&ip("127.0.0.1")));
|
||||||
|
|||||||
Reference in new issue
Block a user