package client import ( "bytes" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "lchat/internal/e2ee" ) // JoinResult says what kind of link Join claimed, beyond the identity itself. type JoinResult struct { // AddDevice: the link carried an existing member's keypair — the session is // that member's identity on a new device, not a new person. AddDevice bool // ThreadID is the pending DM a user invite drops the joiner into ("" for // other link kinds) — a client can open it first. ThreadID string } // Join is web/static/join.js run headlessly: turn any invite link into a local // identity. It handles all three link shapes — a fresh-person invite (generate // a keypair, register it), a member-minted user invite (additionally re-wrap // the pending DM's key to the new keypair), and an add-device link (adopt the // keypair the link carries; the session then IS that member). The secret T // never reaches the server: the invite id and claim secret derive from it, and // whatever the link's wrapped blob holds decrypts locally. func Join(base, fragment, name string) (*Session, JoinResult, error) { base = strings.TrimRight(base, "/") hc := &http.Client{Timeout: 30 * time.Second} inv, err := e2ee.DeriveInvite(fragment) if err != nil { return nil, JoinResult{}, fmt.Errorf("client: bad invite secret: %w", err) } var info struct { OK bool `json:"ok"` Wrapped string `json:"wrapped"` } if err := getJSON(hc, base+"/api/key-invite/"+url.PathEscape(inv.ID), &info); err != nil { return nil, JoinResult{}, err } if !info.OK { return nil, JoinResult{}, fmt.Errorf("client: this invite was already used or has expired — ask for a fresh one") } // What the link carries decides who we become (join.js): a keypair means // add-device; a thread key means a user invite with a pending DM; nothing // means a plain fresh-person invite. var payload struct { Priv string `json:"priv"` Pub string `json:"pub"` ThreadKey string `json:"threadKey"` } if info.Wrapped != "" { if err := e2ee.DecryptJSON(inv.WrapKey, info.Wrapped, &payload); err != nil { return nil, JoinResult{}, fmt.Errorf("client: couldn't unlock this link (wrong or corrupt secret)") } } pub, priv := payload.Pub, payload.Priv wrappedSelf := "" if priv == "" { kp, err := e2ee.GenerateUserKeypair() if err != nil { return nil, JoinResult{}, err } pub, priv = kp.PublicKey, kp.PrivateKey if payload.ThreadKey != "" { // User invite: re-wrap the pending DM's key to our new keypair so the // chat with the person who invited us opens readable. if wrappedSelf, err = e2ee.WrapThreadKey(payload.ThreadKey, pub); err != nil { return nil, JoinResult{}, err } } } var claim struct { OK bool `json:"ok"` Token string `json:"token"` ThreadID string `json:"thread_id"` } err = postJSON(hc, base+"/api/key-invite/"+url.PathEscape(inv.ID)+"/claim", map[string]string{"claim_secret": inv.ClaimSecret, "wrapped": wrappedSelf}, &claim) if err != nil { return nil, JoinResult{}, err } if !claim.OK || claim.Token == "" { return nil, JoinResult{}, fmt.Errorf("client: couldn't claim the invite (it may have just been used)") } // Register (or, on add-device, harmlessly re-register) the public key. err = postJSON(hc, base+"/api/pubkey", map[string]string{"token": claim.Token, "public_key": pub}, nil) if err != nil { return nil, JoinResult{}, err } sess := &Session{Base: base, Token: claim.Token, Name: name, UserPub: pub, UserPriv: priv} return sess, JoinResult{AddDevice: payload.Priv != "", ThreadID: claim.ThreadID}, nil } func getJSON(hc *http.Client, url string, out any) error { resp, err := hc.Get(url) if err != nil { return err } defer resp.Body.Close() return json.NewDecoder(resp.Body).Decode(out) } func postJSON(hc *http.Client, url string, body, out any) error { raw, err := json.Marshal(body) if err != nil { return err } resp, err := hc.Post(url, "application/json", bytes.NewReader(raw)) if err != nil { return err } defer resp.Body.Close() if out == nil { if resp.StatusCode != http.StatusOK { return fmt.Errorf("client: %s: HTTP %d", url, resp.StatusCode) } return nil } return json.NewDecoder(resp.Body).Decode(out) }