我给自己生成了 7 份两页的占位 PDF。正文只有一行「示例合同(占位文字,仅供测试)」。S0 不加密。S1 到 S4 和 S6 只设了权限密码,打开时谁都不要密码。S5 设了打开密码。拿去做压缩测试的时候让我停下来的是 S3。它只禁复制,修改和组合页面都允许,压缩工具照样把它判成了失败。我想弄清楚处理工具在拒绝之前能不能先看清这份文件到底限制了什么,读这些信息又要不要密码。跑完之后我的答案是不要。权限写在 /Encrypt 字典里,这个字典本身就是明文。连 S5 这种要打开密码的文件我不输任何密码也能从字节里读出它的权限位。
加密字典在文件尾部明文可读
标准安全处理器加密一份 PDF 时加密的是内容流。描述加密方式的 /Encrypt 字典并不在加密范围里。它挂在文件尾部字典上,里面我最关心的有五个键。V 和 R 是版本号、Length 是密钥位数、CFM 是加密方法。P 是一个整数,权限就写在它的各个位上。为了确认这件事我没用任何 PDF 库,只用 Node.js 按 latin1 读原始字节,找到最后一处 /Encrypt 再按 << 和 >> 配平把字典截出来。S1 到 S5 的字典直接内联在尾部字典里。pypdf 生成的 S6 写的是间接引用 /Encrypt 31 0 R,要跳到 31 0 obj 才读得到。最早那版我偷懒用了 lastIndexOf('/Encrypt'),结果在 S1 到 S5 上命中的全是 /EncryptMetadata。V、R、P 一个都没读出来。P 成了 NaN 之后 8 项权限全报不允许,只禁复制的 S3 也跟着成了全禁。这显然不对。后来我改成要求 /Encrypt 后面不能再跟字母才读对。
const hits = [...text.matchAll(/\/Encrypt(?![A-Za-z])/g)];
const pos = hits.at(-1).index;
const tail = text.slice(pos + 8, pos + 40);
const ref = tail.match(/^\s*(\d+)\s+(\d+)\s+R/);
const start = ref ? text.indexOf(`${ref[1]} ${ref[2]} obj`) : pos + 8;
const dict = sliceDict(text, text.indexOf('<<', start));
const u = P >>> 0;
return Object.entries(BIT).filter(([b]) => !(u & (1 << (b - 1)))).map(([, name]) => name);
代码说明:前六行负责找字典,正则末尾那段否定前瞻就是用来排除 /EncryptMetadata 的。尾部要是跟着「对象号 代号 R」就得按对象号去找对应的那个 obj 才能拿到字典。后两行负责读权限,查的那张表写的是规范里的 8 个权限位。整段只读字节既不解密也不改写文件。下面只贴 S1 和 S5 两行输出。S0 没有 /Encrypt 这个键,其余几份跟开头说的设置一致,S3 的 -20 和 S6 的 0 下一节再说。
S1 V5 R6 Length 256 CFM AESV3 P=-3392 0xfffff2c0 不允许: 打印、改内容、复制文字、批注、填表、组合页面、高质量打印
S5 V5 R6 Length 256 CFM AESV3 P=-3392 0xfffff2c0 不允许: 打印、改内容、复制文字、批注、填表、组合页面、高质量打印
S5 那一行是我最想要的。它要打开密码,可我什么密码都没给也照样读出了 P=-3392。
P值按位读出八项权限
权限值在文件里是个有符号的 32 位整数,所以才会看到 -3392 这种负数。按无符号解释再逐位去看就清楚了。位号从 1 开始数,第 3、4、5、6 位依次管打印、修改内容、复制文字和批注。第 9、10、11、12 位依次是填表、无障碍读取、组合页面和高质量打印。某一位是 1 表示允许、是 0 表示不允许。-3392 写成十六进制是 0xfffff2c0。低字节 0xc0 把第 3 到第 6 位全清成了 0,再加上第 9、11、12 位也是 0 就只留下了第 10 位。意思是只允许无障碍读取,其余 7 项全禁。-20 是 0xffffffec,只清了第 5 位也就是只禁复制。-2056 写成 0xfffff7f8 后清掉的是第 3 位和第 12 位这两项打印权限。S6 的 P=0 最干脆,32 位全是 0 就连无障碍读取也禁了。JS 里这一步有个小坑是位运算会把数当成有符号的来处理,我在脚本里先做一次 >>> 0 转成无符号再打印十六进制才不会冒出负号。
算法这边我原本以为会有差别。S1、S2、S6 三份全禁文件分别用 AES-256、RC4-128 和 RC4-40,在 Chromium 和 Firefox 两种设置下的结果完全一致。S3 用 AES-128、S4 用 AES-256 也都按各自的权限位表现。影响阅读器行为的只有权限位,版本号和加密方法换来换去都不起作用。
执行权在打开文件的那一方
权限位只是写在文件里的一个声明,拦不拦要看打开文件的软件。我把同一批文件放进几个处理方里挨个试过,差别比我预想的大得多。
这张图看的是同一批只设权限密码的文件在五个处理方里的表现。每一行对应一个处理方,三列分别是打印、复制文字和编辑批注,格子里写的是拦住、照常可用或只报告权限位。最上面一行是 Chromium 149 开源构建自带的阅读器,它拦了打印和复制。正文能拖选出蓝色高亮,可剪贴板里什么都没写进去。它没拦绘制,7 个样本都能进「绘制」画出笔迹。第二行 Firefox 151 在默认设置下三项都没拦。第三行是打开 pdfjs.enablePermissions 开关以后的结果,打印和编辑拦住了,复制仍然照常。这一条我没想到。最下面两行是 pdf.js 6.3 当页面库和 PyMuPDF 1.26 当处理库。它们只把权限位交给调用方,执不执行由应用自己决定。图底下那行小字交代的是权限位相同时四种算法的结果没有差别。
还有一个细节是 S5 输入打开密码以后 Chromium 照样拦复制和打印。打开密码只管能不能打开,打开之后的限制还是按 P 执行。这两件事我以前会混着说。放到这批结果里看 Chromium 确实拦住了复制和打印,Firefox 默认设置却一样都没拦。同一份文件的限制落不落地全看阅读软件配不配合。
图映对带加密字典的文件一律拒绝
开头说的压缩工具是图映。这次我试的是图映 ImgIng(imging.cn/)的 PDF 压缩,测试条件是我自己生成的只设权限密码和带打开密码两种测试 PDF 并统一选了「电子书」档。图映的 PDF 引擎不是我做的,下面只按界面结果来说。S0 正常压缩后从 66 KB 压到 49 KB,界面上写的是「−25.7%」。S1、S2、S3、S5 导入时都只显示「待处理」。界面上没有任何提示,S5 也没弹出密码框。点了开始压缩之后四份在队列里全部标「失败」,提示都是同一句「这份 PDF 有加密或权限保护,需要先解除保护」。全程 46 个请求里非 GET 请求是 0 个。
这张图先看左边红框。S1 标了「失败」,下面就是那句提示原话。左栏常驻说明里也写着「加密或有权限保护的 PDF 需要先解除保护」。右边进度卡同时写着「全部完成」和「0 个文件已压缩完成,可以逐个保存。」这两处状态对不上。我照实记一笔。
从结果看它不分打开密码和权限密码也不看 P 的值。S3 的权限位明明允许修改和组合也照样被拒。为什么这么做界面上没说。下面是我的推测,没法实测。压缩要重写整份文件,重写就得重新处理加密并碰到所有者设下的限制。换我来做大概也会选干脆不处理这条最稳的路。只是我前面刚验证过读 P 不需要任何密码。导入那一刻就能知道这份文件带了 /Encrypt 以及限制了哪几项。失败提示完全可以提前到导入那一步。
这段脚本还不能当通用解析器
它只在这 7 份文件上验证过,取最后一处 /Encrypt 在这里没出错。换成保存过多次的增量更新文件时尾部字典就不止一个,该取哪一个我现在没有把握。加密字典放进对象流的情况它也处理不了。样本本身也只是 2 页、约 66–69 KB 的占位文件,真实合同和银行回单里权限位怎么分布它代表不了。样本全是标准安全处理器。公钥加密、只加密附件、/EncryptMetadata false 和带数字签名的文件我都没测。阅读器那边 Acrobat Reader、macOS 预览和 Safari 正式版没测。WebKit 26.5 上我只跑了页面里的 pdf.js,它自己显示 PDF 的那条路没碰。Chromium 的打印我也只看到了间接信号,真正的打印对话框没见到。
我现在拿到一份提示已加密的 PDF 会先读 /Encrypt 和 P,分清它要的是打开密码还是只有权限密码,再看限制了哪几项。做处理工具的话我会把这一步放在导入时做,能省掉用户一次白等。碰到别人的受限文件就按正规做法找原作者要一份不带限制的版本。如果文件是自己的、也记得所有者密码,就用当初生成它的软件输入所有者密码重新导出一份。