swarm-secret-client: the agreements both ends of the store must share
mara ruled (a) on #3726: a thin workspace crate over `vaultrs` rather than keeping bao access in nix and having each end trigger units. The HTTP is the SDK's job; what this crate owns is the things the controller and a hive must say *identically*, and which have no other home because neither end is senior to the other. Three such agreements: `path::matrix_account` builds where a credential lives. It is fallible rather than a `format!`, because both names reach it from elsewhere -- the agent name from the topology, the account name from an agent's own config -- and a `/` or `..` in either does not produce a malformed path, it produces a valid path to a *different agent's* secret. The charset mirrors the KV bucket-name rule. `Credential`'s `value` field is not a free choice: glue-matrix-bao-token.nix reads the store with `bao kv get -field=value`, so the name is load-bearing for a consumer no Rust test can reach. A test pins the serialised shape. `client::Settings` reads BAO_ADDR / BAO_CLIENT_CERT / BAO_CLIENT_KEY / BAO_CACERT explicitly instead of letting vaultrs fall through to its own defaults, which look for VAULT_ADDR / VAULT_CLIENT_CERT / VAULT_CLIENT_KEY. Every unit in this tree sets the BAO_ spellings, so the defaults would yield a client with no identity at all -- surfacing as a TLS handshake failure, which names neither the missing variable nor the reason. The env read is split from the connect so every misconfiguration arm is testable without a reachable store and without touching process-global env. Dependency impact, measured against the lock at forge/main rather than assumed: native-tls 0 -> 0, openssl-sys 0 -> 0, one reqwest (0.13.4) which vaultrs shares, and 10 new crates that are all derive/proc-macro helpers. Refs #3726
This commit is contained in:
parent
9c601c4166
commit
4384a1fffa
6 changed files with 584 additions and 6 deletions
98
swarm-secret-client/src/path.rs
Normal file
98
swarm-secret-client/src/path.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
//! Where a credential lives, for both ends of the store.
|
||||
//!
|
||||
//! The controller writes and a hive reads, and neither is senior to the other,
|
||||
//! so the path they must agree on is built here rather than formatted at each
|
||||
//! call site.
|
||||
|
||||
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.
|
||||
pub const MOUNT: &str = "secret";
|
||||
|
||||
/// The prefix under [`MOUNT`] owned by per-agent credentials.
|
||||
pub const AGENT_PREFIX: &str = "swarm/agents";
|
||||
|
||||
/// A path segment that cannot change the path's shape.
|
||||
///
|
||||
/// The charset is deliberately narrower than what the store accepts: a `/`
|
||||
/// turns one agent's segment into another agent's directory, and `..` walks
|
||||
/// out of the prefix entirely. Both are names this crate receives from
|
||||
/// elsewhere — an agent name from the topology, an account name from an
|
||||
/// agent's own config — so neither is trusted to be well-formed here.
|
||||
fn checked_segment(kind: &'static str, value: &str) -> Result<(), Error> {
|
||||
if value.is_empty() {
|
||||
return Err(Error::PathSegment {
|
||||
kind,
|
||||
value: value.to_owned(),
|
||||
});
|
||||
}
|
||||
if !value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
{
|
||||
return Err(Error::PathSegment {
|
||||
kind,
|
||||
value: value.to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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 matrix_account(agent: &str, account: &str) -> Result<String, Error> {
|
||||
checked_segment("agent", agent)?;
|
||||
checked_segment("account", account)?;
|
||||
Ok(format!("{AGENT_PREFIX}/{agent}/matrix/{account}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_well_formed_pair_lands_under_the_agent_prefix() {
|
||||
let p = matrix_account("atlas", "ops-relay").expect("both segments are legal");
|
||||
assert_eq!(p, "swarm/agents/atlas/matrix/ops-relay");
|
||||
assert!(p.starts_with(AGENT_PREFIX));
|
||||
}
|
||||
|
||||
#[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!(
|
||||
matrix_account(bad, "ops-relay").is_err(),
|
||||
"agent segment {bad:?} must be refused"
|
||||
);
|
||||
assert!(
|
||||
matrix_account("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!(matrix_account("a-b_C9", "d-e_F0").is_ok());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue