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 Enterprise(nextpdf/enterprise)一同交付,并以 Enterprise 层级的许可证信封激活。缺少该授权的部署不会加载此能力的类。比较各版本并获取许可证。
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 会按此顺序、作为单一流水线运行它们:
- 获取(Fetch)——
TslFetcher仅通过 HTTPS 获取 XML。SSRF 防护在任何出站之前校验主机。响应有大小上限,且 PSR-16 缓存可支持ETag重校验与气隙(air-gapped)读取。 - 验证(Verify)——
TslSignatureVerifier校验被包裹(enveloped)的 XMLDSig 签名。签名证书必须链接到你以带外方式固定的信任锚;文档内部的任何内容都不会凭自身获得信任。 - 解析(Parse)——
TslXmlParser将方案信息与每一项 TSP 服务提取为一个不可变的TslDocument。带有 DOCTYPE 的文档会在构建任何实体表之前被拒绝。 - 执行(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 自身之上,因此每一条消费路径都会强制执行它,而非依赖某个可选的协作者。其结果是一个小型内核,可测试、确定性强,并对它所拒绝的内容诚实。
设计背景:合格签名详解。
API 表面
标题为“API 表面”的章节TslPolicyEnforcer
标题为“TslPolicyEnforcer”的章节编排式的入口点:一次调用即完成获取、验证、解析与新鲜度检查。
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): TslDocument抛出或失败于: 来自获取阶段的 TslFetchException 与 NextPDF\Enterprise\Security\NetworkPolicyViolation;来自签名验证的 TslSignatureException;来自解析、非规范 NextUpdate 值或过期列表的 TslParseException。仅当每一道门控均通过时,两个方法才返回 TslDocument。此处的过期门控将 NextUpdate 与当前系统时钟进行比较。
TslFetcher
标题为“TslFetcher”的章节带有基于 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。
TslSignatureVerifier
标题为“TslSignatureVerifier”的章节面向已签名可信列表的 XMLDSig 验证器。
public function __construct( private readonly array $trustAnchorsPem, private readonly int $clockTolerance = 0,)public function verify(string $xml): stringverify() 返回签名证书的 PEM,并已证明其链接到 $trustAnchorsPem 之一。当锚点列表为空时,构造函数抛出 InvalidArgumentException。$clockTolerance 以秒为单位对称地放宽证书有效期窗口。
被接受的配置是固定的。签名算法:ALLOWED_SIG_ALG 允许清单(rsa-sha256/384/512、ecdsa-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_signer | KeyInfo 证书未链接到已配置的锚点。 |
invalid_signature | 结构缺陷,或 RSA/ECDSA 检查失败。 |
digest_mismatch | 引用摘要与规范化后的文档不匹配。 |
unsupported_algorithm | 签名或摘要算法在允许清单之外。 |
unsupported_transform | 规范化或变换流水线在固定配置之外。 |
expired_anchor | 某条链上证书超出其有效期窗口,或其有效期无法解析。 |
TslXmlParser
标题为“TslXmlParser”的章节与签名无关的结构解析器。调用方必须先验证再信任其输出;TslPolicyEnforcer 会为你强制执行该顺序。
public function parse(string $xml): TslDocument抛出或失败于: 当 XML 声明了 DOCTYPE(XXE 与实体展开加固)、无法解析、缺少 TrustServiceStatusList 根节点,或携带无效的 TSLSequenceNumber 时,抛出 TslParseException。该类暴露命名空间常量 NS_TSL、NS_DSIG 与 NS_TSL_X。
TslDocument 与 TspService
标题为“TslDocument 与 TspService”的章节TslDocument 是一个不可变的值对象:schemeTerritory、schemeOperatorName、tslType、sequenceNumber、issueDateTime、nextUpdate、tspServices 以及 rawXmlSha256(对原始字节的证据哈希)。
public function isStale(DateTimeImmutable $now): boolpublic function assertFresh(DateTimeImmutable $now): voidpublic function servicesOfType(string $serviceTypeIdentifier): arraypublic function activeServices(): array抛出或失败于: 当 nextUpdate 不是带有显式 Z 或数字偏移的规范 UTC dateTime 时,isStale() 与 assertFresh() 抛出 TslParseException;过期的列表会使 assertFresh() 抛出异常。activeServices() 仅返回处于 granted 状态的服务。servicesOfType() 按 ETSI 服务类型 URI 进行筛选。
每一个 TspService 条目都暴露 tspName、serviceName、serviceTypeIdentifier、serviceStatus、statusStartingTime、serviceCertificatePem、qualifiers 与 additionalServiceInformation,此外还有:
public function isGranted(): boolpublic function isQualifiedCa(): bool有用的常量:TspService::STATUS_GRANTED、TspService::STATUS_WITHDRAWN、TspService::TYPE_CA_QC、TspService::TYPE_OCSP_QC、TspService::TYPE_TSA_QTST。限定符 URI(例如 TspServiceQualifier::FOR_ESIG、FOR_ESEAL、QSCD_STATEMENT、NO_QSCD)在 TspServiceQualifier 上暴露,供 eIDAS 映射层使用。
TslTrustAnchorProvider 与锚点捆绑包
标题为“TslTrustAnchorProvider 与锚点捆绑包”的章节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 锚点)、bundleVersion(tsl-<territory>-seq<N>)与 bundleSha256(对规范化 PEM 串接的完整性摘要)。请从 buildBundle() 获取它;不要手工构造——构造函数会在摘要不匹配或 PEM 格式错误时抛出 InvalidArgumentException。
public function containsFingerprint(string $anchorDerSha256Hex): boolpublic static function computeBundleSha256(array $anchorsPem): string代码示例——快速上手
标题为“代码示例——快速上手”的章节认证并消费一个本地镜像的可信列表。此路径无需 HTTP 依赖:先验证、再解析,然后在你的验证时刻门控新鲜度。
<?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: DESequence: 127Active services: 143代码示例——生产环境
标题为“代码示例——生产环境”的章节接通完整的在线流水线:带缓存的受保护获取、签名验证、解析、新鲜度检查,然后派生锚点捆绑包。每一类失败都被单独捕获并区分报告。
<?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...)在你对捆绑包执行的每一次验证中,都记录下 bundleVersion 与 bundleSha256。它们精确标识出每个裁决背后的锚点集合。
边界情形与陷阱
标题为“边界情形与陷阱”的章节- 执行器的新鲜度门控使用当前时钟。
fetchAndVerify()与verifyXml()会拒绝其NextUpdate已过的列表。若要针对存档列表进行历史性验证,请直接驱动TslSignatureVerifier与TslXmlParser,然后以你的证据所支持的过去时刻调用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 消费一份可信列表,本身并不能使某个签名成为“合格”或具有法律效力。你的完整验证流程是否满足某项法律或采购要求,是由你的评估方决定的。
FIPS 模式行为
标题为“FIPS 模式行为”的章节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 服务类型的服务派生;空的有效集合会抛出异常,而非产出空捆绑包。
- 每一次失败都是有类型的异常(
TslFetchException、NetworkPolicyViolation、带 reason code 的TslSignatureException、TslParseException);没有任何方法会返回部分或未经验证的文档。
Core 回退
标题为“Core 回退”的章节NextPDF Core 会针对你通过其 CaTrustAnchorBundle 契约显式固定的信任锚验证 PDF 签名——参见 Core 安全。Core 没有可信列表能力:没有 TSL 获取、没有 XMLDSig 列表认证、没有 ETSI TS 119 612 解析,也没有从合格服务条目派生锚点。仅凭 Core,你需要手工维护自己的锚点集合;从已认证的欧盟可信列表派生它则需要 NextPDF Enterprise。
发布边界
标题为“发布边界”的章节本页仅记录外部可观测的行为与受支持的公共 API 表面。内部命名空间路径、辅助类、机制表、运行手册文件名与工单前缀均不在范围内。