Pular para o conteúdo principal

Segurança

Esta página descreve mecanismos que existem em código rodando. Onde um controle está ausente ou é mais fraco do que parece, isso é declarado em vez de omitido.

Sem auditoria externa

A Veridia não passou por teste de intrusão, auditoria nem certificação por ninguém. Nada nesta página é uma atestação de terceiros. É uma descrição da implementação, oferecida para que você mesmo possa avaliá-la.

Chaves de API

Duas famílias, distinguidas pelo prefixo, com capacidades genuinamente diferentes.

PrefixoOnde ela pertencePode fazer
qv_pub_ / qv_pubt_Navegador, app mobile, código-fonte da páginaPOST /v1/verify/init, POST /v1/verify/submit
qv_sec_ / qv_sect_Apenas no servidorO acima, mais ler vereditos

As variantes com t são as chaves de modo de teste. Repare no formato: é qv_pubt_, não qv_pub_test_.

Ler um veredito exige uma chave secreta. GET /v1/verify/{id} rejeita uma chave publicável com 401 secret_key_required antes de fazer qualquer outra coisa. A razão é simples: uma chave publicável fica visível para qualquer um que abra a sua página, então, se ela pudesse ler vereditos, qualquer pessoa poderia ler o resultado de KYC de qualquer verificação cujo identificador conseguisse nomear.

Como as chaves são armazenadas

O valor bruto da chave é exibido uma única vez, na criação. O que o banco de dados guarda é um hash SHA-256 dele, mais uma cópia criptografada em AES-256-GCM. A cópia criptografada existe por um motivo específico: na revogação, o valor bruto precisa ser recuperado para apagar a entrada correspondente do cache de edge, de modo que a revogação de fato se propague em vez de apenas ficar registrada.

A revogação é imediata — e não existe rotação

Não existe janela de sobreposição

Revogar uma chave apaga sua entrada no edge imediatamente. Toda requisição em andamento que a utilize começa a falhar no mesmo instante. Não existe fluxo de rotação, nem período de carência, nem "a chave antiga continua funcionando por N minutos".

A sequência segura é: criar a nova chave, implantá-la em todos os lugares, verificar que o tráfego está fluindo por ela e só então revogar a antiga. Revogar primeiro vai derrubar a sua integração.

allowedOrigins não faz o que o nome sugere

Cada chave carrega uma lista allowedOrigins. Leia isto antes de tratá-la como um controle de acesso.

  • Uma lista vazia permite todas as origens. As chaves são criadas com uma lista vazia, então, por padrão, a checagem não roda de jeito nenhum.
  • Ela só se aplica a navegadores. A checagem só é avaliada para chaves publicáveis em requisições que carregam um header Origin. Um cliente do lado do servidor não envia Origin e passa incondicionalmente.
  • Ela compara o hostname puro. A entrada é app.example.com, não https://app.example.com e não https://app.example.com:443. Curingas como *.example.com são suportados, e não correspondem ao ápice example.com por si só.

Para o que este recurso serve: restringir em quais páginas o seu widget roda. O que ele não é: um controle que impeça a sua chave publicável de ser usada em outro lugar. Trate a chave publicável como pública, porque ela é.

Isolamento entre tenants

Toda requisição autenticada resolve para exatamente um tenant, obtido a partir da chave de API. Não existe parâmetro de tenant em nenhum corpo de requisição — enviar um não tem efeito nenhum.

Ler uma verificação compara o tenant dono dela com o do chamador, e responde 404 verification_not_found em vez de 403 quando eles diferem. Um 403 confirmaria que o identificador existe e pertence a outra pessoa; um 404 não revela nada.

Autenticação de upload

As imagens capturadas não vão para armazenamento de objetos com URL pré-assinada. Elas são enviadas para um endpoint dedicado na própria API, e é por isso que esse endpoint autentica de forma diferente de todos os outros:

  • A chave de API como bearer não é aceita ali. A autenticação é um X-Veridia-Upload-Token de vida curta, com escopo de uma única verificação e válido por 15 minutos.
  • Esse token é retornado dentro do objeto headers de cada slot de upload em /v1/verify/init. Encaminhe esses headers literalmente; não os reconstrua à mão.
  • O corpo precisa ser um JPEG real — os magic bytes FF D8 FF são verificados — e ter no máximo 8 MB.

Rotear os bytes através da API em vez de direto para o armazenamento é o que torna possível vincular a imagem enviada à verificação que a solicitou.

Rate limiting

Duas camadas independentes.

CamadaLimiteAplicada
Por IP100 requisições / 60 sAntes da autenticação, em todas as rotas
Por tenant, /v1/verify/init60 / minutoDepois da autenticação
Por tenant, /v1/verify/submit30 / minutoDepois da autenticação
Por tenant, GET /v1/verify/{id}600 / minutoDepois da autenticação
O limite por IP é o que morde primeiro

Ele roda antes da autenticação, então um 429 pode chegar antes de existir qualquer tenant. E como cada imagem é um upload separado — uma verificação com desafio ativo de prova de vida faz cerca de 20 deles —, vários usuários mobile atrás do mesmo NAT de operadora podem esgotar 100 requisições por minuto enquanto os seus números por tenant ainda parecem intocados. Se você está depurando um 429 que não faz sentido diante da tabela por tenant, normalmente é por isso.

Assinaturas de webhook

Toda entrega carrega:

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

O MAC é HMAC-SHA256(secret, "<t>." + raw_body_bytes). Duas consequências que vale internalizar:

Verifique sobre os bytes brutos. Fazer o parse do JSON e reserializá-lo produz bytes diferentes e, portanto, um digest diferente, mesmo que cada chave e cada valor sejam idênticos. Capture o corpo antes que o seu framework o toque.

O timestamp vive dentro do header de assinatura. Não existe header separado de timestamp, e não existe X-Veridia-Signature — o header não tem prefixo X-.

Por que uma tolerância de 5 minutos é suficiente

Os reintentos se estendem por aproximadamente 12,6 minutos, o que naturalmente levanta a questão de se a janela de replay precisa ser alargada para acompanhar. Ela não precisa.

O despachante calcula uma assinatura nova a cada tentativa. A sexta tentativa carrega um t carimbado instantes antes de ser enviada, não um de doze minutos atrás. Uma tolerância de 300 segundos aceita todo reintento legítimo.

Não a alargue. Cada minuto extra é tempo extra durante o qual uma entrega capturada pode ser reproduzida contra você, comprado sem nenhum benefício.

Comparando assinaturas

Compare em tempo constante — crypto.timingSafeEqual, hmac.compare_digest, hash_equals.

Uma armadilha: crypto.timingSafeEqual no Node lança exceção quando os dois buffers têm comprimentos diferentes. Um atacante que envie v1=ab transforma o seu handler em uma exceção não capturada e um 500. Cheque os comprimentos primeiro e rejeite se não baterem, e só então compare.

A proteção contra SSRF nas URLs de webhook

Uma URL de webhook é um dado fornecido pelo atacante: qualquer tenant digita uma no painel, e o backend então faz uma requisição de saída para ela. Sem uma proteção, isso transforma a plataforma em uma sonda para infraestrutura que o tenant não conseguiria alcançar de outro jeito — o corpo da resposta nunca volta para ele, mas o código de status e o tempo já bastam para enumerar o que existe.

Toda URL é verificada ao ser salva e novamente imediatamente antes de cada envio. A segunda verificação é a que importa: um hostname que resolve publicamente ao ser salvo pode resolver para 127.0.0.1 um minuto depois.

A verificação rejeita:

Qualquer esquema que não seja httpsOs payloads carregam PII de identidade; transporte em texto claro é recusado de imediato
Qualquer porta que não seja 443 ou 8443Outras portas são muito mais frequentemente um serviço interno do que um endpoint real
Credenciais user:pass@hostUma forma clássica de fazer uma URL parecer um host e resolver para outro
Loopback (127.0.0.0/8, ::1)
Link-local (169.254.0.0/16)Onde vivem os endpoints de metadados de instância na nuvem — o alvo de maior valor no host
Faixas privadas (RFC 1918 e equivalentes IPv6)
NAT de operadora (100.64.0.0/10)Não é "privada" pela definição da biblioteca padrão, mas nunca é um endpoint público legítimo
Não especificado, multicast, reservado
IPv6 mapeado de IPv4 (::ffff:127.0.0.1)Desembrulhado e reverificado, ou passaria por baixo de tudo acima

Se um hostname resolve para qualquer endereço proibido, o nome inteiro é rejeitado mesmo quando as outras respostas parecem boas — um nome que resolve tanto para um endereço público quanto para um privado tem o formato de um ataque de rebinding, então aceitação parcial não é segura.

As entregas não seguem redirecionamentos, e expiram em 10 segundos no total, com 5 segundos de timeout de conexão.

Consequência prática para desenvolvimento local

http://localhost:3000 não consegue receber webhooks. Não é "desaconselhado" — é recusado, pelo esquema e pelo endereço. Use um túnel que termine TLS em um hostname público.

Transporte

A API é servida sobre TLS. A entrega de webhooks é exclusivamente por TLS, e texto claro é rejeitado pela proteção acima.

Controles que não existem

Declarados abertamente, porque uma lacuna declarada é honesta e uma lacuna escondida é o que afunda uma auditoria.

  • Sem criptografia em nível de aplicação nas colunas de identidade. extracted_name, extracted_document_number e extracted_date_of_birth são armazenados como colunas simples no MySQL. Qualquer criptografia que exista abaixo disso é uma propriedade do disco e do provedor de infraestrutura, não um controle que a Veridia implemente ou possa atestar. Não descreva isso como "criptografado em repouso" na sua própria documentação por nossa conta.
  • Sem rotação de chaves com janela de sobreposição. Veja acima — a revogação é imediata.
  • Sem captura de consentimento. O widget não apresenta, não registra nem carimba o horário de nenhum consentimento. Veja GDPR.
  • Sem exclusão ou exportação self-service. Veja Retenção de dados.
  • Sem lista publicada de IPs de saída. Se o seu firewall precisa colocar em allowlist as origens de webhooks de entrada, não existe lista para te dar hoje.
  • webhook_log.attempts é aproximado. A tabela de histórico por tentativa registra uma contagem de tentativas que pode ser imprecisa sob entrega concorrente. Use a caixa de saída de entregas exibida no painel, e não essa coluna, como o registro do que foi entregue.

Reportando uma vulnerabilidade

Reporte em privado, e não em uma issue pública. Veja Suporte.