Files
mesh-catalog/modules/messenger/cmd/messenger/link.go
T
jschoubben 4a56294633 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.
2026-10-09 14:03:41 +02:00

391 lines
16 KiB
Go

package main
// Who the operator is on a channel (novox/hq ADR 0259 §7): the router's list of the operator's
// identities, one per kind, changed only by linking and unlinking — never by a setting, which any
// principal granted `settings` could write.
//
// - **Linking:** the operator sends the channel's bot `/start`; the channel says `link.<kind>` with the
// sender as its service authenticated it. The router shows a six-digit code on the desk, with the name
// the service shows, for ten minutes. The operator sends the code to the bot; the channel asks it as a
// proof (`proof.code.<kind>`: request and reply, never kept). When it matches, the account is linked,
// and said on every channel.
// - **A new link acknowledges at once and approves only after an hour** (LinkedApproveAfter), decided
// by the operator on 2026-10-08 until an authenticator-app code exists: the hour lets the operator
// unlink an account an agent linked by reading the code off the X11 desk.
// - **One identity per kind.** A second link of a kind is refused until the first is unlinked; unlinking
// is said on every channel, the unlinked one included.
// - Only a kind whose claim declares `verified-sender` can be linked: the desk proves nothing about who
// clicked, so it has no identities and answers acknowledgements only.
// - The code is kept only as a salted hash; five wrong tries end the link asked for.
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
const (
// LinkCodeFor is how long a link's code is good for.
LinkCodeFor = 10 * time.Minute
// LinkTries is how many wrong codes end a link asked for.
LinkTries = 5
)
// PendingLink is a link asked for and not yet confirmed: who asked, and the code's hash.
type PendingLink struct {
Kind string `json:"kind"`
Identity string `json:"identity"`
Display string `json:"display,omitempty"`
Salt string `json:"salt"`
CodeHash string `json:"code-hash"`
Expires time.Time `json:"expires"`
Tries int `json:"tries"`
// Confirmed: the code was right, and the link waits for the account it replaces.
Confirmed bool `json:"confirmed,omitempty"`
}
func identityKey(kind, identity string) string { return kind + "." + kvKey(identity) }
func hashCode(salt, code string) string {
sum := sha256.Sum256([]byte(salt + ":" + code))
return hex.EncodeToString(sum[:])
}
// identity is the linked account of a kind, when this sender is it.
func (h *Holder) identity(kind, identity string) *Identity {
if h.Asks == nil || identity == "" {
return nil
}
id, err := h.Asks.GetIdentity(identityKey(kind, identity))
if err != nil || id == nil || id.Kind != kind || id.Identity != identity {
return nil
}
return id
}
// identitiesOf is every account linked on a kind.
func (h *Holder) identitiesOf(kind string) []Identity {
if h.Asks == nil {
return nil
}
keys, err := h.Asks.IdentityKeys()
if err != nil {
return nil
}
var out []Identity
for _, k := range keys {
if !strings.HasPrefix(k, kind+".") {
continue
}
if id, err := h.Asks.GetIdentity(k); err == nil && id != nil && id.Kind == kind {
out = append(out, *id)
}
}
return out
}
func (h *Holder) linked(kind string) bool { return len(h.identitiesOf(kind)) > 0 }
// LinksAnHour is how many links one account may ask in an hour, and KindLinksAnHour how many all accounts
// together may ask of one kind: kept in the router's state, so a restart forgets neither (the review of
// 2026-10-09). Per account, so a stranger's tries do not stop the operator's.
const (
LinksAnHour = 3
KindLinksAnHour = 10
)
// allowLink counts a link asked by an account on a kind, and says why not when either bound is reached, or
// the count cannot be kept: an uncounted link is not begun.
func (h *Holder) allowLink(kind, identity string, now time.Time) string {
for _, b := range []struct {
key string
most int
words string
}{
{"asked." + kind + "." + kvKey(identity), LinksAnHour, fmt.Sprintf("This account asked to be linked %d times this hour. Try again in an hour.", LinksAnHour)},
{"asked." + kind, KindLinksAnHour, fmt.Sprintf("Links were asked %d times this hour on %s. Try again in an hour.", KindLinksAnHour, Display(kind))},
} {
times, err := h.Asks.GetLinksAsked(b.key)
if err != nil {
return "How often links were asked cannot be read, so none is begun: " + err.Error()
}
var recent []time.Time
for _, t := range times {
if now.Sub(t) < time.Hour && now.Sub(t) >= 0 {
recent = append(recent, t)
}
}
if len(recent) >= b.most {
return b.words
}
}
for _, key := range []string{"asked." + kind + "." + kvKey(identity), "asked." + kind} {
times, _ := h.Asks.GetLinksAsked(key)
var recent []time.Time
for _, t := range times {
if now.Sub(t) < time.Hour && now.Sub(t) >= 0 {
recent = append(recent, t)
}
}
if err := h.Asks.PutLinksAsked(key, append(recent, now)); err != nil {
return "How often links were asked cannot be kept, so none is begun: " + err.Error()
}
}
return ""
}
// offerDeskUnlink asks at the desk whether an account still in its first hour is unlinked: it cannot approve
// yet, so whoever is at the desk may undo it (ADR 0259 §7, the review of 2026-10-09). One such question per
// account at a time. Answers whether it was asked.
func (h *Holder) offerDeskUnlink(id Identity) bool {
desk := ""
for _, k := range h.Bench.Kinds() {
if k.Kind != id.Kind && h.Bench.Promises(k.Kind, "private") && !h.Bench.Promises(k.Kind, "verified-sender") &&
h.Bench.Ready(k.Kind) == nil {
desk = k.Kind
break
}
}
if desk == "" {
return false
}
h.mu.Lock()
for _, e := range h.asks {
if e.rec.Asker == SelfAsker && e.rec.State == StateOpen && e.rec.Act != nil && e.rec.Act.Do == "unlink" &&
e.rec.Act.Identity == id.Identity && e.rec.Only != nil && e.rec.Only.Kind == desk {
h.mu.Unlock()
return true
}
}
h.mu.Unlock()
now := h.Now()
until := id.ApproveFrom
a := asks.Ask{ID: "desk-unlink-" + id.Kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink the account linked just now?",
Explanation: fmt.Sprintf("The %s account %s (number %s) was linked as you at %s. Another account just asked "+
"to be linked. If the linked one is not yours, unlink it here before %s; after that only that account, "+
"or root at the router's machine, can.", Display(id.Kind), id.Display, id.Identity,
id.Linked.In(h.zone()).Format("15:04"), until.In(h.zone()).Format("15:04")),
Who: asks.Operator, Expires: until, OnExpiry: "the account stays linked",
Options: []asks.Option{{ID: "unlink", Label: "Unlink it", Does: "that account stops answering as you", Level: asks.Acknowledge}}}
if err := h.askSelf(a, Identity{Kind: desk}, SelfAct{Do: "unlink", Kind: id.Kind, Identity: id.Identity}); err != nil {
h.Logf("[messenger] the desk could not be asked to unlink %s: %v", id.Display, err)
return false
}
return true
}
// 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 {
_ = h.showOn(kind, Message{Title: title, Body: body, Quiet: true, Reply: l.ID})
return title
}
if h.Bench == nil || !h.Bench.Promises(kind, "verified-sender") {
return reply("This channel cannot be linked", "It cannot prove who sends to it, so it answers only what anyone at the desk could.")
}
if !l.Sender.Authenticated || l.Sender.Identity == "" {
return reply("Not linked", "The service did not say who you are.")
}
now := h.Now()
if now.Sub(l.At) > FreshFor {
h.Logf("[messenger] a link asked on %s at %s is too old to act on", kind, l.At.Format(time.RFC3339))
return "too old"
}
for _, id := range h.identitiesOf(kind) {
if id.Identity == l.Sender.Identity {
return reply("Already linked", "This account is already linked as the operator.")
}
if now.Before(id.ApproveFrom) && h.offerDeskUnlink(id) {
return reply("Another account is linked", "Another account was linked here less than an hour ago. If "+
"that was not you, unlink it at your desk, where you were just asked, or as root at the router's "+
"machine; then send /start again.")
}
return reply("Another account is linked", "Another account is linked as the operator here. It is unlinked "+
"with that account's own approval, or as root at the router's machine, and then this one can be linked.")
}
if u := h.recentUnlink(kind); u != nil && u.Identity.Identity == l.Sender.Identity {
// An account unlinked less than a day ago does not come back by a code at once (the review of
// 2026-10-09): whoever unlinked it meant it, and an account taken with a phone must not walk back in.
return reply("Not linked", "This account was unlinked less than a day ago. It can be linked again from "+
u.At.Add(RelinkWaits).In(h.zone()).Format("2006-01-02 15:04")+", or allowed sooner as root at the router's machine.")
}
if why := h.allowLink(kind, l.Sender.Identity, now); why != "" {
return reply("Not linked", why)
}
// A code is shown only where the words stay on the operator's machines: a kind declaring `private`.
// It is shown now or never: a desk that cannot show it now (its notifications paused, nobody there) begins
// no link, so no code waits in a queue to be shown to whoever comes later (the review of 2026-10-09).
shows := ""
var whyNot []string
for _, k := range h.Bench.Kinds() {
if k.Kind == kind || !h.Bench.Promises(k.Kind, "private") {
continue
}
if err := h.Bench.Ready(k.Kind); err != nil {
whyNot = append(whyNot, err.Error())
continue
}
shows = k.Kind
break
}
if shows == "" {
why := "No private place can show the code now."
if len(whyNot) > 0 {
why = "Your desk cannot show the code now: " + strings.Join(whyNot, "; ") + "."
}
return reply("Not linked", why+" Nothing was begun. Try again when you are at your machine.")
}
code, err := sixDigits()
if err != nil {
return reply("Not linked", "No code could be made.")
}
salt := ticket()
p := PendingLink{Kind: kind, Identity: l.Sender.Identity, Display: l.Sender.Display, Salt: salt,
CodeHash: hashCode(salt, code), Expires: now.Add(LinkCodeFor)}
if err := h.Asks.PutLink(kind, p); err != nil {
return reply("Not linked", "The link could not be recorded: "+err.Error())
}
who := l.Sender.Display
if who == "" {
who = "an account"
}
notice := Message{Title: "Link a " + Display(kind) + " account as you",
Body: fmt.Sprintf("The %s account %q (number %s) asked to be linked as you. If that is you, send this code to "+
"the bot before %s: %s %s. If it is not you, do nothing: nothing is linked.", Display(kind), who,
l.Sender.Identity, p.Expires.In(h.zone()).Format("15:04"), code[:3], code[3:]),
Urgent: true, Secret: true}
if err := h.showOn(shows, notice); err != nil {
_ = h.Asks.DeleteLink(kind)
return reply("Not linked", "The code could not be shown: "+err.Error())
}
h.Logf("[messenger] a link was asked on %s by %q (%s); its code is shown on %s", kind, who, l.Sender.Identity, shows)
return reply("Send the code shown on your desk", "Six digits, within ten minutes.")
}
// 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} }
if c.Purpose != "" && c.Purpose != "link" {
return no("The mesh takes no code for that yet.")
}
if h.Asks == nil {
return no("Nothing can be linked here.")
}
p, err := h.Asks.GetLink(kind)
if err != nil || p == nil || p.Confirmed {
return no("No link waits for a code. Send /start to begin.")
}
now := h.Now()
if !now.Before(p.Expires) {
_ = h.Asks.DeleteLink(kind)
return no("The code expired. Send /start for a new one.")
}
if c.Sender.Identity != p.Identity || !c.Sender.Authenticated {
return no("This code was asked for by another account.")
}
code := strings.ReplaceAll(strings.TrimSpace(c.Code), " ", "")
if subtle.ConstantTimeCompare([]byte(hashCode(p.Salt, code)), []byte(p.CodeHash)) != 1 {
p.Tries++
if p.Tries >= LinkTries {
_ = h.Asks.DeleteLink(kind)
h.Logf("[messenger] a link on %s ended after %d wrong codes", kind, p.Tries)
return no("That code is not right, and that was the last try. Send /start for a new one.")
}
_ = h.Asks.PutLink(kind, *p)
return no("That code is not right.")
}
id := Identity{Kind: kind, Identity: p.Identity, Display: p.Display}
u := h.recentUnlink(kind)
if u != nil && u.Identity.Identity == p.Identity {
_ = h.Asks.DeleteLink(kind)
return no("This account was unlinked less than a day ago, and is not linked again by a code before a day has passed.")
}
if others := h.identitiesOf(kind); len(others) > 0 {
_ = h.Asks.DeleteLink(kind)
return no("Another account was linked here meanwhile. Nothing was linked.")
}
if u != nil {
// Within a day of an unlink: the account it replaces decides (novox/hq ADR 0259 §7).
p.Confirmed = true
p.Expires = now.Add(RelinkWaits)
_ = h.Asks.PutLink(kind, *p)
a := asks.Ask{ID: "relink-" + kind + "-" + fmt.Sprint(now.Unix()),
Headline: "Allow another account in your place?",
Explanation: fmt.Sprintf("This account was unlinked from being you less than a day ago. The %s account %s "+
"(number %s) now asks to be linked as you. If you choose Allow, it is.", Display(kind), p.Display, p.Identity),
Who: asks.Operator, Expires: now.Add(RelinkWaits - time.Minute), OnExpiry: "nothing is linked",
Options: []asks.Option{{ID: "allow", Label: "Allow", Does: "the other account is linked as you", Level: asks.Approve}}}
prior := u.Identity
prior.ApproveFrom = prior.Linked // an account linked before: its hour passed while it was linked
if err := h.askSelf(a, prior, SelfAct{Do: "link", Kind: kind, Identity: id.Identity, Link: &id}); err != nil {
return no("The link could not be asked of the account it replaces: " + err.Error())
}
h.announce(Message{Title: "A new " + Display(kind) + " account asks to be linked as you",
Body: fmt.Sprintf("%s (number %s), less than a day after an unlink. It waits for the account it replaces, "+
"or for root at the router's machine.", p.Display, p.Identity), Urgent: true})
return asks.ProofAnswer{Accepted: true, Words: "The code is right. Because an account was unlinked here less " +
"than a day ago, the link waits for that account's approval, or for you at the router's machine."}
}
h.linkNow(id, "with the code shown on the desk")
from := now.Add(LinkedApproveAfter).In(h.zone()).Format("15:04")
return asks.ProofAnswer{Accepted: true, Words: "Linked. You can answer acknowledgements now, and approve from " + from + "."}
}
// Identities is every linked account, by kind.
func (h *Holder) Identities() []Identity {
if h.Asks == nil {
return nil
}
keys, _ := h.Asks.IdentityKeys()
var out []Identity
for _, k := range keys {
if id, err := h.Asks.GetIdentity(k); err == nil && id != nil {
out = append(out, *id)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Kind+out[i].Identity < out[j].Kind+out[j].Identity })
return out
}
// announce says a message on every kind that can send, at once: a link or an unlink is seen everywhere.
func (h *Holder) announce(m Message) {
if h.Bench == nil {
return
}
for _, k := range h.Bench.Kinds() {
if h.Bench.Ready(k.Kind) == nil {
_ = h.showOn(k.Kind, m)
}
}
}
func sixDigits() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}
// decodeLink reads a link asked for.
func decodeLink(body json.RawMessage) (asks.LinkAsked, error) {
var l asks.LinkAsked
return l, json.Unmarshal(body, &l)
}