The token carries the node's name

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.
This commit is contained in:
2026-08-30 02:36:57 +02:00
parent b9aac2b700
commit 02d1020bce
3 changed files with 38 additions and 8 deletions
+1 -1
View File
@@ -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.
+15 -1
View File
@@ -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")
}
+22 -6
View File
@@ -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())
}
}