Files
mesh-controller/internal/link/handover_test.go
T
jochen b55a38ca9f Sign the hand-over ask, and fail the line on the engine's refusal (hq issue 356, review)
The subject proved nothing: the bus lets any principal allowed to answer reply to a message it received on the reply subject that message named, so a tool server — the operator's account, every agent — could deliver a hand-over to an engine. The controller now signs the ask with the mesh's key over a fixed context (node, path, who asked, a minute's expiry, a fresh nonce), as declarations are signed, and the engine verifies it. The writers table gains the row for mesh.node.*.ask.hand-over; the subject's comment no longer claims who the engine hears. The line's known-node check and the refusal branch are tested; every check was removed in turn and a test failed.
2026-10-09 19:44:55 +02:00

178 lines
6.8 KiB
Go

package link
import (
"context"
"crypto/ed25519"
"encoding/json"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/testbus"
)
// The hand-over's ask and answer hold these field names; the engine's side holds the same list (mesh-host
// internal/link, TestTheHandOverAskAndAnswerKeepTheirFieldNames).
func TestTheHandOverAskAndAnswerKeepTheirFieldNames(t *testing.T) {
body, _ := json.Marshal(HandOverAsk{Node: "laptop", Path: "/srv/notes", By: "jo through mesh-cli on anchor",
Expires: time.Now(), Nonce: "n"})
if got := keysIn(t, body); got != "by expires node nonce path" {
t.Fatalf("the ask's fields are %q", got)
}
body, _ = json.Marshal(SignedHandOver{Ask: []byte("{}"), Signature: []byte("s")})
if got := keysIn(t, body); got != "ask signature" {
t.Fatalf("the signed ask's fields are %q", got)
}
body, _ = json.Marshal(HandOverAnswer{Said: "s", Refused: "r"})
if got := keysIn(t, body); got != "refused said" {
t.Fatalf("the answer's fields are %q", got)
}
if broker.AskHandOverSubject("laptop") != "mesh.node.laptop.ask.hand-over" {
t.Fatalf("the subject is %q", broker.AskHandOverSubject("laptop"))
}
if HandOverContext != "novox-mesh hand-over v1\n" {
t.Fatalf("the signing context is %q; the engine holds the same words", HandOverContext)
}
}
// keySigner signs with one key, as the controller's identity does.
type keySigner struct{ key ed25519.PrivateKey }
func (k keySigner) Sign(_ context.Context, message []byte) ([]byte, error) {
return ed25519.Sign(k.key, message), nil
}
func testSigner(t *testing.T) (keySigner, ed25519.PublicKey) {
t.Helper()
public, private, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
return keySigner{private}, public
}
// **The ask is signed over the context and its bytes, with a fresh nonce and a short expiry** (review of issue
// 356): the signature verifies over HandOverContext and the ask's bytes, and not over the bytes alone — so it is
// never a declaration's — and two asks never share a nonce.
func TestAHandOverIsSignedWithAContextANonceAndAnExpiry(t *testing.T) {
signer, public := testSigner(t)
body, err := SignHandOver(context.Background(), signer, HandOverAsk{Node: "laptop", Path: "/srv/notes", By: "jo"})
if err != nil {
t.Fatal(err)
}
var signed SignedHandOver
if err := json.Unmarshal(body, &signed); err != nil {
t.Fatal(err)
}
if !ed25519.Verify(public, append([]byte(HandOverContext), signed.Ask...), signed.Signature) {
t.Fatal("the signature does not verify over the context and the ask")
}
if ed25519.Verify(public, signed.Ask, signed.Signature) {
t.Fatal("the signature verifies over the ask's bytes alone, as a declaration's would")
}
var ask HandOverAsk
if err := json.Unmarshal(signed.Ask, &ask); err != nil {
t.Fatal(err)
}
if ask.Node != "laptop" || ask.Path != "/srv/notes" || ask.By != "jo" || len(ask.Nonce) != 32 {
t.Fatalf("signed %+v", ask)
}
if left := time.Until(ask.Expires); left <= 0 || left > HandOverGood {
t.Fatalf("the ask is good for %s", left)
}
again, _ := SignHandOver(context.Background(), signer, HandOverAsk{Node: "laptop", Path: "/srv/notes", By: "jo"})
var other SignedHandOver
_ = json.Unmarshal(again, &other)
var second HandOverAsk
_ = json.Unmarshal(other.Ask, &second)
if second.Nonce == ask.Nonce {
t.Fatal("two asks share a nonce")
}
if _, err := SignHandOver(context.Background(), nil, HandOverAsk{}); err == nil {
t.Fatal("an ask was made with no key")
}
}
// The ask reaches the machine's engine on its own subject and its answer comes back whole: what it recorded, or
// its refusal as the engine worded it. A machine with no engine listening is said as not answering, and an
// answer that is neither is refused rather than read as a record.
func TestAHandOverIsAskedOfTheMachineAndItsAnswerComesBack(t *testing.T) {
url := testbus.URL(t)
conn, err := nats.Connect(url)
if err != nil {
t.Fatal(err)
}
t.Cleanup(conn.Close)
signer, public := testSigner(t)
var heard HandOverAsk
engine, err := conn.Subscribe(broker.AskHandOverSubject("laptop"), func(msg *nats.Msg) {
var signed SignedHandOver
_ = json.Unmarshal(msg.Data, &signed)
if !ed25519.Verify(public, append([]byte(HandOverContext), signed.Ask...), signed.Signature) {
_ = msg.Respond([]byte(`{"refused":"not the mesh's signature"}`))
return
}
_ = json.Unmarshal(signed.Ask, &heard)
switch heard.Path {
case "/srv/notes":
body, _ := json.Marshal(HandOverAnswer{Said: "/srv/notes (notes.data) is handed to the mesh by " + heard.By})
_ = msg.Respond(body)
case "/srv/empty":
_ = msg.Respond([]byte(`{}`))
default:
body, _ := json.Marshal(HandOverAnswer{Refused: heard.Path + " is not a directory this machine uses as found; nothing was handed over"})
_ = msg.Respond(body)
}
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = engine.Unsubscribe() })
ctx := context.Background()
a, err := AskHandOver(ctx, conn, signer, "laptop", "/srv/notes", "jo through mesh-cli on anchor", 5*time.Second)
if err != nil || a.Refused != "" || !strings.Contains(a.Said, "handed to the mesh by jo through mesh-cli on anchor") {
t.Fatalf("answered %+v, %v", a, err)
}
if heard.Node != "laptop" || heard.Path != "/srv/notes" || heard.By != "jo through mesh-cli on anchor" {
t.Fatalf("the engine heard %+v", heard)
}
a, err = AskHandOver(ctx, conn, signer, "laptop", "/srv/other", "jo", 5*time.Second)
if err != nil || a.Said != "" || !strings.Contains(a.Refused, "nothing was handed over") {
t.Fatalf("a refusal came back as %+v, %v", a, err)
}
if _, err := AskHandOver(ctx, conn, signer, "laptop", "/srv/empty", "jo", 5*time.Second); err == nil ||
!strings.Contains(err.Error(), "neither") {
t.Fatalf("an empty answer was taken: %v", err)
}
if _, err := AskHandOver(ctx, conn, signer, "anchor", "/srv/notes", "jo", 5*time.Second); err == nil ||
!strings.Contains(err.Error(), "node-engine") {
t.Fatalf("a machine with no engine listening: %v", err)
}
if _, err := AskHandOver(ctx, nil, signer, "anchor", "/srv/notes", "jo", time.Second); err == nil {
t.Fatal("asked with no bus")
}
}
// A machine's grant hears its own hand-over ask and nobody else's, and may answer it: the one request a node is
// asked (novox/hq issue 356).
func TestAMachineHearsItsOwnHandOverAskAndMayAnswerIt(t *testing.T) {
perms, err := broker.PermissionsFor(broker.Principal{Kind: broker.KindNode, Node: "laptop", PasswordHash: "x"})
if err != nil {
t.Fatal(err)
}
var hears, other bool
for _, s := range perms.Subscribe {
hears = hears || s == "mesh.node.laptop.ask.hand-over"
other = other || s == "mesh.node.anchor.ask.hand-over"
}
if !hears || other || !perms.AllowResponses {
t.Fatalf("a machine's grant: hears its own %v, another's %v, answers %v (%v)", hears, other, perms.AllowResponses,
perms.Subscribe)
}
}