En AES-256-krypterad PDF med ett icke-ASCII-lösenord öppnas i programmet som skrev den och ingen annanstans. Orsaken är nästan alltid ett saknat förberedelsesteg: ISO 32000-2 §7.6.4.3.3 kräver att lösenordet bearbetas med SASLprep-profilen av stringprep innan det UTF-8-kodas och hashas. PDFlibPas, PDF-biblioteket för Delphi och C++Builder, utför den förberedelsen inuti Encrypt, EncryptFile och DecryptFile
Detta är inte historien om fel lösenord och inte historien om behörighetsbitar. Om dina användare skriver ett lösenord du aldrig utfärdat är återförsöksmaskineriet i artikeln om att försöka igen med krypterade PDF-lösenord vad du vill ha, och om du försöker ta reda på vad en befintlig fil faktiskt upprätthåller täcker krypterings- och behörighetsrevisionen den marken. Den här är smalare och märkligare: lösenordet är korrekt, användaren skrev det korrekt, och filen vägrar ändå öppnas någon annanstans
Varför öppnas ett icke-ASCII-lösenord i en läsare men inte en annan?
Eftersom de två programmen hashar olika bytesekvenser från samma tangenttryckningar. Nyckelhärledningen för revision 6 i ISO 32000-2 §7.6.4.3.3 tar lösenordet som UTF-8-byte, trunkerar till 127 byte, lägger till ett salt, och kör den härdade hashen; resultatet kontrolleras mot /U- och /O-posterna i krypteringsordboken. Inget i den kedjan är oskarpt. En avvikande byte var som helst i indata producerar en helt annan digest, valideringen misslyckas, och läsaren har exakt en sak den kan säga: fel lösenord
Byten divergerar eftersom Unicode erbjuder flera sätt att skriva vad som ser ut som samma lösenord. Ett kinesiskt lösenord kan komma som förkomponerade tecken från en inmatningsmetod och som kompatibilitetsformer från en annan. Ett tyskt eller franskt lösenord kopierat ur en ordbehandlare kan bära ett HÅRT MELLANSLAG (U+00A0) där användaren tror det finns ett vanligt mellanslag, eller ett MJUKT BINDESTRECK (U+00AD) som renderas som ingenting alls. SASLprep finns för att kollapsa alla dessa till en kanonisk form innan någon hashar något, så att varje konform implementation härleder samma nyckel från samma avsikt
Vad ändrar SASLprep egentligen hos ett lösenord?
RFC 4013 definierar SASLprep som en profil av stringprep-ramverket i RFC 3454, och det är fyra ordnade steg snarare än en transformation. Mappning kommer först: RFC 3454-tabell C.1.2 (icke-ASCII-mellanslag) mappas till U+0020, och tabell B.1 (tecken som vanligen mappas till ingenting) tas bort helt. Normalisering till Unicode NFKC följer, vilket är steget som viker in kompatibilitetstecken och kombinerande sekvenser. Sedan avvisar kontrollen för förbjuden utdata allt i tabellerna C.2.1 till C.9. Slutligen tillämpas den bidirektionella regeln från RFC 3454 avsnitt 6 på den normaliserade strängen
PDFlibPas implementerar hela profilen i enheten PDFlibSASLprep, som exponerar en enda ingångspunkt. PLSASLprepPassword tar det råa lösenordet, skriver den förberedda formen till en var-parameter, och returnerar False när lösenordet måste avvisas. Funktionen är avsiktligt total på lyckovägen: ett rent ASCII-lösenord kommer tillbaka byte-identiskt, så inget om befintliga driftsättningar ändras
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;
U+200B-tvetydigheten som tabellerna inte löser
En kodpunkt hamnar i två RFC 3454-tabeller samtidigt, och de två tabellerna är oense. ZERO WIDTH SPACE (U+200B) faller inom C.1.2-intervallet U+2000 till U+200B, där regeln säger mappa den till U+0020, och den faller också inom B.1-intervallet U+200B till U+200D, där regeln säger ta bort den. Läs mappningssteget i endera ordningen och du får olika byte ut ur samma lösenord: a+U+200B+b förbereds till a b under C.1.2 och till ab under B.1. RFC 4013 namnger båda tabellerna och säger inte vilken som vinner, så det här är en genuin tvetydighet i specifikationen snarare än ett läsfel. PDFlibPas testar C.1.2-medlemskap först och mappar därför U+200B till ett mellanslag, vilket är beteendet andra brett utplacerade stringprep-implementationer landade på; att matcha dem är det enda som spelar roll här, eftersom målet är byteöverensstämmelse med vilken läsare kunden råkar använda
Att läsa gamla filer: förberedd först, rå andra
Fixet skapar sitt eget kompatibilitetsproblem. Varje AES-256-fil skriven före ändringen hashade det råa UTF-8-lösenordet, så att göra läsaren strikt konform skulle låsa ute kunder från deras egna arkiv. PDFlibPas löser detta på läsidan genom att prova två kandidater i ordning. TPDFDocument.SetPassword bygger en kandidatlista som börjar med den förberedda formen och faller tillbaka på den råa formen, och den lägger bara till den förberedda posten när dokumentet faktiskt är AES-256 och de två formerna skiljer sig åt. För ett ASCII-lösenord är formerna identiska, listan håller en post, och kostnaden för hela mekanismen är en enda strängjämförelse. DecryptFile gör samma sak längs sin direkta AES-256-omskrivningsväg, och anropar PLDirectDecryptFileAES256 med det förberedda lösenordet först
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;
Fallbacket bär en vakt värd att kopiera. Det andra försöket i DecryptFile körs bara när den förberedda och den råa formen skiljer sig åt och det första försöket rapporterade ingen hård felkod. Ett strukturellt fel betyder att indata är skadad eller inte är den krypteringsrevision du antog, och att försöka igen med ett trasigt fil med ett annat lösenord bara bränner en andra fullständig parsning över fientlig indata; resonemanget bakom den reflexen läggs ut i anteckningen om att tolka opålitliga PDF-filer säkert. Notera också att det inte finns något fallback på skrivsidan, och den asymmetrin är avsiktlig. Läsning tolererar historia, skrivning gör inte det: varje ny AES-256-fil får de konforma byten
Vilka lösenord avvisas helt, och vad är fel 604?
SASLprep kan avvisa ett lösenord helt, och när den gör det måste kryptering misslyckas högljutt snarare än tyst ersätta med något. Encrypt och EncryptFile förbereder både ägar- och användarlösenordet när Strength är 3 eller 4, returnerar 0 vid avvisning, och sätter LastErrorCode till PDFLIB_ERROR_PASSWORD_SASLPREP, vilket är 604. Två familjer av indata utlöser den. Tabellerna för förbjuden utdata avvisar kontrolltecken (C.2.1 och C.2.2), privatanvändningskodpunkter (C.3), icke-tecken (C.4), ensamma surrogat (C.5), U+FFFD (C.6), ideografiska beskrivningstecken (C.7), och display-kontroll- och taggningsintervallen (C.8 och C.9). Separat avvisar bidi-regeln i RFC 3454 avsnitt 6 varje sträng som innehåller ett RandALCat-tecken från tabell D.1 om inte strängen både börjar och slutar med ett sådant och inte innehåller några vänster-till-höger-bokstäver alls
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;
Den bidi-regeln är den som kommer överraska din supportdesk. Ett arabiskt eller hebreiskt lösenord som slutar med en västerländsk siffra, eller ett med en enstaka latinsk bokstav i mitten, avvisas av specifikationen även om det ser fullkomligt rimligt ut i inmatningsfältet. Visa 604 som ett meddelande om lösenordstecknen, inte som ett generiskt krypteringsfel, annars kommer någon spendera en eftermiddag med att leta efter en bugg i din nyckelhärledning
Ärliga gränser: NFKC, en approximativ LCat, och en Delphi-fälla
Två delar av implementationen är approximationer, och båda förtjänar att uttryckas rakt på sak snarare än begravas. NFKC-normalisering utförs av Windows NormalizeString-API:t, laddat dynamiskt från Normaliz.dll. När det biblioteket inte är tillgängligt används den mappade strängen onormaliserad, vilket betyder att mapprings- och förbudsstegen fortfarande körs men kompatibilitetsvikningen inte gör det. I praktiken har DLL:n levererats med varje Windows-utgåva sedan Vista, så den degraderade vägen är ett pre-Vista- och icke-Windows-bekymmer snarare än ett levande sådant, men ett lösenord som förlitar sig på NFKC-vikning skulle producera olika byte där och det är en verklig, om än avlägsen, divergens. Bidi-kontrollen är den andra approximationen: att upptäcka LCat-tecken använder de vanliga bokstavsintervallen i stället för hela RFC 3454-tabellen D.2, och riktningen på det felet är vad som gör det acceptabelt. Ett missat LCat-tecken kan bara få bidi-regeln att passera där specifikationen skulle ha avvisat, aldrig tvärtom, och det rör aldrig mapprings- eller normaliseringsstegen, så den förberedda bytesekvensen för ett accepterat lösenord är oförändrad. Den kvarvarande risken är därför en policydivergens snarare än en bytedivergens: ett lösenord med exotisk skrift som en striktare implementation skulle vägra acceptera alls. Varje lösenord båda sidor accepterar hashar identiskt, vilket är egenskapen interoperabilitet faktiskt beror på
Slutligen, en Delphi-syntaxfälla som kostar en timme om du inte stött på den förut. När en funktion returnerar en procedurell typ innebär tilldelning utan parenteser inte att den anropas. Kompilatorn läser Proc := GetNormalizeProc; som att ta adressen till GetNormalizeProc själv, och rapporterar sedan E2009 med den föga hjälpsamma klagan att anropskonventionerna skiljer sig, eftersom accessorn använder standardkonventionen medan den importerade API-typen är stdcall. De tomma parenteserna är obligatoriska
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;
Lösenordsförberedelse är en av de detaljer som aldrig dyker upp i en funktionslista och som avgör om ett krypterat dokument överlever kontakt med en kund i en annan lokal. Ingångspunkterna Encrypt, EncryptFile, DecryptFile och SetPassword som beskrivs här är del av losLab PDF Developer Library Pascal Edition för Delphi och C++Builder, vars produktsida bär den fullständiga krypteringsreferensen och den kompletta felkodstabellen