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
125 lines
4.9 KiB
JavaScript
125 lines
4.9 KiB
JavaScript
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));
|
|
});
|
|
});
|