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.
This commit is contained in:
parent
6e64489050
commit
240ae79ad6
1 changed files with 128 additions and 0 deletions
|
|
@ -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"
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue