caudal/programa/cartera/sobres.py
2026-10-13 08:01:06 -05:00

93 lines
3.4 KiB
Python

"""
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