render_agent gains a second stanza: list on secret/metadata/swarm/agents/<agent>/*, next to the existing read on secret/data/swarm/agents/<agent>/*. An agent can now learn which credentials it holds by listing its own subtree. Metadata read, writes and every other principal's paths stay refused. An agent's policy was only written when it was minted, so existing agents would never get the new stanza. swarm-controller now rewrites every agent's policy at start (read_policy::ensure_agent_policies), with the same 30s / 24h retry as ensure_hive_access. The roster is the store's hive-agent-* cert-auth roles, listed with the controller's existing `list` on auth/cert/certs; the writes use its existing grant on sys/policies/acl/hive-*. Only the policy is written: mint_and_verify also reissues the certificate, so the pass does not call it. Refs #4348
461 lines
20 KiB
Rust
461 lines
20 KiB
Rust
//! One agent's own identity at the swarm's secret store: issued by the store,
|
||
//! published here, granted here — and, before the job node reports success,
|
||
//! **used** here.
|
||
//!
|
||
//! The swarm obtains the agent's certificate so that no hive ever needs the
|
||
//! capability to obtain one; the hive only carries it down. The controller is
|
||
//! the swarm-level service that asks because it already logs in to the store,
|
||
//! and its grant covers exactly the calls made here (`swarm-bao.nix`'s
|
||
//! `controllerPolicyText`: `update` on the agent PKI role's `issue` path, and
|
||
//! `create/update` on `secret/data/swarm/agents/*`, on
|
||
//! `sys/policies/acl/hive-*`, and on `auth/cert/certs/hive-*`).
|
||
//!
|
||
//! **Two credentials, deliberately unrelated.** The certificate reaches the
|
||
//! store; the queue secret identifies the agent to the swarm queue. Both sit
|
||
//! under `swarm/agents/<agent>`, so neither costs a grant — but the second is
|
||
//! not derived from the first, so either renews without reference to the other.
|
||
//!
|
||
//! Four separate strings have to agree before an agent can authenticate: the
|
||
//! policy's name, the cert-auth role's name, the certificate's common name,
|
||
//! and the authority the role pins. That is the kind of agreement that holds
|
||
//! in review and fails in production — a mismatch is a 403 naming none of the
|
||
//! four — so [`mint_and_verify`] does not finish on a write. See
|
||
//! [`read_back_as_agent`].
|
||
//!
|
||
//! The authority is the store's own agent CA, generated inside its agent PKI
|
||
//! mount; its key never leaves the store. It signs no host leaf, and no host
|
||
//! role pins it, so an agent's certificate satisfies only that agent's role.
|
||
//! [`crate::agent_renewal`] re-issues a live agent's leaf once it is past half
|
||
//! its validity (the role's `ttl`), by queueing the same node as creation.
|
||
|
||
use anyhow::{Context, Result, bail};
|
||
use swarm_secret_client::{
|
||
SecretStore,
|
||
client::{DEFAULT_CERT_MOUNT, Settings},
|
||
mtls, policy, queue,
|
||
};
|
||
|
||
/// The PKI mount agent leaves are issued from, as `swarm-controller.nix` sets
|
||
/// it from `deploy.bao.agentPkiMountPath`.
|
||
pub const ENV_AGENT_PKI_MOUNT: &str = "SWARM_CONTROLLER_AGENT_PKI_MOUNT";
|
||
|
||
/// The role on [`ENV_AGENT_PKI_MOUNT`] agent leaves are issued through, as
|
||
/// `swarm-controller.nix` sets it from `deploy.bao.agentPkiRoleName`.
|
||
pub const ENV_AGENT_PKI_ROLE: &str = "SWARM_CONTROLLER_AGENT_PKI_ROLE";
|
||
|
||
/// Where agent leaves are issued: `(mount, role)`, read from `get`.
|
||
///
|
||
/// # Errors
|
||
/// Naming the first of the two variables that is unset or empty.
|
||
fn agent_pki(get: impl Fn(&str) -> Option<String>) -> Result<(String, String)> {
|
||
let required = |var: &str| -> Result<String> {
|
||
get(var)
|
||
.filter(|v| !v.is_empty())
|
||
.with_context(|| format!("{var} is unset or empty, so no agent leaf can be issued"))
|
||
};
|
||
Ok((
|
||
required(ENV_AGENT_PKI_MOUNT)?,
|
||
required(ENV_AGENT_PKI_ROLE)?,
|
||
))
|
||
}
|
||
|
||
/// What the cert-auth role for `agent` is written from.
|
||
struct RoleInputs<'a> {
|
||
/// Role name, policy name and the common name the role matches: one string.
|
||
name: String,
|
||
/// The authority the role pins: the one that signed this very leaf.
|
||
ca: &'a str,
|
||
/// The policy document the role attaches.
|
||
policy: String,
|
||
}
|
||
|
||
fn role_inputs<'a>(agent: &str, credential: &'a mtls::Credential) -> Result<RoleInputs<'a>> {
|
||
Ok(RoleInputs {
|
||
name: policy::agent_object_name(agent)?,
|
||
ca: &credential.ca,
|
||
policy: policy::render_agent(agent)?,
|
||
})
|
||
}
|
||
|
||
/// How many bytes of kernel randomness a queue secret is before encoding.
|
||
///
|
||
/// Thirty-two because the secret is a bearer token compared for equality and
|
||
/// nothing else — there is no work factor and no rate limit behind it, so the
|
||
/// only defence is that guessing is not worth attempting. Encoded it is 43
|
||
/// characters.
|
||
const QUEUE_SECRET_BYTES: usize = 32;
|
||
|
||
/// Generate a queue secret: [`QUEUE_SECRET_BYTES`] from the kernel's CSPRNG,
|
||
/// base64url without padding.
|
||
///
|
||
/// The alphabet matters and is the reason for `URL_SAFE_NO_PAD` rather than
|
||
/// the standard engine: the secret is destined to be carried in a token the
|
||
/// verifying end splits on `.`, so it must not be able to contain one. This
|
||
/// alphabet is `[A-Za-z0-9_-]`, and `=` padding is dropped as well so the
|
||
/// value survives anything that treats it as a word.
|
||
///
|
||
/// # Errors
|
||
/// When the kernel will not supply randomness. Bubbled rather than panicked
|
||
/// on: the caller is a job node that reports a named failure, and a secret
|
||
/// from a degraded source is worse than no secret.
|
||
pub(crate) fn generate_queue_secret() -> Result<String> {
|
||
let mut bytes = [0u8; QUEUE_SECRET_BYTES];
|
||
getrandom::fill(&mut bytes).context("drawing a queue secret from the kernel's CSPRNG")?;
|
||
Ok(base64::Engine::encode(
|
||
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
||
bytes,
|
||
))
|
||
}
|
||
|
||
/// Give `agent` an identity at the store, and prove it works.
|
||
///
|
||
/// Five store calls' worth of agreement, then the login that checks it:
|
||
///
|
||
/// 1. have the agent PKI role issue a leaf whose common name is
|
||
/// [`policy::agent_object_name`]; the store generates its key;
|
||
/// 2. publish it at [`mtls::identity_path`], where the agent's hive collects
|
||
/// it under the hive's own certificate;
|
||
/// 3. publish a queue secret at [`queue::agent_queue_path`] — the agent's own
|
||
/// identity at the swarm queue, minted here so that the credential an agent
|
||
/// presents names *it* rather than its hive;
|
||
/// 4. write the ACL document [`policy::render_agent`] renders — read and list on
|
||
/// this one agent's paths and nothing else;
|
||
/// 5. write the cert-auth role that ties the three together, pinning the CA
|
||
/// the store named as this leaf's issuer.
|
||
///
|
||
/// Policy before role, for the reason `read_policy::provision` gives: the role
|
||
/// names the policy, so the other order leaves a window in which it points at
|
||
/// nothing.
|
||
///
|
||
/// ⚠️ **Step 3 is idempotent and steps 1–2 are not.** Re-running issues a
|
||
/// fresh leaf, picked up on the agent's next boot, but leaves an existing
|
||
/// queue secret alone: this is re-run against running agents, which hold that
|
||
/// secret in a live connection. Revoking one means deleting the path;
|
||
/// replacing one by age is [`crate::agent_renewal`]'s.
|
||
///
|
||
/// # Errors
|
||
/// Anything that stops one of those five steps, with the step named. A
|
||
/// failure here fails the job node and nothing else — the agent is still
|
||
/// created, without a store identity.
|
||
pub async fn mint_and_verify(agent: &str) -> Result<()> {
|
||
let (mount, pki_role) = agent_pki(|k| std::env::var(k).ok())?;
|
||
let name = policy::agent_object_name(agent)?;
|
||
let path = mtls::identity_path(agent)?;
|
||
let queue_path = queue::agent_queue_path(agent)?;
|
||
|
||
let store = crate::store::connect()
|
||
.await
|
||
.context("logging in to the swarm secret store")?;
|
||
let credential = store
|
||
.issue_client_certificate(&mount, &pki_role, &name)
|
||
.await
|
||
.with_context(|| format!("issuing {name}'s certificate from {mount}/issue/{pki_role}"))?;
|
||
store
|
||
.write(&path, &credential)
|
||
.await
|
||
.with_context(|| format!("publishing the agent identity at {path}"))?;
|
||
|
||
// Read before write, and `read_optional` rather than `read`, so that only
|
||
// a genuine 404 leads to a new secret — see that method for why every
|
||
// other failure has to stay a failure here.
|
||
let existing: Option<queue::AgentCredential> = store
|
||
.read_optional(&queue_path)
|
||
.await
|
||
.with_context(|| format!("checking whether {queue_path} already holds a credential"))?;
|
||
// The secret and its mint time survive a re-run. An object naming a
|
||
// different agent is corrected by rewriting the name around the *same*
|
||
// `value`, which no live connection notices.
|
||
let wanted = match &existing {
|
||
Some(existing) => queue::AgentCredential {
|
||
agent: agent.to_owned(),
|
||
..existing.clone()
|
||
},
|
||
None => crate::agent_renewal::fresh(agent, crate::agent_renewal::unix_now())?,
|
||
};
|
||
if existing.as_ref() == Some(&wanted) {
|
||
tracing::info!(
|
||
agent,
|
||
%queue_path,
|
||
"agent queue credential already published; left as it is"
|
||
);
|
||
} else {
|
||
store
|
||
.write(&queue_path, &wanted)
|
||
.await
|
||
.with_context(|| format!("publishing the agent queue credential at {queue_path}"))?;
|
||
tracing::info!(
|
||
agent,
|
||
%queue_path,
|
||
// Never "rotated": the secret is the same one, only the name
|
||
// around it moved.
|
||
corrected = existing.is_some(),
|
||
"agent queue credential published"
|
||
);
|
||
}
|
||
let queue_credential = wanted;
|
||
|
||
let inputs = role_inputs(agent, &credential)?;
|
||
store
|
||
.write_policy(&inputs.name, &inputs.policy)
|
||
.await
|
||
.with_context(|| format!("writing the read policy {}", inputs.name))?;
|
||
store
|
||
.write_cert_role(
|
||
DEFAULT_CERT_MOUNT,
|
||
&inputs.name,
|
||
inputs.ca,
|
||
&inputs.name,
|
||
&inputs.name,
|
||
)
|
||
.await
|
||
.with_context(|| format!("writing the cert-auth role {}", inputs.name))?;
|
||
tracing::info!(agent, %path, role = %name, "agent store identity published");
|
||
|
||
read_back_as_agent(&credential, &name, &path, &queue_path, &queue_credential).await?;
|
||
tracing::info!(
|
||
agent,
|
||
role = %name,
|
||
"agent store identity verified: the issued leaf logged in and read both its own paths"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// Revoke `agent`'s queue credential: delete the path
|
||
/// [`mint_and_verify`]'s step 3 published, and everything ever written at it.
|
||
///
|
||
/// The undo of that one step and of no other. The leaf, the ACL document and
|
||
/// the cert-auth role that make up the rest of an agent's identity stay where
|
||
/// they are — they are what a *hive* uses to collect an agent's secrets, they
|
||
/// are minted afresh on every run of the mint, and tearing them down is not
|
||
/// what the queue credential outliving its holder is about.
|
||
///
|
||
/// **Deletes every version, not the newest one.** The mint rewrites this path
|
||
/// whenever the principal it names has to be corrected, so a soft delete would
|
||
/// leave the identical secret sitting in version history, readable at
|
||
/// `?version=N` by anything that can read the path at all — a value still
|
||
/// recoverable has not been revoked. See
|
||
/// [`SecretStore::delete_all_versions`][swarm_secret_client::SecretStore::delete_all_versions].
|
||
///
|
||
/// **Idempotent**: revoking an agent that never had a credential, or one
|
||
/// already revoked, succeeds and says so. A teardown that runs twice is
|
||
/// ordinary, and a second run that failed would be a worse fault than the one
|
||
/// this exists to fix.
|
||
///
|
||
/// # Errors
|
||
/// When the store cannot be reached or refuses the delete. The caller decides
|
||
/// what that costs — `set_agent_state` logs it and lets the destroy proceed,
|
||
/// since a credential that is still live is a smaller harm than an agent that
|
||
/// cannot be torn down.
|
||
pub async fn revoke_queue_credential(agent: &str) -> Result<()> {
|
||
let queue_path = queue::agent_queue_path(agent)?;
|
||
let store = crate::store::connect()
|
||
.await
|
||
.context("logging in to the swarm secret store")?;
|
||
store
|
||
.delete_all_versions(&queue_path)
|
||
.await
|
||
.with_context(|| format!("revoking the agent queue credential at {queue_path}"))?;
|
||
// At `info` and unconditional: an unlogged revocation is indistinguishable
|
||
// from a leak, and this line is the only record an operator has that the
|
||
// credential stopped being usable. It cannot say whether one was there —
|
||
// the controller's grant on these paths is write-only by design, so it
|
||
// deletes blind.
|
||
tracing::info!(
|
||
agent,
|
||
%queue_path,
|
||
"agent queue credential revoked: every version of the path deleted"
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// The consumer of everything [`mint_and_verify`] wrote: log in **as the
|
||
/// agent**, with the leaf just issued, and read back both paths just
|
||
/// published.
|
||
///
|
||
/// The address and the store's CA come from this process's own `BAO_*`
|
||
/// environment; the *identity* deliberately does not — see
|
||
/// [`SecretStore::connect_with_identity`]. The freshly issued private key
|
||
/// never touches a filesystem.
|
||
///
|
||
/// Both paths, not just the certificate's, for the reason this function
|
||
/// exists at all: the queue credential is readable only because it sits inside
|
||
/// the stanza [`policy::render_agent`] already grants, and a policy that
|
||
/// drifted from that path would otherwise fail at the agent's first connection
|
||
/// — far from here, as a denial naming neither the policy nor the path.
|
||
///
|
||
/// # Errors
|
||
/// When the store refuses the login (the role, the authority or the common
|
||
/// name disagree), when either read is denied (the policy does not cover the
|
||
/// path, or the role attached the wrong policy), or when what comes back is
|
||
/// not what went in.
|
||
async fn read_back_as_agent(
|
||
credential: &mtls::Credential,
|
||
role: &str,
|
||
path: &str,
|
||
queue_path: &str,
|
||
queue_credential: &queue::AgentCredential,
|
||
) -> Result<()> {
|
||
let settings = Settings::from_env()
|
||
.context("reading this daemon's own store settings for the read-back")?;
|
||
|
||
// The concatenated blob `connect_with_identity` takes, built in memory.
|
||
let mut identity = credential.cert.clone().into_bytes();
|
||
identity.push(b'\n');
|
||
identity.extend_from_slice(credential.key.as_bytes());
|
||
|
||
let as_agent =
|
||
SecretStore::connect_with_identity(&settings, &identity, role, DEFAULT_CERT_MOUNT)
|
||
.await
|
||
.with_context(|| {
|
||
format!("logging in to the store as {role} with the leaf just issued")
|
||
})?;
|
||
let read_back: mtls::Credential = as_agent
|
||
.read(path)
|
||
.await
|
||
.with_context(|| format!("reading {path} back under {role}'s own token"))?;
|
||
|
||
// Compared, not merely decoded: a successful read of an object written by
|
||
// some earlier run would otherwise pass this check while this run's leaf
|
||
// was the one nobody could use. Nothing about either value is printed.
|
||
if read_back != *credential {
|
||
bail!("the store returned a different object at {path} than the one just published");
|
||
}
|
||
|
||
let read_back: queue::AgentCredential = as_agent
|
||
.read(queue_path)
|
||
.await
|
||
.with_context(|| format!("reading {queue_path} back under {role}'s own token"))?;
|
||
// The agent is compared as well as the secret: the verifying end refuses
|
||
// an object naming a different agent than the path, so a mismatch here is
|
||
// the same class of fault as an unreadable path.
|
||
if read_back != *queue_credential {
|
||
bail!("the store returned a different object at {queue_path} than the one just published");
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{
|
||
ENV_AGENT_PKI_MOUNT, ENV_AGENT_PKI_ROLE, QUEUE_SECRET_BYTES, agent_pki,
|
||
generate_queue_secret, role_inputs,
|
||
};
|
||
use swarm_secret_client::{mtls, policy};
|
||
|
||
/// The alphabet claim the token format rests on: the secret is carried in
|
||
/// a composite the verifying end splits on `.`, so a secret that could
|
||
/// contain one would make that split ambiguous. Also the entropy claim —
|
||
/// a generator that silently returned a short or constant value would pass
|
||
/// every other test in this file.
|
||
#[test]
|
||
fn a_queue_secret_is_high_entropy_and_carries_no_separator() {
|
||
let a = generate_queue_secret().expect("the kernel supplies randomness");
|
||
let b = generate_queue_secret().expect("twice");
|
||
assert_ne!(a, b, "two draws must not agree");
|
||
|
||
// base64 without padding: one character per six bits, rounded up.
|
||
assert_eq!(a.len(), (QUEUE_SECRET_BYTES * 8).div_ceil(6));
|
||
assert!(
|
||
a.chars()
|
||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
|
||
"{a}"
|
||
);
|
||
assert!(!a.contains('.'), "{a}");
|
||
assert!(!a.contains('='), "{a}");
|
||
}
|
||
|
||
/// Revocation undoes step 3 of the mint and nothing else, and both ends
|
||
/// spell the path through one function — the property that keeps a
|
||
/// revocation from missing the object it is meant to remove.
|
||
///
|
||
/// The second half is what stops this being a tautology: the agent's
|
||
/// other published object, the mTLS leaf, is at a different path and is
|
||
/// deliberately left alone. Revoking both would take away the identity a
|
||
/// re-created agent is re-minted under, for a credential problem that is
|
||
/// only about the queue.
|
||
#[test]
|
||
fn revocation_names_the_path_the_mint_published_and_not_the_leaf_beside_it() {
|
||
use swarm_secret_client::{mtls, queue};
|
||
|
||
assert_eq!(
|
||
queue::agent_queue_path("atlas").expect("a plain name is legal"),
|
||
"swarm/agents/atlas/queue"
|
||
);
|
||
assert_ne!(
|
||
queue::agent_queue_path("atlas").expect("legal"),
|
||
mtls::identity_path("atlas").expect("legal"),
|
||
);
|
||
}
|
||
|
||
/// A name the store must never be asked to delete under. `revoke_queue_credential`
|
||
/// builds its path with the same validating function the mint does, so a
|
||
/// traversal is refused before a request is made rather than addressing
|
||
/// some other principal's secret.
|
||
#[test]
|
||
fn a_traversal_never_becomes_a_revocation() {
|
||
swarm_secret_client::queue::agent_queue_path("../pr1ma")
|
||
.expect_err("a traversal is not a legal agent name");
|
||
}
|
||
|
||
fn both(k: &str) -> Option<String> {
|
||
match k {
|
||
ENV_AGENT_PKI_MOUNT => Some("pki-agents".to_owned()),
|
||
ENV_AGENT_PKI_ROLE => Some("swarm-agent".to_owned()),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_issue_path_comes_from_the_two_variables_the_module_sets() {
|
||
assert_eq!(
|
||
agent_pki(both).expect("both set"),
|
||
("pki-agents".to_owned(), "swarm-agent".to_owned())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_missing_or_empty_pki_variable_is_named() {
|
||
for var in [ENV_AGENT_PKI_MOUNT, ENV_AGENT_PKI_ROLE] {
|
||
let unset = agent_pki(|k| if k == var { None } else { both(k) })
|
||
.expect_err("one variable is unset");
|
||
assert!(format!("{unset:#}").contains(var), "{unset:#}");
|
||
let empty = agent_pki(|k| {
|
||
if k == var {
|
||
Some(String::new())
|
||
} else {
|
||
both(k)
|
||
}
|
||
})
|
||
.expect_err("one variable is empty");
|
||
assert!(format!("{empty:#}").contains(var), "{empty:#}");
|
||
}
|
||
}
|
||
|
||
/// Three of the four strings that must agree, checked where this process
|
||
/// sets them: role, policy and matched CN are one name; the pinned CA is
|
||
/// the issuer the store reported for this leaf; the policy is the agent's
|
||
/// own single stanza.
|
||
#[test]
|
||
fn the_role_pins_the_leafs_own_issuer_under_the_agents_name() {
|
||
let credential = mtls::Credential {
|
||
cert: "LEAF".to_owned(),
|
||
key: "KEY".to_owned(),
|
||
ca: "AGENT-CA".to_owned(),
|
||
};
|
||
let inputs = role_inputs("atlas", &credential).expect("legal");
|
||
assert_eq!(inputs.name, "hive-agent-atlas");
|
||
assert_eq!(
|
||
inputs.name,
|
||
policy::agent_object_name("atlas").expect("legal"),
|
||
"the CN the leaf is issued for"
|
||
);
|
||
assert_eq!(inputs.ca, "AGENT-CA");
|
||
assert_eq!(inputs.policy, policy::render_agent("atlas").expect("legal"));
|
||
assert!(!inputs.policy.contains("swarm/hives/"), "{}", inputs.policy);
|
||
|
||
// The control: another agent's inputs differ in both name and grant.
|
||
let other = role_inputs("argus", &credential).expect("legal");
|
||
assert_ne!(other.name, inputs.name);
|
||
assert!(!other.policy.contains("agents/atlas/"), "{}", other.policy);
|
||
}
|
||
}
|