PDF Signing (pyHanko, open-source)
What this does
Applies a real cryptographic digital signature to a PDF: it hashes the whole
document, signs the hash with a private key, and embeds the signature + certificate.
Any later edit breaks the signature — that is what makes it tamper-evident and
non-repudiable. Uses pyHanko (the strongest open-source PDF signer: PAdES B-B/B-T/
B-LT/B-LTA, CAdES, document timestamps, LTV) with the cryptography library. No
proprietary tools, no cloud service, no Adobe.
It is not the same as pasting a picture of a signature — that is decorative and
proves nothing. This produces a signature a verifier (Adobe Acrobat, pdfsig, DSS)
can check.
Quick start
cd "$(dirname skills/pdf-signing/SKILL.md)/scripts" 2>/dev/null || cd skills/pdf-signing/scripts
./sign.sh /path/to/invoice.pdf # → invoice-signed.pdf beside it
./sign.sh invoice.pdf out.pdf --tsa http://timestamp.digicert.com # + trusted timestamp
./sign.sh doc.pdf --invisible # crypto only, no visible panel
sign.sh will, on first use: resolve/provision the interpreter (setup.sh), then
generate a self-signed identity if none exists, then sign. Verify with:
pdfsig invoice-signed.pdf # expect "Signature is Valid" + "Total document signed"
Where the keys live (important)
The signing private key never goes in the container image or a git repo. It lives
in $PDF_SIGNING_KEYS_DIR (default ~/.local/share/pdf-signing/):
| File | Secret? | Purpose |
|---|---|---|
dreamlab-signing.p12 |
YES — mode 600 | key + cert bundle used to sign |
.p12-pass |
YES — mode 600 | passphrase protecting the .p12 |
dreamlab-signing-cert.pem |
no — shareable | public cert; send to a recipient so they can trust your signature |
Regenerate/rotate the identity (5-year validity, RSA-3072) with:
./scripts/make_signing_cert.py --force # defaults: DreamLab AI Consulting Ltd
./scripts/make_signing_cert.py --cn "Acme Ltd" --org "Acme Ltd" --email billing@acme.com
Self-signed vs trusted — what a recipient sees
Out of the box the cert is self-signed, so pdfsig / Acrobat report
"signature is valid, but the signer's identity is unknown" (issuer unknown). The
maths is sound — integrity and non-repudiation hold — it just isn't chained to a CA the
reader already trusts. Two ways to get a clean "identity verified":
- One-off trust — send the recipient
dreamlab-signing-cert.pem; they add it as a trusted identity once. Fine for a known counterparty who has asked for signed docs. - CA-issued / eIDAS — for a permanent green tick everywhere, obtain a
document-signing certificate from a public CA (or a Qualified cert / QES from an
eIDAS trust-service provider) and point
sign.shat that.p12. Seereferences/trust-and-upgrade.md.
Most B2B "we need a signed invoice" requirements are satisfied by option 1 (or even by the self-signed signature alone — it is a bona-fide digital signature).
Visible signature panel
By default a compact panel is drawn (signer, reason, UTC date, location). Default placement targets the blank lower area of the last page (works well for invoices whose final page carries only remittance details). Control it:
--page N # 1-based page for the panel (default: last)
--pos x1,y1,x2,y2 # box in PDF points from bottom-left
--image sig.png # overlay a scanned autograph as the panel background
--reason "…" --location "…" --name "…"
Durable install (bake into the image)
setup.sh prefers an ambient python3 that already imports pyHanko and only builds a
venv as a fallback. pyhanko (plus pillow, fonttools; cryptography/asn1crypto/
oscrypto come transitively) is already declared in flake.nix's
pythonRuntimeEnv (the block near pikepdf, ~line 876) — there is nothing to add.
poppler-utils (for pdfsig verification) and qpdf are already in the image too. If
the running container's ambient python3 -c "import pyhanko" still fails, the image
predates that flake.nix change: the fix is a rebuild of the image, not another
flake.nix edit — until then setup.sh's venv fallback carries you through.
Verifying
pdfsig signed.pdf # poppler — validity + signed-range + signer DN
./scripts/sign_pdf.py --help # all options
pdfsig prints NSS_Init failed: … bad database first — harmless (no NSS cert DB);
the Signature Validation: Signature is Valid. line is the one that matters.
Files
scripts/sign.sh— entry point (setup → identity → sign)scripts/setup.sh— resolve/provision interpreter, prints its pathscripts/sign_pdf.py— the signer (pyHanko API; honours$PDF_SIGNING_KEYS_DIR)scripts/make_signing_cert.py— generate a self-signed signing identityreferences/trust-and-upgrade.md— trust import, CA/eIDAS upgrade, PAdES levels, LTV