265 lines
10 KiB
Go
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)
|
|
}
|