
Ingénierie
Signer mes PDF : une mini-AC ancrée sur PGP, pilotée depuis Tails
Comment je signe mes devis, factures et CV sans Adobe ni autorité de certification payante. Une clé PGP comme ancre, une AC racine X.509 qui ne vit que sur une clé USB chiffrée dans Tails, un certificat de signature annuel, une liste de révocation, des signatures horodatées avec pyHanko et un vérificateur sur ce site. Toutes les étapes sous Tails, erreurs comprises.
- engineering
Pourquoi signer un PDF ?
Qu’est-ce que vaut un PDF envoyé par mail ? Honnêtement, pas grand-chose. Depuis que je suis freelance, j’en envoie beaucoup : devis, factures, mon CV, parfois un contrat. N’importe qui peut l’ouvrir dans un éditeur, changer un prix ou une date, et le faire suivre. Et coller une photo de ma signature manuscrite en bas ne change rien : une image, ça se copie aussi.
Une vraie signature numérique règle le problème. Le lecteur peut vérifier que le fichier vient de moi, et que pas un seul octet n’a bougé depuis que je l’ai signé. Reste à savoir comment en obtenir une.
Certificat payant (liste de confiance d’Adobe)
- Avantages : Acrobat affiche la coche verte directement.
- Inconvénients : un abonnement annuel, souvent un token matériel envoyé par la poste, et toute la confiance est louée à une entreprise.
Certificat auto-signé
- Avantages : gratuit, cinq minutes avec openssl.
- Inconvénients : personne ne peut savoir qu’il est vraiment à moi, et si on me vole mon ordinateur, impossible de l’annuler.
Signature PGP à côté du PDF (cv.pdf.asc)
- Avantages : solide, et ma clé PGP est déjà publique.
- Inconvénients : le lecteur PDF n’affiche rien, et à part quelques geeks,
personne ne vérifiera jamais un fichier
.asc.
Du coup j’ai pris un peu de chaque. Ma propre petite autorité de certification (une « mini-AC ») dont la racine est garantie par ma clé PGP, de vraies signatures PDF que n’importe quel lecteur comprend, et une page sur ce site qui vérifie toute la chaîne dans votre navigateur. Coût : 0 €, à part quelques clés USB.
Avertissement : je ne suis pas un professionnel de la sécurité, et ce n’est pas une signature électronique qualifiée (eIDAS ou autre). Ça prouve une seule chose : le fichier vient de moi et n’a pas été modifié. Votre lecteur PDF dira aussi que l’identité du signataire est « inconnue », parce que ma racine n’est pas dans la liste d’Adobe. C’est exactement à ça que sert la page de vérification.
La chaîne de confiance
Avant de plonger dans les étapes, un petit rappel de comment les morceaux tiennent ensemble. Comme je vis au Japon, la comparaison la plus simple, c’est le hanko (判子), le sceau personnel :
- Le jitsuin (実印) est le sceau enregistré. Il reste dans un tiroir et ne sort que pour les grosses affaires, comme acheter une voiture. C’est mon AC racine : elle reste hors ligne et ne sort qu’une fois par an.
- Le mitomein (認印) est le sceau de tous les jours, celui des colis. C’est mon certificat de signature : il vit sur l’ordinateur et signe chaque PDF, mais il est remplacé chaque année et peut être annulé.
- L’enregistrement en mairie qui prouve que le jitsuin est bien le vôtre, c’est ma clé PGP. Elle est publiée à plusieurs endroits, vous pouvez donc vérifier son empreinte ailleurs que sur mon site.
(Et l’outil Python qui fait la signature s’appelle pyHanko. Je ne pense pas que ce soit un hasard ^^)
En termes techniques :
Clé PGP 7526 0AC4 B55D 0A91 A079 760C BE7E 6798 D983 5EA6
│ clé primaire hors ligne, sous-clé [S] sur l'ordinateur
└─ signe en clair le SHA-512 des certificats → /gpg/x509.cert.sha512.asc
AC racine (RSA-4096, 10 ans, uniquement sur une clé LUKS, uniquement dans Tails)
├─ émet ───▶ certificat de signature (RSA-4096, 400 jours, sur l'ordinateur)
│ └─ signe les PDF (PAdES + horodatage RFC 3161)
└─ signe ──▶ root.crl (liste de révocation, 400 jours, servie sur /gpg/root.crl)
Deux pièces de plus :
- La CRL (liste de révocation des certificats) est la liste des « annulés », signée par la racine. Chaque certificat de signature contient son adresse, un vérificateur sait donc où regarder.
- L’horodatage vient du serveur public et gratuit de DigiCert (RFC 3161). Il prouve quand un PDF a été signé. Et ça change tout : le vérificateur contrôle le certificat à la date de l’horodatage, pas aujourd’hui. Un PDF signé en 2026 reste donc valide après l’expiration de son certificat, et quelqu’un qui vole ma clé en 2027 ne peut pas faire croire qu’il a signé quelque chose en 2026.
Ce qu’il faut
- Une clé USB Tails. Tails est un Linux qui démarre depuis une clé et oublie tout à l’extinction. Ici, il ne se connecte jamais à aucun réseau.
- Une clé AC, chiffrée avec LUKS, qui ne va que dans Tails. Elle contient la clé privée de la racine.
- Une clé de transfert, en clair, avec un dossier
pdf-ca-transferà sa racine. Elle ne transporte que des données publiques entre l’ordinateur et Tails. - Une clé de sauvegarde (LUKS aussi), pour un endroit sûr, loin de l’ordinateur.
- Sur l’ordinateur :
openssl,gpget pyHanko. Dans Tails : rien à installer,bash,openssletgpgsont déjà là.
Tous les scripts sont dans scripts/pki/ dans le dépôt de ce site, et la partie
Tails dans scripts/pki/offline/.
Pourquoi deux clés USB ?
Ma première version n’avait qu’une seule clé. L’ordinateur écrivait les demandes
de signature directement dans le dossier de l’AC et y relisait les résultats. Ça
marchait, mais regardez ce que ça veut dire : chaque année, la clé racine
(chiffrée, d’accord), la base de données de l’AC et les scripts que Tails lance
avec la phrase de passe racine étaient montés sur un ordinateur connecté à
internet. Un malware sur l’ordinateur n’aurait qu’à modifier session.sh pour
qu’il copie la clé déchiffrée sur la clé USB, et attendre l’année suivante. Tout
l’intérêt de Tails tombe à l’eau.
Maintenant la règle est simple : la clé AC ne va jamais dans l’ordinateur. Tout passe par la clé de transfert, dans trois dossiers :
requests/: ordinateur → Tails, les demandes de signatureresults/: Tails → ordinateur, le certificat racine, la CRL et les certificats émisupdate/: ordinateur → Tails, les nouvelles versions des scripts, signées avec ma clé PGP
Et les scripts le vérifient pour moi. Ceux de l’ordinateur refusent de tourner tant qu’un dossier d’AC est monté. Ceux de Tails refusent de tourner avec une connexion réseau, ou si le dossier de transfert est sur la clé AC elle-même.
Étape par étape : les sessions Tails
Étape 0, sur l’ordinateur
Deux commandes, avec seulement la clé de transfert branchée :
scripts/pki/pack-offline.sh # scripts signés PGP → update/
scripts/pki/new-signing-key.sh # clé + demande → requests/
new-signing-key.sh demande la phrase de passe de la clé de signature (celle
que je tape pour chaque PDF) et affiche le sha256 de la clé publique.
Notez-le ou prenez-le en photo : Tails va le réafficher, et les deux doivent
correspondre.
Étape 1, démarrer Tails
- Sur l’écran d’accueil, choisissez la disposition du clavier avec laquelle vous tapez la phrase de passe. Une phrase de passe tapée sur la mauvaise disposition, c’est juste une mauvaise phrase de passe.
- Ne vous connectez à aucun réseau. Pas même une fois.
- Branchez les deux clés. La première fois, formatez la clé AC dans Disques en cochant la protection par mot de passe (LUKS). Ensuite, déverrouillez-la dans Fichiers.
Étape 2, vérifier les scripts avant de les lancer
Les scripts viennent de l’ordinateur, qui est en ligne. Donc avant de lancer quoi que ce soit avec la phrase de passe racine, Tails vérifie qu’ils sont bien signés par ma clé PGP. La toute première fois, cette vérification se fait à la main, parce qu’il n’y a pas encore de copie de confiance sur la clé AC :
cd /media/amnesia/<transfer label>/pdf-ca-transfer/update
gpg --import pgp.asc
gpg --decrypt SHA512SUMS.asc > /tmp/SUMS # doit dire "Good signature"
sha512sum -c /tmp/SUMS # chaque ligne : OK
mkdir -p /media/amnesia/<CA label>/pdf-ca
INSTALL_TO=/media/amnesia/<CA label>/pdf-ca bash update.sh
Prenez votre temps sur cette étape. L’empreinte affichée par gpg doit être
7526 0AC4 B55D 0A91 A079 760C BE7E 6798 D983 5EA6, comparée à une copie de
confiance : papier, téléphone, keys.openpgp.org vu depuis un autre appareil. Pas
avec la clé USB, évidemment. Ensuite, update.sh vit sur la clé AC et fait
cette vérification tout seul, avec l’empreinte inscrite en dur dans _lib.sh.
Étape 3, créer l’AC racine (une fois tous les dix ans)
bash /media/amnesia/<CA label>/pdf-ca/init-root.sh
Le script vérifie qu’il n’y a pas de réseau, trouve la clé de transfert, affiche l’horloge, puis demande une nouvelle phrase de passe racine (12 caractères minimum, la mienne vit dans KeePassXC). Ensuite il crée :
- une clé RSA-4096, chiffrée en AES-256
- un certificat auto-signé valable 10 ans, qui ne peut signer que des
certificats et des CRL, et un seul niveau en dessous (
pathlen:0) - une première CRL, et une copie de tout ce qui est public dans
results/
Il termine par l’empreinte SHA-256 de la racine. Notez-la aussi :
publish.sh l’affiche sur l’ordinateur, et les deux doivent correspondre.
Étape 4, la session annuelle
bash /media/amnesia/<CA label>/pdf-ca/session.sh
C’est la seule commande à lancer dans Tails chaque année :
- elle demande une fois la phrase de passe racine
- pour chaque demande sur la clé de transfert, elle affiche le sujet et le
sha256 de la clé publique. Comparez avec ce que l’ordinateur a affiché à
l’étape 0, et seulement ensuite répondez
y - elle émet un certificat de signature de 400 jours. Peu importe ce que dit
la demande, le script impose le nom et l’email, et donne au certificat les
bons usages (
digitalSignature,nonRepudiation, signature de documents) et l’adresse de la CRL - elle re-signe la CRL pour 400 jours de plus et réécrit
results/
Pourquoi 400 jours ? Une session par an renouvelle à la fois le certificat et la CRL, avec environ cinq semaines de marge si je suis en retard.
Étape 5, la sauvegarde (« la pierre »)
Juste après init-root.sh, copiez pdf-ca/ sur la clé de sauvegarde, avec la
clé PGP primaire et son certificat de révocation. Pas la phrase de passe racine,
elle vit ailleurs. Ensuite la clé part dans un endroit sûr et personne n’y
touche pendant dix ans.
Pourquoi une seule fois ? Parce que la seule chose irremplaçable, c’est la clé racine, et elle ne change jamais. Tout ce qui change chaque année (certificats émis, révocations) est public : c’est sur la clé de transfert et dans l’historique git de ce site. Ma première version demandait une sauvegarde à jour après chaque session, ce que personne ne fait dans la vraie vie. Maintenant :
- les numéros de série sont aléatoires, une vieille copie ne peut donc pas réémettre un numéro déjà utilisé
- les numéros de CRL viennent de la date, ils ne reculent jamais
session.shlit d’abordresults/et ajoute tout certificat ou révocation qui manque au dossier de l’AC
Restaurer une copie vieille de trois ans, ça marche, tout simplement.
Étape 6, retour sur l’ordinateur
On éteint Tails, et on branche uniquement la clé de transfert sur l’ordinateur :
scripts/pki/publish.sh
git add public/gpg && git commit && git push
publish.sh vérifie que la racine est la même que celle déjà publiée (une
racine différente, c’est la mauvaise clé USB, ou une falsification), que la CRL
et les certificats remontent bien jusqu’à elle, puis écrit public/gpg/ : ma
clé publique PGP, les certificats racine et de signature, la CRL, et
x509.cert.sha512.asc, le SHA-512 des certificats signé en clair avec ma clé
PGP. Ce dernier fichier, c’est le pont entre PGP et X.509. Cloudflare
reconstruit le site et c’est en ligne.
Signer un PDF
La partie de tous les jours, enfin ! pyHanko est un outil Python open source pour les signatures PDF. Sa ligne de commande est un paquet séparé, et le support des images est un extra à ajouter à la main (celui-là m’a coûté deux commits) :
pipx install pyhanko-cli
pipx runpip pyhanko-cli install 'pyhanko[image-support]'
scripts/pki/set-signature-image.sh sig.png # une seule fois
scripts/pki/sign-pdf.sh cv.pdf # → cv.signed.pdf
set-signature-image.shrecadre ma signature manuscrite et la stocke chiffrée pour ma clé PGP (signature.png.asc), elle peut donc rester dans le dépôt. Elle n’est déchiffrée que dans un dossier temporaire privé, le temps de signer.sign-pdf.shliste les champs de signature vides du PDF (la case « signez ici » d’un formulaire) et demande lequel utiliser. La case affiche ma signature manuscrite avec « Signed by… / date / Verify at eymeric.me/gpg ». Cette dernière ligne est un lien cliquable, ajouté avant la signature pour qu’elle soit couverte elle aussi.- Attendez-vous à deux demandes : la phrase de passe PGP (pour déchiffrer l’image), puis celle de la clé de signature.
- La signature est en PAdES, avec un horodatage du serveur de DigiCert.
D’où vient le champ vide ? Mes devis et mes factures sont faits avec mon propre
template Typst, et Typst ne sait pas écrire de champs de formulaire PDF. Le
template a donc une case sign-field() qui note seulement où elle a atterri sur
la page, et un petit script ajoute un champ de signature vide juste là avec
pyhanko sign addfields. Ensuite sign-pdf.sh le trouve tout seul.
Une chose à bien retenir : l’image n’est qu’une image. La preuve, c’est la signature cryptographique sur tout le fichier. Ne modifiez pas le PDF après l’avoir signé.
Vérifier un PDF
Sur la page /gpg, vous déposez le PDF et tout est vérifié dans votre navigateur :
- la signature couvre le fichier et le fichier n’a pas été modifié
- le certificat de signature remonte jusqu’à ma racine
- il n’était pas révoqué au moment de l’horodatage (CRL)
- l’horodatage lui-même est valide
- l’empreinte de la racine est signée par ma clé PGP
- en option, la clé PGP de la page correspond à celle de keys.openpgp.org et de GitHub : même un site piraté ne pourrait pas vous tromper
Essayez vous-même ! Pas besoin d’attendre un de mes devis : voici un
petit PDF d’exemple (en anglais), fait avec le même template
Typst et signé avec sign-pdf.sh. Téléchargez-le et déposez-le sur la
page /gpg, tout doit être vert. Ensuite, essayez de tricher :
transformez le total de ¥0 de son faux devis en ¥1,000,000 avec n’importe quel
éditeur PDF (ou ajoutez simplement un commentaire avec votre lecteur PDF),
enregistrez, et déposez-le à nouveau. Cette fois, le vérificateur vous dit que le
fichier a été modifié après la signature.
Il y a aussi un fichier verify.html unique, hors ligne, avec mes clés
intégrées. Et pour ceux qui ne font confiance à rien (bravo !), les mêmes
vérifications à la main :
gpg --import eymeric.asc # vérifiez l'empreinte
gpg --verify x509.cert.sha512.asc
gpg --decrypt x509.cert.sha512.asc | sha512sum -c
openssl crl -inform der -in root.crl -out root.crl.pem
openssl verify -CAfile root.cert.pem -CRLfile root.crl.pem -crl_check signing.cert.pem
pyhanko sign validate --pretty-print --trust root.cert.pem document.pdf
Quand ça tourne mal
J’espère ne jamais avoir besoin de cette partie, mais c’est la raison pour laquelle la chaîne a autant de maillons :
- Ordinateur volé ou clé de signature compromise : dans Tails,
bash revoke.sh <serial> keyCompromise(avecCOMPROMISED_AT=...si la fuite date d’avant), puispublish.shet push tout de suite. Les PDF horodatés avant restent valides. - Clé racine compromise : X.509 ne sait pas révoquer une racine, c’est donc
ma clé PGP qui s’en charge.
revoke-root.shpublie un avis signé en PGP que le vérificateur lit. Ensuite, une nouvelle AC à partir de zéro. - Clé PGP compromise : tout en dépend. On sort le certificat de révocation créé avec la clé, puis une nouvelle clé et une nouvelle AC.
Conclusion
Au final : trois jours de commits (du 27 au 29 septembre), quelques clés USB,
zéro abonnement, et chaque devis ou facture que j’envoie porte maintenant une
signature que tout le monde peut vérifier. Le coût annuel : une session Tails et
un publish.sh.
La limite honnête reste : Acrobat continuera d’afficher « identité inconnue ». Vous pouvez importer mon certificat racine dans la liste de confiance de votre lecteur, mais je ne m’attends pas à ce que quelqu’un le fasse, c’est pour ça que la page de vérification existe.
Et la suite ? Les sous-clés PGP expirent le 24 septembre 2028 : dans deux ans, la clé primaire devra sortir de sa cachette. Et j’aimerais mettre la clé de signature sur une YubiKey (son applet PIV gère le RSA-4096 depuis le firmware 5.7), pour qu’elle ne puisse même pas être copiée. Restez connectés !