Le script complet produit une page PDF 2.0 lisible et une véritable signature CMS utilisant P-256 et SHA3-256. La page explique les algorithmes choisis. Son champ de signature ne possède pas de widget : le texte visible décrit l’opération, tandis que la signature cryptographique couvre les octets réels du fichier.

Le certificat et la clé privée sont des données publiques de test, conservées pour rendre les exemples reproductibles. Leur signature démontre l’opération mathématique sans établir une identité de confiance. L’application fournit explicitement ses propres données d’identité et sa politique de confiance.

Exécuter l’exemple

Depuis une copie du core 0.7.1 avec ses dépendances de développement verrouillées, sous PHP 8.3 ou ultérieur en 64 bits :

php examples/pdf20-modern-signature.php modern-signature.pdf

L’exemple fournit Courier avec des métriques explicites : FirstChar/LastChar, largeurs ASCII et FontDescriptor. PDF 2.0 ne permet plus d’omettre ces entrées pour les quatorze polices standard. Cette construction n’incorpore pas de programme de fonte ; un profil exigeant l’incorporation demande une fonte explicitement incorporée.

Cet exemple utilise l’extension OpenSSL de PHP pour ECDSA. Ed25519 et Ed448 utilisent un fournisseur distinct dont l’exécutable est choisi explicitement ; charger la bibliothèque ne démarre aucun processus. Le PDF reste non chiffré pour permettre son aperçu dans le navigateur.

Choisir l’identité et le condensat

X509Certificate reçoit les octets DER du certificat. CertificateChain et OpenSslIdentity associent ce certificat à la clé privée fournie. Aucun chemin, aucune URL de certificat distant ni aucun magasin de confiance du système ne sont déduits.

$identity = new OpenSslIdentity(new CertificateChain($certificate), $privateKeyPem);
$backend = new ModernCmsSignatureBackend(
    $identity,
    SignatureDigestMethod::SHA3_256,
);

Le fournisseur lit la courbe nommée dans le certificat réel et contrôle le couple courbe/condensat. P-256 avec SHA3-256 est pris en charge ; P-256 avec SHA-512 est refusé. ECDSA utilise le condensat choisi pour le document et pour les attributs signés CMS encodés en DER. EdDSA possède ses propres règles d’algorithme. Un nom d’algorithme ne suffit pas à contourner ces contraintes.

Déclarer les extensions indépendamment

$extensions = new IsoExtensions(
    IsoTechnicalSpecification::HashAlgorithms,
    IsoTechnicalSpecification::DigitalSignatures,
);

Ces objets écrivent des dictionnaires d’extension distincts dans Extensions.ISO_. ISO/TS 32001 apporte les condensats supplémentaires ; ISO/TS 32002 apporte les algorithmes de signature et leurs combinaisons. Choisir le numéro d’extension le plus élevé ne déclare pas le plus petit. Le fichier utilise aussi explicitement new FileHeader('2.0').

SHA3-256, SHA3-384, SHA3-512 et SHAKE256 avec une sortie fixe de 64 octets sont représentés par SignatureDigestMethod. EC3 supprime le paragraphe 5.1.3 d’ISO/TS 32001 : l’implémentation n’étend donc pas cette règle supprimée concernant les références de signature.

Réserver, préparer et signer les octets exacts

PendingSignature possède /Contents, /ByteRange, /Type, /Filter et /SubFilter. Sa SignatureCapacity(4096) réserve 4 096 octets de signature, encodés en chaîne hexadécimale dans le PDF. SignatureField relie ce dictionnaire exact à l’AcroForm.

$prepared = (new SignatureWriter())->prepare($file, $pending);
$signed = $prepared->sign($backend);
$signed->writeTo($path);

La préparation fige les identités des objets, les positions et les plages couvrant le fichier entier. La signature calcule le condensat de ces octets, construit le conteneur CMS et remplit uniquement la région réservée. La longueur du fichier et les positions des objets restent identiques. Si le résultat dépasse la capacité, il faut préparer un autre document avec une réservation plus grande et signer ses nouveaux octets ; aucune troncature n’est utilisée.

Une signature ultérieure demande une nouvelle révision incrémentale. Le préfixe d’octets précédent et les politiques de signature historiques restent dans l’historique figé. La confiance dans les certificats, l’expiration, la révocation et le choix d’algorithmes adaptés au déploiement relèvent de la politique de validation de l’application.

Ajouter explicitement le chiffrement et PDF MAC

Le chiffrement, les signatures d’approbation et PDF MAC répondent à des besoins différents. ModernStandardSecurityHandler choisit R=6 AES-CBC ou R=7 AES-GCM. AES-GCM authentifie chaque valeur chiffrée. ISO/TS 32004 ajoute un MAC couvrant toute la révision sérialisée, y compris ses octets structurels.

Pour une révision chiffrée avec un MAC attaché à la signature :

$writer = new FileWriter(
    encryption: $encryption,
    mac: $mac,
    macSignature: $pending,
);
$prepared = new PreparedSignature($writer->revision($file), $pending, mac: $mac);
$signed = $prepared->sign($backend);

PdfMacTokenComposer reçoit la clé de chiffrement réelle, le sel KDF de 32 octets et une source aléatoire explicite. Les déclarations d’extension ISO correspondantes et le bit d’autorisation de chiffrement sont exigés. Le MAC est attaché comme attribut CMS non signé et lie à la fois les octets PDF couverts et les octets de signature réels du SignerInfo. Un MAC autonome utilise un emplacement distinct dans le trailer. Chaque révision incrémentale demande un nouveau MAC ; un ancien jeton ne peut pas authentifier des octets ajoutés.

Le guide du bundle explique ces constructeurs et leurs frontières. L’aperçu ci-dessous présente l’exemple de signature non chiffré : il ne demande donc pas de mot de passe et ne prétend pas démontrer le chiffrement.

Vérifier avec des implémentations indépendantes

tools/validate_iso32000_crypto.py produit des fixtures chiffrées et protégées par MAC avec les writers natifs. Il utilise pyHanko pour les déchiffrer et les valider, OpenSSL et Python cryptography pour vérifier les signatures CMS, ainsi que qpdf pour les combinaisons structurelles prises en charge. Il exerce les références croisées classiques, en flux et hybrides, les révisions initiales et incrémentales, la linéarisation et des altérations volontaires.

python tools/validate_iso32000_crypto.py \
  --php php --qpdf qpdf --openssl /usr/bin/openssl \
  --work-dir /tmp/pdf20-crypto

Installez les dépendances de validation verrouillées par le dépôt avant d’exécuter cette commande de développement. Elles ne font pas partie des dépendances Composer de production. Les tests vérifient indépendamment les octets émis ; leur réussite ne rend pas le certificat public de démonstration digne de confiance et ne certifie pas tous les profils PDF.

Références

Les originaux conservés d’ISO/TS 32001 EC3, ISO/TS 32002 EC3, ISO/TS 32003 et ISO/TS 32004 proviennent de la PDF Association. Le catalogue du bundle consigne les identités des sources, les pages physiques et les annotations de correction.

Parcours des tutoriels · Guide du bundle · Sécurité

Voir le résultat PDF

Ce fichier a été généré avec le code de cette documentation. Toutes ses pages sont reproduites ci-dessous ; ouvrir le PDF pour utiliser ses liens et ses signets.

Résultat PDF pdf20-modern-signature : page 1 sur 1
Page 1 sur 1
Utiliser le lecteur PDF du navigateur

Ce lecteur dépend du navigateur. Si le document ne s’affiche pas, utiliser « Ouvrir le PDF » ou « Télécharger ».

Code source complet

Code de roukmoute/pdf · Consulter la source au commit documenté ↗

Lire le script commenté : 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;

Source de ce guide au commit documenté ↗