From 02d1020bce8939ff514eef237708fc1437f69ecb Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 30 Aug 2026 02:36:57 +0200 Subject: [PATCH] The token carries the node's name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by raising a mesh end to end. The broker account a joining node authenticates as is named after the node, and exists before that machine has been told anything — so the node has to know its name before the mesh can tell it. Without it, enrolment fails at the broker with an empty username, which says nothing about why. Not a secret, and the issuer already knows it. The wire-format test now covers it, so a rename on either side fails in both repositories rather than at enrolment on a real machine. --- cmd/mesh-control/main.go | 2 +- internal/token/token.go | 16 +++++++++++++++- internal/token/token_test.go | 28 ++++++++++++++++++++++------ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index 3f9dfa5..977dc7d 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -352,7 +352,7 @@ func tokenCommand(ctx context.Context, args []string) error { return err } - made := token.Token{Signer: key.Public, Secret: issued.Secret} + made := token.Token{Node: issued.Node.Name, Signer: key.Public, Secret: issued.Secret} // Absent is a state, not a failure: a control plane can hold records and a key before it has // a broker. What it cannot do is issue a token anybody could use, and Missing() says so. diff --git a/internal/token/token.go b/internal/token/token.go index 77e5470..ef99fbe 100644 --- a/internal/token/token.go +++ b/internal/token/token.go @@ -19,7 +19,7 @@ import ( "strings" ) -// Token is the four things, and it is assembled by whatever holds all four. +// Token is everything a joining node needs, assembled by whatever holds all of it. // // Two contexts contribute: `inventory` owns the node record and mints the secret, `identity` owns // the signing key. Neither reads the other's store — the process holding both grants asks each @@ -27,6 +27,17 @@ import ( type Token struct { Version int `json:"v"` + // Node is what the mesh calls this machine. + // + // Not a secret and not the node's to choose — the record was created before the token was + // issued, and the broker account the node must authenticate as is named after it. So the node + // has to know it *before* the mesh can tell it anything, which is why it travels here. + // + // It was not here at first, and enrolment then needed a separate flag while its own help said + // the token was the only thing required. The failure that produced was a connection refused + // with an empty username, which says nothing about the cause. + Node string `json:"node,omitempty"` + // Broker is an address and not a name. There is no resolution before joining, which is why // this is the one place in the mesh where an address is carried deliberately. Broker string `json:"broker,omitempty"` @@ -50,6 +61,9 @@ type Token struct { // a compromised broker could then forge declarations, and that is the whole machine. func (t Token) Missing() []string { var missing []string + if strings.TrimSpace(t.Node) == "" { + missing = append(missing, "the node's name — the broker account is named after it") + } if strings.TrimSpace(t.Broker) == "" { missing = append(missing, "the broker's address — there is nowhere to connect to") } diff --git a/internal/token/token_test.go b/internal/token/token_test.go index 86b8f10..a7860a1 100644 --- a/internal/token/token_test.go +++ b/internal/token/token_test.go @@ -14,6 +14,7 @@ func complete(t *testing.T) Token { t.Fatal(err) } return Token{ + Node: "anchor", Broker: "192.0.2.10:5671", Fingerprint: "sha256:" + strings.Repeat("ab", 32), Signer: public, @@ -85,8 +86,8 @@ func TestEveryMissingPartIsNamed(t *testing.T) { // decision into four. empty := Token{} missing := empty.Missing() - if len(missing) != 4 { - t.Fatalf("an empty token named %d missing parts, expected 4: %v", len(missing), missing) + if len(missing) != 5 { + t.Fatalf("an empty token named %d missing parts, expected 5: %v", len(missing), missing) } if empty.Complete() { t.Error("an empty token reported itself complete") @@ -105,7 +106,7 @@ func TestAShortSigningKeyIsNotASigningKey(t *testing.T) { func TestACompleteTokenIsComplete(t *testing.T) { if got := complete(t); !got.Complete() { - t.Errorf("a token with all four parts reported missing: %v", got.Missing()) + t.Errorf("a token with every part reported missing: %v", got.Missing()) } } @@ -131,12 +132,27 @@ func TestTheWireFormatIsExactlyTheseFieldNames(t *testing.T) { if err := json.Unmarshal(raw, &fields); err != nil { t.Fatal(err) } - for _, want := range []string{"v", "broker", "fingerprint", "signer", "secret"} { + for _, want := range []string{"v", "node", "broker", "fingerprint", "signer", "secret"} { if _, ok := fields[want]; !ok { t.Errorf("the token has no %q field; the host reads that name", want) } } - if len(fields) != 5 { - t.Errorf("the token has %d fields, expected 5: %v", len(fields), fields) + if len(fields) != 6 { + t.Errorf("the token has %d fields, expected 6: %v", len(fields), fields) + } +} + +func TestATokenWithNoNameIsRefused(t *testing.T) { + // The broker account a joining node authenticates as is named after the node, so the node has + // to know its name before the mesh can tell it anything. Without this the connection is + // refused with an empty username, which says nothing about the cause — which is exactly how + // it went the first time a mesh was raised end to end. + without := complete(t) + without.Node = "" + if without.Complete() { + t.Fatal("a token with no node name reported itself usable") + } + if !strings.Contains(strings.Join(without.Missing(), " "), "node's name") { + t.Fatalf("the refusal does not say what is missing: %v", without.Missing()) } }