Saltar al contenido principal

Seguridad

Esta página describe mecanismos que existen en código corriendo. Donde un control falta o es más débil de lo que suena, está dicho en vez de omitido.

Sin auditoría externa

Veridia no fue sometida a pentesting, ni auditada, ni certificada por nadie. Nada de esta página es una atestación de un tercero. Es una descripción de la implementación, ofrecida para que puedas evaluarla vos mismo.

Claves de API

Dos familias, distinguidas por prefijo, con capacidades genuinamente distintas.

PrefijoDónde vaPuede hacer
qv_pub_ / qv_pubt_Navegador, app móvil, código fuente de la páginaPOST /v1/verify/init, POST /v1/verify/submit
qv_sec_ / qv_sect_Solo servidorLo anterior, más leer veredictos

Las variantes con t son las claves de modo de prueba. Fijate en la forma: es qv_pubt_, no qv_pub_test_.

Leer un veredicto requiere una clave secreta. GET /v1/verify/{id} rechaza una clave publicable con 401 secret_key_required antes de hacer cualquier otra cosa. La razón es simple: una clave publicable es visible para cualquiera que abra tu página, así que si pudiera leer veredictos, cualquiera podría leer el resultado KYC de cualquier verificación que pudiera nombrar.

Cómo se guardan las claves

El valor crudo de la clave se muestra una sola vez, al crearla. Lo que la base de datos guarda es un hash SHA-256 de ese valor, más una copia cifrada con AES-256-GCM. La copia cifrada existe por una razón específica: al revocar, hay que recuperar el valor crudo para poder borrar la entrada correspondiente del caché del edge, de modo que la revocación efectivamente se propague en lugar de quedar solo registrada.

La revocación es inmediata — y no hay rotación

No hay ventana de solapamiento

Revocar una clave borra su entrada del edge de inmediato. Todo request en vuelo que la use empieza a fallar en ese mismo instante. No hay flujo de rotación, no hay período de gracia, no hay "la clave vieja sigue funcionando N minutos".

La secuencia segura es: crear la clave nueva, desplegarla en todos lados, verificar que el tráfico esté fluyendo por ella, y recién entonces revocar la vieja. Revocar primero te va a tirar abajo la integración.

allowedOrigins no hace lo que su nombre sugiere

Cada clave lleva una lista allowedOrigins. Leé esto antes de tratarla como un control de acceso.

  • Una lista vacía permite todos los orígenes. Las claves se crean con la lista vacía, así que por defecto el control no corre en absoluto.
  • Solo aplica a navegadores. El control se evalúa únicamente para claves publicables en requests que traen un header Origin. Un cliente server-side no manda Origin y pasa incondicionalmente.
  • Matchea el hostname pelado. La entrada es app.example.com, no https://app.example.com ni https://app.example.com:443. Los comodines tipo *.example.com están soportados, y por sí solos no matchean el ápex example.com.

Para qué sirve esta funcionalidad: para acotar en qué páginas corre tu widget. Para qué no: no es un control que impida que tu clave publicable se use en otro lado. Tratá a la clave publicable como pública, porque lo es.

Aislamiento entre tenants

Todo request autenticado resuelve a exactamente un tenant, tomado de la clave de API. No hay parámetro de tenant en ningún cuerpo de request — mandar uno no tiene efecto.

Leer una verificación compara el tenant dueño contra el del llamador, y responde 404 verification_not_found en vez de 403 cuando difieren. Un 403 confirmaría que el identificador existe y pertenece a otro; un 404 no revela nada.

Autenticación de las subidas

Las imágenes capturadas no van a un almacenamiento de objetos con URLs prefirmadas. Se suben a un endpoint dedicado de la propia API, y por eso ese endpoint autentica distinto a todos los demás:

  • La clave de API como bearer no se acepta ahí. La autenticación es un X-Veridia-Upload-Token de vida corta, acotado a una verificación y válido por 15 minutos.
  • Ese token viene dentro del objeto headers de cada slot de subida devuelto por /v1/verify/init. Reenviá esos headers tal cual; no los reconstruyas a mano.
  • El cuerpo tiene que ser un JPEG real — se chequean los bytes mágicos FF D8 FF — y de como máximo 8 MB.

Rutear los bytes por la API en lugar de mandarlos directo al almacenamiento es lo que hace posible vincular la imagen subida con la verificación que la pidió.

Rate limiting

Dos capas independientes.

CapaLímiteSe aplica
Por IP100 requests / 60 sAntes de la autenticación, en toda ruta
Por tenant, /v1/verify/init60 / minutoDespués de la autenticación
Por tenant, /v1/verify/submit30 / minutoDespués de la autenticación
Por tenant, GET /v1/verify/{id}600 / minutoDespués de la autenticación
El límite por IP es el que muerde primero

Corre antes de la autenticación, así que un 429 puede llegar antes de que exista ningún tenant. Y como cada imagen es una subida separada — una verificación con reto de prueba de vida (liveness) activo hace unas 20 —, varios usuarios móviles detrás del mismo NAT carrier-grade pueden agotar 100 requests por minuto mientras tus números por tenant siguen intactos. Si estás debuggeando un 429 que no tiene sentido contra la tabla por tenant, esta suele ser la razón.

Firmas de webhook

Cada entrega lleva:

Veridia-Signature: t=<unix_seconds>,v1=<hmac_sha256_hex>
Veridia-Event: verification.approved
Veridia-Event-Id: evt_<hex>

El MAC es HMAC-SHA256(secret, "<t>." + raw_body_bytes). Dos consecuencias que vale internalizar:

Verificá sobre los bytes crudos. Parsear el JSON y volver a serializarlo produce bytes distintos y por lo tanto un digest distinto, aunque cada clave y cada valor sean idénticos. Capturá el cuerpo antes de que tu framework lo toque.

El timestamp vive dentro del header de la firma. No hay un header de timestamp separado, ni un X-Veridia-Signature — el header no tiene prefijo X-.

Por qué una tolerancia de 5 minutos alcanza

Los reintentos se estiran a lo largo de unos 12,6 minutos, lo que naturalmente lleva a preguntarse si la ventana de replay tiene que ampliarse para acompañar. No hace falta.

El despachador calcula una firma nueva en cada intento. El sexto intento lleva un t estampado instantes antes de ser enviado, no uno de doce minutos atrás. Una tolerancia de 300 segundos acepta todo reintento legítimo.

No la amplíes. Cada minuto extra es tiempo extra en el que una entrega capturada puede ser reenviada contra vos, comprado a cambio de ningún beneficio.

Comparar firmas

Compará en tiempo constante — crypto.timingSafeEqual, hmac.compare_digest, hash_equals.

Una trampa: crypto.timingSafeEqual en Node lanza excepción cuando los dos buffers difieren en longitud. Un atacante que mande v1=ab convierte tu handler en una excepción no capturada y un 500. Chequeá las longitudes primero y rechazá si no coinciden, y después compará.

La protección contra SSRF en las URLs de webhook

Una URL de webhook es dato provisto por un atacante: cualquier tenant tipea una en el panel, y el backend después hace un request saliente hacia ella. Sin una protección, eso convierte a la plataforma en una sonda hacia infraestructura que el tenant no podría alcanzar de otro modo — el cuerpo de la respuesta nunca vuelve a él, pero el código de estado y los tiempos alcanzan para enumerar qué existe.

Cada URL se chequea al guardarla y otra vez inmediatamente antes de cada envío. El segundo chequeo es el que importa: un hostname que resuelve públicamente al guardarse puede resolver a 127.0.0.1 un minuto después.

El chequeo rechaza:

Cualquier esquema que no sea httpsLos payloads llevan PII de identidad; el transporte en texto plano se rechaza de plano
Cualquier puerto que no sea 443 u 8443Otros puertos son, mucho más seguido, un servicio interno que un endpoint real
Credenciales user:pass@hostUna forma clásica de hacer que una URL se lea como un host y resuelva a otro
Loopback (127.0.0.0/8, ::1)
Link-local (169.254.0.0/16)Donde viven los endpoints de metadata de instancia en la nube — el objetivo de mayor valor en el host
Rangos privados (RFC 1918 y equivalentes IPv6)
NAT carrier-grade (100.64.0.0/10)No es "privado" según la definición de la librería estándar, pero nunca es un endpoint público legítimo
Unspecified, multicast, reservados
IPv6 con IPv4 mapeada (::ffff:127.0.0.1)Se desenvuelve y se vuelve a chequear, o se colaría por delante de todo lo anterior

Si un hostname resuelve a cualquier dirección prohibida, se rechaza el nombre entero incluso cuando las otras respuestas se vean bien — un nombre que resuelve a la vez a una dirección pública y a una privada tiene la forma de un ataque de rebinding, así que aceptarlo parcialmente no es seguro.

Las entregas no siguen redirects, y expiran a los 10 segundos en total, con un timeout de conexión de 5 segundos.

Consecuencia práctica para el desarrollo local

http://localhost:3000 no puede recibir webhooks. No es que "se desaconseje" — se rechaza, por esquema y por dirección. Usá un túnel que termine TLS en un hostname público.

Transporte

La API se sirve sobre TLS. La entrega de webhooks es solo TLS y el texto plano lo rechaza la protección de arriba.

Controles que no existen

Dicho en claro, porque un hueco declarado es honesto y uno escondido es lo que hunde una auditoría.

  • Sin cifrado a nivel de aplicación de las columnas de identidad. extracted_name, extracted_document_number y extracted_date_of_birth se guardan como columnas planas en MySQL. Cualquier cifrado que exista por debajo es una propiedad del disco y del proveedor de infraestructura, no un control que Veridia implemente o pueda atestiguar. No lo describas como "cifrado en reposo" en tu propia documentación apoyándote en nosotros.
  • Sin rotación de claves con ventana de solapamiento. Ver más arriba — la revocación es inmediata.
  • Sin captura de consentimiento. El widget no presenta, ni registra, ni estampa timestamp de ningún consentimiento. Ver GDPR.
  • Sin borrado ni exportación self-service. Ver Retención de datos.
  • Sin lista publicada de IPs de salida. Si tu firewall necesita allowlistear las fuentes entrantes de webhooks, hoy no hay lista para darte.
  • webhook_log.attempts es aproximado. La tabla de historial por intento registra un contador de intentos que puede ser inexacto bajo entrega concurrente. Usá el outbox de entrega que se muestra en el panel, no esa columna, como el registro de qué se entregó.

Reportar una vulnerabilidad

Reportá en privado en lugar de hacerlo en un issue público. Ver Soporte.