Releases: lsnepomuceno/signet-pdf
Release list
2.0.1
Nothing in src/ differs from the 2.0.0
tag. This release exists so that ^2 resolves to that code.
composer require lsnepomuceno/signet-pdf:^22.0.0 was tagged twice. The first tag pointed at a commit that predated
two-phase signing, the signature policy in validation and the security store key
fix, all three of which the 2.0.0 notes describe, so it was deleted and re-cut
onto the commit that carries them. Packagist read GitHub's tag list during the
thirty-nine seconds between the two pushes and cached the earlier commit against
the version, and a published version's reference cannot be corrected from the
repository side.
So what 2.0.0 promised is what this release delivers, and the 2.0.0 notes are
the ones to read. If you installed 2.0.0 from Packagist at any point,
composer update lsnepomuceno/signet-pdf moves you onto it.
Documentation: lsnepomuceno.github.io/signet-pdf.
2.0.0
The first stable release of the standalone package: signing, validation and the
command line, with no framework anywhere in src/.
composer require lsnepomuceno/signet-pdf:^2Read UPGRADE.md
before moving from 1.x. Every breaking change is answered there with the
replacement rather than the removal.
The whole of what changed is in
CHANGELOG.md.
The headlines:
- Signing appends a revision, never a rebuild, so an earlier signature,
every annotation and every form field survive a second one. - The private key does not have to be in this process. Signing is
prepare()andcomplete(): the first appends the revision and fills the
/ByteRange, which is where the offsets stop moving, and hands back the
digest of the covered bytes. The second is one fixed-width overwrite and
takes no certificate at all, so a key on an A3 token, in an HSM or behind a
cloud service signs where it lives. - PAdES
legacythroughpades-b-lta, including on an encrypted
document: the security store and the archive timestamp are encrypted with
everything else, and only the timestamp token stays in the clear, as
ISO 32000-1 §7.6.2 requires. - Validation needs no
opensslbinary if you do not want it to.
NativeSignatureVerifierchecks the signed attributes, the message digest,
the content type and the ESSsigning-certificate-v2attribute through
ext-openssl, and is checked against the binary on every sample, a foreign
pyHanko document and three tamper cases. - Validation reports the signature policy a signer declared, the
signature-policy-identifierof RFC 5126 §5.8.1. A verifier in Brazil looks
for it before calling a signature ICP-Brasil conformant, and until now an
application could not see it at all. - A signature field can be created, not only filled, with no certificate
involved. - A visible seal keeps PDF/A and PDF/UA conformance, measured with veraPDF
rather than asserted. - Encrypted documents that pack their objects into object streams sign,
which is what a password-protected export from a word processor looks like. - ICP-Brasil identity, including the alphanumeric CNPJ that takes effect in
2026.
Documentation: lsnepomuceno.github.io/signet-pdf.
The 1.x documentation is archived at
/v1/.
This tag was re-cut on 2026-08-20. A 2.0.0 published two days earlier
pointed at an earlier commit, and work kept merging after it: two-phase
signing, the signature policy above, and a fix to the document security store,
which keyed its entries by a signature recovered with a trailing-zero trim and
so filed the validation material of about one signature in 256 under a hash of
something that does not exist. If you installed 2.0.0 in that window,
composer update lsnepomuceno/signet-pdf gets the release this page describes.
v2.0.0-rc.1 - Encryption on the platform, a bounded regional layer, and validation that says what changed
A release candidate for 2.0.0. Please exercise the certificate vault
migration before this goes stable, for the reason below.
composer require lsnepomuceno/signet-pdf:2.0.0-rc.1Composer will only resolve it with minimum-stability set accordingly, which is
what makes it safe to try.
The break nothing can check for you
CertificateVault::create() keeps its signature and returns a 32-byte key
where it returned 16. A storage column sized for the old width truncates it
silently, and no type checker can see that: the backward-compatibility check
reports nothing about it and is right to.
That checker also skipped seventeen files it could not compile, alongside the
twelve breaks it did report, so twelve is a floor rather than a total. Read
UPGRADE.md,
not the green check.
Nothing has to be re-encrypted. A key issued by 1.x keeps opening what it
sealed: the payload carries its version and withKey() picks the reader from
the key's length.
Breaking
-
ext-sodiumis now required, and new certificate material is sealed with
XChaCha20-Poly1305 instead of an AES-128-CBC and HMAC construction this package
assembled itself. Encrypt-then-MAC written in application code is the shape
that fails quietly, and encryption at rest is a convenience beside a PDF
signing package rather than the product. Material sealed here no longer opens
inlsnepomuceno/laravel-a1-pdf-signuntil that package learns the same
envelope; the other direction is unaffected. (0103) -
The ICP-Brasil layer moved to
IcpBrasil\, eight class names, behaviour
identical. If you do not sign Brazilian documents, none of it reaches you.
(0104) -
SealPlacement::LAST_PAGEis gone, replaced byEnums\SealPage::Last.
A numbered page is unchanged andSealPage::Lastis still the default, so a
placement that never named a page needs no edit. (0105)
Validation now says what changed, not only that something did
Six additions, none of which costs a consumer anything.
-
onlyAddedSignatures().coversWholeDocumentsaid bytes were appended
after a signature and never what they did, which is the live attack surface
for PAdES: append an annotation over the payment terms and the signature still
verifies, because the new bytes are outside its/ByteRange. Each revision is
now reported with the objects it defines and what they touched. True is not
a verdict of safe: a counter-signer produces the same shape. (0110) -
The
/ByteRangeis checked before it is believed. It is the one input to
validation an attacker writes, and everything downstream derived from it
unchecked: which bytes are hashed, and where the CMS is read from. Six
conditions now, the decisive one being that the gap is the value of a
/Contentskey rather than any window holding hexadecimal. Nothing changes
for a well-formed document. (0107) -
Enums\ValidationFinding. Ten cases naming facts the validator already
established, where the only ways to reach them were a dozen properties or
matching English in$error. Exactly one decides validity, and the enum
carries no severity: how muchNotTrustedmatters is not this package's call.
signet verify --jsonprints them. (0106) -
missingValidationMaterial().hasLongTermMaterial()answers presence;
B-LT promises a verifier could decide offline. A store with one certificate, a
/VRIentry and no OCSP satisfies the first completely. (0109) -
verifiableUntil(), so a document can be re-stamped before its chain can
no longer be built. An archive timestamp renews the horizon. Null means
unanswerable, never "never". (0108) -
$messageDigest, short and stable enough for an audit trail to record and
compare later. Not proof on its own. (0108)
Also
Every docblock in src/ now explains its design without naming the framework
the package was extracted from, and the arch rule that enforced that for imports
covers prose as well.
lsnepomuceno/laravel-a1-pdf-sign is not a consumer of this package. The
documentation said it was; it is a separate implementation sharing a lineage,
and that is now stated wherever it was claimed otherwise.
Verification
674 tests on PHP 8.4 and 8.5 with --fail-on-skipped, PHPStan at level max with
no baseline, type coverage at 100%, and zero warnings. Checked independently
against veraPDF, qpdf, pyHanko, poppler's pdfsig and the Arlington PDF Model.
Full detail in
CHANGELOG.md.
v1.0.1 - A floor the package can actually be installed on
A one-line fix, and the reason it is worth a release of its own.
The declared floor was not installable
1.0.0 declared php: >=8.4. That was not true: symfony/process 8.1.0 requires php >=8.4.1, so resolving against a platform of 8.4.0 fails outright.
Problem 1
- symfony/process v8.1.0 requires php >=8.4.1 -> your php version (8.4.0) does not satisfy that requirement.
A declared floor is a promise that the package installs there. 1.0.1 makes it one: >=8.4.1 <8.6.
Why the matrix did not catch it
CI installs the newest patch of each minor, so 8.4 means 8.4.24 there, and the lower bound is never the version anything resolves against. Every cell was green and the floor was still wrong.
Dependabot found it on its first run, because resolving a dependency update is the one job that starts from the declared floor rather than from an installed one. docs/decisions/0005-php-and-laravel-floor.md records the outcome so the next person to widen the constraint knows what to check.
Nothing else changed. No behaviour, no API.
v1.0.0 - The core, without the framework it grew up in
Signet PDF signs and verifies PDF signatures in PHP: PAdES B-B to B-LTA, by incremental update, with no framework.
It is the core of lsnepomuceno/laravel-a1-pdf-sign, extracted so it can be used from Symfony, Slim, a plain script, or another library. That package remains and becomes the Laravel integration over this one.
composer require lsnepomuceno/signet-pdfWhat it does
- Signs by appending a revision, never by rebuilding the document. The original bytes survive byte for byte, so annotations, form fields and every earlier signature stay intact and a second signature does not invalidate the first (ISO 32000-1 §7.5.6).
- PAdES profiles
legacy,pades-b-b,pades-b-t,pades-b-lt,pades-b-lta, including the Document Security Store and the archive timestamp. - Verifies cryptographically. "Valid" means the CMS actually verifies.
- Certification signatures (
/DocMDP) and field locks (/Lock), enforced rather than merely written. - ICP-Brasil identities read out of the certificate's own extensions.
- A command line:
signet sign,verify,fields,check.verify --jsonputs the verdict in the exit status, so a build in any language can gate on it.
What "framework agnostic" means here
Five rules, one per piece of code that had to move, in 0100. The core returns no HTTP, reads no configuration, has no container, opens no connection or process outside its two contracts, and keeps a fluent builder of its own.
A token walk in tests/Project/ArchTest.php fails on any Illuminate, Laravel or Orchestra import in src/. It is a walk rather than an arch rule because an arch rule can only be pointed at symbols that exist, and the whole point is that these do not.
Symfony is the only framework vendor (0101): process, http-client, uid, console. One exception, argued and recorded: psr/log, for the optional audit trail.
Moving from the Laravel package
UPGRADE.md maps it. The short version: the namespace becomes LSNepomuceno\Signet\, the facade becomes an object you construct, and configuration becomes value objects.
Certificates encrypted by either package open in the other. Support\OpensslEncrypter writes the same envelope Illuminate\Encryption\Encrypter does, byte for byte, because an application moving between the two cannot re-encrypt material whose plaintext it no longer holds.
Signed output is unchanged. The bytes this package emits are the bytes the Laravel package emitted, which is what samples/ and the pdfsig cross-check are for.
Verification
627 tests on PHP 8.4 and 8.5, PHPStan at level max with no baseline, type coverage at 100%, and zero warnings: phpunit.xml fails the run on any diagnostic, and a call whose failure is an expected answer goes through Support\Probe::run() rather than the @ operator, which does not actually stop PHPUnit reporting it.
What the suite is checked against, none of which ships: qpdf for structure, veraPDF for PDF/A and PDF/UA, pyHanko for /DocMDP, poppler's pdfsig as an independent reader, and the PDF Association's Arlington PDF Model, which checks each object against the specification's own grammar.