Skip to content

Arquitetura de Hashing de Senhas — OLP

Visão geral

O sistema utiliza 5 camadas de proteção (Defense in Depth) para armazenamento de senhas:

  1. Salt único — 16 bytes via crypto.getRandomValues()
  2. Pepper — HMAC-SHA256 com secret OLP_PEPPER_SECRET (Supabase Secrets)
  3. Argon2id — KDF memory-hard via hash-wasm@4.11.0 (WASM puro)
  4. Breach Check (HIBP) — k-anonymity, fire-and-forget com notificação in-app
  5. Histórico de senhas — Últimas 5 senhas verificadas em série

Implementação

  • Helper: supabase/functions/_shared/password-helper.ts
  • EF set-password: Define senha (autenticado, primeiro acesso) — HIBP bloqueante
  • EF reset-password: Redefine senha (autenticado, pós-OTP) — HIBP fire-and-forget
  • EF change-password: Altera senha (autenticado, perfil) — HIBP fire-and-forget
  • EF verify-password: Login via código + senha (público)

Parâmetros de produção

ParâmetroValorJustificativa
memorySize131.072 KB (128 MB)Teto viável do isolate Deno (256 MB total)
iterations2t=3 a 128MB causa crash; t=2 = 378ms avg
parallelism1Deno isolate é single-thread
hashLength32 bytes256 bits de output
outputTypeencodedFormato padrão $argon2id$v=19$m=131072,t=2,p=1$...

Regras de senha

RegraValor
Comprimento mínimo10 caracteres
Maiúscula≥1
Minúscula≥1
Dígito≥1 (sem 3+ sequenciais asc/desc)
Caractere especial≥1

Validação duplicada: frontend (modal-definir-senha.tsx) + backend (password-helper.ts).

Breach Check (HIBP) — Camada 4

A senha é verificada contra vazamentos conhecidos via Have I Been Pwned usando k-anonymity:

  1. SHA-1 da senha → divide em prefix (5 chars) + suffix (restante)
  2. Envia apenas o prefix à API HIBP (GET /range/{prefix})
  3. API retorna todos os sufixos que combinam (formato SUFIXO:COUNT\r\n)
  4. Helper busca o suffix localmente na lista retornada
AspectoDetalhe
Helpersupabase/functions/_shared/hibp-helper.ts
Timeout3s via AbortSignal.timeout(3000)
Fail-openSe a API falhar (429, 503, timeout, network error), retorna { breached: false }
RS-1NUNCA logar senha ou SHA-1 completo — apenas err.message em console.warn
HeaderAdd-Padding: true (previne timing attacks na API HIBP)

Modos de operação por Edge Function

Edge FunctionModo HIBPJustificativa
set-passwordBloqueante — rejeita se breached (HTTP 400 SENHA_VAZADA)Primeiro acesso, budget de CPU amplo
change-passwordFire-and-forget — notifica in-app se breachedBudget CPU apertado (~1890ms bloqueantes)
reset-passwordFire-and-forget — notifica in-app se breachedMesmo budget apertado

Fire-and-forget: fluxo de notificação

Quando HIBP é fire-and-forget (change/reset), o check ocorre após o response 200:

Response 200 (senha já salva)
  ↓ (fire-and-forget, void promise)
checkPasswordBreach(nova_senha)

Se breached:
  ├─ INSERT notificacoes (tipo: "sistema", titulo: "Alerta de segurança")
  └─ registrarLog(acao: "auth.senha_vazada_detectada")

A notificação é inserida na tabela public.notificacoes (existente) com:

  • tipo: "sistema" (valor do enum notification_type)
  • dados.code: SENHA_VAZADA_POS_TROCA ou SENHA_VAZADA_POS_RESET
  • Exibida no painel de notificações in-app via useNotificacoes

Por que fire-and-forget é aceitável:

  1. HIBP já é fail-open — se a API cair, a senha passa (mesma semântica)
  2. A senha já passou por 4 verificações bloqueantes: strength, same-check, history, Argon2id
  3. Senhas no HIBP são de terceiros em listas públicas — risco menor que senha fraca
  4. A notificação garante que o usuário saiba e possa agir

Fluxo de hash (registro de senha)

set-password (HIBP bloqueante)

senha_plaintext

  ├─ validatePasswordStrength() — regras de complexidade

  ├─ checkPasswordBreach() — HIBP (bloqueante, rejeita se breached)

  ├─ salt = crypto.getRandomValues(new Uint8Array(16))

  ├─ peppered = HMAC-SHA256(senha_plaintext, OLP_PEPPER_SECRET)

  └─ hash = argon2id(peppered, salt, m=131072, t=2, p=1)

       └─ Armazenar: hash (encoded, contém salt embutido)

change-password / reset-password (HIBP fire-and-forget)

senha_plaintext

  ├─ validatePasswordStrength() — regras de complexidade

  ├─ verifyPassword(nova, atual) — SENHA_IGUAL_ATUAL

  ├─ checkPasswordHistory() — SENHA_REUTILIZADA (Camada 5)

  ├─ hashPassword(nova) → senhaHash

  ├─ UPDATE usuarios.senha_hash

  ├─ savePasswordToHistory(senhaHash)

  ├─ registrarLog() — fire-and-forget

  ├─ Response 200 ← (retorna aqui)

  └─ checkPasswordBreach() — fire-and-forget (void promise)
       └─ Se breached → INSERT notificacoes + registrarLog incidente

Fluxo de verify (login)

senha_tentativa

  ├─ peppered = HMAC-SHA256(senha_tentativa, OLP_PEPPER_SECRET)

  └─ argon2id.verify(peppered, stored_hash) → boolean

       ├─ true  → gerar JWT + Set-Cookie
       └─ false → 401 Unauthorized

Fluxo de login completo

PRIMEIRO ACESSO (sem senha):
  Código → OTP WhatsApp → Login → Modal bloqueante → Define senha → OK

ACESSO NORMAL (com senha):
  Código → Senha → verify-password → JWT + cookie

FALLBACK OTP:
  Código → "Entrar com WhatsApp" → OTP → Login

ESQUECEU SENHA:
  Código → "Esqueci minha senha" → sessionStorage flag → OTP WhatsApp → Login
  → Frontend detecta flag → Modal redefinir senha → reset-password → OK

TROCA DE SENHA (autenticado):
  Perfil → Aba Segurança → "Alterar senha" → Modal (senha atual + nova)
  → change-password → OK
  → HIBP fire-and-forget → notificação in-app se vazada

Edge Function reset-password

Fluxo autenticado (requer cookie olp_auth):

  1. Validar força da nova senha (server-side)
  2. Verificar que nova senha ≠ senha atual (verifyPassword)
  3. Verificar histórico de senhas (checkPasswordHistory)
  4. Hash com pepper + Argon2id
  5. Update usuarios.senha_hash + salvar no histórico
  6. Log auth.senha_redefinida (fire-and-forget)
  7. Response 200
  8. HIBP fire-and-forget → notificação in-app se breached

Edge Function change-password

Fluxo autenticado (requer cookie olp_auth):

  1. Verificar senha atual (verifyPassword)
  2. Check nova ≠ atual (comparação plaintext, zero-cost)
  3. Validar força da nova senha (server-side)
  4. Verificar histórico de senhas (checkPasswordHistory)
  5. Hash com pepper + Argon2id
  6. Update usuarios.senha_hash + salvar no histórico
  7. Log auth.senha_alterada (fire-and-forget)
  8. Response 200
  9. HIBP fire-and-forget → notificação in-app se breached

Budget de CPU (change-password / reset-password)

Operaçãoms (avg)Modo
verifyPassword (senha_atual)~378Bloqueante
checkPasswordHistory (3 max)~1134Bloqueante
hashPassword~378Bloqueante
Total bloqueante~1890
HIBP check~200Fire-and-forget (não conta)
Margem restante~110Dentro do limite de 2s

Histórico de Senhas (Camada 5)

Antes de aceitar uma nova senha, o sistema verifica se ela já foi utilizada nas últimas N = 5 senhas armazenadas na tabela senha_historico.

AspectoDetalhe
Tabelapublic.senha_historico (id, usuario_id, senha_hash, criado_em)
Helpersupabase/functions/_shared/password-history-helper.ts
Limite5 últimas senhas (constante HISTORY_LIMIT)
VerificaçãoverifyPassword() sequencial contra cada hash (~400ms × N)
Verificação práticaLimitado a 3 mais recentes para caber no budget de 2s
Posição no fluxoApós same-check, antes do hash Argon2id
CleanupInline no savePasswordToHistory() — DELETE dos mais antigos
RLSAtivado sem policies públicas — acesso via createSupabaseSystem()
Fail-openSe a query falhar, permite (não bloqueia o usuário)
Código de erroHTTP 400 com code: "SENHA_REUTILIZADA"

Fluxo

nova_senha
  ├─ validatePasswordStrength()
  ├─ verifyPassword(nova, atual) — SENHA_IGUAL_ATUAL
  ├─ checkPasswordHistory() — SENHA_REUTILIZADA  ← Camada 5

  ├─ hashPassword(nova) → senhaHash
  ├─ UPDATE usuarios.senha_hash
  ├─ savePasswordToHistory(senhaHash)  ← Salva + cleanup
  ├─ registrarLog()
  ├─ Response 200
  └─ checkPasswordBreach() — HIBP fire-and-forget
       └─ Se breached → notificação in-app + log incidente

Segurança

ControleImplementação
PepperHMAC-SHA256 antes do Argon2id, secret isolado
Salt16 bytes random (embutido no encoded output)
Brute forceLockout progressivo: 5→15min, 10→30min, 15→60min (via verify-password LOCKOUT_TIERS; difere do portal que usa 3→2min, 6→10min, 10→60min)
Anti-timing500-1000ms delay em todos os caminhos de erro
Alertasntfy push em lockout de brute force

Observabilidade

EventoAção logContexto
auth.senha_definidaSenha definida pela primeira vezset-password
auth.senha_redefinidaSenha redefinida (reset pós-OTP)reset-password
auth.senha_alteradaSenha alterada (troca autenticada)change-password
auth.senha_vazada_detectadaHIBP breach detectado pós-troca/resetfire-and-forget
login.success_senhaLogin por senha OKverify-password
login.falha_senhaSenha incorretaverify-password
auth.bloqueio_brute_force_senhaLockout ativadoverify-password

Referências