Technisch artikel

PDF-cryptfilters in Delphi: StmF-, StrF- en EFF-beleid

De HotPDF Delphi PDF-component implementeert het crypt-filtermodel van ISO 32000-1 §7.6.5 als drie onafhankelijke policies in plaats van één switch: ConfigureCryptFilterDefaults wijst het stringfilter /StrF, het streamfilter /StmF en het embedded-filefilter /EFF afzonderlijk toe, SetStreamCryptFilter overschrijft één stream en GetLoadedCryptFilterInfo rapporteert wat een binnenkomend bestand declareert. De meeste interopbugs met versleutelde PDF's zitten in de gaten tussen die drie

Dit is de fout die mensen deze laag in stuurt. Een team levert een document waarin de pagina-inhoud leesbaar moet blijven voor een downstreamtool maar de bijlage niet, dus het stelt /EFF /StdCF in en laat /StmF /Identity staan. Acrobat opent het probleemloos. Een conforme reader geeft de bijlage terug als ciphertext-rommel, omdat /EFF een policy aan de producerkant is over welk filter op embedded files van toepassing is, terwijl een algemene reader een niet-gemarkeerde stream nog steeds via /StmF resolveert. De fix is geen andere waarde voor /EFF. De fix is een expliciet /Crypt-filter op de embedded-file-stream zelf

Wat bestuurt de crypt-filterlaag werkelijk?

Cryptfilters zitten tussen het encryptiealgoritme en de objectgraph en beslissen welke objecten het algoritme raakt, niet hoe het werkt. Het /CF-dictionary binnen het encryption dictionary mapt namen naar filterdefinities, elk met een /CFM-methode, een optionele /Length en een /AuthEvent. De drie toplevelentries /StrF, /StmF en /EFF kiezen vervolgens welk benoemd filter geldt voor strings, streams zonder expliciet filter en embedded files. HotPDF beperkt bewust wat zijn ingebouwde handlers zullen schrijven. ConfigureCryptFilterDefaults accepteert alleen de gereserveerde namen voor de actieve handler: de Standard security handler emit /StdCF of /Identity, de public-key-handler emit /DefaultCryptFilter of /Identity en al het andere werpt EArgumentException op de callsite. Filters die externe producers onder andere namen schrijven, blijven wel behouden op de load-, inspectie- en compatibility-rewrite-paden, zodat HotPDF als writer conservatief en als reader permissief is. Er gelden nog twee guards: de call werpt EInvalidOpException zodra documentserialisatie is begonnen en opnieuw wanneer het document in een incremental update zit, omdat encryptiebeleid niet tussen revisions van hetzelfde bestand mag veranderen

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'wrapper.pdf';
    Pdf.OwnerPassword := 'owner-secret';
    Pdf.UserPassword := 'open-secret';
    Pdf.CryptKeyLength := aes128;
    // strings versleuteld, paginastreams plaintext, bijlagen versleuteld
    Pdf.ConfigureCryptFilterDefaults('StdCF', 'Identity', 'StdCF');
    Pdf.ActivateProtection := True;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(50, 50, 0, 'Visible stream operators');
    Pdf.AddDocumentAttachment('payload.bin', 'Encrypted payload');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Eén beperking moet vooraf worden genoemd, omdat die laat wordt gecontroleerd en mensen verrast. Named cryptfilters in HotPDF vereisen documentencryptie met aes128, aes256 of aesgcm. Configureer een filterpolicy boven op RC4 k40 of k128, dan werpt de validatiepass die bij het inschakelen van encryptie draait een fout op in plaats van stilletjes het keytype te promoveren. Dat is dezelfde ontwerpkeuze als in de rest van het AES-256-PDF-encryptiepad in Delphi: weiger een ambigue configuratie in plaats van te raden wat de caller bedoelde

Waarom betekent de /Length-entry twee verschillende dingen?

Omdat de specificatie hem afhankelijk van de security handler in twee verschillende eenheden definieert, en HotPDF beide moet respecteren. In een crypt-filter-dictionary waarvan /CFM /V2 is, staat de /Length-entry bij de Standard security handler in bytes en bij de public-key-handler in bits. De /Length in het encryption dictionary naast /V (ISO 32000-1 §7.6.2) staat altijd in bits. Lees je een filterdictionary met /Length 16, dan heb je in een Standard-handlerbestand een key van 128 bits en in een public-keybestand een geweigerd bestand. HotPDF normaliseert dit bij het vastleggen van de geladen configuratie. Het vermenigvuldigt de /Length van een /V2-filter alleen met acht wanneer het bestand niet met public key is versleuteld, valt terug op het documentniveau-/Length wanneer het filter geen eigen waarde heeft en slaat het resultaat op in THPDFCryptFilterInfo.KeyLengthBits. AESV2 staat vast op 128 bits en AESV3 en AESV4 op 256, omdat die methoden geen onderhandelbare keygrootte hebben. Daarna komt het strikte deel: alleen 40-bit en 128-bit /V2 worden geaccepteerd. Een filter dat op een andere lengte uitkomt, wordt als unavailable gemeld en de operatie faalt, in plaats van naar 128 te worden afgerond op de theorie dat de meeste producers toch 128 bedoelden. Stilletjes een keylengte normaliseren is hoe je een bestand uitlevert dat op jouw machine decrypt en nergens anders

var
  Reader: THotPDF;
  Info: THPDFCryptFilterInfo;
  I: Integer;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    if Reader.LoadFromFile('incoming.pdf', 'open-secret') <> 1 then
      Exit;
    // /StrF en /StmF standaard Identity; /EFF standaard /StmF
    WriteLn(Reader.LoadedStringCryptFilterName);        // StdCF
    WriteLn(Reader.LoadedStreamCryptFilterName);        // Identity
    WriteLn(Reader.LoadedEmbeddedFileCryptFilterName);  // StdCF
    for I := 0 to Reader.GetLoadedCryptFilterCount - 1 do
      if Reader.GetLoadedCryptFilterInfo(I, Info) then
        if (Info.Method = hcfmV2) and
           not (Info.KeyLengthBits in [40, 128]) then
          raise Exception.CreateFmt(
            'crypt filter /%s: unsupported V2 key length %d',
            [String(Info.Name), Info.KeyLengthBits]);
  finally
    Reader.Free;
  end;
end;

Wat garandeert /CFM /None en waarin verschilt /Identity?

Ze bereiken dezelfde uitkomst via verschillende routes, en ze door elkaar halen breekt lookups. Een named filter waarvan /CFM /None is, en een named filter dat /CFM helemaal weglaat, betekenen allebei dat dit filter geen encryptie of decryptie uitvoert — HotPDF mapt de ontbrekende entry vóór het resolven naar None, zodat beide op hcfmNone uitkomen met een vastgelegde keylengte van nul. /Identity verschilt van aard: het is de gereserveerde naam die de lookup in /CF volledig omzeilt, zodat een document naar /Identity mag verwijzen zonder het ergens in /CF te definiëren. PDF-namen zijn hoofdlettergevoelig, waardoor nog één implementatiedetail niet onderhandelbaar is: geen enkele crypt-filterlookup mag case-insensitive zijn. HotPDF resolveert namen in het /CF-subdictionary, de /Length-entry van het filter en de /Type-check van de stream via hoofdlettergevoelige dictionarylookups. Een bestand dat /stdcf definieert terwijl /StmF naar /StdCF wijst, is malformed, en die twee als dezelfde key behandelen zou een detecteerbare authoringbug veranderen in een verkeerde key die stilletjes op elke stream van het document wordt toegepast

/EFF laten gelden op embedded-file-streams

Wanneer /EFF verschilt van /StmF, heeft de embedded-file-stream een expliciete vooraanstaande /Crypt-entry nodig in zijn /Filter en een overeenkomend /DecodeParms-dictionary met /Name op dezelfde arraypositie. HotPDF werkt dit per stream uit bij het saven: het detecteert /Type /EmbeddedFile, erft het geconfigureerde embedded-file-filter en emit de expliciete /Crypt-marker alleen wanneer die geërfde naam afwijkt van de effectieve streamdefault. Wanneer /EFF en /StmF overeenkomen, wordt geen marker geschreven, omdat een reader toch hetzelfde filter zou resolven. De arraypositie is dan even belangrijk als de naam. Wanneer HotPDF een stream terugleest, scant het /Filter op de /Crypt-entry, registreert het de index en zoekt het daarna precies diezelfde index op in de /DecodeParms-array om de /Name te vinden. Een /Crypt op index 0 met parameters op index 1 resolveert naar /Identity en niet naar jouw filter. Daarom vult de writer de parameterarray ook met een null wanneer de stream eerder wel een /Filter maar geen /DecodeParms had: de posities moeten uitgelijnd blijven

Daaronder zit een scherpere valkuil. Als het bestaande /Filter of /DecodeParms een indirect object is — gebruikelijk in bestanden van generators die één filterarray over meerdere streams delen — zou /Crypt ter plaatse invoegen een gedeelde filtergraph muteren en elke andere stream die ernaar wijst beschadigen. HotPDF resolveert het indirecte object en kloont het eerst naar een directe stream-private objectkopie, waarbij object- en generation numbers worden gewist zodat de oorspronkelijke indirecte root nooit in de nieuwe array wordt ingebed. Voor een stream die al ASCIIHexDecode gebruikte, is het geserialiseerde resultaat /Filter [ /Crypt /ASCIIHexDecode ] met /DecodeParms [ << /Type /CryptFilterDecodeParms /Name /StdCF >> ... ]. Dezelfde positionele discipline geldt voor elke andere filterchain, ook voor de chains die je doorloopt bij het extraheren van afbeeldingen uit een geladen PDF via hun decodefilters

// Editor bevat al een geladen document en ContentStream is een
// THPDFStreamObject waarvan /Filter een indirecte naam /ASCIIHexDecode is
Editor.OwnerPassword := 'owner-secret';
Editor.UserPassword := 'open-secret';
Editor.CryptKeyLength := aes128;
Editor.ConfigureCryptFilterDefaults('StdCF', 'Identity');
Editor.SetStreamCryptFilter(ContentStream, 'StdCF');
Editor.ActivateProtection := True;
Editor.SaveLoadedDocument('out.pdf');

// Een lege naam wist de override en verwijdert de verouderde /Crypt-
// entry samen met de decodeparameters bij de volgende save
Editor.SetStreamCryptFilter(ContentStream, '');
Editor.SaveLoadedDocument('cleared.pdf');

Erven object streams het /Encrypt-beleid van het document?

Nee, en aannemen dat dit wel zo is, is een betrouwbare manier om rommel te produceren. Een objectstream moet het werkelijke /StmF-beleid volgen of zijn eigen expliciete /Crypt-marker; alleen de aanwezigheid van een /Encrypt-dictionary maakt niet elke /ObjStm-container ciphertext. Een document met /StmF /Identity heeft plaintext-objectstreams, ook al zijn zijn strings volledig versleuteld, en een decoder die ze toch decrypt voert aan de inflatefase input toe die nooit deflate-output is geweest

De consequentie voor member objects is het deel dat je tweemaal moet lezen. Volgens ISO 32000-1 §7.5.7 zijn strings binnen een versleutelde objectstream al plaintext zodra de container zelf is gedecrypt, dus ze opnieuw decrypten zou een double-decrypt zijn. HotPDF bewaakt dat door te vragen of de container van elk type-2-object versleuteld was en het object over te slaan als dat zo is, waarbij de skips worden geteld in XRefProbeDecryptObjStmSkips als direct bewijs dat de guard afging. Wanneer de container plaintext was, zijn de memberstrings nooit door iets afgedekt, dus materialiseert HotPDF die members en past het /StrF op elk afzonderlijk toe — met als key, precies zoals de implementatie het doet, het objectnummer en generation number van het member en niet het objectnummer van de omvattende /ObjStm. Draai dat om op een bestand met gemengd beleid en elke string in elk gecomprimeerd object decodeert naar ruis. De regels op containerniveau worden verder behandeld in de notities over PDF-objectstreams en incremental updates

Waar weigert HotPDF te raden?

Crypt-filtersemantiek bestaat niet onder /V 4, dus HotPDF weigert elke override per stream op zo'n bestand met een expliciete fout in plaats van een /Crypt-marker te schrijven die geen enkele conforme reader zou respecteren. Hetzelfde geldt bij het lezen: een encryption dictionary met /V onder 4 wist alle drie geladen filternamen, omdat er niets te rapporteren is. Drie verdere grenzen worden bewust afgedwongen:

  • Een per-streamfilter dat niet Identity is op een met public key versleuteld document wordt geweigerd, omdat stream-specifiek beleid onder de public-key-handler een stream-specifieke recipient envelope nodig heeft die HotPDF nog niet emit
  • Met public key versleutelde embedded files waarvan /EFF verschilt van de effectieve /StmF worden om dezelfde reden geweigerd, in plaats van geschreven in een vorm die niemand kan decrypten
  • Het directe AES-256 fast path voor bestanden geldt alleen wanneer strings, streams en embedded files allemaal naar dezelfde crypt-filtermethode resolven en geen enkel object in het bestand een expliciete /Crypt bevat; een gemengd beleid of plaintext-metadata dwingt een fallback naar het volledige objectgraph-pad af

Geen van deze grenzen is een performancebeslissing. Ze markeren de plaatsen waar een verkeerde gok een PDF oplevert die in de ene viewer opent, in een andere faalt en de developer helemaal geen signaal geeft totdat een klant het meldt. Een weigering bij ConfigureCryptFilterDefaults of tijdens het saven kost één exception; een embedded file die stilletjes met de verkeerde key is bewerkt kost een supportcyclus. Als je Delphi- of C++Builder-software bouwt die versleutelde PDF's produceert of consumeert — selectief plaintext pagina-inhoud met versleutelde bijlagen, met PDF 2.0 versleutelde payloadwrappers of interop met bestanden waarvan je het crypt-filterbeleid niet hebt gekozen — wordt de hier beschreven crypt-filter-API meegeleverd in de actuele HotPDF Delphi PDF-component, naast de encryptie-, objectstream- en incremental-update-paden waarop hij voortbouwt