The complete script produces a readable PDF 2.0 page and a real CMS signature using P-256 and SHA3-256. The page explains the selected algorithms. Its signature field has no widget: the visible text describes the operation, while the cryptographic signature covers the actual file bytes.
The certificate and private key are public test credentials, committed for reproducible examples. Their signature demonstrates the mathematical operation and does not establish a trusted identity. Applications supply their own credentials and trust policy explicitly.
Run the example
From a core 0.7.1 checkout with the locked development dependencies, on 64-bit PHP 8.3 or later:
php examples/pdf20-modern-signature.php modern-signature.pdf
The example supplies Courier through explicit metrics: FirstChar/LastChar, ASCII widths and a FontDescriptor. PDF 2.0 no longer permits omitting these entries for the fourteen standard fonts. This construction does not embed a font program; profiles requiring embedding need an explicitly embedded font.
This example uses PHP's OpenSSL extension for ECDSA. Ed25519 and Ed448 use a separate, explicitly selected executable provider; merely loading the library starts no process. The PDF remains unencrypted so that its page can be previewed in the browser.
Select the credential and digest
X509Certificate receives the certificate's DER bytes. CertificateChain and OpenSslIdentity associate that certificate with the supplied private key. No filename, remote certificate URL or operating-system trust store is inferred.
$identity = new OpenSslIdentity(new CertificateChain($certificate), $privateKeyPem);
$backend = new ModernCmsSignatureBackend(
$identity,
SignatureDigestMethod::SHA3_256,
);
The backend reads the named curve from the actual certificate and checks the curve/digest pair. P-256 with SHA3-256 is supported; P-256 with SHA-512 is rejected. ECDSA uses the selected digest for both the document and the DER CMS signed attributes. EdDSA has its own prescribed algorithm rules. An algorithm name alone does not override these constraints.
Declare the extensions independently
$extensions = new IsoExtensions(
IsoTechnicalSpecification::HashAlgorithms,
IsoTechnicalSpecification::DigitalSignatures,
);
These objects write separate developer-extension dictionaries in Extensions.ISO_. ISO/TS 32001 supplies the additional digests; ISO/TS 32002 supplies the signature algorithms and combinations. Selecting the larger extension number does not declare the smaller one. The file also explicitly uses new FileHeader('2.0').
SHA3-256, SHA3-384, SHA3-512 and SHAKE256 with a fixed 64-byte output are represented by SignatureDigestMethod. EC3 deletes ISO/TS 32001 clause 5.1.3: the implementation therefore does not extend that deleted signature-reference rule.
Reserve, prepare and sign exact bytes
PendingSignature owns /Contents, /ByteRange, /Type, /Filter and /SubFilter. Its SignatureCapacity(4096) reserves 4,096 signature bytes, encoded as a hexadecimal string in the PDF. SignatureField connects that exact dictionary to the AcroForm.
$prepared = (new SignatureWriter())->prepare($file, $pending);
$signed = $prepared->sign($backend);
$signed->writeTo($path);
Preparation freezes the object identities, byte positions and complete-file range. Signing hashes those bytes, creates the CMS container and fills only the reserved region. The file length and object offsets remain unchanged. If the result exceeds its capacity, prepare another document with a larger reservation and sign its new bytes; truncation is never used.
A later signature requires a new incremental revision. The previous byte prefix and historical signature policies remain part of the frozen history. Certificate trust, expiry, revocation and suitable algorithms for a particular deployment require the application's validation policy.
Add encryption and PDF MAC explicitly
Encryption, approval signatures and PDF MAC solve different problems. ModernStandardSecurityHandler selects R=6 AES-CBC or R=7 AES-GCM. AES-GCM authenticates each encrypted value. ISO/TS 32004 adds a MAC over the complete serialized revision, including its structural bytes.
For an encrypted revision with a signature-attached MAC:
$writer = new FileWriter(
encryption: $encryption,
mac: $mac,
macSignature: $pending,
);
$prepared = new PreparedSignature($writer->revision($file), $pending, mac: $mac);
$signed = $prepared->sign($backend);
PdfMacTokenComposer receives the actual encryption key, the 32-byte KDF salt and an explicit random source. The matching ISO extension declarations and encryption permission bit are required. The MAC is attached as a CMS unsigned attribute and binds both the covered PDF bytes and the actual SignerInfo signature bytes. A standalone MAC uses a distinct trailer location. Every incremental revision needs a new MAC; an earlier token cannot authenticate appended bytes.
The bundle guide explains these constructors and boundaries. The preview below demonstrates the unencrypted signature example, so it does not require a password or claim to demonstrate encryption.
Verify with independent implementations
tools/validate_iso32000_crypto.py generates encrypted and MAC-protected fixtures through the native writers. It uses pyHanko to decrypt and validate them, OpenSSL and Python cryptography to verify CMS signatures, and qpdf for the supported structural combinations. It exercises classic, stream and hybrid cross-references, initial and incremental revisions, linearization and deliberate tampering.
python tools/validate_iso32000_crypto.py \
--php php --qpdf qpdf --openssl /usr/bin/openssl \
--work-dir /tmp/pdf20-crypto
Install the validation dependencies pinned by the repository before running that development command. They are not Composer production dependencies. The tests verify emitted bytes independently; their success does not make the public demonstration certificate trusted or certify every PDF profile.
References
The retained ISO/TS 32001 EC3, ISO/TS 32002 EC3, ISO/TS 32003 and ISO/TS 32004 come from the PDF Association. Their source identities, physical pages and correction annotations are recorded in the bundle catalogue.
Tutorial path · Bundle guide · Security
View the PDF result
This file was generated with the code in this documentation. All pages appear below; open the PDF to use its links and bookmarks.
Use the browser PDF viewer
This viewer depends on your browser. If the document does not appear, use “Open the PDF” or “Download”.
Complete source code
Code from roukmoute/pdf · View the source at the documented commit ↗
Read the commented script: pdf20-modern-signature.php
<?php
declare(strict_types=1);
use PDF\Document\Extensions\IsoExtensions;
use PDF\Document\Extensions\IsoTechnicalSpecification;
use PDF\Document\Forms\AcroForm;
use PDF\Document\Forms\FieldTree;
use PDF\Document\Forms\PartialFieldName;
use PDF\Document\Forms\SignatureField;
use PDF\Document\ResourceDictionary;
use PDF\Document\Resources\Font;
use PDF\Document\Signatures\PendingSignature;
use PDF\Document\Signatures\SignatureCapacity;
use PDF\Document\Signatures\SignatureFormat;
use PDF\Document\Signatures\Policies\SignatureDigestMethod;
use PDF\FileStructure;
use PDF\Security\CertificateChain;
use PDF\Security\ModernCmsSignatureBackend;
use PDF\Security\OpenSslIdentity;
use PDF\Security\X509Certificate;
use PDF\Serialization\SignatureWriter;
use PDF\Syntax\ContentStreamsResources\ContentStreams;
use PDF\Syntax\ContentStreamsResources\Operators as Op;
use PDF\Syntax\DataStructure\Rectangle;
use PDF\Syntax\DataStructure\TextString;
use PDF\Syntax\DocumentStructure\DocumentCatalog;
use PDF\Syntax\DocumentStructure\PageTree\PageObject;
use PDF\Syntax\DocumentStructure\PageTree\PageTreeNode;
use PDF\Syntax\FileStructure\FileBody;
use PDF\Syntax\FileStructure\FileHeader;
use PDF\Syntax\Object\DictionaryObject\Entry;
use PDF\Syntax\Object\DictionaryType;
use PDF\Syntax\Object\IndirectReference;
use PDF\Syntax\Object\NameType;
use PDF\Syntax\Object\NumericType;
use PDF\Syntax\Object\StreamObject;
use PDF\Syntax\Object\StringObject\HexadecimalString;
use PDF\Syntax\Object\StringObject\LiteralString;
require dirname(__DIR__) . '/vendor/autoload.php';
// 1. These synthetic credentials are PUBLIC TEST DATA. They demonstrate the
// mathematical operation and assert no trusted identity. Applications supply
// their own certificate and key bytes through the same explicit boundary.
$fixtureBytes = file_get_contents(dirname(__DIR__) . '/tests/Fixtures/iso32000-crypto/keys.json');
if ($fixtureBytes === false) {
throw new RuntimeException('The public signature fixtures are unavailable.');
}
$fixtures = json_decode($fixtureBytes, true, flags: JSON_THROW_ON_ERROR);
$credential = null;
foreach ($fixtures['keys'] as $candidate) {
if ($candidate['name'] === 'P-256') {
$credential = $candidate;
}
}
if ($credential === null) {
throw new RuntimeException('The public P-256 demonstration credential is unavailable.');
}
$certificateBytes = base64_decode($credential['certificate'], true);
if ($certificateBytes === false) {
throw new RuntimeException('The public demonstration certificate has invalid base64 data.');
}
$certificate = new X509Certificate(new HexadecimalString($certificateBytes));
$identity = new OpenSslIdentity(new CertificateChain($certificate), $credential['private_key']);
// The backend inspects the actual certificate's named curve. P-256/SHA3-256
// is an allowed pair; P-256/SHA512 would be rejected before any bytes are signed.
$backend = new ModernCmsSignatureBackend($identity, SignatureDigestMethod::SHA3_256);
// 2. Hash support and signature support are separate ISO extension levels.
// Both are declared in the ISO_ array; choosing the larger number cannot
// replace the smaller declaration. The document header explicitly selects 2.0.
$extensions = new IsoExtensions(IsoTechnicalSpecification::HashAlgorithms, IsoTechnicalSpecification::DigitalSignatures);
$pending = new PendingSignature(new SignatureCapacity(4096), SignatureFormat::Pkcs7Detached, entries: new Entry(new NameType('Reason'), new LiteralString('Public educational fixture')));
$fields = (new FieldTree())->addRoot(new SignatureField(new PartialFieldName(new TextString('Demonstration')), value: new IndirectReference($pending)));
$form = new AcroForm($fields);
// PDF 2.0 requires explicit metrics and a descriptor even for standard fonts.
$font = require __DIR__ . '/fixtures/pdf20-courier-font.php';
$resources = new ResourceDictionary(new Font(new DictionaryType(new Entry(new NameType('F1'), new IndirectReference($font)))));
$content = new StreamObject(new ContentStreams(
new Op\BeginTextObject(),
new Op\SetTextFontAndSize(new NameType('F1'), new NumericType(20)),
new Op\MoveTextPosition(new NumericType(48), new NumericType(740)),
new Op\ShowText(new LiteralString('PDF 2.0: an explicit modern signature')),
new Op\SetTextFontAndSize(new NameType('F1'), new NumericType(12)),
new Op\MoveTextPosition(new NumericType(0), new NumericType(-48)),
new Op\ShowText(new LiteralString('Named curve: P-256. Message digest: SHA3-256.')),
new Op\MoveTextPosition(new NumericType(0), new NumericType(-28)),
new Op\ShowText(new LiteralString('ISO/TS 32001 and ISO/TS 32002 are declared separately.')),
new Op\MoveTextPosition(new NumericType(0), new NumericType(-28)),
new Op\ShowText(new LiteralString('The exact ByteRange is signed through CMS signed attributes.')),
new Op\MoveTextPosition(new NumericType(0), new NumericType(-40)),
new Op\ShowText(new LiteralString('PUBLIC TEST KEY: no trusted production identity is asserted.')),
new Op\EndTextObject(),
));
$page = new PageObject(new Rectangle(new NumericType(0), new NumericType(0), new NumericType(595), new NumericType(842)), $resources, $content);
$catalog = new DocumentCatalog((new PageTreeNode())->addKid($page), new Entry(new NameType('Extensions'), $extensions), new Entry(new NameType('AcroForm'), new IndirectReference($form)));
$file = new FileStructure(new FileHeader('2.0'), new FileBody($catalog));
// 3. Preparing fixes the final byte ranges. Signing hashes those exact bytes,
// hashes DER signedAttrs with the same digest, verifies the mathematical
// result, and fills only the preallocated Contents region.
$prepared = (new SignatureWriter())->prepare($file, $pending);
$signed = $prepared->sign($backend);
$path = $argv[1] ?? __DIR__ . '/pdf20-modern-signature.pdf';
$signed->writeTo($path);
echo 'PDF written to ' . $path . PHP_EOL;
