Enterprise 版本
批量签名验证
NextPDF Enterprise 只需一次调用即可验证多个 PDF 文档中的数字签名。NextPDF\Enterprise\Signature\BatchSignatureValidator::validate() 接收一个文档列表,并返回一个 BatchValidationReport。每个签名都会经过相同的 fail-closed 流水线:对已签名字节范围进行 CMS 密码学身份认证、基于信任锚的证书链验证,以及 OCSP/CRL 吊销检查。报告携带按文档和按签名的细节——CertChainStatus、RevocationStatus、TimestampStatus——以便合规工具能够从其记录的证据重新推导出每一条裁决。
裁决模型刻意设计得很严格。只有当所有证据都得到肯定性确立时,签名才为 Valid。缺失的吊销证据会产生 Indeterminate,绝不会是 Valid。本页介绍批量编排器及其结果类型。单文档 AdES 验证侧记录于签名验证。嵌入长期验证材料记录于归档。
可用性与授权
标题为“可用性与授权”的章节此能力随 NextPDF Enterprise(nextpdf/enterprise)一起提供,并通过 Enterprise 层级的许可信封激活。缺少该授权的部署不会加载此能力的类。比较各版本并获取许可。
composer require nextpdf/enterprisenextpdf/premium 元包也会解析出 Enterprise 包。激活使用你的 Enterprise 许可信封;参见许可与激活。批量类型在 NextPDF\Enterprise\Signature 下自动加载。除引擎基线之外,不需要任何额外的 PHP 扩展。
概念总览
标题为“概念总览”的章节一次 validate() 调用处理一个 DocumentSignatureInput 值的列表。每个输入携带一个文档标识符、原始 PDF 字节,以及可选的 PEM 编码信任锚。验证器提取每个文档的签名字典,并对每个签名运行三个阶段。
阶段 1 — 密码学身份认证。 来自 /Contents 的分离式 CMS/PKCS#7 数据块会在 /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 为主:只有经过密码学验证的响应才算数,为 Good 或 Revoked。CRL 路径为回退,并证明列表的新鲜度。当两个客户端都未配置时,状态为 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 结果都为 Unknown 或 Unavailable 时,吊销状态未定,裁决为 Indeterminate。这遵循 ETSI EN 319 102-1:不可用的吊销状态信息导致 INDETERMINATE,绝不通过(clause 5.1.3,TRY_LATER)。这是 3.1.0 中的一项行为加固,具有向后兼容性影响:早期版本可能在没有确凿吊销证据的情况下报告 Valid。未配置 OCSP 或 CRL 客户端的部署现在通常会在之前看到 Valid 的地方看到 Indeterminate。
有两条边界诚实地界定了此能力。第一,批量验证器不评估嵌入的时间戳令牌:批量结果中的 TimestampStatus 始终为缺失状态。RFC 3161 时间戳评估属于单文档验证侧;参见签名验证。第二,本页是只读验证。为长期有效性嵌入 DSS/VRI 材料是归档能力。
为何采用这种设计
标题为“为何采用这种设计”的章节承重的决策是一个 fail-closed 的裁决生成器。Valid 只从三条轴上的肯定性证据铸造而来:密码学身份认证、基于信任锚的链,以及确凿的未吊销。任何未确立的项都会降级为 Indeterminate,而不是默认为通过,这正是 EN 319 102-1 针对缺失吊销材料的立场。批量吞吐绝不以牺牲严谨性为代价:批量层是在与单文档相同的、经过审计的 CMS 验证器之上的编排,因此 1,000 个文档的运行应用完全相同的密码学。报告还将证据与裁决分离——CertChainStatus 和 RevocationStatus 记录每条裁决所依据的输入,以便审计员日后能够重新推导。
设计背景:大规模签名,绝不妥协。
API 接口
标题为“API 接口”的章节以下所有符号均为 nextpdf/enterprise 3.1.0 中的公开 API。
BatchSignatureValidator
标题为“BatchSignatureValidator”的章节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,因此在冻结的测试时钟下裁决是确定性的。
DocumentSignatureInput
标题为“DocumentSignatureInput”的章节final readonly class DocumentSignatureInput{ public string $documentId;
public function __construct( string $documentId, public string $pdfData, public array $trustedCerts = [], )}抛出或失败情形:若 $documentId 为空字符串,则抛出 \InvalidArgumentException。$trustedCerts 是 PEM 编码信任锚证书的列表。
BatchValidationReport
标题为“BatchValidationReport”的章节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() 才为 true。默认情况下,toJson() 应用一个隐私默认开启的 NextPDF\Enterprise\Signature\Eidas\CertPiiGuard,它会掩盖签名者姓名、根签发者、TSA 名称,以及链问题诊断信息;关于该守卫的 API,参见 eIDAS 保障级别。
DocumentValidationResult 和 DocumentValidationStatus
标题为“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';}抛出或失败情形:无。不可变值对象与带值枚举。
SignatureValidationResult 和 SignatureValidationStatus
标题为“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 符合性判定。
CertChainStatus
标题为“CertChainStatus”的章节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 仅在确认命中信任锚成员时才设置,绝不因锚列表非空而设置。
RevocationStatus 和 RevocationCheckResult
标题为“RevocationStatus 和 RevocationCheckResult”的章节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——已吊销的结果绝不能被铸造为未吊销,反之亦然。仅当状态为未吊销且至少有一项检查为 Good 时,hasConclusiveGood() 才为 true。
TimestampStatus
标题为“TimestampStatus”的章节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,因此输出是确定性的。
<?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: 1Signatures: 0doc-001: no_signaturesAll valid: noUnsigned documents: yes请注意 allValid() 此处报告 no:它要求至少一个签名且没有非有效结果,因此空签名集绝不会静默通过。
代码示例 — 生产环境
标题为“代码示例 — 生产环境”的章节使用吊销客户端、信任锚、批量分块,以及经 PII 守护的 JSON 报告,验证一个目录中的已签名合同。
<?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 客户端的情况下,吊销为
unavailable,因此没有签名能够达到Valid。早期版本在此报告Valid;3.1.0 报告Indeterminate(参见概念总览)。 - 文档级计数器很严格:只有
Valid才使validCount递增。Invalid、Indeterminate和Error都会使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 模式行为”的章节批量验证器不参考任何 FIPS 模式策略,启用 FIPS 模式不会改变批量裁决。其验证侧的算法处理是固定且 fail-closed 的:无论是否启用 FIPS 模式,弱(SHA-1)签名绝不会被报告为 Valid。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()应用隐私默认开启的CertPiiGuard。 - 报告总计是对按文档结果的精确求和;
durationMs是该批次实测的墙上时间。
Core 回退方案
标题为“Core 回退方案”的章节NextPDF Core 的 安全 / 签名 模块是生产者侧:它创建 CMS 签名、应用 RFC 3161 时间戳,并对其在签名时嵌入的材料验证链和吊销。Core 不提供验证侧的批量编排器:没有多文档报告、没有聚合状态分类法、没有针对第三方文档的 OCSP/CRL 吊销裁决,也没有经 PII 守护的报告序列化。仅凭 Core,你需要自行提取并验证每个签名,并构建自己的报告。Enterprise 的单文档验证侧(签名验证)和这个批量编排器提供了该层。
发布边界
标题为“发布边界”的章节本页仅记录外部可观察的行为和受支持的公开 API 接口。内部命名空间路径、辅助类、机制表、运行手册文件名,以及工单前缀均不在范围内。
- 签名验证 — 单文档 AdES/PAdES 密码学验证侧,包括时间戳和归档链验证
- 归档 — 为长期有效性嵌入 DSS/VRI 材料和文档时间戳
- 验证 — 只读的结构性策略检查,无密码学
- 签名 — 深度参考 — 签名模块的深度参考
- eIDAS 保障级别 —
CertPiiGuardAPI 与保障级别映射 - 大规模签名,绝不妥协 — 关于大批量签名与验证设计的 Insider 文章
- 正确地验证签名 — 关于 fail-closed 验证为何重要的 Insider 文章