ADR-027 — Reconocimiento del dispositivo por secreto opaco (memoria del DNI sin guardar el DNI)¶
Status: accepted (2026-08-23; feature nueva pedida por el operador — acelerar la 2ª
participación sin re-pedir el DNI)
Context: En la 2ª participación desde el mismo teléfono, hoy el cliente vuelve a ingresar
(o re-escanear) el DNI aunque ya esté en el padrón. Se quiere "recordar" a la persona para que
el flujo sea un saludo + confirmación de un toque. El camino ingenuo — guardar el DNI en
localStorage — no es aceptable: deja el DNI en claro en el navegador (CWE-312). Un hash
del DNI en el cliente tampoco protege: un DNI son ~90 millones de valores, así que el hash
se revierte por fuerza bruta con la propia app (falsa privacidad). El teléfono además puede ser
compartido (la caja del súper, un familiar), así que reconocer no puede implicar participar.
Alternatives:
DNI en claro en localStorage: simple, pero expone el DNI en el navegador. Rechazado
(viola CONSTITUTION — CWE-312).
Hash del DNI en el cliente: falsa privacidad (espacio de ~90M → brute-forceable). Rechazado.
Secreto opaco emitido por el backend (elegido): al participar, el backend genera un
secreto aleatorio (≥128 bits, CSPRNG); el navegador guarda solo { secreto, nombre }. El DNI
nunca toca el navegador. El secreto no significa nada sin el servidor (es un identificador
opaco, no deriva del DNI). En DB se persiste solo el hash del secreto (mismo patrón que
hash_token del voucher, AC-03), asociado al cliente.
Decision:
Naming (evita colisión): en TODO el sistema "token" ya es el voucher QR (hash_token,
/p/{token}). Esta feature se llama "reconocimiento", nunca "token" a secas. Tabla
reconocimiento_dispositivo, secreto en cliente = secretoReconocimiento, columna en DB =
hash_reconocimiento, endpoint = POST /api/participacion/reconocer.
Emisión: al consumir una chance (alta caso D o confirmar caso E), el backend emite un
secreto opaco nuevo, guarda su hash en reconocimiento_dispositivo (cliente_id,
hash_reconocimiento UNIQUE, creado_en, ultimo_uso_en), y lo devuelve una sola vez en la
respuesta. El navegador lo guarda en localStorage junto al primer nombre.
Excepción (no rota): el path confirmar-reconocido NO emite un secreto nuevo — el
dispositivo ya presentó uno válido y lo reusa, garantizando "1 por dispositivo". Solo los
caminos por DNI (alta caso D, confirmar caso E por DNI = primer contacto del dispositivo)
emiten. El frontend confía en el secreto que emite el backend: lo guarda cuando viene, y en
su ausencia (confirmar-reconocido) conserva el que ya tiene.
Cardinalidad:N por cliente, 1 por dispositivo (tabla aparte, no columna en cliente).
Un cliente puede ser reconocido desde el celu de casa Y el del trabajo sin pisar secretos.
Vida:permanente, sin expiración, cruza campañas (decisión explícita del operador —
ver Consequences). El padrón de clientes es permanente, así que reconocer entre campañas es
coherente con el modelo de datos.
Lectura:POST /api/participacion/reconocer recibe el secreto, lo hashea, busca la fila y
devuelve solo{ nombre, dni_mascarado } (reusa ClienteResumenDto). Nunca DNI completo,
nunca email/celular. Secreto no encontrado / cliente borrado → respuesta que instruye alta por
DNI, nunca 500 ni error colgante (mismo criterio que AC-43).
Reconocer ≠ participar: aun con secreto válido, participar exige confirmación explícita
del usuario + un voucher vigente. El saludo "Hola {nombre} — confirmá tu DNI *5555" nunca
dispara la chance solo. *"No soy yo" borra el secreto del localStorage y cae al alta por DNI
(protege el teléfono compartido).
Confirmar-por-reconocimiento (el one-tap real):POST /api/participacion/confirmar-reconocido
recibe { token (voucher), secreto } — sin DNI del cliente. Hashea el secreto, lo mapea a
cliente del lado servidor, y consume la chance para ese cliente por el MISMO camino que
confirmar caso E (voucher vigente, consumo atómico, tope diario, exclusión). El DNI nunca
viaja del cliente: se resuelve server-side desde el secreto — refuerza la privacidad, no la
debilita. Secreto no encontrado → respuesta que instruye alta por DNI, nunca 500. La landing lo
invoca solo con el tap explícito de "Confirmar y participar" (cumple AC-53: no participa
solo). Sin este endpoint el saludo sería cosmético (reconoce pero igual pide el DNI) y la feature
no entregaría su valor — por eso es parte de PR1, no diferible.
Seguridad del endpoint: cae bajo /api/** → ya cubierto por RateLimitFilter (AC-19). El
secreto es ≥128-bit CSPRNG (no adivinable ni enumerable).
Configurable por cliente (sp.reconocimiento.habilitado, default true): toda la feature
vive detrás de un flag en el YAML externo (ADR-029). En false: el backend no emite
secreto (secretoReconocimiento queda null en alta/confirmar), y /reconocer +
/confirmar-reconocido responden como "secreto no encontrado" (instruyen alta por DNI, nunca
error); la landing no guarda secreto ni intenta reconocer — pide siempre el DNI (flujo
clásico). Le da al cliente una perilla de privacidad. Asume single-tenant (una instancia por
supermercado, igual que el branding); si fuera multi-tenant, el flag iría por cliente en DB, no
en YAML. Un solo booleano, sin sub-opciones (YAGNI).
Consequences:
El DNI nunca queda en el navegador; el reconocimiento es privado por construcción.
La 2ª participación es un saludo + un toque, sin re-tipear el DNI.
Consistente con el patrón existente (se persiste el hash, no el secreto).
El secreto permanente es un credencial bearer que no caduca: si el teléfono se pierde o se
vende, quien lo tenga ve "{nombre} *{4 dígitos}" y puede participar como esa persona de un
toque. *Blast radius acotado y aceptado: solo expone primer nombre + DNI enmascarado (nunca
el DNI completo ni contacto), siempre exige confirmación + voucher vigente, y "No soy yo" lo
borra. La conveniencia de no re-pedir el DNI se prioriza sobre ese riesgo acotado.
Schema nuevo: tabla reconocimiento_dispositivo (migración V11).
backend/.../api/ParticipacionController.java — POST /api/participacion/reconocer.
backend/.../api/dto/ParticipacionResponse.java — agregar secretoReconocimiento? (solo en
respuesta de alta/confirmar, NON_NULL); reusar ClienteResumenDto para reconocer.
landing/src/api/client.ts + types/api.ts — reconocerDispositivo(secreto); persistencia
en localStorage (nuevo lib/reconocimiento.ts: get/set/clear { secreto, nombre }).
landing/src/App.tsx — al abrir /p/{token}, si hay secreto guardado → pantalla de saludo
(reconocido) antes de pedir DNI; "No soy yo" limpia y cae al flujo por DNI.
Patterns: hash del secreto = mismo enfoque que hash_token (AC-03). Endpoint bajo /api/**
para heredar rate-limit. Respuesta enmascarada = DniUtil.mascarar (ya existe).
Tests: golden del enmascarado; test de que reconocer con secreto inexistente cae a alta;
test de que el DNI no se escribe en localStorage; test de que reconocer no consume chance.
[x] AC-50 — Al consumir chance (alta/confirmar), el backend emite un secreto opaco ≥128-bit
CSPRNG; en DB se persiste solo hash_reconocimiento (nunca el secreto), asociado al cliente;
el secreto vuelve una sola vez en la respuesta. Evidencia: ReconocimientoEmisionTest —
Tests run: 1, Failures: 0, Errors: 0 (SQL Server real, V11 aplicada).
[x] AC-51 — POST /api/participacion/reconocer con secreto válido devuelve solo
{ nombre, dni_mascarado } (nunca DNI completo, email ni celular); secreto no matcheado →
respuesta que instruye alta por DNI, nunca 500. Evidencia: ReconocimientoApiTest —
Tests run: 3, Failures: 0, Errors: 0.
[x] AC-52 — El DNI nunca se persiste en el navegador: localStorage guarda solo
{ secretoReconocimiento, nombre } (test sobre el flujo de participación). Evidencia:
tests/reconocimiento.test.ts (5 tests) + tests/reconocimiento-flujo.test.tsx — vitest
run, 0 fallos.
[x] AC-53 — Reconocer nunca participa solo: con secreto válido, participar exige confirmación
explícita + voucher vigente; "No soy yo" borra el secreto del navegador y cae al alta por DNI.
Evidencia: ReconocimientoApiTest.ac53_reconocerNoConsumeChance +
reconocimiento-flujo.test.tsx (backend real + vitest, 0 fallos).
[x] AC-61 — Confirmar-por-reconocimiento: POST /api/participacion/confirmar-reconocido con
{token, secreto} válidos mapea el secreto→cliente server-side y consume la chance para ese
cliente (caso E) SIN que el DNI viaje del cliente (el request no lleva DNI); exige voucher
vigente; secreto no encontrado → respuesta que instruye alta por DNI, nunca 500; la landing lo
invoca solo con el tap explícito de "Confirmar y participar". Evidencia:
ConfirmarReconocidoApiTest — Tests run: 3, Failures: 0, Errors: 0 (SQL Server real) +
reconocimiento-flujo.test.tsx AC-61 (vitest, 0 fallos). Suite completa del backend:
Tests run: 217, Failures: 0, Errors: 0 — BUILD SUCCESS.
[x] AC-62 — Flag sp.reconocimiento.habilitado: en false, alta/confirmar NO devuelven
secretoReconocimiento (null), /reconocer y /confirmar-reconocido responden instruyendo
alta por DNI (nunca 500), y la landing pide siempre el DNI (no guarda ni intenta reconocer);
en true, la feature opera normal (AC-50..53, AC-61). Evidencia:
ReconocimientoFlagDeshabilitadoTest (4 tests) + CampaniaPublicaFlagDeshabilitadoTest (1
test) — Tests run: 5, Failures: 0, Errors: 0 (SQL Server real); reconocimiento-flujo.test.tsx
AC-62 (vitest, 0 fallos). Suite completa del backend: Tests run: 222, Failures: 0, Errors: 0
— BUILD SUCCESS. Suite completa de landing: 20 archivos, 96 tests, 0 fallos.
Hallazgo durante AC-62 (fijado en el mismo commit): el guard de PERF del estado
embebido (SpaForwardController) cortaba el chequeo de reconocer() ANTES de correrlo —
como esa ruta se ejecuta en TODA visita real a /p/{token}, el reconocimiento nunca se
disparaba en produccion. Cubierto con un test de regresion (AC-51, camino embebido) antes
del fix.