技术文章

Delphi 中使用 PDFlibPas 重试加密 PDF 密码

PDFlibPas 在加密 PDF 中遇到错误密码时,会丢弃刚刚失败的 TPDFDocument,为下一次尝试创建全新的对象,并由 OnPassword 回调(TPDFlibPasswordEvent)驱动,最多尝试十六次后放弃。这有意背离了大多数 Delphi 开发者首先会采用的直觉:保留已经位于内存中的文档对象,填入修正后的密码,在原对象上重新加载,而不是从零开始。PDFlibPas 在 v3.245.0 中加入的重试循环采取了相反的做法,原因在于失败的密码尝试会留下特定的内部状态。这个场景足够普通,大多数以文档为核心的 Delphi 应用迟早都会遇到:接收 PDF 的输入界面弹出,加密文件尾部要求输入密码,操作员错误输入字符串,然后对话框再次出现等待第二次尝试。这样的用户体验并不特殊,因此背后的代码必须允许同一个文件接受多个候选密码,而且必须安全完成,不能让被拒绝尝试的状态泄漏到下一次尝试中

为什么不能直接在同一个文档对象上重试

在多次密码尝试之间复用 TPDFDocument 不会生效,因为失败的尝试已经在内部拆除了该对象,而不是让它停留在某种暂停且可恢复的状态。打开加密 PDF 意味着解析交叉引用表、在底层源上构建读取器,并根据所提供的密码构造加密处理器,所有这些都发生在 PDFlibPas 能够判断密码是否正确之前。当密码被判定为错误时,文档的内部加载例程会在失败退出的过程中清理读取器、交叉引用表和加密处理器,这正是应有的行为,也意味着不存在一个半成品解析器等待在第二次调用中接收修正后的密码。无论如何驱动同一个对象再次加载,都会进入极难调试的失败模式:错误从为另一个已经失败的解析所构建的内部状态中浮现,表面上没有任何信息明显指向三次调用之前的密码。PDFlibPas 通过绝不尝试恢复打开失败的文档对象来避开整类问题,每次尝试都会获得一个从未见过错误密码的文档,读取器和交叉引用表也一并全新创建

OnPassword 回调如何请求下一个密码

TPDFlibPasswordEvent 是 PDFlibPas 通过 TPDFlib.LoadFromFile、LoadFromStream 和 LoadFromString 调用的回调类型,只要刚刚尝试的密码被判定为错误就会触发,并向处理程序提供三项内容:即将运行的尝试编号、用于覆盖下一个候选值的 Password 参数,以及默认值为 false 的 Retry 标志

TPDFlibPasswordEvent = procedure(Sender: TObject; AttemptNumber: Integer;
  var Password: WideString; var Retry: Boolean) of object;

property OnPassword: TPDFlibPasswordEvent read FOnPassword write FOnPassword;

传入原始 LoadFromFile 调用的密码计为第一次尝试,因此 OnPassword 首次触发时,AttemptNumber 会是 2。如果不设置 Retry,加载会以 LastErrorCode 404 干净地失败;将它设为 true 后,PDFlibPas 会使用处理程序刚刚写入 Password 的值再次尝试

重试循环内部:每次尝试都创建新的 TPDFDocument

在内部,PDFlibPas 对 LoadFromFile、LoadFromStream 和 LoadFromString 采用相同的对象生命周期策略:每次尝试,包括第一次,都会创建全新的 TPDFDocument,使用该次尝试的密码完成完整打开流程,只有密码验证成功时才保留对象。被拒绝尝试的 TPDFDocument 会立即释放,其读取器、交叉引用表和加密处理器也随之销毁,下一次尝试则从一个完全没有历史记录的对象开始

// Simplified excerpt from inside LoadFromFile: every attempt gets a
// document that has never seen a previously rejected password. FileName,
// AttemptNumber and AttemptPassword come from the enclosing method.
Var
  Doc: TPDFDocument;
  LoadResult: TPLLoadResult;
  Success: Boolean;
Begin
  Success := False;
  Repeat
    Doc := TPDFDocument.Create;
    Doc.DecodeMode := FDefaultDecodeMode;
    Try
      LoadResult := Doc.LoadFromFile(FileName, AttemptPassword);
      Success := LoadResult = lrOkay;
      if Success then
      begin
        FDocs.Add(Doc);            // hand the verified document to the
        Doc := nil;                 // caller's collection; skip the Free below
      end;
    Finally
      Doc.Free;                     // a rejected attempt's reader, xref table
    End;                            // and crypt handler are torn down right here
    if Success or (LoadResult <> lrWrongPassword) then
      Break;                        // success, or a non-password failure: stop
    Inc(AttemptNumber);
  Until not RequestPasswordRetry(AttemptNumber, AttemptPassword);
End;

Finally 代码块之前的 Doc := nil 一行,正是整个对象生命周期契约的集中体现。失败的文档会按照设计将半成品解析器状态一同带入销毁过程,而成功的文档才会被加入 FDocs,也就是 TPDFlib 为调用者保持的所有已打开文档集合。被拒绝的尝试不会从重试循环外部暴露任何内容:没有半初始化的读取器,没有过时的页面数,也没有由错误密钥构建的加密处理器

PDFlibPas 会重试错误密码多少次

对于一次 LoadFromFile、LoadFromStream 或 LoadFromString 调用,PDFlibPas 允许总共十六次尝试,并把传入调用的密码计为第一次尝试。OnPassword 只会在第二次到第十六次尝试时触发,因此回调最多被调用十五次;如果请求第十七次尝试,PDFlibPas 会直接拒绝,甚至不会调用处理程序。在任何时刻都不设置 Retry,或十六次尝试全部用尽仍没有正确密码时,LoadFromFile 会返回 0,并将 LastErrorCode 设为 404,这是 PDFlibPas 表示密码被拒绝的代码。这个上限的原因不只是整洁:无界重试循环很容易把一次密码输入错误变成对执行加载的线程的意外拒绝服务,尤其当处理程序连接到自动化来源时,例如已经见过的密码列表,而不是由人逐次点击对话框。PDFlibPas 还支持在处理程序内部对 TPDFlib 实例调用 Abort,因为 Sender 正是同一个对象,这对密码对话框背后的取消按钮很有用,并且无论 Retry 被设置成什么值,都会在下一次检查时停止重试循环。因错误密码之外的原因而失败的加载,例如交叉引用表损坏,根本不会进入重试循环:PDFlibPas 会报告 LastErrorCode 401,并在第一次尝试后停止,因为猜多少次密码都无法修复结构损坏的文件

文件、流和字符串的重试循环是否相同

在 LoadFromFile、LoadFromStream 和 LoadFromString 中,OnPassword 回调和十六次尝试上限的行为完全相同,但三个入口点在尝试之间保留源的方式不同。文件路径很容易再次访问,因为每次尝试只需重新打开指定文件;字符串源已经作为调用者自己的副本位于内存中,因此这两种情况都不需要调用者在尝试之间提供帮助。调用者提供的流是唯一值得特别说明的情况:LoadFromStream 会把流定位回零,并在第一次解析尝试前将其复制到内部,因此后续每次尝试以及背后新创建的 TPDFDocument,都会从该内部副本重放,而不是从失败解析改变流位置后的地方继续。将受密码保护的文档通过 TFileStream 或 TMemoryStream 交给 PDFlibPas 时,无需在重试之间回退流位置;PDFlibPas 已经处理了第一次失败尝试可能移动过的位置

将密码重试接入文档接收界面

文档接收流程是这个回调的自然归宿,因为它正是 OnPassword 要解决的问题形态:文件从应用程序外部到达,密码事先无法确定,提供候选密码的人需要多次猜测,而周围代码不必自行围绕 LoadFromFile 编写重试循环

procedure TIntakeForm.SupplyPassword(Sender: TObject; AttemptNumber: Integer;
  var Password: WideString; var Retry: Boolean);
var
  Typed: string;
begin
  // AttemptNumber counts from 2: the password already tried was attempt 1.
  Typed := '';
  Retry := InputQuery('Password required',
    Format('Attempt %d of 16 - enter the document password', [AttemptNumber]), Typed);
  if Retry then
    Password := Typed;
  // Retry is False when the operator cancels, which leaves
  // LastErrorCode at 404 for the caller to report.
end;
procedure TIntakeForm.LoadInboundDocument;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.OnPassword := SupplyPassword;
    if Lib.LoadFromFile('inbound-invoice.pdf', '') = 1 then
      RegisterIntakeDocument(Lib)        // only a verified document reaches here
    else
      LogRejectedIntake('inbound-invoice.pdf', Lib.LastErrorCode);
  finally
    Lib.Free;
  end;
end;

只有当 LoadFromFile 返回 1 时,RegisterIntakeDocument 才会收到 Lib,这意味着这次交互中的某个密码确实通过了文件加密处理器的验证;被拒绝的尝试不会到达这一行,半打开的文档也不会。文档确认打开后,下一步值得重新检查其保护设置,而不是假定成功的密码就是完整的安全说明:审查文档的 /Encrypt 字典实际声明的内容 会介绍如何读取 PDFlibPas 在此类文件加载后公开的算法、修订版本和权限位

密码重试也是 PDFlibPas 在整个解析层贯彻的更广泛原则的一个小例子:尚未证明自身可靠的文件不会获得任何信任,无论问题是哪个密码可以解锁它,还是文件中的长度字段是否谎报了所需缓冲区的大小。增强 Pascal PDF 解析器对恶意文件的防护 介绍了这一原则的另一面,即把传入 PDF 中的每个字体程序和图像流都当作对手输入,而不是一个只是忘记密码的格式良好文档

OnPassword 及其背后的重试循环属于标准的面向 Delphi 和 C++Builder 的 PDFlibPas PDF 库,只要已经可以使用 LoadFromFile、LoadFromStream 或 LoadFromString,就可以直接使用,无需为只需要再次猜测密码的文档另行安装模块或购买许可层级