From d13680674501c6a5bc588769bf7a1803bab3a004 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 14:03:36 +0200 Subject: [PATCH] messenger: an early desk unlink bars nothing, root lifts any bar, no root-free ask inside the work, no button without its effect (the confirmation review of 2026-10-09) N1-router: another account's /start offered the desk an unlink of the operator's account in its first hour, and a desk click, which a program on the desk machine can forge, unlinked it and barred it for a day, again and again. An unlink at the desk in the first hour now bars nothing: any account, the operator's too, links again at once with a code from the desk; the day's bar stays for an account removed by its own approval or by root, and root's allow-link lifts any bar. The words say which. Lows: the controller's root-free word is asked before the work is taken (it waited up to 10 s inside it) and the answer is judged with that fresh word; inside the work only a recent word is read, and a failed ask drops the old one. An answer whose effect the content rule withholds is not offered. mesh-sdk moves to 16984aa. Each test failed before its fix. --- modules/messenger/cmd/messenger/answers.go | 6 +- modules/messenger/cmd/messenger/asks.go | 65 +++++++- modules/messenger/cmd/messenger/holder.go | 17 ++- modules/messenger/cmd/messenger/link.go | 2 + .../messenger/cmd/messenger/review_test.go | 139 ++++++++++++++++++ modules/messenger/cmd/messenger/selfact.go | 44 ++++-- modules/messenger/go.mod | 2 +- modules/messenger/go.sum | 6 +- 8 files changed, 260 insertions(+), 21 deletions(-) diff --git a/modules/messenger/cmd/messenger/answers.go b/modules/messenger/cmd/messenger/answers.go index 55ba0fc6..4c81b708 100644 --- a/modules/messenger/cmd/messenger/answers.go +++ b/modules/messenger/cmd/messenger/answers.go @@ -94,6 +94,10 @@ func (h *Holder) choices(r *Record, kind string) []Choice { // stands, closes the ask with a warrant for its asker (asks.go); Details are shown where they were asked. // Anything else is refused, and said there. The same answer is acted on once. func (h *Holder) Chosen(kind string, c asks.Choice) (string, error) { + // The controller's root-free word is asked before the work is taken (the confirmation review of 2026-10-09): + // up to RootFreeWithin, which no ask, answer or link should wait behind. Asked fresh here, for this answer, + // and judged with in it. + root := h.rootWordFor(kind) h.work.Lock() defer h.work.Unlock() h.mu.Lock() @@ -124,7 +128,7 @@ func (h *Holder) Chosen(kind string, c asks.Choice) (string, error) { return "too old", errorf("it was given %s ago", roughly(now.Sub(c.At))) } if key != "" { - out, err := h.judge(kind, c, key) + out, err := h.judge(kind, c, key, root) h.remember(c.ID, out) return out, err } diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index fa986f86..ab616643 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -180,7 +180,7 @@ func (h *Holder) carriers(level asks.Level) []string { case asks.Acknowledge: out = append(out, k.Kind) case asks.Approve: - if h.Bench.Promises(k.Kind, "verified-sender") && h.linked(k.Kind) && h.rootFree(k, false) == "" { + if h.Bench.Promises(k.Kind, "verified-sender") && h.linked(k.Kind) && h.rootFree(k, true) == "" { out = append(out, k.Kind) } } @@ -270,11 +270,20 @@ func (h *Holder) rootFree(k KindInfo, cached bool) string { kept, ok := h.rootWord[key] h.mu.Unlock() var verdicts map[string]RootVerdict - if cached && ok && now.Sub(kept.at) < RootFreeKept && now.Sub(kept.at) >= 0 { + switch { + case cached && ok && now.Sub(kept.at) < RootFreeKept && now.Sub(kept.at) >= 0: verdicts = kept.verdicts - } else { + case cached: + // Read inside the work, which never waits on the controller: no recent word is no pass. warmRootFree + // asks outside it. + return "the controller's word on whether an agent can become root there is not recent" + default: got, err := h.askRootFree(machines) if err != nil { + // A word that could not be asked again is not kept from before: no recent word, no pass. + h.mu.Lock() + delete(h.rootWord, key) + h.mu.Unlock() return "whether an agent can become root where it runs could not be asked: " + err.Error() } verdicts = got @@ -302,6 +311,51 @@ func (h *Holder) rootFree(k KindInfo, cached bool) string { return "" } +// warmRootFree asks the controller's root-free word for every kind that proves its sender, outside the work, +// so what is rendered and offered inside it reads a recent word. +func (h *Holder) warmRootFree() { + if h.Bench == nil { + return + } + now := h.Now() + for _, k := range h.Bench.Kinds() { + if !h.Bench.Promises(k.Kind, "verified-sender") || !h.linked(k.Kind) || h.Node == "" || k.Node == "" { + continue + } + key := h.Node + if k.Node != h.Node { + key += "," + k.Node + } + h.mu.Lock() + kept, ok := h.rootWord[key] + h.mu.Unlock() + if ok && now.Sub(kept.at) >= 0 && now.Sub(kept.at) < RootFreeKept/2 { + continue // recent enough: asked again when half its time has gone + } + _ = h.rootFree(k, false) + } +} + +// rootWordFor asks the controller's root-free word for a kind fresh, before the work is taken, and answers +// what judging an answer of that kind then reads: the word for that very kind's placement, or why there is none. +// A kind that does not prove its sender is asked nothing: its answers never approve. +func (h *Holder) rootWordFor(kind string) func(KindInfo) string { + if h.Bench == nil { + return func(KindInfo) string { return "the router does not know the channels yet" } + } + asked, held := h.Bench.Kind(kind) + if !held || !h.Bench.Promises(kind, "verified-sender") { + return func(KindInfo) string { return Display(kind) + " cannot prove who answered" } + } + why := h.rootFree(asked, false) + return func(info KindInfo) string { + if info.Kind != asked.Kind || info.Node != asked.Node || info.Module != asked.Module { + return "where " + Display(kind) + " runs changed while the answer was judged; answer again" + } + return why + } +} + // rootWord is the controller's last word on a set of machines, and when it was asked. type rootWord struct { at time.Time @@ -342,6 +396,7 @@ const ConditionOwner = "mesh-controller" // grants that module alone. An error is returned only when the ask could not be recorded, so the queue // offers it again; a refusal is said to the asker and the work is taken. func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error { + h.warmRootFree() // before the work: offering an approval reads the recent word inside it h.work.Lock() defer h.work.Unlock() h.mu.Lock() @@ -745,7 +800,7 @@ func joinOr(labels []string) string { // judge is an answer to an ask's option, chosen on a kind: refused at the first failure, in words said on // the kind; otherwise the ask closed and the warrant published (ADR 0259 §5). -func (h *Holder) judge(kind string, c asks.Choice, key string) (string, error) { +func (h *Holder) judge(kind string, c asks.Choice, key string, root func(KindInfo) string) (string, error) { e := h.askOf(key) if e == nil { return h.refuseChoice(kind, c, "it belongs to no ask this router holds") @@ -823,7 +878,7 @@ func (h *Holder) judge(kind string, c asks.Choice, key string) (string, error) { return h.refuseChoice(kind, c, "this account was linked at "+id.Linked.In(h.zone()).Format("15:04")+ " and can answer this question from "+id.ApproveFrom.In(h.zone()).Format("15:04")) } - if why := h.rootFree(info, false); why != "" { + if why := root(info); why != "" { return h.refuseChoice(kind, c, why) } if o.Level == asks.Approve { diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 45ec112d..a45e424b 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -554,6 +554,7 @@ func (h *Holder) cleared(key string, quiet bool) { // Tick does what time asks: reminders, retries, what is held, and forgetting what cleared long // enough ago that a new raise is a new message. func (h *Holder) Tick() { + h.warmRootFree() // outside the work: what is offered inside it reads a recent word h.work.Lock() defer h.work.Unlock() h.mu.Lock() @@ -609,6 +610,7 @@ func (h *Holder) Tick() { // Flush sends what is due on each channel; the runtime calls it every few seconds, so a held // message waits its window and not a minute more. func (h *Holder) Flush() { + h.warmRootFree() // outside the work: what is offered inside it reads a recent word h.work.Lock() defer h.work.Unlock() h.mu.Lock() @@ -642,6 +644,7 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message { if kind == "" { return m } + withheld := map[string]bool{} // the labels of answers whose effect could not be said if rec := h.openAsk(r); rec != nil { // Who asks, as the bus proves it (the asker is the subject's, never the body's), what each answer does, // and what happens if nobody answers (the review of 2026-10-09). @@ -650,13 +653,25 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message { m.Body = strings.TrimSpace(m.Body + "\n" + line) } } + // **An answer whose effect is not said is not offered** (the confirmation review of 2026-10-09): its line + // refused by the content rule, its button went out all the same, and a tap would authorise what the + // person was not shown. + for _, o := range rec.Ask.Options { + if _, ok := Check(o.Label + ": " + o.Does + "."); !ok || strings.TrimSpace(o.Does) == "" { + withheld[o.Label] = true + } + } if line := h.elsewhere(rec, kind); line != "" { if _, ok := Check(line); ok { m.Body = strings.TrimSpace(m.Body + "\n" + line) } } } - m.Actions = h.choices(r, kind) + for _, c := range h.choices(r, kind) { + if !withheld[c.Label] { + m.Actions = append(m.Actions, c) + } + } return m } diff --git a/modules/messenger/cmd/messenger/link.go b/modules/messenger/cmd/messenger/link.go index 24a1720f..b19032a9 100644 --- a/modules/messenger/cmd/messenger/link.go +++ b/modules/messenger/cmd/messenger/link.go @@ -186,6 +186,7 @@ func (h *Holder) offerDeskUnlink(id Identity) bool { // Link takes a link asked for on a kind: a code shown where only the operator sees it, or a refusal said on // the kind. func (h *Holder) Link(kind string, l asks.LinkAsked) string { + h.warmRootFree() // outside the work: a question it asks reads a recent word h.work.Lock() defer h.work.Unlock() reply := func(title, body string) string { @@ -276,6 +277,7 @@ func (h *Holder) Link(kind string, l asks.LinkAsked) string { // Prove answers a code sent on a kind: the link it confirms, or why not. Never said in an event. func (h *Holder) Prove(kind string, c asks.Code) asks.ProofAnswer { + h.warmRootFree() // outside the work: a question it asks reads a recent word h.work.Lock() defer h.work.Unlock() no := func(words string) asks.ProofAnswer { return asks.ProofAnswer{Accepted: false, Words: words} } diff --git a/modules/messenger/cmd/messenger/review_test.go b/modules/messenger/cmd/messenger/review_test.go index 827d1f3b..a5357129 100644 --- a/modules/messenger/cmd/messenger/review_test.go +++ b/modules/messenger/cmd/messenger/review_test.go @@ -5,6 +5,9 @@ package main import ( "encoding/json" "errors" + "os" + "path/filepath" + "regexp" "strings" "sync" "testing" @@ -394,3 +397,139 @@ func TestTwoAnswersAtOnceMakeOneWarrant(t *testing.T) { t.Errorf("not exactly one chosen: %v", outs) } } + +// The confirmation review of 2026-10-09, N1-router: another account's /start offers the desk an unlink of the +// operator's account while it is in its first hour; a desk click — which a program on the desk machine can forge +// — unlinked it and barred it for a day, again and again, so the operator never approved anything. A desk +// unlink in the first hour bars nothing: the operator links again at once with a code from the desk, and the +// next link waits for nobody. The bar is for an account removed deliberately later. +func TestAnUnlinkFromTheDeskInTheFirstHourBarsNothing(t *testing.T) { + h, tg, dt, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-10*time.Minute)) // the operator's own, linked just now + h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "666", Display: "X", Authenticated: true}}) + asked := dt.sends[len(dt.sends)-1] + ch := choiceOf(ticketFor(asked, "Unlink it"), "g14", c.now()) // a click nobody can prove was the operator's + ch.Sender.Authenticated = false + if out, err := h.Chosen("desktop", ch); err != nil || out != "chosen" { + t.Fatalf("%s %v", out, err) + } + if len(store.ids) != 0 { + t.Fatalf("not unlinked: %v", store.ids) + } + if u := h.recentUnlink("telegram"); u != nil { + t.Errorf("a desk unlink in the first hour bars relinking: %+v", u) + } + // The operator sends /start again: a code is shown at once, and the code links without waiting for anybody. + c.pass(time.Minute) + stand(h, c) + h.Link("telegram", asks.LinkAsked{ID: "l2", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}}) + code := "" + for _, m := range dt.sends { + if strings.Contains(m.Body, "number 42") && strings.Contains(m.Body, "send this code") { + code = codeIn(t, m.Body) + } + } + if code == "" { + t.Fatalf("no code was shown to relink the operator's account; said %q", tg.sends[len(tg.sends)-1].Body) + } + if a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"}); !a.Accepted || + !strings.Contains(a.Words, "Linked") { + t.Fatalf("the operator's code was not taken at once: %+v", a) + } +} + +// And break-glass lifts any bar: root's allow-link at the router's machine, with no link waiting, ends a day's bar. +func TestBreakGlassLiftsABarOnRelinking(t *testing.T) { + h, tg, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + _, _ = h.Unlink("telegram") + _, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now())) + if h.recentUnlink("telegram") == nil { + t.Fatal("the control: a deliberate unlink bars relinking for a day") + } + h.StateDir = t.TempDir() + was := fileOwner + t.Cleanup(func() { fileOwner = was }) + fileOwner = func(os.FileInfo) uint32 { return 0 } + _ = os.WriteFile(filepath.Join(h.StateDir, "allow-link.telegram"), nil, 0o600) + h.Tick() + if u := h.recentUnlink("telegram"); u != nil { + t.Errorf("root's allow-link left the bar: %+v", u) + } +} + +// codeIn is the six digits a link's notice shows, "123 456". +func codeIn(t *testing.T, body string) string { + t.Helper() + m := regexp.MustCompile(`(\d{3}) (\d{3})\.`).FindStringSubmatch(body) + if m == nil { + t.Fatalf("no code in %q", body) + } + return m[1] + m[2] +} + +// The confirmation review of 2026-10-09 (low): the router asked the controller's root-free word — up to ten +// seconds — while holding its work, so every ask, answer and link waited behind one slow controller. It now +// asks before it takes the work, and judges with the word it was given then. +func TestTheRootFreeWordIsNotAskedWhileTheWorkIsHeld(t *testing.T) { + h, tg, _, c, store, w := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + settle(h, c) + release := ticketFor(tg.sends[0], "Release") + asking, slow := make(chan struct{}), make(chan struct{}) + var once sync.Once + free := h.RootFree + h.RootFree = func(machines []string) (map[string]RootVerdict, error) { + once.Do(func() { close(asking); <-slow }) + return free(machines) + } + done := make(chan string, 1) + go func() { + out, _ := h.Chosen("telegram", choiceOf(release, "42", c.now())) + done <- out + }() + <-asking + linked := make(chan struct{}) + go func() { + h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: asks.Sender{Identity: "77", Authenticated: true}}) + close(linked) + }() + select { + case <-linked: + case <-time.After(2 * time.Second): + close(slow) + t.Fatal("a link waited for the controller's root-free word: the work was held while it was asked") + } + close(slow) + if out := <-done; out != "chosen" || len(w.of("mesh-delivery")) != 1 { + t.Fatalf("the answer after the word: %s, %v", out, w.of("mesh-delivery")) + } +} + +// The confirmation review of 2026-10-09 (low, on M2): an answer whose line — what it does — the content rule +// withholds is not offered either; its button went out alone, and a tap would choose what was not shown. +func TestAnAnswerWhoseEffectIsWithheldIsNotOffered(t *testing.T) { + h, _, _, c, store, _ := newRouter(t) + link(h, store, c, "42", c.now().Add(-2*time.Hour)) + submit(t, h, "mesh-delivery", heldAsk(c, "d-1")) + key := askKey("mesh-delivery", "d-1") + h.mu.Lock() + e := h.asks[key] + e.rec.Ask.Options[0].Does = "the delivery reads /etc/shadow" + h.asks[key] = e + r := h.open["ask."+key] + h.mu.Unlock() + m := h.sayOn(r, "asked", "telegram") + for _, a := range m.Actions { + if a.Label == "Release" { + t.Errorf("Release was offered with its effect withheld: %+v", m.Actions) + } + } + if strings.Contains(m.Body, "/etc/shadow") { + t.Errorf("the withheld words were said: %q", m.Body) + } + if len(m.Actions) == 0 { + t.Error("the answers whose effect was said were not offered either") + } +} diff --git a/modules/messenger/cmd/messenger/selfact.go b/modules/messenger/cmd/messenger/selfact.go index 20c890b9..b9956e88 100644 --- a/modules/messenger/cmd/messenger/selfact.go +++ b/modules/messenger/cmd/messenger/selfact.go @@ -49,6 +49,11 @@ func (a SelfAct) act() asks.Act { type Unlinked struct { Identity Identity `json:"identity"` At time.Time `json:"at"` + // Early is an unlink at the desk in the account's first hour: it bars nothing (the confirmation review of + // 2026-10-09, N1-router). A desk click is anyone's at the desk machine, and a bar it set — the account not + // linked again for a day, the next link waiting for it — let a forged click lock the operator out, again + // and again. The bar is for an account removed deliberately: by its own approval, or by root. + Early bool `json:"early,omitempty"` } // askSelf puts a question of the router's own to one account, on its kind, and keeps it like any ask. @@ -123,10 +128,10 @@ func (h *Holder) selfAct(rec AskRecord, w asks.Warrant) { "machine, can unlink it now.", Urgent: true}) return } - h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "at the desk, within the hour after it was linked") + h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "at the desk, within the hour after it was linked", true) return } - h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "approved by the account itself") + h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "approved by the account itself", false) case "link": if rec.Act.Link != nil && rec.Act.Link.Identity == rec.Act.Identity { h.linkNow(*rec.Act.Link, "approved by the account it replaces") @@ -136,6 +141,7 @@ func (h *Holder) selfAct(rec AskRecord, w asks.Warrant) { // Unlink asks the account linked on a kind whether it is to be unlinked: an approve ask put to it alone. func (h *Holder) Unlink(kind string) (map[string]any, error) { + h.warmRootFree() // outside the work: a question it asks reads a recent word h.work.Lock() defer h.work.Unlock() ids := h.identitiesOf(kind) @@ -160,7 +166,7 @@ func (h *Holder) Unlink(kind string) (map[string]any, error) { // unlinkNow unlinks one account of a kind: told first, by name, then every channel. It unlinks the account it // is named, and nothing when another is linked now. -func (h *Holder) unlinkNow(kind, identity, how string) { +func (h *Holder) unlinkNow(kind, identity, how string, early bool) { if h.Asks == nil { return } @@ -175,10 +181,14 @@ func (h *Holder) unlinkNow(kind, identity, how string) { h.Logf("[messenger] the unlink of %s on %s could not be recorded: %v", id.Display, kind, err) return } - h.unlinked(kind, Unlinked{Identity: *id, At: h.Now()}) + h.unlinked(kind, Unlinked{Identity: *id, At: h.Now(), Early: early}) + after := "A new link within a day waits for that account's approval, and that account itself can be linked " + + "again only after a day." + if early { + after = "Any account, this one too, can be linked again at once with a code shown on your desk." + } h.announce(Message{Title: "No " + Display(kind) + " account is linked as you any more", - Body: fmt.Sprintf("Unlinked: %s (number %s), %s. A new link within a day waits for that account's approval, "+ - "and that account itself can be linked again only after a day.", id.Display, id.Identity, how), Urgent: true}) + Body: fmt.Sprintf("Unlinked: %s (number %s), %s. %s", id.Display, id.Identity, how, after), Urgent: true}) h.record(Sent{At: h.Now(), Channel: kind, Key: "(link)", What: "unlinked", Outcome: id.Display + ", " + how}) } @@ -250,7 +260,7 @@ func (h *Holder) breakGlass() { switch what { case "unlink": if ids := h.identitiesOf(kind); len(ids) > 0 { - h.unlinkNow(kind, ids[0].Identity, where+", as root") + h.unlinkNow(kind, ids[0].Identity, where+", as root", false) done = fmt.Sprintf("the %s account %s (number %s) was unlinked", Display(kind), ids[0].Display, ids[0].Identity) } else { done = "nothing was unlinked: no " + Display(kind) + " account is linked" @@ -262,6 +272,11 @@ func (h *Holder) breakGlass() { } else { done = "nothing was linked: no link with its code given waits on " + Display(kind) } + // And whatever bar an unlink set is lifted: root decided who may be linked. + if h.recentUnlink(kind) != nil { + h.liftBar(kind) + done += "; the bar on linking an account again after an unlink was lifted" + } } ended := h.endSelfAsks("root decided " + where) body := "Root " + where + " asked it: " + done + "." @@ -315,7 +330,18 @@ func (h *Holder) unlinked(kind string, u Unlinked) { } } -// recentUnlink is the account unlinked on a kind less than a day ago, if any. +// liftBar forgets the last unlink of a kind: root's allow-link at the router's machine. +func (h *Holder) liftBar(kind string) { + h.mu.Lock() + delete(h.lastUnlink, kind) + h.mu.Unlock() + if h.Asks != nil { + _ = h.Asks.PutUnlinked(kind, Unlinked{}) + } +} + +// recentUnlink is the account unlinked deliberately on a kind less than a day ago, if any: an early desk +// unlink bars nothing. func (h *Holder) recentUnlink(kind string) *Unlinked { h.mu.Lock() u, ok := h.lastUnlink[kind] @@ -325,7 +351,7 @@ func (h *Holder) recentUnlink(kind string) *Unlinked { u, ok = *got, true } } - if !ok || h.Now().Sub(u.At) >= RelinkWaits { + if !ok || u.Early || u.At.IsZero() || h.Now().Sub(u.At) >= RelinkWaits { return nil } return &u diff --git a/modules/messenger/go.mod b/modules/messenger/go.mod index 4c5ebafe..36af77c1 100644 --- a/modules/messenger/go.mod +++ b/modules/messenger/go.mod @@ -2,4 +2,4 @@ module messenger go 1.22 -require git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39 +require git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689 diff --git a/modules/messenger/go.sum b/modules/messenger/go.sum index 867b9f8a..0f6b346e 100644 --- a/modules/messenger/go.sum +++ b/modules/messenger/go.sum @@ -1,4 +1,2 @@ -git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8 h1:soqhLNpEXThdq6PdiPy6ExxjJ+yjhh1N1n9E3j1CtrM= -git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= -git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39 h1:WHW6CgbuTxP7M+qRBOgzsiG9vT49xdkZ/rarc9/vKMA= -git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009095928-76902998cd39/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689 h1:Ti2P9nwders7YQ/hq3X/dPo+CXMj5pdUcfA5P/c12CU= +git.novox.be/novox/mesh-sdk/go v0.1.10-0.20261009115850-16984aafc689/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=