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.
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.
| Prefijo | Dónde va | Puede hacer |
|---|---|---|
qv_pub_ / qv_pubt_ | Navegador, app móvil, código fuente de la página | POST /v1/verify/init, POST /v1/verify/submit |
qv_sec_ / qv_sect_ | Solo servidor | Lo 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
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 mandaOriginy pasa incondicionalmente. - Matchea el hostname pelado. La entrada es
app.example.com, nohttps://app.example.comnihttps://app.example.com:443. Los comodines tipo*.example.comestán soportados, y por sí solos no matchean el ápexexample.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-Tokende vida corta, acotado a una verificación y válido por 15 minutos. - Ese token viene dentro del objeto
headersde 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.
| Capa | Límite | Se aplica |
|---|---|---|
| Por IP | 100 requests / 60 s | Antes de la autenticación, en toda ruta |
Por tenant, /v1/verify/init | 60 / minuto | Después de la autenticación |
Por tenant, /v1/verify/submit | 30 / minuto | Después de la autenticación |
Por tenant, GET /v1/verify/{id} | 600 / minuto | Después de la autenticación |
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 https | Los payloads llevan PII de identidad; el transporte en texto plano se rechaza de plano |
| Cualquier puerto que no sea 443 u 8443 | Otros puertos son, mucho más seguido, un servicio interno que un endpoint real |
Credenciales user:pass@host | Una 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_numberyextracted_date_of_birthse 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.attemptses 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.