Hash API keys with HMAC-SHA256 + persistent server secret

Stores the HMAC secret in a new app_secrets singleton table. Fixes
CodeQL py/weak-sensitive-data-hashing alert on storage.py:74-76,
394-395. Refresh tokens stay on plain SHA-256 (unchanged _hash_token)
so existing user sessions survive upgrade — API keys are new on this
branch so there is no migration.
This commit is contained in:
Roland Tannous 2026-04-13 15:49:39 +04:00
commit 2010901666

View file

@ -6,6 +6,7 @@ SQLite storage for authentication data (user credentials + JWT secret).
"""
import hashlib
import hmac
import secrets
import sqlite3
from datetime import datetime, timezone
@ -72,7 +73,19 @@ def clear_bootstrap_password() -> None:
def _hash_token(token: str) -> str:
"""SHA-256 hash helper used for refresh token storage."""
"""SHA-256 hash helper used for refresh token storage.
Plain SHA-256 is intentional here: refresh tokens are high-entropy
random strings from ``secrets.token_urlsafe(48)`` (384 bits of
entropy), so a slow KDF (Argon2 / bcrypt / PBKDF2) provides zero
additional security no attacker can brute-force 2^384 regardless
of hash speed while adding tens of ms of CPU to every refresh.
See the OWASP Password Storage Cheat Sheet on fast-vs-slow hashing
of high-entropy inputs.
API keys use the separate ``_hmac_api_key`` helper below, which
keyed-hashes with a persistent server secret for defense-in-depth.
"""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@ -118,6 +131,14 @@ def get_connection() -> sqlite3.Connection:
);
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS app_secrets (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
)
columns = {row["name"] for row in conn.execute("PRAGMA table_info(auth_user)")}
if "must_change_password" not in columns:
conn.execute(
@ -127,6 +148,71 @@ def get_connection() -> sqlite3.Connection:
return conn
# ── API-key HMAC secret ────────────────────────────────────────────────
#
# Module-level cache for the persistent API-key HMAC secret. Populated
# lazily on first use via ``_get_or_create_api_key_hmac_secret``. Not
# protected by a lock because (a) the ``INSERT OR IGNORE`` provides
# atomicity at the SQLite layer and (b) concurrent populations converge
# on the same value, so the worst case is a harmless duplicate read on
# startup.
_api_key_hmac_secret_cache: Optional[bytes] = None
def _get_or_create_api_key_hmac_secret() -> bytes:
"""Return the persistent API-key HMAC secret, generating it once if missing.
Stored as a hex-encoded 32-byte random value in the ``app_secrets``
table under key ``"api_key_hmac"``. Regenerated only if the row is
missing (i.e. fresh install, or operator manually deleted the row
and accepts invalidating existing API keys).
"""
global _api_key_hmac_secret_cache
if _api_key_hmac_secret_cache is not None:
return _api_key_hmac_secret_cache
conn = get_connection()
try:
cur = conn.execute(
"SELECT value FROM app_secrets WHERE key = ?",
("api_key_hmac",),
)
row = cur.fetchone()
if row is None:
new_value = secrets.token_hex(32) # 32 bytes -> 64 hex chars
conn.execute(
"INSERT OR IGNORE INTO app_secrets (key, value) VALUES (?, ?)",
("api_key_hmac", new_value),
)
conn.commit()
cur = conn.execute(
"SELECT value FROM app_secrets WHERE key = ?",
("api_key_hmac",),
)
row = cur.fetchone()
secret = bytes.fromhex(row["value"])
finally:
conn.close()
_api_key_hmac_secret_cache = secret
return secret
def _hmac_api_key(raw_key: str) -> str:
"""HMAC-SHA256 an API key with the persistent server-side secret.
Used for API-key storage ONLY, not refresh tokens. API keys are
cryptographically random 128-bit tokens (via ``secrets.token_hex``),
so a slow KDF would buy zero security 2^128 is unreachable by
brute force regardless of hash speed. HMAC with a persistent secret
adds defense-in-depth vs. plain SHA-256: an attacker dumping the
``api_keys`` table alone cannot compute hashes for candidate
tokens without also obtaining the ``app_secrets`` row.
"""
secret = _get_or_create_api_key_hmac_secret()
return hmac.new(secret, raw_key.encode("utf-8"), hashlib.sha256).hexdigest()
def is_initialized() -> bool:
"""Check if auth is ready for login (at least one user exists in DB)."""
conn = get_connection()
@ -392,7 +478,7 @@ def create_api_key(
exactly once. The database only stores the SHA-256 hash.
"""
raw_key = API_KEY_PREFIX + secrets.token_hex(16)
key_hash = _hash_token(raw_key)
key_hash = _hmac_api_key(raw_key)
key_prefix = raw_key[len(API_KEY_PREFIX) : len(API_KEY_PREFIX) + 8]
now = datetime.now(timezone.utc).isoformat()
@ -450,7 +536,7 @@ def validate_api_key(raw_key: str) -> Optional[str]:
Also updates ``last_used_at`` on success.
"""
key_hash = _hash_token(raw_key)
key_hash = _hmac_api_key(raw_key)
conn = get_connection()
try:
cur = conn.execute(