| 1 | # π€ Cross-instance relay β chatting across the trust boundary |
| 2 | |
| 3 | Status: **proposal / for discussion** (no code). This is a design sketch, not a |
| 4 | commitment. It builds on `docs/threads-and-dms.md` (user keypairs, per-thread |
| 5 | keys, server-blind key delivery) and deliberately re-opens something that |
| 6 | document calls out as *"intentionally impossible"* β cross-instance contact. |
| 7 | |
| 8 | Today each chat is a **fully-isolated instance**: its own Go binary, SQLite DB, |
| 9 | admin token, and subdomain. A user is a member of exactly one roster; a message |
| 10 | is fanned out in-process to WebSocket clients on that same server and can't leave |
| 11 | the box. There is no federation, no server-to-server protocol, no user discovery |
| 12 | across instances. This doc asks: *could* two people on different instances chat, |
| 13 | and if so, what would it actually cost? |
| 14 | |
| 15 | ## The key insight: the crypto is the easy part |
| 16 | |
| 17 | E2EE here is about **keys, not servers**. A "user" is an ECDH P-256 public key |
| 18 | with no inherent instance affiliation; a thread key is AES-GCM, wrapped to each |
| 19 | member's public key via ECIES; the server only ever holds blobs it can't open, |
| 20 | and messages are self-describing ciphertext (filename/MIME ride *inside* the |
| 21 | payload β `crypto.js`). |
| 22 | |
| 23 | So for Alice@family to DM Bob@friends, the cryptographic need is only: |
| 24 | |
| 25 | 1. they hold each other's **public keys**, |
| 26 | 2. someone generates a thread key and **wraps it to both** pubkeys (ECIES), and |
| 27 | 3. both can **fetch** the ciphertext + their own wrapped key blob. |
| 28 | |
| 29 | None of that requires them to share a server. A relay would see exactly what |
| 30 | today's server already sees β opaque bytes. **The E2EE invariant survives |
| 31 | untouched**; the server was never trusted with plaintext in the first place. The |
| 32 | wire format needs no real change: `iv||ciphertext` base64url blobs plus one |
| 33 | wrapped-key blob per recipient. |
| 34 | |
| 35 | What a relay actually costs is **metadata privacy** and the **invite-gated trust |
| 36 | boundary** β not confidentiality. Those, not the encryption, are the design work. |
| 37 | |
| 38 | ## Three topologies (lightest β heaviest) |
| 39 | |
| 40 | 1. **Client-as-relay (no new server).** Alice is a member of both instances; her |
| 41 | browser posts the same ciphertext to both DBs. Keys/discovery bootstrap via an |
| 42 | out-of-band invite β *exactly today's invite-fragment mechanism*. Cheapest, |
| 43 | leaks the least (no new observer), but there's no offline delivery to a peer |
| 44 | who isn't co-homed, and the "same human, two identities" model is awkward. |
| 45 | |
| 46 | 2. **Instance-to-instance federation.** Instance A `POST`s ciphertext to an |
| 47 | inbound endpoint on instance B. Needs server-to-server auth, a way for A to |
| 48 | discover B's address, and each instance to trust the other's routing claims. |
| 49 | Distributes metadata (no single chokepoint) but multiplies the number of trust |
| 50 | relationships an admin has to reason about. |
| 51 | |
| 52 | 3. **Central relay / mailbox.** A third dumb server both instances push to and |
| 53 | poll, addressing messages by recipient public key (or a hash of it). Best UX β |
| 54 | directory-style discovery and offline delivery β but it concentrates routing |
| 55 | metadata in one place. |
| 56 | |
| 57 | ## What you'd actually be trading away |
| 58 | |
| 59 | The encryption doesn't weaken, but three real properties do. These are the |
| 60 | decisions, and they're why this is a *proposal* and not a PR that ships code. |
| 61 | |
| 62 | - **Cross-instance metadata stops being non-existent.** Today, isolated instances |
| 63 | mean the cross-circle social graph *lives nowhere* β no server can observe that |
| 64 | a member of a kid's-friends instance also talks to a work contact. Any relay |
| 65 | creates a place where `{pubkey X β pubkey Y, time T, size N}` is visible, even |
| 66 | with content sealed. This is precisely the property the isolated-instance design |
| 67 | exists to provide (see the metadata note in |
| 68 | `docs/onboarding-and-home-server.md`). Losing it is the central cost. |
| 69 | |
| 70 | - **Invite-gating stops being the anti-abuse mechanism.** Right now "you can only |
| 71 | be reached if an admin put you on the roster" *is* the spam/abuse control. |
| 72 | Cross-instance reachability punches through it, so it must be replaced by an |
| 73 | explicit **contact-request / consent** model: no one can open a cross-instance |
| 74 | thread with you until you accept. |
| 75 | |
| 76 | - **Governance spans two admins.** A shared thread lives under two rosters with |
| 77 | two admins and two removal/rotation policies. Whose rules win? Removing someone |
| 78 | from one instance doesn't evict them from a thread hosted (or mirrored) on the |
| 79 | other; a thread-key rotation on one side has to propagate across the boundary. |
| 80 | Push, too, is per-instance today (APNs topic per instance, subscriptions keyed |
| 81 | by token) β cross-instance delivery needs the peer/relay to trigger it, adding |
| 82 | metadata at the push layer. |
| 83 | |
| 84 | ## A recommended shape (if we do this) |
| 85 | |
| 86 | Frame it honestly: **this is a metadata-and-consent project, not a crypto |
| 87 | project.** The lightest design that keeps the most of what makes lchat good is |
| 88 | option 1 stretched slightly: |
| 89 | |
| 90 | - A **cross-instance invite** bootstraps a shared thread, reusing the exact |
| 91 | key-shipping already built for onboarding: the invite fragment carries the |
| 92 | thread key wrapped to the recipient, exchanged out-of-band (so no central |
| 93 | directory is introduced, and discovery stays a human act). |
| 94 | - The thread's ciphertext is **mirrored to both instances** so neither side needs |
| 95 | to trust a new server and either instance alone can serve history. |
| 96 | - A **consent step** gates the first message, replacing roster-gating as the |
| 97 | abuse control. |
| 98 | |
| 99 | Reach for a central relay (option 3) only if offline delivery and directory-style |
| 100 | discovery are worth conceding the metadata point β a genuine trade, not a default. |
| 101 | |
| 102 | ## Open questions |
| 103 | |
| 104 | - **Identity across instances.** Is a person's cross-instance identity their |
| 105 | existing per-instance user keypair, or a separate long-term "contact" key they |
| 106 | present to multiple instances? The latter enables real discovery but creates a |
| 107 | correlatable global identifier β the thing isolation currently prevents. |
| 108 | - **Where history lives** when the two instances have different retention/deletion |
| 109 | and one deletes the shared thread. |
| 110 | - **Trust display.** How does the UI make it unmistakable that a thread crosses |
| 111 | the trust boundary (different admin, different rules) rather than looking like |
| 112 | any other DM? |
| 113 | - **Blast radius on compromise.** A relay or federation endpoint that's reachable |
| 114 | is new attack surface; even serving only ciphertext, it becomes a place to |
| 115 | correlate the graph. Worth a threat model of its own before any code. |
| 116 | |
| 117 | ## Non-goals (for this doc) |
| 118 | |
| 119 | No implementation, schema, or endpoints are proposed here. Sealed-sender / |
| 120 | private-membership (hiding the graph even from the relay) is a separate, harder |
| 121 | problem and explicitly out of scope. This exists to make the trade-offs |
| 122 | reviewable so a future decision is deliberate, not incremental. |
| 123 | |