An AES-encrypted contract signed elsewhere loads with a signature value that decodes to random bytes, or under AES-GCM with a signature dictionary that will not decrypt. Outside the trailer /ID and the encryption dictionary, ISO 32000-2 §7.6.2 leaves only the signature /Contents unencrypted, and PDFlibPas keeps it in the clear on read and write while /Reason and /M stay encrypted
The exemption ISO 32000-2 added to the encryption rules
ISO 32000-2 §7.6.2 says encryption covers every string and stream in the file, with four exceptions:
- the values of the trailer
/IDentry - strings inside the
/Encryptdictionary - strings inside streams, which are encrypted as a whole
- any hexadecimal string that is the value of
/Contentsin a signature dictionary
The first three are old. ISO 32000-1 §7.6.1 lists only those three, so a decryptor written against the 2008 text has no instruction to skip anything in a signature dictionary. Signing tools wrote the value in the clear anyway. The reason is mechanical — a signer reserves a hex placeholder, writes the whole file, hashes everything except the placeholder, builds the CMS and patches its hex into the gap. That digest covers the bytes as they sit on disk, ciphertext included. Encrypting the signature value would protect nothing, and it would force the patching step to know the file key and fit an IV plus padding into a placeholder sized before the hash existed. The rest of the dictionary follows the normal rules:
| Signature dictionary entry | PDF type | Stored encrypted? |
|---|---|---|
/Contents | hexadecimal string | No, exempt under ISO 32000-2 §7.6.2 |
/Reason, /Location, /ContactInfo, /Name | text string | Yes |
/M | date string | Yes |
/ByteRange | array of integers | No, integers are never encrypted |
/Type, /Filter, /SubFilter | name | No, names are never encrypted |
A reader that misses the exemption runs the cipher over plain DER. What comes out depends on the algorithm. RC4 is a stream cipher, so the output keeps its length and turns into noise; the CMS parser sees a first byte that is no longer 0x30 and reports a malformed signature. AES-CBC treats the first 16 bytes as the initialisation vector and the rest as ciphertext, which ends in a padding error or a shorter blob of garbage. AES-GCM, defined for PDF by ISO/TS 32003, is the harshest case. Every encrypted string carries its own nonce and authentication tag, and a cleartext value cannot pass the tag check. A decryptor that treats one failed tag as fatal for the whole dictionary then loses /Reason and /M along with the signature value
How do you tell a signature /Contents from every other /Contents?
Matching on the key name is the obvious first attempt. It fails in both directions. Annotation dictionaries have a /Contents entry too — a text string holding the comment a reviewer typed — and that string must be encrypted. Skip it and every sticky note in a confidential file is readable without the password. Matching on /Type /Sig breaks the other way. ISO 32000-2 Table 255 makes /Type optional in a signature dictionary, with Sig as the default, and requires it only for /DocTimeStamp, so a conforming producer may leave it out. PDFlibPas therefore treats a /Contents string as a signature value only when the same dictionary carries a /ByteRange array plus either a /Type of Sig or DocTimeStamp, or no /Type at all next to a /Filter name. /Filter is required in every signature dictionary and /ByteRange in every signature that belongs to a signature field; an annotation has neither. The library runs the same test when it encrypts on save and when it decrypts on load, so /Contents comes out of a load holding the bytes the producer wrote
Encrypt first, then sign with PDFlibPas
Sign last. The signature digest covers stored bytes, so any operation that rewrites the file after signing breaks every signature in it. Applying encryption to a signed document re-encrypts each string and stream; removing encryption rewrites them just as thoroughly. The sequence that works is encrypt, save, then sign the encrypted file. The sign process appends an incremental update whose new strings are encrypted with the existing file key and whose /Contents placeholder stays in the clear. Open the file in the sign process with the owner password; a user password is enough only when the permissions include bit 9 of ISO 32000-2 Table 22, which names signature fields explicitly. Encrypt and the sign process are available in every PDFlibPas edition
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, fill fields (bit 9 covers signature fields), accessibility copy, high-res print
Perms := Lib.EncodePermissions(1, 0, 0, 0, 1, 1, 0, 1);
// Strength 4 = AES-256 revision 6: file key used directly, /ID not part of it
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); // created invisibly if missing
Lib.SetSignProcessPFXFromFile(SP, PFXFile, PFXPassword);
Lib.SetSignProcessInfo(SP, 'Approved for release', 'Head office', '');
// headroom in the placeholder for a signature time stamp added later
Lib.SetSignProcessReserveContentsBytes(SP, 16384);
Lib.EndSignProcessToFile(SP, Target); // always returns 0
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 selects AES-256 revision 6, and that choice removes two failure modes. Under RC4 and AES-128, Algorithm 1 (ISO 32000-1 §7.6.2) derives a separate key for every object from its object number and generation number. Algorithm 2 (ISO 32000-1 §7.6.3.3, step e) also feeds the first /ID element into the file key. A signing update rewrites the AcroForm dictionary and the page's /Annots array, so each rewritten object must keep its original generation number, and the update's trailer must carry the same first /ID element. Get the generation wrong and that object no longer decrypts. Get the /ID wrong and nothing does. Revision 6 uses the file key directly (ISO 32000-2 §7.6.3.3, Algorithm 1.A) and retrieves it without consulting /ID (§7.6.4.3.3, Algorithm 2.A). PDFlibPas keeps generation numbers on rewritten objects at every strength and derives the key from /ID as stored, even when a malformed file carries /ID 7 0 R instead of the direct array ISO 32000-2 Table 15 requires; an empty /ID [] fails the password check. The password-to-key path is traced in more depth alongside opening encrypted PDFs with a raw file key
A text editor is enough to see the result. A signature dictionary sits uncompressed in the update that added it, because the signer patches /ByteRange and /Contents by file offset. Search for /ByteRange and you land on it. The /Contents hex typically opens with 3082, the DER header of the CMS SignedData SEQUENCE, and ends in a long run of zeros where the placeholder was larger than the signature. /Reason is ciphertext that changes from one signing run to the next even when the text does not, because AES draws a fresh IV per string. Expected with a recent qpdf: qpdf --password=owner-secret --show-object=14 signed.pdf prints the decrypted /Reason beside an unchanged /Contents. Use a strength 4 file for that check. Expected shape in the raw file, with offsets and bytes varying:
14 0 obj
<</Type /Sig /Filter /Adobe.PPKLite
/ByteRange [0 48211 81093 2304]
/Contents <308240A206092A864886F70D010702A0...000000000000>
/Reason <7F21C9E04B...>
/M <D35A0F98...>
...>>
endobj
Which signatures break when a time stamp is patched in?
Every signature added after the stamped one. A PAdES signature time stamp is an RFC 3161 token stored as an unsigned attribute of the signer (RFC 5652 §5.3, unsignedAttrs), so it lives inside the CMS in /Contents. Adding one means rewriting that hex in place. In an encrypted file that rewrite works only because /Contents is cleartext: no re-encryption, no new object — the token only needs to fit in the room reserved at signing time. The owning signature survives because its /ByteRange skips those bytes. Later signatures get no such exemption. ISO 32000-2 §12.8.1 has each signature's byte range run from the %PDF- header to the end of the incremental update that added it. A second signature, or a document time stamp, therefore hashes the first signature's /Contents along with everything else. The gap belongs to one signature only. Patch it after a second signer has signed, and the second signature's digest no longer matches. The token request itself is part of PAdES signing and validation from Delphi; this section is only about which bytes move
PDFlibPas checks for the overlap before it hashes anything. The signature time-stamp process reads the /ByteRange of every other signature in the document and compares each pair of spans with the target's /Contents gap. Any overlap stops preparation with process result 17, and no output file is written. The process belongs to the Enterprise edition, which you instantiate as TPDFlibEnterprise from the PDFlibEnterprise unit; other editions keep the methods, which return 0 and set LastErrorCode to 900. Watch the number 17 in your logs. From GetPAdESSignatureTimeStampProcessResult it means a later signature covers this one; from GetSignProcessResult it means a certification level or field lock forbids signing. Map the two separately. The helper below uses SignField and OwnerPwd from the previous 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; // value hashed, TSA exchange failed: see 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]);
// Expected: 17. Approver's byte range covers Author's /Contents in
// step2.pdf, so the process refuses and restamped.pdf is never written
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 each signature before the next signer touches the file. It costs one TSA round trip per signer and gives every signature its own trusted time. A file that arrives with several signatures already in place is different: the earlier ones can no longer take a signature time stamp. Append a document time stamp instead — a /Type /DocTimeStamp signature with /SubFilter /ETSI.RFC3161 in a new revision, whose byte range covers all earlier signatures and proves they existed at that moment. ApplyPAdESLongTermArchiveValidation adds one after attaching the validation material. Reserve room for a signature time stamp when you sign, through SetSignProcessReserveContentsBytes as in the first listing. A token that embeds the TSA certificate chain easily runs to several kilobytes, and a placeholder without that headroom makes the later augmentation fail with result 13
Decrypting a signed file and what a verifier hashes
The signed ranges of an encrypted file are ciphertext. A verifier compares a digest of stored ciphertext with the digest inside the CMS: /ByteRange is integers, /Contents is cleartext, and the integrity check needs no key — only the field name and /Reason do. The same fact explains why decrypting a signed file and saving it plain invalidates every signature in it, though not one visible pixel changed. Keep the encrypted, signed original as the record copy. Cross-reference streams are never encrypted either (ISO 32000-2 §7.5.8.2), so an update written with an xref stream keeps its offsets readable before any key is derived. The layout is described with object streams and cross-reference streams in Delphi. When a signed range is too large to read into memory for comparison, PDFlibPas reports the integrity check as unknown rather than invalid, because bytes that were never compared have not been shown to differ
An encrypted file's signature value stays writable after signing until another signature or a document time stamp covers it. Put the time-stamp step inside that window, and the encrypt-then-sign sequence from the first listing carries over to multi-signer files in PDF Library for Delphi without changes