`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.
217 lines
7 KiB
Rust
217 lines
7 KiB
Rust
//! 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/<agent>/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<Output = anyhow::Result<Option<AgentCredential>>> + 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<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 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<Option<AgentCredential>> {
|
|
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);
|
|
}
|
|
}
|