UpworkAutomation / app /services /key_store.py
ananyajoshi's picture
key_store: try both Supabase download endpoint forms for robustness
2c8d3ee verified
Raw
History Blame Contribute Delete
7.37 kB
"""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