기술 문서

대용량 문서를 위한 고속 AES-256 PDF 암호화

2GB PDF를 암호화하는 것은 스트리밍 문제처럼 들립니다: 파일을 열고, 2기가바이트를 AES-256으로 푸시하고, 결과를 씁니다. 이러한 멘탈 모델은 전체 성능 예산을 결정짓는 방식에서 틀렸습니다. ISO 32000-1 §7.6은 PDF 암호화의 단위를 개별 객체로 설정합니다 — 모든 스트림과 모든 문자열은 각각 자체 초기화 벡터(IV)와 패딩을 사용하여 개별적으로 암호화됩니다. 500,000개의 객체를 포함하는 2GB 스캔 아카이브는 한 번의 긴 패스가 아니라 500,000개의 작은 CBC 작업이며, 이 규모에서는 각 작업 주변의 고정 비용이 그 내부의 AES 연산보다 더 중요합니다

이 문서는 바로 그 고정 비용에 관한 것입니다: Delphi 코드가 매우 큰 문서에 AES-256을 적용할 때 시간이 어디에 소요되며, 이를 어떻게 회수할 수 있는지. 암호, 권한 플래그, 리비전 5 대 6 호환성 호출과 같은 설정 측면에 대해서는 HotPDF에서 AES-256 암호화 구성하기에 관한 자매 문서를 참조하십시오; 이 중 어느 것도 여기서는 반복하지 않습니다

단일 패스가 아닌 백만 번의 CBC 작업

파일의 골격은 일반 텍스트로 유지됩니다. 상호 참조 테이블, 객체 번호, 딕셔너리 키, 페이지 트리: 이 중 어느 것도 암호화되지 않으며, 이것이 리더가 암호를 검증하기 전에 객체를 위치시킬 수 있는 방법입니다. 표준이 암호화하는 것은 콘텐츠입니다 — 페이지 설명, 이미지, 폰트 및 첨부 파일과 같은 스트림 데이터와 메타데이터 값 및 주석 텍스트와 같은 문자열입니다. AES-256 암호화 필터 아래에서 각각은 자체적으로 처리됩니다: 새로운 무작위 16바이트 IV, 바이트에 대한 CBC, 16바이트 경계에 맞춘 블록 패딩, 그리고 암호문 앞에 일반 텍스트로 기록된 IV

이로 인해 두 가지 결과가 발생합니다. 첫째, 암호문은 항상 일반 텍스트보다 깁니다: IV가 16바이트를 추가하고 패딩이 1에서 16바이트를 더 추가하므로 100바이트 문자열은 디스크에서 128바이트를 차지하며, 빈 스트림도 여전히 32바이트를 생성합니다. 출력 버퍼의 크기를 입력 길이에 맞추거나 읽은 바이트 수만큼만 다시 쓰는 코드는 모든 객체의 마지막 블록에서 암호 해독에 실패하는 파일을 생성합니다. 둘째, 비용은 단순히 바이트 수가 아니라 객체 수를 따릅니다. 스캔한 아카이브는 바이트를 몇 개의 큰 이미지 스트림에 집중시키지만, 수십만 개의 짧은 스트림과 작은 문자열을 수반하므로 AES가 아니라 작업당 오버헤드가 비용의 주범입니다

AES-256 설계의 한 가지 자비는 키 처리입니다. 리비전 4까지의 보안 핸들러는 객체 및 생성 번호와 함께 파일 키를 해싱하여 모든 객체에 대해 별개의 키를 파생시켰으므로 매번 새로운 키 스케줄이 필요했습니다. /V 5 체계는 객체별 파생을 없앴습니다: 하나의 무작위 256비트 파일 키가 문서 내의 모든 객체를 암호화합니다. 이 사실이 아래의 모든 최적화를 허용합니다 — 고비용의 암호화 상태는 객체마다가 아니라 파일당 한 번만 빌드할 수 있습니다

R6 /Encrypt 딕셔너리: 한 번의 느린 열기, 저렴한 객체들

리비전 6 문서는 트레일러의 /Encrypt 딕셔너리에 체계를 선언하며, 중요한 항목은 몇 줄에 맞습니다:

/Filter /Standard
/V 5  /R 6  /Length 256
/CF << /StdCF << /CFM /AESV3  /Length 32  /AuthEvent /DocOpen >> >>
/StmF /StdCF    /StrF /StdCF
/O ...48 바이트...   /U ...48 바이트...
/OE ...32 바이트...  /UE ...32 바이트...
/Perms ...16 바이트...  /P -3904  /EncryptMetadata true

/V 5는 256비트 키 아키텍처를 선택하고 /R 6은 강화된 ISO 32000-2 핸드셰이크를 선택합니다. /CF는 명명된 암호화 필터를 정의하며 — /AESV3는 접두어 IV를 사용하는 CBC 모드의 AES-256을 의미합니다 — /StmF 및 /StrF는 해당 필터를 각각 스트림 및 문자열에 할당합니다. /O, /U, /OE 및 /UE는 암호 검증 및 키 래핑 자료를 보관하고, /Perms는 악의적인 편집자가 /P를 은밀하게 뒤집을 수 없도록 권한 비트의 AES 암호화된 복사본을 전달합니다

비용 구조는 /OE 및 /UE에 숨어 있습니다. 이들로부터 파일 키를 래핑 해제하는 것은 SHA-256, SHA-384 및 SHA-512 라운드를 체인으로 연결하는 반복 키 파생 함수인 알고리즘 2.B를 실행합니다 — 암호 추측 비용이 높게 유지되도록 의도적으로 느리게 구축된 데이터 종속 종료 규칙과 함께 최소 64라운드가 진행됩니다. 이 비용은 라이터가 파일을 생성할 때 한 번, 리더가 파일을 열 때 한 번 지불되며, 각각 한 자릿수 밀리초입니다. 500,000개 객체 파일에서 알고리즘 2.B는 노이즈에 불과하며, 만약 저장이 느리다면 범인은 알고리즘 2.B가 아니라 객체별 루프입니다

키 핸들 재사용, 스크래치 버퍼 재사용

가장 단순한 구현은 깔끔한 유틸리티 함수입니다: Windows CNG 공급자를 열고, CBC를 선택하고, 키 객체를 생성하고, 하나의 버퍼를 암호화한 다음 모든 것을 해제하는 EncryptAes256Cbc 헬퍼. 정확하고 단위 테스트가 가능하지만, 500,000번의 반복 루프 내에서는 재앙입니다. Microsoft의 문서는 BCryptOpenAlgorithmProvider가 비용이 많이 든다고 표시하고 핸들 캐싱을 권장하며, BCryptGenerateSymmetricKey는 전체 AES 키 스케줄을 실행하고 공급자 상태를 할당합니다 — 문서 전체에 걸쳐 키가 변하지 않을 때는 순전한 낭비입니다

Delphi RTL은 bcrypt 가져오기(import) 유닛을 제공하지 않으므로 진입점을 직접 선언하십시오. 아래 클래스는 모든 암호화 상태를 한 번 구축한 다음, 정상 상태(steady-state) 할당 없이 수의 제한 없이 객체를 암호화합니다:

uses
  Winapi.Windows, System.SysUtils, System.Classes;

const
  BCRYPT_AES_ALGORITHM  = 'AES';
  BCRYPT_CHAINING_MODE  = 'ChainingMode';
  BCRYPT_CHAIN_MODE_CBC = 'ChainingModeCBC';
  BCRYPT_OBJECT_LENGTH  = 'ObjectLength';
  BCRYPT_BLOCK_PADDING            = $00000001;
  BCRYPT_USE_SYSTEM_PREFERRED_RNG = $00000002;

type
  NTSTATUS = Integer;
  BCRYPT_HANDLE = Pointer;

function BCryptOpenAlgorithmProvider(out hAlg: BCRYPT_HANDLE; AlgId,
  Impl: PWideChar; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptCloseAlgorithmProvider(hAlg: BCRYPT_HANDLE;
  Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptSetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Input: PByte;
  cbInput, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Output: PByte;
  cbOutput: ULONG; out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall;
  external 'bcrypt.dll';
function BCryptGenerateSymmetricKey(hAlg: BCRYPT_HANDLE;
  out hKey: BCRYPT_HANDLE; KeyObj: PByte; cbKeyObj: ULONG; Secret: PByte;
  cbSecret: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptDestroyKey(hKey: BCRYPT_HANDLE): NTSTATUS; stdcall;
  external 'bcrypt.dll';
function BCryptEncrypt(hKey: BCRYPT_HANDLE; Input: PByte; cbInput: ULONG;
  Padding: Pointer; IV: PByte; cbIV: ULONG; Output: PByte; cbOutput: ULONG;
  out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGenRandom(hAlg: BCRYPT_HANDLE; Buffer: PByte;
  cbBuffer, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';

procedure CngCheck(Status: NTSTATUS; const Api: string);
begin
  if Status <> 0 then
    raise Exception.CreateFmt('%s 실패, NTSTATUS 0x%.8x',
      [Api, Cardinal(Status)]);
end;

type
  TPdfObjectEncryptor = class
  private
    FAlg: BCRYPT_HANDLE;
    FKey: BCRYPT_HANDLE;
    FKeyObject: TBytes;  // CNG 키-객체 작업 공간, 한 번 할당됨
    FScratch: TBytes;    // 암호문 스크래치, 커진 후 그대로 유지됨
  public
    constructor Create(const FileKey: TBytes);
    destructor Destroy; override;
    procedure EncryptObject(const Plain: TBytes; Dest: TStream);
  end;

constructor TPdfObjectEncryptor.Create(const FileKey: TBytes);
var
  Mode: string;
  ObjLen, Got: ULONG;
begin
  inherited Create;
  if Length(FileKey) <> 32 then
    raise Exception.Create('AES-256 파일 키는 32바이트여야 합니다');
  CngCheck(BCryptOpenAlgorithmProvider(FAlg, BCRYPT_AES_ALGORITHM, nil, 0),
    'BCryptOpenAlgorithmProvider');
  Mode := BCRYPT_CHAIN_MODE_CBC;
  CngCheck(BCryptSetProperty(FAlg, BCRYPT_CHAINING_MODE,
    PByte(PWideChar(Mode)), (Length(Mode) + 1) * SizeOf(WideChar), 0),
    'BCryptSetProperty');
  CngCheck(BCryptGetProperty(FAlg, BCRYPT_OBJECT_LENGTH, PByte(@ObjLen),
    SizeOf(ObjLen), Got, 0), 'BCryptGetProperty');
  SetLength(FKeyObject, ObjLen);
  // AES 키 스케줄은 여기서 한 번 빌드되고 모든 객체에 재사용됩니다
  CngCheck(BCryptGenerateSymmetricKey(FAlg, FKey, PByte(FKeyObject), ObjLen,
    PByte(FileKey), 32, 0), 'BCryptGenerateSymmetricKey');
end;

destructor TPdfObjectEncryptor.Destroy;
begin
  if FKey <> nil then
    BCryptDestroyKey(FKey);
  if FAlg <> nil then
    BCryptCloseAlgorithmProvider(FAlg, 0);
  inherited;
end;

procedure TPdfObjectEncryptor.EncryptObject(const Plain: TBytes; Dest: TStream);
var
  IV, IVWork: array[0..15] of Byte;
  Need, Written: ULONG;
  Src: PByte;
begin
  // 객체마다 새로운 랜덤 IV; 이것은 데이터보다 앞서 일반 텍스트로 전달됩니다
  CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
    'BCryptGenRandom');
  Src := PByte(Plain);  // 빈 입력에 대해 nil 유효: 패딩 전용 블록

  // 크기 조회: CBC 패딩은 항상 1..16 바이트를 추가하므로 Need > Length(Plain)
  IVWork := IV;  // BCryptEncrypt는 체인으로 연결하는 동안 IV 버퍼를 전진시킵니다
  CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
    nil, 0, Need, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt(size)');

  if ULONG(Length(FScratch)) < Need then
    SetLength(FScratch, Need);  // 몇 번 커진 후에는 그대로 유지됩니다

  IVWork := IV;
  CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
    PByte(FScratch), Need, Written, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt');

  // AESV3 레이아웃: 16바이트 IV, 그 다음 패딩된 암호문
  Dest.WriteBuffer(IV[0], 16);
  Dest.WriteBuffer(FScratch[0], Written);
end;

세 가지 세부 사항이 하중을 지탱합니다. 크기 쿼리(출력 버퍼가 nil인 첫 번째 BCryptEncrypt 호출)는 패딩된 암호문 길이를 반환하며 이는 절대 입력 길이와 같지 않습니다; 패딩은 결정적이므로 스스로 ((Len div 16) + 1) * 16을 계산하여 호출 수를 절반으로 줄일 수 있지만, 쿼리가 문서화된 계약입니다. 둘째, BCryptEncrypt는 체인으로 연결할 때 제자리에서 IV 버퍼를 전진시키므로 각 호출에 작업 복사본이 들어가고 깨끗한 IV가 출력에 남습니다. 셋째, FScratch는 파일 내 가장 큰 객체까지만 커지며, 그 이후로는 루프에서 할당이 발생하지 않습니다

핸들 재사용의 가치 측정

이 연습을 강제한 파일은 1.8GB 스캔 대출 아카이브였습니다: 412,000개의 암호화된 객체가 일반 텍스트 구조를 뺀 후 1,710MB의 페이로드를 전달합니다. 동일한 기계, 동일한 파일, NVMe 스토리지, 단일 스레드:

  • 호출당 설정 (공급자가 열리고 헬퍼 내부에서 키가 생성됨): 암호화 단계 71.3초 — 1,710MB ÷ 71.3초 ≈ 24MB/s
  • 상태 끌어올리기 (위의 클래스): 9.6초 — 1,710MB ÷ 9.6초 ≈ 178MB/s

차이는 412,000회 호출에 걸쳐 61.7초, 즉 결코 변하지 않는 키에 대해 공급자를 열고 체인 모드를 설정하고 키 스케줄을 다시 구축하는 데 호출당 대략 150µs가 소요되었습니다. 그 중 암호화 연산은 없었습니다. AES-NI를 사용하면 대용량 버퍼의 CBC 암호화가 1 코어에서 1.4GB/s에 가깝게 실행되므로 AES 연산 자체는 9.6초 중 약 1.2초를 차지합니다; 나머지 대부분은 객체당 두 번의 사용자 모드 BCryptEncrypt 전환과 객체당 IV 생성입니다. IV를 일괄 처리하면 — 한 번의 BCryptGenRandom 호출로 4,096개를 채움 — 실행 시간이 8.9초로 줄었습니다. 이를 넘어서면 API의 객체당 최하한선(floor)에 도달하게 되며, 남은 지렛대는 병렬성입니다: /V 5 객체는 공유 파일 키 아래에서 독립적이므로, 각각 키 객체를 가진 4개의 작업자 스레드는 출력 라이터가 직렬화 지점이 되기 전에 이 단계를 3.1초로 단축했습니다

전체 다시 쓰기 대 증분 저장

세분성은 저장 비용도 결정합니다. 기존 일반 텍스트 문서에 암호화를 추가하면 정의상 모든 객체를 다시 씁니다: 모든 스트림과 문자열은 내용과 길이가 변경되고, 모든 상호 참조 오프셋이 이동하며, 증분 경로는 존재하지 않습니다. 이를 전체 순차적 다시 쓰기로 예산을 짜고, 대상에 덮어쓸 임시 파일에 쓰십시오. 그렇지 않으면 암호화 중간에 충돌이 발생할 경우 어떤 암호로도 열리지 않는 절반만 암호화된 파일이 남게 됩니다

반대 방향이 저렴한 방향입니다. 파일이 암호화되고 나면 증분 업데이트는 동일한 파일 키로 암호화된 새 객체를 추가하고 원본 바이트는 건드리지 않습니다. 2GB 암호화 아카이브에 승인 주석을 스탬프 찍는 데에는 2GB 다시 쓰기가 아닌 킬로바이트 단위의 추가 출력이 소요됩니다. 파이프라인의 결과: 작업의 마지막 단계로 한 번 암호화하고, 이후의 터치들은 증분 저장에 편승하도록 하십시오. 파일 키도 함께 회전시키는 암호 회전은 다시 전체 다시 쓰기입니다 — 전체 다시 쓰기처럼 스케줄링하십시오

스스로를 속이지 않고 처리량 측정하기

암호화 처리량 주장은 분자, 분모 또는 둘 다에서 틀리는 경향이 있습니다. 분자는 페이로드 바이트여야 합니다: 압축 후 실제로 AES를 통해 푸시된 스트림 및 문자열 길이의 합계이며, 작성자가 진행하면서 합산할 수 있습니다. 파일 크기는 이를 과장합니다 — 위의 아카이브는 디스크에서 1.8GB이지만, 그 중 1,710MB만이 실제 암호화 연산을 거칩니다. 분모는 암호화 단계 자체여야 하며, 파싱, deflate 및 디스크 I/O를 괄호 밖에 둔 채 System.Diagnostics의 TStopwatch로 묶어야 합니다. 이들을 포함하면 동일한 암호화 코드가 단지 압축이 더 안 되는 파일에서 몇 배 더 느리게 측정될 것입니다. 위의 수치들은 나눗셈의 양쪽이 암호화 전용이기 때문에 정확히 비교 가능합니다

이 중 어느 것도 여러분이 소유한 코드일 필요는 없습니다. HotPDF는 대화형 VCL 애플리케이션에 알맞은 수준에서 컴포넌트 속성 — ActivateProtection, CryptKeyLength, UseAES256R6 — 뒤에 동일한 엔지니어링을 포장하며, 할당 순서의 함정은 HotPDF AES-256 기사에서 다룹니다. 무인 파이프라인의 경우 PDFlibPas는 단일 EncryptFile 호출(Strength 4)을 통해 기존 파일에 AES-256 리비전 6을 적용하고 그 후 디스크에 저장된 것을 검증하며, 이 워크플로우는 PDFlibPas 암호화 감사 기사에서 안내합니다

여기서 설명한 암호화 경로는 Delphi 및 C++Builder용 HotPDF ComponentPDFlibPas 라이브러리로 제공되며, 두 제품 페이지 모두 완전한 암호화 참조를 포함하고 있습니다