feat: store rdp, vnc and telnet logins in a generic per-protocol table

This commit is contained in:
LukeGus committed 2026-09-29 12:33:09 -05:00
1 parent e0c30a4e6f
commit bba06f7ff6
107 files changed
+18750 -1224

No files matched your search

@@ -0,0 +1,178 @@
import crypto from "node:crypto";
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import {
HostProtocolAuthRepository,
decryptProtocolLogin,
protocolAuthRecordId,
type HostProtocolLogin,
} from "../../../database/repositories/host-protocol-auth-repository.js";
import { FieldCrypto } from "../../../utils/field-crypto.js";
const ownerKey = crypto.randomBytes(32);
const otherKey = crypto.randomBytes(32);
function login(overrides: Partial<HostProtocolLogin> = {}): HostProtocolLogin {
return {
protocol: "spice",
authType: "direct",
credentialId: null,
username: "viewer",
password: "spice-pass",
fields: { display: "2" },
secretFields: { ticket: "t-secret" },
...overrides,
};
}
describe("HostProtocolAuthRepository", () => {
let adapter: TestSqliteDatabase | null = null;
let writes = 0;
afterEach(async () => {
await adapter?.close();
adapter = null;
});
async function createRepository(): Promise<HostProtocolAuthRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash) VALUES
('owner-1', 'owner-1', 'hash'),
('owner-2', 'owner-2', 'hash');
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
(5, 'owner-1', 'cred-5', 'root', 'password');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES
(42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 'password'),
(43, 'owner-1', 'stage', '10.0.0.43', 22, 'root', 'password'),
(44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 'password');
`);
writes = 0;
return new HostProtocolAuthRepository(context, () => {
writes++;
});
}
it("round-trips a login with its secrets encrypted at rest", async () => {
const repository = await createRepository();
await repository.upsert("owner-1", 42, login(), ownerKey);
const row = await repository.findRow(42, "spice");
expect(row).toMatchObject({
hostId: 42,
userId: "owner-1",
protocol: "spice",
authType: "direct",
username: "viewer",
fields: JSON.stringify({ display: "2" }),
});
expect(FieldCrypto.isEncrypted(row!.password!)).toBe(true);
expect(row!.password).not.toContain("spice-pass");
expect(row!.secretFields).not.toContain("t-secret");
expect(await repository.find(42, "spice", ownerKey)).toEqual(login());
expect(writes).toBe(1);
});
it("updates in place, one row per host and protocol", async () => {
const repository = await createRepository();
await repository.upsert("owner-1", 42, login(), ownerKey);
await repository.upsert(
"owner-1",
42,
login({ username: "other", password: null, secretFields: {} }),
ownerKey,
);
expect(await repository.listRowsForHost(42)).toHaveLength(1);
expect(await repository.find(42, "spice", ownerKey)).toMatchObject({
username: "other",
password: null,
secretFields: {},
});
});
it("opens nothing with another user's key", async () => {
const repository = await createRepository();
await repository.upsert("owner-1", 42, login(), ownerKey);
expect(await repository.find(42, "spice", otherKey)).toMatchObject({
username: "viewer",
password: null,
secretFields: {},
});
});
it("treats a tampered secret as missing", async () => {
const repository = await createRepository();
await repository.upsert("owner-1", 42, login(), ownerKey);
const row = (await repository.findRow(42, "spice"))!;
const envelope = JSON.parse(row.password!);
envelope.data = envelope.data.replace(/^./, (c: string) =>
c === "0" ? "1" : "0",
);
expect(
decryptProtocolLogin(
{ ...row, password: JSON.stringify(envelope) },
ownerKey,
).password,
).toBeNull();
});
it("binds each secret to its host and protocol", async () => {
const repository = await createRepository();
await repository.upsert("owner-1", 42, login(), ownerKey);
const row = (await repository.findRow(42, "spice"))!;
expect(JSON.parse(row.password!).recordId).toBe(
protocolAuthRecordId(42, "spice"),
);
expect(() =>
FieldCrypto.decryptField(row.password!, ownerKey, "", "secretFields"),
).toThrow();
});
it("finds the owner's hosts whose login uses a credential", async () => {
const repository = await createRepository();
await repository.upsert(
"owner-1",
42,
login({ authType: "credential", credentialId: 5, password: null }),
ownerKey,
);
await repository.upsert(
"owner-1",
43,
login({ protocol: "x2go" }),
ownerKey,
);
expect(await repository.listHostIdsForCredential("owner-1", 5)).toEqual([
42,
]);
expect(await repository.listHostIdsForCredential("owner-2", 5)).toEqual([]);
expect(
(await repository.listRowsForUser("owner-1"))
.map((row) => row.hostId)
.sort(),
).toEqual([42, 43]);
});
it("deletes one protocol's login", async () => {
const repository = await createRepository();
await repository.upsert("owner-1", 42, login(), ownerKey);
await repository.upsert(
"owner-1",
42,
login({ protocol: "x2go" }),
ownerKey,
);
expect(await repository.delete(42, "spice")).toBe(true);
expect(await repository.delete(42, "spice")).toBe(false);
expect(
(await repository.listRowsForHost(42)).map((row) => row.protocol),
).toEqual(["x2go"]);
});
});
@@ -192,9 +192,6 @@ describe("HostResolutionRepository", () => {
await expect(repository.findHostUpdateState(1)).resolves.toEqual({
userId: "user-1",
credentialId: 7,
rdpCredentialId: null,
vncCredentialId: null,
telnetCredentialId: null,
authType: "password",
parentHostId: null,
folder: null,
@@ -36,8 +36,8 @@ describe("RbacAccessRepository", () => {
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES
(44, 'admin', 'host-44', '10.0.0.45', 22, 'root', 'password');
INSERT INTO ssh_data (
id, user_id, name, ip, port, username, credential_id, rdp_credential_id, vnc_credential_id, telnet_credential_id, folder, tags, auth_type)
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 125, 126, 'servers', 'linux', 'password');
id, user_id, name, ip, port, username, credential_id, folder, tags, auth_type)
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 'servers', 'linux', 'password');
INSERT INTO host_access (
id, host_id, user_id, role_id, granted_by, permission_level, expires_at, created_at
)
@@ -138,9 +138,6 @@ describe("RbacAccessRepository", () => {
{
hostAccessId: 2,
credentialId: 123,
rdpCredentialId: 124,
vncCredentialId: 125,
telnetCredentialId: 126,
hostId: 42,
hostOwnerId: "owner-1",
},
@@ -31,10 +31,13 @@ describe("SharedHostSecretsRepository", () => {
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
(123, 'user-1', 'cred-123', 'root', 'password'),
(124, 'user-1', 'cred-124', 'root', 'password');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, rdp_credential_id, auth_type)
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 'password'),
(43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, NULL, 'password'),
(44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, NULL, 'password');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, auth_type)
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 'password'),
(43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, 'password'),
(44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, 'password');
INSERT INTO host_protocol_auth (host_id, user_id, protocol, auth_type, credential_id)
VALUES (42, 'owner-1', 'spice', 'credential', 124),
(43, 'owner-1', 'spice', 'direct', NULL);
INSERT INTO host_access (id, host_id, user_id, role_id, granted_by)
VALUES
(1, 42, 'user-1', NULL, 'owner-1'),
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import { afterEach, describe, it, expect } from "vitest";
import { setHostProtocolSource } from "../../../hosts/protocol-auth/registry.js";
import {
applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate,
@@ -47,13 +48,13 @@ describe("containsOwnerPrivateAuthUpdate", () => {
);
});
it("keeps protocol field definitions isolated", () => {
expect(containsOwnerPrivateAuthUpdate({ rdpCredentialId: 7 }, "rdp")).toBe(
true,
);
expect(containsOwnerPrivateAuthUpdate({ rdpCredentialId: 7 }, "ssh")).toBe(
false,
);
it("leaves plugin protocol logins to their own guard", () => {
expect(
containsOwnerPrivateAuthUpdate(
{ protocolAuth: { spice: { credentialId: 7 } } },
"ssh",
),
).toBe(false);
});
it("allows shared editors to update non-authentication host settings", () => {
@@ -171,11 +172,41 @@ describe("normalizeImportedHost", () => {
expect(host.enableSsh).toBe(true);
});
it("infers rdp from enableRdp and uses default rdp port", () => {
const host = normalizeImportedHost({ enableRdp: true, ip: "10.0.0.2" });
expect(host.connectionType).toBe("rdp");
expect(host.port).toBe(3389);
expect(host.enableSsh).toBe(false);
describe("with a plugin protocol declared", () => {
afterEach(() => setHostProtocolSource(() => []));
function declareSpice() {
setHostProtocolSource(() => [
{
id: "spice",
defaultPort: 5930,
pluginId: "spice-plugin",
pluginName: "Spice",
},
]);
}
it("infers the protocol from its enable flag and uses its default port", () => {
declareSpice();
const host = normalizeImportedHost({ enableSpice: true, ip: "10.0.0.2" });
expect(host.connectionType).toBe("spice");
expect(host.port).toBe(5930);
expect(host.enableSsh).toBe(false);
});
it("reads the protocol's own port field", () => {
declareSpice();
const host = normalizeImportedHost({
connectionType: "spice",
spicePort: 5999,
});
expect(host.port).toBe(5999);
});
it("ignores the flag of a protocol nobody declares", () => {
const host = normalizeImportedHost({ enableSpice: true });
expect(host.connectionType).toBe("ssh");
});
});
it("honors an explicit port over protocol defaults", () => {
@@ -267,26 +298,13 @@ describe("stripSensitiveFields", () => {
).toBeUndefined();
});
it("strips rdp/vnc/telnet passwords and adds their presence flags", () => {
const result = stripSensitiveFields({
name: "rdp-box",
rdpPassword: "rdp-secret",
vncPassword: "vnc-secret",
telnetPassword: "telnet-secret",
});
expect(result.rdpPassword).toBeUndefined();
expect(result.vncPassword).toBeUndefined();
expect(result.telnetPassword).toBeUndefined();
expect(result.hasRdpPassword).toBe(true);
expect(result.hasVncPassword).toBe(true);
expect(result.hasTelnetPassword).toBe(true);
});
it("marks rdp/vnc/telnet presence flags false when absent", () => {
const result = stripSensitiveFields({ name: "rdp-box" });
expect(result.hasRdpPassword).toBe(false);
expect(result.hasVncPassword).toBe(false);
expect(result.hasTelnetPassword).toBe(false);
it("leaves protocol login summaries alone for the owner", () => {
const protocolAuth = {
spice: { authType: "direct", hasPassword: true, secretFieldKeys: [] },
};
expect(stripSensitiveFields({ protocolAuth }).protocolAuth).toEqual(
protocolAuth,
);
});
});
@@ -352,8 +370,17 @@ describe("sanitizeHostForRecipient", () => {
password: "hunter2",
key: "PRIVATE",
sudoPassword: "sudo",
rdpPassword: "rdp",
socks5Password: "socks",
protocolAuth: {
spice: {
authType: "direct",
credentialId: null,
username: "viewer",
fields: { display: "0" },
hasPassword: true,
secretFieldKeys: ["ticket"],
},
},
enableSsh: true,
enableRdp: true,
sshPort: 22,
@@ -371,8 +398,15 @@ describe("sanitizeHostForRecipient", () => {
expect(result.password).toBeUndefined();
expect(result.key).toBeUndefined();
expect(result.sudoPassword).toBeUndefined();
expect(result.rdpPassword).toBeUndefined();
expect(result.socks5Password).toBeUndefined();
expect(result.protocolAuth).toEqual({
spice: {
authType: "direct",
credentialId: null,
username: "viewer",
fields: { display: "0" },
},
});
expect(result.credentialId).toBeUndefined();
expect(result.overrideCredentialUsername).toBeUndefined();
expect(result.terminalConfig).toEqual({ theme: "termix" });
@@ -404,7 +438,6 @@ describe("sanitizeHostForRecipient", () => {
{
...sharedHost,
permissionLevel: "connect",
rdpAuthType: "none",
authOverrides: {
ssh: {
credentialId: 9,
@@ -417,7 +450,7 @@ describe("sanitizeHostForRecipient", () => {
);
expect(result.name).toBe("prod");
expect(result.ip).toBe("10.0.0.42");
expect(result.rdpAuthType).toBe("none");
expect(result.protocolAuth).toEqual({ spice: { authType: "direct" } });
expect(result.permissionLevel).toBe("connect");
expect(result.shareSshAuth).toBe(true);
expect(result.authOverrides).toEqual({
@@ -263,12 +263,21 @@ describe("shared host authentication override routes", () => {
expect(unauthenticatedResponse.status).toBe(401);
});
it("serves every real protocol and rejects invalid protocol names", async () => {
const rdpResponse = await invoke("get", {}, "rdp");
expect(rdpResponse.status).toBe(200);
expect(rdpResponse.body.protocol).toBe("rdp");
it("serves any protocol a plugin declares and rejects the rest", async () => {
const { setHostProtocolSource } =
await import("../../../hosts/protocol-auth/registry.js");
setHostProtocolSource(() => [
{ id: "spice", pluginId: "spice-plugin", pluginName: "Spice" },
]);
const declared = await invoke("get", {}, "spice");
setHostProtocolSource(() => []);
expect(declared.status).toBe(200);
expect(declared.body.protocol).toBe("spice");
expect(state.writes).toEqual([]);
const undeclared = await invoke("get", {}, "spice");
expect(undeclared.status).toBe(400);
const invalidResponse = await invoke("get", {}, "smtp");
expect(invalidResponse).toEqual({
status: 400,