diff --git a/02-DECISIONS/0038-a-node-joins-by-linking-first.md b/02-DECISIONS/0038-a-node-joins-by-linking-first.md index e06721c..e0c0f22 100644 --- a/02-DECISIONS/0038-a-node-joins-by-linking-first.md +++ b/02-DECISIONS/0038-a-node-joins-by-linking-first.md @@ -1,5 +1,5 @@ --- -status: proposed +status: accepted date: 2026-08-25 deciders: jochen reconstructed: false @@ -89,8 +89,8 @@ makes it smaller. - **The rarely-run path is now the common one.** The first node exercises the same code every other node exercises constantly. That is the whole reason for choosing this over two modes. - **The link becomes the security boundary.** 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. **Not decided here, and it is a gap.** + what may be pushed, and how a joining node proves it is entitled to join, is its own + question — taken up by [ADR 0039](0039-the-link-is-the-security-boundary.md). - **The bundle must be able to raise the substrate alone.** Whether one host can bring up the four pinned services with no mesh present is Move 1 of the skeleton and remains unproven. This record depends on it and does not establish it. diff --git a/02-DECISIONS/0039-the-link-is-the-security-boundary.md b/02-DECISIONS/0039-the-link-is-the-security-boundary.md new file mode 100644 index 0000000..ef9d005 --- /dev/null +++ b/02-DECISIONS/0039-the-link-is-the-security-boundary.md @@ -0,0 +1,141 @@ +--- +status: proposed +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, so it is +proposed rather than measured — but what it replaces is measured, and that is the argument. + +### 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= + REGISTRY_MINIO_PASSWORD= +``` + +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. + +## 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. **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. + +## 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.