ai_config was missing from PROTECTED_SETTING_KEYS, so the AI provider's api_token sat in plaintext in the settings table, and GET returned it in full to the browser. It is now Fernet-encrypted like every other integration config, and GET shows the token as ***set***. The AI clients and the model-list fallback read the decrypted value. The fallback also treats the ***set*** placeholder as "use the stored token". Rows saved in plaintext before their key was protected used to stay that way until the next edit. encrypt_plaintext_rows now encrypts them at startup, for ai_config and any key added to the list later.
110 lines
3.4 KiB
Python
110 lines
3.4 KiB
Python
"""
|
|
Transparent at-rest encryption for sensitive setting values.
|
|
|
|
Setting rows whose `key` is in PROTECTED_SETTING_KEYS get their `value`
|
|
column encrypted with the AUTH_PROVIDER_CRYPTO_KEY Fernet key (same key
|
|
that protects TOTP secrets). Encrypted values are prefixed with
|
|
`ENC_PREFIX` so reads can detect ciphertext vs. legacy plaintext and
|
|
auto-migrate on the next write; `encrypt_plaintext_rows` (run at startup)
|
|
encrypts any still-plaintext row, e.g. after a key joins the list.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.auth.totp import _fernet # reuses AUTH_PROVIDER_CRYPTO_KEY
|
|
from app.models.setting import Setting
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ENC_PREFIX = "enc:v1:"
|
|
|
|
# Settings whose value field contains secrets (API tokens, passwords).
|
|
# Values written under these keys are encrypted at rest; reads transparently
|
|
# decrypt. Pre-existing plaintext rows still readable until next write.
|
|
PROTECTED_SETTING_KEYS: frozenset[str] = frozenset({
|
|
"wazuh_config",
|
|
"smtp_config",
|
|
"nessus_config",
|
|
"openrouter_api_key",
|
|
"typesafe_api_key",
|
|
"intune_config",
|
|
"vcenter_config",
|
|
"igel_config",
|
|
"netdisco_config",
|
|
"dockhand_config",
|
|
"xenserver_config",
|
|
"github_pat",
|
|
"ai_config",
|
|
})
|
|
|
|
|
|
def is_protected(key: str) -> bool:
|
|
return key in PROTECTED_SETTING_KEYS
|
|
|
|
|
|
def encrypt_value(plaintext: str) -> str:
|
|
"""Return ENC_PREFIX + base64-ciphertext."""
|
|
token = _fernet().encrypt(plaintext.encode()).decode()
|
|
return f"{ENC_PREFIX}{token}"
|
|
|
|
|
|
def decrypt_value(stored: str) -> str:
|
|
"""Decrypt an ENC_PREFIX-tagged value. Passes plaintext through unchanged."""
|
|
if not stored or not stored.startswith(ENC_PREFIX):
|
|
return stored
|
|
ciphertext = stored[len(ENC_PREFIX):]
|
|
return _fernet().decrypt(ciphertext.encode()).decode()
|
|
|
|
|
|
def read_setting_value(db: Session, key: str) -> Optional[str]:
|
|
"""Return decrypted setting value (or None if not set)."""
|
|
row = db.query(Setting).filter(Setting.key == key).first()
|
|
if not row or row.value is None:
|
|
return None
|
|
if is_protected(key):
|
|
try:
|
|
return decrypt_value(row.value)
|
|
except Exception as e:
|
|
logger.error("Failed to decrypt setting %s: %s", key, e)
|
|
return None
|
|
return row.value
|
|
|
|
|
|
def encrypt_plaintext_rows(db: Session) -> int:
|
|
"""Encrypt protected rows still stored in plaintext. Returns how many."""
|
|
rows = (db.query(Setting)
|
|
.filter(Setting.key.in_(PROTECTED_SETTING_KEYS))
|
|
.filter(Setting.value.isnot(None), Setting.value != "")
|
|
.all())
|
|
pending = [r for r in rows if not r.value.startswith(ENC_PREFIX)]
|
|
for row in pending:
|
|
row.value = encrypt_value(row.value)
|
|
if pending:
|
|
db.commit()
|
|
logger.info("Encrypted %d plaintext setting(s): %s",
|
|
len(pending), ", ".join(r.key for r in pending))
|
|
return len(pending)
|
|
|
|
|
|
def write_setting_value(
|
|
db: Session,
|
|
key: str,
|
|
value: str,
|
|
description: str = "",
|
|
) -> Setting:
|
|
"""Upsert a setting, encrypting if the key is protected."""
|
|
stored = encrypt_value(value) if is_protected(key) else value
|
|
row = db.query(Setting).filter(Setting.key == key).first()
|
|
if row:
|
|
row.value = stored
|
|
else:
|
|
row = Setting(key=key, value=stored, description=description)
|
|
db.add(row)
|
|
db.commit()
|
|
db.refresh(row)
|
|
return row
|