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.
391 lines
16 KiB
Go
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)
|
|
}
|