197 lines
8.9 KiB
Markdown
197 lines
8.9 KiB
Markdown
# Swarm CA
|
|
|
|
A hive's internal TLS chains to a **swarm root CA**: the root signs each
|
|
hive's own CA, and that hive CA signs the gateway leaf. A peer that
|
|
trusts the root once validates every hive in the swarm, present and
|
|
future, instead of being pinned to each one by hand.
|
|
|
|
That is the whole point of the hierarchy — it turns per-peer trust from
|
|
O(n²) hand-pinning into one anchor per swarm.
|
|
|
|
## Two provisioning modes, one structure
|
|
|
|
What differs is who puts the artifacts on disk:
|
|
|
|
| | swarm root | this hive's CA |
|
|
| --- | --- | --- |
|
|
| default | operator-provided | operator-provided, else self-signed as before |
|
|
| `autoConfigure = true` (all on one host) | generated by `swarm-ca.service` on first boot | issued by `hive-tls-ca.service` under the root |
|
|
|
|
`services.hyperhive.swarm.ca.autoConfigure` selects between them, and is
|
|
**off by default**: a swarm's services and its hives can live on
|
|
different hosts, and a host cannot tell whether it is the one holding
|
|
the root, so setting the swarm CA up is an operator action rather than
|
|
something a host assumes. Turn it on for an all-on-one-host deployment
|
|
and the hierarchy costs no configuration.
|
|
|
|
It defaults from `services.hyperhive.enableAllLocalDefaults`, the single
|
|
switch that says "this box is the whole deployment".
|
|
|
|
**A hive given neither artifact keeps the self-signed CA it has always
|
|
had.** It serves TLS exactly as before and simply isn't part of a
|
|
swarm's trust hierarchy — the right outcome for a hive nobody has
|
|
federated yet. Only `autoConfigure` issues a hive sub-CA, because only
|
|
that case can: signing one needs the root's private key.
|
|
|
|
Moving the swarm CA onto its own host is then a matter of moving
|
|
`services.hyperhive.swarm.ca.stateDir` and leaving `autoConfigure` off —
|
|
there is no second code path to switch to.
|
|
|
|
### The warning about uncovered service names
|
|
|
|
The swarm's service names (`swarm.forge.domain`,
|
|
`swarm.matrix.gatewayHost`, `swarm.authelia.domain`) default to
|
|
siblings of the hive domain — `forge.<swarm.domain>`, not
|
|
`forge.<hive domain>`. The hive CA's leaf is a **single-label** wildcard
|
|
over the hive's own domain, so it cannot cover them; only the
|
|
swarm-services leaf can, and this host signs that one only under
|
|
`autoConfigure`. A hive with neither serves the hive leaf on those names
|
|
and every client sees a name mismatch.
|
|
|
|
So `hive-tls` emits an eval-time **warning** naming the uncovered names.
|
|
It is deliberately not an assertion: this module can see what *it* is
|
|
able to issue, but not an operator-installed sub-CA in
|
|
`swarm.ca.stateDir`, an external ACME setup, or a certificate delivered
|
|
by any other means. If you have arranged one, the warning is expected
|
|
and can be ignored. Otherwise either install the sub-CA, or pin the
|
|
names back under the hive domain — a supported migration, since the
|
|
sub-CA is constrained to the *configured* names and the swarm root
|
|
carries no name constraints at all.
|
|
|
|
## Constraints on the material
|
|
|
|
The root's private key never reaches the nix store: the store is
|
|
world-readable and content-addressed, so a key committed to a flake is a
|
|
key published to everyone who builds it. Only certificates are
|
|
distributed.
|
|
|
|
Each hive CA is **name-constrained** (X.509 `nameConstraints`) to that
|
|
hive's own domain, so a hive CA that leaks can only mint names inside
|
|
its own subdomain — enforced by every verifier rather than by
|
|
convention. The constraint excludes both IP families as well, since a
|
|
permitted-DNS-only constraint says nothing about IP SANs.
|
|
|
|
The root is issued with `pathlen:1`: it may sign hive CAs, and those may
|
|
sign leaves, and the chain stops there.
|
|
|
|
## What to hand a peer
|
|
|
|
`hivectl peer-config` prints the `services.hyperhive.swarm.hives."<name>"`
|
|
block a peer operator pastes into their own config. When this hive's
|
|
gateway serves a self-signed leaf under the hierarchy (detected by the
|
|
presence of `<tls.stateDir>/trust-bundle.pem`), it also prints a one-time
|
|
`scp` line installing the **swarm root** —
|
|
`<swarm.ca.stateDir>/root.pem`, not this hive's own CA — on the peer's
|
|
host:
|
|
|
|
```
|
|
scp /var/lib/swarm-ca/root.pem <peer-host>:/var/lib/swarm-ca/root.pem
|
|
```
|
|
|
|
That is the point of the hierarchy: the root is installed **once per
|
|
swarm host**, not once per peer, so a hive joining later needs no edit on
|
|
the hives already running. A hive whose cert already chains to a public
|
|
CA has nothing to install — `peer-config` says so instead.
|
|
|
|
Handing a peer this hive's own `ca.pem` would not work even as a
|
|
one-off: once a hive CA is an intermediate under the swarm root, it is
|
|
no longer something a verifier can build a chain *to* — OpenSSL will not
|
|
terminate a chain at a trusted non-self-signed certificate without
|
|
`-partial_chain`. That is why the tool distributes the root, not a
|
|
per-hive file.
|
|
|
|
## Adopting the hierarchy on an existing hive
|
|
|
|
A hive that predates the swarm root carries a self-signed `ca.pem`, and
|
|
adopting the hierarchy means replacing it. That invalidates an anchor
|
|
consumers already trust, and they refresh on their own schedule — agents
|
|
only pick up new trust when their container restarts, peers only on
|
|
their own rebuild. **Who is allowed to decide that is what splits the
|
|
two cases.**
|
|
|
|
### Where this host owns the root (`autoConfigure`)
|
|
|
|
Adoption happens by itself, once. `hive-tls-ca.service` notices that
|
|
`ca.pem` does not chain to the root, keeps the old certificate as
|
|
`ca-previous.pem`, and re-issues under the root; the next leaf is signed
|
|
by the new CA.
|
|
|
|
It is safe to automate here precisely because this is the
|
|
all-on-one-host shape: every consumer is on this box, so "when will they
|
|
have refreshed" is knowable rather than guessed.
|
|
|
|
The old CA stays in `trust-bundle.pem` afterwards, so adoption is
|
|
**additive to the anchor set before it is subtractive** — a container
|
|
that has not restarted yet still validates. Removing `ca-previous.pem`
|
|
is a deliberate later step: how long is long enough is a property of the
|
|
deployment, not something the unit can know.
|
|
|
|
A marker file (`.swarm-ca-adopted`) records that this ran. Its *absence*
|
|
is the trigger, so adoption fires once per hive rather than being
|
|
re-decided on every activation.
|
|
|
|
### Everywhere else
|
|
|
|
No automatic adoption. `hive-tls-ca.service` fails, loudly, naming both
|
|
certificates and giving the two-command recipe:
|
|
|
|
```
|
|
rm <tls.stateDir>/ca.pem <tls.stateDir>/ca-key.pem
|
|
systemctl restart hive-tls-ca.service
|
|
```
|
|
|
|
Failing rather than warning is deliberate: a hive whose CA does not
|
|
chain to the root it has been given is misconfigured, and a warning in a
|
|
build log is not something anyone reads twice.
|
|
|
|
To keep the current CA on purpose — a hive that deliberately stays
|
|
outside the hierarchy, or one mid-migration — `touch` the marker file
|
|
named in the message. That is a decision, and it is recorded as one.
|
|
|
|
A hive with **no** root configured at all is not affected by any of
|
|
this: it self-signs exactly as it always has.
|
|
|
|
## Distributing the root
|
|
|
|
The root **key** is a runtime file for an obvious reason: the nix store
|
|
is world-readable and content-addressed, so a key committed to a flake
|
|
is a key *published* to every consumer of that flake.
|
|
|
|
The root **certificate** is a runtime file as a *consequence* — it lives
|
|
beside the key under `swarm.ca.stateDir` — and that has a cost worth
|
|
naming, because it is not obvious and it bites at a distance:
|
|
|
|
> **Nothing whose trust store is assembled at build time can reference
|
|
> the swarm root.** `security.pki.certificateFiles` is read inside the
|
|
> derivation; the root does not exist there.
|
|
|
|
Two consumers, and only one of them is fine:
|
|
|
|
- **Agents are covered.** `hive-tls.nix` folds the root into this hive's
|
|
`trust-bundle.pem`, hive-c0re receives that path as
|
|
`HIVE_TLS_CA_PATH`, and the meta-flake renderer embeds that one file
|
|
next to every agent's flake. The bundle is the runtime-to-build-time
|
|
bridge.
|
|
- **The Matrix container crosses the same bridge**, via the shared
|
|
`lib/hive-ca-trust.nix` helper that `hive-ci` and `hive-forge` already
|
|
use: the bundle is bind-mounted read-only into the container, and a
|
|
oneshot concatenates it with the system CAs before tuwunel starts.
|
|
|
|
The consumption differs per runtime and is the part worth knowing.
|
|
tuwunel links no openssl, which makes `SSL_CERT_FILE` look inapplicable
|
|
— it isn't: its rustls-based TLS stack still resolves trust through the
|
|
platform certificate store on Linux, and that store honors
|
|
`SSL_CERT_FILE`, so the env var takes effect the same way it would for
|
|
an OpenSSL-linked binary.
|
|
|
|
> ⚠️ **Concatenate; never point `SSL_CERT_FILE` at the anchor alone.**
|
|
> `openssl-probe` uses it *instead of* the default store, so naming
|
|
> just the bundle would drop every public CA and break federation with
|
|
> the wider matrix network — a much bigger outage than the one being
|
|
> fixed. The same caveat applies to `hive-forge` (Go) for the same
|
|
> reason.
|
|
|
|
To put the root on another host, copy the certificate to the same path
|
|
there (`scp <stateDir>/root.pem <host>:<stateDir>/root.pem`). One anchor
|
|
per host, not one per peer: a hive joining later needs no edit on the
|
|
hives already running, which is the whole point of the hierarchy.
|