pdf-signature
= aloli-crystal/pdf-signature :toc: left :toc-title: Contents
Crystal library for digitally signing PDFs in PAdES format (PDF Advanced Electronic Signatures, ETSI EN 319 142).
Companion to aloli-crystal/pdf, which already covers encryption (RC4 / AES-128 / AES-256) and PDF parsing/writing. This shard adds digital signing, the only cryptographic protection that detects any modification of a PDF.
[NOTE]
This shard is in its infancy (v0.1). See doc/RATIONALE.adoc for the in-depth analysis (PAdES, TSA, trust infrastructure, ALOLI roadmap, architectural decisions). The substantive document is in French — translation to come.
== Why a dedicated shard?
Three distinct protections coexist on a PDF:
[cols="1,1,1,1",options="header"] |=== | Protection | Prevents reading | Detects modification | Cryptographic | User password | ✅ | ❌ | ✅ | Permissions (/P) | ❌ | ⚠️ honor-system | ❌ | Digital signature | ❌ | ✅ | ✅ |===
The first two are handled by aloli-crystal/pdf. Signing deserves its own shard because:
- Distinct API surface (signature ≠ encryption).
- Optional dependencies (not every
pdfconsumer will sign). - Different release cycle (PAdES B-B → B-T → B-LT → B-LTA, post-quantum algorithms…).
== Roadmap
[cols="1,3,1",options="header"] |=== | Version | Target | Status
| v0.1 | PAdES B-B (basic detached PKCS#7 signature) | ✅ done
| v0.2 | PAdES B-T (RFC 3161 timestamping) | ✅ done
| v0.3 | PAdES B-LT (long-term validation material via /DSS) | ✅ done
| v0.4 | PAdES B-LTA (archive timestamping — indefinite extension) | ✅ done
| v0.5 | Verification (reading + validation) | ✅ done
| v0.6 | HSM / PKCS#11 backend | ✅ done |===
== Quickstart
[source,crystal]
require "pdf-signature"
B-B — sign an existing PDF (incremental update)
PDF::Signature::Signer.sign( input: "report.pdf", output: "signed-report.pdf", certificate: "./signer.p12", passphrase: "...", # or via env / Keychain level: :b_b, # :b_b | :b_t (done) | :b_lt | :b_lta (future) reason: "ISO 27001 audit validation", location: "Laguiole, FR", signing_time: Time.utc, )
B-T — add an RFC 3161 signature timestamp from a TSA
PDF::Signature::Signer.sign( input: "report.pdf", output: "signed-report.pdf", certificate: "./signer.p12", passphrase: "...", level: :b_t, tsa_url: "https://freetsa.org/tsr", # any RFC 3161 service reason: "ISO 27001 audit validation", )
B-LT — B-T + embedded long-term validation material (/DSS).
Signer and TSA certs are harvested automatically; supply the issuing
CA chain plus the revocation proofs (OCSP responses / CRLs).
PDF::Signature::Signer.sign( input: "report.pdf", output: "signed-report.pdf", certificate: "./signer.p12", passphrase: "...", level: :b_lt, tsa_url: "https://freetsa.org/tsr", ltv_certs: ["./issuing-ca.pem"], ltv_ocsps: ["./signer-ocsp.der"], ltv_crls: ["./issuing-ca.crl"], )
Verify a signed PDF (one report per signature / document timestamp)
PDF::Signature::Verifier.verify("signed-report.pdf", ca_bundle: "./trust.pem").each do |r| puts "#{r.field}: #{r.level} valid=#{r.valid?} whole=#{r.covers_whole_document?}" end
NOTE: B-T requires the openssl CLI (≥ 3.0, for cms -cades) on the PATH. The signature is a CAdES-BES detached envelope (ESS signing-certificate-v2) with the timestamp embedded as the id-aa-timeStampToken unsigned attribute, validated against openssl cms, openssl ts and poppler's pdf-sign.
== PAdES levels — summary
[cols="1,3,3",options="header"] |=== | Level | Main guarantee | Typical use case
| B-B | Modification detection + signer identity (self-declared date) | Internal signatures, immediate validation
| B-T | B-B + date proof via third-party TSA | ISO 27001 audit reports, mid-term contracts — minimum eIDAS advanced
| B-LT | B-T + embedded validation material (CRL/OCSP/certs) | Security policies, regulatory dossiers (5-10 years)
| B-LTA | B-LT + renewable archive timestamps | Legal archives, qualified eIDAS signatures, notarial |===
== Install
As a library :
[source,yaml]
dependencies: pdf-signature: github: aloli-crystal/pdf-signature version: "~> 0.7"
As the pdf-sign command-line tool — build and symlink (no sudo, no copy into /usr/local/bin) :
[source,sh]
shards build --release ln -s "$PWD/bin/pdf-sign" ~/bin/pdf-sign
== CLI : pdf-sign
[source,sh]
Sign (passphrase via the environment, never on the command line)
export PDFSIG_PASSPHRASE=… pdf-sign sign -i report.pdf -o signed.pdf -c signer.p12
-l b-t -t https://freetsa.org/tsr -r "ISO 27001 audit"
Long-term (B-LT) : supply the CA chain + revocation proofs
pdf-sign sign -i report.pdf -o signed.pdf -c signer.p12 -l b-lt
-t https://freetsa.org/tsr
-C issuing-ca.pem -O signer-ocsp.der -R issuing-ca.crl
Hardware key (PKCS#11 / HSM / SoftHSM) : PIN via the environment
export PDFSIG_PIN=… pdf-sign sign -i report.pdf -o signed.pdf -c signer.crt -l b-t
-t https://freetsa.org/tsr
-k "pkcs11:token=…;object=…;type=private" -m /path/to/module.so
Strict PAdES (native CMS, no signing-time signed attribute ; B-T+)
pdf-sign sign -i report.pdf -o signed.pdf -c signer.p12 -l b-t -s
-t https://freetsa.org/tsr
Verify (one report per signature ; exit 0 if all valid, 2 otherwise)
pdf-sign verify signed.pdf -a trust.pem
pdf-sign help [sign|verify]
Neither the PKCS#12 passphrase nor the PKCS#11 PIN ever appears on the command line : you pass the name of an environment variable (-p/--passphrase-env, -P/--pkcs11-pin-env) that holds it, so it stays out of ps.
== Dependencies
- https://github.com/aloli-crystal/pdf[`aloli-crystal/pdf`] ≥ 0.5 — PDF object model, parser, writer
- OpenSSL CLI ≥ 1.1 (already on every modern Unix) — used for PKCS#7 and RFC 3161 (the shard shells out to
openssl cmsandopenssl ts, seedoc/RATIONALE.adoc§ Technical choices)
== Documentation
doc/RATIONALE.adoc— substantive analysis: signature principles, the 4 PAdES levels, TSA construction, Sigstore model, ALOLI trust infrastructure trajectory, architectural decisions (in French)CHANGELOG.adoc— version history
== License
MIT — see LICENSE.
pdf-signature
- 0
- 0
- 0
- 1
- 2
- 17 days ago
- May 7, 2026
MIT License
Fri, 05 Jun 2026 15:47:04 GMT