From e7dbb94f28d09a5afbc09692d1d7d40164ab7bf7 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 12 Oct 2026 22:47:37 +0200 Subject: [PATCH] swarm-agent-verify: lift the agent-token store check out of swarm-nats-auth `CredentialSource`, `Store` and `verify` move from swarm-nats-auth's `agent_token` module into a small library crate, so a second verifier can make the same check: the secret in a `swarm-agent..` token equals the one stored at `swarm/agents//queue`, read under the verifier's own certificate. `verify` now takes its timeout as an argument; swarm-nats-auth passes `INTROSPECTION_TIMEOUT` as before. No behaviour change. The store-denial tests moved with the code; swarm-nats-auth keeps the ones that go through its policy and its bound. Part of #4459. --- Cargo.lock | 14 +- Cargo.toml | 4 +- swarm-agent-verify/Cargo.toml | 18 +++ swarm-agent-verify/src/lib.rs | 217 +++++++++++++++++++++++++++++ swarm-nats-auth/Cargo.toml | 7 +- swarm-nats-auth/src/agent_token.rs | 204 +++------------------------ swarm-nats-auth/src/main.rs | 2 +- 7 files changed, 275 insertions(+), 191 deletions(-) create mode 100644 swarm-agent-verify/Cargo.toml create mode 100644 swarm-agent-verify/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index adf97684..1e99c3a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4789,6 +4789,18 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swarm-agent-verify" +version = "0.1.0" +dependencies = [ + "anyhow", + "subtle", + "swarm-queue-client", + "swarm-secret-client", + "tokio", + "tracing", +] + [[package]] name = "swarm-authelia-bridge" version = "0.1.0" @@ -4910,7 +4922,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "subtle", + "swarm-agent-verify", "swarm-queue-client", "swarm-secret-client", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 2271332d..049e205c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ members = [ "hive-subagent-mcp", "hive-types", "hivectl", + "swarm-agent-verify", "swarm-authelia-bridge", "swarm-authelia-bridge-sock", "swarm-controller", @@ -104,6 +105,7 @@ hive-priv-sock = { path = "hive-priv-sock" } hive-runtime = { path = "hive-runtime" } hive-sock-client = { path = "hive-sock-client" } hive-types = { path = "hive-types" } +swarm-agent-verify = { path = "swarm-agent-verify" } swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" } swarm-matrix-client = { path = "swarm-matrix-client" } swarm-queue-client = { path = "swarm-queue-client" } @@ -234,7 +236,7 @@ futures-util = "0.3" hmac = "0.13" sha2 = "0.11" # Constant-time comparison of a presented secret against the stored one -# (`swarm-nats-auth::agent_token`). Already in the tree through the TLS stack. +# (`swarm-agent-verify`). Already in the tree through the TLS stack. subtle = "2.6" # The NATS protocol client, for the swarm queue's auth-callout responder. # `default-features = false` because the default set is broad - jetstream, kv, diff --git a/swarm-agent-verify/Cargo.toml b/swarm-agent-verify/Cargo.toml new file mode 100644 index 00000000..d247c10f --- /dev/null +++ b/swarm-agent-verify/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "swarm-agent-verify" +version.workspace = true +edition.workspace = true + +[dependencies] +anyhow.workspace = true +# Comparing an agent's presented secret with the stored one. +subtle.workspace = true +# The agent-token spelling the agent also uses. +swarm-queue-client.workspace = true +# The read of the stored credential the token is checked against. +swarm-secret-client.workspace = true +tokio.workspace = true +tracing.workspace = true + +[lints] +workspace = true diff --git a/swarm-agent-verify/src/lib.rs b/swarm-agent-verify/src/lib.rs new file mode 100644 index 00000000..24c6ad9c --- /dev/null +++ b/swarm-agent-verify/src/lib.rs @@ -0,0 +1,217 @@ +//! 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); + } +} diff --git a/swarm-nats-auth/Cargo.toml b/swarm-nats-auth/Cargo.toml index 99f03a91..7ba2da59 100644 --- a/swarm-nats-auth/Cargo.toml +++ b/swarm-nats-auth/Cargo.toml @@ -23,17 +23,16 @@ serde.workspace = true serde_json.workspace = true # The jti digest: base32hex(sha256(claims)) over every JWT this crate signs. sha2.workspace = true -# Comparing an agent's presented secret with the stored one. -subtle.workspace = true # For `status::BUCKET` and `notices::STREAM` - the subjects a hive may # publish to are derived from these names, and every end that touches them # must agree on the same one. Deliberately WITHOUT the `kv` feature: this # crate derives subject strings, it never opens the bucket. `notices` # is name-only too (no `jetstream`/`kv` surface), same reason. swarm-queue-client = { workspace = true, features = ["notices"] } -# The agent-token spelling the agent also uses, and the read of the stored -# credential it is checked against. +# The store reads: the callout keys, its own OIDC secret, and the agent +# credentials `swarm-agent-verify` checks a presented token against. swarm-secret-client.workspace = true +swarm-agent-verify.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/swarm-nats-auth/src/agent_token.rs b/swarm-nats-auth/src/agent_token.rs index c8254033..82f947bc 100644 --- a/swarm-nats-auth/src/agent_token.rs +++ b/swarm-nats-auth/src/agent_token.rs @@ -1,22 +1,15 @@ -//! Verifying an agent's own credential, presented as `auth_token`. +//! Classifying a presented `auth_token`, and the grant for an agent's own. //! //! The spelling is `swarm_queue_client::agent_token`'s, shared with the agent -//! that presents it: 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 nothing in this module grants on the name -//! alone: every path that does not reach that comparison, and every lookup -//! that fails, is a denial. +//! that presents it. Whether its secret is the stored one is +//! [`swarm_agent_verify::verify`]'s answer; nothing here grants on the name +//! alone. //! //! A token without the prefix is not this module's: it goes to introspection //! unchanged. -use std::future::Future; - -use anyhow::Context; -use subtle::ConstantTimeEq; +use swarm_agent_verify::CredentialSource; use swarm_queue_client::agent_token::{AgentToken, Malformed, parse_agent_token}; -use swarm_secret_client::client::{DEFAULT_CERT_MOUNT, Settings}; -use swarm_secret_client::queue::{self, AgentCredential}; use crate::policy::{Permissions, Policy}; @@ -41,112 +34,18 @@ pub fn classify(token: &str) -> Presented<'_> { } } -/// 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 this responder's own certificate. -pub struct Store { - settings: Settings, - cert_role: String, -} - -impl Store { - /// The store at `settings`, logged in to as `cert_role`. - pub fn new(settings: Settings, cert_role: String) -> Self { - Self { - settings, - cert_role, - } - } -} - -impl CredentialSource for Store { - /// Logs in per lookup. An agent connects once per boot and per reconnect, - /// so a login each time costs little, and it leaves no token 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 grant for an agent token, or `None` for a denial. /// -/// The lookup is bounded by introspection's budget, under the server's +/// The store lookup is bounded by introspection's budget, under the server's /// `authorization.timeout`. The callout loop answers one request at a time, so /// a store that does not answer holds every request behind it, OIDC ones /// included, for at most that long, and this then denies. -async fn verify(source: &impl CredentialSource, token: &AgentToken<'_>) -> bool { - let agent = token.agent; - let stored = match tokio::time::timeout( - crate::introspect::INTROSPECTION_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() -} - -/// The grant for an agent token, or `None` for a denial. pub async fn authorize( policy: &Policy, source: &impl CredentialSource, token: &AgentToken<'_>, ) -> Option { - if !verify(source, token).await { + if !swarm_agent_verify::verify(source, token, crate::introspect::INTROSPECTION_TIMEOUT).await { return None; } let permissions = policy.agent_token_permissions(token.agent); @@ -162,47 +61,28 @@ pub async fn authorize( #[cfg(test)] mod tests { use super::*; + use swarm_secret_client::queue::AgentCredential; - /// A store holding at most one credential, or failing outright. + /// A store holding atlas's credential, or one that never answers. + /// The store's own denials are `swarm_agent_verify`'s tests. 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. + Atlas, 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::Atlas => Ok((agent == "atlas").then(|| AgentCredential { + value: "Ab9_-zSECRET".to_owned(), + agent: "atlas".to_owned(), + minted_at: None, + })), 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") - } - fn policy() -> Policy { Policy::new( "hive-".to_owned(), @@ -229,7 +109,7 @@ mod tests { #[tokio::test] async fn a_valid_agent_token_is_granted_exactly_its_own_subjects() { - let g = grant(&atlas(), "swarm-agent.atlas.Ab9_-zSECRET") + let g = grant(&Fake::Atlas, "swarm-agent.atlas.Ab9_-zSECRET") .await .expect("granted"); assert_eq!( @@ -245,42 +125,7 @@ mod tests { #[tokio::test] async fn a_wrong_secret_is_denied() { assert!( - grant(&atlas(), "swarm-agent.atlas.Ab9_-zSECREX") - .await - .is_none() - ); - assert!( - grant(&atlas(), "swarm-agent.atlas.Ab9_-zSECRE") - .await - .is_none() - ); - } - - /// 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!( - grant(&atlas(), "swarm-agent.argus.Ab9_-zSECRET") - .await - .is_none() - ); - } - - #[tokio::test] - async fn an_agent_with_nothing_stored_is_denied() { - assert!( - grant(&stored("argus", "argus"), "swarm-agent.atlas.Ab9_-zSECRET") - .await - .is_none() - ); - } - - #[tokio::test] - async fn a_failed_lookup_is_denied() { - assert!( - grant(&Fake::Fails, "swarm-agent.atlas.Ab9_-zSECRET") + grant(&Fake::Atlas, "swarm-agent.atlas.Ab9_-zSECREX") .await .is_none() ); @@ -305,15 +150,6 @@ mod tests { ); } - #[tokio::test] - async fn a_stored_object_naming_another_agent_is_denied() { - assert!( - grant(&stored("argus", "atlas"), "swarm-agent.argus.Ab9_-zSECRET") - .await - .is_none() - ); - } - #[tokio::test] async fn a_verified_agent_with_no_subjects_configured_is_denied() { let bare = Policy::new( @@ -327,7 +163,7 @@ mod tests { let Presented::Agent(token) = classify("swarm-agent.atlas.Ab9_-zSECRET") else { panic!("an agent token"); }; - assert!(authorize(&bare, &atlas(), &token).await.is_none()); + assert!(authorize(&bare, &Fake::Atlas, &token).await.is_none()); } /// Everything without the prefix reaches introspection as it arrived. diff --git a/swarm-nats-auth/src/main.rs b/swarm-nats-auth/src/main.rs index 31d7d2ce..49bb645b 100644 --- a/swarm-nats-auth/src/main.rs +++ b/swarm-nats-auth/src/main.rs @@ -242,7 +242,7 @@ async fn serve(args: &Args) -> anyhow::Result<()> { .context("logging in to the secret store for the callout keys and the OIDC client secret")?; let (callout_xkey, issuer, user_seed) = load_callout_keys(&keys).await?; let client_secret = load_client_secret(&keys, args).await?; - let store = agent_token::Store::new(settings, args.store_cert_role.clone()); + let store = swarm_agent_verify::Store::new(settings, args.store_cert_role.clone()); let client = async_nats::ConnectOptions::with_nkey(user_seed) .name("swarm-nats-auth") .connect(&args.nats_url)