Een met AES-256 versleutelde PDF met een niet-ASCII wachtwoord opent in het programma dat het schreef en nergens anders. De oorzaak is bijna altijd een ontbrekende voorbereidingsstap: ISO 32000-2 §7.6.4.3.3 vereist dat het wachtwoord wordt verwerkt met het SASLprep-profiel van stringprep voordat het UTF-8-gecodeerd en gehasht wordt. PDFlibPas, de PDF-bibliotheek voor Delphi en C++Builder, voert die voorbereiding uit binnen Encrypt, EncryptFile en DecryptFile
Dit is niet het verhaal van het verkeerde wachtwoord en niet het verhaal van de permissiebits. Als je gebruikers een wachtwoord typen dat je nooit hebt uitgegeven, is de retry-machinerie in het artikel over het opnieuw proberen van versleutelde PDF-wachtwoorden wat je wilt, en als je probeert uit te vinden wat een bestaand bestand daadwerkelijk afdwingt, behandelt de versleutelings- en permissieaudit dat terrein. Dit artikel is smaller en vreemder: het wachtwoord is correct, de gebruiker heeft het correct getypt, en het bestand weigert nog steeds elders te openen
Waarom opent een niet-ASCII wachtwoord in de ene reader wel en in de andere niet?
Omdat de twee programma's verschillende bytereeksen hashen van dezelfde toetsaanslagen. De revisie-6-sleutelafleiding in ISO 32000-2 §7.6.4.3.3 neemt het wachtwoord als UTF-8-bytes, trunceert tot 127 bytes, voegt een salt toe, en draait de geharde hash; het resultaat wordt gecontroleerd tegen de items /U en /O in het versleutelingsdictionary. Niets in die keten is vaag. Eén afwijkende byte waar dan ook in de invoer produceert een compleet andere digest, de validatie faalt, en de reader kan precies één ding zeggen: verkeerd wachtwoord
De bytes lopen uiteen omdat Unicode meerdere manieren biedt om te typen wat eruitziet als hetzelfde wachtwoord. Een Chinees wachtwoord kan binnenkomen als voorgecomponeerde tekens vanuit de ene invoermethode en als compatibiliteitsvormen vanuit een andere. Een Duits of Frans wachtwoord dat uit een tekstverwerker is gekopieerd, kan een NO-BREAK SPACE (U+00A0) bevatten waar de gebruiker denkt dat er een gewone spatie staat, of een SOFT HYPHEN (U+00AD) die als niets wordt weergegeven. SASLprep bestaat om al die vormen samen te vouwen tot één canonieke vorm voordat iemand ook maar iets hasht, zodat elke conforme implementatie dezelfde sleutel afleidt van dezelfde bedoeling
Wat verandert SASLprep eigenlijk aan een wachtwoord?
RFC 4013 definieert SASLprep als een profiel van het stringprep-framework in RFC 3454, en het bestaat uit vier geordende stappen in plaats van één transformatie. Mapping komt eerst: RFC 3454-tabel C.1.2 (niet-ASCII spaties) wordt gemapt naar U+0020, en tabel B.1 (tekens die doorgaans naar niets worden gemapt) wordt volledig verwijderd. Normalisatie naar Unicode NFKC volgt, wat de stap is die compatibiliteitstekens en combinerende sequenties samenvouwt. Vervolgens verwerpt de controle op verboden uitvoer alles in de tabellen C.2.1 tot en met C.9. Ten slotte wordt de bidirectionele regel uit RFC 3454 sectie 6 toegepast op de genormaliseerde string
PDFlibPas implementeert het volledige profiel in de unit PDFlibSASLprep, die één enkel toegangspunt blootstelt. PLSASLprepPassword neemt het ruwe wachtwoord, schrijft de voorbereide vorm naar een var-parameter, en retourneert False wanneer het wachtwoord geweigerd moet worden. De functie is bewust totaal op het happy path: een puur ASCII-wachtwoord komt byte-identiek terug, dus er verandert niets aan bestaande implementaties
uses
PDFlibSASLprep;
var
Prepared: WideString;
begin
// RFC 4013: mapping, then NFKC, then prohibited output, then the bidi rule
PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared); // -> 'IX' B.1 deletes SOFT HYPHEN
PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared); // -> 'a b' C.1.2 maps NBSP to U+0020
PLSASLprepPassword(WideString(WideChar($00AA)), Prepared); // -> 'a' NFKC folds ORDINAL INDICATOR
PLSASLprepPassword(WideString(WideChar($2168)), Prepared); // -> 'IX' NFKC folds ROMAN NUMERAL NINE
PLSASLprepPassword('user', Prepared); // -> 'user' ASCII is never touched
end;
De U+200B-ambiguïteit die de tabellen niet oplossen
Eén code point komt tegelijk in twee RFC 3454-tabellen voor, en de twee tabellen zijn het oneens. ZERO WIDTH SPACE (U+200B) valt binnen het C.1.2-bereik U+2000 tot U+200B, waar de regel zegt: map naar U+0020, en het valt ook binnen het B.1-bereik U+200B tot U+200D, waar de regel zegt: verwijder het. Lees je de mappingstap in de ene of de andere volgorde, dan krijg je verschillende bytes uit hetzelfde wachtwoord: a+U+200B+b bereidt zich voor tot a b onder C.1.2 en tot ab onder B.1. RFC 4013 noemt beide tabellen en zegt niet welke wint, dus dit is een echte ambiguïteit in de specificatie in plaats van een leesfout. PDFlibPas test eerst het lidmaatschap van C.1.2 en mapt U+200B daarom naar een spatie, wat het gedrag is waar andere breed ingezette stringprep-implementaties op zijn uitgekomen; daarmee overeenkomen is het enige wat hier telt, omdat het doel byte-overeenstemming is met welke reader de klant ook gebruikt
Oude bestanden lezen: eerst voorbereid, dan ruw
De oplossing creëert zijn eigen compatibiliteitsprobleem. Elk AES-256-bestand dat vóór de wijziging is geschreven, hashte het ruwe UTF-8-wachtwoord, dus zou het maken van een strikt conforme reader klanten buitensluiten van hun eigen archieven. PDFlibPas lost dit op de leeskant op door twee kandidaten in volgorde te proberen. TPDFDocument.SetPassword bouwt een kandidatenlijst die begint met de voorbereide vorm en terugvalt op de ruwe vorm, en voegt het voorbereide item alleen toe wanneer het document daadwerkelijk AES-256 is en de twee vormen verschillen. Voor een ASCII-wachtwoord zijn de vormen identiek, bevat de lijst één item, en zijn de kosten van het hele mechanisme één stringvergelijking. DecryptFile doet hetzelfde langs zijn directe AES-256-herschrijfpad, en roept PLDirectDecryptFileAES256 eerst aan met het voorbereide wachtwoord
var
Lib: TPDFlib;
Bytes: AnsiString;
begin
Lib := TPDFlib.Create;
try
Lib.SetOrigin(1);
Lib.DrawText(100, 100, 'saslprep roundtrip');
// Strength 3 and 4 are the two AES-256 values; both are prepared before hashing
Lib.Encrypt('ow' + WideChar($00AD) + 'ner', 'pa' + WideChar($00AD) + 'ss', 4,
Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1));
Bytes := Lib.SaveToString;
finally
Lib.Free;
end;
Lib := TPDFlib.Create;
try
// 'pass' is what SASLprep produced and what any conforming reader computes,
// so the plain ASCII form opens a file created with the soft-hyphen form
if Lib.LoadFromString(Bytes, 'pass') = 1 then
Caption := IntToStr(Lib.PageCount);
finally
Lib.Free;
end;
end;
De fallback draagt één beveiliging die het waard is om over te nemen. De tweede poging in DecryptFile draait alleen wanneer de voorbereide en ruwe vormen verschillen én de eerste poging geen harde foutcode meldde. Een structureel falen betekent dat de invoer beschadigd is of niet de versleutelingsrevisie is die je aannam, en het opnieuw proberen van een kapot bestand met een ander wachtwoord verbrandt gewoon een tweede volledige parse over vijandige invoer; de redenering achter die reflex staat uiteengezet in de notitie over het veilig parsen van niet-vertrouwde PDF's. Merk ook op dat er geen fallback is aan de schrijfkant, en die asymmetrie is opzettelijk. Lezen tolereert geschiedenis, schrijven niet: elk nieuw AES-256-bestand krijgt de conforme bytes
Welke wachtwoorden worden ronduit geweigerd, en wat is fout 604?
SASLprep kan een wachtwoord volledig weigeren, en wanneer dat gebeurt, moet versleuteling luid falen in plaats van stilzwijgend iets anders te vervangen. Encrypt en EncryptFile bereiden zowel het eigenaars- als het gebruikerswachtwoord voor wanneer Strength 3 of 4 is, retourneren 0 bij weigering, en zetten LastErrorCode op PDFLIB_ERROR_PASSWORD_SASLPREP, wat 604 is. Twee families invoer triggeren dit. De tabellen voor verboden uitvoer verwerpen controletekens (C.2.1 en C.2.2), private-use-codepoints (C.3), non-characters (C.4), losse surrogates (C.5), U+FFFD (C.6), ideografische beschrijvingstekens (C.7), en de weergavecontrole- en taggingbereiken (C.8 en C.9). Daarnaast verwerpt de bidi-regel uit RFC 3454 sectie 6 elke string die een RandALCat-teken uit tabel D.1 bevat, tenzij de string zowel begint als eindigt met zo'n teken en helemaal geen links-naar-rechts-letters bevat
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
// U+0007 is a C.2.1 control character, so preparation refuses the password
if Lib.Encrypt('owner', 'bad' + WideChar($0007), 4,
Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1)) = 0 then
begin
if Lib.LastErrorCode = PDFLIB_ERROR_PASSWORD_SASLPREP then // 604
ShowMessage('The password contains characters that PDF encryption does not permit.');
end;
finally
Lib.Free;
end;
end;
Die bidi-regel is degene die je supportdesk zal verrassen. Een Arabisch of Hebreeuws wachtwoord dat eindigt op een westers cijfer, of een met een verdwaalde Latijnse letter in het midden, wordt door de specificatie geweigerd, ook al ziet het er in het invoerveld volkomen redelijk uit. Toon 604 als een melding over de wachtwoordtekens, niet als een generieke versleutelingsfout, of iemand besteedt een middag aan het zoeken naar een bug in je sleutelafleiding
Eerlijke grenzen: NFKC, een benaderde LCat, en één Delphi-valkuil
Twee onderdelen van de implementatie zijn benaderingen, en beide verdienen het om nadrukkelijk benoemd te worden in plaats van verstopt. NFKC-normalisatie wordt uitgevoerd door de Windows-API NormalizeString, dynamisch geladen vanuit Normaliz.dll. Wanneer die bibliotheek niet beschikbaar is, wordt de gemapte string ongenormaliseerd gebruikt, wat betekent dat de mapping- en verbodstappen nog steeds draaien maar compatibiliteitsvouwing niet. In de praktijk wordt de DLL al sinds elke Windows-release vanaf Vista meegeleverd, dus het gedegradeerde pad is eerder een pre-Vista- en niet-Windows-aangelegenheid dan een actueel risico, maar een wachtwoord dat op NFKC-vouwing leunt zou daar andere bytes opleveren en dat is een echte, zij het verre, afwijking. De bidi-controle is de tweede benadering: het detecteren van LCat-tekens gebruikt de gangbare letterbereiken in plaats van de volledige RFC 3454-tabel D.2, en de richting van die fout is wat het aanvaardbaar maakt. Een gemist LCat-teken kan er alleen toe leiden dat de bidi-regel slaagt waar de specificatie zou hebben geweigerd, nooit omgekeerd, en het raakt nooit de mapping- of normalisatiestappen, dus de voorbereide bytereeks van een geaccepteerd wachtwoord blijft ongewijzigd. Het restrisico is daarom een beleidsafwijking in plaats van een byteafwijking: een wachtwoord in een exotisch schrift dat een strengere implementatie helemaal zou weigeren te accepteren. Elk wachtwoord dat beide kanten accepteren hasht identiek, wat de eigenschap is waar interoperabiliteit daadwerkelijk van afhangt
Ten slotte een Delphi-syntaxvalkuil die een uur kost als je hem nog niet eerder bent tegengekomen. Wanneer een functie een procedureel type retourneert, zorgt toewijzing zonder haakjes er niet voor dat hij wordt aangeroepen. De compiler leest Proc := GetNormalizeProc; als het nemen van het adres van GetNormalizeProc zelf, en meldt vervolgens E2009 met de weinig behulpzame klacht dat de aanroepconventies verschillen, omdat de accessor de standaardconventie gebruikt terwijl het geïmporteerde API-type stdcall is. De lege haakjes zijn verplicht
type
TNormalizeString = function(NormForm: Integer; SrcString: PWideChar; SrcLength: Integer;
DstString: PWideChar; DstLength: Integer): Integer; stdcall;
function GetNormalizeProc: TNormalizeString; // loads Normaliz.dll on first use
...
var
Proc: TNormalizeString;
begin
// Proc := GetNormalizeProc; // E2009: reads as @GetNormalizeProc, conventions differ
Proc := GetNormalizeProc(); // correct: calls the accessor and assigns its result
if not Assigned(Proc) then
Exit; // no NFKC available, mapped string is used as-is
end;
Wachtwoordvoorbereiding is een van die details die nooit in een featurelijst verschijnt en die bepaalt of een versleuteld document het contact met een klant in een andere locale overleeft. De hier beschreven toegangspunten Encrypt, EncryptFile, DecryptFile en SetPassword maken deel uit van de losLab PDF Developer Library Pascal Edition voor Delphi en C++Builder, waarvan de productpagina de volledige versleutelingsreferentie en de complete foutcodetabel bevat