diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index 7683d730..bf7c2fa0 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -23,6 +23,7 @@ ./hive-priv.nix ./hive-tls.nix ./otel.nix + ./glue-bao-tls.nix ./swarm-authelia.nix ./swarm-bao.nix ./swarm-ca.nix diff --git a/nix/host-modules/glue-bao-tls.nix b/nix/host-modules/glue-bao-tls.nix new file mode 100644 index 00000000..eab900b2 --- /dev/null +++ b/nix/host-modules/glue-bao-tls.nix @@ -0,0 +1,119 @@ +# Glue: give the secret store a PKI of its own, and point it at it. +# +# ONE PAIRING PER FILE — `glue--.nix`. A single module holding +# every co-location default becomes the file nobody dares change, because a +# reader cannot tell which of its rules their deployment is subject to. Each +# of these should be deletable on its own, and deleting this one leaves a +# store that takes operator-provided certificates and nothing else. +# +# ⚠️ Why the PKI lives HERE and not in ./swarm-bao.nix: the store must have no +# opinion about where its identity comes from. Minting is an opinion — the +# most consequential one available — so it belongs to the glue that decides +# this deployment self-signs, not to the service that merely serves what it is +# handed. A deployment with a real internal CA drops this file and names its +# own paths; nothing in the store changes. +# +# ⚠️ Not the hive CA and not the swarm CA. The store will eventually +# distribute both, and an authority you must already hold a certificate from +# cannot be one the store hands out — reach the store to get the CA material, +# need a cert from that CA to reach the store. This CA signs exactly two +# things and distributes nothing, so it cannot enter that cycle. +# +# ⚠️ Files like this are the only place a `deploy.` value may derive from +# a `deploy..enable`. Everywhere else that is forbidden. The exception +# earns itself: the derivation happens either way, and the alternative is +# having it spread through the service modules where it is invisible. +# +# Everything is `mkDefault`. An operator naming their own paths wins. +{ + pkgs, + lib, + config, + ... +}: +let + hyperhiveCfg = config.services.hyperhive; + deployCfg = hyperhiveCfg.deploy; + cfg = hyperhiveCfg.swarm.bao; + + # Host-side, outside the container's tree, for the same reason the raft data + # is: `nixos-container destroy` must not take it. Losing the CA key means + # re-issuing every client certificate in the swarm. + pkiDir = "/var/lib/swarm-bao-pki"; + + # What a reader calls itself to the store. The hive's name, because a bao + # cert-auth role matches on the CN — this is an interface, not a label. + clientCn = if hyperhiveCfg.hiveName != null then hyperhiveCfg.hiveName else cfg.domain; + + # $1 dir $2 basename $3 CN $4 SAN or "" $5 EKU + signLeaf = pkgs.writeShellScript "swarm-bao-sign-leaf" '' + set -euo pipefail + d="$1"; base="$2"; cn="$3"; sans="$4"; eku="$5" + csr="$(mktemp "$d/$base.csr.XXXXXX")" + ext="$(mktemp "$d/$base.ext.XXXXXX")" + trap 'rm -f "$csr" "$ext"' EXIT + + openssl req -newkey rsa:4096 -nodes -sha256 \ + -keyout "$d/$base-key.pem" -out "$csr" -subj "/CN=$cn" + { + [ -n "$sans" ] && printf 'subjectAltName=%s\n' "$sans" + printf 'basicConstraints=critical,CA:FALSE\n' + printf 'keyUsage=critical,digitalSignature,keyEncipherment\n' + printf 'extendedKeyUsage=%s\n' "$eku" + } > "$ext" + openssl x509 -req -in "$csr" -CA "$d/ca.pem" -CAkey "$d/ca-key.pem" \ + -CAcreateserial -days 3650 -sha256 -extfile "$ext" -out "$d/$base.pem" + chmod 0600 "$d/$base-key.pem" + chmod 0644 "$d/$base.pem" + ''; +in +{ + config = lib.mkIf (hyperhiveCfg.enable && deployCfg.bao.enable) { + services.hyperhive.deploy.bao = { + serverCertFile = lib.mkDefault "${pkiDir}/server.pem"; + serverKeyFile = lib.mkDefault "${pkiDir}/server-key.pem"; + clientCaFile = lib.mkDefault "${pkiDir}/ca.pem"; + }; + + # Idempotent on ABSENCE, never on content. Re-issuing the CA invalidates + # every client certificate already trusting it, so a rebuild that + # "refreshed" it would lock every reader in the swarm out at once — the + # same rule the store's TPM PIN unit follows, for a sharper reason. + systemd.services.swarm-bao-pki = { + description = "mint the swarm secret store's own CA and leaves"; + before = [ "swarm-bao-certs.service" ]; + requiredBy = [ "swarm-bao-certs.service" ]; + path = [ + pkgs.openssl + pkgs.coreutils + ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + set -euo pipefail + install -d -m 0700 ${pkiDir} + + if [ ! -s ${pkiDir}/ca.pem ]; then + openssl req -x509 -newkey rsa:4096 -nodes -sha256 -days 3650 \ + -keyout ${pkiDir}/ca-key.pem -out ${pkiDir}/ca.pem \ + -subj "/CN=swarm-bao-ca ${cfg.domain}" \ + -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" + chmod 0600 ${pkiDir}/ca-key.pem + chmod 0644 ${pkiDir}/ca.pem + fi + + # The store's own identity, and the identity of a reader on this host. + # A reader elsewhere gets its leaf from this CA out of band — that is + # what makes the store reachable from another machine at all, and why + # the CA is a file rather than a service. + [ -s ${pkiDir}/server.pem ] || ${signLeaf} ${pkiDir} server \ + ${lib.escapeShellArg cfg.domain} ${lib.escapeShellArg "DNS:${cfg.domain}"} serverAuth + [ -s ${pkiDir}/client.pem ] || ${signLeaf} ${pkiDir} client \ + ${lib.escapeShellArg clientCn} "" clientAuth + ''; + }; + }; +}