perf(auth): load user passwords when they change, not every 10s

The password table was re-read and re-decrypted on a 10s timer, while the
file it reads only ever changes when the security downloader replaces it,
every 300s. The downloader already reloads the table on a successful
download, so 29 of every 30 decrypt cycles produced an identical result.

Each cycle also read the encryption key from disk once per entry, because
decrypt_password builds its own Fernet: 781 entries meant 781 stats, 781
reads of the same key file and 781 Fernet constructions, about 78 key
reads a second sustained.

Dropping the timer and decrypting a table on one Fernet takes this from
~23,400 key reads per five minutes to one, measured at 85.5ms per cycle
on a server carrying 781 entries.

The loader now keeps no config of its own: it was held only to let the
expired timer reload itself.
pull/91/head
Rodrigo Pérez 1 week ago
parent e76d49716d
commit 5ee724dc92

@ -66,3 +66,20 @@ def decrypt_password(encrypted_password: Optional[str], key_path: str = "config/
return decrypted.decode("utf-8")
except Exception:
return encrypted_password
def decrypt_passwords(
encrypted: dict[str, str], key_path: str = "config/encryption_key.secret"
) -> dict[str, str]:
"""Decrypt a whole table on one Fernet: the key is read once, not once per entry."""
fernet = get_fernet(key_path)
decrypted: dict[str, str] = {}
for radio_id, password in encrypted.items():
if not password:
decrypted[str(radio_id)] = ""
continue
try:
decrypted[str(radio_id)] = fernet.decrypt(password.encode("utf-8")).decode("utf-8")
except Exception:
decrypted[str(radio_id)] = password
return decrypted

@ -28,32 +28,27 @@ from __future__ import annotations
import json
import logging
import os
import time
from typing import Any
logger = logging.getLogger(__name__)
USER_PASSWORDS_RELOAD_INTERVAL = 10.0
_last_load = 0.0
class UserPasswordsLoader:
"""Load and cache decrypted user passwords from GLOBAL.USERS_PASS (JSON with 'passwords' dict)."""
"""Load and cache decrypted user passwords from GLOBAL.USERS_PASS (JSON with 'passwords' dict).
Loaded at startup and again whenever the security downloader replaces the file,
which is the only way it changes. Re-reading it on a timer decrypted the whole
table ~30 times between downloads for an identical result.
"""
def __init__(self, project_root: str) -> None:
self._project_root = project_root
self._passwords: dict[str, str] = {}
self._config: dict[str, Any] = {}
self._config_dir = os.path.join(project_root, "config")
self._key_path = os.path.join(self._config_dir, "encryption_key.secret")
def load(self, config: dict[str, Any]) -> dict[str, str]:
"""Load user_passwords.json from data dir, decrypt each; return passwords dict. Legacy load_user_passwords."""
global _last_load
self._config = config
now = time.time()
if now - _last_load < USER_PASSWORDS_RELOAD_INTERVAL and self._passwords:
return self._passwords
previous = dict(self._passwords)
data_dir = os.path.join(
self._project_root,
@ -68,7 +63,6 @@ class UserPasswordsLoader:
hash_encrypt = (config.get("GLOBAL", {}).get("HASH_ENCRYPT") or "encryption_key.secret").strip()
self._key_path = os.path.join(key_path, hash_encrypt)
if not os.path.exists(path):
_last_load = now
if previous:
logger.warning("(AUTH) user passwords file missing, keeping cached passwords")
return self._passwords
@ -79,13 +73,9 @@ class UserPasswordsLoader:
data = json.load(f)
if not isinstance(data, dict) or not isinstance(data.get("passwords"), dict):
raise ValueError("invalid user_passwords.json shape")
encrypted = data.get("passwords", {})
from .password_crypto import decrypt_password
from .password_crypto import decrypt_passwords
new_passwords: dict[str, str] = {}
for radio_id, pwd in encrypted.items():
new_passwords[str(radio_id)] = decrypt_password(pwd, self._key_path) or ""
self._passwords = new_passwords
self._passwords = decrypt_passwords(data.get("passwords", {}), self._key_path)
logger.debug("(AUTH) Loaded %d individual passwords from %s", len(self._passwords), path)
except (FileNotFoundError, json.JSONDecodeError, ValueError, Exception) as e:
logger.warning("(AUTH) Could not load user passwords: %s", e)
@ -94,13 +84,10 @@ class UserPasswordsLoader:
self._passwords = previous
else:
self._passwords = {}
_last_load = now
return self._passwords
def get_user_password(self, radio_id: int) -> bytes | None:
"""Return password for radio_id (for login auth); 7-char prefix match like legacy. Legacy get_user_password."""
if time.time() - _last_load >= USER_PASSWORDS_RELOAD_INTERVAL and self._config:
self.load(self._config)
radio_id_str = str(radio_id)
if not radio_id_str.isdigit():
return None

Loading…
Cancel
Save

Powered by TurnKey Linux.