matrix: one sender account and one sender token per hive

A swarm runs one homeserver and every hive on it logged in as the same
`@hive:` localpart, holding the same access token out of one swarm-wide
store path. That is one matrix identity for N hives: the homeserver
cannot attribute an action to the hive that took it, and revoking one
hive's standing revokes every hive's.

Three changes, and the third is the one that makes the other two real:

- **The localpart carries the hive's name** (`hive-<hive>`), derived in
  one place, `swarm_secret_client::matrix::hive_localpart`.
  `hive-matrix.nix` renders the same string as the appservice
  registration's `sender_localpart`, so the shared account stops being
  created rather than merely stops being used.
- **The store path is templated by hive**, not a constant. The
  "a swarm runs one homeserver, so this is a constant rather than a
  parameter" rationale went with it; it stopped holding the moment two
  hives shared the homeserver it describes.
- **The path moved out from under the grant every hive has.** It sat at
  `swarm/services/matrix/sender-token`, inside the
  `secret/data/swarm/services/*` read stanza `policy::render` gives every
  hive. It now sits under that hive's own stanza,
  `secret/data/swarm/hives/<hive>/*`, which interpolates the reader's
  name — so a hive reads its own token and is refused another's. The
  policy renderer itself is unchanged: narrowing the `services/*` grant
  would break the OIDC-secret read it exists for, and moving the
  credential is what this needed instead. A policy test walks the
  rendered stanzas and asserts none of hive alpha's covers hive beta's
  sender token, so a later stanza that widened it fails here.

`swarm-matrix-ctl` takes a new required `MATRIX_MINT_HIVE` and writes
that hive's path; its store grant in `swarm-bao.nix` follows, scoped to
one hive's leaf via the new `deploy.bao.matrixCtlHiveName` (defaulting to
this host's `hiveName`) rather than a `hives/*` wildcard, which would
hand the matrix container every hive's token back.

Migration: no outage at deploy. `ensure_hive_user` short-circuits on the
local token file, so a hive keeps running on what it has; with no such
file it reads the new per-hive path, finds nothing, and falls through to
the existing register-or-appservice-login ladder against its own
localpart — which needs only the per-hive `as_token` on local disk. The
old shared object is read by nothing afterwards. Rooms do not follow the
identity, and that is the one operator step; both ways out are written
into `docs/integrations/matrix.md`.

No admin standing is granted to the per-hive accounts: `admin_execute`
stays empty and the assertion pinning it is untouched.
This commit is contained in:
atlas 2026-09-20 20:06:31 +02:00 committed by mara
commit 1261b525d6
14 changed files with 461 additions and 133 deletions

View file

@ -149,19 +149,27 @@ a token.
### The appservice's sender account, and why it isn't an admin ### The appservice's sender account, and why it isn't an admin
`@hive:<server_name>` is the appservice's own `sender_localpart`, which `@hive-<hive>:<server_name>` is the appservice's own `sender_localpart`,
the homeserver creates itself when it loads the registration — on a which the homeserver creates itself when it loads the registration — on a
zero-user database, inside startup, before the HTTP listener accepts zero-user database, inside startup, before the HTTP listener accepts
anything. It's an **ordinary account**: nothing promotes it, and the anything. It's an **ordinary account**: nothing promotes it, and the
homeserver runs no `admin_execute` for it. homeserver runs no `admin_execute` for it.
**One account per hive.** The localpart carries the hive's name, so a swarm
whose hives share a homeserver gives each of them its own identity: the
homeserver attributes an action to the hive that took it, and revoking one
hive's standing leaves the others alone.
Its access token is the **sender token**, and it's the credential 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 hive-c0re presents for every homeserver call it makes on the hive's
behalf. `swarm-matrix-ctl` mints it inside the `hive-matrix` behalf. It's per hive for the same reason the account is:
container and publishes it to `swarm/services/matrix/sender-token`; the `swarm-matrix-ctl` mints it inside the `hive-matrix` container and
hive reads it from there. The name says what it authenticates as — an publishes it to `swarm/hives/<hive>/matrix/sender-token`, and the hive
account the appservice registration brings into being — rather than any reads it from there under its own certificate. That path sits inside the
privilege level, because it carries none. hive's own read grant (`swarm/hives/<hive>/*`), 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 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 Space and the chat room, writing their hierarchy and join rules, and
@ -175,13 +183,51 @@ and `hivectl matrix reset-password`. Both are `!admin …` messages into
`#admins:<server_name>`, and tuwunel only treats a message as a command `#admins:<server_name>`, and tuwunel only treats a message as a command
when its sender is already an admin. They're swarm-level operations, when its sender is already an admin. They're swarm-level operations,
rehomed to the swarm tier rather than granted here; from the hive, rehomed to the swarm tier rather than granted here; from the hive,
`@hive:` has no admin sender to make that call with, so both get the `@hive-<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 admin room's refusal rather than an over-privileged credential that
every other call site would also carry. The one hive-side path 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 depends on them is the automatic password recovery for an agent that
has lost its stored password — the admin-sender limitation doesn't has lost its stored password — the admin-sender limitation doesn't
touch the ordinary appservice re-login above. touch the ordinary appservice re-login above.
<details><summary>Upgrading a hive that shared one sender account with every other hive</summary>
Nothing to do, and no window where the hive is without an account.
<!-- vale write-good.Passive = NO -->
- **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-<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-<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-<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.
<!-- vale write-good.Passive = YES -->
</details>
<details><summary>Upgrading a hive that used the registration token</summary> <details><summary>Upgrading a hive that used the registration token</summary>
Nothing to do, and nothing to time. The activation script mints the Nothing to do, and nothing to time. The activation script mints the
@ -248,8 +294,8 @@ Initial rollout settings:
## Hive Matrix Space ## Hive Matrix Space
On first boot, after hive-c0re provisions all agent accounts, it On first boot, after hive-c0re provisions all agent accounts, it
creates a private **Matrix Space** named `"hive"` using the `@hive:` creates a private **Matrix Space** named `"hive"` using its own hive
account (`@hive:<server_name>`) and invites every provisioned agent account (`@hive-<hive>:<server_name>`) and invites every provisioned agent
into it. This gives the operator a single Space in FluffyChat or any 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 Matrix client that groups all agent-to-agent + operator rooms in one
place. place.

View file

@ -59,9 +59,9 @@ strategy for every credential, including the mTLS leaf.
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | | `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
| `swarm/agents/<agent>/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/agents/<agent>/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/<hive>/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/<hive>/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/<hive>/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/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | | `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated |
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | | `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated |
| `swarm/services/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated |
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | | _(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 |
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->

View file

@ -74,12 +74,13 @@ hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a spec
- `create-user`: for agents, persists the `access_token` to - `create-user`: for agents, persists the `access_token` to
`<state>/matrix-token`. Skips registration when the file already `<state>/matrix-token`. Skips registration when the file already
exists — delete it first to force re-registration. exists — delete it first to force re-registration.
- `sync-admin`: ensures the appservice's sender account (`@hive:<server_name>`) exists - `sync-admin`: ensures this hive's appservice sender account
(`@hive-<hive>:<server_name>`, one per hive) exists
(the account `hive-c0re` provisions rooms with). Token persisted to the (the account `hive-c0re` provisions rooms with). Token persisted to the
access token path. Safe to run again — idempotent. access token path. Safe to run again — idempotent.
- `promote-user`: promotes an already-registered user to homeserver - `promote-user`: promotes an already-registered user to homeserver
admin by an `!admin` command in `#admins`. ⚠️ Needs an admin **sender**, admin by an `!admin` command in `#admins`. ⚠️ Needs an admin **sender**,
which `@hive:` isn't — promotion is a swarm-level operation, rehomed to which `@hive-<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 the swarm tier rather than granted here, so it has no admin sender to
call it with from the hive. call it with from the hive.
- `reset-password`: asks the admin room to set a new random - `reset-password`: asks the admin room to set a new random

View file

@ -94,6 +94,20 @@ it.
the grant per hive (and pay for the re-emission), or give each agent container 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. 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 ### Threat model: prompt injection → confused deputy
The realistic adversary **never needs to breach the container**. They supply The realistic adversary **never needs to breach the container**. They supply

View file

@ -65,21 +65,43 @@ const HTTP_TIMEOUT_SECS: u64 = 10;
/// store it nowhere. /// store it nowhere.
const PASSWORD_BYTES: usize = 32; const PASSWORD_BYTES: usize = 32;
/// Matrix localpart for the hive system admin account. Not an agent; has /// Matrix localpart this hive acts as. Not an agent; has no state dir.
/// no state dir. ///
/// **Per hive, derived from the hive's name** — `hive-<name>`, 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, /// Also the `sender_localpart` of the hive's appservice registration,
/// which is what creates this account on a homeserver that has never had /// which is what creates this account on a homeserver that has never had
/// one: the homeserver creates a registration's sender user itself, at /// one: the homeserver creates a registration's sender user itself, at
/// startup, before it accepts a request. See /// startup, before it accepts a request. See
/// `nix/host-modules/hive-matrix.nix`, where this same literal appears as /// `nix/host-modules/hive-matrix.nix`, where the same string is rendered as
/// `hiveLocalpart`. **The two must match**; nothing wires an override /// `hiveLocalpart`. **The two must match**; nothing wires an override
/// across. /// across.
/// ///
/// An ordinary account, with no homeserver-admin standing: what it /// An ordinary account, with no homeserver-admin standing: what it
/// provisions — the Space, the chat room, the invites — it provisions as /// provisions — the Space, the chat room, the invites — it provisions as
/// the creator of those rooms. /// the creator of those rooms.
pub const HIVE_LOCALPART: &str = "hive"; ///
/// # Errors
/// When `HYPERHIVE_HIVE_NAME` is unset or empty. That is the same variable
/// [`stored_sender_token`] logs in with and `hive-c0re`'s meta flake asserts
/// for every hive, so its absence is a broken deployment rather than a
/// supported one — and there is no safe default to fall back to: the only one
/// available is the shared account this replaced.
pub fn hive_localpart() -> Result<String> {
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 /// Display name of the hive Space. Plain text, no special characters, so
/// the Space stays rediscoverable by name (no room alias needed) even when /// the Space stays rediscoverable by name (no room alias needed) even when
@ -389,7 +411,7 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re
/// room when the locally stored password is missing. Returns the new /// room when the locally stored password is missing. Returns the new
/// password (already persisted to [`password_path`]) on success. /// password (already persisted to [`password_path`]) on success.
/// ///
/// ⚠️ Needs an **admin sender**, which `@hive:` is not — the reset is a /// ⚠️ Needs an **admin sender**, which `@hive-<hive>:` is not — the reset is a
/// `!admin` command and tuwunel only treats a message as a command when /// `!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 /// 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 /// the two admin operations are rehomed at swarm level; the ordinary
@ -595,7 +617,7 @@ async fn admin_room_send_and_poll<T>(
// Poll for bot response: fetch the 20 most recent events (newest-first) // 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; // on each tick. Walk the list until we hit our own command event_id;
// everything *before* that marker arrived after our command. // everything *before* that marker arrived after our command.
let own_user_id = format!("@{HIVE_LOCALPART}:{server_name}"); let own_user_id = format!("@{}:{server_name}", hive_localpart()?);
let poll_url = format!("{base}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20"); let poll_url = format!("{base}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20");
for _ in 0..15_u8 { for _ in 0..15_u8 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await; tokio::time::sleep(std::time::Duration::from_secs(1)).await;
@ -649,8 +671,8 @@ async fn admin_room_send_and_poll<T>(
} }
/// Reset a user's password via the Matrix admin room (`#admins:<server>`). /// Reset a user's password via the Matrix admin room (`#admins:<server>`).
/// Sends `!admin users reset-password @<localpart>:<server>` as @hive, polls for the bot's /// Sends `!admin users reset-password @<localpart>:<server>` as `@hive-<hive>:`, polls for
/// response containing the new password. /// the bot's response containing the new password.
/// ///
/// Returns the new password; caller is responsible for persisting it. /// Returns the new password; caller is responsible for persisting it.
async fn admin_room_reset_password( async fn admin_room_reset_password(
@ -772,9 +794,9 @@ pub async fn ensure_user_for(client: &reqwest::Client, name: &str, as_token: &st
// Password file missing — attempt auto-recovery through the // Password file missing — attempt auto-recovery through the
// admin room. // admin room.
// This covers the case where agent state dirs were wiped but the // This covers the case where agent state dirs were wiped but the
// homeserver still has the accounts. Requires the `@hive:` // homeserver still has the accounts. Requires the hive's sender
// token at /var/lib/hyperhive/matrix/access-token, and an admin // token at /var/lib/hyperhive/matrix/access-token, and an admin
// sender, which `@hive:` no longer is. // sender, which `@hive-<hive>:` is not.
tracing::info!( tracing::info!(
%name, %name,
"matrix: stored password missing, attempting admin-room auto-recovery" "matrix: stored password missing, attempting admin-room auto-recovery"
@ -893,7 +915,7 @@ pub async fn sync_agent_standalone(name: &str) {
sync_agent(&client, name, &as_token).await; sync_agent(&client, name, &as_token).await;
} }
/// Ensure the `@hive:` matrix user exists and that its access token is /// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is
/// persisted at [`sender_token_path()`]. /// persisted at [`sender_token_path()`].
/// ///
/// **Nothing here depends on registration order, and nothing here is /// **Nothing here depends on registration order, and nothing here is
@ -927,15 +949,18 @@ pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Resul
if let Some(token) = stored_sender_token().await { if let Some(token) = stored_sender_token().await {
return persist_sender_token(&path, &token); 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 password = random_password()?;
let access_token = match register_user(client, HIVE_LOCALPART, as_token, &password).await { let access_token = match register_user(client, &localpart, as_token, &password).await {
Ok(token) => { Ok(token) => {
let pw_path = password_path(HIVE_LOCALPART); let pw_path = password_path(&localpart);
if let Some(parent) = pw_path.parent() { if let Some(parent) = pw_path.parent() {
std::fs::create_dir_all(parent).ok(); std::fs::create_dir_all(parent).ok();
} }
if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) { if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) {
tracing::warn!(error = ?e, "matrix: failed to persist the @hive: account password"); tracing::warn!(error = ?e, %localpart, "matrix: failed to persist the sender account password");
} else { } else {
let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600)); let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600));
} }
@ -948,26 +973,26 @@ pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Resul
// hive-c0re gets a chance to ask. An appservice login needs // hive-c0re gets a chance to ask. An appservice login needs
// no password, which is just as well since an account the // no password, which is just as well since an account the
// homeserver created has none. // homeserver created has none.
tracing::info!("matrix: the @hive: user already exists, logging in as the appservice"); tracing::info!(%localpart, "matrix: the sender account already exists, logging in as the appservice");
match appservice_login(client, as_token, HIVE_LOCALPART).await { match appservice_login(client, as_token, &localpart).await {
Ok(token) => token, Ok(token) => token,
Err(e) => { Err(e) => {
tracing::warn!(error = ?e, "matrix: appservice login for @hive: failed; falling back to the stored password"); tracing::warn!(error = ?e, %localpart, "matrix: appservice login for the sender account failed; falling back to the stored password");
let pw_path = password_path(HIVE_LOCALPART); let pw_path = password_path(&localpart);
let stored = std::fs::read_to_string(&pw_path) let stored = std::fs::read_to_string(&pw_path)
.ok() .ok()
.map(|s| s.trim().to_owned()) .map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.with_context(|| { .with_context(|| {
format!( format!(
"matrix: the @hive: user exists, appservice login failed, and no \ "matrix: @{localpart}: exists, appservice login failed, and no \
password is stored at {} check that the registration file's \ password is stored at {} check that the registration file's \
namespace covers @{HIVE_LOCALPART} and that the homeserver \ namespace covers @{localpart} and that the homeserver \
loaded it", loaded it",
pw_path.display() pw_path.display()
) )
})?; })?;
login_user(client, HIVE_LOCALPART, &stored).await? login_user(client, &localpart, &stored).await?
} }
} }
} }
@ -1002,8 +1027,10 @@ fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` — /// The 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 /// the same role string `workers::credential` logs in with, and already in
/// this process's environment, so the store read costs no plumbing through /// this process's environment, so the store read costs no plumbing through
/// [`ensure_all`]. No new grant either: a hive's policy already covers the /// [`ensure_all`]. That name is also the path's own hive segment, which is
/// whole `swarm/services/*` tree `swarm-matrix-ctl` writes into. /// 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/<name>/*` stanza, not
/// the swarm-wide `swarm/services/*` one the shared token used to sit under.
/// ///
/// `None`, never an error, for every way this can come up empty — no hive /// `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 /// name, no `BAO_*` identity, an unreachable store, nothing at the path. All
@ -1017,7 +1044,13 @@ async fn stored_sender_token() -> Option<String> {
let hive = std::env::var("HYPERHIVE_HIVE_NAME") let hive = std::env::var("HYPERHIVE_HIVE_NAME")
.ok() .ok()
.filter(|h| !h.is_empty())?; .filter(|h| !h.is_empty())?;
let path = swarm_secret_client::matrix::sender_token_path(); let path = match swarm_secret_client::matrix::sender_token_path(&hive) {
Ok(path) => path,
Err(e) => {
tracing::warn!(error = %e, "matrix: this hive's name does not form a store path");
return None;
}
};
let store = match swarm_secret_client::SecretStore::from_env(&hive).await { let store = match swarm_secret_client::SecretStore::from_env(&hive).await {
Ok(store) => store, Ok(store) => store,
Err(e) => { Err(e) => {
@ -1095,11 +1128,11 @@ mod is_make_admin_success_tests {
/// Promote a user to homeserver admin via the Matrix admin room /// Promote a user to homeserver admin via the Matrix admin room
/// (`#admins:<server>`). Sends `!admin users make-user-admin @<localpart>:<server>` as /// (`#admins:<server>`). Sends `!admin users make-user-admin @<localpart>:<server>` as
/// @hive, polls for the bot's success reply. /// `@hive-<hive>:`, polls for the bot's success reply.
/// ///
/// ⚠️ Requires the **sender** to be an admin already — tuwunel only /// ⚠️ Requires the **sender** to be an admin already — tuwunel only
/// treats a message as a command when its sender is in the admin room. /// treats a message as a command when its sender is in the admin room.
/// `@hive:` is an ordinary account (`hive-matrix.nix` grants it no /// `@hive-<hive>:` is an ordinary account (`hive-matrix.nix` grants it no
/// `admin_execute` promotion), so this call has no working sender from /// `admin_execute` promotion), so this call has no working sender from
/// the hive and fails with the admin room's refusal. Promotion is a /// 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, /// swarm-level operation and is being rehomed as such; this stays here,
@ -1139,13 +1172,13 @@ pub async fn promote_user_to_admin(
/// Reset a user's password via the Matrix admin room (`#admins:<server>`). /// Reset a user's password via the Matrix admin room (`#admins:<server>`).
/// ///
/// Sends `!admin users reset-password @<localpart>:<server>` to the admin room as @hive, /// Sends `!admin users reset-password @<localpart>:<server>` to the admin room as
/// polls for the bot's response containing the new password, and persists /// `@hive-<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 /// it to the non-purgeable creds path so [`ensure_user_for`] can re-login
/// on the next provisioning sweep. /// on the next provisioning sweep.
/// ///
/// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and /// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and
/// the same consequence: `@hive:` is an ordinary account with no admin /// the same consequence: `@hive-<hive>:` is an ordinary account with no admin
/// sender, and reset, like promotion, is a swarm-level operation rehomed /// sender, and reset, like promotion, is a swarm-level operation rehomed
/// to the swarm tier rather than granted here — so it has no working /// to the swarm tier rather than granted here — so it has no working
/// sender from the hive either. /// sender from the hive either.
@ -1219,7 +1252,7 @@ pub fn read_sender_token() -> Result<String> {
format!( format!(
"the matrix sender token was not found at {} — \ "the matrix sender token was not found at {} — \
ensure hive-c0re has started at least once with matrix enabled \ ensure hive-c0re has started at least once with matrix enabled \
(it provisions the @hive: account on boot)", (it provisions the hive's @hive-<hive>: account on boot)",
path.display() path.display()
) )
}) })
@ -1301,7 +1334,7 @@ async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Opt
/// Create (or recover) the hive Matrix Space and persist its room ID to /// 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_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-<hive>:`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias).
/// ///
/// Dedup strategy (single canonical space): /// Dedup strategy (single canonical space):
/// 1. If the room-id file exists, reuse it. /// 1. If the room-id file exists, reuse it.
@ -1877,7 +1910,7 @@ pub async fn ensure_all() -> bool {
return false; return false;
} }
}; };
// The `@hive:` account first, because everything below provisions // The hive's `@hive-<hive>:` account first, because everything below provisions
// THROUGH it (the Space, the chat room and every invite are sent with // 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 // its token) — as an ordinary user that created those rooms, not as a
// homeserver admin. // homeserver admin.
@ -1934,17 +1967,17 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
return false; return false;
} }
}; };
// Invite @hive first, then all agents. // This hive's own account first, then all agents.
if let Err(e) = invite_to_room( let localpart = match hive_localpart() {
client, Ok(l) => l,
&sender_token, Err(e) => {
&room_id, tracing::warn!(error = ?e, "matrix: skipping hive space provisioning");
HIVE_LOCALPART, return false;
&server_name, }
) };
.await 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; ok = false;
} }
for name in agent_names { for name in agent_names {
@ -1955,7 +1988,7 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
} }
// Provision the default hive chat room as an m.space.child of the Space // 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-<hive>:` + every agent. Joining a Space alone surfaces no
// rooms to chat in (Matrix semantics — children aren't auto-joined), so // rooms to chat in (Matrix semantics — children aren't auto-joined), so
// without this the Space is empty. The restricted join rule additionally // without this the Space is empty. The restricted join rule additionally
// lets the operator (a Space member) join from the Space hierarchy. // lets the operator (a Space member) join from the Space hierarchy.
@ -1965,12 +1998,12 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
client, client,
&sender_token, &sender_token,
&chat_room_id, &chat_room_id,
HIVE_LOCALPART, &localpart,
&server_name, &server_name,
) )
.await .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; ok = false;
} }
for name in agent_names { for name in agent_names {

View file

@ -784,7 +784,7 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
Ok(HostResponse::messages(vec![ Ok(HostResponse::messages(vec![
format!( format!(
"matrix: the @{}: user is provisioned", "matrix: the @{}: user is provisioned",
crate::matrix::HIVE_LOCALPART crate::matrix::hive_localpart()?
), ),
format!("token persisted at: {}", path.display()), format!("token persisted at: {}", path.display()),
])) ]))

View file

@ -73,10 +73,21 @@ let
# anything, so the account exists on the very first boot of a fresh # anything, so the account exists on the very first boot of a fresh
# homeserver without hive-c0re racing to register it. # homeserver without hive-c0re racing to register it.
# #
# ⚠️ Must equal `matrix::HIVE_LOCALPART` in hive-c0re, which derives # 🩸 **Derived from the hive name, so it is one account per hive.** It was
# it independently with nothing wiring an override across — same # the bare `hive` and a swarm runs one homeserver: every hive on it acted
# agreement, and same reason for saying so, as the token path below. # as the same `@hive:`, which is one identity with no attribution and no
hiveLocalpart = "hive"; # way to revoke a single hive. The hives that do not run this container
# register their own `hive-<name>` 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 # 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 # minted by the render script below, mode 0600; the `as_token` is the one
@ -168,7 +179,7 @@ let
# knows about itself. # knows about itself.
ctlHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}"; ctlHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}";
# Every local user this hive may provision — agents, `@hive:` itself, and # Every local user this hive may provision — agents, `@hive-<hive>:` itself, and
# the operator accounts `hivectl matrix create-user` makes, which is the # the operator accounts `hivectl matrix create-user` makes, which is the
# whole matrix localpart charset. # whole matrix localpart charset.
# #
@ -1439,6 +1450,12 @@ in
# never a value. # never a value.
MATRIX_MINT_REGISTRATION = appserviceRegistrationPath; MATRIX_MINT_REGISTRATION = appserviceRegistrationPath;
MATRIX_MINT_LOCALPART = hiveLocalpart; 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/<name>/*` 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; MATRIX_MINT_HOMESERVER = ctlHomeserverUrl;
} }
// lib.optionalAttrs (deployCfg.bao.serverCaFile != null) { // lib.optionalAttrs (deployCfg.bao.serverCaFile != null) {

View file

@ -249,9 +249,17 @@ let
# written by the caller — the same trap as the two grants above. # written by the caller — the same trap as the two grants above.
# #
# Not `swarm/services/*` like the publisher's: this principal produces # Not `swarm/services/*` like the publisher's: this principal produces
# exactly one secret, the appservice sender account's access token, and a # exactly one secret, its own hive's matrix sender account access token, and
# homeserver is not entitled to overwrite Grafana's OIDC client. The path is # a homeserver is not entitled to overwrite Grafana's OIDC client. The path
# spelled to the leaf for that reason, not for tidiness. # 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/<name>/` 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 # `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 # only once" mechanical: matrix-ctl's first act is to read this path back and
@ -260,11 +268,22 @@ let
# here recovers one secret this principal itself wrote, which is a much # here recovers one secret this principal itself wrote, which is a much
# narrower grant than the publisher's would have been. # narrower grant than the publisher's would have been.
matrixCtlPolicyText = '' matrixCtlPolicyText = ''
path "${credentialMountPath}/data/swarm/services/matrix/sender-token" { path "${credentialMountPath}/data/swarm/hives/${matrixCtlHive}/matrix/sender-token" {
capabilities = ["create", "update", "read"] 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 # The KV v2 engine the controller writes agent credentials through. Named
# once because the grant above and the `secrets enable` in the bootstrap unit # 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 # have to agree: a policy pointing at a mount nobody created is precisely the
@ -711,6 +730,31 @@ in
''; '';
}; };
matrixCtlHiveName = lib.mkOption {
type = lib.types.str;
default = toString hyperhiveCfg.hiveName;
defaultText = lib.literalExpression "services.hyperhive.hiveName";
example = "pr1ma";
description = ''
Hive whose matrix sender token the store's matrix-ctl role may write.
The sender account's access token is **per hive**: it lives at
`swarm/hives/<name>/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 { serverCaFile = lib.mkOption {
type = lib.types.nullOr lib.types.str; type = lib.types.nullOr lib.types.str;
default = null; default = null;

View file

@ -147,20 +147,26 @@ let
# the `services/` prefix the publisher holds would be a real loss even # the `services/` prefix the publisher holds would be a real loss even
# though it would read as tidier. # though it would read as tidier.
# #
# ⚠️ `services` is PLURAL, because the path segment comes from # ⚠️ `hives` is PLURAL, because the path segment comes from
# `Kind::Service`'s `#[strum(serialize = "services")]` and not from # `Kind::Hive`'s strum serialisation and not from `Kind::label`, which
# `Kind::label`, which renders the singular for error text. The singular # renders the singular for error text. The singular spelling evaluates,
# spelling evaluates, deploys, and 403s every read with "permission # deploys, and 403s every read with "permission denied" and nothing else.
# denied" and nothing else. #
name = "matrix-ctl's grant is the sender token's path 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 = ok =
let let
s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script; s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script;
in in
lib.hasInfix "path \"secret/data/swarm/services/matrix/sender-token\" {" s lib.hasInfix "path \"secret/data/swarm/hives/h1/matrix/sender-token\" {" s
&& !(lib.hasInfix "secret/data/swarm/services/*" s) && !(lib.hasInfix "secret/data/swarm/services" s)
&& !(lib.hasInfix "secret/data/swarm/agents" s) && !(lib.hasInfix "secret/data/swarm/agents" s)
&& !(lib.hasInfix "secret/data/swarm/hives" s) && !(lib.hasInfix "secret/data/swarm/hives/*" s)
&& !(lib.hasInfix "sys/policies/acl" s); && !(lib.hasInfix "sys/policies/acl" s);
} }
{ {

View file

@ -289,6 +289,26 @@ let
&& u.environment.MATRIX_MINT_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml" && u.environment.MATRIX_MINT_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml"
&& u.serviceConfig.Type == "oneshot"; && 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 # 🩸 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 # VERB. This is the one end of that contract nix owns: the binary's own

View file

@ -22,8 +22,8 @@ const PASSWORD_BYTES: usize = 32;
/// What the homeserver said about a registration attempt. /// 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 /// An enum rather than a string match on the error text: `M_USER_IN_USE` is the
/// *expected* answer here — the `@hive:` account is the appservice registration's /// *expected* answer here — the hive's `@hive-<hive>:` account is the appservice
/// own `sender_localpart`, so the homeserver creates it at startup, before /// 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 /// anything gets to ask — and an expected answer should not have to be
/// recovered from a formatted message. /// recovered from a formatted message.
pub enum Registered { pub enum Registered {
@ -262,8 +262,8 @@ mod tests {
#[test] #[test]
fn the_expected_already_exists_answer_is_recognised_by_its_errcode() { fn the_expected_already_exists_answer_is_recognised_by_its_errcode() {
// Matched on the spec's code rather than on message text, because this // Matched on the spec's code rather than on message text, because this
// is the arm a healthy homeserver takes every time: the `@hive:` account // is the arm a healthy homeserver takes every time: the hive's `@hive-<hive>:`
// is the appservice registration's own sender, created at startup. // 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" }); let json = serde_json::json!({ "errcode": "M_USER_IN_USE", "error": "User ID taken" });
assert_eq!(errcode(&json), Some("M_USER_IN_USE")); assert_eq!(errcode(&json), Some("M_USER_IN_USE"));
} }

View file

@ -2,10 +2,12 @@
//! homeserver access token to the swarm's secret store, once. //! homeserver access token to the swarm's secret store, once.
//! //!
//! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the //! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the
//! swarm controller. It is this account rather than an agent's because a //! swarm controller. It mints for **one hive** — the hive this container runs
//! homeserver has **one** appservice registration and so one sender account, //! on, named by [`ENV_HIVE`] — and publishes to that hive's own path, so a
//! and a swarm runs one homeserver: "only once" is a property of what is being //! swarm whose hives share a homeserver gets one account and one token per
//! minted, so there is nothing to lock and no trigger to serve. //! 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 //! The store, not the homeserver, is the idempotency key — see
//! [`already_published`]. On the hive side `hive-c0re`'s //! [`already_published`]. On the hive side `hive-c0re`'s
@ -31,9 +33,15 @@ const ENV_API_URL: &str = "MATRIX_MINT_API_URL";
/// The bind-mounted appservice registration, which is where the `as_token` /// The bind-mounted appservice registration, which is where the `as_token`
/// comes from. A path, never a value. /// comes from. A path, never a value.
const ENV_REGISTRATION: &str = "MATRIX_MINT_REGISTRATION"; const ENV_REGISTRATION: &str = "MATRIX_MINT_REGISTRATION";
/// Localpart of the appservice's sender account. The registration's own /// Localpart of this hive's sender account. The registration's own
/// `sender_localpart`, and `hive-c0re`'s `matrix::HIVE_LOCALPART`. /// `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"; 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 /// 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 /// 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. /// has no such URL, and `matrix::Credential` types the field to say so.
@ -50,6 +58,7 @@ struct Config {
api_url: String, api_url: String,
registration: String, registration: String,
localpart: String, localpart: String,
hive: String,
homeserver: Option<String>, homeserver: Option<String>,
} }
@ -77,6 +86,7 @@ impl Config {
api_url: required(ENV_API_URL)?, api_url: required(ENV_API_URL)?,
registration: required(ENV_REGISTRATION)?, registration: required(ENV_REGISTRATION)?,
localpart: required(ENV_LOCALPART)?, localpart: required(ENV_LOCALPART)?,
hive: required(ENV_HIVE)?,
// Empty is absent: systemd renders an unset nix option as // Empty is absent: systemd renders an unset nix option as
// `Environment=VAR=`, so that is the shape this arrives in. // `Environment=VAR=`, so that is the shape this arrives in.
homeserver: get(ENV_HOMESERVER).filter(|v| !v.is_empty()), homeserver: get(ENV_HOMESERVER).filter(|v| !v.is_empty()),
@ -128,7 +138,8 @@ pub async fn run() -> Result<()> {
) )
})?; })?;
let path = matrix::sender_token_path(); let path = matrix::sender_token_path(&config.hive)
.with_context(|| format!("building the store path for hive {}", config.hive))?;
if already_published(&store, &path).await { if already_published(&store, &path).await {
tracing::info!(%path, "the sender token is already published; not minting"); tracing::info!(%path, "the sender token is already published; not minting");
return Ok(()); return Ok(());
@ -175,7 +186,8 @@ mod tests {
ENV_REGISTRATION => { ENV_REGISTRATION => {
Some("/var/lib/hyperhive/matrix-appservice/hyperhive.yaml".to_owned()) Some("/var/lib/hyperhive/matrix-appservice/hyperhive.yaml".to_owned())
} }
ENV_LOCALPART => Some("hive".to_owned()), ENV_LOCALPART => Some("hive-pr1ma".to_owned()),
ENV_HIVE => Some("pr1ma".to_owned()),
_ => None, _ => None,
} }
} }
@ -185,13 +197,20 @@ mod tests {
// The control: without it every assertion below could be passing // The control: without it every assertion below could be passing
// because `from_lookup` rejects everything. // because `from_lookup` rejects everything.
let c = Config::from_lookup(full).expect("every required variable is set"); let c = Config::from_lookup(full).expect("every required variable is set");
assert_eq!(c.localpart, "hive"); assert_eq!(c.localpart, "hive-pr1ma");
assert_eq!(c.hive, "pr1ma");
assert_eq!(c.homeserver, None, "an absent public URL is not an error"); assert_eq!(c.homeserver, None, "an absent public URL is not an error");
} }
#[test] #[test]
fn each_required_variable_is_named_when_it_is_the_missing_one() { fn each_required_variable_is_named_when_it_is_the_missing_one() {
for var in [ENV_CERT_ROLE, ENV_API_URL, ENV_REGISTRATION, ENV_LOCALPART] { for var in [
ENV_CERT_ROLE,
ENV_API_URL,
ENV_REGISTRATION,
ENV_LOCALPART,
ENV_HIVE,
] {
let e = Config::from_lookup(|k| if k == var { None } else { full(k) }) let e = Config::from_lookup(|k| if k == var { None } else { full(k) })
.expect_err("one required variable is absent"); .expect_err("one required variable is absent");
assert!( assert!(
@ -237,6 +256,7 @@ mod tests {
ENV_API_URL, ENV_API_URL,
ENV_REGISTRATION, ENV_REGISTRATION,
ENV_LOCALPART, ENV_LOCALPART,
ENV_HIVE,
ENV_HOMESERVER, ENV_HOMESERVER,
] { ] {
assert!( assert!(
@ -253,8 +273,29 @@ mod tests {
// and names the literal so a move of the path is a deliberate edit on // 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. // both sides rather than a silent 404 on the reading one.
assert_eq!( assert_eq!(
matrix::sender_token_path(), matrix::sender_token_path("pr1ma").expect("a plain name is legal"),
"swarm/services/matrix/sender-token" "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));
}
} }

View file

@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
use crate::{ use crate::{
Error, Error,
path::{Kind, ROOT, checked_segment, principal_prefix}, path::{Kind, checked_segment, principal_prefix},
}; };
/// The path holding `agent`'s token for the external matrix account `account`. /// The path holding `agent`'s token for the external matrix account `account`.
@ -26,32 +26,50 @@ pub fn account_path(agent: &str, account: &str) -> Result<String, Error> {
Ok(format!("{prefix}/matrix/{account}")) Ok(format!("{prefix}/matrix/{account}"))
} }
/// The swarm service the homeserver is, as the name segment under /// The localpart `hive` acts as on the homeserver, and the `sender_localpart`
/// [`Kind::Service`]. /// of that hive's appservice registration.
/// ///
/// A swarm runs one homeserver, so this is a constant rather than a parameter — /// **Derived from the hive's name, which is what makes it one account per
/// and that is the whole of what makes the credential below mintable /// hive.** It used to be the bare constant `hive`: one swarm runs one
/// "only once" without a lock. /// homeserver, so every hive on it logged in as the same `@hive:` and the
pub const HOMESERVER_SERVICE: &str = "matrix"; /// homeserver could not tell them apart — no attribution, and no way to revoke
/// one hive without revoking all of them.
/// The path holding the matrix appservice sender account's homeserver
/// access token.
/// ///
/// Keyed per **homeserver**, not per hive and not per agent: a homeserver has one /// The prefix is kept so the account still reads as a hive's rather than an
/// appservice registration and so one sender account (`@hive:<server_name>`, /// agent's; `nix/host-modules/hive-matrix.nix` renders the same string into
/// hive-c0re's `matrix::HIVE_LOCALPART`), so a /// the registration's `sender_localpart`, and **the two must match** — nothing
/// per-hive copy would be several names for one secret. That is also why this /// wires an override across.
/// takes no argument and cannot fail — there is no caller-supplied segment in
/// it to reject.
/// ///
/// Reachable by every hive without a new grant: [`crate::policy::render`] /// Infallible on purpose: a hive name is `[a-z0-9-]` (`hive_types::Ident`) and
/// already grants a hive read on the whole [`Kind::Service`] tree. /// 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] #[must_use]
pub fn sender_token_path() -> String { pub fn hive_localpart(hive: &str) -> String {
format!( format!("hive-{hive}")
"{ROOT}/{}/{HOMESERVER_SERVICE}/sender-token", }
<&str>::from(Kind::Service)
) /// 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/<this hive>/*`, 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<String, Error> {
let prefix = principal_prefix(Kind::Hive, hive)?;
Ok(format!("{prefix}/matrix/sender-token"))
} }
/// The path holding `hive`'s matrix appservice token (`as_token`). /// The path holding `hive`'s matrix appservice token (`as_token`).
@ -115,25 +133,59 @@ mod tests {
} }
#[test] #[test]
fn the_hive_credential_lands_under_the_service_prefix_the_grant_covers() { fn the_sender_token_lands_under_the_hive_prefix_the_grant_covers() {
// Spelled out rather than rebuilt from the same pieces the code uses, // 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 // for the reason above — and with a second job here: the hive's own
// policy grants read on `secret/data/swarm/services/*`, so this exact // policy grants read on `secret/data/swarm/hives/<this hive>/*`, so
// string is what makes the path reachable at all. The `services` // this exact string is what makes the path reachable at all.
// segment is PLURAL; `Kind::label` renders the singular and is for assert_eq!(
// error text only, so reading it as the path segment produces a sender_token_path("pr1ma").expect("a plain name is legal"),
// 403 the store explains as "permission denied" and nothing else. "swarm/hives/pr1ma/matrix/sender-token"
assert_eq!(sender_token_path(), "swarm/services/matrix/sender-token"); );
} }
#[test] #[test]
fn the_hive_credential_sits_where_a_service_principal_would() { fn the_sender_token_left_the_tree_every_hive_can_read() {
// The constant path above must be the same one the general builder // 🩸 The whole point of the move. `policy::render` grants every hive
// produces, or the grant covering `Kind::Service` would cover a // read on `secret/data/swarm/services/*` — a deliberate grant that
// neighbouring tree instead of this one. // stays, because a service's OIDC secret is read with the certificate
let prefix = // of whatever hive hosts it. What must not stay is this credential
principal_prefix(Kind::Service, HOMESERVER_SERVICE).expect("a plain name is legal"); // sitting inside it: under `services/` one shared token was readable
assert_eq!(sender_token_path(), format!("{prefix}/sender-token")); // 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/<name>/*` 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] #[test]

View file

@ -24,10 +24,10 @@
//! The **hive** stanza has no such problem and is therefore narrow: that path //! 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 //! 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 //! 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 //! Rendering stays separate from writing so the text can be asserted with no store to talk to.
//! store to talk to.
use crate::{ use crate::{
Error, 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<String> {
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] #[test]
fn the_grant_is_read_only() { fn the_grant_is_read_only() {
// A hive reads credentials; a hive that could write one could hand // A hive reads credentials; a hive that could write one could hand