压缩前查PDF签名,字节搜索会漏掉对象流

我们中台有个环节是用户上传 PDF 后统一压一遍再入库,为的是省存储和下载流量。最近业务方开始往里传电子合同。我这才想到一个问题:签过名的 PDF 压完以后签名还在不在。我拿一份自签名的测试文件试了 5 个压缩和重写工具,签名一个都没保住。这篇讲压缩之前怎么用代码判断一份 PDF 带不带签名。结论先放这儿:读 AcroForm 里已经签过的 /Sig 字段和 SigFlags,比直接在文件字节里搜 /ByteRange、/SigFlags 可靠。我测的 6 个文件里字节搜索漏掉了 1 个文件的 SigFlags。

五个工具压完签名都失效了

测试文件是我自己造的一页虚构协议。里面放了一张 1800×1200 的合成照片,用自建的测试证书签了名。文件一共 931,980 字节,签名类型是常见的 adbe.pkcs7.detached。我先在本地用 qpdf 重写和打包对象流、Ghostscript 的 /ebook 各处理一遍,再交给 iLovePDF 推荐档和 PDF24 默认档。最后一个是图映的 PDF 压缩,我用的是图映 ImgIng(imging.cn/)的电子书档,浏览器 Chromium 149 跑在一台 M4 的 Mac 上。验签用 poppler 的 pdfsig 和 pyHanko。

结果分成两种。qpdf、图映和 PDF24 留着签名字典但验签全部失败。pdfsig 报的是「Illegal values in ByteRange array」。Ghostscript 和 iLovePDF 更干脆,整个签名域都被删了。后一种打开文件时不会有任何「签名无效」的提示,因为签名已经不存在了。图映压完只剩 64,903 字节,签名字典里的 ByteRange 还写着到第 931,980 字节为止。压缩工具的活是把文件变小,签名这一关只能在压之前自己查。

我对原因的理解是这样。ByteRange 是两段字节区间,这份文件是 [0 923749 931275 705]。中间空出的那段放签名值。前后两段一起参与哈希。压缩工具都要把整份文件重写一遍,这两段字节必然变掉。我测的 5 个工具没有一个是在文件末尾增量追加的。

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

最省事的检测办法是把文件读成字节再用正则搜 /ByteRange、/Type /Sig、/SigFlags。我一开始也是这么写的。拿 6 个文件一跑就看出了问题。

文件 字节里搜到 ByteRange / Type Sig / SigFlags 读结构得到的 SigFlags 已签字段
原始签名文件 1 / 1 / 1 3 Sig1
增量追加了第二个签名 2 / 2 / 2 3 Sig1、Sig2
USGS 公开报告 0 / 0 / 0 无 无
1887 年字典扫描件 0 / 0 / 0 无 无
qpdf 打包对象流后 1 / 1 / 0 3 Sig1
Ghostscript 压缩后 0 / 0 / 0 无 无

问题出在 qpdf 打包对象流的那一份。AcroForm 被压进了对象流,/SigFlags 这几个字符在文件字节里已经找不到。字节层搜到 0 次。结构层读出来却是 3。检测规则要是靠 SigFlags 来判断,这份就会被当成没签名的文件放过去。我的理解是 qpdf 打包对象流时会把不是流的普通对象塞进一个压缩过的流里,AcroForm 字典就在其中。签名字典这次没被塞进去,原因我没去翻 qpdf 的源码。这次碰巧 ByteRange 还搜得到。它会不会也被压进对象流我没再造样本去试,所以字节搜索我只拿来做初筛。

从AcroForm里找已经签过的字段

结构层我用 pypdf 6.20.1 来读。思路是从 /Root 拿到 /AcroForm 再遍历 /Fields 和每一层 /Kids,找 /FT 等于 /Sig 而且带 /V 的字段。有 /V 才说明这个签名框真的签过。顺手把 ByteRange 的末端算出来,后面要拿它和文件长度比。

python 复制代码
def find_signed_sigs(pdf_path):
    form_dict = PdfReader(pdf_path).trailer["/Root"].get("/AcroForm")
    if form_dict is None:
        return None, []
    form_dict, hits = form_dict.get_object(), []
    queue = list(form_dict.get("/Fields", []))
    while queue:
        fld = queue.pop(0).get_object()
        queue += fld.get("/Kids", [])
        if fld.get("/FT") == "/Sig" and "/V" in fld:
            br = fld["/V"].get_object()["/ByteRange"]
            hits.append((str(fld["/T"]), int(br[2]) + int(br[3])))
    return int(form_dict.get("/SigFlags", 0)), hits

这段在 6 个文件上跑出来的结果和表里结构层两列一致。原始文件返回 (3, [('Sig1', 931980)])。两份公开样本和 Ghostscript 那份都返回 (None, [])。这里的 None 表示文件里压根没有 AcroForm,跟读出 SigFlags 等于 0 不是一回事。前者是没有表单,后者是有表单但没声明签名,日志里我分开记,排查的时候能少猜一步。遍历 /Kids 是因为签名框可能挂在父字段下面,只看顶层 /Fields 会漏。我这几份样本的签名字段都在顶层,这一层是给别家系统生成的合同留的。后来我补造了一份只有空白签名框的文件,框名叫 SignHere,还没人签。这段返回 (1, []),空框没被当成签名。

字节计数说不清是哪个签名

结构层多返回一个 ByteRange 末端,是因为字节搜索还有第二个盲区。增量追加了第二个签名的那份,字节层三项都是 2,只能告诉我「有两处签名」。哪个签名覆盖了整份文件、哪个签完以后文件又被改过,光数字符串是数不出来的。结构层的结果是 Sig1 末端停在 931,980,Sig2 末端是 942,215,正好等于文件长度。Sig1 签完以后文件被追加过。

Ghostscript 压过的那份在字节层是 0、0、0,和两份从没签过的公开样本一模一样。结构层的结果也一样是 (None, [])。这一条两层都没办法,后面单独说。字节规则换成搜 /FT /Sig 也不行。那份空白签名框的文件里 /SigFlags 和 /FT /Sig 各搜到 1 次,/ByteRange 是 0 次,只看前两个就会把还没签的模板当成签过的。结构层里专门看 /V 就是为了把这两种分开。

接进上传流程以后我是这么分的。找到已签字段的文件原样保存,不进压缩。SigFlags 声明有签名却找不到已签字段的转人工看一眼。剩下的才压缩。pypdf 读的时候直接报错的文件也归到人工那一类,宁可少压几份也不冒险把合同压坏。合同在我们这儿占的存储本来就不多。少压这一类省下的空间有限,出了问题却要法务出面。

还有一个漏洞我没解决。Ghostscript 和 iLovePDF 压过的文件签名已经被删光,检测结果和一份从没签过的文件完全一样。用户要是上传一份被别的工具压过的合同的话从文件本身看不出它原来签过。这一点只能靠业务那边在上传时多问一句来源。我的环境是 Python 3.9.6、pdfsig 26.08.0 和 pyHanko 0.33.0,证书是自建的测试根证书。Acrobat 里怎么显示我没看。想自己验证可以拿一份签过的 PDF 用 qpdf 打包对象流重写一遍再分别跑字节搜索和上面这段代码,两边结果就对不上了。

相关推荐
inksci1 小时前
发布Ai生成的html markdown 推荐
前端·javascript·html
Lank_M1 小时前
PDF签完名还能改什么?会签能过,压缩不行
前端
星光不问赶路人1 小时前
Vue 项目类型检查:深入理解 vue-tsc --build
前端·vue.js
修炼的dance1 小时前
压完的PDF验签不报错,是因为签名被删了
前端
ModStart1 小时前
想玩 AI 翻唱?SeedVC 模型 + AIGCPanel 上手
前端
ModStart1 小时前
ProcHub v0.7.0 命令行工具上线,进程、配置与 WSL 都能在终端管理
前端
那年那棵树2 小时前
基于WebGis的人生如旅地图系统
前端·arcgis·vue·课程设计
ModStart2 小时前
3 条命令,ProcHub 在终端管好后台进程
前端