Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-secret-client/src/matrix.rs
atlas 1261b525d6 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.
2026-09-20 22:07:16 +02:00

286 lines
13 KiB
Rust

//! The matrix agreement: where an account's credential lives in the store, and
//! what the object at that path holds.
//!
//! Both halves are one agreement and neither end of it is senior, so they are
//! stated together here rather than split between the path module and the
//! client. Nothing in [`crate::client`] knows this shape — it moves whatever
//! type a caller names — so a second kind of swarm secret gets its own module
//! beside this one instead of another field on a shared struct.
use serde::{Deserialize, Serialize};
use crate::{
Error,
path::{Kind, checked_segment, principal_prefix},
};
/// The path holding `agent`'s token for the external matrix account `account`.
///
/// # Errors
/// [`Error::PathSegment`] when either name contains anything but
/// `[A-Za-z0-9_-]`, which is what keeps one agent's name from addressing
/// another agent's secret.
pub fn account_path(agent: &str, account: &str) -> Result<String, Error> {
let prefix = principal_prefix(Kind::Agent, agent)?;
checked_segment("account", account)?;
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/<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`).
///
/// Keyed per **hive**, not per agent, like [`crate::queue::agent_client_path`]
/// and unlike [`account_path`] above: one homeserver admits one hive's
/// accounts, so the identity that creates them is the hive's.
///
/// ⚠️ Renamed from `registration-token` along with what it holds: the
/// homeserver no longer accepts a shared registration secret at all, so a
/// value still stored under the old path would be read by nothing. A hive
/// whose store has only the old path falls back to its locally minted
/// token — see `glue-matrix-bao-token.nix` — so the rename degrades rather
/// than breaks, but the store needs a fresh `put` to take effect again.
///
/// # 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 appservice_token_path(hive: &str) -> Result<String, Error> {
let prefix = principal_prefix(Kind::Hive, hive)?;
Ok(format!("{prefix}/matrix/appservice-token"))
}
/// What an account's path holds: the token, plus the homeserver it belongs to.
///
/// The homeserver rides with the token rather than on the queue notice that
/// triggers a delivery, because a notice is not persistence — re-delivering a
/// credential has to reconstruct it, and the store is the only thing that keeps
/// it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Credential {
/// `glue-matrix-bao-token.nix` reads the store with
/// `bao kv get -field=value`, so this name is load-bearing for a reader
/// this crate does not control. Renaming it silently breaks that unit.
pub value: String,
/// Absent on every object written before this field existed, and KV2 keeps
/// those versions forever. **`Option` is what tolerates that** — serde
/// decodes a missing field to `None` for an optional type, so the type is
/// the compatibility guarantee and changing it to a bare `String` is what
/// would break every stored credential at once.
///
/// `skip_serializing_if` is doing separate work: without it a token-only
/// credential serialises `"homeserver":null`, and this object is read by
/// `glue-matrix-bao-token.nix` with `bao kv get -field=value`.
#[serde(skip_serializing_if = "Option::is_none")]
pub homeserver: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_well_formed_pair_lands_under_the_agent_prefix() {
let p = account_path("atlas", "ops-relay").expect("both segments are legal");
// Spelled out rather than rebuilt from the same pieces the code uses:
// a test that composes `ROOT` and `Kind::Agent` would keep passing
// through a rename that moves every stored credential.
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/<this hive>/*`, 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/<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]
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:
// the read policy grants `secret/data/swarm/hives/<hive>/*`, so this
// string is what makes the path reachable at all.
assert_eq!(
appservice_token_path("pr1ma").expect("a plain name is legal"),
"swarm/hives/pr1ma/matrix/appservice-token"
);
}
#[test]
fn the_appservice_token_is_not_a_top_level_namespace() {
// The path its predecessor used to hold. `Kind` is a closed set and
// `matrix` is not one of its members, so a path with `matrix` as the
// second segment is outside every grant — which is how it came to 403
// on every read.
let p = appservice_token_path("pr1ma").expect("legal");
assert!(!p.starts_with("swarm/matrix/"), "{p}");
}
#[test]
fn a_traversal_in_the_hive_name_is_refused() {
let e = appservice_token_path("../beta").expect_err("a traversal is not");
assert!(matches!(e, Error::PathSegment { kind: "hive", .. }), "{e}");
}
#[test]
fn a_segment_cannot_escape_its_own_directory() {
// Each of these is a *different* way to address another agent's tree,
// and the last two are the ones a charset check catches but a
// `contains("..")` check does not.
for bad in [
"../argus",
"atlas/../argus",
"atlas/matrix",
"a b",
"a.b",
"",
] {
assert!(
account_path(bad, "ops-relay").is_err(),
"agent segment {bad:?} must be refused"
);
assert!(
account_path("atlas", bad).is_err(),
"account segment {bad:?} must be refused"
);
}
}
#[test]
fn the_legal_charset_is_actually_reachable() {
// The control for the test above: if `checked_segment` rejected
// everything, the escape cases would pass for the wrong reason.
assert!(account_path("a-b_C9", "d-e_F0").is_ok());
}
/// KV2 keeps every prior version, so objects written before `homeserver`
/// existed are still readable and still get decoded by this type. What
/// tolerates the absence is the field being `Option`, not any attribute:
/// making it a bare `String` would not surface as a migration, it would
/// surface as every previously-stored credential becoming unreadable at
/// once.
#[test]
fn a_credential_stored_before_the_homeserver_field_still_decodes() {
let old: Credential = serde_json::from_str(r#"{"value":"t"}"#)
.expect("an object written by the previous version must still decode");
assert_eq!(old.value, "t");
assert_eq!(old.homeserver, None);
// Control: the field is genuinely read when present, so the arm above
// is about absence being tolerated rather than the field being ignored.
let new: Credential = serde_json::from_str(r#"{"value":"t","homeserver":"https://hs"}"#)
.expect("an object with the field decodes too");
assert_eq!(new.homeserver.as_deref(), Some("https://hs"));
}
#[test]
fn the_value_field_matches_what_the_nix_reader_asks_for() {
// The literal is the point: `bao kv get -field=value` is the other end
// of this agreement and lives in a file no Rust test can reach, so the
// name is pinned here rather than derived from the struct.
//
// It doubles as the compatibility control for `homeserver`: a
// token-only credential must still serialise to exactly these bytes,
// with no `homeserver` key at all, so adding the field cannot change
// what that unit reads.
let json = serde_json::to_string(&Credential {
value: "t".to_owned(),
homeserver: None,
})
.expect("a struct of one String serialises");
assert_eq!(json, r#"{"value":"t"}"#);
}
}