fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-30 09:55:34 -05:00
1 parent ec7c2c2835
commit 9b690b9935
19 files changed
+1203 -58

No files matched your search

@@ -0,0 +1,86 @@
import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
const require = createRequire(import.meta.url);
const { createC2SAuthBridge } = require("../../../../electron/c2s-auth.cjs");
function setup() {
const sender = Object.assign(new EventEmitter(), {
send: vi.fn(),
isDestroyed: () => false,
});
const ws = Object.assign(new EventEmitter(), {
readyState: 1,
send: vi.fn(),
close: vi.fn(() => {
ws.readyState = 3;
ws.emit("close");
}),
});
let answer!: (
event: { sender: unknown },
id: string,
value: unknown,
) => boolean;
const attach = createC2SAuthBridge(
{
handle: (_name: string, fn: typeof answer) => {
answer = fn;
},
},
() => ({ isDestroyed: () => false, webContents: sender }),
);
attach(ws, "dynamic");
const prompt = () =>
ws.emit(
"message",
Buffer.from(
JSON.stringify({
type: "auth-prompt",
requestId: "server-challenge",
request: { kind: "totp", prompt: "Code:", retry: false },
}),
),
false,
);
return { sender, ws, answer, prompt };
}
afterEach(() => vi.useRealTimers());
describe("desktop tunnel prompt bridge", () => {
it("binds answers to the renderer and challenge, and rejects replay", () => {
const { sender, ws, answer, prompt } = setup();
prompt();
const id = sender.send.mock.calls[0][1].id;
expect(answer({ sender: {} }, id, "123456")).toBe(false);
expect(answer({ sender }, "unknown", "123456")).toBe(false);
expect(answer({ sender }, id, { secret: "bad" })).toBe(false);
expect(ws.send).not.toHaveBeenCalled();
expect(answer({ sender }, id, "123456")).toBe(true);
expect(JSON.parse(ws.send.mock.calls[0][0])).toEqual({
type: "auth-response",
requestId: "server-challenge",
answer: "123456",
});
expect(answer({ sender }, id, "again")).toBe(false);
expect(sender.listenerCount("destroyed")).toBe(0);
ws.close();
});
it.each(["close", "reload", "timeout"])(
"clears a pending dialog on %s",
(reason) => {
vi.useFakeTimers();
const { sender, ws, answer, prompt } = setup();
prompt();
const id = sender.send.mock.calls[0][1].id;
if (reason === "close") ws.close();
else if (reason === "reload") sender.emit("did-start-loading");
else vi.advanceTimersByTime(60_000);
expect(sender.send).toHaveBeenLastCalledWith("c2s-auth-prompt", {
id,
closed: true,
});
expect(answer({ sender }, id, "late")).toBe(false);
expect(ws.close).toHaveBeenCalled();
},
);
});
@@ -0,0 +1,143 @@
import { EventEmitter } from "node:events";
import { readFileSync } from "node:fs";
import vm from "node:vm";
import { describe, expect, it, vi } from "vitest";
const main = readFileSync(
new URL("../../../../electron/main.cjs", import.meta.url),
"utf8",
);
const section = (from: string, to: string) =>
main.slice(main.indexOf(from), main.indexOf(to, main.indexOf(from)));
function setup() {
const sockets: Socket[] = [];
class Socket extends EventEmitter {
readyState = 1;
send = vi.fn();
close = vi.fn(() => {
if (this.readyState === 3) return;
this.readyState = 3;
this.emit("close");
});
constructor() {
super();
sockets.push(this);
queueMicrotask(() => this.emit("open"));
}
ready() {
this.emit(
"message",
Buffer.from(JSON.stringify({ type: "ready" })),
false,
);
}
}
const runtimes = new Map();
let incoming!: (socket: EventEmitter) => void;
const server = Object.assign(new EventEmitter(), {
listen: (_options: unknown, ready: () => void) => ready(),
close: vi.fn((done?: () => void) => done?.()),
});
const headers = vi.fn(async () => ({}));
const dynamic = vi.fn();
const context = vm.createContext({
setTimeout,
clearTimeout,
WebSocket: Socket,
crypto: { randomUUID: () => "runtime-token" },
net: {
createServer: (callback: typeof incoming) => {
incoming = callback;
return server;
},
},
c2sTunnelRuntimes: runtimes,
normalizeC2STunnelAddresses: (t: unknown) => t,
resolveC2SRemoteSourceHost: async (t: unknown) => t,
getC2STunnelName: (t: { name: string }) => t.name,
getC2SLocalAddress: () => "127.0.0.1",
getC2SRemoteAddress: () => "127.0.0.1",
getC2SRelayUrl: () => "wss://server/relay",
getC2SRelayHeaders: headers,
getWebSocketOptions: (_url: string, options: unknown) => options,
attachC2SAuth: vi.fn(),
logToFile: vi.fn(),
emitC2STunnelStatuses: vi.fn(),
checkLocalPortAvailable: async () => ({ available: true }),
handleC2SDynamicConnection: dynamic,
setC2STunnelStatus: (name: string, status: unknown) => {
runtimes.get(name).status = status;
},
setC2STunnelError: (name: string, error: string) => {
runtimes.get(name).status = { connected: false, reason: error };
},
createC2SFailure: (error: string) => ({ success: false, error }),
});
vm.runInContext(
section("async function testC2SRelay(", "async function testC2STunnel(") +
section("async function startC2STunnel(", "function stopAllC2STunnels("),
context,
);
return {
context,
runtimes,
sockets,
server,
headers,
dynamic,
incoming: () => incoming,
};
}
describe("client tunnel runtime authentication", () => {
it("holds preflight open, shares its runtime identity and token, and releases it when stopped", async () => {
const { context, runtimes, sockets, server, headers, dynamic, incoming } =
setup();
const tunnel = {
name: "socks",
mode: "dynamic",
sourceHostId: 7,
sourcePort: 1080,
};
await context.startC2STunnel(tunnel, 0, "local-auth");
await vi.waitFor(() => expect(sockets).toHaveLength(1));
const premature = Object.assign(new EventEmitter(), { destroy: vi.fn() });
incoming()(premature);
expect(premature.destroy).toHaveBeenCalledOnce();
expect(headers).toHaveBeenCalledWith("wss://server/relay", "local-auth");
expect(JSON.parse(sockets[0].send.mock.calls[0][0])).toMatchObject({
keepAlive: true,
tunnelConfig: { sessionId: "runtime-token" },
});
sockets[0].ready();
await vi.waitFor(() =>
expect(runtimes.get("socks").status.connected).toBe(true),
);
expect(sockets[0].close).not.toHaveBeenCalled();
const client = Object.assign(new EventEmitter(), { destroy: vi.fn() });
incoming()(client);
expect(dynamic).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: "runtime-token" }),
client,
"local-auth",
);
await context.stopC2STunnel("socks");
expect(sockets[0].close).toHaveBeenCalledOnce();
expect(client.destroy).toHaveBeenCalledOnce();
expect(server.close).toHaveBeenCalledOnce();
expect(runtimes.size).toBe(0);
});
it("does not retain a socket for a standalone test or a stopped runtime", async () => {
const { context, sockets } = setup();
const test = context.testC2SRelay({ name: "test" });
await vi.waitFor(() => expect(sockets).toHaveLength(1));
sockets[0].ready();
expect(await test).toEqual({ success: true });
expect(sockets[0].close).toHaveBeenCalledOnce();
expect(
await context.testC2SRelay({ name: "stopped" }, null, null, null, {}),
).toEqual({ success: false, error: "Tunnel stopped" });
expect(sockets).toHaveLength(1);
});
});
@@ -518,6 +518,89 @@ describe("connectHost", () => {
dispose();
});
it("answers TOTP on key and password jump hops and closes the entire chain", async () => {
const { createJumpHostChain } = await vi.importActual<
typeof import("../../../hosts/jump-host-chain.js")
>("../../../hosts/jump-host-chain.js");
mocks.resolveHostById
.mockResolvedValueOnce(
host({ id: 2, authType: "key", key: plainKey, password: null }),
)
.mockResolvedValueOnce(host({ id: 3, password: "hop-password" }));
FakeSshClient.nextBehaviour = ["hang", "hang"];
const prompt = {
ask: vi
.fn()
.mockResolvedValueOnce("111111")
.mockResolvedValueOnce("222222"),
};
const pending = createJumpHostChain(
[{ hostId: 2 }, { hostId: 3 }],
"user-1",
prompt,
);
await vi.waitFor(() =>
expect(FakeSshClient.instances[0]?.connectConfig).toBeTruthy(),
);
const first = FakeSshClient.instances[0];
expect(first.connectConfig?.privateKey).toBeTruthy();
const firstAnswer = vi.fn();
first.emit(
"keyboard-interactive",
"",
"",
"",
[{ prompt: "Verification code:", echo: false }],
firstAnswer,
);
await vi.waitFor(() =>
expect(firstAnswer).toHaveBeenCalledWith(["111111"]),
);
first.emit("ready");
await vi.waitFor(() =>
expect(FakeSshClient.instances[1]?.connectConfig).toBeTruthy(),
);
const second = FakeSshClient.instances[1];
const secondAnswer = vi.fn();
second.emit(
"keyboard-interactive",
"",
"",
"",
[
{ prompt: "Password:", echo: false },
{ prompt: "Verification code:", echo: false },
],
secondAnswer,
);
await vi.waitFor(() =>
expect(secondAnswer).toHaveBeenCalledWith(["hop-password", "222222"]),
);
second.emit("ready");
expect(await pending).toBe(second);
expect(prompt.ask.mock.calls[0][0].prompt).toContain("Jump host 1/2");
expect(prompt.ask.mock.calls[1][0].prompt).toContain("Jump host 2/2");
second.emit("close");
expect(first.ended).toBe(true);
expect(second.ended).toBe(true);
});
it("passes the interactive prompt channel through to jump hosts", async () => {
const jumpClient = new FakeSshClient();
mocks.createJumpHostChain.mockResolvedValueOnce(jumpClient);
const prompt = { ask: vi.fn().mockResolvedValue("123456") };
const connection = await connectHost(host({ jumpHosts: [{ hostId: 2 }] }), {
userId: "user-1",
prompt,
});
expect(mocks.createJumpHostChain).toHaveBeenCalledWith(
[{ hostId: 2 }],
"owner-1",
prompt,
);
connection.dispose();
});
it("goes through the jump chain and forwards to the target", async () => {
const jumpClient = new FakeSshClient();
mocks.createJumpHostChain.mockResolvedValueOnce(jumpClient);
@@ -528,6 +611,7 @@ describe("connectHost", () => {
expect(mocks.createJumpHostChain).toHaveBeenCalledWith(
[{ hostId: 2 }, { hostId: 3 }],
"owner-1",
undefined,
);
expect(jumpClient.forwardOut).toHaveBeenCalledWith(
"127.0.0.1",