diff --git a/README.md b/README.md index bfe085a..59e847e 100644 --- a/README.md +++ b/README.md @@ -1,96 +1,164 @@ +
+ +telecode + # telecode **Launch, watch, and steer Claude Code agents on your own machine — from any browser.** -telecode is an open-source, self-hostable command center for coding agents. The agents run on _your_ -computer, where your code already is; a responsive web app (an installable PWA — no native app) lets you -launch them, watch them work, and approve each consequential action from your phone or another laptop. -Session content is end-to-end encrypted, so the server in the middle only ever forwards ciphertext. +The agents run on _your_ computer, where your code already is. A responsive web app — an installable PWA, +no native app — lets you launch them, watch them work, and approve each consequential action from your +phone or another laptop. Session content is **end-to-end encrypted**, so the server in the middle only +ever forwards ciphertext. + +[![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-e8a33d?style=flat-square)](LICENSE) +[![npm: @telecode/cli](https://img.shields.io/npm/v/@telecode/cli?style=flat-square&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/@telecode/cli) +[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?style=flat-square&logo=typescript&logoColor=white)](packages/protocol) +[![SvelteKit](https://img.shields.io/badge/SvelteKit-PWA-FF3E00?style=flat-square&logo=svelte&logoColor=white)](apps/web) +[![Fastify](https://img.shields.io/badge/Fastify-ws_relay-000000?style=flat-square&logo=fastify&logoColor=white)](apps/relay) +[![Claude Agent SDK](https://img.shields.io/badge/Claude-Agent_SDK-D97757?style=flat-square&logo=anthropic&logoColor=white)](packages/daemon) +[![End-to-end encrypted](https://img.shields.io/badge/end--to--end-encrypted-56a585?style=flat-square)](docs/end-to-end-encryption.md) + +[Why](#why-its-built-this-way) · +[Architecture](#architecture) · +[How it connects](#how-it-connects) · +[Quick start](#quick-start) · +[Security](#security--privacy) · +[Docs](#documentation) + +
+ +--- ## Why it's built this way -- **Execution stays on your machine.** Agents run locally, with your tools and credentials. There is no - cloud execution — that's a product promise, not a detail. -- **Outbound-only.** Both your machine (the daemon) and your browser dial _out_ to a relay; nothing ever - reaches _into_ your machine. No ports to open, no inbound access. -- **End-to-end encrypted.** Prompts, output, diffs, and transcripts are encrypted in the browser and the - daemon. The relay sees only routing metadata (see the [threat model](docs/threat-model.md)). -- **You hold the gate.** Every consequential tool call pauses for your approval before it runs. -- **Open and self-hostable.** Run the whole thing yourself; the relay is the only piece that could live - elsewhere, and even then it sees only ciphertext. +Remote-control tools for coding agents usually run the agent **in the cloud** and ask you to trust a +closed service with your code and your keys. Telecode inverts that: the agent runs **where your code +already is — your machine** — and the network in the middle is reduced to a dumb, blind courier. -## How it fits together +| Principle | What it means | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| **Execution stays on your machine** | Agents run locally, with your tools and credentials. There is no cloud execution — a product promise, not a detail. | +| **Outbound-only** | Both your machine (the daemon) and your browser dial _out_ to a relay; nothing ever reaches _into_ your machine. No ports to open. | +| **End-to-end encrypted** | Prompts, output, diffs, and transcripts are encrypted in the browser and the daemon. The relay sees only routing metadata. | +| **You hold the gate** | Every consequential tool call pauses for your approval before it runs. | +| **Open & self-hostable** | Run the whole thing yourself; the relay is the only piece that could live elsewhere, and even then it sees only ciphertext. | -| Part | Role | -| ---------- | ---------------------------------------------------------------------------------------------- | -| **Daemon** | Runs on your machine via the Claude Agent SDK; spawns and supervises agent sessions. | -| **Relay** | A stateless multiplexer + device/session registry. Forwards ciphertext; never runs agents. | -| **Web** | A SvelteKit PWA: launch sessions, watch the stream, approve tool calls, steer with follow-ups. | +## Architecture + +A small TypeScript monorepo: a SvelteKit PWA and a Claude-Agent-SDK daemon talk through a thin Fastify + +`ws` relay, over one shared, zod-validated wire contract. ```mermaid flowchart LR - subgraph machine["Your machine"] - D["Daemon
(Claude Agent SDK)"] + subgraph machine["💻 Your machine"] + D["Daemon
Claude Agent SDK"] A["Agent session
your code · tools · keys"] D -->|"approval gate"| A end - subgraph cloud["Relay — runs anywhere"] + subgraph cloud["☁ Relay — runs anywhere"] R["Multiplexer
ciphertext + routing only"] end - B["Browser / PWA
encrypts + decrypts"] + B["🌐 Browser / PWA
encrypts + decrypts"] B -->|"dials out · WSS"| R D -->|"dials out · WSS"| R - R -.->|"end-to-end encrypted"| B - R -.->|"end-to-end encrypted"| D + R -.->|"🔒 end-to-end encrypted"| B + R -.->|"🔒 end-to-end encrypted"| D + + classDef ends fill:#3b2f10,stroke:#e8a33d,stroke-width:2px,color:#fff; + class B,D,A ends ``` -Both the browser and the daemon **dial out** to the relay — nothing reaches into your machine. The relay -multiplexes encrypted frames between them and can't read any of it; agent work, your code, and your keys -never leave your machine. +| Layer | Package / app | What it owns | +| ------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| **Daemon** | `packages/daemon` → `npx @telecode/cli` | Runs on your machine via the Claude Agent SDK; spawns and supervises agent sessions behind the human-in-the-loop approval gate. | +| **Relay** | `apps/relay` · Fastify + `ws` | A stateless multiplexer + device/session registry. Forwards ciphertext, routes by `(user, device)`, never runs agents. | +| **Web** | `apps/web` · SvelteKit PWA | Launch sessions, watch the stream, approve tool calls, steer with follow-ups — from a phone or laptop. | +| **Protocol** | `packages/protocol` | The shared wire contract: zod schemas (one validated `Envelope`) + the WebCrypto E2E helpers. | +| **UI** | `packages/ui` | The shared design system — dark-first tokens + primitives. | + +## How it connects + +Your laptop sits behind a router with no open ports, so both ends **dial out** to the relay, which only +ever matches a browser to a daemon belonging to the **same user and device**. Two questions matter, and +each has a dedicated, plain-language page: + +- **How do we know it's _you_?** You sign in (GitHub OAuth, server-side session), and your machine is + paired with the OAuth Device Grant — where the device is bound to your account **server-side**, so the + client can never claim someone else's. → **[Connecting your machine](docs/connecting-your-machine.md)** +- **How is the content kept private?** A per-session key is exchanged via X25519 ECDH so only your browser + and daemon can read the session; the relay forwards sealed AES-256-GCM frames it has no key to open. → + **[End-to-end encryption](docs/end-to-end-encryption.md)** ## Quick start -1. **Run the daemon** on the machine you want to control — it prints a pairing code. _(The published - `telecode` command is on the way; until then, run it from a clone with `make run` — see - [Getting started](docs/getting-started.md).)_ -2. **Open the web app**, sign in, and enter the pairing code to bind the machine to your account. +On the machine you want to control: + +```sh +# one-line install (checks Node 22+, installs the telecode command) +curl -fsSL https://telecode.io/install.sh | bash + +# …or run it directly with npm +npx @telecode/cli +``` + +Then: + +1. The daemon prints a **pairing code**. +2. Open the web app, **sign in**, and enter the code to bind this machine to your account. 3. **Launch a session**, then approve actions as they come and steer with follow-up messages. -Check a machine's setup any time with `telecode doctor` (Node version, API key, pairing, relay +Check a machine's setup any time with **`telecode doctor`** (Node version, API key, pairing, relay reachability). → Full walkthrough: **[docs/getting-started.md](docs/getting-started.md)** +## Security & privacy + +Telecode's trust model is the product. The three pages below explain it end to end, in plain language and +with diagrams: + +- **[End-to-end encryption](docs/end-to-end-encryption.md)** — the keys, the handshake, and a message's + round trip; the relay only ever holds ciphertext. +- **[Connecting your machine](docs/connecting-your-machine.md)** — outbound-only connections, sign-in + identity, and how pairing binds a machine to exactly your account. +- **[Threat model](docs/threat-model.md)** — what each part can and cannot see, the approval gate, and how + to verify it yourself. telecode also **[collects nothing by default](docs/telemetry.md)**. + +Found a vulnerability? Please report it privately — see the [Security Policy](SECURITY.md). + ## Documentation -- [Getting started](docs/getting-started.md) — install, pair, and run your first session. -- [Reconnecting & offline behavior](docs/reconnect-and-offline.md) — what happens on reload, network - drops, and laptop sleep. -- [Self-hosting the relay](docs/self-hosting.md) — run your own relay with Docker. -- [Threat model](docs/threat-model.md) — what each part can and cannot see, and how to verify it. -- [Telemetry & privacy](docs/telemetry.md) — telecode collects nothing by default. -- [Publishing the CLI](docs/publishing.md) — maintainer runbook for shipping the `telecode` command. +Full docs live in **[docs/](docs/README.md)** — start there for the index. + +| Doc | Read it for | +| ---------------------------------------------------------- | ------------------------------------------------------- | +| **[Getting started](docs/getting-started.md)** | Install, pair, and run your first session | +| [Connecting your machine](docs/connecting-your-machine.md) | Secure connection + how we know it's you (diagrams) | +| [End-to-end encryption](docs/end-to-end-encryption.md) | How the relay only ever sees ciphertext (diagrams) | +| [Threat model](docs/threat-model.md) | What each part can and cannot see, and how to verify it | +| [Reconnecting & offline](docs/reconnect-and-offline.md) | What happens on reload, network drops, and sleep | +| [Self-hosting the relay](docs/self-hosting.md) | Run your own relay with Docker | +| [Deploying to Azure](docs/deploy-azure.md) | Production runbook (Container Apps + Supabase) | ## Contributing Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for setup and the change bar, and the -[Code of Conduct](CODE_OF_CONDUCT.md). Found a security issue? Please report it privately per the -[Security Policy](SECURITY.md). Release history lives in the [CHANGELOG](CHANGELOG.md). +[Code of Conduct](CODE_OF_CONDUCT.md). Release history lives in the [CHANGELOG](CHANGELOG.md). ## Development -telecode is a TypeScript monorepo (pnpm workspaces + Turborepo): a SvelteKit web app, a Fastify + `ws` -relay, a Claude Agent SDK daemon, and a shared protocol/crypto package. +Telecode is a TypeScript monorepo (pnpm workspaces + Turborepo). From a clone: ```sh make setup # install workspace dependencies -make run # start the relay, a local daemon, and the web app +make run # start the relay, a local daemon, and the web app together make test # run the test suites make stop # stop the local stack ``` ## License -telecode is free software, licensed under the **GNU Affero General Public License v3.0**. See +Telecode is free software, licensed under the **GNU Affero General Public License v3.0**. See [LICENSE](LICENSE). diff --git a/docs/README.md b/docs/README.md index 13e7c8e..b61f7e5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,18 +1,43 @@ # telecode documentation -Start with the project [README](../README.md) for what telecode is and why. These guides go deeper: - -- **[Getting started](./getting-started.md)** — install the daemon, pair a machine, and run your first - session (with `telecode doctor` for preflight). -- **[Reconnecting & offline behavior](./reconnect-and-offline.md)** — why reopen is a reconnect, how - reconnect and laptop sleep/wake are handled, and what doesn't survive a powered-off machine. -- **[Self-hosting the relay](./self-hosting.md)** — run your own relay with Docker so even routing metadata - stays with you. -- **[Deploying to Azure](./deploy-azure.md)** — production runbook: the web app + relay on Azure Container - Apps (Bicep IaC + CI/CD), with Postgres on managed Supabase. -- **[Threat model](./threat-model.md)** — exactly what each part can and cannot see, how the end-to-end - encryption works, and how to verify the relay only sees ciphertext. -- **[Telemetry & privacy](./telemetry.md)** — telecode collects nothing by default; what the self-host-only +Telecode is an open-source, self-hostable command center for Claude Code agents: the agents run on **your** +machine, and you launch, watch, and steer them from any browser — with session content **end-to-end +encrypted**, so the relay in the middle only ever forwards ciphertext. + +Start with the [project README](../README.md) for the one-paragraph overview and install steps. These +pages go deeper. Each is self-contained and cross-links the others where concepts meet; the diagrams are +authored in **Mermaid** and render on GitHub. + +## Use it + +- **[Getting started](getting-started.md)** — install the daemon, pair a machine, and run your first + session, with `telecode doctor` for preflight. +- **[Reconnecting & offline behavior](reconnect-and-offline.md)** — why reopening is a _reconnect_ (never + a restart), how network drops and laptop sleep/wake are handled, and what a powered-off machine can't do. + +## Understand it + +- **[Connecting your machine](connecting-your-machine.md)** — how your browser reaches a laptop behind a + router with no open ports, and how telecode knows a paired machine belongs to _exactly_ you (sign-in + identity + the device-pairing flow). Plain language, with diagrams. +- **[End-to-end encryption](end-to-end-encryption.md)** — how the relay only ever sees ciphertext: the + keys, the handshake, and a message's round trip — built on X25519 · ECDH · HKDF · AES-256-GCM. Plain + language, with diagrams. +- **[Threat model](threat-model.md)** — the adversary's-eye view: what each part _can_ and _cannot_ see, + the approval gate, and how to verify the relay only ever holds ciphertext. + +## Operate it + +- **[Self-hosting the relay](self-hosting.md)** — run your own relay with Docker, so even the routing + metadata stays with you. +- **[Deploying to Azure](deploy-azure.md)** — the production runbook: web app + relay on Azure Container + Apps (Bicep IaC + CI/CD), Postgres on managed Supabase. +- **[Telemetry & privacy](telemetry.md)** — telecode collects nothing by default; what the self-host-only opt-in operational metrics do and don't include. -- **[Publishing the CLI](./publishing.md)** — maintainer runbook for shipping the `telecode` command to - npm. +- **[Publishing the CLI](publishing.md)** — maintainer runbook for shipping the `telecode` command to npm. + +--- + +New to the project? Read **[Connecting your machine](connecting-your-machine.md)** and +**[End-to-end encryption](end-to-end-encryption.md)** together — between them they explain the whole trust +model in plain language. diff --git a/docs/connecting-your-machine.md b/docs/connecting-your-machine.md new file mode 100644 index 0000000..fcfcf5e --- /dev/null +++ b/docs/connecting-your-machine.md @@ -0,0 +1,206 @@ +# Connecting your machine — pairing, identity & a secure channel + +Telecode lets you drive coding agents that run on **your own computer** from a browser anywhere. That +raises two trust questions, and this page answers both in plain language: + +1. **How do we connect your browser to your laptop securely**, when your laptop is at home behind a + router with no open ports? +2. **How do we know it's _you_** — that the machine someone just paired belongs to _exactly_ the person + signed in, and that the live connection is really that user? + +The short version: there are **two identities** — _you_ (a signed-in user) and _your machine_ (a paired +device) — and telecode binds them together in a way the client can't fake. + +```mermaid +flowchart LR + U["👤 You
signed in (GitHub OAuth)"] + DEV["💻 Your machine
the daemon, a paired device"] + U -- "pairing binds them
server-side" --- DEV + + classDef id fill:#3b2f10,stroke:#e8a33d,stroke-width:2px,color:#fff; + class U,DEV id +``` + +--- + +## Nothing reaches _into_ your machine + +First, the shape of the connection — because it's what makes this safe by default. + +Your laptop sits behind NAT (a home router); there's no public address to call and no port to open. So +telecode is **outbound-only**: both your **browser** and your **daemon** _dial out_ to the relay, and the +relay just multiplexes messages between the two outbound connections. **Nothing ever connects inward to +your laptop.** + +```mermaid +flowchart TB + subgraph cloud["Relay — a meeting point"] + R["Multiplexer
keyed by (user, device)"] + end + B["🌐 Browser / PWA"] -->|"dials OUT · WSS"| R + D["💻 Daemon (your laptop)"] -->|"dials OUT · WSS"| R + R -. "routes encrypted frames" .-> B + R -. "between the two" .-> D + + classDef ends fill:#3b2f10,stroke:#e8a33d,stroke-width:2px,color:#fff; + class B,D ends +``` + +Both connections are **WSS** (WebSocket over TLS). The relay matches a browser to a daemon only when +they belong to the **same `(user_id, device_id)`** pair — that routing key is the backbone of everything +below. (On top of this channel, the actual session content is **[end-to-end encrypted](end-to-end-encryption.md)**, +so even the relay can't read it.) + +--- + +## Identity #1 — knowing it's _you_ (sign-in) + +You prove who you are by signing in with **GitHub OAuth**. Telecode uses the **backend-for-frontend +(BFF)** pattern, which keeps the sensitive bits on the server and out of the browser: + +- The **web app** (SvelteKit) runs the OAuth dance and, on success, sets a **`httpOnly` session cookie**. + `httpOnly` means JavaScript can't read it — so injected script can't steal your session. +- The browser never gets a long-lived API token. Instead, when it opens its live WebSocket to the relay, + the web server hands it a **short-lived, signed channel token** that says "this connection belongs to + user X." The relay verifies that signature; it **never trusts a user id supplied by the client.** + +```mermaid +sequenceDiagram + autonumber + participant B as Browser + participant W as Web app (SvelteKit, server) + participant GH as GitHub + participant R as Relay + + B->>W: "Continue with GitHub" + W->>GH: OAuth authorize (state + scopes) + GH-->>W: code → exchange for identity + W-->>B: set httpOnly session cookie (no token in JS) + + Note over B,W: Later, to open the live channel… + B->>W: request a channel token + W-->>B: short-lived SIGNED channel token (user id baked in by the server) + B->>R: connect WSS + channel token + Note over R: verifies the signature → trusts the user id
(never a client-claimed id) +``` + +The takeaway: **your identity is decided server-side and signed.** The browser can't claim to be someone +else, because it never gets to _state_ who it is — the server bakes that into a token the relay verifies. + +--- + +## Identity #2 — pairing your machine (and binding it to you) + +Now the part that connects a _machine_ to _you_. The daemon has no browser and no cookie, so telecode +uses the **OAuth 2.0 Device Authorization Grant** ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) +— the same "enter this code" flow a TV app uses to log into your account. + +Here's the whole dance. Watch where the **user id** comes from — that's the crux. + +```mermaid +sequenceDiagram + autonumber + participant D as Daemon (your laptop) + participant R as Relay + participant B as You (signed-in browser) + participant W as Web app (server) + + D->>R: POST /device/code { name, public_key } + R-->>D: { user_code: "ABCD-EFGH", device_code, expires_in, interval } + Note over D: prints "Go to the app and enter ABCD-EFGH" + + loop every few seconds + D->>R: POST /device/token { device_code } + R-->>D: authorization_pending… + end + + Note over B: You sign in (Identity #1), open /activate, type ABCD-EFGH + B->>W: submit user_code (you are already authenticated) + W->>R: POST /device/approve { user_code, user_id } 🔒 server-to-server + Note over W,R: user_id is the AUTHENTICATED user's id,
derived server-side — the client never supplies it.
Endpoint is guarded by a service secret. + R-->>W: ok (device created under that user) + + D->>R: POST /device/token { device_code } + R-->>D: approved { device_token, user_id, device_id } + Note over D: saves to ~/.telecode/credentials.json +``` + +### Why this proves "exactly you" + +The security hinges on **step 8**: approval is **server-derived**. When you type the code on `/activate`, +your browser doesn't send a user id — it can't. The web server, which already knows who you are from your +`httpOnly` session, calls the relay's `/device/approve` endpoint **server-to-server** (guarded by a +shared service secret) and passes **its own authenticated `user_id`**. So the device is bound to the +exact person who was signed in when they entered the code — there is no field a malicious client could +set to claim someone else's account. + +Several smaller defenses harden the short window where a `user_code` is live: + +- **Short, unambiguous codes.** The `user_code` (e.g. `ABCD-EFGH`) is drawn from an alphabet with no + `0/O/1/I`, and **expires in ~5 minutes**. +- **Brute-force lockout.** Too many invalid approve attempts by a user (default **10 in 10 minutes**) + locks further attempts — so a `user_code` can't be guessed at scale. +- **Tokens are stored hashed.** The device token is shown to the daemon **once**; the relay stores only + its **SHA-256 hash**. A database leak doesn't reveal a usable token. +- **One-time delivery.** Once the daemon polls and receives the approved token, the pending record is + consumed — a replayed poll can't re-read it. + +After pairing, the daemon holds a `device_token` plus its `user_id` and `device_id` in +`~/.telecode/credentials.json`. From then on it just **dials out** with that token and is recognized as +that one device under that one user — no re-pairing on restart. + +--- + +## Putting it together — the steady state + +Once you're signed in and your machine is paired, every reconnect is just the two sides dialing out and +the relay matching them on `(user_id, device_id)`: + +```mermaid +flowchart LR + subgraph you["👤 user_id = you"] + B["Browser
signed cookie → channel token"] + D["Daemon
device_token"] + end + R{{"Relay
match on (user_id, device_id)"}} + B -->|"WSS + channel token"| R + D -->|"WSS + device_token"| R + R -->|"only same-user, same-device frames"| B + R --> D + + classDef hl fill:#3b2f10,stroke:#e8a33d,stroke-width:2px,color:#fff; + class B,D hl +``` + +- The **browser** authenticates with the server-signed **channel token** (Identity #1). +- The **daemon** authenticates with its **device token** (Identity #2). +- The relay only ever connects a browser to a daemon when **both resolve to the same user and device** — + and even then, it's forwarding **[end-to-end-encrypted](end-to-end-encryption.md)** frames it can't + read. + +And remember the execution boundary on top of all this: even with a valid connection, **every +consequential tool call pauses for your approval** before it runs on your machine (the +[threat model](threat-model.md) covers that gate). + +--- + +## How it's built (for contributors) + +- **Wire contracts** for the device grant are shared zod schemas in + [`packages/protocol/src/device-auth.ts`](../packages/protocol/src/device-auth.ts), so the daemon and + relay can never drift. +- **Daemon side** (the RFC 8628 client) is [`packages/daemon/src/pairing.ts`](../packages/daemon/src/pairing.ts): + request code → prompt → poll → store credentials. +- **Relay side** is [`apps/relay/src/device-auth.ts`](../apps/relay/src/device-auth.ts): `/device/code`, + `/device/token`, and the **service-secret-guarded** `/device/approve` whose `user_id` is always the + web tier's authenticated user — never the client's. +- **Web side**: the OAuth provider + `httpOnly` cookie live under `apps/web/src/lib/server/auth`, and the + code-entry screen is the `/activate` route; the channel token is minted by the web server for the live + relay connection. + +--- + +**Related:** [End-to-end encryption](end-to-end-encryption.md) (how the public keys exchanged at pairing +secure the actual content) · [Threat model](threat-model.md) (the approval gate and what the relay can +infer) · [Getting started](getting-started.md) (do it for real) · [Self-hosting](self-hosting.md) (own +the relay too). diff --git a/docs/end-to-end-encryption.md b/docs/end-to-end-encryption.md new file mode 100644 index 0000000..608e594 --- /dev/null +++ b/docs/end-to-end-encryption.md @@ -0,0 +1,236 @@ +# End-to-end encryption + +Telecode runs coding agents on **your** machine and lets you drive them from a browser. In between sits +a **relay** — a small server both your laptop and your browser dial out to, which passes messages +between them. End-to-end encryption (E2E) is the promise that **the relay only ever sees scrambled +bytes**: your prompts, the agent's replies, file diffs, and the whole transcript are encrypted on one +end and only decrypted on the other. The relay in the middle cannot read any of it — even though every +message flows through it. + +This page explains, in plain language, how that works and how it's built. If you only remember one +picture, remember this: + +```mermaid +flowchart LR + B["Your browser
encrypts / decrypts"] + R["Relay
sees ciphertext only"] + D["Your laptop · daemon
encrypts / decrypts"] + + B -->|"🔒 ciphertext"| R -->|"🔒 ciphertext"| D + D -->|"🔒 ciphertext"| R -->|"🔒 ciphertext"| B + + classDef ends fill:#3b2f10,stroke:#e8a33d,stroke-width:2px,color:#fff; + class B,D ends +``` + +The two amber boxes are the only places anything is ever readable. The relay is deliberately in the +dark. + +> **Want the adversary's-eye view** — exactly what the relay _can_ still infer (timing, sizes, which +> session is busy), and the known limitations? That's the **[threat model](threat-model.md)**. This page +> is the "how it works"; the threat model is the "what's still exposed." + +--- + +## The idea in one minute + +Imagine you and your laptop each have a **padlock** that only you can open. Before any real message is +sent, you swap padlocks so each side can lock a box only the _other_ can open. Then, for the actual +session, you agree on a single fast key and lock every message with it. The courier (the relay) carries +locked boxes back and forth and never holds a key. + +Telecode does exactly this, with standard cryptography built into every modern browser and into Node.js +— **no custom crypto, no native dependencies**: + +| Plain idea | The actual primitive | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Each side's personal padlock | An **X25519** key pair (a public half anyone can have, a private half that never leaves) | +| Swapping padlocks to get a shared secret | **ECDH** — combine _your private_ key with _their public_ key and both sides arrive at the same secret | +| Turning that secret into a real key | **HKDF-SHA256** — stretches the raw shared secret into a clean 256-bit key | +| Locking each message, fast | **AES-256-GCM** — symmetric encryption that also detects tampering | + +All of it runs through the platform's **WebCrypto** (`crypto.subtle`) — in the browser, and in the daemon +on Node 22+. The crypto lives in one place, [`packages/protocol/src/webcrypto.ts`](../packages/protocol/src/webcrypto.ts); +nothing in the app encrypts on its own. + +--- + +## The three keys + +There are exactly three kinds of key. Two are long-lived **identities**; one is a short-lived +**per-session** key. + +```mermaid +flowchart TB + subgraph laptop["💻 Your laptop (daemon)"] + DK["Device identity key
X25519 private — never leaves
~/.telecode/credentials.json"] + CK["Per-session content key
AES-256-GCM, minted per session"] + end + subgraph browser["🌐 Your browser"] + BK["Browser identity key
X25519 private — non-extractable
stored in IndexedDB"] + end + + DK -. "public half registered at pairing" .-> REG[("Relay: devices.public_key")] + BK -. "public half sent on launch" .-> DK + DK == "wraps the content key
for this browser" ==> BK + + classDef k fill:#3b2f10,stroke:#e8a33d,stroke-width:2px,color:#fff; + class DK,BK,CK k +``` + +1. **The device identity key.** When the daemon first runs, it generates an X25519 key pair. The + **private** half is written to `~/.telecode/credentials.json` and _never leaves your laptop_. The + **public** half is registered with the relay when you pair the machine (it's stored as + `devices.public_key`). Public keys are public by definition — there's nothing secret about them. + +2. **The browser identity key.** Each browser holds its own X25519 key pair. Its private half is a + **non-extractable `CryptoKey`** kept in the browser's IndexedDB. "Non-extractable" is the important + word: the page can _use_ the key to decrypt while it's on the real telecode origin, but **no script + can ever read the raw key out** — so even a malicious injected script can't copy it and walk away. + Because it's persisted, reopening the app on the same device reuses the same identity (no + re-handshake). + +3. **The per-session content key.** This is the fast symmetric key (AES-256-GCM) that actually locks the + session's messages. The **daemon mints a fresh one for each session**, and hands it to your browser + _wrapped_ (encrypted) so only that browser can unwrap it. + +--- + +## The handshake (what happens when you launch a session) + +When you launch a session, the browser and the daemon do a one-time handshake to agree on the session's +content key. The relay carries the messages but learns nothing it could decrypt with. + +```mermaid +sequenceDiagram + autonumber + participant B as Browser + participant R as Relay (ciphertext only) + participant D as Daemon (your laptop) + + Note over B,D: Both already know each other's PUBLIC key
(device key from pairing; browser key sent on launch) + + B->>R: launch session (carries browser public key) + R->>D: launch session (forwarded) + + Note over D: ECDH(daemon_private, browser_public) → shared secret
HKDF-SHA256 → 256-bit "wrapping" key + Note over D: mint per-session content key (AES-256-GCM)
encrypt ("wrap") it with the wrapping key + + D->>R: session.key = wrapped content key + R->>B: session.key (forwarded, still wrapped) + + Note over B: ECDH(browser_private, daemon_public) → same shared secret
HKDF → same wrapping key → unwrap the content key + + Note over B,D: ✅ Both hold the same content key.
The relay only saw two public keys and a wrapped blob. +``` + +The magic of ECDH is in steps 3 and 7: each side combines **its own private key** with **the other +side's public key**, and — by the math of the curve — both arrive at the _identical_ shared secret, +without that secret ever crossing the wire. The relay sees the public keys (harmless) and the _wrapped_ +content key (a blob it has no key to open). + +If you open the same session in a second browser tab or on your phone, the daemon simply wraps the same +content key for that browser's public key too — so one session can be watched from several places, each +decrypting locally. + +--- + +## A message's round trip + +Once the handshake is done, every session message — in **both** directions — is sealed with the content +key using AES-256-GCM before it touches the wire. + +```mermaid +sequenceDiagram + autonumber + participant D as Daemon + participant R as Relay + participant B as Browser + + Note over D: agent produces output (plaintext) + Note over D: AES-256-GCM encrypt with the content key
→ { payload: ciphertext, nonce: 12-byte IV } + D->>R: { type, ids, status, payload, nonce } + Note over R: routes by (user, device, session).
CANNOT read payload — no content key. + R->>B: { type, ids, status, payload, nonce } + Note over B: AES-256-GCM decrypt with the content key
→ plaintext, rendered in the UI +``` + +A few details worth knowing: + +- The wire envelope carries a `nonce` — that's the 12-byte GCM **initialization vector**, fresh per + message. GCM also produces an authentication tag, so if anyone (including the relay) flips a single + bit, decryption **fails loudly** rather than returning garbage. Encryption here is also _integrity_. +- One encrypted frame **fans out** to every browser watching the session — the relay copies the same + ciphertext to each subscriber; each decrypts it locally. +- A couple of fields stay in cleartext **on purpose**, because the relay needs them to route and to show + a session list without decrypting anything: the message `type`, the lifecycle `status` + (`running` / `awaiting_input` / `done` / `error`), and the routing ids. Never the content. + +--- + +## What the relay sees — and never sees + +| The relay **sees** (routing metadata) | The relay **never sees** | +| ------------------------------------------------------ | ----------------------------------------------- | +| That a session exists; its id, owning user, and device | Your prompts | +| Message `type` and lifecycle `status` | The agent's output, messages, and diffs | +| Timing and approximate message sizes | Tool names and tool inputs | +| Public keys (public by definition) | The session transcript | +| The _wrapped_ content key (a blob it can't open) | Any private key, or the per-session content key | + +That metadata exposure is **real and not hidden in v1** — see the threat model. If you don't want a +third party to see even that, **[run your own relay](self-hosting.md)**; then the only party that sees +the metadata is you. + +--- + +## How it's built (for contributors) + +- **One crypto module.** Every primitive lives in + [`packages/protocol/src/webcrypto.ts`](../packages/protocol/src/webcrypto.ts): generate/import identity + keys, derive the shared key (ECDH → HKDF), generate/import the content key, and `sealPayload` / + `openPayload` (AES-GCM). No component rolls its own crypto. +- **Typed key handles.** The WebCrypto handle types are aliased from Node's `webcrypto` namespace so the + same code typechecks in the browser and the daemon; raw bytes are fed to `subtle.*` through a small + helper. Node 22+ is required because it speaks the same spec `X25519` the browsers do. +- **Thin cipher seams.** The browser and the daemon each wrap the session in a `session-cipher` seam, so + the encrypt/decrypt boundary is a single, testable place rather than scattered calls. +- **The browser's key is XSS-resistant by construction.** It's a non-extractable `CryptoKey` persisted + in IndexedDB ([`apps/web/src/lib/keystore.ts`](../apps/web/src/lib/keystore.ts)); script can use it but + never read it. +- **One unrelated use of `tweetnacl` remains** — sealing the relay's stored GitHub OAuth token _at rest_ + in the database. That's a server-side secret-at-rest concern, separate from the session E2E path. + +### Verifying it yourself + +The guarantee is enforced by a test and is observable in the relay's own logs. + +```sh +# 1. Automated: a full encrypted session across a real relay + real daemon, asserting every forwarded +# frame is ciphertext and the relay logs contain no plaintext and no payload at all. +pnpm --filter @telecode/relay test -- e2e-session + +# 2. By hand: run the stack, launch a session, then confirm the relay log holds no session content. +make run +grep -i 'payload\|prompt' .run-state/relay.log # → routing metadata only, never a prompt or message +``` + +--- + +## Known limitations (v1) + +- **Relay-brokered key exchange.** The relay relays the public keys between browser and daemon, so a + _malicious_ relay could in principle substitute keys (a man-in-the-middle). The daemon's key is + registered over TLS at pairing and connections use WSS, but **out-of-band key verification isn't + implemented yet**. Self-hosting removes the untrusted-relay assumption entirely. +- **Relay-cached ciphertext.** To make reopening a session instant, the relay keeps a small bounded ring + of the most recent **ciphertext** frames (plus the latest wrapped `session.key`). It's still + ciphertext the relay can't read — it just shortens time-to-first-paint on reconnect. The daemon stays + the authoritative source. + +--- + +**Related:** [Threat model](threat-model.md) (what's still exposed and how to verify) · +[Connecting your machine](connecting-your-machine.md) (how the public keys get exchanged at pairing, and +how we know a machine belongs to exactly you) · [Self-hosting](self-hosting.md) (remove the untrusted +relay). diff --git a/docs/threat-model.md b/docs/threat-model.md index 9048b3c..f10c4a4 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -4,6 +4,10 @@ Telecode runs coding agents on **your** machine and lets you drive them from a b a meeting point: both your laptop (the daemon) and your browser dial _out_ to it, and it forwards messages between them. This document states plainly what each part can and cannot see, and how you can verify it. +> **Companion pages, in plain language with diagrams:** [End-to-end encryption](end-to-end-encryption.md) +> (how the relay only ever sees ciphertext) and [Connecting your machine](connecting-your-machine.md) +> (how a paired machine is bound to exactly your account). This page is the adversary's-eye view of both. + ## Two boundaries **1. The approval gate is the execution boundary.** The daemon runs code on your machine. Every