Files
paseo/packages/client/src/node/index.ts
T
Mohamed Boudra 793b16c0df 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
2026-10-22 19:50:06 +07:00

205 lines
7.5 KiB
TypeScript

export { createHostChannelTransport } from "../hosts/index.js";
import { HostTransportBudget } from "./buffering.js";
export { HostTransportBudget } from "./buffering.js";
import { homedir } from "node:os";
import { WebSocket } from "ws";
import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target";
import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js";
import { createWebSocketTransportFactory } from "../daemon-client-websocket-transport.js";
import { createRelayE2eeTransportFactory } from "../daemon-client-relay-e2ee-transport.js";
import type { SecurityMode } from "@getpaseo/relay/e2ee";
import type {
DaemonTransport,
DaemonTransportFactory,
WebSocketLike,
} from "../daemon-client-transport-types.js";
import { DaemonClient, type DaemonClientConfig } from "../daemon-client.js";
export { parseHostTarget };
export type { SshTunnel } from "./ssh-tunnel.js";
export { createSshTunnel, resolveSshFailureDetail } from "./ssh-tunnel.js";
/** A factory owns one attempt, including a fresh SSH process and its teardown. */
export function createHostTransportFactory(
connection: string,
budget = new HostTransportBudget(),
): DaemonTransportFactory {
const target = parseHostTarget(connection);
// Rises to "protected" once the daemon negotiates it, so a later attempt
// cannot be downgraded to legacy endpoint security.
let securityFloor: SecurityMode | undefined =
target.kind === "socket" ? target.offer?.minimumSecurityMode : undefined;
return (attemptOptions) => {
const opens = new Set<() => void>();
const closes = new Set<(event?: unknown) => void>();
const errors = new Set<(event?: unknown) => void>();
const messages = new Set<(data: unknown, binary: boolean) => void>();
let closed = false;
let closeEvent: unknown;
let transport: DaemonTransport | undefined;
let teardown = () => {};
let disposeSocket = () => {};
let assertOutboundCapacity = (bytes: number) => budget.assertCapacity(bytes);
let sshTunnel: SshTunnel | null = null;
const close = (code?: number, reason?: string) => {
if (closed) return;
closed = true;
try {
transport?.close(code, reason);
} finally {
try {
disposeSocket();
teardown();
} finally {
for (const handler of closes) handler(closeEvent);
}
}
};
void (async () => {
let socketTarget: Extract<HostTarget, { kind: "socket" }>;
if (target.kind === "ssh") {
const tunnel = await createSshTunnel(target.ssh);
sshTunnel = tunnel;
teardown = () => tunnel.close();
socketTarget = parseHostTarget(tunnel.endpoint) as Extract<HostTarget, { kind: "socket" }>;
} else socketTarget = target;
if (closed) {
teardown();
return;
}
const socketPath = socketTarget.socketPath?.replace(/^~(?=\/)/, homedir());
let factory = createWebSocketTransportFactory((url, options) => {
const socket = new WebSocket(
socketPath && url.startsWith("ws+unix:") ? `ws+unix://${socketPath}:/ws` : url,
options?.protocols,
{
headers: options?.headers,
...(socketPath ? { socketPath } : {}),
maxPayload: 100 * 1024 * 1024,
},
);
disposeSocket = () => socket.terminate();
assertOutboundCapacity = (bytes) => {
if (socket.bufferedAmount + bytes > 128 * 1024 * 1024) {
throw Object.assign(new Error("Host socket buffer limit reached"), {
code: "resource_limit",
});
}
budget.assertCapacity(bytes);
};
const pending = new Set<() => void>();
const send = socket.send.bind(socket);
socket.send = (data, ...args: unknown[]) => {
const bytes =
typeof data === "string"
? Buffer.byteLength(data)
: Buffer.byteLength(data as Uint8Array);
assertOutboundCapacity(bytes);
const release = budget.reserve(bytes);
pending.add(release);
const callback = args.find(
(value): value is (error?: Error) => void => typeof value === "function",
);
const sendOptions = args.find((value) => value !== null && typeof value === "object") as
| { binary?: boolean }
| undefined;
try {
send(data, sendOptions ?? {}, (error) => {
release();
pending.delete(release);
callback?.(error);
});
} catch (error) {
release();
pending.delete(release);
throw error;
}
};
socket.once("close", () => {
for (const release of pending) release();
pending.clear();
});
return socket as unknown as WebSocketLike;
});
if (socketTarget.offer)
factory = createRelayE2eeTransportFactory({
baseFactory: factory,
daemonPublicKeyB64: socketTarget.offer.daemonPublicKeyB64,
...(securityFloor ? { minimumMode: securityFloor } : {}),
onSecurityMode: (mode) => {
if (mode === "protected") securityFloor = "protected";
},
logger: { warn() {} },
assertOutboundCapacity: (bytes) => assertOutboundCapacity(bytes),
});
transport = factory({ ...attemptOptions, url: socketTarget.url });
transport.onOpen(() => {
if (!closed) for (const handler of opens) handler();
});
transport.onClose((event) => {
closeEvent = event;
close();
});
transport.onError((error) => {
if (closed) return;
const detail = sshTunnel?.failureDetail() ?? "";
const failure =
/permission denied|authentication failed|host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(
detail,
)
? Object.assign(
new Error(
"SSH authentication or host verification failed. Check the SSH configuration on this daemon.",
),
{ code: "authentication_failed" },
)
: error;
for (const handler of errors) handler(failure);
close();
});
transport.onMessage((data, binary) => {
if (!closed) for (const handler of messages) handler(data, binary);
});
})().catch(() => {
for (const handler of errors) handler(new Error("Host transport failed"));
close();
});
return {
send(data) {
if (closed || !transport) throw new Error("Host transport is unavailable");
transport.send(data);
},
close,
onOpen(handler) {
opens.add(handler);
return () => opens.delete(handler);
},
onClose(handler) {
closes.add(handler);
return () => closes.delete(handler);
},
onError(handler) {
errors.add(handler);
return () => errors.delete(handler);
},
onMessage(handler) {
messages.add(handler);
return () => messages.delete(handler);
},
};
};
}
export function createHostClient(
connection: string,
config: Omit<DaemonClientConfig, "url" | "transportFactory">,
budget?: HostTransportBudget,
): DaemonClient {
const target = parseHostTarget(connection);
return new DaemonClient({
...config,
url: "ws://host-connector/ws",
...(target.password ? { password: target.password } : {}),
transportFactory: createHostTransportFactory(connection, budget),
});
}