Skip to content

feat: Api Keys database encryption.#33

Open
denisgmarques wants to merge 1 commit into
thaleslaray:mainfrom
denisgmarques:main
Open

feat: Api Keys database encryption.#33
denisgmarques wants to merge 1 commit into
thaleslaray:mainfrom
denisgmarques:main

Conversation

@denisgmarques
Copy link
Copy Markdown

@denisgmarques denisgmarques commented Apr 8, 2026

Motivation

Improving security with LLM API Keys avoing database plain text exposition.

Summary by CodeRabbit

  • Security Enhancements

    • AI API keys are now encrypted when stored in the database for improved data protection.
    • Requires configuration of a new environment variable to enable the encryption system.
  • Error Handling

    • AI API endpoints now return clear error responses when key decryption fails during operations.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 8, 2026

@denisgmarques is attempting to deploy a commit to the Thales Laray Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 8, 2026

📝 Walkthrough

Walkthrough

This pull request implements end-to-end encryption for AI provider API keys stored in the database. A new encryption module using AES-256-CBC is introduced alongside environment configuration. API routes are updated to decrypt keys when retrieving from storage and encrypt keys when persisting. Database migrations document encrypted column storage.

Changes

Cohort / File(s) Summary
Encryption Infrastructure
.env.example, lib/security/encryption.ts
Added ENCRYPTION_SECRET environment variable and new encryption module exporting encrypt() and decrypt() functions using AES-256-CBC with crypto.scryptSync key derivation and fixed salt.
AI API Routes
app/api/ai/actions/route.ts, app/api/ai/chat/route.ts, app/api/settings/ai/route.ts
Updated three routes to decrypt organization-stored AI provider API keys before use. Each route wraps decryption in try/catch, returning 500 error on failure. Settings route additionally encrypts keys on POST persistence and decrypts on GET retrieval.
Database Migrations
supabase/migrations/20251201000000_schema_init.sql, supabase/migrations/20260402224235_encrypt_ai_api_keys.sql
Fixed _api_key_sha256_hex() function to properly cast token to bytea. Added migration with comments documenting encrypted columns store ciphertext in ciphertext:iv format.

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Keys now lock beneath the ground,
With AES and secrets sound,
Each decrypt brings keys to light,
Encryption keeps them safe and tight!
Hopping forward, crypto strong! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Api Keys database encryption' clearly and accurately summarizes the main objective of the pull request—implementing encryption for API keys stored in the database.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/api/settings/ai/route.ts (1)

81-91: ⚠️ Potential issue | 🟠 Major

Add error handling for decryption failures in GET.

The decrypt() calls on lines 85-87 are not wrapped in try/catch, unlike the pattern in app/api/ai/chat/route.ts and app/api/ai/actions/route.ts. If a stored key is corrupted, uses an old format, or was never encrypted (pre-migration data), this will throw an unhandled exception and return a 500 without a meaningful error message.

Suggested fix
+  let aiGoogleKey = '';
+  let aiOpenaiKey = '';
+  let aiAnthropicKey = '';
+  try {
+    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) : '';
+  } catch (e) {
+    console.error('[api/settings/ai] Failed to decrypt API keys:', e);
+    return json({ error: 'Failed to decrypt stored API keys' }, 500);
+  }
+
   return json({
     aiEnabled,
     aiProvider: (orgSettings?.ai_provider || 'google') as Provider,
     aiModel: orgSettings?.ai_model || AI_DEFAULT_MODELS.google,
-    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) : '',
+    aiGoogleKey,
+    aiOpenaiKey,
+    aiAnthropicKey,
     aiHasGoogleKey: Boolean(orgSettings?.ai_google_key),
     aiHasOpenaiKey: Boolean(orgSettings?.ai_openai_key),
     aiHasAnthropicKey: Boolean(orgSettings?.ai_anthropic_key),
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/settings/ai/route.ts` around lines 81 - 91, The decrypt(...) calls
for aiGoogleKey, aiOpenaiKey, and aiAnthropicKey can throw and need try/catch:
wrap each decrypt(orgSettings.ai_<provider>_key) used to build
aiGoogleKey/aiOpenaiKey/aiAnthropicKey in a try/catch, on error log the
exception (include context like org id or orgSettings) and set the returned key
to '' and its corresponding aiHas... flag to false; ensure the GET handler in
app/api/settings/ai/route.ts returns a safe value instead of letting decrypt
throw (follow the same pattern used in app/api/ai/chat/route.ts and
app/api/ai/actions/route.ts).
🧹 Nitpick comments (1)
lib/security/encryption.ts (1)

1-30: Consider using authenticated encryption (AES-GCM) instead of CBC.

AES-CBC without message authentication is vulnerable to padding oracle attacks. An attacker who can observe decryption errors may be able to decrypt ciphertext without knowing the key.

For encrypting sensitive data like API keys, authenticated encryption modes like AES-256-GCM are recommended as they provide both confidentiality and integrity in a single operation.

Example using AES-256-GCM
import crypto from 'crypto';

const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12; // GCM recommended IV length
const AUTH_TAG_LENGTH = 16;

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.');
  }

  const salt = crypto.randomBytes(16);
  const key = crypto.scryptSync(ENCRYPTION_SECRET, salt, 32);
  const iv = crypto.randomBytes(IV_LENGTH);
  const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });

  let encrypted = cipher.update(text, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  const authTag = cipher.getAuthTag();

  // Format: ciphertext:iv:salt:authTag (all hex)
  return `${encrypted}:${iv.toString('hex')}:${salt.toString('hex')}:${authTag.toString('hex')}`;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/security/encryption.ts` around lines 1 - 30, The encrypt implementation
uses AES-256-CBC without authentication; change it to AES-256-GCM to provide
authenticated encryption: update ALGORITHM to 'aes-256-gcm' and IV_LENGTH to 12,
add an AUTH_TAG_LENGTH constant, generate a random salt (e.g.,
crypto.randomBytes(16)) and derive the key with
crypto.scryptSync(ENCRYPTION_SECRET, salt, 32), create the cipher with
crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }),
call cipher.getAuthTag() after final(), and return a formatted string that
includes ciphertext, iv, salt, and authTag (e.g., hex-separated) so the
corresponding decrypt function can re-derive the key and verify the tag; update
the encrypt function, constants ALGORITHM/IV_LENGTH/AUTH_TAG_LENGTH, and ensure
any decrypt logic is updated to parse the new format and verify the auth tag
using cipher.getAuthTag()/setAuthTag().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.env.example:
- Around line 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.

In `@lib/security/encryption.ts`:
- Around line 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.
- Around line 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.
- Around line 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.

In `@supabase/migrations/20260402224235_encrypt_ai_api_keys.sql`:
- Around line 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.

---

Outside diff comments:
In `@app/api/settings/ai/route.ts`:
- Around line 81-91: The decrypt(...) calls for aiGoogleKey, aiOpenaiKey, and
aiAnthropicKey can throw and need try/catch: wrap each
decrypt(orgSettings.ai_<provider>_key) used to build
aiGoogleKey/aiOpenaiKey/aiAnthropicKey in a try/catch, on error log the
exception (include context like org id or orgSettings) and set the returned key
to '' and its corresponding aiHas... flag to false; ensure the GET handler in
app/api/settings/ai/route.ts returns a safe value instead of letting decrypt
throw (follow the same pattern used in app/api/ai/chat/route.ts and
app/api/ai/actions/route.ts).

---

Nitpick comments:
In `@lib/security/encryption.ts`:
- Around line 1-30: The encrypt implementation uses AES-256-CBC without
authentication; change it to AES-256-GCM to provide authenticated encryption:
update ALGORITHM to 'aes-256-gcm' and IV_LENGTH to 12, add an AUTH_TAG_LENGTH
constant, generate a random salt (e.g., crypto.randomBytes(16)) and derive the
key with crypto.scryptSync(ENCRYPTION_SECRET, salt, 32), create the cipher with
crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }),
call cipher.getAuthTag() after final(), and return a formatted string that
includes ciphertext, iv, salt, and authTag (e.g., hex-separated) so the
corresponding decrypt function can re-derive the key and verify the tag; update
the encrypt function, constants ALGORITHM/IV_LENGTH/AUTH_TAG_LENGTH, and ensure
any decrypt logic is updated to parse the new format and verify the auth tag
using cipher.getAuthTag()/setAuthTag().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 875a60f5-a749-4341-9385-cb983e72b09b

📥 Commits

Reviewing files that changed from the base of the PR and between 7e272ed and 5bb0f27.

📒 Files selected for processing (7)
  • .env.example
  • app/api/ai/actions/route.ts
  • app/api/ai/chat/route.ts
  • app/api/settings/ai/route.ts
  • lib/security/encryption.ts
  • supabase/migrations/20251201000000_schema_init.sql
  • supabase/migrations/20260402224235_encrypt_ai_api_keys.sql

Comment thread .env.example
Comment on lines +35 to +37

# Chave para encriptar as chaves de api no banco de dados evitando deixá-las expostas
ENCRYPTION_SECRET="sua_chave_secreta_longa_e_aleatoria_aqui"
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.

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

Comment on lines +21 to +23
// Use a consistent key length for AES-256 (32 bytes)
const key = crypto.scryptSync(ENCRYPTION_SECRET, 'salt', 32);
const iv = crypto.randomBytes(IV_LENGTH);
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.

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

Comment on lines +1 to +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)';
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.

@thaleslaray
Copy link
Copy Markdown
Owner

Code review

Found 3 issues:

  1. No data migration for existing plaintext keys — all existing orgs break on deploy

The migration 20260402224235_encrypt_ai_api_keys.sql only adds COMMENT ON COLUMN statements. It does not encrypt existing plaintext keys already stored in organization_settings. After deploy, decrypt() splits on : and throws Invalid encrypted text format for any key stored as plain text (e.g. AIzaSy... contains no :). There is no backwards-compatible fallback. Every organization with an existing API key will have broken AI immediately on deployment.

https://github.com/thaleslaray/nossocrm/blob/5bb0f277496d80c64a130a7284157d273fef99b9/supabase/migrations/20260402224235_encrypt_ai_api_keys.sql#L1-L15

  1. decrypt() called without try/catch in GET handler of /api/settings/ai (inconsistent with the same PR's pattern in the POST and the other two routes)

The GET handler calls decrypt() inline without error handling, while app/api/ai/actions/route.ts and app/api/ai/chat/route.ts (also modified in this PR) both wrap their decrypt() calls in try/catch. If decryption throws (e.g. plaintext key, missing env var), the GET handler produces an unhandled 500 with no log context or user-facing message.

aiEnabled,
aiProvider: (orgSettings?.ai_provider || 'google') as Provider,
aiModel: orgSettings?.ai_model || AI_DEFAULT_MODELS.google,
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),

  1. Historical migration file mutated — change will never apply to existing databases (CLAUDE.md says "Não deletar migrations históricas")

supabase/migrations/20251201000000_schema_init.sql (a December 2025 migration already applied to all environments) is edited in this PR. Supabase tracks applied migrations by timestamp — this change will never run. The fix to extensions.digest(token::bytea, 'sha256') should be in a new migration file.

SECURITY DEFINER
AS $$
SELECT encode(extensions.digest(token::bytea, 'sha256'), 'hex');
$$;
-- Create API key (admin via UI) - returns the token ONCE

🤖 Generated with Claude Code

If this code review was useful, please react with 👍. Otherwise, react with 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants