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