PDF签完名还能改什么?会签能过,压缩不行

摘要

电子合同和电子发票的归档链路里,常见一种排法:先签名、再压缩、最后归档。签完的文件里常带着扫描页和照片,归档前压一下看着很顺手。我拿一份自造的签名件把这条路走了一遍。五个压缩或重写工具处理完,没有一个签名还能验过。三个留着签名字典但验签失败。另外两个把签名整个删了。反过来往签好的文件尾部追加一行字,pdfsig 照样报 Signature is Valid。

两个现象出自同一套机制。签名校验的是 ByteRange 圈出来的两段字节。整份重写会让这两段字节全部变样。压缩又一定是整份重写。增量更新只往文件尾部接新内容,原来的字节一个不动,哈希也就仍然成立。可哈希成立只说明被签的那段字节没动。签名之后追加了什么要靠 DocMDP、FieldMDP 这类修改许可来管。判的时候还得用会比对修订的验签工具。

这篇顺着这条线往下拆:先拆 ByteRange 和签名里存的摘要,再看压缩为什么只能整份重写,然后看增量更新怎么保住签名,最后用一组 DocMDP 和 FieldMDP 的补测,看「签名有效」到底能说明多少。每一步都配了能在本机复现的代码和输出。

版本声明

测试日期是 2026-10-16。机器是一台 Apple M4 的 Mac,16 GB 内存。签名件是我造的一页虚构英文协议,页面上写明了 fictional, for testing only。里面放了一张 1800×1200 的合成照片。根证书和签名证书都是我用 openssl 现造的测试证书,签名交给 pyHanko 做,子过滤器选的 adbe.pkcs7.detached。验签工具有两个:poppler 的 pdfsig 26.08.0 和 pyHanko 0.32.0 的验签接口,两边都只信任这张自建根证书。重写和压缩工具有 qpdf 12.4.0、Ghostscript 10.07.1、图映 ImgIng 的 PDF 压缩(页脚构建号 10.10)、iLovePDF 和 PDF24 的在线压缩。文中代码在 Python 3.9.6 里跑,用到 pypdf 6.20.1 和 asn1crypto 1.5.1。

适用边界

所有结论都出自这一份签名件和它派生出来的文件。没用真实 CA 签发的证书,没带时间戳和长期验证信息。Acrobat 和福昕里的签名面板怎么显示,我没看。文中的「判非法」「判有效」都只是 pdfsig 和 pyHanko 两个工具的结论。每个工具只跑了一次,更细的边界放在第九章。

文章目录

一、PDF数字签名到底校验了哪些字节

  • ByteRange把文件切成两段
  • 中间挖掉的正是签名值
  • 签名里存着两段字节的摘要

二、为什么PDF压缩后签名就失效

  • 压缩只能把文件整份重写
  • 五个工具的产物只有文件头没变
  • 签名值被原样搬进了新文件
  • 有两个工具直接删掉了签名

三、增量更新为什么能保住签名

  • 新修订只接在文件末尾
  • 追加三次后原摘要都没变
  • 压缩没法走增量更新

四、签名有效能说明文件没被改吗

  • pdfsig对改过内容的文件也报有效
  • pyHanko逐个比对签名后的修订

五、DocMDP和FieldMDP能拦住哪些修改

  • 认证签名P1下连会签都签不上
  • 会签要在认证前预留签名域
  • 锁住的表单域一填就判非法
  • 加批注在默认策略下总被判非法

六、签名文件的压缩该放在哪一步

七、测签名有效性有哪些坑

  • valid为真不代表字节没变
  • pdfsig要指定信任哪张根证书
  • 两个pyHanko版本号对不上
  • 搜字节会漏掉对象流里的标记

八、还有哪些没弄清

九、适用边界与风险提示

参考资料

一、PDF数字签名到底校验了哪些字节

ByteRange把文件切成两段

我以前做过三年音视频编解码,碰到一个陌生格式的校验机制时第一反应是去找它到底覆盖了哪些字节。PDF 签名在这件事上写得很直白。签名字典里有一项叫 ByteRange,是四个整数。两两一组。每组是一个起点加一个长度。验签的人按这两组数把文件切成两段拼起来算摘要再去和签名里存的值比对。页面长什么样它不管。它只认这两段字节。

ByteRange 存在签名字典里。签名字典挂在 AcroForm 的签名域下面。最稳的取法是按结构读:从文档根找到 AcroForm、遍历字段、挑出类型是 /Sig 且已经有值的那些。直接在文件里搜 /ByteRange 也能找到。问题是文件里有多个签名时分不清谁是谁。第七章还会讲到。对象被打包进对象流以后,有些标记根本搜不到。下面这段按结构读,顺手把两段之间夹着的东西拿出来看一眼:

python 复制代码
import sys
from hashlib import sha256
import pypdf

def signed_fields(fname):
    acro = pypdf.PdfReader(fname).trailer['/Root'].get('/AcroForm')
    for node in (acro.get_object().get('/Fields', []) if acro else []):
        node = node.get_object()
        if node.get('/FT') == '/Sig' and node.get('/V') is not None:
            yield str(node['/T']), node['/V'].get_object()

whole = open(sys.argv[1], 'rb').read()
for label, sigdict in signed_fields(sys.argv[1]):
    off1, n1, off2, n2 = (int(v) for v in sigdict['/ByteRange'])
    gap = whole[off1 + n1:off2]
    print(f'{label}: [{off1}, {off1 + n1}) + [{off2}, {off2 + n2})  file={len(whole)}')
    print(f'  gap={len(gap)} bytes, starts {gap[:1]!r}, ends {gap[-1:]!r}, hex-only={gap[1:-1].strip(b"0123456789abcdefABCDEF") == b""}')
    print(f'  sha256(seg1+seg2)={sha256(whole[off1:off1 + n1] + whole[off2:off2 + n2]).hexdigest()[:16]}')
text 复制代码
$ python c1_segments.py signed.pdf
Sig1: [0, 923749) + [931275, 931980)  file=931980
  gap=7526 bytes, starts b'<', ends b'>', hex-only=True
  sha256(seg1+seg2)=040cd959d9698502

代码说明:signed_fields 只挑有 /V 的签名域。空着等人签的域没有 /V,会被跳过。区间按左闭右开打印方便直接对照文件长度。签好的文件有 931,980 字节。第一段从 0 到 923,749。第二段从 931,275 到 931,980。第二段的终点正好是文件末尾。pdfsig 管这种情况叫 Total document signed。还有一处容易忽略:ByteRange 这四个数本身也写在第一段里,也被签了进去。谁事后改了这四个数,摘要就对不上。

中间挖掉的正是签名值

两段之间空出来 7,526 字节。输出里能看到它以 < 开头、以 > 结尾,中间全是十六进制字符。这就是签名字典的 /Contents。签名值以十六进制字符串写在这里。

为什么非要挖掉一段?因为签名值是对文件算出来的,它没法把自己也算进去。签名工具的做法是先在文件里预留一块固定大小的空位。空位里先填一串 0。除空位以外的字节算完摘要、做完签名后再把结果写回空位。空位要一次留够。写回时文件长度不能变,否则 ByteRange 里的偏移就全错了。这块占位一共 7,524 个十六进制字符,折合 3,762 字节。我解码出来数了一下。末尾有 1,254 个补零字节,正好占三分之一。真正的签名数据用不满预留的空间,剩下的就一直是 0。

这让我想起以前做 MP4 封装。想把 moov 挪到文件头又不想事后重写整个文件,常见做法是先留一块 free 空间,最后把真实的 moov 填进去。大小不能超。两边是同一个思路:位置先占住,内容后填。

签名里存着两段字节的摘要

光拼出两段字节算个哈希,还说明不了问题。签名值是一个 CMS 结构,它的签名属性里有一项叫 messageDigest,存的就是签名那一刻两段字节的摘要。验签分两步:先用签名人的公钥验 CMS 本身,再把 messageDigest 和现场算出来的摘要比一下。我把第二步单独拎出来做了一遍:

python 复制代码
import sys, hashlib
from asn1crypto import cms
import pypdf

path = sys.argv[1]
raw = open(path, 'rb').read()
for fld in pypdf.PdfReader(path).trailer['/Root']['/AcroForm']['/Fields']:
    v = fld.get_object().get('/V')
    if v is None:
        continue
    v = v.get_object()
    p, q, r, s = (int(x) for x in v['/ByteRange'])
    der = bytes(v['/Contents'])                   # 签名值,末尾补了一串 0x00
    signer = cms.ContentInfo.load(der)['content']['signer_infos'][0]
    stored = [a['values'][0].native for a in signer['signed_attrs']
              if a['type'].native == 'message_digest'][0]
    fresh = hashlib.sha256(raw[p:p + q] + raw[r:r + s]).digest()
    print(fld.get_object()['/T'], 'CMS 记录', stored.hex()[:16], '| 现算', fresh.hex()[:16],
          '| 一致' if stored == fresh else '| 不一致', '| 补零', len(der) - len(der.rstrip(b'\0')))
text 复制代码
$ python c2_digest.py signed.pdf
Sig1 CMS 记录 040cd959d9698502 | 现算 040cd959d9698502 | 一致 | 补零 1254

代码说明:/Contents 交给 pypdf 去读,拿回来的已经是解码后的字节,不用自己处理尖括号和十六进制。cms.ContentInfo.load 能容忍末尾那串补零,直接解析就行。签名属性里按类型名找 message_digest,取出来和现场算的 SHA-256 比较。这段只比摘要。证书链和 CMS 签名本身它都没验,它只回答一个问题:签名记下的字节和现在文件里那两段字节是不是同一批。

两个值完全一致。前 16 位都是 040cd959d9698502。到这里证据链就闭合了。签名人当时对这两段字节算了摘要并放进 CMS,再用私钥签了 CMS。之后谁让这两段字节变了一个比特,现场算出来的摘要就和 CMS 里存的对不上。CMS 里那个值又改不了,一改公钥那一步就验不过。

二、为什么PDF压缩后签名就失效

压缩只能把文件整份重写

PDF 文件末尾有一张交叉引用表,记着每个对象从第几个字节开始。阅读器打开文件时先从尾部找到这张表再按表里的偏移去取对象。压缩做的事基本都是把对象换成更小的版本。图片重新编码、内容流换一种压缩方式、重复的对象合并、没用的对象删掉、零散的对象打包进对象流。只要有一个对象变小了,它后面所有对象的位置都要往前挪,交叉引用表也得跟着重算。

压缩工具几乎只能整份重写:从头到尾重新排一遍对象、重新写一张交叉引用表、最后写一个新的 %%EOF。这跟视频转封装一个道理。中间某一块的大小一变,后面的偏移表就得全部重写。重写之后 ByteRange 圈住的那两段区域里装的已经是别的东西了。

五个工具的产物只有文件头没变

我把 signed.pdf 交给五个工具处理。其中 qpdf 跑了两遍:原样重写一遍,打包对象流再一遍。Ghostscript 用 /ebook 预设。iLovePDF 用推荐档。PDF24 用默认档。第五个压缩工具我用的是图映 ImgIng(imging.cn/)的 PDF 压缩,选的电子书档,浏览器是 Chromium 149。它在本机处理,iLovePDF 和 PDF24 则会把文件传到自家服务器上。

判断是不是整份重写,我看两样东西。一是文件里有几个 %%EOF,二是产物和原文件从开头起有多少字节相同。下面这段把这两样数出来,三个增量追加的文件也放在一起对照,增量更新第三章再细讲:

python 复制代码
import os, sys

origin = open('signed.pdf', 'rb').read()

def shared_head(a, b):
    k = 0
    while k < min(len(a), len(b)) and a[k] == b[k]:
        k += 1
    return k

print(f'{"file":<26}{"bytes":>9}{"%%EOF":>7}{"same head":>11}')
for cand in sys.argv[1:]:
    data = open(cand, 'rb').read()
    print(f'{cand:<26}{len(data):>9,}{data.count(b"%%EOF"):>7}{shared_head(origin, data):>11,}')
text 复制代码
file                          bytes  %%EOF  same head
qpdf_rewrite.pdf            930,748      1         10
qpdf_objstm.pdf             930,178      1         10
gs_ebook.pdf                 39,876      1          7
imging_ebook.pdf             64,903      1         10
ilovepdf_recommended.pdf     37,513      1         10
pdf24_default.pdf            50,877      1         10
full_annot_pypdf.pdf        931,610      1          7
inc_annot_pypdf.pdf         932,540      3    931,980
inc_content_change.pdf      932,614      3    931,980
inc_second_sig.pdf          942,215      3    931,980

代码说明:shared_head 逐字节往后比,碰到第一个不同的字节就停。返回值是相同前缀的长度。数 %%EOF 是个粗略办法,正文里碰巧出现这串字符也会被算进去。我只拿它当旁证。主证据是相同前缀。full_annot_pypdf 是对照组:同样加一个批注,用 pypdf 整份重写不走增量。

原文件里有 2 个 %%EOF:生成时写了一个,pyHanko 签名时又追加了一个。六个重写产物都只剩 1 个。相同前缀那一列更直接:10 字节是文件头 %PDF-1.7\n%,7 字节是 %PDF-1.。Ghostscript 的产物写的是 1.5 版本头。pypdf 重写写的是 1.3。也就是说这些产物和原文件只有开头那几个字节一样,从第 11 个字节起就各走各的了。三个增量文件的相同前缀都是 931,980,原文件一个字节都没少。

五个工具的压缩力度差得很远。Ghostscript 和 iLovePDF 把 931,980 字节压到四万字节上下。PDF24 压到 50,877,图映压到 64,903。qpdf 两次重写只少了一千多字节。它不重新编码图片,主要是重排结构。对签名来说结局都一样。第一段从第 11 个字节就开始变了。

签名值被原样搬进了新文件

重写之后签名字典去了哪里?我把五个工具的产物逐个交给 pdfsig 和 pyHanko,结果如下:

处理 输出字节 签名字典 pdfsig pyHanko
原 signed.pdf 931,980 在 Signature is Valid INTACT:TRUSTED,UNTOUCHED
qpdf 原样重写 930,748 在,ByteRange 照抄 Illegal values in ByteRange array INVALID
qpdf 打包对象流 930,178 在 同上 INVALID
Ghostscript /ebook 39,876 没了 does not contain any signatures 无签名可验
图映电子书档 64,903 在,ByteRange 仍写到 931,980 Illegal values in ByteRange array INVALID
iLovePDF 推荐档 37,513 没了 does not contain any signatures 无签名可验
PDF24 默认档 50,877 在,ByteRange 照抄 Illegal values in ByteRange array INVALID

qpdf 原样重写那份最适合看细节。它把签名字典整个搬了过去。ByteRange 还是 0 923749 931275 705。可文件只剩 930,748 字节,第二段的终点 931,980 已经越过了文件末尾。pdfsig 读到这里先报一句 Illegal values in ByteRange array,签名状态停在 Signature has not yet been verified,连验都没开始验。pyHanko 的原话是 The signature is cryptographically unsound,最后判 INVALID。我拿第一章那两段代码对它跑了一遍:

text 复制代码
$ python c1_segments.py qpdf_rewrite.pdf
Sig1: [0, 923749) + [931275, 931980)  file=930748
  gap=6999 bytes, starts b'\xa9', ends b'\n', hex-only=False
  sha256(seg1+seg2)=571be5df0bc525d4
$ python c2_digest.py qpdf_rewrite.pdf
Sig1 CMS 记录 040cd959d9698502 | 现算 571be5df0bc525d4 | 不一致 | 补零 1254
$ python c2_digest.py pdf24_default.pdf
Sig1 CMS 记录 040cd959d9698502 | 现算 e0aceef442a17c9a | 不一致 | 补零 1254

按旧的 ByteRange 去切新文件,中间那段「签名值」成了 6,999 字节的二进制数据。开头是 0xA9,已经不是十六进制了。从签名字典里按结构读出来的 /Contents 却一个字节都没变。CMS 里记的摘要还是 040cd959。补零也还是 1,254 个。签名值被原样搬进了新文件,它守着的那两段字节却已经不存在了。PDF24 那份是同样的情况。现算出来的摘要是 e0aceef4。

签名值本身没坏。坏掉的是它和字节之间的对应。拿「签名字典还在」去判断签名还在不在,结论会错。

有两个工具直接删掉了签名

Ghostscript 和 iLovePDF 的处理方式不同。它们的产物里签名域整个没了。iLovePDF 那份连 AcroForm 里的 SigFlags 都没了。pdfsig 的回答是文件里找不到任何签名。pyHanko 也没东西可验。

站在收件人的角度看这一类更麻烦。第一类文件打开后阅读器多半会提示签名有问题,至少会让人起疑。第二类文件看上去就是一份普通 PDF,从文件本身也看不出它原来签过。这一点是我按 pdfsig 和 pyHanko 的结论推出来的,阅读器里的实际显示我没测。

三、增量更新为什么能保住签名

新修订只接在文件末尾

PDF 规范从一开始就留了另一种写法,叫增量更新。改了什么就把改过的对象接在文件末尾。后面再跟一张只列这些对象的交叉引用表、一个指回上一张表的 trailer,最后写一个 %%EOF,前面的字节一个不动。阅读器从文件尾往前读,先看到最新的交叉引用表。同一个对象号以新写入的为准,拿到的就是最新版本。

签名这一步本身就是一次增量更新。签名工具在原文件后面追加签名域、签名字典和预留的空位,签完再回填。第二章里原文件有 2 个 %%EOF,原因就在这里。下面这段按 startxref ... %%EOF 把文件切成一个个修订,看每个修订有多大、写了哪些对象:

python 复制代码
import re, sys

buf = open(sys.argv[1], 'rb').read()
cut = 0
for n, eof in enumerate(re.finditer(rb'startxref\s+(\d+)\s+%%EOF\r?\n?', buf), 1):
    piece = buf[cut:eof.end()]
    objs = [o.decode() for o in re.findall(rb'(?m)^(\d+ \d+) obj', piece)]
    shown = objs if len(objs) <= 6 else objs[:3] + ['...'] + objs[-2:]
    print(f'rev{n}: bytes {cut}..{eof.end()} ({eof.end() - cut:,}), xref@{int(eof.group(1))}, objs {len(objs)}: {" ".join(shown)}')
    cut = eof.end()
tail = buf[931980:]
print('appended text ops:', re.findall(rb'\((ADDED[^)]*)\) Tj', tail))
text 复制代码
$ python c4_revisions.py inc_content_change.pdf
rev1: bytes 0..921653 (921,653), xref@921254, objs 12: 1 0 2 0 3 0 ... 11 0 12 0
rev2: bytes 921653..931980 (10,327), xref@931600, objs 9: 1 0 4 0 14 0 ... 19 0 20 0
rev3: bytes 931980..932614 (634), xref@932349, objs 3: 4 0 20 0 22 0
appended text ops: [b'ADDED AFTER SIGNING']

代码说明:切修订的依据是每个修订末尾那句 startxref、偏移和 %%EOF。xref@ 后面就是这一修订交叉引用表的位置。对象只按行首的 N 0 obj 数。压进对象流的对象数不到,好在这份文件里没有对象流。最后一行在追加部分里找写字的操作符,内容流没压缩,能直接读到明文。

这份文件是在 signed.pdf 后面用 pyHanko 追加了一次修订:往页面的内容流数组里多挂一个流,画一行 ADDED AFTER SIGNING。rev1 是生成文件时写的,921,653 字节、12 个对象。rev2 是签名那一步,追加了 10,327 字节、9 个对象。签名域、签名字典和那块 7,526 字节的空位都在这里。rev3 是签完之后追加的,只有 634 字节、3 个对象。对象 4 是页面,整个重写了一份放在尾部。对象 22 是新内容流。对象 20 是文档信息。旧的页面对象 4 还原封不动躺在 rev1 里,只是交叉引用表不再指向它。会签那份我也用同样的办法切了,它的 rev3 有 10,235 字节、8 个对象,比改内容多出一大截。原因是第二个签名同样要留一块 7,526 字节的空位。

追加三次后原摘要都没变

我一共做了三种追加:用 pyHanko 再签一个名模拟会签、用 pypdf 加一个文字批注、用 pyHanko 改页面内容流。三份文件的开头都是完整的 931,980 字节原文件。第二章那张表里相同前缀一列已经给出来了。拿第一章两段代码再量一遍,Sig1 的 ByteRange 还是 0 923749 931275 705。两段的摘要还是 040cd959,和 CMS 里存的值一致:

text 复制代码
$ python c1_segments.py inc_second_sig.pdf
Sig1: [0, 923749) + [931275, 931980)  file=942215
  gap=7526 bytes, starts b'<', ends b'>', hex-only=True
  sha256(seg1+seg2)=040cd959d9698502
Sig2: [0, 934127) + [941653, 942215)  file=942215
  gap=7526 bytes, starts b'<', ends b'>', hex-only=True
  sha256(seg1+seg2)=cbc2fcdacadb9cd4
$ python c2_digest.py inc_content_change.pdf
Sig1 CMS 记录 040cd959d9698502 | 现算 040cd959d9698502 | 一致 | 补零 1254

会签那份里有两个签名。Sig1 的第二段终点是 931,980,已经不是文件末尾了。它只覆盖到自己签名那一刻的文件。Sig2 的第二段终点 942,215 才是现在的文件末尾。它覆盖了 Sig1 也覆盖了 Sig1 之后追加的一切。多人签的文件就是这样一层套一层。越晚的签名覆盖得越多,最早的那个只管最里面那一层。

这个结构很像只追加的日志。我做编解码时常用一种调试办法:码流只往后写,出了问题就按偏移回放到任意一个时刻。PDF 的增量更新天然留下了每个修订的完整快照。截到某个 %%EOF 为止的那部分字节,本身就是一份合法的 PDF。

压缩没法走增量更新

既然增量更新能保住签名,压缩能不能也走这条路?我想了一圈。结论是做不到,至少没法真的变小。

增量更新要求原来的字节一个都不能少。一张图片要压缩,旧的图片流还得原样留在 rev1 里。新压出来的小图只能写在尾部,再让交叉引用表指向它。文件里同时存着大图和小图,只会比原来更大。要真正把大图的字节去掉就得改动 ByteRange 覆盖的范围,签名也就保不住了。签名之后想变小结构上就没有这条路。

图映这次的产物也是这样。它的 PDF 压缩不是我做的那部分,内部实现我没看过。从产物看它和其他四个工具一样是整份重写。%%EOF 只剩 1 个,从第 11 个字节起就和原文件不同。我的理解是只要目标是把文件压小,整份重写几乎是唯一的选择。工具在本地跑还是在服务器上跑,跟这件事没关系。

四、签名有效能说明文件没被改吗

pdfsig对改过内容的文件也报有效

回到 inc_content_change.pdf。页面上已经多了一行 ADDED AFTER SIGNING。pdfsig 对 Sig1 的结论是这样的:

text 复制代码
Signed Ranges: [0 - 923749], [931275 - 931980]
Not total document signed
Signature Validation: Signature is Valid.
Certificate Validation: Certificate is Trusted.

签名有效。证书可信。唯一的提示是 Not total document signed,意思是签名没有覆盖整个文件。pdfsig 做的就是第一章那一套。按 ByteRange 切字节、算摘要、和 CMS 比,再验证书链。这几步全部成立,它就报 Valid。签名之后有人追加了什么,它不判断。加批注那份和会签那份,pdfsig 给的也是同样的结论。

这不能怪 pdfsig。Signature is Valid 回答的问题本来就窄:签名人签过的那些字节现在还在不在。把这句话读成「这份文件和签的时候一样」,就多读了一层。

pyHanko逐个比对签名后的修订

pyHanko 多做了一步:它先找到签名覆盖到的那个修订,再把之后追加的每个修订拿出来逐个对象和签名时的版本比对。比完按一套修改策略判断这些改动能不能接受。同样三份文件,它的结论就不一样了:

追加方式 追加字节 pdfsig(Sig1) pyHanko(Sig1)
再签一个名 10,235 Signature is Valid INTACT:TRUSTED,EXTENDED_WITH_FORM_FILLING,ACCEPTABLE_MODIFICATIONS
pypdf 加批注 560 Signature is Valid INTACT:TRUSTED,EXTENDED_WITH_OTHER,ILLEGAL_MODIFICATIONS
改页面内容流 634 Signature is Valid INTACT:TRUSTED,EXTENDED_WITH_OTHER,ILLEGAL_MODIFICATIONS
对照:批注但整份重写 --- not yet verified INVALID

会签被归到填表和签名这一级判可接受,改内容流被判非法修改。INTACT 说明 Sig1 那两段字节没动。这一点和 pdfsig 一致。两个工具只在追加部分怎么算上有分歧。加批注那一行也被判了非法。pyHanko 给的理由是 unexplained xrefs。我原先拿不准这是批注本身不被允许,还是 pyHanko 认不出 pypdf 的写法,第五章的补测回答了这个问题。

「签名有效」至少要拆成两个问题来看。被签的字节还在不在,这一层 pdfsig 和 pyHanko 都能答。签名之后的改动合不合规矩,这一层只有会比对修订的工具能答。它依据的规矩就是 DocMDP 和 FieldMDP。

五、DocMDP和FieldMDP能拦住哪些修改

签名分两种:普通的审批签名只表示「我签了这一版」,前面用的 signed.pdf 就是这种。认证签名一份文件只能有一个,而且必须是第一个签名。它额外带一个 DocMDP 级别,告诉后来的人签完之后允许改到什么程度。1 是不许任何改动,2 是允许填表和签名,3 在 2 的基础上再允许加批注。FieldMDP 管得更细,可以在签名时指定锁住哪几个表单域。

第四章那三份都是审批签名,没带 DocMDP。我想看这两套许可真起作用时是什么样,就补了一组测试。同一份虚构协议,分别做 DocMDP 级别 1、2、3 的认证签名。之后各自追加会签、批注、改内容流。另外造了一份带 Amount 和 Note 两个文本域的表单,用审批签名加 FieldMDP 锁住 Amount,然后分别去填这两个域。验签还是 pdfsig 和 pyHanko。下面这段把 pyHanko 的判断压成一行一份:

python 复制代码
import sys, logging
from pyhanko import keys
from pyhanko.pdf_utils import reader as pdf_reader
from pyhanko.sign import validation as hv
import pyhanko_certvalidator as cv

logging.disable(logging.CRITICAL)                 # 差异分析的报错堆栈很长,这里只看结论
root = keys.load_cert_from_pemder('ca.pem')
for pdf_name in sys.argv[1:]:
    with open(pdf_name, 'rb') as fh:
        first = pdf_reader.PdfFileReader(fh, strict=False).embedded_signatures[0]
        res = hv.validate_pdf_signature(first, cv.ValidationContext(trust_roots=[root]))
        perm = first.docmdp_level.value if first.docmdp_level else '-'
        print(f'{pdf_name:<27} P={perm}  intact={res.intact}  level={res.modification_level.name:<12} docmdp_ok={res.docmdp_ok}')
text 复制代码
inc_second_sig.pdf          P=-  intact=True  level=FORM_FILLING docmdp_ok=True
inc_annot_pypdf.pdf         P=-  intact=True  level=OTHER        docmdp_ok=False
approval+annot_pyhanko.pdf  P=-  intact=True  level=OTHER        docmdp_ok=False
cert_P2+sig2.pdf            P=2  intact=True  level=OTHER        docmdp_ok=False
cert_P2_presig+sig2.pdf     P=2  intact=True  level=FORM_FILLING docmdp_ok=True
cert_P1+annot.pdf           P=1  intact=True  level=OTHER        docmdp_ok=False
cert_P3+annot.pdf           P=3  intact=True  level=OTHER        docmdp_ok=False
cert_P1+content.pdf         P=1  intact=True  level=OTHER        docmdp_ok=False
fieldlock+Amount.pdf        P=-  intact=True  level=OTHER        docmdp_ok=False
fieldlock+Note.pdf          P=-  intact=True  level=OTHER        docmdp_ok=False

代码说明:每份文件只看第一个签名。它是最早的那个,也是受后续修订影响的一方。docmdp_level 为空表示这是审批签名,打印成短横。intact 是那两段字节有没有动。modification_level 是 pyHanko 给追加部分定的级别,docmdp_ok 是这个级别有没有超出允许范围。关掉日志是因为差异分析判非法时会往日志里打一整段异常堆栈。函数本身照常返回,结论不受影响。

十份文件的 intact 全是 True。被签的字节一个没动,pdfsig 对这十份的 Sig1 也全报 Signature is Valid。真正把它们分开的只有后两列。

认证签名P1下连会签都签不上

DocMDP 级别 1 的文件,我想在后面再签一个名。pyHanko 在签名这一步就拒绝了,报的是 SigningError。原话是 Author signature forbids all changes,文件没有生成出来。签名工具自己会读 DocMDP,签之前先做检查。

批注和内容流就不一样了。它们不是用签名工具写的,增量写入器不管这些,照样写了进去。cert_P1 加批注和改内容流两份,pyHanko 都判 OTHER 且 docmdp_ok 为 False,pdfsig 照旧都报有效。级别 1 拦得住守规矩的签名工具,拦不住直接改文件的人。它能做的是让改过的文件在会比对修订的工具那里现形。

会签要在认证前预留签名域

级别 2 写明允许签名,我以为会签肯定能过。结果第一次就被判了非法,cert_P2+sig2 那一行是 OTHER。pyHanko 日志里的原话是 Update of Reference(idnum=22) is only allowed after an approval signature, not a certification signature。我的会签脚本是先新建一个签名域再签名。新建签名域要往页面上加一个控件,这一步在认证签名之后不被接受。

换个顺序再做一次。认证之前先在文件里建好一个空的 Sig2 域。认证签名做完之后第二个人只往这个预留的域里签,不新建别的东西。这一份 cert_P2_presig+sig2 判 FORM_FILLING,docmdp_ok 为 True。两个签名 pdfsig 也都报有效。

这个结果对设计签署流程有直接影响。第一个签名如果要做成认证签名,后面几个人的签名域最好在认证之前就全部建好。审批签名没有这个限制。第四章 inc_second_sig 那份就是签完再新建 Sig2,被判可接受。

锁住的表单域一填就判非法

FieldMDP 那份表单的 Sig1 是审批签名,锁住了 Amount。签完之后往 Amount 里填 9999,pyHanko 判非法。日志原话是 Update of Reference(idnum=13) is not allowed because the form field Amount is locked。对象 13 正是 Amount 那个域。这一条和预期完全一致。

往没锁的 Note 里填 ok,我预期会判可接受。结果也被判了 OTHER。报错同样指向 Amount 被锁。引用的是对象 15,也就是 Amount 的外观流。可这次修订只写了对象 16 和 25。16 是 Note,25 是文档信息,对象 15 根本没碰。原因我没查清。放到第八章再说。

加批注在默认策略下总被判非法

批注这一类我一共做了五份。审批签名下 pypdf 写的一份和 pyHanko 写的一份,认证级别 1、2、3 下各一份。五份全部判 OTHER,docmdp_ok 为 False。审批签名下换成 pyHanko 自己的增量写入器去加,报的仍是 unexplained xrefs。指向的也是对象 4 和 22,和 pypdf 那份一样。第四章留下的疑问有了答案。被判非法跟 pypdf 的写法无关,是 pyHanko 的默认策略本身不放行批注。

级别 3 明明允许批注,pyHanko 也判了非法。它在日志里自己交代了一句:StandardDiffPolicy was not designed to support DocMDP level 3 (MDPPerm.ANNOTATE). Unexpected validation results may occur。这是验签工具的能力边界,文件并没有违规。换成 Acrobat 会怎么判,我没测。

六、签名文件的压缩该放在哪一步

把前面几章串起来结论很简单:压缩放在签名之前。签名之后能做的只有增量追加。增量追加只会让文件变大,压缩的目标在这里实现不了。签完再压,不管用哪个工具、在本地还是在线,结局都是签名失效或被删。

我自己设计这类链路时会这样排:原始文件先压缩,压缩产物做一遍人工或自动的内容核对。然后签名。签名之后的文件只读,归档、传输、展示都不再改写它。需要多人签而第一个签名又是认证签名时,签名域在认证之前一次建好,后面的人只签预留的域。

收到别人发来的签名件,我会做三件事。先用代码按结构读 AcroForm 确认哪些签名域有值,这一步不靠搜字节。再看最后一个签名的 ByteRange 第二段终点是不是文件末尾。不是的话就说明签名之后还有修订。有修订时 pdfsig 的 Valid 只当作第一层结论。我会再用 pyHanko 这类会比对修订的工具验一遍,看它给的修改级别。

签好的 PDF 太大,确实需要变小怎么办?我能想到的办法只有一个:回到签名之前的那一版,压缩之后重新签。签名覆盖的就是那批字节,想换一批字节就得重新签。

七、测签名有效性有哪些坑

valid为真不代表字节没变

pyHanko 的验签结果里有好几个布尔字段,第一次看很容易读错。qpdf 重写那份,它给的是 valid 为 True、intact 为 False、整体结论 INVALID。valid 指 CMS 签名结构本身在数学上没坏。第二章已经看到签名值被原样搬了过去,它当然没坏。intact 才说明被签的字节有没有变。判断签名能不能用我只看 intact 和最后的整体结论。单看 valid 会把重写过的文件当成好的。

pdfsig要指定信任哪张根证书

pdfsig 默认用系统的证书库。我试过一次不指定证书库去验 signed.pdf。签名一行仍是 Signature is Valid,证书一行变成 Certificate issuer isn't Trusted,退出码还是 0。我这次的 pdfsig 结果都带了 -nssdir,指向一个只信任自建根证书的库。脚本里如果只看退出码,证书不受信任这一层会被漏掉。

两个pyHanko版本号对不上

第一轮测试的记录里,pyHanko 版本写的是 0.33.0。后来在本机虚拟环境里查,实际装的是 0.32.0,补测和本文代码都按 0.32.0 记。我用 0.32.0 把第四章三份增量文件重跑了一遍。会签判 FORM_FILLING、加批注判 OTHER,和第一轮的记录一致。不同版本的差异分析策略可能不一样。批注这种它自己声明不完全支持的情况尤其如此,引用这类结论时要带上版本号。

搜字节会漏掉对象流里的标记

用 grep 搜 /SigFlags 来判断有没有签名,在 qpdf 打包对象流的那份文件上会漏。AcroForm 被压进了对象流,SigFlags 这串字节在文件里就不存在了。我拿 6 份文件试过,漏掉的就是这 1 份。签名字典的 /ByteRange 倒还搜得到。我的做法是判断签名一律按结构读,字节搜索只当初筛。

八、还有哪些没弄清

第一件是 FieldMDP 那份表单里填 Note 被判非法。pyHanko 报的是 Amount 被锁。引用的对象 15 是 Amount 的外观流,可那次修订根本没写对象 15。我怀疑跟造表单的方式有关。两个文本域是用 PyMuPDF 加的,共用同一个字体资源。pyHanko 比对时可能把共享引用算到了 Amount 头上。这只是猜测。我没有换一种方式造表单去验证。查清之前「FieldMDP 只拦被锁的域」这句话我不会写死。

第二件是阅读器的表现。Acrobat 和福昕打开这些文件会显示什么,我一份都没看。第五章那些被 pyHanko 判非法的文件在它们那里会不会提示,我也不知道。pdfsig 和 pyHanko 的结论不能直接替代阅读器里看到的签名面板。

九、适用边界与风险提示

样本只有一份。它是自造的单页虚构协议,带一张合成照片,所有派生文件都从它来。真实的电子合同可能有多页、多个签名、时间戳、长期验证信息,这些都没测。证书是自建的测试证书,没有真实 CA 也没做吊销检查。

验签工具只有两个:pdfsig 26.08.0 和 pyHanko 0.32.0 的默认策略。文中的「判非法」「判可接受」都只是这两个工具在默认设置下的结论。它们不代表任何签章平台或阅读器的判断。

压缩工具每个只跑了一次,iLovePDF 和 PDF24 的产物是那一天在线处理的结果。它们以后换了实现,结果可能会变。在线工具会把文件传到服务器,处理带签名的合同前要先想到这一点。图映的 PDF 压缩在本地处理,可它同样是整份重写,签名一样会失效。

增量追加的三种写法和补测里的写法,都是为了观察验签行为而做的。签名之后有修订的文件,验收时按第六章的顺序多验一层。

想动手复现最省事的起点是第一章那两段代码。拿一份自己的签名件跑一遍,先看 ByteRange 第二段终点是不是文件末尾,再核对 CMS 里存的摘要和现算的对不对得上。这两件事核对完,这份签名还剩多少可信度心里就有数了。

参考资料

  • PDF 规范 ISO 32000 中数字签名、增量更新、DocMDP 与 FieldMDP 的相关章节。本文只引用了概念名称,没有逐条核对规范原文。
  • poppler pdfsig 26.08.0、pyHanko 0.32.0(含 pyhanko-certvalidator 0.29.1)、pypdf 6.20.1、asn1crypto 1.5.1、qpdf 12.4.0、Ghostscript 10.07.1、PyMuPDF 1.26.5。
  • 在线工具:iLovePDF、PDF24 的 PDF 压缩,均为 2026-10-16 当天处理。
  • 本文数据:2026-10-16 在版本声明所列环境里的一轮测试和补测。签名件与证书均为自造,无真实人员与合同。
相关推荐
inksci1 小时前
发布Ai生成的html markdown 推荐
前端·javascript·html
修炼的dance1 小时前
压完的PDF验签不报错,是因为签名被删了
前端
ModStart1 小时前
ProcHub v0.7.0 命令行工具上线,进程、配置与 WSL 都能在终端管理
前端
那年那棵树2 小时前
基于WebGis的人生如旅地图系统
前端·arcgis·vue·课程设计
XZ-0700012 小时前
week9-三元表达式
java·前端·python
叫我铁汁儿吧2 小时前
Redux的原理
前端
云和数据.ChenGuang2 小时前
name=“张三“ age=18 weight=100.3 print (name,age,weight)
java·服务器·前端
代码猎人2 小时前
前端怎么优化兼容性
前端