From abee78a6890dc3ca92b51d97810530daea9aee88 Mon Sep 17 00:00:00 2001 From: DeWitt Gibson Date: Sat, 4 Apr 2026 12:50:06 -0700 Subject: [PATCH] Add encrypted plugin settings support Introduce AES-256-GCM encryption for sensitive plugin settings. Changes: - Add PLUGIN_SETTINGS_ENCRYPTION_KEY to .env.example and instruct how to generate it. - New engine/plugin-settings-crypto.ts with encrypt/decrypt helpers (format: enc:::) and runtime validation of the key. - PluginManager and InstalledPluginsService now detect manifest setting definitions with encrypted: true and encrypt values on save. - InstalledPluginsService.getSettingsForContext added to return decrypted settings for plugin runtime, with graceful fallback on decryption errors. - SDK types updated to include encrypted?: true in plugin setting definitions. Why: Protect sensitive configuration (API keys, secrets) stored in plugin settings while allowing gradual migration of existing plaintext values. --- .env.example | 4 + .../modules/plugins/engine/plugin-manager.ts | 87 ++++++++++++++----- .../plugins/engine/plugin-settings-crypto.ts | 76 ++++++++++++++++ .../plugins/installed-plugins.service.ts | 55 +++++++++++- packages/plugins/sdk/src/index.ts | 1 + 5 files changed, 201 insertions(+), 22 deletions(-) create mode 100644 packages/core/src/modules/plugins/engine/plugin-settings-crypto.ts diff --git a/.env.example b/.env.example index 3227160..88e9712 100644 --- a/.env.example +++ b/.env.example @@ -79,6 +79,10 @@ SMTP_USER= SMTP_PASSWORD= EMAIL_FROM=noreply@agentbase.dev +# --- Plugin Settings Encryption (AES-256-GCM key for encrypted plugin settings) --- +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +PLUGIN_SETTINGS_ENCRYPTION_KEY= + # --- File Uploads (Phase 5) --- S3_BUCKET= S3_REGION=us-east-1 diff --git a/packages/core/src/modules/plugins/engine/plugin-manager.ts b/packages/core/src/modules/plugins/engine/plugin-manager.ts index e27419e..b3b3359 100644 --- a/packages/core/src/modules/plugins/engine/plugin-manager.ts +++ b/packages/core/src/modules/plugins/engine/plugin-manager.ts @@ -5,14 +5,16 @@ * install → activate → deactivate → uninstall */ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { HookEngine } from '../../hooks/hook.engine'; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { HookEngine } from "../../hooks/hook.engine"; import { InstalledPlugin, InstalledPluginStatus, -} from '../../../database/entities/installed-plugin.entity'; +} from "../../../database/entities/installed-plugin.entity"; +import { Plugin } from "../../../database/entities/plugin.entity"; +import { encryptSetting } from "./plugin-settings-crypto"; @Injectable() export class PluginManager { @@ -21,15 +23,22 @@ export class PluginManager { constructor( @InjectRepository(InstalledPlugin) private readonly installedRepo: Repository, + @InjectRepository(Plugin) + private readonly pluginRepo: Repository, private readonly hookEngine: HookEngine, ) {} - async install(applicationId: string, pluginId: string): Promise { + async install( + applicationId: string, + pluginId: string, + ): Promise { const existing = await this.installedRepo.findOne({ where: { applicationId, pluginId }, }); if (existing) { - this.logger.warn(`Plugin ${pluginId} already installed for app ${applicationId}`); + this.logger.warn( + `Plugin ${pluginId} already installed for app ${applicationId}`, + ); return existing; } @@ -41,26 +50,41 @@ export class PluginManager { }); const saved = await this.installedRepo.save(installed); - await this.hookEngine.doAction('plugin:installed', { applicationId, pluginId }); + await this.hookEngine.doAction("plugin:installed", { + applicationId, + pluginId, + }); this.logger.log(`Plugin installed: ${pluginId} for app ${applicationId}`); return saved; } - async activate(applicationId: string, pluginId: string): Promise { + async activate( + applicationId: string, + pluginId: string, + ): Promise { const installed = await this.findInstalled(applicationId, pluginId); installed.status = InstalledPluginStatus.ACTIVE; const saved = await this.installedRepo.save(installed); - await this.hookEngine.doAction('plugin:activated', { applicationId, pluginId }); + await this.hookEngine.doAction("plugin:activated", { + applicationId, + pluginId, + }); this.logger.log(`Plugin activated: ${pluginId} for app ${applicationId}`); return saved; } - async deactivate(applicationId: string, pluginId: string): Promise { + async deactivate( + applicationId: string, + pluginId: string, + ): Promise { const installed = await this.findInstalled(applicationId, pluginId); installed.status = InstalledPluginStatus.INACTIVE; const saved = await this.installedRepo.save(installed); this.hookEngine.removePluginHooks(pluginId); - await this.hookEngine.doAction('plugin:deactivated', { applicationId, pluginId }); + await this.hookEngine.doAction("plugin:deactivated", { + applicationId, + pluginId, + }); this.logger.log(`Plugin deactivated: ${pluginId} for app ${applicationId}`); return saved; } @@ -69,23 +93,28 @@ export class PluginManager { const installed = await this.findInstalled(applicationId, pluginId); this.hookEngine.removePluginHooks(pluginId); await this.installedRepo.remove(installed); - await this.hookEngine.doAction('plugin:uninstalled', { applicationId, pluginId }); - this.logger.log(`Plugin uninstalled: ${pluginId} from app ${applicationId}`); + await this.hookEngine.doAction("plugin:uninstalled", { + applicationId, + pluginId, + }); + this.logger.log( + `Plugin uninstalled: ${pluginId} from app ${applicationId}`, + ); } async listInstalled(applicationId: string): Promise { return this.installedRepo.find({ where: { applicationId }, - relations: ['plugin'], - order: { executionOrder: 'ASC', createdAt: 'ASC' }, + relations: ["plugin"], + order: { executionOrder: "ASC", createdAt: "ASC" }, }); } async listActive(applicationId: string): Promise { return this.installedRepo.find({ where: { applicationId, status: InstalledPluginStatus.ACTIVE }, - relations: ['plugin'], - order: { executionOrder: 'ASC' }, + relations: ["plugin"], + order: { executionOrder: "ASC" }, }); } @@ -95,7 +124,21 @@ export class PluginManager { settings: Record, ): Promise { const installed = await this.findInstalled(applicationId, pluginId); - installed.settings = { ...installed.settings, ...settings }; + + const plugin = await this.pluginRepo.findOne({ where: { id: pluginId } }); + const settingDefs = plugin?.manifest?.settings ?? {}; + + const encryptedSettings: Record = {}; + for (const [key, value] of Object.entries(settings)) { + const def = settingDefs[key]; + if (def?.encrypted === true && typeof value === "string") { + encryptedSettings[key] = encryptSetting(value); + } else { + encryptedSettings[key] = value; + } + } + + installed.settings = { ...installed.settings, ...encryptedSettings }; return this.installedRepo.save(installed); } @@ -105,10 +148,12 @@ export class PluginManager { ): Promise { const installed = await this.installedRepo.findOne({ where: { applicationId, pluginId }, - relations: ['plugin'], + relations: ["plugin"], }); if (!installed) { - throw new NotFoundException('Plugin is not installed for this application'); + throw new NotFoundException( + "Plugin is not installed for this application", + ); } return installed; } diff --git a/packages/core/src/modules/plugins/engine/plugin-settings-crypto.ts b/packages/core/src/modules/plugins/engine/plugin-settings-crypto.ts new file mode 100644 index 0000000..dec2b93 --- /dev/null +++ b/packages/core/src/modules/plugins/engine/plugin-settings-crypto.ts @@ -0,0 +1,76 @@ +import * as crypto from "crypto"; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 16; +const AUTH_TAG_LENGTH = 16; +const ENCRYPTED_PREFIX = "enc:"; + +function getKey(): Buffer { + const raw = process.env.PLUGIN_SETTINGS_ENCRYPTION_KEY; + if (!raw) { + throw new Error( + "PLUGIN_SETTINGS_ENCRYPTION_KEY is not set. " + + "Generate one with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"", + ); + } + const buf = Buffer.from(raw, "hex"); + if (buf.length !== 32) { + throw new Error( + "PLUGIN_SETTINGS_ENCRYPTION_KEY must be a 64-character hex string (32 bytes).", + ); + } + return buf; +} + +/** + * Encrypts a plaintext string using AES-256-GCM. + * Returns a string of the form: enc::: + */ +export function encryptSetting(plaintext: string): string { + const key = getKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + return ( + ENCRYPTED_PREFIX + + iv.toString("hex") + + ":" + + authTag.toString("hex") + + ":" + + encrypted.toString("hex") + ); +} + +/** + * Decrypts a value that was encrypted by encryptSetting(). + * If the value does not start with the "enc:" prefix it is returned as-is + * (allows gradual migration of settings that were saved before encryption was enabled). + */ +export function decryptSetting(value: string): string { + if (!value.startsWith(ENCRYPTED_PREFIX)) { + return value; + } + const key = getKey(); + const parts = value.slice(ENCRYPTED_PREFIX.length).split(":"); + if (parts.length !== 3) { + throw new Error("Invalid encrypted setting format."); + } + const [ivHex, authTagHex, ciphertextHex] = parts; + const iv = Buffer.from(ivHex, "hex"); + const authTag = Buffer.from(authTagHex, "hex").subarray(0, AUTH_TAG_LENGTH); + const ciphertext = Buffer.from(ciphertextHex, "hex"); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + return decipher.update(ciphertext).toString("utf8") + decipher.final("utf8"); +} + +/** + * Returns true if the value looks like an encrypted setting (has the enc: prefix). + */ +export function isEncryptedSetting(value: unknown): value is string { + return typeof value === "string" && value.startsWith(ENCRYPTED_PREFIX); +} diff --git a/packages/core/src/modules/plugins/installed-plugins.service.ts b/packages/core/src/modules/plugins/installed-plugins.service.ts index b74b929..49816ac 100644 --- a/packages/core/src/modules/plugins/installed-plugins.service.ts +++ b/packages/core/src/modules/plugins/installed-plugins.service.ts @@ -15,6 +15,10 @@ import { } from "../../database/entities"; import { HookEngine } from "../hooks/hook.engine"; import { LicenseValidatorService } from "../marketplace/license-validator.service"; +import { + encryptSetting, + decryptSetting, +} from "./engine/plugin-settings-crypto"; @Injectable() export class InstalledPluginsService { @@ -167,10 +171,59 @@ export class InstalledPluginsService { settings: Record, ): Promise { const installed = await this.getInstalled(applicationId, pluginId, ownerId); - installed.settings = { ...installed.settings, ...settings }; + + const plugin = await this.pluginRepo.findOne({ where: { id: pluginId } }); + const settingDefs = plugin?.manifest?.settings ?? {}; + + const encryptedSettings: Record = {}; + for (const [key, value] of Object.entries(settings)) { + const def = settingDefs[key]; + if (def?.encrypted === true && typeof value === "string") { + encryptedSettings[key] = encryptSetting(value); + } else { + encryptedSettings[key] = value; + } + } + + installed.settings = { ...installed.settings, ...encryptedSettings }; return this.installedRepo.save(installed); } + /** + * Returns the plugin's settings with encrypted fields decrypted. + * Use this when constructing a PluginContext so plugins receive plaintext values. + */ + async getSettingsForContext( + applicationId: string, + pluginId: string, + ): Promise> { + const installed = await this.installedRepo.findOne({ + where: { applicationId, pluginId }, + }); + if (!installed) return {}; + + const plugin = await this.pluginRepo.findOne({ where: { id: pluginId } }); + const settingDefs = plugin?.manifest?.settings ?? {}; + + const result: Record = {}; + for (const [key, value] of Object.entries(installed.settings ?? {})) { + const def = settingDefs[key]; + if (def?.encrypted === true && typeof value === "string") { + try { + result[key] = decryptSetting(value); + } catch { + this.logger.warn( + `Failed to decrypt setting "${key}" for plugin ${pluginId}`, + ); + result[key] = value; + } + } else { + result[key] = value; + } + } + return result; + } + async getInstalledPlugins( applicationId: string, ownerId: string, diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 7a2769f..4dbe89c 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -196,6 +196,7 @@ export interface PluginDefinition { label: string; default?: any; options?: string[]; + encrypted?: true; } >; /** Custom API endpoints registered by this plugin. */