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>
148 lines
6.1 KiB
Python
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")
|