security & protocol

The protocol, the JWT lifecycle, and what we do — and don't — store.

Security teams ask three questions: where does the data go, where do the tokens go, and what's logged. This page answers all three with code-level specifics. If anything's missing, file a GitHub issue.

WebRTC DTLS-SCTP7-byte framing10-min session JWT2-min pairing TTLIdentity-lockedNo payload logging
$npx share-terminal start
threat model

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.

identity

Two layers of credentials

Authentication is split into two layers, intentionally:

  • HTTP layer. Google ID tokens authenticate the /pair and /pair/claim endpoints. We validate the JWT against Google's JWKS, check the aud, exp, and email_verified claims, 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.

wire protocol

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.
jwt lifecycle

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 — host or viewer. 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.

get started

One command. One encrypted tunnel.

No global install. No SaaS dashboard. No account to provision — sign in with Google, pair, share.

$npx share-terminal start
140 KB CLINode ≥ 20macOS, Linux