eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// The setup invite: a one-time, admin-granting invite minted for a fresh
4// instance's first user, so standing up a server lands its operator straight in
5// the chat view (where the Eleven setup conversation takes over) instead of on
6// /admin. See docs/conversational-onboarding.md.
7//
8// The link is /join/setup#<T>. Everything the server stores is derived from T
9// exactly the way the browser does it (deriveInvite in crypto.js): the invite id
10// and the claim hash via domain-separated HKDF. The minter here is the operator's
11// control binary — the same trust context that already holds the admin token —
12// so T never rides through anything the operator doesn't already control.
13
14import (
15 "bytes"
16 "crypto/rand"
17 "crypto/sha256"
18 "encoding/base64"
19 "encoding/hex"
20 "encoding/json"
21 "flag"
22 "fmt"
23 "io"
24 "net/http"
25 "path/filepath"
26 "strings"
27 "time"
28
29 "golang.org/x/crypto/hkdf"
30)
31
32// deriveInviteParts mirrors crypto.js deriveInvite: from the invite secret T
33// (base64url), derive the invite id, the claim secret, and the claim hash the
34// server stores. HKDF-SHA256 with an empty salt and domain-separated info
35// strings; the wrap key (unused here — a setup invite wraps nothing) is a third
36// derivation the client makes on its own.
37func deriveInviteParts(tB64 string) (id, claimSecret, claimHash string, err error) {
38 ikm, err := base64.RawURLEncoding.DecodeString(tB64)
39 if err != nil {
40 return "", "", "", fmt.Errorf("bad invite secret: %v", err)
41 }
42 derive := func(info string) (string, error) {
43 out := make([]byte, 32)
44 if _, err := io.ReadFull(hkdf.New(sha256.New, ikm, nil, []byte(info)), out); err != nil {
45 return "", err
46 }
47 return base64.RawURLEncoding.EncodeToString(out), nil
48 }
49 if id, err = derive("lchat-invite-id"); err != nil {
50 return "", "", "", err
51 }
52 if claimSecret, err = derive("lchat-invite-claim"); err != nil {
53 return "", "", "", err
54 }
55 // hex SHA-256 of the claim secret string — matches the server's hashToken and
56 // the browser's sha256hex.
57 sum := sha256.Sum256([]byte(claimSecret))
58 return id, claimSecret, hex.EncodeToString(sum[:]), nil
59}
60
61// mintSetupInvite creates a grants_admin key-invite on a running instance via
62// its admin API and returns the full join link. client lets the caller reach the
63// instance however it's listening (unix socket on the box, TCP locally); baseURL
64// is what the printed link should start with (https://<host> in production).
65func mintSetupInvite(client *http.Client, apiBase, linkBase, adminToken string) (string, error) {
66 // 16 random bytes, like the browser's randomSecret().
67 var raw [16]byte
68 if _, err := rand.Read(raw[:]); err != nil {
69 return "", err
70 }
71 t := base64.RawURLEncoding.EncodeToString(raw[:])
72 id, _, claimHash, err := deriveInviteParts(t)
73 if err != nil {
74 return "", err
75 }
76 // An UNCLAIMED admin-granting link must not live forever — a stale one in
77 // terminal scrollback or provisioning logs would mint an admin months later.
78 // Seven days covers any realistic bootstrap; `lchat setup-invite` re-mints.
79 body, _ := json.Marshal(map[string]any{
80 "id": id, "claim_hash": claimHash, "grants_admin": true,
81 "expires": time.Now().Add(7 * 24 * time.Hour).Unix(),
82 })
83 req, err := http.NewRequest("POST", apiBase+"/api/admin/key-invites", bytes.NewReader(body))
84 if err != nil {
85 return "", err
86 }
87 req.Header.Set("Authorization", "Bearer "+adminToken)
88 req.Header.Set("Content-Type", "application/json")
89 resp, err := client.Do(req)
90 if err != nil {
91 return "", err
92 }
93 defer resp.Body.Close()
94 if resp.StatusCode != http.StatusOK {
95 b, _ := io.ReadAll(resp.Body)
96 return "", fmt.Errorf("mint setup invite: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
97 }
98 return linkBase + "/join/setup#" + t, nil
99}
100
101// runSetupInvite: `lchat setup-invite <host>` mints a fresh setup link for an
102// instance on this box (reading its admin token from /data/<host>.env), for when
103// the one add-instance printed was lost before it was claimed. With --url and
104// --token it targets any reachable instance instead (local dev).
105func runSetupInvite(args []string) {
106 fs := flag.NewFlagSet("setup-invite", flag.ExitOnError)
107 url := fs.String("url", "", "instance base URL (dev; e.g. http://127.0.0.1:1747) — with --token, bypasses the on-box env lookup")
108 token := fs.String("token", "", "admin token to authenticate the mint (with --url)")
109 _ = fs.Parse(reorderFlagsFirst(fs, args))
110
111 if *url != "" || *token != "" {
112 if *url == "" || *token == "" {
113 die("setup-invite: --url and --token go together")
114 }
115 link, err := mintSetupInvite(http.DefaultClient, strings.TrimRight(*url, "/"), strings.TrimRight(*url, "/"), *token)
116 if err != nil {
117 die("setup-invite: %v", err)
118 }
119 fmt.Println(link)
120 return
121 }
122
123 host := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
124 if !validHost(host) {
125 die("setup-invite: invalid host %q (or pass --url + --token)", host)
126 }
127 env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env"))
128 if err != nil {
129 die("setup-invite: %v", err)
130 }
131 tok := env["LCHAT_ADMIN_TOKEN"]
132 if tok == "" {
133 die("setup-invite: no LCHAT_ADMIN_TOKEN in env for %s", host)
134 }
135 socket := filepath.Join(instanceSocketDir, host+".sock")
136 link, err := mintSetupInvite(socketClient(socket), "http://lchat", "https://"+host, tok)
137 if err != nil {
138 // A hibernating instance may live (or be asleep) on a mesh peer — the
139 // local socket then has nobody behind it, ever. Same fallback the
140 // create path uses (control.go): mint through the public router hop,
141 // which wakes the instance on whichever box owns it; the replicated
142 // env means it holds this same admin token.
143 pubLink, perr := mintPublicSetupInvite(host, tok)
144 if perr != nil {
145 die("setup-invite: socket: %v; public: %v", err, perr)
146 }
147 link = pubLink
148 }
149 fmt.Println(link)
150}
151