Node.js: gerenciando códigos 2FA (TOTP)
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:
⏱️ 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ção | Faz 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.
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
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.