nix: issue the swarm-services leaf from bao's pki mount
The `pki` mount had no issuer and no principal could log in to it, so the swarm's service certificates were still minted by two openssl hops from a root key on disk. Close both halves and retire the openssl path with them. The mount now generates its own root, once. The granting unit asks bao whether an issuer already exists (`bao list pki/issuers`) before calling `pki/root/generate/internal`, so a rebuild or a reboot re-asserts the role and the grant without touching the anchor — a root that changed per boot would invalidate every certificate issued under it and every browser taught to trust it. The guard asks the store rather than looking for a marker file on this host's disk: a file is a claim about a mount that may have been restored from a snapshot or disabled and re-enabled underneath it. `swarm-services-issuer` stops being an inert policy. A fourth cert-auth role attaches it, following the shape the controller, the publisher and matrix-ctl already use, and glue-bao-tls.nix signs the leaf carrying its CN — that credential is what opens the mount, so it cannot come out of it. `swarm-services-cert.service` logs in with that leaf, calls `pki/issue/swarm-services`, and writes the result to the path hive-tls.nix already wrote and the gateway already copies from. The sub-CA layer does not move; it stops existing. The role's `allowed_domains`, read from the same `swarm.serviceDomains` the SANs come from, enforces at issue time what the sub-CA encoded in x509 `nameConstraints`, and with the root inside the mount there is nothing left for an intermediate to be an intermediate of. Not a flag day: the issuing root is published beside the leaf as `swarm-services-root.pem` (0644) and joins `trust-bundle.pem`, where the swarm root still sits. A leaf chaining to the old sub-CA and one issued by the store both verify against the same bundle, so hives can be rebuilt in any order. The same file is what an operator hands a browser — readable without a store login, which matters because every listener demands a client certificate. The eval-time warning about uncovered service names is gone rather than reworded. It fired on "this host does not hold the swarm root key", which was the reason a hive could end up serving its own leaf on a swarm-service name. Every hive now asks the store with its own identity, so that stopped being the thing that decides. Closes #4586
This commit is contained in:
parent
ebcc5bde89
commit
f4df4fc4a9
10 changed files with 700 additions and 315 deletions
|
|
@ -14,6 +14,13 @@
|
|||
# for the obvious reason; the cert as a consequence, and that one costs
|
||||
# something real — nothing whose trust store is built at build time can
|
||||
# name it: `docs/swarm/ca.md::Distributing the root`.
|
||||
#
|
||||
# ⚠️ This root no longer issues the swarm's SERVICE certificates, and the
|
||||
# `swarm-services-ca` sub-CA that used to sit under it is gone rather than
|
||||
# moved. Those come out of the secret store's own `pki` mount now, anchored
|
||||
# on a root generated inside it (./swarm-bao.nix), and ./hive-tls.nix asks
|
||||
# for them over the network instead of signing them here. What is left here
|
||||
# is one job: the anchor each hive's own CA is issued under.
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
|
|
@ -36,13 +43,6 @@ let
|
|||
hyperhiveCfg.domain
|
||||
else
|
||||
"hyperhive";
|
||||
|
||||
# Derived once in ./swarm.nix, read here and by ./hive-tls.nix: the
|
||||
# CA that name-constrains these and the leaf that carries them as SANs
|
||||
# must agree exactly, and two modules each assembling the list is how
|
||||
# they stop agreeing. It is also this unit's *rotation trigger* below,
|
||||
# which is why the ordering is stable there rather than here.
|
||||
serviceDomains = hyperhiveCfg.swarm.serviceDomains;
|
||||
in
|
||||
{
|
||||
options.services.hyperhive.swarm.ca = {
|
||||
|
|
@ -90,23 +90,6 @@ 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;
|
||||
|
|
@ -125,11 +108,8 @@ in
|
|||
|
||||
config = lib.mkIf (hyperhiveCfg.enable && cfg.autoConfigure) {
|
||||
# A CA that fails to issue is invisible until something makes a TLS call
|
||||
# hours later, so these two oneshots are worth more than most services.
|
||||
services.hyperhive.swarm.otel.journaldUnits = [
|
||||
"swarm-ca"
|
||||
"swarm-services-ca"
|
||||
];
|
||||
# hours later, so this oneshot is worth more than most services.
|
||||
services.hyperhive.swarm.otel.journaldUnits = [ "swarm-ca" ];
|
||||
|
||||
systemd.services.swarm-ca = {
|
||||
description = "Generate the swarm root CA when absent";
|
||||
|
|
@ -183,107 +163,5 @@ 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.
|
||||
#
|
||||
# ⚠️ EVERY entry carries its own `permitted;` / `excluded;`
|
||||
# prefix. openssl's parser takes the qualifier per subtree, not
|
||||
# once for a run of them: `permitted;DNS:a,DNS:b` is rejected
|
||||
# outright with `v2i_NAME_CONSTRAINTS: invalid syntax`, which
|
||||
# fails the whole unit. The hive CA next door emits exactly one
|
||||
# permitted name, so the missing-prefix form is accidentally
|
||||
# valid there and does NOT generalise — this list is always
|
||||
# longer than one.
|
||||
printf 'nameConstraints=critical,%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/^/permitted;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