mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
414 lines
14 KiB
TypeScript
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,
|
|
};
|
|
},
|
|
};
|
|
}
|