Watch
0
0
Fork
You've already forked hyperhive
0

swarm-bao: agent certificates issued by a store-generated agent CA

An agent's store identity was signed in swarm-controller's memory by a CA
a controller-host unit generated on disk, and the listener never trusted
that CA. Agent leaves now come from the store itself: a `pki-agents` PKI
mount whose root openbao generates internally, so the agent CA's key
never exists outside the store.

- swarm-bao-agent-pki (new, store host, as the bao granter): enables and
  tunes the mount, generates the root once (guarded on an empty issuer
  list, no replace branch), upserts the `swarm-agent` role (client
  certificates named `hive-agent-*` only, 90 days), caches the CA at
  /var/lib/swarm-bao-tls/agent-ca.pem and composes the listener bundle.
- The listener's tls_client_ca_file is a new listener-client-ca.pem
  (client-ca.pem, then the agent CA). Host cert-auth roles still pin
  client-ca.pem, so an agent leaf satisfies no host role. swarm-bao-certs
  composes the same bundle before openbao starts.
- openbao reads tls_client_ca_file only at start, so when the bundle
  changed after openbao started, swarm-bao-agent-pki restarts
  openbao.service in the container; under `seal = "shamir"` it prints
  the step instead. Once swarm-bao-certs has a cached CA, later boots
  start openbao with it and do not restart.
- The controller policy gains exactly `update` on
  pki-agents/issue/swarm-agent. mint_and_verify now asks that role for
  the leaf (the store generates the key), writes the agent's cert-auth
  role pinning the issuing CA bao returned, and writes the agent's
  policy as render_agent alone: the hive-shared queue credential stanza
  is gone.
- deploy.bao.agentPkiRoleName (must start `swarm-`, asserted with the
  other pki role names); swarm-controller gets
  SWARM_CONTROLLER_AGENT_PKI_MOUNT/_ROLE from the deploy.bao options.

Deleted: swarm-controller-agent-ca and its options (agentCaFile,
agentCaKeyFile), env, LoadCredential entries and assertion;
agent_identity's Authority, rcgen signing and validity window; the
rcgen and time dependencies of swarm-controller (rcgen leaves the
workspace); policy::render_agent_with_queue and its tests. The CN-prefix
assertion policy.rs said was owed is not: agent and host roles pin
different CAs.

Migration is re-creating each agent after deploy; that overwrites the
stale role and policy.

Closes #4756
This commit is contained in:
atlas 2026-09-27 19:30:38 +02:00
commit 6170e74a31
16 changed files with 894 additions and 925 deletions

View file

@ -2,9 +2,12 @@
use rustify_derive::Endpoint;
use serde::{Serialize, de::DeserializeOwned};
use vaultrs::client::{Client, VaultClient, VaultClientSettingsBuilder};
use vaultrs::{
api::pki::{requests::GenerateCertificateRequest, responses::GenerateCertificateResponse},
client::{Client, VaultClient, VaultClientSettingsBuilder},
};
use crate::{Error, path::MOUNT};
use crate::{Error, mtls, path::MOUNT};
/// The store's address.
pub const ENV_ADDR: &str = "BAO_ADDR";
@ -261,6 +264,30 @@ impl SecretStore {
Ok(())
}
/// Have the PKI role `role` on `mount` issue a client certificate for
/// `common_name`, returning it with its key and the issuing CA.
///
/// The store generates the key: it exists in the store's answer and in the
/// returned value, nowhere else. What the certificate may carry (names,
/// usages, lifetime) is the role's to decide, so nothing but the name is
/// asked for here.
///
/// # Errors
/// [`Error::Vault`] when the token's policy does not cover
/// `<mount>/issue/<role>` or the role refuses `common_name`, and
/// [`Error::IncompleteIssue`] when the answer lacks any of the three.
pub async fn issue_client_certificate(
&self,
mount: &str,
role: &str,
common_name: &str,
) -> Result<mtls::Credential, Error> {
let issued =
vaultrs::api::exec_with_result(&self.inner, issue_request(mount, role, common_name))
.await?;
credential_from_issue(issued)
}
/// The names of every cert-auth role under `mount`, or none when the
/// mount has no roles at all.
///
@ -296,6 +323,45 @@ struct WriteAclPolicy {
policy: String,
}
/// The request [`SecretStore::issue_client_certificate`] sends.
///
/// The name stays out of the SANs so the certificate carries exactly one name,
/// the one the cert-auth role matches. PKCS#8 because that is the key shape
/// every reader of the published identity already parses.
fn issue_request(mount: &str, role: &str, common_name: &str) -> GenerateCertificateRequest {
GenerateCertificateRequest {
mount: mount.to_owned(),
role: role.to_owned(),
common_name: Some(common_name.to_owned()),
exclude_cn_from_sans: Some(true),
private_key_format: Some("pkcs8".to_owned()),
..GenerateCertificateRequest::default()
}
}
/// The store's answer, moved straight into the redacting
/// [`mtls::Credential`]. `GenerateCertificateResponse` derives `Debug` and
/// holds the private key, so it is consumed here and never formatted.
///
/// `issuing_ca` rather than `ca_chain` because it is the one certificate a
/// cert-auth role pins: the authority that signed this leaf.
fn credential_from_issue(issued: GenerateCertificateResponse) -> Result<mtls::Credential, Error> {
for (field, value) in [
("certificate", &issued.certificate),
("private_key", &issued.private_key),
("issuing_ca", &issued.issuing_ca),
] {
if value.trim().is_empty() {
return Err(Error::IncompleteIssue(field));
}
}
Ok(mtls::Credential {
cert: issued.certificate,
key: issued.private_key,
ca: issued.issuing_ca,
})
}
#[cfg(test)]
mod tests {
use super::*;
@ -408,4 +474,84 @@ mod tests {
the store's copy of the document disagree with its own name"
);
}
/// The controller's grant names exactly this path with `update`, which is
/// what a POST needs; any other path or verb is a 403.
#[test]
fn an_issue_request_posts_to_the_roles_issue_path() {
use rustify::endpoint::Endpoint as _;
let request = issue_request("pki-agents", "swarm-agent", "hive-agent-atlas");
assert_eq!(request.path(), "pki-agents/issue/swarm-agent");
assert!(
matches!(request.method(), rustify::enums::RequestMethod::POST),
"{:?}",
request.method()
);
}
#[test]
fn an_issue_request_asks_for_the_name_and_nothing_the_role_decides() {
use rustify::endpoint::Endpoint as _;
let body = issue_request("pki-agents", "swarm-agent", "hive-agent-atlas")
.body()
.expect("the body serialises")
.expect("an issue request sends one");
let sent: serde_json::Value = serde_json::from_slice(&body).expect("JSON");
assert_eq!(sent["common_name"], "hive-agent-atlas");
assert_eq!(sent["exclude_cn_from_sans"], true);
assert_eq!(sent["private_key_format"], "pkcs8");
for decided_by_the_role in ["ttl", "alt_names", "ip_sans", "uri_sans", "other_sans"] {
assert!(
sent.get(decided_by_the_role)
.is_none_or(serde_json::Value::is_null),
"{decided_by_the_role} is the role's to set: {sent}"
);
}
}
fn issued() -> GenerateCertificateResponse {
GenerateCertificateResponse {
ca_chain: None,
certificate: "LEAF".to_owned(),
expiration: None,
issuing_ca: "AGENT-CA".to_owned(),
private_key: "SECRET-KEY-BYTES".to_owned(),
private_key_type: "ec".to_owned(),
serial_number: "01".to_owned(),
}
}
#[test]
fn a_complete_answer_becomes_the_published_credential() {
let credential = credential_from_issue(issued()).expect("every field is present");
assert_eq!(credential.cert, "LEAF");
assert_eq!(credential.key, "SECRET-KEY-BYTES");
assert_eq!(credential.ca, "AGENT-CA", "the role pins the issuing CA");
assert!(
!format!("{credential:?}").contains("SECRET-KEY-BYTES"),
"the key must not reach a formatted value"
);
}
#[test]
fn an_answer_missing_any_part_of_the_identity_is_refused_by_name() {
type Blank = fn(&mut GenerateCertificateResponse);
let cases: [(&str, Blank); 3] = [
("certificate", |r| r.certificate.clear()),
("private_key", |r| r.private_key = " \n".to_owned()),
("issuing_ca", |r| r.issuing_ca.clear()),
];
for (field, blank) in cases {
let mut answer = issued();
blank(&mut answer);
let e = credential_from_issue(answer).expect_err("an incomplete identity");
assert!(
matches!(e, Error::IncompleteIssue(f) if f == field),
"blanking {field} gave {e:?}"
);
assert!(!e.to_string().contains("SECRET-KEY-BYTES"), "{e}");
}
}
}