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,
Binary file not shown.
+10
View File
@@ -117,6 +117,8 @@ export const ENCRYPTED_COLUMNS: Array<[string, string, string, string]> = [
["ssh_data", "key_password", "keyPassword", "user_id"],
["ssh_data", "sudo_password", "sudoPassword", "user_id"],
["ssh_data", "rdp_password", "rdpPassword", "user_id"],
["ssh_data", "vnc_password", "vncPassword", "user_id"],
["ssh_data", "telnet_password", "telnetPassword", "user_id"],
["ssh_credentials", "password", "password", "user_id"],
["ssh_credentials", "private_key", "privateKey", "user_id"],
["ssh_credentials", "key", "key", "user_id"],
@@ -251,6 +253,14 @@ const HOST_ROWS: Row[] = [
telnet_port: 2323,
rdp_user: "d4-rdp-user",
rdp_password: "d4-rdp-password",
rdp_domain: "D4-DOMAIN",
rdp_auth_type: "direct",
vnc_user: "d4-vnc-user",
vnc_password: "d4-vnc-password",
vnc_auth_type: "direct",
// Telnet through the saved credential, which has no password of its own.
telnet_auth_type: "credential",
telnet_credential_id: 1,
rdp_security: "nla",
rdp_ignore_cert: 1,
guacamole_config: JSON.stringify({ colorDepth: 24 }),
+28
View File
@@ -415,6 +415,34 @@ ALTER TABLE `plugin_settings` MODIFY COLUMN `scope` enum('admin','user','host','
-- ==== core 0062_smooth_speed ====
ALTER TABLE `ssh_data` ADD `ssh_options` text;
-- ==== core 0063_needy_radioactive_man ====
-- The RDP, VNC and Telnet logins moved out of ssh_data into host_protocol_auth.
-- A boot migration copies them after this runs, so the old ssh_data columns
-- and their foreign keys stay in place, unused, until 3.0.0.
CREATE TABLE `host_protocol_auth` (
`id` int AUTO_INCREMENT NOT NULL,
`host_id` int NOT NULL,
`user_id` varchar(255) NOT NULL,
`protocol` varchar(255) NOT NULL,
`auth_type` text NOT NULL DEFAULT ('direct'),
`credential_id` int,
`username` text,
`password` text,
`fields` text,
`secret_fields` text,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `host_protocol_auth_id` PRIMARY KEY(`id`),
CONSTRAINT `idx_host_protocol_auth_host_protocol` UNIQUE(`host_id`,`protocol`)
);
--> statement-breakpoint
ALTER TABLE `shared_host_secrets` ADD `encrypted_fields` text;--> statement-breakpoint
ALTER TABLE `host_protocol_auth` ADD CONSTRAINT `host_protocol_auth_host_id_ssh_data_id_fk` FOREIGN KEY (`host_id`) REFERENCES `ssh_data`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `host_protocol_auth` ADD CONSTRAINT `host_protocol_auth_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `host_protocol_auth` ADD CONSTRAINT `host_protocol_auth_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_host_protocol_auth_user` ON `host_protocol_auth` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_host_protocol_auth_credential` ON `host_protocol_auth` (`credential_id`);
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
-- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing.
+27
View File
@@ -435,6 +435,33 @@ DROP TABLE "sync_tombstones" CASCADE;
-- ==== core 0062_curvy_killmonger ====
ALTER TABLE "ssh_data" ADD COLUMN "ssh_options" text;
-- ==== core 0063_giant_starfox ====
-- The RDP, VNC and Telnet logins moved out of ssh_data into host_protocol_auth.
-- A boot migration copies them after this runs, so the old ssh_data columns
-- and their foreign keys stay in place, unused, until 3.0.0.
CREATE TABLE "host_protocol_auth" (
"id" serial PRIMARY KEY NOT NULL,
"host_id" integer NOT NULL,
"user_id" varchar(255) NOT NULL,
"protocol" varchar(255) NOT NULL,
"auth_type" text DEFAULT 'direct' NOT NULL,
"credential_id" integer,
"username" text,
"password" text,
"fields" text,
"secret_fields" text,
"created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "shared_host_secrets" ADD COLUMN "encrypted_fields" text;--> statement-breakpoint
ALTER TABLE "host_protocol_auth" ADD CONSTRAINT "host_protocol_auth_host_id_ssh_data_id_fk" FOREIGN KEY ("host_id") REFERENCES "public"."ssh_data"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "host_protocol_auth" ADD CONSTRAINT "host_protocol_auth_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "host_protocol_auth" ADD CONSTRAINT "host_protocol_auth_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "idx_host_protocol_auth_host_protocol" ON "host_protocol_auth" USING btree ("host_id","protocol");--> statement-breakpoint
CREATE INDEX "idx_host_protocol_auth_user" ON "host_protocol_auth" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_host_protocol_auth_credential" ON "host_protocol_auth" USING btree ("credential_id");
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
-- workspaces 0001: adopt_user_workspaces
-- Generated by termix-plugin migrations. Review before committing.
@@ -0,0 +1,205 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../database/db/index.js", () => ({
getDb: () => null,
getSqlite: () => null,
}));
vi.mock("../../../hosts/usable-credential.js", () => ({
findUsableCredential: async (id: number, userId: string) =>
id === 5 && userId === "importer" ? { id, username: "u" } : null,
}));
import {
fromPortableLogin,
keepUsableProtocolCredentials,
mergeProtocolLogin,
readProtocolAuthPayload,
toPortableLogins,
} from "../../../hosts/protocol-auth/protocol-auth.js";
import {
setHostProtocolSource,
type DeclaredHostProtocol,
} from "../../../hosts/protocol-auth/registry.js";
import type { HostProtocolLogin } from "../../../database/repositories/host-protocol-auth-repository.js";
const SPICE: DeclaredHostProtocol = {
id: "spice",
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
pluginId: "spice-plugin",
pluginName: "Spice",
};
const REMOTE_X: DeclaredHostProtocol = {
id: "remote-x",
credentialFields: [{ key: "domain" }],
pluginId: "x-plugin",
pluginName: "X",
};
const stored: HostProtocolLogin = {
protocol: "spice",
authType: "direct",
credentialId: null,
username: "viewer",
password: "saved",
fields: { display: "2" },
secretFields: { ticket: "t-saved" },
};
beforeEach(() => setHostProtocolSource(() => [SPICE, REMOTE_X]));
afterEach(() => setHostProtocolSource(() => []));
describe("readProtocolAuthPayload", () => {
it("reads protocolAuth for declared protocols only", () => {
const patch = readProtocolAuthPayload({
protocolAuth: {
spice: { username: "a" },
"remote-x": null,
stranger: { username: "b" },
},
});
expect(patch && Object.fromEntries(patch)).toEqual({
spice: { username: "a" },
"remote-x": null,
});
});
it("reads the flat fields a 2.8 client sends, named after the protocol", () => {
const patch = readProtocolAuthPayload({
spiceUser: "viewer",
spicePassword: "pw",
spiceDisplay: "3",
spiceAuthType: "direct",
remoteXUser: "x-user",
remoteXDomain: "CORP",
remoteXCredentialId: 9,
});
expect(patch && Object.fromEntries(patch)).toEqual({
spice: {
authType: "direct",
username: "viewer",
password: "pw",
fields: { display: "3" },
},
"remote-x": {
credentialId: 9,
username: "x-user",
fields: { domain: "CORP" },
},
});
});
it("treats a 2.8 editor's null password as leaving it alone", () => {
const patch = readProtocolAuthPayload({
spiceAuthType: "direct",
spiceUser: "viewer",
spicePassword: null,
});
expect(patch?.get("spice")).toEqual({
authType: "direct",
username: "viewer",
});
});
it("reads a 2.8 editor's all-null protocol as removed", () => {
const patch = readProtocolAuthPayload({
spiceAuthType: null,
spiceUser: null,
spicePassword: null,
spiceCredentialId: null,
});
expect(patch?.get("spice")).toBeNull();
});
it("answers null when a write carries no logins", () => {
expect(readProtocolAuthPayload({ name: "host" })).toBeNull();
});
});
describe("mergeProtocolLogin", () => {
it("keeps what the input leaves out, including secrets", () => {
expect(mergeProtocolLogin("spice", SPICE, stored, {})).toEqual(stored);
});
it("keeps a saved password and secret for an empty value", () => {
const next = mergeProtocolLogin("spice", SPICE, stored, {
password: "",
fields: { ticket: "", display: "" },
});
expect(next.password).toBe("saved");
expect(next.secretFields).toEqual({ ticket: "t-saved" });
// A plain field can be cleared.
expect(next.fields).toEqual({});
});
it("drops the direct login and undeclared fields for a credential", () => {
const next = mergeProtocolLogin("spice", SPICE, stored, {
authType: "credential",
credentialId: "12",
fields: { unknown: "x" },
});
expect(next).toMatchObject({
authType: "credential",
credentialId: 12,
username: null,
password: null,
fields: { display: "2" },
});
});
it("starts a new login from the input", () => {
expect(
mergeProtocolLogin("spice", SPICE, null, {
username: "new",
password: "pw",
fields: { ticket: "t" },
}),
).toEqual({
protocol: "spice",
authType: "direct",
credentialId: null,
username: "new",
password: "pw",
fields: {},
secretFields: { ticket: "t" },
});
});
it("falls back to direct for an auth type it does not know", () => {
expect(
mergeProtocolLogin("spice", SPICE, null, { authType: "magic" }).authType,
).toBe("direct");
});
});
describe("portable logins", () => {
it("round-trips through an export, splitting fields by the declaration", () => {
const portable = toPortableLogins([stored]);
expect(portable.spice.fields).toEqual({ display: "2", ticket: "t-saved" });
expect(fromPortableLogin("spice", portable.spice, null)).toEqual(stored);
});
it("keeps every field of an undeclared protocol secret", () => {
expect(
fromPortableLogin("gone", { fields: { display: "1" } }, null)
?.secretFields,
).toEqual({ display: "1" });
});
it("drops a credential link a user cannot use", async () => {
const patch = await keepUsableProtocolCredentials(
new Map([
["spice", { authType: "credential", credentialId: 5 }],
["remote-x", { authType: "credential", credentialId: 6 }],
]),
"importer",
);
expect(patch.get("spice")).toEqual({
authType: "credential",
credentialId: 5,
});
expect(patch.get("remote-x")).toEqual({
authType: "direct",
credentialId: null,
});
});
});
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it } from "vitest";
import {
findHostProtocol,
isAuthOverrideProtocol,
listHostProtocols,
plainFieldKeys,
secretFieldKeys,
setHostProtocolSource,
type DeclaredHostProtocol,
} from "../../../hosts/protocol-auth/registry.js";
const SPICE: DeclaredHostProtocol = {
id: "spice",
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
pluginId: "spice-plugin",
pluginName: "Spice",
};
afterEach(() => setHostProtocolSource(() => []));
describe("the host protocol registry", () => {
it("knows nothing until a manifest declares it", () => {
expect(listHostProtocols()).toEqual([]);
expect(findHostProtocol("spice")).toBeUndefined();
});
it("keeps the first plugin to declare an id", () => {
setHostProtocolSource(() => [
SPICE,
{ ...SPICE, pluginId: "late", pluginName: "Late" },
]);
expect(listHostProtocols()).toHaveLength(1);
expect(findHostProtocol("spice")?.pluginId).toBe("spice-plugin");
});
it("allows overrides for ssh and declared protocols only", () => {
setHostProtocolSource(() => [SPICE]);
expect(isAuthOverrideProtocol("ssh")).toBe(true);
expect(isAuthOverrideProtocol("spice")).toBe(true);
expect(isAuthOverrideProtocol("rdp")).toBe(false);
expect(isAuthOverrideProtocol(7)).toBe(false);
});
it("splits declared fields into plain and secret", () => {
expect(plainFieldKeys(SPICE)).toEqual(["display"]);
expect(secretFieldKeys(SPICE)).toEqual(["ticket"]);
});
});
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { sanitizeProtocolAuthForRecipient } from "../../../hosts/protocol-auth/summary.js";
const summaries = {
spice: {
authType: "credential",
credentialId: 4,
username: null,
fields: { display: "2" },
hasPassword: true,
secretFieldKeys: ["ticket"],
},
};
describe("sanitizeProtocolAuthForRecipient", () => {
it("keeps only the auth type at connect level", () => {
expect(sanitizeProtocolAuthForRecipient(summaries, "connect")).toEqual({
spice: { authType: "credential" },
});
});
it("never says whether secrets are set, at any level", () => {
for (const level of ["view", "edit", "manage"]) {
expect(sanitizeProtocolAuthForRecipient(summaries, level)).toEqual({
spice: {
authType: "credential",
credentialId: 4,
username: null,
fields: { display: "2" },
},
});
}
});
it("answers an empty map for anything else", () => {
expect(sanitizeProtocolAuthForRecipient(null, "view")).toEqual({});
expect(sanitizeProtocolAuthForRecipient({ spice: 3 }, "view")).toEqual({});
});
});
+147 -34
View File
@@ -18,6 +18,8 @@ const state = vi.hoisted(() => ({
savedCredentials: [] as Array<Record<string, unknown>>,
userCredentials: new Map<string, Array<Record<string, unknown>>>(),
corePermissions: new Set<string>(["credentials.view", "credentials.create"]),
logins: new Map<string, Record<string, unknown>>(),
ownerDecrypts: 0,
}));
vi.mock("../../database/repositories/factory.js", () => ({
@@ -40,6 +42,30 @@ vi.mock("../../database/repositories/factory.js", () => ({
return row;
},
}),
createCurrentHostProtocolAuthRepository: () => ({
find: async (hostId: number, protocol: string) => {
state.ownerDecrypts++;
return state.logins.get(`${hostId}:${protocol}`) ?? null;
},
findRow: async (hostId: number, protocol: string) => {
const login = state.logins.get(`${hostId}:${protocol}`);
return login
? {
hostId,
protocol,
authType: login.authType,
credentialId: login.credentialId ?? null,
username: login.username ?? null,
password: "sealed",
fields: JSON.stringify(login.fields ?? {}),
secretFields: "sealed",
}
: null;
},
}),
createCurrentSharedHostAuthOverrideRepository: () => ({
listCredentialIds: async () => ({}),
}),
createCurrentHostResolutionRepository: () => ({
findHostOwnerId: async (hostId: number) =>
(state.hosts.get(hostId)?.userId as string) ?? null,
@@ -49,6 +75,17 @@ vi.mock("../../database/repositories/factory.js", () => ({
}),
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
validateUserAccess: () => Buffer.alloc(32),
getUserDataKey: () => Buffer.alloc(32),
},
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (id: number) => state.credentials.get(id) ?? null,
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
@@ -84,6 +121,13 @@ import {
} from "../../hosts/connect/secret-resolver-registry.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import ssh2 from "ssh2";
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
const SPICE = {
id: "spice",
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
hostLoginFallback: ["password" as const],
};
function contextFor(
capabilities: string[],
@@ -114,13 +158,13 @@ const baseHost = {
username: "sshuser",
password: "ssh-secret",
jumpHosts: JSON.stringify([{ hostId: 3 }]),
rdpUser: "admin",
rdpPassword: "rdp-secret",
rdpDomain: "CORP",
rdpAuthType: null,
rdpCredentialId: null,
};
/** The demo plugin declares SPICE; nothing in core knows it. */
function spiceContext(capabilities: string[]) {
return contextFor(capabilities, { protocols: [SPICE] });
}
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
@@ -134,17 +178,32 @@ beforeEach(() => {
state.userCredentials.clear();
state.corePermissions = new Set(["credentials.view", "credentials.create"]);
state.hosts.set(7, { ...baseHost });
state.logins.clear();
state.ownerDecrypts = 0;
state.logins.set("7:spice", {
protocol: "spice",
authType: "direct",
credentialId: null,
username: "admin",
password: "spice-secret",
fields: { display: "2" },
secretFields: { ticket: "t-123" },
});
setHostProtocolSource(() => [
{ ...SPICE, pluginId: "demo", pluginName: "demo" },
{ id: "rival", pluginId: "other", pluginName: "other" },
]);
resetSecretResolverRegistryForTests();
});
describe("ctx.credentials.resolveHostProtocol", () => {
it("refuses without credentials:read and audits the refusal", async () => {
grants.set("demo", []);
const ctx = contextFor([]);
const ctx = spiceContext([]);
await expect(
runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "rdp"),
ctx.credentials.resolveHostProtocol(7, "spice"),
),
).rejects.toThrow(/credentials:read/);
expect(auditEntries.at(-1)).toMatchObject({
@@ -153,12 +212,31 @@ describe("ctx.credentials.resolveHostProtocol", () => {
});
});
it("refuses a protocol the plugin does not declare, or another plugin owns", async () => {
grants.set("demo", ["credentials:read"]);
const ctx = contextFor(["credentials:read"], {
protocols: [SPICE, { id: "rival" }],
});
await expect(
runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "vnc"),
),
).rejects.toThrow(/does not declare/);
await expect(
runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "rival"),
),
).rejects.toThrow(/does not declare/);
expect(state.ownerDecrypts).toBe(0);
});
it("returns the owner's stored login and audits it", async () => {
grants.set("demo", ["credentials:read"]);
const ctx = contextFor(["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);
const target = await runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "rdp"),
ctx.credentials.resolveHostProtocol(7, "spice"),
);
expect(target).toEqual({
host: {
@@ -173,8 +251,8 @@ describe("ctx.credentials.resolveHostProtocol", () => {
auth: {
authType: "direct",
username: "admin",
password: "rdp-secret",
domain: "CORP",
password: "spice-secret",
fields: { display: "2", ticket: "t-123" },
},
});
expect(auditEntries.at(-1)).toMatchObject({
@@ -183,23 +261,52 @@ describe("ctx.credentials.resolveHostProtocol", () => {
});
});
it("uses the stored credential in credential mode", async () => {
it("falls back to the host's own password as declared", async () => {
grants.set("demo", ["credentials:read"]);
state.hosts.set(7, {
...baseHost,
vncCredentialId: 11,
vncAuthType: "credential",
state.logins.set("7:spice", {
protocol: "spice",
authType: "direct",
credentialId: null,
username: null,
password: null,
fields: {},
secretFields: {},
});
state.credentials.set(11, { username: "viewer", password: "vnc-pass" });
const ctx = contextFor(["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);
const target = await runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "vnc"),
ctx.credentials.resolveHostProtocol(7, "spice"),
);
expect(target?.auth).toEqual({
authType: "direct",
username: "",
password: "ssh-secret",
fields: { display: "", ticket: "" },
});
});
it("uses the stored credential in credential mode", async () => {
grants.set("demo", ["credentials:read"]);
state.logins.set("7:spice", {
protocol: "spice",
authType: "credential",
credentialId: 11,
username: null,
password: null,
fields: { display: "1" },
secretFields: {},
});
state.credentials.set(11, { username: "viewer", password: "cred-pass" });
const ctx = spiceContext(["credentials:read"]);
const target = await runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "spice"),
);
expect(target?.auth).toMatchObject({
authType: "credential",
username: "viewer",
password: "vnc-pass",
password: "cred-pass",
fields: { display: "1" },
});
});
@@ -208,50 +315,56 @@ describe("ctx.credentials.resolveHostProtocol", () => {
state.resolution = {
source: "owner-shared",
authType: "direct",
secret: { username: "shared", password: "snap", domain: null },
secret: {
username: "shared",
password: "snap",
fields: { ticket: "snap-ticket" },
},
};
const ctx = contextFor(["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);
const target = await runAsActor("guest", "request", () =>
ctx.credentials.resolveHostProtocol(7, "rdp"),
ctx.credentials.resolveHostProtocol(7, "spice"),
);
expect(target?.shared).toBe(true);
expect(target?.auth).toMatchObject({
expect(target?.auth).toEqual({
authType: "direct",
username: "shared",
password: "snap",
fields: { display: "2", ticket: "snap-ticket" },
});
expect(state.resolverCalls[0]).toMatchObject({
password: null,
rdpUser: null,
rdpPassword: null,
});
expect(state.resolverCalls[0]).toMatchObject({ password: null });
// The owner's login is never decrypted for a recipient.
expect(state.ownerDecrypts).toBe(0);
});
it("gives a recipient with nothing shared an empty login", async () => {
grants.set("demo", ["credentials:read"]);
state.resolution = { source: "required" };
const ctx = contextFor(["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);
const target = await runAsActor("guest", "request", () =>
ctx.credentials.resolveHostProtocol(7, "telnet"),
ctx.credentials.resolveHostProtocol(7, "spice"),
);
expect(target?.auth.username).toBe("");
expect(target?.auth.password).toBe("");
expect(target?.auth.fields.ticket).toBe("");
expect(state.ownerDecrypts).toBe(0);
});
it("returns null without connect access or for a missing host", async () => {
grants.set("demo", ["credentials:read"]);
state.canConnect = false;
const ctx = contextFor(["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);
await expect(
runAsActor("guest", "request", () =>
ctx.credentials.resolveHostProtocol(7, "rdp"),
ctx.credentials.resolveHostProtocol(7, "spice"),
),
).resolves.toBeNull();
await expect(
runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(99, "rdp"),
ctx.credentials.resolveHostProtocol(99, "spice"),
),
).resolves.toBeNull();
expect(auditEntries.at(-1)).toMatchObject({ success: false });
@@ -759,3 +759,67 @@ describe("contributes.keybindingActions", () => {
expect(errors).toMatch(/d/);
});
});
describe("contributes.protocols", () => {
const withProtocols = (protocols: unknown) =>
validateManifest(base({ contributes: { protocols } }));
it("accepts protocols with credential fields", () => {
expect(
withProtocols([
{
id: "spice",
credentialFields: [
{ key: "display" },
{ key: "ticket", secret: true },
],
defaultPort: 5930,
hostLoginFallback: ["username", "password"],
},
{ id: "x2go" },
]),
).toEqual([]);
});
it("refuses core's protocol, duplicates and bad ids", () => {
const errors = withProtocols([
{ id: "ssh" },
{ id: "spice" },
{ id: "spice" },
{ id: "Bad_Id" },
]);
expect(errors.join("\n")).toMatch(/"ssh" belongs to core/);
expect(errors.join("\n")).toMatch(/duplicates "spice"/);
expect(errors.join("\n")).toMatch(/\[3\]\.id must be lowercase/);
});
it("refuses field keys every login already has, and duplicate keys", () => {
const errors = withProtocols([
{
id: "spice",
credentialFields: [
{ key: "password" },
{ key: "display" },
{ key: "display", secret: "yes" },
],
},
]);
expect(errors.join("\n")).toMatch(/"password" is part of every login/);
expect(errors.join("\n")).toMatch(/duplicates "display"/);
expect(errors.join("\n")).toMatch(/secret must be a boolean/);
});
it("refuses unknown keys, bad ports and bad fallbacks", () => {
const errors = withProtocols([
{
id: "spice",
defaultPort: 70000,
hostLoginFallback: ["key"],
extra: true,
},
]);
expect(errors.join("\n")).toMatch(/defaultPort must be a port number/);
expect(errors.join("\n")).toMatch(/hostLoginFallback/);
expect(errors.join("\n")).toMatch(/extra/);
});
});
@@ -0,0 +1,387 @@
/**
* A fixture plugin declares a protocol core has never heard of ("spice") and
* gets a per-host login stored, encrypted, shared with a recipient (and
* overridden by them) and resolved, with no core change: only the manifest
* declaration. Runs on a real database with real repositories, sharing
* manager and override service; only keys, permissions and audit are faked.
*/
import crypto from "node:crypto";
import { sql } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { TestSqliteDatabase } from "../database/repositories/test-support.js";
import type { DatabaseContext } from "../../database/repositories/database-context.js";
const state = vi.hoisted(() => ({
db: null as null | { drizzle: unknown },
keys: new Map<string, Buffer>(),
levels: new Map<string, string>(),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => state.db!.drizzle,
getSqlite: () => null,
}));
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: {
triggerSave: vi.fn(),
forceSave: vi.fn(async () => {}),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async () => {}),
getAuditUsername: async (id: string) => id,
getRequestMeta: () => ({}),
}));
vi.mock("../../utils/data-crypto.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../utils/data-crypto.js")>();
const DataCrypto = actual.DataCrypto as unknown as Record<string, unknown>;
DataCrypto.getUserDataKey = (userId: string) =>
state.keys.get(userId) ?? null;
DataCrypto.validateUserAccess = (userId: string) => {
const key = state.keys.get(userId);
if (!key) throw new Error(`User ${userId} has no data encryption key`);
return key;
};
return actual;
});
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
canAccessHost: async (userId: string, _hostId: number) => {
const level = state.levels.get(userId);
return level
? { hasAccess: true, isShared: true, permissionLevel: level }
: { hasAccess: false };
},
hasPermission: async () => true,
}),
},
}));
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
import {
loadProtocolAuthSummaries,
ProtocolAuthWriteError,
readProtocolAuthPayload,
writeProtocolAuth,
} from "../../hosts/protocol-auth/protocol-auth.js";
import { SharedHostSecretsManager } from "../../utils/shared-host-secrets-manager.js";
import { SharedHostAuthOverrideService } from "../../utils/shared-host-auth-override-service.js";
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import { sanitizeHostForRecipient } from "../../database/routes/host-normalizers.js";
import { FieldCrypto } from "../../utils/field-crypto.js";
const HOST_ID = 10;
const GRANT_ID = 1;
const manifest = {
id: "fixture-spice",
name: "Fixture Spice",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: ["credentials:read"],
contributes: {
protocols: [
{
id: "spice",
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
},
],
},
} as PluginManifest;
let database: TestSqliteDatabase | null = null;
let context: DatabaseContext;
async function exec(query: string): Promise<void> {
await context.drizzle.run(sql.raw(query));
}
beforeEach(async () => {
database = new TestSqliteDatabase("sqlite");
context = await database.connect();
state.db = context;
state.keys = new Map([
["owner", crypto.randomBytes(32)],
["recipient", crypto.randomBytes(32)],
]);
state.levels = new Map([["recipient", "connect"]]);
for (const statement of [
`INSERT INTO users (id, username, password_hash) VALUES
('owner', 'owner', 'x'), ('recipient', 'recipient', 'x')`,
`INSERT INTO ssh_credentials (id, user_id, name, username, password, auth_type)
VALUES (5, 'recipient', 'mine', 'own-user', 'own-pass', 'password'),
(6, 'owner', 'owners', 'cred-user', 'cred-pass', 'password')`,
`INSERT INTO ssh_data (id, user_id, name, ip, port, username, password, auth_type)
VALUES (${HOST_ID}, 'owner', 'desk', '10.0.0.10', 22, 'root', 'ssh-pass', 'password')`,
`INSERT INTO host_access (id, host_id, user_id, granted_by, permission_level)
VALUES (${GRANT_ID}, ${HOST_ID}, 'recipient', 'owner', 'connect')`,
`INSERT INTO plugins (id, name, version, state, manifest_json)
VALUES ('fixture-spice', 'Fixture Spice', '1.0.0', 'enabled', '{}')`,
`INSERT INTO plugin_permission_grants (plugin_id, capability, source)
VALUES ('fixture-spice', 'credentials:read', 'bundled')`,
]) {
await exec(statement);
}
invalidatePluginPermissionCache();
// What the loader does with every installed manifest.
setHostProtocolSource(() =>
manifest.contributes!.protocols!.map((protocol) => ({
...protocol,
pluginId: manifest.id,
pluginName: manifest.name,
})),
);
});
afterEach(async () => {
setHostProtocolSource(() => []);
await database?.close();
database = null;
});
function pluginCtx() {
return createPluginContext(
manifest,
createPluginHandle(manifest.id, { activate: () => {} }),
);
}
async function storeLogin(): Promise<void> {
const patch = readProtocolAuthPayload({
protocolAuth: {
spice: {
authType: "direct",
username: "viewer",
password: "spice-pass",
fields: { display: "2", ticket: "t-secret" },
},
unknown: { username: "ignored" },
},
});
await writeProtocolAuth("owner", HOST_ID, patch!, { isOwner: true });
}
async function storedRow(): Promise<Record<string, unknown>> {
const rows = (await context.drizzle.all(
sql.raw(`SELECT * FROM host_protocol_auth WHERE host_id = ${HOST_ID}`),
)) as Record<string, unknown>[];
expect(rows).toHaveLength(1);
return rows[0];
}
describe("a plugin protocol core does not know", () => {
it("stores the login encrypted with the owner's key, secrets apart", async () => {
await storeLogin();
const row = await storedRow();
expect(row.protocol).toBe("spice");
expect(row.username).toBe("viewer");
expect(JSON.parse(row.fields as string)).toEqual({ display: "2" });
expect(String(row.password)).not.toContain("spice-pass");
expect(String(row.secret_fields)).not.toContain("t-secret");
expect(
FieldCrypto.decryptField(
row.password as string,
state.keys.get("owner")!,
"",
"password",
),
).toBe("spice-pass");
expect(() =>
FieldCrypto.decryptField(
row.password as string,
state.keys.get("recipient")!,
"",
"password",
),
).toThrow();
const summaries = await loadProtocolAuthSummaries([{ id: HOST_ID }]);
expect(summaries.get(HOST_ID)).toEqual({
spice: {
authType: "direct",
credentialId: null,
username: "viewer",
fields: { display: "2" },
hasPassword: true,
secretFieldKeys: ["ticket"],
},
});
});
it("resolves the owner's login for the declaring plugin", async () => {
await storeLogin();
const target = await runAsActor("owner", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.shared).toBe(false);
expect(target?.auth).toEqual({
authType: "direct",
username: "viewer",
password: "spice-pass",
fields: { display: "2", ticket: "t-secret" },
});
});
it("treats a tampered password as missing, never as a value", async () => {
await storeLogin();
const row = await storedRow();
const envelope = JSON.parse(row.password as string);
envelope.tag = "0".repeat(envelope.tag.length);
await exec(
`UPDATE host_protocol_auth SET password = '${JSON.stringify(envelope)}' WHERE host_id = ${HOST_ID}`,
);
const target = await runAsActor("owner", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.auth.password).toBe("");
expect(target?.auth.fields.ticket).toBe("t-secret");
});
it("gives a recipient the shared snapshot, not the owner's secret", async () => {
await storeLogin();
await SharedHostSecretsManager.getInstance().snapshotForUser(
GRANT_ID,
HOST_ID,
"recipient",
"owner",
);
// The owner's row loses its password without a resync: the recipient
// keeps reading their own copy, never the owner's row.
await exec(
`UPDATE host_protocol_auth SET password = NULL WHERE host_id = ${HOST_ID}`,
);
const target = await runAsActor("recipient", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.shared).toBe(true);
expect(target?.auth).toEqual({
authType: "direct",
username: "viewer",
password: "spice-pass",
fields: { display: "2", ticket: "t-secret" },
});
});
it("uses the recipient's own override over the shared snapshot", async () => {
await storeLogin();
await SharedHostSecretsManager.getInstance().snapshotForUser(
GRANT_ID,
HOST_ID,
"recipient",
"owner",
);
await SharedHostAuthOverrideService.getInstance().setCredentialId(
HOST_ID,
"recipient",
"spice",
5,
);
const target = await runAsActor("recipient", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.auth).toEqual({
authType: "credential",
username: "own-user",
password: "own-pass",
// The owner's plain fields, never their secret ones.
fields: { display: "2", ticket: "" },
});
});
it("refuses an override for a protocol nobody declares", async () => {
await expect(
SharedHostAuthOverrideService.getInstance().setCredentialId(
HOST_ID,
"recipient",
"made-up",
5,
),
).rejects.toThrow(/No plugin declares/);
});
it("follows a credential the owner points the login at", async () => {
const patch = readProtocolAuthPayload({
protocolAuth: { spice: { authType: "credential", credentialId: 6 } },
});
await writeProtocolAuth("owner", HOST_ID, patch!, { isOwner: true });
const target = await runAsActor("owner", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.auth).toMatchObject({
authType: "credential",
username: "cred-user",
password: "cred-pass",
});
});
it("lets a shared editor change the login but not its credential", async () => {
await storeLogin();
const rename = readProtocolAuthPayload({
protocolAuth: { spice: { username: "renamed", password: "" } },
});
await writeProtocolAuth("owner", HOST_ID, rename!, { isOwner: false });
expect((await storedRow()).username).toBe("renamed");
// An empty password from an editor who cannot see it keeps it.
const target = await runAsActor("owner", "request", () =>
pluginCtx().credentials.resolveHostProtocol(HOST_ID, "spice"),
);
expect(target?.auth.password).toBe("spice-pass");
const repoint = readProtocolAuthPayload({
protocolAuth: { spice: { authType: "credential", credentialId: 6 } },
});
await expect(
writeProtocolAuth("owner", HOST_ID, repoint!, { isOwner: false }),
).rejects.toBeInstanceOf(ProtocolAuthWriteError);
const remove = readProtocolAuthPayload({ protocolAuth: { spice: null } });
await writeProtocolAuth("owner", HOST_ID, remove!, { isOwner: false });
expect(
(await context.drizzle.all(
sql.raw(`SELECT id FROM host_protocol_auth`),
)) as unknown[],
).toHaveLength(0);
});
it("shows each share level only what it may see", async () => {
await storeLogin();
const summaries = await loadProtocolAuthSummaries([{ id: HOST_ID }]);
const host = {
id: HOST_ID,
name: "desk",
ip: "10.0.0.10",
protocolAuth: summaries.get(HOST_ID),
};
expect(
sanitizeHostForRecipient({ ...host }, "connect").protocolAuth,
).toEqual({ spice: { authType: "direct" } });
for (const level of ["view", "edit", "manage"]) {
expect(
sanitizeHostForRecipient({ ...host }, level).protocolAuth,
level,
).toEqual({
spice: {
authType: "direct",
credentialId: null,
username: "viewer",
fields: { display: "2" },
},
});
}
});
});
@@ -31,9 +31,6 @@ function secretHost(id: number): Record<string, unknown> {
keyPassword: "s3cret-value",
sudoPassword: "s3cret-value",
socks5Password: "s3cret-value",
rdpPassword: "s3cret-value",
vncPassword: "s3cret-value",
telnetPassword: "s3cret-value",
autostartPassword: "s3cret-value",
autostartKey: "s3cret-value",
autostartKeyPassword: "s3cret-value",
@@ -420,11 +420,45 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
environmentVariables: [{ key: "D4", value: "yes" }],
});
// Every 2.8 RDP, VNC and Telnet login reads back through
// host_protocol_auth, the only place core reads them from now.
const ctx = await pluginCtx("remote-desktop");
const target = await asUser(USERS.admin, () =>
ctx.credentials.resolveHostProtocol(HOSTS.rdpOnly, "rdp"),
const resolve = (hostId: number, protocol: string) =>
asUser(USERS.admin, () =>
ctx.credentials.resolveHostProtocol(hostId, protocol),
);
expect((await resolve(HOSTS.rdpOnly, "rdp"))?.auth).toMatchObject({
authType: "direct",
username: "d4-rdp-user",
password: "d4-rdp-password",
});
expect((await resolve(HOSTS.desktop, "rdp"))?.auth).toEqual({
authType: "direct",
username: "d4-rdp-user",
password: "d4-rdp-password",
fields: { domain: "D4-DOMAIN" },
});
expect((await resolve(HOSTS.desktop, "vnc"))?.auth).toMatchObject({
authType: "direct",
username: "d4-vnc-user",
password: "d4-vnc-password",
});
expect((await resolve(HOSTS.desktop, "telnet"))?.auth).toMatchObject({
authType: "credential",
username: "deploy",
});
const logins = await selectRows<{ host_id: number; protocol: string }>(
sql`SELECT host_id, protocol FROM host_protocol_auth ORDER BY host_id, protocol`,
);
expect(target?.auth.password).toBe("d4-rdp-password");
expect(
logins.map((row) => `${Number(row.host_id)}:${row.protocol}`),
).toEqual([
`${HOSTS.rdpOnly}:rdp`,
`${HOSTS.desktop}:rdp`,
`${HOSTS.desktop}:telnet`,
`${HOSTS.desktop}:vnc`,
]);
});
it("registers an SSH auth provider for every 2.8 auth type", async () => {
@@ -0,0 +1,131 @@
/**
* A host's plugin protocol logins over sync: out with the credential named
* by syncId, back in with it translated to the local id.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
logins: [] as Array<Record<string, unknown>>,
replaced: null as null | { ownerId: string; hostId: number; logins: unknown },
}));
vi.mock(
"../../hosts/protocol-auth/protocol-auth.js",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../hosts/protocol-auth/protocol-auth.js")
>();
return {
...actual,
listProtocolLogins: async () => state.logins,
replaceProtocolLogins: async (
ownerId: string,
hostId: number,
logins: unknown,
) => {
state.replaced = { ownerId, hostId, logins };
},
};
},
);
vi.mock("../../database/db/index.js", () => ({
getDb: () => null,
getSqlite: () => null,
}));
import {
exportProtocolLogins,
importProtocolLogins,
} from "../../sync/host-protocol-auth.js";
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
beforeEach(() => {
state.replaced = null;
state.logins = [
{
protocol: "spice",
authType: "credential",
credentialId: 4,
username: null,
password: null,
fields: { display: "2" },
secretFields: { ticket: "t" },
},
];
setHostProtocolSource(() => [
{
id: "spice",
credentialFields: [{ key: "display" }, { key: "ticket", secret: true }],
pluginId: "p",
pluginName: "P",
},
]);
});
describe("host protocol logins over sync", () => {
it("sends each login with its credential as a syncId", async () => {
const wire = await exportProtocolLogins(1, "owner", async (type, id) =>
type === "sshCredentials" && id === 4 ? "cred-sync-4" : null,
);
expect(wire).toEqual({
spice: {
authType: "credential",
username: null,
password: null,
fields: { display: "2", ticket: "t" },
credentialSyncId: "cred-sync-4",
},
});
});
it("writes what arrived with the credential's local id", async () => {
await importProtocolLogins(
9,
"owner",
{
spice: {
authType: "credential",
fields: { display: "3", ticket: "t2" },
credentialSyncId: "cred-sync-4",
},
},
async (type, syncId) =>
type === "sshCredentials" && syncId === "cred-sync-4" ? 40 : null,
);
expect(state.replaced).toEqual({
ownerId: "owner",
hostId: 9,
logins: [
{
protocol: "spice",
authType: "credential",
credentialId: 40,
username: null,
password: null,
fields: { display: "3" },
secretFields: { ticket: "t2" },
},
],
});
});
it("keeps the current credential while its target has not arrived", async () => {
await importProtocolLogins(
9,
"owner",
{ spice: { authType: "credential", credentialSyncId: "later" } },
async () => null,
);
expect(
(state.replaced?.logins as Array<{ credentialId: number }>)[0]
.credentialId,
).toBe(4);
});
it("leaves the logins alone for a peer that sends none", async () => {
await importProtocolLogins(9, "owner", undefined, async () => null);
expect(state.replaced).toBeNull();
});
});
@@ -0,0 +1,223 @@
/**
* The 2.8 RDP, VNC and Telnet logins move out of the ssh_data columns into
* host_protocol_auth. Every shape a 2.8 install could hold must come across
* (encrypted under the column's name, the blob 2.8 copied from `password`,
* plaintext, credential mode, the pre-protocol-columns hosts), a second run
* must change nothing, and a login the user removed afterwards must not come
* back. Runs on SQLite, or on TEST_DIALECT's server.
*/
import crypto from "node:crypto";
import { sql } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TestSqliteDatabase } from "../database/repositories/test-support.js";
const state = vi.hoisted(() => ({
db: null as null | { drizzle: unknown },
keys: new Map<string, Buffer>(),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => state.db!.drizzle,
getSqlite: () => null,
}));
vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: {
triggerSave: vi.fn(),
forceSave: vi.fn(async () => {}),
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: (userId: string) => state.keys.get(userId) ?? null,
},
}));
import {
copiedMarker,
runProtocolAuthMigration,
} from "../../upgrade/protocol-auth-migration.js";
import { createCurrentHostProtocolAuthRepository } from "../../database/repositories/factory.js";
import { FieldCrypto } from "../../utils/field-crypto.js";
const ownerKey = crypto.randomBytes(32);
let database: TestSqliteDatabase | null = null;
function sealed(value: string, hostId: number, field: string): string {
return FieldCrypto.encryptField(value, ownerKey, String(hostId), field);
}
function quote(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
beforeEach(async () => {
database = new TestSqliteDatabase();
state.db = await database.connect();
state.keys = new Map([["owner", ownerKey]]);
await database.exec(`
INSERT INTO users (id, username, password_hash) VALUES
('owner', 'owner', 'x'), ('locked', 'locked', 'x');
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
(5, 'owner', 'vnc-cred', 'viewer', 'password');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
rdp_user, rdp_password, rdp_domain, rdp_auth_type)
VALUES (1, 'owner', 'ssh', 'win', '10.0.0.1', 22, 'root', 'password',
'admin', ${quote(sealed("rdp-pass", 1, "rdpPassword"))}, 'CORP', 'direct');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
vnc_auth_type, vnc_credential_id)
VALUES (2, 'owner', 'ssh', 'vnc', '10.0.0.2', 22, 'root', 'password', 'credential', 5);
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
telnet_user, telnet_password)
VALUES (3, 'owner', 'ssh', 'switch', '10.0.0.3', 22, 'root', 'password', 'tu', 'tel-plain');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, password,
domain, auth_type)
VALUES (4, 'owner', 'rdp', 'old', '10.0.0.4', 3389, 'old-user',
${quote(sealed("old-pass", 4, "password"))}, 'OLDDOM', 'password');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
rdp_user, rdp_password, rdp_auth_type)
VALUES (5, 'owner', 'ssh', 'copied', '10.0.0.5', 22, 'root', 'password',
'copied', ${quote(sealed("copied-pass", 5, "password"))}, 'none');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type)
VALUES (6, 'owner', 'ssh', 'plain-ssh', '10.0.0.6', 22, 'root', 'password');
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, auth_type,
rdp_user)
VALUES (7, 'locked', 'ssh', 'later', '10.0.0.7', 22, 'root', 'password', 'waits');
`);
});
afterEach(async () => {
await database?.close();
database = null;
});
async function logins() {
const repository = createCurrentHostProtocolAuthRepository();
const out: Record<string, unknown> = {};
for (const hostId of [1, 2, 3, 4, 5, 6]) {
for (const login of await repository.listForHost(hostId, ownerKey)) {
out[`${hostId}:${login.protocol}`] = login;
}
}
return out;
}
const EXPECTED = {
"1:rdp": {
protocol: "rdp",
authType: "direct",
credentialId: null,
username: "admin",
password: "rdp-pass",
fields: { domain: "CORP" },
secretFields: {},
},
"2:vnc": {
protocol: "vnc",
authType: "credential",
credentialId: 5,
username: null,
password: null,
fields: {},
secretFields: {},
},
"3:telnet": {
protocol: "telnet",
authType: "direct",
credentialId: null,
username: "tu",
password: "tel-plain",
fields: {},
secretFields: {},
},
"4:rdp": {
protocol: "rdp",
authType: "direct",
credentialId: null,
username: "old-user",
password: "old-pass",
fields: { domain: "OLDDOM" },
secretFields: {},
},
"5:rdp": {
protocol: "rdp",
authType: "none",
credentialId: null,
username: null,
password: null,
fields: {},
secretFields: {},
},
};
describe("runProtocolAuthMigration", () => {
it("copies every 2.8 login shape, and a second run changes nothing", async () => {
const first = await runProtocolAuthMigration();
expect(first).toEqual({ copied: 5, pendingUsers: 1 });
expect(await logins()).toEqual(EXPECTED);
const second = await runProtocolAuthMigration();
expect(second).toEqual({ copied: 0, pendingUsers: 1 });
expect(await logins()).toEqual(EXPECTED);
});
it("leaves the legacy columns in place", async () => {
await runProtocolAuthMigration();
const [row] = await database!.query<{ rdp_user: string }>(
sql`SELECT rdp_user FROM ssh_data WHERE id = 1`,
);
expect(row.rdp_user).toBe("admin");
});
it("does not bring back a login the user removed", async () => {
await runProtocolAuthMigration();
await createCurrentHostProtocolAuthRepository().delete(1, "rdp");
await runProtocolAuthMigration();
expect(await logins()).not.toHaveProperty("1:rdp");
});
it("waits for a locked data key, then copies at that user's login", async () => {
await runProtocolAuthMigration();
const repository = createCurrentHostProtocolAuthRepository();
expect(await repository.listRowsForHost(7)).toEqual([]);
const lockedKey = crypto.randomBytes(32);
state.keys.set("locked", lockedKey);
expect(await runProtocolAuthMigration("locked")).toEqual({
copied: 1,
pendingUsers: 0,
});
expect(await repository.find(7, "rdp", lockedKey)).toMatchObject({
username: "waits",
});
const [marker] = await database!.query<{ value: string }>(
sql`SELECT value FROM settings WHERE key = ${copiedMarker("locked")}`,
);
expect(marker.value).toBe("done");
});
it("skips a host that already has a login, as after a partial run", async () => {
await createCurrentHostProtocolAuthRepository().upsert(
"owner",
1,
{
protocol: "rdp",
authType: "direct",
credentialId: null,
username: "edited",
password: null,
fields: {},
secretFields: {},
},
ownerKey,
);
await runProtocolAuthMigration();
expect((await logins())["1:rdp"]).toMatchObject({ username: "edited" });
expect(await logins()).toHaveProperty("3:telnet");
});
});
@@ -12,9 +12,19 @@ const state = vi.hoisted(() => ({
accessToHost: new Map<number, number>(),
grants: [] as Array<Record<string, unknown>>,
roleMembers: new Map<number, string[]>(),
// hostId -> that host's plugin protocol logins, decrypted
logins: new Map<number, Array<Record<string, unknown>>>(),
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (credentialId: number) =>
state.credentials.get(credentialId) ?? null,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostProtocolAuthRepository: () => ({
listForHost: async (hostId: number) => state.logins.get(hostId) ?? [],
}),
createCurrentHostResolutionRepository: () => ({
findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
findHostOwnerId: async (hostId: number) =>
@@ -137,22 +147,20 @@ function baseHost(overrides: Record<string, unknown> = {}) {
credentialId: null,
shareSshAuth: false,
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
rdpAuthType: null,
vncAuthType: null,
telnetAuthType: null,
rdpCredentialId: null,
vncCredentialId: null,
telnetCredentialId: null,
rdpUser: null,
rdpPassword: null,
rdpDomain: null,
vncUser: null,
vncPassword: null,
telnetUser: null,
telnetPassword: null,
...overrides,
};
}
/** A plugin protocol login as the repository hands it back, decrypted. */
function login(protocol: string, overrides: Record<string, unknown> = {}) {
return {
protocol,
authType: "direct",
credentialId: null,
username: null,
password: null,
fields: {},
secretFields: {},
...overrides,
};
}
@@ -164,6 +172,7 @@ beforeEach(() => {
state.accessToHost = new Map([[7, 42]]);
state.grants = [];
state.roleMembers.clear();
state.logins.clear();
});
describe("SharedHostSecretsManager", () => {
@@ -189,7 +198,7 @@ describe("SharedHostSecretsManager", () => {
});
});
it("snapshots opted-in SSH credential auth alongside enabled non-SSH protocols", async () => {
it("snapshots opted-in SSH credential auth alongside plugin protocol logins", async () => {
state.credentials.set(123, {
id: 123,
userId: "owner",
@@ -208,22 +217,24 @@ describe("SharedHostSecretsManager", () => {
credentialId: 123,
shareSshAuth: true,
password: null,
enableRdp: true,
rdpUser: "rdp-admin",
rdpPassword: "rdp-pass",
rdpDomain: "CORP",
enableTelnet: true,
telnetUser: "tel-user",
telnetPassword: "tel-pass",
}),
);
state.logins.set(42, [
login("spice", {
username: "spice-admin",
password: "spice-pass",
fields: { display: "2" },
secretFields: { ticket: "t-1" },
}),
login("x2go", { username: "x-user", password: "x-pass" }),
]);
await manager.snapshotForUser(7, 42, "target", "owner");
expect(state.secretRows.map((row) => row.protocol).sort()).toEqual([
"rdp",
"spice",
"ssh",
"telnet",
"x2go",
]);
expect(await manager.getSecretForUser(42, "target", "ssh")).toMatchObject({
@@ -234,21 +245,101 @@ describe("SharedHostSecretsManager", () => {
keyType: "ssh-ed25519",
});
const rdp = await manager.getSecretForUser(42, "target", "rdp");
expect(rdp).toMatchObject({
username: "rdp-admin",
password: "rdp-pass",
domain: "CORP",
const spice = await manager.getSecretForUser(42, "target", "spice");
expect(spice).toMatchObject({
username: "spice-admin",
password: "spice-pass",
fields: { display: "2", ticket: "t-1" },
authType: "direct",
});
const telnet = await manager.getSecretForUser(42, "target", "telnet");
expect(telnet).toMatchObject({
username: "tel-user",
password: "tel-pass",
const x2go = await manager.getSecretForUser(42, "target", "x2go");
expect(x2go).toMatchObject({
username: "x-user",
password: "x-pass",
});
});
it("snapshots a credential-backed protocol login from the credential", async () => {
state.credentials.set(55, {
id: 55,
userId: "owner",
username: "cred-viewer",
password: "cred-pass",
});
state.hosts.set(42, baseHost());
state.logins.set(42, [
login("spice", {
authType: "credential",
credentialId: 55,
fields: { display: "1" },
}),
]);
await manager.snapshotForUser(7, 42, "target", "owner");
expect(state.secretRows[0]).toMatchObject({
protocol: "spice",
sourceType: "credential",
originalCredentialId: 55,
});
expect(await manager.getSecretForUser(42, "target", "spice")).toMatchObject(
{
authType: "credential",
username: "cred-viewer",
password: "cred-pass",
fields: { display: "1" },
},
);
});
it("takes no snapshot of a login that asks at connect time", async () => {
state.hosts.set(42, baseHost());
state.logins.set(42, [login("spice", { authType: "none" })]);
await manager.snapshotForUser(7, 42, "target", "owner");
expect(state.secretRows).toHaveLength(0);
});
it("reads a snapshot taken before 2.9.0 with its domain column", async () => {
const recordId = "shared-7-target-legacy";
state.secretRows.push({
id: 1,
hostAccessId: 7,
targetUserId: "target",
protocol: "legacy",
encryptedUsername: FieldCrypto.encryptField(
"admin",
targetDEK,
recordId,
"username",
),
encryptedAuthType: "direct",
encryptedDomain: FieldCrypto.encryptField(
"CORP",
targetDEK,
recordId,
"domain",
),
});
expect(
await manager.getSecretForUser(42, "target", "legacy"),
).toMatchObject({ username: "admin", fields: { domain: "CORP" } });
});
it("drops a protocol's snapshot once its login is gone", async () => {
state.hosts.set(42, baseHost());
state.logins.set(42, [login("spice", { username: "u", password: "p" })]);
await manager.snapshotForUser(7, 42, "target", "owner");
expect(state.secretRows).toHaveLength(1);
state.logins.set(42, []);
await manager.snapshotForUser(7, 42, "target", "owner");
expect(state.secretRows).toHaveLength(0);
});
it("produces no snapshot rows for secret-less auth types", async () => {
state.hosts.set(
42,
@@ -283,36 +374,54 @@ describe("SharedHostSecretsManager", () => {
});
it("cannot be decrypted with the wrong DEK", async () => {
state.hosts.set(
42,
baseHost({
enableRdp: true,
rdpUser: "rdp-admin",
rdpPassword: "rdp-pass",
state.hosts.set(42, baseHost());
state.logins.set(42, [
login("spice", {
username: "spice-admin",
password: "spice-pass",
secretFields: { ticket: "t-1" },
}),
);
]);
await manager.snapshotForUser(7, 42, "target", "owner");
const row = state.secretRows[0];
expect(() =>
FieldCrypto.decryptField(
row.encryptedPassword as string,
ownerDEK,
"shared-7-target-rdp",
"password",
),
).toThrow();
for (const [column, field] of [
["encryptedPassword", "password"],
["encryptedFields", "fields"],
]) {
expect(() =>
FieldCrypto.decryptField(
row[column] as string,
ownerDEK,
"shared-7-target-spice",
field,
),
).toThrow();
}
});
it("refuses a tampered snapshot rather than handing it out", async () => {
state.hosts.set(42, baseHost());
state.logins.set(42, [login("spice", { password: "spice-pass" })]);
await manager.snapshotForUser(7, 42, "target", "owner");
const row = state.secretRows[0];
const envelope = JSON.parse(row.encryptedPassword as string);
envelope.data = envelope.data.replace(/^./, (c: string) =>
c === "0" ? "1" : "0",
);
row.encryptedPassword = JSON.stringify(envelope);
await expect(
manager.getSecretForUser(42, "target", "spice"),
).rejects.toThrow();
});
it("resyncHost re-snapshots direct grants and role members", async () => {
state.hosts.set(
42,
baseHost({
enableRdp: true,
rdpUser: "rdp-admin",
rdpPassword: "rdp-pass",
}),
);
state.hosts.set(42, baseHost());
state.logins.set(42, [
login("spice", { username: "spice-admin", password: "spice-pass" }),
]);
state.accessToHost = new Map([
[1, 42],
[2, 42],
@@ -334,29 +443,20 @@ describe("SharedHostSecretsManager", () => {
]);
// Owner rotates the non-SSH password; resync updates those copies.
state.hosts.set(
42,
baseHost({
enableRdp: true,
rdpUser: "rdp-admin",
rdpPassword: "rotated",
}),
);
state.logins.set(42, [
login("spice", { username: "spice-admin", password: "rotated" }),
]);
await manager.resyncHost(42);
const secret = await manager.getSecretForUser(42, "target", "rdp");
const secret = await manager.getSecretForUser(42, "target", "spice");
expect(secret?.password).toBe("rotated");
});
it("snapshotForRoleMember fans out from role grants", async () => {
state.hosts.set(
42,
baseHost({
enableRdp: true,
rdpUser: "rdp-admin",
rdpPassword: "rdp-pass",
}),
);
state.hosts.set(42, baseHost());
state.logins.set(42, [
login("spice", { username: "spice-admin", password: "spice-pass" }),
]);
state.accessToHost = new Map([[2, 42]]);
state.grants = [{ id: 2, hostId: 42, userId: null, roleId: 9 }];
@@ -366,7 +466,7 @@ describe("SharedHostSecretsManager", () => {
expect(state.secretRows[0]).toMatchObject({
hostAccessId: 2,
targetUserId: "member-1",
protocol: "rdp",
protocol: "spice",
});
});
});