技術記事

DelphiでPDFlibPasを使って暗号化PDFのパスワードを再試行する

PDFlibPasは、暗号化されたPDFに対する間違ったパスワードを、たった今失敗したTPDFDocumentを破棄し、次の試行のために全く新しいものを作成することで再試行する。これは、TPDFlibPasswordEventというOnPasswordコールバックによって駆動され、あきらめる前に最大16回試行を実行する。これは、ほとんどのDelphi開発者が最初に手を伸ばす本能からの意図的な逸脱である:すでにメモリ上に座っている文書オブジェクトを保持し、修正されたパスワードをそれに供給し、ゼロから始め直すのではなくその場で再度ロードすることだ。v3.245.0で追加されたPDFlibPasの再試行ループは、失敗したパスワード試行が背後に残すものに特有の理由から、正反対の立場を取る。その背後にあるシナリオは十分に普通であり、文書を多用するほとんどのDelphiアプリケーションは結局それにぶつかる:入力画面がPDFを受け付け、暗号化されたトレーラーがパスワードダイアログを強制し、オペレーターが文字列を打ち間違え、そのダイアログが2回目の試行のために再び現れる。そのユーザー体験には何も異常なところはない。そのためその背後のコードは同じファイルに対して複数の候補パスワードを受け入れなければならず、それを安全に行わなければならない。拒否された試行から後続の試行へと状態を漏らすことなくである

なぜ同じ文書オブジェクトで単純に再試行できないのか

パスワードの試行にわたってTPDFDocumentを再利用することは機能しない。なぜなら失敗した試行は、そのオブジェクトを一時停止した再開可能な状態に残すのではなく、すでに内部でそれを取り壊しているからだ。暗号化されたPDFを開くということは、相互参照テーブルを解析し、基礎となるソースの上にリーダーを構築し、供給されたどんなパスワードからも暗号ハンドラを構築することを意味し、これらすべては、PDFlibPasがそのパスワードが正しいかどうかをテストできるようになる前に行われる。パスワードが間違っていることが判明すると、文書の内部ロードルーチンは、それが正しくそうすべきように、失敗の一部としてリーダー、相互参照テーブル、暗号ハンドラをクリーンアップする。つまり、2回目の呼び出しで修正されたパスワードを待っている半分だけ構築されたパーサーは存在しない。それでも同じオブジェクトを別のロード試行に通すと、失敗モードはまさにデバッグするのが悲惨な種類のものになる:異なる、すでに失敗した解析のために構築された内部状態からエラーが表面化し、それが3回呼び出し上流のパスワードを明白に指し示すものは何もない。PDFlibPasは、一度開くことに失敗した文書オブジェクトを決して回復しようとしないことで、この問題のクラス全体を回避する:あらゆる試行は、リーダーと相互参照テーブルを含めて、間違ったパスワードを一度も見たことのない文書を得る

OnPasswordコールバックはどうやって次のパスワードを求めるのか

TPDFlibPasswordEventは、たった今試したパスワードが間違っていることが判明するたびに、PDFlibPasがTPDFlib.LoadFromFileLoadFromStreamLoadFromStringを通じて呼び出すコールバック型であり、それはハンドラに3つのものを渡す:どの試行が実行されようとしているか、次の候補で上書きするための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呼び出しに渡されたパスワードは試行1としてカウントされる。そのためOnPasswordが一度でも発火する最初の時点で、AttemptNumberは2として到着する。Retryを未設定のままにすると、ロードはLastErrorCode 404できれいに失敗する;それをtrueに設定すると、PDFlibPasはハンドラがたった今Passwordに書き込んだものを使って再度試みる

再試行ループの内部:試行ごとに新しいTPDFDocument

内部的には、PDFlibPasはLoadFromFileLoadFromStreamLoadFromStringに対してオブジェクトライフサイクルの問題に同じ方法で答える:最初の試行を含むあらゆる試行は、新しい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という行が、オブジェクトライフサイクルの契約全体を1文で表している。失敗する文書は、設計上、その半分だけ構築されたパーサー状態を墓場まで持っていき、成功する文書だけが、呼び出し元が開いているすべての文書のためにTPDFlibが保持するコレクションであるFDocsに追加される唯一のものである。拒否された試行について再試行ループの外から見えるものは何もない:半分だけ初期化されたリーダーも、古びたページ数も、間違った鍵から構築された暗号ハンドラも見えない

PDFlibPasは間違ったパスワードを何回まで再試行するのか

PDFlibPasは、単一のLoadFromFileLoadFromStreamLoadFromString呼び出しに対して合計16回の試行を許し、呼び出し自体に渡されたパスワードを試行1としてカウントする。OnPasswordは試行2から16まででしか発火せず、コールバックを15回の呼び出しに制限する;17回目の試行を求めても、PDFlibPasはハンドラを呼び出しさえせずに拒否する。どの時点でもRetryを既定のfalseのままにするか、正しいパスワードなしに16回すべての試行を使い果たすと、LoadFromFileは0を返し、LastErrorCodeには拒否されたパスワードを表すPDFlibPasのコード404が設定される。この上限は整頓以上の理由で存在する:無制限の再試行ループは、1つの打ち間違えたパスワードを、そのロードを実行しているどんなスレッドに対する偶発的なサービス拒否にも変えてしまう簡単な方法である。特にハンドラが、ダイアログをクリックする人間ではなく、以前に見たパスワードのリストのような何か自動化されたものに配線されている場合はなおさらだ。PDFlibPasはまた、ハンドラの内部からTPDFlibインスタンスに対して呼ばれるAbortも尊重する、なぜならSenderはその同じオブジェクトとして到着するからだ。これはパスワードダイアログの背後にあるキャンセルボタンの背後で有用であり、Retryが何に設定されていたかにかかわらず次のチェックで再試行ループを止める。間違ったパスワード以外の理由で失敗するロード、たとえば損傷した相互参照テーブルの場合、再試行ループに一切入らない:PDFlibPasはLastErrorCode 401を報告し最初の試行の後で止まる、なぜならどれだけパスワードを推測しても構造的に壊れたファイルは直らないからだ

再試行ループはファイル、ストリーム、文字列で同じように機能するのか

OnPasswordコールバックと16回の試行上限は、LoadFromFileLoadFromStreamLoadFromStringにわたって同一に振る舞うが、この3つのエントリポイントは試行の間にそのソースを異なる方法で保持する。ファイルパスは再訪するのが安価である、なぜなら各試行は単に名前付きファイルを再度開くだけだからだ。そして文字列ソースはすでに呼び出し元自身のコピーとしてメモリ上に座っているため、どちらも試行の間に呼び出し元からの助けを一切必要としない。呼び出し元が提供するストリームは一時停止して考える価値のある唯一のケースである:LoadFromStreamは最初の解析試行の前にそのストリームを位置ゼロにシークし戻し、内部でそれをコピーする。そのため後続のすべての試行、そしてその背後にある新しく構築されたTPDFDocumentは、失敗した解析がストリームの位置をどこに残そうとも、その内部コピーから再生する。パスワード保護された文書のためにTFileStreamTMemoryStreamを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;

RegisterIntakeDocumentは、LoadFromFileが1を返した後にだけLibを受け取る。つまりそのやり取りの中の何らかのパスワードが実際にそのファイルの暗号ハンドラに対して検証されたことを意味する;拒否された試行はその行に決して到達せず、半分だけ開いた文書もそうだ。このような文書が開いたことが確認された後に次に来るものは、機能したパスワードがセキュリティの話のすべてだという仮定ではなく、その保護設定をもう一度見る価値がある:文書の/Encrypt辞書が実際に何を宣言しているかを監査するは、このようなファイルが読み込まれた後にPDFlibPasが公開するアルゴリズム、リビジョン、権限ビットを読む方法を扱っている

パスワード再試行はまた、PDFlibPasがその解析層全体に適用するより広い規律の狭い一例でもある:まだ自身を証明していないファイルは、どのパスワードがそれを解錠するかという問題であれ、その内部の長さフィールドが必要とするバッファサイズについて嘘をついているかという問題であれ、疑わしきは罰せずの恩恵を受けない。悪意のあるファイルに対してPascal PDFパーサーを強化するは、その規律のもう半分、すなわち受信PDF内のあらゆるフォントプログラムと画像ストリームを、単にパスワードを忘れただけの整形式文書としてではなく敵対的な入力として扱うデコーダを扱っている

OnPasswordとその背後の再試行ループは、DelphiおよびC++Builder向け標準PDFlibPas PDFライブラリの一部であり、LoadFromFileLoadFromStreamLoadFromStringがすでに利用可能などこでも利用でき、パスワードにもう一度推測が必要なだけの文書のために別個のモジュールやライセンス階層は一切必要ない