Ce billet est issu d'une question posée sur Stack Overflow et relative à la sécurisation de contenus sur téléphones Android.

On a parfois besoin de chiffrer des contenus afin qu'ils ne puissent être copiés, ou simplement visualisés en dehors de l'application à laquelle ils appartiennent. Ces contenus peuvent être créés par l'application elle-même (contenus confidentiels définis par l'utilisateur et protégés par mot de passe, par exemple) et stockés localement, être téléchargés depuis une source externe (fichiers de niveau pour un jeu, questions/réponses pour un quizz, ou encore cartes pour un logiciel de navigation), ou encore être produit par l'application et stockés à distance, sur un serveur, pour être ensuite accédés par téléchargement. Et ces contenus peuvent être de tous types.

Quel algorithme ?

Ce qui est proposé ici, c'est d'utiliser l'algorithme AES. Il s'agit d'un algorithme de chiffrement symétrique, ce qui signifie que la clef utilisée pour chiffrer est la même que celle utilisée pour déchiffrer.

Il existe d'autres techniques de chiffrement. Sans rentrer plus avant dans les considérations liées à la sécurité informatique - un billet n'y suffirait pas, et devrait de toutes façons être écrit par un spécialiste en la matière, ce que je ne suis pas - le choix d'AES, ici, est un choix de compromis:

  • il est simple à mettre en place
  • il couvre l'essentiel des besoins en garantissant un résistance suffisante à une tentative de déchiffrage par force brute. Par résistance suffisante, il faut entendre qu'il est au moins aussi difficile de déchiffrer ce contenu sans en connaître la clé que d'y accéder par d'autres moyens: vol de cette clé, accès à la source avant chiffrement, accès par utilisation de l'application et recopie du contenu, etc - ces moyens dépendent du contexte. Inutile de surprotéger un contenu stocké sur un téléphone mobile ou une tablette si l'on est pas certain que toute la chaîne par lequel ce contenu passe n'offre pas une protection au moins égale.
  • il est rapide. C'est important car l'utilisateur n'accepterait pas, par exemple, d'attendre plusieurs secondes qu'une image enregistrée sur son mobile soit déchiffrée avant qu'elle ne s'affiche.

Quel format de stockage ?

Le chiffrement d'un contenu par l'algorithme AES produit un code binaire, et ce même si le contenu avant chiffrement est en mode texte.

Si les contenus à chiffrer sont générés par l'application et stockés localement, il n'y a pas de question à se poser : ils peuvent être stockés en mode binaire sans que cela ne pose le moindre problème.

Si par contre ces contenus doivent transiter par le réseau (stockage sur un serveur distant, ou téléchargement), on peut souhaiter ou se voir imposer, selon que l'on maîtrise ou non la partie serveur destinée à gérer ces contenus, que cet échange s'effectue en mode texte par HTTP plutôt qu'en mode binaire par FTP ou encore par l'emploi d'une connection directe par socket. Deux raisons à cela : HTTP est très simple à mettre en place côté client, Android offrant tous les outils nécessaires pour ce faire, mais aussi côté serveur, car il est assez simple de construire un service Java ou PHP destiné à gérer l'échange et le stockage de ces contenus sous la forme désirée (système de fichiers, base de données, etc). Nous ne nous intéresserons pas plus avant à la partie transfert dans ce billet[1] mais nous intéresserons aux conversions entre mode texte et mode binaire des contenus chiffrés.

Comment passe-t-on de binaire à texte ?

Pour effectuer ces conversions, base64 est tout indiqué. Cet algorithme permet de convertir n'importe quel flux binaire[2] en un code texte. Ce code résultant est approximativement 30% plus volumineux que sa donnée source, et cette conversion demande un peu de temps machine, mais le base64 a le mérite d'être très simple à mettre en place et d'être un standard.

Android intègre base64 depuis la version 8 de son API et nous verrons dans un prochain billet comment encoder et décoder du base64 en restant compatible API 3.

Côté implémentation, ça donne quoi?

Ça donne ce qui suit:

  • On construit une classe chargée d'effectuer le travail de base, à savoir le chiffrement et le déchiffrement:
public class AESEncrypter {
        // Notre algorithme de chiffrement est AES, et notre 
        // générateur de nombre aléatoire utilisera l'algorithme
        // SHA1PRNG, seul supporté par Android à ce jour.
        // (De quoi s'agit-il ?? On y vient plus bas):
        private static final String CIPHER_ALGO = "AES"; 
        private static final String RANDOM_ALGO = "SHA1PRNG";
 
        // On utilise une clef de 128 bits. AES admet des clefs plus longues
        // mais pas forcément disponibles sur tous systèmes. 
        // 128 est toujours disponible et suffit pour la majorité des usages,
        // eu égard à ce que l'on a vu au début de ce billet:
        private static final int KEY_SIZE = 128; 
 
        // Cette méthode à pour objet de chiffrer les contenus.
        // Elle reçoit le mot de passe désiré et le contenu 
        // à chiffrer (clear), sous forme de tableaux d'octets.
       private static byte[] encrypt(byte[] password, byte[] clear) 
                   throws AESEncrypterException {
            try {
            	// On crée une spécification de clef à partir de
                // notre mot de passe. (On y revient plus bas).
                final SecretKeySpec skeySpec = getKeySpec(password);
 
                // On crée également un outil de chiffrement AES, et
                // on l'initialise avec le mode souhaité (ici ENCRYPT)
                // et avec notre spécification de clef.
                final Cipher cipher = Cipher.getInstance(CIPHER_ALGO);
                cipher.init(Cipher.ENCRYPT_MODE, skeySpec);
 
                // Et on retourne notre contenu chiffré:
                return cipher.doFinal(clear);
 
            } catch (Exception e) {
                // On ne devrait pas non plus arriver
                // ici, sauf à passer une clef raw invalide
                throw new AESEncrypterException(e);
            }
        }
 
        // Cette méthode a pour objet de déchiffrer un
        // contenu.
        // Elle reçoit le mot de passe et le contenu chiffré en AES
        private static byte[] decrypt(byte[] password, byte[] encrypted) 
                   throws AESEncrypterException {
            try {
                // L'opération est la même que dans la méthode encrypt()...
            	final SecretKeySpec skeySpec = getKeySpec(password);
                final Cipher cipher = Cipher.getInstance(CIPHER_ALGO);
 
                // ... si ce n'est que l'on est en DECRYPT_MODE
                cipher.init(Cipher.DECRYPT_MODE, skeySpec);
 
                return cipher.doFinal(encrypted);
 
            } catch (Exception e) {
                // Si l'on se trompe de mot de passe, la
                // clef AES résultante est invalide. 
                // On arrive alors ici !
                throw new AESEncrypterException(e);
            }
        }
 
        // Cette méhtode génére une clef AES à partir du mot de passe  
        // désiré. 
        // AES nécessite en effet des clefs de taille bien précise et obéissant
        // à certaines spécifications.
        // Le paramètre passwordest notre mot de passe sous forme de 
        // tableau d'octets.
        private static SecretKeySpec getKeySpec(byte[] password) 
                    throws AESEncrypterException {
 
            try {
                // On crée le générateur de clefs.
                final KeyGenerator kgen = KeyGenerator.getInstance(CIPHER_ALGO);
 
                // On crée un générateur de nombres aléatoires
                final SecureRandom sr = 
                       SecureRandom.getInstance(RANDOM_ALGO);
 
                // On initialise ce dernier avec notre mot de passe, ce qui rend
                // la suite de nombres aléatoires tout à fait prédictible.
                sr.setSeed(password);
 
                // On initialise le générateur de clefs avec ce générateur
                // de nombres aléatoires
                kgen.init(KEY_SIZE, sr); 
 
                // Et on génère une clef. Comme notre mot de passe
                // est utilisé pour initialiser le générateur de nombres
                // aléatoires, lequel initialise le générateur de clef,
                // on a la garantie qu'un même mot de passe génère
                // toujours une même clef AES.
                final SecretKey skey = kgen.generateKey();
 
                // On retourne notre clef sous forme exploitable
                return new SecretKeySpec(skey.getEncoded(), CIPHER_ALGO);
            } catch (Exception e) {
                // On ne devrait jamais arriver ici sur Android : les paramètres
                // CIPHER_ALGO, RANDOM_ALGO et KEY_SIZE ont
                // été choisis car ils existent dans toutes les versions de cet OS
                throw new AESEncrypterException(e);
            }
        }
   }

Pour que cette classe compile, on aura besoin de lui adjoindre cette exception:

public class AESEncrypterException extends Exception {
        public AESEncrypterException (Throwable e) { super(e); }
    }

On a maintenant notre classe de base AESEncrypter, encore dépourvue de méthodes publiques. Il s'agit pour le moment de notre moteur de chiffrement/déchiffrement : tout ce qui suit ne sera que de la gestion de fichiers ou de base64.

On voit dans ce code que l'on utilise un générateur de nombres aléatoires de façon... à le rendre déterministe. C'est simplement un moyen technique d'associer, pour tout mot de passe donnée, une valeur pouvant être utilisée comme source valide par le générateur de clef.

On peut également noter que les mots de passe sont passés sous forme binaire à getKeySpec(). C'est qu'ils sont gérés ainsi nativement. Les mots de passe texte ne sont que des cas particuliers ayant la particularité d'être rédigés en Unicode.

  • Maintenant, ajoutons des méthodes publiques à notre AESEncrypter afin de pouvoir chiffrer et déchiffrer des contenus stockés localement sous forme de fichiers ou à distance sous forme de base64:
...
 
        // Cette méthode chiffre un contenu binaire et stocke le résultat
        // dans un fichier. Elle reçoit le mot de passe désiré, le contenu
        // sous forme binaire, et le fichier de destination.
        public static void encryptBytesToBinaryFile(String password, 
                  byte[] bytes, 
                  File file) 
                  throws AESEncrypterException {
 
            try {
                // On ouvre notre fichier de sortie en écriture
                final FileOutputStream ostream = new FileOutputStream(file, false);
 
                // Et on y écrit notre contenu après l'avoir chiffré
                ostream.write(encrypt(password.getBytes(), bytes));
 
                ostream.flush();
                ostream.close();
 
            } catch (IOException e) {
                throw new AESEncrypterException(e);
            }
        }
 
        // Cette méthode déchiffre un contenu binaire et retourne le résultat
        // binaire déchiffré.
        public static byte[] decryptBytesFromBinaryFile(String password, File file) 
                  throws AESEncrypterException {
 
            try {
                // On ouvre notre fichier d'entrée et on crée
                // un tableau d'octets de la taille de ce fichier (AES
                // préserve la longueur des contenus)
                final FileInputStream istream = new FileInputStream(file);
                final byte[] buffer = new byte[(int)file.length()];
 
                // On lit notre contenu chiffré
                istream.read(buffer);
 
                // Et on retourne son déchiffrement
                return decrypt(password.getBytes(), buffer);
 
            } catch (IOException e) {
                throw new AESEncrypterException(e);
            }
        }
 
        // Cette méthode chiffre un contenu binaire et retourne
        // le résultat chiffré sous forme de base64
        public static String encryptBytesToBase64(String password, byte[] bytes) 
                  throws AESEncrypterException {
 
            // Ici, au lieu de stocker le résultat chiffré dans un fichier
            // on le retourne après l'avoir convertir en texte base 64
            return Base64.encodeToString(encrypt(password.getBytes(), 
                                                 bytes), 
                                                 Base64.DEFAULT);
        }
 
       // Cette méthode déchiffre un contenu binaire préalablement 
       // chiffré et converti en base 64 
       public static byte[] decryptBytesFromBase64(String password, 
                 String encrypted) 
                 throws AESEncrypterException {
 
            try {
                final byte[] enc = Base64.decode(encrypted, Base64.DEFAULT);
 
                return decrypt(password.getBytes(), enc);
 
            } catch (IllegalArgumentException e) {
                throw new AESEncrypterException(e);
            }
        }

Ici, les mots de passe sont des chaînes de caractères. Mais vous pouvez tout à fait modifier ces méthodes de façon à utiliser des mots de passe binaires. Il suffit pour cela de modifier le type du paramètre password en byte et de le passer directement aux méthodes encrypt() et decrypt().

Ajoutons maintenant quelques méthodes de confort permettant de gérer nativement des contenus texte plutôt que binaires:

...
        // Cette méthode chiffre un contenu texte et stocke le résultat
        // dans un fichier. Elle reçoit le mot de passe désiré, le contenu
        // sous forme binaire, et le fichier de destination.
        public static void encryptTextToBinaryFile(String password, 
                  String cleartext, 
                  File file) 
                  throws AESEncrypterException {
 
            encryptBytesToBinaryFile(password, cleartext.getBytes(), file);
        }
 
        // Cette méthode déchiffre un contenu texte depuis un fichier chiffré
        public static String decryptTextFromBinaryFile(String password, File file) 
                  throws AESEncrypterException {
 
            return new String(decryptBytesFromBinaryFile(password, file));
        }
 
        // Cette méthode chiffre un contenu texte et retourne
        // le résultat chiffré sous forme de base64
        public static String encryptTextToBase64(String password, String cleartext) 
                  throws AESEncrypterException {
 
            return encryptBytesToBase64(password, cleartext.getBytes());
        }
 
        // Et cette méthode, enfin,  déchiffre un contenu texte 
        // préalablement chiffré et converti en base64
        public static String decryptTextFromBase64(String password, 
                  String encrypted) 
                  throws AESEncrypterException {
 
            return new String(decryptBytesFromBase64(password, encrypted));
        }
    }

Et voilà, notre classe de chiffrement / déchiffrement est complète[3]. Le code source correspondant est disponible en téléchargement à la fin de ce billet.

Nous pouvons l'utiliser comme suit:

  • Pour les contenus locaux (fichiers binaires)
try {
 	// Chiffrement / déchiffrement de contenu local (fichier binaire):
        final File file = getFileStreamPath(<nom_de_votre_fichier>);
 
        AESEncrypter.encryptTextToBinaryFile(<mot_de_passe>,<texte>, file); 
 
        ...
 
        final String text = AESEncrypter.decryptTextFromBinaryFile(<mot_de_passe>, file);
 
        ...
} catch (AESEncrypterException e) {
        // Attention ! Utiliser un mauvais mot de passe 
        // au déchiffrement vous emmène ici !
        ...
}
  • Pour les contenus base64 (chaînes base64)
try {
        // Chiffrement / déchiffrement de contenu distant (base64):
        final String aes = 
               AESEncrypter.encryptTextToBase64(<mot_de_passe>, <texte>);
 
        ...
 
        final String text = 
               AESEncrypter.decryptTextFromBase64(<mot_de_passe>, aes);
 
        ...        	
} catch (AESEncrypterException e) {
        // Attention ! Utiliser un mauvais mot de passe
        //  au déchiffrement vous emmène ici !
	...
}

Et quel mot de passe, alors ?

Si vos contenus sont générés par l'utilisateur et pour son seul usage, un mot de passe entré par ses soins convient parfaitement.

Si par contre il revient à l'application d'administrer le mot de passe, ou d'utiliser un mot de passe utilisé pour déchiffrer des contenus téléchargés sous forme chiffrée, il vaut mieux éviter un mot de passe simplement codé en dur (sous forme de private static final String, par exemple) car même après obfuscation par ProGuard ceux-ci sont assez simples à retrouver. Il vaut mieux utiliser le résultat d'une méthode ou d'un calcul déterministe, ou encore le nom d'une méthode ou d'une classe obtenu par introspection car ceci est plus difficile à retrouver en décompilant l'application.

"Plus difficile" ne signifie pas impossible, mais selon le même raisonnement que plus haut, on peut considérer ceci comme étant suffisant dans la majorité des cas. L'idée, ici, est de proposer une solution simple destinée à rendre les contenus suffisamment difficiles à déchiffrer pour que cela soit décourageant et n'en vaille pas la peine. Pas de les rendre inviolables.

Notes

[1] Message caché : nous en reparlerons.

[2] Et a fortiori texte, mais ce n'est pas l'objet ici : on s'intéresse au code AES binaire à échanger au travers du réseau.

[3] Navré pour la mise en forme de ce code... Si quelqu'un a des suggestions de plug-in de formattage de code sous DotClear...