Technisch artikel

De signatuurwaarde blijft in een versleutelde PDF leesbaar

Een met AES versleuteld contract dat elders is ondertekend laadt met een signatuurwaarde die decodeert tot willekeurige bytes, of bij AES-GCM met een signatuur-dictionary die helemaal niet ontsleutelt. Buiten de trailer-/ID en de encryption-dictionary laat ISO 32000-2 §7.6.2 alleen de signatuur-/Contents onversleuteld, en PDFlibPas houdt hem bij lezen en schrijven cleartext terwijl /Reason en /M versleuteld blijven

De uitzondering die ISO 32000-2 aan de versleutelingsregels toevoegde

ISO 32000-2 §7.6.2 zegt dat versleuteling elke string en stream in het bestand dekt, met vier uitzonderingen:

  • de waarden van de trailer-entry /ID
  • strings binnen de dictionary /Encrypt
  • strings binnen streams, die als geheel versleuteld worden
  • elke hexadecimale string die de waarde van /Contents in een signatuur-dictionary is

De eerste drie zijn oud. ISO 32000-1 §7.6.1 noemt er maar drie, dus een ontsleutelaar geschreven tegen de tekst van 2008 heeft geen instructie om iets in een signatuur-dictionary over te slaan. Onderteken-tools schreven de waarde toch cleartext weg. De reden is mechanisch — een signer reserveert een hex-placeholder, schrijft het hele bestand, hasht alles behalve de placeholder, bouwt de CMS en plakt zijn hex in de opening. Die digest dekt de bytes zoals ze op schijf staan, ciphertext incluis. De signatuurwaarde versleutelen zou niets beschermen, en het zou de patchstap dwingen de file key te kennen en een IV plus padding in een placeholder te proppen die groter is gemaakt voordat de hash bestond. De rest van de dictionary volgt de normale regels:

Entry in de signatuur-dictionaryPDF-typeVersleuteld opgeslagen?
/Contentshexadecimale stringNee, vrijgesteld onder ISO 32000-2 §7.6.2
/Reason, /Location, /ContactInfo, /Nametext stringJa
/Mdate stringJa
/ByteRangearray van integersNee, integers worden nooit versleuteld
/Type, /Filter, /SubFilternameNee, names worden nooit versleuteld

Een reader die de uitzondering mist draait het cipher over kale DER. Wat eruit komt hangt van het algoritme af. RC4 is een stream cipher, dus de uitvoer houdt zijn lengte en verandert in ruis; de CMS-parser ziet een eerste byte die niet meer 0x30 is en meldt een misvormde signatuur. AES-CBC behandelt de eerste 16 bytes als initialisatievector en de rest als ciphertext, wat eindigt in een paddingfout of een kortere klodder rommel. AES-GCM, door ISO/TS 32003 voor PDF gedefinieerd, is het hardste geval. Elke versleutelde string draagt zijn eigen nonce en authentication tag, en een cleartext-waarde komt door de tag-check niet heen. Een ontsleutelaar die één falende tag als fataal voor de hele dictionary behandelt, verliest dan /Reason en /M samen met de signatuurwaarde

PDFlibPas versleutelde PDF-signatuur-dictionary in Delphi met de hexadecimale /Contents cleartext gelaten onder ISO 32000-2 §7.6.2 terwijl /Reason en /M versleuteld blijven, plus de faalwijzen van RC4, AES-CBC en AES-GCM
Signatuur-/Contents blijft in een versleutelde PDF leesbare DER terwijl /Reason en /M ciphertext zijn, en die DER alsnog ontsleutelen faalt onder elk cipher anders

Hoe herkent u een signatuur-/Contents tussen alle andere /Contents?

Matchen op de keynaam is de voor de hand liggende eerste poging. Hij faalt in beide richtingen. Annotation-dictionaries hebben ook een /Contents-entry — een text string met daarin het commentaar dat een reviewer typte — en die string moet versleuteld worden. Slaat u hem over, dan is elke memo in een vertrouwelijk bestand leesbaar zonder het wachtwoord. Matchen op /Type /Sig faalt de andere kant op. ISO 32000-2 Tabel 255 maakt /Type optioneel in een signatuur-dictionary, met Sig als default, en eist hem alleen voor /DocTimeStamp, dus een conforme producent mag hem weglaten. PDFlibPas behandelt een /Contents-string daarom alleen als signatuurwaarde wanneer dezelfde dictionary een /ByteRange-array draagt plus óf een /Type van Sig of DocTimeStamp, óf helemaal geen /Type naast een /Filter-naam. /Filter is verplicht in elke signatuur-dictionary en /ByteRange in elke signatuur die bij een signatuurveld hoort; een annotation heeft geen van beide. De bibliotheek draait dezelfde test bij het versleutelen bij het opslaan en het ontsleutelen bij het laden, dus /Contents komt uit een load met de bytes die de producent wegschreef

PDFlibPas versleutelingstest in Delphi die een signatuur-/Contents van een annotation-/Contents onderscheidt via /ByteRange, /Type Sig of DocTimeStamp en /Filter voordat de string wordt versleuteld of ontsleuteld
Annotation-commentaar blijft versleuteld want PDFlibPas behandelt /Contents alleen als signatuurwaarde naast /ByteRange en een matchende /Type, of een /Filter-naam als /Type ontbreekt

Eerst versleutelen, dan ondertekenen met PDFlibPas

Onderteken als laatste. De signatuur-digest dekt de opgeslagen bytes, dus elke operatie die het bestand na het ondertekenen herschrijft breekt elke signatuur erin. Versleuteling toepassen op een ondertekend document versleutelt elke string en stream opnieuw; versleuteling verwijderen herschrijft ze net zo grondig. De volgorde die werkt is: versleutelen, opslaan, en dan het versleutelde bestand ondertekenen. Het sign-proces voegt een incrementele update toe waarvan de nieuwe strings met de bestaande file key worden versleuteld en waarvan de /Contents-placeholder cleartext blijft. Open het bestand in het sign-proces met het owner password; een user password volstaat alleen als de permissions bit 9 van ISO 32000-2 Tabel 22 bevatten, dat signature fields expliciet noemt. Encrypt en het sign-proces zijn in elke PDFlibPas-editie beschikbaar

uses
  SysUtils, PDFlibrary;

const
  OwnerPwd = 'owner-secret';
  UserPwd  = 'reader-secret';

procedure EncryptCopy(const Source, Target: string);
var
  Lib: TPDFlib;
  Perms: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(Source, '') <> 1 then
      raise Exception.CreateFmt('Load failed (%d)', [Lib.LastErrorCode]);
    // print, fields vullen (bit 9 dekt signature fields), toegankelijke kopie, print in hoge resolutie
    Perms := Lib.EncodePermissions(1, 0, 0, 0, 1, 1, 0, 1);
    // Strength 4 = AES-256 revision 6: file key direct gebruikt, /ID maakt er geen deel van uit
    if Lib.Encrypt(OwnerPwd, UserPwd, 4, Perms) <> 1 then
      raise Exception.CreateFmt('Encrypt failed (%d)', [Lib.LastErrorCode]);
    if Lib.SaveToFile(Target) <> 1 then
      raise Exception.CreateFmt('Save failed (%d)', [Lib.LastErrorCode]);
  finally
    Lib.Free;
  end;
end;

procedure SignField(Lib: TPDFlib; const Source, Target, FieldName,
  PFXFile, PFXPassword: string);
var
  SP, R: Integer;
begin
  SP := Lib.NewSignProcessFromFile(Source, OwnerPwd);
  if SP = 0 then
    raise Exception.CreateFmt('Input not found: %s', [Source]);
  try
    Lib.SetSignProcessField(SP, FieldName);   // onzichtbaar aangemaakt als het veld ontbreekt
    Lib.SetSignProcessPFXFromFile(SP, PFXFile, PFXPassword);
    Lib.SetSignProcessInfo(SP, 'Approved for release', 'Head office', '');
    // ruimte in de placeholder voor een signature time stamp die later volgt
    Lib.SetSignProcessReserveContentsBytes(SP, 16384);
    Lib.EndSignProcessToFile(SP, Target);     // geeft altijd 0 terug
    R := Lib.GetSignProcessResult(SP);
    if R <> 1 then
      raise Exception.CreateFmt('Signing %s failed (result %d)', [FieldName, R]);
  finally
    Lib.ReleaseSignProcess(SP);
  end;
end;

Strength 4 kiest AES-256 revision 6, en die keuze schakelt twee faalwijzen uit. Onder RC4 en AES-128 leidt Algorithm 1 (ISO 32000-1 §7.6.2) voor elk object een aparte key af uit zijn objectnummer en generationnummer. Algorithm 2 (ISO 32000-1 §7.6.3.3, stap e) voert ook het eerste /ID-element in de file key in. Een signing-update herschrijft de AcroForm-dictionary en de /Annots-array van de pagina, dus elk herschreven object moet zijn oorspronkelijke generationnummer houden en de trailer van de update moet hetzelfde eerste /ID-element dragen. Zit de generation ernaast, dan ontsleutelt dat object niet meer. Zit de /ID ernaast, dan ontsleutelt niets meer. Revision 6 gebruikt de file key rechtstreeks (ISO 32000-2 §7.6.3.3, Algorithm 1.A) en haalt hem op zonder naar /ID te kijken (§7.6.4.3.3, Algorithm 2.A). PDFlibPas houdt generationnummers op herschreven objecten aan bij elke strength en leidt de key af van /ID zoals opgeslagen, zelfs wanneer een misvormd bestand /ID 7 0 R draagt in plaats van de directe array die ISO 32000-2 Tabel 15 eist; een lege /ID [] faalt bij de wachtwoordcontrole. Het pad van wachtwoord naar key wordt in versleutelde PDFs openen met een raw file key dieper uitgetekend

Een teksteditor is genoeg om het resultaat te zien. Een signatuur-dictionary staat ongecomprimeerd in de update die hem toevoegde, want de signer patcht /ByteRange en /Contents op bestandsoffset. Zoek naar /ByteRange en u staat er. De hex van /Contents opent doorgaans met 3082, de DER-header van de CMS SignedData SEQUENCE, en eindigt in een lange reeks nullen waar de placeholder groter was dan de signatuur. /Reason is ciphertext die van de ene signing-run naar de volgende verandert, ook als de tekst niet verandert, want AES trekt per string een verse IV. Verwacht met een recente qpdf: qpdf --password=owner-secret --show-object=14 signed.pdf drukt de ontsleutelde /Reason af naast een onveranderde /Contents. Gebruik voor die controle een bestand met strength 4. Verwachte vorm in het ruwe bestand, met variërende offsets en bytes:

14 0 obj
<</Type /Sig /Filter /Adobe.PPKLite
  /ByteRange [0 48211 81093 2304]
  /Contents <308240A206092A864886F70D010702A0...000000000000>
  /Reason <7F21C9E04B...>
  /M <D35A0F98...>
  ...>>
endobj

Welke signaturen breken er als een time stamp wordt ingepatcht?

Elke signatuur die na de gestampte wordt toegevoegd. Een PAdES signature time stamp is een RFC 3161-token dat als unsigned attribute van de signer wordt opgeslagen (RFC 5652 §5.3, unsignedAttrs), dus hij woont binnen de CMS in /Contents. Eén toevoegen betekent die hex ter plekke herschrijven. In een versleuteld bestand werkt die herschrijving alleen omdat /Contents cleartext is: geen her-versleuteling, geen nieuw object — het token hoeft alleen te passen in de ruimte die op signing-tijd was gereserveerd. De bezittende signatuur overleeft het omdat zijn /ByteRange die bytes overslaat. Latere signaturen krijgen zo'n vrijstelling niet. ISO 32000-2 §12.8.1 laat de byte range van elke signatuur lopen van de %PDF--header tot het einde van de incrementele update die hem toevoegde. Een tweede signatuur, of een document time stamp, hasht daarom de /Contents van de eerste signatuur mee met alles eromheen. De opening toebehoort aan één signatuur. Patch hem nadat een tweede signer heeft getekend, en de digest van de tweede signatuur matcht niet meer. De tokenaanvraag zelf hoort bij PAdES signing en validatie vanuit Delphi; deze sectie gaat alleen over welke bytes verhuizen

PDFlibPas controleert de overlap voordat hij ook maar iets hasht. Het signature time-stamp-proces leest de /ByteRange van elke andere signatuur in het document en legt elk paar spans naast de /Contents-opening van het doel. Elke overlap stopt de voorbereiding met process result 17, en er wordt geen uitvoerbestand geschreven. Het proces hoort bij de Enterprise-editie, die u instantieert als TPDFlibEnterprise uit de unit PDFlibEnterprise; andere edities houden de methoden, die 0 teruggeven en LastErrorCode op 900 zetten. Houd het getal 17 in uw logs in de gaten. Vanuit GetPAdESSignatureTimeStampProcessResult betekent het dat een latere signatuur deze dekt; vanuit GetSignProcessResult betekent het dat een certificatieniveau of field lock ondertekenen verbiedt. Houd de twee apart. De helper hieronder gebruikt SignField en OwnerPwd uit de vorige listing

PDFlibPas PAdES signature time stamp volgorde in Delphi: de /ByteRange van een latere signatuur die een eerdere /Contents-opening dekt laat GetPAdESSignatureTimeStampProcessResult 17 teruggeven zonder bestand geschreven
Zodra Approver heeft getekend dekt zijn byte range de Author-/Contents-opening, en PDFlibPas weigert de late Author time stamp met process result 17 in plaats van de latere signatuur te breken
uses
  SysUtils, PDFlibrary, PDFlibEnterprise;

const
  STS_COVERED_BY_LATER_SIGNATURE = 17;

function StampSignature(Lib: TPDFlibEnterprise; const Source, Target,
  FieldName: string): Integer;
var
  STS: Integer;
begin
  Result := 0;
  STS := Lib.NewPAdESSignatureTimeStampProcessFromFile(Source, OwnerPwd);
  if STS = 0 then
    Exit;
  try
    Lib.SetPAdESSignatureTimeStampField(STS, FieldName);
    if Lib.ApplyPAdESSignatureTimeStamp(STS, 0) <> 1 then
    begin
      Result := Lib.GetPAdESSignatureTimeStampProcessResult(STS);
      if Result = 1 then
        Result := -1;  // waarde gehasht, TSA-uitwisseling mislukt: zie GetTimeStampError
      Exit;
    end;
    Result := Lib.EndPAdESSignatureTimeStampProcessToFile(STS, Target);
  finally
    Lib.ReleasePAdESSignatureTimeStampProcess(STS);
  end;
end;

procedure SignAndStampInOrder(const EncryptedFile: string);
var
  Lib: TPDFlibEnterprise;
  R: Integer;
begin
  Lib := TPDFlibEnterprise.Create;
  try
    Lib.SetTimeStampServer('https://tsa.example.com/tsa', '', '');

    SignField(Lib, EncryptedFile, 'step1.pdf', 'Author', 'author.pfx', 'pfx-1');
    R := StampSignature(Lib, 'step1.pdf', 'step1-ts.pdf', 'Author');
    if R <> 1 then
      raise Exception.CreateFmt('Author time stamp failed (%d)', [R]);

    SignField(Lib, 'step1-ts.pdf', 'step2.pdf', 'Approver', 'approver.pfx', 'pfx-2');
    R := StampSignature(Lib, 'step2.pdf', 'final.pdf', 'Approver');
    if R <> 1 then
      raise Exception.CreateFmt('Approver time stamp failed (%d)', [R]);

    // Verwacht: 17. De byte range van Approver dekt de /Contents van Author in
    // step2.pdf, dus het proces weigert en restamped.pdf wordt nooit geschreven
    R := StampSignature(Lib, 'step2.pdf', 'restamped.pdf', 'Author');
    if R <> STS_COVERED_BY_LATER_SIGNATURE then
      raise Exception.CreateFmt('Unexpected result %d', [R]);
  finally
    Lib.Free;
  end;
end;

Stamp elke signatuur voordat de volgende signer het bestand aanraakt. Het kost één TSA-roundtrip per signer en geeft elke signatuur zijn eigen betrouwbare tijd. Een bestand dat al met meerdere signaturen binnenkomt is anders: de eerdere kunnen geen signature time stamp meer krijgen. Voeg in plaats daarvan een document time stamp toe — een signatuur /Type /DocTimeStamp met /SubFilter /ETSI.RFC3161 in een nieuwe revisie, waarvan de byte range alle eerdere signaturen dekt en bewijst dat ze op dat moment bestonden. ApplyPAdESLongTermArchiveValidation voegt er één toe na het bijvoegen van het validatiemateriaal. Reserveer ruimte voor een signature time stamp bij het tekenen, via SetSignProcessReserveContentsBytes zoals in de eerste listing. Een token dat de TSA-certificaatketen ingebouwd heeft loopt gemakkelijk op tot enkele kilobytes, en een placeholder zonder die marge laat de latere augmentation mislukken met result 13

Een ondertekend bestand ontsleutelen en wat een verifier hasht

De signed ranges van een versleuteld bestand zijn ciphertext. Een verifier legt een digest van opgeslagen ciphertext naast de digest binnen de CMS: /ByteRange is integers, /Contents is cleartext, en de integriteitscontrole heeft geen key nodig — alleen de veldnaam en /Reason wel. Dezelfde wetmatigheid verklaart waarom een ondertekend bestand ontsleutelen en plat opslaan elke signatuur erin ongeldig maakt, ook al veranderde geen één zichtbare pixel. Bewaar het versleutelde, ondertekende origineel als archiefkopie. Cross-reference streams worden ook nooit versleuteld (ISO 32000-2 §7.5.8.2), dus een update geschreven met een xref stream houdt zijn offsets leesbaar voordat er ergens een key is afgeleid. De layout staat beschreven bij object streams en cross-reference streams in Delphi. Is een signed range te groot om ter vergelijking in het geheugen te lezen, dan meldt PDFlibPas de integriteitscontrole als unknown in plaats van invalid, want bytes die nooit vergeleken zijn zijn niet aangetoond te verschillen

De signatuurwaarde van een versleuteld bestand blijft na het ondertekenen beschrijfbaar totdat een andere signatuur of een document time stamp hem dekt. Zet de time-stamp-stap binnen dat venster, en de encrypt-then-sign-volgorde uit de eerste listing draagt ongewijzigd over op bestanden met meerdere signers in PDF Library for Delphi