import { getErrorMessage } from "./error-message.js"; import { createCurrentHostResolutionRepository, createCurrentRbacAccessRepository, createCurrentRoleRepository, createCurrentSharedHostSecretsRepository, } from "../database/repositories/factory.js"; import type { SharedHostSecretRecord, ShareProtocol, } from "../database/repositories/shared-host-secrets-repository.js"; import type { HostResolutionHostRecord } from "../database/repositories/host-resolution-repository.js"; import { DataCrypto } from "./data-crypto.js"; import { FieldCrypto } from "./field-crypto.js"; import { databaseLogger } from "./logger.js"; export interface SharedSecretData { username?: string; authType: string; password?: string; key?: string; keyPassword?: string; keyType?: string; domain?: string; } interface ProtocolSnapshot { protocol: ShareProtocol; sourceType: "credential" | "inline"; originalCredentialId: number | null; data: SharedSecretData; } function snapshotRecordId( hostAccessId: number, targetUserId: string, protocol: ShareProtocol, ): string { return `shared-${hostAccessId}-${targetUserId}-${protocol}`; } // SSH follows its own switch. The remote desktop protocols' switches are the // remote-desktop plugin's host settings, and the host editor clears a // protocol's login when it is switched off, so a snapshot is taken whenever // login data is present (see collectProtocolSnapshots). function enabledProtocols( host: HostResolutionHostRecord, ): Record { const ct = host.connectionType; return { ssh: host.enableSsh !== false && (!ct || ct === "ssh"), rdp: true, vnc: true, telnet: true, }; } // Per-recipient copies of connection secrets, re-encrypted under the // recipient's DEK. SSH authentication is copied only when the host owner // explicitly opts in; recipient-owned credential overrides remain separate. class SharedHostSecretsManager { private static instance: SharedHostSecretsManager; private constructor() {} static getInstance(): SharedHostSecretsManager { if (!this.instance) { this.instance = new SharedHostSecretsManager(); } return this.instance; } async snapshotForUser( hostAccessId: number, hostId: number, targetUserId: string, ownerId: string, ): Promise { if (targetUserId === ownerId) return; try { const targetDEK = DataCrypto.validateUserAccess(targetUserId); DataCrypto.validateUserAccess(ownerId); const host = await createCurrentHostResolutionRepository().findHostById( hostId, ownerId, ); if (!host) { throw new Error(`Host ${hostId} not found`); } const snapshots = await this.collectProtocolSnapshots(host, ownerId); const repository = createCurrentSharedHostSecretsRepository(); for (const snapshot of snapshots) { const recordId = snapshotRecordId( hostAccessId, targetUserId, snapshot.protocol, ); const encrypt = (value: string | undefined, fieldName: string) => value ? FieldCrypto.encryptField(value, targetDEK, recordId, fieldName) : null; await repository.upsert({ hostAccessId, targetUserId, protocol: snapshot.protocol, sourceType: snapshot.sourceType, originalCredentialId: snapshot.originalCredentialId, encryptedUsername: encrypt(snapshot.data.username, "username"), encryptedAuthType: snapshot.data.authType, encryptedPassword: encrypt(snapshot.data.password, "password"), encryptedKey: encrypt(snapshot.data.key, "key"), encryptedKeyPassword: encrypt( snapshot.data.keyPassword, "key_password", ), encryptedKeyType: snapshot.data.keyType || null, encryptedDomain: encrypt(snapshot.data.domain, "domain"), }); } await repository.deleteForHostAccessAndTarget( hostAccessId, targetUserId, snapshots.map((snapshot) => snapshot.protocol), ); } catch (error) { databaseLogger.error("Failed to snapshot shared host secrets", error, { operation: "shared_host_secrets_snapshot", hostAccessId, hostId, targetUserId, }); throw error; } } async snapshotForRole( hostAccessId: number, hostId: number, roleId: number, ownerId: string, ): Promise { const roleUserIds = await createCurrentRoleRepository().listRoleUserIds(roleId); for (const userId of roleUserIds) { try { await this.snapshotForUser(hostAccessId, hostId, userId, ownerId); } catch (error) { databaseLogger.error( "Failed to snapshot shared host secrets for role member", error, { operation: "shared_host_secrets_snapshot_role", hostAccessId, roleId, userId, }, ); } } } async snapshotForRoleMember( roleId: number, targetUserId: string, ): Promise { const hostsSharedWithRole = await createCurrentRbacAccessRepository().listRoleHostAccessCredentialSources( roleId, ); for (const sharedHost of hostsSharedWithRole) { try { await this.snapshotForUser( sharedHost.hostAccessId, sharedHost.hostId, targetUserId, sharedHost.hostOwnerId, ); } catch (error) { databaseLogger.error( "Failed to snapshot shared host secrets for role member", error, { operation: "shared_host_secrets_snapshot_role_member", roleId, targetUserId, hostId: sharedHost.hostId, }, ); } } } async snapshotForUserRoles(userId: string): Promise { const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId); for (const roleId of roleIds) { await this.snapshotForRoleMember(roleId, userId); } } // Re-snapshot every active grant on a host. Called after the owner (or an // editor) changes the host so recipients keep working secrets. async resyncHost(hostId: number): Promise { const hostResolutionRepository = createCurrentHostResolutionRepository(); const ownerId = await hostResolutionRepository.findHostOwnerId(hostId); if (!ownerId) return; const grants = await createCurrentRbacAccessRepository().listActiveHostAccessGrants( hostId, ); if (grants.length === 0) return; const roleRepository = createCurrentRoleRepository(); for (const grant of grants) { const targetUserIds = grant.userId ? [grant.userId] : grant.roleId ? await roleRepository.listRoleUserIds(grant.roleId) : []; for (const targetUserId of targetUserIds) { if (targetUserId === ownerId) continue; try { await this.snapshotForUser(grant.id, hostId, targetUserId, ownerId); } catch (error) { databaseLogger.warn( "Skipping shared host secret resync for recipient", { operation: "shared_host_secrets_resync_skip", hostId, hostAccessId: grant.id, targetUserId, error: getErrorMessage(error), }, ); } } } } // Re-snapshot every shared host that references a credential after the // owner edits that credential. async resyncHostsForCredential( credentialId: number, ownerId: string, ): Promise { try { const hostIds = await createCurrentSharedHostSecretsRepository().findHostIdsReferencingCredential( ownerId, credentialId, ); for (const hostId of hostIds) { await this.resyncHost(hostId); } } catch (error) { databaseLogger.error( "Failed to resync shared host secrets for credential", error, { operation: "shared_host_secrets_resync_credential", credentialId, }, ); } } async getSecretForUser( hostId: number, userId: string, protocol: ShareProtocol, ): Promise { try { const userDEK = DataCrypto.validateUserAccess(userId); const secret = await createCurrentSharedHostSecretsRepository().findForHostUserProtocol( hostId, userId, protocol, ); if (!secret) return null; return this.decryptSnapshot(secret, userDEK); } catch (error) { databaseLogger.error("Failed to get shared host secret", error, { operation: "shared_host_secrets_get", hostId, userId, protocol, }); throw error; } } async deleteForHostAccess(hostAccessId: number): Promise { try { await createCurrentSharedHostSecretsRepository().deleteByHostAccessId( hostAccessId, ); } catch (error) { databaseLogger.error( "Failed to delete shared host secrets for access grant", error, { operation: "shared_host_secrets_delete_access", hostAccessId, }, ); } } async deleteForUser(userId: string): Promise { try { await createCurrentSharedHostSecretsRepository().deleteByTargetUserId( userId, ); } catch (error) { databaseLogger.error( "Failed to delete shared host secrets for user", error, { operation: "shared_host_secrets_delete_user", userId, }, ); } } async deleteForCredential(credentialId: number): Promise { try { await createCurrentSharedHostSecretsRepository().deleteByOriginalCredentialId( credentialId, ); } catch (error) { databaseLogger.error( "Failed to delete shared host secrets for credential", error, { operation: "shared_host_secrets_delete_credential", credentialId, }, ); } } private async collectProtocolSnapshots( host: HostResolutionHostRecord, ownerId: string, ): Promise { const repository = createCurrentHostResolutionRepository(); const enabled = enabledProtocols(host); const snapshots: ProtocolSnapshot[] = []; if (enabled.ssh && host.shareSshAuth) { if (host.credentialId) { const credential = await repository.findCredentialByIdForUser( host.credentialId, ownerId, ); if (credential) { snapshots.push({ protocol: "ssh", sourceType: "credential", originalCredentialId: host.credentialId, data: { username: credential.username || undefined, authType: credential.authType, password: credential.password || undefined, key: credential.privateKey || credential.key || undefined, keyPassword: credential.keyPassword || undefined, keyType: credential.keyType || undefined, }, }); } } else if ( (host.authType === "password" && host.password) || (host.authType === "key" && host.key) ) { snapshots.push({ protocol: "ssh", sourceType: "inline", originalCredentialId: null, data: { username: host.username || undefined, authType: host.authType, password: host.password || undefined, key: host.key || undefined, keyPassword: host.keyPassword || undefined, keyType: host.keyType || undefined, }, }); } } if (enabled.rdp) { const rdpAuthType = host.rdpAuthType || (host.rdpCredentialId ? "credential" : "direct"); if (rdpAuthType === "credential" && host.rdpCredentialId) { const credential = await repository.findCredentialByIdForUser( host.rdpCredentialId, ownerId, ); if (credential) { snapshots.push({ protocol: "rdp", sourceType: "credential", originalCredentialId: host.rdpCredentialId, data: { username: credential.username || undefined, authType: "credential", password: credential.password || undefined, domain: host.rdpDomain || undefined, }, }); } } else if (host.rdpUser || host.rdpPassword) { snapshots.push({ protocol: "rdp", sourceType: "inline", originalCredentialId: null, data: { username: host.rdpUser || undefined, authType: "direct", password: host.rdpPassword || undefined, domain: host.rdpDomain || undefined, }, }); } } if (enabled.vnc) { const vncAuthType = host.vncAuthType || (host.vncCredentialId ? "credential" : "direct"); if (vncAuthType === "credential" && host.vncCredentialId) { const credential = await repository.findCredentialByIdForUser( host.vncCredentialId, ownerId, ); if (credential) { snapshots.push({ protocol: "vnc", sourceType: "credential", originalCredentialId: host.vncCredentialId, data: { username: credential.username || undefined, authType: "credential", password: credential.password || undefined, }, }); } } else if (host.vncUser || host.vncPassword) { snapshots.push({ protocol: "vnc", sourceType: "inline", originalCredentialId: null, data: { username: host.vncUser || undefined, authType: "direct", password: host.vncPassword || undefined, }, }); } } if (enabled.telnet) { const telnetAuthType = host.telnetAuthType || (host.telnetCredentialId ? "credential" : "direct"); if (telnetAuthType === "credential" && host.telnetCredentialId) { const credential = await repository.findCredentialByIdForUser( host.telnetCredentialId, ownerId, ); if (credential) { snapshots.push({ protocol: "telnet", sourceType: "credential", originalCredentialId: host.telnetCredentialId, data: { username: credential.username || undefined, authType: "credential", password: credential.password || undefined, }, }); } } else if (host.telnetUser || host.telnetPassword) { snapshots.push({ protocol: "telnet", sourceType: "inline", originalCredentialId: null, data: { username: host.telnetUser || undefined, authType: "direct", password: host.telnetPassword || undefined, }, }); } } return snapshots; } private decryptSnapshot( secret: SharedHostSecretRecord, userDEK: Buffer, ): SharedSecretData { const recordId = snapshotRecordId( secret.hostAccessId, secret.targetUserId, secret.protocol as ShareProtocol, ); const decrypt = (value: string | null, fieldName: string) => value ? FieldCrypto.decryptField(value, userDEK, recordId, fieldName) : undefined; return { username: decrypt(secret.encryptedUsername, "username"), authType: secret.encryptedAuthType || "password", password: decrypt(secret.encryptedPassword, "password"), key: decrypt(secret.encryptedKey, "key"), keyPassword: decrypt(secret.encryptedKeyPassword, "key_password"), keyType: secret.encryptedKeyType || undefined, domain: decrypt(secret.encryptedDomain, "domain"), }; } } export { SharedHostSecretsManager };