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
@@ -46,6 +46,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
name = "client-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"event-model",
|
||||
"log",
|
||||
"pulldown-cmark",
|
||||
|
||||
@@ -37,6 +37,10 @@ ureq = { version = "3", features = ["json"] }
|
||||
# the same parser at the same version, rather than a hand-written splitter
|
||||
# that would drift from it.
|
||||
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
|
||||
# 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
|
||||
|
||||
+232
-16
@@ -6,33 +6,57 @@
|
||||
//! the same text a phone would scan as a QR, with no second format
|
||||
//! invented for it (RUST.md's E4).
|
||||
//!
|
||||
//! What this type deliberately does not decide: where it is persisted, and
|
||||
//! under what file permissions. A phone seals its token in the Android
|
||||
//! Keystore; a desktop client has its own `$XDG_CONFIG_HOME/<app>/`
|
||||
//! directory and its own file-mode conventions (MACHINE.md: owner-only,
|
||||
//! never in the repo). Both are caller-specific, so they stay out of this
|
||||
//! crate per the code rules' "ask for the least you need" -- see
|
||||
//! `iris/desktop-app/src/config.rs` for the desktop instance.
|
||||
//! [`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. Does not carry the pinned CA -- that is
|
||||
/// 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
|
||||
/// client is told a path).
|
||||
/// 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` (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.
|
||||
/// 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!(
|
||||
@@ -44,6 +68,7 @@ impl EnrolledServer {
|
||||
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;
|
||||
@@ -53,6 +78,7 @@ impl EnrolledServer {
|
||||
"host" => host = Some(value),
|
||||
"port" => port = Some(value),
|
||||
"token" => token = Some(value),
|
||||
"ca" => ca = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -63,8 +89,14 @@ impl EnrolledServer {
|
||||
.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 })
|
||||
Ok(Self {
|
||||
host,
|
||||
port,
|
||||
token,
|
||||
ca_pem,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
@@ -108,6 +214,7 @@ mod tests {
|
||||
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");
|
||||
@@ -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]
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user