Skip to content
ecdsa.com
← All articles

PDF signatures explained: ByteRange, CMS, PAdES and LTV

A PDF signature signs every byte of the file except a hole that holds the signature itself. Most confusing verdicts come from what lies outside that hole: later revisions, missing trust roots, missing timestamps. Every case below was signed and checked with pyHanko and OpenSSL.

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 (pyHanko 0.38.0, 20 October 2026)
$ 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):

A signature dictionary written by pyHanko (another run)
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:

Signature layout of invoice-signed.pdf
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: b bytes starting at a, then d bytes starting at c. The gap between them is exactly the /Contents hex 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.
  • /Contents holds the DER-encoded CMS SignedData, 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.
  • /SubFilter names the format. ETSI.CAdES.detached is the PAdES format. adbe.pkcs7.detached is the older ISO 32000-1 one. Both are detached CMS signatures over the ByteRange bytes.
  • /M is the signing time claimed by the signer. PAdES puts it here: the baseline profile says the CMS signing-time attribute "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:

openssl cms -verify on /Contents (OpenSSL 3.5.7)
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:

pyhanko sign validate --pretty-print --trust root.pem invoice-signed.pdf (abridged)
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:

Same file, no trust root for the signer (abridged)
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:

One amount edited in place (abridged)
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:

Content replaced in an incremental update after signing
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:

A second signature added after the first: Sig1's verdict
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:

LevelAddsPurpose
B-BThe CMS signature with the required signed attributesBasic signature
B-TA trusted timestampProves the signature existed at a given time
B-LTAll validation material: certificates, CRLs and OCSP responses in the document's DSSValidation without fetching anything
B-LTADocument timestamps over the document including that material, renewed over timeKeeps 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:

pyhanko sign addsig --use-pades-lta --with-validation-info (abridged)
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:

Document Security Store of a B-LTA file (read with pyHanko and asn1crypto)
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 ltaupdate runs 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-territories and --require-qualified options 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

  1. Look at all signatures and revisions, not just the green banner. How many revisions follow the last signature, and what do they change?
  2. 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.
  3. Check the time evidence. Is there a timestamp from a TSA you trust, or only the signer's /M?
  4. For archives, check LTV. Is the validation material embedded, and is the latest document timestamp still within its TSA certificate's validity?
  5. Use a validator that reports all of this. In scripts, pyhanko sign validate --pretty-print with an explicit --trust, or the AdES-style pyhanko 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.detached and 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.