跳转到内容
getnextpdf.com

Enterprise 版本

批量签名验证

NextPDF Enterprise 只需一次调用即可验证多个 PDF 文档中的数字签名。NextPDF\Enterprise\Signature\BatchSignatureValidator::validate() 接收一个文档列表,并返回一个 BatchValidationReport。每个签名都会经过相同的 fail-closed 流水线:对已签名字节范围进行 CMS 密码学身份认证、基于信任锚的证书链验证,以及 OCSP/CRL 吊销检查。报告携带按文档和按签名的细节——CertChainStatusRevocationStatusTimestampStatus——以便合规工具能够从其记录的证据重新推导出每一条裁决。

裁决模型刻意设计得很严格。只有当所有证据都得到肯定性确立时,签名才为 Valid。缺失的吊销证据会产生 Indeterminate,绝不会是 Valid。本页介绍批量编排器及其结果类型。单文档 AdES 验证侧记录于签名验证。嵌入长期验证材料记录于归档

此能力随 NextPDF Enterprisenextpdf/enterprise)一起提供,并通过 Enterprise 层级的许可信封激活。缺少该授权的部署不会加载此能力的类。比较各版本并获取许可

Terminal window
composer require nextpdf/enterprise

nextpdf/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 为主:只有经过密码学验证的响应才算数,为 GoodRevoked。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 结果都为 UnknownUnavailable 时,吊销状态未定,裁决为 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 个文档的运行应用完全相同的密码学。报告还将证据与裁决分离——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() 才为 true。默认情况下,toJson() 应用一个隐私默认开启的 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——已吊销的结果绝不能被铸造为未吊销,反之亦然。仅当状态为未吊销且至少有一项检查为 Good 时,hasConclusiveGood() 才为 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:它要求至少一个签名且没有非有效结果,因此空签名集绝不会静默通过。

使用吊销客户端、信任锚、批量分块,以及经 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 客户端的情况下,吊销为 unavailable,因此没有签名能够达到 Valid。早期版本在此报告 Valid;3.1.0 报告 Indeterminate(参见概念总览)。
  • 文档级计数器很严格:只有 Valid 才使 validCount 递增。InvalidIndeterminateError 都会使 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 的:无论是否启用 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 是该批次实测的墙上时间。

NextPDF Core 的 安全 / 签名 模块是生产者侧:它创建 CMS 签名、应用 RFC 3161 时间戳,并对其在签名时嵌入的材料验证链和吊销。Core 不提供验证侧的批量编排器:没有多文档报告、没有聚合状态分类法、没有针对第三方文档的 OCSP/CRL 吊销裁决,也没有经 PII 守护的报告序列化。仅凭 Core,你需要自行提取并验证每个签名,并构建自己的报告。Enterprise 的单文档验证侧(签名验证)和这个批量编排器提供了该层。

本页仅记录外部可观察的行为和受支持的公开 API 接口。内部命名空间路径、辅助类、机制表、运行手册文件名,以及工单前缀均不在范围内。