package main // The setup invite: a one-time, admin-granting invite minted for a fresh // instance's first user, so standing up a server lands its operator straight in // the chat view (where the Eleven setup conversation takes over) instead of on // /admin. See docs/conversational-onboarding.md. // // The link is /join/setup#. Everything the server stores is derived from T // exactly the way the browser does it (deriveInvite in crypto.js): the invite id // and the claim hash via domain-separated HKDF. The minter here is the operator's // control binary — the same trust context that already holds the admin token — // so T never rides through anything the operator doesn't already control. import ( "bytes" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "flag" "fmt" "io" "net/http" "path/filepath" "strings" "time" "golang.org/x/crypto/hkdf" ) // deriveInviteParts mirrors crypto.js deriveInvite: from the invite secret T // (base64url), derive the invite id, the claim secret, and the claim hash the // server stores. HKDF-SHA256 with an empty salt and domain-separated info // strings; the wrap key (unused here — a setup invite wraps nothing) is a third // derivation the client makes on its own. func deriveInviteParts(tB64 string) (id, claimSecret, claimHash string, err error) { ikm, err := base64.RawURLEncoding.DecodeString(tB64) if err != nil { return "", "", "", fmt.Errorf("bad invite secret: %v", err) } derive := func(info string) (string, error) { out := make([]byte, 32) if _, err := io.ReadFull(hkdf.New(sha256.New, ikm, nil, []byte(info)), out); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(out), nil } if id, err = derive("lchat-invite-id"); err != nil { return "", "", "", err } if claimSecret, err = derive("lchat-invite-claim"); err != nil { return "", "", "", err } // hex SHA-256 of the claim secret string — matches the server's hashToken and // the browser's sha256hex. sum := sha256.Sum256([]byte(claimSecret)) return id, claimSecret, hex.EncodeToString(sum[:]), nil } // mintSetupInvite creates a grants_admin key-invite on a running instance via // its admin API and returns the full join link. client lets the caller reach the // instance however it's listening (unix socket on the box, TCP locally); baseURL // is what the printed link should start with (https:// in production). func mintSetupInvite(client *http.Client, apiBase, linkBase, adminToken string) (string, error) { // 16 random bytes, like the browser's randomSecret(). var raw [16]byte if _, err := rand.Read(raw[:]); err != nil { return "", err } t := base64.RawURLEncoding.EncodeToString(raw[:]) id, _, claimHash, err := deriveInviteParts(t) if err != nil { return "", err } // An UNCLAIMED admin-granting link must not live forever — a stale one in // terminal scrollback or provisioning logs would mint an admin months later. // Seven days covers any realistic bootstrap; `lchat setup-invite` re-mints. body, _ := json.Marshal(map[string]any{ "id": id, "claim_hash": claimHash, "grants_admin": true, "expires": time.Now().Add(7 * 24 * time.Hour).Unix(), }) req, err := http.NewRequest("POST", apiBase+"/api/admin/key-invites", bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("mint setup invite: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) } return linkBase + "/join/setup#" + t, nil } // runSetupInvite: `lchat setup-invite ` mints a fresh setup link for an // instance on this box (reading its admin token from /data/.env), for when // the one add-instance printed was lost before it was claimed. With --url and // --token it targets any reachable instance instead (local dev). func runSetupInvite(args []string) { fs := flag.NewFlagSet("setup-invite", flag.ExitOnError) url := fs.String("url", "", "instance base URL (dev; e.g. http://127.0.0.1:1747) — with --token, bypasses the on-box env lookup") token := fs.String("token", "", "admin token to authenticate the mint (with --url)") _ = fs.Parse(reorderFlagsFirst(fs, args)) if *url != "" || *token != "" { if *url == "" || *token == "" { die("setup-invite: --url and --token go together") } link, err := mintSetupInvite(http.DefaultClient, strings.TrimRight(*url, "/"), strings.TrimRight(*url, "/"), *token) if err != nil { die("setup-invite: %v", err) } fmt.Println(link) return } host := strings.ToLower(strings.TrimSpace(fs.Arg(0))) if !validHost(host) { die("setup-invite: invalid host %q (or pass --url + --token)", host) } env, err := readEnvFile(filepath.Join(instanceDataDir, host+".env")) if err != nil { die("setup-invite: %v", err) } tok := env["LCHAT_ADMIN_TOKEN"] if tok == "" { die("setup-invite: no LCHAT_ADMIN_TOKEN in env for %s", host) } socket := filepath.Join(instanceSocketDir, host+".sock") link, err := mintSetupInvite(socketClient(socket), "http://lchat", "https://"+host, tok) if err != nil { // A hibernating instance may live (or be asleep) on a mesh peer — the // local socket then has nobody behind it, ever. Same fallback the // create path uses (control.go): mint through the public router hop, // which wakes the instance on whichever box owns it; the replicated // env means it holds this same admin token. pubLink, perr := mintPublicSetupInvite(host, tok) if perr != nil { die("setup-invite: socket: %v; public: %v", err, perr) } link = pubLink } fmt.Println(link) }