eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package bot
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/url"
9 "strings"
10 "time"
11
12 "lchat/internal/e2ee"
13)
14
15// Provision turns an admin agent-invite into a bot identity — agent/
16// provision.mjs (web/static/join.js run headlessly) in Go. The invite's
17// #fragment secret T never reaches the server: we derive the invite id from
18// it, generate our own keypair, claim a device token by presenting the derived
19// claim secret, and register the public key in the roster. The screen name is
20// never sent — an agent's label was set server-side when the invite was minted.
21func Provision(base, fragment, name string) (*Session, error) {
22 base = strings.TrimRight(base, "/")
23 client := &http.Client{Timeout: 30 * time.Second}
24
25 inv, err := e2ee.DeriveInvite(fragment)
26 if err != nil {
27 return nil, fmt.Errorf("bot: bad invite fragment: %w", err)
28 }
29
30 var info struct {
31 OK bool `json:"ok"`
32 Wrapped string `json:"wrapped"`
33 }
34 if err := getJSON(client, base+"/api/key-invite/"+url.PathEscape(inv.ID), &info); err != nil {
35 return nil, err
36 }
37 if !info.OK {
38 return nil, fmt.Errorf("bot: invite is used or invalid — mint a fresh one")
39 }
40 if info.Wrapped != "" {
41 return nil, fmt.Errorf("bot: that's an add-device link; use a fresh-person agent invite")
42 }
43
44 kp, err := e2ee.GenerateUserKeypair()
45 if err != nil {
46 return nil, err
47 }
48
49 var claim struct {
50 OK bool `json:"ok"`
51 Token string `json:"token"`
52 }
53 err = postJSON(client, base+"/api/key-invite/"+url.PathEscape(inv.ID)+"/claim",
54 map[string]string{"claim_secret": inv.ClaimSecret}, &claim)
55 if err != nil {
56 return nil, err
57 }
58 if !claim.OK || claim.Token == "" {
59 return nil, fmt.Errorf("bot: couldn't claim the invite (it may have just been used)")
60 }
61
62 err = postJSON(client, base+"/api/pubkey",
63 map[string]string{"token": claim.Token, "public_key": kp.PublicKey}, nil)
64 if err != nil {
65 return nil, err
66 }
67
68 return &Session{Token: claim.Token, Name: name, UserPub: kp.PublicKey, UserPriv: kp.PrivateKey}, nil
69}
70
71// EnsureSession loads the session at path, self-provisioning it first when
72// the file is absent and an invite is available. This is what makes a freshly
73// staged sidecar zero-touch (#806): add-bot — and the activator's co-spawn on
74// a hibernating host — hand the process one-time invites in its environment,
75// and the first boot redeems them and writes the session, so keys are born on
76// the machine that runs the bot rather than copied to it. A missing session
77// with no invite still returns (nil, nil): "not provisioned" stays a normal
78// state the caller reports.
79func EnsureSession(base, path, invite, name string, logf func(string, ...any)) (*Session, error) {
80 sess, err := LoadSession(path)
81 if err != nil || sess != nil {
82 return sess, err
83 }
84 invite = strings.TrimSpace(invite)
85 if invite == "" {
86 return nil, nil
87 }
88 if logf != nil {
89 logf("bot: no session at %s — provisioning %q from its invite", path, name)
90 }
91 sess, err = Provision(base, invite, name)
92 if err != nil {
93 return nil, fmt.Errorf("bot: provision %q: %w", name, err)
94 }
95 // Persist before connecting: the invite is one-time, so a session held
96 // only in memory would be unrecoverable after the next restart.
97 if err := sess.Save(path); err != nil {
98 return nil, fmt.Errorf("bot: save session %s: %w", path, err)
99 }
100 return sess, nil
101}
102
103func getJSON(client *http.Client, url string, out any) error {
104 resp, err := client.Get(url)
105 if err != nil {
106 return err
107 }
108 defer resp.Body.Close()
109 return json.NewDecoder(resp.Body).Decode(out)
110}
111
112func postJSON(client *http.Client, url string, body, out any) error {
113 raw, err := json.Marshal(body)
114 if err != nil {
115 return err
116 }
117 resp, err := client.Post(url, "application/json", bytes.NewReader(raw))
118 if err != nil {
119 return err
120 }
121 defer resp.Body.Close()
122 if out == nil {
123 if resp.StatusCode != http.StatusOK {
124 return fmt.Errorf("bot: %s: HTTP %d", url, resp.StatusCode)
125 }
126 return nil
127 }
128 return json.NewDecoder(resp.Body).Decode(out)
129}
130