agent-yes · engineering

End-to-end encryption for share links: even a hacked relay can't read your terminal

agent-yes share links now run an end-to-end-encrypted protocol. The signaling server that introduces your browser to your machine never sees a key — so even if it is fully compromised, it cannot read your terminal, type into your agents, or spawn new ones.

What a share link does

When you run ay serve --webrtc, your machine connects to a tiny Cloudflare signaling server and waits for a browser to open the printed link on agent-yes.com. The signaling server is only a matchmaker: it relays the WebRTC handshake (SDP + ICE) so the two sides can find each other, then your terminal traffic flows peer-to-peer over an encrypted WebRTC DataChannel — it never passes through the server. The room link looks like:

https://agent-yes.com/#<room>:e1.<secret>

The threat: "what if the matchmaker is hacked?"

Previously, the link's token did double duty — it was both the value the server used to match peers and the only shared secret. That meant the server saw the secret. A compromised signaling server could therefore impersonate a browser or man-in-the-middle the connection, and a breach would expose every user's token at once. The bar we want instead:

A fully compromised signaling server (or an active network attacker who can rewrite the relayed handshake) may slow you down or learn metadata — but must never read terminal I/O, inject input, spawn agents, or recover any key.

One secret, two derived keys (the HKDF split)

The link still carries one 32-byte secret S. We run it through HKDF-SHA256 to derive two unrelated values:

authToken = HKDF(S, "ay/ay-e2e-1/auth")   →  the ONLY value the server sees
e2e keys  = HKDF(S, "ay/ay-e2e-1/key/…")  →  never leave your machine or browser

HKDF is one-way, so from authToken the server cannot recover S or the encryption keys. The actual terminal frames are sealed with AES-256-GCM under keys the server has never seen. Because the link is pasted directly between the two endpoints, both sides already share S without the server ever being told it.

The subtle part nobody gets right: nonce reuse

AES-GCM is catastrophic if a (key, nonce) pair ever repeats — it leaks plaintext and lets an attacker forge messages. The naive design (one key per room plus a per-connection counter) reuses nonces across reconnects, host restarts, and multiple browsers in the same room. Our fix: derive the encryption keys per connection by folding the live DTLS handshake fingerprint into HKDF as the salt. Every session and every peer therefore gets fresh keys, so a counter that restarts at 0 is always paired with a never-before-used key. We also use directional keys (host→client and client→host are different keys), so the two senders never share a nonce space.

Binding to the real connection (anti-MITM), done right

We hash both peers' DTLS fingerprints — plus the handshake role and ICE ufrag — from the negotiated session, and use that hash as both the HKDF salt for the keys and the authenticated data on every frame. On top of that, the channel runs a mandatory, bidirectional key-confirmation handshake (each side sends a fresh challenge nonce and must see it echoed back) that has to complete in both directions before a single byte of terminal I/O is processed. If a relay sat in the middle, the fingerprints differ, the keys differ, confirmation fails, and the connection closes — there is no "connected but unverified" window and no silent fallback.

Replay, reordering, and forged aborts

The DataChannel is reliable and ordered, and we add a mandatory monotonic frame counter, 128-bit random request ids, and per-stream sequence numbers. So a captured frame can't be replayed to re-run a command, a stale "abort" can't cancel a fresh request, and a truncated stream can't masquerade as a complete response.

Fail closed, always

Encrypted links carry a version marker (#room:e1.<secret>). The grammar is strict: anything that looks like a marker but isn't exactly e1. + 64 hex is rejected, never quietly treated as a legacy link. Legacy plaintext is hard-disabled in the client, the host refuses to bridge a plaintext channel for an encrypted room, and on any version skew, missing marker, bad tag, fingerprint mismatch, or confirmation timeout we refuse rather than downgrade.

One implementation, no dependencies

The crypto is a single WebCrypto module shared by both ends — the browser imports it, and the host (running on Bun, which ships WebCrypto) bundles the exact same file. One implementation means the two sides can't drift apart, and there are no new dependencies. A test suite pins the key derivation, the frame format, and every fail-closed path.

What a hacked server gets — and doesn't

Before Now
Read your terminal I/O possible (sees the secret) no — keys never reach the server
Inject input / spawn agents possible no — frames it forges fail decryption
Harvest every user's secret in a breach yes (stores raw tokens) no — only one-way authTokens
DoS / see metadata (rooms, timing, IPs) yes still yes

What changed for you

Old share links rotate to a fresh encrypted room automatically on upgrade — re-open the new printed link (or rm ~/.agent-yes/.share-room to force a rotation). The signaling server got one small additive change (it pins a protocol version) but still never sees a key.

Honest limitations

End-to-end encryption protects the channel, not the capability: anyone you hand the full link to gets control, so treat it like an SSH key. The server can still deny service and observe metadata (which rooms exist, connection timing, IPs via ICE). The channel binding uses DTLS fingerprints from the SDP rather than a true RFC 5705 keying exporter (a limitation of the host's WebRTC stack today), and the secret still sits in your browser's local storage until it ages out. Forward secrecy / rekeying, and the separate codehost transport, are future work.