matrix: mint the appservice sender token in the matrix container
A swarm runs one homeserver and a homeserver has one appservice sender account, so "mint it once" is a property of the thing being minted rather than something a lock has to enforce. That is what makes this account the one to move first: no trigger route, no controller change and no agent list — a boot-time oneshot beside tuwunel is the whole mechanism. `swarm-matrix-minter` runs inside `containers.hive-matrix`, which already holds the appservice token: the rendered registration is bound in read-only because that is how tuwunel is handed it. What the container lacked was an identity of its own, so this adds one — a leaf from the store's CA with a grant of exactly one path, not the hive's leaf, which reads every secret in the store. Both ends of the credential ship here. The minter reads the path it publishes to before it touches the homeserver, and returning on a non-empty read IS the "only once"; `hive-c0re`'s `ensure_hive_user` reads the same path, authenticating with the hive name already in `HYPERHIVE_HIVE_NAME`. The existing mint-then-`M_USER_IN_USE`-login ladder stays as the fallback for a store that is empty, unconfigured or unreachable, which is every swarm deployed before this — so nothing needs backfilling and nothing breaks if the rest of the sequence never lands. The credential is not an admin credential, and is not named like one. It is the access token of the appservice registration's own `sender_localpart` — `@hive:<server_name>`, an account the homeserver creates for itself when it loads the registration. The store path is `swarm/services/matrix/sender-token`, the host path is `matrix/access-token`, and the homeserver no longer runs an `admin_execute` promotion for that account at boot. Everything the hive provisions with it — the Space, the chat room, their hierarchy and join rules, the invites — rides on being the creator of those rooms at power level 100, not on homeserver admin; there is no Synapse admin API here to need, tuwunel has none. Two operations do need an admin *sender* and therefore stop working: `hivectl matrix promote-user` and `hivectl matrix reset-password`, both `!admin …` messages into `#admins:<server>`, plus the password-reset recovery path that an agent with a lost password file falls back to. They are swarm-level operations and are left failing loudly rather than served by an over-privileged token every other call site would also carry. The sweep's own admin-rights check and self-repair go with them: an account that is deliberately not an admin has nothing to check. `ephemeral = false` stays, and hive root can still read the container's filesystem. Accepted: what this buys is identity separation — no hive *process* holds or reads the appservice token — not physical isolation. Refs #4345
This commit is contained in:
parent
ff0da0b617
commit
f778122f5a
28 changed files with 1566 additions and 320 deletions
|
|
@ -140,6 +140,56 @@ let
|
|||
baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy.after
|
||||
);
|
||||
}
|
||||
{
|
||||
# The third principal's grant, and the narrowest of the three: ONE path,
|
||||
# spelled to the leaf. The negative arms are the property — a homeserver
|
||||
# is not entitled to overwrite Grafana's OIDC client, so widening this to
|
||||
# the `services/` prefix the publisher holds would be a real loss even
|
||||
# though it would read as tidier.
|
||||
#
|
||||
# ⚠️ `services` is PLURAL, because the path segment comes from
|
||||
# `Kind::Service`'s `#[strum(serialize = "services")]` and not from
|
||||
# `Kind::label`, which renders the singular for error text. The singular
|
||||
# spelling evaluates, deploys, and 403s every read with "permission
|
||||
# denied" and nothing else.
|
||||
name = "the matrix minter's grant is the hive credential's path and nothing else";
|
||||
ok =
|
||||
let
|
||||
s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script;
|
||||
in
|
||||
lib.hasInfix "path \"secret/data/swarm/services/matrix/hive-access-token\" {" s
|
||||
&& !(lib.hasInfix "secret/data/swarm/services/*" s)
|
||||
&& !(lib.hasInfix "secret/data/swarm/agents" s)
|
||||
&& !(lib.hasInfix "secret/data/swarm/hives" s)
|
||||
&& !(lib.hasInfix "sys/policies/acl" s);
|
||||
}
|
||||
{
|
||||
# 🩸 `read` is load-bearing here and is the one capability neither
|
||||
# sibling has. The minter's first act is to read this path back and stop
|
||||
# if something is there — that read IS "and only once", so without the
|
||||
# capability every container restart would mint a second access token and
|
||||
# invalidate the hive's.
|
||||
name = "the matrix minter may read back the one path it writes";
|
||||
ok =
|
||||
let
|
||||
s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script;
|
||||
in
|
||||
lib.hasInfix "capabilities = [\"create\", \"update\", \"read\"]" s
|
||||
&& lib.hasInfix "auth/cert/certs/swarm-matrix-minter" s
|
||||
&& lib.hasInfix "allowed_common_names=swarm-matrix-minter" s;
|
||||
}
|
||||
{
|
||||
# Same two controls its siblings carry: ordered after the unit that makes
|
||||
# the mounts it writes into, and rendered on the HOST rather than inside
|
||||
# the store's container, where it would have neither an identity nor a
|
||||
# route to the store.
|
||||
name = "the minter's granting unit is ordered after the mounts and rendered on the host";
|
||||
ok =
|
||||
lib.elem "swarm-bao-controller-policy.service" (
|
||||
baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.after
|
||||
)
|
||||
&& !(baoGrantHere.containers.swarm-bao.config.systemd.services ? swarm-bao-matrix-minter-policy);
|
||||
}
|
||||
{
|
||||
# The policy authorising this route lives in another file, and nothing
|
||||
# else relates the grants to the paths the code actually writes.
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ let
|
|||
deploy.bao.clientCertFile = "/etc/pki/bao-client.pem";
|
||||
deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem";
|
||||
};
|
||||
|
||||
# A homeserver on a hive with NO store identity at all — neither a local
|
||||
# store nor a hand-placed leaf. The absence arm for the minter cases below
|
||||
# needs it, and defining it here rather than importing keeps each group's
|
||||
# fixture set its own, as ./lib.nix asks.
|
||||
matrixNoBaoIdentity = hive { deploy.matrix.enable = true; };
|
||||
cases = [
|
||||
{
|
||||
# A login failure is the store being unreachable, sealed, or not yet
|
||||
|
|
@ -219,6 +225,110 @@ let
|
|||
&& (s.hive-c0re.environment.BAO_CACERT or null) == "%d/bao-ca.pem"
|
||||
&& lib.any (c: lib.hasPrefix "bao-ca.pem:" c) s.hive-c0re.serviceConfig.LoadCredential;
|
||||
}
|
||||
{
|
||||
# The same hole a third time, and the leaf whose absence is hardest to
|
||||
# see from outside: it is consumed by a unit INSIDE a container, so a
|
||||
# missing pairing renders as a container that comes up fine and publishes
|
||||
# nothing.
|
||||
name = "the store mints a leaf for the matrix minter, and the container is pointed at it";
|
||||
ok =
|
||||
let
|
||||
m = baoWithMatrix;
|
||||
p = m.services.hyperhive.deploy.matrix;
|
||||
in
|
||||
lib.hasInfix "matrix-minter.pem" m.systemd.services.swarm-bao-pki.script
|
||||
&& p.minterBaoClientCertFile == "/var/lib/swarm-bao-pki/matrix-minter.pem"
|
||||
&& p.minterBaoClientKeyFile == "/var/lib/swarm-bao-pki/matrix-minter-key.pem";
|
||||
}
|
||||
{
|
||||
# 🩸 The identity separation this whole arrangement buys, stated as the
|
||||
# one thing that would silently undo it. The container gets the MINTER's
|
||||
# leaf — whose grant is a single path — and not the hive's, which reads
|
||||
# every secret in the store. Both files exist in the same directory and
|
||||
# both would evaluate, deploy and work.
|
||||
name = "the matrix minter presents its own leaf, never the hive's store-wide one";
|
||||
ok =
|
||||
let
|
||||
env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-minter.environment;
|
||||
hiveLeaf = baoWithMatrix.services.hyperhive.deploy.bao.clientCertFile;
|
||||
in
|
||||
env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-minter.pem"
|
||||
&& env.BAO_CLIENT_CERT != hiveLeaf;
|
||||
}
|
||||
{
|
||||
# The bind mount is what makes the environment above resolvable: without
|
||||
# it the unit names two paths the container does not have, and fails at
|
||||
# the TLS handshake naming no cause. Read off the mount table rather than
|
||||
# the option, so a pairing that stops reaching `bindMounts` still fails.
|
||||
#
|
||||
# The second arm is the shape guard: `bindMounts` is one literal plus two
|
||||
# merges, and a rewrite that dropped the appservice registration would
|
||||
# take the homeserver's own credential with it.
|
||||
name = "the matrix container binds the minter's PKI read-only, without losing the appservice registration";
|
||||
ok =
|
||||
let
|
||||
mounts = baoWithMatrix.containers.hive-matrix.bindMounts;
|
||||
in
|
||||
mounts ? "/var/lib/swarm-bao-pki"
|
||||
&& mounts."/var/lib/swarm-bao-pki".isReadOnly
|
||||
&& mounts ? "/var/lib/hyperhive/matrix-appservice";
|
||||
}
|
||||
{
|
||||
# What the unit is for, read as the two agreements it cannot get wrong:
|
||||
# the cert role ./host-modules/swarm-bao.nix writes, and a homeserver
|
||||
# address that is loopback because the container shares the host netns. A
|
||||
# vhost here would be a request out through the gateway and back.
|
||||
name = "the matrix minter is handed the store role and the loopback homeserver";
|
||||
ok =
|
||||
let
|
||||
m = baoWithMatrix;
|
||||
u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-minter;
|
||||
port = m.services.hyperhive.swarm.matrix.httpPort;
|
||||
in
|
||||
u.environment.MATRIX_MINTER_CERT_ROLE == "swarm-matrix-minter"
|
||||
&& u.environment.MATRIX_MINTER_API_URL == "http://127.0.0.1:${toString port}"
|
||||
&& u.environment.MATRIX_MINTER_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml"
|
||||
&& u.serviceConfig.Type == "oneshot";
|
||||
}
|
||||
{
|
||||
# 🩸 A secret is a path, never a value — checked on the one unit in this
|
||||
# tree whose whole job is an `as_token`. Every variable it is given names
|
||||
# a file or an address; the token itself is read out of the bind-mounted
|
||||
# registration at runtime, so nothing here can be a token and an
|
||||
# environment block is world-readable through `systemctl show`.
|
||||
name = "the matrix minter's environment carries paths and addresses, never a token";
|
||||
ok =
|
||||
let
|
||||
env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-minter.environment;
|
||||
in
|
||||
!(lib.any (v: lib.hasInfix "as_token" v || lib.hasInfix "syt_" v) (lib.attrValues env));
|
||||
}
|
||||
{
|
||||
# The absence arm, and the deployment it protects: a homeserver on a hive
|
||||
# with no store identity at all. Without it the unit would exist naming
|
||||
# `null` as its certificate, which nixos renders as the literal string.
|
||||
name = "a matrix container with no store identity runs no minter and binds no PKI";
|
||||
ok =
|
||||
let
|
||||
units = matrixNoBaoIdentity.containers.hive-matrix.config.systemd.services;
|
||||
in
|
||||
!(units ? swarm-matrix-minter)
|
||||
&& !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki");
|
||||
}
|
||||
{
|
||||
# 🩸 The privilege arm of the credential this slice publishes: the
|
||||
# account it belongs to must not be a homeserver admin. Read on the
|
||||
# rendered homeserver settings rather than on an option, because the
|
||||
# grant was never an option — it was a boot command in `admin_execute`,
|
||||
# and a command list is exactly the shape a later edit re-adds without
|
||||
# anything noticing.
|
||||
name = "the homeserver promotes no account to admin at boot";
|
||||
ok =
|
||||
let
|
||||
g = baoWithMatrix.containers.hive-matrix.config.services.matrix-tuwunel.settings.global;
|
||||
in
|
||||
!(g ? admin_execute) || g.admin_execute == [ ];
|
||||
}
|
||||
];
|
||||
in
|
||||
runGroup "bao-matrix-reader" cases
|
||||
|
|
|
|||
|
|
@ -52,6 +52,17 @@ let
|
|||
swarm.hives.pubctl.domain = "p.t.local";
|
||||
};
|
||||
|
||||
# The THIRD element of the same list, colliding on its own so neither of the
|
||||
# two above can carry it. The minter's grant is one path rather than a whole
|
||||
# prefix, which is exactly why a dead entry here would be easy to miss: a
|
||||
# hive that inherited it would not obviously break anything, it would
|
||||
# silently gain the ability to overwrite the swarm's matrix credential.
|
||||
hiveNamedAfterMinterSubject = hive {
|
||||
deploy.swarm-otel.enable = false;
|
||||
deploy.bao.matrixMinterCommonName = "mintctl";
|
||||
swarm.hives.mintctl.domain = "m.t.local";
|
||||
};
|
||||
|
||||
hiveNameWithComposedWord = hive {
|
||||
deploy.swarm-otel.enable = false;
|
||||
swarm.hives."h1-agent".domain = "a.t.local";
|
||||
|
|
@ -88,6 +99,18 @@ let
|
|||
a: !a.assertion && lib.hasInfix "'pubctl'" a.message
|
||||
) hiveNamedAfterPublisherSubject.assertions;
|
||||
}
|
||||
{
|
||||
# And the third, for the reason the second one's comment gives one list
|
||||
# element earlier. `certAuthCns` is where a role added beside the others
|
||||
# has to register itself, and nothing but a case per element notices when
|
||||
# one forgets.
|
||||
name = "a hive named after the matrix minter's subject is refused too";
|
||||
ok =
|
||||
equalityGuardFired hiveNamedAfterMinterSubject
|
||||
&& lib.any (
|
||||
a: !a.assertion && lib.hasInfix "'mintctl'" a.message
|
||||
) hiveNamedAfterMinterSubject.assertions;
|
||||
}
|
||||
{
|
||||
# Without this the case above proves nothing: an arm that fires for every
|
||||
# roster is not a guard, and `hives` is non-empty in both fixtures.
|
||||
|
|
|
|||
Loading…
Reference in a new issue