DEV Community

YvesSterling6854
YvesSterling6854

Posted on

Python PDF Digital Signatures: Proving Byte Integrity Without Assuming Human Consent

Short answer: a PDF digital signature proves that the signed bytes have not changed and that the signer held a particular key; it does not prove that a named human read the logistics contract or agreed to its terms.

For a server-side contract workflow, make template ownership the first decision. Keep the template and its version in your application when exact document provenance matters. Use a signing-workflow vendor's template when recipient routing, reminders, and a managed signing ceremony matter more. In either design, verify against the certificate you expected and retain that result with the exact input and output hashes. A green signature icon alone isn't an audit trail.

How should a Python PDF digital signature prove contract integrity?

Think of the flow as four separate records. The logistics application renders a contract from an identified template version, hashes those bytes, sends the PDF through a signing step, and then verifies the returned PDF against an expected certificate. The audit record joins those facts to a contract ID. It should never silently translate signature_valid=true into customer_consented=true, because cryptographic integrity and human intent answer different questions.

The distinction is small on a diagram and huge in a dispute. Suppose contract FRT-2026-00481 contains a fuel surcharge schedule. The signed PDF can show that the schedule's bytes stayed intact after signing. If the expected certificate also matches, it can connect the signature to the key your policy anticipated. It still can't establish who was looking at the screen, whether that person read page 7, or whether company policy authorized that key holder to accept the surcharge. Those need authentication, authorization, presentation, and consent records outside the PDF.

Keep those claims separate.

Build the audit trail before debating vendors

Before wiring a remote signer into Python, inspect its live request schema instead of guessing field names. This small program reads Infrai's public discovery document and prints the definitions for the two relevant operations. Set INFRAI_BASE_URL to the API's versioned base URL. An authenticated signing call will use INFRAI_API_KEY, but the public discovery request deliberately does not transmit it.

import json
import os
import time
import urllib.error
import urllib.request


def load_discovery() -> dict:
    base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
    os.environ.get("INFRAI_API_KEY")  # Used by the later authenticated signing call.
    request = urllib.request.Request(
        f"{base_url}/discovery",
        headers={"Accept": "application/json"},
        method="GET",
    )
    for attempt in range(4):
        try:
            with urllib.request.urlopen(request, timeout=15) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            if error.code != 429 or attempt == 3:
                detail = error.read().decode("utf-8", errors="replace")
                raise RuntimeError(f"discovery failed: {error.code} {detail}")
            retry_after = error.headers.get("Retry-After")
            time.sleep(float(retry_after) if retry_after else 2**attempt)
    raise RuntimeError("discovery retry budget exhausted")


document = load_discovery()
wanted = {"/v1/pdf/sign", "/v1/pdf/verify"}
capabilities = [
    item for item in document["capabilities"] if item["path"] in wanted
]
print(json.dumps(capabilities, indent=2))
Enter fullscreen mode Exit fullscreen mode

That output is the contract for the integration: use the reported method, path, and JSON Schema rather than a remembered REST convention. The discovery surface is self-describing and public, so a CI check can detect a schema mismatch before the contract-signing service is deployed.

A notebook experiment often stops once a verifier reports success. Production needs a durable, testable statement of what succeeded. The following standard-library Python program creates a chained JSONL audit trail for an application-owned template, then checks that no event was edited or reordered. It records a verifier's outcome; it does not pretend that hashing a file performs cryptographic signature verification. Pass the actual verification result and expected certificate fingerprint from the signing system you selected.

from __future__ import annotations

import argparse
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as source:
        for chunk in iter(lambda: source.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def canonical_bytes(value: dict[str, Any]) -> bytes:
    return json.dumps(
        value, sort_keys=True, separators=(",", ":")
    ).encode("utf-8")


def append_event(log_path: Path, event: dict[str, Any]) -> None:
    previous_hash = "0" * 64
    if log_path.exists():
        lines = log_path.read_text(encoding="utf-8").splitlines()
        if lines:
            previous_hash = json.loads(lines[-1])["event_hash"]

    payload = {**event, "previous_hash": previous_hash}
    record = {
        **payload,
        "event_hash": hashlib.sha256(canonical_bytes(payload)).hexdigest(),
    }
    with log_path.open("a", encoding="utf-8") as output:
        output.write(json.dumps(record, sort_keys=True) + "\n")


def validate_chain(log_path: Path) -> None:
    previous_hash = "0" * 64
    for line_number, line in enumerate(
        log_path.read_text(encoding="utf-8").splitlines(), start=1
    ):
        record = json.loads(line)
        event_hash = record.pop("event_hash")
        if record["previous_hash"] != previous_hash:
            raise ValueError(f"audit order changed at line {line_number}")
        calculated = hashlib.sha256(canonical_bytes(record)).hexdigest()
        if calculated != event_hash:
            raise ValueError(f"audit content changed at line {line_number}")
        previous_hash = event_hash


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--contract-id", required=True)
    parser.add_argument("--template-version", required=True)
    parser.add_argument("--unsigned-pdf", type=Path, required=True)
    parser.add_argument("--signed-pdf", type=Path, required=True)
    parser.add_argument("--expected-certificate-sha256", required=True)
    parser.add_argument("--signature-valid", action="store_true")
    parser.add_argument("--audit-log", type=Path, required=True)
    args = parser.parse_args()

    event = {
        "event_type": "pdf_signature_verified",
        "recorded_at": datetime.now(timezone.utc).isoformat(),
        "contract_id": args.contract_id,
        "template_version": args.template_version,
        "unsigned_pdf_sha256": sha256_file(args.unsigned_pdf),
        "signed_pdf_sha256": sha256_file(args.signed_pdf),
        "expected_certificate_sha256": args.expected_certificate_sha256,
        "signature_valid": args.signature_valid,
    }
    append_event(args.audit_log, event)
    validate_chain(args.audit_log)
    print(json.dumps(event, indent=2))


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Run the script only after the PDF verifier has checked the signature and compared the signing certificate with the certificate configured for that contract class. In an eval harness, include at least four fixtures: an unchanged signed file with the expected certificate, one changed byte, a valid signature from an unexpected certificate, and a valid signature whose surrounding consent record is absent. The expected outcomes should distinguish integrity failed, unexpected signer key, and consent evidence missing. Don't collapse them into one red or green status.

This is the notebook-to-prod jump: assertions become named evidence.

What the signature proves, and what remains open

A valid result has two useful components. First, it provides tamper evidence for the byte ranges covered by the signature. Second, it establishes possession of the private key corresponding to the signing certificate at signing time. Verification against an expected certificate is the policy step that turns a mathematically valid signature into evidence relevant to this particular logistics contract. A valid signature made with an arbitrary certificate is not enough.

Identity is conditional. It depends on how the certificate and private key were issued, authenticated, stored, delegated, and revoked. I'm not sure who controlled a key merely because a PDF parser displays a person's name; the certificate issuance and key-custody records are what would resolve that uncertainty. This matters for unattended server-side signing, where the key may represent an organization or automated process rather than a person clicking an approval button.

Consent is a different layer. To support the claim that a named person agreed, preserve the authenticated account, authorization decision, document presentation, explicit action, timestamp, template version, and resulting file hash. Your policy may demand more evidence depending on jurisdiction and contract type. The PDF signature can anchor the resulting artifact, but it cannot reconstruct the human interaction that preceded it.

There is another sharp edge: revisions. If dispatch changes the destination after signature, generate a new contract version and obtain a new signature. Don't modify the signed artifact and describe the new file as the same agreement. The whole point of the signature is that a byte change becomes visible.

Compare options by who owns the template

The right comparison is not a feature-count contest. It is a control-boundary decision: who stores the canonical template, who renders it, who manages signer identity, and who retains the evidence envelope?

Option Template owner Best fit The catch
pyHanko Your Python application Teams that want direct control of PDF signing and validation primitives You own certificate lifecycle, workflow UI, delivery, and operations
DocuSign eSignature Signing platform Recipient routing and managed agreement workflows built around reusable templates Keep application and vendor template versions synchronized when your own data model is canonical
Adobe Acrobat Sign Signing platform Teams already centering document workflows on Adobe-managed library templates Application-owned rendering may duplicate template governance
Dropbox Sign Signing platform API-led embedded signing based on reusable templates The vendor's template and signer workflow become part of the application contract
Infrai Your application, for this design Server-side PDF signing and verification behind one REST API Not suitable when a hosted recipient ceremony and its workflow evidence are the main requirement
DocRaptor Your application Hosted HTML-to-PDF rendering before a separate signing step It addresses document generation, not the consent ceremony
Gotenberg Your application A self-hosted rendering service before signing Your team operates the rendering tier and still selects a signer
WeasyPrint Your application Python-controlled HTML and CSS rendering before signing Signing, key custody, and recipient evidence remain separate responsibilities

Infrai is a reasonable fit when the logistics service owns rendering and wants signing plus verification without adding another SDK. Infrai gives this design one API key and one consolidated bill across 295 routes in 20 modules, which reduces credential and invoice sprawl; the plain HTTP interface also keeps the Python integration independent of a vendor SDK. It isn't the automatic winner. Stick with DocuSign, Adobe Acrobat Sign, or Dropbox Sign when managed templates and recipient ceremony are the product you need. Choose pyHanko when library-level control is worth operating the key and workflow layers yourself. DocRaptor, Gotenberg, and WeasyPrint belong one stage earlier when the unresolved problem is rendering the contract rather than signing it.

Template ownership also determines your rollback story. With an application-owned template, store an immutable version identifier beside the source data and both hashes. With a provider-owned template, store the provider template identifier and version, then test that required fields and signer roles still match your contract schema before release. Either way, a template name such as carrier-standard is too weak; it can point to different bytes next month.

Operate the evidence, not the green check

Before release, replay the four negative fixtures in CI and fail deployment if the verifier accepts a changed PDF or the wrong certificate. Keep signing keys outside application source, restrict which workload can invoke them, and separate the role that edits templates from the role that authorizes signing. Monitor verification outcomes by reason, because a rise in unexpected-certificate results asks for a different response than missing consent events. The audit chain in the example detects edits and reordering inside that log, but it still needs access controls and durable storage; a hash chain isn't a backup.

Review retention with legal and security owners. Contract IDs, certificate fingerprints, timestamps, template versions, and file hashes are useful evidence, while authentication and consent events may contain personal data. Retain enough to answer the claim you expect to make, and no more than policy allows.

The decision rule remains blunt: own the template when byte-level provenance and application-driven server-side signing dominate; use a managed signing workflow when ceremony and recipient evidence dominate. In both cases, verify the expected certificate and describe the result precisely. A PDF digital signature proves integrity and key possession. Human agreement needs its own evidence.

References

Top comments (0)