Skip to main content

Digital Signatures

paperjam can inspect, verify, and apply digital signatures. Signature support requires the signatures feature, which is included in all pre-built PyPI wheels.

Inspecting signatures

doc.signatures returns a list of SignatureInfo objects:

import paperjam

doc = paperjam.open("signed-contract.pdf")

for sig in doc.signatures:
print(f"Signature: {sig.name}")
print(f" Signer: {sig.signer}")
print(f" Reason: {sig.reason}")
print(f" Location: {sig.location}")
print(f" Date: {sig.date}")
print(f" Covers whole document: {sig.covers_whole_document}")
if sig.certificate:
cert = sig.certificate
print(f" Certificate subject: {cert.subject}")
print(f" Certificate issuer: {cert.issuer}")
print(f" Serial number: {cert.serial_number}")
print(f" Valid from: {cert.not_before}")
print(f" Valid until: {cert.not_after}")
print(f" Self-signed: {cert.is_self_signed}")

SignatureInfo attributes:

AttributeTypeDescription
namestrSignature field name in the PDF
signerstr | NoneSigner's name from the signature dictionary
reasonstr | NoneStated reason for signing
locationstr | NoneLocation where signing took place
datestr | NoneSigning date/time
contact_infostr | NoneContact information
byte_rangetuple | None(offset_a, len_a, offset_b, len_b) of signed bytes
certificateCertificateInfo | NoneEmbedded certificate details
covers_whole_documentboolWhether the signature covers the entire document
has_timestampboolWhether an RFC 3161 timestamp token is present
timestamp_datestr | NoneTimestamp date from the TSA
has_ocspboolWhether OCSP responses are embedded
has_crlsboolWhether CRLs are embedded

CertificateInfo attributes:

AttributeTypeDescription
subjectstrCertificate subject DN
issuerstrCertificate issuer DN
serial_numberstrCertificate serial number (hex)
not_beforestrValidity start (ISO 8601)
not_afterstrValidity end (ISO 8601)
is_self_signedboolWhether subject equals issuer

Verifying signatures

verify_signatures() checks each signature's integrity and certificate validity:

validity_list = doc.verify_signatures()

for v in validity_list:
status = "OK" if (v.integrity_ok and v.certificate_valid) else "FAILED"
print(f"[{status}] {v.name}{v.message}")
if v.signer:
print(f" Signer: {v.signer}")

SignatureValidity attributes:

AttributeTypeDescription
namestrSignature field name
integrity_okboolWhether the signed bytes hash matches the PKCS#7 signature
certificate_validboolWhether the certificate date range is valid
messagestrHuman-readable status message
signerstr | NoneSigner name, if available
timestamp_validbool | NoneWhether the timestamp token is valid (None if no timestamp)
revocation_okbool | NoneWhether revocation info is valid (None if not present)
is_ltvboolWhether this signature has long-term validation info

What is checked

  • Integrity: the SHA-256 hash of the byte ranges specified in the signature is compared against the hash stored inside the PKCS#7 envelope. If the PDF was modified after signing, this check fails.
  • Certificate validity: the current date is checked against the certificate's not_before/not_after range. Full certificate chain validation against a trust store is not performed.

Signing a document

sign() appends a digital signature to the document and returns the signed PDF as bytes:

# Load your DER-encoded private key and certificate chain
with open("private_key.der", "rb") as f:
private_key = f.read()

with open("certificate.der", "rb") as f:
signing_cert = f.read()

signed_bytes = doc.sign(
private_key=private_key,
certificates=[signing_cert], # first cert = signing cert
reason="Approved by legal",
location="London, UK",
contact_info="legal@example.com",
field_name="Signature1", # signature field to fill
)

with open("signed.pdf", "wb") as f:
f.write(signed_bytes)

Generating a test key pair

For testing purposes you can generate a self-signed certificate using OpenSSL:

# Generate private key
openssl genpkey -algorithm RSA -out key.pem -pkeyopt rsa_keygen_bits:2048

# Generate self-signed certificate
openssl req -new -x509 -key key.pem -out cert.pem -days 365 \
-subj "/CN=Test Signer/O=Test Org"

# Convert to DER format for paperjam
openssl pkey -in key.pem -outform DER -out key.der
openssl x509 -in cert.pem -outform DER -out cert.der
with open("key.der", "rb") as f:
private_key = f.read()
with open("cert.der", "rb") as f:
cert = f.read()

signed_bytes = doc.sign(private_key=private_key, certificates=[cert])

Sign parameters

ParameterTypeDescription
private_keybytesDER-encoded PKCS#8 private key
certificateslist[bytes]DER-encoded X.509 certificates; first = signing cert
reasonstr | NoneReason for signing
locationstr | NoneGeographic location
contact_infostr | NoneContact information
field_namestrSignature field name (default: "Signature1")
tsa_urlstr | NoneTSA server URL for RFC 3161 timestamps
timestamp_tokenbytes | NonePre-fetched timestamp token (for custom HTTP)
ocsp_responseslist[bytes] | NoneDER-encoded OCSP responses to embed
crlslist[bytes] | NoneDER-encoded CRLs to embed

LTV (Long-Term Validation) signatures

To create a signature that can be validated after the signing certificate expires, add a timestamp from a TSA server:

signed_bytes = doc.sign(
private_key=private_key,
certificates=[signing_cert, intermediate_cert, root_cert],
reason="Approved",
tsa_url="http://timestamp.digicert.com",
)

The timestamp token is fetched automatically and embedded as an unsigned CMS attribute. You can also provide a pre-fetched token:

signed_bytes = doc.sign(
private_key=private_key,
certificates=[signing_cert],
timestamp_token=my_token_bytes,
)

After signing, doc.signatures will show has_timestamp=True and verify_signatures() will report is_ltv=True when both timestamp and revocation info are present.