From 14305255f0b0465a1b8362faa7b6cbeaa2b3ddb9 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 12 Sep 2026 19:46:04 +0200 Subject: [PATCH] swarm: put the matrix registration token where the reader is granted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `swarm-bao-matrix-token` reads `secret/swarm/matrix/registration-token` and is refused with `Code: 403 — permission denied`, measured on this host at 16:17:49Z after a successful cert login. path.rs makes every swarm path `swarm///…` where Kind is a closed set of four: agents, hives, services, controller. `matrix` sits where a kind belongs, so policy.rs's read document — which emits exactly `swarm/agents/*` and `swarm/hives//*` — cannot cover it. The module's own doc predicted this: "a misspelled kind is a 403 at provision time rather than anything a compiler sees". Moves the token to `swarm/hives//matrix/registration-token`, built through principal_prefix(Kind::Hive, …) like its per-hive sibling queue::agent_client_path. The policy is untouched: render already grants that prefix. mara chose this over widening the namespace. The nix reader interpolates hyperhiveCfg.hiveName, with the no-fallback reasoning glue-bao-tls.nix already gives at its own use of it. path.rs's MOUNT doc justified itself by citing the old literal, which this commit deletes; rewritten to cite the nix reader instead. Scope: this makes the read reachable, not the value present. Nothing writes that path yet, and a 403 says nothing about presence — the two are separate findings and only the first is fixed here. No migration: nothing ever wrote the old path and no read ever succeeded. Gate: cargo fmt 0, cargo test -p swarm-secret-client 0 — 32 passed against a 29-passed baseline with the change stashed, so the three new tests are accounted for rather than assumed. Refs #4308 --- nix/host-modules/glue-matrix-bao-token.nix | 12 ++++++- nix/module-eval.nix | 16 +++++++++ swarm-secret-client/src/matrix.rs | 40 ++++++++++++++++++++++ swarm-secret-client/src/path.rs | 6 ++-- 4 files changed, 70 insertions(+), 4 deletions(-) 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.