Terminal / api /vault.py
Pulka
sync: 111 file da Baida98/AI@e8f2ba6a (2026-06-21 16:56 UTC)
068e397 verified
Raw
History Blame
10.9 kB
"""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}