Saltar al contenido principal

SIFEN QR — Lógica de Generación del dCarQR

Fuente canónica: rshk-jsifen-maven Clase principal: DocumentoElectronico.java (método generateQRLink, línea 379) Utilidades: SifenUtil.java (bytesToHex, sha256Hex, buildUrlParams) Configuración: SifenConfig.java (URLs base, IdCSC, CSC) Auditoría: 2026-03-13


1. Ubicación en el XML

El QR se emite como el último elemento del documento, dentro de gCamFuFD:

<gCamFuFD>
<dCarQR>https://ekuatia.set.gov.py/consultas/qr?nVersion=...&amp;Id=...&amp;...&amp;cHashQR=...</dCarQR>
</gCamFuFD>

Código Java exacto de emisión (DocumentoElectronico.java, líneas 323–326):

// Firma Digital del XML
SignedInfo signedInfo = SignatureHelper.signDocument(sifenConfig, rDE, this.getId());

// Preparación de la URL del QR
this.enlaceQR = this.generateQRLink(signedInfo, sifenConfig);
SOAPElement gCamFuFD = rDE.addChildElement("gCamFuFD");
gCamFuFD.addChildElement("dCarQR").setTextContent(this.enlaceQR);

Escapado &&amp;: El método .setTextContent() de SOAP lo hace automáticamente. Java nunca hace un .replace("&", "&amp;") manual. El QR scanner lee & (decodificado). PHP: usar htmlspecialchars($url, ENT_XML1) o dejar que el builder XML lo maneje. NO concatenar la URL directamente en un string XML.


2. URL Base por Ambiente

Definidas en SifenConfig.java, líneas 103–104:

private final String URL_CONSULTA_QR_DEV = "https://ekuatia.set.gov.py/consultas-test/qr?";
private final String URL_CONSULTA_QR_PROD = "https://ekuatia.set.gov.py/consultas/qr?";

La URL ya incluye el ? final. Los parámetros se concatenan directamente.


3. Estructura Completa de la URL

{urlBase}nVersion={v}&Id={CDC45}&dFeEmiDE={hexFecha}&{dRucRec|dNumIDRec}={valor}&dTotGralOpe={total}&dTotIVA={iva}&cItems={n}&DigestValue={hexB64Digest}&IdCSC={idCsc4d}&cHashQR={sha256hex}

Ejemplo real construido (paso a paso)

Datos de entrada del documento:

  • Ambiente: PROD
  • CDC + DV = "0180069590600100100000011202401151030001234X" (44 chars, X = DV calculado)
  • Fecha emisión: 2024-01-15T10:30:00
  • Receptor: Contribuyente (iNatRec=1), RUC = "80012345-6"
  • Total operación: 160000 PYG
  • Total IVA: 14545 PYG (iTImp=1)
  • Cantidad de ítems: 2
  • DigestValue (raw bytes de la firma): [0xAB, 0xCD, ...] (32 bytes SHA-256)
  • IdCSC configurado: "2" (se padea a "0002")
  • CSC: "EFGH0000000000000000000000000000"

Paso 1 — Parámetros en orden (LinkedHashMap):

nVersion = "150"
Id = "0180069590600100100000011202401151030001234X"
dFeEmiDE = "323032342d30312d31355431303a33303a3030" ← HEX de UTF-8 de la fecha
dRucRec = "80012345-6" ← B2B: iNatRec=1
dTotGralOpe = "160000"
dTotIVA = "14545" ← iTImp=1 o 5; si no: "0"
cItems = "2"
DigestValue = "596d46...6630" ← HEX de Base64(rawDigestBytes)
IdCSC = "0002" ← leftPad a 4 dígitos

Paso 2 — urlParamsString:

nVersion=150&Id=0180069590600100100000011202401151030001234X&dFeEmiDE=323032342d30312d31355431303a33303a3030&dRucRec=80012345-6&dTotGralOpe=160000&dTotIVA=14545&cItems=2&DigestValue=596d46...6630&IdCSC=0002

Paso 3 — cHashQR:

input = urlParamsString + "EFGH0000000000000000000000000000"
↑ CSC pegado directo, sin "&" ni separador
cHashQR = sha256Hex(input) → lowercase hex de 64 chars

Paso 4 — URL final:

https://ekuatia.set.gov.py/consultas/qr?{urlParamsString}&cHashQR={cHashQR}

Archivo: rshk-jsifen-maven/src/main/java/com/roshka/sifen/core/beans/DocumentoElectronico.java Líneas 379–417

private String generateQRLink(SignedInfo signedInfo, SifenConfig sifenConfig) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
LinkedHashMap<String, String> queryParams = new LinkedHashMap<>();

queryParams.put("nVersion", SIFEN_CURRENT_VERSION);
queryParams.put("Id", this.getId());
queryParams.put("dFeEmiDE", SifenUtil.bytesToHex(this.getgDatGralOpe().getdFeEmiDE().format(formatter).getBytes(StandardCharsets.UTF_8)));

if (this.getgDatGralOpe().getgDatRec().getiNatRec().getVal() == 1) {
queryParams.put("dRucRec", this.getgDatGralOpe().getgDatRec().getdRucRec());
} else if (this.getgDatGralOpe().getgDatRec().getiTiOpe().getVal() != 4 && this.getgDatGralOpe().getgDatRec().getdNumIDRec() != null) {
queryParams.put("dNumIDRec", this.getgDatGralOpe().getgDatRec().getdNumIDRec());
} else {
queryParams.put("dNumIDRec", "0");
}

if (this.getgTimb().getiTiDE().getVal() != 7) {
queryParams.put("dTotGralOpe", String.valueOf(this.getgTotSub().getdTotGralOpe()));
queryParams.put("dTotIVA",
this.getgDatGralOpe().getgOpeCom().getiTImp().getVal() == 1 || this.getgDatGralOpe().getgOpeCom().getiTImp().getVal() == 5
? String.valueOf(this.getgTotSub().getdTotIVA())
: "0"
);
} else {
queryParams.put("dTotGralOpe", "0");
queryParams.put("dTotIVA", "0");
}

queryParams.put("cItems", String.valueOf(this.getgDtipDE().getgCamItemList().size()));

byte[] digestValue = Base64.getEncoder().encode(((Reference) signedInfo.getReferences().get(0)).getDigestValue());
queryParams.put("DigestValue", SifenUtil.bytesToHex(digestValue));
queryParams.put("IdCSC", sifenConfig.getIdCSC());

String urlParamsString = SifenUtil.buildUrlParams(queryParams);
String hashedParams = SifenUtil.sha256Hex(urlParamsString + sifenConfig.getCSC());

return sifenConfig.getUrlConsultaQr() + urlParamsString + "&cHashQR=" + hashedParams;
}

5. Código Java Original — Utilidades (copia textual)

Archivo: rshk-jsifen-maven/src/main/java/com/roshka/sifen/internal/util/SifenUtil.java

// Convierte byte[] a hexadecimal MINÚSCULA
public static String bytesToHex(byte[] bytes) {
char[] HEX_ARRAY = "0123456789abcdef".toCharArray(); // ← minúsculas
char[] hexChars = new char[bytes.length * 2];
for (int j = 0; j < bytes.length; j++) {
int v = bytes[j] & 0xFF;
hexChars[j * 2] = HEX_ARRAY[v >>> 4];
hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
}
return new String(hexChars);
}

// SHA-256 → hexadecimal MINÚSCULA
public static String sha256Hex(String input) {
try {
MessageDigest md5 = MessageDigest.getInstance("SHA-256");
byte[] digest = md5.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(Integer.toHexString((b & 0xFF) | 0x100), 1, 3); // ← minúsculas
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
return null;
}
}

// Construye "k1=v1&k2=v2&..." SIN trailing "&", respeta orden de inserción
public static String buildUrlParams(HashMap<String, String> params) {
if (params.isEmpty()) return "";
StringBuilder paramsString = new StringBuilder();
for (Map.Entry<String, String> param : params.entrySet()) {
paramsString.append(param.getKey()).append("=").append(param.getValue()).append("&");
}
return paramsString.substring(0, paramsString.length() - 1); // elimina el último "&"
}

Archivo: SifenConfig.java, línea 429 (setter de IdCSC):

public final void setIdCSC(String idCSC) {
this.idCSC = SifenUtil.leftPad(idCSC, '0', 4); // ← siempre 4 dígitos con ceros
}

6. Mapeo Técnico: Parámetro QR → Fuente Java → Campo JSON

#Parámetro URLFuente JavaCampo JSON / Regla
1nVersionConstants.SIFEN_CURRENT_VERSIONConstante "150"
2Idthis.getId()CDC (43 chars) + DV (1 char) = 44 chars
3dFeEmiDEbytesToHex(fecha.format("yyyy-MM-dd'T'HH:mm:ss").getBytes(UTF_8))gDatGralOpe.dFeEmiDEhex de bytes UTF-8
4adRucRecgDatRec.dRucRec si iNatRec=1 (B2B)gDatGralOpe.gDatRec.dRucRec (con guión si aplica)
4bdNumIDRecgDatRec.dNumIDRec si iNatRec=2 && iTiOpe≠4 && dNumIDRec≠nullgDatGralOpe.gDatRec.dNumIDRec
4cdNumIDRec"0" si exportación (iTiOpe=4) o sin receptorLiteral "0"
5dTotGralOpeString.valueOf(gTotSub.dTotGralOpe)gTotSub.dTotGralOpe; "0" si iTiDE=7 (remisión)
6dTotIVAString.valueOf(gTotSub.dTotIVA)Solo si iTImp=1 o 5; si no → "0"
7cItemsgDtipDE.gCamItemList.size()Cantidad de ítems del documento
8DigestValuebytesToHex(Base64.encode(rawDigestBytes))Doble codificación: bytes crudos → Base64 → hex lowercase
9IdCSCsifenConfig.getIdCSC() (leftPad 4 dígitos)Configurado en DB como empresa.idCsc
10cHashQRsha256Hex(urlParamsString + CSC)SHA-256 lowercase de todos los params (sin cHashQR=) + CSC secreto

7. El DigestValue — Doble Codificación (CRÍTICO)

Este es el parámetro más frecuentemente mal implementado.

Lo que hace Java:

// signedInfo.getReferences().get(0).getDigestValue()
// → retorna byte[] crudos (ej. 32 bytes de SHA-256)

byte[] rawDigest = ((Reference) signedInfo.getReferences().get(0)).getDigestValue();
// rawDigest = [0xAB, 0xF3, 0x01, ...] (32 bytes)

byte[] base64Bytes = Base64.getEncoder().encode(rawDigest);
// base64Bytes = bytes de la cadena Base64, ej. "q/MB..." en UTF-8
// = [0x71, 0x2F, 0x4D, 0x42, ...] (44 bytes para SHA-256)

String digestValue = SifenUtil.bytesToHex(base64Bytes);
// digestValue = "712f4d42..." (88 chars hex lowercase)

Lo que NO debe hacerse:

// ❌ INCORRECTO — Base64 crudo en la URL
$digestValue = base64_encode($rawDigest);
// "q/MB..." ← esto no es lo que espera SET

// ❌ INCORRECTO — Hex de los bytes crudos (sin pasar por Base64 primero)
$digestValue = bin2hex($rawDigest);
// Hex de 32 bytes = 64 chars, pero Java genera hex de 44 bytes Base64 = 88 chars

Lo que SÍ debe hacerse en PHP:

// ✅ CORRECTO — replicar exactamente la lógica Java
$rawDigest = /* bytes crudos del DigestValue del XML firmado */;
$base64String = base64_encode($rawDigest); // cadena Base64 como string
$digestValue = bin2hex($base64String); // hex lowercase de los bytes del string Base64

Verificación rápida: para SHA-256 (32 bytes), el DigestValue correcto en el QR siempre tiene exactamente 88 caracteres (44 bytes Base64 × 2 hex chars). Si tu valor tiene 64 chars, estás haciendo bin2hex($rawDigest) sin pasar por Base64 primero.


8. La Fecha dFeEmiDE — Hex de UTF-8 (CRÍTICO)

La fecha no va como texto plano. Va como hexadecimal lowercase de los bytes UTF-8.

Transformación:

"2024-01-15T10:30:00" → UTF-8 bytes → hex lowercase

Cada carácter ASCII tiene su valor byte directo:

TextoBytes UTF-8 (hex)
2024-01-15T10:30:00323032342d30312d31355431303a33303a3030

Siempre 38 caracteres hex (19 chars ASCII × 2).

PHP:

// ✅ CORRECTO
$fechaFormateada = $fecha->format('Y-m-d\TH:i:s'); // "2024-01-15T10:30:00"
$dFeEmiDE = bin2hex($fechaFormateada); // "323032342d..."
// ❌ INCORRECTO — texto plano
$dFeEmiDE = $fecha->format('Y-m-d\TH:i:s'); // "2024-01-15T10:30:00"

9. El cHashQR — Algoritmo de Hash

String urlParamsString = SifenUtil.buildUrlParams(queryParams);
// → "nVersion=150&Id=...&dFeEmiDE=...&...&IdCSC=0002"
// (sin trailing "&", sin "cHashQR=")

String hashedParams = SifenUtil.sha256Hex(urlParamsString + sifenConfig.getCSC());
// → SHA-256 de: "nVersion=150&Id=...&IdCSC=0002EFGH0000000000000000000000000000"
// ↑ CSC pegado directo, SIN "&"

Reglas del hash:

  1. El string de entrada = todos los parámetros de nVersion hasta IdCSC separados por &
  2. El CSC se concatena directamente al final, sin & ni ningún otro separador
  3. El resultado es SHA-256 en hexadecimal minúscula (64 chars)
  4. cHashQR se añade como último parámetro: &cHashQR={hash}

PHP:

// ✅ CORRECTO
$params = [
'nVersion' => '150',
'Id' => $cdc,
'dFeEmiDE' => bin2hex($fecha->format('Y-m-d\TH:i:s')),
'dRucRec' => $rucRec, // o 'dNumIDRec' según tipo receptor
'dTotGralOpe' => (string) $totGralOpe,
'dTotIVA' => (string) $totIVA,
'cItems' => (string) count($items),
'DigestValue' => bin2hex(base64_encode($rawDigestBytes)),
'IdCSC' => str_pad($idCsc, 4, '0', STR_PAD_LEFT),
];

$urlParams = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
// ADVERTENCIA: http_build_query URL-encodea ciertos chars como "+"
// Verificar que el resultado coincida con la simple concatenación de Java

$cHashQR = hash('sha256', $urlParams . $csc); // minúsculas por defecto en PHP

$url = $baseUrl . '?' . $urlParams . '&cHashQR=' . $cHashQR;

Atención con http_build_query: Java NO URL-encodea los valores (usa concatenación directa). Si el RUC o algún valor contiene -, +, %, etc., http_build_query puede encodearlo mientras Java no lo hace. Verificar con un ejemplo conocido. Para máxima fidelidad, construir el string manualmente:

$parts = [];
foreach ($params as $k => $v) { $parts[] = "$k=$v"; }
$urlParams = implode('&', $parts);

10. El dCodSeg — Relación con el QR

dCodSeg es el código de seguridad aleatorio que forma parte del CDC. No aparece en la URL del QR directamente.

Dónde vive:

// En obtenerCDC() — DocumentoElectronico.java, línea 140
CDC = ... + this.getgOpeDE().getdCodSeg(); // últimos 9 dígitos del CDC (antes del DV)

Generación:

// TgOpeDE o SifenUtil.generateRandomNumber()
String dCodSeg = SifenUtil.leftPad(
String.valueOf(new Random().ints(1, 1, 999999999).distinct().toArray()[0]),
'0', 9
);
// → 9 dígitos aleatorios, padded con ceros: "030001234"

Cuadro resumen:

CampoRolAparece en QR
dCodSegParte del CDC (posiciones 35–43)Indirectamente (dentro del Id)
IdCSCID del CSC asignado por SET (4 dígitos) (parámetro IdCSC)
CSCCódigo secreto CSC asignado por SETNo (solo se usa para calcular cHashQR)

11. Diagnóstico del Error 0160

Significado: 0160 = "El código de seguridad del QR no coincide" (cHashQR inválido).

SET recalcula el cHashQR con los mismos parámetros y su propio CSC. Si no coincide, rechaza con 0160.

Checklist de causas frecuentes:

#CausaSíntomaFix
AdFeEmiDE enviado como texto plano38 chars esperados vs texto recibidobin2hex($fecha->format('Y-m-d\TH:i:s'))
BDigestValue como Base64 crudo (sin hex)String con +, /, = en URLbin2hex(base64_encode($rawDigest)) — resultado siempre 88 chars
CDigestValue como hex de bytes crudos64 chars en vez de 88Agregar la capa Base64 antes del hex
DHex en MAYÚSCULAS"AB3F..." vs "ab3f..."Forzar strtolower() en bytesToHex y sha256Hex
E& entre urlParamsString y CSC"...IdCSC=0002&EFGH..."Concatenar directo: $urlParams . $csc
FTrailing & en urlParamsString antes del hash"...IdCSC=0002&" + CSCJava elimina el último & (substring(0, length-1))
GIdCSC sin pad a 4 dígitos"2" vs "0002"str_pad($idCsc, 4, '0', STR_PAD_LEFT)
HParámetros en orden diferenteHash calculado sobre string distintoRespetar el orden: nVersion, Id, dFeEmiDE, dRucRec/dNumIDRec, dTotGralOpe, dTotIVA, cItems, DigestValue, IdCSC
IdTotIVA con valor real cuando iTImp≠1,5SET espera "0"Java hardcodea "0" para iTImp=2,3,4
Jhttp_build_query URL-encodea valores- se convierte en %2D en RUCConstruir el query string manualmente

Procedimiento de debugging:

// 1. Loguear el urlParamsString ANTES de concatenar el CSC
$urlParams = implode('&', array_map(fn($k,$v) => "$k=$v", array_keys($params), $params));
Log::debug('QR params string: ' . $urlParams);
Log::debug('QR params length: ' . strlen($urlParams));

// 2. Verificar longitud de DigestValue (debe ser 88 chars para SHA-256)
Log::debug('DigestValue length: ' . strlen($params['DigestValue'])); // esperado: 88

// 3. Verificar dFeEmiDE (debe ser 38 chars)
Log::debug('dFeEmiDE length: ' . strlen($params['dFeEmiDE'])); // esperado: 38

// 4. Loguear el input del hash
$hashInput = $urlParams . $csc;
Log::debug('cHashQR input: ' . $hashInput);
Log::debug('cHashQR: ' . hash('sha256', $hashInput));

12. Flujo Completo — Diagrama de Secuencia

DocumentoElectronico.setupDE()

├─ [1] Serializa todos los campos XML
├─ [2] SignatureHelper.signDocument() → retorna SignedInfo
│ └─ DigestValue = SHA-256 de nodo DE canonicalizado (bytes crudos)

├─ [3] generateQRLink(signedInfo, sifenConfig)
│ ├─ nVersion = "150"
│ ├─ Id = CDC + DV (44 chars)
│ ├─ dFeEmiDE = bytesToHex(fecha.getBytes(UTF_8))
│ ├─ dRucRec/dNumIDRec = según iNatRec + iTiOpe
│ ├─ dTotGralOpe = String.valueOf(gTotSub.dTotGralOpe)
│ ├─ dTotIVA = String.valueOf(gTotSub.dTotIVA) si iTImp=1|5, sino "0"
│ ├─ cItems = gCamItemList.size()
│ ├─ DigestValue = bytesToHex(Base64.encode(rawDigestBytes))
│ ├─ IdCSC = leftPad(idCSC, '0', 4)
│ ├─ urlParamsString= "k1=v1&k2=v2&...&IdCSC=0002"
│ ├─ cHashQR = sha256Hex(urlParamsString + CSC)
│ └─ return urlBase + urlParamsString + "&cHashQR=" + cHashQR

└─ [4] gCamFuFD > dCarQR .setTextContent(enlaceQR)
└─ SOAP auto-escapa & → &amp; en el XML