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
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:
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
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