A word kept from a moment ago, not free, refused an ask taken just after root became free again, and the asker keeps a refusal until something changes. Taking an ask asks the controller fresh, still before the work is taken (the lab's proof found it).
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(false) // 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(false) // 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)
|
|
}
|