mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user