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
16 changes: 14 additions & 2 deletions backend/app/services/api_key_service.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import hashlib
import logging
import os
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
Expand All @@ -14,6 +15,11 @@

logger = logging.getLogger(__name__)

# Parameters for API key hashing. Using PBKDF2 makes brute-force attacks
# against leaked API key hashes significantly more expensive.
API_KEY_HASH_SALT = os.environ.get("API_KEY_HASH_SALT", "default_api_key_salt").encode("utf-8")
API_KEY_HASH_ITERATIONS = 100_000

# Dedicated async engine/session for API key operations.
# Use NullPool to avoid sharing connections across event loops
# (e.g., TestClient threads/xdist workers), which can otherwise trigger
Expand All @@ -33,8 +39,14 @@ def generate_api_key() -> str:

@staticmethod
def hash_api_key(key: str) -> str:
"""Hash an API key using SHA-256."""
return hashlib.sha256(key.encode()).hexdigest()
"""Hash an API key using a computationally expensive function (PBKDF2)."""
derived_key = hashlib.pbkdf2_hmac(
"sha256",
key.encode("utf-8"),
API_KEY_HASH_SALT,
API_KEY_HASH_ITERATIONS,
)
return derived_key.hex()

async def validate_api_key(
self, key: str, request_ip: Optional[str] = None
Expand Down
305 changes: 305 additions & 0 deletions backend/scripts/regenerate_api_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
#!/usr/bin/env python3
"""
Regenerate API keys created before PBKDF2 hashing change.

Keys created before 2026-01-20 need to be regenerated because they use
SHA-256 hashing, which is no longer supported after commit 3ab29b0.

This script:
1. Lists all existing API keys
2. Identifies keys created before the PBKDF2 change date
3. Creates replacement keys with identical tier/settings
4. Optionally deactivates old keys
5. Outputs a migration report
"""

import argparse
import asyncio
import logging
import os
import sys
from datetime import datetime
from typing import Dict, List

from dotenv import load_dotenv

# Add backend/ to import path (scripts/ is under backend/scripts/)
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))

from app.services.api_key_service import APIKeyService # noqa: E402

load_dotenv()

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger(__name__)

# Default cutoff date: when PBKDF2 was introduced (commit 3ab29b0)
DEFAULT_CUTOFF_DATE = datetime(2026, 1, 20)


async def regenerate_api_keys(
cutoff_date: datetime,
dry_run: bool = False,
deactivate_old: bool = False,
) -> Dict[str, any]:
"""Regenerate API keys created before the cutoff date.

Args:
cutoff_date: Keys created before this date will be regenerated
dry_run: If True, only show what would be done without making changes
deactivate_old: If True, deactivate old keys after creating replacements

Returns:
Dictionary with migration results and statistics
"""
api_key_service = APIKeyService()

# Get all API keys
logger.info("Fetching all API keys...")
all_keys = await api_key_service.list_api_keys()
logger.info(f"Found {len(all_keys)} total API keys")

# Identify keys that need regeneration
keys_to_regenerate: List[Dict] = []
for key in all_keys:
if not key.get("created_at"):
logger.warning(f"Key {key.get('id')} has no created_at date, skipping")
continue

try:
created_at = datetime.fromisoformat(key["created_at"].replace("Z", "+00:00"))
# Remove timezone for comparison
if created_at.tzinfo:
created_at = created_at.replace(tzinfo=None)
except (ValueError, AttributeError) as e:
logger.warning(
f"Key {key.get('id')} has invalid created_at date '{key.get('created_at')}': {e}"
)
continue

if created_at < cutoff_date:
keys_to_regenerate.append(key)

logger.info(
f"Found {len(keys_to_regenerate)} keys created before {cutoff_date.date()} "
f"that need regeneration"
)

if not keys_to_regenerate:
logger.info("No keys need regeneration. All keys are using PBKDF2 hashing.")
return {
"total_keys": len(all_keys),
"keys_to_regenerate": 0,
"regenerated": 0,
"failed": 0,
"migrations": [],
}

# Process each key
migrations: List[Dict] = []
successful = 0
failed = 0

for old_key in keys_to_regenerate:
key_id = old_key["id"]
tier_name = old_key["tier_name"]
name = old_key.get("name")
allowed_ips = old_key.get("allowed_ips")

logger.info(f"Processing key ID {key_id} (tier: {tier_name}, name: {name or 'unnamed'})")

if dry_run:
logger.info(f" [DRY RUN] Would create replacement key for key ID {key_id}")
migrations.append(
{
"old_key_id": key_id,
"old_key_name": name,
"tier_name": tier_name,
"status": "would_regenerate",
"new_api_key": None,
"new_key_id": None,
"error": None,
}
)
continue

try:
# Create replacement key with same settings
result = await api_key_service.create_api_key(
tier_name=tier_name,
name=name,
allowed_ips=allowed_ips if allowed_ips else None,
)

if result is None:
error_msg = f"Failed to create replacement key for key ID {key_id}"
logger.error(error_msg)
migrations.append(
{
"old_key_id": key_id,
"old_key_name": name,
"tier_name": tier_name,
"status": "failed",
"new_api_key": None,
"new_key_id": None,
"error": error_msg,
}
)
failed += 1
continue

new_api_key = result["api_key"]
new_key_id = result["key_id"]

logger.info(f" ✓ Created replacement key ID {new_key_id} for old key ID {key_id}")
# Note: API key is not logged for security - see report output instead

# Optionally deactivate old key
if deactivate_old:
deactivated = await api_key_service.revoke_api_key_by_id(key_id)
if deactivated:
logger.info(f" ✓ Deactivated old key ID {key_id}")
else:
logger.warning(f" ⚠ Failed to deactivate old key ID {key_id}")

migrations.append(
{
"old_key_id": key_id,
"old_key_name": name,
"tier_name": tier_name,
"status": "regenerated",
"new_api_key": new_api_key,
"new_key_id": new_key_id,
"error": None,
}
)
successful += 1

except Exception as e:
error_msg = f"Error regenerating key ID {key_id}: {str(e)}"
logger.error(error_msg, exc_info=True)
migrations.append(
{
"old_key_id": key_id,
"old_key_name": name,
"tier_name": tier_name,
"status": "error",
"new_api_key": None,
"new_key_id": None,
"error": error_msg,
}
)
failed += 1

return {
"total_keys": len(all_keys),
"keys_to_regenerate": len(keys_to_regenerate),
"regenerated": successful,
"failed": failed,
"migrations": migrations,
}


def print_report(results: Dict) -> None:
"""Print a human-readable migration report."""
print("\n" + "=" * 80)
print("API Key Regeneration Report")
print("=" * 80)
print(f"Total API keys found: {results['total_keys']}")
print(f"Keys needing regeneration: {results['keys_to_regenerate']}")

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.

Copilot Autofix

AI 15 days ago

In general, the fix is to ensure that sensitive data (the API keys returned from create_api_key) are never written directly to logs or standard output in clear text. Non-sensitive metadata such as counts, IDs, and tier names can remain logged.

In this code, the sensitive flow is: APIKeyService.create_api_key() returns a dict that includes "api_key", regenerate_api_keys() stores that in new_api_key and in the "new_api_key" field of each migration record, and print_report() prints that field ("→ New API Key: {new_key}"). results['keys_to_regenerate'] itself is safe to print; the real problem and the reason for the taint is that results also contains the sensitive new_api_key fields. The minimal fix that preserves existing functionality while removing the clear-text exposure is:

  • Stop printing the actual API key value in print_report. Instead, print only non-sensitive metadata (e.g., key IDs and tier, which are already there).
  • Optionally, keep "new_api_key" available in the results structure so that calling code (if any) can decide how to handle it, but do not print it or log it. Since this script is stand-alone and only uses results for printing and exit codes, leaving the field in place but not printing it is functionally equivalent for the caller.
  • No changes are needed in APIKeyService.create_api_key itself; it is reasonable for that service to return the key exactly once to trusted callers.

Concretely, in backend/scripts/regenerate_api_keys.py:

  • In print_report, remove or comment out the line that prints new_key (" → New API Key: {new_key}"). We will simply delete that line. This removes the only direct clear-text logging of the actual API key in the script.
  • Leave the lines that print counts (results['keys_to_regenerate'], etc.) as they are; they are safe and useful.

No new methods or imports are needed; this is just removing a single print of sensitive data.

Suggested changeset 1
backend/scripts/regenerate_api_keys.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/backend/scripts/regenerate_api_keys.py b/backend/scripts/regenerate_api_keys.py
--- a/backend/scripts/regenerate_api_keys.py
+++ b/backend/scripts/regenerate_api_keys.py
@@ -228,10 +228,9 @@
                 print(f"  [DRY RUN] Key ID {old_id} ({old_name}, tier: {tier})")
             elif status == "regenerated":
                 new_id = migration["new_key_id"]
-                new_key = migration["new_api_key"]
+                # Do not print the raw API key for security reasons
                 print(f"  ✓ Key ID {old_id} ({old_name}, tier: {tier})")
                 print(f"    → New Key ID: {new_id}")
-                print(f"    → New API Key: {new_key}")
             elif status == "failed" or status == "error":
                 error = migration["error"]
                 print(f"  ✗ Key ID {old_id} ({old_name}, tier: {tier})")
EOF
@@ -228,10 +228,9 @@
print(f" [DRY RUN] Key ID {old_id} ({old_name}, tier: {tier})")
elif status == "regenerated":
new_id = migration["new_key_id"]
new_key = migration["new_api_key"]
# Do not print the raw API key for security reasons
print(f" ✓ Key ID {old_id} ({old_name}, tier: {tier})")
print(f" → New Key ID: {new_id}")
print(f" → New API Key: {new_key}")
elif status == "failed" or status == "error":
error = migration["error"]
print(f" ✗ Key ID {old_id} ({old_name}, tier: {tier})")
Copilot is powered by AI and may make mistakes. Always verify output.
print(f"Successfully regenerated: {results['regenerated']}")

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.

Copilot Autofix

AI 15 days ago

In general, the problem should be fixed by ensuring that sensitive values (API keys) are never written to logs or stdout/stderr in clear text. Instead, only non-sensitive metadata (IDs, counts, tiers, truncated hashes, etc.) should be logged. Any structures that carry secrets (like migrations and results) must not be serialized, printed, or logged in a way that might emit those secrets, unless they are explicitly stripped or redacted first.

For this specific code, the best minimal fix is to change how the migration report prints details so that it no longer outputs the full new_api_key. We can still display IDs and counts for operational visibility, but omit or redact the actual API key. This addresses all variants because the sensitive taint originates from result["api_key"] / new_api_key in migrations, which is what makes results tainted; by ensuring that we never print the key itself in print_report, we remove the clear-text exposure. We do not need to change how results is constructed, only how it is rendered.

Concretely, in backend/scripts/regenerate_api_keys.py:

  • In print_report, inside the status == "regenerated" branch, remove or replace the line:

    print(f"    → New API Key: {new_key}")
  • Option A (most conservative): drop the line entirely so the API key is never printed.

  • Option B (if you want to give a hint without exposing the secret): print only a redacted form, e.g., last 4 characters, clearly labeled as redacted.

Given the instructions to avoid logging sensitive data, Option A is the safest, but Option B still avoids logging the full key and is often acceptable in practice. I’ll implement Option B so operators can match a key they just copied, while making sure the full key never appears.

No additional imports or helpers are necessary; we can compute a simple redacted string inline.


Suggested changeset 1
backend/scripts/regenerate_api_keys.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/backend/scripts/regenerate_api_keys.py b/backend/scripts/regenerate_api_keys.py
--- a/backend/scripts/regenerate_api_keys.py
+++ b/backend/scripts/regenerate_api_keys.py
@@ -231,7 +231,10 @@
                 new_key = migration["new_api_key"]
                 print(f"  ✓ Key ID {old_id} ({old_name}, tier: {tier})")
                 print(f"    → New Key ID: {new_id}")
-                print(f"    → New API Key: {new_key}")
+                # Do not print the full API key for security reasons.
+                if new_key:
+                    redacted = f"...{new_key[-4:]}" if len(new_key) > 4 else "***"
+                    print(f"    → New API Key: {redacted} (redacted)")
             elif status == "failed" or status == "error":
                 error = migration["error"]
                 print(f"  ✗ Key ID {old_id} ({old_name}, tier: {tier})")
EOF
@@ -231,7 +231,10 @@
new_key = migration["new_api_key"]
print(f" ✓ Key ID {old_id} ({old_name}, tier: {tier})")
print(f" → New Key ID: {new_id}")
print(f" → New API Key: {new_key}")
# Do not print the full API key for security reasons.
if new_key:
redacted = f"...{new_key[-4:]}" if len(new_key) > 4 else "***"
print(f" → New API Key: {redacted} (redacted)")
elif status == "failed" or status == "error":
error = migration["error"]
print(f" ✗ Key ID {old_id} ({old_name}, tier: {tier})")
Copilot is powered by AI and may make mistakes. Always verify output.
print(f"Failed: {results['failed']}")

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.

Copilot Autofix

AI 15 days ago

In general, sensitive credentials (like API keys) must never be logged or printed in clear text. When some visibility is useful (for operators to match keys), you can log a non-sensitive derivative such as the database ID or a truncated/masked version of the key, or avoid outputting it at all and instead rely on secure side channels.

The best fix here is to stop printing the full new_api_key in the print_report function and, if needed, replace it with a safe, non-sensitive representation. The rest of the script already avoids logging the API key via the logger and identifies keys primarily by key_id, tier, and name, which should be sufficient. To minimize functional change while eliminating the vulnerability and satisfying the analyzer, we will:

  • Modify print_report so that it never outputs the full new_api_key.
  • Instead, print either:
    • nothing about the raw key, or
    • a masked version (e.g., last 4 characters) clearly marked as partial, which is safe because it does not allow reconstruction of the key.
  • Keep all other metrics, including results['failed'], unchanged; we don’t need to alter the structure of results itself.

Concretely, in backend/scripts/regenerate_api_keys.py:

  • Inside print_report, in the "regenerated" branch (status == "regenerated"), replace:
new_id = migration["new_key_id"]
new_key = migration["new_api_key"]
print(f"  ✓ Key ID {old_id} ({old_name}, tier: {tier})")
print(f"    → New Key ID: {new_id}")
print(f"    → New API Key: {new_key}")

with something that does not expose the full key, e.g.:

new_id = migration["new_key_id"]
new_key = migration["new_api_key"]
masked_key = f"{new_key[:4]}...{new_key[-4:]}" if new_key and len(new_key) > 8 else "<hidden>"
print(f"  ✓ Key ID {old_id} ({old_name}, tier: {tier})")
print(f"    → New Key ID: {new_id}")
print(f"    → New API Key (partial): {masked_key}")

or, if you prefer, omit the API key line entirely. This change requires no new imports and keeps existing behavior (showing “which key” was created) in a much safer, non-reversible way. Since the taint now only flows into a masked, derived value that does not reveal the secret, CodeQL’s concern about clear-text password/API-key logging at this sink will be addressed.

Suggested changeset 1
backend/scripts/regenerate_api_keys.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/backend/scripts/regenerate_api_keys.py b/backend/scripts/regenerate_api_keys.py
--- a/backend/scripts/regenerate_api_keys.py
+++ b/backend/scripts/regenerate_api_keys.py
@@ -229,9 +229,16 @@
             elif status == "regenerated":
                 new_id = migration["new_key_id"]
                 new_key = migration["new_api_key"]
+                # Do not print full API key for security reasons. Show only a partial/masked form.
+                if isinstance(new_key, str) and len(new_key) > 8:
+                    masked_key = f"{new_key[:4]}...{new_key[-4:]}"
+                elif new_key:
+                    masked_key = "<partial>"
+                else:
+                    masked_key = "<hidden>"
                 print(f"  ✓ Key ID {old_id} ({old_name}, tier: {tier})")
                 print(f"    → New Key ID: {new_id}")
-                print(f"    → New API Key: {new_key}")
+                print(f"    → New API Key (partial): {masked_key}")
             elif status == "failed" or status == "error":
                 error = migration["error"]
                 print(f"  ✗ Key ID {old_id} ({old_name}, tier: {tier})")
EOF
@@ -229,9 +229,16 @@
elif status == "regenerated":
new_id = migration["new_key_id"]
new_key = migration["new_api_key"]
# Do not print full API key for security reasons. Show only a partial/masked form.
if isinstance(new_key, str) and len(new_key) > 8:
masked_key = f"{new_key[:4]}...{new_key[-4:]}"
elif new_key:
masked_key = "<partial>"
else:
masked_key = "<hidden>"
print(f" ✓ Key ID {old_id} ({old_name}, tier: {tier})")
print(f" → New Key ID: {new_id}")
print(f" → New API Key: {new_key}")
print(f" → New API Key (partial): {masked_key}")
elif status == "failed" or status == "error":
error = migration["error"]
print(f" ✗ Key ID {old_id} ({old_name}, tier: {tier})")
Copilot is powered by AI and may make mistakes. Always verify output.
print("\n" + "-" * 80)

if results["migrations"]:
print("\nMigration Details:")
print("-" * 80)
for migration in results["migrations"]:
status = migration["status"]
old_id = migration["old_key_id"]
old_name = migration["old_key_name"] or "unnamed"
tier = migration["tier_name"]

if status == "would_regenerate":
print(f" [DRY RUN] Key ID {old_id} ({old_name}, tier: {tier})")

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.

Copilot Autofix

AI 15 days ago

General approach: do not print the API key value anywhere (logs or stdout). Instead, report non-sensitive identifiers (key IDs, names, tiers) and, if necessary, truncated or masked representations of keys. Since this is a migration script, we can still provide a useful report by omitting or masking new_api_key in the human-readable output. We should also avoid storing the full api_key in large, long-lived result structures when not strictly needed.

Best targeted fix here: keep the internal data structure (results["migrations"]) unchanged for compatibility, but update print_report so that it no longer prints new_api_key. Optionally, we can replace it with a masked indication or a generic “API key generated” message. This addresses all CodeQL variants because the tainted flow into the sink (print) is broken: even though results and migration remain tainted, the actual secret (migration["new_api_key"]) is no longer included in the output.

Concretely, in backend/scripts/regenerate_api_keys.py:

  • In print_report, in the "regenerated" branch:
    • Remove or change the line print(f" → New API Key: {new_key}").
    • Potentially rename new_key to new_key_id or similar to keep clarity, but not required.
  • The "would_regenerate" (dry-run) branch currently only prints key ID, name, and tier, which are fine; no change needed.
  • No changes are needed in APIKeyService or in how results is composed unless you want to also stop storing the full API key in migrations. To keep behavior as close as possible, we will only stop printing it.

No new imports or helper methods are required; we only alter the string formatting in print_report.


Suggested changeset 1
backend/scripts/regenerate_api_keys.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/backend/scripts/regenerate_api_keys.py b/backend/scripts/regenerate_api_keys.py
--- a/backend/scripts/regenerate_api_keys.py
+++ b/backend/scripts/regenerate_api_keys.py
@@ -228,10 +228,10 @@
                 print(f"  [DRY RUN] Key ID {old_id} ({old_name}, tier: {tier})")
             elif status == "regenerated":
                 new_id = migration["new_key_id"]
-                new_key = migration["new_api_key"]
+                # Do not print the new API key value to avoid exposing sensitive data
                 print(f"  ✓ Key ID {old_id} ({old_name}, tier: {tier})")
                 print(f"    → New Key ID: {new_id}")
-                print(f"    → New API Key: {new_key}")
+                print("    → New API key generated (value not shown for security)")
             elif status == "failed" or status == "error":
                 error = migration["error"]
                 print(f"  ✗ Key ID {old_id} ({old_name}, tier: {tier})")
EOF
@@ -228,10 +228,10 @@
print(f" [DRY RUN] Key ID {old_id} ({old_name}, tier: {tier})")
elif status == "regenerated":
new_id = migration["new_key_id"]
new_key = migration["new_api_key"]
# Do not print the new API key value to avoid exposing sensitive data
print(f" ✓ Key ID {old_id} ({old_name}, tier: {tier})")
print(f" → New Key ID: {new_id}")
print(f" → New API Key: {new_key}")
print(" → New API key generated (value not shown for security)")
elif status == "failed" or status == "error":
error = migration["error"]
print(f" ✗ Key ID {old_id} ({old_name}, tier: {tier})")
Copilot is powered by AI and may make mistakes. Always verify output.
elif status == "regenerated":
new_id = migration["new_key_id"]
new_key = migration["new_api_key"]
print(f" ✓ Key ID {old_id} ({old_name}, tier: {tier})")

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.

Copilot Autofix

AI 15 days ago

In general, to fix clear‑text logging of sensitive data, you should either: (a) avoid logging/printing the sensitive value at all, logging only non‑sensitive metadata; or (b) mask/redact the value so that the secret cannot be reconstructed. For API keys, the usual pattern is to show only part of the key (e.g., last 4 characters) or a hash/fingerprint.

For this script, the minimal and safest fix without changing the overall functionality is to stop printing the full new_api_key in print_report. The script already logs IDs and tier names, so operators can still see which keys were processed. If you still want a per‑key handle for verification, you can print a short fingerprint derived from the key (e.g., a SHA‑256 hash truncated to a few hex characters) instead of the raw key; but that would require adding hashing code. Since the requirement is to avoid clear‑text logging of the API key, and we must minimize changes, the best fix is simply to remove or redact the line that prints new_key.

Concretely, in backend/scripts/regenerate_api_keys.py, within print_report, replace:

231:                 new_key = migration["new_api_key"]
232:                 print(f"  ✓ Key ID {old_id} ({old_name}, tier: {tier})")
233:                 print(f"    → New Key ID: {new_id}")
234:                 print(f"    → New API Key: {new_key}")

with a version that does not output new_key. For instance:

  • Drop new_key entirely and print only IDs and tier, or
  • Replace the “New API Key” line with a note that the key value is intentionally not printed.

No new imports are required if we simply stop printing the key; we don’t need to touch APIKeyService or any other file. This single change addresses all three CodeQL variants, because the tainted value result["api_key"] is no longer propagated into a clear‑text output sink.

Suggested changeset 1
backend/scripts/regenerate_api_keys.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/backend/scripts/regenerate_api_keys.py b/backend/scripts/regenerate_api_keys.py
--- a/backend/scripts/regenerate_api_keys.py
+++ b/backend/scripts/regenerate_api_keys.py
@@ -228,10 +228,9 @@
                 print(f"  [DRY RUN] Key ID {old_id} ({old_name}, tier: {tier})")
             elif status == "regenerated":
                 new_id = migration["new_key_id"]
-                new_key = migration["new_api_key"]
                 print(f"  ✓ Key ID {old_id} ({old_name}, tier: {tier})")
                 print(f"    → New Key ID: {new_id}")
-                print(f"    → New API Key: {new_key}")
+                print("    → New API Key: [REDACTED]")
             elif status == "failed" or status == "error":
                 error = migration["error"]
                 print(f"  ✗ Key ID {old_id} ({old_name}, tier: {tier})")
EOF
@@ -228,10 +228,9 @@
print(f" [DRY RUN] Key ID {old_id} ({old_name}, tier: {tier})")
elif status == "regenerated":
new_id = migration["new_key_id"]
new_key = migration["new_api_key"]
print(f" ✓ Key ID {old_id} ({old_name}, tier: {tier})")
print(f" → New Key ID: {new_id}")
print(f" → New API Key: {new_key}")
print(" → New API Key: [REDACTED]")
elif status == "failed" or status == "error":
error = migration["error"]
print(f" ✗ Key ID {old_id} ({old_name}, tier: {tier})")
Copilot is powered by AI and may make mistakes. Always verify output.
print(f" → New Key ID: {new_id}")

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.

Copilot Autofix

AI 15 days ago

General approach:

  • Do not print the raw API key (new_api_key) directly in print_report.
  • Avoid passing the raw API key through the same results/migrations structure used for general reporting and potentially logging.
  • If we still need to expose new API keys for operators, provide a safer, more explicit mechanism (e.g., a separate “secrets output” structure the caller can decide how to handle) or make printing keys opt‑in via a CLI flag.
  • Ensure we do not treat key IDs as secrets; only the actual API key value should be removed from the logging/reporting path.

Best minimal fix while preserving functionality:

  1. Stop including new_api_key in the migrations entries used for print_report.

    • In regenerate_api_keys, change the "new_api_key": new_api_key field to something non-sensitive (e.g., None) in the migrations.append(...) call for successful regenerations.
    • Similarly, for the dry‑run and error cases, leave "new_api_key": None as it is (already not exposing data).
  2. Stop printing the new API key in print_report.

    • In print_report, under the status == "regenerated" branch, remove or replace the line print(f" → New API Key: {new_key}").
    • We can still print the non-secret metadata (old ID, tier, new key ID). Key IDs are not secrets, so print(f" → New Key ID: {new_id}") is safe and should remain.
  3. (Optional but consistent): If we want admins to still obtain the new API keys in a controlled way, we can:

    • Keep new_api_key only in the in-memory results structure returned from regenerate_api_keys, but not in migrations that print_report iterates. Instead, add a separate top‑level list, e.g., "new_keys_secrets": [...], that contains dicts with old_key_id, new_key_id, api_key. Then, the main function (or the caller) can decide what to do with it (write to a secure file, print only when a --show-secrets flag is passed, etc.).
    • However, since we’re constrained to the shown snippets and want minimal change, the simplest and safest fix is just not to surface new_api_key at all via print_report/stdout and to keep the rest as-is.

Given the instructions, I’ll implement the minimal change:

  • In backend/scripts/regenerate_api_keys.py:
    • In the migrations.append dict for the status == "regenerated" case, set "new_api_key": None instead of new_api_key.
    • In print_report, remove the retrieval of new_key = migration["new_api_key"] and the subsequent line that prints "→ New API Key: {new_key}".

This removes the clear-text logging path for the API key while keeping the rest of the reporting (including non-secret key IDs) intact.

No changes are needed in backend/app/services/api_key_service.py for this issue.


Suggested changeset 1
backend/scripts/regenerate_api_keys.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/backend/scripts/regenerate_api_keys.py b/backend/scripts/regenerate_api_keys.py
--- a/backend/scripts/regenerate_api_keys.py
+++ b/backend/scripts/regenerate_api_keys.py
@@ -156,7 +156,7 @@
             new_key_id = result["key_id"]
 
             logger.info(f"  ✓ Created replacement key ID {new_key_id} for old key ID {key_id}")
-            # Note: API key is not logged for security - see report output instead
+            # Note: API key is not logged for security, and is not included in migration reports.
 
             # Optionally deactivate old key
             if deactivate_old:
@@ -172,7 +172,7 @@
                     "old_key_name": name,
                     "tier_name": tier_name,
                     "status": "regenerated",
-                    "new_api_key": new_api_key,
+                    "new_api_key": None,
                     "new_key_id": new_key_id,
                     "error": None,
                 }
@@ -228,10 +228,8 @@
                 print(f"  [DRY RUN] Key ID {old_id} ({old_name}, tier: {tier})")
             elif status == "regenerated":
                 new_id = migration["new_key_id"]
-                new_key = migration["new_api_key"]
                 print(f"  ✓ Key ID {old_id} ({old_name}, tier: {tier})")
                 print(f"    → New Key ID: {new_id}")
-                print(f"    → New API Key: {new_key}")
             elif status == "failed" or status == "error":
                 error = migration["error"]
                 print(f"  ✗ Key ID {old_id} ({old_name}, tier: {tier})")
EOF
@@ -156,7 +156,7 @@
new_key_id = result["key_id"]

logger.info(f" ✓ Created replacement key ID {new_key_id} for old key ID {key_id}")
# Note: API key is not logged for security - see report output instead
# Note: API key is not logged for security, and is not included in migration reports.

# Optionally deactivate old key
if deactivate_old:
@@ -172,7 +172,7 @@
"old_key_name": name,
"tier_name": tier_name,
"status": "regenerated",
"new_api_key": new_api_key,
"new_api_key": None,
"new_key_id": new_key_id,
"error": None,
}
@@ -228,10 +228,8 @@
print(f" [DRY RUN] Key ID {old_id} ({old_name}, tier: {tier})")
elif status == "regenerated":
new_id = migration["new_key_id"]
new_key = migration["new_api_key"]
print(f" ✓ Key ID {old_id} ({old_name}, tier: {tier})")
print(f" → New Key ID: {new_id}")
print(f" → New API Key: {new_key}")
elif status == "failed" or status == "error":
error = migration["error"]
print(f" ✗ Key ID {old_id} ({old_name}, tier: {tier})")
Copilot is powered by AI and may make mistakes. Always verify output.
print(f" → New API Key: {new_key}")

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.

Copilot Autofix

AI 15 days ago

In general, the fix is to avoid printing the raw API key value anywhere. The script can still report that a new key was created and show its ID and metadata, but not the actual secret. If operators genuinely need to see the key once, that should be handled via a more controlled/explicit output mechanism (e.g., a dedicated flag), but given the current requirements we should remove the clear-text API key from the report.

The minimal, non‑functional‑breaking change is to adjust print_report in backend/scripts/regenerate_api_keys.py so that it no longer prints migration["new_api_key"]. Instead, we can print a generic message that a new key was generated and where to retrieve it, or note that the key is intentionally not shown. This keeps the structure of results and migrations untouched, so all other code paths remain the same. No changes are needed in APIKeyService itself.

Concretely:

  • In print_report, in the elif status == "regenerated": block, remove or replace the line:
    • print(f" → New API Key: {new_key}")
  • Optionally, replace it with something like:
    • print(" → New API Key: [REDACTED - not logged]")
      or a similar wording so the report is still informative.
  • We do not need any new imports or helper methods; this is a simple change to the printed string.

This single change will address all CodeQL variants because they all point to the same sink: the printing of new_key (the API key) as clear text.


Suggested changeset 1
backend/scripts/regenerate_api_keys.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/backend/scripts/regenerate_api_keys.py b/backend/scripts/regenerate_api_keys.py
--- a/backend/scripts/regenerate_api_keys.py
+++ b/backend/scripts/regenerate_api_keys.py
@@ -228,10 +228,10 @@
                 print(f"  [DRY RUN] Key ID {old_id} ({old_name}, tier: {tier})")
             elif status == "regenerated":
                 new_id = migration["new_key_id"]
-                new_key = migration["new_api_key"]
+                # new_api_key is intentionally not printed to avoid exposing secrets
                 print(f"  ✓ Key ID {old_id} ({old_name}, tier: {tier})")
                 print(f"    → New Key ID: {new_id}")
-                print(f"    → New API Key: {new_key}")
+                print("    → New API Key: [REDACTED - not logged]")
             elif status == "failed" or status == "error":
                 error = migration["error"]
                 print(f"  ✗ Key ID {old_id} ({old_name}, tier: {tier})")
EOF
@@ -228,10 +228,10 @@
print(f" [DRY RUN] Key ID {old_id} ({old_name}, tier: {tier})")
elif status == "regenerated":
new_id = migration["new_key_id"]
new_key = migration["new_api_key"]
# new_api_key is intentionally not printed to avoid exposing secrets
print(f" ✓ Key ID {old_id} ({old_name}, tier: {tier})")
print(f" → New Key ID: {new_id}")
print(f" → New API Key: {new_key}")
print(" → New API Key: [REDACTED - not logged]")
elif status == "failed" or status == "error":
error = migration["error"]
print(f" ✗ Key ID {old_id} ({old_name}, tier: {tier})")
Copilot is powered by AI and may make mistakes. Always verify output.
elif status == "failed" or status == "error":
error = migration["error"]
print(f" ✗ Key ID {old_id} ({old_name}, tier: {tier})")

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.

Copilot Autofix

AI 15 days ago

In general, the fix is to ensure that sensitive data (the raw API key) is never written to logs or stdout/stderr. Instead, logs should use non-sensitive identifiers (such as key IDs) or redacted/masked representations and keep the API key only in memory for the minimal time necessary to display it securely to an authorized operator, if at all.

For this specific code, we should:

  • Stop including new_api_key in the migrations report structure, so it never reaches print_report.
  • Adjust print_report so it no longer prints new_api_key.
  • Optionally, clearly document in the report that the new key is intentionally omitted from logs, and rely on whatever the caller already does (or could do in a follow-up) to present the key in a safer, ephemeral way if needed.

Concretely, in backend/scripts/regenerate_api_keys.py:

  1. In the regeneration loop, immediately after calling create_api_key, we currently do:
    • new_api_key = result["api_key"]
    • Store "new_api_key": new_api_key into each migration entry.
  2. We should:
    • Still extract new_api_key so we can use it locally if needed, but not put it into the migrations dict that is returned and printed.
    • Replace the "new_api_key": new_api_key field with something non-sensitive, such as None or a placeholder string, or simply omit the field if the structure is not strictly relied upon elsewhere. To avoid changing external behavior too much, we can keep the key but set the value explicitly to None.
  3. In print_report, remove the line print(f" → New API Key: {new_key}"), and optionally replace it with a message like print(" → New API Key: [REDACTED - see secure output]") or, more neutral, print(" → New API Key: (not logged)"). This preserves the shape of the report while avoiding secret leakage.
  4. No changes are needed in backend/app/services/api_key_service.py for this particular issue, since the service already avoids logging the API key and only returns it once.

No new imports, methods, or dependencies are required; all changes are confined to backend/scripts/regenerate_api_keys.py within the shown snippets.


Suggested changeset 1
backend/scripts/regenerate_api_keys.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/backend/scripts/regenerate_api_keys.py b/backend/scripts/regenerate_api_keys.py
--- a/backend/scripts/regenerate_api_keys.py
+++ b/backend/scripts/regenerate_api_keys.py
@@ -156,7 +156,7 @@
             new_key_id = result["key_id"]
 
             logger.info(f"  ✓ Created replacement key ID {new_key_id} for old key ID {key_id}")
-            # Note: API key is not logged for security - see report output instead
+            # Note: API key is intentionally not logged or included in the migration report
 
             # Optionally deactivate old key
             if deactivate_old:
@@ -172,7 +172,7 @@
                     "old_key_name": name,
                     "tier_name": tier_name,
                     "status": "regenerated",
-                    "new_api_key": new_api_key,
+                    "new_api_key": None,
                     "new_key_id": new_key_id,
                     "error": None,
                 }
@@ -228,10 +228,9 @@
                 print(f"  [DRY RUN] Key ID {old_id} ({old_name}, tier: {tier})")
             elif status == "regenerated":
                 new_id = migration["new_key_id"]
-                new_key = migration["new_api_key"]
                 print(f"  ✓ Key ID {old_id} ({old_name}, tier: {tier})")
                 print(f"    → New Key ID: {new_id}")
-                print(f"    → New API Key: {new_key}")
+                print("    → New API Key: (not logged; retrieve securely at creation time)")
             elif status == "failed" or status == "error":
                 error = migration["error"]
                 print(f"  ✗ Key ID {old_id} ({old_name}, tier: {tier})")
EOF
@@ -156,7 +156,7 @@
new_key_id = result["key_id"]

logger.info(f" ✓ Created replacement key ID {new_key_id} for old key ID {key_id}")
# Note: API key is not logged for security - see report output instead
# Note: API key is intentionally not logged or included in the migration report

# Optionally deactivate old key
if deactivate_old:
@@ -172,7 +172,7 @@
"old_key_name": name,
"tier_name": tier_name,
"status": "regenerated",
"new_api_key": new_api_key,
"new_api_key": None,
"new_key_id": new_key_id,
"error": None,
}
@@ -228,10 +228,9 @@
print(f" [DRY RUN] Key ID {old_id} ({old_name}, tier: {tier})")
elif status == "regenerated":
new_id = migration["new_key_id"]
new_key = migration["new_api_key"]
print(f" ✓ Key ID {old_id} ({old_name}, tier: {tier})")
print(f" → New Key ID: {new_id}")
print(f" → New API Key: {new_key}")
print(" → New API Key: (not logged; retrieve securely at creation time)")
elif status == "failed" or status == "error":
error = migration["error"]
print(f" ✗ Key ID {old_id} ({old_name}, tier: {tier})")
Copilot is powered by AI and may make mistakes. Always verify output.
print(f" Error: {error}")

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.

Copilot Autofix

AI 15 days ago

In general, the fix is to ensure that sensitive values such as raw API keys never appear in logs or user‑visible output in clear text. For this script, that means (1) removing or masking the direct printing of new_api_key in the migration report, and (2) ensuring that error messages and report structures do not inadvertently include those secrets.

The minimal, functionality‑preserving change is to stop printing the full API key and instead print only a safe, non‑sensitive representation, such as a fixed‑length prefix with the rest masked. We can also keep the API key in results["migrations"] if the calling code (or operator) needs to handle it programmatically, but we must not display it in the report. To additionally satisfy the analyzer, we can explicitly mask the key when constructing the migrations entries that are meant for human consumption, or at least when printing. Concretely:

  • In regenerate_api_keys, when constructing the migrations entry for a successful regeneration, replace "new_api_key": new_api_key with "new_api_key": None or a masked/truncated representation that is not usable as a credential.
  • In print_report, remove or change the line that prints new_key so it never prints the full API key. If a hint is useful for operators, print a masked form (e.g., first 4 characters and last 4 characters, with the middle replaced by *), computed locally in print_report.

These changes only affect backend/scripts/regenerate_api_keys.py; no changes are needed in backend/app/services/api_key_service.py. No additional imports are required; masking can be done with basic string operations.

Suggested changeset 1
backend/scripts/regenerate_api_keys.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/backend/scripts/regenerate_api_keys.py b/backend/scripts/regenerate_api_keys.py
--- a/backend/scripts/regenerate_api_keys.py
+++ b/backend/scripts/regenerate_api_keys.py
@@ -156,7 +156,7 @@
             new_key_id = result["key_id"]
 
             logger.info(f"  ✓ Created replacement key ID {new_key_id} for old key ID {key_id}")
-            # Note: API key is not logged for security - see report output instead
+            # Note: API key is intentionally not logged or included in the report output
 
             # Optionally deactivate old key
             if deactivate_old:
@@ -172,7 +172,8 @@
                     "old_key_name": name,
                     "tier_name": tier_name,
                     "status": "regenerated",
-                    "new_api_key": new_api_key,
+                    # Do not store raw API key in the migrations report structure
+                    "new_api_key": None,
                     "new_key_id": new_key_id,
                     "error": None,
                 }
@@ -228,10 +229,9 @@
                 print(f"  [DRY RUN] Key ID {old_id} ({old_name}, tier: {tier})")
             elif status == "regenerated":
                 new_id = migration["new_key_id"]
-                new_key = migration["new_api_key"]
                 print(f"  ✓ Key ID {old_id} ({old_name}, tier: {tier})")
                 print(f"    → New Key ID: {new_id}")
-                print(f"    → New API Key: {new_key}")
+                # New API key is not printed to avoid exposing sensitive credentials
             elif status == "failed" or status == "error":
                 error = migration["error"]
                 print(f"  ✗ Key ID {old_id} ({old_name}, tier: {tier})")
EOF
@@ -156,7 +156,7 @@
new_key_id = result["key_id"]

logger.info(f" ✓ Created replacement key ID {new_key_id} for old key ID {key_id}")
# Note: API key is not logged for security - see report output instead
# Note: API key is intentionally not logged or included in the report output

# Optionally deactivate old key
if deactivate_old:
@@ -172,7 +172,8 @@
"old_key_name": name,
"tier_name": tier_name,
"status": "regenerated",
"new_api_key": new_api_key,
# Do not store raw API key in the migrations report structure
"new_api_key": None,
"new_key_id": new_key_id,
"error": None,
}
@@ -228,10 +229,9 @@
print(f" [DRY RUN] Key ID {old_id} ({old_name}, tier: {tier})")
elif status == "regenerated":
new_id = migration["new_key_id"]
new_key = migration["new_api_key"]
print(f" ✓ Key ID {old_id} ({old_name}, tier: {tier})")
print(f" → New Key ID: {new_id}")
print(f" → New API Key: {new_key}")
# New API key is not printed to avoid exposing sensitive credentials
elif status == "failed" or status == "error":
error = migration["error"]
print(f" ✗ Key ID {old_id} ({old_name}, tier: {tier})")
Copilot is powered by AI and may make mistakes. Always verify output.

print("\n" + "=" * 80)


async def main() -> None:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Regenerate API keys created before PBKDF2 hashing change"
)
parser.add_argument(
"--cutoff-date",
type=str,
default="2026-01-20",
help="Keys created before this date will be regenerated (YYYY-MM-DD, default: 2026-01-20)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be regenerated without making changes",
)
parser.add_argument(
"--deactivate-old",
action="store_true",
help="Deactivate old keys after creating replacements",
)

args = parser.parse_args()

# Parse cutoff date
try:
cutoff_date = datetime.strptime(args.cutoff_date, "%Y-%m-%d")
except ValueError:
logger.error(f"Invalid cutoff date format: {args.cutoff_date}. Use YYYY-MM-DD")
sys.exit(1)

if args.dry_run:
logger.info("DRY RUN MODE: No changes will be made")
if args.deactivate_old:
logger.info("Old keys will be deactivated after creating replacements")

logger.info(f"Cutoff date: {cutoff_date.date()}")

try:
results = await regenerate_api_keys(
cutoff_date=cutoff_date,
dry_run=args.dry_run,
deactivate_old=args.deactivate_old,
)

print_report(results)

# Exit with error code if there were failures
if results["failed"] > 0:
logger.warning(f"Migration completed with {results['failed']} failures")

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.
This expression logs
sensitive data (password)
as clear text.

Copilot Autofix

AI 15 days ago

General approach: Ensure that raw API keys are never stored in or exposed via structures that are later logged or printed, and keep any sensitive values as ephemeral as possible (e.g., only used to show one-time output to the operator, or written to a dedicated secure channel). Specifically, we should avoid including new_api_key inside the migrations entries that are returned from regenerate_api_keys, because results (containing migrations) is passed to print_report(results) and may be logged or displayed. Instead, we can handle the API key in a separate, clearly-scoped way (e.g., print it directly when created, with a clear warning, and only once) and track only non-sensitive metadata in migrations.

Best minimal fix without changing existing external behavior too much:

  1. In backend/scripts/regenerate_api_keys.py, inside regenerate_api_keys:

    • Stop storing new_api_key in each migration dict. Replace the "new_api_key": new_api_key field with something non-sensitive, such as "new_api_key_redacted": True (or simply omit that field altogether). This removes the secret from the aggregate results structure and breaks the taint chain into results.
    • Optionally (and safely) log or print the new API key immediately after creation, but keep that outside of any aggregated structure. The comment already says # Note: API key is not logged for security - see report output instead; to stay conservative and keep functionality, we should not log the key and should keep any existing behavior of print_report (which we are not shown). Given the constraint to only change shown snippets, the safest approach is simply to avoid putting new_api_key into migrations and let print_report work with the remaining fields (key IDs, status, etc.). If print_report expects new_api_key, it will just see None, which is safer than exposing secrets and is an acceptable hardening for an admin-only migration script.
    • This change ensures that results contains no raw key material, so logging results["failed"] or any other counter is guaranteed not to leak the key.
  2. No change is required to backend/app/services/api_key_service.py for logging purposes, since that file already avoids logging the api_key and only returns it once in a dict. The main issue arises from how that return value is propagated into a long-lived report.

Concrete edits:

  • In backend/scripts/regenerate_api_keys.py, in the migrations.append({ ... }) block for the successful regeneration, change:

    "new_api_key": new_api_key,

    to either:

    "new_api_key": None,

    or (better, to reflect semantics explicitly):

    "new_api_key": None,  # API key intentionally omitted for security

    This ensures that no secret is reachable via results.

No other lines need to change to satisfy the clear-text logging concern, and we do not need to modify the flagged log line itself, since after this fix results no longer contains the sensitive field.


Suggested changeset 1
backend/scripts/regenerate_api_keys.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/backend/scripts/regenerate_api_keys.py b/backend/scripts/regenerate_api_keys.py
--- a/backend/scripts/regenerate_api_keys.py
+++ b/backend/scripts/regenerate_api_keys.py
@@ -172,7 +172,7 @@
                     "old_key_name": name,
                     "tier_name": tier_name,
                     "status": "regenerated",
-                    "new_api_key": new_api_key,
+                    "new_api_key": None,  # API key intentionally omitted for security
                     "new_key_id": new_key_id,
                     "error": None,
                 }
EOF
@@ -172,7 +172,7 @@
"old_key_name": name,
"tier_name": tier_name,
"status": "regenerated",
"new_api_key": new_api_key,
"new_api_key": None, # API key intentionally omitted for security
"new_key_id": new_key_id,
"error": None,
}
Copilot is powered by AI and may make mistakes. Always verify output.
sys.exit(1)
elif results["keys_to_regenerate"] > 0:
logger.info("Migration completed successfully")
else:
logger.info("No keys needed regeneration")

except Exception as e:
logger.error(f"Fatal error during migration: {e}", exc_info=True)
sys.exit(1)


if __name__ == "__main__":
asyncio.run(main())
16 changes: 12 additions & 4 deletions backend/tests/services/test_api_key_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import pytest

from app.services.api_key_service import APIKeyService
from app.services.api_key_service import API_KEY_HASH_ITERATIONS, API_KEY_HASH_SALT, APIKeyService


@pytest.mark.unit
Expand All @@ -28,13 +28,21 @@ def test_generate_api_key(self, api_key_service):
assert len(key) == 36 # UUID v4 format

def test_hash_api_key(self, api_key_service):
"""Test API key hashing."""
"""Test API key hashing using PBKDF2."""
key = "test-api-key-123"
key_hash = api_key_service.hash_api_key(key)

# Should be SHA-256 hash (64 hex characters)
# Should be PBKDF2-HMAC-SHA256 hash (64 hex characters)
assert len(key_hash) == 64
assert key_hash == hashlib.sha256(key.encode()).hexdigest()

# Verify it's using PBKDF2 with the correct parameters
expected_hash = hashlib.pbkdf2_hmac(
"sha256",
key.encode("utf-8"),
API_KEY_HASH_SALT,
API_KEY_HASH_ITERATIONS,
).hex()
assert key_hash == expected_hash

def test_hash_api_key_consistency(self, api_key_service):
"""Test that hashing the same key produces the same hash."""
Expand Down
Loading