Overview
tc-lib-pdf-sign provides the cryptographic building blocks and PDF signature objects used by tc-lib-pdf to produce signed PDF documents.
The crypto and the PDF object generation live here, while the host library keeps the ByteRange placement, the incremental update writer, and the public facade. The package assembles CMS/CAdES signatures natively in pure PHP through a small DER ASN.1 codec, so it can embed the ESS signing-certificate-v2 attribute that openssl_pkcs7_sign() cannot add. That attribute is what lifts a plain PKCS#7 signature to a PAdES baseline signature.
Repository and API Docs
- GitHub: https://github.com/tecnickcom/tc-lib-pdf-sign
- API docs: https://tcpdf.org/docs/srcdoc/tc-lib-pdf-sign
- Packagist: https://packagist.org/packages/tecnickcom/tc-lib-pdf-sign
Project Metadata
| Item | Value |
|---|---|
| Namespace | \Com\Tecnick\Pdf\Sign |
| License | GNU LGPL v3 |
Installation
composer require tecnickcom/tc-lib-pdf-sign
This package is normally pulled in transitively by tc-lib-pdf. Install it directly only when you need the low-level primitives on their own.
Where It Fits
Use this package when generated PDFs must carry legally meaningful digital signatures: approval workflows, PAdES-compliant archival documents, or long-term validation (LTV) requirements. For the high-level signing workflow, drive it through the tc-lib-pdf fluent signature() facade rather than calling these primitives directly. See the Digital Signatures guide.
Signature Profiles
Each level builds on the previous one:
| Profile | /SubFilter | What it provides |
|---|---|---|
| Legacy | adbe.pkcs7.detached | ISO 32000-1 detached CMS, carrying the ESS signing-certificate-v2 attribute. |
| PAdES B-B | ETSI.CAdES.detached | CAdES-based CMS with content-type, message-digest, and signing-certificate-v2 signed attributes. |
| PAdES B-T | ETSI.CAdES.detached | B-B plus an RFC 3161 signature timestamp embedded as the id-aa-signatureTimeStampToken unsigned attribute. |
| PAdES B-LT | ETSI.CAdES.detached | B-T plus a Document Security Store (/DSS, /VRI) with certificate, OCSP, and CRL validation material. |
| PAdES B-LTA | ETSI.CAdES.detached + ETSI.RFC3161 | B-LT plus a /Type /DocTimeStamp archive timestamp for long-term archival. |
The PAdES baseline output has been validated against the EU DSS reference validator (B-B, B-T, B-LT, and B-LTA all report the expected baseline level).
Features
- RSA and ECDSA signing keys, with SHA-256, SHA-384, or SHA-512 digests.
- Native detached CAdES-BES
SignedDatabuilder (no reliance onopenssl_pkcs7_sign()). - RFC 3161 timestamp codec for signature and archive timestamps; the returned token is verified and matched against the request.
- RFC 6960 OCSP request builder and response validator for revocation status.
- RFC 5280 CRL reader: issuer match, scope, validity interval, revocation entries, and signature.
- DSS validation material collection: certificate dedup, AIA/CRL-DP URL extraction, OCSP/CRL retrieval.
- Local (private key), external, and remote (HSM, smart card, CSC) signing. The CMS is assembled in one call or in two, and the
tc-lib-pdffacade drives the placeholder and the injection.
Components
| Component | Responsibility |
|---|---|
Config | Immutable signature configuration (profile, digest algorithm, certification level) with /SubFilter derivation. |
SignatureProfile | Backed enum of the supported profiles, the closed set Config validates against. |
DigestAlgorithm | Backed enum of the supported digests, with their lengths and NIST OIDs. |
Signer | Orchestration entry point: builds the detached CAdES CMS and collects the LTV material. |
Cms\Builder | Native detached CAdES-BES SignedData builder (signs the DER signed attributes with openssl_sign()), in one call or in two. |
Cms\SigningRequest | Validated, immutable inputs for the signed attributes; the state that crosses a two-phase signature. |
Cms\SignatureEncoding | Encoding of an externally produced signature: DER, or the fixed-width ECDSA form (IEEE P1363). |
Cms\Asn1 | Minimal DER ASN.1 encoder/decoder for CMS, RFC 3161, and OCSP structures. |
Cms\Oid | The CMS content type and signed attribute type OIDs this library emits and reads. |
Cms\Certificate | X.509 field reader for the issuer, subject, serial, and public key that CMS and OCSP quote verbatim. |
Cms\SignatureVerifier | Verifies a signature over a signed body against the certificate that produced it. |
Cms\SignedDataVerifier | Verifies a CMS SignedData, whether it carries its own content, as a timestamp token does, or is detached over content the caller supplies. |
Timestamp\Client / Timestamp\Config | RFC 3161 timestamp codec; the returned token is verified and matched against the request. |
Ocsp\Client | RFC 6960 OCSP request builder and response validator. |
Ltv\ValidationMaterial | DSS material collection: certificate dedup, AIA/CRL-DP URL extraction, OCSP/CRL retrieval. |
Ltv\Crl | RFC 5280 CRL reader: issuer match, scope, validity interval, revocation entries, and signature. |
Ltv\SkipReason | Why a revocation URL was discarded: revoked, invalid, unreachable, duplicate, not attempted. |
Output\Signature | The /Sig value dictionary, including the /ByteRange and /Contents placeholders. |
Output\Widget | Signature and empty-field widget annotations. |
Output\Dss | DSS/VRI object emitter. |
Output\DocTimeStamp | The /Type /DocTimeStamp value object (B-LTA). |
Output\PdfString | Shared PDF string-token encoder. |
Exception | Library exception type. |
RevokedException | Raised when a responder or a CRL states that a certificate is revoked. |
Typed Enums
| Enum | Purpose |
|---|---|
\Com\Tecnick\Pdf\Sign\SignatureProfile | Legacy (legacy), PadesBB (pades-b-b), PadesBT (pades-b-t), PadesBLT (pades-b-lt), PadesBLTA (pades-b-lta). |
\Com\Tecnick\Pdf\Sign\DigestAlgorithm | Sha256 (sha256), Sha384 (sha384), Sha512 (sha512). |
The backing value of each case is the configuration string previously accepted, so profile and digest_algorithm can be given as either an enum case or a plain string. On the tc-lib-pdf side, \Com\Tecnick\Pdf\Signature\SignatureAppearanceMode (N, R, D) and \Com\Tecnick\Pdf\Signature\ExternalSignatureEncoding (binary, base64, hex) cover the appearance stream and the external-signing payload encoding.
Design
The codecs are pure and perform no file or network access. HTTP transports (TSA, OCSP, CRL) and key loading are injected by the host as callables, so the consuming application owns networking and SSRF protection. This keeps the package deterministic and testable, and lets the host reuse its existing HTTP stack and URL allow-list.
Timestamp\Config carries host, timeout, verifyPeer, username, password, and cert for the host to apply to its own HTTP client. This library never reads them, opening no connection.
Verified Validation Material
Nothing a TSA, an OCSP responder, or a CRL distribution point returns becomes validation material until its signature has been checked:
- A timestamp token must carry exactly one
SignerInfowhose signature verifies against the TSA certificate the token embeds, whosecontent-typeandmessage-digestattributes match the encapsulatedTSTInfo(RFC 5652 sections 5.3 and 11.1), and whose ESSsigning-certificateattribute, when present, names that same certificate. It must then answer the request that was sent (RFC 3161 section 2.4.2): the same message imprint under the same digest algorithm, the nonce echoed unchanged, the policy that was asked for when one was requested, and agenTimenear the moment of the request.id-kp-timeStampingis required as the single extended key usage, marked critical, and the certificate must have covered the instant the token attests. - An OCSP response is checked against the RFC 6960 section 3.2 acceptance rules: successful status, a basic response type, a signature that verifies against a responder the issuer authorised (itself, or a delegate holding
id-kp-OCSPSigningthat the issuer signed and that is inside its own validity period), aSingleResponsewhoseCertIDmatches the request by value, a good certificate status, and a validity interval covering the moment of use. A criticalsingleExtensionorresponseExtensionthis codec does not recognise makes the response unusable (RFC 6960 section 4.4). - A CRL must be one complete
CertificateListissued by the certificate the distribution point came from and signed by it, with matching inner and outer signature algorithms, covering the moment of use, and not narrowed by adeltaCRLIndicatoror anissuingDistributionPoint. The issuing certificate must be a CA whosekeyUsageadmitscRLSign(RFC 5280 section 6.3.3 (f)). A critical extension the reader does not recognise makes the list unusable.
Anything that fails is rejected rather than embedded in the document. The number of URLs taken from one certificate extension is bounded at Ltv\ValidationMaterial::MAX_URLS; the excess is reported rather than dropped. Revocation collection is best-effort, so a rejection is a skip rather than an error: Signer::collectValidationMaterial() takes an $onSkip observer that receives every discarded URL with its reason and an Ltv\SkipReason, which separates a revoked verdict from a timeout.
$signer->collectValidationMaterial($chain, $ocsp, $crl, $tokens, $now, function (
string $source,
string $url,
string $reason,
SkipReason $code,
): void {
if ($code === SkipReason::Revoked) {
throw new RuntimeException('Refusing to sign: ' . $reason);
}
$this->logger->warning('LTV material skipped', compact('source', 'url', 'reason'));
});
The digest and signature algorithms accepted for validation material are SHA-256 and above. SHA-1 is refused; a host that has to accept one from a legacy responder passes allowSha1: true to Cms\SignatureVerifier or Cms\SignedDataVerifier and injects it.
RSA signatures are PKCS#1 v1.5 throughout, identified by rsaEncryption as RFC 3370 section 3.2 defines it for CMS and read back under either that identifier or the sha*WithRSAEncryption form. RSASSA-PSS is not supported in either direction: a PSS-signing key cannot sign through this library, and a PSS-signed timestamp token, OCSP response, or CRL is refused as an unsupported signature algorithm.
Some checks stay with the host. The library does not decide whether a certificate is trusted, and by default it does not refuse to sign with one that has expired or whose key usage forbids signing, since a host may deliberately re-sign historical content. Cms\Certificate::assertValidAt() and Cms\Certificate::assertUsableForSigning() run those checks on demand, and new Signer(checkSignerCertificate: true) runs both on every sign() and prepare().
Standards
- ETSI EN 319 142-1 : PAdES baseline profiles (B-B, B-T, B-LT, B-LTA)
- ISO 32000-1 / ISO 32000-2 : PDF digital signatures and the Document Security Store
- RFC 5652 : Cryptographic Message Syntax (CMS)
- RFC 5035 : ESS
signing-certificate-v2attribute - RFC 3161 : Time-Stamp Protocol (TSP)
- RFC 6960 : Online Certificate Status Protocol (OCSP)
- RFC 5280 : X.509 certificates and CRLs
Requirements
- PHP 8.2 or later
- Extensions:
hash,json,openssl,pcre - Composer
Integration Notes
- Manage certificate and key material outside application source and images.
- For
pades-b-ltandpades-b-lta, the signing certificate must expose reachable OCSP or CRL responders; a self-signed certificate embeds only its own bytes, so a validator then reports B-T with a DSS present. - Keep networking (TSA, OCSP, CRL retrieval) under the host application’s URL allow-list, since the codecs delegate transport to injected callables.
Signer::collectValidationMaterial()takes the signer chain leaf-first, one certificate per entry as PEM or DER, and verifies that ordering by signature rather than by name. A PEM entry holding more than one certificate, such as afullchain.pem, is refused rather than decoded as a chain of one.- Pass the signature timestamp tokens as the fourth argument of
collectValidationMaterial(): the certificates they embed run through the same OCSP and CRL lookups as the signer’s own chain, which is what ETSI EN 319 142-1 requires of a B-LT Document Security Store. Ordering the token’s certificate bag costs a signature check per pair of members, so one larger thanSigner::MAX_PATH_CERTIFICATESis refused rather than ordered. - Pass the signing time as the fifth argument so a retried or queued signature collects against the same instant it signs for. It is separate from the
$timestampNowargument ofsign()andbuildFromSignature(), which is the moment the timestamp request is made and what the token’sgenTimeis checked against. Output\Dss::emit()returns astateentry alongside the objects. A DSS written by an incremental update replaces the one before it, so pass that state back on the next update (a second signature, or the B-LTA archive timestamp) and the earlier VRI entries are carried into the new dictionary instead of being dropped.
Example
To sign a document, drive the package through the tc-lib-pdf fluent signature() facade:
$pdf->signature()->configure([
'profile' => 'pades-b-t', // legacy | pades-b-b | pades-b-t | pades-b-lt | pades-b-lta
'digest_algorithm' => 'sha256', // sha256 | sha384 | sha512
'signcert' => 'file:///path/to/cert.pem',
'privkey' => 'file:///path/to/key.pem',
'password' => '',
]);
The runnable E007, E008, E009, E081, and E084 signature examples in tc-lib-pdf cover the complete workflows.
Low-Level: Building a Detached CMS
The Cms\Builder produces a detached CAdES-BES CMS over arbitrary bytes (the host supplies the ByteRange-covered content). It is the core of PAdES B-B:
<?php
require_once __DIR__ . '/vendor/autoload.php';
use Com\Tecnick\Pdf\Sign\Cms\Builder;
$privateKey = openssl_pkey_get_private('file:///path/to/key.pem');
$certDer = ''; // DER bytes of the signing certificate
$content = ''; // detached content bytes (the ByteRange-covered document)
$cms = (new Builder())->sign(
$content, // detached content bytes (the ByteRange-covered document)
$certDer, // DER of the signing certificate
$privateKey, // OpenSSLAsymmetricKey (RSA or EC)
[], // additional chain certificates (DER), if any
'sha256', // digest algorithm
time(), // signing time (Unix timestamp)
);
// $cms is a DER-encoded CMS ContentInfo ready for injection into /Contents.
Low-Level: Two-Phase Signing
sign() needs the private key and the content in this process. When either is unavailable, the same call is available in two halves. signaturePayload() returns the DER SET OF signed attributes that the signature has to cover, and buildFromSignature() turns those plus the signature into the CMS.
This covers a key held in a hardware token, a smart card, or a remote signing service, and a document too large to hold as a string, since the request carries the message digest rather than the content:
<?php
require_once __DIR__ . '/vendor/autoload.php';
use Com\Tecnick\Pdf\Sign\Cms\Builder;
use Com\Tecnick\Pdf\Sign\Cms\SigningRequest;
$builder = new Builder();
// The digest can be computed incrementally, so the content is never held in memory.
$context = hash_init('sha256');
hash_update_stream($context, $stream);
$request = new SigningRequest(
hash_final($context, true), // digest of the ByteRange-covered content
$certDer, // DER of the signing certificate
'sha256', // digest algorithm
time(), // signing time (Unix timestamp)
false, // include the CMS signing-time attribute (false for PAdES)
);
$signature = $remoteSigner->sign($builder->signaturePayload($request));
$cms = $builder->buildFromSignature($request, $signature, []);
Signer exposes the same three steps (prepare(), signaturePayload(), buildFromSignature()), so a host driving a remote signer can complete the flow through that class alone while getting the profile rules applied: the signing-time attribute is omitted for PAdES, and a B-T or higher profile requires the timestamp client and transport.
buildFromSignature() verifies the signature against the signing certificate before it emits anything, so a signature over the wrong bytes, from the wrong key, or in the wrong encoding fails at the call. An ECDSA signature returned as the fixed-width r || s concatenation is accepted by passing SignatureEncoding::P1363 as the last argument. Cms\SignedDataVerifier::verify() reads a finished CMS back, given the content a detached signature covers:
(new SignedDataVerifier())->verify($cms, $byteRangeContent); // returns the signer certificate DER
The request is immutable and validated on construction. When the two phases are separate HTTP requests, carry it across with toArray() and fromArray(), which validate again on the way back in. Validation is not authentication: re-running the constructor rejects a payload that is not a valid request, but not one edited into a different valid request. Pass a secret to both calls and the state carries an HMAC that is checked before anything else, or carry it over a channel the host already protects.
$state = $request->toArray($secret); // adds a 'mac' entry
$request = SigningRequest::fromArray($state, $secret);
For the full document-signing workflow, use the tc-lib-pdf fluent facade described in the Digital Signatures guide.
Development and Packaging
- QA and local checks:
make deps,make help,make qa - Coverage report:
make qa-coverage - Packaging:
make rpm,make deb
Support and Contribution
- Sponsor: https://github.com/sponsors/tecnickcom
- Contribution guide: https://github.com/tecnickcom/tc-lib-pdf-sign/blob/main/CONTRIBUTING.md
- Security policy: https://github.com/tecnickcom/tc-lib-pdf-sign/blob/main/SECURITY.md