Files
CRM_AGENTES_CARGA/backend/core/crypto.py
Jair Cedillo 6e208876f7 feat(fin): timbrado de CFDI 4.0 de ingreso con Comercio Digital
Cierra el ciclo de la factura: construcción del comprobante, sellado con el CSD de la
empresa emisora y transmisión al PAC.

- cfdi_builder: XML 4.0 de ingreso en el orden de atributos del XSD, del que depende la
  cadena original y con ella el sello. Todo el dinero con Decimal.
- sealer: cadena original vía el XSLT oficial del SAT y firma con la llave del CSD.
- pac_comercio_digital: cliente de timbrarV5. Conserva el código y el saldo de folios que
  el legado leía en una variable que descartaba (CFDI.cs:19324-19336).
- csd_service y core/crypto: CSD por empresa, con la contraseña cifrada en la base. Antes
  el certificado había que dejarlo a mano en el almacenamiento y su contraseña era una
  variable de entorno global, lo que no funciona con varias empresas emisoras.
- Cada intento —también los rechazados— guarda el XML que se transmitió y el que contestó
  el PAC: sin ese par no hay forma de reconstruir un rechazo cuando termina la petición.

La declaración XML se escribe a mano con comillas dobles. lxml la emite con comillas
simples, que es XML válido, pero Comercio Digital compara la cadena literal version="1.0"
y responde 642 "la versión del XML no es 1.0".

El modo (pruebas o producción) sale de invoices.stamping_mode y no se puede pasar por la
API: es lo único que separa un timbre de prueba de un CFDI con validez fiscal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:07:05 -05:00

78 lines
2.9 KiB
Python

"""Cifrado simétrico de secretos que hay que guardar y volver a leer.
Se usa hoy para la contraseña de la llave privada del CSD: el timbrado necesita abrir el
``.key`` sin que haya nadie tecleando, así que la contraseña tiene que estar en reposo. Un
hash no sirve — habría que recuperar el valor original, no compararlo.
**La clave maestra vive en el entorno** (``CSD_ENCRYPTION_KEY``), nunca en la base ni en el
repositorio. Quien tenga a la vez la base de datos y esa variable puede descifrar los secretos:
esa es la propiedad que da el diseño y conviene tenerla presente al decidir quién ve qué.
Para generar una clave nueva::
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
"""
from cryptography.fernet import Fernet, InvalidToken
from core.config import settings
class SecretsNotConfigured(Exception):
"""No hay clave maestra configurada, así que no se puede cifrar ni descifrar."""
class SecretDecryptionError(Exception):
"""El valor guardado no se pudo descifrar con la clave maestra actual."""
def _fernet() -> Fernet:
clave = (settings.CSD_ENCRYPTION_KEY or "").strip()
if not clave:
raise SecretsNotConfigured(
"Falta CSD_ENCRYPTION_KEY. Genérala con "
'`python -c "from cryptography.fernet import Fernet; '
'print(Fernet.generate_key().decode())"` y ponla en el .env.'
)
try:
return Fernet(clave.encode("utf-8"))
except (ValueError, TypeError) as exc:
raise SecretsNotConfigured(
"CSD_ENCRYPTION_KEY no es una clave Fernet válida (32 bytes en base64 url-safe)."
) from exc
def encrypt_secret(value: str) -> str:
"""Cifra un secreto. Devuelve el token en texto, listo para guardar en una columna."""
if not value:
raise ValueError("no se cifra un secreto vacío")
return _fernet().encrypt(value.encode("utf-8")).decode("ascii")
def decrypt_secret(token: str) -> str:
"""Recupera el secreto original.
Falla con ``SecretDecryptionError`` si la clave maestra cambió o el dato está corrupto.
Se distingue de ``SecretsNotConfigured`` a propósito: una dice "configura la variable" y la
otra "la variable no es la que cifró este dato", y confundirlas manda a buscar al lugar
equivocado.
"""
if not token:
raise SecretDecryptionError("no hay secreto guardado")
try:
return _fernet().decrypt(token.encode("ascii")).decode("utf-8")
except InvalidToken as exc:
raise SecretDecryptionError(
"No pude descifrar el secreto guardado: la clave maestra no corresponde con la que "
"se usó al guardarlo, o el dato está dañado. Hay que volver a capturarlo."
) from exc
def secrets_available() -> bool:
"""``True`` si hay clave maestra utilizable. Para avisar en la UI antes de pedir el dato."""
try:
_fernet()
return True
except SecretsNotConfigured:
return False