End-to-End Encrypted Terminal Sharing: What the Relay Can Still Do After shell.online v0.16.1

End-to-End Encrypted Terminal Sharing: What the Relay Can Still Do After shell.online v0.16.1

Anthropic's September 2026 threat intelligence report has two cases with the same shape. In one, victims were sent to lookalike verification domains "whose reverse proxy relayed the real know your customer (KYC) flow", so they passed a genuine identity check while the operator captured the verified session "from the proxy relay in the middle". In the other, a fraudulent reseller sold discounted Claude access, and customer traffic was "silently proxied to a different AI model" while the reseller's tooling installed a credential harvester. In both, the damage came from a box in the middle that the victim assumed would pass traffic along unchanged.

Any product that offers end-to-end encrypted terminal sharing through a hosted relay owes its users a precise answer to the question those cases raise: what can the middle do? shell.online is that kind of product. The terminal stays on your machine, a Cloudflare relay carries the bytes, and a browser opens them. On September 17 we released v0.16.1, which takes replay off the relay's list. This post walks through that list as it stands in the source, including the parts that did not change.

One command, and what leaves the machine

shell claude

The CLI starts the agent in a PTY on your machine and prints a URL, a password, and a QR code. If you have not seen the basic flow, the introduction to the live terminal browser link covers it. The generated password is ten characters drawn from a 64-symbol URL-safe alphabet, which is 60 random bits. The URL ends in a #salt= fragment, and RFC 3986 section 3.5 is the reason that matters: a fragment is "dereferenced solely by the user agent", so the browser never sends it to the relay.

The CLI and the browser each derive the same AES-256-GCM key locally from the password and that salt, using PBKDF2-HMAC-SHA256 with 600,000 iterations. That is the same count the OWASP Password Storage Cheat Sheet recommends for PBKDF2-HMAC-SHA256. Every terminal frame is sealed before it reaches the relay and opened in the browser. The Go side lives in internal/e2ee/e2ee.go in the shell.online repository, the browser side in web/e2ee.ts, and a cross-language test vector pins the two together.

What end-to-end encrypted terminal sharing hides, and what the relay still sees

Terminal input, output, snapshots, and latency probes travel as authenticated ciphertext. The relay cannot read what the agent printed or what you typed.

It can see plenty else, and the end-to-end encryption page in the docs lists it without softening: connection IPs, timing, encrypted frame sizes, frame opcodes, the access mode, labels, lifecycle metadata, and the terminal grid dimensions, which ride in plaintext control messages so that every viewer renders the same PTY size. Traffic shape is visible too: ciphertext sizes and timing are enough to tell a busy agent from an idle prompt.

The gap that AES-GCM alone leaves open

AES-GCM is an AEAD construction in the sense of RFC 5116. Flip one bit of a frame and the tag fails. What a valid tag does not say is how many times the frame was delivered, or whether it arrived in order.

Until this release the envelope was version 1: a version byte, a 12-byte nonce, then ciphertext and tag, with the frame's opcode as the only associated data. That is 29 bytes of overhead per frame. Our security policy said out loud what followed from it, listing relay-side "dropping, delaying, and replaying of valid ciphertext" as documented protocol limits.

For output, a replay is cosmetic. For input it is not. An input frame carries keystrokes, and a captured frame containing the Enter that answered a confirmation prompt would authenticate just as well the second time it was handed to the CLI. With a coding agent that stops to ask whether it may proceed, that is the frame you least want delivered twice.

Envelope v2: direction, stream, and sequence inside the authenticated data

Envelope v2 adds three fields between the version byte and the nonce:

  • A direction byte, 1 for host to viewer and 2 for viewer to host. The receiver computes the expected direction from the opcode and rejects a mismatch.
  • An 8-byte stream identifier, chosen at random by each sender when its cipher is created.
  • An 8-byte big-endian sequence number. It starts at 1, zero is invalid, and sealing fails outright instead of wrapping when the counter is exhausted.

The opcode, version, direction, stream, and sequence together form the GCM associated data, so none of them can be edited without breaking the tag. Overhead rises from 29 bytes to 46, and the relay's frame-length checks accept either size.

After the tag verifies, the receiver runs the sequence through a 64-frame sliding window kept per stream. A higher number advances the window. A number up to 63 behind the newest is accepted once. A duplicate, or anything older than the window, is refused with "replayed or stale E2EE frame". IPsec ESP uses the same mechanism: RFC 4303 section 3.4.3 says a window size of 64 "is preferred and SHOULD be employed as the default". A window is used instead of strict ordering because, as the comment on acceptSequence puts it, concurrent output and snapshot writers can legitimately reorder a few frames. The test TestFrameSequenceRejectsReplayAndAllowsLimitedReordering seals three frames, opens them in the order third, first, second, then replays one and requires the open to fail.

In the browser a replayed frame raises a dedicated E2EEReplayError and is dropped. Any other failure to open a frame still means the key is wrong and brings back the password prompt, so the two cases do not get confused.

One compatibility detail. The v0.16.1 CLI opens only v2 envelopes. The browser still opens v1 frames so that a session started by an older CLI stays viewable, answers that CLI in v1, and remembers up to 4,096 recent nonces to refuse duplicates on its own side. The policy is explicit that those sessions "do not gain v2's sequence guarantee until their CLI is upgraded". Rerun the installer on any machine that still hosts sessions:

curl -fsSL https://shell.online/install | sh

Two decisions the relay no longer gets a vote on

The first is whether a link is encrypted. The relay sends the viewer a control message that includes an encrypted flag. In the standalone viewer, a link that carries a #salt= or #key= fragment is now treated as encrypted from the start, and if the relay reports it as plaintext, the viewer closes the socket and shows "The relay reported this encrypted link as plaintext. The connection was blocked." The comment in web/main.ts states the rule: "The URL fragment is the viewer's cryptographic intent."

The second is read-only.

shell --read-only claude

The relay rejects browser input on a read-only session. As of v0.16.1 the CLI makes the same check independently. In cmd/shell/session_unix.go, acceptsViewerInput discards both ordinary input and confirmed-EOF frames when the session was created read-only, before anything is written to the PTY. The security model page now titles the card "Read-only is enforced twice", and the security policy puts a bypass of either check in scope for reports.

What is still on the relay's list, and on yours

The relay can still drop or delay ciphertext. No envelope format changes that. What a relay can do to the ciphertext of a current session is slow it down or cut it off, and the local process keeps running either way.

The URL and the password together are a bearer credential. Anyone holding both can view the session and, unless it was started with --read-only, type with the permissions of the wrapped process. Replay protection stops a relay from repeating your keystrokes. It does nothing about a person you handed the credential to.

Sixty bits is a deliberate tradeoff for short, task-bound shares. For long-lived or sensitive sessions, set a longer password in the SHELL_ONLINE_E2EE_PASSWORD environment variable and send it separately from the URL. If a credential gets out, rotate it without restarting the process:

shell password rotate <id>

Rotation replaces the salt, password, and key, and disconnects current viewers. It changes future access only. It cannot erase output a viewer already received.

The same commit added a section to the security policy called "Hosted-service trust boundary", and it is worth reading in full in .github/SECURITY.md. The JavaScript served by the hosted web app is trusted while it is open, because it derives session keys. The installer verifies binaries against SHA256SUMS, but both come from the same release path and are not independently signed. In the policy's words: "That catches corruption, not a compromised publisher. Build tagged source when publisher independence matters." And --no-e2ee remains an explicit opt-out that keeps HTTPS/WSS on each hop while terminal contents pass through the relay in memory.

Put a live terminal behind an encrypted link

Run shell followed by any command and open the URL with its password from a phone or desktop browser. Frames are sealed on your machine, and as of v0.16.1 the CLI accepts each input frame at most once.

Try shell.online
About this article

Published by the Pilot Protocol team. Product claims are scoped to the availability labels and technical references linked in the article; deployment behavior can vary by version and environment.

How we publish · Suggest a correction · Technical references