Spaces:
Configuration error
Configuration error
| """backend/api/vault.py — Encrypted token vault. | |
| GAP-VAULT-CRYPTO (fix): sostituisce XOR-stream (two-time-pad) con Fernet (AES-128-CBC + HMAC-SHA256 + nonce univoco). | |
| GAP-VAULT-AUTH (fix): aggiunge _require_vault_auth (Bearer VAULT_ADMIN_TOKEN) su endpoint con accesso ai valori. | |
| Backward-compat: _vault_decrypt tenta Fernet, poi fallback XOR per segreti già salvati (migrazione trasparente). | |
| """ | |
| import os, base64 as _b64, hashlib as _hashlib, hmac as _hmac_mod, json as _json_v, secrets as _secrets_mod, logging, time | |
| from pathlib import Path as _Path | |
| from typing import Optional | |
| from fastapi import APIRouter, HTTPException, Request, Depends, Header | |
| from pydantic import BaseModel | |
| try: | |
| from cryptography.fernet import Fernet as _Fernet, InvalidToken as _InvalidToken | |
| _HAS_FERNET = True | |
| except ImportError: | |
| _HAS_FERNET = False | |
| router = APIRouter() | |
| _vault_logger = logging.getLogger('agente_ai') | |
| # ── Vault directory resolution ───────────────────────────────────────────────── | |
| def _resolve_vault_dir() -> _Path: | |
| env_path = os.getenv('VAULT_DATA_DIR', '') | |
| if env_path: | |
| return _Path(env_path) | |
| for candidate in [_Path('/data/.vault_data'), _Path('/tmp/.vault_data')]: | |
| try: | |
| candidate.mkdir(parents=True, exist_ok=True) | |
| return candidate | |
| except (PermissionError, OSError): | |
| continue | |
| return _Path('/tmp/.vault_data') | |
| _VAULT_DATA_DIR = _resolve_vault_dir() | |
| try: | |
| _VAULT_DATA_DIR.mkdir(parents=True, exist_ok=True) | |
| except (PermissionError, OSError) as _exc: | |
| _vault_logger.debug("[vault] silenced %s", type(_exc).__name__) | |
| _VAULT_PATH = _Path(os.getenv('VAULT_PATH', str(_VAULT_DATA_DIR / 'vault_secrets.json'))) | |
| # ── Chiave vault ─────────────────────────────────────────────────────────────── | |
| _raw_vault_key = os.getenv('VAULT_KEY', '') | |
| _VAULT_KEY_IS_PERSISTENT = bool(_raw_vault_key) | |
| if not _raw_vault_key: | |
| _raw_vault_key = _secrets_mod.token_hex(32) | |
| _vault_logger.critical('BOOT: VAULT_KEY not set — vault usa chiave EPHEMERAL, tutti i secret vanno persi al restart!') | |
| _vault_logger.critical('BOOT: Fix → imposta VAULT_KEY in HF Spaces: Settings > Repository secrets') | |
| _vault_logger.critical('BOOT: Genera con: python3 -c "import secrets; print(secrets.token_hex(32))"') | |
| _VAULT_KEY_RAW = _raw_vault_key.encode() | |
| # Per Fernet: URL-safe base64 di SHA256(VAULT_KEY) | |
| _FERNET_KEY = _b64.urlsafe_b64encode(_hashlib.sha256(_VAULT_KEY_RAW).digest()) | |
| _fernet_instance = _Fernet(_FERNET_KEY) if _HAS_FERNET else None | |
| # Per fallback XOR (legacy decrypt): SHA256 digest grezzo | |
| _VAULT_KEY_BYTES = _hashlib.sha256(_VAULT_KEY_RAW).digest() | |
| if not _HAS_FERNET: | |
| _vault_logger.error('BOOT: cryptography non installata — Fernet non disponibile, uso XOR legacy (meno sicuro). Aggiungi cryptography>=42 a requirements.txt') | |
| # ── GAP-VAULT-AUTH: autenticazione Bearer ───────────────────────────────────── | |
| _VAULT_ADMIN_TOKEN = os.getenv('VAULT_ADMIN_TOKEN', '') | |
| async def _require_vault_auth(authorization: Optional[str] = Header(None)) -> None: | |
| """Richiede Authorization: Bearer VAULT_ADMIN_TOKEN se configurato. | |
| Se VAULT_ADMIN_TOKEN non è impostato: nessuna protezione aggiuntiva (backward compat). | |
| Configura VAULT_ADMIN_TOKEN in HF Spaces secrets per abilitare l'autenticazione. | |
| Genera con: python3 -c "import secrets; print(secrets.token_hex(32))" | |
| """ | |
| if not _VAULT_ADMIN_TOKEN: | |
| return # Auth disabilitata — imposta VAULT_ADMIN_TOKEN per proteggere il vault | |
| if authorization != f'Bearer {_VAULT_ADMIN_TOKEN}': | |
| _vault_logger.warning('vault: unauthorized access attempt') | |
| raise HTTPException(status_code=401, detail='Vault: non autorizzato — Bearer token non valido o mancante') | |
| # ── Crittografia: Fernet (AES-128-CBC + HMAC-SHA256 + nonce univoco) ─────────── | |
| def _vault_encrypt(plaintext: str) -> str: | |
| """GAP-VAULT-CRYPTO fix: Fernet con nonce casuale per ogni cifratura (niente two-time-pad).""" | |
| if _fernet_instance: | |
| return _fernet_instance.encrypt(plaintext.encode('utf-8')).decode('ascii') | |
| # Fallback XOR legacy se cryptography non installata | |
| return _vault_encrypt_xor(plaintext) | |
| def _vault_decrypt(ciphertext: str) -> str: | |
| """Decrittografia con migrazione trasparente: tenta Fernet, poi XOR legacy.""" | |
| if _fernet_instance: | |
| try: | |
| return _fernet_instance.decrypt(ciphertext.encode('ascii')).decode('utf-8') | |
| except Exception: | |
| pass # Potrebbe essere un vecchio ciphertext XOR — prova fallback | |
| return _vault_decrypt_xor(ciphertext) | |
| # ── XOR legacy (usato solo come fallback per migrazione segreti esistenti) ──── | |
| def _vault_encrypt_xor(plaintext: str) -> str: | |
| pt = plaintext.encode('utf-8') | |
| key_stream = b'' | |
| i = 0 | |
| while len(key_stream) < len(pt): | |
| key_stream += _hashlib.sha256(_VAULT_KEY_BYTES + i.to_bytes(4, 'big')).digest() | |
| i += 1 | |
| ct = bytes(a ^ b for a, b in zip(pt, key_stream[:len(pt)])) | |
| sig = _hmac_mod.new(_VAULT_KEY_BYTES, ct, _hashlib.sha256).digest() | |
| return _b64.b64encode(sig + ct).decode() | |
| def _vault_decrypt_xor(ciphertext: str) -> str: | |
| raw = _b64.b64decode(ciphertext) | |
| sig, ct = raw[:32], raw[32:] | |
| expected_sig = _hmac_mod.new(_VAULT_KEY_BYTES, ct, _hashlib.sha256).digest() | |
| if not _hmac_mod.compare_digest(sig, expected_sig): | |
| raise ValueError('VAULT: firma non valida — chiave errata o dati corrotti') | |
| key_stream = b'' | |
| i = 0 | |
| while len(key_stream) < len(ct): | |
| key_stream += _hashlib.sha256(_VAULT_KEY_BYTES + i.to_bytes(4, 'big')).digest() | |
| i += 1 | |
| return bytes(a ^ b for a, b in zip(ct, key_stream[:len(ct)])).decode('utf-8') | |
| # ── I/O ──────────────────────────────────────────────────────────────────────── | |
| def _vault_load() -> dict: | |
| try: | |
| return _json_v.loads(_VAULT_PATH.read_text()) if _VAULT_PATH.exists() else {} | |
| except Exception: | |
| return {} | |
| def _vault_save_data(data: dict) -> None: | |
| _VAULT_PATH.write_text(_json_v.dumps(data, indent=2)) | |
| # ── Rate limiting ────────────────────────────────────────────────────────────── | |
| _vault_rl: dict[str, list[float]] = {} | |
| _VAULT_RL_LIMIT = 30 | |
| _VAULT_RL_WINDOW = 60.0 | |
| def _vault_rate_check(request: Request) -> None: | |
| ip = (request.client.host if request.client else None) or 'unknown' | |
| now = time.monotonic() | |
| hits = [t for t in _vault_rl.get(ip, []) if now - t < _VAULT_RL_WINDOW] | |
| if len(hits) >= _VAULT_RL_LIMIT: | |
| _vault_logger.warning('vault rate limit hit: ip=%s hits=%d', ip, len(hits)) | |
| raise HTTPException(status_code=429, detail='Vault rate limit exceeded - max 30 req/min per IP') | |
| hits.append(now) | |
| _vault_rl[ip] = hits | |
| if len(_vault_rl) > 200: | |
| cutoff = now - _VAULT_RL_WINDOW | |
| stale = [k for k, v in list(_vault_rl.items()) if not v or v[-1] < cutoff] | |
| for k in stale: | |
| _vault_rl.pop(k, None) | |
| class _VaultSaveRequest(BaseModel): | |
| key: str | |
| value: str | |
| description: str = '' | |
| # ── Endpoints ────────────────────────────────────────────────────────────────── | |
| async def vault_health(): | |
| """Stato vault: chiave persistente o effimera, path, count. Pubblico (nessun valore esposto).""" | |
| data = _vault_load() | |
| warning = None if _VAULT_KEY_IS_PERSISTENT else ( | |
| 'VAULT_KEY non configurata: chiave effimera in uso. ' | |
| 'Tutti i segreti andranno PERSI al prossimo restart del container. ' | |
| 'Imposta VAULT_KEY nei secrets HF Spaces (Settings > Repository secrets).' | |
| ) | |
| auth_warning = None if _VAULT_ADMIN_TOKEN else ( | |
| 'VAULT_ADMIN_TOKEN non configurato: endpoint vault accessibili senza autenticazione. ' | |
| 'Imposta VAULT_ADMIN_TOKEN nei secrets HF Spaces per proteggere i token.' | |
| ) | |
| return { | |
| 'persistent': _VAULT_KEY_IS_PERSISTENT, | |
| 'encryption': 'fernet' if _HAS_FERNET else 'xor-legacy', | |
| 'vault_path': str(_VAULT_PATH), | |
| 'secrets_count': len(data), | |
| 'warning': warning, | |
| 'auth_warning': auth_warning, | |
| } | |
| async def vault_status(): | |
| """Lista chiavi (non valori). Pubblico: espone solo nomi, non segreti.""" | |
| data = _vault_load() | |
| return {'keys': list(data.keys()), 'count': len(data)} | |
| async def vault_save( | |
| req: _VaultSaveRequest, | |
| request: Request, | |
| _auth: None = Depends(_require_vault_auth), | |
| ): | |
| """Salva un segreto cifrato con Fernet. Richiede Bearer VAULT_ADMIN_TOKEN se configurato.""" | |
| _vault_rate_check(request) | |
| key = req.key.lower().strip().replace(' ', '_') | |
| if not key or not req.value.strip(): | |
| raise HTTPException(status_code=400, detail='key e value sono obbligatori') | |
| data = _vault_load() | |
| data[key] = _vault_encrypt(req.value.strip()) | |
| _vault_save_data(data) | |
| _vault_logger.info('vault: saved key=%s enc=fernet', key) | |
| return {'ok': True, 'key': key, 'encrypted': True, 'encryption': 'fernet' if _HAS_FERNET else 'xor-legacy'} | |
| async def vault_get_token( | |
| key: str, | |
| request: Request, | |
| _auth: None = Depends(_require_vault_auth), | |
| ): | |
| """Restituisce il valore decifrato. Richiede Bearer VAULT_ADMIN_TOKEN se configurato.""" | |
| _vault_rate_check(request) | |
| data = _vault_load() | |
| if key not in data: | |
| raise HTTPException(status_code=404, detail=f"Chiave '{key}' non trovata nel vault") | |
| try: | |
| return {'key': key, 'value': _vault_decrypt(data[key])} | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f'Decryption error: {e}') | |
| async def vault_delete( | |
| key: str, | |
| request: Request, | |
| _auth: None = Depends(_require_vault_auth), | |
| ): | |
| """Elimina un segreto. Richiede Bearer VAULT_ADMIN_TOKEN se configurato.""" | |
| _vault_rate_check(request) | |
| data = _vault_load() | |
| if key not in data: | |
| raise HTTPException(status_code=404, detail=f"Chiave '{key}' non trovata") | |
| del data[key] | |
| _vault_save_data(data) | |
| return {'ok': True, 'key': key, 'deleted': True} | |