Files
mesh-catalog/modules/messenger/cmd/messenger/link.go
T

265 lines
10 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"`
}
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 }
// Link takes a link asked for on a kind: a code shown on the desk, or a refusal said on the kind.
func (h *Holder) Link(kind string, l asks.LinkAsked) string {
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.")
}
if now := h.Now(); 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.")
}
return reply("Another account is linked", "Another account is linked as the operator here. It is unlinked from the mesh MCP server (messenger_unlink), and then this one can be linked.")
}
if h.Desk == nil || h.Desk.Ready() != nil {
return reply("Not linked", "No desk can show the code now. 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: h.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"
}
desk := Message{Title: "Link a " + Display(kind) + " account as you",
Body: fmt.Sprintf("The %s account %q 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,
p.Expires.In(h.zone()).Format("15:04"), code[:3], code[3:]),
Urgent: true}
if _, err := h.Desk.Send(desk); err != nil {
_ = h.Asks.DeleteLink(kind)
return reply("Not linked", "The desk could not show the code: "+err.Error())
}
h.Logf("[messenger] a link was asked on %s by %q; its code is shown on the desk", kind, who)
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.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 {
return no("No link was asked for. 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, Linked: now, ApproveFrom: now.Add(LinkedApproveAfter)}
if err := h.Asks.PutIdentity(identityKey(kind, p.Identity), id); err != nil {
return no("The link could not be recorded: " + err.Error())
}
_ = h.Asks.DeleteLink(kind)
from := id.ApproveFrom.In(h.zone()).Format("15:04")
h.announce(Message{Title: "A " + Display(kind) + " account is now linked as you",
Body: fmt.Sprintf("The %s account %q can answer acknowledgements now, and approve from %s. If this was not "+
"you, unlink it from the mesh MCP server (messenger_unlink) before then.", Display(kind), p.Display, from),
Urgent: true})
h.record(Sent{At: now, Channel: kind, Key: "(link)", What: "linked " + p.Display, Outcome: "linked"})
h.Logf("[messenger] linked %q on %s; it approves from %s", p.Display, kind, from)
return asks.ProofAnswer{Accepted: true, Words: "Linked. You can answer acknowledgements now, and approve from " + from + "."}
}
// Unlink removes every account linked on a kind, said on every channel.
func (h *Holder) Unlink(kind string) (map[string]any, error) {
h.work.Lock()
defer h.work.Unlock()
if h.Asks == nil {
return nil, errorf("nothing is linked here")
}
ids := h.identitiesOf(kind)
if len(ids) == 0 {
return map[string]any{"kind": kind, "unlinked": []string{}}, nil
}
var names []string
for _, id := range ids {
if err := h.Asks.DeleteIdentity(identityKey(kind, id.Identity)); err != nil {
return nil, err
}
names = append(names, id.Display)
}
h.announce(Message{Title: "No " + Display(kind) + " account is linked as you any more",
Body: "Unlinked: " + strings.Join(names, ", ") + ". Answers that need you to be proven cannot be given on " +
Display(kind) + " until an account is linked again.", Urgent: true})
h.record(Sent{At: h.Now(), Channel: kind, Key: "(link)", What: "unlinked", Outcome: strings.Join(names, ", ")})
return map[string]any{"kind": kind, "unlinked": names}, nil
}
// 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)
}