Sur cette page
- Le problème : une horloge qu’il faut croire sur parole
- Qu’est-ce qu’un jeton d’horodatage
- L’échange : une requête, un jeton et des contrôles
- genTime : ce que l’heure signifie vraiment
- Horodatages de signature et horodatages de document
- De B-T à B-LTA
- Valider des années plus tard
- Mariama signe, Ibrahima vérifie des années plus tard
- À vous : une TSA locale, puis un PDF horodaté
- RFC 3161 avec OpenSSL
- De B-T à un horodatage de document avec pyHanko
- La place de SEDEYA
- Risques et idées reçues
- En résumé
- Vérifiez votre compréhension
- Prochaine étape
Pour qui
Ingénieurs logiciels et décideurs techniques qui ont lu les articles 1 à 6 et savent ce que sont un certificat, une CRL et une signature PAdES.
À lire avant
- Signature numérique : comment fonctionnent clés et hachage
- PKI et certificats : comment une clé publique reçoit un nom
- Clés de signature, HSM et PKCS#11 : où réside la clé
- Construire une PKI : AC racine et émettrice, CRL et OCSP
- La transaction de signature : lier l'accord à un document
- Signatures PDF et PAdES : au cœur d'un PDF signé
Vous apprendrez
- Expliquer pourquoi l'horloge d'un signataire ou l'heure de signature déclarée dans un PDF ne constitue pas une preuve, et ce qu'apporte une autorité d'horodatage
- Lire une requête et un jeton RFC 3161 : empreinte du message, nonce, politique, genTime et accuracy
- Dire précisément ce que prouve un horodatage : les données existaient au plus tard à genTime plus accuracy
- Distinguer un horodatage de signature d'un horodatage de document, et les rattacher aux niveaux PAdES B-T, B-LT et B-LTA
- Décrire comment un validateur s'appuie sur les horodatages pour vérifier une signature après l'expiration de ses certificats, et pourquoi le renouvellement d'archivage ne s'arrête jamais
- Faire tourner une TSA locale avec OpenSSL, puis faire passer un PDF de B-T à un fichier doté d'un horodatage de document avec pyHanko, et lire ce que rapportent les validateurs
Le problème : une horloge qu’il faut croire sur parole
Mariama a signé le contrat de prêt ce matin. Sa signature de l’article 6 indique à Ibrahima quelle clé a signé quels octets. Elle ne lui dit pas quand.
Le PDF porte bien une date. L’entrée /M du dictionnaire de signature contient l’heure de signature déclarée, et PAdES l’exige à tous les niveaux (EN 319 142-1, §6.3, tableau 1). Mais c’est le logiciel de Mariama qui l’a écrite, à partir de l’horloge de Mariama. L’EN 319 102-1 le dit sans détour : une heure de signature déclarée « ne peut être qu’une heure déclarée », comme une date écrite à la main sur du papier, et un horodatage est nécessaire quand une politique exige davantage qu’une déclaration (EN 319 102-1, §4.2.5.8).
Dans dix ans, le certificat de Mariama aura expiré, peut-être été révoqué, et SHA-256 ne sera peut-être plus jugé assez robuste. Ibrahima, ou un juge, devra pourtant savoir si la signature a été faite alors que tout était en règle. Une horloge que n’importe qui peut régler ne peut pas répondre à cette question. Un horodatage signé par un tiers le peut, et les niveaux PAdES s’appuient sur lui.
Les normes IETF et ETSI, comme les documentations d’OpenSSL et de pyHanko, n’existent qu’en anglais : leurs citations sont traduites par nos soins. Les sorties des outils restent telles quelles.
Qu’est-ce qu’un jeton d’horodatage
La RFC 3161 pose l’idée d’emblée : « Un service d’horodatage étaye des affirmations prouvant qu’une donnée existait avant un instant donné » (RFC 3161, §1). Ce service est une autorité d’horodatage (time-stamping authority) (TSA, pour time-stamping authority), un tiers de confiance qui émet des jetons d’horodatage.
L’exemple qui motive la RFC est justement le problème d’Ibrahima : montrer qu’une signature est antérieure à une révocation, afin qu’« un certificat de clé publique révoqué [puisse] servir à vérifier des signatures créées avant la date de révocation » (§1).
Une TSA DOIT utiliser une source de temps fiable, placer cette heure et un numéro de série unique dans chaque jeton, et nommer la politique sous laquelle elle a émis le jeton (§2.1). Quatre autres règles délimitent ce qu’elle peut savoir et ce qu’elle ignore :
- Elle ne voit qu’une empreinte. Le demandeur envoie une empreinte du message (message imprint) (message imprint) : un identifiant d’algorithme de hachage et une valeur d’empreinte. La TSA vérifie que la longueur correspond à l’algorithme et ne doit pas examiner l’empreinte plus avant (§2.1, points 6 à 8). Elle ne voit jamais le contrat.
- Elle ne sait pas qui la sollicite. La requête ne porte aucune identité du demandeur, et le jeton ne doit pas l’identifier (§2.1, point 9 ; §2.4.1). Des données identiques donnent toutefois des empreintes identiques : plusieurs jetons peuvent donc révéler qu’ils portent sur les mêmes données (§4, point 5).
- Elle utilise une clé dédiée. Le certificat de la TSA doit contenir un seul usage étendu de clé,
id-kp-timeStamping, marqué critique (§2.1, point 10 ; §2.3). Vous avez rencontré l’usage étendu de clé dans l’article 2. - Ses règles lui appartiennent. La RFC 3161 ne fixe aucune exigence de sécurité globale : la TSA publie ses politiques, et les clients décident si elles leur suffisent (§1).
L’échange : une requête, un jeton et des contrôles
Le protocole tient en un aller-retour. Le demandeur envoie une TimeStampReq ; la TSA répond par une TimeStampResp qui contient normalement un jeton. La RFC 3161 n’impose aucun transport : HTTP, le courrier électronique, les fichiers (.tsq et .tsr) et TCP sont tous décrits comme des options (RFC 3161, §2.2, §3).
La requête. Outre l’empreinte, une TimeStampReq peut porter un nonce, un indicateur certReq et une politique demandée (§2.4.1). Le nonce est un grand nombre aléatoire qui « permet au client de vérifier la fraîcheur de la réponse lorsqu’aucune horloge locale n’est disponible » ; la même valeur DOIT revenir. Quand certReq est vrai, la TSA doit inclure son certificat ; sinon, elle ne doit pas l’inclure (RFC 5816, §2.1). Une TSA qui juge l’algorithme de hachage trop faible devrait refuser avec badAlg (RFC 3161, §2.4.1).
La réponse. Un statut granted ou grantedWithMods signifie qu’un jeton est présent ; tout autre statut signifie qu’il ne l’est pas. Le jeton est un SignedData CMS, la structure de l’article 6, signé par la seule TSA, dont le contenu est un TSTInfo : politique, empreinte, numéro de série, genTime, accuracy facultatif, ordering, le nonce s’il a été envoyé, et un nom de TSA facultatif (§2.4.2).
Les contrôles. Le demandeur DOIT vérifier le statut, la concordance de l’empreinte et de l’algorithme de hachage avec ce qu’il a envoyé, la signature de la TSA et l’identifiant de son certificat, ainsi que la fraîcheur, par rapport à une horloge de confiance ou en comparant le nonce. Le moindre échec impose de rejeter le jeton. Il DEVRAIT aussi vérifier l’état de révocation du certificat de la TSA et la politique (RFC 3161, §2.2).
genTime : ce que l’heure signifie vraiment
Le champ le plus mal compris est genTime : « l’instant auquel le jeton d’horodatage a été créé par la TSA » (RFC 3161, §2.4.2). Il est exprimé en UTC, toujours avec les secondes, et les fractions de seconde sont facultatives.
Ce n’est pas le moment où Mariama a cliqué sur « Signer ». Ce n’est pas le moment où son logiciel a calculé la signature. C’est le moment où la TSA a fabriqué le jeton.
accuracy indique de combien l’horloge de la TSA peut s’écarter. En l’ajoutant à genTime, on obtient une borne supérieure de l’instant de création du jeton ; en la soustrayant, une borne inférieure. Si le jeton ne porte pas de précision, la valeur peut être connue autrement, par exemple par la politique de la TSA (§2.4.2).
« Au plus tard » : la phrase à retenir
Un horodatage prouve que les données hachées existaient au plus tard à genTime plus accuracy. Cette formulation est la nôtre, pas celle de la RFC : elle découle de la « preuve qu’une donnée existait avant un instant donné » de la RFC 3161 (§1) et de la borne supérieure que donne la précision (§2.4.2). C’est une borne supérieure, rien de plus. Les données peuvent avoir existé bien avant, et le jeton ne dit rien du moment où le signataire a agi. La documentation de pyHanko le formule de la même façon : un jeton « prouve seulement que la signature existait au moment où le jeton d’horodatage a été créé. La signature elle-même peut avoir été générée bien avant ! » (pyHanko, guide CLI : validation)
Horodatages de signature et horodatages de document
Un horodatage couvre ce qui a été haché dans son empreinte. Un PDF offre deux possibilités.
Un horodatage de signature est stocké dans l’objet CMS du signataire, sous la forme de l’attribut non signé id-aa-timeStampToken, que l’EN 319 142-1 appelle signature-time-stamp. Son empreinte est celle de la valeur de signature, pas celle du document (RFC 3161, annexe A). Il prouve que cette signature précise existait à genTime (plus accuracy). L’article 6 a montré où se trouvent les attributs non signés ; celui-ci s’ajoute après le calcul de la signature.
Un horodatage de document est un dictionnaire de signature à part entière, avec /Type /DocTimeStamp et /SubFilter /ETSI.RFC3161. Son Contents est un jeton RFC 3161 nu, dont l’empreinte est celle du ByteRange : tout le fichier, sauf le jeton lui-même (EN 319 142-1, §5.4.3). Il couvre donc tout ce que contient la révision, y compris chaque signature antérieure et toutes les données de validation déjà ajoutées. Un horodatage de document ne devrait porter ni nom de signataire ni heure de signature déclarée, et il est ignoré lors de l’évaluation DocMDP (§5.4.3).
De B-T à B-LTA
PAdES définit quatre niveaux baseline, et chacun « couvre toujours toutes les exigences couvertes par les niveaux inférieurs » (EN 319 142-1, §6.1). L’article 6 les a présentés ; voici ce que chacun ajoute du point de vue du temps.
- B-T ajoute « un jeton de confiance prouvant que la signature elle-même existait réellement à une date et une heure données ». Ce jeton est un horodatage de signature ou un horodatage de document, et il peut y en avoir plusieurs (§6.1 ; §6.3, tableau 1).
- B-LT ajoute « tout le matériel nécessaire à la validation de la signature » : les certificats d’AC et tout certificat de TSA déjà présent dans la signature (exig. r), ainsi que l’ensemble complet des réponses CRL ou OCSP pour chacun d’eux (§6.1 ; §6.3, exig. t). Ils vont dans le Document Security Store (Document Security Store) (DSS), un dictionnaire du catalogue qui contient les tableaux
Certs,OCSPsetCRLs(§5.4.2). Les formats CRL et OCSP sont ceux de l’article 4. - B-LTA ajoute au moins un horodatage de document, qui permet « la validation de la signature longtemps après sa génération ». Avant de l’ajouter, tout le matériel de validation manquant doit être ajouté, y compris pour les certificats de TSA antérieurs et les horodatages de document antérieurs (§6.1 ; §6.3, exig. w à y).
Le DSS est ajouté dans une mise à jour incrémentale non signée, exemptée de DocMDP (§5.4.1, §5.4.2) ; c’est l’horodatage de document B-LTA qui le scelle.
Pour les signatures baseline, « le dictionnaire VRI ne devrait pas être utilisé » (§6.3, exig. v), même si pyHanko en écrit un par défaut.
Valider des années plus tard
Les classes de validation de l’EN 319 102-1 suivent les niveaux, de la signature de base (B-B) à la disponibilité et l’intégrité à long terme du matériel de validation (B-LTA) (EN 319 102-1, §4.3.1, annexe B). Un jeton d’horodatage est lui-même une signature de base : le validateur le vérifie de la même façon, chaîne comprise (§5.4.1).
L’idée est une chaîne de preuves d’existence (proof of existence), « une preuve qu’un objet existait à une date et une heure données » (EN 319 142-1, §3.1). Chaque horodatage en fournit une pour tout ce qu’il couvre, mais seulement tant que son propre algorithme de hachage reste fiable, ou qu’un horodatage ultérieur le couvre depuis un moment où il l’était encore (EN 319 102-1, §5.6.2.3.1).
À partir de ces preuves, le validateur calcule une best-signature-time, « l’instant le plus ancien auquel l’existence de la signature peut être prouvée » (§5.5.4, note 10). Lorsqu’il rencontre un certificat révoqué, un algorithme cassé ou des données de révocation périmées, il fait reculer l’heure de validation jusqu’à un moment couvert par une preuve d’existence, puis vérifie de nouveau (§5.6.2.2.1). L’annexe B de la RFC 3161 en esquissait le principe : l’horodatage doit tomber dans la période de validité du certificat du signataire, et toute révocation doit lui être postérieure (RFC 3161, annexe B). Quand tout le matériel se trouve dans le fichier, ce contrôle peut se faire hors ligne (EN 319 102-1, §5.6.1).
Un horodatage de signature protège contre une révocation ultérieure, mais « pas toujours contre l’expiration » (§5.5.4, note 9). Les horodatages de document et le DSS couvrent ce cas.
Les certificats de TSA expirent aussi. La clé d’une TSA a une durée de vie limitée : la RFC 3161 indique donc que ses jetons devraient être horodatés de nouveau plus tard (RFC 3161, §4, point 3). En PAdES, c’est la LTV répétée : ajouter les données DSS qui valident le dernier horodatage de document, puis ajouter un nouvel horodatage de document sur l’ensemble. On fait de même lorsque l’algorithme du dernier horodatage « risque de céder à une attaque » (EN 319 142-1, §5.4.1, §5.4.3). C’est le renouvellement d’archivage (archival renewal), et il n’a pas de fin naturelle.
Mariama signe, Ibrahima vérifie des années plus tard
Supposons que le genTime de l’horodatage de signature indique 08:53:28 UTC. Si chaque maillon de la chaîne se vérifie, Ibrahima conclut que la signature de Mariama existait au plus tard à 08:53:28 UTC plus la précision de la TSA, alors que son certificat était valide et non révoqué. Il ne peut pas en conclure à quel moment Mariama a cliqué. L’heure qu’elle a déclarée dans /M n’est toujours que sa déclaration.
À vous : une TSA locale, puis un PDF horodaté
Tout ce qui suit s’exécute dans des conteneurs jetables, avec des clés de test générées pour l’occasion. N’utilisez jamais ces commandes avec une vraie clé.
RFC 3161 avec OpenSSL
openssl ts est « un client et un serveur d’autorité d’horodatage (TSA) élémentaires », sans prise en charge de HTTP ni de TCP (openssl-ts(1)) : nous faisons donc circuler des fichiers .tsq et .tsr dans un seul conteneur.
Exemple pédagogique exécutable · Docker · alpine:3.22 · OpenSSL 3.5.8 25 Aug 2026
Placez les deux fichiers ci-dessous, run.sh et tsa.cnf, dans un répertoire vide et lancez cette commande depuis ce répertoire. Les deux sont montés en lecture seule ; tout ce qu’écrit le script reste dans le conteneur.
docker run --rm \
--mount type=bind,src="$PWD/run.sh",dst=/in/run.sh,readonly \
--mount type=bind,src="$PWD/tsa.cnf",dst=/in/tsa.cnf,readonly \
-w /work alpine:3.22 sh /in/run.sh
run.sh construit une racine de démonstration et un certificat de TSA, écrit le contrat de l’article 1 et une copie falsifiée, puis demande, émet, inspecte et vérifie deux jetons, sans nonce puis avec nonce.
apk add --no-cache openssl
openssl version
mkdir -p tsa
# Root CA (the toy PKI's trust anchor for this scenario)
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out tsa/cakey.pem
openssl req -x509 -new -key tsa/cakey.pem -sha256 -days 3650 \
-subj "/CN=Demo Root CA/O=Demo PKI" \
-out tsa/cacert.pem
# TSA key + CSR
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out tsa/tsakey.pem
openssl req -new -key tsa/tsakey.pem \
-subj "/CN=Demo TSA/O=Demo PKI" \
-out tsa/tsa.csr
cat > tsa/tsa_ext.cnf <<'CNF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=critical,timeStamping
CNF
openssl x509 -req -in tsa/tsa.csr \
-CA tsa/cacert.pem -CAkey tsa/cakey.pem -CAcreateserial \
-extfile tsa/tsa_ext.cnf \
-days 3650 -sha256 \
-out tsa/tsacert.pem
echo "--- TSA cert extensions ---"
openssl x509 -in tsa/tsacert.pem -noout -text | sed -n '/X509v3 extensions/,/Signature Algorithm/p'
echo "01" > tsa/tsaserial
# Sample contract
printf "Contrat de pret 2026-001 : montant 5000000 GNF\n" > contrat.txt
printf "Contrat de pret 2026-001 : montant 6000000 GNF\n" > contrat_tampere.txt
echo "=== QUERY without nonce ==="
openssl ts -query -data contrat.txt -sha256 -cert -no_nonce -out req_nonoce.tsq
openssl ts -query -in req_nonoce.tsq -text
echo "=== QUERY with nonce ==="
openssl ts -query -data contrat.txt -sha256 -cert -out req_nonce.tsq
openssl ts -query -in req_nonce.tsq -text
echo "=== REPLY (local TSA) for no-nonce query ==="
openssl ts -reply -config /in/tsa.cnf -queryfile req_nonoce.tsq -out resp_nonoce.tsr
echo "=== REPLY (local TSA) for nonce query ==="
openssl ts -reply -config /in/tsa.cnf -queryfile req_nonce.tsq -out resp_nonce.tsr
echo "=== INSPECT reply (no-nonce) -text ==="
openssl ts -reply -in resp_nonoce.tsr -text
echo "=== INSPECT reply (with nonce) -text ==="
openssl ts -reply -in resp_nonce.tsr -text
echo "=== VERIFY no-nonce reply against original data, CAfile ==="
openssl ts -verify -data contrat.txt -in resp_nonoce.tsr -CAfile tsa/cacert.pem
echo "=== VERIFY nonce reply against original data + queryfile, CAfile ==="
openssl ts -verify -queryfile req_nonce.tsq -in resp_nonce.tsr -CAfile tsa/cacert.pem
echo "=== VERIFY no-nonce reply against TAMPERED data (expect fail) ==="
openssl ts -verify -data contrat_tampere.txt -in resp_nonoce.tsr -CAfile tsa/cacert.pem
echo "exit=$?"
tsa.cnf configure la TSA locale : un OID de politique de démonstration, SHA-256, une seconde de précision, un genTime à la seconde entière (clock_precision_digits = 0) et ordering = yes. Le paramètre crypto_device existe dans la série 3.5 que nous avons utilisée ; OpenSSL 4.0 l’a supprimé.
oid_section = new_oids
[new_oids]
demoTsaPolicy1 = 1.3.6.1.4.1.99999.1.1
[tsa]
default_tsa = tsa_config1
[tsa_config1]
dir = ./tsa
serial = $dir/tsaserial
crypto_device = builtin
signer_cert = $dir/tsacert.pem
certs = $dir/cacert.pem
signer_key = $dir/tsakey.pem
signer_digest = sha256
default_policy = demoTsaPolicy1
digests = sha256
accuracy = secs:1
clock_precision_digits = 0
ordering = yes
tsa_name = yes
ess_cert_id_chain = no
ess_cert_id_alg = sha256
Le certificat de la TSA, sortie réduite à la ligne de version et à l’étape du certificat (les lignes d’installation d’apk sont retirées de toutes les sorties ci-dessous) :
Résultat attendu
OpenSSL 3.5.8 25 Aug 2026 (Library: OpenSSL 3.5.8 25 Aug 2026)
Certificate request self-signature ok
subject=CN=Demo TSA, O=Demo PKI
--- TSA cert extensions ---
X509v3 extensions:
X509v3 Basic Constraints: critical
CA:FALSE
X509v3 Key Usage: critical
Digital Signature
X509v3 Extended Key Usage: critical
Time Stamping
X509v3 Subject Key Identifier:
98:68:FD:31:23:D5:69:D2:E5:EE:1B:F2:57:35:DF:6B:AB:94:93:2A
X509v3 Authority Key Identifier:
EF:8E:7B:8C:1F:DF:16:82:6F:6B:DA:FA:F0:99:7B:76:07:3B:D3:29
Signature Algorithm: ecdsa-with-SHA256Les deux requêtes. Les données du message sont la même empreinte SHA-256 du contrat que dans l’article 1 : ces 32 octets sont tout ce que reçoit la TSA.
Résultat attendu
=== QUERY without nonce ===
Using configuration from /etc/ssl/openssl.cnf
Using configuration from /etc/ssl/openssl.cnf
Version: 1
Hash Algorithm: sha256
Message data:
0000 - 0c 53 f8 4a 69 28 87 1a-96 53 28 cc 98 3d 0a 20 .S.Ji(...S(..=.
0010 - d0 7f 16 dd 69 a3 b2 73-84 7a a2 a9 f5 4c ff 4c ....i..s.z...L.L
Policy OID: unspecified
Nonce: unspecified
Certificate required: yes
Extensions:
=== QUERY with nonce ===
Using configuration from /etc/ssl/openssl.cnf
Using configuration from /etc/ssl/openssl.cnf
Version: 1
Hash Algorithm: sha256
Message data:
0000 - 0c 53 f8 4a 69 28 87 1a-96 53 28 cc 98 3d 0a 20 .S.Ji(...S(..=.
0010 - d0 7f 16 dd 69 a3 b2 73-84 7a a2 a9 f5 4c ff 4c ....i..s.z...L.L
Policy OID: unspecified
Nonce: 0x1786B6AED0388987
Certificate required: yes
Extensions:La TSA émet les deux jetons :
Résultat attendu
=== REPLY (local TSA) for no-nonce query ===
Using configuration from /in/tsa.cnf
Response has been generated.
20CD6592FFFF0000:error:0700006C:configuration file routines:NCONF_get_string:no value:crypto/conf/conf_lib.c:316:group=tsa_config1 name=other_policies
=== REPLY (local TSA) for nonce query ===
Using configuration from /in/tsa.cnf
Response has been generated.
204D09BBFFFF0000:error:0700006C:configuration file routines:NCONF_get_string:no value:crypto/conf/conf_lib.c:316:group=tsa_config1 name=other_policiesLa ligne d’erreur other_policies est sans conséquence : ce paramètre facultatif n’est pas défini, et le jeton a bien été généré.
Le jeton de la requête avec nonce, sortie réduite à cette seule inspection (le jeton sans nonce ne diffère que par son numéro de série, 0x02, et par Nonce: unspecified) :
Résultat attendu
=== INSPECT reply (with nonce) -text ===
Using configuration from /etc/ssl/openssl.cnf
Status info:
Status: Granted.
Status description: unspecified
Failure info: unspecified
TST info:
Version: 1
Policy OID: 1.3.6.1.4.1.99999.1.1
Hash Algorithm: sha256
Message data:
0000 - 0c 53 f8 4a 69 28 87 1a-96 53 28 cc 98 3d 0a 20 .S.Ji(...S(..=.
0010 - d0 7f 16 dd 69 a3 b2 73-84 7a a2 a9 f5 4c ff 4c ....i..s.z...L.L
Serial number: 0x03
Time stamp: Sep 23 08:52:16 2026 GMT
Accuracy: 0x01 seconds, unspecified millis, unspecified micros
Ordering: yes
Nonce: 0x1786B6AED0388987
TSA: DirName:/CN=Demo TSA/O=Demo PKI
Extensions:Time stamp est le genTime, à la seconde entière comme configuré, et Accuracy vaut une seconde : l’empreinte du contrat existait au plus tard à 08:52:17 GMT. Le nonce est revenu inchangé, et la politique est celle de tsa.cnf.
Vérifiez maintenant les jetons, puis confrontez le premier au contrat falsifié :
Résultat attendu
=== VERIFY no-nonce reply against original data, CAfile ===
Using configuration from /etc/ssl/openssl.cnf
Verification: OK
=== VERIFY nonce reply against original data + queryfile, CAfile ===
Using configuration from /etc/ssl/openssl.cnf
Verification: OK
=== VERIFY no-nonce reply against TAMPERED data (expect fail) ===
Using configuration from /etc/ssl/openssl.cnf
Verification: FAILED
202DA985FFFF0000:error:17800067:time stamp routines:ts_check_imprints:message imprint mismatch:crypto/ts/ts_rsp_verify.c:508:
exit=1L’empreinte falsifiée ne correspond pas à l’empreinte du jeton : la vérification échoue avec le code de sortie 1. Cette exécution n’a pas testé de nonce discordant.
Ce qui varie d’une exécution à l’autre : les clés, les empreintes de certificats, les identifiants de clés, le nonce, le genTime et le préfixe hexadécimal de chaque ligne d’erreur (20CD6592FFFF0000, etc.) changent à chaque fois. L’empreinte du contrat, elle, ne change pas. Sur votre machine, apk affiche aussi d’abord ses lignes de téléchargement, et les lignes de stderr peuvent apparaître dans un autre ordre si vous redirigez la sortie.
De B-T à un horodatage de document avec pyHanko
La CLI de pyHanko ne dialogue avec une TSA qu’en HTTP, et attend une réponse application/timestamp-reply (RFC 3161, §3.4). Notre exécution a enveloppé openssl ts -reply dans un petit serveur HTTP Python sur 127.0.0.1:3161. La commande pyhanko est désormais livrée à part, dans pyhanko-cli.
Exemple pédagogique exécutable · Docker · python:3.12-slim · pyHanko 0.37.0 · pyhanko-cli 0.5.0 · fpdf2 2.8.8 · OpenSSL 3.5.7 (Debian package)
Placez entry.sh et run.py, tous deux ci-dessous, dans un répertoire vide et lancez cette commande depuis ce répertoire. Les deux sont montés en lecture seule :
docker run --rm \
--mount type=bind,src="$PWD/entry.sh",dst=/in/entry.sh,readonly \
--mount type=bind,src="$PWD/run.py",dst=/in/run.py,readonly \
-w /work python:3.12-slim sh /in/entry.sh
entry.sh installe les paquets épinglés et lance le script d’amorçage :
pip install --quiet pyhanko==0.37.0 pyhanko-cli==0.5.0 fpdf2==2.8.8
python3 /in/run.py
Enregistrez ce script, celui-là même qui a produit la sortie ci-dessous, sous le nom run.py. Il construit une PKI jouet EC P-256 (racine de démonstration, TSA, Mariama), génère un contrat.pdf d’une page, démarre l’enveloppe HTTP de la TSA et exécute les commandes pyHanko présentées pas à pas à sa suite. Les niveaux de ses en-têtes (« expect B-T ») sont nos propres étiquettes, pas une sortie de pyHanko ; adesverify, plus bas, montre qu’ils sont optimistes.
import http.server
import os
import shutil
import socketserver
import subprocess
import sys
import tempfile
import threading
import time
WORK = "/work"
os.chdir(WORK)
def run(cmd, cwd=None):
print("+ " + " ".join(cmd))
r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
if r.stdout:
sys.stdout.write(r.stdout)
if r.stderr:
sys.stdout.write(r.stderr)
print(f"[exit={r.returncode}]")
return r
def header(title):
print("=" * 80)
print(title)
print("=" * 80)
header("VERSIONS")
run(["openssl", "version"])
run(["pyhanko", "--version"])
import fpdf # noqa: E402
print(f"fpdf2 {fpdf.__version__}")
print()
header("BUILD TOY PKI + TSA (openssl, EC P-256)")
os.makedirs("tsa", exist_ok=True)
run(["openssl", "genpkey", "-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256",
"-out", "tsa/cakey.pem"])
run(["openssl", "req", "-x509", "-new", "-key", "tsa/cakey.pem", "-sha256", "-days", "3650",
"-subj", "/CN=Demo Root CA/O=Demo PKI", "-out", "tsa/cacert.pem"])
run(["openssl", "genpkey", "-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256",
"-out", "tsa/tsakey.pem"])
run(["openssl", "req", "-new", "-key", "tsa/tsakey.pem",
"-subj", "/CN=Demo TSA/O=Demo PKI", "-out", "tsa/tsa.csr"])
with open("tsa/tsa_ext.cnf", "w") as f:
f.write(
"basicConstraints=critical,CA:FALSE\n"
"keyUsage=critical,digitalSignature\n"
"extendedKeyUsage=critical,timeStamping\n"
)
run(["openssl", "x509", "-req", "-in", "tsa/tsa.csr",
"-CA", "tsa/cacert.pem", "-CAkey", "tsa/cakey.pem", "-CAcreateserial",
"-extfile", "tsa/tsa_ext.cnf", "-days", "3650", "-sha256",
"-out", "tsa/tsacert.pem"])
with open("tsa/tsaserial", "w") as f:
f.write("01\n")
with open("tsa.cnf", "w") as f:
f.write(
"oid_section = new_oids\n\n"
"[new_oids]\n"
"demoTsaPolicy1 = 1.3.6.1.4.1.99999.1.1\n\n"
"[tsa]\n"
"default_tsa = tsa_config1\n\n"
"[tsa_config1]\n"
"dir = ./tsa\n"
"serial = $dir/tsaserial\n"
"crypto_device = builtin\n"
"signer_cert = $dir/tsacert.pem\n"
"certs = $dir/cacert.pem\n"
"signer_key = $dir/tsakey.pem\n"
"signer_digest = sha256\n"
"default_policy = demoTsaPolicy1\n"
"digests = sha256\n"
"accuracy = secs:1\n"
"clock_precision_digits = 0\n"
"ordering = yes\n"
"tsa_name = yes\n"
"ess_cert_id_chain = no\n"
"ess_cert_id_alg = sha256\n"
)
run(["openssl", "genpkey", "-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256",
"-out", "mariama.key"])
run(["openssl", "req", "-new", "-key", "mariama.key",
"-subj", "/CN=Mariama Diallo", "-out", "mariama.csr"])
with open("mariama_ext.cnf", "w") as f:
f.write(
"basicConstraints=critical,CA:FALSE\n"
"keyUsage=critical,digitalSignature,nonRepudiation\n"
)
run(["openssl", "x509", "-req", "-in", "mariama.csr",
"-CA", "tsa/cacert.pem", "-CAkey", "tsa/cakey.pem", "-CAcreateserial",
"-extfile", "mariama_ext.cnf", "-days", "3650", "-sha256",
"-out", "mariama.pem"])
print()
header("BUILD contrat.pdf (fpdf2)")
from fpdf import FPDF # noqa: E402
pdf = FPDF()
pdf.add_page()
pdf.set_font("Helvetica", size=12)
pdf.multi_cell(0, 10, "Contrat de pret 2026-001 : montant 5000000 GNF")
pdf.output("contrat.pdf")
print(f"wrote contrat.pdf {os.path.getsize('contrat.pdf')} bytes")
print()
header("START local RFC 3161 HTTP TSA (wraps `openssl ts -reply`)")
class TSAHandler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
qfd, qpath = tempfile.mkstemp(suffix=".tsq")
with os.fdopen(qfd, "wb") as qf:
qf.write(body)
rpath = qpath + ".tsr"
r = subprocess.run(
["openssl", "ts", "-reply", "-config", "tsa.cnf", "-queryfile", qpath, "-out", rpath],
cwd=WORK, capture_output=True, text=True,
)
print(f"[tsa-server] openssl ts -reply exit={r.returncode} stderr={r.stderr!r}")
if r.returncode == 0 and os.path.exists(rpath):
with open(rpath, "rb") as f:
resp = f.read()
self.send_response(200)
self.send_header("Content-Type", "application/timestamp-reply")
self.send_header("Content-Length", str(len(resp)))
self.end_headers()
self.wfile.write(resp)
else:
self.send_response(500)
self.end_headers()
os.unlink(qpath)
if os.path.exists(rpath):
os.unlink(rpath)
def log_message(self, fmt, *args):
print(f"[tsa-server] {fmt % args}")
httpd = socketserver.TCPServer(("127.0.0.1", 3161), TSAHandler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
time.sleep(0.3)
print("TSA HTTP server up on http://127.0.0.1:3161")
print()
header("STEP 1 - addsig: PAdES B-T (signature + signature timestamp)")
run(["pyhanko", "sign", "addsig", "--field", "Signature1", "--use-pades",
"--timestamp-url", "http://127.0.0.1:3161", "pemder",
"--key", "mariama.key", "--cert", "mariama.pem", "--chain", "tsa/cacert.pem", "--no-pass",
"contrat.pdf", "contrat-bt.pdf"])
print()
header("VALIDATE after step 1 (expect B-T) - compact status line")
run(["pyhanko", "sign", "validate", "--trust", "tsa/cacert.pem", "contrat-bt.pdf"])
print()
header("VALIDATE after step 1 (expect B-T) - pretty print")
run(["pyhanko", "sign", "validate", "--pretty-print", "--trust", "tsa/cacert.pem", "contrat-bt.pdf"])
print()
header("STEP 2 - ltvfix: add validation info to the DSS (PAdES B-LT)")
shutil.copyfile("contrat-bt.pdf", "contrat-lt.pdf")
print("+ cp contrat-bt.pdf contrat-lt.pdf")
print("[exit=0]")
run(["pyhanko", "sign", "ltvfix", "--field", "Signature1", "--timestamp-url", "http://127.0.0.1:3161",
"--trust", "tsa/cacert.pem", "contrat-lt.pdf"])
print()
header("VALIDATE after step 2 (expect B-LT) - compact status line")
run(["pyhanko", "sign", "validate", "--trust", "tsa/cacert.pem", "contrat-lt.pdf"])
print()
header("VALIDATE after step 2 (expect B-LT) - pretty print")
run(["pyhanko", "sign", "validate", "--pretty-print", "--trust", "tsa/cacert.pem", "contrat-lt.pdf"])
print()
header("STEP 3 - ltaupdate: add a document timestamp (PAdES B-LTA)")
shutil.copyfile("contrat-lt.pdf", "contrat-lta.pdf")
print("+ cp contrat-lt.pdf contrat-lta.pdf")
print("[exit=0]")
run(["pyhanko", "sign", "ltaupdate", "--timestamp-url", "http://127.0.0.1:3161",
"--trust", "tsa/cacert.pem", "contrat-lta.pdf"])
print()
header("VALIDATE after step 3 (expect B-LTA) - compact status line")
run(["pyhanko", "sign", "validate", "--trust", "tsa/cacert.pem", "contrat-lta.pdf"])
print()
header("VALIDATE after step 3 (expect B-LTA) - pretty print")
run(["pyhanko", "sign", "validate", "--pretty-print", "--trust", "tsa/cacert.pem", "contrat-lta.pdf"])
print()
header("EXTRA - strict AdES validation (adesverify) on the B-LTA file")
print("Our toy CA issues no CRL/OCSP, so this is expected to report missing revocation info for the")
print("end-entity certs - captured verbatim to show that honestly, not swallowed.")
run(["pyhanko", "sign", "adesverify", "--pretty-print", "--trust", "tsa/cacert.pem", "contrat-lta.pdf"])
print()
header("DONE")
httpd.shutdown()
Les versions des outils qu’il a affichées, sortie réduite aux trois lignes de version :
Résultat attendu
OpenSSL 3.5.7 9 Jun 2026 (Library: OpenSSL 3.5.7 9 Jun 2026)
pyHanko, version 0.37.0 (CLI 0.5.0)
fpdf2 2.8.8Étape 1 : signer avec un horodatage de signature (B-T). --use-pades produit une signature PAdES, et --timestamp-url demande à la TSA un jeton portant sur la valeur de signature.
pyhanko sign addsig \
--field Signature1 --use-pades --timestamp-url http://127.0.0.1:3161 \
pemder --key mariama.key --cert mariama.pem --chain tsa/cacert.pem --no-pass \
contrat.pdf contrat-bt.pdf
Validez ensuite, en forme compacte puis lisible (chaque validate suivant affiche la même ligne WARNING ; elle est omise plus bas) :
pyhanko sign validate --trust tsa/cacert.pem contrat-bt.pdf
pyhanko sign validate --pretty-print --trust tsa/cacert.pem contrat-bt.pdf
Résultat attendu
Signature1:a8c8be765ff351cecfaed6e28818d71239b162b7d6068c466921c4655a8f78a3:INTACT:TRUSTED,TIMESTAMP_TOKEN<INTACT:TRUSTED>,UNTOUCHED
2026-09-23 08:53:28,440 - cli - WARNING - The configured trust roots are being combined with the operating system's trust list. This fallback is deprecated and will be removed in a future release, when the behaviour of '--trust-replace' becomes the default.Le WARNING est un avis de dépréciation : --trust est combiné à la liste de confiance du système (l’article 6 utilisait --trust-replace). La forme lisible, sortie réduite de la section sur l’heure de signature jusqu’au verdict :
Résultat attendu
Signing time
------------
Signing time as reported by signer: 2026-09-23T08:53:28+00:00
Signature timestamp token: 2026-09-23T08:53:28+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: "Organization: Demo PKI, Common Name: Demo TSA"
TSA certificate SHA1 fingerprint: 3ee94bebbca26d20a90e624a27f6e9d335dd80f6
TSA certificate SHA256 fingerprint: dcc75c4effa8a60eb8f7bbc5a53620ffbda489339cc1d7a1060e511c16109517
TSA cert trust anchor: "Organization: Demo PKI, Common Name: Demo Root CA"
The TSA certificate is trusted.
Modifications
-------------
The signature covers the entire file.
Bottom line
-----------
The signature is judged VALID.« Signing time as reported by signer » est la déclaration de Mariama. « Signature timestamp token » est le genTime de la TSA, et pyHanko en tire la bonne conclusion : « The token is guaranteed to be newer than the signature » (le jeton est forcément postérieur à la signature).
Étape 2 : ajouter les données de validation, et un horodatage de document. ltvfix ajoute au DSS, sur place, les données de validation qu’il trouve pour Signature1. Comme l’exécution passait aussi --timestamp-url, pyhanko-cli 0.5.0 ajoute ensuite un horodatage de document ; la TSA locale a répondu pendant cette étape.
cp contrat-bt.pdf contrat-lt.pdf
pyhanko sign ltvfix \
--field Signature1 --timestamp-url http://127.0.0.1:3161 --trust tsa/cacert.pem \
contrat-lt.pdf
pyhanko sign validate --trust tsa/cacert.pem contrat-lt.pdf
pyhanko sign validate --pretty-print --trust tsa/cacert.pem contrat-lt.pdf
Résultat attendu
Signature1:a8c8be765ff351cecfaed6e28818d71239b162b7d6068c466921c4655a8f78a3:INTACT:TRUSTED,TIMESTAMP_TOKEN<INTACT:TRUSTED>,EXTENDED_WITH_LTA_UPDATES,ACCEPTABLE_MODIFICATIONSSortie réduite aux deux dernières sections de la forme lisible :
Résultat attendu
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.Étape 3 : renouveler avec ltaupdate. ltaupdate ajoute un nouvel horodatage de document, sur place. Il ne valide que l’horodatage le plus externe : une chaîne rompue plus bas n’est ni détectée ni réparée (pyHanko, guide CLI : signature). C’est un tour de renouvellement d’archivage.
cp contrat-lt.pdf contrat-lta.pdf
pyhanko sign ltaupdate \
--timestamp-url http://127.0.0.1:3161 --trust tsa/cacert.pem \
contrat-lta.pdf
pyhanko sign validate --trust tsa/cacert.pem contrat-lta.pdf
pyhanko sign validate --pretty-print --trust tsa/cacert.pem contrat-lta.pdf
Résultat attendu
Signature1:a8c8be765ff351cecfaed6e28818d71239b162b7d6068c466921c4655a8f78a3:INTACT:TRUSTED,TIMESTAMP_TOKEN<INTACT:TRUSTED>,EXTENDED_WITH_LTA_UPDATES,ACCEPTABLE_MODIFICATIONSLes sections de modifications et de verdict de la forme lisible sont les mêmes qu’après l’étape 2.
Ce que validate vous dit, et ce qu’il ne dit pas. Il n’affiche jamais de niveau PAdES, à aucune étape. Il montre des ingrédients. TIMESTAMP_TOKEN<INTACT:TRUSTED> signifie un horodatage de signature dont la chaîne de TSA est de confiance : l’ingrédient de B-T. Après les étapes 2 et 3, la ligne indique EXTENDED_WITH_LTA_UPDATES,ACCEPTABLE_MODIFICATIONS : des mises à jour ont été ajoutées, et pyHanko les accepte comme maintenance à long terme. Le niveau se déduit des commandes exécutées et de la structure du fichier ; pyHanko « ne propose pas actuellement de validation des exigences structurelles des profils PAdES » (pyHanko, guide CLI : validation).
Le contrôle plus strict. La commande adesverify de pyHanko suit de plus près le modèle de validation AdES. Nous l’avons lancée sur le fichier final :
pyhanko sign adesverify --pretty-print --trust tsa/cacert.pem contrat-lta.pdf
Sortie réduite aux sections à partir de l’heure de signature, plus le journal :
Résultat attendu
Signing time
------------
No available information about the signing time.
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 INVALID.
2026-09-23 08:53:30,525 - cli - WARNING - The configured trust roots are being combined with the operating system's trust list. [...]
2026-09-23 08:53:30,582 - pyhanko.sign.validation.generic_cms - WARNING - Validation error [cert context: Organization: Demo PKI, Common Name: Demo TSA]: The path could not be validated because no revocation information could be found for the end-entity certificate Organization: Demo PKI, Common Name: Demo TSA
2026-09-23 08:53:30,584 - pyhanko.sign.validation.generic_cms - WARNING - Validation error [cert context: Organization: Demo PKI, Common Name: Demo TSA]: Failed to get control time for point-in-time validation for path with leaf Organization: Demo PKI, Common Name: Demo TSA
2026-09-23 08:53:30,584 - pyhanko.sign.validation.ades - WARNING - Unable to construct plausible past validation path [AdESIndeterminate.NO_POE]
2026-09-23 08:53:30,585 - pyhanko.sign.validation.ades - WARNING - Document timestamp chain failed to validate; proceeding without past proof of existence.
2026-09-23 08:53:30,587 - pyhanko.sign.validation.generic_cms - WARNING - Validation error [cert context: Common Name: Mariama Diallo]: The path could not be validated because no revocation information could be found for the end-entity certificate Common Name: Mariama Diallo
Error: Validation failed
[exit=1]Elle échoue, et à juste titre : notre AC jouet n’a placé d’adresse CRL ou OCSP dans aucun certificat, et le code source de pyHanko ne récupère des données de révocation que là où une adresse est déclarée. pyHanko n’en a donc intégré aucune, alors que B-LT l’exige (EN 319 142-1, §6.3, exig. t). Sans elles, adesverify ne peut pas valider le chemin de certification de la TSA, et n’a donc aucune heure de signature de confiance. Un vrai déploiement exige des certificats qui pointent vers une CRL ou un répondeur OCSP en service, comme dans l’article 4.
Ce qui varie d’une exécution à l’autre : les clés, les empreintes de certificats, les genTime, les nonces et les horodatages du journal changent à chaque fois. pip affiche un avertissement sur l’exécution en tant que root, ainsi que des avis de mise à jour.
En pratique, l’option --use-pades-lta de pyHanko, que son aide décrit comme produisant une signature PAdES-B-LTA, fait tout cela en une seule commande, avec une --timestamp-url.
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. Les clés de signature sont conservées dans un HSM (PKCS#11) ou un KMS, et le journal d’audit chaîné par hachage est scellé dans le PDF signé. Ibrahima n’a pas besoin de pyHanko pour contrôler un document SEDEYA : il peut le déposer sur la page de vérification publique, ou scanner le QR code qui y est imprimé. Pour le point de vue du signataire sur ce même parcours, lisez comment fonctionne vraiment une signature électronique ; les articles suivants traitent de l’identité et de l’architecture de production.
Risques et idées reçues
« L'horodatage dit quand Mariama a signé. »
genTime est l’instant où la TSA a créé le jeton (RFC 3161, §2.4.2). La signature existait au plus
tard à genTime plus accuracy, et peut-être bien avant. L’heure inscrite dans /M n’est qu’une
déclaration (EN 319 102-1, §4.2.5.8).
« Le nonce prouve l'heure. »
Le nonce prouve la fraîcheur : cette réponse répond bien à cette requête et n’est pas rejouée.
L’heure vient du genTime signé par la TSA (RFC 3161, §2.4.1, §4). Et openssl ts -verify -data ne
vérifie pas du tout le nonce ; seul -queryfile le fait.
« Une fois en B-LTA, un fichier est valide pour toujours. »
Il ne reste vérifiable qu’aussi longtemps que son dernier horodatage de document : le certificat de sa TSA et son algorithme de hachage. Quelqu’un doit continuer d’ajouter des données de validation et un nouvel horodatage de document (EN 319 142-1, §5.4.1 ; RFC 3161, §4, point 3). pyHanko parle d’une maintenance « active » du document.
En résumé
- Une heure de signature déclarée n’est qu’une déclaration. Un jeton RFC 3161 est une affirmation signée par une TSA au sujet d’une empreinte, faite avec une clé réservée à l’horodatage.
- genTime est l’instant où la TSA a fabriqué le jeton. Les données existaient au plus tard à genTime plus accuracy ; le jeton ne fixe aucune borne inférieure.
- Un horodatage de signature couvre une valeur de signature ; un horodatage de document couvre tout le fichier, DSS compris.
- B-T ajoute une heure fiable, B-LT les certificats et les données de révocation, B-LTA un horodatage de document sur l’ensemble. Garder un fichier B-LTA en vie exige des renouvellements répétés.
- Dans la partie pratique,
validaten’a jamais affiché de niveau, etadesverifya échoué faute de données de révocation.
Vérifiez votre compréhension
Vérifiez votre compréhension
Le genTime d'un jeton est 08:52:16 GMT, avec une seconde de précision. Que peut dire Ibrahima de l'empreinte du contrat ?
Afficher la réponse
Qu’elle existait au plus tard à 08:52:17 GMT. Rien sur le temps écoulé avant, et rien sur le moment où quiconque a signé.
Vérifiez votre compréhension
Pourquoi un horodatage de document protège-t-il le DSS, alors qu'un horodatage de signature ne le protège pas ?
Afficher la réponse
L’empreinte d’un horodatage de signature ne porte que sur la valeur de signature. Celle d’un horodatage de document porte sur tout le ByteRange, qui inclut le DSS ajouté avant lui.
Prochaine étape
Les horodatages et les données de validation disent à Ibrahima quand une signature existait, et qu’elle peut encore être vérifiée. Ils ne disent rien de l’identité de Mariama. L’article 8 traite de l’identité, des niveaux de garantie et du droit. En attendant, parlez-nous : nous vous montrerons un PDF signé avec SEDEYA, avec ses horodatages et ses données de validation intégrées, et comment n’importe qui peut le vérifier.
Références
- Internet X.509 Public Key Infrastructure Time-Stamp Protocol (TSP), RFC 3161, IETF, August 2001, updated by RFC 5816 (§1, §2.1–2.4, §3, §4, Appendix A, Appendix B)
- ESSCertIDv2 Update for RFC 3161, RFC 5816, IETF, April 2010 (§2.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) (§3.1, §5.4.1–5.4.3, §6.1, §6.3 Table 1)
- Electronic Signatures and Trust Infrastructures (ESI); Procedures for Creation and Validation of AdES Digital Signatures; Part 1, ETSI EN 319 102-1, ETSI, V1.4.1 (2024-06) (§4.2.5.8, §4.3.1, §5.4.1, §5.5.4, §5.6.1, §5.6.2, Annex B)
- openssl-ts(1), OpenSSL, OpenSSL 3.5 manual (Description, configuration file options, bugs)
- pyHanko documentation, pyHanko project, v0.37.0 (CLI guide: signing (cli-guide/signing.html) and validation (cli-guide/validation.html))

