mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: store rdp, vnc and telnet logins in a generic per-protocol table
This commit is contained in:
1 parent
e0c30a4e6f
commit
bba06f7ff6
107 files changed
+18750
-1224
No files matched your search
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user