On this page
- The problem: a clock you have to take on trust
- What a timestamp token is
- The exchange: a request, a token, and checks
- genTime: what the time actually means
- Signature timestamps and document timestamps
- From B-T to B-LTA
- Validating years later
- Mariama signs, Ibrahima checks years later
- Try it: a local TSA, then a timestamped PDF
- RFC 3161 with OpenSSL
- From B-T to a document timestamp with pyHanko
- Where SEDEYA fits
- Risks and misconceptions
- Summary
- Check your understanding
- Next step
Who this is for
Software engineers and technical decision-makers who have read Articles 1 to 6 and know what a certificate, a CRL and a PAdES signature are.
Read first
You will learn
- Explain why a signer's clock or a PDF's claimed signing time is not evidence, and what a time-stamping authority adds
- Read an RFC 3161 request and token: message imprint, nonce, policy, genTime and accuracy
- State precisely what a timestamp proves: the data existed no later than genTime plus accuracy
- Tell a signature timestamp from a document timestamp, and map them to PAdES B-T, B-LT and B-LTA
- Describe how a validator uses timestamps to check a signature after its certificates expire, and why archival renewal never stops
- Run a local TSA with OpenSSL, then take a PDF from B-T to a document-timestamped file with pyHanko, and read what the validators report
The problem: a clock you have to take on trust
Mariama signed the loan contract this morning. Her signature from Article 6 tells Ibrahima which key signed which bytes. It does not tell him when.
The PDF does carry a date. The signature dictionary’s /M entry holds the claimed signing time, and PAdES requires it at every level (EN 319 142-1, §6.3, Table 1). But Mariama’s software wrote it, from Mariama’s clock. EN 319 102-1 is plain about this: a claimed signing time “can only be a claimed one”, as with a date written by hand on paper, and a timestamp is needed when a policy asks for more than a claim (EN 319 102-1, §4.2.5.8).
In ten years, Mariama’s certificate will have expired, perhaps been revoked, and SHA-256 may no longer be considered strong. Ibrahima, or a judge, will still need to know whether the signature was made while everything was in order. A clock anyone can set cannot answer that. A timestamp signed by a third party can, and the PAdES levels build on it.
What a timestamp token is
RFC 3161 opens with the whole idea: “A time-stamping service supports assertions of proof that a datum existed before a particular time” (RFC 3161, §1). The service is a time-stamping authority (TSA), a trusted third party that issues time-stamp tokens.
Its own motivating example is Ibrahima’s problem: showing that a signature predates a revocation, so that “a revoked public key certificate [can] be used for verifying signatures created prior to the time of revocation” (§1).
A TSA is REQUIRED to use a trustworthy time source, put that time and a unique serial number in every token, and name the policy under which it issued the token (§2.1). Four more rules shape what it can and cannot know:
- It sees only a hash. The requester sends a message imprint: a hash algorithm identifier and a hash value. The TSA checks the length against the algorithm and must not examine the imprint any further (§2.1, items 6–8). It never sees the contract.
- It does not know who asked. The request carries no requester identity, and the token must not identify the requester (§2.1, item 9; §2.4.1). Identical data still gives identical imprints, so several tokens can reveal that they cover the same data (§4, item 5).
- It uses a dedicated key. The TSA’s certificate must contain exactly one extended key usage,
id-kp-timeStamping, marked critical (§2.1, item 10; §2.3). You met extended key usage in Article 2. - Its rules are its own. RFC 3161 sets no overall security requirements; the TSA publishes policies, and clients decide whether they suffice (§1).
The exchange: a request, a token, and checks
The protocol is one round trip. The requester sends a TimeStampReq; the TSA answers with a TimeStampResp that normally contains a token. RFC 3161 mandates no transport: HTTP, e-mail, files (.tsq and .tsr) and TCP are all described as options (RFC 3161, §2.2, §3).
The request. Besides the imprint, a TimeStampReq can carry a nonce, a certReq flag and a requested policy (§2.4.1). The nonce is a large random number that “allows the client to verify the timeliness of the response when no local clock is available”; the same value MUST come back. When certReq is true, the TSA must include its certificate; otherwise it must not (RFC 5816, §2.1). A TSA that considers the hash algorithm weak should refuse with badAlg (RFC 3161, §2.4.1).
The response. A status of granted or grantedWithMods means a token is present; any other status means it is not. The token is a CMS SignedData, the structure from Article 6, signed only by the TSA, whose content is a TSTInfo: policy, imprint, serial number, genTime, optional accuracy, ordering, the nonce if one was sent, and an optional TSA name (§2.4.2).
The checks. The requester SHALL check the status, that the imprint and hash algorithm match what it sent, the TSA’s signature and certificate identifier, and timeliness, against a trusted clock or by matching the nonce. Any failure means rejecting the token. It SHOULD also check the TSA certificate’s revocation status and the policy (RFC 3161, §2.2).
genTime: what the time actually means
The most misread field is genTime: “the time at which the time-stamp token has been created by the TSA” (RFC 3161, §2.4.2). It is in UTC, always with seconds, and fractions are optional.
It is not when Mariama clicked “sign”. It is not when her software computed the signature. It is when the TSA made the token.
accuracy says how far the TSA’s clock may be off. Adding it to genTime gives an upper limit on when the token was created; subtracting it gives a lower limit. If the token carries no accuracy, the value may be available by other means, for example from the TSA’s policy (§2.4.2).
“Existed no later than”: the one sentence to remember
A timestamp proves that the hashed data existed no later than genTime plus accuracy. That phrase is ours, not the RFC’s: it follows from RFC 3161’s “proof that a datum existed before a particular time” (§1) and the upper limit that accuracy gives (§2.4.2). It is an upper bound only. The data may have existed long before, and the token says nothing about when the signer acted. pyHanko’s documentation puts it the same way: a token “only provides proof that the signature existed when the timestamp token was created. The signature itself may have been generated long before that!” (pyHanko, validation guide)
Signature timestamps and document timestamps
A timestamp covers whatever was hashed into its imprint. A PDF offers two choices.
A signature timestamp is stored inside the signer’s CMS object, as the unsigned attribute id-aa-timeStampToken, which EN 319 142-1 calls signature-time-stamp. Its imprint is the hash of the signature value, not of the document (RFC 3161, Appendix A). It proves that this particular signature existed by genTime (plus accuracy). Article 6 showed where unsigned attributes sit; this one is added after the signature is computed.
A document timestamp is a signature dictionary of its own, with /Type /DocTimeStamp and /SubFilter /ETSI.RFC3161. Its Contents is a bare RFC 3161 token whose imprint is the hash of the ByteRange: the whole file except the token itself (EN 319 142-1, §5.4.3). So it covers everything in the revision, including every earlier signature and any validation data already added. A document timestamp should carry no signer name or claimed signing time, and it is ignored when evaluating DocMDP (§5.4.3).
From B-T to B-LTA
PAdES defines four baseline levels, and each “always addresses all the requirements addressed at levels that are below it” (EN 319 142-1, §6.1). Article 6 introduced them; here is what each one adds in terms of time.
- B-T adds “a trusted token proving that the signature itself actually existed at a certain date and time”. The token is a signature timestamp or a document timestamp, and there may be several (§6.1; §6.3, Table 1).
- B-LT adds “all the material required for validating the signature”: the CA certificates and any TSA certificate already in the signature (req. r), and the full set of CRL or OCSP responses for all of them (§6.1; §6.3, req. t). They go in the Document Security Store (DSS), a catalog-level dictionary with arrays
Certs,OCSPsandCRLs(§5.4.2). The CRL and OCSP formats are the ones from Article 4. - B-LTA adds at least one document timestamp, which allows “validation of the signature long time after its generation”. Before adding it, all missing validation material shall be added, including for earlier TSA certificates and earlier document timestamps (§6.1; §6.3, req. w–y).
The DSS is added in an unsigned incremental update, exempt from DocMDP (§5.4.1, §5.4.2); the B-LTA document timestamp is what seals it.
For baseline signatures, “the VRI dictionary should not be used” (§6.3, req. v), though pyHanko writes one by default.
Validating years later
EN 319 102-1’s validation classes line up with the levels, from basic signature (B-B) to long-term availability and integrity of validation material (B-LTA) (EN 319 102-1, §4.3.1, Annex B). A timestamp token is itself a basic signature, so the validator checks it the same way, chain and all (§5.4.1).
The idea is a chain of proofs of existence, “evidence that proves that an object existed at a specific date/time” (EN 319 142-1, §3.1). Each timestamp gives one for everything it covers, but only while the timestamp’s own hash algorithm is still reliable, or a later timestamp covers it from a time when it was (EN 319 102-1, §5.6.2.3.1).
From those proofs the validator computes a best-signature-time, “the earliest time at which the existence of the signature can be proven” (§5.5.4, note 10). When it meets a revoked certificate, a broken algorithm or stale revocation data, it slides the validation time back to a moment covered by a proof of existence and checks again (§5.6.2.2.1). RFC 3161’s Appendix B sketched the core: the timestamp must fall inside the signer certificate’s validity, and any revocation must come after it (RFC 3161, Appendix B). With all material in the file, this can run offline (EN 319 102-1, §5.6.1).
A signature timestamp protects against later revocation, but “not always against expiration” (§5.5.4, note 9). Document timestamps and the DSS cover that.
TSA certificates run out too. A TSA’s key has a finite life, so RFC 3161 says its tokens should be timestamped again later (RFC 3161, §4, item 3). In PAdES that is repeated LTV: add DSS data that validates the last document timestamp, then add a new document timestamp over everything. The same is done when the last timestamp’s algorithm “becomes at risk for successful attack” (EN 319 142-1, §5.4.1, §5.4.3). This is archival renewal, and it has no natural end.
Mariama signs, Ibrahima checks years later
Say the signature timestamp’s genTime reads 08:53:28 UTC. If every link in the chain checks out, Ibrahima concludes that Mariama’s signature existed no later than 08:53:28 UTC plus the TSA’s accuracy, while her certificate was valid and not revoked. He cannot conclude when Mariama clicked. Her claimed time in /M is still only her claim.
Try it: a local TSA, then a timestamped PDF
Everything below runs in throwaway containers with generated test keys. Never use these commands with a real key.
RFC 3161 with OpenSSL
openssl ts is “a basic Time Stamping Authority (TSA) client and server” with no HTTP or TCP support (openssl-ts(1)), so we pass .tsq and .tsr files around inside one container.
Executable educational example · Docker · alpine:3.22 · OpenSSL 3.5.8 25 Aug 2026
Put the two files below, run.sh and tsa.cnf, in an empty directory and run this from it. Both are mounted read-only; everything the script writes stays inside the container.
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 builds a demo root and a TSA certificate, writes Article 1’s contract and a tampered copy, then requests, issues, inspects and verifies two tokens, without and with a 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 configures the local TSA: a demo policy OID, SHA-256, one second of accuracy, whole-second genTime (clock_precision_digits = 0), and ordering = yes. The crypto_device setting exists in the 3.5 series we ran; OpenSSL 4.0 removed it.
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
The TSA certificate, trimmed to the version line and the certificate step (the apk install lines are removed from all the output below):
Expected output
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-SHA256The two requests. The message data is the same SHA-256 digest of the contract you saw in Article 1: those 32 bytes are all the TSA receives.
Expected output
=== 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:The TSA issues both tokens:
Expected output
=== 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_policiesThe other_policies error line is harmless: that optional setting is unset, and the token was still generated.
The token for the request with a nonce, trimmed to that one inspection (the no-nonce token differs only in its serial number, 0x02, and Nonce: unspecified):
Expected output
=== 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 is genTime, in whole seconds as configured, and Accuracy is one second: the contract’s digest existed no later than 08:52:17 GMT. The nonce came back unchanged, and the policy is the one from tsa.cnf.
Now verify the tokens, then check the first one against the tampered contract:
Expected output
=== 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=1The tampered digest does not match the imprint, so verification fails with exit status 1. This run did not test a mismatched nonce.
What varies between runs: keys, fingerprints, key identifiers, the nonce, genTime and the hex prefix of each error line (20CD6592FFFF0000 and so on) change every time. The digest of the contract does not. On your machine, apk also prints its download lines first, and stderr lines may appear in a different order if you redirect output.
From B-T to a document timestamp with pyHanko
pyHanko’s CLI only talks to a TSA over HTTP, expecting an application/timestamp-reply answer (RFC 3161, §3.4). Our run wrapped openssl ts -reply in a small Python HTTP server on 127.0.0.1:3161. The pyhanko command now ships separately, in pyhanko-cli.
Executable educational example · Docker · python:3.12-slim · pyHanko 0.37.0 · pyhanko-cli 0.5.0 · fpdf2 2.8.8 · OpenSSL 3.5.7 (Debian package)
Put entry.sh and run.py, both below, in an empty directory and run this from it. Both are mounted read-only:
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 installs the pinned packages and starts the bootstrap script:
pip install --quiet pyhanko==0.37.0 pyhanko-cli==0.5.0 fpdf2==2.8.8
python3 /in/run.py
Save this script, the exact one that produced the output below, as run.py. It builds a toy EC P-256 PKI (demo root, TSA, Mariama), renders a one-page contrat.pdf, starts the HTTP TSA wrapper and runs the pyHanko commands shown step by step after it. The levels in its headers (“expect B-T”) are our labels, not pyHanko output; adesverify below shows them optimistic.
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()
The tool versions it printed, output trimmed to the three version lines:
Expected output
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.8Step 1: sign with a signature timestamp (B-T). --use-pades produces a PAdES signature, and --timestamp-url asks the TSA for a token over the signature value.
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
Then validate, compact and pretty-printed (each later validate prints the same WARNING line; it is omitted below):
pyhanko sign validate --trust tsa/cacert.pem contrat-bt.pdf
pyhanko sign validate --pretty-print --trust tsa/cacert.pem contrat-bt.pdf
Expected output
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.The WARNING is a deprecation notice: --trust is being combined with the system trust list (Article 6 used --trust-replace). The pretty form, output trimmed from the signing-time section to the verdict:
Expected output
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” is Mariama’s claim. “Signature timestamp token” is the TSA’s genTime, and pyHanko draws the right conclusion: “The token is guaranteed to be newer than the signature”.
Step 2: add validation data, and a document timestamp. ltvfix adds the validation data it finds for Signature1 to the DSS, in place. Because the run also passed --timestamp-url, pyhanko-cli 0.5.0 then appends a document timestamp; the local TSA answered during this step.
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
Expected output
Signature1:a8c8be765ff351cecfaed6e28818d71239b162b7d6068c466921c4655a8f78a3:INTACT:TRUSTED,TIMESTAMP_TOKEN<INTACT:TRUSTED>,EXTENDED_WITH_LTA_UPDATES,ACCEPTABLE_MODIFICATIONSOutput trimmed to the pretty form’s last two sections:
Expected output
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.Step 3: renew with ltaupdate. ltaupdate appends a new document timestamp, in place. It validates only the outermost timestamp, so a chain broken further down is neither detected nor fixed (pyHanko, signing guide). This is one round of archival renewal.
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
Expected output
Signature1:a8c8be765ff351cecfaed6e28818d71239b162b7d6068c466921c4655a8f78a3:INTACT:TRUSTED,TIMESTAMP_TOKEN<INTACT:TRUSTED>,EXTENDED_WITH_LTA_UPDATES,ACCEPTABLE_MODIFICATIONSThe pretty form’s modification and verdict sections are the same as after step 2.
What validate does and does not tell you. It never prints a PAdES level, at any step. It shows ingredients. TIMESTAMP_TOKEN<INTACT:TRUSTED> means a signature timestamp with a trusted TSA chain: the B-T ingredient. After steps 2 and 3 the line reads EXTENDED_WITH_LTA_UPDATES,ACCEPTABLE_MODIFICATIONS: updates were appended and pyHanko accepts them as long-term maintenance. The level is inferred from the commands you ran and the file’s structure; pyHanko “currently does not offer validation of structural PAdES profile requirements” (pyHanko, validation guide).
The stricter check. pyHanko’s adesverify follows the AdES validation model more closely. We ran it on the final file:
pyhanko sign adesverify --pretty-print --trust tsa/cacert.pem contrat-lta.pdf
Output trimmed to the sections from signing time down, plus the log:
Expected output
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]It fails, and rightly: our toy CA put no CRL or OCSP address in any certificate, and pyHanko’s source fetches revocation data only where one is declared. So pyHanko embedded none, which B-LT requires (EN 319 142-1, §6.3, req. t). Without it adesverify cannot validate the TSA’s path, so it has no trusted signing time. A real deployment needs certificates that point to a working CRL or OCSP responder, as in Article 4.
What varies between runs: keys, fingerprints, genTimes, nonces and the log timestamps change every time. pip prints a warning about running as root, plus upgrade notices.
In practice, pyHanko’s --use-pades-lta option, which its help describes as producing a PAdES-B-LTA signature, does this in one command, given a --timestamp-url.
Where SEDEYA fits
SEDEYA signs PDFs in PAdES up to the B-LTA level, with RFC 3161 timestamps and the validation data embedded in the file. Signing keys are held in an HSM, over PKCS#11, or a KMS, and the hash-chained audit trail is sealed into the signed PDF. Ibrahima does not need pyHanko to check a SEDEYA document: he can upload it to the public verification page or scan the QR code printed on it. For the signer’s side of the same flow, see how an electronic signature actually works; later articles turn to identity and production architecture.
Risks and misconceptions
“The timestamp says when Mariama signed.”
genTime is when the TSA created the token (RFC 3161, §2.4.2). The signature existed no later than
genTime plus accuracy, and possibly long before. The time in /M is only a claim (EN 319 102-1,
§4.2.5.8).
“The nonce proves the time.”
The nonce proves freshness: that this response answers this request and is not a replay. The time
comes from the TSA’s signed genTime (RFC 3161, §2.4.1, §4). And openssl ts -verify -data does
not check the nonce at all; only -queryfile does.
“Once a file is B-LTA, it is valid forever.”
It stays verifiable only as long as its last document timestamp: its TSA certificate and its hash algorithm. Someone has to keep adding validation data and a new document timestamp (EN 319 142-1, §5.4.1; RFC 3161, §4, item 3). pyHanko calls this “active” maintenance of the document.
Summary
- A claimed signing time is only a claim. An RFC 3161 token is a TSA’s signed statement about a hash, made with a key reserved for timestamping.
- genTime is when the TSA made the token. The data existed no later than genTime plus accuracy; the token sets no lower bound.
- A signature timestamp covers one signature value; a document timestamp covers the whole file, DSS included.
- B-T adds trusted time, B-LT the certificates and revocation data, B-LTA a document timestamp over everything. Keeping B-LTA alive takes repeated renewal.
- In the hands-on,
validatenever printed a level, andadesverifyfailed for want of revocation data.
Check your understanding
Check your understanding
A token's genTime is 08:52:16 GMT with one second of accuracy. What can Ibrahima say about the contract's digest?
Show the answer
That it existed no later than 08:52:17 GMT. Nothing about how much earlier it existed, and nothing about when anyone signed.
Check your understanding
Why does a document timestamp protect the DSS when a signature timestamp does not?
Show the answer
A signature timestamp’s imprint is the hash of the signature value only. A document timestamp’s imprint is the hash of the whole ByteRange, which includes the DSS added before it.
Next step
Timestamps and validation data tell Ibrahima when a signature existed and that it can still be checked. They say nothing about who Mariama is. Article 8 turns to identity, assurance and the law. Meanwhile, talk to us and we will show you a SEDEYA-signed PDF with its timestamps and embedded validation data, and how anyone can verify it.
References
- 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))

