跳转到内容
getnextpdf.com

Enterprise 版本

可信列表(TSL)

欧盟签名验证从一个已发布的事实出发:哪些提供方持有合格状态。该事实存在于可信列表(TSL)中——每个成员国发布的、经签名的 XML 文档,并由欧盟的可信列表清单(LOTL)编制索引。NextPDF\Enterprise\Security\Tsl\TslPolicyEnforcer 会把一个 TSL URL 或原始 XML 转化为你可以信赖的 TslDocument。它通过受保护的 HTTPS 获取,针对你固定的锚点验证 XMLDSig 签名,解析加固后的 XML,并拒绝过期的列表。再进一步调用 TslTrustAnchorProvider::buildBundle(),即可把处于有效状态的 CA/QC 服务转换为带版本的信任锚捆绑包。每一道门控都失败即拒;每一次拒绝都是有类型的异常。

本页负责列表摄入与锚点派生。证书路径验证参见 签名验证。eIDAS 保障级别映射参见 eIDAS 保障级别。容器信任绑定参见 ASiC 信任绑定

此能力随 NextPDF Enterprisenextpdf/enterprise)一同交付,并以 Enterprise 层级的许可证信封激活。缺少该授权的部署不会加载此能力的类。比较各版本并获取许可证

Terminal window
composer require nextpdf/enterprise

激活需要你的 Enterprise 许可证信封。参见 安装与认证。本页涉及的类位于 NextPDF\Enterprise\Security\Tsl 之下;网络策略类型位于 NextPDF\Enterprise\Security 之下。在线获取还需要任意 PSR-18 客户端与 PSR-17 工厂(例如 guzzlehttp/guzzle)。

根据 eIDAS 第 22 条,每个成员国都会发布其合格信任服务提供方的可信列表,并经过签名或盖章以便自动化处理。ETSI TS 119 612 定义了 XML 格式。该列表的可信度只等于三项检查所赋予的程度:其签名、其结构与其新鲜度。NextPDF 会按此顺序、作为单一流水线运行它们:

  1. 获取(Fetch)——TslFetcher 仅通过 HTTPS 获取 XML。SSRF 防护在任何出站之前校验主机。响应有大小上限,且 PSR-16 缓存可支持 ETag 重校验与气隙(air-gapped)读取。
  2. 验证(Verify)——TslSignatureVerifier 校验被包裹(enveloped)的 XMLDSig 签名。签名证书必须链接到你以带外方式固定的信任锚;文档内部的任何内容都不会凭自身获得信任。
  3. 解析(Parse)——TslXmlParser 将方案信息与每一项 TSP 服务提取为一个不可变的 TslDocument。带有 DOCTYPE 的文档会在构建任何实体表之前被拒绝。
  4. 执行(Enforce)——列表的 NextUpdate 时刻不得已过。过期的列表会被丢弃,绝不消费。

TslPolicyEnforcer 组合了上述四步;由它产出的 TslDocument 已通过每一道门控。在此之上,TslTrustAnchorProvider::buildBundle() 会筛选出同时处于 granted 状态且属于 CA/QC 类型的服务,并发出一个 EnterpriseCaTrustAnchorBundle:固定的 PEM 锚点、一个 tsl-<territory>-seq<N> 版本,以及一个 SHA-256 完整性摘要。该捆绑包正是路径验证与 ASiC 信任绑定所消费的对象。

同一套机制也覆盖 LOTL 工作流。针对手动固定的锚点验证 LOTL;随后针对 LOTL 为各成员国声明的签名证书,验证每一份成员国 TSL。

承重的决策是采用固定、最小的验证配置,而非通用的 XMLDSig。灵活的 XML 签名处理——任意变换链、由攻击者声明的 ID 引用、算法可协商性——正是验证器历来出问题的地方。因此该验证器只接受唯一一种处理模型:排他式 C14N、一个覆盖根节点的引用,以及双变换流水线 [enveloped-signature, exclusive-C14N],其余一切都失败即拒。信任绝不从文档自身自举:KeyInfo 证书只会链接到你配置的锚点。新鲜度存在于 TslDocument 自身之上,因此每一条消费路径都会强制执行它,而非依赖某个可选的协作者。其结果是一个小型内核,可测试、确定性强,并对它所拒绝的内容诚实。

设计背景:合格签名详解

编排式的入口点:一次调用即完成获取、验证、解析与新鲜度检查。

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

抛出或失败于: 来自获取阶段的 TslFetchExceptionNextPDF\Enterprise\Security\NetworkPolicyViolation;来自签名验证的 TslSignatureException;来自解析、非规范 NextUpdate 值或过期列表的 TslParseException。仅当每一道门控均通过时,两个方法才返回 TslDocument。此处的过期门控将 NextUpdate 与当前系统时钟进行比较。

带有基于 ETag 的缓存与网络策略门控的 HTTP 获取器。

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

抛出或失败于: 在非 HTTPS URL、被拒绝的(SSRF)主机、HTTP 错误状态、超大响应或空响应体时抛出 TslFetchException;当 NetworkPolicy::STRICT_OFFLINE 生效且不存在缓存体时抛出 NetworkPolicyViolation。缓存体可满足 304 Not Modified 重校验,并且是 STRICT_OFFLINE 下唯一被提供的响应体。缓存条目的存活时长为 $defaultTtlSeconds

面向已签名可信列表的 XMLDSig 验证器。

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

verify() 返回签名证书的 PEM,并已证明其链接到 $trustAnchorsPem 之一。当锚点列表为空时,构造函数抛出 InvalidArgumentException$clockTolerance 以秒为单位对称地放宽证书有效期窗口。

被接受的配置是固定的。签名算法:ALLOWED_SIG_ALG 允许清单(rsa-sha256/384/512ecdsa-sha256/384/512)。摘要:ALLOWED_DIGEST_ALG 允许清单(SHA-256、SHA-384、SHA-512)。规范化:仅限排他式 C14N 1.0。SHA-1 与 MD5 会作为 unsupported_algorithm 被拒绝。

抛出或失败于: TslSignatureException,携带一个机器可读的 reason

Reason code含义
missing_signature文档没有 ds:Signature 元素。
untrusted_signerKeyInfo 证书未链接到已配置的锚点。
invalid_signature结构缺陷,或 RSA/ECDSA 检查失败。
digest_mismatch引用摘要与规范化后的文档不匹配。
unsupported_algorithm签名或摘要算法在允许清单之外。
unsupported_transform规范化或变换流水线在固定配置之外。
expired_anchor某条链上证书超出其有效期窗口,或其有效期无法解析。

与签名无关的结构解析器。调用方必须先验证再信任其输出;TslPolicyEnforcer 会为你强制执行该顺序。

public function parse(string $xml): TslDocument

抛出或失败于: 当 XML 声明了 DOCTYPE(XXE 与实体展开加固)、无法解析、缺少 TrustServiceStatusList 根节点,或携带无效的 TSLSequenceNumber 时,抛出 TslParseException。该类暴露命名空间常量 NS_TSLNS_DSIGNS_TSL_X

TslDocument 是一个不可变的值对象:schemeTerritoryschemeOperatorNametslTypesequenceNumberissueDateTimenextUpdatetspServices 以及 rawXmlSha256(对原始字节的证据哈希)。

public function isStale(DateTimeImmutable $now): bool
public function assertFresh(DateTimeImmutable $now): void
public function servicesOfType(string $serviceTypeIdentifier): array
public function activeServices(): array

抛出或失败于:nextUpdate 不是带有显式 Z 或数字偏移的规范 UTC dateTime 时,isStale()assertFresh() 抛出 TslParseException;过期的列表会使 assertFresh() 抛出异常。activeServices() 仅返回处于 granted 状态的服务。servicesOfType() 按 ETSI 服务类型 URI 进行筛选。

每一个 TspService 条目都暴露 tspNameserviceNameserviceTypeIdentifierserviceStatusstatusStartingTimeserviceCertificatePemqualifiersadditionalServiceInformation,此外还有:

public function isGranted(): bool
public function isQualifiedCa(): bool

有用的常量:TspService::STATUS_GRANTEDTspService::STATUS_WITHDRAWNTspService::TYPE_CA_QCTspService::TYPE_OCSP_QCTspService::TYPE_TSA_QTST。限定符 URI(例如 TspServiceQualifier::FOR_ESIGFOR_ESEALQSCD_STATEMENTNO_QSCD)在 TspServiceQualifier 上暴露,供 eIDAS 映射层使用。

public function buildBundle(TslDocument $tsl, DateTimeImmutable $now): EnterpriseCaTrustAnchorBundle

抛出或失败于: 当 TSL 在 $now 时已过期、当 nextUpdate 不是规范 UTC 值,或当列表中不含处于有效状态的 CA/QC 服务时,抛出 TslParseException

BC 说明——buildBundle($now) 的新鲜度规则。 buildBundle() 需要验证时刻,并在提取任何一个锚点之前调用 TslDocument::assertFresh($now)。较早的修订版本可以在没有任何新鲜度检查的情况下,从解析器产出的 TslDocument 派生锚点。此前喂入缓存或存档列表的调用方,现在必须传入其验证运行所在的时刻;在该时刻已过期的列表会抛出异常,而非默默地播种信任锚。

返回的 EnterpriseCaTrustAnchorBundle 是一个只读值对象:anchorsPem(PEM 锚点)、bundleVersiontsl-<territory>-seq<N>)与 bundleSha256(对规范化 PEM 串接的完整性摘要)。请从 buildBundle() 获取它;不要手工构造——构造函数会在摘要不匹配或 PEM 格式错误时抛出 InvalidArgumentException

public function containsFingerprint(string $anchorDerSha256Hex): bool
public static function computeBundleSha256(array $anchorsPem): string

认证并消费一个本地镜像的可信列表。此路径无需 HTTP 依赖:先验证、再解析,然后在你的验证时刻门控新鲜度。

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";

预期输出(值因列表而异):

Territory: DE
Sequence: 127
Active services: 143

接通完整的在线流水线:带缓存的受保护获取、签名验证、解析、新鲜度检查,然后派生锚点捆绑包。每一类失败都被单独捕获并区分报告。

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),
);

预期输出(值因列表而异):

Anchor bundle tsl-de-seq127: 96 anchors (sha256 4b0e2a9f31c8...)

在你对捆绑包执行的每一次验证中,都记录下 bundleVersionbundleSha256。它们精确标识出每个裁决背后的锚点集合。

  • 执行器的新鲜度门控使用当前时钟。 fetchAndVerify()verifyXml() 会拒绝其 NextUpdate 已过的列表。若要针对存档列表进行历史性验证,请直接驱动 TslSignatureVerifierTslXmlParser,然后以你的证据所支持的过去时刻调用 assertFresh()
  • buildBundle() 会在你的 $now 处重新断言新鲜度。 一个通过了执行器的列表,如果你的验证时刻更晚,仍可能在此处被拒绝。参见上文的 BC 说明。
  • 绝不要从你正在验证的列表中播种 trustAnchorsPem 锚点必须来自带外固定的来源(对于 LOTL),或来自一个已验证的父列表(对于成员国 TSL)。任何其他做法都会使验证陷入循环。
  • 任何位置出现 DOCTYPE 都是致命的。 合规的 TSL 从不携带 DTD,因此解析器会在 libxml 构建实体表之前拒绝任何 DOCTYPE。这是有意的加固,而非解析器的局限。
  • 缺失的结构字段会安全降级。 状态不可读的服务被视为已撤销,因此绝不会成为锚点。缺失的方案领地会解析为 unknown。失败即拒的默认值使格式错误的条目远离信任材料。
  • 中间证书必须是真正的 CA。 在链构建过程中,缺少 basicConstraints cA=TRUE(或声明了 keyUsage 却没有 keyCertSign)的候选签发者会被跳过。夹带进 KeyInfo 的终端实体证书无法充当路径中间证书。链的深度上限为 8。
  • NextUpdate 必须是规范 UTC。 没有显式 Z 或数字偏移的值会抛出 TslParseException。它绝不会被重新解释为服务器的本地时区。
  • 大型列表与字节上限。 响应最多读取到 $maxBytes(默认 16 MiB)。若你的方案列表更大,请在构造函数中调高上限;截断会表现为签名失败,绝不会是默默接受。
  • clockTolerance 只放宽不收紧。 它为证书有效期检查增加对称的余量。它不会放松列表级的新鲜度门控。
  • 始终先验证后解析。 TslXmlParser 在设计上与签名无关。TslPolicyEnforcer 会先排布验证;如果你自行组合各部件,请保持该顺序。
  • 纵深防御的 SSRF 防护。 fetch() 要求 https:// 并将主机针对私有、回环、链路本地、CGN 与云元数据地址范围进行校验,同时进行 A 与 AAAA DNS 解析以缓解重绑定。被拒绝的 URL 会在任何出站之前抛出。
  • XXE 与实体展开加固。 带有 DOCTYPE 的文档会在实体表存在之前、以及加载之后再次被拒绝。网络实体加载已禁用;外部实体从不被替换。
  • 严格的 XMLDSig 配置。 仅限排他式 C14N;恰好为 [enveloped-signature, exclusive-C14N] 变换对;被验证的引用必须覆盖文档根;enveloped 变换仅移除被验证的那个签名,保留同级签名。已弃用的算法(SHA-1、MD5)会被拒绝。
  • 链纪律。 每一条链环——签名者、中间证书以及直接锚点的情形——都会检查时间有效性,并在有效期界限无法解析时失败即拒。会检测循环;深度设有上限。
  • 气隙姿态。NetworkPolicy::STRICT_OFFLINE 下,获取路径完全不执行任何出站;只有此前缓存的响应体才可被提供,其余一切都会以 NetworkPolicyViolation 快速失败抛出。
  • 捆绑包摘要检测的是损坏,而非篡改。 bundleSha256 在构造时被校验,可检测转录漂移。当摘要派生自它所保护的同一批锚点时,它并非独立的防篡改证据。在系统之间传输捆绑包时,请以带外方式固定摘要。

该流水线按 ETSI TS 119 612 的定义消费可信列表:它认证方案运营者的签名(§5.7),解析方案信息与提供方列表结构(§5.3、§5.4、§5.5),强制执行 UTC dateTime 规则(§5.1.3),并丢弃其 NextUpdate 已过的列表(§5.3.15)。这支持 eIDAS 第 22 条所规定的已签名、机器可处理的可信列表模型。链构建对候选签发者施加 RFC 5280 的 basic-constraints 与 key-usage 门控。

支持不等于一致,一致不等于认证。NextPDF 实现了本页所述的检查;它未经任何机构针对 ETSI TS 119 612、eIDAS 或任何其他标准进行认证,NextPDF 不持有任何认证,也不授予任何认证。通过此 API 消费一份可信列表,本身并不能使某个签名成为“合格”或具有法律效力。你的完整验证流程是否满足某项法律或采购要求,是由你的评估方决定的。

TSL 签名验证通过内置的加密库在进程内运行 RSA 与 ECDSA 检查。它不经过 Enterprise FIPS 模式运行时守护,启用 FIPS 模式不会改变其行为。它不是经 FIPS 验证的加密服务,也不声明任何 FIPS 140 认证。负有 FIPS 义务的部署应相应地界定此 API 的范围,并参见 FIPS 140-2/3 加密策略

  • fetch() 仅对通过 SSRF 校验的 HTTPS URL 执行出站,最多读取 $maxBytes,并遵守所配置的 NetworkPolicy;在 STRICT_OFFLINE 下只会返回缓存的响应体。
  • verify() 成功之前,任何解析器输出都不会成为信任材料;TslPolicyEnforcer 保证该顺序。
  • 仅当摘要与签名在固定配置下检查通过,且签名者在深度 8 以内、每一链环时间有效地链接到已配置的锚点时,verify() 才返回签名者 PEM。
  • 执行器会拒绝其 NextUpdate 在当前时钟已过的任何列表;buildBundle() 会在派生锚点之前,于调用方提供的时刻重新断言新鲜度。
  • 锚点仅从处于 granted 状态且具备 CA/QC 服务类型的服务派生;空的有效集合会抛出异常,而非产出空捆绑包。
  • 每一次失败都是有类型的异常(TslFetchExceptionNetworkPolicyViolation、带 reason code 的 TslSignatureExceptionTslParseException);没有任何方法会返回部分或未经验证的文档。

NextPDF Core 会针对你通过其 CaTrustAnchorBundle 契约显式固定的信任锚验证 PDF 签名——参见 Core 安全。Core 没有可信列表能力:没有 TSL 获取、没有 XMLDSig 列表认证、没有 ETSI TS 119 612 解析,也没有从合格服务条目派生锚点。仅凭 Core,你需要手工维护自己的锚点集合;从已认证的欧盟可信列表派生它则需要 NextPDF Enterprise。

本页仅记录外部可观测的行为与受支持的公共 API 表面。内部命名空间路径、辅助类、机制表、运行手册文件名与工单前缀均不在范围内。