The swarm gets an appservice identity of its own, separate from each hive's `hyperhive` registration. `swarm-matrix-ctl appservice render` mints its tokens inside the matrix container when they are absent and renders the registration tuwunel loads; `appservice publish` writes its as_token to `swarm/controller/swarm-controller/matrix/appservice-token`, the one kind no hive's policy grants. The homeserver calls move out of swarm-matrix-ctl into swarm-matrix-client, with a `whoami`, so swarm-controller can mint agents' accounts through the same pinned device id instead of a copy of them.
341 lines
15 KiB
Rust
341 lines
15 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"))
|
|
}
|
|
|
|
/// The name segment of the controller's own subtree, and the cert-auth role it
|
|
/// logs in under (`swarm-controller`'s `store::CERT_ROLE`).
|
|
const CONTROLLER: &str = "swarm-controller";
|
|
|
|
/// The path holding the **swarm's** appservice token: the one registration
|
|
/// on the swarm's homeserver that is not a hive's, whose sender is promoted
|
|
/// to homeserver admin at boot.
|
|
///
|
|
/// The matrix container mints it and publishes it here; `swarm-controller`
|
|
/// reads it to create agents' accounts. Under [`Kind::Controller`] because
|
|
/// that is the one kind [`crate::policy::render`] grants no hive: every hive
|
|
/// reads `agents/*`, its own `hives/<hive>/*` and `services/*`, so under any
|
|
/// of those this admin credential would be readable by every hive.
|
|
///
|
|
/// # Errors
|
|
/// Never in practice: the name segment is a constant. The `Result` is
|
|
/// [`principal_prefix`]'s.
|
|
pub fn swarm_appservice_token_path() -> Result<String, Error> {
|
|
let prefix = principal_prefix(Kind::Controller, CONTROLLER)?;
|
|
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 the_swarm_appservice_token_lands_under_the_controller() {
|
|
assert_eq!(
|
|
swarm_appservice_token_path().expect("a constant segment"),
|
|
"swarm/controller/swarm-controller/matrix/appservice-token"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_hive_policy_reaches_the_swarm_appservice_token() {
|
|
// 🩸 The swarm's sender is a homeserver admin, so its token must not
|
|
// be under any stanza a hive's policy renders. Checked against the
|
|
// rendered document rather than against the prefix, because the
|
|
// document is what the store enforces.
|
|
let doc = crate::policy::render("pr1ma").expect("a plain name is legal");
|
|
// Every stanza is `path "secret/data/<prefix>*" { … }`.
|
|
let granted: Vec<&str> = doc
|
|
.lines()
|
|
.filter_map(|l| l.strip_prefix("path \"secret/data/"))
|
|
.filter_map(|p| p.strip_suffix("*\" {"))
|
|
.collect();
|
|
let reads = |path: &str| granted.iter().any(|g| path.starts_with(g));
|
|
let path = swarm_appservice_token_path().expect("a constant segment");
|
|
assert!(!reads(&path), "{path} is readable under {granted:?}");
|
|
// The control: an agent's account path IS under a hive stanza, so the
|
|
// assertion above can fail at all.
|
|
let agent = account_path("atlas", "main").expect("legal");
|
|
assert!(
|
|
reads(&agent),
|
|
"{agent} should be readable under {granted:?}"
|
|
);
|
|
}
|
|
|
|
#[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"}"#);
|
|
}
|
|
}
|