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:
atlas 2026-09-02 22:27:58 +02:00 committed by mara
commit 4384a1fffa
6 changed files with 584 additions and 6 deletions

View file

@ -0,0 +1,248 @@
//! A logged-in handle on the store, built from this deployment's environment.
use serde::{Deserialize, Serialize};
use vaultrs::client::{Client, VaultClient, VaultClientSettingsBuilder};
use crate::{Error, path::MOUNT};
/// The store's address.
pub const ENV_ADDR: &str = "BAO_ADDR";
/// PEM client certificate presented to the store's listener.
pub const ENV_CLIENT_CERT: &str = "BAO_CLIENT_CERT";
/// PEM private key for [`ENV_CLIENT_CERT`].
pub const ENV_CLIENT_KEY: &str = "BAO_CLIENT_KEY";
/// CA bundle the store's own certificate is verified against. Optional:
/// absent means the system trust store, which is what a deployment with a
/// real CA wants and what a self-signed one must not be left with.
pub const ENV_CACERT: &str = "BAO_CACERT";
/// The auth mount a cert login goes through, unless a caller says otherwise.
pub const DEFAULT_CERT_MOUNT: &str = "cert";
#[derive(Serialize, Deserialize)]
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.
value: String,
}
/// Where the store is and which identity we present to it.
///
/// Separate from the connect so the environment can be checked without one:
/// every arm below is a misconfiguration an operator has to read an error
/// about, and none of them needs a reachable store to happen.
#[derive(Debug, PartialEq, Eq)]
pub struct Settings {
address: String,
cert_path: String,
key_path: String,
ca_path: Option<String>,
}
impl Settings {
/// Read the `BAO_*` variables from the process environment.
///
/// # Errors
/// [`Error::MissingEnv`] naming the first variable that is unset or empty.
pub fn from_env() -> Result<Self, Error> {
Self::from_lookup(|k| std::env::var(k).ok())
}
/// [`Settings::from_env`] against an arbitrary lookup.
///
/// [`vaultrs`]'s own defaults are deliberately not used: it looks for
/// `VAULT_ADDR` / `VAULT_CLIENT_CERT` / `VAULT_CLIENT_KEY`, and every unit
/// in this tree sets the `BAO_` spellings. Falling through to those
/// defaults yields a client with **no identity**, which fails at the TLS
/// handshake rather than anywhere that names the cause.
///
/// # Errors
/// [`Error::MissingEnv`] naming the first variable that is unset or empty.
pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self, Error> {
let required = |var: &'static str| -> Result<String, Error> {
get(var)
.filter(|v| !v.is_empty())
.ok_or(Error::MissingEnv(var))
};
Ok(Self {
address: required(ENV_ADDR)?,
cert_path: required(ENV_CLIENT_CERT)?,
key_path: required(ENV_CLIENT_KEY)?,
ca_path: get(ENV_CACERT).filter(|v| !v.is_empty()),
})
}
}
/// A client that has already exchanged its certificate for a token.
pub struct SecretStore {
inner: VaultClient,
}
fn read_file(var: &'static str, path: &str) -> Result<Vec<u8>, Error> {
std::fs::read(path).map_err(|source| Error::Identity {
var,
path: path.to_owned(),
source,
})
}
impl SecretStore {
/// Connect using the `BAO_*` environment and log in with the certificate
/// auth method, returning a handle that already holds a token.
///
/// `cert_role` is the role configured on the store's `cert` mount; it
/// selects which policy the returned token carries.
///
/// # Errors
/// As [`SecretStore::connect`], plus [`Error::MissingEnv`].
pub async fn from_env(cert_role: &str) -> Result<Self, Error> {
Self::connect(&Settings::from_env()?, cert_role, DEFAULT_CERT_MOUNT).await
}
/// Present `settings`' identity to the store and log in at `cert_mount`.
///
/// # Errors
/// [`Error::Identity`] when a named file cannot be read, [`Error::Tls`]
/// when the cert/key pair does not form a usable identity,
/// [`Error::Settings`] when the address will not parse, and
/// [`Error::Vault`] when the store refuses the login — which is what an
/// unconfigured `cert` mount looks like from here.
pub async fn connect(
settings: &Settings,
cert_role: &str,
cert_mount: &str,
) -> Result<Self, Error> {
// One PEM blob holding both, which is the shape `Identity::from_pem`
// wants; the two stay separate on disk because the key is the half
// that gets `0600`.
let mut pem = read_file(ENV_CLIENT_CERT, &settings.cert_path)?;
pem.push(b'\n');
pem.extend_from_slice(&read_file(ENV_CLIENT_KEY, &settings.key_path)?);
let identity = reqwest::Identity::from_pem(&pem).map_err(Error::Tls)?;
let mut builder = VaultClientSettingsBuilder::default();
builder
.address(settings.address.clone())
.identity(Some(identity));
if let Some(ca) = &settings.ca_path {
builder.ca_certs(vec![ca.clone()]);
}
let built = builder
.build()
.map_err(|e| Error::Settings(e.to_string()))?;
let mut inner = VaultClient::new(built)?;
let auth = vaultrs::auth::cert::login(&inner, cert_mount, cert_role).await?;
inner.set_token(&auth.client_token);
Ok(Self { inner })
}
/// Read the credential stored at `path`.
///
/// # Errors
/// [`Error::Vault`] when the path does not exist, the token's policy does
/// not cover it, or the stored object has no `value` field.
pub async fn read(&self, path: &str) -> Result<String, Error> {
let c: Credential = vaultrs::kv2::read(&self.inner, MOUNT, path).await?;
Ok(c.value)
}
/// Write `value` as the credential at `path`, creating a new version.
///
/// # Errors
/// [`Error::Vault`] when the token's policy does not cover the path.
pub async fn write(&self, path: &str, value: &str) -> Result<(), Error> {
let body = Credential {
value: value.to_owned(),
};
vaultrs::kv2::set(&self.inner, MOUNT, path, &body).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A lookup standing in for a fully-configured unit's environment.
fn full(k: &str) -> Option<String> {
match k {
ENV_ADDR => Some("https://store.invalid:8200".to_owned()),
ENV_CLIENT_CERT => Some("/run/c.pem".to_owned()),
ENV_CLIENT_KEY => Some("/run/k.pem".to_owned()),
_ => None,
}
}
#[test]
fn a_complete_environment_is_accepted() {
// The control: without this, every assertion below could be passing
// because `from_lookup` rejects everything.
let s = Settings::from_lookup(full).expect("every required variable is set");
assert_eq!(s.address, "https://store.invalid:8200");
assert_eq!(s.ca_path, None, "an absent CA is the system trust store");
}
#[test]
fn each_required_variable_is_named_when_it_is_the_missing_one() {
for var in [ENV_ADDR, ENV_CLIENT_CERT, ENV_CLIENT_KEY] {
let e = Settings::from_lookup(|k| if k == var { None } else { full(k) })
.expect_err("one required variable is absent");
assert!(
matches!(e, Error::MissingEnv(v) if v == var),
"dropping {var} should name {var}, got {e:?}"
);
}
}
#[test]
fn an_empty_variable_is_as_absent_as_an_unset_one() {
// systemd writes `Environment=BAO_CACERT=` for an unset nix option, so
// empty is the shape this actually arrives in.
let e = Settings::from_lookup(|k| {
if k == ENV_ADDR {
Some(String::new())
} else {
full(k)
}
})
.expect_err("an empty address is not an address");
assert!(matches!(e, Error::MissingEnv(ENV_ADDR)), "got {e:?}");
let s = Settings::from_lookup(|k| {
if k == ENV_CACERT {
Some(String::new())
} else {
full(k)
}
})
.expect("an empty CA is optional, not fatal");
assert_eq!(s.ca_path, None);
}
#[test]
fn an_unreadable_cert_names_the_file_and_the_variable() {
let e = read_file(ENV_CLIENT_CERT, "/nonexistent/cert.pem")
.expect_err("the file does not exist");
match e {
Error::Identity { var, ref path, .. } => {
assert_eq!(var, ENV_CLIENT_CERT);
assert_eq!(path, "/nonexistent/cert.pem");
}
other => panic!("wanted an Identity error, got {other:?}"),
}
}
#[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.
let json = serde_json::to_string(&Credential {
value: "t".to_owned(),
})
.expect("a struct of one String serialises");
assert_eq!(json, r#"{"value":"t"}"#);
}
}

View file

@ -0,0 +1,68 @@
//! The swarm's secret-store client: where a credential lives, and how both ends
//! reach it.
//!
//! The HTTP is [`vaultrs`]'s job. What this crate owns is the *agreements* —
//! the path a credential is written to and read from ([`path`]), the field its
//! bytes live in, and the translation from this deployment's environment into
//! a logged-in client ([`client`]). Each of those is a thing the controller and
//! a hive must say identically, so it is said once here.
pub mod client;
pub mod path;
pub use client::SecretStore;
/// What can go wrong between "we have a client certificate" and "we have the
/// credential".
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// A name that would have addressed something other than what the caller
/// meant. See [`path`].
#[error("{kind} name {value:?} is not a single path segment of [A-Za-z0-9_-]")]
PathSegment {
/// Which name was rejected — `agent` or `account`.
kind: &'static str,
/// The offending value, quoted in the message because the caller
/// usually got it from config and needs to see which one.
value: String,
},
/// A variable the store's address or identity comes from is unset or
/// empty. Named rather than defaulted: a wrong store address fails much
/// later and much less clearly than a missing one.
#[error("{0} is unset or empty")]
MissingEnv(&'static str),
/// A client-certificate file named by the environment could not be read.
#[error("reading {path} (from {var}): {source}")]
Identity {
/// The variable that named the file.
var: &'static str,
/// The path it named.
path: String,
/// The underlying IO failure.
source: std::io::Error,
},
/// The address would not parse into a URL the client can use.
#[error("the store's settings are unusable: {0}")]
Settings(String),
/// The store refused us, was unreachable, or answered something we could
/// not parse.
#[error(transparent)]
Vault(#[from] Box<vaultrs::error::ClientError>),
/// The client certificate and key did not form a usable identity, or the
/// CA bundle did not parse.
#[error("building the TLS identity: {0}")]
Tls(#[source] reqwest::Error),
}
impl From<vaultrs::error::ClientError> for Error {
fn from(e: vaultrs::error::ClientError) -> Self {
// Boxed because `ClientError` is large enough that carrying it inline
// makes every `Result` in the crate pay for the rare arm.
Self::Vault(Box::new(e))
}
}

View 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());
}
}