Teknisk artikkel

SASLprep AES-256 PDF-passord i Delphi med PDFlibPas

En AES-256-kryptert PDF med et ikke-ASCII-passord åpner i programmet som skrev den og ingen andre steder. Årsaken er nesten alltid et manglende forberedelsessteg: ISO 32000-2 §7.6.4.3.3 krever at passordet behandles med SASLprep-profilen av stringprep før det UTF-8-kodes og hashes. PDFlibPas, PDF-biblioteket for Delphi og C++Builder, utfører den forberedelsen inne i Encrypt, EncryptFile og DecryptFile

Dette er ikke historien om feil passord og ikke historien om tillatelsesbiter. Hvis brukerne dine skriver inn et passord du aldri utstedte, er gjenforsøksmaskineriet i artikkelen om gjenforsøk av krypterte PDF-passord det du vil ha, og hvis du prøver å finne ut hva en eksisterende fil faktisk håndhever, dekker revisjon av kryptering og tillatelser den grunnen. Denne er smalere og merkeligere: passordet er riktig, brukeren skrev det riktig, og filen nekter likevel å åpne et annet sted

Hvorfor åpner et ikke-ASCII-passord i én leser, men ikke en annen?

Fordi de to programmene hasher forskjellige bytesekvenser fra de samme tastetrykkene. Nøkkelutledningen for revisjon 6 i ISO 32000-2 §7.6.4.3.3 tar passordet som UTF-8-bytes, trunkerer til 127 bytes, legger til et salt, og kjører den herdede hashen; resultatet sjekkes mot /U- og /O-oppføringene i krypteringsordboken. Ingenting i den kjeden er upresist. Én avvikende byte hvor som helst i inndataen produserer en helt annen digest, valideringen feiler, og leseren har akkurat én ting den kan si: feil passord

Bytene divergerer fordi Unicode tilbyr flere måter å skrive det som ser ut som det samme passordet. Et kinesisk passord kan komme som forhåndssammensatte tegn fra én inndatametode og som kompatibilitetsformer fra en annen. Et tysk eller fransk passord kopiert ut av et tekstbehandlingsprogram kan bære et HARD MELLOMROM (U+00A0) der brukeren tror det er et vanlig mellomrom, eller en MYK BINDESTREK (U+00AD) som gjengis som ingenting i det hele tatt. SASLprep finnes for å slå alt dette sammen til én kanonisk form før noen hasher noe som helst, slik at hver samsvarende implementasjon utleder samme nøkkel fra samme hensikt

Hva endrer SASLprep egentlig med et passord?

RFC 4013 definerer SASLprep som en profil av stringprep-rammeverket i RFC 3454, og det er fire ordnede trinn snarere enn én transformasjon. Mapping kommer først: RFC 3454-tabell C.1.2 (ikke-ASCII-mellomrom) mappes til U+0020, og tabell B.1 (tegn som vanligvis mappes til ingenting) slettes helt. Normalisering til Unicode NFKC følger, som er trinnet som folder kompatibilitetstegn og kombinerende sekvenser. Deretter avviser den forbudte-utdata-sjekken alt i tabellene C.2.1 til C.9. Til slutt anvendes den bidireksjonelle regelen fra RFC 3454 seksjon 6 på den normaliserte strengen

PDFlibPas implementerer hele profilen i enheten PDFlibSASLprep, som eksponerer ett enkelt inngangspunkt. PLSASLprepPassword tar det rå passordet, skriver den forberedte formen inn i en var-parameter, og returnerer False når passordet må avvises. Funksjonen er bevisst total på den lykkelige stien: et rent ASCII-passord kommer tilbake byte-identisk, så ingenting endres for eksisterende utrullinger

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 tabellene ikke løser

Ett kodepunkt havner i to RFC 3454-tabeller samtidig, og de to tabellene er uenige. ZERO WIDTH SPACE (U+200B) faller innenfor C.1.2-området U+2000 til U+200B, hvor regelen sier at det skal mappes til U+0020, og det faller også innenfor B.1-området U+200B til U+200D, hvor regelen sier at det skal slettes. Les mappingtrinnet i én eller annen rekkefølge, og du får forskjellige bytes ut av samme passord: a+U+200B+b forberedes til a b under C.1.2 og til ab under B.1. RFC 4013 nevner begge tabellene og sier ikke hvilken som vinner, så dette er en genuin tvetydighet i spesifikasjonen snarere enn en lesefeil. PDFlibPas tester C.1.2-medlemskap først og mapper derfor U+200B til et mellomrom, som er atferden andre bredt utrullede stringprep-implementasjoner har landet på; å matche dem er det eneste som betyr noe her, fordi målet er byte-enighet med hvilken leser kunden nå enn bruker

Lesing av gamle filer: forberedt først, rå deretter

Fiksen skaper sitt eget kompatibilitetsproblem. Hver AES-256-fil skrevet før endringen hashet det rå UTF-8-passordet, så å gjøre leseren strengt samsvarende ville låse kunder ute av sine egne arkiver. PDFlibPas løser dette på lesesiden ved å prøve to kandidater i rekkefølge. TPDFDocument.SetPassword bygger en kandidatliste som starter med den forberedte formen og faller tilbake til den rå formen, og den legger bare til den forberedte oppføringen når dokumentet faktisk er AES-256 og de to formene er forskjellige. For et ASCII-passord er formene identiske, listen inneholder én oppføring, og kostnaden ved hele mekanismen er én strengsammenligning. DecryptFile gjør det samme langs sin direkte AES-256-omskrivingssti, og kaller PLDirectDecryptFileAES256 med det forberedte passordet 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;

Fallback-en bærer én sikring verdt å kopiere. Det andre forsøket i DecryptFile kjører kun når den forberedte og den rå formen er forskjellige og det første forsøket ikke rapporterte noen hard feilkode. En strukturell feil betyr at inndataen er skadet eller ikke er krypteringsrevisjonen du antok, og å gjenforsøke en ødelagt fil med et annet passord bruker bare opp en ny full parsing over fiendtlige inndata; resonnementet bak den refleksen er lagt ut i notatet om trygg parsing av upålitelige PDF-er. Merk også at det ikke finnes noen fallback på skrivesiden, og den asymmetrien er tilsiktet. Lesing tolererer historie, skriving gjør ikke det: hver ny AES-256-fil får de samsvarende bytene

Hvilke passord blir avvist rett ut, og hva er feil 604?

SASLprep kan avvise et passord helt, og når den gjør det, må krypteringen feile høylytt i stedet for å stille erstatte noe. Encrypt og EncryptFile forbereder både eier- og brukerpassordet når Strength er 3 eller 4, returnerer 0 ved avvisning, og setter LastErrorCode til PDFLIB_ERROR_PASSWORD_SASLPREP, som er 604. To familier av inndata utløser den. Tabellene for forbudt utdata avviser kontrolltegn (C.2.1 og C.2.2), private bruk-kodepunkter (C.3), ikke-tegn (C.4), enslige surrogater (C.5), U+FFFD (C.6), ideografiske beskrivelsestegn (C.7), og skjermkontroll- og taggingområdene (C.8 og C.9). Separat avviser bidi-regelen fra RFC 3454 seksjon 6 enhver streng som inneholder et RandALCat-tegn fra tabell D.1, med mindre strengen både begynner og slutter med ett og ikke inneholder noen venstre-til-høyre-bokstaver i det hele tatt

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-regelen er den som vil overraske brukerstøtten din. Et arabisk eller hebraisk passord som slutter på et vestlig siffer, eller ett med en tilfeldig latinsk bokstav i midten, avvises av spesifikasjonen selv om det ser helt rimelig ut i inntastingsfeltet. Vis 604 som en melding om passordtegnene, ikke som en generisk krypteringsfeil, ellers vil noen bruke en ettermiddag på å lete etter en feil i nøkkelutledningen din

Ærlige begrensninger: NFKC, en tilnærmet LCat, og én Delphi-felle

To deler av implementasjonen er tilnærminger, og begge fortjener å bli sagt tydelig i stedet for å graves ned. NFKC-normalisering utføres av Windows' NormalizeString-API, lastet dynamisk fra Normaliz.dll. Når det biblioteket ikke er tilgjengelig, brukes den mappede strengen unormalisert, som betyr at mapping- og forbud-trinnene fortsatt kjører, men kompatibilitetsfolding gjør det ikke. I praksis har DLL-en fulgt med hver Windows-utgivelse siden Vista, så den nedgraderte stien er en pre-Vista- og ikke-Windows-bekymring snarere enn en levende en, men et passord som stoler på NFKC-folding ville produsere andre bytes der, og det er en reell, om enn fjern, divergens. Bidi-sjekken er den andre tilnærmingen: å oppdage LCat-tegn bruker de vanlige bokstavområdene i stedet for hele RFC 3454-tabell D.2, og retningen på den feilen er det som gjør den akseptabel. Et oversett LCat-tegn kan bare føre til at bidi-regelen godkjenner der spesifikasjonen ville ha avvist, aldri motsatt, og det rører aldri mapping- eller normaliseringstrinnene, så den forberedte bytesekvensen til et godkjent passord er uendret. Restrisikoen er derfor en policy-divergens snarere enn en byte-divergens: et eksotisk-skrift-passord som en strengere implementasjon ville nektet å godta i det hele tatt. Hvert passord begge sider godtar hasher identisk, som er egenskapen interoperabilitet faktisk avhenger av

Til slutt, en Delphi-syntaksfelle som koster en time hvis du ikke har støtt på den før. Når en funksjon returnerer en prosedyretype, kaller ikke tildeling uten parenteser den. Kompilatoren leser Proc := GetNormalizeProc; som å ta adressen til GetNormalizeProc selv, og rapporterer deretter E2009 med den lite hjelpsomme klagen om at kallekonvensjonene er forskjellige, fordi tilgangsmetoden bruker standardkonvensjonen mens den importerte API-typen er stdcall. De tomme parentesene er obligatoriske

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;

Passordforberedelse er en av de detaljene som aldri dukker opp i en funksjonsliste og som avgjør om et kryptert dokument overlever møtet med en kunde i en annen lokalitet. Inngangspunktene Encrypt, EncryptFile, DecryptFile og SetPassword beskrevet her er del av losLab PDF Developer Library Pascal Edition for Delphi og C++Builder, hvis produktside bærer hele krypteringsreferansen og den komplette feilkodetabellen