""" sobres.py — Las dos piezas de cifrado que comparten la entrada con contraseña, la copia de seguridad y el cifrado de los datos. · Una LLAVE es un número al azar de 32 bytes: es la que de verdad cifra. · Un SOBRE guarda esa llave cerrada con un secreto que sabe una persona (su contraseña, o su clave de recuperación). El secreto pasa por scrypt, que es lento a propósito, para que probar contraseñas una a una no salga a cuenta; con lo que sale se cierra la llave con AES-256-GCM. Así la contraseña se puede cambiar sin volver a cifrar nada (solo se hace un sobre nuevo para la misma llave), y la misma llave puede tener varios sobres: uno con la contraseña y otro con la clave de recuperación. Todo lo cifrado va con AES-256-GCM, que además detecta cualquier cambio. Necesita el paquete «cryptography». """ from __future__ import annotations import base64 import hashlib import secrets N, R, P = 2 ** 15, 8, 1 # scrypt: ~33 MB y unas décimas de segundo por intento class ErrorCifrado(Exception): """Lo que se le dice al usuario tal cual.""" def _aesgcm(llave: bytes): try: from cryptography.hazmat.primitives.ciphers.aead import AESGCM except ImportError: raise ErrorCifrado("Para cifrar hace falta el paquete «cryptography»: pip install cryptography") from None return AESGCM(llave) def _b64(b: bytes) -> str: return base64.b64encode(b).decode() def llave_nueva() -> bytes: return secrets.token_bytes(32) def cifrar(llave: bytes, contenido: bytes, contexto: bytes = b"") -> bytes: """nonce (12) + cifrado con su sello. «contexto» va sellado pero no cifrado.""" nonce = secrets.token_bytes(12) return nonce + _aesgcm(llave).encrypt(nonce, contenido, contexto) def descifrar(llave: bytes, cifrado: bytes, contexto: bytes = b"") -> bytes: try: return _aesgcm(llave).decrypt(cifrado[:12], cifrado[12:], contexto) except ErrorCifrado: raise except Exception: raise ErrorCifrado("No se puede descifrar: la llave no es la suya, o está dañado.") from None def _de_secreto(secreto: str, sal: bytes, n: int, r: int, p: int) -> bytes: return hashlib.scrypt(secreto.encode("utf-8"), salt=sal, n=n, r=r, p=p, dklen=32, maxmem=2 ** 27) def envolver(llave: bytes, secreto: str, tipo: str = "clave") -> dict: """Un sobre: la llave cerrada con ese secreto.""" sal = secrets.token_bytes(16) return {"tipo": tipo, "n": N, "r": R, "p": P, "sal": _b64(sal), "cerrada": _b64(cifrar(_de_secreto(secreto, sal, N, R, P), llave, tipo.encode()))} def abrir(sobre: dict, secreto: str) -> bytes | None: """La llave que guarda el sobre, o None si ese secreto no lo abre.""" try: de_secreto = _de_secreto(secreto, base64.b64decode(sobre["sal"]), int(sobre["n"]), int(sobre["r"]), int(sobre["p"])) return descifrar(de_secreto, base64.b64decode(sobre["cerrada"]), str(sobre["tipo"]).encode()) except ErrorCifrado as e: if "cryptography" in str(e): raise return None except (KeyError, ValueError, TypeError): return None def abrir_alguno(sobres: list[dict], secreto: str) -> bytes | None: """Prueba el secreto con cada sobre (el de la contraseña, el de la recuperación…).""" for sobre in sobres or []: llave = abrir(sobre, secreto) if llave is not None: return llave return None