ADR-038: Antirrobots ALTCHA en la ventanilla pública y ficha provisional del remitente anónimo¶
Estado: Aceptado Fecha: 2026-10-20 Autores: Giampiero (mantenedor principal); triage 2026-10-09, tanda C-3 (C16 y C17) Relacionados: ADR-031 (constancia de la autorización y política), ADR-035 (estado de la entidad en el borde), ADR-003 (SQL crudo), ADR-010 (auditoría inmutable)
Contexto¶
La ventanilla pública (POST /api/v1/public/{tenant}/ventanilla y la ruta heredada /pqrs) es la única puerta que crea radicados —con número oficial inmutable (Ley 594/2000)— sin autenticar. Tenía tres defensas: honeypot, límite de tasa en el gateway y la política de tratamiento. Dos defectos quedaban abiertos:
- C16 — sin antirrobots. Un script que respete el honeypot y la cuota por IP quema numeración oficial y almacenamiento (los anexos se suben antes del consecutivo). reCAPTCHA contradice la soberanía declarada (no se envía el tráfico de una entidad pública a un tercero).
- C17 — el remitente se asociaba por número de documento.
crear_o_recuperarbuscaba la ficha por(tipo, número)y devolvía la ajena: quien escribía la cédula de otra persona quedaba atado a SU ficha (con la dirección y el teléfono vigentes de otra persona), sin verificar nada. Era a la vez una suplantación gratuita y un oráculo de enumeración (la respuesta, o el tiempo, delata qué cédulas están en el directorio). Además creaba la ficha fuera de la transacción del radicado: una falla posterior la dejaba huérfana.
Decisión¶
C16 — ALTCHA autoalojado, protocolo v1, en la biblioteca estándar¶
- Reto:
GET /api/v1/public/{tenant}/ventanilla/reto(document-service; el gateway ya enruta/api/v1/public/y lo deja anónimo). Sin base de datos y sin Redis: emitir es cálculo.salt = 32 hex ? expires=<epoch>&t=<slug>&v=1;challenge = sha256(salt + número);signature = HMAC-SHA256(ALTCHA_HMAC_KEY, challenge).tata el reto a la entidad yexpiresa su vigencia: ambos están dentro dechallenge, que la firma cubre, así que alargar el vencimiento o cambiar de entidad invalida la firma. Cae en el cubo anónimo general del gateway, no en el de radicación (si cayera en el estrecho, pedir el reto gastaría la cuota del POST). - Verificación dentro del POST, campo
altchaobligatorio enVentanillaRadicaciony enPqrsCreate(una puerta sin el gate sería el atajo). Un único punto:VentanillaService.radicar, para que ventanilla y PQRS no diverjan. Orden, todo fail-closed: forma (base64, JSON, tipos), algoritmoSHA-256exacto,saltcon parámetrosexpires,t,vúnicos yt == entidad, rango, solución, firma, y solo después de la firma el vencimiento. Va después del camino de reintento porIdempotency-Key(la huella excluyealtcha) y antes de política, anexos y consecutivo: el costo caro solo lo paga quien resolvió un reto vigente y no usado. - Antirreplay en Redis:
SET altcha:usado:{slug}:{challenge} 1 NX EX max(1, expires − ahora + holgura). Redis caído o sin clave →503 antirrobots_no_disponible(fail-closed, nombra los canales alternos: presencial y correo). No es un «cierre total no neutral»: no corre plazo alguno sin radicado y el canal alterno existe. Un409de política o un422de anexo quema el reto; el cliente pide otro (cuesta milisegundos). - Códigos:
422 antirrobots_invalido,422 antirrobots_vencido,409 antirrobots_usado,503 antirrobots_no_disponible. Ninguno revela nada del radicado ni del directorio. - Configuración:
ALTCHA_HMAC_KEY(≥ 32 bytes; el servicio no arranca sin ella; obligatoria${…:?}en producción; nunca llega al navegador),ALTCHA_TTL_SEGUNDOS(300, 60–900),ALTCHA_MAXNUMBER(100000, 1000–1000000),ALTCHA_SKEW_SEGUNDOS(60). Clave por despliegue, no por entidad (la entidad va en elsalt). - [Sustituido por ADR-047: hoy
altcha@3.3.0exacto, protocolo v2 PBKDF2] Widget fijado enaltcha@2.3.0exacto (sin^): la 3.x usa otro protocolo (PBKDF2/Argon2,keyPrefix) y un^lo cambiaría en silencio.tests/test_altcha.pyfija un vector generado con el algoritmo del widget 2.3.0 (no con este código): un bump del widget sin cambiar el verificador lo rompe. - Frontend (otro agente): el widget y su worker salen de
'self'(sinworker-src blob:), el proxy SSR del reto reenvía las cabeceras de IP, el payload viaja dentro dedatos(no en cabecera: no hay listas de cabeceras del gateway que tocar).
C17 — Ficha provisional siempre que un anónimo aporta documento¶
- Con
identificacion,VentanillaServiceno consulta el directorio: pasaremitente_provisionalaDocumentService.create(argumento interno, no campo deDocumentCreate: un cliente autenticado no debe poder pedir fichas provisionales), que crea la ficha dentro de la transacción del radicado y la ata como remitente principal. Si el radicado falla no queda ficha huérfana. Un solo camino de código, mismo tiempo y misma respuesta exista o no el documento. - Modelo (migración tenant
049):tercero.identidad(confirmada|provisional),documento_declarado_tipo/numero(inmutables por trigger: lo declarado es evidencia),conciliacion(pendiente|vinculada|confirmada|descartada),conciliada_at,conciliada_por(auth_users.id, nunca elsubde Keycloak),conciliacion_motivo(≥ 10 caracteres). Una provisional no llevatipo/numero_documento(CHECK): no ocupa el documento ni choca conux_tercero_documento. Los estados terminales no se rehacen (trigger). Las filas existentes sonconfirmadaporDEFAULT: sin backfill ni rama «legado». - Se excluyen del directorio:
buscar,buscar_por_email(ingesta),grupos_duplicados;get_por_documentoya no las ve (númeroNULL).vincular_actoresdesde el radicador interno →422 tercero_provisional;fusionar→409 tercero_provisional(la fusión no deja el motivo ni el asiento de la conciliación y el huecodoc_sup and doc_absla dejaría pasar siempre). - Cola y acto:
GET /terceros/por-conciliaryPOST /terceros/{id}/conciliar(vincular|confirmar|descartar;medio_verificacionobligatorio en las dos primeras),PERM_RADIconmin_crud=3también en la lectura (datos personales no verificados). Clearance no-read-up en elWHEREde las filas, del conteo y de cadaUPDATE(una provisional atada a un radicado por encima del clearance del llamante no aparece ni cuenta);404indistinguible para inexistente/invisible.vincularreutilizafusionado_en(el radicado y suradicado_actorno se tocan —el trigger de la 026 lo impide—; la ficha existente hereda el historial al leer);motivoobligatorio si el documento del destino no coincide;confirmarcopia el documento declarado a la identidad (409 documento_ya_registradosi ya es de otra: vincular);descartarexige motivo. Asientodocument.tercero_conciliadoatómico, solo con identificadores y sin el número de documento (elaudit_loges inmutable y el dato personal ya vive en la ficha). - La identidad del acto sale de
X-User-Idfirmado por el gateway: el gateway reconstruye esa cabecera desde la validación de auth-service (auth_users.id) y no reenvía la del cliente (lista de admisiónFORWARDED_REQUEST_HEADERS), así que lo que el proxy del frontend mande (locals.user.sub) no llega al servicio.
Correcciones tras las auditorías de seguridad y archivística de C-3¶
- La cadena de fusiones se lee entera.
radicados_deresolvíafusionado_enen un solo nivel: provisional → D (vinculada) y luego D → X dejaba los radicados de la provisional fuera de X. Ahora usaWITH RECURSIVE … CYCLEsobrefusionado_en, con el gate de nivel en el mismoWHERE; lo mismo hace el conteo de vínculos defusionary elDOC_Rde la combinación de correspondencia (que además sigue la cadena hasta la superviviente: trasvincular, el documento verificado es el de la ficha destino; una provisional sin vincular dejaDOC_Rvacío). - Conciliar y fusionar se bloquean en el mismo orden.
conciliar(vincular)bloquea la provisional y el destino (FOR UPDATE, en orden de id) antes de validar, y elUPDATErepite elEXISTSdel destino (confirmado, vivo, sin fusionar);fusionarbloquea sus dos fichas en el mismo orden antes de validar. Una fusión concurrente espera; no hay deadlock por orden único. Si elUPDATEno afecta filas se relee con el clearance: ya no visible es404, no409. - Medio de verificación (evidencia archivística).
vincularyconfirmarexigenmedio_verificacion∈ {presencial_documento,respuesta_canal_registrado,llamada_telefono_registrado,otro} (conotro,motivoobligatorio), guardado entercero.conciliacion_medio(CHECK en la 049) y en el asiento. Sin él el acto decía quién decidió, no en qué se basó. - El asiento solo lleva identificadores:
{accion, destino_id, documento_coincide, radicados, motivo, medio_verificacion}; ni el documento ni nombres (elaudit_loges inmutable; el dato personal vive, y se rectifica, en la ficha). - El canal alterno tiene datos.
GET /ventanilla/estadoexponecontacto(entidad_nombre|direccion|telefono|email|webdesgd_param_admin, el membrete de los oficios) yantirrobots_disponible(clave ≥ 32 bytes yPINGa Redis); el503 antirrobots_no_disponiblerepite el contacto en sudetail. Hueco declarado: no existe un parámetro de horario de atención; no se inventa y queda para una tanda de configuración de la entidad.scripts/verificar-ventanilla.shfalla siantirrobots_disponibleesfalse. ALTCHA_HMAC_KEYmínima es una constante del módulo (HMAC_MIN_BYTES), no un ajuste por entorno.- La migración
049se enmendó en su sitio (no estaba commiteada, todo es dato de prueba): es idempotente (ADD COLUMN IF NOT EXISTS,DROP CONSTRAINT IF EXISTS), así que en dev se borró su fila del libro (public.schema_migrations,tenant_demo) y se reaplicó con el runner.
Deuda declarada adicional¶
- Disposición final del radicado: el único
DELETE FROM radicadosdel servicio arrastraradicado_actorporON DELETE CASCADE, pero nadie purgatercero: la provisional atada solo a ese radicado sobrevive con su nombre, correo, teléfono, dirección y documento declarado (y, si seguíapendiente, reaparece en la cola conradicados: []). La disposición final NO la alcanza. Corrección propuesta (no implementada): al eliminar un radicado, borrar —con asiento— las provisionalespendiente/descartadaque quedaron sin vínculos, y anonimizar lasvinculada. - E19 (
resolver_por_email): corrección propuesta para la ingesta de correo, mismo criterio que C17: asociar por la dirección solo cuando el mensaje trae DKIM o SPF enpassalineado con el dominio delFrom; si no hay autenticación verificable, el remitente entra como ficha provisional y pasa por la misma cola de conciliación. Hoy asocia a la ficha existente con solo elFrom, que es trivialmente falsificable.
Alternativas descartadas¶
- Protocolo v2 (PBKDF2) / widget 3.x y librería Python
altcha: el primero cambia widget y verificador a la vez; la segunda añade una dependencia (lockfile, mantenimiento) para ~100 líneas dehashlib/hmac. El verificador queda aislado enapp/core/altcha.pypara sustituirlo. - Antirreplay en una tabla del tenant consumida dentro de la transacción del radicado: atómica y sin quemar retos en fallos, pero consumir después de subir anexos deja que un mismo reto dispare N subidas en paralelo antes del commit. Se queda Redis, que se consulta antes.
- Ficha provisional solo si el número ya existe: abre un oráculo de tiempo (rama distinta) y permite a un anónimo «reservar» la cédula de otro creando primero la ficha definitiva con datos falsos.
Residuos declarados¶
- Carga interna: toda petición identificada deja una provisional (1:1 con radicados identificados), no una por persona. La cola se ordena por antigüedad y trae la ficha
candidata; agrupar pordocumento_declarado_numeroy conciliar por lote queda como mejora de la pantalla. resolver_por_email(E19): la ingesta de correo asocia por dirección, mismo vector que C17 por otro canal. No entra en C-3 y queda declarado como deuda.- Protocolo v2 (PBKDF2) cuando se suba el widget a 3.x (roadmap).
- Una provisional con
documento_declarado_tiponulo no se puede confirmar (422 documento_sin_tipo) ni tienecandidata: se vincula o se descarta. El formulario no exige el tipo cuando hay número; endurecerlo es decisión de producto. - La plantilla de respuesta imprime
DOC_Rdesdetercero.numero_documento: de una provisional sale vacío (lo declarado no se imprime como si estuviera verificado). - Un reto vale ~5 minutos y un solo uso, pero un robot que resuelva retos sigue pudiendo radicar: ALTCHA sube el costo, no lo hace imposible; el límite de tasa y el honeypot siguen siendo parte de la defensa.
Consecuencias¶
- El ciudadano no resuelve nada visible (WCAG 2.2 SC 3.3.8): su navegador hace una comprobación de unos segundos.
- Producción exige
ALTCHA_HMAC_KEY; sin Redis la ventanilla pública responde 503 (la radicación presencial y por correo siguen). - Todo script que radique por la puerta pública resuelve el reto con
scripts/lib/altcha_solver.py(stdlib). Ningún script del repositorio lo hacía al momento de este ADR:seed_demo.pyyseed_corpus.pyusan la API autenticada;scripts/verificar-ventanilla.shcomprueba que el reto se emita.