Le script complet crée deux documents : un PDF protégé par mot de passe et une enveloppe PDF 2.0 non chiffrée qui l'incorpore. La page de l'enveloppe explique comment accéder au contenu ; la collection identifie le fichier à ouvrir. Le contenu incorporé est réellement chiffré avant son insertion.

Exécuter l'exemple

Depuis un checkout du core 0.7.1, avec PHP 8.3 ou supérieur et OpenSSL :

php examples/pdf20-encrypted-payload.php wrapper.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.

Le mot de passe de lecture de démonstration est demo-reader. Il est écrit sur la page et dans le code afin que le PDF téléchargé puisse être inspecté. Il ne convient pas à un document confidentiel. Les clés, sels et vecteurs d'initialisation sont produits par SystemRandomSource.

Chiffrer le vrai document avant de l'incorporer

ModernStandardSecurityHandler construit une opération de chiffrement explicite avec les mots de passe préparés, les permissions PDF 2.0 et le choix de chiffrer les métadonnées. L'exemple utilise R=6 avec AES-256-CBC. FileWriter, auquel l'opération est fournie, écrit le dictionnaire de chiffrement et les chaînes et flux chiffrés du PDF intérieur.

$security = (new ModernStandardSecurityHandler(
    new OpenSslAes256Backend(),
    new SystemRandomSource(),
))->create(
    new PreparedPassword('demo-reader'),
    new PreparedPassword('demo-owner'),
    new Pdf20Permissions(-4),
    new EncryptMetadata(true),
);
$encryptedBytes = (new FileWriter(encryption: $security))->serialize($inner);

Les valeurs ASCII de démonstration sont déjà préparées. PreparedPassword représente ce choix explicite ; son constructeur ne normalise pas un mot de passe arbitraire. Pour les entrées Unicode d'une application, suivre le contrat de préparation du handler choisi. Le PDF intérieur reçoit également son identifiant de fichier explicite dans le trailer.

EmbeddedFileStream reçoit ensuite les octets du PDF chiffré, le type MIME application/pdf et la date source. Le filtre Flate de l'enveloppe compresse ces octets ; il ne remplace pas leur chiffrement. Ajouter uniquement un nom EncryptedPayload à un flux clair n'assurerait aucune protection.

Décrire le payload et fermer ses références

La spécification de fichier contient EF, AFRelationship /EncryptedPayload et le dictionnaire EP :

$descriptor = new EncryptedPayload(new NameType('Standard'));
$specification = new FileSpecification(
    new LiteralString('payload.pdf'),
    $options,
    new AssociatedFileRelationship(AFRelationship::EncryptedPayload),
    encryptedPayload: $descriptor,
);

EP.Subtype /Standard indique le handler réellement utilisé par le PDF incorporé. EP.Version est un nom de version facultatif, distinct d'un nombre réel PDF ; aucun n'est ajouté ici. EncryptedPayload décrit les moyens d'accès au fichier. Cet objet n'invente pas son algorithme et ne chiffre pas ses données.

La même spécification indirecte est placée dans Names.EmbeddedFiles et dans le tableau AF du catalogue. La clé de l'arbre de noms est payload.pdf. L'enveloppe ne contient qu'un seul fichier dans cet arbre, conformément au mécanisme d'enveloppe non chiffrée.

Sélectionner le contenu tout en gardant les instructions lisibles

Collection(view: CollectionView::Hidden, initialDocument: ...) écrit View /H et D (payload.pdf). D reprend la clé de l'arbre de noms, plutôt qu'un chemin de disque ou une référence arbitraire. Le catalogue lie explicitement cette collection, les noms et l'association AF.

L'enveloppe elle-même est écrite sans opération de chiffrement. Sa page reste donc lisible par un processeur qui ne sait pas ouvrir le contenu incorporé. Un processeur capable peut ouvrir automatiquement le document sélectionné ; d'autres lecteurs affichent la page d'instructions et leur panneau de pièces jointes. L'exemple explique aussi l'extraction manuelle.

Le graphe effectif contrôle ces relations lors d'une écriture complète ou incrémentale : descripteur EP, fichier incorporé, unique entrée de noms, sélection D, vue cachée et absence de chiffrement de l'enveloppe. Retirer une relation nécessaire ou remplacer la collection par une sélection incohérente fait échouer l'opération.

Vérifier les deux niveaux indépendamment

L'aperçu ci-dessous rend l'enveloppe, pas le contenu déchiffré. Télécharger le PDF, extraire payload.pdf, puis l'ouvrir avec demo-reader permet de voir la page intérieure. Le téléchargement n'exécute aucune pièce jointe.

tools/validate_pdf20_metadata.py inspecte l'enveloppe avec pypdf, retrouve exactement le même fichier dans l'arbre de noms et dans AF, extrait ses octets, vérifie son dictionnaire R=6 et le déchiffre avec qpdf. Il relit ensuite le texte de la page déchiffrée. Les tests natifs vérifient aussi les enveloppes incohérentes et les changements incrémentaux ; la réussite du rendu de l'enveloppe ne suffit pas à démontrer le chiffrement intérieur.

Sources conservées : ISO 32000-2, pages physiques 109–110 pour l'enveloppe et la table 28 ; spécification de fichier, pages 149–152 ; note sur les fichiers associés, page 10, §4.1.5. Le bundle provient de l'accès sponsorisé de la PDF Association.

Parcours des tutoriels · Métadonnées et parties de document

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-encrypted-payload : 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-encrypted-payload.php
<?php

declare(strict_types=1);

use PDF\Document\Collections\Collection;
use PDF\Document\Collections\CollectionView;
use PDF\Document\Files as Files;
use PDF\Document\ResourceDictionary;
use PDF\Document\Resources\Font;
use PDF\FileStructure;
use PDF\Security as Security;
use PDF\Serialization\FileWriter;
use PDF\Syntax\ContentStreamsResources\ContentStreams;
use PDF\Syntax\ContentStreamsResources\Operators as Op;
use PDF\Syntax\DataStructure\Date;
use PDF\Syntax\DataStructure\Rectangle;
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\Filters\FlateDecode;
use PDF\Syntax\Object\ArrayType;
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\StringObject\HexadecimalString;
use PDF\Syntax\Object\StringObject\LiteralString;

require dirname(__DIR__) . '/vendor/autoload.php';

// The content helper creates ordinary explicit PDF page/font/operator objects.
$createPage = static function (string ...$lines): PageTreeNode {
    // Each explicit page construction supplies complete PDF 2.0 font metrics.
    $font = require __DIR__ . '/fixtures/pdf20-courier-font.php';
    $resources = new ResourceDictionary(new Font(new DictionaryType(new Entry(new NameType('F1'), new IndirectReference($font)))));
    $operators = [];
    foreach (array_values($lines) as $index => $line) {
        $operators[] = new Op\BeginTextObject();
        $operators[] = new Op\SetTextFontAndSize(new NameType('F1'), new NumericType($index === 0 ? 20 : 12));
        $operators[] = new Op\MoveTextPosition(new NumericType(48), new NumericType(780 - 32 * $index));
        $operators[] = new Op\ShowText(new LiteralString($line));
        $operators[] = new Op\EndTextObject();
    }
    return (new PageTreeNode())->addKid(new PageObject(new Rectangle(new NumericType(0), new NumericType(0), new NumericType(595), new NumericType(842)), $resources, new \PDF\Syntax\Object\StreamObject(new ContentStreams(...$operators))));
};
// This is a real encrypted PDF, not an EP label on clear bytes. Demo passwords
// are intentionally public. File keys, salts and IVs come from SystemRandomSource.
$random = new Security\SystemRandomSource();
$security = (new Security\ModernStandardSecurityHandler(new Security\OpenSslAes256Backend(), $random))->create(new Security\PreparedPassword('demo-reader'), new Security\PreparedPassword('demo-owner'), new Security\Pdf20Permissions(-4), new Security\EncryptMetadata(true));
$identifier = new HexadecimalString($random->bytes(16));
$inner = new FileStructure(new FileHeader('2.0'), new FileBody(new DocumentCatalog($createPage('Encrypted payload', 'This text becomes available after password authentication.'))), trailerEntries: new DictionaryType(new Entry(new NameType('ID'), new ArrayType($identifier, $identifier))));
$encryptedBytes = (new FileWriter(encryption: $security))->serialize($inner);
$payload = new Files\EmbeddedFileStream($encryptedBytes, new NameType('application/pdf'), modificationDate: new Date(new DateTimeImmutable('2026-10-14T00:00:00Z')), filters: new FlateDecode());
$specification = new Files\FileSpecification(new LiteralString('payload.pdf'), new Files\FileSpecificationOptions(embedded: new Files\EmbeddedFileBindings(new Files\EmbeddedFileBinding(Files\EmbeddedFileKey::File, $payload))), new Files\AssociatedFileRelationship(Files\AFRelationship::EncryptedPayload), encryptedPayload: new Files\EncryptedPayload(new NameType('Standard')));
$attachments = new Files\EmbeddedFiles(new Files\EmbeddedFileAttachment(new LiteralString('payload.pdf'), $specification));
$collection = new Collection(view: CollectionView::Hidden, initialDocument: new LiteralString('payload.pdf'));
// ISO 32000-2 7.6.7 requires exactly one EmbeddedFiles entry, a catalog AF
// association and a hidden collection selecting that same name. The wrapper
// stays unencrypted so a processor can display these instructions.
$catalog = new DocumentCatalog($createPage('Encrypted PDF payload', 'This unencrypted wrapper contains one encrypted PDF.', 'Extract payload.pdf from the attachments, then open it.', 'Demonstration reader password: demo-reader', 'The payload uses the Standard security handler with AES-256.', 'Some viewers open the selected payload automatically.'), new Entry(new NameType('Names'), new DictionaryType($attachments->nameDictionaryEntry())), new Files\AssociatedFilesEntry(new Files\AssociatedFiles(new IndirectReference($specification))), $collection->catalogEntry());
$output = $argv[1] ?? __DIR__ . '/pdf20-encrypted-payload.pdf';
(new FileStructure(new FileHeader('2.0'), new FileBody($catalog)))->writeTo($output);
echo $output . PHP_EOL;

Source de ce guide au commit documenté ↗