File size: 19,213 Bytes
bb56382
971fa87
 
 
 
 
 
 
 
bb56382
971fa87
bb56382
 
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb56382
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb56382
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb56382
971fa87
 
 
bb56382
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb56382
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb56382
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
bb56382
971fa87
 
 
 
 
 
 
 
bb56382
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb56382
971fa87
 
 
 
 
 
 
 
 
 
47d5028
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb56382
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb56382
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb56382
971fa87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
"""Authentication, encryption, and session management.

Encryption model (defense in depth)
==================================

Every "important" secret in the panel is encrypted **twice**::

  stored = USER_FERNET.encrypt(STATIC_FERNET.encrypt(plaintext))

* **Inner** — ``STATIC_FERNET`` uses ``PANEL_ENCRYPTION_KEY`` (32-byte
  Fernet key from env).  Compromising the DB on its own is not
  enough — the panel admin key is still required to read anything.
* **Outer** — ``USER_FERNET`` uses a per-user key generated at
  signup, stored encrypted-by-PANEL-KEY on the ``User`` row, and
  optionally mirrored to Supabase.  Even if ``PANEL_ENCRYPTION_KEY``
  leaks, the attacker still needs every user's outer key to read
  their secrets.

Backwards compatibility
-----------------------

Older rows that were encrypted only with the static key are still
readable.  ``SecretVault.decrypt`` tries the two-tier path first,
and falls back to a single-layer decrypt on the same value if the
outer layer is absent or doesn't decrypt cleanly.

When a user *writes* a secret, the panel always uses the
two-tier form (assuming the user has a key).  Unwrapped single-layer
rows persist on disk and get re-wrapped the next time the user
edits that secret.

Supabase mirror
---------------

If ``SUPABASE_URL`` and ``SUPABASE_SERVICE_KEY`` are configured,
``PerUserKeyManager`` mirrors every user key into a ``user_keys``
table in the user's Supabase project.  This gives a second,
cloud-resident copy in case the panel's storage is wiped — the
user can re-import keys from Supabase on a fresh install.
"""
from __future__ import annotations

import base64
import hashlib
import hmac
import logging
import secrets
import time
from dataclasses import dataclass
from typing import Optional

import bcrypt
from cryptography.fernet import Fernet, InvalidToken
from fastapi import Request, HTTPException, status
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired

from config import PANEL_CONFIG

log = logging.getLogger("hosting-panel.auth")


# ---------------------------------------------------------------------------
# Two-tier secret vault
# ---------------------------------------------------------------------------


class SecretVault:
    """Encrypt / decrypt bot secrets with the panel's static key.

    Pass a per-user key to enable the second tier.  Decryption tries
    both tiers and falls back to single-tier if the outer is missing
    or malformed (legacy rows).
    """

    def __init__(self) -> None:
        raw = PANEL_CONFIG.encryption_key
        if not raw:
            # Without this key the panel can't decrypt anything, but we
            # don't want startup to die — let the admin see a clear
            # warning at the encrypt/decrypt boundary.
            self._fernet: Optional[Fernet] = None
            log.warning(
                "PANEL_ENCRYPTION_KEY is not set — encrypted secrets "
                "will fail to decrypt. Set the env var and restart."
            )
            return
        try:
            self._fernet = Fernet(raw.encode() if isinstance(raw, str) else raw)
        except Exception as exc:
            raise RuntimeError(f"Invalid PANEL_ENCRYPTION_KEY: {exc}") from exc

    @property
    def is_configured(self) -> bool:
        return self._fernet is not None

    # -- encrypt -----------------------------------------------------------

    def encrypt(self, plaintext: str, user_key: Optional[str] = None) -> str:
        """Encrypt ``plaintext`` with the static key, then optionally
        wrap the result with ``user_key``.

        With no user key, this is a single-layer Fernet — legacy format.
        """
        if not plaintext:
            return plaintext
        if not self._fernet:
            raise RuntimeError("PANEL_ENCRYPTION_KEY not configured")

        inner = self._fernet.encrypt(plaintext.encode()).decode()
        if not user_key:
            return inner
        user_fernet = self._user_fernet(user_key)
        return user_fernet.encrypt(inner.encode()).decode()

    # -- decrypt -----------------------------------------------------------

    def decrypt(self, ciphertext: Optional[str], user_key: Optional[str] = None) -> str:
        """Decrypt ``ciphertext``.  Tries the layered path first, then
        falls back to the single-tier path.  Returns ``""`` on empty
        input — never raises unless the value is real but undecryptable
        with *both* keys.
        """
        if not ciphertext:
            return ""
        if not self._fernet:
            raise RuntimeError("PANEL_ENCRYPTION_KEY not configured")

        # Try two-tier first (with user key)
        if user_key:
            try:
                outer = self._user_fernet(user_key).decrypt(ciphertext.encode())
                return self._fernet.decrypt(outer).decode()
            except InvalidToken:
                pass  # may be a single-tier legacy row — fall through
            except Exception:
                pass

        # Fallback: single-tier (PANEL key only)
        try:
            return self._fernet.decrypt(ciphertext.encode()).decode()
        except InvalidToken as exc:
            raise ValueError(
                "Cannot decrypt secret — PANEL_ENCRYPTION_KEY has likely "
                "changed since this row was written, or the value is corrupted. "
                "If you rotated the key, this secret must be re-entered."
            ) from exc

    # -- helpers -----------------------------------------------------------

    @staticmethod
    def _user_fernet(user_key: str) -> Fernet:
        """Validate a user-supplied Fernet key.

        Accepts raw bytes (b"..."), urlsafe-base64, or urlsafe-base64
        without padding.  We pad and validate.
        """
        if isinstance(user_key, str):
            kb = user_key.encode()
        else:
            kb = user_key
        try:
            return Fernet(kb)
        except Exception:
            # try padding
            pad = -len(kb) % 4
            try:
                return Fernet(kb + b"=" * pad)
            except Exception as exc:
                raise ValueError(f"Invalid user key: {exc}") from exc

    @staticmethod
    def generate_user_key() -> str:
        """Generate a fresh 32-byte urlsafe-base64 Fernet key."""
        return Fernet.generate_key().decode()


VAULT = SecretVault()


# ---------------------------------------------------------------------------
# Per-user key management
# ---------------------------------------------------------------------------


@dataclass
class UserKey:
    """Plaintext per-user Fernet key + metadata.

    ``created_at`` is set locally so the panel can warn the user about
    rotation age; ``last_used`` is updated on every decrypt.
    """
    key: str
    created_at: float
    source: str = "local"        # "local" | "supabase"
    key_id: Optional[str] = None # opaque id (Supabase row primary key)


class PerUserKeyManager:
    """Persistent store + Supabase mirror for per-user Fernet keys.

    The panel keeps the canonical copy on ``User.user_key_enc`` (a
    Fernet-wrapped Fernet key).  When Supabase is configured, we also
    mirror the plaintext key to a ``user_keys`` table there so a
    fresh panel can import keys from a cloud-resident copy.
    """

    def __init__(self) -> None:
        self._supabase = None
        if PANEL_CONFIG.supabase_url and PANEL_CONFIG.supabase_service_key:
            try:
                from supabase import create_client
                self._supabase = create_client(
                    PANEL_CONFIG.supabase_url,
                    PANEL_CONFIG.supabase_service_key,
                )
                log.info("Supabase mirror enabled: %s", PANEL_CONFIG.supabase_url)
            except Exception as exc:
                log.warning("Supabase client init failed — mirror disabled: %s", exc)

    @property
    def mirror_enabled(self) -> bool:
        return self._supabase is not None

    # -- read / write ------------------------------------------------------

    def get_user_key(self, user) -> Optional[str]:
        """Return the plaintext per-user Fernet key for ``user``.

        ``user`` is a ``models.User`` instance.  Returns
        ``None`` when the user doesn't have a key yet (e.g. pre-layer
        accounts).
        """
        if user.user_key_enc:
            try:
                return VAULT.decrypt(user.user_key_enc)
            except Exception as exc:
                log.warning("Failed to decrypt user_key for %s: %s", user.username, exc)
        return None

    async def ensure_user_key(self, user, db_session) -> str:
        """Make sure the user has a key; create one if not.

        Returns the plaintext key.  Persists to the local User row
        *and* mirrors to Supabase if enabled.  Works on both sync and
        async ``db_session`` — detects which one we got.
        """
        existing = self.get_user_key(user)
        if existing:
            return existing

        new_key = VAULT.generate_user_key()
        user.user_key_enc = VAULT.encrypt(new_key)
        await _commit_session(db_session)

        if self._supabase:
            try:
                self._supabase.table("user_keys").insert({
                    "user_id": user.id,
                    "username": user.username,
                    "user_key": new_key,
                    "created_at": time.time(),
                }).execute()
            except Exception as exc:
                log.warning("Supabase mirror insert failed for user %s: %s",
                            user.username, exc)
        return new_key

    async def rotate_user_key(self, user, db_session) -> str:
        """Generate a new key, re-wrap all of the user's secrets with it,
        persist both copies.  Returns the new plaintext key."""
        import inspect
        from sqlalchemy import select
        from models import BotInstance

        old_key = self.get_user_key(user)
        new_key = VAULT.generate_user_key()

        # Re-wrap all of the user's bot secrets.
        cls_name = type(db_session).__name__
        is_async = "Async" in cls_name

        if is_async:
            stmt = select(BotInstance).where(BotInstance.owner_id == user.id)
            result = await db_session.execute(stmt)
            bots = list(result.scalars().all())
        else:
            stmt = select(BotInstance).where(BotInstance.owner_id == user.id)
            result = db_session.execute(stmt)
            bots = list(result.scalars().all())

        old_inner_decrypts: list[tuple] = []
        for bot in bots:
            for field in ("bot_token_enc", "hf_token_enc",
                          "nvidia_api_key_enc", "llm_proxy_token_enc"):
                cipher = getattr(bot, field)
                if not cipher:
                    continue
                try:
                    plain = VAULT.decrypt(cipher, user_key=old_key)
                except Exception:
                    try:
                        plain = VAULT.decrypt(cipher)
                    except Exception:
                        log.warning("Cannot decrypt %s on bot %s during rotation; skipping",
                                    field, getattr(bot, "id", "?"))
                        plain = None
                if plain is not None:
                    old_inner_decrypts.append((bot, field, plain))

        # Persist the new user key on the User row FIRST so the next
        # encrypt() uses it.
        user.user_key_enc = VAULT.encrypt(new_key)
        await _commit_session(db_session)

        # Re-encrypt every secret with the new key (and PANEL key).
        for bot, field, plain in old_inner_decrypts:
            setattr(bot, field, VAULT.encrypt(plain, user_key=new_key))
        await _commit_session(db_session)

        if self._supabase:
            try:
                self._supabase.table("user_keys").upsert({
                    "user_id": user.id,
                    "username": user.username,
                    "user_key": new_key,
                    "created_at": time.time(),
                }).execute()
            except Exception as exc:
                log.warning("Supabase mirror upsert failed: %s", exc)

        log.info("Rotated user key for %s — re-wrapped %d secret(s)",
                 user.username, len(old_inner_decrypts))
        return new_key

    def export_to_supabase(self, user) -> bool:
        """Force-mirror the user's key to Supabase.  Returns ``True``
        on success."""
        if not self._supabase:
            return False
        key = self.get_user_key(user)
        if not key:
            return False
        try:
            self._supabase.table("user_keys").upsert({
                "user_id": user.id,
                "username": user.username,
                "user_key": key,
                "created_at": time.time(),
            }).execute()
            return True
        except Exception as exc:
            log.warning("Supabase mirror failed for user %s: %s", user.username, exc)
            return False

    def import_from_supabase(self, user) -> bool:
        """Pull a Supabase mirror back into the local User row."""
        if not self._supabase:
            return False
        try:
            res = (self._supabase.table("user_keys")
                   .select("user_key")
                   .eq("user_id", user.id)
                   .execute())
        except Exception as exc:
            log.warning("Supabase read failed: %s", exc)
            return False
        rows = res.data or []
        if not rows:
            return False
        key = rows[0]["user_key"]
        user.user_key_enc = VAULT.encrypt(key)
        return True


PER_USER_KEYS = PerUserKeyManager()


# ---------------------------------------------------------------------------
# Password hashing
# ---------------------------------------------------------------------------

def hash_password(password: str) -> str:
    """Hash a password with bcrypt."""
    return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode()


def verify_password(password: str, hashed: str) -> bool:
    """Verify a password against a bcrypt hash."""
    try:
        return bcrypt.checkpw(password.encode(), hashed.encode())
    except Exception:
        return False


# ---------------------------------------------------------------------------
# Session management (signed cookies)
# ---------------------------------------------------------------------------

@dataclass
class SessionUser:
    user_id: int
    username: str
    role: str
    expires_at: float


_serializer = URLSafeTimedSerializer(PANEL_CONFIG.secret_key, salt="panel-session")


def create_session(user_id: int, username: str, role: str) -> str:
    """Create a signed session token."""
    expires_at = time.time() + PANEL_CONFIG.session_max_age_sec
    data = {"uid": user_id, "u": username, "r": role, "exp": expires_at}
    return _serializer.dumps(data)


def parse_session(token: str) -> Optional[SessionUser]:
    """Parse and validate a session token."""
    try:
        data = _serializer.loads(token, max_age=PANEL_CONFIG.session_max_age_sec)
    except (BadSignature, SignatureExpired):
        return None
    expires_at = data.get("exp", 0)
    if time.time() > expires_at:
        return None
    return SessionUser(
        user_id=data["uid"],
        username=data["u"],
        role=data["r"],
        expires_at=expires_at,
    )


def get_session_user(request: Request) -> Optional[SessionUser]:
    """Extract session from cookie."""
    token = request.cookies.get("session")
    if not token:
        return None
    return parse_session(token)


def require_auth(request: Request) -> SessionUser:
    """Dependency: raise 302 to /login if not authenticated."""
    user = get_session_user(request)
    if not user:
        raise HTTPException(status_code=status.HTTP_302_FOUND, headers={"Location": "/login"})
    return user


def require_role(*roles: str):
    """Dependency factory: require specific role(s)."""
    def _check(request: Request) -> SessionUser:
        user = require_auth(request)
        if user.role not in roles:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="Insufficient permissions.",
            )
        return user
    return _check


# ---------------------------------------------------------------------------
# CSRF protection
# ---------------------------------------------------------------------------

def generate_csrf_token() -> str:
    """Generate a CSRF token."""
    return secrets.token_urlsafe(32)


def validate_csrf(request: Request, token: str) -> bool:
    """Validate CSRF token from form against cookie."""
    cookie_token = request.cookies.get("csrf_token")
    if not cookie_token or not token:
        return False
    return hmac.compare_digest(cookie_token, token)


# ---------------------------------------------------------------------------
# Invite codes
# ---------------------------------------------------------------------------

def generate_invite_code() -> str:
    """Generate a random invite code."""
    return secrets.token_urlsafe(16)


# ---------------------------------------------------------------------------
# Client info for audit logging
# ---------------------------------------------------------------------------

def get_client_info(request: Request) -> dict[str, Optional[str]]:
    """Extract client IP and UA from request."""
    forwarded = request.headers.get("x-forwarded-for")
    ip = forwarded.split(",")[0].strip() if forwarded else request.client.host if request.client else None
    return {
        "ip_address": ip,
        "user_agent": request.headers.get("user-agent"),
    }


# ---------------------------------------------------------------------------
# Sync/async session adapter
# ---------------------------------------------------------------------------
# Some helpers (the per-user key rotation, secret re-wrapping, Supabase
# mirror sync) need to work whether the caller is in a sync context
# (CLI tools, ``run_admin.py``) or an async request handler.
# ---------------------------------------------------------------------------

async def _commit_session(db_session) -> None:
    """Commit a SQLAlchemy session whether it's sync or async."""
    import inspect
    commit = db_session.commit()
    if inspect.isawaitable(commit):
        await commit
    else:
        # Already committed in the sync path.
        pass


async def _exec(db_session, query_fn):
    """Run a synchronous query in both sync and async sessions.

    Returns the raw result.  For async sessions, this uses
    ``run_sync`` to bridge.
    """
    import inspect
    cls = db_session if not inspect.isclass(db_session) else None
    name = type(db_session).__name__
    if "Async" in name or "AsyncSession" in name:
        from sqlalchemy import select
        # Sync query into async session.
        return await db_session.run_sync(query_fn)
    return query_fn(db_session)