eleven: sign in with your passkey — every saved space, one command (#502)
* eleven: signin with your passkey — every saved space, one command internal/client grows the home-vault half: OpenHandoff (nonce check + ephemeral-key unwrap of what restore.js hands back), FetchVault (bearer GET + EHB1 envelope open, byte-compatible with home crypto.js — pinned by the shared JS-generated vector), and Seal/OpenHomeBlob. The CLI session store goes multi-space (~/.eleven/spaces/*.json + home.json for silent re-sync; legacy session.json migrates); chats/chat/send resolve across spaces with space/name scoping. Fixes an own-echo race where the WS broadcast could beat the POST response carrying the stored id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * eleven: the loopback confirm page is the home ceremony card, not a bare line The browser's last stop in the sign-in handoff (127.0.0.1/cb) rendered one unstyled sentence. It can't share home's stylesheet from loopback, so it now inlines the home ceremony card's exact tokens — same card geometry, palette (light+dark), sealed green check badge, and the same 'You're all done / close this window' copy as the app handoff — with a terminal-prompt glyph in the tile, since the card names 'your terminal'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
12 files changed,
+1291
−95
docs/eleven-cli.md+42 −10eleven/main.go+224 −84eleven/signin.go+259 −0eleven/signin_test.go+140 −0eleven/store.go+149 −0eleven_client_test.go+118 −0home/test/gen-home-vector.mjs+3 −1internal/client/client.go+16 −0internal/client/home.go+220 −0internal/client/home_test.go+114 −0internal/client/session.go+5 −0internal/client/testdata/home-vector.txt+1 −0
diff --git a/docs/eleven-cli.md b/docs/eleven-cli.md| index d328056..b0e0e43 100644 |
| --- a/docs/eleven-cli.md |
| +++ b/docs/eleven-cli.md |
| @@ -1,9 +1,10 @@ |
| # 🤖 eleven — the command-line client |
| `eleven` is Eleven Messenger in your terminal: a full E2EE member, not a |
| -gateway or a bot. It joins with the same invite links every device uses, holds |
| -its private key in a local file, and encrypts/decrypts everything itself — the |
| -server sees exactly what it sees from any other device: ciphertext. |
| +gateway or a bot. It signs in with the same passkey backup or invite links |
| +every device uses, holds its private keys in local files, and |
| +encrypts/decrypts everything itself — the servers see exactly what they see |
| +from any other device: ciphertext. |
| It's a separate small binary (like `push/` and `home/`): members install the |
| client, operators install the server. |
| @@ -12,7 +13,35 @@ client, operators install the server. |
| go build -o eleven ./eleven |
| ``` |
| -## Joining |
| +## Sign in with Eleven (`eleven signin`) |
| + |
| +One passkey, every space. `eleven signin` pulls the spaces saved in your |
| +Eleven account (the home server's passkey-protected backup) and signs the |
| +terminal into all of them at once. |
| + |
| +A terminal can't run a passkey ceremony, so the browser does it — the same |
| +handoff the native Mac app uses when its passkey provider can't help: |
| + |
| +1. `eleven signin` mints an ephemeral keypair + nonce, opens a local loopback |
| + listener, and sends your browser to home's restore page (payload in the |
| + `#fragment` — the home server never sees it). |
| +2. You approve with Face ID / Touch ID / your passkey. The page decrypts your |
| + backup **in the browser**, seals the home session to the ephemeral key, and |
| + redirects to `http://127.0.0.1:<port>` — hardcoded loopback, ciphertext all |
| + the way. |
| +3. The CLI unwraps it, pulls the vault, and writes one identity per space |
| + under `~/.eleven/spaces/`. Archived spaces are skipped. |
| + |
| +SSH'd into a box whose browser is elsewhere? `eleven signin --paste` prints |
| +the link and takes the sealed code by paste instead of a redirect. |
| + |
| +The home session lands in `~/.eleven/home.json`, so later `eleven signin` |
| +runs re-sync silently — no browser. Note the trust profile: that file (like |
| +the native apps' Keychain copy) can fetch and open your whole backup, and the |
| +space session files are the space keys themselves. `~/.eleven` *is* your key |
| +ring; deleting it signs the terminal out of everything. |
| + |
| +## Joining one space by invite (`eleven join`) |
| ```sh |
| eleven join # prompts for an invite link (and your name) |
| @@ -29,10 +58,11 @@ All three link shapes work: |
| *you*: the link carries your keypair in its `#fragment`, so the terminal |
| becomes another of your devices and can read everything your others can. |
| -Your identity lands in `~/.eleven/session.json` (0600) — override with |
| -`-session` or `ELEVEN_SESSION`. That file **is** your private key: anyone who |
| -reads it can read your chats. Deleting it signs the terminal out (and, if it |
| -was your only device, loses those keys for good — same as any device). |
| +Your identity lands in `~/.eleven/spaces/<host>.json` (0600) — or pass |
| +`-session file` (`ELEVEN_SESSION`) to pin one identity file and ignore the |
| +store, for scripts. Session files **are** private keys: anyone who reads one |
| +can read those chats, and deleting your only device's key loses it for good — |
| +same as any device. |
| Two honest gaps on a *fresh* join, both inherent to how keys travel: your |
| roster name waits for the instance key, which another member's device grants |
| @@ -44,9 +74,11 @@ welcome DM shows up on its own. |
| ## Talking |
| ```sh |
| -eleven chats # your chats: name, kind, last line |
| -eleven chat # open your most recent chat |
| +eleven spaces # what this terminal is signed into |
| +eleven chats # your chats across every space |
| +eleven chat # open your most recently active chat |
| eleven chat kitchen # …or match one by name (case-insensitive) |
| +eleven chat family/kitchen # space/name scopes when names collide |
| eleven send eleven "what's the weather like for Saturday?" |
| git log --oneline -5 | eleven send eleven # pipe stdin as the message |
| ``` |
diff --git a/eleven/main.go b/eleven/main.go| index 3d90b94..d81ed76 100644 |
| --- a/eleven/main.go |
| +++ b/eleven/main.go |
| @@ -1,8 +1,8 @@ |
| // eleven is the Eleven Messenger command line: a full E2EE member in your |
| -// terminal. Join a space with an invite link (or bring your existing identity |
| -// over with an add-device link), then read and send messages from the shell — |
| -// the private key lives in a local file and the server only ever sees |
| -// ciphertext, exactly like every other device. |
| +// terminal. Sign in once with your passkey (`eleven signin`) to pull every |
| +// space saved in your Eleven account, or join a single space with an invite |
| +// link — either way the private keys live in local files and the servers only |
| +// ever see ciphertext, exactly like every other device. |
| // |
| // A separate small binary (like push/ and home/), not an lchat subcommand: |
| // members install the client, operators install the server. |
| @@ -24,8 +24,10 @@ import ( |
| func main() { |
| fs := flag.NewFlagSet("eleven", flag.ExitOnError) |
| - sessionPath := fs.String("session", envOr("ELEVEN_SESSION", defaultSessionPath()), |
| - "identity file (env: ELEVEN_SESSION) — holds your private key; keep it safe") |
| + sessionPath := fs.String("session", envOr("ELEVEN_SESSION", ""), |
| + "use ONE identity file instead of the ~/.eleven store (env: ELEVEN_SESSION)") |
| + dir := fs.String("dir", envOr("ELEVEN_DIR", defaultDir()), |
| + "the eleven store directory (env: ELEVEN_DIR)") |
| fs.Usage = usage(fs) |
| _ = fs.Parse(os.Args[1:]) |
| args := fs.Args() |
| @@ -35,14 +37,25 @@ func main() { |
| } |
| switch args[0] { |
| + case "signin": |
| + if *sessionPath != "" { |
| + die("signin fills the ~/.eleven store — drop the -session flag") |
| + } |
| + st, err := openStore(*dir) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + runSignin(st, args[1:]) |
| case "join": |
| - runJoin(*sessionPath, args[1:]) |
| + runJoin(*dir, *sessionPath, args[1:]) |
| + case "spaces": |
| + runSpaces(mustSpaces(*dir, *sessionPath)) |
| case "chats": |
| - runChats(mustClient(*sessionPath)) |
| + runChats(mustSpaces(*dir, *sessionPath)) |
| case "chat": |
| - runChat(mustClient(*sessionPath), strings.Join(args[1:], " ")) |
| + runChat(mustSpaces(*dir, *sessionPath), strings.Join(args[1:], " ")) |
| case "send": |
| - runSend(mustClient(*sessionPath), args[1:]) |
| + runSend(mustSpaces(*dir, *sessionPath), args[1:]) |
| case "help", "-h", "--help": |
| fs.Usage() |
| default: |
| @@ -54,27 +67,35 @@ func main() { |
| func usage(fs *flag.FlagSet) func() { |
| return func() { |
| - fmt.Fprint(os.Stderr, `usage: eleven [-session file] <command> |
| + fmt.Fprint(os.Stderr, `usage: eleven <command> |
| commands: |
| - join [--name you] [link] join a space with an invite link (add-device links |
| - sign this terminal in as you; prompts if no link given) |
| - chats list your chats |
| - chat [name] open a chat and talk; newest chat when no name. |
| - Type to send, Ctrl-C or Ctrl-D to leave |
| - send <name> [text] send one message and exit (reads stdin without text) |
| + signin [--paste] [--fresh] sign in with your passkey (via your browser) and |
| + pull every space saved in your Eleven account; |
| + re-syncs silently once signed in |
| + join [--name you] [link] join one space with an invite link (add-device |
| + links sign this terminal in as you) |
| + spaces list the spaces this terminal is signed into |
| + chats list your chats |
| + chat [name] open a chat and talk; newest chat when no name. |
| + Type to send, Ctrl-C or Ctrl-D to leave |
| + send <name> [text] send one message and exit (reads stdin without text) |
| + |
| +Chat names match by substring; prefix with a space to disambiguate |
| +("family/kitchen"). Identities live in ~/.eleven — those files hold your |
| +private keys; keep them safe. |
| `) |
| fs.PrintDefaults() |
| } |
| } |
| -func defaultSessionPath() string { |
| +func defaultDir() string { |
| home, err := os.UserHomeDir() |
| if err != nil { |
| - return "eleven-session.json" |
| + return ".eleven" |
| } |
| - return filepath.Join(home, ".eleven", "session.json") |
| + return filepath.Join(home, ".eleven") |
| } |
| func envOr(key, def string) string { |
| @@ -89,40 +110,68 @@ func die(format string, args ...any) { |
| os.Exit(1) |
| } |
| -func mustClient(sessionPath string) *client.Client { |
| - sess, err := client.LoadSession(sessionPath) |
| +// space is one signed-in space: its session plus a lazily-shared client. |
| +type space struct { |
| + sess *client.Session |
| + c *client.Client |
| +} |
| + |
| +// mustSpaces loads every identity this terminal holds: the explicit -session |
| +// file when given (single-space mode), else the ~/.eleven store. Fresh |
| +// members' name publish is retried here (it needs the instance key, granted |
| +// asynchronously by another member's device). |
| +func mustSpaces(dir, sessionPath string) []*space { |
| + if sessionPath != "" { |
| + sess, err := client.LoadSession(sessionPath) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + if sess == nil { |
| + die("no identity at %s — run `eleven join` or `eleven signin` first", sessionPath) |
| + } |
| + if sess.Base == "" { |
| + die("%s has no server URL — re-join with `eleven join`", sessionPath) |
| + } |
| + return []*space{newSpace(sess, sessionPath)} |
| + } |
| + st, err := openStore(dir) |
| if err != nil { |
| die("%v", err) |
| } |
| - if sess == nil { |
| - die("no identity at %s — run `eleven join` with an invite link first", sessionPath) |
| + sessions, err := st.spaces() |
| + if err != nil { |
| + die("%v", err) |
| } |
| - if sess.Base == "" { |
| - die("%s has no server URL — re-join with `eleven join`", sessionPath) |
| + if len(sessions) == 0 { |
| + die("no spaces yet — `eleven signin` (passkey) or `eleven join` (invite link) first") |
| } |
| - c := &client.Client{Base: sess.Base, Session: sess} |
| - // A fresh member's name publish needs the instance key, granted only once |
| - // another member's device is online — keep retrying until it lands. |
| + var out []*space |
| + for _, sess := range sessions { |
| + path, err := st.spacePath(sess) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + out = append(out, newSpace(sess, path)) |
| + } |
| + return out |
| +} |
| + |
| +func newSpace(sess *client.Session, path string) *space { |
| + sp := &space{sess: sess, c: &client.Client{Base: sess.Base, Session: sess}} |
| if !sess.LabelPublished { |
| - if err := c.PublishLabel(); err == nil { |
| + if err := sp.c.PublishLabel(); err == nil { |
| sess.LabelPublished = true |
| - _ = sess.Save(sessionPath) |
| + _ = sess.Save(path) |
| } |
| } |
| - return c |
| + return sp |
| } |
| -func runJoin(sessionPath string, args []string) { |
| +func runJoin(dir, sessionPath string, args []string) { |
| fs := flag.NewFlagSet("eleven join", flag.ExitOnError) |
| name := fs.String("name", "", "your display name (prompts if the link doesn't carry one)") |
| _ = fs.Parse(args) |
| - if existing, err := client.LoadSession(sessionPath); err != nil { |
| - die("%v", err) |
| - } else if existing != nil { |
| - die("already joined as %q (%s) — delete %s to start over", existing.Name, existing.Base, sessionPath) |
| - } |
| - |
| in := bufio.NewReader(os.Stdin) |
| link := strings.TrimSpace(strings.Join(fs.Args(), " ")) |
| if link == "" { |
| @@ -138,6 +187,28 @@ func runJoin(sessionPath string, args []string) { |
| die("%v", err) |
| } |
| + // One identity per space per store: joining a space this terminal already |
| + // holds would orphan the existing keys. |
| + var st *store |
| + if sessionPath == "" { |
| + if st, err = openStore(dir); err != nil { |
| + die("%v", err) |
| + } |
| + existing, err := st.spaces() |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + for _, e := range existing { |
| + if e.Base == inv.Base { |
| + die("already signed into %s (as %q) — `eleven chat` to use it", inv.Base, e.Name) |
| + } |
| + } |
| + } else if existing, err := client.LoadSession(sessionPath); err != nil { |
| + die("%v", err) |
| + } else if existing != nil { |
| + die("already joined as %q (%s) — delete %s to start over", existing.Name, existing.Base, sessionPath) |
| + } |
| + |
| who := strings.TrimSpace(*name) |
| if who == "" { |
| who = inv.Name // the inviter's suggestion rides in the link fragment |
| @@ -164,7 +235,13 @@ func runJoin(sessionPath string, args []string) { |
| // member's device grants the instance key; retried on later runs. |
| sess.LabelPublished = true |
| } |
| - if err := sess.Save(sessionPath); err != nil { |
| + saveTo := sessionPath |
| + if st != nil { |
| + if err := st.saveSpace(sess); err != nil { |
| + die("%v", err) |
| + } |
| + saveTo, _ = st.spacePath(sess) |
| + } else if err := sess.Save(sessionPath); err != nil { |
| die("%v", err) |
| } |
| if res.AddDevice { |
| @@ -175,52 +252,95 @@ func runJoin(sessionPath string, args []string) { |
| fmt.Println("(Your name will appear in the member list once another member is online; your messages carry it already.)") |
| } |
| } |
| - fmt.Printf("Identity saved to %s — it holds your private key; keep it safe.\n", sessionPath) |
| + fmt.Printf("Identity saved to %s — it holds your private key; keep it safe.\n", saveTo) |
| fmt.Println("\nTry: eleven chats") |
| } |
| -func runChats(c *client.Client) { |
| - threads, err := c.Threads() |
| - if err != nil { |
| - die("%v", err) |
| +func runSpaces(spaces []*space) { |
| + for _, sp := range spaces { |
| + star := " " |
| + if sp.sess.Starred { |
| + star = "★" |
| + } |
| + fmt.Printf("%s %-28s %s (as %s)\n", star, clip(spaceLabel(sp.sess), 28), spaceHost(sp.sess), sp.sess.Name) |
| } |
| - if len(threads) == 0 { |
| - fmt.Println("No chats yet — they appear here once someone messages you or adds you to a group.") |
| - return |
| +} |
| + |
| +// spaceThread pairs a thread with the space it lives in. |
| +type spaceThread struct { |
| + sp *space |
| + t client.Thread |
| +} |
| + |
| +func allThreads(spaces []*space) []spaceThread { |
| + var out []spaceThread |
| + for _, sp := range spaces { |
| + threads, err := sp.c.Threads() |
| + if err != nil { |
| + fmt.Fprintf(os.Stderr, "eleven: %s: %v\n", spaceHost(sp.sess), err) |
| + continue |
| + } |
| + for _, t := range threads { |
| + out = append(out, spaceThread{sp: sp, t: t}) |
| + } |
| } |
| - for _, t := range threads { |
| - name := displayName(t) |
| - preview := "" |
| - if t.LastText != "" { |
| - preview = t.LastName + ": " + firstLine(t.LastText) |
| + return out |
| +} |
| + |
| +func runChats(spaces []*space) { |
| + printed := 0 |
| + for _, sp := range spaces { |
| + threads, err := sp.c.Threads() |
| + if err != nil { |
| + fmt.Fprintf(os.Stderr, "eleven: %s: %v\n", spaceHost(sp.sess), err) |
| + continue |
| + } |
| + if len(spaces) > 1 { |
| + fmt.Printf("— %s (%s)\n", spaceLabel(sp.sess), spaceHost(sp.sess)) |
| + } |
| + for _, t := range threads { |
| + name := displayName(t) |
| + preview := "" |
| + if t.LastText != "" { |
| + preview = t.LastName + ": " + firstLine(t.LastText) |
| + } |
| + fmt.Printf("%-24s %-6s %s %s\n", clip(name, 24), t.Kind, relTime(t.LastAt), clip(preview, 60)) |
| + printed++ |
| } |
| - fmt.Printf("%-24s %-6s %s %s\n", clip(name, 24), t.Kind, relTime(t.LastAt), clip(preview, 60)) |
| + } |
| + if printed == 0 { |
| + fmt.Println("No chats yet — they appear here once someone messages you or adds you to a group.") |
| } |
| } |
| -func runChat(c *client.Client, needle string) { |
| - t := resolveThread(c, needle) |
| +func runChat(spaces []*space, needle string) { |
| + st := resolveThread(spaces, needle) |
| + sp, t := st.sp, st.t |
| name := displayName(t) |
| - history, err := c.History(t.ID, 25) |
| + history, err := sp.c.History(t.ID, 25) |
| if err != nil { |
| die("%v", err) |
| } |
| - fmt.Printf("── %s ── (type to send · Ctrl-C to leave)\n", name) |
| + if len(spaces) > 1 { |
| + fmt.Printf("── %s · %s ── (type to send · Ctrl-C to leave)\n", name, spaceLabel(sp.sess)) |
| + } else { |
| + fmt.Printf("── %s ── (type to send · Ctrl-C to leave)\n", name) |
| + } |
| for _, m := range history { |
| printMessage(m) |
| } |
| ctx, cancel := context.WithCancel(context.Background()) |
| defer cancel() |
| - c.OnMessage = func(m client.Message) { |
| + sp.c.OnMessage = func(m client.Message) { |
| if m.ThreadID != t.ID || m.Echo { |
| return // this terminal's own sends are already on screen as the typed line |
| } |
| printMessage(m) // Mine-but-not-Echo = sent from another of your devices → "you" |
| } |
| errc := make(chan error, 1) |
| - go func() { errc <- c.Run(ctx) }() |
| + go func() { errc <- sp.c.Run(ctx) }() |
| in := bufio.NewScanner(os.Stdin) |
| in.Buffer(make([]byte, 64*1024), 64*1024) |
| @@ -229,7 +349,7 @@ func runChat(c *client.Client, needle string) { |
| if text == "" { |
| continue |
| } |
| - if err := c.Send(t.ID, text); err != nil { |
| + if err := sp.c.Send(t.ID, text); err != nil { |
| fmt.Fprintf(os.Stderr, "eleven: not sent: %v\n", err) |
| } |
| } |
| @@ -237,11 +357,11 @@ func runChat(c *client.Client, needle string) { |
| <-errc |
| } |
| -func runSend(c *client.Client, args []string) { |
| +func runSend(spaces []*space, args []string) { |
| if len(args) == 0 { |
| die("usage: eleven send <chat> [text] (reads stdin when text is omitted)") |
| } |
| - t := resolveThread(c, args[0]) |
| + st := resolveThread(spaces, args[0]) |
| text := strings.TrimSpace(strings.Join(args[1:], " ")) |
| if text == "" { |
| raw, err := readAllStdin() |
| @@ -253,10 +373,10 @@ func runSend(c *client.Client, args []string) { |
| if text == "" { |
| die("nothing to send") |
| } |
| - if err := c.Send(t.ID, text); err != nil { |
| + if err := st.sp.c.Send(st.t.ID, text); err != nil { |
| die("%v", err) |
| } |
| - fmt.Printf("Sent to %s.\n", displayName(t)) |
| + fmt.Printf("Sent to %s.\n", displayName(st.t)) |
| } |
| func readAllStdin() (string, error) { |
| @@ -264,31 +384,51 @@ func readAllStdin() (string, error) { |
| return string(raw), err |
| } |
| -// resolveThread finds a chat by (case-insensitive) name substring; an empty |
| -// needle picks the newest chat. Exact match beats substring; ambiguity lists |
| -// the candidates instead of guessing. |
| -func resolveThread(c *client.Client, needle string) client.Thread { |
| - threads, err := c.Threads() |
| - if err != nil { |
| - die("%v", err) |
| +// resolveThread finds a chat by (case-insensitive) name substring across every |
| +// space; "space/name" scopes to one space first. An empty needle picks the |
| +// most recently active chat overall. Exact match beats substring; ambiguity |
| +// lists the candidates instead of guessing. |
| +func resolveThread(spaces []*space, needle string) spaceThread { |
| + needle = strings.ToLower(strings.TrimSpace(needle)) |
| + if i := strings.Index(needle, "/"); i >= 0 { |
| + spaceNeedle, rest := needle[:i], needle[i+1:] |
| + var scoped []*space |
| + for _, sp := range spaces { |
| + if matchesSpace(sp.sess, spaceNeedle) { |
| + scoped = append(scoped, sp) |
| + } |
| + } |
| + if len(scoped) == 0 { |
| + die("no space matches %q — see `eleven spaces`", spaceNeedle) |
| + } |
| + spaces, needle = scoped, rest |
| } |
| - if len(threads) == 0 { |
| + all := allThreads(spaces) |
| + if len(all) == 0 { |
| die("no chats yet — someone needs to message you or add you to a group first") |
| } |
| - needle = strings.ToLower(strings.TrimSpace(needle)) |
| if needle == "" { |
| - return threads[0] // server order: pinned, then most recent |
| + newest := all[0] |
| + for _, st := range all[1:] { |
| + if st.t.LastAt > newest.t.LastAt { |
| + newest = st |
| + } |
| + } |
| + return newest |
| } |
| - var matches []client.Thread |
| - for _, t := range threads { |
| - n := strings.ToLower(displayName(t)) |
| + var exact, partial []spaceThread |
| + for _, st := range all { |
| + n := strings.ToLower(displayName(st.t)) |
| if n == needle { |
| - return t |
| - } |
| - if strings.Contains(n, needle) || t.ID == needle { |
| - matches = append(matches, t) |
| + exact = append(exact, st) |
| + } else if strings.Contains(n, needle) || st.t.ID == needle { |
| + partial = append(partial, st) |
| } |
| } |
| + if len(exact) == 1 { |
| + return exact[0] |
| + } |
| + matches := append(exact, partial...) |
| switch len(matches) { |
| case 1: |
| return matches[0] |
| @@ -296,8 +436,8 @@ func resolveThread(c *client.Client, needle string) client.Thread { |
| die("no chat matches %q — see `eleven chats`", needle) |
| default: |
| names := make([]string, len(matches)) |
| - for i, t := range matches { |
| - names[i] = displayName(t) |
| + for i, st := range matches { |
| + names[i] = spaceHost(st.sp.sess) + "/" + displayName(st.t) |
| } |
| die("%q matches several chats: %s", needle, strings.Join(names, ", ")) |
| } |
diff --git a/eleven/signin.go b/eleven/signin.go| new file mode 100644 |
| index 0000000..6f90bf1 |
| --- /dev/null |
| +++ b/eleven/signin.go |
| @@ -0,0 +1,259 @@ |
| +package main |
| + |
| +import ( |
| + "bufio" |
| + "context" |
| + "encoding/json" |
| + "flag" |
| + "fmt" |
| + "net" |
| + "net/http" |
| + "os" |
| + "os/exec" |
| + "runtime" |
| + "strings" |
| + "time" |
| + |
| + "lchat/internal/client" |
| + "lchat/internal/e2ee" |
| +) |
| + |
| +const defaultHomeURL = "https://home.elevenmessenger.com" |
| + |
| +// runSignin pulls the member's saved spaces from their passkey-protected home |
| +// backup. The passkey ceremony can't run in a terminal, so the browser does it |
| +// (home's restore page, CLI-handoff mode): we mint an ephemeral keypair + |
| +// nonce, the page unlocks the vault and seals the home session to that key, |
| +// and it comes back either by loopback redirect or as a pasted code. With a |
| +// home session already on disk this is just a silent re-sync. |
| +func runSignin(st *store, args []string) { |
| + fs := flag.NewFlagSet("eleven signin", flag.ExitOnError) |
| + home := fs.String("home", envOr("ELEVEN_HOME", defaultHomeURL), "home server URL (env: ELEVEN_HOME)") |
| + paste := fs.Bool("paste", false, "no local browser (e.g. SSH): print the link, paste the code back") |
| + fresh := fs.Bool("fresh", false, "run the passkey ceremony even if a home sign-in is saved") |
| + _ = fs.Parse(args) |
| + |
| + if !*fresh { |
| + if hs, err := st.homeSession(); err != nil { |
| + die("%v", err) |
| + } else if hs != nil { |
| + vault, err := client.FetchVault(hs) |
| + if err == nil { |
| + applyVault(st, vault) |
| + return |
| + } |
| + if err != client.ErrHomeAuth { |
| + die("%v", err) |
| + } |
| + fmt.Fprintln(os.Stderr, "Saved home sign-in has expired — running a fresh sign-in.") |
| + } |
| + } |
| + |
| + kp, err := e2ee.GenerateUserKeypair() |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + nonce, err := e2ee.RandomSecret() |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + |
| + var back string |
| + if *paste { |
| + back = signinPaste(*home, nonce, kp.PublicKey) |
| + } else { |
| + back = signinLoopback(*home, nonce, kp.PublicKey) |
| + } |
| + hs, err := client.OpenHandoff(back, nonce, kp.PrivateKey) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + hs.Base = strings.TrimRight(*home, "/") |
| + if err := st.saveHomeSession(hs); err != nil { |
| + die("%v", err) |
| + } |
| + vault, err := client.FetchVault(hs) |
| + if err != nil { |
| + die("%v", err) |
| + } |
| + applyVault(st, vault) |
| + fmt.Printf("Home sign-in saved to %s — `eleven signin` re-syncs without the browser now.\n", st.homePath()) |
| +} |
| + |
| +// restoreURL builds the handoff link: the payload rides in the #fragment, so |
| +// it never reaches the home server. |
| +func restoreURL(home, nonce, ephPub string, port int) string { |
| + p := map[string]any{"cli": 1, "nonce": nonce, "ephPub": ephPub} |
| + if port != 0 { |
| + p["port"] = port |
| + } |
| + raw, _ := json.Marshal(p) |
| + return strings.TrimRight(home, "/") + "/restore#" + e2ee.B64uEncode(raw) |
| +} |
| + |
| +func signinPaste(home, nonce, ephPub string) string { |
| + fmt.Printf("Open this link in a browser that has your Eleven passkey:\n\n %s\n\n", restoreURL(home, nonce, ephPub, 0)) |
| + fmt.Print("It will show a code once you've signed in. Paste it here:\n> ") |
| + in := bufio.NewReader(os.Stdin) |
| + line, _ := in.ReadString('\n') |
| + code := strings.TrimSpace(line) |
| + if code == "" { |
| + die("no code pasted") |
| + } |
| + return code |
| +} |
| + |
| +// cbPage is the browser's last stop in the sign-in handoff. It's served from |
| +// 127.0.0.1 so it can't share home's stylesheet — instead it inlines the home |
| +// ceremony card's exact tokens (home/web/static/home.css) so the flow's final |
| +// screen still reads as the same product: same card, same sealed green check, |
| +// same "all done" copy as the app handoff. The tile glyph is a terminal |
| +// prompt, because that's what the card is naming. |
| +const cbPage = `<!doctype html><html lang="en"><head><meta charset="utf-8"> |
| +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| +<title>Signed in</title> |
| +<style> |
| +:root{color-scheme:light dark; |
| +--bg:light-dark(#f6f7fb,#15171c);--panel:light-dark(#ffffff,#1d2026); |
| +--ink:light-dark(#1c2333,#e7e9f0);--muted:light-dark(#5a6170,#9aa1b3); |
| +--border:light-dark(#e7e9f0,rgba(255,255,255,.10));--accent:light-dark(#5666f0,#7d8bff); |
| +--ok:light-dark(#237a41,#58b97e);--shadow:light-dark(rgba(20,30,80,.10),rgba(0,0,0,.55))} |
| +body{margin:0;min-height:100svh;padding:24px 16px;display:grid;place-items:center; |
| +font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; |
| +background:var(--bg);color:var(--ink)} |
| +.card{background:var(--panel);border:1px solid var(--border);border-radius:24px; |
| +padding:28px;width:100%;max-width:420px;box-shadow:0 12px 40px var(--shadow);text-align:center} |
| +.tile{position:relative;width:72px;height:72px;margin:0 auto 10px} |
| +.glyph{width:72px;height:72px;border-radius:18px;display:grid;place-items:center; |
| +background:color-mix(in srgb,var(--accent) 14%,var(--panel));color:var(--accent)} |
| +.glyph svg{width:34px;height:34px} |
| +.badge{position:absolute;right:-8px;bottom:-8px;width:30px;height:30px;border-radius:50%; |
| +display:grid;place-items:center;background:var(--ok);color:#fff;border:3px solid var(--panel)} |
| +.badge svg{width:15px;height:15px} |
| +.name{font-weight:700;font-size:18px} |
| +h1{margin:14px 0 6px;font-size:22px} |
| +.sub{margin:0;color:var(--muted);font-size:15px} |
| +</style></head><body> |
| +<main class="card"> |
| +<div class="tile" aria-hidden="true"> |
| +<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> |
| +<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> |
| +</div> |
| +<div class="name">your terminal</div> |
| +<h1>You’re all done</h1> |
| +<p class="sub">You’re signed in — head back to your terminal. |
| +You can close this window now.</p> |
| +</main></body></html>` |
| + |
| +// loopback is the local callback listener a browser handoff redirects to: |
| +// http://127.0.0.1:<port>/cb?d=<sealed session>. First ?d= wins; everything |
| +// else (favicon probes etc.) 404s quietly. |
| +type loopback struct { |
| + port int |
| + got chan string |
| + srv *http.Server |
| +} |
| + |
| +func startLoopback() (*loopback, error) { |
| + ln, err := net.Listen("tcp4", "127.0.0.1:0") |
| + if err != nil { |
| + return nil, err |
| + } |
| + lb := &loopback{port: ln.Addr().(*net.TCPAddr).Port, got: make(chan string, 1)} |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /cb", func(w http.ResponseWriter, r *http.Request) { |
| + d := r.URL.Query().Get("d") |
| + if d == "" { |
| + http.Error(w, "missing code", http.StatusBadRequest) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| + fmt.Fprint(w, cbPage) |
| + select { |
| + case lb.got <- d: |
| + default: // a second hit after success — already handled |
| + } |
| + }) |
| + lb.srv = &http.Server{Handler: mux} |
| + go lb.srv.Serve(ln) |
| + return lb, nil |
| +} |
| + |
| +func (lb *loopback) close() { |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + lb.srv.Shutdown(ctx) |
| +} |
| + |
| +func signinLoopback(home, nonce, ephPub string) string { |
| + lb, err := startLoopback() |
| + if err != nil { |
| + die("can't open a local port for the browser to call back on (%v) — try --paste", err) |
| + } |
| + defer lb.close() |
| + |
| + url := restoreURL(home, nonce, ephPub, lb.port) |
| + 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) |
| + openBrowser(url) |
| + |
| + select { |
| + case d := <-lb.got: |
| + return d |
| + case <-time.After(5 * time.Minute): |
| + die("timed out waiting for the browser — rerun, or use --paste") |
| + } |
| + panic("unreachable") |
| +} |
| + |
| +func openBrowser(url string) { |
| + var cmd *exec.Cmd |
| + switch runtime.GOOS { |
| + case "darwin": |
| + cmd = exec.Command("open", url) |
| + case "linux": |
| + cmd = exec.Command("xdg-open", url) |
| + default: |
| + return // the URL is printed; opening by hand works everywhere |
| + } |
| + _ = cmd.Start() |
| +} |
| + |
| +// applyVault writes every restorable space from the backup into the store. |
| +func applyVault(st *store, vault *client.Vault) { |
| + var saved, archived, bare int |
| + for _, s := range vault.Servers { |
| + if s.Archived { |
| + archived++ |
| + continue |
| + } |
| + sess := s.Session() |
| + if sess == nil { |
| + bare++ // saved by URL alone — nothing to sign in with |
| + continue |
| + } |
| + if err := st.saveSpace(sess); err != nil { |
| + die("%v", err) |
| + } |
| + star := "" |
| + if sess.Starred { |
| + star = " ★" |
| + } |
| + fmt.Printf(" ✓ %s (%s)%s\n", spaceLabel(sess), spaceHost(sess), star) |
| + saved++ |
| + } |
| + switch { |
| + case saved == 0 && len(vault.Servers) == 0: |
| + fmt.Println("Signed in, but your Eleven account has no saved spaces yet — save one from the app first.") |
| + case saved == 0: |
| + fmt.Println("Signed in, but none of your saved spaces carry sign-in keys this terminal can use.") |
| + default: |
| + fmt.Printf("Signed into %d space(s).", saved) |
| + if archived > 0 { |
| + fmt.Printf(" (%d archived space(s) skipped.)", archived) |
| + } |
| + if bare > 0 { |
| + fmt.Printf(" (%d bookmark-only entr(y/ies) skipped.)", bare) |
| + } |
| + fmt.Println("\n\nTry: eleven chats") |
| + } |
| +} |
diff --git a/eleven/signin_test.go b/eleven/signin_test.go| new file mode 100644 |
| index 0000000..d1de2e6 |
| --- /dev/null |
| +++ b/eleven/signin_test.go |
| @@ -0,0 +1,140 @@ |
| +package main |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "strconv" |
| + "testing" |
| + "time" |
| + |
| + "lchat/internal/client" |
| + "lchat/internal/e2ee" |
| +) |
| + |
| +// fakeHome serves a sealed vault blob the way home's GET /api/blob does — |
| +// bearer-gated, opaque bytes. |
| +func fakeHome(t *testing.T, token string, envelope []byte) *httptest.Server { |
| + t.Helper() |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /api/blob", func(w http.ResponseWriter, r *http.Request) { |
| + if r.Header.Get("Authorization") != "Bearer "+token { |
| + http.Error(w, "unauthorized", http.StatusUnauthorized) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "application/octet-stream") |
| + w.Write(envelope) |
| + }) |
| + ts := httptest.NewServer(mux) |
| + t.Cleanup(ts.Close) |
| + return ts |
| +} |
| + |
| +func sealVault(t *testing.T, v *client.Vault) (envelope []byte, dekB64 string) { |
| + t.Helper() |
| + dek, err := e2ee.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + envelope, err = client.SealHomeBlob(v, dek, map[string]string{"cred-1": "opaque"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return envelope, e2ee.B64uEncode(dek) |
| +} |
| + |
| +// The silent re-sync path: a saved home session pulls the vault and fills the |
| +// store — no ceremony, no browser. |
| +func TestSigninResyncFillsStore(t *testing.T) { |
| + vault := &client.Vault{V: 1, Servers: []client.VaultServer{ |
| + {URL: "https://alpha.11ch.at", Name: "Alpha", Starred: true, |
| + Credentials: &client.VaultCredentials{Token: "tokA", UserPub: "pubA", UserPriv: "privA", Name: "Paul"}}, |
| + {URL: "https://gone.11ch.at", Name: "Old", Archived: true, |
| + Credentials: &client.VaultCredentials{Token: "tokB", UserPub: "pubB", UserPriv: "privB"}}, |
| + {URL: "https://bare.11ch.at", Name: "Bookmark"}, |
| + }} |
| + envelope, dekB64 := sealVault(t, vault) |
| + home := fakeHome(t, "hometok", envelope) |
| + |
| + dir := t.TempDir() |
| + st, err := openStore(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.saveHomeSession(&client.HomeSession{V: 1, Base: home.URL, Token: "hometok", CredID: "cred-1", DEK: dekB64}); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + runSignin(st, nil) // dies (exits) on failure; success writes spaces |
| + |
| + sessions, err := st.spaces() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(sessions) != 1 { |
| + t.Fatalf("want 1 restorable space, got %d", len(sessions)) |
| + } |
| + s := sessions[0] |
| + if s.Base != "https://alpha.11ch.at" || s.Token != "tokA" || s.Name != "Paul" || |
| + s.SpaceName != "Alpha" || !s.Starred || !s.LabelPublished { |
| + t.Fatalf("restored session: %+v", s) |
| + } |
| + if _, err := os.Stat(filepath.Join(dir, "spaces", "alpha.11ch.at.json")); err != nil { |
| + t.Fatalf("session file: %v", err) |
| + } |
| +} |
| + |
| +// The loopback listener: the browser's redirect lands the ?d= payload with the |
| +// terminal, the success page renders, and duplicate hits don't wedge it. |
| +func TestLoopbackCallback(t *testing.T) { |
| + lb, err := startLoopback() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer lb.close() |
| + |
| + base := "http://127.0.0.1:" + strconv.Itoa(lb.port) |
| + if resp, err := http.Get(base + "/cb"); err != nil || resp.StatusCode != http.StatusBadRequest { |
| + t.Fatalf("missing d: %v %v", err, resp.Status) |
| + } |
| + for i := 0; i < 2; i++ { // a reload of the success page must not block |
| + resp, err := http.Get(base + "/cb?d=sealed-code") |
| + if err != nil || resp.StatusCode != http.StatusOK { |
| + t.Fatalf("callback hit %d: %v", i, err) |
| + } |
| + resp.Body.Close() |
| + } |
| + select { |
| + case d := <-lb.got: |
| + if d != "sealed-code" { |
| + t.Fatalf("got %q", d) |
| + } |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("payload never delivered") |
| + } |
| +} |
| + |
| +// Legacy migration: a single-space ~/.eleven/session.json from the pre-signin |
| +// CLI moves into spaces/ on first store open. |
| +func TestLegacySessionMigration(t *testing.T) { |
| + dir := t.TempDir() |
| + legacy := &client.Session{Base: "https://old.11ch.at", Token: "tok", Name: "Paul", UserPub: "p", UserPriv: "k"} |
| + if err := legacy.Save(filepath.Join(dir, "session.json")); err != nil { |
| + t.Fatal(err) |
| + } |
| + st, err := openStore(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sessions, err := st.spaces() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(sessions) != 1 || sessions[0].Base != "https://old.11ch.at" { |
| + t.Fatalf("migrated: %+v", sessions) |
| + } |
| + if _, err := os.Stat(filepath.Join(dir, "session.json")); !os.IsNotExist(err) { |
| + t.Fatal("legacy file still present") |
| + } |
| +} |
diff --git a/eleven/store.go b/eleven/store.go| new file mode 100644 |
| index 0000000..11b5e8d |
| --- /dev/null |
| +++ b/eleven/store.go |
| @@ -0,0 +1,149 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "net/url" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + |
| + "lchat/internal/client" |
| +) |
| + |
| +// The session store: ~/.eleven holds one session file per space plus the home |
| +// sign-in. Every file is a private key in JSON clothing, hence 0600/0700. |
| +// |
| +// ~/.eleven/spaces/<host>.json one client.Session per space |
| +// ~/.eleven/home.json the home vault session (token + blob key) |
| +// |
| +// A -session flag bypasses the store entirely (single-space mode, for scripts |
| +// pinning a specific identity file) — that path never touches this type. |
| +type store struct { |
| + dir string |
| +} |
| + |
| +func openStore(dir string) (*store, error) { |
| + st := &store{dir: dir} |
| + if err := os.MkdirAll(filepath.Join(dir, "spaces"), 0o700); err != nil { |
| + return nil, err |
| + } |
| + if err := st.migrateLegacy(); err != nil { |
| + return nil, err |
| + } |
| + return st, nil |
| +} |
| + |
| +// migrateLegacy moves a pre-multi-space ~/.eleven/session.json into spaces/. |
| +func (st *store) migrateLegacy() error { |
| + legacy := filepath.Join(st.dir, "session.json") |
| + sess, err := client.LoadSession(legacy) |
| + if err != nil || sess == nil { |
| + return err |
| + } |
| + if sess.Base == "" { |
| + return fmt.Errorf("%s has no server URL — re-join with `eleven join`", legacy) |
| + } |
| + if err := st.saveSpace(sess); err != nil { |
| + return err |
| + } |
| + if err := os.Remove(legacy); err != nil { |
| + return err |
| + } |
| + fmt.Fprintf(os.Stderr, "(Moved %s into %s.)\n", legacy, filepath.Join(st.dir, "spaces")) |
| + return nil |
| +} |
| + |
| +func (st *store) spacePath(sess *client.Session) (string, error) { |
| + u, err := url.Parse(sess.Base) |
| + if err != nil || u.Host == "" { |
| + return "", fmt.Errorf("bad space URL %q", sess.Base) |
| + } |
| + return filepath.Join(st.dir, "spaces", u.Host+".json"), nil |
| +} |
| + |
| +func (st *store) saveSpace(sess *client.Session) error { |
| + path, err := st.spacePath(sess) |
| + if err != nil { |
| + return err |
| + } |
| + return sess.Save(path) |
| +} |
| + |
| +// spaces loads every saved space, hosts alphabetical (stable for display and |
| +// for "first match wins" resolution). |
| +func (st *store) spaces() ([]*client.Session, error) { |
| + glob, err := filepath.Glob(filepath.Join(st.dir, "spaces", "*.json")) |
| + if err != nil { |
| + return nil, err |
| + } |
| + sort.Strings(glob) |
| + var out []*client.Session |
| + for _, p := range glob { |
| + sess, err := client.LoadSession(p) |
| + if err != nil { |
| + fmt.Fprintf(os.Stderr, "eleven: skipping %s: %v\n", p, err) |
| + continue |
| + } |
| + if sess != nil && sess.Base != "" { |
| + out = append(out, sess) |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +func (st *store) homePath() string { return filepath.Join(st.dir, "home.json") } |
| + |
| +// homeSession loads the saved home sign-in, nil when there is none. |
| +func (st *store) homeSession() (*client.HomeSession, error) { |
| + raw, err := os.ReadFile(st.homePath()) |
| + if os.IsNotExist(err) { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + var hs client.HomeSession |
| + if err := json.Unmarshal(raw, &hs); err != nil { |
| + return nil, fmt.Errorf("bad home session file %s: %w", st.homePath(), err) |
| + } |
| + if hs.Token == "" || hs.DEK == "" { |
| + return nil, fmt.Errorf("home session file %s is missing its session", st.homePath()) |
| + } |
| + return &hs, nil |
| +} |
| + |
| +func (st *store) saveHomeSession(hs *client.HomeSession) error { |
| + raw, err := json.MarshalIndent(hs, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + return os.WriteFile(st.homePath(), append(raw, '\n'), 0o600) |
| +} |
| + |
| +// spaceLabel names a space for display: its vault name when the backup had |
| +// one, else its host. |
| +func spaceLabel(sess *client.Session) string { |
| + if sess.SpaceName != "" { |
| + return sess.SpaceName |
| + } |
| + if u, err := url.Parse(sess.Base); err == nil && u.Host != "" { |
| + return u.Host |
| + } |
| + return sess.Base |
| +} |
| + |
| +// spaceHost is the space's host, for the space/chat needle syntax. |
| +func spaceHost(sess *client.Session) string { |
| + if u, err := url.Parse(sess.Base); err == nil { |
| + return u.Host |
| + } |
| + return sess.Base |
| +} |
| + |
| +func matchesSpace(sess *client.Session, needle string) bool { |
| + needle = strings.ToLower(needle) |
| + return strings.Contains(strings.ToLower(spaceHost(sess)), needle) || |
| + strings.Contains(strings.ToLower(sess.SpaceName), needle) |
| +} |
diff --git a/eleven_client_test.go b/eleven_client_test.go| index 454dea5..abfb723 100644 |
| --- a/eleven_client_test.go |
| +++ b/eleven_client_test.go |
| @@ -11,6 +11,7 @@ import ( |
| "context" |
| "encoding/json" |
| "net/http" |
| + "net/http/httptest" |
| "testing" |
| "time" |
| @@ -297,3 +298,120 @@ func TestElevenClientAddDeviceJoin(t *testing.T) { |
| t.Errorf("new device token maps to %q, want %q", got, uid) |
| } |
| } |
| + |
| +// The signin round trip's last leg: credentials restored from a home-vault |
| +// blob must be a working member session on the instance they point at. This |
| +// is the real contract behind `eleven signin` — the vault entry IS the |
| +// session. |
| +func TestVaultRestoredSessionTalksToInstance(t *testing.T) { |
| + ts, _ := newTestServer(t) |
| + phone := joinForTest(t, ts.URL, "Paul") |
| + friend := joinForTest(t, ts.URL, "Friend") |
| + |
| + // A DM between them, so the restored terminal has something to read. |
| + phoneUID, err := phone.Me() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + friendUID, err := friend.Me() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tk, err := e2ee.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tkB64 := e2ee.B64uEncode(tk) |
| + wrapP, err := e2ee.WrapThreadKey(tkB64, phone.Session.UserPub) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + wrapF, err := e2ee.WrapThreadKey(tkB64, friend.Session.UserPub) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + body, _ := json.Marshal(map[string]any{ |
| + "token": phone.Session.Token, "kind": "dm", |
| + "members": []map[string]string{ |
| + {"user_id": phoneUID, "wrapped": wrapP}, |
| + {"user_id": friendUID, "wrapped": wrapF}, |
| + }, |
| + }) |
| + resp, err := http.Post(ts.URL+"/api/threads", "application/json", bytes.NewReader(body)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var created struct { |
| + ThreadID string `json:"thread_id"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil || created.ThreadID == "" { |
| + t.Fatalf("create dm: %v (%q)", err, created.ThreadID) |
| + } |
| + resp.Body.Close() |
| + if err := friend.Send(created.ThreadID, "seen from the phone"); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Seal the phone's credentials into a vault blob (what the member's other |
| + // devices keep in home), then restore it the way FetchVault does. |
| + vault := &lclient.Vault{V: 1, Servers: []lclient.VaultServer{{ |
| + URL: ts.URL, Name: "Test Space", |
| + Credentials: &lclient.VaultCredentials{ |
| + Token: phone.Session.Token, UserPub: phone.Session.UserPub, |
| + UserPriv: phone.Session.UserPriv, Name: phone.Session.Name, |
| + }, |
| + }}} |
| + dek, err := e2ee.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + envelope, err := lclient.SealHomeBlob(vault, dek, map[string]string{"cred": "opaque"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + homeMux := http.NewServeMux() |
| + homeMux.HandleFunc("GET /api/blob", func(w http.ResponseWriter, r *http.Request) { |
| + if r.Header.Get("Authorization") != "Bearer hometok" { |
| + http.Error(w, "unauthorized", http.StatusUnauthorized) |
| + return |
| + } |
| + w.Write(envelope) |
| + }) |
| + home := httptest.NewServer(homeMux) |
| + defer home.Close() |
| + |
| + got, err := lclient.FetchVault(&lclient.HomeSession{V: 1, Base: home.URL, Token: "hometok", DEK: e2ee.B64uEncode(dek)}) |
| + if err != nil { |
| + t.Fatalf("FetchVault: %v", err) |
| + } |
| + if len(got.Servers) != 1 { |
| + t.Fatalf("vault: %+v", got) |
| + } |
| + sess := got.Servers[0].Session() |
| + if sess == nil || sess.SpaceName != "Test Space" { |
| + t.Fatalf("restored session: %+v", sess) |
| + } |
| + terminal := &lclient.Client{Base: sess.Base, Session: sess} |
| + hist, err := terminal.History(created.ThreadID, 5) |
| + if err != nil { |
| + t.Fatalf("restored History: %v", err) |
| + } |
| + if len(hist) != 1 || hist[0].Text != "seen from the phone" { |
| + t.Fatalf("restored terminal reads: %+v", hist) |
| + } |
| + if err := terminal.Send(created.ThreadID, "and the terminal answers"); err != nil { |
| + t.Fatalf("restored Send: %v", err) |
| + } |
| + fhist, err := friend.History(created.ThreadID, 5) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(fhist) != 2 || fhist[1].Text != "and the terminal answers" || fhist[1].Name != "Paul" { |
| + t.Fatalf("friend sees: %+v", fhist) |
| + } |
| + |
| + // A revoked home token surfaces as ErrHomeAuth (the rerun-ceremony signal). |
| + if _, err := lclient.FetchVault(&lclient.HomeSession{V: 1, Base: home.URL, Token: "wrong", DEK: e2ee.B64uEncode(dek)}); err != lclient.ErrHomeAuth { |
| + t.Fatalf("bad token: %v", err) |
| + } |
| +} |
diff --git a/home/test/gen-home-vector.mjs b/home/test/gen-home-vector.mjs| index c6a741f..66113ec 100644 |
| --- a/home/test/gen-home-vector.mjs |
| +++ b/home/test/gen-home-vector.mjs |
| @@ -6,7 +6,9 @@ |
| // |
| // node home/test/gen-home-vector.mjs |
| // |
| -// Paste the printed envelope into HomeCryptoVectors.swift. The inputs (prf, |
| +// Paste the printed envelope into HomeCryptoVectors.swift, and refresh the Go |
| +// copy: node home/test/gen-home-vector.mjs > internal/client/testdata/home-vector.txt |
| +// (internal/client/home_test.go opens it with the same fixed DEK). The inputs (prf, |
| // dekRaw, credId, wrapped) are constants in HomeCryptoTests.swift — keep them |
| // in sync if you ever change them here. The envelope bytes differ per run |
| // (fresh AES-GCM iv); that's fine, only the opened contents are asserted. |
diff --git a/internal/client/client.go b/internal/client/client.go| index 80a4a13..d11e025 100644 |
| --- a/internal/client/client.go |
| +++ b/internal/client/client.go |
| @@ -48,6 +48,12 @@ type Client struct { |
| sentMu sync.Mutex // guards sentIDs |
| sentIDs map[int64]bool |
| + // sendMu is held for a Send's whole store round trip. The server |
| + // broadcasts as soon as it stores, so our own echo can race the POST |
| + // response that carries the stored id — delivery of own-name messages |
| + // takes this lock first, waiting out the in-flight Send so the echo is |
| + // never misread as another device's message. |
| + sendMu sync.Mutex |
| } |
| type keyEntry struct { |
| @@ -239,6 +245,13 @@ func (c *Client) deliver(threadID string, id int64, blob string) { |
| return // a typed payload (mini-app event, connect traffic), not chat text |
| } |
| if c.OnMessage != nil { |
| + if p.Name == c.Session.Name { |
| + // Our own message: let any in-flight Send finish recording its |
| + // stored id before deciding whether this is its echo. |
| + c.sendMu.Lock() |
| + //lint:ignore SA2001 the lock is a barrier, not a critical section |
| + c.sendMu.Unlock() |
| + } |
| c.sentMu.Lock() |
| echo := c.sentIDs[id] |
| c.sentMu.Unlock() |
| @@ -486,6 +499,9 @@ func (c *Client) Send(threadID, text string) error { |
| if err != nil { |
| return err |
| } |
| + // Held until the stored id is recorded — see sendMu. |
| + c.sendMu.Lock() |
| + defer c.sendMu.Unlock() |
| blob, err := e2ee.EncryptJSON(key, map[string]any{ |
| "name": c.Session.Name, "text": text, "ts": time.Now().UnixMilli(), |
| }) |
diff --git a/internal/client/home.go b/internal/client/home.go| new file mode 100644 |
| index 0000000..db4571a |
| --- /dev/null |
| +++ b/internal/client/home.go |
| @@ -0,0 +1,220 @@ |
| +package client |
| + |
| +import ( |
| + "encoding/binary" |
| + "encoding/json" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "strings" |
| + "time" |
| + |
| + "lchat/internal/e2ee" |
| +) |
| + |
| +// The home server (home.elevenmessenger.com, home/) is the passkey-gated |
| +// backup store: a member's saved spaces — each with its credentials — live in |
| +// one client-encrypted blob there. This file is the Go half of that contract, |
| +// byte-compatible with home/web/static/crypto.js (and the Swift HomeCrypto): |
| +// open the EHB1 envelope with a DEK, and unwrap the {token, dek} session the |
| +// restore page hands a CLI after the passkey ceremony. The CLI never sees a |
| +// PRF or KEK — the browser did the ceremony; it arrives holding the DEK. |
| + |
| +// HomeSession is what the restore page hands over after a passkey unlock |
| +// (restore.js's app/cli handoff): ongoing bearer auth to the vault plus the |
| +// key that opens it. Holding this file IS holding every saved space's |
| +// credentials — same trust profile as the native apps' Keychain copy. |
| +type HomeSession struct { |
| + V int `json:"v"` |
| + Base string `json:"base,omitempty"` // home server URL (CLI bookkeeping) |
| + Token string `json:"token"` |
| + CredID string `json:"credId"` |
| + DEK string `json:"dek"` // raw AES-256 key, base64url |
| +} |
| + |
| +// Vault is the decrypted backup: the member's saved spaces in master order |
| +// (ios/LChatCore HomeVault.Contents / home/web/static/serverlist.js). |
| +type Vault struct { |
| + V int `json:"v"` |
| + Servers []VaultServer `json:"servers"` |
| +} |
| + |
| +// VaultServer is one saved space. Credentials is nil for a bare saved URL |
| +// (home.js can save a space by URL alone); Archived entries are hidden from |
| +// the native rail and home's list, so a restore skips them too. |
| +type VaultServer struct { |
| + URL string `json:"url"` |
| + Name string `json:"name,omitempty"` |
| + Starred bool `json:"starred,omitempty"` |
| + Archived bool `json:"archived,omitempty"` |
| + Credentials *VaultCredentials `json:"credentials,omitempty"` |
| +} |
| + |
| +// VaultCredentials is a space session in vault clothing — the same fields as |
| +// Session, keyed the same way (home's blob JSON and the session file share |
| +// the userPub/userPriv naming on purpose). |
| +type VaultCredentials struct { |
| + Token string `json:"token"` |
| + UserPub string `json:"userPub"` |
| + UserPriv string `json:"userPriv"` |
| + Name string `json:"name,omitempty"` |
| +} |
| + |
| +// Session converts a vault entry into a live space session. |
| +func (s VaultServer) Session() *Session { |
| + if s.Credentials == nil { |
| + return nil |
| + } |
| + name := s.Credentials.Name |
| + if name == "" { |
| + name = s.Name |
| + } |
| + return &Session{ |
| + Base: strings.TrimRight(s.URL, "/"), |
| + Token: s.Credentials.Token, |
| + Name: name, |
| + UserPub: s.Credentials.UserPub, |
| + UserPriv: s.Credentials.UserPriv, |
| + // The roster label already exists server-side for a member restored |
| + // from backup — this is an existing identity, not a fresh join. |
| + LabelPublished: true, |
| + SpaceName: s.Name, |
| + Starred: s.Starred, |
| + } |
| +} |
| + |
| +// OpenHandoff decodes what the restore page returned (the base64url "back" |
| +// payload — a loopback ?d= value or a pasted code), verifies the nonce is the |
| +// one this process minted, and unwraps the home session with the ephemeral |
| +// private key that never left this process. |
| +func OpenHandoff(backB64, nonce, ephPrivB64 string) (*HomeSession, error) { |
| + raw, err := e2ee.B64uDecode(strings.TrimSpace(backB64)) |
| + if err != nil { |
| + return nil, fmt.Errorf("client: that doesn't look like a sign-in code") |
| + } |
| + var back struct { |
| + Nonce string `json:"nonce"` |
| + Wrapped string `json:"wrapped"` |
| + } |
| + if json.Unmarshal(raw, &back) != nil || back.Wrapped == "" { |
| + return nil, fmt.Errorf("client: that doesn't look like a sign-in code") |
| + } |
| + if back.Nonce != nonce { |
| + return nil, fmt.Errorf("client: sign-in code is from a different signin attempt — run signin again and use the fresh code") |
| + } |
| + var hs HomeSession |
| + if err := e2ee.OpenSealed(back.Wrapped, ephPrivB64, &hs); err != nil { |
| + return nil, fmt.Errorf("client: couldn't unlock the sign-in code: %w", err) |
| + } |
| + if hs.Token == "" || hs.DEK == "" { |
| + return nil, fmt.Errorf("client: sign-in code is missing its session") |
| + } |
| + return &hs, nil |
| +} |
| + |
| +// FetchVault pulls the encrypted backup from home and opens it with the |
| +// session's DEK. A member with no backup yet returns an empty vault, not an |
| +// error. An expired/revoked token returns ErrHomeAuth so callers can rerun |
| +// the ceremony. |
| +func FetchVault(hs *HomeSession) (*Vault, error) { |
| + req, err := http.NewRequest("GET", strings.TrimRight(hs.Base, "/")+"/api/blob", nil) |
| + if err != nil { |
| + return nil, err |
| + } |
| + req.Header.Set("Authorization", "Bearer "+hs.Token) |
| + hc := &http.Client{Timeout: 30 * time.Second} |
| + resp, err := hc.Do(req) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer resp.Body.Close() |
| + switch resp.StatusCode { |
| + case http.StatusOK: |
| + case http.StatusNotFound: |
| + return &Vault{V: 1}, nil |
| + case http.StatusUnauthorized, http.StatusForbidden: |
| + return nil, ErrHomeAuth |
| + default: |
| + return nil, fmt.Errorf("client: home server: HTTP %d", resp.StatusCode) |
| + } |
| + envelope, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + dek, err := e2ee.B64uDecode(hs.DEK) |
| + if err != nil { |
| + return nil, fmt.Errorf("client: bad home session key: %w", err) |
| + } |
| + return OpenHomeBlob(envelope, dek) |
| +} |
| + |
| +// ErrHomeAuth: the stored home session no longer authenticates (token revoked |
| +// or expired) — the caller should run the passkey ceremony again. |
| +var ErrHomeAuth = fmt.Errorf("client: home sign-in has expired — run `eleven signin` again") |
| + |
| +// OpenHomeBlob opens an EHB1 envelope with an already-unwrapped DEK, |
| +// mirroring home/web/static/crypto.js unpackBlob + openBlob: |
| +// |
| +// "EHB1"(4) || u32 BE headerLen || header JSON || iv(12) || ciphertext |
| +// |
| +// The header (per-passkey wrapped DEKs) is skipped — the DEK is in hand. The |
| +// plaintext is padded (4-byte BE length prefix, zeros to 16 KiB) so ciphertext |
| +// length leaks nothing; unpad recovers the JSON. |
| +func OpenHomeBlob(envelope, dek []byte) (*Vault, error) { |
| + if len(envelope) < 8 || string(envelope[:4]) != "EHB1" { |
| + return nil, fmt.Errorf("client: not a home backup blob") |
| + } |
| + headerLen := binary.BigEndian.Uint32(envelope[4:8]) |
| + if int(headerLen) > len(envelope)-8 { |
| + return nil, fmt.Errorf("client: corrupt home backup blob") |
| + } |
| + body := envelope[8+headerLen:] |
| + padded, err := e2ee.DecryptBytes(dek, body) |
| + if err != nil { |
| + return nil, fmt.Errorf("client: couldn't open the backup (wrong key?): %w", err) |
| + } |
| + if len(padded) < 4 { |
| + return nil, fmt.Errorf("client: corrupt home backup blob") |
| + } |
| + ptLen := binary.BigEndian.Uint32(padded[:4]) |
| + if int(ptLen) > len(padded)-4 { |
| + return nil, fmt.Errorf("client: corrupt home backup blob") |
| + } |
| + var v Vault |
| + if err := json.Unmarshal(padded[4:4+ptLen], &v); err != nil { |
| + return nil, fmt.Errorf("client: corrupt home backup blob: %w", err) |
| + } |
| + return &v, nil |
| +} |
| + |
| +// SealHomeBlob is OpenHomeBlob's inverse (crypto.js sealBlob) — the envelope a |
| +// browser would PUT. Used by tests to prove byte-compatibility both ways; a |
| +// future write-back (starring a space from the CLI) would go through here. |
| +// keys is the per-passkey wrapped-DEK header, passed through untouched. |
| +func SealHomeBlob(v *Vault, dek []byte, keys map[string]string) ([]byte, error) { |
| + pt, err := json.Marshal(v) |
| + if err != nil { |
| + return nil, err |
| + } |
| + const pad = 16384 // crypto.js BLOB_PAD |
| + if len(pt) > pad-4 { |
| + return nil, fmt.Errorf("client: backup contents too large") |
| + } |
| + padded := make([]byte, pad) |
| + binary.BigEndian.PutUint32(padded, uint32(len(pt))) |
| + copy(padded[4:], pt) |
| + body, err := e2ee.EncryptBytes(dek, padded) |
| + if err != nil { |
| + return nil, err |
| + } |
| + header, err := json.Marshal(map[string]any{"keys": keys}) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := make([]byte, 0, 8+len(header)+len(body)) |
| + out = append(out, "EHB1"...) |
| + out = binary.BigEndian.AppendUint32(out, uint32(len(header))) |
| + out = append(out, header...) |
| + out = append(out, body...) |
| + return out, nil |
| +} |
diff --git a/internal/client/home_test.go b/internal/client/home_test.go| new file mode 100644 |
| index 0000000..20d8b4a |
| --- /dev/null |
| +++ b/internal/client/home_test.go |
| @@ -0,0 +1,114 @@ |
| +package client |
| + |
| +import ( |
| + "os" |
| + "strings" |
| + "testing" |
| + |
| + "lchat/internal/e2ee" |
| +) |
| + |
| +// The cross-implementation vector: a JS-sealed envelope (fixed DEK) from |
| +// home/test/gen-home-vector.mjs — the same generator the Swift HomeCrypto |
| +// tests pin against. Opening it byte-for-byte proves the AES-GCM layout, |
| +// padding, and EHB1 framing match the browser's. |
| +func TestOpenHomeBlobJSVector(t *testing.T) { |
| + raw, err := os.ReadFile("testdata/home-vector.txt") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + envelope, err := e2ee.B64uDecode(strings.TrimSpace(string(raw))) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + dek, err := e2ee.B64uDecode("AAcOFRwjKjE4P0ZNVFtiaXB3foWMk5qhqK-2vcTL0tk") // gen-home-vector.mjs's fixed DEK |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + v, err := OpenHomeBlob(envelope, dek) |
| + if err != nil { |
| + t.Fatalf("OpenHomeBlob: %v", err) |
| + } |
| + if v.V != 1 || len(v.Servers) != 1 || v.Servers[0].URL != "https://test.example.com" || v.Servers[0].Name != "Test" { |
| + t.Fatalf("vector contents: %+v", v) |
| + } |
| +} |
| + |
| +func TestSealOpenHomeBlobRoundTrip(t *testing.T) { |
| + dek, err := e2ee.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + in := &Vault{V: 1, Servers: []VaultServer{ |
| + {URL: "https://a.11ch.at", Name: "Alpha", Starred: true, |
| + Credentials: &VaultCredentials{Token: "tokA", UserPub: "pubA", UserPriv: "privA", Name: "Paul"}}, |
| + {URL: "https://b.11ch.at", Name: "Bare save"}, // credential-less entry |
| + {URL: "https://c.11ch.at", Archived: true, |
| + Credentials: &VaultCredentials{Token: "tokC", UserPub: "pubC", UserPriv: "privC"}}, |
| + }} |
| + envelope, err := SealHomeBlob(in, dek, map[string]string{"cred-1": "wrapped-opaque"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + out, err := OpenHomeBlob(envelope, dek) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(out.Servers) != 3 || out.Servers[0].Credentials.Token != "tokA" || |
| + !out.Servers[0].Starred || !out.Servers[2].Archived || out.Servers[1].Credentials != nil { |
| + t.Fatalf("round trip: %+v", out) |
| + } |
| + |
| + // Session projection: credentials become a live space session; the vault |
| + // name backfills when credentials carry none; bare entries yield nil. |
| + s := out.Servers[0].Session() |
| + if s == nil || s.Base != "https://a.11ch.at" || s.Token != "tokA" || s.Name != "Paul" || !s.LabelPublished { |
| + t.Fatalf("session projection: %+v", s) |
| + } |
| + if got := out.Servers[2].Session(); got == nil || got.Name != "" { |
| + t.Fatalf("nameless credentials: %+v", got) |
| + } |
| + if out.Servers[1].Session() != nil { |
| + t.Fatal("bare entry must project no session") |
| + } |
| + |
| + // Wrong key must not open. |
| + other, _ := e2ee.GenerateKey() |
| + if _, err := OpenHomeBlob(envelope, other); err == nil { |
| + t.Fatal("opened with the wrong DEK") |
| + } |
| +} |
| + |
| +// OpenHandoff is the CLI's end of restore.js's cli branch: the page seals the |
| +// home session to the ephemeral pubkey; only the matching private key + |
| +// matching nonce open it. |
| +func TestOpenHandoff(t *testing.T) { |
| + kp, err := e2ee.GenerateUserKeypair() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sealed, err := e2ee.SealForPub(kp.PublicKey, HomeSession{V: 1, Token: "hometok", CredID: "cred-1", DEK: "dEk"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + back := e2ee.B64uEncode([]byte(`{"nonce":"n-123","wrapped":"` + sealed + `"}`)) |
| + |
| + hs, err := OpenHandoff(back, "n-123", kp.PrivateKey) |
| + if err != nil { |
| + t.Fatalf("OpenHandoff: %v", err) |
| + } |
| + if hs.Token != "hometok" || hs.CredID != "cred-1" || hs.DEK != "dEk" { |
| + t.Fatalf("session: %+v", hs) |
| + } |
| + |
| + if _, err := OpenHandoff(back, "other-nonce", kp.PrivateKey); err == nil { |
| + t.Fatal("nonce mismatch accepted") |
| + } |
| + otherKP, _ := e2ee.GenerateUserKeypair() |
| + if _, err := OpenHandoff(back, "n-123", otherKP.PrivateKey); err == nil { |
| + t.Fatal("wrong ephemeral key accepted") |
| + } |
| + if _, err := OpenHandoff("not base64 json", "n", kp.PrivateKey); err == nil { |
| + t.Fatal("garbage accepted") |
| + } |
| +} |
diff --git a/internal/client/session.go b/internal/client/session.go| index b5734a3..e024fad 100644 |
| --- a/internal/client/session.go |
| +++ b/internal/client/session.go |
| @@ -30,6 +30,11 @@ type Session struct { |
| // which a fresh member is granted asynchronously, so the CLI retries on |
| // later runs until it lands. |
| LabelPublished bool `json:"labelPublished,omitempty"` |
| + // SpaceName/Starred mirror the member's home-vault entry for this space |
| + // (its saved display name and star) — CLI display bookkeeping, refreshed |
| + // on each signin sync. |
| + SpaceName string `json:"spaceName,omitempty"` |
| + Starred bool `json:"starred,omitempty"` |
| } |
| // LoadSession reads a session file. A missing file returns (nil, nil) — "not |
diff --git a/internal/client/testdata/home-vector.txt b/internal/client/testdata/home-vector.txt| new file mode 100644 |
| index 0000000..0c7bec0 |
| --- /dev/null |
| +++ b/internal/client/testdata/home-vector.txt |
| @@ -0,0 +1 @@ |
| +RUhCMQAAAGh7ImtleXMiOnsiY3JlZC1hYmMiOiJMZkR5OU9qVzF2V05XZlZjcFNoY3hBTkc3bWNXSXBzckR0UzJ5cmxoaWM3S3dkTjFhYmlRSUY4ek9nN0VkSEY1cDlvYUkwZjA5TEdnX1d2bCJ9fa_RC6-6G6uSneUY0xWqM18lj443XJuvDJeWLUTxkf_JZTvHoLiRRxKhTyNVgiGj5gCk6PoYJvAteVBzody6O1lp2LqiGhF3MwoPP5a4leUDiQs3opOID9k9tV1uVI0QbvoE9e5dH0YbpCQgb9zS_CyVzA0Fh3yKKPl4bzzYoDsqn42xauAlkNS_tWp0xsflKy_4Ht5ahk5O9-v9aG32UNykDrr3rD-aeaa0TahgoiAwpALMtqvpIfIUOtHIx9Y1VVKL9PB7woIm9Ym0N7kkToci0AGXMfBk9zsB8ir4NvpFJgckwZcEwzfqHKFhbxLFa2LWLJvI0RiHqb2Fwk_YYFkcaSrbx3r7elMCQb71BosGsuZ-CkhwbZ78XJPH2YDveAJcRorueThICgpjGZXSJh3v1XPDSRU93cvioM3OaugzWYTz_5bVgw4nWuBnI-iNPRhNPI0waXxObWcHON0LfUUqX0GLMtRvdr5-HZUS7E73xpMC11SkopbF8k7McfI7q4J-yOfdd7_cpEWxGCmpLze_hVZeHhC2ZI52DRHEJvQkJXo4QYXmmWKgV3_xdVZE54wdtX_RAZJWZGqUCLB1-OJIm8x5n9wd_W1mXpovAXoWKYQB1qDkfTv2ytLWpv0z6PzAtdlU74feeChqigRuDqTTB79xIQLnn1tNwpUONF7t4WheRZM98rWz-n6RExfvJDltlRFwQCIoegMjfgeW0AxtHY_itaOP1todD_zYXsTGaOktrlz-0tb73d3NivA3zNEObvgt7zoVbSB_Pc2PBdO3jJOF4W6_32naVwRfzxNBU6GiYix6n_BwOja9hp6PvRWjUadPpBsyLB0qAqApPmFxKJ18QdvNLakc1PivLCoUGG6MbBqa44wUowbRWkpLMW5zFEE8_Wy0dEFYLfR7DBqr2c9QckkzkV7IGTxpVBo2CUyN-UO7pll6OmrrlrSSUMI0hT61lqjd_Z6-0tw0yPkX8xKTz_ktXCiie98ziBj7asIk-x3HPhpauKU400iSglAcfAy4keCfE31EkV0nqqm3PzpAQS0XuSp2acHpxC5Jd33JOVUh-GM-2Gxo_b4ithWjqAxA_ONu-iGFI1Pz_fQ8xjtOMsTuMyuvQ0EkHyewyfGCkuMu_Pt1E0dtwv1_yFYT4KmoDR-s7JYdgmoT-KYzqYVs65PCOoFIELkrfBCcKX1JdxgsaXTxBv4atW38BVzrm1XQowIVlLvEKPVbgSgUCNA5yZuMFtEcN8H1zClxgYM9QFwVT4CAa-txQGG52Qp6D4Lmje3MlOgNAhdyZWxkewCaNo-LLxypDp35Jmx-C9xka2rDUkxhix5Y9UXsHfwlLwO14PDstUMCDnGVhNJ5oHvCuJop7TNAl_tRr_svH1KkKonjyq-wmzcR1S91DwvCw-8hXn1gm8PCzGMj_G7yZNlEbJkO1VYfrVa_2DBl9EX7uffW4uK1MIjw2fOK7Py3sznxkrSzy2VImGpEz4fdkLrNvh2Ksh0_f9fIPT9wESxFTMYqGxLrj_33wFrTso5XxaWrDUGqMw6fBr5NOWEFwkkG19g-sxkkI_CmhXDvKsxY7hy1epM7KZIcQMgZyN4xLxxIGtHeXdbu-bCOd0nd6tCewsRh0_Q9xu3osobsXk5Qou95avIp42C082tFieV4yYjIL41rjK15_ePEhj4FJVzRV3UFQ29AlsP6IRi3X8Oa88XG2HTNdko9FAoCkCsoS2aLaQvKkbUOGpfwmIeYjfxTQR_ABLLBsrLg60x2Sq3YIYs-unas8ldISMBwi_Kmwv6Duc0ATcRjAL9V9a99Qj1om2LsjSlUisCmthprlbBIewnL9AxFNFkL7rL4s-kvUurcmxFWUIDmBcIpsjyH9Gz5aPIRSlMffBME9MNqaahnmrL8xkbMGrch7kdgN-JT3KUsCsM6Cttiqo3XxMcm9RCgcmF-4CM1gXMKCNWqYXenhOJyAWmm3Q73ipoAHuAQtUH5Nb6QZ4D2H-V341JKu7gr7yGqWwxBOsv6lBJ0VXDlddL1ce350qb2JOZMUf7loJGN6UChMS6vGFcwvnFGoi6n8tlyUrIUhZ1j5iDTPzGEeatPyyLMAd2hYc1kanbCIyKFnTlDgtio6XplqI7G8zZQKuB_0BZWGiX7TlPyd0qbJ9-7DsAXW0VF2cngOEVjhmcXTYeQbNtNW0vNEq6OGtCCAOHz4sPiq5B0VqgApYD9yz1SyAxICVuNUe5uGa3iLRlslOnw8-0eqBMikHx6K7fEfCjI3QZw8eyBFTyM3amVP2lm30tmV5XkZdgG9UETimuTtKcIQAqB9r9BtiWG1Lost_55RE5D_PP4sNqKMeDL8UEV35qcEmPesJ8gC0ND0yDuddhuCLE3p7WhbDyYqkoG26kv9SaA8dHjXRcjmxNDwOJJXRRrHF6_jVrap66FiBLNT2_9UMIT5g8GskLJxTCSBnRkLWg4T3v2IxWE6fEuqyrCgWiqppPSxbaMZVruaqmHqK5KS5bYMlHk8aWSfTGAJN_Pfk7VmAClY_2wjk6xowhY2CMND3CKr3kr_8mv-owhVZt8T-RjeODbY7_mff59slGfqCa6kuGxfGn6qpZf3ohIjAiSZ6UAxwe6zDkMbBIZ6rNeHOMK71wBawPUujMExwyWwf36GaFT9XS19BVMxXaAn80RCHRlKeBl7O2uzLSdP0i75NmHmL4K5DWBREfaFzQHTONXnrnmPeYgnFoVKfid7B7-n3HK6DVKUj7nqbWk_KoH3fqyLBxdeFdmiBJLxZZ1J7qS49oyJ1fVxw7vrQEfIlNnNaKQ_ViMlVGxZ5oxnVfWegjyBLqAdcHxg0RgSJHZyDdWp_G3pTeXy_psendI6fTaZoCtbzbef6T_HjEUf9REujmxAsWGmz1UibPuuWd0axisW_pumD4qu6vYgXsq0AqsG9xVs9ohOPPlMjxox5FTdvXSSoX4zOp23_Ou-vWgbxeLIU7dSGHaP2zQAHptevSRgUItkd3ekrn1z_-n6SJJe20c9B7GPPQeb7mDc6d8swrkk7UDe3vdVEVosSpX4enn_vFOsdWqcYzNo0Q35rQx2W22dLxKUqNEj29MMDCmQ20rFQIqU7PQfZh0m7bpVZ8A1cN1XTscrsLzbcOfTUVV2IwcVFrgSbFAG1o9Yy7hoayQI0__yGVHRHrzN9a5CJmcyspT8A7qgsbVJM5DPW9oZLscB0j0caVzgiYjI183IM0lt0WTU6BHGGdzfKYihtQcMPI6r--EF4LKrzRgwG60Fi-KO8mste7W5GMP4JXxdq6Mk3F32AKFx4BxgBeIdsIKm1aCzyQ30tE5kaS_l1Bn-MZOb4JP3A5rWs7hQEfIX8KruurPZsaGBr3EdS_mB8Ve0P42LPVtw_U44zsRbUgNawhdDZkU8n9M_qACM49ZL41Q8wWUt2e9n4k5ikk9yfcZ7Aea2tXDZJearS9f7VBd0sFZtJxp21GVtjRa1CxB5_G_O0n80AmkDRpV_AT8CrZnP8THIa7bWR1eHfCK3fjgAfwGHEj6X567y7nTXKFLKS33EXQnTCj3vB_09KyM8IGNcLgwMO2xW5JUDKtWtrccNGosd_siz1XWBeZxWdBioJGk_n9YPjBylqEy0RQLv2qEuCukmjHEZ7zg-tKrP_i8Hp3A5oT2MGCi9Wd-zjAR6iC00mP3lnZFa70ZiCejR92KXk-qMLuxTnlUS4AoTsIbNfjDucs0GB-0-JmOcA4p1tzpoJb9T0M8x64zJDuKX_IJ7AFfrDENB0BOXr569xWFNKAGH8b4778sM-2A1apSWg4T2uEuef7rjgWwhlMHJWHfiiB1G2gJgycix2VORM6htkAlP9rkv-QoJHefBEEYoBIDpqJ832qivSW0MQahqyFwsgh-Mo8FA0jNsfVAuPG4y9zf9E003dx37NqdbWzsxynPbB_T33YHEiq4xnar3MBCzLuul8xwyL5PAH0wdpJYkCCCMbuur6jVYZFqVgB0AGU1rCx0BDKgHdq2xUx8YPy6RqgD3By66w21Jq2h-uOta28wCHIjf_G7wmQ4GY218kMbovqkVQo-u_XaHf8g3ZHhYjWGPZITfYAWVGJr4TNavzFFY96RZfwef-fgSY6tyn9au6sj0ZkYpRY7fEHU04bxA30_-czGg-w0D2wiXMW_N-aZGlnxR_CeUm93qHAh3b0ePXb-51J15eleEkn6Ztqa35Ipo9Wqg-yd5KbWgZGdO1IH04nCR05sIo7vLqeBgne0gaMaKrM8I7TTjFtjYDkhf6UPFsNKLJt9FLc3nquuEXGRVdBv_pcoOtoZBOXBZbHqUkRUQVGRMB0hMOM95QyZ3Yw6lWJcF1Z-4MzZPB5Egsp7L8-zlEyylGplpqE-_15Z5yBz-jaqsjIumSeK0UIDvXg5gMaAsj7II2qDfXsqa4LyUGu2YZAA8__FCj1e_5MI2MOx1cWiqiW6qrvlHwe-zejqM_qrC-6z4EGorkkmIgrxsoKFKvgsSX7Mg23mnxsX2WT0kyljto1mDxlffJ4rDnXualIZQhcp54P-tnkJ1HgIrvq_Pqps5IuiZAm-6Z_n42BoBRlZCRhAIGy9fG4jpt2NumG8JixGo_4Q5kjvMvFFZUlpmtMBb9lbuQTn286eC9HfmV6JrjaaxPgpO5LH258fVzDYfX6p_g-RsMiWufq6SqZ5sl5rzNdKuVvr6RwQvHwZu8L2K9rcsYzVYm75InOIy2Kwjq6Mm7Qoate4mluB-KhGIsLZTN6dMRQLk2x-rYEvOaLlnHxzlAa7Z4PojSNqwhwo7y4r9A5Bwk6TUVn220bfij5KoY_xTLRdpSBb1b__tYKUMQxDL7e3uAhBrj24BtGyKDfrY2sB9njWVJZryC-5t5m1cBEFjYrc3cZi-swFNNu9jmuid0aecg38fbkn2LGQWgUCxUw_o_dyvd53YcdyrcnxgDwGEE-JAuKjdUmc1gbzqxQcZyToha3zzKPL6fKoMJw5OMaJS3UrEjWR8BkRZntFjZbGdb-Tp8KqWTbnmemTCtUKMgGU5_03Dko18lBEzBV9-mlRPWFJs_KSDYKhDdIiCZRzyNyMDLOZoZ6yjg55gLDqjtsxIwiBEVMdtk1NC_pniaHvAP4G8Jt3OE_2lpm_Lm924sMEQ5_pI3M7hMNKxR9m712HHVVclf-DFU03z5iBqmHY6DogKnkrqTxmW4qgTgzD0241q89UQSE-bGwlICEAEAXvpCbEIHTDtvvXVkTv1mlMZg8YRYuwB-xgQm8k0FNFWf8iziIjQPtn6bcudkT46xaQMk-loEWA2sUWEXjFqp7Yovp5t1vUryc_EYk7Qrb__N9jsMxP7DYR3UJucVb7PYJUUhseZYPEjdDFLLh7oQgO4kcEV5ImvHbofhVD5AB5BqzT4owtd0PgVZHcJ1Szjihe-EZQG6t7bhtb79QTuPZvGSasXSrgEv8htj6tA1XBfcAVYWY9lu6JsO8cUKrhnzq_nHvBCfzl7pft_j5y0f7NqPXNHzhWgygKQ6gDPKW44pCmrALhoClwoO0CNV1Ny19E6jB4eHk5HB5i-xD5rS11hVtz7SdvoONtBiT-lQ4r1fJAGAPDDBS_-wSg2EOXLrtocrK_c1G_WygTMdYg-_TZhbNoxETFgGE84M2v8prpDHfFRnHG_v0Edm71Unb6WrxGUk0AMhchXXBfnp6b4w2ZyR9ZYS3x2SacJOCysG0HSRGJm5LmGA9Scqn6VVgSJgcxjG99nXBhStHU3wMJJKcXUMYhbxi9kYGuXRhRTlyrPZ7U3Ak1vswUwnc9EsF3QYA-5FKZjbGghvmVHsEANqjwfPUKVfCysfcvfYfKdiDSom1oWaA9XAuxx_IkEzw8skn4Zk7a_GBGaS6iu14LRRNPTQs0QZlbfpv6eAVjV2zZ6MqFgxNq0gOWc4gGbb5_4mc9fZtvhYEHnxAFQqZeo3PqXF6bpu_zO0HEptlW9g0c5QHBCFH8dLILyBN1qTMBjHwBTdgPnxdF4UV8zmdO2vCru3Z9WCSvvXF1ITi5NOg-dxhS_BQwbbzF7QKmnlPAg4l4lcQb0dABAzf_esI1_NJeJGZCojbZtPdqug2pgV0RZq1czjsI2PLIJHcOcpe2f10ciDsBLK4ApiBbcOnxBxmqAMz2GQQCgbGH7Xa0BPRKd3Oui10DwIAcQjb3UjqCxP3uH84tNA9JU8nIbxzo6Ahtjbn6Ak-P7ZmKXvd789pMwcVVsg4IDffc0H7Z_T_dtcEQtA9aB4fDeYa5YOgAP95W_EOiTYl4Ngy-tH8lbJIGEqa4F6YOsCwqqvJh653Ypq7GfgcayVQxxcAp_kVON8iVaMLnk6C_e6mKYilKr-3gk9JNK5YVYM6rLqCclFRSI-Qc356hAbG1uPutnOJTGyWujjrp2NBKw7AqjXcVeC1oQ-geF76nyxO9hxmPF7bFr-GDz7h8bF31a4kxe0TYrFH5iHDR4PvPgsl-prhFzbAsuYUIkforcaf6jZG7n1isM-k0DyZr8a87PRkWuLXZsSt3U_uS7lY0UFPTP6REgkxHRcn7kFIDWdLEWx0197u3rOzUTIP163qCBa4_7MbKuX5FN0nwpA1c94ju_WJee8m3TVJ44n4cnSPF6i-ZHIVwLieHrNM59I5p2JENX_KS1Of0bM_teRKA46tPWEX-2SZ4g4NJEiwK-m3Dq5pozgXe1OvfN9ir3ty4LCMy4jrKNDMXtGRTu9VVnNvVfYw-c2v6X7M_XQBOn72MhUSkWB_3_hErJFJenyEoDZ1ITnXUlSE08gBDjyemYC93slPZCO44QXbo0YYUfKR_JJQQ_h-YG54ADS_7L1NluFFJb8lNu5WPffgeLbAVv0-CctyW0CW5Hh7shHIzoXAk-BAKRHsE26ET-HsCII-D8AgYvcHgO8IRLBEYPDHyFpdLlW1KreWDO3TMJ0Rm_nD4tWsXZ2Wsw5iXkdjC1OGzputuG6YOk4QcS3GWctWzh0Q9NN_DfITkvTqoOgTTceMddZt95VwG7XJkykPeUQYgvliKU5AubI4-S932_wpeHxTGVxAVkCSiQgkwQYSvk_mqwAPbPRe03_0frTkvf5M1ghOSIIKP9qfTx3c_GdJi66a3IoIeXvoviNFy1zeAE4g94ObLh3VID11qg3t8qrpTzaPJUE8b6WcTJEHqgVU7waE0J92gx4dQ_DKH9YZnBQSExDHJDh7Mfl60l1yEQAlDjD6wlXqTfyBCldpjoucjXS7t9AtUzVqs1shpxdFVeWxzHWUrEY51pbIC3sGcelogHpMZB2n_MhMunoDNUvZb_QPCl4-A4wPa1-JivvYw-dz42AV61gUixfjcJM9a8A41nlI2N0l-VuzaONnQLnQe6XYGdQPX66MIRTUO6UGVOggsY22OPxjosJQu2stVVmvyU8BPpMAQynSpJ3ORJQ3awjEhYcstFhdhVH1pK83LzgYgc-mRhGrxyM-yaM0yXDwlnb6N9pS620TZOUAJhcBMrXCije8yleirDono79HaNj0CGErBd6jgWLJLUmE4apHOI3ggYS6de1RwD1w67B4nJ10Q7dKcbDxE5FSJaEhcZ1oc4b7UGirNBNFW1sHrvBGG3tAybscFvbXLdqGgLaAH7WJkQc9tgZOjMKnVfACcRZWpGiWQmdbvK7xl5UyWJVQOiMAs1YQu5lvBu8pWuJiAp-lW8fvYIUXS7pvVyiseMGynuGSqppIwmouWlRp1ddK2sQ2Wtd2xKVIdHvEzbirSu96jhqTr1FQ05MREBC8Hk4dXnHU181_qeO9tgWlmya-hq5RnS1_ITZm24zlzNiQxjUgnp3uynUwMlDkGi72x4pUZoUop33wUAfe2xqdXYdy3jH2aCbAqQMVuMZAV2alyBwjhejjQG5dw1-PXiAtVHv4wxYi2NZLzk1YARVORHcJNRLek6nN7KF1m3H_ausCpiYKg5owOhHY36VtOw4O8QlK_U4-_CwG_5klshPmikRwYnkAuGFuJdBb2oJ1jUFKmhLVY6Otx1k9RrdTx-0r8MDvfnoCMgO3f-KOusjsN5W7nEkpJ4Ldg-ePv7PR4q8aMQ_52YQ_ePeAdS7Vc2Ct-_F4VdgdgHL7I5EXiJdsnSYwAr9pw9duf5D1hqbVrl9wTWdegcnZZ-75hCUVWcW0mAluHWZuBbuyus2n7lBateg4Y1S0Hqcm-UbqnSrrVfs2eC0fkZ9eKYPTvZIEvYyEUubnvD-LcgYWcoYbCF3FnHCRdtE26wEzFJ5JiZLLFG2bkTHO0Gw0M976oQAgdbUr3surUDbptmu4e9QVswOd4k-OCt0-7IQbkS-3VYo0DvrNyQ5BF8LcEE8BoE0OOG0ssQZSzzn4s6w5LcIa6fc0nhw1j-tqKWz8AKQVWhtONPy1XoxSqEHRVD17aKvPRYXRI8u0izHqoAsVMtyXRKMICl9Fr8U0P2FKNAceg30-EjYWAw0Yy9OInXCutUx5FSXkqdy9jJJsLWJRg3xDpC7jN24TSyD_7H7wCo8Uap5GIdf2c6Q7aRgJYJjOR9i4mS2ErOqU1_zS8S3EdPbERzigd3qjnlw3rMk4mmwoewNngKmqNNGOc57eLh-oHFklEIZlYSyC5xi5SBUZHyDxBNYldkjmdsRZYgPLo-7q0NMpqURXMqhwFeVneWZgkm-_hWESahnNEdoiv767G2EFe-Tnw6QiRQlVJCuU0yR7xLjfVwJsvMDctakXj3SLAOBc5AnO4QY_VArVf1ybbfpHvoh_-TD3SQhLhfl7AWIa70EQw-GNmP-03jU9fNV0xC4I5K_O9cUhSypEMfJQMlEddR-Qo_oB7_aD-39DaArxZVDe8rmylMcGN-_MSUwiSKjO-UpvEfeoiZX3R-FXayNvvWz7-x0QqFmrePa3XOGByB9kCcdEdmgTCrdhww4kuAECsebytx7hYs5YLdCB5gJakUz1ibZYXKLUpBAE7NHuyLey7mYUJYO_LRyirvey4NSBK45RqDzL64jIHAVuKi0c-o8UJlGY3miNFo_fpvhW7sJv5Nqx-gdIHF4-ZfmjgV-0R7RV5Ay8tdLOGgoIOilJkeZD4vhW87ovGnYV7dUbHEJAaVaHwVGp3VTBc3AwxCjMVxltWPZoKGgCFOiwInVkfa_STIsdChZMKx7RI1ZJKPHGE-BgAg6JknhB5AVTwEAbnM7IzP6aQ0KQAqSpVVpl_cjysfviPM7lLbFVF79W4H7Lv600OXIMAoIuMdGEKRQVSxIroBDMPGmHiVboYID5x4_PO4GeYMpSt0FJuBWRGERwilWGLX2ZmoYJYervI2X-3hoPpYRzki5kF4eTlqpwxJXwebamF3Il0K9pduRNFIjUltuqszMKKYs_k1YNB7zp8VeJANQPUFSwpb5O7RAodV_TU0Fe-zVFzwNjtd3pLcgpf3ESqHn5QfbqZk8Uc4II4OjCoF_33LLoNHrC_GuT5mOGNI5Hudkp2VZZIECmo67JghrNA2l3LvGzGl435sXli8ioGuurfgYHMZXCxJep0RFbnkye0k_S_5S1o39rzj2Z7Fj0EkatB9fMeUdVk707sCtI5I2mrtgmNfKH8fJdJDFQkxW36nlto9igH9Fs6_arXjXeQGnCwkHdbyBmdftmY8WQIHz376juc4UfsMLL99taUlzNeZR7og9X7p1F-NrGqovxaHnnN-TL1pFao0E0jBOgtBNa6UmbC_6aVyDbG11UJJHWuMLglPwCAADD0K519L60r19H_atC0ppMr_9WTTVbopKCd8pUvmV-lS5DVF69pGzOU-8OTLhKPaYXaJtMM-KAmuWLGiH7e2km4cSG63GiogzxHa4SiSFSHYAYLCrhP371nY8-_LOdULla2r8iWaua1tLEgBuxR31eVy_YycUcGi1VQxt7fSVZgn0UXemCzmktrmrnUzvM-zSveo7s0dDHJ6rQNsemKvKT4pwLBbb0QHJ8vmN-mpTZBstfKsfn8uZC5C1XGWW4bTOZKpLQbBRCaMMHkpawlJ1VO624cwbMh4Yfbq0tNaB4ZjjRjHBwej_NRjRW3wn1HZg4yC1NW8kr3Q9O2b-mti1FFq8G-W0WQYXneve_7XqyDvFVvIPZ8-7A1Q0_LvyK48qjuF9sV0emJqG0M8-GVMKTbAO_ZdAHq1Rx4gwsUKwlfSYMiYXLPoiU5n4LZ0twN_8yqFQa9b4dGcNlQngmgJOcGUnHBsxFMmwxou_fI_g_OlAHziLLJI4NgjXOSpwLQpS018rnZ1HKZ8qJOM-BIW0gMqJh1vaVJ6dOghkXbFUn6t5mNgYyJSa0UgJ1-9pDDgy726QUq9qOe95iXJNHDgg-qsQ1orpODSUzRCJC1MckF4lCHhLp6qBy9a6NY9brZUXgVPEfkHtc_r8r8IHkXfgegqYYKjbBqXWf9DPl7UfVxatyVXVVv3o3i0d0LA3aQ6YSNBM8Udn4NZw_6l7mnKMnRsZq1QZDCvf0bj6e928LVX2SXsEXxBsHwimbBOup2UPmQzU_MIkrQKWHBZBM4WrO2UqoDTqmR40WiFKYaFRpqgihHKLzHoK-9oTSLeJN2OAWPs5_LmmNLYoRxV8Px3ba0JA5K2_JtrnNPh8jPwWi-CvasCqitZdg5ko7KG9e3YvUfAtyXZ-otYfbt2pTtJoWwDMn_PX_VBkoleY2n2hzaVHwaD6ie69FBkTkgeR4px1FVnQcAxK7V5VGVN5tW0fgUoaiT8hLpvdwhzcYSjTRy9Hn2LgZfAYcZB3lIkhFfzeXxxAHW6nYIXMqKv5PQon4mtFGtDqVZykrD8xFuGenYlWm6GJHpLEG36SIAvsSYB57lalZYNvO5lv_Uf_K7d8f6sWpHHUVNiOtJ2dNVCpzlum0uoYUcXeq1WHOWBOuBL_6RfESrx6NdSiKJn-1m1R7ZydCovGSCmqZZ68ksLh2DoD8ZansShecvlztFsgWKwpALydN9hjbJ8K9GvzKxJFPp-kMI8OyLaa0t_jp864x1GoMSqCh9cwMUjBmWxVWvhXF4Qy37nmI2m0R97caBfRIxxYBDdStO1TYuHfMLspSgw05b9cVY6STqMXJeVbKTX3_dFKNh4xbhS-jO0OW7TS1JEXVoqX6cNU_1sgOhKdJnzl2CAugMwxbkF_qVwSCRmA2CWtWIUA3Zbrc54AgjkzBG9N88U9_buTmcNxkLgCyYobZSHCUI35k1MLw5FX9fjr0EyrRiZQF9MxXMR-bJjGbxvxc-lGr4_4V4_gSsiYSjjJmd-Y6kv8ye8Q8zZ1pCSAzaDYf8a9eh_xmdIdWf75yDEE2SQl7bZa1yD-9m2X4Z5ED49XX3AmmVSGkPkxcGIVp5_KOCx4uMQstqBHUfr6xmJGBpagKvh2yVPMzkx7Rd0FKlEVg1vzKhU54oXzVJTCoBeSY1mtKrDlSj2mjjxW_6cZcHQ4k9MyWpNBeKEqZ5NUnW2EOyXT0QZ5wzX2N_BpiwNjsYufmEuRSrsUME3fssdL69asckNrHHmEi8vmJruQydclu5IphXdoIzbXYX7JaWC3lw-V3uBbIQEWEXbiI497gJiE0lIF1wHv7a8fM9jzwy27EW6o36PLwSK8IEp9kFLDkqej71GYvJ8HYXjmRe9q7fNMlVIyF68yDoJs2UAlQM-No1KAJlnaDi1tMl0gIqL6FvXnXz7naiiuBy_eoXuqvO1FSWfy01C1U53g95VYdFDxdOdS4MNSr_AE89SMjWSFTH9V5LeKBZjv5z6Rs7tmE7RnINvxcTfrpQNxjHq2OIyqmHvt5AH0fmkKR32X5UHuA498jK0LKDYLsMkVIeVXyEYlAQxC0RvoeHnCwg6kj3ItKsl-BQSWFQwZ1IZgLvrL3yk98jRDeHJq8cOy8-tiAE4bh8wjt4ByhBwpFyVAcL_RHmY1aEhtE440xg3WcHGE4C9qWzNQ8uuus-rO09e7C70y5e83z2SLy3GVJ5uxaCoIOpWg01c-ZdAyaROyTkvz49qBN7_qYIZ8MKTHSW6Hc2TUW5z9cHZa2gJ5UX9ZRpHaHZCydeo9egF0Hcg798pdcVlZpIFmX6NE-TjZBp9szoEBRnTo-E5dkWeGtf52l0IVDhAEpxGqEnwgHSiFSkknuwIg1kvxKjBNdWOHaPXLqPEDHsm7LVFKPTMqnpsC8o_X1gwIEL0gGsdieqdPLcSUNgntlPpEBgk48xdNAGu-0O83M_kkWbMh0y4GzDNzX2bW-c2w1A_DMrKPPKDPHgo2fH3yDY3SY-y9xHNWIR15VSOzAzLFWN-Oc2JpJg6ViKRo8natWBy8iAJ1cMRMmwLiVCSlC4M2uUiO6s6Y6SDXUEIcdL--vOgBbOA4QC_Z8wfyNgZvJyX2uH2LmY4sTQPQE6rUfrUFpiJTz4AoMDUB132cOq_L_twjFtjzyY5s2Ca55XlVPJW1aKRka-2t95-AcQjtLjNqggKeWnxVcfjoimka2NPmxRT8HdlN3CGd3mbJX5yc7lS1UwZNJXsI-T9G3kDBo-U8KzMhjMjMJZa4zRmNHViFiRw35cBsxFzQ3dm_Lvl6ifpISQmLc2Gc6_flhDUYH7TXWlSuGUsnO7BsE-qmquEeAJqU5b16-vwCUYoD2dT_fgHzCnRZh-eQsdim2GSrLvlM5TqDawA1oSrkwyxstApmXheILCKXmR-f5ZJdPiAl85I4mmDL3lnvl-7tC92u0YxLUo9CvKHAJOluILRq9hRztQ9QPknnASXE2jReQ6vT7zE1IRE6QAnq6nmGGXEyIo7PiZsBSehrhJ1arwlNFYhlSNegpMqEq9vlgPuw2Sn0QEsJk3i8xKcoKJJvszNu0_k8Q03LGJvpRfDCgdfBaVLxrT2bUToQeYJCOpr2btQFNahtnxLRR8buzOI02QUPJL9YfYmsiiRn5jUysZ34oW1v31ZtcQN_xZh3ewl6mLpCswDocNweV9pu_Ez-iF8qt8T7JT77f4ajsoajua9f-aw-nbFO9WBS0_QjDhdo1U8xTSDUkUQ365EQFxsX6KdGNFe4FAGTj0PCWGklOcfmWfEU4u2Xt5kf-le_WgGPoOvkTKsJF5VjBj15aku35eqld-EqmqnFAUd7Mbhv7rAO7LUW_dNdnpAZRt-fxxcdKjqp3RZ7aeD3cPm07tn-UzGQBjje0tymCM9MLOvIIgUxPtKqJV7Ma-oyusONZidBQBqtP8n_rI4CD2_5kSRk0X2sSvA7T0W28bEAK2zIiXGgG_rrAodRXhvSfAiEFQpmpGd2xqqe7Q02zcZcnQW2_JdfrUWlSeO6PhZljiHXO3ATUEB_lVaP-1u5TmlWbQL097uvwM3qHlkXoWIpVegjJsmGsf85fL9qp5X8jo0pLLJWdPOsK8YdTyYo1y9xBxBzrfEWVb0RUiiDXjFh-YEIzNzKjS4notcqEu4qkeNOOdpMfIMei5_6EDNReVL2Li5IPSR-e4e-Jm8OJbrLA7FRlEPD1N2DbA_7bIRaB7YT6m2SSX5Q8Q4HXzS_u1gzFEEILGaRCfePspdVutisJUhp5VVu9jPC_LZA7VkhF10sYnaQ_0XZ57zx1Z4tw2RyqO1Q2kRlWGRVmqhL0m1sanXKAYMN0tR8EO2NcqyUGj0FBPg5w2zGtL0Rc3Pne8wkVqVj30ybw5qWi0-szrCGpfbH-ZlmJozMBurTUlUtflAduBjne2YX-mZddrYKp_ZvXFKWipxrKYA6KXVdJ3bQjmcsOVgaV3FhavMcKCuqKoW1mXbovSjt1Ijfy81z8w9yp1HIqg3iKbTzfsib4r8PyAkJvIGSNDgTMcjuqqpNA_cBtfwq_k_vLgFb10GnGoEN0ZJ1hY1K7fRPMR7_QVqdRT9Br5edVQgSKas0pZ4SWOh-d5D_zb7yBPPeQ61aGKWiLHkf0XxXxDRyGBKnVamZs2eI1UX3V0ux2xMU8Tdz0FY0WdQIlEKEMjl9G8dzO1ibDLUuR6a6ZJ4TclZyeuQPRv6fF9gfdF-GEjjOHL-HvfZz2e6S07cuT2O25R0KFXxTpIz0A1ZBc3Miqy0X5pvzBSlHa2SlRUR-WsnsJK-xrGYOPrKTM-Tm-_lTHFwJl1QFN--JTjb-2hhAfgh2QQipb16lDnp63xLmbLdxrtoij6n7GMiWahUwJK79F0qWcSM6vVIK4Wa9yFfZlYhW9-774B2pUnCVsCafrmd6TwrzEyrQhCCrjCy5SwtwOPBpQPn-2fo6MYGUFsqq5OLtbBpOnMFjBrcrr2K9SwEqVcNe0kYnife-g_r-GjzFcOSpMAvia-5oi0O6dUxjs6tEvbXOT0P7qQeSoNdTJoms2J4wz6U2wEP0LxUhbo1_D3LnRStnavWMxFrQh_2dwhricJwlD44bGXbzTffg76bN0VpIU1YS164hNrHIyKEsr65opU38sm3IhqXT5Z50IBnmCpSRVqq9IjClvoyhNVrmk6FNVX-Nq3ylkGvS3g_SLXLa8J2FA8KWmJyIZxDE90eBwezVtrhdn-38lAo5g1ShEN5OykoX7T84XToofsh-38e3tIvpf-hRhWvEnWp4XhsCka6dhOwEIzmpJQr1k5Svj4YuIXHBy9k2v9sXNbY-6f3PNVCINCZABQw9vKpJsV-AxrmIczXxoWlyyaNWVd8RrWgH-7_G4VZ7lkGygBWhOI8fsmySmwzKq4y9lai_GKUzF0UOKJXczxeUWnxhDP_Ouz5Rg2Ozfb6hOnGjNjC1UOKGa3Cx_WOorcv0UmYmO9Lj9cUNglgunz-3sQ01Xba8S6SWXoTItZKk5AARNsS4UfLhfeXzaP6r9tRTkNkfr8HOCr8Mhi6nLW0YyKOW83943isguziPnuFeDqBDdPDO0I3WzfSmeHoMw-UvJJ4MdYBT2L0Bq8GEdimFVavWDy7AKAJ7NW1GU8LTFNaBmOMnNu0Br8qLi6L87yfSjPrynsPrse32QcPBrJxIo-lL5JWFfm0vERiV88syRSdg3Z09_Zx8x22ICekZlap8eC29AMZZnn38cPtEMN-UMRUEPK4aagYy_W2A6eNvtUFZoZQHnCxQmJXlDNe8pjUyD-NtoLVBScUwzkXXiwSik3w_FvVv1ouFsoOvI_1g3ywk8eWeItw-aMJrCjGCmTfJJNqx5EZ-s91K8zIy3IA-jtyngtsImpI0NDaQ14Q21rm7XStuooL6v7e8xOOc-ERf_rTsXpaCMATuAop1T1Hf7kRtyfh-1C3PD7BUqwm7RCq7SpHQmFUWlmTEWmF2yxo8rA4ley0EM7jMtpUkwA5MeApblkPxoiOvsmPlPCh0scxtml3tABK0cO7sv5mExmnBJNqkMWcgLxZIQzQ0WWg9a2fBz2M7QbrTfE_Q7aw77DWeblfxN6jV8VMWm1WZHULMtmB7CsU9UaLK6SYzI6RYTTdRNreUfxznRVN2AMjNwYv2z7BzJUhJUKfY_y8Zu-bacBHNkGJyeXHQ8JfV5rgUsbTYG8oSgBTot9OOyWHtBaLb91K_Jn7vePwxsjFJJUgufm9VDO1ez5CUNPvN1l1e4u1957mNElCFf9GWoondVSzSGmdHZ691RH9BtqB--R3hvlF1Rl6DgGI5ASH1NHBbK2FhdGF55DdwJsVyiE2jJGBYq7z_B4HqTr6MqHHZGe3RZrqD8B115XkjTf0yPRa4pcaTBpLXQerz_7uBxlp9avCxHf4s7wX8YzrMjaqdRWqf3vGaZFtl6eXNxb_SvY3dIjNyQoO7b8wFKHdtcDaCCnyidI8cDExGjufOc5c3AFu4vASFtEfh9awhu5I2VxDp9eB5-T-56R-bswm_J7RGVzyTazigjPSWv2CGYFVdZ234zL-XQVNL4wtM-d7sbGmbKGUJtZwkrUZIDTtRjgzhE_M27raQnrZ58w_fXizslGVM5MKPOCbqjoroYIViy1tQUNy5C-CH1GkovJUHxtyG1LeqbsLptRPhp-lIidizQhwzkxDGK1mtg1CEtNUP5IqK0mSFLjDlQeT-WMJPTVUcAYVe0LH-M7hxujX0u_PPgAGRCf3rBQCGai89f74jSlyx4IP2cbsF5BN-7Fdz8oEJ5pWU-BlVikBT0tWc4IWcC-dCBDfRqiZH10aNzewdFV4UYZlCENdSFxiiREL0zCrkzUWWMt9yQd8neJZYtmeUnfNU2UhNWotgkZPXTXAUXgLLCBid8ZAAMF9x1VI1nUCLlOm5KkNlEyTz_jU4iOUpEURJRsuoMzWKY3XegE1InAVjyjlaZXAbUCVvt7F2hNembf1g5w_NSmywioCxBD5ZXtLSoCNskUN3l-sJ-PujARbq2PpmGXsQ6B0_NJzFNwdT3hCpI0RLSV_6PHIoxnVdBcHguA7ILe8wSr2dfr0cc2PLugA52JBHLU2ZF35uQ7_rbuH5ol6vlblTFx2nuX8EHYhm_swKm__1qm1wv-qLAcsSTuMm4dAvU2CAVxOlSM5JXFeaNoY-eXT9wR2ZrkA-syHNOqTl4rQAH4eZvhfEHbKmUKCV4HCusiW04J7Wo8itLMyFsuLC9dekqLAhELY8bfNOj1COdTZq1Oh94SinvpmrbptD25EnsdI8GAgwjzQITn5_4M89p7fqNpwab6eO1SarmQU62PaWlgg0GtGpWhNLyNKK9jdg0FFaZcpREpU3gkML14RE29ND5MtrQ48Ja4QlOHAb5NRPLTMv6XMTHYHhVjCX6rgfpBU8PYK8ua7P3w6dCL1opjMGZ3v-6SzQHXBKOfQjX1wGoyu6dKjL4kry6_80VV4KPXHalZc7b5RXUWE4y7qf66o9viziqUvHuwl6axoL9H7ei_a6NaxEp6kIpWHDZzxHYpMEVUrOHaFeNHfMDiNORzfeyoI8lGhxtbEcVhik1iOUpqGO6APdFE8oIwklbRAzC1gOLnGzhoeva7eizeC1YK4ETf6Xolok1qNlEZxEil5bHidnzIZ_Ib0v8Qs8qSlH9-MG1jD0CFld2B7LYT_aTYYNz_fXwlZxN2946Arcuicoo-uZwKt653MNz5XMJRXbi68rFMqtNbsKlQh5RJYm8lKLyGmqiDpFd5gpFUhCGmV-HDFjI740Pbvo2KyYHtUFDntnQF_zCizqNxyCZRISwYZqmpMnrIuN-A4T0L_WLmUtMAZJH4cP-lHjPT5s5Kc90vVvRcvJczwzOOxsfzlRWvTMqauoksJWkoYjTb9rnMixRGCyiuTaphNUWTwDVQDUsUdrnW5C2mvX4yArVjT2jBQKIHcpuLd9zDucAKeTTPwkOIuxrYp9-ZmoQyxoRmKhKJ1VOIwgupaZ2LTsUsjqayLaL1Rrmtm0f705lIDopodhJKcIOcy5kLdoLDlvN9JFf_J0cWdafSOoXqQgLWMpktLl-L5iAV8aFwZs1pNzNRt1q398FpaJYyoJInBo7OoPm9qdVdyuTb-oQ51xVjvRQS6g0bg0anOSRTp6hKk2701RO-DF6AMozdpb1Ahaf-X-23i7NyAwDcexY4za8CSsgvuBGg-zElZUDO0VG3cP0fBG4tD1ioD5z0bnLf0I16qO9ZrcfIcmWltXi77kud0OPqPLNj2rc59MEHetkG4H_Qq0kbj_eNi5LeePXs5lhcCUo7UAhsogVhIXU0oiJOT9xaSpNlS4c1wPWBe4AG5v7m-m3mMhJPMlno6a34c-NWOZ51Ltn9VffLzZm6-awy5pfE2YrAXnE9kFcKqYZKBssGGI4VpwYZ45Um55NStcrz7dFyykiFjJcvW3ONzOPQVMUoYjOAQWmpkgrphBMn5e9irop3zw1VkGNyu63wsYTJj0x_rB_Sokzdn5odnmW3u9vn5if3F-u_lh4o9Mb5HDy3Tkasp-0b_6cZp3coF5u7aSqmazC3MmtLaA9eiHAkA3g5hA9AdhkyFR7s5xjweKlfd4dIA15ymmqWOdBGuqF_e4FuIrSUqzXKbmq0zLFdQ1xksfeCtSt2_shadQNJYg9avIcaXJpQjvOYWC6SdmWrMcq3Ga7EF02A0c7aeytodY6ENRmn_k7IqMcttTQvk8tXV9mVyI0u2_szswQul37DuJlpB9L3rsu4jPSZpyMZxAAEBOqMgywslBRHFbTNAgqW1cmTfCRgiL3OVA8hVN7g7nfpde11OdvlY_Oj5942e7IicX4-CxJSLX-mKIYBax0WqC02C_jb5B3ASyPpYHbBcLnmj7YkLiCG2HNt0U_ZHphimej0GUJ1YlsGuCtfPjcS4J6ARRfIp4OVGTQtPryOde-JVA3fE-RVpPf_2e-LvRsu8Wk4LC90r4jUnY0hmCCB5aHExXAtH3oXcxi57zuo7LLys_Yg5EAJGAt0rVu3WNEfKEA0_bonGhCheqEGdQAUSxSP-VWmRon7rdmaPfI0UlKaFV1gWFl0ipqgitYeDnetRuiT1TkslEGADJc8CKqJpFW8ayjl56ppfbiwsBFKprH49zBxCDOR-vvDjEA9v3RtcafdQPQEzwbps-W7ZyHfVLnGt8sBu_oTELhk-KSP_aU7whMEYinEW4pP7sG0bkXPygiRk71gOXk4E7U_kUA1Dr4wCfzY8PhgPXjo1bqxOViTiP6vVeBd28a7spGQ1NXGcmy7ZsN1DBdHB_ATQf-Ibz8KncwCId_qSEMCmRdCRw7UO4cjYCs15K_7gHNRIWTBR_zT9oOulZezInyivFFDnVR5YkljGc-LPd8yeZ5-LSIl6l39BVBe9CDL2tOrUVOb0MlquO6yO4W5ZiRadtjArlHHqPt7Tnb1hO1gBbXgOXJeD2zohIvz_uGaIZXaMRg3JeV3lRJLY9zkjmSnJ0hgbNhGcjq0Er1OSPW2ok31niSQjjQGvFQXI32WmExSi-QZF2rTuMjXJKVx4P7kKdPOkIQxruznvIC01XxSQ6i6iJnF5D5KMjgkDifhRmjEHBQ3M0ltQ1GHtF-rRn7kCs2gEQ_ZN_bsVNIbgZ1uZxO66iGh_gnyc_oLG8haGVJvuoZW3QHr18c4v4RokdHqF1V97N8PcDMCa1kArEK9cFk3yE4NlhN-VWBwu5QMLXT1aM0NR6FCUkKFTnmTVXaG8riSvgEAy4NYkIMuSqiuAeKf5nPKISJhj04nHEInK76Ff5wWgsHerW0DuCJcjrW1-FpaMqWptPFxDGfDJHntVXbqUUD1ga8nvYzJY6a9ZCnOBdX3rBETXzsJVxdDn5BzVjGVXna0vRztyHApCZ7K-tDvTiQ8h1A3RTRqrlvxHGFVSL3aoZdbnBQksyQhIW4P0XxWrsRT_LqS9IvoeEbdaH3elyryEfd4Lkb2Yr_yq3yP1qq-jQwth6vPADd0YDGy3XLzkAjhfgThksuLDXEXZ_C3rJxLdgLVmBkyalVL6cQQzIZVozbFTxvwyGfBei94EQK_qwKxYrKYxq2TmsfV_BGQoUVBC_e2DjJV_Vz6mCAnwtnBJHvMMj5OSDb2Z5GOq7g2EqmtlbzJG-CeFkGMGuEsoj5HzYYeuJq-kp1CFSUMlerKHBOigI4PJ-cRvUTYI1vxLXamQVGe_iahnVlkaRqoU3Lr40GE0JfVowuLOW2t6c1MXS9_jTNX_IH_rrn2HhTep-dru0iGQc4P6l8C30ZAGItYKwdrfP58pGY0GngcPlajzow4Oe8R7lr1Xb5ahpEBfkyTufR0wN4Jw6IAp2SmWgjdYRkwrWe_gJ9v6rhJTJu-H-36Znk103-Bqj5Jo8kkTIFlbpoezlKgpx7_UXYpUhnl9pDE1WIyZ6mae9qKYM_F87Xpl1med-0U33wGuJsNlIumGjELNs5r3jiY7sLBBByeWyhZxrhyzGfUsSlI5jxtqq1zGqVsmobr-bXEo04ftX-2WH-zdHWYYCM3_wO_5FA65eZI0SGDiKlQBoz1KZi49SRknSnERMa6rgcjyUdAir8fzysT1tGb2PdU7yHm4W89AY77P2bJbF4X72J4cNRlDJkU01jGrFxozdy5k_GkD_elfUEqwdM8nfOjyT5nlHviHT_XebDNiBRHAOnjhFf9dKSuBfFZ2QaIQLjkhfWphFvOk0FFFU_Q659kYOKHM4L6Bz_BssHtl88BHJhfg8-mxCliAMYsxWE7EsVyc3i59IsrvM2M7iRJesC0prXVLI_4Dozdf_vYmL2dLChjbdqvr-ZPqW3kWhhMwpFP4E346Ne3RKVc3vNW7Exd-t41IVVXhOWEgsrD89rPya5yHVwM6zStRkhA-RBqSyOWU9FneYNxdGzWRpAMxQe8Kj6PR4LToC7UXM2Iwpyx0Amq9ocfo5bTT9T4Wvu3ak2c2wtO_EfoDWHdfBat33XgK87_FPQSg8TCsYYAw-ORaYZnmHvYOMYs7iQJHbUzekEKPbsI9TSsPtuaW4TDRkFUYcZ_rU9JCflOtHlVhvqidZo42NJb_9A9egjDE2HacwGeRAGC3iK1aEwMOl_Ph3GGdug-OQGaId4nlu-ni3Bb_ze2kh1-8xXYhrkuyUx8HCsqB7vmRQx6mhqLWBgdrkGjI8Ng1MrcJByer1Z0FuxDt554a4bOanijV7Z6tOb0XxtF3vDsYa7fugAgiUB91PR4VKO72yfJBknmU_W_VEQ8VQMsH-0vQ-SsXGfbI7FwF8J42qN5c2SbJEFVLyOKbajUOFf7A83qtHA3wiYDg7aoYFSleFCxuh15QBIewqQwtE0VOMlERfjQLAZA_tSH5Ey5sCSmbkGwe4jq5ww9Srh3OpVeyzMj5M2iDuIeYN1EfQCgoz4gH4oRwA8UEIVG01bnXe9Hnjx-UskzcfXPlm8aVvqgQWBhfa8yh6akTmCc58Cp27qJdBxoxp5kD_efNgkOXvgLGNPaW9rPLjvKePLcDf5ncjtDfGGm1j1PEGXlFzCgmLC0vl36CHztGhrhSgeFo0qSigLhjGVgWiHd02-0jpsviSyFxCKpvm1yaZ8XLHdiwwe19Cg5TcLwnjSMRtO9MKh4lKUR6wR8ahMaOSzOxLHWGQwhj4GbHkkKUmTsIf_wXSlwpCoAUQEn_1IZLOKRUl8pugeDJfngOPCwGBeJWlSOpyJZpSgvFDrD6aau7DrM-LIAYsUhSplsbO_eFCFqzOGk2Fw6vDPGz2filkB1UV6CNz33lL0OSHERzIpauLARfTMSFc-PsTNiwUbXH44V9elAzBJRt4GaN6WxVZ_ZIGXVMgvcdwu7RziUPcgNoAdqzeDFhrmpai89wvTGSjcvlRxeO5brIMGUwNHHyeRdoXkQTBQWmIQdaj4sSxwrrmH6UVvCYWt7JBWSm0aPd1wLdIM0XZ2983QRk406f4AMDTR7bSQ1zCtyI5qWl12ZE1vN-AjP6TbI_QwKIG_WuM70qF7K50naE9QQg3ieC_N_Hn1i5hemUbJ8cs6qlMSD59mM0vdC6RHl3F4kSVTXmJW5FXi3H8rZRor1UZtSPfMMNJs2AP1lgamLgDIgBaGRKBO43fqLHScLsIggQAl8bZZNRxfDJn5bDee9WUQz-brtTSJaSWIX9jtz7ZILCjeAclh2j2D0z0-6zFM6fOe1BREKZ8o4d1t5eU-jx84BAI6iB8E26XvgGh5VfaYCgQ0NfJXkOdCkImTKqmzm9fVANJC9SSjvfTJq1tPFNhP5ei4VWngUmBdwvWm6K6ewBNVXGdPPW8Qb7VTrhO4ZL8ulQjlOhHU7n63iI4E73idhxYCO3US8_E8Nj3zID3o-2RBYgzej1hQpsmOTpplxW42xJhlHYgUdSKO9qaErviovSsnLVBFLy9W7UeQLEFwcSxk5Xm0E7gAxc9A8aFT-Ah9QF_rUvBmEjM7HIMpI_Kd5FNSHGho2dR1rJJlLGuaw2bLRgvE30FtkpxagvkFKxRxkPF6IQaBmCDRVU2SULUgZqvRPQrrGwEhkHbECvzGbOeb6_UiaEGEVO6u0VFfqG7NfxMQJyaBlWCzknBCbd2yKm8o_4Wsh9DCE6RYRP0ZM_xaTv0Ft |