Gere senhas únicas para 2FA em JS usando a API Web Crypto

1. Introdução


Hoje, a autenticação de dois fatores está em todo lugar. Graças a ela, para roubar uma conta, apenas uma senha não é suficiente. E, embora a presença dele não garanta que sua conta não seja removida para contorná-la, será necessário um ataque mais complexo e em vários níveis. Como você sabe, quanto mais complicado algo neste mundo, maior a probabilidade de ele não funcionar.


Estou certo de que todo mundo que lê este artigo usou pelo menos uma vez a autenticação de dois fatores (doravante denominada 2FA, uma frase dolorosamente longa) em sua vida. Hoje, convido você a descobrir como essa tecnologia funciona, que protege inúmeras contas diariamente.


Mas para iniciantes, você pode dar uma olhada na demonstração do que vamos fazer hoje.


O básico


A primeira coisa que vale a pena mencionar sobre senhas de uso único é que elas são de dois tipos: HOTP e TOTP . Ou seja, senha de uso único baseada em HMAC e OTP baseado em tempo . TOTP é apenas um complemento para o HOTP, então vamos falar sobre um algoritmo mais simples primeiro.


O HOTP é descrito pela especificação RFC4226 . É pequeno, tem apenas 35 páginas e contém tudo o que você precisa: uma descrição formal, um exemplo de implementação e dados de teste. Vamos dar uma olhada nos conceitos básicos.


Primeiro de tudo, o que é HMAC ? HMAC significa código de autenticação de mensagens baseado em hash ou "código de autenticação de mensagens usando funções de hash" em russo. O MAC é um mecanismo para verificar o remetente de uma mensagem. O algoritmo MAC gera uma tag MAC usando uma chave secreta conhecida apenas pelo remetente e pelo destinatário. Após receber a mensagem, você pode gerar a etiqueta MAC e comparar as duas. Se eles coincidirem - tudo está em ordem, não houve interferência no processo de comunicação. Como bônus, da mesma maneira, você pode verificar se a mensagem foi danificada durante a transmissão. Obviamente, não funcionará para distinguir interferência de dano, mas o fato de corrupção de informações é suficiente.


Etiqueta MAC


O que é um hash? Um hash é o resultado da aplicação de uma função de hash a uma mensagem. As funções de hash pegam seus dados e fazem dele uma sequência de comprimento fixo. Um bom exemplo é a conhecida função MD5 , que tem sido amplamente usada para verificar a integridade do arquivo.


O próprio MAC não é um algoritmo específico, mas apenas um termo geral. O HMAC, por sua vez, já é uma implementação concreta. Mais especificamente, HMAC- X , onde X é uma das funções de hash criptográfico. O HMAC usa dois argumentos: uma chave secreta e uma mensagem, os mistura de uma certa maneira, aplica a função de hash selecionada duas vezes e retorna uma tag MAC.


Se você está pensando no que tudo isso tem a ver com senhas únicas - não se preocupe, estamos quase no centro.


De acordo com a especificação, o HOTP é calculado com base em dois valores:


  • K é a chave secreta que o cliente e o servidor conhecem. Ele deve ter pelo menos 128 bits de comprimento, e de preferência 160, e criado quando você configura o 2FA.
  • C é o contador .

Um contador é um valor de 8 bytes sincronizado entre o cliente e o servidor. Ele é atualizado à medida que você gera novas senhas. No esquema HOTP, um contador do lado do cliente é incrementado toda vez que você gera uma nova senha. No lado do servidor, toda vez que a senha passa na validação com sucesso. Como é possível gerar uma senha, mas não usá-la, o servidor permite que o valor do contador avance um pouco na janela estabelecida. No entanto, se você jogar demais com o gerador de senhas no esquema HOTP, precisará sincronizá-lo novamente.


Então Como você provavelmente notou, o HMAC também usa dois argumentos. RFC4226 define a função de geração HOTP da seguinte maneira:


HOTP(K,C) = Truncate(HMAC-SHA-1(K,C)) 

Como esperado, K é usado como uma chave secreta. O contador, por sua vez, é usado como uma mensagem. Depois que a função HMAC gera um tag MAC, a misteriosa função Truncate extrai a senha de uso único que já nos é familiar, que você vê no aplicativo gerador ou no token.


Vamos começar a escrever o código e lidar com o resto à medida que avançamos.


Plano de implementação


Para obter senhas únicas, precisamos seguir estas etapas.


Implementação


  • Gere um hash HMAC-SHA1 a partir dos parâmetros K e C. Essa será uma sequência de 20 bytes.
  • Puxe 4 bytes desta string de uma maneira específica.
  • Converta o valor extraído em um número e divida-o por 10 ^ n, onde n = o número de dígitos na senha descartável (geralmente n = 6). E, finalmente, pegue o restante dessa divisão. Esta será a nossa senha.

Isso não parece muito difícil, certo? Vamos começar com a geração de hash.


Gerar HMAC-SHA1


Esta é talvez a mais fácil das etapas listadas acima. Não tentaremos recriar o algoritmo por conta própria (nunca precisamos tentar implementar algo da criptografia por conta própria). Em vez disso, usaremos a API Web Crypto . O pequeno problema é que essa API de especificação está disponível apenas em Contexto Seguro (HTTPS). Para nós, isso é preocupante porque não podemos usá-lo sem configurar o HTTPS no servidor de desenvolvimento. Um pouco de história e discussão sobre como esta é a decisão certa pode ser encontrado aqui .


Felizmente, no Firefox, você pode usar o Web Crypto em um contexto inseguro e não precisa reinventar a roda ou arrastar as bibliotecas de terceiros. Portanto, para fins de desenvolvimento de uma demonstração, sugiro o uso do FF.


A própria API Crypto é definida em window.crypto.subtle . Se você está surpreso com o nome, cito a especificação:


A API é chamada SubtleCrypto como um reflexo do fato de que muitos dos algoritmos têm requisitos de uso específicos. Somente quando esses requisitos são atendidos eles mantêm sua durabilidade.

Vamos revisar os métodos que precisamos. Nota: todos os métodos mencionados aqui são assíncronos e retornam Promise .


Primeiro, precisamos do método importKey , pois usaremos nossa chave privada e não a geramos no navegador. importKey leva 5 argumentos:


 importKey( format, keyData, algorithm, extractable, usages ); 

No nosso caso:


  • format será 'raw' , ou seja, forneceremos a chave como uma matriz de bytes ArrayBuffer .
  • keyData é o mesmo ArrayBuffer. Muito em breve falaremos sobre como gerá-lo.
  • algorithm , de acordo com a especificação, será HMAC-SHA1 . Este argumento deve corresponder ao formato de HmacImportParams .
  • extractable como false, porque não temos planos de exportar a chave secreta
  • E, finalmente, de todos os usages precisamos apenas do 'sign' .

Nossa chave secreta será uma longa sequência aleatória. No mundo real, isso pode ser uma sequência de bytes, que pode ser imprimível, no entanto, por conveniência, neste artigo, consideraremos uma sequência. Para convertê-lo em ArrayBuffer , usaremos a interface TextEncoder . Com ele, a chave é preparada em duas linhas de código:


 const encoder = new TextEncoder('utf-8'); const secretBytes = encoder.encode(secret); 

Agora vamos juntar tudo:


  const Crypto = window.crypto.subtle; const encoder = new TextEncoder('utf-8'); const secretBytes = encoder.encode(secret); const key = await Crypto.importKey( 'raw', secretBytes, { name: 'HMAC', hash: { name: 'SHA-1' } }, false, ['sign'] ); 

Ótimo! A criptografia foi preparada. Agora vamos lidar com o balcão e, finalmente, assinar a mensagem.


De acordo com a especificação, nosso contador deve ter 8 bytes de comprimento. Vamos trabalhar com ele novamente, como no ArrayBuffer . Para traduzi-lo para este formulário, usaremos o truque geralmente usado em JS para salvar zeros nos dígitos superiores de um número. Depois disso, colocaremos cada byte em um ArrayBuffer usando um DataView . Lembre-se de que, por especificação de todos os dados binários, o formato é big endian .


 function padCounter(counter) { const buffer = new ArrayBuffer(8); const bView = new DataView(buffer); const byteString = '0'.repeat(64); // 8 bytes const bCounter = (byteString + counter.toString(2)).slice(-64); for (let byte = 0; byte < 64; byte += 8) { const byteValue = parseInt(bCounter.slice(byte, byte + 8), 2); bView.setUint8(byte / 8, byteValue); } return buffer; } 

Contador de almofada


Finalmente, depois de preparar a chave e o contador, você pode gerar um hash! Para fazer isso, usaremos a função de sign de SubtleCrypto .


 const counterArray = padCounter(counter); const HS = await Crypto.sign('HMAC', key, counterArray); 

E nisso concluímos o primeiro passo. Na saída, obtemos um valor um pouco misterioso chamado HS. E embora esse não seja o melhor nome para uma variável, é assim que (e algumas das seguintes) são chamadas na especificação. Vamos deixar esses nomes para facilitar a comparação do código com ele. O que vem a seguir?


Etapa 2: gerar uma sequência de 4 bytes (truncamento dinâmico)
Seja Sbits = DT (HS) // DT, definido abaixo,
// retorna uma string de 31 bits

DT significa truncamento dinâmico. E aqui está como isso funciona:


 function DT(HS) { // First we take the last byte of our generated HS and extract last 4 bits out of it. // This will be our _offset_, a number between 0 and 15. const offset = HS[19] & 0b1111; // Next we take 4 bytes out of the HS, starting at the offset const P = ((HS[offset] & 0x7f) << 24) | (HS[offset + 1] << 16) | (HS[offset + 2] << 8) | HS[offset + 3] // Finally, convert it into a binary string representation const pString = P.toString(2); return pString; } 

Truncamento


Observe como aplicamos AND bit a bit no primeiro byte do HS. 0x7f no sistema binário é 0b01111111 , então basicamente descartamos o primeiro bit. Em JS, é aí que o significado dessa expressão termina, mas em outros idiomas, ele também fornecerá o corte do bit de sinal para remover a confusão entre números positivos / negativos e apresentar esse número como não assinado.


Quase pronto! Resta apenas converter o valor obtido da DT em um número e encaminhar para a terceira etapa.


 function truncate(uKey) { const Sbits = DT(uKey); const Snum = parseInt(Sbits, 2); return Snum; } 

O terceiro passo também é bastante pequeno. Tudo o que precisa ser feito é dividir o número resultante por 10 ** ( ) e, em seguida, pegar o restante dessa divisão. Assim, cortamos os últimos N dígitos desse número. De acordo com a especificação, nosso código deve ser capaz de extrair pelo menos seis senhas e potencialmente 7 e 8 dígitos. Teoricamente, como esse é um número de 31 bits, poderíamos ter retirado 9 caracteres, mas, na realidade, eu pessoalmente nunca vi mais do que 6 caracteres. E você?


O código para a função final, que combina todas as anteriores, neste caso, será algo como isto:


 async function generateHOTP(secret, counter) { const key = await generateKey(secret, counter); const uKey = new Uint8Array(key); const Snum = truncate(uKey); // Make sure we keep leading zeroes const padded = ('000000' + (Snum % (10 ** 6))).slice(-6); return padded; } 

Viva! Mas como verificar agora se nosso código está correto?


Teste


Para testar a implementação, usaremos exemplos da RFC. O Apêndice D inclui valores de teste para a chave secreta "12345678901234567890" e valores de contador de 0 a 9. Também são contados hashes HMAC e resultados intermediários da função Truncate. Bastante útil para depurar todas as etapas do algoritmo. Aqui está um pequeno exemplo desta tabela (apenas o contador e o HOTP são deixados):


  Count HOTP 0 755224 1 287082 2 359152 3 969429 ... 

Se você ainda não assistiu a demonstração , agora é a hora. Você pode direcionar os valores do RFC. E volte porque estamos iniciando o TOTP.


Totp


Finalmente chegamos à parte mais moderna do 2FA. Quando você abre o gerador de senhas de uso único e vê um pequeno temporizador contando quanto mais código será válido, é TOTP. Qual a diferença?


Baseado em tempo significa que, em vez de um valor estático, o tempo atual é usado como um contador. Ou, mais precisamente, o "intervalo" (intervalo de tempo). Ou até o número do intervalo atual. Para calcular, usamos o tempo do Unix (o número de milissegundos desde a meia-noite de 1º de janeiro de 1970 no UTC) e dividimos pela janela a validade da senha (geralmente 30 segundos). O servidor geralmente tolera pequenos desvios devido à sincronização imperfeita do relógio. Geralmente 1 intervalo para frente e para trás, dependendo da configuração.


Obviamente, isso é muito mais seguro que o esquema HOTP. Em um esquema com limite de tempo, o código válido é alterado a cada 30 segundos, mesmo que não tenha sido usado. No algoritmo original, uma senha válida é determinada pelo valor atual do contador na janela do servidor + tolerância. Se você não se autenticar, a senha não será alterada indefinidamente. Você pode ler mais sobre o TOTP no RFC6238 .


Como o esquema baseado em tempo é uma adição ao algoritmo original, não precisamos fazer alterações na implementação original. Usaremos requestAnimationFrame e verificaremos para cada quadro se ainda estamos dentro do intervalo de tempo. Caso contrário, gere um novo contador e calcule o HOTP novamente. Omitindo todo o código de controle, a solução é mais ou menos assim:


 let stepWindow = 30 * 1000; // 30 seconds in ms let lastTimeStep = 0; const updateTOTPCounter = () => { const timeSinceStep = Date.now() - lastTimeStep * stepWindow; const timeLeft = Math.ceil(stepWindow - timeSinceStep); if (timeLeft > 0) { return requestAnimationFrame(updateTOTPCounter); } timeStep = getTOTPCounter(); lastTimeStep = timeStep; <...update counter and regenerate...> requestAnimationFrame(updateTOTPCounter); } 

Toques finais - Suporte para QR Code


Geralmente, quando configuramos o 2FA, digitalizamos os parâmetros iniciais com um código QR. Ele contém todas as informações necessárias: o esquema selecionado, chave secreta, nome da conta, nome do provedor, número de dígitos na senha.


Em um artigo anterior, falei sobre como você pode escanear códigos QR diretamente da tela usando a API getDisplayMedia . Com base nesse material, fiz uma pequena biblioteca, que usaremos agora. A biblioteca é chamada stream-display e, além disso, usamos o maravilhoso pacote jsQR .


O link codificado em QR tem o seguinte formato:


 otpauth://TYPE/LABEL?PARAMETERS 

Por exemplo:


 otpauth://totp/label?secret=oyu55d4q5kllrwhy4euqh3ouw7hebnhm5qsflfcqggczoafxu75lsagt&algorithm=SHA1&digits=6&period=30 

Omitirei o código que configura o processo de inicialização da captura e reconhecimento de tela, pois tudo isso pode ser encontrado na documentação. Em vez disso, veja como analisar este link:


 const setupFromQR = data => { const url = new URL(data); // drop the "//" and get TYPE and LABEL const [scheme, label] = url.pathname.slice(2).split('/'); const params = new URLSearchParams(url.search); const secret = params.get('secret'); let counter; if (scheme === 'hotp') { counter = params.get('counter'); } else { stepWindow = parseInt(params.get('period'), 10) * 1000; counter = getTOTPCounter(); } } 

No mundo real, a chave secreta será uma string codificada em 32 (!), Pois alguns bytes podem não ser imprimíveis. Mas, por simplicidade de demonstração, omitimos esse ponto. Infelizmente, não consegui encontrar informações sobre o motivo da base-32 ou apenas esse formato. Aparentemente, não existe uma especificação oficial para este formato de URL, e o próprio formato foi cunhado pelo Google. Você pode ler um pouco sobre ele aqui.


Para gerar códigos QR de teste, eu recomendo usar o FreeOTP .


Conclusão


E isso é tudo! Mais uma vez, não se esqueça de assistir à demonstração . Há também um link para o repositório com o código que está por trás de tudo.


Hoje, desmontamos uma tecnologia bastante importante que usamos diariamente. Espero que você tenha aprendido algo novo para si mesmo. Este artigo levou muito mais tempo do que eu pensava. No entanto, é bastante interessante transformar uma especificação de papel em algo funcional e familiar.


Até breve!

Source: https://habr.com/ru/post/pt462945/


All Articles