lsnepomuceno / signet-pdf
Signet PDF: sign and verify PDF signatures in PHP. PAdES B-B to B-LTA, incremental updates, framework agnostic.
Requires
- php: >=8.4.1 <8.6
- composer-runtime-api: ^2.2
- ext-fileinfo: *
- ext-gd: *
- ext-json: *
- ext-mbstring: *
- ext-openssl: *
- ext-sodium: *
- ext-zlib: *
- intervention/image: ^3.11
- psr/log: ^3.0
- symfony/console: ^8
- symfony/http-client: ^8
- symfony/http-client-contracts: ^3.6
- symfony/process: ^8
- symfony/uid: ^8
- tecnickcom/tc-lib-pdf-sign: ^1.1
Requires (Dev)
- ext-tokenizer: *
- ergebnis/composer-normalize: ^2.52
- laravel/pint: ^1.30
- pestphp/pest: ^5.0
- pestphp/pest-plugin-type-coverage: ^5.0
- phpstan/phpstan: ^2.2
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- shipmonk/composer-dependency-analyser: ^1.8
Suggests
- ext-gd: To use GD library based image processing.
- ext-imagick: To use Imagick based image processing.
- lsnepomuceno/laravel-a1-pdf-sign: Laravel integration for this package.
This package is auto-updated.
Last update: 2026-08-14 17:18:46 UTC
README
Sign and verify PDF signatures in PHP, from PKCS#12 or PEM, with PAdES profiles,
long-term validation and cryptographic verification. No framework.
Architecture · Public API · Upgrading · Changelog · Signed samples
Installation
composer require lsnepomuceno/signet-pdf
There is nothing to register: no service provider, no facade, no container and
no global state. src/Signet.php wires the default object graph by hand, and
every class can also be built directly, which is what lets an application
register them in its own container instead.
openssl on PATH is not required to sign; it is used only for verifying a
signature and for reading a legacy PFX file. Where it is needed it is needed
properly: ext-openssl being loaded is a different thing from the binary
being installed, and a minimal container commonly has the first without the
second. Validating without it raises MissingBinaryException, and an
environment where proc_open is disabled raises ProcessUnavailableException.
Neither is reported as a signature that failed to verify.
Every exception this package raises implements Exceptions\SignetException, so
an application can handle them as a group rather than by name:
use LSNepomuceno\Signet\Exceptions\SignetException; try { $signet->newSignature()->certificate($pfx, $password)->pdf($path)->sign(); } catch (SignetException $e) { // Every failure this package raises, and nothing else. }
The classes stay granular beneath it. InvalidCertificatePasswordException is
the one worth catching on its own, since a wrong password is the failure a
production application meets most, and it extends
InvalidCertificateContentException so the general catch still works.
Using Laravel?
lsnepomuceno/laravel-a1-pdf-signwraps this package with a service provider, a facade, Artisan commands and upload and response helpers.
What it does
| PKCS#12 and PEM | .pfx, .p12, or a PEM certificate with the key beside it or in its own file |
| Incremental signing | a revision is appended, never a rebuilt document, so earlier signatures and form fields survive |
| PAdES profiles | legacy through pades-b-lta, with RFC 3161 timestamps and long-term validation |
| Visible seals | rendered from the certificate or drawn from your own artwork, on the page you name |
| Template fields | fills a signature field a contract already carries, instead of appending beside it |
| Certification | ISO 32000-1 §12.8.2.2 DocMDP, plus field locks that later signatures honour |
| Encrypted documents | AES-128 and AES-256, signed and re-encrypted under the document's own key |
| Archive maintenance | refresh a B-LTA archive with no certificate and no key material involved |
| Verification | the CMS is actually verified, with the timestamp, the profile and revocation reported |
| ICP-Brasil identity | CPF, CNPJ and the rest, read from the certificate rather than parsed out of a name |
| PDF/A | a signed document stays conformant, measured with veraPDF rather than assumed |
| PDF/UA | measured too: an invisible signature keeps an accessible document conformant, a visible seal does not |
Signing
The fluent builder is the primary API.
use LSNepomuceno\Signet\Signet; use LSNepomuceno\Signet\Enums\SignatureProfile; $signed = new Signet()->newSignature() ->certificate('/path/certificate.pfx', $password) ->pdf('/path/contract.pdf') ->info(name: 'Lucas Nepomuceno', reason: 'Contract') ->profile(SignatureProfile::PadesBB) ->sign(); $signed->save('/path/contract-signed.pdf');
sign() returns the document rather than a transport, so the caller decides
what it becomes: contents() for the bytes, save() for a path, writeTo()
for a destination.
Where the document comes from
A path is the common case, not the only one. from() takes a source, so bytes
in a queue payload, in object storage or in memory never need a temporary file.
use LSNepomuceno\Signet\Io\StringSource; use LSNepomuceno\Signet\Io\FileDestination; $signed = new Signet()->newSignature() ->certificateContents($pfxBytes, $password) ->from(new StringSource($pdfBytes, 'contract.pdf')) ->sign(); $signed->writeTo(new FileDestination('/var/documents'));
Implement Contracts\PdfSource or Contracts\PdfDestination for anything else.
The seal
Omitting seal() leaves the signature invisible, which is still a valid
signature: the seal is an appearance, not part of the cryptography.
use LSNepomuceno\Signet\Data\SealPlacement; ->seal(placement: new SealPlacement(x: 155, y: 250, width: 50, page: 2)) ->seal(placement: new SealPlacement(x: 155, y: 250, width: 50, onEveryPage: true)) ->sealFrom('/path/artwork.png')
page is 1-based and defaults to the last one; a page the document does not
have raises SealPlacementException rather than clamping to the nearest. With
onEveryPage there is still one signature: the widget goes on the first
page and every further page gets a stamp annotation drawing the same
appearance, so the image is embedded once whatever the page count.
Contracts\SealRenderer draws it, so replacing it is a constructor argument
away. That is the route for a corporate logo, a QR code linking to a validation
page, or any layout of your own: render() builds a seal from the certificate,
and fromImage() embeds artwork you already have. The package stays out of the
business of turning HTML into pixels, which would be a large dependency for a
signing library.
Timestamps and long-term validation
Every profile above pades-b-b needs a timestamp authority.
use LSNepomuceno\Signet\Config\SignetConfig; use LSNepomuceno\Signet\Config\SigningConfig; use LSNepomuceno\Signet\Config\TimestampConfig; $signet = new Signet(new SignetConfig( signing: new SigningConfig( timestamp: new TimestampConfig(url: 'https://freetsa.org/tsr'), ), )); $signed = $signet->newSignature() ->certificate($pfx, $password) ->pdf($path) ->profile(SignatureProfile::PadesBLTA) ->sign();
An archive is a chain rather than a state, so it can be extended before the algorithms behind it weaken. No certificate is involved: a DocTimeStamp is signed by the authority, not by the signer, so a scheduled job can do this with no key material anywhere near it.
$signet->extendArchive($path);
PAdES profiles
| Profile | Adds |
|---|---|
legacy |
ISO 32000-1 detached CMS. Widest reader support |
pades-b-b |
CAdES signed attributes, with ESS signing-certificate-v2. Default |
pades-b-t |
plus an RFC 3161 timestamp, so the signing time is attested by a third party |
pades-b-lt |
plus a Document Security Store, so it still verifies after the certificate expires |
pades-b-lta |
plus an archive timestamp over the whole file |
Enums\SignatureProfile owns each level's /SubFilter and what it requires.
Every entry point accepts the enum case or its backing value, so configuration
can stay as plain strings. timestamp() is shorthand for pades-b-t.
Signing into a template's own fields
A contract laid out by someone else arrives with its signature fields already
placed. intoField() fills the one you name instead of appending another beside
it:
foreach ($signet->signatureFields($template) as $field) { $field->name; // 'SignatureManager' $field->isSigned; // false $field->pageNumber; // 3 $field->rectangle; // [30.0, 200.0, 200.0, 250.0] } $signet->newSignature() ->certificate($pfx, $password) ->pdf($template) ->intoField('SignatureManager') ->seal() // drawn into the field's own rectangle ->sign();
A field that is missing or already signed raises SignatureFieldException
rather than falling back to appending. That fallback is the failure this
prevents: a signature that is valid and in the wrong place, with the template's
field still empty.
Certification and locks
use LSNepomuceno\Signet\Data\FieldLock; $signet->newSignature()->certificate($pfx, $password)->pdf($path) ->certify('form-filling') // no-changes | form-filling | annotations ->lock(FieldLock::only(['Amount'])) // ->lock() for every field ->sign();
A certification governs the whole document; a lock governs the fields you name.
The half that matters is the reading: a later signature into a field an
existing lock covers is refused, rather than producing a document whose earlier
signature silently stopped verifying. A certification also has to be the first
signature and there can be only one, both enforced with
CertificationException.
Encrypted documents
A password-protected document is signed and re-encrypted under its own key, so the file stays consistent:
$signet->newSignature() ->certificate($pfxPath, $certificatePassword) ->pdf($path, 'the document password') ->sign();
The document's password and the certificate's are different things and are passed separately: one opens the file, the other unlocks the key that signs it. AES-128 and AES-256 are supported. RC4 is refused, because signing it would mean writing RC4 back into a document in order to sign it.
Shortcuts
$signet->signFromFile($pfxPath, $password, $pdfPath); $signet->signFromPem($pemPath, $password, $pdfPath, $privateKeyPath);
Verifying
$report = new Signet()->validate('/path/contract-signed.pdf'); $report->isValid(); // every signature verifies against the bytes it covers $report->count(); // how many signatures the document carries $report->signers(); // structured signer identity $report->isCertified(); // whether the author certified the document
isValid() means the CMS actually verifies, not that a subject line could be
parsed. Each signature also reports what the document can prove about it:
$signature = $report->latest(); $signature?->attestedAt(); // the authority's time, or null. Never the signer's own clock $signature?->profile; // the level it actually satisfies, not the one it claims $signature?->isRevoked(); // what the document's own OCSP responses and CRLs say $signature?->coversWholeDocument;
Revocation is evaluated from the material the document carries, and the material
is verified against the issuer before it is believed. Nothing is fetched:
validation makes no network request and cannot be made to. DocTimeStamps are
classified separately and excluded from isValid().
Whether to accept the signer is a separate question, answered against roots you name:
use LSNepomuceno\Signet\Validation\TrustStore; $store = TrustStore::fromDirectory('/etc/ssl/anchors'); // or ::fromFile($pem), ::fromPem($bundle), ::empty() $report = $signet->validate($path, $store); $report->isTrusted(); // ?bool. null when no store was given: nobody was asked
Note
The package ships no trust store and will not. A bundled one goes stale
between releases, and shipping it would make this package's release cadence
the thing that decides whose signatures you accept. For ICP-Brasil, fetch the
current chain from the ITI and keep it with your configuration. OpenSSL does
the path validation, so intermediate validity, basicConstraints, key usage
and name constraints are all checked rather than approximated.
An untrusted signature is not an invalid one: the two questions are independent.
signatureFields() lists the fields a document declares, signed or not, which
is the question that comes before signing into a template someone else laid out.
Certificates
$sealed = $signet->encryptCertificate($pfxPath, $password); // $sealed->certificate, $sealed->password, $sealed->hash $certificate = $signet->decryptCertificate($sealed->hash, $sealed->certificate, $sealed->password);
The hash is the key, so keep it somewhere other than the ciphertext it opens. Without it the pair cannot be read back, by you or by anyone else.
vault() exposes the same encryption directly. New material is sealed with
XChaCha20-Poly1305 through ext-sodium, so the package assembles no
cryptographic construction of its own.
Anything sealed by an earlier release still opens, under the key it was
sealed with: the payload carries its version and withKey() picks the reader
from the key's length. That older envelope is the one
lsnepomuceno/laravel-a1-pdf-sign writes, so material sealed by that package
opens here too. The reverse no longer holds until it learns the current
envelope. See
0103.
Contracts\Encrypter is the seam if you would rather use your own scheme, your
own key management or your own rotation. It is three methods.
ICP-Brasil
A Brazilian certificate carries the holder's identity in
subjectAlternativeName, not in the subject, and PHP renders every one of those
fields as othername:<unsupported>. This package reads them:
$signer = $signet->validate($path)->signers()[0]; $signer->icpBrasil?->cpf; // '11144477735' $signer->icpBrasil?->cnpj; // the company, for an e-CNPJ $signer->icpBrasil?->formattedRegistry(); // '11.222.333/0001-81' $signer->name(); // the name, without the number glued to it
A certificate can also be checked against the rules its own specification states, before anything is signed:
$report = $signet->icpBrasil($pfxPath, $password); $report->conforms(); // required fields, widths, alphabet, check digits, the CPF in two places agreeing $report->messages(); // one line per finding, naming the field
Warning
conforms() is not isTrusted(). Every rule it checks is decidable from
the certificate alone, so a self-signed certificate built to satisfy them will
conform. Whether the chain reaches an ICP-Brasil root is TrustStore's
question, and it is a different one.
Command line
vendor/bin/signet sign contract.pdf --certificate cert.pfx --password-env CERT_PASSWORD vendor/bin/signet sign contract.pdf -c cert.pfx -o signed.pdf -p pades-b-t --tsa https://freetsa.org/tsr vendor/bin/signet verify contract-signed.pdf --trust /etc/ssl/anchors vendor/bin/signet verify contract-signed.pdf --json vendor/bin/signet fields contract.pdf vendor/bin/signet check
The password is read from an environment variable and never from an argument: a
command line is visible in ps and in shell history.
verify puts the verdict in the exit status, so a build can gate on it: 0
every signature verifies, 1 one does not, 2 the document could not be read.
check reports what this package needs from the environment, before anything is
signed. It exists because a missing openssl binary once made validation report
every signature as invalid, in silence.
Testing your own code
Signing for real in an application's test suite means a PKCS#12 bundle in its repository and a real CMS built for every case that merely passes through. Neither is necessary:
use LSNepomuceno\Signet\Testing\FakeCertificateReader; use LSNepomuceno\Signet\Testing\FakePdfSigner; $signer = new FakePdfSigner(); $signet = new Signet(signer: $signer, certificateReader: new FakeCertificateReader()); // The code under test, unchanged. $signet->newSignature()->certificate('anything.pfx', '')->pdfContents($pdf)->sign(); $signer->assertSigned(); $signer->assertSignedWithProfile(SignatureProfile::PadesBT); $signer->assertNothingSigned();
Testing\FakeProcessRunner and Testing\LocalTimestampAuthority substitute the
other two seams, so a suite can exercise B-T and above without reaching a real
authority.
Audit trail
Off by default: a package that logs unasked fills somebody's disk.
use LSNepomuceno\Signet\Support\SigningLog; use LSNepomuceno\Signet\Signing\IncrementalSigner; $signer = new IncrementalSigner(..., log: new SigningLog($psrLogger));
The allowlist is the feature. This package handles bundles, private keys and
passwords, and #[\SensitiveParameter] keeps a value out of a stack trace while
having nothing to say about a line written to disk. The context is filtered
against a list of keys that may appear rather than a list that may not: a
denylist is how the next property added to a data object ends up in a log file.
Compatibility
| Package | PHP | Notes |
|---|---|---|
| ^1 | 8.4.1 – 8.5 | the current line |
ext-openssl, ext-gd, ext-mbstring, ext-zlib, ext-fileinfo and
ext-json are required. The openssl binary on PATH is needed only by the
legacy certificate reader and by signature verification.
The floor is 8.4.1 rather than 8.4 because symfony/process 8.1.0 requires
it, so a platform of 8.4.0 cannot resolve this package at all. A declared floor
is a promise that the package installs there
(docs/decisions/0005-php-and-laravel-floor.md).
Coming from lsnepomuceno/laravel-a1-pdf-sign? That package keeps the facade,
the service provider and the Artisan commands, so moving is only worth it if you
want to drop Laravel. The two are still separate implementations rather than a
core and an integration: what binds them is the signed output and the encryption
envelope, not a dependency. UPGRADE.md maps the surface across.
Verified, not asserted
Signed output is checked against tools that were not written here, because a validator sharing its assumptions with the signer proves very little:
poppler pdfsig |
reads the samples independently, and has caught defects the suite passed straight through |
| veraPDF | decides PDF/A and PDF/UA conformance, in CI and in the development image |
| pyHanko | enforces /DocMDP, and signs the documents this package's validator is read against |
| qpdf | checks structure, and reads back documents this package encrypted |
samples/ holds one signed document per profile plus a
six-signature document. Open them in any reader to see what the package
produces.
Documentation
| Read | For |
|---|---|
docs/spec/public-api.md |
what the package exposes, and what changing it costs |
docs/spec/invariants.md |
the rules that break the product when violated |
docs/spec/conventions.md |
how the code is written |
docs/spec/quality-policy.md |
the gates, and why each sits where it does |
docs/decisions/ |
why the design is what it is, one numbered file per decision |
ARCHITECTURE.md |
the index |
Contributing
Patches are expected to come with tests. composer check runs everything CI
runs: Pint, PHPStan at level max with no baseline, a dependency report and the
suite. Everything runs in Docker, because the floor is PHP 8.4.
docker compose -f .docker/compose.yaml run --rm php composer check
See CONTRIBUTING.md.
Security
Found a vulnerability? Please follow SECURITY.md rather than opening a public issue.
Licence
MIT. See LICENSE.md.