HotXLS leest Agile-gecodeerde Excel-bestanden — de wachtwoordbeveiliging die Excel 2010 en elke latere versie standaard toepassen — via een enkele aanroep: TXLSXWorkbook.OpenEncrypted. Het component parseert de XML-coderingsdescriptor (encryption descriptor), leidt sleutels af van het wachtwoord met een SHA-512 spin-count hashketen, verifieert het wachtwoord tegen de gecodeerde verifieerder en decodeert vervolgens het pakket in AES-CBC-segmenten van 4096 bytes. Er is geen Excel-installatie, COM of externe cryptografische DLL bij betrokken
Dit artikel behandelt specifiek de leeszijde van Agile-codering. Twee verwante problemen hebben hun eigen artikelen: interoperabiliteit met de verouderde RC4- en XOR-schema's in oude BIFF .xls-bestanden wordt behandeld in het ECB- en RC4-interoperabiliteitsartikel, en het produceren van met een wachtwoord beveiligde werkmappen met ECMA-376 Standard Encryption wordt behandeld in het AES-beveiligde XLSX-uitvoerartikel. Hier bestaat het bestand al, heeft iemand anders het gecodeerd en is het uw taak om het te openen
Het scenario dat dit dwingend maakt is bekend bij iedereen die een documentverwerkingspijplijn beheert. Een importservice aan de serverzijde accepteert uploads van werkmappen; er is geen Excel op de machine en dat zal er ook nooit zijn; en op een ochtend uploadt een klant een volkomen normale .xlsx die de ZIP-lezer weigert omdat het helemaal geen ZIP is. De klant heeft het opgeslagen met een wachtwoord. Vanaf dat moment moet uw lader [MS-OFFCRYPTO] begrijpen, of hij stuurt het bestand terug naar een gebruiker who, vanuit zijn standpunt, niets ongewoons deed
Wat is Agile-codering in een Excel-bestand?
Agile-codering is het wachtwoordbeveiligingsschema gedefinieerd in [MS-OFFCRYPTO] §2.3.4.10 tot §2.3.4.15, en het is wat Excel 2010 and later schrijven telkens wanneer een werkmap met een wachtwoord wordt opgeslagen. Het gecodeerde bestand is geen ZIP-pakket meer. Het is een OLE Compound File Binary (CFB)-container die twee streams bevat: EncryptionInfo, die beschrijft hoe de codering is uitgevoerd, en EncryptedPackage, wat de echte .xlsx ZIP is, gecodeerd als een ondoorzichtige blob. De CFB-handtekening (D0 CF 11 E0 A1 B1 1A E1) is dezelfde magie die verouderde BIFF .xls-bestanden dragen, en daarom kan een hernoemd of gecodeerd bestand niet alleen op basis van de extensie worden geclassificeerd
Wat Agile onderscheidt van zijn voorgangers is dat EncryptionInfo zelfbeschrijvend is. Na een 8-byte versie-voorvoegsel, met zowel de hoofd- als subversie op 4, is de stream een UTF-8 XML-descriptor. Een element keyData declareert de cipher (AES), de chaining-modus (ChainingModeCBC), the hash (SHA512), de sleutellengte in bits, de blokgrootte en een Base64-salt. Een wachtwoord-element keyEncryptor bevat zijn eigen salt, de spinCount en drie Base64-payloads: encryptedVerifierHashInput, encryptedVerifierHashValue en encryptedKeyValue. Excel schrijft AES-256 met een spin-count van 100.000, maar de descriptor mag AES-128 of AES-192 declareren, en HotXLS respecteert wat keyBits aangeeft in plaats van uit te gaan van 256
Eén toegangspunt voor platte tekst, Standard- en Agile-werkmappen
TXLSXWorkbook.OpenEncrypted verwerkt alle drie de toestanden die een aanroeper kan tegenkomen: platte ZIP, Standard-gecodeerd en Agile-gecodeerd, zodat upload-handlers bestanden niet hoeven te classificeren voordat ze worden geladen. De methode snuffelt eerst aan het bestand: als er geen CFB-handtekening is, verwijst deze naar het normale Open-pad en wordt het wachtwoord simpelweg genegeerd. Als het bestand een CFB-container is, probeert het eerst ECMA-376 Standard Encryption en, wanneer de handtekening van de EncryptionInfo-versie de Agile 4.4 is, stuurt het door naar de Agile-pijplijn. De retourwaarde is 1 bij succes, hetzelfde contract als Open
var
Wb: TXLSXWorkbook;
begin
Wb := TXLSXWorkbook.Create;
try
// Works for plain .xlsx, Standard-encrypted and
// Agile-encrypted files alike
if Wb.OpenEncrypted('upload.xlsx', 'customer-password') = 1 then
Writeln(VarToWideStr(Wb.Sheets[1].Cells[1, 1].Value));
finally
Wb.Free;
end;
end;
De fallback voor niet-gecodeerde invoer is belangrijker dan het lijkt. Een batch-importeur die altijd OpenEncrypted aanroept, heeft geen vertakking nodig op de aanroeplocatie: bestanden die nooit beveiligd waren, laden exact zoals voorheen, en bestanden die gecodeerd binnenkomen, worden ter plaatse gedecodeerd en vervolgens als een in-geheugenstream aan de gewone ZIP-lezer aangeboden. Er is één codepad om te testen, geen drie
Hoe wordt een wachtwoord een AES-sleutel?
Agile-codering gebruikt het wachtwoord nooit rechtstreeks. HotXLS berekent eerst een herhaalde (iterated) hash: de initiële digest is SHA-512 over de wachtwoordsalt samengevoegd met de UTF-16LE-bytes van het wachtwoord, en vervolgens wordt de digest spinCount keer opnieuw gehasht, waarbij bij elke ronde de 32-bits little-endian iteratieteller voor de vorige digest wordt gezet. Met de standaard spin-count van Excel van 100.000 zijn dat honderdduizend seriële SHA-512-aanroepen per wachtwoordpoging, en dat is precies de bedoeling. De spin-count is een brute-force-vertrager: het kost een legitieme aanroeper eenmalig een paar milliseconden, en het kost een dictionary-aanvaller diezelfde paar milliseconden voor elke afzonderlijke gok
// [MS-OFFCRYPTO] iterated password hash:
// H(0) = SHA-512(salt + UTF-16LE(password))
// H(n) = SHA-512(LE32(n - 1) + H(n - 1)), repeated spinCount times
function AgilePasswordHash(const Password: WideString;
const Salt: TBytes; SpinCount: Integer): TBytes;
var
buf: TBytes;
i: Integer;
begin
Result := XlsSHA512(Concat(Salt, Utf16LEBytes(Password)));
SetLength(buf, 4 + 64);
for i := 0 to SpinCount - 1 do
begin
PutLE32(buf, 0, i); // iteration counter, little-endian
Move(Result[0], buf[4], 64); // previous digest
Result := XlsSHA512(buf);
end;
end;
De verkregen hash is nog steeds geen sleutel. Drie verschillende sleutels worden ervan afgeleid door deze nogmaals te hashen met een vaste 8-byte bloksleutel erachter geplakt, één constante per doel: FE A7 D2 76 3B 4B 9E 79 voor het decoderen van de verifieerdersinvoer, D7 AA 0F 6D 30 61 34 4E voor de verifieerdershash, en 14 6E 0B E7 AB AC D0 D6 voor het uitpakken van de werkelijke pakketsleutel. Elk SHA-512-resultaat wordt afgekapt tot de gedeclareerde sleutellengte en, volgens [MS-OFFCRYPTO], opgevuld met 0x36-bytes in het theoretische geval dat de hash korter is dan de sleutel. Dezelfde 0x36-opvulregel is van toepassing wanneer de wachtwoordsalt wordt uitgebreid tot de blokgrootte voor gebruik als de CBC-initialisatievector
Wachtwoordverificatie en de saltSize afkap-valkuil
HotXLS verifieert het wachtwoord voordat het aan het pakket komt, met behulp van het verifieerderspaar uit de descriptor. Het decodeert encryptedVerifierHashInput met de eerste afgeleide sleutel, hasht het resultaat met SHA-512, decodeert encryptedVerifierHashValue met de tweede afgeleide sleutel en vergelijkt de twee digests byte voor byte. Een mismatch betekent dat het wachtwoord onjuist is. Dit wordt gemeld als een apart resultaat in plaats van als een verminkt werkmapbestand, en cruciaal is dat het pakketlichaam nooit met een slechte sleutel wordt gedecodeerd, zodat er geen scenario is waarin een verkeer wachtwoord aannemelijk uitziende corrupte gegevens oplevert
Er zit hier een specificatiedetail in dat makkelijk fout kan worden gedaan. [MS-OFFCRYPTO] §2.3.4.13 definieert de verifieerder als *saltSize* bytes aan willekeurige gegevens, waarbij saltSize is de lengte is van de salt van de key-encryptor, niet de cipher-blokgrootte. Omdat AES-CBC-cijfertekst blok-uitgelijnd is, komt de gedecodeerde verifieerdersinvoer terug opgevuld tot een veelvoud van 16 bytes, en deze moet voor het hashen worden afgekapt tot saltSize. Excel schrijft saltSize altijd gelijk aan blockSize, beide 16, dus een implementatie die het afkappen overslaat, slaagt voor elke test tegen echte Excel-uitvoer en faalt vervolgens op het eerste bestand van een maker die een andere salt-lengte koos. HotXLS kapt af tot de salt-lengte omdat dat is wat de specificatie feitelijk voorschrijft, en dat de twee waarden in de praktijk overeenstemmen is een toeval, geen contract
Hoe wordt het EncryptedPackage gedecodeerd?
De stream EncryptedPackage begint met een 8-byte little-endian omvang van de platte tekst, gevolgd door de cijfertekst in segmenten van 4096 bytes, en HotXLS decodeert deze segment voor segment met een verse IV per segment. De pakketsleutel zelf is niet van het wachtwoord afgeleid: het is een willekeurige tussentijdse sleutel die de schrijver in encryptedKeyValue heeft gecodeerd, en HotXLS pakt deze uit met de derde afgeleide sleutel, afgekapt tot de sleutellengte die is gedeclareerd door keyData. De IV van elk segment is SHA-512 over de keyData-salt samengevoegd met de 32-bits little-endian segment-index, afgekapt tot de blokgrootte. Die constructie betekent dat elk segment van 4096 bytes onafhankelijk kan worden gedecodeerd, wat het formaat in principe ook vriendelijk maakt voor willekeurige toegang (random access), hoewel HotXLS het hele pakket in het geheugen decodeert en de resulterende ZIP-bytes aan de normale XLSX-lezer overhandigt
De gedeclareerde omvang van de platte tekst doet het laatste werk. AES-CBC-uitvoer is blok-uitgelijnd, dus het laatste segment bevat tot 15 bytes aan opvulling (padding) die geen deel uitmaken van het document; de gedecodeerde buffer wordt afgekapt tot het grootte-voorvoegsel, en het resultaat is exact de .xlsx ZIP die Excel heeft gecodeerd. HotXLS valideert het voorvoegsel tegen de werkelijke streamlengte alvorens te decoderen, zodat een afgekapte upload of een gemanipuleerd grootteveld netjes faalt in plaats van te overschrijden
Foutrapportage en eerlijke grenzen
De faalmodi worden bewust uit elkaar gehouden. Een verkeerd wachtwoord genereert een uitzondering (exception) met een expliciet foutwachtwoordbericht, aangedreven door de verifieerders-mismatch, zodat een UI de gebruiker kan vragen het opnieuw te proberen. Een CFB-container waarvan de descriptor algoritmen declareert buiten de ondersteunde set — alles behalve AES met CBC-chaining en SHA-512-hashing in een Agile-descriptor, of een container die noch Standard noch Agile is — genereert een andere uitzondering die het schema identificeert als niet-ondersteund. Die twee mogen nooit worden verward: het opnieuw proberen van een wachtwoord tegen een niet-ondersteund schema verspilt de tijd van de gebruiker, en het rapporteren van een verkeerd wachtwoord als een formaatfout stuurt uw ondersteuningsteam de verkeerde kant op
function LoadUploadedWorkbook(const FileName: WideString;
const Password: WideString; Wb: TXLSXWorkbook): Boolean;
begin
Result := False;
try
Result := Wb.OpenEncrypted(FileName, Password) = 1;
except
on E: EXlsxEncryptionNotImplemented do
// Raised for both a wrong password and an unsupported
// scheme; E.Message states which, so log it verbatim and
// only offer a password retry for the wrong-password case
RejectUpload(FileName, E.Message);
end;
end;
De grenzen zijn het waard om duidelijk te worden aangegeven. HotXLS leest Agile-descriptoren die AES in CBC-modus met SHA-512 declareren, wat dekt wat Excel 2010 tot en met Excel 365 daadwerkelijk schrijven, in alle three sleutelgroottes. Descriptoren die andere ciphers of hash-algoritmen declareren, worden geweigerd in plaats van dat er naar wordt geraden, en op certificaten gebaseerde key-encryptors worden niet geraadpleegd, alleen de wachtwoord-key-encryptor wel. Als de schrijverzijde produceert HotXLS momenteel Standard Encryption in plaats van Agile, een onderscheid dat belangrijk is als stroomafwaartse tools het schema inspecteren; de details zijn te vinden in het artikel over het schrijven van AES-beveiligde XLSX-uitvoer
Met een wachtwoord beveiligde uploads zijn niet langer een speciaal geval zodra de lader encryptie behandelt als onderdeel van het bestandsformaat in plaats van als een uitzondering erop. Het toegangspunt OpenEncrypted, de SHA-512 spin-count-afleiding en de gesegmenteerde AES-CBC-pijplijn die hier worden beschreven, worden geleverd als onderdeel van het HotXLS Delphi Excel Component, samen met de rest van de native XLS- en XLSX-lezen- en schrijfmachine voor Delphi en C++Builder