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:
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.