diff --git a/Cargo.lock b/Cargo.lock index 64c1b82b..d0371900 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4924,6 +4924,20 @@ dependencies = [ "swarm-queue-client", ] +[[package]] +name = "swarm-matrix-ctl" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "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..a4baf59e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "swarm-authelia-bridge", "swarm-authelia-bridge-sock", "swarm-controller", + "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 cd284449..ea23be35 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: @@ -229,7 +229,7 @@ control: [`swarm/ui.md`](../swarm/ui.md). ### 6 · Matrix ```bash -# Ensure the hive-internal admin 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 matrix admin 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 cb3870ef..992de996 100644 --- a/docs/integrations/matrix.md +++ b/docs/integrations/matrix.md @@ -147,29 +147,86 @@ 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 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. 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's 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. +**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. -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. +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. 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 +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, and no Synapse admin API to reach for either, since +tuwunel has none. + +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 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 @@ -185,10 +242,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. +- **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. - **`/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,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 admin -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. @@ -276,9 +333,9 @@ services.hyperhive.swarm.matrix = { ``` **`trustedServers`** (default `[]`) — list of peer homeserver names -whose signing keys tuwunel will fetch and trust. 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 +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)). diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index c1bace54..0143621a 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//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 | +| _(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 12ecc67a..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,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 matrix appservice's sender 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..5a40ddb0 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 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,19 +74,23 @@ 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 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 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 - credentials. + admin by an `!admin` command in `#admins`. ⚠️ Needs an admin **sender**, + 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. ⚠️ 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 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 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. @@ -225,7 +229,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/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/flake.nix b/flake.nix index 870a4b2e..3171895d 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.ctlPackage = + lib.mkDefault + 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 b4c09558..e7f7c433 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -65,18 +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. 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 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. +/// +/// # 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 @@ -94,11 +119,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 appservice sender 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 sender_token_path() -> PathBuf { + crate::paths::matrix_sender_token() } /// Token file inside the agent's bind-mounted state dir (visible as @@ -382,21 +407,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 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, &admin_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"); @@ -416,7 +446,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, + sender_token: &str, server_name: &str, ) -> Result { let base = matrix_base()?; @@ -425,7 +455,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(sender_token) .send() .await .context("matrix: GET admin room alias")?; @@ -553,7 +583,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, + sender_token: &str, server_name: &str, room_url: &str, command: &str, @@ -566,7 +596,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(sender_token) .json(&serde_json::json!({"msgtype": "m.text", "body": command})) .send() .await @@ -587,13 +617,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!("@{}:{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; let poll_json = client .get(&poll_url) - .bearer_auth(admin_token) + .bearer_auth(sender_token) .send() .await .context("matrix: admin room poll")? @@ -641,22 +671,22 @@ 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( client: &reqwest::Client, - admin_token: &str, + sender_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, 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, - admin_token, + sender_token, server_name, &room_url, &command, @@ -761,13 +791,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's sender + // token at /var/lib/hyperhive/matrix/access-token, and an admin + // sender, which `@hive-:` is not. 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 +915,52 @@ 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's `@hive-:` matrix user exists and that its access token is +/// persisted at [`sender_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-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 +/// binary. +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 = sender_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 sender token is already present"); return Ok(()); } + 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_ADMIN_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_ADMIN_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 hive admin 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)); } @@ -931,125 +973,108 @@ 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!(%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 the hive admin failed; falling back to the stored password"); - let pw_path = password_path(HIVE_ADMIN_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: hive admin 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_ADMIN_LOCALPART} and that the homeserver \ + namespace covers @{localpart} and that the homeserver \ loaded it", pw_path.display() ) })?; - login_user(client, HIVE_ADMIN_LOCALPART, &stored).await? + login_user(client, &localpart, &stored).await? } } } Err(other) => return Err(other), }; + persist_sender_token(&path, &access_token) +} + +/// 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_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 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 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 sender token"); Ok(()) } -/// Check that the hive admin account holds admin rights, and repair it -/// through the admin room when it does not. +/// Fetch the sender token `swarm-matrix-ctl` 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`]. 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. /// -/// 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 `swarm-matrix-ctl` +/// 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_sender_token() -> Option { + let hive = std::env::var("HYPERHIVE_HIVE_NAME") + .ok() + .filter(|h| !h.is_empty())?; + let path = match swarm_secret_client::matrix::sender_token_path(&hive) { + Ok(path) => path, Err(e) => { - tracing::warn!(error = ?e, "matrix: cannot resolve #admins — not checking the hive admin's rights"); - return false; + tracing::warn!(error = %e, "matrix: this hive's name does not form a store path"); + 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; + 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 sender token from"); + return None; } - 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 sender token from the swarm store"); + Some(credential.value) + } + Ok(_) => { + tracing::warn!(%path, "matrix: the stored sender token is empty; minting instead"); + None + } + Err(e) => { + tracing::debug!(%path, error = %e, "matrix: no sender token 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. @@ -1103,12 +1128,16 @@ 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. -/// 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 @@ -1116,16 +1145,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, + sender_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, 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, - admin_token, + sender_token, server_name, &room_url, &command, @@ -1143,19 +1172,25 @@ 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 +/// 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( client: &reqwest::Client, - admin_token: &str, + sender_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, sender_token, server_name, localpart) .await .with_context(|| { format!("matrix: admin-room password reset for @{localpart}:{server_name}") @@ -1205,19 +1240,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 sender token from disk. Returns an error if it +/// is absent — callers should gate their homeserver calls on this. +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!( - "hive admin matrix token 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 admin account on boot)", + (it provisions the hive's @hive-: account on boot)", path.display() ) }) @@ -1244,12 +1279,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, 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(admin_token) + .bearer_auth(sender_token) .send() .await .ok()? @@ -1266,7 +1301,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti 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) + .bearer_auth(sender_token) .send() .await { @@ -1282,7 +1317,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(sender_token).send().await { Ok(r) if r.status().is_success() => r .json::() .await @@ -1299,7 +1334,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti /// 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. @@ -1313,7 +1348,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, 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()) { @@ -1326,7 +1361,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, sender_token).await { persist_space_room_id(&room_id)?; tracing::info!(%room_id, "matrix: recovered hive space by name"); return Ok(room_id); @@ -1342,7 +1377,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(sender_token) .json(&body) .send() .await @@ -1382,12 +1417,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, 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(admin_token) + .bearer_auth(sender_token) .send() .await .ok()? @@ -1404,7 +1439,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> 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) + .bearer_auth(sender_token) .send() .await { @@ -1420,7 +1455,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(sender_token).send().await { Ok(r) if r.status().is_success() => r .json::() .await @@ -1460,10 +1495,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, + sender_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(sender_token).send().await { Ok(resp) => resp, Err(e) => { tracing::debug!(error = ?e, url, "matrix: state read unreachable; writing"); @@ -1491,7 +1526,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 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 @@ -1506,7 +1541,7 @@ async fn current_room_state( /// missing or divergent link still gets written. async fn set_room_state( client: &reqwest::Client, - admin_token: &str, + sender_token: &str, room_id: &str, event_type: &str, state_key: &str, @@ -1517,7 +1552,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, sender_token, &url).await; if !state_needs_write(current.as_ref(), content) { tracing::debug!( %room_id, @@ -1528,7 +1563,7 @@ async fn set_room_state( } let resp = client .put(&url) - .bearer_auth(admin_token) + .bearer_auth(sender_token) .json(content) .send() .await @@ -1565,7 +1600,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, + sender_token: &str, space_room_id: &str, server_name: &str, ) -> Result { @@ -1579,7 +1614,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, sender_token).await { persist_chat_room_id(&id)?; tracing::info!(room_id = %id, "matrix: recovered hive chat room by name"); id @@ -1619,7 +1654,7 @@ pub async fn ensure_hive_chat_room( }); let resp = client .post(&url) - .bearer_auth(admin_token) + .bearer_auth(sender_token) .json(&body) .send() .await @@ -1650,7 +1685,7 @@ pub async fn ensure_hive_chat_room( }); if let Err(e) = set_room_state( client, - admin_token, + sender_token, space_room_id, "m.space.child", &room_id, @@ -1668,21 +1703,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, + sender_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, sender_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 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, - admin_token: &str, + sender_token: &str, encoded_room_id: &str, user_id: &str, ) -> Option { @@ -1695,7 +1730,7 @@ async fn room_membership( ); let resp = client .get(&url) - .bearer_auth(admin_token) + .bearer_auth(sender_token) .send() .await .ok()?; @@ -1709,13 +1744,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 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, - admin_token: &str, + sender_token: &str, room_id: &str, user_id: &str, ) -> Result<()> { @@ -1727,7 +1762,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, 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)"); @@ -1737,7 +1772,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(sender_token) .json(&serde_json::json!({ "user_id": user_id })) .send() .await @@ -1770,13 +1805,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 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, - admin_token: &str, + sender_token: &str, user: &str, room_override: Option<&str>, server_name: &str, @@ -1790,7 +1825,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, 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; \ @@ -1804,14 +1839,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, 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, - admin_token: &str, + sender_token: &str, alias: &str, ) -> Result { let base = matrix_base()?; @@ -1819,7 +1854,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(sender_token) .send() .await .with_context(|| format!("matrix: GET directory for {alias}"))?; @@ -1875,13 +1910,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'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. + 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 +1945,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 sender_token = match read_sender_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 matrix sender token)"); return false; } }; @@ -1926,60 +1960,55 @@ 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, &sender_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 + // 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 { - if let Err(e) = invite_to_room(client, &admin_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; } } // 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. - match ensure_hive_chat_room(client, &admin_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, - &admin_token, + &sender_token, &chat_room_id, - HIVE_ADMIN_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 { if let Err(e) = - invite_to_room(client, &admin_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 5ad8066b..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-admin-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 (admin 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,10 +137,10 @@ pub fn matrix_dir() -> PathBuf { state_root().join("matrix") } -/// `matrix/admin-token` — hive system admin access token. +/// `matrix/access-token` — the matrix appservice sender account's access token. #[must_use] -pub fn matrix_admin_token() -> PathBuf { - matrix_dir().join("admin-token") +pub fn matrix_sender_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-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 803682e2..e9cbbf08 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 + 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. // --------------------------------------------------------------------------- @@ -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::sender_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 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, &admin_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 admin_token = crate::matrix::read_admin_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, &admin_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 admin_token = crate::matrix::read_admin_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, &admin_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 12a81b75..542c36e6 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 matrix appservice's sender 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..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 + admin 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 + -/// admin 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/default.nix b/nix/host-modules/default.nix index 0161ea2d..b4f1e7e8 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-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 419f9c63..819569bf 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 `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 + # 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-ctl.pem ] || ${signLeaf} ${pkiDir} matrix-ctl \ + ${lib.escapeShellArg deployCfg.bao.matrixCtlCommonName} "" clientAuth ''; }; }; diff --git a/nix/host-modules/glue-matrix-ctl-bao-identity.nix b/nix/host-modules/glue-matrix-ctl-bao-identity.nix new file mode 100644 index 00000000..0d725ad4 --- /dev/null +++ b/nix/host-modules/glue-matrix-ctl-bao-identity.nix @@ -0,0 +1,40 @@ +# Glue: point the matrix container's `swarm-matrix-ctl` at the bao leaf minted for it. +# +# 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 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 +# 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 = { + 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 eeb72d16..1304d9b2 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,30 @@ 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 - # it independently with nothing wiring an override across — same - # agreement, and same reason for saying so, as the token path below. - adminLocalpart = "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 @@ -107,7 +124,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-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, + # 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. + 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 + # `matrixCtlPolicyName` and this hands it to the binary in the + # environment, so the binary itself spells no role at all. + ctlCertRole = "swarm-matrix-ctl"; + + # 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. + # + # 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. + ctlPkiDirs = lib.optionals ctlActive ( + lib.unique ( + map builtins.dirOf ( + [ + deployCfg.matrix.ctlBaoClientCertFile + deployCfg.matrix.ctlBaoClientKeyFile + ] + ++ lib.optional (deployCfg.bao.serverCaFile != null) deployCfg.bao.serverCaFile + ) + ) + ); + + 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 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. + 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 # whole matrix localpart charset. # @@ -173,7 +245,7 @@ let cat <<'REGISTRATION' id: ${appserviceId} url: null - sender_localpart: ${adminLocalpart} + sender_localpart: ${hiveLocalpart} rate_limited: false namespaces: users: @@ -686,6 +758,56 @@ in match authelia's register. ''; }; + + ctlPackage = lib.mkOption { + type = lib.types.package; + defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-matrix-ctl"; + description = '' + The `swarm-matrix-ctl` build run inside the matrix container. + + ⚠️ 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`. + ''; + }; + + ctlBaoClientCertFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + 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.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 + 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-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. + 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. + ''; + }; + + ctlBaoClientKeyFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Private key for + {option}`services.hyperhive.deploy.matrix.ctlBaoClientCertFile`. + Both or neither — the unit does not exist unless each is set. + ''; + }; }; config = lib.mkIf deployCfg.matrix.enable { @@ -1082,7 +1204,11 @@ in isReadOnly = true; }; } - // caTrust.bindMount; + // caTrust.bindMount + # 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. + // ctlBindMounts; config = { ... }: { @@ -1192,30 +1318,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 +1403,66 @@ in "oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}" ]; + # 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 `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 + # 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-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 + # 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"; + # 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-ctl"; + }; + environment = { + BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}"; + 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_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_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) { + 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..702e5e72 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -236,6 +236,54 @@ let } ''; + # 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. + matrixCtlPolicyName = "swarm-matrix-ctl"; + matrixCtlCn = baoDeploy.matrixCtlCommonName; + + # 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, 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 + # 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. + matrixCtlPolicyText = '' + 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 @@ -659,6 +707,54 @@ in ''; }; + matrixCtlCommonName = lib.mkOption { + type = lib.types.str; + default = "swarm-matrix-ctl"; + example = "swarm-matrix-ctl.svc"; + description = '' + 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. + + 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 matrix-ctl's grant. + ''; + }; + + 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; @@ -1081,7 +1177,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}/"'*) ;; @@ -1105,7 +1201,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 — @@ -1222,6 +1318,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-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" + ]; + 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 matrixCtlPolicyText} | + bao policy write ${lib.escapeShellArg matrixCtlPolicyName} - + '' + + lib.optionalString (baoDeploy.clientCaFile != null) '' + + bao write auth/cert/certs/${lib.escapeShellArg matrixCtlPolicyName} \ + certificate=@${tlsDir}/client-ca.pem \ + allowed_common_names=${lib.escapeShellArg matrixCtlCn} \ + token_policies=${lib.escapeShellArg matrixCtlPolicyName} \ + display_name=${lib.escapeShellArg matrixCtlCn} + ''; + }; + containers.${cfg.machine} = { autoStart = true; ephemeral = false; diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 0625d336..fd1fe9c8 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.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 256b1cee..2068620c 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -140,6 +140,62 @@ 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. + # + # ⚠️ `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/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 "sys/policies/acl" s); + } + { + # 🩸 `read` is load-bearing here and is the one capability neither + # 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 = "matrix-ctl may read back the one path it writes"; + ok = + let + 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-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 = "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-ctl-policy.after + ) + && !(baoGrantHere.containers.swarm-bao.config.systemd.services ? swarm-bao-matrix-ctl-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..00f2256b 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 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; }; cases = [ { # A login failure is the store being unreachable, sealed, or not yet @@ -219,6 +225,144 @@ 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 matrix-ctl, and the container is pointed at it"; + ok = + let + m = baoWithMatrix; + p = m.services.hyperhive.deploy.matrix; + in + 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 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 = "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-ctl.environment; + hiveLeaf = baoWithMatrix.services.hyperhive.deploy.bao.clientCertFile; + in + 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 + # 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 matrix-ctl'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 = "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-ctl; + port = m.services.hyperhive.swarm.matrix.httpPort; + in + 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 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 + # 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 = "matrix-ctl's environment carries paths and addresses, never a token"; + ok = + let + 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)); + } + { + # 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 matrix-ctl and binds no PKI"; + ok = + let + units = matrixNoBaoIdentity.containers.hive-matrix.config.systemd.services; + in + !(units ? swarm-matrix-ctl) + && !(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..8716f980 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. 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. + hiveNamedAfterMatrixCtlSubject = hive { + deploy.swarm-otel.enable = false; + deploy.bao.matrixCtlCommonName = "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 matrix-ctl's subject is refused too"; + ok = + equalityGuardFired hiveNamedAfterMatrixCtlSubject + && lib.any ( + a: !a.assertion && lib.hasInfix "'mintctl'" a.message + ) hiveNamedAfterMatrixCtlSubject.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..bd7198ba 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 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-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 # `swarm-nats-auth` above (this one lives in `swarm-authelia`'s diff --git a/swarm-matrix-ctl/Cargo.toml b/swarm-matrix-ctl/Cargo.toml new file mode 100644 index 00000000..3d308778 --- /dev/null +++ b/swarm-matrix-ctl/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "swarm-matrix-ctl" +version.workspace = true +readme = "README.md" +edition.workspace = true + +[[bin]] +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 +# 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-ctl/README.md b/swarm-matrix-ctl/README.md new file mode 100644 index 00000000..65008f53 --- /dev/null +++ b/swarm-matrix-ctl/README.md @@ -0,0 +1,62 @@ +# swarm-matrix-ctl + +The rust that runs **inside `containers.hive-matrix`**, beside the homeserver. + +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. + +## 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 +`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** 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 `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: + +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-ctl/src/homeserver.rs b/swarm-matrix-ctl/src/homeserver.rs new file mode 100644 index 00000000..05ac19e0 --- /dev/null +++ b/swarm-matrix-ctl/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 +//! sender token 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'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 { + /// 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'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")); + } + + #[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-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-ctl/src/mint.rs b/swarm-matrix-ctl/src/mint.rs new file mode 100644 index 00000000..a72f848b --- /dev/null +++ b/swarm-matrix-ctl/src/mint.rs @@ -0,0 +1,301 @@ +//! `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 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 +//! `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. + +use anyhow::{Context, Result}; +use swarm_secret_client::{ + SecretStore, + client::{DEFAULT_CERT_MOUNT, Settings}, + 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_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_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 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. +const ENV_HOMESERVER: &str = "MATRIX_MINT_HOMESERVER"; + +/// 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 +/// 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, + hive: String, + homeserver: Option, +} + +impl Config { + /// Read the `MATRIX_MINT_*` 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)?, + 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()), + }) + } +} + +/// 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 + } + } +} + +/// 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 + // 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::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(()); + } + + 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 sender 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 sender token to {path}"))?; + tracing::info!(%path, "published the sender token"); + 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-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()) + } + ENV_LOCALPART => Some("hive-pr1ma".to_owned()), + ENV_HIVE => Some("pr1ma".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-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, + ENV_HIVE, + ] { + 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); + } + + /// 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_HIVE, + 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 + // 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::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-matrix-ctl/src/registration.rs b/swarm-matrix-ctl/src/registration.rs new file mode 100644 index 00000000..6d2da06d --- /dev/null +++ b/swarm-matrix-ctl/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..beb97837 100644 --- a/swarm-secret-client/src/matrix.rs +++ b/swarm-secret-client/src/matrix.rs @@ -26,6 +26,52 @@ pub fn account_path(agent: &str, account: &str) -> Result { Ok(format!("{prefix}/matrix/{account}")) } +/// The localpart `hive` acts as on the homeserver, and the `sender_localpart` +/// of that hive's appservice registration. +/// +/// **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. +/// +/// 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. +/// +/// 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 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`). /// /// Keyed per **hive**, not per agent, like [`crate::queue::agent_client_path`] @@ -86,6 +132,62 @@ mod tests { assert_eq!(p, "swarm/agents/atlas/matrix/ops-relay"); } + #[test] + 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/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_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] 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: 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