Artikel Teknis

Enkripsi AES-256 Cepat untuk PDF Berukuran Raksasa

Mengenkripsi PDF 2 GB terdengar seperti persoalan streaming: buka file-nya, dorong dua gigabyte melewati AES-256, tulis hasilnya. Model mental itu keliru dengan cara yang menentukan seluruh anggaran performanya. ISO 32000-1 §7.6 menetapkan granularitas enkripsi PDF pada objek individual — setiap stream dan setiap string dienkripsi secara terpisah, masing-masing dengan initialization vector-nya sendiri dan padding-nya sendiri. Sebuah arsip hasil pindaian 2 GB dengan 500.000 objek berarti 500.000 operasi CBC kecil, bukan satu lintasan panjang, dan pada skala itu biaya tetap di sekeliling setiap operasi lebih penting daripada aritmetika AES di dalamnya

Artikel ini membahas biaya tetap tersebut: ke mana perginya waktu ketika kode Delphi menerapkan AES-256 pada dokumen yang sangat besar, dan bagaimana merebutnya kembali. Untuk sisi penyiapannya — password, flag izin, dan pilihan kompatibilitas revisi 5 versus 6 — lihat tulisan pendamping tentang konfigurasi enkripsi AES-256 di HotPDF; tak satu pun darinya diulang di sini

Setengah juta operasi CBC, bukan satu lintasan

Kerangka file-nya tetap berupa teks biasa. Tabel referensi silang, nomor objek, kunci dictionary, page tree: tak satu pun dienkripsi, dan itulah cara sebuah pembaca bisa menemukan objek sebelum ia memvalidasi password. Yang dienkripsi standar tersebut adalah konten — data stream seperti deskripsi halaman, gambar, font, dan lampiran, ditambah string seperti nilai metadata dan teks anotasi. Di bawah crypt filter AES-256, masing-masing diproses sendiri-sendiri: IV acak 16 byte yang baru, CBC atas byte-nya, padding blok hingga batas 16 byte, dan IV-nya ditulis terang-terangan mendahului ciphertext-nya

Diagram PDF tentang granularitas enkripsi PDF AES-256, tempat satu file key 256 bit bersama menyegel setengah juta stream konten dan string secara terpisah sementara kerangka strukturalnya tetap berupa teks biasa
Kerangkanya tetap terbaca sementara setiap stream dan string disegel sendiri-sendiri — setiap item membawa IV 16 byte yang baru, perantaian CBC, dan padding blok di bawah satu file key 256 bit bersama

Dua konsekuensi menyusul. Pertama, ciphertext selalu lebih panjang daripada plaintext: IV menambah 16 byte dan padding menambah 1 sampai 16 byte lagi, sehingga string 100 byte menempati 128 byte di disk dan stream kosong pun tetap menghasilkan 32. Kode yang mengukur buffer keluarannya sepanjang masukan, atau yang menulis balik hanya sebanyak byte yang dibacanya, menghasilkan file yang gagal didekripsi pada blok terakhir setiap objek. Kedua, biayanya mengikuti jumlah objek, bukan sekadar jumlah byte. Arsip hasil pindaian memusatkan byte-nya pada beberapa stream gambar besar, namun membawa ratusan ribu stream pendek dan string kecil, dan di sanalah overhead per operasi, bukan AES, yang menjadi tagihannya

Satu belas kasihan dalam rancangan AES-256 adalah penanganan key-nya. Security handler sampai revisi 4 menurunkan key yang berbeda untuk setiap objek dengan meng-hash file key bersama nomor objek dan generasinya, memaksa penjadwalan key yang baru setiap kali. Skema /V 5 membuang penurunan per objek: satu file key acak 256 bit mengenkripsi setiap objek di dalam dokumen. Fakta itulah yang mengizinkan setiap optimasi di bawah ini — keadaan kriptografis yang mahal bisa dibangun sekali per file, bukan sekali per objek

Dictionary /Encrypt R6: satu pembukaan lambat, objek yang murah

Sebuah dokumen revisi 6 menyatakan skemanya di dalam dictionary /Encrypt pada trailer-nya, dan entry yang penting muat dalam beberapa baris:

/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 memilih arsitektur key 256 bit dan /R 6 memilih handshake ISO 32000-2 yang telah diperkeras. /CF mendefinisikan crypt filter bernama itu — /AESV3 berarti AES-256 dalam mode CBC dengan IV di depan — sementara /StmF dan /StrF menugaskan filter tersebut masing-masing pada stream dan string. /O, /U, /OE, dan /UE menyimpan bahan verifikasi password dan pembungkusan key-nya, sedangkan /Perms membawa salinan bit izin yang terenkripsi AES supaya editor yang bermaksud jahat tidak bisa diam-diam membalik /P

Struktur biayanya bersembunyi di /OE dan /UE. Membuka bungkus file key dari keduanya menjalankan Algoritme 2.B, sebuah fungsi penurunan key beriterasi yang merangkai putaran SHA-256, SHA-384, dan SHA-512 — setidaknya 64 putaran, dengan aturan berhenti yang bergantung pada datanya — dibuat lambat dengan sengaja supaya penebakan password tetap mahal. Harga itu dibayar sekali ketika penulisnya menghasilkan file dan sekali ketika seorang pembaca membukanya, masing-masing beberapa milidetik saja. Pada file setengah juta objek, KDF-nya adalah derau, dan jika sebuah penyimpanan terasa lambat, Algoritme 2.B bukanlah tersangkanya; loop per objeklah tersangkanya

Pakai ulang handle key-nya, pakai ulang buffer sementaranya

Implementasi naifnya adalah sebuah fungsi utilitas yang rapi: helper EncryptAes256Cbc yang membuka provider CNG Windows, memilih CBC, membangkitkan objek key-nya, mengenkripsi satu buffer, lalu membongkar semuanya. Benar, bisa diuji unit, dan menjadi bencana di dalam loop 500.000 iterasi. Dokumentasi Microsoft menandai BCryptOpenAlgorithmProvider sebagai mahal dan menyarankan meng-cache handle-nya, sementara BCryptGenerateSymmetricKey menjalankan penjadwalan key AES yang lengkap dan mengalokasikan keadaan provider — pemborosan murni ketika key-nya tidak pernah berubah di sepanjang dokumen

RTL Delphi tidak menyertakan unit impor bcrypt, jadi deklarasikan entry point-nya secara langsung. Kelas di bawah ini membangun semua keadaan kriptografisnya sekali lalu mengenkripsi objek sebanyak apa pun tanpa alokasi pada keadaan mapan:

Perbandingan PDF antara helper AES-256 naif yang membangun ulang provider CNG Windows, mode perantaian, dan penjadwalan key untuk setiap objek PDF melawan konstruktor TPdfObjectEncryptor yang diangkat ke luar sehingga loop-nya tidak mengalokasi apa pun
Membuka ulang provider CNG dan membangun ulang penjadwalan key berbiaya kira-kira 150 mikrodetik per objek; konstruktor yang diangkat ke luar membayarnya sekali dan loop yang sudah panas tidak mengalokasi apa pun
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;  // area kerja objek key CNG, dialokasikan sekali
    FScratch: TBytes;    // buffer ciphertext, tumbuh lalu menetap
  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);
  // Penjadwalan key AES dibangun sekali di sini dan dipakai ulang tiap objek
  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 acak baru per objek; ia berjalan terang-terangan mendahului datanya
  CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
    'BCryptGenRandom');
  Src := PByte(Plain);  // nil untuk masukan kosong itu sah: blok padding saja

  // Kueri ukuran: padding CBC selalu menambah 1..16 byte, jadi Need > Length(Plain)
  IVWork := IV;  // BCryptEncrypt memajukan buffer IV selagi ia merantai
  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);  // tumbuh beberapa kali saja, lalu diam

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

  // Tata letak AESV3: IV 16 byte, lalu ciphertext yang sudah di-padding
  Dest.WriteBuffer(IV[0], 16);
  Dest.WriteBuffer(FScratch[0], Written);
end;

Ada tiga detail yang menanggung beban. Kueri ukuran — panggilan BCryptEncrypt pertama, dengan buffer keluaran nil — mengembalikan panjang ciphertext setelah padding, yang tidak pernah sama dengan panjang masukannya; padding bersifat deterministik, jadi Anda bisa menghitung ((Len div 16) + 1) * 16 sendiri dan memangkas separuh jumlah panggilannya, namun kueri itulah kontrak yang terdokumentasi. Kedua, BCryptEncrypt memajukan buffer IV di tempat selagi ia merantai, sehingga sebuah salinan kerja masuk ke setiap panggilan dan IV yang masih murni mendarat di keluarannya. Ketiga, FScratch hanya bertumbuh, sampai sebesar objek terbesar di file itu, dan sesudahnya loop-nya tidak mengalokasi apa pun

Berapa nilai pemakaian ulang handle, terukur

File yang memaksa latihan ini adalah arsip pinjaman hasil pindaian sebesar 1.8 GB: 412.000 objek terenkripsi yang membawa muatan 1.710 MB setelah struktur teks biasanya dikurangkan. Mesin yang sama, file yang sama, penyimpanan NVMe, satu thread:

  • Penyiapan per panggilan (provider dibuka dan key dibangkitkan di dalam helper): fase enkripsi 71.3 detik — 1.710 MB ÷ 71.3 detik ≈ 24 MB/s
  • Keadaan diangkat ke luar (kelas di atas): 9.6 detik — 1.710 MB ÷ 9.6 detik ≈ 178 MB/s

Selisihnya 61.7 detik di seluruh 412.000 panggilan, atau kira-kira 150 µs per panggilan yang habis untuk membuka provider, menyetel mode perantaian, dan membangun ulang penjadwalan key untuk key yang tidak pernah berubah. Tak satu pun dari itu adalah kriptografi. Dengan AES-NI, enkripsi CBC atas buffer besar berjalan mendekati 1.4 GB/s pada satu core, sehingga aritmetika AES-nya sendiri hanya menyumbang sekitar 1.2 detik dari 9.6 detik itu; sisanya sebagian besar adalah dua transisi BCryptEncrypt di ruang pengguna per objek ditambah pembangkitan IV per objek. Membuat IV-nya secara borongan — satu panggilan BCryptGenRandom yang mengisi 4.096 IV sekaligus — memangkas larinya menjadi 8.9 detik. Melewati titik itu Anda sudah berada di lantai per objek milik API-nya, dan tuas yang tersisa adalah paralelisme: objek /V 5 saling bebas di bawah file key bersama, sehingga empat thread pekerja dengan satu objek key masing-masing membawa fase itu ke 3.1 detik sebelum penulis keluarannya menjadi titik serialisasinya

PDF: Diagram batang waktu enkripsi AES-256 pada PDF pindaian 1.8 GB yang turun dari 71.3 detik ke 9.6 detik dengan keadaan CNG yang diangkat ke luar, 8.9 detik dengan IV borongan, dan 3.1 detik memakai empat thread pekerja
Diukur pada pindaian 1.8 GB berisi 412.000 objek: mengangkat keadaan CNG ke luar merebut kembali sekitar 61.7 detik overhead API murni, IV borongan memangkas lebih jauh, dan empat thread pekerja mencapai 3.1 detik sebelum penulisnya menserialkan

Penulisan ulang penuh versus penyimpanan inkremental

Granularitas juga menentukan berapa biaya sebuah penyimpanan. Menambahkan enkripsi ke dokumen teks biasa yang sudah ada berarti menulis ulang setiap objek menurut definisinya: setiap stream dan string berubah baik isi maupun panjangnya, setiap offset referensi silang bergeser, dan tidak ada jalur inkremental yang tersedia. Anggarkan itu sebagai penulisan ulang sekuensial yang penuh, dan tulislah ke sebuah file sementara yang kemudian di-rename menimpa sasarannya, karena crash di tengah enkripsi akan meninggalkan file setengah tersandi yang tidak akan terbuka oleh password apa pun

Arah sebaliknya justru murah. Begitu sebuah file terenkripsi, sebuah incremental update menambahkan objek baru yang dienkripsi dengan file key yang sama dan membiarkan setiap byte aslinya tak tersentuh. Membubuhkan anotasi persetujuan pada arsip terenkripsi 2 GB berbiaya beberapa kilobyte keluaran tambahan, bukan penulisan ulang 2 GB. Konsekuensi bagi pipeline: enkripsi sekali saja, sebagai langkah terakhir pekerjaan itu, lalu biarkan sentuhan berikutnya menumpang penyimpanan inkremental. Rotasi password yang juga merotasi file key kembali menjadi penulisan ulang penuh — jadwalkan seperti itu

Mengukur throughput tanpa membodohi diri sendiri

Klaim throughput enkripsi cenderung salah di pembilangnya, di penyebutnya, atau di keduanya. Pembilangnya seharusnya adalah byte muatan: jumlah panjang stream dan string yang benar-benar didorong melewati AES, setelah kompresi, yang bisa ditotalkan penulisnya sambil berjalan. Ukuran file melebih-lebihkannya — arsip di atas berukuran 1.8 GB di disk, tetapi hanya 1.710 MB darinya yang pernah menyentuh cipher-nya. Penyebutnya seharusnya adalah fase enkripsinya saja, dikurung dengan TStopwatch dari System.Diagnostics, dengan parsing, deflate, dan I/O disk berada di luar kurungan itu. Lipat semuanya ke dalam dan kode enkripsi yang identik akan terukur beberapa kali lebih lambat pada file yang sekadar terkompresi lebih buruk. Angka di atas bisa dibandingkan justru karena kedua sisi pembagiannya hanya berisi enkripsi

Tak satu pun dari ini harus menjadi kode milik Anda sendiri. HotPDF membungkus rekayasa yang sama di balik properti component — ActivateProtection, CryptKeyLength, UseAES256R6 — pada ketinggian yang tepat untuk aplikasi VCL interaktif, dengan jebakan urutan penugasan yang dibahas di artikel AES-256 HotPDF. Untuk pipeline tanpa penjaga, PDF Library for Delphi menerapkan AES-256 revisi 6 pada file yang sudah ada dalam satu panggilan EncryptFile pada Strength 4 lalu memverifikasi sesudahnya apa yang mendarat di disk, sebuah workflow yang ditelusuri di artikel audit enkripsi PDF Library for Delphi

Jalur enkripsi yang diuraikan di sini hadir dalam HotPDF Delphi Component untuk Delphi dan C++Builder serta dalam library PDF Library for Delphi; kedua halaman produknya membawa referensi enkripsi yang lengkap