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
/Contentsin 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-dictionary | PDF-type | Versleuteld opgeslagen? |
|---|---|---|
/Contents | hexadecimale string | Nee, vrijgesteld onder ISO 32000-2 §7.6.2 |
/Reason, /Location, /ContactInfo, /Name | text string | Ja |
/M | date string | Ja |
/ByteRange | array van integers | Nee, integers worden nooit versleuteld |
/Type, /Filter, /SubFilter | name | Nee, 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
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
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
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