|
7 | 7 |
|
8 | 8 | from __future__ import annotations |
9 | 9 |
|
| 10 | +import logging |
10 | 11 | import os |
11 | 12 | from datetime import datetime, timedelta |
12 | 13 | from pathlib import Path |
13 | 14 | from typing import Any |
14 | 15 |
|
15 | 16 | import aiosqlite |
| 17 | +from cryptography.fernet import Fernet, InvalidToken |
| 18 | + |
| 19 | +_logger = logging.getLogger(__name__) |
16 | 20 |
|
17 | 21 | DB_DIR = Path(os.getenv("CASHPILOT_DATA_DIR", "/data")) |
18 | 22 | DB_PATH = DB_DIR / "cashpilot.db" |
19 | 23 |
|
| 24 | +# --------------------------------------------------------------------------- |
| 25 | +# Credential encryption (Fernet) |
| 26 | +# --------------------------------------------------------------------------- |
| 27 | + |
| 28 | +_FERNET_KEY_FILE = DB_DIR / ".fernet_key" |
| 29 | + |
| 30 | +# Keys that contain secrets and must be encrypted at rest |
| 31 | +SECRET_CONFIG_KEYS = { |
| 32 | + "password", |
| 33 | + "token", |
| 34 | + "auth_token", |
| 35 | + "access_token", |
| 36 | + "api_key", |
| 37 | + "secret_key", |
| 38 | + "session_cookie", |
| 39 | + "oauth_token", |
| 40 | + "brd_sess_id", |
| 41 | + "remember_web", |
| 42 | + "xsrf_token", |
| 43 | +} |
| 44 | + |
| 45 | + |
| 46 | +def _is_secret_key(key: str) -> bool: |
| 47 | + """Return True if a config key holds a secret value (by suffix match).""" |
| 48 | + lower = key.lower() |
| 49 | + return any(lower.endswith(s) for s in SECRET_CONFIG_KEYS) |
| 50 | + |
| 51 | + |
| 52 | +def _load_or_create_fernet() -> Fernet: |
| 53 | + """Load or generate the Fernet encryption key.""" |
| 54 | + try: |
| 55 | + if _FERNET_KEY_FILE.is_file(): |
| 56 | + raw = _FERNET_KEY_FILE.read_text().strip() |
| 57 | + if raw: |
| 58 | + return Fernet(raw.encode()) |
| 59 | + except (OSError, ValueError): |
| 60 | + pass |
| 61 | + |
| 62 | + key = Fernet.generate_key() |
| 63 | + try: |
| 64 | + DB_DIR.mkdir(parents=True, exist_ok=True) |
| 65 | + _FERNET_KEY_FILE.write_text(key.decode()) |
| 66 | + _FERNET_KEY_FILE.chmod(0o600) |
| 67 | + _logger.info("Generated new Fernet key at %s", _FERNET_KEY_FILE) |
| 68 | + except OSError as exc: |
| 69 | + _logger.warning("Could not persist Fernet key: %s", exc) |
| 70 | + return Fernet(key) |
| 71 | + |
| 72 | + |
| 73 | +_fernet = _load_or_create_fernet() |
| 74 | + |
| 75 | +_ENC_PREFIX = "enc:" |
| 76 | + |
| 77 | + |
| 78 | +def encrypt_value(value: str) -> str: |
| 79 | + """Encrypt a string value, returning an 'enc:' prefixed token.""" |
| 80 | + return _ENC_PREFIX + _fernet.encrypt(value.encode()).decode() |
| 81 | + |
| 82 | + |
| 83 | +def decrypt_value(value: str) -> str: |
| 84 | + """Decrypt an 'enc:' prefixed token back to plaintext.""" |
| 85 | + if not value.startswith(_ENC_PREFIX): |
| 86 | + return value # Not encrypted (legacy data) |
| 87 | + try: |
| 88 | + return _fernet.decrypt(value[len(_ENC_PREFIX) :].encode()).decode() |
| 89 | + except InvalidToken: |
| 90 | + _logger.warning("Failed to decrypt config value — key may have changed") |
| 91 | + return "" |
| 92 | + |
| 93 | + |
20 | 94 | _SCHEMA = """ |
21 | 95 | CREATE TABLE IF NOT EXISTS earnings ( |
22 | 96 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
@@ -94,6 +168,7 @@ async def _get_db() -> aiosqlite.Connection: |
94 | 168 | db = await aiosqlite.connect(str(DB_PATH)) |
95 | 169 | db.row_factory = aiosqlite.Row |
96 | 170 | await db.execute("PRAGMA journal_mode=WAL") |
| 171 | + await db.execute("PRAGMA foreign_keys=ON") |
97 | 172 | return db |
98 | 173 |
|
99 | 174 |
|
@@ -389,40 +464,48 @@ async def get_daily_earnings(days: int = 7) -> list[dict[str, Any]]: |
389 | 464 |
|
390 | 465 |
|
391 | 466 | async def get_config(key: str | None = None) -> dict[str, str] | str | None: |
392 | | - """Get a single config value (if key given) or all config as a dict.""" |
| 467 | + """Get a single config value (if key given) or all config as a dict. |
| 468 | +
|
| 469 | + Secret values are decrypted transparently. |
| 470 | + """ |
393 | 471 | db = await _get_db() |
394 | 472 | try: |
395 | 473 | if key: |
396 | 474 | cursor = await db.execute("SELECT value FROM config WHERE key = ?", (key,)) |
397 | 475 | row = await cursor.fetchone() |
398 | | - return row["value"] if row else None |
| 476 | + if not row: |
| 477 | + return None |
| 478 | + val = row["value"] |
| 479 | + return decrypt_value(val) if _is_secret_key(key) else val |
399 | 480 | cursor = await db.execute("SELECT key, value FROM config") |
400 | 481 | rows = await cursor.fetchall() |
401 | | - return {r["key"]: r["value"] for r in rows} |
| 482 | + return {r["key"]: (decrypt_value(r["value"]) if _is_secret_key(r["key"]) else r["value"]) for r in rows} |
402 | 483 | finally: |
403 | 484 | await db.close() |
404 | 485 |
|
405 | 486 |
|
406 | 487 | async def set_config(key: str, value: str) -> None: |
407 | | - """Upsert a config key-value pair.""" |
| 488 | + """Upsert a config key-value pair. Secrets are encrypted at rest.""" |
| 489 | + stored = encrypt_value(value) if _is_secret_key(key) else value |
408 | 490 | db = await _get_db() |
409 | 491 | try: |
410 | 492 | await db.execute( |
411 | 493 | "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", |
412 | | - (key, value), |
| 494 | + (key, stored), |
413 | 495 | ) |
414 | 496 | await db.commit() |
415 | 497 | finally: |
416 | 498 | await db.close() |
417 | 499 |
|
418 | 500 |
|
419 | 501 | async def set_config_bulk(data: dict[str, str]) -> None: |
420 | | - """Upsert multiple config entries at once.""" |
| 502 | + """Upsert multiple config entries at once. Secrets are encrypted at rest.""" |
| 503 | + pairs = [(k, encrypt_value(v) if _is_secret_key(k) else v) for k, v in data.items()] |
421 | 504 | db = await _get_db() |
422 | 505 | try: |
423 | 506 | await db.executemany( |
424 | 507 | "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", |
425 | | - list(data.items()), |
| 508 | + pairs, |
426 | 509 | ) |
427 | 510 | await db.commit() |
428 | 511 | finally: |
|
0 commit comments