Wolffish Relay
The zero-retention meeting point that carries encrypted traffic between your desktop and your phone — and cannot read, keep, or log any of it.
How it works
Two devices need to talk. One sits behind a home router, the other behind a mobile carrier's NAT; neither can accept an incoming connection. So both dial outward to the same address and meet there. That address is the relay.
The relay's whole job is to notice that two sockets presented the same rendezvous ID and to pass bytes between them. It performs no authentication of its own, keeps no accounts, and holds nothing once the sockets close. Everything that makes the tunnel safe happens on the devices, before a byte is handed over.
Pairing happens once, by QR
The desktop displays a QR code carrying three things: the relay URL, its own long-lived public key, and a freshly generated 32-byte pairing secret. The phone's camera reads it.
That camera hop matters more than it looks. Because the secret travels out of band — screen to lens, never over the network — a hostile relay cannot insert itself into the pairing. It never sees the secret, so it can never complete the handshake in the middle.
From that secret both devices derive the same rendezvous ID: rid = HMAC(secret, "rid-v1"). It is 256 bits, unguessable, and the only fact about a pairing the relay ever learns. It identifies a meeting, not a person.
illustrative — each pairing
generates a fresh one
Then a handshake, every time they reconnect
Pairing is once. Reconnecting is automatic and needs no user action: the devices already hold each other's keys, so they run a Noise IKpsk2 handshake — the pattern designed for exactly this asymmetry, where the initiator already knows the responder's static key but not vice versa.
Three properties fall out of it. Mutual authentication: each side proves possession of its long-term key, and the desktop pins the phone's key on first contact. Forward secrecy: fresh ephemeral keys per session mean a key stolen next year cannot decrypt traffic captured today. Pairing binding: the PSK means knowing the rendezvous ID is not enough — an attacker who somehow learns it still cannot complete a handshake.
How it's built
The entire cloud footprint is one Worker script containing one Durable Object class — about 250 lines. There is no server, no container, no database, and nothing to patch.
| File | Role |
|---|---|
src/index.ts | Stateless front door: validates the URL shape and the role, then routes to the one Durable Object that owns this rendezvous ID |
src/tunnel.ts | The Durable Object: pairs the two roles, forwards binary frames verbatim, replaces zombie sockets, enforces limits |
src/protocol.ts | The wire contract in one file — ID format, roles, frame cap, keepalive, close codes, presence notices |
wrangler.jsonc | Configuration, and where the retention guarantees are pinned so a deploy re-asserts them |
Why a Durable Object
A plain Worker is stateless by design: two requests can land on two machines in two cities that share nothing. If both devices simply "connected to the Worker", they would land on unrelated instances and never find each other.
A Durable Object adds exactly one thing: named single-instance routing. Every request for a given name reaches the same one running instance, anywhere on earth. That is what lets two sockets be in the same place at the same time. "Durable" refers to that stable identity — not to storage. A Durable Object may attach persistent storage; this one simply never calls it.
The wire contract
| Route | Behaviour |
|---|---|
GET /t/<rid>?role=host|guest + upgrade | Join the tunnel for that rendezvous ID |
GET /healthz | 200 ok |
GET / | Status page |
| anything else | 404 / 400 / 405 / 426 |
rid must match ^[0-9a-f]{64}$. The desktop connects as host — the parked, always-on side. The phone connects as guest.
Frames
- Binary frames are peer data: opaque sealed records, forwarded verbatim to the other role, capped at 1 MiB each.
- Text frames from the relay are presence notices, the only text it ever sends:
{"t":"peer-present"}and{"t":"peer-gone"}. - Text from a client: exactly one is legal, the keepalive
ping→pong, answered by the runtime without even waking the object. Anything else closes the connection.
| Close code | Meaning |
|---|---|
4000 Replaced | A newer connection for your role arrived; this socket was evicted |
4400 ProtocolViolation | You sent a non-keepalive text frame |
4413 MessageTooLarge | You sent a frame over 1 MiB |
Two semantics matter for client authors. One live socket per role — a reconnect replaces its predecessor, and the peer never sees a false "gone" during that swap. And frames sent while the peer is absent are dropped: presence notices tell you when someone is there, and delivery guarantees belong to the end-to-end protocol's acknowledgements, never to the relay.
Moving data and files
Above the sealed frame layer sit three kinds of traffic, all multiplexed on the one socket: RPC (request/response — fetch the config, list conversations, run an agent turn), events (unsolicited pushes — streaming agent output, "this conversation changed"), and file transfer.
Files never move as files. They move as a stream of independently sealed chunks, which is what makes the transfer resumable, memory-flat, and verifiable.
Each chunk is encrypted on its own, so a corrupted or reordered one fails loudly and alone rather than poisoning the file. The receiver writes each chunk straight to disk at its offset and checkpoints its progress every sixteen chunks; if the connection dies, reconnecting replays the manifest and the receiver simply asks to continue from its checkpoint. When the last chunk lands, the whole-file hash is compared against the manifest before the file is made visible.
Security promises
The relay is untrusted by design. Every guarantee below holds even if the relay is fully compromised, run by someone else, or replaced by a hostile clone.
| Promise | How it's achieved |
|---|---|
| Content is unreadable in transit | Every frame is sealed on-device with ChaCha20-Poly1305 under keys the relay never sees |
| Tampering is detected | AEAD authentication on every frame — a single flipped bit fails the tag and the frame is discarded |
| Replay and reordering fail | Per-session keys with a strictly increasing nonce counter |
| Past traffic stays safe | Forward secrecy: fresh ephemeral keys per session, so a stolen device key cannot decrypt earlier sessions |
| The relay cannot impersonate either device | Mutual authentication against pinned static keys; the pairing secret it never saw is required to finish a handshake |
| Pairing cannot be intercepted | The secret travels out of band, screen to camera, and is mixed into the handshake as the PSK |
| Strangers cannot join | The rendezvous ID is 256 bits and unguessable; even holding it does not let an attacker complete a handshake |
| Nothing survives the session | No storage is ever written — see the retention section below |
What a hostile relay could still do
Stated plainly, because a security claim without its limits is marketing. A relay operator — including Cloudflare, including you if you self-host — can observe, while the sockets are live: the two IP addresses, the rendezvous ID, the timing of frames, and their sizes. It can also refuse service or drop frames.
It cannot read content, alter it undetected, replay it, forge a frame either device will accept, impersonate either side, or recover anything after disconnection. Traffic metadata is the irreducible cost of any relayed system; the design keeps it to the minimum and never records it.
Zero data retention
"We don't store your data" is a promise. This is the version you can check: an inventory of every piece of state in the system and where it lives.
| State | Where it lives | Lifetime |
|---|---|---|
| Device keypairs, peer public key, pairing secret | Platform secure storage — see below | Until you unpair |
| Conversations, configs, files, sync cursors | Each app's own local database | The product's own data — endpoints, not the pipe |
| Session keys | Both devices' RAM | One connection |
| The socket pair and a role tag | Relay RAM | Dies with the sockets |
| Any database, object store, queue or log | Does not exist | — |
Unpairing is deleting the stored key material on each device. There is nothing in the cloud to delete because nothing was ever written there — which is also why reconnection is free: there is no server-side session to restore, ever.
Where the keys actually live
"Secure storage" is not one thing, and the guarantee differs per platform.
| Platform | Backend | What it protects against |
|---|---|---|
| macOS | Keychain, via Electron safeStorage | Other users and other apps in the same user session |
| iOS | Keychain Services, via expo-secure-store | Other apps; survives app uninstall under the same bundle ID |
| Android | Keystore-encrypted SharedPreferences | Other apps; cleared on uninstall |
| Windows | DPAPI, via Electron safeStorage | Other users on the machine — not other apps running as you |
| Linux | gnome-libsecret / kwallet | Other users, only when a secret store is present |
The approach is deliberately boring: each platform's default. safeStorage on desktop and expo-secure-store on mobile are built in, need no native modules, and behave identically on every install — no passphrase prompts, no custom key derivation, nothing that can fail for a subset of users.
safeStorage.getSelectedStorageBackend() reports the active backend in one call, so a connection-details screen can simply show it.No logs, no telemetry
Retention has a second face: even without a database, a service can leak everything through its logs. This one is configured so there is nothing to leak, and the settings live in version control so every deploy re-asserts them.
| Surface | State |
|---|---|
| Worker logs and traces | Disabled in wrangler.jsonc (observability.enabled = false) |
| Log export (Logpush) | No jobs configured |
console.log anywhere in the source | Not a single one |
| Error-tracking or analytics SDK | None — the Worker has no dependencies at runtime |
| Storage bindings (KV, D1, R2, queues, analytics engine) | None — the only binding is the Durable Object itself |
| Build-tool telemetry | Off (send_metrics: false) |
| Browser telemetry headers (NEL / report-to) | Switched off at the zone |
| Tail workers (a covert log path) | None connected |
What remains, honestly: the platform's own aggregate request counters, and TLS terminating at the edge — as it does for any hosted service. Neither exposes content, because what crosses the edge is already ciphertext sealed on your devices. If that residual matters to you, the design's answer is the last section: run the relay yourself.
Example run
Generated 2026-08-02T23:57:40.378Z · a real cycle against wss://relay.wolffi.sh, not a simulation. Reproduce it with npm run playground.
One run drives the whole lifecycle the way the apps will: it stages real data from a live Wolffish workspace, pairs by QR, hand-shakes, audits the wire, tries to break in, syncs configuration and conversations, streams a real agent turn, and moves files — including a 248 MB PDF that is deliberately interrupted mid-flight to prove resume.
The big one — miller.pdf
1.23 MB/s (10 Mbps)
3m 21s · resumed at chunk 160
The phone's socket was terminated outright partway through. It reconnected, ran a fresh handshake, asked to continue from its checkpoint, and finished — with the final hash matching the source byte for byte.
Phases
| Phase | What it proves | Checks | Time |
|---|---|---|---|
| Stage from the published demo dataset | manifest, conversations, config and files — all from the CDN | 4/4 | 30.1 s |
| Pair by QR code | desktop shows, mobile scans, both derive the same rendezvous ID | 3/3 | 42 ms |
| Connect and hand-shake | Noise IKpsk2 over the live relay | 4/4 | 1.3 s |
| Prove the wire is opaque | ciphertext audit on live frames plus integrity probes | 7/7 | 1.2 s |
| Resist intruders | wrong rendezvous ID, and a scanner-less impostor | 5/5 | 4.9 s |
| Sync configuration | the desktop config.json, projected for a phone | 3/3 | 379 ms |
| Sync conversations | index first, then full bodies on demand | 2/2 | 1.5 s |
| Run a live conversation | mobile asks, desktop streams a real agent turn back | 3/3 | 658 ms |
| Move files | published sample files plus deliberately awkward ones | 12/12 | 5.4 s |
| Reverse direction — the phone serves the desktop | device tools, a remote invocation, and an upload | 6/6 | 3.2 s |
| Move the 248 MB PDF, and survive a dropout | the phone loses signal mid-transfer and resumes | 4/4 | 3m 21s |
| Verify delivery | compare every artifact on both sides | 2/2 | 341 ms |
Transfers
| File | Origin | Size | Time | Throughput | Result |
|---|---|---|---|---|---|
wolffish-sample.png | samples/png | 35.7 KB | 441 ms | 0.08 MB/s (1 Mbps) | delivered |
wolffish-sample.md | samples/md | 550 B | 442 ms | 0.00 MB/s (0 Mbps) | delivered |
wolffish-sample.json | samples/json | 497 B | 432 ms | 0.00 MB/s (0 Mbps) | delivered |
wolffish-sample.html | samples/html | 773 B | 390 ms | 0.00 MB/s (0 Mbps) | delivered |
wolffish-sample.pdf | samples/pdf | 56.8 KB | 465 ms | 0.12 MB/s (1 Mbps) | delivered |
wolffish-sample.gif | samples/gif | 28.1 KB | 451 ms | 0.06 MB/s (1 Mbps) | delivered |
wolffish-sample.mp4 | samples/mp4 | 629.8 KB | 1.1 s | 0.58 MB/s (5 Mbps) | delivered |
wolffish-sample.docx | samples/docx | 4.3 KB | 462 ms | 0.01 MB/s (0 Mbps) | delivered |
empty-marker.txt | generated (zero-byte edge case) | 0 B | 203 ms | 0.00 MB/s (0 Mbps) | delivered |
تقرير-المزامنة-٢٠٢٦.txt | generated (RTL filename edge case) | 140 B | 456 ms | 0.00 MB/s (0 Mbps) | delivered |
chunk-boundary.bin | generated (chunk-boundary edge case) | 256.0 KB | 561 ms | 0.45 MB/s (4 Mbps) | delivered |
camera-capture.jpg | phone camera roll → desktop | 59.2 KB | 2.2 s | 0.03 MB/s (0 Mbps) | delivered |
voice-memo.md | phone recording → desktop | 141 B | 427 ms | 0.00 MB/s (0 Mbps) | delivered |
miller.pdf | cdn.wolffi.sh/generic/miller.pdf | 248.1 MB | 3m 21s | 1.23 MB/s (10 Mbps) | delivered · resumed |
Both endpoints run on one machine during the example, sharing a single uplink — read these as a floor, not a ceiling. Small files are latency-bound (two protocol round trips before the first byte); large files are bandwidth-bound.
What the relay actually carried
These are the first bytes of real frames, captured at the socket boundary — exactly the bytes the relay forwarded during this run.
guest→relay 152 B noise msg1 010db5f4797d6c166bf09b9549513cffd1929a2a3d746ddffe85c2435cdee9d345a35e365559b9f318c6a59e1854d64d… host→relay 104 B noise msg2 01d854e12313bc355bf8ea65ee0b7f0bba0fdcfad3164c84924443bc10cdbb4e0cc4271614cc905515ce96c400be7ff8… guest→relay 62 B RPC_REQ 02a7ad0483804b6a83bef3b783ad4708b6c7c7779bb1222fa689d3e434f2eb9822bbb8b52b81115caa9eca4a754117fb… host→relay 138 B RPC_RES 02bcd536e39c417463d1741b12aab3e998801dfddc0381806d6481766b11fa2893ec706f3f8c4fe0f414c8d5ded61f81… guest→relay 68 B RPC_REQ 02a6ff52a5b08573f57bd0cefe8bd81bb998e26d4632ed819a946f3165ac1eb102cb432f7ad00d2e4d8a5109876400e5… host→relay 2380 B RPC_RES 026c9ad503d440813407dbb352a5f01f5baf0540907c9874949f0db9ee7c8f6f6ca4a2d9dadeb18719c7ccb18b08ae9e… guest→relay 86 B RPC_REQ 023ad6c226ae9b3c446fb00e03103b19068f8857315d4634f1ac636b9504bf5cbfeb15612fbec28248e419b04c151c98… host→relay 8308 B RPC_RES 023834a25d792f77dd2cf3bda2bd2327adf40b521c0693cd882e6e5a2a1f8d7f1b40bdd24e8d4fcff645bdf85e774937…
Across 12 frames and 17.4 KB, the captured ciphertext measures 7.980 bits per byte of entropy — 8.0 is indistinguishable from random noise. No plaintext marker searched for appears anywhere in it, including the %PDF header of the file being transferred at the time.
Every check
| Assertion | Detail | Phase | |
|---|---|---|---|
| ✓ | demo manifest fetched from the CDN | version 025fc50bc5f8 | Stage from the published demo dataset |
| ✓ | demo conversations loaded | 12 | Stage from the published demo dataset |
| ✓ | file spread staged | 11 files | Stage from the published demo dataset |
| ✓ | large PDF staged | 248.1 MB | Stage from the published demo dataset |
| ✓ | QR carries the desktop static key | Pair by QR code | |
| ✓ | both sides derive the same rendezvous ID | 0673974282b41176… | Pair by QR code |
| ✓ | rendezvous ID is 256-bit lowercase hex | Pair by QR code | |
| ✓ | desktop pinned the real mobile static key | Connect and hand-shake | |
| ✓ | both sides agree on the handshake transcript hash | f9800c2b2554567bac2a0df4… | Connect and hand-shake |
| ✓ | desktop identity received by mobile | Connect and hand-shake | |
| ✓ | mobile identity received by desktop | Connect and hand-shake | |
| ✓ | RPC round-trip over the encrypted tunnel | Prove the wire is opaque | |
| ✓ | no plaintext markers found in captured ciphertext | 5 needles searched | Prove the wire is opaque |
| ✓ | captured frames are indistinguishable from random | 7.980 bits/byte over 8.8 KB (random would score 7.980) | Prove the wire is opaque |
| ✓ | sealed frame does not contain its plaintext | Prove the wire is opaque | |
| ✓ | a single flipped bit fails authentication | Prove the wire is opaque | |
| ✓ | the wrong key cannot open a frame | Prove the wire is opaque | |
| ✓ | the right key round-trips exactly | Prove the wire is opaque | |
| ✓ | a socket on another rendezvous ID receives nothing | Resist intruders | |
| ✓ | impostor cannot finish the handshake without the QR secret | knowing the rendezvous ID is not enough | Resist intruders |
| ✓ | impostor derived no session keys — it can neither read nor write | Resist intruders | |
| ✓ | forged frames are rejected — the impostor cannot drive the desktop | responder keys exist but nothing authenticates against them | Resist intruders |
| ✓ | the real session survived the probes | Resist intruders | |
| ✓ | every config section arrived | 12 sections in 378 ms | Sync configuration |
| ✓ | credentials were replaced before leaving the desktop | Sync configuration | |
| ✓ | demo settings survived the trip | 33 capabilities · 11 services | Sync configuration |
| ✓ | conversation index delivered | 12/12 | Sync conversations |
| ✓ | conversation bodies match the desktop byte for byte | 6/6 | Sync conversations |
| ✓ | agent streamed deltas to the phone | 7 events | Run a live conversation |
| ✓ | streamed transcript reassembled exactly | 1812 chars | Run a live conversation |
| ✓ | tool-run event delivered | Run a live conversation | |
| ✓ | delivered wolffish-sample.png | 35.7 KB · 441 ms · 0.08 MB/s (1 Mbps) | Move files |
| ✓ | delivered wolffish-sample.md | 550 B · 442 ms · 0.00 MB/s (0 Mbps) | Move files |
| ✓ | delivered wolffish-sample.json | 497 B · 432 ms · 0.00 MB/s (0 Mbps) | Move files |
| ✓ | delivered wolffish-sample.html | 773 B · 390 ms · 0.00 MB/s (0 Mbps) | Move files |
| ✓ | delivered wolffish-sample.pdf | 56.8 KB · 465 ms · 0.12 MB/s (1 Mbps) | Move files |
| ✓ | delivered wolffish-sample.gif | 28.1 KB · 451 ms · 0.06 MB/s (1 Mbps) | Move files |
| ✓ | delivered wolffish-sample.mp4 | 629.8 KB · 1.1 s · 0.58 MB/s (5 Mbps) | Move files |
| ✓ | delivered wolffish-sample.docx | 4.3 KB · 462 ms · 0.01 MB/s (0 Mbps) | Move files |
| ✓ | delivered empty-marker.txt | 0 B · 203 ms · 0.00 MB/s (0 Mbps) | Move files |
| ✓ | delivered تقرير-المزامنة-٢٠٢٦.txt | 140 B · 456 ms · 0.00 MB/s (0 Mbps) | Move files |
| ✓ | delivered chunk-boundary.bin | 256.0 KB · 561 ms · 0.45 MB/s (4 Mbps) | Move files |
| ✓ | every file completed on the mobile side | 11/11 | Move files |
| ✓ | desktop fetched the phone's own tool definitions | Reverse direction — the phone serves the desktop | |
| ✓ | desktop read live device status from the phone | battery 72% · cellular | Reverse direction — the phone serves the desktop |
| ✓ | desktop invoked a tool that only the phone can run | Reverse direction — the phone serves the desktop | |
| ✓ | phone uploaded camera-capture.jpg | 59.2 KB · 2.2 s | Reverse direction — the phone serves the desktop |
| ✓ | phone uploaded voice-memo.md | 141 B · 427 ms | Reverse direction — the phone serves the desktop |
| ✓ | uploads are byte-identical on the desktop | 2/2 | Reverse direction — the phone serves the desktop |
| ✓ | the transfer really was interrupted mid-flight | Move the 248 MB PDF, and survive a dropout | |
| ✓ | transfer resumed from a checkpoint, not from zero | chunk 160 | Move the 248 MB PDF, and survive a dropout |
| ✓ | delivered size matches | 248.1 MB | Move the 248 MB PDF, and survive a dropout |
| ✓ | sha256 matches end to end | 2ddd71a4a8fce8272e4073db… | Move the 248 MB PDF, and survive a dropout |
| ✓ | every desktop file is byte-identical on the phone | 12/12 | Verify delivery |
| ✓ | no partial files left behind | 0 stragglers | Verify delivery |
Run your own relay
The desktop and mobile apps let you point at any relay, because the relay URL travels inside the pairing QR. Change it there and the devices meet at your address instead — no other setting, no rebuild.
This is deliberate. Content security never depended on the relay's honesty, so swapping in your own costs you nothing and gains you full control of the metadata surface.
Option A — your own Cloudflare deployment
The same code, on your account, in about ten minutes.
# 1. Fork or clone
git clone https://github.com/thewolffish/wolffish-relay.git
cd wolffish-relay && npm install
# 2. Point it at your own hostname
# edit wrangler.jsonc → "routes": [{ "pattern": "relay.example.com", "custom_domain": true }]
# (or delete "routes" and set "workers_dev": true to use a *.workers.dev address)
# 3. Deploy
npx wrangler login
npx wrangler deploy
# 4. Check it
curl https://relay.example.com/healthz # → okTo keep the retention guarantees, leave these exactly as they ship: observability.enabled: false, send_metrics: false, no storage bindings, and no Logpush job on the zone. If you deploy from CI instead of a laptop, the included GitHub Actions workflow needs only two secrets — a scoped API token and your account ID.
Option B — anywhere that runs Node
Nothing about the relay is Cloudflare-specific: it matches two sockets by name and forwards bytes. Here is the whole thing for a plain Node host, behind any TLS terminator you already run.
import { WebSocketServer } from 'ws' // npm i ws
const rooms = new Map() // rid → { host, guest } RAM only, never persisted
const RID = /^[0-9a-f]{64}$/
const MAX = 1024 * 1024
new WebSocketServer({ port: 8787 }).on('connection', (ws, req) => {
const url = new URL(req.url, 'http://x')
const rid = url.pathname.match(/^\/t\/([0-9a-f]{64})$/)?.[1]
const role = url.searchParams.get('role')
if (!rid || !RID.test(rid) || (role !== 'host' && role !== 'guest')) return ws.close(1008)
const room = rooms.get(rid) ?? {}
room[role]?.close(4000, 'replaced') // newest connection per role wins
room[role] = ws
rooms.set(rid, room)
const peer = () => (role === 'host' ? room.guest : room.host)
if (peer()?.readyState === 1) {
ws.send('{"t":"peer-present"}')
peer().send('{"t":"peer-present"}')
}
ws.on('message', (data, isBinary) => {
if (!isBinary) return data.toString() === 'ping' ? ws.send('pong') : ws.close(4400)
if (data.length > MAX) return ws.close(4413)
if (peer()?.readyState === 1) peer().send(data, { binary: true })
})
ws.on('close', () => {
if (room[role] === ws) delete room[role]
if (peer()?.readyState === 1) peer().send('{"t":"peer-gone"}')
if (!room.host && !room.guest) rooms.delete(rid) // forget everything
})
})That is the complete contract. Serve it over TLS (wss://), keep access logs off if you want the retention story to hold, and make sure your proxy does not buffer or time out WebSocket upgrades.
Verifying your relay
# health
curl https://relay.example.com/healthz
# two terminals: presence and forwarding
npx wscat -c "wss://relay.example.com/t/$(printf 'a%.0s' {1..64})?role=host"
npx wscat -c "wss://relay.example.com/t/$(printf 'a%.0s' {1..64})?role=guest"
# both should print {"t":"peer-present"} the moment the second connects
# or drive the whole tunnel against it, end to end
npm run playground -- --relay wss://relay.example.comThat last command runs the complete cycle in this document — pairing, handshake, ciphertext audit, intrusion probes, file transfer with an interruption, and byte-for-byte verification — against your own deployment, and writes you a report just like this one.