mint per-agent proxy credentials and a per-hive squid self-check credential
This commit is contained in:
parent
d452da09ae
commit
ec06a91deb
1 changed files with 487 additions and 4 deletions
153
swarm-secret-client/src/proxy.rs
Normal file
153
swarm-secret-client/src/proxy.rs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
//! The egress-proxy agreement: the credentials squid authenticates its clients
|
||||
//! with, where they live in the store, and what the objects at those paths hold.
|
||||
//!
|
||||
//! Each agent has one ([`agent_credential_path`]), minted by `swarm-controller`
|
||||
//! and read by the agent's hive, which turns it into the hash squid's users file
|
||||
//! holds. Each hive has one more ([`selfcheck_path`]) for its own squid
|
||||
//! self-check, so the self-check can prove an authenticated request is let
|
||||
//! through. Neither end is senior, so both halves are stated once, here, beside
|
||||
//! [`crate::forge`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
Error,
|
||||
path::{Kind, principal_prefix},
|
||||
};
|
||||
|
||||
/// The name of an agent's leaf.
|
||||
pub const AGENT_LEAF: &str = "proxy-credential";
|
||||
|
||||
/// The name of a hive's self-check leaf.
|
||||
pub const SELFCHECK_LEAF: &str = "squid-selfcheck";
|
||||
|
||||
/// The squid user the self-check logs in as.
|
||||
///
|
||||
/// An agent's squid user is its name, and agent names cannot hold a `.`
|
||||
/// ([`crate::path::checked_segment`]), so this one cannot be an agent's.
|
||||
/// `nix/host-modules/hive-squid.nix` spells the same string.
|
||||
pub const SELFCHECK_USER: &str = "selfcheck.squid";
|
||||
|
||||
/// The path holding the secret `agent` authenticates to the egress proxy with.
|
||||
///
|
||||
/// A flat leaf under the agent's prefix, like
|
||||
/// [`crate::forge::agent_token_path`], so the agent's own read stanza
|
||||
/// ([`crate::policy::render_shared_agent`]) already covers it, and so does the
|
||||
/// hive's.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`,
|
||||
/// which is what keeps one agent's name from addressing another agent's secret.
|
||||
pub fn agent_credential_path(agent: &str) -> Result<String, Error> {
|
||||
let prefix = principal_prefix(Kind::Agent, agent)?;
|
||||
Ok(format!("{prefix}/{AGENT_LEAF}"))
|
||||
}
|
||||
|
||||
/// The path holding the secret `hive`'s own squid self-check authenticates with.
|
||||
///
|
||||
/// Under [`Kind::Hive`], so the grant that reaches it is the hive's own
|
||||
/// `swarm/hives/<this hive>/*` stanza: hive `alpha` reads its own and gets a 403
|
||||
/// on `beta`'s.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `hive` contains anything but `[A-Za-z0-9_-]`.
|
||||
pub fn selfcheck_path(hive: &str) -> Result<String, Error> {
|
||||
let prefix = principal_prefix(Kind::Hive, hive)?;
|
||||
Ok(format!("{prefix}/{SELFCHECK_LEAF}"))
|
||||
}
|
||||
|
||||
/// What [`agent_credential_path`] and [`selfcheck_path`] hold.
|
||||
///
|
||||
/// No `Debug` derive: `value` is a live credential, and a derived `Debug` is
|
||||
/// one `{:?}` away from a log line.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Credential {
|
||||
/// The secret: 32 random bytes, base64url without padding, so it can sit in
|
||||
/// a proxy URL's userinfo unescaped.
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Credential {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Credential")
|
||||
.field("value", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn covered_by(policy: &str, path: &str) -> bool {
|
||||
policy.lines().any(|line| {
|
||||
line.strip_prefix("path \"")
|
||||
.and_then(|rest| rest.split_once("\" {"))
|
||||
.and_then(|(p, _)| p.strip_suffix('*'))
|
||||
.is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix))
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_paths_are_the_leaves_the_nix_side_spells() {
|
||||
assert_eq!(
|
||||
agent_credential_path("atlas").expect("legal"),
|
||||
"swarm/agents/atlas/proxy-credential"
|
||||
);
|
||||
assert_eq!(
|
||||
selfcheck_path("h1").expect("legal"),
|
||||
"swarm/hives/h1/squid-selfcheck"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traversal_in_either_name_is_refused() {
|
||||
for bad in ["../argus", "a/b", "a.b", ""] {
|
||||
assert!(agent_credential_path(bad).is_err(), "{bad:?}");
|
||||
assert!(selfcheck_path(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
assert!(agent_credential_path("a-b_C9").is_ok());
|
||||
assert!(selfcheck_path("a-b_C9").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agent_reads_its_own_credential_and_a_hive_reads_every_one() {
|
||||
let path = agent_credential_path("atlas").expect("legal");
|
||||
assert!(covered_by(&crate::policy::effective_agent("atlas"), &path));
|
||||
assert!(!covered_by(&crate::policy::effective_agent("argus"), &path));
|
||||
assert!(covered_by(&crate::policy::effective_hive("h1"), &path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hive_reads_its_own_selfcheck_credential_and_no_other_hives() {
|
||||
let path = selfcheck_path("h1").expect("legal");
|
||||
assert!(covered_by(&crate::policy::effective_hive("h1"), &path));
|
||||
assert!(!covered_by(&crate::policy::effective_hive("h2"), &path));
|
||||
assert!(!covered_by(&crate::policy::effective_agent("atlas"), &path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selfcheck_user_is_not_a_legal_agent_name() {
|
||||
assert!(crate::path::checked_segment("agent", SELFCHECK_USER).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_hides_the_value() {
|
||||
let shown = format!(
|
||||
"{:?}",
|
||||
Credential {
|
||||
value: "s3cret".to_owned()
|
||||
}
|
||||
);
|
||||
assert!(!shown.contains("s3cret"), "{shown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_value_field_is_named_value() {
|
||||
let json = serde_json::to_string(&Credential {
|
||||
value: "t".to_owned(),
|
||||
})
|
||||
.expect("a String serialises");
|
||||
assert_eq!(json, r#"{"value":"t"}"#);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue