Files
openchamber/packages/web/server/lib/relay/cross-compat.test.js
T
Bohdan Triapitsyn 859b4529da 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
2026-07-08 03:44:02 +03:00

136 lines
6.3 KiB
JavaScript

// 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);
});
});