Saltar a contenido

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 localStorageno 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).

Implementation Plan

  • Affected paths:
  • backend/.../db/migration/V11__reconocimiento_y_datos_cliente.sql — tabla reconocimiento_dispositivo (FK cliente_id, hash_reconocimiento UNIQUE, creado_en, ultimo_uso_en).
  • backend/.../dominio/ReconocimientoDispositivo.java (entity) + repositorio.
  • backend/.../participacion/ParticipacionService.java — emitir secreto al consumir chance (alta/confirmar); método reconocer(secreto, ip).
  • backend/.../api/ParticipacionController.javaPOST /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.tsreconocerDispositivo(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.

Verification

  • [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.