mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
feat: mTLS client certificates for desktop server links
This commit is contained in:
1 parent
802b3003d5
commit
895488d400
9 files changed
+373
-12
No files matched your search
@@ -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
|
- 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
|
- 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
|
- 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
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ function setLinkedServer(config) {
|
|||||||
basicAuth:
|
basicAuth:
|
||||||
config.basicAuth && config.basicAuth.username ? config.basicAuth : null,
|
config.basicAuth && config.basicAuth.username ? config.basicAuth : null,
|
||||||
allowInvalidCertificate: !!config.allowInvalidCertificate,
|
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;
|
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) {
|
function basicAuthValue(basicAuth) {
|
||||||
return `Basic ${Buffer.from(
|
return `Basic ${Buffer.from(
|
||||||
`${basicAuth.username}:${basicAuth.password || ""}`,
|
`${basicAuth.username}:${basicAuth.password || ""}`,
|
||||||
@@ -99,6 +111,7 @@ module.exports = {
|
|||||||
setLinkedServer,
|
setLinkedServer,
|
||||||
getLinkedServer,
|
getLinkedServer,
|
||||||
isLinkedOrigin,
|
isLinkedOrigin,
|
||||||
|
clientCertOptionsFor,
|
||||||
applyLinkHeaders,
|
applyLinkHeaders,
|
||||||
linkedRequestHeaders,
|
linkedRequestHeaders,
|
||||||
answerLogin,
|
answerLogin,
|
||||||
|
|||||||
@@ -459,6 +459,7 @@ function isInvalidCertificateAllowedForUrl(url) {
|
|||||||
function getTlsVerificationOptions(url) {
|
function getTlsVerificationOptions(url) {
|
||||||
return {
|
return {
|
||||||
rejectUnauthorized: !isInvalidCertificateAllowedForUrl(url),
|
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(
|
app.on(
|
||||||
"certificate-error",
|
"certificate-error",
|
||||||
(event, _webContents, url, error, certificate, callback) => {
|
(event, _webContents, url, error, certificate, callback) => {
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export function createPluginDesktopLocal(deps: Deps): PluginDesktopLocal {
|
|||||||
customHeaders: link.customHeaders,
|
customHeaders: link.customHeaders,
|
||||||
basicAuth: link.basicAuth,
|
basicAuth: link.basicAuth,
|
||||||
allowInvalidCertificate: link.allowInvalidCertificate,
|
allowInvalidCertificate: link.allowInvalidCertificate,
|
||||||
|
clientCert: link.clientCert,
|
||||||
},
|
},
|
||||||
pathname,
|
pathname,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ export async function applyElectronProxyConfig(
|
|||||||
link:
|
link:
|
||||||
| (Pick<
|
| (Pick<
|
||||||
SyncLink,
|
SyncLink,
|
||||||
"serverUrl" | "customHeaders" | "basicAuth" | "allowInvalidCertificate"
|
| "serverUrl"
|
||||||
|
| "customHeaders"
|
||||||
|
| "basicAuth"
|
||||||
|
| "allowInvalidCertificate"
|
||||||
|
| "clientCert"
|
||||||
> & { sessionToken?: string | null; status?: string })
|
> & { sessionToken?: string | null; status?: string })
|
||||||
| null,
|
| null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -30,6 +34,7 @@ export async function applyElectronProxyConfig(
|
|||||||
headers: link.customHeaders ?? [],
|
headers: link.customHeaders ?? [],
|
||||||
basicAuth: link.basicAuth ?? null,
|
basicAuth: link.basicAuth ?? null,
|
||||||
allowInvalidCertificate: !!link.allowInvalidCertificate,
|
allowInvalidCertificate: !!link.allowInvalidCertificate,
|
||||||
|
clientCert: link.clientCert ?? null,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { Agent, ProxyAgent, fetch as undiciFetch } from "undici";
|
import { Agent, ProxyAgent, fetch as undiciFetch } from "undici";
|
||||||
import type { Dispatcher } 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 {
|
export interface RemoteTarget {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
@@ -17,6 +17,7 @@ export interface RemoteTarget {
|
|||||||
customHeaders?: ProxyHeader[];
|
customHeaders?: ProxyHeader[];
|
||||||
basicAuth?: BasicAuth | null;
|
basicAuth?: BasicAuth | null;
|
||||||
allowInvalidCertificate?: boolean;
|
allowInvalidCertificate?: boolean;
|
||||||
|
clientCert?: ClientCert | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RemoteErrorKind =
|
export type RemoteErrorKind =
|
||||||
@@ -24,6 +25,8 @@ export type RemoteErrorKind =
|
|||||||
| "offline"
|
| "offline"
|
||||||
/** TLS failed and the certificate is not allowed. */
|
/** TLS failed and the certificate is not allowed. */
|
||||||
| "tls"
|
| "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). */
|
/** A proxy answered instead of Termix (its login page, a 404 page). */
|
||||||
| "proxy"
|
| "proxy"
|
||||||
/** The proxy wants basic auth. */
|
/** The proxy wants basic auth. */
|
||||||
@@ -53,6 +56,13 @@ const TLS_CODES = new Set([
|
|||||||
"CERT_UNTRUSTED",
|
"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 {
|
function envProxy(url: string): string | null {
|
||||||
const proxy =
|
const proxy =
|
||||||
process.env.https_proxy ||
|
process.env.https_proxy ||
|
||||||
@@ -84,10 +94,16 @@ const dispatchers = new Map<string, Dispatcher>();
|
|||||||
function dispatcherFor(target: RemoteTarget): Dispatcher {
|
function dispatcherFor(target: RemoteTarget): Dispatcher {
|
||||||
const insecure = !!target.allowInvalidCertificate;
|
const insecure = !!target.allowInvalidCertificate;
|
||||||
const proxy = envProxy(target.serverUrl);
|
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);
|
let dispatcher = dispatchers.get(key);
|
||||||
if (!dispatcher) {
|
if (!dispatcher) {
|
||||||
const tls = insecure ? { rejectUnauthorized: false } : {};
|
const tls = {
|
||||||
|
...(insecure ? { rejectUnauthorized: false } : {}),
|
||||||
|
...(cert
|
||||||
|
? { pfx: Buffer.from(cert.pfx, "base64"), passphrase: cert.passphrase }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
dispatcher = proxy
|
dispatcher = proxy
|
||||||
? new ProxyAgent({ uri: proxy, requestTls: tls })
|
? new ProxyAgent({ uri: proxy, requestTls: tls })
|
||||||
: new Agent({ connect: tls });
|
: new Agent({ connect: tls });
|
||||||
@@ -141,10 +157,22 @@ export function requestHeaders(target: RemoteTarget): Record<string, string> {
|
|||||||
return headers;
|
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 } })
|
const cause = (error as { cause?: { code?: string; message?: string } })
|
||||||
?.cause;
|
?.cause;
|
||||||
const code = cause?.code ?? (error as { code?: string })?.code ?? "";
|
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 ?? "")) {
|
if (TLS_CODES.has(code) || /certificate/i.test(cause?.message ?? "")) {
|
||||||
return new RemoteError("tls", cause?.message || code);
|
return new RemoteError("tls", cause?.message || code);
|
||||||
}
|
}
|
||||||
@@ -193,7 +221,7 @@ export async function remoteFetch(
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw describeNetworkError(error);
|
throw describeNetworkError(error, target);
|
||||||
} finally {
|
} finally {
|
||||||
if (timeout) clearTimeout(timeout);
|
if (timeout) clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
@@ -228,7 +256,7 @@ export async function remoteStreamFetch(
|
|||||||
signal: request.signal,
|
signal: request.signal,
|
||||||
} as never);
|
} as never);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw describeNetworkError(error);
|
throw describeNetworkError(error, target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* system key. Without a user, a call means the active account's link.
|
* system key. Without a user, a call means the active account's link.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createSecureContext } from "node:tls";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { getActiveUserId } from "../accounts.js";
|
import { getActiveUserId } from "../accounts.js";
|
||||||
import { syncLink } from "../../database/db/schema.js";
|
import { syncLink } from "../../database/db/schema.js";
|
||||||
@@ -36,6 +37,14 @@ export interface BasicAuth {
|
|||||||
password: string;
|
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 {
|
export interface SyncLink {
|
||||||
id: number;
|
id: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -46,6 +55,7 @@ export interface SyncLink {
|
|||||||
customHeaders: ProxyHeader[];
|
customHeaders: ProxyHeader[];
|
||||||
basicAuth: BasicAuth | null;
|
basicAuth: BasicAuth | null;
|
||||||
allowInvalidCertificate: boolean;
|
allowInvalidCertificate: boolean;
|
||||||
|
clientCert: ClientCert | null;
|
||||||
remoteUserId: string | null;
|
remoteUserId: string | null;
|
||||||
remoteUsername: string | null;
|
remoteUsername: string | null;
|
||||||
account: LinkAccount | null;
|
account: LinkAccount | null;
|
||||||
@@ -98,6 +108,7 @@ async function fromRow(row: Row): Promise<SyncLink> {
|
|||||||
const scope = parseJson<{
|
const scope = parseJson<{
|
||||||
disabled?: string[];
|
disabled?: string[];
|
||||||
options?: Record<string, unknown>;
|
options?: Record<string, unknown>;
|
||||||
|
clientCert?: string;
|
||||||
}>(row.scope, {});
|
}>(row.scope, {});
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -109,6 +120,10 @@ async function fromRow(row: Row): Promise<SyncLink> {
|
|||||||
customHeaders: await decryptJson<ProxyHeader[]>(row.customHeaders, []),
|
customHeaders: await decryptJson<ProxyHeader[]>(row.customHeaders, []),
|
||||||
basicAuth: await decryptJson<BasicAuth | null>(row.basicAuth, null),
|
basicAuth: await decryptJson<BasicAuth | null>(row.basicAuth, null),
|
||||||
allowInvalidCertificate: !!row.allowInvalidCertificate,
|
allowInvalidCertificate: !!row.allowInvalidCertificate,
|
||||||
|
clientCert: await decryptJson<ClientCert | null>(
|
||||||
|
scope.clientCert ?? null,
|
||||||
|
null,
|
||||||
|
),
|
||||||
remoteUserId: row.remoteUserId,
|
remoteUserId: row.remoteUserId,
|
||||||
remoteUsername: row.remoteUsername,
|
remoteUsername: row.remoteUsername,
|
||||||
account: parseJson<LinkAccount | null>(row.account, null),
|
account: parseJson<LinkAccount | null>(row.account, null),
|
||||||
@@ -150,11 +165,39 @@ export interface NewLink {
|
|||||||
customHeaders?: ProxyHeader[];
|
customHeaders?: ProxyHeader[];
|
||||||
basicAuth?: BasicAuth | null;
|
basicAuth?: BasicAuth | null;
|
||||||
allowInvalidCertificate?: boolean;
|
allowInvalidCertificate?: boolean;
|
||||||
|
clientCert?: ClientCert | null;
|
||||||
remoteUserId?: string | null;
|
remoteUserId?: string | null;
|
||||||
remoteUsername?: 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> {
|
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().delete(syncLink).where(eq(syncLink.userId, link.userId));
|
||||||
await db()
|
await db()
|
||||||
.insert(syncLink)
|
.insert(syncLink)
|
||||||
@@ -171,6 +214,7 @@ export async function createLink(link: NewLink): Promise<SyncLink> {
|
|||||||
? await encryptSystemSecret(JSON.stringify(link.basicAuth))
|
? await encryptSystemSecret(JSON.stringify(link.basicAuth))
|
||||||
: null,
|
: null,
|
||||||
allowInvalidCertificate: !!link.allowInvalidCertificate,
|
allowInvalidCertificate: !!link.allowInvalidCertificate,
|
||||||
|
scope: link.clientCert ? await sealScope([], {}, link.clientCert) : null,
|
||||||
remoteUserId: link.remoteUserId ?? null,
|
remoteUserId: link.remoteUserId ?? null,
|
||||||
remoteUsername: link.remoteUsername ?? null,
|
remoteUsername: link.remoteUsername ?? null,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
@@ -189,6 +233,7 @@ export interface LinkPatch {
|
|||||||
customHeaders?: ProxyHeader[];
|
customHeaders?: ProxyHeader[];
|
||||||
basicAuth?: BasicAuth | null;
|
basicAuth?: BasicAuth | null;
|
||||||
allowInvalidCertificate?: boolean;
|
allowInvalidCertificate?: boolean;
|
||||||
|
clientCert?: ClientCert | null;
|
||||||
remoteUserId?: string | null;
|
remoteUserId?: string | null;
|
||||||
remoteUsername?: string | null;
|
remoteUsername?: string | null;
|
||||||
account?: LinkAccount | null;
|
account?: LinkAccount | null;
|
||||||
@@ -207,6 +252,7 @@ export async function updateLink(
|
|||||||
): Promise<SyncLink | null> {
|
): Promise<SyncLink | null> {
|
||||||
const current = await getLink(userId);
|
const current = await getLink(userId);
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
|
if (patch.clientCert) assertClientCert(patch.clientCert);
|
||||||
const set: Partial<Row> = {};
|
const set: Partial<Row> = {};
|
||||||
if (patch.serverName !== undefined) set.serverName = patch.serverName;
|
if (patch.serverName !== undefined) set.serverName = patch.serverName;
|
||||||
if (patch.serverVersion !== undefined)
|
if (patch.serverVersion !== undefined)
|
||||||
@@ -236,11 +282,16 @@ export async function updateLink(
|
|||||||
if (patch.account !== undefined) {
|
if (patch.account !== undefined) {
|
||||||
set.account = patch.account ? JSON.stringify(patch.account) : null;
|
set.account = patch.account ? JSON.stringify(patch.account) : null;
|
||||||
}
|
}
|
||||||
if (patch.disabledTypes !== undefined || patch.options !== undefined) {
|
if (
|
||||||
set.scope = JSON.stringify({
|
patch.disabledTypes !== undefined ||
|
||||||
disabled: patch.disabledTypes ?? current.disabledTypes,
|
patch.options !== undefined ||
|
||||||
options: patch.options ?? current.options,
|
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) {
|
if (patch.knownTypes !== undefined) {
|
||||||
set.knownTypes = JSON.stringify(patch.knownTypes);
|
set.knownTypes = JSON.stringify(patch.knownTypes);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const linkedServer = require("../../../../electron/linked-server.cjs") as {
|
const linkedServer = require("../../../../electron/linked-server.cjs") as {
|
||||||
setLinkedServer: (config: unknown) => void;
|
setLinkedServer: (config: unknown) => void;
|
||||||
|
clientCertOptionsFor: (url: string) => Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const source = fs.readFileSync(
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user