Pular para o conteúdo
Node.js

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

Paloma Macetko
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! 🌈🌟

Leia também