From 4f3f6522d276dabcfb13a486f2a4b1e87be2bc11 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 17 Jun 2026 19:38:56 +0200 Subject: [PATCH] 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. --- hive-c0re/src/meta.rs | 127 ++++++++++++++++++++++++++++++++++++++- nix/modules/hive-tls.nix | 8 +++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 401287a0..36dec055 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -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 ` 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 +/// `/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 { + 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 `/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}" + ); + } } diff --git a/nix/modules/hive-tls.nix b/nix/modules/hive-tls.nix index 7053e831..e68733cc 100644 --- a/nix/modules/hive-tls.nix +++ b/nix/modules/hive-tls.nix @@ -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"; }; }