| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "flag" |
| 8 | "fmt" |
| 9 | "net" |
| 10 | "net/http" |
| 11 | "os" |
| 12 | "os/exec" |
| 13 | "runtime" |
| 14 | "strings" |
| 15 | "time" |
| 16 | |
| 17 | "lchat/internal/client" |
| 18 | "lchat/internal/e2ee" |
| 19 | ) |
| 20 | |
| 21 | const defaultHomeURL = "https://home.elevenmessenger.com" |
| 22 | |
| 23 | // runSignin pulls the member's saved spaces from their passkey-protected home |
| 24 | // backup. The passkey ceremony can't run in a terminal, so the browser does it |
| 25 | // (home's restore page, CLI-handoff mode): we mint an ephemeral keypair + |
| 26 | // nonce, the page unlocks the vault and seals the home session to that key, |
| 27 | // and it comes back either by loopback redirect or as a pasted code. With a |
| 28 | // home session already on disk this is just a silent re-sync. |
| 29 | func runSignin(st *store, args []string) { |
| 30 | fs := flag.NewFlagSet("eleven signin", flag.ExitOnError) |
| 31 | home := fs.String("home", envOr("ELEVEN_HOME", defaultHomeURL), "home server URL (env: ELEVEN_HOME)") |
| 32 | paste := fs.Bool("paste", false, "no local browser (e.g. SSH): print the link, paste the code back") |
| 33 | fresh := fs.Bool("fresh", false, "run the passkey ceremony even if a home sign-in is saved") |
| 34 | _ = fs.Parse(args) |
| 35 | |
| 36 | if !*fresh { |
| 37 | if hs, err := st.homeSession(); err != nil { |
| 38 | die("%v", err) |
| 39 | } else if hs != nil { |
| 40 | vault, err := client.FetchVault(hs) |
| 41 | if err == nil { |
| 42 | applyVault(st, vault) |
| 43 | return |
| 44 | } |
| 45 | if err != client.ErrHomeAuth { |
| 46 | die("%v", err) |
| 47 | } |
| 48 | fmt.Fprintln(os.Stderr, "Saved home sign-in has expired — running a fresh sign-in.") |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | kp, err := e2ee.GenerateUserKeypair() |
| 53 | if err != nil { |
| 54 | die("%v", err) |
| 55 | } |
| 56 | nonce, err := e2ee.RandomSecret() |
| 57 | if err != nil { |
| 58 | die("%v", err) |
| 59 | } |
| 60 | |
| 61 | var back string |
| 62 | if *paste { |
| 63 | back = signinPaste(*home, nonce, kp.PublicKey) |
| 64 | } else { |
| 65 | back = signinLoopback(*home, nonce, kp.PublicKey) |
| 66 | } |
| 67 | hs, err := client.OpenHandoff(back, nonce, kp.PrivateKey) |
| 68 | if err != nil { |
| 69 | die("%v", err) |
| 70 | } |
| 71 | hs.Base = strings.TrimRight(*home, "/") |
| 72 | if err := st.saveHomeSession(hs); err != nil { |
| 73 | die("%v", err) |
| 74 | } |
| 75 | vault, err := client.FetchVault(hs) |
| 76 | if err != nil { |
| 77 | die("%v", err) |
| 78 | } |
| 79 | applyVault(st, vault) |
| 80 | fmt.Printf("Home sign-in saved to %s — `eleven signin` re-syncs without the browser now.\n", st.homePath()) |
| 81 | } |
| 82 | |
| 83 | // restoreURL builds the handoff link: the payload rides in the #fragment, so |
| 84 | // it never reaches the home server. |
| 85 | func restoreURL(home, nonce, ephPub string, port int) string { |
| 86 | p := map[string]any{"cli": 1, "nonce": nonce, "ephPub": ephPub} |
| 87 | if port != 0 { |
| 88 | p["port"] = port |
| 89 | } |
| 90 | raw, _ := json.Marshal(p) |
| 91 | return strings.TrimRight(home, "/") + "/restore#" + e2ee.B64uEncode(raw) |
| 92 | } |
| 93 | |
| 94 | func signinPaste(home, nonce, ephPub string) string { |
| 95 | fmt.Printf("Open this link in a browser that has your Eleven passkey:\n\n %s\n\n", restoreURL(home, nonce, ephPub, 0)) |
| 96 | fmt.Print("It will show a code once you've signed in. Paste it here:\n> ") |
| 97 | in := bufio.NewReader(os.Stdin) |
| 98 | line, _ := in.ReadString('\n') |
| 99 | code := strings.TrimSpace(line) |
| 100 | if code == "" { |
| 101 | die("no code pasted") |
| 102 | } |
| 103 | return code |
| 104 | } |
| 105 | |
| 106 | // cbPage is the browser's last stop in the sign-in handoff. It's served from |
| 107 | // 127.0.0.1 so it can't share home's stylesheet — instead it inlines the home |
| 108 | // ceremony card's exact tokens (home/web/static/home.css) so the flow's final |
| 109 | // screen still reads as the same product: same card, same sealed green check, |
| 110 | // same "all done" copy as the app handoff. The tile glyph is a terminal |
| 111 | // prompt, because that's what the card is naming. |
| 112 | const cbPage = `<!doctype html><html lang="en"><head><meta charset="utf-8"> |
| 113 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 114 | <title>Signed in</title> |
| 115 | <style> |
| 116 | :root{color-scheme:light dark; |
| 117 | --bg:light-dark(#f6f7fb,#15171c);--panel:light-dark(#ffffff,#1d2026); |
| 118 | --ink:light-dark(#1c2333,#e7e9f0);--muted:light-dark(#5a6170,#9aa1b3); |
| 119 | --border:light-dark(#e7e9f0,rgba(255,255,255,.10));--accent:light-dark(#5666f0,#7d8bff); |
| 120 | --ok:light-dark(#237a41,#58b97e);--shadow:light-dark(rgba(20,30,80,.10),rgba(0,0,0,.55))} |
| 121 | body{margin:0;min-height:100svh;padding:24px 16px;display:grid;place-items:center; |
| 122 | font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; |
| 123 | background:var(--bg);color:var(--ink)} |
| 124 | .card{background:var(--panel);border:1px solid var(--border);border-radius:24px; |
| 125 | padding:28px;width:100%;max-width:420px;box-shadow:0 12px 40px var(--shadow);text-align:center} |
| 126 | .tile{position:relative;width:72px;height:72px;margin:0 auto 10px} |
| 127 | .glyph{width:72px;height:72px;border-radius:18px;display:grid;place-items:center; |
| 128 | background:color-mix(in srgb,var(--accent) 14%,var(--panel));color:var(--accent)} |
| 129 | .glyph svg{width:34px;height:34px} |
| 130 | .badge{position:absolute;right:-8px;bottom:-8px;width:30px;height:30px;border-radius:50%; |
| 131 | display:grid;place-items:center;background:var(--ok);color:#fff;border:3px solid var(--panel)} |
| 132 | .badge svg{width:15px;height:15px} |
| 133 | .name{font-weight:700;font-size:18px} |
| 134 | h1{margin:14px 0 6px;font-size:22px} |
| 135 | .sub{margin:0;color:var(--muted);font-size:15px} |
| 136 | </style></head><body> |
| 137 | <main class="card"> |
| 138 | <div class="tile" aria-hidden="true"> |
| 139 | <div class="glyph"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 6 6 6-6 6"/><path d="M13 18h7"/></svg></div> |
| 140 | <div class="badge"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></div> |
| 141 | </div> |
| 142 | <div class="name">your terminal</div> |
| 143 | <h1>You’re all done</h1> |
| 144 | <p class="sub">You’re signed in — head back to your terminal. |
| 145 | You can close this window now.</p> |
| 146 | </main></body></html>` |
| 147 | |
| 148 | // loopback is the local callback listener a browser handoff redirects to: |
| 149 | // http://127.0.0.1:<port>/cb?d=<sealed session>. First ?d= wins; everything |
| 150 | // else (favicon probes etc.) 404s quietly. |
| 151 | type loopback struct { |
| 152 | port int |
| 153 | got chan string |
| 154 | srv *http.Server |
| 155 | } |
| 156 | |
| 157 | func startLoopback() (*loopback, error) { |
| 158 | ln, err := net.Listen("tcp4", "127.0.0.1:0") |
| 159 | if err != nil { |
| 160 | return nil, err |
| 161 | } |
| 162 | lb := &loopback{port: ln.Addr().(*net.TCPAddr).Port, got: make(chan string, 1)} |
| 163 | mux := http.NewServeMux() |
| 164 | mux.HandleFunc("GET /cb", func(w http.ResponseWriter, r *http.Request) { |
| 165 | d := r.URL.Query().Get("d") |
| 166 | if d == "" { |
| 167 | http.Error(w, "missing code", http.StatusBadRequest) |
| 168 | return |
| 169 | } |
| 170 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 171 | fmt.Fprint(w, cbPage) |
| 172 | select { |
| 173 | case lb.got <- d: |
| 174 | default: // a second hit after success — already handled |
| 175 | } |
| 176 | }) |
| 177 | // Loopback-only and torn down after one hit, but cap header reads anyway — |
| 178 | // same posture as every other listener (main.go). |
| 179 | lb.srv = &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} |
| 180 | go lb.srv.Serve(ln) |
| 181 | return lb, nil |
| 182 | } |
| 183 | |
| 184 | func (lb *loopback) close() { |
| 185 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| 186 | defer cancel() |
| 187 | lb.srv.Shutdown(ctx) |
| 188 | } |
| 189 | |
| 190 | func signinLoopback(home, nonce, ephPub string) string { |
| 191 | lb, err := startLoopback() |
| 192 | if err != nil { |
| 193 | die("can't open a local port for the browser to call back on (%v) — try --paste", err) |
| 194 | } |
| 195 | defer lb.close() |
| 196 | |
| 197 | url := restoreURL(home, nonce, ephPub, lb.port) |
| 198 | fmt.Printf("Opening your browser to sign in with your passkey…\n\n %s\n\n(If the browser is on another machine, Ctrl-C and rerun with --paste.)\n", url) |
| 199 | openBrowser(url) |
| 200 | |
| 201 | select { |
| 202 | case d := <-lb.got: |
| 203 | return d |
| 204 | case <-time.After(5 * time.Minute): |
| 205 | die("timed out waiting for the browser — rerun, or use --paste") |
| 206 | } |
| 207 | panic("unreachable") |
| 208 | } |
| 209 | |
| 210 | func openBrowser(url string) { |
| 211 | var cmd *exec.Cmd |
| 212 | switch runtime.GOOS { |
| 213 | case "darwin": |
| 214 | cmd = exec.Command("open", url) |
| 215 | case "linux": |
| 216 | cmd = exec.Command("xdg-open", url) |
| 217 | default: |
| 218 | return // the URL is printed; opening by hand works everywhere |
| 219 | } |
| 220 | _ = cmd.Start() |
| 221 | } |
| 222 | |
| 223 | // applyVault writes every restorable space from the backup into the store. |
| 224 | func applyVault(st *store, vault *client.Vault) { |
| 225 | var saved, archived, bare int |
| 226 | for _, s := range vault.Servers { |
| 227 | if s.Archived { |
| 228 | archived++ |
| 229 | continue |
| 230 | } |
| 231 | sess := s.Session() |
| 232 | if sess == nil { |
| 233 | bare++ // saved by URL alone — nothing to sign in with |
| 234 | continue |
| 235 | } |
| 236 | if err := st.saveSpace(sess); err != nil { |
| 237 | die("%v", err) |
| 238 | } |
| 239 | star := "" |
| 240 | if sess.Starred { |
| 241 | star = " ★" |
| 242 | } |
| 243 | fmt.Printf(" ✓ %s (%s)%s\n", spaceLabel(sess), spaceHost(sess), star) |
| 244 | saved++ |
| 245 | } |
| 246 | switch { |
| 247 | case saved == 0 && len(vault.Servers) == 0: |
| 248 | fmt.Println("Signed in, but your Eleven account has no saved spaces yet — save one from the app first.") |
| 249 | case saved == 0: |
| 250 | fmt.Println("Signed in, but none of your saved spaces carry sign-in keys this terminal can use.") |
| 251 | default: |
| 252 | fmt.Printf("Signed into %d space(s).", saved) |
| 253 | if archived > 0 { |
| 254 | fmt.Printf(" (%d archived space(s) skipped.)", archived) |
| 255 | } |
| 256 | if bare > 0 { |
| 257 | fmt.Printf(" (%d bookmark-only entr(y/ies) skipped.)", bare) |
| 258 | } |
| 259 | fmt.Println("\n\nTry: eleven chats") |
| 260 | } |
| 261 | } |
| 262 | |