//! The matrix access token as a value this process holds, and the two places //! it comes from. //! //! 🩸 **A secret is a path, not a value.** Nothing here writes the token //! anywhere, interpolates it into a command, or lets it reach a log line or an //! error message: [`Token`] has no `Debug` derive, and every message below //! names an [`Origin`] — which is a path in the store or a path on disk, never //! the bytes at either. //! //! The store read is this agent reading **its own** credential under **its own //! identity, from inside its own container** — the path //! `swarm/agents//matrix/` is already agent-scoped, and the //! certificate the login presents is the one this agent's hive delivered as a //! systemd credential (`nix/agent-modules/bao.nix`). The hive is not in the //! path of the value at all. //! //! The file arm is the hive-side delivery that still runs beside this one for //! extra accounts (`hive_c0re::workers::credential`), and the `main` token a //! hive minted before the swarm did. That is what this replaces, not something //! it depends on, and it is the arm that goes when the hive-side loop does. use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; use swarm_secret_client::{ SecretStore, client::{DEFAULT_CERT_MOUNT, ENV_ADDR, ENV_CACERT, Settings}, matrix, policy, }; /// Names the agent this container belongs to, set by /// `nix/agent-modules/matrix.nix` from the agent's own unix user name. /// /// The name and nothing else: `swarm_secret_client` owns both spellings /// derived from it — the credential's path ([`matrix::account_path`]) and the /// cert-auth role the login selects ([`policy::agent_object_name`]) — so a /// path or a role name forwarded from nix would be a second copy of a string /// whose mismatch is a 403 that names neither. pub const ENV_AGENT: &str = "HIVE_AGENT_NAME"; /// Where a token came from. /// /// A **path** in both arms, which is what makes it safe to put in a log line: /// the whole point of this module is that the thing beside it never is. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Origin { /// A file in this agent's state dir, written by the hive-side delivery. File(PathBuf), /// A path in the swarm secret store, read by this agent as itself. Store(String), } impl std::fmt::Display for Origin { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::File(p) => write!(f, "{}", p.display()), Self::Store(p) => write!(f, "the store's {p}"), } } } /// A matrix access token, held in memory for as long as it takes to restore a /// session with it. /// /// ⚠️ **No `Debug` derive**, for the reason /// [`swarm_secret_client::mtls::Credential`] states for the private key it /// carries: this value is threaded through `anyhow` context chains and /// `tracing` fields, both of which format whatever they are handed. The /// hand-written impl below reports the origin and the length, which is every /// question a reader of a log line actually has. pub struct Token { value: String, origin: Origin, } impl std::fmt::Debug for Token { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Token") .field("origin", &self.origin) .field( "value", &format_args!("", self.value.len()), ) .finish() } } impl Token { /// The bytes, for the one caller that has to present them to the /// homeserver. #[must_use] pub fn expose(&self) -> &str { &self.value } /// Where this token came from — the only half of it that may be printed. #[must_use] pub fn origin(&self) -> &Origin { &self.origin } /// Read this agent's credential for `account` out of the swarm secret /// store, under this agent's own certificate. /// /// `Ok(None)` means this deployment has no store: the agent's hive was /// given no `BAO_ADDR` to forward, so `nix/agent-modules/bao.nix` minted no /// identity check and there is nothing here to log in to. That is an absent /// integration, not a failure — the caller falls back to the file the hive /// delivered. /// /// # Errors /// A name that is not a single path segment, an environment naming an /// identity that cannot be read, a store that refuses the certificate or /// the path, or a credential stored empty. pub async fn from_store(agent: &str, account: &str) -> Result> { let Some(settings) = store_settings(|k| std::env::var(k).ok())? else { return Ok(None); }; let path = matrix::account_path(agent, account) .context("building this agent's credential path in the store")?; let role = policy::agent_object_name(agent) .context("building this agent's cert-auth role name")?; let store = SecretStore::connect(&settings, &role, DEFAULT_CERT_MOUNT) .await .context("logging in to the swarm secret store as this agent")?; // The stored object also carries the account's homeserver, and it is // deliberately dropped here: the account's URL is already settled by // the time this runs (`AccountCfg::homeserver`), and taking it from the // store instead would change which accounts come up at all — that is // the discovery half of this move, which goes with the hive-side loop // rather than with the read. let credential: matrix::Credential = store .read(&path) .await .with_context(|| format!("reading {path} from the store"))?; let value = credential.value.trim().to_owned(); if value.is_empty() { return Err(anyhow!("the credential at {path} in the store is empty")); } Ok(Some(Self { value, origin: Origin::Store(path), })) } /// Read a token out of the file the hive-side delivery wrote. /// /// `Ok(None)` when the file is not there — the account has not been /// provisioned yet, which the caller treats as "skip", not "fail". /// /// # Errors /// The file existing but being unreadable or empty. pub async fn from_file(path: &Path) -> Result> { if !tokio::fs::try_exists(path).await.unwrap_or(false) { return Ok(None); } let value = tokio::fs::read_to_string(path) .await .with_context(|| format!("read matrix token from {}", path.display()))? .trim() .to_owned(); if value.is_empty() { return Err(anyhow!("matrix token at {} is empty", path.display())); } Ok(Some(Self { value, origin: Origin::File(path.to_owned()), })) } /// Drop this token after the homeserver rejected it, so a restart does not /// loop on the same dead value. /// /// Only a file can be dropped. A credential in the store is not this /// agent's to delete — its policy grants `read` and nothing else — and /// replacing it is renewal, a different job from this one. Saying so is the /// whole of what the store arm does. pub async fn discard_stale(&self) { match &self.origin { Origin::File(p) => { let _ = tokio::fs::remove_file(p).await; } Origin::Store(path) => tracing::warn!( credential = %path, "the stored matrix credential was rejected; leaving it alone (this agent may only read it)" ), } } } /// This agent's name, or `None` when the harness did not say. /// /// `None` is not a failure: it is what an agent whose harness predates /// [`ENV_AGENT`] looks like, and such an agent keeps working off the file its /// hive delivers. #[must_use] pub fn agent_name() -> Option { std::env::var(ENV_AGENT).ok().filter(|v| !v.is_empty()) } /// The store's coordinates as this container was given them, or `None` when it /// was given none. /// /// Split out of [`Token::from_store`] and taken through a lookup so the two /// decisions it makes — "is there a store at all" and "is the delivered CA /// usable" — can be asserted without a store to talk to or an environment to /// mutate. /// /// # Errors /// [`swarm_secret_client::Error::MissingEnv`] when an address was forwarded but /// the identity beside it was not, which is a half-delivered container rather /// than one without a store. fn store_settings(get: impl Fn(&str) -> Option) -> Result> { if get(ENV_ADDR).is_none_or(|v| v.is_empty()) { return Ok(None); } let settings = Settings::from_lookup(|k| get(k).filter(|v| k != ENV_CACERT || ca_is_usable(v))) .context("reading the swarm secret store's coordinates from the environment")?; Ok(Some(settings)) } /// Whether the CA bundle at `path` is a file with bytes in it. /// /// `BAO_CACERT` names a systemd credential, and the bare `LoadCredential=` form /// is non-fatal when the manager received no such credential — so the variable /// can name a file that is not there. Absent means "verify the store's listener /// against the container's own trust store", which is what a deployment with a /// real CA wants; handing the path through regardless would fail the TLS /// handshake on a file that was never meant to exist and blame the store. fn ca_is_usable(path: &str) -> bool { std::fs::metadata(path).is_ok_and(|m| m.len() > 0) } #[cfg(test)] mod tests { use super::*; /// A lookup standing in for a container that was handed a store. fn with_store(k: &str) -> Option { match k { ENV_ADDR => Some("https://bao.t.local:8200".to_owned()), "BAO_CLIENT_CERT" => Some("/run/credentials/x/cert".to_owned()), "BAO_CLIENT_KEY" => Some("/run/credentials/x/key".to_owned()), _ => None, } } /// The property the hand-written `Debug` exists for: a token that reaches a /// log line is a token in a journal somebody else can read. #[test] fn formatting_a_token_does_not_reveal_it() { let token = Token { value: "syt_SUPER_SECRET_ACCESS_TOKEN".to_owned(), origin: Origin::Store("swarm/agents/a1/matrix/ccc".to_owned()), }; let rendered = format!("{token:?}"); assert!( !rendered.contains("syt_SUPER_SECRET_ACCESS_TOKEN"), "the token must not survive formatting, got {rendered}" ); assert!(rendered.contains("