How verification works

What /verify actually does when you drop a PDF — hash lookup, PAdES validation, eIDAS QES detection, the lot.

/verify is the page anyone — buyer, auditor, signer — can use to confirm a PDF is authentic. This doc explains exactly what runs under the hood so the verdict copy isn't a black box.

What the page actually checks

When you drop a PDF on /verify, the file is hashed and sent through three phases. Each phase can short-circuit with a verdict; the rest only run on a miss.

Phase 1 — Hash lookup (letssign.now)

We SHA-256 the bytes you uploaded and look the hash up against every signed document we've ever issued. A single byte different and the hash misses — that's the whole point.

  • Hit ⇒ verdict verified (emerald). We show the document name, the workspace it came from, every signer's email + signing time, and the trusted-timestamp authority that recorded the seal.
  • Miss ⇒ Phase 2.

Phase 2 — letssign.now certificate check

The PDF still might be one we issued — if someone modified it after signing, the hash is dead but our certificate is still embedded. We parse the PAdES Contents block, pull the signer cert from the CMS SignedData bag, and look its serial up against signed_documents.

  • Our cert serial, hash differs ⇒ verdict tampered (rose). The certificate is one we sealed with, but the bytes have changed since. Do not trust the signature.
  • Not our cert ⇒ Phase 3.

Phase 3 — Real PAdES validation (Python sidecar)

The PDF is signed but not by us. The question becomes "is this a valid PAdES signature from someone trusted?" — a question pyHanko

  • a freshly-cached EU trust pool answer.

For every embedded signature the sidecar:

  1. Verifies the signature math over the document's /ByteRange.
  2. Builds a certificate chain from the signer cert to a trust anchor, using Mozilla NSS (commercial CAs) plus the eIDAS LOTL member-state trust lists (currently DE + LU, scalable via TRUST_LIST_COUNTRIES), plus Switzerland's ZertES federal TL, plus the AATL curated snapshot.
  3. Validates each cert in the chain at the signing time, not "now" — otherwise any intermediate that's since expired fails a valid historical signature.
  4. Runs eIDAS QualificationAssessor to classify the signature as QES (Qualified — equivalent to a handwritten signature in every EU member state) or AdES (Advanced — trusted but not formally qualified).
  5. Detects the PAdES baseline profile: B-B (basic), B-T (timestamped), B-LT (long-term validation data embedded), B-LTA (archival timestamps stacked).

Possible verdicts at the end of Phase 3:

  • verified_external_qualified (emerald + EU pill). eIDAS QES — legally equivalent to a handwritten signature in every EU member state.
  • verified_external_advanced (sky blue). Trusted CA but not formally qualified under eIDAS.
  • untrusted_signer (amber). Signature is cryptographically intact but the certificate doesn't chain to any trust list we cover. Could be self-signed, internal CA, or a region we don't yet support.
  • tampered (rose). Sidecar found a real CMS but the math doesn't validate.
  • not_signed (slate). PDF has no embedded signature dict.

Optional second opinion — EU eIDValidator

When the local verdict is INDETERMINATE or FAILED, the sidecar optionally calls the EU Commission's free eIDValidator (DSS-as-a-service) for a second opinion. EU DSS has every member-state TL freshly cached and the authoritative ETSI verdict machinery, so it can often classify cases we can't — especially Italian InfoCert, Estonian Mobile-ID, and other QES variants with vendor extensions.

Disabled by default (each call adds 2-5s and contacts an EU endpoint). Enable per-environment with EIDVALIDATOR_ENABLED=1. A circuit breaker pauses calls after 3 consecutive failures so an EU outage doesn't pin the verify endpoint.

What we deliberately don't do

  • No CRL/OCSP revocation fetches at verification time. The embedded validation data inside the PDF (B-LT/B-LTA) is what we rely on. Live revocation lookups would add 1-5s and depend on the CA being reachable — both bad properties for a real-time check.
  • No XAdES, no CAdES, no ASiC containers. Only PAdES (PDF signatures). If you have an XML signature to validate, the EU eIDValidator handles it directly.
  • No "we accept any CA you trust" mode. Trust scope is what's in eIDAS LOTL + Swiss ZertES + AATL + Mozilla NSS. Adding more requires a code change (intentional — drift in the trust scope leaks legal weight).

Trust scope, in detail

SourceCoverageRefresh
eIDAS LOTL → member-state TLsEU QTSPs (currently DE + LU active; scale via env var)LOTL refreshes ~every 6 hours; pre-warmed every 5 min by Vercel Cron
Swiss ZertESSwisscom, SwissSign, QuoVadis CH, DigiCert Switzerland, Swiss Government PKISame warm-up cadence
Adobe Approved Trust List (AATL)Adobe-trusted commercial CAs not in Mozilla NSSManual quarterly refresh (Adobe doesn't publish a clean feed)
Mozilla NSS / certifiCommercial CAs that also issue TLS (DigiCert, Entrust, GlobalSign, IdenTrust, QuoVadis…)Bundled with certifi; refreshed on dep bump

Auditability

Every verification — including failures — emits a structured verify_event log line on the server side. Ops can grep Vercel's log stream to build verdict histograms over time:

# Verdict counts in the last 7 days
vercel logs --since 7d \
  | jq -r 'select(.type=="verify_event") | .reason' \
  | sort | uniq -c

Logs are PII-free: SHA truncated to 12 chars, no emails, no PDF bytes. Fields: reason, ok, sha256_prefix, size_kb, latency_ms, signer_cn (when known), qualified (boolean), profile, source (which phase decided).