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:
1 parent
9b27e858b5
commit
ade572973a
9 files changed
+325
-155
No files matched your search
Generated
+1
@@ -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",
|
||||||
|
|||||||
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Generated
+1
@@ -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
@@ -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"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
+1
-1
Submodule wg-app-link updated: d35c880753...22ec18fcf2.
Reference in new issue
Block a user