-
Notifications
You must be signed in to change notification settings - Fork 1
Potential fix for code scanning alert no. 18: Use of a broken or weak cryptographic hashing algorithm on sensitive data #56
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Conversation
… cryptographic hashing algorithm on sensitive data Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
- Consolidated the encoding of the API_KEY_HASH_SALT variable into a single line for improved readability. - Updated the test for API key hashing to explicitly verify the use of PBKDF2 with the correct parameters, enhancing the test's accuracy and clarity.
- Introduced `regenerate_api_keys.py` to handle the regeneration of API keys created before the PBKDF2 hashing change on January 20, 2026. - The script identifies outdated keys, creates replacements with the same settings, and can optionally deactivate old keys. - Includes a dry-run mode for previewing changes and generates a detailed migration report. - Updated documentation to reflect the new script and the implications of the hashing migration.
| 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
sensitive data (password)
This expression logs
sensitive data (password)
This expression logs
sensitive data (password)
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 12 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 theresultsstructure 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 usesresultsfor 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_keyitself; 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 printsnew_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.
-
Copy modified line R231
| @@ -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})") |
| print("=" * 80) | ||
| print(f"Total API keys found: {results['total_keys']}") | ||
| print(f"Keys needing regeneration: {results['keys_to_regenerate']}") | ||
| print(f"Successfully regenerated: {results['regenerated']}") |
Check failure
Code scanning / CodeQL
Clear-text logging of sensitive information High
sensitive data (password)
This expression logs
sensitive data (password)
This expression logs
sensitive data (password)
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 12 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 thestatus == "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.
-
Copy modified lines R234-R237
| @@ -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})") |
| 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}") |
Check failure
Code scanning / CodeQL
Clear-text logging of sensitive information High
sensitive data (password)
This expression logs
sensitive data (password)
This expression logs
sensitive data (password)
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 12 days ago
General approach:
- Do not print the raw API key (
new_api_key) directly inprint_report. - Avoid passing the raw API key through the same
results/migrationsstructure 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:
-
Stop including
new_api_keyin themigrationsentries used forprint_report.- In
regenerate_api_keys, change the"new_api_key": new_api_keyfield to something non-sensitive (e.g.,None) in themigrations.append(...)call for successful regenerations. - Similarly, for the dry‑run and error cases, leave
"new_api_key": Noneas it is (already not exposing data).
- In
-
Stop printing the new API key in
print_report.- In
print_report, under thestatus == "regenerated"branch, remove or replace the lineprint(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.
- In
-
(Optional but consistent): If we want admins to still obtain the new API keys in a controlled way, we can:
- Keep
new_api_keyonly in the in-memoryresultsstructure returned fromregenerate_api_keys, but not inmigrationsthatprint_reportiterates. Instead, add a separate top‑level list, e.g.,"new_keys_secrets": [...], that contains dicts withold_key_id,new_key_id,api_key. Then, themainfunction (or the caller) can decide what to do with it (write to a secure file, print only when a--show-secretsflag 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_keyat all viaprint_report/stdout and to keep the rest as-is.
- Keep
Given the instructions, I’ll implement the minimal change:
- In
backend/scripts/regenerate_api_keys.py:- In the
migrations.appenddict for thestatus == "regenerated"case, set"new_api_key": Noneinstead ofnew_api_key. - In
print_report, remove the retrieval ofnew_key = migration["new_api_key"]and the subsequent line that prints"→ New API Key: {new_key}".
- In the
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.
-
Copy modified line R159 -
Copy modified line R175
| @@ -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})") |
| 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}") |
Check failure
Code scanning / CodeQL
Clear-text logging of sensitive information High
sensitive data (password)
This expression logs
sensitive data (password)
This expression logs
sensitive data (password)
This expression logs
sensitive data (password)
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 12 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 theelif 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.
-
Copy modified line R231 -
Copy modified line R234
| @@ -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})") |
| 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})") |
Check failure
Code scanning / CodeQL
Clear-text logging of sensitive information High
sensitive data (password)
This expression logs
sensitive data (password)
This expression logs
sensitive data (password)
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 12 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_keyin themigrationsreport structure, so it never reachesprint_report. - Adjust
print_reportso it no longer printsnew_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:
- 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_keyinto eachmigrationentry.
- We should:
- Still extract
new_api_keyso we can use it locally if needed, but not put it into themigrationsdict that is returned and printed. - Replace the
"new_api_key": new_api_keyfield with something non-sensitive, such asNoneor 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 toNone.
- Still extract
- In
print_report, remove the lineprint(f" → New API Key: {new_key}"), and optionally replace it with a message likeprint(" → 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. - No changes are needed in
backend/app/services/api_key_service.pyfor 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.
-
Copy modified line R159 -
Copy modified line R175 -
Copy modified line R233
| @@ -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})") |
| elif status == "failed" or status == "error": | ||
| error = migration["error"] | ||
| print(f" ✗ Key ID {old_id} ({old_name}, tier: {tier})") | ||
| print(f" Error: {error}") |
Check failure
Code scanning / CodeQL
Clear-text logging of sensitive information High
sensitive data (password)
This expression logs
sensitive data (password)
This expression logs
sensitive data (password)
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 12 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 themigrationsentry for a successful regeneration, replace"new_api_key": new_api_keywith"new_api_key": Noneor a masked/truncated representation that is not usable as a credential. - In
print_report, remove or change the line that printsnew_keyso 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 inprint_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.
-
Copy modified line R159 -
Copy modified lines R175-R176 -
Copy modified line R234
| @@ -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})") |
|
|
||
| # 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
sensitive data (password)
This expression logs
sensitive data (password)
This expression logs
sensitive data (password)
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 12 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:
-
In
backend/scripts/regenerate_api_keys.py, insideregenerate_api_keys:- Stop storing
new_api_keyin eachmigrationdict. Replace the"new_api_key": new_api_keyfield with something non-sensitive, such as"new_api_key_redacted": True(or simply omit that field altogether). This removes the secret from the aggregateresultsstructure and breaks the taint chain intoresults. - 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 ofprint_report(which we are not shown). Given the constraint to only change shown snippets, the safest approach is simply to avoid puttingnew_api_keyintomigrationsand letprint_reportwork with the remaining fields (key IDs, status, etc.). Ifprint_reportexpectsnew_api_key, it will just seeNone, which is safer than exposing secrets and is an acceptable hardening for an admin-only migration script. - This change ensures that
resultscontains no raw key material, so loggingresults["failed"]or any other counter is guaranteed not to leak the key.
- Stop storing
-
No change is required to
backend/app/services/api_key_service.pyfor logging purposes, since that file already avoids logging theapi_keyand 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 themigrations.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.
-
Copy modified line R175
| @@ -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, | ||
| } |
- Modified the `regenerate_api_keys.py` script to prevent logging of the new API key for security reasons, ensuring sensitive information is not exposed in logs. Instead, users are directed to check the report output for key details.
Potential fix for https://github.com/geobtaa/geospatial-api/security/code-scanning/18
In general terms, the fix is to avoid using a fast, general-purpose hash (SHA-256) for hashing secrets used as authenticators and instead use a password hashing/key derivation function that is intentionally slow and parameterizable (e.g., Argon2, bcrypt, scrypt, or PBKDF2). This makes offline brute-force attacks impractical if the stored hashes are leaked.
For this specific code, the best minimal-impact fix is to change
hash_api_keyto use Python’s standard-library PBKDF2 implementation (hashlib.pbkdf2_hmac) with sufficient iterations and a per-key random salt. Because we only see the hashing function and the write path in this snippet (and not the verify/lookup path), we must preserve the method signature and return type while improving security. We can do this by:hash_api_keyto derive a key usinghashlib.pbkdf2_hmacwith a high iteration count (e.g., 100,000+), and return the hex-encoded result.hash_api_key(key: str) -> strinterface so all existing callers (like the insert logic on lines 176–189) remain unchanged.Concretely in
backend/app/services/api_key_service.py:os(for generating a module-level random salt) and define a constantAPI_KEY_HASH_SALTand iteration count near the top of the file, close to the logger definition.hash_api_keyso it useshashlib.pbkdf2_hmac("sha256", key.encode("utf-8"), API_KEY_HASH_SALT, API_KEY_HASH_ITERATIONS).hex()instead ofhashlib.sha256(...).hexdigest().Suggested fixes powered by Copilot Autofix. Review carefully before merging.