FreeSign

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

1. Extract the embedded evidence JSON

The signed PDF carries the evidence JSON inside the signature's CMS, as an unsignedAttribute (OID 1.3.6.1.4.1.65834.1.2). 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. Pull it out with pdftk, with a small Python snippet, or directly with openssl against the raw bytes pointed to by the /ByteRange array. Adobe-convention ByteRange is two segments with the signature gap in between (and the surrounding </> in the gap, not in either segment).

# Quick path: list signatures + extract with pyHanko
pyhanko sign list signed.pdf
pyhanko sign extract --output sig.cms signed.pdf 0

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 the ByteRange digest of every signature to the OpenTimestamps calendar pool. The proof is embedded in the PDF's CMS as an unsignedAttribute (and is also downloadable from the receipt API). In the first 1-2 hours after signing it's a calendar attestation; once the calendar batch confirms, ots upgrade attaches the public block-header attestation.

# The .ots proof + the digest it commits to come from the receipt API:
EID=$(jq -r .envelope_id evidence.json)
RECEIPT=$(curl -s https://free-sign.com/api/receipts/$EID)
ANCHOR_HASH=$(echo "$RECEIPT" | jq -r .ots_anchor.anchored_hash)
curl -O "https://free-sign.com$(echo "$RECEIPT" | jq -r .ots_anchor.proof_download)"

# Verify against the .ots proof
ots verify --digest "$ANCHOR_HASH" *.ots

# If still pending, upgrade and re-verify (works any time later)
ots upgrade *.ots
ots verify --digest "$ANCHOR_HASH" *.ots

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, 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

  1. The PDF is the one FreeSign attested to (step 1).
  2. The CMS signature covers the entire PDF and the file hasn't been modified post-signing (step 4).
  3. The signer's typed name and OTP-verified email are bound into the cert (step 3) and chain to the FreeSign CA (step 5).
  4. A trusted TSA witnessed the signing time (step 6).
  5. The OpenTimestamps proof witnesses that the document hash existed by a public confirmation time (step 7).
  6. The browser key that agreed to consent is the same key that sealed the final PDF (step 8).

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 proof.

Want a signed PDF to test against?

Sign one yourself in under a minute. No account required.

Open free-sign.com →