El token del QR (Anexo A)¶
Todo el sistema de participación depende de un solo contrato: el token de 35 caracteres que
viaja en la URL del QR impreso en cada ticket. Esta página es la referencia normativa de ese
token — el layout de bytes, cómo se calcula, cómo se valida y qué se persiste. El Anexo A al
que hace referencia el título es la sección normativa del PDF de especificación funcional
(v2.2) que define este layout; el módulo qrtoken (C89 en el POS + espejo Java en el backend) es
su única implementación.
Por qué importa que esto sea exacto
El POS corre en MS-DOS sobre Borland C++ 3.1 (hardware real de las cajas de Caracol) y el backend corre en Java 17 / Spring Boot. Son dos mundos que nunca se sincronizan entre sí: lo único que comparten es la clave HMAC. Si el layout de bytes no coincide byte a byte entre ambas implementaciones, cada voucher impreso sería inválido. Por eso el módulo se trata como código compartido validado, no como algo que se reimplementa por conveniencia.
La idea: el QR es autocontenido¶
El ticket imprime una URL con este formato:
Todo lo que la landing necesita para resolver la participación —sucursal, caja, fecha y hora de emisión, número de comprobante, y qué campaña corresponde— viaja firmado dentro del token. No existe sincronización entre las cajas y el servidor web: la caja imprime offline, y el servidor valida la firma sin depender de ningún dato compartido salvo la clave.
Layout del payload (16 bytes, big-endian)¶

| Offset | Largo | Campo | Descripción |
|---|---|---|---|
| 0 | 1 | keyId |
Id de clave de firma, para rotación sin corte (0–255) |
| 1 | 4 | promoId |
Promoción, long nativo del backoffice de POS (0–2³²-1). Política de asignación/reuso diferida (ADR-014) |
| 5 | 2 | nrosuc |
Sucursal (0–65535) |
| 7 | 1 | nropos |
Caja / POS (0–255) |
| 8 | 2 | fecha |
Días transcurridos desde 2026-01-01 |
| 10 | 2 | hora |
Minutos del día (0–1439) |
| 12 | 4 | nroticket |
Número de comprobante (0–4294967295) |
16 bytes exactos, sin relleno explícito entre campos — el layout es una tira contigua.
La firma autentica, no encripta
Los campos viajan legibles a propósito: la landing muestra directamente "Ticket 45678 · Sucursal 4 · 19/08/2026 14:32" leyendo el payload decodificado, sin ninguna consulta adicional. Lo que el HMAC garantiza es que ese payload no fue alterado ni forjado — no que sea secreto. Con 80 bits de MAC (ver más abajo), forjar un token es computacionalmente inviable.
Cómo se calcula el token¶
token = base64url( payload[16 bytes] ‖ HMAC-SHA256(clave, payload)[0..9] )
= base64url( 26 bytes )
= 35 caracteres, sin padding

- Se arma el
payloadde 16 bytes con el layout de la sección anterior. - Se calcula
HMAC-SHA256(clave, payload)(32 bytes) y se trunca a los primeros 10 bytes (80 bits). - Se concatena
payload ‖ mac_truncado→ 26 bytes. - Se codifica en
base64urlsin padding → 35 caracteres. Ese string es el<token>de la URL.
Por qué 10 bytes de MAC y no los 32 completos
80 bits es inviable de enumerar por fuerza bruta, y dejar el MAC truncado permite que la URL completa entre en un QR versión 3 — que imprime nítido en la impresora térmica de 58/80 mm de las cajas. Con el MAC completo, la URL sería más larga y exigiría una versión de QR más densa, con peor tasa de lectura en térmica.
Codificación canónica (obligatoria en la validación)¶
base64url tiene una particularidad: los últimos bits de un string codificado pueden ser
relleno, así que más de un string puede decodificar exactamente a los mismos 26 bytes. Si el
servidor no normalizara esto, alguien podría "reescribir" un token ya usado con una codificación
alternativa — misma firma válida, pero un hash_token distinto — y duplicar la chance sobre el
mismo voucher físico.
Por eso, validar() exige la codificación canónica: decodifica el token, y si volver a
codificar esos mismos bytes no reproduce el string original, lo rechaza. Dos codificaciones del
mismo voucher siempre resuelven al mismo registro.
Qué se persiste: el hash, nunca el token¶
QrToken.Datos d = QrToken.validar(token, keyId -> claves.get(keyId));
String pk = QrToken.hashToken(token); // esto se persiste, no el token
chance.hash_token (y también voucher_prueba.hash_token) guardan un CHAR(64) — el hash
SHA-256 en hexadecimal de la codificación canónica del token. El token en sí nunca toca la
base de datos. Motivo: si la base de datos se filtrara, nadie podría reconstruir URLs de QR
válidas a partir de los hashes. Ver la tabla chance en el
modelo de datos para el resto de las columnas que acompañan al hash.
Comparación en tiempo constante¶
La verificación del MAC usa MessageDigest.isEqual (equivalente a una comparación de tiempo
constante) en vez de una comparación byte a byte con salida temprana — evita que un atacante
pueda inferir el MAC correcto midiendo cuánto tarda cada intento fallido (timing attack).
Vector de referencia¶
Este es el vector de prueba que ambos autotests (el de C89 del POS y el de Java del backend) deben reproducir exactamente. La clave es solo para test — nunca se usa en producción.
Clave : "CaracolDemoKey2026!" (SOLO PARA TEST — no usar en prod)
Datos : keyId=1 promoId=1 suc=4 pos=2 19/08/2026 14:32 ticket=45678
Token : AQAAAAEABAIA5gNoAACybvOHZD3fe037qgw
# Autotest C (POS, BC3.1 o gcc para CI)
BCC -ms QRTEST.C QRTOKEN.C && QRTEST
# o: gcc -x c -std=c89 -pedantic -Wall -Wextra -O2 -o qrtest QRTEST.C QRTOKEN.C && ./qrtest
# Autotest Java (backend)
javac QrToken.java && java QrToken
Ambos autotests deben terminar en TODO OK y producir el token de arriba. Si alguna vez difieren,
el layout de bytes o el HMAC dejaron de coincidir entre las dos implementaciones — bloqueante para
cualquier despliegue.
Rotación de claves¶
keyId(offset 0) permite rotar la clave de firma sin cortar la emisión: el backend mantiene un mapakeyId → clavey acepta cualquier clave vigente; el POS emite siempre con la clave activa del momento.- El registro
clave_firma(key_id, secreto_cifrado, estado, vigencia_desde, vigencia_hasta)vive en la base de datos — el secreto se cifra at-rest y nunca se loguea ni se devuelve en ninguna respuesta de la API del gestor. Ver clave_firma en el modelo de datos y el recursoclavesen la API del gestor. - En el binario DOS del POS, la clave queda embebida en el ejecutable: rotarla por campaña acota la ventana de exposición si alguien la extrae del binario.
Ver también¶
- Modelo de datos — dónde y cómo se persiste
hash_token. - API HTTP — los endpoints públicos que reciben el token (
resolver,confirmar,alta) y el recurso de claves del gestor. - Casos de participación (A–E) — cómo cada resultado de validar el token (firma inválida, ya usado, fuera de vigencia, vigente) deriva en una pantalla distinta de la landing.