eleven / messenger Public

docs: propose a cross-instance relay protocol for discussion (#153)

Design sketch (no code) exploring whether two people on different,
fully-isolated instances could chat. Key framing: because the server is
already untrusted, a relay is cryptographically almost free — the real
costs are cross-instance metadata (today the cross-circle graph lives
nowhere) and losing invite-gating as the anti-abuse mechanism (needs a
consent model). Covers three topologies (client-as-relay, federation,
central relay), a recommended lightest shape reusing the existing
invite key-shipping, and open questions. Filed for future discussion.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell d05042ddb80c913c88438cc8f72dcd3f429e87d7 parent af7b893
1 file changed, +122 −0
  • docs/cross-instance-relay.md +122 −0
diff --git a/docs/cross-instance-relay.md b/docs/cross-instance-relay.md
new file mode 100644
index 0000000..6dff11a
--- /dev/null
+++ b/docs/cross-instance-relay.md
@@ -0,0 +1,122 @@
+# Cross-instance relay — chatting across the trust boundary
+
+Status: **proposal / for discussion** (no code). This is a design sketch, not a
+commitment. It builds on `docs/threads-and-dms.md` (user keypairs, per-thread
+keys, server-blind key delivery) and deliberately re-opens something that
+document calls out as *"intentionally impossible"* — cross-instance contact.
+
+Today each chat is a **fully-isolated instance**: its own Go binary, SQLite DB,
+admin token, and subdomain. A user is a member of exactly one roster; a message
+is fanned out in-process to WebSocket clients on that same server and can't leave
+the box. There is no federation, no server-to-server protocol, no user discovery
+across instances. This doc asks: *could* two people on different instances chat,
+and if so, what would it actually cost?
+
+## The key insight: the crypto is the easy part
+
+E2EE here is about **keys, not servers**. A "user" is an ECDH P-256 public key
+with no inherent instance affiliation; a thread key is AES-GCM, wrapped to each
+member's public key via ECIES; the server only ever holds blobs it can't open,
+and messages are self-describing ciphertext (filename/MIME ride *inside* the
+payload — `crypto.js`).
+
+So for Alice@family to DM Bob@friends, the cryptographic need is only:
+
+1. they hold each other's **public keys**,
+2. someone generates a thread key and **wraps it to both** pubkeys (ECIES), and
+3. both can **fetch** the ciphertext + their own wrapped key blob.
+
+None of that requires them to share a server. A relay would see exactly what
+today's server already sees — opaque bytes. **The E2EE invariant survives
+untouched**; the server was never trusted with plaintext in the first place. The
+wire format needs no real change: `iv||ciphertext` base64url blobs plus one
+wrapped-key blob per recipient.
+
+What a relay actually costs is **metadata privacy** and the **invite-gated trust
+boundary** — not confidentiality. Those, not the encryption, are the design work.
+
+## Three topologies (lightest → heaviest)
+
+1. **Client-as-relay (no new server).** Alice is a member of both instances; her
+ browser posts the same ciphertext to both DBs. Keys/discovery bootstrap via an
+ out-of-band invite — *exactly today's invite-fragment mechanism*. Cheapest,
+ leaks the least (no new observer), but there's no offline delivery to a peer
+ who isn't co-homed, and the "same human, two identities" model is awkward.
+
+2. **Instance-to-instance federation.** Instance A `POST`s ciphertext to an
+ inbound endpoint on instance B. Needs server-to-server auth, a way for A to
+ discover B's address, and each instance to trust the other's routing claims.
+ Distributes metadata (no single chokepoint) but multiplies the number of trust
+ relationships an admin has to reason about.
+
+3. **Central relay / mailbox.** A third dumb server both instances push to and
+ poll, addressing messages by recipient public key (or a hash of it). Best UX —
+ directory-style discovery and offline delivery — but it concentrates routing
+ metadata in one place.
+
+## What you'd actually be trading away
+
+The encryption doesn't weaken, but three real properties do. These are the
+decisions, and they're why this is a *proposal* and not a PR that ships code.
+
+- **Cross-instance metadata stops being non-existent.** Today, isolated instances
+ mean the cross-circle social graph *lives nowhere* — no server can observe that
+ a member of a kid's-friends instance also talks to a work contact. Any relay
+ creates a place where `{pubkey X ↔ pubkey Y, time T, size N}` is visible, even
+ with content sealed. This is precisely the property the isolated-instance design
+ exists to provide (see the metadata note in
+ `docs/onboarding-and-home-server.md`). Losing it is the central cost.
+
+- **Invite-gating stops being the anti-abuse mechanism.** Right now "you can only
+ be reached if an admin put you on the roster" *is* the spam/abuse control.
+ Cross-instance reachability punches through it, so it must be replaced by an
+ explicit **contact-request / consent** model: no one can open a cross-instance
+ thread with you until you accept.
+
+- **Governance spans two admins.** A shared thread lives under two rosters with
+ two admins and two removal/rotation policies. Whose rules win? Removing someone
+ from one instance doesn't evict them from a thread hosted (or mirrored) on the
+ other; a thread-key rotation on one side has to propagate across the boundary.
+ Push, too, is per-instance today (APNs topic per instance, subscriptions keyed
+ by token) — cross-instance delivery needs the peer/relay to trigger it, adding
+ metadata at the push layer.
+
+## A recommended shape (if we do this)
+
+Frame it honestly: **this is a metadata-and-consent project, not a crypto
+project.** The lightest design that keeps the most of what makes lchat good is
+option 1 stretched slightly:
+
+- A **cross-instance invite** bootstraps a shared thread, reusing the exact
+ key-shipping already built for onboarding: the invite fragment carries the
+ thread key wrapped to the recipient, exchanged out-of-band (so no central
+ directory is introduced, and discovery stays a human act).
+- The thread's ciphertext is **mirrored to both instances** so neither side needs
+ to trust a new server and either instance alone can serve history.
+- A **consent step** gates the first message, replacing roster-gating as the
+ abuse control.
+
+Reach for a central relay (option 3) only if offline delivery and directory-style
+discovery are worth conceding the metadata point — a genuine trade, not a default.
+
+## Open questions
+
+- **Identity across instances.** Is a person's cross-instance identity their
+ existing per-instance user keypair, or a separate long-term "contact" key they
+ present to multiple instances? The latter enables real discovery but creates a
+ correlatable global identifier — the thing isolation currently prevents.
+- **Where history lives** when the two instances have different retention/deletion
+ and one deletes the shared thread.
+- **Trust display.** How does the UI make it unmistakable that a thread crosses
+ the trust boundary (different admin, different rules) rather than looking like
+ any other DM?
+- **Blast radius on compromise.** A relay or federation endpoint that's reachable
+ is new attack surface; even serving only ciphertext, it becomes a place to
+ correlate the graph. Worth a threat model of its own before any code.
+
+## Non-goals (for this doc)
+
+No implementation, schema, or endpoints are proposed here. Sealed-sender /
+private-membership (hiding the graph even from the relay) is a separate, harder
+problem and explicitly out of scope. This exists to make the trade-offs
+reviewable so a future decision is deliberate, not incremental.