From d6a79b4e634b169ba5f9775ce13f8c9a96224fa4 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 11 Sep 2026 23:18:09 +0200 Subject: [PATCH] module-eval: cover the secret publisher, and catch that it cannot evaluate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five cases: the leaf and the pairing that points at it, that the push hands bao a path rather than the secret, a path per hive in the roster, that a publisher holding an identity renders on a host with no store, and the control that renders none without an identity. They fail on this branch, and that is the finding. The suite was green at 100 cases with the publisher already committed, because no fixture enabled both authelia and a store identity — so the module's `config` never activated and its script was never evaluated. An imported module whose config never fires is as unmeasured as an unimported one. What they surface: `swarm.authelia.agentClientSuffix` does not exist on main. The agent principal these secrets belong to is minted by the PR for the agent queue principal, whose branch adds both the option and the `agentClients` list authelia mints from. Delivery of a secret nothing mints cannot evaluate, let alone run — so that PR lands first and this one rebases onto it. The argv case strips comments before matching, which it earned: 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. Matching the raw text read that warning and failed — a check the artifact defeats by describing the thing it is checked for. Refs #3853 --- nix/module-eval.nix | 118 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) 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