mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05: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
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -157,6 +157,7 @@ export function createPluginDesktopLocal(deps: Deps): PluginDesktopLocal {
|
||||
customHeaders: link.customHeaders,
|
||||
basicAuth: link.basicAuth,
|
||||
allowInvalidCertificate: link.allowInvalidCertificate,
|
||||
clientCert: link.clientCert,
|
||||
},
|
||||
pathname,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user