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>
78 lines
2.9 KiB
Python
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
|