Calcul de hachage pendant la lecture d’un fichier

Le hachage (md5, sha1…) est très couramment utilisé en transmission de fichier, pour vérifier que les données n’ont pas été corrompues entre leur production et leur consommation. Si le poids du fichier est conséquent, il est préférable de calculer le hachage à la volée plutôt que de parcourir le fichier plusieurs fois (pour le hachage, puis pour consommer les données). Cela offre un gain de temps non négligeable sur un contenu de plusieurs gigaoctets. C’est aussi une contrainte lorsque le flux est en provenance du réseau: cela évite de devoir le stocker sur le disque avant de le traiter.

Heureusement, il aurait été difficile de faire plus simple que ce qui est déjà proposé par .NET avec la classe Stream et l’API de chiffrement. La solution ci-dessous fonctionne d’ailleurs aussi bien lors de la production des données (écriture) que de la consommation (lecture).

Mise à jour du 6 avril 2016: Noter qu’il existe déjà une classe standard de .NET qui implémente ce qui est présenté dans cet article. On préférera généralement utiliser la classe standard CryptoStream sauf si les performances sont vraiment critiques: en effet CryptoStream est optimisé pour la transformation du flux et non le simple calcul de hachage (la transformation est susceptible de modifier la taille du flux, le hachage non). Pour cela, il maintient un buffer interne qui ralentira légèrement le parcourt du flux avec un hachage. Bien que je n’ai pas pris le temps de publier mes résultats ici, j’ai trouvé qu’il était plus efficace de définir un buffer relativement important (512 Ko) sur le flux de base que l’on utilise pour le calcul du hachage, tel que décrit à la fin de cet article.

Décoration de la classe Stream

L’astuce consiste à créer un wrapper autour de la classe Stream afin de décorer la méthode responsable de la lecture des données (et/ou celle responsable de l’écriture). Les autres méthodes surchargées font appel directement au Stream encapsulé.

C’est d’une simplicité remarquable et cela permet d’utiliser les nombreuses API basées sur la classe Stream. On remarquera que le choix de l’algorithme de hachage est laissé au constructeur de notre nouvelle classe:


using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;

class HashedStream : Stream
{
	private readonly Stream _baseStream;
	private readonly HashAlgorithm _hashAlgo;
	private bool _finalized;

	public byte[] Hash
    {
        get
        {
            return _hashAlgo.Hash;
        }
    }

	public HashedStream(Stream stream, HashAlgorithm hashAlgo)
    {
		_baseStream = stream;
		_hashAlgo = hashAlgo;
    }

	public override int Read(byte[] buffer, int offset, int count)
	{
		const int emptyCount = 0;
		int bytesRead = _baseStream.Read(buffer, offset, count);
		if (bytesRead == emptyCount)
		{
			if (!_finalized)
			{
				_hashAlgo.TransformFinalBlock(buffer, 0, 0);
				_finalized = true;
			}
			return emptyCount;
		}
		_hashAlgo.TransformBlock(buffer, 0, bytesRead, buffer, 0);
		return bytesRead;
	}

    // other Stream abstract's methods [...]
}

J’ai simplifié cet exemple pour le limiter à la lecture.
Une implémentation complète avec prise en charge de l’écriture est disponible ici.

La seule subtilité que l’on pourra remarquer est l’utilisation de HashAlgorithm: on invoque TransformBlock() sur chaque bloc de données lues et on doit finaliser le hash en appelant TransformFinalBlock. L’astuce est qu’on peut finaliser le hash avec un bloc vide.

On peut être supris par la méthode TransformBlock() qui prend le buffer de données en entrée et qui prend un buffer de sortie. On a spécifié le buffer d’entrée dans les deux arguments. C’est parce que cette méthode est l’implémentation de l’interface ICryptoTransform. Cette interface est prévue à la fois pour du hachage (via la classe abstraite HashAlgorithm) et pour du chiffrement (par exemple, SymmetricAlgorithm.CreateEncryptor() retourne ICryptoTransform).
Dans le cas du chiffrement, le buffer de sortie contiendrai les données chiffrées. Comme nous utilisons HashAlgorithm, le buffer de sortie contiendra une copie des données en entrée. Et comme l’implémentation de HashAlgorithm est généralement faite de manière intelligente, la copie n’a même pas lieu si le buffer d’entrée et de sortie sont la même référence. On peut également passer null mais c’est moins élégant (rien ne dit dans la documentation que c’est toléré).

Utilisation


byte[] expectedHash; // filled with hash to verify
string path = "path of file";
using (FileStream fs = File.OpenRead(path))
using (var md5 = MD5.Create())
using (var hashedStream = new HashedStream(fs, md5))
using (var reader = new StreamReader(hashedStream))
{
	string line;
	while ((line = reader.ReadLine()) != null)
	{
		// [...]
	}
	if (!expectedHash.SequenceEqual(hashedStream.Hash))
		throw new InvalidDataException(string.Format("The file {0} is corrupted.", path);
}

En général, le fichier est également compressé, et le hachage correspond aux données non compressées. On peut donc ajouter un flux intermédiaire pour une décompression à la volée grâce à GZipStream. Notre FileStream serait encapsulé par GZipStream lui-même encapsulé par HashedStream.

Optimisation du buffer

Pour de meilleures performances, il pourra être intéressant de jouer sur la taille du buffer de StreamReader. De manière générale, la taille conseillée est un multiple de 4096 ou 8192 octets en fonction de la taille des blocs du système de fichier. Un exemple simplifié: si le système lit des blocs de 8ko sur le disque et qu’on utilise un buffer de 5ko pour lire deux blocs successifs de 10ko, le système va devoir lire réellement 16ko (il aura jeté 3ko à chaque lecture).

C’est pour l’aspect lecture. Ensuite, pour optimiser le hachage, je vous conseille d’augmenter ce buffer afin que chaque appel à TransformBlock() traite un volume conséquent de données, plutôt qu’un faible volume. Lors de mes tests, j’ai trouvé qu’un buffer de 512Ko était efficace mais cela peut varier en fonction de nombreux facteurs. Dans mon cas, la lecture d’un fichier de 5Go avec un buffer de 512ko dure 48 secondes (contre 1 minute 25 secondes en séparant le calcul du hachage de la lecture). Alors qu’avec un buffer plus conventionnel de 8ko, le même traitement durait 56 secondes (aucun changement pour le calcul séparé de la lecture). Le plus simple est d’augmenter la taille du buffer tant qu’on observe un gain de performances. À partir d’une certaine capacité, on n’observera plus de gain significatif.

Avec un buffer adéquat, on a réduit le temps de traitement de 44% comparé au calcul séparé du hachage (et 34% si on laisse le buffer par défaut).

Utilisation de IDisposable avec Stream

Noter enfin que le code ci-dessus aurait pu être allégé car chaque flux encapsulé est libéré par son flux parent par convention, sauf paramétrage contraire dans leur constructeur. On pourrait donc fusionner les quatre using en un seul. Cependant c’est une mauvaise pratique: lorsqu’on fait appel au constructeur d’une implémentation de IDisposable, les bonnes pratiques veulent qu’on le libère explicitement. Si l’implémentation de IDisposable respecte les conventions, il n’y a aucun risque à ce que sa méthode IDisposable.Dispose soit invoquée plusieurs fois (comme ce sera le cas dans le code présenté plus haut). MD5.Create() est un cas particulier: ce n’est pas un constructeur mais une factory qui, dans le cas présent, ne propose pas de méthode de libération. Donc on utilise également using. Si le sujet vous intéresse, vous pouvez également lire ce précédent article: IDisposable, Factory, Composition Root et Best Practices