""" Sérialiseur signé — RPA Vision V3 Remplace les usages de `pickle.load` (vulnérables à la désérialisation arbitraire de code) par une sérialisation JSON signée via HMAC-SHA256. Principes : - Les données sont sérialisées en JSON (avec support des types numpy / datetime via un encodeur custom). - Une signature HMAC-SHA256 est calculée sur le JSON avec une clé secrète dérivée de `RPA_SIGNING_KEY` (ou, à défaut, de `TOKEN_SECRET_KEY`). - À la lecture, la signature est vérifiée AVANT tout parsing applicatif. - Rétrocompatibilité : un fallback `pickle.load` est disponible pour migrer les anciens fichiers. Il logue un WARNING et doit être suivi d'une ré-écriture en JSON signé. ATTENTION : n'utiliser le fallback pickle que sur des fichiers dont la source est réputée sûre (locale + protégée). Le fallback est désactivable via la variable d'environnement `RPA_ALLOW_PICKLE_FALLBACK=0`. """ from __future__ import annotations import base64 import hashlib import hmac import io import json import logging import os import pickle from datetime import datetime, timedelta from pathlib import Path from typing import Any, Callable, Optional, Union import numpy as np logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- # Clé de signature # ----------------------------------------------------------------------------- _SIGNATURE_ALGO = "sha256" _SIGNATURE_HEADER = b"RPA_SIGNED_V1\n" # Marqueur de format signé def _resolve_signing_key() -> bytes: """Récupère la clé de signature HMAC. Ordre de priorité : 1. RPA_SIGNING_KEY (dédiée à la signature de fichiers) 2. TOKEN_SECRET_KEY (clé déjà utilisée pour signer les tokens API) 3. Clé dérivée en dev (avec WARNING) La clé dev est stable pour une même machine (dérivée du hostname + path) afin que les lectures/écritures locales restent cohérentes en l'absence de configuration, tout en refusant de valider des fichiers produits ailleurs. """ explicit = os.getenv("RPA_SIGNING_KEY", "").strip() if explicit: return explicit.encode("utf-8") fallback = os.getenv("TOKEN_SECRET_KEY", "").strip() if fallback: return fallback.encode("utf-8") # Clé dev dérivée : non cryptographiquement sûre, juste pour éviter des # erreurs en dev local. On loggue explicitement. logger.warning( "RPA_SIGNING_KEY et TOKEN_SECRET_KEY non définis — " "utilisation d'une clé dérivée locale. " "Définir RPA_SIGNING_KEY en production." ) seed = f"rpa-vision-v3::{os.uname().nodename}::dev-signing" # type: ignore[attr-defined] return hashlib.sha256(seed.encode("utf-8")).digest() # ----------------------------------------------------------------------------- # Encodage JSON étendu (numpy, datetime, Path, bytes) # ----------------------------------------------------------------------------- class _RPAJSONEncoder(json.JSONEncoder): """Encodeur JSON supportant numpy / datetime / Path / bytes.""" def default(self, obj: Any) -> Any: # noqa: D401 - API json standard if isinstance(obj, np.ndarray): return { "__type__": "ndarray", "dtype": str(obj.dtype), "shape": list(obj.shape), "data": base64.b64encode(obj.tobytes()).decode("ascii"), } if isinstance(obj, (np.integer,)): return int(obj) if isinstance(obj, (np.floating,)): return float(obj) if isinstance(obj, (np.bool_,)): return bool(obj) if isinstance(obj, datetime): return {"__type__": "datetime", "iso": obj.isoformat()} if isinstance(obj, timedelta): return {"__type__": "timedelta", "seconds": obj.total_seconds()} if isinstance(obj, Path): return {"__type__": "path", "value": str(obj)} if isinstance(obj, bytes): return { "__type__": "bytes", "data": base64.b64encode(obj).decode("ascii"), } if isinstance(obj, set): return {"__type__": "set", "items": list(obj)} return super().default(obj) def _json_object_hook(obj: Any) -> Any: """Reconstruit les types étendus depuis le JSON.""" if not isinstance(obj, dict): return obj tag = obj.get("__type__") if tag is None: return obj if tag == "ndarray": raw = base64.b64decode(obj["data"]) arr = np.frombuffer(raw, dtype=np.dtype(obj["dtype"])) return arr.reshape(obj["shape"]).copy() if tag == "datetime": return datetime.fromisoformat(obj["iso"]) if tag == "timedelta": return timedelta(seconds=float(obj["seconds"])) if tag == "path": return Path(obj["value"]) if tag == "bytes": return base64.b64decode(obj["data"]) if tag == "set": return set(obj.get("items", [])) return obj # ----------------------------------------------------------------------------- # Erreurs dédiées # ----------------------------------------------------------------------------- class SignedSerializerError(Exception): """Erreur de base du module.""" class SignatureVerificationError(SignedSerializerError): """Signature HMAC invalide : le fichier a été altéré ou forgé.""" class UnsupportedFormatError(SignedSerializerError): """Le fichier n'est ni au format signé, ni reconnu comme pickle legacy.""" # ----------------------------------------------------------------------------- # API publique # ----------------------------------------------------------------------------- def _compute_hmac(payload: bytes, key: bytes) -> str: return hmac.new(key, payload, hashlib.sha256).hexdigest() def dumps_signed(data: Any, key: Optional[bytes] = None) -> bytes: """Sérialise `data` en JSON signé HMAC-SHA256. Format binaire retourné : b"RPA_SIGNED_V1\n" + utf8(json({"hmac": "", "payload": })) Le HMAC couvre le JSON canonique de `payload` (keys triées, séparateurs compacts) pour qu'un même objet produise toujours la même signature. """ signing_key = key if key is not None else _resolve_signing_key() payload_json = json.dumps( data, cls=_RPAJSONEncoder, sort_keys=True, separators=(",", ":"), ensure_ascii=False, ).encode("utf-8") signature = _compute_hmac(payload_json, signing_key) envelope = {"hmac": signature, "payload_b64": base64.b64encode(payload_json).decode("ascii")} body = json.dumps(envelope, separators=(",", ":"), ensure_ascii=False).encode("utf-8") return _SIGNATURE_HEADER + body def loads_signed(raw: bytes, key: Optional[bytes] = None) -> Any: """Désérialise un blob produit par `dumps_signed` après vérification HMAC.""" if not raw.startswith(_SIGNATURE_HEADER): raise UnsupportedFormatError("Marqueur RPA_SIGNED_V1 absent.") signing_key = key if key is not None else _resolve_signing_key() body = raw[len(_SIGNATURE_HEADER):] try: envelope = json.loads(body.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise SignedSerializerError(f"Enveloppe JSON invalide : {exc}") from exc if not isinstance(envelope, dict): raise SignedSerializerError("Enveloppe inattendue.") signature = envelope.get("hmac") payload_b64 = envelope.get("payload_b64") if not isinstance(signature, str) or not isinstance(payload_b64, str): raise SignedSerializerError("Enveloppe mal formée (hmac / payload_b64).") try: payload_bytes = base64.b64decode(payload_b64.encode("ascii"), validate=True) except Exception as exc: # noqa: BLE001 - base64 peut lever plusieurs erreurs raise SignedSerializerError(f"Payload base64 invalide : {exc}") from exc expected = _compute_hmac(payload_bytes, signing_key) if not hmac.compare_digest(expected, signature): raise SignatureVerificationError( "Signature HMAC invalide — fichier altéré ou clé différente." ) return json.loads(payload_bytes.decode("utf-8"), object_hook=_json_object_hook) def _pickle_fallback_allowed() -> bool: return os.getenv("RPA_ALLOW_PICKLE_FALLBACK", "1") != "0" def save_signed(path: Union[str, Path], data: Any, key: Optional[bytes] = None) -> None: """Écrit `data` sur disque dans le format JSON signé.""" path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) blob = dumps_signed(data, key=key) tmp = path.with_suffix(path.suffix + ".tmp") with open(tmp, "wb") as fp: fp.write(blob) os.replace(tmp, path) def load_signed( path: Union[str, Path], *, allow_pickle_fallback: bool = True, migrate_on_fallback: bool = True, pickle_loader: Optional[Callable[[io.BufferedReader], Any]] = None, key: Optional[bytes] = None, ) -> Any: """Charge un fichier sauvegardé par `save_signed`. Si le fichier n'est pas au format signé, et si `allow_pickle_fallback` est vrai (ET `RPA_ALLOW_PICKLE_FALLBACK != "0"`), tente un `pickle.load()` pour migrer les anciens fichiers. Dans ce cas, un WARNING est émis et le fichier est ré-écrit en JSON signé si `migrate_on_fallback` vaut True. Args: path: Chemin du fichier allow_pickle_fallback: Activer la compat legacy migrate_on_fallback: Ré-écrire en JSON signé après fallback pickle_loader: Callable alternatif (pour tests / restricted unpickler) key: Clé HMAC explicite (sinon dérivée de l'environnement) Raises: SignatureVerificationError: HMAC invalide (fichier altéré) UnsupportedFormatError: format inconnu et fallback désactivé """ path = Path(path) with open(path, "rb") as fp: raw = fp.read() if raw.startswith(_SIGNATURE_HEADER): return loads_signed(raw, key=key) if not allow_pickle_fallback or not _pickle_fallback_allowed(): raise UnsupportedFormatError( f"{path} n'est pas au format signé et le fallback pickle est désactivé." ) logger.warning( "Chargement legacy pickle pour %s — ce format est obsolète et " "sera ré-écrit en JSON signé. Voir docs/SECURITY.md.", path, ) # Par défaut on refuse tout type non documenté dans ce fichier à risque : # utilisateur peut fournir un `pickle_loader` custom (ex: Unpickler # restreint). On log l'ouverture pour la traçabilité. loader = pickle_loader or (lambda f: pickle.load(f)) # noqa: S301 - usage legacy with open(path, "rb") as fp: data = loader(fp) if migrate_on_fallback: try: save_signed(path, data, key=key) logger.info("Fichier %s migré en JSON signé.", path) except Exception as exc: # noqa: BLE001 logger.error( "Migration JSON signé échouée pour %s : %s", path, exc ) return data __all__ = [ "SignedSerializerError", "SignatureVerificationError", "UnsupportedFormatError", "dumps_signed", "loads_signed", "save_signed", "load_signed", ]