跳到內容
getnextpdf.com

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 Enterprisenextpdf/enterprise)出貨,並以 Enterprise 級授權封套啟用。不具該權益的部署不會載入此能力的類別。比較各版本並取得授權

Terminal window
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 依此順序將三者作為單一管線執行:

  1. 擷取(Fetch)——TslFetcher 僅透過 HTTPS 取回該 XML。一道 SSRF 防護會在任何對外連線前驗證主機。回應有大小上限,而 PSR-16 cache 可啟用 ETag 重新驗證與離線讀取。
  2. 驗證(Verify)——TslSignatureVerifier 檢查該 enveloped XMLDSig 簽章。簽署憑證必須鏈接至你以帶外方式釘選的信任錨;文件內部的任何內容本身皆不受信任。
  3. 剖析(Parse)——TslXmlParser 將 scheme 資訊與每一項 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 簽章處理——任意的 transform 鏈、由攻擊者宣告的 ID 參照、演算法敏捷性(algorithm agility)——正是驗證器在歷史上屢屢失守之處。因此驗證器只接受唯一一種處理模型:exclusive C14N、一個涵蓋根節點的 reference,以及 [enveloped-signature, exclusive-C14N] 這組雙 transform 管線,其餘一切皆 fail-closed 拒絕。信任絕不從文件自身啟動: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 錯誤狀態、過大回應或空的 body 時擲出 TslFetchException;當 NetworkPolicy::STRICT_OFFLINE 啟用且無快取 body 存在時擲出 NetworkPolicyViolation。快取 body 可滿足 304 Not Modified 重新驗證,且在 STRICT_OFFLINE 下是唯一會被送出的 body。快取項目的存活時間為 $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)。正規化:僅限 exclusive C14N 1.0。SHA-1 與 MD5 會被拒絕為 unsupported_algorithm

擲出或失敗於: TslSignatureException,並攜帶一個機器可讀的 reason

Reason code意義
missing_signature文件沒有 ds:Signature 元素。
untrusted_signerKeyInfo 憑證未鏈接至已設定的錨。
invalid_signature結構缺陷,或 RSA/ECDSA 檢查失敗。
digest_mismatchreference 摘要與正規化後的文件不符。
unsupported_algorithm簽章或摘要演算法在允許清單之外。
unsupported_transform正規化或 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 service-type 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。Qualifier URI(例如 TspServiceQualifier::FOR_ESIGFOR_ESEALQSCD_STATEMENTNO_QSCD)呈現於 TspServiceQualifier,供 eIDAS 對應層使用。

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 錨)、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。它們指名了每一項裁決背後的確切錨集合。

  • enforcer 的新鮮度關卡使用目前時鐘。 fetchAndVerify()verifyXml() 會拒絕 NextUpdate 已過的清單。若要對封存清單進行歷史性驗證,請直接驅動 TslSignatureVerifierTslXmlParser,然後以你的證據所支持的過去時刻呼叫 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 消費一份信任清單,本身並不會使一個簽章成為「合格」或具法律效力。你的完整驗證流程是否符合某項法律或採購要求,是由你的評估者判定。

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 集合會擲出,而非產生空套件。
  • 每一次失敗都是一個具型別的例外(TslFetchExceptionNetworkPolicyViolation、帶有 reason code 的 TslSignatureExceptionTslParseException);沒有任何方法會回傳一份部分或未驗證的文件。

NextPDF Core 針對你透過其 CaTrustAnchorBundle 契約明確釘選的信任錨驗證 PDF 簽章——參見 Core 安全性。Core 沒有信任清單能力:沒有 TSL 擷取、沒有 XMLDSig 清單驗證來源、沒有 ETSI TS 119 612 剖析,也沒有從合格服務項目推導錨。僅以 Core 你需要手動維護你的錨集合;從已驗證來源的歐盟信任清單推導它,則需要 NextPDF Enterprise。

本頁僅記載外部可觀察的行為與受支援的公開 API 介面。內部命名空間路徑、輔助類別、機制表、runbook 檔名與工單前綴皆不在範圍內。