SIFEN — Implementación de Bajo Nivel (Referencia Maestra OnnixConnect OC)
Origen del análisis: proyecto
sifen_laravel_muestra(Laravel 10/11, PHP 8.x) Propósito: documentar la lógica de bajo nivel que garantiza compatibilidad estricta con la DNIT/SET Restricción: este documento describe pasos técnicos y parámetros — no copia el código fuente completo
Índice
- Cliente SOAP Personalizado — cURL vs SoapClient nativo
- Construcción Manual de XML — DOMDocument
- Lógica Maestra del QR — Independencia Total
- Utilidades Matemáticas — CDC y Dígito Verificador (DV)
- Flujo de Envío Síncrono — Orden exacto de pasos
- Configuración de entorno requerida
1. Cliente SOAP Personalizado
Archivo analizado: app/Services/Sifen/SifenSoapClient.php
1.1 Por qué cURL en lugar de SoapClient nativo de PHP
El SoapClient nativo de PHP tiene limitaciones críticas para SIFEN:
- No soporta correctamente SOAP 1.2 sin configuración manual del
Content-Type. - No permite adjuntar un certificado
.p12(PKCS#12) para mTLS de forma sencilla. - El proxy F5 BIG-IP de la SET devuelve redirects 302 que el SoapClient no sigue correctamente.
- El SoapClient intenta parsear el WSDL en cada instancia, introduciendo latencia innecesaria.
1.2 Headers de SOAP 1.2 — Configuración exacta
El header Content-Type es la diferencia clave entre SOAP 1.1 y 1.2:
Content-Type: application/soap+xml; charset=utf-8; action="rEnviDe"
Accept: application/soap+xml, text/xml, */*
Cache-Control: no-cache
Pragma: no-cache
Connection: keep-alive
Expect: (vacío — suprime el handshake 100-continue)
Puntos críticos:
action="rEnviDe"va dentro delContent-Type, no como header separadoSOAPAction.Expect:se pasa vacío para evitar que proxies intermedios rompan el handshake al recibir el cuerpo de una vez.Connection: keep-alivemejora la compatibilidad con el BIG-IP de la SET.
1.3 Handshake mTLS con certificado .p12
El servidor de la SET (HTTPS) puede exigir autenticación mutua de cliente (mTLS). La cadena de configuración cURL es:
| Opción cURL | Valor | Descripción |
|---|---|---|
CURLOPT_SSLCERTTYPE | 'P12' | Indica que el certificado es PKCS#12 (no PEM) |
CURLOPT_SSLCERT | ruta absoluta al .p12 | Archivo que contiene cert + clave privada + chain |
CURLOPT_SSLCERTPASSWD | contraseña del .p12 | Passphrase del almacén PKCS#12 |
Nota: cURL con
CURLOPT_SSLCERTTYPE='P12'extrae internamente tanto el certificado cliente como la clave privada del mismo archivo.p12. No se necesita exportar un.pemseparado.
La flag $useMtls (booleano) permite desactivar mTLS en ambientes de TEST donde el WS no lo exija, sin cambiar código.
1.4 Opciones de conexión anti-F5 BIG-IP
| Opción cURL | Valor | Razón |
|---|---|---|
CURLOPT_FOLLOWLOCATION | true | Sigue redirects 302 del proxy BIG-IP |
CURLOPT_MAXREDIRS | 5 | Límite de redirects para evitar loops |
CURLOPT_USERAGENT | 'Mozilla/5.0 ... SifenLaravelClient/1.0' | El BIG-IP rechaza UA vacíos o de bot |
CURLOPT_HTTP_VERSION | CURL_HTTP_VERSION_1_1 | HTTP/2 puede romper chunked con el F5 |
CURLOPT_CONNECTTIMEOUT | 30 segundos | Timeout de establecimiento de conexión |
CURLOPT_TIMEOUT | 180 segundos | Timeout total (el WS de SIFEN puede demorar) |
CURLOPT_SSL_VERIFYPEER | false (TEST) / true + CA bundle (PROD) | En Windows/XAMPP el CA chain falla sin bundle |
CURLOPT_SSL_VERIFYHOST | false (TEST) / 2 (PROD) | Idem anterior |
1.5 Manejo de la respuesta y separación headers/body
cURL con CURLOPT_HEADER=true devuelve un string único con headers HTTP + body SOAP. La separación es:
headerSize = curl_getinfo($ch)['header_size']
rawHeaders = substr($raw, 0, headerSize)
body = substr($raw, headerSize)
El retorno del método incluye: endpoint, http_code, headers_raw, response (body XML) y curl_info para debugging.
1.6 Errores de conexión — Manejo
curl_exec()retornafalseen error de red/timeout → se lanza\Exceptionconcurl_error()+curl_errno()+curl_getinfo().- El
http_codedel retorno debe verificarse externamente (200 = éxito, otro = error del servidor). - El body del error de la SET viene en XML SOAP fault — se debe parsear con
DOMDocument/SimpleXMLpara extraer el código y mensaje.
1.7 Resolución del endpoint (desde WSDL URL)
El config puede contener la URL del WSDL (?wsdl o .wsdl). El método resolveEndpoint() normaliza esto quitando ?wsdl (case-insensitive) y .wsdl del final, obteniendo la URL del endpoint real para POST.
2. Construcción Manual de XML
Archivo analizado: app/Services/Sifen/DocumentoElectronico.php
2.1 Estrategia general — DOMDocument vs SimpleXML/Heredoc
Se usa DOMDocument (no SimpleXML ni heredoc) porque:
- Permite marcar el atributo
Idcomo tipo ID real (setIdAttributeNode()), requerido para queXMLSecurityDSigresuelva la referencia#CDCen la firma. - Garantiza escape correcto de
&en URLs del QR (createTextNode()escapa automáticamente). - Ofrece control total del orden de nodos (XSD de SIFEN es sequence-ordered).
Parámetros críticos del DOMDocument:
preserveWhiteSpace = false → Elimina nodos de texto con whitespace
formatOutput = false → Sin indentación (sin \n ni \t entre tags)
Crítico para la firma: cualquier espacio en blanco entre nodos altera el
DigestValue.formatOutput=falsees obligatorio.
2.2 Estructura del envelope SOAP
La estructura es SOAP 1.2 con namespace env: (no soapenv: de SOAP 1.1):
env:Envelope [xmlns:env="http://www.w3.org/2003/05/soap-envelope"]
env:Header (vacío)
env:Body
rEnviDe [xmlns="http://ekuatia.set.gov.py/sifen/xsd"]
dId (número de correlativo de envío)
xDE
rDE [xmlns:xsi + xsi:schemaLocation]
dVerFor
DE [Id="{CDC}"] ← atributo marcado como tipo ID
2.3 El atributo Id del nodo DE — Por qué es crítico
$DE->setAttribute('Id', $this->Id); // CDC completo (44 dígitos)
$attr = $DE->getAttributeNode('Id');
$DE->setIdAttributeNode($attr, true); // Marca como tipo XML ID
Sin este setIdAttributeNode(), la librería robrichards/xmlseclibs no puede localizar el nodo por referencia URI="#CDC" durante la firma, produciendo una firma inválida que SIFEN rechaza.
2.4 Construcción por grupos — Orden obligatorio
El XSD de SIFEN define los grupos como xs:sequence, por lo que el orden es mandatorio:
| Orden | Grupo | Contenido principal |
|---|---|---|
| 1 | gOpeDE | iTipEmi (tipo emisión), dCodSeg (código seguridad 9 dígitos) |
| 2 | gTimb | iTiDE, dNumTim, dEst, dPunExp, dNumDoc, dFeIniT |
| 3 | gDatGralOpe | dFeEmiDE, sub-grupos gOpeCom, gEmis, gDatRec |
| 4 | gDtipDE | gCamFE, gCamCond, gCamItem (uno por ítem) |
| 5 | gTotSub | Totales: dTotOpe, dTotGralOpe, dTotIVA, etc. |
| 6 | Signature | Insertado por SignatureHelper (xmlseclibs) |
| 7 | gCamFuFD | dCarQR — SIEMPRE al final, después de la firma |
2.5 Helpers de nodos — Por qué no se crean nodos vacíos
El método appendText() verifica null y trim() === '' antes de crear el nodo. Esto es esencial porque:
- Tags vacíos como
<dNomFanEmi/>no pasan la validación XSD de la SET. - El campo es simplemente omitido si no tiene valor (comportamiento diferente a poner cadena vacía).
Los tipos de helpers y sus reglas de formato:
| Helper | Formato de salida | Uso |
|---|---|---|
appendText | string literal (trim) | campos alfanuméricos |
appendInt | cast a (int) → string | campos numéricos enteros |
appendDec | number_format(n, scale, '.') | montos — sin separador de miles, punto decimal |
appendDate | Y-m-d | fechas simples |
appendDateTime | Y-m-d\TH:i:s | fecha+hora ISO 8601 sin offset TZ |
Crítico:
number_format($val, 0, '.', '')para montos en PYG garantiza que no aparezca separador de miles ni notación científica para números grandes. PHP puede usar notación científica (1.0E+6) si se hace cast directo a string.
2.6 Secuencia dentro de toSoapXmlEnviDe()
1. buildSoapEnvelopeEnv() → DOMDocument con env:Envelope/Header/Body
2. Crear rEnviDe con xmlns SIFEN
3. Crear xDE > rDE con xsi:schemaLocation
4. computeCdc() → calcula Id (CDC 44 dígitos) + dDVId
5. Crear nodo DE con Id=CDC, markIdAttribute()
6. appendGOpeDE, appendGTimb, appendGDatGralOpe, appendGDtipDE, appendGTotSub
7. SignatureHelper::signDocument() → firma el nodo rDE, referencia #CDC
8. SifenQrHelper::generateQrUrlFromSignedDocument() → extrae DigestValue de la firma
9. Crear gCamFuFD > dCarQR con createTextNode(enlaceQR)
10. $doc->saveXML()
3. Lógica Maestra del QR
Archivo analizado: app/Services/Sifen/SifenQrHelper.php
3.1 Por qué el QR se genera DESPUÉS de la firma
El parámetro DigestValue del QR es el hash SHA-256 del nodo DE canonicalizado, calculado durante la firma digital XML. Este valor solo existe en el documento después de que SignatureHelper::signDocument() inserté el nodo ds:Signature. Por lo tanto, el orden es:
firma → DigestValue disponible → generar URL QR → insertar en gCamFuFD
3.2 Extracción del DigestValue
Ruta XPath dentro del XML firmado:
//ds:Signature//ds:SignedInfo//ds:Reference[1]//ds:DigestValue
Namespace registrado: ds → http://www.w3.org/2000/09/xmldsig#
El valor en el XML es un string Base64 (resultado de SHA-256 del nodo canonicalizado). Este string Base64 se procesa en dos pasos para replicar la lógica de la implementación Java de referencia (Roshka):
Paso 1: base64_decode(digestB64) → $bytes (array de bytes del hash SHA-256)
Paso 2: base64_encode($bytes) → $b64Again (misma cadena base64 original)
Paso 3: bin2hex($b64Again) → $digestHex (representación hex del string base64)
Por qué este doble proceso: la implementación Java toma los bytes del digest, los re-codifica en Base64 y luego convierte ese string Base64 en hexadecimal. El resultado es el hex del string Base64, no el hex de los bytes crudos del hash.
3.3 Parámetros de la URL QR — Orden exacto
El orden de los parámetros es fijo y mandatorio (el hash final depende del orden):
| N° | Parámetro | Origen | Formato |
|---|---|---|---|
| 1 | nVersion | config('sifen.version') (ej: 150) | entero |
| 2 | Id | CDC completo (44 dígitos) | string numérico |
| 3 | dFeEmiDE | fecha emisión del DE | hex UTF-8 de Y-m-d\TH:i:s |
| 4a | dRucRec | RUC del receptor (si iNatRec = 1) | string |
| 4b | dNumIDRec | Nro documento receptor (si iNatRec ≠ 1) | string o '0' |
| 5 | dTotGralOpe | Total general de la operación | entero string |
| 6 | dTotIVA | Total IVA | entero string |
| 7 | cItems | Cantidad de ítems (count(gCamItemList)) | entero string |
| 8 | DigestValue | Hex del Base64 del DigestValue firmado | hex string |
| 9 | IdCSC | config('sifen.id_csc') | string (ej: 0001) |
Nota: el parámetro 4 es mutuamente excluyente: si iNatRec=1 (persona jurídica/contribuyente) se usa dRucRec; de lo contrario dNumIDRec.
3.4 Construcción del hash cHashQR
queryString = "nVersion=150&Id=...&dFeEmiDE=...&dRucRec=...&...&IdCSC=0001"
cHashQR = sha256_hex( queryString + CSC )
Donde CSC es el Código de Seguridad del Contribuyente completo (no el IdCSC). El resultado es un hash SHA-256 en lowercase hexadecimal (64 caracteres).
3.5 Construcción final de la URL
URL = rtrim(url_consulta_qr, '?') + '?' + queryString + '&cHashQR=' + cHashQR
La función buildUrlParams() en el helper no aplica urlencode() a los valores. Esto es intencional: todos los valores son numéricos o hexadecimales, sin caracteres que requieran encoding. Aplicar urlencode alteraría el hash si algún valor contuviese + o = (que pueden aparecer en Base64).
3.6 Codificación de dFeEmiDE en hex
$feEmiStr = Carbon::parse($feEmi)->format('Y-m-d\TH:i:s');
// Ejemplo: "2025-01-15T10:30:00"
$feEmiHex = bin2hex($feEmiStr);
// Resultado: "323032352d30312d31355431303a33303a3030"
La cadena fecha se trata como UTF-8 (que para caracteres ASCII es idéntico a bytes), y se convierte directamente a hexadecimal. Es una codificación de bytes, no un hash.
4. Utilidades Matemáticas
Archivo analizado: app/Services/Sifen/SifenUtil.php
4.1 Algoritmo Módulo 11 para el Dígito Verificador
El mismo algoritmo aplica para el DV del RUC y para el DV del CDC (dDVId). El proceso es:
Pesos utilizados: [2, 3, 4, 5, 6, 7] (cíclico, de derecha a izquierda)
Pasos:
1. Recorrer el string de derecha a izquierda (i = len-1 → 0)
2. Multiplicar cada dígito por su peso cíclico (índice % 6)
3. Acumular la suma
4. mod = suma % 11
5. dv = 11 - mod
6. Si dv == 11 → dv = 0
7. Si dv == 10 → dv = 1
Sobre el manejo de números grandes (BigInt):
PHP no tiene tipo nativo BigInt, pero opera con strings carácter a carácter en el loop for. El código procesa $input[$i] (un carácter) con (int) cast, por lo que nunca hay desbordamiento aritmético. El CDC tiene 44 dígitos — trabajar carácter por carácter elimina el problema que tendría si se intentara hacer (int)$cdcCompleto (que desbordaría).
No se usa
gmp_*nibcmathporque el algoritmo procesa un dígito a la vez, nunca el número completo como entero.
4.2 Padding de campos del CDC
Antes de concatenar para el CDC, cada segmento se rellena con ceros a la izquierda:
| Campo | Longitud | Padding |
|---|---|---|
| iTiDE | 2 | str_pad($v, 2, '0', LEFT) |
| dRucEm | 8 | str_pad($v, 8, '0', LEFT) |
| dDVEmi | 1 | sin padding |
| dEst | 3 | str_pad($v, 3, '0', LEFT) |
| dPunExp | 3 | str_pad($v, 3, '0', LEFT) |
| dNumDoc | 7 | str_pad($v, 7, '0', LEFT) |
| iTipCont | 1 | sin padding |
| dFeEmiDE | 8 | Ymd (Carbon::format) |
| iTipEmi | 1 | sin padding |
| dCodSeg | 9 | sin padding (exacto) |
Total CDC base: 2+8+1+3+3+7+1+8+1+9 = 43 dígitos + 1 DV = 44 dígitos.
4.3 Otras utilidades
| Método | Función |
|---|---|
leftPad() | str_pad($v, $len, '0', STR_PAD_LEFT) — alias legible |
bytesToHex() | bin2hex($str) — para encoding de campos en QR |
sha256Hex() | hash('sha256', $value) — lowercase hex sin prefijo |
buildUrlParams() | implode('&', $pairs) sin urlencode() |
5. Flujo de Envío Síncrono
Archivo analizado: app/Console/Commands/SifenSendTest.php
5.1 Orden exacto de pasos (maestro)
[1] Cargar datos desde JSON / BD / request HTTP
└── Mapear a arrays gOpeDE, gTimb, gDatGralOpe, gDtipDE, gTotSub
[2] Instanciar DocumentoElectronico
└── Asignar arrays de grupos al objeto
[3] Llamar $de->toSoapXmlEnviDe($dId)
│
├── [3.1] computeCdc()
│ └── PRIMER paso interno: se calcula el CDC (Id 44 dígitos)
│ y se setea $this->Id y $this->dDVId
│ ↳ El CDC depende de: iTiDE, RUC+DV, Est, Pun, NumDoc,
│ TipCont, FechaEmision(Ymd), TipEmi, CodSeg
│
├── [3.2] Construcción del árbol DOM
│ └── Nodos en orden XSD: gOpeDE → gTimb → gDatGralOpe →
│ gDtipDE → gTotSub
│
├── [3.3] SignatureHelper::signDocument()
│ └── Firma el nodo rDE con referencia URI="#CDC"
│ (xmlseclibs + certificado .p12)
│ ↳ Inserta ds:Signature DENTRO de rDE, ANTES de gCamFuFD
│
├── [3.4] SifenQrHelper::generateQrUrlFromSignedDocument()
│ └── Lee DigestValue del ds:Signature recién insertado
│ Construye URL QR con params en orden fijo
│ ↳ $this->enlaceQR = URL completa con cHashQR
│
├── [3.5] Crear nodo gCamFuFD > dCarQR
│ └── createTextNode(enlaceQR) — escapa & automáticamente
│
└── [3.6] $doc->saveXML() → string XML SOAP completo
[4] SifenSoapClient::enviarDE($xmlSoap, useMtls=true, verbose=true)
└── POST por cURL con headers SOAP 1.2
└── Retorna array: endpoint, http_code, headers_raw, response, curl_info
[5] Persistir logs
├── storage/app/sifen/last_request.xml
├── storage/app/sifen/last_response.xml
└── storage/app/sifen/logs/{timestamp}_{CDC}_request.xml / response.xml
5.2 ¿El CDC se calcula antes o después del XML?
Antes. computeCdc() se llama dentro de toSoapXmlEnviDe() como primer paso, antes de crear cualquier nodo DOM. Esto es porque el CDC es el atributo Id del nodo DE, que debe existir antes de poder crear el nodo y marcarlo como tipo ID.
5.3 ¿Cuándo exactamente se genera el QR e inserta en el XML?
Después de la firma, antes del saveXML().
El nodo gCamFuFD (con dCarQR) es el último nodo que se agrega al árbol DOM. Este orden es mandatorio porque:
- La firma cubre el nodo
rDEpor referencia canónica. - Si
gCamFuFDexistiera antes de firmar, el DigestValue cambiaría (incluiría ese nodo). - Al agregarlo después de la firma, no altera el digest ya calculado, y la firma sigue siendo válida.
Implicación para OnnixConnect OC: el nodo QR debe siempre ser el último child de
rDE. No insertarlo antes de la firma bajo ninguna circunstancia.
5.4 Manejo del dId (correlativo de envío)
El dId es un entero que identifica el lote de envío dentro de la sesión. En el WS Sync se acepta valor 1 para envíos unitarios. No debe confundirse con el CDC. Se pasa como parámetro al método toSoapXmlEnviDe(int $dId = 1).
6. Configuración de Entorno Requerida
Estas claves deben estar disponibles en config/sifen.php (o a través de .env):
| Clave config | Variable .env | Descripción |
|---|---|---|
sifen.ws_sync | SIFEN_WS_SYNC | URL del WS síncrono (rEnviDe). Puede ser URL con ?wsdl |
sifen.cert_p12_path | SIFEN_CERT_P12_PATH | Ruta absoluta al certificado .p12 del contribuyente |
sifen.cert_p12_pass | SIFEN_CERT_P12_PASS | Contraseña del .p12 |
sifen.ns_uri | SIFEN_NS_URI | http://ekuatia.set.gov.py/sifen/xsd |
sifen.version | SIFEN_VERSION | 150 (versión del esquema XSD) |
sifen.schema_location | SIFEN_SCHEMA_LOCATION | Ej: http://...xsd siRecepDE_v150.xsd |
sifen.url_consulta_qr | SIFEN_URL_CONSULTA_QR | URL base para el QR (sin ?) |
sifen.id_csc | SIFEN_ID_CSC | ID del CSC (ej: 0001) |
sifen.csc | SIFEN_CSC | Código de Seguridad del Contribuyente (secreto) |
sifen.sis_fact | SIFEN_SIS_FACT | 1 = sistema propio, 2 = SET |
7. Puntos Críticos para OnnixConnect OC (Checklist)
- SOAP 1.2: usar
Content-Type: application/soap+xmlconaction=inline, nuncatext/xml. - Namespace del envelope:
http://www.w3.org/2003/05/soap-envelope(prefijoenv:), nosoapenv:. - mTLS: pasar
.p12conCURLOPT_SSLCERTTYPE='P12'. Un único archivo, no PEM separado. - F5 BIG-IP: activar
FOLLOWLOCATION,HTTP_VERSION_1_1,USERAGENTno vacío,Expect:vacío. - DOMDocument:
preserveWhiteSpace=false+formatOutput=falseANTES de construir el árbol. - Atributo Id:
setIdAttributeNode($attr, true)sobre el nodoDE. Sin esto la firma falla. - Orden de grupos:
gOpeDE → gTimb → gDatGralOpe → gDtipDE → gTotSub → Signature → gCamFuFD. - Firma ANTES del QR:
SignatureHelper::signDocument()antes de creargCamFuFD. - DigestValue → Hex:
bin2hex(base64_encode(base64_decode($digestB64)))— triple transformación. - dFeEmiDE en QR:
bin2hex(Carbon::parse($fecha)->format('Y-m-d\TH:i:s'))— hex de string UTF-8. - Parámetros QR en orden fijo:
nVersion, Id, dFeEmiDE, [dRucRec|dNumIDRec], dTotGralOpe, dTotIVA, cItems, DigestValue, IdCSC— el hash depende del orden. - Sin urlencode en QR:
buildUrlParams()usa concatenación directa, nohttp_build_query(). - DV Módulo 11: dígito a dígito (no parsear el número completo como int). Pesos
[2,3,4,5,6,7]cíclicos de derecha a izquierda.dv=11→0,dv=10→1. - CDC: 43 dígitos con padding + 1 DV = 44 dígitos. Calcular antes de construir el DOM.
- gCamFuFD: último nodo del XML, creado con
createTextNode()para que el&de la URL quede como&en el XML sin romper el parser. - Montos decimales:
number_format($val, $scale, '.', '')sin separador de miles, nunca cast directo.
Generado: 2026-03-03 | Proyecto fuente: sifen_laravel_muestra | Destino: OnnixConnect OC