Settled in the operator's own words: nodes should not own passwords, only an identity when communicating to the broker. Recorded that way at the top of the record, because it is the whole decision in one line and the rest is why. What this obliges, in order of newness: enrolment is the one mechanism that does not exist. Per-node broker users, virtual hosts and per-queue permissions are broker configuration. Mutual authority is certificates on a connection already open. And the boundary must fail legibly, which is the requirement the debugging objection earned.
191 lines
9.8 KiB
Markdown
191 lines
9.8 KiB
Markdown
---
|
|
status: accepted
|
|
date: 2026-08-25
|
|
deciders: jochen
|
|
reconstructed: false
|
|
extends: 0038-a-node-joins-by-linking-first.md
|
|
---
|
|
|
|
# 39. The link is the security boundary
|
|
|
|
## Context
|
|
|
|
[ADR 0038](0038-a-node-joins-by-linking-first.md) makes the link the one channel a node takes
|
|
declarations from, and names the gap it leaves: *"everything a node applies arrives through it,
|
|
so what may be pushed, and how a joining node proves it is entitled to join, is now a question
|
|
worth its own record."*
|
|
|
|
This is that record. It is a design decision about a boundary that does not exist yet — but
|
|
what it replaces is measured, and that is the argument.
|
|
|
|
Settled as: **a node owns no password. It owns an identity, and that identity is what it
|
|
presents to the broker.**
|
|
|
|
### What adoption does today
|
|
|
|
`install.d/adopt.sh` asks the operator to paste credentials in by hand:
|
|
|
|
```
|
|
The meshware module needs registry database and minio credentials.
|
|
REGISTRY_DB_PASSWORD=<postgres password from novox>
|
|
REGISTRY_MINIO_PASSWORD=<minio password from novox>
|
|
```
|
|
|
|
plus an `NPM_TOKEN` for the private registry. These are not adoption-time credentials that are
|
|
then discarded: `wireguard` and `traefik` open a `pg` connection on every reconcile
|
|
([ADR 0037](0037-the-host-applies-it-does-not-decide.md)).
|
|
|
|
**So every node permanently holds a credential to the control plane's database, and to the
|
|
object store.** They are the same credentials on every node. There is no rotation —
|
|
[`00-as-is/06`](../03-DESIGN/00-as-is/06-configuration-and-secrets.md) records that *"there is
|
|
no mechanism that rotates one and informs everything holding it. Where a rotation has been
|
|
done, it has been done by hand, and doing it wrong has taken services down."*
|
|
|
|
Compromise of any node is therefore compromise of the mesh's database, and there is no
|
|
mechanism to recover from it.
|
|
|
|
### The link is not new
|
|
|
|
Written first as though the link were a thing to build. It is not.
|
|
[ADR 0001](0001-nodes-communicate-over-a-broker.md) already has it: *every node connects
|
|
outbound to a single broker; nothing ever connects to a node*, each node declaring an exchange
|
|
named for itself and consuming from its own queue
|
|
([`00-as-is/01`](../03-DESIGN/00-as-is/01-mesh-and-transport.md)).
|
|
|
|
That is already outbound-only, already per-node addressed, and already the one channel
|
|
everything arrives through. **This record is not proposing a channel. It is proposing that the
|
|
channel carry per-node identity instead of one shared credential.**
|
|
|
|
The same as-is records the fault, for the broker rather than the database: *"the broker is a
|
|
single point of failure and a single point of trust. Its credential is mesh-wide, so rotating
|
|
it is a mesh-wide operation, and doing it wrong has taken the broker down."*
|
|
|
|
## Considered options
|
|
|
|
1. **Keep shared credentials, scope them per node.** Least change: give each node its own
|
|
database role. Rejected — it makes the blast radius smaller without changing its shape, and
|
|
it keeps tier 0 speaking the control plane's schema, which ADR 0037 forbids for reasons that
|
|
are not about security at all.
|
|
2. **Accept the exposure as the cost of simplicity.** A shared credential is one thing to
|
|
understand and nothing to build, and the objection to replacing it is real: mutual
|
|
authentication fails opaquely, and a node that cannot link is harder to debug than a node
|
|
with a wrong password. Rejected on the ground that the simplicity is what makes it
|
|
unrotatable — the credential cannot be changed *because* everything holds the same one, so
|
|
the arrangement's convenience and its unfixability are the same property.
|
|
3. **Mutual authority on a node-initiated link, with the node holding nothing but its own
|
|
identity.** Chosen.
|
|
|
|
## Decision
|
|
|
|
**The link is the only way anything reaches a node**, and four properties make it a boundary
|
|
rather than a pipe.
|
|
|
|
### It is outbound and node-initiated
|
|
|
|
The node dials the control plane. Nothing dials a node. This is not only defensive — it is what
|
|
the topology already requires: most nodes sit behind a household connection with no forwarded
|
|
port ([research 004](../01-RESEARCH/004-lab-network/00-overview.md)), so an inbound control
|
|
channel would work for the hosted node and not for the rest, and the difference would be
|
|
invisible until it mattered.
|
|
|
|
A node therefore has **no listening control surface at all**.
|
|
|
|
### A node holds its own identity and nothing else
|
|
|
|
No shared secret, no credential to anything it does not own. A node's identity authenticates it
|
|
to the control plane and grants access to nothing else.
|
|
|
|
**Compromise of a node is compromise of that node.** That is the property today's arrangement
|
|
does not have, and it is the main reason for this record.
|
|
|
|
### Authority is mutual
|
|
|
|
The node proves it may join, and **the control plane proves it is the mesh**. One-way is not
|
|
enough here: the host applies whatever the link delivers, so a node that cannot tell the mesh
|
|
from something impersonating it will apply that something's declarations. Given ADR 0038, an
|
|
attacker who can answer a joining node's first call owns the machine.
|
|
|
|
### What may be pushed is bounded by form, not by trust
|
|
|
|
The control plane may push **declarations of known shape** and nothing else. It may not push a
|
|
command to run. The host's vocabulary is finite, versioned and auditable, and anything outside
|
|
it is refused rather than best-effort interpreted.
|
|
|
|
**Stated honestly: this bounds form, not impact.** A compromised control plane can declare
|
|
harmful state — a malicious package, an open firewall — and the host will apply it faithfully,
|
|
because that is what it is for. What the property buys is that the blast radius is describable:
|
|
it is exactly what the declaration language can express, which can be reviewed. An arbitrary
|
|
command channel has no such bound. This is a real limit and not a defence-in-depth story.
|
|
|
|
### Joining is a deliberate, bounded act
|
|
|
|
A joining node presents a **one-time, short-lived enrolment token** issued by the mesh for that
|
|
purpose, and exchanges it for its own durable identity. The token grants exactly one thing:
|
|
the right to become a node. It is not a credential to any service, it does not persist after
|
|
exchange, and it expires whether used or not.
|
|
|
|
This replaces hand-carried shared secrets with a thing that is useless once used and useless
|
|
after a while.
|
|
|
|
## What this actually costs
|
|
|
|
The objection to weigh is overhead, and it is smaller than it looks because most of it is
|
|
already running.
|
|
|
|
| Property | Where it comes from |
|
|
|---|---|
|
|
| outbound, node-initiated | already true — ADR 0001 |
|
|
| per-node addressing | already true — per-node exchange and queue |
|
|
| per-node credential | a broker user per node; the broker already has users, virtual hosts and per-queue permissions |
|
|
| mutual authority | transport-level certificates on a connection that already exists |
|
|
| bounded by form | already true — three message shapes and only three |
|
|
| **enrolment** | **the one genuinely new mechanism** |
|
|
|
|
And ADR 0037 subtracts rather than adds: under it a node holds **no** database credential at
|
|
all, so this record replaces three hand-carried shared secrets with one per-node identity that
|
|
grants only identity.
|
|
|
|
**It must fail legibly.** A boundary that refuses a node without saying why is worse than the
|
|
credential it replaced, because a wrong password at least announces itself. A node that cannot
|
|
link must report which side rejected it and on what grounds, in terms someone can act on. This
|
|
is `how-we-build` §5 applied to a security mechanism: a refusal that proves only that something
|
|
went wrong is transport reported as effect.
|
|
|
|
## Consequences
|
|
|
|
- **ADR 0037 removes a standing exposure as a side effect.** Its rule — the host never queries
|
|
the mesh database — was chosen for tier discipline. It also removes the reason every node
|
|
holds the database password. Worth recording because the two arguments are independent and
|
|
both hold.
|
|
- **Rotation becomes possible and is still not designed.** Per-node identities can be revoked
|
|
individually, which is what makes rotation tractable at all. The mechanism —
|
|
what rotates, on what trigger, and how holders learn — is **not decided here** and remains
|
|
the open weakness `00-as-is/06` records.
|
|
- **The enrolment token has to come from somewhere.** Issuing it is a control-plane operation
|
|
and the first node has no control plane, so the first node's identity is self-issued and
|
|
becomes the root of trust when the mesh comes up. **That is a real asymmetry** — the one
|
|
place ADR 0038's "no special first node" does not fully hold — and it is named here rather
|
|
than hidden.
|
|
- **A declaration vocabulary is now a security artefact, not only a design one.** Every
|
|
addition widens what a compromised control plane can express. That is a reason to keep it
|
|
small and a reason for additions to be reviewed as such.
|
|
- **Offline nodes need identities that survive disconnection.** Per
|
|
[ADR 0036](0036-a-node-is-a-managed-machine.md) disconnection is ordinary, so an identity
|
|
that must be refreshed to remain valid would make a laptop fail for being a laptop. What
|
|
expires and what does not is **not decided here**.
|
|
- **This is a boundary that does not exist yet.** Nothing in the current mesh implements any of
|
|
it, and the migration from shared credentials to per-node identity touches every node and the
|
|
substrate. No estimate is offered.
|
|
|
|
## References
|
|
|
|
- [ADR 0038](0038-a-node-joins-by-linking-first.md) — the link, and the gap this fills.
|
|
- [ADR 0037](0037-the-host-applies-it-does-not-decide.md) — why the host stops holding database
|
|
credentials at all.
|
|
- [ADR 0036](0036-a-node-is-a-managed-machine.md) — disconnection as ordinary, which constrains
|
|
what may expire.
|
|
- [`00-as-is/06-configuration-and-secrets.md`](../03-DESIGN/00-as-is/06-configuration-and-secrets.md)
|
|
— secrets today, and the absence of rotation.
|
|
- [Research 004](../01-RESEARCH/004-lab-network/00-overview.md) — why most nodes cannot accept
|
|
an inbound connection.
|