Vous écrivez un classeur, le chiffrez avec un mot de passe, transmettez le fichier à un collègue, et celui-ci l'ouvre dans Excel. Excel réclame le mot de passe. Votre collègue le saisit, et Excel l'accepte. Jusqu'ici, le chiffrement semble correct. Excel affiche alors une boîte de dialogue indiquant que le fichier est corrompu et ne peut pas être ouvert, ou bien s'ouvre sur une feuille remplie de cellules incompréhensibles. Le mot de passe était correct. Le fichier est pourtant cassé. C'est le mode de défaillance le plus déroutant du chiffrement Office, car l'élément de vérification du mot de passe et les données réelles sont protégés par deux opérations distinctes. Réussir la première ne garantit en rien la réussite de la seconde.
Les deux bugs décrits ici présentaient précisément cette forme. Dans chaque cas, le vérificateur était validé mais pas le corps du document, ce qui oriente les recherches vers un bug de mot de passe ou de dérivation de clé qui n'existe pas. Le problème réel se situait en aval, dans la manière dont les octets du package étaient transformés. Ces deux défauts sont indépendants (l'un sur le chemin AES et l'autre sur le chemin RC4) mais partagent la même difficulté de diagnostic. Il est donc utile de comprendre pourquoi un résultat partiellement correct est le plus difficile à analyser.
Pourquoi un mot de passe validé ne prouve rien sur le corps du document
Le format de fichier XLSX chiffré moderne utilise le chiffrement standard ECMA-376 (Standard Encryption) et stocke deux éléments chiffrés côte à côte. Le premier est l'EncryptionVerifier : un petit bloc contenant une valeur aléatoire et son empreinte (hash), chiffrés avec la clé dérivée du mot de passe. Le second est l'EncryptedPackage : l'intégralité du conteneur zip du classeur, chiffré avec cette même clé. Le vérificateur existe pour qu'un lecteur puisse valider un mot de passe avant de décoder des mégaoctets de données. Déchiffrez le vérificateur, calculez le hash de la valeur aléatoire, comparez-le au hash stocké : s'ils coïncident, le mot de passe est correct.
Le piège est que le vérificateur et le package sont chiffrés par des appels distincts sur des tampons différents. Une clé correctement dérivée déchiffrera correctement le vérificateur, quoi qu'il arrive au package par la suite. Ainsi, si votre dérivation de clé est correcte mais que votre transformation de package est erronée, Excel valide le mot de passe du vérificateur puis échoue sur le corps du document. Le symptôme s'affiche comme "mot de passe correct, fichier corrompu", ce qui oriente l'enquête vers le mot de passe (la seule partie qui fonctionnait). La même séparation s'applique au format RC4 historique : le hash du vérificateur est validé en premier, et un corps de document décalé laisse tout de même cette vérification intacte.
Bug un : l'AES en ECB, pas en CBC
La spécification [MS-OFFCRYPTO] §2.3.4.15 indique que Standard Encryption chiffre le package avec AES en mode Electronic Codebook (ECB). Chaque bloc de 16 octets du package complété est chiffré indépendamment avec la même clé. Il n'y a pas de chaînage entre les blocs ni de vecteur d'initialisation (IV). C'est un choix inhabituel par rapport aux standards modernes, où l'ECB est généralement évité, mais la compatibilité n'est pas un domaine où l'on réécrit les spécifications. Excel déchiffre le package en ECB. Le producteur doit donc le chiffrer en ECB pour s'accorder avec lui.
Le bug résidait dans le fait que le package était chiffré en AES mode CBC en utilisant un vecteur d'initialisation nul. C'est une méthode qui fonctionne presque, et le "presque" est le pire écueil. En CBC, le premier bloc de texte en clair subit un OU exclusif (XOR) avec l'IV avant chiffrement. Lorsque l'IV est nul, ce XOR ne modifie rien, de sorte que le premier bloc du mode CBC avec IV nul produit exactement le même texte chiffré que le mode ECB. À partir du deuxième bloc, le mode CBC injecte le texte chiffré précédent dans le suivant, de sorte que chaque bloc après le premier s'écarte de l'ECB.
Appliquons cela à la structure du fichier. La disposition du package place un préfixe de longueur sur 8 octets en little-endian au tout début, de sorte que les parties du fichier qu'Excel valide en premier se situent dans les deux premiers blocs. Un premier bloc correct permet de valider les premières étapes, tandis que tout le reste du fichier se déchiffre en bruit. La correction est évidente : chiffrer chaque bloc de 16 octets en ECB et supprimer le chaînage. Dans le moteur, XlsEncryptStdPackage parcourt le tampon complété par blocs de 16 octets et appelle AESEncryptECB128Block sur chacun d'eux, qui est la primitive déjà exploitée pour les blocs du vérificateur. Le code source comporte un commentaire au niveau de la boucle rappelant cette règle : CBC avec IV nul ne correspond à ECB que pour le premier bloc, le reste du package se décryptant en données corrompues rejetées par Excel.
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create(nil);
try
Book.Open('report.xlsx');
// SaveAsEncrypted serializes the workbook, then runs the
// ECMA-376 Standard Encryption pipeline: AES-128 ECB over the
// package per [MS-OFFCRYPTO] 2.3.4.15. Returns 1 on success.
if Book.SaveAsEncrypted('report_secure.xlsx', 'S3cret!') <> 1 then
raise Exception.Create('Encryption failed');
finally
Book.Free;
end;
end;
Bug deux : le changement de clé RC4 se décale
Le chemin d'accès historique du format .xls utilise le schéma RC4 CryptoAPI, et sa règle est d'une autre nature. La spécification [MS-OFFCRYPTO] §2.3.6 indique que le chiffrement est réinitialisé à chaque frontière de bloc de 1024 octets. Le flux est divisé en blocs de 1024 octets, une nouvelle clé RC4 est dérivée pour les blocs 0, 1, 2 et ainsi de suite, et au sein de chaque bloc, le flux de clé est consommé de manière continue. Deux invariants doivent être respectés conjointement : réinitialiser la clé à chaque frontière de bloc, et consommer le flux de clé sans interruption à l'intérieur d'un bloc. RC4 étant un chiffrement par flux, sa suite de clé est ordonnée ; le n-ième octet lu est déterminé par les octets lus précédemment. Le déchiffrement consiste à appliquer le même XOR sur la même suite, impliquant que le producteur et le consommateur lisent exactement les mêmes octets aux mêmes positions.
C'est là toute la difficulté. Un chiffrement par flux n'a pas de resynchronisation. Si vous perdez un seul octet de flux de clé, chaque octet suivant subit le XOR du mauvais octet de flux de clé, et l'erreur ne se corrige jamais ; elle se propage jusqu'à la fin du bloc et, la position courante devenant incorrecte, à chaque bloc suivant. C'est exactement ce que faisait ce bug. Le compteur de blocs commençait à une valeur sentinelle de moins un, et la routine d'évitement (skip) supposait que le compteur correspondait déjà au bloc actif. À partir de cette sentinelle, elle réinitialisait la clé et consommait tout un bloc de 1024 octets de flux de clé qui n'aurait jamais dû être lu, et ce faisant, elle rendait le compteur restant négatif. Le déchiffreur se décalait alors d'un bloc entier. Le vérificateur, validé avant tout cela, passait toujours, de sorte que le mot de passe semblait correct tandis que chaque cellule de données devenait illisible.
La logique corrigée se trouve dans TXLSDecrypterRC4. Les fonctions Skip et Decrypt partagent la même boucle : réinitialiser la clé uniquement lorsque la position courante entre dans un nouveau bloc, où l'index du bloc correspond à la position divisée par REKEY_BLOCK_SIZE (1024), puis consommer jusqu'à la fin du bloc courant, pas plus. La fonction MakeKey est appelée avec l'index de bloc réel, jamais avec un index erroné ou sentinelle, et la position progresse du nombre exact d'octets traités afin que Skip et Decrypt restent synchronisés avec le producteur. La leçon est simple : un unique octet perdu n'est pas une petite erreur dans un chiffrement par flux, c'est la perte complète de tout ce qui suit.
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create(nil);
try
// CanReadEncrypted checks the Compound File (OLE2) signature so
// you can branch before attempting a normal Open. OpenEncrypted
// routes plain files to Open and handles the encrypted container.
if Book.CanReadEncrypted('legacy.xls') then
Book.OpenEncrypted('legacy.xls', 'S3cret!')
else
Book.Open('legacy.xls');
// read cells here
finally
Book.Free;
end;
end;
La compatibilité avec une norme figée exige une correspondance à l'octet près
Ces deux bugs se ramènent au même principe directeur, important car il redéfinit les choix de conception. Lorsque le consommateur de votre sortie est un programme externe fixe que vous ne pouvez pas modifier, le mode de chiffrement et la réinitialisation des clés ne sont pas des détails d'implémentation que vous pouvez optimiser ou simplifier. Ils font partie du contrat d'échange. Excel déchiffrera avec ECB et réinitialisera les clés aux frontières de 1024 octets, que ces choix vous conviennent ou non, et votre unique tâche consiste à produire des octets qui se décoderont selon ce processus précis. Un mode plus moderne, un IV semblant inoffensif ou un compteur démarrant d'un autre point sont des défauts dès lors qu'ils diffèrent de ce qu'attend le lecteur. La compatibilité avec une spécification figée n'est pas approximative : elle doit être exacte à l'octet près ou elle échoue.
C'est pourquoi le vérificateur est un mauvais test de validation à lui seul. Il indique que la dérivation de clé fonctionne (ce qui est nécessaire mais pas suffisant). Un test consistant uniquement à ouvrir un fichier chiffré et à s'assurer que le mot de passe est accepté signalera un succès alors que le contenu reste illisible. Un test réel doit déchiffrer le package et comparer les octets récupérés aux données d'origine, ou effectuer un aller-retour d'écriture et lecture de cellules. Le vérificateur valide le mot de passe ; seul le corps du document valide le chiffrement.
Le moyen pris en charge pour lire et écrire les classeurs protégés
La surface publique est restreinte. Pour écrire un classeur moderne protégé par mot de passe, renseignez ou ouvrez un TXLSXWorkbook et appelez SaveAsEncrypted avec un nom de fichier et un mot de passe ; la méthode sérialise le classeur et déroule le pipeline Standard Encryption corrigé par le premier correctif, renvoyant 1 en cas de succès. Pour lire, appelez CanReadEncrypted afin de déterminer si le fichier est un conteneur OLE2 composé chiffré, puis effectuez l'aiguillage : OpenEncrypted prend en charge le chemin chiffré et se rabat sur Open pour les fichiers classiques, et Open accepte directement un mot de passe. La gestion du mode et la boucle de réinitialisation des clés résident sous ces appels ; vous fournissez le mot de passe et le nom de fichier, et le moteur s'assure de respecter les spécifications pour vous.
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create(nil);
try
Book.Open('quarterly.xlsx');
Book.SaveAsEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
// Reopen on the consumer side
Book.OpenEncrypted('quarterly_locked.xlsx', 'P@ssphrase');
finally
Book.Free;
end;
end;
La structure des données générées chiffrées, le flux EncryptionInfo, les blocs du vérificateur et la disposition du package sont détaillés dans notre guide sur la génération de fichiers XLSX protégés par AES. Concernant le verrouillage des feuilles et l'interaction de la protection avec la mise en page et l'impression, reportez-vous à l'article sur la protection, la mise en page et l'impression. Les deux s'appuient sur le chemin de chiffrement présenté ici, fourni avec le composant de classeur HotXLS pour Delphi et C++Builder, aux côtés des API de lecture, d'écriture et de rendu présentées sur ce blog.