Gardez les jetons d'autorisation en sécurité

Salut% username%. Quel que soit le sujet du rapport, on me pose constamment lors des conférences la même question - «comment stocker en toute sécurité des jetons sur l'appareil de l'utilisateur?». Habituellement, j'essaie de répondre, mais le temps ne permet pas de révéler pleinement le sujet. Avec cet article, je veux fermer complètement ce problème.

J'ai analysé une douzaine d'applications pour voir comment elles fonctionnent avec les jetons. Toutes les applications que j'ai analysées ont traité des données critiques et m'ont permis de définir un code PIN pour la saisie comme protection supplémentaire. Regardons les erreurs les plus courantes:

  • Envoi d'un code PIN à l'API avec RefreshToken pour confirmer l'authentification et recevoir de nouveaux jetons. - Mauvais, le RefreshToken n'est pas sécurisé dans le stockage local, avec un accès physique à l'appareil ou une sauvegarde, vous pouvez le supprimer, ainsi que le malware peut le faire.
  • Enregistrement du code PIN dans le message avec RefreshToken, puis vérification locale du code PIN et envoi du RefreshToken à l'API. - Un cauchemar, RefreshToken n'est pas sécurisé avec la broche, ce qui permet de les extraire, en plus, un autre vecteur apparaît suggérant de contourner l'authentification locale.
  • Mauvais cryptage RefreshToken avec un code PIN, ce qui vous permet de restaurer le code PIN et RefreshToken à partir du texte chiffré. - Un cas particulier d'une erreur précédente, exploitée un peu plus compliquée. Mais notez que c'est la bonne façon.

Après avoir examiné les erreurs courantes, vous pouvez commencer à réfléchir à la logique du stockage sécurisé des jetons dans votre application. Cela vaut la peine de commencer par les actifs de base associés à l'authentification / autorisation pendant le fonctionnement de l'application et de mettre en avant certaines exigences:

Les informations d'identification - (nom d'utilisateur + mot de passe) - sont utilisées pour authentifier l'utilisateur dans le système.
+ le mot de passe n'est jamais stocké sur l'appareil et doit être immédiatement effacé de la RAM après l'envoi à l'API
+ ne sont pas transmis par la méthode GET dans les paramètres de requête de la requête HTTP, les requêtes POST sont utilisées à la place
+ le cache du clavier est désactivé pour le traitement des mots de passe des champs de texte
+ presse-papiers est désactivé pour les champs de texte qui contiennent un mot de passe
+ le mot de passe n'est pas divulgué via l'interface utilisateur (ils utilisent des astérisques), ainsi, le mot de passe n'entre pas dans les captures d'écran

AccessToken - utilisé pour confirmer l'autorisation de l'utilisateur.
+ jamais stocké dans la mémoire à long terme et stocké uniquement dans la RAM
+ ne sont pas transmis par la méthode GET dans les paramètres de requête de la requête HTTP, les requêtes POST sont utilisées à la place

RefreshToken - utilisé pour obtenir un nouveau bundle AccessToken + RefreshToken.
+ n'est stocké sous aucune forme dans la RAM et doit être immédiatement retiré de celui-ci après avoir reçu de l'API et enregistré dans la mémoire à long terme ou après avoir reçu de la mémoire à long terme et utilisé
+ stocké uniquement sous forme cryptée dans la mémoire à long terme
+ chiffré avec une épingle en utilisant la magie et certaines règles (les règles seront décrites ci-dessous), celles si la broche n'a pas été définie, alors ne les enregistrez pas du tout
+ ne sont pas transmis par la méthode GET dans les paramètres de requête de la requête HTTP, les requêtes POST sont utilisées à la place

PIN - (généralement un nombre à 4 ou 6 chiffres) - utilisé pour crypter / décrypter le RefreshToken.
+ jamais stocké nulle part sur l'appareil et doit être immédiatement effacé de la RAM après utilisation
+ ne quitte jamais les limites d'application, celles-ci ne sont transmises nulle part
+ utilisé uniquement pour le cryptage / décryptage RefreshToken

OTP est un code à usage unique pour 2FA.
+ OTP n'est jamais stocké sur l'appareil et doit être immédiatement effacé de la RAM après l'envoi à l'API
+ ne sont pas transmis par la méthode GET dans les paramètres de requête de la requête HTTP, les requêtes POST sont utilisées à la place
+ cache clavier désactivé pour le traitement des champs de texte OTP
+ presse-papiers désactivé pour les champs de texte qui contiennent OTP
+ OTP n'entre pas dans les captures d'écran
+ l'application supprime OTP de l'écran lorsqu'il passe en arrière-plan

Passons maintenant à la magie de la cryptographie. La principale exigence est que vous ne devez en aucun cas autoriser la mise en œuvre d'un tel mécanisme de chiffrement RefreshToken, dans lequel vous pouvez valider le résultat du déchiffrement localement. Autrement dit, si un attaquant a pris possession du texte chiffré, il ne devrait pas être en mesure de récupérer la clé. Le seul validateur devrait être l'API. C'est le seul moyen de limiter les tentatives de sélection de clés et d'invalider les jetons en cas d'attaque Brute-Force.

Je vais donner un bon exemple, disons que nous voulons crypter l'UUID
aec27f0f-b8a3-43cb-b076-e075a095abfe
avec cet ensemble de AES / CBC / PKCS5Padding, en utilisant un code PIN comme clé. Il semble que l'algorithme soit bon, tout est basé sur des directives, mais il y a un point clé - la clé contient très peu d'entropie. Voyons ce que cela entraîne:

  1. Remplissage - puisque notre jeton prend 36 octets, et AES a un mode de chiffrement par bloc avec un bloc de 128 bits, alors l'algorithme doit terminer le jeton jusqu'à 48 octets (ce qui est un multiple de 128 bits). Dans notre version, la queue sera ajoutée selon la norme PKCS5Padding, c'est-à-dire la valeur de chaque octet ajouté est égale au nombre d'octets ajoutés
    01
    02 02
    03 03 03
    04 04 04 04
    05 05 05 05 05
    06 06 06 06 06 06
    etc.
    Notre dernier bloc ressemblera à ceci:
    ... | 61 62 66 65 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C |
    Et il y a un problème, en regardant ce remplissage, nous pouvons filtrer les données (par le dernier bloc non valide) déchiffrées avec la mauvaise clé et, ainsi, déterminer le RefreshToken valide à partir du tas torsadé.
  2. Format prévisible du jeton - même si nous faisons de notre jeton un multiple de 128 bits (par exemple, en supprimant les tirets) pour éviter d'ajouter du remplissage, nous rencontrerons le problème suivant. Le problème est que tous les mêmes tas torsadés, nous pouvons collecter les lignes et déterminer laquelle tombe sous le format UUID. L'UUID dans sa forme textuelle canonique est de 32 chiffres au format hexadécimal séparés par un tiret en 5 groupes 8-4-4-4-12
    xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx
    où M est la version et N est l'option. Tout cela suffit pour filtrer les jetons décryptés avec la mauvaise clé, laissant un format UUID RefreshToken approprié.

Compte tenu de tout ce qui précède, vous pouvez procéder à la mise en œuvre, j'ai choisi une option simple pour générer 64 octets aléatoires et les envelopper dans base64:

public String createRefreshToken() { byte[] refreshToken = new byte[64]; final SecureRandom secureRandom = new SecureRandom(); secureRandom.nextBytes(refreshToken); return Base64.getUrlEncoder().withoutPadding() .encodeToString(refreshToken); } 
Voici un exemple d'un tel jeton:
YmI8rF9pwB1KjJAZKY9JzqsCu3kFz4xt4GkRCzXS9-FS_kbN3-CF9RGiRuuGqwqMo-VxFDhgQNmgjlQFD2GvbA
Voyons maintenant à quoi cela ressemble algorithmiquement (sur Android et iOS, l'algorithme sera le même):

 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; } 

À quelles lignes méritent une attention particulière:

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

Pas de rembourrage, eh bien, tu te souviens.

 decodedToken = decodeToken(token); //   

Vous ne pouvez pas simplement prendre et crypter un jeton dans la représentation base64, car cette représentation a un certain format (enfin, vous vous en souvenez).

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

En sortie, on obtient une clé de taille AES_KEY_SIZE, adaptée à l'algorithme AES. Toute fonction de dérivation de clé recommandée par Argon2, SHA-3, Scrypt peut être utilisée comme kdf en cas de mauvaise vie pbkdf2 (elle est très bien parallèle sur FPGA).

Le jeton crypté final peut être stocké en toute sécurité sur l'appareil et ne vous inquiétez pas que quelqu'un puisse le voler, que ce soit un malware ou une entité non alourdie par des principes moraux.

Quelques recommandations supplémentaires:

  • Exclure les jetons des sauvegardes.
  • Sur iOS, stockez le jeton dans le trousseau avec l'attribut kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly.
  • Ne dispersez pas les actifs décrits dans cet article (clé, code PIN, mot de passe, etc.) dans l'application.
  • Remplacez les actifs dès qu'ils deviennent inutiles, ne les conservez pas en mémoire plus longtemps que nécessaire.
  • Utilisez SecureRandom sur Android et SecRandomCopyBytes sur iOS pour générer des octets aléatoires dans un contexte cryptographique.

Nous avons examiné un certain nombre de pièges lors du stockage de jetons, qui, à mon avis, devraient être connus de toute personne développant des applications qui fonctionnent avec des données critiques. Ce sujet, dans lequel vous pouvez vous perdre à n'importe quelle étape, si vous avez des questions, posez-les dans les commentaires. Les commentaires sur le texte sont également les bienvenus.

Références:

CWE-311: Chiffrement manquant des données sensibles
CWE-327: Utilisation d'un algorithme cryptographique cassé ou risqué
CWE-327: CWE-338: Utilisation d'un générateur de nombres pseudo-aléatoires cryptographiquement faibles (PRNG)
CWE-598: Exposition d'informations via des chaînes de requête dans une demande GET

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


All Articles