组里在给合同附件加一道上传预检。附件传上来以后还要转图和压缩。碰到加密文件后面这几步会直接失败。产品那边希望用户点上传时就知道这份文件处理不了,最好还能说清原因。我一开始觉得这事不难,因为能打开的文件总不会是加密的吧。我试了几份自己生成的测试 PDF 就发现想错了。打开时不要任何密码的文件照样带着加密,在阅读器里还复制不出字。这篇记一下前端怎么把要不要密码、有没有加密、限制了哪几项这三件事分开判断。只写检测不碰文件本身。
打开不要密码也可能是加密文件
PDF 的密码其实有两种,名字很像但管的事不同。打开密码又叫用户密码,没有它就看不到内容。权限密码也叫所有者密码。它不拦打开,只在文件里声明打印、复制、编辑这些操作允不允许。只设了权限密码的文件用户密码是空的,阅读器会自动拿空密码把内容解开。打开时谁也不会问你要密码。可文件确实带着完整的加密字典,内容流也确实用 AES 或 RC4 加密过。工具说它已加密并没有说错。我用脚本生成了 7 份 2 页的占位 PDF,正文都是「示例合同(占位文字,仅供测试)」,每份 66 到 69 KB 左右。
| 样本 | 打开密码 | 加密方式 | 声明的限制 |
|---|---|---|---|
| S0 | 无 | 无 | 无 |
| S1 | 无 | AES-256 | 全禁(只留无障碍读取) |
| S2 | 无 | RC4 128 位 | 全禁(只留无障碍读取) |
| S3 | 无 | AES-128 | 只禁复制 |
| S4 | 无 | AES-256 | 只禁打印 |
| S5 | 有 | AES-256 | 全禁(只留无障碍读取) |
| S6 | 无 | RC4 40 位 | 全禁(连无障碍读取也禁) |
加密方式这一列我原本以为会影响阅读器的表现,专门做了 AES 和 RC4 好几个版本。实测下来并不影响。权限设定相同的 S1、S2、S6 在 Chromium 149 开源构建和 Firefox 151 里表现完全一样,真正起作用的只有权限位。还有一处我也想错了。S5 输入用户密码打开以后 Chromium 照样拦复制和打印。用户密码只管能不能打开,打开之后的限制还是照权限位来。
第一步先看要不要打开密码
检测页用的是从 cdnjs 加载的 pdf.js 6.3.289。文件读成 Uint8Array 以后在页面里直接解析并不往任何地方传。要不要打开密码看 onPassword 回调就够了。S5 一交给 getDocument 这个回调马上就会触发。我在回调里只记一笔需要打开密码,接着把这次加载任务销毁掉。页面上不弹密码框。任务销毁以后等待中的 promise 会抛出 PasswordException。我在外面包了一层 try 把它接住,它是预期内的结果而不是故障。S0 到 S4 和 S6 这 6 份都不会触发这个回调。
这一步只回答要不要密码这一件事。有没有加密它一个字也不说,S1 这种全禁文件在这一步和未加密的 S0 看起来完全一样。
第二步看文件带没带加密字典
能打开的 6 份里哪几份其实加密了要看 getMetadata() 返回的 info 里的 EncryptFilterName。S0 这一项是空的,S1 到 S4 和 S6 全是 Standard 也就是标准安全处理器。我把检测页放进 Chromium 149、Firefox 151、WebKit 26.5 三个内核里各跑了一遍,结果一样。WebKit 这里只测了页面里跑的 pdf.js。它不是 Safari 正式版,Safari 自带的 PDF 显示我没测。
这一步最容易和后端的判断对不上。假如后端用 Python 做二次校验,那么 PyMuPDF 的 is_encrypted 对 S1 返回 False。pypdf 的 is_encrypted 对 S1 返回 True。两边都没错,只是同一个词在两个库里是两个意思。PyMuPDF 问的是现在还锁着没解开,S1 已经用空密码解开了。pypdf 问的是文件带不带加密字典,S1 带着。要是前端看一个、后端看另一个,同一份文件就可能在两边得出相反的结论。我把预检结果定成了未加密、只有权限限制、需要打开密码这三态。接口里不再传那个布尔值。
第三步读出限制了哪几项
带加密字典的文件还要再调 getPermissions() 读权限位。它返回的是允许的操作。每个值对应 pdf.js 里 PermissionFlag 的一项,在集合里就是允许,不在就是不允许。我把打印、高质量打印、复制文字、修改内容、添加注释、填写表单、无障碍读取、插页旋转删页这 8 项逐一对照后做成了下面这个检查页。

这张图是检查页一次读三份文件的结果。第一张卡片是只设权限密码的全禁文件 S1。标签写着「打开不要密码 · 设了权限限制」,8 项里只有无障碍读取是绿色的允许。第二张是只禁复制的 S3。8 项里只有复制文字那一行标红。第三张是要打开密码的 S5。缩略图位置写着输入密码前看不到内容,下面一行权限都没有,因为不给密码什么也读不到。前两张卡片底部的小字是加密方式 Standard 和页面库读到的文字数。两份都是 169 字,跟未加密的 S0 一样多。页面库只把权限位报给调用方,执不执行由应用自己决定。
权限位这里有两个坑是我实际撞上的。第一个坑是这个接口在不同版本里的返回类型。页面里的 pdf.js 6.3.289 返回的是 Set,用 Object.prototype.toString 打出来是 object Set。Firefox 151 自带阅读器里的 pdf.js 5.7.195 返回的却是数组。我最早照数组的写法直接调 includes,换到 6.3 就报错了。现在拿到结果都先用 Array.from 转成数组再判断。第二个坑是 null 和空集合的区别。未加密的 S0 返回 null,意思是文件没声明任何限制。连无障碍读取都禁掉的 S6 返回一个空集合,意思是一项都不允许。空集合在 JS 里是真值。Set 又没有 length 属性,拿 length 当判断条件的话 S6 就会被当成没有限制并和 S0 混成一类。这两种情况在我的预检里是分开写的。
检测结果只提示不处理
读出限制以后前端做什么我定得很窄,只是把结果告诉用户。限制原样留着,前端也不会悄悄另存一份去掉限制的文件。一个原因是合规,这是我最在意的。文件作者声明了不许复制和修改,系统要是替用户绕开的话法务问起来我解释不了。数据整改那三个月我已经领教过了。另一个原因是这些限制本来就靠阅读软件配合执行,各家的执行并不一致。拿 S1 来说,Chromium 149 自带的阅读器拦下了打印和复制,在页面上画笔迹却没拦。Firefox 151 在默认设置下三样都没拦。前端要是照某一个阅读器的表现给用户下结论,换个阅读器就不准了。提示文案里我只写文件声明了什么,不写这份文件不能复制。
后面的转图和压缩都要重写文件,碰到加密文件会怎样我想先看看现成的工具怎么处理。我用的是图映 ImgIng 的 PDF 压缩(https://imging.cn/),测试文件是自己生成的只设权限密码和带打开密码两种 PDF。处理在本机完成,整个过程非 GET 请求是 0 个。S1、S2、S3、S5 导入时都只显示待处理,没有提示也没弹密码框。一点开始压缩,四份全被标成了失败。提示原话是「这份 PDF 有加密或权限保护,需要先解除保护」。S3 其实只禁复制并且允许修改和组合,也照样被拒。它左栏的常驻说明里同样写着加密或有权限保护的 PDF 需要先解除保护。拒绝的内部原因界面上没说。我猜是重写文件就得处理原来的加密和限制,它选择干脆不碰。这只是我从界面结果倒推的猜测。还有一处状态对不上。失败以后右侧进度卡写的是「全部完成」和「0 个文件已压缩完成」。看完这一轮我更确定预检要放在上传那一刻。别让用户等处理完才看到失败。
三态我各配了一句提示。需要打开密码的那一类,提示用户找发件人要密码,或者请对方另发一份不设密码的。只有权限限制的先列出文件声明了哪几项,再说明后面的转图和压缩会失败。可以请原作者发一份不带限制的版本。文件是用户自己做的、还记得所有者密码的,就请他用当初生成它的软件输入所有者密码后重新导出一份。未加密的文件直接放行进入后面的流程。
还没测到的情况
Acrobat Reader、macOS 预览、Safari 正式版我都没测,文中的阅读器表现只限 Chromium 149 开源构建和 Firefox 151。安全处理器也只测了 Standard 这一种。证书做的公钥加密、只加密附件、带数字签名的文件都没试,检查页碰到它们会给什么结果我现在说不准。样本全是自己生成的 2 页占位文件,真实合同的权限位怎么分布这批数据代表不了。下一步我先把三态预检接进测试环境,再找几份有授权的真实附件过一遍。重点看 null 和空集合这两类在里面各占多少。