diff --git a/nix/host-modules/glue-matrix-bao-token.nix b/nix/host-modules/glue-matrix-bao-token.nix index 4e84d8da..4f2fe486 100644 --- a/nix/host-modules/glue-matrix-bao-token.nix +++ b/nix/host-modules/glue-matrix-bao-token.nix @@ -42,7 +42,17 @@ let # Where the token lives in the store. A path, not a convention to guess at: # whoever writes it and whoever reads it must agree, and the agreement # belongs in one visible place. - tokenPath = "secret/swarm/matrix/registration-token"; + # + # ⚠️ The `hives/` segment is not decoration — it is what the reader's + # own grant covers. The store's read policy grants `swarm/agents/*` and + # `swarm/hives//*` and nothing else, so a path outside those is a + # 403 rather than a miss, however correct it looks. `swarm-secret-client`'s + # `matrix::registration_token_path` builds the same string from the same + # pieces; this literal is the nix half of that one agreement. + # + # `hiveName` has no fallback here for the reason ./glue-bao-tls.nix gives at + # its own use of it: it is asserted set for every hyperhive host. + tokenPath = "secret/swarm/hives/${hyperhiveCfg.hiveName}/matrix/registration-token"; # A literal, not an option — ./hive-matrix.nix names its container # `containers.hive-matrix` directly and declares no `machine` to derive it diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 5b948882..bc451438 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -1148,6 +1148,22 @@ let && lib.hasInfix "exit 1" loginBranch && lib.hasInfix "exit 0" (lib.last (lib.splitString "bao kv get" u.script)); } + { + # The reader's own grant covers `swarm/hives//*` and + # `swarm/agents/*`; a path outside those answers 403, not "no such key". + # So the hive segment is what makes the read reachable, and a rename that + # drops it looks correct and fails identically on every boot. + name = "the matrix token path sits inside the prefix the reader is granted"; + ok = + let + s = baoWithMatrix.systemd.services.swarm-bao-matrix-token.script; + in + lib.hasInfix "secret/swarm/hives/" s + && lib.hasInfix "/matrix/registration-token" s + # The shape it used to have: `matrix` where a principal kind belongs, + # which no grant covers. + && !(lib.hasInfix "secret/swarm/matrix/" s); + } { # The doctrine three glue files state, as a property a rewrite has to # keep: a client is defined by holding a certificate the store accepts, diff --git a/swarm-secret-client/src/matrix.rs b/swarm-secret-client/src/matrix.rs index 0a640b9b..69a130c7 100644 --- a/swarm-secret-client/src/matrix.rs +++ b/swarm-secret-client/src/matrix.rs @@ -26,6 +26,20 @@ pub fn account_path(agent: &str, account: &str) -> Result { Ok(format!("{prefix}/matrix/{account}")) } +/// The path holding `hive`'s matrix registration 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 token that creates them is the hive's. +/// +/// # 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 registration_token_path(hive: &str) -> Result { + let prefix = principal_prefix(Kind::Hive, hive)?; + Ok(format!("{prefix}/matrix/registration-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 @@ -65,6 +79,32 @@ mod tests { assert_eq!(p, "swarm/agents/atlas/matrix/ops-relay"); } + #[test] + fn the_registration_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!( + registration_token_path("pr1ma").expect("a plain name is legal"), + "swarm/hives/pr1ma/matrix/registration-token" + ); + } + + #[test] + fn the_registration_token_is_not_a_top_level_namespace() { + // The path it 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 = registration_token_path("pr1ma").expect("legal"); + assert!(!p.starts_with("swarm/matrix/"), "{p}"); + } + + #[test] + fn a_traversal_in_the_hive_name_is_refused() { + let e = registration_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, diff --git a/swarm-secret-client/src/path.rs b/swarm-secret-client/src/path.rs index c4f69ed5..a869fdd7 100644 --- a/swarm-secret-client/src/path.rs +++ b/swarm-secret-client/src/path.rs @@ -10,9 +10,9 @@ use crate::Error; /// The KV v2 mount every swarm secret lives under. /// -/// A literal because the store's existing reader already hardcodes the same -/// one (`secret/swarm/matrix/registration-token`); an option nobody sets would -/// be two ways to say one thing. +/// A literal because the nix-side reader spells the same one in +/// `glue-matrix-bao-token.nix`; an option nobody sets would be two ways to say +/// one thing. pub const MOUNT: &str = "secret"; /// The root under [`MOUNT`] that every swarm secret lives beneath.