"""Encrypted, per-browser persistence of the Setup config (incl. API key). Goal: a visitor enters their API key once and, on the same browser, never has to re-enter it — without any login or any code they must remember. A random, invisible per-browser *tag* identifies the returning visitor (see :mod:`app.services.browser_tag`); this module stores their config in Supabase, encrypted so that: * Supabase only ever holds **ciphertext** — a DB/storage leak reveals nothing. * The encryption secret is derived from the browser tag, which is **never sent to Supabase**, so one visitor's stored blob can't be read with another's tag. * The storage object id is derived from the tag with a *different* salt than the encryption key, so even the id can't be turned back into the decryption key. Everything here is **fail-safe**: if Supabase isn't configured, or any network call fails, every function quietly degrades to a no-op / ``None`` and the app falls back to today's session-only behavior. It must never raise into the UI. Storage backend: a private Supabase Storage bucket (auto-created on first use), one small object per browser tag. Using Storage means no database schema / SQL setup is required — the bucket is created via the service key at runtime. """ from __future__ import annotations import base64 import hashlib import json import os from typing import Optional # Bucket that holds the encrypted per-tag config blobs. _BUCKET = "apikeys" _TIMEOUT = 8.0 # Salts keep the storage-object id and the encryption key independent, so # knowing the id (which Supabase stores) never yields the decryption key. _ID_SALT = b"upwork-strategist::object-id::v1" _KEY_SALT = b"upwork-strategist::enc-key::v1" # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- def is_configured() -> bool: """True only when the Supabase service credentials are present.""" return bool(_base_url() and _service_key()) def _base_url() -> str: return (os.getenv("SUPABASE_URL") or "").rstrip("/") def _service_key() -> str: return os.getenv("SUPABASE_SERVICE_KEY") or "" # --------------------------------------------------------------------------- # Crypto / id derivation (pure, unit-testable) # --------------------------------------------------------------------------- def _object_path(tag: str) -> str: digest = hashlib.sha256(_ID_SALT + tag.encode("utf-8")).hexdigest() return f"{digest}.bin" def _fernet(tag: str): from cryptography.fernet import Fernet raw = hashlib.sha256(_KEY_SALT + tag.encode("utf-8")).digest() return Fernet(base64.urlsafe_b64encode(raw)) def encrypt_payload(tag: str, payload: dict) -> bytes: """Encrypt a config dict for ``tag`` -> ciphertext bytes (pure).""" data = json.dumps(payload, separators=(",", ":")).encode("utf-8") return _fernet(tag).encrypt(data) def decrypt_payload(tag: str, token: bytes) -> Optional[dict]: """Decrypt ciphertext for ``tag`` -> config dict, or ``None`` if it can't be decrypted with this tag (wrong tag / corrupt / tampered).""" from cryptography.fernet import InvalidToken try: plain = _fernet(tag).decrypt(token) obj = json.loads(plain) return obj if isinstance(obj, dict) else None except (InvalidToken, ValueError, TypeError): return None # --------------------------------------------------------------------------- # HTTP transport (single seam — monkeypatched in tests) # --------------------------------------------------------------------------- def _http(method: str, url: str, headers: dict, content: Optional[bytes] = None): """Perform one HTTP request. Returns ``(status_code, body_bytes)``. Isolated so tests can replace it with an in-memory fake. Uses ``httpx``, which is already installed (httpx is an OpenAI SDK dependency). """ import httpx with httpx.Client(timeout=_TIMEOUT) as client: resp = client.request(method, url, headers=headers, content=content) return resp.status_code, resp.content def _auth_headers(extra: Optional[dict] = None) -> dict: key = _service_key() headers = {"Authorization": f"Bearer {key}", "apikey": key} if extra: headers.update(extra) return headers def _ensure_bucket() -> None: """Create the private bucket if missing. Idempotent; errors are ignored (a 'already exists' response is the normal case after the first run).""" url = f"{_base_url()}/storage/v1/bucket" body = json.dumps({"id": _BUCKET, "name": _BUCKET, "public": False}).encode() try: _http("POST", url, _auth_headers({"Content-Type": "application/json"}), body) except Exception: # noqa: BLE001 - best-effort; save() will surface real failure pass # --------------------------------------------------------------------------- # Public API (fail-safe) # --------------------------------------------------------------------------- def save_config(tag: str, payload: dict) -> bool: """Encrypt ``payload`` under ``tag`` and upsert it to Supabase. Returns True on success, False on any failure or when not configured. Never raises. """ if not (is_configured() and tag and payload): return False try: _ensure_bucket() token = encrypt_payload(tag, payload) url = f"{_base_url()}/storage/v1/object/{_BUCKET}/{_object_path(tag)}" # x-upsert overwrites an existing object for this tag. status, _ = _http( "POST", url, _auth_headers( {"Content-Type": "application/octet-stream", "x-upsert": "true"} ), token, ) return 200 <= status < 300 except Exception: # noqa: BLE001 - fail-safe: never break the UI return False def load_config(tag: str) -> Optional[dict]: """Fetch and decrypt the stored config for ``tag``. Returns the config dict, or ``None`` if absent / not configured / any failure. Never raises. """ if not (is_configured() and tag): return None obj = _object_path(tag) # Supabase exposes private downloads at two equivalent paths depending on # version; try the authenticated form first, then the plain one. candidates = ( f"/storage/v1/object/authenticated/{_BUCKET}/{obj}", f"/storage/v1/object/{_BUCKET}/{obj}", ) for path in candidates: try: status, body = _http("GET", f"{_base_url()}{path}", _auth_headers()) except Exception: # noqa: BLE001 - fail-safe; try the next candidate continue if 200 <= status < 300 and body: payload = decrypt_payload(tag, body) if payload is not None: return payload return None def delete_config(tag: str) -> bool: """Remove the stored config for ``tag`` (used by Clear Credentials). Returns True on success, False otherwise. Never raises. """ if not (is_configured() and tag): return False try: url = f"{_base_url()}/storage/v1/object/{_BUCKET}/{_object_path(tag)}" status, _ = _http("DELETE", url, _auth_headers()) return 200 <= status < 300 except Exception: # noqa: BLE001 - fail-safe return False