Artikel Teknis

Enkripsi PDF AES-256 Kecepatan-Tinggi untuk Dokumen Masif

Mengenknkripsi PDF 2 GB terdengar seperti masalah pengaliran (streaming): buka file, dorong dua gigabyte melalui AES-256, tulis hasilnya. Model mental itu salah dengan cara yang menentukan seluruh anggaran kinerja. ISO 32000-1 §7.6 menetapkan granularitas enkripsi PDF pada masing-masing objek — setiap aliran dan setiap string dienkripsi secara terpisah, masing-masing dengan vektor inisialisasi dan penambahan (padding) miliknya sendiri. Arsip hasil pindai 2 GB dengan 500.000 objek adalah 500.000 operasi CBC kecil, bukan satu laluan (pass) panjang, dan pada skala tersebut biaya tetap di sekitar setiap operasi lebih penting daripada aritmetika AES di dalamnya

Artikel ini membahas biaya tetap tersebut: ke mana waktu berjalan saat kode Delphi menerapkan AES-256 pada dokumen yang amat besar, dan bagaimana cara memperolehnya kembali. Untuk sisi persiapannya — kata sandi, bendera (flags) izin, perbandingan panggilan kompatibilitas revisi 5 melawan 6 — lihatlah artikel pendamping tentang mengonfigurasi enkripsi AES-256 di HotPDF; tak satu pun dari hal tersebut yang diulang di sini

Setengah juta operasi CBC, bukan satu laluan

Kerangka file tetap berupa teks datar (plaintext). Tabel referensi-silang, nomor objek, kunci kamus, pohon halaman: tidak ada yang dienkripsi, yang mana merupakan cara pembaca melacak objek sebelum mereka memvalidasi kata sandi. Apa yang distandarkan untuk dienkripsi adalah konten — data aliran seperti deskripsi halaman, gambar, font, dan lampiran, plus string seperti nilai metadata dan teks anotasi. Di bawah filter sandi AES-256 setiap bagian ini diproses secara mandiri: sebuah IV 16-byte acak yang baru, CBC di atas byte-byte tersebut, blok penambahan (padding) hingga mencapai batas 16-byte, dan IV ditulis dalam keadaan telanjang di depan teks tersandi (ciphertext)

Dua akibat menyusul kemudian. Pertama, teks tersandi selalu lebih panjang daripada teks datar: IV menambahkan 16 byte dan penambahan menambahkan 1 hingga 16 lebih banyak, jadi string 100-byte akan memakan 128 byte di cakram dan aliran kosong masih menghasilkan 32. Kode yang mengatur ukuran buffer keluaran terhadap panjang asupan (input), atau menulis kembali hanya sejumlah byte yang dibacanya, akan menghasilkan file yang gagal didekripsi pada blok terakhir dari setiap objek. Kedua, biaya melacak jumlah objek, tidak sekadar jumlah byte. Arsip pindaian memusatkan byte-bytenya pada segelintir aliran gambar berukuran besar, namun membawa ratusan ribu aliran pendek dan string kecil di mana biaya operasional tetaplah yang menjadi beban, bukannya AES

Satu-satunya belas kasihan di dalam desain AES-256 adalah penanganan kunci. Penangan keamanan hingga revisi 4 menurunkan kunci tersendiri bagi setiap objek dengan meng-hash kunci file bersanding dengan nomor objek dan generasi, memaksa jadwal kunci baru setiap saat. Skema /V 5 membuang penurunan per-objek ini: satu kunci file 256-bit acak menyandikan setiap objek di dokumen. Fakta tersebut menjadi dasar untuk setiap optimasi di bawah ini — keadaan kriptografik yang mahal dapat dibangun sekali per dokumen, alih-alih dibangun sekali per objek

Kamus /Encrypt R6: satu bukaan lambat, objek-objek murah

Dokumen revisi 6 mendeklarasikan skemanya di dalam kamus /Encrypt pada cuplikan (trailer), dan entri-entri yang bermakna muat dalam segelintir baris:

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

/V 5 memilih arsitektur kunci 256-bit dan /R 6 memilih jabat tangan ISO 32000-2 yang diperkeras. /CF mendefinisikan filter sandi bernama — /AESV3 berarti AES-256 dalam mode CBC dengan IV yang ditambahkan sebelumnya — dan /StmF dan /StrF menugaskan filter tersebut ke aliran dan string masing-masing. /O, /U, /OE, dan /UE menyimpan materi verifikasi kata sandi dan pembungkus kunci, dan /Perms membawa salinan bit izin terenkripsi-AES sehingga penyunting tak ramah (hostile editor) tidak dapat membalikkan nilai /P secara diam-diam

Struktur biaya bersembunyi dalam /OE dan /UE. Membuka bungkusan kunci file dari mereka akan menjalankan Algoritma 2.B, fungsi penurunan-kunci (KDF) berulang yang merantai putaran SHA-256, SHA-384, dan SHA-512 — setidaknya 64 daripadanya, dengan kaidah penghentian yang dikendalikan oleh muatannya — dibangun sedemikian rupa dengan sengaja secara lambat agar gempuran penebakan kata sandi tetap berbiaya mahal. Harga itu dibayar sekali ketika penulis membuat file bersangkutan dan sekali sewaktu sang pembaca membukanya, masing-masing dengan jeda satu digit milidetik. Pada file dengan setengah-juta-objek, KDF hanya laksana kebisingan ringan (noise), dan bila suatu penyimpanan lambat, Algoritma 2.B bukanlah tersangkanya; melainkan ikal (loop) per-objek itulah asalnya

Gunakan kembali handel kunci, gunakan kembali buffer cakaran

Implementasi yang naif adalah fungsi utilitas yang rapi: pembantu EncryptAes256Cbc yang membuka penyedia CNG Windows, memilih CBC, membuat objek kunci, mengenkripsi satu buffer, dan merobohkan semuanya. Benar, dapat-diuji-unit, dan merupakan bencana di dalam ikal 500.000 iterasi. Dokumentasi Microsoft menandai BCryptOpenAlgorithmProvider sebagai sesuatu yang mahal dan merekomendasikan menyembunyikan (caching) handelnya, dan BCryptGenerateSymmetricKey menjalankan jadwal kunci AES secara penuh dan mengalokasikan status penyedia — pemborosan murni saat kunci tersebut tak pernah berubah di seluruh dokumen

RTL Delphi tidak membawa unit impor bcrypt, oleh karena itu deklarasikan titik masuknya secara langsung. Kelas berikut ini akan membangun semua keadaan kriptografis cukup sekali lalu mengenkripsi sembarang jumlah objek tanpa satupun alokasi keadaan-tunak:

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 gagal, NTSTATUS 0x%.8x',
      [Api, Cardinal(Status)]);
end;

type
  TPdfObjectEncryptor = class
  private
    FAlg: BCRYPT_HANDLE;
    FKey: BCRYPT_HANDLE;
    FKeyObject: TBytes;  // ruang kerja objek-kunci CNG, dialokasikan sekali
    FScratch: TBytes;    // cakaran teks tersandi, membesar 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('Kunci file AES-256 harus berukuran 32 byte');
  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);
  // Jadwal kunci AES dibangun sekali di sini dan digunakan kembali untuk setiap 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 segar per objek; mengalir dengan jelas di depan data
  CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
    'BCryptGenRandom');
  Src := PByte(Plain);  // nil untuk asupan kosong adalah sah: blok hanya-penambahan

  // Kueri ukuran: Penambahan CBC selalu menambahkan 1..16 byte, sehingga Need > Length(Plain)
  IVWork := IV;  // BCryptEncrypt memajukan buffer IV saat 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, lalu diam di tempat

  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 teks tersandi dengan penambahan
  Dest.WriteBuffer(IV[0], 16);
  Dest.WriteBuffer(FScratch[0], Written);
end;

Tiga rincian adalah titik pemicu beban (load-bearing). Kueri ukuran — panggilan perdana ke BCryptEncrypt dengan nilai keluaran nil — menyetor balik panjang untai teks tersandi setelah ditambahkan (padded), dan tak akan pernah menyamai ukuran masukan awalnya; operasi penambahan itu dapat dipastikan (deterministic), jadi Anda bisa menghitung ((Len div 16) + 1) * 16 sendiri lalu memangkas hitungan panggilan tersebut menjadi paruhnya, akan tetapi, kueri tersebut adalah apa yang terdokumentasikan dalam spesifikasinya. Kedua, BCryptEncrypt memajukan buffer IV seraya ia merantai, oleh karenanya sebuah kopian aktif (working copy) dikirimkan pada setiap panggilannya dan IV yang bersih mendarat di sisi output. Ketiga, ukuran FScratch hanya akan membesar, hingga selebar objek terbesar dalam file, di mana sesudahnya ikal (loop) tersebut tak akan lagi melakukan alokasi apa-apa

Apa nilai dari penggunaan kembali handel, diukur

File yang memaksakan latihan ini adalah arsip pinjaman hasil pemindaian berukuran 1,8 GB: 412.000 objek terenkripsi membawa 1.710 MB muatan (payload) sesudah kerangka teks biasa disubtraksi. Mesin yang sama, file yang sama, penyimpanan NVMe, utas (thread) tunggal:

  • Persiapan per panggilan (penyedia dibuka dan kunci dibuat di dalam si pembantu): fase penyandian (encryption phase) 71,3 detik — 1.710 MB ÷ 71,3 dtk ≈ 24 MB/dtk
  • Status dialihkan (menggunakan kelas di atas): 9,6 detik — 1.710 MB ÷ 9,6 dtk ≈ 178 MB/dtk

Perbedaannya adalah 61,7 detik melintasi 412.000 panggilan, atau kasarnya sekitar 150 µs per panggilan yang dihabiskan demi membuka penyedia, mematok model berantai (chaining mode), serta merekonstruksi jadwal kunci bagi kunci yang sedari awal tak pernah berubah nilainya. Tak satu pun daripadanya merupakan kriptografi. Dipanggul bersama AES-NI, laju enkripsi model CBC bagi buffer besar dapat berlari mendekati 1,4 GB/detik lajunya bersandarkan satu core, alhasil aritmetika AES itu sendirilah penyokong di balik dalang taksiran waktu 1,2 detik dari total 9,6 detik; yang mana sebagian besarnya merupakan efek sisa transisi mode pengguna (user-mode) BCryptEncrypt bagi tiap objek beserta pembuatan IV per objek. Mem-batch IV ini — yakni satu panggilan ke BCryptGenRandom untuk memuati sebanyak 4.096 darinya sekaligus — memangkas laju berlarinya ke sekitar 8,9 detik. Melebihi wujud lajunya di rentangan lurus tersebut, Anda akan terantuk pada batas dasar per-objek milik sang API, dan daya pendorong sisanya adalah kelonggaran paralel (parallelism): objek-objek per-skema /V 5 berwujud saling independen dalam naungan satu kunci file bersama, lantaran itu empat pekerja (worker) utas dengan masing-masing memanggul satu kunci objek, sanggup menekan langkah tersebut jatuh menjadi 3,1 detik sebelum pada akhirnya si penulis (writer) keluaran mendarat menemui titik serialisasi akhirnya

Tulis ulang penuh versus penyimpanan inkremental

Granularitas juga menentukan apa ongkos dari sebuah penyimpanan (save). Menambahkan enkripsi terhadap dokumen teks datar yang sudah ada akan menulis ulang setiap objek berdasarkan definisinya: setiap aliran dan string berubah pada konten beserta panjangnya, setiap offset referensi-silang akan berpindah, dan tiada jalan masuk berwujud inkremental. Anggarkan itu sebagai sebuah tulis ulang secara urut penuh, dan tuliskan kepada sebuah file sementara yang kelak ditimpa (di-rename) ke arah atas (over) targetnya, lantaran crash di pertengahan langkah enkripsi akan menyisakan sebuah file terenkripsi setengah jalan yang mana mustahil bisa dibuka oleh kata sandi manapun

Arah sebaliknya merupakan jalan yang murah. Setelah sebuah file terenkripsi, sebuah pembaruan inkremental menambahkan objek-objek baru yang terenkripsi dengan kunci file yang sama dan membiarkan setiap byte aslinya tidak tersentuh. Mengecap (stamping) anotasi persetujuan ke dalam sebuah arsip terenkripsi 2 GB hanya butuh memakan kilobyte keluaran tambahan, bukannya penulisan ulang 2 GB. Akibat daripadanya (corollary) bagi jalur perpipaan Anda: enkripsi sekali, sebagai langkah pamungkas dari tugas tersebut, dan biarkan proses-sentuhan berikutnya menunggangi atas dasar per-simpan inkremental. Rotasi kata sandi yang juga memutar (rotates) kunci file merupakan momen penulisan ulang penuh sekali lagi — jadwalkan dia sebagaimana mestinya

Mengukur hasil pemrosesan (throughput) tanpa membodohi diri sendiri

Klaim hasil pemrosesan (throughput) enkripsi cenderung salah pada pembilangnya, penyebutnya, atau keduanya. Pembilang semestinya adalah byte muatan (payload): jumlah panjang aliran dan string yang benar-benar didorong lewat AES, sesudah kompresi, yang mana dapat dijumlahkan seiring berjalannya si penulis. Ukuran file melebih-lebihkannya — arsip di atas memakan 1,8 GB di cakram, namun hanya 1.710 MB darinya yang benar-benar menyentuh sandi tersebut. Penyebut mestinya terbatas pada fase enkripsi semata, diapit dengan TStopwatch dari System.Diagnostics, yang mana penguraian, deflasi (kompresi), serta I/O disk dibuang pada luar sisi apitan. Gabungkan hal itu ke dalamnya lantas dipastikan kode enkripsi identik tersebut akan mengukur lajunya memakan beberapa kali lipat lebih lambat pada sebuah file yang sekadar mengompres dengan lebih buruk. Angka-angka di atas bisa dibandingkan dengan tepat lantaran pembagian pada kedua sisi dari nilai ukurnya memang murni memuat porsi wujud enkripsi-saja

Tak satu pun dari hal-hal tersebut yang diwajibkan untuk menjadi porsi pada kode Anda. HotPDF membungkus rekayasa identik semacamnya ke balik punggung properti komponennya — ActivateProtection, CryptKeyLength, UseAES256R6 — pada rentang nilai ketinggian yang sesuai untuk aplikasi VCL interaktif, diulas persamaannya dengan bahaya jebakan turutan (assignment-order) yang diliput dalam artikel AES-256 dari HotPDF. Untuk alur perpipaan yang tiada terjaga (unattended pipelines), PDFlibPas menerapkan enkripsi AES-256 revisi 6 terhadap file yang ada dalam satu panggilan tunggal EncryptFile di Tingkat Kekuatan 4 lalu sesudahnya memverifikasi apa yang telah mendarat di cakram, sebuah rentang langkah kerja (workflow) yang dibahas pada artikel audit enkripsi PDFlibPas

Jalur enkripsi yang dijelaskan di sini dikirimkan dalam Komponen HotPDF untuk Delphi dan C++Builder dan dalam Pustaka PDFlibPas; kedua halaman produk tersebut memuat referensi enkripsi yang lengkap