docs/companion-relay-design.md
Rama master · réplica de lectura del repositorio original.
# iTerm2 Companion Relay: Design
Status: implemented.
Scope: remote communication between a paired Mac and iPhone over the
internet, including pairing on networks that forbid peer-to-peer
traffic.
## Goal
A paired phone and Mac should be able to communicate whenever both
have internet access, with no requirement that they be on the same
network. Pairing must work on client-isolated networks (corporate
WiFi, hotels) and across networks (phone on cellular, Mac on
ethernet). If a network blocks outbound HTTPS to the relay, the
devices cannot reach each other; that case is out of scope.
## Non-goals
- **Peer-to-peer NAT traversal** (ICE/STUN hole punching). It isn't
worth the complexity here: the traffic is small (chat messages and
occasional ~100KB session tiles), and the main remote case (phone on
cellular) sits behind carrier-grade NAT where hole punching usually
fails anyway. If we ever want it, the relay's Durable Object can
serve as the ICE signaling channel; nothing here rules that out.
- **Background connectivity on the phone.** iOS kills our sockets when
the app is backgrounded; push notifications cover that case.
- **Trusting the relay.** The relay only ever sees ciphertext, and the
design must stay safe even if it doesn't. In particular, every
protection below must hold even when the Worker runs fully open
(`ATTEST_REQUIRED=false`; see Bring Your Own Relay). The quota and
admission machinery is therefore mandatory, not optional tuning.
## Background: what already exists
- **Security.** Every connection runs
Noise_XK_25519_ChaChaPoly_BLAKE2s. The QR pins the Mac's static
public key (`rs`), and the handshake prologue binds the pairing id
(`pid`). The phone's static key crosses the wire only encrypted; the
Mac's never crosses at all.
## Architecture
The relay is a public server that lets a phone and Mac find each
other and exchange encrypted frames when they are not on the same
network. Its main job is to *splice* their two connections, copying
frames between them, and it cannot read what it carries: all traffic
is end-to-end encrypted. But it is not a pure pipe. Before splicing a
connection it admits the client (checking that it is legitimate), and
throughout it enforces quotas and other limits to protect itself from
abuse. Those checks are detailed in later sections.
The parties:
- **The Mac and the iPhone:** a pair of devices that want to
communicate, whether or not they are already paired. All traffic
between them is end-to-end encrypted with Noise, so nothing on the
path, including the relay, can read or alter it.
- **The relay operator:** whoever runs the relay server. By default
this is the iTerm2 project's hosted instance, but anyone can run
their own (see Bring Your Own Relay). The operator sees connection
metadata (IPs, timing, volume) and can deny service, but cannot read
traffic or impersonate either device.
The relay is a Cloudflare Worker backed by one Durable Object (DO) per
pairing. Both devices connect outbound to it over WebSocket (wss), so
there are no inbound firewall holes to open on either end. They meet
in a *room* (one DO), which performs the admission checks and then
splices the two connections.
The relay is its own Worker deployment, kept separate from the
project's other services (see `companion-push-relay.md`) so that the
high-traffic, most-exposed splice path cannot reach their secrets if it
is compromised.
### The QR code
The Mac displays a QR that the phone scans to begin pairing. It encodes
an `iterm2://pair` URL with these fields:
- `v` — format version.
- `proto` — the Noise protocol name
(`Noise_XK_25519_ChaChaPoly_BLAKE2s`).
- `rs` — the Mac's static public key (base64url).
- `pid` — the pairing id (the random nonce mentioned above).
- `relay` — the relay's base URL (an HTTPS origin). Optional; if
absent, the phone uses the official default relay. This is how a
self-hoster points a pairing at their own relay.
Everything the phone needs to locate the room (`rs`, `pid`, `relay`)
and to authenticate the Mac end-to-end (`rs`, `proto`) comes from the
QR and nowhere else.
### Room names
Both devices must derive the same room name with no coordination, and
no one else should be able to guess it. The name is a hash of the
pairing's secret material, both halves delivered by the QR: `rs` (the
Mac's static public key) and `pid` (a random nonce the Mac chooses for
the pairing).
roomName = SHA256(canonical("iterm2-room", [rs, pid]))
`canonical` is a domain-separated, length-prefixed encoding: each
element is prefixed with its 4-byte length, behind the label
`"iterm2-room"`. This makes the `rs`/`pid` boundary unambiguous and
keeps the hash from ever colliding with another use of the same
inputs. Because `rs` only ever travels inside the QR, the room name is
unknowable to anyone who has not scanned it, which rules out room
squatting and probing.
The room name is sent to the Worker in a request header, never in the
URL path. A path like `/room/<name>` would record the pseudonym in
Cloudflare's edge request logs, which no operator setting can disable;
a header keeps it out. (The Worker needs the name before it can
address the DO, so it cannot wait for the first WebSocket message to
carry it.)
On the app side, the relay is reached from two directions:
- **Phone:** connects to the relay, authenticates, and once the room
splices it through, gets back a transport for the Noise channel to
run over.
- **Mac:** *parks* in the room, meaning it holds an open connection
there waiting for the phone to appear. The Mac parks during pairing
and whenever it is paired but currently disconnected.
The Noise handshake runs end-to-end through the splice on every
connection, so the relay only ever sees ciphertext. (A Noise handshake
also exposes each side's ephemeral public key in the clear, but those
are useless to the relay.)
Framing: one WebSocket binary message carries exactly one frame, since
the WebSocket layer already provides message boundaries. The Noise
channel's existing chunking of payloads larger than 64KB is unchanged.
The relay caps frame size itself, well below Cloudflare's 1 MiB
WebSocket message limit, before splicing.
## App Attest keys
To prove to the relay that a phone is running the real iTerm2 Buddy app
rather than a script abusing it, the phone uses Apple's App Attest.
Each pairing gets its own App Attest key. Over time a phone may pair
more than once (each re-pairing creates a new room on the relay), and
if one App Attest key were reused across those pairings, the relay
would see the same key id in each of that phone's rooms and could link
them as belonging to a single device. A per-pairing key prevents that.
The cost is one extra attestation per pairing and a small, predictable
amount of key churn, low enough to stay within the per-IP limits on
new keys (see Abuse).
## Credentials and gatekeeping
Every connection tells the relay its role, phone or Mac. The relay
needs this because the two roles are admitted differently and occupy
separate slots in the room (described later).
Admission is purely abuse prevention: it controls who may spend the
relay's bandwidth, never who may read the traffic. Confidentiality and
authenticity between the two devices come entirely from the end-to-end
Noise channel, which the relay cannot see into. A wide-open relay with
attestation disabled is therefore still safe for its users; it is only
cheaper to abuse.
The relay admits a connection differently depending on whether the
pairing already exists:
- **Before pairing,** there is no shared secret yet, so the phone
proves it is running the real iTerm2 Buddy app using App Attest. The Mac
cannot attest (App Attest is not available off the App Store) and
simply parks.
- **After pairing,** both devices share a secret established during
pairing, and each proves it holds that secret to rejoin the room.
### Pairing rooms (no shared secret yet)
App Attest lets the phone authorize itself without anything from the
Mac. Again, this is only an abuse gate, allowing a signed iTerm2 Buddy
install to spend relay resources while turning away a script; it says
nothing about who the phone is talking to.
1. The phone scans the QR and requests an attestation challenge (a
fresh 32-byte random nonce) from the relay. It refuses non-HTTPS
relay URLs and refuses redirects, and it reduces the `relay=` URL to
scheme, host, and port at parse time, rejecting any userinfo, path,
query, or fragment. (The phone builds its own endpoint paths from
that origin, so an embedded path or userinfo in the QR could only be
an attack.)
2. The phone generates its per-pairing key and attests, binding the
attestation to
`clientDataHash = SHA256(canonical("iterm2-relay-attest", [challenge, relayOrigin]))`,
where `relayOrigin` is the origin the phone believes it is talking
to. (The canonical, length-prefixed encoding makes the
challenge/origin boundary unambiguous.) The relay verifies the
attestation chain to Apple's root, checks the app ID, and confirms
that `relayOrigin` is its own. That last check closes a cross-relay
forwarding attack: a hostile QR pointing at `relay=evil.example`
could otherwise pass a valid challenge from the official relay
through the phone and spend the official instance's resources under
a victim device's attestation.
3. If verification succeeds, the relay issues the phone a pairing-room
ticket: a single-use, short-lived (minutes) token bound to the
attest key id and the room name. The phone presents this ticket when
it opens its WebSocket to the room. The ticket carries the result of
the HTTPS attestation over to the WebSocket connection, so the relay
can admit the phone to the splice without rerunning attestation
there, and it ties that admission to this one room.
4. The Mac parks in the room it named. The DO begins splicing only once
a ticketed phone arrives.
The tickets, challenges, and their single-use and expiry state all
live in the room's DO, which is already addressed by the room name.
They are deliberately not kept in Workers KV (Cloudflare's key-value
store), because KV is eventually consistent and cannot enforce
single-use. Outstanding challenges are capped in number and expire on
their own, so issuing challenges cannot be used to fill the DO's
storage.
### Pairing confirmation (the anti-hijack step)
Because the relay works from anywhere, the QR no longer has to be near
the Mac to be used. A photograph of it is enough for an attacker to
attempt pairing from anywhere, using a real phone and a valid
attestation (the attacker's phone is running the real iTerm2 Buddy too,
so attestation does not distinguish it), by racing the real phone into
the handshake. The mitigations below, all required, defend against it.
**Short pairing validity.** The QR (and its `pid`) expires after two
minutes of inactivity; the pairing window then regenerates it.
**Mac-side verification with a short authentication string (SAS).**
Completing the Noise handshake does not finalize pairing. Both ends
derive the same SAS from the Noise handshake hash, which commits to
both static keys, using a fixed label and a fixed length (6 decimal
digits). The user confirms it, and the UI is deliberately asymmetric:
the phone displays the SAS, and the Mac only accepts input. The user
reads the digits off the phone and types them into the Mac, which
compares them against its own derived SAS. The Mac must never display
the code itself: if it did, the user could read the Mac's number and
type it straight back, which always matches and defeats the check.
Typed entry (rather than a single Accept button, or a
pick-the-matching-code list) forces the user to engage with the actual
digits, which resists blind clicking. (A picker was considered and
rejected: its best case is still a 1-in-N chance for an attacker to
win by blind guessing.)
**The SAS commits to one handshake.** While the confirmation UI is up,
a new connection can take over the phone's slot in the room (the room
keeps only the newest connection in each role; see admission below):
an attacker displacing the real phone, or the real phone displacing an
attacker. The relay closes the displaced socket. On the Mac, the
replacement surfaces as a fatal error on the Noise channel, because the
new connection's bytes cannot decrypt against the handshake already in
progress, and an AEAD failure tears the channel down rather than being
skipped (see Reconnection). That voids the pending confirmation, which
was bound to one specific handshake hash; the Mac re-parks, and the
next phone produces a fresh handshake and a freshly derived SAS. The
phone that loses the race does not sit silently: it keeps showing its
own SAS along with guidance ("your Mac is showing a code; if it does
not match this, reject"), so a hijacker's SAS on the Mac will visibly
fail to match the code on the real phone in the user's hand.
**The phone blanks its SAS the instant its connection drops.** A
6-digit SAS is only about 20 bits. Without this rule, an attacker who
photographed the QR could reconnect over and over during the pairing
window, getting a fresh handshake hash (and so a fresh SAS) each time,
and keep trying until one happened to match the victim phone's
still-displayed, by then stale, code, which the user might then type
into the Mac. Clearing the phone's SAS the moment its connection dies
removes that target.
**The DO caps phone-slot turnover per room** at eight cycles
(`MAX_PAIRING_CYCLES`). The DO cannot see the end-to-end Noise
handshake, but it counts how many times the phone slot is taken while
the Mac is parked, and each new phone connection is one more handshake
attempt. The count resets on every fresh Mac park, and when it exceeds
eight the room is killed and the QR must regenerate. Counting per park
rather than per QR matters because a confirmed-but-not-yet-established
Mac re-parks in pairing mode without a QR (see the lifecycle section),
and each fresh park gets its own budget. Together with
blank-on-disconnect, this makes grinding through the roughly 2^20
possible SAS values hopeless even when attestation is off.
**Wrong SAS entries regenerate the QR.** The Mac accepts three typed
attempts; a code still wrong after that (the other visible sign of a
hijack, or just a typo) is treated as a failed pairing: the Mac voids
it and regenerates the pid, invalidating the photographed QR.
**Mac-to-phone accept/reject.** The SAS is checked only on the Mac, so
the phone cannot tell on its own whether it succeeded; without a signal
it would sit on the pairing screen forever, even after a success. So
once the typed code matches, the Mac sends an explicit "pairing
accepted" control message to the phone over the now-active Noise
channel. This is the first application message on the channel, and it
is the phone's cue to stop showing the SAS, move into the chat list,
and only then begin the work that turns the pairing into an
established room (the next section). The phone does none of that
beforehand. On a wrong code, an abort, or a timeout, the Mac instead
sends "pairing rejected" so the phone fails fast rather than waiting
out its own timeout. The phone keeps an independent pairing-window
timeout regardless (no accept means failure) and blanks its SAS on
disconnect or timeout. It can trust the accept because the channel is
end-to-end authenticated by the completed handshake: the message
provably came from the device the phone handshook with, and only the
phone whose code the user typed correctly on the Mac ever receives one.
The human's trust decision happened on the Mac.
**Either side can abort at any time.** Both the Mac's confirmation
screen and the phone's SAS screen show a Cancel control throughout the
pairing window. Aborting on the Mac sends "pairing rejected" and tears
down; aborting on the phone closes its connection, which the Mac sees
as its slot dying, voiding any pending confirmation. Either way nothing
is committed: the pairing is not finalized and the room is not
established, and the QR regenerates for a fresh attempt. No half-paired
state is left on either side.
The phone's confirmation screen also shows the relay host whenever
`relay=` differs from the official default, so a camera-scanned hostile
QR pointing at an attacker's relay is disclosed to the user. The host
is shown as ASCII/punycode, never its Unicode form, so a lookalike of
the official hostname cannot be made to read as legitimate.
### Established rooms
Once pairing is confirmed, the room becomes established, and from then
on either device can rejoin it without pairing again. Rejoining is
authenticated, and the credential is built so that the relay can check
a join but never forge one.
It all starts from the `roomSecret`: a 32-byte random value the phone
mints. From it, both devices independently derive the same Ed25519
signing keypair. (The private key seed is
`HKDF-SHA256(roomSecret, info = "relay-auth-ed25519")`; the label keeps
this key distinct from any other use of `roomSecret`.) The public half
of that keypair is the verifier, `V`.
The two halves are used in opposite places:
- The relay stores only `V`. To rejoin, a device signs a challenge from
the relay with its private signing key, and the relay checks the
signature against `V`. (The full join exchange is described below.)
- Because the relay holds only the public `V`, it can verify a join but
never produce one. A one-time relay compromise or storage dump leaks
only public keys and authorizes nothing, then or later.
(A symmetric key stored at the relay would be far worse: a single dump
would let an attacker occupy and displace devices in every room until
the key rotated, which in practice happens only on unpair. Storing only
`V` also closes a TLS-MITM concern, since even an enterprise root that
captured the registration traffic would see nothing but `V`.)
Establishing the room runs during the first confirmed session, in this
order:
1. The phone mints the 32-byte `roomSecret` (kept in the Keychain,
rotated on unpair) and derives the signing keypair.
2. The phone sends `roomSecret` to the Mac inside the Noise channel, as
a field of the connection-time status message (resent on every
connect, so it self-heals and re-keys automatically), and waits for
the Mac's in-channel ack. The Mac now derives the same keypair, so
both sides can sign joins.
3. Only after that ack does the phone register `V` with the relay, over
HTTPS (a separate connection from the splice), which flips the room
to established.
The order matters. Establishing a room permanently closes
pairing-mode admission, and the Mac can only join an established
room once it holds `roomSecret`. If the phone registered `V` first
and the session died before `roomSecret` reached the Mac, the room
would be established but the Mac would have no signing key: it could
not park, and the room would be stranded until the user re-paired.
Acking before registering avoids this. The reverse failure is
harmless: if the Mac acks but registration then fails, the room
simply stays in pairing mode and the phone retries on its next
connect.
This first registration is tied to the pairing session. At phone
admission the DO mints a one-time registration token; the phone
presents that token alongside `V`, and the DO accepts a room's first
`V` only with a valid, unused token. That bounds the open-mode race
over who gets to register `V` to the pairing room's lifetime, even
though registration does not ride the spliced connection. With
attestation enabled, the phone also proves it is the same device
that attested during pairing: it signs the relay's challenge with
its per-pairing App Attest key (an App Attest assertion), and the
relay checks that signature against the public key it recorded at
attestation, with a counter that must strictly increase to block
replay.
4. The relay accepts only the first registration of `V` for a room and
refuses any later one ("already registered"). This keeps
registration from becoming an overwrite channel: otherwise anyone
who learned the room name could register their own `V` and take over
the room. As a result, `V` cannot be replaced today, and the way to
get a new one is to re-pair.
The pairing-to-established switch happens atomically inside the DO, and
once it does, pairing-mode admission is closed for good: no second
ticketed phone can ever join.
From then on, every connection to the room, by either device, is
authenticated the same way: a signature challenge-response. The DO
sends a fresh nonce; the device replies with a signature over a
fixed-length transcript of (protocol version, role, nonce, room name,
relay origin), which the DO verifies against the stored `V`. Binding
the room name and origin into the signed bytes makes the signing key
useless on any other room or relay, even if some future change reused
it. Since the relay holds nothing that can authorize a join, even a
fully compromised or dumped relay cannot mint one, and `roomSecret`
never leaves the Noise channel between the two devices.
All signature and ticket comparisons on the relay are constant-time.
In short, the Mac's standing rests entirely on holding a secret that
only the confirmed phone could have minted and only the paired Mac
could have received.
### The confirmed-but-not-yet-established window
Between SAS confirmation and the phone registering `V`, the room is
confirmed but not yet established. Normally this window is tiny: the
phone registers `V` as soon as the Mac acks `roomSecret`, within the
same session. But if the session drops in between, the Mac is paired
yet has never seen the room established, and two things follow.
First, the Mac parks in pairing mode, because it has no established
room to park in yet. Pairing-mode rooms expire after a couple of
minutes, so the Mac does not hold one open indefinitely; it re-parks
opportunistically rather than keeping a permanent presence. Once the
phone registers `V` on a later connect, the room becomes established
and the Mac parks persistently as normal.
Second, this is the one moment a paired Mac sits in a room whose
admission still says "new pairings welcome," so the DO could splice
some other admitted connection to it (any ticketed phone under
attestation, or any peer in open mode). The Mac defends by pinning the
phone static key it recorded at confirmation: it rejects any Noise
handshake from a different static, and it never re-enters the
pairing/SAS UI, completing the handshake only with the already-confirmed
phone. (Noise XK provides the rejection; the requirement is just that
this Mac not treat a new static as a new pairing.)
### Deletion, revocation, and expiry
Unpairing, on either side, sends the relay an explicit delete-room
request and deletes the device's local `roomSecret`. The request is
authenticated: before discarding `roomSecret`, the device signs a fresh
DO-issued nonce with the room's signing key (the private half of `V`,
derived from `roomSecret`), exactly like a join. That keeps a captured
delete signature from being replayed, and because it is an ordinary
signature it works in open mode too. Without this authentication,
anyone who knew a room name could destroy rooms.
Delete is best-effort, though: the relay might be unreachable at
unpair, or the device wiped, or the app deleted. So established rooms
also carry a 30-day idle TTL. This is housekeeping for the operator,
not a security control: the retained record authorizes nothing, so the
TTL exists only to keep dead rooms from accumulating in storage. Every
authenticated connection (a device joining, or the Mac re-parking)
refreshes the room's last-activity stamp, so the TTL never reaps a room
still in use; only a room left untouched for 30 days is deleted. The
retained record is pseudonymous: the room name is a derived hash, `V`
is a public verifier, and the attest key id (when present) is opaque.
A device that comes back after its room was reaped simply re-pairs. Its
reconnect finds no verifier (its signed join is refused, or in open
mode the room is just empty), and the app surfaces a "remote access
needs re-pairing" prompt rather than failing silently. Re-pairing mints
a fresh pid and room, so a leaked-QR holder who squats the reaped room
in the meantime gains nothing: the real devices never join it (their
signing key does not match the squatter's verifier) and move to a new
room anyway.
## Pairing flow
This is the end-to-end sequence with the real requests and
cryptographic values. Participants: the **phone**, the **relay** (its
Worker and the room's DO), **Apple's App Attest service**, and the
**Mac**. Every HTTP and WebSocket request to the relay carries the room
name in an `x-relay-room` header (64 lowercase hex chars) and a fixed
User-Agent, and the relay rejects any request bearing an `Origin`
header (only browsers send one).
**0. The Mac sets up the room and parks.** It loads or generates its
Noise static keypair, mints a random `pid`, computes
`roomName = SHA256(canonical("iterm2-room", [rs, pid]))`, and opens a
`wss` connection to the relay to *park*: it runs the admission
handshake declaring itself the Mac, then holds the socket open waiting
for the phone.
```
Mac -> DO: Hello { v, role: "mac" }
DO -> Mac: Challenge { nonce }
Mac -> DO: Proof { } # a pairing-mode Mac is pre-auth:
# no ticket, no signature
DO -> Mac: Result { ok: true }
```
It then blocks reading the socket, waiting for the phone's first frame,
and displays the QR from "The QR code" (expires after two minutes of
inactivity, then regenerates).
(The DO sends a `Challenge` nonce on every admission, but only
established-room joins use it, where the proof is a signature over a
transcript binding the nonce. In pairing mode neither the Mac's empty
proof nor the phone's ticket consumes it; the DO sends it regardless so
an observer cannot tell from the reply whether a room is in pairing or
established mode.)
**1. The phone ingests the QR.** Either camera works, the system Camera
app or the in-app scanner, and a scan pairs directly. A pairing link
(`iterm2://pair?...`) opened from somewhere else, such as a webpage,
instead lands on a confirmation screen that shows the relay host first
(in punycode when it is not the official `companion-relay.iterm2.com`),
so a page cannot silently aim the phone at an attacker's relay. The
phone canonicalizes `relay=` to a bare https origin and stores `rs`,
`pid`, and that origin.
**2. The phone attests** (HTTPS, separate from the splice):
```
phone -> relay: POST /attest/challenge (x-relay-room: <roomName>)
relay -> phone: { challenge } # fresh 32-byte nonce, base64
phone: keyId = AppAttest.generateKey() # contacts Apple
cdh = SHA256(canonical("iterm2-relay-attest",
[challenge, relayOrigin]))
att = AppAttest.attestKey(keyId, cdh) # Apple signs it
phone -> relay: POST /attest { challenge, attestationObject: att }
relay: verify att chain to Apple root, app id, AAGUID environment,
and that cdh embeds the relay's own origin
relay -> phone: { ticket } # 24 random bytes
```
On success the DO stores the ticket (`ticket:<ticket>` -> keyId, 5-min
TTL) and returns a copy to the phone. The ticket is in effect a
single-use shared secret between the phone and this DO: the phone
presents it in step 3, and the DO checks it against the stored copy and
deletes it. The phone also pins `keyId` to this room so the step-6
assertion reuses the same attested key. (In open mode the relay answers
`/attest` with `400 attestation disabled` and the phone continues with
no ticket.)
**3. The phone joins the room** (WebSocket upgrade, room header),
running the admission handshake with the DO:
```
phone -> DO: Hello { v, role: "phone" }
DO -> phone: Challenge { nonce }
phone -> DO: Proof { ticket } # ticket from step 2
DO: look up ticket:<ticket>, check unexpired, delete it (single-use)
DO -> phone: Result { ok: true, registrationToken } # one-time, used in step 6
```
With both slots now filled, the DO starts splicing and from here copies
frames between the two sockets blindly.
**4. Noise XK handshake** (end-to-end through the splice). The phone is
the initiator, since it knows the Mac's static `rs` from the QR; the
prologue on both ends is `"iterm2-companion/v1/pid:" + pid`:
```
phone -> Mac: msg1: e
Mac -> phone: msg2: e, ee
phone -> Mac: msg3: s, se # phone's static, sent encrypted
```
The Mac's static is never transmitted (the phone already had it); the
phone's crosses only inside msg3's encryption. A wrong `rs` or `pid`
makes the two sides' handshake hashes diverge and the handshake fails
its MAC. After msg3 both hold the same transport keys and the same
handshake hash `h`.
**5. SAS confirmation.** Both sides derive
`SAS = HKDF(h, "iterm2-sas-v1")` reduced to 6 decimal digits. The phone
shows it; the user types it into the Mac, which compares it against its
own. On a match the Mac sends the first application message over the
now-encrypted channel:
```
Mac -> phone: {"pairing":"accepted"} # or {"pairing":"rejected"}
```
Nothing in step 6 happens before this arrives.
**6. The phone establishes the room.** Over the Noise channel the phone
sends `roomSecret` (32 random bytes) in its connection-time status
message and waits for the Mac's in-channel ack, so both derive the join
keypair: a private signing key and its public verifier `V`. Only then
does the phone register `V`. Under attestation it must also prove it is
the same device that attested in step 2, so it fetches a fresh
challenge and has its Secure Enclave produce an App Attest assertion
(which signs the challenge and carries a monotonic counter):
```
phone -> relay: POST /attest/challenge ; { challenge } # fresh, for the assertion
phone: assertion = AppAttest.generateAssertion(keyId,
SHA256(canonical("iterm2-relay-attest", [challenge, relayOrigin])))
phone -> relay: POST /register {
registrationToken, # from step 3
verifier: V,
challenge, assertion # both omitted in open mode
}
relay: token unused?; verify the assertion against the key attested in
step 2; counter strictly greater than the last one seen
relay -> phone: ok # room now established
```
The room flips to established atomically, and pairing-mode admission
closes for good.
Nothing here requires the two devices to share a network. Physical
proximity matters only for showing the QR to the phone's camera and
reading the SAS off the phone; everything else runs through the relay
from anywhere.
## Reconnection
After pairing, either device reconnects on its own. The Mac parks in
the established room whenever it is paired but disconnected, retrying by
itself if the connection drops; the phone reconnects on app open or
after losing its connection. Both authenticate the same way, as
established-room joins: each signs the DO's challenge with its private
signing key (`signingKey`, the private half of `V`, derived from
`roomSecret` during pairing), and the DO verifies the signature against
the stored `V`. There is no SAS after the first pairing, since both
static keys were pinned then.
A reconnect (here, the Mac parking) looks like:
```
Mac -> DO: Hello { v, role: "mac" }
DO -> Mac: Challenge { nonce }
Mac: sig = Sign(signingKey,
canonical("iterm2-relay-join",
[v, "mac", nonce, roomName, relayOrigin]))
Mac -> DO: Proof { sig }
DO: verify sig against the stored V; refresh last-activity
DO -> Mac: Result { ok: true }
```
The phone's join is identical with `role: "phone"`. Binding the role,
room name, and origin into the signed transcript keeps a captured
signature from being replayed into a different role, room, or relay,
and binding the fresh `nonce` keeps it from being replayed at all. Once
both slots are filled the DO splices, and the two run a fresh Noise XK
handshake (step 4 of Pairing flow), after which the channel is live. No
SAS, no accept/reject.
**Hibernation keeps a parked Mac nearly free.** Cloudflare evicts the
DO from memory while the WebSocket stays open, and billing accrues only
while frames actually flow. Parked connections still need keepalive
pings to survive NAT timeouts; these are answered by the hibernation
API's auto-response, so they never wake the DO or bill duration. This is
what makes an always-parked Mac affordable.
**AEAD failure is fatal.** The relay can drop, reorder, or replay
frames; with Noise's counter-based nonces, any of those shows up as an
AEAD decryption failure after the handshake. A single such failure
tears the transport down completely and forces a fresh handshake,
rather than skipping the offending frame. That turns any relay
frame-tampering into a clean reconnect instead of undefined behavior.
(The rule is stated explicitly because the lenient default would be to
skip and continue.)
## DO admission and connection hygiene
These controls run in every configuration, including a fully open relay
with attestation off, so they are load-bearing, not tuning. Some are
cost-motivated; the strategic view of cost and abuse is in Abuse and
cost control below.
**Admission is the first traffic, and it is bounded.** The
Hello/Challenge/Proof/Result exchange must complete before anything is
spliced; pre-auth bytes are never buffered or forwarded. Each pre-auth
control frame is capped at 8KB, the exchange has a 15-second deadline,
and a violation closes the socket silently. A room holds at most 4
simultaneous pre-auth sockets; when a fifth arrives the oldest is
dropped (close 1008), bounding per-room memory and wake cost during the
deadline window. The Hello carries a protocol version byte, so a future
admission change can never be mistaken for v1.
**The first reply is uniform, to avoid a mode oracle.** A naive DO
would answer differently for a pairing-mode, established, or absent room
(different challenge types), letting anyone who knows a room name probe
its state. Instead the DO always replies to Hello with a generic
`Challenge { nonce }` and branches only on what the client then
presents (a ticket or a signature). This matters little, since only
someone who already holds the unguessable name can reach it, but it
costs nothing to close.
**WS upgrades carrying an `Origin` header are rejected.** Browsers
attach `Origin` to every WebSocket upgrade and there is no CORS gate on
WS, so any webpage could otherwise conscript its visitors' browsers
into burning the relay's daily request cap against guessed room names,
from residential IPs that per-IP limits never catch. Native clients
send no `Origin`, so one header check removes the entire in-browser
attack surface for free.
**Two slots per room, one per role; newest wins, but displacement is
auth-gated.** Each room has exactly one phone slot and one Mac slot.
When a second connection arrives for a slot that is already taken, the
newer one wins, but only if it presents the credential that slot
requires, checked by the DO before any splice: a valid join signature
for an established room, or a valid pairing ticket for a pairing room.
(It has to be a DO-checkable, pre-splice credential. The DO is blind to
the end-to-end Noise handshake, so it cannot gate displacement on that:
a challenger cannot handshake until it is spliced, and cannot be
spliced until it displaces.)
- *Established room, phone slot:* the join signature is checked by the
DO before splicing, so displacement gates on it directly. A new
connection takes the slot only after verifying against `V`.
- *Pairing room, phone slot:* the DO gates displacement on the pairing
ticket (also DO-checkable, checked before splicing). Under
attestation this preserves the intent: only another attested,
ticketed phone can disturb a mid-pairing victim, never a random
pre-auth socket. In open mode there is no ticket, so it degrades to
displace-on-connect, which the per-park cycle cap and SAS voiding
already bound.
- *Mac slot (always pre-auth, since the Mac cannot attest):* the newest
Mac-role parker wins. First-wins would be worse: the real Mac's
silently-dead socket (NAT timeouts can lag WS close detection by
minutes) would block the real Mac from reclaiming its slot for the
rest of the window. Newest-wins is self-healing, since the real Mac
just re-parks. A QR-photo squatter flapping the Mac slot is only a
nuisance: it lacks the Mac's static private key, so the phone's XK
handshake against it fails and the attempt burns a cycle against the
kill cap.
The dangerous shortcut would be to replace a slot on connect
unconditionally; the phone-slot rules above must gate it. A
legitimately displaced device simply reconnects.
**A per-room failed-auth limiter.** A room accepts at most 40 admission
attempts per 10-second window (counted in DO memory, reset on
hibernation). This bounds someone who knows the room name from grinding
auth attempts against an established room, each of which wakes the DO
and bills a request. The Worker may also run advisory per-IP and
per-room buckets ahead of the DO, with the per-isolate caveat below.
**Strict room-name validation ahead of the DO.** The Worker checks the
room header against `^[0-9a-f]{64}$` before addressing the DO, so a
malformed name never reaches DO storage, cardinality, or error paths.
**Expiry.** Pairing-mode state (challenges, tickets, registration
tokens) expires after 5 minutes, so a pairing that never completes
leaves nothing behind and the DO hibernates; there is no long-lived
pairing room. Established rooms carry the 30-day idle TTL described
earlier: kept alive by use, reaped only when unused, removed
immediately by an explicit delete-room at unpair.
## What Cloudflare can and cannot know
**Knows:** both devices' IPs while they are connected, the room
pseudonym, traffic volume and timing, the App Attest key ids (one per
pairing), and the public verifier `V` (which authorizes nothing). The
room pseudonym is stable for the life of the pairing, so over time the
operator can build a per-pairing history of IPs and activity. Rotating
the pseudonym on a schedule (epoch-based names) was considered but
deferred.
**Cannot know:** message plaintext, session images, either device's
static identity key, the raw `roomSecret`, or the `pid` behind the
pseudonym.
**Cannot do:** impersonate either device, splice in a third party (the
Noise handshake fails), originate traffic, or mint a join (it holds
only `V`, and the signed transcript also binds the room and origin).
**Can always do:** deny service, globally or for a specific room, and
observe each room's traffic metadata. That is inherent to any relay;
BYO relay is the remedy for anyone who will not accept it.
Separately, Apple sees pairing cadence: `generateKey` and `attest`
contact Apple from the device, so one key generation per pairing is
visible to Apple (device, app id, timing). That is the cost of the
per-pairing keys. The optional fraud-assessment receipts (Abuse layer
3) would send Apple more.
### Logging and retention commitments (hosted instance)
These describe what the operator controls. Cloudflare's edge keeps its
own connection metadata regardless, which no operator setting can
disable, so the claim is "we don't log," not "nothing is seen." BYO
relay is the remedy for anyone who needs more.
- No operator request logging: Workers logs, tail, and Logpush stay
off, and the operator persists no IPs anywhere.
- The abuse counters (byte and frame quotas) are coarse, time-bucketed,
auto-expiring, and keyed by room pseudonym, never by IP.
- An established room's record (its pseudonym, public verifier, and
opaque attest key id) is deleted on unpair, and otherwise reaped by
the 30-day idle TTL once the pairing goes unused, so an abandoned
pairing's record does not persist indefinitely. No IP or content is
ever part of it.
- Traffic timing and volume are inherent to relaying; no padding or
cover traffic is added (any future ~100ms coalescing done for cost
reasons would help here only incidentally).
- Both clients pin their HTTP User-Agent to a fixed string.
URLSession's default User-Agent would otherwise leak the app version
and OS build to the relay and Cloudflare's edge logs.
- This covers the Worker's own behavior only. Cloudflare operates the
underlying platform (TLS termination, edge routing) and may log at
those layers entirely outside any operator's control, a self-hoster's
included; BYO relay changes who runs the Worker but still runs on
Cloudflare, so it does not escape that.
## What each party stores
This consolidates what each party keeps at rest. The design above is
the source of truth; this is a quick reference.
- **Phone.** Keychain (this-device-only, non-synchronizable): the
phone's Noise static private key, the per-pairing App Attest key (in
the Secure Enclave), and `roomSecret`. UserDefaults (non-secret): the
paired `pid`, the Mac's Noise static public key `rs` (from the QR),
and the relay URL. The join signing keypair is derived from
`roomSecret` on demand, not stored.
- **Mac.** Keychain: the Mac's Noise static private key and
`roomSecret`. User defaults: the paired `pid`, the phone's Noise
static public key (pinned at pairing), and the relay URL (an advanced
setting). The existing chat database holds chats, messages, and icons.
The join keypair is again derived from `roomSecret`.
- **Relay room DO.** The room name, the public verifier `V`, the attest
key id, and coarse byte/frame counters keyed by room name. It never
stores plaintext, session images, any static key, the raw
`roomSecret`, or the `pid`.
The asymmetry to notice: the room relay stores only public or
non-authorizing values, so a full dump of it reveals pseudonyms, public
keys, key ids, and traffic counts, and authorizes nothing, then or
later.
### If a location is compromised
- **Relay (dump or full takeover):** pseudonyms, public verifiers, key
ids, IPs, and traffic metadata. It cannot read messages, cannot mint
a join (only `V` is stored), and cannot impersonate either device; it
can deny service and observe metadata.
- **Phone Keychain:** the full pairing, the Noise static, `roomSecret`
(hence the join key), and the attest key. This is the trusted device;
this-device-only, non-synchronizable storage confines it to the
physical phone (migration means re-pairing).
- **Mac:** `roomSecret`, the Mac static, and the chat history. It is the
peer the phone talks to and is expected to be trusted.
- **Photographed QR (no device):** `rs`, `pid`, and the relay URL,
which enable a remote pairing attempt, defeated by Mac-side SAS
confirmation, the QR's short validity, and the per-room handshake cap.
## Abuse and cost control
The relay is a generic ciphertext pipe with an open-source protocol,
so it has to assume hostile clients. The defenses are layered. Layers 1
and 2, plus the admission hygiene above, apply in every configuration;
attestation (layer 3) hardens the hosted instance on top; and BYO relay
(layer 4) is the escape valve.
1. **Lean on the free tier.** Cloudflare's free plan has hard daily
caps rather than overage billing, so the worst case under attack is
"remote access unavailable until the caps reset," never a surprise
bill. Moving to the paid plan is a deliberate operator decision
later. The operator also enables Cloudflare's daily-cap alerts
(which need no request logging), so a sustained exhaustion attack is
distinguishable from "nobody is using remote access."
2. **DO-enforced quotas, sized so normal use never notices but
tunneling is worthless.** Each room has a daily byte quota (512MB by
default, tunable); exceeding it tears the room down for the rest of
the 24-hour window. There are also frame caps, 500 frames per second
and 256KB per frame, enforced before splicing: a flood of tiny
frames is a CPU and billing attack that a byte quota alone misses,
since many small messages can bill as one request-equivalent. The
per-room counters double as the abuse signal.
3. **App Attest on the hosted instance** (`ATTEST_REQUIRED`): joining a
pairing room and registering a verifier both require App Attest
proof from a signed iTerm2 Buddy install. Several limits keep it
from being trusted too far:
- `ATTEST_REQUIRED` fails closed. Anything other than the exact
string `false` means attestation is required, so one botched
config edit cannot silently open the relay. Only an explicit
`false` disables it (the BYO path).
- The verifier checks the attestation's AAGUID, the field that
identifies which App Attest environment produced it, and on the
production instance rejects development-environment attestations
(`appattestdevelop`), accepting only production (`appattest`).
Skipping this would let any dev-signed build of the app pass during
the window Apple allows development attestations, a classic App
Attest mistake. Self-hosted instances configure which environments
they accept.
- Attestation proves the install is the signed iTerm2 Buddy app on a
real Apple device, not that its operator is friendly, and an
attest key id is not a stable device identity: `generateKey` can
be called any number of times, so an attacker running a signed
install can mint a fresh key id per request and walk through any
key-id-keyed quota. The real backstop is the per-IP rate limit on
the attestation endpoint (each new key costs an `/attest` call),
with the WS upgrade limited per IP as well. Apple's
attestation-receipt fraud assessment, which scores per-device
key-generation count, could tighten this further but is not
implemented.
Per-IP limiting is coarse either way: it over-blocks many phones
behind one carrier-grade NAT address and under-blocks one phone
rotating addresses, so it is never trusted alone.
4. **Bring your own relay.** Anyone can `wrangler deploy` the same
Worker to their own free account (hard-capped, $0) and point the
Mac's relay setting at it; the QR's `relay=` carries it to the
phone, so a self-hoster configures exactly one thing on one device.
Forks sign with their own team and cannot attest against the
official app id, so BYO relay with `ATTEST_REQUIRED=false` is the
supported path for them, not just a capacity escape valve. Each
pairing remembers which relay it lives on, so mixed setups stay
coherent.
## Implementation notes
- The per-key-id assertion counter can race against itself when the
phone reconnects rapidly: two assertions are in flight and the
later-signed one is verified first. A counter rejection should be
retried with a fresh assertion, not treated as an attack signal.
- App Attest keys cannot be deleted (Apple exposes no API for it), so
per-pairing keys leave orphaned Secure Enclave material behind across
re-pairs. This is harmless (each key is unusable once its Keychain
key id is gone), but worth knowing so it is not mistaken for a leak.