Logosulfite.app
rafagazani/sulfite 999999

Exportação Verificada

Exportação de relatórios com verificação de identidade via OTP

Exportação Verificada#

O sistema de exportação verificada permite baixar relatórios — incluindo pacotes .sulfite assinados — exigindo que o solicitante confirme sua identidade via código OTP enviado por email.

┌──────────┐  POST /export/verify   ┌──────────────┐   email   ┌──────────┐
│  Cliente  │──────────────────────▶│  Server       │──────────▶│  Email   │
│           │◀── { status: sent }   │  (gera OTP)   │           │  (OTP)   │
│           │                       └──────────────┘           └──────────┘
│           │  POST /export/confirm
│           │  { email, reportId, code, format }
│           │──────────────────────▶┌──────────────┐
│           │◀── .sulfite assinado  │  Server       │
└──────────┘                        │  (verifica +  │
                                    │   assina +    │
                                    │   gera output)│
                                    └──────────────┘

Princípios:

  • O OTP é gerado com Random.secure()15 caracteres alfanuméricos por default
  • O código é armazenado como hash SHA-256 — nunca em plaintext
  • A comparação é timing-safe
  • Após verificação, o relatório é assinado com role: "export" e a identidade do usuário é registrada (VerifiedIdentity)
  • O OTP é consumido na verificação — não pode ser reutilizado

Endpoints#

Política de exportação — GET /api/v1/export/policy#

Autenticação: canGenerate (opcional — retorna dados genéricos em modo dev)

Retorna a configuração de export policy do servidor. O cliente usa isso para decidir se deve exibir o fluxo OTP.

Response 200:

{
  "protectedFormats": ["sulfite", "pdf"],
  "requireVerificationForAll": false,
  "otpExpirationSeconds": 300,
  "identityHints": ["@acme.com", "user@example.com"]
}
CampoDescrição
protectedFormatsFormatos que exigem verificação OTP
requireVerificationForAll Se true, todos os formatos precisam de OTP
otpExpirationSecondsTempo de vida do código em segundos
identityHintsDomínios/emails permitidos (para UI de dicas)

Solicitar OTP — POST /api/v1/export/verify#

Autenticação: canGenerate (opcional em modo dev)

Gera e envia um código OTP para o email informado.

Body:

{
  "email": "user@acme.com",
  "reportId": "uuid-do-relatorio"
}

Response 200:

{
  "status": "sent",
  "expiresIn": 300
}

Erros:

StatusErroQuando
400invalid_emailEmail inválido
400 validation_error reportId ausente
403 identity_not_allowed Email/domínio não permitido por EXPORT_ALLOWED_IDENTITIES
404not_foundRelatório não encontrado
429 rate_limited Código já enviado há menos de 60s, ou limite por hora atingido (5/hora por email)
503 otp_store_full Capacidade do OTP store atingida
503otp_delivery_failedFalha no envio do email
503 not_configured OTP service não configurado (sem email provider)

Confirmar OTP e baixar — POST /api/v1/export/confirm#

Autenticação: canGenerate (opcional em modo dev)

Verifica o código OTP e retorna o relatório exportado, assinado.

Body:

{
  "email": "user@acme.com",
  "reportId": "uuid-do-relatorio",
  "code": "ABC123XYZ456DEF",
  "format": "sulfite"
}
CampoObrigatórioDefaultDescrição
emailSimEmail que recebeu o OTP
reportIdSimID do relatório
codeSimCódigo OTP recebido
format Não sulfite sulfite, pdf, html, csv, excel

Response 200: Bytes do relatório exportado.

Headers de resposta:

Content-Type: application/octet-stream  (sulfite) / application/pdf  (pdf) / ...
Content-Disposition: attachment; filename="nome-do-relatorio.sulfite"
X-Sulfite-Signed: true
X-Sulfite-Signed-By: user@acme.com
X-Sulfite-Signed-At: 2026-04-09T10:00:00.000Z

Erros:

StatusErroQuando
400invalid_emailEmail inválido
400 validation_error reportId ou code ausente
400invalid_codeCódigo incorreto
400 otp_not_found Nenhum código pendente para este email/relatório
403 identity_not_allowed Email/domínio não permitido
404not_foundRelatório não encontrado
410expiredCódigo expirado
422invalid_reportRelatório sem definição válida
422 package_rebuild_failed Falha ao preservar conteúdo do pacote .sulfite ao assinar
423 max_attempts Tentativas esgotadas — solicitar novo código
503 not_configured OTP service ou signer não configurados

Configuração#

Variáveis de ambiente#

VariávelDefaultDescrição
EXPORT_ALLOWED_IDENTITIES — (todos) Emails ou domínios permitidos, separados por vírgula. Ex: user@example.com,acme.com
EXPORT_PROTECTED_FORMATS sulfite,pdf Formatos que exigem OTP
EXPORT_REQUIRE_ALL false Se true, todos os formatos exigem OTP
EXPORT_OTP_EXPIRATION 300 Expiração do OTP em segundos
EXPORT_OTP_MAX_ATTEMPTS 3 Tentativas antes de invalidar
EXPORT_OTP_LENGTH15Comprimento do código

Também é necessário configurar um email provider (Resend ou MailerSend). Veja Configuração → Email.

Requisitos#

  • ENCRYPTION_KEY configurado (necessário para assinar o relatório após verificação)
  • Um email provider configurado (RESEND_API_TOKEN ou MAILERSEND_API_TOKEN)

Exemplo de configuração#

export ENCRYPTION_KEY="base64-encoded-32-byte-key"
export EXPORT_ALLOWED_IDENTITIES="acme.com,partner@example.com"
export EXPORT_PROTECTED_FORMATS="sulfite,pdf"
export EXPORT_OTP_EXPIRATION=300
export RESEND_API_TOKEN="re_..."
export RESEND_FROM_EMAIL="noreply@acme.com"

Fluxo completo#

1. Cliente detecta a política#

GET /api/v1/export/policy
# → { "protectedFormats": ["sulfite", "pdf"], ... }

Se o formato desejado está em protectedFormats, exibir o fluxo OTP.

2. Solicitar OTP#

curl -X POST https://server.com/api/v1/export/verify \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "email": "user@acme.com", "reportId": "abc123" }'

# Response: { "status": "sent", "expiresIn": 300 }
# → Usuário recebe email com o código

3. Confirmar e baixar#

curl -X POST https://server.com/api/v1/export/confirm \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "user@acme.com",
    "reportId": "abc123",
    "code": "ABC123XYZ456DEF",
    "format": "sulfite"
  }' \
  -o relatorio.sulfite

O arquivo baixado é um pacote .sulfite assinado digitalmente com a identidade verificada do usuário.


Assinatura resultante#

Após exportação verificada, o relatório carrega uma assinatura com role: "export":

{
  "_signatures": {
    "export": {
      "alg": "hmac-sha256",
      "key_id": "current",
      "value": "base64url...",
      "signed_at": "2026-04-09T10:00:00.000Z",
      "origin": "verified-export",
      "identity": {
        "email": "user@acme.com",
        "verified_at": "2026-04-09T10:00:00.000Z",
        "domain": "acme.com"
      }
    }
  }
}

Essa assinatura pode ser verificada via GET /api/v1/reports/:id/signatures. Veja Assinatura.


Segurança#

ProteçãoDetalhe
OTP hashingSHA-256 — código nunca armazenado em plaintext
ComparaçãoTiming-safe (evita timing attacks)
Rate limit por IPHerdado do rate limiter global
Rate limit por email5 OTPs/hora por email
Rate limit de reenvio60s entre pedidos para o mesmo relatório
Tentativas máximas3 (configurável) — após esgotar, OTP invalidado
Identidades permitidas EXPORT_ALLOWED_IDENTITIES — bloqueia emails/domínios não autorizados
Auditoria Todos os eventos logados (ver Segurança → Auditoria)