"""Symmetric encryption for credentials we have to store. Used today for the per-user Nextcloud app password — we need the plaintext to put it in an outgoing HTTP Basic header, so a one-way hash won't do. Key is derived from `settings.secret_key` via SHA-256 so existing deployments don't need a separate KMS dance, and a stable SECRET_KEY rotates these credentials automatically. Fernet is symmetric AES-128-CBC + HMAC-SHA256 with a versioned ciphertext envelope; good enough for column-level secrecy in a single-host homelab. Rotate by setting a new SECRET_KEY and asking users to re-enter their app password. """ import base64 import hashlib from typing import Optional from cryptography.fernet import Fernet, InvalidToken from app.config import settings def _fernet() -> Fernet: # Fernet requires a 32-byte url-safe base64 key. SHA-256 of the # configured secret gives us exactly 32 bytes; b64-urlsafe-encode # to fit the API contract. digest = hashlib.sha256(settings.secret_key.encode("utf-8")).digest() return Fernet(base64.urlsafe_b64encode(digest)) def encrypt(plaintext: str) -> str: """Return a base64 token that can be stored in a VARCHAR column.""" return _fernet().encrypt(plaintext.encode("utf-8")).decode("ascii") def decrypt(token: Optional[str]) -> Optional[str]: """Inverse of encrypt. Returns None for None / empty input. Raises on tampered or wrong-key tokens — callers should treat that as "credential unset" rather than crashing the request.""" if not token: return None try: return _fernet().decrypt(token.encode("ascii")).decode("utf-8") except InvalidToken: return None