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
3import (
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
21const 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.
29func 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.
85func 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
94func 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.
112const 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))}
121body{margin:0;min-height:100svh;padding:24px 16px;display:grid;place-items:center;
122font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
123background:var(--bg);color:var(--ink)}
124.card{background:var(--panel);border:1px solid var(--border);border-radius:24px;
125padding: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;
128background: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%;
131display: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}
134h1{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&rsquo;re all done</h1>
144<p class="sub">You&rsquo;re signed in &mdash; head back to your terminal.
145You 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.
151type loopback struct {
152 port int
153 got chan string
154 srv *http.Server
155}
156
157func 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
184func (lb *loopback) close() {
185 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
186 defer cancel()
187 lb.srv.Shutdown(ctx)
188}
189
190func 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
210func 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.
224func 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