//! 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 { 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//*`, 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 { 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 { 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//*` 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 { 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, } #[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//*`, 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//*` 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//*`, 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/*" { … }`. 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"}"#); } }