Public API
What this package exposes, as it is built. Everything here is a promise to consumers: adding to it is a minor release, changing it is a major one.
Written from the code, not from the v2 plan. The plan's §2 described a
TcLibPdfSigner/TcpdfSignerpair, anEnums\SealPage, aConsole\namespace andapproval()/certify()/ltv()builder methods. None of them were built. See the modernisation record. This file supersedes that section.
Namespace layout
The root namespace LSNepomuceno\Signet is fixed; renaming it would be a gratuitous break. Structure below it:
src/
├── Signet.php # the entry point: wires the default graph
├── Config/ # value objects; the core reads no file
├── Contracts/ # CertificateReader, PdfSigner, SealRenderer,
│ # SignatureValidator, SignatureTransport,
│ # ProcessRunner, Encrypter,
│ # PdfSource, PdfDestination
├── Data/ # final readonly value objects
├── Enums/ # FontSize, ImageDriver, SignatureProfile,
│ # CertificationLevel, RevocationStatus,
│ # EncryptionAlgorithm, the ICP-Brasil three
├── Certificates/ # readers, parser, vault, factory,
│ # ICP-Brasil and subjectAltName readers
├── Signing/
│ ├── PendingSignature.php # the fluent builder
│ ├── IncrementalSigner.php # bound to PdfSigner
│ ├── ArchiveExtender.php # a further archive timestamp, no key needed
│ ├── Incremental/ # revision writer, byte range, DSS, timestamps
│ ├── Encryption/ # the standard security handler
│ └── Cades/ # detached CMS, HTTP transport
├── Validation/ # extractor, ASN.1 readers, verifier
├── Seal/InterventionSealRenderer.php
├── Io/ # sources and destinations for documents
├── Support/ # Files, SymfonyProcessRunner, TemporaryFile,
│ # TempDirectory, OpensslEncrypter, SigningLog,
│ # PdfFilters, PngReader, SrgbProfile
├── Console/ # sign, verify, fields, check
├── Exceptions/ # one class per failure mode, all sharing
│ # the SignetException interface
└── Testing/ # certificates, a local timestamp authority,
# a revocation one, and the fakes
bin/signetThe contracts a consumer may replace
Five contracts are bound in the service provider, and two of them are deliberately swappable by a consuming application:
Contracts\SealRenderer | replace to draw a different seal: a logo, a QR code, any layout |
Contracts\SignatureTransport | replace to own the TSA, OCSP and CRL calls, which is the SSRF surface (invariant 9) |
Writing that down makes their signatures public API, which is the cost and is worth paying: they were already published contracts, and a consumer who cannot find out they may be replaced has an extension point that does not exist.
SealRenderer::fromImage() is how artwork produced elsewhere gets in, Blade included. The package does not turn HTML into pixels (0004).
Exceptions
One class per failure mode (0008), and every one of them implements Exceptions\SignetException, which extends Throwable. That is what lets a consuming application catch the package's failures as a group instead of naming nineteen classes or catching \Exception and swallowing everything the runtime throws with them:
try {
$signet->newSignature()->certificate($pfx, $password)->pdf($path)->sign();
} catch (SignetException $e) {
// Every failure this package raises, and nothing else.
}The interface is surface, and adding a class that does not implement it is a hole in a promise rather than an oversight. tests/Support/ExceptionsTest.php builds its dataset from the directory, so a new exception is covered the moment the file exists.
InvalidCertificatePasswordException is the one distinction worth making by type rather than by message: it is the most common failure in production, and it extends InvalidCertificateContentException, the class it used to arrive as, so catching the general failure still works.
The builder
newSignature() returns a Signing\PendingSignature. It is the primary API.
use LSNepomuceno\Signet\Signet;
$signed = new Signet()->newSignature()
->certificate($pfxPath, $password)
->pdf($pdfPath)
->info(name: 'Lucas', reason: 'Contract')
->seal()
->sign();Certificate input, one of:
| Method | Takes |
|---|---|
certificate($path, $password) | a PKCS#12 file on disk |
certificateContents($bytes, $password) | PKCS#12 bytes already in hand |
certificatePem($path, $keyPath, $password) | PEM, key combined or separate |
certificateFromPem($contents, $key, $password) | PEM bytes already in hand |
usingCertificate($certificate) | an already-parsed Data\Certificate |
pdf($path, $password) takes the document's password as its second argument, when the document is encrypted. It is unrelated to the certificate's: one opens the file, the other unlocks the key that signs it (0030).
Document input: pdf($path), pdfContents($bytes, $fileName), or from($source) for anything that is not a local file (0102).
Everything else is optional: info(), seal(), sealFrom(), profile(), timestamp(), fieldName(). sign() closes the chain and returns a Data\SignedPdf.
The entry point
Signet wires the default object graph and offers one-shot entry points for callers that do not need the builder:
$signet = new Signet();
$signet->signFromFile($pfxPath, $password, $pdfPath);
$signet->signFromPem($pemPath, $password, $pdfPath, $keyPath);
$signet->encryptCertificate($pfxPath, $password);
$signet->decryptCertificate($hashKey, $encrypted, $password, $isBase64);
$signet->validate($pdfPath); // Data\SignatureReport
$signet->signatureFields($pdfPath);
$signet->extendArchive($pdfPath); // a further archive timestamp, no certificate
$signet->icpBrasil($pfxPath, $password); // Data\IcpBrasilReport
$signet->newSignature(); // Signing\PendingSignature
$signet->vault(); // Certificates\CertificateVaultIt is a convenience over the parts, never a layer in front of them. Nothing in src/ depends on it, every class it builds can be built directly, and an application with its own container should register those classes and ignore this entirely (0100).
Its constructor is also the substitution point: processes, transport, signer and certificateReader all accept a replacement, which is how Testing\FakePdfSigner and Testing\LocalTimestampAuthority are installed without a container.
Output
sign() does not decide transport. The same result answers all of these:
$signed->contents(); // string, the signed bytes
$signed->size(); // int
$signed->save($path); // string, the path written
$signed->download('doc.pdf'); // BinaryFileResponse, forces a download
$signed->toResponse(); // Response, renders inline
(string) $signed; // same as contents()Validation is symmetric:
$report = $signet->validate($pdfPath);
$report->isValid(); // every signature verifies against the bytes it covers
$report->isSigned();
$report->count();
$report->signers(); // list<Data\Signer>
$report->timestamps(); // DocTimeStamps, classified separately
$report->latest(); // ?Data\SignatureDetailsEach Data\SignatureDetails also carries when it claims to have been signed:
$signature->signedAt; // ?int, unix timestamp, null when absent
$signature->signerWasValidWhenSigned(); // ?bool, null when either date is unknownsignedAt is read from /M in the signature dictionary. That is inside the range the signature covers, so altering it breaks the signature, but it is still the signer's own clock: only an RFC 3161 timestamp, which pades-b-t and above carry, makes the time attributable to a third party.
signerWasValidWhenSigned() returns null rather than false when the time or the certificate dates are unknown. An absence is not a violation.
The certificates a signature embeds are also ordered into a chain, and the document's long-term validation material is reported:
$signature->chain; // list<Data\Signer>, leaf first
$signature->chainReachesRoot; // bool, whether it ends at a self-signed root
$report->securityStore; // ?Data\SecurityStore
$report->hasLongTermMaterial(); // bool, material present for every signatureEach link in the chain is confirmed with the issuer's public key rather than by matching names. None of this decides trust: whether the root is an authority you accept stays with the application.
isValid() answers "does this signature match these bytes". It does not check the issuer against a trust store: that decision stays with the application.
Where the seal goes
Data\SealPlacement carries position, size and page. All three are read.
use LSNepomuceno\Signet\Data\SealPlacement;
->seal(placement: new SealPlacement(x: 155, y: 250, width: 50, page: 2))
->seal(placement: new SealPlacement(x: 155, y: 250, width: 50)) // the last page
->seal(placement: new SealPlacement(x: 155, y: 250, width: 50, onEveryPage: true))page | 1-based, in the order the page tree declares. SealPlacement::LAST_PAGE, the default, is the last page |
onEveryPage | the seal appears on every page, and wins over page |
| A page the document does not have | SealPlacementException, rather than clamping to the nearest one |
onEveryPage still produces one signature: the widget goes on the first page and every further page gets a stamp annotation drawing the same appearance, so the JPEG is embedded once whatever the page count (0017).
Omitting seal() leaves the signature invisible, which is still a valid signature: the seal is an appearance, not part of the cryptography.
Trust
isValid() answers "does this signature match these bytes". Whether to accept the signer is a separate question, and it is answered against roots the application names:
$store = TrustStore::fromFile(storage_path('icp-brasil.pem'));
// or ::fromPem($bundle), ::fromDirectory($path), ::empty()
$report = $signet->validate($path, $store);
$report->isTrusted(); // ?bool, across every signature
$report->latest()?->isTrusted; // ?bool, per signatureThe package ships no trust store and will not. A bundled one goes stale between releases, and shipping it would make this package's release cadence the thing that decides whose signatures you accept (0016).
Three answers, not two:
null | no store was given. Nobody was asked, so there is nothing to report |
false | a store was given and the chain does not reach it |
true | the chain validates against it, path and all: intermediate validity, basicConstraints, key usage and name constraints, since OpenSSL does the checking |
An untrusted signature is not an invalid one. isValid() and isTrusted() are independent, and a document can be one without the other.
Certification signatures
A certification is the author's statement about what may happen to the document from here on, rather than a signer's statement about what the bytes were (ISO 32000-1 §12.8.2.2):
$signet->newSignature()
->certificate($pfx, $password)
->pdf($path)
->certify('form-filling') // no-changes | form-filling | annotations
->sign();
$report->isCertified(); // bool
$report->certification; // ?CertificationLevel
$report->acceptsFurtherSignatures(); // false only at no-changesThree rules are enforced rather than documented, each raising CertificationException: a certification has to be the first signature, there can be only one, and a document certified at no-changes cannot be signed again because a further signature is a further revision, which is what that level forbids.
certify() defaults to form-filling, since a document that still has to be signed is the common case.
What a certification does depends on the reader honouring it, and poppler does not: measured with a differential test, it allows form filling on a document certified at no-changes exactly as it does at form-filling. The bytes are correct and the package enforces its own rules, but enforcement in the reader is Adobe Reader and ITI Validar territory (0012).
Signing into a field the document already carries
A template laid out by someone else arrives with its fields placed, and the application is expected to fill the right one rather than append a field beside it:
foreach ($signet->signatureFields($template) as $field) {
$field->name; // 'SignatureManager'
$field->isSigned; // false
$field->pageNumber; // 3
$field->rectangle; // [30.0, 200.0, 200.0, 250.0]
$field->isVisible(); // true
}
$signet->newSignature()
->certificate($pfx, $password)
->pdf($template)
->intoField('SignatureManager')
->seal()
->sign();The field's own rectangle decides where the seal goes, so intoField() cannot be combined with a SealPlacement, and a field with a zero rectangle keeps the signature invisible even when seal() was called.
A field that is missing or already signed raises SignatureFieldException rather than falling back to appending, which would reproduce exactly the failure this exists to prevent (0013).
What the signer accepts
Both cross-reference forms, and both PDF 1.5 compression structures:
| Classic cross-reference table, §7.5.4 | read and written |
| Cross-reference stream, §7.5.8 | read and written. The revision follows the form the document already uses, because mixing them produces a file readers do not see as signed (0009) |
| Object stream, §7.5.7 | packed objects are read, and written back uncompressed by the revision that changes them (0015) |
The last two travel together in practice. Word, "print to PDF" in Chrome and LaTeX with compression emit both, and reading only the index is not enough: signing rewrites the catalog, so a catalog packed into an object stream has to be readable before the document can be signed at all.
What the signer cannot do
Stated here because a public API is also its boundaries, and each has a record.
Two entries stood here until 2.5 and are gone, which is worth saying rather than quietly deleting: encrypted documents were refused outright, and revocation material was counted rather than read. Both were named here as limits, and both were fixed by the records that named them (0030 and 0024).
What is left:
| RC4-encrypted documents | refused, deliberately: signing one means writing RC4 back into it (0030) |
| An encrypted document packed into object streams | the streams holding the objects are encrypted too, so reading the catalog needs decryption on the way in. Reachable, not done |
pades-b-lt and above, on an encrypted document | they append a security store and an archive timestamp whose streams this does not encrypt |
| A security handler other than the standard one | its key comes from somewhere this package cannot reach, by definition |
| Fetching revocation at validation time | evaluated from what the document carries, never from the network. That is a decision rather than a gap (0024) |
| Signing with an A3 token, a smart card or an HSM | out of scope: this package signs with A1 material it can hold |
Signature profiles
Enums\SignatureProfile owns each level's /SubFilter and what it requires.
| Case | Value | Adds |
|---|---|---|
Legacy | legacy | ISO 32000-1 detached CMS |
PadesBB | pades-b-b | CAdES signed attributes. The default |
PadesBT | pades-b-t | an RFC 3161 timestamp |
PadesBLT | pades-b-lt | a Document Security Store |
PadesBLTA | pades-b-lta | an archive timestamp over the whole file |
Every entry point accepts the enum case or its backing value, so configuration can stay as plain strings. timestamp() is shorthand for pades-b-t.
Configuration
Published with --tag=a1-pdf-sign-config. Nothing is required.
'temp_path' => env('A1_PDF_SIGN_TEMP_PATH'), // null = system temp directory
'signature' => [
'profile' => env('A1_PDF_SIGN_PROFILE', 'pades-b-b'),
'digest_algorithm' => env('A1_PDF_SIGN_DIGEST', 'sha256'),
'timestamp' => ['url' => …, 'username' => …, 'password' => …, 'timeout' => 20],
'ltv' => ['timeout' => 10],
],
'certificate' => [
'use_path_env' => …, // pass the host PATH to the openssl child process
'legacy' => …, // openssl -legacy, for RC2/40-bit PFX under OpenSSL 3.x
],
'seal' => [
'driver' => 'gd',
'font' => ['path' => null, 'size' => 'large', 'color' => '#16A085'],
'background' => null,
],Nullable config-backed arguments mean "use the configured default" rather than forcing every call site to repeat an infrastructure decision.
Console
pdf:sign {pdfPath} {certificatePath} {password} {fileName?} {--key=}
pdf:validate-signature {pdfPath}Both map a Throwable to a failure exit code, so they compose in a pipeline.
Stability
Data\* are final readonly and are public return types, so adding a property changes the public shape. The contracts in Contracts\ may be implemented by consumers, so adding a method to one is a breaking change for them even though callers are unaffected.