Enterprise 版本
信任清單(TSL)
歐盟簽章驗證始於一項公開事實:哪些提供者持有合格狀態。這項事實存在於信任清單(TSL)中——由各會員國發布的簽署 XML 文件,並由歐盟的信任清單之清單(LOTL)加以索引。NextPDF\Enterprise\Security\Tsl\TslPolicyEnforcer 會把一個 TSL URL 或原始 XML 轉換為你可以信賴的 TslDocument。它透過受防護的 HTTPS 擷取、對你釘選的錨驗證 XMLDSig 簽章、剖析經強化的 XML,並拒絕過期的清單。再多一次呼叫 TslTrustAnchorProvider::buildBundle(),即可把處於 active 狀態的 CA/QC 服務轉換為版本化的信任錨套件。每一道關卡都 fail closed;每一次拒絕都是一個具型別的例外。
本頁負責清單擷取與錨推導。憑證路徑驗證位於簽章驗證。eIDAS 保證等級對應位於 eIDAS 保證等級。容器信任綁定位於 ASiC 信任綁定。
供應與授權
標題為「供應與授權」的區段此能力隨 NextPDF Enterprise(nextpdf/enterprise)出貨,並以 Enterprise 級授權封套啟用。不具該權益的部署不會載入此能力的類別。比較各版本並取得授權。
composer require nextpdf/enterprise啟用需要你的 Enterprise 授權封套。參見安裝與驗證授權。本頁的類別位於 NextPDF\Enterprise\Security\Tsl 之下;網路政策型別位於 NextPDF\Enterprise\Security 之下。線上擷取另需任一 PSR-18 client 與 PSR-17 factory(例如 guzzlehttp/guzzle)。
概念總覽
標題為「概念總覽」的區段根據 eIDAS Article 22,各會員國發布一份其合格信任服務提供者的信任清單,並經簽署或蓋章以供自動化處理。ETSI TS 119 612 定義了該 XML 格式。清單的可信程度,取決於三項檢查:它的簽章、它的結構,以及它的新鮮度。NextPDF 依此順序將三者作為單一管線執行:
- 擷取(Fetch)——
TslFetcher僅透過 HTTPS 取回該 XML。一道 SSRF 防護會在任何對外連線前驗證主機。回應有大小上限,而 PSR-16 cache 可啟用ETag重新驗證與離線讀取。 - 驗證(Verify)——
TslSignatureVerifier檢查該 enveloped XMLDSig 簽章。簽署憑證必須鏈接至你以帶外方式釘選的信任錨;文件內部的任何內容本身皆不受信任。 - 剖析(Parse)——
TslXmlParser將 scheme 資訊與每一項 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 簽章處理——任意的 transform 鏈、由攻擊者宣告的 ID 參照、演算法敏捷性(algorithm agility)——正是驗證器在歷史上屢屢失守之處。因此驗證器只接受唯一一種處理模型:exclusive C14N、一個涵蓋根節點的 reference,以及 [enveloped-signature, exclusive-C14N] 這組雙 transform 管線,其餘一切皆 fail-closed 拒絕。信任絕不從文件自身啟動: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 錯誤狀態、過大回應或空的 body 時擲出 TslFetchException;當 NetworkPolicy::STRICT_OFFLINE 啟用且無快取 body 存在時擲出 NetworkPolicyViolation。快取 body 可滿足 304 Not Modified 重新驗證,且在 STRICT_OFFLINE 下是唯一會被送出的 body。快取項目的存活時間為 $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)。正規化:僅限 exclusive 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 | reference 摘要與正規化後的文件不符。 |
unsupported_algorithm | 簽章或摘要演算法在允許清單之外。 |
unsupported_transform | 正規化或 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 service-type 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。Qualifier 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 值,或當清單不含任何 active 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。它們指名了每一項裁決背後的確切錨集合。
邊界情況與陷阱
標題為「邊界情況與陷阱」的區段- enforcer 的新鮮度關卡使用目前時鐘。
fetchAndVerify()與verifyXml()會拒絕NextUpdate已過的清單。若要對封存清單進行歷史性驗證,請直接驅動TslSignatureVerifier與TslXmlParser,然後以你的證據所支持的過去時刻呼叫assertFresh()。 buildBundle()會在你的$now重新斷定新鮮度。 一份通過 enforcer 的清單,若你的驗證時刻更晚,仍可能在此被拒絕。參見上方的 BC 註記。- 絕不從你正在驗證的清單播種
trustAnchorsPem。 錨必須來自帶外釘選的來源(對 LOTL 而言)或已驗證過的上層清單(對會員國 TSL 而言)。其餘任何做法都會使驗證變成循環。 - 任何位置的 DOCTYPE 都是致命的。 符合規範的 TSL 絕不攜帶 DTD,因此剖析器會在 libxml 建立實體表之前拒絕任何 DOCTYPE。這是刻意的強化,而非剖析器的限制。
- 缺漏的結構性欄位會安全降級。 沒有可讀狀態的服務會被視為 withdrawn,因此絕不可能成為錨。缺漏的 scheme territory 會剖析為
unknown。fail-closed 的預設值讓格式錯誤的項目排除在信任材料之外。 - 中繼憑證必須是真正的 CA。 在鏈建構期間,不具
basicConstraints cA=TRUE(或宣告了keyUsage卻無keyCertSign)的候選簽發者會被略過。被夾帶進KeyInfo的端實體憑證無法充當路徑中繼。鏈的深度上限為 8。 NextUpdate必須是正規 UTC。 不具明確Z或數值偏移的值會擲出TslParseException。它絕不會被重新解讀為伺服器的本機時區。- 大型清單與位元組上限。 回應最多讀取到
$maxBytes(預設 16 MiB)。若你的 scheme 清單更大,請在建構子中提高上限;截斷會呈現為簽章失敗,而絕不會是默默接受。 clockTolerance只會放寬。 它為憑證有效期檢查加上對稱的寬容量。它不會鬆綁清單層級的新鮮度關卡。
安全性註記
標題為「安全性註記」的區段- 一律先驗證再剖析。
TslXmlParser設計上與簽章無關。TslPolicyEnforcer會先排定驗證;若你自行組合各元件,請維持該順序。 - 縱深防禦的 SSRF 防護。
fetch()要求https://並針對私有、loopback、link-local、CGN 與 cloud-metadata 範圍驗證主機,並以 A 與 AAAA DNS 解析來緩解 rebinding。遭拒的 URL 會在任何對外連線前擲出。 - XXE 與實體展開的強化。 帶有 DOCTYPE 的文件會在實體表存在之前、以及載入之後再次被拒絕。網路實體載入被停用;外部實體絕不會被替換。
- 嚴格的 XMLDSig 設定檔。 僅限 exclusive C14N;恰為
[enveloped-signature, exclusive-C14N]這組 transform 對;被驗證的 reference 必須涵蓋文件根節點;enveloped transform 只移除被驗證的那個簽章,並保留同層的其他簽章。已淘汰的演算法(SHA-1、MD5)會被拒絕。 - 鏈的紀律。 每一個鏈環——簽署者、中繼憑證,以及直接錨的情況——都會檢查時間有效性,並在有效期界限無法剖析時 fail-closed。迴圈會被偵測;深度有上限。
- 氣隙姿態。 在
NetworkPolicy::STRICT_OFFLINE下,擷取路徑完全不執行任何對外連線;只有先前快取的 body 可能被送出,其餘任何情況都會 fail-fast 擲出NetworkPolicyViolation。 - 套件摘要偵測毀損,而非竄改。
bundleSha256在建構時被驗證,並偵測轉錄漂移。當摘要衍生自它所保護的同一批錨時,它並非獨立的竄改證據。在系統之間運送套件時,請以帶外方式釘選摘要。
符規性
標題為「符規性」的區段此管線依 ETSI TS 119 612 的定義消費信任清單:它驗證 scheme 操作者的簽章(§5.7)、剖析 scheme 資訊與提供者清單結構(§5.3、§5.4、§5.5)、施行 UTC dateTime 規則(§5.1.3),並丟棄 NextUpdate 已過的清單(§5.3.15)。這支援 eIDAS Article 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下,只有快取 body 會被回傳。- 在
verify()成功之前,任何剖析器輸出都不會成為信任材料;TslPolicyEnforcer保證該順序。 - 只有當摘要與簽章在固定設定檔下檢查通過,且簽署者在深度 8 之內、每一環都時間有效地鏈接至一個已設定的錨時,
verify()才會回傳簽署者 PEM。 - enforcer 會拒絕任何在目前時鐘下
NextUpdate已過的清單;buildBundle()會在推導錨之前,於呼叫端提供的時刻重新斷定新鮮度。 - 錨僅衍生自處於 granted 狀態且具 CA/QC 服務型別的服務;空的 active 集合會擲出,而非產生空套件。
- 每一次失敗都是一個具型別的例外(
TslFetchException、NetworkPolicyViolation、帶有 reason code 的TslSignatureException、TslParseException);沒有任何方法會回傳一份部分或未驗證的文件。
Core 退路
標題為「Core 退路」的區段NextPDF Core 針對你透過其 CaTrustAnchorBundle 契約明確釘選的信任錨驗證 PDF 簽章——參見 Core 安全性。Core 沒有信任清單能力:沒有 TSL 擷取、沒有 XMLDSig 清單驗證來源、沒有 ETSI TS 119 612 剖析,也沒有從合格服務項目推導錨。僅以 Core 你需要手動維護你的錨集合;從已驗證來源的歐盟信任清單推導它,則需要 NextPDF Enterprise。
發布邊界
標題為「發布邊界」的區段本頁僅記載外部可觀察的行為與受支援的公開 API 介面。內部命名空間路徑、輔助類別、機制表、runbook 檔名與工單前綴皆不在範圍內。