move agent_token from swarm-queue-client to swarm-agent-verify
per #5471, verifiers stop depending on the queue client. the module is store-free and needs only thiserror, so swarm-agent-verify gains one dependency and loses its swarm-queue-client edge. consumers import it from swarm_agent_verify; swarm-logs and swarm-metrics, whose only use of swarm-queue-client was this module, drop that dependency, as does swarm-logs-auth.
This commit is contained in:
parent
741fe388f9
commit
87b1f94ac6
18 changed files with 34 additions and 33 deletions
|
|
@ -7,10 +7,9 @@ edition.workspace = true
|
|||
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
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
|
|
|
|||
153
swarm-agent-verify/src/agent_token.rs
Normal file
153
swarm-agent-verify/src/agent_token.rs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
//! The one spelling of the token an agent presents its own queue secret in,
|
||||
//! shared by the agent that formats it and the verifiers that parse it back:
|
||||
//! [`AGENT_TOKEN_PREFIX`], the agent's name, `.`, the secret.
|
||||
//!
|
||||
//! The secret itself lives at `swarm/agents/<agent>/queue` in the swarm's
|
||||
//! secret store (`swarm_secret_client::queue`). This module knows nothing of
|
||||
//! the store.
|
||||
|
||||
/// Marks an `auth_token` as an agent's own credential.
|
||||
///
|
||||
/// An OIDC access token may itself contain `.`, so the `<agent>.<secret>`
|
||||
/// shape alone does not tell the two apart. Authelia's access tokens start
|
||||
/// `authelia_at_`.
|
||||
pub const AGENT_TOKEN_PREFIX: &str = "swarm-agent.";
|
||||
|
||||
/// An agent's own credential as presented at the queue.
|
||||
///
|
||||
/// No `Debug`: `secret` is the credential itself.
|
||||
pub struct AgentToken<'a> {
|
||||
/// The agent the presenter claims to be. Unproven until `secret` is
|
||||
/// checked against that agent's stored credential.
|
||||
pub agent: &'a str,
|
||||
/// The presented secret.
|
||||
pub secret: &'a str,
|
||||
}
|
||||
|
||||
/// A token that is not `<agent>.<secret>` after its prefix, or parts that
|
||||
/// would not make one. Carries the reason only: a token holds a secret.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("malformed agent token: {0}")]
|
||||
pub struct Malformed(pub &'static str);
|
||||
|
||||
/// Spell `agent`'s credential as the token it presents at the queue.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Malformed`] when `agent` or `secret` is empty or holds anything outside
|
||||
/// `[A-Za-z0-9_-]`. Either would make [`parse_agent_token`] split the token
|
||||
/// differently than it was joined.
|
||||
pub fn format_agent_token(agent: &str, secret: &str) -> Result<String, Malformed> {
|
||||
check_agent(agent)?;
|
||||
check_secret(secret)?;
|
||||
Ok(format!("{AGENT_TOKEN_PREFIX}{agent}.{secret}"))
|
||||
}
|
||||
|
||||
/// Read a presented token back into an [`AgentToken`].
|
||||
///
|
||||
/// `None` when `token` does not start with [`AGENT_TOKEN_PREFIX`]: it is some
|
||||
/// other kind of token, not a malformed one of these. `Some(Err(_))` when it
|
||||
/// does and is not exactly `<agent>.<secret>` after it.
|
||||
#[must_use]
|
||||
pub fn parse_agent_token(token: &str) -> Option<Result<AgentToken<'_>, Malformed>> {
|
||||
let rest = token.strip_prefix(AGENT_TOKEN_PREFIX)?;
|
||||
Some(
|
||||
rest.split_once('.')
|
||||
.ok_or(Malformed("no `.` between the agent and the secret"))
|
||||
.and_then(|(agent, secret)| {
|
||||
check_agent(agent)?;
|
||||
check_secret(secret)?;
|
||||
Ok(AgentToken { agent, secret })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_segment(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
}
|
||||
|
||||
/// The alphabet a store path segment allows, so the name cannot widen a
|
||||
/// subject with `.`, `*` or `>`, or address another agent's path.
|
||||
fn check_agent(agent: &str) -> Result<(), Malformed> {
|
||||
if is_segment(agent) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Malformed("the agent is not a single [A-Za-z0-9_-] segment"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The alphabet the controller mints secrets in, base64url without padding.
|
||||
/// The error never carries the value.
|
||||
fn check_secret(secret: &str) -> Result<(), Malformed> {
|
||||
if is_segment(secret) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Malformed(
|
||||
"the secret is empty or holds a byte outside [A-Za-z0-9_-]",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_agent_token_round_trips() {
|
||||
let token = format_agent_token("atlas", "Ab9_-z").expect("legal");
|
||||
assert_eq!(token, "swarm-agent.atlas.Ab9_-z");
|
||||
let parsed = parse_agent_token(&token)
|
||||
.expect("carries the prefix")
|
||||
.expect("well-formed");
|
||||
assert_eq!(parsed.agent, "atlas");
|
||||
assert_eq!(parsed.secret, "Ab9_-z");
|
||||
}
|
||||
|
||||
/// Anything without the prefix belongs to the OIDC path, including a
|
||||
/// token that happens to look like `<agent>.<secret>`.
|
||||
#[test]
|
||||
fn a_token_without_the_prefix_is_not_an_agent_token() {
|
||||
for token in ["authelia_at_abc.def", "atlas.s3cr3t", "", "swarm-agent"] {
|
||||
assert!(parse_agent_token(token).is_none(), "{token:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_agent_token_is_refused() {
|
||||
for token in [
|
||||
"swarm-agent.",
|
||||
"swarm-agent.atlas",
|
||||
"swarm-agent.atlas.",
|
||||
"swarm-agent..s3cr3t",
|
||||
"swarm-agent.atlas.s3.cr3t",
|
||||
"swarm-agent.at*las.s3cr3t",
|
||||
"swarm-agent.at>las.s3cr3t",
|
||||
"swarm-agent.at/las.s3cr3t",
|
||||
"swarm-agent.atlas.s3cr3t=",
|
||||
"swarm-agent.atlas.s3 cr3t",
|
||||
] {
|
||||
assert!(
|
||||
matches!(parse_agent_token(token), Some(Err(_))),
|
||||
"{token:?} must be refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What formats always parses back into the same two parts.
|
||||
#[test]
|
||||
fn formatting_refuses_what_parsing_would_split_differently() {
|
||||
assert!(format_agent_token("at.las", "s3cr3t").is_err());
|
||||
assert!(format_agent_token("atlas", "s3.cr3t").is_err());
|
||||
assert!(format_agent_token("atlas", "").is_err());
|
||||
assert!(format_agent_token("", "s3cr3t").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_secret_is_not_echoed_in_the_error() {
|
||||
let Some(Err(e)) = parse_agent_token("swarm-agent.atlas.hunter2!") else {
|
||||
panic!("must be refused");
|
||||
};
|
||||
assert!(!e.to_string().contains("hunter2"), "{e}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +1,27 @@
|
|||
//! Checking an agent's own credential against the swarm secret store.
|
||||
//!
|
||||
//! The token is `swarm_queue_client::agent_token`'s spelling: a prefix, the
|
||||
//! The token is [`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.
|
||||
//!
|
||||
//! [`agent_token`] also holds the spelling the agents, `swarm-logs` and
|
||||
//! `swarm-metrics` format their token in.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
pub mod agent_token;
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::agent_token::AgentToken;
|
||||
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};
|
||||
|
||||
|
|
@ -118,7 +123,7 @@ pub async fn verify(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use swarm_queue_client::agent_token::parse_agent_token;
|
||||
use crate::agent_token::parse_agent_token;
|
||||
|
||||
/// A store holding at most one credential, or failing outright.
|
||||
enum Fake {
|
||||
|
|
|
|||
Loading…
Reference in a new issue