//! Checking an agent's own credential against the swarm secret store. //! //! The token is `swarm_queue_client::agent_token`'s spelling: a prefix, the //! agent's name, and its secret. The name is only a claim. It becomes an //! identity when the secret equals the one stored at //! `swarm/agents//queue`, and [`verify`] grants nothing on the name //! alone: every path that does not reach that comparison, and every lookup //! that fails, is a denial. //! //! Two verifiers use this: the queue's auth callout (`swarm-nats-auth`) and //! the log store's `auth_request` responder (`swarm-logs-auth`). Each reads //! the store under its own certificate. use std::future::Future; use std::time::Duration; use anyhow::Context; use subtle::ConstantTimeEq; use swarm_queue_client::agent_token::AgentToken; use swarm_secret_client::client::{DEFAULT_CERT_MOUNT, Settings}; use swarm_secret_client::queue::{self, AgentCredential}; /// Where the stored credential for an agent comes from. pub trait CredentialSource { /// The object at `agent`'s queue path, `None` when nothing is stored /// there, or an error when the store could not answer. fn lookup( &self, agent: &str, ) -> impl Future>> + Send; } /// The swarm secret store, reached with the verifier's own certificate. pub struct Store { settings: Settings, cert_role: String, } impl Store { /// The store at `settings`, logged in to as `cert_role`. #[must_use] pub fn new(settings: Settings, cert_role: String) -> Self { Self { settings, cert_role, } } } impl CredentialSource for Store { /// Logs in per lookup, so no token is left to expire inside a long-lived /// process. async fn lookup(&self, agent: &str) -> anyhow::Result> { let path = queue::agent_queue_path(agent)?; let store = swarm_secret_client::SecretStore::connect( &self.settings, &self.cert_role, DEFAULT_CERT_MOUNT, ) .await .context("logging in to the secret store")?; store .read_optional(&path) .await .with_context(|| format!("reading {path}")) } } /// Whether `token`'s secret is the one stored for the agent it names. /// /// The lookup is bounded by `timeout`, after which this denies. pub async fn verify( source: &impl CredentialSource, token: &AgentToken<'_>, timeout: Duration, ) -> bool { let agent = token.agent; let stored = match tokio::time::timeout(timeout, source.lookup(agent)).await { Ok(Ok(Some(stored))) => stored, Ok(Ok(None)) => { tracing::warn!( agent, "no queue credential is stored for this agent; denying" ); return false; } Ok(Err(e)) => { tracing::warn!( agent, error = format!("{e:#}"), "credential lookup failed; denying" ); return false; } Err(_) => { tracing::warn!(agent, "credential lookup timed out; denying"); return false; } }; if stored.agent != agent { tracing::warn!( agent, stored_agent = %stored.agent, "the stored credential names a different agent than its path; denying" ); return false; } // Constant time, so the time to refuse says nothing about how much of the // secret was right. A length mismatch returns early; the length is not // secret, every minted secret has the same one. stored .value .as_bytes() .ct_eq(token.secret.as_bytes()) .into() } #[cfg(test)] mod tests { use super::*; use swarm_queue_client::agent_token::parse_agent_token; /// A store holding at most one credential, or failing outright. enum Fake { /// `credential` is stored at `path_agent`'s queue path. Holds { path_agent: &'static str, credential: AgentCredential, }, Fails, /// A store that accepts the request and never answers. Hangs, } impl CredentialSource for Fake { async fn lookup(&self, agent: &str) -> anyhow::Result> { match self { Self::Holds { path_agent, credential, } => Ok((agent == *path_agent).then(|| credential.clone())), Self::Fails => anyhow::bail!("store unreachable"), Self::Hangs => std::future::pending().await, } } } fn stored(path_agent: &'static str, named: &str) -> Fake { Fake::Holds { path_agent, credential: AgentCredential { value: "Ab9_-zSECRET".to_owned(), agent: named.to_owned(), minted_at: None, }, } } fn atlas() -> Fake { stored("atlas", "atlas") } const BOUND: Duration = Duration::from_millis(200); async fn verified(source: &Fake, token: &str) -> bool { let token = parse_agent_token(token) .expect("an agent token") .expect("well-formed"); verify(source, &token, BOUND).await } #[tokio::test] async fn the_stored_secret_is_accepted() { assert!(verified(&atlas(), "swarm-agent.atlas.Ab9_-zSECRET").await); } #[tokio::test] async fn a_wrong_secret_is_denied() { assert!(!verified(&atlas(), "swarm-agent.atlas.Ab9_-zSECREX").await); assert!(!verified(&atlas(), "swarm-agent.atlas.Ab9_-zSECRE").await); } /// The secret check is what stops one agent claiming another's name: the /// right secret under someone else's name finds that agent's credential, /// or none. #[tokio::test] async fn another_agents_name_with_this_agents_secret_is_denied() { assert!(!verified(&atlas(), "swarm-agent.argus.Ab9_-zSECRET").await); } #[tokio::test] async fn an_agent_with_nothing_stored_is_denied() { assert!(!verified(&stored("argus", "argus"), "swarm-agent.atlas.Ab9_-zSECRET").await); } #[tokio::test] async fn a_failed_lookup_is_denied() { assert!(!verified(&Fake::Fails, "swarm-agent.atlas.Ab9_-zSECRET").await); } #[tokio::test] async fn a_store_that_never_answers_is_denied_within_the_bound() { let started = std::time::Instant::now(); assert!(!verified(&Fake::Hangs, "swarm-agent.atlas.Ab9_-zSECRET").await); let took = started.elapsed(); assert!(took >= BOUND, "denied before the bound: {took:?}"); assert!( took < BOUND + Duration::from_millis(250), "denied well after the bound: {took:?}" ); } #[tokio::test] async fn a_stored_object_naming_another_agent_is_denied() { assert!(!verified(&stored("argus", "atlas"), "swarm-agent.argus.Ab9_-zSECRET").await); } }