技术文章

PDF 权限标志:/P 整数与 /Perms 加密副本

你用一个本想放开一切的掩码 $FFFFFFF0 加密报告,结果 Acrobat 的安全选项卡里,打印和修改文档都列着不允许。/P 是从第 1 位开始编号的无符号 32 位标志字,所以那个掩码把打印(第 3 位)和修改(第 4 位)都清掉了;全放开的取值是 $FFFFFFFC,也就是 PDFium Component 里 TPdfEncryptOptions 的起点

/P 标志字的位是怎么排的

只有八个位置有意义,其余都是保留位,而且编号从 1 开始。ISO 32000-1 §7.6.3.2 说 P "shall be interpreted as an unsigned 32-bit quantity",从低端第 1 位编号到高端第 32 位,并要求合规阅读器忽略 3、4、5、6、9、10、11 和 12 之外的所有位。ISO 32000-2 §7.6.4.2 修正了表 22 并把保留位置写死:第 1–2 位必须是 0,第 7–8 位必须是 1,修订版 3 及以后第 13–32 位必须是 1。PDF 2.0 还退役了第 10 位,阅读器现在忽略它,写入器为照顾老阅读器仍一律置 1;无障碍那条例外挪到第 5 位,所以内容送去辅助技术时,阅读器应当当它允许复制。下面每一行都能用一个公式算出来:第 n 位的掩码是 1 shl (n - 1)——TPdfPermission 也是按同样的方式对应到这个字上

位掩码含义(修订版 3 及以后)TPdfPermission
1–2$00000003保留,必须为 0无
3$00000004打印,质量取决于第 12 位permPrint
4$00000008用第 6、9、11 位之外的操作修改内容permModify
5$00000010复制或抽取文本与图形permCopy
6$00000020添加或修改注释、填写字段;配合第 4 位可创建字段permAnnotate
7–8$000000C0保留,必须为 1无
9$00000100填写现有表单字段(含签名字段),即使第 6 位为 0permFillForms
10$00000200无障碍抽取;PDF 2.0 里不再使用,一律写 1permAccessibility
11$00000400组装:插入、旋转或删除页面,创建书签项与缩略图permAssemble
12$00000800打印到忠实的高质量呈现permPrintHighQuality
13–32$FFFFF000保留,必须为 1(ISO/TS 32004 占用第 13 位,见下文)无

$FFFFFFF0 这个掩码,来自把“低位是保留的”读成了“干脆整个 nibble 都清掉才保险”。保留区其实只有第 1–2 位,掩码 $3;清掉 $F 顺手也抹掉了 $4 和 $8,那正是打印和修改,任何认真执行这些标志的查看器都会把两者拒掉。镜像的错误是从零往上凑掩码:把 $4、$10 和 $800 或在一起,得到一个很小的正数 /P,第 7–8 位和第 13–32 位全是 0。大多数阅读器打开这种文件一声不吭,因为规范叫它们忽略保留位,但这个值对修订版 3 及以后是不合规的,严格的检查器完全有权抱怨。第三种变体出现在把位从零开始数的代码里,“第 3 位”变成 $8,程序本意给打印,结果给了修改。这些错误在保存时都不报错,权限只是和作者的本意不一样了

PDFium Component 在 Delphi 里用的 PDF /P 权限标志字布局:保留位 1-2、7-8 与 13-32,permPrint、permModify 等 TPdfPermission 位,以及 $FFFFFFFC 与 $FFFFFFF0 两个掩码
用 $FFFFFFF0 把整个低位 nibble 清掉,打印和修改也一起没了;从零往上堆掩码,则会让修订版 3 及以后的保留位留成 0

/P -4 和 /P 4294967292 是同一个值吗

是同一个。同样的 32 位,两种写法。PDF 的整数对象是有符号的,表 22 上方那条注记指出:由于高位保留位是 1,这个整数会被写成负数,所以修订版 3 及以后习惯上的全放开值是 -4。把值存在无符号变量里的生成器则写 4294967292,两种拼写在真实文件里都见得到。规范在它自己的密钥派生里定下了哪种读法算对:ISO 32000-1 §7.6.3.3 算法 2 的第 (d) 步把 P 转成 32 位无符号二进制数,并把四个字节按最低有效字节在前送进 MD5,所以对修订版 2 到 4,-3904 和 4294963392 产出同一个文件密钥。把 /P 存在有符号 32 位 Integer 里的解析器,会把无符号写法当成溢出拒掉;把变量换成 Cardinal,负的写法又反过来失败;而拿 token 文本去和存下来的值比较的代码,会把同一份文档当成两份。把 token 读进 Int64,接受从 Low(Integer) 到 High(Cardinal) 的任何值,比较之前先掩到 32 位。PDFium Component 自己的解密代码处理修订版 2 到 7 时就是这么读 /P 的,它的写入器则发出规范注记里那种有符号形式。片段的保留位报告停在 14 位,因为第 13 位为 0 正是 ISO/TS 32004 标记“此文件带 PDF MAC”的方式

uses
  System.SysUtils;

// 把你自己字典解析器给的 /P token 文本归一化
function TryNormalizePermissions(const Token: string;
  out Mask: Cardinal): Boolean;
var
  Value: Int64;
begin
  Result := TryStrToInt64(Token, Value) and
    (Value >= Low(Integer)) and (Value <= High(Cardinal));
  if Result then
    Mask := Cardinal(Value and $FFFFFFFF);
end;

function DescribeReservedBits(Mask: Cardinal): string;
begin
  Result := '';
  if (Mask and $00000003) <> 0 then
    Result := Result + 'bits 1-2 set; ';
  if (Mask and $000000C0) <> $000000C0 then
    Result := Result + 'bits 7-8 clear; ';
  // 第 13 位($1000)跳过:ISO/TS 32004 清掉它来表示要求 PDF MAC
  if (Mask and $FFFFE000) <> $FFFFE000 then
    Result := Result + 'bits 14-32 not all set; ';
end;

// 预期:两次调用都得到 $FFFFFFFC,保留位报告为空
//   TryNormalizePermissions('-4', M1);
//   TryNormalizePermissions('4294967292', M2);
按 PDFium Component 的做法在 Delphi 里归一化 PDF 的 /P:/P -4 与 /P 4294967292 都读进 Int64,从 Low(Integer) 到 High(Cardinal) 校验范围,再掩成 Cardinal 的 $FFFFFFFC
有符号 Integer 和 Cardinal 解析器各自会拒掉 /P 的一种合法写法,先读进 Int64 再掩到 32 位,两种写法就归到同一个值

从 TPdfPermissions 集合拼出掩码

先把每个保留位放到规定的值上,再只把你允许的那些或进去,这样漏掉的权限最多只会变成拒绝。FPdfEncrypt 单元里 TPdfEncryptOptions.Default 已经装着 $FFFFFFFC,什么都允许,从它上面清掉一两位做快速限制很方便。如果策略是要列出受件人能做什么,用集合写读起来更顺,下面那个常量数组与 TPdf 解码这个字时用的位位置一一对应。第 10 位不管调用方传什么都强制打开。ISO 32000-2 要求写入器一律置它,PDF 2.0 的阅读器反正也忽略,留着 0 只会在老软件里堵住屏幕阅读器

uses
  System.SysUtils, PDFium, FPdfEncrypt;

const
  PermissionBit: array[TPdfPermission] of Cardinal = (
    $0004,   // permPrint             第 3 位
    $0008,   // permModify            第 4 位
    $0010,   // permCopy              第 5 位
    $0020,   // permAnnotate          第 6 位
    $0100,   // permFillForms         第 9 位
    $0200,   // permAccessibility     第 10 位
    $0400,   // permAssemble          第 11 位
    $0800);  // permPrintHighQuality  第 12 位

  // 位 7-8 与 13-32 置 1;位 1-2 与所有权限位清 0
  ReservedOnes = Cardinal($FFFFF0C0);

function PermissionsToMask(Allowed: TPdfPermissions): Cardinal;
var
  P: TPdfPermission;
begin
  Result := ReservedOnes or PermissionBit[permAccessibility];
  for P in Allowed do
    Result := Result or PermissionBit[P];
end;

procedure SaveFillAndPrintCopy(const Source, Target: string;
  const OwnerPassword: AnsiString);
var
  Pdf: TPdf;
  Opts: TPdfEncryptOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := Source;
    Pdf.Active := True;
    if not Pdf.Active then
      raise Exception.CreateFmt('Could not open %s', [Source]);

    Opts := TPdfEncryptOptions.Default;   // 这里 Revision = erR5
    Opts.UserPassword := '';              // 任何人都能打开文件
    Opts.OwnerPassword := OwnerPassword;  // 守着权限集合
    Opts.Revision := erR6;
    Opts.Permissions := PermissionsToMask(
      [permPrint, permPrintHighQuality, permFillForms]);

    // 预期:/P -1084 = 4294966212 ($FFFFFBC4)
    Writeln('/P ', Integer(Opts.Permissions), ' = ', Opts.Permissions);

    if not Pdf.SaveAsEncrypted(Target, Opts) then
      raise Exception.Create('Encrypted save failed');
  finally
    Pdf.Free;
  end;
end;

预期:输出的 Encrypt 字典里带着 /P -1084,认真执行这些标志的查看器会提供全质量打印和表单填写,同时把内容编辑、复制、注释和页面组装置灰。第 13 位是写入器替你管的一个位置。EnableIntegrityProtection 留 False 时,不管你的掩码怎么说,SaveAsEncrypted 都会置上第 13 位;设成 True 时,写入器会同时清掉 /P 和 /Perms 里的第 13 位。其余保留位还是你自己的责任——所以起点用 $FFFFF0C0。示例选 erR6,因为 R6 是 ISO 32000-2 标准化下来的 AES-256 修订版;默认记录里装的是 erR5

/Perms:AES-256 文件里 /P 的加密副本

修订版 2 到 4 里,权限字是焊在密钥上的:改动 /P 里一位,MD5 的输入就变,派生出的密钥不对,什么都解不开。AES-256 文件断开了这条链。ISO 32000-2 §7.6.4.3.3 的算法 2.A 从 /UE 或 /OE 解包文件加密密钥,P 在派生里完全没份,所以单看明文 /P,谁都能随便改。/Perms 补了这个洞。算法 10(§7.6.4.4.9)填一个 16 字节的块,用文件密钥以 AES-256 ECB 模式加密它:

  • 第 0–3 字节:权限字,最低有效字节在前,由第 4–7 字节扩展到 64 位,那几个字节全是 $FF
  • 第 8 字节:ASCII 的 T 或 F,与 /EncryptMetadata 一致
  • 第 9–11 字节:ASCII 标记 adb
  • 第 12–15 字节:阅读器会忽略的随机数据

算法 2.A 的第 (f) 步解密这个块,验 adb,并要求第 0–3 字节与 P 相符——是 shall,不是 should。算法 13(§7.6.4.4.12)用更软的 should 重复了标记与权限检查,还多一条:第 8 字节要与 EncryptMetadata 一致。PDFium Component 在自己代码打开加密层的每一处都按严格的那套读:AESV4 解密、PDF MAC 校验,以及往加密文件里做增量保存。用户密码或所有者密码解开文件密钥之后,这些代码把四个权限字节拿去和归一化后的 /P 逐字节比,检查 $FF 填充、T/F 字节与那个标记,任何一处对不上就让加载失败并报 "PDF encryption permissions validation failed"——因为不一致说明有人在加密之后动过字典,两边的副本都不能按面值采信

PDFium Component 里的 AES-256 /Perms 块:/P 权限字最低有效字节在前、$FF 填充、/EncryptMetadata 的 T 或 F 字节与 adb 标记,由算法 10 加密,再按算法 2.A 与 13 校验
AES-256 的文件密钥不再依赖 /P,所以真正能证明明文权限字在加密后没被改过的,是解密出来的 /Perms 字节

EncryptMetadata 的比较在你写的任何阅读器里都值得留神。Pos('/EncryptMetadata false', Dict) > 0 这样的捷径,会漏掉中间隔两个空格的 /EncryptMetadata false、键与值之间换行、或中间夹一条注释的情况,而这些全是合法 PDF 语法。于是阅读器按默认值 true 处理,在一份完全合法的 /Perms 里看到 F,就把文件拒了。合法文件,被拒。请把键和它的值当 token 解析,非布尔值按畸形处理,而不是给它套默认值

ISO/TS 32004 给第 13 位派了活:它一清 0,就表示文档带着一个阅读器必须校验的 PDF MAC。MAC token 住在 trailer 的 /AuthCode 字典里,那个字典没加密,所以只找 /AuthCode 的检查会把一份删掉了它的文件判成从来没有完整性保护。把 /KDFSalt 也删掉并不能藏住这项要求,因为清掉的位住在 /P 里,而 /P 由 /Perms 认证。所以 PDFium Component 的 AESV4 解密把“第 13 位为 0”、或“剩下一个 /KDFSalt”而没有 /AuthCode 的情况,一律当成 MAC 被剥掉,直接失败;完整性保护关闭时,写入器会把第 13 位置 1,好让输出永不宣称一个它没有的 MAC。这个 token 背后的密钥层级与字节范围属于 AES-GCM 加密与 PDF 2.0 里的 ISO/TS 32004 MAC,而在这一切之前解锁文件密钥的迭代口令哈希,见 R6 口令哈希与它的轮数

Permissions 还是 UserPermissions:界面该读哪个

要决定当前这次会话能做什么,读 Permissions;要报告文件对一个普通受件人放开了什么,读 UserPermissions。TPdf.Permissions 包的是 PDFium 的 FPDF_GetDocPermissions,描述这份打开的文档被授予的访问权,取决于用哪个密码解开的。TPdf.UserPermissions 包的是 FPDF_GetDocUserPermissions,不管用了哪个密码,都持续报告 /P 里的用户访问授权,所以用所有者密码打开文件时,这两个值会不一样。两个都是 LongWord;GrantedPermissions 和 UserGrantedPermissions 把它们解码成 TPdfPermissions 集合,HasPermission 和 UserHasPermission 测单个成员。SecurityHandlerRevision 告诉你这个文件到底有没有安全处理器。下面这段例程把原始字的两种整数视图连同解码出的集合一起打出来,看某个文件为什么“打不了印”时很快就能明白

uses
  System.SysUtils, System.TypInfo, PDFium;

procedure DumpPermissions(const FileName, Password: string);
var
  Pdf: TPdf;
  Mask: LongWord;
  Granted: TPdfPermissions;
  P: TPdfPermission;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Password := Password;   // 在 Active 之前设
    Pdf.Active := True;
    if not Pdf.Active then
      raise Exception.CreateFmt('Could not open %s', [FileName]);

    if Pdf.SecurityHandlerRevision <= 0 then
    begin
      Writeln('Not encrypted: no /P to decode');
      Exit;
    end;

    Mask := Pdf.UserPermissions;
    Granted := Pdf.UserGrantedPermissions;
    Writeln(Format('R%d  /P as Integer %d, as Cardinal %u ($%.8x)',
      [Pdf.SecurityHandlerRevision, Integer(Mask), Mask, Mask]));

    for P := Low(TPdfPermission) to High(TPdfPermission) do
      Writeln(Format('  %-22s %s',
        [GetEnumName(TypeInfo(TPdfPermission), Ord(P)),
         BoolToStr(P in Granted, True)]));

    if not Pdf.HasPermission(permCopy) then
      Writeln('  this session may not copy text');
  finally
    Pdf.Free;
  end;
end;

// 前文写出的那个文件的预期输出:
//   R6  /P as Integer -1084, as Cardinal 4294966212 ($FFFFFBC4)
//   permPrint True, permModify False, permCopy False, ...

执行权限是宿主应用的事

不管标志怎么写,PDFium 都照渲染、照抽文本、照光栅化页面。引擎负责报告权限,策略留给调用方。手里有用户密码的人就握着解密后的内容——用户密码为空时,这个人就是所有人——所以保密性只能靠一个阅读器没有的用户密码。把检查放进每个命令处理器(复制、导出、删页、打印),而不是把工具栏按钮藏起来,因为快捷键、右键菜单和自动化都能绕过工具栏,这和Delphi 应用里的安全 PDF 预览窗格用的是同一层思路。比如打印命令,在往后台送任何东西之前先测 permPrint

在 AES-256 文件里,明文的 -1084 在 /Perms 解密出的头四个字节读出 C4 FB FF FF 之前,什么也证明不了。那次逐字节比较跑在PDFium Component 的 AESV4、PDF MAC 与加密增量保存代码里