Pular para o conteúdo
Node.js

Node.js: gerenciando códigos 2FA (TOTP)

Ilustração colorida de um cofre mágico com porta circular do qual saem seis dígitos brilhantes, ao lado um unicórnio de crina luminosa, uma ampulheta dourada e uma coruja segurando uma chave e um pergaminho com QR code

Olá meus Unicórnios! 🦄✨

Sabe aquele momento em que a equipe inteira precisa entrar num painel e o código de dois fatores está no celular de uma pessoa só? 😅 Pois é. Aconteceu comigo mais vezes do que eu gostaria de admitir: "manda o código do AWS aí", "peraí que meu celular tá carregando", "ela tá em reunião". E quando essa pessoa troca de aparelho sem exportar as contas... aí a novela vira drama. 😱

Foi daí que nasceu o cofre: um cofre centralizado de códigos 2FA, self-hosted, onde o servidor guarda os secrets cifrados e gera os códigos sob demanda. Um "Google Authenticator compartilhado", só que auditado — dá para saber quem gerou qual código e quando.

Este artigo é o que eu aprendi construindo isso. Não é sobre "instale a biblioteca X e pronto" — é sobre as armadilhas que só aparecem quando o código já está rodando: a função da biblioteca que parece a certa e não é, a validação que você acha que tem e não tem, e o jeito de deduplicar chaves sem decifrar nenhuma delas.

Todo o código está no GitHub, com uma página pronta para você adicionar chaves e ver os códigos girando:

Exemplos_TOTP2FA no GitHubO cofre completo em Node.js + Express: secrets cifrados com AES-256-GCM, importação por imagem de QR code e log de auditoria. Licença MIT.github.com

⏱️ O que o TOTP realmente é

Antes do código, vale desmistificar — porque quando eu entendi isso, metade dos meus bugs fez sentido de uma vez. 💡

Aqueles seis dígitos que mudam a cada 30 segundos não vêm do servidor. Nada é enviado, nada é consultado, seu celular gera aquilo offline. E a fórmula, definida na RFC 6238, é mais simples do que parece:

contador = floor(tempo_unix_agora / 30)
hash     = HMAC-SHA1(secret, contador)
codigo   = trunca(hash) % 1000000

É só isso. 🤯 O segredo compartilhado é o mesmo dos dois lados, o relógio é o mesmo dos dois lados, então os dois chegam ao mesmo número sem trocar uma palavra. É por isso que o 2FA funciona com o celular em modo avião — e é por isso que ele quebra quando o relógio do servidor está errado.

Repare na consequência prática: se você tem o secret, você é o segundo fator. Não existe "revogar o código". Guardar isso num banco é guardar a chave da casa.

🔐 Cifrando o secret em repouso

Como os secrets vão para o MySQL, eles precisam estar cifrados — e a escolha aqui é AES-256-GCM, não o AES-CBC que aparece na maioria dos tutoriais.

O motivo é uma palavra: autenticado. O GCM não só cifra, ele produz uma auth tag que denuncia qualquer adulteração do texto cifrado. Com CBC, alguém com acesso ao banco pode virar bits do ciphertext e você decifra lixo sem perceber. Com GCM, a decifragem simplesmente falha. Para um cofre, isso não é luxo.

import crypto from 'node:crypto';

const ALGO = 'aes-256-gcm';
const IV_BYTES = 12;   // 96 bits: o tamanho para o qual o GCM foi provado
const TAG_BYTES = 16;  // 128 bits: a tag completa

export function cifrar(textoPlano) {
  const iv = crypto.randomBytes(IV_BYTES);
  const cipher = crypto.createCipheriv(ALGO, ENCRYPTION_KEY, iv);
  const ct = Buffer.concat([
    cipher.update(String(textoPlano), 'utf8'),
    cipher.final(),
  ]);
  return {
    ciphertext: ct.toString('base64'),
    iv: iv.toString('base64'),
    authTag: cipher.getAuthTag().toString('base64'),
  };
}

Três coisas vão para o banco, não uma: ciphertext, iv e authTag. O IV é gerado novo a cada chamada e não é segredo — pode ficar em claro ao lado. O que ele não pode é se repetir: reusar IV no GCM é uma das poucas falhas que arruínam a cifra de vez.

A verificação que o Node não faz por você

Aqui está a primeira armadilha de verdade, e ela é silenciosa. 😳

export function decifrar({ ciphertext, iv, authTag }) {
  const ivBuf = Buffer.from(iv, 'base64');
  const tagBuf = Buffer.from(authTag, 'base64');
  // O Node aceita IV curto e tag truncada sem reclamar — e uma tag truncada
  // enfraquece a resistência a forja. Recusamos aqui, na porta de entrada.
  if (ivBuf.length !== IV_BYTES) throw new Error('IV inválido');
  if (tagBuf.length !== TAG_BYTES) throw new Error('auth tag inválida');

  const decipher = crypto.createDecipheriv(ALGO, ENCRYPTION_KEY, ivBuf, {
    authTagLength: TAG_BYTES,
  });
  decipher.setAuthTag(tagBuf);
  return Buffer.concat([
    decipher.update(Buffer.from(ciphertext, 'base64')),
    decipher.final(),
  ]).toString('utf8');
}

Aqueles dois if parecem paranoia defensiva. Não são. O setAuthTag() do Node aceita uma tag de 8 bytes numa boa — e uma tag truncada pela metade reduz drasticamente o custo de forjar um valor que "decifra com sucesso". A garantia de integridade do GCM pressupõe a tag inteira.

O detalhe cruel: se alguém conseguir escrever no seu banco uma tag curta, o Node não reclama e você perde a proteção sem ver um único erro no log. Essas quatro linhas são a diferença entre "temos GCM" e "temos GCM funcionando".

🎲 Gerando o código — e a função errada que parece a certa

Se você for de otplib, o instinto manda importar totp. Faz todo sentido: você quer um TOTP, o export chama totp. 🙃

E é aí que mora o bug — um que produz códigos errados sem lançar exceção nenhuma:

import { authenticator } from 'otplib';

// `authenticator`, não `totp`: o export `totp` do otplib trata a entrada como
// ASCII cru e não tem decodificador base32.
export function gerarCodigo({ secret, algorithm = 'SHA1', digits = 6, period = 30 }) {
  authenticator.options = {
    algorithm: String(algorithm).toLowerCase(),
    digits,
    step: period,
  };
  const code = authenticator.generate(secret);
  const epochSec = Math.floor(Date.now() / 1000);
  const remaining = period - (epochSec % period);
  authenticator.resetOptions();
  return { code, remaining };
}

A diferença: secrets TOTP circulam em base32 (aquele JBSWY3DPEHPK3PXP que o site mostra ao lado do QR). O export totp do otplib pega o que você passar e usa como bytes ASCII, sem decodificar. Ou seja: ele calcula um HMAC sobre a string "JBSWY3DPEHPK3PXP" em vez dos 10 bytes que ela representa.

Resultado? Seis dígitos lindos, bem formatados... e que nunca vão bater com o Google Authenticator. Nenhum erro, nenhum aviso — só um código que não funciona. O authenticator decodifica o base32 antes, respeitando as mesmas opções de algoritmo/dígitos/janela.

Provando que está certo: o vetor da RFC

"Mas como eu sei que meu código está certo, se não tenho o celular na mão para comparar?" Você tem algo melhor: a própria RFC 6238 publica vetores de teste — entradas conhecidas com saídas conhecidas.

// Seed da RFC: "12345678901234567890" em ASCII, convertido para base32.
const seed = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';
authenticator.options = { epoch: 59 * 1000, digits: 8, algorithm: 'sha1', step: 30 };
console.log(authenticator.generate(seed));  // deve imprimir 94287082
authenticator.resetOptions();

Aquele epoch congela o relógio em T=59s, que é o primeiro vetor da tabela da RFC. Se sair 94287082, sua implementação está correta — não "parece correta", está. É o teste que eu rodaria antes de qualquer outro. ✅

✅ Validando o secret antes de guardar

Esta seção é resultado de uma pergunta desconfortável: o que acontece se alguém colar a letra "A" no campo de secret?

Eu esperava um erro. O que acontece de verdade é pior — funciona. 😬 O otplib aceita, decodifica 5 bits de material e gera seis dígitos alegremente. Você guarda no cofre uma chave que parece perfeita e é criptograficamente inútil.

import base32 from 'hi-base32';

const BASE32_RE = /^[A-Z2-7]+=*$/;
const MIN_SECRET_BYTES = 10; // 80 bits — o piso do mundo real

export function validarSecret({ secret, digits = 6, period = 30 }) {
  const limpo = String(secret || '').toUpperCase().replace(/\s+/g, '');
  if (!limpo) return { ok: false, erro: 'Secret vazio' };
  if (!BASE32_RE.test(limpo)) return { ok: false, erro: 'Secret não é base32 válido' };
  if (!(digits >= 6 && digits <= 8)) return { ok: false, erro: 'digits deve estar entre 6 e 8' };
  if (!(period > 0)) return { ok: false, erro: 'period deve ser maior que 0' };

  let bytes;
  try {
    bytes = base32.decode.asBytes(limpo.replace(/=+$/, ''));
  } catch {
    return { ok: false, erro: 'Secret não é base32 válido' };
  }
  if (bytes.length < MIN_SECRET_BYTES) {
    return { ok: false, erro: `Secret muito curto (mínimo ${MIN_SECRET_BYTES * 8} bits)` };
  }

  // Prova final: se não gera código, não entra no cofre.
  try {
    gerarCodigo({ secret: limpo, digits, period });
  } catch {
    return { ok: false, erro: 'Secret não gera código TOTP válido' };
  }
  return { ok: true };
}

O número 80 bits foi o mais difícil de escolher, e vale explicar o raciocínio. A RFC 4226 recomenda secrets de 128 bits ou mais. Se eu exigisse isso, o cofre rejeitaria chaves legítimas de serviços reais — o próprio Google Authenticator emite secrets de 16 caracteres base32, que dão exatos 80 bits.

Então o piso ficou onde o mundo real está: barra o secret trivialmente fraco (1 ou 2 caracteres) sem quebrar a importação de contas que você já tem. É a diferença entre segurança que protege e segurança que só atrapalha. 🙏

Note também a ordem: o teste de charset vem antes do decode. O hi-base32 não é rigoroso com caracteres fora do alfabeto, então a regex é quem realmente barra um "1" ou um "0" (que não existem em base32 — justamente para não confundir com I e O).

🕵️ Deduplicando sem decifrar: o fingerprint

Aqui está minha parte favorita do projeto, e a mais contraintuitiva. Se você só for ler um pedaço deste artigo, leia este. 🙏

Ao importar 40 contas do Google Authenticator, você precisa saber quais já estão no cofre. O jeito óbvio é: decifrar todas as chaves guardadas e comparar. Só que isso significa colocar todos os secrets em memória em texto plano a cada importação. Para conferir duplicata. 😰

A solução é um HMAC determinístico — um "apelido" estável do secret:

export function fingerprint(secretBase32) {
  return crypto
    .createHmac('sha256', FINGERPRINT_KEY)
    .update(String(secretBase32).toUpperCase().replace(/=+$/, ''))
    .digest('hex');
}

Guardado numa coluna ao lado do ciphertext, ele resolve a dedupe com um SELECT: mesmo secret sempre gera o mesmo fingerprint, e o fingerprint não revela nada sobre o secret.

Três detalhes que não são cosméticos:

1. É HMAC, não SHA-256 puro. Um hash simples seria vulnerável a força bruta: o espaço de secrets base32 é enumerável, e quem roubasse o banco poderia testar candidatos até casar o hash. A chave do HMAC (guardada só no .env, separada da chave AES) torna isso impossível sem ela.

2. A normalização é obrigatória. O mesmo secret aparece como JBSWY3DPEHPK3PXP, jbswy3dpehpk3pxp ou com = de padding no fim, dependendo de quem exportou. Sem o toUpperCase() e o corte do padding, os três geram fingerprints diferentes e a dedupe não dedupa nada.

3. A busca precisa ser escopada ao dono. Esse foi o achado que mais me surpreendeu:

// A dedupe do import só deve considerar as chaves do PRÓPRIO usuário. Sem
// escopo, o fingerprint (HMAC determinístico) vazaria se outro usuário já
// possui um dado segredo (oráculo de existência cross-tenant) e ainda
// descartaria a entrada do importador na colisão.
export function importDedupeWhere(userId) {
  return { createdById: userId };
}

Pense no que uma dedupe global faria: eu importo um secret, o sistema diz "ignorada, duplicada" — e acabei de descobrir que outra pessoa no cofre tem exatamente esse secret. É um oráculo de existência entre usuários, vazando por um contador de estatística. E de quebra minha entrada some sem eu ter acesso à dela. Duas falhas numa cláusula WHERE faltando. 🤯

📥 Importando do Google Authenticator

Aceitar uma URI otpauth:// é o caso fácil — a biblioteca otpauth resolve:

import * as OTPAuth from 'otpauth';

export function lerOtpauthUri(uri) {
  const parsed = OTPAuth.URI.parse(uri);
  if (!(parsed instanceof OTPAuth.TOTP)) throw new Error('Apenas TOTP é suportado');
  return {
    issuer: parsed.issuer || '',
    accountName: parsed.label || '',
    secret: parsed.secret.base32.replace(/=+$/, ''),
    algorithm: normalizarAlgo(parsed.algorithm),
    digits: parsed.digits,
    period: parsed.period,
  };
}

Aquele instanceof OTPAuth.TOTP importa: a mesma URI pode descrever um HOTP (baseado em contador, não em tempo). Aceitar um HOTP num cofre TOTP guardaria uma chave que nunca gera o código certo.

O QR de migração é outro bicho

Agora a parte que me custou uma tarde. 😅 Quando você exporta contas do Google Authenticator, o QR gerado não é um otpauth://. É isto:

otpauth-migration://offline?data=<base64 url-encoded>

E o OTPAuth.URI.parse estoura com Invalid URI format nele. Não é bug da biblioteca — é um formato proprietário do Google, não padronizado: aquele data é um protobuf comprimido em base64. Para ler, é preciso declarar o schema (que a comunidade documentou a partir do código-fonte do app):

const migrationRoot = protobuf.Root.fromJSON({
  nested: {
    MigrationPayload: {
      fields: {
        otpParameters: { rule: 'repeated', type: 'OtpParameters', id: 1 },
        version: { type: 'int32', id: 2 },
        batchSize: { type: 'int32', id: 3 },
        batchIndex: { type: 'int32', id: 4 },
        batchId: { type: 'int32', id: 5 },
      },
      nested: {
        OtpParameters: {
          fields: {
            secret: { type: 'bytes', id: 1 },
            name: { type: 'string', id: 2 },
            issuer: { type: 'string', id: 3 },
            // ALGORITHM_UNSPECIFIED=0, SHA1=1, SHA256=2, SHA512=3, MD5=4
            algorithm: { type: 'int32', id: 4 },
            // DIGIT_COUNT_UNSPECIFIED=0, SIX=1, EIGHT=2
            digits: { type: 'int32', id: 5 },
            // OTP_TYPE_UNSPECIFIED=0, HOTP=1, TOTP=2
            type: { type: 'int32', id: 6 },
            counter: { type: 'int64', id: 7 },
          },
        },
      },
    },
  },
});

E na hora de decodificar, três pegadinhas de uma vez:

export function lerMigrationUri(uri) {
  const data = extrairDadoMigracao(String(uri).trim());
  const buffer = Buffer.from(decodeURIComponent(data), 'base64');
  const payload = MigrationPayload.decode(buffer);

  return (payload.otpParameters || [])
    // 1. Só TOTP: o campo `type` também pode trazer HOTP.
    .filter((p) => Number(p.type) === 2)
    .map((p) => ({
      issuer: p.issuer || '',
      accountName: p.name || '',
      // 2. O secret vem em BYTES CRUS, não em base32. Precisa codificar.
      secret: base32.encode([...p.secret]).replace(/=+$/, ''),
      algorithm: MIGRATION_ALGO[Number(p.algorithm)] || 'SHA1',
      digits: MIGRATION_DIGITS[Number(p.digits)] || 6,
      // 3. O payload NÃO carrega o período. O Google usa 30s.
      period: 30,
    }));
}

A número 2 é a que mais dói: o secret do protobuf são bytes crus, enquanto todo o resto do mundo TOTP fala base32. Esqueça o base32.encode e você guarda um secret que parece plausível e gera códigos errados — de novo, silenciosamente.

E os campos batchSize/batchIndex existem porque o Google divide a exportação em vários QRs quando você tem muitas contas. Se o seu import só aceita um QR por vez, ele importa um pedaço e o usuário nunca descobre o que ficou de fora.

📋 Auditoria: o cofre precisa contar o que fez

Um cofre compartilhado sem log é um problema de segurança com interface bonita. Se qualquer pessoa da equipe pode gerar o código do AWS, o mínimo é saber quem gerou.

A regra que eu segui, e que recomendo gravar em pedra: o log registra a ação, nunca o material. Nem o secret, nem o código gerado, nem senha. Se o log vaza, ele conta uma história — não entrega o cofre.

// `usernameAttempted` vem direto do corpo do login (não confiável). Remove
// caracteres de controle (anti log-forging) e limita o tamanho antes de gravar.
function sanitizarTentativa(value) {
  if (typeof value !== 'string') return null;
  let out = '';
  for (const ch of value) {
    const c = ch.codePointAt(0);
    if (c >= 0x20 && c !== 0x7f && !(c >= 0x80 && c <= 0x9f)) out += ch;
  }
  return out.slice(0, 100);
}

Esse detalhe é sutil e vale o parágrafo: o nome de usuário de uma tentativa falha de login vem de quem está tentando invadir. Se ele mandar um nome com \n no meio, e você grava cru num log de texto, ele acabou de escrever uma linha inteira falsa no seu log de auditoria — pode forjar um "login bem-sucedido" de outra pessoa. É o log forging, e a defesa é essa: descartar caracteres de controle na origem.

🚪 Onde a autorização precisa morar

Última lição, e é de arquitetura. O instinto manda checar permissão no controller — a camada da rota, antes de chamar o serviço. Faz sentido e é o que quase todo tutorial mostra.

O problema aparece seis meses depois, quando alguém (você mesma 😅) adiciona uma rota nova que chama o mesmo serviço e esquece a checagem. O serviço decifra e devolve o secret sem perguntar nada.

// getCode/exportEntry descriptografam e retornam material sensível. A checagem
// de acesso vive DENTRO delas (não só no controller) para que a autorização
// viaje junto com o acesso ao segredo — defesa em profundidade contra um
// call-site futuro que esqueça de checar.
export async function getCode(id, ctx, viewer) {
  const row = await prisma.vaultEntry.findUnique({ where: { id } });
  if (!row) return null;
  if (viewer && !(await accessFor(row, viewer)).canView) return FORBIDDEN;

  const secret = decifrar({
    ciphertext: row.secretEncrypted,
    iv: row.secretIv,
    authTag: row.secretAuthTag,
  });
  const { code, remaining } = gerarCodigo({ ...row, secret });
  return { code, remaining, period: row.period, titleSnapshot: row.title };
}

A regra que eu tirei disso: toda função que decifra também autoriza. Não é redundância — é fazer a permissão andar de mãos dadas com o segredo, para que seja impossível chegar num sem passar pelo outro.

Repare também no FORBIDDEN, um Symbol devolvido em vez de null. Parece firula, mas resolve uma ambiguidade real: null significa "não existe" (404) e FORBIDDEN significa "existe, mas não é sua" (403). Se os dois virassem null, você não conseguiria distinguir — e responder 404 para tudo é uma escolha defensável, mas tem que ser uma escolha, não um acidente.

🧪 Juntando tudo: o cofre rodando

Juntei as peças num script único, cofre2fa.mjs, e rodei de verdade — a saída logo abaixo é o terminal real, sem edição.

npm i otplib otpauth hi-base32
node cofre2fa.mjs

O script tem seis blocos, e cada um faz uma coisa só:

FunçãoFaz isto
cifrar() / decifrar()Guardam e recuperam o secret com AES-256-GCM. O decifrar é quem recusa IV curto e tag truncada.
fingerprint()Gera o apelido HMAC do secret, para deduplicar sem decifrar nada.
validarSecret()Barra o que não é base32 e o que tem menos de 80 bits.
gerarCodigo()Produz os seis dígitos pela RFC 6238 e diz quantos segundos faltam.
lerOtpauthUri()Lê uma URI otpauth:// e devolve os campos da conta.
criarChave()Entrada do cofre: valida, deduplica e cifra.
verCodigo()Saída do cofre: decifra, gera o código e audita.

Todos os seis já apareceram inteiros nas seções acima — não vou repetir o código aqui. O que falta mostrar é só a cola que os junta, que é a parte que ninguém escreve num tutorial:

const banco = [];      // no lugar da tabela do MySQL
const auditoria = [];  // no lugar da tabela de audit_logs

function criarChave({ titulo, secret, algorithm = 'SHA1', digits = 6, period = 30 }) {
  const limpo = String(secret).toUpperCase().replace(/\s+/g, '').replace(/=+$/, '');
  const v = validarSecret({ secret: limpo, digits, period });
  if (!v.ok) throw new Error(v.erro);

  // Dedupe ANTES de cifrar: o fingerprint não precisa do ciphertext.
  const fp = fingerprint(limpo);
  if (banco.some((r) => r.secretFingerprint === fp)) {
    return { status: 'duplicada', titulo };
  }

  const caixa = cifrar(limpo);
  banco.push({
    id: banco.length + 1,
    titulo,
    secretEncrypted: caixa.ciphertext,
    secretIv: caixa.iv,
    secretAuthTag: caixa.authTag,
    secretFingerprint: fp,
    algorithm, digits, period,
  });
  return { status: 'criada', titulo };
}

function verCodigo(id, quem) {
  const linha = banco.find((r) => r.id === id);
  if (!linha) return null;

  const secret = decifrar({
    ciphertext: linha.secretEncrypted,
    iv: linha.secretIv,
    authTag: linha.secretAuthTag,
  });

  // `...linha` traz algorithm/digits/period; `secret` vem do decifrado. Passar
  // só `linha` aqui é um erro fácil: a linha do banco NÃO tem campo `secret`
  // (é esse o ponto), e o otplib estoura lá dentro com "Received undefined".
  const { code, remaining } = gerarCodigo({ ...linha, secret });

  auditar({ acao: 'view_code', usuario: quem, chave: linha.titulo });
  return { code, remaining, period: linha.period };
}

function auditar(evento) {
  // O código e o secret JAMAIS entram aqui. Só quem, o quê e quando.
  auditoria.push({ ...evento, em: new Date().toISOString() });
}

Repare na ordem dentro do criarChave: valida, depois deduplica, depois cifra. O fingerprint é calculado sobre o secret em claro, então a dedupe acontece sem precisar do ciphertext — e uma chave repetida nem chega a gastar uma operação de criptografia. 🎯

A parte de demonstração (as seções numeradas que imprimem a saída) é só console.log em cima dessas funções — omiti aqui para não dobrar o tamanho do bloco, mas é ela que produz exatamente o que vem abaixo.

📟 A saída real

Este é o terminal, sem edição. E ele prova sozinho quase tudo que o artigo contou até aqui:

1. RFC 6238 — o vetor de teste conhecido
────────────────────────────────────────
  T=59s, SHA1, 8 dígitos
  esperado pela RFC : 94287082
  nosso código      : 94287082   ✓ confere

2. AES-256-GCM — ida, volta e adulteração
─────────────────────────────────────────
  secret original   : JBSWY3DPEHPK3PXP
  ciphertext        : U82eDLCrOXYLfN53oLjDFg==
  iv                : rr/bpMY+GWfe/eOB
  authTag           : YEjSIU7Hc55hFXyk74LRpQ==
  decifrado         : JBSWY3DPEHPK3PXP
  mesmo secret, iv diferente? sim ✓
  authTag adulterada: recusada ✓ (Unsupported state or unable to authenticate data)
  tag truncada (8B) : recusada ✓ (auth tag inválida)

3. Validação — o que entra e o que não entra no cofre
─────────────────────────────────────────────────────
  ✓ aceito    "JBSWY3DPEHPK3PXP"
              secret real do Google Authenticator (80 bits)
  ✗ recusado  "A"                      → Secret muito curto (mínimo 80 bits)
              um único caractere (5 bits)
  ✗ recusado  "JBSWY3DP"               → Secret muito curto (mínimo 80 bits)
              8 chars = 40 bits
  ✓ aceito    "jbswy3dpehpk3pxp"
              minúsculas
  ✓ aceito    "JBSW Y3DP EHPK 3PXP"
              com espaços, como o site mostra
  ✗ recusado  "JBSWY3DPEHPK3PX1"       → Secret não é base32 válido
              tem "1", fora do alfabeto base32
  ✗ recusado  ""                       → Secret vazio
              vazio

4. Fingerprint — deduplicar sem decifrar
────────────────────────────────────────
  fp("JBSWY3DPEHPK3PXP")  = 496e58fedc6ffc3bad76c29da0681b9caa9233bdeffdf74e523a4811aeaf4a5e
  fp(mesmo, minúsculo)    = 496e58fedc6ffc3bad76c29da0681b9caa9233bdeffdf74e523a4811aeaf4a5e
  fp(mesmo, com padding)  = 496e58fedc6ffc3bad76c29da0681b9caa9233bdeffdf74e523a4811aeaf4a5e
  os três batem?          sim ✓
  fp(outro secret)        = 39e57eeaf6ea3741e607b88f3cd25a74c229e548c195b86ad70495ef6099f370
  colide com o primeiro?  não ✓
  o secret aparece no hash? não ✓

5. Importando de uma URI otpauth://
───────────────────────────────────
  otpauth://totp/GitHub:paloma?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA1&digits=6&period=30
  → {
      "issuer": "GitHub",
      "accountName": "paloma",
      "secret": "JBSWY3DPEHPK3PXP",
      "algorithm": "SHA1",
      "digits": 6,
      "period": 30
    }

6. O cofre trabalhando
──────────────────────
  {"status":"criada","titulo":"GitHub"}
  {"status":"criada","titulo":"AWS"}
  {"status":"duplicada","titulo":"GitHub (de novo)"}
  {"status":"rejeitada","motivo":"Secret muito curto (mínimo 80 bits)"}

  Chaves no banco: 2
    #1 GitHub     fp=496e58fedc6ffc3b…  ct=Xu3UJmyZ19cyVoY0nrjimQ==
    #2 AWS        fp=39e57eeaf6ea3741…  ct=gSRXol8mFP0N/s+5gn5aGklW

  Gerando os códigos:
    GitHub     363747   expira em 20s de 30s
    AWS        658042   expira em 20s de 30s

7. O log de auditoria
─────────────────────
  2026-08-05T23:04:10.639Z  paloma  view_code  "GitHub"
  2026-08-05T23:04:10.640Z  paloma  view_code  "AWS"

  Repare: nem o código nem o secret estão em nenhuma linha acima.

Olhe a seção 3 com carinho: "JBSW Y3DP EHPK 3PXP" com espaços entra numa boa (é como os sites exibem o secret ao lado do QR), "jbswy3dpehpk3pxp" minúsculo também — mas "A" e "JBSWY3DP" ficam de fora. É a validação fazendo exatamente o que se pediu dela.

E a seção 6 mostra a dedupe funcionando sem decifrar nada: "jbswy3dp ehpk3pxp", minúsculo e com espaço no meio, foi reconhecido como a mesma chave do GitHub que já estava lá. Foi o fingerprint normalizado que pegou. 🎯

Um bug meu, de brinde 😳

Já que o assunto é assumir erro: a primeira execução deste script quebrou. Não em produção, não numa borda exótica — na linha mais óbvia do arquivo.

TypeError [ERR_INVALID_ARG_TYPE]: The first argument must be of type string
or an instance of Buffer... Received undefined
    at exports.decode (thirty-two.js:87:16)
    at Authenticator.generate

Eu tinha escrito gerarCodigo(linha), passando a linha do banco direto. Só que a linha do banco não tem campo secret — esse é literalmente o objetivo do projeto inteiro. 🤦‍♀️ Eu decifrava para uma variável local e depois não usava.

O certo é gerarCodigo({ ...linha, secret }): os parâmetros vêm da linha, o segredo vem do decifrado. E deixei o comentário no script justamente porque, num cofre, esse erro é fácil de cometer e o stack trace aponta para dentro do thirty-two — três camadas longe de onde o problema realmente está.

E, sendo honesta até o fim: um cofre centralizado enfraquece a premissa do segundo fator, que é justamente o segredo estar num dispositivo separado. Ele é uma troca — conveniência de equipe por concentração de risco. Vale a pena para credenciais compartilhadas de infraestrutura, atrás de VPN. Não vale para a sua conta pessoal do banco. 🙂

📦 O cofre inteiro, pronto para clonar

Juntei tudo num repositório: a API em Express, os módulos de criptografia e importação, e uma página onde você adiciona as chaves e vê os códigos girando com o contador regressivo. 🎉

Tem também o que este artigo não mostrou em código: você arrasta a imagem do QR code para a página — ou cola um print com Ctrl+V — e o cofre lê, valida e importa. Funciona com o QR de uma conta só e com aquele QR de exportação do Google Authenticator que traz várias de uma vez.

Exemplos_TOTP2FA no GitHubClone, npm install, npm start — e abra o localhost:3009 para adicionar sua primeira chave.github.com

Uma sugestão para testar: exporte uma chave pelo botão QR e importe a imagem de volta. O cofre vai dizer "duplicada" — é o fingerprint reconhecendo o mesmo secret sem decifrar nada, exatamente o que este artigo contou. 😄

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Perguntas frequentes

Como o TOTP gera os seis dígitos que mudam a cada 30 segundos?
Pela RFC 6238, com três passos: o contador é floor(tempo_unix_agora / 30), o hash é HMAC-SHA1(secret, contador) e o código é a truncagem desse hash módulo 1000000. Nada é enviado ao servidor: o celular calcula offline, e é por isso que o 2FA funciona em modo avião. Também é por isso que ele quebra quando o relógio do servidor está errado.
Por que usar AES-256-GCM e não AES-CBC para guardar o secret?
Porque o GCM é cifrado autenticado: ele produz uma auth tag que denuncia qualquer adulteração do texto cifrado. Com CBC, alguém com acesso ao banco pode virar bits do ciphertext e você decifra lixo sem perceber. Com GCM, a decifragem simplesmente falha. Vão três coisas para o banco, não uma: ciphertext, iv e authTag.
Por que meu código TOTP nunca bate com o Google Authenticator?
Provavelmente você importou o export totp do otplib em vez do authenticator. Secrets TOTP circulam em base32, e o totp trata a entrada como bytes ASCII crus, sem decodificar. O resultado são seis dígitos bem formatados que nunca conferem, sem erro nem aviso nenhum. O authenticator decodifica o base32 antes de calcular o HMAC.
Qual o tamanho mínimo aceitável para um secret TOTP?
No cofre eu fixei o piso em 80 bits, ou seja 10 bytes. A RFC 4226 recomenda 128 bits ou mais, mas exigir isso rejeitaria chaves legítimas de serviços reais: o próprio Google Authenticator emite secrets de 16 caracteres base32, que dão exatos 80 bits. O piso barra o secret trivialmente fraco (um ou dois caracteres) sem quebrar a importação das contas que você já tem.
Como deduplicar secrets 2FA sem decifrar nenhum deles?
Com um HMAC-SHA256 determinístico do secret normalizado, guardado numa coluna ao lado do ciphertext: o mesmo secret sempre gera o mesmo fingerprint, e a dedupe vira um SELECT. É HMAC e não SHA-256 puro porque o espaço de secrets base32 é enumerável e um hash simples cairia por força bruta. A normalização (toUpperCase() e corte do padding) é obrigatória, senão o mesmo secret gera fingerprints diferentes.
Por que a busca de duplicatas precisa ser escopada ao próprio usuário?
Porque uma dedupe global vira um oráculo de existência entre usuários. Eu importo um secret, o sistema responde "ignorada, duplicada" e acabei de descobrir que outra pessoa no cofre tem exatamente aquele secret. E, de quebra, a minha entrada desaparece sem que eu tenha acesso à dela. São duas falhas numa cláusula WHERE que faltou.

Leia também