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.
3.0.0 - 2026-09-02
The release that made the package usable on a document nobody can hold in memory, and conformant to the country it was written for.
Signing stopped holding the document twice. A 300 MB file signs in 310 MB where it used to need 602, and the ceiling is now the size of the document rather than a multiple of it. Every profile is reachable at that size, and the receipt says what was signed.
A signature can be produced without the private key ever entering the process, and the ICP-Brasil work reached the point where the country's own Verificador accepts what this package writes: the policy declaration, the security store, and the three entries the archival policies require that PAdES does not define.
Every breaking change is in UPGRADE.md, and there are three.
Added
The security store carries the artefacts an ICP-Brasil archival signature declares.
PBAD_PolicyArtifacts,PBAD_LpaArtifactsandPBAD_LpaSignaturesin/DSS, and the singular forms in/VRI, holding the policy document, ITI's published policy list and that list's signature inside the file. ITI refused a document for their absence while every other attribute in the same report passed.Nothing has to be configured. Sign at
pades-b-ltadeclaring an AD-RA policy and they are written, from copies that ship insrc/Resources/, so signing reaches no network for them. Any other policy, or none, produces the document it produced before: the other three families ask for none of these, AD-RC included.The document grows by about 15 KB, which is what buys a verifier the ability to check the policy years later without
politicas.icpbrasil.gov.branswering.php// A newer list, before a release carries it. new Signet($config, storeContributor: new PolicyArtifacts('/etc/signet/icp-brasil'));Contracts\SecurityStoreContributorandData\SecurityStoreEntryare the seam, so a host signing under a policy this package has never heard of contributes its own entries and nothing insrc/Signing/learns what aPBAD_entry is (0132).Data\SigningReceipt, so an application can say what it signed.sign()returned the bytes and a file name, and every other fact signing knew was discarded: which field was filled, at what profile, when, who signed, and what the document was before it was signed.$signed->receipt()returns all of it, plus the digest of the signed document and of the one it was given.php$receipt = $signed->receipt(); $receipt->hash; // the signed document, SHA-256, hex $receipt->originalHash; // the document as it arrived $receipt->documentId; // the PDF's own /ID $receipt->signedAt; $receipt->icpBrasil?->cpf;It carries no PDF, because it is what goes in a column or a queue message rather than what is shown, and
receipt()is a method because it hashes: two passes over the document, which on a 300 MB file is a second nobody should spend by callingsign().documentIdis there because a digest is not an identifier. ISO 32000-1 §14.4 gives a document a permanent/IDthat survives being re-saved, and a hash of the bytes does not, while the signature inside stays valid either way.There is no MD5 and there will not be:
Enums\DigestAlgorithmis a closed set of SHA-2 and it is the same enum that chooses the digest of the signature itself (0127).Contracts\SigningKey, so the private key can live outside this process.Contracts\SignatureProducerhands out the covered bytes and takes back a complete CMS, which unblocks a signer that assembles CAdES itself. A certificate in the cloud, an A3 token through PKCS#11 and a cloud KMS do not: each takes bytes and returns a raw signature. This is the seam one level deeper, and what it hands out is the DER encoding of the signed attributes, since that is what a CAdES signature is computed over (0120).Bind one through
new Signet(signingKey: $key)and sign through the ordinary entry point, withcertificatePublic()carrying the certificate. The two paths produce the same bytes: a PAdES baseline signature carries no signing-time attribute and RSA PKCS#1 v1.5 is deterministic, so for the same content and certificate the CMS is byte for byte identical to the one the bundled key produces.Enums\SignatureEncoding, which is how aContracts\SigningKeysays what it returns. ECDSA has two encodings in the field, the DER SEQUENCE of RFC 3279 and the fixed-width concatenation of IEEE P1363, and they are not reliably distinguishable by inspection. Declared rather than guessed, because the wrong guess produces a signature that verifies against nothing.A signature can declare an ICP-Brasil policy. The package produced PAdES signatures conformant to ETSI EN 319 142-1 that declared no policy, and a Brazilian verifier looks for that declaration before calling a signature ICP-Brasil conformant. So a document signed with an e-CPF was cryptographically fine and reported as conformant to nothing by ITI's own Verificador (0121).
Name one in
Config\SigningConfigand every signature carries thesignature-policy-identifiersigned attribute:phpnew SigningConfig(policy: SignaturePolicy::forProfile(SignatureProfile::PadesBT)?->identifier())IcpBrasil\Enums\SignaturePolicycarries every policy ITI has published for PDF, superseded versions included, so a document declaring an older one can still be named when it is read back. Every value was read fromhttp://politicas.icpbrasil.gov.br/LPA_PAdES.deron 2026-08-29, that file is committed, and a test fails when the two disagree: a wrong policy hash produces a signature that declares conformance and fails it.IcpBrasil\PolicyConformanceandIcpBrasil\Data\PolicyReport, which say whether a signature kept to the policy it declared: an unknown identifier, a digest that disagrees with the published list, a policy that was not in force when the document was signed, and a signature carrying less than the policy demands. The report is the shapeIcpBrasil\Data\Reportalready has,conforms(),has()andmessages()included.isValid()consults none of it, because a signature that declares a policy it does not satisfy is still cryptographically valid.Config\SigningConfig::$policyandSigning\Cades\PolicyAttribute, the two pieces underneath. The configuration takes a plainData\SignaturePolicyrather than the regional enum, so the core still knows nothing about which policies exist.Contracts\DigestSignatureProducer, a producer that builds the CMS from a digest instead of from the covered bytes.Signing\Cades\CadesBuilderimplements it, and signing uses it, so the copy of nearly the whole document thatPreparedSignature::signableBytes()made is not made at all.Signing\Incremental\ByteRangeCalculator::digestOfSpan()hashes the covered span in chunks rather than assembling it first.This does not raise the largest signable document yet, and the measurement says why: the peak is the revision being assembled while the original is still held, which is a later stage of the same work (0122).
tests/Signing/MemoryFootprintTest.phprecords the ratio so a regression is a failing test rather than a support question.Testing\LocalRevocationAuthority::crlFor(), which signs a real CRL with the authority that issued the certificate under test.Testing\DebugCertificate::makeRevocable()now issues from a throwaway authority instead of self-signing, and returns that authority's certificate and key alongside the bundle. Both exist because of the change below.PendingSignature::certificatePublic($certificatePem), a way in for a certificate that arrived without its private key. The four existing entry points all require the key, correctly, since signing needs it; the two-phase flow never has one, and both things it does with a certificate read only public material. The route until now wasusingCertificate()with a hand-assembledData\Certificate, which putopenssl_x509_read()and a four-argument constructor into application code (0116).A builder made this way is for
prepare(), which is why it is a method of its own rather than a flag on one of the four.sign()still works if the application has bound aContracts\SignatureProducerthat holds the key elsewhere, and raisesMissingPrivateKeyExceptionfrom the default producer, which is the one that needs it.Exceptions\MissingPrivateKeyException, raised bySigning\Cades\CadesBuilderwhen the certificate carries no key. It extendsInvalidCertificateContentException, which is what the case used to arrive as, so an existing catch keeps matching. The message names both halves of the flow instead of reporting an OpenSSL error about a key that could not be read, which described a corrupt key rather than an absent one.It comes from the producer rather than from
sign()deliberately:sign()routes throughContracts\SignatureProducer, so an application that bound one holding the key elsewhere signs from a keyless certificate quite happily.Support\Pem::hasPrivateKey(), which reports whether a private key block is present rather than whether it loads. Loading answers false to a key that is absent and to one that is merely locked, and those are different faults.Certificates\PemCertificateReader::readPublic()andCertificates\CertificateParser::parsePublic(), the two steps underneath it.signet sign --legacy, for a PKCS#12 bundle OpenSSL 3.x refuses natively. The library could read one throughnew CertificateConfig(legacy: true)and the command line could not, under any option, whilesignet checkreported theopensslbinary as present and needed for exactly this. Every bundle a Brazilian authority issues is that shape, so the command could not sign with an e-CPF at all (0123).Exceptions\InvalidCertificateContentException::legacyAlgorithms(), which is what such a bundle now fails with. It says what the bundle is, why ext-openssl cannot read it, and the two ways to reach the reader that can, keeping the OpenSSL string for a reader who already knows the code. What a caller used to get waserror:0308010C:digital envelope routines::unsupportedand nothing else.Known limits, a page for what the package does not do yet. The limits were on issues and in commit messages, which is where a maintainer looks and not where somebody deciding whether to use this looks. Each entry says what fails, what the instrument reported in its own words, what to do meanwhile, and where it is tracked.
The one worth reading before choosing a profile: every level above
pades-b-bis refused by ITI's Verificador today, and for a reason that belongs to the timestamp authority rather than to the document. Signing for ICP-Brasil above the baseline needs an accredited ACT, those are contracted rather than public, and they authenticate by client certificate rather than by the username and passwordConfig\TimestampConfigcarries. The page shows how to reach one anyway, through the injectable HTTP client, and states the cost of the private key that puts on disk.A signature carrying no timestamp is untouched by all of it, which is stated there too, because it is the first question anybody reading the rest will ask.
Changed
Signing no longer holds the document twice. The peak was a multiple of the file:
Signing\Incremental\RevisionWriterreturned the whole document, so a revision of a few kilobytes allocated the file a second time to add itself to it. It returns the revision now, and the caller extends the document in place, which PHP does without copying while nothing else points at those bytes.Measured through
tests/Signing/MemoryFootprintTest.php, on the same fixtures as before:Document Peak before Peak now 8 MB 22.1 MB, 2.75x 17.6 MB, 2.19x 16 MB 38.1 MB, 2.38x 24.1 MB, 1.50x 32 MB 70.1 MB, 2.19x 40.1 MB, 1.25x And the size this is actually for:
300 MB document Peak while signing before 602.0 MB, 2.01x now 309.8 MB, 1.03x It signs in 1.4 seconds, validates here, and
pdfsigreads it. A 300 MB document is a scanned process file or a photographic annex, and every one of those was a signature this package could not produce on a default configuration.The ratio falling is not the point; the constant is. What is held is one document plus about 8 MB, at every size, where it used to be two documents. The 8 MB is the chunk the covered span is hashed in. The test asserts the constant rather than a ratio, because a ratio passes for a large document however many copies are made.
It does not yet sign in less than its own size, which is what #48 asks for: 300 MB under a 128 MB limit needs the structure read by seeking rather than from a string, and 0122 carries what that costs and which fourteen files it touches.
pades-b-ltaholds one more, and it is named rather than absorbed: an RFC 3161 request carries the digest of what it timestamps and the client hashes that content itself instead of taking an imprint, so the archive timestamp assembles the span it covers (0122).Data\PreparedSignature::$documentis aSupport\DocumentBufferrather than a string, which is what lets phase two write into the document instead of around it.UPGRADE.mdcarries the one-line change.Switching version on the documentation site keeps the page. The control used to link to an archive's front door from everywhere, so a reader on
/spec/public-apiwho wanted the1.xversion of that page navigated again from the top. It now links to the same route when the archived line has it, and sayshome pageon the entry when it does not, so the reader is told before the click. It works leaving an archive too.The version menu also carried the changelog and the upgrade guide, which are a different question. Those are a
Releasesnav item now, and the version control carries versions and nothing else (0112).intervention/imagemoves to^4.3, and a consumer pinned to 3.x cannot take this release. Intervention Image 4 removedImageManager::read(), and the seal renderer was built on it. Measured against 3.11.8 and 4.3.2, that rename is the whole difference:ImageInterface::text(),encode(),width(),height(), the JPEG and PNG encoders,FontFactoryand both drivers are unchanged, so the port is two call sites.Both majors were considered and rejected on evidence rather than taste. There is no method present in both, so supporting them means a branch, and CI has no
--prefer-lowestleg: Composer resolves the highest allowed version in every cell of the matrix, so the 3.x branch would never once execute. PHPStan at level max with no baseline cannot see it either, since whichever major is installed the other branch calls a method that does not exist. The seal output is unchanged, and the suite asserts the rendered bytes, the dimensions and PDF/A conformance of sealed documents against 4.3.2 without a fixture being touched (0125).intervention/gifmoves from 4.2.4 to 5.0.1 with it, pulled by the same requirement.tecnickcom/tc-lib-pdf-signmoves to^2.0, and two behaviours change with it. Both are checks that were not being made.A timestamp token is verified before it is embedded: its signature, the certificate it names, its imprint and nonce against the request that was sent, and its genTime against the clock. Until now a token was parsed and trusted, which is a gap under everything
pades-b-tand above promises. The legacy ESS certificate binding is accepted, because refusing it rejects authorities in production use today, freetsa.org among them (0118).Revocation material is verified before it is embedded: a CRL is checked against the issuer that signed it and the certificate it covers, and material is gathered only for a certificate whose issuer is in the chain. A Document Security Store therefore holds only evidence that verified when it was written. A document signed with a self-signed certificate now carries no revocation material at
pades-b-lt, where before it carried material nothing could check. The store still carries the chain (0119).
Fixed
The offline witness read every B-LT and B-LTA document as
BASELINE-T, and the documents were right the whole time. EU DSS decides a baseline level by asking whether the file carries validation material for every certificate in every chain, excluding trust anchors because a trust anchor needs none. It was configured to trust nothing, so a self-signed root was an ordinary certificate with no revocation data and no document could be read above T whatever it carried.Given one anchor and a certificate that publishes a distribution point, the reference implementation of the European standards reads this package's output as
PAdES-BASELINE-B,-T,-LTand-LTA, and the suite asserts all four on every run.Testing\LocalTimestampAuthority::certificate()is new, and is what lets a test trust the authority it stamped with. Nothing insrc/changed (0133).The committed samples still read as
BASELINE-Tat those two levels, and correctly: their certificate has no responder and no distribution point, so there is no revocation material to embed.docs/guide/samples.mdsays so.AD-RC was mapped onto
pades-b-lt, and ITI refuses that document. Every AD-RC version names/DocTimeStampamong the dictionaries it requires, which is whatpades-b-ltaadds, so the family that looks like the B-LT policy is not one. The mapping followed ETSI EN 319 142-1 exactly, where complete references sit on that rung, and ICP-Brasil does not use it.Nome do atributo: DocTimeStamp Corretude: Invalid Mensagem de erro: Atributo DocTimeStamp obrigatório ausente na assinaturaSignaturePolicy::forProfile(SignatureProfile::PadesBLT)now answers null, because ITI publishes no policy a B-LT signature satisfies. Code that passed that answer intoSigningConfigdeclared AD-RC and produced documents the authority refused, so what it loses is a false positive rather than a capability.forProfile(PadesBLTA)answers AD-RA, and the tie between the two families that now land there is broken by the arc ITI numbers them with rather than by the order the cases are declared in.The mapping is gated by the committed policy documents from here on: the suite asks each artefact whether it names the dictionary and requires that to agree, case by case (0131).
The security store named the signature by the wrong hash, and every Brazilian verifier said so. A
/VRIentry is keyed by the SHA-1 of the signature's/Contents, and that value is a fixed-width placeholder: the CMS at the front and zeroes after it. Two different strings to hash, and this package hashed the first:Nome do atributo: DSS Corretude: Invalid Mensagem de erro: Não encontrado VRI identificado com o hash da assinatura.Settled by submitting the same document twice, once under each key, since both are forty hexadecimal characters and one can be written over the other in place. The second came back
DSS: Valid.The fix retires a hazard rather than working around it. The key was once the CMS recovered with
rtrim(), which lost a trailing0x00about one signature in 256 (#103, invariant 5), and then the CMS recovered by declared length. There is no recovery left to get wrong: the bytes hashed are the bytes in the file (0130).Data\SignatureDetailsgainscontentsAsWritten, andsamples/is regenerated. A document signed by an earlier version now reports its store as not covering its signature, which is the honest answer and what every other verifier already said about it.A
pades-b-ltsignature could come back short and say nothing. The profile gathers revocation evidence for every link of the chain and embeds only what verifies, which is deliberate (0119): an authority that does not answer must not stop a signature. What was never said is that anything had been dropped, so a document could declare the profile, carry evidence for two links of three, and report success.Measured against a real ICP-Brasil chain, one list of three was missing, and the reason existed the whole time in a callback this package did not pass:
crl http://www.receita.fazenda.gov.br/acrfb/acrfbv4.crl: The CRL is too old$signed->receipt()->skippednow carries aData\SkippedMaterialper piece, with what was fetched, where it was asked for and why it was refused, andSupport\SigningLogrecords the same asvalidation-material.skippedwhen a host wires one. Empty is not a claim of completeness: atpades-b-bnothing is looked for (0129).It says nothing about whether the reasons are right. The one above is about a list valid for another two and a half months, and arguing with that rule is #156. Reporting it is what makes arguing with it possible.
pades-b-ltandpades-b-ltaembedded nothing when signed with a real certificate authority. No exception and no finding:sign()returned a document reporting itself aspades-b-ltand carrying no Document Security Store at all.The chain was there and it was in the wrong order.
Signing\Incremental\DssWriterpassed the bundle to the collector as read, and the collector pairs each certificate with the next one as its issuer. A real PKCS#12 is not leaf-first: an RFB e-CPF A1 reads back as holder, AC RFB v4, AC Raiz, AC SERPRORFB, so the leaf was paired with a certificate that did not issue it and every piece of material gathered was then correctly refused. The chain is built withValidation\ChainBuildernow, which is what the two-phase path has always done (0128).Nothing could see it.
Testing\DebugCertificateissues a two-certificate bundle, leaf then issuer, and two elements in the right order are also two elements in an order the old code happened to handle.Measured with the certificate that found it: 4 certificates and 2 CRLs embedded, and
IcpBrasil\PolicyConformanceconformant at AD-RT v1.3, AD-RC v1.4 and AD-RA v1.4. A B-LT document from a Brazilian authority is measured in megabytes: the same document is 42 KB atpades-b-tand 2.3 MB atpades-b-lt, and signing takes about ten seconds instead of one, nearly all of it fetching revocation lists.The EU DSS gate could not read a document carrying a CRL, which is every B-LT and B-LTA document from a real authority. DSS ships the CRL interface and picks an implementation off the classpath, and none was there, so it died with
No implementation found for ICRLUtils. The same shape as the colour-profile library: blind to a category of document rather than failing on one.The signing time said UTC and wrote local time.
/Mwasdate('YmdHis')followed by a literal+00'00', so a signature made at 10:00 in São Paulo declared 10:00 UTC, three hours before it happened, and every reader that parses the offset believed it, this package's own extractor included. It isgmdate()now, so a signed document under a non-UTC process says when it was signed (0127).A real ICP-Brasil certificate did not fit the space reserved for the signature. An RFB e-CPF A1 signing at
pades-b-tproduces a 10501-byte CMS, and 8192 bytes were reserved, so three of the four profiles were unreachable for the audience the regional layer exists to serve:the 10501-byte signature does not fit the 8192-byte reserved spaceBoth placeholders double, to 16 KB of CMS. Overflowing is a hard failure, so being generous costs 8 KB of zeroes per signature and being tight costs a document that cannot be signed at all. The suite could not see this:
Testing\DebugCertificateissues a self-signed certificate with no chain, so every measurement the width was checked against was of the smallest possible CMS.And widening it exposed a reading defect that was already costing other producers.
Validation\PdfSignatureExtractorread/M, the signing time, from a 32 KB window scanned forward from the/ByteRange, wide enough to clear a 16 KB placeholder and no wider. A document reserving more than this package does lost its signing time silently, and its sub-filter with it. The signature dictionary is read with its own payload cut out of the middle now, using the/ByteRange's own offsets, so it does not depend on how much anybody reserved (0126).Data\PreparedSignature::$reservedBytesreports 16384 rather than 8192, and every signed document grows by 8 KB.The EU DSS gate could not read a sealed document, and said nothing about it. PDFBox loads
liblcms.soout of the JVM's ownlib/to decode an ICC profile and the headless JRE omits it, so the witness added in this release died withUnsatisfiedLinkErroron every document carrying a colour profile, which is every sealed one, while reading the rest perfectly. Not failing, not skipping: silent on part of what it was installed for.The image installs the full JRE, and the suite reads a sealed sample through DSS now rather than assuming it can. Reading the rest raised a question that is open rather than answered: DSS calls the
pades-b-ltandpades-b-ltasamplesPAdES-BASELINE-T, most likely because the sample certificate is self-signed and publishes no revocation material for the store to carry (#152, 0124).The ICP-Brasil policy attribute declared the wrong hash of the right policy. A document signed with an RFB e-CPF A1 at AD-RB v1.3 was rejected by ITI's Verificador, with one attribute invalid out of five and everything else passing, the certification path included:
Nome do atributo: IdAaEtsSigPolicyId Corretude: Invalid Mensagem de erro: Falha ao construir o atributo. O valor do resumo criptográfico não é equivalente ao esperado.There are two hashes of a policy, and this package used the wrong one. A policy document is
SEQUENCE { signPolicyHashAlg, signPolicyInfo, signPolicyHash }, and the third field is a hash over the first two, excluding itself. That is the valuesigPolicyHashmust carry, and it is what a verifier rebuilds from the policy and compares.LPA_PAdES.derrecords a different hash over the whole file, which is how you check you downloaded the right document.For PA_PAdES_AD_RB_v1_3.derWhat the list records, over the file 23da544aef71f7a7...What the policy carries in signPolicyHash23e4be4b9b362172...What this package declared the first All eighteen digests came from the list, so all eighteen were the file hash. Both are genuine hashes of genuine artefacts published by the authority, which is why the wrong one survived review and a test that compared it against the list.
digest()now comes from each policy document, all eighteen of which are committed and checked against the list's file hash before use (0121 carries both outcomes, including a first diagnosis that was wrong).The gate that would have caught it now exists, and it is not ours. EU DSS is installed as an instrument and the digest is checked against it offline, on a correct document and on one carrying the file hash, which is the exact substitution that shipped. Nothing else in the toolchain could see this:
pdfsig, pyHanko and Demoiselle all reported the defective document as valid, correctly, because none of them resolves the policy document and so none of them ever compares (0124).ITI's Verificador accepts the corrected signature. Two documents signed with a real RFB e-CPF A1 at
pades-b-b, declaring AD-RB v1.3 and AD-RB v1.2, were submitted on 2026-09-01 and both came back approved, as qualified electronic signatures under MP 2.200-2/01 and Lei 14.063/20. DSS had approved the same two files offline first, so the gate and the authority agree.pades-b-band the AD-RB family only: the other three families declare more than a baseline signature carries.Every signature this package produced declaring an ICP-Brasil policy is affected, and re-signing is the only fix for a document already issued: the attribute is signed, so it cannot be corrected in place.
The policy hash algorithm was encoded with explicit
NULLparameters. Found while diagnosing the above and fixed on its own merits, not because it caused the rejection: it did not. RFC 5754 section 2 is explicit that implementations "MUST generate SHA2 AlgorithmIdentifiers with absent parameters", and names omitting them the correct encoding. ICP-Brasil follows that in both the list and every policy document; this package wrote300dwith theNULLwhere they write300b.A temporary file holding key material was world-readable while it existed.
Certificates\OpenSslCliCertificateReaderwrites the decrypted private key to disk, because-nodesis how theopensslbinary emits one, andSupport\Files::write()writes at the process umask. Measured at the default 0022: the file was 0644 inside a 0755 directory, so any user on the host could read the key for the length of the call. The file was always deleted in afinally, which is whatSECURITY.mdpromised and was never the part at issue.Support\Files::writePrivate()andSupport\Files::makePrivateDirectory()are the fix, andSupport\TemporaryFileuses them for every caller: the CMS, the bytes a signature covers, the timestamp query and the bundle itself were all written the same way. Files are 0600 and are restricted before any content lands, since achmod()after the write leaves a window with the secret already in it. Directories are 0700 only when this package creates them: the default is the system temporary directory, and narrowing/tmpwould break every other process on the host.This is local exposure, on a host where an unprivileged account already exists, for the duration of one
opensslcall. It ships as an ordinary fix rather than an advisory.The certificate password no longer reaches the command line. It went in
-password pass:, whichpsdiscloses to any user on the host while the process runs. It now goes through-passin file:at a 0600 file deleted by the samefinallyas the rest.#[\SensitiveParameter]keeps a password out of a stack trace and says nothing about a command line, which is the gap this closes.An empty password stays on the command line, because
file:reads the first line of a file and an empty file has none, which openssl reports as a failure to read the password at all. A bundle with no password is one this reader opened before, and an emptypass:discloses nothing.The clean answer is a descriptor the parent writes to, which needs an argument on
Contracts\ProcessRunner::run()and is therefore a major release (0117).A compressed stream in a document now decodes under a ceiling. Nothing in the PDF format bounds a compression ratio, and
Support\PdfFiltersreads streams out of the document being signed or validated, so a small payload could expand until the process ran out of memory. Measured throughdecode(): 194 KB of/FlateDecodeyielded 200 MB, and 1038 bytes declaring the legal chain/Filter [/FlateDecode /FlateDecode]yielded 400 MB at a peak of 772 MB. PHP treats exhausting memory as a fatal error rather than an exception, so an application signing an uploaded document had nothing to catch.Every filter now decodes under
PdfFilters::MAXIMUM_DECODED_BYTES, 64 MiB, and a stream past it is reported as one that does not decode. The value is a constructor argument: an application whose documents carry revocation lists larger than that can raise it withnew PdfFilters(maximumDecodedBytes: ...).Nothing this package writes is affected, and no document that decoded within the ceiling before behaves differently.
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.