From 89aff8d6135bdf7cf7f90fafd5a46eea66cf62e9 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 24 Sep 2026 23:42:36 +0200 Subject: [PATCH] swarm-matrix-ctl: mint the swarm's own appservice registration The swarm gets an appservice identity of its own, separate from each hive's `hyperhive` registration. `swarm-matrix-ctl appservice render` mints its tokens inside the matrix container when they are absent and renders the registration tuwunel loads; `appservice publish` writes its as_token to `swarm/controller/swarm-controller/matrix/appservice-token`, the one kind no hive's policy grants. The homeserver calls move out of swarm-matrix-ctl into swarm-matrix-client, with a `whoami`, so swarm-controller can mint agents' accounts through the same pinned device id instead of a copy of them. --- Cargo.lock | 11 +- Cargo.toml | 2 + swarm-matrix-client/Cargo.toml | 13 + swarm-matrix-client/README.md | 17 ++ .../src/lib.rs | 117 +++++++- swarm-matrix-ctl/Cargo.toml | 4 +- swarm-matrix-ctl/src/appservice.rs | 279 ++++++++++++++++++ swarm-matrix-ctl/src/main.rs | 44 ++- swarm-matrix-ctl/src/mint.rs | 4 +- swarm-secret-client/src/matrix.rs | 55 ++++ 10 files changed, 523 insertions(+), 23 deletions(-) create mode 100644 swarm-matrix-client/Cargo.toml create mode 100644 swarm-matrix-client/README.md rename swarm-matrix-ctl/src/homeserver.rs => swarm-matrix-client/src/lib.rs (70%) create mode 100644 swarm-matrix-ctl/src/appservice.rs diff --git a/Cargo.lock b/Cargo.lock index 097329b3..cf431442 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4935,14 +4935,23 @@ dependencies = [ "swarm-queue-client", ] +[[package]] +name = "swarm-matrix-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "reqwest", + "serde_json", +] + [[package]] name = "swarm-matrix-ctl" version = "0.1.0" dependencies = [ "anyhow", "clap", - "reqwest", "serde_json", + "swarm-matrix-client", "swarm-secret-client", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index edae4e54..d01321e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "swarm-authelia-bridge", "swarm-authelia-bridge-sock", "swarm-controller", + "swarm-matrix-client", "swarm-matrix-ctl", "swarm-nats-auth", "swarm-queue-client", @@ -101,6 +102,7 @@ hive-priv-sock = { path = "hive-priv-sock" } hive-sock-client = { path = "hive-sock-client" } hive-types = { path = "hive-types" } swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" } +swarm-matrix-client = { path = "swarm-matrix-client" } swarm-queue-client = { path = "swarm-queue-client" } swarm-secret-client = { path = "swarm-secret-client" } thiserror = "2" diff --git a/swarm-matrix-client/Cargo.toml b/swarm-matrix-client/Cargo.toml new file mode 100644 index 00000000..4d8bd38b --- /dev/null +++ b/swarm-matrix-client/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "swarm-matrix-client" +version.workspace = true +readme = "README.md" +edition.workspace = true + +[dependencies] +anyhow.workspace = true +reqwest.workspace = true +serde_json.workspace = true + +[lints] +workspace = true diff --git a/swarm-matrix-client/README.md b/swarm-matrix-client/README.md new file mode 100644 index 00000000..36a344bd --- /dev/null +++ b/swarm-matrix-client/README.md @@ -0,0 +1,17 @@ +# swarm-matrix-client + +The appservice calls against the swarm's homeserver, shared by the two binaries +that make them: `swarm-matrix-ctl` (inside the matrix container, for a hive's +sender account) and `swarm-controller` (for each agent's account, with the +swarm's own appservice token). + +Three calls, all client-server API: register an account as the appservice, log +in to an existing one as the appservice, and ask a token who it is. Both mints +pin the device id `hyperhive-`, so a second login **replaces** that +device's token instead of adding a device. + +## 🩸 A secret is a path, never a value + +Every error here is built from the response's status and its `errcode`, never +its body: a successful `/register` or `/login` body is an access token, and an +error body is one malformed response away from being the same bytes. diff --git a/swarm-matrix-ctl/src/homeserver.rs b/swarm-matrix-client/src/lib.rs similarity index 70% rename from swarm-matrix-ctl/src/homeserver.rs rename to swarm-matrix-client/src/lib.rs index 05ac19e0..c7c4c4fa 100644 --- a/swarm-matrix-ctl/src/homeserver.rs +++ b/swarm-matrix-client/src/lib.rs @@ -1,4 +1,6 @@ -//! The two calls the mint ladder is made of, against the homeserver next door. +//! The appservice calls against the swarm's homeserver: register, log in, and +//! ask a token who it is. Shared by `swarm-matrix-ctl` and `swarm-controller`; +//! see the README for why both mint through the same device id. //! //! 🩸 **Every error in this module is built from the response's `status` and //! its `errcode`, never its body.** A successful `/register` or `/login` body @@ -8,8 +10,8 @@ use anyhow::{Context, Result, bail}; -/// Client-server API calls are one round trip each against a homeserver in the -/// same netns; a slow one is a broken one. +/// Every call here is one client-server API round trip; a slow one is a broken +/// one. const TIMEOUT_SECS: u64 = 10; /// Bytes of the throwaway password `/register` is given. @@ -21,11 +23,11 @@ const PASSWORD_BYTES: usize = 32; /// What the homeserver said about a registration attempt. /// -/// An enum rather than a string match on the error text: `M_USER_IN_USE` is the -/// *expected* answer here — the hive's `@hive-:` account is the appservice -/// registration's own `sender_localpart`, so the homeserver creates it at startup, before -/// anything gets to ask — and an expected answer should not have to be -/// recovered from a formatted message. +/// An enum rather than a string match on the error text: `M_USER_IN_USE` is an +/// *expected* answer — a registration's own `sender_localpart` is created by +/// the homeserver at startup, and an agent minted once before already exists — +/// and an expected answer should not have to be recovered from a formatted +/// message. pub enum Registered { /// A fresh account, and the access token minted with it. Token(String), @@ -68,7 +70,7 @@ pub async fn register( // request carries the as_token. "type": "m.login.application_service", "username": localpart, - "password": random_password()?, + "password": random_hex(PASSWORD_BYTES)?, "device_id": device_id(localpart), "initial_device_display_name": format!("hyperhive ({localpart})"), "inhibit_login": false, @@ -140,6 +142,52 @@ pub async fn appservice_login( access_token(&json) } +/// What the homeserver says a token is. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Whoami { + /// A live token, and the full user id it authenticates as. + User(String), + /// `M_UNKNOWN_TOKEN`: the homeserver does not know this token, because a + /// later login on the same device replaced it or it was revoked. + UnknownToken, +} + +/// Ask the homeserver who `token` is. +/// +/// # Errors +/// When the request cannot be sent, the response will not decode, or the +/// homeserver refuses with anything other than `M_UNKNOWN_TOKEN`. That one is +/// [`Whoami::UnknownToken`], because it is a verdict about the token and the +/// rest are not. +pub async fn whoami(client: &reqwest::Client, base: &str, token: &str) -> Result { + let resp = client + .get(format!("{base}/_matrix/client/v3/account/whoami")) + .bearer_auth(token) + .send() + .await + .context("GET /account/whoami: sending the request")?; + let status = resp.status(); + let json = resp + .json::() + .await + .context("GET /account/whoami: decoding the response as JSON")?; + whoami_verdict(status, &json) +} + +/// [`whoami`]'s reading of a response, apart from the IO. +fn whoami_verdict(status: reqwest::StatusCode, json: &serde_json::Value) -> Result { + if status.is_success() { + return json["user_id"] + .as_str() + .map(|u| Whoami::User(u.to_owned())) + .context("the homeserver's whoami response carried no `user_id`"); + } + if errcode(json) == Some("M_UNKNOWN_TOKEN") { + return Ok(Whoami::UnknownToken); + } + bail!("the homeserver refused whoami: {}", why(status, json)) +} + /// The device every token this binary mints is pinned to. fn device_id(localpart: &str) -> String { format!("hyperhive-{localpart}") @@ -195,18 +243,18 @@ fn why(status: reqwest::StatusCode, json: &serde_json::Value) -> String { } } -/// A throwaway password for [`register`], as hex. +/// `bytes` random bytes from `/dev/urandom`, as hex: [`register`]'s +/// throwaway password, and the appservice tokens `swarm-matrix-ctl` mints. /// -/// From `/dev/urandom` directly rather than through an RNG crate: this is the -/// one random value the binary needs, and the kernel is already the source any -/// such crate would reach for here. +/// From `/dev/urandom` directly rather than through an RNG crate: the kernel is +/// already the source any such crate would reach for here. /// /// # Errors /// When `/dev/urandom` cannot be read. -fn random_password() -> Result { +pub fn random_hex(bytes: usize) -> Result { use std::io::Read as _; - let mut buf = [0u8; PASSWORD_BYTES]; + let mut buf = vec![0u8; bytes]; std::fs::File::open("/dev/urandom") .context("opening /dev/urandom")? .read_exact(&mut buf) @@ -275,11 +323,48 @@ mod tests { assert!(!format!("{e}").contains("@hive:t.local"), "{e}"); } + #[test] + fn whoami_reads_the_user_of_a_live_token() { + let json = + serde_json::json!({ "user_id": "@atlas:t.local", "device_id": "hyperhive-atlas" }); + assert_eq!( + whoami_verdict(reqwest::StatusCode::OK, &json).expect("a live token"), + Whoami::User("@atlas:t.local".to_owned()) + ); + } + + #[test] + fn a_replaced_token_is_a_verdict_and_not_an_error() { + // The arm a mint decides on: this is how a token some other login on + // the same device replaced looks. + let json = serde_json::json!({ "errcode": "M_UNKNOWN_TOKEN", "error": "Unknown token" }); + assert_eq!( + whoami_verdict(reqwest::StatusCode::UNAUTHORIZED, &json).expect("a verdict"), + Whoami::UnknownToken + ); + } + + #[test] + fn any_other_refusal_is_an_error_so_nobody_mints_on_it() { + // The control for the arm above: a 401 with another code, or a 5xx, + // says nothing about the token, and a caller that minted on it would + // rotate every token during an outage. + for (status, json) in [ + ( + reqwest::StatusCode::UNAUTHORIZED, + serde_json::json!({ "errcode": "M_MISSING_TOKEN" }), + ), + (reqwest::StatusCode::BAD_GATEWAY, serde_json::json!({})), + ] { + assert!(whoami_verdict(status, &json).is_err(), "{status}"); + } + } + #[test] fn a_minted_password_is_hex_of_the_declared_length() { // The control on the hex fold: a short or non-hex password would be // accepted by the homeserver and only surface much later, if at all. - let pw = random_password().expect("/dev/urandom is readable"); + let pw = random_hex(PASSWORD_BYTES).expect("/dev/urandom is readable"); assert_eq!(pw.len(), PASSWORD_BYTES * 2); assert!(pw.bytes().all(|b| b.is_ascii_hexdigit()), "not hex"); } diff --git a/swarm-matrix-ctl/Cargo.toml b/swarm-matrix-ctl/Cargo.toml index 3d308778..155bf71a 100644 --- a/swarm-matrix-ctl/Cargo.toml +++ b/swarm-matrix-ctl/Cargo.toml @@ -14,8 +14,10 @@ anyhow.workspace = true # single-purpose binary: the next thing that has to run in the matrix container # is a subcommand here, not a new crate. clap.workspace = true -reqwest.workspace = true serde_json.workspace = true +# The appservice calls, shared with `swarm-controller`, which mints agents' +# accounts through the same device id. +swarm-matrix-client.workspace = true # The agreement this binary is one end of: where the credential lives, what the # object at that path holds, and the `BAO_*` spellings the unit sets. swarm-secret-client.workspace = true diff --git a/swarm-matrix-ctl/src/appservice.rs b/swarm-matrix-ctl/src/appservice.rs new file mode 100644 index 00000000..da78df21 --- /dev/null +++ b/swarm-matrix-ctl/src/appservice.rs @@ -0,0 +1,279 @@ +//! `swarm-matrix-ctl appservice` — the **swarm's** own appservice +//! registration: minted here, loaded by the homeserver beside us, and published +//! to the one store path `swarm-controller` reads it from. +//! +//! Two verbs, because they have opposite failure rules: +//! +//! - [`render`] runs before tuwunel and touches nothing but this container's +//! state dir. tuwunel loads the file it writes through `LoadCredential`, and +//! a missing credential source fails the homeserver's start, so this half +//! must not be able to fail on a network. +//! - [`publish`] runs after it and needs the store. A sealed store delays it +//! and nothing else. +//! +//! "Only once": the token file in the state dir is the record. [`render`] +//! mints only when it is absent and re-renders from it every time; [`publish`] +//! writes the store only when the store's copy differs. +//! +//! This registration's sender is promoted to homeserver admin at boot +//! (`nix/host-modules/hive-matrix.nix`), which is why its token goes to +//! `swarm_secret_client::matrix::swarm_appservice_token_path` — a path no +//! hive's policy reaches — and to nowhere else. + +use std::io::Write as _; +use std::os::unix::fs::OpenOptionsExt as _; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use swarm_secret_client::{ + SecretStore, + client::{DEFAULT_CERT_MOUNT, Settings}, + matrix, +}; + +use crate::registration; + +/// This container's state dir for the registration and its two tokens. +const ENV_DIR: &str = "MATRIX_APPSERVICE_DIR"; +/// The registration's `sender_localpart`: the account the homeserver creates +/// for it and the one `admin_execute` promotes. +const ENV_SENDER: &str = "MATRIX_APPSERVICE_SENDER"; +/// The user namespace regex, rendered by nix beside the hive registration's. +const ENV_USER_REGEX: &str = "MATRIX_APPSERVICE_USER_REGEX"; +/// Role on the store's `cert` auth mount that [`publish`] logs in with. +const ENV_CERT_ROLE: &str = "MATRIX_APPSERVICE_CERT_ROLE"; + +/// The registration's `id`. Distinct from the hive registration's +/// (`hyperhive`): tuwunel refuses two registrations with one id. +const ID: &str = "swarm"; +/// File names inside [`ENV_DIR`]. `REGISTRATION` is what tuwunel loads. +const AS_TOKEN: &str = "as-token"; +const HS_TOKEN: &str = "hs-token"; +const REGISTRATION: &str = "swarm.yaml"; +/// Random bytes per token, as the hive registration's renderer uses. +const TOKEN_BYTES: usize = 32; + +/// Read a required variable. +fn required(get: &impl Fn(&str) -> Option, var: &'static str) -> Result { + get(var) + .filter(|v| !v.is_empty()) + .with_context(|| format!("{var} is unset or empty")) +} + +/// Mint the tokens if absent and render the registration from them. +/// +/// # Errors +/// If a variable is missing, or the state dir cannot be read or written. +pub fn render() -> Result<()> { + let get = |k: &str| std::env::var(k).ok(); + let dir = PathBuf::from(required(&get, ENV_DIR)?); + let sender = required(&get, ENV_SENDER)?; + let regex = required(&get, ENV_USER_REGEX)?; + render_into(&dir, &sender, ®ex)?; + tracing::info!(path = %dir.join(REGISTRATION).display(), "rendered the swarm appservice registration"); + Ok(()) +} + +/// [`render`] against an explicit directory, so a test can run it twice. +fn render_into(dir: &Path, sender: &str, regex: &str) -> Result<()> { + let as_token = existing_or_minted(&dir.join(AS_TOKEN))?; + let hs_token = existing_or_minted(&dir.join(HS_TOKEN))?; + write_secret( + &dir.join(REGISTRATION), + ®istration_yaml(sender, regex, &as_token, &hs_token), + ) +} + +/// The token at `path`, minting and writing one first when there is none. +fn existing_or_minted(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(t) if !t.trim().is_empty() => return Ok(t.trim().to_owned()), + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())), + } + let token = swarm_matrix_client::random_hex(TOKEN_BYTES)?; + write_secret(path, &token)?; + tracing::info!(path = %path.display(), "minted a swarm appservice token"); + Ok(token) +} + +/// Write `contents` to `path` as a `0600` file, through a rename so a reader +/// never sees half of it. +fn write_secret(path: &Path, contents: &str) -> Result<()> { + let tmp = path.with_extension("tmp"); + let _ = std::fs::remove_file(&tmp); + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&tmp) + .with_context(|| format!("creating {}", tmp.display()))?; + f.write_all(contents.as_bytes()) + .and_then(|()| f.sync_all()) + .with_context(|| format!("writing {}", tmp.display()))?; + std::fs::rename(&tmp, path).with_context(|| format!("renaming onto {}", path.display())) +} + +/// The registration, in the shape `hive-matrix.nix` renders the hive's. +/// +/// `exclusive: false` for that file's reason: an exclusive namespace does not +/// widen what this appservice may do, it refuses everyone else — and the hive +/// registration covers the same names until it is retired. +fn registration_yaml(sender: &str, regex: &str, as_token: &str, hs_token: &str) -> String { + format!( + "id: {ID}\n\ + url: null\n\ + sender_localpart: {sender}\n\ + rate_limited: false\n\ + namespaces:\n \ + users:\n \ + - exclusive: false\n \ + regex: '{regex}'\n \ + aliases: []\n \ + rooms: []\n\ + as_token: {as_token}\n\ + hs_token: {hs_token}\n" + ) +} + +/// Whether the store needs the local token written to it. +fn needs_publish(stored: Option<&matrix::Credential>, local: &str) -> bool { + stored.is_none_or(|c| c.value.trim() != local) +} + +/// Write the rendered registration's `as_token` to the store, unless the store +/// already holds it. +/// +/// # Errors +/// If a variable is missing, the registration has not been rendered, or the +/// store refuses the login, the read or the write. +pub async fn publish() -> Result<()> { + let get = |k: &str| std::env::var(k).ok(); + let dir = PathBuf::from(required(&get, ENV_DIR)?); + let cert_role = required(&get, ENV_CERT_ROLE)?; + let registration = dir.join(REGISTRATION); + let local = registration::as_token(®istration.to_string_lossy())?; + + let settings = Settings::from_env().context("reading the store's BAO_* environment")?; + let store = SecretStore::connect(&settings, &cert_role, DEFAULT_CERT_MOUNT) + .await + .with_context(|| { + format!("logging in to the swarm secret store as cert role {cert_role}") + })?; + let path = matrix::swarm_appservice_token_path()?; + let stored: Option = store + .read_optional(&path) + .await + .with_context(|| format!("reading {path}"))?; + if !needs_publish(stored.as_ref(), &local) { + tracing::info!(%path, "the swarm appservice token is already published"); + return Ok(()); + } + store + .write( + &path, + &matrix::Credential { + value: local, + homeserver: None, + }, + ) + .await + .with_context(|| format!("writing the swarm appservice token to {path}"))?; + tracing::info!(%path, "published the swarm appservice token"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const REGEX: &str = "^@[a-z0-9._=/-]+:t\\.local$"; + + fn scratch() -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "swarm-appservice-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("after the epoch") + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("temp dir"); + dir + } + + #[test] + fn the_rendered_registration_carries_the_token_it_minted() { + let dir = scratch(); + render_into(&dir, "swarm", REGEX).expect("renders"); + let token = registration::as_token(&dir.join(REGISTRATION).to_string_lossy()) + .expect("the as_token line parses"); + assert_eq!(token.len(), TOKEN_BYTES * 2); + assert_eq!( + std::fs::read_to_string(dir.join(AS_TOKEN)).expect("minted"), + token + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_second_render_keeps_the_token() { + // "Only once": a re-mint on every boot would hand the controller a + // token the homeserver no longer loads until publish catches up. + let dir = scratch(); + render_into(&dir, "swarm", REGEX).expect("first"); + let first = std::fs::read_to_string(dir.join(REGISTRATION)).expect("rendered"); + render_into(&dir, "swarm", REGEX).expect("second"); + let second = std::fs::read_to_string(dir.join(REGISTRATION)).expect("rendered"); + assert_eq!(first, second); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn the_registration_is_the_swarms_and_not_the_hives() { + let y = registration_yaml("swarm", REGEX, "aa", "bb"); + assert!(y.starts_with("id: swarm\n"), "{y}"); + assert!(y.contains("\nsender_localpart: swarm\n"), "{y}"); + assert!(y.contains("\n - exclusive: false\n"), "{y}"); + assert!(y.contains(&format!("regex: '{REGEX}'")), "{y}"); + assert!(y.contains("\nurl: null\n"), "{y}"); + } + + #[test] + fn the_registration_and_tokens_are_owner_only() { + use std::os::unix::fs::PermissionsExt as _; + let dir = scratch(); + render_into(&dir, "swarm", REGEX).expect("renders"); + for f in [AS_TOKEN, HS_TOKEN, REGISTRATION] { + let mode = std::fs::metadata(dir.join(f)) + .expect("exists") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "{f}"); + } + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn publish_writes_only_what_the_store_lacks() { + let same = matrix::Credential { + value: "aa".to_owned(), + homeserver: None, + }; + assert!(!needs_publish(Some(&same), "aa")); + assert!(needs_publish(None, "aa")); + let other = matrix::Credential { + value: "bb".to_owned(), + homeserver: None, + }; + assert!(needs_publish(Some(&other), "aa")); + } + + #[test] + fn every_variable_is_scoped_to_the_verb() { + for var in [ENV_DIR, ENV_SENDER, ENV_USER_REGEX, ENV_CERT_ROLE] { + assert!(var.starts_with("MATRIX_APPSERVICE_"), "{var}"); + } + } +} diff --git a/swarm-matrix-ctl/src/main.rs b/swarm-matrix-ctl/src/main.rs index 0008e919..9a734195 100644 --- a/swarm-matrix-ctl/src/main.rs +++ b/swarm-matrix-ctl/src/main.rs @@ -7,18 +7,19 @@ //! identity plumbing to add one action, so the next thing that has to run in //! here is a verb below, not a new crate. //! -//! Today that is one verb, [`mint`]: publish the appservice sender account's -//! homeserver access token to the swarm's secret store, once. +//! [`mint`] publishes a hive's appservice sender token to the swarm's secret +//! store, once. [`appservice`] mints the **swarm's** own appservice +//! registration and publishes its token for `swarm-controller`. //! //! It lives in the container because the appservice `as_token` that authorises //! the mint is *already* there — the registration tuwunel loads is bind-mounted //! in — so no second holder of that secret is created. //! //! 🩸 **A secret is a path, never a value.** The only identifier any verb here -//! logs is the store path; see `homeserver`'s module doc for the same rule +//! logs is the store path; see `swarm_matrix_client`'s module doc for the same rule //! applied to error messages. -mod homeserver; +mod appservice; mod mint; mod registration; @@ -44,6 +45,20 @@ enum Command { /// no flags, because a systemd `Environment=` block is what a nix module /// can render and a command line full of paths is not. Mint, + /// The swarm's own appservice registration, whose sender is the + /// homeserver's admin account. Configured by `MATRIX_APPSERVICE_*`. + #[command(subcommand)] + Appservice(Appservice), +} + +#[derive(Debug, Subcommand)] +enum Appservice { + /// Mint the tokens when absent and render the registration tuwunel loads. + /// Local only: it runs before the homeserver and must not need a network. + Render, + /// Write the rendered `as_token` to the swarm secret store when the + /// store's copy differs. + Publish, } #[tokio::main] @@ -57,6 +72,8 @@ async fn main() -> Result<()> { match Cli::parse().command { Command::Mint => mint::run().await, + Command::Appservice(Appservice::Render) => appservice::render(), + Command::Appservice(Appservice::Publish) => appservice::publish().await, } } @@ -80,6 +97,25 @@ mod tests { /// The control: without it the case above passes on a parser that accepts /// anything. + /// The two units name these verbs, same reason as the test above. + #[test] + fn the_appservice_verbs_are_spelled_the_way_the_units_invoke_them() { + let cli = + Cli::try_parse_from(["swarm-matrix-ctl", "appservice", "render"]).expect("a verb"); + assert!(matches!( + cli.command, + Command::Appservice(Appservice::Render) + )); + let cli = + Cli::try_parse_from(["swarm-matrix-ctl", "appservice", "publish"]).expect("a verb"); + assert!(matches!( + cli.command, + Command::Appservice(Appservice::Publish) + )); + Cli::try_parse_from(["swarm-matrix-ctl", "appservice"]) + .expect_err("a sub-verb is required"); + } + #[test] fn an_unknown_verb_is_refused() { Cli::try_parse_from(["swarm-matrix-ctl", "conjure"]) diff --git a/swarm-matrix-ctl/src/mint.rs b/swarm-matrix-ctl/src/mint.rs index a72f848b..465b49a6 100644 --- a/swarm-matrix-ctl/src/mint.rs +++ b/swarm-matrix-ctl/src/mint.rs @@ -21,7 +21,9 @@ use swarm_secret_client::{ matrix, }; -use crate::{homeserver, registration}; +use swarm_matrix_client as homeserver; + +use crate::registration; /// Role on the store's `cert` auth mount to log in with. Its policy is what /// allows the write below; the certificate the `BAO_*` variables name has to diff --git a/swarm-secret-client/src/matrix.rs b/swarm-secret-client/src/matrix.rs index beb97837..352546cf 100644 --- a/swarm-secret-client/src/matrix.rs +++ b/swarm-secret-client/src/matrix.rs @@ -93,6 +93,28 @@ pub fn appservice_token_path(hive: &str) -> Result { Ok(format!("{prefix}/matrix/appservice-token")) } +/// The name segment of the controller's own subtree, and the cert-auth role it +/// logs in under (`swarm-controller`'s `store::CERT_ROLE`). +const CONTROLLER: &str = "swarm-controller"; + +/// The path holding the **swarm's** appservice token: the one registration +/// on the swarm's homeserver that is not a hive's, whose sender is promoted +/// to homeserver admin at boot. +/// +/// The matrix container mints it and publishes it here; `swarm-controller` +/// reads it to create agents' accounts. Under [`Kind::Controller`] because +/// that is the one kind [`crate::policy::render`] grants no hive: every hive +/// reads `agents/*`, its own `hives//*` and `services/*`, so under any +/// of those this admin credential would be readable by every hive. +/// +/// # Errors +/// Never in practice: the name segment is a constant. The `Result` is +/// [`principal_prefix`]'s. +pub fn swarm_appservice_token_path() -> Result { + let prefix = principal_prefix(Kind::Controller, CONTROLLER)?; + Ok(format!("{prefix}/matrix/appservice-token")) +} + /// What an account's path holds: the token, plus the homeserver it belongs to. /// /// The homeserver rides with the token rather than on the queue notice that @@ -215,6 +237,39 @@ mod tests { assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}"); } + #[test] + fn the_swarm_appservice_token_lands_under_the_controller() { + assert_eq!( + swarm_appservice_token_path().expect("a constant segment"), + "swarm/controller/swarm-controller/matrix/appservice-token" + ); + } + + #[test] + fn no_hive_policy_reaches_the_swarm_appservice_token() { + // 🩸 The swarm's sender is a homeserver admin, so its token must not + // be under any stanza a hive's policy renders. Checked against the + // rendered document rather than against the prefix, because the + // document is what the store enforces. + let doc = crate::policy::render("pr1ma").expect("a plain name is legal"); + // Every stanza is `path "secret/data/*" { … }`. + let granted: Vec<&str> = doc + .lines() + .filter_map(|l| l.strip_prefix("path \"secret/data/")) + .filter_map(|p| p.strip_suffix("*\" {")) + .collect(); + let reads = |path: &str| granted.iter().any(|g| path.starts_with(g)); + let path = swarm_appservice_token_path().expect("a constant segment"); + assert!(!reads(&path), "{path} is readable under {granted:?}"); + // The control: an agent's account path IS under a hive stanza, so the + // assertion above can fail at all. + let agent = account_path("atlas", "main").expect("legal"); + assert!( + reads(&agent), + "{agent} should be readable under {granted:?}" + ); + } + #[test] fn a_segment_cannot_escape_its_own_directory() { // Each of these is a *different* way to address another agent's tree,