A swarm runs one homeserver and every hive on it logged in as the same `@hive:` localpart, holding the same access token out of one swarm-wide store path. That is one matrix identity for N hives: the homeserver cannot attribute an action to the hive that took it, and revoking one hive's standing revokes every hive's. Three changes, and the third is the one that makes the other two real: - **The localpart carries the hive's name** (`hive-<hive>`), derived in one place, `swarm_secret_client::matrix::hive_localpart`. `hive-matrix.nix` renders the same string as the appservice registration's `sender_localpart`, so the shared account stops being created rather than merely stops being used. - **The store path is templated by hive**, not a constant. The "a swarm runs one homeserver, so this is a constant rather than a parameter" rationale went with it; it stopped holding the moment two hives shared the homeserver it describes. - **The path moved out from under the grant every hive has.** It sat at `swarm/services/matrix/sender-token`, inside the `secret/data/swarm/services/*` read stanza `policy::render` gives every hive. It now sits under that hive's own stanza, `secret/data/swarm/hives/<hive>/*`, which interpolates the reader's name — so a hive reads its own token and is refused another's. The policy renderer itself is unchanged: narrowing the `services/*` grant would break the OIDC-secret read it exists for, and moving the credential is what this needed instead. A policy test walks the rendered stanzas and asserts none of hive alpha's covers hive beta's sender token, so a later stanza that widened it fails here. `swarm-matrix-ctl` takes a new required `MATRIX_MINT_HIVE` and writes that hive's path; its store grant in `swarm-bao.nix` follows, scoped to one hive's leaf via the new `deploy.bao.matrixCtlHiveName` (defaulting to this host's `hiveName`) rather than a `hives/*` wildcard, which would hand the matrix container every hive's token back. Migration: no outage at deploy. `ensure_hive_user` short-circuits on the local token file, so a hive keeps running on what it has; with no such file it reads the new per-hive path, finds nothing, and falls through to the existing register-or-appservice-login ladder against its own localpart — which needs only the per-hive `as_token` on local disk. The old shared object is read by nothing afterwards. Rooms do not follow the identity, and that is the one operator step; both ways out are written into `docs/integrations/matrix.md`. No admin standing is granted to the per-hive accounts: `admin_execute` stays empty and the assertion pinning it is untouched.
123 lines
8.7 KiB
Markdown
123 lines
8.7 KiB
Markdown
# Credentials: the target shape
|
|
|
|
<!-- vale write-good.Passive = NO -->
|
|
|
|
The swarm's credential store is bao. This page describes the shape every
|
|
credential is meant to have — who mints it, who reads it, and how it renews
|
|
— not what's on disk today. [`secrets.md`](secrets.md) remains the map of
|
|
the files that exist right now; this page replaces it, and `secrets.md` gets
|
|
deleted, once the swarm's credential path matches what's described below.
|
|
|
|
<!-- vale write-good.Passive = YES -->
|
|
|
|
**Public material is a value.** The store hands a certificate or a public
|
|
nkey to every client that connects, so it's a fine place for that material.
|
|
Nothing below is about those.
|
|
|
|
**No secret the store holds is ever written to disk.** That's the invariant,
|
|
and everything else in this page follows from it. A value pulled from the
|
|
store — bao — lives in the memory of the process that asked for it and
|
|
nowhere else: not in a state directory, not in a bind-mounted file, not in a
|
|
systemd credential, not in a rendered config, not for a moment before a unit
|
|
deletes it. The target isn't a shorter list of secret files. It's the store,
|
|
plus one file per identity.
|
|
|
|
**Those files are mTLS client certificates, one per identity, and they're
|
|
the only credential on disk.** Each has to be a file, and the reason is the
|
|
whole asymmetry: the certificate is what authenticates a principal to the
|
|
store, so it's the one credential nothing can fetch from the store.
|
|
Something has to exist on disk before the first request, or there's nothing
|
|
to make the request with. Every identity — an agent, a hive, a swarm-level
|
|
service — needs one; a host running several holds several, and its only
|
|
power is to ask the store for the rest.
|
|
`swarm-bao.nix:529-533` states the rule for the nix option that carries it:
|
|
this is _"the credential an operator places by hand"_, and _"a path, never a
|
|
value."_ A literal in a nix expression lands in the nix store —
|
|
world-readable and permanent — so that option takes a path to the
|
|
certificate on disk, never the certificate's bytes.
|
|
|
|
**The hive hands an agent an identity, never a secret.** Its hive passes an
|
|
agent container an mTLS certificate, and from then on the agent authenticates
|
|
to the store under its own name, pulling what it needs when it needs it. No
|
|
process reads a secret on another principal's behalf: the principal that
|
|
needs a value is the principal that authenticates for it.
|
|
|
|
Two per-agent credential files — the forge token and the github token — sit
|
|
outside this page: they're operator-supplied and never pass through the
|
|
store, so the table below doesn't govern them.
|
|
|
|
**Per secret, the target specifies minter, reader, and renewal strategy.**
|
|
Those three are the contract, and the reader is a process pulling a store
|
|
path at runtime — not a path on disk, and not a unit whose job is to turn a
|
|
store value into a file. A renewal cell may never read `NONE`: state the
|
|
strategy for every credential, including the mTLS leaf.
|
|
|
|
<!-- vale write-good.Passive = NO -->
|
|
|
|
| store path | minter | reader — pulls at runtime, holds in memory | renewal |
|
|
| -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- |
|
|
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
|
|
| `swarm/agents/<agent>/bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated |
|
|
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated |
|
|
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated |
|
|
| `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated |
|
|
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated |
|
|
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated |
|
|
|
|
<!-- vale write-good.Passive = YES -->
|
|
|
|
**An agent's mTLS leaf is in the store; a hive's isn't, and the difference
|
|
isn't an inconsistency.** The rule the exception protects is that nothing
|
|
can fetch from the store the credential it would need in order to fetch. A
|
|
hive's leaf is that credential, so it can only come off disk. The _hive_,
|
|
which already holds one, reads an agent's — so publishing it costs nothing
|
|
and buys the property this page asks for: the swarm mints it, the hive only
|
|
carries it, and no hive ever needs the capability to mint an identity.
|
|
`swarm-controller` proves the leaf it publishes before the creation job
|
|
reports success, by logging in with it and reading the row back.
|
|
|
|
**Who reads that row, and what happens to it.** `hive-c0re` reads it every
|
|
time it writes an agent's container configuration
|
|
(`lifecycle::agent_identity`), stages the certificate and its key `0600`
|
|
outside every bind-mounted tree, and passes both to the container as systemd
|
|
credentials — the same mechanism, and for the same mode reason, as the
|
|
per-hive queue secret. A bind mount would hand the agent's unprivileged user
|
|
a file it lacks the rights to open; the container manager reads a credential
|
|
as root and re-exposes it under the consuming unit's own user.
|
|
|
|
Inside the container, `hive-agent-bao-identity.service` logs in with that
|
|
certificate and reads this row back before reporting success, so an agent
|
|
locked out of its own identity says so at boot rather than at whichever pull
|
|
needed the store first. The unit exists whenever
|
|
`services.hyperhive.agent.bao.addr` has a value, which the hive's meta flake
|
|
sets from its own store address — the same all-or-nothing gate the per-hive
|
|
queue credential beside it uses, and the reason the delivery above never lands
|
|
in a container with nothing to read it. It fails loudly where the hive-side
|
|
readers degrade quietly, which is deliberate: a missing queue secret means a
|
|
swarm whose publisher has yet to run, while a refused certificate means an
|
|
agent that believes it reaches the store and never does.
|
|
|
|
## Progressive enhancement
|
|
|
|
New functionality has to match this shape immediately — no PR introducing a
|
|
credential gets a pass on any of the rules below. A PR can move existing
|
|
functionality step by step, as long as each individual step moves toward the
|
|
target shape; a step that doesn't isn't allowed just because it's existing.
|
|
|
|
A pull request that touches a credential can't:
|
|
|
|
- add a minter outside the swarm's existing mint path
|
|
- persist a store-provided secret to disk — a state directory, a bind mount,
|
|
a rendered config
|
|
- add a credential whose renewal strategy is `NONE` — state the strategy,
|
|
even if it's "operator reissues and restarts the reader"
|
|
- read a secret on another principal's behalf and hand it over — the
|
|
principal that needs the value authenticates for it
|
|
- give a host or container an out-of-band credential that isn't the store
|
|
mTLS leaf — one out-of-band credential per principal is the whole point of
|
|
the store
|
|
|
|
While the swarm's credential path is still moving to this shape, a PR that
|
|
moves a secret into bao may leave its renewal strategy unresolved, provided
|
|
it opens a follow-up issue to settle renewal. That's a migration-era
|
|
allowance, not a standing exception to the renewal-strategy rule above.
|