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"; import { findUsableCredential } from "../hosts/usable-credential.js"; import { listProtocolLogins } from "../hosts/protocol-auth/protocol-auth.js"; export interface SharedSecretData { username?: string; authType: string; password?: string; key?: string; keyPassword?: string; keyType?: string; domain?: string; /** A plugin protocol's declared credential fields. */ fields?: Record; } 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. A plugin protocol's switch is that plugin's // host setting, and the editor removes a protocol's login when it is // switched off, so a snapshot is taken whenever a login is stored. function sshEnabled(host: HostResolutionHostRecord): boolean { const ct = host.connectionType; return host.enableSsh !== false && (!ct || ct === "ssh"); } // 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. /** * A snapshot's protocol fields. Snapshots taken before 2.9.0 kept a domain * in its own column and nothing else. */ function parseFields( value: string | undefined, legacyDomain: string | undefined, ): Record | undefined { if (value) { try { const parsed = JSON.parse(value); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { return parsed as Record; } } catch { // Unreadable fields are left out. } } return legacyDomain ? { domain: legacyDomain } : undefined; } 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"), encryptedFields: encrypt( snapshot.data.fields ? JSON.stringify(snapshot.data.fields) : "", "fields", ), }); } 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 snapshots: ProtocolSnapshot[] = []; if (sshEnabled(host) && host.shareSshAuth) { if (host.credentialId) { const credential = await findUsableCredential( 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, }, }); } } for (const login of await listProtocolLogins(host.id, ownerId)) { const fields = { ...login.fields, ...login.secretFields }; const hasFields = Object.keys(fields).length > 0; if (login.authType === "credential" && login.credentialId) { const credential = await findUsableCredential( login.credentialId, ownerId, ); if (credential) { snapshots.push({ protocol: login.protocol, sourceType: "credential", originalCredentialId: login.credentialId, data: { username: credential.username || undefined, authType: "credential", password: credential.password || undefined, fields: hasFields ? fields : undefined, }, }); } } else if ( login.authType === "direct" && (login.username || login.password || hasFields) ) { snapshots.push({ protocol: login.protocol, sourceType: "inline", originalCredentialId: null, data: { username: login.username || undefined, authType: "direct", password: login.password || undefined, fields: hasFields ? fields : 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; const domain = decrypt(secret.encryptedDomain, "domain"); 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, fields: parseFields(decrypt(secret.encryptedFields, "fields"), domain), }; } } export { SharedHostSecretsManager };