From f778122f5a349ec21ebd62eb27fc2469446c83c8 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 20 Sep 2026 13:42:17 +0200 Subject: [PATCH 1/8] matrix: mint the appservice sender token in the matrix container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A swarm runs one homeserver and a homeserver has one appservice sender account, so "mint it once" is a property of the thing being minted rather than something a lock has to enforce. That is what makes this account the one to move first: no trigger route, no controller change and no agent list — a boot-time oneshot beside tuwunel is the whole mechanism. `swarm-matrix-minter` runs inside `containers.hive-matrix`, which already holds the appservice token: the rendered registration is bound in read-only because that is how tuwunel is handed it. What the container lacked was an identity of its own, so this adds one — a leaf from the store's CA with a grant of exactly one path, not the hive's leaf, which reads every secret in the store. Both ends of the credential ship here. The minter reads the path it publishes to before it touches the homeserver, and returning on a non-empty read IS the "only once"; `hive-c0re`'s `ensure_hive_user` reads the same path, authenticating with the hive name already in `HYPERHIVE_HIVE_NAME`. The existing mint-then-`M_USER_IN_USE`-login ladder stays as the fallback for a store that is empty, unconfigured or unreachable, which is every swarm deployed before this — so nothing needs backfilling and nothing breaks if the rest of the sequence never lands. The credential is not an admin credential, and is not named like one. It is the access token of the appservice registration's own `sender_localpart` — `@hive:`, an account the homeserver creates for itself when it loads the registration. The store path is `swarm/services/matrix/sender-token`, the host path is `matrix/access-token`, and the homeserver no longer runs an `admin_execute` promotion for that account at boot. Everything the hive provisions with it — the Space, the chat room, their hierarchy and join rules, the invites — rides on being the creator of those rooms at power level 100, not on homeserver admin; there is no Synapse admin API here to need, tuwunel has none. Two operations do need an admin *sender* and therefore stop working: `hivectl matrix promote-user` and `hivectl matrix reset-password`, both `!admin …` messages into `#admins:`, plus the password-reset recovery path that an agent with a lost password file falls back to. They are swarm-level operations and are left failing loudly rather than served by an over-privileged token every other call site would also carry. The sweep's own admin-rights check and self-repair go with them: an account that is deliberately not an admin has nothing to check. `ephemeral = false` stays, and hive root can still read the container's filesystem. Accepted: what this buys is identity separation — no hive *process* holds or reads the appservice token — not physical isolation. Refs #4345 --- Cargo.lock | 13 + Cargo.toml | 1 + docs/getting-started/setup.md | 4 +- docs/integrations/matrix.md | 46 +- docs/tools/hivectl-cli.md | 4 +- docs/tools/hivectl.md | 22 +- flake.nix | 3 + hive-c0re/src/matrix.rs | 421 ++++++++---------- hive-c0re/src/paths.rs | 12 +- hive-c0re/src/server.rs | 22 +- hivectl/src/cli.rs | 4 +- hivectl/src/matrix.rs | 4 +- nix/host-modules/default.nix | 1 + nix/host-modules/glue-bao-tls.nix | 13 + .../glue-matrix-minter-bao-identity.nix | 40 ++ nix/host-modules/hive-matrix.nix | 227 ++++++++-- nix/host-modules/swarm-bao.nix | 100 +++++ nix/host-modules/swarm.nix | 1 + nix/module-eval/bao-grants.nix | 50 +++ nix/module-eval/bao-matrix-reader.nix | 110 +++++ nix/module-eval/name-guards.nix | 23 + nix/packages/default.nix | 7 + swarm-matrix-minter/Cargo.toml | 23 + swarm-matrix-minter/README.md | 44 ++ swarm-matrix-minter/src/homeserver.rs | 286 ++++++++++++ swarm-matrix-minter/src/main.rs | 249 +++++++++++ swarm-matrix-minter/src/registration.rs | 104 +++++ swarm-secret-client/src/matrix.rs | 50 ++- 28 files changed, 1565 insertions(+), 319 deletions(-) create mode 100644 nix/host-modules/glue-matrix-minter-bao-identity.nix create mode 100644 swarm-matrix-minter/Cargo.toml create mode 100644 swarm-matrix-minter/README.md create mode 100644 swarm-matrix-minter/src/homeserver.rs create mode 100644 swarm-matrix-minter/src/main.rs create mode 100644 swarm-matrix-minter/src/registration.rs diff --git a/Cargo.lock b/Cargo.lock index 64c1b82b..feee92c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4924,6 +4924,19 @@ dependencies = [ "swarm-queue-client", ] +[[package]] +name = "swarm-matrix-minter" +version = "0.1.0" +dependencies = [ + "anyhow", + "reqwest", + "serde_json", + "swarm-secret-client", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "swarm-nats-auth" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a56fe0f0..693a7dee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "swarm-authelia-bridge", "swarm-authelia-bridge-sock", "swarm-controller", + "swarm-matrix-minter", "swarm-nats-auth", "swarm-queue-client", "swarm-logs", diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index cd284449..740d6367 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -229,7 +229,7 @@ control: [`swarm/ui.md`](../swarm/ui.md). ### 6 · Matrix ```bash -# Ensure the hive-internal admin account exists first +# Ensure the hive's own `@hive:` account exists first hivectl matrix sync-admin # Provision ruth's own matrix account — same bootstrap-bypass reasoning @@ -293,7 +293,7 @@ See [`tools/hivectl.md`](../tools/hivectl.md) for every `hivectl` verb. - **No forge admin token is stored in any agent state dir.** Agents hold a regular agent token in their `forge-token` file; sensitive - creds (the core token, the matrix admin token) live on the host. + creds (the core token, the `@hive:` matrix access token) live on the host. - All config changes (forge PRs on `agent-configs/`) go through operator approval — agents can't unilaterally rebuild containers, by design. See [`boundary.md`](../trust-boundary/boundary.md) and [`security.md`](../trust-boundary/security.md). diff --git a/docs/integrations/matrix.md b/docs/integrations/matrix.md index cb3870ef..8847a098 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -147,29 +147,31 @@ a token. (`hive-matrix-daemon.path` watching for `matrix-token` appearance) brings the daemon up on the same boot cycle anyway. -### The admin account, and why it needs no first-user luck +### The `@hive:` account, and why it is not an admin `@hive:` is the appservice's own `sender_localpart`, which the homeserver creates itself when it loads the registration — on a zero-user database, inside startup, before the HTTP listener accepts -anything. Its **admin rights** then come from an explicit -`make_user_admin`, run by tuwunel's `admin_execute` in the same startup -and likewise before the listener — so a fresh hive has a joined, -power-level-100 admin on its first boot. +anything. It is an **ordinary account**: nothing promotes it, and the +homeserver runs no `admin_execute` for it. -This replaces a dependency on being the first account ever registered, -which was fragile in both directions: the design excludes an -appservice-created account from that automatic grant, and on a homeserver that -already had users the rule never fired at all. +It needs no promotion for what the hive does with it. Creating the hive +Space and the chat room, writing their hierarchy and join rules, and +inviting agents into them are all ordinary client calls that ride on +being the rooms' own creator at power level 100 — there is no homeserver +admin in any of it. (There is no Synapse admin API here either; tuwunel +has none.) -Tuwunel doesn't support bootstrapping promotion over the API, and -that's upstream's design rather than a gap: it only treats an admin-room message as a -command when its sender is already an admin. `admin_execute` is the one -lever with no sender to check. hive-c0re re-checks the result on every -sweep by reading the admin account's own joined-rooms list; if the rights -are missing it says so, names -`systemctl restart container@hive-matrix` as the fix, and carries on — -agent accounts, the hive Space and the chat room need no admin. +Two operations do need an admin **sender**, and neither works today: +`hivectl matrix promote-user` and `hivectl matrix reset-password`. Both +are `!admin …` messages into `#admins:`, and tuwunel only +treats a message as a command when its sender is already an admin. They +are swarm-level operations and are being rehomed as such; until then +they fail with the admin room's refusal rather than being served by an +over-privileged credential that every other call site would also carry. +The one hive-side path that depends on them is the password-reset +auto-recovery for an agent whose stored password is gone — the ordinary +appservice re-login above is unaffected.
Upgrading a hive that used the registration token @@ -185,10 +187,10 @@ restarts, so the first boot after the switch already has both halves. - **The per-agent sweep honours existing token files.** It skips any agent that already has a `matrix-token`, so it re-registers no account and displaces no session. -- **The admin account is already admin** on such a hive (it won the - first-user grant when the hive was new), so the startup promotion is a - no-op — upstream's `make_user_admin` short-circuits when the user is - already joined at power level 100. +- **`@hive:` may already be an admin** on such a hive (it won the + first-user grant when the hive was new). Nothing here demotes it; the + homeserver simply no longer promotes it, so a hive built fresh has an + ordinary account and an older one keeps whatever standing it acquired. - **`/var/lib/hyperhive/matrix-register-token` stays on disk**, read by nothing. Delete it or leave it; neither does any harm. - **`registrationTokenFile` is a removed option.** A config that still @@ -237,7 +239,7 @@ Initial rollout settings: ## Hive Matrix Space On first boot, after hive-c0re provisions all agent accounts, it -creates a private **Matrix Space** named `"hive"` using the admin +creates a private **Matrix Space** named `"hive"` using the `@hive:` account (`@hive:`) and invites every provisioned agent into it. This gives the operator a single Space in FluffyChat or any Matrix client that groups all agent-to-agent + operator rooms in one diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 12ecc67a..5870819f 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -193,9 +193,9 @@ For an existing agent, persists the token to its state dir; for a human/other ac ## `hivectl matrix sync-admin` -Provision (or re-provision) the hive system admin matrix account. +Provision (or re-provision) the hive's own `@hive:` matrix account. -Runs automatically on startup; run manually to recover a missing admin token. +Runs automatically on startup; run manually to recover a missing access token. **Usage:** `hivectl matrix sync-admin` diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 280afe08..ce7d4864 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -64,7 +64,7 @@ running (`services.hyperhive.deploy.matrix.enable = true`). hivectl matrix create-user iris # provision (or re-provision) matrix account for agent `iris` hivectl matrix create-user mara # create matrix account for a human; prints access_token to stdout hivectl matrix create-user mara --password hunter2 # set a client-login password -hivectl matrix sync-admin # provision / refresh the hive internal admin account +hivectl matrix sync-admin # provision / refresh the hive's own `@hive:` account hivectl matrix promote-user mara # promote an existing matrix user to homeserver admin hivectl matrix reset-password iris # generate and set a new random password for `iris`; prints it hivectl matrix invite mara # invite a user to the hive Space @@ -74,19 +74,21 @@ hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a spec - `create-user`: for agents, persists the `access_token` to `/matrix-token`. Skips registration when the file already exists — delete it first to force re-registration. -- `sync-admin`: ensures the hive's internal admin matrix user exists - (used by `hive-c0re` for admin-room commands). Token persisted to the - admin token path. Safe to run again — idempotent. +- `sync-admin`: ensures the hive's own `@hive:` matrix user exists + (the account `hive-c0re` provisions rooms with). Token persisted to the + access token path. Safe to run again — idempotent. - `promote-user`: promotes an already-registered user to homeserver - admin via the matrix admin API. Requires `sync-admin` to have run - first (needs a valid admin token). -- `reset-password`: calls the matrix admin API to set a new random - password and prints it to stdout. Useful if an agent or human lost + admin by an `!admin` command in `#admins`. ⚠️ Needs an admin **sender**, + which `@hive:` is not — this operation is being rehomed at swarm level + and does not work from the hive today. +- `reset-password`: asks the admin room to set a new random + password and prints it to stdout. ⚠️ Needs an admin **sender** too, so + it does not work from the hive today either. Useful if an agent or human lost credentials. - `invite`: invites a matrix user (full `@user:server` or a bare localpart, qualified with the homeserver's `server_name`) to the hive - Space by default, or to a `--room` id / `#alias`. Uses the hive admin - token; the admin account must be a member of the target room with + Space by default, or to a `--room` id / `#alias`. Uses the `@hive:` + token; the account must be a member of the target room with invite power (it owns the hive Space, so that case always works). Idempotent — already-member / already-invited is a no-op. diff --git a/flake.nix b/flake.nix index 870a4b2e..ec910de8 100644 --- a/flake.nix +++ b/flake.nix @@ -156,6 +156,9 @@ services.hyperhive.deploy.nats.authPackage = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth; + services.hyperhive.deploy.matrix.minterPackage = + lib.mkDefault + self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-matrix-minter; services.hyperhive.deploy.authelia.bridgePackage = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge; diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index b4c09558..e3cd205d 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -71,12 +71,15 @@ const PASSWORD_BYTES: usize = 32; /// Also the `sender_localpart` of the hive's appservice registration, /// which is what creates this account on a homeserver that has never had /// one: the homeserver creates a registration's sender user itself, at -/// startup, before it accepts a request. The account's *admin rights* -/// then come from the `admin_execute` promotion beside that registration -/// — see `nix/host-modules/hive-matrix.nix`, where this same literal -/// appears as `adminLocalpart`. **The two must match**; nothing wires an -/// override across. -pub const HIVE_ADMIN_LOCALPART: &str = "hive"; +/// startup, before it accepts a request. See +/// `nix/host-modules/hive-matrix.nix`, where this same literal appears as +/// `hiveLocalpart`. **The two must match**; nothing wires an override +/// across. +/// +/// An ordinary account, with no homeserver-admin standing: what it +/// provisions — the Space, the chat room, the invites — it provisions as +/// the creator of those rooms. +pub const HIVE_LOCALPART: &str = "hive"; /// Display name of the hive Space. Plain text, no special characters, so /// the Space stays rediscoverable by name (no room alias needed) even when @@ -94,11 +97,11 @@ pub const HIVE_CHAT_ROOM_NAME: &str = "hive-chat"; const HIVE_CHAT_ROOM_TOPIC: &str = "Hive-wide chat for all agents and the operator. Auto-provisioned by hive-c0re."; -/// Host path for the hive admin matrix access token. Outside every +/// Host path for the `@hive:` account's matrix access token. Outside every /// purgeable path — not deleted by `destroy --purge` on any agent. #[must_use] -pub fn admin_token_path() -> PathBuf { - crate::paths::matrix_admin_token() +pub fn hive_token_path() -> PathBuf { + crate::paths::matrix_hive_token() } /// Token file inside the agent's bind-mounted state dir (visible as @@ -382,21 +385,26 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re extract_access_token(&json) } -/// Auto-recovery helper: reset a user's matrix password via the admin API -/// when the locally stored password is missing. Requires a valid hive admin -/// token at [`admin_token_path()`]. Returns the new password (already -/// persisted to [`password_path`]) on success. +/// Auto-recovery helper: reset a user's matrix password through the admin +/// room when the locally stored password is missing. Returns the new +/// password (already persisted to [`password_path`]) on success. +/// +/// ⚠️ Needs an **admin sender**, which `@hive:` is not — the reset is a +/// `!admin` command and tuwunel only treats a message as a command when +/// its sender is an admin in that room. So this recovery path fails until +/// the two admin operations are rehomed at swarm level; the ordinary +/// route (the stored password, or an appservice login) is unaffected. /// /// Called by [`ensure_user_for`] when registration returns `M_USER_IN_USE` /// but the password file is absent — covers the case where agent state dirs /// were wiped but the homeserver still has the accounts. async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Result { - let admin_token = read_admin_token() - .context("matrix: admin token unavailable for auto-recovery; provision hive admin first")?; + let hive_token = read_hive_token() + .context("matrix: the @hive: access token is unavailable for auto-recovery")?; let server_name = discover_server_name(client) .await .context("matrix: discover_server_name for auto-recovery")?; - let effective_password = reset_user_password(client, &admin_token, name, &server_name) + let effective_password = reset_user_password(client, &hive_token, name, &server_name) .await .with_context(|| format!("matrix: admin-room password reset for {name} (auto-recovery)"))?; tracing::info!(%name, "matrix: auto-recovered password via admin-room reset"); @@ -416,7 +424,7 @@ fn encode_room_id_for_url(room_id: &str) -> String { /// Look up the room ID for the `#admins:` alias. async fn discover_admin_room_id( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, server_name: &str, ) -> Result { let base = matrix_base()?; @@ -425,7 +433,7 @@ async fn discover_admin_room_id( let url = format!("{base}/_matrix/client/v3/directory/room/{encoded_alias}"); let resp = client .get(&url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .send() .await .context("matrix: GET admin room alias")?; @@ -553,7 +561,7 @@ mod extract_new_password_tests { /// Generic over `T` so both password-returning and `()` callers share the loop. async fn admin_room_send_and_poll( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, server_name: &str, room_url: &str, command: &str, @@ -566,7 +574,7 @@ async fn admin_room_send_and_poll( format!("{base}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}"); let send_resp = client .put(&send_url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .json(&serde_json::json!({"msgtype": "m.text", "body": command})) .send() .await @@ -587,13 +595,13 @@ async fn admin_room_send_and_poll( // Poll for bot response: fetch the 20 most recent events (newest-first) // on each tick. Walk the list until we hit our own command event_id; // everything *before* that marker arrived after our command. - let own_user_id = format!("@{HIVE_ADMIN_LOCALPART}:{server_name}"); + let own_user_id = format!("@{HIVE_LOCALPART}:{server_name}"); let poll_url = format!("{base}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20"); for _ in 0..15_u8 { tokio::time::sleep(std::time::Duration::from_secs(1)).await; let poll_json = client .get(&poll_url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .send() .await .context("matrix: admin room poll")? @@ -647,16 +655,16 @@ async fn admin_room_send_and_poll( /// Returns the new password; caller is responsible for persisting it. async fn admin_room_reset_password( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, server_name: &str, localpart: &str, ) -> Result { - let room_id = discover_admin_room_id(client, admin_token, server_name).await?; + let room_id = discover_admin_room_id(client, hive_token, server_name).await?; let room_url = encode_room_id_for_url(&room_id); let command = format!("!admin users reset-password @{localpart}:{server_name}"); admin_room_send_and_poll( client, - admin_token, + hive_token, server_name, &room_url, &command, @@ -761,13 +769,15 @@ pub async fn ensure_user_for(client: &reqwest::Client, name: &str, as_token: &st { pw } else { - // Password file missing — attempt auto-recovery via admin API. + // Password file missing — attempt auto-recovery through the + // admin room. // This covers the case where agent state dirs were wiped but the - // homeserver still has the accounts. Requires the hive admin - // token at /var/lib/hyperhive/matrix/admin-token. + // homeserver still has the accounts. Requires the `@hive:` + // token at /var/lib/hyperhive/matrix/access-token, and an admin + // sender, which `@hive:` no longer is. tracing::info!( %name, - "matrix: stored password missing, attempting admin-API auto-recovery" + "matrix: stored password missing, attempting admin-room auto-recovery" ); match auto_reset_password(client, name).await { Ok(new_pw) => new_pw, @@ -883,42 +893,49 @@ pub async fn sync_agent_standalone(name: &str) { sync_agent(&client, name, &as_token).await; } -/// Ensure the hive system admin matrix user exists, that its token is -/// persisted at [`admin_token_path()`], and that it actually holds admin -/// rights. +/// Ensure the `@hive:` matrix user exists and that its access token is +/// persisted at [`hive_token_path()`]. /// -/// **Nothing here depends on registration order any more.** The account -/// used to have to be the first ever registered, to win tuwunel's -/// automatic first-user grant — a rule that cannot fire for an -/// appservice-created account at all, and one that silently did nothing -/// on a homeserver that already had users. Admin rights now come from an -/// explicit `make_user_admin`: the `admin_execute` entry beside the -/// appservice registration performs it at homeserver startup, and -/// [`ensure_admin_rights`] checks the result and says so when it is -/// missing. +/// **Nothing here depends on registration order, and nothing here is +/// privileged.** The account used to have to be the first ever +/// registered, to win tuwunel's automatic first-user grant — a rule that +/// cannot fire for an appservice-created account at all. It is now an +/// ordinary account: the homeserver creates it because it is the +/// appservice registration's `sender_localpart`, and everything the hive +/// provisions with it, it provisions as the creator of those rooms. /// /// Idempotent — skips the account work when the token file already exists /// and is non-empty. -pub async fn ensure_admin_user(client: &reqwest::Client, as_token: &str) -> Result<()> { +/// +/// The token is taken from the **swarm secret store** when it is there: +/// `swarm-matrix-minter`, the oneshot inside the matrix container, publishes +/// it under an identity of its own, and taking it from there is what lets a +/// hive that holds no `as_token` have an admin at all. The mint ladder below +/// stays as the fallback for a store that is empty, unconfigured or +/// unreachable — which is every swarm whose matrix container predates that +/// minter. +pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Result<()> { use std::os::unix::fs::PermissionsExt; - let path = admin_token_path(); + let path = hive_token_path(); if path.exists() && let Ok(existing) = std::fs::read_to_string(&path) && !existing.trim().is_empty() { - tracing::debug!("matrix: hive admin token already present"); + tracing::debug!("matrix: the @hive: access token is already present"); return Ok(()); } + if let Some(token) = stored_hive_token().await { + return persist_hive_token(&path, &token); + } let password = random_password()?; - let access_token = match register_user(client, HIVE_ADMIN_LOCALPART, as_token, &password).await - { + let access_token = match register_user(client, HIVE_LOCALPART, as_token, &password).await { Ok(token) => { - let pw_path = password_path(HIVE_ADMIN_LOCALPART); + let pw_path = password_path(HIVE_LOCALPART); if let Some(parent) = pw_path.parent() { std::fs::create_dir_all(parent).ok(); } if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) { - tracing::warn!(error = ?e, "matrix: failed to persist hive admin password"); + tracing::warn!(error = ?e, "matrix: failed to persist the @hive: account password"); } else { let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600)); } @@ -931,125 +948,104 @@ pub async fn ensure_admin_user(client: &reqwest::Client, as_token: &str) -> Resu // hive-c0re gets a chance to ask. An appservice login needs // no password, which is just as well since an account the // homeserver created has none. - tracing::info!("matrix: hive admin user already exists, logging in as the appservice"); - match appservice_login(client, as_token, HIVE_ADMIN_LOCALPART).await { + tracing::info!("matrix: the @hive: user already exists, logging in as the appservice"); + match appservice_login(client, as_token, HIVE_LOCALPART).await { Ok(token) => token, Err(e) => { - tracing::warn!(error = ?e, "matrix: appservice login for the hive admin failed; falling back to the stored password"); - let pw_path = password_path(HIVE_ADMIN_LOCALPART); + tracing::warn!(error = ?e, "matrix: appservice login for @hive: failed; falling back to the stored password"); + let pw_path = password_path(HIVE_LOCALPART); let stored = std::fs::read_to_string(&pw_path) .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) .with_context(|| { format!( - "matrix: hive admin user exists, appservice login failed, and no \ + "matrix: the @hive: user exists, appservice login failed, and no \ password is stored at {} — check that the registration file's \ - namespace covers @{HIVE_ADMIN_LOCALPART} and that the homeserver \ + namespace covers @{HIVE_LOCALPART} and that the homeserver \ loaded it", pw_path.display() ) })?; - login_user(client, HIVE_ADMIN_LOCALPART, &stored).await? + login_user(client, HIVE_LOCALPART, &stored).await? } } } Err(other) => return Err(other), }; + persist_hive_token(&path, &access_token) +} + +/// Write the `@hive:` account's access token to `path`, 0600, creating the +/// directory if it is not there. +/// +/// Shared by both arms of [`ensure_hive_user`] rather than duplicated into +/// the store one: the file's mode is the only thing keeping an unprivileged +/// reader off the hive's matrix credential, and a second copy of that decision +/// is one that can be edited alone. +fn persist_hive_token(path: &std::path::Path, access_token: &str) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } - std::fs::write(&path, format!("{access_token}\n")) - .with_context(|| format!("matrix: write hive admin token to {}", path.display()))?; - let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); - tracing::info!(path = %path.display(), "matrix: provisioned hive admin token"); + std::fs::write(path, format!("{access_token}\n")).with_context(|| { + format!( + "matrix: write the @hive: access token to {}", + path.display() + ) + })?; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + tracing::info!(path = %path.display(), "matrix: provisioned the @hive: access token"); Ok(()) } -/// Check that the hive admin account holds admin rights, and repair it -/// through the admin room when it does not. +/// Fetch the `@hive:` access token `swarm-matrix-minter` published, under +/// this hive's own store identity. /// -/// Admin-ness in tuwunel is membership of the admin room, so that is what -/// this reads: the account's own joined-rooms list, which needs no -/// privileges to fetch. When the admin room is in it there is nothing to -/// do and nothing is sent — worth insisting on, because the repair is a -/// message in a room and this runs on every sweep. +/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` — +/// the same role string `workers::credential` logs in with, and already in +/// this process's environment, so the store read costs no plumbing through +/// [`ensure_all`]. No new grant either: a hive's policy already covers the +/// whole `swarm/services/*` tree the minter writes into. /// -/// When it is absent, the repair is attempted anyway (a stale or failed -/// read is cheaper to retry than to reason about) and a failure is -/// reported rather than raised: agent accounts, the hive Space and the -/// chat room all work without an admin, so a hive with an unpromoted -/// admin is degraded, not broken. Only `hivectl matrix promote-user` / -/// `reset-password` need it. +/// `None`, never an error, for every way this can come up empty — no hive +/// name, no `BAO_*` identity, an unreachable store, nothing at the path. All +/// four mean the same thing to the caller ("mint it the old way"), and three +/// of them are the ordinary state of a swarm that has not deployed the minter +/// yet, so raising would turn a supported deployment into a warning every +/// sweep. /// -/// Returns whether the account holds admin rights now. A `false` has -/// already been logged, with what to do about it. -async fn ensure_admin_rights( - client: &reqwest::Client, - admin_token: &str, - server_name: &str, -) -> bool { - let room_id = match discover_admin_room_id(client, admin_token, server_name).await { - Ok(id) => id, +/// 🩸 Logs the store **path** and never the value. +async fn stored_hive_token() -> Option { + let hive = std::env::var("HYPERHIVE_HIVE_NAME") + .ok() + .filter(|h| !h.is_empty())?; + let path = swarm_secret_client::matrix::hive_token_path(); + let store = match swarm_secret_client::SecretStore::from_env(&hive).await { + Ok(store) => store, Err(e) => { - tracing::warn!(error = ?e, "matrix: cannot resolve #admins — not checking the hive admin's rights"); - return false; + tracing::debug!(error = %e, "matrix: no swarm secret store to read the @hive: access token from"); + return None; } }; - match joined_rooms(client, admin_token).await { - Some(rooms) if rooms.iter().any(|r| r == &room_id) => { - tracing::debug!("matrix: hive admin is in the admin room"); - return true; - } - Some(_) => { - tracing::warn!( - "matrix: hive admin is not in the admin room — attempting to promote it" - ); - } - None => tracing::debug!( - "matrix: could not read the hive admin's joined rooms; attempting to promote it" - ), - } - if let Err(e) = - promote_user_to_admin(client, admin_token, HIVE_ADMIN_LOCALPART, server_name).await + match store + .read::(&path) + .await { - // The honest message. Promoting through the admin room requires - // being admin already, so the one lever that works on a - // homeserver with no admin at all is the `admin_execute` entry in - // the hive-matrix module — which runs at startup, which means a - // restart is the fix rather than another sweep. - tracing::warn!( - error = ?e, - "matrix: hive admin '@{HIVE_ADMIN_LOCALPART}' holds no admin rights. \ - The homeserver promotes it at startup (admin_execute in hive-matrix.nix), \ - so `systemctl restart container@hive-matrix` grants it. Agent accounts and \ - room provisioning are unaffected; `hivectl matrix promote-user` and \ - `reset-password` need it." - ); - return false; + Ok(credential) if !credential.value.trim().is_empty() => { + tracing::info!(%path, "matrix: taking the @hive: access token from the swarm store"); + Some(credential.value) + } + Ok(_) => { + tracing::warn!(%path, "matrix: the stored @hive: credential is empty; minting instead"); + None + } + Err(e) => { + tracing::debug!(%path, error = %e, "matrix: no @hive: credential in the store; minting instead"); + None + } } - tracing::info!("matrix: promoted the hive admin through the admin room"); - true -} - -/// The rooms `token`'s account has joined, or `None` when the list could -/// not be read. `None` is deliberately not an empty list: "no rooms" and -/// "no answer" lead to different decisions in [`ensure_admin_rights`]. -async fn joined_rooms(client: &reqwest::Client, token: &str) -> Option> { - let base = matrix_http()?; - let url = format!("{base}/_matrix/client/v3/joined_rooms"); - let resp = client.get(&url).bearer_auth(token).send().await.ok()?; - if !resp.status().is_success() { - return None; - } - let body = resp.json::().await.ok()?; - Some( - body["joined_rooms"] - .as_array()? - .iter() - .filter_map(|r| r.as_str().map(ToOwned::to_owned)) - .collect(), - ) } /// Whether an admin-room reply says a `make-user-admin` succeeded. @@ -1116,16 +1112,16 @@ mod is_make_admin_success_tests { /// intended long-term mechanism, not a stopgap awaiting an upstream fix. pub async fn promote_user_to_admin( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, localpart: &str, server_name: &str, ) -> Result<()> { - let room_id = discover_admin_room_id(client, admin_token, server_name).await?; + let room_id = discover_admin_room_id(client, hive_token, server_name).await?; let room_url = encode_room_id_for_url(&room_id); let command = format!("!admin users make-user-admin @{localpart}:{server_name}"); admin_room_send_and_poll( client, - admin_token, + hive_token, server_name, &room_url, &command, @@ -1151,11 +1147,11 @@ pub async fn promote_user_to_admin( /// Returns the new password for use in subsequent `login_user` calls. pub async fn reset_user_password( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, localpart: &str, server_name: &str, ) -> Result { - let pw = admin_room_reset_password(client, admin_token, server_name, localpart) + let pw = admin_room_reset_password(client, hive_token, server_name, localpart) .await .with_context(|| { format!("matrix: admin-room password reset for @{localpart}:{server_name}") @@ -1205,19 +1201,19 @@ pub async fn discover_server_name(client: &reqwest::Client) -> Result { }) } -/// Read the hive admin access token from disk. Returns an error if it -/// is absent — callers should gate their admin-API calls on this. -pub fn read_admin_token() -> Result { - let path = admin_token_path(); +/// Read the `@hive:` access token from disk. Returns an error if it +/// is absent — callers should gate their homeserver calls on this. +pub fn read_hive_token() -> Result { + let path = hive_token_path(); std::fs::read_to_string(&path) .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) .with_context(|| { format!( - "hive admin matrix token not found at {} — \ + "the @hive: matrix access token was not found at {} — \ ensure hive-c0re has started at least once with matrix enabled \ - (it provisions the admin account on boot)", + (it provisions the @hive: account on boot)", path.display() ) }) @@ -1244,12 +1240,12 @@ fn persist_space_room_id(room_id: &str) -> Result<()> { /// /// Name-based (not alias-based) rediscovery keeps the Space free of any /// special-char room alias — the hardcoded plain name is the anchor. -async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Option { +async fn find_space_by_name(client: &reqwest::Client, hive_token: &str) -> Option { let base = matrix_http()?; let joined_url = format!("{base}/_matrix/client/v3/joined_rooms"); let joined: serde_json::Value = client .get(&joined_url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .send() .await .ok()? @@ -1264,12 +1260,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti let encoded = encode_room_id_for_url(room_id); // Must be an m.space (m.room.create `type`). let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); - let is_space = match client - .get(&create_url) - .bearer_auth(admin_token) - .send() - .await - { + let is_space = match client.get(&create_url).bearer_auth(hive_token).send().await { Ok(r) if r.status().is_success() => r .json::() .await @@ -1282,7 +1273,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti } // …and named HIVE_SPACE_NAME (m.room.name `name`). let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); - let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await { + let name_matches = match client.get(&name_url).bearer_auth(hive_token).send().await { Ok(r) if r.status().is_success() => r .json::() .await @@ -1313,7 +1304,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti /// /// Returns an error if the homeserver is unreachable, `createRoom` fails, /// or the room-ID file cannot be written. -pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> Result { +pub async fn ensure_hive_space(client: &reqwest::Client, hive_token: &str) -> Result { let base = matrix_base()?; // 1. Stored room id wins (fast path). if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) { @@ -1326,7 +1317,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> R // 2. No stored id — rediscover the existing space by its hardcoded name // before creating a new one (prevents duplicate spaces after a wipe). - if let Some(room_id) = find_space_by_name(client, admin_token).await { + if let Some(room_id) = find_space_by_name(client, hive_token).await { persist_space_room_id(&room_id)?; tracing::info!(%room_id, "matrix: recovered hive space by name"); return Ok(room_id); @@ -1342,7 +1333,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> R }); let resp = client .post(&url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .json(&body) .send() .await @@ -1382,12 +1373,12 @@ fn persist_chat_room_id(room_id: &str) -> Result<()> { /// [`find_space_by_name`] so a lost room-id file recovers the existing chat /// room instead of spawning a duplicate. `None` if the homeserver is /// unreachable or no match exists. -async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> Option { +async fn find_chat_room_by_name(client: &reqwest::Client, hive_token: &str) -> Option { let base = matrix_http()?; let joined_url = format!("{base}/_matrix/client/v3/joined_rooms"); let joined: serde_json::Value = client .get(&joined_url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .send() .await .ok()? @@ -1402,12 +1393,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> let encoded = encode_room_id_for_url(room_id); // Skip the Space itself (and any other m.space). let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); - let is_space = match client - .get(&create_url) - .bearer_auth(admin_token) - .send() - .await - { + let is_space = match client.get(&create_url).bearer_auth(hive_token).send().await { Ok(r) if r.status().is_success() => r .json::() .await @@ -1420,7 +1406,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> } // …and named HIVE_CHAT_ROOM_NAME (m.room.name `name`). let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); - let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await { + let name_matches = match client.get(&name_url).bearer_auth(hive_token).send().await { Ok(r) if r.status().is_success() => r .json::() .await @@ -1460,10 +1446,10 @@ fn state_needs_write(current: Option<&serde_json::Value>, desired: &serde_json:: /// too, loudly — and a 404 is the expected first-setup case. async fn current_room_state( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, url: &str, ) -> Option { - let resp = match client.get(url).bearer_auth(admin_token).send().await { + let resp = match client.get(url).bearer_auth(hive_token).send().await { Ok(resp) => resp, Err(e) => { tracing::debug!(error = ?e, url, "matrix: state read unreachable; writing"); @@ -1491,7 +1477,7 @@ async fn current_room_state( } } -/// PUT a state event into `room_id` using the admin token, **skipping the +/// PUT a state event into `room_id` using the `@hive:` token, **skipping the /// write when the room already carries identical content**. /// /// The read is not an optimisation. A PUT of identical content is a no-op @@ -1506,7 +1492,7 @@ async fn current_room_state( /// missing or divergent link still gets written. async fn set_room_state( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, room_id: &str, event_type: &str, state_key: &str, @@ -1517,7 +1503,7 @@ async fn set_room_state( let encoded_key = encode_room_id_for_url(state_key); let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}"); - let current = current_room_state(client, admin_token, &url).await; + let current = current_room_state(client, hive_token, &url).await; if !state_needs_write(current.as_ref(), content) { tracing::debug!( %room_id, @@ -1528,7 +1514,7 @@ async fn set_room_state( } let resp = client .put(&url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .json(content) .send() .await @@ -1565,7 +1551,7 @@ async fn set_room_state( /// link is logged but not fatal (the room still exists + is joinable). pub async fn ensure_hive_chat_room( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, space_room_id: &str, server_name: &str, ) -> Result { @@ -1579,7 +1565,7 @@ pub async fn ensure_hive_chat_room( { tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned"); id - } else if let Some(id) = find_chat_room_by_name(client, admin_token).await { + } else if let Some(id) = find_chat_room_by_name(client, hive_token).await { persist_chat_room_id(&id)?; tracing::info!(room_id = %id, "matrix: recovered hive chat room by name"); id @@ -1619,7 +1605,7 @@ pub async fn ensure_hive_chat_room( }); let resp = client .post(&url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .json(&body) .send() .await @@ -1650,7 +1636,7 @@ pub async fn ensure_hive_chat_room( }); if let Err(e) = set_room_state( client, - admin_token, + hive_token, space_room_id, "m.space.child", &room_id, @@ -1668,21 +1654,21 @@ pub async fn ensure_hive_chat_room( /// account. Idempotent — treats already-member responses as success. async fn invite_to_room( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, room_id: &str, localpart: &str, server_name: &str, ) -> Result<()> { let user_id = format!("@{localpart}:{server_name}"); - invite_user_id(client, admin_token, room_id, &user_id).await + invite_user_id(client, hive_token, room_id, &user_id).await } -/// Fetch a user's current membership in a room via the admin token, or +/// Fetch a user's current membership in a room via the `@hive:` token, or /// `None` if there is no membership event (never invited) or the lookup /// fails. Returns the raw membership string (`invite`, `join`, `leave`, …). async fn room_membership( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, encoded_room_id: &str, user_id: &str, ) -> Option { @@ -1693,12 +1679,7 @@ async fn room_membership( let url = format!( "{base}/_matrix/client/v3/rooms/{encoded_room_id}/state/m.room.member/{encoded_user}" ); - let resp = client - .get(&url) - .bearer_auth(admin_token) - .send() - .await - .ok()?; + let resp = client.get(&url).bearer_auth(hive_token).send().await.ok()?; if !resp.status().is_success() { // 404 = no membership event yet; anything else we treat as "unknown" // and let the caller fall through to the invite attempt. @@ -1709,13 +1690,13 @@ async fn room_membership( } /// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id` -/// using the admin token. Idempotent: a user who is already a member or +/// using the `@hive:` token. Idempotent: a user who is already a member or /// already has a pending invite is left untouched (no fresh invite is sent, /// so they are not re-notified), and a 403 `M_FORBIDDEN` / `M_BAD_STATE` /// from a racing invite is still treated as success. async fn invite_user_id( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, room_id: &str, user_id: &str, ) -> Result<()> { @@ -1727,7 +1708,7 @@ async fn invite_user_id( // Skip the invite entirely when the user is already invited or joined. // Re-POSTing an invite to a pending member re-sends the invite event, // which re-notifies the agent on every provisioning sweep. - if let Some(membership) = room_membership(client, admin_token, &encoded_room_id, user_id).await + if let Some(membership) = room_membership(client, hive_token, &encoded_room_id, user_id).await && matches!(membership.as_str(), "invite" | "join") { tracing::debug!(%user_id, %room_id, %membership, "matrix: invite skipped (already a member/invited)"); @@ -1737,7 +1718,7 @@ async fn invite_user_id( let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room_id}/invite"); let resp = client .post(&url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .json(&serde_json::json!({ "user_id": user_id })) .send() .await @@ -1770,13 +1751,13 @@ async fn invite_user_id( /// /// # Errors /// -/// Returns an error if the admin token or `server_name` can't be read, +/// Returns an error if the `@hive:` token or `server_name` can't be read, /// the target room can't be resolved (no `--room` and no persisted hive /// space), or the invite POST fails for a reason other than the user /// already being a member / invited. pub async fn invite_user( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, user: &str, room_override: Option<&str>, server_name: &str, @@ -1790,7 +1771,7 @@ pub async fn invite_user( // Resolve the room: explicit override (id or #alias) wins; otherwise // the persisted hive Space. let room_id = match room_override { - Some(r) if r.starts_with('#') => resolve_room_alias(client, admin_token, r).await?, + Some(r) if r.starts_with('#') => resolve_room_alias(client, hive_token, r).await?, Some(r) if r.starts_with('!') => r.to_owned(), Some(r) => anyhow::bail!( "matrix: --room {r:?} is neither a room id nor an alias; \ @@ -1804,14 +1785,14 @@ pub async fn invite_user( (run the hive-c0re matrix sweep first)", )?, }; - invite_user_id(client, admin_token, &room_id, &user_id).await?; + invite_user_id(client, hive_token, &room_id, &user_id).await?; Ok(room_id) } /// Resolve a `#alias:server` to its room id via the directory API. async fn resolve_room_alias( client: &reqwest::Client, - admin_token: &str, + hive_token: &str, alias: &str, ) -> Result { let base = matrix_base()?; @@ -1819,7 +1800,7 @@ async fn resolve_room_alias( let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}"); let resp = client .get(&url) - .bearer_auth(admin_token) + .bearer_auth(hive_token) .send() .await .with_context(|| format!("matrix: GET directory for {alias}"))?; @@ -1875,13 +1856,12 @@ pub async fn ensure_all() -> bool { return false; } }; - // The hive admin first, because everything below provisions THROUGH - // it (the Space, the chat room and every invite are sent with its - // token). Not, any more, so that it wins a first-registered-user - // grant: it holds admin rights by explicit promotion, checked in - // `provision_space` once the server name is known. - if let Err(e) = ensure_admin_user(&client, &as_token).await { - tracing::warn!(error = ?e, "matrix: ensure_admin_user failed"); + // The `@hive:` account first, because everything below provisions + // THROUGH it (the Space, the chat room and every invite are sent with + // its token) — as an ordinary user that created those rooms, not as a + // homeserver admin. + if let Err(e) = ensure_hive_user(&client, &as_token).await { + tracing::warn!(error = ?e, "matrix: ensure_hive_user failed"); ok = false; } let Ok(containers) = crate::lifecycle::list().await else { @@ -1911,10 +1891,10 @@ pub async fn ensure_all() -> bool { async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bool { let mut ok = true; // server_name is needed to form full Matrix user IDs for invites. - let admin_token = match read_admin_token() { + let hive_token = match read_hive_token() { Ok(t) => t, Err(e) => { - tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no admin token)"); + tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no @hive: access token)"); return false; } }; @@ -1926,33 +1906,22 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo return false; } }; - // Before the rooms: the one thing in this sweep that is about the - // admin account itself rather than about what it provisions. - if !ensure_admin_rights(client, &admin_token, &server_name).await { - ok = false; - } - let room_id = match ensure_hive_space(client, &admin_token).await { + let room_id = match ensure_hive_space(client, &hive_token).await { Ok(id) => id, Err(e) => { tracing::warn!(error = ?e, "matrix: ensure_hive_space failed"); return false; } }; - // Invite @hive admin first, then all agents. - if let Err(e) = invite_to_room( - client, - &admin_token, - &room_id, - HIVE_ADMIN_LOCALPART, - &server_name, - ) - .await + // Invite @hive first, then all agents. + if let Err(e) = + invite_to_room(client, &hive_token, &room_id, HIVE_LOCALPART, &server_name).await { tracing::warn!(error = ?e, "matrix: invite @hive to space failed"); ok = false; } for name in agent_names { - if let Err(e) = invite_to_room(client, &admin_token, &room_id, name, &server_name).await { + if let Err(e) = invite_to_room(client, &hive_token, &room_id, name, &server_name).await { tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed"); ok = false; } @@ -1963,13 +1932,13 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo // rooms to chat in (Matrix semantics — children aren't auto-joined), so // without this the Space is empty. The restricted join rule additionally // lets the operator (a Space member) join from the Space hierarchy. - match ensure_hive_chat_room(client, &admin_token, &room_id, &server_name).await { + match ensure_hive_chat_room(client, &hive_token, &room_id, &server_name).await { Ok(chat_room_id) => { if let Err(e) = invite_to_room( client, - &admin_token, + &hive_token, &chat_room_id, - HIVE_ADMIN_LOCALPART, + HIVE_LOCALPART, &server_name, ) .await @@ -1979,7 +1948,7 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo } for name in agent_names { if let Err(e) = - invite_to_room(client, &admin_token, &chat_room_id, name, &server_name).await + invite_to_room(client, &hive_token, &chat_room_id, name, &server_name).await { tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed"); ok = false; diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index 5ad8066b..72ebb64f 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -2,7 +2,7 @@ //! `/run/hyperhive` + `/run/hive-agent` runtime roots). //! //! Historically these were flat string literals scattered across many -//! modules (`broker.sqlite`, `matrix-admin-token`, `agent-sockets.json`, +//! modules (`broker.sqlite`, `matrix-hive-token`, `agent-sockets.json`, //! …). This module is the **single Rust-side source** for every host //! path — the strictly host-side ones grouped into subdirs (`db/`, //! `forge/`, `matrix/`, `run/`), plus the **nix-coupled** roots @@ -128,7 +128,7 @@ pub fn agent_identity_dir(name: &str) -> PathBuf { agent_identity_root().join(name) } -/// `matrix/` — host-side matrix provisioning state (admin token, hive +/// `matrix/` — host-side matrix provisioning state (the `@hive:` access token, hive /// Space room id, per-agent password creds). The shared registration /// token is bind-mounted into the tuwunel container via nix and stays /// at its own path (tracked separately). @@ -137,10 +137,10 @@ pub fn matrix_dir() -> PathBuf { state_root().join("matrix") } -/// `matrix/admin-token` — hive system admin access token. +/// `matrix/access-token` — the `@hive:` account's matrix access token. #[must_use] -pub fn matrix_admin_token() -> PathBuf { - matrix_dir().join("admin-token") +pub fn matrix_hive_token() -> PathBuf { + matrix_dir().join("access-token") } /// `matrix/space-room-id` — persisted hive Space room id. @@ -327,7 +327,7 @@ pub fn relocate_legacy_state() { "forge-agent-configs-avatar-set", forge_config_org_avatar_marker(), ), - ("matrix-admin-token", matrix_admin_token()), + ("matrix-hive-token", matrix_hive_token()), ("matrix-space-room-id", matrix_space_room_id()), ("matrix-creds", matrix_creds_dir()), ("agent-sockets.json", agent_sockets_file()), diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 803682e2..339be2fb 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -455,7 +455,7 @@ async fn stream_agent_status( // The `hivectl matrix` subcommands used to run these in-process, which forced // the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …). // They now run daemon-side over the host socket: the daemon already holds the -// register + admin tokens and the matrix creds dir. Each op returns the +// register + `@hive:` tokens and the matrix creds dir. Each op returns the // operator-facing lines hivectl used to `println!` in `HostResponse::messages` // for the client to print verbatim. // --------------------------------------------------------------------------- @@ -777,14 +777,14 @@ async fn handle_matrix_sync_admin() -> Result { let as_token = crate::matrix::read_appservice_token().context("read matrix appservice token")?; let client = matrix_http_client()?; - crate::matrix::ensure_admin_user(&client, &as_token) + crate::matrix::ensure_hive_user(&client, &as_token) .await .context("matrix sync-admin")?; - let path = crate::matrix::admin_token_path(); + let path = crate::matrix::hive_token_path(); Ok(HostResponse::messages(vec![ format!( - "matrix: hive admin user '@{}' provisioned", - crate::matrix::HIVE_ADMIN_LOCALPART + "matrix: the @{}: user is provisioned", + crate::matrix::HIVE_LOCALPART ), format!("token persisted at: {}", path.display()), ])) @@ -792,12 +792,12 @@ async fn handle_matrix_sync_admin() -> Result { async fn handle_matrix_promote_user(name: &str) -> Result { require_matrix_present()?; - let admin_token = crate::matrix::read_admin_token()?; + let hive_token = crate::matrix::read_hive_token()?; let client = matrix_http_client()?; let server_name = crate::matrix::discover_server_name(&client) .await .context("discover matrix server_name")?; - crate::matrix::promote_user_to_admin(&client, &admin_token, name, &server_name) + crate::matrix::promote_user_to_admin(&client, &hive_token, name, &server_name) .await .with_context(|| format!("matrix promote-user {name}"))?; Ok(HostResponse::messages(vec![format!( @@ -807,12 +807,12 @@ async fn handle_matrix_promote_user(name: &str) -> Result { async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result { require_matrix_present()?; - let admin_token = crate::matrix::read_admin_token()?; + let hive_token = crate::matrix::read_hive_token()?; let client = matrix_http_client()?; let server_name = crate::matrix::discover_server_name(&client) .await .context("discover matrix server_name")?; - let room_id = crate::matrix::invite_user(&client, &admin_token, user, room, &server_name) + let room_id = crate::matrix::invite_user(&client, &hive_token, user, room, &server_name) .await .with_context(|| format!("matrix invite {user}"))?; let target = if user.starts_with('@') { @@ -827,12 +827,12 @@ async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result Result { require_matrix_present()?; - let admin_token = crate::matrix::read_admin_token()?; + let hive_token = crate::matrix::read_hive_token()?; let client = matrix_http_client()?; let server_name = crate::matrix::discover_server_name(&client) .await .context("discover matrix server_name")?; - crate::matrix::reset_user_password(&client, &admin_token, name, &server_name) + crate::matrix::reset_user_password(&client, &hive_token, name, &server_name) .await .with_context(|| format!("matrix reset-password {name}"))?; // Password is persisted by reset_user_password. diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index 12a81b75..aa48c444 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -335,10 +335,10 @@ pub enum MatrixCmd { #[arg(long, conflicts_with = "password")] password_stdin: bool, }, - /// Provision (or re-provision) the hive system admin matrix account. + /// Provision (or re-provision) the hive's own `@hive:` matrix account. /// /// Runs automatically on startup; run manually to recover a missing - /// admin token. + /// access token. SyncAdmin, /// Promote a matrix user to homeserver admin. PromoteUser { diff --git a/hivectl/src/matrix.rs b/hivectl/src/matrix.rs index a75ce7bf..32c31ebc 100644 --- a/hivectl/src/matrix.rs +++ b/hivectl/src/matrix.rs @@ -1,5 +1,5 @@ //! `hivectl matrix` — matrix account provisioning verbs. hivectl forwards -//! each request to the daemon (which owns the register + admin tokens and +//! each request to the daemon (which owns the register + `@hive:` tokens and //! the matrix creds dir) and renders the reply; it no longer links the //! matrix machinery itself. @@ -28,7 +28,7 @@ pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()> /// Send a matrix provisioning request to the daemon and print the /// operator-facing result lines it returns. The daemon owns the register + -/// admin tokens and the matrix creds dir, so hivectl no longer links the +/// `@hive:` tokens and the matrix creds dir, so hivectl no longer links the /// matrix machinery — it just forwards the request and renders the reply. async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> { let resp = crate::client::request(socket, req) diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index 0161ea2d..14f62bf1 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -27,6 +27,7 @@ ./glue-controller-bao-identity.nix ./glue-grafana-oidc-client.nix ./glue-matrix-bao-token.nix + ./glue-matrix-minter-bao-identity.nix ./glue-queue-agent-credential.nix ./glue-secret-publisher-bao-identity.nix ./glue-swarm-otel-oidc-client.nix diff --git a/nix/host-modules/glue-bao-tls.nix b/nix/host-modules/glue-bao-tls.nix index 419f9c63..88eb78b7 100644 --- a/nix/host-modules/glue-bao-tls.nix +++ b/nix/host-modules/glue-bao-tls.nix @@ -147,6 +147,19 @@ in # to be this one whenever the store has a host of its own. [ -s ${pkiDir}/secret-publisher.pem ] || ${signLeaf} ${pkiDir} secret-publisher \ ${lib.escapeShellArg deployCfg.bao.secretPublisherCommonName} "" clientAuth + + # The matrix container's minter. Minted unconditionally like the two + # above, and for the third variant of the same reason: the homeserver + # is a swarm singleton, so on every hive but the one running it this + # leaf is the file an operator copies rather than a file anything + # local reads. + # + # Its own CN, not the reader's: the reader's leaf carries this hive's + # name and its policy reads the whole store, while this principal may + # only write one path — which is the entire point of giving the + # container an identity instead of lending it the hive's. + [ -s ${pkiDir}/matrix-minter.pem ] || ${signLeaf} ${pkiDir} matrix-minter \ + ${lib.escapeShellArg deployCfg.bao.matrixMinterCommonName} "" clientAuth ''; }; }; diff --git a/nix/host-modules/glue-matrix-minter-bao-identity.nix b/nix/host-modules/glue-matrix-minter-bao-identity.nix new file mode 100644 index 00000000..2274b4cc --- /dev/null +++ b/nix/host-modules/glue-matrix-minter-bao-identity.nix @@ -0,0 +1,40 @@ +# Glue: point the matrix container's minter at the bao leaf minted for it. +# +# ONE PAIRING PER FILE — minter ← bao, and nothing else. Deleting this leaves a +# minter that takes operator-provided certificate paths, which is what any +# deployment not minting its own already does. +# +# ⚠️ The minting is NOT here. ./glue-bao-tls.nix holds the CA and signs the +# leaf, because the thing that owns a private key owns issuing from it. What +# belongs here is the pairing: which paths the minter presents. +# +# ⚠️ Gated on the leaf existing, not on the store being enabled — the same rule +# ./glue-secret-publisher-bao-identity.nix states, and it bites harder here: a +# swarm runs ONE homeserver, so the hive hosting it is the one least likely to +# also be the hive hosting the store. +# +# Everything is `mkDefault`. An operator naming their own paths wins. +{ + lib, + config, + ... +}: +let + hyperhiveCfg = config.services.hyperhive; + deployCfg = hyperhiveCfg.deploy; + baoDeploy = deployCfg.bao; + + # Where ./glue-bao-tls.nix puts the leaves, derived from the reader's own path + # rather than repeating that file's directory literal: an operator who moves + # the PKI moves both, and the two cannot drift apart. + haveMintedPki = baoDeploy.clientCertFile != null; + pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null; +in +{ + config = lib.mkIf (hyperhiveCfg.enable && deployCfg.matrix.enable && haveMintedPki) { + services.hyperhive.deploy.matrix = { + minterBaoClientCertFile = lib.mkDefault "${pkiDir}/matrix-minter.pem"; + minterBaoClientKeyFile = lib.mkDefault "${pkiDir}/matrix-minter-key.pem"; + }; + }; +} diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index eeb72d16..db0817de 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -26,6 +26,11 @@ let # `null/.well-known/…`. autheliaCfg = config.services.hyperhive.swarm.authelia; deployCfg = config.services.hyperhive.deploy; + + # Where the swarm's secret store answers. Swarm-tier, identical on every + # host, which is what lets the container address it without knowing whether + # it stands here. + baoCfg = config.services.hyperhive.swarm.bao; autheliaUrl = autheliaCfg.url; # The all-local case: this host runs BOTH the homeserver and the swarm's @@ -59,18 +64,19 @@ let # namespace below, with no shared registration secret in the picture. appserviceId = "hyperhive"; - # The appservice's own user, and deliberately the hive admin account. - # Loading a registration CREATES its `sender_localpart` user when absent - # (tuwunel `src/service/appservice/mod.rs`), on a zero-user database, - # inside `Services::start()` — before the HTTP listener accepts anything. - # That is what lets the `admin_execute` promotion below land on the very - # first boot of a fresh homeserver, instead of depending on hive-c0re - # racing to register the first account and win the auto-admin grant. + # The appservice's own user, and the account the hive acts as. An + # ordinary user, not a homeserver admin: everything the hive does with + # it — the Space, the chat room, the invites — it does as the creator of + # those rooms. Loading a registration CREATES its `sender_localpart` user + # when absent (tuwunel `src/service/appservice/mod.rs`), on a zero-user + # database, inside `Services::start()` — before the HTTP listener accepts + # anything, so the account exists on the very first boot of a fresh + # homeserver without hive-c0re racing to register it. # - # ⚠️ Must equal `matrix::HIVE_ADMIN_LOCALPART` in hive-c0re, which derives + # ⚠️ Must equal `matrix::HIVE_LOCALPART` in hive-c0re, which derives # it independently with nothing wiring an override across — same # agreement, and same reason for saying so, as the token path below. - adminLocalpart = "hive"; + hiveLocalpart = "hive"; # The `as_token`, and the `hs_token` the spec requires alongside it. Both # minted by the render script below, mode 0600; the `as_token` is the one @@ -107,7 +113,62 @@ let appserviceCredentialId = "${appserviceId}-appservice.yaml"; appserviceCredentialDir = "/run/credentials/tuwunel.service"; - # Every local user this hive may provision — agents, the hive admin, and + # ── swarm-matrix-minter ──────────────────────────────────────────────── + # + # The oneshot that publishes the `@hive:` account's access token to + # the swarm's secret store. It runs INSIDE the container, beside tuwunel, + # because the appservice token that authorises the mint is already in here — + # `appserviceDir` below is bound read-only precisely so the homeserver can + # load it — and minting anywhere else would create a second holder of that + # secret, which is the thing this whole arrangement exists to stop. + # + # Gated on the identity, not on `deploy.bao.enable`: a swarm's ONE homeserver + # is the host least likely to also be the host running the store, so + # "co-located with bao" would leave the intended deployment silently minting + # nothing. Same rule ./swarm-secret-publisher.nix's `haveClientIdentity` + # states, for a sharper reason. + minterActive = + deployCfg.matrix.minterBaoClientCertFile != null && deployCfg.matrix.minterBaoClientKeyFile != null; + + # The role on the store's `cert` auth mount, and so the single source of the + # string both ends must agree on: ./swarm-bao.nix writes the role under + # `matrixMinterPolicyName` and this hands it to the binary in the + # environment, so the binary itself spells no role at all. + minterCertRole = "swarm-matrix-minter"; + + # Every host directory the minter's bao identity is spread across. Normally + # one — ./glue-bao-tls.nix puts all three files in ./glue's PKI dir — but + # derived rather than assumed, because an operator naming paths by hand is + # exactly the deployment the gate above is written for. + # + # Directories rather than the files, for the reason `appserviceDir`'s own + # comment gives: a re-issued leaf is a new inode, and binding the file would + # pin the one the container saw when it started. + minterPkiDirs = lib.optionals minterActive ( + lib.unique ( + map builtins.dirOf ( + [ + deployCfg.matrix.minterBaoClientCertFile + deployCfg.matrix.minterBaoClientKeyFile + ] + ++ lib.optional (deployCfg.bao.serverCaFile != null) deployCfg.bao.serverCaFile + ) + ) + ); + + minterBindMounts = lib.genAttrs minterPkiDirs (dir: { + hostPath = dir; + isReadOnly = true; + }); + + # Where a reader of the published credential is told the token is good for. + # Empty when this hive serves no vhost: `matrix::Credential.homeserver` is an + # `Option`, and the minter reads an empty variable as absent rather than as + # the string "null" — which is what a hive with no gateway host actually + # knows about itself. + minterHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}"; + + # Every local user this hive may provision — agents, `@hive:` itself, and # the operator accounts `hivectl matrix create-user` makes, which is the # whole matrix localpart charset. # @@ -173,7 +234,7 @@ let cat <<'REGISTRATION' id: ${appserviceId} url: null - sender_localpart: ${adminLocalpart} + sender_localpart: ${hiveLocalpart} rate_limited: false namespaces: users: @@ -686,6 +747,56 @@ in match authelia's register. ''; }; + + minterPackage = lib.mkOption { + type = lib.types.package; + defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-matrix-minter"; + description = '' + The `swarm-matrix-minter` build run inside the matrix container. + + ⚠️ Named `minterPackage`, not folded into `package` above: that one is + the homeserver, and this is a hyperhive binary that happens to run + beside it. Same split, and same reason, as + {option}`services.hyperhive.deploy.nats.authPackage`. + ''; + }; + + minterBaoClientCertFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Client certificate the matrix container's minter presents to the + swarm's secret store. Its subject must be + {option}`services.hyperhive.deploy.bao.matrixMinterCommonName` — cert + auth matches on the CN, and the role accepts nothing else. + + ⚠️ **Not the hive's own leaf**, and that is the whole deliverable of + giving this container an identity: the hive's certificate reads every + secret in the store, while this one may write a single path. Pointing + this at `deploy.bao.clientCertFile` would evaluate, deploy and work — + and give away the separation in one line. + + No default: a module that guessed would be holding the CA opinion + ./swarm-bao.nix deliberately does not hold. + ./glue-matrix-minter-bao-identity.nix points it at the leaf + ./glue-bao-tls.nix mints, where this host mints one. + + The file and its key are bind-mounted into the container read-only. + Co-located in the hive's filesystem and therefore readable by hive + **root** — accepted: the boundary this buys is identity (no hive + *process* holds the appservice token), not physical isolation. + ''; + }; + + minterBaoClientKeyFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Private key for + {option}`services.hyperhive.deploy.matrix.minterBaoClientCertFile`. + Both or neither — the minter unit does not exist unless each is set. + ''; + }; }; config = lib.mkIf deployCfg.matrix.enable { @@ -1082,7 +1193,11 @@ in isReadOnly = true; }; } - // caTrust.bindMount; + // caTrust.bindMount + # The minter's bao client identity, and nothing else of the hive's. See + # `minterPkiDirs` above for why it is a derived set of directories + # rather than one literal. + // minterBindMounts; config = { ... }: { @@ -1192,30 +1307,21 @@ in # `Services::start()`, before the listener accepts anything. appservice_dir = appserviceCredentialDir; - # The zero-user bootstrap, and the only thing here that needs - # no account to already exist. These run after startup and - # BEFORE the HTTP listener, with no sender and no permission - # check — which is what makes them the one lever that can - # promote the hive admin on a homeserver where nobody is admin - # yet. The appservice registration above creates - # `@${adminLocalpart}` as its sender user moments earlier; - # this joins it to the admin room at power level 100. + # No `admin_execute` promotion for `@${hiveLocalpart}`. The + # hive's account is an ordinary user: it creates the hive + # Space and chat room and invites agents into them, all of + # which ride on being the rooms' own creator at power level + # 100, and none of which is a homeserver-admin capability. + # Granting it server admin at boot would hand a credential + # that every hive reads far more than the work needs. # - # Idempotent by upstream's own guard: `make_user_admin` - # short-circuits when the user is already joined at 100, so a - # hive that has had an admin for months emits nothing. - admin_execute = [ - "users make-user-admin @${adminLocalpart}:${effectiveServerName}" - ]; - - # ⚠️ Load-bearing, not tidiness. An `admin_execute` command - # that fails aborts startup outright when this is false — so - # the one boot where the promotion cannot work (a homeserver - # that has no `@${adminLocalpart}` and no appservice user yet, - # e.g. a registration file that arrived late) would take the - # homeserver down with it rather than converging on the next - # start. - admin_execute_errors_ignore = true; + # The two operations that do need an admin sender — + # `!admin users make-user-admin` and + # `!admin users reset-password`, both messages into + # `#admins:${effectiveServerName}` — therefore have no + # working sender here. They are swarm-level operations and + # are being rehomed as such; until then they fail, loudly, + # rather than being served by an over-privileged token. # Server-side E2EE is opt-in (default off); the agent matrix # client always supports decryption regardless. allow_encryption = cfg.allowEncryption; @@ -1286,6 +1392,57 @@ in "oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}" ]; + # Publish the `@hive:` account's access token to the swarm + # store, once, under an identity that belongs to this container and + # not to the hive. See `minterActive` above for why it runs here. + # + # A `oneshot` with no timer and no retry loop of its own: the whole + # of "and only once" is the binary's first act, a read of the path it + # would write. `Restart=on-failure` covers a store that is sealed or + # a homeserver still starting; `RemainAfterExit` is deliberately NOT + # set, because the unit having succeeded is not the idempotency + # record — the store is, and it outlives this machine. + systemd.services.swarm-matrix-minter = lib.mkIf minterActive { + description = "publish the @hive: matrix credential to the swarm secret store"; + # Ordered after the homeserver because both of the ladder's arms + # are client-server API calls. `wants`, not `requires`: a run that + # finds the credential already published never touches tuwunel at + # all, so a homeserver that is slow to come up should delay this, + # not cancel it. + after = [ "tuwunel.service" ]; + wants = [ "tuwunel.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${deployCfg.matrix.minterPackage}/bin/swarm-matrix-minter"; + Restart = "on-failure"; + RestartSec = 30; + # Bounded here rather than left to systemd's default, for the + # reason ./swarm-secret-publisher.nix states: a sealed store + # answers on the port and never answers the read. + TimeoutStartSec = 60; + SyslogIdentifier = "swarm-matrix-minter"; + }; + environment = { + BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}"; + BAO_CLIENT_CERT = deployCfg.matrix.minterBaoClientCertFile; + BAO_CLIENT_KEY = deployCfg.matrix.minterBaoClientKeyFile; + MATRIX_MINTER_CERT_ROLE = minterCertRole; + # Loopback: this container shares the host netns, so the + # homeserver it must talk to is the one in this very unit's + # netns and needs no name, no vhost and no TLS. + MATRIX_MINTER_API_URL = "http://127.0.0.1:${toString cfg.httpPort}"; + # The bind-mounted registration, which IS the as_token. A path, + # never a value. + MATRIX_MINTER_REGISTRATION = appserviceRegistrationPath; + MATRIX_MINTER_LOCALPART = hiveLocalpart; + MATRIX_MINTER_HOMESERVER = minterHomeserverUrl; + } + // lib.optionalAttrs (deployCfg.bao.serverCaFile != null) { + BAO_CACERT = deployCfg.bao.serverCaFile; + }; + }; + environment.systemPackages = [ deployCfg.matrix.package ]; }; }; diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 762cab99..2cf2db26 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -236,6 +236,35 @@ let } ''; + # The identity the matrix container's minter presents. Named outside `hive-*` + # for the reason its two siblings above give — the controller may rewrite + # every policy under that prefix, and a policy it can rewrite constrains + # nothing. + matrixMinterPolicyName = "swarm-matrix-minter"; + matrixMinterCn = baoDeploy.matrixMinterCommonName; + + # ONE path, and every narrowing in it is load-bearing. + # + # `secret/data/` is KV v2's ACL prefix, inserted by the engine rather than + # written by the caller — the same trap as the two grants above. + # + # Not `swarm/services/*` like the publisher's: this principal produces + # exactly one secret, the `@hive:` account's access token, and a + # homeserver is not entitled to overwrite Grafana's OIDC client. The path is + # spelled to the leaf for that reason, not for tidiness. + # + # `read` as well as write, unlike either sibling, and it is what makes "and + # only once" mechanical: the minter's first act is to read this path back and + # stop if something is there, so without the capability every container + # restart would mint a second access token and invalidate the hive's. A read + # here recovers one secret this principal itself wrote, which is a much + # narrower grant than the publisher's would have been. + matrixMinterPolicyText = '' + path "${credentialMountPath}/data/swarm/services/matrix/hive-access-token" { + capabilities = ["create", "update", "read"] + } + ''; + # The KV v2 engine the controller writes agent credentials through. Named # once because the grant above and the `secrets enable` in the bootstrap unit # have to agree: a policy pointing at a mount nobody created is precisely the @@ -659,6 +688,29 @@ in ''; }; + matrixMinterCommonName = lib.mkOption { + type = lib.types.str; + default = "swarm-matrix-minter"; + example = "swarm-matrix-minter.svc"; + description = '' + Subject the store's matrix-minter cert-auth role accepts — the + identity the oneshot inside the matrix container presents when it + publishes the `@hive:` account's access token. + + A **third** identity rather than reuse of either sibling above, and + the narrowest of the three: its grant is one path, that + credential itself. The point of the whole arrangement is that the + process holding the appservice token is not the hive, so handing it + the hive's own leaf — which reads every secret in the store — would + give the separation away in one line. + + ⚠️ Same collision as its siblings, and the same answer: ./swarm.nix + feeds this value into the guard on + {option}`services.hyperhive.swarm.hives`, so a hive named after it + fails evaluation rather than silently receiving the minter's grant. + ''; + }; + serverCaFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; @@ -1222,6 +1274,54 @@ in ''; }; + # A THIRD sibling, for the reason the second one's comment gives: these + # unit names are operator-facing strings, and the grant written here + # belongs to a principal neither of the other two names. Same `after` + # rather than `requires`, and for the same stated reason — the first unit + # creates the mounts this one writes into, but a failed oneshot still + # counts as finished, so only ordering plus this unit's own retry + # converges. + systemd.services.swarm-bao-matrix-minter-policy = lib.mkIf haveBootstrapToken { + description = "write the swarm matrix minter's bao policy and cert-auth role"; + after = [ + "container@${cfg.machine}.service" + "swarm-bao-controller-policy.service" + ]; + wantedBy = [ "multi-user.target" ]; + path = [ + baoCli + pkgs.coreutils + ]; + unitConfig.ConditionPathExists = baoDeploy.bootstrapTokenFile; + # Same unseal wait as its two siblings above, for the reason stated + # there: under `seal = "shamir"` a human unseals by hand. + startLimitBurst = 2880; + startLimitIntervalSec = 90000; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + Restart = "on-failure"; + RestartSec = 30; + }; + script = '' + set -euo pipefail + + BAO_TOKEN="$(cat ${lib.escapeShellArg baoDeploy.bootstrapTokenFile})" + export BAO_TOKEN + + printf '%s' ${lib.escapeShellArg matrixMinterPolicyText} | + bao policy write ${lib.escapeShellArg matrixMinterPolicyName} - + '' + + lib.optionalString (baoDeploy.clientCaFile != null) '' + + bao write auth/cert/certs/${lib.escapeShellArg matrixMinterPolicyName} \ + certificate=@${tlsDir}/client-ca.pem \ + allowed_common_names=${lib.escapeShellArg matrixMinterCn} \ + token_policies=${lib.escapeShellArg matrixMinterPolicyName} \ + display_name=${lib.escapeShellArg matrixMinterCn} + ''; + }; + containers.${cfg.machine} = { autoStart = true; ephemeral = false; diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 0625d336..118bd261 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -46,6 +46,7 @@ let certAuthCns = [ deployCfg.bao.controllerCommonName deployCfg.bao.secretPublisherCommonName + deployCfg.bao.matrixMinterCommonName ]; # Public hostnames of the swarm's own services, in declaration order. diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index 256b1cee..dcb6830c 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -140,6 +140,56 @@ let baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy.after ); } + { + # The third principal's grant, and the narrowest of the three: ONE path, + # spelled to the leaf. The negative arms are the property — a homeserver + # is not entitled to overwrite Grafana's OIDC client, so widening this to + # the `services/` prefix the publisher holds would be a real loss even + # though it would read as tidier. + # + # ⚠️ `services` is PLURAL, because the path segment comes from + # `Kind::Service`'s `#[strum(serialize = "services")]` and not from + # `Kind::label`, which renders the singular for error text. The singular + # spelling evaluates, deploys, and 403s every read with "permission + # denied" and nothing else. + name = "the matrix minter's grant is the hive credential's path and nothing else"; + ok = + let + s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script; + in + lib.hasInfix "path \"secret/data/swarm/services/matrix/hive-access-token\" {" s + && !(lib.hasInfix "secret/data/swarm/services/*" s) + && !(lib.hasInfix "secret/data/swarm/agents" s) + && !(lib.hasInfix "secret/data/swarm/hives" s) + && !(lib.hasInfix "sys/policies/acl" s); + } + { + # 🩸 `read` is load-bearing here and is the one capability neither + # sibling has. The minter's first act is to read this path back and stop + # if something is there — that read IS "and only once", so without the + # capability every container restart would mint a second access token and + # invalidate the hive's. + name = "the matrix minter may read back the one path it writes"; + ok = + let + s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script; + in + lib.hasInfix "capabilities = [\"create\", \"update\", \"read\"]" s + && lib.hasInfix "auth/cert/certs/swarm-matrix-minter" s + && lib.hasInfix "allowed_common_names=swarm-matrix-minter" s; + } + { + # Same two controls its siblings carry: ordered after the unit that makes + # the mounts it writes into, and rendered on the HOST rather than inside + # the store's container, where it would have neither an identity nor a + # route to the store. + name = "the minter's granting unit is ordered after the mounts and rendered on the host"; + ok = + lib.elem "swarm-bao-controller-policy.service" ( + baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.after + ) + && !(baoGrantHere.containers.swarm-bao.config.systemd.services ? swarm-bao-matrix-minter-policy); + } { # The policy authorising this route lives in another file, and nothing # else relates the grants to the paths the code actually writes. diff --git a/nix/module-eval/bao-matrix-reader.nix b/nix/module-eval/bao-matrix-reader.nix index 29cd5fdf..eb815761 100644 --- a/nix/module-eval/bao-matrix-reader.nix +++ b/nix/module-eval/bao-matrix-reader.nix @@ -37,6 +37,12 @@ let deploy.bao.clientCertFile = "/etc/pki/bao-client.pem"; deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem"; }; + + # A homeserver on a hive with NO store identity at all — neither a local + # store nor a hand-placed leaf. The absence arm for the minter cases below + # needs it, and defining it here rather than importing keeps each group's + # fixture set its own, as ./lib.nix asks. + matrixNoBaoIdentity = hive { deploy.matrix.enable = true; }; cases = [ { # A login failure is the store being unreachable, sealed, or not yet @@ -219,6 +225,110 @@ let && (s.hive-c0re.environment.BAO_CACERT or null) == "%d/bao-ca.pem" && lib.any (c: lib.hasPrefix "bao-ca.pem:" c) s.hive-c0re.serviceConfig.LoadCredential; } + { + # The same hole a third time, and the leaf whose absence is hardest to + # see from outside: it is consumed by a unit INSIDE a container, so a + # missing pairing renders as a container that comes up fine and publishes + # nothing. + name = "the store mints a leaf for the matrix minter, and the container is pointed at it"; + ok = + let + m = baoWithMatrix; + p = m.services.hyperhive.deploy.matrix; + in + lib.hasInfix "matrix-minter.pem" m.systemd.services.swarm-bao-pki.script + && p.minterBaoClientCertFile == "/var/lib/swarm-bao-pki/matrix-minter.pem" + && p.minterBaoClientKeyFile == "/var/lib/swarm-bao-pki/matrix-minter-key.pem"; + } + { + # 🩸 The identity separation this whole arrangement buys, stated as the + # one thing that would silently undo it. The container gets the MINTER's + # leaf — whose grant is a single path — and not the hive's, which reads + # every secret in the store. Both files exist in the same directory and + # both would evaluate, deploy and work. + name = "the matrix minter presents its own leaf, never the hive's store-wide one"; + ok = + let + env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-minter.environment; + hiveLeaf = baoWithMatrix.services.hyperhive.deploy.bao.clientCertFile; + in + env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-minter.pem" + && env.BAO_CLIENT_CERT != hiveLeaf; + } + { + # The bind mount is what makes the environment above resolvable: without + # it the unit names two paths the container does not have, and fails at + # the TLS handshake naming no cause. Read off the mount table rather than + # the option, so a pairing that stops reaching `bindMounts` still fails. + # + # The second arm is the shape guard: `bindMounts` is one literal plus two + # merges, and a rewrite that dropped the appservice registration would + # take the homeserver's own credential with it. + name = "the matrix container binds the minter's PKI read-only, without losing the appservice registration"; + ok = + let + mounts = baoWithMatrix.containers.hive-matrix.bindMounts; + in + mounts ? "/var/lib/swarm-bao-pki" + && mounts."/var/lib/swarm-bao-pki".isReadOnly + && mounts ? "/var/lib/hyperhive/matrix-appservice"; + } + { + # What the unit is for, read as the two agreements it cannot get wrong: + # the cert role ./host-modules/swarm-bao.nix writes, and a homeserver + # address that is loopback because the container shares the host netns. A + # vhost here would be a request out through the gateway and back. + name = "the matrix minter is handed the store role and the loopback homeserver"; + ok = + let + m = baoWithMatrix; + u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-minter; + port = m.services.hyperhive.swarm.matrix.httpPort; + in + u.environment.MATRIX_MINTER_CERT_ROLE == "swarm-matrix-minter" + && u.environment.MATRIX_MINTER_API_URL == "http://127.0.0.1:${toString port}" + && u.environment.MATRIX_MINTER_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml" + && u.serviceConfig.Type == "oneshot"; + } + { + # 🩸 A secret is a path, never a value — checked on the one unit in this + # tree whose whole job is an `as_token`. Every variable it is given names + # a file or an address; the token itself is read out of the bind-mounted + # registration at runtime, so nothing here can be a token and an + # environment block is world-readable through `systemctl show`. + name = "the matrix minter's environment carries paths and addresses, never a token"; + ok = + let + env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-minter.environment; + in + !(lib.any (v: lib.hasInfix "as_token" v || lib.hasInfix "syt_" v) (lib.attrValues env)); + } + { + # The absence arm, and the deployment it protects: a homeserver on a hive + # with no store identity at all. Without it the unit would exist naming + # `null` as its certificate, which nixos renders as the literal string. + name = "a matrix container with no store identity runs no minter and binds no PKI"; + ok = + let + units = matrixNoBaoIdentity.containers.hive-matrix.config.systemd.services; + in + !(units ? swarm-matrix-minter) + && !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki"); + } + { + # 🩸 The privilege arm of the credential this slice publishes: the + # account it belongs to must not be a homeserver admin. Read on the + # rendered homeserver settings rather than on an option, because the + # grant was never an option — it was a boot command in `admin_execute`, + # and a command list is exactly the shape a later edit re-adds without + # anything noticing. + name = "the homeserver promotes no account to admin at boot"; + ok = + let + g = baoWithMatrix.containers.hive-matrix.config.services.matrix-tuwunel.settings.global; + in + !(g ? admin_execute) || g.admin_execute == [ ]; + } ]; in runGroup "bao-matrix-reader" cases diff --git a/nix/module-eval/name-guards.nix b/nix/module-eval/name-guards.nix index d2ee8b32..40775c1b 100644 --- a/nix/module-eval/name-guards.nix +++ b/nix/module-eval/name-guards.nix @@ -52,6 +52,17 @@ let swarm.hives.pubctl.domain = "p.t.local"; }; + # The THIRD element of the same list, colliding on its own so neither of the + # two above can carry it. The minter's grant is one path rather than a whole + # prefix, which is exactly why a dead entry here would be easy to miss: a + # hive that inherited it would not obviously break anything, it would + # silently gain the ability to overwrite the swarm's matrix credential. + hiveNamedAfterMinterSubject = hive { + deploy.swarm-otel.enable = false; + deploy.bao.matrixMinterCommonName = "mintctl"; + swarm.hives.mintctl.domain = "m.t.local"; + }; + hiveNameWithComposedWord = hive { deploy.swarm-otel.enable = false; swarm.hives."h1-agent".domain = "a.t.local"; @@ -88,6 +99,18 @@ let a: !a.assertion && lib.hasInfix "'pubctl'" a.message ) hiveNamedAfterPublisherSubject.assertions; } + { + # And the third, for the reason the second one's comment gives one list + # element earlier. `certAuthCns` is where a role added beside the others + # has to register itself, and nothing but a case per element notices when + # one forgets. + name = "a hive named after the matrix minter's subject is refused too"; + ok = + equalityGuardFired hiveNamedAfterMinterSubject + && lib.any ( + a: !a.assertion && lib.hasInfix "'mintctl'" a.message + ) hiveNamedAfterMinterSubject.assertions; + } { # Without this the case above proves nothing: an arm that fires for every # roster is not a guard, and `hives` is non-empty in both fixtures. diff --git a/nix/packages/default.nix b/nix/packages/default.nix index ac18933d..296a53a3 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -163,6 +163,13 @@ in # rather than every hive's. swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder"; + # The matrix admin credential's minter. Out of `daemonBins` for the same + # "runs *inside* a container, not on the host" reason as the responder + # above, and with a second one: putting it in the core bundle would place + # the binary that reads the appservice token on every hive's filesystem, + # which is the arrangement it exists to end. + swarm-matrix-minter = mkBinPackage "swarm-matrix-minter" "hyperhive matrix admin-credential minter"; + # The only process allowed to write swarm-authelia's users database — # same "runs *inside* a container, not on the host" placement as # `swarm-nats-auth` above (this one lives in `swarm-authelia`'s diff --git a/swarm-matrix-minter/Cargo.toml b/swarm-matrix-minter/Cargo.toml new file mode 100644 index 00000000..8d56e641 --- /dev/null +++ b/swarm-matrix-minter/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "swarm-matrix-minter" +version.workspace = true +readme = "README.md" +edition.workspace = true + +[[bin]] +name = "swarm-matrix-minter" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +reqwest.workspace = true +serde_json.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 +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +[lints] +workspace = true diff --git a/swarm-matrix-minter/README.md b/swarm-matrix-minter/README.md new file mode 100644 index 00000000..ac0455d8 --- /dev/null +++ b/swarm-matrix-minter/README.md @@ -0,0 +1,44 @@ +# swarm-matrix-minter + +A boot-time oneshot that runs **inside `containers.hive-matrix`**, beside the +homeserver, and puts the `@hive:` account's access token into the swarm's secret +store under an identity of its own. + +## Why it lives in the matrix container + +The credential it mints is authorised by the appservice `as_token`, and the +container already holds that: `nix/host-modules/hive-matrix.nix` bind-mounts the +rendered appservice registration into it read-only, because that is how tuwunel +itself is handed the registration. Minting anywhere else would mean copying the +`as_token` to a second holder — and the point of this component is that the hive +stops being one. + +It is not the swarm controller for the same reason, plus a structural one: a +homeserver has exactly **one** `@hive:` account and a swarm runs one +homeserver, so "mint it once" needs no lock, no lease and no trigger surface — it +is a property of the thing being minted. + +## Idempotency + +The **store** is the key, not the homeserver. A run reads +`swarm/services/matrix/hive-access-token` first and returns without touching the +homeserver when something is already there. Only an empty path reaches the mint +ladder: + +1. `POST /_matrix/client/v3/register` with `"type": "m.login.application_service"` + — one round trip, no UIAA. +2. `M_USER_IN_USE` (the expected arm on a homeserver that has already loaded the + registration, since the account is the appservice's own `sender_localpart`) → + `POST /_matrix/client/v3/login` as the appservice, same pinned `device_id`, so + the old device is replaced rather than duplicated. +3. Write the result to the store. + +A crash between the homeserver call and the store write is recoverable: the next +run takes arm 2. + +## 🩸 A secret is a path, never a value + +Nothing here logs, prints or interpolates a token. The mint ladder's errors are +built from the homeserver's _status_ and its `errcode`, never its body, because a +`/login` response body is an access token. The one identifier this binary logs is +the store path it wrote. diff --git a/swarm-matrix-minter/src/homeserver.rs b/swarm-matrix-minter/src/homeserver.rs new file mode 100644 index 00000000..3a82a659 --- /dev/null +++ b/swarm-matrix-minter/src/homeserver.rs @@ -0,0 +1,286 @@ +//! The two calls the mint ladder is made of, against the homeserver next door. +//! +//! 🩸 **Every error in this module 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 — so a `body: {json}` in a message here would put the +//! `@hive:` credential in the journal. + +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. +const TIMEOUT_SECS: u64 = 10; + +/// Bytes of the throwaway password `/register` is given. +/// +/// Protocol overhead, and stored nowhere: this account authenticates by access +/// token, and the recovery path when that token is lost is the appservice login +/// below rather than anything a password could open. +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:` 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. +pub enum Registered { + /// A fresh account, and the access token minted with it. + Token(String), + /// The account is already there; it has to be logged into instead. + AlreadyExists, +} + +/// An HTTP client with this module's timeout. +/// +/// # Errors +/// When the TLS backend will not initialise, which is the only way building a +/// client fails. +pub fn client() -> Result { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(TIMEOUT_SECS)) + .build() + .context("building the HTTP client for the homeserver") +} + +/// Create `localpart`'s account as the appservice and return its access token. +/// +/// One round trip: an appservice-typed registration needs no UIAA stage, so +/// there is no session to carry and no shared registration secret in the +/// picture. The `device_id` is pinned so that a later [`appservice_login`] +/// replaces this device rather than accumulating one per run. +/// +/// # Errors +/// When the request cannot be sent, the response will not decode, or the +/// homeserver refuses with anything other than `M_USER_IN_USE` — which is +/// [`Registered::AlreadyExists`] rather than an error. +pub async fn register( + client: &reqwest::Client, + base: &str, + localpart: &str, + as_token: &str, +) -> Result { + let body = serde_json::json!({ + // What makes this an appservice registration rather than an ordinary + // one: without it the homeserver asks for a UIAA flow even though the + // request carries the as_token. + "type": "m.login.application_service", + "username": localpart, + "password": random_password()?, + "device_id": device_id(localpart), + "initial_device_display_name": format!("hyperhive ({localpart})"), + "inhibit_login": false, + }); + let (status, json) = post( + client, + &format!("{base}/_matrix/client/v3/register?kind=user"), + as_token, + &body, + ) + .await + .context("POST /register as the appservice")?; + + if status.is_success() { + return Ok(Registered::Token(access_token(&json)?)); + } + if errcode(&json) == Some("M_USER_IN_USE") { + return Ok(Registered::AlreadyExists); + } + bail!( + "the homeserver refused to register @{localpart}: {}", + why(status, &json) + ); +} + +/// Log in as an **existing** account using the appservice's authority, and +/// return a fresh access token for it. +/// +/// No password: the appservice is authorised for every localpart in its +/// namespace, so it mints a session for one without knowing anything about the +/// account — which is just as well, since an account the homeserver created for +/// its own registration has none. +/// +/// # Errors +/// When the request cannot be sent, the response will not decode, or the +/// homeserver refuses. +pub async fn appservice_login( + client: &reqwest::Client, + base: &str, + localpart: &str, + as_token: &str, +) -> Result { + let body = serde_json::json!({ + "type": "m.login.application_service", + "identifier": { + "type": "m.id.user", + "user": localpart, + }, + // Matching `register`'s, so a re-login REPLACES that device's token + // rather than leaving a second live device behind. + "device_id": device_id(localpart), + "initial_device_display_name": format!("hyperhive ({localpart})"), + }); + let (status, json) = post( + client, + &format!("{base}/_matrix/client/v3/login"), + as_token, + &body, + ) + .await + .context("POST /login as the appservice")?; + + if !status.is_success() { + bail!( + "the homeserver refused to log in @{localpart}: {}", + why(status, &json) + ); + } + access_token(&json) +} + +/// The device every token this binary mints is pinned to. +fn device_id(localpart: &str) -> String { + format!("hyperhive-{localpart}") +} + +/// One authenticated JSON POST, returning the status beside the decoded body. +async fn post( + client: &reqwest::Client, + url: &str, + as_token: &str, + body: &serde_json::Value, +) -> Result<(reqwest::StatusCode, serde_json::Value)> { + let resp = client + .post(url) + .bearer_auth(as_token) + .json(body) + .send() + .await + .context("sending the request")?; + let status = resp.status(); + let json = resp + .json::() + .await + .context("decoding the response as JSON")?; + Ok((status, json)) +} + +/// Pull `access_token` out of a successful response. +fn access_token(json: &serde_json::Value) -> Result { + json["access_token"] + .as_str() + .map(str::to_owned) + // Not `{json}`: on the success path this object holds the credential, + // and a response missing the field is exactly when a reflex to print it + // would fire. + .context("the homeserver's response carried no `access_token`") +} + +/// The matrix error code, when the body is a standard error object. +fn errcode(json: &serde_json::Value) -> Option<&str> { + json["errcode"].as_str() +} + +/// Everything about a refusal that is safe to put in a message. +/// +/// The `errcode` is a closed vocabulary from the spec and the status is a +/// number; between them they say which of the ladder's arms was taken. The +/// `error` string beside them is free-form homeserver text, so it stays out. +fn why(status: reqwest::StatusCode, json: &serde_json::Value) -> String { + match errcode(json) { + Some(code) => format!("HTTP {status}, errcode {code}"), + None => format!("HTTP {status}, no errcode"), + } +} + +/// A throwaway password for [`register`], as hex. +/// +/// 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. +/// +/// # Errors +/// When `/dev/urandom` cannot be read. +fn random_password() -> Result { + use std::io::Read as _; + + let mut buf = [0u8; PASSWORD_BYTES]; + std::fs::File::open("/dev/urandom") + .context("opening /dev/urandom")? + .read_exact(&mut buf) + .context("reading from /dev/urandom")?; + Ok(buf.iter().fold(String::new(), |mut acc, b| { + use std::fmt::Write as _; + // Infallible: `write!` into a `String` only fails if the formatter + // does, and `{:02x}` of a `u8` has nothing to fail at. + let _ = write!(acc, "{b:02x}"); + acc + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn both_calls_pin_the_same_device_so_a_relogin_replaces_it() { + // The whole reason the recovery arm is safe to take repeatedly: an + // unpinned login mints a NEW device each time, and a homeserver + // accumulating devices for this account is one where revoking the credential + // means finding all of them. + assert_eq!(device_id("hive"), "hyperhive-hive"); + } + + #[test] + fn a_refusal_is_described_by_its_errcode_and_never_by_its_body() { + // 🩸 The invariant this module exists to keep. `error` is free-form + // homeserver text and `access_token` is the credential itself; a + // message built from the body would carry whichever of them the + // response happened to hold. + let json = serde_json::json!({ + "errcode": "M_FORBIDDEN", + "error": "some free-form text", + "access_token": "syt_the_actual_secret", + }); + let message = why(reqwest::StatusCode::FORBIDDEN, &json); + assert!(message.contains("M_FORBIDDEN"), "{message}"); + assert!(!message.contains("syt_the_actual_secret"), "{message}"); + assert!(!message.contains("free-form"), "{message}"); + } + + #[test] + fn a_refusal_with_no_errcode_still_produces_a_message() { + // A homeserver behind a proxy answers with HTML, not a matrix error + // object. The status is then the only thing there is to say, and + // saying it is better than an empty report. + let message = why(reqwest::StatusCode::BAD_GATEWAY, &serde_json::json!({})); + assert!(message.contains("502"), "{message}"); + } + + #[test] + fn the_expected_already_exists_answer_is_recognised_by_its_errcode() { + // Matched on the spec's code rather than on message text, because this + // is the arm a healthy homeserver takes every time: the `@hive:` account + // is the appservice registration's own sender, created at startup. + let json = serde_json::json!({ "errcode": "M_USER_IN_USE", "error": "User ID taken" }); + assert_eq!(errcode(&json), Some("M_USER_IN_USE")); + } + + #[test] + fn a_response_without_a_token_is_an_error_that_does_not_quote_it() { + let e = access_token(&serde_json::json!({ "user_id": "@hive:t.local" })) + .expect_err("no access_token in this object"); + assert!(!format!("{e}").contains("@hive:t.local"), "{e}"); + } + + #[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"); + assert_eq!(pw.len(), PASSWORD_BYTES * 2); + assert!(pw.bytes().all(|b| b.is_ascii_hexdigit()), "not hex"); + } +} diff --git a/swarm-matrix-minter/src/main.rs b/swarm-matrix-minter/src/main.rs new file mode 100644 index 00000000..25cd3fcb --- /dev/null +++ b/swarm-matrix-minter/src/main.rs @@ -0,0 +1,249 @@ +//! Mint the `@hive:` account's homeserver access token, once, and publish it to the +//! swarm's secret store. +//! +//! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the +//! swarm controller. It is 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. It is +//! this account rather than an agent's because a homeserver has **one** `@hive:` +//! account and a swarm runs one homeserver: "only once" is a property of what +//! is being minted, so there is nothing to lock and no trigger to serve. +//! +//! The store, not the homeserver, is the idempotency key — see +//! [`already_published`]. On the hive side `hive-c0re`'s +//! `matrix::ensure_hive_user` reads exactly the path written here, which is how +//! a hive that holds no `as_token` still gets its matrix account. +//! +//! 🩸 A secret is a path, never a value. The only identifier this binary logs is +//! the store path; see `homeserver`'s module doc for the same rule applied to +//! error messages. + +mod homeserver; +mod registration; + +use anyhow::{Context, Result}; +use swarm_secret_client::{ + SecretStore, + client::{DEFAULT_CERT_MOUNT, Settings}, + matrix, +}; + +/// 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 +/// carry the CN that role accepts. +const ENV_CERT_ROLE: &str = "MATRIX_MINTER_CERT_ROLE"; +/// Client-server API base of the homeserver beside us — loopback, since the +/// container shares the host netns. +const ENV_API_URL: &str = "MATRIX_MINTER_API_URL"; +/// The bind-mounted appservice registration, which is where the `as_token` +/// comes from. A path, never a value. +const ENV_REGISTRATION: &str = "MATRIX_MINTER_REGISTRATION"; +/// Localpart of the hive's account. The appservice registration's own +/// `sender_localpart`, and `hive-c0re`'s `matrix::HIVE_LOCALPART`. +const ENV_LOCALPART: &str = "MATRIX_MINTER_LOCALPART"; +/// Public base URL of the homeserver, stored beside the token so a reader can +/// reconstruct where it is good for. Optional: a swarm with no gateway vhost +/// has no such URL, and `matrix::Credential` types the field to say so. +const ENV_HOMESERVER: &str = "MATRIX_MINTER_HOMESERVER"; + +/// Everything the unit tells this process, checked before anything is opened. +/// +/// Separate from the work for the reason `swarm_secret_client::client::Settings` +/// is: every arm is a misconfiguration an operator reads an error about, and +/// none of them needs a reachable homeserver or store to happen. +#[derive(Debug, PartialEq, Eq)] +struct Config { + cert_role: String, + api_url: String, + registration: String, + localpart: String, + homeserver: Option, +} + +impl Config { + /// Read the `MATRIX_MINTER_*` variables from the process environment. + /// + /// # Errors + /// Naming the first variable that is unset or empty. + fn from_env() -> Result { + Self::from_lookup(|k| std::env::var(k).ok()) + } + + /// [`Config::from_env`] against an arbitrary lookup. + /// + /// # Errors + /// Naming the first variable that is unset or empty. + fn from_lookup(get: impl Fn(&str) -> Option) -> Result { + let required = |var: &'static str| -> Result { + get(var) + .filter(|v| !v.is_empty()) + .with_context(|| format!("{var} is unset or empty")) + }; + Ok(Self { + cert_role: required(ENV_CERT_ROLE)?, + api_url: required(ENV_API_URL)?, + registration: required(ENV_REGISTRATION)?, + localpart: required(ENV_LOCALPART)?, + // Empty is absent: systemd renders an unset nix option as + // `Environment=VAR=`, so that is the shape this arrives in. + homeserver: get(ENV_HOMESERVER).filter(|v| !v.is_empty()), + }) + } +} + +/// Is the credential already in the store? +/// +/// **This read is the "and only once".** The homeserver is not asked — a +/// re-run of the container, or of this unit, costs one store read and stops. +/// It is also the read-back of what a previous run wrote, so the path published +/// and the path consulted cannot drift apart: they are one function call. +/// +/// A failure to read is reported and treated as absent rather than raised. The +/// two cases that reach it are a path that has never been written (the first +/// run, which must go on to mint) and a token whose policy does not cover the +/// path — and the second fails again, loudly and with the store's own message, +/// at the write below. +async fn already_published(store: &SecretStore, path: &str) -> bool { + match store.read::(path).await { + Ok(credential) => !credential.value.trim().is_empty(), + Err(e) => { + tracing::info!(%path, error = %e, "nothing readable in the store yet"); + false + } + } +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let config = Config::from_env()?; + // Explicitly, rather than through `SecretStore::from_env`: a missing or + // misspelled `BAO_*` variable is the most likely thing to be wrong with a + // freshly deployed unit, and this reports it before the homeserver is + // touched at all. + let settings = Settings::from_env().context("reading the store's BAO_* environment")?; + let store = SecretStore::connect(&settings, &config.cert_role, DEFAULT_CERT_MOUNT) + .await + .with_context(|| { + format!( + "logging in to the swarm secret store as cert role {}", + config.cert_role + ) + })?; + + let path = matrix::hive_token_path(); + if already_published(&store, &path).await { + tracing::info!(%path, "the @hive: credential is already published; not minting"); + return Ok(()); + } + + let as_token = registration::as_token(&config.registration)?; + let http = homeserver::client()?; + let token = + match homeserver::register(&http, &config.api_url, &config.localpart, &as_token).await? { + homeserver::Registered::Token(token) => token, + homeserver::Registered::AlreadyExists => { + // The expected arm, not an edge case: this account is the + // appservice's own `sender_localpart`, so the homeserver creates it + // when it loads the registration — before anything gets to ask. + tracing::info!("the @hive: account exists; logging in as the appservice instead"); + homeserver::appservice_login(&http, &config.api_url, &config.localpart, &as_token) + .await? + } + }; + + store + .write( + &path, + &matrix::Credential { + value: token, + homeserver: config.homeserver, + }, + ) + .await + .with_context(|| format!("writing the @hive: credential to {path}"))?; + tracing::info!(%path, "published the @hive: credential"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A lookup standing in for a fully-configured unit's environment. + fn full(k: &str) -> Option { + match k { + ENV_CERT_ROLE => Some("swarm-matrix-minter".to_owned()), + ENV_API_URL => Some("http://127.0.0.1:8008".to_owned()), + ENV_REGISTRATION => { + Some("/var/lib/hyperhive/matrix-appservice/hyperhive.yaml".to_owned()) + } + ENV_LOCALPART => Some("hive".to_owned()), + _ => None, + } + } + + #[test] + fn a_complete_environment_is_accepted() { + // The control: without it every assertion below could be passing + // because `from_lookup` rejects everything. + let c = Config::from_lookup(full).expect("every required variable is set"); + assert_eq!(c.localpart, "hive"); + assert_eq!(c.homeserver, None, "an absent public URL is not an error"); + } + + #[test] + fn each_required_variable_is_named_when_it_is_the_missing_one() { + for var in [ENV_CERT_ROLE, ENV_API_URL, ENV_REGISTRATION, ENV_LOCALPART] { + let e = Config::from_lookup(|k| if k == var { None } else { full(k) }) + .expect_err("one required variable is absent"); + assert!( + format!("{e}").contains(var), + "dropping {var} should name {var}, got {e}" + ); + } + } + + #[test] + fn an_empty_variable_is_as_absent_as_an_unset_one() { + // systemd writes `Environment=VAR=` for an unset nix option, so empty + // is the shape these actually arrive in. + let e = Config::from_lookup(|k| { + if k == ENV_CERT_ROLE { + Some(String::new()) + } else { + full(k) + } + }) + .expect_err("an empty role is not a role"); + assert!(format!("{e}").contains(ENV_CERT_ROLE), "{e}"); + + let c = Config::from_lookup(|k| { + if k == ENV_HOMESERVER { + Some(String::new()) + } else { + full(k) + } + }) + .expect("an empty public URL is optional, not fatal"); + assert_eq!(c.homeserver, None); + } + + #[test] + fn the_published_path_is_the_one_the_hive_reads() { + // Both ends of this slice's loop resolve the same function, so there is + // no second spelling to drift — this pins that the loop exists at all, + // and names the literal so a move of the path is a deliberate edit on + // both sides rather than a silent 404 on the reading one. + assert_eq!( + matrix::hive_token_path(), + "swarm/services/matrix/hive-access-token" + ); + } +} diff --git a/swarm-matrix-minter/src/registration.rs b/swarm-matrix-minter/src/registration.rs new file mode 100644 index 00000000..6d2da06d --- /dev/null +++ b/swarm-matrix-minter/src/registration.rs @@ -0,0 +1,104 @@ +//! Reading the `as_token` out of the appservice registration the matrix +//! container already has. +//! +//! No new credential is delivered for this. `nix/host-modules/hive-matrix.nix` +//! binds the registration directory into the container read-only so tuwunel can +//! load it, and the registration **is** the `as_token` — so the file this +//! module opens is one this process could already read, and one the homeserver +//! beside it reads too. +//! +//! Scanned line-by-line rather than parsed as YAML. The file has exactly one +//! renderer (`appserviceRegistrationScript` in that same module, a `printf` of +//! `as_token: `), so a parser would be a second, looser reading of a shape +//! this repo writes itself — and it would pull a YAML crate into a binary whose +//! only other input is JSON. + +use anyhow::{Context, Result, bail}; + +/// The key the token is stored under, and the whole of the agreement with the +/// renderer. +const KEY: &str = "as_token:"; + +/// Read the registration at `path` and return its `as_token`. +/// +/// # Errors +/// When the file cannot be read, or holds no `as_token` with a value — which is +/// what a registration rendered by something other than this repo looks like +/// from here. +pub fn as_token(path: &str) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("reading the appservice registration at {path}"))?; + // The path, not the file's contents: every line of it is either a secret or + // a shape this module already knows. + extract(&text).with_context(|| format!("no `as_token` in the registration at {path}")) +} + +/// [`as_token`] over text already in hand, so the agreement with the renderer +/// can be tested without a file. +fn extract(text: &str) -> Result { + for line in text.lines() { + if let Some(rest) = line.strip_prefix(KEY) { + let token = rest.trim(); + if token.is_empty() { + bail!("the registration's `as_token` is empty"); + } + return Ok(token.to_owned()); + } + } + bail!("the registration carries no `as_token` line") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The registration exactly as `appserviceRegistrationScript` renders it — + /// the quoted heredoc, then the `printf` of the two tokens. Reproduced + /// verbatim because that script is the other end of this agreement and + /// lives in a file no Rust test can reach. + const RENDERED: &str = "id: hyperhive\n\ + url: null\n\ + sender_localpart: hive\n\ + rate_limited: false\n\ + namespaces:\n \ + users:\n \ + - exclusive: false\n \ + regex: '@[a-z0-9._=/+-]+:example\\.test$'\n \ + aliases: []\n \ + rooms: []\n\ + as_token: deadbeef\n\ + hs_token: cafebabe\n"; + + #[test] + fn the_token_is_taken_from_the_registration_this_repo_renders() { + assert_eq!( + extract(RENDERED).expect("the rendered shape parses"), + "deadbeef" + ); + } + + #[test] + fn the_homeservers_own_token_is_not_mistaken_for_the_appservices() { + // `hs_token` authenticates the homeserver TO the appservice and is a + // different secret with a confusingly similar name; a substring search + // would find it inside neither, but a `contains("s_token")`-shaped one + // would. The control is that the line order in `RENDERED` puts + // `as_token` first, so this arm needs the reverse to mean anything. + let reversed = "hs_token: cafebabe\nas_token: deadbeef\n"; + assert_eq!( + extract(reversed).expect("order does not matter"), + "deadbeef" + ); + } + + #[test] + fn a_registration_with_no_token_is_an_error_rather_than_an_empty_string() { + // An empty token authenticates nothing, and a homeserver answers a + // request carrying one with a 403 that names the account rather than + // the credential — so failing here is the only report an operator can + // act on. + for bad in ["id: hyperhive\n", "as_token:\n", "as_token: \n"] { + assert!(extract(bad).is_err(), "{bad:?} must not yield a token"); + } + } +} diff --git a/swarm-secret-client/src/matrix.rs b/swarm-secret-client/src/matrix.rs index 9a0cda5b..1aa39a22 100644 --- a/swarm-secret-client/src/matrix.rs +++ b/swarm-secret-client/src/matrix.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use crate::{ Error, - path::{Kind, checked_segment, principal_prefix}, + path::{Kind, ROOT, checked_segment, principal_prefix}, }; /// The path holding `agent`'s token for the external matrix account `account`. @@ -26,6 +26,32 @@ pub fn account_path(agent: &str, account: &str) -> Result { Ok(format!("{prefix}/matrix/{account}")) } +/// The swarm service the homeserver is, as the name segment under +/// [`Kind::Service`]. +/// +/// A swarm runs one homeserver, so this is a constant rather than a parameter — +/// and that is the whole of what makes the credential below mintable +/// "only once" without a lock. +pub const HOMESERVER_SERVICE: &str = "matrix"; + +/// The path holding the `@hive:` account's homeserver access token. +/// +/// Keyed per **homeserver**, not per hive and not per agent: a homeserver has one +/// `@hive:` account (hive-c0re's `matrix::HIVE_LOCALPART`), so a +/// per-hive copy would be several names for one secret. That is also why this +/// takes no argument and cannot fail — there is no caller-supplied segment in +/// it to reject. +/// +/// Reachable by every hive without a new grant: [`crate::policy::render`] +/// already grants a hive read on the whole [`Kind::Service`] tree. +#[must_use] +pub fn hive_token_path() -> String { + format!( + "{ROOT}/{}/{HOMESERVER_SERVICE}/hive-access-token", + <&str>::from(Kind::Service) + ) +} + /// The path holding `hive`'s matrix appservice token (`as_token`). /// /// Keyed per **hive**, not per agent, like [`crate::queue::agent_client_path`] @@ -86,6 +112,28 @@ mod tests { assert_eq!(p, "swarm/agents/atlas/matrix/ops-relay"); } + #[test] + fn the_hive_credential_lands_under_the_service_prefix_the_grant_covers() { + // Spelled out rather than rebuilt from the same pieces the code uses, + // for the reason above — and with a second job here: the hive's own + // policy grants read on `secret/data/swarm/services/*`, so this exact + // string is what makes the path reachable at all. The `services` + // segment is PLURAL; `Kind::label` renders the singular and is for + // error text only, so reading it as the path segment produces a + // 403 the store explains as "permission denied" and nothing else. + assert_eq!(hive_token_path(), "swarm/services/matrix/hive-access-token"); + } + + #[test] + fn the_hive_credential_sits_where_a_service_principal_would() { + // The constant path above must be the same one the general builder + // produces, or the grant covering `Kind::Service` would cover a + // neighbouring tree instead of this one. + let prefix = + principal_prefix(Kind::Service, HOMESERVER_SERVICE).expect("a plain name is legal"); + assert_eq!(hive_token_path(), format!("{prefix}/hive-access-token")); + } + #[test] fn the_appservice_token_lands_under_the_hive_prefix_the_grant_covers() { // Spelled out for the same reason as above, and with a second job here: From 88b4a7841d3280db39566f04441818a952751db4 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 20 Sep 2026 13:42:18 +0200 Subject: [PATCH 2/8] docs/swarm/credentials.md: add the matrix sender-token row `swarm/services/matrix/sender-token` has a real minter (`swarm-matrix-minter`, in the `hive-matrix` container) and two readers now: the minter's own pre-flight read-back before it mints, and hive-c0re's `stored_sender_token()` under the hive's own certificate. The credentials table catalogs minter/reader/renewal for every credential path; this one was missing a row. Refs #4345 --- docs/swarm/credentials.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index c1bace54..67f731eb 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -54,14 +54,15 @@ strategy for every credential, including the mTLS leaf. -| store path | minter | reader — pulls at runtime, holds in memory | renewal | -| -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | -------------- | -| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | -| `swarm/agents//bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated | -| `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | -| `swarm/hives//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | -| `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | -| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | +| store path | minter | reader — pulls at runtime, holds in memory | renewal | +| -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | +| `swarm/agents//bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated | +| `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | +| `swarm/hives//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | +| `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | +| `swarm/services/matrix/hive-access-token` | `swarm-matrix-minter`, in the `hive-matrix` container | `swarm-matrix-minter` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_hive_token()`, under the hive's own certificate | must be stated | +| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | From bbb4e471ea406fb3d704ae3a74a814f48793b880 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 20 Sep 2026 05:17:23 +0200 Subject: [PATCH 3/8] matrix, docs: drop the last admin framing, fix tense and prose lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three leftovers from the rename, plus the gating prose job. `nix/packages/default.nix` still described the minter as the "matrix admin credential's minter", and shipped that claim in the package's `meta.description` — a PR-visible string. `promote_user_to_admin`'s doc comment pointed at the `admin_execute` entry in `hive-matrix.nix` as the thing that bootstraps the first admin. That entry is gone, so the comment referenced nothing. It now records that the account is ordinary, that the call therefore has no working sender, and that rehoming at swarm level is the fix rather than re-granting. `reset_user_password` gained the matching warning; it had none. The prose fixes clear all 8 `CI / prose lint (vale, errors)` failures, all of which were in docs this branch touches. No vale config change, no exception, no carve-out: contractions, one recast sentence, one de-hyphenation and one dropped "simply". Tense: four docs described system behaviour in the future ("will refuse", "will fetch and trust"). Reference docs get read from the other side of the change, so they say what the system does. --- docs/getting-started/setup.md | 6 +++--- docs/integrations/matrix.md | 16 ++++++++-------- docs/tools/hivectl.md | 8 ++++---- hive-c0re/src/matrix.rs | 12 ++++++++++-- nix/packages/default.nix | 4 ++-- 5 files changed, 27 insertions(+), 19 deletions(-) diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 740d6367..1add36d6 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -181,7 +181,7 @@ which. A hive that does **not** deploy the store names the reader's three itself: the operator issues that leaf out of band, and it's the one credential the store can't hand you, being what opens it. ⚠️ Not the gateway's HTTPS certificates and not the hive CA — this is **mTLS between services and the store**, a separate trust domain, because a -store that took its identity from an authority it will itself distribute could +store that took its identity from an authority it itself distributes could never come up before that authority. ### 4 · Swarm SSO (only when `deploy.authelia`) @@ -203,8 +203,8 @@ surfaces (the swarm UI below) gate on that group, and an account without it authenticates successfully and is then refused — which reads like a broken login rather than a missing group. -If an account already exists without it, `user add` will refuse rather -than amend — adding the group afterwards is `swarmctl user update mara +If an account already exists without it, `user add` refuses rather +than amends — adding the group afterwards is `swarmctl user update mara --add-group admins`. Detail, including what the password is and why this stays manual: diff --git a/docs/integrations/matrix.md b/docs/integrations/matrix.md index 8847a098..6be59be0 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -147,20 +147,20 @@ a token. (`hive-matrix-daemon.path` watching for `matrix-token` appearance) brings the daemon up on the same boot cycle anyway. -### The `@hive:` account, and why it is not an admin +### The `@hive:` account, and why it isn't an admin `@hive:` is the appservice's own `sender_localpart`, which the homeserver creates itself when it loads the registration — on a zero-user database, inside startup, before the HTTP listener accepts -anything. It is an **ordinary account**: nothing promotes it, and the +anything. It's an **ordinary account**: nothing promotes it, and the homeserver runs no `admin_execute` for it. It needs no promotion for what the hive does with it. Creating the hive Space and the chat room, writing their hierarchy and join rules, and inviting agents into them are all ordinary client calls that ride on being the rooms' own creator at power level 100 — there is no homeserver -admin in any of it. (There is no Synapse admin API here either; tuwunel -has none.) +admin in any of it, and no Synapse admin API to reach for either, since +tuwunel has none. Two operations do need an admin **sender**, and neither works today: `hivectl matrix promote-user` and `hivectl matrix reset-password`. Both @@ -169,8 +169,8 @@ treats a message as a command when its sender is already an admin. They are swarm-level operations and are being rehomed as such; until then they fail with the admin room's refusal rather than being served by an over-privileged credential that every other call site would also carry. -The one hive-side path that depends on them is the password-reset -auto-recovery for an agent whose stored password is gone — the ordinary +The one hive-side path that depends on them is the automatic password +recovery for an agent whose stored password is gone — the ordinary appservice re-login above is unaffected.
Upgrading a hive that used the registration token @@ -189,7 +189,7 @@ restarts, so the first boot after the switch already has both halves. and displaces no session. - **`@hive:` may already be an admin** on such a hive (it won the first-user grant when the hive was new). Nothing here demotes it; the - homeserver simply no longer promotes it, so a hive built fresh has an + homeserver no longer promotes it, so a hive built fresh has an ordinary account and an older one keeps whatever standing it acquired. - **`/var/lib/hyperhive/matrix-register-token` stays on disk**, read by nothing. Delete it or leave it; neither does any harm. @@ -278,7 +278,7 @@ services.hyperhive.swarm.matrix = { ``` **`trustedServers`** (default `[]`) — list of peer homeserver names -whose signing keys tuwunel will fetch and trust. tuwunel enables federation +whose signing keys tuwunel fetches and trusts. Federation is enabled at the protocol level from first boot (`allow_federation = true`) but trusts no remote homeserver until you list it here. For a closed single-hive deployment the default empty list is correct — add peer diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index ce7d4864..8b5c407e 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -79,11 +79,11 @@ hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a spec access token path. Safe to run again — idempotent. - `promote-user`: promotes an already-registered user to homeserver admin by an `!admin` command in `#admins`. ⚠️ Needs an admin **sender**, - which `@hive:` is not — this operation is being rehomed at swarm level - and does not work from the hive today. + which `@hive:` isn't — this operation is being rehomed at swarm level + and doesn't work from the hive today. - `reset-password`: asks the admin room to set a new random password and prints it to stdout. ⚠️ Needs an admin **sender** too, so - it does not work from the hive today either. Useful if an agent or human lost + it doesn't work from the hive today either. Useful if an agent or human lost credentials. - `invite`: invites a matrix user (full `@user:server` or a bare localpart, qualified with the homeserver's `server_name`) to the hive @@ -227,7 +227,7 @@ granting that group the one action `machinectl shell` needs); `choom` checks this itself and refuses up front with a message naming the requirement rather than letting systemd reject the exec later with an opaque polkit prompt. Same caveat as the socket grant: a shell opened -before the operator added you to `hive-admin` won't see it until you log +before the operator added you to `hive-admin` doesn't see it until you log back in — secondary group membership applies at login. It also needs the daemon socket, unlike the other exec-into-a-container diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index e3cd205d..1c2b16fa 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -1103,8 +1103,12 @@ mod is_make_admin_success_tests { /// /// ⚠️ Requires the **sender** to be an admin already — tuwunel only /// treats a message as a command when its sender is in the admin room. -/// So this promotes a *second* user; it cannot bootstrap the first one. -/// That is what the `admin_execute` entry in `hive-matrix.nix` is for. +/// `@hive:` is an ordinary account (`hive-matrix.nix` grants it no +/// `admin_execute` promotion), so this call has no working sender from +/// the hive and fails with the admin room's refusal. Promotion is a +/// swarm-level operation and is being rehomed as such; this stays here, +/// failing loudly, rather than justifying an over-privileged token that +/// all 13 ordinary call sites would also carry. /// /// Goes through the admin room rather than a direct HTTP call because /// tuwunel implements parts of the Synapse admin API but not user @@ -1144,6 +1148,10 @@ pub async fn promote_user_to_admin( /// it to the non-purgeable creds path so [`ensure_user_for`] can re-login /// on the next provisioning sweep. /// +/// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and +/// the same consequence: `@hive:` is an ordinary account, so this fails +/// from the hive until the operation is rehomed at swarm level. +/// /// Returns the new password for use in subsequent `login_user` calls. pub async fn reset_user_password( client: &reqwest::Client, diff --git a/nix/packages/default.nix b/nix/packages/default.nix index 296a53a3..714307bd 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -163,12 +163,12 @@ in # rather than every hive's. swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder"; - # The matrix admin credential's minter. Out of `daemonBins` for the same + # The `@hive:` access token's minter. Out of `daemonBins` for the same # "runs *inside* a container, not on the host" reason as the responder # above, and with a second one: putting it in the core bundle would place # the binary that reads the appservice token on every hive's filesystem, # which is the arrangement it exists to end. - swarm-matrix-minter = mkBinPackage "swarm-matrix-minter" "hyperhive matrix admin-credential minter"; + swarm-matrix-minter = mkBinPackage "swarm-matrix-minter" "hyperhive matrix @hive: access-token minter"; # The only process allowed to write swarm-authelia's users database — # same "runs *inside* a container, not on the host" placement as From fb9c6122df48588532c1dcaab70a8b1819b4263a Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 20 Sep 2026 13:28:01 +0200 Subject: [PATCH 4/8] matrix: name the credential after the account it authenticates as MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store path and every identifier around it called this an admin token. It is not one: of ~15 hive-c0re call sites only two need homeserver admin, and the homeserver no longer promotes the account at boot, so the name overstated both what the credential is and what it may do. Renaming it to the account was not enough either. "The `@hive:` token" reads as the token of a hive user, and no such user is provisioned — `@hive:` is the appservice registration's own `sender_localpart`, an account the homeserver creates for itself when it loads the registration. So it is the **sender token**: the matrix appservice sender account's access token, at `swarm/services/matrix/sender-token`. The name says what it authenticates as rather than what it may do, which is the part that was wrong. The path has one constructor, and the bao grant, the grant assertion and three unit tests pin its literal independently — so a half-finished rename fails a check rather than leaving the minter and its readers disagreeing at runtime. `tracing` messages are renamed with the code, so the journal reads the way the source does. The host-side file keeps its name (`matrix/access-token`): it carried no admin framing, and renaming it would orphan the file on every deployed hive for nothing. `docs/tools/hivectl-cli.md` is regenerated from the clap tree. --- docs/getting-started/setup.md | 4 +- docs/integrations/matrix.md | 12 +- docs/swarm/credentials.md | 18 +-- docs/tools/hivectl-cli.md | 4 +- docs/tools/hivectl.md | 6 +- hive-c0re/src/matrix.rs | 197 ++++++++++++++------------ hive-c0re/src/paths.rs | 10 +- hive-c0re/src/server.rs | 16 +-- hivectl/src/cli.rs | 2 +- hivectl/src/matrix.rs | 4 +- nix/host-modules/hive-matrix.nix | 6 +- nix/host-modules/swarm-bao.nix | 6 +- nix/module-eval/bao-grants.nix | 4 +- nix/packages/default.nix | 4 +- swarm-matrix-minter/README.md | 4 +- swarm-matrix-minter/src/homeserver.rs | 2 +- swarm-matrix-minter/src/main.rs | 14 +- swarm-secret-client/src/matrix.rs | 14 +- 18 files changed, 177 insertions(+), 150 deletions(-) diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 1add36d6..cc74ce4b 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -229,7 +229,7 @@ control: [`swarm/ui.md`](../swarm/ui.md). ### 6 · Matrix ```bash -# Ensure the hive's own `@hive:` account exists first +# Ensure the appservice's sender account exists first hivectl matrix sync-admin # Provision ruth's own matrix account — same bootstrap-bypass reasoning @@ -293,7 +293,7 @@ See [`tools/hivectl.md`](../tools/hivectl.md) for every `hivectl` verb. - **No forge admin token is stored in any agent state dir.** Agents hold a regular agent token in their `forge-token` file; sensitive - creds (the core token, the `@hive:` matrix access token) live on the host. + creds (the core token, the matrix sender token) live on the host. - All config changes (forge PRs on `agent-configs/`) go through operator approval — agents can't unilaterally rebuild containers, by design. See [`boundary.md`](../trust-boundary/boundary.md) and [`security.md`](../trust-boundary/security.md). diff --git a/docs/integrations/matrix.md b/docs/integrations/matrix.md index 6be59be0..88db2269 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -147,7 +147,7 @@ a token. (`hive-matrix-daemon.path` watching for `matrix-token` appearance) brings the daemon up on the same boot cycle anyway. -### The `@hive:` account, and why it isn't an admin +### The appservice's sender account, and why it isn't an admin `@hive:` is the appservice's own `sender_localpart`, which the homeserver creates itself when it loads the registration — on a @@ -155,6 +155,14 @@ zero-user database, inside startup, before the HTTP listener accepts anything. It's an **ordinary account**: nothing promotes it, and the homeserver runs no `admin_execute` for it. +Its access token is the **sender token**, and it's the credential +hive-c0re presents for every homeserver call it makes on the hive's +behalf. `swarm-matrix-minter` mints it inside the `hive-matrix` +container and publishes it to `swarm/services/matrix/sender-token`; the +hive reads it from there. The name says what it authenticates as — an +account the appservice registration brings into being — rather than any +privilege level, because it carries none. + It needs no promotion for what the hive does with it. Creating the hive Space and the chat room, writing their hierarchy and join rules, and inviting agents into them are all ordinary client calls that ride on @@ -187,7 +195,7 @@ restarts, so the first boot after the switch already has both halves. - **The per-agent sweep honours existing token files.** It skips any agent that already has a `matrix-token`, so it re-registers no account and displaces no session. -- **`@hive:` may already be an admin** on such a hive (it won the +- **The sender account may already be an admin** on such a hive (it won the first-user grant when the hive was new). Nothing here demotes it; the homeserver no longer promotes it, so a hive built fresh has an ordinary account and an older one keeps whatever standing it acquired. diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 67f731eb..4cf13026 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -54,15 +54,15 @@ strategy for every credential, including the mTLS leaf. -| store path | minter | reader — pulls at runtime, holds in memory | renewal | -| -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | -| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | -| `swarm/agents//bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated | -| `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | -| `swarm/hives//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | -| `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | -| `swarm/services/matrix/hive-access-token` | `swarm-matrix-minter`, in the `hive-matrix` container | `swarm-matrix-minter` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_hive_token()`, under the hive's own certificate | must be stated | -| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | +| store path | minter | reader — pulls at runtime, holds in memory | renewal | +| -------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | +| `swarm/agents//bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated | +| `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | +| `swarm/hives//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | +| `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | +| `swarm/services/matrix/sender-token` | `swarm-matrix-minter`, in the `hive-matrix` container | `swarm-matrix-minter` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated | +| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 5870819f..38cd6602 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -165,7 +165,7 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for ###### **Subcommands:** * `create-user` — Create or refresh the matrix account + access token for `` -* `sync-admin` — Provision (or re-provision) the hive system admin matrix account +* `sync-admin` — Provision (or re-provision) the matrix appservice's sender account * `promote-user` — Promote a matrix user to homeserver admin * `reset-password` — Reset a matrix user's password via the admin API * `invite` — Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent @@ -193,7 +193,7 @@ For an existing agent, persists the token to its state dir; for a human/other ac ## `hivectl matrix sync-admin` -Provision (or re-provision) the hive's own `@hive:` matrix account. +Provision (or re-provision) the matrix appservice's sender account. Runs automatically on startup; run manually to recover a missing access token. diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 8b5c407e..4bb4639c 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -64,7 +64,7 @@ running (`services.hyperhive.deploy.matrix.enable = true`). hivectl matrix create-user iris # provision (or re-provision) matrix account for agent `iris` hivectl matrix create-user mara # create matrix account for a human; prints access_token to stdout hivectl matrix create-user mara --password hunter2 # set a client-login password -hivectl matrix sync-admin # provision / refresh the hive's own `@hive:` account +hivectl matrix sync-admin # provision / refresh the appservice's sender account hivectl matrix promote-user mara # promote an existing matrix user to homeserver admin hivectl matrix reset-password iris # generate and set a new random password for `iris`; prints it hivectl matrix invite mara # invite a user to the hive Space @@ -74,7 +74,7 @@ hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a spec - `create-user`: for agents, persists the `access_token` to `/matrix-token`. Skips registration when the file already exists — delete it first to force re-registration. -- `sync-admin`: ensures the hive's own `@hive:` matrix user exists +- `sync-admin`: ensures the appservice's sender account (`@hive:`) exists (the account `hive-c0re` provisions rooms with). Token persisted to the access token path. Safe to run again — idempotent. - `promote-user`: promotes an already-registered user to homeserver @@ -87,7 +87,7 @@ hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a spec credentials. - `invite`: invites a matrix user (full `@user:server` or a bare localpart, qualified with the homeserver's `server_name`) to the hive - Space by default, or to a `--room` id / `#alias`. Uses the `@hive:` + Space by default, or to a `--room` id / `#alias`. Uses the sender token; the account must be a member of the target room with invite power (it owns the hive Space, so that case always works). Idempotent — already-member / already-invited is a no-op. diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 1c2b16fa..4ba2aa59 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -97,11 +97,11 @@ pub const HIVE_CHAT_ROOM_NAME: &str = "hive-chat"; const HIVE_CHAT_ROOM_TOPIC: &str = "Hive-wide chat for all agents and the operator. Auto-provisioned by hive-c0re."; -/// Host path for the `@hive:` account's matrix access token. Outside every +/// Host path for the appservice sender account's matrix access token. Outside every /// purgeable path — not deleted by `destroy --purge` on any agent. #[must_use] -pub fn hive_token_path() -> PathBuf { - crate::paths::matrix_hive_token() +pub fn sender_token_path() -> PathBuf { + crate::paths::matrix_sender_token() } /// Token file inside the agent's bind-mounted state dir (visible as @@ -399,12 +399,12 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re /// but the password file is absent — covers the case where agent state dirs /// were wiped but the homeserver still has the accounts. async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Result { - let hive_token = read_hive_token() - .context("matrix: the @hive: access token is unavailable for auto-recovery")?; + let sender_token = read_sender_token() + .context("matrix: the matrix sender token is unavailable for auto-recovery")?; let server_name = discover_server_name(client) .await .context("matrix: discover_server_name for auto-recovery")?; - let effective_password = reset_user_password(client, &hive_token, name, &server_name) + let effective_password = reset_user_password(client, &sender_token, name, &server_name) .await .with_context(|| format!("matrix: admin-room password reset for {name} (auto-recovery)"))?; tracing::info!(%name, "matrix: auto-recovered password via admin-room reset"); @@ -424,7 +424,7 @@ fn encode_room_id_for_url(room_id: &str) -> String { /// Look up the room ID for the `#admins:` alias. async fn discover_admin_room_id( client: &reqwest::Client, - hive_token: &str, + sender_token: &str, server_name: &str, ) -> Result { let base = matrix_base()?; @@ -433,7 +433,7 @@ async fn discover_admin_room_id( let url = format!("{base}/_matrix/client/v3/directory/room/{encoded_alias}"); let resp = client .get(&url) - .bearer_auth(hive_token) + .bearer_auth(sender_token) .send() .await .context("matrix: GET admin room alias")?; @@ -561,7 +561,7 @@ mod extract_new_password_tests { /// Generic over `T` so both password-returning and `()` callers share the loop. async fn admin_room_send_and_poll( client: &reqwest::Client, - hive_token: &str, + sender_token: &str, server_name: &str, room_url: &str, command: &str, @@ -574,7 +574,7 @@ async fn admin_room_send_and_poll( format!("{base}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}"); let send_resp = client .put(&send_url) - .bearer_auth(hive_token) + .bearer_auth(sender_token) .json(&serde_json::json!({"msgtype": "m.text", "body": command})) .send() .await @@ -601,7 +601,7 @@ async fn admin_room_send_and_poll( tokio::time::sleep(std::time::Duration::from_secs(1)).await; let poll_json = client .get(&poll_url) - .bearer_auth(hive_token) + .bearer_auth(sender_token) .send() .await .context("matrix: admin room poll")? @@ -655,16 +655,16 @@ async fn admin_room_send_and_poll( /// Returns the new password; caller is responsible for persisting it. async fn admin_room_reset_password( client: &reqwest::Client, - hive_token: &str, + sender_token: &str, server_name: &str, localpart: &str, ) -> Result { - let room_id = discover_admin_room_id(client, hive_token, server_name).await?; + let room_id = discover_admin_room_id(client, sender_token, server_name).await?; let room_url = encode_room_id_for_url(&room_id); let command = format!("!admin users reset-password @{localpart}:{server_name}"); admin_room_send_and_poll( client, - hive_token, + sender_token, server_name, &room_url, &command, @@ -894,7 +894,7 @@ pub async fn sync_agent_standalone(name: &str) { } /// Ensure the `@hive:` matrix user exists and that its access token is -/// persisted at [`hive_token_path()`]. +/// persisted at [`sender_token_path()`]. /// /// **Nothing here depends on registration order, and nothing here is /// privileged.** The account used to have to be the first ever @@ -916,16 +916,16 @@ pub async fn sync_agent_standalone(name: &str) { /// minter. pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Result<()> { use std::os::unix::fs::PermissionsExt; - let path = hive_token_path(); + let path = sender_token_path(); if path.exists() && let Ok(existing) = std::fs::read_to_string(&path) && !existing.trim().is_empty() { - tracing::debug!("matrix: the @hive: access token is already present"); + tracing::debug!("matrix: the sender token is already present"); return Ok(()); } - if let Some(token) = stored_hive_token().await { - return persist_hive_token(&path, &token); + if let Some(token) = stored_sender_token().await { + return persist_sender_token(&path, &token); } let password = random_password()?; let access_token = match register_user(client, HIVE_LOCALPART, as_token, &password).await { @@ -973,34 +973,30 @@ pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Resul } Err(other) => return Err(other), }; - persist_hive_token(&path, &access_token) + persist_sender_token(&path, &access_token) } -/// Write the `@hive:` account's access token to `path`, 0600, creating the +/// Write the appservice sender account's access token to `path`, 0600, creating the /// directory if it is not there. /// /// Shared by both arms of [`ensure_hive_user`] rather than duplicated into /// the store one: the file's mode is the only thing keeping an unprivileged /// reader off the hive's matrix credential, and a second copy of that decision /// is one that can be edited alone. -fn persist_hive_token(path: &std::path::Path, access_token: &str) -> Result<()> { +fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()> { use std::os::unix::fs::PermissionsExt; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } - std::fs::write(path, format!("{access_token}\n")).with_context(|| { - format!( - "matrix: write the @hive: access token to {}", - path.display() - ) - })?; + std::fs::write(path, format!("{access_token}\n")) + .with_context(|| format!("matrix: write the sender token to {}", path.display()))?; let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); - tracing::info!(path = %path.display(), "matrix: provisioned the @hive: access token"); + tracing::info!(path = %path.display(), "matrix: provisioned the sender token"); Ok(()) } -/// Fetch the `@hive:` access token `swarm-matrix-minter` published, under +/// Fetch the sender token `swarm-matrix-minter` published, under /// this hive's own store identity. /// /// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` — @@ -1017,15 +1013,15 @@ fn persist_hive_token(path: &std::path::Path, access_token: &str) -> Result<()> /// sweep. /// /// 🩸 Logs the store **path** and never the value. -async fn stored_hive_token() -> Option { +async fn stored_sender_token() -> Option { let hive = std::env::var("HYPERHIVE_HIVE_NAME") .ok() .filter(|h| !h.is_empty())?; - let path = swarm_secret_client::matrix::hive_token_path(); + let path = swarm_secret_client::matrix::sender_token_path(); let store = match swarm_secret_client::SecretStore::from_env(&hive).await { Ok(store) => store, Err(e) => { - tracing::debug!(error = %e, "matrix: no swarm secret store to read the @hive: access token from"); + tracing::debug!(error = %e, "matrix: no swarm secret store to read the sender token from"); return None; } }; @@ -1034,15 +1030,15 @@ async fn stored_hive_token() -> Option { .await { Ok(credential) if !credential.value.trim().is_empty() => { - tracing::info!(%path, "matrix: taking the @hive: access token from the swarm store"); + tracing::info!(%path, "matrix: taking the sender token from the swarm store"); Some(credential.value) } Ok(_) => { - tracing::warn!(%path, "matrix: the stored @hive: credential is empty; minting instead"); + tracing::warn!(%path, "matrix: the stored sender token is empty; minting instead"); None } Err(e) => { - tracing::debug!(%path, error = %e, "matrix: no @hive: credential in the store; minting instead"); + tracing::debug!(%path, error = %e, "matrix: no sender token in the store; minting instead"); None } } @@ -1116,16 +1112,16 @@ mod is_make_admin_success_tests { /// intended long-term mechanism, not a stopgap awaiting an upstream fix. pub async fn promote_user_to_admin( client: &reqwest::Client, - hive_token: &str, + sender_token: &str, localpart: &str, server_name: &str, ) -> Result<()> { - let room_id = discover_admin_room_id(client, hive_token, server_name).await?; + let room_id = discover_admin_room_id(client, sender_token, server_name).await?; let room_url = encode_room_id_for_url(&room_id); let command = format!("!admin users make-user-admin @{localpart}:{server_name}"); admin_room_send_and_poll( client, - hive_token, + sender_token, server_name, &room_url, &command, @@ -1155,11 +1151,11 @@ pub async fn promote_user_to_admin( /// Returns the new password for use in subsequent `login_user` calls. pub async fn reset_user_password( client: &reqwest::Client, - hive_token: &str, + sender_token: &str, localpart: &str, server_name: &str, ) -> Result { - let pw = admin_room_reset_password(client, hive_token, server_name, localpart) + let pw = admin_room_reset_password(client, sender_token, server_name, localpart) .await .with_context(|| { format!("matrix: admin-room password reset for @{localpart}:{server_name}") @@ -1209,17 +1205,17 @@ pub async fn discover_server_name(client: &reqwest::Client) -> Result { }) } -/// Read the `@hive:` access token from disk. Returns an error if it +/// Read the sender token from disk. Returns an error if it /// is absent — callers should gate their homeserver calls on this. -pub fn read_hive_token() -> Result { - let path = hive_token_path(); +pub fn read_sender_token() -> Result { + let path = sender_token_path(); std::fs::read_to_string(&path) .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) .with_context(|| { format!( - "the @hive: matrix access token was not found at {} — \ + "the matrix sender token was not found at {} — \ ensure hive-c0re has started at least once with matrix enabled \ (it provisions the @hive: account on boot)", path.display() @@ -1248,12 +1244,12 @@ fn persist_space_room_id(room_id: &str) -> Result<()> { /// /// Name-based (not alias-based) rediscovery keeps the Space free of any /// special-char room alias — the hardcoded plain name is the anchor. -async fn find_space_by_name(client: &reqwest::Client, hive_token: &str) -> Option { +async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Option { let base = matrix_http()?; let joined_url = format!("{base}/_matrix/client/v3/joined_rooms"); let joined: serde_json::Value = client .get(&joined_url) - .bearer_auth(hive_token) + .bearer_auth(sender_token) .send() .await .ok()? @@ -1268,7 +1264,12 @@ async fn find_space_by_name(client: &reqwest::Client, hive_token: &str) -> Optio let encoded = encode_room_id_for_url(room_id); // Must be an m.space (m.room.create `type`). let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); - let is_space = match client.get(&create_url).bearer_auth(hive_token).send().await { + let is_space = match client + .get(&create_url) + .bearer_auth(sender_token) + .send() + .await + { Ok(r) if r.status().is_success() => r .json::() .await @@ -1281,7 +1282,7 @@ async fn find_space_by_name(client: &reqwest::Client, hive_token: &str) -> Optio } // …and named HIVE_SPACE_NAME (m.room.name `name`). let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); - let name_matches = match client.get(&name_url).bearer_auth(hive_token).send().await { + let name_matches = match client.get(&name_url).bearer_auth(sender_token).send().await { Ok(r) if r.status().is_success() => r .json::() .await @@ -1312,7 +1313,7 @@ async fn find_space_by_name(client: &reqwest::Client, hive_token: &str) -> Optio /// /// Returns an error if the homeserver is unreachable, `createRoom` fails, /// or the room-ID file cannot be written. -pub async fn ensure_hive_space(client: &reqwest::Client, hive_token: &str) -> Result { +pub async fn ensure_hive_space(client: &reqwest::Client, sender_token: &str) -> Result { let base = matrix_base()?; // 1. Stored room id wins (fast path). if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) { @@ -1325,7 +1326,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, hive_token: &str) -> Re // 2. No stored id — rediscover the existing space by its hardcoded name // before creating a new one (prevents duplicate spaces after a wipe). - if let Some(room_id) = find_space_by_name(client, hive_token).await { + if let Some(room_id) = find_space_by_name(client, sender_token).await { persist_space_room_id(&room_id)?; tracing::info!(%room_id, "matrix: recovered hive space by name"); return Ok(room_id); @@ -1341,7 +1342,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, hive_token: &str) -> Re }); let resp = client .post(&url) - .bearer_auth(hive_token) + .bearer_auth(sender_token) .json(&body) .send() .await @@ -1381,12 +1382,12 @@ fn persist_chat_room_id(room_id: &str) -> Result<()> { /// [`find_space_by_name`] so a lost room-id file recovers the existing chat /// room instead of spawning a duplicate. `None` if the homeserver is /// unreachable or no match exists. -async fn find_chat_room_by_name(client: &reqwest::Client, hive_token: &str) -> Option { +async fn find_chat_room_by_name(client: &reqwest::Client, sender_token: &str) -> Option { let base = matrix_http()?; let joined_url = format!("{base}/_matrix/client/v3/joined_rooms"); let joined: serde_json::Value = client .get(&joined_url) - .bearer_auth(hive_token) + .bearer_auth(sender_token) .send() .await .ok()? @@ -1401,7 +1402,12 @@ async fn find_chat_room_by_name(client: &reqwest::Client, hive_token: &str) -> O let encoded = encode_room_id_for_url(room_id); // Skip the Space itself (and any other m.space). let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); - let is_space = match client.get(&create_url).bearer_auth(hive_token).send().await { + let is_space = match client + .get(&create_url) + .bearer_auth(sender_token) + .send() + .await + { Ok(r) if r.status().is_success() => r .json::() .await @@ -1414,7 +1420,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, hive_token: &str) -> O } // …and named HIVE_CHAT_ROOM_NAME (m.room.name `name`). let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); - let name_matches = match client.get(&name_url).bearer_auth(hive_token).send().await { + let name_matches = match client.get(&name_url).bearer_auth(sender_token).send().await { Ok(r) if r.status().is_success() => r .json::() .await @@ -1454,10 +1460,10 @@ fn state_needs_write(current: Option<&serde_json::Value>, desired: &serde_json:: /// too, loudly — and a 404 is the expected first-setup case. async fn current_room_state( client: &reqwest::Client, - hive_token: &str, + sender_token: &str, url: &str, ) -> Option { - let resp = match client.get(url).bearer_auth(hive_token).send().await { + let resp = match client.get(url).bearer_auth(sender_token).send().await { Ok(resp) => resp, Err(e) => { tracing::debug!(error = ?e, url, "matrix: state read unreachable; writing"); @@ -1485,7 +1491,7 @@ async fn current_room_state( } } -/// PUT a state event into `room_id` using the `@hive:` token, **skipping the +/// PUT a state event into `room_id` using the sender token, **skipping the /// write when the room already carries identical content**. /// /// The read is not an optimisation. A PUT of identical content is a no-op @@ -1500,7 +1506,7 @@ async fn current_room_state( /// missing or divergent link still gets written. async fn set_room_state( client: &reqwest::Client, - hive_token: &str, + sender_token: &str, room_id: &str, event_type: &str, state_key: &str, @@ -1511,7 +1517,7 @@ async fn set_room_state( let encoded_key = encode_room_id_for_url(state_key); let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}"); - let current = current_room_state(client, hive_token, &url).await; + let current = current_room_state(client, sender_token, &url).await; if !state_needs_write(current.as_ref(), content) { tracing::debug!( %room_id, @@ -1522,7 +1528,7 @@ async fn set_room_state( } let resp = client .put(&url) - .bearer_auth(hive_token) + .bearer_auth(sender_token) .json(content) .send() .await @@ -1559,7 +1565,7 @@ async fn set_room_state( /// link is logged but not fatal (the room still exists + is joinable). pub async fn ensure_hive_chat_room( client: &reqwest::Client, - hive_token: &str, + sender_token: &str, space_room_id: &str, server_name: &str, ) -> Result { @@ -1573,7 +1579,7 @@ pub async fn ensure_hive_chat_room( { tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned"); id - } else if let Some(id) = find_chat_room_by_name(client, hive_token).await { + } else if let Some(id) = find_chat_room_by_name(client, sender_token).await { persist_chat_room_id(&id)?; tracing::info!(room_id = %id, "matrix: recovered hive chat room by name"); id @@ -1613,7 +1619,7 @@ pub async fn ensure_hive_chat_room( }); let resp = client .post(&url) - .bearer_auth(hive_token) + .bearer_auth(sender_token) .json(&body) .send() .await @@ -1644,7 +1650,7 @@ pub async fn ensure_hive_chat_room( }); if let Err(e) = set_room_state( client, - hive_token, + sender_token, space_room_id, "m.space.child", &room_id, @@ -1662,21 +1668,21 @@ pub async fn ensure_hive_chat_room( /// account. Idempotent — treats already-member responses as success. async fn invite_to_room( client: &reqwest::Client, - hive_token: &str, + sender_token: &str, room_id: &str, localpart: &str, server_name: &str, ) -> Result<()> { let user_id = format!("@{localpart}:{server_name}"); - invite_user_id(client, hive_token, room_id, &user_id).await + invite_user_id(client, sender_token, room_id, &user_id).await } -/// Fetch a user's current membership in a room via the `@hive:` token, or +/// Fetch a user's current membership in a room via the sender token, or /// `None` if there is no membership event (never invited) or the lookup /// fails. Returns the raw membership string (`invite`, `join`, `leave`, …). async fn room_membership( client: &reqwest::Client, - hive_token: &str, + sender_token: &str, encoded_room_id: &str, user_id: &str, ) -> Option { @@ -1687,7 +1693,12 @@ async fn room_membership( let url = format!( "{base}/_matrix/client/v3/rooms/{encoded_room_id}/state/m.room.member/{encoded_user}" ); - let resp = client.get(&url).bearer_auth(hive_token).send().await.ok()?; + let resp = client + .get(&url) + .bearer_auth(sender_token) + .send() + .await + .ok()?; if !resp.status().is_success() { // 404 = no membership event yet; anything else we treat as "unknown" // and let the caller fall through to the invite attempt. @@ -1698,13 +1709,13 @@ async fn room_membership( } /// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id` -/// using the `@hive:` token. Idempotent: a user who is already a member or +/// using the sender token. Idempotent: a user who is already a member or /// already has a pending invite is left untouched (no fresh invite is sent, /// so they are not re-notified), and a 403 `M_FORBIDDEN` / `M_BAD_STATE` /// from a racing invite is still treated as success. async fn invite_user_id( client: &reqwest::Client, - hive_token: &str, + sender_token: &str, room_id: &str, user_id: &str, ) -> Result<()> { @@ -1716,7 +1727,7 @@ async fn invite_user_id( // Skip the invite entirely when the user is already invited or joined. // Re-POSTing an invite to a pending member re-sends the invite event, // which re-notifies the agent on every provisioning sweep. - if let Some(membership) = room_membership(client, hive_token, &encoded_room_id, user_id).await + if let Some(membership) = room_membership(client, sender_token, &encoded_room_id, user_id).await && matches!(membership.as_str(), "invite" | "join") { tracing::debug!(%user_id, %room_id, %membership, "matrix: invite skipped (already a member/invited)"); @@ -1726,7 +1737,7 @@ async fn invite_user_id( let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room_id}/invite"); let resp = client .post(&url) - .bearer_auth(hive_token) + .bearer_auth(sender_token) .json(&serde_json::json!({ "user_id": user_id })) .send() .await @@ -1759,13 +1770,13 @@ async fn invite_user_id( /// /// # Errors /// -/// Returns an error if the `@hive:` token or `server_name` can't be read, +/// Returns an error if the sender token or `server_name` can't be read, /// the target room can't be resolved (no `--room` and no persisted hive /// space), or the invite POST fails for a reason other than the user /// already being a member / invited. pub async fn invite_user( client: &reqwest::Client, - hive_token: &str, + sender_token: &str, user: &str, room_override: Option<&str>, server_name: &str, @@ -1779,7 +1790,7 @@ pub async fn invite_user( // Resolve the room: explicit override (id or #alias) wins; otherwise // the persisted hive Space. let room_id = match room_override { - Some(r) if r.starts_with('#') => resolve_room_alias(client, hive_token, r).await?, + Some(r) if r.starts_with('#') => resolve_room_alias(client, sender_token, r).await?, Some(r) if r.starts_with('!') => r.to_owned(), Some(r) => anyhow::bail!( "matrix: --room {r:?} is neither a room id nor an alias; \ @@ -1793,14 +1804,14 @@ pub async fn invite_user( (run the hive-c0re matrix sweep first)", )?, }; - invite_user_id(client, hive_token, &room_id, &user_id).await?; + invite_user_id(client, sender_token, &room_id, &user_id).await?; Ok(room_id) } /// Resolve a `#alias:server` to its room id via the directory API. async fn resolve_room_alias( client: &reqwest::Client, - hive_token: &str, + sender_token: &str, alias: &str, ) -> Result { let base = matrix_base()?; @@ -1808,7 +1819,7 @@ async fn resolve_room_alias( let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}"); let resp = client .get(&url) - .bearer_auth(hive_token) + .bearer_auth(sender_token) .send() .await .with_context(|| format!("matrix: GET directory for {alias}"))?; @@ -1899,10 +1910,10 @@ pub async fn ensure_all() -> bool { async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bool { let mut ok = true; // server_name is needed to form full Matrix user IDs for invites. - let hive_token = match read_hive_token() { + let sender_token = match read_sender_token() { Ok(t) => t, Err(e) => { - tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no @hive: access token)"); + tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no matrix sender token)"); return false; } }; @@ -1914,7 +1925,7 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo return false; } }; - let room_id = match ensure_hive_space(client, &hive_token).await { + let room_id = match ensure_hive_space(client, &sender_token).await { Ok(id) => id, Err(e) => { tracing::warn!(error = ?e, "matrix: ensure_hive_space failed"); @@ -1922,14 +1933,20 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo } }; // Invite @hive first, then all agents. - if let Err(e) = - invite_to_room(client, &hive_token, &room_id, HIVE_LOCALPART, &server_name).await + if let Err(e) = invite_to_room( + client, + &sender_token, + &room_id, + HIVE_LOCALPART, + &server_name, + ) + .await { tracing::warn!(error = ?e, "matrix: invite @hive to space failed"); ok = false; } for name in agent_names { - if let Err(e) = invite_to_room(client, &hive_token, &room_id, name, &server_name).await { + if let Err(e) = invite_to_room(client, &sender_token, &room_id, name, &server_name).await { tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed"); ok = false; } @@ -1940,11 +1957,11 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo // rooms to chat in (Matrix semantics — children aren't auto-joined), so // without this the Space is empty. The restricted join rule additionally // lets the operator (a Space member) join from the Space hierarchy. - match ensure_hive_chat_room(client, &hive_token, &room_id, &server_name).await { + match ensure_hive_chat_room(client, &sender_token, &room_id, &server_name).await { Ok(chat_room_id) => { if let Err(e) = invite_to_room( client, - &hive_token, + &sender_token, &chat_room_id, HIVE_LOCALPART, &server_name, @@ -1956,7 +1973,7 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo } for name in agent_names { if let Err(e) = - invite_to_room(client, &hive_token, &chat_room_id, name, &server_name).await + invite_to_room(client, &sender_token, &chat_room_id, name, &server_name).await { tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed"); ok = false; diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index 72ebb64f..b7353d5a 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -2,7 +2,7 @@ //! `/run/hyperhive` + `/run/hive-agent` runtime roots). //! //! Historically these were flat string literals scattered across many -//! modules (`broker.sqlite`, `matrix-hive-token`, `agent-sockets.json`, +//! modules (`broker.sqlite`, `matrix-sender-token`, `agent-sockets.json`, //! …). This module is the **single Rust-side source** for every host //! path — the strictly host-side ones grouped into subdirs (`db/`, //! `forge/`, `matrix/`, `run/`), plus the **nix-coupled** roots @@ -128,7 +128,7 @@ pub fn agent_identity_dir(name: &str) -> PathBuf { agent_identity_root().join(name) } -/// `matrix/` — host-side matrix provisioning state (the `@hive:` access token, hive +/// `matrix/` — host-side matrix provisioning state (the appservice sender token, hive /// Space room id, per-agent password creds). The shared registration /// token is bind-mounted into the tuwunel container via nix and stays /// at its own path (tracked separately). @@ -137,9 +137,9 @@ pub fn matrix_dir() -> PathBuf { state_root().join("matrix") } -/// `matrix/access-token` — the `@hive:` account's matrix access token. +/// `matrix/access-token` — the matrix appservice sender account's access token. #[must_use] -pub fn matrix_hive_token() -> PathBuf { +pub fn matrix_sender_token() -> PathBuf { matrix_dir().join("access-token") } @@ -327,7 +327,7 @@ pub fn relocate_legacy_state() { "forge-agent-configs-avatar-set", forge_config_org_avatar_marker(), ), - ("matrix-hive-token", matrix_hive_token()), + ("matrix-sender-token", matrix_sender_token()), ("matrix-space-room-id", matrix_space_room_id()), ("matrix-creds", matrix_creds_dir()), ("agent-sockets.json", agent_sockets_file()), diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 339be2fb..0af25230 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -455,7 +455,7 @@ async fn stream_agent_status( // The `hivectl matrix` subcommands used to run these in-process, which forced // the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …). // They now run daemon-side over the host socket: the daemon already holds the -// register + `@hive:` tokens and the matrix creds dir. Each op returns the +// register + sender tokens and the matrix creds dir. Each op returns the // operator-facing lines hivectl used to `println!` in `HostResponse::messages` // for the client to print verbatim. // --------------------------------------------------------------------------- @@ -780,7 +780,7 @@ async fn handle_matrix_sync_admin() -> Result { crate::matrix::ensure_hive_user(&client, &as_token) .await .context("matrix sync-admin")?; - let path = crate::matrix::hive_token_path(); + let path = crate::matrix::sender_token_path(); Ok(HostResponse::messages(vec![ format!( "matrix: the @{}: user is provisioned", @@ -792,12 +792,12 @@ async fn handle_matrix_sync_admin() -> Result { async fn handle_matrix_promote_user(name: &str) -> Result { require_matrix_present()?; - let hive_token = crate::matrix::read_hive_token()?; + let sender_token = crate::matrix::read_sender_token()?; let client = matrix_http_client()?; let server_name = crate::matrix::discover_server_name(&client) .await .context("discover matrix server_name")?; - crate::matrix::promote_user_to_admin(&client, &hive_token, name, &server_name) + crate::matrix::promote_user_to_admin(&client, &sender_token, name, &server_name) .await .with_context(|| format!("matrix promote-user {name}"))?; Ok(HostResponse::messages(vec![format!( @@ -807,12 +807,12 @@ async fn handle_matrix_promote_user(name: &str) -> Result { async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result { require_matrix_present()?; - let hive_token = crate::matrix::read_hive_token()?; + let sender_token = crate::matrix::read_sender_token()?; let client = matrix_http_client()?; let server_name = crate::matrix::discover_server_name(&client) .await .context("discover matrix server_name")?; - let room_id = crate::matrix::invite_user(&client, &hive_token, user, room, &server_name) + let room_id = crate::matrix::invite_user(&client, &sender_token, user, room, &server_name) .await .with_context(|| format!("matrix invite {user}"))?; let target = if user.starts_with('@') { @@ -827,12 +827,12 @@ async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result Result { require_matrix_present()?; - let hive_token = crate::matrix::read_hive_token()?; + let sender_token = crate::matrix::read_sender_token()?; let client = matrix_http_client()?; let server_name = crate::matrix::discover_server_name(&client) .await .context("discover matrix server_name")?; - crate::matrix::reset_user_password(&client, &hive_token, name, &server_name) + crate::matrix::reset_user_password(&client, &sender_token, name, &server_name) .await .with_context(|| format!("matrix reset-password {name}"))?; // Password is persisted by reset_user_password. diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index aa48c444..542c36e6 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -335,7 +335,7 @@ pub enum MatrixCmd { #[arg(long, conflicts_with = "password")] password_stdin: bool, }, - /// Provision (or re-provision) the hive's own `@hive:` matrix account. + /// Provision (or re-provision) the matrix appservice's sender account. /// /// Runs automatically on startup; run manually to recover a missing /// access token. diff --git a/hivectl/src/matrix.rs b/hivectl/src/matrix.rs index 32c31ebc..d3d86475 100644 --- a/hivectl/src/matrix.rs +++ b/hivectl/src/matrix.rs @@ -1,5 +1,5 @@ //! `hivectl matrix` — matrix account provisioning verbs. hivectl forwards -//! each request to the daemon (which owns the register + `@hive:` tokens and +//! each request to the daemon (which owns the register + sender tokens and //! the matrix creds dir) and renders the reply; it no longer links the //! matrix machinery itself. @@ -28,7 +28,7 @@ pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()> /// Send a matrix provisioning request to the daemon and print the /// operator-facing result lines it returns. The daemon owns the register + -/// `@hive:` tokens and the matrix creds dir, so hivectl no longer links the +/// sender tokens and the matrix creds dir, so hivectl no longer links the /// matrix machinery — it just forwards the request and renders the reply. async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> { let resp = crate::client::request(socket, req) diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index db0817de..20713724 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -115,7 +115,7 @@ let # ── swarm-matrix-minter ──────────────────────────────────────────────── # - # The oneshot that publishes the `@hive:` account's access token to + # The oneshot that publishes the appservice sender account's access token to # the swarm's secret store. It runs INSIDE the container, beside tuwunel, # because the appservice token that authorises the mint is already in here — # `appserviceDir` below is bound read-only precisely so the homeserver can @@ -1392,7 +1392,7 @@ in "oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}" ]; - # Publish the `@hive:` account's access token to the swarm + # Publish the appservice sender account's access token to the swarm # store, once, under an identity that belongs to this container and # not to the hive. See `minterActive` above for why it runs here. # @@ -1403,7 +1403,7 @@ in # set, because the unit having succeeded is not the idempotency # record — the store is, and it outlives this machine. systemd.services.swarm-matrix-minter = lib.mkIf minterActive { - description = "publish the @hive: matrix credential to the swarm secret store"; + description = "publish the matrix sender token to the swarm secret store"; # Ordered after the homeserver because both of the ladder's arms # are client-server API calls. `wants`, not `requires`: a run that # finds the credential already published never touches tuwunel at diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 2cf2db26..342f6ead 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -249,7 +249,7 @@ let # written by the caller — the same trap as the two grants above. # # Not `swarm/services/*` like the publisher's: this principal produces - # exactly one secret, the `@hive:` account's access token, and a + # exactly one secret, the appservice sender account's access token, and a # homeserver is not entitled to overwrite Grafana's OIDC client. The path is # spelled to the leaf for that reason, not for tidiness. # @@ -260,7 +260,7 @@ let # here recovers one secret this principal itself wrote, which is a much # narrower grant than the publisher's would have been. matrixMinterPolicyText = '' - path "${credentialMountPath}/data/swarm/services/matrix/hive-access-token" { + path "${credentialMountPath}/data/swarm/services/matrix/sender-token" { capabilities = ["create", "update", "read"] } ''; @@ -695,7 +695,7 @@ in description = '' Subject the store's matrix-minter cert-auth role accepts — the identity the oneshot inside the matrix container presents when it - publishes the `@hive:` account's access token. + publishes the appservice sender account's access token. A **third** identity rather than reuse of either sibling above, and the narrowest of the three: its grant is one path, that diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index dcb6830c..4c65a603 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -152,12 +152,12 @@ let # `Kind::label`, which renders the singular for error text. The singular # spelling evaluates, deploys, and 403s every read with "permission # denied" and nothing else. - name = "the matrix minter's grant is the hive credential's path and nothing else"; + name = "the matrix minter's grant is the sender token's path and nothing else"; ok = let s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script; in - lib.hasInfix "path \"secret/data/swarm/services/matrix/hive-access-token\" {" s + lib.hasInfix "path \"secret/data/swarm/services/matrix/sender-token\" {" s && !(lib.hasInfix "secret/data/swarm/services/*" s) && !(lib.hasInfix "secret/data/swarm/agents" s) && !(lib.hasInfix "secret/data/swarm/hives" s) diff --git a/nix/packages/default.nix b/nix/packages/default.nix index 714307bd..fd6d1c67 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -163,12 +163,12 @@ in # rather than every hive's. swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder"; - # The `@hive:` access token's minter. Out of `daemonBins` for the same + # The appservice sender account's access-token minter. Out of `daemonBins` for # "runs *inside* a container, not on the host" reason as the responder # above, and with a second one: putting it in the core bundle would place # the binary that reads the appservice token on every hive's filesystem, # which is the arrangement it exists to end. - swarm-matrix-minter = mkBinPackage "swarm-matrix-minter" "hyperhive matrix @hive: access-token minter"; + swarm-matrix-minter = mkBinPackage "swarm-matrix-minter" "hyperhive matrix appservice sender-token minter"; # The only process allowed to write swarm-authelia's users database — # same "runs *inside* a container, not on the host" placement as diff --git a/swarm-matrix-minter/README.md b/swarm-matrix-minter/README.md index ac0455d8..525fb51e 100644 --- a/swarm-matrix-minter/README.md +++ b/swarm-matrix-minter/README.md @@ -1,7 +1,7 @@ # swarm-matrix-minter A boot-time oneshot that runs **inside `containers.hive-matrix`**, beside the -homeserver, and puts the `@hive:` account's access token into the swarm's secret +homeserver, and puts the appservice sender account's access token into the swarm's secret store under an identity of its own. ## Why it lives in the matrix container @@ -21,7 +21,7 @@ is a property of the thing being minted. ## Idempotency The **store** is the key, not the homeserver. A run reads -`swarm/services/matrix/hive-access-token` first and returns without touching the +`swarm/services/matrix/sender-token` first and returns without touching the homeserver when something is already there. Only an empty path reaches the mint ladder: diff --git a/swarm-matrix-minter/src/homeserver.rs b/swarm-matrix-minter/src/homeserver.rs index 3a82a659..ae44660b 100644 --- a/swarm-matrix-minter/src/homeserver.rs +++ b/swarm-matrix-minter/src/homeserver.rs @@ -4,7 +4,7 @@ //! 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 — so a `body: {json}` in a message here would put the -//! `@hive:` credential in the journal. +//! sender token in the journal. use anyhow::{Context, Result, bail}; diff --git a/swarm-matrix-minter/src/main.rs b/swarm-matrix-minter/src/main.rs index 25cd3fcb..9b14068e 100644 --- a/swarm-matrix-minter/src/main.rs +++ b/swarm-matrix-minter/src/main.rs @@ -1,4 +1,4 @@ -//! Mint the `@hive:` account's homeserver access token, once, and publish it to the +//! Mint the matrix appservice sender account's homeserver access token, once, and publish it to the //! swarm's secret store. //! //! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the @@ -137,9 +137,9 @@ async fn main() -> Result<()> { ) })?; - let path = matrix::hive_token_path(); + let path = matrix::sender_token_path(); if already_published(&store, &path).await { - tracing::info!(%path, "the @hive: credential is already published; not minting"); + tracing::info!(%path, "the sender token is already published; not minting"); return Ok(()); } @@ -167,8 +167,8 @@ async fn main() -> Result<()> { }, ) .await - .with_context(|| format!("writing the @hive: credential to {path}"))?; - tracing::info!(%path, "published the @hive: credential"); + .with_context(|| format!("writing the sender token to {path}"))?; + tracing::info!(%path, "published the sender token"); Ok(()) } @@ -242,8 +242,8 @@ mod tests { // and names the literal so a move of the path is a deliberate edit on // both sides rather than a silent 404 on the reading one. assert_eq!( - matrix::hive_token_path(), - "swarm/services/matrix/hive-access-token" + matrix::sender_token_path(), + "swarm/services/matrix/sender-token" ); } } diff --git a/swarm-secret-client/src/matrix.rs b/swarm-secret-client/src/matrix.rs index 1aa39a22..d956dbac 100644 --- a/swarm-secret-client/src/matrix.rs +++ b/swarm-secret-client/src/matrix.rs @@ -34,10 +34,12 @@ pub fn account_path(agent: &str, account: &str) -> Result { /// "only once" without a lock. pub const HOMESERVER_SERVICE: &str = "matrix"; -/// The path holding the `@hive:` account's homeserver access token. +/// The path holding the matrix appservice sender account's homeserver +/// access token. /// /// Keyed per **homeserver**, not per hive and not per agent: a homeserver has one -/// `@hive:` account (hive-c0re's `matrix::HIVE_LOCALPART`), so a +/// appservice registration and so one sender account (`@hive:`, +/// hive-c0re's `matrix::HIVE_LOCALPART`), so a /// per-hive copy would be several names for one secret. That is also why this /// takes no argument and cannot fail — there is no caller-supplied segment in /// it to reject. @@ -45,9 +47,9 @@ pub const HOMESERVER_SERVICE: &str = "matrix"; /// Reachable by every hive without a new grant: [`crate::policy::render`] /// already grants a hive read on the whole [`Kind::Service`] tree. #[must_use] -pub fn hive_token_path() -> String { +pub fn sender_token_path() -> String { format!( - "{ROOT}/{}/{HOMESERVER_SERVICE}/hive-access-token", + "{ROOT}/{}/{HOMESERVER_SERVICE}/sender-token", <&str>::from(Kind::Service) ) } @@ -121,7 +123,7 @@ mod tests { // segment is PLURAL; `Kind::label` renders the singular and is for // error text only, so reading it as the path segment produces a // 403 the store explains as "permission denied" and nothing else. - assert_eq!(hive_token_path(), "swarm/services/matrix/hive-access-token"); + assert_eq!(sender_token_path(), "swarm/services/matrix/sender-token"); } #[test] @@ -131,7 +133,7 @@ mod tests { // neighbouring tree instead of this one. let prefix = principal_prefix(Kind::Service, HOMESERVER_SERVICE).expect("a plain name is legal"); - assert_eq!(hive_token_path(), format!("{prefix}/hive-access-token")); + assert_eq!(sender_token_path(), format!("{prefix}/sender-token")); } #[test] From 67ba28448f9e18aee6a2fffd070838101da0a34a Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 20 Sep 2026 14:29:45 +0200 Subject: [PATCH 5/8] swarm-matrix-ctl: one control binary for the matrix container, not one per job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames `swarm-matrix-minter` and reshapes it around subcommands. Minting is now `swarm-matrix-ctl mint`. Running rust inside `containers.hive-matrix` is not free: it needs its own store identity, its own cert role and its own bind mounts, and every one of those is per-*container*, not per-task. A second single-purpose crate would have had to duplicate that plumbing to add one action, so the next thing that has to run in there should be a verb here rather than a new crate. The old name guaranteed the opposite. `main.rs` is clap dispatch; the minting logic moves to `mint.rs` unchanged. A bare invocation is refused: `mint` writes a credential, so "no verb" defaulting to it would make a typo in the unit mint rather than fail. The environment prefix moves with it, `MATRIX_MINTER_*` → `MATRIX_MINT_*`. Scoped to the verb and not to the binary, because a binary-scoped prefix is one the next verb has to share or widen, and a widened one never narrows again. A test asserts every variable carries the verb's prefix. The principal renames too. The cert role, bao policy, granting unit, leaf filename and `certAuthCns` entry all have to spell one string the same way, so leaving them as `swarm-matrix-minter` would have rebuilt the naming split this branch exists to remove. Renaming the nix options alongside is free here: every one of them is introduced by this PR and has never been released, so no operator config names them yet. `ExecStart` now names the verb, which is a contract between a nix string and a clap enum that fails at deploy time with no local signal. Both ends assert it: `mint_is_spelled_the_way_the_unit_invokes_it` in the crate, and a new module-eval arm reading the rendered `ExecStart`. docs/getting-started/setup.md drops the sender token from its "live on the host" list: setup does not touch this credential, so a setup guide has no reason to name it. --- Cargo.lock | 3 +- Cargo.toml | 2 +- docs/getting-started/setup.md | 2 +- docs/integrations/matrix.md | 2 +- docs/swarm/credentials.md | 18 ++-- flake.nix | 4 +- hive-c0re/src/matrix.rs | 10 +- nix/host-modules/default.nix | 2 +- nix/host-modules/glue-bao-tls.nix | 6 +- ...y.nix => glue-matrix-ctl-bao-identity.nix} | 12 +-- nix/host-modules/hive-matrix.nix | 77 ++++++++------- nix/host-modules/swarm-bao.nix | 40 ++++---- nix/host-modules/swarm.nix | 2 +- nix/module-eval/bao-grants.nix | 20 ++-- nix/module-eval/bao-matrix-reader.nix | 54 +++++++---- nix/module-eval/name-guards.nix | 12 +-- nix/packages/default.nix | 4 +- .../Cargo.toml | 8 +- .../README.md | 38 ++++++-- .../src/homeserver.rs | 0 swarm-matrix-ctl/src/main.rs | 96 +++++++++++++++++++ .../main.rs => swarm-matrix-ctl/src/mint.rs | 79 ++++++++------- .../src/registration.rs | 0 23 files changed, 319 insertions(+), 172 deletions(-) rename nix/host-modules/{glue-matrix-minter-bao-identity.nix => glue-matrix-ctl-bao-identity.nix} (73%) rename {swarm-matrix-minter => swarm-matrix-ctl}/Cargo.toml (64%) rename {swarm-matrix-minter => swarm-matrix-ctl}/README.md (51%) rename {swarm-matrix-minter => swarm-matrix-ctl}/src/homeserver.rs (100%) create mode 100644 swarm-matrix-ctl/src/main.rs rename swarm-matrix-minter/src/main.rs => swarm-matrix-ctl/src/mint.rs (80%) rename {swarm-matrix-minter => swarm-matrix-ctl}/src/registration.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index feee92c3..d0371900 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4925,10 +4925,11 @@ dependencies = [ ] [[package]] -name = "swarm-matrix-minter" +name = "swarm-matrix-ctl" version = "0.1.0" dependencies = [ "anyhow", + "clap", "reqwest", "serde_json", "swarm-secret-client", diff --git a/Cargo.toml b/Cargo.toml index 693a7dee..a4baf59e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ members = [ "swarm-authelia-bridge", "swarm-authelia-bridge-sock", "swarm-controller", - "swarm-matrix-minter", + "swarm-matrix-ctl", "swarm-nats-auth", "swarm-queue-client", "swarm-logs", diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index cc74ce4b..ea23be35 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -293,7 +293,7 @@ See [`tools/hivectl.md`](../tools/hivectl.md) for every `hivectl` verb. - **No forge admin token is stored in any agent state dir.** Agents hold a regular agent token in their `forge-token` file; sensitive - creds (the core token, the matrix sender token) live on the host. + creds (the core token) live on the host. - All config changes (forge PRs on `agent-configs/`) go through operator approval — agents can't unilaterally rebuild containers, by design. See [`boundary.md`](../trust-boundary/boundary.md) and [`security.md`](../trust-boundary/security.md). diff --git a/docs/integrations/matrix.md b/docs/integrations/matrix.md index 88db2269..8e0abbdc 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -157,7 +157,7 @@ homeserver runs no `admin_execute` for it. Its access token is the **sender token**, and it's the credential hive-c0re presents for every homeserver call it makes on the hive's -behalf. `swarm-matrix-minter` mints it inside the `hive-matrix` +behalf. `swarm-matrix-ctl` mints it inside the `hive-matrix` container and publishes it to `swarm/services/matrix/sender-token`; the hive reads it from there. The name says what it authenticates as — an account the appservice registration brings into being — rather than any diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 4cf13026..965fd2d7 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -54,15 +54,15 @@ strategy for every credential, including the mTLS leaf. -| store path | minter | reader — pulls at runtime, holds in memory | renewal | -| -------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | -| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | -| `swarm/agents//bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated | -| `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | -| `swarm/hives//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | -| `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | -| `swarm/services/matrix/sender-token` | `swarm-matrix-minter`, in the `hive-matrix` container | `swarm-matrix-minter` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated | -| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | +| store path | minter | reader — pulls at runtime, holds in memory | renewal | +| -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- | +| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | +| `swarm/agents//bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated | +| `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | +| `swarm/hives//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | +| `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | +| `swarm/services/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated | +| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | diff --git a/flake.nix b/flake.nix index ec910de8..3171895d 100644 --- a/flake.nix +++ b/flake.nix @@ -156,9 +156,9 @@ services.hyperhive.deploy.nats.authPackage = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth; - services.hyperhive.deploy.matrix.minterPackage = + services.hyperhive.deploy.matrix.ctlPackage = lib.mkDefault - self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-matrix-minter; + self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-matrix-ctl; services.hyperhive.deploy.authelia.bridgePackage = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge; diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 4ba2aa59..a93a2e6e 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -908,12 +908,12 @@ pub async fn sync_agent_standalone(name: &str) { /// and is non-empty. /// /// The token is taken from the **swarm secret store** when it is there: -/// `swarm-matrix-minter`, the oneshot inside the matrix container, publishes +/// `swarm-matrix-ctl`, the oneshot inside the matrix container, publishes /// it under an identity of its own, and taking it from there is what lets a /// hive that holds no `as_token` have an admin at all. The mint ladder below /// stays as the fallback for a store that is empty, unconfigured or /// unreachable — which is every swarm whose matrix container predates that -/// minter. +/// binary. pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Result<()> { use std::os::unix::fs::PermissionsExt; let path = sender_token_path(); @@ -996,19 +996,19 @@ fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<() Ok(()) } -/// Fetch the sender token `swarm-matrix-minter` published, under +/// Fetch the sender token `swarm-matrix-ctl` published, under /// this hive's own store identity. /// /// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` — /// the same role string `workers::credential` logs in with, and already in /// this process's environment, so the store read costs no plumbing through /// [`ensure_all`]. No new grant either: a hive's policy already covers the -/// whole `swarm/services/*` tree the minter writes into. +/// whole `swarm/services/*` tree `swarm-matrix-ctl` writes into. /// /// `None`, never an error, for every way this can come up empty — no hive /// name, no `BAO_*` identity, an unreachable store, nothing at the path. All /// four mean the same thing to the caller ("mint it the old way"), and three -/// of them are the ordinary state of a swarm that has not deployed the minter +/// of them are the ordinary state of a swarm that has not deployed `swarm-matrix-ctl` /// yet, so raising would turn a supported deployment into a warning every /// sweep. /// diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index 14f62bf1..b4f1e7e8 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -27,7 +27,7 @@ ./glue-controller-bao-identity.nix ./glue-grafana-oidc-client.nix ./glue-matrix-bao-token.nix - ./glue-matrix-minter-bao-identity.nix + ./glue-matrix-ctl-bao-identity.nix ./glue-queue-agent-credential.nix ./glue-secret-publisher-bao-identity.nix ./glue-swarm-otel-oidc-client.nix diff --git a/nix/host-modules/glue-bao-tls.nix b/nix/host-modules/glue-bao-tls.nix index 88eb78b7..819569bf 100644 --- a/nix/host-modules/glue-bao-tls.nix +++ b/nix/host-modules/glue-bao-tls.nix @@ -148,7 +148,7 @@ in [ -s ${pkiDir}/secret-publisher.pem ] || ${signLeaf} ${pkiDir} secret-publisher \ ${lib.escapeShellArg deployCfg.bao.secretPublisherCommonName} "" clientAuth - # The matrix container's minter. Minted unconditionally like the two + # The matrix container's `swarm-matrix-ctl`. Minted unconditionally like the two # above, and for the third variant of the same reason: the homeserver # is a swarm singleton, so on every hive but the one running it this # leaf is the file an operator copies rather than a file anything @@ -158,8 +158,8 @@ in # name and its policy reads the whole store, while this principal may # only write one path — which is the entire point of giving the # container an identity instead of lending it the hive's. - [ -s ${pkiDir}/matrix-minter.pem ] || ${signLeaf} ${pkiDir} matrix-minter \ - ${lib.escapeShellArg deployCfg.bao.matrixMinterCommonName} "" clientAuth + [ -s ${pkiDir}/matrix-ctl.pem ] || ${signLeaf} ${pkiDir} matrix-ctl \ + ${lib.escapeShellArg deployCfg.bao.matrixCtlCommonName} "" clientAuth ''; }; }; diff --git a/nix/host-modules/glue-matrix-minter-bao-identity.nix b/nix/host-modules/glue-matrix-ctl-bao-identity.nix similarity index 73% rename from nix/host-modules/glue-matrix-minter-bao-identity.nix rename to nix/host-modules/glue-matrix-ctl-bao-identity.nix index 2274b4cc..0d725ad4 100644 --- a/nix/host-modules/glue-matrix-minter-bao-identity.nix +++ b/nix/host-modules/glue-matrix-ctl-bao-identity.nix @@ -1,12 +1,12 @@ -# Glue: point the matrix container's minter at the bao leaf minted for it. +# Glue: point the matrix container's `swarm-matrix-ctl` at the bao leaf minted for it. # -# ONE PAIRING PER FILE — minter ← bao, and nothing else. Deleting this leaves a -# minter that takes operator-provided certificate paths, which is what any +# ONE PAIRING PER FILE — matrix-ctl ← bao, and nothing else. Deleting this leaves a +# binary that takes operator-provided certificate paths, which is what any # deployment not minting its own already does. # # ⚠️ The minting is NOT here. ./glue-bao-tls.nix holds the CA and signs the # leaf, because the thing that owns a private key owns issuing from it. What -# belongs here is the pairing: which paths the minter presents. +# belongs here is the pairing: which paths matrix-ctl presents. # # ⚠️ Gated on the leaf existing, not on the store being enabled — the same rule # ./glue-secret-publisher-bao-identity.nix states, and it bites harder here: a @@ -33,8 +33,8 @@ in { config = lib.mkIf (hyperhiveCfg.enable && deployCfg.matrix.enable && haveMintedPki) { services.hyperhive.deploy.matrix = { - minterBaoClientCertFile = lib.mkDefault "${pkiDir}/matrix-minter.pem"; - minterBaoClientKeyFile = lib.mkDefault "${pkiDir}/matrix-minter-key.pem"; + ctlBaoClientCertFile = lib.mkDefault "${pkiDir}/matrix-ctl.pem"; + ctlBaoClientKeyFile = lib.mkDefault "${pkiDir}/matrix-ctl-key.pem"; }; }; } diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 20713724..b2635eea 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -113,7 +113,7 @@ let appserviceCredentialId = "${appserviceId}-appservice.yaml"; appserviceCredentialDir = "/run/credentials/tuwunel.service"; - # ── swarm-matrix-minter ──────────────────────────────────────────────── + # ── swarm-matrix-ctl ──────────────────────────────────────────────── # # The oneshot that publishes the appservice sender account's access token to # the swarm's secret store. It runs INSIDE the container, beside tuwunel, @@ -127,16 +127,16 @@ let # "co-located with bao" would leave the intended deployment silently minting # nothing. Same rule ./swarm-secret-publisher.nix's `haveClientIdentity` # states, for a sharper reason. - minterActive = - deployCfg.matrix.minterBaoClientCertFile != null && deployCfg.matrix.minterBaoClientKeyFile != null; + ctlActive = + deployCfg.matrix.ctlBaoClientCertFile != null && deployCfg.matrix.ctlBaoClientKeyFile != null; # The role on the store's `cert` auth mount, and so the single source of the # string both ends must agree on: ./swarm-bao.nix writes the role under - # `matrixMinterPolicyName` and this hands it to the binary in the + # `matrixCtlPolicyName` and this hands it to the binary in the # environment, so the binary itself spells no role at all. - minterCertRole = "swarm-matrix-minter"; + ctlCertRole = "swarm-matrix-ctl"; - # Every host directory the minter's bao identity is spread across. Normally + # Every host directory matrix-ctl's bao identity is spread across. Normally # one — ./glue-bao-tls.nix puts all three files in ./glue's PKI dir — but # derived rather than assumed, because an operator naming paths by hand is # exactly the deployment the gate above is written for. @@ -144,29 +144,29 @@ let # Directories rather than the files, for the reason `appserviceDir`'s own # comment gives: a re-issued leaf is a new inode, and binding the file would # pin the one the container saw when it started. - minterPkiDirs = lib.optionals minterActive ( + ctlPkiDirs = lib.optionals ctlActive ( lib.unique ( map builtins.dirOf ( [ - deployCfg.matrix.minterBaoClientCertFile - deployCfg.matrix.minterBaoClientKeyFile + deployCfg.matrix.ctlBaoClientCertFile + deployCfg.matrix.ctlBaoClientKeyFile ] ++ lib.optional (deployCfg.bao.serverCaFile != null) deployCfg.bao.serverCaFile ) ) ); - minterBindMounts = lib.genAttrs minterPkiDirs (dir: { + ctlBindMounts = lib.genAttrs ctlPkiDirs (dir: { hostPath = dir; isReadOnly = true; }); # Where a reader of the published credential is told the token is good for. # Empty when this hive serves no vhost: `matrix::Credential.homeserver` is an - # `Option`, and the minter reads an empty variable as absent rather than as + # `Option`, and matrix-ctl reads an empty variable as absent rather than as # the string "null" — which is what a hive with no gateway host actually # knows about itself. - minterHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}"; + ctlHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}"; # Every local user this hive may provision — agents, `@hive:` itself, and # the operator accounts `hivectl matrix create-user` makes, which is the @@ -748,26 +748,26 @@ in ''; }; - minterPackage = lib.mkOption { + ctlPackage = lib.mkOption { type = lib.types.package; - defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-matrix-minter"; + defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-matrix-ctl"; description = '' - The `swarm-matrix-minter` build run inside the matrix container. + The `swarm-matrix-ctl` build run inside the matrix container. - ⚠️ Named `minterPackage`, not folded into `package` above: that one is + ⚠️ Named `ctlPackage`, not folded into `package` above: that one is the homeserver, and this is a hyperhive binary that happens to run beside it. Same split, and same reason, as {option}`services.hyperhive.deploy.nats.authPackage`. ''; }; - minterBaoClientCertFile = lib.mkOption { + ctlBaoClientCertFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = '' - Client certificate the matrix container's minter presents to the + Client certificate the matrix container's `swarm-matrix-ctl` presents to the swarm's secret store. Its subject must be - {option}`services.hyperhive.deploy.bao.matrixMinterCommonName` — cert + {option}`services.hyperhive.deploy.bao.matrixCtlCommonName` — cert auth matches on the CN, and the role accepts nothing else. ⚠️ **Not the hive's own leaf**, and that is the whole deliverable of @@ -778,7 +778,7 @@ in No default: a module that guessed would be holding the CA opinion ./swarm-bao.nix deliberately does not hold. - ./glue-matrix-minter-bao-identity.nix points it at the leaf + ./glue-matrix-ctl-bao-identity.nix points it at the leaf ./glue-bao-tls.nix mints, where this host mints one. The file and its key are bind-mounted into the container read-only. @@ -788,13 +788,13 @@ in ''; }; - minterBaoClientKeyFile = lib.mkOption { + ctlBaoClientKeyFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = '' Private key for - {option}`services.hyperhive.deploy.matrix.minterBaoClientCertFile`. - Both or neither — the minter unit does not exist unless each is set. + {option}`services.hyperhive.deploy.matrix.ctlBaoClientCertFile`. + Both or neither — the unit does not exist unless each is set. ''; }; }; @@ -1194,10 +1194,10 @@ in }; } // caTrust.bindMount - # The minter's bao client identity, and nothing else of the hive's. See - # `minterPkiDirs` above for why it is a derived set of directories + # matrix-ctl's bao client identity, and nothing else of the hive's. See + # `ctlPkiDirs` above for why it is a derived set of directories # rather than one literal. - // minterBindMounts; + // ctlBindMounts; config = { ... }: { @@ -1394,7 +1394,7 @@ in # Publish the appservice sender account's access token to the swarm # store, once, under an identity that belongs to this container and - # not to the hive. See `minterActive` above for why it runs here. + # not to the hive. See `ctlActive` above for why it runs here. # # A `oneshot` with no timer and no retry loop of its own: the whole # of "and only once" is the binary's first act, a read of the path it @@ -1402,7 +1402,7 @@ in # a homeserver still starting; `RemainAfterExit` is deliberately NOT # set, because the unit having succeeded is not the idempotency # record — the store is, and it outlives this machine. - systemd.services.swarm-matrix-minter = lib.mkIf minterActive { + systemd.services.swarm-matrix-ctl = lib.mkIf ctlActive { description = "publish the matrix sender token to the swarm secret store"; # Ordered after the homeserver because both of the ladder's arms # are client-server API calls. `wants`, not `requires`: a run that @@ -1414,29 +1414,32 @@ in wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; - ExecStart = "${deployCfg.matrix.minterPackage}/bin/swarm-matrix-minter"; + # The verb is part of the contract: `swarm-matrix-ctl` is a + # subcommand binary and refuses a bare invocation, so dropping + # `mint` here fails the unit rather than doing something else. + ExecStart = "${deployCfg.matrix.ctlPackage}/bin/swarm-matrix-ctl mint"; Restart = "on-failure"; RestartSec = 30; # Bounded here rather than left to systemd's default, for the # reason ./swarm-secret-publisher.nix states: a sealed store # answers on the port and never answers the read. TimeoutStartSec = 60; - SyslogIdentifier = "swarm-matrix-minter"; + SyslogIdentifier = "swarm-matrix-ctl"; }; environment = { BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}"; - BAO_CLIENT_CERT = deployCfg.matrix.minterBaoClientCertFile; - BAO_CLIENT_KEY = deployCfg.matrix.minterBaoClientKeyFile; - MATRIX_MINTER_CERT_ROLE = minterCertRole; + BAO_CLIENT_CERT = deployCfg.matrix.ctlBaoClientCertFile; + BAO_CLIENT_KEY = deployCfg.matrix.ctlBaoClientKeyFile; + MATRIX_MINT_CERT_ROLE = ctlCertRole; # Loopback: this container shares the host netns, so the # homeserver it must talk to is the one in this very unit's # netns and needs no name, no vhost and no TLS. - MATRIX_MINTER_API_URL = "http://127.0.0.1:${toString cfg.httpPort}"; + MATRIX_MINT_API_URL = "http://127.0.0.1:${toString cfg.httpPort}"; # The bind-mounted registration, which IS the as_token. A path, # never a value. - MATRIX_MINTER_REGISTRATION = appserviceRegistrationPath; - MATRIX_MINTER_LOCALPART = hiveLocalpart; - MATRIX_MINTER_HOMESERVER = minterHomeserverUrl; + MATRIX_MINT_REGISTRATION = appserviceRegistrationPath; + MATRIX_MINT_LOCALPART = hiveLocalpart; + MATRIX_MINT_HOMESERVER = ctlHomeserverUrl; } // lib.optionalAttrs (deployCfg.bao.serverCaFile != null) { BAO_CACERT = deployCfg.bao.serverCaFile; diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 342f6ead..d2b0e62c 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -236,12 +236,12 @@ let } ''; - # The identity the matrix container's minter presents. Named outside `hive-*` + # The identity the matrix container's `swarm-matrix-ctl` presents. Named outside `hive-*` # for the reason its two siblings above give — the controller may rewrite # every policy under that prefix, and a policy it can rewrite constrains # nothing. - matrixMinterPolicyName = "swarm-matrix-minter"; - matrixMinterCn = baoDeploy.matrixMinterCommonName; + matrixCtlPolicyName = "swarm-matrix-ctl"; + matrixCtlCn = baoDeploy.matrixCtlCommonName; # ONE path, and every narrowing in it is load-bearing. # @@ -254,12 +254,12 @@ let # spelled to the leaf for that reason, not for tidiness. # # `read` as well as write, unlike either sibling, and it is what makes "and - # only once" mechanical: the minter's first act is to read this path back and + # only once" mechanical: matrix-ctl's first act is to read this path back and # stop if something is there, so without the capability every container # restart would mint a second access token and invalidate the hive's. A read # here recovers one secret this principal itself wrote, which is a much # narrower grant than the publisher's would have been. - matrixMinterPolicyText = '' + matrixCtlPolicyText = '' path "${credentialMountPath}/data/swarm/services/matrix/sender-token" { capabilities = ["create", "update", "read"] } @@ -688,12 +688,12 @@ in ''; }; - matrixMinterCommonName = lib.mkOption { + matrixCtlCommonName = lib.mkOption { type = lib.types.str; - default = "swarm-matrix-minter"; - example = "swarm-matrix-minter.svc"; + default = "swarm-matrix-ctl"; + example = "swarm-matrix-ctl.svc"; description = '' - Subject the store's matrix-minter cert-auth role accepts — the + Subject the store's matrix-ctl cert-auth role accepts — the identity the oneshot inside the matrix container presents when it publishes the appservice sender account's access token. @@ -707,7 +707,7 @@ in ⚠️ Same collision as its siblings, and the same answer: ./swarm.nix feeds this value into the guard on {option}`services.hyperhive.swarm.hives`, so a hive named after it - fails evaluation rather than silently receiving the minter's grant. + fails evaluation rather than silently receiving matrix-ctl's grant. ''; }; @@ -1133,7 +1133,7 @@ in # ⚠️ Deliberately EMPTY: nothing here generates an issuer into it, so # the role below can be written but cannot yet issue. That is the # whole shape of this step — the mount, its narrowing and its grant - # exist, and the minter that uses them arrives separately. Undoing it + # exist, and the binary that uses them arrives separately. Undoing it # is `bao secrets disable ${servicesPkiMountPath}`. case "$mounts" in *'"${servicesPkiMountPath}/"'*) ;; @@ -1157,7 +1157,7 @@ in # is a name type this issuer is unconstrained for. # `server_flag` alone because that is what today's leaf carries # (`extendedKeyUsage=serverAuth`, ./hive-tls.nix:144), and rsa:4096 - # because that is the key the current minter generates. + # because that is the key the current implementation generates. # # An empty `swarm.serviceDomains` renders an empty `allowed_domains`, # which with `allow_any_name=false` is a role that issues nothing — @@ -1281,8 +1281,8 @@ in # creates the mounts this one writes into, but a failed oneshot still # counts as finished, so only ordering plus this unit's own retry # converges. - systemd.services.swarm-bao-matrix-minter-policy = lib.mkIf haveBootstrapToken { - description = "write the swarm matrix minter's bao policy and cert-auth role"; + systemd.services.swarm-bao-matrix-ctl-policy = lib.mkIf haveBootstrapToken { + description = "write swarm-matrix-ctl's bao policy and cert-auth role"; after = [ "container@${cfg.machine}.service" "swarm-bao-controller-policy.service" @@ -1309,16 +1309,16 @@ in BAO_TOKEN="$(cat ${lib.escapeShellArg baoDeploy.bootstrapTokenFile})" export BAO_TOKEN - printf '%s' ${lib.escapeShellArg matrixMinterPolicyText} | - bao policy write ${lib.escapeShellArg matrixMinterPolicyName} - + printf '%s' ${lib.escapeShellArg matrixCtlPolicyText} | + bao policy write ${lib.escapeShellArg matrixCtlPolicyName} - '' + lib.optionalString (baoDeploy.clientCaFile != null) '' - bao write auth/cert/certs/${lib.escapeShellArg matrixMinterPolicyName} \ + bao write auth/cert/certs/${lib.escapeShellArg matrixCtlPolicyName} \ certificate=@${tlsDir}/client-ca.pem \ - allowed_common_names=${lib.escapeShellArg matrixMinterCn} \ - token_policies=${lib.escapeShellArg matrixMinterPolicyName} \ - display_name=${lib.escapeShellArg matrixMinterCn} + allowed_common_names=${lib.escapeShellArg matrixCtlCn} \ + token_policies=${lib.escapeShellArg matrixCtlPolicyName} \ + display_name=${lib.escapeShellArg matrixCtlCn} ''; }; diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 118bd261..fd1fe9c8 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -46,7 +46,7 @@ let certAuthCns = [ deployCfg.bao.controllerCommonName deployCfg.bao.secretPublisherCommonName - deployCfg.bao.matrixMinterCommonName + deployCfg.bao.matrixCtlCommonName ]; # Public hostnames of the swarm's own services, in declaration order. diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index 4c65a603..c6abcc45 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -152,10 +152,10 @@ let # `Kind::label`, which renders the singular for error text. The singular # spelling evaluates, deploys, and 403s every read with "permission # denied" and nothing else. - name = "the matrix minter's grant is the sender token's path and nothing else"; + name = "matrix-ctl's grant is the sender token's path and nothing else"; ok = let - s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script; + s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script; in lib.hasInfix "path \"secret/data/swarm/services/matrix/sender-token\" {" s && !(lib.hasInfix "secret/data/swarm/services/*" s) @@ -165,30 +165,30 @@ let } { # 🩸 `read` is load-bearing here and is the one capability neither - # sibling has. The minter's first act is to read this path back and stop + # sibling has. matrix-ctl's first act is to read this path back and stop # if something is there — that read IS "and only once", so without the # capability every container restart would mint a second access token and # invalidate the hive's. - name = "the matrix minter may read back the one path it writes"; + name = "matrix-ctl may read back the one path it writes"; ok = let - s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script; + s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script; in lib.hasInfix "capabilities = [\"create\", \"update\", \"read\"]" s - && lib.hasInfix "auth/cert/certs/swarm-matrix-minter" s - && lib.hasInfix "allowed_common_names=swarm-matrix-minter" s; + && lib.hasInfix "auth/cert/certs/swarm-matrix-ctl" s + && lib.hasInfix "allowed_common_names=swarm-matrix-ctl" s; } { # Same two controls its siblings carry: ordered after the unit that makes # the mounts it writes into, and rendered on the HOST rather than inside # the store's container, where it would have neither an identity nor a # route to the store. - name = "the minter's granting unit is ordered after the mounts and rendered on the host"; + name = "matrix-ctl's granting unit is ordered after the mounts and rendered on the host"; ok = lib.elem "swarm-bao-controller-policy.service" ( - baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.after + baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.after ) - && !(baoGrantHere.containers.swarm-bao.config.systemd.services ? swarm-bao-matrix-minter-policy); + && !(baoGrantHere.containers.swarm-bao.config.systemd.services ? swarm-bao-matrix-ctl-policy); } { # The policy authorising this route lives in another file, and nothing diff --git a/nix/module-eval/bao-matrix-reader.nix b/nix/module-eval/bao-matrix-reader.nix index eb815761..bf16a643 100644 --- a/nix/module-eval/bao-matrix-reader.nix +++ b/nix/module-eval/bao-matrix-reader.nix @@ -39,7 +39,7 @@ let }; # A homeserver on a hive with NO store identity at all — neither a local - # store nor a hand-placed leaf. The absence arm for the minter cases below + # store nor a hand-placed leaf. The absence arm for the matrix-ctl cases below # needs it, and defining it here rather than importing keeps each group's # fixture set its own, as ./lib.nix asks. matrixNoBaoIdentity = hive { deploy.matrix.enable = true; }; @@ -230,30 +230,29 @@ let # see from outside: it is consumed by a unit INSIDE a container, so a # missing pairing renders as a container that comes up fine and publishes # nothing. - name = "the store mints a leaf for the matrix minter, and the container is pointed at it"; + name = "the store mints a leaf for matrix-ctl, and the container is pointed at it"; ok = let m = baoWithMatrix; p = m.services.hyperhive.deploy.matrix; in - lib.hasInfix "matrix-minter.pem" m.systemd.services.swarm-bao-pki.script - && p.minterBaoClientCertFile == "/var/lib/swarm-bao-pki/matrix-minter.pem" - && p.minterBaoClientKeyFile == "/var/lib/swarm-bao-pki/matrix-minter-key.pem"; + lib.hasInfix "matrix-ctl.pem" m.systemd.services.swarm-bao-pki.script + && p.ctlBaoClientCertFile == "/var/lib/swarm-bao-pki/matrix-ctl.pem" + && p.ctlBaoClientKeyFile == "/var/lib/swarm-bao-pki/matrix-ctl-key.pem"; } { # 🩸 The identity separation this whole arrangement buys, stated as the - # one thing that would silently undo it. The container gets the MINTER's + # one thing that would silently undo it. The container gets MATRIX-CTL's # leaf — whose grant is a single path — and not the hive's, which reads # every secret in the store. Both files exist in the same directory and # both would evaluate, deploy and work. - name = "the matrix minter presents its own leaf, never the hive's store-wide one"; + name = "matrix-ctl presents its own leaf, never the hive's store-wide one"; ok = let - env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-minter.environment; + env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.environment; hiveLeaf = baoWithMatrix.services.hyperhive.deploy.bao.clientCertFile; in - env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-minter.pem" - && env.BAO_CLIENT_CERT != hiveLeaf; + env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-ctl.pem" && env.BAO_CLIENT_CERT != hiveLeaf; } { # The bind mount is what makes the environment above resolvable: without @@ -264,7 +263,7 @@ let # The second arm is the shape guard: `bindMounts` is one literal plus two # merges, and a rewrite that dropped the appservice registration would # take the homeserver's own credential with it. - name = "the matrix container binds the minter's PKI read-only, without losing the appservice registration"; + name = "the matrix container binds matrix-ctl's PKI read-only, without losing the appservice registration"; ok = let mounts = baoWithMatrix.containers.hive-matrix.bindMounts; @@ -278,28 +277,43 @@ let # the cert role ./host-modules/swarm-bao.nix writes, and a homeserver # address that is loopback because the container shares the host netns. A # vhost here would be a request out through the gateway and back. - name = "the matrix minter is handed the store role and the loopback homeserver"; + name = "matrix-ctl is handed the store role and the loopback homeserver"; ok = let m = baoWithMatrix; - u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-minter; + u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl; port = m.services.hyperhive.swarm.matrix.httpPort; in - u.environment.MATRIX_MINTER_CERT_ROLE == "swarm-matrix-minter" - && u.environment.MATRIX_MINTER_API_URL == "http://127.0.0.1:${toString port}" - && u.environment.MATRIX_MINTER_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml" + u.environment.MATRIX_MINT_CERT_ROLE == "swarm-matrix-ctl" + && u.environment.MATRIX_MINT_API_URL == "http://127.0.0.1:${toString port}" + && u.environment.MATRIX_MINT_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml" && u.serviceConfig.Type == "oneshot"; } + { + # 🩸 The crate is a `*ctl` with subcommands, so the unit has to name a + # VERB. This is the one end of that contract nix owns: the binary's own + # test pins how `mint` is spelled, but only a rendered `ExecStart` can + # say the unit actually passes it. A bare invocation exits non-zero with + # clap's usage — which is a deploy-time failure with no local signal, and + # exactly what the next verb added here is most likely to disturb. + name = "the unit invokes a verb rather than the bare binary"; + ok = + let + exec = + baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.serviceConfig.ExecStart; + in + lib.hasSuffix "/bin/swarm-matrix-ctl mint" exec; + } { # 🩸 A secret is a path, never a value — checked on the one unit in this # tree whose whole job is an `as_token`. Every variable it is given names # a file or an address; the token itself is read out of the bind-mounted # registration at runtime, so nothing here can be a token and an # environment block is world-readable through `systemctl show`. - name = "the matrix minter's environment carries paths and addresses, never a token"; + name = "matrix-ctl's environment carries paths and addresses, never a token"; ok = let - env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-minter.environment; + env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl.environment; in !(lib.any (v: lib.hasInfix "as_token" v || lib.hasInfix "syt_" v) (lib.attrValues env)); } @@ -307,12 +321,12 @@ let # The absence arm, and the deployment it protects: a homeserver on a hive # with no store identity at all. Without it the unit would exist naming # `null` as its certificate, which nixos renders as the literal string. - name = "a matrix container with no store identity runs no minter and binds no PKI"; + name = "a matrix container with no store identity runs no matrix-ctl and binds no PKI"; ok = let units = matrixNoBaoIdentity.containers.hive-matrix.config.systemd.services; in - !(units ? swarm-matrix-minter) + !(units ? swarm-matrix-ctl) && !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki"); } { diff --git a/nix/module-eval/name-guards.nix b/nix/module-eval/name-guards.nix index 40775c1b..8716f980 100644 --- a/nix/module-eval/name-guards.nix +++ b/nix/module-eval/name-guards.nix @@ -53,13 +53,13 @@ let }; # The THIRD element of the same list, colliding on its own so neither of the - # two above can carry it. The minter's grant is one path rather than a whole + # two above can carry it. matrix-ctl's grant is one path rather than a whole # prefix, which is exactly why a dead entry here would be easy to miss: a # hive that inherited it would not obviously break anything, it would # silently gain the ability to overwrite the swarm's matrix credential. - hiveNamedAfterMinterSubject = hive { + hiveNamedAfterMatrixCtlSubject = hive { deploy.swarm-otel.enable = false; - deploy.bao.matrixMinterCommonName = "mintctl"; + deploy.bao.matrixCtlCommonName = "mintctl"; swarm.hives.mintctl.domain = "m.t.local"; }; @@ -104,12 +104,12 @@ let # element earlier. `certAuthCns` is where a role added beside the others # has to register itself, and nothing but a case per element notices when # one forgets. - name = "a hive named after the matrix minter's subject is refused too"; + name = "a hive named after matrix-ctl's subject is refused too"; ok = - equalityGuardFired hiveNamedAfterMinterSubject + equalityGuardFired hiveNamedAfterMatrixCtlSubject && lib.any ( a: !a.assertion && lib.hasInfix "'mintctl'" a.message - ) hiveNamedAfterMinterSubject.assertions; + ) hiveNamedAfterMatrixCtlSubject.assertions; } { # Without this the case above proves nothing: an arm that fires for every diff --git a/nix/packages/default.nix b/nix/packages/default.nix index fd6d1c67..bd7198ba 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -163,12 +163,12 @@ in # rather than every hive's. swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder"; - # The appservice sender account's access-token minter. Out of `daemonBins` for + # The matrix container's control binary; `mint` publishes the sender token. Out of `daemonBins` for # "runs *inside* a container, not on the host" reason as the responder # above, and with a second one: putting it in the core bundle would place # the binary that reads the appservice token on every hive's filesystem, # which is the arrangement it exists to end. - swarm-matrix-minter = mkBinPackage "swarm-matrix-minter" "hyperhive matrix appservice sender-token minter"; + swarm-matrix-ctl = mkBinPackage "swarm-matrix-ctl" "hyperhive matrix container control binary"; # The only process allowed to write swarm-authelia's users database — # same "runs *inside* a container, not on the host" placement as diff --git a/swarm-matrix-minter/Cargo.toml b/swarm-matrix-ctl/Cargo.toml similarity index 64% rename from swarm-matrix-minter/Cargo.toml rename to swarm-matrix-ctl/Cargo.toml index 8d56e641..3d308778 100644 --- a/swarm-matrix-minter/Cargo.toml +++ b/swarm-matrix-ctl/Cargo.toml @@ -1,15 +1,19 @@ [package] -name = "swarm-matrix-minter" +name = "swarm-matrix-ctl" version.workspace = true readme = "README.md" edition.workspace = true [[bin]] -name = "swarm-matrix-minter" +name = "swarm-matrix-ctl" path = "src/main.rs" [dependencies] anyhow.workspace = true +# One verb today (`mint`), and the reason this crate is a `*ctl` rather than a +# 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 agreement this binary is one end of: where the credential lives, what the diff --git a/swarm-matrix-minter/README.md b/swarm-matrix-ctl/README.md similarity index 51% rename from swarm-matrix-minter/README.md rename to swarm-matrix-ctl/README.md index 525fb51e..65008f53 100644 --- a/swarm-matrix-minter/README.md +++ b/swarm-matrix-ctl/README.md @@ -1,12 +1,30 @@ -# swarm-matrix-minter +# swarm-matrix-ctl -A boot-time oneshot that runs **inside `containers.hive-matrix`**, beside the -homeserver, and puts the appservice sender account's access token into the swarm's secret -store under an identity of its own. +The rust that runs **inside `containers.hive-matrix`**, beside the homeserver. -## Why it lives in the matrix container +One binary with subcommands rather than one binary per job. Running code in that +container is not free: it needs its own store identity, its own cert role and +its own bind mounts, and every one of those is per-_container_, not per-task. A +second single-purpose crate would have had to duplicate that plumbing to add one +action, so the next thing that has to run in here is a **verb**, not a new +crate. -The credential it mints is authorised by the appservice `as_token`, and the +## Verbs + +### `mint` + +Puts the appservice sender account's access token into the swarm's secret store, +under an identity of its own. A boot-time oneshot. + +Configured entirely by the `MATRIX_MINT_*` environment the unit sets — no flags. +A systemd `Environment=` block is what a nix module can render; a command line +full of paths is not. The prefix is scoped to the verb rather than to the binary +so the next verb brings its own, instead of widening a shared one nobody can +then narrow. + +## Why this lives in the matrix container + +The credential `mint` writes is authorised by the appservice `as_token`, and the container already holds that: `nix/host-modules/hive-matrix.nix` bind-mounts the rendered appservice registration into it read-only, because that is how tuwunel itself is handed the registration. Minting anywhere else would mean copying the @@ -14,13 +32,13 @@ itself is handed the registration. Minting anywhere else would mean copying the stops being one. It is not the swarm controller for the same reason, plus a structural one: a -homeserver has exactly **one** `@hive:` account and a swarm runs one -homeserver, so "mint it once" needs no lock, no lease and no trigger surface — it -is a property of the thing being minted. +homeserver has exactly **one** appservice registration and so one sender +account, and a swarm runs one homeserver, so "mint it once" needs no lock, no +lease and no trigger surface — it is a property of the thing being minted. ## Idempotency -The **store** is the key, not the homeserver. A run reads +The **store** is the key, not the homeserver. A `mint` run reads `swarm/services/matrix/sender-token` first and returns without touching the homeserver when something is already there. Only an empty path reaches the mint ladder: diff --git a/swarm-matrix-minter/src/homeserver.rs b/swarm-matrix-ctl/src/homeserver.rs similarity index 100% rename from swarm-matrix-minter/src/homeserver.rs rename to swarm-matrix-ctl/src/homeserver.rs diff --git a/swarm-matrix-ctl/src/main.rs b/swarm-matrix-ctl/src/main.rs new file mode 100644 index 00000000..0008e919 --- /dev/null +++ b/swarm-matrix-ctl/src/main.rs @@ -0,0 +1,96 @@ +//! `swarm-matrix-ctl` — the rust that runs *inside* `containers.hive-matrix`. +//! +//! One binary with subcommands rather than one binary per job. The container +//! is an awkward place to put code — it needs its own store identity, its own +//! bind mounts and its own cert role — and all of that is per-*container*, not +//! per-task. A second single-purpose crate would have had to duplicate the +//! 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. +//! +//! 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 +//! applied to error messages. + +mod homeserver; +mod mint; +mod registration; + +use anyhow::Result; +use clap::{Parser, Subcommand}; + +#[derive(Debug, Parser)] +#[command( + name = "swarm-matrix-ctl", + about = "Act on the swarm's matrix homeserver from inside its container" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Publish the appservice sender account's access token to the swarm + /// secret store, once. + /// + /// Configured entirely by the `MATRIX_MINT_*` environment the unit sets — + /// no flags, because a systemd `Environment=` block is what a nix module + /// can render and a command line full of paths is not. + Mint, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + match Cli::parse().command { + Command::Mint => mint::run().await, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn the_clap_tree_is_well_formed() { + Cli::command().debug_assert(); + } + + /// The unit's `ExecStart` names a verb, so a rename of it is a deploy-time + /// failure with no local signal. This is that signal. + #[test] + fn mint_is_spelled_the_way_the_unit_invokes_it() { + let cli = Cli::try_parse_from(["swarm-matrix-ctl", "mint"]).expect("`mint` is a verb"); + assert!(matches!(cli.command, Command::Mint)); + } + + /// The control: without it the case above passes on a parser that accepts + /// anything. + #[test] + fn an_unknown_verb_is_refused() { + Cli::try_parse_from(["swarm-matrix-ctl", "conjure"]) + .expect_err("only declared verbs are accepted"); + } + + /// A bare invocation must not silently do something. `mint` writes a + /// credential, so "no verb" defaulting to it would make a typo in the unit + /// mint rather than fail. + #[test] + fn no_verb_at_all_is_refused() { + Cli::try_parse_from(["swarm-matrix-ctl"]).expect_err("a verb is required"); + } +} diff --git a/swarm-matrix-minter/src/main.rs b/swarm-matrix-ctl/src/mint.rs similarity index 80% rename from swarm-matrix-minter/src/main.rs rename to swarm-matrix-ctl/src/mint.rs index 9b14068e..20cffeab 100644 --- a/swarm-matrix-minter/src/main.rs +++ b/swarm-matrix-ctl/src/mint.rs @@ -1,25 +1,16 @@ -//! Mint the matrix appservice sender account's homeserver access token, once, and publish it to the -//! swarm's secret store. +//! `swarm-matrix-ctl mint` — publish the appservice sender account's +//! homeserver access token to the swarm's secret store, once. //! //! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the -//! swarm controller. It is 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. It is -//! this account rather than an agent's because a homeserver has **one** `@hive:` -//! account and a swarm runs one homeserver: "only once" is a property of what -//! is being minted, so there is nothing to lock and no trigger to serve. +//! swarm controller. It is this account rather than an agent's because a +//! homeserver has **one** appservice registration and so one sender account, +//! and a swarm runs one homeserver: "only once" is a property of what is being +//! minted, so there is nothing to lock and no trigger to serve. //! //! The store, not the homeserver, is the idempotency key — see //! [`already_published`]. On the hive side `hive-c0re`'s //! `matrix::ensure_hive_user` reads exactly the path written here, which is how //! a hive that holds no `as_token` still gets its matrix account. -//! -//! 🩸 A secret is a path, never a value. The only identifier this binary logs is -//! the store path; see `homeserver`'s module doc for the same rule applied to -//! error messages. - -mod homeserver; -mod registration; use anyhow::{Context, Result}; use swarm_secret_client::{ @@ -28,25 +19,27 @@ use swarm_secret_client::{ matrix, }; +use crate::{homeserver, 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 /// carry the CN that role accepts. -const ENV_CERT_ROLE: &str = "MATRIX_MINTER_CERT_ROLE"; +const ENV_CERT_ROLE: &str = "MATRIX_MINT_CERT_ROLE"; /// Client-server API base of the homeserver beside us — loopback, since the /// container shares the host netns. -const ENV_API_URL: &str = "MATRIX_MINTER_API_URL"; +const ENV_API_URL: &str = "MATRIX_MINT_API_URL"; /// The bind-mounted appservice registration, which is where the `as_token` /// comes from. A path, never a value. -const ENV_REGISTRATION: &str = "MATRIX_MINTER_REGISTRATION"; -/// Localpart of the hive's account. The appservice registration's own +const ENV_REGISTRATION: &str = "MATRIX_MINT_REGISTRATION"; +/// Localpart of the appservice's sender account. The registration's own /// `sender_localpart`, and `hive-c0re`'s `matrix::HIVE_LOCALPART`. -const ENV_LOCALPART: &str = "MATRIX_MINTER_LOCALPART"; +const ENV_LOCALPART: &str = "MATRIX_MINT_LOCALPART"; /// Public base URL of the homeserver, stored beside the token so a reader can /// reconstruct where it is good for. Optional: a swarm with no gateway vhost /// has no such URL, and `matrix::Credential` types the field to say so. -const ENV_HOMESERVER: &str = "MATRIX_MINTER_HOMESERVER"; +const ENV_HOMESERVER: &str = "MATRIX_MINT_HOMESERVER"; -/// Everything the unit tells this process, checked before anything is opened. +/// Everything the unit tells this verb, checked before anything is opened. /// /// Separate from the work for the reason `swarm_secret_client::client::Settings` /// is: every arm is a misconfiguration an operator reads an error about, and @@ -61,7 +54,7 @@ struct Config { } impl Config { - /// Read the `MATRIX_MINTER_*` variables from the process environment. + /// Read the `MATRIX_MINT_*` variables from the process environment. /// /// # Errors /// Naming the first variable that is unset or empty. @@ -113,15 +106,13 @@ async fn already_published(store: &SecretStore, path: &str) -> bool { } } -#[tokio::main] -async fn main() -> Result<()> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .init(); - +/// Run the verb. +/// +/// # Errors +/// If the environment is incomplete, the store refuses the login or the write, +/// the registration cannot be read, or the homeserver refuses both the +/// registration and the appservice login. +pub async fn run() -> Result<()> { let config = Config::from_env()?; // Explicitly, rather than through `SecretStore::from_env`: a missing or // misspelled `BAO_*` variable is the most likely thing to be wrong with a @@ -152,7 +143,7 @@ async fn main() -> Result<()> { // The expected arm, not an edge case: this account is the // appservice's own `sender_localpart`, so the homeserver creates it // when it loads the registration — before anything gets to ask. - tracing::info!("the @hive: account exists; logging in as the appservice instead"); + tracing::info!("the sender account exists; logging in as the appservice instead"); homeserver::appservice_login(&http, &config.api_url, &config.localpart, &as_token) .await? } @@ -179,7 +170,7 @@ mod tests { /// A lookup standing in for a fully-configured unit's environment. fn full(k: &str) -> Option { match k { - ENV_CERT_ROLE => Some("swarm-matrix-minter".to_owned()), + ENV_CERT_ROLE => Some("swarm-matrix-ctl".to_owned()), ENV_API_URL => Some("http://127.0.0.1:8008".to_owned()), ENV_REGISTRATION => { Some("/var/lib/hyperhive/matrix-appservice/hyperhive.yaml".to_owned()) @@ -235,6 +226,26 @@ mod tests { assert_eq!(c.homeserver, None); } + /// The environment prefix is a contract with the nix unit, and the crate + /// rename that produced it moved every one of these. A verb-scoped prefix + /// is the point: the next verb brings its own, instead of widening a + /// binary-scoped one nobody can then narrow. + #[test] + fn every_variable_is_scoped_to_the_verb() { + for var in [ + ENV_CERT_ROLE, + ENV_API_URL, + ENV_REGISTRATION, + ENV_LOCALPART, + ENV_HOMESERVER, + ] { + assert!( + var.starts_with("MATRIX_MINT_"), + "{var} is not scoped to the mint verb" + ); + } + } + #[test] fn the_published_path_is_the_one_the_hive_reads() { // Both ends of this slice's loop resolve the same function, so there is diff --git a/swarm-matrix-minter/src/registration.rs b/swarm-matrix-ctl/src/registration.rs similarity index 100% rename from swarm-matrix-minter/src/registration.rs rename to swarm-matrix-ctl/src/registration.rs From 19cae964c80f6cf6f64ab72e93e6f016176ae6c1 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 20 Sep 2026 15:40:51 +0200 Subject: [PATCH 6/8] matrix, docs: describe promotion/reset as rehomed, not as a pending fix Both spots hedged the swarm-level framing with a temporal qualifier ("until then", "today") that read as future work awaiting a fix, on top of the framing itself. Drops the qualifiers; the operations are swarm-level and rehomed there now, not pending anything from the hive. --- docs/integrations/matrix.md | 21 +++++++++++---------- docs/tools/hivectl.md | 11 ++++++----- hive-c0re/src/matrix.rs | 6 ++++-- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/docs/integrations/matrix.md b/docs/integrations/matrix.md index 8e0abbdc..c730e0d0 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -170,16 +170,17 @@ being the rooms' own creator at power level 100 — there is no homeserver admin in any of it, and no Synapse admin API to reach for either, since tuwunel has none. -Two operations do need an admin **sender**, and neither works today: -`hivectl matrix promote-user` and `hivectl matrix reset-password`. Both -are `!admin …` messages into `#admins:`, and tuwunel only -treats a message as a command when its sender is already an admin. They -are swarm-level operations and are being rehomed as such; until then -they fail with the admin room's refusal rather than being served by an -over-privileged credential that every other call site would also carry. -The one hive-side path that depends on them is the automatic password -recovery for an agent whose stored password is gone — the ordinary -appservice re-login above is unaffected. +Two operations need an admin **sender**: `hivectl matrix promote-user` +and `hivectl matrix reset-password`. Both are `!admin …` messages into +`#admins:`, and tuwunel only treats a message as a command +when its sender is already an admin. They're swarm-level operations, +rehomed to the swarm tier rather than granted here; from the hive, +`@hive:` has no admin sender to make that call with, so both get the +admin room's refusal rather than an over-privileged credential that +every other call site would also carry. The one hive-side path that +depends on them is the automatic password recovery for an agent whose +stored password is gone — the ordinary appservice re-login above is +unaffected.
Upgrading a hive that used the registration token diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 4bb4639c..976b901f 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -79,12 +79,13 @@ hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a spec access token path. Safe to run again — idempotent. - `promote-user`: promotes an already-registered user to homeserver admin by an `!admin` command in `#admins`. ⚠️ Needs an admin **sender**, - which `@hive:` isn't — this operation is being rehomed at swarm level - and doesn't work from the hive today. + which `@hive:` isn't — promotion is a swarm-level operation, rehomed to + the swarm tier rather than granted here, so it has no admin sender to + call it with from the hive. - `reset-password`: asks the admin room to set a new random - password and prints it to stdout. ⚠️ Needs an admin **sender** too, so - it doesn't work from the hive today either. Useful if an agent or human lost - credentials. + password and prints it to stdout. ⚠️ Same admin-**sender** requirement, + and the same swarm-level rehoming, so it's unavailable from the hive + too. Useful if an agent or human lost credentials. - `invite`: invites a matrix user (full `@user:server` or a bare localpart, qualified with the homeserver's `server_name`) to the hive Space by default, or to a `--room` id / `#alias`. Uses the sender diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index a93a2e6e..1b9f57db 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -1145,8 +1145,10 @@ pub async fn promote_user_to_admin( /// on the next provisioning sweep. /// /// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and -/// the same consequence: `@hive:` is an ordinary account, so this fails -/// from the hive until the operation is rehomed at swarm level. +/// the same consequence: `@hive:` is an ordinary account with no admin +/// sender, and reset, like promotion, is a swarm-level operation rehomed +/// to the swarm tier rather than granted here — so it has no working +/// sender from the hive either. /// /// Returns the new password for use in subsequent `login_user` calls. pub async fn reset_user_password( From 637b308d4d22169a0802011c114a8f2d9779f0cf Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 20 Sep 2026 18:28:32 +0200 Subject: [PATCH 7/8] docs/integrations/matrix.md: clear write-good.Passive hits from the rebase Three hits the recent write-good.Passive=error flip (hyperhive#4546) now catches on this branch's content: - "stored password is gone" -> "has lost its stored password" - "the ordinary appservice re-login above is unaffected" -> named the actual actor (the admin-sender limitation) instead of leaving it passive - "Federation is enabled" -> "tuwunel enables federation" (tuwunel is already named one clause earlier in the same paragraph) No suppressions, no content dropped. --- docs/integrations/matrix.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/integrations/matrix.md b/docs/integrations/matrix.md index c730e0d0..bcac38c9 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -178,9 +178,9 @@ rehomed to the swarm tier rather than granted here; from the hive, `@hive:` has no admin sender to make that call with, so both get the admin room's refusal rather than an over-privileged credential that every other call site would also carry. The one hive-side path that -depends on them is the automatic password recovery for an agent whose -stored password is gone — the ordinary appservice re-login above is -unaffected. +depends on them is the automatic password recovery for an agent that +has lost its stored password — the admin-sender limitation doesn't +touch the ordinary appservice re-login above.
Upgrading a hive that used the registration token @@ -287,9 +287,9 @@ services.hyperhive.swarm.matrix = { ``` **`trustedServers`** (default `[]`) — list of peer homeserver names -whose signing keys tuwunel fetches and trusts. Federation is enabled -at the protocol level from first boot (`allow_federation = true`) but -trusts no remote homeserver until you list it here. For a closed +whose signing keys tuwunel fetches and trusts. tuwunel enables +federation at the protocol level from first boot (`allow_federation = +true`) but trusts no remote homeserver until you list it here. For a closed single-hive deployment the default empty list is correct — add peer hive domains here when connecting hives into a swarm (see [`docs/swarm/`](../swarm/README.md)). From 1261b525d64c2fb4d1690ab88338f2283e27baa3 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 20 Sep 2026 20:06:31 +0200 Subject: [PATCH 8/8] matrix: one sender account and one sender token per hive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A swarm runs one homeserver and every hive on it logged in as the same `@hive:` localpart, holding the same access token out of one swarm-wide store path. That is one matrix identity for N hives: the homeserver cannot attribute an action to the hive that took it, and revoking one hive's standing revokes every hive's. Three changes, and the third is the one that makes the other two real: - **The localpart carries the hive's name** (`hive-`), derived in one place, `swarm_secret_client::matrix::hive_localpart`. `hive-matrix.nix` renders the same string as the appservice registration's `sender_localpart`, so the shared account stops being created rather than merely stops being used. - **The store path is templated by hive**, not a constant. The "a swarm runs one homeserver, so this is a constant rather than a parameter" rationale went with it; it stopped holding the moment two hives shared the homeserver it describes. - **The path moved out from under the grant every hive has.** It sat at `swarm/services/matrix/sender-token`, inside the `secret/data/swarm/services/*` read stanza `policy::render` gives every hive. It now sits under that hive's own stanza, `secret/data/swarm/hives//*`, which interpolates the reader's name — so a hive reads its own token and is refused another's. The policy renderer itself is unchanged: narrowing the `services/*` grant would break the OIDC-secret read it exists for, and moving the credential is what this needed instead. A policy test walks the rendered stanzas and asserts none of hive alpha's covers hive beta's sender token, so a later stanza that widened it fails here. `swarm-matrix-ctl` takes a new required `MATRIX_MINT_HIVE` and writes that hive's path; its store grant in `swarm-bao.nix` follows, scoped to one hive's leaf via the new `deploy.bao.matrixCtlHiveName` (defaulting to this host's `hiveName`) rather than a `hives/*` wildcard, which would hand the matrix container every hive's token back. Migration: no outage at deploy. `ensure_hive_user` short-circuits on the local token file, so a hive keeps running on what it has; with no such file it reads the new per-hive path, finds nothing, and falls through to the existing register-or-appservice-login ladder against its own localpart — which needs only the per-hive `as_token` on local disk. The old shared object is read by nothing afterwards. Rooms do not follow the identity, and that is the one operator step; both ways out are written into `docs/integrations/matrix.md`. No admin standing is granted to the per-hive accounts: `admin_execute` stays empty and the assertion pinning it is untouched. --- docs/integrations/matrix.md | 66 ++++++++++++-- docs/swarm/credentials.md | 2 +- docs/tools/hivectl.md | 5 +- docs/trust-boundary/security.md | 14 +++ hive-c0re/src/matrix.rs | 123 ++++++++++++++++--------- hive-c0re/src/server.rs | 2 +- nix/host-modules/hive-matrix.nix | 27 +++++- nix/host-modules/swarm-bao.nix | 52 ++++++++++- nix/module-eval/bao-grants.nix | 24 +++-- nix/module-eval/bao-matrix-reader.nix | 20 ++++ swarm-matrix-ctl/src/homeserver.rs | 8 +- swarm-matrix-ctl/src/mint.rs | 65 ++++++++++--- swarm-secret-client/src/matrix.rs | 126 ++++++++++++++++++-------- swarm-secret-client/src/policy.rs | 60 +++++++++++- 14 files changed, 461 insertions(+), 133 deletions(-) diff --git a/docs/integrations/matrix.md b/docs/integrations/matrix.md index bcac38c9..992de996 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -149,19 +149,27 @@ a token. ### The appservice's sender account, and why it isn't an admin -`@hive:` is the appservice's own `sender_localpart`, which -the homeserver creates itself when it loads the registration — on a +`@hive-:` is the appservice's own `sender_localpart`, +which the homeserver creates itself when it loads the registration — on a zero-user database, inside startup, before the HTTP listener accepts anything. It's an **ordinary account**: nothing promotes it, and the homeserver runs no `admin_execute` for it. +**One account per hive.** The localpart carries the hive's name, so a swarm +whose hives share a homeserver gives each of them its own identity: the +homeserver attributes an action to the hive that took it, and revoking one +hive's standing leaves the others alone. + Its access token is the **sender token**, and it's the credential hive-c0re presents for every homeserver call it makes on the hive's -behalf. `swarm-matrix-ctl` mints it inside the `hive-matrix` -container and publishes it to `swarm/services/matrix/sender-token`; the -hive reads it from there. The name says what it authenticates as — an -account the appservice registration brings into being — rather than any -privilege level, because it carries none. +behalf. It's per hive for the same reason the account is: +`swarm-matrix-ctl` mints it inside the `hive-matrix` container and +publishes it to `swarm/hives//matrix/sender-token`, and the hive +reads it from there under its own certificate. That path sits inside the +hive's own read grant (`swarm/hives//*`), so a hive fetches its own +token and gets a refusal on any other hive's. The name says what it +authenticates as — an account the appservice registration brings into being +— rather than any privilege level, because it carries none. It needs no promotion for what the hive does with it. Creating the hive Space and the chat room, writing their hierarchy and join rules, and @@ -175,13 +183,51 @@ and `hivectl matrix reset-password`. Both are `!admin …` messages into `#admins:`, and tuwunel only treats a message as a command when its sender is already an admin. They're swarm-level operations, rehomed to the swarm tier rather than granted here; from the hive, -`@hive:` has no admin sender to make that call with, so both get the +`@hive-:` has no admin sender to make that call with, so both get the admin room's refusal rather than an over-privileged credential that every other call site would also carry. The one hive-side path that depends on them is the automatic password recovery for an agent that has lost its stored password — the admin-sender limitation doesn't touch the ordinary appservice re-login above. +
Upgrading a hive that shared one sender account with every other hive + +Nothing to do, and no window where the hive is without an account. + + +- **The old shared value at `swarm/services/matrix/sender-token` is read by + nothing.** `hive-c0re` and `swarm-matrix-ctl` both build the path from the + same function, and it now carries the hive's name — so the old object + stays in the store, unread, until an operator deletes it. Delete it or + leave it; the accounts it authenticates as keep their own standing either + way, since an access token lives on the device that minted it. +- **The hive re-mints, per hive, on the next boot.** `ensure_hive_user` runs + from the startup sweep. It short-circuits on the token file it already has + — so the first boot after the upgrade keeps running on the shared token + until that file goes — and on a hive with no such file it reads the new + per-hive store path, finds nothing on a store that has never held one, and + falls through to the register-or-appservice-login ladder against + `@hive-:`. That ladder needs only the `as_token`, which is per hive + and on local disk, so it works with or without a reachable store. +- **To move a hive onto its own account now**, delete its sender-token file + (`hivectl matrix sync-admin` re-provisions, or the next sweep does) — the + ladder then registers `@hive-:` and persists that account's token. + Only then does the hive stop presenting the shared one. +- **The rooms the shared account created don't follow the new account, and + this is the one step that needs a decision.** Membership is per account. + `ensure_hive_space` takes the stored room id first, so the sweep hands the + new account the old Space's id, the invite it then sends comes back + refused (a non-member can't invite), and the sweep logs it and carries + on — degraded, not crashed. Two ways out, both operator-chosen. Either invite + `@hive-:` into the existing Space and chat room from a client, which + keeps the history; or delete the hive's stored room-id files, after which + the next sweep creates a Space and chat room owned by the new account and + invites every agent into them. Do one of the two; leaving it gives a hive + that provisions no rooms. + + +
+
Upgrading a hive that used the registration token Nothing to do, and nothing to time. The activation script mints the @@ -248,8 +294,8 @@ Initial rollout settings: ## Hive Matrix Space On first boot, after hive-c0re provisions all agent accounts, it -creates a private **Matrix Space** named `"hive"` using the `@hive:` -account (`@hive:`) and invites every provisioned agent +creates a private **Matrix Space** named `"hive"` using its own hive +account (`@hive-:`) and invites every provisioned agent into it. This gives the operator a single Space in FluffyChat or any Matrix client that groups all agent-to-agent + operator rooms in one place. diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 965fd2d7..0143621a 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -59,9 +59,9 @@ strategy for every credential, including the mTLS leaf. | `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | | `swarm/agents//bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated | | `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | +| `swarm/hives//matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated | | `swarm/hives//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | | `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | -| `swarm/services/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated | | _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 976b901f..5a40ddb0 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -74,12 +74,13 @@ hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a spec - `create-user`: for agents, persists the `access_token` to `/matrix-token`. Skips registration when the file already exists — delete it first to force re-registration. -- `sync-admin`: ensures the appservice's sender account (`@hive:`) exists +- `sync-admin`: ensures this hive's appservice sender account + (`@hive-:`, one per hive) exists (the account `hive-c0re` provisions rooms with). Token persisted to the access token path. Safe to run again — idempotent. - `promote-user`: promotes an already-registered user to homeserver admin by an `!admin` command in `#admins`. ⚠️ Needs an admin **sender**, - which `@hive:` isn't — promotion is a swarm-level operation, rehomed to + which `@hive-:` isn't — promotion is a swarm-level operation, rehomed to the swarm tier rather than granted here, so it has no admin sender to call it with from the hive. - `reset-password`: asks the admin room to set a new random diff --git a/docs/trust-boundary/security.md b/docs/trust-boundary/security.md index 6e2d544c..78a98465 100644 --- a/docs/trust-boundary/security.md +++ b/docs/trust-boundary/security.md @@ -94,6 +94,20 @@ it. the grant per hive (and pay for the re-emission), or give each agent container its own store identity so credentials never pass through a hive at all. +### Per-hive credentials live under the hive kind, not beside a service + +`swarm_secret_client::policy` scopes the **hive** stanza narrowly (unlike the +agent and service stanzas above) because a hive's own path names it, so +scoping to the reader's own name costs nothing and drifts nowhere. That only +holds if a credential that must be one-per-hive is actually stored under the +hive kind — the matrix sender token was originally published to +`swarm/services/matrix/sender-token`, under the **service** kind, and so was +readable by every hive though it belonged to only one. Making it per-hive meant +moving its path under the hive kind, not narrowing the service stanza's grant. +**A credential that must be one-per-hive goes under `Kind::Hive`**; putting it +under `Kind::Service` and expecting the grant to scope it — that's the mistake +this note exists to stop. + ### Threat model: prompt injection → confused deputy The realistic adversary **never needs to breach the container**. They supply diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 1b9f57db..e7f7c433 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -65,21 +65,43 @@ const HTTP_TIMEOUT_SECS: u64 = 10; /// store it nowhere. const PASSWORD_BYTES: usize = 32; -/// Matrix localpart for the hive system admin account. Not an agent; has -/// no state dir. +/// Matrix localpart this hive acts as. Not an agent; has no state dir. +/// +/// **Per hive, derived from the hive's name** — `hive-`, built by +/// `swarm_secret_client::matrix::hive_localpart`, which is the one place the +/// derivation lives. It used to be the bare constant `hive`, and a swarm runs +/// one homeserver: every hive on it logged in as the same `@hive:`, so the +/// homeserver could not attribute an action to a hive and revoking one hive's +/// standing revoked all of them. /// /// Also the `sender_localpart` of the hive's appservice registration, /// which is what creates this account on a homeserver that has never had /// one: the homeserver creates a registration's sender user itself, at /// startup, before it accepts a request. See -/// `nix/host-modules/hive-matrix.nix`, where this same literal appears as +/// `nix/host-modules/hive-matrix.nix`, where the same string is rendered as /// `hiveLocalpart`. **The two must match**; nothing wires an override /// across. /// /// An ordinary account, with no homeserver-admin standing: what it /// provisions — the Space, the chat room, the invites — it provisions as /// the creator of those rooms. -pub const HIVE_LOCALPART: &str = "hive"; +/// +/// # Errors +/// When `HYPERHIVE_HIVE_NAME` is unset or empty. That is the same variable +/// [`stored_sender_token`] logs in with and `hive-c0re`'s meta flake asserts +/// for every hive, so its absence is a broken deployment rather than a +/// supported one — and there is no safe default to fall back to: the only one +/// available is the shared account this replaced. +pub fn hive_localpart() -> Result { + let hive = std::env::var("HYPERHIVE_HIVE_NAME") + .ok() + .filter(|h| !h.is_empty()) + .context( + "matrix: HYPERHIVE_HIVE_NAME is unset, so this hive has no matrix account of its own \ + — the localpart is derived from the hive name and has no swarm-wide fallback", + )?; + Ok(swarm_secret_client::matrix::hive_localpart(&hive)) +} /// Display name of the hive Space. Plain text, no special characters, so /// the Space stays rediscoverable by name (no room alias needed) even when @@ -389,7 +411,7 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re /// room when the locally stored password is missing. Returns the new /// password (already persisted to [`password_path`]) on success. /// -/// ⚠️ Needs an **admin sender**, which `@hive:` is not — the reset is a +/// ⚠️ Needs an **admin sender**, which `@hive-:` is not — the reset is a /// `!admin` command and tuwunel only treats a message as a command when /// its sender is an admin in that room. So this recovery path fails until /// the two admin operations are rehomed at swarm level; the ordinary @@ -595,7 +617,7 @@ async fn admin_room_send_and_poll( // Poll for bot response: fetch the 20 most recent events (newest-first) // on each tick. Walk the list until we hit our own command event_id; // everything *before* that marker arrived after our command. - let own_user_id = format!("@{HIVE_LOCALPART}:{server_name}"); + let own_user_id = format!("@{}:{server_name}", hive_localpart()?); let poll_url = format!("{base}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20"); for _ in 0..15_u8 { tokio::time::sleep(std::time::Duration::from_secs(1)).await; @@ -649,8 +671,8 @@ async fn admin_room_send_and_poll( } /// Reset a user's password via the Matrix admin room (`#admins:`). -/// Sends `!admin users reset-password @:` as @hive, polls for the bot's -/// response containing the new password. +/// Sends `!admin users reset-password @:` as `@hive-:`, polls for +/// the bot's response containing the new password. /// /// Returns the new password; caller is responsible for persisting it. async fn admin_room_reset_password( @@ -772,9 +794,9 @@ pub async fn ensure_user_for(client: &reqwest::Client, name: &str, as_token: &st // Password file missing — attempt auto-recovery through the // admin room. // This covers the case where agent state dirs were wiped but the - // homeserver still has the accounts. Requires the `@hive:` + // homeserver still has the accounts. Requires the hive's sender // token at /var/lib/hyperhive/matrix/access-token, and an admin - // sender, which `@hive:` no longer is. + // sender, which `@hive-:` is not. tracing::info!( %name, "matrix: stored password missing, attempting admin-room auto-recovery" @@ -893,7 +915,7 @@ pub async fn sync_agent_standalone(name: &str) { sync_agent(&client, name, &as_token).await; } -/// Ensure the `@hive:` matrix user exists and that its access token is +/// Ensure the hive's `@hive-:` matrix user exists and that its access token is /// persisted at [`sender_token_path()`]. /// /// **Nothing here depends on registration order, and nothing here is @@ -927,15 +949,18 @@ pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Resul if let Some(token) = stored_sender_token().await { return persist_sender_token(&path, &token); } + // Per hive, and fatal when it cannot be derived: the fallback ladder below + // must not mint under some other hive's name. + let localpart = hive_localpart()?; let password = random_password()?; - let access_token = match register_user(client, HIVE_LOCALPART, as_token, &password).await { + let access_token = match register_user(client, &localpart, as_token, &password).await { Ok(token) => { - let pw_path = password_path(HIVE_LOCALPART); + let pw_path = password_path(&localpart); if let Some(parent) = pw_path.parent() { std::fs::create_dir_all(parent).ok(); } if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) { - tracing::warn!(error = ?e, "matrix: failed to persist the @hive: account password"); + tracing::warn!(error = ?e, %localpart, "matrix: failed to persist the sender account password"); } else { let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600)); } @@ -948,26 +973,26 @@ pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Resul // hive-c0re gets a chance to ask. An appservice login needs // no password, which is just as well since an account the // homeserver created has none. - tracing::info!("matrix: the @hive: user already exists, logging in as the appservice"); - match appservice_login(client, as_token, HIVE_LOCALPART).await { + tracing::info!(%localpart, "matrix: the sender account already exists, logging in as the appservice"); + match appservice_login(client, as_token, &localpart).await { Ok(token) => token, Err(e) => { - tracing::warn!(error = ?e, "matrix: appservice login for @hive: failed; falling back to the stored password"); - let pw_path = password_path(HIVE_LOCALPART); + tracing::warn!(error = ?e, %localpart, "matrix: appservice login for the sender account failed; falling back to the stored password"); + let pw_path = password_path(&localpart); let stored = std::fs::read_to_string(&pw_path) .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) .with_context(|| { format!( - "matrix: the @hive: user exists, appservice login failed, and no \ + "matrix: @{localpart}: exists, appservice login failed, and no \ password is stored at {} — check that the registration file's \ - namespace covers @{HIVE_LOCALPART} and that the homeserver \ + namespace covers @{localpart} and that the homeserver \ loaded it", pw_path.display() ) })?; - login_user(client, HIVE_LOCALPART, &stored).await? + login_user(client, &localpart, &stored).await? } } } @@ -1002,8 +1027,10 @@ fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<() /// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` — /// the same role string `workers::credential` logs in with, and already in /// this process's environment, so the store read costs no plumbing through -/// [`ensure_all`]. No new grant either: a hive's policy already covers the -/// whole `swarm/services/*` tree `swarm-matrix-ctl` writes into. +/// [`ensure_all`]. That name is also the path's own hive segment, which is +/// what makes this read reach **this** hive's token and 403 on any other's: +/// the grant behind it is the hive's own `swarm/hives//*` stanza, not +/// the swarm-wide `swarm/services/*` one the shared token used to sit under. /// /// `None`, never an error, for every way this can come up empty — no hive /// name, no `BAO_*` identity, an unreachable store, nothing at the path. All @@ -1017,7 +1044,13 @@ async fn stored_sender_token() -> Option { let hive = std::env::var("HYPERHIVE_HIVE_NAME") .ok() .filter(|h| !h.is_empty())?; - let path = swarm_secret_client::matrix::sender_token_path(); + let path = match swarm_secret_client::matrix::sender_token_path(&hive) { + Ok(path) => path, + Err(e) => { + tracing::warn!(error = %e, "matrix: this hive's name does not form a store path"); + return None; + } + }; let store = match swarm_secret_client::SecretStore::from_env(&hive).await { Ok(store) => store, Err(e) => { @@ -1095,11 +1128,11 @@ mod is_make_admin_success_tests { /// Promote a user to homeserver admin via the Matrix admin room /// (`#admins:`). Sends `!admin users make-user-admin @:` as -/// @hive, polls for the bot's success reply. +/// `@hive-:`, polls for the bot's success reply. /// /// ⚠️ Requires the **sender** to be an admin already — tuwunel only /// treats a message as a command when its sender is in the admin room. -/// `@hive:` is an ordinary account (`hive-matrix.nix` grants it no +/// `@hive-:` is an ordinary account (`hive-matrix.nix` grants it no /// `admin_execute` promotion), so this call has no working sender from /// the hive and fails with the admin room's refusal. Promotion is a /// swarm-level operation and is being rehomed as such; this stays here, @@ -1139,13 +1172,13 @@ pub async fn promote_user_to_admin( /// Reset a user's password via the Matrix admin room (`#admins:`). /// -/// Sends `!admin users reset-password @:` to the admin room as @hive, -/// polls for the bot's response containing the new password, and persists +/// Sends `!admin users reset-password @:` to the admin room as +/// `@hive-:`, polls for the bot's response containing the new password, and persists /// it to the non-purgeable creds path so [`ensure_user_for`] can re-login /// on the next provisioning sweep. /// /// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and -/// the same consequence: `@hive:` is an ordinary account with no admin +/// the same consequence: `@hive-:` is an ordinary account with no admin /// sender, and reset, like promotion, is a swarm-level operation rehomed /// to the swarm tier rather than granted here — so it has no working /// sender from the hive either. @@ -1219,7 +1252,7 @@ pub fn read_sender_token() -> Result { format!( "the matrix sender token was not found at {} — \ ensure hive-c0re has started at least once with matrix enabled \ - (it provisions the @hive: account on boot)", + (it provisions the hive's @hive-: account on boot)", path.display() ) }) @@ -1301,7 +1334,7 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt /// Create (or recover) the hive Matrix Space and persist its room ID to /// [`hive_space_room_id_path()`]. The Space is a private `m.space` owned by -/// `@hive`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias). +/// `@hive-:`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias). /// /// Dedup strategy (single canonical space): /// 1. If the room-id file exists, reuse it. @@ -1877,7 +1910,7 @@ pub async fn ensure_all() -> bool { return false; } }; - // The `@hive:` account first, because everything below provisions + // The hive's `@hive-:` account first, because everything below provisions // THROUGH it (the Space, the chat room and every invite are sent with // its token) — as an ordinary user that created those rooms, not as a // homeserver admin. @@ -1934,17 +1967,17 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo return false; } }; - // Invite @hive first, then all agents. - if let Err(e) = invite_to_room( - client, - &sender_token, - &room_id, - HIVE_LOCALPART, - &server_name, - ) - .await + // This hive's own account first, then all agents. + let localpart = match hive_localpart() { + Ok(l) => l, + Err(e) => { + tracing::warn!(error = ?e, "matrix: skipping hive space provisioning"); + return false; + } + }; + if let Err(e) = invite_to_room(client, &sender_token, &room_id, &localpart, &server_name).await { - tracing::warn!(error = ?e, "matrix: invite @hive to space failed"); + tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the space failed"); ok = false; } for name in agent_names { @@ -1955,7 +1988,7 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo } // Provision the default hive chat room as an m.space.child of the Space - // and invite @hive + every agent. Joining a Space alone surfaces no + // and invite `@hive-:` + every agent. Joining a Space alone surfaces no // rooms to chat in (Matrix semantics — children aren't auto-joined), so // without this the Space is empty. The restricted join rule additionally // lets the operator (a Space member) join from the Space hierarchy. @@ -1965,12 +1998,12 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo client, &sender_token, &chat_room_id, - HIVE_LOCALPART, + &localpart, &server_name, ) .await { - tracing::warn!(error = ?e, "matrix: invite @hive to chat room failed"); + tracing::warn!(error = ?e, %localpart, "matrix: invite the hive account to the chat room failed"); ok = false; } for name in agent_names { diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 0af25230..e9cbbf08 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -784,7 +784,7 @@ async fn handle_matrix_sync_admin() -> Result { Ok(HostResponse::messages(vec![ format!( "matrix: the @{}: user is provisioned", - crate::matrix::HIVE_LOCALPART + crate::matrix::hive_localpart()? ), format!("token persisted at: {}", path.display()), ])) diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index b2635eea..1304d9b2 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -73,10 +73,21 @@ let # anything, so the account exists on the very first boot of a fresh # homeserver without hive-c0re racing to register it. # - # ⚠️ Must equal `matrix::HIVE_LOCALPART` in hive-c0re, which derives - # it independently with nothing wiring an override across — same - # agreement, and same reason for saying so, as the token path below. - hiveLocalpart = "hive"; + # 🩸 **Derived from the hive name, so it is one account per hive.** It was + # the bare `hive` and a swarm runs one homeserver: every hive on it acted + # as the same `@hive:`, which is one identity with no attribution and no + # way to revoke a single hive. The hives that do not run this container + # register their own `hive-` through the appservice namespace below, + # which already admits it. + # + # ⚠️ Must equal `swarm_secret_client::matrix::hive_localpart`, which + # hive-c0re and swarm-matrix-ctl both derive from independently with + # nothing wiring an override across — same agreement, and same reason for + # saying so, as the token path below. + # + # `hiveName` has no fallback here for the reason ./glue-matrix-bao-token.nix + # gives at its own use of it: it is asserted set for every hyperhive host. + hiveLocalpart = "hive-${toString config.services.hyperhive.hiveName}"; # The `as_token`, and the `hs_token` the spec requires alongside it. Both # minted by the render script below, mode 0600; the `as_token` is the one @@ -168,7 +179,7 @@ let # knows about itself. ctlHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}"; - # Every local user this hive may provision — agents, `@hive:` itself, and + # Every local user this hive may provision — agents, `@hive-:` itself, and # the operator accounts `hivectl matrix create-user` makes, which is the # whole matrix localpart charset. # @@ -1439,6 +1450,12 @@ in # never a value. MATRIX_MINT_REGISTRATION = appserviceRegistrationPath; MATRIX_MINT_LOCALPART = hiveLocalpart; + # The hive segment of the store path the token is published + # under, and so the thing that keeps this hive's token out of + # every other hive's reach: the grant that reaches it is the + # hive's own `swarm/hives//*` stanza. ./swarm-bao.nix + # spells the same name into matrix-ctl's write grant. + MATRIX_MINT_HIVE = toString config.services.hyperhive.hiveName; MATRIX_MINT_HOMESERVER = ctlHomeserverUrl; } // lib.optionalAttrs (deployCfg.bao.serverCaFile != null) { diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index d2b0e62c..702e5e72 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -249,9 +249,17 @@ let # written by the caller — the same trap as the two grants above. # # Not `swarm/services/*` like the publisher's: this principal produces - # exactly one secret, the appservice sender account's access token, and a - # homeserver is not entitled to overwrite Grafana's OIDC client. The path is - # spelled to the leaf for that reason, not for tidiness. + # exactly one secret, its own hive's matrix sender account access token, and + # a homeserver is not entitled to overwrite Grafana's OIDC client. The path + # is spelled to the leaf for that reason, not for tidiness. + # + # 🩸 And the leaf now carries a HIVE segment, which is the narrowing that + # matters: the credential used to live at `swarm/services/matrix/sender-token` + # — one value for the whole swarm, under the `services/*` tree every hive's + # own policy grants read on. Under `swarm/hives//` the only read grant + # that reaches it is that hive's own stanza, so one hive cannot fetch + # another's. `matrixCtlHive` below is the name this principal may write, and + # it is one hive rather than a `hives/*` wildcard for the same reason. # # `read` as well as write, unlike either sibling, and it is what makes "and # only once" mechanical: matrix-ctl's first act is to read this path back and @@ -260,11 +268,22 @@ let # here recovers one secret this principal itself wrote, which is a much # narrower grant than the publisher's would have been. matrixCtlPolicyText = '' - path "${credentialMountPath}/data/swarm/services/matrix/sender-token" { + path "${credentialMountPath}/data/swarm/hives/${matrixCtlHive}/matrix/sender-token" { capabilities = ["create", "update", "read"] } ''; + # Which hive matrix-ctl mints for. This host's own by default, which is right + # whenever the store and the homeserver are co-located and is the shape the + # `mkDefault` deployments produce; an operator running them apart names the + # homeserver's hive here, because the policy is written where the store is + # and the container runs where the homeserver is. + # + # A wrong value is loud rather than silent: matrix-ctl's write comes back 403 + # with the store's own message and the hive falls back to minting its account + # locally, which is the same degrade a store that was never deployed gives. + matrixCtlHive = baoDeploy.matrixCtlHiveName; + # The KV v2 engine the controller writes agent credentials through. Named # once because the grant above and the `secrets enable` in the bootstrap unit # have to agree: a policy pointing at a mount nobody created is precisely the @@ -711,6 +730,31 @@ in ''; }; + matrixCtlHiveName = lib.mkOption { + type = lib.types.str; + default = toString hyperhiveCfg.hiveName; + defaultText = lib.literalExpression "services.hyperhive.hiveName"; + example = "pr1ma"; + description = '' + Hive whose matrix sender token the store's matrix-ctl role may write. + + The sender account's access token is **per hive**: it lives at + `swarm/hives//matrix/sender-token`, and the only read grant that + reaches it is that hive's own. So matrix-ctl's write grant names one + hive too — the hive whose homeserver container it runs in. + + Defaults to this host's own {option}`services.hyperhive.hiveName`, + which is correct whenever the store and the homeserver are co-located. + Set it when they are not: the policy is written where the store runs, + and the oneshot runs where the homeserver does. + + A wrong value degrades rather than breaks — matrix-ctl's write is + refused with the store's own message and the hive mints its account + locally instead, the same fallback a swarm that never deployed the + store already uses. + ''; + }; + serverCaFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index c6abcc45..2068620c 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -147,20 +147,26 @@ let # the `services/` prefix the publisher holds would be a real loss even # though it would read as tidier. # - # ⚠️ `services` is PLURAL, because the path segment comes from - # `Kind::Service`'s `#[strum(serialize = "services")]` and not from - # `Kind::label`, which renders the singular for error text. The singular - # spelling evaluates, deploys, and 403s every read with "permission - # denied" and nothing else. - name = "matrix-ctl's grant is the sender token's path and nothing else"; + # ⚠️ `hives` is PLURAL, because the path segment comes from + # `Kind::Hive`'s strum serialisation and not from `Kind::label`, which + # renders the singular for error text. The singular spelling evaluates, + # deploys, and 403s every read with "permission denied" and nothing else. + # + # 🩸 The hive NAME in the middle is the per-hive half of this credential: + # the token used to be one swarm-wide value under `services/matrix/`, + # which every hive's own policy granted read on. The negative arms below + # are what keep it from drifting back — neither the `services/*` tree nor + # a `hives/*` wildcard may appear, since either one hands matrix-ctl (or + # a hive) reach beyond the single leaf it owns. + name = "matrix-ctl's grant is one hive's sender token path and nothing else"; ok = let s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script; in - lib.hasInfix "path \"secret/data/swarm/services/matrix/sender-token\" {" s - && !(lib.hasInfix "secret/data/swarm/services/*" s) + lib.hasInfix "path \"secret/data/swarm/hives/h1/matrix/sender-token\" {" s + && !(lib.hasInfix "secret/data/swarm/services" s) && !(lib.hasInfix "secret/data/swarm/agents" s) - && !(lib.hasInfix "secret/data/swarm/hives" s) + && !(lib.hasInfix "secret/data/swarm/hives/*" s) && !(lib.hasInfix "sys/policies/acl" s); } { diff --git a/nix/module-eval/bao-matrix-reader.nix b/nix/module-eval/bao-matrix-reader.nix index bf16a643..00f2256b 100644 --- a/nix/module-eval/bao-matrix-reader.nix +++ b/nix/module-eval/bao-matrix-reader.nix @@ -289,6 +289,26 @@ let && u.environment.MATRIX_MINT_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml" && u.serviceConfig.Type == "oneshot"; } + { + # 🩸 The per-hive minting identity, read off the rendered unit rather than + # off the option: the binary builds its store path out of + # `MATRIX_MINT_HIVE` and logs in as `MATRIX_MINT_LOCALPART`, so a unit + # that passed the old bare `hive` would publish one identity for the + # whole swarm again and nothing in the Rust tests could see it. Both + # spellings are pinned, and the localpart is pinned as *derived from* the + # hive name rather than as a literal, which is the agreement + # `swarm_secret_client::matrix::hive_localpart` owns. + name = "matrix-ctl is told which hive it mints for, and acts as that hive's account"; + ok = + let + m = baoWithMatrix; + u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-ctl; + hive = m.services.hyperhive.hiveName; + in + u.environment.MATRIX_MINT_HIVE == hive + && u.environment.MATRIX_MINT_LOCALPART == "hive-${hive}" + && u.environment.MATRIX_MINT_LOCALPART != "hive"; + } { # 🩸 The crate is a `*ctl` with subcommands, so the unit has to name a # VERB. This is the one end of that contract nix owns: the binary's own diff --git a/swarm-matrix-ctl/src/homeserver.rs b/swarm-matrix-ctl/src/homeserver.rs index ae44660b..05ac19e0 100644 --- a/swarm-matrix-ctl/src/homeserver.rs +++ b/swarm-matrix-ctl/src/homeserver.rs @@ -22,8 +22,8 @@ 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:` account is the appservice registration's -/// own `sender_localpart`, so the homeserver creates it at startup, before +/// *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. pub enum Registered { @@ -262,8 +262,8 @@ mod tests { #[test] fn the_expected_already_exists_answer_is_recognised_by_its_errcode() { // Matched on the spec's code rather than on message text, because this - // is the arm a healthy homeserver takes every time: the `@hive:` account - // is the appservice registration's own sender, created at startup. + // is the arm a healthy homeserver takes every time: the hive's `@hive-:` + // account is the appservice registration's own sender, created at startup. let json = serde_json::json!({ "errcode": "M_USER_IN_USE", "error": "User ID taken" }); assert_eq!(errcode(&json), Some("M_USER_IN_USE")); } diff --git a/swarm-matrix-ctl/src/mint.rs b/swarm-matrix-ctl/src/mint.rs index 20cffeab..a72f848b 100644 --- a/swarm-matrix-ctl/src/mint.rs +++ b/swarm-matrix-ctl/src/mint.rs @@ -2,10 +2,12 @@ //! homeserver access token to the swarm's secret store, once. //! //! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the -//! swarm controller. It is this account rather than an agent's because a -//! homeserver has **one** appservice registration and so one sender account, -//! and a swarm runs one homeserver: "only once" is a property of what is being -//! minted, so there is nothing to lock and no trigger to serve. +//! swarm controller. It mints for **one hive** — the hive this container runs +//! on, named by [`ENV_HIVE`] — and publishes to that hive's own path, so a +//! swarm whose hives share a homeserver gets one account and one token per +//! hive rather than one shared between all of them. "Only once" is therefore +//! once per hive, and it is still a property of what is being minted rather +//! than of a lock: nothing else writes that path. //! //! The store, not the homeserver, is the idempotency key — see //! [`already_published`]. On the hive side `hive-c0re`'s @@ -31,9 +33,15 @@ const ENV_API_URL: &str = "MATRIX_MINT_API_URL"; /// The bind-mounted appservice registration, which is where the `as_token` /// comes from. A path, never a value. const ENV_REGISTRATION: &str = "MATRIX_MINT_REGISTRATION"; -/// Localpart of the appservice's sender account. The registration's own -/// `sender_localpart`, and `hive-c0re`'s `matrix::HIVE_LOCALPART`. +/// Localpart of this hive's sender account. The registration's own +/// `sender_localpart`, rendered by `hive-matrix.nix` from the hive name — the +/// same string `swarm_secret_client::matrix::hive_localpart` builds, which is +/// what `hive-c0re` derives its own copy with. const ENV_LOCALPART: &str = "MATRIX_MINT_LOCALPART"; +/// Name of the hive this container belongs to, and so the segment of the store +/// path the token is published under. It is what keeps one hive's token out of +/// another hive's reach — see `swarm_secret_client::matrix::sender_token_path`. +const ENV_HIVE: &str = "MATRIX_MINT_HIVE"; /// Public base URL of the homeserver, stored beside the token so a reader can /// reconstruct where it is good for. Optional: a swarm with no gateway vhost /// has no such URL, and `matrix::Credential` types the field to say so. @@ -50,6 +58,7 @@ struct Config { api_url: String, registration: String, localpart: String, + hive: String, homeserver: Option, } @@ -77,6 +86,7 @@ impl Config { api_url: required(ENV_API_URL)?, registration: required(ENV_REGISTRATION)?, localpart: required(ENV_LOCALPART)?, + hive: required(ENV_HIVE)?, // Empty is absent: systemd renders an unset nix option as // `Environment=VAR=`, so that is the shape this arrives in. homeserver: get(ENV_HOMESERVER).filter(|v| !v.is_empty()), @@ -128,7 +138,8 @@ pub async fn run() -> Result<()> { ) })?; - let path = matrix::sender_token_path(); + let path = matrix::sender_token_path(&config.hive) + .with_context(|| format!("building the store path for hive {}", config.hive))?; if already_published(&store, &path).await { tracing::info!(%path, "the sender token is already published; not minting"); return Ok(()); @@ -175,7 +186,8 @@ mod tests { ENV_REGISTRATION => { Some("/var/lib/hyperhive/matrix-appservice/hyperhive.yaml".to_owned()) } - ENV_LOCALPART => Some("hive".to_owned()), + ENV_LOCALPART => Some("hive-pr1ma".to_owned()), + ENV_HIVE => Some("pr1ma".to_owned()), _ => None, } } @@ -185,13 +197,20 @@ mod tests { // The control: without it every assertion below could be passing // because `from_lookup` rejects everything. let c = Config::from_lookup(full).expect("every required variable is set"); - assert_eq!(c.localpart, "hive"); + assert_eq!(c.localpart, "hive-pr1ma"); + assert_eq!(c.hive, "pr1ma"); assert_eq!(c.homeserver, None, "an absent public URL is not an error"); } #[test] fn each_required_variable_is_named_when_it_is_the_missing_one() { - for var in [ENV_CERT_ROLE, ENV_API_URL, ENV_REGISTRATION, ENV_LOCALPART] { + for var in [ + ENV_CERT_ROLE, + ENV_API_URL, + ENV_REGISTRATION, + ENV_LOCALPART, + ENV_HIVE, + ] { let e = Config::from_lookup(|k| if k == var { None } else { full(k) }) .expect_err("one required variable is absent"); assert!( @@ -237,6 +256,7 @@ mod tests { ENV_API_URL, ENV_REGISTRATION, ENV_LOCALPART, + ENV_HIVE, ENV_HOMESERVER, ] { assert!( @@ -253,8 +273,29 @@ mod tests { // and names the literal so a move of the path is a deliberate edit on // both sides rather than a silent 404 on the reading one. assert_eq!( - matrix::sender_token_path(), - "swarm/services/matrix/sender-token" + matrix::sender_token_path("pr1ma").expect("a plain name is legal"), + "swarm/hives/pr1ma/matrix/sender-token" ); } + + #[test] + fn two_hives_are_published_to_two_paths() { + // What the hive segment is FOR: this binary runs beside a homeserver + // several hives share, so a path without the hive name in it would + // have each run overwrite the last and leave every hive holding one + // identity — which is the shape this change exists to end. + let a = matrix::sender_token_path("alpha").expect("legal"); + let b = matrix::sender_token_path("beta").expect("legal"); + assert_ne!(a, b); + } + + #[test] + fn the_localpart_the_unit_hands_over_is_the_one_derived_from_the_hive() { + // The nix unit renders both variables independently; this pins that + // the pair it is expected to render agrees with the shared derivation, + // so a unit still passing the old bare `hive` fails here rather than + // silently logging in as another hive's account. + let c = Config::from_lookup(full).expect("every required variable is set"); + assert_eq!(c.localpart, matrix::hive_localpart(&c.hive)); + } } diff --git a/swarm-secret-client/src/matrix.rs b/swarm-secret-client/src/matrix.rs index d956dbac..beb97837 100644 --- a/swarm-secret-client/src/matrix.rs +++ b/swarm-secret-client/src/matrix.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use crate::{ Error, - path::{Kind, ROOT, checked_segment, principal_prefix}, + path::{Kind, checked_segment, principal_prefix}, }; /// The path holding `agent`'s token for the external matrix account `account`. @@ -26,32 +26,50 @@ pub fn account_path(agent: &str, account: &str) -> Result { Ok(format!("{prefix}/matrix/{account}")) } -/// The swarm service the homeserver is, as the name segment under -/// [`Kind::Service`]. +/// The localpart `hive` acts as on the homeserver, and the `sender_localpart` +/// of that hive's appservice registration. /// -/// A swarm runs one homeserver, so this is a constant rather than a parameter — -/// and that is the whole of what makes the credential below mintable -/// "only once" without a lock. -pub const HOMESERVER_SERVICE: &str = "matrix"; - -/// The path holding the matrix appservice sender account's homeserver -/// access token. +/// **Derived from the hive's name, which is what makes it one account per +/// hive.** It used to be the bare constant `hive`: one swarm runs one +/// homeserver, so every hive on it logged in as the same `@hive:` and the +/// homeserver could not tell them apart — no attribution, and no way to revoke +/// one hive without revoking all of them. /// -/// Keyed per **homeserver**, not per hive and not per agent: a homeserver has one -/// appservice registration and so one sender account (`@hive:`, -/// hive-c0re's `matrix::HIVE_LOCALPART`), so a -/// per-hive copy would be several names for one secret. That is also why this -/// takes no argument and cannot fail — there is no caller-supplied segment in -/// it to reject. +/// The prefix is kept so the account still reads as a hive's rather than an +/// agent's; `nix/host-modules/hive-matrix.nix` renders the same string into +/// the registration's `sender_localpart`, and **the two must match** — nothing +/// wires an override across. /// -/// Reachable by every hive without a new grant: [`crate::policy::render`] -/// already grants a hive read on the whole [`Kind::Service`] tree. +/// Infallible on purpose: a hive name is `[a-z0-9-]` (`hive_types::Ident`) and +/// the appservice namespace regex admits that charset, so there is no +/// rejection arm to write. [`sender_token_path`] below does the checking, and +/// it is the one that interpolates into a store path. #[must_use] -pub fn sender_token_path() -> String { - format!( - "{ROOT}/{}/{HOMESERVER_SERVICE}/sender-token", - <&str>::from(Kind::Service) - ) +pub fn hive_localpart(hive: &str) -> String { + format!("hive-{hive}") +} + +/// The path holding `hive`'s matrix sender account's homeserver access token. +/// +/// Keyed per **hive**, like [`appservice_token_path`] below and unlike the +/// per-homeserver constant this replaced. The old path +/// (`swarm/services/matrix/sender-token`) held **one value for the whole +/// swarm** and sat under the [`Kind::Service`] tree that +/// [`crate::policy::render`] grants *every* hive read on — so every hive could +/// read, and act as, the one shared account. +/// +/// Under [`Kind::Hive`] the grant that reaches it is the hive's **own** +/// stanza, `secret/data/swarm/hives//*`, which interpolates the +/// name: hive `alpha` reads its own token and gets a 403 on `beta`'s. Nothing +/// was added to the policy to do that — the path moved out from under the +/// broad grant, which is the half that makes it real. +/// +/// # Errors +/// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`, +/// which is what keeps one hive's name from addressing another hive's secret. +pub fn sender_token_path(hive: &str) -> Result { + let prefix = principal_prefix(Kind::Hive, hive)?; + Ok(format!("{prefix}/matrix/sender-token")) } /// The path holding `hive`'s matrix appservice token (`as_token`). @@ -115,25 +133,59 @@ mod tests { } #[test] - fn the_hive_credential_lands_under_the_service_prefix_the_grant_covers() { + fn the_sender_token_lands_under_the_hive_prefix_the_grant_covers() { // Spelled out rather than rebuilt from the same pieces the code uses, // for the reason above — and with a second job here: the hive's own - // policy grants read on `secret/data/swarm/services/*`, so this exact - // string is what makes the path reachable at all. The `services` - // segment is PLURAL; `Kind::label` renders the singular and is for - // error text only, so reading it as the path segment produces a - // 403 the store explains as "permission denied" and nothing else. - assert_eq!(sender_token_path(), "swarm/services/matrix/sender-token"); + // policy grants read on `secret/data/swarm/hives//*`, so + // this exact string is what makes the path reachable at all. + assert_eq!( + sender_token_path("pr1ma").expect("a plain name is legal"), + "swarm/hives/pr1ma/matrix/sender-token" + ); } #[test] - fn the_hive_credential_sits_where_a_service_principal_would() { - // The constant path above must be the same one the general builder - // produces, or the grant covering `Kind::Service` would cover a - // neighbouring tree instead of this one. - let prefix = - principal_prefix(Kind::Service, HOMESERVER_SERVICE).expect("a plain name is legal"); - assert_eq!(sender_token_path(), format!("{prefix}/sender-token")); + fn the_sender_token_left_the_tree_every_hive_can_read() { + // 🩸 The whole point of the move. `policy::render` grants every hive + // read on `secret/data/swarm/services/*` — a deliberate grant that + // stays, because a service's OIDC secret is read with the certificate + // of whatever hive hosts it. What must not stay is this credential + // sitting inside it: under `services/` one shared token was readable + // by every hive, which is one matrix identity for the whole swarm. + let p = sender_token_path("pr1ma").expect("legal"); + assert!(!p.starts_with("swarm/services/"), "{p}"); + } + + #[test] + fn one_hives_sender_token_is_not_another_hives() { + // The property the per-hive path exists for: two hives never name the + // same object, so the `hives//*` grant that reaches one cannot + // reach the other. + let a = sender_token_path("alpha").expect("legal"); + let b = sender_token_path("beta").expect("legal"); + assert_ne!(a, b); + } + + #[test] + fn a_traversal_in_the_hive_name_is_refused_by_the_sender_path_too() { + let e = sender_token_path("../beta").expect_err("a traversal is not legal"); + assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}"); + } + + #[test] + fn the_localpart_is_derived_from_the_hive_name() { + // The literal is the point: `nix/host-modules/hive-matrix.nix` renders + // the same string into the registration's `sender_localpart` and into + // `MATRIX_MINT_LOCALPART`, and nothing wires an override across — so a + // change here is a change there. + assert_eq!(hive_localpart("pr1ma"), "hive-pr1ma"); + assert_ne!( + hive_localpart("pr1ma"), + hive_localpart("secunda"), + "two hives must not land on one account" + ); + // And it is never the bare `hive` that used to be shared swarm-wide. + assert_ne!(hive_localpart("pr1ma"), "hive"); } #[test] diff --git a/swarm-secret-client/src/policy.rs b/swarm-secret-client/src/policy.rs index c314ce27..a04e4e57 100644 --- a/swarm-secret-client/src/policy.rs +++ b/swarm-secret-client/src/policy.rs @@ -24,10 +24,10 @@ //! The **hive** stanza has no such problem and is therefore narrow: that path //! names its principal, so scoping it to the reader's own name costs nothing //! and drifts nowhere. Do not widen it to match its neighbours — the asymmetry -//! is the point. +//! is the point, and only holds if a one-per-hive credential is stored under +//! [`Kind::Hive`], not [`Kind::Service`] — see docs/trust-boundary/security.md. //! -//! Rendering stays separate from writing so the text can be asserted with no -//! store to talk to. +//! Rendering stays separate from writing so the text can be asserted with no store to talk to. use crate::{ Error, @@ -251,6 +251,60 @@ mod tests { ); } + /// 🩸 The per-hive matrix sender-token scoping, asserted on the rendered + /// document rather than on the path function alone: it is the policy text + /// that decides what a hive may fetch, so "the path is per-hive" only + /// means something if the stanza that reaches it is too. + /// + /// The `services/*` stanza above is deliberate and stays — it is how a + /// host reads the OIDC secret of a service it runs. The property here is + /// that the matrix sender token is no longer *inside* it. + #[test] + fn a_hive_reaches_its_own_matrix_sender_token_and_no_other_hives() { + let alpha = render("alpha").expect("legal"); + let own = crate::matrix::sender_token_path("alpha").expect("legal"); + let other = crate::matrix::sender_token_path("beta").expect("legal"); + + // Reachable: the hive's own stanza is the prefix of its own path. + assert!( + alpha.contains("path \"secret/data/swarm/hives/alpha/*\""), + "{alpha}" + ); + assert!(own.starts_with("swarm/hives/alpha/"), "{own}"); + + // Unreachable: no stanza in alpha's document is a prefix of beta's + // path. Checked by walking the stanzas rather than by asserting the + // absence of the string "beta", so a future stanza that happened to + // cover it — `swarm/hives/*`, say — would fail this too. + assert!( + !stanza_paths(&alpha) + .iter() + .any(|granted| covers(granted, &other)), + "alpha's document reaches {other}:\n{alpha}" + ); + } + + /// Every `path "…"` a rendered document grants, with the `secret/data/` + /// ACL prefix stripped so it compares against a store path. + fn stanza_paths(document: &str) -> Vec { + document + .lines() + .filter_map(|line| line.strip_prefix("path \"")) + .filter_map(|rest| rest.split('"').next()) + .filter_map(|p| p.strip_prefix("secret/data/")) + .map(str::to_owned) + .collect() + } + + /// Does a granted policy path cover `path`? Only the trailing-`*` form + /// this module renders, which is the only form it has to understand. + fn covers(granted: &str, path: &str) -> bool { + match granted.strip_suffix('*') { + Some(prefix) => path.starts_with(prefix), + None => granted == path, + } + } + #[test] fn the_grant_is_read_only() { // A hive reads credentials; a hive that could write one could hand