feat: add private relay for end-to-end-encrypted remote access (#2087)
Adds OpenChamber Relay — an opt-in way to reach an instance from a phone, browser, or another desktop from anywhere, with no open inbound ports, no tunnel, and no shared LAN. The instance dials outbound to a relay; all app traffic (HTTP, the event stream, terminal, dictation) is multiplexed and encrypted through a single connection per client, so the relay only ever forwards opaque ciphertext. Transport - End-to-end-encrypted channel over WebCrypto (ECDH P-256 -> HKDF -> AES-256-GCM) with a capability-negotiated handshake and a small HTTP/SSE/WebSocket multiplexing protocol. A byte-compatible JS host mirror is cross-checked by tests. - Host: outbound connection manager, per-client tunnel dispatcher to the local server over loopback, reuse of the existing instance identity key, and management routes. Disabled by default; explicit opt-in. - Client: plugs into the existing runtime layer (runtime-fetch/-url/-switch/ -auth, event pipeline, terminal, dictation) so features work over the relay unchanged; direct-URL and Electron realtime-proxy paths are untouched. Pairing & UX - Relay section in Settings -> Remote Instances (live status, QR/link pairing, revocation via the existing client-token list) and the mobile connect flow. - Frame batching and idle-gated keepalive keep tunnel message volume low without affecting streaming smoothness. Security - The tunnel is transport only; the server authenticates every tunneled request exactly as for a direct remote client. fragments only. The relay stores no keys, tokens, or payloads. Operability - The endpoint can be pinned to a self-hosted rel paired clients inherit it from the offer automatically. - Relay module DOCUMENTATION.md and a relay-trans invariants that future WebSocket/streaming changes must follow. The relay transport is complete and tested; the UI for enabling and pairing is gated behind openchamber_relay_gate and stays
This commit is contained in:
committed by
GitHub
parent
42e470cefa
commit
859b4529da
@@ -89,6 +89,7 @@ import { createProjectConfigRuntime } from './lib/projects/project-config.js';
|
||||
import { createRemoteClientAuthRuntime } from './lib/client-auth/remote-clients.js';
|
||||
import { createPreviewProxyRuntime } from './lib/preview/proxy-runtime.js';
|
||||
import { attachRealtimeProxy } from './lib/realtime-proxy.js';
|
||||
import { createRelayService } from './lib/relay/service.js';
|
||||
import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware';
|
||||
import webPush from 'web-push';
|
||||
|
||||
@@ -1286,6 +1287,19 @@ async function main(options = {}) {
|
||||
const tunnelRuntimeContext = tunnelWiringRuntime.initialize(app, port);
|
||||
const { tunnelService, startTunnelWithNormalizedRequest } = tunnelRuntimeContext;
|
||||
|
||||
// Private relay host service: config + management routes + host client
|
||||
// lifecycle. Loopback port comes from the same source the tunnel uses so
|
||||
// relay-tunneled requests hit the local Express app on 127.0.0.1.
|
||||
const relayService = createRelayService({
|
||||
crypto,
|
||||
os,
|
||||
readSettingsFromDiskMigrated,
|
||||
writeSettingsToDisk,
|
||||
remoteClientAuthRuntime,
|
||||
getLocalPort: () => tunnelRuntimeContext.getActivePort(),
|
||||
});
|
||||
relayService.registerRoutes(app);
|
||||
|
||||
await featureRoutesRuntime.registerRoutes(app, {
|
||||
crypto,
|
||||
fs,
|
||||
@@ -1395,6 +1409,9 @@ async function main(options = {}) {
|
||||
console.warn('[ScheduledTasks] Failed to start runtime:', error?.message || error);
|
||||
}
|
||||
|
||||
// Only opens a relay control socket when the user opted in (config enabled).
|
||||
void relayService.startIfEnabled();
|
||||
|
||||
return {
|
||||
expressApp: app,
|
||||
httpServer: server,
|
||||
@@ -1425,6 +1442,11 @@ async function main(options = {}) {
|
||||
},
|
||||
stop: (shutdownOptions = {}) => {
|
||||
realtimeProxyRuntime.stop();
|
||||
try {
|
||||
relayService.stop();
|
||||
} catch {
|
||||
// best-effort teardown of the relay host client
|
||||
}
|
||||
try {
|
||||
dictationRuntime?.stop?.();
|
||||
} catch {
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
// Wired into the same trigger fanout as web push (see runtime.js); the relay carries only
|
||||
// generic, model-based text (no session content) — see APNS.md.
|
||||
|
||||
import {
|
||||
getOrCreateRelaySigningKeypair,
|
||||
signRelayMessage as signRelayMessageShared,
|
||||
} from '../relay/signing-key.js';
|
||||
|
||||
const APNS_TOKENS_VERSION = 1;
|
||||
const APNS_HOST_PRODUCTION = 'https://api.push.apple.com';
|
||||
const APNS_HOST_SANDBOX = 'https://api.sandbox.push.apple.com';
|
||||
@@ -51,27 +56,15 @@ export const createApnsRuntime = (deps) => {
|
||||
// device token alone can't be used to push. Zero-config: the keypair generates on first use.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Key access lives in lib/relay/signing-key.js now (shared with the private
|
||||
// relay identity — same keypair, same storage, same serverId derivation).
|
||||
const getOrCreateRelayKeypair = async () => {
|
||||
if (cachedRelayKey) return cachedRelayKey;
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const existing = settings?.relaySigningKey;
|
||||
if (existing && existing.privateJwk && existing.publicJwk) {
|
||||
cachedRelayKey = {
|
||||
privateKey: crypto.createPrivateKey({ key: existing.privateJwk, format: 'jwk' }),
|
||||
publicJwk: existing.publicJwk,
|
||||
};
|
||||
return cachedRelayKey;
|
||||
}
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const privateJwk = privateKey.export({ format: 'jwk' });
|
||||
const publicJwk = publicKey.export({ format: 'jwk' });
|
||||
await writeSettingsToDisk({ ...settings, relaySigningKey: { privateJwk, publicJwk } });
|
||||
cachedRelayKey = { privateKey, publicJwk };
|
||||
cachedRelayKey = await getOrCreateRelaySigningKeypair({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk });
|
||||
return cachedRelayKey;
|
||||
};
|
||||
|
||||
const signRelayMessage = (privateKey, message) =>
|
||||
crypto.sign('SHA256', Buffer.from(message), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url');
|
||||
const signRelayMessage = (privateKey, message) => signRelayMessageShared({ crypto }, privateKey, message);
|
||||
|
||||
// Trim to the 4 fields the relay's schema accepts (and that feed the serverId hash).
|
||||
const relayPublicJwk = (publicJwk) => ({
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Relay Module Documentation
|
||||
|
||||
## Purpose
|
||||
|
||||
The private relay lets an OpenChamber client (mobile app, browser, or another desktop) reach a user's OpenChamber instance through OpenChamber-hosted infrastructure when the instance is not directly reachable (behind NAT, no public URL, no tunnel). The instance dials **outbound** to the relay; nothing needs to be exposed inbound.
|
||||
|
||||
Traffic is **end-to-end encrypted between the two endpoints** (client and host instance). The relay infrastructure forwards opaque ciphertext and cannot read application traffic — it is an untrusted transport, not a trusted middlebox.
|
||||
|
||||
This module (`packages/web/server/lib/relay/`) is the **host side**: it runs inside the OpenChamber web server (so it works for Electron desktop, headless server, and CLI installs alike). The **client side** lives in `packages/ui/src/lib/relay/`. The **relay service itself** is a separate Cloudflare Worker in the `openchamber-website` repo and only brokers connections.
|
||||
|
||||
## The three layers
|
||||
|
||||
Traffic is modeled as three stacked layers. The relay understands only Layer 1; Layers 2–3 exist solely between the client and the host.
|
||||
|
||||
1. **Relay routing (Layer 1)** — outbound WebSocket connections to the relay, connection brokering, and host authentication to the relay. The relay routes each client to the correct host and forwards frames verbatim.
|
||||
2. **End-to-end encryption (Layer 2)** — an authenticated encrypted channel established directly between client and host, keyed so the relay cannot participate. Built on standard WebCrypto primitives (ECDH key agreement + AEAD framing). The host's encryption public key is distributed to the client out-of-band via the pairing payload and is the client's trust anchor.
|
||||
3. **Tunnel multiplexing (Layer 3)** — because an OpenChamber client speaks many concurrent HTTP requests, an event stream (SSE), and WebSockets to one origin, the encrypted channel carries a small multiplexing protocol. It frames HTTP request/response (including streamed bodies) and WebSocket sub-streams so the whole app works over one encrypted connection.
|
||||
|
||||
## Entrypoints and structure
|
||||
|
||||
Host side (`packages/web/server/lib/relay/`):
|
||||
- `service.js` — thin entrypoint: relay config (enabled flag + relay URL), the management routes (`GET/POST /api/openchamber/relay/{status,enable,disable,offer}`), and lifecycle wiring. Started from `packages/web/server/index.js` only when the user has explicitly enabled the relay. The relay endpoint defaults to the OpenChamber-hosted relay but can be pinned to a self-hosted relay via the `OPENCHAMBER_RELAY_URL` env var (must be `ws://`/`wss://`); when set it overrides the stored setting for the host connection, the pairing offer, and status, so paired clients inherit the endpoint automatically from the offer.
|
||||
- `identity.js` — the host's stable identity: the long-lived signing keypair (shared with the push relay, defines the routing id) plus a long-lived encryption keypair (the E2EE trust anchor). Reused across restarts; never rotated implicitly.
|
||||
- `signing-key.js` — storage/derivation of the signing keypair and the routing id, shared with the notifications runtime.
|
||||
- `host-client.js` — the long-lived connection manager: one outbound control connection to the relay, a per-client data connection for each connected device, reconnect/backoff, and the E2EE responder handshake per connection.
|
||||
- `tunnel-host.js` — the per-connection dispatcher: decrypts tunnel frames and forwards HTTP/SSE/WS to the local server over loopback, then streams responses back. Enforces a path allowlist and never injects credentials.
|
||||
- `e2ee.js`, `tunnel-codec.js` — host-side (JS) mirrors of the shared crypto and framing (see "Two implementations" below).
|
||||
|
||||
Client side (`packages/ui/src/lib/relay/`):
|
||||
- `protocol.ts` — the shared contract: constants, frame types, message shapes. The normative source both implementations follow.
|
||||
- `crypto.ts`, `handshake.ts` — the E2EE primitives and handshake state machines (initiator + responder).
|
||||
- `tunnel-codec.ts` — Layer 3 frame codec, fragmentation, and outbound frame batching.
|
||||
- `tunnel-client.ts` — the client tunnel: exposes a `fetch()`-compatible and a WebSocket-compatible surface backed by the encrypted tunnel.
|
||||
- `tunnel-payloads.ts`, `runtime-tunnel.ts`, `runtime-socket.ts` — payload helpers, the active-tunnel singleton, and the shared "open a runtime WebSocket the right way" helper.
|
||||
- `offer.ts` — the pairing payload builder/parser (secrets travel in URL fragments only).
|
||||
|
||||
## What travels the tunnel
|
||||
|
||||
Everything a client normally sends to the single OpenChamber origin:
|
||||
- **HTTP** — REST endpoints and proxied OpenCode SDK calls under `/api/*`, plus `/auth/*` and `/health`.
|
||||
- **SSE** — long-lived streamed responses (the event stream, notifications, terminal output fallback). These are just HTTP responses whose body streams; the tunnel needs no special SSE handling.
|
||||
- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation).
|
||||
|
||||
The host dispatcher restricts tunneled traffic to explicit path allowlists (one for HTTP, one for WS).
|
||||
|
||||
## Authentication model
|
||||
|
||||
- The tunnel is **transport only**. The OpenChamber server still authenticates every tunneled request exactly as it authenticates a direct remote client. The relay path grants reachability, not authorization.
|
||||
- Clients carry their normal credential. HTTP and SSE requests authenticate with the client's bearer token (a header). **WebSocket upgrades cannot send headers**, so they authenticate with a short-lived URL-scoped token minted beforehand and passed as a query parameter. This asymmetry is important when adding new WebSocket features (see the skill).
|
||||
- The host authenticates itself to the relay with a signed handshake using its long-lived signing key.
|
||||
- Enabling the relay is explicit opt-in and disabled by default; disabling it severs all relay reachability immediately.
|
||||
|
||||
## End-to-end flow (overview)
|
||||
|
||||
1. **Pairing.** The host builds an offer describing the relay endpoint, its routing id, and its encryption public key, rendered as a QR code / deep link. Secrets are carried in the URL fragment so they never reach any server. The client imports it and stores the connection.
|
||||
2. **Presence.** When the relay is enabled, the host opens one outbound control connection and waits.
|
||||
3. **Connect.** The client connects for a given routing id; the relay notifies the host over the control connection; the host opens a matching per-client data connection.
|
||||
4. **Handshake.** Over that connection pair, client and host run the E2EE handshake and derive a shared encrypted channel the relay cannot read.
|
||||
5. **Traffic.** All normal app traffic is multiplexed and encrypted through that channel. On the host, decrypted requests are dispatched to the local server over loopback; responses stream back encrypted. Reconnects re-establish a fresh channel and the app's existing retry machinery recovers.
|
||||
|
||||
## Two implementations, kept in sync
|
||||
|
||||
The E2EE and framing logic exists twice: TypeScript in `packages/ui/src/lib/relay/` (shared by the client and the normative reference) and a JavaScript mirror in this module (the host, which is plain JS ESM). They **must stay byte-compatible** — a client encrypted by one must decrypt on the other. A cross-compatibility test (`cross-compat.test.js`) imports the TS modules directly and exercises a full TS-client ↔ JS-host exchange. Any change to the wire format, frame codec, handshake, or batching must update both sides and keep that test green.
|
||||
|
||||
## Runtime integration (client)
|
||||
|
||||
Relay mode plugs into the existing client transport layer rather than a parallel path: `runtime-switch` activates the tunnel singleton, `runtime-fetch` routes runtime requests through it, `runtime-url`/`runtime-socket` yield tunnel-backed URLs and sockets, and `runtime-auth` mints the URL-scoped token through the tunnel. Direct-URL connections and the Electron realtime-proxy path are unaffected.
|
||||
|
||||
## Design invariants (do not regress)
|
||||
|
||||
- The relay never sees plaintext application traffic; it sees only routing metadata (routing id, connection identifiers, timestamps, coarse counts).
|
||||
- Pairing secrets travel in URL fragments only, never in query strings, never logged.
|
||||
- The host dispatcher never injects credentials; the server authenticates each tunneled request.
|
||||
- The tunnel is transparent to the app: adding relay support to a feature should not require the feature to know the relay exists — it goes through the shared runtime transport helpers.
|
||||
- The two implementations stay byte-compatible and the wire format is versioned/negotiated so mixed client/host app versions degrade gracefully rather than break.
|
||||
|
||||
For the operational rules that keep future changes (new WebSocket endpoints, transport refactors, terminal/voice porting) from breaking this, load the `relay-transport` skill.
|
||||
@@ -0,0 +1,135 @@
|
||||
// Cross-compatibility: the JS host e2ee must interoperate with the normative TS
|
||||
// modules in packages/ui/src/lib/relay. bun runs TS directly, so import the TS
|
||||
// client handshake and drive a full TS-client <-> JS-host exchange both ways.
|
||||
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { createHostHandshake, exportPublicKeyJwk, generateEcdhKeyPair } from './e2ee.js';
|
||||
import { createClientHandshake } from '../../../../ui/src/lib/relay/handshake.ts';
|
||||
import {
|
||||
TunnelFrameType as JsFrameType,
|
||||
decodeFrameBatch as jsDecodeBatch,
|
||||
decodeTunnelFrame as jsDecode,
|
||||
encodeFrameBatch as jsEncodeBatch,
|
||||
encodeTunnelFrame as jsEncode,
|
||||
} from './tunnel-codec.js';
|
||||
import {
|
||||
decodeFrameBatch as tsDecodeBatch,
|
||||
decodeTunnelFrame as tsDecode,
|
||||
encodeFrameBatch as tsEncodeBatch,
|
||||
encodeTunnelFrame as tsEncode,
|
||||
} from '../../../../ui/src/lib/relay/tunnel-codec.ts';
|
||||
import { TunnelFrameType as TsFrameType } from '../../../../ui/src/lib/relay/protocol.ts';
|
||||
|
||||
describe('relay JS-host <-> TS-client cross compatibility', () => {
|
||||
it('completes a handshake and exchanges frames both ways', async () => {
|
||||
const hostKeys = await generateEcdhKeyPair();
|
||||
const hostPubJwk = await exportPublicKeyJwk(hostKeys.publicKey);
|
||||
|
||||
const jsHost = createHostHandshake(hostKeys.privateKey);
|
||||
const tsClient = await createClientHandshake(hostPubJwk);
|
||||
|
||||
// TS client hello -> JS host establishes and replies ready.
|
||||
const hostAction = await jsHost.handleText(tsClient.helloText);
|
||||
expect(hostAction.type).toBe('established');
|
||||
const hostChannel = hostAction.channel;
|
||||
|
||||
// JS host ready -> TS client establishes.
|
||||
const clientAction = await tsClient.handleText(hostAction.replyText);
|
||||
expect(clientAction.type).toBe('established');
|
||||
const clientChannel = clientAction.channel;
|
||||
|
||||
// TS client -> JS host.
|
||||
const up = new TextEncoder().encode('ts client speaking');
|
||||
const upPlain = await hostChannel.decryptor.decrypt(await clientChannel.encryptor.encrypt(up));
|
||||
expect(new TextDecoder().decode(upPlain)).toBe('ts client speaking');
|
||||
|
||||
// JS host -> TS client.
|
||||
const down = new TextEncoder().encode('js host replying');
|
||||
const downPlain = await clientChannel.decryptor.decrypt(await hostChannel.encryptor.encrypt(down));
|
||||
expect(new TextDecoder().decode(downPlain)).toBe('js host replying');
|
||||
});
|
||||
|
||||
it('tunnel frames are byte-compatible across TS and JS codecs', () => {
|
||||
const payload = new TextEncoder().encode('{"method":"GET"}');
|
||||
const tsFrame = tsEncode(TsFrameType.HttpRequest, 5, payload);
|
||||
const jsFrame = jsEncode(JsFrameType.HttpRequest, 5, payload);
|
||||
expect(Array.from(jsFrame)).toEqual(Array.from(tsFrame));
|
||||
|
||||
const decodedByJs = jsDecode(tsFrame);
|
||||
const decodedByTs = tsDecode(jsFrame);
|
||||
expect(decodedByJs.streamId).toBe(5);
|
||||
expect(decodedByTs.streamId).toBe(5);
|
||||
expect(decodedByJs.frameType).toBe(TsFrameType.HttpRequest);
|
||||
});
|
||||
|
||||
it('negotiates batching between a TS client and a JS host, then exchanges a batch', async () => {
|
||||
const hostKeys = await generateEcdhKeyPair();
|
||||
const hostPubJwk = await exportPublicKeyJwk(hostKeys.publicKey);
|
||||
|
||||
const jsHost = createHostHandshake(hostKeys.privateKey);
|
||||
const tsClient = await createClientHandshake(hostPubJwk);
|
||||
|
||||
const hostAction = await jsHost.handleText(tsClient.helloText);
|
||||
expect(hostAction.type).toBe('established');
|
||||
expect(hostAction.batch).toBe(true);
|
||||
const clientAction = await tsClient.handleText(hostAction.replyText);
|
||||
expect(clientAction.type).toBe('established');
|
||||
expect(clientAction.batch).toBe(true);
|
||||
|
||||
// TS client encodes a multi-frame batch -> JS host decodes it byte-identically.
|
||||
const frames = [
|
||||
tsEncode(TsFrameType.HttpBody, 1, new TextEncoder().encode('alpha')),
|
||||
tsEncode(TsFrameType.HttpBody, 1, new TextEncoder().encode('beta')),
|
||||
tsEncode(TsFrameType.HttpBody, 1, new TextEncoder().encode('gamma')),
|
||||
];
|
||||
const overWire = await hostAction.channel.decryptor.decrypt(
|
||||
await clientAction.channel.encryptor.encrypt(tsEncodeBatch(frames)),
|
||||
);
|
||||
const jsFrames = jsDecodeBatch(overWire);
|
||||
expect(jsFrames.length).toBe(3);
|
||||
jsFrames.forEach((frame, index) => expect(Array.from(frame)).toEqual(Array.from(frames[index])));
|
||||
|
||||
// JS host encodes a batch -> TS client decodes it.
|
||||
const downFrames = [
|
||||
jsEncode(JsFrameType.HttpBody, 1, new TextEncoder().encode('down-1')),
|
||||
jsEncode(JsFrameType.HttpBody, 1, new TextEncoder().encode('down-2')),
|
||||
];
|
||||
const downWire = await clientAction.channel.decryptor.decrypt(
|
||||
await hostAction.channel.encryptor.encrypt(jsEncodeBatch(downFrames)),
|
||||
);
|
||||
const tsFrames = tsDecodeBatch(downWire);
|
||||
expect(tsFrames.length).toBe(2);
|
||||
tsFrames.forEach((frame, index) => expect(Array.from(frame)).toEqual(Array.from(downFrames[index])));
|
||||
});
|
||||
|
||||
it('falls back to legacy (no batch) when either peer does not advertise batching', async () => {
|
||||
const hostKeys = await generateEcdhKeyPair();
|
||||
const hostPubJwk = await exportPublicKeyJwk(hostKeys.publicKey);
|
||||
|
||||
// Legacy JS host (batch:false) vs batch-capable TS client -> batching off.
|
||||
const legacyHost = createHostHandshake(hostKeys.privateKey, { batch: false });
|
||||
const tsClient = await createClientHandshake(hostPubJwk);
|
||||
const hostAction = await legacyHost.handleText(tsClient.helloText);
|
||||
expect(hostAction.type).toBe('established');
|
||||
expect(hostAction.batch).toBe(false);
|
||||
const clientAction = await tsClient.handleText(hostAction.replyText);
|
||||
expect(clientAction.type).toBe('established');
|
||||
expect(clientAction.batch).toBe(false);
|
||||
|
||||
// Legacy wire: plaintext is a single raw tunnel frame (no container tag).
|
||||
const frame = tsEncode(TsFrameType.HttpBody, 1, new TextEncoder().encode('legacy'));
|
||||
const overWire = await hostAction.channel.decryptor.decrypt(
|
||||
await clientAction.channel.encryptor.encrypt(frame),
|
||||
);
|
||||
expect(jsDecode(overWire).frameType).toBe(JsFrameType.HttpBody);
|
||||
|
||||
// Batch-capable JS host vs legacy TS client (batch:false) -> also off.
|
||||
const host2 = createHostHandshake(hostKeys.privateKey);
|
||||
const legacyClient = await createClientHandshake(hostPubJwk, { batch: false });
|
||||
const host2Action = await host2.handleText(legacyClient.helloText);
|
||||
expect(host2Action.batch).toBe(false);
|
||||
const client2Action = await legacyClient.handleText(host2Action.replyText);
|
||||
expect(client2Action.batch).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
// E2EE primitives + responder handshake for the private relay (Layer 2).
|
||||
// JS mirror of the normative TS implementation in
|
||||
// packages/ui/src/lib/relay/{protocol,crypto,handshake}.ts — the web server is
|
||||
// plain JS and cannot import from packages/ui, so the logic is copied verbatim
|
||||
// (converted to JSDoc'd JS) and MUST stay byte-compatible with those modules.
|
||||
// WebCrypto only: `globalThis.crypto.subtle` (Node >= 22).
|
||||
// Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 2).
|
||||
|
||||
const subtle = globalThis.crypto.subtle;
|
||||
|
||||
export const RELAY_PROTOCOL_VERSION = 1;
|
||||
export const RELAY_HKDF_INFO = 'openchamber-relay-v1';
|
||||
|
||||
// Encrypted frame layout: [1 byte version][12 byte IV][ciphertext + 16 byte GCM tag].
|
||||
export const ENCRYPTED_FRAME_VERSION = 1;
|
||||
export const ENCRYPTED_FRAME_IV_BYTES = 12;
|
||||
export const ENCRYPTED_FRAME_HEADER_BYTES = 1 + ENCRYPTED_FRAME_IV_BYTES;
|
||||
export const MAX_PLAINTEXT_FRAME_BYTES = 64 * 1024;
|
||||
|
||||
// Relay-assigned WebSocket close codes (subset the host needs).
|
||||
export const RelayCloseCode = {
|
||||
RekeyMismatch: 1008,
|
||||
ChannelFailure: 1011,
|
||||
};
|
||||
|
||||
const ECDH_PARAMS = { name: 'ECDH', namedCurve: 'P-256' };
|
||||
const HANDSHAKE_NONCE_BYTES = 16;
|
||||
const SESSION_KEY_BYTES = 32;
|
||||
const GCM_TAG_BYTES = 16;
|
||||
// IV = 4-byte random per-direction prefix || 8-byte big-endian frame counter.
|
||||
const IV_PREFIX_BYTES = 4;
|
||||
const IV_COUNTER_BYTES = 8;
|
||||
|
||||
export class RelayCryptoError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'RelayCryptoError';
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<CryptoKeyPair>} */
|
||||
export const generateEcdhKeyPair = () => subtle.generateKey(ECDH_PARAMS, true, ['deriveBits']);
|
||||
|
||||
/**
|
||||
* @param {CryptoKey} key
|
||||
* @returns {Promise<JsonWebKey>} public JWK reduced to the fields that define the point
|
||||
*/
|
||||
export const exportPublicKeyJwk = async (key) => {
|
||||
const jwk = await subtle.exportKey('jwk', key);
|
||||
return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
|
||||
};
|
||||
|
||||
/** @param {JsonWebKey} jwk */
|
||||
export const importEcdhPublicKey = async (jwk) => {
|
||||
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256' || typeof jwk.x !== 'string' || typeof jwk.y !== 'string') {
|
||||
throw new RelayCryptoError('invalid ECDH public key JWK');
|
||||
}
|
||||
try {
|
||||
return await subtle.importKey(
|
||||
'jwk',
|
||||
{ kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, ext: true },
|
||||
ECDH_PARAMS,
|
||||
true,
|
||||
[],
|
||||
);
|
||||
} catch {
|
||||
throw new RelayCryptoError('invalid ECDH public key JWK');
|
||||
}
|
||||
};
|
||||
|
||||
/** @param {JsonWebKey} jwk private ECDH JWK (d + point) */
|
||||
export const importEcdhPrivateKey = async (jwk) => {
|
||||
try {
|
||||
return await subtle.importKey('jwk', jwk, ECDH_PARAMS, false, ['deriveBits']);
|
||||
} catch {
|
||||
throw new RelayCryptoError('invalid ECDH private key JWK');
|
||||
}
|
||||
};
|
||||
|
||||
// Stable fingerprint of a public key, used to detect rekey attempts on re-hello.
|
||||
/** @param {JsonWebKey} jwk */
|
||||
export const publicKeyJwkFingerprint = (jwk) =>
|
||||
JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
|
||||
|
||||
export const generateHandshakeNonce = () => {
|
||||
const nonce = new Uint8Array(HANDSHAKE_NONCE_BYTES);
|
||||
globalThis.crypto.getRandomValues(nonce);
|
||||
return nonce;
|
||||
};
|
||||
|
||||
/**
|
||||
* Both sides call this with their own private key and the peer's public key;
|
||||
* ECDH yields the same shared secret, so the derived key pair matches.
|
||||
* @param {CryptoKey} ownPrivateKey
|
||||
* @param {CryptoKey} peerPublicKey
|
||||
* @param {Uint8Array} handshakeNonce
|
||||
* @returns {Promise<{ clientToHost: CryptoKey, hostToClient: CryptoKey }>}
|
||||
*/
|
||||
export const deriveSessionKeys = async (ownPrivateKey, peerPublicKey, handshakeNonce) => {
|
||||
if (handshakeNonce.length !== HANDSHAKE_NONCE_BYTES) {
|
||||
throw new RelayCryptoError('invalid handshake nonce length');
|
||||
}
|
||||
const sharedSecret = await subtle.deriveBits({ name: 'ECDH', public: peerPublicKey }, ownPrivateKey, 256);
|
||||
const hkdfKey = await subtle.importKey('raw', sharedSecret, 'HKDF', false, ['deriveBits']);
|
||||
const keyMaterial = new Uint8Array(
|
||||
await subtle.deriveBits(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: handshakeNonce,
|
||||
info: new TextEncoder().encode(RELAY_HKDF_INFO),
|
||||
},
|
||||
hkdfKey,
|
||||
SESSION_KEY_BYTES * 2 * 8,
|
||||
),
|
||||
);
|
||||
const importAesKey = (bytes, usage) => subtle.importKey('raw', bytes, { name: 'AES-GCM' }, false, usage);
|
||||
return {
|
||||
clientToHost: await importAesKey(keyMaterial.slice(0, SESSION_KEY_BYTES), ['encrypt', 'decrypt']),
|
||||
hostToClient: await importAesKey(keyMaterial.slice(SESSION_KEY_BYTES), ['encrypt', 'decrypt']),
|
||||
};
|
||||
};
|
||||
|
||||
const writeCounter = (target, offset, counter) => {
|
||||
for (let i = IV_COUNTER_BYTES - 1; i >= 0; i -= 1) {
|
||||
target[offset + i] = Number(counter & 0xffn);
|
||||
counter >>= 8n;
|
||||
}
|
||||
};
|
||||
|
||||
const readCounter = (source, offset) => {
|
||||
let value = 0n;
|
||||
for (let i = 0; i < IV_COUNTER_BYTES; i += 1) {
|
||||
value = (value << 8n) | BigInt(source[offset + i]);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/** @param {CryptoKey} key AES-256-GCM key for this direction */
|
||||
export const createFrameEncryptor = (key) => {
|
||||
const ivPrefix = new Uint8Array(IV_PREFIX_BYTES);
|
||||
globalThis.crypto.getRandomValues(ivPrefix);
|
||||
let counter = 0n;
|
||||
return {
|
||||
/** @param {Uint8Array} plaintext */
|
||||
async encrypt(plaintext) {
|
||||
if (plaintext.length > MAX_PLAINTEXT_FRAME_BYTES) {
|
||||
throw new RelayCryptoError('plaintext frame exceeds maximum size');
|
||||
}
|
||||
counter += 1n;
|
||||
const iv = new Uint8Array(ENCRYPTED_FRAME_IV_BYTES);
|
||||
iv.set(ivPrefix, 0);
|
||||
writeCounter(iv, IV_PREFIX_BYTES, counter);
|
||||
const ciphertext = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext));
|
||||
const frame = new Uint8Array(ENCRYPTED_FRAME_HEADER_BYTES + ciphertext.length);
|
||||
frame[0] = ENCRYPTED_FRAME_VERSION;
|
||||
frame.set(iv, 1);
|
||||
frame.set(ciphertext, ENCRYPTED_FRAME_HEADER_BYTES);
|
||||
return frame;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Enforces strictly increasing per-direction counters: the relay WS preserves
|
||||
// ordering, so any regression or replay means tampering and must fail closed.
|
||||
/** @param {CryptoKey} key AES-256-GCM key for this direction */
|
||||
export const createFrameDecryptor = (key) => {
|
||||
let lastCounter = 0n;
|
||||
return {
|
||||
/** @param {Uint8Array} frame */
|
||||
async decrypt(frame) {
|
||||
if (frame.length < ENCRYPTED_FRAME_HEADER_BYTES + GCM_TAG_BYTES) {
|
||||
throw new RelayCryptoError('encrypted frame too short');
|
||||
}
|
||||
if (frame[0] !== ENCRYPTED_FRAME_VERSION) {
|
||||
throw new RelayCryptoError('unsupported encrypted frame version');
|
||||
}
|
||||
const iv = frame.slice(1, ENCRYPTED_FRAME_HEADER_BYTES);
|
||||
const counter = readCounter(iv, IV_PREFIX_BYTES);
|
||||
if (counter <= lastCounter) {
|
||||
throw new RelayCryptoError('frame counter regression');
|
||||
}
|
||||
let plaintext;
|
||||
try {
|
||||
plaintext = await subtle.decrypt({ name: 'AES-GCM', iv }, key, frame.slice(ENCRYPTED_FRAME_HEADER_BYTES));
|
||||
} catch {
|
||||
throw new RelayCryptoError('frame decryption failed');
|
||||
}
|
||||
lastCounter = counter;
|
||||
return new Uint8Array(plaintext);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||
|
||||
/** @param {Uint8Array} bytes */
|
||||
export const bytesToBase64Url = (bytes) => {
|
||||
let out = '';
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const b0 = bytes[i];
|
||||
const b1 = i + 1 < bytes.length ? bytes[i + 1] : undefined;
|
||||
const b2 = i + 2 < bytes.length ? bytes[i + 2] : undefined;
|
||||
out += BASE64URL_ALPHABET[b0 >> 2];
|
||||
out += BASE64URL_ALPHABET[((b0 & 0x03) << 4) | ((b1 ?? 0) >> 4)];
|
||||
if (b1 !== undefined) out += BASE64URL_ALPHABET[((b1 & 0x0f) << 2) | ((b2 ?? 0) >> 6)];
|
||||
if (b2 !== undefined) out += BASE64URL_ALPHABET[b2 & 0x3f];
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** @param {string} value */
|
||||
export const base64UrlToBytes = (value) => {
|
||||
if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
|
||||
throw new RelayCryptoError('invalid base64url input');
|
||||
}
|
||||
const out = new Uint8Array(Math.floor((value.length * 3) / 4));
|
||||
let outIndex = 0;
|
||||
let buffer = 0;
|
||||
let bits = 0;
|
||||
for (const char of value) {
|
||||
buffer = (buffer << 6) | BASE64URL_ALPHABET.indexOf(char);
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out[outIndex] = (buffer >> bits) & 0xff;
|
||||
outIndex += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Responder handshake state machine (host side). Mirror of createHostHandshake
|
||||
// in packages/ui/src/lib/relay/handshake.ts.
|
||||
//
|
||||
// Fail-closed rules (from the spec):
|
||||
// - a repeated identical `hello` re-sends `ready` (client retry race);
|
||||
// - a `hello` with a DIFFERENT key on an established channel is a rekey
|
||||
// attack -> close 1008, never rekey in place;
|
||||
// - plaintext after `ready`, or any decrypt failure -> close 1011.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const parseHandshakeMessage = (raw) => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||
if (parsed.v !== RELAY_PROTOCOL_VERSION) return null;
|
||||
// Unknown/missing capability flag = false = legacy behavior.
|
||||
const batch = parsed.batch === true;
|
||||
if (parsed.t === 'ready') {
|
||||
return { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch };
|
||||
}
|
||||
if (parsed.t === 'hello' && typeof parsed.nonce === 'string' && typeof parsed.clientPubJwk === 'object' && parsed.clientPubJwk !== null) {
|
||||
return { t: 'hello', v: RELAY_PROTOCOL_VERSION, clientPubJwk: parsed.clientPubJwk, nonce: parsed.nonce, batch };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const failClosed = (reason) => ({
|
||||
type: 'fail',
|
||||
closeCode: RelayCloseCode.ChannelFailure,
|
||||
reason,
|
||||
});
|
||||
|
||||
/**
|
||||
* Host (responder) handshake. Feed every inbound text frame to `handleText`;
|
||||
* it returns one of:
|
||||
* { type: 'send-text', text } — send this plaintext frame
|
||||
* { type: 'established', channel, replyText } — send replyText first, then switch to encrypted frames
|
||||
* { type: 'ignore' } — drop the frame
|
||||
* { type: 'fail', closeCode, reason } — close the socket with closeCode
|
||||
* @param {CryptoKey} hostEncPrivateKey long-lived ECDH private key
|
||||
* @param {{ batch?: boolean }} [options] `batch` defaults true; set false to force legacy behavior
|
||||
*/
|
||||
export const createHostHandshake = (hostEncPrivateKey, options = {}) => {
|
||||
const localBatch = options.batch !== false;
|
||||
let established = false;
|
||||
let acceptedClientKeyFingerprint = null;
|
||||
let readyText = null;
|
||||
let negotiatedBatch = false;
|
||||
return {
|
||||
get established() {
|
||||
return established;
|
||||
},
|
||||
/** @param {string} raw */
|
||||
async handleText(raw) {
|
||||
const message = parseHandshakeMessage(raw);
|
||||
if (message?.t !== 'hello') {
|
||||
if (established) {
|
||||
return failClosed('plaintext frame on established channel');
|
||||
}
|
||||
return { type: 'ignore' };
|
||||
}
|
||||
const fingerprint = publicKeyJwkFingerprint(message.clientPubJwk);
|
||||
if (acceptedClientKeyFingerprint !== null) {
|
||||
if (fingerprint === acceptedClientKeyFingerprint && readyText !== null) {
|
||||
// Client retried `hello` before our `ready` arrived — answer again.
|
||||
return { type: 'send-text', text: readyText };
|
||||
}
|
||||
return { type: 'fail', closeCode: RelayCloseCode.RekeyMismatch, reason: 'rekey mismatch' };
|
||||
}
|
||||
let clientPublicKey;
|
||||
let nonce;
|
||||
try {
|
||||
clientPublicKey = await importEcdhPublicKey(message.clientPubJwk);
|
||||
nonce = base64UrlToBytes(message.nonce);
|
||||
} catch {
|
||||
return failClosed('malformed hello');
|
||||
}
|
||||
let keys;
|
||||
try {
|
||||
keys = await deriveSessionKeys(hostEncPrivateKey, clientPublicKey, nonce);
|
||||
} catch {
|
||||
return failClosed('key derivation failed');
|
||||
}
|
||||
acceptedClientKeyFingerprint = fingerprint;
|
||||
// Batching runs only if both peers advertised it.
|
||||
negotiatedBatch = localBatch && message.batch === true;
|
||||
readyText = JSON.stringify(
|
||||
negotiatedBatch
|
||||
? { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch: true }
|
||||
: { t: 'ready', v: RELAY_PROTOCOL_VERSION },
|
||||
);
|
||||
established = true;
|
||||
return {
|
||||
type: 'established',
|
||||
batch: negotiatedBatch,
|
||||
replyText: readyText,
|
||||
channel: {
|
||||
encryptor: createFrameEncryptor(keys.hostToClient),
|
||||
decryptor: createFrameDecryptor(keys.clientToHost),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import {
|
||||
base64UrlToBytes,
|
||||
bytesToBase64Url,
|
||||
createFrameDecryptor,
|
||||
createFrameEncryptor,
|
||||
createHostHandshake,
|
||||
deriveSessionKeys,
|
||||
exportPublicKeyJwk,
|
||||
generateEcdhKeyPair,
|
||||
generateHandshakeNonce,
|
||||
RELAY_PROTOCOL_VERSION,
|
||||
} from './e2ee.js';
|
||||
|
||||
const subtle = globalThis.crypto.subtle;
|
||||
|
||||
// A minimal client-side initiator so the host handshake can be exercised
|
||||
// end-to-end without importing the browser TS modules.
|
||||
const createClientHandshake = async (hostEncPubJwk) => {
|
||||
const hostPublicKey = await subtle.importKey(
|
||||
'jwk',
|
||||
{ kty: hostEncPubJwk.kty, crv: hostEncPubJwk.crv, x: hostEncPubJwk.x, y: hostEncPubJwk.y, ext: true },
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
true,
|
||||
[],
|
||||
);
|
||||
const ephemeral = await generateEcdhKeyPair();
|
||||
const nonce = generateHandshakeNonce();
|
||||
const helloText = JSON.stringify({
|
||||
t: 'hello',
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
clientPubJwk: await exportPublicKeyJwk(ephemeral.publicKey),
|
||||
nonce: bytesToBase64Url(nonce),
|
||||
});
|
||||
const deriveChannel = async () => {
|
||||
const keys = await deriveSessionKeys(ephemeral.privateKey, hostPublicKey, nonce);
|
||||
return {
|
||||
encryptor: createFrameEncryptor(keys.clientToHost),
|
||||
decryptor: createFrameDecryptor(keys.hostToClient),
|
||||
};
|
||||
};
|
||||
return { helloText, deriveChannel };
|
||||
};
|
||||
|
||||
describe('relay e2ee', () => {
|
||||
it('round-trips frames in both directions after handshake', async () => {
|
||||
const hostKeys = await generateEcdhKeyPair();
|
||||
const hostPubJwk = await exportPublicKeyJwk(hostKeys.publicKey);
|
||||
const host = createHostHandshake(hostKeys.privateKey);
|
||||
const client = await createClientHandshake(hostPubJwk);
|
||||
|
||||
const action = await host.handleText(client.helloText);
|
||||
expect(action.type).toBe('established');
|
||||
const hostChannel = action.channel;
|
||||
const clientChannel = await client.deriveChannel();
|
||||
|
||||
const c2h = new TextEncoder().encode('client-to-host payload');
|
||||
const decodedAtHost = await hostChannel.decryptor.decrypt(await clientChannel.encryptor.encrypt(c2h));
|
||||
expect(new TextDecoder().decode(decodedAtHost)).toBe('client-to-host payload');
|
||||
|
||||
const h2c = new TextEncoder().encode('host-to-client payload');
|
||||
const decodedAtClient = await clientChannel.decryptor.decrypt(await hostChannel.encryptor.encrypt(h2c));
|
||||
expect(new TextDecoder().decode(decodedAtClient)).toBe('host-to-client payload');
|
||||
});
|
||||
|
||||
it('rejects tampered ciphertext', async () => {
|
||||
const keyBytes = new Uint8Array(32);
|
||||
globalThis.crypto.getRandomValues(keyBytes);
|
||||
const key = await subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
||||
const enc = createFrameEncryptor(key);
|
||||
const dec = createFrameDecryptor(key);
|
||||
const frame = await enc.encrypt(new Uint8Array([1, 2, 3]));
|
||||
frame[frame.length - 1] ^= 0xff;
|
||||
await expect(dec.decrypt(frame)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects counter regression / replay', async () => {
|
||||
const keyBytes = new Uint8Array(32);
|
||||
globalThis.crypto.getRandomValues(keyBytes);
|
||||
const key = await subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
||||
const enc = createFrameEncryptor(key);
|
||||
const dec = createFrameDecryptor(key);
|
||||
const first = await enc.encrypt(new Uint8Array([9]));
|
||||
await dec.decrypt(first);
|
||||
// Replaying the same frame (counter no longer strictly increasing) fails.
|
||||
await expect(dec.decrypt(first)).rejects.toThrow('frame counter regression');
|
||||
});
|
||||
|
||||
it('re-sends ready on identical re-hello and fails on rekey', async () => {
|
||||
const hostKeys = await generateEcdhKeyPair();
|
||||
const hostPubJwk = await exportPublicKeyJwk(hostKeys.publicKey);
|
||||
const host = createHostHandshake(hostKeys.privateKey);
|
||||
const client = await createClientHandshake(hostPubJwk);
|
||||
|
||||
const first = await host.handleText(client.helloText);
|
||||
expect(first.type).toBe('established');
|
||||
|
||||
const repeat = await host.handleText(client.helloText);
|
||||
expect(repeat.type).toBe('send-text');
|
||||
expect(repeat.text).toBe(first.replyText);
|
||||
|
||||
const other = await createClientHandshake(hostPubJwk);
|
||||
const rekey = await host.handleText(other.helloText);
|
||||
expect(rekey.type).toBe('fail');
|
||||
expect(rekey.closeCode).toBe(1008);
|
||||
});
|
||||
|
||||
it('fails closed on plaintext after ready', async () => {
|
||||
const hostKeys = await generateEcdhKeyPair();
|
||||
const hostPubJwk = await exportPublicKeyJwk(hostKeys.publicKey);
|
||||
const host = createHostHandshake(hostKeys.privateKey);
|
||||
const client = await createClientHandshake(hostPubJwk);
|
||||
await host.handleText(client.helloText);
|
||||
const action = await host.handleText(JSON.stringify({ hello: 'not a handshake' }));
|
||||
expect(action.type).toBe('fail');
|
||||
expect(action.closeCode).toBe(1011);
|
||||
});
|
||||
|
||||
it('base64url helpers round-trip', () => {
|
||||
const bytes = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255]);
|
||||
expect(Array.from(base64UrlToBytes(bytesToBase64Url(bytes)))).toEqual(Array.from(bytes));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
// Long-lived relay host client: maintains the signed `host-control` socket to
|
||||
// the relay, and per connected client a signed `host-data` socket that runs the
|
||||
// responder E2EE handshake and feeds decrypted frames into a tunnel-host
|
||||
// dispatcher. Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 1).
|
||||
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
import { RELAY_PROTOCOL_VERSION, RelayCloseCode, createHostHandshake } from './e2ee.js';
|
||||
import { createOutboundFrameBatcher, decodeFrameBatch } from './tunnel-codec.js';
|
||||
import { createTunnelHost } from './tunnel-host.js';
|
||||
|
||||
const BACKOFF_BASE_MS = 1000;
|
||||
const BACKOFF_CAP_MS = 30000;
|
||||
const DATA_SOCKET_OPEN_TIMEOUT_MS = 15000;
|
||||
const DEFAULT_BATCH_WINDOW_MS = 150;
|
||||
|
||||
// Resolve the frame-batching flush window: explicit option wins, then env, then
|
||||
// the 150 ms default. Only applies on directions where batching was negotiated.
|
||||
const resolveBatchWindowMs = (option) => {
|
||||
if (Number.isFinite(option) && option >= 0) return option;
|
||||
const envValue = Number.parseInt(process.env.OPENCHAMBER_RELAY_BATCH_WINDOW_MS ?? '', 10);
|
||||
if (Number.isFinite(envValue) && envValue >= 0) return envValue;
|
||||
return DEFAULT_BATCH_WINDOW_MS;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* relayUrl: string,
|
||||
* identity: { serverId: string, hostEncPrivateKey: CryptoKey, signRelayAuth: (role: string, connectionId?: string | null) => { ts: number, sig: string, pk: string } },
|
||||
* localPort?: number,
|
||||
* getLocalPort?: () => number,
|
||||
* onStatus?: (status: { state: string, lastError: string | null, connectedClients: number }) => void,
|
||||
* logger?: Pick<Console, 'warn'>,
|
||||
* }} options
|
||||
*/
|
||||
export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, batchWindowMs, batch }) => {
|
||||
const resolveLocalPort = typeof getLocalPort === 'function' ? getLocalPort : () => localPort;
|
||||
const localBatch = batch !== false;
|
||||
const resolvedBatchWindowMs = resolveBatchWindowMs(batchWindowMs);
|
||||
|
||||
let stopped = false;
|
||||
let state = 'connecting';
|
||||
let lastError = null;
|
||||
let controlSocket = null;
|
||||
let reconnectTimer = null;
|
||||
let consecutiveFailures = 0;
|
||||
/** @type {Map<string, { socket: WebSocket, tunnel: ReturnType<typeof createTunnelHost> | null, openTimer: NodeJS.Timeout | null }>} */
|
||||
const dataSockets = new Map();
|
||||
|
||||
const emitStatus = () => {
|
||||
try {
|
||||
onStatus?.({ state, lastError, connectedClients: dataSockets.size });
|
||||
} catch {
|
||||
// status consumers must not break the transport
|
||||
}
|
||||
};
|
||||
|
||||
const setState = (nextState, error) => {
|
||||
state = nextState;
|
||||
if (error !== undefined) lastError = error;
|
||||
emitStatus();
|
||||
};
|
||||
|
||||
const buildSocketUrl = (role, connectionId) => {
|
||||
const url = new URL(relayUrl);
|
||||
url.searchParams.set('v', String(RELAY_PROTOCOL_VERSION));
|
||||
url.searchParams.set('role', role);
|
||||
url.searchParams.set('serverId', identity.serverId);
|
||||
if (connectionId) url.searchParams.set('connectionId', connectionId);
|
||||
const auth = identity.signRelayAuth(role, connectionId ?? null);
|
||||
url.searchParams.set('ts', String(auth.ts));
|
||||
url.searchParams.set('sig', auth.sig);
|
||||
url.searchParams.set('pk', auth.pk);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const teardownDataSocket = (connectionId, closeCode, reason) => {
|
||||
const entry = dataSockets.get(connectionId);
|
||||
if (!entry) return;
|
||||
dataSockets.delete(connectionId);
|
||||
if (entry.openTimer) clearTimeout(entry.openTimer);
|
||||
entry.batcher?.dispose();
|
||||
entry.tunnel?.close();
|
||||
try {
|
||||
if (entry.socket.readyState === WebSocket.OPEN || entry.socket.readyState === WebSocket.CONNECTING) {
|
||||
if (closeCode) entry.socket.close(closeCode, reason ?? '');
|
||||
else entry.socket.terminate();
|
||||
}
|
||||
} catch {
|
||||
// socket already gone
|
||||
}
|
||||
emitStatus();
|
||||
};
|
||||
|
||||
const openDataSocket = (connectionId) => {
|
||||
if (stopped || dataSockets.has(connectionId)) return;
|
||||
|
||||
let socket;
|
||||
try {
|
||||
socket = new WebSocket(buildSocketUrl('host-data', connectionId));
|
||||
} catch (error) {
|
||||
logger.warn(`[Relay] host-data dial failed: ${error?.message ?? error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = { socket, tunnel: null, openTimer: null, batcher: null };
|
||||
dataSockets.set(connectionId, entry);
|
||||
entry.openTimer = setTimeout(() => {
|
||||
logger.warn('[Relay] host-data socket open timeout');
|
||||
teardownDataSocket(connectionId);
|
||||
}, DATA_SOCKET_OPEN_TIMEOUT_MS);
|
||||
|
||||
const handshake = createHostHandshake(identity.hostEncPrivateKey, { batch: localBatch });
|
||||
let channel = null;
|
||||
let batchNegotiated = false;
|
||||
// Serialize async message handling so encrypted frame order (and the
|
||||
// strictly-increasing decrypt counter) is preserved.
|
||||
let processing = Promise.resolve();
|
||||
// Serialize encrypt+send so the per-direction IV counter reaches the wire in
|
||||
// encryption order. One encrypt() == one WS message == one counter tick,
|
||||
// whether it carries a batch or a lone frame.
|
||||
let sendChain = Promise.resolve();
|
||||
const sendEncryptedPlaintext = (plaintext) => {
|
||||
sendChain = sendChain
|
||||
.then(async () => {
|
||||
if (dataSockets.get(connectionId) !== entry || socket.readyState !== WebSocket.OPEN || !channel) return;
|
||||
const encrypted = await channel.encryptor.encrypt(plaintext);
|
||||
socket.send(encrypted, { binary: true });
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn(`[Relay] host-data send failed: ${error?.message ?? error}`);
|
||||
});
|
||||
};
|
||||
|
||||
const failChannel = (closeCode, reason) => {
|
||||
// connectionId + reason only — never payload contents.
|
||||
logger.warn(`[Relay] data channel failed connectionId=${connectionId} reason=${reason ?? 'unknown'}`);
|
||||
teardownDataSocket(connectionId, closeCode, reason);
|
||||
};
|
||||
|
||||
const handleMessage = async (data, isBinary) => {
|
||||
const current = dataSockets.get(connectionId);
|
||||
if (current !== entry) return;
|
||||
|
||||
if (!isBinary) {
|
||||
const action = await handshake.handleText(data.toString('utf8'));
|
||||
if (action.type === 'send-text') {
|
||||
socket.send(action.text);
|
||||
} else if (action.type === 'established') {
|
||||
channel = action.channel;
|
||||
batchNegotiated = action.batch === true;
|
||||
entry.batcher = batchNegotiated
|
||||
? createOutboundFrameBatcher({ windowMs: resolvedBatchWindowMs, sendBatch: sendEncryptedPlaintext })
|
||||
: null;
|
||||
entry.tunnel = createTunnelHost({
|
||||
connectionId,
|
||||
getLocalPort: resolveLocalPort,
|
||||
getBufferedAmount: () => socket.bufferedAmount,
|
||||
sendFrame: (plaintextFrame) => {
|
||||
if (dataSockets.get(connectionId) !== entry || socket.readyState !== WebSocket.OPEN) return;
|
||||
if (entry.batcher) entry.batcher.enqueue(plaintextFrame);
|
||||
else sendEncryptedPlaintext(plaintextFrame);
|
||||
},
|
||||
});
|
||||
if (action.replyText) socket.send(action.replyText);
|
||||
} else if (action.type === 'fail') {
|
||||
failChannel(action.closeCode, action.reason);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!channel || !entry.tunnel) {
|
||||
// Encrypted traffic before the handshake completed: fail closed.
|
||||
failChannel(RelayCloseCode.ChannelFailure, 'binary frame before handshake');
|
||||
return;
|
||||
}
|
||||
let plaintext;
|
||||
try {
|
||||
plaintext = await channel.decryptor.decrypt(new Uint8Array(data));
|
||||
} catch {
|
||||
failChannel(RelayCloseCode.ChannelFailure, 'frame decryption failed');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (batchNegotiated) {
|
||||
// One encrypted message may carry several tunnel frames; dispatch each
|
||||
// in order through the same per-frame handling as legacy.
|
||||
for (const frame of decodeFrameBatch(plaintext)) {
|
||||
if (dataSockets.get(connectionId) !== entry) return;
|
||||
await entry.tunnel.handleFrame(frame);
|
||||
}
|
||||
} else {
|
||||
await entry.tunnel.handleFrame(plaintext);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[Relay] tunnel frame handling failed: ${error?.message ?? error}`);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('open', () => {
|
||||
if (entry.openTimer) {
|
||||
clearTimeout(entry.openTimer);
|
||||
entry.openTimer = null;
|
||||
}
|
||||
emitStatus();
|
||||
});
|
||||
socket.on('message', (data, isBinary) => {
|
||||
processing = processing
|
||||
.then(() => handleMessage(data, isBinary))
|
||||
.catch((error) => {
|
||||
logger.warn(`[Relay] data socket message failed: ${error?.message ?? error}`);
|
||||
failChannel(RelayCloseCode.ChannelFailure, 'internal error');
|
||||
});
|
||||
});
|
||||
socket.on('close', () => {
|
||||
teardownDataSocket(connectionId);
|
||||
});
|
||||
socket.on('error', (error) => {
|
||||
logger.warn(`[Relay] host-data socket error: ${error?.message ?? error}`);
|
||||
});
|
||||
};
|
||||
|
||||
const handleControlMessage = (raw) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object') return;
|
||||
if (message.type === 'sync' && Array.isArray(message.connectionIds)) {
|
||||
const wanted = new Set(message.connectionIds.filter((id) => typeof id === 'string' && id.length > 0));
|
||||
for (const connectionId of [...dataSockets.keys()]) {
|
||||
if (!wanted.has(connectionId)) teardownDataSocket(connectionId);
|
||||
}
|
||||
for (const connectionId of wanted) {
|
||||
openDataSocket(connectionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type === 'connected' && typeof message.connectionId === 'string') {
|
||||
openDataSocket(message.connectionId);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'disconnected' && typeof message.connectionId === 'string') {
|
||||
teardownDataSocket(message.connectionId);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (stopped || reconnectTimer) return;
|
||||
const delay = Math.min(BACKOFF_BASE_MS * 2 ** consecutiveFailures, BACKOFF_CAP_MS);
|
||||
consecutiveFailures += 1;
|
||||
setState('reconnecting');
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connectControl();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const connectControl = () => {
|
||||
if (stopped) return;
|
||||
setState(consecutiveFailures === 0 ? 'connecting' : 'reconnecting');
|
||||
|
||||
let socket;
|
||||
try {
|
||||
socket = new WebSocket(buildSocketUrl('host-control'));
|
||||
} catch (error) {
|
||||
lastError = error?.message ?? String(error);
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
controlSocket = socket;
|
||||
|
||||
socket.on('open', () => {
|
||||
if (controlSocket !== socket) return;
|
||||
consecutiveFailures = 0;
|
||||
setState('connected', null);
|
||||
});
|
||||
socket.on('message', (data, isBinary) => {
|
||||
if (controlSocket !== socket || isBinary) return;
|
||||
handleControlMessage(data.toString('utf8'));
|
||||
});
|
||||
socket.on('error', (error) => {
|
||||
if (controlSocket !== socket) return;
|
||||
lastError = error?.message ?? String(error);
|
||||
});
|
||||
socket.on('close', (code, reasonBuffer) => {
|
||||
if (controlSocket !== socket) return;
|
||||
controlSocket = null;
|
||||
const reason = reasonBuffer ? reasonBuffer.toString('utf8') : '';
|
||||
if (!lastError && code && code !== 1000) {
|
||||
lastError = `control socket closed (${code}${reason ? `: ${reason}` : ''})`;
|
||||
}
|
||||
// Data sockets ride their own relay connections; the relay keeps clients
|
||||
// alive through a 30 s control-reconnect grace window, so leave them up.
|
||||
scheduleReconnect();
|
||||
});
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
for (const connectionId of [...dataSockets.keys()]) {
|
||||
teardownDataSocket(connectionId, 1001, 'host stopping');
|
||||
}
|
||||
const socket = controlSocket;
|
||||
controlSocket = null;
|
||||
if (socket) {
|
||||
try {
|
||||
socket.close(1001, 'host stopping');
|
||||
} catch {
|
||||
socket.terminate();
|
||||
}
|
||||
}
|
||||
setState('disabled');
|
||||
};
|
||||
|
||||
connectControl();
|
||||
|
||||
return {
|
||||
stop,
|
||||
getStatus: () => ({ state, lastError, connectedClients: dataSockets.size }),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
// Integration test: fake relay (minimal Layer 1) + real host-client + a scripted
|
||||
// client using the JS e2ee initiator. Verifies the full handshake and a tunneled
|
||||
// HTTP GET /health, and asserts only binary frames cross the relay post-handshake.
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
||||
import http from 'node:http';
|
||||
import crypto from 'node:crypto';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
|
||||
import { startRelayHost } from './host-client.js';
|
||||
import {
|
||||
bytesToBase64Url,
|
||||
createFrameDecryptor,
|
||||
createFrameEncryptor,
|
||||
deriveSessionKeys,
|
||||
exportPublicKeyJwk,
|
||||
generateEcdhKeyPair,
|
||||
generateHandshakeNonce,
|
||||
importEcdhPrivateKey,
|
||||
RELAY_PROTOCOL_VERSION,
|
||||
} from './e2ee.js';
|
||||
import {
|
||||
TunnelFrameType,
|
||||
decodeTunnelFrame,
|
||||
encodeJsonPayload,
|
||||
encodeTunnelFrame,
|
||||
} from './tunnel-codec.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake relay: routes host-control <-> host-data <-> client by (serverId, connectionId).
|
||||
// Forwards frames verbatim, never inspects them.
|
||||
// ---------------------------------------------------------------------------
|
||||
const startFakeRelay = () => {
|
||||
const server = http.createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
const state = {
|
||||
control: null,
|
||||
hostData: new Map(), // connectionId -> ws
|
||||
clients: new Map(), // connectionId -> ws
|
||||
buffered: new Map(), // connectionId -> [[data, isBinary]] awaiting host-data
|
||||
relayFrames: [], // observed forwarded frames (for plaintext assertions)
|
||||
};
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const role = url.searchParams.get('role');
|
||||
const connectionId = url.searchParams.get('connectionId');
|
||||
|
||||
if (role === 'host-control') {
|
||||
state.control = ws;
|
||||
// Announce any already-waiting clients.
|
||||
ws.send(JSON.stringify({ type: 'sync', connectionIds: [...state.clients.keys()] }));
|
||||
for (const id of state.clients.keys()) {
|
||||
ws.send(JSON.stringify({ type: 'connected', connectionId: id }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === 'host-data') {
|
||||
state.hostData.set(connectionId, ws);
|
||||
// Flush any client frames that arrived before this socket attached.
|
||||
const buffered = state.buffered.get(connectionId) || [];
|
||||
state.buffered.delete(connectionId);
|
||||
for (const [data, isBinary] of buffered) ws.send(data, { binary: isBinary });
|
||||
ws.on('message', (data, isBinary) => {
|
||||
state.relayFrames.push({ from: 'host', isBinary });
|
||||
const client = state.clients.get(connectionId);
|
||||
if (client && client.readyState === WebSocket.OPEN) client.send(data, { binary: isBinary });
|
||||
});
|
||||
ws.on('close', () => state.hostData.delete(connectionId));
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === 'client') {
|
||||
state.clients.set(connectionId, ws);
|
||||
ws.on('message', (data, isBinary) => {
|
||||
state.relayFrames.push({ from: 'client', isBinary });
|
||||
const host = state.hostData.get(connectionId);
|
||||
if (host && host.readyState === WebSocket.OPEN) {
|
||||
host.send(data, { binary: isBinary });
|
||||
} else {
|
||||
const queue = state.buffered.get(connectionId) || [];
|
||||
queue.push([data, isBinary]);
|
||||
state.buffered.set(connectionId, queue);
|
||||
}
|
||||
});
|
||||
ws.on('close', () => state.clients.delete(connectionId));
|
||||
if (state.control && state.control.readyState === WebSocket.OPEN) {
|
||||
state.control.send(JSON.stringify({ type: 'connected', connectionId }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const port = server.address().port;
|
||||
resolve({
|
||||
wsUrl: `ws://127.0.0.1:${port}`,
|
||||
state,
|
||||
stop: () => new Promise((r) => {
|
||||
wss.close();
|
||||
server.close(() => r());
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// A stub loopback origin serving /health.
|
||||
const startLoopbackOrigin = () =>
|
||||
new Promise((resolve) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/health') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, service: 'stub', relayConn: req.headers['x-openchamber-relay-connection'] || null }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => resolve({ port: server.address().port, stop: () => new Promise((r) => server.close(() => r())) }));
|
||||
});
|
||||
|
||||
// Build the host identity around a fresh keypair (ECDH enc key + ECDSA sign key).
|
||||
const buildIdentity = async () => {
|
||||
const enc = await generateEcdhKeyPair();
|
||||
const encPrivJwk = await globalThis.crypto.subtle.exportKey('jwk', enc.privateKey);
|
||||
const { privateKey: signPriv, publicKey: signPub } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const signPubJwk = signPub.export({ format: 'jwk' });
|
||||
const canonical = JSON.stringify({ crv: signPubJwk.crv, kty: signPubJwk.kty, x: signPubJwk.x, y: signPubJwk.y });
|
||||
const serverId = crypto.createHash('sha256').update(canonical).digest('base64url');
|
||||
return {
|
||||
serverId,
|
||||
hostEncPubJwk: await exportPublicKeyJwk(enc.publicKey),
|
||||
hostEncPrivateKey: await importEcdhPrivateKey(encPrivJwk),
|
||||
signRelayAuth: (role, connectionId) => {
|
||||
const ts = Date.now();
|
||||
const sig = crypto
|
||||
.sign('SHA256', Buffer.from(`${ts}.${serverId}.${role}.${connectionId ?? ''}`), { key: signPriv, dsaEncoding: 'ieee-p1363' })
|
||||
.toString('base64url');
|
||||
return { ts, sig, pk: Buffer.from(canonical, 'utf8').toString('base64url') };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Scripted client using the JS initiator: connects, handshakes, does a GET.
|
||||
const runScriptedClient = async ({ relayUrl, serverId, hostEncPubJwk }) => {
|
||||
const connectionId = 'conn-test-1';
|
||||
const url = new URL(`${relayUrl}/`);
|
||||
url.searchParams.set('v', String(RELAY_PROTOCOL_VERSION));
|
||||
url.searchParams.set('role', 'client');
|
||||
url.searchParams.set('serverId', serverId);
|
||||
url.searchParams.set('connectionId', connectionId);
|
||||
const ws = new WebSocket(url.toString());
|
||||
|
||||
const hostPub = await globalThis.crypto.subtle.importKey(
|
||||
'jwk',
|
||||
{ kty: hostEncPubJwk.kty, crv: hostEncPubJwk.crv, x: hostEncPubJwk.x, y: hostEncPubJwk.y, ext: true },
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
true,
|
||||
[],
|
||||
);
|
||||
const ephemeral = await generateEcdhKeyPair();
|
||||
const nonce = generateHandshakeNonce();
|
||||
|
||||
let channel = null;
|
||||
const responseChunks = [];
|
||||
let responseStatus = null;
|
||||
let resolveDone;
|
||||
const done = new Promise((resolve) => {
|
||||
resolveDone = resolve;
|
||||
});
|
||||
|
||||
ws.on('open', async () => {
|
||||
ws.send(JSON.stringify({
|
||||
t: 'hello',
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
clientPubJwk: await exportPublicKeyJwk(ephemeral.publicKey),
|
||||
nonce: bytesToBase64Url(nonce),
|
||||
}));
|
||||
});
|
||||
|
||||
// Serialize message handling: an async ws handler runs per-message tasks
|
||||
// concurrently, letting StreamEnd overtake HttpBody and trip the decryptor's
|
||||
// strict counter ordering (the production tunnel client chains decrypts).
|
||||
let processing = Promise.resolve();
|
||||
const handleMessage = async (data, isBinary) => {
|
||||
if (!isBinary) {
|
||||
const msg = JSON.parse(data.toString('utf8'));
|
||||
if (msg.t === 'ready') {
|
||||
const keys = await deriveSessionKeys(ephemeral.privateKey, hostPub, nonce);
|
||||
channel = {
|
||||
encryptor: createFrameEncryptor(keys.clientToHost),
|
||||
decryptor: createFrameDecryptor(keys.hostToClient),
|
||||
};
|
||||
// Send an HTTP GET /health over stream 1.
|
||||
const req = encodeTunnelFrame(TunnelFrameType.HttpRequest, 1, encodeJsonPayload({
|
||||
method: 'GET',
|
||||
path: '/health',
|
||||
query: '',
|
||||
headers: { accept: 'application/json' },
|
||||
}));
|
||||
ws.send(await channel.encryptor.encrypt(req), { binary: true });
|
||||
ws.send(await channel.encryptor.encrypt(encodeTunnelFrame(TunnelFrameType.StreamEnd, 1, new Uint8Array(0))), { binary: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!channel) return;
|
||||
const plaintext = await channel.decryptor.decrypt(new Uint8Array(data));
|
||||
const frame = decodeTunnelFrame(plaintext);
|
||||
if (frame.frameType === TunnelFrameType.HttpResponse) {
|
||||
responseStatus = JSON.parse(new TextDecoder().decode(frame.payload)).status;
|
||||
} else if (frame.frameType === TunnelFrameType.HttpBody) {
|
||||
responseChunks.push(frame.payload);
|
||||
} else if (frame.frameType === TunnelFrameType.StreamEnd) {
|
||||
const total = responseChunks.reduce((n, c) => n + c.length, 0);
|
||||
const body = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const c of responseChunks) {
|
||||
body.set(c, off);
|
||||
off += c.length;
|
||||
}
|
||||
resolveDone({ status: responseStatus, body: JSON.parse(new TextDecoder().decode(body)) });
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
ws.on('message', (data, isBinary) => {
|
||||
processing = processing.then(() => handleMessage(data, isBinary));
|
||||
});
|
||||
|
||||
return done;
|
||||
};
|
||||
|
||||
describe('relay host-client integration', () => {
|
||||
let relay;
|
||||
let origin;
|
||||
let host;
|
||||
|
||||
beforeAll(async () => {
|
||||
relay = await startFakeRelay();
|
||||
origin = await startLoopbackOrigin();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
host?.stop();
|
||||
await relay?.stop();
|
||||
await origin?.stop();
|
||||
});
|
||||
|
||||
it('tunnels an HTTP GET /health with only binary frames post-handshake', async () => {
|
||||
const identity = await buildIdentity();
|
||||
host = startRelayHost({
|
||||
relayUrl: `${relay.wsUrl}/`,
|
||||
identity,
|
||||
getLocalPort: () => origin.port,
|
||||
onStatus: () => {},
|
||||
logger: { warn: () => {} },
|
||||
});
|
||||
|
||||
// Give the control socket a moment to connect before the client arrives.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
const result = await runScriptedClient({
|
||||
relayUrl: relay.wsUrl,
|
||||
serverId: identity.serverId,
|
||||
hostEncPubJwk: identity.hostEncPubJwk,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.ok).toBe(true);
|
||||
expect(result.body.relayConn).toBe('conn-test-1');
|
||||
|
||||
// Every forwarded frame after the two plaintext handshake frames (client
|
||||
// hello, host ready) must be binary.
|
||||
const forwarded = relay.state.relayFrames;
|
||||
const plaintextForwarded = forwarded.filter((f) => !f.isBinary);
|
||||
expect(plaintextForwarded.length).toBe(2); // hello + ready only
|
||||
expect(forwarded.filter((f) => f.isBinary).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// Host relay identity: the EXISTING ECDSA P-256 signing keypair (shared with
|
||||
// the push relay via signing-key.js — same storage, same serverId) plus a NEW
|
||||
// long-lived ECDH P-256 encryption keypair for the E2EE channel (WebCrypto
|
||||
// keys are single-purpose, so signing and encryption keys must differ).
|
||||
// The encryption keypair is persisted as `settings.relayEncryptionKey =
|
||||
// { privateJwk, publicJwk }`, mirroring the relaySigningKey precedent.
|
||||
|
||||
import {
|
||||
canonicalPublicJwkString,
|
||||
deriveServerId,
|
||||
getOrCreateRelaySigningKeypair,
|
||||
signRelayMessage,
|
||||
} from './signing-key.js';
|
||||
import { exportPublicKeyJwk, generateEcdhKeyPair, importEcdhPrivateKey } from './e2ee.js';
|
||||
|
||||
const isJwkPair = (value) => Boolean(value && typeof value === 'object' && value.privateJwk && value.publicJwk);
|
||||
|
||||
/**
|
||||
* @param {{ crypto: typeof import('node:crypto'), readSettingsFromDiskMigrated: () => Promise<object>, writeSettingsToDisk: (settings: object) => Promise<void> }} deps
|
||||
*/
|
||||
export const createRelayIdentityRuntime = (deps) => {
|
||||
const { crypto, readSettingsFromDiskMigrated, writeSettingsToDisk } = deps;
|
||||
|
||||
let cachedIdentity = null;
|
||||
|
||||
const getOrCreateEncryptionKeypair = async () => {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const existing = settings?.relayEncryptionKey;
|
||||
if (isJwkPair(existing)) {
|
||||
return existing;
|
||||
}
|
||||
const keyPair = await generateEcdhKeyPair();
|
||||
const privateJwk = await globalThis.crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
||||
const publicJwk = await exportPublicKeyJwk(keyPair.publicKey);
|
||||
await writeSettingsToDisk({ ...settings, relayEncryptionKey: { privateJwk, publicJwk } });
|
||||
return { privateJwk, publicJwk };
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {Promise<{
|
||||
* serverId: string,
|
||||
* hostEncPubJwk: JsonWebKey,
|
||||
* hostEncPrivateKey: CryptoKey,
|
||||
* signRelayAuth: (role: string, connectionId?: string | null) => { ts: number, sig: string, pk: string },
|
||||
* }>}
|
||||
*/
|
||||
const getRelayIdentity = async () => {
|
||||
if (cachedIdentity) return cachedIdentity;
|
||||
const signing = await getOrCreateRelaySigningKeypair({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk });
|
||||
const serverId = deriveServerId({ crypto }, signing.publicJwk);
|
||||
const encryption = await getOrCreateEncryptionKeypair();
|
||||
const hostEncPrivateKey = await importEcdhPrivateKey(encryption.privateJwk);
|
||||
const pk = Buffer.from(canonicalPublicJwkString(signing.publicJwk), 'utf8').toString('base64url');
|
||||
|
||||
// Relay-layer auth for host-control / host-data upgrades. Signature payload
|
||||
// string is `${ts}.${serverId}.${role}.${connectionId ?? ""}` (spec Layer 1).
|
||||
const signRelayAuth = (role, connectionId) => {
|
||||
const ts = Date.now();
|
||||
const sig = signRelayMessage({ crypto }, signing.privateKey, `${ts}.${serverId}.${role}.${connectionId ?? ''}`);
|
||||
return { ts, sig, pk };
|
||||
};
|
||||
|
||||
cachedIdentity = {
|
||||
serverId,
|
||||
hostEncPubJwk: encryption.publicJwk,
|
||||
hostEncPrivateKey,
|
||||
signRelayAuth,
|
||||
};
|
||||
return cachedIdentity;
|
||||
};
|
||||
|
||||
return { getRelayIdentity };
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { createRelayIdentityRuntime } from './identity.js';
|
||||
import { canonicalPublicJwkString } from './signing-key.js';
|
||||
|
||||
// In-memory settings store standing in for the on-disk settings file.
|
||||
const makeSettingsStore = (initial = {}) => {
|
||||
let settings = { ...initial };
|
||||
return {
|
||||
readSettingsFromDiskMigrated: async () => ({ ...settings }),
|
||||
writeSettingsToDisk: async (next) => {
|
||||
settings = { ...next };
|
||||
},
|
||||
peek: () => settings,
|
||||
};
|
||||
};
|
||||
|
||||
describe('relay identity', () => {
|
||||
it('derives a stable serverId from the signing key and persists both keypairs', async () => {
|
||||
const store = makeSettingsStore();
|
||||
const runtime = createRelayIdentityRuntime({ crypto, ...store });
|
||||
const identity = await runtime.getRelayIdentity();
|
||||
|
||||
const stored = store.peek();
|
||||
expect(stored.relaySigningKey).toBeDefined();
|
||||
expect(stored.relayEncryptionKey).toBeDefined();
|
||||
|
||||
const expectedServerId = crypto
|
||||
.createHash('sha256')
|
||||
.update(canonicalPublicJwkString(stored.relaySigningKey.publicJwk))
|
||||
.digest('base64url');
|
||||
expect(identity.serverId).toBe(expectedServerId);
|
||||
expect(identity.hostEncPubJwk.crv).toBe('P-256');
|
||||
});
|
||||
|
||||
it('reuses an existing signing key (serverId stays stable across installs)', async () => {
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
void privateKey;
|
||||
const publicJwk = publicKey.export({ format: 'jwk' });
|
||||
const store = makeSettingsStore({
|
||||
relaySigningKey: {
|
||||
privateJwk: crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ format: 'jwk' }),
|
||||
publicJwk,
|
||||
},
|
||||
});
|
||||
// Match private to public so importing works.
|
||||
const pair = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
store.peek().relaySigningKey.privateJwk = pair.privateKey.export({ format: 'jwk' });
|
||||
store.peek().relaySigningKey.publicJwk = pair.publicKey.export({ format: 'jwk' });
|
||||
|
||||
const runtime = createRelayIdentityRuntime({ crypto, ...store });
|
||||
const identity = await runtime.getRelayIdentity();
|
||||
const expected = crypto
|
||||
.createHash('sha256')
|
||||
.update(canonicalPublicJwkString(pair.publicKey.export({ format: 'jwk' })))
|
||||
.digest('base64url');
|
||||
expect(identity.serverId).toBe(expected);
|
||||
});
|
||||
|
||||
it('produces a verifiable relay auth signature', async () => {
|
||||
const store = makeSettingsStore();
|
||||
const runtime = createRelayIdentityRuntime({ crypto, ...store });
|
||||
const identity = await runtime.getRelayIdentity();
|
||||
const { ts, sig, pk } = identity.signRelayAuth('host-control', null);
|
||||
|
||||
const canonical = Buffer.from(pk, 'base64url').toString('utf8');
|
||||
const publicJwk = JSON.parse(canonical);
|
||||
const key = crypto.createPublicKey({ key: publicJwk, format: 'jwk' });
|
||||
const ok = crypto.verify(
|
||||
'SHA256',
|
||||
Buffer.from(`${ts}.${identity.serverId}.host-control.`),
|
||||
{ key, dsaEncoding: 'ieee-p1363' },
|
||||
Buffer.from(sig, 'base64url'),
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
// Private relay service: config persistence, lifecycle of the relay host
|
||||
// client, and the /api/openchamber/relay/* management routes.
|
||||
//
|
||||
// Config lives in the server settings file as `settings.privateRelay =
|
||||
// { enabled, relayUrl }` (same storage precedent as tunnels/notifications).
|
||||
// Routes are registered with the other OpenChamber feature routes, before the
|
||||
// generic OpenCode proxy, and are covered by the same global UI auth gate.
|
||||
//
|
||||
// Cross-runtime parity note: relay host mode intentionally targets the web
|
||||
// server runtime only in v1 (Electron shares this server in-process). The VS
|
||||
// Code runtime does not host a relay; shared UI must treat these routes as
|
||||
// web-runtime capabilities.
|
||||
|
||||
import express from 'express';
|
||||
|
||||
import { createRelayIdentityRuntime } from './identity.js';
|
||||
import { startRelayHost } from './host-client.js';
|
||||
import { bytesToBase64Url } from './e2ee.js';
|
||||
|
||||
export const DEFAULT_RELAY_URL = 'wss://relay.openchamber.dev/ws';
|
||||
|
||||
const isValidRelayUrl = (value) => {
|
||||
if (typeof value !== 'string') return false;
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
return url.protocol === 'ws:' || url.protocol === 'wss:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeRelayUrl = (value) => {
|
||||
if (typeof value !== 'string') return DEFAULT_RELAY_URL;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || !isValidRelayUrl(trimmed)) return DEFAULT_RELAY_URL;
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
// A deployment can pin the relay endpoint via env (e.g. a self-hosted relay on
|
||||
// your own Cloudflare account/domain). When set and valid it overrides the
|
||||
// stored setting entirely, so the host connection, the pairing offer, and the
|
||||
// status all point at it — clients then inherit it from the offer automatically.
|
||||
const envRelayUrlOverride = () => {
|
||||
const raw = process.env.OPENCHAMBER_RELAY_URL;
|
||||
if (typeof raw !== 'string' || !raw.trim() || !isValidRelayUrl(raw)) return null;
|
||||
return raw.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* crypto: typeof import('node:crypto'),
|
||||
* os: typeof import('node:os'),
|
||||
* readSettingsFromDiskMigrated: () => Promise<object>,
|
||||
* writeSettingsToDisk: (settings: object) => Promise<void>,
|
||||
* remoteClientAuthRuntime: { createClient: (options: object) => Promise<{ client: object, token: string }> },
|
||||
* getLocalPort: () => number,
|
||||
* logger?: Pick<Console, 'warn'>,
|
||||
* }} deps
|
||||
*/
|
||||
export const createRelayService = ({
|
||||
crypto,
|
||||
os,
|
||||
readSettingsFromDiskMigrated,
|
||||
writeSettingsToDisk,
|
||||
remoteClientAuthRuntime,
|
||||
getLocalPort,
|
||||
logger = console,
|
||||
}) => {
|
||||
const identityRuntime = createRelayIdentityRuntime({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk });
|
||||
|
||||
let hostClient = null;
|
||||
let status = { state: 'disabled', lastError: null, connectedClients: 0 };
|
||||
|
||||
const readConfig = async () => {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const stored = settings?.privateRelay;
|
||||
const override = envRelayUrlOverride();
|
||||
return {
|
||||
enabled: stored?.enabled === true,
|
||||
relayUrl: override ?? normalizeRelayUrl(stored?.relayUrl),
|
||||
// True when the endpoint is pinned by OPENCHAMBER_RELAY_URL (a self-hosted
|
||||
// relay); the stored setting is ignored while it is set.
|
||||
relayUrlLocked: override !== null,
|
||||
};
|
||||
};
|
||||
|
||||
const writeConfig = async (config) => {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
await writeSettingsToDisk({
|
||||
...settings,
|
||||
privateRelay: { enabled: config.enabled === true, relayUrl: normalizeRelayUrl(config.relayUrl) },
|
||||
});
|
||||
};
|
||||
|
||||
const start = async (relayUrl) => {
|
||||
if (hostClient) return;
|
||||
const identity = await identityRuntime.getRelayIdentity();
|
||||
hostClient = startRelayHost({
|
||||
relayUrl,
|
||||
identity,
|
||||
getLocalPort,
|
||||
logger,
|
||||
onStatus: (next) => {
|
||||
status = next;
|
||||
},
|
||||
});
|
||||
status = hostClient.getStatus();
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (!hostClient) return;
|
||||
hostClient.stop();
|
||||
hostClient = null;
|
||||
status = { state: 'disabled', lastError: null, connectedClients: 0 };
|
||||
};
|
||||
|
||||
const startIfEnabled = async () => {
|
||||
try {
|
||||
const config = await readConfig();
|
||||
if (config.enabled) {
|
||||
await start(config.relayUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[Relay] startup failed: ${error?.message ?? error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatus = async () => {
|
||||
const config = await readConfig();
|
||||
const identity = await identityRuntime.getRelayIdentity();
|
||||
const live = hostClient ? hostClient.getStatus() : status;
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
state: hostClient ? live.state : 'disabled',
|
||||
serverId: identity.serverId,
|
||||
connectedClients: live.connectedClients,
|
||||
relayUrl: config.relayUrl,
|
||||
relayUrlLocked: config.relayUrlLocked,
|
||||
...(live.lastError ? { lastError: live.lastError } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const buildOffer = async ({ includeToken = false, clientLabel } = {}) => {
|
||||
const config = await readConfig();
|
||||
const identity = await identityRuntime.getRelayIdentity();
|
||||
const offer = {
|
||||
v: 1,
|
||||
mode: 'relay',
|
||||
relayUrl: config.relayUrl,
|
||||
serverId: identity.serverId,
|
||||
hostEncPubJwk: identity.hostEncPubJwk,
|
||||
label: os.hostname(),
|
||||
};
|
||||
if (includeToken) {
|
||||
const label = typeof clientLabel === 'string' && clientLabel.trim().length > 0
|
||||
? clientLabel.trim()
|
||||
: 'Relay client';
|
||||
const { token } = await remoteClientAuthRuntime.createClient({ label, clientKind: 'relay' });
|
||||
offer.token = token;
|
||||
}
|
||||
const encoded = bytesToBase64Url(new TextEncoder().encode(JSON.stringify(offer)));
|
||||
return {
|
||||
offer,
|
||||
url: `openchamber://connect?v=1&mode=relay#offer=${encoded}`,
|
||||
};
|
||||
};
|
||||
|
||||
const registerRoutes = (app) => {
|
||||
app.get('/api/openchamber/relay/status', async (_req, res) => {
|
||||
try {
|
||||
res.json(await getStatus());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message ?? 'Failed to read relay status' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/openchamber/relay/enable', express.json({ limit: '16kb' }), async (req, res) => {
|
||||
try {
|
||||
const current = await readConfig();
|
||||
const relayUrl = typeof req.body?.relayUrl === 'string' ? normalizeRelayUrl(req.body.relayUrl) : current.relayUrl;
|
||||
await writeConfig({ enabled: true, relayUrl });
|
||||
if (hostClient) stop();
|
||||
await start(relayUrl);
|
||||
res.json(await getStatus());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message ?? 'Failed to enable relay' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/openchamber/relay/disable', async (_req, res) => {
|
||||
try {
|
||||
const current = await readConfig();
|
||||
await writeConfig({ enabled: false, relayUrl: current.relayUrl });
|
||||
stop();
|
||||
res.json(await getStatus());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message ?? 'Failed to disable relay' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/openchamber/relay/offer', express.json({ limit: '16kb' }), async (req, res) => {
|
||||
try {
|
||||
const result = await buildOffer({
|
||||
includeToken: req.body?.includeToken === true,
|
||||
clientLabel: req.body?.clientLabel,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error?.message ?? 'Failed to build relay offer' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
registerRoutes,
|
||||
startIfEnabled,
|
||||
stop,
|
||||
getStatus,
|
||||
buildOffer,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
// Per-server relay signing identity (ECDSA P-256), extracted from
|
||||
// lib/notifications/apns-runtime.js so both the push relay and the private
|
||||
// relay share the SAME keypair and thus the SAME serverId
|
||||
// (base64url(SHA-256(canonical public JWK))). Storage format is unchanged:
|
||||
// `settings.relaySigningKey = { privateJwk, publicJwk }` — existing installs'
|
||||
// serverId must stay stable because push token binding depends on it.
|
||||
|
||||
/**
|
||||
* @param {{ crypto: typeof import('node:crypto'), readSettingsFromDiskMigrated: () => Promise<object>, writeSettingsToDisk: (settings: object) => Promise<void> }} deps
|
||||
* @returns {Promise<{ privateKey: import('node:crypto').KeyObject, publicJwk: JsonWebKey }>}
|
||||
*/
|
||||
export const getOrCreateRelaySigningKeypair = async ({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk }) => {
|
||||
const settings = await readSettingsFromDiskMigrated();
|
||||
const existing = settings?.relaySigningKey;
|
||||
if (existing && existing.privateJwk && existing.publicJwk) {
|
||||
return {
|
||||
privateKey: crypto.createPrivateKey({ key: existing.privateJwk, format: 'jwk' }),
|
||||
publicJwk: existing.publicJwk,
|
||||
};
|
||||
}
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const privateJwk = privateKey.export({ format: 'jwk' });
|
||||
const publicJwk = publicKey.export({ format: 'jwk' });
|
||||
await writeSettingsToDisk({ ...settings, relaySigningKey: { privateJwk, publicJwk } });
|
||||
return { privateKey, publicJwk };
|
||||
};
|
||||
|
||||
// Fixed key order so the hash is stable regardless of stored JSON field order.
|
||||
// Byte-for-byte mirror of canonicalJwk in openchamber-website apps/api relay-auth.ts.
|
||||
/** @param {JsonWebKey} jwk */
|
||||
export const canonicalPublicJwkString = (jwk) =>
|
||||
JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
|
||||
|
||||
/**
|
||||
* serverId = base64url(SHA-256(canonical public JWK)). Must match the push
|
||||
* relay's deriveServerId — this id is the routing key for both relays.
|
||||
* @param {{ crypto: typeof import('node:crypto') }} deps
|
||||
* @param {JsonWebKey} publicJwk
|
||||
*/
|
||||
export const deriveServerId = ({ crypto }, publicJwk) =>
|
||||
crypto.createHash('sha256').update(canonicalPublicJwkString(publicJwk)).digest('base64url');
|
||||
|
||||
/**
|
||||
* ECDSA-SHA256, IEEE P1363 (raw r||s) signature — the form WebCrypto verifies.
|
||||
* @param {{ crypto: typeof import('node:crypto') }} deps
|
||||
* @param {import('node:crypto').KeyObject} privateKey
|
||||
* @param {string} message
|
||||
*/
|
||||
export const signRelayMessage = ({ crypto }, privateKey, message) =>
|
||||
crypto.sign('SHA256', Buffer.from(message), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url');
|
||||
@@ -0,0 +1,373 @@
|
||||
// Tunnel mux frame codec (Layer 3 of the protocol spec). Pure functions, no I/O.
|
||||
// JS mirror of packages/ui/src/lib/relay/tunnel-codec.ts (+ the Layer 3
|
||||
// constants from protocol.ts) — MUST stay byte-compatible with those modules.
|
||||
// Frame layout: [1 byte frameType (high bit = fragment-continues)][4 byte BE streamId][payload].
|
||||
// Client-initiated streams use odd streamIds starting at 1; even ids are reserved.
|
||||
// Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 3).
|
||||
|
||||
import { MAX_PLAINTEXT_FRAME_BYTES } from './e2ee.js';
|
||||
|
||||
|
||||
export const TUNNEL_FRAME_HEADER_BYTES = 5;
|
||||
export const TUNNEL_FRAGMENT_FLAG = 0x80;
|
||||
|
||||
// Batch envelope container (mirror of protocol.ts). Only used when both peers
|
||||
// negotiated `batch`. Reserve the per-frame envelope overhead from the payload
|
||||
// budget so any single frame still fits one 64 KiB encrypted plaintext.
|
||||
export const BATCH_CONTAINER_TAG_SINGLE = 0x00;
|
||||
export const BATCH_CONTAINER_TAG_BATCH = 0x01;
|
||||
export const BATCH_FRAME_LENGTH_BYTES = 4;
|
||||
export const BATCH_ENVELOPE_RESERVED_BYTES = 1 + BATCH_FRAME_LENGTH_BYTES;
|
||||
export const MAX_TUNNEL_PAYLOAD_BYTES =
|
||||
MAX_PLAINTEXT_FRAME_BYTES - TUNNEL_FRAME_HEADER_BYTES - BATCH_ENVELOPE_RESERVED_BYTES;
|
||||
|
||||
export const TunnelFrameType = {
|
||||
HttpRequest: 1,
|
||||
HttpBody: 2,
|
||||
HttpResponse: 3,
|
||||
StreamEnd: 4,
|
||||
StreamAbort: 5,
|
||||
WsOpen: 6,
|
||||
WsOpened: 7,
|
||||
WsText: 8,
|
||||
WsBinary: 9,
|
||||
WsClose: 10,
|
||||
Ping: 11,
|
||||
Pong: 12,
|
||||
};
|
||||
|
||||
const TUNNEL_FRAME_TYPE_VALUES = new Set(Object.values(TunnelFrameType));
|
||||
|
||||
/** @param {number} value */
|
||||
export const isTunnelFrameType = (value) => TUNNEL_FRAME_TYPE_VALUES.has(value);
|
||||
|
||||
const MAX_STREAM_ID = 0xffffffff;
|
||||
|
||||
export class TunnelCodecError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'TunnelCodecError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} frameType
|
||||
* @param {number} streamId
|
||||
* @param {Uint8Array} payload
|
||||
* @param {boolean} [hasMoreFragments]
|
||||
*/
|
||||
export const encodeTunnelFrame = (frameType, streamId, payload, hasMoreFragments = false) => {
|
||||
if (!Number.isInteger(streamId) || streamId < 0 || streamId > MAX_STREAM_ID) {
|
||||
throw new TunnelCodecError('invalid stream id');
|
||||
}
|
||||
if (payload.length > MAX_TUNNEL_PAYLOAD_BYTES) {
|
||||
throw new TunnelCodecError('tunnel payload exceeds maximum size');
|
||||
}
|
||||
const frame = new Uint8Array(TUNNEL_FRAME_HEADER_BYTES + payload.length);
|
||||
frame[0] = hasMoreFragments ? frameType | TUNNEL_FRAGMENT_FLAG : frameType;
|
||||
frame[1] = (streamId >>> 24) & 0xff;
|
||||
frame[2] = (streamId >>> 16) & 0xff;
|
||||
frame[3] = (streamId >>> 8) & 0xff;
|
||||
frame[4] = streamId & 0xff;
|
||||
frame.set(payload, TUNNEL_FRAME_HEADER_BYTES);
|
||||
return frame;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} frame
|
||||
* @returns {{ frameType: number, streamId: number, payload: Uint8Array, hasMoreFragments: boolean }}
|
||||
*/
|
||||
export const decodeTunnelFrame = (frame) => {
|
||||
if (frame.length < TUNNEL_FRAME_HEADER_BYTES) {
|
||||
throw new TunnelCodecError('tunnel frame too short');
|
||||
}
|
||||
const rawType = frame[0];
|
||||
const hasMoreFragments = (rawType & TUNNEL_FRAGMENT_FLAG) !== 0;
|
||||
const frameType = rawType & ~TUNNEL_FRAGMENT_FLAG;
|
||||
if (!isTunnelFrameType(frameType)) {
|
||||
throw new TunnelCodecError(`unknown tunnel frame type ${frameType}`);
|
||||
}
|
||||
const streamId = ((frame[1] << 24) | (frame[2] << 16) | (frame[3] << 8) | frame[4]) >>> 0;
|
||||
return {
|
||||
frameType,
|
||||
streamId,
|
||||
payload: frame.slice(TUNNEL_FRAME_HEADER_BYTES),
|
||||
hasMoreFragments,
|
||||
};
|
||||
};
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
/** @param {unknown} value */
|
||||
export const encodeJsonPayload = (value) => textEncoder.encode(JSON.stringify(value));
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} payload
|
||||
* @param {(parsed: unknown) => boolean} validate
|
||||
*/
|
||||
export const decodeJsonPayload = (payload, validate) => {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(textDecoder.decode(payload));
|
||||
} catch {
|
||||
throw new TunnelCodecError('malformed JSON tunnel payload');
|
||||
}
|
||||
if (!validate(parsed)) {
|
||||
throw new TunnelCodecError('unexpected JSON tunnel payload shape');
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Split a body/message into payload-sized chunks. Empty input yields one empty chunk.
|
||||
* @param {Uint8Array} bytes
|
||||
* @param {number} [chunkSize]
|
||||
*/
|
||||
export const chunkPayload = (bytes, chunkSize = MAX_TUNNEL_PAYLOAD_BYTES) => {
|
||||
if (chunkSize <= 0 || chunkSize > MAX_TUNNEL_PAYLOAD_BYTES) {
|
||||
throw new TunnelCodecError('invalid chunk size');
|
||||
}
|
||||
if (bytes.length === 0) return [new Uint8Array(0)];
|
||||
const chunks = [];
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
chunks.push(bytes.slice(offset, offset + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encode one logical message as one or more frames, setting the fragment flag
|
||||
* on all but the last. Used for WS messages that exceed the frame budget.
|
||||
* @param {number} frameType
|
||||
* @param {number} streamId
|
||||
* @param {Uint8Array} payload
|
||||
*/
|
||||
export const encodeFragmentedMessage = (frameType, streamId, payload) => {
|
||||
const chunks = chunkPayload(payload);
|
||||
return chunks.map((chunk, index) => encodeTunnelFrame(frameType, streamId, chunk, index < chunks.length - 1));
|
||||
};
|
||||
|
||||
/**
|
||||
* Reassembles fragmented messages per (streamId, frameType). Bounded to protect memory.
|
||||
* @param {number} [maxMessageBytes]
|
||||
*/
|
||||
export const createFragmentAssembler = (maxMessageBytes = 16 * 1024 * 1024) => {
|
||||
const pending = new Map();
|
||||
return {
|
||||
/**
|
||||
* Returns the complete message payload once all fragments arrived, or null
|
||||
* while more fragments are expected.
|
||||
* @param {{ frameType: number, streamId: number, payload: Uint8Array, hasMoreFragments: boolean }} frame
|
||||
*/
|
||||
push(frame) {
|
||||
const key = `${frame.streamId}:${frame.frameType}`;
|
||||
const entry = pending.get(key);
|
||||
if (!frame.hasMoreFragments && !entry) {
|
||||
return frame.payload;
|
||||
}
|
||||
const chunks = entry?.chunks ?? [];
|
||||
const totalBytes = (entry?.totalBytes ?? 0) + frame.payload.length;
|
||||
if (totalBytes > maxMessageBytes) {
|
||||
pending.delete(key);
|
||||
throw new TunnelCodecError('fragmented message exceeds maximum size');
|
||||
}
|
||||
chunks.push(frame.payload);
|
||||
if (frame.hasMoreFragments) {
|
||||
pending.set(key, { chunks, totalBytes });
|
||||
return null;
|
||||
}
|
||||
pending.delete(key);
|
||||
const message = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
message.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return message;
|
||||
},
|
||||
/** @param {number} streamId */
|
||||
dropStream(streamId) {
|
||||
for (const key of pending.keys()) {
|
||||
if (key.startsWith(`${streamId}:`)) pending.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Batch envelope encoder (mirror of tunnel-codec.ts encodeFrameBatch). Only used
|
||||
* when both peers negotiated `batch`. One encrypted WS message still equals one
|
||||
* encrypt() call — this only changes how many tunnel frames it carries.
|
||||
* @param {Uint8Array[]} frames
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export const encodeFrameBatch = (frames) => {
|
||||
if (frames.length === 0) {
|
||||
throw new TunnelCodecError('cannot encode an empty frame batch');
|
||||
}
|
||||
if (frames.length === 1) {
|
||||
const frame = frames[0];
|
||||
const out = new Uint8Array(1 + frame.length);
|
||||
out[0] = BATCH_CONTAINER_TAG_SINGLE;
|
||||
out.set(frame, 1);
|
||||
if (out.length > MAX_PLAINTEXT_FRAME_BYTES) {
|
||||
throw new TunnelCodecError('frame batch exceeds maximum plaintext size');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let total = 1;
|
||||
for (const frame of frames) total += BATCH_FRAME_LENGTH_BYTES + frame.length;
|
||||
if (total > MAX_PLAINTEXT_FRAME_BYTES) {
|
||||
throw new TunnelCodecError('frame batch exceeds maximum plaintext size');
|
||||
}
|
||||
const out = new Uint8Array(total);
|
||||
out[0] = BATCH_CONTAINER_TAG_BATCH;
|
||||
let offset = 1;
|
||||
for (const frame of frames) {
|
||||
out[offset] = (frame.length >>> 24) & 0xff;
|
||||
out[offset + 1] = (frame.length >>> 16) & 0xff;
|
||||
out[offset + 2] = (frame.length >>> 8) & 0xff;
|
||||
out[offset + 3] = frame.length & 0xff;
|
||||
offset += BATCH_FRAME_LENGTH_BYTES;
|
||||
out.set(frame, offset);
|
||||
offset += frame.length;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes a batch-envelope plaintext into its ordered tunnel frames.
|
||||
* @param {Uint8Array} plaintext
|
||||
* @returns {Uint8Array[]}
|
||||
*/
|
||||
export const decodeFrameBatch = (plaintext) => {
|
||||
if (plaintext.length < 1) {
|
||||
throw new TunnelCodecError('empty batch plaintext');
|
||||
}
|
||||
const tag = plaintext[0];
|
||||
if (tag === BATCH_CONTAINER_TAG_SINGLE) {
|
||||
return [plaintext.slice(1)];
|
||||
}
|
||||
if (tag !== BATCH_CONTAINER_TAG_BATCH) {
|
||||
throw new TunnelCodecError(`unknown batch container tag ${tag}`);
|
||||
}
|
||||
const frames = [];
|
||||
let offset = 1;
|
||||
while (offset < plaintext.length) {
|
||||
if (offset + BATCH_FRAME_LENGTH_BYTES > plaintext.length) {
|
||||
throw new TunnelCodecError('truncated batch frame length');
|
||||
}
|
||||
const length =
|
||||
((plaintext[offset] << 24)
|
||||
| (plaintext[offset + 1] << 16)
|
||||
| (plaintext[offset + 2] << 8)
|
||||
| plaintext[offset + 3]) >>> 0;
|
||||
offset += BATCH_FRAME_LENGTH_BYTES;
|
||||
if (offset + length > plaintext.length) {
|
||||
throw new TunnelCodecError('truncated batch frame body');
|
||||
}
|
||||
frames.push(plaintext.slice(offset, offset + length));
|
||||
offset += length;
|
||||
}
|
||||
if (frames.length === 0) {
|
||||
throw new TunnelCodecError('empty frame batch');
|
||||
}
|
||||
return frames;
|
||||
};
|
||||
|
||||
// Only high-volume body/stream data is buffered; setup/teardown/keepalive frames
|
||||
// flush immediately so TTFT, terminal echo, and liveness stay snappy.
|
||||
const BUFFERED_FRAME_TYPES = new Set([
|
||||
TunnelFrameType.HttpBody,
|
||||
TunnelFrameType.WsText,
|
||||
TunnelFrameType.WsBinary,
|
||||
]);
|
||||
|
||||
// See the TS mirror (tunnel-codec.ts) for the 150ms rationale: the chat render pipeline's
|
||||
// 100ms input throttle + ~64ms paced-reveal smoothing make a 150ms batch window invisible.
|
||||
export const DEFAULT_BATCH_WINDOW_MS = 150;
|
||||
export const DEFAULT_BATCH_MAX_BYTES = 24 * 1024;
|
||||
export const DEFAULT_BATCH_MAX_FRAMES = 32;
|
||||
|
||||
/**
|
||||
* Outbound batching buffer (mirror of tunnel-codec.ts createOutboundFrameBatcher).
|
||||
* @param {{
|
||||
* windowMs?: number,
|
||||
* maxBatchBytes?: number,
|
||||
* maxBatchFrames?: number,
|
||||
* sendBatch: (plaintext: Uint8Array) => void,
|
||||
* now?: () => number,
|
||||
* setTimer?: (fn: () => void, ms: number) => any,
|
||||
* clearTimer?: (handle: any) => void,
|
||||
* }} options
|
||||
*/
|
||||
export const createOutboundFrameBatcher = (options) => {
|
||||
const windowMs = options.windowMs ?? DEFAULT_BATCH_WINDOW_MS;
|
||||
const maxBatchBytes = options.maxBatchBytes ?? DEFAULT_BATCH_MAX_BYTES;
|
||||
const maxBatchFrames = options.maxBatchFrames ?? DEFAULT_BATCH_MAX_FRAMES;
|
||||
const now = options.now ?? (() => Date.now());
|
||||
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
||||
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
||||
|
||||
let buffer = [];
|
||||
let bufferedBytes = 0;
|
||||
let timer = null;
|
||||
let lastFlushAt = 0;
|
||||
let disposed = false;
|
||||
|
||||
const clearPendingTimer = () => {
|
||||
if (timer !== null) {
|
||||
clearTimer(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
clearPendingTimer();
|
||||
if (buffer.length === 0) return;
|
||||
const frames = buffer;
|
||||
buffer = [];
|
||||
bufferedBytes = 0;
|
||||
lastFlushAt = now();
|
||||
options.sendBatch(encodeFrameBatch(frames));
|
||||
};
|
||||
|
||||
const enqueue = (frame) => {
|
||||
if (disposed) return;
|
||||
const frameType = frame[0] & ~TUNNEL_FRAGMENT_FLAG;
|
||||
if (!BUFFERED_FRAME_TYPES.has(frameType)) {
|
||||
buffer.push(frame);
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
const at = now();
|
||||
if (buffer.length === 0 && at - lastFlushAt >= windowMs) {
|
||||
buffer.push(frame);
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
const frameCost = BATCH_FRAME_LENGTH_BYTES + frame.length;
|
||||
if (buffer.length > 0 && 1 + bufferedBytes + frameCost > MAX_PLAINTEXT_FRAME_BYTES) {
|
||||
flush();
|
||||
}
|
||||
buffer.push(frame);
|
||||
bufferedBytes += frameCost;
|
||||
if (bufferedBytes >= maxBatchBytes || buffer.length >= maxBatchFrames) {
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
if (timer === null) timer = setTimer(flush, windowMs);
|
||||
};
|
||||
|
||||
return {
|
||||
enqueue,
|
||||
flush,
|
||||
dispose() {
|
||||
disposed = true;
|
||||
clearPendingTimer();
|
||||
buffer = [];
|
||||
bufferedBytes = 0;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import {
|
||||
TunnelCodecError,
|
||||
TunnelFrameType,
|
||||
createFragmentAssembler,
|
||||
decodeTunnelFrame,
|
||||
encodeFragmentedMessage,
|
||||
encodeTunnelFrame,
|
||||
MAX_TUNNEL_PAYLOAD_BYTES,
|
||||
} from './tunnel-codec.js';
|
||||
|
||||
describe('relay tunnel codec', () => {
|
||||
it('round-trips a frame', () => {
|
||||
const payload = new TextEncoder().encode('hello tunnel');
|
||||
const frame = encodeTunnelFrame(TunnelFrameType.HttpRequest, 7, payload);
|
||||
const decoded = decodeTunnelFrame(frame);
|
||||
expect(decoded.frameType).toBe(TunnelFrameType.HttpRequest);
|
||||
expect(decoded.streamId).toBe(7);
|
||||
expect(decoded.hasMoreFragments).toBe(false);
|
||||
expect(new TextDecoder().decode(decoded.payload)).toBe('hello tunnel');
|
||||
});
|
||||
|
||||
it('preserves large stream ids without sign issues', () => {
|
||||
const frame = encodeTunnelFrame(TunnelFrameType.HttpBody, 0xfffffffd, new Uint8Array(0));
|
||||
expect(decodeTunnelFrame(frame).streamId).toBe(0xfffffffd);
|
||||
});
|
||||
|
||||
it('rejects truncated and unknown frames', () => {
|
||||
expect(() => decodeTunnelFrame(new Uint8Array([1, 2]))).toThrow(TunnelCodecError);
|
||||
expect(() => decodeTunnelFrame(new Uint8Array([99, 0, 0, 0, 1]))).toThrow(TunnelCodecError);
|
||||
});
|
||||
|
||||
it('fragments and reassembles oversized messages', () => {
|
||||
const big = new Uint8Array(MAX_TUNNEL_PAYLOAD_BYTES * 2 + 10);
|
||||
for (let i = 0; i < big.length; i += 1) big[i] = i & 0xff;
|
||||
const frames = encodeFragmentedMessage(TunnelFrameType.WsBinary, 3, big);
|
||||
expect(frames.length).toBe(3);
|
||||
|
||||
const assembler = createFragmentAssembler();
|
||||
let result = null;
|
||||
for (const frame of frames) {
|
||||
result = assembler.push(decodeTunnelFrame(frame));
|
||||
}
|
||||
expect(result).not.toBeNull();
|
||||
expect(Array.from(result)).toEqual(Array.from(big));
|
||||
});
|
||||
|
||||
it('bounds fragment reassembly memory', () => {
|
||||
const assembler = createFragmentAssembler(MAX_TUNNEL_PAYLOAD_BYTES + 1);
|
||||
const chunk = new Uint8Array(MAX_TUNNEL_PAYLOAD_BYTES);
|
||||
// First fragment fits, second pushes past the cap.
|
||||
assembler.push({ frameType: TunnelFrameType.WsText, streamId: 1, payload: chunk, hasMoreFragments: true });
|
||||
expect(() =>
|
||||
assembler.push({ frameType: TunnelFrameType.WsText, streamId: 1, payload: chunk, hasMoreFragments: true }),
|
||||
).toThrow(TunnelCodecError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
// Host side of the tunnel mux (Layer 3): consumes decrypted tunnel frames for
|
||||
// ONE relay connection and dispatches them to the local loopback origin.
|
||||
// HTTP streams -> fetch http://127.0.0.1:<port> with streamed duplex bodies;
|
||||
// WS streams -> `ws` client to the loopback WebSocket endpoints.
|
||||
// The dispatcher NEVER injects credentials: tunneled requests authenticate
|
||||
// exactly like any remote client (bearer oc_client_* header, oc_url_token query).
|
||||
// Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 3).
|
||||
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
import {
|
||||
MAX_TUNNEL_PAYLOAD_BYTES,
|
||||
TunnelFrameType,
|
||||
chunkPayload,
|
||||
createFragmentAssembler,
|
||||
decodeJsonPayload,
|
||||
decodeTunnelFrame,
|
||||
encodeFragmentedMessage,
|
||||
encodeJsonPayload,
|
||||
encodeTunnelFrame,
|
||||
} from './tunnel-codec.js';
|
||||
|
||||
// Path allowlists (defense in depth; same families realtime-proxy.js allows).
|
||||
const isAllowedHttpPath = (pathname) =>
|
||||
pathname === '/health'
|
||||
|| pathname === '/api'
|
||||
|| pathname.startsWith('/api/')
|
||||
|| pathname === '/auth'
|
||||
|| pathname.startsWith('/auth/');
|
||||
|
||||
const ALLOWED_WS_PATHS = new Set([
|
||||
'/api/global/event/ws',
|
||||
'/api/event/ws',
|
||||
'/api/terminal/ws',
|
||||
'/api/dictation/ws',
|
||||
]);
|
||||
|
||||
// Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch
|
||||
// to the loopback origin. content-length is dropped too because the body is
|
||||
// re-chunked through the tunnel and undici computes framing itself.
|
||||
const STRIPPED_REQUEST_HEADERS = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
'host',
|
||||
'content-length',
|
||||
]);
|
||||
|
||||
// Response framing headers that no longer apply once the body crosses the
|
||||
// tunnel as HttpBody chunks (loopback fetch already decoded content-encoding).
|
||||
const STRIPPED_RESPONSE_HEADERS = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'transfer-encoding',
|
||||
'content-length',
|
||||
'content-encoding',
|
||||
]);
|
||||
|
||||
// v1 backpressure rule: pause reading the loopback source while the outbound
|
||||
// relay socket has more than this buffered.
|
||||
const BACKPRESSURE_LIMIT_BYTES = 4 * 1024 * 1024;
|
||||
const BACKPRESSURE_POLL_MS = 20;
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const isHttpRequestPayload = (parsed) =>
|
||||
Boolean(parsed && typeof parsed === 'object'
|
||||
&& typeof parsed.method === 'string'
|
||||
&& typeof parsed.path === 'string'
|
||||
&& typeof parsed.query === 'string'
|
||||
&& parsed.headers && typeof parsed.headers === 'object');
|
||||
|
||||
const isWsOpenPayload = (parsed) =>
|
||||
Boolean(parsed && typeof parsed === 'object'
|
||||
&& typeof parsed.path === 'string'
|
||||
&& typeof parsed.query === 'string'
|
||||
&& (parsed.protocols === undefined || Array.isArray(parsed.protocols)));
|
||||
|
||||
const isWsClosePayload = (parsed) => Boolean(parsed && typeof parsed === 'object');
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* connectionId: string,
|
||||
* getLocalPort: () => number,
|
||||
* sendFrame: (plaintextFrame: Uint8Array) => void | Promise<void>,
|
||||
* getBufferedAmount: () => number,
|
||||
* }} deps
|
||||
*/
|
||||
export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBufferedAmount }) => {
|
||||
/** @type {Map<number, { kind: 'http', abort: AbortController, body: ReadableStreamDefaultController | null } | { kind: 'ws', socket: WebSocket, opened: boolean }>} */
|
||||
const streams = new Map();
|
||||
const assembler = createFragmentAssembler();
|
||||
let closed = false;
|
||||
|
||||
const send = async (frame) => {
|
||||
if (closed) return;
|
||||
await sendFrame(frame);
|
||||
};
|
||||
|
||||
const sendJson = (frameType, streamId, payload) =>
|
||||
send(encodeTunnelFrame(frameType, streamId, encodeJsonPayload(payload)));
|
||||
|
||||
const sendAbort = async (streamId, reason) => {
|
||||
await sendJson(TunnelFrameType.StreamAbort, streamId, { reason: String(reason ?? 'stream error') });
|
||||
};
|
||||
|
||||
const dropStream = (streamId) => {
|
||||
streams.delete(streamId);
|
||||
assembler.dropStream(streamId);
|
||||
};
|
||||
|
||||
const abortLocalStream = (streamId, reason) => {
|
||||
const stream = streams.get(streamId);
|
||||
if (!stream) return;
|
||||
dropStream(streamId);
|
||||
if (stream.kind === 'http') {
|
||||
try {
|
||||
stream.body?.error(new Error(String(reason ?? 'aborted')));
|
||||
} catch {
|
||||
// body already closed
|
||||
}
|
||||
stream.abort.abort();
|
||||
} else {
|
||||
try {
|
||||
stream.socket.terminate();
|
||||
} catch {
|
||||
// socket already gone
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const waitForBackpressure = async (signal) => {
|
||||
while (!closed && getBufferedAmount() > BACKPRESSURE_LIMIT_BYTES) {
|
||||
if (signal?.aborted) return;
|
||||
await sleep(BACKPRESSURE_POLL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HTTP
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const buildRequestHeaders = (rawHeaders) => {
|
||||
const headers = {};
|
||||
for (const [name, value] of Object.entries(rawHeaders)) {
|
||||
if (typeof name !== 'string' || typeof value !== 'string') continue;
|
||||
const lower = name.toLowerCase();
|
||||
if (STRIPPED_REQUEST_HEADERS.has(lower)) continue;
|
||||
if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) continue;
|
||||
headers[lower] = value;
|
||||
}
|
||||
headers['x-openchamber-relay-connection'] = connectionId;
|
||||
return headers;
|
||||
};
|
||||
|
||||
// Synthetic responses never ship an empty body: `reason` states explicitly
|
||||
// that the relay host (not the upstream server) produced this response.
|
||||
const syntheticResponse = async (streamId, status, message) => {
|
||||
await sendJson(TunnelFrameType.HttpResponse, streamId, {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
await send(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, encodeJsonPayload({ error: message, reason: message, source: 'relay-tunnel-host' })));
|
||||
await send(encodeTunnelFrame(TunnelFrameType.StreamEnd, streamId, new Uint8Array(0)));
|
||||
};
|
||||
|
||||
const runHttpStream = async (streamId, request) => {
|
||||
const method = request.method.toUpperCase();
|
||||
if (!isAllowedHttpPath(request.path)) {
|
||||
dropStream(streamId);
|
||||
await syntheticResponse(streamId, 403, 'Path is not allowed through the relay');
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = streams.get(streamId);
|
||||
if (!stream || stream.kind !== 'http') return;
|
||||
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
let requestBody;
|
||||
if (hasBody) {
|
||||
requestBody = new ReadableStream({
|
||||
start(controller) {
|
||||
stream.body = controller;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
stream.body = null;
|
||||
stream.noBody = true;
|
||||
}
|
||||
|
||||
const url = `http://127.0.0.1:${getLocalPort()}${request.path}${request.query ? `?${request.query}` : ''}`;
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
headers: buildRequestHeaders(request.headers),
|
||||
body: requestBody,
|
||||
duplex: hasBody ? 'half' : undefined,
|
||||
signal: stream.abort.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (streams.get(streamId) === stream) {
|
||||
dropStream(streamId);
|
||||
await sendAbort(streamId, error?.message ?? 'loopback request failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const responseHeaders = {};
|
||||
for (const [name, value] of response.headers.entries()) {
|
||||
if (STRIPPED_RESPONSE_HEADERS.has(name)) continue;
|
||||
responseHeaders[name] = value;
|
||||
}
|
||||
await sendJson(TunnelFrameType.HttpResponse, streamId, { status: response.status, headers: responseHeaders });
|
||||
|
||||
try {
|
||||
if (response.body) {
|
||||
for await (const chunk of response.body) {
|
||||
if (closed || stream.abort.signal.aborted) return;
|
||||
const bytes = chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk);
|
||||
for (const piece of chunkPayload(bytes, MAX_TUNNEL_PAYLOAD_BYTES)) {
|
||||
await waitForBackpressure(stream.abort.signal);
|
||||
if (closed || stream.abort.signal.aborted) return;
|
||||
await send(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, piece));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (streams.get(streamId) === stream) {
|
||||
dropStream(streamId);
|
||||
await send(encodeTunnelFrame(TunnelFrameType.StreamEnd, streamId, new Uint8Array(0)));
|
||||
}
|
||||
} catch (error) {
|
||||
if (streams.get(streamId) === stream) {
|
||||
dropStream(streamId);
|
||||
await sendAbort(streamId, error?.message ?? 'loopback response failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleHttpRequest = (streamId, payload) => {
|
||||
if (streams.has(streamId)) {
|
||||
abortLocalStream(streamId, 'duplicate stream id');
|
||||
void sendAbort(streamId, 'duplicate stream id');
|
||||
return;
|
||||
}
|
||||
let request;
|
||||
try {
|
||||
request = decodeJsonPayload(payload, isHttpRequestPayload);
|
||||
} catch (error) {
|
||||
void sendAbort(streamId, error?.message ?? 'malformed request');
|
||||
return;
|
||||
}
|
||||
const stream = { kind: 'http', abort: new AbortController(), body: null, noBody: false };
|
||||
streams.set(streamId, stream);
|
||||
void runHttpStream(streamId, request);
|
||||
};
|
||||
|
||||
const handleHttpBody = (streamId, payload) => {
|
||||
const stream = streams.get(streamId);
|
||||
if (!stream || stream.kind !== 'http' || stream.noBody) return;
|
||||
// The body controller attaches synchronously in runHttpStream before any
|
||||
// await, so by the time body frames arrive it is set for body-carrying
|
||||
// methods; drop stray body bytes otherwise.
|
||||
try {
|
||||
stream.body?.enqueue(payload);
|
||||
} catch {
|
||||
// stream already errored/closed
|
||||
}
|
||||
};
|
||||
|
||||
const handleStreamEnd = (streamId) => {
|
||||
const stream = streams.get(streamId);
|
||||
if (!stream || stream.kind !== 'http') return;
|
||||
try {
|
||||
stream.body?.close();
|
||||
} catch {
|
||||
// stream already errored/closed
|
||||
}
|
||||
// Response side keeps running; only the request body is half-closed.
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// WebSocket
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const handleWsOpen = (streamId, payload) => {
|
||||
if (streams.has(streamId)) {
|
||||
abortLocalStream(streamId, 'duplicate stream id');
|
||||
void sendAbort(streamId, 'duplicate stream id');
|
||||
return;
|
||||
}
|
||||
let open;
|
||||
try {
|
||||
open = decodeJsonPayload(payload, isWsOpenPayload);
|
||||
} catch (error) {
|
||||
void sendAbort(streamId, error?.message ?? 'malformed ws open');
|
||||
return;
|
||||
}
|
||||
if (!ALLOWED_WS_PATHS.has(open.path)) {
|
||||
void sendAbort(streamId, 'Path is not allowed through the relay');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `ws://127.0.0.1:${getLocalPort()}${open.path}${open.query ? `?${open.query}` : ''}`;
|
||||
// Present the loopback origin we're actually dialing. The server derives this
|
||||
// as a trusted same-origin candidate from the Host header (127.0.0.1:<port>),
|
||||
// so the WS origin check passes reliably for every client platform. We do NOT
|
||||
// use the client's window.location.origin: it's unreliable in WKWebView (empty
|
||||
// or "null" for custom schemes), and the `ws` client sends no Origin at all
|
||||
// otherwise — a no-origin upgrade is rejected 403. The request itself is still
|
||||
// authenticated by the tunneled oc_url_token, not by this origin.
|
||||
const dialHeaders = {
|
||||
'x-openchamber-relay-connection': connectionId,
|
||||
origin: `http://127.0.0.1:${getLocalPort()}`,
|
||||
};
|
||||
let socket;
|
||||
try {
|
||||
socket = new WebSocket(url, open.protocols, {
|
||||
headers: dialHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
void sendAbort(streamId, error?.message ?? 'ws dial failed');
|
||||
return;
|
||||
}
|
||||
const stream = { kind: 'ws', socket, opened: false };
|
||||
streams.set(streamId, stream);
|
||||
|
||||
socket.on('open', () => {
|
||||
if (streams.get(streamId) !== stream) return;
|
||||
stream.opened = true;
|
||||
void sendJson(TunnelFrameType.WsOpened, streamId, socket.protocol ? { protocol: socket.protocol } : {});
|
||||
});
|
||||
socket.on('message', (data, isBinary) => {
|
||||
if (streams.get(streamId) !== stream || closed) return;
|
||||
const bytes = Buffer.isBuffer(data) ? new Uint8Array(data) : new Uint8Array(Buffer.concat(data));
|
||||
const frameType = isBinary ? TunnelFrameType.WsBinary : TunnelFrameType.WsText;
|
||||
void (async () => {
|
||||
for (const frame of encodeFragmentedMessage(frameType, streamId, bytes)) {
|
||||
await waitForBackpressure(null);
|
||||
if (streams.get(streamId) !== stream || closed) return;
|
||||
await send(frame);
|
||||
}
|
||||
})();
|
||||
});
|
||||
socket.on('close', (code, reasonBuffer) => {
|
||||
if (streams.get(streamId) !== stream) return;
|
||||
dropStream(streamId);
|
||||
const reason = reasonBuffer ? reasonBuffer.toString('utf8') : '';
|
||||
if (stream.opened) {
|
||||
void sendJson(TunnelFrameType.WsClose, streamId, { code: code || 1000, reason });
|
||||
} else {
|
||||
void sendAbort(streamId, reason || `upstream ws closed (${code || 'no code'})`);
|
||||
}
|
||||
});
|
||||
socket.on('error', (error) => {
|
||||
if (streams.get(streamId) !== stream) return;
|
||||
if (!stream.opened) {
|
||||
dropStream(streamId);
|
||||
try {
|
||||
socket.terminate();
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
void sendAbort(streamId, error?.message ?? 'upstream ws error');
|
||||
}
|
||||
// Post-open errors are followed by 'close', handled above.
|
||||
});
|
||||
};
|
||||
|
||||
const handleWsMessage = (streamId, frameType, message) => {
|
||||
const stream = streams.get(streamId);
|
||||
if (!stream || stream.kind !== 'ws' || stream.socket.readyState !== WebSocket.OPEN) return;
|
||||
if (frameType === TunnelFrameType.WsText) {
|
||||
stream.socket.send(Buffer.from(message).toString('utf8'));
|
||||
} else {
|
||||
stream.socket.send(message, { binary: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleWsClose = (streamId, payload) => {
|
||||
const stream = streams.get(streamId);
|
||||
if (!stream || stream.kind !== 'ws') return;
|
||||
dropStream(streamId);
|
||||
let close = { code: 1000, reason: '' };
|
||||
try {
|
||||
close = decodeJsonPayload(payload, isWsClosePayload);
|
||||
} catch {
|
||||
// fall through with defaults
|
||||
}
|
||||
const code = Number.isInteger(close.code) && close.code >= 1000 && close.code <= 4999 ? close.code : 1000;
|
||||
try {
|
||||
stream.socket.close(code, typeof close.reason === 'string' ? close.reason : '');
|
||||
} catch {
|
||||
stream.socket.terminate();
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Frame entrypoint
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** @param {Uint8Array} plaintextFrame one decrypted tunnel frame */
|
||||
const handleFrame = async (plaintextFrame) => {
|
||||
if (closed) return;
|
||||
const frame = decodeTunnelFrame(plaintextFrame);
|
||||
|
||||
// WS message frames can be fragmented; everything else arrives whole.
|
||||
if (frame.frameType === TunnelFrameType.WsText || frame.frameType === TunnelFrameType.WsBinary) {
|
||||
const message = assembler.push(frame);
|
||||
if (message === null) return;
|
||||
handleWsMessage(frame.streamId, frame.frameType, message);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (frame.frameType) {
|
||||
case TunnelFrameType.HttpRequest:
|
||||
handleHttpRequest(frame.streamId, frame.payload);
|
||||
return;
|
||||
case TunnelFrameType.HttpBody:
|
||||
handleHttpBody(frame.streamId, frame.payload);
|
||||
return;
|
||||
case TunnelFrameType.StreamEnd:
|
||||
handleStreamEnd(frame.streamId);
|
||||
return;
|
||||
case TunnelFrameType.StreamAbort:
|
||||
abortLocalStream(frame.streamId, 'aborted by client');
|
||||
return;
|
||||
case TunnelFrameType.WsOpen:
|
||||
handleWsOpen(frame.streamId, frame.payload);
|
||||
return;
|
||||
case TunnelFrameType.WsClose:
|
||||
handleWsClose(frame.streamId, frame.payload);
|
||||
return;
|
||||
case TunnelFrameType.Ping:
|
||||
await send(encodeTunnelFrame(TunnelFrameType.Pong, frame.streamId, new Uint8Array(0)));
|
||||
return;
|
||||
case TunnelFrameType.Pong:
|
||||
return;
|
||||
default:
|
||||
// Host never receives HttpResponse/WsOpened; ignore rather than tear down.
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const streamId of [...streams.keys()]) {
|
||||
abortLocalStream(streamId, 'connection closed');
|
||||
}
|
||||
streams.clear();
|
||||
};
|
||||
|
||||
return {
|
||||
handleFrame,
|
||||
close,
|
||||
get streamCount() {
|
||||
return streams.size;
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user