feat: mTLS client certificates for desktop server links

This commit is contained in:
LukeGus committed 2026-10-19 21:54:19 -05:00
1 parent 802b3003d5
commit 895488d400
9 files changed
+373 -12

No files matched your search

+1
View File
@@ -28,6 +28,7 @@
- FIDO2 security keys (ed25519-sk and ecdsa-sk) work through SSH Agent authentication when Termix runs in the desktop app. The key asks for a touch or PIN on your own machine
- Pasting a security key file now explains how to use it instead of failing to decode it
- Plugins can add a panel that sits beside a host tab, like a terminal. You can open, resize and move it
- The desktop app can link to a server that requires a client certificate (mutual TLS). It uses a PFX or P12 file for sync and the certificate in your system store for the app window
### Changed
+13
View File
@@ -34,6 +34,8 @@ function setLinkedServer(config) {
basicAuth:
config.basicAuth && config.basicAuth.username ? config.basicAuth : null,
allowInvalidCertificate: !!config.allowInvalidCertificate,
clientCert:
config.clientCert && config.clientCert.pfx ? config.clientCert : null,
};
}
@@ -45,6 +47,16 @@ function isLinkedOrigin(url) {
return !!linked && getOrigin(url) === linked.origin;
}
// The PFX of the link's client certificate, for TLS this process makes itself
// to the linked origin. Null for any other origin.
function clientCertOptionsFor(url) {
if (!isLinkedOrigin(url) || !linked.clientCert) return {};
return {
pfx: Buffer.from(linked.clientCert.pfx, "base64"),
passphrase: linked.clientCert.passphrase || "",
};
}
function basicAuthValue(basicAuth) {
return `Basic ${Buffer.from(
`${basicAuth.username}:${basicAuth.password || ""}`,
@@ -99,6 +111,7 @@ module.exports = {
setLinkedServer,
getLinkedServer,
isLinkedOrigin,
clientCertOptionsFor,
applyLinkHeaders,
linkedRequestHeaders,
answerLogin,
+34
View File
@@ -459,6 +459,7 @@ function isInvalidCertificateAllowedForUrl(url) {
function getTlsVerificationOptions(url) {
return {
rejectUnauthorized: !isInvalidCertificateAllowedForUrl(url),
...linkedServer.clientCertOptionsFor(url),
};
}
@@ -690,6 +691,39 @@ ipcMain.handle("allow-invalid-certificate-for-origin", (_event, origin) => {
}
});
// Mutual TLS for the linked server: Chromium asks which certificate from the
// OS store to present. Other origins keep Chromium's default (none).
const chosenClientCerts = new Map();
app.on(
"select-client-certificate",
async (event, _webContents, url, list, callback) => {
if (!linkedServer.isLinkedOrigin(url) || !list.length) return;
event.preventDefault();
const origin = new URL(url).origin;
const remembered = list.find(
(cert) => cert.fingerprint === chosenClientCerts.get(origin),
);
let chosen = remembered || (list.length === 1 ? list[0] : null);
if (!chosen) {
const { response } = await dialog.showMessageBox(mainWindow, {
type: "question",
title: "Client certificate",
message: "Choose the certificate for this server",
detail: origin,
buttons: [
...list.slice(0, 8).map((cert) => cert.subjectName),
"Cancel",
],
cancelId: Math.min(list.length, 8),
});
chosen = list[response] || null;
}
if (chosen) chosenClientCerts.set(origin, chosen.fingerprint);
callback(chosen || undefined);
},
);
app.on(
"certificate-error",
(event, _webContents, url, error, certificate, callback) => {
+1
View File
@@ -157,6 +157,7 @@ export function createPluginDesktopLocal(deps: Deps): PluginDesktopLocal {
customHeaders: link.customHeaders,
basicAuth: link.basicAuth,
allowInvalidCertificate: link.allowInvalidCertificate,
clientCert: link.clientCert,
},
pathname,
);
+6 -1
View File
@@ -14,7 +14,11 @@ export async function applyElectronProxyConfig(
link:
| (Pick<
SyncLink,
"serverUrl" | "customHeaders" | "basicAuth" | "allowInvalidCertificate"
| "serverUrl"
| "customHeaders"
| "basicAuth"
| "allowInvalidCertificate"
| "clientCert"
> & { sessionToken?: string | null; status?: string })
| null,
): Promise<void> {
@@ -30,6 +34,7 @@ export async function applyElectronProxyConfig(
headers: link.customHeaders ?? [],
basicAuth: link.basicAuth ?? null,
allowInvalidCertificate: !!link.allowInvalidCertificate,
clientCert: link.clientCert ?? null,
}
: null,
);
+34 -6
View File
@@ -9,7 +9,7 @@
import { Agent, ProxyAgent, fetch as undiciFetch } from "undici";
import type { Dispatcher } from "undici";
import type { BasicAuth, ProxyHeader } from "./link-store.js";
import type { BasicAuth, ClientCert, ProxyHeader } from "./link-store.js";
export interface RemoteTarget {
serverUrl: string;
@@ -17,6 +17,7 @@ export interface RemoteTarget {
customHeaders?: ProxyHeader[];
basicAuth?: BasicAuth | null;
allowInvalidCertificate?: boolean;
clientCert?: ClientCert | null;
}
export type RemoteErrorKind =
@@ -24,6 +25,8 @@ export type RemoteErrorKind =
| "offline"
/** TLS failed and the certificate is not allowed. */
| "tls"
/** The server wants a client certificate and none was accepted. */
| "client_cert"
/** A proxy answered instead of Termix (its login page, a 404 page). */
| "proxy"
/** The proxy wants basic auth. */
@@ -53,6 +56,13 @@ const TLS_CODES = new Set([
"CERT_UNTRUSTED",
]);
const CLIENT_CERT_CODES = new Set([
"ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED",
"ERR_SSL_SSLV3_ALERT_BAD_CERTIFICATE",
"ERR_SSL_TLSV1_ALERT_UNKNOWN_CA",
"ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE",
]);
function envProxy(url: string): string | null {
const proxy =
process.env.https_proxy ||
@@ -84,10 +94,16 @@ const dispatchers = new Map<string, Dispatcher>();
function dispatcherFor(target: RemoteTarget): Dispatcher {
const insecure = !!target.allowInvalidCertificate;
const proxy = envProxy(target.serverUrl);
const key = `${insecure}|${proxy ?? ""}`;
const cert = target.clientCert;
const key = `${insecure}|${proxy ?? ""}|${cert ? `${cert.passphrase}|${cert.pfx}` : ""}`;
let dispatcher = dispatchers.get(key);
if (!dispatcher) {
const tls = insecure ? { rejectUnauthorized: false } : {};
const tls = {
...(insecure ? { rejectUnauthorized: false } : {}),
...(cert
? { pfx: Buffer.from(cert.pfx, "base64"), passphrase: cert.passphrase }
: {}),
};
dispatcher = proxy
? new ProxyAgent({ uri: proxy, requestTls: tls })
: new Agent({ connect: tls });
@@ -141,10 +157,22 @@ export function requestHeaders(target: RemoteTarget): Record<string, string> {
return headers;
}
function describeNetworkError(error: unknown): RemoteError {
// A server that requires a client certificate often just drops the
// connection when it gets none, or one it does not accept.
const DROPPED_CODES = new Set(["UND_ERR_SOCKET", "ECONNRESET"]);
function describeNetworkError(
error: unknown,
target?: RemoteTarget,
): RemoteError {
const cause = (error as { cause?: { code?: string; message?: string } })
?.cause;
const code = cause?.code ?? (error as { code?: string })?.code ?? "";
const droppedOverTls =
DROPPED_CODES.has(code) && target?.serverUrl.startsWith("https:");
if (CLIENT_CERT_CODES.has(code) || droppedOverTls) {
return new RemoteError("client_cert", cause?.message || code);
}
if (TLS_CODES.has(code) || /certificate/i.test(cause?.message ?? "")) {
return new RemoteError("tls", cause?.message || code);
}
@@ -193,7 +221,7 @@ export async function remoteFetch(
signal: controller.signal,
});
} catch (error) {
throw describeNetworkError(error);
throw describeNetworkError(error, target);
} finally {
if (timeout) clearTimeout(timeout);
}
@@ -228,7 +256,7 @@ export async function remoteStreamFetch(
signal: request.signal,
} as never);
} catch (error) {
throw describeNetworkError(error);
throw describeNetworkError(error, target);
}
}
+56 -5
View File
@@ -5,6 +5,7 @@
* system key. Without a user, a call means the active account's link.
*/
import { createSecureContext } from "node:tls";
import { eq } from "drizzle-orm";
import { getActiveUserId } from "../accounts.js";
import { syncLink } from "../../database/db/schema.js";
@@ -36,6 +37,14 @@ export interface BasicAuth {
password: string;
}
/** A PFX/P12 client certificate for servers that require mutual TLS. */
export interface ClientCert {
/** Base64 of the PFX/P12 file. */
pfx: string;
passphrase: string;
name: string | null;
}
export interface SyncLink {
id: number;
userId: string;
@@ -46,6 +55,7 @@ export interface SyncLink {
customHeaders: ProxyHeader[];
basicAuth: BasicAuth | null;
allowInvalidCertificate: boolean;
clientCert: ClientCert | null;
remoteUserId: string | null;
remoteUsername: string | null;
account: LinkAccount | null;
@@ -98,6 +108,7 @@ async function fromRow(row: Row): Promise<SyncLink> {
const scope = parseJson<{
disabled?: string[];
options?: Record<string, unknown>;
clientCert?: string;
}>(row.scope, {});
return {
id: row.id,
@@ -109,6 +120,10 @@ async function fromRow(row: Row): Promise<SyncLink> {
customHeaders: await decryptJson<ProxyHeader[]>(row.customHeaders, []),
basicAuth: await decryptJson<BasicAuth | null>(row.basicAuth, null),
allowInvalidCertificate: !!row.allowInvalidCertificate,
clientCert: await decryptJson<ClientCert | null>(
scope.clientCert ?? null,
null,
),
remoteUserId: row.remoteUserId,
remoteUsername: row.remoteUsername,
account: parseJson<LinkAccount | null>(row.account, null),
@@ -150,11 +165,39 @@ export interface NewLink {
customHeaders?: ProxyHeader[];
basicAuth?: BasicAuth | null;
allowInvalidCertificate?: boolean;
clientCert?: ClientCert | null;
remoteUserId?: string | null;
remoteUsername?: string | null;
}
/** Throws when the PFX is unreadable or the passphrase is wrong. */
export function assertClientCert(cert: ClientCert): void {
try {
createSecureContext({
pfx: Buffer.from(cert.pfx, "base64"),
passphrase: cert.passphrase,
});
} catch {
throw new Error("The client certificate or its passphrase is not valid");
}
}
async function sealScope(
disabled: string[],
options: Record<string, unknown>,
clientCert: ClientCert | null,
): Promise<string> {
return JSON.stringify({
disabled,
options,
...(clientCert
? { clientCert: await encryptSystemSecret(JSON.stringify(clientCert)) }
: {}),
});
}
export async function createLink(link: NewLink): Promise<SyncLink> {
if (link.clientCert) assertClientCert(link.clientCert);
await db().delete(syncLink).where(eq(syncLink.userId, link.userId));
await db()
.insert(syncLink)
@@ -171,6 +214,7 @@ export async function createLink(link: NewLink): Promise<SyncLink> {
? await encryptSystemSecret(JSON.stringify(link.basicAuth))
: null,
allowInvalidCertificate: !!link.allowInvalidCertificate,
scope: link.clientCert ? await sealScope([], {}, link.clientCert) : null,
remoteUserId: link.remoteUserId ?? null,
remoteUsername: link.remoteUsername ?? null,
cursor: 0,
@@ -189,6 +233,7 @@ export interface LinkPatch {
customHeaders?: ProxyHeader[];
basicAuth?: BasicAuth | null;
allowInvalidCertificate?: boolean;
clientCert?: ClientCert | null;
remoteUserId?: string | null;
remoteUsername?: string | null;
account?: LinkAccount | null;
@@ -207,6 +252,7 @@ export async function updateLink(
): Promise<SyncLink | null> {
const current = await getLink(userId);
if (!current) return null;
if (patch.clientCert) assertClientCert(patch.clientCert);
const set: Partial<Row> = {};
if (patch.serverName !== undefined) set.serverName = patch.serverName;
if (patch.serverVersion !== undefined)
@@ -236,11 +282,16 @@ export async function updateLink(
if (patch.account !== undefined) {
set.account = patch.account ? JSON.stringify(patch.account) : null;
}
if (patch.disabledTypes !== undefined || patch.options !== undefined) {
set.scope = JSON.stringify({
disabled: patch.disabledTypes ?? current.disabledTypes,
options: patch.options ?? current.options,
});
if (
patch.disabledTypes !== undefined ||
patch.options !== undefined ||
patch.clientCert !== undefined
) {
set.scope = await sealScope(
patch.disabledTypes ?? current.disabledTypes,
patch.options ?? current.options,
patch.clientCert === undefined ? current.clientCert : patch.clientCert,
);
}
if (patch.knownTypes !== undefined) {
set.knownTypes = JSON.stringify(patch.knownTypes);
@@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const linkedServer = require("../../../../electron/linked-server.cjs") as {
setLinkedServer: (config: unknown) => void;
clientCertOptionsFor: (url: string) => Record<string, unknown>;
};
const source = fs.readFileSync(
@@ -59,3 +60,26 @@ describe("linked server certificate exception", () => {
});
});
});
describe("linked server client certificate", () => {
const clientCert = {
pfx: Buffer.from("pfx-bytes").toString("base64"),
passphrase: "pw",
};
it("hands the PFX to TLS made for the linked origin only", () => {
linkedServer.setLinkedServer({ origin: ORIGIN, clientCert });
expect(linkedServer.clientCertOptionsFor(TERMINAL)).toEqual({
pfx: Buffer.from("pfx-bytes"),
passphrase: "pw",
});
expect(
linkedServer.clientCertOptionsFor("https://elsewhere.example/"),
).toEqual({});
});
it("offers nothing when the link has no certificate", () => {
linkedServer.setLinkedServer({ origin: ORIGIN });
expect(linkedServer.clientCertOptionsFor(TERMINAL)).toEqual({});
});
});
@@ -0,0 +1,204 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import https from "node:https";
import os from "node:os";
import path from "node:path";
import type { AddressInfo } from "node:net";
import { sql } from "drizzle-orm";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
const state = vi.hoisted(() => ({ drizzle: null as unknown }));
vi.mock("../../../database/db/index.js", () => ({
getDb: () => state.drizzle,
getSqlite: () => null,
}));
vi.mock("../../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: {
triggerSave: vi.fn(),
forceSave: vi.fn(async () => {}),
},
}));
vi.mock("../../../utils/system-crypto.js", () => ({
SystemCrypto: {
getInstance: () => ({ getEncryptionKey: async () => Buffer.alloc(32, 5) }),
},
}));
const { TestSqliteDatabase } =
await import("../../database/repositories/test-support.js");
const { createLink, getLink, updateLink } =
await import("../../../sync/client/link-store.js");
const { RemoteError, remoteJson } =
await import("../../../sync/client/http.js");
function hasOpenssl(): boolean {
try {
execFileSync("openssl", ["version"], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
const openssl = hasOpenssl();
const PASSPHRASE = "pfx-pass";
let dir = "";
let pfx = "";
let serverKey = "";
let serverCert = "";
let clientCert = "";
function run(args: string[]) {
execFileSync("openssl", args, { cwd: dir, stdio: "ignore" });
}
function selfSigned(name: string) {
run([
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-days",
"2",
"-keyout",
`${name}.key`,
"-out",
`${name}.crt`,
"-subj",
`/CN=${name}`,
]);
}
beforeAll(() => {
if (!openssl) return;
dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-mtls-"));
selfSigned("server");
selfSigned("client");
run([
"pkcs12",
"-export",
"-inkey",
"client.key",
"-in",
"client.crt",
"-out",
"client.p12",
"-passout",
`pass:${PASSPHRASE}`,
]);
pfx = fs.readFileSync(path.join(dir, "client.p12")).toString("base64");
serverKey = fs.readFileSync(path.join(dir, "server.key"), "utf8");
serverCert = fs.readFileSync(path.join(dir, "server.crt"), "utf8");
clientCert = fs.readFileSync(path.join(dir, "client.crt"), "utf8");
});
afterAll(() => {
if (dir) fs.rmSync(dir, { recursive: true, force: true });
});
describe.skipIf(!openssl)("client certificates", () => {
let server: https.Server;
let base: string;
beforeAll(async () => {
server = https.createServer(
{
key: serverKey,
cert: serverCert,
ca: [clientCert],
requestCert: true,
rejectUnauthorized: true,
},
(_req, res) => {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ ok: true }));
},
);
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
base = `https://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
it("presents the PFX to a server that requires one", async () => {
const result = await remoteJson<{ ok: boolean }>(
{
serverUrl: base,
allowInvalidCertificate: true,
clientCert: { pfx, passphrase: PASSPHRASE, name: "client.p12" },
},
"/x",
);
expect(result.ok).toBe(true);
});
it("reports a missing client certificate as its own error", async () => {
const error = await remoteJson(
{ serverUrl: base, allowInvalidCertificate: true },
"/x",
).catch((e) => e);
expect(error).toBeInstanceOf(RemoteError);
expect((error as InstanceType<typeof RemoteError>).kind).toBe(
"client_cert",
);
});
describe("on the link", () => {
let db: InstanceType<typeof TestSqliteDatabase>;
beforeEach(async () => {
db = new TestSqliteDatabase("sqlite");
state.drizzle = (await db.connect()).drizzle;
await db.run(
sql`INSERT INTO users (id, username, password_hash) VALUES ('local', 'local', '')`,
);
});
afterEach(async () => {
await db.close();
});
it("stores the PFX encrypted and keeps it through other updates", async () => {
await createLink({
userId: "local",
serverUrl: "https://termix.example",
sessionToken: "t",
clientCert: { pfx, passphrase: PASSPHRASE, name: "client.p12" },
});
const raw = await db.query(sql`SELECT scope FROM sync_link`);
expect(JSON.stringify(raw)).not.toContain(pfx.slice(0, 40));
await updateLink({ disabledTypes: ["hosts"] }, "local");
const link = await getLink("local");
expect(link?.clientCert?.pfx).toBe(pfx);
expect(link?.disabledTypes).toEqual(["hosts"]);
await updateLink({ clientCert: null }, "local");
expect((await getLink("local"))?.clientCert).toBeNull();
});
it("rejects a PFX with the wrong passphrase", async () => {
await expect(
createLink({
userId: "local",
serverUrl: "https://termix.example",
sessionToken: "t",
clientCert: { pfx, passphrase: "nope", name: null },
}),
).rejects.toThrow(/not valid/);
});
});
});