SIFEN QR — Lógica de Generación del dCarQR
Fuente canónica:
rshk-jsifen-mavenClase principal:DocumentoElectronico.java(métodogenerateQRLink, 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=...&Id=...&...&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
&→&: El método.setTextContent()de SOAP lo hace automáticamente. Java nunca hace un.replace("&", "&")manual. El QR scanner lee&(decodificado). PHP: usarhtmlspecialchars($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:
160000PYG - Total IVA:
14545PYG (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}
4. Código Java Original — generateQRLink() (copia textual)
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 URL | Fuente Java | Campo JSON / Regla |
|---|---|---|---|
| 1 | nVersion | Constants.SIFEN_CURRENT_VERSION | Constante "150" |
| 2 | Id | this.getId() | CDC (43 chars) + DV (1 char) = 44 chars |
| 3 | dFeEmiDE | bytesToHex(fecha.format("yyyy-MM-dd'T'HH:mm:ss").getBytes(UTF_8)) | gDatGralOpe.dFeEmiDE → hex de bytes UTF-8 |
| 4a | dRucRec | gDatRec.dRucRec si iNatRec=1 (B2B) | gDatGralOpe.gDatRec.dRucRec (con guión si aplica) |
| 4b | dNumIDRec | gDatRec.dNumIDRec si iNatRec=2 && iTiOpe≠4 && dNumIDRec≠null | gDatGralOpe.gDatRec.dNumIDRec |
| 4c | dNumIDRec | "0" si exportación (iTiOpe=4) o sin receptor | Literal "0" |
| 5 | dTotGralOpe | String.valueOf(gTotSub.dTotGralOpe) | gTotSub.dTotGralOpe; "0" si iTiDE=7 (remisión) |
| 6 | dTotIVA | String.valueOf(gTotSub.dTotIVA) | Solo si iTImp=1 o 5; si no → "0" |
| 7 | cItems | gDtipDE.gCamItemList.size() | Cantidad de ítems del documento |
| 8 | DigestValue | bytesToHex(Base64.encode(rawDigestBytes)) | Doble codificación: bytes crudos → Base64 → hex lowercase |
| 9 | IdCSC | sifenConfig.getIdCSC() (leftPad 4 dígitos) | Configurado en DB como empresa.idCsc |
| 10 | cHashQR | sha256Hex(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:
| Texto | Bytes UTF-8 (hex) |
|---|---|
2024-01-15T10:30:00 | 323032342d30312d31355431303a33303a3030 |
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:
- El string de entrada = todos los parámetros de
nVersionhastaIdCSCseparados por& - El CSC se concatena directamente al final, sin
&ni ningún otro separador - El resultado es SHA-256 en hexadecimal minúscula (64 chars)
cHashQRse 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_querypuede 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:
| Campo | Rol | Aparece en QR |
|---|---|---|
dCodSeg | Parte del CDC (posiciones 35–43) | Indirectamente (dentro del Id) |
IdCSC | ID del CSC asignado por SET (4 dígitos) | Sí (parámetro IdCSC) |
CSC | Código secreto CSC asignado por SET | No (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:
| # | Causa | Síntoma | Fix |
|---|---|---|---|
| A | dFeEmiDE enviado como texto plano | 38 chars esperados vs texto recibido | bin2hex($fecha->format('Y-m-d\TH:i:s')) |
| B | DigestValue como Base64 crudo (sin hex) | String con +, /, = en URL | bin2hex(base64_encode($rawDigest)) — resultado siempre 88 chars |
| C | DigestValue como hex de bytes crudos | 64 chars en vez de 88 | Agregar la capa Base64 antes del hex |
| D | Hex en MAYÚSCULAS | "AB3F..." vs "ab3f..." | Forzar strtolower() en bytesToHex y sha256Hex |
| E | & entre urlParamsString y CSC | "...IdCSC=0002&EFGH..." | Concatenar directo: $urlParams . $csc |
| F | Trailing & en urlParamsString antes del hash | "...IdCSC=0002&" + CSC | Java elimina el último & (substring(0, length-1)) |
| G | IdCSC sin pad a 4 dígitos | "2" vs "0002" | str_pad($idCsc, 4, '0', STR_PAD_LEFT) |
| H | Parámetros en orden diferente | Hash calculado sobre string distinto | Respetar el orden: nVersion, Id, dFeEmiDE, dRucRec/dNumIDRec, dTotGralOpe, dTotIVA, cItems, DigestValue, IdCSC |
| I | dTotIVA con valor real cuando iTImp≠1,5 | SET espera "0" | Java hardcodea "0" para iTImp=2,3,4 |
| J | http_build_query URL-encodea valores | - se convierte en %2D en RUC | Construir 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 & → & en el XML