Spaces:
Sleeping
Sleeping
File size: 7,374 Bytes
be469a4 2c8d3ee be469a4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | """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
|