eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package client
2
3import (
4 "encoding/binary"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "strings"
10 "time"
11
12 "lchat/internal/e2ee"
13)
14
15// The home server (home.elevenmessenger.com, home/) is the passkey-gated
16// backup store: a member's saved spaces — each with its credentials — live in
17// one client-encrypted blob there. This file is the Go half of that contract,
18// byte-compatible with home/web/static/crypto.js (and the Swift HomeCrypto):
19// open the EHB1 envelope with a DEK, and unwrap the {token, dek} session the
20// restore page hands a CLI after the passkey ceremony. The CLI never sees a
21// PRF or KEK — the browser did the ceremony; it arrives holding the DEK.
22
23// HomeSession is what the restore page hands over after a passkey unlock
24// (restore.js's app/cli handoff): ongoing bearer auth to the vault plus the
25// key that opens it. Holding this file IS holding every saved space's
26// credentials — same trust profile as the native apps' Keychain copy.
27type HomeSession struct {
28 V int `json:"v"`
29 Base string `json:"base,omitempty"` // home server URL (CLI bookkeeping)
30 Token string `json:"token"`
31 CredID string `json:"credId"`
32 DEK string `json:"dek"` // raw AES-256 key, base64url
33}
34
35// Vault is the decrypted backup: the member's saved spaces in master order
36// (ios/LChatCore HomeVault.Contents / home/web/static/serverlist.js).
37type Vault struct {
38 V int `json:"v"`
39 Servers []VaultServer `json:"servers"`
40}
41
42// VaultServer is one saved space. Credentials is nil for a bare saved URL
43// (home.js can save a space by URL alone); Archived entries are hidden from
44// the native rail and home's list, so a restore skips them too.
45type VaultServer struct {
46 URL string `json:"url"`
47 Name string `json:"name,omitempty"`
48 Starred bool `json:"starred,omitempty"`
49 Archived bool `json:"archived,omitempty"`
50 Credentials *VaultCredentials `json:"credentials,omitempty"`
51}
52
53// VaultCredentials is a space session in vault clothing — the same fields as
54// Session, keyed the same way (home's blob JSON and the session file share
55// the userPub/userPriv naming on purpose).
56type VaultCredentials struct {
57 Token string `json:"token"`
58 UserPub string `json:"userPub"`
59 UserPriv string `json:"userPriv"`
60 Name string `json:"name,omitempty"`
61}
62
63// Session converts a vault entry into a live space session.
64func (s VaultServer) Session() *Session {
65 if s.Credentials == nil {
66 return nil
67 }
68 name := s.Credentials.Name
69 if name == "" {
70 name = s.Name
71 }
72 return &Session{
73 Base: strings.TrimRight(s.URL, "/"),
74 Token: s.Credentials.Token,
75 Name: name,
76 UserPub: s.Credentials.UserPub,
77 UserPriv: s.Credentials.UserPriv,
78 // The roster label already exists server-side for a member restored
79 // from backup — this is an existing identity, not a fresh join.
80 LabelPublished: true,
81 SpaceName: s.Name,
82 Starred: s.Starred,
83 }
84}
85
86// OpenHandoff decodes what the restore page returned (the base64url "back"
87// payload — a loopback ?d= value or a pasted code), verifies the nonce is the
88// one this process minted, and unwraps the home session with the ephemeral
89// private key that never left this process.
90func OpenHandoff(backB64, nonce, ephPrivB64 string) (*HomeSession, error) {
91 raw, err := e2ee.B64uDecode(strings.TrimSpace(backB64))
92 if err != nil {
93 return nil, fmt.Errorf("client: that doesn't look like a sign-in code")
94 }
95 var back struct {
96 Nonce string `json:"nonce"`
97 Wrapped string `json:"wrapped"`
98 }
99 if json.Unmarshal(raw, &back) != nil || back.Wrapped == "" {
100 return nil, fmt.Errorf("client: that doesn't look like a sign-in code")
101 }
102 if back.Nonce != nonce {
103 return nil, fmt.Errorf("client: sign-in code is from a different signin attempt — run signin again and use the fresh code")
104 }
105 var hs HomeSession
106 if err := e2ee.OpenSealed(back.Wrapped, ephPrivB64, &hs); err != nil {
107 return nil, fmt.Errorf("client: couldn't unlock the sign-in code: %w", err)
108 }
109 if hs.Token == "" || hs.DEK == "" {
110 return nil, fmt.Errorf("client: sign-in code is missing its session")
111 }
112 return &hs, nil
113}
114
115// FetchVault pulls the encrypted backup from home and opens it with the
116// session's DEK. A member with no backup yet returns an empty vault, not an
117// error. An expired/revoked token returns ErrHomeAuth so callers can rerun
118// the ceremony.
119func FetchVault(hs *HomeSession) (*Vault, error) {
120 req, err := http.NewRequest("GET", strings.TrimRight(hs.Base, "/")+"/api/blob", nil)
121 if err != nil {
122 return nil, err
123 }
124 req.Header.Set("Authorization", "Bearer "+hs.Token)
125 hc := &http.Client{Timeout: 30 * time.Second}
126 resp, err := hc.Do(req)
127 if err != nil {
128 return nil, err
129 }
130 defer resp.Body.Close()
131 switch resp.StatusCode {
132 case http.StatusOK:
133 case http.StatusNotFound:
134 return &Vault{V: 1}, nil
135 case http.StatusUnauthorized, http.StatusForbidden:
136 return nil, ErrHomeAuth
137 default:
138 return nil, fmt.Errorf("client: home server: HTTP %d", resp.StatusCode)
139 }
140 envelope, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
141 if err != nil {
142 return nil, err
143 }
144 dek, err := e2ee.B64uDecode(hs.DEK)
145 if err != nil {
146 return nil, fmt.Errorf("client: bad home session key: %w", err)
147 }
148 return OpenHomeBlob(envelope, dek)
149}
150
151// ErrHomeAuth: the stored home session no longer authenticates (token revoked
152// or expired) — the caller should run the passkey ceremony again.
153var ErrHomeAuth = fmt.Errorf("client: home sign-in has expired — run `eleven signin` again")
154
155// OpenHomeBlob opens an EHB1 envelope with an already-unwrapped DEK,
156// mirroring home/web/static/crypto.js unpackBlob + openBlob:
157//
158// "EHB1"(4) || u32 BE headerLen || header JSON || iv(12) || ciphertext
159//
160// The header (per-passkey wrapped DEKs) is skipped — the DEK is in hand. The
161// plaintext is padded (4-byte BE length prefix, zeros to 16 KiB) so ciphertext
162// length leaks nothing; unpad recovers the JSON.
163func OpenHomeBlob(envelope, dek []byte) (*Vault, error) {
164 if len(envelope) < 8 || string(envelope[:4]) != "EHB1" {
165 return nil, fmt.Errorf("client: not a home backup blob")
166 }
167 headerLen := binary.BigEndian.Uint32(envelope[4:8])
168 if int(headerLen) > len(envelope)-8 {
169 return nil, fmt.Errorf("client: corrupt home backup blob")
170 }
171 body := envelope[8+headerLen:]
172 padded, err := e2ee.DecryptBytes(dek, body)
173 if err != nil {
174 return nil, fmt.Errorf("client: couldn't open the backup (wrong key?): %w", err)
175 }
176 if len(padded) < 4 {
177 return nil, fmt.Errorf("client: corrupt home backup blob")
178 }
179 ptLen := binary.BigEndian.Uint32(padded[:4])
180 if int(ptLen) > len(padded)-4 {
181 return nil, fmt.Errorf("client: corrupt home backup blob")
182 }
183 var v Vault
184 if err := json.Unmarshal(padded[4:4+ptLen], &v); err != nil {
185 return nil, fmt.Errorf("client: corrupt home backup blob: %w", err)
186 }
187 return &v, nil
188}
189
190// SealHomeBlob is OpenHomeBlob's inverse (crypto.js sealBlob) — the envelope a
191// browser would PUT. Used by tests to prove byte-compatibility both ways; a
192// future write-back (starring a space from the CLI) would go through here.
193// keys is the per-passkey wrapped-DEK header, passed through untouched.
194func SealHomeBlob(v *Vault, dek []byte, keys map[string]string) ([]byte, error) {
195 pt, err := json.Marshal(v)
196 if err != nil {
197 return nil, err
198 }
199 const pad = 16384 // crypto.js BLOB_PAD
200 if len(pt) > pad-4 {
201 return nil, fmt.Errorf("client: backup contents too large")
202 }
203 padded := make([]byte, pad)
204 binary.BigEndian.PutUint32(padded, uint32(len(pt)))
205 copy(padded[4:], pt)
206 body, err := e2ee.EncryptBytes(dek, padded)
207 if err != nil {
208 return nil, err
209 }
210 header, err := json.Marshal(map[string]any{"keys": keys})
211 if err != nil {
212 return nil, err
213 }
214 out := make([]byte, 0, 8+len(header)+len(body))
215 out = append(out, "EHB1"...)
216 out = binary.BigEndian.AppendUint32(out, uint32(len(header)))
217 out = append(out, header...)
218 out = append(out, body...)
219 return out, nil
220}
221