diff --git a/CLAUDE.md b/CLAUDE.md index 79bdef92f8..3f1731597c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources | | [docs/website-i18n.md](docs/website-i18n.md) | Website languages: locale URLs, translated Markdown pairs, `{#id}` anchors, Chinese style | | [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config | +| [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) | Relay E2EE modes: Noise NK mapping, record framing, negotiation, the `requireProtectedSecurity` flag, close codes | | [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | | [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | | [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging | diff --git a/SECURITY.md b/SECURITY.md index ea23984fa0..8fbd768c46 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -21,8 +21,8 @@ The relay is designed to be untrusted. All traffic between your phone and daemon 1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600` 2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key. -3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes. -4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The encrypted bundle is `[24-byte nonce][ciphertext]`. Peers optionally negotiate `binaryCiphertext` in `e2ee_hello` / `e2ee_ready`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. +3. When the phone connects via the relay, it generates fresh ephemeral keys and sends an `e2ee_hello` message. The daemon will not process any application messages until this handshake completes. +4. Updated peers run a Noise `NK` handshake (`Noise_NK_25519_ChaChaPoly_SHA256`) keyed to the daemon's static key: the `e2ee_hello` carries the first Noise message and the daemon's `e2ee_ready` carries the second. Every later frame is a counter-nonced ChaCha20-Poly1305 record. When either side predates this, the session falls back to the legacy construction: a Curve25519 ECDH shared key with XSalsa20-Poly1305 (NaCl `box`) and a random `[24-byte nonce][ciphertext]` bundle. Both modes negotiate `binaryCiphertext`: negotiated application text is carried as a base64 WebSocket text frame, while application binary is carried as a raw WebSocket binary frame. A peer that does not negotiate the capability uses base64 text frames for both kinds. [docs/relay-endpoint-security.md](docs/relay-endpoint-security.md) has the exact mapping. The WebSocket opcode is preserved end to end after negotiation; the receiver never guesses whether authenticated plaintext is text or binary from its byte contents. The plaintext handshake remains WebSocket text and contains only public keys and capability declarations. @@ -34,9 +34,9 @@ The daemon requires a valid cryptographic handshake before processing any comman - **Impersonate the daemon to your phone** — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone - **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon. -- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake -- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected -- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters. +- **Read your traffic** — After the handshake every message is encrypted: ChaCha20-Poly1305 in protected mode, XSalsa20-Poly1305 (NaCl box) in legacy mode +- **Forge messages** — Both constructions are authenticated encryption; a tampered message is rejected in either mode +- **Replay old messages** — In protected mode the daemon contributes a fresh ephemeral key to every session and every record carries a strictly increasing counter, so a recorded session cannot be replayed into a new one and a duplicated, reordered, or reflected record closes the session. Legacy mode has neither property: it uses random nonces, and a daemon that still admits legacy clients accepts a recorded legacy transcript (hello, credentials, commands) as a new session. Set `daemon.relay.requireProtectedSecurity: true` to refuse legacy clients once every client you use has updated. ### Trust model diff --git a/docs/architecture.md b/docs/architecture.md index 8e63bacaf6..29f9c7f817 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -182,9 +182,9 @@ Communicates with the daemon via the same WebSocket protocol as the app. Enables remote access when the daemon is behind a firewall. -- Curve25519 establishes the relay-session secret; NaCl `box` protects each payload with XSalsa20-Poly1305 +- Updated peers negotiate a Noise `NK` session with per-record counters; peers on either side that predate it fall back to the legacy NaCl `box` construction. [docs/relay-endpoint-security.md](relay-endpoint-security.md) owns the mapping, negotiation, and the `daemon.relay.requireProtectedSecurity` flag - The relay is zero-knowledge — it routes encrypted bytes and cannot read content -- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`) +- Client and daemon channels with identical API (`openClientSecureChannel`, `openDaemonSecureChannel`) - Pairing via QR code transfers the daemon's public key to the client - New homes keep relay disabled until pairing consent. `DaemonConfigStore` persists the desired state, while the relay runtime starts or stops the outbound transport live; pairing reads that current state instead of a startup snapshot. - Optional E2EE capability negotiation preserves application frame kind: text plaintext uses base64 ciphertext text frames, while binary plaintext uses raw ciphertext binary frames; mixed-version peers remain base64-only diff --git a/docs/relay-endpoint-security.md b/docs/relay-endpoint-security.md new file mode 100644 index 0000000000..0fd6a1899f --- /dev/null +++ b/docs/relay-endpoint-security.md @@ -0,0 +1,88 @@ +# Relay endpoint security + +The relay carries daemon sessions between a client and a daemon that cannot reach each other directly. The relay is untrusted, so the two endpoints secure the session themselves. This doc owns the protected construction, its mapping onto Paseo's handshake and record flow, the negotiation rules, and the enforcement flag. The threat model lives in [SECURITY.md](../SECURITY.md); the code is `packages/relay/src/secure-channel.ts`. + +Fixes [#359](https://github.com/getpaseo/paseo/issues/359): the legacy construction has no replay protection. A recorded legacy hello plus records replays onto a fresh daemon channel, and duplicated records are delivered twice. + +## Two modes + +| Mode | Construction | Replay protection | +| ----------- | ------------------------------------------------ | -------------------------------- | +| `legacy` | X25519 + NaCl `box`, random nonces | none | +| `protected` | `Noise_NK_25519_ChaChaPoly_SHA256` (revision 34) | counters, fresh daemon ephemeral | + +Two updated peers always negotiate `protected`. An old peer on either side falls to `legacy` while the daemon permits it. Everything in `packages/relay/src/encrypted-channel.ts` is the legacy construction and is unchanged. + +The shared entry points are `openClientSecureChannel` and `openDaemonSecureChannel`. Callers get a `SecureChannel` and ask it `securityMode()`; nothing outside the module branches on the construction. + +## Noise mapping + +- **Pattern:** NK. The daemon's static key is the existing `$PASEO_HOME/daemon-keypair.json` X25519 key that pairing already distributes. There is no second daemon identity. +- **Prologue:** the ASCII bytes `paseo-relay-e2ee/1`. +- **Primitives:** `@noble/curves` x25519, `@noble/ciphers` ChaCha20-Poly1305, `@noble/hashes` SHA-256 and HMAC. The state machine in `packages/relay/src/noise/` is our own implementation of the spec and is checked against vendored third-party vectors in `noise/fixtures/`. +- **Message 1** (`-> e, es`) travels inside the legacy hello: `{ type: "e2ee_hello", key, capabilities, protected: { v: 1, message: } }`. `key` is a separate legacy ephemeral so an old daemon still completes a legacy handshake. The Noise payload is the offered parameters. +- **Message 2** (`<- e, ee`) travels inside the ready: `{ type: "e2ee_ready", protected: { v: 1, message: } }`. Its payload is the selected parameters. The outer `capabilities` field is ignored in protected mode because it is not authenticated. +- **Parameters payload:** two bytes, `[0x01 version][flags]`. Flag bit 0 is `binaryCiphertext`. Unknown versions, reserved flag bits, and a selection the client did not offer fail the handshake. +- **Split:** the initiator sends with the first cipher state and receives with the second. The daemon is the mirror. Nonces are 64-bit little-endian in a 12-byte nonce, reserved synchronously before any await, never wrapped. A receiver advances its counter only after the tag verifies. + +Nothing else is bound into the handshake. The outer JSON, the relay URL, and session credentials are not authenticated by Noise. Credentials travel in the application `hello` after message 2 verified the daemon. + +## Records + +Each application frame becomes one record of one or more Noise transport messages: + +``` +chunk plaintext = kind(1) || flags(1) || totalLength(u32 BE) || data +wire record = (u16 BE ciphertextLength || ciphertext)+ +``` + +`kind` is `0x00` text or `0x01` binary. `flags` bit 0 marks the first chunk and bit 1 the last. `data` is at most 65513 bytes per chunk, so a chunk never exceeds the Noise message limit. The chunk count is `max(1, ceil(totalLength / 65513))`. + +The receiver rejects a record whose metadata disagrees between chunks, whose non-final chunks are not full, whose byte total differs from `totalLength`, or that has zero chunks, trailing bytes, reserved flags, or an invalid kind. Partial plaintext never reaches the application. Transport AD is empty, as the Noise spec writes it; framing authenticity comes from the metadata being inside the ciphertext. `packages/relay/src/protected-records.ts` owns this. + +Frame kind is preserved on the wire the same way as legacy: text records are base64 WebSocket text, binary records are WebSocket binary when `binaryCiphertext` was negotiated. + +## Retries and failures + +- The client resends its hello every second until a ready arrives. The daemon caches the exact hello text and ready text for the channel. An identical hello gets the identical ready and counters keep running. A different hello closes the channel (`1008`, transcript mismatch). A ready after the channel is open is ignored. +- A fresh daemon channel always generates a fresh `e`, so a recorded hello and its records fail authentication when replayed later, including after a daemon restart. +- A present but invalid `protected` field fails the handshake. There is no fallback to legacy once protected was attempted. +- Any record failure (bad tag, duplicate, reordered, reflected, malformed) closes the channel and nothing from that record is delivered. The daemon closes with `1011`; the client closes with `4003`, because a browser `WebSocket.close` accepts only `1000` and `3000`-`4999`. +- A record header is checked against the bytes actually received before any buffer is allocated for it, so a header claiming gigabytes costs nothing. +- Keys are cleared on close as a best effort. + +## Negotiation and admission + +The daemon advertises `server_info.features.relayProtectedSecurity: true` on every session, whatever mode that session negotiated. A relay client that ended up in `legacy` with such a daemon closes with `4002` before the client marks itself connected, restores subscriptions, or flushes queued commands. The check lives in the relay E2EE transport, so it also covers the Node connector; direct connections never see it. + +The app pins `minimumSecurityMode: "protected"` on every relay connection to a daemon public key after the first protected handshake with that key, or when the pairing offer carries `minimumSecurityMode`. A pinned client passes `minimumMode: "protected"` to the handshake and refuses a legacy ready before sending credentials. The pin belongs to the daemon identity, not to a relay address: re-importing the same key through any relay keeps it, a different key is a different connection, and connection failures never clear it. + +The handshake result is read in one place, `HostRuntimeController` when the host's active client reports connected. Every client reaches that point, whether the controller created it or adopted it from a pairing or startup probe, so no client constructor carries a persistence callback. A probe that negotiated protected but was never adopted pins nothing; the pin lands when the host first goes online. + +Within one client process the floor also rises on its own. `DaemonClient` and the Node host connector remember a protected result and require it on every later attempt, so a reconnect after a relay drop cannot be answered in legacy mode even before the pin is persisted. + +The daemon refuses a legacy client with close `1008` and reason `protected endpoint security required`, and logs `relay_e2ee_handshake_rejected`. That close code stops at the relay: both relays replace a daemon-side close with their own (the wrangler relay sends `1012 Server disconnected`), so an old client sees an ordinary disconnect and keeps retrying. The only client-detectable failure is the client's own `4002`, which stops reconnecting and tells the user to update. + +The client learns its own `4002` and `4003` from the relay E2EE transport, which reports the close the moment the channel decides it. A socket close event arrives later and the channel's error callback earlier; neither is what `DaemonClient` acts on, because acting on the error would start a reconnect before the code is known. + +## Enforcement flag + +`daemon.relay.requireProtectedSecurity` in `config.json` (also `relay.requireProtectedSecurity` through the config RPC) makes the daemon refuse legacy relay clients. Default `false`. It is a local daemon setting with no relay-server counterpart. + +- It is read when each hello arrives, so toggling it affects the next admission without a restart. Sessions already admitted in legacy mode stay up until they disconnect; turning the flag on does not close them. To finish enforcement, restart the daemon or disconnect its relay sessions after enabling the flag: every reconnect is then a new admission and legacy clients are refused. +- While it is on, pairing offers carry `minimumSecurityMode: "protected"` and `relay://` URIs carry `security=protected`, so new clients pin before their first handshake. +- Direct and local connections are outside the flag. + +Flipping the default is a separate decision, tentatively reviewed about 30 days after the compatibility release. Nothing in the code expires on a date. + +## Known limitation + +While a daemon accepts legacy clients, a transcript recorded from any legacy session (hello, credentials, commands) can still be replayed to it. Pinning on the client does not change that, because the attacker replays the old client's transcript, not the pinned client's. The only remedy is enabling the flag on that daemon. + +## Testing + +- `packages/relay/src/noise/noise.test.ts`: vendored vectors, replay and out-of-order rejection at the cipher-state level. +- `packages/relay/src/protected-records.test.ts`: framing round trips and every malformed-record rule. +- `packages/relay/src/secure-channel.test.ts`: both modes, mixed versions, policy rejection, retries, recorded-transcript replay, downgrade attempts. +- `packages/server/src/server/websocket/secure-relay-ingress.test.ts`: daemon admission and the flag. +- `packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts`: real daemon through the wrangler relay. Set `PASEO_RELAY_E2E_PORT` to run the same cases against an already-running relay such as the Elixir relay in Docker. diff --git a/package-lock.json b/package-lock.json index 9e0a02eb17..2f60b8cc1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8805,6 +8805,45 @@ "license": "MIT", "optional": true }, + "node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -42184,6 +42223,9 @@ "name": "@getpaseo/relay", "version": "0.13.0-beta.1", "dependencies": { + "@noble/ciphers": "^2.4.0", + "@noble/curves": "^2.4.0", + "@noble/hashes": "^2.4.0", "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", "ws": "^8.14.2" diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 0142548674..33e4f1a316 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -56,6 +56,7 @@ class FakeDaemonClient { supportsBackgroundWorkspaces(): boolean { return false; } + relaySecurityFloor: "legacy" | "protected" | undefined = undefined; private state: ConnectionState = { status: "idle" }; private listeners = new Set<(status: ConnectionState) => void>(); private error: string | null = null; @@ -345,6 +346,11 @@ class Deferred { } } +function relayPinOf(host: HostProfile | undefined): "protected" | undefined { + const connection = host?.connections[0]; + return connection?.type === "relay" ? connection.minimumSecurityMode : undefined; +} + async function waitForHostOnline(store: HostRuntimeStore, serverId: string): Promise { if (store.getSnapshot(serverId)?.connectionStatus === "online") return; await new Promise((resolve) => { @@ -564,6 +570,7 @@ function createPairingStore( connectAttempts?: string[]; serverIdForHost?: (host: HostProfile) => string; storage?: HostRuntimeStorage; + probeSecurityFloor?: "legacy" | "protected"; } = {}, ): HostRuntimeStore { return new HostRuntimeStore({ @@ -575,8 +582,10 @@ function createPairingStore( if (input.password && host.password !== input.password) { throw new DaemonAuthenticationError("password_required"); } + const client = makeConnectedProbeClient(5); + client.relaySecurityFloor = input.probeSecurityFloor; return { - client: makeConnectedProbeClient(5) as unknown as DaemonClient, + client: client as unknown as DaemonClient, serverId: input.serverIdForHost?.(host) ?? host.serverId, hostname: input.hostname ?? "paired", }; @@ -787,6 +796,85 @@ describe("HostRuntimeController", () => { expect(controller.getSnapshot().client).toBe(createdClients[1]?.client); }); + it("keeps the active relay client when its security pin is learned", async () => { + const relay: HostConnection = { + id: "relay:wss:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + }; + const createdClients: FakeDaemonClient[] = []; + const controller = new HostRuntimeController({ + host: makeHost({ connections: [relay], preferredConnectionId: relay.id }), + deps: { + createClient: () => { + const client = new FakeDaemonClient(); + createdClients.push(client); + return client as unknown as DaemonClient; + }, + connectToDaemon: async ({ host, connection }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: connection.id, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await controller.activateConnection({ connectionId: relay.id }); + // The client negotiated protected itself; the pin it reported is already enforced. + createdClients[0]!.relaySecurityFloor = "protected"; + await controller.updateHost( + makeHost({ + connections: [{ ...relay, minimumSecurityMode: "protected" }], + preferredConnectionId: relay.id, + }), + ); + + expect(createdClients).toHaveLength(1); + expect(createdClients[0]?.isDisposed()).toBe(false); + expect(controller.getSnapshot().client).toBe(createdClients[0]); + }); + + it("replaces the active legacy relay client when a strict offer pins its connection", async () => { + const relay: HostConnection = { + id: "relay:wss:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + }; + const createdClients: Array<{ client: FakeDaemonClient; connection: HostConnection }> = []; + const controller = new HostRuntimeController({ + host: makeHost({ connections: [relay], preferredConnectionId: relay.id }), + deps: { + createClient: ({ connection }) => { + const client = new FakeDaemonClient(); + createdClients.push({ client, connection }); + return client as unknown as DaemonClient; + }, + connectToDaemon: async ({ host, connection }) => ({ + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: connection.id, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + await controller.activateConnection({ connectionId: relay.id }); + expect(createdClients[0]?.client.relaySecurityFloor).toBeUndefined(); + const pinned: HostConnection = { ...relay, minimumSecurityMode: "protected" }; + await controller.updateHost( + makeHost({ connections: [pinned], preferredConnectionId: relay.id }), + ); + + expect(createdClients.map((entry) => entry.connection)).toEqual([relay, pinned]); + expect(createdClients[0]?.client.isDisposed()).toBe(true); + expect(controller.getSnapshot().client).toBe(createdClients[1]?.client); + }); + it("keeps known hosts in connecting when a created client reports idle during connect", async () => { const host = makeHost({ connections: [ @@ -3857,6 +3945,96 @@ describe("HostRuntimeStore", () => { store.syncHosts([]); }); + it("keeps a strict offer's security pin through both pairing paths and a reload", async () => { + const storage = createMemoryHostRuntimeStorage(); + const store = createPairingStore({ storage }); + answerHostConfirmations(store, true); + const strictOffer = (serverId: string): ConnectionOffer => ({ + ...makeOffer({ serverId, daemonPublicKeyB64: `pk_${serverId}` }), + minimumSecurityMode: "protected", + }); + + await store.importConnectionLink(encodeOfferUrl(strictOffer("srv_import")), "openProject"); + await store.beginLinkPairing().submit(encodeOfferUrl(strictOffer("srv_pair"))); + + const pinOf = (hosts: HostProfile[], serverId: string) => + relayPinOf(hosts.find((host) => host.serverId === serverId)); + expect(pinOf(store.getHosts(), "srv_import")).toBe("protected"); + expect(pinOf(store.getHosts(), "srv_pair")).toBe("protected"); + + await waitForHostOnline(store, "srv_pair"); + const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]"); + expect(pinOf(persisted, "srv_import")).toBe("protected"); + expect(pinOf(persisted, "srv_pair")).toBe("protected"); + store.syncHosts([]); + + const reloaded = createPairingStore({ storage }); + const registryLoaded = onceHostListMatches(reloaded, () => reloaded.isHostRegistryLoaded()); + reloaded.boot(); + await registryLoaded; + expect(pinOf(reloaded.getHosts(), "srv_import")).toBe("protected"); + expect(pinOf(reloaded.getHosts(), "srv_pair")).toBe("protected"); + + reloaded.syncHosts([]); + }); + + it("learns the security pin from an adopted probe client and keeps it across a reload", async () => { + const relay: HostConnection = { + id: "relay:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "pk_saved", + }; + const host = makeHost({ + serverId: "srv_saved", + connections: [relay], + preferredConnectionId: relay.id, + }); + const storage = createMemoryHostRuntimeStorage({ + "@paseo:daemon-registry": JSON.stringify([host]), + "@paseo:e2e": "1", + }); + const store = createPairingStore({ storage, probeSecurityFloor: "protected" }); + + store.boot(); + await waitForHostOnline(store, "srv_saved"); + const pinned = onceHostListMatches( + store, + () => relayPinOf(store.getHosts()[0]) === "protected", + ); + await pinned; + + const activeClient = store.getSnapshot("srv_saved")?.client as unknown as FakeDaemonClient; + expect(activeClient.relaySecurityFloor).toBe("protected"); + expect(activeClient.isDisposed()).toBe(false); + const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]"); + expect(persisted[0]?.connections[0]?.minimumSecurityMode).toBe("protected"); + store.syncHosts([]); + + const reloaded = createPairingStore({ storage, probeSecurityFloor: "legacy" }); + const registryLoaded = onceHostListMatches(reloaded, () => reloaded.isHostRegistryLoaded()); + reloaded.boot(); + await registryLoaded; + expect(relayPinOf(reloaded.getHosts()[0])).toBe("protected"); + + reloaded.syncHosts([]); + }); + + it("learns the security pin for a host paired from an old offer once it goes online", async () => { + const storage = createMemoryHostRuntimeStorage(); + const store = createPairingStore({ storage, probeSecurityFloor: "protected" }); + answerHostConfirmations(store, true); + + await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer())); + await waitForHostOnline(store, "srv_offer"); + await onceHostListMatches(store, () => relayPinOf(store.getHosts()[0]) === "protected"); + + const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]"); + expect(persisted[0]?.connections[0]?.minimumSecurityMode).toBe("protected"); + + store.syncHosts([]); + }); + it("probes a pairing link immediately and saves only after admission", async () => { const store = createPairingStore({ password: "correct-password" }); answerHostConfirmations(store, true); diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index fd8c1499d0..bb953220cb 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -16,6 +16,7 @@ import { connectionFromListen, createRemoteSshHostConnection, normalizeStoredHostProfile, + pinRelayProtectedSecurityInProfiles, upsertHostConnectionInProfiles, registryHasConnection, relayConnectionFromOffer, @@ -191,6 +192,12 @@ function hashForLog(value: string): string { return `h_${Math.abs(hash).toString(16)}`; } +/** A daemon identity that negotiated protected relay security. */ +export interface RelaySecurityPin { + serverId: string; + daemonPublicKeyB64: string; +} + export interface HostRuntimeControllerDeps { createClient: (input: { host: HostProfile; @@ -503,6 +510,29 @@ function buildConnectionCandidates(host: HostProfile): ConnectionCandidate[] { })); } +/** + * A pin learned from the active client is already enforced by that client, so + * it keeps running. A pin that arrived from elsewhere (a strict offer) while + * the active client is still legacy needs a client that enforces it. + */ +function connectionNeedsNewClient( + previous: HostConnection, + next: HostConnection, + activeClient: DaemonClient | null, +): boolean { + if (previous.type === "relay" && next.type === "relay") { + const { minimumSecurityMode: previousPin, ...previousRest } = previous; + const { minimumSecurityMode: nextPin, ...nextRest } = next; + if (!equal(previousRest, nextRest)) return true; + return ( + nextPin === "protected" && + previousPin !== "protected" && + activeClient?.relaySecurityFloor !== "protected" + ); + } + return !equal(previous, next); +} + function findConnectionById(host: HostProfile, connectionId: string | null): HostConnection | null { if (!connectionId) { return null; @@ -616,6 +646,9 @@ function createDefaultDeps( e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64, + ...(connection.minimumSecurityMode + ? { minimumMode: connection.minimumSecurityMode } + : {}), }, }); }, @@ -678,6 +711,7 @@ export class HostRuntimeController { private host: HostProfile; private deps: HostRuntimeControllerDeps; private onReconcileServerId: ((oldId: string, newId: string) => void) | null; + private onRelayProtectedSecurityNegotiated: ((input: RelaySecurityPin) => void) | null; private connectionMachineState: HostRuntimeConnectionMachineState; private connectionEpoch = 0; private snapshot: HostRuntimeSnapshot; @@ -702,10 +736,12 @@ export class HostRuntimeController { host: HostProfile; deps?: HostRuntimeControllerDeps; onReconcileServerId?: (oldId: string, newId: string) => void; + onRelayProtectedSecurityNegotiated?: (input: RelaySecurityPin) => void; }) { this.host = input.host; this.deps = input.deps ?? createDefaultDeps(); this.onReconcileServerId = input.onReconcileServerId ?? null; + this.onRelayProtectedSecurityNegotiated = input.onRelayProtectedSecurityNegotiated ?? null; this.connectionMachineState = { tag: "booting", }; @@ -807,7 +843,12 @@ export class HostRuntimeController { activeConnectionId && previousActiveConnection && nextActiveConnection && - (!equal(previousActiveConnection, nextActiveConnection) || passwordChanged) + (connectionNeedsNewClient( + previousActiveConnection, + nextActiveConnection, + this.activeClient, + ) || + passwordChanged) ) { this.connectionLastProbedAt.delete(activeConnectionId); await this.switchToConnection({ connectionId: activeConnectionId }); @@ -1433,6 +1474,7 @@ export class HostRuntimeController { void failConnection(new DaemonAuthenticationError(client.authFailureReason)); return; } + if (state.status === "connected") this.learnRelaySecurity(client, connection.id); this.applyConnectionEvent({ type: "client_state", state, lastError: client.lastError }); this.updateSnapshot({ ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), @@ -1446,6 +1488,25 @@ export class HostRuntimeController { } } + // The active client is the one place every connection path meets: clients + // created here and probe clients adopted from `connectToDaemon` both land in + // `switchToConnection`. A protected handshake observed here becomes the + // persisted pin for that daemon identity. + private learnRelaySecurity(client: DaemonClient, connectionId: string): void { + const connection = findConnectionById(this.host, connectionId); + if ( + connection?.type !== "relay" || + connection.minimumSecurityMode === "protected" || + client.relaySecurityFloor !== "protected" + ) { + return; + } + this.onRelayProtectedSecurityNegotiated?.({ + serverId: this.host.serverId, + daemonPublicKeyB64: connection.daemonPublicKeyB64, + }); + } + adoptReconciledServerId(newServerId: string): void { this.host = { ...this.host, serverId: newServerId }; this.snapshot = { ...this.snapshot, serverId: newServerId }; @@ -1994,39 +2055,6 @@ export class HostRuntimeStore { }); } - private async upsertRelayConnection(input: { - serverId: string; - relayEndpoint: string; - useTls?: boolean; - daemonPublicKeyB64: string; - label?: string; - password?: string; - }): Promise { - const relayEndpoint = normalizeHostPort(input.relayEndpoint); - const useTls = input.useTls ?? false; - const daemonPublicKeyB64 = input.daemonPublicKeyB64.trim(); - if (!daemonPublicKeyB64) { - throw new Error("daemonPublicKeyB64 is required"); - } - const explicitUseTls = input.useTls !== undefined; - const profile = await this.upsertHostConnection({ - serverId: input.serverId, - label: input.label, - connection: { - id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`, - type: "relay", - relayEndpoint, - ...(explicitUseTls ? { useTls } : {}), - daemonPublicKeyB64, - }, - }); - if (input.password) { - await this.setHostPassword(profile.serverId, input.password); - return this.hosts.find((host) => host.serverId === profile.serverId) ?? profile; - } - return profile; - } - // A pairing link (a URL, a QR code, or a pasted link) asks the user to // confirm when it adds a new host or changes a saved host's key, relay, or // TLS setting. The answer needs the saved hosts, so it waits for them to load. @@ -2094,14 +2122,19 @@ export class HostRuntimeStore { password?: string, ): Promise { const connection = relayConnectionFromOffer(offer); - return this.upsertRelayConnection({ + if (!connection.daemonPublicKeyB64) { + throw new Error("daemonPublicKeyB64 is required"); + } + const profile = await this.upsertHostConnection({ serverId: offer.serverId, - relayEndpoint: connection.relayEndpoint, - useTls: connection.useTls, - daemonPublicKeyB64: connection.daemonPublicKeyB64, label, - password, + connection, }); + if (password) { + await this.setHostPassword(profile.serverId, password); + return this.hosts.find((host) => host.serverId === profile.serverId) ?? profile; + } + return profile; } private async probeAndUpsertOffer( @@ -2147,6 +2180,13 @@ export class HostRuntimeStore { }); } + private async pinRelayProtectedSecurity(input: RelaySecurityPin): Promise { + const next = pinRelayProtectedSecurityInProfiles({ profiles: this.hosts, ...input }); + if (next === this.hosts) return; + this.setHostsAndSync(next); + await this.persistHosts(); + } + private async updateHost( serverId: string, apply: (host: HostProfile) => HostProfile, @@ -2356,6 +2396,7 @@ export class HostRuntimeStore { host, deps: this.deps, onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId), + onRelayProtectedSecurityNegotiated: (pin) => void this.pinRelayProtectedSecurity(pin), }); this.controllers.set(host.serverId, controller); useSessionStore.getState().initializeSession(host.serverId, null); diff --git a/packages/app/src/types/host-connection.test.ts b/packages/app/src/types/host-connection.test.ts index 0f0590bb9e..8d7fb87b35 100644 --- a/packages/app/src/types/host-connection.test.ts +++ b/packages/app/src/types/host-connection.test.ts @@ -4,6 +4,8 @@ import { createRemoteSshHostConnection, normalizeStoredHostProfile, orderHostsLocalFirst, + pinRelayProtectedSecurityInProfiles, + relayConnectionFromOffer, resolveActiveHostServerId, upsertHostConnectionInProfiles, type HostConnection, @@ -314,6 +316,155 @@ describe("upsertHostConnectionInProfiles", () => { }); }); +describe("relay endpoint security pin", () => { + const pinned: HostConnection = { + id: "relay:wss:relay.example.com:443", + type: "relay", + relayEndpoint: "relay.example.com:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + minimumSecurityMode: "protected", + }; + const unpinned: HostConnection = { + id: pinned.id, + type: "relay", + relayEndpoint: "relay.example.com:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + }; + + it("survives storage", () => { + const profile = normalizeStoredHostProfile({ serverId: "srv", connections: [pinned] }); + expect(profile?.connections[0]).toEqual(pinned); + }); + + it("comes from a pairing offer that requires protected security", () => { + const offer: Parameters[0] = { + v: 2, + serverId: "srv", + daemonPublicKeyB64: "pk_a", + relay: { endpoint: "relay.example.com:443", useTls: true }, + minimumSecurityMode: "protected", + }; + expect(relayConnectionFromOffer(offer)).toEqual(pinned); + expect(relayConnectionFromOffer({ ...offer, minimumSecurityMode: undefined })).toEqual( + unpinned, + ); + }); + + it("is kept when the same daemon identity is paired again without it", () => { + const profiles = upsertHostConnectionInProfiles({ + profiles: [{ ...makeHost("srv"), connections: [pinned], preferredConnectionId: pinned.id }], + serverId: "srv", + connection: unpinned, + }); + expect(profiles[0].connections).toEqual([pinned]); + }); + + it("is persisted when a strict offer re-imports an unpinned route", () => { + const profiles = [ + { ...makeHost("srv"), connections: [unpinned], preferredConnectionId: unpinned.id }, + ]; + const next = upsertHostConnectionInProfiles({ + profiles, + serverId: "srv", + connection: pinned, + }); + expect(next).not.toBe(profiles); + expect(next[0].connections).toEqual([pinned]); + }); + + it("tightens every route to the same daemon identity when a strict offer arrives through another relay", () => { + const strictElsewhere: HostConnection = { + id: "relay:wss:other.example.com:443", + type: "relay", + relayEndpoint: "other.example.com:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + minimumSecurityMode: "protected", + }; + const profiles = upsertHostConnectionInProfiles({ + profiles: [ + { ...makeHost("srv"), connections: [unpinned], preferredConnectionId: unpinned.id }, + ], + serverId: "srv", + connection: strictElsewhere, + }); + expect(profiles[0].connections).toEqual([pinned, strictElsewhere]); + }); + + it("follows the daemon identity to a relay at another address", () => { + const elsewhere: HostConnection = { + id: "relay:wss:other.example.com:443", + type: "relay", + relayEndpoint: "other.example.com:443", + useTls: true, + daemonPublicKeyB64: "pk_a", + }; + const profiles = upsertHostConnectionInProfiles({ + profiles: [{ ...makeHost("srv"), connections: [pinned], preferredConnectionId: pinned.id }], + serverId: "srv", + connection: elsewhere, + }); + expect(profiles[0].connections).toEqual([ + pinned, + { ...elsewhere, minimumSecurityMode: "protected" }, + ]); + }); + + it("does not carry over to a different daemon identity", () => { + const rotated: HostConnection = { ...unpinned, daemonPublicKeyB64: "pk_b" }; + const profiles = upsertHostConnectionInProfiles({ + profiles: [{ ...makeHost("srv"), connections: [pinned], preferredConnectionId: pinned.id }], + serverId: "srv", + connection: rotated, + }); + expect(profiles[0].connections).toEqual([rotated]); + }); + + it("is learned for every relay route to the identity that negotiated protected security", () => { + const elsewhere: HostConnection = { + ...unpinned, + id: "relay:wss:other.example.com:443", + relayEndpoint: "other.example.com:443", + }; + const rotated: HostConnection = { + ...unpinned, + id: "relay:wss:rotated.example.com:443", + relayEndpoint: "rotated.example.com:443", + daemonPublicKeyB64: "pk_b", + }; + const profiles = [{ ...makeHost("srv"), connections: [unpinned, elsewhere, rotated] }]; + const next = pinRelayProtectedSecurityInProfiles({ + profiles, + serverId: "srv", + daemonPublicKeyB64: "pk_a", + now: "2026-02-01T00:00:00.000Z", + }); + expect(next[0].connections).toEqual([ + pinned, + { ...elsewhere, minimumSecurityMode: "protected" }, + rotated, + ]); + expect(next[0].updatedAt).toBe("2026-02-01T00:00:00.000Z"); + + expect( + pinRelayProtectedSecurityInProfiles({ + profiles: next, + serverId: "srv", + daemonPublicKeyB64: "pk_a", + }), + ).toBe(next); + expect( + pinRelayProtectedSecurityInProfiles({ + profiles, + serverId: "srv", + daemonPublicKeyB64: "pk_other", + }), + ).toBe(profiles); + }); +}); + describe("resolveActiveHostServerId", () => { it("uses the selected host when one is set", () => { expect( diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index fb3150a168..e7449b9a67 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -49,6 +49,12 @@ export interface RelayHostConnection { relayEndpoint: string; useTls?: boolean; daemonPublicKeyB64: string; + /** + * Pinned once this daemon identity has negotiated protected endpoint + * security, or when its pairing offer required it. Never cleared by a + * connection failure; a new daemon identity is a different connection. + */ + minimumSecurityMode?: "protected"; } export interface RoutedHostConnection { @@ -192,7 +198,69 @@ function upsertHostConnectionById( replaced = true; } if (!replaced) next.push(connection); - return next; + // A pin is never lost: the replaced record counts as much as the incoming one. + return shareRelaySecurityPin(next, [...connections, connection]); +} + +/** + * The security pin belongs to the daemon identity, not to one relay address. + * Once any relay route to a key is pinned, by a strict offer or a learned + * handshake, every route to that key is pinned. + */ +function shareRelaySecurityPin( + connections: HostConnection[], + pinSources: HostConnection[], +): HostConnection[] { + const pinnedKeys = new Set(); + for (const connection of pinSources) { + if (connection.type === "relay" && connection.minimumSecurityMode === "protected") { + pinnedKeys.add(connection.daemonPublicKeyB64); + } + } + return connections.map((connection) => + connection.type === "relay" && + connection.minimumSecurityMode !== "protected" && + pinnedKeys.has(connection.daemonPublicKeyB64) + ? { ...connection, minimumSecurityMode: "protected" as const } + : connection, + ); +} + +function relaySecurityPinEquals(left: HostConnection, right: HostConnection): boolean { + const pinOf = (connection: HostConnection) => + connection.type === "relay" ? connection.minimumSecurityMode : undefined; + return pinOf(left) === pinOf(right); +} + +/** + * Records that the relay daemon identity `daemonPublicKeyB64` negotiated + * protected endpoint security. Every relay route to that identity is pinned. + * Returns the same array when nothing changes. + */ +export function pinRelayProtectedSecurityInProfiles(input: { + profiles: HostProfile[]; + serverId: string; + daemonPublicKeyB64: string; + now?: string; +}): HostProfile[] { + const now = input.now ?? new Date().toISOString(); + let changed = false; + const next = input.profiles.map((host) => { + if (host.serverId !== input.serverId) return host; + const connections = host.connections.map((connection) => { + if ( + connection.type !== "relay" || + connection.daemonPublicKeyB64 !== input.daemonPublicKeyB64 || + connection.minimumSecurityMode === "protected" + ) { + return connection; + } + changed = true; + return { ...connection, minimumSecurityMode: "protected" as const }; + }); + return changed ? { ...host, connections, updatedAt: now } : host; + }); + return changed ? next : input.profiles; } function hasProfileChanged(input: { @@ -218,7 +286,10 @@ function hasProfileChanged(input: { connections.length !== previous.connections.length || connections.some((candidate, index) => { const old = previous.connections[index]; - return !old || !hostConnectionEquals(candidate, old); + // Identity equality decides profile matching; the pin is policy on top of it. + return ( + !old || !hostConnectionEquals(candidate, old) || !relaySecurityPinEquals(candidate, old) + ); }) ); } @@ -377,6 +448,7 @@ export function relayConnectionFromOffer(offer: ConnectionOffer): RelayHostConne relayEndpoint, useTls, daemonPublicKeyB64: offer.daemonPublicKeyB64.trim(), + ...(offer.minimumSecurityMode ? { minimumSecurityMode: offer.minimumSecurityMode } : {}), }; } @@ -441,6 +513,7 @@ const StoredHostConnectionSchema = z.discriminatedUnion("type", [ relayEndpoint: z.string(), useTls: z.boolean().optional(), daemonPublicKeyB64: z.string(), + minimumSecurityMode: z.literal("protected").optional(), }), ]); const StoredHostProfileSchema = z.strictObject({ @@ -504,6 +577,9 @@ function normalizeStoredConnection(connection: StoredHostConnection): HostConnec relayEndpoint, ...(useTls !== undefined ? { useTls } : {}), daemonPublicKeyB64, + ...(connection.minimumSecurityMode + ? { minimumSecurityMode: connection.minimumSecurityMode } + : {}), }; } catch { return null; diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index 5942b45621..18ffa62dc4 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -202,7 +202,11 @@ export async function buildClientConfig( useTls: connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint), serverId, }), - e2ee: { enabled: true, daemonPublicKeyB64: connection.daemonPublicKeyB64 }, + e2ee: { + enabled: true, + daemonPublicKeyB64: connection.daemonPublicKeyB64, + ...(connection.minimumSecurityMode ? { minimumMode: connection.minimumSecurityMode } : {}), + }, }; } diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index ab208102aa..4636b1841f 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -92,6 +92,7 @@ export async function resolveLocalPairingOffer(options: { relayPublicEndpoint: config.relayPublicEndpoint, relayUseTls: config.relayUseTls, relayPublicUseTls: config.relayPublicUseTls, + relayRequireProtectedSecurity: config.relayRequireProtectedSecurity, appBaseUrl: config.appBaseUrl, includeQr: true, }); diff --git a/packages/client/src/daemon-client-relay-e2ee-transport.ts b/packages/client/src/daemon-client-relay-e2ee-transport.ts index 237699e600..258c0aa9b5 100644 --- a/packages/client/src/daemon-client-relay-e2ee-transport.ts +++ b/packages/client/src/daemon-client-relay-e2ee-transport.ts @@ -1,7 +1,9 @@ import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { - createClientChannel, - type EncryptedChannel, + openClientSecureChannel, + PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE, + type SecureChannel, + type SecurityMode, type Transport as RelayTransport, } from "@getpaseo/relay/e2ee"; import type { @@ -20,39 +22,86 @@ type CloseHandler = (event?: unknown) => void; type ErrorHandler = (event?: unknown) => void; type MessageHandler = (data: unknown, isBinary: boolean) => void; -export function createRelayE2eeTransportFactory(args: { - baseFactory: DaemonTransportFactory; +export interface RelayE2eeTransportOptions { daemonPublicKeyB64: string; logger: TransportLogger; assertOutboundCapacity?: (wireBytes: number) => void; -}): DaemonTransportFactory { - return ({ url }) => { - const base = args.baseFactory({ url }); - return createEncryptedTransport( - base, - args.daemonPublicKeyB64, - args.logger, - args.assertOutboundCapacity, - ); - }; + /** Refuse to open unless the daemon negotiates at least this endpoint security mode. */ + minimumMode?: SecurityMode; + /** Reports the negotiated mode once the daemon has authenticated the handshake. */ + onSecurityMode?: (mode: SecurityMode) => void; } -export function createRelayTransportFactory( +export function createRelayE2eeTransportFactory( + args: RelayE2eeTransportOptions & { baseFactory: DaemonTransportFactory }, +): DaemonTransportFactory { + const { baseFactory, ...options } = args; + return ({ url }) => createEncryptedTransport(baseFactory({ url }), options); +} + +const DAEMON_REQUIRES_PROTECTED_REASON = "daemon requires protected endpoint security"; + +/** + * Returns a user-facing message when this client closed the session itself + * because endpoint security could not be established, so reconnecting cannot help. + * + * The daemon's own policy close never reaches the client: both relays replace a + * daemon-side close with their own code (1012 on the wrangler relay). + */ +export function describeEndpointSecurityClose(event: unknown): string | null { + if (!event || typeof event !== "object") return null; + const code = "code" in event ? event.code : undefined; + const reason = "reason" in event && typeof event.reason === "string" ? event.reason : ""; + if (code === PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE) { + return `Endpoint security handshake failed: ${reason || "protected mode unavailable"}`; + } + return null; +} + +/** + * The first session message from the daemon is `server_info`. A daemon that can + * negotiate protected security advertises it there, so a legacy session with such + * a daemon was downgraded in transit and must not proceed to admission. + */ +function readServerInfoProtectedSupport(text: string): boolean | null { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + if (!isRecord(parsed) || parsed.type !== "session" || !isRecord(parsed.message)) return null; + const message = parsed.message; + if (message.type !== "status" || !isRecord(message.payload)) return null; + if (message.payload.status !== "server_info") return null; + const features = message.payload.features; + return isRecord(features) && features.relayProtectedSecurity === true; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * Relay upgrades carry routing only. Daemon credentials and subprotocols must + * never reach the relay; they travel inside the encrypted hello. + */ +export function createRelayUpgradeTransportFactory( baseFactory: DaemonTransportFactory, ): DaemonTransportFactory { - // The relay upgrade precedes E2EE. Only the encrypted hello may carry daemon credentials. return ({ url }) => baseFactory({ url }); } export function createEncryptedTransport( base: DaemonTransport, - daemonPublicKeyB64: string, - logger: TransportLogger, - assertOutboundCapacity: (wireBytes: number) => void = () => {}, + options: RelayE2eeTransportOptions, ): DaemonTransport { - let channel: EncryptedChannel | null = null; + const { daemonPublicKeyB64, logger, minimumMode, onSecurityMode } = options; + const assertOutboundCapacity = options.assertOutboundCapacity ?? (() => {}); + let channel: SecureChannel | null = null; let opened = false; let closed = false; + let awaitingServerInfo = true; const openHandlers = new Set(); const closeHandlers = new Set(); @@ -86,6 +135,20 @@ export function createEncryptedTransport( if (closed) { return; } + if (awaitingServerInfo && typeof data === "string") { + const daemonSupportsProtected = readServerInfoProtectedSupport(data); + if (daemonSupportsProtected !== null) { + awaitingServerInfo = false; + if (daemonSupportsProtected && channel?.securityMode() === "legacy") { + logger.warn( + { reason: DAEMON_REQUIRES_PROTECTED_REASON }, + "relay_e2ee_downgrade_rejected", + ); + channel.close(PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE, DAEMON_REQUIRES_PROTECTED_REASON); + return; + } + } + } emitHandlers(messageHandlers, data, data instanceof ArrayBuffer); }; @@ -105,7 +168,13 @@ export function createEncryptedTransport( } base.send(String(data)); }, - close: (code?: number, reason?: string) => base.close(code, reason), + // The channel decides the close code. Report it now: a socket close event + // arrives asynchronously, and the consumer must not act on an earlier + // error before it has seen the code. + close: (code?: number, reason?: string) => { + base.close(code, reason); + emitClose({ code, reason }); + }, onmessage: null, onclose: null, onerror: null, @@ -113,11 +182,23 @@ export function createEncryptedTransport( const startHandshake = async () => { try { - channel = await createClientChannel(relayTransport, daemonPublicKeyB64, { - onopen: emitOpen, - onmessage: (data) => emitMessage(data), - onclose: (code, reason) => emitClose({ code, reason }), - onerror: (error) => emitError(error), + channel = await openClientSecureChannel(relayTransport, { + daemonPublicKeyB64, + ...(minimumMode ? { minimumMode } : {}), + events: { + onopen: () => { + onSecurityMode?.(channel?.securityMode() ?? "legacy"); + emitOpen(); + }, + onmessage: (data) => emitMessage(data), + onclose: (code, reason) => emitClose({ code, reason }), + // Every fatal channel error is followed by a close that carries the + // code and reason; forwarding the error first would make the consumer + // reconnect before it sees them. + onerror: (error) => { + logger.warn({ err: normalizeTransportError(error) }, "relay_e2ee_channel_error"); + }, + }, }); } catch (error) { logger.warn({ err: normalizeTransportError(error) }, "relay_e2ee_handshake_failed"); diff --git a/packages/client/src/daemon-client-transport.test.ts b/packages/client/src/daemon-client-transport.test.ts index 6f80717dd1..963a8b541c 100644 --- a/packages/client/src/daemon-client-transport.test.ts +++ b/packages/client/src/daemon-client-transport.test.ts @@ -10,17 +10,9 @@ import { extractRelayMessage, } from "./daemon-client-transport.js"; -const createClientChannelMock = vi.hoisted(() => vi.fn()); - -vi.mock("@getpaseo/relay/e2ee", () => ({ - createClientChannel: createClientChannelMock, -})); - describe("daemon-client transport helpers", () => { test("createEncryptedTransport closes handshake failures with browser-safe code", async () => { - createClientChannelMock.mockReset(); - createClientChannelMock.mockRejectedValueOnce(new Error("handshake failed")); - + // An unusable daemon key fails the handshake before any frame is sent. const connection: { open: (() => void) | null } = { open: null }; const close = vi.fn(); @@ -40,8 +32,7 @@ describe("daemon-client transport helpers", () => { onError: () => () => {}, onMessage: () => () => {}, }, - "daemon-public-key", - { warn: vi.fn() }, + { daemonPublicKeyB64: "not-a-key", logger: { warn: vi.fn() } }, ); expect(connection.open).not.toBeNull(); diff --git a/packages/client/src/daemon-client-transport.ts b/packages/client/src/daemon-client-transport.ts index 01933fd1ec..90c4613f84 100644 --- a/packages/client/src/daemon-client-transport.ts +++ b/packages/client/src/daemon-client-transport.ts @@ -17,7 +17,8 @@ export { export { createEncryptedTransport, createRelayE2eeTransportFactory, - createRelayTransportFactory, + createRelayUpgradeTransportFactory, + describeEndpointSecurityClose, } from "./daemon-client-relay-e2ee-transport.js"; export { bindWsHandler, diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 6fef9fea40..f663aeb8be 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -919,6 +919,29 @@ test("keeps relay upgrade credentials out of the socket request", async () => { await expect(connecting).rejects.toThrow("Daemon client closed"); }); +test("keeps relay upgrade credentials out of the socket request without E2EE", async () => { + const mock = createMockTransport(); + const requests: Array<{ url: string; headers?: Record; protocols?: string[] }> = + []; + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_relay_header_test", + authHeader: "Bearer synthetic-review-token", + reconnect: { enabled: false }, + transportFactory: (request) => { + requests.push(request); + return mock.transport; + }, + }); + clients.push(client); + const connecting = client.connect(); + void connecting.catch(() => {}); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + expect(requests[0]).toEqual({ url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2" }); + await client.close(); + await expect(connecting).rejects.toThrow("Daemon client closed"); +}); + test("refuses relay password auth without an encrypted hello", async () => { const requests: unknown[] = []; const client = new DaemonClient({ @@ -4287,6 +4310,40 @@ test("reconnects after relay close with replaced-by-new-connection reason", asyn } }); +test("stops reconnecting after the transport closes for an endpoint security failure", async () => { + const close = { code: 4002, reason: "legacy security below required minimum" }; + const error = "Endpoint security handshake failed: legacy security below required minimum"; + useHeartbeatClock(); + try { + let attempts = 0; + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_security_close_test", + e2ee: { enabled: true, daemonPublicKeyB64: "daemon-public-key" }, + reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 }, + transportFactory: () => { + attempts += 1; + return mock.transport; + }, + }); + clients.push(client); + + const connecting = client.connect(); + void connecting.catch(() => {}); + await vi.waitFor(() => expect(attempts).toBe(1)); + mock.triggerClose(close); + + await expect(connecting).rejects.toThrow(error); + await vi.advanceTimersByTimeAsync(50); + expect(attempts).toBe(1); + expect(client.getConnectionState().status).toBe("disconnected"); + expect(client.lastError).toContain(error); + } finally { + vi.useRealTimers(); + } +}); + test("requires non-empty clientId", () => { expect(() => { const _client = new DaemonClient({ @@ -7898,3 +7955,264 @@ test("initial acquisition preserves structured transport failures and ordinary o cause: failure, }); }); + +// --------------------------------------------------------------------------- +// Relay endpoint security through the real secure channel +// --------------------------------------------------------------------------- + +import { + createDaemonChannel, + exportPublicKey, + generateKeyPair, + openDaemonSecureChannel, + type SecureChannel, + type Transport as RelayWire, +} from "@getpaseo/relay/e2ee"; + +/** + * An in-memory socket pair: the client side is a DaemonTransport, the daemon + * side is the relay Transport the daemon's secure channel runs over. Frames + * are delivered asynchronously like a real socket, and a close from either + * side reaches the other with its code, as the browser WebSocket does. + */ +function createRelayLink(options?: { toDaemon?: (text: string) => string }) { + const clientOpen = new Set<() => void>(); + const clientClose = new Set<(event?: unknown) => void>(); + const clientError = new Set<(event?: unknown) => void>(); + const clientMessage = new Set<(data: unknown, isBinary: boolean) => void>(); + let closed = false; + const wire: RelayWire = { + send: (data) => { + if (closed) throw new Error("socket closed"); + const isBinary = typeof data !== "string"; + setTimeout(() => { + for (const handler of clientMessage) handler(data, isBinary); + }, 0); + }, + close: (code = 1005, reason = "") => shutdown(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + const shutdown = (code: number, reason: string) => { + if (closed) return; + closed = true; + setTimeout(() => { + wire.onclose?.(code, reason); + for (const handler of clientClose) handler({ code, reason }); + }, 0); + }; + const transport: DaemonTransport = { + send: (data) => { + if (closed) throw new Error("socket closed"); + if (typeof data === "string") { + const text = options?.toDaemon ? options.toDaemon(data) : data; + setTimeout(() => wire.onmessage?.({ data: text, isBinary: false }), 0); + return; + } + const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data; + const copy = bytes.slice().buffer as ArrayBuffer; + setTimeout(() => wire.onmessage?.({ data: copy, isBinary: true }), 0); + }, + close: (code = 1005, reason = "") => shutdown(code, reason), + onOpen: (handler) => { + clientOpen.add(handler); + return () => clientOpen.delete(handler); + }, + onClose: (handler) => { + clientClose.add(handler); + return () => clientClose.delete(handler); + }, + onError: (handler) => { + clientError.add(handler); + return () => clientError.delete(handler); + }, + onMessage: (handler) => { + clientMessage.add(handler); + return () => clientMessage.delete(handler); + }, + }; + return { + transport, + wire, + open: () => { + for (const handler of clientOpen) handler(); + }, + isClosed: () => closed, + }; +} + +function serverInfoFrame(features: Record): string { + return wrapSessionMessage({ + type: "status", + payload: { + status: "server_info", + serverId: "srv_test", + hostname: null, + version: null, + features, + }, + }); +} + +/** Opens the daemon side of a link and answers the application hello with the daemon's feature flags. */ +function serveProtectedDaemon( + link: ReturnType, + keys: ReturnType, + features: Record, + received: unknown[] = [], +): void { + let channel: SecureChannel | null = null; + const pendingAnswers: string[] = []; + const opened = openDaemonSecureChannel(link.wire, { + daemonKeyPair: keys, + events: { + onmessage: (data) => { + received.push(data); + if (typeof data !== "string") return; + const frame = JSON.parse(data) as { type?: string }; + if (frame.type !== "hello") return; + const answer = serverInfoFrame(features); + if (channel) void channel.send(answer); + else pendingAnswers.push(answer); + }, + }, + }); + void (async () => { + try { + channel = await opened; + for (const answer of pendingAnswers.splice(0)) void channel.send(answer); + } catch { + // the test asserts on the client side + } + })(); +} + +test("connects in legacy mode to a daemon that only speaks the legacy construction", async () => { + const keys = generateKeyPair(); + const modes: string[] = []; + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_security_legacy_daemon", + e2ee: { + enabled: true, + daemonPublicKeyB64: exportPublicKey(keys.publicKey), + onSecurityMode: (mode) => modes.push(mode), + }, + reconnect: { enabled: false }, + transportFactory: () => { + const link = createRelayLink(); + const opened = createDaemonChannel(link.wire, keys, { + onmessage: (data) => { + if (typeof data !== "string") return; + if ((JSON.parse(data) as { type?: string }).type !== "hello") return; + void opened.then((ch) => ch.send(serverInfoFrame({ ownedSubscriptions: true }))); + }, + }); + void opened.catch(() => {}); + setTimeout(() => link.open(), 0); + return link.transport; + }, + }); + clients.push(client); + + await client.connect(); + expect(client.getConnectionState().status).toBe("connected"); + expect(modes).toEqual(["legacy"]); +}); + +test("refuses legacy endpoint security on reconnect once the daemon negotiated protected", async () => { + const keys = generateKeyPair(); + const modes: string[] = []; + const links: ReturnType[] = []; + const legacyReceived: unknown[] = []; + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_security_latch", + e2ee: { + enabled: true, + daemonPublicKeyB64: exportPublicKey(keys.publicKey), + onSecurityMode: (mode) => modes.push(mode), + }, + reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 }, + transportFactory: () => { + const link = createRelayLink(); + links.push(link); + if (links.length === 1) { + serveProtectedDaemon(link, keys, { relayProtectedSecurity: true }); + } else { + // A daemon that only speaks the legacy construction (older release). + void createDaemonChannel(link.wire, keys, { + onmessage: (data) => legacyReceived.push(data), + }).catch(() => {}); + } + setTimeout(() => link.open(), 0); + return link.transport; + }, + }); + clients.push(client); + + await client.connect(); + expect(client.getConnectionState().status).toBe("connected"); + expect(modes).toEqual(["protected"]); + + // The relay drops the session; the client reconnects to a legacy-only daemon. + links[0].wire.close(1012, "Server disconnected"); + await vi.waitFor(() => expect(links).toHaveLength(2)); + await vi.waitFor(() => expect(links[1].isClosed()).toBe(true)); + await vi.waitFor(() => expect(client.getConnectionState().status).toBe("disconnected")); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(links).toHaveLength(2); + expect(modes).toEqual(["protected"]); + expect(legacyReceived).toEqual([]); + expect(client.lastError).toContain( + "Endpoint security handshake failed: daemon offered only legacy endpoint security", + ); +}); + +test("closes before admission when a protected-capable daemon is reached in legacy mode", async () => { + const keys = generateKeyPair(); + const daemonReceived: unknown[] = []; + let attempts = 0; + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_security_downgrade", + e2ee: { enabled: true, daemonPublicKeyB64: exportPublicKey(keys.publicKey) }, + reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 }, + transportFactory: () => { + attempts += 1; + // A relay in the middle strips the protected offer from the client hello. + const link = createRelayLink({ + toDaemon: (text) => { + if (!text.startsWith("{")) return text; + const parsed = JSON.parse(text) as Record; + if (parsed.type !== "e2ee_hello") return text; + const { protected: _dropped, ...legacyHello } = parsed; + return JSON.stringify(legacyHello); + }, + }); + serveProtectedDaemon(link, keys, { relayProtectedSecurity: true }, daemonReceived); + setTimeout(() => link.open(), 0); + return link.transport; + }, + }); + clients.push(client); + + const connecting = client.connect(); + void connecting.catch(() => {}); + const queued = client.ping({ timeoutMs: 10_000 }); + void queued.catch(() => {}); + + await expect(connecting).rejects.toThrow( + "Endpoint security handshake failed: daemon requires protected endpoint security", + ); + await expect(queued).rejects.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(attempts).toBe(1); + expect(client.getConnectionState().status).toBe("disconnected"); + // Only the application hello reached the daemon; the queued command never did. + expect(daemonReceived).toHaveLength(1); + expect(JSON.parse(daemonReceived[0] as string)).toMatchObject({ type: "hello" }); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index de127c5b6d..524df56704 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -162,10 +162,11 @@ import { } from "@getpaseo/protocol/binary-frames/index"; import { createRelayE2eeTransportFactory, - createRelayTransportFactory, + createRelayUpgradeTransportFactory, createWebSocketTransportFactory, decodeMessageData, defaultWebSocketFactory, + describeEndpointSecurityClose, describeTransportClose, describeTransportError, type DaemonTransport, @@ -394,6 +395,9 @@ export type DaemonEventHandler = (event: DaemonEvent) => void; export type BrowserAutomationExecuteRequestMessage = BrowserAutomationExecuteRequest; export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteResponse; +/** Endpoint security negotiated on a relay connection. See docs/relay-endpoint-security.md. */ +export type RelaySecurityMode = "legacy" | "protected"; + export interface DaemonClientConfig { /** Deliver compact bodies/hash references to a caller-owned snapshot cache. * The default keeps public SDK snapshot entries expanded. */ @@ -415,6 +419,10 @@ export interface DaemonClientConfig { e2ee?: { enabled?: boolean; daemonPublicKeyB64?: string; + /** Refuse connections whose negotiated endpoint security is below this mode. */ + minimumMode?: RelaySecurityMode; + /** Called with the negotiated mode after each authenticated relay handshake. */ + onSecurityMode?: (mode: RelaySecurityMode) => void; }; reconnect?: { enabled?: boolean; @@ -1334,6 +1342,12 @@ export class DaemonClient { private completedBinaryFileReads = new Map(); private logger: Logger; private pendingSendQueue: PendingSend[] = []; + /** + * Lowest relay endpoint security this client accepts on its next attempt. + * Starts at the configured minimum and rises to "protected" once a daemon + * negotiates it, so a later reconnect cannot be downgraded to legacy. + */ + private relaySecurityFloorValue: RelaySecurityMode | undefined; private readonly logConnectionPath: "direct" | "relay"; private readonly logServerId: string | null; private readonly logClientIdHash: string; @@ -1349,6 +1363,7 @@ export class DaemonClient { constructor(private config: DaemonClientConfig) { this.logger = config.logger ?? consoleLogger; + this.relaySecurityFloorValue = config.e2ee?.minimumMode; this.logConnectionPath = isRelayClientWebSocketUrl(this.config.url) ? "relay" : "direct"; let parsedUrlForLog: URL | null = null; try { @@ -1450,14 +1465,15 @@ export class DaemonClient { this.config.transportFactory ?? createWebSocketTransportFactory(this.config.webSocketFactory ?? defaultWebSocketFactory); const isRelayTransport = isRelayClientWebSocketUrl(this.config.url); - const shouldUseRelayE2ee = this.config.e2ee?.enabled === true && isRelayTransport; this.assertEncryptedRelayAuth(selected.helloAuth, isRelayTransport); + // Relay upgrades are routing only: credentials never reach the relay socket. let transportFactory = isRelayTransport - ? createRelayTransportFactory(baseTransportFactory) + ? createRelayUpgradeTransportFactory(baseTransportFactory) : baseTransportFactory; - if (shouldUseRelayE2ee) { - const daemonPublicKeyB64 = this.config.e2ee?.daemonPublicKeyB64; + // Endpoint security is selected by configuration, never inferred from the URL. + if (this.config.e2ee?.enabled === true) { + const daemonPublicKeyB64 = this.config.e2ee.daemonPublicKeyB64; if (!daemonPublicKeyB64) { throw new Error("daemonPublicKeyB64 is required for relay E2EE"); } @@ -1465,6 +1481,11 @@ export class DaemonClient { baseFactory: transportFactory, daemonPublicKeyB64, logger: this.logger, + ...(this.relaySecurityFloorValue ? { minimumMode: this.relaySecurityFloorValue } : {}), + onSecurityMode: (mode) => { + if (mode === "protected") this.relaySecurityFloorValue = "protected"; + this.config.e2ee?.onSecurityMode?.(mode); + }, }); } const transportUrl = this.resolveTransportUrlForAttempt(); @@ -1522,9 +1543,12 @@ export class DaemonClient { this.pendingGenericTransportErrorTimeout = null; } this.authFailureReasonValue ??= authFailureFromLegacyClose(event); + // An endpoint security policy close cannot be fixed by retrying. + const securityFailure = describeEndpointSecurityClose(event); + if (securityFailure) this.setReconnectEnabled(false); const reason = this.authFailureReasonValue ? new DaemonAuthenticationError(this.authFailureReasonValue).message - : describeTransportClose(event); + : (securityFailure ?? describeTransportClose(event)); if (reason) { this.lastErrorValue = reason; } @@ -1826,6 +1850,11 @@ export class DaemonClient { return this.connectionState.status === "connecting"; } + /** The relay endpoint security this client requires on its next attempt. */ + get relaySecurityFloor(): RelaySecurityMode | undefined { + return this.relaySecurityFloorValue; + } + get lastError(): string | null { return this.lastErrorValue; } diff --git a/packages/client/src/node/connector.test.ts b/packages/client/src/node/connector.test.ts index 6b31107f24..6e916d4bec 100644 --- a/packages/client/src/node/connector.test.ts +++ b/packages/client/src/node/connector.test.ts @@ -151,3 +151,59 @@ test("encrypted connector rejects overload synchronously and preserves the physi ).toBe(true); expect(server.wss.clients.size).toBe(1); }); + +import { openDaemonSecureChannel } from "@getpaseo/relay/e2ee"; +test("encrypted connector refuses legacy endpoint security on a later attempt once protected was negotiated", async () => { + const server = await endpoint(); + const keys = generateKeyPair(); + const legacyReceived: unknown[] = []; + let connections = 0; + server.wss.on("connection", (socket) => { + connections += 1; + const wire: Transport = { + send: (data) => socket.send(data), + close: (code, reason) => socket.close(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + socket.on("message", (data, isBinary) => { + const bytes = Buffer.from(data as Buffer); + wire.onmessage?.({ + data: isBinary + ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) + : bytes.toString(), + isBinary, + }); + }); + socket.on("close", (code, reason) => wire.onclose?.(code, reason.toString())); + if (connections === 1) { + void openDaemonSecureChannel(wire, { daemonKeyPair: keys }).catch(() => {}); + } else { + // The daemon behind the relay now only speaks the legacy construction. + void createDaemonChannel(wire, keys, { + onmessage: (data) => legacyReceived.push(data), + }).catch(() => {}); + } + }); + const port = new URL(server.url).port; + const factory = createHostTransportFactory( + `relay://127.0.0.1:${port}/srv_latch?key=${encodeURIComponent(exportPublicKey(keys.publicKey))}`, + ); + + const first = factory({ url: server.url }); + cleanup.push(async () => first.close()); + await new Promise((resolve) => first.onOpen(resolve)); + first.close(); + + const second = factory({ url: server.url }); + cleanup.push(async () => second.close()); + let opened = false; + second.onOpen(() => { + opened = true; + }); + const closed = await new Promise((resolve) => second.onClose(resolve)); + expect(closed).toMatchObject({ code: 4002, reason: expect.stringContaining("legacy") }); + expect(opened).toBe(false); + expect(legacyReceived).toEqual([]); +}); diff --git a/packages/client/src/node/index.ts b/packages/client/src/node/index.ts index 857c2ba432..7132d09f10 100644 --- a/packages/client/src/node/index.ts +++ b/packages/client/src/node/index.ts @@ -7,6 +7,7 @@ import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js"; import { createWebSocketTransportFactory } from "../daemon-client-websocket-transport.js"; import { createRelayE2eeTransportFactory } from "../daemon-client-relay-e2ee-transport.js"; +import type { SecurityMode } from "@getpaseo/relay/e2ee"; import type { DaemonTransport, DaemonTransportFactory, @@ -24,6 +25,10 @@ export function createHostTransportFactory( budget = new HostTransportBudget(), ): DaemonTransportFactory { const target = parseHostTarget(connection); + // Rises to "protected" once the daemon negotiates it, so a later attempt + // cannot be downgraded to legacy endpoint security. + let securityFloor: SecurityMode | undefined = + target.kind === "socket" ? target.offer?.minimumSecurityMode : undefined; return (attemptOptions) => { const opens = new Set<() => void>(); const closes = new Set<(event?: unknown) => void>(); @@ -120,6 +125,10 @@ export function createHostTransportFactory( factory = createRelayE2eeTransportFactory({ baseFactory: factory, daemonPublicKeyB64: socketTarget.offer.daemonPublicKeyB64, + ...(securityFloor ? { minimumMode: securityFloor } : {}), + onSecurityMode: (mode) => { + if (mode === "protected") securityFloor = "protected"; + }, logger: { warn() {} }, assertOutboundCapacity: (bytes) => assertOutboundCapacity(bytes), }); diff --git a/packages/protocol/src/connection-offer.ts b/packages/protocol/src/connection-offer.ts index 073cf0be0c..3b5d455ad6 100644 --- a/packages/protocol/src/connection-offer.ts +++ b/packages/protocol/src/connection-offer.ts @@ -14,6 +14,9 @@ export const ConnectionOfferV2Schema = z.object({ endpoint: z.string().min(1), useTls: z.boolean().optional(), }), + // COMPAT(relayProtectedSecurity): added in v0.13.0. Present when the daemon requires + // protected endpoint security; clients pin it before their first handshake. + minimumSecurityMode: z.literal("protected").optional(), }); export type ConnectionOfferV2 = z.infer; diff --git a/packages/protocol/src/daemon-endpoints.test.ts b/packages/protocol/src/daemon-endpoints.test.ts index 3ac1c111ac..58c08f95ed 100644 --- a/packages/protocol/src/daemon-endpoints.test.ts +++ b/packages/protocol/src/daemon-endpoints.test.ts @@ -232,3 +232,40 @@ describe("relay connection URI", () => { ).toEqual(parts); }); }); + +describe("relay connection URI security mode", () => { + test("round-trips a protected minimum security mode", () => { + const uri = serializeRelayConnectionUri({ + offer: { + v: 2, + serverId: "srv_test", + daemonPublicKeyB64: "key", + relay: { endpoint: "relay.example.com:443", useTls: true }, + minimumSecurityMode: "protected", + }, + }); + + expect(new URL(uri).searchParams.get("security")).toBe("protected"); + expect(parseRelayConnectionUri(uri).offer.minimumSecurityMode).toBe("protected"); + }); + + test("omits the security parameter when the offer has no minimum", () => { + const uri = serializeRelayConnectionUri({ + offer: { + v: 2, + serverId: "srv_test", + daemonPublicKeyB64: "key", + relay: { endpoint: "relay.example.com:443", useTls: true }, + }, + }); + + expect(new URL(uri).searchParams.has("security")).toBe(false); + expect(parseRelayConnectionUri(uri).offer).not.toHaveProperty("minimumSecurityMode"); + }); + + test("rejects an unknown security mode instead of silently downgrading", () => { + expect(() => + parseRelayConnectionUri("relay://relay.example.com:443/srv_test?key=k&security=legacy"), + ).toThrow("unsupported security mode"); + }); +}); diff --git a/packages/protocol/src/daemon-endpoints.ts b/packages/protocol/src/daemon-endpoints.ts index a4b72c9416..5c4270bcb9 100644 --- a/packages/protocol/src/daemon-endpoints.ts +++ b/packages/protocol/src/daemon-endpoints.ts @@ -42,12 +42,17 @@ export function parseRelayConnectionUri(input: string): ParsedRelayConnectionUri } const endpoint = normalizeHostPort(url.host); const password = url.searchParams.get("password") || undefined; + const security = url.searchParams.get("security"); + if (security !== null && security !== "protected") { + throw new Error("Relay connection URI has an unsupported security mode"); + } return { offer: { v: 2, serverId, daemonPublicKeyB64: key, relay: { endpoint, useTls: url.searchParams.get("ssl") === "true" }, + ...(security === "protected" ? { minimumSecurityMode: "protected" as const } : {}), }, ...(password ? { password } : {}), }; @@ -59,6 +64,9 @@ export function serializeRelayConnectionUri(parts: ParsedRelayConnectionUri): st ); url.searchParams.set("key", parts.offer.daemonPublicKeyB64); if (parts.offer.relay.useTls) url.searchParams.set("ssl", "true"); + if (parts.offer.minimumSecurityMode) { + url.searchParams.set("security", parts.offer.minimumSecurityMode); + } if (parts.password) url.searchParams.set("password", parts.password); return url.toString(); } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 3e5deeb88e..1fcd96ed85 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -187,6 +187,9 @@ const MutableBrowserToolsConfigSchema = z const MutableRelayConfigSchema = z .object({ enabled: z.boolean(), + // COMPAT(relayProtectedSecurity): added in v0.13.0, keep optional while old daemons are supported. + // When true the daemon admits only relay sessions that negotiated protected endpoint security. + requireProtectedSecurity: z.boolean().optional(), }) .passthrough(); @@ -3745,6 +3748,9 @@ export const ServerInfoStatusPayloadSchema = z workspaceMarkUnread: z.boolean().optional(), // COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X. hubRelationship: z.boolean().optional(), + // COMPAT(relayProtectedSecurity): added in v0.13.0. A capable daemon always advertises + // it; relay clients that negotiated legacy security reject the connection on seeing it. + relayProtectedSecurity: z.boolean().optional(), // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15. projectGithubClone: z.boolean().optional(), // COMPAT(workspaceGithubRepositorySearch): added in v0.1.108, remove gate after 2027-01-15. diff --git a/packages/relay/package.json b/packages/relay/package.json index 6b28a6e5d3..ea3d9d1111 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -35,6 +35,9 @@ "test:watch": "vitest" }, "dependencies": { + "@noble/ciphers": "^2.4.0", + "@noble/curves": "^2.4.0", + "@noble/hashes": "^2.4.0", "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", "ws": "^8.14.2" diff --git a/packages/relay/src/e2ee.ts b/packages/relay/src/e2ee.ts index c3a3b852fb..231e517058 100644 --- a/packages/relay/src/e2ee.ts +++ b/packages/relay/src/e2ee.ts @@ -1,3 +1,20 @@ +export { + openClientSecureChannel, + openDaemonSecureChannel, + PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE, + PROTECTED_MODE_REQUIRED_CLOSE_CODE, + PROTECTED_MODE_REQUIRED_CLOSE_REASON, + PROTECTED_RECORD_FAILED_CLOSE_CODE, + SecureChannelRejectedError, +} from "./secure-channel.js"; +export type { + ClientSecureChannelOptions, + DaemonSecureChannelOptions, + SecureChannel, + SecureChannelEvents, + SecurityMode, +} from "./secure-channel.js"; + export { createClientChannel, createDaemonChannel, EncryptedChannel } from "./encrypted-channel.js"; export type { Transport, TransportMessage, EncryptedChannelEvents } from "./encrypted-channel.js"; diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts index b9f455e0f1..9f8dbc43d8 100644 --- a/packages/relay/src/index.ts +++ b/packages/relay/src/index.ts @@ -16,4 +16,5 @@ export { EncryptedChannel, maxBase64EncryptedPlaintextByteLength, } from "./encrypted-channel.js"; +export { maxSecuredPlaintextByteLength } from "./secure-channel.js"; export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js"; diff --git a/packages/relay/src/noise/cipher-state.ts b/packages/relay/src/noise/cipher-state.ts new file mode 100644 index 0000000000..e54ab6bc07 --- /dev/null +++ b/packages/relay/src/noise/cipher-state.ts @@ -0,0 +1,76 @@ +import { chacha20poly1305 } from "@noble/ciphers/chacha.js"; + +/** Noise spec §5.1 CipherState for ChaChaPoly. Nonce is 4 zero bytes then 64-bit little-endian n. */ + +export const NOISE_TAG_BYTES = 16; +export const NOISE_MAX_MESSAGE_BYTES = 65535; +const MAX_NONCE = (1n << 64n) - 1n; + +export class NoiseProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = "NoiseProtocolError"; + } +} + +export class CipherState { + private k: Uint8Array | null = null; + private n = 0n; + + initializeKey(key: Uint8Array | null, nonce = 0n): void { + this.k = key; + this.n = nonce; + } + + hasKey(): boolean { + return this.k !== null; + } + + nonce(): bigint { + return this.n; + } + + encryptWithAd(ad: Uint8Array, plaintext: Uint8Array): Uint8Array { + if (this.k === null) return plaintext; + const nonce = this.takeNonce(); + return chacha20poly1305(this.k, nonce, ad).encrypt(plaintext); + } + + decryptWithAd(ad: Uint8Array, ciphertext: Uint8Array): Uint8Array { + if (this.k === null) return ciphertext; + if (this.n === MAX_NONCE) throw new NoiseProtocolError("nonce exhausted"); + let plaintext: Uint8Array; + try { + plaintext = chacha20poly1305(this.k, encodeNonce(this.n), ad).decrypt(ciphertext); + } catch { + throw new NoiseProtocolError("authentication failed"); + } + // Only a successful authentication advances the receive counter. + this.n += 1n; + return plaintext; + } + + /** Reserves the next nonce synchronously so concurrent senders never share one. */ + private takeNonce(): Uint8Array { + if (this.n === MAX_NONCE) throw new NoiseProtocolError("nonce exhausted"); + const nonce = encodeNonce(this.n); + this.n += 1n; + return nonce; + } + + clear(): void { + this.k?.fill(0); + this.k = null; + this.n = MAX_NONCE; + } +} + +function encodeNonce(n: bigint): Uint8Array { + const nonce = new Uint8Array(12); + let value = n; + for (let i = 4; i < 12; i += 1) { + nonce[i] = Number(value & 0xffn); + value >>= 8n; + } + return nonce; +} diff --git a/packages/relay/src/noise/fixtures/nk-25519-chachapoly-sha256.json b/packages/relay/src/noise/fixtures/nk-25519-chachapoly-sha256.json new file mode 100644 index 0000000000..07b258c384 --- /dev/null +++ b/packages/relay/src/noise/fixtures/nk-25519-chachapoly-sha256.json @@ -0,0 +1,85 @@ +{ + "vectors": [ + { + "name": "Noise_NK_25519_ChaChaPoly_SHA256", + "pattern": "NK", + "dh": "25519", + "cipher": "ChaChaPoly", + "hash": "SHA256", + "init_prologue": "4a6f686e2047616c74", + "init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a", + "init_remote_static": "31e0303fd6418d2f8c0e78b91f22e8caed0fbe48656dcf4767e4834f701b8f62", + "resp_prologue": "4a6f686e2047616c74", + "resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893", + "resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b", + "messages": [ + { + "payload": "4c756477696720766f6e204d69736573", + "ciphertext": "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c79448134d00711fdb390a0d178fa008f6d47d2891e5ea18ae136c3b4c23ac384efb0" + }, + { + "payload": "4d757272617920526f746862617264", + "ciphertext": "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f1448088438ea16e3701bc0d77744f117bee22451c9afa7f4cdbbcff00c04a8ee0913c88" + }, + { + "payload": "462e20412e20486179656b", + "ciphertext": "a62de29ce27cb80245d440d986ed816c156e9d757d7008df2198b0" + }, + { + "payload": "4361726c204d656e676572", + "ciphertext": "174a35f11c689f4530d7208618e0564ae12f2f50ba8eb4df5382ff" + }, + { + "payload": "4a65616e2d426170746973746520536179", + "ciphertext": "337e475ebb8eae60f91974c4e455a5af38d1d8628d1803b160d60442874b0a1777" + }, + { + "payload": "457567656e2042f6686d20766f6e2042617765726b", + "ciphertext": "047e80e060b7bb08b53c5a23dfe9920cae135b9d1dc6302fc475003062723700366346ac9d" + } + ], + "source": "https://raw.githubusercontent.com/rweather/noise-c/master/tests/vector/cacophony.txt" + }, + { + "name": "Noise_NK_25519_ChaChaPoly_SHA256", + "pattern": "NK", + "dh": "25519", + "cipher": "ChaChaPoly", + "hash": "SHA256", + "init_prologue": "50726f6c6f677565313233", + "init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a", + "init_remote_static": "31e0303fd6418d2f8c0e78b91f22e8caed0fbe48656dcf4767e4834f701b8f62", + "resp_prologue": "50726f6c6f677565313233", + "resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893", + "resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b", + "messages": [ + { + "payload": "4c756477696720766f6e204d69736573", + "ciphertext": "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c79448134d00711fdb390a0d178fa008f6d47f49a76e297aa164052a3d842aa8ff7d8" + }, + { + "payload": "4d757272617920526f746862617264", + "ciphertext": "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f1448088438ea16e3701bc0d77744f117bee22451628b075da65d4114bd343e2d93006c4" + }, + { + "payload": "462e20412e20486179656b", + "ciphertext": "a62de29ce27cb80245d440d986ed816c156e9d757d7008df2198b0" + }, + { + "payload": "4361726c204d656e676572", + "ciphertext": "174a35f11c689f4530d7208618e0564ae12f2f50ba8eb4df5382ff" + }, + { + "payload": "4a65616e2d426170746973746520536179", + "ciphertext": "337e475ebb8eae60f91974c4e455a5af38d1d8628d1803b160d60442874b0a1777" + }, + { + "payload": "457567656e2042f6686d20766f6e2042617765726b", + "ciphertext": "047e80e060b7bb08b53c5a23dfe9920cae135b9d1dc6302fc475003062723700366346ac9d" + } + ], + "handshake_hash": "d5c4ce9ffe8bcd940aa50f842a5d4d90d3f7163f4b3916deb87a5d747712d718", + "source": "https://raw.githubusercontent.com/rweather/noise-c/master/tests/vector/noise-c-basic.txt" + } + ] +} diff --git a/packages/relay/src/noise/index.ts b/packages/relay/src/noise/index.ts new file mode 100644 index 0000000000..758d6a540b --- /dev/null +++ b/packages/relay/src/noise/index.ts @@ -0,0 +1,241 @@ +/** + * Conforming Noise_NK_25519_ChaChaPoly_SHA256 (Noise Protocol Framework rev 34). + * + * The pattern is executed token by token from its definition, so the vector + * tests exercise the same code path as production. Only NK is provided; the + * daemon's static X25519 key is the responder's `s`, known to the initiator + * from pairing. + * + * NK: + * <- s + * ... + * -> e, es + * <- e, ee + */ +import { x25519 } from "@noble/curves/ed25519.js"; +import { CipherState, NoiseProtocolError, NOISE_MAX_MESSAGE_BYTES } from "./cipher-state.js"; +import { SymmetricState, concat } from "./symmetric-state.js"; + +export { + CipherState, + NoiseProtocolError, + NOISE_MAX_MESSAGE_BYTES, + NOISE_TAG_BYTES, +} from "./cipher-state.js"; + +export const NOISE_NK_PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_SHA256"; +export const DH_BYTES = 32; + +export interface NoiseKeyPair { + publicKey: Uint8Array; + secretKey: Uint8Array; +} + +export interface NoiseInitiatorOptions { + /** Responder's static public key, trusted out of band. */ + remoteStatic: Uint8Array; + prologue: Uint8Array; + /** Test injection only; production generates a fresh ephemeral. */ + ephemeral?: NoiseKeyPair; +} + +export interface NoiseResponderOptions { + localStatic: NoiseKeyPair; + prologue: Uint8Array; + /** Test injection only; production generates a fresh ephemeral. */ + ephemeral?: NoiseKeyPair; +} + +export interface TransportKeys { + /** Initiator → responder. */ + send: CipherState; + /** Responder → initiator. */ + receive: CipherState; +} + +export interface NoiseHandshake { + /** Builds the next handshake message this side is due to write. */ + writeMessage(payload: Uint8Array): Uint8Array; + /** Consumes the next handshake message this side is due to read; returns its payload. */ + readMessage(message: Uint8Array): Uint8Array; + /** True once both handshake messages have been processed. */ + isComplete(): boolean; + handshakeHash(): Uint8Array; + /** Derives transport keys, oriented for this side. Ends the handshake state. */ + split(): { send: CipherState; receive: CipherState }; +} + +type Token = "e" | "es" | "ee"; +const NK_MESSAGES: ReadonlyArray> = [ + ["e", "es"], + ["e", "ee"], +]; + +export function generateNoiseKeyPair(): NoiseKeyPair { + const secretKey = x25519.utils.randomSecretKey(); + return { secretKey, publicKey: x25519.getPublicKey(secretKey) }; +} + +export function createNoiseInitiator(options: NoiseInitiatorOptions): NoiseHandshake { + requireKeyLength(options.remoteStatic, "remote static key"); + return new HandshakeState({ + initiator: true, + prologue: options.prologue, + s: null, + rs: options.remoteStatic, + ephemeral: options.ephemeral ?? null, + }); +} + +export function createNoiseResponder(options: NoiseResponderOptions): NoiseHandshake { + requireKeyLength(options.localStatic.publicKey, "local static public key"); + requireKeyLength(options.localStatic.secretKey, "local static secret key"); + return new HandshakeState({ + initiator: false, + prologue: options.prologue, + s: options.localStatic, + rs: null, + ephemeral: options.ephemeral ?? null, + }); +} + +class HandshakeState implements NoiseHandshake { + private readonly symmetric = new SymmetricState(NOISE_NK_PROTOCOL_NAME); + private readonly initiator: boolean; + private readonly s: NoiseKeyPair | null; + private e: NoiseKeyPair | null; + private rs: Uint8Array | null; + private re: Uint8Array | null = null; + private messageIndex = 0; + private finished = false; + + constructor(params: { + initiator: boolean; + prologue: Uint8Array; + s: NoiseKeyPair | null; + rs: Uint8Array | null; + ephemeral: NoiseKeyPair | null; + }) { + this.initiator = params.initiator; + this.s = params.s; + this.rs = params.rs; + this.e = params.ephemeral; + this.symmetric.mixHash(params.prologue); + // Pre-message "<- s": the responder's static public key. + const preMessageStatic = this.initiator ? this.rs : this.s?.publicKey; + if (!preMessageStatic) throw new NoiseProtocolError("missing responder static key"); + this.symmetric.mixHash(preMessageStatic); + } + + writeMessage(payload: Uint8Array): Uint8Array { + const tokens = this.nextTokens(this.initiator); + const parts: Uint8Array[] = []; + for (const token of tokens) { + switch (token) { + case "e": { + if (!this.e) this.e = generateNoiseKeyPair(); + parts.push(this.e.publicKey); + this.symmetric.mixHash(this.e.publicKey); + break; + } + case "es": + this.mixDh(this.initiator ? this.e : this.s, this.initiator ? this.rs : this.re); + break; + case "ee": + this.mixDh(this.e, this.re); + break; + } + } + parts.push(this.symmetric.encryptAndHash(payload)); + const message = concat(...parts); + if (message.byteLength > NOISE_MAX_MESSAGE_BYTES) { + throw new NoiseProtocolError("handshake message too large"); + } + this.advance(); + return message; + } + + readMessage(message: Uint8Array): Uint8Array { + if (message.byteLength > NOISE_MAX_MESSAGE_BYTES) { + throw new NoiseProtocolError("handshake message too large"); + } + const tokens = this.nextTokens(!this.initiator); + let offset = 0; + for (const token of tokens) { + switch (token) { + case "e": { + if (message.byteLength < offset + DH_BYTES) { + throw new NoiseProtocolError("handshake message truncated"); + } + this.re = message.slice(offset, offset + DH_BYTES); + offset += DH_BYTES; + this.symmetric.mixHash(this.re); + break; + } + case "es": + this.mixDh(this.initiator ? this.e : this.s, this.initiator ? this.rs : this.re); + break; + case "ee": + this.mixDh(this.e, this.re); + break; + } + } + const payload = this.symmetric.decryptAndHash(message.slice(offset)); + this.advance(); + return payload; + } + + isComplete(): boolean { + return this.messageIndex >= NK_MESSAGES.length; + } + + handshakeHash(): Uint8Array { + return this.symmetric.handshakeHash(); + } + + split(): { send: CipherState; receive: CipherState } { + if (!this.isComplete()) throw new NoiseProtocolError("handshake incomplete"); + if (this.finished) throw new NoiseProtocolError("handshake already split"); + this.finished = true; + const [c1, c2] = this.symmetric.split(); + this.e?.secretKey.fill(0); + this.e = null; + return this.initiator ? { send: c1, receive: c2 } : { send: c2, receive: c1 }; + } + + private nextTokens(expectedWriterIsInitiator: boolean): ReadonlyArray { + if (this.isComplete() || this.finished) throw new NoiseProtocolError("handshake complete"); + const writerIsInitiator = this.messageIndex % 2 === 0; + if (writerIsInitiator !== expectedWriterIsInitiator) { + throw new NoiseProtocolError("handshake message out of turn"); + } + return NK_MESSAGES[this.messageIndex]; + } + + private advance(): void { + this.messageIndex += 1; + } + + private mixDh(local: NoiseKeyPair | null, remote: Uint8Array | null): void { + if (!local || !remote) throw new NoiseProtocolError("missing DH key"); + let shared: Uint8Array; + try { + shared = x25519.getSharedSecret(local.secretKey, remote); + } catch { + throw new NoiseProtocolError("invalid DH public key"); + } + if (isAllZero(shared)) throw new NoiseProtocolError("invalid DH public key"); + this.symmetric.mixKey(shared); + shared.fill(0); + } +} + +function requireKeyLength(key: Uint8Array, label: string): void { + if (key.byteLength !== DH_BYTES) throw new NoiseProtocolError(`${label} must be 32 bytes`); +} + +function isAllZero(bytes: Uint8Array): boolean { + let acc = 0; + for (const byte of bytes) acc |= byte; + return acc === 0; +} diff --git a/packages/relay/src/noise/noise.test.ts b/packages/relay/src/noise/noise.test.ts new file mode 100644 index 0000000000..b8d1228d1a --- /dev/null +++ b/packages/relay/src/noise/noise.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "vitest"; +import { + CipherState, + createNoiseInitiator, + createNoiseResponder, + generateNoiseKeyPair, + NoiseProtocolError, + NOISE_NK_PROTOCOL_NAME, +} from "./index.js"; +import vectors from "./fixtures/nk-25519-chachapoly-sha256.json" with { type: "json" }; +import { x25519 } from "@noble/curves/ed25519.js"; + +function hex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("hex"); +} +function fromHex(hexText: string): Uint8Array { + return new Uint8Array(Buffer.from(hexText, "hex")); +} +function keyPairFromSecret(secretHex: string) { + const secretKey = fromHex(secretHex); + return { secretKey, publicKey: x25519.getPublicKey(secretKey) }; +} + +describe(NOISE_NK_PROTOCOL_NAME, () => { + for (const vector of vectors.vectors) { + test(`matches vector from ${vector.source}`, () => { + expect(vector.name).toBe(NOISE_NK_PROTOCOL_NAME); + const responderStatic = keyPairFromSecret(vector.resp_static); + expect(hex(responderStatic.publicKey)).toBe(vector.init_remote_static); + const initiator = createNoiseInitiator({ + remoteStatic: fromHex(vector.init_remote_static), + prologue: fromHex(vector.init_prologue), + ephemeral: keyPairFromSecret(vector.init_ephemeral), + }); + const responder = createNoiseResponder({ + localStatic: responderStatic, + prologue: fromHex(vector.resp_prologue), + ephemeral: keyPairFromSecret(vector.resp_ephemeral), + }); + + const [m1, m2, ...transport] = vector.messages; + const msg1 = initiator.writeMessage(fromHex(m1.payload)); + expect(hex(msg1)).toBe(m1.ciphertext); + expect(hex(responder.readMessage(msg1))).toBe(m1.payload); + const msg2 = responder.writeMessage(fromHex(m2.payload)); + expect(hex(msg2)).toBe(m2.ciphertext); + expect(hex(initiator.readMessage(msg2))).toBe(m2.payload); + expect(initiator.isComplete()).toBe(true); + expect(responder.isComplete()).toBe(true); + if ("handshake_hash" in vector) { + expect(hex(initiator.handshakeHash())).toBe(vector.handshake_hash); + expect(hex(responder.handshakeHash())).toBe(vector.handshake_hash); + } + + const initiatorKeys = initiator.split(); + const responderKeys = responder.split(); + const empty = new Uint8Array(0); + transport.forEach((message, index) => { + const fromInitiator = index % 2 === 0; + const sender = fromInitiator ? initiatorKeys.send : responderKeys.send; + const receiver = fromInitiator ? responderKeys.receive : initiatorKeys.receive; + const ciphertext = sender.encryptWithAd(empty, fromHex(message.payload)); + expect(hex(ciphertext)).toBe(message.ciphertext); + expect(hex(receiver.decryptWithAd(empty, ciphertext))).toBe(message.payload); + }); + }); + } + + test("rejects a replayed, reflected, or out-of-order transport message", () => { + const daemon = generateNoiseKeyPair(); + const prologue = new TextEncoder().encode("test"); + const initiator = createNoiseInitiator({ remoteStatic: daemon.publicKey, prologue }); + const responder = createNoiseResponder({ localStatic: daemon, prologue }); + const empty = new Uint8Array(0); + responder.readMessage(initiator.writeMessage(empty)); + initiator.readMessage(responder.writeMessage(empty)); + const client = initiator.split(); + const server = responder.split(); + + const first = client.send.encryptWithAd(empty, new Uint8Array([1])); + const second = client.send.encryptWithAd(empty, new Uint8Array([2])); + expect(server.receive.decryptWithAd(empty, first)).toEqual(new Uint8Array([1])); + // duplicate + expect(() => server.receive.decryptWithAd(empty, first)).toThrow(NoiseProtocolError); + // a failed attempt did not advance the counter: the genuine next record still decrypts + expect(server.receive.decryptWithAd(empty, second)).toEqual(new Uint8Array([2])); + // reflection: a record the client sent, handed back to the client + expect(() => client.receive.decryptWithAd(empty, second)).toThrow(NoiseProtocolError); + // out of order + const third = client.send.encryptWithAd(empty, new Uint8Array([3])); + const fourth = client.send.encryptWithAd(empty, new Uint8Array([4])); + expect(() => server.receive.decryptWithAd(empty, fourth)).toThrow(NoiseProtocolError); + expect(server.receive.decryptWithAd(empty, third)).toEqual(new Uint8Array([3])); + expect(server.receive.decryptWithAd(empty, fourth)).toEqual(new Uint8Array([4])); + }); + + test("a recorded first message cannot derive the keys of a fresh responder", () => { + const daemon = generateNoiseKeyPair(); + const prologue = new TextEncoder().encode("test"); + const empty = new Uint8Array(0); + const initiator = createNoiseInitiator({ remoteStatic: daemon.publicKey, prologue }); + const msg1 = initiator.writeMessage(empty); + const original = createNoiseResponder({ localStatic: daemon, prologue }); + original.readMessage(msg1); + initiator.readMessage(original.writeMessage(empty)); + const clientKeys = initiator.split(); + const record = clientKeys.send.encryptWithAd(empty, new Uint8Array([7])); + + const replayTarget = createNoiseResponder({ localStatic: daemon, prologue }); + replayTarget.readMessage(msg1); + replayTarget.writeMessage(empty); + const replayKeys = replayTarget.split(); + expect(() => replayKeys.receive.decryptWithAd(empty, record)).toThrow(NoiseProtocolError); + }); + + test("rejects a low-order remote ephemeral and a wrong responder key", () => { + const daemon = generateNoiseKeyPair(); + const prologue = new TextEncoder().encode("test"); + const empty = new Uint8Array(0); + const responder = createNoiseResponder({ localStatic: daemon, prologue }); + const zeroEphemeral = new Uint8Array(32 + 16); + expect(() => responder.readMessage(zeroEphemeral)).toThrow(NoiseProtocolError); + + const other = generateNoiseKeyPair(); + const initiator = createNoiseInitiator({ remoteStatic: other.publicKey, prologue }); + const victim = createNoiseResponder({ localStatic: daemon, prologue }); + expect(() => victim.readMessage(initiator.writeMessage(empty))).toThrow(NoiseProtocolError); + }); + + test("stops at the last nonce in both directions and keeps state on a failed tag", () => { + const MAX = (1n << 64n) - 1n; + const key = new Uint8Array(32).map((_, i) => i + 1); + const ad = new Uint8Array(0); + const sender = new CipherState(); + const receiver = new CipherState(); + sender.initializeKey(key.slice(), MAX - 1n); + receiver.initializeKey(key.slice(), MAX - 1n); + const last = sender.encryptWithAd(ad, new Uint8Array([1, 2, 3])); + expect(sender.nonce()).toBe(MAX); + expect(() => sender.encryptWithAd(ad, new Uint8Array([4]))).toThrow("nonce exhausted"); + expect(sender.nonce()).toBe(MAX); + + const tampered = last.slice(); + tampered[0] ^= 1; + expect(() => receiver.decryptWithAd(ad, tampered)).toThrow("authentication failed"); + expect(receiver.nonce()).toBe(MAX - 1n); + expect(Array.from(receiver.decryptWithAd(ad, last))).toEqual([1, 2, 3]); + expect(receiver.nonce()).toBe(MAX); + expect(() => receiver.decryptWithAd(ad, last)).toThrow("nonce exhausted"); + }); + + test("refuses to proceed out of turn or after completion", () => { + const daemon = generateNoiseKeyPair(); + const prologue = new Uint8Array(0); + const empty = new Uint8Array(0); + const initiator = createNoiseInitiator({ remoteStatic: daemon.publicKey, prologue }); + const responder = createNoiseResponder({ localStatic: daemon, prologue }); + expect(() => responder.writeMessage(empty)).toThrow(NoiseProtocolError); + expect(() => initiator.split()).toThrow(NoiseProtocolError); + responder.readMessage(initiator.writeMessage(empty)); + expect(() => initiator.writeMessage(empty)).toThrow(NoiseProtocolError); + initiator.readMessage(responder.writeMessage(empty)); + expect(() => initiator.readMessage(empty)).toThrow(NoiseProtocolError); + initiator.split(); + expect(() => initiator.split()).toThrow(NoiseProtocolError); + }); +}); diff --git a/packages/relay/src/noise/symmetric-state.ts b/packages/relay/src/noise/symmetric-state.ts new file mode 100644 index 0000000000..a4afcc3c40 --- /dev/null +++ b/packages/relay/src/noise/symmetric-state.ts @@ -0,0 +1,83 @@ +import { sha256 } from "@noble/hashes/sha2.js"; +import { hmac } from "@noble/hashes/hmac.js"; +import { CipherState } from "./cipher-state.js"; + +/** Noise spec §5.2 SymmetricState for SHA256 (HASHLEN 32). */ + +const HASHLEN = 32; +const EMPTY = new Uint8Array(0); + +export class SymmetricState { + readonly cipher = new CipherState(); + private ck: Uint8Array; + private h: Uint8Array; + + constructor(protocolName: string) { + const name = new TextEncoder().encode(protocolName); + if (name.byteLength <= HASHLEN) { + this.h = new Uint8Array(HASHLEN); + this.h.set(name); + } else { + this.h = sha256(name); + } + this.ck = this.h.slice(); + this.cipher.initializeKey(null); + } + + mixKey(inputKeyMaterial: Uint8Array): void { + const [ck, tempK] = hkdf2(this.ck, inputKeyMaterial); + this.ck = ck; + this.cipher.initializeKey(tempK); + } + + mixHash(data: Uint8Array): void { + this.h = sha256(concat(this.h, data)); + } + + handshakeHash(): Uint8Array { + return this.h.slice(); + } + + encryptAndHash(plaintext: Uint8Array): Uint8Array { + const ciphertext = this.cipher.encryptWithAd(this.h, plaintext); + this.mixHash(ciphertext); + return ciphertext; + } + + decryptAndHash(ciphertext: Uint8Array): Uint8Array { + const plaintext = this.cipher.decryptWithAd(this.h, ciphertext); + this.mixHash(ciphertext); + return plaintext; + } + + split(): [CipherState, CipherState] { + const [k1, k2] = hkdf2(this.ck, EMPTY); + const c1 = new CipherState(); + const c2 = new CipherState(); + c1.initializeKey(k1); + c2.initializeKey(k2); + this.ck.fill(0); + this.cipher.clear(); + return [c1, c2]; + } +} + +/** Noise spec §4.3 HKDF with two outputs: HMAC chain with 0x01 and 0x02 info bytes. */ +function hkdf2(chainingKey: Uint8Array, inputKeyMaterial: Uint8Array): [Uint8Array, Uint8Array] { + const tempKey = hmac(sha256, chainingKey, inputKeyMaterial); + const output1 = hmac(sha256, tempKey, new Uint8Array([0x01])); + const output2 = hmac(sha256, tempKey, concat(output1, new Uint8Array([0x02]))); + tempKey.fill(0); + return [output1, output2]; +} + +export function concat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.byteLength, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.byteLength; + } + return out; +} diff --git a/packages/relay/src/protected-records.test.ts b/packages/relay/src/protected-records.test.ts new file mode 100644 index 0000000000..fd3247b344 --- /dev/null +++ b/packages/relay/src/protected-records.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "vitest"; +import { CipherState, NoiseProtocolError } from "./noise/index.js"; +import { + decodeProtectedRecord, + encodeProtectedRecord, + MAX_CHUNK_DATA_BYTES, + protectedRecordByteLength, +} from "./protected-records.js"; + +function pair(): { sender: CipherState; receiver: CipherState } { + const key = new Uint8Array(32).map((_, i) => i + 1); + const sender = new CipherState(); + const receiver = new CipherState(); + sender.initializeKey(key.slice()); + receiver.initializeKey(key.slice()); + return { sender, receiver }; +} + +function frameOf(length: number): Uint8Array { + return new Uint8Array(length).map((_, i) => (i * 7 + 3) & 0xff); +} + +const EMPTY = new Uint8Array(0); + +/** Re-encrypts one chunk's plaintext under the next sender nonce. */ +function reencrypt(sender: CipherState, plaintext: Uint8Array): Uint8Array { + const ct = sender.encryptWithAd(EMPTY, plaintext); + const out = new Uint8Array(2 + ct.byteLength); + out[0] = ct.byteLength >>> 8; + out[1] = ct.byteLength & 0xff; + out.set(ct, 2); + return out; +} + +function chunkPlaintext(kind: number, flags: number, total: number, data: Uint8Array): Uint8Array { + const pt = new Uint8Array(6 + data.byteLength); + pt[0] = kind; + pt[1] = flags; + pt[2] = (total >>> 24) & 0xff; + pt[3] = (total >>> 16) & 0xff; + pt[4] = (total >>> 8) & 0xff; + pt[5] = total & 0xff; + pt.set(data, 6); + return pt; +} + +function concat(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0)); + let o = 0; + for (const p of parts) { + out.set(p, o); + o += p.byteLength; + } + return out; +} + +describe("protected records", () => { + test.each([ + ["empty text", "text", 0], + ["empty binary", "binary", 0], + ["small binary", "binary", 100], + ["exactly one chunk", "text", MAX_CHUNK_DATA_BYTES], + ["one byte over a chunk", "binary", MAX_CHUNK_DATA_BYTES + 1], + ["exact multiple of chunks", "text", MAX_CHUNK_DATA_BYTES * 3], + ["three and a bit chunks", "binary", MAX_CHUNK_DATA_BYTES * 3 + 17], + ] as const)("round-trips %s", (_label, kind, length) => { + const { sender, receiver } = pair(); + const frame = frameOf(length); + const record = encodeProtectedRecord(kind, frame, sender); + expect(record.byteLength).toBe(protectedRecordByteLength(length)); + const decoded = decodeProtectedRecord(record, receiver); + expect(decoded.kind).toBe(kind); + expect(Buffer.from(decoded.data).equals(Buffer.from(frame))).toBe(true); + expect(receiver.nonce()).toBe(sender.nonce()); + }); + + test("rejects two valid records concatenated into one message", () => { + const { sender, receiver } = pair(); + const a = encodeProtectedRecord("text", frameOf(10), sender); + const b = encodeProtectedRecord("text", frameOf(10), sender); + expect(() => decodeProtectedRecord(concat(a, b), receiver)).toThrow(NoiseProtocolError); + }); + + test("rejects a multi-chunk record split across messages", () => { + const { sender, receiver } = pair(); + const record = encodeProtectedRecord("binary", frameOf(MAX_CHUNK_DATA_BYTES + 5), sender); + const firstChunkLength = 2 + ((record[0] << 8) | record[1]); + const firstOnly = record.subarray(0, firstChunkLength); + expect(() => decodeProtectedRecord(firstOnly, receiver)).toThrow(NoiseProtocolError); + }); + + test("rejects a record whose complete final chunk is removed", () => { + const { sender, receiver } = pair(); + const record = encodeProtectedRecord("binary", frameOf(MAX_CHUNK_DATA_BYTES * 2), sender); + const firstChunkLength = 2 + ((record[0] << 8) | record[1]); + expect(() => decodeProtectedRecord(record.subarray(0, firstChunkLength), receiver)).toThrow( + NoiseProtocolError, + ); + }); + + test("rejects zero chunks and trailing bytes", () => { + const { sender, receiver } = pair(); + expect(() => decodeProtectedRecord(new Uint8Array(0), receiver)).toThrow(NoiseProtocolError); + const record = encodeProtectedRecord("text", frameOf(3), sender); + expect(() => decodeProtectedRecord(concat(record, new Uint8Array([0])), receiver)).toThrow( + NoiseProtocolError, + ); + }); + + test("rejects an extra zero-byte FINAL chunk after an exact-multiple frame", () => { + const { sender, receiver } = pair(); + const total = MAX_CHUNK_DATA_BYTES; + const data = frameOf(total); + const forged = concat( + reencrypt(sender, chunkPlaintext(0x00, 0x01, total, data)), + reencrypt(sender, chunkPlaintext(0x00, 0x02, total, EMPTY)), + ); + expect(() => decodeProtectedRecord(forged, receiver)).toThrow(NoiseProtocolError); + }); + + test("rejects reserved flags, invalid kind, changed kind, and changed total length", () => { + const cases: Array<[Uint8Array[]]> = [ + [[chunkPlaintext(0x00, 0x01 | 0x02 | 0x04, 1, new Uint8Array([1]))]], + [[chunkPlaintext(0x02, 0x03, 1, new Uint8Array([1]))]], + [ + [ + chunkPlaintext(0x00, 0x01, MAX_CHUNK_DATA_BYTES + 1, frameOf(MAX_CHUNK_DATA_BYTES)), + chunkPlaintext(0x01, 0x02, MAX_CHUNK_DATA_BYTES + 1, new Uint8Array([1])), + ], + ], + [ + [ + chunkPlaintext(0x00, 0x01, MAX_CHUNK_DATA_BYTES + 1, frameOf(MAX_CHUNK_DATA_BYTES)), + chunkPlaintext(0x00, 0x02, MAX_CHUNK_DATA_BYTES + 2, new Uint8Array([1, 2])), + ], + ], + ]; + for (const [plaintexts] of cases) { + const { sender, receiver } = pair(); + const forged = concat(...plaintexts.map((pt) => reencrypt(sender, pt))); + expect(() => decodeProtectedRecord(forged, receiver)).toThrow(NoiseProtocolError); + } + }); + + test("rejects a tampered chunk without advancing past it, and later chunks never escape", () => { + const { sender, receiver } = pair(); + const record = encodeProtectedRecord("binary", frameOf(MAX_CHUNK_DATA_BYTES + 5), sender); + const tampered = record.slice(); + tampered[2 + 10] ^= 0x01; + expect(() => decodeProtectedRecord(tampered, receiver)).toThrow(NoiseProtocolError); + expect(receiver.nonce()).toBe(0n); + }); + + test("rejects a record whose header claims more bytes than it carries before allocating", () => { + const { sender, receiver } = pair(); + const claimed = 64 * 1024 * 1024; + const forged = reencrypt(sender, chunkPlaintext(0x01, 0x01, claimed, new Uint8Array([1]))); + const before = process.memoryUsage().arrayBuffers; + expect(() => decodeProtectedRecord(forged, receiver)).toThrow("record length mismatch"); + expect(process.memoryUsage().arrayBuffers - before).toBeLessThan(1024 * 1024); + // A relay that truncates a genuine large record gets the same answer. + const { sender: s2, receiver: r2 } = pair(); + const genuine = encodeProtectedRecord("binary", frameOf(MAX_CHUNK_DATA_BYTES * 2 + 1), s2); + const firstChunkOnly = genuine.subarray(0, 2 + 6 + MAX_CHUNK_DATA_BYTES + 16); + expect(() => decodeProtectedRecord(firstChunkOnly, r2)).toThrow("record length mismatch"); + expect(r2.nonce()).toBe(1n); + }); + + test("a record delivered twice fails the second time", () => { + const { sender, receiver } = pair(); + const record = encodeProtectedRecord("text", frameOf(20), sender); + decodeProtectedRecord(record, receiver); + expect(() => decodeProtectedRecord(record, receiver)).toThrow(NoiseProtocolError); + }); +}); diff --git a/packages/relay/src/protected-records.ts b/packages/relay/src/protected-records.ts new file mode 100644 index 0000000000..a8eb661b80 --- /dev/null +++ b/packages/relay/src/protected-records.ts @@ -0,0 +1,229 @@ +/** + * Authenticated application-frame framing over Noise transport messages. + * + * One application frame becomes one wire record. Every Noise transport message + * (chunk) carries, inside its authenticated plaintext, the frame kind, FIRST + * and FINAL flags, and the total frame length, so a relay cannot merge two + * records, split one across WebSocket messages, truncate a final chunk, or + * re-chunk a frame without an authentication failure or a framing violation. + * Transport AD is always empty, as in Noise §5. + * + * chunk plaintext = kind(1) || flags(1) || totalLength(u32 BE) || data + * wire record = for each chunk: ciphertextLength(u16 BE) || ciphertext + */ +import { + CipherState, + NoiseProtocolError, + NOISE_MAX_MESSAGE_BYTES, + NOISE_TAG_BYTES, +} from "./noise/index.js"; + +export type FrameKind = "text" | "binary"; + +const KIND_TEXT = 0x00; +const KIND_BINARY = 0x01; +const FLAG_FIRST = 0x01; +const FLAG_FINAL = 0x02; +const HEADER_BYTES = 6; +const LENGTH_PREFIX_BYTES = 2; +const EMPTY_AD = new Uint8Array(0); + +/** Largest data payload a single chunk carries. */ +export const MAX_CHUNK_DATA_BYTES = NOISE_MAX_MESSAGE_BYTES - NOISE_TAG_BYTES - HEADER_BYTES; +/** Largest application frame a record may carry; bounds allocation before decryption. */ +export const MAX_FRAME_BYTES = 0xffff_ffff; +const CHUNK_OVERHEAD_BYTES = LENGTH_PREFIX_BYTES + HEADER_BYTES + NOISE_TAG_BYTES; + +export function chunkCount(frameBytes: number): number { + return Math.max(1, Math.ceil(frameBytes / MAX_CHUNK_DATA_BYTES)); +} + +/** Wire bytes a frame of `frameBytes` occupies once protected. */ +export function protectedRecordByteLength(frameBytes: number): number { + return frameBytes + chunkCount(frameBytes) * CHUNK_OVERHEAD_BYTES; +} + +export function encodeProtectedRecord( + kind: FrameKind, + frame: Uint8Array, + sender: CipherState, +): Uint8Array { + if (frame.byteLength > MAX_FRAME_BYTES) throw new NoiseProtocolError("frame too large"); + const chunks = chunkCount(frame.byteLength); + const out = new Uint8Array(protectedRecordByteLength(frame.byteLength)); + let offset = 0; + for (let index = 0; index < chunks; index += 1) { + const start = index * MAX_CHUNK_DATA_BYTES; + const data = frame.subarray(start, Math.min(frame.byteLength, start + MAX_CHUNK_DATA_BYTES)); + const plaintext = new Uint8Array(HEADER_BYTES + data.byteLength); + plaintext[0] = kind === "text" ? KIND_TEXT : KIND_BINARY; + plaintext[1] = (index === 0 ? FLAG_FIRST : 0) | (index === chunks - 1 ? FLAG_FINAL : 0); + writeU32(plaintext, 2, frame.byteLength); + plaintext.set(data, HEADER_BYTES); + const ciphertext = sender.encryptWithAd(EMPTY_AD, plaintext); + writeU16(out, offset, ciphertext.byteLength); + out.set(ciphertext, offset + LENGTH_PREFIX_BYTES); + offset += LENGTH_PREFIX_BYTES + ciphertext.byteLength; + } + return out; +} + +export interface DecodedFrame { + kind: FrameKind; + data: Uint8Array; +} + +/** + * Decodes one wire record. Chunks are authenticated in order and the receive + * counter advances per authenticated chunk; any framing violation throws and + * nothing partial is returned. The caller must close the channel on error. + */ +export function decodeProtectedRecord(record: Uint8Array, receiver: CipherState): DecodedFrame { + let offset = 0; + let assembly: RecordAssembly | null = null; + while (offset < record.byteLength) { + if (assembly !== null && assembly.index >= assembly.expectedChunks) { + throw new NoiseProtocolError("unexpected chunk after final"); + } + const { chunk, next } = readChunk(record, offset, receiver); + offset = next; + assembly = + assembly === null + ? startAssembly(chunk, record.byteLength) + : continueAssembly(assembly, chunk); + } + if (assembly === null) throw new NoiseProtocolError("empty record"); + if (assembly.index !== assembly.expectedChunks || assembly.written !== assembly.totalLength) { + throw new NoiseProtocolError("record incomplete"); + } + return { kind: assembly.kind, data: assembly.frame }; +} + +interface Chunk { + kind: FrameKind; + isFirst: boolean; + isFinal: boolean; + totalLength: number; + data: Uint8Array; +} + +interface RecordAssembly { + kind: FrameKind; + totalLength: number; + expectedChunks: number; + frame: Uint8Array; + written: number; + index: number; +} + +function readChunk( + record: Uint8Array, + offset: number, + receiver: CipherState, +): { chunk: Chunk; next: number } { + if (record.byteLength - offset < LENGTH_PREFIX_BYTES) { + throw new NoiseProtocolError("record truncated"); + } + const ciphertextLength = readU16(record, offset); + const start = offset + LENGTH_PREFIX_BYTES; + if ( + ciphertextLength < NOISE_TAG_BYTES + HEADER_BYTES || + ciphertextLength > NOISE_MAX_MESSAGE_BYTES + ) { + throw new NoiseProtocolError("invalid chunk length"); + } + if (record.byteLength - start < ciphertextLength) { + throw new NoiseProtocolError("record truncated"); + } + const plaintext = receiver.decryptWithAd( + EMPTY_AD, + record.subarray(start, start + ciphertextLength), + ); + const flags = plaintext[1]; + if ((flags & ~(FLAG_FIRST | FLAG_FINAL)) !== 0) { + throw new NoiseProtocolError("reserved flag set"); + } + return { + chunk: { + kind: decodeKind(plaintext[0]), + isFirst: (flags & FLAG_FIRST) !== 0, + isFinal: (flags & FLAG_FINAL) !== 0, + totalLength: readU32(plaintext, 2), + data: plaintext.subarray(HEADER_BYTES), + }, + next: start + ciphertextLength, + }; +} + +function startAssembly(chunk: Chunk, recordByteLength: number): RecordAssembly { + if (!chunk.isFirst) throw new NoiseProtocolError("record does not start with FIRST"); + // The claimed frame length fixes the record's exact wire length. Check it + // before allocating so a tiny authenticated header cannot demand gigabytes. + if (protectedRecordByteLength(chunk.totalLength) !== recordByteLength) { + throw new NoiseProtocolError("record length mismatch"); + } + const assembly: RecordAssembly = { + kind: chunk.kind, + totalLength: chunk.totalLength, + expectedChunks: chunkCount(chunk.totalLength), + frame: new Uint8Array(chunk.totalLength), + written: 0, + index: 0, + }; + return appendChunk(assembly, chunk); +} + +function continueAssembly(assembly: RecordAssembly, chunk: Chunk): RecordAssembly { + if (chunk.isFirst) throw new NoiseProtocolError("FIRST repeated inside record"); + if (chunk.kind !== assembly.kind) { + throw new NoiseProtocolError("frame kind changed inside record"); + } + if (chunk.totalLength !== assembly.totalLength) { + throw new NoiseProtocolError("frame length changed inside record"); + } + return appendChunk(assembly, chunk); +} + +function appendChunk(assembly: RecordAssembly, chunk: Chunk): RecordAssembly { + const isLastExpected = assembly.index === assembly.expectedChunks - 1; + if (chunk.isFinal !== isLastExpected) throw new NoiseProtocolError("FINAL flag mismatch"); + const expectedData = isLastExpected + ? assembly.totalLength - assembly.index * MAX_CHUNK_DATA_BYTES + : MAX_CHUNK_DATA_BYTES; + if (chunk.data.byteLength !== expectedData) throw new NoiseProtocolError("chunk size mismatch"); + assembly.frame.set(chunk.data, assembly.written); + assembly.written += chunk.data.byteLength; + assembly.index += 1; + return assembly; +} + +function decodeKind(byte: number): FrameKind { + if (byte === KIND_TEXT) return "text"; + if (byte === KIND_BINARY) return "binary"; + throw new NoiseProtocolError("invalid frame kind"); +} + +function writeU16(target: Uint8Array, offset: number, value: number): void { + target[offset] = (value >>> 8) & 0xff; + target[offset + 1] = value & 0xff; +} + +function readU16(source: Uint8Array, offset: number): number { + return (source[offset] << 8) | source[offset + 1]; +} + +function writeU32(target: Uint8Array, offset: number, value: number): void { + target[offset] = (value >>> 24) & 0xff; + target[offset + 1] = (value >>> 16) & 0xff; + target[offset + 2] = (value >>> 8) & 0xff; + target[offset + 3] = value & 0xff; +} + +function readU32(source: Uint8Array, offset: number): number { + return ( + ((source[offset] << 24) >>> 0) + + (source[offset + 1] << 16) + + (source[offset + 2] << 8) + + source[offset + 3] + ); +} diff --git a/packages/relay/src/secure-channel.test.ts b/packages/relay/src/secure-channel.test.ts new file mode 100644 index 0000000000..8f5034368d --- /dev/null +++ b/packages/relay/src/secure-channel.test.ts @@ -0,0 +1,627 @@ +import { describe, expect, it, vi } from "vitest"; +import { + openClientSecureChannel, + openDaemonSecureChannel, + SecureChannelRejectedError, + type SecureChannel, +} from "./secure-channel.js"; +import { + createClientChannel, + createDaemonChannel, + type Transport, + type TransportMessage, +} from "./encrypted-channel.js"; +import { exportPublicKey, generateKeyPair } from "./crypto.js"; +import { MAX_CHUNK_DATA_BYTES } from "./protected-records.js"; + +interface Frame { + data: string | ArrayBuffer; + isBinary: boolean; +} +type Interceptor = (frame: Frame) => Frame | null; + +/** + * Two transports wired back to back through an optional interceptor per + * direction, standing in for a relay that may record, drop, reorder, or + * rewrite frames. + */ +/** Delivers a frame to a transport a few milliseconds later, like a relay retry. */ +function deliverLater(transport: Transport, frame: TransportMessage, ms = 5): void { + setTimeout(() => transport.onmessage?.(frame), ms); +} + +function createLink() { + const toDaemon: Frame[] = []; + const toClient: Frame[] = []; + let interceptToDaemon: Interceptor = (frame) => frame; + let interceptToClient: Interceptor = (frame) => frame; + + const daemon: Transport = { + send: vi.fn((data: string | ArrayBuffer) => { + const recorded = toFrame(data); + toClient.push(recorded); + const frame = interceptToClient(recorded); + if (frame) setTimeout(() => client.onmessage?.(frame), 0); + }), + close: vi.fn(), + onmessage: null, + onclose: null, + onerror: null, + }; + const client: Transport = { + send: vi.fn((data: string | ArrayBuffer) => { + const recorded = toFrame(data); + toDaemon.push(recorded); + const frame = interceptToDaemon(recorded); + if (frame) setTimeout(() => daemon.onmessage?.(frame), 0); + }), + // The client may run in a browser: WebSocket.close throws on any code other + // than 1000 or 3000-4999 and leaves the socket open, so mirror that here. + close: vi.fn((code?: number, reason?: string) => { + if (code !== undefined && code !== 1000 && (code < 3000 || code > 4999)) { + throw new DOMException( + `The code must be either 1000, or between 3000 and 4999. ${code} is neither.`, + "InvalidAccessError", + ); + } + setTimeout(() => client.onclose?.(code ?? 1005, reason ?? ""), 0); + }), + onmessage: null, + onclose: null, + onerror: null, + }; + return { + daemon, + client, + recordedToDaemon: toDaemon, + recordedToClient: toClient, + setToDaemon(fn: Interceptor) { + interceptToDaemon = fn; + }, + setToClient(fn: Interceptor) { + interceptToClient = fn; + }, + }; +} + +function toFrame(data: string | ArrayBuffer): Frame { + return { data, isBinary: data instanceof ArrayBuffer }; +} + +async function settle(ms = 30): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +interface Pair { + link: ReturnType; + daemonKeyPair: ReturnType; + client: SecureChannel; + daemon: SecureChannel; + daemonReceived: Array; + clientReceived: Array; +} + +async function openProtectedPair( + options: { + link?: ReturnType; + daemonKeyPair?: ReturnType; + clientMinimum?: "legacy" | "protected"; + daemonMinimum?: "legacy" | "protected"; + } = {}, +): Promise { + const link = options.link ?? createLink(); + const daemonKeyPair = options.daemonKeyPair ?? generateKeyPair(); + const daemonReceived: Array = []; + const clientReceived: Array = []; + const opened = deferred(); + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair, + minimumMode: options.daemonMinimum, + events: { onmessage: (data) => daemonReceived.push(data) }, + }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + minimumMode: options.clientMinimum, + events: { onopen: () => opened.resolve(), onmessage: (data) => clientReceived.push(data) }, + }); + const daemon = await daemonPromise; + await opened.promise; + return { link, daemonKeyPair, client, daemon, daemonReceived, clientReceived }; +} + +function bytes(length: number): ArrayBuffer { + return new Uint8Array(length).map((_, i) => (i * 13 + 1) & 0xff).buffer; +} + +describe("secure channel: protected mode", () => { + it("negotiates protected mode between two updated peers and carries both frame kinds", async () => { + const pair = await openProtectedPair(); + expect(pair.client.securityMode()).toBe("protected"); + expect(pair.daemon.securityMode()).toBe("protected"); + + await pair.client.send("hello daemon"); + await pair.client.send(bytes(5)); + await pair.daemon.send("hello client"); + await pair.daemon.send(bytes(7)); + await settle(); + + expect(pair.daemonReceived[0]).toBe("hello daemon"); + expect(new Uint8Array(pair.daemonReceived[1] as ArrayBuffer)).toEqual(new Uint8Array(bytes(5))); + expect(pair.clientReceived[0]).toBe("hello client"); + expect(new Uint8Array(pair.clientReceived[1] as ArrayBuffer)).toEqual(new Uint8Array(bytes(7))); + }); + + it("keeps text as base64 text frames and binary as binary frames when negotiated", async () => { + const pair = await openProtectedPair(); + pair.link.recordedToDaemon.length = 0; + await pair.client.send("text"); + await pair.client.send(bytes(3)); + await settle(); + expect(pair.link.recordedToDaemon.map((frame) => frame.isBinary)).toEqual([false, true]); + expect(pair.link.recordedToDaemon[0].data).not.toContain("text"); + expect(pair.client.outboundWireByteLength(bytes(3))).toBe(3 + 2 + 6 + 16); + expect(pair.client.outboundWireByteLength("text")).toBe(4 * Math.ceil((4 + 24) / 3)); + }); + + it("carries frames larger than one Noise message", async () => { + const pair = await openProtectedPair(); + const big = bytes(MAX_CHUNK_DATA_BYTES * 2 + 100); + await pair.client.send(big); + await settle(100); + expect(new Uint8Array(pair.daemonReceived[0] as ArrayBuffer)).toEqual(new Uint8Array(big)); + }); + + it("flushes sends queued before the handshake completes, in order", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const daemonReceived: Array = []; + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair, + events: { onmessage: (data) => daemonReceived.push(data) }, + }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + await client.send("one"); + await client.send("two"); + expect(client.isOpen()).toBe(false); + await daemonPromise; + await settle(); + expect(daemonReceived).toEqual(["one", "two"]); + }); + + it("rejects an exact duplicate record and delivers nothing twice", async () => { + const pair = await openProtectedPair(); + let captured: Frame | null = null; + pair.link.setToDaemon((frame) => { + captured ??= frame; + return frame; + }); + await pair.client.send("once"); + await settle(); + expect(pair.daemonReceived).toEqual(["once"]); + + pair.link.daemon.onmessage?.(captured!); + await settle(); + expect(pair.daemonReceived).toEqual(["once"]); + expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + }); + + it("rejects a reflected record", async () => { + const pair = await openProtectedPair(); + let captured: Frame | null = null; + pair.link.setToDaemon((frame) => { + captured ??= frame; + return frame; + }); + await pair.client.send("mine"); + await settle(); + pair.link.client.onmessage?.(captured!); + await settle(); + expect(pair.clientReceived).toEqual([]); + expect(pair.link.client.close).toHaveBeenCalledWith(4003, expect.any(String)); + }); + + it("closes the browser socket and reports the close when a record fails at the client", async () => { + const closes: Array<[number, string]> = []; + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + events: { onclose: (code, reason) => closes.push([code, reason]) }, + }); + await daemonPromise; + await settle(); + link.client.onmessage?.({ + data: Buffer.from("not a record").toString("base64"), + isBinary: false, + }); + await settle(); + expect(client.isOpen()).toBe(false); + expect(closes).toEqual([[4003, expect.any(String)]]); + }); + + it("rejects out-of-order records and delivers nothing from them", async () => { + const pair = await openProtectedPair(); + const held: Frame[] = []; + pair.link.setToDaemon((frame) => { + held.push(frame); + return null; + }); + await pair.client.send("first"); + await pair.client.send("second"); + await settle(); + expect(held).toHaveLength(2); + pair.link.daemon.onmessage?.(held[1]); + await settle(); + expect(pair.daemonReceived).toEqual([]); + expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + pair.link.daemon.onmessage?.(held[0]); + await settle(); + expect(pair.daemonReceived).toEqual([]); + }); + + it("rejects a tampered record without delivering it", async () => { + const pair = await openProtectedPair(); + pair.link.setToDaemon((frame) => { + const tampered = new Uint8Array((frame.data as ArrayBuffer).slice(0)); + tampered[tampered.byteLength - 1] ^= 0x01; + return { data: tampered.buffer, isBinary: true }; + }); + await pair.client.send(bytes(10)); + await settle(); + expect(pair.daemonReceived).toEqual([]); + expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + }); + + it("closes on plaintext application traffic", async () => { + const pair = await openProtectedPair(); + pair.link.daemon.onmessage?.({ data: JSON.stringify({ type: "hello" }), isBinary: false }); + await settle(); + expect(pair.link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + expect(pair.daemonReceived).toEqual([]); + }); + + it("answers an identical hello retry with the identical ready and keeps counters", async () => { + const pair = await openProtectedPair(); + const hello = pair.link.recordedToDaemon.find( + (frame) => typeof frame.data === "string" && frame.data.includes('"e2ee_hello"'), + ); + const readyFrames = () => + (pair.link.daemon.send as ReturnType).mock.calls + .map(([data]) => data) + .filter((data) => typeof data === "string" && data.includes('"e2ee_ready"')); + expect(hello).toBeDefined(); + expect(readyFrames()).toHaveLength(1); + + pair.link.daemon.onmessage?.(hello!); + await settle(); + expect(readyFrames()).toHaveLength(2); + expect(readyFrames()[0]).toBe(readyFrames()[1]); + expect(pair.link.daemon.close).not.toHaveBeenCalled(); + + await pair.client.send("still protected"); + await pair.daemon.send("both ways"); + await settle(); + expect(pair.daemonReceived).toEqual(["still protected"]); + expect(pair.clientReceived).toEqual(["both ways"]); + }); + + it("closes when a different hello arrives on an open channel", async () => { + const pair = await openProtectedPair(); + const hello = pair.link.recordedToDaemon.find( + (frame) => typeof frame.data === "string" && frame.data.includes('"e2ee_hello"'), + )!; + const changed = (hello.data as string).replace('"v":1', '"v":1 '); + expect(changed).not.toBe(hello.data); + pair.link.daemon.onmessage?.({ data: changed, isBinary: false }); + await settle(); + expect(pair.link.daemon.close).toHaveBeenCalledWith(1008, expect.stringContaining("mismatch")); + }); + + it("answers an identical hello that arrives while the ready send is in flight", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const sendFromDaemon = link.daemon.send; + link.daemon.send = vi.fn(async (data: string | ArrayBuffer) => { + await settle(20); + sendFromDaemon(data); + }); + link.setToDaemon((frame) => { + // The relay delivers the client's 1s retry while the daemon is still sending ready. + if (typeof frame.data === "string" && frame.data.includes('"e2ee_hello"')) { + deliverLater(link.daemon, frame); + } + return frame; + }); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + const daemon = await daemonPromise; + await settle(80); + const readyFrames = link.recordedToClient.filter( + (frame) => typeof frame.data === "string" && frame.data.includes('"e2ee_ready"'), + ); + expect(readyFrames).toHaveLength(2); + expect(readyFrames[0].data).toBe(readyFrames[1].data); + expect(link.daemon.close).not.toHaveBeenCalled(); + expect(client.isOpen()).toBe(true); + expect(daemon.isOpen()).toBe(true); + }); + + it("closes when a different hello arrives while the ready send is in flight", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const sendFromDaemon = link.daemon.send; + link.daemon.send = vi.fn(async (data: string | ArrayBuffer) => { + await settle(20); + sendFromDaemon(data); + }); + link.setToDaemon((frame) => { + if (typeof frame.data === "string" && frame.data.includes('"e2ee_hello"')) { + const changed = frame.data.replace('"v":1', '"v":1 '); + deliverLater(link.daemon, { data: changed, isBinary: false }); + } + return frame; + }); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + const daemon = await daemonPromise; + await settle(80); + expect(link.daemon.close).toHaveBeenCalledWith(1008, expect.stringContaining("mismatch")); + expect(daemon.isOpen()).toBe(false); + }); + + it("does not let a recorded hello and records replay into a fresh daemon channel", async () => { + // Session 1: a legitimate client talks to the daemon; the relay records everything. + const first = await openProtectedPair(); + await first.client.send("secret command"); + await first.client.send(bytes(4)); + await settle(); + expect(first.daemonReceived).toHaveLength(2); + const transcript = first.link.recordedToDaemon.slice(); + expect(transcript.length).toBeGreaterThanOrEqual(3); + + // Session 2: the same daemon key pair (restart or reconnect), attacker replays the transcript. + const link = createLink(); + const replayed: Array = []; + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair: first.daemonKeyPair, + events: { onmessage: (data) => replayed.push(data) }, + }); + for (const frame of transcript) link.daemon.onmessage?.(frame); + const daemon = await daemonPromise; + await settle(); + expect(daemon.securityMode()).toBe("protected"); + expect(replayed).toEqual([]); + expect(link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + }); +}); + +describe("secure channel: negotiation and policy", () => { + it("rejects a present but invalid protected hello instead of falling back", async () => { + for (const mutate of [ + (hello: Record) => ({ ...hello, protected: { v: 2, message: "AAAA" } }), + (hello: Record) => ({ ...hello, protected: { v: 1, message: "AAAA" } }), + (hello: Record) => ({ ...hello, protected: "nope" }), + ]) { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + link.setToDaemon((frame) => { + if (typeof frame.data !== "string") return frame; + return { data: JSON.stringify(mutate(JSON.parse(frame.data))), isBinary: false }; + }); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + await expect(daemonPromise).rejects.toThrow(); + expect((link.daemon.send as ReturnType).mock.calls).toHaveLength(0); + } + }); + + it("rejects a malformed protected ready instead of waiting for a legacy one", async () => { + for (const protectedValue of [ + null, + {}, + { v: 1, message: "" }, + { v: "1", message: "AAAA" }, + { v: 1, message: 7 }, + [], + ]) { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + link.setToClient((frame) => { + if (typeof frame.data !== "string") return frame; + const ready = JSON.parse(frame.data); + // A tampered ready first, then the daemon's genuine legacy-shaped ready. + deliverLater(link.client, { + data: JSON.stringify({ type: "e2ee_ready" }), + isBinary: false, + }); + return { data: JSON.stringify({ ...ready, protected: protectedValue }), isBinary: false }; + }); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + await daemonPromise; + await settle(40); + expect(link.client.close).toHaveBeenCalledWith(4002, expect.stringContaining("malformed")); + expect(client.isOpen()).toBe(false); + await expect(client.send("never")).rejects.toThrow("Channel not open"); + } + }); + + it("rejects a tampered protected ready and never retries legacy", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + link.setToClient((frame) => { + if (typeof frame.data !== "string") return frame; + const ready = JSON.parse(frame.data); + const message = Buffer.from(ready.protected.message, "base64"); + message[message.byteLength - 1] ^= 0x01; + ready.protected.message = message.toString("base64"); + return { data: JSON.stringify(ready), isBinary: false }; + }); + const errors: Error[] = []; + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + events: { onerror: (error) => errors.push(error) }, + }); + await daemonPromise; + await settle(); + expect(client.isOpen()).toBe(false); + expect(link.client.close).toHaveBeenCalledWith(4002, expect.any(String)); + expect(errors).toHaveLength(1); + await expect(client.send("x")).rejects.toThrow("Channel not open"); + }); + + it("rejects a ready that selects an unoffered parameter", async () => { + // The daemon cannot do this by itself; a forged msg2 would need the keys, so the + // check is exercised by driving the decoder with a crafted selection. + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + link.setToClient((frame) => { + if (typeof frame.data !== "string") return frame; + const ready = JSON.parse(frame.data); + ready.capabilities = { binaryCiphertext: true }; + return { data: JSON.stringify(ready), isBinary: false }; + }); + const daemonPromise = openDaemonSecureChannel(link.daemon, { daemonKeyPair }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + await daemonPromise; + await settle(); + // Unauthenticated outer capabilities are ignored in protected mode. + expect(client.isOpen()).toBe(true); + expect(client.securityMode()).toBe("protected"); + }); + + it("stripping protected from the ready does not downgrade a protected daemon", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + link.setToClient((frame) => { + if (typeof frame.data !== "string") return frame; + const ready = JSON.parse(frame.data); + delete ready.protected; + return { data: JSON.stringify(ready), isBinary: false }; + }); + const daemonReceived: unknown[] = []; + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair, + events: { onmessage: (data) => daemonReceived.push(data) }, + }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + }); + await daemonPromise; + await settle(); + expect(client.securityMode()).toBe("legacy"); + await client.send("legacy record"); + await settle(); + expect(daemonReceived).toEqual([]); + expect(link.daemon.close).toHaveBeenCalledWith(1011, expect.any(String)); + }); + + it("client with a protected minimum refuses a legacy-only daemon", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const errors: Error[] = []; + const daemonPromise = createDaemonChannel(link.daemon, daemonKeyPair); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + minimumMode: "protected", + events: { onerror: (error) => errors.push(error) }, + }); + await daemonPromise; + await settle(); + expect(client.isOpen()).toBe(false); + expect(link.client.close).toHaveBeenCalledWith(4002, expect.stringContaining("legacy")); + }); + + it("daemon with a protected minimum refuses a legacy-only client", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair, + minimumMode: () => "protected", + }); + await createClientChannel(link.client, exportPublicKey(daemonKeyPair.publicKey)); + await expect(daemonPromise).rejects.toBeInstanceOf(SecureChannelRejectedError); + await expect(daemonPromise).rejects.toMatchObject({ + closeCode: 1008, + closeReason: "protected endpoint security required", + }); + expect((link.daemon.send as ReturnType).mock.calls).toHaveLength(0); + }); +}); + +describe("secure channel: legacy compatibility", () => { + it("new client against an old daemon negotiates legacy and exchanges records", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const daemonReceived: unknown[] = []; + const clientReceived: unknown[] = []; + const opened = deferred(); + const daemonPromise = createDaemonChannel(link.daemon, daemonKeyPair, { + onmessage: (data) => daemonReceived.push(data), + }); + const client = await openClientSecureChannel(link.client, { + daemonPublicKeyB64: exportPublicKey(daemonKeyPair.publicKey), + events: { onopen: () => opened.resolve(), onmessage: (data) => clientReceived.push(data) }, + }); + const daemon = await daemonPromise; + await opened.promise; + expect(client.securityMode()).toBe("legacy"); + await client.send("from new client"); + await daemon.send("from old daemon"); + await settle(); + expect(daemonReceived).toEqual(["from new client"]); + expect(clientReceived).toEqual(["from old daemon"]); + }); + + it("old client against a new daemon negotiates legacy and keeps legacy retry semantics", async () => { + const link = createLink(); + const daemonKeyPair = generateKeyPair(); + const daemonReceived: unknown[] = []; + const opened = deferred(); + const daemonPromise = openDaemonSecureChannel(link.daemon, { + daemonKeyPair, + events: { onmessage: (data) => daemonReceived.push(data) }, + }); + const client = await createClientChannel( + link.client, + exportPublicKey(daemonKeyPair.publicKey), + { + onopen: () => opened.resolve(), + }, + ); + const daemon = await daemonPromise; + await opened.promise; + expect(daemon.securityMode()).toBe("legacy"); + const hello = (link.client.send as ReturnType).mock.calls.find( + ([data]) => typeof data === "string" && data.includes('"e2ee_hello"'), + )?.[0] as string; + link.daemon.onmessage?.({ data: hello, isBinary: false }); + await settle(); + expect(link.daemon.close).not.toHaveBeenCalled(); + await client.send("legacy still works"); + await settle(); + expect(daemonReceived).toEqual(["legacy still works"]); + }); +}); diff --git a/packages/relay/src/secure-channel.ts b/packages/relay/src/secure-channel.ts new file mode 100644 index 0000000000..760dcc49d0 --- /dev/null +++ b/packages/relay/src/secure-channel.ts @@ -0,0 +1,835 @@ +/// +/** + * Endpoint security for relay-carried daemon sessions. + * + * This is the one entrypoint both endpoints use to secure a transport. It owns + * the handshake, record protection, counters, retries, termination, and the + * negotiated security mode. Relay adapters own routing and physical I/O; + * daemon admission owns credentials and authorization. + * + * Two modes exist during migration: + * + * - "protected": conforming Noise_NK_25519_ChaChaPoly_SHA256 bound to the + * daemon's static key, with authenticated record framing. Handshake + * messages travel byte-exact (base64) inside the existing `e2ee_hello` / + * `e2ee_ready` JSON envelopes under `protected`. + * - "legacy": the pre-existing ephemeral-static box construction in + * `encrypted-channel.ts`. It has no replay protection. Chosen only when the + * peer omits `protected` (an older peer) and policy allows it. + * + * A present but invalid `protected` field never falls back to legacy. + * + * See docs/relay-endpoint-security.md for the byte-level mapping. + */ + +import { + EncryptedChannel, + base64EncryptedWireByteLength, + maxBase64EncryptedPlaintextByteLength, + type EncryptedChannelEvents, + type Transport, + type TransportMessage, +} from "./encrypted-channel.js"; +import { + deriveSharedKey, + exportPublicKey, + generateKeyPair, + importPublicKey, + type KeyPair, +} from "./crypto.js"; +import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js"; +import { + createNoiseInitiator, + createNoiseResponder, + NoiseProtocolError, + type CipherState, + type NoiseHandshake, +} from "./noise/index.js"; +import { + decodeProtectedRecord, + encodeProtectedRecord, + protectedRecordByteLength, +} from "./protected-records.js"; + +export type SecurityMode = "legacy" | "protected"; + +export type SecureChannelEvents = EncryptedChannelEvents; + +export interface SecureChannel { + send(data: string | ArrayBuffer): Promise; + close(code?: number, reason?: string): void; + isOpen(): boolean; + securityMode(): SecurityMode; + /** Bytes the transport will carry for `data`, including protection overhead. */ + outboundWireByteLength(data: string | ArrayBuffer): number; + onTransitionToOpen(cb: () => void): void; + onClose(cb: () => void): void; +} + +export interface ClientSecureChannelOptions { + daemonPublicKeyB64: string; + /** Lowest mode this client accepts. "protected" refuses a daemon that only offers legacy. */ + minimumMode?: SecurityMode; + events?: SecureChannelEvents; +} + +export interface DaemonSecureChannelOptions { + daemonKeyPair: KeyPair; + /** + * Lowest mode this daemon admits. "protected" refuses a client that only + * offers legacy. A function is evaluated when the hello arrives so a policy + * change applies to every admission from that moment on. + */ + minimumMode?: SecurityMode | (() => SecurityMode); + events?: SecureChannelEvents; +} + +/** Thrown by {@link openDaemonSecureChannel} when the handshake is refused; carries the close to send. */ +export class SecureChannelRejectedError extends Error { + constructor( + message: string, + readonly closeCode: number, + readonly closeReason: string, + ) { + super(message); + this.name = "SecureChannelRejectedError"; + } +} + +export const PROTECTED_MODE_REQUIRED_CLOSE_CODE = 1008; +export const PROTECTED_MODE_REQUIRED_CLOSE_REASON = "protected endpoint security required"; +/** Client-side close when a protected handshake fails. Browser-safe (4000-4999). */ +export const PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE = 4002; +/** + * Client-side close when an inbound record fails authentication or framing. + * Browser WebSocket.close accepts only 1000 or 3000-4999, so a client must + * never try to send a 1011. + */ +export const PROTECTED_RECORD_FAILED_CLOSE_CODE = 4003; +/** Daemon-side close for the same failures; the daemon runs on Node's ws. */ +const DAEMON_RECORD_FAILURE_CLOSE_CODE = 1011; +const REHANDSHAKE_REJECTION_CODE = 1008; +const REHANDSHAKE_MISMATCH_CLOSE_REASON = "E2EE re-handshake transcript mismatch"; + +const NOISE_PROLOGUE = new TextEncoder().encode("paseo-relay-e2ee/1"); +const PROTECTED_ENVELOPE_VERSION = 1; +const PARAMS_VERSION = 0x01; +const PARAM_FLAG_BINARY_CIPHERTEXT = 0x01; +const PARAM_FLAGS_KNOWN = PARAM_FLAG_BINARY_CIPHERTEXT; +const HANDSHAKE_RETRY_MS = 1000; +const MAX_PENDING_SENDS = 200; + +interface ProtectedEnvelope { + v: number; + message: string; +} + +interface HelloEnvelope { + type: "e2ee_hello"; + key: string; + capabilities?: { binaryCiphertext?: boolean }; + protected?: ProtectedEnvelope; +} + +interface ReadyEnvelope { + type: "e2ee_ready"; + capabilities?: { binaryCiphertext?: boolean }; + protected?: ProtectedEnvelope; +} + +interface NegotiatedParams { + binaryCiphertext: boolean; +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/** + * Secures a transport as the initiator. Resolves once the hello has been sent; + * `events.onopen` fires when the channel is usable. Sends queued before open + * are flushed in order after the handshake, capped at {@link MAX_PENDING_SENDS}. + */ +export async function openClientSecureChannel( + transport: Transport, + options: ClientSecureChannelOptions, +): Promise { + const events = options.events ?? {}; + const minimumMode = options.minimumMode ?? "legacy"; + const daemonPublicKey = importPublicKey(options.daemonPublicKeyB64); + + // Separate ephemeral keys per protocol: the legacy box key is routing and + // compatibility input only; the Noise ephemeral lives inside the handshake. + const legacyKeyPair = generateKeyPair(); + const legacySharedKey = deriveSharedKey(legacyKeyPair.secretKey, daemonPublicKey); + const noise = createNoiseInitiator({ remoteStatic: daemonPublicKey, prologue: NOISE_PROLOGUE }); + const offered: NegotiatedParams = { binaryCiphertext: true }; + const msg1 = noise.writeMessage(encodeParams(offered)); + const hello: HelloEnvelope = { + type: "e2ee_hello", + key: exportPublicKey(legacyKeyPair.publicKey), + capabilities: { binaryCiphertext: true }, + protected: { v: PROTECTED_ENVELOPE_VERSION, message: arrayBufferToBase64(toArrayBuffer(msg1)) }, + }; + const helloText = JSON.stringify(hello); + + const channel = new ClientSecureChannel(transport, events, { + minimumMode, + noise, + offered, + legacySharedKey, + }); + + let retry: ReturnType | null = null; + const emitSendError = (error: unknown) => { + events.onerror?.(error instanceof Error ? error : new Error(String(error))); + }; + const sendHello = () => { + try { + const result = transport.send(helloText); + if (result) void result.catch(emitSendError); + } catch (error) { + // Daemon restarts move the socket through CLOSING/CLOSED; report, never throw from timers. + emitSendError(error); + } + }; + const clearRetry = () => { + if (retry) { + clearInterval(retry); + retry = null; + } + }; + channel.onTransitionToOpen(clearRetry); + channel.onClose(clearRetry); + + sendHello(); + retry = setInterval(() => { + if (channel.isOpen()) { + clearRetry(); + return; + } + sendHello(); + }, HANDSHAKE_RETRY_MS); + unref(retry); + + return channel; +} + +interface ClientHandshakeState { + minimumMode: SecurityMode; + noise: NoiseHandshake; + offered: NegotiatedParams; + legacySharedKey: Uint8Array; +} + +/** + * Client channel: waits for the daemon's ready, selects the mode, then + * delegates records to the mode's implementation. + */ +class ClientSecureChannel implements SecureChannel { + private state: "handshaking" | "open" | "closed" = "handshaking"; + private closeNotified = false; + private inner: InnerChannel | null = null; + private pendingSends: Array = []; + private readonly onOpenCallbacks: Array<() => void> = []; + private readonly onCloseCallbacks: Array<() => void> = []; + private handshake: ClientHandshakeState | null; + + constructor( + private readonly transport: Transport, + private readonly events: SecureChannelEvents, + handshake: ClientHandshakeState, + ) { + this.handshake = handshake; + Object.assign(transport, { + onmessage: (message: TransportMessage) => this.handleHandshakeMessage(message), + onclose: (code: number, reason: string) => this.handleTransportClose(code, reason), + onerror: (error: Error) => this.events.onerror?.(error), + }); + } + + private handleHandshakeMessage(message: TransportMessage): void { + if (this.state !== "handshaking" || !this.handshake) return; + let parsed: unknown; + try { + if (message.isBinary) return; + parsed = JSON.parse(decodeTransportText(message.data)); + } catch { + return; // not handshake traffic; ignore while handshaking + } + if (!isRecord(parsed) || parsed.type !== "e2ee_ready") return; + + try { + // A ready the daemon recognizably sent but that fails validation is a + // broken or tampered handshake, never unrelated traffic to wait past. + if (!isReadyEnvelope(parsed)) throw new NoiseProtocolError("malformed ready envelope"); + const ready: ReadyEnvelope = parsed; + if (ready.protected !== undefined) { + this.openProtected(ready.protected); + } else { + this.openLegacy(ready); + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.events.onerror?.(err); + this.fail(PROTECTED_HANDSHAKE_FAILED_CLOSE_CODE, err.message); + } + } + + private openProtected(envelope: ProtectedEnvelope): void { + const handshake = this.handshake!; + if (envelope.v !== PROTECTED_ENVELOPE_VERSION) { + throw new NoiseProtocolError("unsupported protected envelope version"); + } + const msg2 = new Uint8Array(base64ToArrayBuffer(envelope.message)); + const selected = decodeParams(handshake.noise.readMessage(msg2)); + if (selected.binaryCiphertext && !handshake.offered.binaryCiphertext) { + throw new NoiseProtocolError("daemon selected an unoffered parameter"); + } + const keys = handshake.noise.split(); + this.becomeOpen( + new ProtectedChannel(this.transport, this.events, keys.send, keys.receive, selected, { + retry: null, + failureCloseCode: PROTECTED_RECORD_FAILED_CLOSE_CODE, + }), + ); + } + + private openLegacy(ready: ReadyEnvelope): void { + const handshake = this.handshake!; + if (handshake.minimumMode === "protected") { + throw new Error("daemon offered only legacy endpoint security"); + } + const legacy = new EncryptedChannel(this.transport, handshake.legacySharedKey, this.events, { + binaryCiphertext: ready.capabilities?.binaryCiphertext === true, + }); + legacy.setState("open"); + this.becomeOpen(new LegacyInner(legacy)); + } + + private becomeOpen(inner: InnerChannel): void { + this.handshake = null; + this.inner = inner; + this.state = "open"; + // The mode implementation now owns the transport callbacks; mirror its close. + inner.onClose(() => this.notifyClosed()); + this.events.onopen?.(); + for (const cb of this.onOpenCallbacks) cb(); + const pending = this.pendingSends; + this.pendingSends = []; + void (async () => { + try { + for (const item of pending) await inner.send(item); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.events.onerror?.(err); + this.fail(PROTECTED_RECORD_FAILED_CLOSE_CODE, err.message); + } + })(); + } + + /** Transport closed while this wrapper still owned its callbacks (handshaking). */ + private handleTransportClose(code: number, reason: string): void { + this.events.onclose?.(code, reason); + this.notifyClosed(); + } + + private notifyClosed(): void { + this.state = "closed"; + this.handshake = null; + if (this.closeNotified) return; + this.closeNotified = true; + for (const cb of this.onCloseCallbacks) cb(); + } + + private fail(code: number, reason: string): void { + if (this.state === "closed") return; + this.state = "closed"; + this.handshake = null; + try { + this.transport.close(code, reason); + } catch { + // ignore + } + } + + async send(data: string | ArrayBuffer): Promise { + if (this.state === "handshaking") { + if (this.pendingSends.length >= MAX_PENDING_SENDS) this.pendingSends.shift(); + this.pendingSends.push(data); + return; + } + if (this.state !== "open" || !this.inner) throw new Error("Channel not open"); + await this.inner.send(data); + } + + close(code = 1000, reason = "Normal closure"): void { + if (this.inner) { + this.inner.close(code, reason); + } else { + this.transport.close(code, reason); + } + this.state = "closed"; + this.handshake = null; + } + + isOpen(): boolean { + return this.state === "open" && this.inner?.isOpen() === true; + } + + securityMode(): SecurityMode { + if (!this.inner) throw new Error("security mode is unknown until the channel opens"); + return this.inner.mode; + } + + outboundWireByteLength(data: string | ArrayBuffer): number { + if (this.inner) return this.inner.outboundWireByteLength(data); + // Mode unknown while handshaking: report the larger of the two so callers stay within limits. + const plaintextBytes = typeof data === "string" ? utf8ByteLength(data) : data.byteLength; + return Math.max( + base64EncryptedWireByteLength(plaintextBytes), + 4 * Math.ceil(protectedRecordByteLength(plaintextBytes) / 3), + ); + } + + onTransitionToOpen(cb: () => void): void { + this.onOpenCallbacks.push(cb); + } + + onClose(cb: () => void): void { + this.onCloseCallbacks.push(cb); + } +} + +// --------------------------------------------------------------------------- +// Daemon +// --------------------------------------------------------------------------- + +/** + * Secures a transport as the responder. Resolves once the peer's hello has + * been accepted and the ready sent. Rejects with + * {@link SecureChannelRejectedError} when policy refuses the peer, and with a + * plain error for malformed or failed handshakes; the caller closes the socket. + */ +export async function openDaemonSecureChannel( + transport: Transport, + options: DaemonSecureChannelOptions, +): Promise { + const events = options.events ?? {}; + const minimumMode = (): SecurityMode => + typeof options.minimumMode === "function" + ? options.minimumMode() + : (options.minimumMode ?? "legacy"); + return new Promise((resolve, reject) => { + const buffered: TransportMessage[] = []; + + const handleHello = async (message: TransportMessage): Promise => { + try { + if (message.isBinary) throw new Error("Invalid hello message (binary frame)"); + const helloText = decodeTransportText(message.data); + let parsed: unknown; + try { + parsed = JSON.parse(helloText); + } catch { + throw new Error(`Invalid hello message: ${preview(helloText)}`); + } + if (!isHelloEnvelope(parsed)) + throw new Error(`Invalid hello message: ${preview(helloText)}`); + + // Buffer traffic that arrives while the ready is in flight so an early + // record is not mistaken for a second hello. + Object.assign(transport, { onmessage: (next: TransportMessage) => buffered.push(next) }); + + const channel = + parsed.protected !== undefined + ? await acceptProtectedHello( + transport, + events, + options.daemonKeyPair, + parsed, + helloText, + ) + : await acceptLegacyHello( + transport, + events, + options.daemonKeyPair, + parsed, + minimumMode(), + ); + + events.onopen?.(); + // The channel now owns the transport: buffered records are decoded and a + // buffered hello retry gets the same exact-transcript answer as a live one. + for (const next of buffered) transport.onmessage?.(next); + resolve(channel); + } catch (error) { + reject(error); + } + }; + + Object.assign(transport, { + onmessage: handleHello, + onerror: (error: Error) => reject(error), + onclose: (code: number, reason: string) => + reject(new Error(`Connection closed during handshake: ${code} ${reason}`)), + }); + }); +} + +async function acceptProtectedHello( + transport: Transport, + events: SecureChannelEvents, + daemonKeyPair: KeyPair, + hello: HelloEnvelope, + helloText: string, +): Promise { + const envelope = hello.protected!; + if (envelope.v !== PROTECTED_ENVELOPE_VERSION) { + throw new NoiseProtocolError("unsupported protected envelope version"); + } + const noise = createNoiseResponder({ localStatic: daemonKeyPair, prologue: NOISE_PROLOGUE }); + const msg1 = new Uint8Array(base64ToArrayBuffer(envelope.message)); + const offered = decodeParams(noise.readMessage(msg1)); + const selected: NegotiatedParams = { binaryCiphertext: offered.binaryCiphertext }; + const msg2 = noise.writeMessage(encodeParams(selected)); + const ready: ReadyEnvelope = { + type: "e2ee_ready", + protected: { v: PROTECTED_ENVELOPE_VERSION, message: arrayBufferToBase64(toArrayBuffer(msg2)) }, + }; + const readyText = JSON.stringify(ready); + await transport.send(readyText); + const keys = noise.split(); + return new ProtectedChannel(transport, events, keys.send, keys.receive, selected, { + retry: { helloText, readyText }, + failureCloseCode: DAEMON_RECORD_FAILURE_CLOSE_CODE, + }); +} + +async function acceptLegacyHello( + transport: Transport, + events: SecureChannelEvents, + daemonKeyPair: KeyPair, + hello: HelloEnvelope, + minimumMode: SecurityMode, +): Promise { + if (minimumMode === "protected") { + throw new SecureChannelRejectedError( + "client offered only legacy endpoint security", + PROTECTED_MODE_REQUIRED_CLOSE_CODE, + PROTECTED_MODE_REQUIRED_CLOSE_REASON, + ); + } + const clientPublicKey = importPublicKey(hello.key); + const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey); + const binaryCiphertext = hello.capabilities?.binaryCiphertext === true; + await transport.send( + JSON.stringify({ + type: "e2ee_ready", + ...(binaryCiphertext ? { capabilities: { binaryCiphertext: true } } : {}), + } satisfies ReadyEnvelope), + ); + const legacy = new EncryptedChannel(transport, sharedKey, events, { + daemonKeyPair, + binaryCiphertext, + }); + legacy.setState("open"); + return new LegacyInner(legacy); +} + +// --------------------------------------------------------------------------- +// Mode implementations +// --------------------------------------------------------------------------- + +interface InnerChannel extends SecureChannel { + readonly mode: SecurityMode; +} + +class LegacyInner implements InnerChannel { + readonly mode = "legacy" as const; + constructor(readonly legacy: EncryptedChannel) {} + send(data: string | ArrayBuffer): Promise { + return this.legacy.send(data); + } + close(code?: number, reason?: string): void { + this.legacy.close(code, reason); + } + isOpen(): boolean { + return this.legacy.isOpen(); + } + securityMode(): SecurityMode { + return this.mode; + } + outboundWireByteLength(data: string | ArrayBuffer): number { + return this.legacy.outboundWireByteLength(data); + } + onTransitionToOpen(cb: () => void): void { + this.legacy.onTransitionToOpen(cb); + } + onClose(cb: () => void): void { + this.legacy.onClose(cb); + } +} + +interface DaemonRetryTranscript { + helloText: string; + readyText: string; +} + +/** + * Protected records over an open transport. Every inbound record is + * authenticated and framing-checked before delivery; any failure closes the + * channel and clears the keys. + */ +class ProtectedChannel implements InnerChannel { + readonly mode = "protected" as const; + private state: "open" | "closed" = "open"; + private readonly onCloseCallbacks: Array<() => void> = []; + + constructor( + private readonly transport: Transport, + private readonly events: SecureChannelEvents, + private readonly sender: CipherState, + private readonly receiver: CipherState, + private readonly params: NegotiatedParams, + private readonly role: { + /** Daemon side only: exact transcript used to answer identical retries. */ + retry: DaemonRetryTranscript | null; + failureCloseCode: number; + }, + ) { + Object.assign(transport, { + onmessage: (message: TransportMessage) => this.handleMessage(message), + onclose: (code: number, reason: string) => { + this.markClosed(); + this.events.onclose?.(code, reason); + }, + onerror: (error: Error) => this.events.onerror?.(error), + }); + } + + private async handleMessage(message: TransportMessage): Promise { + if (this.state !== "open") return; + try { + const handshake = !message.isBinary ? parseHandshakeEnvelope(message.data) : null; + if (handshake) { + await this.handleHandshakeTraffic(handshake); + return; + } + const record = message.isBinary + ? new Uint8Array(requireArrayBuffer(message.data)) + : new Uint8Array(base64ToArrayBuffer(decodeTransportText(message.data))); + const frame = decodeProtectedRecord(record, this.receiver); + const plaintext = + frame.kind === "text" + ? new TextDecoder("utf-8", { fatal: true }).decode(frame.data) + : toArrayBuffer(frame.data); + this.events.onmessage?.(plaintext); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.fail(this.role.failureCloseCode, err.message); + } + } + + private async handleHandshakeTraffic( + handshake: { kind: "hello"; text: string } | { kind: "ready" }, + ): Promise { + if (handshake.kind === "ready") return; // client ignores duplicate ready after open + if (!this.role.retry) return; // a client never answers hellos + if (handshake.text === this.role.retry.helloText) { + await this.transport.send(this.role.retry.readyText); + return; + } + this.fail(REHANDSHAKE_REJECTION_CODE, REHANDSHAKE_MISMATCH_CLOSE_REASON); + } + + async send(data: string | ArrayBuffer): Promise { + if (this.state !== "open") throw new Error("Channel not open"); + const kind = typeof data === "string" ? "text" : "binary"; + const frame = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data); + let wire: Uint8Array; + try { + // Synchronous up to here: the nonce is reserved before any await. + wire = encodeProtectedRecord(kind, frame, this.sender); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.fail(this.role.failureCloseCode, err.message); + throw err; + } + if (this.params.binaryCiphertext && kind === "binary") { + await this.transport.send(toArrayBuffer(wire)); + return; + } + await this.transport.send(arrayBufferToBase64(toArrayBuffer(wire))); + } + + outboundWireByteLength(data: string | ArrayBuffer): number { + const plaintextBytes = typeof data === "string" ? utf8ByteLength(data) : data.byteLength; + const wireBytes = protectedRecordByteLength(plaintextBytes); + if (this.params.binaryCiphertext && typeof data !== "string") return wireBytes; + return 4 * Math.ceil(wireBytes / 3); + } + + close(code = 1000, reason = "Normal closure"): void { + this.markClosed(); + this.transport.close(code, reason); + } + + private fail(code: number, reason: string): void { + if (this.state !== "open") return; + this.markClosed(); + try { + this.transport.close(code, reason); + } catch { + // ignore + } + } + + private markClosed(): void { + if (this.state === "closed") return; + this.state = "closed"; + this.sender.clear(); + this.receiver.clear(); + for (const cb of this.onCloseCallbacks) cb(); + } + + isOpen(): boolean { + return this.state === "open"; + } + + securityMode(): SecurityMode { + return this.mode; + } + + onTransitionToOpen(cb: () => void): void { + cb(); + } + + onClose(cb: () => void): void { + this.onCloseCallbacks.push(cb); + } +} + +/** + * Largest plaintext that fits in `wireBytes` as a base64 text frame in either + * mode. Callers size payloads before the mode is known, so take the tighter bound. + */ +export function maxSecuredPlaintextByteLength(wireBytes: number): number { + const legacy = maxBase64EncryptedPlaintextByteLength(wireBytes); + const base64Capacity = Math.floor(wireBytes / 4) * 3; + let protectedBound = base64Capacity; + while (protectedBound > 0 && protectedRecordByteLength(protectedBound) > base64Capacity) { + protectedBound = base64Capacity - (protectedRecordByteLength(protectedBound) - protectedBound); + } + return Math.max(0, Math.min(legacy, protectedBound)); +} + +// --------------------------------------------------------------------------- +// Envelope parsing and parameter encoding +// --------------------------------------------------------------------------- + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isCapabilities(value: unknown): value is { binaryCiphertext?: boolean } { + return ( + value === undefined || + (isRecord(value) && + (value.binaryCiphertext === undefined || typeof value.binaryCiphertext === "boolean")) + ); +} + +function isProtectedEnvelope(value: unknown): value is ProtectedEnvelope { + return ( + isRecord(value) && + typeof value.v === "number" && + typeof value.message === "string" && + value.message.length > 0 + ); +} + +function isHelloEnvelope(value: unknown): value is HelloEnvelope { + return ( + isRecord(value) && + value.type === "e2ee_hello" && + typeof value.key === "string" && + value.key.trim().length > 0 && + isCapabilities(value.capabilities) && + (value.protected === undefined || isProtectedEnvelope(value.protected)) + ); +} + +function isReadyEnvelope(value: unknown): value is ReadyEnvelope { + return ( + isRecord(value) && + value.type === "e2ee_ready" && + isCapabilities(value.capabilities) && + (value.protected === undefined || isProtectedEnvelope(value.protected)) + ); +} + +/** Classifies plaintext handshake traffic seen on an open channel; null for anything else. */ +function parseHandshakeEnvelope( + data: string | ArrayBuffer, +): { kind: "hello"; text: string } | { kind: "ready" } | null { + let text: string; + try { + text = decodeTransportText(data); + } catch { + return null; + } + if (!text.trim().startsWith("{")) return null; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new Error("Received plaintext frame on encrypted channel"); + } + if (isRecord(parsed) && parsed.type === "e2ee_hello") return { kind: "hello", text }; + if (isRecord(parsed) && parsed.type === "e2ee_ready") return { kind: "ready" }; + // Any other JSON is plaintext app traffic: the peer is not encrypting. + throw new Error("Received plaintext frame on encrypted channel"); +} + +function encodeParams(params: NegotiatedParams): Uint8Array { + return new Uint8Array([ + PARAMS_VERSION, + params.binaryCiphertext ? PARAM_FLAG_BINARY_CIPHERTEXT : 0, + ]); +} + +function decodeParams(payload: Uint8Array): NegotiatedParams { + if (payload.byteLength !== 2) throw new NoiseProtocolError("invalid parameter payload"); + if (payload[0] !== PARAMS_VERSION) throw new NoiseProtocolError("unsupported parameter version"); + if ((payload[1] & ~PARAM_FLAGS_KNOWN) !== 0) + throw new NoiseProtocolError("reserved parameter flag set"); + return { binaryCiphertext: (payload[1] & PARAM_FLAG_BINARY_CIPHERTEXT) !== 0 }; +} + +function preview(text: string): string { + const compact = text.replace(/\s+/g, " ").trim(); + return JSON.stringify(compact.length > 160 ? `${compact.slice(0, 157)}...` : compact); +} + +function decodeTransportText(data: string | ArrayBuffer): string { + return typeof data === "string" ? data : new TextDecoder().decode(data); +} + +function requireArrayBuffer(data: string | ArrayBuffer): ArrayBuffer { + if (data instanceof ArrayBuffer) return data; + throw new Error("Binary WebSocket frame did not contain bytes"); +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +function utf8ByteLength(text: string): number { + return new TextEncoder().encode(text).byteLength; +} + +function unref(timer: unknown): void { + if (typeof timer === "object" && timer !== null && "unref" in timer) { + (timer as { unref: () => void }).unref(); + } +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 201702444c..d8d611be70 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -173,6 +173,7 @@ import { createConfiguredTerminalManager } from "../terminal/terminal-manager-fa import { applyTerminalAgentHookSetting } from "../terminal/agent-hooks/terminal-agent-hook-setting.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; import { createRelayRuntime, type RelayRuntime } from "./relay-runtime.js"; +import { admitSecuredRelaySocket } from "./websocket/secure-relay-ingress.js"; import type { PushNotificationSender } from "./push/index.js"; import { getOrCreateServerId } from "./server-id.js"; import { resolveDaemonVersion } from "./daemon-version.js"; @@ -424,6 +425,7 @@ export interface PaseoDaemonConfig { agentStoragePath: string; relayEnabled?: boolean; relayEnabledMutable?: boolean; + relayRequireProtectedSecurity?: boolean; relayEndpoint?: string; relayPublicEndpoint?: string; relayUseTls?: boolean; @@ -539,11 +541,18 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri return config.trustedProxies ?? ["loopback"]; } +function createInitialRelayConfig(config: PaseoDaemonConfig): MutableDaemonConfig["relay"] { + return { + enabled: config.relayEnabled ?? true, + requireProtectedSecurity: config.relayRequireProtectedSecurity ?? false, + }; +} + function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig { const providers = config.providerOverrides ?? {}; const initialConfig: MutableDaemonConfig = { - relay: { enabled: config.relayEnabled ?? true }, + relay: createInitialRelayConfig(config), mcp: { enabled: config.mcpEnabled ?? true, injectIntoAgents: config.mcpInjectIntoAgents ?? true, @@ -1758,14 +1767,17 @@ export async function createPaseoDaemon( return appBaseUrl; }, desktopManaged: config.desktopManaged === true, - getRelayConfig: () => - relayRuntime?.getConfig() ?? { + getRelayConfig: () => ({ + ...(relayRuntime?.getConfig() ?? { enabled: daemonConfigStore.get().relay?.enabled ?? relayEnabled, endpoint: relayEndpoint, publicEndpoint: relayPublicEndpoint, useTls: relayUseTls, publicUseTls: relayPublicUseTls, - }, + }), + requireProtectedSecurity: + daemonConfigStore.get().relay?.requireProtectedSecurity === true, + }), }, serviceProxyPublicBaseUrl, browserToolsBroker, @@ -1779,6 +1791,7 @@ export async function createPaseoDaemon( await pluginRuntime.start(); providerSnapshotManager.settlePluginProviders(); wsServer.beginAcceptingConnections(); + const relaySecurityLogger = logger.child({ module: "relay-security" }); relayRuntime = createRelayRuntime({ config: { enabled: relayEnabled, @@ -1788,12 +1801,25 @@ export async function createPaseoDaemon( publicUseTls: relayPublicUseTls, }, logger, - attachSocket: async (ws, metadata) => { - if (!wsServer) throw new Error("WebSocket server is not ready"); - await wsServer.attachExternalSocket(ws, metadata); - }, + attachSocket: (ws, metadata) => + admitSecuredRelaySocket( + ws, + { + daemonKeyPair: daemonKeyPair.keyPair, + logger: relaySecurityLogger, + requireProtectedMode: () => + daemonConfigStore.get().relay?.requireProtectedSecurity === true, + }, + async (secured) => { + if (!wsServer) throw new Error("WebSocket server is not ready"); + await wsServer.attachExternalSocket(secured.socket, { + ...metadata, + transport: "relay", + securityMode: secured.securityMode, + }); + }, + ), serverId, - daemonKeyPair: daemonKeyPair.keyPair, }); daemonConfigStore.onFieldChange("relay.enabled", (value) => { relayRuntime?.setEnabled(value === true); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 7b2d06560e..cc4eee4fec 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -275,6 +275,7 @@ interface ResolveRelayInput { interface ResolvedRelay { enabled: boolean; enabledMutable: boolean; + requireProtectedSecurity: boolean; endpoint: string; publicEndpoint: string; useTls: boolean; @@ -297,6 +298,21 @@ function resolveTlsFromEnv( return persistedValue ?? fallback; } +function resolveRelayEndpoints(input: ResolveRelayInput): { + endpoint: string; + publicEndpoint: string; +} { + const endpoint = + input.env.PASEO_RELAY_ENDPOINT ?? + input.persisted.daemon?.relay?.endpoint ?? + DEFAULT_RELAY_ENDPOINT; + const publicEndpoint = + input.env.PASEO_RELAY_PUBLIC_ENDPOINT ?? + input.persisted.daemon?.relay?.publicEndpoint ?? + endpoint; + return { endpoint, publicEndpoint }; +} + function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay { const environmentEnabled = parseBooleanEnv(input.env.PASEO_RELAY_ENABLED); // COMPAT(relayOptInDefault): daemons whose startup config omitted this field @@ -306,14 +322,7 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay { environmentEnabled ?? input.persisted.daemon?.relay?.enabled ?? input.enabledFallback; - const endpoint = - input.env.PASEO_RELAY_ENDPOINT ?? - input.persisted.daemon?.relay?.endpoint ?? - DEFAULT_RELAY_ENDPOINT; - const publicEndpoint = - input.env.PASEO_RELAY_PUBLIC_ENDPOINT ?? - input.persisted.daemon?.relay?.publicEndpoint ?? - endpoint; + const { endpoint, publicEndpoint } = resolveRelayEndpoints(input); const useTls = input.cliRelayUseTls ?? resolveTlsFromEnv( @@ -329,6 +338,7 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay { return { enabled, enabledMutable: input.cliRelayEnabled === undefined && environmentEnabled === undefined, + requireProtectedSecurity: input.persisted.daemon?.relay?.requireProtectedSecurity ?? false, endpoint, publicEndpoint, useTls, @@ -635,6 +645,7 @@ export function resolveConfigFromPersisted( agentClients: {}, relayEnabled: relay.enabled, relayEnabledMutable: relay.enabledMutable, + relayRequireProtectedSecurity: relay.requireProtectedSecurity, relayEndpoint: relay.endpoint, relayPublicEndpoint: relay.publicEndpoint, relayUseTls: relay.useTls, diff --git a/packages/server/src/server/connection-offer.ts b/packages/server/src/server/connection-offer.ts index d435049f06..df2e952a06 100644 --- a/packages/server/src/server/connection-offer.ts +++ b/packages/server/src/server/connection-offer.ts @@ -31,12 +31,14 @@ export async function createConnectionOfferV2(args: { serverId: string; daemonPublicKeyB64: string; relay: { endpoint: string; useTls?: boolean }; + minimumSecurityMode?: "protected"; }): Promise { return ConnectionOfferV2Schema.parse({ v: 2, serverId: args.serverId, daemonPublicKeyB64: args.daemonPublicKeyB64, relay: args.relay, + ...(args.minimumSecurityMode ? { minimumSecurityMode: args.minimumSecurityMode } : {}), }); } diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 3629a0fd18..c68cda02d2 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -120,6 +120,35 @@ describe("DaemonConfigStore", () => { expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true); }); + test("patch persists the relay protected security requirement independently of relay enablement", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + const store = new DaemonConfigStore( + paseoHome, + { + relay: { enabled: true }, + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + }, + undefined, + // A launch override owns relay.enabled; the security requirement is still editable. + { relayEnabledMutable: false }, + ); + const changes: unknown[] = []; + store.onFieldChange("relay.requireProtectedSecurity", (value) => changes.push(value)); + + store.patch({ relay: { requireProtectedSecurity: true } }); + + expect(changes).toEqual([true]); + expect(store.get().relay).toEqual({ enabled: true, requireProtectedSecurity: true }); + expect(loadPersistedConfig(paseoHome).daemon?.relay?.requireProtectedSecurity).toBe(true); + }); + test("patch round-trips agent profiles through the strictly-parsed persisted config", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 3e285bded0..489f68ed5d 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -17,7 +17,7 @@ type MutableDaemonConfigPatch = import("@getpaseo/protocol/messages").MutableDae type ProviderOverride = import("./agent/provider-launch-config.js").ProviderOverride; interface SupportedMutableConfigPatch { - relay?: { enabled?: boolean }; + relay?: { enabled?: boolean; requireProtectedSecurity?: boolean }; mcp?: { injectIntoAgents?: boolean }; browserTools?: { enabled?: boolean }; providers?: MutableDaemonConfig["providers"]; @@ -170,6 +170,7 @@ function isEqualValue(a: unknown, b: unknown): boolean { const RELOADABLE_PATHS = [ "daemon.relay.enabled", + "daemon.relay.requireProtectedSecurity", "daemon.mcp.enabled", "daemon.mcp.injectIntoAgents", "daemon.browserTools.enabled", @@ -193,6 +194,7 @@ const RELOADABLE_PATHS = [ const PERSISTED_TO_MUTABLE_PATH = new Map([ ["daemon.relay.enabled", "relay.enabled"], + ["daemon.relay.requireProtectedSecurity", "relay.requireProtectedSecurity"], ["daemon.mcp.enabled", "mcp.enabled"], ["daemon.mcp.injectIntoAgents", "mcp.injectIntoAgents"], ["daemon.browserTools.enabled", "browserTools.enabled"], @@ -249,9 +251,21 @@ function compactOwnedPaths(paths: readonly string[], owners: readonly string[]): return Array.from(compacted).sort(); } +function pickRelayPatch( + relay: MutableDaemonConfigPatch["relay"], +): Pick { + const picked: NonNullable = { + ...(relay?.enabled !== undefined ? { enabled: relay.enabled } : {}), + ...(relay?.requireProtectedSecurity !== undefined + ? { requireProtectedSecurity: relay.requireProtectedSecurity } + : {}), + }; + return Object.keys(picked).length > 0 ? { relay: picked } : {}; +} + function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMutableConfigPatch { return { - ...(patch.relay?.enabled !== undefined ? { relay: { enabled: patch.relay.enabled } } : {}), + ...pickRelayPatch(patch.relay), ...(patch.mcp?.injectIntoAgents !== undefined ? { mcp: { injectIntoAgents: patch.mcp.injectIntoAgents } } : {}), @@ -646,6 +660,9 @@ function mergeMutableDaemonPatch( if (persistRelayEnabled && patch.relay?.enabled !== undefined) { next.relay = { ...next.relay, enabled: patch.relay.enabled }; } + if (patch.relay?.requireProtectedSecurity !== undefined) { + next.relay = { ...next.relay, requireProtectedSecurity: patch.relay.requireProtectedSecurity }; + } if (patch.mcp?.injectIntoAgents !== undefined) { next.mcp = { ...next.mcp, injectIntoAgents: patch.mcp.injectIntoAgents }; } diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index facf6dffd7..dd0ac2fbdb 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -26,7 +26,12 @@ import os from "node:os"; import { generateLocalPairingOffer } from "../pairing-offer.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { FILE_EXPLORER_STREAM_CHUNK_BYTES } from "../file-explorer/service.js"; -import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; +import { + createClientChannel, + openClientSecureChannel, + type SecureChannel, + type Transport, +} from "@getpaseo/relay/e2ee"; import { deriveSharedKey, decrypt, @@ -59,6 +64,7 @@ async function getPairingOfferUrl(args: { relayEnabled?: boolean; relayEndpoint?: string; relayPublicEndpoint?: string; + relayRequireProtectedSecurity?: boolean; appBaseUrl?: string; }): Promise { const pairing = await generateLocalPairingOffer({ @@ -66,6 +72,7 @@ async function getPairingOfferUrl(args: { relayEnabled: args.relayEnabled, relayEndpoint: args.relayEndpoint, relayPublicEndpoint: args.relayPublicEndpoint, + relayRequireProtectedSecurity: args.relayRequireProtectedSecurity, appBaseUrl: args.appBaseUrl, includeQr: false, }); @@ -90,6 +97,113 @@ function decodeOfferFromFragmentUrl(url: string): { return { serverId: offer.serverId, daemonPublicKeyB64: offer.daemonPublicKeyB64 }; } +/** + * An updated relay client: the shared secure channel over a raw relay socket. + * Resolves once the daemon answers the app hello with server_info. + */ +async function openUpdatedRelayClient(args: { + relayPort: number; + serverId: string; + daemonPublicKeyB64: string; + clientId: string; + minimumMode?: "protected"; +}): Promise<{ + channel: SecureChannel; + serverInfo: { features?: Record }; + messages: unknown[]; + waitFor: (predicate: (message: unknown) => boolean) => Promise; + close: () => void; +}> { + const ws = new WebSocket( + buildRelayWebSocketUrl({ + endpoint: `127.0.0.1:${args.relayPort}`, + useTls: false, + serverId: args.serverId, + role: "client", + }), + ); + const transport: Transport = { + send: (data) => ws.send(data), + close: (code?: number, reason?: string) => ws.close(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + ws.on("message", (data, isBinary) => { + const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as Buffer); + transport.onmessage?.({ + data: isBinary + ? buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) + : buffer.toString("utf8"), + isBinary, + }); + }); + ws.on("close", (code, reason) => transport.onclose?.(code, reason.toString())); + ws.on("error", (error) => transport.onerror?.(error)); + await once(ws, "open"); + + const messages: unknown[] = []; + const waiters: Array<{ + predicate: (message: unknown) => boolean; + resolve: (m: unknown) => void; + }> = []; + const deliver = (message: unknown) => { + messages.push(message); + for (const waiter of waiters.slice()) { + if (!waiter.predicate(message)) continue; + waiters.splice(waiters.indexOf(waiter), 1); + waiter.resolve(message); + } + }; + const waitFor = (predicate: (message: unknown) => boolean) => + withTimeout( + new Promise((resolve) => { + const existing = messages.find(predicate); + if (existing !== undefined) resolve(existing); + else waiters.push({ predicate, resolve }); + }), + 20_000, + "relay client message", + ); + const isServerInfo = (message: unknown) => { + const parsed = WSOutboundMessageSchema.safeParse(message); + return ( + parsed.success && + parsed.data.type === "session" && + parsed.data.message.type === "status" && + parsed.data.message.payload?.status === "server_info" + ); + }; + + const channel = await openClientSecureChannel(transport, { + daemonPublicKeyB64: args.daemonPublicKeyB64, + ...(args.minimumMode ? { minimumMode: args.minimumMode } : {}), + events: { + onmessage: (data) => { + if (typeof data === "string") deliver(JSON.parse(data)); + }, + }, + }); + await channel.send( + JSON.stringify({ + type: "hello", + clientId: args.clientId, + clientType: "cli", + protocolVersion: 1, + }), + ); + const serverInfoMessage = (await waitFor(isServerInfo)) as { + message: { payload: { features?: Record } }; + }; + return { + channel, + serverInfo: serverInfoMessage.message.payload, + messages, + waitFor, + close: () => ws.close(), + }; +} + function sha256(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); } @@ -204,9 +318,18 @@ async function waitForCapturedLog( let relayPort: number; let relayProcess: ChildProcess | null = null; let relayStdoutLines: string[] = []; + // PASEO_RELAY_E2E_PORT points at an already-running relay (for example the + // Elixir relay) so the same daemon/client exchanges run against both. + const externalRelayPort = Number(process.env.PASEO_RELAY_E2E_PORT ?? ""); + const usesExternalRelay = Number.isInteger(externalRelayPort) && externalRelayPort > 0; const startRelay = async (options: { useLocalRelay?: boolean; port?: number } = {}) => { relayStdoutLines = []; + if (usesExternalRelay) { + relayPort = externalRelayPort; + await waitForRelayWebSocketReady(relayPort, 60000); + return; + } relayPort = options.port ?? (await getAvailablePort()); const relayDir = path.resolve(process.cwd(), "../relay"); const relayArgs = [ @@ -273,144 +396,149 @@ async function waitForCapturedLog( await withTimeout(exited, 10000, "Local relay did not stop"); }; - test("connected hosts route RPC and binary traffic through local E2EE relay and recover scopes", async () => { - let a: Awaited> | undefined; - let b: Awaited> | undefined; - let origin: ReturnType | undefined; - let api: PaseoApi | undefined; - let remote: PaseoApi | undefined; - let sibling: PaseoApi | undefined; - let terminalRoute: Awaited> | undefined; - let wire: Awaited> | undefined; - try { - await startRelay({ useLocalRelay: true }); - const port = relayPort; - b = await createTestPaseoDaemon({ - relayEnabled: true, - relayEndpoint: `127.0.0.1:${port}`, - relayUseTls: false, - relayPublicUseTls: false, - auth: { password: hashSync("relay-password", 4) }, - }); - a = await createTestPaseoDaemon(); - origin = createHostClient(`127.0.0.1:${a.port}`, { clientId: crypto.randomUUID() }); - await origin.connect(); - api = createPaseoApi(origin); - const offerUrl = await getPairingOfferUrl({ - paseoHome: b.paseoHome, - relayEnabled: true, - relayEndpoint: b.config.relayEndpoint, - }); - const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); - wire = await relayWire(port); - const connection = new URL(`relay://127.0.0.1:${wire.port}/${serverId}`); - connection.searchParams.set("key", daemonPublicKeyB64); - connection.searchParams.set("password", "wrong-password"); - const saved = await api.hosts.add({ connection: connection.toString() }); - await expect - .poll(async () => (await api!.hosts.list()).hosts[0]?.error?.code, { timeout: 20000 }) - .toBe("authentication_failed"); - expect(JSON.stringify(await api.hosts.list())).not.toMatch(/wrong-password|relay-password/); - connection.searchParams.set("password", "relay-password"); - await api.hosts.update(saved.registrationId, { connection: connection.toString() }); - await expect - .poll(async () => (await api!.hosts.list()).hosts[0]?.status, { timeout: 20000 }) - .toBe("online"); - const admittedConnections = wire.connections(); - expect(wire.active()).toBe(1); - remote = await api.hosts.client(serverId); - sibling = await api.hosts.client(serverId); - expect(wire.connections()).toBe(admittedConnections); - expect(wire.active()).toBe(1); - expect((await remote.workspaces.list()).entries).toEqual([]); - const observed = await remote.workspaces.list({ subscribe: {} }); - let snapshots = 0; - observed.subscription.subscribe({ - snapshot: () => { - snapshots++; - }, - update() {}, - }); - const workspace = await remote.workspaces.create({ - source: { kind: "directory", path: b.staticDir }, - title: "Relayed workspace", - }); - const terminal = await workspace.terminals.create({ - command: process.execPath, - args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], - }); - terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id); - expect(wire.connections()).toBe(admittedConnections); - terminalRoute.write("ENCRYPTED_BINARY_BEFORE\n"); - await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_BEFORE"); - terminalRoute.close(); - terminalRoute = undefined; - const before = snapshots; - await stopRelay(); - await expect - .poll( - async () => { - try { - await remote!.workspaces.list(); - return "online"; - } catch (error) { - return (error as { code: string }).code; - } - }, - { timeout: 15000 }, - ) - .toBe("DAEMON_CONNECTION_LOST"); - await startRelay({ useLocalRelay: true, port }); - await expect - .poll( - async () => { - try { - return (await remote!.workspaces.list()).entries.length; - } catch { - return -1; - } - }, - { timeout: 30000 }, - ) - .toBe(1); - await expect.poll(() => snapshots, { timeout: 15000 }).toBeGreaterThan(before); - expect(wire.active()).toBe(1); - terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id); - terminalRoute.write("ENCRYPTED_BINARY_AFTER\n"); - await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_AFTER"); - terminalRoute.close(); - terminalRoute = undefined; - await remote.dispose(); - remote = undefined; - expect((await sibling.workspaces.list()).entries).toHaveLength(1); - await sibling.terminals.ref(terminal.id).kill(); - await api.dispose(); - await expect(sibling.workspaces.list()).rejects.toThrow(); - const direct = createHostClient(`127.0.0.1:${b.port}`, { - clientId: crypto.randomUUID(), - password: "relay-password", - reconnect: { enabled: false }, - }); + // Needs to stop and restart the relay, which only the spawned wrangler relay allows. + test.skipIf(usesExternalRelay)( + "connected hosts route RPC and binary traffic through local E2EE relay and recover scopes", + async () => { + let a: Awaited> | undefined; + let b: Awaited> | undefined; + let origin: ReturnType | undefined; + let api: PaseoApi | undefined; + let remote: PaseoApi | undefined; + let sibling: PaseoApi | undefined; + let terminalRoute: Awaited> | undefined; + let wire: Awaited> | undefined; try { - await direct.connect(); - const directApi = createPaseoApi(direct); - expect((await directApi.workspaces.list()).entries).toHaveLength(1); - await directApi.dispose(); + await startRelay({ useLocalRelay: true }); + const port = relayPort; + b = await createTestPaseoDaemon({ + relayEnabled: true, + relayEndpoint: `127.0.0.1:${port}`, + relayUseTls: false, + relayPublicUseTls: false, + auth: { password: hashSync("relay-password", 4) }, + }); + a = await createTestPaseoDaemon(); + origin = createHostClient(`127.0.0.1:${a.port}`, { clientId: crypto.randomUUID() }); + await origin.connect(); + api = createPaseoApi(origin); + const offerUrl = await getPairingOfferUrl({ + paseoHome: b.paseoHome, + relayEnabled: true, + relayEndpoint: b.config.relayEndpoint, + }); + const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); + wire = await relayWire(port); + const connection = new URL(`relay://127.0.0.1:${wire.port}/${serverId}`); + connection.searchParams.set("key", daemonPublicKeyB64); + connection.searchParams.set("password", "wrong-password"); + const saved = await api.hosts.add({ connection: connection.toString() }); + await expect + .poll(async () => (await api!.hosts.list()).hosts[0]?.error?.code, { timeout: 20000 }) + .toBe("authentication_failed"); + expect(JSON.stringify(await api.hosts.list())).not.toMatch(/wrong-password|relay-password/); + connection.searchParams.set("password", "relay-password"); + await api.hosts.update(saved.registrationId, { connection: connection.toString() }); + await expect + .poll(async () => (await api!.hosts.list()).hosts[0]?.status, { timeout: 20000 }) + .toBe("online"); + const admittedConnections = wire.connections(); + expect(wire.active()).toBe(1); + remote = await api.hosts.client(serverId); + sibling = await api.hosts.client(serverId); + expect(wire.connections()).toBe(admittedConnections); + expect(wire.active()).toBe(1); + expect((await remote.workspaces.list()).entries).toEqual([]); + const observed = await remote.workspaces.list({ subscribe: {} }); + let snapshots = 0; + observed.subscription.subscribe({ + snapshot: () => { + snapshots++; + }, + update() {}, + }); + const workspace = await remote.workspaces.create({ + source: { kind: "directory", path: b.staticDir }, + title: "Relayed workspace", + }); + const terminal = await workspace.terminals.create({ + command: process.execPath, + args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"], + }); + terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id); + expect(wire.connections()).toBe(admittedConnections); + terminalRoute.write("ENCRYPTED_BINARY_BEFORE\n"); + await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_BEFORE"); + terminalRoute.close(); + terminalRoute = undefined; + const before = snapshots; + await stopRelay(); + await expect + .poll( + async () => { + try { + await remote!.workspaces.list(); + return "online"; + } catch (error) { + return (error as { code: string }).code; + } + }, + { timeout: 15000 }, + ) + .toBe("DAEMON_CONNECTION_LOST"); + await startRelay({ useLocalRelay: true, port }); + await expect + .poll( + async () => { + try { + return (await remote!.workspaces.list()).entries.length; + } catch { + return -1; + } + }, + { timeout: 30000 }, + ) + .toBe(1); + await expect.poll(() => snapshots, { timeout: 15000 }).toBeGreaterThan(before); + expect(wire.active()).toBe(1); + terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id); + terminalRoute.write("ENCRYPTED_BINARY_AFTER\n"); + await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_AFTER"); + terminalRoute.close(); + terminalRoute = undefined; + await remote.dispose(); + remote = undefined; + expect((await sibling.workspaces.list()).entries).toHaveLength(1); + await sibling.terminals.ref(terminal.id).kill(); + await api.dispose(); + await expect(sibling.workspaces.list()).rejects.toThrow(); + const direct = createHostClient(`127.0.0.1:${b.port}`, { + clientId: crypto.randomUUID(), + password: "relay-password", + reconnect: { enabled: false }, + }); + try { + await direct.connect(); + const directApi = createPaseoApi(direct); + expect((await directApi.workspaces.list()).entries).toHaveLength(1); + await directApi.dispose(); + } finally { + await direct.close(); + } } finally { - await direct.close(); + terminalRoute?.close(); + await remote?.dispose(); + await sibling?.dispose(); + await api?.dispose(); + await origin?.close(); + await a?.close(); + await b?.close(); + await wire?.close(); + await stopRelay(); } - } finally { - terminalRoute?.close(); - await remote?.dispose(); - await sibling?.dispose(); - await api?.dispose(); - await origin?.close(); - await a?.close(); - await b?.close(); - await wire?.close(); - await stopRelay(); - } - }, 120000); + }, + 120000, + ); test("oversized attachments fail without disconnecting or resubmitting over the relay", async () => { await startRelay({ useLocalRelay: true }); @@ -620,11 +748,11 @@ async function waitForCapturedLog( } }, 90000); - test("daemon closes a relay client that sends an unsupported handshake key", async () => { + test("updated client negotiates protected endpoint security through the relay", async () => { process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; const { logger, lines } = createCapturingLogger(); - await startRelay({ useLocalRelay: true }); + await startRelay(); const daemon = await createTestPaseoDaemon({ listen: "127.0.0.1", @@ -641,8 +769,76 @@ async function waitForCapturedLog( relayPublicEndpoint: daemon.config.relayPublicEndpoint, appBaseUrl: daemon.config.appBaseUrl, }); - const { serverId } = decodeOfferFromFragmentUrl(offerUrl); - const ws = new WebSocket( + const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); + const client = await openUpdatedRelayClient({ + relayPort, + serverId, + daemonPublicKeyB64, + clientId: "cid_relay_protected_client", + }); + try { + expect(client.channel.securityMode()).toBe("protected"); + expect(client.serverInfo.features?.relayProtectedSecurity).toBe(true); + + await client.channel.send(JSON.stringify({ type: "ping" })); + const pong = await client.waitFor( + (message) => (message as { type?: string }).type === "pong", + ); + expect(pong).toEqual({ type: "pong" }); + await waitForCapturedLog( + lines, + (line) => line.includes('"securityMode":"protected"'), + 5_000, + ); + } finally { + client.close(); + } + } catch (err) { + // eslint-disable-next-line no-console + console.error("daemon logs (tail):\n", lines.slice(-50).join("")); + throw err; + } finally { + await daemon.close(); + await stopRelay(); + } + }, 90000); + + test("daemon requiring protected security rejects a legacy relay client and admits an updated one", async () => { + process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; + + const { logger, lines } = createCapturingLogger(); + await startRelay(); + + const daemon = await createTestPaseoDaemon({ + listen: "127.0.0.1", + logger, + relayEnabled: true, + relayEndpoint: `127.0.0.1:${relayPort}`, + relayRequireProtectedSecurity: true, + }); + + try { + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + relayRequireProtectedSecurity: daemon.config.relayRequireProtectedSecurity, + appBaseUrl: daemon.config.appBaseUrl, + }); + const offer = ConnectionOfferSchema.parse( + JSON.parse( + Buffer.from( + offerUrl.slice(offerUrl.indexOf("#offer=") + "#offer=".length), + "base64url", + ).toString("utf8"), + ), + ); + expect(offer.minimumSecurityMode).toBe("protected"); + const { serverId, daemonPublicKeyB64 } = offer; + + // A client that only speaks the legacy construction is refused before admission. + const legacyWs = new WebSocket( buildRelayWebSocketUrl({ endpoint: `127.0.0.1:${relayPort}`, useTls: false, @@ -650,92 +846,188 @@ async function waitForCapturedLog( role: "client", }), ); - - let receivedServerInfo = false; - const isRelayDataConnectedLog = (line: string) => line.includes("relay_data_connected"); - const outcome = await new Promise< - { type: "closed"; code: number; reason: string } | { type: "errored"; error: Error } - >((resolve, reject) => { - const timeout = setTimeout(() => { - ws.close(); - reject(new Error("timed out waiting for daemon to reject unsupported handshake key")); - }, 20_000); - - ws.on("open", async () => { - try { - await waitForCapturedLog(lines, isRelayDataConnectedLog); - ws.send( - JSON.stringify({ - type: "e2ee_hello", - key: exportPublicKey(new Uint8Array(32)), - }), - ); - ws.send( - encodeCiphertext( - encrypt( - UNSUPPORTED_PEER_TRANSPORT_KEY, - JSON.stringify({ - type: "hello", - clientId: "cid_relay_unsupported_key", - clientType: "cli", - protocolVersion: 1, - }), - ), - ), - ); - } catch (error) { - clearTimeout(timeout); - reject(error); - } - }); - - ws.on("message", (data) => { - try { - const parsed = WSOutboundMessageSchema.parse( - parseEncryptedJson(UNSUPPORTED_PEER_TRANSPORT_KEY, data.toString()), - ); - if ( - parsed.type === "session" && - parsed.message.type === "status" && - parsed.message.payload?.status === "server_info" - ) { - receivedServerInfo = true; - clearTimeout(timeout); - reject( - new Error("daemon sent server_info after accepting an unsupported handshake key"), - ); - } - } catch { - // Plaintext e2ee_ready cannot be decrypted and is irrelevant to this assertion. - } - }); - ws.on("close", (code, reason) => { - clearTimeout(timeout); - resolve({ type: "closed", code, reason: reason.toString() }); - }); - ws.on("error", (error) => { - clearTimeout(timeout); - resolve({ type: "errored", error }); + const legacyTransport: Transport = { + send: (data) => legacyWs.send(data), + close: (code?: number, reason?: string) => legacyWs.close(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + legacyWs.on("message", (data, isBinary) => { + const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as Buffer); + legacyTransport.onmessage?.({ + data: isBinary + ? buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) + : buffer.toString("utf8"), + isBinary, }); }); + const legacyClosed = new Promise<{ code: number; reason: string }>((resolve) => { + legacyWs.on("close", (code, reason) => resolve({ code, reason: reason.toString() })); + }); + await once(legacyWs, "open"); + const legacyMessages: unknown[] = []; + void createClientChannel(legacyTransport, daemonPublicKeyB64, { + onmessage: (data) => legacyMessages.push(data), + }).catch(() => undefined); + const closed = await withTimeout(legacyClosed, 20_000, "legacy client close"); + // The daemon closes its side with 1008; the relay reports its own close code to + // the client (the wrangler relay sends 1012 "Server disconnected"), so only the + // daemon log carries the policy reason. + expect(closed.code).not.toBe(1000); + expect(legacyMessages).toEqual([]); + await waitForCapturedLog( + lines, + (line) => + line.includes("relay_e2ee_handshake_rejected") && + line.includes("legacy endpoint security"), + ); - expect(receivedServerInfo).toBe(false); - if (outcome.type === "closed") { - expect(outcome.code).toBeGreaterThan(0); - } else { - expect(outcome.error).toBeInstanceOf(Error); + // An updated client pinned by the offer is admitted in protected mode. + const client = await openUpdatedRelayClient({ + relayPort, + serverId, + daemonPublicKeyB64, + clientId: "cid_relay_protected_required", + minimumMode: offer.minimumSecurityMode, + }); + try { + expect(client.channel.securityMode()).toBe("protected"); + expect(client.serverInfo.features?.relayProtectedSecurity).toBe(true); + } finally { + client.close(); } - - const isRejectedHandshakeLog = (line: string) => - line.includes("relay_e2ee_handshake_failed") && line.includes("Invalid peer public key"); - await waitForCapturedLog(lines, isRejectedHandshakeLog); - expect(lines.some(isRejectedHandshakeLog)).toBe(true); + } catch (err) { + // eslint-disable-next-line no-console + console.error("daemon logs (tail):\n", lines.slice(-50).join("")); + throw err; } finally { await daemon.close(); await stopRelay(); } }, 90000); + // The Elixir relay rejects the invalid key itself, so the daemon never sees this hello. + test.skipIf(usesExternalRelay)( + "daemon closes a relay client that sends an unsupported handshake key", + async () => { + process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; + + const { logger, lines } = createCapturingLogger(); + await startRelay({ useLocalRelay: true }); + + const daemon = await createTestPaseoDaemon({ + listen: "127.0.0.1", + logger, + relayEnabled: true, + relayEndpoint: `127.0.0.1:${relayPort}`, + }); + + try { + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + appBaseUrl: daemon.config.appBaseUrl, + }); + const { serverId } = decodeOfferFromFragmentUrl(offerUrl); + const ws = new WebSocket( + buildRelayWebSocketUrl({ + endpoint: `127.0.0.1:${relayPort}`, + useTls: false, + serverId, + role: "client", + }), + ); + + let receivedServerInfo = false; + const isRelayDataConnectedLog = (line: string) => line.includes("relay_data_connected"); + const outcome = await new Promise< + { type: "closed"; code: number; reason: string } | { type: "errored"; error: Error } + >((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close(); + reject(new Error("timed out waiting for daemon to reject unsupported handshake key")); + }, 20_000); + + ws.on("open", async () => { + try { + await waitForCapturedLog(lines, isRelayDataConnectedLog); + ws.send( + JSON.stringify({ + type: "e2ee_hello", + key: exportPublicKey(new Uint8Array(32)), + }), + ); + ws.send( + encodeCiphertext( + encrypt( + UNSUPPORTED_PEER_TRANSPORT_KEY, + JSON.stringify({ + type: "hello", + clientId: "cid_relay_unsupported_key", + clientType: "cli", + protocolVersion: 1, + }), + ), + ), + ); + } catch (error) { + clearTimeout(timeout); + reject(error); + } + }); + + ws.on("message", (data) => { + try { + const parsed = WSOutboundMessageSchema.parse( + parseEncryptedJson(UNSUPPORTED_PEER_TRANSPORT_KEY, data.toString()), + ); + if ( + parsed.type === "session" && + parsed.message.type === "status" && + parsed.message.payload?.status === "server_info" + ) { + receivedServerInfo = true; + clearTimeout(timeout); + reject( + new Error("daemon sent server_info after accepting an unsupported handshake key"), + ); + } + } catch { + // Plaintext e2ee_ready cannot be decrypted and is irrelevant to this assertion. + } + }); + ws.on("close", (code, reason) => { + clearTimeout(timeout); + resolve({ type: "closed", code, reason: reason.toString() }); + }); + ws.on("error", (error) => { + clearTimeout(timeout); + resolve({ type: "errored", error }); + }); + }); + + expect(receivedServerInfo).toBe(false); + if (outcome.type === "closed") { + expect(outcome.code).toBeGreaterThan(0); + } else { + expect(outcome.error).toBeInstanceOf(Error); + } + + const isRejectedHandshakeLog = (line: string) => + line.includes("relay_e2ee_handshake_failed") && line.includes("Invalid peer public key"); + await waitForCapturedLog(lines, isRejectedHandshakeLog); + expect(lines.some(isRejectedHandshakeLog)).toBe(true); + } finally { + await daemon.close(); + await stopRelay(); + } + }, + 90000, + ); + test("daemon keeps relay socket open while idle (no handshake timeout loop)", async () => { process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; diff --git a/packages/server/src/server/pairing-offer.ts b/packages/server/src/server/pairing-offer.ts index c8f038e886..59b8c623c7 100644 --- a/packages/server/src/server/pairing-offer.ts +++ b/packages/server/src/server/pairing-offer.ts @@ -18,6 +18,8 @@ export async function generateLocalPairingOffer(args: { relayPublicEndpoint?: string; relayUseTls?: boolean; relayPublicUseTls?: boolean; + /** Stamp the offer so new clients require protected endpoint security from their first handshake. */ + relayRequireProtectedSecurity?: boolean; appBaseUrl?: string; includeQr?: boolean; logger?: Logger; @@ -42,6 +44,9 @@ export async function generateLocalPairingOffer(args: { serverId, daemonPublicKeyB64: daemonKeyPair.publicKeyB64, relay: { endpoint: relayPublicEndpoint, useTls: relayPublicUseTls }, + ...(args.relayRequireProtectedSecurity === true + ? { minimumSecurityMode: "protected" as const } + : {}), }); const url = encodeOfferToFragmentUrl({ offer, appBaseUrl }); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 11d94495b1..cccc0228d8 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -278,6 +278,12 @@ export const PersistedConfigSchema = z relay: z .object({ enabled: z.boolean().optional(), + requireProtectedSecurity: z + .boolean() + .optional() + .describe( + "Admit only relay clients that negotiate protected endpoint security (replay protection). Defaults to false; older clients still connect while it is off.", + ), endpoint: z.string().optional(), publicEndpoint: z.string().optional(), useTls: z.boolean().optional(), diff --git a/packages/server/src/server/relay-runtime.ts b/packages/server/src/server/relay-runtime.ts index e9f2ccf158..885aaa9b6e 100644 --- a/packages/server/src/server/relay-runtime.ts +++ b/packages/server/src/server/relay-runtime.ts @@ -1,5 +1,4 @@ import type pino from "pino"; -import type { KeyPair } from "@getpaseo/relay/e2ee"; import type { ExternalSocketMetadata } from "./websocket-server.js"; import { startRelayTransport, @@ -20,7 +19,6 @@ interface RelayRuntimeOptions { logger: pino.Logger; attachSocket(ws: RelaySocketLike, metadata?: ExternalSocketMetadata): Promise; serverId: string; - daemonKeyPair: KeyPair; startTransport?: typeof startRelayTransport; } @@ -43,7 +41,6 @@ export function createRelayRuntime(options: RelayRuntimeOptions): RelayRuntime { relayEndpoint: config.endpoint, relayUseTls: config.useTls, serverId: options.serverId, - daemonKeyPair: options.daemonKeyPair, }); } diff --git a/packages/server/src/server/relay-transport.test.ts b/packages/server/src/server/relay-transport.test.ts index 2dcf29b948..26868e7112 100644 --- a/packages/server/src/server/relay-transport.test.ts +++ b/packages/server/src/server/relay-transport.test.ts @@ -1,7 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import pino from "pino"; -import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; -import { exportPublicKey, generateKeyPair } from "@getpaseo/relay"; import { startRelayTransport } from "./relay-transport"; function createMockLogger() { @@ -146,37 +144,6 @@ describe("relay-transport control lifecycle", () => { vi.useRealTimers(); }); - test("releases a stalled encrypted data connection without restarting control", async () => { - vi.useFakeTimers(); - let attachments = 0; - controllers.push( - startRelayTransport({ - logger: pino({ level: "silent" }), - attachSocket: async () => { - attachments += 1; - }, - relayEndpoint: "relay.paseo.sh:443", - relayUseTls: true, - serverId: "srv_test", - daemonKeyPair: generateKeyPair(), - createWebSocket: relay.createWebSocket, - }), - ); - const control = relay.sockets[0]; - control.open(); - const connected = JSON.stringify({ type: "connected", connectionId: "stalled" }); - control.message(connected); - const stalled = relay.sockets[1]; - stalled.open(); - await vi.advanceTimersByTimeAsync(15_000); - expect(stalled.terminateCalls).toBe(1); - expect(attachments).toBe(0); - expect(control.terminateCalls).toBe(0); - control.message(connected); - expect(relay.sockets).toHaveLength(3); - expect(relay.sockets[2].url).toBe(stalled.url); - }); - test("logs relay_control_connected only after first valid control message", () => { const logger = createMockLogger(); const controller = startRelayTransport({ @@ -286,80 +253,6 @@ describe("relay-transport control lifecycle", () => { ]); }); - test("encrypted sends wait for the physical data socket callback", async () => { - const logger = createMockLogger(); - const daemonKeyPair = generateKeyPair(); - let resolveAttached: ((socket: unknown) => void) | undefined; - const attached = new Promise((resolve) => { - resolveAttached = resolve; - }); - const controller = startRelayTransport({ - logger: logger as unknown as pino.Logger, - attachSocket: async (socket) => resolveAttached?.(socket), - relayEndpoint: "relay.paseo.sh:443", - relayUseTls: true, - serverId: "srv_test", - daemonKeyPair, - createWebSocket: relay.createWebSocket, - }); - controllers.push(controller); - - const control = relay.sockets[0]; - control.open(); - control.message(JSON.stringify({ type: "sync", connectionIds: [] }), false); - control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" }), false); - - const dataSocket = relay.sockets[1]; - dataSocket.deferSendCompletion = true; - dataSocket.open(); - let clientTransport: Transport; - clientTransport = { - send: (data) => dataSocket.message(data, data instanceof ArrayBuffer), - close: () => undefined, - onmessage: null, - onclose: null, - onerror: null, - }; - dataSocket.onSend = (data) => { - clientTransport.onmessage?.({ - data: data instanceof Uint8Array ? data.slice().buffer : data, - isBinary: data instanceof ArrayBuffer || data instanceof Uint8Array, - }); - }; - let resolveClientOpen: (() => void) | undefined; - const clientOpen = new Promise((resolve) => { - resolveClientOpen = resolve; - }); - await createClientChannel(clientTransport, exportPublicKey(daemonKeyPair.publicKey), { - onopen: () => resolveClientOpen?.(), - }); - - let attachedCompleted = false; - void attached.then(() => { - attachedCompleted = true; - return undefined; - }); - await clientOpen; - await Promise.resolve(); - expect(attachedCompleted).toBe(false); - dataSocket.completeNextSend(); - const encryptedSocket = (await attached) as { - send: (data: Uint8Array) => void | Promise; - }; - let completed = false; - - const sending = Promise.resolve(encryptedSocket.send(new Uint8Array([1, 2, 3]))).then(() => { - completed = true; - return undefined; - }); - await Promise.resolve(); - expect(completed).toBe(false); - - dataSocket.completeNextSend(); - await sending; - expect(completed).toBe(true); - }); - test("uses relayUseTls for control and data socket URLs", () => { const logger = createMockLogger(); const controller = startRelayTransport({ diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 113cce605c..08fcd826d5 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -1,15 +1,8 @@ /// -import { EventEmitter } from "node:events"; import { WebSocket } from "ws"; import type pino from "pino"; -import { - createDaemonChannel, - type Transport as RelayTransport, - type KeyPair, -} from "@getpaseo/relay/e2ee"; import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; import type { ExternalSocketMetadata } from "./websocket-server.js"; -import { createEncryptedRelaySocket } from "./websocket/encrypted-relay-socket.js"; export interface RelayTransportOptions { logger: pino.Logger; @@ -17,7 +10,6 @@ export interface RelayTransportOptions { relayEndpoint: string; // "host:port" relayUseTls: boolean; serverId: string; - daemonKeyPair?: KeyPair; createWebSocket?: RelayWebSocketFactory; } @@ -30,13 +22,12 @@ export interface RelaySocketLike { bufferedAmount?: number; send: (data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => void; close: (code?: number, reason?: string) => void; - terminate?: () => void; + terminate: () => void; on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void; once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void; } interface RelayWebSocketLike extends RelaySocketLike { - terminate: () => void; ping: () => void; on: ( event: "open" | "message" | "close" | "error" | "pong", @@ -118,7 +109,6 @@ export function startRelayTransport({ relayEndpoint, relayUseTls, serverId, - daemonKeyPair, createWebSocket = createDefaultRelayWebSocket, }: RelayTransportOptions): RelayTransportController { const relayLogger = logger.child({ module: "relay-transport" }); @@ -385,17 +375,7 @@ export function startRelayTransport({ externalSessionKey: `session:${connectionId}`, relayConnectionId: connectionId, }; - if (daemonKeyPair) { - void attachEncryptedSocket( - socket, - daemonKeyPair, - relayLogger.child({ connectionId }), - attachSocket, - externalMetadata, - ); - } else { - void attachSocket(socket, externalMetadata); - } + void attachSocket(socket, externalMetadata); }); socket.on("close", (code, reason) => { @@ -418,149 +398,3 @@ export function startRelayTransport({ return { stop }; } - -async function attachEncryptedSocket( - socket: RelayWebSocketLike, - daemonKeyPair: KeyPair, - logger: pino.Logger, - attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise, - metadata?: ExternalSocketMetadata, -): Promise { - const handshakeTimeout = setTimeout(() => { - logger.warn("relay_e2ee_handshake_timeout_terminating"); - socket.terminate(); - }, 15_000); - try { - const relayTransport = createRelayTransportAdapter(socket, logger); - const emitter = new EventEmitter(); - const pendingMessages: Array = []; - let attached = false; - const emitMessage = (data: string | ArrayBuffer) => { - if (attached) { - emitter.emit("message", data); - return; - } - pendingMessages.push(data); - }; - const channel = await createDaemonChannel(relayTransport, daemonKeyPair, { - onmessage: emitMessage, - onclose: (code, reason) => emitter.emit("close", code, reason), - onerror: (error) => { - logger.warn({ err: error }, "relay_e2ee_error"); - emitter.emit("error", error); - }, - }); - clearTimeout(handshakeTimeout); - const encryptedSocket = createEncryptedRelaySocket({ - channel, - emitter, - getTransportBufferedAmount: () => socket.bufferedAmount, - terminateTransport: () => socket.terminate(), - }); - await attachSocket(encryptedSocket, metadata); - attached = true; - for (const message of pendingMessages) { - emitter.emit("message", message); - } - pendingMessages.length = 0; - } catch (error) { - logger.warn({ err: error }, "relay_e2ee_handshake_failed"); - try { - socket.close(1011, "E2EE handshake failed"); - } catch { - // ignore - } - } finally { - clearTimeout(handshakeTimeout); - } -} - -function createRelayTransportAdapter( - socket: RelayWebSocketLike, - logger: pino.Logger, -): RelayTransport { - const relayTransport: RelayTransport = { - send: (data) => - new Promise((resolve, reject) => { - try { - socket.send(data, (error) => { - if (!error) { - resolve(); - return; - } - logger.warn({ err: error }, "relay_socket_send_failed"); - reject(error); - }); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - logger.warn({ err }, "relay_socket_send_failed"); - reject(err); - } - }), - close: (code?: number, reason?: string) => socket.close(code, reason), - onmessage: null, - onclose: null, - onerror: null, - }; - - socket.on("message", (data, isBinary) => { - const binary = isBinary === true; - relayTransport.onmessage?.({ data: normalizeMessageData(data, binary), isBinary: binary }); - }); - socket.on("close", (code, reason) => { - const closeCode = typeof code === "number" ? code : 1006; - relayTransport.onclose?.(closeCode, String(reason ?? "")); - }); - socket.on("error", (err) => { - relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err))); - }); - - return relayTransport; -} - -function normalizeMessageData(data: unknown, isBinary: boolean): string | ArrayBuffer { - if (!isBinary) { - if (typeof data === "string") return data; - const buffer = bufferFromWsData(data); - if (buffer) return buffer.toString("utf8"); - return String(data); - } - - if (data instanceof ArrayBuffer) return data; - - const buffer = bufferFromWsData(data); - if (buffer) { - const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); - const out = new Uint8Array(view.byteLength); - out.set(view); - return out.buffer; - } - - return String(data); -} - -function bufferFromWsData(data: unknown): Buffer | null { - if (Buffer.isBuffer(data)) return data; - if (Array.isArray(data)) { - const buffers: Buffer[] = []; - for (const part of data) { - if (Buffer.isBuffer(part)) { - buffers.push(part); - } else if (part instanceof ArrayBuffer) { - buffers.push(Buffer.from(part)); - } else if (ArrayBuffer.isView(part)) { - buffers.push(Buffer.from(part.buffer, part.byteOffset, part.byteLength)); - } else if (typeof part === "string") { - buffers.push(Buffer.from(part, "utf8")); - } else { - return null; - } - } - return Buffer.concat(buffers); - } - if (data instanceof ArrayBuffer) return Buffer.from(data); - if (ArrayBuffer.isView(data)) { - return Buffer.from(data.buffer, data.byteOffset, data.byteLength); - } - return null; -} diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts index 95caafc58b..fbe1a06415 100644 --- a/packages/server/src/server/session/daemon/daemon-session.ts +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -24,6 +24,7 @@ export interface DaemonRuntimeConfig { publicEndpoint: string; useTls: boolean; publicUseTls: boolean; + requireProtectedSecurity?: boolean; } | null; } @@ -224,6 +225,7 @@ export class DaemonSession { relayPublicEndpoint: relay?.publicEndpoint, relayUseTls: relay?.useTls, relayPublicUseTls: relay?.publicUseTls, + relayRequireProtectedSecurity: relay?.requireProtectedSecurity ?? false, appBaseUrl: this.daemonRuntimeConfig?.appBaseUrl, includeQr: true, logger: this.logger, diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 91b125328c..289d556bec 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -30,6 +30,7 @@ interface TestPaseoDaemonOptions { relayEndpoint?: string; relayUseTls?: boolean; relayPublicUseTls?: boolean; + relayRequireProtectedSecurity?: boolean; daemonStatusRpcCapability?: boolean; relayConfigCapability?: boolean; agentClients?: Partial>; @@ -200,6 +201,7 @@ async function prepareTestDaemonConfig( relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443", relayUseTls: options.relayUseTls, relayPublicUseTls: options.relayPublicUseTls, + relayRequireProtectedSecurity: options.relayRequireProtectedSecurity, appBaseUrl: "https://app.paseo.sh", auth: options.auth, pushNotificationSender: options.pushNotificationSender, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 8a28f25d5a..d896bcf37d 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -112,6 +112,7 @@ import { } from "./lifecycle-reasons.js"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; +import type { SecurityMode } from "@getpaseo/relay/e2ee"; import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js"; import { DirectorySyncService } from "./directory-sync/index.js"; @@ -133,6 +134,8 @@ export interface ExternalSocketMetadata { transport: "relay" | "hub"; externalSessionKey?: string; relayConnectionId?: string; + /** Negotiated endpoint security for relay-carried sockets. */ + securityMode?: SecurityMode; hubDaemonId?: string; } @@ -160,6 +163,7 @@ interface WebSocketConnectionIdentity { userAgent?: string; remoteAddress?: string; relayConnectionId?: string; + securityMode?: SecurityMode; hubDaemonId?: string; clientId?: string; sessionId?: string; @@ -1943,6 +1947,8 @@ export class VoiceAssistantWebSocketServer { features: { connectedHosts: true, hostMultiplexing: true, + // COMPAT(relayProtectedSecurity): added in v0.13.0, remove gate after 2027-04-21. + relayProtectedSecurity: true, usageSources: true, ownedSubscriptions: true, agentRequestReceipts: true, @@ -2984,6 +2990,7 @@ function createWebSocketConnectionIdentity( ...(requestMetadata.userAgent ? { userAgent: requestMetadata.userAgent } : {}), ...(requestMetadata.remoteAddress ? { remoteAddress: requestMetadata.remoteAddress } : {}), ...(metadata?.relayConnectionId ? { relayConnectionId: metadata.relayConnectionId } : {}), + ...(metadata?.securityMode ? { securityMode: metadata.securityMode } : {}), ...(metadata?.hubDaemonId ? { hubDaemonId: metadata.hubDaemonId } : {}), }; } @@ -3002,6 +3009,7 @@ function toConnectionLogFields(identity: WebSocketConnectionIdentity): Record = []; private resolveSend: (() => void) | null = null; - setState(state: "open"): void { - expect(state).toBe("open"); - } - send(data: string | ArrayBuffer): Promise { this.sent.push(data); return new Promise((resolve) => { diff --git a/packages/server/src/server/websocket/encrypted-relay-socket.ts b/packages/server/src/server/websocket/encrypted-relay-socket.ts index 3b76a49711..f11162761c 100644 --- a/packages/server/src/server/websocket/encrypted-relay-socket.ts +++ b/packages/server/src/server/websocket/encrypted-relay-socket.ts @@ -2,7 +2,6 @@ import { EventEmitter } from "node:events"; import { MAX_PHYSICAL_SOCKET_BUFFERED_BYTES } from "./physical-socket.js"; export interface EncryptedRelayChannel { - setState: (state: "open") => void; send: (data: string | ArrayBuffer) => Promise; outboundWireByteLength: (data: string | ArrayBuffer) => number; close: (code?: number, reason?: string) => void; @@ -27,8 +26,6 @@ export function createEncryptedRelaySocket(params: { const { channel, emitter, getTransportBufferedAmount, terminateTransport } = params; let readyState = 1; - channel.setState("open"); - const terminate = () => { if (readyState === 3) return; readyState = 3; diff --git a/packages/server/src/server/websocket/secure-relay-ingress.test.ts b/packages/server/src/server/websocket/secure-relay-ingress.test.ts new file mode 100644 index 0000000000..d6bae93889 --- /dev/null +++ b/packages/server/src/server/websocket/secure-relay-ingress.test.ts @@ -0,0 +1,272 @@ +import { afterEach, expect, test, vi } from "vitest"; +import pino from "pino"; +import { + createClientChannel, + exportPublicKey, + generateKeyPair, + openClientSecureChannel, +} from "@getpaseo/relay/e2ee"; +import type { Transport } from "@getpaseo/relay/e2ee"; +import { admitSecuredRelaySocket, type SecuredRelaySocket } from "./secure-relay-ingress.js"; + +type Listener = (...args: unknown[]) => void; + +class FakeIngressSocket { + readyState = 1; + bufferedAmount = 0; + terminateCalls = 0; + closes: Array<{ code?: number; reason?: string }> = []; + deferSendCompletion = false; + onSend: ((data: string | Uint8Array | ArrayBuffer) => void) | null = null; + private pendingSendCallbacks: Array<(error?: Error) => void> = []; + private listeners = new Map(); + + on(event: "message" | "close" | "error", listener: Listener) { + this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener]); + } + + send(data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) { + this.onSend?.(data); + if (!callback) return; + if (this.deferSendCompletion) { + this.pendingSendCallbacks.push(callback); + return; + } + callback(); + } + + completeNextSend() { + this.pendingSendCallbacks.shift()?.(); + } + + close(code?: number, reason?: string) { + this.closes.push({ code, reason }); + this.readyState = 3; + } + + terminate() { + this.terminateCalls += 1; + this.readyState = 3; + this.emit("close", 1006, ""); + } + + message(data: string | ArrayBuffer) { + this.emit("message", data, data instanceof ArrayBuffer); + } + + private emit(event: string, ...args: unknown[]) { + for (const listener of this.listeners.get(event) ?? []) listener(...args); + } +} + +const logger = pino({ level: "silent" }); +const allowLegacy = { requireProtectedMode: () => false }; + +/** Wires a client secure channel to the fake socket so both ends talk directly. */ +async function openClientAgainst( + socket: FakeIngressSocket, + daemonPublicKeyB64: string, + events: { onopen?: () => void; onmessage?: (data: string | ArrayBuffer) => void } = {}, +) { + const clientTransport: Transport = { + send: (data) => socket.message(data), + close: () => undefined, + onmessage: null, + onclose: null, + onerror: null, + }; + socket.onSend = (data) => { + clientTransport.onmessage?.({ + data: data instanceof Uint8Array ? data.slice().buffer : data, + isBinary: data instanceof ArrayBuffer || data instanceof Uint8Array, + }); + }; + return openClientSecureChannel(clientTransport, { daemonPublicKeyB64, events }); +} + +/** An older client that only speaks the legacy construction. */ +async function openLegacyClientAgainst(socket: FakeIngressSocket, daemonPublicKeyB64: string) { + const clientTransport: Transport = { + send: (data) => socket.message(data), + close: () => undefined, + onmessage: null, + onclose: null, + onerror: null, + }; + socket.onSend = (data) => { + clientTransport.onmessage?.({ + data: data instanceof Uint8Array ? data.slice().buffer : data, + isBinary: data instanceof ArrayBuffer || data instanceof Uint8Array, + }); + }; + return createClientChannel(clientTransport, daemonPublicKeyB64); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +test("terminates a relay socket whose peer never completes the handshake", async () => { + vi.useFakeTimers(); + const socket = new FakeIngressSocket(); + let attachments = 0; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair: generateKeyPair(), logger, ...allowLegacy }, + async () => { + attachments += 1; + }, + ); + await vi.advanceTimersByTimeAsync(15_000); + await admitted; + expect(socket.terminateCalls).toBe(1); + expect(attachments).toBe(0); +}); + +test("admits the secured socket with its negotiated security mode", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + let secured: SecuredRelaySocket | null = null; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair, logger, ...allowLegacy }, + async (next) => { + secured = next; + }, + ); + await openClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey)); + await admitted; + expect(secured).not.toBeNull(); + expect(secured!.securityMode).toBe("protected"); + expect(secured!.socket.readyState).toBe(1); +}); + +test("admits a legacy client while protected mode is not required", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + let secured: SecuredRelaySocket | null = null; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair, logger, ...allowLegacy }, + async (next) => { + secured = next; + }, + ); + await openLegacyClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey)); + await admitted; + expect(secured!.securityMode).toBe("legacy"); +}); + +test("rejects a legacy client with a policy close when protected mode is required", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + let attachments = 0; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair, logger, requireProtectedMode: () => true }, + async () => { + attachments += 1; + }, + ); + await openLegacyClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey)); + await admitted; + expect(attachments).toBe(0); + expect(socket.closes).toEqual([{ code: 1008, reason: "protected endpoint security required" }]); +}); + +test("reads the policy when the hello arrives, not when the socket was accepted", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + let required = false; + let attachments = 0; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair, logger, requireProtectedMode: () => required }, + async () => { + attachments += 1; + }, + ); + required = true; + await openLegacyClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey)); + await admitted; + expect(attachments).toBe(0); + expect(socket.closes[0]?.code).toBe(1008); +}); + +test("delivers records received before admission completes, in order, after attach", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + let releaseAttach: (() => void) | undefined; + const attachGate = new Promise((resolve) => { + releaseAttach = resolve; + }); + const received: unknown[] = []; + let secured: SecuredRelaySocket | null = null; + const admitted = admitSecuredRelaySocket( + socket, + { daemonKeyPair, logger, ...allowLegacy }, + async (next) => { + secured = next; + next.socket.on("message", (data) => received.push(data)); + await attachGate; + }, + ); + let clientOpen: () => void = () => undefined; + const opened = new Promise((resolve) => { + clientOpen = resolve; + }); + const client = await openClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey), { + onopen: () => clientOpen(), + }); + await opened; + await vi.waitFor(() => expect(secured).not.toBeNull()); + await client.send("first"); + await client.send("second"); + expect(received).toEqual([]); + releaseAttach?.(); + await admitted; + expect(received).toEqual(["first", "second"]); +}); + +test("encrypted sends wait for the physical socket send callback", async () => { + const socket = new FakeIngressSocket(); + const daemonKeyPair = generateKeyPair(); + socket.deferSendCompletion = true; + let resolveAttached: ((secured: SecuredRelaySocket) => void) | undefined; + const attached = new Promise((resolve) => { + resolveAttached = resolve; + }); + void admitSecuredRelaySocket(socket, { daemonKeyPair, logger, ...allowLegacy }, async (next) => + resolveAttached?.(next), + ); + let resolveClientOpen: (() => void) | undefined; + const clientOpen = new Promise((resolve) => { + resolveClientOpen = resolve; + }); + await openClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey), { + onopen: () => resolveClientOpen?.(), + }); + + let attachedCompleted = false; + void attached.then(() => { + attachedCompleted = true; + return undefined; + }); + await clientOpen; + await Promise.resolve(); + expect(attachedCompleted).toBe(false); + socket.completeNextSend(); + const { socket: encryptedSocket } = await attached; + + let completed = false; + const sending = Promise.resolve(encryptedSocket.send(new Uint8Array([1, 2, 3]))).then(() => { + completed = true; + return undefined; + }); + await Promise.resolve(); + expect(completed).toBe(false); + + socket.completeNextSend(); + await sending; + expect(completed).toBe(true); +}); diff --git a/packages/server/src/server/websocket/secure-relay-ingress.ts b/packages/server/src/server/websocket/secure-relay-ingress.ts new file mode 100644 index 0000000000..013e3e8e03 --- /dev/null +++ b/packages/server/src/server/websocket/secure-relay-ingress.ts @@ -0,0 +1,210 @@ +/// +/** + * Endpoint security boundary for relay-carried daemon sessions. + * + * A relay data socket arrives here as plaintext bytes. This module runs the + * shared secure channel over it and hands the secured socket to session + * admission, together with the negotiated security mode. Relay routing lives in + * `relay-transport.ts`; credentials and authorization live in admission. + */ +import { EventEmitter } from "node:events"; +import type pino from "pino"; +import { + openDaemonSecureChannel, + SecureChannelRejectedError, + type KeyPair, + type SecurityMode, + type Transport as RelayTransport, +} from "@getpaseo/relay/e2ee"; +import { createEncryptedRelaySocket, type EncryptedRelaySocket } from "./encrypted-relay-socket.js"; + +const HANDSHAKE_TIMEOUT_MS = 15_000; + +export interface RelayIngressSocket { + readyState: number; + bufferedAmount?: number; + send: (data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => void; + close: (code?: number, reason?: string) => void; + terminate: () => void; + on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void; +} + +export interface SecuredRelaySocket { + socket: EncryptedRelaySocket; + securityMode: SecurityMode; +} + +export interface SecureRelayIngressOptions { + daemonKeyPair: KeyPair; + logger: pino.Logger; + /** + * Current policy, read when the peer's hello arrives so a flag flipped at + * runtime applies to every admission from that moment on. + */ + requireProtectedMode: () => boolean; +} + +/** + * Secures `socket` and admits it through `attach`. Messages the peer sends + * before `attach` resolves are delivered once it has. + * + * A peer that never completes the handshake is terminated after + * {@link HANDSHAKE_TIMEOUT_MS}; a failed handshake closes the socket. + */ +export async function admitSecuredRelaySocket( + socket: RelayIngressSocket, + options: SecureRelayIngressOptions, + attach: (secured: SecuredRelaySocket) => Promise, +): Promise { + const { logger } = options; + const handshakeTimeout = setTimeout(() => { + logger.warn("relay_e2ee_handshake_timeout_terminating"); + socket.terminate(); + }, HANDSHAKE_TIMEOUT_MS); + try { + const relayTransport = createRelayTransportAdapter(socket, logger); + const emitter = new EventEmitter(); + const pendingMessages: Array = []; + let attached = false; + const emitMessage = (data: string | ArrayBuffer) => { + if (attached) { + emitter.emit("message", data); + return; + } + pendingMessages.push(data); + }; + const channel = await openDaemonSecureChannel(relayTransport, { + daemonKeyPair: options.daemonKeyPair, + minimumMode: () => (options.requireProtectedMode() ? "protected" : "legacy"), + events: { + onmessage: emitMessage, + onclose: (code, reason) => emitter.emit("close", code, reason), + onerror: (error) => { + logger.warn({ err: error }, "relay_e2ee_error"); + emitter.emit("error", error); + }, + }, + }); + clearTimeout(handshakeTimeout); + const encryptedSocket = createEncryptedRelaySocket({ + channel, + emitter, + getTransportBufferedAmount: () => socket.bufferedAmount, + terminateTransport: () => socket.terminate(), + }); + await attach({ socket: encryptedSocket, securityMode: channel.securityMode() }); + attached = true; + for (const message of pendingMessages) { + emitter.emit("message", message); + } + pendingMessages.length = 0; + } catch (error) { + if (error instanceof SecureChannelRejectedError) { + logger.warn({ reason: error.message }, "relay_e2ee_handshake_rejected"); + try { + socket.close(error.closeCode, error.closeReason); + } catch { + // ignore + } + return; + } + logger.warn({ err: error }, "relay_e2ee_handshake_failed"); + try { + socket.close(1011, "E2EE handshake failed"); + } catch { + // ignore + } + } finally { + clearTimeout(handshakeTimeout); + } +} + +function createRelayTransportAdapter( + socket: RelayIngressSocket, + logger: pino.Logger, +): RelayTransport { + const relayTransport: RelayTransport = { + send: (data) => + new Promise((resolve, reject) => { + try { + socket.send(data, (error) => { + if (!error) { + resolve(); + return; + } + logger.warn({ err: error }, "relay_socket_send_failed"); + reject(error); + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logger.warn({ err }, "relay_socket_send_failed"); + reject(err); + } + }), + close: (code?: number, reason?: string) => socket.close(code, reason), + onmessage: null, + onclose: null, + onerror: null, + }; + + socket.on("message", (data, isBinary) => { + const binary = isBinary === true; + relayTransport.onmessage?.({ data: normalizeMessageData(data, binary), isBinary: binary }); + }); + socket.on("close", (code, reason) => { + const closeCode = typeof code === "number" ? code : 1006; + relayTransport.onclose?.(closeCode, String(reason ?? "")); + }); + socket.on("error", (err) => { + relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err))); + }); + + return relayTransport; +} + +function normalizeMessageData(data: unknown, isBinary: boolean): string | ArrayBuffer { + if (!isBinary) { + if (typeof data === "string") return data; + const buffer = bufferFromWsData(data); + if (buffer) return buffer.toString("utf8"); + return String(data); + } + + if (data instanceof ArrayBuffer) return data; + + const buffer = bufferFromWsData(data); + if (buffer) { + const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const out = new Uint8Array(view.byteLength); + out.set(view); + return out.buffer; + } + + return String(data); +} + +function bufferFromWsData(data: unknown): Buffer | null { + if (Buffer.isBuffer(data)) return data; + if (Array.isArray(data)) { + const buffers: Buffer[] = []; + for (const part of data) { + if (Buffer.isBuffer(part)) { + buffers.push(part); + } else if (part instanceof ArrayBuffer) { + buffers.push(Buffer.from(part)); + } else if (ArrayBuffer.isView(part)) { + buffers.push(Buffer.from(part.buffer, part.byteOffset, part.byteLength)); + } else if (typeof part === "string") { + buffers.push(Buffer.from(part, "utf8")); + } else { + return null; + } + } + return Buffer.concat(buffers); + } + if (data instanceof ArrayBuffer) return Buffer.from(data); + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + return null; +} diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index ff13dc2ad8..3cc5c3dace 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -14,7 +14,7 @@ import { join } from "path"; import { win32 } from "node:path"; import { tmpdir } from "os"; import pino from "pino"; -import { base64EncryptedWireByteLength } from "@getpaseo/relay"; +import { base64EncryptedWireByteLength, maxSecuredPlaintextByteLength } from "@getpaseo/relay"; import { __resetCheckoutShortstatCacheForTests, __resetPullRequestStatusCacheForTests, @@ -1654,9 +1654,14 @@ const x = 1; CHECKOUT_DIFF_MAX_STRUCTURED_BYTES + frameEnvelopeHeadroomBytes, ); - expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBe(24_117_208); + // The cap is sized for the tighter of the legacy and protected relay framings. + expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBe( + maxSecuredPlaintextByteLength(relayFrameBytes) - frameEnvelopeHeadroomBytes, + ); + expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBeLessThanOrEqual(24_117_208); + expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBeGreaterThan(24_000_000); expect(diffWireBytes).toBeLessThan(relayFrameBytes); - expect(maximumFrameWireBytes).toBe(relayFrameBytes); + expect(maximumFrameWireBytes).toBeLessThanOrEqual(relayFrameBytes); }); it("marks tracked generated one-line diffs as too_large by content size", async () => { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index f2001e4651..fbbb494b4a 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -7,7 +7,7 @@ import { setImmediate } from "node:timers/promises"; import { TTLCache } from "@isaacs/ttlcache"; import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages"; import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; -import { maxBase64EncryptedPlaintextByteLength } from "@getpaseo/relay"; +import { maxSecuredPlaintextByteLength } from "@getpaseo/relay"; import type { Logger } from "pino"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; import { @@ -2162,7 +2162,7 @@ const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024; // binding: string frames are encrypted and base64-encoded. Reserve 1 MiB plaintext for // the surrounding WebSocket JSON envelope after inverting that exact wire expansion. export const CHECKOUT_DIFF_MAX_STRUCTURED_BYTES = - maxBase64EncryptedPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) - + maxSecuredPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) - CHECKOUT_DIFF_FRAME_HEADROOM_BYTES; interface StructuredDiffAccumulator { diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 0ba1cdbc7f..7e34e35e5c 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -194,6 +194,10 @@ "enabled": { "type": "boolean" }, + "requireProtectedSecurity": { + "description": "Admit only relay clients that negotiate protected endpoint security (replay protection). Defaults to false; older clients still connect while it is off.", + "type": "boolean" + }, "endpoint": { "type": "string" },