From 406a5559b0054daf50de489d30e7e43080f266f2 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 22 Sep 2026 14:33:31 +0200 Subject: [PATCH] An enrolling node signs its request with the identity it just generated, so the mesh can tell it from anyone who knows its public key (novox/hq issue 083) --- cmd/mesh-host/main.go | 6 +++++- internal/link/enrol.go | 18 ++++++++++++++++-- internal/link/enrol_proof_test.go | 13 +++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 internal/link/enrol_proof_test.go diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 73157a0..f4822b0 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -493,8 +493,12 @@ func enrol(ctx context.Context, opts options) error { _ = json.Unmarshal(raw, &reported) } + // Signed with the identity just generated, so the mesh can tell this machine from anyone else + // who knows its public key (novox/hq issue 083). + proof := mine.Sign(link.EnrolProof(token.Secret, mine.Public, mine.Overlay.Public, + sealing.Public, serving.Public)) reply, err := link.Enrol(ctx, token.Broker, token.Fingerprint, *name, token.Secret, - mine.Public, mine.Overlay.Public, sealing.Public, serving.Public, reported, opts.timeout) + mine.Public, mine.Overlay.Public, sealing.Public, serving.Public, reported, proof, opts.timeout) if err != nil { return err } diff --git a/internal/link/enrol.go b/internal/link/enrol.go index b227e66..6cf839b 100644 --- a/internal/link/enrol.go +++ b/internal/link/enrol.go @@ -2,6 +2,7 @@ package link import ( "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -46,6 +47,11 @@ type EnrolRequest struct { ServingKey string `json:"serving_key,omitempty"` Profile map[string]any `json:"profile,omitempty"` + + // Proof is this node's identity key signing EnrolProof over this request: that the presenter + // holds the private half of PublicKey. The mesh asks for it before letting an enrolment finish + // on a token this key already spent (novox/hq issue 083). + Proof []byte `json:"proof,omitempty"` } // EnrolReply is what the mesh says back. @@ -71,6 +77,13 @@ type EnrolReply struct { Refusal string `json:"refusal,omitempty"` } +// EnrolProof is what a node signs with its identity key when it enrols: the token and every key it +// presents, so a proof cannot be moved to another request. The mesh builds the same bytes. +func EnrolProof(secret string, public []byte, overlay, sealing, serving string) []byte { + return []byte("novox-mesh-enrol\x00" + secret + "\x00" + base64.StdEncoding.EncodeToString(public) + + "\x00" + overlay + "\x00" + sealing + "\x00" + serving) +} + // ErrRefused is what a node gets when the mesh will not have it. var ErrRefused = errors.New("the mesh refused this enrolment") @@ -111,7 +124,7 @@ func answered(reply EnrolReply, asking time.Duration) (again bool, err error) { // says once it is in, and the secret travels again because the control plane must not have to ask // the broker who connected. func Enrol(ctx context.Context, address, pin, node, secret string, public []byte, - overlayKey, sealingKey, servingKey string, profile map[string]any, + overlayKey, sealingKey, servingKey string, profile map[string]any, proof []byte, timeout time.Duration) (EnrolReply, error) { config, err := PinnedConfig(pin) @@ -158,7 +171,8 @@ func Enrol(ctx context.Context, address, pin, node, secret string, public []byte } request := EnrolRequest{Node: node, Secret: secret, PublicKey: public, - OverlayKey: overlayKey, SealingKey: sealingKey, ServingKey: servingKey, Profile: profile} + OverlayKey: overlayKey, SealingKey: sealingKey, ServingKey: servingKey, Profile: profile, + Proof: proof} body, err := json.Marshal(request) if err != nil { return EnrolReply{}, err diff --git a/internal/link/enrol_proof_test.go b/internal/link/enrol_proof_test.go new file mode 100644 index 0000000..6d6d47b --- /dev/null +++ b/internal/link/enrol_proof_test.go @@ -0,0 +1,13 @@ +package link + +import "testing" + +// The bytes a node signs when it enrols. The mesh builds the same bytes to check the signature, in +// another repository; this known answer is repeated in its test, so the two cannot drift apart +// without one of them failing (novox/hq issue 083). +func TestWhatAnEnrollingNodeSignsIsFixed(t *testing.T) { + got := string(EnrolProof("s", []byte{1, 2, 3}, "o", "e", "v")) + if want := "novox-mesh-enrol\x00s\x00AQID\x00o\x00e\x00v"; got != want { + t.Fatalf("the enrolment proof's message changed: %q, want %q", got, want) + } +}