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

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

View file

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