Compare commits

..
3 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
iris f95bc77f7b wg-app-link: the WireGuard-and-pinned-TLS half both apps needed
The Rust crate and the Android half of one arrangement: a server that binds
the tunnel interface and nothing else, certificates it generates and keeps
outside any shared checkout, enrolment that carries a token and the CA to a
phone, and a client that trusts exactly that certificate and no other.

Extracted because ai-app and dev-updater had written all of it twice and the
two copies had already drifted -- one of them carried a bug the other did
not. History before this point was squashed away; it was a running record of
that extraction and of a personal machine's addresses, and neither is worth
keeping in a public repository.
2026-08-31 20:27:27 -04:00
4 changed files with 254 additions and 14 deletions

No files matched your search

@@ -147,6 +147,18 @@ class ServerStore(private val scheme: String, private val keyAlias: String) {
* the OS drops the traffic, so a blocked app and an unreachable server produce the same connect
* timeout. Without asking explicitly there is no way to tell those apart, and the failure shown
* would blame the server or the tunnel for something neither is doing.
*
* **The permission is still required when the server is reached through WireGuard, and the
* platform's own documentation says otherwise.** Android's Local Network Definition describes a
* local network as one that "utilizes a broadcast-capable network interface, such as Wi-Fi or
* Ethernet, but excludes cellular (WWAN) or VPN connections" — read straight, a tunnelled 10.66.0.1
* is excluded and needs nothing. Measured on a real device on 2026-08-28: it is not excluded, and
* without the permission the traffic is dropped. Do not remove the permission on the strength of
* that paragraph.
*
* **Neither project can catch this in an emulator.** The API 36 images both are tested against do
* not enforce the permission at all, so removing it passes every local test and fails only on a
* phone. That asymmetry is the reason this note is here rather than in a commit message.
*/
fun localNetworkAllowed(context: Context): Boolean =
android.os.Build.VERSION.SDK_INT < 37 ||
+1 -4
View File
@@ -155,10 +155,7 @@ mod tests {
}
fn addresses() -> Vec<IpAddr> {
vec![
"192.168.1.168".parse().unwrap(),
"127.0.0.1".parse().unwrap(),
]
vec!["192.168.1.5".parse().unwrap(), "127.0.0.1".parse().unwrap()]
}
#[test]
+240 -9
View File
@@ -24,7 +24,10 @@
//! middleware, which stays in each project because it is generic over
//! that project's state.
use std::fs;
use std::net::IpAddr;
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result};
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 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
@@ -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
/// 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>()
@@ -97,6 +160,74 @@ pub fn print_enrollment(scheme: &str, host: IpAddr, port: u16, token: &str) -> R
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)]
mod tests {
use super::*;
@@ -135,12 +266,112 @@ 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();
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
View File
@@ -114,7 +114,7 @@ mod tests {
/// and nothing may appear twice, since these become certificate SANs.
#[test]
fn the_certificate_always_covers_loopback_and_the_emulator_alias() {
let found = [ip("192.168.1.168"), ip("10.66.0.1"), ip("192.168.1.168")];
let found = [ip("192.168.1.5"), ip("10.66.0.1"), ip("192.168.1.5")];
let addresses = addresses_among(found);
assert!(addresses.contains(&ip("127.0.0.1")));