Extract relay endpoint security boundary from transport and admission

Move the daemon-side handshake, record framing, and handshake timeout out of
relay-transport.ts into websocket/secure-relay-ingress.ts, and give both
endpoints one secure-channel entrypoint in @getpaseo/relay/e2ee. Relay
transport now attaches plaintext sockets; bootstrap secures them before
admission. The client selects endpoint security from configuration instead
of inferring it from the relay URL. Behavior-preserving: the legacy
construction is unchanged and no new security guarantee is claimed.
This commit is contained in:
Mohamed Boudra
2026-10-21 13:33:56 +02:00
parent 5878680f7f
commit 50cde73137
15 changed files with 517 additions and 317 deletions
@@ -1,7 +1,7 @@
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
import {
createClientChannel,
type EncryptedChannel,
openClientSecureChannel,
type SecureChannel,
type Transport as RelayTransport,
} from "@getpaseo/relay/e2ee";
import type {
@@ -37,20 +37,13 @@ export function createRelayE2eeTransportFactory(args: {
};
}
export function createRelayTransportFactory(
baseFactory: DaemonTransportFactory,
): DaemonTransportFactory {
// The relay upgrade precedes E2EE. Only the encrypted hello may carry daemon credentials.
return ({ url }) => baseFactory({ url });
}
export function createEncryptedTransport(
base: DaemonTransport,
daemonPublicKeyB64: string,
logger: TransportLogger,
assertOutboundCapacity: (wireBytes: number) => void = () => {},
): DaemonTransport {
let channel: EncryptedChannel | null = null;
let channel: SecureChannel | null = null;
let opened = false;
let closed = false;
@@ -113,11 +106,14 @@ export function createEncryptedTransport(
const startHandshake = async () => {
try {
channel = await createClientChannel(relayTransport, daemonPublicKeyB64, {
onopen: emitOpen,
onmessage: (data) => emitMessage(data),
onclose: (code, reason) => emitClose({ code, reason }),
onerror: (error) => emitError(error),
channel = await openClientSecureChannel(relayTransport, {
daemonPublicKeyB64,
events: {
onopen: emitOpen,
onmessage: (data) => emitMessage(data),
onclose: (code, reason) => emitClose({ code, reason }),
onerror: (error) => emitError(error),
},
});
} catch (error) {
logger.warn({ err: normalizeTransportError(error) }, "relay_e2ee_handshake_failed");
@@ -10,16 +10,16 @@ import {
extractRelayMessage,
} from "./daemon-client-transport.js";
const createClientChannelMock = vi.hoisted(() => vi.fn());
const openClientSecureChannelMock = vi.hoisted(() => vi.fn());
vi.mock("@getpaseo/relay/e2ee", () => ({
createClientChannel: createClientChannelMock,
openClientSecureChannel: openClientSecureChannelMock,
}));
describe("daemon-client transport helpers", () => {
test("createEncryptedTransport closes handshake failures with browser-safe code", async () => {
createClientChannelMock.mockReset();
createClientChannelMock.mockRejectedValueOnce(new Error("handshake failed"));
openClientSecureChannelMock.mockReset();
openClientSecureChannelMock.mockRejectedValueOnce(new Error("handshake failed"));
const connection: { open: (() => void) | null } = { open: null };
const close = vi.fn();
@@ -17,7 +17,6 @@ export {
export {
createEncryptedTransport,
createRelayE2eeTransportFactory,
createRelayTransportFactory,
} from "./daemon-client-relay-e2ee-transport.js";
export {
bindWsHandler,
+4 -7
View File
@@ -162,7 +162,6 @@ import {
} from "@getpaseo/protocol/binary-frames/index";
import {
createRelayE2eeTransportFactory,
createRelayTransportFactory,
createWebSocketTransportFactory,
decodeMessageData,
defaultWebSocketFactory,
@@ -1448,14 +1447,12 @@ export class DaemonClient {
this.config.transportFactory ??
createWebSocketTransportFactory(this.config.webSocketFactory ?? defaultWebSocketFactory);
const isRelayTransport = isRelayClientWebSocketUrl(this.config.url);
const shouldUseRelayE2ee = this.config.e2ee?.enabled === true && isRelayTransport;
this.assertEncryptedRelayAuth(selected.helloAuth, isRelayTransport);
let transportFactory = isRelayTransport
? createRelayTransportFactory(baseTransportFactory)
: baseTransportFactory;
if (shouldUseRelayE2ee) {
const daemonPublicKeyB64 = this.config.e2ee?.daemonPublicKeyB64;
// Endpoint security is selected by configuration, never inferred from the URL.
let transportFactory = baseTransportFactory;
if (this.config.e2ee?.enabled === true) {
const daemonPublicKeyB64 = this.config.e2ee.daemonPublicKeyB64;
if (!daemonPublicKeyB64) {
throw new Error("daemonPublicKeyB64 is required for relay E2EE");
}
+9
View File
@@ -1,3 +1,12 @@
export { openClientSecureChannel, openDaemonSecureChannel } from "./secure-channel.js";
export type {
ClientSecureChannelOptions,
DaemonSecureChannelOptions,
SecureChannel,
SecureChannelEvents,
SecurityMode,
} from "./secure-channel.js";
export { createClientChannel, createDaemonChannel, EncryptedChannel } from "./encrypted-channel.js";
export type { Transport, TransportMessage, EncryptedChannelEvents } from "./encrypted-channel.js";
+84
View File
@@ -0,0 +1,84 @@
/// <reference lib="dom" />
/**
* Endpoint security for relay-carried daemon sessions.
*
* This is the one entrypoint both endpoints use to secure a transport. It owns
* the handshake, record protection, retries, termination, and the negotiated
* security mode. Relay adapters own routing and physical I/O; daemon admission
* owns credentials and authorization.
*
* "legacy" is the pre-existing ephemeral-static box construction in
* `encrypted-channel.ts`. It has no replay protection.
*/
import {
createClientChannel,
createDaemonChannel,
type EncryptedChannelEvents,
type Transport,
} from "./encrypted-channel.js";
import type { KeyPair } from "./crypto.js";
export type SecurityMode = "legacy";
export type SecureChannelEvents = EncryptedChannelEvents;
export interface SecureChannel {
send(data: string | ArrayBuffer): Promise<void>;
close(code?: number, reason?: string): void;
isOpen(): boolean;
securityMode(): SecurityMode;
/** Bytes the transport will carry for `data`, including protection overhead. */
outboundWireByteLength(data: string | ArrayBuffer): number;
onTransitionToOpen(cb: () => void): void;
onClose(cb: () => void): void;
}
export interface ClientSecureChannelOptions {
daemonPublicKeyB64: string;
events?: SecureChannelEvents;
}
export interface DaemonSecureChannelOptions {
daemonKeyPair: KeyPair;
events?: SecureChannelEvents;
}
/**
* Secures a transport as the initiator. Resolves once the handshake has been
* started; `events.onopen` fires when the channel is usable.
*/
export async function openClientSecureChannel(
transport: Transport,
options: ClientSecureChannelOptions,
): Promise<SecureChannel> {
const channel = await createClientChannel(transport, options.daemonPublicKeyB64, options.events);
return withSecurityMode(channel, "legacy");
}
/**
* Secures a transport as the responder. Resolves once the peer's handshake has
* been accepted and the channel is open.
*/
export async function openDaemonSecureChannel(
transport: Transport,
options: DaemonSecureChannelOptions,
): Promise<SecureChannel> {
const channel = await createDaemonChannel(transport, options.daemonKeyPair, options.events);
return withSecurityMode(channel, "legacy");
}
function withSecurityMode(
channel: Omit<SecureChannel, "securityMode">,
mode: SecurityMode,
): SecureChannel {
return {
send: (data) => channel.send(data),
close: (code, reason) => channel.close(code, reason),
isOpen: () => channel.isOpen(),
securityMode: () => mode,
outboundWireByteLength: (data) => channel.outboundWireByteLength(data),
onTransitionToOpen: (cb) => channel.onTransitionToOpen(cb),
onClose: (cb) => channel.onClose(cb),
};
}
+15 -5
View File
@@ -172,6 +172,7 @@ import { createConfiguredTerminalManager } from "../terminal/terminal-manager-fa
import { applyTerminalAgentHookSetting } from "../terminal/agent-hooks/terminal-agent-hook-setting.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
import { createRelayRuntime, type RelayRuntime } from "./relay-runtime.js";
import { admitSecuredRelaySocket } from "./websocket/secure-relay-ingress.js";
import type { PushNotificationSender } from "./push/index.js";
import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
@@ -1761,6 +1762,7 @@ export async function createPaseoDaemon(
await pluginRuntime.start();
providerSnapshotManager.settlePluginProviders();
wsServer.beginAcceptingConnections();
const relaySecurityLogger = logger.child({ module: "relay-security" });
relayRuntime = createRelayRuntime({
config: {
enabled: relayEnabled,
@@ -1770,12 +1772,20 @@ export async function createPaseoDaemon(
publicUseTls: relayPublicUseTls,
},
logger,
attachSocket: async (ws, metadata) => {
if (!wsServer) throw new Error("WebSocket server is not ready");
await wsServer.attachExternalSocket(ws, metadata);
},
attachSocket: (ws, metadata) =>
admitSecuredRelaySocket(
ws,
{ daemonKeyPair: daemonKeyPair.keyPair, logger: relaySecurityLogger },
async (secured) => {
if (!wsServer) throw new Error("WebSocket server is not ready");
await wsServer.attachExternalSocket(secured.socket, {
...metadata,
transport: "relay",
securityMode: secured.securityMode,
});
},
),
serverId,
daemonKeyPair: daemonKeyPair.keyPair,
});
daemonConfigStore.onFieldChange("relay.enabled", (value) => {
relayRuntime?.setEnabled(value === true);
@@ -1,5 +1,4 @@
import type pino from "pino";
import type { KeyPair } from "@getpaseo/relay/e2ee";
import type { ExternalSocketMetadata } from "./websocket-server.js";
import {
startRelayTransport,
@@ -20,7 +19,6 @@ interface RelayRuntimeOptions {
logger: pino.Logger;
attachSocket(ws: RelaySocketLike, metadata?: ExternalSocketMetadata): Promise<void>;
serverId: string;
daemonKeyPair: KeyPair;
startTransport?: typeof startRelayTransport;
}
@@ -43,7 +41,6 @@ export function createRelayRuntime(options: RelayRuntimeOptions): RelayRuntime {
relayEndpoint: config.endpoint,
relayUseTls: config.useTls,
serverId: options.serverId,
daemonKeyPair: options.daemonKeyPair,
});
}
@@ -1,7 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import pino from "pino";
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
import { exportPublicKey, generateKeyPair } from "@getpaseo/relay";
import { startRelayTransport } from "./relay-transport";
function createMockLogger() {
@@ -146,37 +144,6 @@ describe("relay-transport control lifecycle", () => {
vi.useRealTimers();
});
test("releases a stalled encrypted data connection without restarting control", async () => {
vi.useFakeTimers();
let attachments = 0;
controllers.push(
startRelayTransport({
logger: pino({ level: "silent" }),
attachSocket: async () => {
attachments += 1;
},
relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true,
serverId: "srv_test",
daemonKeyPair: generateKeyPair(),
createWebSocket: relay.createWebSocket,
}),
);
const control = relay.sockets[0];
control.open();
const connected = JSON.stringify({ type: "connected", connectionId: "stalled" });
control.message(connected);
const stalled = relay.sockets[1];
stalled.open();
await vi.advanceTimersByTimeAsync(15_000);
expect(stalled.terminateCalls).toBe(1);
expect(attachments).toBe(0);
expect(control.terminateCalls).toBe(0);
control.message(connected);
expect(relay.sockets).toHaveLength(3);
expect(relay.sockets[2].url).toBe(stalled.url);
});
test("logs relay_control_connected only after first valid control message", () => {
const logger = createMockLogger();
const controller = startRelayTransport({
@@ -286,80 +253,6 @@ describe("relay-transport control lifecycle", () => {
]);
});
test("encrypted sends wait for the physical data socket callback", async () => {
const logger = createMockLogger();
const daemonKeyPair = generateKeyPair();
let resolveAttached: ((socket: unknown) => void) | undefined;
const attached = new Promise<unknown>((resolve) => {
resolveAttached = resolve;
});
const controller = startRelayTransport({
logger: logger as unknown as pino.Logger,
attachSocket: async (socket) => resolveAttached?.(socket),
relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true,
serverId: "srv_test",
daemonKeyPair,
createWebSocket: relay.createWebSocket,
});
controllers.push(controller);
const control = relay.sockets[0];
control.open();
control.message(JSON.stringify({ type: "sync", connectionIds: [] }), false);
control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" }), false);
const dataSocket = relay.sockets[1];
dataSocket.deferSendCompletion = true;
dataSocket.open();
let clientTransport: Transport;
clientTransport = {
send: (data) => dataSocket.message(data, data instanceof ArrayBuffer),
close: () => undefined,
onmessage: null,
onclose: null,
onerror: null,
};
dataSocket.onSend = (data) => {
clientTransport.onmessage?.({
data: data instanceof Uint8Array ? data.slice().buffer : data,
isBinary: data instanceof ArrayBuffer || data instanceof Uint8Array,
});
};
let resolveClientOpen: (() => void) | undefined;
const clientOpen = new Promise<void>((resolve) => {
resolveClientOpen = resolve;
});
await createClientChannel(clientTransport, exportPublicKey(daemonKeyPair.publicKey), {
onopen: () => resolveClientOpen?.(),
});
let attachedCompleted = false;
void attached.then(() => {
attachedCompleted = true;
return undefined;
});
await clientOpen;
await Promise.resolve();
expect(attachedCompleted).toBe(false);
dataSocket.completeNextSend();
const encryptedSocket = (await attached) as {
send: (data: Uint8Array) => void | Promise<void>;
};
let completed = false;
const sending = Promise.resolve(encryptedSocket.send(new Uint8Array([1, 2, 3]))).then(() => {
completed = true;
return undefined;
});
await Promise.resolve();
expect(completed).toBe(false);
dataSocket.completeNextSend();
await sending;
expect(completed).toBe(true);
});
test("uses relayUseTls for control and data socket URLs", () => {
const logger = createMockLogger();
const controller = startRelayTransport({
+2 -168
View File
@@ -1,15 +1,8 @@
/// <reference lib="dom" />
import { EventEmitter } from "node:events";
import { WebSocket } from "ws";
import type pino from "pino";
import {
createDaemonChannel,
type Transport as RelayTransport,
type KeyPair,
} from "@getpaseo/relay/e2ee";
import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
import type { ExternalSocketMetadata } from "./websocket-server.js";
import { createEncryptedRelaySocket } from "./websocket/encrypted-relay-socket.js";
export interface RelayTransportOptions {
logger: pino.Logger;
@@ -17,7 +10,6 @@ export interface RelayTransportOptions {
relayEndpoint: string; // "host:port"
relayUseTls: boolean;
serverId: string;
daemonKeyPair?: KeyPair;
createWebSocket?: RelayWebSocketFactory;
}
@@ -30,13 +22,12 @@ export interface RelaySocketLike {
bufferedAmount?: number;
send: (data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => void;
close: (code?: number, reason?: string) => void;
terminate?: () => void;
terminate: () => void;
on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void;
once: (event: "close" | "error", listener: (...args: unknown[]) => void) => void;
}
interface RelayWebSocketLike extends RelaySocketLike {
terminate: () => void;
ping: () => void;
on: (
event: "open" | "message" | "close" | "error" | "pong",
@@ -118,7 +109,6 @@ export function startRelayTransport({
relayEndpoint,
relayUseTls,
serverId,
daemonKeyPair,
createWebSocket = createDefaultRelayWebSocket,
}: RelayTransportOptions): RelayTransportController {
const relayLogger = logger.child({ module: "relay-transport" });
@@ -385,17 +375,7 @@ export function startRelayTransport({
externalSessionKey: `session:${connectionId}`,
relayConnectionId: connectionId,
};
if (daemonKeyPair) {
void attachEncryptedSocket(
socket,
daemonKeyPair,
relayLogger.child({ connectionId }),
attachSocket,
externalMetadata,
);
} else {
void attachSocket(socket, externalMetadata);
}
void attachSocket(socket, externalMetadata);
});
socket.on("close", (code, reason) => {
@@ -418,149 +398,3 @@ export function startRelayTransport({
return { stop };
}
async function attachEncryptedSocket(
socket: RelayWebSocketLike,
daemonKeyPair: KeyPair,
logger: pino.Logger,
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>,
metadata?: ExternalSocketMetadata,
): Promise<void> {
const handshakeTimeout = setTimeout(() => {
logger.warn("relay_e2ee_handshake_timeout_terminating");
socket.terminate();
}, 15_000);
try {
const relayTransport = createRelayTransportAdapter(socket, logger);
const emitter = new EventEmitter();
const pendingMessages: Array<string | ArrayBuffer> = [];
let attached = false;
const emitMessage = (data: string | ArrayBuffer) => {
if (attached) {
emitter.emit("message", data);
return;
}
pendingMessages.push(data);
};
const channel = await createDaemonChannel(relayTransport, daemonKeyPair, {
onmessage: emitMessage,
onclose: (code, reason) => emitter.emit("close", code, reason),
onerror: (error) => {
logger.warn({ err: error }, "relay_e2ee_error");
emitter.emit("error", error);
},
});
clearTimeout(handshakeTimeout);
const encryptedSocket = createEncryptedRelaySocket({
channel,
emitter,
getTransportBufferedAmount: () => socket.bufferedAmount,
terminateTransport: () => socket.terminate(),
});
await attachSocket(encryptedSocket, metadata);
attached = true;
for (const message of pendingMessages) {
emitter.emit("message", message);
}
pendingMessages.length = 0;
} catch (error) {
logger.warn({ err: error }, "relay_e2ee_handshake_failed");
try {
socket.close(1011, "E2EE handshake failed");
} catch {
// ignore
}
} finally {
clearTimeout(handshakeTimeout);
}
}
function createRelayTransportAdapter(
socket: RelayWebSocketLike,
logger: pino.Logger,
): RelayTransport {
const relayTransport: RelayTransport = {
send: (data) =>
new Promise<void>((resolve, reject) => {
try {
socket.send(data, (error) => {
if (!error) {
resolve();
return;
}
logger.warn({ err: error }, "relay_socket_send_failed");
reject(error);
});
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
logger.warn({ err }, "relay_socket_send_failed");
reject(err);
}
}),
close: (code?: number, reason?: string) => socket.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
socket.on("message", (data, isBinary) => {
const binary = isBinary === true;
relayTransport.onmessage?.({ data: normalizeMessageData(data, binary), isBinary: binary });
});
socket.on("close", (code, reason) => {
const closeCode = typeof code === "number" ? code : 1006;
relayTransport.onclose?.(closeCode, String(reason ?? ""));
});
socket.on("error", (err) => {
relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err)));
});
return relayTransport;
}
function normalizeMessageData(data: unknown, isBinary: boolean): string | ArrayBuffer {
if (!isBinary) {
if (typeof data === "string") return data;
const buffer = bufferFromWsData(data);
if (buffer) return buffer.toString("utf8");
return String(data);
}
if (data instanceof ArrayBuffer) return data;
const buffer = bufferFromWsData(data);
if (buffer) {
const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
const out = new Uint8Array(view.byteLength);
out.set(view);
return out.buffer;
}
return String(data);
}
function bufferFromWsData(data: unknown): Buffer | null {
if (Buffer.isBuffer(data)) return data;
if (Array.isArray(data)) {
const buffers: Buffer[] = [];
for (const part of data) {
if (Buffer.isBuffer(part)) {
buffers.push(part);
} else if (part instanceof ArrayBuffer) {
buffers.push(Buffer.from(part));
} else if (ArrayBuffer.isView(part)) {
buffers.push(Buffer.from(part.buffer, part.byteOffset, part.byteLength));
} else if (typeof part === "string") {
buffers.push(Buffer.from(part, "utf8"));
} else {
return null;
}
}
return Buffer.concat(buffers);
}
if (data instanceof ArrayBuffer) return Buffer.from(data);
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
}
return null;
}
@@ -103,6 +103,7 @@ import {
} from "./lifecycle-reasons.js";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import type { SecurityMode } from "@getpaseo/relay/e2ee";
import type { BrowserToolsBroker } from "./browser-tools/broker.js";
import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js";
import { DirectorySyncService } from "./directory-sync/index.js";
@@ -124,6 +125,8 @@ export interface ExternalSocketMetadata {
transport: "relay" | "hub";
externalSessionKey?: string;
relayConnectionId?: string;
/** Negotiated endpoint security for relay-carried sockets. */
securityMode?: SecurityMode;
hubDaemonId?: string;
}
@@ -151,6 +154,7 @@ interface WebSocketConnectionIdentity {
userAgent?: string;
remoteAddress?: string;
relayConnectionId?: string;
securityMode?: SecurityMode;
hubDaemonId?: string;
clientId?: string;
sessionId?: string;
@@ -2944,6 +2948,7 @@ function createWebSocketConnectionIdentity(
...(requestMetadata.userAgent ? { userAgent: requestMetadata.userAgent } : {}),
...(requestMetadata.remoteAddress ? { remoteAddress: requestMetadata.remoteAddress } : {}),
...(metadata?.relayConnectionId ? { relayConnectionId: metadata.relayConnectionId } : {}),
...(metadata?.securityMode ? { securityMode: metadata.securityMode } : {}),
...(metadata?.hubDaemonId ? { hubDaemonId: metadata.hubDaemonId } : {}),
};
}
@@ -2962,6 +2967,7 @@ function toConnectionLogFields(identity: WebSocketConnectionIdentity): Record<st
...(identity.userAgent ? { userAgent: identity.userAgent } : {}),
...(identity.remoteAddress ? { remoteAddress: identity.remoteAddress } : {}),
...(identity.relayConnectionId ? { relayConnectionId: identity.relayConnectionId } : {}),
...(identity.securityMode ? { securityMode: identity.securityMode } : {}),
...(identity.hubDaemonId ? { hubDaemonId: identity.hubDaemonId } : {}),
...(identity.clientId ? { clientId: identity.clientId } : {}),
...(identity.sessionId ? { sessionId: identity.sessionId } : {}),
@@ -11,10 +11,6 @@ class BlockingChannel implements EncryptedRelayChannel {
readonly closes: Array<{ code?: number; reason?: string }> = [];
private resolveSend: (() => void) | null = null;
setState(state: "open"): void {
expect(state).toBe("open");
}
send(data: string | ArrayBuffer): Promise<void> {
this.sent.push(data);
return new Promise((resolve) => {
@@ -2,7 +2,6 @@ import { EventEmitter } from "node:events";
import { MAX_PHYSICAL_SOCKET_BUFFERED_BYTES } from "./physical-socket.js";
export interface EncryptedRelayChannel {
setState: (state: "open") => void;
send: (data: string | ArrayBuffer) => Promise<void>;
outboundWireByteLength: (data: string | ArrayBuffer) => number;
close: (code?: number, reason?: string) => void;
@@ -27,8 +26,6 @@ export function createEncryptedRelaySocket(params: {
const { channel, emitter, getTransportBufferedAmount, terminateTransport } = params;
let readyState = 1;
channel.setState("open");
const terminate = () => {
if (readyState === 3) return;
readyState = 3;
@@ -0,0 +1,188 @@
import { afterEach, expect, test, vi } from "vitest";
import pino from "pino";
import { exportPublicKey, generateKeyPair, openClientSecureChannel } from "@getpaseo/relay/e2ee";
import type { Transport } from "@getpaseo/relay/e2ee";
import { admitSecuredRelaySocket, type SecuredRelaySocket } from "./secure-relay-ingress.js";
type Listener = (...args: unknown[]) => void;
class FakeIngressSocket {
readyState = 1;
bufferedAmount = 0;
terminateCalls = 0;
closes: Array<{ code?: number; reason?: string }> = [];
deferSendCompletion = false;
onSend: ((data: string | Uint8Array | ArrayBuffer) => void) | null = null;
private pendingSendCallbacks: Array<(error?: Error) => void> = [];
private listeners = new Map<string, Listener[]>();
on(event: "message" | "close" | "error", listener: Listener) {
this.listeners.set(event, [...(this.listeners.get(event) ?? []), listener]);
}
send(data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) {
this.onSend?.(data);
if (!callback) return;
if (this.deferSendCompletion) {
this.pendingSendCallbacks.push(callback);
return;
}
callback();
}
completeNextSend() {
this.pendingSendCallbacks.shift()?.();
}
close(code?: number, reason?: string) {
this.closes.push({ code, reason });
this.readyState = 3;
}
terminate() {
this.terminateCalls += 1;
this.readyState = 3;
this.emit("close", 1006, "");
}
message(data: string | ArrayBuffer) {
this.emit("message", data, data instanceof ArrayBuffer);
}
private emit(event: string, ...args: unknown[]) {
for (const listener of this.listeners.get(event) ?? []) listener(...args);
}
}
const logger = pino({ level: "silent" });
/** Wires a client secure channel to the fake socket so both ends talk directly. */
async function openClientAgainst(
socket: FakeIngressSocket,
daemonPublicKeyB64: string,
events: { onopen?: () => void; onmessage?: (data: string | ArrayBuffer) => void } = {},
) {
const clientTransport: Transport = {
send: (data) => socket.message(data),
close: () => undefined,
onmessage: null,
onclose: null,
onerror: null,
};
socket.onSend = (data) => {
clientTransport.onmessage?.({
data: data instanceof Uint8Array ? data.slice().buffer : data,
isBinary: data instanceof ArrayBuffer || data instanceof Uint8Array,
});
};
return openClientSecureChannel(clientTransport, { daemonPublicKeyB64, events });
}
afterEach(() => {
vi.useRealTimers();
});
test("terminates a relay socket whose peer never completes the handshake", async () => {
vi.useFakeTimers();
const socket = new FakeIngressSocket();
let attachments = 0;
const admitted = admitSecuredRelaySocket(
socket,
{ daemonKeyPair: generateKeyPair(), logger },
async () => {
attachments += 1;
},
);
await vi.advanceTimersByTimeAsync(15_000);
await admitted;
expect(socket.terminateCalls).toBe(1);
expect(attachments).toBe(0);
});
test("admits the secured socket with its negotiated security mode", async () => {
const socket = new FakeIngressSocket();
const daemonKeyPair = generateKeyPair();
let secured: SecuredRelaySocket | null = null;
const admitted = admitSecuredRelaySocket(socket, { daemonKeyPair, logger }, async (next) => {
secured = next;
});
await openClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey));
await admitted;
expect(secured).not.toBeNull();
expect(secured!.securityMode).toBe("legacy");
expect(secured!.socket.readyState).toBe(1);
});
test("delivers records received before admission completes, in order, after attach", async () => {
const socket = new FakeIngressSocket();
const daemonKeyPair = generateKeyPair();
let releaseAttach: (() => void) | undefined;
const attachGate = new Promise<void>((resolve) => {
releaseAttach = resolve;
});
const received: unknown[] = [];
let secured: SecuredRelaySocket | null = null;
const admitted = admitSecuredRelaySocket(socket, { daemonKeyPair, logger }, async (next) => {
secured = next;
next.socket.on("message", (data) => received.push(data));
await attachGate;
});
let clientOpen: () => void = () => undefined;
const opened = new Promise<void>((resolve) => {
clientOpen = resolve;
});
const client = await openClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey), {
onopen: () => clientOpen(),
});
await opened;
await vi.waitFor(() => expect(secured).not.toBeNull());
await client.send("first");
await client.send("second");
expect(received).toEqual([]);
releaseAttach?.();
await admitted;
expect(received).toEqual(["first", "second"]);
});
test("encrypted sends wait for the physical socket send callback", async () => {
const socket = new FakeIngressSocket();
const daemonKeyPair = generateKeyPair();
socket.deferSendCompletion = true;
let resolveAttached: ((secured: SecuredRelaySocket) => void) | undefined;
const attached = new Promise<SecuredRelaySocket>((resolve) => {
resolveAttached = resolve;
});
void admitSecuredRelaySocket(socket, { daemonKeyPair, logger }, async (next) =>
resolveAttached?.(next),
);
let resolveClientOpen: (() => void) | undefined;
const clientOpen = new Promise<void>((resolve) => {
resolveClientOpen = resolve;
});
await openClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey), {
onopen: () => resolveClientOpen?.(),
});
let attachedCompleted = false;
void attached.then(() => {
attachedCompleted = true;
return undefined;
});
await clientOpen;
await Promise.resolve();
expect(attachedCompleted).toBe(false);
socket.completeNextSend();
const { socket: encryptedSocket } = await attached;
let completed = false;
const sending = Promise.resolve(encryptedSocket.send(new Uint8Array([1, 2, 3]))).then(() => {
completed = true;
return undefined;
});
await Promise.resolve();
expect(completed).toBe(false);
socket.completeNextSend();
await sending;
expect(completed).toBe(true);
});
@@ -0,0 +1,194 @@
/// <reference lib="dom" />
/**
* Endpoint security boundary for relay-carried daemon sessions.
*
* A relay data socket arrives here as plaintext bytes. This module runs the
* shared secure channel over it and hands the secured socket to session
* admission, together with the negotiated security mode. Relay routing lives in
* `relay-transport.ts`; credentials and authorization live in admission.
*/
import { EventEmitter } from "node:events";
import type pino from "pino";
import {
openDaemonSecureChannel,
type KeyPair,
type SecurityMode,
type Transport as RelayTransport,
} from "@getpaseo/relay/e2ee";
import { createEncryptedRelaySocket, type EncryptedRelaySocket } from "./encrypted-relay-socket.js";
const HANDSHAKE_TIMEOUT_MS = 15_000;
export interface RelayIngressSocket {
readyState: number;
bufferedAmount?: number;
send: (data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => void;
close: (code?: number, reason?: string) => void;
terminate: () => void;
on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void;
}
export interface SecuredRelaySocket {
socket: EncryptedRelaySocket;
securityMode: SecurityMode;
}
export interface SecureRelayIngressOptions {
daemonKeyPair: KeyPair;
logger: pino.Logger;
}
/**
* Secures `socket` and admits it through `attach`. Messages the peer sends
* before `attach` resolves are delivered once it has.
*
* A peer that never completes the handshake is terminated after
* {@link HANDSHAKE_TIMEOUT_MS}; a failed handshake closes the socket.
*/
export async function admitSecuredRelaySocket(
socket: RelayIngressSocket,
options: SecureRelayIngressOptions,
attach: (secured: SecuredRelaySocket) => Promise<void>,
): Promise<void> {
const { logger } = options;
const handshakeTimeout = setTimeout(() => {
logger.warn("relay_e2ee_handshake_timeout_terminating");
socket.terminate();
}, HANDSHAKE_TIMEOUT_MS);
try {
const relayTransport = createRelayTransportAdapter(socket, logger);
const emitter = new EventEmitter();
const pendingMessages: Array<string | ArrayBuffer> = [];
let attached = false;
const emitMessage = (data: string | ArrayBuffer) => {
if (attached) {
emitter.emit("message", data);
return;
}
pendingMessages.push(data);
};
const channel = await openDaemonSecureChannel(relayTransport, {
daemonKeyPair: options.daemonKeyPair,
events: {
onmessage: emitMessage,
onclose: (code, reason) => emitter.emit("close", code, reason),
onerror: (error) => {
logger.warn({ err: error }, "relay_e2ee_error");
emitter.emit("error", error);
},
},
});
clearTimeout(handshakeTimeout);
const encryptedSocket = createEncryptedRelaySocket({
channel,
emitter,
getTransportBufferedAmount: () => socket.bufferedAmount,
terminateTransport: () => socket.terminate(),
});
await attach({ socket: encryptedSocket, securityMode: channel.securityMode() });
attached = true;
for (const message of pendingMessages) {
emitter.emit("message", message);
}
pendingMessages.length = 0;
} catch (error) {
logger.warn({ err: error }, "relay_e2ee_handshake_failed");
try {
socket.close(1011, "E2EE handshake failed");
} catch {
// ignore
}
} finally {
clearTimeout(handshakeTimeout);
}
}
function createRelayTransportAdapter(
socket: RelayIngressSocket,
logger: pino.Logger,
): RelayTransport {
const relayTransport: RelayTransport = {
send: (data) =>
new Promise<void>((resolve, reject) => {
try {
socket.send(data, (error) => {
if (!error) {
resolve();
return;
}
logger.warn({ err: error }, "relay_socket_send_failed");
reject(error);
});
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
logger.warn({ err }, "relay_socket_send_failed");
reject(err);
}
}),
close: (code?: number, reason?: string) => socket.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
socket.on("message", (data, isBinary) => {
const binary = isBinary === true;
relayTransport.onmessage?.({ data: normalizeMessageData(data, binary), isBinary: binary });
});
socket.on("close", (code, reason) => {
const closeCode = typeof code === "number" ? code : 1006;
relayTransport.onclose?.(closeCode, String(reason ?? ""));
});
socket.on("error", (err) => {
relayTransport.onerror?.(err instanceof Error ? err : new Error(String(err)));
});
return relayTransport;
}
function normalizeMessageData(data: unknown, isBinary: boolean): string | ArrayBuffer {
if (!isBinary) {
if (typeof data === "string") return data;
const buffer = bufferFromWsData(data);
if (buffer) return buffer.toString("utf8");
return String(data);
}
if (data instanceof ArrayBuffer) return data;
const buffer = bufferFromWsData(data);
if (buffer) {
const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
const out = new Uint8Array(view.byteLength);
out.set(view);
return out.buffer;
}
return String(data);
}
function bufferFromWsData(data: unknown): Buffer | null {
if (Buffer.isBuffer(data)) return data;
if (Array.isArray(data)) {
const buffers: Buffer[] = [];
for (const part of data) {
if (Buffer.isBuffer(part)) {
buffers.push(part);
} else if (part instanceof ArrayBuffer) {
buffers.push(Buffer.from(part));
} else if (ArrayBuffer.isView(part)) {
buffers.push(Buffer.from(part.buffer, part.byteOffset, part.byteLength));
} else if (typeof part === "string") {
buffers.push(Buffer.from(part, "utf8"));
} else {
return null;
}
}
return Buffer.concat(buffers);
}
if (data instanceof ArrayBuffer) return Buffer.from(data);
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
}
return null;
}