Aller au contenu principal
La confiance électronique, du hachage à l'archive

Signatures PDF et PAdES : au cœur d'un PDF signé

Où se loge la signature d'un PDF : le ByteRange, l'objet CMS de /Contents, les mises à jour incrémentales des signataires et les quatre niveaux PAdES.

Dans cette série
Partie 6 sur 9
Auteur
Lamine Diallo
Temps de lecture
26 min de lecture
Publié le
Sur cette page

Vous apprendrez

  • Distinguer l'apparence visible d'une signature de la signature cryptographique stockée dans /Contents
  • Lire un /ByteRange et dire exactement quels octets d'un PDF signé sont hachés, et lesquels ne le sont pas
  • Nommer les parties du SignedData CMS d'une signature PAdES, et dire ce que la clé privée signe réellement
  • Expliquer pourquoi un signataire suivant ou une mise à jour ajoutée laisse intactes les signatures antérieures, et pourquoi un validateur doit malgré tout juger la modification
  • Distinguer les niveaux PAdES baseline B-B, B-T, B-LT et B-LTA
  • Signer deux fois un PDF avec pyHanko, le valider, le falsifier et lire les verdicts

Le problème : un PDF signé qui peut encore grandir

Dans l’article 1, Mariama a signé une ligne de texte et Ibrahima l’a vérifiée avec une clé publique. Les vrais contrats sont des PDF, et un PDF soulève des questions qu’un fichier texte ne pose pas.

La signature doit se trouver à l’intérieur du fichier qu’elle signe. Un contrat exige souvent plusieurs signataires, et chaque nouvelle signature modifie le fichier. Quelqu’un peut ajouter une note une fois que tout le monde a signé. Ibrahima doit donc savoir trois choses :

  • Quels octets chaque signature a-t-elle réellement couverts ?
  • A-t-on ajouté quelque chose depuis, et cet ajout compte-t-il ?
  • L’empreinte signée est-elle celle du document que Mariama a relu ?

Cet article y répond à partir de l’ISO 32000-1, qui définit les signatures PDF, et de l’ETSI EN 319 142-1, qui définit PAdES par-dessus, puis signe et falsifie un vrai fichier.

Les normes ISO, ETSI et IETF, comme la documentation de pyHanko, n’existent qu’en anglais : leurs citations sont traduites par nos soins. Les sorties des outils restent telles quelles.

L’apparence et la signature sont deux choses différentes

Une signature PDF peut avoir une apparence visuelle, dessinée dans un cadre sur la page, ou n’en avoir aucune. La documentation de pyHanko appelle ce second type des « signatures invisibles » (pyHanko, guide CLI : signature). L’apparence est un décor, distinct de l’objet signature ; n’importe qui peut dessiner une image sans rien signer.

La partie cryptographique se trouve dans un dictionnaire de signature (signature dictionary) (ISO 32000-1, tableau 252). Pour une signature d’approbation ordinaire, ce dictionnaire est la valeur d’un champ de formulaire de signature (ISO 32000-1, §12.8.1). Ses principales entrées sont :

  • /Contents : la valeur de signature elle-même, sous forme de chaîne hexadécimale.
  • /ByteRange : les octets du fichier qui ont été hachés.
  • /SubFilter : l’encodage de /Contents. PAdES exige ETSI.CAdES.detached (EN 319 142-1, tableau 1, exig. l).
  • /M : « l’heure de la signature », dont l’ISO 32000-1 prévient qu’elle peut être « une heure d’ordinateur ordinaire, non vérifiée ».
  • /Name, /Reason, /Location, /ContactInfo : des libellés facultatifs.

/Name « ne devrait être utilisé que lorsqu’il n’est pas possible d’extraire le nom de la signature » : c’est un libellé, pas une preuve d’identité. L’identité vient de la validation du certificat, comme dans l’article 2, et une heure fiable vient d’un horodatage.

L’emplacement réservé et le ByteRange

La signature est stockée dans le fichier sur lequel elle est calculée : l’y écrire changerait donc l’empreinte. Le PDF résout ce problème avec un trou réservé. « L’espace destiné à la valeur Contents doit être alloué avant le calcul de l’empreinte du message » (ISO 32000-1, tableau 252). Le signataire écrit tout le fichier avec un emplacement /Contents de taille fixe, hache tout sauf cet emplacement, signe, puis écrit la signature dans le trou. La signature tient dans l’espace déjà réservé : aucun autre octet ne bouge.

/ByteRange enregistre ce qui a été haché : « un tableau de paires d’entiers (décalage de l’octet de départ, longueur en octets) ». Comme la partie hachée doit sauter /Contents, elle compte deux morceaux, l’un avant le trou, l’autre après. L’ISO 32000-1 indique que la plage « devrait être le fichier entier, dictionnaire de signature compris, mais à l’exclusion de la valeur de signature elle-même » (§12.8.1). PAdES transforme ce « devrait » en « doit » (EN 319 142-1, tableau 1, exig. k).

Dans la partie pratique ci-dessous, la signature de Mariama a /ByteRange [0 1324 5346 1182]. Lisez-le comme deux paires :

  • octets 0 à 1 323 (début 0, longueur 1 324) : le document d’origine, les objets que la nouvelle révision ajoute ou réécrit (catalogue, page, champ de formulaire) et le dictionnaire de signature jusqu’à la clé /Contents, en s’arrêtant juste avant le < ;
  • octets 5 346 à 6 527 (début 5 346, longueur 1 182) : le reste du dictionnaire, les nouvelles données de références croisées et le trailer, jusqu’à la fin du fichier.

Les 4 022 octets intermédiaires sont la signature encodée en hexadécimal et ses délimiteurs < > : les seuls octets de cette révision qu’elle ne couvre pas.

La signature de Mariama : deux plages d'octets autour du trou /Contents. Valeurs tirées de l'exécution de la partie pratique ci-dessous.

Dans /Contents : un SignedData CMS

Dans une signature PAdES, /Contents contient « un objet SignedData encodé en DER tel que spécifié dans CMS (IETF RFC 5652) », et « il ne doit y avoir qu’un seul signataire (c’est-à-dire un seul composant de type SignerInfo dans l’élément signerInfos) dans toute signature PDF » (EN 319 142-1, §4.1, tableau 1, exig. h). L’ISO 32000-1 seule autorise aussi adbe.x509.rsa_sha1, une signature PKCS #1 nue dont les certificats sont placés dans une entrée /Cert : toutes les signatures PDF ne sont donc pas des CMS. PAdES interdit cette forme.

La signature est détachée : eContent est absent, et la signature est calculée « comme si la valeur eContent était présente » (RFC 5652, §5.2). Le contenu, ce sont les octets du ByteRange, lus dans le fichier.

Le SignedData CMS contenu dans le /Contents d'une signature PAdES, d'après les §5.1 et §5.3 de la RFC 5652.

Le schéma suit la RFC 5652 (§5.1, §5.3). PAdES ajoute des exigences (EN 319 142-1, tableau 1) : le certificat du signataire doit figurer dans certificates ; content-type (valeur id-data) et message-digest doivent être présents ; le certificat du signataire doit être protégé par signing-certificate ou, de préférence, par signing-certificate-v2 ; et l’attribut CMS signing-time est interdit, car l’heure déclarée va dans /M.

Ce que la clé privée signe réellement

Lorsque des attributs signés sont présents, la clé privée ne signe pas l’empreinte du document. L’attribut message-digest contient le SHA-256 des octets du ByteRange ; le signataire hache ensuite « l’encodage DER complet de la valeur SignedAttrs », et c’est à cette empreinte que s’applique l’algorithme de signature (RFC 5652, §5.4). Le contenu n’est lié qu’indirectement, par l’attribut message-digest.

La RFC 8933 ajoute une règle : « le même algorithme d’empreinte DOIT être utilisé » pour l’empreinte du contenu et pour celle des attributs signés (RFC 8933, §3). Et le vérificateur « NE DOIT PAS se fier aux valeurs d’empreinte calculées par l’émetteur » : il hache lui-même les octets du ByteRange et compare le résultat à message-digest (RFC 5652, §5.6). C’est la règle que l’article 1 a montrée pour FIPS 186-5 : le vérificateur hache toujours ce qu’il a reçu.

Un horodatage de signature va dans les attributs non signés, sous le nom signature-time-stamp (EN 319 142-1, §5.2), et s’ajoute après la signature. L’article 7 en traite.

L’empreinte du document relu n’est pas l’empreinte signée

L’article 5 a figé le contrat et lié l’approbation de Mariama à son empreinte. Cette empreinte n’est pas ce qui finit signé dans le PDF. Il existe trois empreintes différentes, qu’il ne faut jamais confondre sous le nom d’« empreinte du document ».

  1. L’empreinte du document relu. Un SHA-256 du fichier que Mariama a approuvé, avant qu’aucune signature n’existe : dans l’exécution ci-dessous, le contrat.pdf de 620 octets.
  2. L’empreinte du ByteRange. La valeur de l’attribut CMS message-digest : un SHA-256 des octets du ByteRange du fichier signé. Ces octets comprennent le champ de signature, le dictionnaire de signature et la structure de mise à jour, dont rien n’existait au moment de la relecture. Le ByteRange de Sig1, ci-dessous, couvre 2 506 octets d’une révision de 6 528 octets.
  3. L’empreinte des attributs signés. Ce que signe la clé privée.

Lors de l’exécution de recherche menée pour cet article, ces trois empreintes et celle du fichier signé entier donnaient quatre valeurs différentes.

La signature PDF lie la révision signée, pas une empreinte du fichier relu calculée ailleurs. Montrer que « ce qui a été signé est ce qui a été relu » demande une étape de plus :

  • Le contrôle de préfixe. Les mises à jour incrémentales s’ajoutent au fichier et laissent « son contenu d’origine intact » (ISO 32000-1, §7.5.6). Si le signataire n’a fait qu’ajouter, le fichier relu devrait être un préfixe de la révision signée, et un vérificateur peut hacher ses 620 premiers octets. C’est notre déduction à partir des §7.5.6 et §12.8.1 de l’ISO 32000-1, pas une règle posée par une norme, et la partie pratique ne la teste pas.
  • Un enregistrement séparé de l’empreinte relue, comme un journal d’audit protégé lié à l’événement de signature.

Le point de l’article 5, un niveau plus bas : une empreinte ne lie que si c’est la bonne empreinte.

Mises à jour incrémentales et signataires multiples

On peut modifier un PDF sans le réécrire. « Lors d’une mise à jour incrémentale d’un fichier PDF, les modifications doivent être ajoutées à la fin du fichier, en laissant son contenu d’origine intact » (ISO 32000-1, §7.5.6). Chaque mise à jour ajoute sa propre section de références croisées et son propre trailer, et se termine par son propre %%EOF. Par défaut, pyHanko procède par mise à jour incrémentale pour chaque opération (pyHanko, guide CLI : signature).

C’est ainsi que plusieurs personnes signent un même PDF. Une signature PAdES ne contient qu’un SignerInfo : une seconde signataire ajoute donc un nouveau champ de signature dans une nouvelle mise à jour incrémentale, et son ByteRange couvre tout jusqu’à la fin de sa révision, première signature comprise. « Comme chaque signature donne lieu à un enregistrement incrémental, les signatures ultérieures ont une valeur de longueur plus grande » (ISO 32000-1, tableau 252).

La signature antérieure n’est pas perturbée : après un enregistrement incrémental, « les données correspondant à la plage d’octets de la signature d’origine sont préservées », si bien que, « si la signature est valide, il est possible de recréer l’état du document tel qu’il existait au moment de la signature » (§12.8.1, note 1).

Les signatures s'empilent en révisions incrémentales. Aucune des deux ne couvre l'annotation, et rien n'a changé dans la plage de l'une ou de l'autre.

Le piège : un lecteur utilise « la copie la plus récente de chaque objet » (§7.5.6), et affiche donc la dernière révision. Notre déduction : une mise à jour ajoutée peut changer ce qui s’affiche sans toucher un seul octet de la plage d’une signature antérieure. Les calculs se vérifient toujours ; il faut donc que quelque chose d’autre juge la modification.

Signatures d’approbation et de certification

Un document peut contenir un nombre quelconque de signatures d’approbation, et au plus une signature de certification, qui porte une transformation DocMDP (§12.8.1). Le champ DocMDP « doit être le premier champ signé du document » (§12.8.2.2.1), et sa valeur P indique quelles modifications ultérieures sont permises (tableau 254) :

  • P=1 : aucune modification ; « toute modification … doit invalider la signature » ;
  • P=2 : remplir des formulaires, instancier des modèles de page et signer (valeur par défaut) ;
  • P=3 : comme 2, plus les annotations.

Pour valider une signature DocMDP, un lecteur « doit d’abord vérifier l’empreinte de la plage d’octets », puis « doit vérifier que toute modification … est permise par les paramètres de la transformation » (§12.8.2.2.2). PAdES ajoute deux exceptions : les mises à jour qui n’ajoutent que des données de validation (le DSS, plus bas) en sont exemptées, et les horodatages de document sont ignorés lors de l’évaluation DocMDP (EN 319 142-1, §5.4.2.3, §5.4.3).

Au-delà des grandes catégories que nomme DocMDP, savoir quelles modifications ajoutées sont légitimes reste une question ouverte. La documentation de pyHanko indique que ce point « n’est pas rigoureusement défini dans la norme », que cela « a donné lieu à diverses attaques », et que sa propre analyse des différences est « (très) expérimentale » (pyHanko, guide CLI). La partie pratique la montre à l’œuvre.

Les quatre niveaux PAdES baseline

PAdES s’appuie sur les signatures de l’ISO 32000-1 « avec un encodage de signature alternatif » qui prend en charge des formats équivalents à CAdES, le format AdES fondé sur CMS de l’ETSI EN 319 122-1, en ajoutant des attributs signés et non signés (EN 319 142-1, §1). La norme définit quatre niveaux baseline, et chacun « couvre toujours toutes les exigences couvertes par les niveaux inférieurs » (§6.1) :

  • B-B : la signature avec ses attributs signés, comme ci-dessus.
  • B-T : ajoute « un jeton de confiance prouvant que la signature elle-même existait réellement à une date et une heure données », soit un attribut signature-time-stamp, soit un horodatage de document (tableau 1, exig. n).
  • B-LT : les données de validation, certificats plus réponses CRL ou OCSP, sont stockées dans le Document Security Store (Document Security Store) (DSS), sous la clé /DSS du catalogue (§5.4.1, §6.1).
  • B-LTA : ajoute des horodatages de document « qui permettent de valider la signature longtemps après sa génération ». Les données de validation manquantes sont d’abord ajoutées (exig. x), et l’horodatage de document (/Type /DocTimeStamp, /SubFilter /ETSI.RFC3161) a un ByteRange qui couvre tout le fichier (§5.4.3) : il couvre donc le DSS.

Le DSS n’est pas signé lorsqu’il est ajouté ; c’est l’horodatage de document B-LTA qui le protège. Ces niveaux comptent là où « l’expiration des certificats, la révocation et/ou l’obsolescence des algorithmes posent problème » (§6.1, notes 2 à 4). L’article 7 les traite en détail.

« PAdES-BES », « EPES » et « PAdES-LTV » ne sont pas des noms de niveaux actuels : les signatures relevant de l’ancienne TS 103 172 sont dites « legacy », et « LTV » ne désigne que l’extension DSS et horodatage de document (§3.1, §5.4.1). « L’algorithme MD5 ne doit pas être utilisé comme algorithme d’empreinte » (§6.2.1). La V1.2.1 (2024-01) est la version en vigueur ; une révision, la V1.3.0, qui ajoute la prise en charge de PDF 2.0, est en cours d’approbation à l’ETSI jusqu’au 9 novembre 2026 (projet V1.3.0).

La confiance relève de la politique du validateur

Une signature peut être cryptographiquement correcte sans être digne de confiance. L’ISO 32000-1 laisse cette décision au logiciel : « La politique d’établissement des listes d’identités de confiance servant à valider les certificats intégrés revient au gestionnaire de validation de signature » (§12.8.3.3.1). De même, la RFC 5652 laisse hors de CMS le choix et la validation de la clé publique du signataire (§5.6).

Dans la partie pratique, pyHanko ne fait confiance au signataire que parce que nous lui passons la racine de démonstration avec --trust ca.crt --trust-replace. Un lecteur qui n’a pas cette racine ne ferait pas confiance au même fichier.

Les lecteurs de bureau comme Acrobat prennent cette décision selon leur propre politique (§12.8.3.3.1). Nous n’avons pu lire ni l’Acrobat Digital Signature Guide d’Adobe (Adobe) ni ses pages d’aide pendant la préparation de cet article : nous n’affirmons donc rien sur les racines auxquelles Acrobat fait confiance ni sur ce qu’il affiche. Si cela compte pour vos documents, lisez le guide et faites des essais avec vos propres fichiers.

Mariama signe, Fatoumata contresigne, Ibrahima vérifie

Revenons au contrat de prêt de l’article 1, désormais sous forme de PDF d’une page.

Mariama, l’emprunteuse, signe le champ Sig1. Fatoumata, l’agente de crédit, contresigne dans Sig2, une mise à jour ajoutée dont la plage inclut la signature de Mariama. Ibrahima valide. Quelqu’un remplace 5000000 par 6000000, une modification d’octets à l’intérieur des deux plages ; sur une copie intacte, quelqu’un ajoute une annotation texte.

Une modification d’octets dans une plage casse les calculs. Une modification ajoutée laisse les calculs intacts, et le verdict dépend de l’analyse des modifications du validateur.

À vous : deux signataires, valider, falsifier

Tout s’exécute dans un conteneur jetable. Les clés et certificats de test sont générés à l’intérieur et disparaissent quand il s’arrête. N’utilisez jamais ces commandes avec une vraie clé.

Exemple pédagogique exécutable · 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)

Placez les quatre fichiers ci-dessous dans un répertoire vide et lancez cette commande depuis ce répertoire. Chaque fichier est monté en lecture seule ; rien n’est écrit dans ce répertoire.

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

Le script d’entrée, run_all.sh. Chaque ligne ### RUN N est affichée, pour que vous retrouviez chaque étape dans la sortie.

#!/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 écrit à la main un PDF minimal d’une page, sans bibliothèque PDF, pour que vous puissiez lire chaque octet du fichier non signé :

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 donne aux certificats des signataires les usages de clé dont ils ont besoin. Par défaut, pyHanko exige le bit de non-répudiation sur les certificats de signataire (pyHanko, guide CLI) :

keyUsage=digitalSignature,nonRepudiation
extendedKeyUsage=emailProtection

add_annotation.py ajoute au fichier signé une simple annotation texte, et non un champ de formulaire, sous forme de mise à jour incrémentale, avec l’outil d’écriture PDF bas niveau de pyHanko :

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 compte : lors de notre exécution de recherche, la CLI de pyHanko sans cette option a écrit /SubFilter /adbe.pkcs7.detached, qui n’est pas du PAdES. --no-pass indique que les clés de test ne sont pas chiffrées. --trust ca.crt --trust-replace fait de la racine de démonstration la seule ancre de confiance ; s’appuyer sur la liste de confiance du système d’exploitation en plus de --trust est déprécié dans cette version de pyHanko. La ligne d’installation récupère aussi les paquets de polices OpenType facultatifs de pyHanko et n’épingle que pyHanko lui-même ; les autres versions de l’étiquette sont celles que pip a résolues lors de notre exécution.

Les deux signatures

La signature ne produit aucune sortie en cas de succès. La sortie de RUN 5 et de RUN 6 :

Résultat attendu

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

Résultat attendu

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

Le fichier non signé faisait 620 octets ; chaque signature ajoute quelques kilo-octets, surtout le trou de /Contents.

Validation

Le contrôle d’Ibrahima, forme lisible, réduite au rapport de Sig1 :

Résultat attendu

=============
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.

Le rapport de Sig2 a la même forme ; sa section Modifications indique « The signature covers the entire file. » La forme brute, une ligne par signature :

Résultat attendu

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

INTACT est le contrôle cryptographique. TRUSTED est la chaîne jusqu’à la racine de démonstration. EXTENDED_WITH_FORM_FILLING et ACCEPTABLE_MODIFICATIONS sont le verdict de pyHanko sur ce qui a été ajouté après Sig1 : le nouveau champ de signature de Fatoumata. Sig2 est la dernière révision : elle est donc UNTOUCHED. pyHanko déclare les deux signatures VALID, mais il « ne propose pas de validation des exigences structurelles des profils PAdES » (pyHanko, guide CLI : validation) : cette exécution ne montre donc pas que le fichier est conforme à un quelconque profil PAdES.

Les deux ByteRange, tirés directement du fichier brut :

Résultat attendu

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

Sig1 couvre de 0 à 6 528, moins son trou ; Sig2 couvre de 0 à 12 484, moins le sien. L’octet 402 est l’endroit où commence 5000000 dans le contenu de la page, à l’intérieur des deux plages.

Falsifier : une modification dans la plage signée

RUN 9 écrase sept octets au décalage 402, sans changer la longueur du fichier. Forme lisible, réduite à la section Integrity de Sig1 (celle de Sig2 est identique) :

Résultat attendu

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

La fin de la forme lisible, puis la forme brute :

Résultat attendu

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

Les deux signatures sont INVALID, car les octets modifiés se trouvent dans les deux plages, et la commande se termine avec le code 1. Dès que l’intégrité échoue, la forme lisible cesse aussi d’indiquer que le certificat est de confiance ; c’est la ligne d’intégrité qu’il faut lire.

Une mise à jour après la signature

RUN 10 ajoute l’annotation. Forme lisible, réduite au rapport de Sig1 à partir de la section Integrity (celui de Sig2 est identique) :

Résultat attendu

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.

La forme brute :

Résultat attendu

--- 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

Les deux signatures sont toujours INTACT et « cryptographically sound » : l’annotation n’a modifié aucun octet couvert par l’une ou l’autre. Mais la politique de modification par défaut de pyHanko classe l’ajout en EXTENDED_WITH_OTHER et ILLEGAL_MODIFICATIONS, et juge les deux signatures INVALID. Les signatures ne sont pas cassées ; c’est le validateur qui a refusé la modification, et une autre politique pourrait en juger autrement. Les lignes WARNING sont le journal de l’analyse des différences de pyHanko.

Ce qui varie d’une exécution à l’autre : les empreintes des certificats, les heures de signature et les horodatages des lignes WARNING changent à chaque fois. Les numéros de série aléatoires des certificats peuvent décaler de quelques octets les tailles de fichier et les valeurs du ByteRange. Le contrat.pdf de 620 octets et le décalage 402 ne changent pas. pip et Docker affichent des messages d’installation et de téléchargement.

La place de SEDEYA

SEDEYA signe les PDF en PAdES, jusqu’au niveau B-LTA, avec des horodatages RFC 3161 et les données de validation intégrées au fichier. Son journal d’audit chaîné par hachage est scellé dans le PDF signé : la trace de la transaction voyage avec le document. Les enveloppes peuvent être signées en séquence ou en parallèle ; « en parallèle » décrit le déroulement : une signature PAdES contient toujours exactement un SignerInfo, et le format ne place donc jamais deux signataires dans une même signature. Les clés de signature sont conservées dans un HSM (PKCS#11) ou un KMS. Ibrahima n’a pas besoin de pyHanko : il peut déposer le PDF sur la page de vérification publique de SEDEYA, ou scanner le QR code qui y est imprimé. Pour aller plus loin, voir cette série.

Risques et idées reçues

« L'image de signature sur la page, c'est la signature. »

L’apparence est facultative et distincte de l’objet signature. La signature cryptographique est le SignedData CMS de /Contents (ISO 32000-1, §12.8.1 ; EN 319 142-1, §4.1).

« La signature couvre tout le fichier. »

Elle couvre sa propre révision, sauf le trou de /Contents. Dans l’exécution, Sig1 couvrait les octets jusqu’à 6 528 d’un fichier de 12 484 octets.

« Toute modification après la signature casse la signature. »

Une modification à l’intérieur du ByteRange rend la signature cryptographiquement incorrecte. Une mise à jour ajoutée laisse intacts les octets signés : la signature reste cryptographiquement correcte, et c’est à DocMDP, s’il est présent, et à l’analyse des modifications du validateur de dire si la modification est acceptable. La politique par défaut de pyHanko a jugé illégitime une annotation ajoutée, et les signatures INVALID.

« Deux signataires, c'est une seule signature qui en contient deux. »

PAdES n’autorise qu’un seul SignerInfo par signature PDF (EN 319 142-1, §4.1). Les signataires s’empilent en révisions incrémentales, chacune avec son propre dictionnaire de signature.

« L'empreinte signée est celle du document que j'ai relu. »

Il y a trois empreintes : celle du fichier relu, celle des octets du ByteRange (message-digest) et celle des attributs signés (RFC 5652, §5.4). Relier la première aux deux autres demande un contrôle de préfixe ou un enregistrement séparé.

« pyHanko a répondu VALID, donc tous les lecteurs lui feront confiance. »

pyHanko a fait confiance à la racine de démonstration parce que nous le lui avons demandé. La confiance dépend des ancres de confiance du validateur (ISO 32000-1, §12.8.3.3.1), et pyHanko ne vérifie pas la conformité structurelle à PAdES.

En résumé

  • L’apparence est un décor ; la signature est un SignedData CMS dans /Contents.
  • /ByteRange couvre toute la révision, sauf le trou de /Contents.
  • message-digest est l’empreinte des octets du ByteRange ; la clé signe les attributs signés. L’empreinte du document relu est une troisième valeur.
  • Les signataires s’empilent en révisions incrémentales. Une modification d’octets dans une plage a rendu les deux signatures incorrectes ; une annotation ajoutée les a laissées intactes, et pourtant la politique par défaut de pyHanko les a jugées INVALID.
  • PAdES compte quatre niveaux baseline, de B-B à B-LTA, et la confiance vient des ancres du validateur.

Vérifiez votre compréhension

Vérifiez votre compréhension

Le ByteRange d'un PDF signé vaut [0 1324 5346 1182] et le fichier fait 12 484 octets. Qu'est-ce que cela vous apprend ?

Afficher la réponse

Il couvre tout jusqu’à l’octet 6 528, sauf son propre trou /Contents. Les 5 956 autres octets ont été ajoutés plus tard, et un validateur doit décider s’ils sont acceptables.

Vérifiez votre compréhension

Ibrahima dispose du SHA-256 du contrat que Mariama a relu. Peut-il le comparer au message-digest de sa signature ?

Afficher la réponse

Non. Le message-digest couvre la révision signée, dictionnaire de signature compris. Il peut hacher les premiers octets du fichier signé, si la signataire n’a fait qu’ajouter, ou s’appuyer sur un enregistrement séparé comme un journal d’audit.

Vérifiez votre compréhension

Après l'ajout d'une annotation, pyHanko indique INTACT mais INVALID. L'annotation a-t-elle cassé la signature ?

Afficher la réponse

Non. INTACT signifie que les octets signés se hachent et se vérifient toujours. INVALID est le verdict de la politique de modification : la politique par défaut de pyHanko a classé l’annotation ajoutée comme une modification illégitime (EXTENDED_WITH_OTHER, ILLEGAL_MODIFICATIONS).

Prochaine étape

B-B vous dit quelle clé a signé quels octets, mais pas quand. L’article 7 ajoute les horodatages RFC 3161, le DSS et le renouvellement qui permet de vérifier une signature pendant des années. En attendant, parlez-nous : nous vous montrerons un PDF signé avec SEDEYA, ses révisions et son journal d’audit scellé, et comment n’importe qui peut le vérifier.

Références

  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