기술 문서

Delphi PDFlibPas로 암호화된 PDF 비밀번호 재시도하기

PDFlibPas는 암호화된 PDF에 대한 잘못된 비밀번호를 재시도할 때, 방금 실패한 TPDFDocument를 버리고 다음 시도를 위해 완전히 새로운 것을 만든다. 이는 최대 16번의 시도까지 실행되는 OnPassword 콜백(TPDFlibPasswordEvent)이 이끌며, 이후에는 포기한다. 이는 대부분의 Delphi 개발자가 처음 떠올리는 본능적인 접근과는 의도적으로 다른 방향이다: 이미 메모리에 있는 문서 객체를 유지하고 수정된 비밀번호를 넣어 처음부터 다시 시작하는 대신 제자리에서 다시 로드하는 것 말이다. v3.245.0에서 추가된 PDFlibPas의 재시도 루프는 실패한 비밀번호 시도가 뒤에 무엇을 남기는지에 특화된 이유로 정반대의 입장을 취한다. 그 배경 시나리오는 문서를 많이 다루는 대부분의 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 줄이 전체 객체 생명주기 계약을 한 문장으로 담고 있다. 실패한 문서는 설계상 자신의 반쯤 만들어진 파서 상태를 그대로 무덤까지 가져간다. 그리고 성공한 문서만이 TPDFlib이 호출자가 열어둔 모든 문서를 위해 유지하는 컬렉션인 FDocs에 추가된다. 거부된 시도의 어떤 것도 재시도 루프 밖에서 보이지 않는다: 반쯤 초기화된 리더도, 낡은 페이지 수도, 잘못된 키로 만들어진 암호 핸들러도 없다

PDFlibPas는 잘못된 비밀번호를 몇 번이나 재시도하는가?

PDFlibPas는 단일 LoadFromFile, LoadFromStream, LoadFromString 호출에 대해 총 16번의 시도를 허용하며, 호출 자체에 넘겨진 비밀번호를 첫 번째 시도로 센다. OnPassword는 오직 2번째부터 16번째 시도까지만 발생하는데, 이는 콜백을 최대 15번의 호출로 제한한다; 17번째 시도를 요청하면 PDFlibPas는 핸들러를 호출하지도 않고 거부한다. 어느 시점에서든 Retry를 기본값인 false로 남겨두거나, 올바른 비밀번호 없이 16번 시도를 모두 소진하면, LoadFromFile은 PDFlibPas의 거부된 비밀번호 코드인 404가 설정된 LastErrorCode와 함께 0을 반환한다. 이 상한은 정돈 이상의 이유로 존재한다: 무제한 재시도 루프는 하나의 오타 비밀번호를 로드를 실행 중인 어떤 스레드에 대한 우발적인 서비스 거부 공격으로 쉽게 바꾸는 방법이며, 특히 핸들러가 대화상자를 클릭하는 사람이 아니라 이전에 본 비밀번호 목록 같은 자동화된 무언가에 연결되어 있을 때 그렇다. PDFlibPas는 또한 핸들러 안에서 TPDFlib 인스턴스에 대해 호출된 Abort도 존중한다. Sender가 바로 그 객체로 도착하기 때문이며, 이는 비밀번호 대화상자의 취소 버튼 뒤에서 유용하고 Retry가 무엇으로 설정되었든 상관없이 다음 검사에서 재시도 루프를 멈춘다. 잘못된 비밀번호가 아닌 다른 이유로 실패하는 로드 — 예를 들어 손상된 크로스 레퍼런스 테이블 — 는 재시도 루프에 아예 들어가지 않는다: PDFlibPas는 LastErrorCode 401을 보고하고 첫 시도 후 멈추는데, 어떤 개수의 비밀번호 추측으로도 구조적으로 망가진 파일은 고쳐지지 않기 때문이다

재시도 루프는 파일, 스트림, 문자열에 대해 똑같이 동작하는가?

OnPassword 콜백과 16회 시도 상한은 LoadFromFile, LoadFromStream, LoadFromString 전체에 걸쳐 동일하게 동작하지만, 세 진입점은 시도 사이에 소스를 다르게 붙잡고 있다. 파일 경로는 다시 방문하기에 저렴한데, 각 시도가 그저 이름 붙은 파일을 다시 여는 것이기 때문이며, 문자열 소스는 이미 호출자 자신의 사본으로 메모리에 있으므로, 둘 다 시도 사이에 호출자의 도움이 전혀 필요 없다. 호출자가 제공한 스트림은 잠시 멈춰볼 가치가 있는 경우다: LoadFromStream은 첫 파싱 시도 전에 그 스트림을 위치 0으로 되감고 내부적으로 복사하므로, 이후의 모든 시도와 그 뒤의 새로 구성된 TPDFDocument는 실패한 파싱이 스트림의 위치를 어디에 남겨두었든 상관없이 그 내부 사본으로부터 재생한다. 비밀번호로 보호된 문서를 위해 PDFlibPas에 TFileStream이나 TMemoryStream을 건네면 재시도 사이에 되감을 필요가 전혀 없다; 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 라이브러리의 일부이며, LoadFromFile, LoadFromStream, LoadFromString이 이미 있는 어디에서나 사용할 수 있다. 비밀번호를 한 번 더 추측해야 하는 문서를 위해 별도의 모듈이나 라이선스 등급이 필요하지 않다