跳到內容
getnextpdf.com

Enterprise 版本

批次簽章驗證

NextPDF Enterprise 能在單一呼叫中跨多份 PDF 文件驗證數位簽章。NextPDF\Enterprise\Signature\BatchSignatureValidator::validate() 接收一份文件清單並回傳一個 BatchValidationReport。每個簽章都會通過相同的 fail-closed 管線:在簽署位元組範圍上進行密碼學 CMS 認證、信任錨定的憑證鏈驗證,以及 OCSP/CRL 撤銷檢查。報告會攜帶每份文件與每個簽章的細節 — CertChainStatusRevocationStatusTimestampStatus — 讓合規工具能從其記錄的證據重新推導出每一個裁決。

裁決模型刻意採取嚴格立場。只有在所有證據都被積極確立時,簽章才會是 Valid。缺少撤銷證據會產生 Indeterminate,絕不會是 Valid。本頁涵蓋批次協調器及其結果型別。單一文件的 AdES 驗證側記載於 簽章驗證。嵌入長期驗證材料的說明則記載於 Archive

此能力隨 NextPDF Enterprisenextpdf/enterprise)出貨,並透過 Enterprise 等級的授權封裝啟用。未持有該權益的部署不會載入此能力的類別。比較各版本並取得授權

Terminal window
composer require nextpdf/enterprise

nextpdf/premium metapackage 也會解析出 Enterprise 套件。啟用時使用你的 Enterprise 授權封裝;請參閱 授權與啟用。批次型別自動載入於 NextPDF\Enterprise\Signature 之下。除引擎基準之外,不需要任何額外的 PHP 擴充。

一次對 validate() 的呼叫會處理一份 DocumentSignatureInput 值的清單。每個輸入攜帶一個文件識別碼、原始 PDF 位元組,以及選用的 PEM 編碼信任錨。驗證器會擷取每份文件的簽章字典,並對每個簽章執行三個階段。

階段 1 — 密碼學認證。 來自 /Contents 的分離式 CMS/PKCS#7 blob 會在 /ByteRange 所涵蓋的位元組上進行驗證。驗證器自行重新計算內容摘要,並將其與 messageDigest 簽署屬性比對。它絕不信任產生者提供的摘要(RFC 5652 §5.6)。簽章值必須通過驗證,且簽署憑證必須綁定至 CMS。缺少或格式錯誤的 /Contents/ByteRange、無法解析的 CMS、摘要不符,或簽章檢查失敗,全都會 fail closed。在 SHA-1 下通過驗證的簽章會被視為弱簽章,絕不構成完整通過。

階段 2 — 鏈驗證與信任錨定。 從 CMS 復原的簽署者鏈會作為候選憑證路徑進行驗證。你提供的 trustedCerts 就是信任錨輸入,符合 RFC 5280 §6.1.1 的意義:鏈的終端必須以 DER SHA-256 指紋比對到某個提供的錨。若一條結構一致的鏈其終端並非已設定的錨,則絕不會被回報為受信任。在沒有可用錨的情況下,只會回報結構裁決,而 CertChainStatus::$trusted 維持為 false

階段 3 — 撤銷。 撤銷會在認證之後於復原的鏈上執行,呼應 ETSI EN 319 102-1 模型中撤銷檢查接續於成功路徑驗證之後的做法(clause 5.2.6.2)。OCSP 為主:只有經密碼學驗證的回應才算數,判為 GoodRevoked。CRL 路徑為後備,並證明清單的新鮮度。當兩個 client 皆未設定時,狀態為 unavailable

每個簽章的裁決是一個 SignatureValidationStatus。此分類法在每個簽章的粒度上呼應 ETSI EN 319 102-1 的狀態模型(TOTAL-PASSED / TOTAL-FAILED / INDETERMINATE):

證據裁決
憑證確認已撤銷Invalid(決定性,無論其他檢查為何)
CMS 認證失敗,未復原任何簽署者材料Error
CMS 認證失敗,存在簽署者材料Invalid
已認證,但鏈未通過驗證Invalid(若無鏈則為 Error
已認證且鏈有效,但無已確認的信任錨Indeterminate
已認證、鏈有效、受信任,但無確定的非撤銷Indeterminate
以上全部皆積極確立Valid

確定非撤銷規則。「未被證明已撤銷」並不等同於「被證明未撤銷」。Valid 裁決至少需要一個 Good 撤銷結果。經驗證為良好的 OCSP 回應是確定的形式:它斷言簽署者憑證自身的狀態。一份經密碼學接受、新鮮的 CRL 在本實作中也能滿足此門檻,但僅作為新鮮度與完整性的證明 — 該路徑並不逐序號解析條目,因此它不提供任何逐序號的撤銷保證,也絕不會產生肯定的 revoked 裁決。凡是肯定撤銷偵測重要之處,都請設定 OCSP:僅使用 CRL 的部署不會將已撤銷的憑證呈現為 Invalid。當 OCSP 與 CRL 結果皆為 UnknownUnavailable 時,撤銷狀態無法確定,裁決為 Indeterminate。這遵循 ETSI EN 319 102-1:無法取得的撤銷狀態資訊會導致 INDETERMINATE,絕不通過(clause 5.1.3,TRY_LATER)。這是 3.1.0 中具向後相容性影響的行為強化:較早的版本可能在沒有確定撤銷證據的情況下回報 Valid。未設定任何 OCSP 或 CRL client 的部署,如今在過去看到 Valid 之處通常會看到 Indeterminate

有兩條邊界誠實地界定了此能力。第一,批次驗證器不評估嵌入的時間戳記符記:批次結果中的 TimestampStatus 永遠是缺席狀態。RFC 3161 時間戳評估屬於單一文件驗證側;請參閱 簽章驗證。第二,本頁是唯讀驗證。嵌入 DSS/VRI 材料以達成長期有效性屬於 Archive 能力。

承載重量的決策是一個 fail-closed 的裁決產生器。Valid 只會由三個軸線上的積極證據鑄造:密碼學認證、一條信任錨定的鏈,以及確定的非撤銷。任何未確立者都會退化為 Indeterminate,而非預設為通過,這正是 EN 319 102-1 對於缺少撤銷材料的立場。批次吞吐量絕不換回嚴謹:批次層是在同一份經稽核的 CMS 驗證器(單一文件也使用該驗證器)之上的協調,因此一次 1,000 份文件的執行套用的是相同的密碼學。報告也將證據與裁決分離 — CertChainStatusRevocationStatus 記錄每個裁決所依據的輸入,讓稽核者日後能重新推導出來。

設計背景:大規模簽署,不妥協

以下所有符號皆為 nextpdf/enterprise 3.1.0 的公開 API。

final class BatchSignatureValidator
{
public function __construct(
?SignatureExtractor $extractor = null,
?CertificateChainValidator $chainValidator = null,
private readonly ?OcspClient $ocspClient = null,
private readonly ?CrlFetcher $crlFetcher = null,
?CmsSignatureDataExtractor $cmsExtractor = null,
private readonly ClockInterface $clock = new SystemClock(),
)
public function validate(array $inputs): BatchValidationReport
}

擲出或失敗於:若輸入清單為空,validate() 會擲出 \InvalidArgumentException;當批次超過 1,000 份文件時擲出 \OverflowException。無法解析為 PDF 的文件不會擲出例外;它會成為一個每份文件的 Error 結果。$clock 是用於 CRL 新鮮度決策的 PSR-20 Psr\Clock\ClockInterface,因此在凍結的測試時鐘下裁決具有確定性。

final readonly class DocumentSignatureInput
{
public string $documentId;
public function __construct(
string $documentId,
public string $pdfData,
public array $trustedCerts = [],
)
}

擲出或失敗於:若 $documentId 為空字串則擲出 \InvalidArgumentException$trustedCerts 是一份 PEM 編碼信任錨憑證的清單。

final readonly class BatchValidationReport
{
public function __construct(
public array $documents,
public int $totalDocuments,
public int $totalSignatures,
public int $totalValid,
public int $totalInvalid,
public float $durationMs,
)
public function allValid(): bool
public function hasDocumentsWithoutSignatures(): bool
public function toJson(?CertPiiGuard $piiGuard = null): string
}

擲出或失敗於:若編碼失敗,toJson() 會擲出 \JsonException。只有在存在簽章且沒有任何一個非有效時,allValid() 才為 truetoJson() 預設套用一個 privacy-by-default 的 NextPDF\Enterprise\Signature\Eidas\CertPiiGuard,遮罩簽署者名稱、根簽發者、TSA 名稱與鏈問題診斷;關於該守衛的 API,請參閱 eIDAS 保證等級

DocumentValidationResultDocumentValidationStatus

標題為「DocumentValidationResult 與 DocumentValidationStatus」的區段
final readonly class DocumentValidationResult
{
public function __construct(
public string $documentId,
public DocumentValidationStatus $status,
public array $signatures,
public int $validCount,
public int $invalidCount,
)
public function hasSignatures(): bool
public function totalSignatures(): int
}
enum DocumentValidationStatus: string
{
case AllValid = 'all_valid';
case SomeInvalid = 'some_invalid';
case AllInvalid = 'all_invalid';
case NoSignatures = 'no_signatures';
case Error = 'error';
}

擲出或失敗於:無。不可變值物件與後端列舉。

SignatureValidationResultSignatureValidationStatus

標題為「SignatureValidationResult 與 SignatureValidationStatus」的區段
final readonly class SignatureValidationResult
{
public function __construct(
public SignatureValidationStatus $status,
public CertChainStatus $certChain,
public TimestampStatus $timestamp,
public RevocationStatus $revocation,
public string $signer,
public string $level = '',
public string $subFilter = '',
public string $reason = '',
)
public function isValid(): bool
}
enum SignatureValidationStatus: string
{
case Valid = 'valid';
case Invalid = 'invalid';
case Indeterminate = 'indeterminate';
case Error = 'error';
}

擲出或失敗於:無。當認證通過時,$signer 是經 CMS 驗證的憑證主體,否則為空字串。$level 是一個由 SubFilter 衍生的標籤(例如 ETSI.CAdES.detached 對應 B-B),而非 AdES 一致性判定。

final readonly class CertChainStatus
{
public function __construct(
public bool $valid,
public bool $trusted,
public int $chainLength,
public string $rootIssuer,
public array $issues = [],
)
public function hasIssues(): bool
}

擲出或失敗於:無。$trusted 只在確認信任錨成員命中時才設定,絕不會因錨清單非空而設定。

final readonly class RevocationStatus
{
public function __construct(
public RevocationCheckResult $ocspStatus,
public RevocationCheckResult $crlStatus,
public bool $isRevoked,
public ?DateTimeImmutable $revocationDate = null,
)
public static function unavailable(): self
public function hasConclusiveGood(): bool
}
enum RevocationCheckResult: string
{
case Good = 'good';
case Revoked = 'revoked';
case Unknown = 'unknown';
case Unavailable = 'unavailable';
}

擲出或失敗於:所示成員皆不擲出。此類別也提供經證據檢查的靜態工廠(good()revoked()fromResults()),當所主張的狀態與 OCSP/CRL 證據矛盾時會擲出 \InvalidArgumentException — 已撤銷的結果絕不能鑄造為未撤銷,反之亦然。hasConclusiveGood() 只在未撤銷狀態且至少一項檢查為 Good 時才為 true

final readonly class TimestampStatus
{
public function __construct(
public bool $present,
public bool $valid,
public ?DateTimeImmutable $timestampTime = null,
public string $tsaName = '',
public array $issues = [],
)
public static function absent(): self
}

擲出或失敗於:無。在批次結果中這永遠是 absent() 狀態;請參閱 邊界情況與陷阱

驗證一份文件並讀取報告。此範例使用一份未簽署的 PDF,因此輸出具有確定性。

batch-quick-start.php
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use NextPDF\Enterprise\Signature\BatchSignatureValidator;
use NextPDF\Enterprise\Signature\DocumentSignatureInput;
// A minimal, unsigned PDF: the validator reports it as no_signatures.
$unsigned = "%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n";
$validator = new BatchSignatureValidator();
try {
$report = $validator->validate([
new DocumentSignatureInput(documentId: 'doc-001', pdfData: $unsigned),
]);
} catch (\InvalidArgumentException $e) {
// Empty input list, or an empty documentId.
echo 'Rejected: ' . $e->getMessage() . "\n";
exit(1);
}
echo 'Documents: ' . $report->totalDocuments . "\n";
echo 'Signatures: ' . $report->totalSignatures . "\n";
foreach ($report->documents as $doc) {
echo $doc->documentId . ': ' . $doc->status->value . "\n";
}
echo 'All valid: ' . ($report->allValid() ? 'yes' : 'no') . "\n";
echo 'Unsigned documents: ' . ($report->hasDocumentsWithoutSignatures() ? 'yes' : 'no') . "\n";

預期輸出:

Documents: 1
Signatures: 0
doc-001: no_signatures
All valid: no
Unsigned documents: yes

請注意此處 allValid() 回報 no:它需要至少一個簽章且沒有任何非有效結果,因此空的簽章集合絕不會靜默通過。

以撤銷 client、信任錨、批次分塊,以及一份受 PII 保護的 JSON 報告,驗證一整個目錄的已簽署合約。

batch-validate-contracts.php
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use NextPDF\Enterprise\Security\Ltv\CrlFetcher;
use NextPDF\Enterprise\Security\Ltv\OcspClient;
use NextPDF\Enterprise\Security\Ltv\OcspResponseCache;
use NextPDF\Enterprise\Signature\BatchSignatureValidator;
use NextPDF\Enterprise\Signature\DocumentSignatureInput;
use NextPDF\Enterprise\Signature\SignatureValidationStatus;
// Any PSR-18 client works; Guzzle shown here.
$httpClient = new \GuzzleHttp\Client(['timeout' => 10]);
// Revocation clients make a conclusive non-revoked (Good) result reachable.
// Without them, every verdict tops out at Indeterminate. The response cache
// lets repeat signers across the batch resolve without extra network calls.
$validator = new BatchSignatureValidator(
ocspClient: new OcspClient($httpClient, cache: new OcspResponseCache()),
crlFetcher: new CrlFetcher($httpClient),
);
// Trust anchors are an input: the chain terminus must match one of these.
$anchors = [(string) file_get_contents('/etc/nextpdf/trust/enterprise-root.pem')];
$inputs = [];
foreach (glob('/var/contracts/signed/*.pdf') ?: [] as $path) {
$inputs[] = new DocumentSignatureInput(
documentId: basename($path),
pdfData: (string) file_get_contents($path),
trustedCerts: $anchors,
);
}
$exit = 0;
// One call is capped at 1,000 documents; chunk larger runs.
foreach (array_chunk($inputs, 1000) as $batch) {
try {
$report = $validator->validate($batch);
// Signer PII is redacted by default in the serialized report.
file_put_contents('/var/log/nextpdf/batch-report.jsonl', $report->toJson() . PHP_EOL, FILE_APPEND); // one JSON document per line
} catch (\InvalidArgumentException | \OverflowException $e) {
fwrite(STDERR, 'Batch rejected: ' . $e->getMessage() . "\n");
exit(2);
} catch (\JsonException $e) {
fwrite(STDERR, 'Report encoding failed: ' . $e->getMessage() . "\n");
exit(3);
}
foreach ($report->documents as $doc) {
foreach ($doc->signatures as $sig) {
if ($sig->status !== SignatureValidationStatus::Valid) {
$exit = 1;
fwrite(STDERR, sprintf(
"%s: %s (chain trusted: %s, revoked: %s)\n",
$doc->documentId,
$sig->status->value,
$sig->certChain->trusted ? 'yes' : 'no',
$sig->revocation->isRevoked ? 'yes' : 'no',
));
}
}
}
}
exit($exit);

預期輸出(stderr,針對一份撤銷證據無法取得的文件;其他行會隨你的輸入而不同):

contract-0042.pdf: indeterminate (chain trusted: yes, revoked: no)

JSON 報告會透過預設的 CertPiiGuard 序列化簽署者身分欄位,因此每個簽章的條目看起來像這樣(節錄、示意):

{
"status": "indeterminate",
"signer": "[REDACTED]",
"level": "B-B",
"subFilter": "ETSI.CAdES.detached"
}
  • 空的輸入清單會擲出 \InvalidArgumentException;一次呼叫超過 1,000 份文件會擲出 \OverflowException。請如正式環境範例那樣分塊處理較大的執行。
  • 從較早版本升級: 在未設定任何 OCSP 或 CRL client 時,撤銷為 unavailable,因此沒有任何簽章能達到 Valid。較早版本在此回報 Valid;3.1.0 回報 Indeterminate(請參閱 概念概覽)。
  • 文件層級的計數器很嚴格:只有 Valid 會遞增 validCountInvalidIndeterminateError 全都遞增 invalidCount。因此一份唯一簽章為 Indeterminate 的文件會回報 all_invalid。當這個區別重要時,請以每個簽章的 status 作為判斷依據。
  • OCSP 檢查只在復原的鏈至少有兩張憑證時才執行,因為查詢需要簽發者。單一憑證的鏈會落至 CRL 路徑或 unavailable
  • 在批次結果中,crlStatus 絕不回報 revoked。CRL 後備僅證明清單新鮮度;具權威性的已撤銷結果來自 OCSP。
  • 在批次結果中,timestamp 永遠是 absent()。批次驗證器不評估嵌入的 RFC 3161 符記;時間戳評估請使用 簽章驗證
  • 當認證失敗時 signer 為空。當有值時,它是經 CMS 驗證之憑證的主體 CN(或 O)— 絕不是來自簽章字典中未經認證的 /Name 字串。
  • trustedCerts 條目必須是 PEM 憑證。空的或格式錯誤的錨清單會產生僅結構的鏈裁決,其 trusted: false,將裁決封頂於 Indeterminate
  • 不以 PDF 標頭開頭的位元組會產生一個每份文件的 error 狀態且零簽章 — 不會有例外。
  • toJson() 預設遮罩 PII。只在你握有記載於文件的合法處理簽署者身分之依據時,才傳入 new CertPiiGuard(disclosePii: true)
  • Fail-closed 的裁決產生器。 Valid 需要以下全部:在 /ByteRange 摘要上經驗證的 CMS 認證、一條有效的鏈、確認的信任錨成員資格,以及確定的非撤銷狀態。每一項未確立的檢查都會使裁決退化;沒有任何東西預設為通過。
  • 不容身分洗白。 回報的簽署者是經密碼學綁定的憑證主體。/Name 條目是攻擊者可控的中繼資料,絕不會被當作簽署者呈現。
  • 弱演算法絕不通過。 通過驗證的 SHA-1 簽章仍會被回報為非有效;弱摘要下的密碼學有效性不會被洗成完整通過。
  • 信任是輸入,不是推論。 你提供的錨會以 DER SHA-256 指紋比對鏈的終端(RFC 5280 §6.1.1)。一條鏈的自我一致性,或僅是一份非空的錨清單,絕不建立信任。
  • 撤銷具決定性。 一份經驗證的已撤銷聲明會強制 Invalid,無論其他所有檢查為何;無法取得的證據會強制 Indeterminate
  • 序列化輸出中的預設隱私。 除非你選擇退出,否則 toJson() 會遮罩簽署者 CN、根簽發者 DN、TSA 名稱與鏈問題診斷,在序列化邊界上落實 GDPR Article 5(1)(c) 的資料最小化。
  • 確定性時間。 CRL 新鮮度決策讀取注入的 PSR-20 時鐘,而非主機的實際時鐘,因此撤銷裁決在測試下可重現。

NextPDF Enterprise 實作了受 ETSI EN 319 102-1(三值驗證狀態模型,以及無法取得撤銷資訊即產生 INDETERMINATE 的規則)、RFC 5652 §5.6(驗證側的摘要重新計算)與 RFC 5280 §6.1(信任錨作為信賴方輸入至路徑驗證)啟發的行為。支援不等於一致性,而一致性不等於認證。NextPDF 未持有任何認證,也不授予任何認證。批次驗證器不是合格驗證服務,其狀態是與 EN 319 102-1 分類法對齊的工程裁決 — 而非來自完整 clause 5 驗證流程的 TOTAL-PASSED/TOTAL-FAILED/INDETERMINATE 指示。尤其批次模式不執行任何存在證明或時間戳處理;那片領域由單一文件驗證側涵蓋。

批次驗證器不諮詢任何 FIPS 模式政策,且啟用 FIPS 模式不會改變批次裁決。其驗證側的演算法處理是固定且 fail-closed 的:弱(SHA-1)簽章絕不會被回報為 Valid,無論有無 FIPS 模式。Enterprise 的 FIPS 模式政策把關的是簽署/產生側,記載於 FIPS 140 — 深度參考。FIPS 140 支援是一項能力聲明,而非驗證或認證主張。

  • validate() 對空清單擲出 \InvalidArgumentException,對超過 1,000 份文件擲出 \OverflowException。格式錯誤的文件絕不擲出例外;它們產生每份文件的 error 結果。
  • Valid 需要以下的連言:CMS 經密碼學驗證、鏈有效、確認信任錨成員資格,且 RevocationStatus::hasConclusiveGood() 為真。
  • 一張確認已撤銷的憑證具決定性:無論所有其他證據為何,裁決都是 Invalid
  • 兩項撤銷檢查皆為 Unknown/Unavailable 表示 Indeterminate,絕不是 Valid(3.1.0 強化,具向後相容性影響)。
  • 一個已認證、鏈有效但沒有確認信任錨的簽章是 Indeterminate — 真實,但信任未確立。
  • signer 是經 CMS 驗證的主體或空字串;/Name 條目絕不使用。
  • 在批次結果中,timestamp 永遠是缺席狀態。
  • validCount 只計入 Valid;所有其他狀態都計入 invalidCount,而文件狀態由這些計數器彙整。
  • 除非明確傳入一個守衛,否則 toJson() 套用 privacy-by-default 的 CertPiiGuard
  • 報告總計是對每份文件結果的精確加總;durationMs 是批次所測得的實際耗時。

NextPDF Core 的 Security / Signing 模組是產生側:它建立 CMS 簽章、套用 RFC 3161 時間戳,並針對其在簽署時嵌入的材料驗證鏈與撤銷。Core 未出貨任何驗證側批次協調器:沒有多文件報告、沒有彙總狀態分類法、沒有針對第三方文件的 OCSP/CRL 撤銷裁決,也沒有受 PII 保護的報告序列化。僅靠 Core,你得自行擷取並驗證每個簽章,並建構自己的報告。Enterprise 的單一文件驗證側(簽章驗證)與此批次協調器提供了那一層。

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