Saltar al contenido
Todos los artículos
dgiiJuan T. Pérez L.

12 horas resolviendo el receiver de DGII: 12 bugs, 19 patches y una victoria

La historia técnica de cómo NexConnect resolvió en una sola noche los Pasos 8 a 11 de la certificación DGII e-CF. Bugs reales en signxml, canonicalización XMLDSig, manejo de RNCEmisor, lookup-or-create de ACECFs. Si estás integrando con el sistema de Factura Electrónica de DGII, este post te ahorra horas.

Cualquier proveedor que diga que la certificación DGII es "trámite", probablemente no ha tocado los Pasos 8 a 11. Son los pasos donde dejas de enviar XMLs y empiezas a recibir tráfico de DGII en tus propios endpoints. Son donde se cae la mayoría.

Este es el log honesto de la noche de la maratón, cuando los pasamos. 12 horas, 12 bugs distintos, 19 parches aplicados al pod productivo en vivo, 5 pull requests separados, y un mensaje del portal a las 10:53 PM diciendo "Aceptado".

Es un post largo. Si estás integrando con DGII Factura Electrónica, te ahorra entre 10 y 30 horas de tu propia investigación.

El contexto antes de la maratón

NexConnect llevaba 13 días en proceso de certificación:

  • Pasos 1-5 completados en los primeros días (postulación + 29 e-CFs de prueba + 11 RIs).
  • Paso 6 rebotando en auditoría — 4 rondas (R1-R4) por errores en las Representaciones Impresas. Aceptado en la última ronda (R5).
  • Paso 7 completado el día anterior (registramos las URLs de nuestros endpoints).

Esa mañana le dimos play a Paso 8. En la misma sesión cerramos Paso 11. Lo que pasó en el medio es lo que sigue.

Hour 0–2 — El primer bug

Paso 8 (Inicio Autenticación) funciona así: DGII llama tu endpoint GET /fe/autenticacion/api/Semilla, recibe un XML "semilla", lo firma con su certificado Viafirma TEST y lo POSTea de vuelta a /fe/autenticacion/api/validacioncertificado. Tu sistema verifica la firma de DGII y devuelve un JWT.

Primer rebote: 404 en nuestros logs. DGII estaba llamando a /fe/autenticacion/api/validacioncertificado (todo minúsculas) pero teníamos registrado ValidacionCertificado (CamelCase). La documentación oficial dice CamelCase; el servicio real envía minúsculas.

Bug #1 (URL casing): registrar variantes. Solución:

# urls.py — register both casings
path("autenticacion/api/validacioncertificado", ValidacionCertificadoView.as_view()),
path("autenticacion/api/ValidacionCertificado", ValidacionCertificadoView.as_view()),

Más tarde descubrimos otras variantes: validacionCertificado (camelCase con primera minúscula), VALIDACIONCERTIFICADO (mayúsculas). DGII llama con cualquiera. Postel's law aplicado: registrar todas.

Hour 2–4 — El segundo y tercer bug

Con las URLs corregidas, DGII llegaba al endpoint. Pero ahora rebotaba con no_cert_in_signature — nuestro código no encontraba el certificado en la firma.

Bug #2 (multipart/form-data): DGII envía el XML signado como multipart/form-data con un campo llamado xml, no como cuerpo raw. Nuestro código leía request.body y obtenía el envelope MIME completo. La firma estaba ahí pero no en el lugar donde buscábamos.

def _extract_xml_from_request(request):
    """DGII sends signed seeds as multipart/form-data, field=xml."""
    if "multipart/form-data" in request.META.get("CONTENT_TYPE", ""):
        xml_file = request.FILES.get("xml")
        if xml_file:
            return xml_file.read()
    return request.body

Una vez extraído el XML correctamente, nuevo error: el código que extraía el certificado del <KeyInfo> crasheaba con TypeError sin log. Esto es Bug #3 (Comment nodes en lxml): lxml.iter() devuelve no solo elementos sino también Comment y ProcessingInstruction nodes. Esos tienen .tag que es una función callable, no un string. Nuestro código hacía el.tag.split("}") y reventaba.

for el in root.iter():
    if not isinstance(el.tag, str):
        continue  # skip Comment / PI nodes
    # ... rest of logic

Hour 4–7 — El bug que casi nos rompe

Con cert extraído correctamente, llegamos al paso crítico: verificar la firma XMLDSig del payload de DGII. Y aquí entra el bug que perdimos 3 horas debugueando.

Bug #5 (signxml rechaza firmas válidas): llamábamos XMLVerifier().verify(xml_bytes, x509_cert=cert_pem) de la librería signxml y obteníamos InvalidSignature sin detalle. Pero cuando verificábamos manualmente con cryptography puro — mismo certificado, misma firma, mismo payload — la verificación pasaba.

Significaba una cosa: la firma matemáticamente era válida; signxml la rechazaba por un bug interno. Confirmado en GitHub: hay issues abiertos sobre canonicalización c14n en casos específicos.

Decisión: bypass total de signxml. Implementar verificación manual con cryptography:

def manual_verify_dgii_signature(xml_bytes, cert) -> bool:
    """Manual XMLDSig verify, ~30 lines, bypasses signxml internals."""
    root = etree.fromstring(xml_bytes)
    sig = root.find(f".//{{{_DSIG_NS}}}Signature")

    # 1. Reference digest
    root_copy = etree.fromstring(xml_bytes)
    sig_copy = root_copy.find(f".//{{{_DSIG_NS}}}Signature")
    sig_copy.getparent().remove(sig_copy)
    buf = BytesIO()
    etree.ElementTree(root_copy).write_c14n(buf, exclusive=False)
    expected_digest = base64.b64decode(
        sig.find(f".//{{{_DSIG_NS}}}DigestValue").text.strip()
    )
    if hashlib.sha256(buf.getvalue()).digest() != expected_digest:
        return False

    # 2. RSA verify
    si = sig.find(f".//{{{_DSIG_NS}}}SignedInfo")
    # ... (continúa en hour 5-7)

Hasta aquí parecía straightforward. Pero entonces nos topamos con Bug #6 — la canonicalización del SignedInfo.

Hour 7–9 — La pelea con la canonicalización c14n

El algoritmo XMLDSig verifica firmas en dos pasos: (1) el hash del documento sin la firma debe matchear el DigestValue declarado; (2) la firma RSA debe verificar contra c14n(SignedInfo).

El paso (1) lo teníamos. El paso (2) no funcionaba.

La documentación de DGII (firmado-ecf.pdf, página 6) muestra código TypeScript que usa canonicalización inclusiva c14n 1.0 con un parámetro defaultNsForPrefix que mapea el namespace XMLDSig al prefijo ds:. Pero los bytes que produce ese código TypeScript matchean lo que se produce con canonicalización exclusiva — no inclusiva.

Resultado: la spec dice una cosa, el código de referencia hace otra, y lxml.write_c14n() por default usa la versión que la spec declara — que es la que no matchea lo que DGII firmó.

Probé 12 combinaciones distintas off-line contra un payload real capturado de DGII:

✗ exclusive=False with_comments=False (606 bytes)
✗ exclusive=True with_comments=True
✗ exclusive=False inclusive_ns_prefixes=["ds"]
✗ ... 8 combinaciones más ...
✓ FRESH SUBTREE EXTRACTION + inclusive c14n (570 bytes) ← match

Bug #6 — "fresh subtree extraction": la única combinación que matchó fue: serializar el SignedInfo a bytes, re-parsearlo desde cero (lo que materializa los xmlns heredados como atributos explícitos en el elemento raíz del subtree), y después aplicar c14n inclusiva.

si_serialized = etree.tostring(si)         # materializes inherited xmlns
si_fresh = etree.fromstring(si_serialized) # re-parse as standalone tree
buf2 = BytesIO()
etree.ElementTree(si_fresh).write_c14n(buf2, exclusive=False)
# Now buf2.getvalue() matches what DGII signed.
cert.public_key().verify(sig_value, buf2.getvalue(), PKCS1v15(), SHA256())

Con esto, Paso 8 finalmente cerró exitoso a las 5:00 PM (7 horas en el reloj). Pasamos a Paso 9.

Hour 9–10 — Los bugs del Paso 9

Paso 9 (Inicio Recepción) es: usando el JWT de Paso 8, DGII te envía e-CFs firmados. Tu sistema los verifica, persiste, y responde con ARECF firmado.

Primer rebote en Paso 9: error de base de datos — nuestro campo consumer_rnc era varchar(11), pero el sub del JWT que DGII nos había emitido contenía IDCDO-00199999996 (17 caracteres).

Bug #7 (consumer_rnc \>11 chars): truncar a 11 al insertar.

consumer_rnc=consumer_rnc[:11]  # JWT sub for personal certs is 17 chars

Un parche feo, pero suficiente. La migración correcta (varchar(20)) la dejamos como follow-up post-cert.

Segundo rebote en Paso 9: portal DGII mostraba "El acuse de recibo no es válido". Pero nuestro código respondía sin errores aparentes.

Bug #8 (RNCEmisor del JWT): estábamos usando jwt_payload["sub"] como RNCEmisor en la respuesta ARECF. Pero sub para certs personales es la cédula truncada (IDCDO-00199). El XSD RNCType de DGII rechaza cualquier valor que no sea un RNC numérico. Solución: extraer <RNCEmisor> del cuerpo del e-CF entrante, no del JWT.

def extract_rnc_emisor(xml_bytes: bytes) -> str:
    root = etree.fromstring(xml_bytes)
    el = root.find(".//RNCEmisor")
    return el.text.strip() if el is not None else None

# In the view:
rnc_emisor = extract_rnc_emisor(xml_bytes) or jwt_payload["sub"][:11]

Con esto, Paso 9 cerró exitoso a las 7:30 PM (9.5 horas en el reloj). Tenemos 3 horas para cerrar Pasos 10 y 11.

Hour 10–11 — El bug 11 y el bug 12

Paso 10 (Inicio Aprobación Comercial) es como Paso 9 pero con ACECFs en lugar de e-CFs. Tu sistema firma y devuelve un ACECF de aprobación.

Primer rebote en Paso 10: el portal mostraba 404 NotFound en /fe/aprobacioncomercial/api/ecf. Pero la ruta estaba registrada. ¿Qué pasaba?

Bug #9 (URL casing edición 2): DGII llamaba a una variante CamelCase que no habíamos registrado: /fe/aprobacioncomercial/api/eCF (con CF mayúsculas al final). Postel's law de nuevo:

path("aprobacioncomercial/api/ecf", AprobacionComercialView.as_view()),
path("aprobacioncomercial/api/eCF", AprobacionComercialView.as_view()),
path("AprobacionComercial/api/ecf", AprobacionComercialView.as_view()),

Segundo rebote en Paso 10: el portal mostraba 404 pero ahora en otro endpoint — nuestro sistema recibía el ACECF, pero al buscar el ReceivedInvoice correspondiente en la base, no lo encontraba y devolvía 404.

Bug #11 (ACECF para eNCF no recibido): DGII envía ACECFs para eNCFs que nunca vimos en Paso 9. Específicamente vimos un tipo 45 (Gubernamental) que apareció solo en Paso 10. Devolver 404 (lectura literal de la spec) trababa el paso.

Solución v19 (la última de la noche): lookup-or-create.

received = ReceivedInvoice.objects.filter(
    organization=organization,
    encf=encf,
).first()

if received is None:
    # Auto-create placeholder from inbound ACECF body
    _root_in = etree.fromstring(xml_bytes)
    _rnc_em = _root_in.find(".//RNCEmisor")
    _rnc_co = _root_in.find(".//RNCComprador")
    received = ReceivedInvoice.objects.create(
        organization=organization,
        encf=encf,
        rnc_emisor=(_rnc_em.text or "")[:11],
        rnc_comprador=(_rnc_co.text or "")[:11] or emitter.rnc[:11],
        ecf_type=int(encf[1:3]),
        # ... otros campos placeholder
    )

# Now sign + return ACECF response

Hour 11–12 — La victoria

A las 10:53 PM, después de 12 horas continuas, el portal de DGII marcó Paso 11 como "Aceptado". Pasos 8, 9, 10 y 11 — todos verdes en un solo día.

Lo que aprendimos (que no está en la documentación)

Doce horas dejan lecciones que no se compran en ningún libro:

  1. signxml no es confiable para DGII. Si tu integración usa esta librería, vas a chocar con el bug #5 tarde o temprano. Reemplazá con verificación manual de cryptography.
  2. La canonicalización XMLDSig de DGII no matchea la spec. El truco "fresh subtree extraction + inclusive c14n" fue la única forma de matchear los bytes que DGII firma. Está documentado en sus PDFs internos pero no en la spec pública.
  3. DGII llama tus endpoints con variantes raras de casing. Registrá todas las variantes: lowercase, CamelCase, camelCase, ALL CAPS. Postel's law no es opcional.
  4. El sub del JWT no es necesariamente un RNC. Para certificados personales, es la cédula. Tomá RNCEmisor del cuerpo del e-CF entrante.
  5. Lookup-or-create vs 404. Durante certificación, DGII envía ACECFs para eNCFs nuevos. Devolver 404 traba el paso. Auto-crear placeholders.
  6. Persiste los XMLs firmados. El CodigoSeguridad del Paso 5 son los primeros 6 chars del SignatureValue. Si re-firmas al generar el PDF, el código cambia, DGII no lo encuentra, R4 rebota.
  7. Tu sistema debe loggear el payload entrante. Sin el b64 dump del XML que DGII envió, debuguear el bug #5 hubiera sido imposible. Hoy en día tenemos RECEIVER_DUMP_INBOUND_XML=1 durante cert; lo desactivamos en producción para no leakear PII.

¿Por qué importa este post?

Tres razones:

  1. Si estás certificándote como Emisor en DGII, puedes saltar estos 12 bugs y llegar a Paso 11 en 1-2 horas en lugar de 12.
  2. Si estás evaluando proveedores de Factura Electrónica, ahora sabes qué preguntar. "Cuántos de estos 12 bugs ya pisó tu sistema?" es una mejor pregunta que "cuántos años llevan en el mercado?".
  3. Si estás usando NexConnect, tu receiver ya tiene los 12 fixes desplegados en producción. No los vas a vivir.

Si quieres ver cómo se ve un receiver post-cert (todos los 12 fixes aplicados), el código vive en api/receiver/security/signature.py del monorepo público. O si prefieres evitar todo este capítulo, escríbenos a ventas@nexconnect.do.

Para más contexto del proceso completo, lee los 15 pasos explicados o las 7 trampas comunes.


Esta es una historia real de una sesión técnica de la maratón, registrada en commits, logs de Sentry, y memoria interna del equipo. Los snippets de código son extractos del receiver productivo de NexConnect (RNC 132901322, Emisor e-CF certificado por DGII).

Usamos cookies para entender cómo navegas el sitio y mejorar la experiencia. No vendemos tus datos. Política de privacidad.