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 }