Guide
Verify a signed PDF with openssl, pyHanko, and OpenTimestamps
Vendor-independent verification is the whole point of producing a standards-based PAdES-B-T signature. This guide walks through extracting and validating a FreeSign signature with off-the-shelf open-source tools, end to end. The same flow works for any PAdES-B-T PDF, regardless of the issuer.
What you'll need
openssl1.1+ (any current macOS/Linux/Windows).python3withpyHankoinstalled:pip install pyHanko.- The OpenTimestamps client:
pip install opentimestamps-client. jqfor poking at JSON. Node.js, to run the project'stools/validate-sealed-pdf.mjsextractor (or useopenssl cmsdirectly).- Just the signed PDF — the evidence JSON is embedded inside it (in each signature's CMS), and the CMS signature, certificate chain, timestamp and OpenTimestamps proof are inside it too.
1. Extract the embedded evidence JSON
The signed PDF carries the evidence JSON inside the signature's CMS, as a signed attribute (OID 1.3.6.1.4.1.65834.1.2) — so editing the record invalidates the signature you check in step 4. The project validator pulls it out so you can jq at it:
node tools/validate-sealed-pdf.mjs signed.pdf
# step [6] writes the record to /tmp/_validate.evidence.json
cp /tmp/_validate.evidence.json evidence.json
Then confirm the file is intact: the SHA-256 of the signed PDF is its final_pdf_sha256 (a file can't carry its own hash, so this field is not in the JSON — you compute it):
shasum -a 256 signed.pdf
# This value is final_pdf_sha256. Cross-check it against the receipt:
# curl https://free-sign.com/api/receipts/$(jq -r .envelope_id evidence.json)
If you have a separately-stored hash to compare against and the two strings don't match, the file has been modified after signing. Every subsequent check is going to fail anyway; bail out and ask the sender to resend.
2. Extract the CMS signature
PAdES embeds a CMS PKCS#7 signature inside the PDF as a hex string in the signature dictionary's /Contents entry — the gap between the two /ByteRange segments (the surrounding </> sit in that gap, not in either segment). pyHanko has no CLI command to extract it, so pull it out with a short Python snippet:
python3 - signed.pdf <<'PY'
import sys
data = open(sys.argv[1], "rb").read()
sig = data.index(b"/Type /Sig")
lt = data.index(b"<", sig)
gt = data.index(b">", lt)
der = bytes.fromhex(data[lt + 1:gt].decode("ascii"))
# /Contents is zero-padded — trim to the DER SEQUENCE's declared length.
n = der[1]
total = 2 + n if n < 0x80 else 2 + (n & 0x7f) + int.from_bytes(der[2:2 + (n & 0x7f)], "big")
open("sig.cms", "wb").write(der[:total])
print(f"wrote sig.cms ({total} bytes)")
PY
Or let the project validator do it: node tools/validate-sealed-pdf.mjs signed.pdf writes the same DER to /tmp/_validate.cms.der (step [2]) — copy it with cp /tmp/_validate.cms.der sig.cms.
3. Inspect the per-user X.509 leaf cert
FreeSign issues a fresh X.509 leaf certificate for every ceremony. The cert's Subject CN is the signer's typed legal name; subjectAltName.rfc822Name is the OTP-verified email. This is the cryptographic binding between the signature and the human.
openssl pkcs7 -in sig.cms -inform DER -print_certs > certs.pem
openssl x509 -in certs.pem -text -noout \
| grep -E "Subject:|rfc822Name|Issuer:|Not (Before|After):|Serial Number"
You should see:
Issuer: O = free-sign.com, CN = FreeSign CA
Subject: O = free-sign.com, CN = <signer's typed name>
X509v3 Subject Alternative Name:
email:<signer's verified email>
4. Verify the CMS signature itself
openssl cms -verify wants the content the signature was made over, which for PAdES is the ByteRange-defined regions of the original PDF. The cleanest way to reconstruct it is to ask pyHanko to validate — it understands PAdES coverage formulas.
pyhanko sign validate --pretty-print signed.pdf
Look for:
Integrity check: OK
Document modification: NONE
Coverage: ENTIRE_FILE
Signer cert valid: (status depends on trust anchors)
ENTIRE_FILE coverage is the bedrock proof that the signature covers the whole document — no append-after-signing attack possible without re-signing.
5. Trust-anchor the leaf cert against the FreeSign CA
The leaf cert is issued by the FreeSign CA, whose root cert is published. If your trust store doesn't include the FreeSign CA, openssl will reject the chain at verification. Add the CA cert as a trust anchor for this validation:
# Download the published FreeSign CA cert
curl -O https://free-sign.com/.well-known/free-sign-signing-ca.pem
# Verify the chain
openssl verify -CAfile free-sign-signing-ca.pem certs.pem
Important nuance. Adobe Reader will show yellow ⚠️ because FreeSign's CA is not on Adobe's commercial AATL list — that's an Adobe-marketplace property, not a cryptographic verdict. The FAQ Adobe explainer unpacks this fully. Adding the FreeSign CA to your own trust store (locally, for openssl) is the right move if you want a green chain in your verification pipeline.
6. Verify the RFC 3161 timestamp
FreeSign embeds a DigiCert-issued RFC 3161 timestamp as a SignerInfo unsignedAttr (PAdES-B-T). pyHanko validates this as part of the signature check above. To inspect it directly:
pyhanko sign validate --pretty-print signed.pdf 2>&1 \
| grep -A6 -i timestamp
You should see the signing time as recorded by the TSA, and the chain trust path through DigiCert's AATL-listed timestamping CA.
7. Verify the OpenTimestamps proof
FreeSign submits two digests per signature to the OpenTimestamps calendar pool, and embeds both proofs in the PDF's CMS as unsignedAttributes (they are also downloadable from the receipt API). The first commits to the ByteRange digest — OID 1.3.6.1.4.1.65834.1.1, kind: "byterange" in the receipt — and dates the document. The second commits to SHA-256 of the CMS SignedAttributes — OID …65834.1.5, kind: "signed_attrs" — and dates the attributes, which is what carries the post-quantum key commitment (see the post-quantum page). Verify both. In the first 1-2 hours after signing each is a calendar attestation; once the calendar batch confirms, ots upgrade attaches the public block-header attestation.
# The .ots proofs + the digests they commit to come from the receipt API.
# Loop over every anchor — a seal has two, and a multi-signer PDF has two
# per signature.
EID=$(jq -r .envelope_id evidence.json)
RECEIPT=$(curl -s https://free-sign.com/api/receipts/$EID)
echo "$RECEIPT" | jq -r '.ots_anchors[] | "\(.kind) \(.anchored_hash) \(.proof_download)"' \
| while read -r KIND HASH URL; do
curl -s -o "$KIND.ots" "https://free-sign.com$URL"
# If still calendar-pending, upgrade first (works any time later)
ots upgrade "$KIND.ots" 2>/dev/null
echo "== $KIND"
ots verify --digest "$HASH" "$KIND.ots"
done
A confirmed proof prints the block height and block hash for the underlying public chain attestation. This gives a timestamp outside FreeSign's control: nobody can backdate the proof after that public confirmation exists.
8. Verify the final-payload signature (Free-Sign-specific)
FreeSign produces a second signature in addition to the CMS in the PDF: an ECDSA signature over a canonical-JSON payload that includes the final PDF hash + the consent payload hash + a UTC timestamp. Both signatures share the same browser-resident public key, which is in the embedded evidence JSON as public_key_jwk. The final_payload and final_signature_base64url describe the final PDF bytes, so they cannot live inside those bytes — they come from the receipt API. This closes the gap between “signer agreed to consent” and “this specific PDF was the artifact.”
# Pubkey is in the embedded evidence JSON:
jq '.public_key_jwk' evidence.json
# final_payload + final_signature_base64url come from the receipt:
curl -s https://free-sign.com/api/receipts/$(jq -r .envelope_id evidence.json) \
| jq '{final_payload: (.envelope.final_payload_json | fromjson), final_signature: .envelope.final_signature_base64url}'
# Verify the final_payload signature with any ECDSA P-256 library
# (node:crypto / python-cryptography / openssl pkeyutl)
What the verifier proves, end to end
- The PDF is the one FreeSign attested to (step 1).
- The CMS signature covers the entire PDF and the file hasn't been modified post-signing (step 4).
- The signer's typed name and OTP-verified email are bound into the cert (step 3) and chain to the FreeSign CA (step 5).
- A trusted TSA witnessed the signing time (step 6).
- The OpenTimestamps proofs witness that the document hash — and the signed attributes that bind the signer's certificate and any post-quantum key commitment — existed by a public confirmation time (step 7).
- The browser key that agreed to consent is the same key that sealed the final PDF (step 8).
One property is deliberately out of scope here: the ML-DSA post-quantum co-signature that every FreeSign PDF also carries (a signed key commitment at OID 1.3.6.1.4.1.65834.1.3 plus the signature at ….1.4). Neither openssl nor pyHanko verifies ML-DSA today — which is exactly why it rides as an extra attribute and why every command above works unchanged. You can see the attributes with openssl asn1parse -inform DER -in sig.cms | grep -B1 -A3 65834, and check them cryptographically at /verify or with node tools/validate-sealed-pdf.mjs signed.pdf (step [7]). Full write-up: the post-quantum page.
FreeSign is not in the verification path. If we disappear tomorrow, every check above keeps working — the trust anchors live in the file, in DigiCert's TSA chain, and in the OpenTimestamps proofs.
Want a signed PDF to test against?
Sign one yourself in under a minute. No account required.
Open free-sign.com →