| 1 | package 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 | |
| 14 | import ( |
| 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. |
| 37 | func 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). |
| 65 | func 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). |
| 105 | func 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 | |