Mantenha os tokens de autorização seguros

Olá% username%. Independentemente do tópico do relatório, sou constantemente perguntado nas conferências a mesma pergunta - “como armazenar com segurança tokens no dispositivo do usuário?”. Normalmente, tento responder, mas o tempo não permite revelar completamente o tópico. Com este artigo, quero fechar completamente esse problema.

Analisei uma dúzia de aplicativos para ver como eles funcionam com tokens. Todos os aplicativos que analisei processaram dados críticos e permitiram definir um código PIN para entrada como uma proteção adicional. Vejamos os erros mais comuns:

  • Enviar um código PIN para a API junto com o RefreshToken para confirmar a autenticação e receber novos tokens. - Ruim, o RefreshToken é inseguro no armazenamento local, com acesso físico ao dispositivo ou backup, você pode extraí-lo e o malware pode fazê-lo.
  • Salvando o código PIN na mensagem com RefreshToken, depois a verificação local do código PIN e enviando o RefreshToken para a API. - Um pesadelo, o RefreshToken é inseguro junto com o pino, o que permite que eles sejam extraídos; além disso, outro vetor aparece sugerindo ignorar a autenticação local.
  • Criptografia incorreta do RefreshToken com um código PIN, que permite restaurar o código PIN e o RefreshToken a partir do texto cifrado. - Um caso especial de erro anterior, explorado um pouco mais complicado. Mas note que este é o caminho certo.

Depois de analisar os erros comuns, você pode continuar pensando na lógica do armazenamento seguro de tokens em seu aplicativo. Vale a pena começar com os ativos básicos associados à autenticação / autorização durante a operação do aplicativo e apresentar alguns requisitos para eles:

Credenciais - (nome de usuário + senha) - são usadas para autenticar o usuário no sistema.
+ a senha nunca é armazenada no dispositivo e deve ser limpa imediatamente da RAM após o envio para a API
+ não são transmitidos pelo método GET nos parâmetros de consulta da solicitação HTTP, as solicitações POST são usadas
+ o cache do teclado está desativado para o processamento de senha nos campos de texto
+ área de transferência está desativada para campos de texto que contêm uma senha
+ a senha não é divulgada pela interface do usuário (eles usam asteriscos); também, a senha não aparece nas capturas de tela

AccessToken - usado para confirmar a autorização do usuário.
+ nunca armazenado na memória de longo prazo e armazenado apenas na RAM
+ não são transmitidos pelo método GET nos parâmetros de consulta da solicitação HTTP, as solicitações POST são usadas

RefreshToken - usado para obter um novo pacote configurável AccessToken + RefreshToken.
O + não é armazenado de nenhuma forma na RAM e deve ser removido imediatamente após o recebimento da API e o salvamento na memória de longo prazo ou após o recebimento da memória de longo prazo e o uso
+ armazenado apenas na forma criptografada na memória de longo prazo
+ criptografado com um alfinete usando magia e certas regras (as regras serão descritas abaixo), aquelas se o alfinete não tiver sido definido e não serão salvos
+ não são transmitidos pelo método GET nos parâmetros de consulta da solicitação HTTP, as solicitações POST são usadas

PIN - (geralmente um número de 4 ou 6 dígitos) - usado para criptografar / descriptografar o RefreshToken.
+ nunca armazenado em nenhum lugar do dispositivo e deve ser imediatamente removido da RAM após o uso
+ nunca sai dos limites do aplicativo, eles não são transmitidos a lugar algum
+ usado apenas para criptografia / descriptografia RefreshToken

OTP é um código único para 2FA.
O OTP nunca é armazenado no dispositivo e deve ser imediatamente removido da RAM após o envio para a API
+ não são transmitidos pelo método GET nos parâmetros de consulta da solicitação HTTP, as solicitações POST são usadas
+ cache do teclado desativado para campos de texto que processam OTP
+ área de transferência desativada para campos de texto que contêm OTP
+ OTP não entra em capturas de tela
+ o aplicativo remove o OTP da tela quando passa para o segundo plano

Agora vamos à mágica da criptografia. O principal requisito é que, em hipótese alguma, você permita a implementação de um mecanismo de criptografia RefreshToken, no qual é possível validar o resultado da descriptografia localmente. Ou seja, se um invasor se apossar do texto cifrado, ele não poderá pegar a chave. O único validador deve ser a API. Essa é a única maneira de limitar as tentativas de seleção de teclas e invalidar os tokens no caso de um ataque de força bruta.

Vou dar um bom exemplo, digamos que queremos criptografar o UUID
aec27f0f-b8a3-43cb-b076-e075a095abfe
com esse conjunto de acessórios AES / CBC / PKCS5, usando um PIN como chave. Parece que o algoritmo é bom, tudo é baseado em diretrizes, mas há um ponto-chave - a chave contém muito pouca entropia. Vamos ver o que isso leva a:

  1. Preenchimento - como nosso token ocupa 36 bytes e o AES é um modo de criptografia de bloco com um bloco de 128 bits, o algoritmo precisa finalizar o token até 48 bytes (que é um múltiplo de 128 bits). Em nossa versão, a cauda será adicionada de acordo com o padrão PKCS5Padding, ou seja, o valor de cada byte adicionado é igual ao número de bytes adicionados
    01
    02 02
    03 03 03
    04 04 04 04
    05 05 05 05 05
    06 06 06 06 06 06
    etc.
    Nosso último bloco será mais ou menos assim:
    ... 61 62 66 65 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 |
    E há um problema, observando esse preenchimento, podemos filtrar os dados (pelo último bloco inválido) descriptografados pela chave errada e, assim, determinar o RefreshToken válido a partir do heap distorcido.
  2. Formato previsível do token - mesmo se fizermos dele um múltiplo de 128 bits (por exemplo, remover hífens) para evitar adicionar preenchimento, encontraremos o seguinte problema. O problema é que, da mesma pilha distorcida, podemos coletar as linhas e determinar qual delas se enquadra no formato UUID. O UUID em sua forma textual canônica possui 32 dígitos no formato hexadecimal, separados por um hífen em 5 grupos 8-4-4-4-12
    xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx
    onde M é a versão e N é a opção. Tudo isso é suficiente para filtrar os tokens descriptografados com a chave errada, deixando um formato UUID RefreshToken adequado.

Dado todo o exposto, você pode prosseguir para a implementação. Eu escolhi uma opção simples para gerar 64 bytes aleatórios e envolvê-los em base64:

public String createRefreshToken() { byte[] refreshToken = new byte[64]; final SecureRandom secureRandom = new SecureRandom(); secureRandom.nextBytes(refreshToken); return Base64.getUrlEncoder().withoutPadding() .encodeToString(refreshToken); } 
Aqui está um exemplo de um token:
YmI8rF9pwB1KjJAZKY9JzqsCu3kFz4xt4GkRCzXS9-FS_kbN3-CF9RGiRuuGqwqMo-VxFDhgQNmgjlQFD2GvbA
Agora vamos ver como fica o algoritmo (no Android e iOS, o algoritmo será o mesmo):

 private static final String ALGORITHM = "AES"; private static final String CIPHER_SUITE = "AES/CBC/NoPadding"; private static final int AES_KEY_SIZE = 16; private static final int AES_BLOCK_SIZE = 16; public String encryptToken(String token, String pin) { decodedToken = decodeToken(token); //   rawPin = pin.getBytes(); byte[] iv = generate(AES_BLOCK_SIZE); //      CBC byte[] salt = generate(AES_KEY_SIZE); //       byte[] key = kdf.deriveKey(rawPin, salt, AES_KEY_SIZE); //  -    Cipher cipher = Cipher.getInstance(CIPHER_SUITE); //    cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, ALGORITHM), new IvParameterSpec(iv)); return cipher.doFinal(token); } public byte[] decodeToken(String token) { byte[] rawToken = token.getBytes(); return Base64.getUrlDecoder().decode(rawToken); } public final byte[] generate(int size) { byte[] random = new byte[size]; (new SecureRandom()).nextBytes(random); return random; } 

Em que linhas vale a pena prestar atenção:

 private static final String CIPHER_SUITE = "AES/CBC/NoPadding"; 

Sem preenchimento, bem, você se lembra.

 decodedToken = decodeToken(token); //   

Você não pode simplesmente pegar e criptografar um token na representação base64, pois essa representação tem um determinado formato (bem, você se lembra).

 byte[] key = kdf.deriveKey(rawPin, salt, AES_KEY_SIZE); //  -    

Na saída, obtemos uma chave de tamanho AES_KEY_SIZE, adequada para o algoritmo AES. Qualquer função de derivação de chave recomendada pelo Argon2, SHA-3, Scrypt pode ser usada como kdf em caso de problemas de vida pbkdf2 (ela se assemelha muito bem ao FPGA).

O token criptografado final pode ser armazenado com segurança no dispositivo e não se preocupe se alguém puder roubá-lo, seja um malware ou uma entidade que não seja sobrecarregada por princípios morais.

Mais algumas recomendações:

  • Excluir tokens dos backups.
  • No iOS, armazene o token no chaveiro com o atributo kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly.
  • Não espalhe os ativos discutidos neste artigo (chave, alfinete, senha, etc.) por todo o aplicativo.
  • Substitua os ativos assim que eles se tornarem desnecessários, não os guarde na memória por mais tempo que o necessário.
  • Use SecureRandom no Android e SecRandomCopyBytes no iOS para gerar bytes aleatórios em um contexto criptográfico.

Examinamos um certo número de armadilhas ao armazenar tokens, que, na minha opinião, devem ser conhecidos por todas as pessoas que desenvolvem aplicativos que trabalham com dados críticos. Este tópico, no qual você pode ficar confuso a qualquer momento, se tiver alguma dúvida, pergunte nos comentários. Comentários sobre o texto também são bem-vindos.

Referências:

CWE-311: Criptografia ausente de dados sensíveis
CWE-327: Uso de um algoritmo criptográfico quebrado ou arriscado
CWE-327: CWE-338: Uso do gerador de números pseudo-aleatórios criptograficamente fracos (PRNG)
CWE-598: Exposição de informações através de cadeias de consulta na solicitação GET

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


All Articles