Skip to content
getnextpdf.com

Enterprise edition

Trusted lists (TSL)

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.

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.

Terminal window
composer require nextpdf/enterprise

Activation 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).

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:

  1. FetchTslFetcher retrieves the XML over HTTPS only. An SSRF guard validates the host before any egress. Responses are size-capped, and a PSR-16 cache enables ETag revalidation and air-gapped reads.
  2. VerifyTslSignatureVerifier checks 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.
  3. ParseTslXmlParser extracts scheme information and every TSP service into an immutable TslDocument. DOCTYPE-bearing documents are rejected before any entity table is built.
  4. Enforce — the list’s NextUpdate instant 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.

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.

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): TslDocument
public function verifyXml(string $xml): TslDocument

Throws 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.

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): string

Throws 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.

XMLDSig verifier for signed trusted lists.

public function __construct(
private readonly array $trustAnchorsPem,
private readonly int $clockTolerance = 0,
)
public function verify(string $xml): string

verify() 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 codeMeaning
missing_signatureThe document has no ds:Signature element.
untrusted_signerThe KeyInfo certificate does not chain to a configured anchor.
invalid_signatureStructural defect, or the RSA/ECDSA check failed.
digest_mismatchThe reference digest does not match the canonicalized document.
unsupported_algorithmSignature or digest algorithm outside the allowlist.
unsupported_transformCanonicalization or transform pipeline outside the fixed profile.
expired_anchorA chain certificate is outside its validity window, or its validity is unparseable.

Signature-agnostic structural parser. Callers MUST verify before trusting its output; TslPolicyEnforcer enforces that ordering for you.

public function parse(string $xml): TslDocument

Throws 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 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): bool
public function assertFresh(DateTimeImmutable $now): void
public function servicesOfType(string $serviceTypeIdentifier): array
public function activeServices(): array

Throws 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(): bool
public function isQualifiedCa(): bool

Useful 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): EnterpriseCaTrustAnchorBundle

Throws 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 calls TslDocument::assertFresh($now) before extracting a single anchor. Earlier revisions could derive anchors from a parser-produced TslDocument without 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): bool
public static function computeBundleSha256(array $anchorsPem): string

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.

tsl-verify-quickstart.php
<?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: DE
Sequence: 127
Active services: 143

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.

tsl-anchor-bundle-production.php
<?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.

  • The enforcer’s freshness gate uses the current clock. fetchAndVerify() and verifyXml() reject a list whose NextUpdate has already passed. For historical validation against an archived list, drive TslSignatureVerifier and TslXmlParser directly, then call assertFresh() 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 trustAnchorsPem from 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 asserting keyUsage without keyCertSign) is skipped. An end-entity certificate smuggled into KeyInfo cannot serve as a path intermediate. Chains are capped at depth 8.
  • NextUpdate must be canonical UTC. A value without an explicit Z or numeric offset throws TslParseException. 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.
  • clockTolerance only widens. It adds symmetric slack to certificate validity checks. It does not loosen the list-level freshness gate.
  • Verify before parse, always. TslXmlParser is signature-agnostic by design. TslPolicyEnforcer orders verification first; if you compose the pieces yourself, keep that order.
  • SSRF defense in depth. fetch() requires https:// 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 throws NetworkPolicyViolation fail-fast.
  • Bundle digests detect corruption, not tampering. bundleSha256 is 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.

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.

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.

  • fetch() performs egress only for HTTPS URLs that pass SSRF validation, reads at most $maxBytes, and honors the configured NetworkPolicy; under STRICT_OFFLINE only a cached body is ever returned.
  • No parser output becomes trust material before verify() succeeds; TslPolicyEnforcer guarantees 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 NextUpdate has 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, TslSignatureException with a reason code, TslParseException); no method returns a partial or unverified document.

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.

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.