Skip to content
Validating Signer Trust and Revocation

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.

IsVerifiedIsTrustedWhat it means
falsefalseThe signed bytes changed, the signature is malformed, or no handler matched. See Errors.
truefalseThe bytes are intact. The signer did not pass the trust check, and TrustErrors says why.
truetrueThe 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.

ValueRevocation data usedNetwork
RevocationCheckNoneNone. RevocationStatus stays not checked.None
RevocationCheckOfflineOCSP responses and CRLs embedded in the document: the DSS, the signature’s VRI entry, and the signed PAdES revocation attribute.None
RevocationCheckOnlineThe 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:

RevocationStatusMeaningEffect on IsTrusted
RevocationStatusNotCheckedNo check was requested, or trust failed before the check.None
RevocationStatusGoodEvery chain certificate below the root was confirmed unrevoked at the validation time.None
RevocationStatusRevokedA 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
RevocationStatusUnknownNo 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:

FieldMeaning
IsTrustedThe signer chains to one of Roots at ValidatedAt, may sign documents, and was not revoked or, with RequireRevocationStatus, was confirmed unrevoked.
TrustErrorsEvery reason IsTrusted is false. Empty when it is true.
TrustWarningsProblems that did not change the verdict: an ignored signature timestamp, a failed revocation request, an unknown status that was not required.
TrustChainThe 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.
ValidatedAtThe 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.
RevocationStatusnot checked, unknown, good or revoked.
RevocationTimeWhen the revoked certificate was revoked. Set only for revoked.
IsCrlFound, IsOcspFoundA 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.
CertificatesThe 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.

ValidationValidatedAtIsTrustedRevocationStatusWhy
ValidateSignaturesfalsenot checkedno trust roots configured. IsVerified is true.
Roots, offline, default timenowfalsenot checkedThe signer certificate has expired. The timestamp is ignored because its TSA certificate has expired too.
Roots, offline, ValidationTime 2023-11-242023-11-24truegoodThe embedded OCSP response covers that day.
Roots, offline, ValidationTime 2023-12-10, RequireRevocationStatus2023-12-10falseunknownThe embedded OCSP response stopped being current on 2023-11-27, and nothing else covers the signer.
Roots, online, ValidationTime 2023-12-102023-12-10falserevokedSwissSign’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.

View the full source
Last updated on