Watch
0
0
Fork
You've already forked hyperhive
0

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.<agent>.<secret>`
token equals the one stored at `swarm/agents/<agent>/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.
This commit is contained in:
atlas 2026-10-12 22:47:37 +02:00 • committed by mara
commit e7dbb94f28
7 changed files with 275 additions and 191 deletions

View file

@ -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

View file

@ -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/<agent>/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<Output = anyhow::Result<Option<AgentCredential>>> + 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<Option<AgentCredential>> {
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<Permissions> {
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<Option<AgentCredential>> {
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.

View file

@ -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)