Stockage des données en toute sécurité sur Android

() translation by (you can also view the original English article)
La crédibilité d'une application dépend aujourd'hui fortement de la gestion des données privées de l'utilisateur. La pile Android comporte de nombreuses API puissantes entourant les informations d'identification et le stockage des clés, avec des fonctionnalités spécifiques uniquement disponibles dans certaines versions. Cette courte série commencera avec une approche simple pour bien démarrer en parlant du système de stockage et comment crypter et stocker des données sensibles via un code d'accès fourni par l'utilisateur. Dans le deuxième tutoriel, nous verrons des méthodes plus complexes de protéger les clés et les informations d'identification.
Les bases
La première question à laquelle vous devez réfléchir est la quantité de données que vous devez réellement acquérir. Une bonne approche consiste à éviter de stocker des données privées si vous n'y êtes pas obligé.
Pour les données que vous devez stocker, l'architecture Android est prête à vous aider. Depuis la version 6.0 de Marshmellow, le chiffrement intégral du disque est activé par défaut pour les périphériques disposant de cette fonctionnalité. Les fichiers et SharedPreferences
enregistrés par l'application sont automatiquement définis avec la constante MODE_PRIVATE
. Cela signifie que les données ne sont accessibles que par votre propre application.
C'est une bonne idée de s'en tenir à ce défaut. Vous pouvez le définir explicitement lors de l'enregistrement d'une préférence partagée.
1 |
SharedPreferences.Editor editor = getSharedPreferences("preferenceName", MODE_PRIVATE).edit(); |
2 |
editor.putString("key", "value"); |
3 |
editor.commit(); |
Ou lors de l'enregistrement d'un fichier.
1 |
FileOutputStream fos = openFileOutput(filenameString, Context.MODE_PRIVATE); |
2 |
fos.write(data); |
3 |
fos.close(); |
Évitez de stocker des données sur un support de stockage externe, car les données sont ainsi visibles par d'autres applications et utilisateurs. En fait, pour éviter que les utilisateurs ne puissent copier le binaire et les données de votre application, vous pouvez interdire aux utilisateurs d'installer l'application sur un support de stockage externe. Ajouter android:installLocation
avec une valeur d' internalOnly
au fichier manifest va accomplir cette tâche.
Vous pouvez également empêcher de faire un backup de l'application et de ses données. Cela empêche également le téléchargement du contenu du répertoire de données privé d'une application à l'aide d'adb backup
. Pour ce faire, définissez l'attribut android:allowBackup
sur false
dans le fichier manifest. Par défaut, cet attribut est défini sur true
.
Ce sont les meilleures pratiques, mais elles ne fonctionneront pas pour un périphérique compromis ou rooté, et le chiffrement de disque n'est utile que lorsque le périphérique est sécurisé avec un écran de verrouillage. C'est là qu'être doté d'un mot de passe côté application qui protège ses données avec un cryptage est bénéfique.
Sécurisation des données utilisateur avec un mot de passe
Conceal est un excellent choix pour une bibliothèque de chiffrement, car elle vous permet d'accomplir la tâche très rapidement sans avoir à vous soucier des détails sous-jacents. Cependant, un exploit qui cible pour un framework populaire affectera simultanément toutes les applications qui en dépendent.
Il est également important de bien connaître le fonctionnement des systèmes de chiffrement afin de savoir si vous utilisez un framework particulier de manière sécurisée. Donc, pour ce post, nous allons nous salir les mains en regardant directement le fournisseur de cryptographie.
AES et dérivation de clé basée sur un mot de passe
Nous utiliserons la norme AES recommandée, qui crypte les données en fonction d'une clé. La même clé utilisée pour chiffrer les données est utilisée pour déchiffrer les données, ce qui est appelé le chiffrement symétrique. Il existe différentes tailles de clé et AES256 (256 bits) est la longueur préférée pour une utilisation avec des données sensibles.
Alors que l'expérience utilisateur de votre application devrait forcer un utilisateur à utiliser un code fort, il est possible que le même code soit également choisi par un autre utilisateur. Mettre la sécurité de nos données cryptées entre les mains de l'utilisateur n'est pas conseillé. Nos données doivent être sécurisées avec une clé aléatoire et suffisamment large (c'est-à-dire qui a suffisamment d'entropie) pour être considérée comme forte. C'est pourquoi il n'est jamais recommandé d'utiliser un mot de passe directement pour chiffrer les données - c'est là que la fonction PBKDF2 (Password-Based Key Derivation Function) entre en jeu.
PDKDF2 dérive une clé d'un mot de passe en le hachant plusieurs fois avec un salt. C'est ce qu'on appelle l'étirement des touches. Le salt est juste une séquence aléatoire de données et rend la clé dérivée unique même si le même mot de passe a été utilisé par quelqu'un d'autre. Commençons par générer ce salt.
1 |
SecureRandom random = new SecureRandom(); |
2 |
byte salt[] = new byte[256]; |
3 |
random.nextBytes(salt); |
La classe SecureRandom
garantit que la sortie générée sera difficile à prévoir - c'est un "générateur de nombres aléatoires fort cryptographiquement". Nous pouvons maintenant mettre le salt et le mot de passe dans un objet de chiffrement basé sur un mot de passe : PBEKeySpec
. Le constructeur de l'objet prend également une forme de compteur d'itération rendant la clé plus forte. C'est parce que l'augmentation du nombre d'itérations augmente le temps qu'il faudrait pour fonctionner sur un jeu de clés pendant une attaque en force brute. PBEKeySpec
est ensuite passé dans SecretKeyFactory
, qui génère finalement la clé sous la forme d'un tableau byte[]
. Nous allons envelopper ce tableau byte[]
brut dans un objet SecretKeySpec
.
1 |
char[] passwordChar = passwordString.toCharArray(); //Turn password into char[] array |
2 |
PBEKeySpec pbKeySpec = new PBEKeySpec(passwordChar, salt, 1324, 256); //1324 iterations |
3 |
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); |
4 |
byte[] keyBytes = secretKeyFactory.generateSecret(pbKeySpec).getEncoded(); |
5 |
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES"); |
Notez que le mot de passe est passé en tant que tableau char[]
et la classe PBEKeySpec
le stocke également en tant que tableau char[]
. Les tableaux char[]
sont généralement utilisés pour les fonctions de chiffrement, car si la classe String
est immuable, un tableau char[]
contenant des informations sensibles peut être écrasé, supprimant ainsi entièrement les données sensibles de la RAM du périphérique.
Vecteurs d'initialisation
Nous sommes maintenant prêts à crypter les données, mais nous avons encore une chose à faire. Il existe différents modes de cryptage avec AES, mais nous utiliserons le mode recommandé : CBC (Cipher Block Chaining). Cela fonctionne sur nos données un bloc à la fois. L'avantage de ce mode est que chaque bloc de données non crypté suivant est XORé avec le bloc crypté précédent pour renforcer le cryptage. Cependant, cela signifie que le premier bloc n'est jamais aussi unique que tous les autres !
Si un message à chiffrer devait être le même qu'un autre message à chiffrer, la sortie chiffrée du début serait la même, ce qui donnerait à un attaquant une idée de ce que pourrait être le message. La solution consiste à utiliser un vecteur d'initialisation (IV).
Un IV est juste un bloc d'octets aléatoires qui seront XORés avec le premier bloc de données utilisateur. Comme chaque bloc dépend de tous les blocs traités jusqu'à ce point, le message entier sera crypté de façon unique; des messages identiques cryptés avec la même clé ne produiront pas des résultats identiques. Créons un IV maintenant.
1 |
SecureRandom ivRandom = new SecureRandom(); //not caching previous seeded instance of SecureRandom |
2 |
byte[] iv = new byte[16]; |
3 |
ivRandom.nextBytes(iv); |
4 |
IvParameterSpec ivSpec = new IvParameterSpec(iv); |
Une note à propos de SecureRandom
. Sur les versions 4.3 et inférieures, l'architecture de cryptographie Java présentait une vulnérabilité due à une mauvaise initialisation du générateur de nombres pseudo-aléatoires (PRNG) sous-jacent. Si vous ciblez les versions 4.3 et inférieures, un correctif est disponible.
Chiffrer les données
Armé d'un IvParameterSpec
, nous pouvons maintenant faire réellement le chiffrement.
1 |
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding"); |
2 |
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); |
3 |
byte[] encrypted = cipher.doFinal(plainTextBytes); |
Ici, nous passons dans la chaîne "AES/CBC/PKCS7Padding"
. Ceci spécifie le cryptage AES avec chaînage de blocs de chiffrement. La dernière partie de cette chaîne fait référence à PKCS7, qui est une norme établie pour le remplissage des données qui ne correspond pas parfaitement à la taille du bloc. (Les blocs ont 128 bits et le remplissage est effectué avant le cryptage.)
Pour compléter notre exemple, nous allons mettre ce code dans une méthode de chiffrement qui va empaqueter le résultat dans un HashMap
contenant les données chiffrées, avec le salt et le vecteur d'initialisation nécessaires au décryptage.
1 |
private HashMap<String, byte[]> encryptBytes(byte[] plainTextBytes, String passwordString) |
2 |
{
|
3 |
HashMap<String, byte[]> map = new HashMap<String, byte[]>(); |
4 |
|
5 |
try
|
6 |
{
|
7 |
//Random salt for next step
|
8 |
SecureRandom random = new SecureRandom(); |
9 |
byte salt[] = new byte[256]; |
10 |
random.nextBytes(salt); |
11 |
|
12 |
//PBKDF2 - derive the key from the password, don't use passwords directly
|
13 |
char[] passwordChar = passwordString.toCharArray(); //Turn password into char[] array |
14 |
PBEKeySpec pbKeySpec = new PBEKeySpec(passwordChar, salt, 1324, 256); //1324 iterations |
15 |
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); |
16 |
byte[] keyBytes = secretKeyFactory.generateSecret(pbKeySpec).getEncoded(); |
17 |
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES"); |
18 |
|
19 |
//Create initialization vector for AES
|
20 |
SecureRandom ivRandom = new SecureRandom(); //not caching previous seeded instance of SecureRandom |
21 |
byte[] iv = new byte[16]; |
22 |
ivRandom.nextBytes(iv); |
23 |
IvParameterSpec ivSpec = new IvParameterSpec(iv); |
24 |
|
25 |
//Encrypt
|
26 |
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding"); |
27 |
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); |
28 |
byte[] encrypted = cipher.doFinal(plainTextBytes); |
29 |
|
30 |
map.put("salt", salt); |
31 |
map.put("iv", iv); |
32 |
map.put("encrypted", encrypted); |
33 |
}
|
34 |
catch(Exception e) |
35 |
{
|
36 |
Log.e("MYAPP", "encryption exception", e); |
37 |
}
|
38 |
|
39 |
return map; |
40 |
}
|
La méthode de déchiffrage
Vous avez seulement besoin de stocker l'IV et le salt avec vos données. Alors que les salts et IVs sont considérés comme publics, assurez-vous qu'ils ne sont pas séquentiellement incrémentés ou réutilisés. Pour déchiffrer les données, tout ce que nous devons faire est de changer le mode dans le constructeur Cipher
de ENCRYPT_MODE
à DECRYPT_MODE
. La méthode de déchiffrement prendra un HashMap
qui contient les mêmes informations requises (données cryptées, salt et IV) et retourne un tableau byte[]
décrypté, avec le mot de passe correct. La méthode de déchiffrement régénère la clé de chiffrement à partir du mot de passe. La clé ne devrait jamais être stockée !
1 |
private byte[] decryptData(HashMap<String, byte[]> map, String passwordString) |
2 |
{
|
3 |
byte[] decrypted = null; |
4 |
try
|
5 |
{
|
6 |
byte salt[] = map.get("salt"); |
7 |
byte iv[] = map.get("iv"); |
8 |
byte encrypted[] = map.get("encrypted"); |
9 |
|
10 |
//regenerate key from password
|
11 |
char[] passwordChar = passwordString.toCharArray(); |
12 |
PBEKeySpec pbKeySpec = new PBEKeySpec(passwordChar, salt, 1324, 256); |
13 |
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); |
14 |
byte[] keyBytes = secretKeyFactory.generateSecret(pbKeySpec).getEncoded(); |
15 |
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES"); |
16 |
|
17 |
//Decrypt
|
18 |
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding"); |
19 |
IvParameterSpec ivSpec = new IvParameterSpec(iv); |
20 |
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); |
21 |
decrypted = cipher.doFinal(encrypted); |
22 |
}
|
23 |
catch(Exception e) |
24 |
{
|
25 |
Log.e("MYAPP", "decryption exception", e); |
26 |
}
|
27 |
|
28 |
return decrypted; |
29 |
}
|
Test du chiffrement et du déchiffrement
Pour garder l'exemple simple, nous omettons la vérification d'erreur qui ferait en sorte que le HashMap
contienne la clé requise, les paires de valeurs. Nous pouvons maintenant tester nos méthodes pour nous assurer que les données sont déchiffrées correctement après le cryptage.
1 |
//Encryption test
|
2 |
String string = "My sensitive string that I want to encrypt"; |
3 |
byte[] bytes = string.getBytes(); |
4 |
HashMap<String, byte[]> map = encryptBytes(bytes, "UserSuppliedPassword"); |
5 |
|
6 |
//Decryption test
|
7 |
byte[] decrypted = decryptData(map, "UserSuppliedPassword"); |
8 |
if (decrypted != null) |
9 |
{
|
10 |
String decryptedString = new String(decrypted); |
11 |
Log.e("MYAPP", "Decrypted String is : " + decryptedString); |
12 |
}
|
Les méthodes utilisent un tableau byte[]
pour que vous puissiez chiffrer des données arbitraires au lieu de chiffrer seulement des objets String
.
Enregistrement des données chiffrées
Maintenant que nous avons un tableau byte[]
crypté, nous pouvons l'enregistrer dans le stockage.
1 |
FileOutputStream fos = openFileOutput("test.dat", Context.MODE_PRIVATE); |
2 |
fos.write(encrypted); |
3 |
fos.close(); |
Si vous ne voulez pas enregistrer l'IV et le salt séparément, HashMap
est sérialisable avec les classes ObjectInputStream
et ObjectOutputStream
.
1 |
FileOutputStream fos = openFileOutput("map.dat", Context.MODE_PRIVATE); |
2 |
ObjectOutputStream oos = new ObjectOutputStream(fos); |
3 |
oos.writeObject(map); |
4 |
oos.close(); |
Enregistrement de données sécurisées dans SharedPreferences
Vous pouvez également enregistrer des données sécurisées dans les SharedPreferences
de votre application.
1 |
SharedPreferences.Editor editor = getSharedPreferences("prefs", Context.MODE_PRIVATE).edit(); |
2 |
String keyBase64String = Base64.encodeToString(encryptedKey, Base64.NO_WRAP); |
3 |
String valueBase64String = Base64.encodeToString(encryptedValue, Base64.NO_WRAP); |
4 |
editor.putString(keyBase64String, valueBase64String); |
5 |
editor.commit(); |
Puisque SharedPreferences
est un système XML qui n'accepte que des primitives et des objets spécifiques en tant que valeurs, nous devons convertir nos données en un format compatible tel qu'un objet String
. Base64 nous permet de convertir les données brutes en une représentation String
qui contient uniquement les caractères autorisés par le format XML. Chiffrez à la fois la clé et la valeur afin qu'un attaquant ne puisse pas déterminer quelle valeur pourrait être. Dans l'exemple ci-dessus, encryptedKey
et encryptedValue
sont tous les deux des tableaux byte[]
renvoyés par notre méthode encryptBytes()
. L'IV et le salt peuvent être sauvegardés dans le fichier de préférences ou dans un fichier séparé. Pour récupérer les octets cryptés à partir des SharedPreferences
, nous pouvons appliquer un décodage Base64 sur le String
stockée.
1 |
SharedPreferences preferences = getSharedPreferences("prefs", Context.MODE_PRIVATE); |
2 |
String base64EncryptedString = preferences.getString(keyBase64String, "default"); |
3 |
byte[] encryptedBytes = Base64.decode(base64EncryptedString, Base64.NO_WRAP); |
Essuyage des données non sécurisées des anciennes versions
Maintenant que les données stockées sont sécurisées, il se peut que vous ayez une version précédente de l'application qui a stocké les données de manière non sécurisée. Lors d'une mise à jour, les données peuvent être essuyées et re-chiffrées. Le code suivant essuie un fichier en utilisant des données aléatoires.
En théorie, vous pouvez simplement supprimer vos préférences partagées en supprimant les fichiers /data/data/com.your.package.name/shared_prefs/your_prefs_name.xml et your_prefs_name.bak et en effaçant les préférences en mémoire avec le code suivant :
1 |
getSharedPreferences("prefs", Context.MODE_PRIVATE).edit().clear().commit(); |
Cependant, au lieu de tenter d'essuyer les anciennes données et d'espérer que cela fonctionne, il est préférable de les crypter en premier lieu ! Cela est particulièrement vrai en général pour les lecteurs à semi-conducteurs qui étalent souvent l'écriture de données dans différentes régions pour éviter l'usure. Cela signifie que même si vous remplacez un fichier dans le système de fichiers, la mémoire SSD physique peut conserver vos données dans leur emplacement d'origine sur le disque.
1 |
public static void secureWipeFile(File file) throws IOException |
2 |
{
|
3 |
if (file != null && file.exists()) |
4 |
{
|
5 |
final long length = file.length(); |
6 |
final SecureRandom random = new SecureRandom(); |
7 |
final RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rws"); |
8 |
randomAccessFile.seek(0); |
9 |
randomAccessFile.getFilePointer(); |
10 |
byte[] data = new byte[64]; |
11 |
int position = 0; |
12 |
while (position < length) |
13 |
{
|
14 |
random.nextBytes(data); |
15 |
randomAccessFile.write(data); |
16 |
position += data.length; |
17 |
}
|
18 |
randomAccessFile.close(); |
19 |
file.delete(); |
20 |
}
|
21 |
}
|
Conclusion
Cela conclut notre tutoriel sur le stockage des données cryptées. Dans cet article, vous avez appris comment crypter et décrypter de manière sécurisée les données sensibles avec un mot de passe fourni par l'utilisateur. C'est facile à faire quand vous savez la méthode, mais il est important de suivre toutes les bonnes pratiques pour assurer la sécurité des données de vos utilisateurs.
Dans la prochaine publication, nous allons voir comment tirer parti du KeyStore
et d'autres API liées aux informations d'identification pour stocker les éléments en toute sécurité. En attendant, consultez quelques-uns de nos autres articles sur le développement d'applications Android.