Teknisk artikel

SASLprep AES-256 PDF-lösenord i Delphi med PDFlibPas

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