기술 문서

Delphi에서 서명된 AES-256 PDF가 느린 이유와 HotPDF 수정

HotPDF는 Delphi에서 서명되고 AES-256으로 암호화된 PDF를 예전보다 훨씬 빨리 적재합니다. 적재 경로에서 세 가지 별개의 비용이 제거됐기 때문입니다. 소스 스트림의 버퍼 없는 토큰 파싱, 복호화 중 ISO 32000-2 알고리즘 2.A 비밀번호 검사의 두 번째 실행, 그리고 R6 강화 해시 안의 느린 내부 AES-128-CBC 루프입니다. Delphi 타깃에서는 R6 수정 하나만으로 문서 적재가 약 66 ms에서 16 ms로 줄었습니다

이 셋 모두 대부분의 테스트 스위트가 쓰는 작은 테스트 파일에는 나타나지 않았습니다. 한두 킬로바이트짜리 서명 없고 암호화되지 않은 샘플은 파서가 뭘 하든 몇 백분의 일 초에 열립니다. 문제는 고객이 실제로 보내는 문서에서 시작됩니다. 내장 CMS 서명이 들어 있고 35 KB 정도이며 revision 6 비밀번호로 보호되는 계약서죠. 이 조합은 비싼 코드 경로를 전부 동시에 켜고, 각각은 필요 이상으로 느렸던 나름의 이유가 있었습니다. 이 글은 발견된 순서대로 그것들을 짚고, 서명 검증 결과가 메모이제이션되기 시작하면서 생긴 캐시 키 실수들까지 다룹니다

34 KB 서명 PDF는 왜 적재에 170 ms가 걸릴까

34 KB 서명 PDF가 적재에 170 ms 걸린 이유는 파서가 벌거벗은 THandleStream을 직접 읽어서 토큰 바이트마다 자체 ReadFile 시스템 콜로 변했기 때문입니다. PDF 토크나이저는 구분자를 찾는 동안 한 바이트씩 전진하므로, 버퍼 없는 핸들 스트림은 값싼 메모리 스캔을 수천 번의 커널 전환으로 바꿉니다

디테일이 이 버그가 살아남은 이유를 설명합니다. THotPDF.LoadFromFile은 오랫동안 파일 소스를 내부 읽기 버퍼로 감싸 왔고, AutoDecrypt 프로브는 56 MB 파일의 크기를 1.4초 동안 다시 묻던 끝에 같은 이유로 이미 고쳐져 있었습니다. 그러나 서명되고 암호화된 문서는 다른 경로를 탑니다. 호출자가 자기 스트림을 해제한 뒤에도 지연 객체 읽기와 서명 검사가 항상 같은 불변 리비전을 보게 하려고 THotPDF.LoadFromStream은 소스의 스냅샷을 떠서 두 번째로 파싱합니다. 그 두 번째 파싱이, 그리고 인증된 리비전에 쓰이는 재사용 적재가, 앞에 버퍼 없이 스냅샷 핸들을 읽었습니다. 둘 다 이제 파일 경로가 쓰는 것과 같은 내부 버퍼드 리더를 거칩니다

Delphi에서 HotPDF의 서명된 AES-256 암호화 PDF 적재 파이프라인: 스냅샷 스트림, 버퍼드 구조 파싱, 알고리즘 2.A 프리스캔, 회복된 키를 재사용하는 단일 복호화, 네이티브 AES-128-CBC를 부르는 R6 강화 해시로 적재가 66 ms에서 16 ms로 단축됨
세 수정은 한 경로에 쌓입니다. 버퍼링은 수천 번의 시스템 콜을 없애고, 프리스캔된 키는 스코프 플래그 아래에서 재사용되며, R6 내부 루프는 네이티브 CBC 백엔드에 도달합니다

그 버퍼는 내부 것이므로 여러분이 직접 만들 일은 없습니다. 통제하는 것은 LoadFromStream에 넘기는 스트림이며, HotPDF는 첫 구조 패스 동안 그 스트림을 있는 그대로 읽습니다. 데이터가 이미 메모리에 있으면 TMemoryStream을 넘기십시오. 디스크에 있으면 LoadFromFile을 호출하거나, Delphi 10.1 Berlin 이상이라면 로더에 raw TFileStream 대신 TBufferedFileStream을 주십시오

uses
  System.SysUtils, System.Classes, HPDFDoc;

function LoadEncryptedFromBytes(const FileName: string;
  const Password: AnsiString): THotPDF;
var
  Mem: TMemoryStream;
begin
  Result := THotPDF.Create(nil);
  Mem := TMemoryStream.Create;
  try
    Mem.LoadFromFile(FileName);   // 토큰마다 읽는 대신 한 번에 크게 읽기
    Mem.Position := 0;
    // LoadFromStream은 페이지 수를 반환, 문서가 적재되지 않으면 0
    if Result.LoadFromStream(Mem, Password) < 1 then
      raise Exception.Create('Could not load ' + FileName);
  except
    Result.Free;
    Mem.Free;
    raise;
  end;
  Mem.Free;
end;

적재 직후 메모리 스트림을 해제하는 것은 서명되고 암호화된 입력에 안전합니다. 컴포넌트가 이미 바이트의 자체 스냅샷을 들고 있기 때문입니다. 그래도 나중에 서명을 검증할 계획이라면 원본 파일이나 버퍼는 보관하십시오. 검증은 복호화된 객체 모델이 아니라 파일의 raw /ByteRange 세그먼트를 해싱합니다

적재와 검증을 별개 위상으로 측정하기

적재 시간과 서명 검증 시간은 따로 재야 합니다. 둘은 다른 방식으로 실패하고 다른 곳에서 고쳐지기 때문입니다. 적재 위상은 파싱, 비밀번호 검사, 복호화를 담당하고, 검증 위상은 서명된 바이트 범위를 다시 해싱하고 CMS 서명을 검사합니다. 하나의 숫자로 접으면 어느 한쪽의 퇴보가 다른 쪽 안에 숨습니다

System.Diagnostics의 RTL TStopwatch면 충분합니다. 아래 하니스는 THotPDF.LoadFromFile의 비밀번호 오버로드로 적재한 뒤 VerifyLoadedSignature의 TStream 오버로드로 첫 서명을 검증합니다. 스트림 오버로드가 암호화된 파일에 맞는 선택입니다. 비밀번호 적재 경로는 매개변수 없는 오버로드가 다시 열 소스 파일 이름을 보관하지 않기 때문입니다

uses
  System.SysUtils, System.Classes, System.Diagnostics,
  HPDFDoc, HPDFSignVerify;

procedure TimeSignedEncryptedLoad(const FileName: string;
  const Password: AnsiString);
var
  Pdf: THotPDF;
  Src: TFileStream;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  Watch: TStopwatch;
  Pages: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Watch := TStopwatch.StartNew;
    Pages := Pdf.LoadFromFile(FileName, Password);
    Writeln(Format('Load + decrypt: %d ms, %d pages, encrypted=%s',
      [Watch.ElapsedMilliseconds, Pages, BoolToStr(Pdf.IsEncrypted, True)]));
    if (Pages < 1) or (Pdf.GetLoadedSignatureFieldCount = 0) then
      Exit;

    Src := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
    try
      Watch := TStopwatch.StartNew;
      Status := Pdf.VerifyLoadedSignature(0, Src, Info);
      Writeln(Format('Verify signature 0: %d ms, status %d, signer %s',
        [Watch.ElapsedMilliseconds, Ord(Status), string(Info.SignerName)]));
    finally
      Src.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

몇 번 돌리고 첫 실행은 무시하십시오. 첫 호출은 AES 테이블 구축과 자기검사 실행 같은 일회성 설정 비용을 치르고, 디스크 캐싱도 첫 읽기를 비뚤게 만듭니다. 찾는 것은 실제 고객 파일의 정상 상태 숫자이며, 같은 문서의 서명 없고 암호화되지 않은 사본과 비교해 측정합니다

AES-256 비밀번호는 왜 두 번 유도됐을까

HotPDF가 AES-256 파일 키를 두 번 유도한 이유는 같은 적재의 두 단계가 각자 전체 비밀번호 검사를 돌렸기 때문입니다. 구조 파싱 전에 RecoverReloadKeysFromStreamRanges가 raw 바이트를 프리스캔해 필터 디코딩 중에 암호화된 객체 스트림 컨테이너를 복호화할 수 있게 합니다. 그 프리스캔은 이미 ISO 32000-2 §7.6.4.3.3 알고리즘 2.A 검사를 수행하며, 사용자 및 소유자 솔트에 대해 비밀번호를 검증하고 파일 암호화 키를 회복합니다. DecryptLoadedDocument는 그 결과를 무시하고 전체 유도를 다시 했습니다

revision 4 이하에서는 중복 비용이 MD5 루프 이상이 아니었습니다. revision 6에서는 알고리즘 2.A가 알고리즘 2.B 강화 해시를 부르므로, 중복이 파일을 여는 데 가장 비싼 부분을 두 배로 만들었습니다. 회복된 키를 재사용하는 것은 간단합니다. 안전하게 재사용하는 것이 더 어려운 부분인데, 자기 비밀번호 검사보다 오래 사는 캐시 키는 인증 우회로 변하기 때문입니다

수정은 LoadFromFile과 LoadFromStream의 비밀번호 오버로드 안에서 프리스캔과 복호화 호출 사이에만 참인 스코프 플래그로 재사용을 제한하고, finally 블록에서 해제합니다. 플래그는 프리스캔이 실제로 비밀번호를 검증했을 때만 설정됩니다. 평범한 적재 뒤의 DecryptLoadedDocument 직접 호출은 항상 전체 유도를 실행합니다. 그 비밀번호는 프리스캔된 적이 없으니까요. 이 제한이 없으면 이전 성공한 열기의 낡은 재적재 키가 잘못된 비밀번호를 준 나중 호출자에게 문서를 조용히 복호화해 줄 수 있고, 이건 느린 적재보다 훨씬 나쁜 버그입니다

R6 강화 해시는 시간을 어디에 쓰나

R6 강화 해시는 시간의 거의 전부를 AES-128-CBC 암호화에 씁니다. ISO 32000-2 §7.6.4.3.4 알고리즘 2.B는 라운드를 최소 64번 반복합니다. 각 라운드는 비밀번호와 현재 해시, 소유자 쪽이라면 /U 문자열의 64벌 버퍼를 만들고, 해시의 첫 16바이트를 키로 다음 16바이트를 IV로 삼아 그 버퍼를 AES-128-CBC로 암호화하며, 암호문 첫 16바이트를 3으로 나눈 나머지 수로 읽어 다음 해시로 SHA-256, SHA-384, SHA-512 중 하나를 고릅니다. 해시는 짧습니다. 암호화되는 버퍼는 라운드당 수 킬로바이트입니다

Delphi용 HotPDF R6 알고리즘 2.B 강화 해시 루프 다이어그램: 최소 64라운드 각각이 비밀번호, 해시, /U 문자열의 64벌 버퍼를 만들고 해시를 키와 IV로 삼아 AES-128-CBC로 암호화하며, 첫 암호문 바이트에서 SHA-256, SHA-384, SHA-512를 고름
R6 비용은 다이제스트가 아니라 AES-128-CBC 버퍼 암호화에 있습니다. 그래서 PDF20HashDanceR6 안의 네이티브 CBC 루틴이 전체 문서 적재를 약 66 ms에서 16 ms로 끌어내립니다

그 루프의 HotPDF 구현인 PDF20HashDanceR6는 예전에 바이트 지향 레퍼런스 AES로 버퍼를 한 블록씩 암호화했습니다. 이제는 먼저 네이티브 무패딩 CBC 루틴을 시도하는데, Delphi 타깃에서는 링크된 C AES 백엔드에 도달합니다. 이 변화로 전체 문서 적재가 약 66 ms에서 16 ms로 떨어졌습니다. 네이티브 루틴이 없거나 맞지 않는 길이를 반환하면 코드는 역사적인 블록 루프로 폴백하고, 그 루프는 차례로 기존 S-box에서 T-테이블을 생성하는 테이블 기반 AES-128 블록을 쓸 수 있습니다. 암호는 모든 경우에 같으므로, 어느 경로가 계산했든 유도된 키는 바이트 단위로 동일합니다

이 글은 속도에 관한 것입니다. 알고리즘 2.B 종료 규칙을 맞게 구현하는 건 별개의 정확성 문제이고, 거기서 하나 어긋나면 올바른 비밀번호가 때때로만 거부되는 식이 됩니다. R5 대 R6의 설정 쪽은 HotPDF로 AES-256 PDF 암호화 설정하기 가이드가 다룹니다

빠른 AES 블록이 당신을 속이는 네 가지

테이블 기반 AES 블록은 잘못된 암호문을 만들면서도 자기 자기검사는 통과할 수 있습니다. 아래 네 함정은 모두 빠른 경로를 만드는 동안 실제로 나왔고, 둘은 처음 설계된 자기검사를 통과해 버렸습니다

  • 자기검사 재진입. 준비 루틴은 자기검사를 실행하기 전에 초기화 플래그를 세우므로, 검사 중에 재진입한 호출은 레퍼런스 경로를 탑니다. 그래서 첫 버전의 검사는 레퍼런스 구현과 자기 자신을 비교했습니다. 수정은 자기검사가 직접 부르는 보호받지 않은 raw 빠른 블록입니다. 실제로 검사되는 코드가 테이블 경로가 되도록요
  • 뒤바뀐 테이블 배치. 네 라운드 테이블 중 둘(Te1과 Te3)의 바이트 순서를 바꾸면 재진입 자기검사가 알아챌 방법이 없는 잘못된 암호문이 나옵니다. 잡아낸 것은 key 000102…0f와 plaintext 00112233…ff에 대한 FIPS-197 부록 C.1 암호문 69c4e0d86a7b0430d8cdb78070b4c55a를 출력하는 독립 프로브 프로그램이었습니다. 자기검사는 이제 그 벡터와 레퍼런스 블록과의 바이트 동일성을 둘 다 요구한 뒤에야 빠른 경로를 신뢰합니다
  • FPC 범위 및 오버플로 검사. -Cr과 -Co를 켠 Free Pascal에서는 Delphi 스타일의 명시적 Byte() 변환과 바이트 폭 시프트 연산이 범위나 오버플로 예외를 던집니다. 테이블 인덱스와 GF(2^8) 배더는 이제 네이티브 정수 폭으로 계산하고 and $FF로 마스킹합니다
  • FPC 인라이너 잘못된 컴파일. PByte 매개변수를 받는 작은 빅엔디안 적재·저장 헬퍼들이 FPC에서 inline으로 표시되면 access violation으로 컴파일됐습니다. inline을 제거하니 고쳐졌고, 호출 비용은 테이블 조회 옆에서는 무시할 수준입니다

같은 경험이 최종 배선에도 있습니다. 라이브러리의 모든 AES-128 블록을 빠른 테이블로 돌리게 했더니 한 출시 후보 적재 중에 결정론적인 FPC access violation이 나서, 그 변경은 되돌려졌습니다. 출시된 설계는 보수적입니다. 먼저 네이티브 CBC, R6 루프 안의 명시적 빠른 테이블 분기, 그 밖에서는 레퍼런스 블록입니다. 일반 교훈은 여러 컴파일러에서 빌드되어야 하는 새 암호 코드에는 타깃 컴파일러마다 독립적인 known-answer 프로브가 필요하다는 것입니다. DLL 수준의 테스트 실패는 잘못된 테이블인지 크래시인지 알려 주지 않습니다. Free Pascal 쪽에 대해서는 Free Pascal 아래 HotPDF Deflate 및 AES 백엔드 노트를 보십시오

잘못된 답을 캐시하지 않고 서명 검증을 캐시하는 방법

서명 검증 캐시는 키가 판정을 바꿀 수 있는 모든 입력, 서명된 바이트 범위 밖에 사는 입력까지 커버할 때만 안전합니다. HotPDF의 헤드리스 문서 층은 CMS 바이트, /Contents gap 안의 서명 딕셔너리 바이트, 커버된 범위의 내용, 정책 지문, 검증 예산, 전체 소스 크기, 1분 시간 버킷을 해싱해 CMS 검증 결과를 메모이제이션합니다. 그 입력 중 셋은 버그가 부재를 보여 준 뒤에야 추가됐습니다

Delphi용 HotPDF 서명 검증 캐시 키 입력: CMS 바이트, /Contents gap의 서명 딕셔너리, 커버된 범위 내용, 정책 지문, 예산, 전체 소스 크기, 1분 시간 버킷이 CMS 판정이 메모이제이션되기 전에 함께 해시됨
판정은 그것을 바꿀 수 있는 모든 입력이 키 안에 있을 때만 재사용됩니다. 소스 크기가 중요한 이유는 기준 리비전과 그 증분 업데이트가 동일한 커버된 범위를 공유할 수 있기 때문입니다

신뢰하는 인증서 파일은 경로가 아니라 내용으로 키가 매겨집니다. 연산 층은 명령마다 같은 PEM 자료를 새 임시 파일 이름에 쓰므로, 경로 기반 키는 한 번도 맞지 않았고 캐시는 한 번도 적중하지 않았습니다. 전체 소스 크기가 키의 일부여야 했던 이유는 보고되는 UnsignedTrailingBytes가 그것에 의존하기 때문입니다. 기준 리비전과 이후 증분 업데이트가 정확히 같은 커버된 내용을 공유하면서 서로 다른 trailing 길이를 가질 수 있고, 범위만으로 매겨진 캐시는 기준 리비전의 판정을 업데이트된 파일에 넘겨 줄 것입니다. 그 trailing 바이트들의 의미에 대한 배경은 Delphi에서 PDF 전자 서명 검증하기 HotPDF 가이드에 있습니다

세 번째 버그는 설계 실수가 아니라 Pascal 언어 함정이었습니다. 해시 루틴은 타입 없는 const 버퍼를 받는데, 동적 배열 변수를 타입 없는 매개변수로 넘기면 배열 데이터가 아니라 배열 참조의 주소가 넘어갑니다

function Checksum(const Data; Count: Integer): Cardinal;
var
  P: PByte;
  I: Integer;
begin
  Result := 0;
  P := @Data;
  for I := 0 to Count - 1 do
  begin
    Result := Result + P^;
    Inc(P);
  end;
end;

procedure DigestCMS(const CMS: TBytes);
begin
  // 잘못됨: 배열 참조와 스택에서 그 뒤를 따르는 것들을 읽습니다.
  // 거기서의 16 KB 읽기는 스택 페이지를 벗어나 access violation을
  // 던지며, Delphi와 FPC는 서로 다른 지점에서 실패합니다.
  Writeln(Checksum(CMS, Length(CMS)));

  // 올바름: 첫 원소를 넘깁니다. 빈 배열에는 가드
  if Length(CMS) > 0 then
    Writeln(Checksum(CMS[0], Length(CMS)));
end;

캐시 잠금은 Windows InitializeCriticalSection API가 아니라 SyncObjs.TCriticalSection입니다. 같은 유닛이 그 API가 존재하지 않는 Linux의 FPC 아래에서도 빌드되기 때문입니다

일부러 느리게 남겨 둔 것들

R6 키 유도 자체는 적재 사이에 캐시되지 않으며, 그건 의도입니다. 외부 바이트와 명시적으로 준 비밀번호는 항상 진짜 인증 경로를 거치므로, R6 문서의 모든 비밀번호 적재는 최소 하나의 알고리즘 2.B 해시를 치르고, 소유자 비밀번호를 시도할 때는 사용자 쪽과 소유자 쪽 해시를 모두 치릅니다. 위에서 설명한 스코프 재사용은 단일 적재 안의 중복만 제거하고 그 이상은 아무것도 하지 않습니다

  • 66 ms에서 16 ms 수치는 네이티브 AES 백엔드가 링크된 Delphi 타깃에 적용됩니다. FPC 빌드는 테이블 기반 블록을 타고, Windows의 FPC 코드 생성은 같은 단언 아래에서 Delphi보다 2에서 4배 느렸습니다
  • 검증 캐시는 1분 시간 버킷에 묶이므로, 짧은 창 안의 같은 바이트 반복 검사는 싸고, 캐시된 판정은 계산된 그 분보다 오래 살지 않습니다
  • 병합 리비전 권위 캐시는 구현되고 디버깅된 뒤 제거됐습니다. FPC 격차를 메우지도 않았고 Delphi 보안 콜백을 측정 가능하게 느리게 만들었으니, 캐시를 남겨 두기 전에 측정하라는 좋은 알림입니다
  • 서명 검증에는 원본 암호화 파일 바이트가 필요하므로 소스를 가용하게 두십시오. 빨라진 복호화는 그 요구사항을 없애는 데 아무것도 하지 않습니다

FAQ

프리스캔된 키 재사용은 비밀번호 검사를 약화시키나요?

아니요. HotPDF는 회복된 파일 키를 전체 알고리즘 2.A 검사로 비밀번호를 검증한 같은 비밀번호 오버로드 호출 안에서만 재사용하고, 스코프 플래그는 finally 블록에서 해제됩니다. 단독 DecryptLoadedDocument 호출은 항상 완전한 유도를 수행하므로, 이전 열기가 남긴 키가 잘못된 비밀번호로 빌려 쓰일 수 없습니다

R6 해시 결과를 적재 사이에 캐시하지 않는 이유는요?

적재 사이에 해시를 캐시한다는 것은 열고 있는 파일에 대해 검사하지 않고 비밀번호를 받아들이는 것입니다. HotPDF는 모든 외부 바이트 소스와 모든 명시적 비밀번호를 스스로 인증해야 하는 신뢰할 수 없는 입력으로 다루므로, 측정 가능한 가속은 알고리즘 2.B를 건너뛰는 게 아니라 더 빠르게 만드는 데서 왔습니다

LoadFromStream에 TFileStream을 넘겨도 되나요?

디스크의 파일에는 LoadFromFile이 낫습니다. 내부 버퍼링이 이미 되어 있기 때문입니다. 스트림 오버로드가 필요하면 TMemoryStream이나 TBufferedFileStream을 넘기십시오. HotPDF는 첫 구조 패스 동안 여러분이 준 스트림을 직접 읽고, raw 핸들 스트림은 작은 읽기마다 시스템 콜 비용이 듭니다

비밀번호 보호 적재, AES-256 R6 암호화, CMS 서명 검증, 적재 문서 API의 나머지는 Delphi와 C++Builder용 하나의 네이티브 VCL 라이브러리로 함께 제공됩니다. 기능 상세와 지원 IDE 버전은 HotPDF Component 제품 페이지에 있습니다