Arquitetura de Hashing de Senhas — OLP
Visão geral
O sistema utiliza 5 camadas de proteção (Defense in Depth) para armazenamento de senhas:
- Salt único — 16 bytes via
crypto.getRandomValues() - Pepper — HMAC-SHA256 com secret
OLP_PEPPER_SECRET(Supabase Secrets) - Argon2id — KDF memory-hard via
hash-wasm@4.11.0(WASM puro) - Breach Check (HIBP) — k-anonymity, fire-and-forget com notificação in-app
- 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âmetro | Valor | Justificativa |
|---|---|---|
memorySize | 131.072 KB (128 MB) | Teto viável do isolate Deno (256 MB total) |
iterations | 2 | t=3 a 128MB causa crash; t=2 = 378ms avg |
parallelism | 1 | Deno isolate é single-thread |
hashLength | 32 bytes | 256 bits de output |
outputType | encoded | Formato padrão $argon2id$v=19$m=131072,t=2,p=1$... |
Regras de senha
| Regra | Valor |
|---|---|
| Comprimento mínimo | 10 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:
- SHA-1 da senha → divide em prefix (5 chars) + suffix (restante)
- Envia apenas o prefix à API HIBP (
GET /range/{prefix}) - API retorna todos os sufixos que combinam (formato
SUFIXO:COUNT\r\n) - Helper busca o suffix localmente na lista retornada
| Aspecto | Detalhe |
|---|---|
| Helper | supabase/functions/_shared/hibp-helper.ts |
| Timeout | 3s via AbortSignal.timeout(3000) |
| Fail-open | Se a API falhar (429, 503, timeout, network error), retorna { breached: false } |
| RS-1 | NUNCA logar senha ou SHA-1 completo — apenas err.message em console.warn |
| Header | Add-Padding: true (previne timing attacks na API HIBP) |
Modos de operação por Edge Function
| Edge Function | Modo HIBP | Justificativa |
|---|---|---|
set-password | Bloqueante — rejeita se breached (HTTP 400 SENHA_VAZADA) | Primeiro acesso, budget de CPU amplo |
change-password | Fire-and-forget — notifica in-app se breached | Budget CPU apertado (~1890ms bloqueantes) |
reset-password | Fire-and-forget — notifica in-app se breached | Mesmo 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 enumnotification_type)dados.code:SENHA_VAZADA_POS_TROCAouSENHA_VAZADA_POS_RESET- Exibida no painel de notificações in-app via
useNotificacoes
Por que fire-and-forget é aceitável:
- HIBP já é fail-open — se a API cair, a senha passa (mesma semântica)
- A senha já passou por 4 verificações bloqueantes: strength, same-check, history, Argon2id
- Senhas no HIBP são de terceiros em listas públicas — risco menor que senha fraca
- 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 incidenteFluxo 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 UnauthorizedFluxo 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 vazadaEdge Function reset-password
Fluxo autenticado (requer cookie olp_auth):
- Validar força da nova senha (server-side)
- Verificar que nova senha ≠ senha atual (
verifyPassword) - Verificar histórico de senhas (
checkPasswordHistory) - Hash com pepper + Argon2id
- Update
usuarios.senha_hash+ salvar no histórico - Log
auth.senha_redefinida(fire-and-forget) - Response 200
- HIBP fire-and-forget → notificação in-app se breached
Edge Function change-password
Fluxo autenticado (requer cookie olp_auth):
- Verificar senha atual (
verifyPassword) - Check nova ≠ atual (comparação plaintext, zero-cost)
- Validar força da nova senha (server-side)
- Verificar histórico de senhas (
checkPasswordHistory) - Hash com pepper + Argon2id
- Update
usuarios.senha_hash+ salvar no histórico - Log
auth.senha_alterada(fire-and-forget) - Response 200
- HIBP fire-and-forget → notificação in-app se breached
Budget de CPU (change-password / reset-password)
| Operação | ms (avg) | Modo |
|---|---|---|
| verifyPassword (senha_atual) | ~378 | Bloqueante |
| checkPasswordHistory (3 max) | ~1134 | Bloqueante |
| hashPassword | ~378 | Bloqueante |
| Total bloqueante | ~1890 | — |
| HIBP check | ~200 | Fire-and-forget (não conta) |
| Margem restante | ~110 | Dentro 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.
| Aspecto | Detalhe |
|---|---|
| Tabela | public.senha_historico (id, usuario_id, senha_hash, criado_em) |
| Helper | supabase/functions/_shared/password-history-helper.ts |
| Limite | 5 últimas senhas (constante HISTORY_LIMIT) |
| Verificação | verifyPassword() sequencial contra cada hash (~400ms × N) |
| Verificação prática | Limitado a 3 mais recentes para caber no budget de 2s |
| Posição no fluxo | Após same-check, antes do hash Argon2id |
| Cleanup | Inline no savePasswordToHistory() — DELETE dos mais antigos |
| RLS | Ativado sem policies públicas — acesso via createSupabaseSystem() |
| Fail-open | Se a query falhar, permite (não bloqueia o usuário) |
| Código de erro | HTTP 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 incidenteSegurança
| Controle | Implementação |
|---|---|
| Pepper | HMAC-SHA256 antes do Argon2id, secret isolado |
| Salt | 16 bytes random (embutido no encoded output) |
| Brute force | Lockout progressivo: 5→15min, 10→30min, 15→60min (via verify-password LOCKOUT_TIERS; difere do portal que usa 3→2min, 6→10min, 10→60min) |
| Anti-timing | 500-1000ms delay em todos os caminhos de erro |
| Alertas | ntfy push em lockout de brute force |
Observabilidade
| Evento | Ação log | Contexto |
|---|---|---|
auth.senha_definida | Senha definida pela primeira vez | set-password |
auth.senha_redefinida | Senha redefinida (reset pós-OTP) | reset-password |
auth.senha_alterada | Senha alterada (troca autenticada) | change-password |
auth.senha_vazada_detectada | HIBP breach detectado pós-troca/reset | fire-and-forget |
login.success_senha | Login por senha OK | verify-password |
login.falha_senha | Senha incorreta | verify-password |
auth.bloqueio_brute_force_senha | Lockout ativado | verify-password |
Referências
- OWASP Password Storage Cheat Sheet
- RFC 9106 — Argon2
- hash-wasm
docs/security/BENCHMARK_ARGON2.md— resultados completosdocs/architecture/FUTURE_ARGON2ID_OFFLOAD_PLAN.md— plano futuro de offload + cache HIBP Redis