Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines +35 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove quotes from the example value and add key requirements.

The double quotes around the placeholder value will be interpreted literally in .env files, causing the actual secret to include the quote characters. Also, consider documenting the recommended key entropy.

Suggested fix
 # Chave para encriptar as chaves de api no banco de dados evitando deixá-las expostas
-ENCRYPTION_SECRET="sua_chave_secreta_longa_e_aleatoria_aqui"
+# Use a strong random string (minimum 32 characters recommended). Generate with: openssl rand -base64 32
+ENCRYPTION_SECRET=
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Chave para encriptar as chaves de api no banco de dados evitando deixá-las expostas
ENCRYPTION_SECRET="sua_chave_secreta_longa_e_aleatoria_aqui"
# Chave para encriptar as chaves de api no banco de dados evitando deixá-las expostas
# Use a strong random string (minimum 32 characters recommended). Generate with: openssl rand -base64 32
ENCRYPTION_SECRET=
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 37-37: [QuoteCharacter] The value has quote characters (', ")

(QuoteCharacter)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.env.example around lines 35 - 37, Update the .env.example by removing the
surrounding double quotes from the ENCRYPTION_SECRET placeholder so users don't
accidentally include literal quote characters, and add a short requirement note
for ENCRYPTION_SECRET specifying expected entropy (e.g., minimum length and
randomness), e.g., mention ENCRYPTION_SECRET should be a long random string
(recommend at least 32-64 characters or 256 bits of entropy) and that it must be
kept secret; reference the ENCRYPTION_SECRET key in your note so it's easy to
locate.

19 changes: 13 additions & 6 deletions app/api/ai/actions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<AIActionResponse>({ error: 'Failed to decrypt AI API key' }, 500);
}

if (orgError || !apiKey) {
return json<AIActionResponse>({ error: 'AI consent required', consentType: 'AI_CONSENT' }, 200);
Expand Down
19 changes: 13 additions & 6 deletions app/api/ai/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Expand Down
13 changes: 7 additions & 6 deletions app/api/settings/ai/route.ts
Original file line number Diff line number Diff line change
@@ -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<T>(body: T, status = 200): Response {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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')
Expand Down
62 changes: 62 additions & 0 deletions lib/security/encryption.ts
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +6 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix documentation: output is hex-encoded, not base64.

The JSDoc comment states the output is "base64-encoded" but the implementation uses hex encoding (lines 26-29). Update the documentation to match the actual behavior.

Suggested fix
 /**
  * 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".
+ * The output is a hex-encoded string in the format "ciphertext:iv".
  *
  * `@param` text The plaintext string to encrypt.
- * `@returns` The encrypted string in "ciphertext:iv" format (base64-encoded).
+ * `@returns` The encrypted string in "ciphertext:iv" format (hex-encoded).
  * `@throws` Error if ENCRYPTION_SECRET is not set.
  */
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* 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.
/**
* Encrypts a plaintext string using AES-256-CBC.
* The encryption key is derived from the ENCRYPTION_SECRET environment variable.
* The output is a hex-encoded string in the format "ciphertext:iv".
*
* `@param` text The plaintext string to encrypt.
* `@returns` The encrypted string in "ciphertext:iv" format (hex-encoded).
* `@throws` Error if ENCRYPTION_SECRET is not set.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/security/encryption.ts` around lines 6 - 13, The JSDoc for the encrypt
function is incorrect: the function returns hex-encoded values not base64.
Update the comment for the function (in lib/security/encryption.ts) to state
that the output is hex-encoded and clarify the format is "ciphertext:iv" where
both parts are hex; reference the encrypt function and the return description so
it matches the actual implementation that uses hex encoding for ciphertext and
iv.

*/
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);
Comment on lines +21 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use a unique salt instead of a fixed literal.

The hardcoded 'salt' value significantly weakens the scrypt key derivation. scrypt's salt is designed to be unique per key derivation to prevent rainbow table attacks and ensure that identical secrets produce different keys across installations.

Options to improve:

  1. Per-installation salt: Store a random salt in an environment variable (e.g., ENCRYPTION_SALT) generated at setup time.
  2. Per-ciphertext salt: Generate a random salt for each encryption and store it alongside the ciphertext (e.g., ciphertext:iv:salt).

Option 2 is more secure but requires a format change. Option 1 is simpler and still provides meaningful protection.

Is it safe to use a fixed salt with scrypt for encryption key derivation?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/security/encryption.ts` around lines 21 - 23, Replace the hardcoded salt
passed to crypto.scryptSync with a securely generated and stored salt: either
read a per-installation salt from an environment variable like ENCRYPTION_SALT
(use crypto.randomBytes(16) at setup time and persist it) or, for stronger
security, generate a random salt per encryption and include it with the output
(e.g., store/send ciphertext, iv and salt together) and use that salt in the
scryptSync call; update the scryptSync usage (the call where key is derived from
ENCRYPTION_SECRET) and the corresponding decrypt path to read the same salt
(reference the scryptSync call that creates const key, ENCRYPTION_SECRET, and
IV_LENGTH) so key derivation is deterministic for decryption.

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.
*/
Comment on lines +32 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix documentation for decrypt function as well.

Same issue: the JSDoc says "base64-encoded" but the function expects hex-encoded input.

Suggested fix
 /**
  * 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).
+ * `@param` encryptedText The encrypted string in "ciphertext:iv" format (hex-encoded).
  * `@returns` The decrypted plaintext string.
  * `@throws` Error if ENCRYPTION_SECRET is not set or if the format is invalid.
  */
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* 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.
*/
/**
* 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 (hex-encoded).
* `@returns` The decrypted plaintext string.
* `@throws` Error if ENCRYPTION_SECRET is not set or if the format is invalid.
*/
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/security/encryption.ts` around lines 32 - 39, The JSDoc for the decrypt
function incorrectly states the input is base64-encoded; update the comment in
lib/security/encryption.ts for the decrypt function to state the ciphertext and
IV are hex-encoded and that the expected input format is "ciphertext:iv" with
both parts hex-encoded, and keep the notes about deriving the AES-256-CBC key
from ENCRYPTION_SECRET and throwing on missing secret or invalid format;
reference the decrypt function name when making this documentation change.

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;
}
2 changes: 1 addition & 1 deletion supabase/migrations/20251201000000_schema_init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions supabase/migrations/20260402224235_encrypt_ai_api_keys.sql
Original file line number Diff line number Diff line change
@@ -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)';
Comment on lines +1 to +8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing data migration for existing plaintext keys.

This migration only adds documentation comments but does not encrypt any existing plaintext API keys in the database. If there are existing rows in organization_settings with plaintext keys, they will cause decryption failures after deploying this PR since the new code paths call decrypt() on all stored values.

Consider adding a data migration script (run separately or as part of deployment) to:

  1. Read existing plaintext keys
  2. Encrypt them using the new format
  3. Update the rows with encrypted values

Alternatively, document that this is a breaking change requiring fresh data or a manual migration step.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260402224235_encrypt_ai_api_keys.sql` around lines 1 -
8, The migration only adds comments and omits converting existing plaintext API
keys, so add a data migration that locates rows in public.organization_settings
where ai_google_key, ai_openai_key, or ai_anthropic_key are stored as plaintext,
encrypts each plaintext value using the same application encryption routine (the
function used by your app's encrypt/decrypt pipeline), and updates those columns
with the new ciphertext:iv format; implement this as a separate SQL/JS migration
run during deployment (or include a reversible migration) that reads each
column, skips already-encrypted values, calls the app's encrypt(...) for
ai_google_key, ai_openai_key, ai_anthropic_key, and writes back the encrypted
value, or alternatively document this required manual migration step in the
deployment notes.