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:
parent
637b308d4d
commit
1261b525d6
14 changed files with 461 additions and 133 deletions
|
|
@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::{
|
||||
Error,
|
||||
path::{Kind, ROOT, checked_segment, principal_prefix},
|
||||
path::{Kind, checked_segment, principal_prefix},
|
||||
};
|
||||
|
||||
/// The path holding `agent`'s token for the external matrix account `account`.
|
||||
|
|
@ -26,32 +26,50 @@ pub fn account_path(agent: &str, account: &str) -> Result<String, Error> {
|
|||
Ok(format!("{prefix}/matrix/{account}"))
|
||||
}
|
||||
|
||||
/// The swarm service the homeserver is, as the name segment under
|
||||
/// [`Kind::Service`].
|
||||
/// The localpart `hive` acts as on the homeserver, and the `sender_localpart`
|
||||
/// of that hive's appservice registration.
|
||||
///
|
||||
/// A swarm runs one homeserver, so this is a constant rather than a parameter —
|
||||
/// and that is the whole of what makes the credential below mintable
|
||||
/// "only once" without a lock.
|
||||
pub const HOMESERVER_SERVICE: &str = "matrix";
|
||||
|
||||
/// The path holding the matrix appservice sender account's homeserver
|
||||
/// access token.
|
||||
/// **Derived from the hive's name, which is what makes it one account per
|
||||
/// hive.** It used to be the bare constant `hive`: one swarm runs one
|
||||
/// homeserver, so every hive on it logged in as the same `@hive:` and the
|
||||
/// homeserver could not tell them apart — no attribution, and no way to revoke
|
||||
/// one hive without revoking all of them.
|
||||
///
|
||||
/// Keyed per **homeserver**, not per hive and not per agent: a homeserver has one
|
||||
/// appservice registration and so one sender account (`@hive:<server_name>`,
|
||||
/// hive-c0re's `matrix::HIVE_LOCALPART`), so a
|
||||
/// per-hive copy would be several names for one secret. That is also why this
|
||||
/// takes no argument and cannot fail — there is no caller-supplied segment in
|
||||
/// it to reject.
|
||||
/// The prefix is kept so the account still reads as a hive's rather than an
|
||||
/// agent's; `nix/host-modules/hive-matrix.nix` renders the same string into
|
||||
/// the registration's `sender_localpart`, and **the two must match** — nothing
|
||||
/// wires an override across.
|
||||
///
|
||||
/// Reachable by every hive without a new grant: [`crate::policy::render`]
|
||||
/// already grants a hive read on the whole [`Kind::Service`] tree.
|
||||
/// Infallible on purpose: a hive name is `[a-z0-9-]` (`hive_types::Ident`) and
|
||||
/// the appservice namespace regex admits that charset, so there is no
|
||||
/// rejection arm to write. [`sender_token_path`] below does the checking, and
|
||||
/// it is the one that interpolates into a store path.
|
||||
#[must_use]
|
||||
pub fn sender_token_path() -> String {
|
||||
format!(
|
||||
"{ROOT}/{}/{HOMESERVER_SERVICE}/sender-token",
|
||||
<&str>::from(Kind::Service)
|
||||
)
|
||||
pub fn hive_localpart(hive: &str) -> String {
|
||||
format!("hive-{hive}")
|
||||
}
|
||||
|
||||
/// The path holding `hive`'s matrix sender account's homeserver access token.
|
||||
///
|
||||
/// Keyed per **hive**, like [`appservice_token_path`] below and unlike the
|
||||
/// per-homeserver constant this replaced. The old path
|
||||
/// (`swarm/services/matrix/sender-token`) held **one value for the whole
|
||||
/// swarm** and sat under the [`Kind::Service`] tree that
|
||||
/// [`crate::policy::render`] grants *every* hive read on — so every hive could
|
||||
/// read, and act as, the one shared account.
|
||||
///
|
||||
/// Under [`Kind::Hive`] the grant that reaches it is the hive's **own**
|
||||
/// stanza, `secret/data/swarm/hives/<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`).
|
||||
|
|
@ -115,25 +133,59 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn the_hive_credential_lands_under_the_service_prefix_the_grant_covers() {
|
||||
fn the_sender_token_lands_under_the_hive_prefix_the_grant_covers() {
|
||||
// Spelled out rather than rebuilt from the same pieces the code uses,
|
||||
// for the reason above — and with a second job here: the hive's own
|
||||
// policy grants read on `secret/data/swarm/services/*`, so this exact
|
||||
// string is what makes the path reachable at all. The `services`
|
||||
// segment is PLURAL; `Kind::label` renders the singular and is for
|
||||
// error text only, so reading it as the path segment produces a
|
||||
// 403 the store explains as "permission denied" and nothing else.
|
||||
assert_eq!(sender_token_path(), "swarm/services/matrix/sender-token");
|
||||
// policy grants read on `secret/data/swarm/hives/<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_hive_credential_sits_where_a_service_principal_would() {
|
||||
// The constant path above must be the same one the general builder
|
||||
// produces, or the grant covering `Kind::Service` would cover a
|
||||
// neighbouring tree instead of this one.
|
||||
let prefix =
|
||||
principal_prefix(Kind::Service, HOMESERVER_SERVICE).expect("a plain name is legal");
|
||||
assert_eq!(sender_token_path(), format!("{prefix}/sender-token"));
|
||||
fn the_sender_token_left_the_tree_every_hive_can_read() {
|
||||
// 🩸 The whole point of the move. `policy::render` grants every hive
|
||||
// read on `secret/data/swarm/services/*` — a deliberate grant that
|
||||
// stays, because a service's OIDC secret is read with the certificate
|
||||
// of whatever hive hosts it. What must not stay is this credential
|
||||
// sitting inside it: under `services/` one shared token was readable
|
||||
// by every hive, which is one matrix identity for the whole swarm.
|
||||
let p = sender_token_path("pr1ma").expect("legal");
|
||||
assert!(!p.starts_with("swarm/services/"), "{p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_hives_sender_token_is_not_another_hives() {
|
||||
// The property the per-hive path exists for: two hives never name the
|
||||
// same object, so the `hives/<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]
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@
|
|||
//! The **hive** stanza has no such problem and is therefore narrow: that path
|
||||
//! names its principal, so scoping it to the reader's own name costs nothing
|
||||
//! and drifts nowhere. Do not widen it to match its neighbours — the asymmetry
|
||||
//! is the point.
|
||||
//! is the point, and only holds if a one-per-hive credential is stored under
|
||||
//! [`Kind::Hive`], not [`Kind::Service`] — see docs/trust-boundary/security.md.
|
||||
//!
|
||||
//! Rendering stays separate from writing so the text can be asserted with no
|
||||
//! store to talk to.
|
||||
//! Rendering stays separate from writing so the text can be asserted with no store to talk to.
|
||||
|
||||
use crate::{
|
||||
Error,
|
||||
|
|
@ -251,6 +251,60 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// 🩸 The per-hive matrix sender-token scoping, asserted on the rendered
|
||||
/// document rather than on the path function alone: it is the policy text
|
||||
/// that decides what a hive may fetch, so "the path is per-hive" only
|
||||
/// means something if the stanza that reaches it is too.
|
||||
///
|
||||
/// The `services/*` stanza above is deliberate and stays — it is how a
|
||||
/// host reads the OIDC secret of a service it runs. The property here is
|
||||
/// that the matrix sender token is no longer *inside* it.
|
||||
#[test]
|
||||
fn a_hive_reaches_its_own_matrix_sender_token_and_no_other_hives() {
|
||||
let alpha = render("alpha").expect("legal");
|
||||
let own = crate::matrix::sender_token_path("alpha").expect("legal");
|
||||
let other = crate::matrix::sender_token_path("beta").expect("legal");
|
||||
|
||||
// Reachable: the hive's own stanza is the prefix of its own path.
|
||||
assert!(
|
||||
alpha.contains("path \"secret/data/swarm/hives/alpha/*\""),
|
||||
"{alpha}"
|
||||
);
|
||||
assert!(own.starts_with("swarm/hives/alpha/"), "{own}");
|
||||
|
||||
// Unreachable: no stanza in alpha's document is a prefix of beta's
|
||||
// path. Checked by walking the stanzas rather than by asserting the
|
||||
// absence of the string "beta", so a future stanza that happened to
|
||||
// cover it — `swarm/hives/*`, say — would fail this too.
|
||||
assert!(
|
||||
!stanza_paths(&alpha)
|
||||
.iter()
|
||||
.any(|granted| covers(granted, &other)),
|
||||
"alpha's document reaches {other}:\n{alpha}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every `path "…"` a rendered document grants, with the `secret/data/`
|
||||
/// ACL prefix stripped so it compares against a store path.
|
||||
fn stanza_paths(document: &str) -> Vec<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]
|
||||
fn the_grant_is_read_only() {
|
||||
// A hive reads credentials; a hive that could write one could hand
|
||||
|
|
|
|||
Loading…
Reference in a new issue