"""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 ────────────────────────────────────────────────────────────────── @router.get('/api/vault/health') 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, } @router.get('/api/vault/status') 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)} @router.post('/api/vault') 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'} @router.get('/api/vault/token/{key}') 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}') @router.delete('/api/vault/{key}') 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}