Short answer. A PDF signature is a CMS (PKCS #7) signature stored in the /Contents entry of a signature
dictionary. It covers the bytes listed in /ByteRange, which is the whole file at signing time except the
/Contents value itself. Validators answer three separate questions. Integrity: do the signed bytes still
match? Trust: does the signer's certificate chain to a root the validator trusts? Modifications: what was
appended after signing, and is it allowed? "Valid but not trusted", the yellow question mark in Acrobat, almost
always means the bytes are intact but the signer's CA is not in the reader's trust list. PAdES (ETSI EN 319
142-1) defines levels from B-B to B-LTA. An RFC 3161 timestamp (B-T) proves when the signature existed, and
embedded validation data plus document timestamps (B-LT, B-LTA) keep it verifiable after certificates expire.
What is inside a signed PDF
The test below signs a one-page invoice with pyHanko 0.38 (CLI 0.6.0), using a P-256 key from a small test CA and a timestamp from DigiCert's public TSA:
$ pyhanko sign addsig --field Sig1 --use-pades --timestamp-url http://timestamp.digicert.com --reason Approved \
pemder --key signer.key --cert signer.pem --chain root.pem --no-pass invoice.pdf invoice-signed.pdf
The signer does not rewrite the file. It appends an incremental update: new objects, a new cross-reference
section and a new trailer after the original %%EOF. The original 627-byte file is still there, byte for
byte, at the start of the signed one. The update adds a signature field and a signature dictionary. This one comes from another run of the same
command, so its numbers differ from the file examined below (the hex value of /Contents is shortened):
9 0 obj
<<
/Contents <30821CFC06092A864886F70D010702A0821CED3...(zeros)...0000000>
/ByteRange [0 1331 23601 1118]
/Type /Sig
/Filter /Adobe.PPKLite
/SubFilter /ETSI.CAdES.detached
/M (D\07220261020140517Z)
/Reason (Approved)
/Prop_Build <<
/App <<
/Name /pyHanko#20CLI
/REx (0\05638\0560)
>>
>>
>>
endobj
The /M value is the PDF date D:20261020140517Z. \072 is an escaped colon. A small script that reads
/ByteRange and the size of /Contents gives the numbers for the file used in the rest of this page:
file size: 24869 bytes, revisions (%%EOF markers): 2
/ByteRange [0 1331 23751 1118]
signed: bytes 0-1330 and 23751-24868; the 22420-byte gap is the /Contents value
/Type /Sig /SubFilter /ETSI.CAdES.detached
/Contents: 11209 bytes reserved, 7472 bytes used by the CMS object
range ends at end of file: True
How the pieces fit:
/ByteRange [a b c d]lists two byte ranges:bbytes starting ata, thendbytes starting atc. The gap between them is exactly the/Contentshex string, delimiters included. Everything else in the file at signing time is hashed. Because the file size is not known until the signature is in place, signers write a placeholder, fill in/ByteRange, hash, sign, and then write the signature into the reserved space./Contentsholds the DER-encoded CMSSignedData, hex-encoded and padded with zeros. pyHanko reserved 11,209 bytes and used 7,472. The rest is padding, there because the space must be reserved before the signature's size is known./SubFilternames the format.ETSI.CAdES.detachedis the PAdES format.adbe.pkcs7.detachedis the older ISO 32000-1 one. Both are detached CMS signatures over the ByteRange bytes./Mis the signing time claimed by the signer. PAdES puts it here: the baseline profile says the CMSsigning-timeattribute "shall not be present" at any level. It is only a claim, since anyone with the key can write any time.
The CMS object is ordinary, and OpenSSL verifies it against the extracted ranges:
CMS Verification successful
1 object: contentType (1.2.840.113549.1.9.3)
1 object: id-smime-aa-signingCertificateV2 (1.2.840.113549.1.9.16.2.47)
1 object: id-smime-aa-timeStampToken (1.2.840.113549.1.9.16.2.14)
1 object: messageDigest (1.2.840.113549.1.9.4)
The signed attributes are the content type, the messageDigest of the ByteRange bytes, and
signingCertificateV2, a hash of the signer's certificate that stops someone from swapping in a different
certificate with the same key. The unsigned attribute timeStampToken is the RFC 3161 timestamp over the
signature value. The PDF signature verifier shows this
breakdown for any PDF, including which bytes each signature covers. The
timestamp inspector decodes the embedded token.
Three questions a validator answers
pyHanko's report keeps them apart, which makes it a good teacher. With the test CA's root supplied as trusted:
Trust anchor: "Organization: Example GmbH, Common Name: Example Signing Root CA"
The signer's certificate is trusted.
Integrity
---------
The signature is cryptographically sound.
Signing time
------------
Signing time as reported by signer: 2026-10-20T14:04:36+00:00
Signature timestamp token: 2026-10-20T14:04:37+00:00
The token is guaranteed to be newer than the signature.
This timestamp is backed by a time stamping authority.
The timestamp token is cryptographically sound.
TSA certificate subject: "Common Name: DigiCert SHA256 RSA4096 Timestamp Responder 2026 1; Organization: DigiCert, Inc.; Country: US"
The TSA certificate is trusted.
Modifications
-------------
The signature covers the entire file.
Bottom line
-----------
The signature is judged VALID.
Valid but not trusted
The same file, validated without supplying the test root:
Trust anchor: "No path to trust anchor found."
The signer's certificate is untrusted.
Integrity
---------
The signature is cryptographically sound.
...
Bottom line
-----------
The signature is judged INVALID.
Integrity is intact, trust is missing. This is what Acrobat reports as "Signature validity is UNKNOWN" with a note that the signer's identity is unknown. It is the most common complaint about signed PDFs, and the document is usually fine. Readers trust signing certificates through their own lists. Acrobat uses the Adobe Approved Trust List (AATL) and, optionally, the EU Trusted Lists. Certificates from a company CA, a national ID card the reader doesn't know, or a self-signed certificate end up here. The fixes are on the reader side (trust the issuing CA, or enable the EU trust lists) or on the signer side (sign with a certificate from a CA the recipients trust). Note the TSA certificate's separate verdict: DigiCert's TSA chains to a root in the system store, so the timestamp is trusted even when the signer is not.
Changed bytes inside the signed range
Replacing EUR 1,250.00 with EUR 9,250.00 directly in the file:
Integrity
---------
The signature is cryptographically unsound.
...
Bottom line
-----------
The signature is judged INVALID.
There is no way around this one. Every byte in the ranges is hashed, so any edit in place breaks the signature.
Changes appended after signing
The interesting attacks don't touch the signed bytes. They add a new revision. PDF allows incremental updates, and they are legitimate for filling forms, adding annotations or adding more signatures. The test appends a revision that replaces the page content with the new amount:
file size: 26337 bytes, revisions (%%EOF markers): 3
/ByteRange [0 1331 23751 1118]
range ends at end of file: False
...
The signature is cryptographically sound.
...
Modifications
-------------
The signature does not cover the entire file.
Some modifications may be illegitimate, and they appear to be incompatible with the current document modification policy.
Bottom line
-----------
The signature is judged INVALID.
The signature is still cryptographically sound, because the signed revision is untouched. What the reader sees is the new revision. Only the difference analysis catches it: comparing what changed after signing against what is allowed. A viewer that only checks "signature sound, signer trusted" displays EUR 9,250 under a valid signature. Research on PDF viewers in 2019 and later found exactly these gaps in many applications (incremental saving and shadow attacks). Adding a second signature, by contrast, is an allowed change:
Modifications
-------------
The signature does not cover the entire file.
All modifications relate to signing and form filling operations, and they appear to be compatible with the current document modification policy.
Bottom line
-----------
The signature is judged VALID.
A certification signature (--certify in pyHanko, "certify" in Acrobat) records the author's policy for
later changes in its DocMDP permissions: no changes, form filling only, or form filling and annotations.
Validators enforce it in the difference analysis.
Time: RFC 3161 timestamps
A signature is made with a certificate that expires and can be revoked. Without independent proof of when
the signature was made, a validator can only check the certificate as of now. Once it has expired, or has been
revoked for any reason, the signature looks just as bad as one made yesterday with a stolen key. The /M entry
cannot help, because the signer chooses it.
An RFC 3161 timestamp from a trusted timestamp authority (TSA) fixes
this. The TSA signs a token containing the hash of the signature value and its own time. In the test,
DigiCert's TSA returned a token for 14:04:37 UTC, one second after the claimed signing time. That proves the
signature existed then, while the certificate was valid. This is PAdES level B-T. Timestamps can also be added
as a separate document timestamp, a signature dictionary with /Type /DocTimeStamp and
/SubFilter /ETSI.RFC3161 that timestamps the whole document as it stands.
Long-term validation: B-LT and B-LTA
Validation in ten years needs more than a timestamp. The CA's CRLs or OCSP responders may be gone, and the algorithms may be weakened. ETSI EN 319 142-1 (V1.2.1, January 2024) defines the levels:
| Level | Adds | Purpose |
|---|---|---|
| B-B | The CMS signature with the required signed attributes | Basic signature |
| B-T | A trusted timestamp | Proves the signature existed at a given time |
| B-LT | All validation material: certificates, CRLs and OCSP responses in the document's DSS | Validation without fetching anything |
| B-LTA | Document timestamps over the document including that material, renewed over time | Keeps the material and the timestamps trustworthy as certificates expire and algorithms age |
The validation material lives in the Document Security Store (/DSS in the document catalog), added in an
incremental update after signing. Signing the same invoice at B-LTA, with a CRL served by the test CA:
file size: 54674 bytes, revisions (%%EOF markers): 4
/ByteRange [0 1331 23745 1099]
/Type /Sig /SubFilter /ETSI.CAdES.detached
range ends at end of file: False
/ByteRange [0 34471 52463 668]
/Type /DocTimeStamp /SubFilter /ETSI.RFC3161
range ends at end of file: False
catalog /DSS: True
/Certs present: True
/CRLs present: True
/OCSPs present: True
/VRI present: True
...
Modifications
-------------
The signature does not cover the entire file.
All modifications relate to signature maintenance, and they appear to be compatible with the current document modification policy.
Bottom line
-----------
The signature is judged VALID.
Four revisions: the original; the signature; an update that adds the DSS with the validation data and ends with the document timestamp, whose byte range stops at byte 53,131; and a last update of 1,543 bytes appended after the timestamp, which the validator counts as signature maintenance. Listing the DSS of a file signed the same way shows what "all validation material" means in practice. It holds the signer's chain and the CRL of the test CA, and also the chain of the DigiCert timestamp responder with two OCSP responses, because the timestamp has to stay verifiable too:
certs:
Organization: Example GmbH, Common Name: Example Signing Root CA
Organization: Example GmbH, Common Name: Alice Example
Common Name: DigiCert Trusted Root G4, Organizational Unit: www.digicert.com, Organization: DigiCert Inc, Country: US
Common Name: DigiCert Trusted G4 TimeStamping RSA4096 SHA256 2025 CA1; Organization: DigiCert, Inc.; Country: US
Common Name: DigiCert SHA256 RSA4096 Timestamp Responder 2026 1; Organization: DigiCert, Inc.; Country: US
CRLs:
Organization: Example GmbH, Common Name: Example Signing Root CA
OCSP responses:
ef6f534ae9e4067c7acae29056f62fd449eccb4e
ecd7e382d2715d644cdf2e673fe7ba98ae1c0f4f
The OCSP responders are identified by key hash. The document timestamp in turn needs renewing before its TSA
certificate expires, and pyhanko sign ltaupdate adds a new one. Acrobat labels signatures whose validation
material is embedded as "LTV enabled".
Which level to produce
- Always timestamp. B-B is enough only for documents read and filed within the certificate's lifetime, and even then a timestamp costs one HTTP request to a TSA. Use B-T as the minimum.
- Use B-LTA for anything archived. Contracts, regulated records and invoices that have to be checkable years
later need embedded validation data and document timestamps. Schedule
ltaupdateruns well before the newest document timestamp's TSA certificate expires, and before its algorithms are considered weak. - Legal effect is a separate question. Under the EU eIDAS Regulation (EU) No 910/2014, a qualified
electronic signature needs a qualified certificate from a trust service provider on the EU Trusted Lists, and
a qualified signature creation device. PAdES describes the file format, not that legal level. pyHanko's
--eutl-territoriesand--require-qualifiedoptions check the trust-list side. - Sign with a CA your readers trust. For external recipients that means a CA on the AATL or the EU Trusted Lists. An internal CA is fine when every reader can be configured to trust it.
How to check a signed PDF
- Look at all signatures and revisions, not just the green banner. How many revisions follow the last signature, and what do they change?
- Separate the verdicts. Integrity failed → the signed bytes changed. Trust failed → a reader-side trust question. Modifications flagged → content was added after signing, and you need to see what.
- Check the time evidence. Is there a timestamp from a TSA you trust, or only the signer's
/M? - For archives, check LTV. Is the validation material embedded, and is the latest document timestamp still within its TSA certificate's validity?
- Use a validator that reports all of this. In scripts,
pyhanko sign validate --pretty-printwith an explicit--trust, or the AdES-stylepyhanko sign adesverify. pyHanko 0.38 warns that falling back to the operating system's trust store "is deprecated and will be removed in a future release", so pass your trust roots explicitly. In the browser, the PDF signature verifier does the same without uploading the document.
Common mistakes
- Trusting the visible signature image. The stamp on the page is decoration. The signature is the CMS object, and an image proves nothing.
- Treating "signature valid" as "document unchanged". Only "covers the entire file" or a clean difference analysis means that.
- Adding the signer's own certificate as trusted to make a warning go away. Trust the issuing CA, after checking it, or not at all.
- No timestamp. Without a TSA timestamp, the signature's evidence ends when the certificate expires.
- Re-saving a signed PDF in a tool that rewrites the file. Many editors do a full save rather than an incremental one, which breaks every existing signature.
Sources
- ISO 32000-2 (PDF 2.0), §12.8 Digital signatures. ISO 32000-1 defines
adbe.pkcs7.detachedand the signature dictionary. - ETSI EN 319 142-1 V1.2.1 (2024-01): PAdES building blocks and baseline levels, document timestamps.
- RFC 5652 (CMS), RFC 3161 (timestamps), RFC 5035 (signingCertificateV2).
- pyHanko 0.38.0 documentation and CLI.
- PDF Insecurity: research on incremental saving, signature wrapping and shadow attacks.
All files on this page were created on 20 October 2026 in node:24.22.0-trixie with pyHanko 0.38.0
(pyhanko-cli 0.6.0, pyhanko-certvalidator 0.33.0) in a virtualenv and OpenSSL 3.5.7, with timestamps from
http://timestamp.digicert.com.