diff --git a/CHANGELOG.md b/CHANGELOG.md index 5348fdd5d..065327c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/electron/linked-server.cjs b/electron/linked-server.cjs index a31a03250..5dc91ffa6 100644 --- a/electron/linked-server.cjs +++ b/electron/linked-server.cjs @@ -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, diff --git a/electron/main.cjs b/electron/main.cjs index 892f324da..80fb7930a 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -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) => { diff --git a/src/backend/plugins/ctx-desktop-local.ts b/src/backend/plugins/ctx-desktop-local.ts index d14231d77..eed6c3066 100644 --- a/src/backend/plugins/ctx-desktop-local.ts +++ b/src/backend/plugins/ctx-desktop-local.ts @@ -157,6 +157,7 @@ export function createPluginDesktopLocal(deps: Deps): PluginDesktopLocal { customHeaders: link.customHeaders, basicAuth: link.basicAuth, allowInvalidCertificate: link.allowInvalidCertificate, + clientCert: link.clientCert, }, pathname, ); diff --git a/src/backend/sync/client/electron-proxy.ts b/src/backend/sync/client/electron-proxy.ts index b10b00d37..c7f31f34b 100644 --- a/src/backend/sync/client/electron-proxy.ts +++ b/src/backend/sync/client/electron-proxy.ts @@ -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 { @@ -30,6 +34,7 @@ export async function applyElectronProxyConfig( headers: link.customHeaders ?? [], basicAuth: link.basicAuth ?? null, allowInvalidCertificate: !!link.allowInvalidCertificate, + clientCert: link.clientCert ?? null, } : null, ); diff --git a/src/backend/sync/client/http.ts b/src/backend/sync/client/http.ts index 7b55541f2..640f7ff10 100644 --- a/src/backend/sync/client/http.ts +++ b/src/backend/sync/client/http.ts @@ -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(); 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 { 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); } } diff --git a/src/backend/sync/client/link-store.ts b/src/backend/sync/client/link-store.ts index a333087b0..7a63d2652 100644 --- a/src/backend/sync/client/link-store.ts +++ b/src/backend/sync/client/link-store.ts @@ -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 { const scope = parseJson<{ disabled?: string[]; options?: Record; + clientCert?: string; }>(row.scope, {}); return { id: row.id, @@ -109,6 +120,10 @@ async function fromRow(row: Row): Promise { customHeaders: await decryptJson(row.customHeaders, []), basicAuth: await decryptJson(row.basicAuth, null), allowInvalidCertificate: !!row.allowInvalidCertificate, + clientCert: await decryptJson( + scope.clientCert ?? null, + null, + ), remoteUserId: row.remoteUserId, remoteUsername: row.remoteUsername, account: parseJson(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, + clientCert: ClientCert | null, +): Promise { + return JSON.stringify({ + disabled, + options, + ...(clientCert + ? { clientCert: await encryptSystemSecret(JSON.stringify(clientCert)) } + : {}), + }); +} + export async function createLink(link: NewLink): Promise { + 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 { ? 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 { const current = await getLink(userId); if (!current) return null; + if (patch.clientCert) assertClientCert(patch.clientCert); const set: Partial = {}; 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); diff --git a/src/backend/tests/electron/linked-certificate.test.ts b/src/backend/tests/electron/linked-certificate.test.ts index 67f48fc20..8fa90aafb 100644 --- a/src/backend/tests/electron/linked-certificate.test.ts +++ b/src/backend/tests/electron/linked-certificate.test.ts @@ -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; }; 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({}); + }); +}); diff --git a/src/backend/tests/sync/client/client-cert.test.ts b/src/backend/tests/sync/client/client-cert.test.ts new file mode 100644 index 000000000..706872a86 --- /dev/null +++ b/src/backend/tests/sync/client/client-cert.test.ts @@ -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((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + base = `https://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(() => new Promise((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).kind).toBe( + "client_cert", + ); + }); + + describe("on the link", () => { + let db: InstanceType; + + 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/); + }); + }); +});