Enterprise edition
Batch signature validation
At a glance
Section titled “At a glance”NextPDF Enterprise validates digital signatures across many PDF documents in one call. NextPDF\Enterprise\Signature\BatchSignatureValidator::validate() takes a list of documents and returns a BatchValidationReport. Every signature passes the same fail-closed pipeline: cryptographic CMS authentication over the signed byte range, trust-anchored certificate-chain validation, and OCSP/CRL revocation checking. The report carries per-document and per-signature detail — CertChainStatus, RevocationStatus, TimestampStatus — so compliance tooling can re-derive every verdict from its recorded evidence.
The verdict model is deliberately strict. A signature is Valid only when all evidence is affirmatively established. Missing revocation evidence yields Indeterminate, never Valid. This page covers the batch orchestrator and its result types. The single-document AdES verify-side is documented in Signature verification. Embedding long-term validation material is documented in Archive.
Availability & licensing
Section titled “Availability & licensing”This capability ships in NextPDF Enterprise (nextpdf/enterprise) and activates with an Enterprise-tier license envelope. A deployment without that entitlement does not load the capability’s classes. Compare editions and get a license.
Install
Section titled “Install”composer require nextpdf/enterpriseThe nextpdf/premium metapackage also resolves the Enterprise package. Activation uses your Enterprise license envelope; see Licensing and activation. The batch types autoload under NextPDF\Enterprise\Signature. No PHP extension beyond the engine baseline is required.
Conceptual overview
Section titled “Conceptual overview”One call to validate() processes a list of DocumentSignatureInput values. Each input carries a document identifier, the raw PDF bytes, and optional PEM-encoded trust anchors. The validator extracts each document’s signature dictionaries and runs three stages per signature.
Stage 1 — cryptographic authentication. The detached CMS/PKCS#7 blob from /Contents is verified over the bytes that /ByteRange covers. The verifier recomputes the content digest itself and compares it against the messageDigest signed attribute. It never trusts a digest the producer supplied (RFC 5652 §5.6). The signature value must verify, and the signing certificate must be bound to the CMS. Absent or malformed /Contents or /ByteRange, an unparseable CMS, a digest mismatch, or a failed signature check all fail closed. A signature that verifies under SHA-1 is treated as weak and is never a full pass.
Stage 2 — chain validation and trust anchoring. The signer chain recovered from the CMS is validated as the prospective certification path. The trustedCerts you supply are the trust-anchor input, in the RFC 5280 §6.1.1 sense: the chain terminus must match a supplied anchor by DER SHA-256 fingerprint. A structurally consistent chain whose terminus is not a configured anchor is never reported trusted. With no usable anchors, only the structural verdict is reported, and CertChainStatus::$trusted stays false.
Stage 3 — revocation. Revocation runs over the recovered chain after authentication, mirroring the ETSI EN 319 102-1 model where revocation checking follows successful path validation (clause 5.2.6.2). OCSP is primary: only a cryptographically verified response counts, as Good or Revoked. The CRL path is the fallback and attests list freshness. When neither client is configured, the status is unavailable.
The per-signature verdict is a SignatureValidationStatus. The taxonomy mirrors the ETSI EN 319 102-1 status model (TOTAL-PASSED / TOTAL-FAILED / INDETERMINATE) at per-signature granularity:
| Evidence | Verdict |
|---|---|
| Certificate confirmed revoked | Invalid (decisive, regardless of other checks) |
| CMS authentication failed, no signer material recovered | Error |
| CMS authentication failed, signer material present | Invalid |
| Authenticated, but the chain does not validate | Invalid (or Error with no chain) |
| Authenticated and chain-valid, but no confirmed trust anchor | Indeterminate |
| Authenticated, chain-valid, trusted, but no conclusive non-revocation | Indeterminate |
| All of the above affirmatively established | Valid |
The conclusive-non-revocation rule. “Not proven revoked” is not the same as “proven not revoked”. A Valid verdict requires at least one Good revocation result. A verified-good OCSP response is the conclusive form: it asserts the signer certificate’s own status. A cryptographically accepted, fresh CRL also satisfies the gate in this implementation, but only as a freshness-and-integrity attestation — the path does not parse per-serial entries, so it provides no per-serial revocation assurance and never a positive revoked verdict. Configure OCSP wherever positive revocation detection matters: a CRL-only deployment will not surface a revoked certificate as Invalid. When both OCSP and CRL results are Unknown or Unavailable, the revocation status is undetermined, and the verdict is Indeterminate. This follows ETSI EN 319 102-1: unavailable revocation status information results in INDETERMINATE, never a pass (clause 5.1.3, TRY_LATER). This is a behavior hardening in 3.1.0 with backwards-compatibility impact: earlier releases could report Valid without conclusive revocation evidence. Deployments that configure no OCSP or CRL client now commonly see Indeterminate where they previously saw Valid.
Two boundaries frame this capability honestly. First, the batch validator does not evaluate embedded timestamp tokens: TimestampStatus in batch results is always the absent state. RFC 3161 timestamp evaluation belongs to the single-document verify-side; see Signature verification. Second, this page is read-only validation. Embedding DSS/VRI material for long-term validity is the Archive capability.
Why it works this way
Section titled “Why it works this way”The load-bearing decision is a fail-closed verdict producer. Valid is minted only from affirmative evidence on all three axes: cryptographic authentication, a trust-anchored chain, and conclusive non-revocation. Anything unestablished degrades to Indeterminate rather than defaulting to a pass, which is the EN 319 102-1 posture for missing revocation material. Batch throughput never buys back rigor: the batch layer is orchestration over the same audited CMS verifier used for a single document, so a 1,000-document run applies identical cryptography. The report also separates evidence from verdict — CertChainStatus and RevocationStatus record the inputs each verdict rests on, so an auditor can re-derive it later.
Design background: Signing at scale, without compromise.
API surface
Section titled “API surface”All symbols below are public API in nextpdf/enterprise 3.1.0.
BatchSignatureValidator
Section titled “BatchSignatureValidator”final class BatchSignatureValidator{ public function __construct( ?SignatureExtractor $extractor = null, ?CertificateChainValidator $chainValidator = null, private readonly ?OcspClient $ocspClient = null, private readonly ?CrlFetcher $crlFetcher = null, ?CmsSignatureDataExtractor $cmsExtractor = null, private readonly ClockInterface $clock = new SystemClock(), )
public function validate(array $inputs): BatchValidationReport}Throws or fails with: validate() throws \InvalidArgumentException if the input list is empty, and \OverflowException when the batch exceeds 1,000 documents. A document that is not a parseable PDF does not throw; it becomes a per-document Error result. The $clock is a PSR-20 Psr\Clock\ClockInterface used for the CRL freshness decision, so verdicts are deterministic under a frozen test clock.
DocumentSignatureInput
Section titled “DocumentSignatureInput”final readonly class DocumentSignatureInput{ public string $documentId;
public function __construct( string $documentId, public string $pdfData, public array $trustedCerts = [], )}Throws or fails with: \InvalidArgumentException if $documentId is an empty string. $trustedCerts is a list of PEM-encoded trust-anchor certificates.
BatchValidationReport
Section titled “BatchValidationReport”final readonly class BatchValidationReport{ public function __construct( public array $documents, public int $totalDocuments, public int $totalSignatures, public int $totalValid, public int $totalInvalid, public float $durationMs, )
public function allValid(): bool
public function hasDocumentsWithoutSignatures(): bool
public function toJson(?CertPiiGuard $piiGuard = null): string}Throws or fails with: toJson() throws \JsonException if encoding fails. allValid() is true only when there are signatures and none is non-valid. By default toJson() applies a privacy-by-default NextPDF\Enterprise\Signature\Eidas\CertPiiGuard, which masks the signer name, root issuer, TSA name, and chain-issue diagnostics; see eIDAS assurance levels for the guard’s API.
DocumentValidationResult and DocumentValidationStatus
Section titled “DocumentValidationResult and DocumentValidationStatus”final readonly class DocumentValidationResult{ public function __construct( public string $documentId, public DocumentValidationStatus $status, public array $signatures, public int $validCount, public int $invalidCount, )
public function hasSignatures(): bool
public function totalSignatures(): int}enum DocumentValidationStatus: string{ case AllValid = 'all_valid'; case SomeInvalid = 'some_invalid'; case AllInvalid = 'all_invalid'; case NoSignatures = 'no_signatures'; case Error = 'error';}Throws or fails with: nothing. Immutable value object and backed enum.
SignatureValidationResult and SignatureValidationStatus
Section titled “SignatureValidationResult and SignatureValidationStatus”final readonly class SignatureValidationResult{ public function __construct( public SignatureValidationStatus $status, public CertChainStatus $certChain, public TimestampStatus $timestamp, public RevocationStatus $revocation, public string $signer, public string $level = '', public string $subFilter = '', public string $reason = '', )
public function isValid(): bool}enum SignatureValidationStatus: string{ case Valid = 'valid'; case Invalid = 'invalid'; case Indeterminate = 'indeterminate'; case Error = 'error';}Throws or fails with: nothing. $signer is the CMS-verified certificate subject when authentication passed, else the empty string. $level is a SubFilter-derived label (for example B-B for ETSI.CAdES.detached), not an AdES conformance determination.
CertChainStatus
Section titled “CertChainStatus”final readonly class CertChainStatus{ public function __construct( public bool $valid, public bool $trusted, public int $chainLength, public string $rootIssuer, public array $issues = [], )
public function hasIssues(): bool}Throws or fails with: nothing. $trusted is set only on a confirmed trust-anchor membership hit, never from anchor-list non-emptiness.
RevocationStatus and RevocationCheckResult
Section titled “RevocationStatus and RevocationCheckResult”final readonly class RevocationStatus{ public function __construct( public RevocationCheckResult $ocspStatus, public RevocationCheckResult $crlStatus, public bool $isRevoked, public ?DateTimeImmutable $revocationDate = null, )
public static function unavailable(): self
public function hasConclusiveGood(): bool}enum RevocationCheckResult: string{ case Good = 'good'; case Revoked = 'revoked'; case Unknown = 'unknown'; case Unavailable = 'unavailable';}Throws or fails with: nothing from the members shown. The class also exposes evidence-checked static factories (good(), revoked(), fromResults()), which throw \InvalidArgumentException when the claimed status contradicts the OCSP/CRL evidence — a revoked result can never be minted as not-revoked, or vice versa. hasConclusiveGood() is true only for a not-revoked status where at least one check is Good.
TimestampStatus
Section titled “TimestampStatus”final readonly class TimestampStatus{ public function __construct( public bool $present, public bool $valid, public ?DateTimeImmutable $timestampTime = null, public string $tsaName = '', public array $issues = [], )
public static function absent(): self}Throws or fails with: nothing. In batch results this is always the absent() state; see Edge cases & gotchas.
Code sample — Quick start
Section titled “Code sample — Quick start”Validate one document and read the report. This sample uses an unsigned PDF, so the output is deterministic.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use NextPDF\Enterprise\Signature\BatchSignatureValidator;use NextPDF\Enterprise\Signature\DocumentSignatureInput;
// A minimal, unsigned PDF: the validator reports it as no_signatures.$unsigned = "%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n";
$validator = new BatchSignatureValidator();
try { $report = $validator->validate([ new DocumentSignatureInput(documentId: 'doc-001', pdfData: $unsigned), ]);} catch (\InvalidArgumentException $e) { // Empty input list, or an empty documentId. echo 'Rejected: ' . $e->getMessage() . "\n"; exit(1);}
echo 'Documents: ' . $report->totalDocuments . "\n";echo 'Signatures: ' . $report->totalSignatures . "\n";
foreach ($report->documents as $doc) { echo $doc->documentId . ': ' . $doc->status->value . "\n";}
echo 'All valid: ' . ($report->allValid() ? 'yes' : 'no') . "\n";echo 'Unsigned documents: ' . ($report->hasDocumentsWithoutSignatures() ? 'yes' : 'no') . "\n";Expected output:
Documents: 1Signatures: 0doc-001: no_signaturesAll valid: noUnsigned documents: yesNote that allValid() reports no here: it requires at least one signature and no non-valid results, so an empty signature set never passes silently.
Code sample — Production
Section titled “Code sample — Production”Validate a directory of signed contracts with revocation clients, trust anchors, batch chunking, and a PII-guarded JSON report.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use NextPDF\Enterprise\Security\Ltv\CrlFetcher;use NextPDF\Enterprise\Security\Ltv\OcspClient;use NextPDF\Enterprise\Security\Ltv\OcspResponseCache;use NextPDF\Enterprise\Signature\BatchSignatureValidator;use NextPDF\Enterprise\Signature\DocumentSignatureInput;use NextPDF\Enterprise\Signature\SignatureValidationStatus;
// Any PSR-18 client works; Guzzle shown here.$httpClient = new \GuzzleHttp\Client(['timeout' => 10]);
// Revocation clients make a conclusive non-revoked (Good) result reachable.// Without them, every verdict tops out at Indeterminate. The response cache// lets repeat signers across the batch resolve without extra network calls.$validator = new BatchSignatureValidator( ocspClient: new OcspClient($httpClient, cache: new OcspResponseCache()), crlFetcher: new CrlFetcher($httpClient),);
// Trust anchors are an input: the chain terminus must match one of these.$anchors = [(string) file_get_contents('/etc/nextpdf/trust/enterprise-root.pem')];
$inputs = [];foreach (glob('/var/contracts/signed/*.pdf') ?: [] as $path) { $inputs[] = new DocumentSignatureInput( documentId: basename($path), pdfData: (string) file_get_contents($path), trustedCerts: $anchors, );}
$exit = 0;
// One call is capped at 1,000 documents; chunk larger runs.foreach (array_chunk($inputs, 1000) as $batch) { try { $report = $validator->validate($batch); // Signer PII is redacted by default in the serialized report. file_put_contents('/var/log/nextpdf/batch-report.jsonl', $report->toJson() . PHP_EOL, FILE_APPEND); // one JSON document per line } catch (\InvalidArgumentException | \OverflowException $e) { fwrite(STDERR, 'Batch rejected: ' . $e->getMessage() . "\n"); exit(2); } catch (\JsonException $e) { fwrite(STDERR, 'Report encoding failed: ' . $e->getMessage() . "\n"); exit(3); }
foreach ($report->documents as $doc) { foreach ($doc->signatures as $sig) { if ($sig->status !== SignatureValidationStatus::Valid) { $exit = 1; fwrite(STDERR, sprintf( "%s: %s (chain trusted: %s, revoked: %s)\n", $doc->documentId, $sig->status->value, $sig->certChain->trusted ? 'yes' : 'no', $sig->revocation->isRevoked ? 'yes' : 'no', )); } } }}
exit($exit);Expected output (stderr, for one document whose revocation evidence was unavailable; other lines vary with your inputs):
contract-0042.pdf: indeterminate (chain trusted: yes, revoked: no)The JSON report serializes signer identity fields through the default CertPiiGuard, so a per-signature entry looks like this (excerpt, illustrative):
{ "status": "indeterminate", "signer": "[REDACTED]", "level": "B-B", "subFilter": "ETSI.CAdES.detached"}Edge cases & gotchas
Section titled “Edge cases & gotchas”- An empty input list throws
\InvalidArgumentException; more than 1,000 documents in one call throws\OverflowException. Chunk larger runs, as in the production sample. - Upgrading from earlier releases: with no OCSP or CRL client configured, revocation is
unavailable, so no signature can reachValid. Earlier releases reportedValidhere; 3.1.0 reportsIndeterminate(see Conceptual overview). - Document-level counters are strict: only
ValidincrementsvalidCount.Invalid,Indeterminate, andErrorall incrementinvalidCount. A document whose only signature isIndeterminatetherefore reportsall_invalid. Gate on per-signaturestatuswhen the distinction matters. - The OCSP check runs only when the recovered chain has at least two certificates, because the query needs the issuer. A single-certificate chain falls through to the CRL path or
unavailable. crlStatusnever reportsrevokedin batch results. The CRL fallback attests list freshness only; an authoritative revoked result comes from OCSP.timestampis alwaysabsent()in batch results. The batch validator does not evaluate embedded RFC 3161 tokens; use Signature verification for timestamp evaluation.signeris empty when authentication failed. When set, it is the subject CN (or O) of the CMS-verified certificate — never the unauthenticated/Namestring from the signature dictionary.trustedCertsentries must be PEM certificates. An empty or malformed anchor list yields a structural-only chain verdict withtrusted: false, capping the verdict atIndeterminate.- Bytes that do not start with a PDF header produce a per-document
errorstatus with zero signatures — no exception. toJson()redacts PII by default. Passnew CertPiiGuard(disclosePii: true)only where you hold a documented lawful basis to process signer identity.
Security notes
Section titled “Security notes”- Fail-closed verdict producer.
Validrequires all of: verified CMS authentication over the/ByteRangedigest, a valid chain, confirmed trust-anchor membership, and a conclusive non-revoked status. Every unestablished check degrades the verdict; nothing defaults to a pass. - No identity laundering. The reported signer is the cryptographically bound certificate subject. The
/Nameentry is attacker-controlled metadata and is never surfaced as the signer. - Weak algorithms never pass. A SHA-1 signature that verifies is still reported as non-valid; cryptographic validity under a weak digest is not laundered into a full pass.
- Trust is an input, not an inference. Anchors you supply are matched against the chain terminus by DER SHA-256 fingerprint (RFC 5280 §6.1.1). Self-consistency of a chain, or a non-empty anchor list alone, never establishes trust.
- Revocation is decisive. A verified revoked statement forces
Invalidregardless of every other check; unavailable evidence forcesIndeterminate. - Privacy by default in serialized output.
toJson()masks signer CN, root issuer DN, TSA name, and chain-issue diagnostics unless you opt out, implementing GDPR Article 5(1)(c) data minimisation at the serialization boundary. - Deterministic time. The CRL freshness decision reads the injected PSR-20 clock, not the host wall clock, so revocation verdicts are reproducible under test.
Conformance
Section titled “Conformance”NextPDF Enterprise implements behavior informed by ETSI EN 319 102-1 (the three-value validation status model and the rule that unavailable revocation information yields INDETERMINATE), RFC 5652 §5.6 (verifier-side digest recomputation), and RFC 5280 §6.1 (trust anchors as relying-party inputs to path validation). Its statuses are engineering verdicts aligned with the EN 319 102-1 taxonomy — not TOTAL-PASSED/TOTAL-FAILED/INDETERMINATE indications from a full clause 5 validation process. In particular, batch mode performs no proof-of-existence or timestamp processing; the single-document verify-side covers that ground.
FIPS-mode behavior
Section titled “FIPS-mode behavior”The batch validator consults no FIPS-mode policy, and enabling FIPS mode does not change batch verdicts. Its verification-side algorithm handling is fixed and fail-closed: weak (SHA-1) signatures are never reported Valid, with or without FIPS mode. The Enterprise FIPS-mode policy gates the signing/generation side, documented in FIPS 140 — Deep Reference. FIPS 140 support is a capability statement, not a validation or certification claim.
Behavior contract
Section titled “Behavior contract”validate()throws\InvalidArgumentExceptionfor an empty list and\OverflowExceptionabove 1,000 documents. Malformed documents never throw; they produce per-documenterrorresults.Validrequires the conjunction: CMS cryptographically verified, chain valid, trust-anchor membership confirmed, andRevocationStatus::hasConclusiveGood()true.- A confirmed-revoked certificate is decisive: the verdict is
Invalidregardless of all other evidence. - Both revocation checks
Unknown/UnavailablemeansIndeterminate, neverValid(3.1.0 hardening, backwards-compatibility impact). - An authenticated, chain-valid signature without a confirmed trust anchor is
Indeterminate— authentic, trust unestablished. signeris the CMS-verified subject or the empty string; the/Nameentry is never used.timestampis always the absent state in batch results.validCountcounts onlyValid; all other statuses count intoinvalidCount, and document status aggregates from those counters.toJson()applies the privacy-by-defaultCertPiiGuardunless a guard is passed explicitly.- Report totals are exact sums over per-document results;
durationMsis measured wall time for the batch.
Core fallback
Section titled “Core fallback”NextPDF Core’s Security / Signing module is the producer side: it creates CMS signatures, applies RFC 3161 timestamps, and validates chains and revocation for the material it embeds at signing time. Core ships no verify-side batch orchestrator: no multi-document report, no aggregate status taxonomy, no OCSP/CRL revocation verdicts for third-party documents, and no PII-guarded report serialization. On Core alone, you would extract and verify each signature yourself and build your own reporting. The Enterprise single-document verify-side (Signature verification) and this batch orchestrator provide that layer.
Publication boundary
Section titled “Publication boundary”This page documents externally observable behavior and the supported public API surface only. Internal namespace paths, helper classes, mechanism tables, runbook filenames, and ticket prefixes are out of scope.
See also
Section titled “See also”- Signature verification — the single-document AdES/PAdES cryptographic verify-side, including timestamp and archival-chain validation
- Archive — embedding DSS/VRI material and document timestamps for long-term validity
- Validation — read-only structural policy checks, no cryptography
- Signature — Deep Reference — the Signature module’s deep reference
- eIDAS assurance levels —
CertPiiGuardAPI and assurance-level mapping - Signing at scale, without compromise — Insider essay on high-volume signing and validation design
- Validating a signature properly — Insider essay on why fail-closed validation matters