Enterprise edition
Trusted lists (TSL)
At a glance
Section titled “At a glance”EU signature validation starts from a published fact: which providers hold qualified status. That fact lives in trusted lists (TSLs) — signed XML documents each Member State publishes, indexed by the EU’s list of trusted lists (LOTL). NextPDF\Enterprise\Security\Tsl\TslPolicyEnforcer turns a TSL URL or raw XML into a TslDocument you can rely on. It fetches over guarded HTTPS, verifies the XMLDSig signature against anchors you pin, parses hardened XML, and rejects stale lists. One further call, TslTrustAnchorProvider::buildBundle(), converts active CA/QC services into a versioned trust-anchor bundle. Every gate fails closed; every rejection is a typed exception.
This page owns list ingestion and anchor derivation. Certificate path validation lives in Signature verification. eIDAS assurance-level mapping lives in eIDAS assurance levels. Container trust binding lives in ASiC trust binding.
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/enterpriseActivation requires your Enterprise license envelope. See Install and authenticate. The classes on this page live under NextPDF\Enterprise\Security\Tsl; the network policy types live under NextPDF\Enterprise\Security. Online fetching additionally needs any PSR-18 client and PSR-17 factory (for example guzzlehttp/guzzle).
Conceptual overview
Section titled “Conceptual overview”Under eIDAS Article 22, each Member State publishes a trusted list of its qualified trust service providers, signed or sealed for automated processing. ETSI TS 119 612 defines the XML format. The list is only as trustworthy as three checks make it: its signature, its structure, and its freshness. NextPDF runs them in that order, as one pipeline:
- Fetch —
TslFetcherretrieves the XML over HTTPS only. An SSRF guard validates the host before any egress. Responses are size-capped, and a PSR-16 cache enablesETagrevalidation and air-gapped reads. - Verify —
TslSignatureVerifierchecks the enveloped XMLDSig signature. The signing certificate must chain to a trust anchor you pinned out-of-band; nothing inside the document is trusted on its own. - Parse —
TslXmlParserextracts scheme information and every TSP service into an immutableTslDocument. DOCTYPE-bearing documents are rejected before any entity table is built. - Enforce — the list’s
NextUpdateinstant must not have passed. A stale list is discarded, never consumed.
TslPolicyEnforcer composes all four; a TslDocument from it has passed every gate. From there, TslTrustAnchorProvider::buildBundle() filters services that are both in granted status and of the CA/QC type, and emits an EnterpriseCaTrustAnchorBundle: pinned PEM anchors, a tsl-<territory>-seq<N> version, and a SHA-256 integrity digest. That bundle is what path validation and ASiC trust binding consume.
The same machinery covers the LOTL workflow. Verify the LOTL against a manually pinned anchor; then verify each member-state TSL against the signing certificates the LOTL declares for it.
Why it works this way
Section titled “Why it works this way”The load-bearing decision is a fixed, minimal verification profile instead of general XMLDSig. Flexible XML signature processing — arbitrary transform chains, attacker-declared ID references, algorithm agility — is where verifiers historically break. So the verifier accepts exactly one processing model: exclusive C14N, a root-covering reference, and the two-transform pipeline [enveloped-signature, exclusive-C14N], with everything else rejected fail-closed. Trust never bootstraps from the document itself: KeyInfo certificates only ever chain to anchors you configured. Freshness lives on TslDocument itself, so every consumer path enforces it rather than one optional collaborator. The result is a small kernel that is testable, deterministic, and explicit about what it refuses.
Design background: Qualified signatures, explained.
API surface
Section titled “API surface”TslPolicyEnforcer
Section titled “TslPolicyEnforcer”The orchestrated entry point: fetch, verify, parse, and freshness-check in one call.
public function __construct( private readonly TslFetcher $fetcher, private readonly TslSignatureVerifier $verifier, private readonly TslXmlParser $parser,) {}public function fetchAndVerify(string $url): TslDocumentpublic function verifyXml(string $xml): TslDocumentThrows or fails with: TslFetchException and NextPDF\Enterprise\Security\NetworkPolicyViolation from the fetch stage; TslSignatureException from signature verification; TslParseException from parsing, from a non-canonical NextUpdate value, or from a stale list. Both methods return a TslDocument only when every gate passed. The staleness gate here compares NextUpdate against the current system clock.
TslFetcher
Section titled “TslFetcher”HTTP fetcher with ETag-based caching and a network-policy gate.
public function __construct( private readonly ClientInterface $httpClient, private readonly RequestFactoryInterface $requestFactory, private readonly ?CacheInterface $cache = null, private readonly int $defaultTtlSeconds = 3600, private readonly int $maxBytes = 16_777_216, private readonly NetworkPolicy $networkPolicy = NetworkPolicy::ONLINE,) {}public function fetch(string $url): stringThrows or fails with: TslFetchException on a non-HTTPS URL, a rejected (SSRF) host, an HTTP error status, an oversized response, or an empty body; NetworkPolicyViolation when NetworkPolicy::STRICT_OFFLINE is active and no cached body exists. Cached bodies satisfy 304 Not Modified revalidation and are the only bodies served under STRICT_OFFLINE. Cache entries live for $defaultTtlSeconds.
TslSignatureVerifier
Section titled “TslSignatureVerifier”XMLDSig verifier for signed trusted lists.
public function __construct( private readonly array $trustAnchorsPem, private readonly int $clockTolerance = 0,)public function verify(string $xml): stringverify() returns the PEM of the signing certificate, proven to chain to one of $trustAnchorsPem. The constructor throws InvalidArgumentException when the anchor list is empty. $clockTolerance widens the certificate validity window symmetrically, in seconds.
The accepted profile is fixed. Signature algorithms: the ALLOWED_SIG_ALG allowlist (rsa-sha256/384/512, ecdsa-sha256/384/512). Digests: the ALLOWED_DIGEST_ALG allowlist (SHA-256, SHA-384, SHA-512). Canonicalization: exclusive C14N 1.0 only. SHA-1 and MD5 are rejected as unsupported_algorithm.
Throws or fails with: TslSignatureException, carrying a machine-readable reason:
| Reason code | Meaning |
|---|---|
missing_signature | The document has no ds:Signature element. |
untrusted_signer | The KeyInfo certificate does not chain to a configured anchor. |
invalid_signature | Structural defect, or the RSA/ECDSA check failed. |
digest_mismatch | The reference digest does not match the canonicalized document. |
unsupported_algorithm | Signature or digest algorithm outside the allowlist. |
unsupported_transform | Canonicalization or transform pipeline outside the fixed profile. |
expired_anchor | A chain certificate is outside its validity window, or its validity is unparseable. |
TslXmlParser
Section titled “TslXmlParser”Signature-agnostic structural parser. Callers MUST verify before trusting its output; TslPolicyEnforcer enforces that ordering for you.
public function parse(string $xml): TslDocumentThrows or fails with: TslParseException when the XML declares a DOCTYPE (XXE and entity-expansion hardening), cannot be parsed, lacks the TrustServiceStatusList root, or carries an invalid TSLSequenceNumber. The class exposes the namespace constants NS_TSL, NS_DSIG, and NS_TSL_X.
TslDocument and TspService
Section titled “TslDocument and TspService”TslDocument is an immutable value object: schemeTerritory, schemeOperatorName, tslType, sequenceNumber, issueDateTime, nextUpdate, tspServices, and rawXmlSha256 (evidence hash over the raw bytes).
public function isStale(DateTimeImmutable $now): boolpublic function assertFresh(DateTimeImmutable $now): voidpublic function servicesOfType(string $serviceTypeIdentifier): arraypublic function activeServices(): arrayThrows or fails with: isStale() and assertFresh() throw TslParseException when nextUpdate is not a canonical UTC dateTime with an explicit Z or numeric offset; a stale list makes assertFresh() throw. activeServices() returns only services in granted status. servicesOfType() filters by ETSI service-type URI.
Each TspService entry exposes tspName, serviceName, serviceTypeIdentifier, serviceStatus, statusStartingTime, serviceCertificatePem, qualifiers, and additionalServiceInformation, plus:
public function isGranted(): boolpublic function isQualifiedCa(): boolUseful constants: TspService::STATUS_GRANTED, TspService::STATUS_WITHDRAWN, TspService::TYPE_CA_QC, TspService::TYPE_OCSP_QC, TspService::TYPE_TSA_QTST. Qualifier URIs (for example TspServiceQualifier::FOR_ESIG, FOR_ESEAL, QSCD_STATEMENT, NO_QSCD) surface on TspServiceQualifier for the eIDAS mapping layer.
TslTrustAnchorProvider and the anchor bundle
Section titled “TslTrustAnchorProvider and the anchor bundle”public function buildBundle(TslDocument $tsl, DateTimeImmutable $now): EnterpriseCaTrustAnchorBundleThrows or fails with: TslParseException when the TSL is stale at $now, when nextUpdate is not a canonical UTC value, or when the list contains no active CA/QC services.
BC note — the
buildBundle($now)freshness rule.buildBundle()requires the validation instant and callsTslDocument::assertFresh($now)before extracting a single anchor. Earlier revisions could derive anchors from a parser-producedTslDocumentwithout any freshness check. Callers that fed cached or archived lists must now pass the instant their validation runs at; a list stale at that instant throws instead of silently seeding trust anchors.
The returned EnterpriseCaTrustAnchorBundle is a read-only value object: anchorsPem (the PEM anchors), bundleVersion (tsl-<territory>-seq<N>), and bundleSha256 (integrity digest over the canonicalized PEM concatenation). Obtain it from buildBundle(); do not construct it by hand — the constructor throws InvalidArgumentException on a digest mismatch or malformed PEM.
public function containsFingerprint(string $anchorDerSha256Hex): boolpublic static function computeBundleSha256(array $anchorsPem): stringCode sample — Quick start
Section titled “Code sample — Quick start”Authenticate and consume a locally mirrored trusted list. No HTTP dependency is needed for this path: verify, parse, then gate freshness at your validation instant.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use NextPDF\Enterprise\Security\Tsl\TslParseException;use NextPDF\Enterprise\Security\Tsl\TslSignatureException;use NextPDF\Enterprise\Security\Tsl\TslSignatureVerifier;use NextPDF\Enterprise\Security\Tsl\TslXmlParser;
// The list-signing certificate, pinned OUT-OF-BAND. Never take it from the list itself.$pinnedAnchorPem = (string) file_get_contents(__DIR__ . '/tsl-signer-anchor.pem');
// A trusted-list XML document you mirrored locally.$tslXml = (string) file_get_contents(__DIR__ . '/member-state-tsl.xml');
try { // 1. Authenticate: XMLDSig must verify AND the signer must chain to the pinned anchor. (new TslSignatureVerifier(trustAnchorsPem: [$pinnedAnchorPem]))->verify($tslXml);
// 2. Parse the now-authenticated bytes. $tsl = (new TslXmlParser())->parse($tslXml);
// 3. Freshness: refuse a list whose NextUpdate has passed. $tsl->assertFresh(new DateTimeImmutable('now', new DateTimeZone('UTC')));} catch (TslSignatureException $e) { fwrite(STDERR, "TSL rejected ({$e->reason}): {$e->getMessage()}" . PHP_EOL); exit(1);} catch (TslParseException $e) { fwrite(STDERR, 'TSL unusable: ' . $e->getMessage() . PHP_EOL); exit(1);}
echo "Territory: {$tsl->schemeTerritory}\n";echo "Sequence: {$tsl->sequenceNumber}\n";echo 'Active services: ' . count($tsl->activeServices()) . "\n";Expected output (values vary by list):
Territory: DESequence: 127Active services: 143Code sample — Production
Section titled “Code sample — Production”Wire the full online pipeline: guarded fetch with caching, signature verification, parse, freshness, then anchor-bundle derivation. Each failure class is caught and reported distinctly.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;use GuzzleHttp\Psr7\HttpFactory;use NextPDF\Enterprise\Security\NetworkPolicy;use NextPDF\Enterprise\Security\NetworkPolicyViolation;use NextPDF\Enterprise\Security\Tsl\TslFetchException;use NextPDF\Enterprise\Security\Tsl\TslFetcher;use NextPDF\Enterprise\Security\Tsl\TslParseException;use NextPDF\Enterprise\Security\Tsl\TslPolicyEnforcer;use NextPDF\Enterprise\Security\Tsl\TslSignatureException;use NextPDF\Enterprise\Security\Tsl\TslSignatureVerifier;use NextPDF\Enterprise\Security\Tsl\TslTrustAnchorProvider;use NextPDF\Enterprise\Security\Tsl\TslXmlParser;use Symfony\Component\Cache\Adapter\FilesystemAdapter;use Symfony\Component\Cache\Psr16Cache;
// Any PSR-18 client, PSR-17 factory, and PSR-16 cache work; these are examples.$enforcer = new TslPolicyEnforcer( fetcher: new TslFetcher( httpClient: new Client(), requestFactory: new HttpFactory(), cache: new Psr16Cache(new FilesystemAdapter('tsl')), defaultTtlSeconds: 3600, maxBytes: 16_777_216, networkPolicy: NetworkPolicy::ONLINE, ), verifier: new TslSignatureVerifier( trustAnchorsPem: [(string) file_get_contents(__DIR__ . '/tsl-signer-anchor.pem')], clockTolerance: 300, ), parser: new TslXmlParser(),);
// Use the official publication URL for your scheme territory (HTTPS required).$tslUrl = 'https://trusted-lists.example.eu/member-state-tsl.xml';$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
try { $tsl = $enforcer->fetchAndVerify($tslUrl); $bundle = (new TslTrustAnchorProvider())->buildBundle($tsl, $now);} catch (NetworkPolicyViolation $e) { // Air-gapped posture: egress forbidden and no cached body available. fwrite(STDERR, 'Network policy: ' . $e->getMessage() . PHP_EOL); exit(75);} catch (TslFetchException $e) { // Transport layer: SSRF-rejected URL, HTTP error, oversized or empty body. fwrite(STDERR, 'Fetch failed: ' . $e->getMessage() . PHP_EOL); exit(1);} catch (TslSignatureException $e) { // Authentication layer: treat as a potential attack, not a retry case. fwrite(STDERR, "Signature rejected ({$e->reason}): {$e->getMessage()}" . PHP_EOL); exit(1);} catch (TslParseException $e) { // Structure or freshness: stale list, malformed NextUpdate, no active CA/QC services. fwrite(STDERR, 'List unusable: ' . $e->getMessage() . PHP_EOL); exit(1);}
printf( "Anchor bundle %s: %d anchors (sha256 %s...)\n", $bundle->bundleVersion, count($bundle->anchorsPem), substr($bundle->bundleSha256, 0, 12),);Expected output (values vary by list):
Anchor bundle tsl-de-seq127: 96 anchors (sha256 4b0e2a9f31c8...)Record bundleVersion and bundleSha256 with every validation you perform against the bundle. They name the exact anchor set behind each verdict.
Edge cases & gotchas
Section titled “Edge cases & gotchas”- The enforcer’s freshness gate uses the current clock.
fetchAndVerify()andverifyXml()reject a list whoseNextUpdatehas already passed. For historical validation against an archived list, driveTslSignatureVerifierandTslXmlParserdirectly, then callassertFresh()with the past instant your evidence supports. buildBundle()re-asserts freshness at your$now. A list that passed the enforcer can still be rejected here if your validation instant is later. See the BC note above.- Never seed
trustAnchorsPemfrom the list you are verifying. The anchor must come from an out-of-band pinned source (for the LOTL) or from an already-verified parent list (for member-state TSLs). Anything else makes verification circular. - A DOCTYPE anywhere is fatal. Conforming TSLs never carry a DTD, so the parser rejects any DOCTYPE before libxml builds an entity table. This is intentional hardening, not a parser limitation.
- Missing structural fields degrade safely. A service without a readable status is treated as withdrawn, so it can never become an anchor. A missing scheme territory parses as
unknown. Fail-closed defaults keep malformed entries out of trust material. - Intermediates must be real CAs. During chain building, a candidate issuer without
basicConstraints cA=TRUE(or assertingkeyUsagewithoutkeyCertSign) is skipped. An end-entity certificate smuggled intoKeyInfocannot serve as a path intermediate. Chains are capped at depth 8. NextUpdatemust be canonical UTC. A value without an explicitZor numeric offset throwsTslParseException. It is never reinterpreted in the server’s local timezone.- Large lists and the byte cap. Responses are read up to
$maxBytes(default 16 MiB). Raise the cap in the constructor if your scheme’s list is larger; truncation surfaces as a signature failure, never as silent acceptance. clockToleranceonly widens. It adds symmetric slack to certificate validity checks. It does not loosen the list-level freshness gate.
Security notes
Section titled “Security notes”- Verify before parse, always.
TslXmlParseris signature-agnostic by design.TslPolicyEnforcerorders verification first; if you compose the pieces yourself, keep that order. - SSRF defense in depth.
fetch()requireshttps://and validates the host against private, loopback, link-local, CGN, and cloud-metadata ranges, with A and AAAA DNS resolution to mitigate rebinding. A rejected URL throws before any egress. - XXE and entity-expansion hardening. DOCTYPE-bearing documents are rejected before the entity table exists and again after load. Network entity loading is disabled; external entities are never substituted.
- Strict XMLDSig profile. Exclusive C14N only; exactly the
[enveloped-signature, exclusive-C14N]transform pair; the verified reference must cover the document root; the enveloped transform removes only the verified signature, preserving sibling signatures. Deprecated algorithms (SHA-1, MD5) are rejected. - Chain discipline. Every chain link — signer, intermediates, and the direct-anchor case — is checked for temporal validity, fail-closed on unparseable validity bounds. Loops are detected; depth is capped.
- Air-gap posture. Under
NetworkPolicy::STRICT_OFFLINE, the fetch path performs no outbound egress at all; only a previously cached body may be served, and anything else throwsNetworkPolicyViolationfail-fast. - Bundle digests detect corruption, not tampering.
bundleSha256is validated on construction and detects transcription drift. When the digest is derived from the same anchors it protects, it is not independent tamper evidence. Pin digests out-of-band when transporting bundles between systems.
Conformance
Section titled “Conformance”The pipeline consumes trusted lists as ETSI TS 119 612 defines them: it authenticates the scheme operator’s signature (§5.7), parses the scheme information and provider list structures (§5.3, §5.4, §5.5), enforces the UTC dateTime rules (§5.1.3), and discards lists whose NextUpdate has passed (§5.3.15). This supports the eIDAS Article 22 model of signed, machine-processable trusted lists. Chain-building applies RFC 5280 basic-constraints and key-usage gates to candidate issuers.
NextPDF implements the checks this page describes.
FIPS-mode behavior
Section titled “FIPS-mode behavior”TSL signature verification runs RSA and ECDSA checks in-process through the bundled crypto library. It is not routed through the Enterprise FIPS-mode runtime guard, and enabling FIPS mode does not change its behavior. Deployments with FIPS obligations should scope this API accordingly and see FIPS 140-2/3 cryptographic policy.
Behavior contract
Section titled “Behavior contract”fetch()performs egress only for HTTPS URLs that pass SSRF validation, reads at most$maxBytes, and honors the configuredNetworkPolicy; underSTRICT_OFFLINEonly a cached body is ever returned.- No parser output becomes trust material before
verify()succeeds;TslPolicyEnforcerguarantees that ordering. verify()returns the signer PEM only when the digest and signature check out under the fixed profile and the signer chains, within depth 8 and with every link temporally valid, to a configured anchor.- The enforcer rejects any list whose
NextUpdatehas passed at the current clock;buildBundle()re-asserts freshness at the caller-supplied instant before deriving anchors. - Anchors derive exclusively from services in granted status with the CA/QC service type; an empty active set throws rather than yielding an empty bundle.
- Every failure is a typed exception (
TslFetchException,NetworkPolicyViolation,TslSignatureExceptionwith a reason code,TslParseException); no method returns a partial or unverified document.
Core fallback
Section titled “Core fallback”NextPDF Core validates PDF signatures against trust anchors you pin explicitly through its CaTrustAnchorBundle contract — see Core security. Core has no trusted-list capability: no TSL fetching, no XMLDSig list authentication, no ETSI TS 119 612 parsing, and no anchor derivation from qualified-service entries. With Core alone you maintain your anchor set by hand; deriving it from authenticated EU trusted lists requires NextPDF Enterprise.
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”- ASiC trust binding — binds container signers to the anchor bundles this page derives.
- eIDAS assurance levels — maps
TspServiceevidence to Levels of Assurance. - Signature verification — the verify-side that consumes trust anchors for path validation.
- Security — deep reference — the Enterprise security module’s contract-level reference.
- Qualified signatures, explained — why trusted lists anchor the EU trust model.
- Long-term validation — why validation time and preserved evidence matter.