feat(tls): embed hive CA into agent flakes for self-signed trust

Wire agents to trust the gateway's self-signed leaf at build time. When
the gateway runs self-signed TLS, hive-tls sets HIVE_TLS_CA_PATH in
hive-c0re's service env pointing at the host hive CA cert. The meta flake
renderer reads it and, when present, writes the public CA cert next to
flake.nix as hive-ca.pem and emits security.pki.certificateFiles so every
agent's system trust store includes the hive CA.

Build-time embedding (rather than a runtime bind-mount + bundle service)
keeps trust robust: the CA travels with the flake source, lands in the
standard NixOS trust store, and needs no per-process SSL_CERT_FILE
plumbing. Only the public CA certificate is embedded; the CA private key
never leaves the host. The cert is re-embedded and re-committed on CA
rotation even when the flake is otherwise byte-identical; when self-signed
TLS is off the embedded cert is dropped so the flake stays buildable.

Covers OpenSSL-based tools (git, curl) directly. A follow-up switches the
hive-forge reqwest client to native roots so it picks up the same store.
This commit is contained in:
atlas 2026-06-17 19:38:56 +02:00
commit 4f3f6522d2
2 changed files with 133 additions and 2 deletions

View file

@ -67,7 +67,16 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default();
let initial = !dir.join(".git").exists();
if !initial && on_disk == new_flake {
// Hive CA embedding (self-signed TLS): keep `./hive-ca.pem` at the meta
// root in lockstep with the host CA so the build-time `certificateFiles`
// reference render_flake emits always resolves. `ca_desired` is empty
// when self-signed TLS isn't active (cert / ACME mode).
let ca_path = dir.join(HIVE_CA_FILE);
let (ca_desired, ca_changed) = hive_ca_state(&dir);
// Skip only when both the flake AND the embedded CA are unchanged — a
// CA rotation with an otherwise-identical flake must still re-commit.
if !initial && on_disk == new_flake && !ca_changed {
return Ok(());
}
@ -89,6 +98,16 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
std::fs::write(&flake_path, &new_flake)
.with_context(|| format!("write {}", flake_path.display()))?;
// Materialise (or drop) the embedded hive CA next to flake.nix. When
// self-signed TLS is off, `ca_desired` is empty and we remove any stale
// cert so the flake (which no longer references it) stays buildable.
if ca_desired.is_empty() {
let _ = std::fs::remove_file(&ca_path);
} else if ca_changed {
std::fs::write(&ca_path, &ca_desired)
.with_context(|| format!("write {}", ca_path.display()))?;
}
// Reconcile topology.json against the live agent set — adds
// entries for newly-spawned agents (default: manager as parent,
// manager itself as root) and drops removed agents. Operator
@ -143,6 +162,11 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
// contain '/flake.nix'". Lock then commit once with both
// flake.nix and flake.lock — single commit per change.
git(&dir, &["add", "flake.nix"]).await?;
// Stage the embedded hive CA — added/updated when self-signed TLS is on,
// or its deletion when it was just removed. `git add <path>` stages a
// deletion when the path is tracked and now gone; best-effort so the
// never-tracked-and-absent case (pathspec mismatch) is a harmless no-op.
let _ = git(&dir, &["add", "--", HIVE_CA_FILE]).await;
// Stage topology.json on every sync (regenerated by reconcile
// above when the agent set changed). git add is a no-op when the
// file content is unchanged.
@ -189,6 +213,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> {
.filter_map(|f| match f.as_str() {
"flake.nix" => Some("flake"),
"flake.lock" => Some("lock"),
"hive-ca.pem" => Some("hive-ca"),
"topology.json" => Some("topology"),
"capabilities.json" => Some("capabilities"),
"tool-groups.json" => Some("tool-groups"),
@ -565,6 +590,41 @@ fn forwarded_env_vars() -> Vec<(&'static str, String)> {
.collect()
}
/// Filename the hive CA cert is embedded under at the meta-flake root.
/// `sync_agents` writes it and `render_flake` references `./hive-ca.pem`
/// in `security.pki.certificateFiles` so every agent trusts it at build
/// time.
const HIVE_CA_FILE: &str = "hive-ca.pem";
/// Host path of the hive CA *certificate*, when self-signed TLS is active.
/// `hive-tls.nix` sets `HIVE_TLS_CA_PATH` in hive-c0re's service env (to
/// `<tls.stateDir>/ca.pem`) whenever the gateway serves a self-signed,
/// hive-CA-signed leaf. Returns `Some(path)` only when the var is set AND
/// the cert exists on disk — so render + write stay consistent (we never
/// emit a `certificateFiles` reference to a file we didn't embed). Only the
/// public cert is ever read here; the CA private key never leaves the host.
fn hive_ca_source() -> Option<String> {
let path = std::env::var("HIVE_TLS_CA_PATH").ok()?;
if path.is_empty() || !std::path::Path::new(&path).is_file() {
return None;
}
Some(path)
}
/// Embedded-CA state for the meta repo: `(desired_contents, changed)`.
/// `desired_contents` is the host hive CA cert (empty when self-signed TLS
/// is inactive); `changed` is true when it differs from what's already
/// embedded at `<dir>/hive-ca.pem`, so a CA rotation re-commits even when
/// the flake itself is byte-identical.
fn hive_ca_state(dir: &std::path::Path) -> (String, bool) {
let on_disk = std::fs::read_to_string(dir.join(HIVE_CA_FILE)).unwrap_or_default();
let desired = hive_ca_source()
.and_then(|p| std::fs::read_to_string(p).ok())
.unwrap_or_default();
let changed = desired != on_disk;
(desired, changed)
}
/// Read an agent's applied `flake.lock` and return the subset of
/// `CANONICAL_INPUTS` it declares as direct (root-level) inputs.
/// Returns an empty vec when the lock is missing or unparsable —
@ -700,7 +760,21 @@ where
modules = [
input.nixosModules.default
{
# The harness service inside the container runs as a
"#,
);
// Self-signed TLS trust: embed the hive CA so every agent validates the
// gateway's self-signed leaf at build time. `security.pki.certificateFiles`
// is build-time, so the CA travels with the flake source — `sync_agents`
// writes `./hive-ca.pem` next to flake.nix and stages it. Only the public
// CA cert is embedded; the private key never leaves the host. Emitted only
// when hive-tls.nix signalled a CA (HIVE_TLS_CA_PATH) and the cert exists,
// matching the write condition in `sync_agents` so we never reference a
// file we didn't embed.
if hive_ca_source().is_some() {
out.push_str(" security.pki.certificateFiles = [ ./hive-ca.pem ];\n");
}
out.push_str(
r#" # The harness service inside the container runs as a
# non-root unix user named after the agent (`damocles`,
# `iris`, `root`, ). UID auto-assigned by NixOS; the
# per-agent override here is what makes
@ -1138,4 +1212,53 @@ mod tests {
not only the harness service env:\n{out}"
);
}
#[test]
fn render_flake_embeds_hive_ca_when_signalled() {
// When hive-tls.nix signals a self-signed hive CA via
// HIVE_TLS_CA_PATH (and the cert exists), the agent module must
// trust it at build time via security.pki.certificateFiles. Absent
// the signal, no reference is emitted (so the flake doesn't point at
// a file that was never embedded).
//
// SAFETY: single-threaded mutation of a process env var the other
// tests don't assert the absence of; restored before returning.
let ca_file = std::env::temp_dir().join(format!("hive-ca-test-{}.pem", std::process::id()));
std::fs::write(
&ca_file,
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n",
)
.expect("write temp CA");
let render = || {
render_flake(
"github:example/hyperhive",
"path:/nix/store/aaaa-nixpkgs-source",
"path:/nix/store/bbbb-nixpkgs-unstable-source",
8000,
"she/her",
&std::collections::HashMap::new(),
&[sample_spec("alice", false, 9001)],
)
};
unsafe {
std::env::set_var("HIVE_TLS_CA_PATH", &ca_file);
}
let with_ca = render();
unsafe {
std::env::remove_var("HIVE_TLS_CA_PATH");
}
let without_ca = render();
let _ = std::fs::remove_file(&ca_file);
assert!(
with_ca.contains("security.pki.certificateFiles = [ ./hive-ca.pem ]"),
"CA cert must be wired into certificateFiles when signalled:\n{with_ca}"
);
assert!(
!without_ca.contains("security.pki.certificateFiles"),
"no certificateFiles reference without the HIVE_TLS_CA_PATH signal:\n{without_ca}"
);
}
}

View file

@ -153,5 +153,13 @@ in
fi
'';
};
# Signal the hive-c0re lifecycle that a hive CA exists: it bind-mounts
# this file (read-only, the CA cert ONLY — never the key) into each
# agent container so agents + their tools can trust the gateway's
# self-signed leaf, and the meta flake wires the per-agent trust
# bundle. Only the `ca.pem` path is exposed; `ca-key.pem` stays on the
# host (an agent that could read it could mint trusted certs).
systemd.services.hive-c0re.environment.HIVE_TLS_CA_PATH = "${cfg.stateDir}/ca.pem";
};
}