mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -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