diff --git a/packages/gateway/src/auth/settings/agent-settings-store.ts b/packages/gateway/src/auth/settings/agent-settings-store.ts index 899f90237..c72d469f5 100644 --- a/packages/gateway/src/auth/settings/agent-settings-store.ts +++ b/packages/gateway/src/auth/settings/agent-settings-store.ts @@ -1,16 +1,27 @@ import { type AuthProfile, BaseRedisStore, + decrypt, + encrypt, type InstalledProvider, type McpServerConfig, type NetworkConfig, type NixConfig, type PluginsConfig, type SkillsConfig, + safeJsonParse, + safeJsonStringify, type ToolsConfig, } from "@lobu/core"; import type Redis from "ioredis"; +const ENCRYPTED_VALUE_PREFIX = "enc:v1:"; + +interface SensitiveValueDecodeResult { + value: string; + needsMigration: boolean; +} + /** * Agent settings - configurable per agentId via web UI * Stored in Redis at agent:settings:{agentId} @@ -59,12 +70,16 @@ export interface AgentSettings { * - Explicit (from channel binding): any custom agentId */ export class AgentSettingsStore extends BaseRedisStore { + private readonly encryptionAvailable: boolean; + constructor(redis: Redis) { super({ redis, keyPrefix: "agent:settings", loggerName: "agent-settings-store", }); + + this.encryptionAvailable = this.canEncryptSensitiveValues(); } /** @@ -73,7 +88,33 @@ export class AgentSettingsStore extends BaseRedisStore { */ async getSettings(agentId: string): Promise { const key = this.buildKey(agentId); - return this.get(key); + try { + const data = await this.redis.get(key); + if (!data) { + return null; + } + + const parsed = safeJsonParse(data); + if (!parsed) { + this.logger.warn("Failed to parse agent settings from Redis", { key }); + return null; + } + + const { settings, needsMigration } = + this.decryptSettingsForRuntime(parsed); + + if (needsMigration) { + await this.migrateSettingsInPlace(key, settings, data); + } + + return settings; + } catch (error) { + this.logger.error("Failed to get settings from Redis", { + error: error instanceof Error ? error.message : String(error), + key, + }); + return null; + } } /** @@ -127,4 +168,254 @@ export class AgentSettingsStore extends BaseRedisStore { const key = this.buildKey(agentId); return this.exists(key); } + + protected override serialize(value: AgentSettings): string { + const encrypted = this.encryptSettingsForStorage(value); + const json = safeJsonStringify(encrypted); + if (json === null) { + throw new Error("Failed to serialize value to JSON"); + } + return json; + } + + private canEncryptSensitiveValues(): boolean { + try { + const probe = "agent-settings-store-encryption-probe"; + return decrypt(encrypt(probe)) === probe; + } catch { + this.logger.warn( + "ENCRYPTION_KEY not configured or invalid - auth profile credentials will be stored unencrypted" + ); + return false; + } + } + + private async migrateSettingsInPlace( + key: string, + settings: AgentSettings, + originalRaw: string + ): Promise { + if (!this.encryptionAvailable) { + return; + } + + try { + await this.redis.watch(key); + const currentRaw = await this.redis.get(key); + if (currentRaw !== originalRaw) { + await this.redis.unwatch(); + this.logger.info( + "Skipped credentials migration due to concurrent settings update", + { key } + ); + return; + } + + const serialized = this.serialize(settings); + const result = await this.redis.multi().set(key, serialized).exec(); + if (!result) { + this.logger.info( + "Skipped credentials migration due to concurrent settings update", + { key } + ); + return; + } + + this.logger.info( + "Migrated agent settings credentials to encrypted format", + { + key, + } + ); + } catch (error) { + try { + await this.redis.unwatch(); + } catch { + // Ignore cleanup failures. + } + this.logger.warn( + "Failed migrating plaintext agent settings credentials", + { + error: error instanceof Error ? error.message : String(error), + key, + } + ); + } + } + + private encryptSettingsForStorage(settings: AgentSettings): AgentSettings { + if ( + !Array.isArray(settings.authProfiles) || + settings.authProfiles.length === 0 + ) { + return settings; + } + + let changed = false; + const authProfiles = settings.authProfiles.map((profile) => { + const encryptedCredential = this.encryptSensitiveValue( + profile.credential + ); + const credentialChanged = encryptedCredential !== profile.credential; + let metadataChanged = false; + let metadata = profile.metadata; + + if (profile.metadata?.refreshToken) { + const encryptedRefreshToken = this.encryptSensitiveValue( + profile.metadata.refreshToken + ); + if (encryptedRefreshToken !== profile.metadata.refreshToken) { + metadataChanged = true; + metadata = { + ...profile.metadata, + refreshToken: encryptedRefreshToken, + }; + } + } + + if (!credentialChanged && !metadataChanged) { + return profile; + } + + changed = true; + return { + ...profile, + credential: encryptedCredential, + metadata, + }; + }); + + if (!changed) { + return settings; + } + + return { + ...settings, + authProfiles, + }; + } + + private decryptSettingsForRuntime(settings: AgentSettings): { + settings: AgentSettings; + needsMigration: boolean; + } { + if ( + !Array.isArray(settings.authProfiles) || + settings.authProfiles.length === 0 + ) { + return { settings, needsMigration: false }; + } + + let changed = false; + let needsMigration = false; + + const authProfiles = settings.authProfiles.map((profile) => { + const credential = this.decryptSensitiveValue(profile.credential); + let metadata = profile.metadata; + let metadataChanged = false; + + if (profile.metadata?.refreshToken) { + const refreshToken = this.decryptSensitiveValue( + profile.metadata.refreshToken + ); + if (refreshToken.value !== profile.metadata.refreshToken) { + metadataChanged = true; + metadata = { + ...profile.metadata, + refreshToken: refreshToken.value, + }; + } + needsMigration ||= refreshToken.needsMigration; + } + + if (credential.value !== profile.credential || metadataChanged) { + changed = true; + } + + needsMigration ||= credential.needsMigration; + + if (!metadataChanged && credential.value === profile.credential) { + return profile; + } + + return { + ...profile, + credential: credential.value, + metadata, + }; + }); + + if (!changed) { + return { settings, needsMigration }; + } + + return { + settings: { + ...settings, + authProfiles, + }, + needsMigration, + }; + } + + private encryptSensitiveValue(value: string): string { + if (!this.encryptionAvailable) { + return value; + } + if (value.startsWith(ENCRYPTED_VALUE_PREFIX)) { + return value; + } + return `${ENCRYPTED_VALUE_PREFIX}${encrypt(value)}`; + } + + private decryptSensitiveValue(value: string): SensitiveValueDecodeResult { + if (value.startsWith(ENCRYPTED_VALUE_PREFIX)) { + if (!this.encryptionAvailable) { + return { value, needsMigration: false }; + } + + try { + const decrypted = decrypt(value.slice(ENCRYPTED_VALUE_PREFIX.length)); + return { value: decrypted, needsMigration: false }; + } catch (error) { + this.logger.warn("Failed to decrypt auth profile credential value", { + error: error instanceof Error ? error.message : String(error), + }); + return { value, needsMigration: false }; + } + } + + if (this.encryptionAvailable && this.looksLikeLegacyEncryptedValue(value)) { + try { + const decrypted = decrypt(value); + return { value: decrypted, needsMigration: true }; + } catch (error) { + this.logger.warn( + "Failed to decrypt legacy auth profile credential value", + { + error: error instanceof Error ? error.message : String(error), + } + ); + return { value, needsMigration: false }; + } + } + + return { + value, + needsMigration: this.encryptionAvailable, + }; + } + + private looksLikeLegacyEncryptedValue(value: string): boolean { + const [iv, tag, encrypted, ...rest] = value.split(":"); + if (rest.length > 0) return false; + if (!iv || !tag || !encrypted) return false; + return ( + iv.length === 24 && + tag.length === 32 && + /^[0-9a-f]+$/i.test(iv) && + /^[0-9a-f]+$/i.test(tag) && + /^[0-9a-f]+$/i.test(encrypted) + ); + } }