174 lines
6.7 KiB
Rust
174 lines
6.7 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 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_shared_agent`'s stanza is `swarm/agents/<entity name>/*`), 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::effective_agent("atlas");
|
|
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}"
|
|
);
|
|
}
|
|
}
|