Teknisk artikel

SASLprep AES-256 PDF-adgangskoder i Delphi med PDFlibPas

En AES-256-krypteret PDF med en ikke-ASCII-adgangskode åbner i programmet, der skrev den, og ingen andre steder. Årsagen er næsten altid et manglende forberedelsestrin: ISO 32000-2 §7.6.4.3.3 kræver, at adgangskoden behandles med SASLprep-profilen af stringprep, før den UTF-8-kodes og hashes. PDFlibPas, PDF-biblioteket til Delphi og C++Builder, udfører den forberedelse inde i Encrypt, EncryptFile og DecryptFile

Dette er ikke historien om den forkerte adgangskode, og det er ikke historien om tilladelsesbit. Skriver dine brugere en adgangskode, du aldrig udstedte, er retry-mekanikken i artiklen om gentagelse af krypterede PDF-adgangskoder det, man skal bruge, og forsøger man at finde ud af, hvad en eksisterende fil faktisk håndhæver, dækker audit'en af kryptering og tilladelser det terræn. Denne er smallere og mærkeligere: adgangskoden er korrekt, brugeren skrev den korrekt, og filen nægter alligevel at åbne et andet sted

Hvorfor åbner en ikke-ASCII-adgangskode i én læser, men ikke i en anden?

Fordi de to programmer hasher forskellige bytesekvenser fra de samme tastetryk. Revision 6-nøgleudledningen i ISO 32000-2 §7.6.4.3.3 tager adgangskoden som UTF-8-bytes, trunkerer til 127 bytes, tilføjer et salt og kører den hærdede hash; resultatet tjekkes mod /U- og /O-posterne i krypteringsdictionary'et. Intet i den kæde er upræcist. Én afvigende byte hvor som helst i input producerer en helt anden digest, valideringen fejler, og læseren har præcis én ting at sige: forkert adgangskode

Bytene afviger, fordi Unicode tilbyder flere måder at skrive, hvad der ligner samme adgangskode. En kinesisk adgangskode kan ankomme som prækomponerede tegn fra én input-metode og som kompatibilitetsformer fra en anden. En tysk eller fransk adgangskode kopieret ud af et tekstbehandlingsprogram kan bære et NO-BREAK SPACE (U+00A0), hvor brugeren tror, der er et almindeligt mellemrum, eller et SOFT HYPHEN (U+00AD), som gengives som ingenting. SASLprep findes for at kollapse alle disse til én kanonisk form, før nogen hasher noget, så hver overholdende implementering udleder samme nøgle fra samme hensigt

Hvad ændrer SASLprep egentlig ved en adgangskode?

RFC 4013 definerer SASLprep som en profil af stringprep-rammeværket i RFC 3454, og det er fire ordnede trin frem for én transformation. Mapping kommer først: RFC 3454-tabel C.1.2 (ikke-ASCII mellemrum) mappes til U+0020, og tabel B.1 (tegn der almindeligvis mappes til intet) slettes helt. Normalisering til Unicode NFKC følger, hvilket er trinnet, der folder kompatibilitetstegn og kombinerende sekvenser. Så afviser prohibited-output-tjekket alt i tabellerne C.2.1 til C.9. Til sidst anvendes den bidirektionale regel fra RFC 3454 sektion 6 på den normaliserede streng

PDFlibPas implementerer hele profilen i PDFlibSASLprep-enheden, som eksponerer ét enkelt entry point. PLSASLprepPassword tager den rå adgangskode, skriver den forberedte form ind i en var-parameter og returnerer False, når adgangskoden må afvises. Funktionen er bevidst total på den lykkelige sti: en ren ASCII-adgangskode kommer tilbage byte-identisk, så intet ved eksisterende deployments ændres

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-tvetydigheden, som tabellerne ikke løser

Ét kodepunkt lander i to RFC 3454-tabeller på samme tid, og de to tabeller er uenige. ZERO WIDTH SPACE (U+200B) falder inden for C.1.2-intervallet U+2000 til U+200B, hvor reglen siger, den skal mappes til U+0020, og den falder også inden for B.1-intervallet U+200B til U+200D, hvor reglen siger, den skal slettes. Læs mapping-trinnet i den ene eller den anden rækkefølge, og man får forskellige bytes ud af samme adgangskode: a+U+200B+b forberedes til a b under C.1.2 og til ab under B.1. RFC 4013 nævner begge tabeller og siger ikke, hvem der vinder, så dette er en reel tvetydighed i specifikationen frem for en læsefejl. PDFlibPas tester C.1.2-medlemskab først og mapper derfor U+200B til et mellemrum, hvilket er den adfærd, andre bredt udbredte stringprep-implementeringer har lagt sig fast på; at matche dem er det eneste, der betyder noget her, fordi målet er byte-enighed med hvilken læser kunden nu engang bruger

Læsning af gamle filer: forberedt først, rå bagefter

Løsningen skaber sit eget kompatibilitetsproblem. Hver AES-256-fil skrevet før ændringen hashede den rå UTF-8-adgangskode, så at gøre læseren strengt overholdende ville låse kunder ude af deres egne arkiver. PDFlibPas løser dette på læsesiden ved at prøve to kandidater i rækkefølge. TPDFDocument.SetPassword bygger en kandidatliste, der starter med den forberedte form og falder tilbage til den rå form, og den tilføjer kun den forberedte post, når dokumentet faktisk er AES-256 og de to former er forskellige. For en ASCII-adgangskode er formerne identiske, listen holder én post, og omkostningen ved hele mekanismen er én enkelt strengsammenligning. DecryptFile gør det samme langs sin direkte AES-256-omskrivningssti ved at kalde PLDirectDecryptFileAES256 med den forberedte adgangskode 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;

Fallbacken bærer én vagt værd at kopiere. Det andet forsøg i DecryptFile kører kun, når den forberedte og den rå form er forskellige og det første forsøg ikke rapporterede en hård fejlkode. En strukturel fejl betyder, at input er beskadiget eller ikke er den krypteringsrevision, man antog, og at prøve igen på en ødelagt fil med en anden adgangskode brænder bare en ekstra fuld gennemgang af fjendtligt input; ræsonnementet bag den reflex er lagt ud i noten om sikker parsing af upålidelige PDF'er. Bemærk også, at der ikke er nogen fallback på skrivesiden, og den asymmetri er tilsigtet. Læsning tolererer historik, skrivning gør ikke: hver ny AES-256-fil får de overholdende bytes

Hvilke adgangskoder afvises helt, og hvad er fejl 604?

SASLprep kan afvise en adgangskode helt, og når den gør, skal kryptering fejle højlydt frem for stiltiende at erstatte noget. Encrypt og EncryptFile forbereder både ejer- og bruger-adgangskoden, når Strength er 3 eller 4, returnerer 0 ved afvisning og sætter LastErrorCode til PDFLIB_ERROR_PASSWORD_SASLPREP, som er 604. To familier af input udløser den. De forbudte output-tabeller afviser kontroltegn (C.2.1 og C.2.2), private-use-kodepunkter (C.3), ikke-tegn (C.4), ensomme surrogater (C.5), U+FFFD (C.6), ideografiske beskrivelsestegn (C.7), samt display-control- og tagging-intervallerne (C.8 og C.9). Separat afviser RFC 3454 sektion 6's bidi-regel enhver streng, der indeholder et RandALCat-tegn fra tabel D.1, medmindre strengen både begynder og slutter med ét og ikke indeholder nogen venstre-mod-højre-bogstaver overhovedet

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-regel er den, der vil overraske jeres supportafdeling. En arabisk eller hebraisk adgangskode, der ender på et vestligt ciffer, eller én med et forvildet latinsk bogstav i midten, afvises af specifikationen, selvom den ser helt fornuftig ud i indtastningsfeltet. Vis 604 som en besked om adgangskodens tegn, ikke som en generisk krypteringsfejl, ellers vil nogen bruge en eftermiddag på at lede efter en fejl i jeres nøgleudledning

Ærlige grænser: NFKC, en tilnærmet LCat, og én Delphi-fælde

To dele af implementeringen er tilnærmelser, og begge fortjener at blive sagt ligeud frem for begravet. NFKC-normalisering udføres af Windows' NormalizeString-API, indlæst dynamisk fra Normaliz.dll. Når det bibliotek ikke er tilgængeligt, bruges den mappede streng unormaliseret, hvilket betyder, at mapping- og forbudstrinnene stadig kører, men kompatibilitetsfoldning gør ikke. I praksis har DLL'en fulgt med hver Windows-udgave siden Vista, så den nedgraderede sti er et pre-Vista- og ikke-Windows-anliggende frem for et levende ét, men en adgangskode, der er afhængig af NFKC-foldning, ville producere andre bytes der, og det er en reel, om end fjern, afvigelse. Bidi-tjekket er den anden tilnærmelse: registrering af LCat-tegn bruger de almindelige bogstavintervaller frem for den fulde RFC 3454-tabel D.2, og retningen af den fejl er det, der gør den acceptabel. Et overset LCat-tegn kan kun få bidi-reglen til at bestå, hvor specifikationen ville have afvist, aldrig omvendt, og det rører aldrig mapping- eller normaliseringstrinnene, så den forberedte bytesekvens af en accepteret adgangskode er uændret. Restrisikoen er derfor en politikafvigelse frem for en byteafvigelse: en eksotisk-skrift-adgangskode, som en strengere implementering ville nægte at acceptere overhovedet. Enhver adgangskode, begge sider accepterer, hasher identisk, hvilket er den egenskab, interoperabilitet reelt afhænger af

Endelig, en Delphi-syntaksfælde, der koster en time, hvis man ikke har ramt den før. Når en funktion returnerer en procedural type, kalder tildeling uden parenteser den ikke. Kompilatoren læser Proc := GetNormalizeProc; som at tage adressen på selve GetNormalizeProc, og rapporterer så E2009 med den uhjælpsomme klage om, at kaldekonventioner er forskellige, fordi accessoren bruger standardkonventionen, mens den importerede API-type er stdcall. De tomme parenteser 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;

Forberedelse af adgangskoder er en af de detaljer, der aldrig optræder på en feature-liste, og som afgør, om et krypteret dokument overlever kontakt med en kunde i en anden locale. Encrypt-, EncryptFile-, DecryptFile- og SetPassword-entry points beskrevet her er en del af losLab PDF Developer Library Pascal Edition til Delphi og C++Builder, hvis produktside bærer den fulde krypteringsreference og den komplette fejlkode-tabel