Autenticação em múltiplos fatores
TOTP, chaves de acesso e SMS, e o que cada fator protege de fato.
O TOTP desta página foi executado nas cinco linguagens e conferido com os vetores de teste do RFC 6238 e com a biblioteca pyotp. O fluxo de chave de acesso foi testado num Chrome real, com um autenticador virtual.
TOTP, em cada linguagem
Cada versão tem duas funções: totp, que calcula o código de um passo, e verificar, que aceita a janela de um passo para cada lado e recusa códigos de passos já usados. Só a biblioteca padrão.
import crypto from "node:crypto";
const PASSO_S = 30; // cada código vale 30 segundos
const JANELA = 1; // aceita um passo antes e um depois, para relógios fora de hora
export function totp(segredo: Buffer, passo: number, digitos = 6): string {
const contador = Buffer.alloc(8);
contador.writeBigUInt64BE(BigInt(passo));
const mac = crypto.createHmac("sha1", segredo).update(contador).digest();
const deslocamento = mac[mac.length - 1] & 0x0f;
const numero = mac.readUInt32BE(deslocamento) & 0x7fffffff;
return String(numero % 10 ** digitos).padStart(digitos, "0");
}
/** Devolve o passo aceito, ou null. Guarde o passo para recusar o mesmo código de novo. */
export function verificar(segredo: Buffer, codigo: string, ultimoPasso: number,
agora = Date.now() / 1000): number | null {
const atual = Math.floor(agora / PASSO_S);
for (let passo = atual - JANELA; passo <= atual + JANELA; passo++) {
const esperado = Buffer.from(totp(segredo, passo));
const recebido = Buffer.from(codigo);
if (passo > ultimoPasso && recebido.length === esperado.length
&& crypto.timingSafeEqual(recebido, esperado)) return passo;
}
return null;
}import hashlib
import hmac
import struct
import time
PASSO_S = 30 # cada código vale 30 segundos
JANELA = 1 # aceita um passo antes e um depois, para relógios fora de hora
def totp(segredo: bytes, passo: int, digitos: int = 6) -> str:
mac = hmac.new(segredo, struct.pack(">Q", passo), hashlib.sha1).digest()
deslocamento = mac[-1] & 0x0F
numero = struct.unpack(">I", mac[deslocamento:deslocamento + 4])[0] & 0x7FFFFFFF
return str(numero % 10**digitos).zfill(digitos)
def verificar(
segredo: bytes, codigo: str, ultimo_passo: int, agora: float | None = None
) -> int | None:
"""Devolve o passo aceito, ou None. Guarde o passo para recusar o mesmo código de novo."""
atual = int((time.time() if agora is None else agora) // PASSO_S)
for passo in range(atual - JANELA, atual + JANELA + 1):
if passo > ultimo_passo and hmac.compare_digest(totp(segredo, passo), codigo):
return passo
return Nonepackage main
import (
"crypto/hmac"
"crypto/sha1"
"crypto/subtle"
"encoding/binary"
"fmt"
"time"
)
const (
passoS = 30 // cada código vale 30 segundos
janela = 1 // aceita um passo antes e um depois, para relógios fora de hora
)
func totp(segredo []byte, passo int64, digitos int) string {
var contador [8]byte
binary.BigEndian.PutUint64(contador[:], uint64(passo))
mac := hmac.New(sha1.New, segredo)
mac.Write(contador[:])
soma := mac.Sum(nil)
deslocamento := soma[len(soma)-1] & 0x0f
numero := binary.BigEndian.Uint32(soma[deslocamento:]) & 0x7fffffff
modulo := uint32(1)
for range digitos {
modulo *= 10
}
return fmt.Sprintf("%0*d", digitos, numero%modulo)
}
// verificar devolve o passo aceito e true. Guarde o passo para recusar o mesmo código de novo.
func verificar(segredo []byte, codigo string, ultimoPasso int64, agora time.Time) (int64, bool) {
atual := agora.Unix() / passoS
for passo := atual - janela; passo <= atual+janela; passo++ {
esperado := totp(segredo, passo, 6)
if passo > ultimoPasso && subtle.ConstantTimeCompare([]byte(esperado), []byte(codigo)) == 1 {
return passo, true
}
}
return 0, false
}import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.List;
import java.util.OptionalLong;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class Totp {
static final long PASSO_S = 30; // cada código vale 30 segundos
static final int JANELA = 1; // aceita um passo antes e um depois, para relógios fora de hora
static String totp(byte[] segredo, long passo, int digitos) throws Exception {
var mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(segredo, "HmacSHA1"));
byte[] soma = mac.doFinal(ByteBuffer.allocate(8).putLong(passo).array());
int deslocamento = soma[soma.length - 1] & 0x0f;
int numero = ByteBuffer.wrap(soma, deslocamento, 4).getInt() & 0x7fffffff;
return String.format("%0" + digitos + "d", numero % (int) Math.pow(10, digitos));
}
/** Devolve o passo aceito. Guarde-o para recusar o mesmo código de novo. */
static OptionalLong verificar(byte[] segredo, String codigo, long ultimoPasso, long agoraS)
throws Exception {
long atual = agoraS / PASSO_S;
for (long passo = atual - JANELA; passo <= atual + JANELA; passo++) {
byte[] esperado = totp(segredo, passo, 6).getBytes(StandardCharsets.US_ASCII);
byte[] recebido = codigo.getBytes(StandardCharsets.US_ASCII);
if (passo > ultimoPasso && MessageDigest.isEqual(esperado, recebido)) {
return OptionalLong.of(passo);
}
}
return OptionalLong.empty();
}
}using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
static class Totp
{
const long PassoS = 30; // cada código vale 30 segundos
const int Janela = 1; // aceita um passo antes e um depois, para relógios fora de hora
public static string Gerar(byte[] segredo, long passo, int digitos = 6)
{
var contador = new byte[8];
BinaryPrimitives.WriteInt64BigEndian(contador, passo);
var soma = HMACSHA1.HashData(segredo, contador);
var deslocamento = soma[^1] & 0x0f;
var numero = BinaryPrimitives.ReadInt32BigEndian(soma.AsSpan(deslocamento)) & 0x7fffffff;
return (numero % (int)Math.Pow(10, digitos)).ToString().PadLeft(digitos, '0');
}
/// Devolve o passo aceito, ou null. Guarde o passo para recusar o mesmo código de novo.
public static long? Verificar(byte[] segredo, string codigo, long ultimoPasso, long agoraS)
{
var atual = agoraS / PassoS;
for (var passo = atual - Janela; passo <= atual + Janela; passo++)
{
var esperado = Encoding.ASCII.GetBytes(Gerar(segredo, passo));
var recebido = Encoding.ASCII.GetBytes(codigo);
if (passo > ultimoPasso && CryptographicOperations.FixedTimeEquals(esperado, recebido))
return passo;
}
return null;
}
}As funções recebem o segredo em bytes. O segredo do QR code vem em base32: decodifique com base64.b32decode no Python, base32.StdEncoding no Go, ou uma biblioteca nas outras linguagens.
O resultado, nas cinco linguagens
Com o segredo dos vetores de teste do RFC 6238 (12345678901234567890), em 8 dígitos, as cinco versões produziram os códigos da tabela do RFC para SHA-1:
| Momento (segundos desde 1970) | Código esperado pelo RFC | Nas cinco |
|---|---|---|
| 59 | 94287082 | 94287082 |
| 1111111109 | 07081804 | 07081804 |
| 1111111111 | 14050471 | 14050471 |
| 1234567890 | 89005924 | 89005924 |
| 2000000000 | 69279037 | 69279037 |
| 20000000000 | 65353130 | 65353130 |
Depois, a verificação com o relógio parado em 1111111111, na ordem:
| Código enviado | Esperado | Nas cinco |
|---|---|---|
| Do passo anterior, com o relógio do celular atrasado | aceito | aceito |
| Do passo atual | aceito | aceito |
| O mesmo do passo atual, de novo | recusado | recusado |
| O do passo anterior, de novo | recusado | recusado |
| De dois passos atrás, num usuário novo | recusado | recusado |
| Do passo seguinte, num usuário novo | aceito | aceito |
000000 | recusado | recusado |
Por fim, um segredo gerado pela pyotp em base32, decodificado e passado à versão em Python, produziu o mesmo código que a pyotp: a implementação é compatível com os aplicativos autenticadores.
O que muda de uma linguagem para outra
| HMAC-SHA1 | Inteiro de 8 bytes | Comparação em tempo constante | |
|---|---|---|---|
| TypeScript | crypto.createHmac("sha1") | writeBigUInt64BE | crypto.timingSafeEqual |
| Python | hmac.new(..., hashlib.sha1) | struct.pack(">Q") | hmac.compare_digest |
| Go | hmac.New(sha1.New) | binary.BigEndian.PutUint64 | subtle.ConstantTimeCompare |
| Java | Mac.getInstance("HmacSHA1") | ByteBuffer.putLong | MessageDigest.isEqual |
| C# | HMACSHA1.HashData | BinaryPrimitives.WriteInt64BigEndian | CryptographicOperations.FixedTimeEquals |
O SHA-1 é fraco para assinaturas, mas não neste uso: o TOTP depende do HMAC, que continua seguro com SHA-1, e é o algoritmo que todos os aplicativos autenticadores aceitam.
A rota que confere o código
As funções acima são a parte fácil. A rota precisa de mais três cuidados:
- Limite de tentativas por conta, antes de conferir: no teste do nível Conceito, sem limite, o código caiu em 3,6 a 81 segundos.
- Guardar o passo aceito no usuário, na mesma transação do login, para dois pedidos simultâneos com o mesmo código não passarem os dois.
- Ler o segredo criptografado e decifrá-lo só na memória.
Com o limite de requisições do tópico de limite de requisições, um esboço:
app.post("/login/mfa", async (req, res) => {
const usuario = await usuarioDoLoginPendente(req); // passou pela senha, falta o segundo fator
if (!(await limite.permitir(`mfa:${usuario.id}`, 5, 15 * 60))) return res.status(429).end();
const segredo = decifrar(usuario.totpSegredoCifrado);
const passo = verificar(segredo, req.body.codigo, usuario.totpUltimoPasso);
if (passo === null) return res.status(401).end();
// Só grava se ninguém usou este passo antes (pedidos simultâneos)
const gravou = await db.query(
"UPDATE usuarios SET totp_ultimo_passo = $2 WHERE id = $1 AND totp_ultimo_passo < $2",
[usuario.id, passo],
);
if (gravou.rowCount === 0) return res.status(401).end();
await iniciarSessao(res, usuario);
res.status(204).end();
});As funções usuarioDoLoginPendente, limite, decifrar e iniciarSessao vêm dos tópicos de autenticação e de limite de requisições. Este trecho é um esboço e não foi executado como está.
Códigos de recuperação
import crypto from "node:crypto";
// 10 códigos de 10 caracteres, sem letras que se confundem (0/O, 1/I/L)
const ALFABETO = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
export function gerarCodigos(quantidade = 10): string[] {
return Array.from({ length: quantidade }, () =>
Array.from({ length: 10 }, () => ALFABETO[crypto.randomInt(ALFABETO.length)]).join(""));
}
// Guarde só o hash; os códigos aparecem para o usuário uma única vez
export const hashDoCodigo = (codigo: string) =>
crypto.createHash("sha256").update(codigo.toUpperCase().replace(/[^0-9A-Z]/g, "")).digest("hex");No teste, o primeiro uso de um código foi aceito e o segundo, recusado. O mesmo código digitado em minúsculas e com um hífen no meio também foi aceito, porque a função normaliza antes do hash. Um código inventado foi recusado.
Chave de acesso com WebAuthn
O teste usou a biblioteca SimpleWebAuthn (versão 14) no servidor e no navegador. O fluxo tem duas idas e voltas, uma para o cadastro e outra para o login:
import {
generateRegistrationOptions, verifyRegistrationResponse,
generateAuthenticationOptions, verifyAuthenticationResponse,
} from "@simplewebauthn/server";
const RP_ID = "loja.exemplo.com"; // o domínio ao qual a chave fica presa
const ORIGEM = "https://loja.exemplo.com";
// Cadastro, passo 1: o servidor gera as opções e guarda o desafio na sessão
const opcoes = await generateRegistrationOptions({
rpName: "Loja", rpID: RP_ID, userName: usuario.email,
authenticatorSelection: { residentKey: "required", userVerification: "required" },
});
sessao.desafio = opcoes.challenge;
// Cadastro, passo 2: o navegador devolve a resposta; o servidor confere e guarda a chave pública
const cadastro = await verifyRegistrationResponse({
response: req.body, expectedChallenge: sessao.desafio,
expectedOrigin: ORIGEM, expectedRPID: RP_ID,
});
if (cadastro.verified) await salvarCredencial(usuario.id, cadastro.registrationInfo.credential);
// Login: o mesmo par de passos, com a credencial guardada
const login = await verifyAuthenticationResponse({
response: req.body, expectedChallenge: sessao.desafio,
expectedOrigin: ORIGEM, expectedRPID: RP_ID,
credential: await credencialDoUsuario(req.body.id),
});No navegador, startRegistration e startAuthentication, da @simplewebauthn/browser, recebem as opções do servidor e chamam o autenticador do dispositivo.
O teste no Chrome confirmou o cadastro e o login em loja.test. Na mesma página servida em 1oja.test, o navegador recusou as duas tentativas de assinatura, como mostra o nível Conceito. A recusa vem do navegador, antes de qualquer código do servidor: é por isso que a chave de acesso resiste a sites falsos.
O trecho acima resume o servidor testado. Num sistema real, o desafio fica na sessão do usuário, e não numa variável, e cada usuário pode ter várias credenciais.
Lista de revisão
TOTP
- Há limite de tentativas por conta na conferência do código?
- O último passo aceito é guardado, e o mesmo código é recusado na segunda vez?
- A janela é de no máximo um passo para cada lado?
- O segredo é guardado criptografado, e a comparação é em tempo constante?
- O cadastro só é concluído depois que o usuário digita um código válido?
Chaves de acesso
- O RP ID e a origem esperada estão fixos no servidor, e não vêm do pedido?
- O desafio é aleatório, de uso único e guardado na sessão?
- O usuário pode cadastrar mais de uma chave?
Recuperação
- Os códigos de recuperação são guardados com hash e valem uma vez só?
- Remover ou trocar um fator exige o segundo fator, ou um processo tão rigoroso quanto?
- O usuário é avisado por todos os canais quando um fator muda?
Sessão
- Ações sensíveis pedem o segundo fator de novo?
- Trocar a senha ou um fator encerra as outras sessões?
Prompts para aprofundar
Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.
Implementar TOTP
Quero implementar o segundo fator com TOTP na minha aplicação. Pergunte qual linguagem, framework e banco eu uso. Vamos em etapas: 1. Gerar o segredo, guardá-lo criptografado e mostrar o QR code. 2. Concluir o cadastro só depois de um código válido. 3. A rota de login que confere o código, com limite de tentativas por conta e uso único de cada código. 4. Os códigos de recuperação, guardados com hash. 5. Testes com os vetores do RFC 6238, com código repetido, com código fora da janela e com tentativas em excesso. Em cada etapa, me passe os comandos para testar.
Implementar chaves de acesso
Quero implementar chaves de acesso (WebAuthn) na minha aplicação. Pergunte qual linguagem e framework eu uso e se as chaves vão substituir a senha ou ser um segundo fator. Explique, com código, usando uma biblioteca madura da minha linguagem: 1. O cadastro: opções, desafio guardado na sessão, conferência e armazenamento da chave pública. 2. O login, com a conferência da origem, do RP ID e do desafio. 3. Várias chaves por usuário e a tela para gerenciá-las. 4. Como testar com o autenticador virtual do Chrome. 5. Como testar que um domínio diferente não consegue usar a chave.
Revisar uma implementação de MFA
Vou colar abaixo o código do segundo fator de uma aplicação: cadastro, login e recuperação. Revise para produção. Verifique, e para cada item diga se está correto, incorreto ou ausente, citando o trecho: 1. Há limite de tentativas por conta na conferência do código? 2. O mesmo código TOTP é recusado na segunda vez? 3. A janela de tolerância é pequena? 4. O segredo TOTP é guardado criptografado, e os códigos de recuperação com hash? 5. A comparação é em tempo constante? 6. Trocar e-mail, senha ou fatores pede o segundo fator de novo? 7. Com WebAuthn: origem, RP ID e desafio são conferidos pelo servidor? Para cada problema, descreva como explorá-lo e mostre a correção. Código: [cole aqui]