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.>"] }