跳转到内容

以 PAdES B-B 签署 PDF,再扩展为 PAdES B-T

本示例会生成一个 PAdES B-B 签名,也就是带有签署属性(content-type、message-digest、signing-time)的 CMS SignedData。随后,它会加入一个 RFC 3161 signature-time-stamp,将该签名扩展为 PAdES B-T。B-T 是 B-B 再加上单个时间戳;它并非独立的签名类别。本示例还会说明信任边界:生成签名,与由验证端判定该签名有效,并不是同一回事。

U-1 注意事项。 NextPDF 并未主张自身具备任何独立的 ETSI EN 319 142-1 PAdES B-T 认证。EN 319 142-1 不在验证语料库内; B-T 的 signature-time-stamp 要求依据下列规范验证: ETSI EN 319 122-1 §5.3,连同 RFC 3161、RFC 5652、RFC 5816,以及 ISO 32000-2 §12.8。支持 B-T 配置文件并不等同于符合性或法律效力认证;该判定由独立验证器作出。

B-LT 与 B-LTA(DSS 验证数据、归档时间戳循环)不在本示例范围内,也不属于此处涵盖的 Core/Pro 签名接口。

Terminal window
composer require nextpdf/core:^3

ext-openssl 必须启用——CertificateInfo 通过 OpenSSL 解析密钥。B-T 还需要一个可连接的 RFC 3161 TSA 端点,以及一个用于连接该端点的 PSR-18 HTTP 客户端。

PAdES B-B 签名会将一个 DER 编码的 CMS SignedData 存放在签名字典的 Contents 项中;Contents 的值是一个基于字节范围摘要并经过填充的十六进制字符串(ISO 32000-2 §12.8.1)。该摘要涵盖整个文件,但排除签名值本身(ISO 32000-2 §12.8.1)。

PAdES B-T 只加入一个 RFC 3161 signature-time-stamp。该时间戳的消息印记(message imprint)是 SignerInfo 签名值八位字节的哈希——不含任何 ASN.1 标签或长度前缀(ETSI EN 319 122-1 §5.3;RFC 3161 附录 A)。该令牌通过 id-aa-timeStampToken 未签名属性承载,OID 为 1.2.840.113549.1.9.16.2.14(RFC 3161 附录 A),放在 SignerInfo.unsignedAttrs [1] IMPLICIT 内(RFC 5652 §5.3)。由于未签名属性不受签名保护(RFC 5652 §5.4),B-B 的已签名摘要、/ByteRange 以及 B-B 签名字节都保持不变——B-T 只是附加了时间戳。TSA 凭证通过 ESSCertIDv2 识别(RFC 5816 更新了 RFC 3161)。

U-1 注意事项(于 B-T 主张处再次重申)。 NextPDF 并未主张具备任何独立的 ETSI EN 319 142-1 认证以涵盖 PAdES B-T。EN 319 142-1 不在验证语料库内;B-T 的 signature-time-stamp 要求依据 ETSI EN 319 122-1 §5.3,连同 RFC 3161、 RFC 5652、RFC 5816 与 ISO 32000-2 §12.8 验证。支持 B-T 配置文件并不是符合性或法律效力认证;该判定由独立验证器作出。

SignatureLevel::PAdES_B_T 是一项 Core 能力:SignatureLevel::PAdES_B_T->requiresTimestamp()true->isAvailableInEnvironment()true,而 ->requiresDss()false——B-T 不会引入 Document Security Store。B-T ≠ B-LT ≠ B-LTA:签名时间戳不会加入验证数据或归档时间戳;这些属于不同的更高层级,并不在此处生成。

下方图表按照引擎实际采用的顺序,呈现先 B-B 后 B-T 的流程。ByteRange 在整个文件写出后才计算,因此写入实际偏移量不会移动被哈希的字节。B-T 随后以未签名属性的形式附加一个 RFC 3161 令牌,使 B-B 的已签名摘要保持不变。

RFC 3161 TSANextPDF DigitalSignerRFC 3161 TSANextPDF DigitalSignerReserve fixed-width /Contents slotand /ByteRange placeholderByteRange covers the whole fileexcluding the /Contents valuePAdES B-B completeB-T = B-B + 1 timestampB-B signed digest unchangedalt[level == PAdES B-T]Callersign — level B-B or B-T1Write the complete PDF incl. xref + EOF2Compute the two real ByteRange offsets3Hash the two concatenated segments4Build CMS SignedData with signed attrs5Hash the SignerInfo signature value — message imprint6TimeStampReq — message imprint + fresh nonce7TimeStampToken — signed, echoes imprint + nonce8Verify token — status, nonce, imprint, signature, time9Embed token in SignerInfo.unsignedAttrs10Signed PDF — /Contents = DER CMS SignedData11Caller
Diagram

配置入口是 Document::setSignature(CertificateInfo $certInfo, SignatureLevel $level = SignatureLevel::PAdES_B_B, ?TsaClient $tsaClient = null)。它会在文件上记录签署意图。Core PAdES 签名引擎(NextPDF\Security\Signature\DigitalSigner)会生成加密签名。集成测试套件会覆盖这个引擎,可执行示例也会直接驱动它,因此输出是真实且可解析的 CMS 对象。SignatureLevel::PAdES_B_T 需要一个非 null 的 TsaClient;如果构建 B-T 签名器时未提供该客户端,则会抛出 SignatureException

高级 API——一次调用,输出已签署结果

标题为“高级 API——一次调用,输出已签署结果”的章节

最快的方式是使用高级接口:在文件上设置签名,然后序列化。这个接口底层执行的是同一个 Core PAdES 引擎(DigitalSigner)——它只是下方较低级流程的一层轻量便利封装,并不是另一条代码路径。

<?php
declare(strict_types=1);
use NextPDF\Core\Document;
use NextPDF\Security\Signature\CertificateInfo;
use NextPDF\Security\Signature\SignatureLevel;
use NextPDF\Security\Timestamp\TsaClient;
$certInfo = CertificateInfo::fromPkcs12(
p12Path: __DIR__ . '/signer.p12',
password: 'p12-passphrase',
);
// PAdES B-B end to end: configure, then serialise.
$doc = Document::createStandalone();
$doc->addPage();
$doc->setFont('helvetica', '', 12);
$doc->cell(0, 10, 'Signed end to end.', newLine: true);
$doc->setSignature(certInfo: $certInfo, level: SignatureLevel::PAdES_B_B);
$doc->save(__DIR__ . '/signed.pdf'); // or output() to stream, getPdfData() for bytes
// PAdES B-T: pass a TsaClient on the same call — one RFC 3161
// signature-time-stamp is added (see the TsaClient hardening notes below).
$doc->setSignature(
certInfo: $certInfo,
level: SignatureLevel::PAdES_B_T,
tsaClient: $tsa,
);
$doc->save(__DIR__ . '/signed-bt.pdf');

save()(以及同样的 output() / getPdfData())会把 /Contents 项写成 SubFilter ETSI.CAdES.detached 下的 DER 编码 CMS SignedData(ISO 32000-2 §12.8、§12.7.5.5;RFC 5652)。输出可供 CMS 验证——它是一个格式正确的 CMS SignedData 对象,可由 CMS 解析器读取——这并不等同于 ETSI EN 319 142-1 基准配置文件符合性或法律效力;这些判定由独立验证器作出(见上方的 U-1 注意事项)。就 B-T 而言,高级调用只会加入概念总览中所述的那一个 RFC 3161 signature-time-stamp;与 B-B 的唯一差别就是传入 TsaClient

如需明确掌控算法、字节范围数据或 SignatureResult,请使用下方较低级的 DigitalSigner 分步流程。

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Security\Signature\CertificateInfo;
use NextPDF\Security\Signature\DigitalSigner;
use NextPDF\Security\Signature\SignatureAlgorithm;
use NextPDF\Security\Signature\SignatureLevel;
$certInfo = CertificateInfo::fromPkcs12(
p12Path: __DIR__ . '/signer.p12',
password: 'p12-passphrase',
);
// PAdES B-B — a CMS SignedData, no timestamp.
$signer = new DigitalSigner(
certInfo: $certInfo,
level: SignatureLevel::PAdES_B_B,
algorithm: SignatureAlgorithm::Pkcs1v15,
);
$result = $signer->sign($byteRangeData);
echo $result->hasTimestamp() ? "B-T\n" : "B-B (no timestamp)\n";

这是一个自包含、可由测试工具执行的程序。它对应 示例 examples/36-sign-pades-b-b-and-b-t.php。该程序会构建一份文件,将其配置为进行 PAdES 签名,随后用 B-B 签署一次,再通过一个 TSA 客户端用 B-T 签署一次。在生产环境中,TsaClient 会通过一个经过强化的 PSR-18 客户端指向真实的 RFC 3161 端点——这个具备安全意识的 HTTP 客户端会对 TSA SPKI 进行固定(pin),并安全地 resolve(解析)DNS。为保持该程序离线且具有确定性,它会注入仓库测试支持使用的假 TSA 客户端。该假 TSA 客户端会返回一个结构上有效的 RFC 3161 TimeStampResp

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\Document;
use NextPDF\Security\Signature\CertificateInfo;
use NextPDF\Security\Signature\DigitalSigner;
use NextPDF\Security\Signature\SignatureAlgorithm;
use NextPDF\Security\Signature\SignatureLevel;
use NextPDF\Security\Timestamp\TsaClient;
use NextPDF\Tests\Support\FakeTsaHttpClient;
// In your application, build CertificateInfo from your own signing material:
// CertificateInfo::fromPkcs12($p12Path, $passphrase) — a .p12/.pfx bundle
// CertificateInfo::fromFiles($certPem, $keyPem, $pass) — separate PEM files
// This program uses the repository RSA-2048 test fixtures so it is offline.
$certDir = __DIR__ . '/tests/Fixtures/Certificates';
$certPath = $certDir . '/test-rsa-2048-cert.pem';
$keyPath = $certDir . '/test-rsa-2048-key.pem';
if (!is_file($certPath) || !is_file($keyPath)) {
fwrite(STDERR, "Certificate fixtures absent. Run tests/Fixtures/Certificates/generate.sh\n");
exit(1);
}
$certInfo = new CertificateInfo(
certificate: (string) file_get_contents($certPath),
privateKey: (string) file_get_contents($keyPath),
);
// Build the document and record the signing intent on it. The ByteRange
// digest input is the document bytes with the /Contents placeholder
// excluded (ISO 32000-2 §12.8); getPdfData() yields the bytes to hash.
$doc = Document::createStandalone();
$doc->setTitle('Signed Invoice 2026-0042');
$doc->setAuthor('NextPDF Cookbook');
$doc->addPage();
$doc->setFont('helvetica', '', 12);
$doc->cell(0, 10, 'This document is configured for a PAdES signature.', newLine: true);
$doc->setSignature(certInfo: $certInfo, level: SignatureLevel::PAdES_B_B);
$byteRangeData = $doc->getPdfData();
// --- PAdES B-B: a CMS SignedData, no timestamp ---
$bb = (new DigitalSigner(
certInfo: $certInfo,
level: SignatureLevel::PAdES_B_B,
algorithm: SignatureAlgorithm::Pkcs1v15,
))->sign($byteRangeData);
// --- PAdES B-T: B-B + one RFC 3161 signature-time-stamp ---
// In production, build the TsaClient with your TSA endpoint and a hardened
// PSR-18 client (use the security-aware HTTP client for SSRF/DNS pinning):
// $tsa = new TsaClient(
// tsaUrl: 'https://tsa.example.com/timestamp',
// httpClient: $hardenedPsr18Client,
// );
// Here the offline fake TSA client keeps the program network-free.
$tsa = new TsaClient(
tsaUrl: 'https://tsa.example.com/timestamp',
httpClient: new FakeTsaHttpClient(),
);
$bt = (new DigitalSigner(
certInfo: $certInfo,
tsaClient: $tsa,
level: SignatureLevel::PAdES_B_T,
algorithm: SignatureAlgorithm::Pkcs1v15,
))->sign($byteRangeData);
// B-T = B-B + a single timestamp token. The B-B signed digest is unchanged;
// $bt->timestampToken holds the DER-encoded RFC 3161 token.
printf("PAdES B-B CMS: %d bytes, timestamp=%s\n", $bb->getSize(), $bb->hasTimestamp() ? 'yes' : 'no');
printf(
"PAdES B-T CMS: %d bytes, timestamp=%s (%d-byte RFC 3161 token)\n",
$bt->getSize(),
$bt->hasTimestamp() ? 'yes' : 'no',
strlen($bt->timestampToken),
);
echo "B-T = B-B + one RFC 3161 signature-time-stamp (unsigned attribute).\n";
// The harness sets NEXTPDF_COOKBOOK_OUTPUT and runs this script under the
// semantic profile (the signed CMS/timestamp bytes are inherently
// non-reproducible and are asserted by the PHPUnit harness, not a byte hash).
$out = getenv('NEXTPDF_COOKBOOK_OUTPUT');
file_put_contents($out !== false && $out !== '' ? $out : __DIR__ . '/signed-invoice.pdf', $byteRangeData);

预期 STDOUT(大小会因凭证与 TSA 令牌而异):

PAdES B-B CMS: <n> bytes, timestamp=no
PAdES B-T CMS: <n> bytes, timestamp=yes (<m>-byte RFC 3161 token)
B-T = B-B + one RFC 3161 signature-time-stamp (unsigned attribute).

U-1 注意事项(与 B-T 正式环境主张并列)。 NextPDF 并未主张具备任何独立的 ETSI EN 319 142-1 认证以涵盖 PAdES B-T。 EN 319 142-1 不在验证语料库内;B-T 的 signature-time-stamp 要求依据 ETSI EN 319 122-1 §5.3,连同 RFC 3161、RFC 5652、RFC 5816 与 ISO 32000-2 §12.8 验证的。 支持 B-T 配置文件并不是符合性或法律效力认证;该判定由独立验证器作出。

  • 没有 TSA 客户端的 B-T。 构建 B-T DigitalSigner 但未提供 TsaClient 时,会抛出 SignatureException(B-T 必须有 TSA)。请在签署前检查好 TSA 配置。
  • TSA 可连接性。 B-T 每次签署都会执行一次即时 RFC 3161 往返。TSA 中断就意味着无法生成 B-T 签名。请使用断路器(circuit breaker),并采用符合你吞吐量的 TSA SLA;TsaClient 可接受断路器。
  • 强化 TSA HTTP 客户端。TsaClient 指向一个会对 TSA 的 SPKI 做固定(RFC 7469 格式)并安全解析 DNS 的 PSR-18 客户端;TsaClient::extractPublicKeyPin() 会从 TSA 凭证推导出该固定值。
  • B-T 不是 B-LT/B-LTA。 签名时间戳不会嵌入验证数据(凭证、OCSP、CRL)或归档时间戳。这些属于 B-LT/B-LTA 层级,本示例不会生成。
  • 线性化冲突。 enableLinearization() 与已配置的签名互斥——当一方已配置时,调用另一方会抛出 InvalidConfigException
  • HSM 密钥。 对于保存在硬件中的密钥,请将 CertificateInfoCertificateInfo::fromHsm() 搭配使用来构建;私钥永远不会进入进程内存。PKCS#11 签名器合约属于 Core;可运行的提供者属于 Premium。

B-B 签名是一项本地 CMS 运算。B-T 每次签署都会额外执行一次同步 RFC 3161 HTTP 往返。在批量工作负载中,请将 TSA 延迟与速率限制纳入预算。建议采用受断路器保护的 TsaClient

已生成的签名并不等于可信签名。一个签名能否通过验证,取决于凭证、其信任锚以及验证端政策——这些都在本库之外。加密提供的是机密性,而非完整性;签名提供的是 integrity/authenticity,而非机密性。请把密钥保管视为首要风险:进程内存中的软件密钥,其安全程度最多等同于主机本身。

签名运算在进程内进行;除 B-T 的 TSA 往返外,文件字节与私钥都不会离开主机,而该往返只会发送消息印记(签名值的哈希),绝不会发送文件内容(RFC 3161 §2.4.1 MessageImprint)。不会有任何文件文本或 PII 传输到 TSA。请选择其管辖区符合你数据驻留政策的 TSA。

DigitalSigner 接受一个可选的 PSR-3 记录器;它只记录算法与层级,不记录密钥材料或签名字节。password 参数——位于 CertificateInfoTsaClient 上——标注了 #[SensitiveParameter],因此密码短语会在堆栈跟踪中被遮蔽。请勿记录 SignatureResult::$cmsSignedData$timestampToken

已纳入考虑:签署后遭篡改的输入(由字节范围摘要检测)、密钥泄露(不在库范围内——密钥保管是集成者的责任)、TSA 冒充(通过 TSA HTTP 客户端上的 SPKI 固定来缓解),以及层级间降级(层级枚举是明确的;引擎不会悄悄把 B-T 降级为 B-B)。未作声明:不存在漏洞,或任何生成的签名具有法律效力。

签名的基础组件由 OpenSSL 提供。在经过 FIPS 验证的 OpenSSL 构建上,RSA/ECDSA 与 SHA-256 运算会通过 FIPS 提供者执行;NextPDF 本身并未主张具备 FIPS 验证。CryptoCapabilities 会报告主机可用的基础组件;请在你的部署环境中验证 OpenSSL 提供者链。

陈述规范条款参考 ID
字节范围摘要涵盖整个文件,并排除签章值。ISO 32000-2§12.8.1
Contents 存放 DER CMS SignedData;文件时间戳的 Contents 则存放一个 TimeStampToken。ISO 32000-2§12.8.1
Contents 是一个基于字节范围摘要并经过填充的十六进制字符串。ISO 32000-2§12.8.1
signature-time-stamp 印记是 SignerInfo 签名值八位字节的哈希(不含 ASN.1 tag/length)。ETSI EN 319 122-1§5.3
signature-time-stamp 的值是一个 SignatureTimeStampToken。ETSI EN 319 122-1§6
MessageImprint ::= SEQUENCE { hashAlgorithm, hashedMessage }RFC 3161§2.4.1
签名时间戳印记是 SignerInfo 签名字段的哈希;SignatureTimeStampToken ::= TimeStampTokenRFC 3161附录 A
id-aa-timeStampToken OID 是 1.2.840.113549.1.9.16.2.14RFC 3161附录 A
SignerInfo 承载 unsignedAttrs [1] IMPLICIT UnsignedAttributes OPTIONALRFC 5652§5.3
未签名属性不受签名保护;B-B 的已签名摘要保持不变。RFC 5652§5.4
RFC 5816 更新了 RFC 3161;ESSCertIDv2 在不使用 SHA-1 的情况下识别 TSA 凭证。RFC 5816§1

本示例描述 NextPDF 如何生成一个 B-B 与一个 B-T 签名。它并未主张任何生成的签名具有法律效力,或已符合 PAdES 符合性;这些判定由独立验证器作出。

PAdES B-LT 与 B-LTA(DSS 验证数据与归档时间戳循环)以及 PKCS#11 HSM 密钥保管,均随 Pro 与 Enterprise 版本交付。本示例刻意只涵盖 B-B 与 B-T;更高层级属于各自独立、单独验证的能力,不在此处范围内。