An APK built in this VM pins this VM's CA, so it can never reach the host's ai-server -- which is exactly the iris Android client's situation (cross-compiled here, run against the host). So ai-server now puts the CA in every enrollment link it mints, base64url of its DER under the 'ca' parameter wg-app-link just learned to add, and client_core parses it back out as PEM. Nothing has to be built on the machine it talks to. Refused rather than ignored where 'ca' does not decode: a link that named a certificate and then pinned nothing is the one outcome nothing downstream could notice. EnrollmentStore moves out of desktop-app into client_core::config, since the Android client needs the same file for the same reason and only the directory differs by platform (AGENTS.md's sharing rule). desktop-app's --ca becomes the override for a link that carried none. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
369 lines
13 KiB
Rust
369 lines
13 KiB
Rust
//! What a Rust client needs to reach one enrolled server: host, port and
|
|
//! bearer token. Mirrors the shape `ServerConfig.kt`/`Api.kt`'s
|
|
//! `handleEnrollment` parses out of an `aiapp://enroll?host=H&port=P&token=T`
|
|
//! deep link -- the exact link `wg-app-link`'s `enroll` module mints and
|
|
//! `app/ui-sandbox.sh`'s banner prints, so any Rust client can enrol from
|
|
//! the same text a phone would scan as a QR, with no second format
|
|
//! invented for it (RUST.md's E4).
|
|
//!
|
|
//! [`EnrollmentStore`] persists one of these as JSON, owner-only, in a
|
|
//! directory the caller names -- `$XDG_CONFIG_HOME/ai-app-desktop` for the
|
|
//! desktop app, the app-private files directory on Android. **Which**
|
|
//! directory is the only part left to the platform: the format, the file
|
|
//! mode and the "nothing saved yet is not an error" answer are the same on
|
|
//! both, and were written twice before this.
|
|
//!
|
|
//! JSON rather than the project's usual RON: `wg-app-link`'s RON house
|
|
//! rules (`format`) are for configs a person hand-edits, and this file
|
|
//! never is one -- only the app itself writes or reads it.
|
|
|
|
use base64::Engine;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::io;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated
|
|
/// with `token` as a bearer header.
|
|
///
|
|
/// `ca_pem` is the trust anchor to pin, when the link carried one (the
|
|
/// `ca` parameter, `wg_app_link::enroll::ca_param`). It is optional
|
|
/// because an app built on the machine its server runs on pins the CA at
|
|
/// build time and needs nothing from the link; one built elsewhere -- the
|
|
/// iris Android client is cross-compiled in a VM and run against the
|
|
/// host's server -- has no other way to get it. A public certificate
|
|
/// rather than a secret, so it costs the link nothing but length.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct EnrolledServer {
|
|
pub host: String,
|
|
pub port: u16,
|
|
pub token: String,
|
|
/// `#[serde(default)]` so an enrollment saved before this field
|
|
/// existed still loads, as the enrolled server it always was.
|
|
#[serde(default)]
|
|
pub ca_pem: Option<String>,
|
|
}
|
|
|
|
impl EnrolledServer {
|
|
/// Parses `aiapp://enroll?host=H&port=P&token=T[&ca=B]` (query order
|
|
/// does not matter; unrecognised keys are ignored). `token` is
|
|
/// percent-decoded, since `ui-sandbox.sh` encodes it precisely because
|
|
/// a raw token can contain `+`, which turns into a space if left to a
|
|
/// naive splitter.
|
|
///
|
|
/// `ca` is base64url of the certificate's DER and is rebuilt into PEM
|
|
/// here, because that is what every consumer of it wants
|
|
/// (`UreqTransport::new`, and the file a person points `curl --cacert`
|
|
/// at). A `ca` that does not decode fails the whole link rather than
|
|
/// enrolling a server with no trust anchor: the link said which
|
|
/// certificate to pin, and quietly not pinning it is the one outcome
|
|
/// nothing downstream could notice.
|
|
pub fn parse_link(link: &str) -> Result<Self, String> {
|
|
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
|
|
format!(
|
|
"'{link}' has no query string (expected \
|
|
aiapp://enroll?host=...&port=...&token=...)"
|
|
)
|
|
})?;
|
|
|
|
let mut host = None;
|
|
let mut port = None;
|
|
let mut token = None;
|
|
let mut ca = None;
|
|
for pair in query.split('&') {
|
|
let Some((key, value)) = pair.split_once('=') else {
|
|
continue;
|
|
};
|
|
let value = percent_decode(value);
|
|
match key {
|
|
"host" => host = Some(value),
|
|
"port" => port = Some(value),
|
|
"token" => token = Some(value),
|
|
"ca" => ca = Some(value),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let host = host.ok_or_else(|| format!("'{link}' is missing 'host'"))?;
|
|
let port_str = port.ok_or_else(|| format!("'{link}' is missing 'port'"))?;
|
|
let port: u16 = port_str
|
|
.parse()
|
|
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?;
|
|
let token = token.ok_or_else(|| format!("'{link}' is missing 'token'"))?;
|
|
let ca_pem = ca.map(|ca| pem_from_link_param(&ca)).transpose()?;
|
|
|
|
Ok(Self {
|
|
host,
|
|
port,
|
|
token,
|
|
ca_pem,
|
|
})
|
|
}
|
|
|
|
/// Where a `client_core::api::UreqTransport` reaches this server.
|
|
pub fn base_url(&self) -> String {
|
|
format!("https://{}:{}", self.host, self.port)
|
|
}
|
|
}
|
|
|
|
/// The `ca` parameter (base64url of DER, unpadded) as a PEM certificate.
|
|
fn pem_from_link_param(ca: &str) -> Result<String, String> {
|
|
let der = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
|
.decode(ca.as_bytes())
|
|
.map_err(|e| format!("the link's 'ca' is not base64url ({e})"))?;
|
|
let body = base64::engine::general_purpose::STANDARD.encode(&der);
|
|
let mut pem = String::from("-----BEGIN CERTIFICATE-----\n");
|
|
for line in body.as_bytes().chunks(64) {
|
|
pem.push_str(std::str::from_utf8(line).expect("base64 is ASCII"));
|
|
pem.push('\n');
|
|
}
|
|
pem.push_str("-----END CERTIFICATE-----\n");
|
|
Ok(pem)
|
|
}
|
|
|
|
/// Where one client keeps the enrollment it should not have to be told
|
|
/// about a second time. `dir` is the caller's, because that is the only
|
|
/// part that differs by platform -- see this module's doc.
|
|
pub struct EnrollmentStore {
|
|
dir: PathBuf,
|
|
}
|
|
|
|
impl EnrollmentStore {
|
|
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
|
Self { dir: dir.into() }
|
|
}
|
|
|
|
pub fn dir(&self) -> &Path {
|
|
&self.dir
|
|
}
|
|
|
|
fn file(&self) -> PathBuf {
|
|
self.dir.join("enrollment.json")
|
|
}
|
|
|
|
/// Writes `server` under `dir`, creating it if needed, and sets the
|
|
/// file owner-only -- it carries a bearer token, the same reason
|
|
/// `server/`'s own token store is 0600.
|
|
pub fn save(&self, server: &EnrolledServer) -> io::Result<()> {
|
|
std::fs::create_dir_all(&self.dir)?;
|
|
let path = self.file();
|
|
let json = serde_json::to_vec_pretty(server)
|
|
.expect("EnrolledServer holds nothing that fails to serialise");
|
|
std::fs::write(&path, json)?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `Ok(None)` when nothing has been enrolled yet, rather than an error
|
|
/// -- "not enrolled" is an ordinary first-run state, not a failure
|
|
/// (UI_RULES' "a deliberate choice is not a problem to report" applies
|
|
/// just as well to a file that simply hasn't been written yet).
|
|
pub fn load(&self) -> io::Result<Option<EnrolledServer>> {
|
|
let path = self.file();
|
|
match std::fs::read(&path) {
|
|
Ok(bytes) => {
|
|
let server = serde_json::from_slice(&bytes).map_err(|e| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!("{} is not a valid enrollment ({e})", path.display()),
|
|
)
|
|
})?;
|
|
Ok(Some(server))
|
|
}
|
|
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn percent_decode(s: &str) -> String {
|
|
let bytes = s.as_bytes();
|
|
let mut out = Vec::with_capacity(bytes.len());
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
if bytes[i] == b'%'
|
|
&& i + 2 < bytes.len()
|
|
&& let Ok(byte) =
|
|
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
|
|
{
|
|
out.push(byte);
|
|
i += 3;
|
|
continue;
|
|
}
|
|
out.push(bytes[i]);
|
|
i += 1;
|
|
}
|
|
String::from_utf8_lossy(&out).into_owned()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_host_port_and_token() {
|
|
let server =
|
|
EnrolledServer::parse_link("aiapp://enroll?host=127.0.0.1&port=8547&token=abcDEF123")
|
|
.unwrap();
|
|
assert_eq!(
|
|
server,
|
|
EnrolledServer {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 8547,
|
|
token: "abcDEF123".to_string(),
|
|
ca_pem: None,
|
|
}
|
|
);
|
|
assert_eq!(server.base_url(), "https://127.0.0.1:8547");
|
|
}
|
|
|
|
#[test]
|
|
fn field_order_does_not_matter() {
|
|
let server =
|
|
EnrolledServer::parse_link("aiapp://enroll?token=tok&port=443&host=example.com")
|
|
.unwrap();
|
|
assert_eq!(server.host, "example.com");
|
|
assert_eq!(server.port, 443);
|
|
assert_eq!(server.token, "tok");
|
|
}
|
|
|
|
#[test]
|
|
fn a_percent_encoded_token_is_decoded() {
|
|
// ui-sandbox.sh's own reason for encoding: a raw '+' would
|
|
// otherwise arrive as a space.
|
|
let server =
|
|
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=a%2Bb%2Fc").unwrap();
|
|
assert_eq!(server.token, "a+b/c");
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_field_is_named_in_the_error() {
|
|
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1").unwrap_err();
|
|
assert!(
|
|
err.contains("token"),
|
|
"error should name the missing field: {err}"
|
|
);
|
|
}
|
|
|
|
/// The CA travels as base64url of the DER and comes back out as the
|
|
/// PEM every consumer of it wants -- the same round trip
|
|
/// `wg_app_link::enroll::ca_param` mints.
|
|
#[test]
|
|
fn a_ca_in_the_link_comes_back_as_pem() {
|
|
let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f];
|
|
let param = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(der);
|
|
let server =
|
|
EnrolledServer::parse_link(&format!("aiapp://enroll?host=h&port=1&token=t&ca={param}"))
|
|
.unwrap();
|
|
let pem = server.ca_pem.expect("the link carried a CA");
|
|
assert!(pem.starts_with("-----BEGIN CERTIFICATE-----\n"), "{pem}");
|
|
assert!(
|
|
pem.trim_end().ends_with("-----END CERTIFICATE-----"),
|
|
"{pem}"
|
|
);
|
|
assert_eq!(
|
|
base64::engine::general_purpose::STANDARD
|
|
.decode(
|
|
pem.lines()
|
|
.filter(|l| !l.starts_with("-----"))
|
|
.collect::<String>()
|
|
)
|
|
.unwrap(),
|
|
der
|
|
);
|
|
}
|
|
|
|
/// A link with no `ca` is an ordinary link, not a broken one: an app
|
|
/// that pins at build time mints and reads exactly these.
|
|
#[test]
|
|
fn no_ca_parameter_is_none_not_an_error() {
|
|
let server = EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t").unwrap();
|
|
assert_eq!(server.ca_pem, None);
|
|
}
|
|
|
|
/// The half that cannot be noticed later: a `ca` that does not decode
|
|
/// must fail the link rather than enrolling with nothing pinned.
|
|
#[test]
|
|
fn a_ca_that_does_not_decode_fails_the_link() {
|
|
let err =
|
|
EnrolledServer::parse_link("aiapp://enroll?host=h&port=1&token=t&ca=not!base64url")
|
|
.unwrap_err();
|
|
assert!(err.contains("ca"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_saved_enrollment_reads_back_the_same() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let store = EnrollmentStore::new(dir.path());
|
|
let server = EnrolledServer {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 8547,
|
|
token: "tok".to_string(),
|
|
ca_pem: Some("-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n".into()),
|
|
};
|
|
store.save(&server).unwrap();
|
|
assert_eq!(store.load().unwrap(), Some(server));
|
|
}
|
|
|
|
#[test]
|
|
fn nothing_saved_yet_is_none_not_an_error() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
assert_eq!(EnrollmentStore::new(dir.path()).load().unwrap(), None);
|
|
}
|
|
|
|
/// An enrollment written before `ca_pem` existed still loads.
|
|
#[test]
|
|
fn an_enrollment_without_a_ca_still_loads() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let store = EnrollmentStore::new(dir.path());
|
|
std::fs::create_dir_all(dir.path()).unwrap();
|
|
std::fs::write(
|
|
dir.path().join("enrollment.json"),
|
|
br#"{"host":"h","port":1,"token":"t"}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(store.load().unwrap().unwrap().ca_pem, None);
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(unix)]
|
|
fn the_saved_file_is_owner_only() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let store = EnrollmentStore::new(dir.path());
|
|
store
|
|
.save(&EnrolledServer {
|
|
host: "h".to_string(),
|
|
port: 1,
|
|
token: "t".to_string(),
|
|
ca_pem: None,
|
|
})
|
|
.unwrap();
|
|
let mode = std::fs::metadata(dir.path().join("enrollment.json"))
|
|
.unwrap()
|
|
.permissions()
|
|
.mode();
|
|
assert_eq!(mode & 0o777, 0o600);
|
|
}
|
|
|
|
#[test]
|
|
fn a_corrupt_file_is_named_in_the_error() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::write(dir.path().join("enrollment.json"), b"not json").unwrap();
|
|
let err = EnrollmentStore::new(dir.path()).load().unwrap_err();
|
|
assert!(err.to_string().contains("enrollment.json"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_non_numeric_port_is_named_in_the_error() {
|
|
let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err();
|
|
assert!(
|
|
err.contains("port"),
|
|
"error should name the offending field: {err}"
|
|
);
|
|
}
|
|
}
|