Skip to content

Releases: lsnepomuceno/signet-pdf

2.0.1

Choose a tag to compare

@lsnepomuceno lsnepomuceno released this 20 Aug 18:24
7cadb7f

Nothing in src/ differs from the 2.0.0
tag.
This release exists so that ^2 resolves to that code.

composer require lsnepomuceno/signet-pdf:^2

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 GitHub's tag list during the
thirty-nine seconds between the two pushes and cached the earlier commit against
the version, and a published version's reference cannot be corrected from the
repository side.

So what 2.0.0 promised is what this release delivers, and the 2.0.0 notes are
the ones to read. If you installed 2.0.0 from Packagist at any point,
composer update lsnepomuceno/signet-pdf moves you onto it.

Documentation: lsnepomuceno.github.io/signet-pdf.

2.0.0

Choose a tag to compare

@lsnepomuceno lsnepomuceno released this 20 Aug 17:26
c589fe5

The first stable release of the standalone package: signing, validation and the
command line, with no framework anywhere in src/.

composer require lsnepomuceno/signet-pdf:^2

Read UPGRADE.md
before moving from 1.x.
Every breaking change is answered there with the
replacement rather than the removal.

The whole of what changed is in
CHANGELOG.md.
The headlines:

  • Signing appends a revision, never a rebuild, so an earlier signature,
    every annotation and every form field survive a second one.
  • The private key does not have to be in this process. Signing is
    prepare() and complete(): the first appends the revision and fills the
    /ByteRange, which is where the offsets stop moving, and hands back the
    digest of the covered bytes. The second is one fixed-width overwrite and
    takes no certificate at all, so a key on an A3 token, in an HSM or behind a
    cloud service signs where it lives.
  • PAdES legacy through pades-b-lta, including on an encrypted
    document: the security store and the archive timestamp are encrypted with
    everything else, and only the timestamp token stays in the clear, as
    ISO 32000-1 §7.6.2 requires.
  • Validation needs no openssl binary if you do not want it to.
    NativeSignatureVerifier checks the signed attributes, the message digest,
    the content type and the ESS signing-certificate-v2 attribute through
    ext-openssl, and is checked against the binary on every sample, a foreign
    pyHanko document and three tamper cases.
  • Validation reports the signature policy a signer declared, the
    signature-policy-identifier of RFC 5126 §5.8.1. A verifier in Brazil looks
    for it before calling a signature ICP-Brasil conformant, and until now an
    application could not see it at all.
  • A signature field can be created, not only filled, with no certificate
    involved.
  • A visible seal keeps PDF/A and PDF/UA conformance, measured with veraPDF
    rather than asserted.
  • Encrypted documents that pack their objects into object streams sign,
    which is what a password-protected export from a word processor looks like.
  • ICP-Brasil identity, including the alphanumeric CNPJ that takes effect in
    2026.

Documentation: lsnepomuceno.github.io/signet-pdf.
The 1.x documentation is archived at
/v1/.


This tag was re-cut on 2026-08-20. A 2.0.0 published two days earlier
pointed at an earlier commit, and work kept merging after it: two-phase
signing, the signature policy above, and a fix to the document security store,
which keyed its entries by a signature recovered with a trailing-zero trim and
so filed the validation material of about one signature in 256 under a hash of
something that does not exist. If you installed 2.0.0 in that window,
composer update lsnepomuceno/signet-pdf gets the release this page describes.

v2.0.0-rc.1 - Encryption on the platform, a bounded regional layer, and validation that says what changed

Choose a tag to compare

@lsnepomuceno lsnepomuceno released this 14 Aug 02:00
14df106

A release candidate for 2.0.0. Please exercise the certificate vault
migration before this goes stable
, for the reason below.

composer require lsnepomuceno/signet-pdf:2.0.0-rc.1

Composer will only resolve it with minimum-stability set accordingly, which is
what makes it safe to try.

The break nothing can check for you

CertificateVault::create() keeps its signature and returns a 32-byte key
where it returned 16. A storage column sized for the old width truncates it
silently, and no type checker can see that: the backward-compatibility check
reports nothing about it and is right to.

That checker also skipped seventeen files it could not compile, alongside the
twelve breaks it did report, so twelve is a floor rather than a total. Read
UPGRADE.md,
not the green check.

Nothing has to be re-encrypted. A key issued by 1.x keeps opening what it
sealed: the payload carries its version and withKey() picks the reader from
the key's length.

Breaking

  • ext-sodium is now required, and new certificate material is sealed with
    XChaCha20-Poly1305 instead of an AES-128-CBC and HMAC construction this package
    assembled itself. Encrypt-then-MAC written in application code is the shape
    that fails quietly, and encryption at rest is a convenience beside a PDF
    signing package rather than the product. Material sealed here no longer opens
    in lsnepomuceno/laravel-a1-pdf-sign until that package learns the same
    envelope; the other direction is unaffected. (0103)

  • The ICP-Brasil layer moved to IcpBrasil\, eight class names, behaviour
    identical. If you do not sign Brazilian documents, none of it reaches you.
    (0104)

  • SealPlacement::LAST_PAGE is gone, replaced by Enums\SealPage::Last.
    A numbered page is unchanged and SealPage::Last is still the default, so a
    placement that never named a page needs no edit. (0105)

Validation now says what changed, not only that something did

Six additions, none of which costs a consumer anything.

  • onlyAddedSignatures(). coversWholeDocument said bytes were appended
    after a signature and never what they did, which is the live attack surface
    for PAdES: append an annotation over the payment terms and the signature still
    verifies, because the new bytes are outside its /ByteRange. Each revision is
    now reported with the objects it defines and what they touched. True is not
    a verdict of safe
    : a counter-signer produces the same shape. (0110)

  • The /ByteRange is checked before it is believed. It is the one input to
    validation an attacker writes, and everything downstream derived from it
    unchecked: which bytes are hashed, and where the CMS is read from. Six
    conditions now, the decisive one being that the gap is the value of a
    /Contents key rather than any window holding hexadecimal. Nothing changes
    for a well-formed document. (0107)

  • Enums\ValidationFinding. Ten cases naming facts the validator already
    established, where the only ways to reach them were a dozen properties or
    matching English in $error. Exactly one decides validity, and the enum
    carries no severity: how much NotTrusted matters is not this package's call.
    signet verify --json prints them. (0106)

  • missingValidationMaterial(). hasLongTermMaterial() answers presence;
    B-LT promises a verifier could decide offline. A store with one certificate, a
    /VRI entry and no OCSP satisfies the first completely. (0109)

  • verifiableUntil(), so a document can be re-stamped before its chain can
    no longer be built. An archive timestamp renews the horizon. Null means
    unanswerable, never "never". (0108)

  • $messageDigest, short and stable enough for an audit trail to record and
    compare later. Not proof on its own. (0108)

Also

Every docblock in src/ now explains its design without naming the framework
the package was extracted from, and the arch rule that enforced that for imports
covers prose as well.

lsnepomuceno/laravel-a1-pdf-sign is not a consumer of this package. The
documentation said it was; it is a separate implementation sharing a lineage,
and that is now stated wherever it was claimed otherwise.

Verification

674 tests on PHP 8.4 and 8.5 with --fail-on-skipped, PHPStan at level max with
no baseline, type coverage at 100%, and zero warnings. Checked independently
against veraPDF, qpdf, pyHanko, poppler's pdfsig and the Arlington PDF Model.

Full detail in
CHANGELOG.md.

v1.0.1 - A floor the package can actually be installed on

Choose a tag to compare

@lsnepomuceno lsnepomuceno released this 13 Aug 06:12
4b0915e

A one-line fix, and the reason it is worth a release of its own.

The declared floor was not installable

1.0.0 declared php: >=8.4. That was not true: symfony/process 8.1.0 requires php >=8.4.1, so resolving against a platform of 8.4.0 fails outright.

Problem 1
  - symfony/process v8.1.0 requires php >=8.4.1 -> your php version (8.4.0) does not satisfy that requirement.

A declared floor is a promise that the package installs there. 1.0.1 makes it one: >=8.4.1 <8.6.

Why the matrix did not catch it

CI installs the newest patch of each minor, so 8.4 means 8.4.24 there, and the lower bound is never the version anything resolves against. Every cell was green and the floor was still wrong.

Dependabot found it on its first run, because resolving a dependency update is the one job that starts from the declared floor rather than from an installed one. docs/decisions/0005-php-and-laravel-floor.md records the outcome so the next person to widen the constraint knows what to check.

Nothing else changed. No behaviour, no API.

v1.0.0 - The core, without the framework it grew up in

Choose a tag to compare

@lsnepomuceno lsnepomuceno released this 13 Aug 05:03
a68dc7f

Signet PDF signs and verifies PDF signatures in PHP: PAdES B-B to B-LTA, by incremental update, with no framework.

It is the core of lsnepomuceno/laravel-a1-pdf-sign, extracted so it can be used from Symfony, Slim, a plain script, or another library. That package remains and becomes the Laravel integration over this one.

composer require lsnepomuceno/signet-pdf

What it does

  • Signs by appending a revision, never by rebuilding the document. The original bytes survive byte for byte, so annotations, form fields and every earlier signature stay intact and a second signature does not invalidate the first (ISO 32000-1 §7.5.6).
  • PAdES profiles legacy, pades-b-b, pades-b-t, pades-b-lt, pades-b-lta, including the Document Security Store and the archive timestamp.
  • Verifies cryptographically. "Valid" means the CMS actually verifies.
  • Certification signatures (/DocMDP) and field locks (/Lock), enforced rather than merely written.
  • ICP-Brasil identities read out of the certificate's own extensions.
  • A command line: signet sign, verify, fields, check. verify --json puts the verdict in the exit status, so a build in any language can gate on it.

What "framework agnostic" means here

Five rules, one per piece of code that had to move, in 0100. The core returns no HTTP, reads no configuration, has no container, opens no connection or process outside its two contracts, and keeps a fluent builder of its own.

A token walk in tests/Project/ArchTest.php fails on any Illuminate, Laravel or Orchestra import in src/. It is a walk rather than an arch rule because an arch rule can only be pointed at symbols that exist, and the whole point is that these do not.

Symfony is the only framework vendor (0101): process, http-client, uid, console. One exception, argued and recorded: psr/log, for the optional audit trail.

Moving from the Laravel package

UPGRADE.md maps it. The short version: the namespace becomes LSNepomuceno\Signet\, the facade becomes an object you construct, and configuration becomes value objects.

Certificates encrypted by either package open in the other. Support\OpensslEncrypter writes the same envelope Illuminate\Encryption\Encrypter does, byte for byte, because an application moving between the two cannot re-encrypt material whose plaintext it no longer holds.

Signed output is unchanged. The bytes this package emits are the bytes the Laravel package emitted, which is what samples/ and the pdfsig cross-check are for.

Verification

627 tests on PHP 8.4 and 8.5, PHPStan at level max with no baseline, type coverage at 100%, and zero warnings: phpunit.xml fails the run on any diagnostic, and a call whose failure is an expected answer goes through Support\Probe::run() rather than the @ operator, which does not actually stop PHPUnit reporting it.

What the suite is checked against, none of which ships: qpdf for structure, veraPDF for PDF/A and PDF/UA, pyHanko for /DocMDP, poppler's pdfsig as an independent reader, and the PDF Association's Arlington PDF Model, which checks each object against the specification's own grammar.