From 240ae79ad6fd51faf85bb96dadfaec3b7c2c8c16 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 5 Aug 2026 21:04:42 +0200 Subject: [PATCH] feat(nix): issue a swarm-services sub-CA under the swarm root The swarm's own service names cannot be signed by any hive CA: each hive CA is name-constrained to its hive's domain, and the service names are siblings of that domain, not children. Adding them to a leaf's SAN list only trades a name-mismatch error for a constraint-violation one. So the root issues one more intermediate, constrained to exactly the configured service names -- not to the whole swarm domain. A leaked services CA mints forge./chat./auth. and nothing else. Rotation is why this is separate rather than a second leaf off the root: the constraint enumerates the names, so adding a service re-issues this CA and never touches the root or any hive CA. The name set is written beside the cert and compared on each start, which is what makes re-issuance happen exactly when the names change -- not every boot, and not never. The list is sorted and deduplicated for the same reason: an unstable order would churn a CA that things are meant to pin. Validity is deliberately much shorter than the root's. This CA is meant to be re-issued, so a long window buys nothing and a short one keeps the rotation path exercised rather than theoretical. Carries the root's half-provisioned guard: a key beside a cert that did not sign it looks like it works and issues nothing anyone trusts. --- nix/host-modules/swarm-ca.nix | 128 ++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/nix/host-modules/swarm-ca.nix b/nix/host-modules/swarm-ca.nix index 08341269..ef623f48 100644 --- a/nix/host-modules/swarm-ca.nix +++ b/nix/host-modules/swarm-ca.nix @@ -50,6 +50,24 @@ let hyperhiveCfg.domain else "hyperhive"; + + # The names the services sub-CA is allowed to issue for, read from the + # service options rather than spelled out here: a constraint list that + # doesn't track its own consumers is a constraint that silently stops + # covering one. + # + # Sorted + deduplicated because this list is also the *rotation + # trigger* below — an unstable order would re-issue the CA on every + # rebuild, and a CA that churns is one nothing can pin. + serviceDomains = lib.sort (a: b: a < b) ( + lib.unique ( + lib.filter (d: d != null && d != "") [ + hyperhiveCfg.swarm.forge.domain + hyperhiveCfg.swarm.matrix.gatewayHost + hyperhiveCfg.swarm.authelia.domain + ] + ) + ); in { options.services.hyperhive.swarm.ca = { @@ -97,6 +115,23 @@ in ''; }; + servicesValidityDays = lib.mkOption { + type = lib.types.int; + default = 1825; + description = '' + Validity window of the swarm-services sub-CA in days (~5y). + Deliberately far shorter than the root's: this CA is *meant* to + be re-issued — adding a swarm service changes its name + constraints and rotates it — so a long window buys nothing, and + a short one keeps the rotation path exercised rather than + theoretical. + + Rotating it is cheap in the way rotating the root is not: it + touches only the swarm-service vhosts, and no peer hive holds it + as an anchor. + ''; + }; + validityDays = lib.mkOption { type = lib.types.int; default = 10950; @@ -166,5 +201,98 @@ in chmod 0644 "$root" ''; }; + + # The swarm-services sub-CA: issues leaves for the swarm's own + # service names, which no hive CA can sign — each of those is + # name-constrained to its own hive's domain, and the service names + # are siblings of it, not children. + # + # Rotation is the point of it being separate (mara: "swarm services + # sub ca that can rotate independently of swarm root ca"): the + # constraint enumerates the exact service names, so adding a service + # re-issues *this* and never touches the root or any hive CA. + systemd.services.swarm-services-ca = { + description = "Issue the swarm-services sub-CA under the swarm root"; + wantedBy = [ "multi-user.target" ]; + after = [ "swarm-ca.service" ]; + requires = [ "swarm-ca.service" ]; + path = [ pkgs.openssl ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + UMask = "0077"; + SyslogIdentifier = "swarm-services-ca"; + }; + script = '' + set -euo pipefail + d=${lib.escapeShellArg cfg.stateDir} + root="$d/root.pem" + rootk="$d/root-key.pem" + ca="$d/services-ca.pem" + cak="$d/services-ca-key.pem" + # The name set this CA was last issued for. Comparing against it + # is what makes re-issuance happen exactly when the service + # names change — not every boot, and not never. + names="$d/services-ca.names" + want=${lib.escapeShellArg (lib.concatStringsSep "\n" serviceDomains)} + + if [ -z "$want" ]; then + echo "no swarm service domains configured — nothing to issue for" + exit 0 + fi + + # Same half-provisioned guard as the root: a key beside a cert + # that did not sign it looks like it works and issues nothing + # anyone will trust. + if { [ -e "$ca" ] && [ ! -e "$cak" ]; } || { [ -e "$cak" ] && [ ! -e "$ca" ]; }; then + echo "services sub-CA half-provisioned ($ca / $cak) — refusing to generate over it" >&2 + exit 1 + fi + + if [ -s "$ca" ] && [ -s "$cak" ] && [ -f "$names" ] \ + && [ "$(cat "$names")" = "$want" ]; then + echo "services sub-CA present and covers the configured names — leaving it alone" + exit 0 + fi + + if [ ! -s "$root" ] || [ ! -s "$rootk" ]; then + echo "no swarm root CA at $root — cannot issue the services sub-CA under it" >&2 + exit 1 + fi + + echo "issuing services sub-CA at $ca for: $(echo "$want" | tr '\n' ' ')" + csr="$(mktemp "$d/services-ca.csr.XXXXXX")" + ext="$(mktemp "$d/services-ca.ext.XXXXXX")" + trap 'rm -f "$csr" "$ext"' EXIT + + openssl req -newkey rsa:4096 -nodes -sha256 \ + -keyout "$cak" -out "$csr" \ + -subj "/CN=swarm-services-ca ${swarmLabel}" + + { + # pathlen:0 — this signs leaves and delegates no further. + printf 'basicConstraints=critical,CA:TRUE,pathlen:0\n' + printf 'keyUsage=critical,keyCertSign,cRLSign\n' + printf 'subjectKeyIdentifier=hash\n' + printf 'authorityKeyIdentifier=keyid:always\n' + # Constrained to the exact service names, not to the whole + # swarm domain: a leaked services CA should mint `forge.`, + # `chat.`, `auth.` and nothing else. The IP exclusions are not + # redundant — a DNS constraint says nothing about an + # iPAddress SAN, and an unconstrained name type is a name + # type this CA is unconstrained for. + printf 'nameConstraints=critical,permitted;%s,excluded;IP:0.0.0.0/0.0.0.0,excluded;IP:0:0:0:0:0:0:0:0/0:0:0:0:0:0:0:0\n' \ + "$(echo "$want" | sed 's/^/DNS:/' | paste -sd, -)" + } > "$ext" + + openssl x509 -req -in "$csr" -CA "$root" -CAkey "$rootk" \ + -CAcreateserial -days ${toString cfg.servicesValidityDays} -sha256 \ + -extfile "$ext" -out "$ca" + + printf '%s' "$want" > "$names" + chmod 0600 "$cak" + chmod 0644 "$ca" "$names" + ''; + }; }; }