Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-secret-client/src/matrix.rs
atlas 14305255f0 swarm: put the matrix registration token where the reader is granted
`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/<kind>/<name>/…` 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/<hive>/*` — 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/<hive>/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
2026-09-12 19:46:04 +02:00

176 lines
7.5 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 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<String, Error> {
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
/// 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_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/<hive>/*`, 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,
// 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"}"#);
}
}