enrolment carries the CA, and one store holds it on every platform

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>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-07 16:34:12 -04:00
1 parent 9b27e858b5
commit ade572973a
9 files changed
+325 -155

No files matched your search

+1
View File
@@ -82,6 +82,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
name = "client-core" name = "client-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64",
"event-model", "event-model",
"log", "log",
"pulldown-cmark", "pulldown-cmark",
+1
View File
@@ -46,6 +46,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
name = "client-core" name = "client-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64",
"event-model", "event-model",
"log", "log",
"pulldown-cmark", "pulldown-cmark",
+4
View File
@@ -37,6 +37,10 @@ ureq = { version = "3", features = ["json"] }
# the same parser at the same version, rather than a hand-written splitter # the same parser at the same version, rather than a hand-written splitter
# that would drift from it. # that would drift from it.
pulldown-cmark = "0.13.4" pulldown-cmark = "0.13.4"
# The enrollment link's `ca` parameter is base64url of the CA's DER
# (`config::parse_link`). Same version `wg-app-link` already pins for the
# minting half, so a workspace that has both resolves one copy.
base64 = "0.23"
# The logging facade only -- `log_ring` implements a `log::Log` backend and # The logging facade only -- `log_ring` implements a `log::Log` backend and
# wraps whichever real one the platform installed (`android_logger` on the # wraps whichever real one the platform installed (`android_logger` on the
# phone, `env_logger` on the desktop), which is why neither of those is a # phone, `env_logger` on the desktop), which is why neither of those is a
+232 -16
View File
@@ -6,33 +6,57 @@
//! the same text a phone would scan as a QR, with no second format //! the same text a phone would scan as a QR, with no second format
//! invented for it (RUST.md's E4). //! invented for it (RUST.md's E4).
//! //!
//! What this type deliberately does not decide: where it is persisted, and //! [`EnrollmentStore`] persists one of these as JSON, owner-only, in a
//! under what file permissions. A phone seals its token in the Android //! directory the caller names -- `$XDG_CONFIG_HOME/ai-app-desktop` for the
//! Keystore; a desktop client has its own `$XDG_CONFIG_HOME/<app>/` //! desktop app, the app-private files directory on Android. **Which**
//! directory and its own file-mode conventions (MACHINE.md: owner-only, //! directory is the only part left to the platform: the format, the file
//! never in the repo). Both are caller-specific, so they stay out of this //! mode and the "nothing saved yet is not an error" answer are the same on
//! crate per the code rules' "ask for the least you need" -- see //! both, and were written twice before this.
//! `iris/desktop-app/src/config.rs` for the desktop instance. //!
//! 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 serde::{Deserialize, Serialize};
use std::io;
use std::path::{Path, PathBuf};
/// One enrolled server: reachable at `https://{host}:{port}`, authenticated /// One enrolled server: reachable at `https://{host}:{port}`, authenticated
/// with `token` as a bearer header. Does not carry the pinned CA -- that is /// with `token` as a bearer header.
/// a public certificate rather than a secret, and where to find it differs ///
/// by caller (a phone pins the one its APK was built against; a desktop /// `ca_pem` is the trust anchor to pin, when the link carried one (the
/// client is told a path). /// `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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EnrolledServer { pub struct EnrolledServer {
pub host: String, pub host: String,
pub port: u16, pub port: u16,
pub token: String, 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 { impl EnrolledServer {
/// Parses `aiapp://enroll?host=H&port=P&token=T` (query order does not /// Parses `aiapp://enroll?host=H&port=P&token=T[&ca=B]` (query order
/// matter; unrecognised keys are ignored). `token` is percent-decoded, /// does not matter; unrecognised keys are ignored). `token` is
/// since `ui-sandbox.sh` encodes it precisely because a raw token can /// percent-decoded, since `ui-sandbox.sh` encodes it precisely because
/// contain `+`, which turns into a space if left to a naive splitter. /// 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> { pub fn parse_link(link: &str) -> Result<Self, String> {
let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| { let query = link.split_once('?').map(|(_, q)| q).ok_or_else(|| {
format!( format!(
@@ -44,6 +68,7 @@ impl EnrolledServer {
let mut host = None; let mut host = None;
let mut port = None; let mut port = None;
let mut token = None; let mut token = None;
let mut ca = None;
for pair in query.split('&') { for pair in query.split('&') {
let Some((key, value)) = pair.split_once('=') else { let Some((key, value)) = pair.split_once('=') else {
continue; continue;
@@ -53,6 +78,7 @@ impl EnrolledServer {
"host" => host = Some(value), "host" => host = Some(value),
"port" => port = Some(value), "port" => port = Some(value),
"token" => token = Some(value), "token" => token = Some(value),
"ca" => ca = Some(value),
_ => {} _ => {}
} }
} }
@@ -63,8 +89,14 @@ impl EnrolledServer {
.parse() .parse()
.map_err(|e| format!("'{link}''s port ('{port_str}') is not a number: {e}"))?; .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 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 }) Ok(Self {
host,
port,
token,
ca_pem,
})
} }
/// Where a `client_core::api::UreqTransport` reaches this server. /// Where a `client_core::api::UreqTransport` reaches this server.
@@ -73,6 +105,80 @@ impl EnrolledServer {
} }
} }
/// 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 { fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes(); let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len()); let mut out = Vec::with_capacity(bytes.len());
@@ -108,6 +214,7 @@ mod tests {
host: "127.0.0.1".to_string(), host: "127.0.0.1".to_string(),
port: 8547, port: 8547,
token: "abcDEF123".to_string(), token: "abcDEF123".to_string(),
ca_pem: None,
} }
); );
assert_eq!(server.base_url(), "https://127.0.0.1:8547"); assert_eq!(server.base_url(), "https://127.0.0.1:8547");
@@ -141,6 +248,115 @@ mod tests {
); );
} }
/// 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] #[test]
fn a_non_numeric_port_is_named_in_the_error() { 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(); let err = EnrolledServer::parse_link("aiapp://enroll?host=h&port=x&token=t").unwrap_err();
+1
View File
@@ -720,6 +720,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
name = "client-core" name = "client-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64",
"event-model", "event-model",
"log", "log",
"pulldown-cmark", "pulldown-cmark",
+10 -113
View File
@@ -1,19 +1,13 @@
//! Where the desktop app keeps the enrollment it should not have to be //! Where the desktop app keeps its enrollment: `client_core::config`'s
//! told about a second time: `client_core::config::EnrolledServer`, //! [`EnrollmentStore`] pointed at `$XDG_CONFIG_HOME/ai-app-desktop`.
//! persisted at `$XDG_CONFIG_HOME/ai-app-desktop/enrollment.json`,
//! owner-only (0600) -- MACHINE.md's rule for anything holding a bearer
//! token, and the reason `client_core::config`'s own doc comment leaves
//! persistence and file mode to the caller.
//! //!
//! JSON rather than the project's usual RON: `wg-app-link`'s RON house //! Only the directory is this app's -- the file's name, its JSON, and its
//! rules (`format`) are for configs a person hand-edits, and this file //! owner-only mode (MACHINE.md's rule for anything holding a bearer token)
//! never is one -- only this program ever writes or reads it, and //! are the store's, shared with the Android client so the two cannot come
//! `serde_json` is already in the dependency graph through `client-core`, //! to disagree about them.
//! so nothing new is added to reach for it.
use client_core::config::EnrolledServer; use client_core::config::EnrollmentStore;
use std::io; use std::path::PathBuf;
use std::path::{Path, PathBuf};
/// `$XDG_CONFIG_HOME/ai-app-desktop`, falling back to `~/.config` the way /// `$XDG_CONFIG_HOME/ai-app-desktop`, falling back to `~/.config` the way
/// the XDG basedir spec says to when the variable is unset -- the same /// the XDG basedir spec says to when the variable is unset -- the same
@@ -32,103 +26,6 @@ pub fn config_dir() -> PathBuf {
base.join("ai-app-desktop") base.join("ai-app-desktop")
} }
fn enrollment_file(dir: &Path) -> PathBuf { pub fn store() -> EnrollmentStore {
dir.join("enrollment.json") EnrollmentStore::new(config_dir())
}
/// Persists `server` under `dir` (`config_dir()` for real use; a tempdir in
/// the tests below), 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_enrollment_in(dir: &Path, server: &EnrolledServer) -> io::Result<()> {
std::fs::create_dir_all(dir)?;
let path = enrollment_file(dir);
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_enrollment_in(dir: &Path) -> io::Result<Option<EnrolledServer>> {
let path = enrollment_file(dir);
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),
}
}
pub fn save_enrollment(server: &EnrolledServer) -> io::Result<()> {
save_enrollment_in(&config_dir(), server)
}
pub fn load_enrollment() -> io::Result<Option<EnrolledServer>> {
load_enrollment_in(&config_dir())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_saved_enrollment_reads_back_the_same() {
let dir = tempfile::tempdir().unwrap();
let server = EnrolledServer {
host: "127.0.0.1".to_string(),
port: 8547,
token: "tok".to_string(),
};
save_enrollment_in(dir.path(), &server).unwrap();
let read_back = load_enrollment_in(dir.path()).unwrap();
assert_eq!(read_back, Some(server));
}
#[test]
fn nothing_saved_yet_is_none_not_an_error() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(load_enrollment_in(dir.path()).unwrap(), None);
}
#[test]
#[cfg(unix)]
fn the_saved_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let server = EnrolledServer {
host: "h".to_string(),
port: 1,
token: "t".to_string(),
};
save_enrollment_in(dir.path(), &server).unwrap();
let mode = std::fs::metadata(enrollment_file(dir.path()))
.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(enrollment_file(dir.path()), b"not json").unwrap();
let err = load_enrollment_in(dir.path()).unwrap_err();
assert!(err.to_string().contains("enrollment.json"));
}
} }
+31 -20
View File
@@ -5,18 +5,20 @@
//! //!
//! Usage: //! Usage:
//! //!
//! desktop-app --ca /path/to/ca.pem --link 'aiapp://enroll?host=H&port=P&token=T' //! desktop-app --link 'aiapp://enroll?host=H&port=P&token=T&ca=B'
//! desktop-app --ca /path/to/ca.pem # after the first run above //! desktop-app # after the first run above
//! desktop-app --ca /path/to/ca.pem # a link that carries no CA
//! //!
//! `--link` is the same text `app/ui-sandbox.sh`'s banner prints and a //! `--link` is the same text `app/ui-sandbox.sh`'s banner prints and a
//! phone would scan as a QR (DECISIONS.md, 2026-09-05) -- pasted rather //! phone would scan as a QR (DECISIONS.md, 2026-09-05) -- pasted rather
//! than scanned, since a desktop has no camera to assume. It is parsed and //! than scanned, since a desktop has no camera to assume. It is parsed and
//! saved to `config::save_enrollment` once; later runs read it back and //! saved once; later runs read it back and `--link` is only needed again
//! `--link` is only needed again to enrol against a different server. The //! to enrol against a different server.
//! CA is never persisted -- it is a public certificate whose path a //!
//! caller is expected to already know (`AGENTS.md`'s "prefer exercising //! The CA comes with the link (`wg_app_link::enroll::ca_param`, which
//! the server directly": the same `certs/ca.pem` a `curl --cacert` call //! `ai-server` now always includes) and is saved with it. `--ca` is the
//! uses). //! override for a link that carries none, and names the same
//! `certs/ca.pem` a `curl --cacert` call uses.
mod app; mod app;
mod config; mod config;
@@ -24,7 +26,7 @@ mod config;
use client_core::config::EnrolledServer; use client_core::config::EnrolledServer;
struct Args { struct Args {
ca_path: std::path::PathBuf, ca_path: Option<std::path::PathBuf>,
link: Option<String>, link: Option<String>,
} }
@@ -43,13 +45,7 @@ fn parse_args() -> Result<Args, String> {
other => return Err(format!("unrecognised argument '{other}'")), other => return Err(format!("unrecognised argument '{other}'")),
} }
} }
Ok(Args { Ok(Args { ca_path, link })
ca_path: ca_path.ok_or(
"--ca PATH is required (the pinned CA's certificate, e.g. \
~/.config/ai-app/certs/ca.pem)",
)?,
link,
})
} }
/// What `app.rs`'s `Client::new` needs to talk to the server: the enrolled /// What `app.rs`'s `Client::new` needs to talk to the server: the enrolled
@@ -60,14 +56,17 @@ fn parse_args() -> Result<Args, String> {
/// other way (`DefaultApp::run()` takes no payload). /// other way (`DefaultApp::run()` takes no payload).
fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> { fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
let args = parse_args()?; let args = parse_args()?;
let store = config::store();
let server = match args.link { let server = match args.link {
Some(link) => { Some(link) => {
let server = EnrolledServer::parse_link(&link)?; let server = EnrolledServer::parse_link(&link)?;
config::save_enrollment(&server) store
.save(&server)
.map_err(|e| format!("couldn't save the enrollment: {e}"))?; .map_err(|e| format!("couldn't save the enrollment: {e}"))?;
server server
} }
None => config::load_enrollment() None => store
.load()
.map_err(|e| format!("couldn't read the saved enrollment: {e}"))? .map_err(|e| format!("couldn't read the saved enrollment: {e}"))?
.ok_or_else(|| { .ok_or_else(|| {
format!( format!(
@@ -77,8 +76,20 @@ fn load_startup_config() -> Result<(EnrolledServer, Vec<u8>), String> {
) )
})?, })?,
}; };
let ca_pem = std::fs::read(&args.ca_path) // `--ca` wins where it was given, so a caller can point a link's
.map_err(|e| format!("couldn't read the CA at {}: {e}", args.ca_path.display()))?; // server at a certificate it did not carry -- and so the flag still
// means what it did before the link could carry one.
let ca_pem = match (&args.ca_path, &server.ca_pem) {
(Some(path), _) => std::fs::read(path)
.map_err(|e| format!("couldn't read the CA at {}: {e}", path.display()))?,
(None, Some(pem)) => pem.clone().into_bytes(),
(None, None) => {
return Err("this enrollment carries no CA -- pass --ca PATH (e.g. \
~/.config/ai-app/certs/ca.pem), or enrol again with a link \
minted by a server that includes one"
.to_string());
}
};
Ok((server, ca_pem)) Ok((server, ca_pem))
} }
+44 -5
View File
@@ -120,6 +120,35 @@ struct Args {
throwaway_sessions: bool, throwaway_sessions: bool,
} }
/// Where this run keeps its certificates: `--certs`, else the XDG default.
/// One reader because `--enroll-link` returns before the rest of startup
/// gets there, and a link minted against a different directory's CA than
/// the server presents is a handshake failure with nothing on screen
/// saying why.
fn certs_dir(certs: &Option<std::path::PathBuf>) -> std::path::PathBuf {
certs
.clone()
.unwrap_or_else(|| config_home("ai-app").join("certs"))
}
/// The CA every enrollment link carries (`wg_app_link::enroll::ca_param`),
/// so an app that was not built on this machine can still pin it -- the
/// iris client is cross-compiled in a VM and run against this server.
///
/// `--enroll-link` reads it before the server has been anywhere near
/// `certs::ensure`, so the file may genuinely not exist yet; the message
/// says what makes it exist rather than reporting a bare ENOENT.
fn read_ca(certs_dir: &std::path::Path) -> Result<String> {
let path = certs_dir.join("ca.pem");
std::fs::read_to_string(&path).with_context(|| {
format!(
"no CA certificate at {} -- start ai-server once so it generates one, \
or point --certs at the directory that has it",
path.display()
)
})
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Both rustls crypto providers are in the dependency graph (ureq brings // Both rustls crypto providers are in the dependency graph (ureq brings
@@ -160,7 +189,13 @@ async fn main() -> Result<()> {
)?; )?;
println!( println!(
"{}", "{}",
enroll::enrollment_uri("aiapp", bind_ip, args.port, &token) enroll::enrollment_uri(
"aiapp",
bind_ip,
args.port,
&token,
Some(&read_ca(&certs_dir(&args.certs))?)
)?
); );
return Ok(()); return Ok(());
} }
@@ -216,9 +251,7 @@ async fn main() -> Result<()> {
// Before the interface check below, deliberately: the certificates are also // Before the interface check below, deliberately: the certificates are also
// what the phone app embeds at build time, so they need to be obtainable on // what the phone app embeds at build time, so they need to be obtainable on
// a machine whose tunnel isn't up yet. The leaf is reissued on every start. // a machine whose tunnel isn't up yet. The leaf is reissued on every start.
let certs_dir = args let certs_dir = certs_dir(&args.certs);
.certs
.unwrap_or_else(|| config_home("ai-app").join("certs"));
let certificates = wg_app_link::certs::ensure("ai-app", &certs_dir, &netif::local_addresses()) let certificates = wg_app_link::certs::ensure("ai-app", &certs_dir, &netif::local_addresses())
.with_context(|| format!("failed to prepare certificates in {}", certs_dir.display()))?; .with_context(|| format!("failed to prepare certificates in {}", certs_dir.display()))?;
if certificates.ca_is_new { if certificates.ca_is_new {
@@ -252,7 +285,13 @@ async fn main() -> Result<()> {
if rotating { if rotating {
tracing::info!("rotated the enrolled token; the previous one is now invalid"); tracing::info!("rotated the enrolled token; the previous one is now invalid");
} }
enroll::print_enrollment("aiapp", bind_ip, args.port, &token)?; enroll::print_enrollment(
"aiapp",
bind_ip,
args.port,
&token,
Some(&read_ca(&certs_dir)?),
)?;
} }
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file( let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(