Files
mesh-host/internal/link/handover_test.go
T
jochen fc6c5a92e6
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Believe a hand-over only signed by the mesh, and give it through no link (hq issue 356, review)
Two holes the review proved. The ask's subject proved nothing — a responder can reach it through a reply — so the engine now verifies the mesh's signature over a fixed context with the key it verifies declarations with, before reading anything, and refuses an ask for another node, expired, good too long, without a nonce, or replayed. And the apply followed a link the hand-over had refused: a component swapped for a link after the record carried root's chmod and chown to its target. A handed-over directory must now still resolve to itself, and is changed only through one descriptor opened from / with no link followed (fchmod, fchown). Tests for the reply guard, the run loop's hook, the fallback renewal on proof and a non-regular fallback; 27 checks each removed in turn, each failing a test.
2026-10-09 19:45:00 +02:00

241 lines
10 KiB
Go

package link
import (
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"sort"
"strings"
"testing"
"time"
)
// The hand-over's ask and answer hold these field names; the controller's side holds the same list
// (mesh-controller internal/link, TestTheHandOverAskAndAnswerKeepTheirFieldNames).
func TestTheHandOverAskAndAnswerKeepTheirFieldNames(t *testing.T) {
body, _ := json.Marshal(HandOverAsk{Node: "laptop", Path: "/srv/notes", By: "jo", Expires: time.Now(), Nonce: "n"})
if got := keysOf(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 := keysOf(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 := keysOf(t, body); got != "refused said" {
t.Fatalf("the answer's fields are %q", got)
}
if AskHandOverSubject("laptop") != "mesh.node.laptop.ask.hand-over" {
t.Fatalf("the subject is %q", AskHandOverSubject("laptop"))
}
if HandOverContext != "novox-mesh hand-over v1\n" {
t.Fatalf("the signing context is %q; the controller holds the same words", HandOverContext)
}
}
func keysOf(t *testing.T, body []byte) string {
t.Helper()
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatal(err)
}
var keys []string
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return strings.Join(keys, " ")
}
const aNonce = "00112233445566778899aabbccddeeff"
// signedAsk is an ask as the controller makes it, signed with key over the context and its bytes.
func signedAsk(t *testing.T, key ed25519.PrivateKey, ask HandOverAsk) []byte {
t.Helper()
raw, err := json.Marshal(ask)
if err != nil {
t.Fatal(err)
}
body, err := json.Marshal(SignedHandOver{Ask: raw, Signature: ed25519.Sign(key, append([]byte(HandOverContext), raw...))})
if err != nil {
t.Fatal(err)
}
return body
}
// anAsk is a good ask at now.
func anAsk(now time.Time) HandOverAsk {
return HandOverAsk{Node: "n1", Path: "/srv/notes", By: " jo through mesh-cli on anchor ", Expires: now.Add(HandOverGood),
Nonce: aNonce}
}
// **An ask is believed only signed by the mesh, for this node, unexpired, once** (review of issue 356): a
// responder can reach the subject through a reply, so the subject proves nothing. Each refusal below hands
// the handler nothing; the good ask reaches it with its path and who asked, and the same ask again is refused.
func TestAHandOverAskIsBelievedOnlySignedForThisNodeUnexpiredAndOnce(t *testing.T) {
m, key := aMember(t)
_, stranger, _ := ed25519.GenerateKey(nil)
now := time.Date(2026, 10, 9, 18, 0, 0, 0, time.UTC)
var given []string
handle := func(path, by string) (string, error) {
given = append(given, path+" by "+by)
if path == "/refused" {
return "", errors.New("not a directory this machine uses as found; nothing was handed over")
}
return path + " is handed to the mesh", nil
}
check := HandOverCheck{Signer: m.Signer, Node: m.Node, Now: func() time.Time { return now }, Nonces: &Nonces{}}
with := func(change func(*HandOverAsk)) HandOverAsk {
a := anAsk(now)
change(&a)
return a
}
good, _ := json.Marshal(anAsk(now))
unsignedBody, _ := json.Marshal(SignedHandOver{Ask: good})
// Signed as a declaration is, over the bytes alone: not a hand-over's signature.
asDeclaration, _ := json.Marshal(SignedHandOver{Ask: good, Signature: ed25519.Sign(key, good)})
refusals := map[string][]byte{
"too large": signedAsk(t, key, with(func(a *HandOverAsk) { a.Path = "/" + strings.Repeat("a", MaxHandOverAsk) })),
"not a signed hand-over": []byte(`not json`),
"unsigned": unsignedBody,
"signed by another key": signedAsk(t, stranger, anAsk(now)),
"signed as a declaration": asDeclaration,
"the bare ask": good,
"for another node": signedAsk(t, key, with(func(a *HandOverAsk) { a.Node = "n2" })),
"expired": signedAsk(t, key, with(func(a *HandOverAsk) { a.Expires = now })),
"no expiry": signedAsk(t, key, with(func(a *HandOverAsk) { a.Expires = time.Time{} })),
"good for too long": signedAsk(t, key, with(func(a *HandOverAsk) { a.Expires = now.Add(HandOverGood + HandOverSkew + time.Second) })),
"no nonce": signedAsk(t, key, with(func(a *HandOverAsk) { a.Nonce = "" })),
"a short nonce": signedAsk(t, key, with(func(a *HandOverAsk) { a.Nonce = "0011" })),
"nobody": signedAsk(t, key, with(func(a *HandOverAsk) { a.By = " " })),
"two lines": signedAsk(t, key, with(func(a *HandOverAsk) { a.By = "jo\nroot" })),
"a name too long": signedAsk(t, key, with(func(a *HandOverAsk) { a.By = strings.Repeat("x", MaxHandOverBy+1) })),
"a NUL in the name": signedAsk(t, key, with(func(a *HandOverAsk) { a.By = "jo\x00" })),
"no directory": signedAsk(t, key, with(func(a *HandOverAsk) { a.Path = "" })),
}
for name, body := range refusals {
a := AnswerHandOver(check, handle, body)
if a.Refused == "" || a.Said != "" || !strings.Contains(a.Refused, "nothing was handed over") {
t.Fatalf("%s was answered %+v; want a refusal saying nothing was handed over", name, a)
}
}
if len(given) != 0 {
t.Fatalf("the handler was given %v although every ask was refused", given)
}
if a := AnswerHandOver(HandOverCheck{Node: "n1", Nonces: &Nonces{}}, handle, signedAsk(t, key, anAsk(time.Now()))); a.Refused == "" {
t.Fatal("an engine with no key believed an ask")
}
if a := AnswerHandOver(check, nil, signedAsk(t, key, anAsk(now))); a.Refused == "" {
t.Fatal("an engine with no handler took a hand-over")
}
body := signedAsk(t, key, anAsk(now))
a := AnswerHandOver(check, handle, body)
if a.Said != "/srv/notes is handed to the mesh" || a.Refused != "" {
t.Fatalf("the good ask was answered %+v", a)
}
if again := AnswerHandOver(check, handle, body); !strings.Contains(again.Refused, "taken already") {
t.Fatalf("the same ask again was answered %+v", again)
}
refused := signedAsk(t, key, with(func(a *HandOverAsk) { a.Path = "/refused"; a.Nonce = strings.Repeat("ab", 16) }))
if a := AnswerHandOver(check, handle, refused); !strings.HasPrefix(a.Refused, "not a directory") {
t.Fatalf("the handler's refusal was answered %+v", a)
}
if strings.Join(given, "; ") != "/srv/notes by jo through mesh-cli on anchor; /refused by jo through mesh-cli on anchor" {
t.Fatalf("the handler was given %v", given)
}
// A nonce is forgotten once its ask has expired, so the set does not grow for ever.
now = now.Add(HandOverGood + time.Second)
check.Nonces.take("ff", now.Add(time.Second), now)
if len(check.Nonces.seen) != 1 {
t.Fatalf("expired nonces are kept: %v", check.Nonces.seen)
}
}
// **A request with no reply is nobody's question**: nothing is judged, recorded or said. One with a reply is
// answered with the judgement.
func TestAHandOverIsAnsweredOnlyWhenItCarriesAReply(t *testing.T) {
m, key := aMember(t)
now := time.Now()
called := 0
handle := func(string, string) (string, error) { called++; return "handed", nil }
check := HandOverCheck{Signer: m.Signer, Node: m.Node, Nonces: &Nonces{}}
if body, answer := HandOverReply(check, handle, "", signedAsk(t, key, anAsk(now))); answer || body != nil || called != 0 {
t.Fatalf("a request with no reply was answered %q %v, handled %d", body, answer, called)
}
body, answer := HandOverReply(check, handle, "_INBOX.x", signedAsk(t, key, anAsk(now)))
var said HandOverAnswer
if !answer || json.Unmarshal(body, &said) != nil || said.Said != "handed" || called != 1 {
t.Fatalf("answered %q %v, handled %d", body, answer, called)
}
}
// handingLink is a quiet link that hears hand-overs, keeping the handler it was given.
type handingLink struct {
*quietLink
given chan HandOverFunc
}
func (l handingLink) HearHandOvers(h HandOverFunc) { l.given <- h }
// **The link is given the queue's hand-over** when it is served: what the controller asks reaches the engine's
// judgement, and nothing else.
func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) {
m, _ := aMember(t)
l := handingLink{quietLink: newQuietLink(), given: make(chan HandOverFunc, 1)}
q := aQueue(m, (&applies{}).apply, &keptInMemory{})
q.HandOver = func(path, by string) (string, error) { return "the queue's: " + path + " by " + by, nil }
ctx, stop := context.WithCancel(context.Background())
served := make(chan error, 1)
go func() { served <- serve(ctx, l, m, q, nil, time.Second) }()
select {
case h := <-l.given:
if said, err := h("/srv/notes", "jo"); err != nil || said != "the queue's: /srv/notes by jo" {
t.Fatalf("the link was given another handler: %q %v", said, err)
}
case <-time.After(5 * time.Second):
t.Fatal("the link was never given the queue's hand-over")
}
stop()
<-served
}
// **Against a real server**: a signed request on the node's own subject is answered; one on another node's is
// not heard; a message with no reply is answered by nobody.
func TestNatsAHandOverIsAskedOfTheNode(t *testing.T) {
conn, js := aBus(t)
m, key := aMember(t)
l := &natsLink{conn: conn, js: js, node: m.Node, signer: m.Signer}
handled := make(chan string, 4)
l.HearHandOvers(func(path, by string) (string, error) { handled <- path; return "handed " + path, nil })
defer l.Close()
if err := conn.Flush(); err != nil {
t.Fatal(err)
}
ask := anAsk(time.Now())
ask.Nonce = strings.Repeat("cd", 16)
reply, err := conn.Request(AskHandOverSubject(m.Node), signedAsk(t, key, ask), 5*time.Second)
if err != nil {
t.Fatal(err)
}
var said HandOverAnswer
if err := json.Unmarshal(reply.Data, &said); err != nil || said.Said != "handed /srv/notes" {
t.Fatalf("answered %s, %v", reply.Data, err)
}
ask.Nonce = strings.Repeat("ef", 16)
if err := conn.Publish(AskHandOverSubject(m.Node), signedAsk(t, key, ask)); err != nil {
t.Fatal(err)
}
if _, err := conn.Request(AskHandOverSubject("another"), signedAsk(t, key, ask), 300*time.Millisecond); err == nil {
t.Fatal("another node's ask was answered here")
}
if err := conn.Flush(); err != nil {
t.Fatal(err)
}
time.Sleep(100 * time.Millisecond)
if len(handled) != 1 {
t.Fatalf("handled %d asks; want only the one with a reply", len(handled))
}
}