简短回答:PDF 签名是存放在签名字典 /Contents 条目中的 CMS(PKCS #7)签名。它覆盖 /ByteRange 列出的字节,也就是签名时的整个文件,只除去 /Contents 的值本身。验证器要回答三个彼此独立的问题。完整性:已签名的字节是否仍然匹配?信任:签名者的证书能否通过证书链追溯到验证器信任的根证书?修改:签名之后追加了什么,这些追加是否被允许?“有效但不受信任”,即 Acrobat 中的黄色问号,几乎总是表示字节完好,但签名者的 CA 不在阅读器的信任列表中。PAdES(ETSI EN 319 142-1)定义了从 B-B 到 B-LTA 的各个级别。RFC 3161 时间戳(B-T)证明签名在什么时间已经存在,而内嵌的验证数据加上文档时间戳(B-LT、B-LTA)能让签名在证书过期之后仍可验证。
已签名的 PDF 里有什么
下面的测试用 pyHanko 0.38(CLI 0.6.0)对一张单页发票签名,使用来自一个小型测试 CA 的 P-256 密钥,以及 DigiCert 公共 TSA 提供的时间戳:
$ pyhanko sign addsig --field Sig1 --use-pades --timestamp-url http://timestamp.digicert.com --reason Approved \
pemder --key signer.key --cert signer.pem --chain root.pem --no-pass invoice.pdf invoice-signed.pdf
签名工具不会重写文件,而是追加一个增量更新:在原有的 %%EOF 之后写入新的对象、新的交叉引用段和新的文件尾(trailer)。原来那个 627 字节的文件仍然逐字节原样保留在已签名文件的开头。这次更新添加了一个签名字段和一个签名字典。下面这个字典来自同一条命令的另一次运行,所以其中的数字与下文检查的文件不同(/Contents 的十六进制值已缩短):
9 0 obj
<<
/Contents <30821CFC06092A864886F70D010702A0821CED3...(zeros)...0000000>
/ByteRange [0 1331 23601 1118]
/Type /Sig
/Filter /Adobe.PPKLite
/SubFilter /ETSI.CAdES.detached
/M (D\07220261020140517Z)
/Reason (Approved)
/Prop_Build <<
/App <<
/Name /pyHanko#20CLI
/REx (0\05638\0560)
>>
>>
>>
endobj
/M 的值是 PDF 日期 D:20261020140517Z。\072 是转义后的冒号。用一个小脚本读取 /ByteRange 和 /Contents 的大小,就能得到本页其余部分所用文件的各项数字:
file size: 24869 bytes, revisions (%%EOF markers): 2
/ByteRange [0 1331 23751 1118]
signed: bytes 0-1330 and 23751-24868; the 22420-byte gap is the /Contents value
/Type /Sig /SubFilter /ETSI.CAdES.detached
/Contents: 11209 bytes reserved, 7472 bytes used by the CMS object
range ends at end of file: True
这些部分是这样组合在一起的:
/ByteRange [a b c d]列出两个字节范围:从a开始的b个字节,以及从c开始的d个字节。两者之间的间隙恰好就是/Contents的十六进制字符串,包括定界符在内。签名时文件中的其他所有内容都会参与哈希计算。由于签名放入之前无法知道文件大小,签名工具会先写入占位符,填好/ByteRange,计算哈希并签名,然后把签名写入预留的空间。/Contents存放 DER 编码的 CMSSignedData,以十六进制编码并用零填充。pyHanko 预留了 11,209 字节,实际用了 7,472 字节。其余部分都是填充,之所以存在,是因为必须在知道签名大小之前就预留好空间。/SubFilter指明格式。ETSI.CAdES.detached是 PAdES 格式,adbe.pkcs7.detached是较早的 ISO 32000-1 格式。两者都是针对 ByteRange 字节的分离式 CMS 签名。/M是签名者声称的签名时间。PAdES 把签名时间放在这里:基线规范规定,在任何级别下 CMS 的signing-time属性都“不得出现”(shall not be present)。这只是签名者的声明,因为任何持有密钥的人都可以写入任意时间。
CMS 对象本身没有什么特别之处,OpenSSL 可以针对提取出的字节范围验证它:
CMS Verification successful
1 object: contentType (1.2.840.113549.1.9.3)
1 object: id-smime-aa-signingCertificateV2 (1.2.840.113549.1.9.16.2.47)
1 object: id-smime-aa-timeStampToken (1.2.840.113549.1.9.16.2.14)
1 object: messageDigest (1.2.840.113549.1.9.4)
已签名属性包括内容类型、ByteRange 字节的 messageDigest,以及 signingCertificateV2。后者是签名者证书的哈希,可以防止有人换上另一张使用相同密钥的证书。未签名属性 timeStampToken 是针对签名值的 RFC 3161 时间戳。PDF 签名验证工具可以为任意 PDF 显示这样的分解结果,包括每个签名覆盖了哪些字节。时间戳查看器可以解码内嵌的令牌。
验证器要回答的三个问题
pyHanko 的报告把这三个问题分开列出,因此很适合用来学习。将测试 CA 的根证书设为受信任时:
Trust anchor: "Organization: Example GmbH, Common Name: Example Signing Root CA"
The signer's certificate is trusted.
Integrity
---------
The signature is cryptographically sound.
Signing time
------------
Signing time as reported by signer: 2026-10-20T14:04:36+00:00
Signature timestamp token: 2026-10-20T14:04:37+00:00
The token is guaranteed to be newer than the signature.
This timestamp is backed by a time stamping authority.
The timestamp token is cryptographically sound.
TSA certificate subject: "Common Name: DigiCert SHA256 RSA4096 Timestamp Responder 2026 1; Organization: DigiCert, Inc.; Country: US"
The TSA certificate is trusted.
Modifications
-------------
The signature covers the entire file.
Bottom line
-----------
The signature is judged VALID.
有效但不受信任
同一个文件,在不提供测试根证书的情况下验证:
Trust anchor: "No path to trust anchor found."
The signer's certificate is untrusted.
Integrity
---------
The signature is cryptographically sound.
...
Bottom line
-----------
The signature is judged INVALID.
完整性没有问题,缺的是信任。Acrobat 会把这种情况报告为“Signature validity is UNKNOWN”(签名有效性未知),并注明签名者的身份未知。这是关于已签名 PDF 最常见的抱怨,而文档本身通常没有问题。阅读器通过各自的列表来信任签名证书。Acrobat 使用 Adobe Approved Trust List(AATL),还可以选择启用欧盟可信列表。来自公司 CA 的证书、来自阅读器不认识的国家身份证的证书,以及自签名证书,都会落到这种情况。解决办法要么在阅读器一侧(信任颁发证书的 CA,或启用欧盟可信列表),要么在签名者一侧(使用收件人信任的 CA 签发的证书签名)。注意 TSA 证书有单独的验证结论:DigiCert 的 TSA 证书能通过证书链追溯到系统信任库中的根证书,所以即使签名者不受信任,时间戳仍然受信任。
已签名范围内的字节被改动
直接在文件中把 EUR 1,250.00 替换为 EUR 9,250.00:
Integrity
---------
The signature is cryptographically unsound.
...
Bottom line
-----------
The signature is judged INVALID.
这种情况没有任何办法绕过。范围内的每个字节都参与了哈希计算,所以任何原地修改都会破坏签名。
签名后追加的改动
真正值得关注的攻击并不触碰已签名的字节,而是添加一个新的修订版本。PDF 允许增量更新,用增量更新来填写表单、添加注释或追加更多签名都是正当的。测试追加了一个修订版本,用新金额替换了页面内容:
file size: 26337 bytes, revisions (%%EOF markers): 3
/ByteRange [0 1331 23751 1118]
range ends at end of file: False
...
The signature is cryptographically sound.
...
Modifications
-------------
The signature does not cover the entire file.
Some modifications may be illegitimate, and they appear to be incompatible with the current document modification policy.
Bottom line
-----------
The signature is judged INVALID.
签名在密码学上仍然成立,因为已签名的修订版本没有被改动。但读者看到的是新的修订版本。只有差异分析能发现这一点:把签名后的改动与允许的改动进行比较。只检查“签名成立、签名者受信任”的查看器,会在有效签名之下显示 EUR 9,250。2019年及之后针对 PDF 查看器的研究,在许多应用中恰恰发现了这些漏洞(增量保存攻击和影子攻击)。相比之下,添加第二个签名是允许的改动:
Modifications
-------------
The signature does not cover the entire file.
All modifications relate to signing and form filling operations, and they appear to be compatible with the current document modification policy.
Bottom line
-----------
The signature is judged VALID.
认证签名(pyHanko 中的 --certify,Acrobat 中的“认证”)在其 DocMDP 权限中记录作者对后续改动的策略:不允许任何改动、只允许填写表单,或允许填写表单和添加注释。验证器在差异分析中执行这一策略。
时间:RFC 3161 时间戳
签名所用的证书会过期,也可能被吊销。如果没有独立的证据证明签名是何时做出的,验证器只能按当前时间检查证书。一旦证书过期,或因任何原因被吊销,这个签名看起来就和昨天用被盗密钥做出的签名一样不可信。/M 条目帮不上忙,因为它由签名者自己选择。
来自受信任时间戳机构(TSA)的 RFC 3161 时间戳可以解决这个问题。TSA 对一个令牌签名,令牌中包含签名值的哈希和 TSA 自己的时间。在测试中,DigiCert 的 TSA 返回的令牌时间为14时04分37秒(UTC),比声称的签名时间晚一秒。这证明签名在那时已经存在,而当时证书仍然有效。这就是 PAdES 的 B-T 级别。时间戳也可以作为单独的文档时间戳添加:它是一个带有 /Type /DocTimeStamp 和 /SubFilter /ETSI.RFC3161 的签名字典,为整个文档的当前状态加上时间戳。
长期验证:B-LT 与 B-LTA
十年后的验证需要的不仅仅是时间戳。CA 的 CRL 或 OCSP 响应器届时可能已经不存在,算法也可能已被削弱。ETSI EN 319 142-1(V1.2.1,2024年1月)定义了以下级别:
| 级别 | 增加的内容 | 用途 |
|---|---|---|
| B-B | 带有必需已签名属性的 CMS 签名 | 基本签名 |
| B-T | 受信任的时间戳 | 证明签名在给定时间已经存在 |
| B-LT | 全部验证材料:文档 DSS 中的证书、CRL 和 OCSP 响应 | 无需获取任何外部数据即可验证 |
| B-LTA | 覆盖文档(包括上述材料)的文档时间戳,并随时间推移不断更新 | 在证书陆续过期、算法逐渐老化的过程中,保持验证材料和时间戳的可信性 |
验证材料存放在文档安全存储(文档目录中的 /DSS)里,在签名之后通过增量更新添加。下面以 B-LTA 级别对同一张发票签名,CRL 由测试 CA 提供:
file size: 54674 bytes, revisions (%%EOF markers): 4
/ByteRange [0 1331 23745 1099]
/Type /Sig /SubFilter /ETSI.CAdES.detached
range ends at end of file: False
/ByteRange [0 34471 52463 668]
/Type /DocTimeStamp /SubFilter /ETSI.RFC3161
range ends at end of file: False
catalog /DSS: True
/Certs present: True
/CRLs present: True
/OCSPs present: True
/VRI present: True
...
Modifications
-------------
The signature does not cover the entire file.
All modifications relate to signature maintenance, and they appear to be compatible with the current document modification policy.
Bottom line
-----------
The signature is judged VALID.
共四个修订版本:原始文件;签名;一次更新,添加包含验证数据的 DSS,并以文档时间戳结尾,该时间戳的字节范围止于第 53,131 字节;以及在时间戳之后追加的最后一次更新,大小为 1,543 字节,验证器把它算作签名维护。列出以同样方式签名的文件的 DSS,就能看到“全部验证材料”在实践中意味着什么。其中有签名者的证书链和测试 CA 的 CRL,还有 DigiCert 时间戳响应器的证书链和两个 OCSP 响应,因为时间戳本身也必须保持可验证:
certs:
Organization: Example GmbH, Common Name: Example Signing Root CA
Organization: Example GmbH, Common Name: Alice Example
Common Name: DigiCert Trusted Root G4, Organizational Unit: www.digicert.com, Organization: DigiCert Inc, Country: US
Common Name: DigiCert Trusted G4 TimeStamping RSA4096 SHA256 2025 CA1; Organization: DigiCert, Inc.; Country: US
Common Name: DigiCert SHA256 RSA4096 Timestamp Responder 2026 1; Organization: DigiCert, Inc.; Country: US
CRLs:
Organization: Example GmbH, Common Name: Example Signing Root CA
OCSP responses:
ef6f534ae9e4067c7acae29056f62fd449eccb4e
ecd7e382d2715d644cdf2e673fe7ba98ae1c0f4f
OCSP 响应器以密钥哈希标识。文档时间戳本身也需要在它自己的 TSA 证书过期之前续期,pyhanko sign ltaupdate 会添加一个新的文档时间戳。Acrobat 会把内嵌了验证材料的签名标记为“LTV enabled”(已启用 LTV)。
应该生成哪个级别
- 始终加时间戳。B-B 只适用于在证书有效期内阅读并归档的文档,而且即便在这种情况下,加时间戳也只需向 TSA 发送一次 HTTP 请求。至少使用 B-T。
- 凡是要归档的,都用 B-LTA。多年后仍需能够查验的合同、受监管的记录和发票,都需要内嵌验证数据和文档时间戳。要在最新文档时间戳的 TSA 证书过期之前、在其算法被视为强度不足之前,留足余量安排
ltaupdate运行。 - 法律效力是另一回事。根据欧盟 eIDAS 条例(第 910/2014 号),合格电子签名需要欧盟可信列表上的信任服务提供商签发的合格证书,以及合格签名创建设备。PAdES 描述的是文件格式,而不是这一法律层级。pyHanko 的
--eutl-territories和--require-qualified选项用于检查可信列表这一侧。 - 使用读者信任的 CA 签名。对外部收件人而言,这意味着使用 AATL 或欧盟可信列表上的 CA。只要每个读者的阅读器都能配置为信任内部 CA,使用内部 CA 也没有问题。
如何检查已签名的 PDF
- 查看所有签名和修订版本,而不只是绿色横幅。最后一个签名之后还有多少个修订版本,它们改动了什么?
- 把各项验证结论分开看。完整性未通过 → 已签名的字节被改动了。信任未通过 → 这是阅读器一侧的信任问题。修改被标记 → 签名后添加了内容,你需要查看添加了什么。
- 检查时间证据。是否有来自你信任的 TSA 的时间戳,还是只有签名者写入的
/M? - 对于归档文件,检查 LTV。验证材料是否已经内嵌,最新的文档时间戳是否仍在其 TSA 证书的有效期内?
- 使用能报告上述全部内容的验证器。在脚本中,可以使用带显式
--trust的pyhanko sign validate --pretty-print,或 AdES 风格的pyhanko sign adesverify。pyHanko 0.38 会警告,回退到操作系统信任库的做法“is deprecated and will be removed in a future release”(已弃用,并将在未来的版本中移除),所以要显式传入你的信任根。在浏览器中,PDF 签名验证工具无需上传文档即可完成同样的检查。
常见错误
- 信任页面上可见的签名图像。页面上的印章只是装饰。签名是 CMS 对象,图像什么也证明不了。
- 把“签名有效”当作“文档未改动”。只有“covers the entire file”(覆盖整个文件)或没有问题的差异分析结果才能说明这一点。
- 把签名者自己的证书添加为受信任证书,只为让警告消失。要么在核实之后信任颁发它的 CA,要么完全不信任。
- 不加时间戳。没有 TSA 时间戳,签名的证据效力在证书过期时就终止了。
- 用会重写文件的工具重新保存已签名的 PDF。许多编辑器执行的是完整保存,而不是增量保存,这会破坏所有现有签名。
来源
- ISO 32000-2(PDF 2.0),§12.8 数字签名。ISO 32000-1 定义了
adbe.pkcs7.detached和签名字典。 - ETSI EN 319 142-1 V1.2.1(2024年1月):PAdES 构建模块与基线级别、文档时间戳
- RFC 5652(CMS)、RFC 3161(时间戳)、RFC 5035(signingCertificateV2)
- pyHanko 0.38.0 文档与 CLI
- PDF Insecurity:关于增量保存攻击、签名包装攻击和影子攻击的研究
本页所有文件均于2026年10月20日在 node:24.22.0-trixie 中创建,使用 virtualenv 中的 pyHanko 0.38.0(pyhanko-cli 0.6.0、pyhanko-certvalidator 0.33.0)和 OpenSSL 3.5.7,时间戳来自 http://timestamp.digicert.com。