From 7180a273a283a11958a7dd9679e4b26a8e38adb5 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 01:31:34 +0200 Subject: [PATCH] The enrolment user is per token, and it has an inbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design 25 §6 says an enrolling node subscribes the inbox its own token derives. It had none: `sub` was empty, so a node would publish its request and wait out its timeout against a mesh that had answered — the handshake could not have completed. And there was one shared `enrolment` user, which cannot carry that inbox at all: a permission belongs to a user, so an inbox per token means a user per token. Named after the node, which **is** the token's id — a token is issued for a node record, the mesh holds one live claim per record, and the node's name is the one identifier both sides have before anything else is agreed. It is also exactly what the other transport does, where the account is named after the node and the secret is its password. A nameless enrolment user is now refused rather than composed into `_INBOX.enrol..>`: an empty subject token, and worse, one every nameless enrolment user would share — which is one machine able to read the credentials sealed to another. Still to wire: something that composes one of these per live token. Nothing composes enrolment users yet, on either bus — on the old one the account is made imperatively through the broker's management API when a token is issued, and here there is no management API, so issuing a token has to recompose the server's configuration. That is the remaining half of enrolment on the new bus. --- internal/broker/nats.go | 29 ++++++++++++++++++++++++-- internal/broker/nats_golden_test.go | 2 +- internal/broker/nats_test.go | 21 ++++++++++++++++--- internal/broker/testdata/composed.conf | 4 ++-- 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 1dbb11f..83c0e87 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -13,6 +13,7 @@ package broker import ( + "errors" "fmt" "regexp" "sort" @@ -94,7 +95,16 @@ func (p Principal) Username() string { case KindController: return "controller" case KindEnrolment: - return "enrolment" + // Per token, not one shared user. **The inbox is the reason**: with a single `enrolment` + // user every machine enrolling at once could read every other's answer, and an answer + // carries that node's credentials sealed to it. Design 25 §6 says the inbox a token + // derives, and a permission belongs to a user, so the user is per token. + // + // Named after the node, which **is** the token's id: a token is issued for a node record, + // the mesh holds one live claim per record, and the node's name is the one identifier both + // sides already have before anything else is agreed. It is also exactly what the other + // transport does, where the account is named after the node and the secret is its password. + return "enrol." + p.Node } return "" } @@ -172,8 +182,23 @@ func PermissionsFor(p Principal) (Permissions, error) { case KindEnrolment: // A leaked token is useless for anything but enrolling: it cannot read a declaration, hear // an event, or subscribe any inbox but the one its own token derives (design 25 §6). + // + // **The inbox was missing and the handshake could not have completed without it.** An + // enrolling node publishes its request and waits on an address it states in the payload; + // with nothing to subscribe it waits out its timeout against a mesh that answered. Its own + // and no wider: `_INBOX.enrol..>`, so what is sealed to one machine cannot be read by + // another enrolling beside it. + if p.Node == "" { + // Refused rather than composed into `_INBOX.enrol..>`, which is a subject with an empty + // token in it — and worse, one every nameless enrolment user would share. A shared + // enrolment inbox is one machine able to read the credentials sealed to another. + return Permissions{}, errors.New( + "an enrolment user names no node, so its inbox would be shared with every other " + + "enrolment: a token is issued for a node record, and that record's name is " + + "the token's id") + } pub = []string{"mesh.control.enrol"} - sub = []string{} + sub = []string{p.inbox()} case KindNode: // A host publishes its own node's control traffic and subscribes its own declaration — diff --git a/internal/broker/nats_golden_test.go b/internal/broker/nats_golden_test.go index 40f2131..6c3b2da 100644 --- a/internal/broker/nats_golden_test.go +++ b/internal/broker/nats_golden_test.go @@ -20,7 +20,7 @@ func TestTheComposedConfigMatchesTheGolden(t *testing.T) { TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"}, []Principal{ {Kind: KindController, PasswordHash: "$2a$11$cccccccccccccccccccccc"}, - {Kind: KindEnrolment, PasswordHash: "$2a$11$eeeeeeeeeeeeeeeeeeeeee"}, + {Kind: KindEnrolment, Node: "one", PasswordHash: "$2a$11$eeeeeeeeeeeeeeeeeeeeee"}, {Kind: KindNode, Node: "one", PasswordHash: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn"}, {Kind: KindModule, Node: "one", Module: "telegram", Holds: []Seat{seat}, Serves: []string{"status"}, PasswordHash: "$2a$11$tttttttttttttttttttttt"}, diff --git a/internal/broker/nats_test.go b/internal/broker/nats_test.go index f1aa154..f5c2500 100644 --- a/internal/broker/nats_test.go +++ b/internal/broker/nats_test.go @@ -115,12 +115,27 @@ func TestAHostIsConfinedToItsOwnNode(t *testing.T) { // A leaked enrolment token is useless for anything but enrolling (design 25 §6). func TestTheEnrolmentUserCanOnlyEnrol(t *testing.T) { - perms, _ := PermissionsFor(Principal{Kind: KindEnrolment, PasswordHash: "x"}) + perms, err := PermissionsFor(Principal{Kind: KindEnrolment, Node: "anchor", PasswordHash: "x"}) + if err != nil { + t.Fatal(err) + } if len(perms.Publish) != 1 || perms.Publish[0] != "mesh.control.enrol" { t.Fatalf("enrolment may publish %v", perms.Publish) } - if len(perms.Subscribe) != 0 { - t.Fatalf("enrolment may subscribe %v, and should hear nothing", perms.Subscribe) + // Its own inbox and nothing else. **Nothing else** is the point: no declaration, no event, and + // no other machine's answer — and the inbox itself is needed, because a node that cannot + // subscribe one waits out its timeout against a mesh that answered. + if len(perms.Subscribe) != 1 || perms.Subscribe[0] != "_INBOX.enrol.anchor.>" { + t.Fatalf("enrolment may subscribe %v, which is not its own inbox alone", perms.Subscribe) + } +} + +// An enrolment user that names no node is refused: its inbox would be an empty subject token, and +// one that every nameless enrolment user shared — which is one machine reading the credentials +// sealed to another. +func TestAnEnrolmentUserWithoutANodeIsRefused(t *testing.T) { + if _, err := PermissionsFor(Principal{Kind: KindEnrolment, PasswordHash: "x"}); err == nil { + t.Fatal("an enrolment user with no node was composed, so its inbox is shared") } } diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index edfd456..489435a 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -24,9 +24,9 @@ accounts { subscribe: { allow: ["$JS.API.>", "_INBOX.controller.>", "mesh.build.>", "mesh.control.>", "mesh.mod.mesh-catalog.event.module.mesh-catalog.catching-up", "mesh.mod.mesh-catalog.event.module.mesh-catalog.upgraded"] } allow_responses: { max: 1, ttl: "1m" } } } - { user: "enrolment", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: { + { user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: { publish: { allow: ["mesh.control.enrol"] } - subscribe: { allow: [] } + subscribe: { allow: ["_INBOX.enrol.one.>"] } } } { user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: { publish: { allow: ["$JS.ACK.NODES.one.>", "mesh.control.one.>"] }