eleven / messenger Public

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>
Paul Campbell 6e4f6ef7e17dd5a27e6d2f1c404b30725c378514 parent d66c438
12 files changed, +1291 −95
  • docs/eleven-cli.md +42 −10
  • eleven/main.go +224 −84
  • eleven/signin.go +259 −0
  • eleven/signin_test.go +140 −0
  • eleven/store.go +149 −0
  • eleven_client_test.go +118 −0
  • home/test/gen-home-vector.mjs +3 −1
  • internal/client/client.go +16 −0
  • internal/client/home.go +220 −0
  • internal/client/home_test.go +114 −0
  • internal/client/session.go +5 −0
  • internal/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&rsquo;re all done</h1>
+<p class="sub">You&rsquo;re signed in &mdash; 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