Captures tether as a P2P app backbone, not just a clipboard: - three-tier model (Mesh / Sync / App) with the inter-tier contracts; Mesh+Sync both live in tethercore, App is the only thing outside - transport-agnostic ladder (LAN/RTC/SSE → QUIC → BT/LoRa) behind one trait - two-tier crypto (room key + durable device keypairs), rotation, private discovery (OPRF/PSI lookup, ZKP ownership) — marked target - supernode model: no server, just nodes volunteering signaling/relay/ DHT/discovery; homelab is supernode #1 - §5a: honest separation-of-concerns grade against the actual code — Transport + MessageHandler boundaries are clean; the Mesh/Sync seam is aspirational (Engine holds both), transports mutate sync state, caps are stringly-typed. The MeshEvent refactor is the prerequisite for the Iroh spike — same work. - Iroh + Automerge recommendation; roadmap (ship / next chapter / horizon) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
18 KiB
tether — architecture & north star
What this is. tether began as a private Universal Clipboard. The clipboard is the proof, not the point. The point is the thing underneath it: a resilient, transport-agnostic P2P substrate that any private cross-platform app can be built on. The clipboard is app #1.
This document is the durable record of where tether is and where it's going, so the vision survives outside any one conversation. It is a map, not a spec — where it describes things that don't exist yet, they are marked (target).
1. The thesis
As long as nodes can come up, find each other, prove who they are, and pass data — securely, over whatever medium is available — everything else is an app-layer concern.
That one sentence is the whole bet. It implies a clean separation into three tiers. Each tier only ever talks to the one below it through a narrow contract, so any tier can be swapped without disturbing the others.
┌─────────────────────────────────────────────────────────────┐
│ APP clipboard · (next app) · (your app) │ ← per-app
│ reads/writes named state; sends/receives msgs │ code
├═══════════════════════════════════════════════════════════════┤
│ SYNC CRDT state · message/data passing · presence │ ┐
│ turns "bytes between peers" into shared state │ │
├─────────────────────────────────────────────────────────────┤ │ tethercore
│ MESH identity · transports · discovery · │ │ (one crate,
│ (substrate) secure channel · reachability/relay │ │ UniFFI'd to
│ promises: "named, authenticated peers you can │ │ every client)
│ reach and send bytes to, over any medium" │ ┘
└─────────────────────────────────────────────────────────────┘
Tiers vs. crates. The three tiers are a contract boundary, not a packaging
one. Mesh and Sync both live in tethercore — sync is app-agnostic (every
app reuses dedup, ordering, CRDT merge, delivery, presence), so it belongs in the
shared core precisely because it's shared, not pushed up into each app. The
only thing outside the crate is the App tier. The double line above (═) is the
crate boundary; the single line (─) inside it is the internal Mesh/Sync seam.
⚠ Reality check (the seam is aspirational). As of 33d0fb7 that internal
─ line is a goal, not implemented. See §5a for the honest state-of-the-code
assessment. The boundaries that are real and clean today: the Transport trait
(Mesh's downward edge) and the MessageHandler trait (the App edge). The
Mesh/Sync line between them does not yet exist as a boundary — Engine holds
both — and closing that gap is the prerequisite for the Iroh spike (§8).
The contracts between tiers:
- Mesh → Sync: "Here is a set of authenticated, named peers. You can send bytes to any of them and receive bytes from them. I don't care what the bytes mean. I'll tell you who's present and reachable; I hide whether that's LAN, WebRTC, QUIC, Bluetooth, or LoRa."
- Sync → App: "Here is replicated state and a message stream, scoped to an identity. Read it, write it, subscribe to it. I handle conflict resolution, ordering, dedup, and delivery; I hide which peer a change came from."
Everything tether-specific and clever lives in Mesh. Everything reusable across future apps lives in Sync. The clipboard is a thin App.
The one seam that's easy to get wrong
"Find each other locally" is the easy half — mDNS / link-local broadcast on a shared L2 is basically solved. The irreducible hard part of the Mesh — the part that must not leak up into the app — is everything that happens when two nodes are not on the same network:
- Non-local discovery — peers on different networks finding each other → rendezvous, then DHT.
- NAT traversal — hole-punching with a relay fallback → supernodes.
- Identity & secure channel — knowing who a peer is and that the link is private → durable keypairs + an authenticated handshake.
These five — identity, transport, discovery, secure channel, reachability — are the Mesh. Get them right once and the transport medium becomes a detail.
2. Transport-agnostic by construction
The Mesh sees every medium through one Transport trait. The upper tiers never
know which one carried a byte. Today's ladder, with room to grow downward:
| Transport | Status | Reach | Notes |
|---|---|---|---|
| LAN (mDNS + length-prefixed TCP) | shipped | same L2 | zero-infra, fastest, works server-down |
| RTC (WebRTC data channel) | shipped | cross-network | STUN/TURN via supernode; the web-reachable transport |
| SSE relay | shipped | cross-network | signaling + last-resort carry of (E2E) ciphertext |
| QUIC | target | cross-network | likely via Iroh; better than hand-rolled RTC |
| Bluetooth LE | someday | proximity, no IP | "bitchat-style" — works with no network at all |
| LoRa / other | someday | long-range, low-bw | same trait, tiny MTU; sync layer must tolerate it |
The reason BT/LoRa are plausible later rather than fantasy is precisely the trait boundary: a transport only has to (a) discover peers on its medium and (b) move framed bytes between them. The sync layer already tolerates multi-transport delivery (it dedups across transports today), which is the same discipline a high-latency / low-MTU medium demands.
3. Identity & crypto (the two-tier model)
Identity is part of the Mesh, not the app. The model is two layers:
-
Room key — shared symmetric secret. Everyone in a room/group can decrypt group content. Today:
key = sha256(account), ChaCha20-Poly1305, payload sealed before any transport touches it (seecore/src/crypto.rs). The server and any LAN sniffer see only ciphertext.- Known weakness (documented, honest): the account is an email — low
entropy. This stops a passive eavesdropper, not someone who knows the
account. (target) Replace the secret with a high-entropy source
(Sign-in-with-Apple
sub, or a passphrase) fed into the samefrom_secret. No protocol change — just a better secret.
- Known weakness (documented, honest): the account is an email — low
entropy. This stops a passive eavesdropper, not someone who knows the
account. (target) Replace the secret with a high-entropy source
(Sign-in-with-Apple
-
Device keypairs — durable per-device identity. (target) Each device has a long-lived X25519 keypair. Peer-to-peer links run an authenticated handshake (Noise via
snow, or adopt libsignal/MLS rather than hand-roll) so each pair has forward-secret session keys. The room key is then distributed by sealing it to each device's public key (sealed-box) instead of being derived from a guessable string.
Rotation & unlinkability (target). New shared key → new room. New device identity → new public key, re-handshake. This buys forward secrecy and unlinkability, but creates a discovery tension (if your identity keeps changing, how does someone find you?). Resolved by a stable handle + directory indirection: a durable, user-facing handle maps — privately — to whatever the current key/room is.
Private discovery (target). Looking someone up by email/phone/handle without revealing the lookup table:
- OPRF / PSI for the lookup ("do we both know this contact?" without either side learning the other's full set).
- ZKP for ownership proofs ("I control the key behind this handle"), not for the lookup itself.
These are v2+ research arcs. Don't hand-roll the primitives — stand on vetted libraries.
4. The supernode model (there is no "server")
There is no server/client split. There are only nodes. Some nodes — by virtue of public reachability + uptime + bandwidth — volunteer extra roles. Those are supernodes (Skype/ultrapeer/libp2p-relay model). The current Go "server" is a supernode; the architecture just generalizes it from the one to one of many, peer-selected.
A supernode offers any subset of these — none of which touch plaintext:
- Signaling / rendezvous — peer-relayed SDP/ICE exchange (today: the SSE bus).
- TURN relay — forwards E2E ciphertext when hole-punching fails; blind to payload.
- DHT bootstrap + capacity — stable entry points; hold more of the keyspace.
- Discovery hints — same-ASN / same-egress-IP co-location hints (the node that can see public IPs). Strong CGNAT false-positive caveat; mostly useful to bias toward the LAN transport and to avoid TURN.
- Store-and-forward mailbox (target) — hold sealed messages for offline peers.
Promotion = reachable + up + has bandwidth. Auto-detected (a node that finds it's publicly reachable volunteers) or designated (flag the homelab). Patrick's homelab is supernode #1; the hosted relay is #2.
Why "many, peer-selected" matters: a single coordinator is a server with extra steps. Several supernodes, cross-checked, are what resist the real failure modes — eclipse attacks (one node controlling your view of the network) and traffic analysis (metadata is visible even though content is E2E).
Two honest constraints:
- Bootstrap is unavoidable. You must know one supernode to start — a hardcoded list or a DNS name. Even BitTorrent's DHT ships bootstrap nodes. "Decentralized" never meant "zero fixed entry point."
- Incentives at public scale. For Patrick's own devices, he runs the supernodes. For a public backbone, who runs them is a real economics question (Skype conscripted user machines — controversial; BitTorrent/Veilid lean on altruism). Decide deliberately, don't default into it.
5. Current state (what actually exists)
At commit 33d0fb7. Verified on real hardware: bidirectional cross-network
clipboard sync (iPhone-on-cellular ⇄ Mac), file transfer (RTC + LAN),
prefer-direct (relay saw only presence), and E2E (relay carried only ciphertext).
core/—tethercoreRust crate, the Mesh + a thin Sync. UniFFI-exported to Swift/Kotlin.Enginemultiplexes LAN + RTC + SSE, dedups across them, prefer-direct (suppress relay once all present peers are directly reachable), delivery receipts (hash, not content), presence, nearby (mDNS), file transfer, E2E (crypto.rs). Account-derived room:room = sha256(account)[..4].agent/— headless desktop node; installs as a launchd/systemd service; clipboard loop; saves received files.ios/— SwiftUI iPhone + Mac apps over the same core (TetherKit).android/— scaffold; needs NDK build wired up.server/— Go supernode: pion signaling + integrated TURN +/api/turn-cred(HMAC ephemeral creds) + web client. ⚠ The deployed binary is ahead of this source (integrated TURN, advanced web client, built from an uncommitted tree ~2026-05-21). Do not redeployserver/as-is — it would regress TURN. Recovering that source is an open task.
What's Mesh today: identity (weak), LAN/RTC/SSE transports, mDNS + relay discovery, E2E channel, presence/reachability. What's Sync today: message passing, dedup, receipts, file frames — but no replicated state (no CRDT). What's App: the clipboard UIs.
5a. Separation-of-concerns: honest grade
Graded against the code (core/src/lib.rs), not the diagram.
Clean and real:
Transporttrait (lib.rs:270) — the strongest boundary. SSE/RTC/LAN each implement it; adding QUIC/BT is "implement the trait." Mesh's downward edge, done right.MessageHandlertrait (lib.rs:59) — the App edge. The clipboard reaches core only throughon_message/on_status/on_file; apps never touch engine internals.crypto.rs— isolated; one seal point at the engine boundary (lib.rs:382) before any transport.
Not yet real (the gaps to fix before building on this):
- No Mesh/Sync seam.
Engine(lib.rs:291) holds both transport orchestration and dedup/receipts/presence/transfers. Swapping the Mesh (e.g. Iroh) today means rewritingEngine, not replacing a module. - Transports mutate Sync state — dependency arrow points the wrong way.
RtcTransport::new(cfg, direct, present, transfers)(lib.rs:339) andLanTransport::new(cfg, discovered, direct, transfers)(lib.rs:345) hand transports the engine's state maps, which they write directly via sharedArc<Mutex>. A transport should move bytes, not own presence/progress. This is the biggest smell and the reason the Mesh isn't swappable. - Stringly-typed capabilities.
send_filegates carriers witht.name() == "rtc" || t.name() == "lan"(lib.rs:386);Transport::send_fileis a default-empty method only 2 of 3 honor. Capability should be typed (aDirectTransportsub-trait or acaps()flag), not a display-string match. Engineis a 745-line god object trending up — every feature bolted on a field + method. Coherent now; watch it.
The one move that fixes the most — MeshEvent. Invert #2: transports emit
events (PeerUp/PeerDown/Frame/Presence/FileChunk) and the engine owns
all state and reacts. That single change creates the real Mesh/Sync boundary
(§1), makes Iroh a drop-in event source, and removes the Arc<Mutex>-passing
coupling. The refactor and the Iroh spike are therefore the same work — do this
first.
6. The big gaps (clipboard → backbone)
The hard networking kernel exists. To become a general substrate:
- State sync, not just messages. Apps need replicated state (a doc, list, board), conflict-free across peers → CRDTs (Automerge / Yjs). This is the biggest single addition and the heart of the Sync tier. The clipboard is stateless message-passing; a backbone is replicated state.
- An app API / SDK. A clean surface: declare data, subscribe, send. The thing a developer building app #2 actually touches.
- Web target = WASM. The Rust core compiles to WASM, but the browser sandbox limits web peers to WebRTC / WebSocket / WebTransport (no raw UDP/TCP/mDNS — same wall as iOS's multicast entitlement). Web is a first-class but transport-limited peer.
- Capabilities / permissions (target). Multi-user needs object-capabilities (UCAN), not ACLs.
- Persistence / offline-first (target). Local store + sync-on-reconnect (append-only log or CRDT doc store).
7. Don't reinvent the substrate
This space has serious, aligned prior art. The moat is product/DX/UX (the "AirDrop-grade just works" + the app model) — not the transport/DHT plumbing, which is being commoditized. Stand on vetted infra:
- Iroh (n0, Rust) — QUIC P2P + relay hole-punching + discovery
(DNS/pkarr-DHT) +
iroh-docs/iroh-blobsfor sync. Possibly ~70% of the Mesh already, in Rust. Its relay nodes = our supernodes; its bootstrap/DHT = our rendezvous. Evaluate first. - libp2p — the components (relay-v2 + DCUtR + Kademlia) if you want them à la carte.
- Veilid (cDc, Rust) — privacy-first P2P app framework, DHT + E2E. Aligned with the privacy/DHT angle specifically.
- Automerge / Yjs — the CRDT Sync layer. UCAN — capability auth. Willow / Earthstar — capability-scoped P2P data.
Strategic recommendation: adopt Iroh (Mesh) + Automerge (Sync), build identity/E2E/discovery-UX + the app API on top, keep the clipboard as the flagship app that proves the DX. Run the homelab as a self-hosted Iroh relay + bootstrap node — that's supernode #1 — and the substrate hands you peer-relay + DHT + multi-supernode selection for free, instead of a year of hand-rolling NAT and crypto and getting it subtly wrong.
8. Roadmap
Near-term (ship the product):
- Coordinated rebuild + reinstall of all clients so E2E actually activates (running apps still carry the old plaintext core).
- Swap the room secret to a high-entropy source (SiwA
sub/ passphrase). - Recover the uncommitted deployed
server/source (don't redeploy until then). - iOS multicast entitlement (Nearby/LAN are dormant on iOS).
- Android NDK build; clipboard-image auto-send; file-size cap.
Next chapter (start the backbone, clean):
- Extract the Mesh/Sync seam via
MeshEvent(§5a) — transports emit events, engine owns state. Prerequisite for everything below; fixes the transport→sync coupling and the stringly-typed capability check. - Spike Iroh as the Mesh — homelab as self-hosted relay/bootstrap
(supernode #1). Compare against the current hand-rolled RTC/LAN/SSE.
(Drops in as a
MeshEventsource once the seam exists.) - Spike Automerge as the Sync tier; re-express the clipboard as a tiny app over replicated state to validate the App API.
- Durable device keypairs + authenticated handshake (Noise/
snow, or libsignal/MLS); seal the room key to device pubkeys.
Horizon (the real vision — stateless P2P app backbone):
- DHT + multi-supernode peer-relay (mostly adopted via Iroh/libp2p, not built).
- Rotatable rooms/keys/identities + stable-handle directory indirection.
- Private discovery (OPRF/PSI lookup; ZKP ownership proofs).
- Capability model (UCAN); offline mailbox; web (WASM) peer.
9. One-line summary
Peers with durable keypairs · E2E content · pluggable transports (LAN/RTC/QUIC/BT/LoRa) · supernodes volunteering signaling/relay/DHT/discovery · CRDT state sync · clipboard as app #1. Built on Iroh + Automerge, differentiated on the "just works" DX. The network is the product; the clipboard is the proof.