Skip to main content
Electronic trust, from hash to archive

PDF signatures and PAdES: inside a signed PDF

Where a signature lives inside a PDF: the ByteRange, the CMS object in /Contents, incremental updates for several signers, and the four PAdES baseline levels.

In this series
Part 6 of 9
Author
Lamine Diallo
Read time
22 min read
Published
On this page

Who this is for

Software engineers and technical decision-makers who have read Articles 1 to 5 and know what a digest, a certificate and a signing transaction are.

Read first

You will learn

  • Tell a signature's visible appearance from the cryptographic signature stored in /Contents
  • Read a /ByteRange and say exactly which bytes of a signed PDF are hashed and which are not
  • Name the parts of the CMS SignedData inside a PAdES signature, and say what the private key actually signs
  • Explain why a later signer or an appended update leaves earlier signatures intact, and why a validator still has to judge the change
  • Distinguish the PAdES baseline levels B-B, B-T, B-LT and B-LTA
  • Sign a PDF twice with pyHanko, validate it, tamper with it, and read the verdicts

The problem: a signed PDF that can still grow

In Article 1, Mariama signed a line of text and Ibrahima checked it with a public key. Real contracts are PDFs, and a PDF raises questions a text file does not.

The signature has to live inside the file it signs. A contract often needs several signers, and each new signature changes the file. Someone may add a note after everyone has signed. So Ibrahima needs to know three things:

  • Which bytes did each signature actually cover?
  • Has anything been added since, and does the addition matter?
  • Is the digest that was signed the digest of the document Mariama reviewed?

This article answers them from ISO 32000-1, which defines PDF signatures, and ETSI EN 319 142-1, which defines PAdES on top of them, then signs and tampers with a real file.

Appearance and signature are two different things

A PDF signature can have a visual appearance, drawn in a box on the page, or none at all. pyHanko’s documentation calls the second kind “invisible signatures” (pyHanko, CLI guide: signing). The appearance is decoration, separate from the signature object; anyone can draw an image without signing anything.

The cryptographic part lives in a signature dictionary (ISO 32000-1, Table 252). For an ordinary approval signature, that dictionary is the value of a signature form field (ISO 32000-1, §12.8.1). Its main entries are:

  • /Contents: the signature value itself, as a hexadecimal string.
  • /ByteRange: which bytes of the file were hashed.
  • /SubFilter: the encoding of /Contents. PAdES requires ETSI.CAdES.detached (EN 319 142-1, Table 1, req. l).
  • /M: “the time of signing”, which ISO 32000-1 warns may be “a normal unverified computer time”.
  • /Name, /Reason, /Location, /ContactInfo: optional labels.

/Name “should be used only when it is not possible to extract the name from the signature”: a label, not proof of identity. Identity comes from validating the certificate, as in Article 2, and a trusted time from a timestamp.

The placeholder and the ByteRange

The signature is stored in the file it is computed over, so writing it in would change the hash. PDF solves this with a reserved hole. “Space for the Contents value must be allocated before the message digest is computed” (ISO 32000-1, Table 252). The signer writes the whole file with a fixed-size /Contents placeholder, hashes everything except that placeholder, signs, and then writes the signature into the hole. The signature fits inside the space already reserved, so no other byte moves.

/ByteRange records what was hashed: “an array of pairs of integers (starting byte offset, length in bytes)”. Because the hashed part must skip /Contents, it has two pieces, one before the hole and one after it. ISO 32000-1 says the range “should be the entire file, including the signature dictionary but excluding the signature value itself” (§12.8.1). PAdES turns that “should” into “shall” (EN 319 142-1, Table 1, req. k).

In the hands-on below, Mariama’s signature has /ByteRange [0 1324 5346 1182]. Read it as two pairs:

  • bytes 0 to 1323 (start 0, length 1,324): the original document, the objects the new revision adds or rewrites (catalog, page, form field), and the signature dictionary up to the /Contents key, stopping just before the <;
  • bytes 5,346 to 6,527 (start 5,346, length 1,182): the rest of the dictionary, the new cross-reference data and the trailer, to the end of the file.

The 4,022 bytes in between are the hex-encoded signature and its < > delimiters: the only bytes of that revision it does not cover.

Mariama's signature: two byte ranges around the /Contents hole. Values from the hands-on run below.

Inside /Contents: a CMS SignedData

In a PAdES signature, /Contents holds “a DER-encoded SignedData object as specified in CMS (IETF RFC 5652)”, and “there shall only be a single signer (i.e. one single component of SignerInfo type within signerInfos element) in any PDF Signature” (EN 319 142-1, §4.1, Table 1, req. h). Plain ISO 32000-1 also allows adbe.x509.rsa_sha1, a bare PKCS #1 signature with the certificates in a /Cert entry, so not every PDF signature is CMS. PAdES forbids that form.

The signature is detached: eContent is absent, and the signature is computed “as though the eContent value was present” (RFC 5652, §5.2). The content is the ByteRange bytes, read from the file.

The CMS SignedData inside a PAdES /Contents, following RFC 5652 §5.1 and §5.3.

The diagram follows RFC 5652 (§5.1, §5.3). PAdES adds requirements (EN 319 142-1, Table 1): the signing certificate must be in certificates; content-type (value id-data) and message-digest must be present; the signing certificate must be protected by signing-certificate or, preferably, signing-certificate-v2; and the CMS signing-time attribute is forbidden, because the claimed time goes in /M.

What the private key actually signs

When signed attributes are present, the private key does not sign the digest of the document. The message-digest attribute holds the SHA-256 of the ByteRange bytes; the signer then hashes “the complete DER encoding of the SignedAttrs value”, and that digest is what the signature algorithm is applied to (RFC 5652, §5.4). The content is bound only indirectly, through the message-digest attribute.

RFC 8933 adds one rule: “the same digest algorithm MUST be used” for the content digest and for the signed-attributes digest (RFC 8933, §3). And the verifier “MUST NOT rely on any message digest values computed by the originator”: it hashes the ByteRange bytes itself and compares the result with message-digest (RFC 5652, §5.6). That is the same rule Article 1 showed for FIPS 186-5: the verifier always hashes what it received.

A signature timestamp goes into the unsigned attributes as signature-time-stamp (EN 319 142-1, §5.2), added after signing. Article 7 covers it.

Reviewed-document hash is not the signed-input digest

Article 5 froze the contract and bound Mariama’s approval to its digest. That digest is not what ends up signed in the PDF. There are three different digests, and they should never be merged into “the document hash”.

  1. The reviewed-document hash. A SHA-256 of the file Mariama approved, before any signature existed: in the run below, the 620-byte contrat.pdf.
  2. The byte-range digest. The value in the CMS message-digest attribute: a SHA-256 of the signed file’s ByteRange bytes. They include the signature field, the signature dictionary and the update structure, none of which existed at review time. Sig1’s ByteRange below covers 2,506 bytes of a 6,528-byte revision.
  3. The signed-attributes digest. What the private key signs.

On the research run for this article, these three and a hash of the whole signed file were four different values.

The PDF signature binds the signed revision, not a hash of the reviewed file computed elsewhere. Showing that “what was signed is what was reviewed” takes one more step:

  • The prefix check. Incremental updates append to the file and leave “its original contents intact” (ISO 32000-1, §7.5.6). If the signer only appended, the reviewed file should be a prefix of the signed revision, and a verifier can hash its first 620 bytes. This is our inference from ISO 32000-1 §7.5.6 and §12.8.1, not a rule in any standard, and the hands-on does not test it.
  • A separate record of the reviewed hash, such as a protected audit trail linked to the signing event.

Article 5’s point again, one level down: a digest is only a binding if it is the right digest.

Incremental updates and several signers

A PDF can be changed without rewriting it. “When updating a PDF file incrementally, changes shall be appended to the end of the file, leaving its original contents intact” (ISO 32000-1, §7.5.6). Each update adds its own cross-reference section and trailer, and ends with its own %%EOF. pyHanko uses incremental updates for every operation by default (pyHanko, CLI guide: signing).

That is how several people sign one PDF. A PAdES signature holds one SignerInfo, so a second signer adds a new signature field in a new incremental update, and her ByteRange covers everything up to the end of her revision, including the first signature. “Since each signature results in an incremental save, later signatures have a greater length value” (ISO 32000-1, Table 252).

The earlier signature is not disturbed: after an incremental save, “the data corresponding to the byte range of the original signature is preserved”, so, “if the signature is valid, it is possible to recreate the state of the document as it existed at the time of signing” (§12.8.1, note 1).

Signatures stack as incremental revisions. Neither signature covers the annotation, and nothing inside either signature's range changed.

The catch: a reader uses “the most recent copy of each object” (§7.5.6), so it displays the latest revision. Our inference: an appended update can change what is displayed without touching a byte of an earlier signature’s range. The maths still verifies, so something else must judge the change.

Approval and certification signatures

A document may hold any number of approval signatures and at most one certification signature, which carries a DocMDP transform (§12.8.1). The DocMDP field “shall be the first signed field in the document” (§12.8.2.2.1), and its P value says which later changes are permitted (Table 254):

  • P=1: no changes; “any change … shall invalidate the signature”;
  • P=2: filling in forms, instantiating page templates and signing (the default);
  • P=3: as 2, plus annotations.

To validate a DocMDP signature, a reader “first shall verify the byte range digest” and then “shall verify that any modifications … are permitted by the transform parameters” (§12.8.2.2.2). PAdES adds two exceptions: updates that only add validation data (the DSS, below) are exempt, and document timestamps are ignored when evaluating DocMDP (EN 319 142-1, §5.4.2.3, §5.4.3).

Beyond the broad categories DocMDP names, which appended changes are legitimate is open. pyHanko’s documentation says it “is not rigorously defined in the standard”, that this “has led to various exploits”, and that its own difference analysis is “(very) experimental” (pyHanko, CLI guide). The hands-on shows it at work.

The four PAdES baseline levels

PAdES builds on ISO 32000-1 signatures “with an alternative signature encoding” that supports formats equivalent to CAdES, the CMS-based AdES format of ETSI EN 319 122-1, by adding signed and unsigned attributes (EN 319 142-1, §1). It defines four baseline levels, and each one “always addresses all the requirements addressed at levels that are below it” (§6.1):

  • B-B: the signature with its signed attributes, as above.
  • B-T: adds “a trusted token proving that the signature itself actually existed at a certain date and time”, either a signature-time-stamp attribute or a document timestamp (Table 1, req. n).
  • B-LT: the validation material, certificates plus CRL or OCSP responses, is stored in the Document Security Store (DSS), under the catalog’s /DSS key (§5.4.1, §6.1).
  • B-LTA: adds document timestamps “that allow validation of the signature long time after its generation”. Missing validation data is added first (req. x), and the document timestamp (/Type /DocTimeStamp, /SubFilter /ETSI.RFC3161) has a ByteRange over the whole file (§5.4.3), so it covers the DSS.

The DSS is unsigned when added; the B-LTA document timestamp is what protects it. These levels matter where “certificate expiration, revocation and/or algorithm obsolescence is of concern” (§6.1, notes 2–4). Article 7 covers them in depth.

“PAdES-BES”, “EPES” and “PAdES-LTV” are not current level names: signatures under the older TS 103 172 are “legacy”, and “LTV” names only the DSS and document-timestamp extension (§3.1, §5.4.1). “MD5 algorithm shall not be used as digest algorithm” (§6.2.1). V1.2.1 (2024-01) is current; a revision, V1.3.0, adding PDF 2.0 support, is in ETSI approval until 9 November 2026 (draft V1.3.0).

Trust is the validator’s policy

A signature can be cryptographically sound and still not be trusted. ISO 32000-1 leaves that decision to software: “The policy of how to establish trusted identity lists to validate embedded certificates is up to the validation signature handler” (§12.8.3.3.1). RFC 5652 likewise leaves choosing and validating the signer’s public key outside CMS (§5.6).

In the hands-on, pyHanko trusts the signer only because we pass it the demo root with --trust ca.crt --trust-replace. A viewer without that root would not trust the same file.

Desktop viewers such as Acrobat make this decision by their own policy (§12.8.3.3.1). We could not read Adobe’s Acrobat Digital Signature Guide (Adobe) or its help pages while preparing this article, so we make no claim about which roots Acrobat trusts or what it displays. If it matters for your documents, read the guide and test with your own files.

Mariama signs, Fatoumata countersigns, Ibrahima checks

Back to the loan contract from Article 1, now as a one-page PDF.

Mariama, the borrower, signs field Sig1. Fatoumata, the credit officer, countersigns in Sig2, an appended update whose range includes Mariama’s signature. Ibrahima validates. Someone overwrites 5000000 with 6000000, a byte change inside both ranges; on a clean copy, someone appends a text annotation.

A byte change inside a range breaks the maths. An appended change leaves the maths intact, and the verdict depends on the validator’s modification analysis.

Try it: two signers, validate, tamper

Everything runs in one throwaway container. Test keys and certificates are generated inside it and disappear when it exits. Never use these commands with a real key.

Executable educational example · Docker · python:3.12-slim (Python 3.12.14) · pyHanko 0.37.0 · pyhanko-cli 0.5.0 · pyhanko-certvalidator 0.32.1 · OpenSSL 3.5.7 (Debian package)

Put the four files below in an empty directory and run this from it. Each file is mounted read-only; nothing in this directory is written to.

docker run --rm \
  --mount type=bind,src="$PWD/make_pdf.py",dst=/scripts/make_pdf.py,readonly \
  --mount type=bind,src="$PWD/signer_ext.cnf",dst=/scripts/signer_ext.cnf,readonly \
  --mount type=bind,src="$PWD/add_annotation.py",dst=/scripts/add_annotation.py,readonly \
  --mount type=bind,src="$PWD/run_all.sh",dst=/scripts/run_all.sh,readonly \
  -w /work \
  python:3.12-slim \
  sh /scripts/run_all.sh

The entry script, run_all.sh. Each ### RUN N line is echoed so you can find each step in the output.

#!/bin/sh
set -u
mkdir -p /work
cd /work || exit 1

echo "### RUN 1 -- install pyHanko 0.37.0 + CLI ###"
apt-get update -qq && apt-get install -y -qq --no-install-recommends openssl
openssl version
pip install -q pyHanko==0.37.0 pyhanko-cli fonttools uharfbuzz
pyhanko --version
echo "--- pip list --format=freeze ---"
pip list --format=freeze

echo "### RUN 2 -- build the sample PDF ###"
python3 /scripts/make_pdf.py "Contrat de pret 2026-001 : montant 5000000 GNF" contrat.pdf
wc -c contrat.pdf
echo "--- contrat.pdf full content ---"
cat contrat.pdf
echo ""

echo "### RUN 3 -- test CA and two signer certificates ###"
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out ca.key
openssl req -x509 -new -key ca.key -sha256 -days 3650 \
  -subj "/CN=Demo Root CA/O=Demo PKI" -out ca.crt

openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out mariama.key
openssl req -new -key mariama.key \
  -subj "/CN=Mariama Diallo/emailAddress=mariama@example.com" \
  -out mariama.csr
openssl x509 -req -in mariama.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -days 730 -sha256 -out mariama.crt -extfile /scripts/signer_ext.cnf

openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out fatoumata.key
openssl req -new -key fatoumata.key \
  -subj "/CN=Fatoumata Barry/emailAddress=fatoumata@example.com" \
  -out fatoumata.csr
openssl x509 -req -in fatoumata.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -days 730 -sha256 -out fatoumata.crt -extfile /scripts/signer_ext.cnf

openssl verify -CAfile ca.crt mariama.crt
openssl verify -CAfile ca.crt fatoumata.crt

echo "### RUN 4 -- pyhanko CLI help for the flags used below ###"
pyhanko sign addsig --help
pyhanko sign addsig pemder --help

echo "### RUN 5 -- sign as Mariama, PAdES B-B (first signature) ###"
pyhanko sign addsig --field Sig1 \
  --name "Mariama Diallo" \
  --reason "Approbation du contrat de pret" \
  --location "Conakry" \
  --use-pades \
  pemder --key mariama.key --cert mariama.crt --chain ca.crt --no-pass \
  contrat.pdf contrat-signed1.pdf
echo "exit=$?"
wc -c contrat-signed1.pdf

echo "### RUN 6 -- add a second signer, Fatoumata (second signature) ###"
pyhanko sign addsig --field Sig2 \
  --name "Fatoumata Barry" \
  --reason "Contresignature - agent de credit" \
  --location "Conakry" \
  --use-pades \
  pemder --key fatoumata.key --cert fatoumata.crt --chain ca.crt --no-pass \
  contrat-signed1.pdf contrat-signed2.pdf
echo "exit=$?"
wc -c contrat-signed2.pdf

echo "### RUN 7 -- validate both signatures ###"
echo "--- pretty form ---"
pyhanko sign validate --pretty-print --trust ca.crt --trust-replace contrat-signed2.pdf
echo "--- plain form ---"
pyhanko sign validate --trust ca.crt --trust-replace contrat-signed2.pdf

echo "### RUN 8 -- ByteRange from the raw signed PDF ###"
grep -a -o '/ByteRange *\[[^]]*\]' contrat-signed2.pdf

echo "### RUN 9 -- edit a byte inside the signed range: invalid ###"
python3 -c "
data = bytearray(open('contrat-signed2.pdf','rb').read())
data[402:402+7] = b'6000000'
open('contrat-tampered.pdf','wb').write(data)
"
echo "--- pretty form ---"
pyhanko sign validate --pretty-print --trust ca.crt --trust-replace contrat-tampered.pdf
echo "exit=$?"
echo "--- plain form ---"
pyhanko sign validate --trust ca.crt --trust-replace contrat-tampered.pdf
echo "exit=$?"

echo "### RUN 10 -- incremental update after signing ###"
python3 /scripts/add_annotation.py
echo "--- pretty form ---"
pyhanko sign validate --pretty-print --trust ca.crt --trust-replace contrat-annotated.pdf
echo "exit=$?"
echo "--- plain form ---"
pyhanko sign validate --trust ca.crt --trust-replace contrat-annotated.pdf
echo "exit=$?"

make_pdf.py writes a minimal one-page PDF by hand, with no PDF library, so you can read every byte of the unsigned file:

def make_pdf(text: str, path: str) -> None:
    objs = [
        b"<< /Type /Catalog /Pages 2 0 R >>",
        b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
        b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 150] "
        b"/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
        b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
    ]
    stream = ("BT /F1 12 Tf 20 100 Td (%s) Tj ET" % text).encode("latin-1")
    objs.append(b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream")

    out = bytearray(b"%PDF-1.4\n")
    offsets = []
    for i, body in enumerate(objs, start=1):
        offsets.append(len(out))
        out += ("%d 0 obj\n" % i).encode() + body + b"\nendobj\n"
    xref_offset = len(out)
    out += ("xref\n0 %d\n" % (len(objs) + 1)).encode()
    out += b"0000000000 65535 f \n"
    for off in offsets:
        out += ("%010d 00000 n \n" % off).encode()
    out += (
        "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF"
        % (len(objs) + 1, xref_offset)
    ).encode()

    with open(path, "wb") as f:
        f.write(out)


if __name__ == "__main__":
    import sys

    make_pdf(sys.argv[1], sys.argv[2])

signer_ext.cnf gives the signer certificates the key usages they need. pyHanko requires the non-repudiation bit on signer certificates by default (pyHanko, CLI guide):

keyUsage=digitalSignature,nonRepudiation
extendedKeyUsage=emailProtection

add_annotation.py appends a plain text annotation, not a form field, to the signed file as an incremental update, using pyHanko’s own low-level PDF writer:

from pyhanko.pdf_utils import generic
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter

with open("contrat-signed2.pdf", "rb") as inf:
    w = IncrementalPdfFileWriter(inf)

    page_ref, _resources = w.find_page_for_modification(0)

    annot = generic.DictionaryObject(
        {
            generic.pdf_name("/Type"): generic.pdf_name("/Annot"),
            generic.pdf_name("/Subtype"): generic.pdf_name("/Text"),
            generic.pdf_name("/Rect"): generic.ArrayObject(
                [
                    generic.NumberObject(250),
                    generic.NumberObject(120),
                    generic.NumberObject(270),
                    generic.NumberObject(140),
                ]
            ),
            generic.pdf_name("/Contents"): generic.pdf_string(
                "Relu par Ibrahima - archive interne"
            ),
        }
    )
    annot_ref = w.add_object(annot)
    w.register_annotation(page_ref, annot_ref)

    with open("contrat-annotated.pdf", "wb") as outf:
        w.write(outf)

print("wrote contrat-annotated.pdf")

--use-pades matters: on our research run, pyHanko’s CLI without it wrote /SubFilter /adbe.pkcs7.detached, which is not PAdES. --no-pass says the test keys are unencrypted. --trust ca.crt --trust-replace makes the demo root the only trust anchor; relying on the operating system’s trust list alongside --trust is deprecated in this pyHanko release. The install line also pulls pyHanko’s optional OpenType font packages and pins only pyHanko itself; the other versions in the label are what pip resolved on our run.

The two signatures

Signing is silent on success. The RUN 5 and RUN 6 output:

Expected output

### RUN 5 -- sign as Mariama, PAdES B-B (first signature) ###
exit=0
6528 contrat-signed1.pdf

Expected output

### RUN 6 -- add a second signer, Fatoumata (second signature) ###
exit=0
12484 contrat-signed2.pdf

The unsigned file was 620 bytes; each signature adds a few kilobytes, mostly the /Contents hole.

Validation

Ibrahima’s check, pretty form, trimmed to Sig1’s report:

Expected output

=============
Field 1: Sig1
=============


Signer info
-----------
Certificate subject: "Email Address: mariama@example.com, Common Name: Mariama Diallo"
Certificate SHA1 fingerprint: ae7f7044b81e46c05e85a4399756af9c47f1e9c2
Certificate SHA256 fingerprint: b551d5af71b69d546cea980540bdbc1c3dc9296cb872a6a6404039cb0a66b166
Trust anchor: "Organization: Demo PKI, Common Name: Demo Root CA"
The signer's certificate is trusted.


Integrity
---------
The signature is cryptographically sound.

The digest algorithm used was 'sha256'.
The signature mechanism used was 'sha256_ecdsa'.
The elliptic curve used for the signer's ECDSA public key was 'secp256r1' (OID: 1.2.840.10045.3.1.7).


Signing time
------------
Signing time as reported by signer: 2026-09-23T08:51:18+00:00


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.

Sig2’s report has the same shape; its Modifications section reads “The signature covers the entire file.” The plain form, one line per signature:

Expected output

--- plain form ---
Sig1:b551d5af71b69d546cea980540bdbc1c3dc9296cb872a6a6404039cb0a66b166:INTACT:TRUSTED,EXTENDED_WITH_FORM_FILLING,ACCEPTABLE_MODIFICATIONS
Sig2:6c7e27d4c662b34f7ab4ba60981103e04682a195b601b3b7f7f5342b9c18233c:INTACT:TRUSTED,UNTOUCHED

INTACT is the cryptographic check. TRUSTED is the chain to the demo root. EXTENDED_WITH_FORM_FILLING and ACCEPTABLE_MODIFICATIONS are pyHanko’s verdict on what was appended after Sig1: Fatoumata’s new signature field. Sig2 is the last revision, so it is UNTOUCHED. pyHanko reports both signatures VALID, but it “does not offer validation of structural PAdES profile requirements” (pyHanko, CLI guide: validation), so this run does not show that the file conforms to any PAdES profile.

The two ByteRanges, straight from the raw file:

Expected output

### RUN 8 -- ByteRange from the raw signed PDF ###
/ByteRange [0 1324 5346 1182]
/ByteRange [0 7799 11833 651]

Sig1 covers 0 to 6,528 minus its hole; Sig2 covers 0 to 12,484 minus its own. Byte 402 is where 5000000 starts in the page content, inside both.

Tamper: one edit inside the signed range

RUN 9 overwrites seven bytes at offset 402, keeping the file the same length. Pretty form, trimmed to Sig1’s integrity section (Sig2’s reads the same):

Expected output

Integrity
---------
The signature is cryptographically unsound.

The end of the pretty form and the plain form:

Expected output

Error: Validation failed
exit=1
--- plain form ---
Sig1:b551d5af71b69d546cea980540bdbc1c3dc9296cb872a6a6404039cb0a66b166:INVALID
Sig2:6c7e27d4c662b34f7ab4ba60981103e04682a195b601b3b7f7f5342b9c18233c:INVALID
Error: Validation failed
exit=1

Both signatures are INVALID, because the edited bytes lie inside both ranges, and the command exits 1. Once integrity fails, the pretty form also stops reporting the certificate as trusted; the integrity line is the one to read.

An update after signing

RUN 10 appends the annotation. Pretty form, trimmed to Sig1’s report from Integrity down (Sig2’s reads the same):

Expected output

Integrity
---------
The signature is cryptographically sound.

The digest algorithm used was 'sha256'.
The signature mechanism used was 'sha256_ecdsa'.
The elliptic curve used for the signer's ECDSA public key was 'secp256r1' (OID: 1.2.840.10045.3.1.7).


Signing time
------------
Signing time as reported by signer: 2026-09-23T08:51:18+00:00


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 plain form:

Expected output

--- plain form ---
2026-09-23 08:51:20,153 - pyhanko.sign.diff_analysis.policies - WARNING - Error in diff operation between revision 1 and 3
Sig1:b551d5af71b69d546cea980540bdbc1c3dc9296cb872a6a6404039cb0a66b166:INTACT:TRUSTED,EXTENDED_WITH_OTHER,ILLEGAL_MODIFICATIONS
2026-09-23 08:51:20,157 - pyhanko.sign.diff_analysis.policies - WARNING - Error in diff operation between revision 2 and 3
Sig2:6c7e27d4c662b34f7ab4ba60981103e04682a195b601b3b7f7f5342b9c18233c:INTACT:TRUSTED,EXTENDED_WITH_OTHER,ILLEGAL_MODIFICATIONS
Error: Validation failed
exit=1

Both signatures are still INTACT and “cryptographically sound”: the annotation changed no byte either one covers. But pyHanko’s default modification policy classifies the addition as EXTENDED_WITH_OTHER and ILLEGAL_MODIFICATIONS, and judges both INVALID. The signatures did not break; the validator refused the change, and a different policy could judge it differently. The WARNING lines are pyHanko’s log from its difference analysis.

What varies between runs: fingerprints, signing times and WARNING timestamps change every time. Random certificate serial numbers can shift file sizes and ByteRange numbers by a few bytes. The 620-byte contrat.pdf and the byte-402 offset do not change. pip and Docker print install and download notices.

Where SEDEYA fits

SEDEYA signs PDFs in PAdES, up to the B-LTA level, with RFC 3161 timestamps and the validation data embedded in the file. Its hash-chained audit trail is sealed into the signed PDF, so the record of the transaction travels with the document. Envelopes can be signed in sequence or in parallel; parallel describes the workflow: a PAdES signature still holds exactly one SignerInfo, so the format never puts two signers inside one signature. Signing keys are held in an HSM (PKCS#11) or a KMS. Ibrahima does not need pyHanko: he can upload the PDF to SEDEYA’s public verification page, or scan the QR code printed on it. More in this series.

Risks and misconceptions

“The signature image on the page is the signature.”

The appearance is optional and separate from the signature object. The cryptographic signature is the CMS SignedData in /Contents (ISO 32000-1, §12.8.1; EN 319 142-1, §4.1).

“The signature covers the whole file.”

It covers its own revision except the /Contents hole. In the run, Sig1 covered bytes up to 6,528 of a 12,484-byte file.

“Any change after signing breaks the signature.”

A change inside the ByteRange makes the signature cryptographically unsound. An appended update leaves the signed bytes intact, so the signature stays cryptographically sound; whether the change is acceptable is up to DocMDP, if present, and the validator’s modification analysis. pyHanko’s default policy judged an appended annotation illegitimate and the signatures INVALID.

“Two signers means one signature with two signers inside.”

PAdES allows exactly one SignerInfo per PDF signature (EN 319 142-1, §4.1). Signers stack as incremental revisions, each with its own signature dictionary.

“The hash that was signed is the hash of the document I reviewed.”

There are three digests: the reviewed file, the ByteRange bytes (message-digest) and the signed attributes (RFC 5652, §5.4). Linking the first to the other two takes a prefix check or a separate record.

“pyHanko said VALID, so every viewer will trust it.”

pyHanko trusted the demo root because we told it to. Trust depends on the validator’s trust anchors (ISO 32000-1, §12.8.3.3.1), and pyHanko does not check PAdES structural conformance.

Summary

  • The appearance is decoration; the signature is a CMS SignedData in /Contents.
  • /ByteRange covers the whole revision except the /Contents hole.
  • message-digest is the digest of the ByteRange bytes; the key signs the signed attributes. The reviewed-document hash is a third value.
  • Signers stack as incremental revisions. A byte edit inside a range made both signatures unsound; an appended annotation left them intact, yet pyHanko’s default policy judged them INVALID.
  • PAdES has four baseline levels, B-B to B-LTA, and trust comes from the validator’s anchors.

Check your understanding

Check your understanding

A signed PDF's ByteRange is [0 1324 5346 1182] and the file is 12,484 bytes long. What does that tell you?

Show the answer

It covers everything up to byte 6,528 except its own /Contents hole. The other 5,956 bytes were appended later, and a validator has to decide whether they are acceptable.

Check your understanding

Ibrahima has the SHA-256 of the contract Mariama reviewed. Can he compare it with the message-digest in her signature?

Show the answer

No. The message-digest covers the signed revision, including the signature dictionary. He can hash the signed file’s leading bytes, if the signer only appended, or rely on a separate record such as an audit trail.

Check your understanding

After an annotation is appended, pyHanko reports INTACT but INVALID. Did the annotation break the signature?

Show the answer

No. INTACT means the signed bytes still hash and verify. INVALID is the modification policy’s verdict: pyHanko’s default policy classified the appended annotation as an illegitimate modification (EXTENDED_WITH_OTHER, ILLEGAL_MODIFICATIONS).

Next step

B-B tells you which key signed which bytes, but not when. Article 7 adds RFC 3161 timestamps, the DSS and the renewal that keeps a signature verifiable for years. Meanwhile, talk to us and we will show you a SEDEYA-signed PDF, its revisions and its sealed audit trail, and how anyone can verify it.

References

  1. Electronic Signatures and Trust Infrastructures (ESI); PAdES digital signatures; Part 1: Building blocks and PAdES baseline signatures, ETSI EN 319 142-1, ETSI, V1.2.1 (2024-01) (§1, §3.1, §4.1, §5.2, §5.4.1–5.4.3, §6.1, §6.2.1, Table 1)
  2. Document management — Portable document format — Part 1: PDF 1.7 (ISO 32000-1:2008), Adobe's public copy, Adobe / ISO, First Edition 2008-07-01 (§7.5.6, §12.8.1 (Table 252), §12.8.2.2 (Table 254), §12.8.3.3)
  3. Cryptographic Message Syntax (CMS), RFC 5652, IETF, September 2009 (§5.1–5.4, §5.6, §11.1–11.2)
  4. Update to the Cryptographic Message Syntax (CMS) for Algorithm Identifier Protection, RFC 8933, IETF, October 2020 (§3)
  5. pyHanko documentation, pyHanko project, v0.37.0 (CLI guide: signing, validation)
  6. Acrobat Digital Signature Guide, Adobe, Not read: not reachable when checked on 2026-09-23
  7. Draft ETSI EN 319 142-1, ETSI, Draft V1.3.0 (2026-08), in approval until 2026-11-09