Digital Signatures

August 25, 2026 ยท View on GitHub

Back to root overview: README.md

tc-lib-pdf produces detached CMS (PKCS#7) signatures and PAdES baseline signatures (ETSI EN 319 142-1) with optional RFC 3161 timestamps, LTV (Long-Term Validation) material, and archive timestamps. The cryptography lives in the companion package tecnickcom/tc-lib-pdf-sign.

Signature profiles

The profile option selects the signature format:

Profile/SubFilterWhat it adds
legacy (default)adbe.pkcs7.detachedISO 32000-1 detached CMS with the ESS signing-certificate-v2 attribute.
pades-b-bETSI.CAdES.detachedPAdES-BASELINE-B: CAdES-based CMS; the signing time is carried by the /M dictionary entry (the CMS signing-time attribute is omitted, as the baseline requires).
pades-b-tETSI.CAdES.detachedPAdES-BASELINE-T: B-B plus an RFC 3161 signature timestamp embedded in the CMS.
pades-b-ltETSI.CAdES.detachedPAdES-BASELINE-LT: B-T plus a Document Security Store (/DSS, /VRI) with certificates and, where reachable, OCSP/CRL revocation data.
pades-b-ltaETSI.CAdES.detached + ETSI.RFC3161PAdES-BASELINE-LTA: B-LT plus a /Type /DocTimeStamp archive timestamp over the whole document.

digest_algorithm accepts sha256 (default), sha384, or sha512; RSA and ECDSA signing keys are both supported.

Signature-focused runnable examples:

Fluent API: signature()

The signature() facade is the entry point. Each call is chainable and forwards to the underlying methods setSignature(), setSignTimeStamp(), setUserRights(), setSignatureAppearance(), and so on, which are also callable directly.

$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'         => '',
        'extracerts'       => 'file:///path/to/chain.pem',   // optional issuer chain
        'cert_type'        => 2,
        'info'             => [
            'Name'        => 'Jane Smith',
            'Location'    => 'London',
            'Reason'      => 'Document approval',
            'ContactInfo' => 'jane@example.com',
        ],
    ])
    ->timestamp([
        'enabled'        => true,
        'host'           => 'https://freetsa.org/tsr',
        'hash_algorithm' => 'sha256',
        'timeout'        => 30,
        'verify_peer'    => true,
        'allow_sha1'     => true,            // this TSA still uses the SHA-1 v1 attribute
    ]);

$pdf->signature()->appearance()->place(posx: 15, posy: 35, width: 90, height: 20, page: -1, name: 'Signature');
$widgetObjId = $pdf->signature()->widgetObjectId();

A signature field name cannot contain a period: ISO 32000-1 clause 12.7.3.2 makes it the separator between the components of a fully qualified field name, so one is rejected.

Adding a TSA Timestamp (RFC 3161)

For pades-b-t and above a timestamp is required. Configure it with signature()->timestamp([...]) (or setSignTimeStamp([...])); the RFC 3161 token is embedded in the CMS as the id-aa-signatureTimeStampToken unsigned attribute:

$pdf->signature()->timestamp([
    'enabled'        => true,
    'host'           => 'https://freetsa.org/tsr',
    'username'       => '',
    'password'       => '',
    'cert'           => '',
    'hash_algorithm' => 'sha256',   // sha256 | sha384 | sha512
    'policy_oid'     => '',         // optional OID string
    'nonce_enabled'  => true,
    'timeout'        => 30,
    'verify_peer'    => true,
    'allow_sha1'     => false,      // accept a SHA-1 token (see below)
]);

The token a TSA returns is verified before it is embedded: its SignerInfo signature must verify against the TSA certificate the token carries, that certificate must hold id-kp-timeStamping as its single critical extended key usage and must have covered the instant the token attests, and the token must answer the request that was sent (same message imprint under the same digest algorithm, the nonce echoed unchanged, the policy that was asked for, and a genTime near the moment of the request). A token failing any of these raises a \Com\Tecnick\Pdf\Exception instead of being written to the document.

allow_sha1 (off by default) relaxes the digest rules for that check: it accepts a token whose signature, message digest, or ESS certificate hash uses SHA-1. Some public TSAs name their certificate with the RFC 2634 signing-certificate (v1) attribute, which is SHA-1 by definition, and are refused without it; freetsa.org, used by the signature examples, is one of them. A TSA emitting the signing-certificate-v2 attribute (SHA-256) needs no such setting. The relaxation applies only to the timestamp token, not to the OCSP responses or CRLs collected for the DSS.

LTV (Long-Term Validation) and archive timestamps

Enable LTV via the ltv key inside configure(). The library fetches OCSP responses and CRL payloads from the certificate's AIA and CRL-DP extensions and writes a /DSS (with a /VRI map keyed by the uppercase SHA-1 of the signature /Contents) in a post-signing incremental revision:

$pdf->signature()->configure([
    'profile'  => 'pades-b-lt',
    'signcert' => 'file:///path/to/cert.pem',
    'privkey'  => 'file:///path/to/key.pem',
    'password' => '',
    'ltv'      => [
        'enabled'     => true,
        'embed_ocsp'  => true,   // fetch OCSP responses
        'embed_crl'   => true,   // fetch CRL payloads (fallback)
        'embed_certs' => true,   // include certificate DER bytes
        'include_dss' => true,   // emit /DSS in the catalog
        'include_vri' => true,   // emit /VRI map keyed by signature SHA-1
    ],
]);

The extracerts bundle is read as the chain above the signing certificate, leaf first up to the root, one certificate per entry; a bundle that repeats the signing certificate (a fullchain.pem) is accepted, the repeat being dropped. The order is verified by signature, so a chain whose entries do not issue one another is rejected with a \Com\Tecnick\Pdf\Exception.

To reach PAdES-BASELINE-LTA, call upgradeToLta() (it selects the pades-b-lta profile, forces the DSS on, and adds a /Type /DocTimeStamp archive timestamp over the whole document in a further incremental revision; a TSA must be configured):

$pdf->signature()->configure([/* pades-b-lt + ltv */])->timestamp([/* TSA */])->upgradeToLta();

A validator only reports the LT/LTA level when the DSS actually contains the revocation data for the chain, so the signing certificate must expose reachable OCSP/CRL responders. A self-signed certificate embeds only its own bytes, so a validator then reports B-T with a DSS present.

What reaches the DSS

Nothing an OCSP responder or a CRL distribution point returns is embedded until it has been verified:

  • an OCSP response must have a successful status, a basic response type, a signature that verifies against a responder the issuer authorised (itself, or a delegate holding id-kp-OCSPSigning), a CertID matching the request, a good certificate status, and a validity interval covering the signing time;
  • a CRL must be one complete list issued and signed by the certificate whose distribution point it came from, covering the signing time, not narrowed by a deltaCRLIndicator or an issuingDistributionPoint, and issued by a CA whose keyUsage admits cRLSign;
  • the digests and signatures accepted are SHA-256 and above; SHA-1 is refused, and RSASSA-PSS is not supported in either direction.

The signing time (the document modification time, the same instant the /M entry carries) is the moment every response is checked against.

Revocation collection is best-effort: a source that is unreachable, that returns something unusable, or that reports the certificate revoked is skipped, and the rest of the material is still embedded. The certificates carried by the signature timestamp token are collected into the DSS alongside the signer's own chain, and are run through the same OCSP and CRL lookups, as ETSI EN 319 142-1 requires of a B-LT Document Security Store.

Signing with a key this process cannot reach

signature()->external() reserves the signature field without a key: the document is built and hashed by the library and the signature is made elsewhere. configure() takes the same options as on the facade, with signcert and privkey left empty; prepare() returns the prepared document bytes, the /ByteRange tuple, and the digest of the covered bytes; apply() writes the returned CMS into the reserved /Contents, which has to hold it (11742 hexadecimal digits, or 31742 with a timestamp enabled).

$pdf->signature()->external()->configure([/* ... */ 'privkey' => '', 'signcert' => '']);
$prepared = $pdf->signature()->external()->prepare('sha256');
$signedPdf = $pdf->signature()->external()->apply(
    $prepared['prepared_pdf'],
    $prepared['byte_range'],
    $cms,
    'binary',
);

Two workflows produce $cms:

  • a provider that returns a complete CMS is handed the digest and gives back the whole detached signature, which apply() only injects. The profile the provider signs under has to match the one configure() was given, since the PDF /SubFilter comes from it. This is examples/E075_external_signature_injection.php;
  • a key service that signs bytes, such as an HSM, a KMS, or a remote signing API, never sees the document. Com\Tecnick\Pdf\Sign\Signer::prepare() turns the digest and the signing certificate into a SigningRequest, signaturePayload() renders it as the DER SET OF signed attributes the key signs, and buildFromSignature() rebuilds the same attributes, checks the returned signature against them, and emits the detached CAdES. The request crosses a queue or a second HTTP request through toArray()/fromArray(), which take an optional key for an HMAC over the exported state. This is examples/E084_signature_two_phase_cms.php.

Both halves of a two-phase signature take the same Com\Tecnick\Pdf\Sign\Config: it fixes the digest algorithm and, through the profile, whether the CMS carries the signing-time attribute. A B-T or higher profile also requires the timestamp client and transport in buildFromSignature().

Generating a Self-Signed Test Certificate

The bundled examples/data/cert/tcpdf.crt is a self-signed demo certificate (certificate and RSA private key in one file). Generate your own with:

openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -sha256 \
    -keyout tcpdf.key -out tcpdf.crt \
    -subj "/CN=tc-lib-pdf test certificate"
# combine into a single file (as the bundled demo does), or reference them separately
cat tcpdf.crt tcpdf.key > tcpdf.pem
# convert to PKCS#12 if needed
openssl pkcs12 -export -in tcpdf.crt -inkey tcpdf.key -out tcpdf.p12

For a real PAdES-BASELINE-LT/LTA validation you need a certificate issued by a CA whose OCSP responder (AIA) and CRL distribution point are reachable at signing time.