Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-matrix-mcp/src/credential.rs
atlas ab153bda2f hive-matrix-mcp: read the main account's token from the store too
The daemon reads each account's token from `swarm/agents/<agent>/matrix/`
as the agent itself, inside its own container, and falls back to the file
only when the store has none. This is #4519's read, without its `main`
carve-out: the swarm now mints `main` there and no hive writes the file.

The daemon unit gets the agent's store identity, spelled the way
forge-token.nix spells it. A timer re-starts it while it is down: a token
the swarm mints or replaces in the store changes no file, so the path
watcher never fires for it, and a daemon that exited on a replaced token
would otherwise stay down until the container restarts.
2026-09-25 08:31:01 +02:00

429 lines
17 KiB
Rust

//! 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/<agent>/matrix/<account>` 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!("<redacted, {} bytes>", 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<Option<Self>> {
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<Option<Self>> {
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<String> {
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<String>) -> Result<Option<Settings>> {
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<String> {
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("<redacted"), "got {rendered}");
// The control: the origin IS meant to survive, so the assertion above
// is not passing because the impl prints nothing at all.
assert!(
rendered.contains("swarm/agents/a1/matrix/ccc"),
"got {rendered}"
);
}
/// The same property for the arm a reader actually reaches for: `Display`
/// on the origin is what every message in this crate interpolates.
#[test]
fn an_origin_renders_as_a_path_in_both_arms() {
assert_eq!(
Origin::File(PathBuf::from("/agents/a1/state/matrix-token-ccc")).to_string(),
"/agents/a1/state/matrix-token-ccc"
);
assert_eq!(
Origin::Store("swarm/agents/a1/matrix/ccc".to_owned()).to_string(),
"the store's swarm/agents/a1/matrix/ccc"
);
}
#[test]
fn a_container_with_no_store_address_reads_no_store() {
// The absence arm, and what the file fallback exists for: a hive that
// was given no store forwards nothing, and this must be "there is no
// store" rather than an error about a missing certificate.
assert!(
store_settings(|_| None)
.expect("an absent store is not a failure")
.is_none()
);
// Empty is how systemd delivers an unset nix option, so it has to read
// the same as unset.
assert!(
store_settings(|k| if k == ENV_ADDR {
Some(String::new())
} else {
with_store(k)
})
.expect("an empty address is not an address")
.is_none()
);
}
#[test]
fn an_address_without_an_identity_is_a_half_delivered_container() {
// Not the same thing as having no store: the hive said where the store
// is and then delivered nothing to present to it, which is a deployment
// bug and must be named rather than silently degraded.
let e = store_settings(|k| if k == ENV_ADDR { with_store(k) } else { None })
.expect_err("an address with no certificate beside it is a failure");
assert!(e.to_string().contains("coordinates"), "got {e:#}");
}
#[test]
fn a_store_address_with_an_identity_is_usable() {
// The control for the two arms above.
assert!(
store_settings(with_store)
.expect("a full environment")
.is_some()
);
}
#[test]
fn a_ca_that_was_never_delivered_is_dropped_rather_than_handed_on() {
// `LoadCredential=` in its bare form is non-fatal when the credential
// is absent, so this variable routinely names a file that is not there.
// The resulting settings must be the ones a container with no CA at all
// gets — anything else fails the handshake and blames the store.
let named = store_settings(|k| {
if k == ENV_CACERT {
Some("/nonexistent/server-ca.pem".to_owned())
} else {
with_store(k)
}
})
.expect("a full environment")
.expect("a store is configured");
let absent = store_settings(with_store)
.expect("a full environment")
.expect("a store is configured");
assert_eq!(named, absent);
}
#[test]
fn a_ca_with_bytes_in_it_is_kept() {
// The control: without it, the test above would pass against a
// correction that dropped every CA it was ever given.
let dir = std::env::temp_dir().join(format!(
"hh-cred-ca-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let ca = dir.join("server-ca.pem");
std::fs::write(&ca, "-----BEGIN CERTIFICATE-----\n").unwrap();
assert!(ca_is_usable(ca.to_str().unwrap()));
let kept = store_settings(|k| {
if k == ENV_CACERT {
Some(ca.to_string_lossy().into_owned())
} else {
with_store(k)
}
})
.expect("a full environment")
.expect("a store is configured");
assert_ne!(
kept,
store_settings(with_store)
.expect("a full environment")
.expect("configured"),
"a delivered CA must change the settings it is delivered into"
);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn an_unprovisioned_account_has_no_token_rather_than_an_error() {
// The daemon skips such an account (a secondary) or waits on the path
// watcher (the primary); neither is a failure.
let missing = std::env::temp_dir().join("hh-cred-definitely-not-here/matrix-token");
assert!(Token::from_file(&missing).await.unwrap().is_none());
}
#[tokio::test]
async fn an_empty_token_file_names_the_file_and_not_its_contents() {
let dir = std::env::temp_dir().join(format!(
"hh-cred-empty-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("matrix-token");
std::fs::write(&path, " \n").unwrap();
let e = Token::from_file(&path)
.await
.expect_err("whitespace is not a token");
assert!(
e.to_string().contains(&path.display().to_string()),
"got {e:#}"
);
// And the control: a real token comes back, trimmed, with the file as
// its origin.
std::fs::write(&path, "syt_abc\n").unwrap();
let token = Token::from_file(&path)
.await
.expect("a readable file")
.expect("a non-empty one");
assert_eq!(token.expose(), "syt_abc");
assert_eq!(token.origin(), &Origin::File(path.clone()));
std::fs::remove_dir_all(&dir).ok();
}
}