diff --git a/Cargo.lock b/Cargo.lock index 0d1fafcc..f356b36c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -894,14 +894,38 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", ] [[package]] @@ -917,13 +941,24 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.119", ] @@ -1020,6 +1055,37 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "1.0.0" @@ -3823,7 +3889,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "serde_json", @@ -4012,6 +4078,40 @@ dependencies = [ "semver", ] +[[package]] +name = "rustify" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4800ce4c1cc2fec12c559dae2ddbf0e17fcee7569b796e6d75898efef443368b" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "http", + "reqwest", + "rustify_derive", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 1.0.69", + "tracing", + "url", +] + +[[package]] +name = "rustify_derive" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ea7fda74240f7410d0198b603a8a2f662acc7d76b6667a49f9b162cd8d9b4f" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "serde_urlencoded", + "syn 1.0.109", + "synstructure 0.12.6", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4642,6 +4742,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "swarm-secret-client" +version = "0.1.0" +dependencies = [ + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "vaultrs", +] + [[package]] name = "swarmctl" version = "0.1.0" @@ -4696,6 +4807,18 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -5337,6 +5460,25 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vaultrs" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ffcc0e81025065dda612ec1e26a3d81bb16ef3062354873d17a35965d68522" +dependencies = [ + "async-trait", + "derive_builder", + "http", + "reqwest", + "rustify", + "rustify_derive", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", + "url", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -5873,7 +6015,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -5914,7 +6056,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d1edfdd6..8ca89bd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "swarm-controller", "swarm-nats-auth", "swarm-queue-client", + "swarm-secret-client", "swarmctl", ] @@ -90,7 +91,9 @@ hive-sock-client = { path = "hive-sock-client" } hive-types = { path = "hive-types" } swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" } swarm-queue-client = { path = "swarm-queue-client" } +swarm-secret-client = { path = "swarm-secret-client" } thiserror = "2" +vaultrs = "0.8" tower-http = { version = "0.7", features = ["fs"] } uuid = { version = "1", features = ["v4"] } rmcp = { version = "2", default-features = false, features = [ diff --git a/swarm-secret-client/Cargo.toml b/swarm-secret-client/Cargo.toml new file mode 100644 index 00000000..bb59a56e --- /dev/null +++ b/swarm-secret-client/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "swarm-secret-client" +version.workspace = true +edition.workspace = true + +[dependencies] +# `Identity` is re-exported by vaultrs, but the identity is built here (from +# the `BAO_*` files) rather than by its env defaults, so the dependency is +# direct rather than incidental. +reqwest.workspace = true +serde.workspace = true +thiserror.workspace = true +vaultrs.workspace = true + +[dev-dependencies] +serde_json.workspace = true + +[lints] +workspace = true diff --git a/swarm-secret-client/src/client.rs b/swarm-secret-client/src/client.rs new file mode 100644 index 00000000..d2f00a14 --- /dev/null +++ b/swarm-secret-client/src/client.rs @@ -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, +} + +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::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) -> Result { + let required = |var: &'static str| -> Result { + 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, 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::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 { + // 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 { + 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 { + 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"}"#); + } +} diff --git a/swarm-secret-client/src/lib.rs b/swarm-secret-client/src/lib.rs new file mode 100644 index 00000000..02fa1efd --- /dev/null +++ b/swarm-secret-client/src/lib.rs @@ -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), + + /// 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 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)) + } +} diff --git a/swarm-secret-client/src/path.rs b/swarm-secret-client/src/path.rs new file mode 100644 index 00000000..00116422 --- /dev/null +++ b/swarm-secret-client/src/path.rs @@ -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 { + 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()); + } +}