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:
parent
9e3a00825b
commit
e7dbb94f28
7 changed files with 275 additions and 191 deletions
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -4789,6 +4789,18 @@ version = "2.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "swarm-agent-verify"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"subtle",
|
||||||
|
"swarm-queue-client",
|
||||||
|
"swarm-secret-client",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swarm-authelia-bridge"
|
name = "swarm-authelia-bridge"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -4910,7 +4922,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"subtle",
|
"swarm-agent-verify",
|
||||||
"swarm-queue-client",
|
"swarm-queue-client",
|
||||||
"swarm-secret-client",
|
"swarm-secret-client",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ members = [
|
||||||
"hive-subagent-mcp",
|
"hive-subagent-mcp",
|
||||||
"hive-types",
|
"hive-types",
|
||||||
"hivectl",
|
"hivectl",
|
||||||
|
"swarm-agent-verify",
|
||||||
"swarm-authelia-bridge",
|
"swarm-authelia-bridge",
|
||||||
"swarm-authelia-bridge-sock",
|
"swarm-authelia-bridge-sock",
|
||||||
"swarm-controller",
|
"swarm-controller",
|
||||||
|
|
@ -104,6 +105,7 @@ hive-priv-sock = { path = "hive-priv-sock" }
|
||||||
hive-runtime = { path = "hive-runtime" }
|
hive-runtime = { path = "hive-runtime" }
|
||||||
hive-sock-client = { path = "hive-sock-client" }
|
hive-sock-client = { path = "hive-sock-client" }
|
||||||
hive-types = { path = "hive-types" }
|
hive-types = { path = "hive-types" }
|
||||||
|
swarm-agent-verify = { path = "swarm-agent-verify" }
|
||||||
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
|
swarm-authelia-bridge-sock = { path = "swarm-authelia-bridge-sock" }
|
||||||
swarm-matrix-client = { path = "swarm-matrix-client" }
|
swarm-matrix-client = { path = "swarm-matrix-client" }
|
||||||
swarm-queue-client = { path = "swarm-queue-client" }
|
swarm-queue-client = { path = "swarm-queue-client" }
|
||||||
|
|
@ -234,7 +236,7 @@ futures-util = "0.3"
|
||||||
hmac = "0.13"
|
hmac = "0.13"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
# Constant-time comparison of a presented secret against the stored one
|
# 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"
|
subtle = "2.6"
|
||||||
# The NATS protocol client, for the swarm queue's auth-callout responder.
|
# The NATS protocol client, for the swarm queue's auth-callout responder.
|
||||||
# `default-features = false` because the default set is broad - jetstream, kv,
|
# `default-features = false` because the default set is broad - jetstream, kv,
|
||||||
|
|
|
||||||
18
swarm-agent-verify/Cargo.toml
Normal file
18
swarm-agent-verify/Cargo.toml
Normal file
|
|
@ -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
|
||||||
217
swarm-agent-verify/src/lib.rs
Normal file
217
swarm-agent-verify/src/lib.rs
Normal file
|
|
@ -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/<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,17 +23,16 @@ serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
# The jti digest: base32hex(sha256(claims)) over every JWT this crate signs.
|
# The jti digest: base32hex(sha256(claims)) over every JWT this crate signs.
|
||||||
sha2.workspace = true
|
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
|
# For `status::BUCKET` and `notices::STREAM` - the subjects a hive may
|
||||||
# publish to are derived from these names, and every end that touches them
|
# 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
|
# must agree on the same one. Deliberately WITHOUT the `kv` feature: this
|
||||||
# crate derives subject strings, it never opens the bucket. `notices`
|
# crate derives subject strings, it never opens the bucket. `notices`
|
||||||
# is name-only too (no `jetstream`/`kv` surface), same reason.
|
# is name-only too (no `jetstream`/`kv` surface), same reason.
|
||||||
swarm-queue-client = { workspace = true, features = ["notices"] }
|
swarm-queue-client = { workspace = true, features = ["notices"] }
|
||||||
# The agent-token spelling the agent also uses, and the read of the stored
|
# The store reads: the callout keys, its own OIDC secret, and the agent
|
||||||
# credential it is checked against.
|
# credentials `swarm-agent-verify` checks a presented token against.
|
||||||
swarm-secret-client.workspace = true
|
swarm-secret-client.workspace = true
|
||||||
|
swarm-agent-verify.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -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
|
//! 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
|
//! that presents it. Whether its secret is the stored one is
|
||||||
//! claim. It becomes an identity when the secret equals the one stored at
|
//! [`swarm_agent_verify::verify`]'s answer; nothing here grants on the name
|
||||||
//! `swarm/agents/<agent>/queue`, and nothing in this module grants on the name
|
//! alone.
|
||||||
//! alone: every path that does not reach that comparison, and every lookup
|
|
||||||
//! that fails, is a denial.
|
|
||||||
//!
|
//!
|
||||||
//! A token without the prefix is not this module's: it goes to introspection
|
//! A token without the prefix is not this module's: it goes to introspection
|
||||||
//! unchanged.
|
//! unchanged.
|
||||||
|
|
||||||
use std::future::Future;
|
use swarm_agent_verify::CredentialSource;
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use subtle::ConstantTimeEq;
|
|
||||||
use swarm_queue_client::agent_token::{AgentToken, Malformed, parse_agent_token};
|
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};
|
use crate::policy::{Permissions, Policy};
|
||||||
|
|
||||||
|
|
@ -41,112 +34,18 @@ pub fn classify(token: &str) -> Presented<'_> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the stored credential for an agent comes from.
|
/// The grant for an agent token, or `None` for a denial.
|
||||||
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 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
|
/// `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
|
/// a store that does not answer holds every request behind it, OIDC ones
|
||||||
/// included, for at most that long, and this then denies.
|
/// 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(
|
pub async fn authorize(
|
||||||
policy: &Policy,
|
policy: &Policy,
|
||||||
source: &impl CredentialSource,
|
source: &impl CredentialSource,
|
||||||
token: &AgentToken<'_>,
|
token: &AgentToken<'_>,
|
||||||
) -> Option<Permissions> {
|
) -> Option<Permissions> {
|
||||||
if !verify(source, token).await {
|
if !swarm_agent_verify::verify(source, token, crate::introspect::INTROSPECTION_TIMEOUT).await {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let permissions = policy.agent_token_permissions(token.agent);
|
let permissions = policy.agent_token_permissions(token.agent);
|
||||||
|
|
@ -162,47 +61,28 @@ pub async fn authorize(
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 {
|
enum Fake {
|
||||||
/// `credential` is stored at `path_agent`'s queue path.
|
Atlas,
|
||||||
Holds {
|
|
||||||
path_agent: &'static str,
|
|
||||||
credential: AgentCredential,
|
|
||||||
},
|
|
||||||
Fails,
|
|
||||||
/// A store that accepts the request and never answers.
|
|
||||||
Hangs,
|
Hangs,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CredentialSource for Fake {
|
impl CredentialSource for Fake {
|
||||||
async fn lookup(&self, agent: &str) -> anyhow::Result<Option<AgentCredential>> {
|
async fn lookup(&self, agent: &str) -> anyhow::Result<Option<AgentCredential>> {
|
||||||
match self {
|
match self {
|
||||||
Self::Holds {
|
Self::Atlas => Ok((agent == "atlas").then(|| AgentCredential {
|
||||||
path_agent,
|
value: "Ab9_-zSECRET".to_owned(),
|
||||||
credential,
|
agent: "atlas".to_owned(),
|
||||||
} => Ok((agent == *path_agent).then(|| credential.clone())),
|
minted_at: None,
|
||||||
Self::Fails => anyhow::bail!("store unreachable"),
|
})),
|
||||||
Self::Hangs => std::future::pending().await,
|
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 {
|
fn policy() -> Policy {
|
||||||
Policy::new(
|
Policy::new(
|
||||||
"hive-".to_owned(),
|
"hive-".to_owned(),
|
||||||
|
|
@ -229,7 +109,7 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_valid_agent_token_is_granted_exactly_its_own_subjects() {
|
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
|
.await
|
||||||
.expect("granted");
|
.expect("granted");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -245,42 +125,7 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_wrong_secret_is_denied() {
|
async fn a_wrong_secret_is_denied() {
|
||||||
assert!(
|
assert!(
|
||||||
grant(&atlas(), "swarm-agent.atlas.Ab9_-zSECREX")
|
grant(&Fake::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")
|
|
||||||
.await
|
.await
|
||||||
.is_none()
|
.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]
|
#[tokio::test]
|
||||||
async fn a_verified_agent_with_no_subjects_configured_is_denied() {
|
async fn a_verified_agent_with_no_subjects_configured_is_denied() {
|
||||||
let bare = Policy::new(
|
let bare = Policy::new(
|
||||||
|
|
@ -327,7 +163,7 @@ mod tests {
|
||||||
let Presented::Agent(token) = classify("swarm-agent.atlas.Ab9_-zSECRET") else {
|
let Presented::Agent(token) = classify("swarm-agent.atlas.Ab9_-zSECRET") else {
|
||||||
panic!("an agent token");
|
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.
|
/// Everything without the prefix reaches introspection as it arrived.
|
||||||
|
|
|
||||||
|
|
@ -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")?;
|
.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 (callout_xkey, issuer, user_seed) = load_callout_keys(&keys).await?;
|
||||||
let client_secret = load_client_secret(&keys, args).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)
|
let client = async_nats::ConnectOptions::with_nkey(user_seed)
|
||||||
.name("swarm-nats-auth")
|
.name("swarm-nats-auth")
|
||||||
.connect(&args.nats_url)
|
.connect(&args.nats_url)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue