기술 문서

일부 AES-256 R6 PDF가 올바른 비밀번호를 거부하는 이유

AES-256 revision 6 PDF가 올바른 걸 아는 비밀번호를 거부할 때, 흔한 범인은 R6 경화 해시(ISO 32000-2 Algorithm 2.B)의 라운드 카운터입니다. Delphi용 PDFium Component는 v2.57.2 이전에 정확히 이 버그가 있었습니다: TPdf.SaveAsEncrypted가 라운드를 잘못된 베이스부터 세었고, 생산된 파일의 약 30%가 FPDF_LoadDocument에서 FPDF_ERR_PASSWORD로 거부되었습니다

성가신 부분은 산수가 아닙니다. 성가신 부분은 그 결함이 파일을 암호화한 다음 같은 코드로 검사하는 모든 테스트에 보이지 않았고, 단 한 번의 성공한 왕복은 아무것도 증명하지 않았다는 점입니다. 이 글은 Algorithm 2.B가 실제로 무엇을 요구하는지, 한 라운드 미끄러짐이 어디에 숨는지, 왜 일부 경우에만 실패하는지, 그리고 고객보다 먼저 이런 버그를 잡는 회귀 테스트를 만드는 방법을 짚어 갑니다

R6 해시 종료 규칙이 실제로 말하는 것은 무엇인가요?

Algorithm 2.B는 최소 64라운드 후에만 멈추고, 그다음에는 최근 AES 출력 E의 마지막 바이트가 라운드 번호 빼기 32 이하일 때만 멈춥니다. 여기서 라운드 번호는 이미 실행된 라운드의 1 기반 개수입니다. N번째 라운드 뒤에는 N과 비교합니다. N − 1도, 다음 라운드의 인덱스도 아닙니다. 그 정의 하나가 버그 표면 전부입니다

알고리즘의 나머지는 기계적입니다. K를 비밀번호, 솔트, (오너 키라면) 48바이트 /U 값의 SHA-256으로 시작합니다. 각 라운드는 K1을 password ‖ K ‖ UK를 64회 반복한 것으로 만들고, K의 처음 16바이트를 키로, 다음 16바이트를 IV로 써서 패딩 없는 AES-128-CBC로 암호화하고, E의 처음 16바이트를 빅엔디안 128비트 정수 mod 3으로 취해 그에 따라 SHA-256, SHA-384, SHA-512를 골라 다음 K를 만듭니다. 최종 해시는 K의 처음 32바이트입니다. ISO 32000-2 §7.6.4.3이 정의합니다; 데이터 의존 루프 길이는 의도된 것으로, GPU 크래커에게 비밀번호 추측당 작업량을 예측 불가능하게 만들기 때문입니다

다음은 루프의 올바른 형태로, 바로 쓰는 구현이 아니라 개념 설명으로 쓴 것입니다. BuildK1, AesCbcNoPad128, FirstBlockMod3, 해시 헬퍼들은 여러분 코드베이스가 이미 갖고 있는 프리미티브의 대역입니다

// 개념도: Algorithm 2.B 루프 뼈대 (ISO 32000-2, revision 6)
K := Sha256(Password + Salt + UK);
RoundNumber := 0;                 // 지금까지 실행된 라운드
repeat
  Inc(RoundNumber);               // 이것이 라운드 1, 2, 3 ...
  K1 := BuildK1(Password, K, UK); // (Password || K || UK)를 64회 반복
  E  := AesCbcNoPad128(Copy(K, 0, 16), Copy(K, 16, 16), K1);
  case FirstBlockMod3(E) of       // E의 처음 16바이트, 빅엔디안, mod 3
    0: K := Sha256(E);
    1: K := Sha384(E);
    2: K := Sha512(E);
  end;
until (RoundNumber >= 64) and (E[High(E)] <= RoundNumber - 32);
Result := Copy(K, 0, 32);

종료 줄을 카운터를 의식하면서 읽으세요. 증분이 본문 꼭대기에 있으므로, until 검사가 도는 시점에 RoundNumber는 완료된 라운드 수를 정확히 담고 있습니다. 표준이 라운드 번호라고 부르는 것이 바로 그것입니다

Delphi 이식본에서 off-by-one이 숨는 곳

PDFium Component의 버그는 −1에서 시작해 루프 꼭대기에서 증분하는 카운터였으므로, until 절에서 검사되는 값은 언제나 실행된 라운드 수 빼기 1이었습니다. 두 가지가 동시에 틀어집니다: 64라운드 최소치가 65가 되고, 바이트 임계값이 표준이 말하는 것보다 한 노치 더 타이트해집니다. 둘 다 중요한 경우에서 탈출 지점을 정확히 한 라운드 밀어냅니다

같은 미끄러짐에는 똑같이 그럴듯한 변장이 여럿 있습니다. 0에서 시작해 본문 바닥에서 증분하기 — 검사는 이미 증분 전 값을 기준으로 쓰여 있는 채로요. 0 기반 for 루프 인덱스를 비교에 재사용하기. 루프 변수가 ++ 전에 검사되는 C 레퍼런스 번역하기. 이것들은 각각 코드 리뷰에서 자연스럽게 읽히고, 각각 완벽하게 무작위처럼 보이는 해시를 만듭니다. 실제로 그렇기 때문입니다: 다른 어떤 리더도 계산하지 않는, 똑같이 그럴듯해 보이는 또 다른 SHA 출력일 뿐입니다. 고쳐진 FPdfEncrypt.pas의 CalculateR6Hash는 이제 1 기반 의미론을 못 박는 주석을 달고 있습니다. 틀린 버전은 틀려 보이지 않기 때문에 정확히 그렇습니다

AES-256 R6 PDF는 왜 일부 경우에만 실패할까요?

실패가 확률적인 이유는, 추가 라운드가 결과를 바꾸는 경우가 종료 바이트가 올바른 임계값과 밀린 임계값 사이의 한 라운드 창에 착지할 때뿐이기 때문입니다. 그 외의 모든 경우에서 두 버전은 같은 라운드에 탈출하고 같은 K를 만들므로, 파일은 멀쩡히 열립니다. PDFium Component 측정에서 60번의 암호화 중 18번(약 30%)이 표준 구현과 어긋나는 /U, /O, /UE, /OE 값을 만들었습니다; 나머지 70%는 바이트 단위로 정확했습니다

새로운 무작위 솔트가 저장마다 뽑히므로, 같은 문서와 같은 비밀번호가 한 번은 성공하고 다음엔 실패합니다. 지원 데스크에서 보면 불안정한 스토리지처럼, 깨진 업로드처럼, 비밀번호 오타처럼 보입니다. 열 장 중 일곱 장이 열릴 때 루프 카운터를 의심하는 사람은 없습니다. 자기 코드가 암호화한 PDF에서 간헐적인 "wrong password" 보고가, 그것도 revision 6에서만 들어온다면 가장 먼저 봐야 할 곳이 여기입니다

Revision 5는 반복 없는 단일 SHA-256 패스를 쓰므로 면역입니다. ISO/TS 32003 AESV4 경로는 그렇지 않습니다: TPdfEncryptionRevision 값 erR7는 비밀번호 해시에 Algorithm 2.B를 재사용하므로, 같은 수정이 ISO/TS 32003의 AES-256-GCM 암호화와 PDF MAC 무결성에서 다룬 GCM 파일까지 커버합니다

자기 일관성 함정: 왕복 테스트가 통과하는 이유

자기 암호화와 자기 검증을 잇는 왕복은 언제나 성공합니다. 양쪽이 같은 틀린 루프를 돌고 같은 틀린 해시에 합의하기 때문입니다. 결함은 남의 구현에 상대적으로만 존재하므로, 판사는 독립적이어야 합니다: pdfium.dll의 PDFium 엔진, Acrobat, qpdf, 여러분이 쓰지 않은 어떤 리더든요

암호 인접 문서 코드를 쓰는 사람 모두를 위한 불편한 일반 교훈입니다. 해시를 계산해 테스트 대상 함수의 출력과 비교하는 유닛 테스트는 결정론성을 증명하지 정확성을 증명하지 않습니다. 표준이나 레퍼런스 도구의 known-answer 벡터가 더 낫고, 교차 구현 왕복이 여러분이 얻는 그라운드 트루스에 가장 가까운 것입니다. PDFium Component는 여기서 운이 좋은 위치에 있습니다: 암호화는 Pascal의 EncryptDocumentBytes가 하고, 로드 시 복호화는 PDFium 자체의 C++ 보안 핸들러를 거치므로, TPdf로 저장된 파일을 다시 여는 것은 거울이 아니라 진짜 독립 검사입니다

uses
  SysUtils, PDFium, FPdfEncrypt;

procedure SaveR6(const Source, Target: string);
var
  Pdf: TPdf;
  Opts: TPdfEncryptOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := Source;
    Pdf.Active := True;
    Opts := TPdfEncryptOptions.Default;
    Opts.UserPassword := 'userpw';
    Opts.OwnerPassword := 'ownerpw';
    Opts.Revision := erR6;          // AESV3, Algorithm 2.B 비밀번호 해시
    if not Pdf.SaveAsEncrypted(Target, Opts) then
      raise Exception.Create('SaveAsEncrypted failed');
  finally
    Pdf.Free;
  end;
end;

TPdfEncryptOptions.Default는 모든 권한 허용, 메타데이터 암호화 상태의 revision 5에서 시작하므로, R6를 원하면 Revision을 명시적으로 설정하세요. 출력이 파일이 아니라 TStream으로 갈 때는 TPdf.SaveAsEncryptedToStream이 같은 옵션 레코드를 받습니다. 비밀번호는 옵션 레코드에서 AnsiString이라는 점에 주의하세요. 비 ASCII 자격 증명을 넘긴다면 중요합니다; 레코드에 닿기 전에 정규화하고 인코딩하세요

확률적 암호화 버그는 어떻게 회귀 테스트하나요?

교차 구현 왕복을 30% 실패율이 빠져나갈 수 없을 만큼 반복하세요. 단 한 번의 실행은 PDFium Component 버그를 약 70%의 확률로 놓칩니다. 새 솔트를 쓰는 20번의 실행은 미스 확률을 약 0.08%(0.7의 20제곱)로 밀어내고, 배포된 회귀 테스트가 한 번이 아니라 20번 도는 이유가 바로 그것입니다

procedure TEncryptTests.R6_EncryptedDocumentReopensWithUserPassword;
var
  Pdf: TPdf;
  I: Integer;
  Target: string;
begin
  Target := TempOutputPath('encrypt_r6_roundtrip.pdf');
  for I := 1 to 20 do
  begin
    SaveR6(SamplePdf, Target);       // 저장마다 새로운 솔트
    Pdf := TPdf.Create(nil);
    try
      Pdf.Password := 'userpw';
      Pdf.FileName := Target;
      Pdf.Active := True;            // PDFium이 /U를 독립적으로 검증
      AssertTrue('Run ' + IntToStr(I) + ' must reopen', Pdf.Active);
    finally
      Pdf.Free;
    end;
  end;
end;

거기에 부정 케이스, 실패해야 할 틀린 비밀번호를 짝지으세요. 그래야 망가진 로더가 긍정 테스트를 공허하게 통과시킬 수 없습니다. 그리고 Lazarus로 빌드한다면 Lib\*.pas를 건드린 뒤 테스트 프로젝트를 돌리기 전에 라이브러리 패키지를 다시 빌드하세요. 테스트 빌드는 패키지의 미리 컴파일된 .ppu 유닛을 링크하므로, 낡은 오브젝트 코드로 돌리면 실제로 고치지 않은 버그에서 안심되는 초록불을 줍니다. 시딩도 같은 패스에서 점검할 가치가 있는 관련 함정입니다: FPC Random이 상수 암호화 키를 만드는 문제는 출력이 무작위처럼 보이지만 조용히 틀린 또 하나의 사례입니다

FAQ

R6 라운드 카운터 버그는 revision 5로 암호화된 파일에 영향을 주나요?

아니요. Revision 5는 단일 SHA-256 패스로 비밀번호를 해시하고 반복 횟수가 없으므로 틀릴 라운드 카운터가 없습니다. 둘 다 Algorithm 2.B를 쓰는 revision 6와 ISO/TS 32003 AESV4 revision만 영향받습니다

버그 있는 버전이 쓴 파일도 여전히 열 수 있나요?

약 70%는 해시가 우연히 일치해서 정상적으로 열립니다. 나머지는 어떤 표준 리더도 재현하지 않는 /U와 /O 값을 담고 있어 비밀번호 검사가 어디서든 실패합니다; 실용적인 해법은 v2.57.2 이상으로 평문 소스에서 재암호화하는 것입니다

내 복호화 코드가 아니라 PDFium으로 테스트하는 이유는 무엇인가요?

자기 복호화기는 암호화기와 같은 Algorithm 2.B 구현을 공유하므로 틀린 라운드 수가 상쇄되고 모든 왕복이 통과합니다. PDFium의 보안 핸들러 같은 독립 구현만이 여러분과 불일치할 수 있는 검사입니다

암호화, PDF MAC 검증, 저장 파이프라인의 나머지는 Delphi와 Lazarus 양쪽의 소스와 회귀 테스트와 함께 Delphi 및 C++Builder용 PDFium Component에 들어 있습니다