기술 문서

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

2GB PDF를 암호화하는 일은 스트리밍 문제처럼 들립니다. 파일을 열고, 2기가바이트를 AES-256으로 밀어 넣고, 결과를 쓰면 됩니다. 그 머릿속 모델은 성능 예산 전체를 좌우하는 방식으로 틀렸습니다. ISO 32000-1 §7.6은 PDF 암호화의 알갱이를 개별 객체로 정합니다. 모든 스트림과 모든 문자열이 저마다의 초기화 벡터와 저마다의 패딩으로 따로 암호화됩니다. 객체 50만 개를 지닌 2GB 스캔 보관 문서는 한 번의 긴 통과가 아니라 50만 번의 작은 CBC 연산이며, 그 규모에서는 연산 안쪽의 AES 산술보다 연산 하나하나를 둘러싼 고정 비용이 더 중요합니다

이 글은 그 고정 비용에 관한 것입니다. 델파이 코드가 아주 큰 문서에 AES-256을 적용할 때 시간이 어디로 가는지, 그리고 그것을 어떻게 되찾는지입니다. 암호, 권한 플래그, 개정 5 대 6 호환성 판단 같은 설정 쪽은 HotPDF에서 AES-256 암호화 구성하기 짝글을 보십시오. 여기서는 그중 아무것도 되풀이하지 않습니다

한 번의 통과가 아니라 오십만 번의 CBC 연산

파일의 뼈대는 평문으로 남습니다. 상호 참조 표, 객체 번호, 사전 키, 페이지 트리 중 어느 것도 암호화되지 않는데, 그래서 리더가 암호를 검증하기도 전에 객체를 찾아낼 수 있습니다. 표준이 암호화하는 것은 내용입니다. 페이지 설명, 이미지, 글꼴, 첨부 파일 같은 스트림 데이터와, 메타데이터 값이나 주석 텍스트 같은 문자열입니다. AES-256 암호 필터 아래에서 그 하나하나가 따로 처리됩니다. 새로 뽑은 무작위 16바이트 IV, 바이트에 대한 CBC, 16바이트 경계까지의 블록 패딩, 그리고 암호문 앞에 그대로 쓰이는 IV입니다

공유된 256비트 파일 키 하나가 오십만 개의 내용 스트림과 문자열을 각각 봉인하는 동안 구조 뼈대는 평문으로 남는 AES-256 PDF 암호화 알갱이 그림
뼈대는 읽을 수 있는 채로 남고 모든 스트림과 문자열이 따로 봉인됩니다 — 항목마다 공유된 256비트 파일 키 아래에서 새 16바이트 IV와 CBC 연쇄와 블록 패딩을 지닙니다

결과 둘이 따라 나옵니다. 첫째, 암호문은 언제나 평문보다 깁니다. 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 bytes...   /U ...48 bytes...
/OE ...32 bytes...  /UE ...32 bytes...
/Perms ...16 bytes...  /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에 숨어 있습니다. 거기서 파일 키를 풀어내는 일은 알고리즘 2.B를 돌리는데, SHA-256과 SHA-384와 SHA-512 라운드를 잇는 반복 키 유도 함수로, 데이터에 따라 멈추는 규칙과 함께 최소 64회를 돌며, 암호 추측이 비싸게 남도록 일부러 느리게 지어졌습니다. 그 값은 기록기가 파일을 만들 때 한 번, 리더가 파일을 열 때 한 번 치르며 저마다 한 자릿수 밀리초입니다. 오십만 객체 파일에서 그 KDF는 잡음이고, 저장이 느리다면 알고리즘 2.B는 용의자가 아닙니다. 객체별 루프가 용의자입니다

키 핸들을 재사용하고 임시 버퍼를 재사용하십시오

순진한 구현은 말끔한 유틸리티 함수입니다. Windows CNG 공급자를 열고, CBC를 고르고, 키 객체를 만들고, 버퍼 하나를 암호화하고, 전부를 허무는 EncryptAes256Cbc 도우미입니다. 옳고, 단위 테스트도 되고, 50만 회 반복 루프 안에서는 참사입니다. Microsoft 문서는 BCryptOpenAlgorithmProvider를 비싼 것으로 표시하며 핸들을 캐시하라고 권하고, BCryptGenerateSymmetricKey는 AES 키 스케줄 전체를 돌리며 공급자 상태를 할당하는데, 키가 문서 내내 바뀌지 않는다면 순전한 낭비입니다

델파이 RTL에는 bcrypt 임포트 유닛이 딸려 오지 않으므로 진입점을 직접 선언하십시오. 아래 클래스는 모든 암호 상태를 한 번 짓고 나서 정상 상태 할당 없이 원하는 만큼의 객체를 암호화합니다:

PDF 객체마다 Windows CNG 공급자와 연쇄 모드와 키 스케줄을 다시 짓는 순진한 AES-256 도우미와, 루프에서 아무것도 할당하지 않는 끌어올린 TPdfObjectEncryptor 생성자를 견주는 그림
CNG 공급자를 다시 열고 키 스케줄을 다시 짓는 데 객체당 대략 150마이크로초가 듭니다. 끌어올린 생성자는 그것을 한 번 치르고 달궈진 루프는 아무것도 할당하지 않습니다
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 failed, 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 file key must be 32 bytes');
  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 스캔 대출 보관 문서였습니다. 평문 구조를 빼고 나면 1,710MB의 짐을 진 암호화 객체 412,000개입니다. 같은 기계, 같은 파일, 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.4GB/s 가까이 돌므로, AES 산술 자체는 9.6초 중 약 1.2초를 차지합니다. 나머지 대부분은 객체당 두 번의 사용자 모드 BCryptEncrypt 전환에 객체별 IV 생성이 더해진 것입니다. IV를 묶어 처리하니 — BCryptGenRandom 호출 한 번으로 4,096개를 채우는 방식 — 실행이 8.9초로 깎였습니다. 그 너머는 API의 객체당 바닥이고, 남은 지렛대는 병렬성입니다. /V 5 객체는 공유 파일 키 아래에서 서로 독립적이므로, 저마다 키 객체를 하나씩 가진 작업자 스레드 넷이 출력 기록기가 직렬화 지점이 되기 전까지 이 단계를 3.1초로 끌어내렸습니다

PDF: 1.8GB 스캔 PDF의 AES-256 암호화 시간이 71.3초에서 CNG 상태를 끌어올려 9.6초로, IV를 묶어 8.9초로, 작업자 스레드 넷으로 3.1초까지 떨어지는 막대 그래프
객체 412,000개짜리 1.8GB 스캔에서 실측: CNG 상태를 끌어올리면 순전한 API 부담 약 61.7초를 되찾고, IV를 묶으면 더 깎이며, 작업자 스레드 넷은 기록기가 직렬화하기 전까지 3.1초에 이릅니다

전면 재작성 대 증분 저장

알갱이는 저장 비용도 정합니다. 기존 평문 문서에 암호화를 더하는 일은 정의상 모든 객체를 다시 씁니다. 모든 스트림과 문자열이 내용과 길이를 함께 바꾸고, 모든 상호 참조 오프셋이 움직이며, 증분 경로는 존재하지 않습니다. 그것을 전체 순차 재작성으로 잡아 예산을 세우고, 임시 파일에 쓴 뒤 대상 위로 이름을 바꾸십시오. 그러지 않으면 암호화 도중의 충돌이 어떤 암호로도 열리지 않는 반쯤 암호화된 파일을 남깁니다

반대 방향은 값싼 쪽입니다. 파일이 한 번 암호화되고 나면, 증분 갱신은 같은 파일 키로 암호화한 새 객체를 덧붙이고 원래 바이트는 하나도 건드리지 않습니다. 2GB 암호화 보관 문서에 승인 주석을 찍는 일은 2GB 재작성이 아니라 킬로바이트 단위로 덧붙는 출력의 값이 듭니다. 파이프라인상의 따름정리는 이렇습니다. 작업의 마지막 단계로 한 번 암호화하고, 이후의 손질은 증분 저장에 태우십시오. 파일 키까지 바꾸는 암호 교체는 다시 전면 재작성이니, 그렇게 알고 일정을 잡으십시오

스스로를 속이지 않고 처리량 재기

암호화 처리량 주장은 분자에서든 분모에서든 아니면 양쪽 모두에서든 틀리기 쉽습니다. 분자는 짐 바이트여야 합니다. 압축한 뒤 실제로 AES를 통과한 스트림과 문자열 길이의 합이며, 기록기가 진행하면서 합계를 낼 수 있습니다. 파일 크기는 그것을 부풀립니다. 위의 보관 문서는 디스크에서 1.8GB지만 그중 1,710MB만이 암호를 거칩니다. 분모는 암호화 단계만이어야 하며, System.Diagnostics의 TStopwatch로 괄호를 치되 구문 분석과 deflate와 디스크 I/O는 괄호 밖에 두어야 합니다. 그것들을 안에 넣으면, 똑같은 암호화 코드가 그저 압축이 덜 되는 파일에서 몇 배 더 느리게 측정됩니다. 위의 수치가 견줄 만한 것은 나눗셈의 양쪽이 정확히 암호화만이기 때문입니다

이 중 어느 것도 여러분이 소유해야 할 코드일 필요는 없습니다. HotPDF는 같은 공학을 컴포넌트 속성 뒤에 감쌉니다. ActivateProtection, CryptKeyLength, UseAES256R6이 대화형 VCL 애플리케이션에 맞는 높이에 있고, 대입 순서의 함정은 HotPDF AES-256 글에서 다룹니다. 무인 파이프라인이라면 PDF Library for Delphi가 EncryptFile 호출 하나로 Strength 4에서 기존 파일에 AES-256 개정 6을 적용하고 그 뒤에 디스크에 무엇이 떨어졌는지 확인해 주며, 그 작업 흐름은 PDF Library for Delphi 암호화 감사 글에서 짚어 갑니다

여기서 설명한 암호화 경로는 델파이와 C++Builder를 위한 HotPDF Delphi ComponentPDF Library for Delphi 라이브러리에 함께 제공되며, 두 제품 페이지 모두 완전한 암호화 참조 문서를 지니고 있습니다