How-to guide

PAdES B-B with pyHanko

pyHanko is the Python library most smaller integrations reach for. Its "interrupted signing" workflow is built for a remote key: prepare the document and the signed attributes, hand the hash to whoever holds the key, and finish with the value that comes back. The code below follows the pattern from pyHanko's own library guide (section "Interrupted signing"), adapted for this flow, with the class and method names as they appear there.

Treat it as a worked sketch, not as tested code. Unlike the DSS chapter, which is cut from a service we run, this one has not been executed here. Check it against the documentation of the pyHanko version you pin (0.25 or later) before you rely on it.

pip install pyHanko pyhanko-certvalidator

Prepare (step 4 of the flow)

import base64, hashlib
from asn1crypto import x509
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
from pyhanko.sign import signers, fields
from pyhanko_certvalidator.registry import SimpleCertificateStore

# Certificates from credentials/info, base64 DER, signer first.
chain = [x509.Certificate.load(base64.b64decode(c)) for c in cert_b64_list]
signing_cert = chain[0]
registry = SimpleCertificateStore.from_certs(chain)

# A placeholder signer: same certificate, a signature value of the right LENGTH.
# 256 bytes for RSA-2048; the real value replaces it at completion.
placeholder = signers.ExternalSigner(
    signing_cert=signing_cert,
    cert_registry=registry,
    signature_value=bytes(256),
)

meta = signers.PdfSignatureMetadata(
    field_name="Signature1",
    subfilter=fields.SigSeedSubFilter.PADES,   # ETSI.CAdES.detached
    md_algorithm="sha256",
)

with open("input.pdf", "rb") as f:
    w = IncrementalPdfFileWriter(f)
    pdf_signer = signers.PdfSigner(meta, signer=placeholder)

    # Writes the field and the reserved /Contents, digests the byte ranges.
    prep_digest, tbs_document, output = pdf_signer.digest_doc_for_signing(w)

    # The CMS signed attributes around the document digest. PAdES flavour:
    # no signingTime attribute, signing-certificate-v2 present.
    signed_attrs = placeholder.signed_attrs(
        prep_digest.document_digest, "sha256", use_pades=True
    )

    # The hash for Cleverbase: SHA-256 over the DER of the signed attributes,
    # encoded exactly as pyHanko will sign them.
    tbs = signed_attrs.dump()
    digest = hashlib.sha256(tbs).digest()
    hash_b64 = base64.b64encode(digest).decode()                       # signHash
    hash_b64url = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()  # oauth2/authorize

Persist output (the prepared PDF bytes, output.getvalue() if it is a BytesIO), prep_digest and signed_attrs (pickle them or keep them in the process). Everything between here and completion, the authorize leg, the signer's confirmation and signHash, must not touch the prepared document.

The placeholder length matters: the real signature must not be longer than the placeholder, or completion fails. Use the key length from credentials/info (key.len / 8 for RSA; for ECDSA over P-256 a DER SEQUENCE { r, s } is at most 72 bytes).

Complete (step 7 of the flow)

import asyncio
from pyhanko.sign.signers.pdf_signer import PdfTBSDocument

signature_value = base64.b64decode(signatures_from_cleverbase[0])

# A signer carrying the REAL value; the attributes are the ones we hashed.
real = signers.ExternalSigner(
    signing_cert=signing_cert,
    cert_registry=registry,
    signature_value=signature_value,
)
sig_cms = real.sign_prescribed_attributes("sha256", signed_attrs=signed_attrs)

asyncio.run(
    PdfTBSDocument.async_finish_signing(
        output, prepared_digest=prep_digest, signature_cms=sig_cms
    )
)
with open("signed.pdf", "wb") as f:
    f.write(output.getvalue())

sign_prescribed_attributes builds the CMS SignedData with your signed attributes, the certificate chain from the registry and the signature value; async_finish_signing writes it into the reserved /Contents. Nothing else in the file changes, so the signature covers exactly the bytes that were digested at prepare.

The algorithm, once

pyHanko takes the signature algorithm from the signing certificate's key type and the digest you pass (sha256), so an RSA certificate gives sha256WithRSAEncryption in the CMS. That is what you want, and it is also why the request to Cleverbase looks different: there you send signAlgo 1.2.840.113549.1.1.1 (rsaEncryption) with hashAlgo SHA-256, the same signature under its other name. Do not pass prefer_pss=True unless Cleverbase has told you the credential does PSS; the CMS would then describe bytes you did not get.

A visible signature

digest_doc_for_signing creates an invisible field by default. For a visible one, define the field first with fields.SigFieldSpec(sig_field_name="Signature1", box=(x1, y1, x2, y2), on_page=0) through fields.append_signature_field(w, spec) before creating the PdfSigner, and give PdfSignatureMetadata a stamp_style. Do this before prepare; the appearance stream is inside the signed byte range.

Going further

  • B-T: give PdfSigner a timestamper=timestamps.HTTPTimeStamper(url); the timestamp is added at completion and the signed hash does not change. Reserve more space with bytes_reserved on digest_doc_for_signing.
  • B-LT and B-LTA: PdfTimeStamper.update_archival_timestamp_chain and a validation context with OCSP/CRL fetching. "The PAdES levels" covers when that is worth doing.