技术文章

在 Delphi 中不能静默修改加密 PDF

对一份已经带有 AES-256 加密的发票 PDF,使用面向 Delphi 和 C++Builder 的 PDFium Component(PDFiumPas)通过增量更新而不是完整重写来添加 PDF/A 归档标记或 PAdES 签名时,不能直接修改加密字节:六个合规标记注入器会检测现有的 /Encrypt 条目,并逐字节原样传递源文件;PAdES 签名器会抛出异常,而不会生成任何验证器都无法接受的签名

这与审计一份并非由你创建、需要排查隐藏风险的 PDF 是另一回事,后者属于独立的只读检查。本文讨论的是同一信任边界的写入侧:当你的代码试图在文件创建之后向其中添加内容时,对于一个字节已经被他人密码锁定的文件,你的代码究竟可以做什么

当你更新加密 PDF 时,ISO 32000-1 要求什么

ISO 32000-1 §7.5.6 要求增量更新的 trailer 重复前一个 trailer 中除 /Prev 之外的每个条目,而表 15 将 /Encrypt 列为 trailer 可以携带的条目之一。如果从新 trailer 中删掉它,符合规范的读取器没有理由怀疑这一遗漏:最新 trailer 具有权威性,因此读取器发现其中没有 /Encrypt 时,会认定整个文件未加密,并尝试将旧 trailer 之后仍处于加密状态的正文当作普通字节解析。如果保留新 trailer 中的 /Encrypt,却把更新部分自己的对象写成明文,失败只会晚一步发生:读取器会正确检测到加密,并用文件密码对它接触到的每个对象解密,包括那些从未加密的新对象,结果得到的是经过解密处理后反而变成噪声的内容,而这些内容在解密前本来完全可读。两种错误都会生成一个在字节层面看起来正常且格式完整的增量更新文件,直到符合规范的读取器打开它

六个标记注入器与 v2.14.2 加密门禁

PDFiumPas 提供六个字节级标记注入器,分别对应它可以标记的六种 ISO PDF 子集:PDF/A(ISO 19005)、PDF/X(ISO 15930)、PDF/UA(ISO 14289-1)、PDF/E-1(ISO 24517-1)、PDF/R-1(ISO 23504-1)和 PDF/VT-1(ISO 16612-2)。每个注入器都会处理 PDFium 自己已经写入的字节 FPDF_SaveAsCopy,并在其上再叠加一个更小的增量更新:新的 XMP 元数据流、指向该元数据流的 catalog 字典编辑,以及面向打印的子集所需的 OutputIntent 和 ICC 配置文件。从 v2.14.2 开始,InjectPdfAMarkersInjectPdfXMarkersInjectPdfUaMarkersInjectPdfEMarkersInjectPdfRMarkersInjectPdfVTMarkers 都会先读取源 trailer;如果其中报告已有 /Encrypt 条目,就将源内容原样复制到目标流,随后立即返回。没有 XMP,没有 OutputIntent,也没有 catalog 编辑,调用方得到的是逐字节不变的原始文件

var
  Src, Dst: TFileStream;
  Opts: TPdfXSaveOptions;
begin
  Src := TFileStream.Create('signed-encrypted-proof.pdf', fmOpenRead);
  Dst := TFileStream.Create('pdfx-attempt.pdf', fmCreate);
  try
    Opts.Conformance := pxc4;
    InjectPdfXMarkers(Src, Dst, Opts);
    // pdfx-attempt.pdf is byte-identical to the source: still encrypted,
    // no /GTS_PDFXVersion, no OutputIntent. Nothing was written, and
    // nothing was corrupted either
  finally
    Dst.Free;
    Src.Free;
  end;
end;

允许加密并不等于可以安全注入

PDF/E-1 和 PDF/R-1 都在规范层面明确允许宿主文档加密,但只有查看磁盘上实际必须发生的事情后,这看似例外的规定才会显出局限。ISO 24517-1 §6.3 允许 PDF/E-1 使用加密,ISO 23504-1 §6.2.3 则允许 PDF/R-1 使用加密,前提是文件头声明 %PDF-2.0。这两条规定都没有说明字节级后处理器能否安全地向这个加密容器添加明文对象,而答案是否定的,原因仍然是适用于其他所有子集的 §7.5.6。PDFiumPas 针对这两个配置文件的合规验证器 ValidatePdfEComplianceValidatePdfRCompliance 会有意记录 /Encrypt 的存在,但不会将其标记为缺陷,这对于从不写入字节的只读验证器来说是正确的。这个行为模式也很容易让人略过,并误以为同一对功能中的注入器不需要单独的门禁,但真正必须拒绝的正是这一对功能中会写入内容的那个函数

SaveAsPdfX 会静默解密文档吗

如果你使用公开的便捷方法,而不是直接调用注入器,答案是会。TPdf.SaveAsPdfASaveAsPdfXSaveAsPdfUaSaveAsPdfESaveAsPdfRSaveAsPdfVT 中的每一个,都会先通过 SaveAs(Tmp, saRemoveSecurity) 将当前文档渲染到临时流,然后把这些字节交给匹配的注入器。saRemoveSecurity 映射到 PDFium 自己的 FPDF_REMOVE_SECURITY 标志,因此注入器收到的临时副本从一开始就没有加密,注入器的 /Encrypt 门禁也没有触发的理由。输出会带有 PDF/A、PDF/X、PDF/UA、PDF/E-1、PDF/R-1 或 PDF/VT-1 标记,但不再受打开源文件所用密码的保护

只有当下游有人不输入密码就打开这份“受保护”的归档副本,并发现它可以直接打开时,这个取舍才会显现。解决办法不是换一个方法调用:PDFiumPas 没有 saAddSecurity,无法与 saRemoveSecurity 配对,因为底层 PDFium 引擎从未设计为写入新的加密,只能移除加密。如果同一个文件必须同时满足这两个要求,就必须把加密作为由你负责的独立步骤,在合规标记之后执行,而不能把它折叠进同一个 SaveAsPdfA 调用中

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.Password := 'open-secret';           // needed to open the source at all
    Pdf.FileName := 'signed-encrypted-invoice.pdf';
    Pdf.LoadDocument;
    Pdf.SaveAsPdfA('invoice-pdfa.pdf', pac2b);
    // invoice-pdfa.pdf now declares PDF/A-2b, but SaveAs(saRemoveSecurity)
    // ran first inside SaveAsPdfA: the output opens without a password
  finally
    Pdf.Free;
  end;
end;

使用 PAdES 签名加密 PDF 时会发生什么

PDFiumPas 会直接拒绝,而不会像标记注入器那样静默放弃请求。TPdf.SignPadesSignPadesToStream 都会经过内部方法 SignPadesBytes,而该方法在解析源 trailer 后执行的第一步就是检查 /Encrypt。如果条目存在,它会抛出 EPadesCrypto,并携带消息“SignPadesBytes: the source document is encrypted; remove encryption before signing”,而不会继续执行。负责嵌入证书、OCSP 响应和 CRL 以支持长期验证的 InjectPadesDssMarkers 也会出于相同原因执行相同检查,并使用自己的消息“InjectPadesDssMarkers: the source document is encrypted; remove encryption before embedding DSS validation material”

这里的处理理由比标记注入器的原样传递更严格,而且这是有意为之。对于 PDF/A 标记,静默传递是安全的,因为跳过标记只会让你得到一个未标记的原始有效 PDF。签名不能这样悄然失败:对于只检查布尔结果的调用代码来说,一个被静默跳过的签名看起来与成功添加的签名完全一样。EPadesCrypto 继承自普通的 Exception 类,因此捕获它属于正常的异常处理,不需要学习任何特殊的控制流约定

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.Password := 'open-secret';
    Pdf.FileName := 'encrypted-contract.pdf';
    Pdf.LoadDocument;
    try
      Pdf.SignPades('encrypted-contract-signed.pdf', 'A1B2C3D4E5F6...');
    except
      on E: EPadesCrypto do
        // E.Message: 'SignPadesBytes: the source document is encrypted;
        // remove encryption before signing'
        raise Exception.Create('Decrypt the contract before signing: '+ E.Message);
    end;
  finally
    Pdf.Free;
  end;
end;

如何安排合规标记、签名和加密的顺序

实际的解决办法是安排顺序,而不是更换库。先应用 PDF/A、PDF/X、PDF/UA、PDF/E-1、PDF/R-1 或 PDF/VT-1 标记,接着添加 PAdES 签名,最后再运行流程中真正负责加密的步骤,无论那是专用 PDF 写入器、签名设备还是你自己的 AES 实现。PDFiumPas 的增量更新层很自然地处在这个顺序的中间,在基本完成的文件上追加小而明确的对象;加密之所以必须放在最后,正是因为它是整个链条中 PDFiumPas 自身无法执行或逆转的唯一操作

以上内容不会改变 PDFiumPas 读取每次增量更新都依赖的 trailer 和交叉引用数据的方式,而一旦出现 xref 流,这条路径本身就有不少细微之处;验证 PDF 的对象流和 xref 流一文介绍了同一条 trailer 读取路径如何处理 PDF 1.5 及更高版本中的压缩结构。当文档准备好进行强度高于合规标记的操作时,在 Delphi 中使用 PAdES B-B 签名签署 PDF会从本文结束的位置接手 SignPades

本文介绍的标记注入器和 SignPades 方法属于面向 Delphi 和 C++Builder 的 PDFium Component,该产品同时提供 PDFium 原生支持的渲染和只读检查能力