Validating Signer Trust and Revocation
IsVerified is not an authenticity verdict. It means the signature matches the
document bytes under the public key in the certificate that came with the signature.
That certificate is part of the file, so whoever made the file chose it. A
self-signed certificate with “Jane Doe, ACME Corp” in the subject verifies just as
well as one issued by a real CA, and ValidateSignatures reports both as
IsVerified: true.
Whether the signer is who the certificate says is a separate question, and only your
application can answer it, because only your application knows which certificate
authorities it accepts. PdfReader.ValidateSignaturesWithOpts takes that answer as
a set of trust roots and checks each signature against it:
opts := &model.SignatureValidationOpts{
Roots: roots,
Revocation: model.RevocationCheckOffline,
RequireRevocationStatus: true,
}
res, err := reader.ValidateSignaturesWithOpts(handlers, opts)
if err != nil {
return err
}
for _, item := range res {
switch {
case !item.IsVerified:
fmt.Println("changed after signing, broken, or no handler supports it:", item.Errors)
case !item.IsTrusted:
fmt.Println("intact, but the signer is not trusted:", item.TrustErrors)
default:
fmt.Println("signed by", item.TrustChain[0].Subject.CommonName)
}
}The handlers are the same validation-only handlers ValidateSignatures uses. They
still decide integrity. Trust is evaluated afterwards, once per signature, and only
for signatures whose integrity passed.
Integrity and trust
IsVerified and IsTrusted answer different questions, and a result can have any
combination except trusted without verified.
IsVerified | IsTrusted | What it means |
|---|---|---|
| false | false | The signed bytes changed, the signature is malformed, or no handler matched. See Errors. |
| true | false | The bytes are intact. The signer did not pass the trust check, and TrustErrors says why. |
| true | true | The bytes are intact and the signer certificate chains to one of your roots at the validation time, may sign documents, and, if you asked, was not revoked. |
IsTrusted is only ever set by ValidateSignaturesWithOpts with Roots set. Plain
ValidateSignatures is exactly ValidateSignaturesWithOpts(handlers, nil), and
every result it returns has IsTrusted false and no trust roots configured in
TrustErrors.
A trusted signer certificate also has to be one that may sign documents. Its key usage, when present, has to include digital signature or content commitment, and its extended key usage, when present, has to allow document signing: any usage, email protection, code signing, or one of the document signing usages from RFC 9336, Microsoft or Adobe. A TLS server certificate that chains to your roots is still refused. Intermediate CAs that carry an extended key usage are held to the same rule. Document timestamps are the exception: their signer must allow time stamping instead, and so must every CA in the chain that carries an extended key usage.
Trust roots
The roots come from your application, never from the document. A root inside the PDF
would let the document vouch for itself, so certificates in the file serve only as
intermediates for building the chain: the certificates the handler verified the
signature with (the PKCS#7 certificate bag, or /Cert for adbe.x509.rsa_sha1), the
document security store and its VRI entries, and anything you pass in
Intermediates.
Load roots from a PEM bundle of the authorities you accept:
pem, err := os.ReadFile("trusted-roots.pem")
if err != nil {
return err
}
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(pem) {
return errors.New("no certificates in trusted-roots.pem")
}The pool decides who counts as a trusted signer, so it should be as narrow as the use case allows. Three common shapes:
- A qualified trust list or a vendor bundle, such as the CAs on the EU Trusted List or Adobe’s AATL, exported to PEM. Use this when documents come from anyone and you need to know they were signed by a certified provider.
- Your company CA, when the documents are signed inside your organization.
- A single partner’s certificate. A self-signed certificate added to the pool is a root, so pinning it trusts exactly that signer and nobody else.
Do not use x509.SystemCertPool(). The system pool is for TLS. Every CA in it can
issue certificates to anyone who controls a domain, so it would turn the holder of
any website certificate into a trusted signer as far as the chain is concerned.
When the document does not carry the full chain, add the missing intermediates:
opts := &model.SignatureValidationOpts{
Roots: roots,
Intermediates: []*x509.Certificate{issuingCA},
}A PKCS#7 package with more than one signer has no single certificate that verified
it, so it can never be trusted. Integrity is unaffected and IsVerified keeps its
value, and TrustErrors says
signer certificate not identified by the signature handler.
Validation time
The chain, the validity periods and the revocation data are evaluated at one
instant, which the result reports as ValidatedAt. By default that instant is:
- the time asserted by a signature timestamp token, when the signature carries one
and the token’s TSA chains to your roots and is valid now (and, when a revocation
check is requested, is not revoked now; with
RequireRevocationStatus, its status must also be known); - otherwise the current time.
A signature timestamp that does not qualify is skipped with a warning in
TrustWarnings that starts with signature timestamp ignored:, and validation
falls back to the current time. A document timestamp (ETSI.RFC3161) is a
signature in its own right and is judged at the current time unless you set
ValidationTime, never at the time its own token asserts.
The signing date in the signature dictionary (/M, which String() prints as
Date) is never used by default. The signer writes it, so it proves nothing about
when the signature was made.
This matters for old documents. A certificate that expired last year fails the chain check today, even though it was valid on the day it signed. A trusted timestamp is the proper fix: it is third-party evidence of the signing time, and with it the signature keeps validating after the signer’s certificate expires. Signing at PAdES B-T or later adds one.
For documents signed without one, set ValidationTime yourself:
opts := &model.SignatureValidationOpts{
Roots: roots,
ValidationTime: receivedAt,
Revocation: model.RevocationCheckOffline,
}ValidationTime overrides everything else, timestamps included. It fits an archive
that recorded when each document arrived, in a log or a receipt it trusts. Passing
the signer’s own /M date here is possible, but then the date is an assumption you
accepted, not something UniPDF verified. Because it overrides a trusted timestamp,
passing /M for a B-T or later signature would replace the time the TSA vouched
for with the one the signer chose, and a back-dated /M before a revocation would
come out trusted. The at-time example therefore uses /M only for signatures that
have no trusted timestamp. Whatever instant you pass, revocation data is checked against it: a
certificate revoked after that instant still passes, which is what an archive wants
and why the instant needs to be one you trust.
Revocation
Revocation selects where revocation data comes from. The default is no check.
| Value | Revocation data used | Network |
|---|---|---|
RevocationCheckNone | None. RevocationStatus stays not checked. | None |
RevocationCheckOffline | OCSP responses and CRLs embedded in the document: the DSS, the signature’s VRI entry, and the signed PAdES revocation attribute. | None |
RevocationCheckOnline | The embedded data first, then OCSP and CRL requests to the URLs in the certificates for every chain certificate the embedded data does not settle. | HTTP and HTTPS only |
Every certificate in the chain below the root is checked. Online, UniPDF asks the OCSP responders a certificate names first and downloads its CRLs only when OCSP did not settle the status. URLs with other schemes, such as LDAP, are skipped. Embedded evidence is how LTV documents carry their revocation data, and offline mode is the way to read it.
The outcome is one of four statuses:
RevocationStatus | Meaning | Effect on IsTrusted |
|---|---|---|
RevocationStatusNotChecked | No check was requested, or trust failed before the check. | None |
RevocationStatusGood | Every chain certificate below the root was confirmed unrevoked at the validation time. | None |
RevocationStatusRevoked | A chain certificate was revoked at or before the validation time, or was on hold then and no later CRL or OCSP response lifted the hold. RevocationTime says when the revocation or hold started. | Cleared, with the certificate and time in TrustErrors |
RevocationStatusUnknown | No usable revocation data for at least one chain certificate. | Cleared only with RequireRevocationStatus |
An unknown status does not fail validation by default. A responder that is down, a
proxy that blocks the request, or a document that simply has no embedded data would
otherwise turn every signature untrusted, and whether that is acceptable is your
decision, not the library’s. The reason is recorded in TrustWarnings and
IsTrusted stays as the chain check left it. Set RequireRevocationStatus when the
result drives a decision and a missing answer should count as a no; the same
messages then go to TrustErrors. A signer certificate that is itself one of your
roots has no issuer to ask, so its status is always unknown.
Online checks go through sigutil.CRLClient and sigutil.OCSPClient. Left nil,
each uses a default client with a 5 second timeout per request. Supply your own to
set timeouts, a proxy, TLS settings or a caching transport:
transport := http.DefaultTransport.(*http.Transport).Clone()
httpClient := &http.Client{
Timeout: 10 * time.Second,
Transport: transport,
}
crlClient := sigutil.NewCRLClient()
crlClient.HTTPClient = httpClient
ocspClient := sigutil.NewOCSPClient()
ocspClient.HTTPClient = httpClient
opts := &model.SignatureValidationOpts{
Roots: roots,
Revocation: model.RevocationCheckOnline,
CRLClient: crlClient,
OCSPClient: ocspClient,
RequireRevocationStatus: true,
}Within one ValidateSignaturesWithOpts call a CRL URL is downloaded once and an OCSP
responder is asked once per certificate, however many signatures share them, and
failed requests are remembered too. Across calls nothing is cached, so a service validating many
documents from the same issuer benefits from a caching transport on the shared
client.
An online check tells the certificate authority that one of its certificates is being checked. It does not send any document content. See Does UniPDF send my documents anywhere? for what the requests contain.
Reading the result
ValidateSignaturesWithOpts fills these fields on top of the integrity fields
described in Validating Digital Signature:
| Field | Meaning |
|---|---|
IsTrusted | The signer chains to one of Roots at ValidatedAt, may sign documents, and was not revoked or, with RequireRevocationStatus, was confirmed unrevoked. |
TrustErrors | Every reason IsTrusted is false. Empty when it is true. |
TrustWarnings | Problems that did not change the verdict: an ignored signature timestamp, a failed revocation request, an unknown status that was not required. |
TrustChain | The verified chain, signer first and root last. Nil when no chain was found. Kept when revocation later cleared IsTrusted, so the revoked chain can be inspected. |
ValidatedAt | The instant the chain was evaluated at. Zero when evaluation stopped before the chain check: no roots, failed integrity, no signer certificate, or a key usage that rules the signer out. |
RevocationStatus | not checked, unknown, good or revoked. |
RevocationTime | When the revoked certificate was revoked. Set only for revoked. |
IsCrlFound, IsOcspFound | A CRL or an OCSP response covering the signer certificate at the validation time was found and used. When no revocation check ran, they keep the handler’s value, which for the PAdES handler means the signature carries a revocation attribute at all. |
Certificates | The chain embedded in the signature, signer first. This is what the file claims, not what was verified. |
String() prints the trust lines between the integrity verdict and the certificate
details. Errors are indented under the Trusted line, each warning gets its own
Trust warning: line, and Validated at, Revocation status and Revocation time
appear only when set. Here is the trust part of the output for the sample below,
validated today with offline revocation checks:
Signature validation: Is valid
Trusted: Untrusted certificate
x509: certificate has expired or is not yet valid: current time 2026-10-21T11:28:00+07:00 is after 2025-08-25T09:57:55Z
Trust warning: signature timestamp ignored: x509: certificate has expired or is not yet valid: current time 2026-10-21T11:28:00+07:00 is after 2025-12-01T10:56:43Z
Validated at: 2026-10-21T04:28:00Z
Certificate: SERIALNUMBER=592e9674-8b2a-48bb-a9c3-1e2f8e91297e,CN=Graf Christoph Markus,C=CH,2.5.4.42=Christoph Markus,2.5.4.4=Graf
Certificate issuer: CN=SwissSign Qualified Electronic Signature ICA 2021 - 2,O=SwissSign AG,C=CH,2.5.4.97=NTRCH-CHE-109.357.012
Certificate validity: 2023-08-25T09:57:55Z - 2025-08-25T09:57:55Z
Certificate chain: 3 certificate(s)The full output ends with Revocation data: CRL not found and
Revocation data: OCSP not found. Those lines print IsCrlFound and IsOcspFound.
When a revocation check ran, they say whether a CRL or an OCSP response was used for
the signer. When none ran, as here, where the chain failed first, they keep the
handler’s value. For a PKCS#7 handler that is always false, and for the PAdES
handler it is whether the signature carries a revocation attribute. Either way they
do not say whether the file contains revocation data; this document carries both in
its DSS.
One document, five validations
A real signature shows how much the validation time decides. The sample is a PDF
signed with a qualified SwissSign certificate on 2023-11-24. SwissSign revoked that
certificate on 2023-12-04, and it expired on 2025-08-25. The signature carries a
timestamp from a SwissSign TSA whose certificate expired on 2025-12-01, and the DSS
holds an OCSP response from signing day that says the certificate was good. The file
comes from UniPDF’s test data and is not distributed with the examples. The root
passed in Roots is SwissSign Signature Services Root 2020 - 2.
| Validation | ValidatedAt | IsTrusted | RevocationStatus | Why |
|---|---|---|---|---|
ValidateSignatures | false | not checked | no trust roots configured. IsVerified is true. | |
| Roots, offline, default time | now | false | not checked | The signer certificate has expired. The timestamp is ignored because its TSA certificate has expired too. |
Roots, offline, ValidationTime 2023-11-24 | 2023-11-24 | true | good | The embedded OCSP response covers that day. |
Roots, offline, ValidationTime 2023-12-10, RequireRevocationStatus | 2023-12-10 | false | unknown | The embedded OCSP response stopped being current on 2023-11-27, and nothing else covers the signer. |
Roots, online, ValidationTime 2023-12-10 | 2023-12-10 | false | revoked | SwissSign’s OCSP responder reports the revocation, and RevocationTime is 2023-12-04T20:54:54Z. |
Every row is the same signature, and every row is right for the question it asked. The second row is the default and the safe answer when you know nothing about when the document arrived. The third is right only if you have your own evidence that the document existed on 2023-11-24, since without the expired timestamp nothing in the file proves it.
Upgrading existing code
Code that calls ValidateSignatures keeps compiling and keeps its integrity results.
One thing changes in what it sees: every result now carries
no trust roots configured in TrustErrors, and String() prints it under
Trusted: Untrusted certificate. Code that compares the
printed output, or treats a non-empty error list as a failure, will notice. The
signature did not get worse; the result now says why it is not trusted.
If your code treated IsVerified as proof of who signed, switch it to
ValidateSignaturesWithOpts with roots and check IsTrusted instead.
Run the examples
Three examples cover the API. Each takes the PDF to check and a PEM bundle of the roots you trust.
pdf_sign_validate_trust.go validates against the roots with offline revocation
checks, or online with a third argument of online, and requires a known
revocation status. It prints whether each signature failed integrity, is intact but
untrusted with the reasons, or is trusted, with the signer and root names.
git clone https://github.com/unidoc/unipdf-examples.git
cd unipdf-examples/signatures/validation
go run pdf_sign_validate_trust.go <INPUT_PDF_PATH> <ROOTS_PEM_PATH> [offline|online]If this is your first time using UniPDF, follow the getting started guide to create an API key and set up your development environment.
View the full source
pdf_sign_validate_online.go runs online checks through CRL and OCSP clients that
share one HTTP client, the one in the snippet above: a clone of
http.DefaultTransport with the proxy from the environment and a 10 second timeout.
It requires a known revocation status, so a request that fails leaves the status
unknown and the signature untrusted. For each signature it prints integrity, trust,
the revocation status with signer CRL found and signer OCSP found from
IsCrlFound and IsOcspFound, the revocation time when revoked, and the trust
errors and warnings.
git clone https://github.com/unidoc/unipdf-examples.git
cd unipdf-examples/signatures/validation
go run pdf_sign_validate_online.go <INPUT_PDF_PATH> <ROOTS_PEM_PATH>If this is your first time using UniPDF, follow the getting started guide to create an API key and set up your development environment.
View the full source
pdf_sign_validate_at_time.go validates at a chosen instant, with offline revocation
checks and a known revocation status required. With no third argument it uses the
default, now or a trusted timestamp. An RFC 3339 time is used as given. With
signing-time it runs the default pass first: a signature evaluated at a trusted
timestamp keeps that result, and only signatures without one are validated again at
their /M date. Document timestamps and signatures without /M keep the default
evaluation. For each signature it prints the signer’s /M date (Signer's date: none when there
is none), ValidatedAt (Validated at: not evaluated when it is zero), integrity,
trust, revocation status and the trust errors and warnings.
git clone https://github.com/unidoc/unipdf-examples.git
cd unipdf-examples/signatures/validation
go run pdf_sign_validate_at_time.go <INPUT_PDF_PATH> <ROOTS_PEM_PATH> [RFC3339_TIME|signing-time]If this is your first time using UniPDF, follow the getting started guide to create an API key and set up your development environment.