diff --git a/.env.example b/.env.example index 33d84d0e0..1acd4944e 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,6 @@ INSTALLER_ENABLED=false # Token opcional para proteger o instalador (se definido, passa a ser obrigatorio). INSTALLER_TOKEN= + +# Chave para encriptar as chaves de api no banco de dados evitando deixá-las expostas +ENCRYPTION_SECRET="sua_chave_secreta_longa_e_aleatoria_aqui" diff --git a/app/api/ai/actions/route.ts b/app/api/ai/actions/route.ts index 7ad32df5b..f7e98ca94 100644 --- a/app/api/ai/actions/route.ts +++ b/app/api/ai/actions/route.ts @@ -17,6 +17,7 @@ import { getModel, type AIProvider } from '@/lib/ai/config'; import { z } from 'zod'; import { createClient } from '@/lib/supabase/server'; import { isAllowedOrigin } from '@/lib/security/sameOrigin'; +import { decrypt } from '@/lib/security/encryption'; import { getResolvedPrompt } from '@/lib/ai/prompts/server'; import { renderPromptTemplate } from '@/lib/ai/prompts/render'; import { isAIFeatureEnabled } from '@/lib/ai/features/server'; @@ -213,12 +214,18 @@ export async function POST(req: Request) { // Frontend expects "AI consent required" as a *payload* error. const provider: AIProvider = (orgSettings?.ai_provider ?? 'google') as AIProvider; - const apiKey: string | null = - provider === 'google' - ? (orgSettings?.ai_google_key ?? null) - : provider === 'openai' - ? (orgSettings?.ai_openai_key ?? null) - : (orgSettings?.ai_anthropic_key ?? null); + let apiKey: string | null = null; + try { + apiKey = + provider === 'google' + ? (orgSettings?.ai_google_key ? decrypt(orgSettings.ai_google_key) : null) + : provider === 'openai' + ? (orgSettings?.ai_openai_key ? decrypt(orgSettings.ai_openai_key) : null) + : (orgSettings?.ai_anthropic_key ? decrypt(orgSettings.ai_anthropic_key) : null); + } catch (e) { + console.error(`[api/ai/actions] Failed to decrypt API key for provider ${provider}:`, e); + return json({ error: 'Failed to decrypt AI API key' }, 500); + } if (orgError || !apiKey) { return json({ error: 'AI consent required', consentType: 'AI_CONSENT' }, 200); diff --git a/app/api/ai/chat/route.ts b/app/api/ai/chat/route.ts index 0538720a6..f454f548b 100644 --- a/app/api/ai/chat/route.ts +++ b/app/api/ai/chat/route.ts @@ -7,6 +7,7 @@ import { createClient } from '@/lib/supabase/server'; import { AI_DEFAULT_MODELS } from '@/lib/ai/defaults'; import type { CRMCallOptions } from '@/types/ai'; import { isAllowedOrigin } from '@/lib/security/sameOrigin'; +import { decrypt } from '@/lib/security/encryption'; import { isAIFeatureEnabled } from '@/lib/ai/features/server'; export const maxDuration = 60; @@ -156,12 +157,18 @@ export async function POST(req: Request) { const provider = (orgSettings?.ai_provider ?? 'google') as AIProvider; const modelId: string | null = orgSettings?.ai_model ?? null; - const apiKey: string | null = - provider === 'google' - ? (orgSettings?.ai_google_key ?? null) - : provider === 'openai' - ? (orgSettings?.ai_openai_key ?? null) - : (orgSettings?.ai_anthropic_key ?? null); + let apiKey: string | null = null; + try { + apiKey = + provider === 'google' + ? (orgSettings?.ai_google_key ? decrypt(orgSettings.ai_google_key) : null) + : provider === 'openai' + ? (orgSettings?.ai_openai_key ? decrypt(orgSettings.ai_openai_key) : null) + : (orgSettings?.ai_anthropic_key ? decrypt(orgSettings.ai_anthropic_key) : null); + } catch (e) { + console.error(`[api/ai/chat] Failed to decrypt API key for provider ${provider}:`, e); + return new Response('Failed to decrypt AI API key', { status: 500 }); + } if (!apiKey) { const providerLabel = provider === 'google' ? 'Google Gemini' : provider === 'openai' ? 'OpenAI' : 'Anthropic'; diff --git a/app/api/settings/ai/route.ts b/app/api/settings/ai/route.ts index f8e4022ac..44f8b954e 100644 --- a/app/api/settings/ai/route.ts +++ b/app/api/settings/ai/route.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { createClient } from '@/lib/supabase/server'; import { isAllowedOrigin } from '@/lib/security/sameOrigin'; +import { encrypt, decrypt } from '@/lib/security/encryption'; import { AI_DEFAULT_MODELS } from '@/lib/ai/defaults'; function json(body: T, status = 200): Response { @@ -81,9 +82,9 @@ export async function GET() { aiEnabled, aiProvider: (orgSettings?.ai_provider || 'google') as Provider, aiModel: orgSettings?.ai_model || AI_DEFAULT_MODELS.google, - aiGoogleKey: orgSettings?.ai_google_key || '', - aiOpenaiKey: orgSettings?.ai_openai_key || '', - aiAnthropicKey: orgSettings?.ai_anthropic_key || '', + aiGoogleKey: orgSettings?.ai_google_key ? decrypt(orgSettings.ai_google_key) : '', + aiOpenaiKey: orgSettings?.ai_openai_key ? decrypt(orgSettings.ai_openai_key) : '', + aiAnthropicKey: orgSettings?.ai_anthropic_key ? decrypt(orgSettings.ai_anthropic_key) : '', aiHasGoogleKey: Boolean(orgSettings?.ai_google_key), aiHasOpenaiKey: Boolean(orgSettings?.ai_openai_key), aiHasAnthropicKey: Boolean(orgSettings?.ai_anthropic_key), @@ -151,13 +152,13 @@ export async function POST(req: Request) { if (updates.aiModel !== undefined) dbUpdates.ai_model = updates.aiModel; const googleKey = normalizeKey(updates.aiGoogleKey); - if (googleKey !== undefined) dbUpdates.ai_google_key = googleKey; + if (googleKey !== undefined) dbUpdates.ai_google_key = googleKey ? encrypt(googleKey) : null; const openaiKey = normalizeKey(updates.aiOpenaiKey); - if (openaiKey !== undefined) dbUpdates.ai_openai_key = openaiKey; + if (openaiKey !== undefined) dbUpdates.ai_openai_key = openaiKey ? encrypt(openaiKey) : null; const anthropicKey = normalizeKey(updates.aiAnthropicKey); - if (anthropicKey !== undefined) dbUpdates.ai_anthropic_key = anthropicKey; + if (anthropicKey !== undefined) dbUpdates.ai_anthropic_key = anthropicKey ? encrypt(anthropicKey) : null; const { error: upsertError } = await supabase .from('organization_settings') diff --git a/lib/security/encryption.ts b/lib/security/encryption.ts new file mode 100644 index 000000000..79d499f4d --- /dev/null +++ b/lib/security/encryption.ts @@ -0,0 +1,62 @@ +import crypto from 'crypto'; + +const ALGORITHM = 'aes-256-cbc'; +const IV_LENGTH = 16; // For AES, this is always 16 bytes + +/** + * Encrypts a plaintext string using AES-256-CBC. + * The encryption key is derived from the ENCRYPTION_SECRET environment variable. + * The output is a base64-encoded string in the format "ciphertext:iv". + * + * @param text The plaintext string to encrypt. + * @returns The encrypted string in "ciphertext:iv" format (base64-encoded). + * @throws Error if ENCRYPTION_SECRET is not set. + */ +export function encrypt(text: string): string { + const ENCRYPTION_SECRET = process.env.ENCRYPTION_SECRET; + if (!ENCRYPTION_SECRET) { + throw new Error('ENCRYPTION_SECRET environment variable is not set.'); + } + + // Use a consistent key length for AES-256 (32 bytes) + const key = crypto.scryptSync(ENCRYPTION_SECRET, 'salt', 32); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + return `${encrypted}:${iv.toString('hex')}`; +} + +/** + * Decrypts an encrypted string (in "ciphertext:iv" format) using AES-256-CBC. + * The encryption key is derived from the ENCRYPTION_SECRET environment variable. + * + * @param encryptedText The encrypted string in "ciphertext:iv" format (base64-encoded). + * @returns The decrypted plaintext string. + * @throws Error if ENCRYPTION_SECRET is not set or if the format is invalid. + */ +export function decrypt(encryptedText: string): string { + const ENCRYPTION_SECRET = process.env.ENCRYPTION_SECRET; + if (!ENCRYPTION_SECRET) { + throw new Error('ENCRYPTION_SECRET environment variable is not set.'); + } + + const parts = encryptedText.split(':'); + if (parts.length !== 2) { + throw new Error('Invalid encrypted text format. Expected "ciphertext:iv".'); + } + + const encrypted = parts[0]; + const iv = Buffer.from(parts[1], 'hex'); + + // Use a consistent key length for AES-256 (32 bytes) + const key = crypto.scryptSync(ENCRYPTION_SECRET, 'salt', 32); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} diff --git a/supabase/migrations/20251201000000_schema_init.sql b/supabase/migrations/20251201000000_schema_init.sql index beb27f747..3555217b9 100644 --- a/supabase/migrations/20251201000000_schema_init.sql +++ b/supabase/migrations/20251201000000_schema_init.sql @@ -1759,7 +1759,7 @@ RETURNS TEXT LANGUAGE sql SECURITY DEFINER AS $$ - SELECT encode(digest(token, 'sha256'), 'hex'); + SELECT encode(extensions.digest(token::bytea, 'sha256'), 'hex'); $$; -- Create API key (admin via UI) - returns the token ONCE diff --git a/supabase/migrations/20260402224235_encrypt_ai_api_keys.sql b/supabase/migrations/20260402224235_encrypt_ai_api_keys.sql new file mode 100644 index 000000000..f5b4a3c6b --- /dev/null +++ b/supabase/migrations/20260402224235_encrypt_ai_api_keys.sql @@ -0,0 +1,8 @@ +-- This migration marks the AI API key columns in organization_settings +-- as intended for encrypted storage. The application layer will handle +-- encryption and decryption. + +-- Add comments to the columns for clarity +COMMENT ON COLUMN public.organization_settings.ai_google_key IS 'Encrypted Google/Gemini API key (ciphertext:iv)'; +COMMENT ON COLUMN public.organization_settings.ai_openai_key IS 'Encrypted OpenAI API key (ciphertext:iv)'; +COMMENT ON COLUMN public.organization_settings.ai_anthropic_key IS 'Encrypted Anthropic/Claude API key (ciphertext:iv)';