Technisch artikel

Waar eindigt een PDF-revisie? Vervalste updates na tekenen

Een contract wordt getekend, krijgt daarna nog één update, en een auditscript voegt die update bij de getekende revisie in of laat de herschreven pagina als validatiemateriaal door. Beide gebeuren niet meer zodra een revisie eindigt waar startxref een sectie benoemt die de /Prev-keten bereikte en een herschreven object zijn eerdere klasse houdt — de twee regels achter de PDFlibPas-revisiekaart

Waarom %%EOF de verkeerde plek is om een bestand te knippen

Elke incrementele update wordt afgesloten met zijn eigen end-of-file marker (ISO 32000-1 §7.5.6), dus het bestand bij elke %%EOF knippen lijkt de voor de hand liggende manier om revisies te vinden. De marker is echter maar vijf bytes, en niets houdt die bytes tegen als ze opduiken in een content stream, een embedded file of een fontprogramma. Gelineariseerde bestanden voegen een tweede probleem toe: Annex F (§F.3.4) staat toe dat de trailer van de eerste pagina eindigt met zijn eigen startxref en %%EOF vlak bij het begin van het bestand, ruim vóór de hoofd-xref-tabel. Knippen bij elke marker verzint revisies die geen enkele xref-sectie steunt. Gokken welke markers nep zijn is erger — die gok is precies wat een vervalser mag vormgeven. De PDFlibPas-revisiekaart werkt precies omgekeerd. Hij volgt de /Prev-keten, noteert de byte-offset van elke xref-sectie die de keten echt las en scant pas daarna naar startxref N … %%EOF-reeksen, waarbij hij een grens alleen houdt als N een van die genoteerde offsets is. Een verdwaalde marker in een stream zou een startxref voor zich nodig hebben die een echte sectie benoemt voordat hij ergens voor telt

PDFlibPas-revisiekaart in Delphi die de /Prev-keten volgt en startxref-offsets matcht om de revisiegrenzen van incrementele updates te vinden, en verdwaalde %%EOF-markers in streams en lineair gemaakte eerste-pagina-trailers negeert
Alleen startxref-offsets die de /Prev-keten echt heeft gelezen markeren een revisie-einde, waardoor verdwaalde %%EOF-bytes in streams niets te zeggen hebben over waar PDFlibPas het bestand knipt

De bytes tussen die drie tokens zijn rommeliger dan §7.5.5 doet vermoeden. De standaard tekent startxref, de offset en de marker op opeenvolgende regels, maar end-of-line-markers en de andere witruimtetekens van §7.2.2 (Tabel 1) mogen herhaald worden, en geen enkele regel stelt daar een grens aan. Mist u één opgevulde marker, dan versmelten twee revisies. De getekende lijkt nu te eindigen na bytes die niemand heeft getekend. De PDFlibPas-revisiekaart volgt de reeks van keyword naar operand naar marker hoeveel witruimte ze ook scheidt en waar die vulling in het bestand ook valt, en hij telt elk keyword één keer. Het trailer-keyword heeft een vergelijkbaar los slot: << is een delimiter (§7.2.2, Tabel 2) en §7.5.5 eist nooit een regeleinde na trailer, dus trailer<< op één regel is conforme syntaxis. Regel voor regel gelezen ziet die regel eruit als een kop van een xref-subsectie — en de keten stopt halverwege

Over de revisiekaart lopen met PDFlibPas

De revisie-accessors staan gedeclareerd op TPDFlib en zijn geïmplementeerd in de Small Business-editie: maak een TPDFlibSmallBusiness aan vanuit de unit PDFlibSmallBusiness (Enterprise stamt ervan af en draagt dezelfde aanroepen). Op een kale TPDFlib geven ze hun faalwaarde terug (0, 7 of -1) en zetten ze LastErrorCode op PDFLIB_ERROR_SMALLBUSINESS_REQUIRED. Revisies worden genummerd vanaf 0, het originele document. Elke aanroep neemt een bestandsnaam en een wachtwoord en bouwt de kaart uit de bytes op schijf, dus de antwoorden beschrijven het bestand zoals het staat, nooit een document dat u toevallig geladen en bewerkt heeft. GetDocumentRevisionKind meldt de verstrekkende verandering in een revisie. Een update die een OCSP-response toevoegt en ook een pagina herschrijft wordt als de paginaherschrijving gemeld:

SoortBetekenisTypische bron
0Alleen huishoudelijk werk: objecten die voor een reader niets veranderenCross-reference stream, een nieuwe object stream-container
1ValidatiemateriaalDocument security store, VRI, certificaten, CRL's, OCSP-responses
2Document time stampEen /DocTimeStamp-signatuur en het veld dat hem draagt
3SignatuurEen bestaand signatuurveld dat zijn waarde krijgt
4FormulierveldEen veldwaarde of zijn appearance
5AnnotationElke annotation anders dan een widget
6DocumentinhoudAlles dat kan veranderen wat het document toont
7Kon niet worden bepaaldIets in de revisie was niet leesbaar; behandel hem als onveilig
uses
  System.SysUtils, PDFlibSmallBusiness;

procedure ListRevisions(const FileName, Password: WideString);
const
  KindNames: array[0..7] of string = ('housekeeping', 'validation material',
    'document time stamp', 'signature', 'form field', 'annotation',
    'content', 'undetermined');
var
  Pdf: TPDFlibSmallBusiness;
  Count, I, J, Kind, Changes: Integer;
begin
  Pdf := TPDFlibSmallBusiness.Create;
  try
    Count := Pdf.GetDocumentRevisionCount(FileName, Password);
    if Count = 0 then
      raise Exception.Create('the cross-reference chain could not be followed');
    if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
      Writeln('WARNING: history incomplete, every figure below is a lower bound');
    for I := 0 to Count - 1 do
    begin
      Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
      if (Kind < 0) or (Kind > 7) then
        Kind := 7;
      Writeln(Format('revision %d: bytes %d..%d, %s', [I,
        Pdf.GetDocumentRevisionStart(FileName, Password, I),
        Pdf.GetDocumentRevisionEnd(FileName, Password, I),
        KindNames[Kind]]));
      // één regel 'objnum,change,class,detail' per object
      Changes := Pdf.GetDocumentRevisionChanges(FileName, Password, I);
      if Changes <> 0 then
      try
        for J := 1 to Pdf.GetStringListCount(Changes) do  // items tellen vanaf 1
          Writeln('    ' + Pdf.GetStringListItem(Changes, J));
      finally
        Pdf.ReleaseStringList(Changes);
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

GetDocumentRevisionsComplete is de aanroep die u leest voordat u een van de anderen vertrouwt. Hij geeft 1 terug alleen als de keten helemaal terug tot de oorspronkelijke sectie werd gevolgd; een gebroken of cyclische /Prev, of een keten dieper dan 256 schakels, laat de oudste updates onzichtbaar, en elke telling en index wordt dan een ondergrens. Een diepte van 256 biedt ruimte voor de opsla-en-teken-geschiedenis van echte bestanden, en een keten die naar zichzelf wijst eindigt nog steeds vrijwel meteen. Let op de indexering in de lus. GetStringListItem telt vanaf 1 terwijl revisies vanaf 0 tellen, en de binnenlus bij 0 laten beginnen verliest het eerste gewijzigde object van elke revisie zonder enige foutmelding

Hoe een vervalsde update zich voordoet als validatiemateriaal

PAdES long-term validation voegt op legitieme wijze updates toe na de laatste signatuur: een document security store (/Type /DSS), per-signatuur /VRI-dictionaries, certificaat- en intrekkingsstreams, document time stamps. Certificatieniveaus laten ze door, en daarmee is validatiemateriaal het waardevolste label dat een update kan dragen. Het is ook het goedkoopste om te vervalsen. /Type is precies wat de update schreef. Een viewer die een pagina tekent kijkt nooit naar de /Type van een content stream, een resource-dictionary of een font, dus een update kan de content stream van pagina 3 herschrijven, /Type /DSS aan zijn dictionary toevoegen, en elke renderer schildert nog steeds de nieuwe tekst terwijl een classificeerder die /Type gelooft huishoudelijk werk ziet. /VRI, /Sig en /DocTimeStamp werken hetzelfde

De PDFlibPas-revisiekaart negeert het label dat een update op een door hem herschreven object schrijft. Een herschreven object telt alleen als validatiemateriaal, time stamp of signatuur wanneer de eerdere versie van datzelfde object dat al was. Certificaat-, CRL- en OCSP-response-streams dragen geen eigen type, dus ze worden herkend via de document security store die naar hen wijst — en alleen een store die in de beoordelde revisie of een oudere werd weggeschreven telt. Een later toegevoegde store kan niet terugreichen en borg staan voor een object dat de getekende revisie schreef. Veranderingen aan de structuur van het document tellen als inhoud zodra ze veranderen wat een reader zou oplossen:

PDFlibPas GetDocumentRevisionKind meldt een vervalste /Type /DSS-update die een pagina-content stream herschrijft als documentinhoud, omdat de revisiekaart een herschreven object classificeert op zijn eerdere versie
Een herschreven pagina-content stream van het label /Type /DSS voorzien verandert niets voor de renderer, en PDFlibPas meldt de update nog steeds als documentinhoud want het object was daarvoor pagina-inhoud
  • het vervangen van een bestaande object stream, want elk gecomprimeerd object erin lost nu op naar de vervanger (een splinternieuwe container blijft huishoudelijk werk, want elk lid krijgt zijn eigen entry en wordt daar beoordeeld)
  • het toevoegen van een catalog of een pagina
  • een trailer waarvan /Root een andere catalog benoemt, zelfs als de update niets herdefinieert dat de signatuur dekte, want het wisselen van de root vervangt het hele document zonder één getekend object aan te raken
  • een herschreven stream waarvan de dictionary byte-identiek is aan de oude: een indirecte /Length of een vervangende body opgevuld tot dezelfde grootte houdt de dictionary onveranderd terwijl de tekening verandert, dus alleen objecten zonder stream-body worden op dat bewijs als onveranderd behandeld

Leden van een object stream hebben geen header om te strippen

Sinds PDF 1.5 kan een update herschreven objecten binnen een object stream opslaan, en §7.5.7 is ondubbelzinnig over het formaat: alleen de objectwaarden worden opgeslagen en de keywords obj en endobj mogen niet worden gebruikt. Code die een object op zijn locatie leest en de header verwijdert door naar de string obj te zoeken, vindt daarom aan de voorkant van een lid niets en blijft doorzoeken tot in zijn body. Geef een na het tekenen herschreven pagina-dictionary een onschuldig ogende string als (obj<</Type/DSS>>) en die zoektocht stopt binnen de string. De decoder ontvangt <</Type/DSS>> — een store-dictionary die de revisie nooit schreef. De PDFlibPas-revisiekaart parseert N G obj van de voorkant van een ongecomprimeerd object, controleert dat de nummers het verwachte object benoemen en behandelt een ontbrekende of niet-matchende header als onleesbaar in plaats van verder te speuren

Cross-reference streams dragen een ambiguïteit op veldniveau met zich mee. Onder §7.5.8.3 (Tabel 18) houdt alleen een type 1-entry een byte-offset vast. Het tweede veld van een type 2-entry is het objectnummer van de omvattende object stream en het derde is de index daarin, terwijl het tweede veld van een type 0-entry het volgende vrije objectnummer is. Leest u dat objectnummer als offset, dan belandt een gecomprimeerde entry in welke revisie die kleine byteposities ook beslaat — vrijwel altijd het originele document, wat de herschrijving binnen de getekende bytes verstopt. De revisiekaart plaatst elke entry bij de xref-sectie die hem declareerde. Dezelfde clausule zegt dat een entry-type anders dan 0, 1 of 2 een verwijzing naar het null-object is, dus zulke entries voegen niets toe of vrij en blijven van de kaart af

Wanneer moet een signatuuraudit weigeren te antwoorden?

Zodra het bestand een stuk van zijn eigen geschiedenis achterhoudt. De revisiekaart meldt dat op drie manieren: GetDocumentRevisionsComplete geeft 0 terug, GetDocumentRevisionKind geeft 7 terug voor een of andere revisie na de signatuur, of GetSignatureRevisionIndex geeft -1 terug. Die laatste waarde dekt een ByteRange die niet op een revisiegrens eindigt — bytes zitten binnen de range die de signatuur claimt te dekken — en evenzeer een veld dat niet bestaat, geen signatuurwaarde draagt of in een bestand zit dat niet leesbaar was. Stuur alle drie naar handmatige beoordeling met de revisie-index en de GetDocumentRevisionChanges-regels erbij, zodat wie het bestand opent bij objectnummers kan beginnen

// Toetst de updates na één signatuur aan een DocMDP-achtig beleid.
// Soorten zijn op ernst geordend, dus elk beleidsniveau is een plafond:
// P 1 tolereert huishoudelijk werk, validatiemateriaal en time stamps (0..2),
// P 2 voegt tekenen en formulieren invullen toe (..4), P 3 voegt annotations toe (..5).
function UpdatesWithinPolicy(Pdf: TPDFlibSmallBusiness;
  const FileName, Password, FieldName: WideString; Level: Integer): Boolean;
const
  Ceiling: array[1..3] of Integer = (2, 4, 5);
var
  Signed, Count, I, Kind: Integer;
begin
  Result := False;
  if (Level < 1) or (Level > 3) then
    Exit;
  if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
    Exit;                        // een deel van de geschiedenis werd nooit gezien
  Signed := Pdf.GetSignatureRevisionIndex(FileName, Password, FieldName);
  if Signed < 0 then
    Exit;                        // buiten een revisiegrens, geen zo getekend veld,
                                 // of het bestand was niet leesbaar
  Count := Pdf.GetDocumentRevisionCount(FileName, Password);
  for I := Signed + 1 to Count - 1 do
  begin
    Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
    if (Kind = 7) or (Kind > Ceiling[Level]) then
      Exit;                      // 7 ligt boven elk plafond; vermeld voor de lezer
  end;
  Result := True;
end;

De plafonds volgen ISO 32000-2 §12.8.2.2 (Tabel 257): P 1 staat geen enkele verandering toe, 2 staat invullen en tekenen toe, 3 voegt annotations toe, en incrementele updates die alleen DSS-data of document time stamps toevoegen tellen op geen enkel niveau als verandering. ISO 32000-1 §12.8.2.2 definieert dezelfde drie waarden zonder die vrijstelling. Schrijf de lus zelf wanneer het beleid van u is en niet van de certificeerder, bijvoorbeeld een intake-regel die geannoteerde contracten weigert welke P het bestand ook draagt. Moet het beleid uit het bestand komen, dan leest GetSignatureModificationVerdict uit dezelfde klasse het certificatieniveau, past de per-veld-locks toe die in bewerkingen van FieldMDP-vergrendelde formuliervelden opsporen staan beschreven, en geeft 5 terug voor het off-boundary-geval. Beiden beantwoorden een andere vraag dan veranderingen classificeren tegen een kopie van het document dat u bewaarde: de revisiekaart heeft niets nodig behalve het bestand ervoor

PDFlibPas DocMDP-achtige signatuuraudit in Delphi met GetDocumentRevisionKind-plafonds voor P 1, P 2 en P 3, waarbij eerst GetDocumentRevisionsComplete en GetSignatureRevisionIndex worden gecontroleerd
Elk DocMDP-niveau wordt een plafond op de revisiesoort: P 1 tolereert soorten 0 tot 2, P 2 tot 4, P 3 tot 5, en een onbepaalde revisie faalt op elk niveau

Toevoegen volgt dezelfde keten. De trailer van een incrementele update moet een /Prev-entry dragen met de locatie van de vorige xref-sectie (§7.5.6), en een document dat alleen te openen was door de xref-tabel op te bouwen vanuit een volledige scan heeft zo'n locatie niet te geven. AppendToFile geeft er 0 voor terug en laat het doelebestand onaangeroerd. Sla het volledig op en teken in plaats daarvan de schone kopie — een volledige herschrijving neemt bestaande signaturen niet mee

Voor een archief van getekende contracten draait u UpdatesWithinPolicy één keer per signatuurveld voordat er iets nieuws wordt toegevoegd. Een bestand waarvan de latere revisie als soort 7 leest moet eerst bij een mens terechtkomen, vóór elke PDFlibPas-signing- of long-term-validatiestap die de onleesbare revisie onder nog een update zou begraven