Changelog
The canonical file is CHANGELOG.md in the repository root, where GitHub renders it and where tooling looks for it. This page is that same file, published as part of the manual rather than left as a neighbour of it (0112).
Every release, and what it costs to move to it.
This file is the summary. The reasoning behind a change lives in docs/decisions/, and the mechanics of upgrading live in UPGRADE.md, which is where a breaking change is explained rather than merely listed.
Semantic versioning, and the public API is what docs/spec/public-api.md says it is. Adding to it is a minor release; changing it is a major one. Testing\ ships and counts: consumers test their own signing paths with it.
The format follows Keep a Changelog.
2.0.1 - 2026-08-20
Nothing in src/ differs from the 2.0.0 tag. This release exists so that ^2 resolves to that code.
2.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 the tag list from GitHub during the thirty-nine seconds between the two pushes, and cached the earlier commit against the version. A published version's reference cannot be corrected from the repository side, so the answer is a version it has not seen yet.
2.0.0 - 2026-08-20
The first stable release of the standalone package. Everything below shipped in one release rather than in a series after it, because the backlog was closed first: what remains open needs a change in tecnickcom/tc-lib-pdf-sign that no work here can substitute for (#48, #56, #59).
Added
The private key does not have to be in this process. Signing is
prepare()andcomplete(), andsign()is the two of them with nothing waiting in between. The first phase appends the revision and fills the/ByteRange, which is where the offsets stop moving: what comes back is a complete document with an empty/Contents, and finishing it is one fixed-width overwrite that can happen in another process, hours later.It takes no certificate at all.
Data\PreparedSignaturecarries the document, the byte range, the reserved width and the digest of the covered bytes, and that digest is exactly themessage-digestthe finished CMS commits to. It survivesserialize(), so it crosses a queue; usually onlydigestBase64()travels and the document stays where it is.pades-b-ltandpades-b-ltawork this way too, with no certificate in the second phase either: the chain the security store needs is read back out of the CMS that was handed in. For the synchronous case,Contracts\SignatureProduceris the seam insidesign()itself, andSigning\Cades\CadesBuilderis the default behind it.This is what makes a key on an A3 token, in an HSM or behind a cloud service usable (#44). Handing out the signed attributes for an external key to sign directly is the deeper split, it needs a change in the CMS library underneath, and this is its prerequisite (#59) (0116).
Validation reports the signature policy a signer declared.
Data\SignatureDetails::$signaturePolicycarries thesignature-policy-identifierof RFC 5126 §5.8.1: the OID naming a policy document, the digest of that document, the algorithm behind it, and thesp-uriqualifier when there is one.It matters in Brazil, where a verifier looks for it before calling a signature ICP-Brasil conformant: a signature carrying none is cryptographically fine and still reported as conformant to nothing. Until now an application could not see it at all.
What the document says, not a verdict. The OID is not matched against a table of known policies, nothing claims the policy was satisfied, and the URI is not fetched, because the network stays behind the injected transport. Declaring a policy is the other half and is not here: the attribute is signed, so it has to be contributed before the attributes are signed, and the CMS library underneath exposes no way to do that (#56).
Validation no longer needs a process.
Contracts\SignatureVerifieris a seam like every other one here, with two implementations behind it:Validation\OpenSslCliSignatureVerifier, which asks theopensslbinary and stays the default, andValidation\NativeSignatureVerifier, which answers through ext-openssl and spawns nothing.The point is a host where
proc_openis disabled, where this package signed perfectly well and could not validate at all. Selecting the native one is the application's decision rather than a fallback, because an environment change should not silently change which code decides whether a signature is valid.It checks the signature over the re-tagged
signedAttrs, themessage-digestattribute against the covered bytes, thecontent-type, and the ESSsigning-certificate-v2attribute against the certificate that verified; every one of those is a way to produce a false valid by omission. An algorithm it cannot express, RSASSA-PSS, raisesExceptions\VerificationUnsupportedExceptionrather than reporting the signature bad. The two implementations are put to every sample, the foreign pyHanko document and three tamper cases, and a disagreement fails the build (0114).A visible seal keeps PDF/UA conformance. It cost two clauses of ISO 14289-1, and both were a set of keys this package did not write rather than anything inherent to signing. The widget is now nested in a
Formstructure element reached through an/OBJR, with/StructParentand a/ParentTreeentry pointing back at it (7.18.1), and every signature field carries a/TUdescription holding the signer and the reason, which is what a screen reader announces where a sighted reader sees the seal (7.18.4).Only for a document that is already tagged: an untagged one has no structure tree to extend and nothing invents one, so a document that was never accessible does not come back claiming to be. A
/ParentTreesplit across/Kidsis left alone for the same reason (0113).A field the document already carried gets both as well. Filling one reuses the widget a template laid out, so neither key was written for it: the description was absent, and the
/ParentTreeentry pointed at a widget carrying no/StructParent, which is half a structure tree. A description already there is replaced rather than kept, because a template describes the field it laid out and what it wrote describes the empty state: a screen reader announcing "sign here" over a signed field tells the one user 7.18.4 exists for something untrue.Signet::addSignatureField()writes one too, naming the field, since a field with no signature has no signer to name yet.The documentation site says which release it documents. It publishes nineteen pages off
mainand not one of them named a version, so a reader who installed^1was reading pages written against2.xwith nothing on the page to say so. The current line is at the root and the1.xline is archived beside it under/v1/, built from that tag's own markdown, with a version switcher in the navigation on both.CHANGELOG.mdandUPGRADE.mdare pages of the site as well, under/releases/, and stay canonical in the repository root where GitHub renders them. The four pages that linked to../../UPGRADE.mdand reached nothing now point at a page, and theignoreDeadLinksexception that excused that shape of link is gone (0112).A signature field can be created, not only filled.
intoField()filled a field a template already carried andsignatureFields()listed them, which is half a workflow: the layout had to happen in whatever produced the PDF.Signet::addSignatureField()andsignet field:addlay one out here.No certificate is involved, so a service that prepares documents for signing needs no key material. It is a revision like any other, so a field added to a signed document leaves that signature verifying. The placement vocabulary is the seal's, rotation and crop box included, rather than a second one for the same question.
The guards are the interesting part: a name already in use is refused, and so is a document certified "form-filling", which permits filling the fields it already carries and not adding one (ISO 32000-1 Table 254). The two refusals have different fixes and say which (0111).
pades-b-ltandpades-b-ltasign an encrypted document. They were refused, accurately: both append a revision of their own, the security store and the archive timestamp, and neither ran what it wrote through the cipher that already encrypted everything else a revision emits. The cipher now reaches both writers, so an AES-128 or AES-256 document signs at every profile andqpdf --checkdecodes the result with its password.One thing stays in the clear on purpose: ISO 32000-1 §7.6.2 exempts the
/Contentsstring of a signature dictionary, and an archive timestamp is a signature dictionary, so the token is readable while the field around it is encrypted.Signet::validate()andSignet::extendArchive()take an optional document password, andsignet verifyandsignet extendtake--document-password-env. A signature verifies without one, because its own bytes are never encrypted; the store's OCSP responses and CRLs are encrypted like every other stream, so without the password the report says revocation is unknown rather than that the document carries nothing.An encrypted document that packs its objects into object streams can be signed. That is what a password-protected export from a word processor looks like, and
Signing\Incremental\DocumentReaderrefused it. Both halves already existed and only needed to meet: the container stream is now decrypted with its own object number before it is unpacked, because an object stream is encrypted as a stream like any other and the objects packed inside it are not encrypted individually (ISO 32000-1 §7.5.7 and §7.6.2). RC4 stays refused, and the/Encryptdictionary is refused if a producer packs it, which no conforming one does.The seal is placed against
/CropBoxand/UserUnit, not only/MediaBox.grep -rn 'CropBox\|UserUnit' src/used to return nothing, and both entries turn up in the documents this matters most for: architectural drawings, engineering plots, anything printed at A1 or A0./CropBoxis the region a reader displays (§7.7.3.3), soxandyare now measured from its corner and it is intersected with/MediaBoxas the clause requires./UserUnitmultiplies every coordinate on the page (§14.11.1), so sizes and offsets are divided by it andwidth: 120means 120 points on paper rather than 60 on an A0 plot at/UserUnit 2.A page declaring neither produces exactly the bytes it did before, which is asserted rather than assumed. A seal that would fall outside the visible area raises
SealPlacementExceptionrather than being written off the page, which is 0017's rule one level down from the page it settled.signet signreaches what the library reaches. It took five options while the builder took considerably more, so a team wanting a stamped, certified signature had to write PHP and a Composer autoload for something the library does in one call. Seventeen options now, each named after the call it maps onto:--name,--reason,--location,--contact;--seal,--seal-image,--seal-page,--seal-every-pageand the four coordinates;--certify,--lock,--into-field,--field-name; and--document-password-env, which follows the existing--password-envprecedent rather than taking a second secret on a command line wherepscan read it.--into-fieldand--field-nameare refused together rather than resolved by precedence, which would create a field beside the one the caller meant to fill. There is no--seal-placement=<corner>, which the issue asked for:Data\SealPlacementis absolute user space, resolving a named corner needs the page box, and that arithmetic belongs with the crop box and/UserUnitwork rather than in a command./DocMDPand field locks are evaluated at validation time, not only at signing time. The signing side has enforced both since 2.0; validation reported the inputs and stopped, so a document certified asno-changesand then modified by something that is not this package validated withisValid()true and achangesAfterarray every application would have interpreted the same way. Two newEnums\ValidationFindingcases,CertificationViolatedandLockedFieldChanged, raised byValidation\CertificationEvaluator.An archive timestamp is not a violation at any level, including
no-changes, whileSigning\ArchiveExtenderstill refuses to write one there. ETSI EN 319 142-1 permits a DocTimeStamp over a certified document because it adds no content, so a document from a conforming archiver must not be flagged; producing one is the other half of the question and refusing is the conservative side of a conflict between two standards (0012).Data\SignatureReportgains$documentFindings, appended, for findings established from the bytes rather than from one signature, and ahas()method matching the oneSignatureDetailsalready had.toArray()gains a key, which is a shape change for anyone consuming it.Signing\Incremental\FormFieldReaderis new: a lock names fields of any kind, andSignatureFieldReaderkeeps only/FT /Sig.The certificate chain can be supplied from outside the bundle.
Signing\PendingSignature::chain(...$paths)andchainContents(...$bytes)take PEM or DER, one certificate per blob or a concatenated bundle, in any order. This is the normal case for an ICP-Brasil e-CPF exported from a browser or a token, which holds the leaf and nothing else: the intermediates are published by the AC and are not in the file, so the DSS apades-b-ltdocument carried was incomplete, revocation could not be checked for a signer whose issuer was absent, and validation reportedChainDoesNotReachRootfor a signature that would otherwise be fine.The supplied certificates are put in issuer order by
Validation\ChainBuilder, since the store's collector reads each certificate's neighbour as its issuer, and deduplicated against the bundle by the digest of their DER. One that issued nothing in the signer's chain is refused rather than embedded.Config\CertificateConfig::$chainPathsconfigures it once for an application whose signers share an AC, andsignet sign --chainis repeatable.Enums\ValidationFindingandSignatureDetails::findings(). The validator computed a great deal more thanisValid()reports, and the only ways to reach it were reading a dozen properties or matching on the English in$error. Nine cases name the facts it already established, anddecidesValidity()marks the one that turnsisValid()false. The other eight are for an application's own policy, which is why the enum carries no severity (0016).SignatureReport::findings()unions them across the document, andsignet verify --jsonprints them, so a build can gate on a revoked signature specifically rather than on the exit status alone. (0106)ValidationFinding::ByteRangeNotSound. The/ByteRangeis the one input to validation an attacker writes, and everything downstream derived from it unchecked: which bytes get hashed, and where the CMS is read from. Six conditions are now checked at extraction, the sixth being that the gap is the value of a/Contentskey rather than any window in the document holding hexadecimal. Nothing changes for a well-formed document. (0107)SignatureDetails::$messageDigestand$digestAlgorithm. The digest the signer put their name to, lowercase hex, short and stable enough for an audit trail to record and compare later. Not proof on its own: it says what the signature claims, and whether the signature is worth believing is$verified's question.verifiableUntil(), on bothSignatureDetailsandSignatureReport. When a signature stops being verifiable, so a document can be re-stamped before its chain can no longer be built. The chain's earliest expiry rather than the leaf's, and at document level an archive timestamp renews the horizon, which is what it is for. Null means unanswerable, never "never". (0108)SignatureReport::missingValidationMaterial()andisSelfContained().hasLongTermMaterial()answers presence; B-LT promises a verifier could decide offline. A store with one certificate, a/VRIentry and no OCSP response satisfies the first completely and leaves an offline verifier unable to decide anything. A list of what is missing rather than a boolean, because "not self-contained" gives an operator nothing to do. It cannot check that each certificate has a matching OCSP or CRL, which needs the store's objects decoded, and both docblocks say so. (0109)SignatureDetails::onlyAddedSignatures(),$changesAfter,Validation\RevisionAnalyzerandEnums\RevisionChange.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, andonlyAddedSignatures()is the predicate an application asks. True is not a verdict of safe: a counter-signer produces the same shape. It reads objects rather than the object graph, and the limits are stated. (0110)Enums\SealPage::First, which was previously unsayable. It is the first page the page tree declares, which is the lowest-numbered page object only when the producer wrote them in order.Support\SodiumEncrypter, aContracts\Encrypteroverext-sodium.Support\OpensslEncrypterstays as the reader for the earlier envelope.
Changed
Contracts\PdfSignerhas two more methods,prepare()andcomplete(), so an application implementing it by hand has to grow them.sign()keeps its signature and its behaviour,Testing\FakePdfSignerships both already, withassertPrepared()andassertCompleted()beside them, andSigning\IncrementalSignertakesContracts\SignatureProducerwhere it took the concreteSigning\Cades\CadesBuilder, which still satisfies it.UPGRADE.mdcarries the path.Validation\SignatureVerifierisValidation\OpenSslCliSignatureVerifier, behindContracts\SignatureVerifier. The class is unchanged and the name says which of the two implementations it is, the wayCertificates\OpenSslCliCertificateReaderdoes.PdfSignatureValidatortakes the contract, so anyone constructing it by hand is affected; nothing changes for a caller going throughSignet.UPGRADE.mdcarries the replacement.An archive timestamp now reports its own time.
Data\SignatureDetails::$stampedAtandattestedAt()carry a DocTimeStamp's genTime, where both were null for one before. Nothing stamps an archive timestamp, sotimestampVerifiedstays null for it, andattestedAt()reads its ownverifiedinstead. This is additive for a caller reading a signature, and it is what--if-duerests on: the one entry whose time comes from an authority was the only entry in a report with no time at all.A timestamp authority that did not answer arrives as
SignatureTransportExceptionagain.Signing\Cades\CadesBuilderandSigning\Incremental\DocTimeStampWriterwrapped everyThrowablefrom the transport in aProcessRunTimeException, which names a fault that did not occur: no process is run to fetch a timestamp (0008). Both now let that one class through and keep wrapping everything else. A caller catchingProcessRunTimeExceptionaround apades-b-tor higher signature to handle an unreachable authority has to catchSignatureTransportExceptioninstead; both implementExceptions\SignetException.Certificate material is sealed with XChaCha20-Poly1305 through
ext-sodium, 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.Nothing has to be re-encrypted. The payload carries its version and
CertificateVault::withKey()picks the reader from the key's length, so a key issued by 1.x keeps opening what it sealed.create()now returns a 32-byte key where it returned 16, so storage sized for the old width needs widening. Material sealed here no longer opens inlsnepomuceno/laravel-a1-pdf-signuntil that package learns the same envelope; the other direction, which is the one a migration needs, is unaffected. (0103)The ICP-Brasil layer moved to
IcpBrasil\, and the redundant prefix came off its class names. Eight public names changed and behaviour did not.Signet::icpBrasil()andData\Signer::$icpBrasilare unchanged, so code reaching the layer through the entry point needs no edit. If you do not sign Brazilian documents, none of it affects you. (0104)SealPlacement::$pageisEnums\SealPage|int. A page number still means what it always did andSealPage::Lastis still the default, so a placement that never named a page needs no edit. A page arriving from configuration or from a request now has to be resolved at your edge rather than cast toint. (0105)
Removed
ext-sodiumis now required. It ships with PHP and has since 7.2, so on most systems this changes nothing, but a build compiled without it now fails atcomposer installinstead of at runtime. (0103)Data\SealPlacement::LAST_PAGE, replaced byEnums\SealPage::Last. (0105)
Fixed
About one B-LT document in 256 carried a security store keyed to no signature.
Signing\Incremental\DssWriterrecovered the signature's/Contentswithrtrim($hex, '0')to drop the placeholder's padding, and that cannot tell the padding from the DER's own trailing zeros: a CMS whose final byte is0x00lost it, and what remained was still valid DER one byte shorter, so nothing complained anywhere.The store is keyed by the SHA-1 of those bytes and every validator keys it by the SHA-1 of the CMS read at its declared length, so the
/VRIentry was written under the hash of a signature that does not exist. A reader then reports a document carrying validation material as carrying none for its own signature, which is the whole point of B-LT.The last byte of a CMS is effectively the last byte of a signature value, so it struck at random and had shipped since
1.0.1. It surfaced as a test failing on one PHP version and passing on the other in the same run.Validation\DerReaderexisted to prevent exactly this and its docblock said so; only the reading side was using it (invariant 5).A revision written onto an encrypted document that uses cross-reference streams left
/Encryptout of its trailer. A cross-reference stream's dictionary is the trailer (§7.5.8.2), and only the classic path repeated the entry. A reader then treats the last revision as the point where the document stopped being encrypted, and every stream written before it inflates to nothing: qpdf says "incorrect header check", a user says the file is broken. It was unreachable until the object-stream work above, since encrypted plus cross-reference streams was exactly the combination that used to be refused, and qpdf found it the moment it became reachable.Testing\DebugCertificate::makeChain()issued two certificates with the same serial, both defaulting to0under the same issuer name. A CMS identifies its signer by exactly that pair (RFC 5652 §5.3), so pyHanko resolved the SignerInfo to the root, found the ESS signing-certificate-v2 attribute describing the leaf, and refused every chained signature outright. The leaf now also declareskeyUsageand the key identifiers, without which pyHanko applies its key usage policy and builds no path at all. Both were fixture defects rather than library ones, and between them they had made the chain gate unable to check a chain.Signing with an ECDSA certificate is gated rather than assumed.
Testing\DebugCertificategeneratedOPENSSL_KEYTYPE_RSAand nothing else, so no test in the suite had ever signed with an elliptic-curve key and the honest answer to "does this package sign with one" was "probably, nobody has looked". It does:tests/Signing/EcdsaSigningTest.phpsigns onprime256v1andsecp384r1, atpades-b-band atpades-b-lta, from PKCS#12 and from PEM in both the PKCS#8 and the SEC1 shapes, andpdfsigand pyHanko agree.No behaviour changed, which is the result worth recording.
DebugCertificategainsmakeEc()and acurveparameter onmakePem(), both defaulting to RSA so no existing fixture moves. Every pairing of the two curves with the three digests inEnums\DigestAlgorithmis exercised: the package deliberately has no opinion there, and the test is now the opinion.validate(),signatureFields()andextendArchive()take aContracts\PdfSourceas well as a path. Signing has taken a document from anywhere since 2.0 (0102) and the other three entry points took a path and nothing else, so an application holding bytes, a document in a queue message, one in object storage behind its own driver, one just produced in memory, had to write a temporary file to ask whether a signature was valid.Additive: a string keeps meaning exactly what it meant, including the extension check and the missing-file error, and the parameters keep their names so a caller passing them by name is unaffected.
extendArchive()already returned aData\SignedPdf, which reaches aContracts\PdfDestination, so a document can arrive as bytes and leave as bytes.A weak digest, a weak key and a certificate that was not issued for signing are reported as findings.
Data\SignatureDetailsreported the digest algorithm and nothing evaluated it, so a CMS signed with SHA-1 arrived asverified: truewith nothing attached for an application to weigh. Four newEnums\ValidationFindingcases:WeakDigestAlgorithm(MD5, SHA-1),WeakSignatureKey(RSA and DSA below 2048 bits, an elliptic curve below 224),WeakTimestampDigest(the same weakness inside the RFC 3161 token, separated because the authority chose it and the remedy is a fresh archive timestamp rather than a fresh signature), andKeyUsageDoesNotPermitSigning.isValid()is unaffected and that is deliberate. A SHA-1 signature does verify, and reporting it as invalid would be a lie of a different kind (0106). The thresholds are policy that ages, so they live in one place,Support\CryptographicStrength, naming the standards they came from and the date those were read.Data\SignergainskeyAlgorithm,keyBits,keyUsageandextendedKeyUsage, appended;Data\SignatureDetailsgainstimestampDigestAlgorithm, also appended.Enums\DigestOidis new and holds the OID-to-name map thatValidation\Pkcs7Readerkept privately andValidation\TimestampTokenReaderwould otherwise have copied.Testing\DebugCertificategainsmakeWithKeySize()andmakeForPurpose(), because a weak fixture cannot be produced by signing:Enums\DigestAlgorithmhas no SHA-1 case on purpose.signet extend, so the archive chain is a cron entry.Signing\ArchiveExtenderrenews a B-LTA document with no certificate anywhere near it, and until now the only way to call it was a PHP script with a Composer autoload in it. The command takes one path and one destination:--outwrites a copy,--in-placeoverwrites, and one of the two is required, because in place is the version that can destroy an archive.--if-due=<days>leaves an archive that was stamped recently alone, and--jsonreports what was done.The exit status is the report.
Enums\ExtendExitCodegives a document with no signature (3), one certifiedno-changes(4) and an authority that did not answer (75,EX_TEMPFAIL) distinct statuses, so a scheduled job retries only what is worth retrying (0022).The alphanumeric CNPJ is no longer rejected as malformed.
IcpBrasil\NationalRegistry::isCnpj()tested/^\d{14}$/andIcpBrasil\Readerread the field through a fourteen-digit test, both of which predate Instrução Normativa RFB nº 2.229/2024: the first twelve positions now takeAtoZas well as0to9, and only the two check digits stay numeric. A valid e-CNPJ issued to a company with an alphanumeric registry therefore read as carrying no CNPJ, and was then reported asInvalidCnpjCheckDigits.Modulus eleven over the same weights, with each character contributing its ASCII value minus 48, so every all-numeric CNPJ answers exactly as before.
Identity::formattedRegistry()punctuates the new shape as12.ABC.345/01DE-35. Lowercase is refused rather than uppercased, since the specification gives a value forAand none fora. Confirmed against the Receita Federal's published example,12ABC34501DE35, which is a case in the suite (0029).Support\TempDirectoryrefuses a relative path instead of writing beside the caller.path()andfile()now raiseProcessRunTimeExceptionwhen the directory they would hand back is not absolute. A relative path is valid to the filesystem, so the previous behaviour was to succeed and leave a temporary PKCS#12 bundle or PEM private key wherever the process happened to have started. Only a consumer passing a relativeSignetConfig::$tempPathis affected, and for that consumer the call was already writing somewhere it did not intend.
Internal
No behaviour changed, and nothing here ships: .docker/ is export-ignore.
A mutation run that mutates nothing now fails as itself.
.docker/mutate.shrefuses a namespace with no directory behind it, and refuses a finished run whose output saysNo mutations created.--path=src/Typois not an error topest-plugin-mutate, it is a path with nothing in it: the whole suite runs,0 Mutations for 0 Files createdscrolls past, and the run reports0.00%. Measured both ways: with a floor of 0 it exits 0, and with the floor the nightly actually passes it exits 1 asMutation score below expected: 0.0 %, which is a typo reported as a score regression. See docs/spec/quality-policy.md.The description of
composer test:mutatesaid the run happens in a scratch directory. It does not, and it must not: the plugin maps coverage by path and scores 0.00% from anywhere but the package root, which is the reason the sweep exists instead.
No behaviour changed, and both are recorded because they change what a contributor is allowed to write.
Every docblock in
src/explains its design without naming the framework the package was extracted from. The arch rule that enforced the same thing for imports now covers prose as well, so the exemption is gone rather than unused.docs/decisions/0018gained an outcome section. All three of its open consequences are settled, and two of them settled differently from what it predicted.
1.0.1 - 2026-08-13
Fixed
The declared PHP floor was not installable.
1.0.0declared>=8.4, whilesymfony/process8.1.0 requires>=8.4.1, so resolving against a platform of 8.4.0 failed outright. The constraint is now>=8.4.1 <8.6.CI never caught it because it installs the newest patch of each minor, so the lower bound is not what anything resolves against. Dependabot found it on its first run, resolution from the declared floor being the one job that starts there. (0005)
No behaviour and no API changed.
1.0.0 - 2026-08-13
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 is still a separate implementation: it was not rebuilt on top of this one, so the two share a lineage, a signed-output guarantee and an encryption envelope rather than a dependency.
Added
- Signing by appending a revision, never by rebuilding the document (ISO 32000-1 §7.5.6). 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. (0006)
- PAdES profiles
legacy,pades-b-b,pades-b-t,pades-b-ltandpades-b-lta, including the Document Security Store and the archive timestamp. - Cryptographic verification, where "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,fieldsandcheck.verify --jsonputs the verdict in the exit status, so a build in any language can gate on it. Contracts\PdfSourceandContracts\PdfDestination, so a document can arrive from and leave to anywhere. (0102)Testing\FakeProcessRunner,Testing\FakePdfSignerandTesting\FakeCertificateReader, so an application can test its own signing path without a certificate.- An opt-in audit trail over
Psr\Log\LoggerInterface, whose context is an allowlist rather than a denylist. (0035)
Changed
- The namespace is
LSNepomuceno\Signet\. The facade became an object you construct, configuration became value objects, and the container went away. UPGRADE.md maps every one of those. (0100) - Symfony is the only framework vendor:
process,http-client,uidandconsole. One exception, argued and recorded:psr/log, for the audit trail. (0101)
Removed
- The service provider, the facade, the Artisan commands, uploads and HTTP responses. All five are framework constructs and all five are still available in
lsnepomuceno/laravel-a1-pdf-sign, which remains a separate implementation rather than a consumer of this one.