diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 94f6c3d5..50313344 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -327,6 +327,32 @@ let deploy.bao.serverCaFile = lib.mkForce "/etc/pki/store-server-ca.pem"; }; + # The IdP and the store on one machine: the shape where minted plaintext and + # a store identity are both present without an operator placing anything. + # Two hives in the roster, because the publisher walks it — an arm written + # against a single-hive fixture passes on a hardcoded name. + secretPublisherHere = hive { + deploy.bao.enable = true; + deploy.authelia.enable = true; + swarm.hives.h2.domain = "h2.t.local"; + }; + # The IdP with no store on the box and a leaf placed by hand, which is the + # deployment this unit exists for: authelia is the one host the store is + # guaranteed not to share once either has a machine of its own. + # + # ⚠️ `enable` is deliberately NOT set here. It used to be, with a comment + # saying the default asked whether both ran on this host — which documented + # the co-location bug instead of catching it. Leaving it unset is what makes + # this fixture exercise the default rather than mask it. + secretPublisherRemote = hive { + deploy.authelia.enable = true; + deploy.swarm-secret-publisher.baoClientCertFile = "/etc/pki/publisher.pem"; + deploy.swarm-secret-publisher.baoClientKeyFile = "/etc/pki/publisher-key.pem"; + }; + # The same IdP with the identity taken away. Minting the secrets is not being + # able to publish them, and this is the arm that separates the two. + secretPublisherNoIdentity = hive { deploy.authelia.enable = true; }; + # The host's `bao` wrapper, pulled apart once so each case below names one # property instead of a conjunction — a failing conjunction says only that # something is wrong. @@ -999,6 +1025,98 @@ let && builtins.elem "bao-client.pem:/var/lib/swarm-bao-pki/controller.pem" s.swarm-controller.serviceConfig.LoadCredential && builtins.elem "bao-client-key.pem:/var/lib/swarm-bao-pki/controller-key.pem" s.swarm-controller.serviceConfig.LoadCredential; } + { + # The same hole the controller's case above names, open a second time: the + # PKI script grew a third leaf and no case read it. + name = "the store mints a leaf for the secret publisher, and the publisher is pointed at it"; + ok = + let + m = secretPublisherHere; + p = m.services.hyperhive.deploy.swarm-secret-publisher; + in + lib.hasInfix "secret-publisher.pem" m.systemd.services.swarm-bao-pki.script + && p.baoClientCertFile == "/var/lib/swarm-bao-pki/secret-publisher.pem" + && p.baoClientKeyFile == "/var/lib/swarm-bao-pki/secret-publisher-key.pem"; + } + { + # mara caught this by reading, which means no arm existed for it: the + # default asked `authelia.enable && bao.enable`, so the split deployment + # this unit is FOR defaulted off and published nothing, silently. + # + # The second clause is the control. Without it this passes on a default + # of plain `true`, which would be a different bug with the same symptom + # — an IdP-less host claiming it publishes secrets it never mints. + name = "the publisher defaults on where secrets are minted, whether or not the store is local"; + ok = + secretPublisherRemote.services.hyperhive.deploy.swarm-secret-publisher.enable + && !bare.services.hyperhive.deploy.swarm-secret-publisher.enable; + } + { + # The one security property of this unit, and why its push cannot be + # rewritten into the obvious shape: `bao` is an external binary, so an + # argument is world-readable in /proc for the life of the call. + # `value=@` hands it the path and bao opens the file itself. + # + # The second arm is what makes the first mean anything — `value=@` can + # sit one line above a command substitution that put the plaintext in + # argv anyway. + # + # ⚠️ Comments are stripped first, and that is not tidiness. A `script` + # renders its own comments into the text, and this unit's comments name + # the hazard verbatim so the next editor does not reintroduce it. Without + # the strip this case reads that warning and fails — a check the artifact + # defeats by DESCRIBING the thing it is checked for. + name = "the publisher hands bao the secret's path, never the secret"; + ok = + let + s = secretPublisherHere.systemd.services.swarm-secret-publish.script; + code = lib.concatStringsSep "\n" ( + lib.filter (l: builtins.match "[[:space:]]*#.*" l == null) (lib.splitString "\n" s) + ); + in + lib.hasInfix "value=@" code && !(lib.hasInfix "$(cat" code); + } + { + # Two ends of a wire nothing at eval time carries end to end: this is the + # path `swarm_secret_client::queue` resolves for the reader. Both hives + # are asserted, so a publisher that knew one name rather than the roster + # fails here rather than on the second hive ever added to a swarm. + name = "the publisher writes every hive in the roster to that hive's own queue path"; + ok = + let + s = secretPublisherHere.systemd.services.swarm-secret-publish.script; + in + lib.hasInfix "secret/swarm/hives/h1/queue/agent" s + && lib.hasInfix "secret/swarm/hives/h2/queue/agent" s; + } + { + # The doctrine three glue files state, as a property a rewrite has to + # keep: a client is defined by holding a certificate the store accepts, + # never by standing next to the store. Gating this on `deploy.bao.enable` + # would have left the unit rendering only on the one deployment that has + # no use for it. + name = "a publisher holding an identity runs on a host with no store"; + ok = + let + m = secretPublisherRemote; + in + !m.services.hyperhive.deploy.bao.enable + && (m.systemd.services ? swarm-secret-publish) + && (m.systemd.paths ? swarm-secret-publish); + } + { + # What makes the arm above able to fail. Minting the secrets is not being + # able to publish them: with no certificate the unit would fail a TLS + # handshake on every rotation, so it must not exist at all. + name = "an IdP host with no store identity renders no publisher"; + ok = + let + m = secretPublisherNoIdentity; + in + m.services.hyperhive.deploy.authelia.enable + && !(m.systemd.services ? swarm-secret-publish) + && !(m.systemd.paths ? swarm-secret-publish); + } { # A hive's cert-auth role carries the authority by value, so the daemon # has to be handed the file rather than a path into the store's own