File size: 6,901 Bytes
62b83b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f86beae
62b83b2
f86beae
 
62b83b2
 
 
 
 
 
 
 
 
f86beae
 
 
62b83b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Auth, RBAC, and API-key management (the three must-haves).

1. Superadmin login  -> a login session token with expiry.
2. User session expiry -> login_sessions.expires_at (LOGIN_SESSION_TTL_HOURS).
3. Provider API keys  -> api_keys.expires_at (lifetime or N days, default 2 weeks).

Roles:
- 'user'       : end users. scopes = chat + websearch + image (NO agentic tools).
- 'superadmin' : full access (chat, websearch, image, agents, commands, files, mcp).
"""
from __future__ import annotations

import hashlib
import hmac
import re
import secrets
import time
from datetime import datetime, timedelta, timezone

from . import config, db

# ---------- brute-force protection (in-memory, per-instance) ----------
_fails: dict[tuple, list[float]] = {}
MAX_FAILS = 5         # failures allowed per window before lockout
FAIL_WINDOW = 300.0   # 5-minute sliding window


def too_many_fails(ip: str, bucket: str) -> bool:
    now = time.time()
    k = (ip, bucket)
    arr = [t for t in _fails.get(k, []) if now - t < FAIL_WINDOW]
    _fails[k] = arr
    return len(arr) >= MAX_FAILS


def record_fail(ip: str, bucket: str) -> None:
    _fails.setdefault((ip, bucket), []).append(time.time())


def clear_fails(ip: str, bucket: str) -> None:
    _fails.pop((ip, bucket), None)


# ---------- input hardening ----------
_SAFE_SID = re.compile(r"[^a-zA-Z0-9_.\-]")


def safe_session_id(sid: str) -> str:
    """Strip anything that could traverse the filesystem; keep it a flat token."""
    sid = _SAFE_SID.sub("_", str(sid))[:128]
    sid = sid.replace("..", "_")
    return sid or "default"


def within(child, parent) -> bool:
    """True only if `child` resolves to a path inside `parent` (containment guarantee)."""
    from pathlib import Path
    try:
        Path(child).resolve().relative_to(Path(parent).resolve())
        return True
    except (ValueError, OSError):
        return False


# ---------- prompt-extraction / jailbreak guard (deterministic, pre-model) ----------
_JAILBREAK = re.compile(
    r"(?is)("
    r"ignore\s+(?:the\s+|all\s+|any\s+|your\s+|previous\s+|prior\s+|above\s+|earlier\s+)*"
    r"(?:instruction|rule|prompt|guideline|directive)s?"
    r"|(?:reveal|show|print|repeat|give\s+me|tell\s+me|share|output|display|leak|expose|"
    r"what(?:'s| is| are)|state|recite)\b[^.\n]{0,40}\b(?:your|the|its|first|initial|original|hidden|secret)\b"
    r"[^.\n]{0,25}\b(?:system\s+)?(?:prompt|instruction|rule|configuration|guideline|directive)s?"
    r"|repeat\s+(?:everything|all|the\s+text|what(?:'s| is)?)\b[^.\n]{0,20}\babove"
    r"|(?:developer|debug|god|admin|dev|sudo|dan)\s*mode"
    r"|\bjailbreak\b|\bsystem\s*prompt\b"
    r"|pretend\s+(?:you\s+are|to\s+be)\s+(?:a\s+)?(?:different|another|new)\s+(?:ai|system|model|assistant)"
    r")"
)

EXTRACTION_REFUSAL = (
    "I can't share my internal configuration or instructions — but I'm happy to help "
    "with a question, search, or task."
)


def is_extraction_attempt(text: str) -> bool:
    return bool(_JAILBREAK.search(text or ""))

# RBAC: what each role is allowed to do.
ROLE_SCOPES = {
    "user": ["chat", "websearch", "image"],
    "superadmin": ["chat", "websearch", "image", "agents", "commands", "files", "mcp"],
}


# ---------- password hashing (stdlib pbkdf2, no extra deps) ----------
def hash_password(password: str) -> str:
    salt = secrets.token_hex(16)
    dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000)
    return f"pbkdf2$200000${salt}${dk.hex()}"


def verify_password(password: str, stored: str) -> bool:
    try:
        algo, iters, salt, want = stored.split("$")
        dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), int(iters))
        return hmac.compare_digest(dk.hex(), want)
    except Exception:
        return False


# ---------- superadmin bootstrap ----------
def ensure_superadmin() -> None:
    """Create/refresh the superadmin from env on startup (no-op without DB or password)."""
    if not db.available() or not config.SUPERADMIN_PASSWORD:
        return
    db.init_schema()
    db.upsert_user(config.SUPERADMIN_USER, hash_password(config.SUPERADMIN_PASSWORD), "superadmin")


# ---------- login sessions (with expiry) ----------
def login(username: str, password: str) -> dict | None:
    user = db.get_user(username)
    if not user or not verify_password(password, user["password_hash"]):
        return None
    token = "sess_" + secrets.token_urlsafe(32)
    expires = datetime.now(timezone.utc) + timedelta(hours=config.LOGIN_SESSION_TTL_HOURS)
    db.create_login(token, user["id"], user["role"], expires)
    return {"token": token, "role": user["role"], "expires_at": expires.isoformat(),
            "user_id": user["id"]}


def check_login(token: str) -> dict | None:
    """Return the login session if valid and not expired, else None."""
    if not token:
        return None
    return db.get_login(token)


# ---------- provider API keys (with expiry + scopes) ----------
def _hash_key(raw: str) -> str:
    return hashlib.sha256(raw.encode()).hexdigest()


def generate_api_key(role: str, label: str = "", scopes: list[str] | None = None,
                     ttl_days: int | None = None, created_by: int | None = None,
                     max_sessions: int | None = None, agents_md: str | None = None) -> dict:
    """Create a key. Returns the FULL key once (store it; only the hash is persisted).
    ttl_days: None -> default; 0 -> lifetime. max_sessions: None/0 -> unlimited.
    agents_md: optional per-user AGENTS.md instructions applied to this key only."""
    role = role if role in ROLE_SCOPES else "user"
    scopes = scopes or ROLE_SCOPES[role]
    raw = "ant_" + secrets.token_urlsafe(32)
    prefix = raw[:12] + "…"
    if ttl_days is None:
        ttl_days = config.DEFAULT_API_KEY_TTL_DAYS
    expires_at = None if ttl_days == 0 else datetime.now(timezone.utc) + timedelta(days=ttl_days)
    if max_sessions in (None, 0):
        max_sessions = None  # unlimited
    agents_md = (agents_md or "").strip() or None
    rec = db.create_api_key(_hash_key(raw), prefix, label, role, scopes, created_by,
                            expires_at, max_sessions, agents_md)
    rec["api_key"] = raw  # shown once
    rec["expires_at"] = expires_at.isoformat() if expires_at else "lifetime"
    rec["max_sessions"] = max_sessions or "unlimited"
    return rec


def validate_api_key(raw: str) -> dict | None:
    """Return the key record if valid (exists, not revoked, not expired), else None."""
    if not raw:
        return None
    rec = db.get_api_key_by_hash(_hash_key(raw))
    if not rec or rec["revoked"]:
        return None
    if rec["expires_at"] and rec["expires_at"] <= datetime.now(timezone.utc):
        return None
    return rec


def scopes_for(rec: dict) -> list[str]:
    return rec.get("scopes") or ROLE_SCOPES.get(rec.get("role", "user"), ROLE_SCOPES["user"])