What we defend against — and what we don't
The protocol is designed against three attackers: a network observer between host and viewer, our own infrastructure acting as a passive relay, and a malicious party who acquires a leaked pairing code.
- Network observers see only DTLS-SCTP packets between two peers. There's no readable terminal stream at any hop, period.
- Our infrastructure sees only signaling messages: SDP offer/answer, ICE candidates, and the pairing code. Once the DataChannel forms, the server is out of the path — there is no opt-in to record session bytes.
- Leaked pairing codes are useless to anyone who isn't the host themselves. The viewer must sign in with the same Google account; mismatches return
403 identity_mismatch.
What we do not defend against: a compromised endpoint. If your laptop is rooted, the attacker has the shell — there's no cryptographic protocol that fixes that.
Two layers of credentials
Authentication is split into two layers, intentionally:
- HTTP layer. Google ID tokens authenticate the
/pairand/pair/claimendpoints. We validate the JWT against Google's JWKS, check theaud,exp, andemail_verifiedclaims, then issue a short-lived session JWT scoped to the pairing. - Signaling layer. The WebSocket to
/signal/<sessionId>is authenticated with our own session JWT (10-minute TTL, single-use binding). The signaler only passes SDP and ICE candidates — it never receives bytes from the DataChannel.
The pairing code itself is a 6-digit random integer generated with crypto.randomInt. It's bound to a Google sub the moment it's issued — claim attempts from a different account are rejected before they can attach a viewer to the session.
A 7-byte framing protocol on top of one DataChannel
Terminal data and control flow as a single binary stream over one ordered+reliable WebRTC DataChannel. Frames are 1 byte of type followed by an opaque payload — except RESIZE and PING/PONG, which use fixed-width integer payloads.
// Frame types — 1 byte type, payload follows.
0x00 OUTPUT host → viewer raw PTY stdout (UTF-8)
0x01 INPUT viewer → host raw keystroke bytes (UTF-8)
0x02 RESIZE both uint16 cols, uint16 rows (big-endian)
0x03 PING both uint32 nonce
0x04 PONG both uint32 nonce (echo of PING)
0x05 HELLO host → viewer JSON { role, ver, term } (first frame)
0x06 BYE both JSON { reason } (final frame)
// Example: viewer types "ls\n"
// → [0x01, 0x6C, 0x73, 0x0A] ; INPUT type + UTF-8 payload
// Example: host writes a 1 KB output chunk
// → [0x00, ...1024 bytes...] ; OUTPUT type + UTF-8 payload
// Backpressure
// Host pauses send when DataChannel bufferedAmount > 1 MiB,
// resumes < 256 KiB. Viewer batches OUTPUT bytes on a 16ms
// timer or 16 KB threshold to avoid main-thread saturation.Session JWT — 10 minutes, single-binding
The session JWT carries three claims that matter:
sub— the Google sub of the authenticated identity.sessionId— the specific pairing this token is for. Tokens from session A can't be replayed against session B.role—hostorviewer. The signaler enforces which messages each role is allowed to send.
The TTL is exp = iat + 600. We do not refresh — the entire handshake-to-DataChannel-open path is expected to complete inside that window. If it doesn't, the session is torn down and the user starts over. There is no rotation mechanism for an active session because there is no need: once the DataChannel is open, the JWT's only consumer (the signaler) is no longer in the loop.