技术文章

加密 PDF 留在明文里的那个签名值

别处签好的一份 AES 加密合同载入后,签名值解出来是一串随机字节;AES-GCM 下的文件更糟,签名字典整个解不开。trailer 的 /ID 和加密字典之外,ISO 32000-2 §7.6.2 只把签名的 /Contents 保留为明文——PDFlibPas 读写时都让它保持明文,而 /Reason 和 /M 照常加密

ISO 32000-2 给加密规则加的那条例外

ISO 32000-2 §7.6.2 说加密覆盖文件里的每个字符串和流,只有四个例外:

  • trailer /ID 条目的值
  • /Encrypt 字典内部的字符串
  • 流内部的字符串——流是整体加密的
  • 签名字典中作为 /Contents 值的任何十六进制字符串

前三条是老规则。ISO 32000-1 §7.6.1 只列了那三条,所以照 2008 年文本写的解密器没有任何指示要跳过签名字典里的东西。签名工具反正一直按明文写这个值。原因是机械的——签名器先预留一个十六进制占位符,写出整个文件,对占位符以外的一切做哈希,构建 CMS,再把它的十六进制补进缺口。这份摘要覆盖的是磁盘上的字节,密文也在内。加密签名值保护不了任何东西,还会逼着回填步骤知道文件密钥,并把 IV 加填充塞进一个哈希存在之前就定好尺寸的占位符。字典其余条目走正常规则:

签名字典条目PDF 类型加密存储?
/Contents十六进制字符串否,ISO 32000-2 §7.6.2 明文豁免
/Reason、/Location、/ContactInfo、/Name文本字符串是
/M日期字符串是
/ByteRange整数数组否,整数从不加密
/Type、/Filter、/SubFilter名称否,名称从不加密

漏掉这条例外的阅读器会对明文 DER 跑一遍密码算法,结果取决于算法。RC4 是流密码,输出长度不变、内容变成噪声;CMS 解析器看到首字节不再是 0x30,报告签名格式错误。AES-CBC 把前 16 字节当初始向量、其余当密文,结局是填充错误或一坨更短的垃圾。AES-GCM(由 ISO/TS 32003 为 PDF 定义)是最狠的情形:每个加密字符串自带 nonce 和认证标签,明文值过不了标签校验。把一次标签失败当成整个字典致命错误的解密器,随后连 /Reason 和 /M 一起丢掉

Delphi 中 PDFlibPas 加密 PDF 的签名字典:ISO 32000-2 §7.6.2 下十六进制 /Contents 保留明文而 /Reason 与 /M 保持加密,附 RC4、AES-CBC 与 AES-GCM 各自的失败形态
加密 PDF 里签名 /Contents 保持为可读 DER,/Reason 与 /M 是密文;硬要解密这份 DER,每种密码算法坏的方式各不相同

怎么把签名的 /Contents 和其他 /Contents 区分开?

按键名匹配是最顺手的第一次尝试,两个方向都会翻车。注释字典也有 /Contents 条目——存审阅者敲下的批注文本——而它必须加密。跳过它,机密文件里的每张便签都能不输密码就读到。按 /Type /Sig 匹配则坏在另一头:ISO 32000-2 Table 255 把 /Type 在签名字典里设为可选、默认 Sig,只对 /DocTimeStamp 强制要求,合规的生产者完全可以不写。所以 PDFlibPas 只在同一个字典同时带有 /ByteRange 数组,且 /Type 为 Sig 或 DocTimeStamp、或没有 /Type 但旁边有 /Filter 名称时,才把 /Contents 字符串当作签名值。每个签名字典必有 /Filter,属于签名字段的每个签名必有 /ByteRange,注释两者皆无。库在保存加密和载入解密时跑同一个测试,所以 /Contents 载入后拿到的就是生产者写下的字节

Delphi 中 PDFlibPas 的加密测试:在加解密字符串之前,依据 /ByteRange、/Type Sig 或 DocTimeStamp 与 /Filter 区分签名 /Contents 与注释 /Contents
注释批注保持加密,因为 PDFlibPas 只在 /Contents 旁边见到 /ByteRange 和匹配的 /Type、或 /Type 缺席时的 /Filter 名称,才把它当签名值

先用 PDFlibPas 加密,再签名

签名放最后。签名摘要覆盖的是存储字节,任何在签名之后重写文件的操作都会毁掉文件里的每个签名。给已签文档加加密会重新加密每个字符串和流;去掉加密同样把它们翻个底朝天。行得通的顺序是:加密、保存、然后签这个加密文件。签名流程追加一份增量更新,新字符串用既有文件密钥加密,/Contents 占位符保持明文。签名流程里用 owner password 打开文件;user password 只在权限包含 ISO 32000-2 Table 22 的第 9 位时够用——那一位点名了签名字段。Encrypt 和签名流程在 PDFlibPas 每个版本都有

uses
  SysUtils, PDFlibrary;

const
  OwnerPwd = 'owner-secret';
  UserPwd  = 'reader-secret';

procedure EncryptCopy(const Source, Target: string);
var
  Lib: TPDFlib;
  Perms: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(Source, '') <> 1 then
      raise Exception.CreateFmt('Load failed (%d)', [Lib.LastErrorCode]);
    // 打印、填写表单字段(bit 9 覆盖签名字段)、无障碍提取、高分辨率打印
    Perms := Lib.EncodePermissions(1, 0, 0, 0, 1, 1, 0, 1);
    // Strength 4 = AES-256 revision 6:直接使用文件密钥,/ID 不参与派生
    if Lib.Encrypt(OwnerPwd, UserPwd, 4, Perms) <> 1 then
      raise Exception.CreateFmt('Encrypt failed (%d)', [Lib.LastErrorCode]);
    if Lib.SaveToFile(Target) <> 1 then
      raise Exception.CreateFmt('Save failed (%d)', [Lib.LastErrorCode]);
  finally
    Lib.Free;
  end;
end;

procedure SignField(Lib: TPDFlib; const Source, Target, FieldName,
  PFXFile, PFXPassword: string);
var
  SP, R: Integer;
begin
  SP := Lib.NewSignProcessFromFile(Source, OwnerPwd);
  if SP = 0 then
    raise Exception.CreateFmt('Input not found: %s', [Source]);
  try
    Lib.SetSignProcessField(SP, FieldName);   // 字段缺失时以不可见方式创建
    Lib.SetSignProcessPFXFromFile(SP, PFXFile, PFXPassword);
    Lib.SetSignProcessInfo(SP, 'Approved for release', 'Head office', '');
    // 占位符里给日后追加的签名时间戳留出余量
    Lib.SetSignProcessReserveContentsBytes(SP, 16384);
    Lib.EndSignProcessToFile(SP, Target);     // 总是返回 0
    R := Lib.GetSignProcessResult(SP);
    if R <> 1 then
      raise Exception.CreateFmt('Signing %s failed (result %d)', [FieldName, R]);
  finally
    Lib.ReleaseSignProcess(SP);
  end;
end;

Strength 4 选的是 AES-256 revision 6,这个选择消掉两种失败模式。RC4 和 AES-128 下,Algorithm 1(ISO 32000-1 §7.6.2)按对象号和生成号为每个对象派生单独的密钥;Algorithm 2(ISO 32000-1 §7.6.3.3 步骤 e)还把第一个 /ID 元素喂进文件密钥。签名更新会重写 AcroForm 字典和页面的 /Annots 数组,每个被重写的对象必须保住原生成号,更新的 trailer 必须带上同一个第一个 /ID 元素。生成号错了,那个对象解不开;/ID 错了,什么都解不开。Revision 6 直接用文件密钥(ISO 32000-2 §7.6.3.3,Algorithm 1.A),取密钥时不看 /ID(§7.6.4.3.3,Algorithm 2.A)。PDFlibPas 在每种强度下都让被重写的对象保留生成号,并按存储原样从 /ID 派生密钥——哪怕畸形文件带的是 /ID 7 0 R 而不是 ISO 32000-2 Table 15 要求的直接数组;空的 /ID [] 则过不了密码检查。密码到密钥的路径,用原始文件密钥打开加密 PDF一文挖得更深

文本编辑器就够看结果了。签名字典在添加它的那份更新里不压缩,因为签名器按文件偏移回填 /ByteRange 和 /Contents。搜 /ByteRange 就能找到它。/Contents 十六进制通常以 3082 开头——CMS SignedData SEQUENCE 的 DER 头——占位符比签名大时以一长串零收尾。/Reason 是密文,即使文本没变,两次签名跑出来的值也不同,因为 AES 给每个字符串抽新的 IV。新版 qpdf 的预期结果:qpdf --password=owner-secret --show-object=14 signed.pdf 打印解密后的 /Reason,旁边是原样的 /Contents。这项检查用 strength 4 的文件。原始文件里的预期形状,偏移与字节随文件而变:

14 0 obj
<</Type /Sig /Filter /Adobe.PPKLite
  /ByteRange [0 48211 81093 2304]
  /Contents <308240A206092A864886F70D010702A0...000000000000>
  /Reason <7F21C9E04B...>
  /M <D35A0F98...>
  ...>>
endobj

回填时间戳时哪些签名会坏?

盖章之后加进来的每个签名都会坏。PAdES 签名时间戳是一个 RFC 3161 令牌,作为签名者的 unsigned 属性存放(RFC 5652 §5.3 的 unsignedAttrs),所以它住在 /Contents 内的 CMS 里。追加一个就意味着就地重写那段十六进制。加密文件里这次重写能成,全靠 /Contents 是明文:不重新加密、不加新对象——令牌只需要塞进签名时预留的空间。持有签名的那个因为 /ByteRange 跳过这些字节而存活。后来的签名没有这种豁免:ISO 32000-2 §12.8.1 让每个签名的字节区间从 %PDF- 头跑到添加它的那份增量更新末尾。于是第二份签名或文档时间戳会把第一份签名的 /Contents 连同其他一切一起哈希。缺口只属于一个签名。第二签名落笔之后再动它,第二个签名的摘要就对不上了。令牌请求本身是从 Delphi 做 PAdES 签名与验证的一部分;本节只关心哪些字节会动

PDFlibPas 在哈希任何东西之前就检查重叠。签名时间戳流程读取文档中其他每个签名的 /ByteRange,把每对区间与目标的 /Contents 缺口比对。任何重叠都以流程结果 17 中止准备,不写出输出文件。这个流程属于 Enterprise 版,以 PDFlibEnterprise 单元的 TPDFlibEnterprise 实例化;其他版本保留这些方法,返回 0 并把 LastErrorCode 设为 900。日志里盯紧 17 这个数:来自 GetPAdESSignatureTimeStampProcessResult 意味着更晚的签名盖住了这一个;来自 GetSignProcessResult 意味着认证级别或字段锁禁止签名。两者分开对号。下面的辅助函数用前一清单里的 SignField 和 OwnerPwd

Delphi 中 PDFlibPas PAdES 签名时间戳的先后次序:更晚签名的 /ByteRange 盖住更早的 /Contents 缺口时,GetPAdESSignatureTimeStampProcessResult 返回 17 且不写出文件
Approver 签完之后,它的字节区间盖住 Author 的 /Contents 缺口;PDFlibPas 以流程结果 17 拒绝迟到的 Author 时间戳,而不是弄坏更晚的签名
uses
  SysUtils, PDFlibrary, PDFlibEnterprise;

const
  STS_COVERED_BY_LATER_SIGNATURE = 17;

function StampSignature(Lib: TPDFlibEnterprise; const Source, Target,
  FieldName: string): Integer;
var
  STS: Integer;
begin
  Result := 0;
  STS := Lib.NewPAdESSignatureTimeStampProcessFromFile(Source, OwnerPwd);
  if STS = 0 then
    Exit;
  try
    Lib.SetPAdESSignatureTimeStampField(STS, FieldName);
    if Lib.ApplyPAdESSignatureTimeStamp(STS, 0) <> 1 then
    begin
      Result := Lib.GetPAdESSignatureTimeStampProcessResult(STS);
      if Result = 1 then
        Result := -1;  // 值已哈希,TSA 交互失败:见 GetTimeStampError
      Exit;
    end;
    Result := Lib.EndPAdESSignatureTimeStampProcessToFile(STS, Target);
  finally
    Lib.ReleasePAdESSignatureTimeStampProcess(STS);
  end;
end;

procedure SignAndStampInOrder(const EncryptedFile: string);
var
  Lib: TPDFlibEnterprise;
  R: Integer;
begin
  Lib := TPDFlibEnterprise.Create;
  try
    Lib.SetTimeStampServer('https://tsa.example.com/tsa', '', '');

    SignField(Lib, EncryptedFile, 'step1.pdf', 'Author', 'author.pfx', 'pfx-1');
    R := StampSignature(Lib, 'step1.pdf', 'step1-ts.pdf', 'Author');
    if R <> 1 then
      raise Exception.CreateFmt('Author time stamp failed (%d)', [R]);

    SignField(Lib, 'step1-ts.pdf', 'step2.pdf', 'Approver', 'approver.pfx', 'pfx-2');
    R := StampSignature(Lib, 'step2.pdf', 'final.pdf', 'Approver');
    if R <> 1 then
      raise Exception.CreateFmt('Approver time stamp failed (%d)', [R]);

    // 预期结果:17。Approver 的字节区间在 step2.pdf 里盖住
    // Author 的 /Contents,流程因此拒绝,restamped.pdf 永远不会写出
    R := StampSignature(Lib, 'step2.pdf', 'restamped.pdf', 'Author');
    if R <> STS_COVERED_BY_LATER_SIGNATURE then
      raise Exception.CreateFmt('Unexpected result %d', [R]);
  finally
    Lib.Free;
  end;
end;

在下一个签名者碰文件之前,先给每个签名盖章。每个签名者多一次 TSA 往返,换来每个签名都有自己可信的时间。拿到手里就已经有多个签名的文件是另一回事:更早的那些再也无法补签名时间戳了,改追加文档时间戳——一份新修订里 /Type /DocTimeStamp 的签名、/SubFilter /ETSI.RFC3161,字节区间盖住所有更早的签名,证明它们在那个时刻已存在。ApplyPAdESLongTermArchiveValidation 在附上验证材料后追加一个。签名时给签名时间戳预留空间,用第一个清单里的 SetSignProcessReserveContentsBytes。嵌入 TSA 证书链的令牌轻松跑到几 KB,占位符没有这个余量,日后的增强就会以结果 13 失败

解密已签文件,以及验证器哈希的到底是什么

加密文件被签的字节区间是密文。验证器拿存储密文的摘要对 CMS 内的摘要:/ByteRange 是整数、/Contents 是明文,完整性检查不需要密钥——要密钥的只是字段名和 /Reason。同一件事也解释了为什么解密一份已签文件再存成明文会作废其中每个签名,尽管可见像素一个都没变。把加密已签的原件留作存档副本。交叉引用流同样从不加密(ISO 32000-2 §7.5.8.2),用 xref stream 写出的更新在任何密钥派生之前偏移就可读。布局详见Delphi 中的对象流与交叉引用流。被签区间大到读不进内存比对时,PDFlibPas 把完整性检查报告为 unknown 而非 invalid,因为从未比对过的字节没有被证明不同

加密文件的签名值在签名之后仍可写,直到另一份签名或文档时间戳盖住它。把时间戳步骤放进这个窗口,第一个清单的先加密后签名顺序就能原样搬到 PDF Library for Delphi 的多签名者文件上