Teknisk artikel

SASLprep AES-256 PDF-lösenord i Delphi med PDF Library for Delphi

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. PDF Library for Delphi, 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

PDF Library for Delphi-diagram över samma icke-ASCII PDF-lösenord som hashas till två olika UTF-8-bytesekvenser, där läsaren som hoppar över preparering faller på /U-kontrollen och den SASLprep-förberedda läsaren härleder den fungerande nyckeln
Läsaren som hoppar över förberedelsen hashar andra byte, så AES-256-valideringen kollapsar till en falsk rapport om fel lösenord

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

PDF Library for Delphi 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: mappning, sedan NFKC, sedan förbjuden utdata, sedan bidi-regeln
  PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared);  // -> 'IX'   B.1 tar bort MJUKT BINDESTRECK
  PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared);  // -> 'a b'  C.1.2 mappar NBSP till U+0020
  PLSASLprepPassword(WideString(WideChar($00AA)), Prepared);  // -> 'a'    NFKC viker ORDNINGSINDIKATOR
  PLSASLprepPassword(WideString(WideChar($2168)), Prepared);  // -> 'IX'   NFKC viker ROMERSK SIFFRA NIO
  PLSASLprepPassword('user', Prepared);                       // -> 'user' ASCII rörs aldrig
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. PDF Library for Delphi 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. PDF Library for Delphi 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 och 4 är de två AES-256-värdena; båda förbereds innan hashning
    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' är vad SASLprep producerade och vad varje konform läsare beräknar,
    // så den vanliga ASCII-formen öppnar en fil skapad med den mjuka-bindestrecks-formen
    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 är ett C.2.1-kontrolltecken, så förberedelsen vägrar lösenordet
    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

PDF Library for Delphi-diagram över de fyra ordnade RFC 4013 SASLprep-stegen: mappa icke-ASCII-blanksteg, ta bort tecken mappade till ingenting, fälla ihop kompatibilitetsformer med NFKC, och köra kontrollerna för förbjuden utdata och dubbelriktadhet för att ge ett stabilt förberett lösenord
Mappning, NFKC-normalisering, skanningen av förbjudna tecken och bidi-regeln körs i ordning och lämnar lösenord med enbart ASCII orörda

Ä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

PDF Library for Delphi-diagram över AES-256-kryptering som vägrar lösenord vars tecken träffar tabellerna för förbjuden utdata C.2.1 till C.9 eller bryter mot RFC 3454:s dubbelriktadhetsregel, och returnerar noll med LastErrorCode 604
Två indatamiljöer triggar vägran: förbjudna kodpunktstabeller och RFC 3454:s bidirektionella regel
type
  TNormalizeString = function(NormForm: Integer; SrcString: PWideChar; SrcLength: Integer;
    DstString: PWideChar; DstLength: Integer): Integer; stdcall;

function GetNormalizeProc: TNormalizeString;   // laddar Normaliz.dll vid första användning
...
var
  Proc: TNormalizeString;
begin
  // Proc := GetNormalizeProc;   // E2009: tolkas som @GetNormalizeProc, konventionerna skiljer sig
  Proc := GetNormalizeProc();    // korrekt: anropar accessorn och tilldelar dess resultat
  if not Assigned(Proc) then
    Exit;                        // ingen NFKC tillgänglig, den mappade strängen används som den är
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