Files
CRM_AGENTES_CARGA/backend/api/v1/modules/fin/stamping/sealer.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

148 lines
6.1 KiB
Python

"""Cadena original y sello del CFDI.
El sello es la firma del emisor sobre el comprobante. Se obtiene en dos pasos:
1. **Cadena original**: transformación XSLT oficial del SAT sobre el XML *sin* sello.
2. **Sello**: firma RSA con SHA-256 de esa cadena, en base64.
Ambos pasos son exactos: un carácter de diferencia en la cadena produce un sello que el SAT
rechaza. Por eso la cadena no se construye a mano ni se "normaliza" — sale tal cual del XSLT.
"""
from __future__ import annotations
import base64
import threading
from pathlib import Path
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
from lxml import etree
_XSLT_DIR = Path(__file__).parent / "xslt"
_XSLT_CADENA = _XSLT_DIR / "cadenaoriginal_4_0.xslt"
# La compilación del XSLT es cara (33 includes) y el resultado es inmutable: se hace una vez.
# El lock evita que dos peticiones concurrentes la compilen a la vez en el arranque.
_transform: etree.XSLT | None = None
_transform_lock = threading.Lock()
class SealingError(Exception):
"""Falla al calcular la cadena original o el sello."""
def _get_transform() -> etree.XSLT:
global _transform
if _transform is None:
with _transform_lock:
if _transform is None: # otro hilo pudo compilarla mientras esperábamos
if not _XSLT_CADENA.is_file():
raise SealingError(
f"No encuentro el XSLT de la cadena original en {_XSLT_CADENA}"
)
# Los includes del XSLT apuntan a rutas RELATIVAS locales (ver xslt/README.md):
# no hay resolución por red, ni aquí ni dentro de libxslt.
_transform = etree.XSLT(etree.parse(str(_XSLT_CADENA)))
return _transform
def build_original_string(xml_bytes: bytes) -> str:
"""Cadena original del comprobante, vía el XSLT oficial del SAT.
``xml_bytes`` es el CFDI **sin** los atributos ``Sello``, ``NoCertificado`` ni
``Certificado``: son justamente los que se calculan a partir de esta cadena.
"""
try:
doc = etree.fromstring(xml_bytes)
except etree.XMLSyntaxError as exc:
raise SealingError(f"El XML del comprobante no es válido: {exc}") from exc
cadena = str(_get_transform()(doc))
# Una cadena vacía o sin los delimitadores significa que la transformación no aplicó ninguna
# plantilla — pasa si el XSLT perdió sus includes. Firmar eso daría un sello con pinta de
# correcto sobre nada, así que se corta aquí.
if not cadena.startswith("||") or not cadena.endswith("||"):
raise SealingError(
"La cadena original no tiene la forma esperada (debe abrir y cerrar con '||'). "
"Revisa los includes de xslt/cadenaoriginal_4_0.xslt."
)
return cadena
def load_private_key(key_der: bytes, password: str) -> rsa.RSAPrivateKey:
"""Carga la llave privada del CSD.
El ``.key`` que entrega el SAT es PKCS#8 **DER** cifrado con contraseña. Se acepta también
PEM para no obligar a convertir llaves que ya estén en ese formato.
"""
if not password:
raise SealingError(
"No se configuró la contraseña de la llave privada del CSD (CSD_PASSWORD)."
)
clave = password.encode("utf-8")
try:
key = serialization.load_der_private_key(key_der, password=clave)
except ValueError:
try:
key = serialization.load_pem_private_key(key_der, password=clave)
except ValueError as exc:
# Mismo error para "archivo corrupto" y "contraseña incorrecta" porque la librería no
# los distingue; el mensaje nombra las dos causas para no mandar a nadie al lugar
# equivocado.
raise SealingError(
"No pude abrir la llave privada del CSD: el archivo no es una llave válida "
"o la contraseña es incorrecta."
) from exc
if not isinstance(key, rsa.RSAPrivateKey):
raise SealingError("La llave privada del CSD no es RSA.")
return key
def read_certificate(cer_bytes: bytes) -> tuple[str, str]:
"""Devuelve ``(numero_de_certificado, certificado_base64)`` a partir del ``.cer``.
- El ``.cer`` del SAT es X.509 **DER**.
- El atributo ``Certificado`` del comprobante es el base64 de ese DER.
- El atributo ``NoCertificado`` son los 20 dígitos del número de serie. El SAT lo codifica
de forma que los bytes del serial son directamente sus caracteres ASCII, así que se
decodifica en vez de imprimirse en hexadecimal.
"""
try:
cert = load_der_x509_certificate(cer_bytes)
der = cer_bytes
except ValueError:
try:
cert = load_pem_x509_certificate(cer_bytes)
der = cert.public_bytes(serialization.Encoding.DER)
except ValueError as exc:
raise SealingError("El archivo del certificado no es un X.509 válido (.cer).") from exc
serial_bytes = cert.serial_number.to_bytes((cert.serial_number.bit_length() + 7) // 8, "big")
try:
numero = serial_bytes.decode("ascii")
except UnicodeDecodeError as exc:
raise SealingError(
"El número de serie del certificado no tiene el formato del SAT "
"(sus bytes deben ser los 20 dígitos en ASCII)."
) from exc
if len(numero) != 20 or not numero.isdigit():
raise SealingError(f"El número de certificado debe ser 20 dígitos; se obtuvo {numero!r}.")
return numero, base64.b64encode(der).decode("ascii")
def sign(original_string: str, private_key: rsa.RSAPrivateKey) -> str:
"""Sello del comprobante: RSA sobre SHA-256 de la cadena original, en base64.
SHA-256 y no SHA-1: SHA-1 sólo aplica al CFDI de retenciones con otros PAC (ver
``CFDI.cs:8157`` del sistema legado). Para CFDI de ingreso con Comercio Digital es SHA-256.
"""
firma = private_key.sign(
original_string.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA256(),
)
return base64.b64encode(firma).decode("ascii")