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
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
base64UrlToBytes,
|
||||
bytesToBase64Url,
|
||||
createFrameDecryptor,
|
||||
createFrameEncryptor,
|
||||
deriveSessionKeys,
|
||||
exportPublicKeyJwk,
|
||||
generateEcdhKeyPair,
|
||||
generateHandshakeNonce,
|
||||
importEcdhPublicKey,
|
||||
publicKeyJwkFingerprint,
|
||||
RelayCryptoError,
|
||||
} from './crypto';
|
||||
import { ENCRYPTED_FRAME_HEADER_BYTES, MAX_PLAINTEXT_FRAME_BYTES } from './protocol';
|
||||
|
||||
const setupSession = async () => {
|
||||
const host = await generateEcdhKeyPair();
|
||||
const client = await generateEcdhKeyPair();
|
||||
const nonce = generateHandshakeNonce();
|
||||
const hostPub = await importEcdhPublicKey(await exportPublicKeyJwk(host.publicKey));
|
||||
const clientPub = await importEcdhPublicKey(await exportPublicKeyJwk(client.publicKey));
|
||||
const clientKeys = await deriveSessionKeys(client.privateKey, hostPub, nonce);
|
||||
const hostKeys = await deriveSessionKeys(host.privateKey, clientPub, nonce);
|
||||
return { clientKeys, hostKeys };
|
||||
};
|
||||
|
||||
describe('relay crypto', () => {
|
||||
test('both sides derive matching directional keys (round trip both ways)', async () => {
|
||||
const { clientKeys, hostKeys } = await setupSession();
|
||||
|
||||
const clientToHost = createFrameEncryptor(clientKeys.clientToHost);
|
||||
const hostReceives = createFrameDecryptor(hostKeys.clientToHost);
|
||||
const message = new TextEncoder().encode('hello from client');
|
||||
const decrypted = await hostReceives.decrypt(await clientToHost.encrypt(message));
|
||||
expect(new TextDecoder().decode(decrypted)).toBe('hello from client');
|
||||
|
||||
const hostToClient = createFrameEncryptor(hostKeys.hostToClient);
|
||||
const clientReceives = createFrameDecryptor(clientKeys.hostToClient);
|
||||
const reply = new TextEncoder().encode('hello from host');
|
||||
const decryptedReply = await clientReceives.decrypt(await hostToClient.encrypt(reply));
|
||||
expect(new TextDecoder().decode(decryptedReply)).toBe('hello from host');
|
||||
});
|
||||
|
||||
test('different nonce yields incompatible keys', async () => {
|
||||
const host = await generateEcdhKeyPair();
|
||||
const client = await generateEcdhKeyPair();
|
||||
const hostPub = await importEcdhPublicKey(await exportPublicKeyJwk(host.publicKey));
|
||||
const clientPub = await importEcdhPublicKey(await exportPublicKeyJwk(client.publicKey));
|
||||
const clientKeys = await deriveSessionKeys(client.privateKey, hostPub, generateHandshakeNonce());
|
||||
const hostKeys = await deriveSessionKeys(host.privateKey, clientPub, generateHandshakeNonce());
|
||||
const frame = await createFrameEncryptor(clientKeys.clientToHost).encrypt(new Uint8Array([1, 2, 3]));
|
||||
await expect(createFrameDecryptor(hostKeys.clientToHost).decrypt(frame)).rejects.toThrow(RelayCryptoError);
|
||||
});
|
||||
|
||||
test('tampered ciphertext is rejected', async () => {
|
||||
const { clientKeys, hostKeys } = await setupSession();
|
||||
const frame = await createFrameEncryptor(clientKeys.clientToHost).encrypt(new Uint8Array([9, 9, 9]));
|
||||
frame[frame.length - 1] ^= 0x01;
|
||||
await expect(createFrameDecryptor(hostKeys.clientToHost).decrypt(frame)).rejects.toThrow(
|
||||
'frame decryption failed',
|
||||
);
|
||||
});
|
||||
|
||||
test('replayed and reordered frames are rejected (counter regression)', async () => {
|
||||
const { clientKeys, hostKeys } = await setupSession();
|
||||
const encryptor = createFrameEncryptor(clientKeys.clientToHost);
|
||||
const decryptor = createFrameDecryptor(hostKeys.clientToHost);
|
||||
const first = await encryptor.encrypt(new Uint8Array([1]));
|
||||
const second = await encryptor.encrypt(new Uint8Array([2]));
|
||||
await decryptor.decrypt(first);
|
||||
await decryptor.decrypt(second);
|
||||
await expect(decryptor.decrypt(first)).rejects.toThrow('frame counter regression');
|
||||
});
|
||||
|
||||
test('skipped counters are tolerated but never regress', async () => {
|
||||
const { clientKeys, hostKeys } = await setupSession();
|
||||
const encryptor = createFrameEncryptor(clientKeys.clientToHost);
|
||||
const decryptor = createFrameDecryptor(hostKeys.clientToHost);
|
||||
const first = await encryptor.encrypt(new Uint8Array([1]));
|
||||
const second = await encryptor.encrypt(new Uint8Array([2]));
|
||||
const third = await encryptor.encrypt(new Uint8Array([3]));
|
||||
await decryptor.decrypt(first);
|
||||
await decryptor.decrypt(third);
|
||||
await expect(decryptor.decrypt(second)).rejects.toThrow('frame counter regression');
|
||||
});
|
||||
|
||||
test('oversized plaintext is rejected before encryption', async () => {
|
||||
const { clientKeys } = await setupSession();
|
||||
const encryptor = createFrameEncryptor(clientKeys.clientToHost);
|
||||
await expect(encryptor.encrypt(new Uint8Array(MAX_PLAINTEXT_FRAME_BYTES + 1))).rejects.toThrow(
|
||||
'plaintext frame exceeds maximum size',
|
||||
);
|
||||
});
|
||||
|
||||
test('truncated and wrong-version frames are rejected', async () => {
|
||||
const { hostKeys } = await setupSession();
|
||||
const decryptor = createFrameDecryptor(hostKeys.clientToHost);
|
||||
await expect(decryptor.decrypt(new Uint8Array(ENCRYPTED_FRAME_HEADER_BYTES))).rejects.toThrow(
|
||||
'encrypted frame too short',
|
||||
);
|
||||
const bogus = new Uint8Array(ENCRYPTED_FRAME_HEADER_BYTES + 20);
|
||||
bogus[0] = 42;
|
||||
await expect(decryptor.decrypt(bogus)).rejects.toThrow('unsupported encrypted frame version');
|
||||
});
|
||||
|
||||
test('importEcdhPublicKey rejects malformed JWKs', async () => {
|
||||
await expect(importEcdhPublicKey({ kty: 'RSA' })).rejects.toThrow(RelayCryptoError);
|
||||
await expect(importEcdhPublicKey({ kty: 'EC', crv: 'P-384', x: 'a', y: 'b' })).rejects.toThrow(
|
||||
RelayCryptoError,
|
||||
);
|
||||
await expect(importEcdhPublicKey({ kty: 'EC', crv: 'P-256', x: '!!', y: '!!' })).rejects.toThrow(
|
||||
RelayCryptoError,
|
||||
);
|
||||
});
|
||||
|
||||
test('fingerprint is stable across key-order differences and distinct per key', async () => {
|
||||
const pair = await generateEcdhKeyPair();
|
||||
const jwk = await exportPublicKeyJwk(pair.publicKey);
|
||||
const shuffled: JsonWebKey = { y: jwk.y, x: jwk.x, crv: jwk.crv, kty: jwk.kty };
|
||||
expect(publicKeyJwkFingerprint(jwk)).toBe(publicKeyJwkFingerprint(shuffled));
|
||||
const other = await exportPublicKeyJwk((await generateEcdhKeyPair()).publicKey);
|
||||
expect(publicKeyJwkFingerprint(jwk)).not.toBe(publicKeyJwkFingerprint(other));
|
||||
});
|
||||
|
||||
test('base64url round trip and rejection of invalid input', () => {
|
||||
for (const length of [0, 1, 2, 3, 16, 31, 32]) {
|
||||
const bytes = new Uint8Array(length);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
expect(base64UrlToBytes(bytesToBase64Url(bytes))).toEqual(bytes);
|
||||
}
|
||||
expect(() => base64UrlToBytes('a+b/c=')).toThrow(RelayCryptoError);
|
||||
expect(() => base64UrlToBytes('abcde')).toThrow(RelayCryptoError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
// E2EE primitives for the private relay (Layer 2 of the protocol spec).
|
||||
// WebCrypto only — isomorphic across browser, Node >= 20, WKWebView, and Workers.
|
||||
// Key agreement: ECDH P-256 -> HKDF-SHA-256 -> two AES-256-GCM keys (one per direction).
|
||||
// Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 2).
|
||||
|
||||
import {
|
||||
ENCRYPTED_FRAME_HEADER_BYTES,
|
||||
ENCRYPTED_FRAME_IV_BYTES,
|
||||
ENCRYPTED_FRAME_VERSION,
|
||||
MAX_PLAINTEXT_FRAME_BYTES,
|
||||
RELAY_HKDF_INFO,
|
||||
} from './protocol';
|
||||
|
||||
const subtle = globalThis.crypto.subtle;
|
||||
|
||||
const ECDH_PARAMS: EcKeyGenParams = { 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: string) {
|
||||
super(message);
|
||||
this.name = 'RelayCryptoError';
|
||||
}
|
||||
}
|
||||
|
||||
export const generateEcdhKeyPair = (): Promise<CryptoKeyPair> =>
|
||||
subtle.generateKey(ECDH_PARAMS, true, ['deriveBits']);
|
||||
|
||||
export const exportPublicKeyJwk = async (key: CryptoKey): Promise<JsonWebKey> => {
|
||||
const jwk = await subtle.exportKey('jwk', key);
|
||||
// Keep only the fields that define the public point so serialized forms compare stably.
|
||||
return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
|
||||
};
|
||||
|
||||
export const importEcdhPublicKey = async (jwk: JsonWebKey): Promise<CryptoKey> => {
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
// Stable fingerprint of a public key, used to detect rekey attempts on re-hello.
|
||||
export const publicKeyJwkFingerprint = (jwk: JsonWebKey): string =>
|
||||
JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
|
||||
|
||||
export const generateHandshakeNonce = (): Uint8Array => {
|
||||
const nonce = new Uint8Array(HANDSHAKE_NONCE_BYTES);
|
||||
globalThis.crypto.getRandomValues(nonce);
|
||||
return nonce;
|
||||
};
|
||||
|
||||
export interface SessionKeys {
|
||||
clientToHost: CryptoKey;
|
||||
hostToClient: CryptoKey;
|
||||
}
|
||||
|
||||
// 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.
|
||||
export const deriveSessionKeys = async (
|
||||
ownPrivateKey: CryptoKey,
|
||||
peerPublicKey: CryptoKey,
|
||||
handshakeNonce: Uint8Array,
|
||||
): Promise<SessionKeys> => {
|
||||
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 as BufferSource,
|
||||
info: new TextEncoder().encode(RELAY_HKDF_INFO),
|
||||
},
|
||||
hkdfKey,
|
||||
SESSION_KEY_BYTES * 2 * 8,
|
||||
),
|
||||
);
|
||||
const importAesKey = (bytes: Uint8Array, usage: KeyUsage[]) =>
|
||||
subtle.importKey('raw', bytes as BufferSource, { 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']),
|
||||
};
|
||||
};
|
||||
|
||||
export interface FrameEncryptor {
|
||||
encrypt(plaintext: Uint8Array): Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
export interface FrameDecryptor {
|
||||
decrypt(frame: Uint8Array): Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
const writeCounter = (target: Uint8Array, offset: number, counter: bigint): void => {
|
||||
for (let i = IV_COUNTER_BYTES - 1; i >= 0; i -= 1) {
|
||||
target[offset + i] = Number(counter & 0xffn);
|
||||
counter >>= 8n;
|
||||
}
|
||||
};
|
||||
|
||||
const readCounter = (source: Uint8Array, offset: number): bigint => {
|
||||
let value = 0n;
|
||||
for (let i = 0; i < IV_COUNTER_BYTES; i += 1) {
|
||||
value = (value << 8n) | BigInt(source[offset + i]);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const createFrameEncryptor = (key: CryptoKey): FrameEncryptor => {
|
||||
const ivPrefix = new Uint8Array(IV_PREFIX_BYTES);
|
||||
globalThis.crypto.getRandomValues(ivPrefix);
|
||||
let counter = 0n;
|
||||
return {
|
||||
async encrypt(plaintext: Uint8Array): Promise<Uint8Array> {
|
||||
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: iv as BufferSource }, key, plaintext as BufferSource),
|
||||
);
|
||||
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.
|
||||
export const createFrameDecryptor = (key: CryptoKey): FrameDecryptor => {
|
||||
let lastCounter = 0n;
|
||||
return {
|
||||
async decrypt(frame: Uint8Array): Promise<Uint8Array> {
|
||||
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: ArrayBuffer;
|
||||
try {
|
||||
plaintext = await subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: iv as BufferSource },
|
||||
key,
|
||||
frame.slice(ENCRYPTED_FRAME_HEADER_BYTES) as BufferSource,
|
||||
);
|
||||
} catch {
|
||||
throw new RelayCryptoError('frame decryption failed');
|
||||
}
|
||||
lastCounter = counter;
|
||||
return new Uint8Array(plaintext);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||
|
||||
export const bytesToBase64Url = (bytes: Uint8Array): string => {
|
||||
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;
|
||||
};
|
||||
|
||||
export const base64UrlToBytes = (value: string): Uint8Array => {
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
// openchamber_relay_gate
|
||||
//
|
||||
// Feature gate for the private-relay UI — the surfaces for enabling the relay and
|
||||
// pairing devices through it (Settings → Remote Instances "Relay" section and its
|
||||
// settings-search entry). The relay transport itself is fully implemented and
|
||||
// tested; this flag only hides the UI entry points until the feature is ready for
|
||||
// public release (the connect flow is being unified across LAN / tunnels / relay).
|
||||
//
|
||||
// TO UNBLOCK FOR PUBLIC RELEASE: set RELAY_UI_ENABLED to true. Grep this token —
|
||||
// `openchamber_relay_gate` — to find this file. Nothing else needs to change; the
|
||||
// gated surfaces read this one constant. Also add a CHANGELOG entry then — the
|
||||
// relay's changelog note is intentionally held back while this is off.
|
||||
//
|
||||
// Note: existing saved relay connections keep working regardless (this gates the
|
||||
// UI for ADDING/pairing, not the runtime transport). If you also want to hide the
|
||||
// mobile side of importing a relay link, gate the relay branch in
|
||||
// packages/ui/src/apps/mobileQrScan.ts / mobileConnections.ts on this same flag.
|
||||
// Typed as boolean (not the literal `false`) so gated call sites don't trip
|
||||
// "condition always false" / unreachable-code checks — flipping to true is a
|
||||
// one-word change with no other edits.
|
||||
export const RELAY_UI_ENABLED: boolean = false;
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { exportPublicKeyJwk, generateEcdhKeyPair } from './crypto';
|
||||
import {
|
||||
createClientHandshake,
|
||||
createHostHandshake,
|
||||
type EstablishedChannelCrypto,
|
||||
type HandshakeAction,
|
||||
} from './handshake';
|
||||
import { RelayCloseCode } from './protocol';
|
||||
|
||||
const createHostIdentity = async () => {
|
||||
const keyPair = await generateEcdhKeyPair();
|
||||
return {
|
||||
privateKey: keyPair.privateKey,
|
||||
publicJwk: await exportPublicKeyJwk(keyPair.publicKey),
|
||||
};
|
||||
};
|
||||
|
||||
const expectEstablished = (
|
||||
action: HandshakeAction,
|
||||
): { channel: EstablishedChannelCrypto; replyText?: string } => {
|
||||
if (action.type !== 'established') {
|
||||
throw new Error(`expected established, got ${action.type}`);
|
||||
}
|
||||
return action;
|
||||
};
|
||||
|
||||
const runFullHandshake = async () => {
|
||||
const host = await createHostIdentity();
|
||||
const client = await createClientHandshake(host.publicJwk);
|
||||
const hostMachine = createHostHandshake(host.privateKey);
|
||||
|
||||
const hostResult = expectEstablished(await hostMachine.handleText(client.helloText));
|
||||
expect(hostResult.replyText).toBeDefined();
|
||||
const clientResult = expectEstablished(await client.handleText(hostResult.replyText as string));
|
||||
return { client, hostMachine, clientChannel: clientResult.channel, hostChannel: hostResult.channel };
|
||||
};
|
||||
|
||||
describe('relay E2EE handshake', () => {
|
||||
test('full handshake establishes a working bidirectional channel', async () => {
|
||||
const { clientChannel, hostChannel } = await runFullHandshake();
|
||||
|
||||
const toHost = await clientChannel.encryptor.encrypt(new TextEncoder().encode('ping'));
|
||||
expect(new TextDecoder().decode(await hostChannel.decryptor.decrypt(toHost))).toBe('ping');
|
||||
|
||||
const toClient = await hostChannel.encryptor.encrypt(new TextEncoder().encode('pong'));
|
||||
expect(new TextDecoder().decode(await clientChannel.decryptor.decrypt(toClient))).toBe('pong');
|
||||
});
|
||||
|
||||
test('negotiates batching only when both peers advertise it', async () => {
|
||||
const assertNegotiated = async (
|
||||
clientBatch: boolean | undefined,
|
||||
hostBatch: boolean | undefined,
|
||||
expected: boolean,
|
||||
) => {
|
||||
const host = await createHostIdentity();
|
||||
const client = await createClientHandshake(host.publicJwk, { batch: clientBatch });
|
||||
const hostMachine = createHostHandshake(host.privateKey, { batch: hostBatch });
|
||||
const hostResult = await hostMachine.handleText(client.helloText);
|
||||
if (hostResult.type !== 'established') throw new Error('host did not establish');
|
||||
const clientResult = await client.handleText(hostResult.replyText as string);
|
||||
if (clientResult.type !== 'established') throw new Error('client did not establish');
|
||||
// Symmetric: both sides agree on the same negotiated value.
|
||||
expect(hostResult.batch).toBe(expected);
|
||||
expect(clientResult.batch).toBe(expected);
|
||||
};
|
||||
|
||||
await assertNegotiated(true, true, true);
|
||||
await assertNegotiated(undefined, undefined, true); // default is batch-on
|
||||
await assertNegotiated(false, true, false); // legacy client
|
||||
await assertNegotiated(true, false, false); // legacy host
|
||||
await assertNegotiated(false, false, false); // both legacy
|
||||
});
|
||||
|
||||
test('host re-sends ready for an identical retried hello', async () => {
|
||||
const host = await createHostIdentity();
|
||||
const client = await createClientHandshake(host.publicJwk);
|
||||
const hostMachine = createHostHandshake(host.privateKey);
|
||||
|
||||
const first = expectEstablished(await hostMachine.handleText(client.helloText));
|
||||
const retry = await hostMachine.handleText(client.helloText);
|
||||
expect(retry).toEqual({ type: 'send-text', text: first.replyText as string });
|
||||
});
|
||||
|
||||
test('client ignores a duplicate ready after establishment (host re-answers retried hellos)', async () => {
|
||||
const { client } = await runFullHandshake();
|
||||
const action = await client.handleText(JSON.stringify({ t: 'ready', v: 1 }));
|
||||
expect(action.type).toBe('ignore');
|
||||
});
|
||||
|
||||
test('hello with a different key after establishment fails with rekey mismatch (1008)', async () => {
|
||||
const host = await createHostIdentity();
|
||||
const firstClient = await createClientHandshake(host.publicJwk);
|
||||
const hostMachine = createHostHandshake(host.privateKey);
|
||||
expectEstablished(await hostMachine.handleText(firstClient.helloText));
|
||||
|
||||
const attacker = await createClientHandshake(host.publicJwk);
|
||||
const action = await hostMachine.handleText(attacker.helloText);
|
||||
expect(action.type).toBe('fail');
|
||||
if (action.type === 'fail') {
|
||||
expect(action.closeCode).toBe(RelayCloseCode.RekeyMismatch);
|
||||
}
|
||||
});
|
||||
|
||||
test('plaintext after establishment fails closed (1011) on both sides', async () => {
|
||||
const { client, hostMachine } = await runFullHandshake();
|
||||
|
||||
const hostAction = await hostMachine.handleText('{"anything":"plaintext"}');
|
||||
expect(hostAction.type).toBe('fail');
|
||||
if (hostAction.type === 'fail') {
|
||||
expect(hostAction.closeCode).toBe(RelayCloseCode.ChannelFailure);
|
||||
}
|
||||
|
||||
const clientAction = await client.handleText('{"anything":"plaintext"}');
|
||||
expect(clientAction.type).toBe('fail');
|
||||
if (clientAction.type === 'fail') {
|
||||
expect(clientAction.closeCode).toBe(RelayCloseCode.ChannelFailure);
|
||||
}
|
||||
});
|
||||
|
||||
test('pre-establishment noise is ignored, not fatal', async () => {
|
||||
const host = await createHostIdentity();
|
||||
const client = await createClientHandshake(host.publicJwk);
|
||||
const hostMachine = createHostHandshake(host.privateKey);
|
||||
|
||||
expect((await client.handleText('not json')).type).toBe('ignore');
|
||||
expect((await client.handleText('{"type":"sync","connectionIds":[]}')).type).toBe('ignore');
|
||||
expect((await hostMachine.handleText('not json')).type).toBe('ignore');
|
||||
expect((await hostMachine.handleText(JSON.stringify({ t: 'ready', v: 1 }))).type).toBe('ignore');
|
||||
});
|
||||
|
||||
test('malformed hello fails closed without corrupting host state', async () => {
|
||||
const host = await createHostIdentity();
|
||||
const hostMachine = createHostHandshake(host.privateKey);
|
||||
const badHello = JSON.stringify({
|
||||
t: 'hello',
|
||||
v: 1,
|
||||
clientPubJwk: { kty: 'EC', crv: 'P-256', x: '!!', y: '!!' },
|
||||
nonce: 'AAAA',
|
||||
});
|
||||
const action = await hostMachine.handleText(badHello);
|
||||
expect(action.type).toBe('fail');
|
||||
expect(hostMachine.established).toBe(false);
|
||||
|
||||
// A valid client can still complete against a fresh machine after garbage.
|
||||
const client = await createClientHandshake(host.publicJwk);
|
||||
expectEstablished(await hostMachine.handleText(client.helloText));
|
||||
});
|
||||
|
||||
test('wrong protocol version hello is ignored', async () => {
|
||||
const host = await createHostIdentity();
|
||||
const client = await createClientHandshake(host.publicJwk);
|
||||
const hostMachine = createHostHandshake(host.privateKey);
|
||||
const tampered = JSON.stringify({ ...JSON.parse(client.helloText), v: 99 });
|
||||
expect((await hostMachine.handleText(tampered)).type).toBe('ignore');
|
||||
});
|
||||
|
||||
test('client bound to a different host key derives non-matching channel keys', async () => {
|
||||
const realHost = await createHostIdentity();
|
||||
const otherHost = await createHostIdentity();
|
||||
// Client trusts otherHost's public key, but realHost answers.
|
||||
const client = await createClientHandshake(otherHost.publicJwk);
|
||||
const hostMachine = createHostHandshake(realHost.privateKey);
|
||||
const hostResult = expectEstablished(await hostMachine.handleText(client.helloText));
|
||||
const clientResult = expectEstablished(await client.handleText(hostResult.replyText as string));
|
||||
|
||||
const frame = await hostResult.channel.encryptor.encrypt(new Uint8Array([1, 2, 3]));
|
||||
await expect(clientResult.channel.decryptor.decrypt(frame)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
// E2EE handshake state machines (Layer 2 of the protocol spec).
|
||||
// Transport-agnostic: callers feed inbound frames in and deliver the returned
|
||||
// outbound frames; text frames are plaintext handshake JSON, binary frames are
|
||||
// encrypted traffic. Wire-up to actual WebSockets happens in the host client
|
||||
// (packages/web/server/lib/relay) and the tunnel client (Phase 3).
|
||||
//
|
||||
// Client (initiator): sends `hello` with an ephemeral public key + nonce,
|
||||
// waits for `ready`. Host (responder): waits for `hello`, derives session
|
||||
// keys with its long-lived encryption private key, replies `ready`.
|
||||
//
|
||||
// Fail-closed rules (adopted 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.
|
||||
|
||||
import {
|
||||
createFrameDecryptor,
|
||||
createFrameEncryptor,
|
||||
base64UrlToBytes,
|
||||
bytesToBase64Url,
|
||||
deriveSessionKeys,
|
||||
exportPublicKeyJwk,
|
||||
generateEcdhKeyPair,
|
||||
generateHandshakeNonce,
|
||||
importEcdhPublicKey,
|
||||
publicKeyJwkFingerprint,
|
||||
type FrameDecryptor,
|
||||
type FrameEncryptor,
|
||||
} from './crypto';
|
||||
import {
|
||||
RELAY_PROTOCOL_VERSION,
|
||||
RelayCloseCode,
|
||||
type E2eeHelloMessage,
|
||||
type E2eeReadyMessage,
|
||||
} from './protocol';
|
||||
|
||||
export interface EstablishedChannelCrypto {
|
||||
encryptor: FrameEncryptor;
|
||||
decryptor: FrameDecryptor;
|
||||
}
|
||||
|
||||
export type HandshakeAction =
|
||||
| { type: 'send-text'; text: string }
|
||||
// `replyText`, when present, must be sent to the peer before any encrypted frame.
|
||||
// `batch` is the negotiated frame-batching capability for the session.
|
||||
| { type: 'established'; channel: EstablishedChannelCrypto; batch: boolean; replyText?: string }
|
||||
| { type: 'ignore' }
|
||||
| { type: 'fail'; closeCode: number; reason: string };
|
||||
|
||||
const parseHandshakeMessage = (raw: string): E2eeHelloMessage | E2eeReadyMessage | null => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||
const message = parsed as Record<string, unknown>;
|
||||
if (message.v !== RELAY_PROTOCOL_VERSION) return null;
|
||||
// Unknown/missing capability flag = false = legacy behavior.
|
||||
const batch = message.batch === true;
|
||||
if (message.t === 'ready') {
|
||||
return { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch };
|
||||
}
|
||||
if (
|
||||
message.t === 'hello' &&
|
||||
typeof message.nonce === 'string' &&
|
||||
typeof message.clientPubJwk === 'object' &&
|
||||
message.clientPubJwk !== null
|
||||
) {
|
||||
return {
|
||||
t: 'hello',
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
clientPubJwk: message.clientPubJwk as JsonWebKey,
|
||||
nonce: message.nonce,
|
||||
batch,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const failClosed = (reason: string): HandshakeAction => ({
|
||||
type: 'fail',
|
||||
closeCode: RelayCloseCode.ChannelFailure,
|
||||
reason,
|
||||
});
|
||||
|
||||
export interface ClientHandshake {
|
||||
/** The `hello` text frame. Send on open and re-send on a retry interval until established. */
|
||||
helloText: string;
|
||||
/** Feed every inbound text frame received before the channel is established. */
|
||||
handleText(raw: string): Promise<HandshakeAction>;
|
||||
readonly established: boolean;
|
||||
}
|
||||
|
||||
export interface ClientHandshakeOptions {
|
||||
/** Advertise frame batching. Default true; set false to force legacy behavior. */
|
||||
batch?: boolean;
|
||||
}
|
||||
|
||||
// hostEncPubJwk comes from the pairing offer (QR / deep link) and is the trust
|
||||
// anchor: only the real host can derive the same session keys.
|
||||
export const createClientHandshake = async (
|
||||
hostEncPubJwk: JsonWebKey,
|
||||
options: ClientHandshakeOptions = {},
|
||||
): Promise<ClientHandshake> => {
|
||||
const localBatch = options.batch !== false;
|
||||
const hostPublicKey = await importEcdhPublicKey(hostEncPubJwk);
|
||||
const ephemeralKeyPair = await generateEcdhKeyPair();
|
||||
const nonce = generateHandshakeNonce();
|
||||
const hello: E2eeHelloMessage = {
|
||||
t: 'hello',
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
clientPubJwk: await exportPublicKeyJwk(ephemeralKeyPair.publicKey),
|
||||
nonce: bytesToBase64Url(nonce),
|
||||
...(localBatch ? { batch: true } : {}),
|
||||
};
|
||||
let established = false;
|
||||
return {
|
||||
helloText: JSON.stringify(hello),
|
||||
get established() {
|
||||
return established;
|
||||
},
|
||||
async handleText(raw: string): Promise<HandshakeAction> {
|
||||
if (established) {
|
||||
// The host answers every retried `hello` with `ready`, so a duplicate
|
||||
// `ready` after establishment is protocol-legal (first-connect latency
|
||||
// exceeding the hello retry interval). Any other plaintext fails closed.
|
||||
const message = parseHandshakeMessage(raw);
|
||||
if (message?.t === 'ready') return { type: 'ignore' };
|
||||
return failClosed('plaintext frame on established channel');
|
||||
}
|
||||
const message = parseHandshakeMessage(raw);
|
||||
if (message?.t !== 'ready') {
|
||||
// Not established yet: tolerate unknown plaintext (relay control noise,
|
||||
// late frames) rather than tearing down a connection that may recover.
|
||||
return { type: 'ignore' };
|
||||
}
|
||||
const keys = await deriveSessionKeys(ephemeralKeyPair.privateKey, hostPublicKey, nonce);
|
||||
established = true;
|
||||
return {
|
||||
type: 'established',
|
||||
// Batching runs only if both peers advertised it.
|
||||
batch: localBatch && message.batch === true,
|
||||
channel: {
|
||||
encryptor: createFrameEncryptor(keys.clientToHost),
|
||||
decryptor: createFrameDecryptor(keys.hostToClient),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export interface HostHandshake {
|
||||
/** Feed every inbound text frame. */
|
||||
handleText(raw: string): Promise<HandshakeAction>;
|
||||
readonly established: boolean;
|
||||
}
|
||||
|
||||
export interface HostHandshakeOptions {
|
||||
/** Support frame batching. Default true; set false to force legacy behavior. */
|
||||
batch?: boolean;
|
||||
}
|
||||
|
||||
export const createHostHandshake = (
|
||||
hostEncPrivateKey: CryptoKey,
|
||||
options: HostHandshakeOptions = {},
|
||||
): HostHandshake => {
|
||||
const localBatch = options.batch !== false;
|
||||
let established = false;
|
||||
let acceptedClientKeyFingerprint: string | null = null;
|
||||
let readyText: string | null = null;
|
||||
let negotiatedBatch = false;
|
||||
return {
|
||||
get established() {
|
||||
return established;
|
||||
},
|
||||
async handleText(raw: string): Promise<HandshakeAction> {
|
||||
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: CryptoKey;
|
||||
let nonce: Uint8Array;
|
||||
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;
|
||||
const ready: E2eeReadyMessage = {
|
||||
t: 'ready',
|
||||
v: RELAY_PROTOCOL_VERSION,
|
||||
...(negotiatedBatch ? { batch: true } : {}),
|
||||
};
|
||||
readyText = JSON.stringify(ready);
|
||||
established = true;
|
||||
return {
|
||||
type: 'established',
|
||||
batch: negotiatedBatch,
|
||||
replyText: readyText,
|
||||
channel: {
|
||||
encryptor: createFrameEncryptor(keys.hostToClient),
|
||||
decryptor: createFrameDecryptor(keys.clientToHost),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { buildRelayOfferUrl, parseRelayOfferUrl, redactOffer } from './offer';
|
||||
import type { RelayOfferV1 } from './protocol';
|
||||
|
||||
const baseOffer: RelayOfferV1 = {
|
||||
v: 1,
|
||||
mode: 'relay',
|
||||
relayUrl: 'wss://relay.example.com/host',
|
||||
serverId: 'srv_0123456789abcdef',
|
||||
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x-coordinate-b64u', y: 'y-coordinate-b64u' },
|
||||
};
|
||||
|
||||
const fullOffer: RelayOfferV1 = {
|
||||
...baseOffer,
|
||||
label: 'My Mac',
|
||||
token: 'oc_client_secret_token_value',
|
||||
grant: 'grant-value',
|
||||
};
|
||||
|
||||
describe('buildRelayOfferUrl / parseRelayOfferUrl', () => {
|
||||
test('round-trips a minimal offer', () => {
|
||||
expect(parseRelayOfferUrl(buildRelayOfferUrl(baseOffer))).toEqual(baseOffer);
|
||||
});
|
||||
|
||||
test('round-trips a full offer with optional fields', () => {
|
||||
expect(parseRelayOfferUrl(buildRelayOfferUrl(fullOffer))).toEqual(fullOffer);
|
||||
});
|
||||
|
||||
test('URL has the expected shape', () => {
|
||||
const url = buildRelayOfferUrl(baseOffer);
|
||||
expect(url.startsWith('openchamber://connect?v=1&mode=relay#offer=')).toBe(true);
|
||||
});
|
||||
|
||||
test('token appears only in the fragment, never in the query string', () => {
|
||||
const url = buildRelayOfferUrl(fullOffer);
|
||||
const [beforeFragment, fragment] = url.split('#');
|
||||
expect(beforeFragment).toBe('openchamber://connect?v=1&mode=relay');
|
||||
expect(beforeFragment.includes(fullOffer.token as string)).toBe(false);
|
||||
expect(fragment.startsWith('offer=')).toBe(true);
|
||||
// Token round-trips through the fragment payload.
|
||||
expect(parseRelayOfferUrl(url)?.token).toBe(fullOffer.token as string);
|
||||
});
|
||||
|
||||
const encodeOffer = (value: unknown): string => {
|
||||
const json = JSON.stringify(value);
|
||||
const b64 = Buffer.from(json, 'utf8').toString('base64url');
|
||||
return `openchamber://connect?v=1&mode=relay#offer=${b64}`;
|
||||
};
|
||||
|
||||
test('rejects wrong scheme, host, version, and mode', () => {
|
||||
const url = buildRelayOfferUrl(baseOffer);
|
||||
expect(parseRelayOfferUrl(url.replace('openchamber://', 'https://'))).toBeNull();
|
||||
expect(parseRelayOfferUrl(url.replace('//connect', '//pair'))).toBeNull();
|
||||
expect(parseRelayOfferUrl(url.replace('v=1', 'v=2'))).toBeNull();
|
||||
expect(parseRelayOfferUrl(url.replace('mode=relay', 'mode=lan'))).toBeNull();
|
||||
expect(parseRelayOfferUrl('not a url')).toBeNull();
|
||||
expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay')).toBeNull();
|
||||
expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay#offer=')).toBeNull();
|
||||
expect(parseRelayOfferUrl('openchamber://connect?v=1&mode=relay#offer=!!not-b64url!!')).toBeNull();
|
||||
});
|
||||
|
||||
const without = (key: keyof RelayOfferV1): Record<string, unknown> => {
|
||||
const clone: Record<string, unknown> = { ...fullOffer };
|
||||
delete clone[key];
|
||||
return clone;
|
||||
};
|
||||
|
||||
test('rejects wholly when any required field is missing or malformed', () => {
|
||||
const cases: unknown[] = [
|
||||
{ ...fullOffer, v: 2 },
|
||||
without('v'),
|
||||
{ ...fullOffer, mode: 'direct' },
|
||||
without('mode'),
|
||||
without('relayUrl'),
|
||||
{ ...fullOffer, relayUrl: '' },
|
||||
{ ...fullOffer, relayUrl: 'not-a-url' },
|
||||
{ ...fullOffer, relayUrl: 'ftp://relay.example.com' },
|
||||
without('serverId'),
|
||||
{ ...fullOffer, serverId: '' },
|
||||
{ ...fullOffer, serverId: 42 },
|
||||
without('hostEncPubJwk'),
|
||||
{ ...fullOffer, hostEncPubJwk: { ...baseOffer.hostEncPubJwk, kty: 'RSA' } },
|
||||
{ ...fullOffer, hostEncPubJwk: { ...baseOffer.hostEncPubJwk, crv: 'P-384' } },
|
||||
{ ...fullOffer, hostEncPubJwk: { kty: 'EC', crv: 'P-256', y: 'y' } },
|
||||
{ ...fullOffer, hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x' } },
|
||||
{ ...fullOffer, hostEncPubJwk: 'jwk' },
|
||||
{ ...fullOffer, label: '' },
|
||||
{ ...fullOffer, token: '' },
|
||||
{ ...fullOffer, token: 123 },
|
||||
{ ...fullOffer, grant: '' },
|
||||
['array'],
|
||||
];
|
||||
for (const payload of cases) {
|
||||
expect(parseRelayOfferUrl(encodeOffer(payload))).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('parse strips unknown fields', () => {
|
||||
const parsed = parseRelayOfferUrl(encodeOffer({ ...baseOffer, extra: 'field' }));
|
||||
expect(parsed).toEqual(baseOffer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redactOffer', () => {
|
||||
test('masks token, grant, and host public key coordinates', () => {
|
||||
const redacted = redactOffer(fullOffer);
|
||||
expect(redacted.token).toBe('[redacted]');
|
||||
expect(redacted.grant).toBe('[redacted]');
|
||||
expect(redacted.hostEncPubJwk.x).toBe('[redacted]');
|
||||
expect(redacted.hostEncPubJwk.y).toBe('[redacted]');
|
||||
const serialized = JSON.stringify(redacted);
|
||||
expect(serialized.includes(fullOffer.token as string)).toBe(false);
|
||||
expect(serialized.includes(baseOffer.hostEncPubJwk.x as string)).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps non-secret fields and omits absent optionals', () => {
|
||||
const redacted = redactOffer(baseOffer);
|
||||
expect(redacted.relayUrl).toBe(baseOffer.relayUrl);
|
||||
expect(redacted.serverId).toBe(baseOffer.serverId);
|
||||
expect('token' in redacted).toBe(false);
|
||||
expect('grant' in redacted).toBe(false);
|
||||
});
|
||||
|
||||
test('does not mutate the input offer', () => {
|
||||
const copy = structuredClone(fullOffer);
|
||||
redactOffer(fullOffer);
|
||||
expect(fullOffer).toEqual(copy);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// Relay pairing offer URL codec (spec §Pairing payload).
|
||||
// The offer JSON travels ONLY in the URL fragment so secrets (token) never
|
||||
// reach servers, logs, or referrer headers via the query string.
|
||||
// Shared by: settings UI (build), mobile scan (parse), desktop host import
|
||||
// (parse), CLI (build).
|
||||
|
||||
import { base64UrlToBytes, bytesToBase64Url } from './crypto';
|
||||
import type { RelayOfferV1 } from './protocol';
|
||||
|
||||
const OFFER_SCHEME = 'openchamber:';
|
||||
const OFFER_HOST = 'connect';
|
||||
const OFFER_FRAGMENT_KEY = 'offer=';
|
||||
|
||||
const REDACTED = '[redacted]';
|
||||
|
||||
export const buildRelayOfferUrl = (offer: RelayOfferV1): string => {
|
||||
const json = JSON.stringify(offer);
|
||||
const encoded = bytesToBase64Url(new TextEncoder().encode(json));
|
||||
return `openchamber://connect?v=1&mode=relay#${OFFER_FRAGMENT_KEY}${encoded}`;
|
||||
};
|
||||
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0;
|
||||
|
||||
const isValidHttpOrWsUrl = (value: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === 'wss:' || parsed.protocol === 'ws:' || parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const parsePublicKeyJwk = (value: unknown): JsonWebKey | null => {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
|
||||
const jwk = value as Record<string, unknown>;
|
||||
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') return null;
|
||||
if (!isNonEmptyString(jwk.x) || !isNonEmptyString(jwk.y)) return null;
|
||||
return { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y };
|
||||
};
|
||||
|
||||
// Strict parse: every required field is validated; any malformed or missing
|
||||
// field rejects the whole offer (returns null, never a partial object).
|
||||
export const parseRelayOfferUrl = (url: string): RelayOfferV1 | null => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol !== OFFER_SCHEME) return null;
|
||||
// Custom-scheme URLs may surface the authority as hostname or pathname
|
||||
// depending on the runtime's parser.
|
||||
const authority = parsed.hostname || parsed.pathname.replace(/^\/*/, '').split(/[/?#]/)[0];
|
||||
if (authority !== OFFER_HOST) return null;
|
||||
if (parsed.searchParams.get('v') !== '1') return null;
|
||||
if (parsed.searchParams.get('mode') !== 'relay') return null;
|
||||
|
||||
const fragment = parsed.hash.startsWith('#') ? parsed.hash.slice(1) : parsed.hash;
|
||||
if (!fragment.startsWith(OFFER_FRAGMENT_KEY)) return null;
|
||||
const encoded = fragment.slice(OFFER_FRAGMENT_KEY.length);
|
||||
if (!encoded) return null;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(new TextDecoder().decode(base64UrlToBytes(encoded)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null;
|
||||
const candidate = raw as Record<string, unknown>;
|
||||
|
||||
if (candidate.v !== 1) return null;
|
||||
if (candidate.mode !== 'relay') return null;
|
||||
if (!isNonEmptyString(candidate.relayUrl) || !isValidHttpOrWsUrl(candidate.relayUrl)) return null;
|
||||
if (!isNonEmptyString(candidate.serverId)) return null;
|
||||
const hostEncPubJwk = parsePublicKeyJwk(candidate.hostEncPubJwk);
|
||||
if (!hostEncPubJwk) return null;
|
||||
if (candidate.label !== undefined && !isNonEmptyString(candidate.label)) return null;
|
||||
if (candidate.token !== undefined && !isNonEmptyString(candidate.token)) return null;
|
||||
if (candidate.grant !== undefined && !isNonEmptyString(candidate.grant)) return null;
|
||||
|
||||
return {
|
||||
v: 1,
|
||||
mode: 'relay',
|
||||
relayUrl: candidate.relayUrl,
|
||||
serverId: candidate.serverId,
|
||||
hostEncPubJwk,
|
||||
...(candidate.label !== undefined ? { label: candidate.label } : {}),
|
||||
...(candidate.token !== undefined ? { token: candidate.token } : {}),
|
||||
...(candidate.grant !== undefined ? { grant: candidate.grant } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
// Safe-for-logging copy: masks the access token and the host public key
|
||||
// coordinates. Never log a raw offer.
|
||||
export const redactOffer = (offer: RelayOfferV1): RelayOfferV1 => ({
|
||||
...offer,
|
||||
hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: REDACTED, y: REDACTED },
|
||||
...(offer.token !== undefined ? { token: REDACTED } : {}),
|
||||
...(offer.grant !== undefined ? { grant: REDACTED } : {}),
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
// OpenChamber private relay protocol constants and shared types.
|
||||
// Spec: .opencode/plans/private-relay/01-protocol-spec.md
|
||||
// Three layers: relay routing (Layer 1), E2EE channel (Layer 2), tunnel mux (Layer 3).
|
||||
// This module is isomorphic: browser, Node, and Cloudflare Workers.
|
||||
|
||||
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;
|
||||
|
||||
// Max plaintext per encrypted frame. Keeps relay-forwarded WS messages far
|
||||
// below Cloudflare's 1 MiB cap even after GCM tag + header overhead.
|
||||
export const MAX_PLAINTEXT_FRAME_BYTES = 64 * 1024;
|
||||
|
||||
// Tunnel frame layout: [1 byte frameType(+fragment flag)][4 byte BE streamId][payload].
|
||||
export const TUNNEL_FRAME_HEADER_BYTES = 5;
|
||||
export const TUNNEL_FRAGMENT_FLAG = 0x80;
|
||||
|
||||
// Batch envelope (Layer 2 plaintext container, used only when both peers
|
||||
// negotiated `batch`). Plaintext = [1 byte container tag] then either the raw
|
||||
// tunnel frame (tag 0x00) or repeated [4 byte BE length][frame] (tag 0x01).
|
||||
// See tunnel-codec encodeFrameBatch/decodeFrameBatch.
|
||||
export const BATCH_CONTAINER_TAG_SINGLE = 0x00;
|
||||
export const BATCH_CONTAINER_TAG_BATCH = 0x01;
|
||||
export const BATCH_FRAME_LENGTH_BYTES = 4;
|
||||
// Worst-case per-frame envelope overhead inside a batch (tag + length prefix).
|
||||
// Reserved from the tunnel payload budget so any single frame — even at the
|
||||
// maximum size — still fits inside one 64 KiB encrypted plaintext once wrapped.
|
||||
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,
|
||||
} as const;
|
||||
|
||||
export type TunnelFrameTypeValue = (typeof TunnelFrameType)[keyof typeof TunnelFrameType];
|
||||
|
||||
const TUNNEL_FRAME_TYPE_VALUES = new Set<number>(Object.values(TunnelFrameType));
|
||||
|
||||
export const isTunnelFrameType = (value: number): value is TunnelFrameTypeValue =>
|
||||
TUNNEL_FRAME_TYPE_VALUES.has(value);
|
||||
|
||||
export interface TunnelHttpRequestPayload {
|
||||
method: string;
|
||||
path: string;
|
||||
query: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TunnelHttpResponsePayload {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TunnelStreamAbortPayload {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface TunnelWsOpenPayload {
|
||||
path: string;
|
||||
query: string;
|
||||
protocols?: string[];
|
||||
}
|
||||
|
||||
export interface TunnelWsOpenedPayload {
|
||||
protocol?: string;
|
||||
}
|
||||
|
||||
export interface TunnelWsClosePayload {
|
||||
code: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// Layer 2 handshake messages (plaintext JSON text frames, before encryption starts).
|
||||
export interface E2eeHelloMessage {
|
||||
t: 'hello';
|
||||
v: typeof RELAY_PROTOCOL_VERSION;
|
||||
clientPubJwk: JsonWebKey;
|
||||
nonce: string; // base64url, 16 bytes
|
||||
// Capability advertisement: the client can pack multiple tunnel frames into
|
||||
// one encrypted WS message. Missing/false = legacy (one frame per message).
|
||||
batch?: boolean;
|
||||
}
|
||||
|
||||
export interface E2eeReadyMessage {
|
||||
t: 'ready';
|
||||
v: typeof RELAY_PROTOCOL_VERSION;
|
||||
// Host echoes `batch: true` only when it also supports batching AND the client
|
||||
// advertised it. Batching is enabled for the session only if both agree.
|
||||
batch?: boolean;
|
||||
}
|
||||
|
||||
// Layer 1 control messages (relay <-> host control socket).
|
||||
export type RelayControlMessage =
|
||||
| { type: 'sync'; connectionIds: string[] }
|
||||
| { type: 'connected'; connectionId: string }
|
||||
| { type: 'disconnected'; connectionId: string }
|
||||
| { type: 'limit'; reason: string };
|
||||
|
||||
// Relay-assigned WebSocket close codes.
|
||||
export const RelayCloseCode = {
|
||||
ControlReplaced: 4001,
|
||||
DuplicateClient: 4002,
|
||||
StuckControlReset: 4003,
|
||||
HostUnavailable: 4008,
|
||||
AuthFailed: 4010,
|
||||
LimitExceeded: 4029,
|
||||
HostWentAway: 1012,
|
||||
RekeyMismatch: 1008,
|
||||
ChannelFailure: 1011,
|
||||
} as const;
|
||||
|
||||
// Pairing payload carried in QR / deep-link URL fragments only.
|
||||
export interface RelayOfferV1 {
|
||||
v: 1;
|
||||
mode: 'relay';
|
||||
relayUrl: string;
|
||||
serverId: string;
|
||||
hostEncPubJwk: JsonWebKey;
|
||||
label?: string;
|
||||
token?: string;
|
||||
grant?: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Opens a runtime WebSocket the right way for the active runtime: through the
|
||||
// relay tunnel when relay mode is active, or a native browser WebSocket
|
||||
// otherwise (wrapped to the same shape). Every runtime WS consumer — the event
|
||||
// pipeline, dictation, terminal — must go through here so relay mode carries
|
||||
// ALL socket traffic, not just the main event stream. A raw `new WebSocket(url)`
|
||||
// against a relay-mode runtime fails: the resolver yields a tunnel-virtual URL
|
||||
// (or a capacitor:// origin) that the platform WebSocket rejects with
|
||||
// "The string did not match the expected pattern".
|
||||
|
||||
import { getActiveRelayTunnel } from './runtime-tunnel';
|
||||
import { wsUrlToTunnelPath } from './tunnel-payloads';
|
||||
import { wrapBrowserWebSocket, type RelayTunnelWebSocket } from './tunnel-client';
|
||||
|
||||
export const openRuntimeWebSocket = (url: string, protocols?: string[]): RelayTunnelWebSocket => {
|
||||
const relay = getActiveRelayTunnel();
|
||||
if (relay) {
|
||||
return relay.openWebSocket(wsUrlToTunnelPath(url), protocols);
|
||||
}
|
||||
return wrapBrowserWebSocket(protocols ? new WebSocket(url, protocols) : new WebSocket(url));
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
// Module-level singleton holding the active relay tunnel client, if the runtime
|
||||
// is in relay mode. Kept in its own module so runtime-switch, runtime-fetch,
|
||||
// runtime-url, and the event pipeline can all read it without an import cycle
|
||||
// (runtime-switch <-> runtime-url).
|
||||
|
||||
import { createRelayTunnelClient, type RelayTunnelClient } from './tunnel-client';
|
||||
|
||||
export interface RelayRuntimeDescriptor {
|
||||
relayUrl: string;
|
||||
serverId: string;
|
||||
hostEncPubJwk: JsonWebKey;
|
||||
grant?: string;
|
||||
}
|
||||
|
||||
let activeTunnel: RelayTunnelClient | null = null;
|
||||
let activeDescriptor: RelayRuntimeDescriptor | null = null;
|
||||
|
||||
const descriptorsEqual = (a: RelayRuntimeDescriptor, b: RelayRuntimeDescriptor): boolean =>
|
||||
a.relayUrl === b.relayUrl &&
|
||||
a.serverId === b.serverId &&
|
||||
a.grant === b.grant &&
|
||||
JSON.stringify(a.hostEncPubJwk) === JSON.stringify(b.hostEncPubJwk);
|
||||
|
||||
export const getActiveRelayTunnel = (): RelayTunnelClient | null => activeTunnel;
|
||||
|
||||
export const isRelayModeActive = (): boolean => activeTunnel !== null;
|
||||
|
||||
/**
|
||||
* Activates relay mode with the given descriptor, replacing any previous tunnel.
|
||||
* Reuses the existing client when the descriptor is unchanged so a redundant
|
||||
* runtime switch does not tear down a live tunnel.
|
||||
*/
|
||||
export const activateRelayTunnel = (descriptor: RelayRuntimeDescriptor): RelayTunnelClient => {
|
||||
if (activeTunnel && activeDescriptor && descriptorsEqual(activeDescriptor, descriptor)) {
|
||||
return activeTunnel;
|
||||
}
|
||||
activeTunnel?.close();
|
||||
activeDescriptor = descriptor;
|
||||
activeTunnel = createRelayTunnelClient(descriptor);
|
||||
return activeTunnel;
|
||||
};
|
||||
|
||||
export const deactivateRelayTunnel = (): void => {
|
||||
activeTunnel?.close();
|
||||
activeTunnel = null;
|
||||
activeDescriptor = null;
|
||||
};
|
||||
@@ -0,0 +1,576 @@
|
||||
// Unit tests for the relay tunnel client against an in-memory wire pair whose
|
||||
// responder side is built from the SAME protocol modules (createHostHandshake +
|
||||
// the tunnel codec). No network, no real WebSocket.
|
||||
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
exportPublicKeyJwk,
|
||||
generateEcdhKeyPair,
|
||||
type FrameDecryptor,
|
||||
type FrameEncryptor,
|
||||
} from './crypto';
|
||||
import { createHostHandshake } from './handshake';
|
||||
import { TunnelFrameType } from './protocol';
|
||||
import {
|
||||
createFragmentAssembler,
|
||||
decodeFrameBatch,
|
||||
decodeJsonPayload,
|
||||
decodeTunnelFrame,
|
||||
encodeFrameBatch,
|
||||
encodeJsonPayload,
|
||||
encodeTunnelFrame,
|
||||
type TunnelFrame,
|
||||
} from './tunnel-codec';
|
||||
import {
|
||||
createRelayTunnelClient,
|
||||
type RelayTunnelClient,
|
||||
type TunnelWireSocket,
|
||||
} from './tunnel-client';
|
||||
|
||||
const WS_OPEN = 1;
|
||||
const WS_CLOSED = 3;
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const isWsOpenPayload = (
|
||||
value: unknown,
|
||||
): value is { path: string; query: string; protocols?: string[] } =>
|
||||
typeof value === 'object' && value !== null && typeof (value as { path?: unknown }).path === 'string';
|
||||
|
||||
const isHttpRequestPayload = (
|
||||
value: unknown,
|
||||
): value is { method: string; path: string; query: string; headers: Record<string, string> } =>
|
||||
typeof value === 'object' && value !== null && typeof (value as { path?: unknown }).path === 'string';
|
||||
|
||||
class FakeEndpoint implements TunnelWireSocket {
|
||||
readyState = WS_OPEN;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null;
|
||||
onclose: ((event: { code: number; reason: string }) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
peer: FakeEndpoint | null = null;
|
||||
closed = false;
|
||||
// Count binary (encrypted) WS messages that cross this endpoint's send path —
|
||||
// the billable unit the batching optimization is designed to reduce.
|
||||
binarySent = 0;
|
||||
|
||||
send(data: string | ArrayBuffer | Uint8Array): void {
|
||||
if (this.closed) return;
|
||||
if (typeof data !== 'string') this.binarySent += 1;
|
||||
const peer = this.peer;
|
||||
if (!peer) return;
|
||||
// Copy bytes so the receiver can't observe later mutation.
|
||||
const payload = typeof data === 'string' ? data : data instanceof Uint8Array ? data.slice() : new Uint8Array(data.slice(0));
|
||||
queueMicrotask(() => {
|
||||
if (peer.closed) return;
|
||||
peer.onmessage?.({ data: payload });
|
||||
});
|
||||
}
|
||||
|
||||
close(code = 1000, reason = ''): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.readyState = WS_CLOSED;
|
||||
const peer = this.peer;
|
||||
queueMicrotask(() => this.onclose?.({ code, reason }));
|
||||
if (peer && !peer.closed) {
|
||||
peer.closed = true;
|
||||
peer.readyState = WS_CLOSED;
|
||||
queueMicrotask(() => peer.onclose?.({ code, reason }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type MiniHostOptions = {
|
||||
silent?: boolean;
|
||||
onConnect?: () => void;
|
||||
// Delay handling of the first inbound text frame: with a delay longer than
|
||||
// the client's helloRetryMs this reproduces the first-connect race where the
|
||||
// client retries `hello` and the host answers every retry with `ready`.
|
||||
firstHelloDelayMs?: number;
|
||||
// Advertise batching from the host (default true = matches production).
|
||||
batch?: boolean;
|
||||
// Records every tunnel frame the host received, in arrival order.
|
||||
recordFrame?: (frame: TunnelFrame) => void;
|
||||
};
|
||||
|
||||
// A minimal host responder wired to one endpoint. Answers a few routes so the
|
||||
// client's HTTP/WS/abort paths can be exercised end to end.
|
||||
const attachMiniHost = (endpoint: FakeEndpoint, hostPrivateKey: CryptoKey, options: MiniHostOptions = {}): void => {
|
||||
const handshake = createHostHandshake(hostPrivateKey, { batch: options.batch });
|
||||
let encryptor: FrameEncryptor | null = null;
|
||||
let decryptor: FrameDecryptor | null = null;
|
||||
let batchNegotiated = false;
|
||||
const assembler = createFragmentAssembler();
|
||||
const httpBodies = new Map<number, Uint8Array[]>();
|
||||
const aborted = new Set<number>();
|
||||
let sendChain: Promise<void> = Promise.resolve();
|
||||
let recvChain: Promise<void> = Promise.resolve();
|
||||
|
||||
const sendFrame = (frame: Uint8Array): void => {
|
||||
sendChain = sendChain.then(async () => {
|
||||
if (!encryptor || endpoint.closed) return;
|
||||
// When batching is negotiated the client always expects a container tag,
|
||||
// so wrap even single frames (tag 0x00). The host here does not coalesce.
|
||||
const plaintext = batchNegotiated ? encodeFrameBatch([frame]) : frame;
|
||||
endpoint.send(await encryptor.encrypt(plaintext));
|
||||
});
|
||||
};
|
||||
|
||||
const respondJson = (streamId: number, status: number, body: unknown): void => {
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.HttpResponse, streamId, encodeJsonPayload({ status, headers: { 'content-type': 'application/json' } })));
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, textEncoder.encode(JSON.stringify(body))));
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, streamId, new Uint8Array(0)));
|
||||
};
|
||||
|
||||
const handleTunnelFrame = (frame: TunnelFrame): void => {
|
||||
options.recordFrame?.(frame);
|
||||
if (options.silent) return;
|
||||
if (frame.frameType === TunnelFrameType.Ping) {
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.Pong, frame.streamId, new Uint8Array(0)));
|
||||
return;
|
||||
}
|
||||
if (frame.frameType === TunnelFrameType.HttpRequest) {
|
||||
const req = decodeJsonPayload(frame.payload, isHttpRequestPayload);
|
||||
httpBodies.set(frame.streamId, []);
|
||||
(endpoint as FakeEndpoint & { pendingPath?: Map<number, string> }).pendingPath ??= new Map();
|
||||
(endpoint as FakeEndpoint & { pendingPath: Map<number, string> }).pendingPath.set(frame.streamId, req.path);
|
||||
return;
|
||||
}
|
||||
if (frame.frameType === TunnelFrameType.HttpBody) {
|
||||
httpBodies.get(frame.streamId)?.push(frame.payload);
|
||||
return;
|
||||
}
|
||||
if (frame.frameType === TunnelFrameType.StreamAbort) {
|
||||
aborted.add(frame.streamId);
|
||||
return;
|
||||
}
|
||||
if (frame.frameType === TunnelFrameType.StreamEnd) {
|
||||
const paths = (endpoint as FakeEndpoint & { pendingPath?: Map<number, string> }).pendingPath;
|
||||
const path = paths?.get(frame.streamId) ?? '';
|
||||
const bodyChunks = httpBodies.get(frame.streamId) ?? [];
|
||||
const total = bodyChunks.reduce((sum, c) => sum + c.length, 0);
|
||||
const body = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const c of bodyChunks) {
|
||||
body.set(c, off);
|
||||
off += c.length;
|
||||
}
|
||||
const streamId = frame.streamId;
|
||||
if (path === '/health') {
|
||||
respondJson(streamId, 200, { ok: true });
|
||||
} else if (path === '/echo-body') {
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.HttpResponse, streamId, encodeJsonPayload({ status: 200, headers: {} })));
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, body));
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, streamId, new Uint8Array(0)));
|
||||
} else if (path === '/stream') {
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.HttpResponse, streamId, encodeJsonPayload({ status: 200, headers: {} })));
|
||||
const emit = (index: number): void => {
|
||||
if (aborted.has(streamId)) return;
|
||||
if (index >= 3) {
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.StreamEnd, streamId, new Uint8Array(0)));
|
||||
return;
|
||||
}
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, textEncoder.encode(`chunk${index};`)));
|
||||
setTimeout(() => emit(index + 1), 10);
|
||||
};
|
||||
emit(0);
|
||||
} else if (path === '/never-ends') {
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.HttpResponse, streamId, encodeJsonPayload({ status: 200, headers: {} })));
|
||||
const pump = (): void => {
|
||||
if (aborted.has(streamId) || endpoint.closed) return;
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, textEncoder.encode('tick;')));
|
||||
setTimeout(pump, 10);
|
||||
};
|
||||
pump();
|
||||
} else {
|
||||
respondJson(streamId, 404, { error: 'not found' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (frame.frameType === TunnelFrameType.WsOpen) {
|
||||
const open = decodeJsonPayload(frame.payload, isWsOpenPayload);
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.WsOpened, frame.streamId, encodeJsonPayload(open.protocols?.length ? { protocol: open.protocols[0] } : {})));
|
||||
return;
|
||||
}
|
||||
if (frame.frameType === TunnelFrameType.WsText) {
|
||||
const complete = assembler.push(frame);
|
||||
if (!complete) return;
|
||||
const text = textDecoder.decode(complete);
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.WsText, frame.streamId, textEncoder.encode(`echo:${text}`)));
|
||||
return;
|
||||
}
|
||||
if (frame.frameType === TunnelFrameType.WsClose) {
|
||||
sendFrame(encodeTunnelFrame(TunnelFrameType.WsClose, frame.streamId, frame.payload));
|
||||
}
|
||||
};
|
||||
|
||||
let firstHelloDelayed = false;
|
||||
endpoint.onmessage = (event) => {
|
||||
const data = event.data;
|
||||
recvChain = recvChain.then(async () => {
|
||||
if (typeof data === 'string') {
|
||||
if (options.firstHelloDelayMs && !firstHelloDelayed) {
|
||||
firstHelloDelayed = true;
|
||||
await new Promise((resolve) => setTimeout(resolve, options.firstHelloDelayMs));
|
||||
}
|
||||
const action = await handshake.handleText(data);
|
||||
if (action.type === 'established') {
|
||||
encryptor = action.channel.encryptor;
|
||||
decryptor = action.channel.decryptor;
|
||||
batchNegotiated = action.batch;
|
||||
if (action.replyText) endpoint.send(action.replyText);
|
||||
options.onConnect?.();
|
||||
} else if (action.type === 'send-text' && action.text) {
|
||||
endpoint.send(action.text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!decryptor) return;
|
||||
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer);
|
||||
const plaintext = await decryptor.decrypt(bytes);
|
||||
const frames = batchNegotiated ? decodeFrameBatch(plaintext) : [plaintext];
|
||||
for (const frame of frames) handleTunnelFrame(decodeTunnelFrame(frame));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const setupClient = async (
|
||||
hostOptions: MiniHostOptions = {},
|
||||
clientOverrides: Partial<Parameters<typeof createRelayTunnelClient>[0]> = {},
|
||||
): Promise<{
|
||||
client: RelayTunnelClient;
|
||||
connectionCount: () => number;
|
||||
killWire: () => void;
|
||||
sendTextToClient: (text: string) => void;
|
||||
clientBinaryCount: () => number;
|
||||
}> => {
|
||||
const hostKeyPair = await generateEcdhKeyPair();
|
||||
const hostPubJwk = await exportPublicKeyJwk(hostKeyPair.publicKey);
|
||||
let count = 0;
|
||||
let lastClientEndpoint: FakeEndpoint | null = null;
|
||||
let lastHostEndpoint: FakeEndpoint | null = null;
|
||||
const client = createRelayTunnelClient({
|
||||
relayUrl: 'wss://relay.test/ws',
|
||||
serverId: 'server-1',
|
||||
hostEncPubJwk: hostPubJwk,
|
||||
helloRetryMs: 20,
|
||||
pingIntervalMs: 40,
|
||||
pingTimeoutMs: 120,
|
||||
reconnectBaseDelayMs: 20,
|
||||
reconnectMaxDelayMs: 80,
|
||||
...clientOverrides,
|
||||
createWireSocket: () => {
|
||||
count += 1;
|
||||
const clientEndpoint = new FakeEndpoint();
|
||||
const hostEndpoint = new FakeEndpoint();
|
||||
clientEndpoint.peer = hostEndpoint;
|
||||
hostEndpoint.peer = clientEndpoint;
|
||||
lastClientEndpoint = clientEndpoint;
|
||||
lastHostEndpoint = hostEndpoint;
|
||||
attachMiniHost(hostEndpoint, hostKeyPair.privateKey, hostOptions);
|
||||
queueMicrotask(() => clientEndpoint.onopen?.());
|
||||
return clientEndpoint;
|
||||
},
|
||||
});
|
||||
return {
|
||||
client,
|
||||
connectionCount: () => count,
|
||||
killWire: () => lastClientEndpoint?.close(1006, 'killed'),
|
||||
sendTextToClient: (text: string) => lastHostEndpoint?.send(text),
|
||||
clientBinaryCount: () => lastClientEndpoint?.binarySent ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
let openClients: RelayTunnelClient[] = [];
|
||||
afterEach(() => {
|
||||
for (const client of openClients) client.close();
|
||||
openClients = [];
|
||||
});
|
||||
|
||||
const track = (client: RelayTunnelClient): RelayTunnelClient => {
|
||||
openClients.push(client);
|
||||
return client;
|
||||
};
|
||||
|
||||
describe('createRelayTunnelClient', () => {
|
||||
test('performs concurrent fetches over one tunnel', async () => {
|
||||
const { client } = await setupClient();
|
||||
track(client);
|
||||
const [a, b, c] = await Promise.all([
|
||||
client.fetch('/health'),
|
||||
client.fetch('/health'),
|
||||
client.fetch('/echo-body', { method: 'POST', body: 'payload-xyz' }),
|
||||
]);
|
||||
expect(a.status).toBe(200);
|
||||
expect(await a.json()).toEqual({ ok: true });
|
||||
expect(b.status).toBe(200);
|
||||
expect(await b.text()).toBe(await new Response('{"ok":true}').text());
|
||||
expect(await c.text()).toBe('payload-xyz');
|
||||
});
|
||||
|
||||
test('streams a response body incrementally', async () => {
|
||||
const { client } = await setupClient();
|
||||
track(client);
|
||||
const response = await client.fetch('/stream');
|
||||
expect(response.body).not.toBeNull();
|
||||
const reader = response.body!.getReader();
|
||||
const chunks: string[] = [];
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) chunks.push(textDecoder.decode(value));
|
||||
}
|
||||
expect(chunks.join('')).toBe('chunk0;chunk1;chunk2;');
|
||||
// The body arrived as multiple frames, not one buffered blob.
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test('propagates abort to the host and errors the stream', async () => {
|
||||
const { client } = await setupClient();
|
||||
track(client);
|
||||
const controller = new AbortController();
|
||||
const response = await client.fetch('/never-ends', { signal: controller.signal });
|
||||
const reader = response.body!.getReader();
|
||||
await reader.read();
|
||||
controller.abort();
|
||||
await expect(reader.read()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('opens, echoes, and closes a tunneled WebSocket', async () => {
|
||||
const { client } = await setupClient();
|
||||
track(client);
|
||||
const socket = client.openWebSocket('/api/global/event/ws?x=1');
|
||||
const opened = new Promise<void>((resolve) => {
|
||||
socket.onopen = () => resolve();
|
||||
});
|
||||
await opened;
|
||||
expect(socket.readyState).toBe(WS_OPEN);
|
||||
const message = new Promise<string>((resolve) => {
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') resolve(event.data);
|
||||
};
|
||||
});
|
||||
socket.send('hello');
|
||||
expect(await message).toBe('echo:hello');
|
||||
const closed = new Promise<number>((resolve) => {
|
||||
socket.onclose = (event) => resolve(event.code);
|
||||
});
|
||||
socket.close(1000, 'done');
|
||||
await closed;
|
||||
expect(socket.readyState).toBe(WS_CLOSED);
|
||||
});
|
||||
|
||||
test('fails open streams on reconnect and recovers on retry', async () => {
|
||||
const { client, connectionCount, killWire } = await setupClient();
|
||||
track(client);
|
||||
const response = await client.fetch('/never-ends');
|
||||
const reader = response.body!.getReader();
|
||||
await reader.read();
|
||||
const socket = client.openWebSocket('/api/event/ws');
|
||||
const socketClosed = new Promise<number>((resolve) => {
|
||||
socket.onclose = (event) => resolve(event.code);
|
||||
});
|
||||
const firstConnections = connectionCount();
|
||||
|
||||
// Kill the relay socket: all open streams must fail so callers' retry
|
||||
// machinery recovers. Tunnel-killed sockets close with 1012.
|
||||
killWire();
|
||||
await expect(reader.read()).rejects.toThrow();
|
||||
expect(await socketClosed).toBe(1012);
|
||||
|
||||
// The client reconnects a fresh wire and works again.
|
||||
const health = await client.fetch('/health');
|
||||
expect(health.status).toBe(200);
|
||||
expect(connectionCount()).toBeGreaterThan(firstConnections);
|
||||
});
|
||||
|
||||
test('reconnects when keepalive times out against a silent host', async () => {
|
||||
const { client, connectionCount } = await setupClient({ silent: true });
|
||||
track(client);
|
||||
// Wait for the first handshake to establish, then for the keepalive timeout
|
||||
// to fire and trigger a reconnect (a new wire connection).
|
||||
await wait(400);
|
||||
expect(connectionCount()).toBeGreaterThan(1);
|
||||
const status = client.getStatus();
|
||||
expect(['reconnecting', 'connecting', 'connected', 'error']).toContain(status.state);
|
||||
});
|
||||
|
||||
test('survives duplicate ready frames from a slow first handshake (first-request 500 regression)', async () => {
|
||||
// firstHelloDelayMs > helloRetryMs (20ms): the client retries `hello`
|
||||
// several times, and the host answers every retry with `ready`. The
|
||||
// duplicate `ready` frames arrive after the client established and must
|
||||
// NOT reset the channel or fail the first in-flight request.
|
||||
const { client, connectionCount, sendTextToClient } = await setupClient({ firstHelloDelayMs: 70 });
|
||||
track(client);
|
||||
// First request in flight with a streamed response...
|
||||
const response = await client.fetch('/stream');
|
||||
const reader = response.body!.getReader();
|
||||
await reader.read();
|
||||
// ...when a straggler duplicate `ready` (the host's answer to a retried
|
||||
// hello) lands on the established channel. Real-world timing: the retry
|
||||
// answer crosses the relay ~helloRetryMs after the first `ready`.
|
||||
sendTextToClient(JSON.stringify({ t: 'ready', v: 1 }));
|
||||
const chunks: string[] = [];
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) chunks.push(textDecoder.decode(value));
|
||||
}
|
||||
expect(chunks.join('')).toContain('chunk');
|
||||
expect(connectionCount()).toBe(1);
|
||||
expect(client.getStatus().state).toBe('connected');
|
||||
const again = await client.fetch('/health');
|
||||
expect(again.status).toBe(200);
|
||||
expect(connectionCount()).toBe(1);
|
||||
});
|
||||
|
||||
test('fails closed on non-ready plaintext after establishment', async () => {
|
||||
const { client, connectionCount, sendTextToClient } = await setupClient();
|
||||
track(client);
|
||||
await client.fetch('/health');
|
||||
expect(connectionCount()).toBe(1);
|
||||
sendTextToClient('{"anything":"plaintext"}');
|
||||
// The channel must reset (fail closed) and the client reconnect a new wire.
|
||||
await wait(150);
|
||||
expect(connectionCount()).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test('publishes status transitions to subscribers', async () => {
|
||||
const { client } = await setupClient();
|
||||
track(client);
|
||||
const seen: string[] = [];
|
||||
client.subscribeStatus((status) => seen.push(status.state));
|
||||
await client.fetch('/health');
|
||||
expect(seen).toContain('connected');
|
||||
});
|
||||
|
||||
test('packs a burst of WS messages into far fewer wire messages, preserving order', async () => {
|
||||
const received: TunnelFrame[] = [];
|
||||
const { client, clientBinaryCount } = await setupClient(
|
||||
{ recordFrame: (frame) => received.push(frame) },
|
||||
{ batchWindowMs: 100 },
|
||||
);
|
||||
track(client);
|
||||
const socket = client.openWebSocket('/api/event/ws');
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.onopen = () => resolve();
|
||||
});
|
||||
|
||||
const echoes: string[] = [];
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') echoes.push(event.data);
|
||||
};
|
||||
|
||||
const BURST = 50;
|
||||
const baseline = clientBinaryCount(); // WsOpen etc. before the burst
|
||||
for (let i = 0; i < BURST; i += 1) socket.send(`m${i}`);
|
||||
|
||||
// Wait for the trailing window to flush and echoes to round-trip.
|
||||
await wait(250);
|
||||
|
||||
const bodyFrames = received.filter((f) => f.frameType === TunnelFrameType.WsText);
|
||||
expect(bodyFrames.length).toBe(BURST);
|
||||
// Order preserved: the host saw m0..m49 in sequence.
|
||||
expect(bodyFrames.map((f) => textDecoder.decode(f.payload))).toEqual(
|
||||
Array.from({ length: BURST }, (_, i) => `m${i}`),
|
||||
);
|
||||
// Echoes arrived in order too.
|
||||
expect(echoes).toEqual(Array.from({ length: BURST }, (_, i) => `echo:m${i}`));
|
||||
|
||||
// The 50 frames crossed the wire as a handful of encrypted messages, not 50.
|
||||
const burstWireMessages = clientBinaryCount() - baseline;
|
||||
expect(burstWireMessages).toBeLessThan(BURST / 3);
|
||||
expect(burstWireMessages).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('leading edge: a single frame after idle is delivered immediately, not a window later', async () => {
|
||||
const WINDOW = 300;
|
||||
let firstWsTextAt = 0;
|
||||
const { client } = await setupClient(
|
||||
{
|
||||
recordFrame: (frame) => {
|
||||
if (frame.frameType === TunnelFrameType.WsText && firstWsTextAt === 0) {
|
||||
firstWsTextAt = Date.now();
|
||||
}
|
||||
},
|
||||
},
|
||||
{ batchWindowMs: WINDOW },
|
||||
);
|
||||
track(client);
|
||||
const socket = client.openWebSocket('/api/event/ws');
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.onopen = () => resolve();
|
||||
});
|
||||
// Stay idle beyond the window so the next frame takes the leading edge.
|
||||
await wait(WINDOW + 50);
|
||||
const sentAt = Date.now();
|
||||
socket.send('solo');
|
||||
await wait(WINDOW / 2);
|
||||
expect(firstWsTextAt).toBeGreaterThan(0);
|
||||
// Delivered well within a full window (leading-edge flush), not delayed.
|
||||
expect(firstWsTextAt - sentAt).toBeLessThan(WINDOW / 2);
|
||||
});
|
||||
|
||||
test('boundary frame (StreamEnd) flushes buffered body immediately', async () => {
|
||||
// A large batch window would stall a POST body if StreamEnd did not force a
|
||||
// flush; the request completing quickly proves the boundary flush.
|
||||
const { client } = await setupClient({}, { batchWindowMs: 1_000 });
|
||||
track(client);
|
||||
const start = Date.now();
|
||||
const response = await client.fetch('/echo-body', { method: 'POST', body: 'boundary-body' });
|
||||
expect(await response.text()).toBe('boundary-body');
|
||||
expect(Date.now() - start).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test('keepalive: no ping while frames flow, ping fires after idle', async () => {
|
||||
const pings: number[] = [];
|
||||
const { client } = await setupClient(
|
||||
{
|
||||
recordFrame: (frame) => {
|
||||
if (frame.frameType === TunnelFrameType.Ping) pings.push(Date.now());
|
||||
},
|
||||
},
|
||||
{ pingIntervalMs: 40, pingTimeoutMs: 5_000, batchWindowMs: 20 },
|
||||
);
|
||||
track(client);
|
||||
const socket = client.openWebSocket('/api/event/ws');
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.onopen = () => resolve();
|
||||
});
|
||||
|
||||
// Keep traffic flowing faster than the ping interval for a few intervals.
|
||||
const busyUntil = Date.now() + 200;
|
||||
while (Date.now() < busyUntil) {
|
||||
socket.send('keepbusy');
|
||||
await wait(10);
|
||||
}
|
||||
expect(pings.length).toBe(0);
|
||||
|
||||
// Now go idle: a ping must appear once we exceed the interval.
|
||||
await wait(150);
|
||||
expect(pings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('negotiates legacy (no batch) when the host does not advertise batching', async () => {
|
||||
// Host advertises batch:false -> both directions fall back to one frame per
|
||||
// encrypted message. Everything still works end to end.
|
||||
const { client } = await setupClient({ batch: false });
|
||||
track(client);
|
||||
const socket = client.openWebSocket('/api/event/ws');
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.onopen = () => resolve();
|
||||
});
|
||||
const message = new Promise<string>((resolve) => {
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') resolve(event.data);
|
||||
};
|
||||
});
|
||||
socket.send('legacy');
|
||||
expect(await message).toBe('echo:legacy');
|
||||
const health = await client.fetch('/health');
|
||||
expect(health.status).toBe(200);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
MAX_TUNNEL_PAYLOAD_BYTES,
|
||||
TunnelFrameType,
|
||||
type TunnelHttpRequestPayload,
|
||||
} from './protocol';
|
||||
import {
|
||||
chunkPayload,
|
||||
createFragmentAssembler,
|
||||
createStreamIdAllocator,
|
||||
decodeFrameBatch,
|
||||
decodeJsonPayload,
|
||||
decodeTunnelFrame,
|
||||
encodeFragmentedMessage,
|
||||
encodeFrameBatch,
|
||||
encodeJsonPayload,
|
||||
encodeTunnelFrame,
|
||||
TunnelCodecError,
|
||||
} from './tunnel-codec';
|
||||
import { MAX_PLAINTEXT_FRAME_BYTES } from './protocol';
|
||||
|
||||
const randomBytes = (length: number): Uint8Array => {
|
||||
const bytes = new Uint8Array(length);
|
||||
// getRandomValues caps at 64 KiB per call.
|
||||
for (let offset = 0; offset < length; offset += 65536) {
|
||||
globalThis.crypto.getRandomValues(bytes.subarray(offset, Math.min(offset + 65536, length)));
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
describe('tunnel codec', () => {
|
||||
test('frame round trip preserves type, stream id, and payload', () => {
|
||||
const payload = randomBytes(1024);
|
||||
for (const streamId of [1, 3, 0x7fffffff, 0xffffffff]) {
|
||||
const frame = decodeTunnelFrame(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, payload));
|
||||
expect(frame.frameType).toBe(TunnelFrameType.HttpBody);
|
||||
expect(frame.streamId).toBe(streamId);
|
||||
expect(frame.payload).toEqual(payload);
|
||||
expect(frame.hasMoreFragments).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('fragment flag round trips and is separated from the frame type', () => {
|
||||
const frame = decodeTunnelFrame(
|
||||
encodeTunnelFrame(TunnelFrameType.WsBinary, 5, new Uint8Array([1]), true),
|
||||
);
|
||||
expect(frame.frameType).toBe(TunnelFrameType.WsBinary);
|
||||
expect(frame.hasMoreFragments).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects invalid stream ids, oversized payloads, short and unknown frames', () => {
|
||||
const payload = new Uint8Array(1);
|
||||
expect(() => encodeTunnelFrame(TunnelFrameType.Ping, -1, payload)).toThrow(TunnelCodecError);
|
||||
expect(() => encodeTunnelFrame(TunnelFrameType.Ping, 2 ** 32, payload)).toThrow(TunnelCodecError);
|
||||
expect(() => encodeTunnelFrame(TunnelFrameType.Ping, 1.5, payload)).toThrow(TunnelCodecError);
|
||||
expect(() =>
|
||||
encodeTunnelFrame(TunnelFrameType.HttpBody, 1, new Uint8Array(MAX_TUNNEL_PAYLOAD_BYTES + 1)),
|
||||
).toThrow('tunnel payload exceeds maximum size');
|
||||
expect(() => decodeTunnelFrame(new Uint8Array(4))).toThrow('tunnel frame too short');
|
||||
const unknown = new Uint8Array(5);
|
||||
unknown[0] = 63;
|
||||
expect(() => decodeTunnelFrame(unknown)).toThrow('unknown tunnel frame type 63');
|
||||
});
|
||||
|
||||
test('json payload helpers validate shape', () => {
|
||||
const isHttpRequest = (parsed: unknown): parsed is TunnelHttpRequestPayload =>
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
typeof (parsed as TunnelHttpRequestPayload).method === 'string' &&
|
||||
typeof (parsed as TunnelHttpRequestPayload).path === 'string';
|
||||
const payload = encodeJsonPayload({ method: 'GET', path: '/health', query: '', headers: {} });
|
||||
const decoded = decodeJsonPayload(payload, isHttpRequest);
|
||||
expect(decoded.method).toBe('GET');
|
||||
expect(() => decodeJsonPayload(new Uint8Array([0x7b]), isHttpRequest)).toThrow(
|
||||
'malformed JSON tunnel payload',
|
||||
);
|
||||
expect(() => decodeJsonPayload(encodeJsonPayload({ nope: true }), isHttpRequest)).toThrow(
|
||||
'unexpected JSON tunnel payload shape',
|
||||
);
|
||||
});
|
||||
|
||||
test('chunkPayload splits exactly and yields one empty chunk for empty input', () => {
|
||||
expect(chunkPayload(new Uint8Array(0))).toEqual([new Uint8Array(0)]);
|
||||
const bytes = randomBytes(10);
|
||||
const chunks = chunkPayload(bytes, 4);
|
||||
expect(chunks.map((c) => c.length)).toEqual([4, 4, 2]);
|
||||
expect(() => chunkPayload(bytes, 0)).toThrow(TunnelCodecError);
|
||||
expect(() => chunkPayload(bytes, MAX_TUNNEL_PAYLOAD_BYTES + 1)).toThrow(TunnelCodecError);
|
||||
});
|
||||
|
||||
test('large message fragments and reassembles byte-identically', () => {
|
||||
const message = randomBytes(MAX_TUNNEL_PAYLOAD_BYTES * 2 + 12345);
|
||||
const frames = encodeFragmentedMessage(TunnelFrameType.WsBinary, 7, message);
|
||||
expect(frames.length).toBe(3);
|
||||
const assembler = createFragmentAssembler();
|
||||
let result: Uint8Array | null = null;
|
||||
for (const encoded of frames) {
|
||||
result = assembler.push(decodeTunnelFrame(encoded));
|
||||
}
|
||||
expect(result).toEqual(message);
|
||||
});
|
||||
|
||||
test('assembler keeps interleaved streams separate and passes unfragmented frames through', () => {
|
||||
const assembler = createFragmentAssembler();
|
||||
const a1 = { frameType: TunnelFrameType.WsBinary, streamId: 1, payload: new Uint8Array([1]), hasMoreFragments: true };
|
||||
const b = { frameType: TunnelFrameType.WsText, streamId: 3, payload: new Uint8Array([9]), hasMoreFragments: false };
|
||||
const a2 = { frameType: TunnelFrameType.WsBinary, streamId: 1, payload: new Uint8Array([2]), hasMoreFragments: false };
|
||||
expect(assembler.push(a1)).toBeNull();
|
||||
expect(assembler.push(b)).toEqual(new Uint8Array([9]));
|
||||
expect(assembler.push(a2)).toEqual(new Uint8Array([1, 2]));
|
||||
});
|
||||
|
||||
test('assembler enforces max message size and dropStream clears pending state', () => {
|
||||
const assembler = createFragmentAssembler(8);
|
||||
const fragment = (payload: Uint8Array, more: boolean) => ({
|
||||
frameType: TunnelFrameType.WsBinary,
|
||||
streamId: 1,
|
||||
payload,
|
||||
hasMoreFragments: more,
|
||||
});
|
||||
expect(assembler.push(fragment(new Uint8Array(6), true))).toBeNull();
|
||||
expect(() => assembler.push(fragment(new Uint8Array(6), false))).toThrow(
|
||||
'fragmented message exceeds maximum size',
|
||||
);
|
||||
|
||||
expect(assembler.push(fragment(new Uint8Array([1]), true))).toBeNull();
|
||||
assembler.dropStream(1);
|
||||
// After drop, a terminal fragment stands alone rather than joining stale chunks.
|
||||
expect(assembler.push(fragment(new Uint8Array([2]), false))).toEqual(new Uint8Array([2]));
|
||||
});
|
||||
|
||||
test('stream id allocator yields odd ascending ids', () => {
|
||||
const allocator = createStreamIdAllocator();
|
||||
expect([allocator.next(), allocator.next(), allocator.next()]).toEqual([1, 3, 5]);
|
||||
});
|
||||
|
||||
test('frame batch round-trips N frames byte-identically, in order', () => {
|
||||
const frames = [
|
||||
encodeTunnelFrame(TunnelFrameType.HttpBody, 1, randomBytes(10)),
|
||||
encodeTunnelFrame(TunnelFrameType.WsText, 3, randomBytes(64)),
|
||||
encodeTunnelFrame(TunnelFrameType.WsBinary, 5, randomBytes(500)),
|
||||
];
|
||||
const decoded = decodeFrameBatch(encodeFrameBatch(frames));
|
||||
expect(decoded.length).toBe(frames.length);
|
||||
decoded.forEach((frame, index) => expect(frame).toEqual(frames[index]));
|
||||
});
|
||||
|
||||
test('single-frame batch uses the compact tag with 1 byte of overhead', () => {
|
||||
const frame = encodeTunnelFrame(TunnelFrameType.HttpBody, 7, randomBytes(128));
|
||||
const encoded = encodeFrameBatch([frame]);
|
||||
expect(encoded[0]).toBe(0x00); // BATCH_CONTAINER_TAG_SINGLE
|
||||
expect(encoded.length).toBe(frame.length + 1);
|
||||
const decoded = decodeFrameBatch(encoded);
|
||||
expect(decoded.length).toBe(1);
|
||||
expect(decoded[0]).toEqual(frame);
|
||||
});
|
||||
|
||||
test('multi-frame batch uses the length-prefixed tag', () => {
|
||||
const encoded = encodeFrameBatch([
|
||||
encodeTunnelFrame(TunnelFrameType.HttpBody, 1, new Uint8Array([1])),
|
||||
encodeTunnelFrame(TunnelFrameType.HttpBody, 1, new Uint8Array([2])),
|
||||
]);
|
||||
expect(encoded[0]).toBe(0x01); // BATCH_CONTAINER_TAG_BATCH
|
||||
});
|
||||
|
||||
test('rejects empty input and oversized batches, and truncated/unknown containers', () => {
|
||||
expect(() => encodeFrameBatch([])).toThrow(TunnelCodecError);
|
||||
const huge = new Uint8Array(MAX_PLAINTEXT_FRAME_BYTES); // no room for the tag
|
||||
expect(() => encodeFrameBatch([huge])).toThrow('frame batch exceeds maximum plaintext size');
|
||||
expect(() => decodeFrameBatch(new Uint8Array(0))).toThrow('empty batch plaintext');
|
||||
expect(() => decodeFrameBatch(new Uint8Array([0x09]))).toThrow('unknown batch container tag 9');
|
||||
// tag 0x01 then a length claiming more bytes than present.
|
||||
expect(() => decodeFrameBatch(new Uint8Array([0x01, 0, 0, 0, 8, 1, 2]))).toThrow(
|
||||
'truncated batch frame body',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,384 @@
|
||||
// Tunnel mux frame codec (Layer 3 of the protocol spec). Pure functions, no I/O.
|
||||
// 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 {
|
||||
BATCH_CONTAINER_TAG_BATCH,
|
||||
BATCH_CONTAINER_TAG_SINGLE,
|
||||
BATCH_FRAME_LENGTH_BYTES,
|
||||
MAX_PLAINTEXT_FRAME_BYTES,
|
||||
MAX_TUNNEL_PAYLOAD_BYTES,
|
||||
TUNNEL_FRAGMENT_FLAG,
|
||||
TUNNEL_FRAME_HEADER_BYTES,
|
||||
TunnelFrameType,
|
||||
isTunnelFrameType,
|
||||
type TunnelFrameTypeValue,
|
||||
} from './protocol';
|
||||
|
||||
const MAX_STREAM_ID = 0xffffffff;
|
||||
|
||||
export class TunnelCodecError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TunnelCodecError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface TunnelFrame {
|
||||
frameType: TunnelFrameTypeValue;
|
||||
streamId: number;
|
||||
payload: Uint8Array;
|
||||
/** True when this frame is a fragment and more fragments of the same message follow. */
|
||||
hasMoreFragments: boolean;
|
||||
}
|
||||
|
||||
export const encodeTunnelFrame = (
|
||||
frameType: TunnelFrameTypeValue,
|
||||
streamId: number,
|
||||
payload: Uint8Array,
|
||||
hasMoreFragments = false,
|
||||
): Uint8Array => {
|
||||
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;
|
||||
};
|
||||
|
||||
export const decodeTunnelFrame = (frame: Uint8Array): TunnelFrame => {
|
||||
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();
|
||||
|
||||
export const encodeJsonPayload = (value: unknown): Uint8Array => textEncoder.encode(JSON.stringify(value));
|
||||
|
||||
export const decodeJsonPayload = <T>(payload: Uint8Array, validate: (parsed: unknown) => parsed is T): T => {
|
||||
let parsed: unknown;
|
||||
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. */
|
||||
export const chunkPayload = (bytes: Uint8Array, chunkSize = MAX_TUNNEL_PAYLOAD_BYTES): Uint8Array[] => {
|
||||
if (chunkSize <= 0 || chunkSize > MAX_TUNNEL_PAYLOAD_BYTES) {
|
||||
throw new TunnelCodecError('invalid chunk size');
|
||||
}
|
||||
if (bytes.length === 0) return [new Uint8Array(0)];
|
||||
const chunks: Uint8Array[] = [];
|
||||
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.
|
||||
*/
|
||||
export const encodeFragmentedMessage = (
|
||||
frameType: TunnelFrameTypeValue,
|
||||
streamId: number,
|
||||
payload: Uint8Array,
|
||||
): Uint8Array[] => {
|
||||
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. */
|
||||
export const createFragmentAssembler = (maxMessageBytes = 16 * 1024 * 1024) => {
|
||||
const pending = new Map<string, { chunks: Uint8Array[]; totalBytes: number }>();
|
||||
return {
|
||||
/**
|
||||
* Returns the complete message payload once all fragments arrived, or null
|
||||
* while more fragments are expected.
|
||||
*/
|
||||
push(frame: TunnelFrame): Uint8Array | null {
|
||||
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;
|
||||
},
|
||||
dropStream(streamId: number): void {
|
||||
for (const key of pending.keys()) {
|
||||
if (key.startsWith(`${streamId}:`)) pending.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Batch envelope encoder (Layer 2 plaintext container). 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.
|
||||
*
|
||||
* - 1 frame -> [0x00][frame bytes] (single, 1-byte overhead)
|
||||
* - N frames -> [0x01]([4B BE length][frame])×N (batch)
|
||||
*
|
||||
* Callers must keep the encoded size within MAX_PLAINTEXT_FRAME_BYTES; the
|
||||
* outbound batcher flushes before an add would exceed the budget.
|
||||
*/
|
||||
export const encodeFrameBatch = (frames: Uint8Array[]): Uint8Array => {
|
||||
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. */
|
||||
export const decodeFrameBatch = (plaintext: Uint8Array): Uint8Array[] => {
|
||||
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: Uint8Array[] = [];
|
||||
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<number>([
|
||||
TunnelFrameType.HttpBody,
|
||||
TunnelFrameType.WsText,
|
||||
TunnelFrameType.WsBinary,
|
||||
]);
|
||||
|
||||
// 150ms: the chat render pipeline already gates visible streaming updates well below this — a
|
||||
// 100ms input throttle (useStreamingTextThrottle) feeding a ~64ms paced-reveal (usePacedText) that
|
||||
// buffers-and-smooths arrival bursts, and the app already tolerates 200ms under backpressure. So a
|
||||
// 150ms batch window is invisible to users while cutting DO messages ~33% more than 100ms.
|
||||
// Leading-edge flush keeps time-to-first-token and terminal echo instant regardless of this value.
|
||||
export const DEFAULT_BATCH_WINDOW_MS = 150;
|
||||
export const DEFAULT_BATCH_MAX_BYTES = 24 * 1024;
|
||||
export const DEFAULT_BATCH_MAX_FRAMES = 32;
|
||||
|
||||
export interface OutboundFrameBatcherOptions {
|
||||
/** Trailing flush window in ms. Buffered frames flush no later than this. */
|
||||
windowMs?: number;
|
||||
maxBatchBytes?: number;
|
||||
maxBatchFrames?: number;
|
||||
/** Encrypt + write one batched plaintext to the wire. Called in enqueue order. */
|
||||
sendBatch: (plaintext: Uint8Array) => void;
|
||||
// Injectable clock/timer so tests can drive timing deterministically.
|
||||
now?: () => number;
|
||||
setTimer?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
||||
clearTimer?: (handle: ReturnType<typeof setTimeout>) => void;
|
||||
}
|
||||
|
||||
export interface OutboundFrameBatcher {
|
||||
/** Buffer or immediately flush a tunnel frame per the batching policy. */
|
||||
enqueue(frame: Uint8Array): void;
|
||||
/** Force-flush any buffered frames now. */
|
||||
flush(): void;
|
||||
/** Stop the batcher; drops any un-flushed frames (channel is being torn down). */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbound batching buffer shared by the client and (mirrored in JS) the host
|
||||
* send paths. Policy:
|
||||
* - Leading edge: if nothing flushed within windowMs, the frame ships now
|
||||
* (batch of 1) — keeps time-to-first-token and keystroke echo instant.
|
||||
* - Trailing window: subsequent body frames buffer and flush when the timer
|
||||
* fires, buffered bytes >= maxBatchBytes, buffered frames >= maxBatchFrames,
|
||||
* or the plaintext budget would be exceeded.
|
||||
* - Non-buffered frame types (setup/teardown/keepalive) flush immediately, and
|
||||
* flush any pending buffer first so per-stream ordering is preserved.
|
||||
*/
|
||||
export const createOutboundFrameBatcher = (
|
||||
options: OutboundFrameBatcherOptions,
|
||||
): OutboundFrameBatcher => {
|
||||
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: Uint8Array[] = [];
|
||||
let bufferedBytes = 0; // conservative multi-envelope size estimate
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastFlushAt = 0; // 0 => idle, so the first frame takes the leading edge
|
||||
let disposed = false;
|
||||
|
||||
const clearPendingTimer = (): void => {
|
||||
if (timer !== null) {
|
||||
clearTimer(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const flush = (): void => {
|
||||
clearPendingTimer();
|
||||
if (buffer.length === 0) return;
|
||||
const frames = buffer;
|
||||
buffer = [];
|
||||
bufferedBytes = 0;
|
||||
lastFlushAt = now();
|
||||
options.sendBatch(encodeFrameBatch(frames));
|
||||
};
|
||||
|
||||
const enqueue = (frame: Uint8Array): void => {
|
||||
if (disposed) return;
|
||||
const frameType = frame[0] & ~TUNNEL_FRAGMENT_FLAG;
|
||||
if (!BUFFERED_FRAME_TYPES.has(frameType)) {
|
||||
// Immediate frame: append then flush so it never overtakes buffered body.
|
||||
buffer.push(frame);
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
const at = now();
|
||||
if (buffer.length === 0 && at - lastFlushAt >= windowMs) {
|
||||
// Leading edge: nothing flushed recently, ship this one right away.
|
||||
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(): void {
|
||||
disposed = true;
|
||||
clearPendingTimer();
|
||||
buffer = [];
|
||||
bufferedBytes = 0;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Allocates client-initiated stream ids: odd, starting at 1. */
|
||||
export const createStreamIdAllocator = () => {
|
||||
let next = 1;
|
||||
return {
|
||||
next(): number {
|
||||
if (next > MAX_STREAM_ID) {
|
||||
throw new TunnelCodecError('stream id space exhausted');
|
||||
}
|
||||
const id = next;
|
||||
next += 2;
|
||||
return id;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
// JSON payload guards and HTTP request normalization for the relay tunnel client.
|
||||
// Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 3).
|
||||
|
||||
import type {
|
||||
TunnelHttpResponsePayload,
|
||||
TunnelStreamAbortPayload,
|
||||
TunnelWsClosePayload,
|
||||
} from './protocol';
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const isStringRecord = (value: unknown): value is Record<string, string> =>
|
||||
isRecord(value) && Object.values(value).every((entry) => typeof entry === 'string');
|
||||
|
||||
export const isHttpResponsePayload = (value: unknown): value is TunnelHttpResponsePayload =>
|
||||
isRecord(value) && typeof value.status === 'number' && isStringRecord(value.headers);
|
||||
|
||||
export const isStreamAbortPayload = (value: unknown): value is TunnelStreamAbortPayload =>
|
||||
isRecord(value) && typeof value.reason === 'string';
|
||||
|
||||
export const isWsClosePayload = (value: unknown): value is TunnelWsClosePayload =>
|
||||
isRecord(value) && typeof value.code === 'number' && typeof value.reason === 'string';
|
||||
|
||||
const ABSOLUTE_URL_PATTERN = /^[a-z][a-z\d+.-]*:\/\//i;
|
||||
|
||||
// Placeholder base for parsing origin-relative request paths; never fetched.
|
||||
// Throwaway base for parsing relative runtime paths — only pathname+search are
|
||||
// ever read, the host is discarded. Shared so relay modules don't diverge on it.
|
||||
export const TUNNEL_PARSE_BASE = 'http://tunnel.invalid';
|
||||
|
||||
/** Extracts `pathname?search` from an absolute or relative WS/HTTP URL. */
|
||||
export const wsUrlToTunnelPath = (url: string): string => {
|
||||
try {
|
||||
const parsed = ABSOLUTE_URL_PATTERN.test(url) ? new URL(url) : new URL(url, TUNNEL_PARSE_BASE);
|
||||
return `${parsed.pathname}${parsed.search}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
export interface NormalizedTunnelRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
query: string;
|
||||
headers: Record<string, string>;
|
||||
body: AsyncIterable<Uint8Array> | null;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const singleChunk = (bytes: Uint8Array): AsyncIterable<Uint8Array> => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield bytes;
|
||||
},
|
||||
});
|
||||
|
||||
const streamChunks = (stream: ReadableStream<Uint8Array>): AsyncIterable<Uint8Array> => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
if (value) yield value;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const copyBytes = (view: ArrayBufferView): Uint8Array => {
|
||||
const copy = new Uint8Array(view.byteLength);
|
||||
copy.set(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
|
||||
return copy;
|
||||
};
|
||||
|
||||
const resolveBody = async (
|
||||
body: BodyInit | ReadableStream<Uint8Array> | null,
|
||||
): Promise<{ body: AsyncIterable<Uint8Array> | null; contentType?: string }> => {
|
||||
if (body === null || body === undefined) return { body: null };
|
||||
if (body instanceof ReadableStream) return { body: streamChunks(body) };
|
||||
if (typeof body === 'string') return { body: singleChunk(new TextEncoder().encode(body)) };
|
||||
if (body instanceof ArrayBuffer) return { body: singleChunk(new Uint8Array(body.slice(0))) };
|
||||
if (ArrayBuffer.isView(body)) return { body: singleChunk(copyBytes(body)) };
|
||||
// Blob / FormData / URLSearchParams: let Response serialize the body exactly
|
||||
// like a native fetch would, and surface the content-type it derives
|
||||
// (e.g. the multipart boundary for FormData).
|
||||
const probe = new Response(body);
|
||||
const contentType = probe.headers.get('content-type') ?? undefined;
|
||||
const bytes = new Uint8Array(await probe.arrayBuffer());
|
||||
return { body: singleChunk(bytes), contentType };
|
||||
};
|
||||
|
||||
/**
|
||||
* Flattens a fetch-style (input, init) pair into the tunnel HttpRequest shape,
|
||||
* preserving method, headers, body bytes/stream, and abort signal.
|
||||
*/
|
||||
export const normalizeTunnelRequest = async (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
): Promise<NormalizedTunnelRequest> => {
|
||||
let urlValue: string;
|
||||
const headers = new Headers();
|
||||
let method = 'GET';
|
||||
let bodySource: BodyInit | ReadableStream<Uint8Array> | null = null;
|
||||
let signal: AbortSignal | undefined;
|
||||
|
||||
if (input instanceof Request) {
|
||||
urlValue = input.url;
|
||||
method = input.method;
|
||||
input.headers.forEach((value, key) => headers.set(key, value));
|
||||
signal = input.signal;
|
||||
bodySource = input.body;
|
||||
} else {
|
||||
urlValue = input.toString();
|
||||
}
|
||||
|
||||
if (init) {
|
||||
if (init.method) method = init.method;
|
||||
if (init.headers) new Headers(init.headers).forEach((value, key) => headers.set(key, value));
|
||||
if (init.body !== undefined) bodySource = init.body;
|
||||
if (init.signal) signal = init.signal;
|
||||
}
|
||||
|
||||
const url = ABSOLUTE_URL_PATTERN.test(urlValue) ? new URL(urlValue) : new URL(urlValue, TUNNEL_PARSE_BASE);
|
||||
const { body, contentType } = await resolveBody(bodySource);
|
||||
if (contentType && !headers.has('content-type')) headers.set('content-type', contentType);
|
||||
|
||||
const headerRecord: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
headerRecord[key] = value;
|
||||
});
|
||||
|
||||
return {
|
||||
method: method.toUpperCase(),
|
||||
path: url.pathname,
|
||||
query: url.search.startsWith('?') ? url.search.slice(1) : url.search,
|
||||
headers: headerRecord,
|
||||
body,
|
||||
signal,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user