swarm-controller re-issues an agent's store leaf at half its validity, but the running container kept presenting the copy its hive delivered at start, so a re-issued leaf reached the agent only on its next restart. hive-agent-bao-identity now seeds a live copy of the pair in its own runtime directory from the delivered credentials, and re-runs every 10 minutes: it logs in with the live leaf, reads its own swarm/agents/<agent>/bao-mtls (a read render_agent already grants), and when the leaf changed, logs in with the new pair and swaps it in only if the store accepts it. Every unit in the container that logs in to the store presents the live pair: hive-agent (queue-identity.nix), hive-matrix-daemon, hive-subagent-daemon, hive-agent-forge-token, hive-agent-forge-accounts and hive-agent-github-token. They keep loading the server CA as a credential. The long-running daemons are ordered after the refresher so the live pair exists when they start. It renews only while the held leaf is still valid: the login that reads the new leaf is made with the current one. Refs #5017
175 lines
6.8 KiB
Rust
175 lines
6.8 KiB
Rust
//! The mTLS agreement: where an agent's own identity **at the store** lives,
|
|
//! and what the object at that path holds.
|
|
//!
|
|
//! The sibling of [`crate::matrix`] and [`crate::queue`], and the one that is
|
|
//! about reaching the store rather than about something kept inside it. An
|
|
//! agent's client certificate is minted at swarm level
|
|
//! (`swarm-controller::agent_identity`) and published here; the agent's hive
|
|
//! collects it under the **hive's** own certificate and hands it into the
|
|
//! container.
|
|
//!
|
|
//! 🔑 The recursion this shape looks like it has — *a credential fetched from
|
|
//! the store that is what opens the store* — is broken by who reads it first.
|
|
//! The first reader is the hive, which already holds its own leaf, so nothing
|
|
//! has to already be an agent in order to obtain an agent's identity. After
|
|
//! that the agent re-reads its own path under the leaf it holds, which picks
|
|
//! up a re-issued leaf only while the held one is still valid.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{
|
|
Error,
|
|
path::{Kind, principal_prefix},
|
|
};
|
|
|
|
/// The path holding `agent`'s client certificate for the store itself.
|
|
///
|
|
/// One path per agent with no further segment below it, unlike
|
|
/// [`crate::matrix::account_path`]: an agent has exactly one identity, and a
|
|
/// second one under a name would be a second principal wearing that name.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`,
|
|
/// which is what keeps one agent's name from addressing another's identity.
|
|
pub fn identity_path(agent: &str) -> Result<String, Error> {
|
|
let prefix = principal_prefix(Kind::Agent, agent)?;
|
|
Ok(format!("{prefix}/bao-mtls"))
|
|
}
|
|
|
|
/// What the path holds: the leaf, its private key, and the authority the leaf
|
|
/// was issued from.
|
|
///
|
|
/// All three, because a reader has to reconstruct a usable identity from the
|
|
/// store alone — the same requirement [`crate::queue::Credential`] states for
|
|
/// carrying its client id. The authority rides along so the cert-auth role
|
|
/// and the certificate cannot be delivered from two different sources and
|
|
/// silently disagree; it is public material, unlike the other two fields.
|
|
///
|
|
/// ⚠️ **No `Debug` derive.** See the hand-written impl below: one of these
|
|
/// fields is a private key, and a derived `Debug` would put it in any log line
|
|
/// that ever formatted a node's payload.
|
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Credential {
|
|
/// The agent's client certificate, PEM. The nix-side reader in an agent's
|
|
/// hive spells `bao kv get -field=cert`, so this name is load-bearing for
|
|
/// a reader this crate does not control.
|
|
pub cert: String,
|
|
|
|
/// The private key for [`Credential::cert`], PEM. `bao kv get -field=key`
|
|
/// on the reading side, and `0600` the moment it lands on disk there.
|
|
pub key: String,
|
|
|
|
/// The authority [`Credential::cert`] was issued from, PEM. Public
|
|
/// material: it is also what the store keeps inside the agent's cert-auth
|
|
/// role, by value (see [`crate::client::SecretStore::write_cert_role`]).
|
|
pub ca: String,
|
|
}
|
|
|
|
impl std::fmt::Debug for Credential {
|
|
/// Redacts the key and summarises the two public fields by length.
|
|
///
|
|
/// Hand-written rather than derived because the derive is the failure:
|
|
/// this type is carried through a job-graph node and an `anyhow` context
|
|
/// chain, both of which format whatever they are given.
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("Credential")
|
|
.field("cert", &format_args!("{} bytes of PEM", self.cert.len()))
|
|
.field("key", &"<redacted>")
|
|
.field("ca", &format_args!("{} bytes of PEM", self.ca.len()))
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn an_agent_name_lands_under_its_own_principal_prefix() {
|
|
assert_eq!(
|
|
identity_path("atlas").expect("a plain name is legal"),
|
|
"swarm/agents/atlas/bao-mtls"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_traversal_in_the_agent_name_is_refused() {
|
|
let e = identity_path("../argus").expect_err("a traversal is not");
|
|
assert!(matches!(e, Error::PathSegment { kind: "agent", .. }), "{e}");
|
|
}
|
|
|
|
#[test]
|
|
fn two_agents_never_share_a_path() {
|
|
assert_ne!(
|
|
identity_path("atlas").expect("legal"),
|
|
identity_path("argus").expect("legal")
|
|
);
|
|
}
|
|
|
|
/// The path sits under the same prefix an agent's policy grants
|
|
/// (`policy::render_agent`'s stanza is `swarm/agents/<agent>/*`), which is
|
|
/// what makes an agent able to read its own identity back.
|
|
#[test]
|
|
fn an_agents_identity_is_inside_its_own_policy_stanza() {
|
|
let path = identity_path("atlas").expect("legal");
|
|
let document = crate::policy::render_agent("atlas").expect("legal");
|
|
let prefix = format!("{}/data/swarm/agents/atlas/", crate::path::MOUNT);
|
|
assert!(
|
|
path.starts_with("swarm/agents/atlas/"),
|
|
"the identity must sit under the agent's own prefix, got {path}"
|
|
);
|
|
assert!(
|
|
document.contains(&format!("{prefix}*")),
|
|
"the agent's document must cover {prefix}*, got:\n{document}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_object_round_trips_through_the_store_representation() {
|
|
let c = Credential {
|
|
cert: "-----BEGIN CERTIFICATE-----\n".to_owned(),
|
|
key: "-----BEGIN PRIVATE KEY-----\n".to_owned(),
|
|
ca: "-----BEGIN CERTIFICATE-----\n".to_owned(),
|
|
};
|
|
let json = serde_json::to_string(&c).expect("serialises");
|
|
assert_eq!(
|
|
serde_json::from_str::<Credential>(&json).expect("deserialises"),
|
|
c
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_field_names_the_nix_reader_asks_for_are_the_ones_written() {
|
|
let json = serde_json::to_value(Credential {
|
|
cert: "leaf".to_owned(),
|
|
key: "private".to_owned(),
|
|
ca: "authority".to_owned(),
|
|
})
|
|
.expect("serialises");
|
|
assert_eq!(json["cert"], "leaf");
|
|
assert_eq!(json["key"], "private");
|
|
assert_eq!(json["ca"], "authority");
|
|
}
|
|
|
|
/// The property the hand-written `Debug` exists for: a key that reaches a
|
|
/// log line is a key on a disk somebody else owns.
|
|
#[test]
|
|
fn formatting_the_credential_does_not_reveal_the_key() {
|
|
let rendered = format!(
|
|
"{:?}",
|
|
Credential {
|
|
cert: "leaf".to_owned(),
|
|
key: "SUPER-SECRET-KEY-MATERIAL".to_owned(),
|
|
ca: "authority".to_owned(),
|
|
}
|
|
);
|
|
assert!(
|
|
!rendered.contains("SUPER-SECRET-KEY-MATERIAL"),
|
|
"the key must not survive formatting, got {rendered}"
|
|
);
|
|
assert!(
|
|
rendered.contains("<redacted>"),
|
|
"and the reader must be told it was withheld, got {rendered}"
|
|
);
|
|
}
|
|
}
|