diff --git a/backend-api/__tests__/channels.test.js b/backend-api/__tests__/channels.test.js index 7d771a8..8ad7e16 100644 --- a/backend-api/__tests__/channels.test.js +++ b/backend-api/__tests__/channels.test.js @@ -1,6 +1,14 @@ const mockDb = { query: jest.fn() }; +const mockEncrypt = jest.fn((value) => `enc(${value})`); +const mockDecrypt = jest.fn((value) => value.startsWith("enc(") ? value.slice(4, -1) : value); +const mockEnsureEncryptionConfigured = jest.fn(); jest.mock("../db", () => mockDb); +jest.mock("../crypto", () => ({ + encrypt: mockEncrypt, + decrypt: mockDecrypt, + ensureEncryptionConfigured: mockEnsureEncryptionConfigured, +})); jest.mock("../../agent-runtime/lib/contracts", () => ({ agentRuntimeUrl: jest.fn(() => "http://runtime.test"), })); @@ -10,6 +18,9 @@ const channels = require("../channels"); describe("channel secret redaction", () => { beforeEach(() => { mockDb.query.mockReset(); + mockEncrypt.mockClear(); + mockDecrypt.mockClear(); + mockEnsureEncryptionConfigured.mockClear(); }); it("redacts password and secret-like fields when creating a channel", async () => { @@ -62,20 +73,35 @@ describe("channel secret redaction", () => { }); it("redacts webhook URLs and password fields when updating a channel", async () => { - mockDb.query.mockResolvedValueOnce({ - rows: [{ - id: "ch-3", - agent_id: "agent-1", - type: "slack", - name: "Ops Slack", - config: { - webhook_url: "https://hooks.slack.test/secret", - bot_token: "xoxb-secret", - channel: "#ops", - }, - enabled: true, - }], - }); + mockDb.query + .mockResolvedValueOnce({ + rows: [{ + id: "ch-3", + agent_id: "agent-1", + type: "slack", + name: "Ops Slack", + config: { + webhook_url: "enc(https://hooks.slack.test/secret)", + bot_token: "enc(xoxb-secret)", + channel: "#ops", + }, + enabled: true, + }], + }) + .mockResolvedValueOnce({ + rows: [{ + id: "ch-3", + agent_id: "agent-1", + type: "slack", + name: "Ops Slack", + config: { + webhook_url: "enc(https://hooks.slack.test/secret)", + bot_token: "enc(xoxb-secret)", + channel: "#ops", + }, + enabled: true, + }], + }); const result = await channels.updateChannel("ch-3", "agent-1", { config: { diff --git a/backend-api/__tests__/channelsEncryption.test.js b/backend-api/__tests__/channelsEncryption.test.js new file mode 100644 index 0000000..ce1a546 --- /dev/null +++ b/backend-api/__tests__/channelsEncryption.test.js @@ -0,0 +1,135 @@ +const mockDb = { query: jest.fn() }; +const mockEncrypt = jest.fn((value) => `enc(${value})`); +const mockDecrypt = jest.fn((value) => value.startsWith("enc(") ? value.slice(4, -1) : value); +const mockEnsureEncryptionConfigured = jest.fn(); +const mockSend = jest.fn().mockResolvedValue({ delivered: true }); + +jest.mock("../db", () => mockDb); +jest.mock("../crypto", () => ({ + encrypt: mockEncrypt, + decrypt: mockDecrypt, + ensureEncryptionConfigured: mockEnsureEncryptionConfigured, +})); +jest.mock("../../agent-runtime/lib/contracts", () => ({ + agentRuntimeUrl: jest.fn(() => "http://runtime.test"), +})); +jest.mock("../channels/adapters", () => ({ + getAdapter: jest.fn((type) => ({ + type, + configFields: [ + { key: "bot_token", type: "password" }, + { key: "access_token", type: "password" }, + { key: "channel_access_token", type: "password" }, + { key: "channel_secret", type: "password" }, + { key: "verify_token", type: "text" }, + { key: "webhook_url", type: "url" }, + ], + send: mockSend, + verify: jest.fn().mockResolvedValue({ valid: true }), + formatInbound: jest.fn((payload) => ({ content: payload.text || "ok", sender: "tester", metadata: {} })), + })), + listAdapterTypes: jest.fn(() => []), +})); + +const channels = require("../channels"); + +describe("channel config encryption", () => { + beforeEach(() => { + mockDb.query.mockReset(); + mockEncrypt.mockClear(); + mockDecrypt.mockClear(); + mockEnsureEncryptionConfigured.mockClear(); + mockSend.mockClear(); + }); + + it("encrypts sensitive config keys before storing a channel", async () => { + mockDb.query.mockResolvedValueOnce({ + rows: [{ + id: "ch-1", + agent_id: "agent-1", + type: "telegram", + name: "Ops Telegram", + config: { bot_token: "enc(secret-token)", chat_id: "42" }, + enabled: true, + }], + }); + + const result = await channels.createChannel("agent-1", "telegram", "Ops Telegram", { + bot_token: "secret-token", + chat_id: "42", + }); + + expect(mockEnsureEncryptionConfigured).toHaveBeenCalledWith("Channel credential storage"); + expect(mockEncrypt).toHaveBeenCalledWith("secret-token"); + expect(JSON.parse(mockDb.query.mock.calls[0][1][3])).toEqual({ + bot_token: "enc(secret-token)", + chat_id: "42", + }); + expect(result.config).toEqual({ bot_token: "[REDACTED]", chat_id: "42" }); + }); + + it("decrypts stored secrets before adapter send", async () => { + mockDb.query + .mockResolvedValueOnce({ + rows: [{ + id: "ch-2", + agent_id: "agent-1", + type: "telegram", + enabled: true, + config: { bot_token: "enc(secret-token)", chat_id: "42" }, + }], + }) + .mockResolvedValueOnce({ rows: [] }); + + await channels.sendMessage("ch-2", "hello", { to: "42" }); + + expect(mockDecrypt).toHaveBeenCalledWith("enc(secret-token)"); + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + config: { bot_token: "secret-token", chat_id: "42" }, + }), + "hello", + { to: "42" } + ); + }); + + it("encrypts secret-like non-password keys such as verify_token on update", async () => { + mockDb.query + .mockResolvedValueOnce({ + rows: [{ + id: "ch-3", + agent_id: "agent-1", + type: "whatsapp", + enabled: true, + config: { phone_number_id: "pn_123" }, + }], + }) + .mockResolvedValueOnce({ + rows: [{ + id: "ch-3", + agent_id: "agent-1", + type: "whatsapp", + enabled: true, + config: { phone_number_id: "pn_123", access_token: "enc(wa-secret)", verify_token: "enc(verify-me)" }, + }], + }); + + const result = await channels.updateChannel("ch-3", "agent-1", { + config: { phone_number_id: "pn_123", access_token: "wa-secret", verify_token: "verify-me" }, + }); + + expect(mockEnsureEncryptionConfigured).toHaveBeenCalledWith("Channel credential storage"); + expect(mockEncrypt).toHaveBeenCalledWith("wa-secret"); + expect(mockEncrypt).toHaveBeenCalledWith("verify-me"); + expect(JSON.parse(mockDb.query.mock.calls[1][1][0])).toEqual({ + phone_number_id: "pn_123", + access_token: "enc(wa-secret)", + verify_token: "enc(verify-me)", + }); + expect(result.config).toEqual({ + phone_number_id: "pn_123", + access_token: "[REDACTED]", + verify_token: "[REDACTED]", + }); + }); +}); diff --git a/backend-api/channels/index.js b/backend-api/channels/index.js index d10516e..093e5e5 100644 --- a/backend-api/channels/index.js +++ b/backend-api/channels/index.js @@ -3,6 +3,7 @@ */ const db = require("../db"); +const { encrypt, decrypt, ensureEncryptionConfigured } = require("../crypto"); const { agentRuntimeUrl } = require("../../agent-runtime/lib/contracts"); const { getAdapter, listAdapterTypes } = require("./adapters"); @@ -13,18 +14,56 @@ function parseConfig(config) { return typeof config === "string" ? JSON.parse(config) : (config || {}); } -function redactChannelConfig(type, config = {}) { +function getSensitiveChannelKeys(type) { const adapter = getAdapter(type); - const parsed = parseConfig(config); - const redacted = { ...parsed }; - const passwordKeys = new Set( + return new Set( (adapter.configFields || []) - .filter((field) => field?.type === "password") + .filter((field) => field?.type === "password" || SECRET_CONFIG_KEY_RE.test(field?.key || "")) .map((field) => field.key) ); +} + +function protectChannelConfig(type, config = {}) { + const parsed = parseConfig(config); + const sensitiveKeys = getSensitiveChannelKeys(type); + const secured = { ...parsed }; + let hasSensitiveMaterial = false; + + for (const key of Object.keys(secured)) { + const value = secured[key]; + if (!value) continue; + if (sensitiveKeys.has(key) || SECRET_CONFIG_KEY_RE.test(key)) { + hasSensitiveMaterial = true; + secured[key] = encrypt(String(value)); + } + } + + return { secured, hasSensitiveMaterial }; +} + +function revealChannelConfig(type, config = {}) { + const parsed = parseConfig(config); + const sensitiveKeys = getSensitiveChannelKeys(type); + const revealed = { ...parsed }; + + for (const key of Object.keys(revealed)) { + const value = revealed[key]; + if (!value) continue; + if (sensitiveKeys.has(key) || SECRET_CONFIG_KEY_RE.test(key)) { + revealed[key] = decrypt(String(value)); + } + } + + return revealed; +} + +function redactChannelConfig(type, config = {}) { + const parsed = parseConfig(config); + const redacted = { ...parsed }; + const sensitiveKeys = getSensitiveChannelKeys(type); for (const key of Object.keys(redacted)) { - if ((passwordKeys.has(key) || SECRET_CONFIG_KEY_RE.test(key)) && redacted[key]) { + if ((sensitiveKeys.has(key) || SECRET_CONFIG_KEY_RE.test(key)) && redacted[key]) { redacted[key] = REDACTED_SECRET; } } @@ -40,6 +79,14 @@ function sanitizeChannel(channel) { }; } +function hydrateChannel(channel) { + if (!channel) return channel; + return { + ...channel, + config: revealChannelConfig(channel.type, channel.config), + }; +} + // ── Channel CRUD ───────────────────────────────────────── async function listChannels(agentId) { @@ -53,14 +100,25 @@ async function listChannels(agentId) { async function createChannel(agentId, type, name, config = {}) { // Verify the adapter type exists getAdapter(type); + const { secured, hasSensitiveMaterial } = protectChannelConfig(type, config); + if (hasSensitiveMaterial) { + ensureEncryptionConfigured("Channel credential storage"); + } const result = await db.query( "INSERT INTO channels(agent_id, type, name, config) VALUES($1, $2, $3, $4) RETURNING *", - [agentId, type, name, JSON.stringify(config)] + [agentId, type, name, JSON.stringify(secured)] ); return sanitizeChannel(result.rows[0]); } async function updateChannel(channelId, agentId, updates) { + const existingResult = await db.query( + "SELECT * FROM channels WHERE id = $1 AND agent_id = $2", + [channelId, agentId] + ); + const existing = existingResult.rows[0]; + if (!existing) throw new Error("Channel not found"); + const sets = []; const params = []; let idx = 1; @@ -70,8 +128,12 @@ async function updateChannel(channelId, agentId, updates) { params.push(updates.name); } if (updates.config !== undefined) { + const { secured, hasSensitiveMaterial } = protectChannelConfig(existing.type, updates.config); sets.push(`config = $${idx++}`); - params.push(JSON.stringify(updates.config)); + params.push(JSON.stringify(secured)); + if (hasSensitiveMaterial) { + ensureEncryptionConfigured("Channel credential storage"); + } } if (updates.enabled !== undefined) { sets.push(`enabled = $${idx++}`); @@ -101,7 +163,7 @@ async function deleteChannel(channelId, agentId) { async function sendMessage(channelId, content, metadata = {}) { const chResult = await db.query("SELECT * FROM channels WHERE id = $1", [channelId]); - const channel = chResult.rows[0]; + const channel = hydrateChannel(chResult.rows[0]); if (!channel) throw new Error("Channel not found"); if (!channel.enabled) throw new Error("Channel is disabled"); @@ -137,7 +199,7 @@ async function testChannel(channelId, agentId) { "SELECT * FROM channels WHERE id = $1 AND agent_id = $2", [channelId, agentId] ); - const channel = chResult.rows[0]; + const channel = hydrateChannel(chResult.rows[0]); if (!channel) throw new Error("Channel not found"); const adapter = getAdapter(channel.type); @@ -159,7 +221,7 @@ async function testChannel(channelId, agentId) { async function handleInboundWebhook(channelId, payload, headers) { const chResult = await db.query("SELECT * FROM channels WHERE id = $1", [channelId]); - const channel = chResult.rows[0]; + const channel = hydrateChannel(chResult.rows[0]); if (!channel) throw new Error("Channel not found"); if (!channel.enabled) throw new Error("Channel is disabled"); @@ -217,4 +279,7 @@ module.exports = { getChannelTypes, redactChannelConfig, sanitizeChannel, + protectChannelConfig, + revealChannelConfig, + hydrateChannel, };