/** * 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 * "://..." 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; interface Deps { manifest: PluginManifest; bag: DisposableBag; audit: AuditFn; } type HostRow = Record; 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 { 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 | 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, }; }, }; }