fix(relay): protect sessions against replayed commands (#7610)

Protect relay sessions against replayed commands with authenticated Noise NK negotiation, ordered records, and identity-bound security pins. Preserve legacy admission during migration and add daemon enforcement policy.

All 21 CI checks passed. General engineering review is complete; specialist cryptographic review of the handwritten Noise implementation and static-key reuse remains required before release or deployment.

Closes #359
This commit is contained in:
Mohamed Boudra
2026-10-22 19:50:06 +07:00
committed by GitHub
parent 91d749f82c
commit 793b16c0df
55 changed files with 4887 additions and 619 deletions
+35 -9
View File
@@ -173,6 +173,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";
@@ -424,6 +425,7 @@ export interface PaseoDaemonConfig {
agentStoragePath: string;
relayEnabled?: boolean;
relayEnabledMutable?: boolean;
relayRequireProtectedSecurity?: boolean;
relayEndpoint?: string;
relayPublicEndpoint?: string;
relayUseTls?: boolean;
@@ -539,11 +541,18 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri
return config.trustedProxies ?? ["loopback"];
}
function createInitialRelayConfig(config: PaseoDaemonConfig): MutableDaemonConfig["relay"] {
return {
enabled: config.relayEnabled ?? true,
requireProtectedSecurity: config.relayRequireProtectedSecurity ?? false,
};
}
function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig {
const providers = config.providerOverrides ?? {};
const initialConfig: MutableDaemonConfig = {
relay: { enabled: config.relayEnabled ?? true },
relay: createInitialRelayConfig(config),
mcp: {
enabled: config.mcpEnabled ?? true,
injectIntoAgents: config.mcpInjectIntoAgents ?? true,
@@ -1758,14 +1767,17 @@ export async function createPaseoDaemon(
return appBaseUrl;
},
desktopManaged: config.desktopManaged === true,
getRelayConfig: () =>
relayRuntime?.getConfig() ?? {
getRelayConfig: () => ({
...(relayRuntime?.getConfig() ?? {
enabled: daemonConfigStore.get().relay?.enabled ?? relayEnabled,
endpoint: relayEndpoint,
publicEndpoint: relayPublicEndpoint,
useTls: relayUseTls,
publicUseTls: relayPublicUseTls,
},
}),
requireProtectedSecurity:
daemonConfigStore.get().relay?.requireProtectedSecurity === true,
}),
},
serviceProxyPublicBaseUrl,
browserToolsBroker,
@@ -1779,6 +1791,7 @@ export async function createPaseoDaemon(
await pluginRuntime.start();
providerSnapshotManager.settlePluginProviders();
wsServer.beginAcceptingConnections();
const relaySecurityLogger = logger.child({ module: "relay-security" });
relayRuntime = createRelayRuntime({
config: {
enabled: relayEnabled,
@@ -1788,12 +1801,25 @@ 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,
requireProtectedMode: () =>
daemonConfigStore.get().relay?.requireProtectedSecurity === true,
},
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);
+19 -8
View File
@@ -275,6 +275,7 @@ interface ResolveRelayInput {
interface ResolvedRelay {
enabled: boolean;
enabledMutable: boolean;
requireProtectedSecurity: boolean;
endpoint: string;
publicEndpoint: string;
useTls: boolean;
@@ -297,6 +298,21 @@ function resolveTlsFromEnv(
return persistedValue ?? fallback;
}
function resolveRelayEndpoints(input: ResolveRelayInput): {
endpoint: string;
publicEndpoint: string;
} {
const endpoint =
input.env.PASEO_RELAY_ENDPOINT ??
input.persisted.daemon?.relay?.endpoint ??
DEFAULT_RELAY_ENDPOINT;
const publicEndpoint =
input.env.PASEO_RELAY_PUBLIC_ENDPOINT ??
input.persisted.daemon?.relay?.publicEndpoint ??
endpoint;
return { endpoint, publicEndpoint };
}
function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
const environmentEnabled = parseBooleanEnv(input.env.PASEO_RELAY_ENABLED);
// COMPAT(relayOptInDefault): daemons whose startup config omitted this field
@@ -306,14 +322,7 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
environmentEnabled ??
input.persisted.daemon?.relay?.enabled ??
input.enabledFallback;
const endpoint =
input.env.PASEO_RELAY_ENDPOINT ??
input.persisted.daemon?.relay?.endpoint ??
DEFAULT_RELAY_ENDPOINT;
const publicEndpoint =
input.env.PASEO_RELAY_PUBLIC_ENDPOINT ??
input.persisted.daemon?.relay?.publicEndpoint ??
endpoint;
const { endpoint, publicEndpoint } = resolveRelayEndpoints(input);
const useTls =
input.cliRelayUseTls ??
resolveTlsFromEnv(
@@ -329,6 +338,7 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
return {
enabled,
enabledMutable: input.cliRelayEnabled === undefined && environmentEnabled === undefined,
requireProtectedSecurity: input.persisted.daemon?.relay?.requireProtectedSecurity ?? false,
endpoint,
publicEndpoint,
useTls,
@@ -635,6 +645,7 @@ export function resolveConfigFromPersisted(
agentClients: {},
relayEnabled: relay.enabled,
relayEnabledMutable: relay.enabledMutable,
relayRequireProtectedSecurity: relay.requireProtectedSecurity,
relayEndpoint: relay.endpoint,
relayPublicEndpoint: relay.publicEndpoint,
relayUseTls: relay.useTls,
@@ -31,12 +31,14 @@ export async function createConnectionOfferV2(args: {
serverId: string;
daemonPublicKeyB64: string;
relay: { endpoint: string; useTls?: boolean };
minimumSecurityMode?: "protected";
}): Promise<ConnectionOffer> {
return ConnectionOfferV2Schema.parse({
v: 2,
serverId: args.serverId,
daemonPublicKeyB64: args.daemonPublicKeyB64,
relay: args.relay,
...(args.minimumSecurityMode ? { minimumSecurityMode: args.minimumSecurityMode } : {}),
});
}
@@ -120,6 +120,35 @@ describe("DaemonConfigStore", () => {
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true);
});
test("patch persists the relay protected security requirement independently of relay enablement", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(
paseoHome,
{
relay: { enabled: true },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
},
undefined,
// A launch override owns relay.enabled; the security requirement is still editable.
{ relayEnabledMutable: false },
);
const changes: unknown[] = [];
store.onFieldChange("relay.requireProtectedSecurity", (value) => changes.push(value));
store.patch({ relay: { requireProtectedSecurity: true } });
expect(changes).toEqual([true]);
expect(store.get().relay).toEqual({ enabled: true, requireProtectedSecurity: true });
expect(loadPersistedConfig(paseoHome).daemon?.relay?.requireProtectedSecurity).toBe(true);
});
test("patch round-trips agent profiles through the strictly-parsed persisted config", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -17,7 +17,7 @@ type MutableDaemonConfigPatch = import("@getpaseo/protocol/messages").MutableDae
type ProviderOverride = import("./agent/provider-launch-config.js").ProviderOverride;
interface SupportedMutableConfigPatch {
relay?: { enabled?: boolean };
relay?: { enabled?: boolean; requireProtectedSecurity?: boolean };
mcp?: { injectIntoAgents?: boolean };
browserTools?: { enabled?: boolean };
providers?: MutableDaemonConfig["providers"];
@@ -170,6 +170,7 @@ function isEqualValue(a: unknown, b: unknown): boolean {
const RELOADABLE_PATHS = [
"daemon.relay.enabled",
"daemon.relay.requireProtectedSecurity",
"daemon.mcp.enabled",
"daemon.mcp.injectIntoAgents",
"daemon.browserTools.enabled",
@@ -193,6 +194,7 @@ const RELOADABLE_PATHS = [
const PERSISTED_TO_MUTABLE_PATH = new Map<string, string>([
["daemon.relay.enabled", "relay.enabled"],
["daemon.relay.requireProtectedSecurity", "relay.requireProtectedSecurity"],
["daemon.mcp.enabled", "mcp.enabled"],
["daemon.mcp.injectIntoAgents", "mcp.injectIntoAgents"],
["daemon.browserTools.enabled", "browserTools.enabled"],
@@ -249,9 +251,21 @@ function compactOwnedPaths(paths: readonly string[], owners: readonly string[]):
return Array.from(compacted).sort();
}
function pickRelayPatch(
relay: MutableDaemonConfigPatch["relay"],
): Pick<SupportedMutableConfigPatch, "relay"> {
const picked: NonNullable<SupportedMutableConfigPatch["relay"]> = {
...(relay?.enabled !== undefined ? { enabled: relay.enabled } : {}),
...(relay?.requireProtectedSecurity !== undefined
? { requireProtectedSecurity: relay.requireProtectedSecurity }
: {}),
};
return Object.keys(picked).length > 0 ? { relay: picked } : {};
}
function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMutableConfigPatch {
return {
...(patch.relay?.enabled !== undefined ? { relay: { enabled: patch.relay.enabled } } : {}),
...pickRelayPatch(patch.relay),
...(patch.mcp?.injectIntoAgents !== undefined
? { mcp: { injectIntoAgents: patch.mcp.injectIntoAgents } }
: {}),
@@ -646,6 +660,9 @@ function mergeMutableDaemonPatch(
if (persistRelayEnabled && patch.relay?.enabled !== undefined) {
next.relay = { ...next.relay, enabled: patch.relay.enabled };
}
if (patch.relay?.requireProtectedSecurity !== undefined) {
next.relay = { ...next.relay, requireProtectedSecurity: patch.relay.requireProtectedSecurity };
}
if (patch.mcp?.injectIntoAgents !== undefined) {
next.mcp = { ...next.mcp, injectIntoAgents: patch.mcp.injectIntoAgents };
}
@@ -26,7 +26,12 @@ import os from "node:os";
import { generateLocalPairingOffer } from "../pairing-offer.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { FILE_EXPLORER_STREAM_CHUNK_BYTES } from "../file-explorer/service.js";
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
import {
createClientChannel,
openClientSecureChannel,
type SecureChannel,
type Transport,
} from "@getpaseo/relay/e2ee";
import {
deriveSharedKey,
decrypt,
@@ -59,6 +64,7 @@ async function getPairingOfferUrl(args: {
relayEnabled?: boolean;
relayEndpoint?: string;
relayPublicEndpoint?: string;
relayRequireProtectedSecurity?: boolean;
appBaseUrl?: string;
}): Promise<string> {
const pairing = await generateLocalPairingOffer({
@@ -66,6 +72,7 @@ async function getPairingOfferUrl(args: {
relayEnabled: args.relayEnabled,
relayEndpoint: args.relayEndpoint,
relayPublicEndpoint: args.relayPublicEndpoint,
relayRequireProtectedSecurity: args.relayRequireProtectedSecurity,
appBaseUrl: args.appBaseUrl,
includeQr: false,
});
@@ -90,6 +97,113 @@ function decodeOfferFromFragmentUrl(url: string): {
return { serverId: offer.serverId, daemonPublicKeyB64: offer.daemonPublicKeyB64 };
}
/**
* An updated relay client: the shared secure channel over a raw relay socket.
* Resolves once the daemon answers the app hello with server_info.
*/
async function openUpdatedRelayClient(args: {
relayPort: number;
serverId: string;
daemonPublicKeyB64: string;
clientId: string;
minimumMode?: "protected";
}): Promise<{
channel: SecureChannel;
serverInfo: { features?: Record<string, unknown> };
messages: unknown[];
waitFor: (predicate: (message: unknown) => boolean) => Promise<unknown>;
close: () => void;
}> {
const ws = new WebSocket(
buildRelayWebSocketUrl({
endpoint: `127.0.0.1:${args.relayPort}`,
useTls: false,
serverId: args.serverId,
role: "client",
}),
);
const transport: Transport = {
send: (data) => ws.send(data),
close: (code?: number, reason?: string) => ws.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
ws.on("message", (data, isBinary) => {
const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as Buffer);
transport.onmessage?.({
data: isBinary
? buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
: buffer.toString("utf8"),
isBinary,
});
});
ws.on("close", (code, reason) => transport.onclose?.(code, reason.toString()));
ws.on("error", (error) => transport.onerror?.(error));
await once(ws, "open");
const messages: unknown[] = [];
const waiters: Array<{
predicate: (message: unknown) => boolean;
resolve: (m: unknown) => void;
}> = [];
const deliver = (message: unknown) => {
messages.push(message);
for (const waiter of waiters.slice()) {
if (!waiter.predicate(message)) continue;
waiters.splice(waiters.indexOf(waiter), 1);
waiter.resolve(message);
}
};
const waitFor = (predicate: (message: unknown) => boolean) =>
withTimeout(
new Promise<unknown>((resolve) => {
const existing = messages.find(predicate);
if (existing !== undefined) resolve(existing);
else waiters.push({ predicate, resolve });
}),
20_000,
"relay client message",
);
const isServerInfo = (message: unknown) => {
const parsed = WSOutboundMessageSchema.safeParse(message);
return (
parsed.success &&
parsed.data.type === "session" &&
parsed.data.message.type === "status" &&
parsed.data.message.payload?.status === "server_info"
);
};
const channel = await openClientSecureChannel(transport, {
daemonPublicKeyB64: args.daemonPublicKeyB64,
...(args.minimumMode ? { minimumMode: args.minimumMode } : {}),
events: {
onmessage: (data) => {
if (typeof data === "string") deliver(JSON.parse(data));
},
},
});
await channel.send(
JSON.stringify({
type: "hello",
clientId: args.clientId,
clientType: "cli",
protocolVersion: 1,
}),
);
const serverInfoMessage = (await waitFor(isServerInfo)) as {
message: { payload: { features?: Record<string, unknown> } };
};
return {
channel,
serverInfo: serverInfoMessage.message.payload,
messages,
waitFor,
close: () => ws.close(),
};
}
function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
@@ -204,9 +318,18 @@ async function waitForCapturedLog(
let relayPort: number;
let relayProcess: ChildProcess | null = null;
let relayStdoutLines: string[] = [];
// PASEO_RELAY_E2E_PORT points at an already-running relay (for example the
// Elixir relay) so the same daemon/client exchanges run against both.
const externalRelayPort = Number(process.env.PASEO_RELAY_E2E_PORT ?? "");
const usesExternalRelay = Number.isInteger(externalRelayPort) && externalRelayPort > 0;
const startRelay = async (options: { useLocalRelay?: boolean; port?: number } = {}) => {
relayStdoutLines = [];
if (usesExternalRelay) {
relayPort = externalRelayPort;
await waitForRelayWebSocketReady(relayPort, 60000);
return;
}
relayPort = options.port ?? (await getAvailablePort());
const relayDir = path.resolve(process.cwd(), "../relay");
const relayArgs = [
@@ -273,144 +396,149 @@ async function waitForCapturedLog(
await withTimeout(exited, 10000, "Local relay did not stop");
};
test("connected hosts route RPC and binary traffic through local E2EE relay and recover scopes", async () => {
let a: Awaited<ReturnType<typeof createTestPaseoDaemon>> | undefined;
let b: Awaited<ReturnType<typeof createTestPaseoDaemon>> | undefined;
let origin: ReturnType<typeof createHostClient> | undefined;
let api: PaseoApi | undefined;
let remote: PaseoApi | undefined;
let sibling: PaseoApi | undefined;
let terminalRoute: Awaited<ReturnType<typeof openConnectedHostTerminal>> | undefined;
let wire: Awaited<ReturnType<typeof relayWire>> | undefined;
try {
await startRelay({ useLocalRelay: true });
const port = relayPort;
b = await createTestPaseoDaemon({
relayEnabled: true,
relayEndpoint: `127.0.0.1:${port}`,
relayUseTls: false,
relayPublicUseTls: false,
auth: { password: hashSync("relay-password", 4) },
});
a = await createTestPaseoDaemon();
origin = createHostClient(`127.0.0.1:${a.port}`, { clientId: crypto.randomUUID() });
await origin.connect();
api = createPaseoApi(origin);
const offerUrl = await getPairingOfferUrl({
paseoHome: b.paseoHome,
relayEnabled: true,
relayEndpoint: b.config.relayEndpoint,
});
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
wire = await relayWire(port);
const connection = new URL(`relay://127.0.0.1:${wire.port}/${serverId}`);
connection.searchParams.set("key", daemonPublicKeyB64);
connection.searchParams.set("password", "wrong-password");
const saved = await api.hosts.add({ connection: connection.toString() });
await expect
.poll(async () => (await api!.hosts.list()).hosts[0]?.error?.code, { timeout: 20000 })
.toBe("authentication_failed");
expect(JSON.stringify(await api.hosts.list())).not.toMatch(/wrong-password|relay-password/);
connection.searchParams.set("password", "relay-password");
await api.hosts.update(saved.registrationId, { connection: connection.toString() });
await expect
.poll(async () => (await api!.hosts.list()).hosts[0]?.status, { timeout: 20000 })
.toBe("online");
const admittedConnections = wire.connections();
expect(wire.active()).toBe(1);
remote = await api.hosts.client(serverId);
sibling = await api.hosts.client(serverId);
expect(wire.connections()).toBe(admittedConnections);
expect(wire.active()).toBe(1);
expect((await remote.workspaces.list()).entries).toEqual([]);
const observed = await remote.workspaces.list({ subscribe: {} });
let snapshots = 0;
observed.subscription.subscribe({
snapshot: () => {
snapshots++;
},
update() {},
});
const workspace = await remote.workspaces.create({
source: { kind: "directory", path: b.staticDir },
title: "Relayed workspace",
});
const terminal = await workspace.terminals.create({
command: process.execPath,
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
});
terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id);
expect(wire.connections()).toBe(admittedConnections);
terminalRoute.write("ENCRYPTED_BINARY_BEFORE\n");
await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_BEFORE");
terminalRoute.close();
terminalRoute = undefined;
const before = snapshots;
await stopRelay();
await expect
.poll(
async () => {
try {
await remote!.workspaces.list();
return "online";
} catch (error) {
return (error as { code: string }).code;
}
},
{ timeout: 15000 },
)
.toBe("DAEMON_CONNECTION_LOST");
await startRelay({ useLocalRelay: true, port });
await expect
.poll(
async () => {
try {
return (await remote!.workspaces.list()).entries.length;
} catch {
return -1;
}
},
{ timeout: 30000 },
)
.toBe(1);
await expect.poll(() => snapshots, { timeout: 15000 }).toBeGreaterThan(before);
expect(wire.active()).toBe(1);
terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id);
terminalRoute.write("ENCRYPTED_BINARY_AFTER\n");
await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_AFTER");
terminalRoute.close();
terminalRoute = undefined;
await remote.dispose();
remote = undefined;
expect((await sibling.workspaces.list()).entries).toHaveLength(1);
await sibling.terminals.ref(terminal.id).kill();
await api.dispose();
await expect(sibling.workspaces.list()).rejects.toThrow();
const direct = createHostClient(`127.0.0.1:${b.port}`, {
clientId: crypto.randomUUID(),
password: "relay-password",
reconnect: { enabled: false },
});
// Needs to stop and restart the relay, which only the spawned wrangler relay allows.
test.skipIf(usesExternalRelay)(
"connected hosts route RPC and binary traffic through local E2EE relay and recover scopes",
async () => {
let a: Awaited<ReturnType<typeof createTestPaseoDaemon>> | undefined;
let b: Awaited<ReturnType<typeof createTestPaseoDaemon>> | undefined;
let origin: ReturnType<typeof createHostClient> | undefined;
let api: PaseoApi | undefined;
let remote: PaseoApi | undefined;
let sibling: PaseoApi | undefined;
let terminalRoute: Awaited<ReturnType<typeof openConnectedHostTerminal>> | undefined;
let wire: Awaited<ReturnType<typeof relayWire>> | undefined;
try {
await direct.connect();
const directApi = createPaseoApi(direct);
expect((await directApi.workspaces.list()).entries).toHaveLength(1);
await directApi.dispose();
await startRelay({ useLocalRelay: true });
const port = relayPort;
b = await createTestPaseoDaemon({
relayEnabled: true,
relayEndpoint: `127.0.0.1:${port}`,
relayUseTls: false,
relayPublicUseTls: false,
auth: { password: hashSync("relay-password", 4) },
});
a = await createTestPaseoDaemon();
origin = createHostClient(`127.0.0.1:${a.port}`, { clientId: crypto.randomUUID() });
await origin.connect();
api = createPaseoApi(origin);
const offerUrl = await getPairingOfferUrl({
paseoHome: b.paseoHome,
relayEnabled: true,
relayEndpoint: b.config.relayEndpoint,
});
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
wire = await relayWire(port);
const connection = new URL(`relay://127.0.0.1:${wire.port}/${serverId}`);
connection.searchParams.set("key", daemonPublicKeyB64);
connection.searchParams.set("password", "wrong-password");
const saved = await api.hosts.add({ connection: connection.toString() });
await expect
.poll(async () => (await api!.hosts.list()).hosts[0]?.error?.code, { timeout: 20000 })
.toBe("authentication_failed");
expect(JSON.stringify(await api.hosts.list())).not.toMatch(/wrong-password|relay-password/);
connection.searchParams.set("password", "relay-password");
await api.hosts.update(saved.registrationId, { connection: connection.toString() });
await expect
.poll(async () => (await api!.hosts.list()).hosts[0]?.status, { timeout: 20000 })
.toBe("online");
const admittedConnections = wire.connections();
expect(wire.active()).toBe(1);
remote = await api.hosts.client(serverId);
sibling = await api.hosts.client(serverId);
expect(wire.connections()).toBe(admittedConnections);
expect(wire.active()).toBe(1);
expect((await remote.workspaces.list()).entries).toEqual([]);
const observed = await remote.workspaces.list({ subscribe: {} });
let snapshots = 0;
observed.subscription.subscribe({
snapshot: () => {
snapshots++;
},
update() {},
});
const workspace = await remote.workspaces.create({
source: { kind: "directory", path: b.staticDir },
title: "Relayed workspace",
});
const terminal = await workspace.terminals.create({
command: process.execPath,
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
});
terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id);
expect(wire.connections()).toBe(admittedConnections);
terminalRoute.write("ENCRYPTED_BINARY_BEFORE\n");
await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_BEFORE");
terminalRoute.close();
terminalRoute = undefined;
const before = snapshots;
await stopRelay();
await expect
.poll(
async () => {
try {
await remote!.workspaces.list();
return "online";
} catch (error) {
return (error as { code: string }).code;
}
},
{ timeout: 15000 },
)
.toBe("DAEMON_CONNECTION_LOST");
await startRelay({ useLocalRelay: true, port });
await expect
.poll(
async () => {
try {
return (await remote!.workspaces.list()).entries.length;
} catch {
return -1;
}
},
{ timeout: 30000 },
)
.toBe(1);
await expect.poll(() => snapshots, { timeout: 15000 }).toBeGreaterThan(before);
expect(wire.active()).toBe(1);
terminalRoute = await openConnectedHostTerminal(origin, serverId, terminal.id);
terminalRoute.write("ENCRYPTED_BINARY_AFTER\n");
await expect.poll(() => terminalRoute!.output()).toContain("ENCRYPTED_BINARY_AFTER");
terminalRoute.close();
terminalRoute = undefined;
await remote.dispose();
remote = undefined;
expect((await sibling.workspaces.list()).entries).toHaveLength(1);
await sibling.terminals.ref(terminal.id).kill();
await api.dispose();
await expect(sibling.workspaces.list()).rejects.toThrow();
const direct = createHostClient(`127.0.0.1:${b.port}`, {
clientId: crypto.randomUUID(),
password: "relay-password",
reconnect: { enabled: false },
});
try {
await direct.connect();
const directApi = createPaseoApi(direct);
expect((await directApi.workspaces.list()).entries).toHaveLength(1);
await directApi.dispose();
} finally {
await direct.close();
}
} finally {
await direct.close();
terminalRoute?.close();
await remote?.dispose();
await sibling?.dispose();
await api?.dispose();
await origin?.close();
await a?.close();
await b?.close();
await wire?.close();
await stopRelay();
}
} finally {
terminalRoute?.close();
await remote?.dispose();
await sibling?.dispose();
await api?.dispose();
await origin?.close();
await a?.close();
await b?.close();
await wire?.close();
await stopRelay();
}
}, 120000);
},
120000,
);
test("oversized attachments fail without disconnecting or resubmitting over the relay", async () => {
await startRelay({ useLocalRelay: true });
@@ -620,11 +748,11 @@ async function waitForCapturedLog(
}
}, 90000);
test("daemon closes a relay client that sends an unsupported handshake key", async () => {
test("updated client negotiates protected endpoint security through the relay", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
const { logger, lines } = createCapturingLogger();
await startRelay({ useLocalRelay: true });
await startRelay();
const daemon = await createTestPaseoDaemon({
listen: "127.0.0.1",
@@ -641,8 +769,76 @@ async function waitForCapturedLog(
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
appBaseUrl: daemon.config.appBaseUrl,
});
const { serverId } = decodeOfferFromFragmentUrl(offerUrl);
const ws = new WebSocket(
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
const client = await openUpdatedRelayClient({
relayPort,
serverId,
daemonPublicKeyB64,
clientId: "cid_relay_protected_client",
});
try {
expect(client.channel.securityMode()).toBe("protected");
expect(client.serverInfo.features?.relayProtectedSecurity).toBe(true);
await client.channel.send(JSON.stringify({ type: "ping" }));
const pong = await client.waitFor(
(message) => (message as { type?: string }).type === "pong",
);
expect(pong).toEqual({ type: "pong" });
await waitForCapturedLog(
lines,
(line) => line.includes('"securityMode":"protected"'),
5_000,
);
} finally {
client.close();
}
} catch (err) {
// eslint-disable-next-line no-console
console.error("daemon logs (tail):\n", lines.slice(-50).join(""));
throw err;
} finally {
await daemon.close();
await stopRelay();
}
}, 90000);
test("daemon requiring protected security rejects a legacy relay client and admits an updated one", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
const { logger, lines } = createCapturingLogger();
await startRelay();
const daemon = await createTestPaseoDaemon({
listen: "127.0.0.1",
logger,
relayEnabled: true,
relayEndpoint: `127.0.0.1:${relayPort}`,
relayRequireProtectedSecurity: true,
});
try {
const offerUrl = await getPairingOfferUrl({
paseoHome: daemon.paseoHome,
relayEnabled: daemon.config.relayEnabled,
relayEndpoint: daemon.config.relayEndpoint,
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
relayRequireProtectedSecurity: daemon.config.relayRequireProtectedSecurity,
appBaseUrl: daemon.config.appBaseUrl,
});
const offer = ConnectionOfferSchema.parse(
JSON.parse(
Buffer.from(
offerUrl.slice(offerUrl.indexOf("#offer=") + "#offer=".length),
"base64url",
).toString("utf8"),
),
);
expect(offer.minimumSecurityMode).toBe("protected");
const { serverId, daemonPublicKeyB64 } = offer;
// A client that only speaks the legacy construction is refused before admission.
const legacyWs = new WebSocket(
buildRelayWebSocketUrl({
endpoint: `127.0.0.1:${relayPort}`,
useTls: false,
@@ -650,92 +846,188 @@ async function waitForCapturedLog(
role: "client",
}),
);
let receivedServerInfo = false;
const isRelayDataConnectedLog = (line: string) => line.includes("relay_data_connected");
const outcome = await new Promise<
{ type: "closed"; code: number; reason: string } | { type: "errored"; error: Error }
>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("timed out waiting for daemon to reject unsupported handshake key"));
}, 20_000);
ws.on("open", async () => {
try {
await waitForCapturedLog(lines, isRelayDataConnectedLog);
ws.send(
JSON.stringify({
type: "e2ee_hello",
key: exportPublicKey(new Uint8Array(32)),
}),
);
ws.send(
encodeCiphertext(
encrypt(
UNSUPPORTED_PEER_TRANSPORT_KEY,
JSON.stringify({
type: "hello",
clientId: "cid_relay_unsupported_key",
clientType: "cli",
protocolVersion: 1,
}),
),
),
);
} catch (error) {
clearTimeout(timeout);
reject(error);
}
});
ws.on("message", (data) => {
try {
const parsed = WSOutboundMessageSchema.parse(
parseEncryptedJson(UNSUPPORTED_PEER_TRANSPORT_KEY, data.toString()),
);
if (
parsed.type === "session" &&
parsed.message.type === "status" &&
parsed.message.payload?.status === "server_info"
) {
receivedServerInfo = true;
clearTimeout(timeout);
reject(
new Error("daemon sent server_info after accepting an unsupported handshake key"),
);
}
} catch {
// Plaintext e2ee_ready cannot be decrypted and is irrelevant to this assertion.
}
});
ws.on("close", (code, reason) => {
clearTimeout(timeout);
resolve({ type: "closed", code, reason: reason.toString() });
});
ws.on("error", (error) => {
clearTimeout(timeout);
resolve({ type: "errored", error });
const legacyTransport: Transport = {
send: (data) => legacyWs.send(data),
close: (code?: number, reason?: string) => legacyWs.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
legacyWs.on("message", (data, isBinary) => {
const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as Buffer);
legacyTransport.onmessage?.({
data: isBinary
? buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
: buffer.toString("utf8"),
isBinary,
});
});
const legacyClosed = new Promise<{ code: number; reason: string }>((resolve) => {
legacyWs.on("close", (code, reason) => resolve({ code, reason: reason.toString() }));
});
await once(legacyWs, "open");
const legacyMessages: unknown[] = [];
void createClientChannel(legacyTransport, daemonPublicKeyB64, {
onmessage: (data) => legacyMessages.push(data),
}).catch(() => undefined);
const closed = await withTimeout(legacyClosed, 20_000, "legacy client close");
// The daemon closes its side with 1008; the relay reports its own close code to
// the client (the wrangler relay sends 1012 "Server disconnected"), so only the
// daemon log carries the policy reason.
expect(closed.code).not.toBe(1000);
expect(legacyMessages).toEqual([]);
await waitForCapturedLog(
lines,
(line) =>
line.includes("relay_e2ee_handshake_rejected") &&
line.includes("legacy endpoint security"),
);
expect(receivedServerInfo).toBe(false);
if (outcome.type === "closed") {
expect(outcome.code).toBeGreaterThan(0);
} else {
expect(outcome.error).toBeInstanceOf(Error);
// An updated client pinned by the offer is admitted in protected mode.
const client = await openUpdatedRelayClient({
relayPort,
serverId,
daemonPublicKeyB64,
clientId: "cid_relay_protected_required",
minimumMode: offer.minimumSecurityMode,
});
try {
expect(client.channel.securityMode()).toBe("protected");
expect(client.serverInfo.features?.relayProtectedSecurity).toBe(true);
} finally {
client.close();
}
const isRejectedHandshakeLog = (line: string) =>
line.includes("relay_e2ee_handshake_failed") && line.includes("Invalid peer public key");
await waitForCapturedLog(lines, isRejectedHandshakeLog);
expect(lines.some(isRejectedHandshakeLog)).toBe(true);
} catch (err) {
// eslint-disable-next-line no-console
console.error("daemon logs (tail):\n", lines.slice(-50).join(""));
throw err;
} finally {
await daemon.close();
await stopRelay();
}
}, 90000);
// The Elixir relay rejects the invalid key itself, so the daemon never sees this hello.
test.skipIf(usesExternalRelay)(
"daemon closes a relay client that sends an unsupported handshake key",
async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
const { logger, lines } = createCapturingLogger();
await startRelay({ useLocalRelay: true });
const daemon = await createTestPaseoDaemon({
listen: "127.0.0.1",
logger,
relayEnabled: true,
relayEndpoint: `127.0.0.1:${relayPort}`,
});
try {
const offerUrl = await getPairingOfferUrl({
paseoHome: daemon.paseoHome,
relayEnabled: daemon.config.relayEnabled,
relayEndpoint: daemon.config.relayEndpoint,
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
appBaseUrl: daemon.config.appBaseUrl,
});
const { serverId } = decodeOfferFromFragmentUrl(offerUrl);
const ws = new WebSocket(
buildRelayWebSocketUrl({
endpoint: `127.0.0.1:${relayPort}`,
useTls: false,
serverId,
role: "client",
}),
);
let receivedServerInfo = false;
const isRelayDataConnectedLog = (line: string) => line.includes("relay_data_connected");
const outcome = await new Promise<
{ type: "closed"; code: number; reason: string } | { type: "errored"; error: Error }
>((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error("timed out waiting for daemon to reject unsupported handshake key"));
}, 20_000);
ws.on("open", async () => {
try {
await waitForCapturedLog(lines, isRelayDataConnectedLog);
ws.send(
JSON.stringify({
type: "e2ee_hello",
key: exportPublicKey(new Uint8Array(32)),
}),
);
ws.send(
encodeCiphertext(
encrypt(
UNSUPPORTED_PEER_TRANSPORT_KEY,
JSON.stringify({
type: "hello",
clientId: "cid_relay_unsupported_key",
clientType: "cli",
protocolVersion: 1,
}),
),
),
);
} catch (error) {
clearTimeout(timeout);
reject(error);
}
});
ws.on("message", (data) => {
try {
const parsed = WSOutboundMessageSchema.parse(
parseEncryptedJson(UNSUPPORTED_PEER_TRANSPORT_KEY, data.toString()),
);
if (
parsed.type === "session" &&
parsed.message.type === "status" &&
parsed.message.payload?.status === "server_info"
) {
receivedServerInfo = true;
clearTimeout(timeout);
reject(
new Error("daemon sent server_info after accepting an unsupported handshake key"),
);
}
} catch {
// Plaintext e2ee_ready cannot be decrypted and is irrelevant to this assertion.
}
});
ws.on("close", (code, reason) => {
clearTimeout(timeout);
resolve({ type: "closed", code, reason: reason.toString() });
});
ws.on("error", (error) => {
clearTimeout(timeout);
resolve({ type: "errored", error });
});
});
expect(receivedServerInfo).toBe(false);
if (outcome.type === "closed") {
expect(outcome.code).toBeGreaterThan(0);
} else {
expect(outcome.error).toBeInstanceOf(Error);
}
const isRejectedHandshakeLog = (line: string) =>
line.includes("relay_e2ee_handshake_failed") && line.includes("Invalid peer public key");
await waitForCapturedLog(lines, isRejectedHandshakeLog);
expect(lines.some(isRejectedHandshakeLog)).toBe(true);
} finally {
await daemon.close();
await stopRelay();
}
},
90000,
);
test("daemon keeps relay socket open while idle (no handshake timeout loop)", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
@@ -18,6 +18,8 @@ export async function generateLocalPairingOffer(args: {
relayPublicEndpoint?: string;
relayUseTls?: boolean;
relayPublicUseTls?: boolean;
/** Stamp the offer so new clients require protected endpoint security from their first handshake. */
relayRequireProtectedSecurity?: boolean;
appBaseUrl?: string;
includeQr?: boolean;
logger?: Logger;
@@ -42,6 +44,9 @@ export async function generateLocalPairingOffer(args: {
serverId,
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
relay: { endpoint: relayPublicEndpoint, useTls: relayPublicUseTls },
...(args.relayRequireProtectedSecurity === true
? { minimumSecurityMode: "protected" as const }
: {}),
});
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
@@ -278,6 +278,12 @@ export const PersistedConfigSchema = z
relay: z
.object({
enabled: z.boolean().optional(),
requireProtectedSecurity: z
.boolean()
.optional()
.describe(
"Admit only relay clients that negotiate protected endpoint security (replay protection). Defaults to false; older clients still connect while it is off.",
),
endpoint: z.string().optional(),
publicEndpoint: z.string().optional(),
useTls: z.boolean().optional(),
@@ -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;
}
@@ -24,6 +24,7 @@ export interface DaemonRuntimeConfig {
publicEndpoint: string;
useTls: boolean;
publicUseTls: boolean;
requireProtectedSecurity?: boolean;
} | null;
}
@@ -224,6 +225,7 @@ export class DaemonSession {
relayPublicEndpoint: relay?.publicEndpoint,
relayUseTls: relay?.useTls,
relayPublicUseTls: relay?.publicUseTls,
relayRequireProtectedSecurity: relay?.requireProtectedSecurity ?? false,
appBaseUrl: this.daemonRuntimeConfig?.appBaseUrl,
includeQr: true,
logger: this.logger,
@@ -30,6 +30,7 @@ interface TestPaseoDaemonOptions {
relayEndpoint?: string;
relayUseTls?: boolean;
relayPublicUseTls?: boolean;
relayRequireProtectedSecurity?: boolean;
daemonStatusRpcCapability?: boolean;
relayConfigCapability?: boolean;
agentClients?: Partial<Record<AgentProvider, AgentClient>>;
@@ -200,6 +201,7 @@ async function prepareTestDaemonConfig(
relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443",
relayUseTls: options.relayUseTls,
relayPublicUseTls: options.relayPublicUseTls,
relayRequireProtectedSecurity: options.relayRequireProtectedSecurity,
appBaseUrl: "https://app.paseo.sh",
auth: options.auth,
pushNotificationSender: options.pushNotificationSender,
@@ -112,6 +112,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";
@@ -133,6 +134,8 @@ export interface ExternalSocketMetadata {
transport: "relay" | "hub";
externalSessionKey?: string;
relayConnectionId?: string;
/** Negotiated endpoint security for relay-carried sockets. */
securityMode?: SecurityMode;
hubDaemonId?: string;
}
@@ -160,6 +163,7 @@ interface WebSocketConnectionIdentity {
userAgent?: string;
remoteAddress?: string;
relayConnectionId?: string;
securityMode?: SecurityMode;
hubDaemonId?: string;
clientId?: string;
sessionId?: string;
@@ -1943,6 +1947,8 @@ export class VoiceAssistantWebSocketServer {
features: {
connectedHosts: true,
hostMultiplexing: true,
// COMPAT(relayProtectedSecurity): added in v0.13.0, remove gate after 2027-04-21.
relayProtectedSecurity: true,
usageSources: true,
ownedSubscriptions: true,
agentRequestReceipts: true,
@@ -2984,6 +2990,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 } : {}),
};
}
@@ -3002,6 +3009,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,272 @@
import { afterEach, expect, test, vi } from "vitest";
import pino from "pino";
import {
createClientChannel,
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" });
const allowLegacy = { requireProtectedMode: () => false };
/** 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 });
}
/** An older client that only speaks the legacy construction. */
async function openLegacyClientAgainst(socket: FakeIngressSocket, daemonPublicKeyB64: string) {
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 createClientChannel(clientTransport, daemonPublicKeyB64);
}
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, ...allowLegacy },
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, ...allowLegacy },
async (next) => {
secured = next;
},
);
await openClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey));
await admitted;
expect(secured).not.toBeNull();
expect(secured!.securityMode).toBe("protected");
expect(secured!.socket.readyState).toBe(1);
});
test("admits a legacy client while protected mode is not required", async () => {
const socket = new FakeIngressSocket();
const daemonKeyPair = generateKeyPair();
let secured: SecuredRelaySocket | null = null;
const admitted = admitSecuredRelaySocket(
socket,
{ daemonKeyPair, logger, ...allowLegacy },
async (next) => {
secured = next;
},
);
await openLegacyClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey));
await admitted;
expect(secured!.securityMode).toBe("legacy");
});
test("rejects a legacy client with a policy close when protected mode is required", async () => {
const socket = new FakeIngressSocket();
const daemonKeyPair = generateKeyPair();
let attachments = 0;
const admitted = admitSecuredRelaySocket(
socket,
{ daemonKeyPair, logger, requireProtectedMode: () => true },
async () => {
attachments += 1;
},
);
await openLegacyClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey));
await admitted;
expect(attachments).toBe(0);
expect(socket.closes).toEqual([{ code: 1008, reason: "protected endpoint security required" }]);
});
test("reads the policy when the hello arrives, not when the socket was accepted", async () => {
const socket = new FakeIngressSocket();
const daemonKeyPair = generateKeyPair();
let required = false;
let attachments = 0;
const admitted = admitSecuredRelaySocket(
socket,
{ daemonKeyPair, logger, requireProtectedMode: () => required },
async () => {
attachments += 1;
},
);
required = true;
await openLegacyClientAgainst(socket, exportPublicKey(daemonKeyPair.publicKey));
await admitted;
expect(attachments).toBe(0);
expect(socket.closes[0]?.code).toBe(1008);
});
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, ...allowLegacy },
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, ...allowLegacy }, 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,210 @@
/// <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,
SecureChannelRejectedError,
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;
/**
* Current policy, read when the peer's hello arrives so a flag flipped at
* runtime applies to every admission from that moment on.
*/
requireProtectedMode: () => boolean;
}
/**
* 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,
minimumMode: () => (options.requireProtectedMode() ? "protected" : "legacy"),
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) {
if (error instanceof SecureChannelRejectedError) {
logger.warn({ reason: error.message }, "relay_e2ee_handshake_rejected");
try {
socket.close(error.closeCode, error.closeReason);
} catch {
// ignore
}
return;
}
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;
}
@@ -14,7 +14,7 @@ import { join } from "path";
import { win32 } from "node:path";
import { tmpdir } from "os";
import pino from "pino";
import { base64EncryptedWireByteLength } from "@getpaseo/relay";
import { base64EncryptedWireByteLength, maxSecuredPlaintextByteLength } from "@getpaseo/relay";
import {
__resetCheckoutShortstatCacheForTests,
__resetPullRequestStatusCacheForTests,
@@ -1654,9 +1654,14 @@ const x = 1;
CHECKOUT_DIFF_MAX_STRUCTURED_BYTES + frameEnvelopeHeadroomBytes,
);
expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBe(24_117_208);
// The cap is sized for the tighter of the legacy and protected relay framings.
expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBe(
maxSecuredPlaintextByteLength(relayFrameBytes) - frameEnvelopeHeadroomBytes,
);
expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBeLessThanOrEqual(24_117_208);
expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBeGreaterThan(24_000_000);
expect(diffWireBytes).toBeLessThan(relayFrameBytes);
expect(maximumFrameWireBytes).toBe(relayFrameBytes);
expect(maximumFrameWireBytes).toBeLessThanOrEqual(relayFrameBytes);
});
it("marks tracked generated one-line diffs as too_large by content size", async () => {
+2 -2
View File
@@ -7,7 +7,7 @@ import { setImmediate } from "node:timers/promises";
import { TTLCache } from "@isaacs/ttlcache";
import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages";
import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { maxBase64EncryptedPlaintextByteLength } from "@getpaseo/relay";
import { maxSecuredPlaintextByteLength } from "@getpaseo/relay";
import type { Logger } from "pino";
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
import {
@@ -2162,7 +2162,7 @@ const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024;
// binding: string frames are encrypted and base64-encoded. Reserve 1 MiB plaintext for
// the surrounding WebSocket JSON envelope after inverting that exact wire expansion.
export const CHECKOUT_DIFF_MAX_STRUCTURED_BYTES =
maxBase64EncryptedPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) -
maxSecuredPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) -
CHECKOUT_DIFF_FRAME_HEADROOM_BYTES;
interface StructuredDiffAccumulator {