Skip to content

feat: verify the signature timestamp, report the profile, and decode the filters documents use - #242

Merged
lsnepomuceno merged 18 commits into
mainfrom
feat/symmetry-and-reach
Aug 12, 2026
Merged

feat: verify the signature timestamp, report the profile, and decode the filters documents use#242
lsnepomuceno merged 18 commits into
mainfrom
feat/symmetry-and-reach

Conversation

@lsnepomuceno

@lsnepomuceno lsnepomuceno commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Items 3, 4, 5, 6, 7, 8, 9, 10, 11 and 12 of the pending list: all ten that were not scope decisions.

Twenty-five commits, ten new decision records (0018 to 0025 plus the two landed separately), and five defects found that were not on the list at all.


The defects the work turned up

Each was a documented or implied behaviour doing something else, found by measuring rather than by reading.

sealFrom() ignored its argument It wrote the path onto SealPlacement::$imagePath and nothing in src/ ever read that property. A caller passing their own artwork got a render of the certificate. Documented in the README and on the site
The revision dropped the trailer /ID ISO 32000-1 §14.4 makes it the file's identity. Found by veraPDF failing a signed PDF/A document on §6.1.3
An invisible signature had no appearance ISO 19005-1 §6.9 requires one for every form field
The OCSP responder was trusted on sight The first version took the responder certificate from inside the response. A test offering an unrelated issuer still got Revoked: the response was vouching for itself, as every forgery does
DocTimeStampWriter counted /FT /Sig in raw bytes Undercounts a form packed into an object stream, so two fields could share a name

3 and 5. The signature timestamp is verified, and the profile reported

A pades-b-t signature carries an RFC 3161 token that the package had embedded since 2.0 and never looked at, so a B-T document reported valid with nobody having checked the one thing B-T adds over B-B.

Validation\Asn1Reader walks the CMS by declared lengths. Searching for the attribute's OID bytes would have been shorter and is wrong: that sequence occurs inside certificates the CMS embeds.

The token stamps the SignerInfo's signature value, not the document. Against the committed freetsa.org samples: B-B carries none, B-T, B-LT and B-LTA each verify, genTime read back to the minute each was produced.

4. Revocation is evaluated, not counted

The store said how many OCSP responses were present and nothing said what any of them meant, so a document could carry a responder's signed word that its signer was revoked and still report valid.

Nothing is believed on sight: every response and CRL is verified against the issuer with openssl_verify() first. Three answers, not two. One verified revocation outweighs any number of good answers. Revocation does not decide isValid(), because a revoked certificate still produces a signature that matches the bytes.

Fixtures produced by OpenSSL itself, which reports Response verify OK and revoked for the revoked one.

6 and 7. Filters and predictors

On a PDF 1.5 document whose cross-reference stream uses PNG-Up, the old decoder read the differences as the values: xref = {"2": 16908288}, one entry 16 MB into a 379-byte file, objects 1, 3 and 4 absent. Object 1 is the catalog, so a document nothing was wrong with came back as unsignable.

Filter chains, PNG predictors per row, TIFF predictor 2, LZW with /EarlyChange, ASCII85, run-length. LZW checked against the worked example in ISO 32000-1 §7.4.4.2 table 10.

8. PDF/A, measured with veraPDF 1.30.2

PDF/A-1b PDF/A-2b
Baseline PASS PASS
Invisible signature PASS PASS
Opaque seal FAIL §6.2.3.3 FAIL §6.2.4.3
Transparent seal FAIL §6.2.3.3, §6.4 FAIL §6.2.4.3, §6.2.10

Before the two fixes, all six failed. An invisible signature now keeps a PDF/A document conformant, which is a supported claim rather than a hope. A seal does not, and the reason is the colour space rather than the signature.

9 and 10. The seal

Transparency, by storing raw samples with the alpha in an /SMask: PDF has no PNG filter. The decoding is the one item 6 already added, since a PNG's IDAT is Flate with a PNG predictor. Data\SealLayout opens up the lines, baselines, font and background.

11 and 12. Archive chain and field locks

A1PdfSign::extendArchive() adds a link to the B-LTA chain with no certificate involved, so a scheduled job can walk an archive with no key material near it. Verified in CI against live freetsa.org.

/Lock plus the /FieldMDP transform that makes a reader enforce it, and a refusal to fill a field an earlier signature locked.


Breaking

  • The default seal is transparent. seal.transparent => false restores the opaque rectangle, and it is what a PDF/A-1 workflow needs.
  • Contracts\SealRenderer, Contracts\PdfSigner and Contracts\A1PdfSign all gain members: breaks for implementers, which the Roave check reports. Every new constructor parameter is appended and defaulted, so no arity moves.
  • Data\SignatureDetails gains five properties, so toArray() changes shape.

Not done, and named

  • The seal's DeviceRGB could become an /ICCBased space and be PDF/A-clean whatever the document declares. That means vendoring an sRGB profile into an MIT package, so it waits on a licensing decision rather than being done quietly.
  • Extending an archive does not refresh the security store first.
  • DocTimeStampWriter's own invisible widget still has no appearance: a B-LTA document was not part of the PDF/A measurement, and claiming the fix covers it without measuring is the thing 0025 exists to avoid.
  • Nothing this package signs reports anything but Unknown for revocation yet, because the debug certificate has no responder and no distribution point.

0010 closed the gap between what the signer writes and what the validator
reads, at the document level. It left the same gap one level down, inside the
CMS.

A pades-b-t signature carries an RFC 3161 token as an unsigned attribute of its
SignerInfo. The package has embedded one since 2.0 and never looked at it, so a
B-T document reported valid with nobody having checked the single thing B-T
adds over B-B: a third party's word on when the signature existed. Only the
DocTimeStamp of B-LTA was verified.

Validation\Asn1Reader and Asn1Node walk the CMS by its declared lengths.
DerReader answers how long a structure is, which is all its placeholder
trimming needs; reaching the signature value and the unsigned attributes needs
to know which child is which, in order. Searching for the attribute's OID bytes
would have been shorter and finds the same sequence inside certificates the CMS
embeds, so the OID is compared as dotted text only after the walk arrives at a
node in the attribute-type position.

The token stamps the SignerInfo's signature value, not the document, so a
verifier handed the document's bytes fails on every correctly built file. Both
halves have to hold: the token's own CMS verifies, and its imprint is the
digest of those octets. Keeping only the first would accept a token lifted from
an unrelated file.

A signature with no token reports null rather than false. It is not a signature
with a broken token, and collapsing the two reports every baseline signature as
carrying a failure.

SignatureProfile::classify() reads the level from what the document carries
rather than from what it claims: a verified token lifts B-B to B-T, a store
lifts that to B-LT, a DocTimeStamp lifts that to B-LTA. Both answers are
reported, so a caller can see a file that says CAdES and carries nothing a
CAdES signature needs.

Measured against the committed samples, which carry freetsa.org tokens: B-B has
none, B-T, B-LT and B-LTA each verify, and the genTime read back matches the
minute each sample was produced.
PdfStream decoded two filters, /FlateDecode and /ASCIIHexDecode, each as a bare
name, and ignored /DecodeParms entirely. That covers what this package writes,
not what it is handed, and it fails in the least useful way: an object that
cannot be read is an object the signer will not sign around, so a document
nothing is wrong with comes back as unsignable.

The predictor is the one that matters. Every modern generator compresses its
cross-reference stream with PNG-Up, because consecutive rows of a
cross-reference table differ from each other by very little. Measured on a
three-object PDF 1.5 document built for the purpose, the old decoder inflated
the stream and then read the differences as the values:

  xref = {"2": 16908288}

One entry, pointing 16 MB into a 379-byte file, with objects 1, 3 and 4 absent.
Object 1 is the catalog and signing rewrites the catalog, so the document was
unsignable and the error blamed the wrong thing.

Support\PdfFilters now applies the whole /Filter and /DecodeParms pair in order:
filter chains, PNG predictors 10 to 15 read per row as RFC 2083 specifies,
TIFF predictor 2, LZW with /EarlyChange, ASCII85 and run-length.

A filter that is not implemented yields null rather than the raw bytes. Handing
back something compressed that happens to contain "<<" is how a caller ends up
parsing noise as objects. /DCTDecode and /JPXDecode stay out on purpose: streams
are read to find objects and an image is never one.

The LZW decoder is checked against the worked example in ISO 32000-1 §7.4.4.2
table 10 rather than round-tripped through an encoder written in the same hour,
which would only establish that two pieces of code agree with each other.

Nothing about what the package writes changes: XrefStreamWriter still emits no
filter, since a revision indexes a handful of objects and zlib's header and
checksum would outweigh what they compress.

One existing test asserted a limitation that no longer holds: it named
/LZWDecode as "a filter it does not decode", and now names /DCTDecode.
@lsnepomuceno lsnepomuceno added enhancement New feature or request php Pull requests that update php code labels Aug 11, 2026
@lsnepomuceno lsnepomuceno self-assigned this Aug 11, 2026
…y there

A template is signed in stages: the manager signs, then the employee, then
somebody fills in an amount. Nothing stopped the amount changing after the
manager signed it.

ISO 32000-1 §12.7.4.5 answers that with a /Lock on the signature field, and
§12.8.2.4 with the /FieldMDP transform that makes a reader enforce it. The
package had neither half, and the missing read is the one that produced broken
documents: signing into a field an earlier signature had locked went through
happily, and the caller learned about it from Adobe.

Both halves are written together. The widget's /Lock is what a reader shows;
the FieldMDP transform is what it enforces, and a document carrying only the
first says the fields are locked and lets them be filled anyway. Both transforms
share one /Reference array, because a signature may certify the document and
lock fields at once and two /Reference entries leave a reader to pick one. That
puts a FieldMDP beside a DocMDP in exactly the arrangement 0012 already guards
against misreading, and the certification still reads back.

Filling a locked field now raises rather than warning, which is the position
this package has taken on every equivalent question: a missing field, a
no-changes certification, an encrypted document, a seal on a page that does not
exist.

Two things a lock deliberately does not do. A /Lock on a field nobody has signed
is not in force, since it states what will happen when the field is signed, and
reading it otherwise would make a template that ships one unsignable. And a lock
cannot lock the field that imposed it, whose signature has already filled it.

An /Include with no fields locks nothing and an /Exclude with no fields locks
every field there is. Both are refused before anything is written, the second
being much the more expensive to discover late.

Field names are escaped: a name carrying ")" would end the /Fields array early
and leave the rest of it as syntax.

Verified with poppler on a locked document and on one that is certified and
locked at once: both report Signature is Valid and Total document signed, and
the unsigned second field is still listed as fillable.
B-LTA is not a state a document stays in. An archive timestamp is worth exactly
what the authority's certificate and the digest algorithm behind it are worth,
and both age. ETSI EN 319 142-1 answers that with a chain: before the current
timestamp stops being verifiable, a new one is stamped over everything,
including the previous timestamp, while that one still checks out.

The package could produce the first link and not the second, so a document
signed for a twenty-year retention had to be re-signed to stay checkable, and
re-signing loses the original signing time, which is the one thing the archive
existed to preserve.

Signing\ArchiveExtender and A1PdfSign::extendArchive(). No certificate is
involved: a DocTimeStamp is signed by the authority, not the signer, so a
scheduled job can walk a directory of archived documents with no key material
anywhere near it. That is why it is not a method on PendingSignature, whose
whole shape starts from a certificate.

It refuses the same things a signature refuses: an unsigned document, because
timestamping one attests bytes nobody vouched for and returns a file that looks
archived while proving nothing about a signer; and a document certified at
no-changes, because an archive timestamp is a further revision.

A document with no timestamp yet is not refused. isArchived() reports rather
than gates, since the chain has to start somewhere and refusing would make
signing time the only chance to reach B-LTA.

Also fixes a latent defect this made reachable twice in one document:
DocTimeStampWriter named its widget by counting "/FT /Sig" in the raw bytes,
which undercounts a document whose fields are packed into an object stream. It
now counts what /AcroForm /Fields declares.

The guards run offline. The extension itself needs a timestamp authority and is
in the network group, asserting that the previous links survive byte for byte,
that the document then carries two timestamps and one signature with all three
verifying, and that the signature underneath still reads as pades-b-lta.

One gap named rather than implied: nothing refreshes the Document Security
Store while extending. A rigorous chain re-collects revocation material for the
previous timestamp's certificate first.
… it out

Three things about the seal, and the third is a defect rather than a limit.

It was always an opaque rectangle. The renderer encoded to JPEG and JPEG has no
alpha channel, so the artwork's own transparency was flattened at encode time.
Every seal this package has ever stamped is a solid block sitting on top of
whatever it covers.

It always said the same three things, at three baselines fixed in the source as
TEXT_ROWS = [80, 150, 250]. A seal that has to carry a protocol number, a
department or a second language had nowhere to put it.

And sealFrom() ignored its argument. It wrote the path onto
SealPlacement::$imagePath and nothing in src/ ever read that property, so a
caller who passed their own artwork got a render of the certificate instead.
Same shape as the $page defect 0017 closed: a documented builder method, in the
README and on the documentation site, silently doing something else.

PDF has no PNG filter, so a transparent seal is stored as deflated RGB samples
with the alpha channel as a separate greyscale image in /SMask (§8.9.5.4). The
decoding is the one already here: a PNG's IDAT is zlib with the per-row PNG
predictor, which is exactly /Filter /FlateDecode with /DecodeParms
<</Predictor 15 ...>>, so PdfFilters undoes it unchanged and PngReader only has
to read IHDR and split the interleaved samples. A PNG it cannot separate falls
back to the opaque JPEG: the seal still renders, and the only thing lost is the
transparency that was never there before.

Data\SealLayout carries the lines, their baselines, the left edge, the font,
the colour and the background, every one optional and null meaning "use the
configured default". seal.text.x and seal.text.rows move into configuration, so
the two class constants go. A line with no baseline is not drawn: stacking it
onto the last row would put two lines on top of each other, which reads as a
rendering fault rather than a caller mistake.

SealRenderer::fromImage() embeds the caller's artwork, honouring its
transparency, and draws only what a layout asks for over it.

BREAKING CHANGE: the default seal is now transparent. seal.transparent is true,
so the artwork's alpha is honoured rather than flattened; set it to false for
the previous opaque rectangle. This changes what every existing seal looks like
and it is the fix rather than a preference, since the artwork has always had an
alpha channel and the encoder has always thrown it away.

Verified by rendering: the opaque seal hides the page text behind a white
block, the transparent one lets it show through, and a laid-out seal says what
was asked instead of the certificate's details. pdfsig reports all three valid.
…g it

The Document Security Store has been written since 2.0 and counted since 2.2:
securityStore->ocspResponses said how many responses were there and nothing
said what any of them meant. So a document could carry a responder's signed
statement that its signer had been revoked, and this package would report it as
valid while cheerfully announcing that one OCSP response was present.

That is the worst direction for the gap to run in. The material exists so a
verifier can answer after the responder is gone, and carrying it is B-LT's
entire purpose.

RevocationReader resolves /OCSPs and /CRLs into DER; RevocationChecker decides
what they say about a serial; SignatureDetails::$revocation reports it as
Good, Revoked or Unknown.

Nothing is believed on sight. Each response and CRL is verified against the
issuer with openssl_verify() before its contents are read, and material that
does not verify is material that is not there.

That rule caught a real hole while it was being built. The first version took
the responder certificate from inside the response and used it as a
verification key, since RFC 6960 allows a delegated responder and the response
carries it. A test offering an unrelated issuer still got Revoked back: the
response was vouching for itself, which is exactly what a forged one does. An
embedded responder is now accepted only when one of the supplied issuers really
issued it, per §4.2.2.2.

Three answers, not two: "nothing in this document says" is not "this
certificate is fine". A verified CRL that lists nothing is Good rather than
Unknown, because a revocation list is a positive statement. One verified
revocation outweighs any number of good answers.

Revocation does not decide isValid(): a revoked certificate produces a
signature that matches the bytes perfectly. What it stops being is a signature
anyone should accept, which is policy and stays with the application, beside
trust.

The fixtures are produced by OpenSSL itself rather than by this package: a CA, a
leaf with serial 0x1234, and a good and a revoked answer of each kind. openssl
ocsp -respin reports "Response verify OK" and "revoked" for the revoked one.
Checking against material this package generated would only establish that the
reader agrees with the writer.

Nothing this package signs will report anything but Unknown yet: the debug
certificate is self-signed with no responder and no distribution point, so
DssWriter has no material to collect. Named in the record rather than implied.
…ainst

The .der fixtures went in and the two .pem files did not: the repository
ignores *.pem outright, because a PEM private key is often unencrypted and that
matters more than convenience. The gate did its job and CI failed on
FileNotFoundException.

Narrowed rather than weakened, the same way /samples/*.pem already is. The two
files are certificates and nothing else, the CA that signed the responses and
the leaf they answer about, and no private key is generated into that
directory: the OpenSSL run that produces the fixtures deliberately copies only
the certificates out.
…an appearance

Whether signing a PDF/A document leaves it conformant had never been checked in
either direction. Reasoning does not close that: either a validator says yes or
nobody knows.

Measured with veraPDF 1.30.2 against two Ghostscript baselines, each confirmed
conformant first. Before this commit all six combinations failed, including the
invisible signature. Two defects came out of it and both are fixed.

The revision dropped the file identifier. RevisionWriter::trailer() wrote /Size,
/Root, /Info and /Prev and no /ID, and the cross-reference stream writer omitted
it too. That is a defect well beyond PDF/A: ISO 32000-1 §14.4 makes /ID the
file's identity, so a revision without it hands every reader a document that has
stopped identifying itself. The pair is carried through unchanged, since the
second string is meant to change when the file does and inventing one here would
be inventing a digest no reader checks. A document with no /ID of its own gets
none invented for it: a file identifier is the producer's.

An invisible signature had no appearance. ISO 19005-1 §6.9 wants every form
field to have an appearance dictionary, and a signature with no seal is still a
form field. It now gets a form XObject with a [0 0 0 0] box, which draws
nothing, which is what invisible means.

After both, an invisible signature keeps a PDF/A-1b and a PDF/A-2b document
conformant. That is now a supported claim rather than a hope.

A visible seal still does not, and the reason is the colour space rather than
the signature: the seal is DeviceRGB, which both parts allow only where the file
carries an RGB OutputIntent. Adding one is the author's statement about their
own document, not something to add on the way past. Giving the seal its own
ICCBased space would fix it and means vendoring an sRGB profile into an MIT
package, so it is named as the next step rather than done quietly. A transparent
seal can never be PDF/A-1 at all, since §6.4 forbids /SMask outright, which is
the whole reason seal.transparent exists as a setting.

veraPDF is Java and is not in CI, so the verdicts live in the record and the
suite guards the structures each one turned on.

One existing test asserted the behaviour this changes: an invisible signature
used to carry no /AP.
… job

The measurement in 0025 lived in a record, and a record does not fail when
somebody breaks it. It had already earned a gate: reasoning said an invisible
signature obviously preserved conformance, and all six combinations failed on a
missing trailer /ID nobody would have thought to look for.

tests/PdfAValidationTest.php now runs veraPDF itself, in the pdfa group. It
blocks, unlike the timestamp group: veraPDF is deterministic and runs offline
once installed, so a failure there is this package's rather than somebody
else's outage.

The group asserts the failures too. A sealed document is not PDF/A conformant,
for reasons that are the colour space rather than the signature, and asserting
that is what will tell someone the day it changes.

Pinned to 1.30.2 in both the Dockerfile and the workflow: a validator that
changes its verdicts between builds cannot be the thing a gate is measured
against.

veraPDF is Java, so it is installed behind a build argument and only by the
`pdfa` compose service. The day-to-day image does not carry a JRE for one
group, and the group skips where the validator is absent, so a normal run is
unaffected: 7 skipped, everything else as before.

And it stays an instrument rather than becoming a dependency. Nothing in src/
may invoke veraPDF, pdfsig, pdftoppm or Ghostscript: a package that shells out
to a JVM to answer a runtime question would be a different package, and the
consuming application would inherit an installation requirement nobody wrote
down. ArchTest enforces it, tokenised so the comments explaining the rule do
not trip it, which the first version did.
…ncies

Three instruments, and one leak they turned up.

qpdf reads the same cross-reference tables and streams this package writes by
hand, and is strict where poppler forgives: a table with slightly wrong offsets
still opens in a reader that recovers by scanning, and the fault stays hidden.
It is C++ and a couple of megabytes, so unlike veraPDF it goes in the everyday
image and needs no service of its own.

The gate is comparative: signing must not introduce a complaint that was not
already there. That formulation came out of the first run. Two fixtures are
minimal documents whose pages carry no /Resources, which qpdf 12 warns about
and qpdf 11 did not, and a gate that failed on it would have been measuring the
fixture rather than the signer. The fixture fault is named as a follow-up
rather than papered over. Every shape the writer can produce is covered: table,
cross-reference stream, object stream, predictor, filled field, certified,
legacy, and four stacked revisions.

The robustness harness runs corrupted input through every reader that parses
bytes the application did not write: the document reader, the signature
extractor, the ASN.1 walker, the stream filters, the PNG reader, the revocation
checker. The contract is narrow and the same for all of them: read it, or throw
the documented exception. Never a TypeError, never a fatal. Fixed seed, so a
failure is reproducible and a green run means the same thing twice. It found
nothing, which is the result worth having from a guard.

composer audit runs in CI. setup-php sets COMPOSER_NO_AUDIT, so advisories were
silently unchecked; for a signing package a known vulnerability in the tree is
worth blocking on. --abandoned=ignore keeps the gate about vulnerabilities
rather than about maintenance status.

And the leak. Everything built for testing is export-ignored so it never
reaches a consumer, and that list had drifted: phpstan.neon, pint.json,
composer-dependency-analyser.php and package-lock.json were all being
distributed, each added later than the rule. tests/DistributionTest.php now
asks git archive what a release actually contains rather than trusting the
list, in both directions: nothing built for testing ships, and the package
still does.

qpdf joins veraPDF, pdfsig, pdftoppm and Ghostscript in the arch rule that
keeps every instrument out of src/. They are development and validation tooling
and none of them may reach production.
The PDF/A group skipped by default. It was behind a build argument, installed
only by a dedicated compose service, so the everyday image would not carry a
JRE for one group.

That was the wrong trade, and the reason is not the JRE. A group that skips
itself leaves the conformance claims unverified on the machine where the work is
being done, and it exits zero: the first run of a job whose entire purpose is
that group could have gone green having validated nothing.

veraPDF is installed in the development image and in CI, alongside qpdf, and
composer test carries --fail-on-skipped. Every check has to run somewhere, and a
skip is how one quietly stops. The suite is 390 passing and nothing skipped.

The dedicated compose service and CI job are gone: with the validator present
everywhere, the group runs with the rest of the suite across the whole PHP
matrix rather than once in a job of its own.

The skip guards stay in the two files that need a tool, because a named skip
reads better than "verapdf: not found" for somebody running outside the
container. They cannot hide any more.

verapdf-rest was considered and is not used. It does not remove the JVM, it
relocates it, and in exchange the gate would depend on a service being up, on
container networking, and on a second project to pin. A deterministic offline
check is worth more than a tidier container for eight seconds of runtime.

0026 records all of it: what each instrument has caught, why none may be reached
from src/, why none may ship, and why nothing skips.
0025 said the archive timestamp's widget had not been given one, because a
B-LTA document was not part of that measurement and claiming the fix covered it
would have been the thing the record exists to avoid.

Not claiming it was right. Leaving it was not. samples/pades-b-lta.pdf shows
the fault outright, in a file committed months ago:

  28 0 obj
  <</Type/Annot/Subtype/Widget/FT/Sig/Rect[0 0 0 0]/T (Timestamp2)/V 27 0 R...

No /AP, beside a signature widget in the same document that has one. ISO
19005-1 §6.9 wants every form field to have an appearance dictionary, and a
timestamp is a form field like any other, so the claim "an invisible signature
keeps a PDF/A document conformant" would have stopped holding at B-LTA. That is
precisely the combination an archive wants: PDF/A plus long-term validation is
the canonical twenty-year artefact, and it was the one cell of the matrix
nobody had measured.

DocTimeStampWriter now writes the same empty form the signature widget gets,
BBox [0 0 0 0], which draws nothing.

The verdict for it is in the network group, since a B-LTA document cannot be
produced without reaching a timestamp authority, so it is reported rather than
blocking, on the same terms as every other test that needs one. Reported beats
unmeasured, which is how this got here. The test asserts both halves: the
widget carries /AP, and veraPDF passes the document at 1b and 2b. A B-T
document is measured beside it, where the token rides inside the CMS and
nothing is added to the page at all.
…ehind it

Invariant 9 says network access stays behind the injected transport, and it was
true and useless. HttpTransport was final and all three collaborators depended
on the concrete type, so the only way to exercise anything above pades-b-b was
to reach freetsa.org, which put it in the network group: deliberately
non-blocking, because somebody else's outage is not a defect here.

The consequence was that three of the five PAdES levels this package advertises
could regress without CI going red. That is not hypothetical: three network
tests were committed broken in this very branch, went green, and were noticed
only by reading the log.

Injection you cannot substitute is not injection. Contracts\SignatureTransport
names the three calls, HttpTransport implements it, the container binds one to
the other, and the collaborators depend on the interface.

Testing\LocalTimestampAuthority is the substitute, and it is a real authority
rather than a stub: openssl ts -reply is a complete RFC 3161 responder that
needs no server and no connection, and its tokens are signed, verifiable and
carry the imprint of the bytes they were handed. The offline tests assert
timestampVerified === true, checked through OpenSSL exactly as a freetsa.org
token is, rather than asserting that something was embedded. Canned bytes could
not pass: the imprint has to match the signature value produced in that run.

Six behaviours move from reported to gated, in the ordinary suite and not in a
group: signing at B-T, B-LT and B-LTA, the archive chain of 0022, the imprint
refusal, and PDF/A conformance at B-LTA, which 0025 could measure only against a
live authority.

What the local authority is not is a third party. It establishes that the
package builds a request, embeds the reply and verifies it correctly, and cannot
establish that it interoperates with somebody else's TSA. So the live tests
stay, in the network group, answering the question the offline ones cannot.

A TSA container was considered and rejected for the same reason verapdf-rest
was: it relocates the dependency rather than removing it, and openssl is already
in the image because signature verification shells out to it anyway.
@lsnepomuceno
lsnepomuceno merged commit 8bf3b9a into main Aug 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request php Pull requests that update php code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant