Files
Termix/src/backend/plugins/ctx-credentials.ts
T

414 lines
14 KiB
TypeScript

/**
* ctx.credentials: plaintext protocol credentials for a host, for a plugin
* that hands them to a program core does not run (remote desktop gives them
* to guacd). Needs credentials:read, the one critical capability, and every
* call is audited whether it succeeds or not.
*
* The logins themselves stay in core, next to the host, because core's
* sharing model (per-recipient secret snapshots, personal overrides) decides
* which ones a shared recipient may use. A plugin reads only the protocols
* its own manifest declares in contributes.protocols.
*
* registerSecretResolver is the other half: a plugin that resolves
* "<scheme>://..." references in a host's secret fields (secret-sources and
* "op://") instead of handing over a host's own stored secret. Needs
* auth:provide, like ctx.auth's registrations, and the scheme must be listed
* in contributes.auth.secretSchemes.
*
* listSshKeys and createSshKey reach the acting user's own saved keys: the
* public half only (credentials:use), and saving a new key pair
* (credentials:write). Termix Identity publishes and generates keys this way.
*/
import type {
PluginCredentials,
PluginSshKeyCredential,
} from "@termix-ssh/plugin-sdk/backend";
import ssh2 from "ssh2";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { getActor } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import { registerSecretResolver } from "../hosts/connect/secret-resolver-registry.js";
import { findHostProtocol } from "../hosts/protocol-auth/registry.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
}
type HostRow = Record<string, unknown>;
function str(value: unknown): string {
return typeof value === "string" ? value : "";
}
function parseJumpHosts(value: unknown): Array<{ hostId: number }> {
let parsed: unknown = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
return [];
}
}
if (!Array.isArray(parsed)) return [];
return parsed
.map((hop) => Number((hop as { hostId?: unknown })?.hostId))
.filter((id) => Number.isInteger(id) && id > 0)
.map((hostId) => ({ hostId }));
}
function derivePublicKey(
privateKey: string | null | undefined,
passphrase: string | null | undefined,
): string | null {
if (!privateKey) return null;
try {
const parsed = ssh2.utils.parseKey(privateKey, passphrase || undefined);
const key = Array.isArray(parsed) ? parsed[0] : parsed;
if (!key || key instanceof Error) return null;
return `${key.type} ${key.getPublicSSH().toString("base64")}`;
} catch {
return null;
}
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function requireCorePermission(
userId: string,
permission: string,
): Promise<void> {
const { PermissionManager } = await import("../utils/permission-manager.js");
if (
!(await PermissionManager.getInstance().hasPermission(userId, permission))
) {
throw new Error(`The acting user lacks ${permission}`);
}
}
function actingUser(): string {
const userId = getActor();
if (!userId) {
throw new Error(
"ctx.credentials needs an acting user: call it inside a request or ctx.asUser",
);
}
return userId;
}
export function createPluginCredentials({
manifest,
bag,
audit,
}: Deps): PluginCredentials {
const pluginId = manifest.id;
const declared = manifest.capabilities;
return {
listSshKeys: async () => {
try {
await assertCapability(pluginId, "credentials:use", declared);
const userId = actingUser();
await requireCorePermission(userId, "credentials.view");
const { createCurrentCredentialRepository } =
await import("../database/repositories/factory.js");
const rows =
await createCurrentCredentialRepository().listDecryptedByUserId(
userId,
);
const keys: PluginSshKeyCredential[] = rows
.filter((row) => row.authType === "key")
.map((row) => ({
id: row.id,
name: row.name,
username: row.username ?? null,
publicKey:
(typeof row.publicKey === "string" && row.publicKey.trim()) ||
derivePublicKey(row.privateKey || row.key, row.keyPassword),
}));
await audit("credentials_list_keys", `${keys.length} keys`, {
success: true,
});
return keys;
} catch (error) {
await audit("credentials_list_keys", "saved SSH keys", {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
list: async () => {
try {
await assertCapability(pluginId, "credentials:use", declared);
const userId = actingUser();
await requireCorePermission(userId, "credentials.view");
const { createCurrentCredentialRepository } =
await import("../database/repositories/factory.js");
const rows =
await createCurrentCredentialRepository().listDecryptedByUserId(
userId,
);
const list = rows.map((row) => ({
id: row.id,
name: row.name,
username: row.username ?? null,
authType: String(row.authType ?? ""),
folder: row.folder ?? null,
}));
await audit("credentials_list", `${list.length} credentials`, {
success: true,
});
return list;
} catch (error) {
await audit("credentials_list", "saved credentials", {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
createSshKey: async (input) => {
const details = `SSH key "${input?.name ?? ""}"`;
try {
await assertCapability(pluginId, "credentials:write", declared);
const userId = actingUser();
await requireCorePermission(userId, "credentials.create");
if (!input?.name || !input.privateKey || !input.publicKey) {
throw new Error("name, privateKey and publicKey are required");
}
const { createCurrentCredentialRepository } =
await import("../database/repositories/factory.js");
const created =
await createCurrentCredentialRepository().createEncryptedForUser(
userId,
{
userId,
name: input.name,
description: input.description ?? null,
folder: null,
tags: "",
authType: "key",
username: input.username || null,
password: null,
key: input.privateKey,
privateKey: input.privateKey,
publicKey: input.publicKey,
keyPassword: null,
keyType: null,
detectedKeyType: input.keyType,
usageCount: 0,
lastUsed: null,
},
);
await audit("credentials_create_key", details, { success: true });
return { id: created.id };
} catch (error) {
await audit("credentials_create_key", details, {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
publicKey: async (credentialId) => {
const details = `public key of credential ${credentialId}`;
try {
await assertCapability(pluginId, "credentials:use", declared);
const userId = actingUser();
await requireCorePermission(userId, "credentials.view");
if (!Number.isInteger(credentialId) || credentialId <= 0) {
throw new Error("A credential id is required");
}
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const credential =
await createCurrentHostResolutionRepository().findCredentialByIdForUser(
credentialId,
userId,
);
const row = credential as unknown as Record<string, unknown> | null;
if (!row || row.authType !== "key") {
await audit("credentials_public_key", details, { success: true });
return null;
}
let publicKey =
typeof row.publicKey === "string" && row.publicKey.trim()
? row.publicKey.trim()
: null;
if (!publicKey) {
const stored = str(row.privateKey) || str(row.key);
if (stored) {
const { resolveKeyReferences } =
await import("../hosts/stored-ssh-key.js");
const resolved = await resolveKeyReferences(
userId,
stored,
typeof row.keyPassword === "string" ? row.keyPassword : null,
);
publicKey = derivePublicKey(resolved.key, resolved.passphrase);
}
}
await audit("credentials_public_key", details, { success: true });
return publicKey;
} catch (error) {
await audit("credentials_public_key", details, {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
resolveKeyRefs: async (key, passphrase) => {
const details = "resolved key references";
try {
await assertCapability(pluginId, "credentials:read", declared);
const userId = actingUser();
const { resolveKeyReferences } =
await import("../hosts/stored-ssh-key.js");
const resolved = await resolveKeyReferences(userId, key, passphrase);
await audit("credentials_resolve_key", details, { success: true });
return resolved;
} catch (error) {
await audit("credentials_resolve_key", details, {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
registerSecretResolver: (scheme, resolve) => {
if (!declared.includes("auth:provide")) {
throw capabilityRefused(pluginId, "auth:provide");
}
if (!manifest.contributes?.auth?.secretSchemes?.includes(scheme)) {
throw new Error(
`Plugin ${pluginId} cannot register secret scheme "${scheme}": it is not listed in contributes.auth.secretSchemes`,
);
}
const dispose = registerSecretResolver(
scheme,
{ pluginId, pluginName: manifest.name },
async (userId, reference) => {
await assertCapability(pluginId, "auth:provide", declared);
const details = `secret reference for ${userId}`;
try {
const value = await resolve(userId, reference);
await audit("secret_resolve", details, { success: true });
return value;
} catch (error) {
await audit("secret_resolve", details, {
success: false,
errorMessage:
error instanceof Error ? error.message : String(error),
});
throw error;
}
},
);
bag.add(dispose, `secret scheme "${scheme}"`);
},
resolveHostProtocol: async (hostId, protocol) => {
const details = `${protocol} credentials for host ${hostId}`;
try {
await assertCapability(pluginId, "credentials:read", declared);
// Another plugin that declared the same id first owns it.
const own =
manifest.contributes?.protocols?.some(
(entry) => entry.id === protocol,
) && findHostProtocol(protocol)?.pluginId === pluginId;
if (!own) {
throw new Error(
`Plugin ${pluginId} does not declare protocol "${String(protocol)}" in contributes.protocols`,
);
}
} catch (error) {
await audit("credentials_read", details, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
const userId = getActor();
if (!userId) {
throw new Error(
"ctx.credentials needs an acting user: call it inside a request or ctx.asUser",
);
}
const { createCurrentHostResolutionRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentHostResolutionRepository();
// Shared hosts carry fields encrypted under the owner's key.
const ownerId = await repository.findHostOwnerId(hostId);
const host = ownerId
? ((await repository.findHostById(hostId, ownerId)) as HostRow | null)
: null;
if (!host) {
await audit("credentials_read", details, {
success: false,
errorMessage: "Host not found",
});
return null;
}
const shared = host.userId !== userId;
if (shared) {
const { PermissionManager } =
await import("../utils/permission-manager.js");
const access = await PermissionManager.getInstance().canAccessHost(
userId,
hostId,
"connect",
);
if (!access.hasAccess) {
await audit("credentials_read", details, {
success: false,
errorMessage: "No connect access",
});
return null;
}
}
const declaredProtocol = findHostProtocol(protocol)!;
const { resolveOwnerProtocolLogin, resolveRecipientProtocolLogin } =
await import("../hosts/protocol-auth/protocol-auth.js");
const auth = shared
? await resolveRecipientProtocolLogin(host, userId, declaredProtocol)
: await resolveOwnerProtocolLogin(host, declaredProtocol);
await audit("credentials_read", details, { success: true });
return {
host: {
id: hostId,
name: (host.name as string | null) ?? null,
ip: str(host.ip),
port: Number(host.port) || 0,
ownerUserId: host.userId as string,
jumpHosts: parseJumpHosts(host.jumpHosts),
},
shared,
auth,
};
},
};
}