Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-secret-client/src/mtls.rs

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}"
);
}
}