Déchiffrement sécurisé de gros fichiers en Go

L’on dit souvent qu’il ne faut pas créer son propre algorithme cryptographique. Qu’en cryptographie, il ne faut pas faire preuve d’originalité ; que la sécurité est dans les sentiers battus, et en particulier ceux qui le sont par les cryptographes. Ce qu’on oublie souvent de dire, c’est qu’utiliser la cryptographie est aussi périlleux. Dans cet article, nous allons discuter du traitement des gros fichiers lors du déchiffrement, et de comment le faire de manière sécurisée sur un système Linux. Cet article sera illustré par du code Go.

Qu’est ce qu’un “gros fichier” et en quoi cela est-il différent des “petits fichiers” ?

Un petit fichier est tout ce qu’il est raisonnable de stocker en RAM, en intégralité. La RAM est un espace sanctuarisé où un programme peut stocker de l’information. Cette sanctuarisation offre avec une garantie relativement forte d’isolation contre les interférences provenant d’autres tâches/processus.

À l’inverse, un gros fichier est un fichier qui ne peut être stocké dans son intégralité dans la RAM du système qui va le déchiffrer. Dès lors des interférences peuvent survenir. Elles sont de plusieurs ordres.

Les modifications de la donnée chiffrée

Le premier problème est que la donnée chiffrée peut être modifiée par un programme externe. En effet, dès lors qu’un fichier est présent dans un répertoire, tout programme tournant avec les privilèges nécessaires peut ouvrir un descripteur de fichiers (file descriptor) sur ce fichier. Grâce à ce descripteur de fichiers, il est alors possible de modifier le fichier. Cela inclut les programmes tournant avec le même niveau de privilèges que le programme qui est en charge du déchiffrement.

Ce n’est pas forcément un problème. L’état de l’art en matière de cryptographie appliquée est d’utiliser du chiffrement authentifié. Ce type de chiffrement vérifie l’intégrité cryptographique du contenu chiffré, en même temps qu’il le déchiffre. Certains algorithmes et modes de chiffrement, comme AES-GCM ou AES-OCB, effectuent ces opérations en “une passe”. C’est-à-dire que la vérification de l’authenticité est effectuée au fur et à mesure du déchiffrement et un verdict d’authenticité est donné à la fin du déchiffrement. Hélas, tous les modes de chiffrement ne sont pas en une passe ; ainsi AES-CCM, par exemple, va d’abord effectuer une vérification en intégrité, puis effectuer le déchiffrement si la donnée a initialement été déterminée comme authentique. Hélas, un attaquant est alors en mesure d’altérer la donnée entre la première et la deuxième passe. Le contenu déchiffré est alors déclaré authentique, alors que ce dernier a été modifié.

En conséquence, lorsqu’un mode de déchiffrement en une passe n’est pas employé, il est nécessaire d’utiliser une copie privée de la donnée, que ce soit en RAM ou par d’autres astuces systèmes qui seront détaillées ultérieurement dans ce document.

Utilisation anticipée d’une donnée dont l’authenticité n’est pas encore assurée

Lorsque les données déchiffrées sont trop grandes pour être contenues en RAM, il est nécessaire de les écrire sur disque. Hélas, des problèmes de sécurité peuvent survenir lors de ce passage sur disque.

La liste de ces problèmes ne saurait être exhaustive, mais il est possible de penser notamment à certains logiciels qui utiliseraient la donnée de manière anticipée. En effet, certains logiciels surveillent le contenu d’un dossier avec inotify(7) ; c’est le cas de la plupart des explorateurs de fichiers.

Cette lecture anticipée peut résulter en des interprétations incorrectes du fichier. Ce n’est cependant pas la conséquence la plus funeste. Dans le cas d’un chiffrement en une passe, ou d’une vérification d’intégrité effectuée après le déchiffrement (c.f. la section de ce document relative à OpenPGP), il est possible que le fichier contenant le déchiffré intègre du code malveillant ajouté par un attaquant ayant corrompu le document chiffré. Il est, en effet, capital de comprendre que le chiffrement seul n’est pas suffisant pour garantir l’intégrité d’une donnée, et qu’il est nécessaire d’utiliser un motif d’intégrité, et de le vérifier, avant de faire usage du déchiffré. S’il est fait usage de la donnée déchiffrée avant que le verdict d’authenticité ne soit rendu, alors il est possible d’exploiter une vulnérabilité avec un document non-authentique.

En conséquence, il est indispensable que le déchiffré reste privé, jusqu’à réception du verdict d’authenticité. Lorsqu’il est stocké en RAM, c’est chose aisée, mais lorsqu’il doit être stocké sur le disque, dans le cas des gros fichiers, alors il est nécessaire d’exploiter quelques stratégies défensives, discutées plus bas dans ce document.

Réduction de la taille par fragmentation

Une stratégie possible pour chiffrer/déchiffrer un gros fichier peut être de le découper en tronçons qui tiendront en RAM.

Il pourrait être tentant de regarder du côté des modes de chiffrement utilisés pour le chiffrement de vastes quantité de données, comme XTS. En quelques mots, XTS effectue un chiffrement/déchiffrement à l’aide d’une unique clé, mais d’un procédé de chiffrement qui est ajusté (“tweaked”) à chaque unité logique. XTS est souvent employé pour le chiffrement de disques, et dans ce cas, l’unité logique est le “secteur” du disque. Sous Linux, il est possible de faire usage de XTS notamment avec dm-crypt et sa surcouche LUKS.

XTS présente un avantage assez intéressant pour le chiffrement de disques. En effet, l’élément qui permet de paramétrer l’ajustement (tweak) pour chaque unité logique est une donnée extérieure et intrinsèque du disque : la position du secteur sur le disque. Cette subtilité fait qu’il n’est pas nécessaire de stocker une donnée supplémentaire (les paramètres d’ajustement) pour chaque secteur. Il n’y a donc pas de dépense de stockage pour des données liées au chiffrement !

Hélas, XTS offre une protection en intégrité limitée. Certes, si l’on prend un secteur chiffré et qu’on le déplace dans un autre secteur du disque, les paramètres d’ajustement de l’algorithme vont être incorrects. En effet, si un secteur doit être déchiffré en tant que secteur X, et qu’on tente de le déchiffrer en tant que secteur Y, alors les paramètres de l’algorithme seront incorrects, et le déchiffré n’aura aucun sens. Cependant XTS ne protège pas contre le remplacement du secteur X par une version antérieure du secteur X, par exemple.

En outre, en découpant le disque en secteurs chiffrés, chacun avec un paramètre différent, il n’existe pas de protection contre la troncature. Si l’on prend un disque de taille X et qu’on le copie sur un disque de taille Y, avec Y < X, l’algorithme cryptographique ne détectera pas qu’il manque de la donnée.

Des erreurs ou contraintes des modes de chiffrement de disques, plusieurs leçons peuvent être tirées. Si l’on dispose d’une donnée à chiffrer/déchiffrer qui est trop grande pour être stockée en RAM, et que la solution envisagée est de découper cette donnée en tronçon, alors il faut veiller à mettre en place une intégrité forte des tronçons.

Cette intégrité doit assurer que :

  1. chaque tronçon ne peut être modifié individuellement, même par remplacement avec un tronçon d’un autre message chiffré de taille comparable ;
  2. l’ordre des tronçons ne peut être modifié ;
  3. il n’est pas possible d’ajouter ou de retirer des tronçons sans que l’ensemble du gros fichier ne soit considéré comme invalide.

La problématique numéro 1 peut être aisément résolue en utilisant une clé de chiffrement/déchiffrement distincte par fichier chiffré, en combinaison avec un algorithme et un mode de chiffrement qui permette d’obtenir un chiffré authentifié, comme AES-GCM.

La problématique 2 peut être aisément résolue en ajoutant un compteur à chaque bloc de données chiffré. Cela représente un surcoût de stockage qu’il est possible de payer quand on parle de chiffrement de fichiers et non de chiffrement de disques.

Il pourrait être envisageable de créer un mode de chiffrement ajustable qui soit également authentifié, par exemple en combinant XTS et un HMAC. Hélas, la conséquence serait que les opérations cryptographiques seraient en deux passes (XTS puis HMAC), ce qui représente un surcoût potentiellement inutile si une meilleure solution est disponible (et c’est le cas ; voir ci-dessous :)).

En outre, XTS + HMAC ne protègerait pas contre la problématique 3). En effet, pour protéger contre cette dernière, une méthode est d’ajouter la quantité de tronçons attendue en meta-données du fichier. Cette quantité devrait être protégée en intégrité. Cette méthode n’est pas originale ; elle est utilisée dans la construction cryptographique Merkle-Damgård, et est employée notamment par les algorithmes de hachage SHA.

Tous ces ajouts sont autant de manière de se tromper lorsqu’on réalise les étapes de chiffrement et de déchiffrement. Or, comme dit en chapô de cet article, sortir des sentiers battus est souvent synonyme de vulnérabilité.

Dès lors, il serait préférable de ne pas réinventer la roue, et d’employer des mécanismes et des bibliothèques cryptographiques bien connues pour résoudre notre problématique de gros fichiers.

Les bibliothèques cryptographiques

En Go, il existe diverses bibliothèques cryptographiques haut-niveau qui sont fréquemment employées. Je vais ici parler d’OpenPGP, qui est problématique, et de NACL, qu’il convient de préférer.

OpenPGP

OpenPGP est un standard de chiffrement assez ancien. Son implémentation principale est GnuPG, et il continue d’être la marotte de certains techniciens bien mal avisés. Oui, je pense notamment à vous, les distributions Linux.

Ces mots durs contre ce format sont cependant mérités. OpenPGP est un musée des horreurs, remplis de mécanismes vétustes, et de constructions cryptographiques datant des balbutiements du chiffrement authentifié. Également, et non des moindres, ses implémenteurs semblent avoir une passion pour les mauvaises idées en matière d’API. L’auteur de cet article a d’ailleurs découvert en 2015 des problèmes dans la plupart des implémentations d’OpenPGP, et certaines, en 2022, sont toujours vulnérables à ces découvertes… dont GnuPG.

En Go, sans surprise, l’implémentation d’OpenPGP contient elle aussi de mauvaises idées. Le package a même été gêlé et déprécié, avec le commentaire qu’il n’est pas souhaitable que les développeurs Go emploient OpenPGP, ce format étant “fragile, complexe, non sûr, et […] son usage expose les développeurs à un écosystème dangereux”. Pour enfoncer le clou, nous allons procéder à une étude de l’une de ses problématiques.

S’il est vrai qu’il est assez universel que des sources de données implémentent io.Reader, il est possible de s’interroger sur la pertinence de ce choix pour une source de données chiffrée dont l’intégrité ne peut être vérifiée qu’après une passe complète.

L’on pourrait alors s’attendre à ce que le containeur OpenPGP openpgp.MessageDetails effectue cette vérification de lui-même lors de son instanciation avec openpgp.ReadMessage. Cela serait assez cohérent avec l’API de encoding/gzip dont la fonction NewReader renvoie une erreur en l’absence d’octets “magiques” en début de lecture. Hélas, comme dit précemment, OpenPGP est un musée des horreurs, et il n’est pas possible de vérifier l’intégrité du chiffré ; il est nécessaire de déchiffrer l’intégralité du document chiffré, pour récupérer enfin un motif d’intégrité. En effet, avec le standard OpenPGP, le motif d’intégrité (un simple SHA-1 du clair) fait partie des données chiffrées, et est suffixé au clair. Cette approche est appelée MAC-then-encrypt et est décriée par la communauté cryptographique.

Bien que le io.Reader de openpgp.MessageDetails soit stocké dans le bien-nommé champ UnverifiedBody, il est extrêmement tentant pour un développeur de le “brancher” dans un autre io.Reader, à la manière d’une série de décorateurs, et d’oublier ou de découvrir trop tard que le message n’était pas intègre !

NACL

NACL est une excellente bibliothèque cryptographique, dont l’API, bien conçue, ne permet qu’aux plus entêtés des idiots de se tromper dans son emploi. Il existe quelques outils en ligne de commande pour l’exploiter, elle ou sa variante (fork) libsodium. On peut notamment citer l’excellent utilitaire minisign, par Frank Denis. L’auteur de cet article recommande vivement minisign comme remplacement à OpenPGP pour la signature de documents !

Il existe des implémentations de minisign en Go, dont go-minisign, qui hélas souffre du même problème de gestion des gros fichiers qui nous occupe dans cet article. Fort heureusement il est possible d’exploiter go-minisign y compris pour des gros fichiers en utilisant les astuces qui sont présentées dans le présent article, plus bas.

Pour en revenir à NACL, les fonctions box.Seal et box.Open ont pour particularité de ne pas recevoir d’io.Reader et d’écrire dans des io.Writer. Elles ne tombent donc pas dans le piège grossier au fond duquel on trouve OpenPGP. Ces fonctions utilisent des slices de bytes. Cela pourrait ressembler à un point bloquant. Cet article vise précisément à proposer une solution pour contourner cette particularité, tout en offrant un niveau de sécurité correct.

Astuces systèmes à la rescousse

Contrôler la mise à disposition des données

Comme vu en début d’article, il est important de maitriser le moment où les données déchiffrées sont publiées ; tant que ces dernières ne sont pas complètes ou pas vérifiées, la copie de travail de ces données doit rester privée. Vu que nous traitons des gros fichiers, qui ne tiennent pas en RAM, il est nécessaire de stocker la copie de travail sur le système de fichiers, tout en s’assurant que nul autre processus ou tâche ne puisse y avoir accès. Pour ce faire, il est possible d’utiliser les fichiers anonymes.

Les fichiers anonymes sont des fichiers qui sont stockés sur le système de fichiers, sans qu’aucun lien n’existe vers ces derniers. Par lien, ici, il faut comprendre lien dans le sens “entrée dans un répertoire” (hardlink). Ces fichiers sont créés en spécifiant l’option O_TMPFILE au syscall open(2). Tout octet écrit dans un tel fichier est effectivement stocké sur le système de fichiers, par l’intermédiaire du descripteur de fichier renvoyé par open(2) et connu uniquement du programme qui l’a créé (et des processus qui vont farfouiller dans /proc… mais ils cherchent les problèmes ;)). Il s’agit donc d’une copie privée du déchiffré. Lorsque le fichier est complet et son contenu vérifié, il est alors possible de le publier de différentes manières.

Une manière de publier le fichier d’une manière peu élégante est simplement de créer un nouveau fichier, sans l’option O_TMPFILE, puis de recopier le contenu du fichier déchiffré dans ce nouveau fichier qui est accessible par les autres processus. Le descripteur de fichiers peut alors être fermé, et le fichier anonyme sera automatiquement libéré. Cette méthode est couteuse et présente le défaut de doubler la taille disque nécessaire pour stocker le déchiffré, au moins temporairement, jusqu’à la fermeture du descripteur de fichier du fichier anonyme.

Une manière plus élégante, qui tire cependant partie d’une fonctionnalité qui n’est pas toujours disponible, est d’utiliser FICLONE du syscall ioctl(2). FICLONE utilise la fonctionnalité de copy-on-write (COW) de certains systèmes de fichiers, comme btrfs. Avec ce syscall, il est possible d’ouvrir un fichier ayant un lien (hardlink) puis de demander que le fichier nommé soit une copie instantanée du fichier anonyme. Les deux fichiers partageront alors les mêmes blocs de données sur le système de fichiers, jusqu’à ce que l’un d’entre eux modifie un bloc. Mais en l’espèce, il n’y aura pas d’écriture ultérieure dans le fichier anonyme, après cet appel à ioctl(2). Il s’agit donc simplement d’une astuce qui permet de créer un lien vers le contenu du fichier anonyme, et donc de le publier. Le seul défaut de cette approche est qu’il faut utiliser un système de fichier compatible avec FICLONE, et ce n’est notamment pas le cas de ext4, qui est généralement le système de fichiers par défaut des distrubutions Linux.

Finalement, il existe une troisième méthode, elle aussi élégante, qui ne tire par partie d’une fonctionnalité particulière de certains systèmes de fichiers. Hélas, il est nécessaire de disposer de certains privilèges système pour ce faire : CAP_DAC_READ_SEARCH. CAP_DAC_READ_SEARCH permet de contourner cette protection du système de fichiers, ce qui est regrettable, car il s’agit aussi du privilège requis pour appeler le syscall linkat(2), avec l’option AT_EMPTY_PATH. Ce syscall cumulé à cette option permet de créer un lien à partir d’un descripteur de fichiers. Il permet donc de donner un nom à notre fichier anonyme, une fois celui-ci complet. Il peut être acceptable de donner CAP_DAC_READ_SEARCH à notre processus, si ce dernier est exécuté dans un chroot dans lequel cette permission ne permet pas au programme de gagner ou conserver des accès indûs à des ressources du système. Cette solution est donc probablement acceptable dans certaines conditions, qui doivent cependant être bien maitrisées.

Travailler sur une copie inaltérable du chiffré

Dans le cas d’un procédé de déchiffrement en deux passes, une pour la vérification d’intégrité, et une pour le déchiffrement en lui-même, il est possible d’utiliser les mêmes mécanismes que détaillés dans la section précédente de cet article pour obtenir une copie privée : créer un fichier anonyme dont le contenu sera alimenté par recopie (avec un surcoût en espace disque) ou le syscall ioctl(2) avec FICLONE pour obtenir une copie instantanée et qui ne pourra être modifiée par des processus ou taches tiers.

Déchiffrer un gros fichier en mémoire virtuelle

Tout ce qui est dans la mémoire virtuelle n’est pas nécessairement de la mémoire physique. Ainsi, il est possible d’obtenir une slice Go comportant le contenu d’un fichier, sans que celui-ci ne soit lu et recopié en RAM. De même, il est possible d’écrire dans une slice, qui n’est pas stocké en RAM, grâce à un syscall : mmap(2). Il nous est donc possible d’appeler box.Seal et box.Open sur de telles slices, et le résultat aura été calculé sans que le contenu des fichiers ne soit stocké en intégralité en RAM !

Hélas, les choses ne sont jamais aussi “simples”. Il a quelques petites subtilités supplémentaires, requises lorsqu’on effectue cette opération d’écriture dans une slice pointant vers un fichier placé en mémoire virtuelle avec mmap(2). D’une part, il est nécessaire que le fichier de destination soit de la bonne taille avant l’appel à mmap(2). Pour cela, on peut créer un fichier creux (sparse file), à l’aide de l’appel système fallocate(2). Ensuite, une fois l’écriture dans la slice accomplie, il est nécessaire d’appeler le syscall msync(2) afin de forcer le transfert de données depuis la mémoire virtuelle vers le fichier, avant d’effectuer l’appel à munmap(2), pour “défaire” la slice créée par mmap(2).

De même, mmap(2) est utilisé pour le fichier chiffré, mais il y a là aussi quelques subtilités. Il est notamment préférable de travailler sur une copie privée du fichier, plutôt qu’un fichier altérable par une source externe. En effet, le comportement n’est pas spécifié si un programme externe tronquait le fichier après que ce dernier ait été passé à mmap(2).

Enfin, lorsqu’on passe la slice recevant les données déchiffrées à box.Open, il convient de passer cette slice découpée avec [:0], afin de garder la capacité de la slice égale à la taille du fichier, mais de forcer sa longueur à 0. Ce faisant, box.Open ne procèdera pas à des réallocations du tableau qui sous-tend la slice. Il est, en effet, capital d’user de cette astuce, afin de continuer de travailler dans la mémoire virtuelle retournée par mmap(2) et ne pas se retrouver à travailler accidentellement en RAM.

Rassembler tout en un programme cohérent

Pour résumer tout ce qui a été abordé dans cet article :

  • pour traiter un gros fichier, il est préférable d’utiliser efficacement Linux que de risquer de créer des vulnérabilités en essayant de tronçonner le fichier ;
  • il est important de toujours travailler avec des copies privées des données, que ce soit le contenu chiffré et le contenu déchiffré ;
  • il est nécessaire d’avoir un contrôle sur la publication du déchiffré, notamment pour s’assurer que le contenu est intègre, et complet avant de le rendre accessible à des applications tierces.

Pour accomplir ces objectifs, il est possible d’exploiter les syscall Linux mmap(2), fallocate(2), msync(2), et ioctl(2) ou linkat(2).

Ce dépôt git contient un exemple d’algorithme mettant tous ces éléments en oeuvre, pour déchiffrer un gros fichier, de manière sécurisée.