From 531d44d34bca28b2a584d3fd2efa5e7b32131102 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 5 Oct 2026 22:13:58 +0200 Subject: [PATCH] swarm-bao: deny the secret publisher the callout xkey's seed `swarm-secret-publisher` holds create and update on `secret/data/swarm/services/*`, which covers the callout xkey's seed at `swarm/services/swarm-nats/callout/xkey-seed`. It never writes that path, but a write there would replace the key the queue's server seals every callout request to. An exact-path `deny` outranks the prefix. A create-only grant would not do: KV v2 honours create versus update, so it would stop an overwrite, but it would still let the publisher plant a seed of its choosing whenever the path is empty, on a first boot or after a key replacement. The publisher needs no access to the path at all. --- nix/host-modules/swarm-bao.nix | 10 +++++++++- nix/module-eval/bao-grants.nix | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 095e30fc..4a42698e 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -453,7 +453,7 @@ let secretPublisherPolicyName = "swarm-secret-publisher"; secretPublisherCn = baoDeploy.secretPublisherCommonName; - # Three grants, and every narrowing in each is load-bearing. + # Three grants and one denial, and every narrowing in each is load-bearing. # # `secret/data/` is KV v2's ACL prefix, inserted by the engine rather than # written by the caller — same trap as the controller's grant above. @@ -468,6 +468,10 @@ let # # Write-only. It copies secrets in and never reads one back; a read # capability would let a file-copier recover every hive's credentials. + # + # The callout xkey's seed sits under `swarm/services/` and is no secret this + # principal copies, so its exact path, which outranks the prefix, is denied: + # a write there would replace the key every queue request is sealed to. secretPublisherPolicyText = '' path "${credentialMountPath}/data/swarm/hives/*" { capabilities = ["create", "update"] @@ -477,6 +481,10 @@ let capabilities = ["create", "update"] } + path "${credentialMountPath}/data/${xkeySeedLeaf}" { + capabilities = ["deny"] + } + path "${credentialMountPath}/data/${controllerClientLeaf}" { capabilities = ["create", "update"] } diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index 51ce3b2c..3595dc0d 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -425,6 +425,23 @@ let && !(lib.hasInfix "secret/data/swarm/*" s) && !(lib.hasInfix "sys/policies/acl" s); } + { + # The publisher's `services/*` write covers the callout xkey's seed, and + # only the exact-path denial, which outranks the prefix, keeps it from + # replacing the key. The control is an OIDC leaf beside it, which the + # prefix still grants. + name = "the publisher may not write the callout xkey's seed"; + ok = + let + grants = grantsIn baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy.script; + in + (grantFor grants "secret/data/swarm/services/swarm-nats/callout/xkey-seed").caps == [ "deny" ] + && + (grantFor grants "secret/data/swarm/services/swarm-nats/oidc/client").caps == [ + "create" + "update" + ]; + } { # The ordering is load-bearing and invisible at runtime: the controller's # unit creates the KV and cert-auth mounts this one writes into, so