Artikel Teknis

Key Enkripsi PDF Berulang di FPC: Perbaikan RNG PDFiumPas

Sebelum versi 3.114.8, PDFiumPas menghasilkan key material enkripsi PDF di target non-Windows dengan fungsi Random milik runtime library, dan karena tak ada yang memanggil Randomize, setiap proses menghasilkan sekuens byte yang sama. Build Free Pascal di Linux dan macOS karena itu menulis key enkripsi berkas, salt, CBC IV, dan prefiks nonce AES-GCM yang identik dari run ke run. Versi 3.114.8 membaca /dev/urandom sebagai gantinya dan melempar exception ketika tidak bisa

Cacatnya sendiri cuma loop empat baris. Pelajaran yang lebih berguna adalah kenapa test suite yang mengenkripsi dan mendekripsi ratusan dokumen — dengan AESV3 dan AESV4, dengan dan tanpa PDF MAC — tetap hijau sepanjang waktu. Kebetulan yang konstan per proses tak terlihat oleh test mana pun yang berjalan di dalam satu proses, dan memang begitulah biasanya test enkripsi ditulis

Di mana PDFiumPas butuh byte acak?

Setiap byte acak di stack enkripsi PDFiumPas berasal dari satu prosedur, AesGenerateRandomBytes di unit FPdfAes, jadi satu sumber yang buruk mencemari semuanya. Security handler standar di ISO 32000-2 §7.6.4 dan ekstensi AESV4 di ISO/TS 32003 mengonsumsi byte-byte itu di tempat-tempat berikut:

  • Key enkripsi berkas 32 byte, dibuat baru oleh DeriveEncryptionKeys untuk tiap dokumen lalu di-wrap ke /UE dan /OE di bawah key turunan password
  • Dua salt 16 byte, satu disimpan di 16 byte terakhir /U dan satu di 16 byte terakhir /O, masing-masing dipecah menjadi salt validasi 8 byte dan salt key 8 byte
  • Byte 12 sampai 15 dari plaintext di balik /Perms, yang diisi ISO 32000-2 dengan data acak sebelum blok dienkripsi di bawah file key
  • CBC IV 16 byte yang ditempelkan di depan setiap string dan stream terenkripsi dalam dokumen AESV3
  • Prefiks nonce 8 byte untuk dokumen AESV4, diikuti counter per objek 4 byte yang mulai dari nol
  • /KDFSalt 32 byte dan MAC key ketika EnableIntegrityProtection diset
Setiap byte acak di stack enkripsi PDFiumPas mengalir dari AesGenerateRandomBytes di FPdfAes ke enam konsumen: key enkripsi berkas 32 byte yang di-wrap ke /UE dan /OE, salt /U dan /O, byte padding /Perms, CBC IV AESV3, prefiks nonce GCM AESV4, serta salt KDF dan MAC key
Satu generator bersama berarti satu sumber buruk mencemari key material di mana-mana sekaligus, dan itulah kenapa perbaikannya mendarat di satu prosedur alih-alih di tiap call site

Mengapa setiap proses menghasilkan key yang sama?

AesGenerateRandomBytes memakai generator sistem operasi hanya di Windows; di tempat lain ia mengisi buffer dari generator pseudo-random RTL, dan generator itu mulai dari RandSeed = 0 kecuali program memanggil Randomize. Komentar di atas loop menyatakan generator di-seed dari GetTickCount64. Tak ada satu baris kode pun yang melakukannya, sehingga komentar itu menjadi satu-satunya tempat seed itu ada:

// Cabang non-Windows milik AesGenerateRandomBytes sebelum 3.114.8
// (komentar di atasnya menjanjikan seed GetTickCount64 yang tak pernah diterapkan)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

Sekuensnya berulang dari awal di tiap proses dan berjalan maju di dalamnya, sehingga dokumen pertama yang dienkripsi sebuah proses berbagi file key dengan dokumen pertama setiap proses lain yang menjalankan build yang sama, dokumen kedua dengan dokumen kedua, dan seterusnya. File key di R5, R6, dan R7 tidak bergantung pada password sama sekali, karena password hanya membungkusnya, yang berarti siapa pun yang mampu mereproduksi sekuens itu memegang key tanpa perlu tahu password. AESV4 menambah kegagalan kedua: key yang sama dengan prefiks 8 byte yang sama dan counter yang mulai lagi dari nol mengulang nonce GCM, yang dilarang keras oleh NIST SP 800-38D §8. Nonce GCM yang berulang di bawah satu key membocorkan XOR dari dua plaintext dan membuka authentication subkey, sehingga tag yang diandalkan enkripsi AESV4-GCM dan token PDF MAC berhenti bermakna apa pun. Kerahasiaan dan integritas runtuh bersamaan

Generator RTL tanpa seed di PDFiumPas pada build FPC non-Windows: dengan RandSeed 0 setiap proses memancarkan sekuens yang sama, sehingga dokumen satu di proses A membawa file key yang sama dengan dokumen satu di proses B, dan AESV4 mengulang nonce GCM karena key yang sama bertemu prefiks yang sama dengan counter yang mulai lagi dari nol
Karena file key tidak pernah bergantung pada password, siapa pun yang mampu mereproduksi sekuens langsung memegang key, dan nonce GCM yang berulang menghancurkan kerahasiaan dan integritas sekaligus

Ruang lingkupnya lebih sempit daripada kesan paragraf tadi. Build Windows tidak pernah terdampak, karena cabang Windows selalu memanggil CryptGenRandom lewat advapi32 dengan CRYPT_VERIFYCONTEXT dan melempar exception ketika gagal. Yang terekspos adalah output dari build non-Windows sebelum 3.114.8, yang dalam praktik berarti aplikasi Lazarus dan Free Pascal di Linux dan macOS — satu entri lagi untuk daftar jebakan Delphi versus FPC di build PDFium

Mengapa Randomize tidak pernah jadi perbaikan yang tepat?

Memanggil Randomize hanya akan menyembunyikan gejala tanpa memperbaiki sumbernya, karena RandSeed adalah nilai 32-bit dan Randomize menurunkannya dari jam. Itu membatasi jumlah key stream yang mungkin di 2^32, dan mengetahui kira-kira kapan sebuah berkas ditulis memangkas pencarian jauh di bawah angka itu — bukan apa-apa dibanding key AES 256-bit. Key material harus berasal dari entropy pool kernel, jadi AesGenerateRandomBytes di 3.114.8 membaca /dev/urandom, berulang atas short read, dan melempar exception bila pool tak mampu menyodorkan setiap byte yang diminta:

Handle := FileOpen('/dev/urandom', fmOpenRead or fmShareDenyNone);
if Handle <> THandle(-1) then
try
  Remaining := Count;
  while Remaining > 0 do
  begin
    Got := FileRead(Handle, P^, Remaining);
    if Got <= 0 then
      Break;               // gagal atau akhir stream tak terduga
    Inc(P, Got);
    Dec(Remaining, Got);
  end;
  if Remaining = 0 then
    Exit;
finally
  FileClose(Handle);
end;
raise Exception.Create('FPdfAes: /dev/urandom unavailable; refusing to emit predictable key/IV');

Menolak itu disengaja, dan ia sejalan dengan yang selalu dilakukan cabang Windows ketika CryptGenRandom tak tersedia. Simpanan terenkripsi yang gagal adalah insiden yang Anda sadari di hari yang sama; simpanan yang sukses dengan key yang bisa diprediksi baru Anda ketahui dari orang lain. Dua konsekuensi praktis mengikuti. Container atau chroot minimal tanpa /dev yang terisi kini gagal mengenkripsi alih-alih terdegradasi diam-diam, jadi mount /dev-nya. Dan karena exception merambat keluar dari TPdf.SaveAsEncrypted setelah berkas target dibuka dengan fmCreate, berkas output kosong tertinggal untuk dihapus error handler Anda

Mengapa test round-trip tak pernah menangkapnya?

Test round-trip tidak bisa melihat kebetulan yang konstan, karena dekripsi mengembalikan file key apa pun yang dipilih enkripsi. Test mengenkripsi sebuah dokumen, membukanya lagi dengan password, membuka bungkusan key dari /UE, dan mendekripsi setiap objek; key yang bisa diprediksi membuka bungkusan dan mendekripsi sama baiknya dengan key acak, dan tag GCM terverifikasi karena dihitung dengan key yang sama itu. Bahkan test yang mengenkripsi dua kali dan mengasertifkan kedua output berbeda pun lolos, karena panggilan kedua di proses yang sama menarik byte-byte berikutnya dari sekuens. Properti yang penting — key berbeda di setiap proses — hanya teramati dengan membandingkan output lintas proses. Kapan pun kode yang sama menghasilkan sekaligus mengonsumsi sebuah nilai, test-testnya buta terhadap kelas cacat utuh, dan kebetulan adalah contoh paling murninya

Bagaimana menguji kebetulan key lintas proses?

Jalankan probe kecil dua kali sebagai proses terpisah di platform target dan bandingkan outputnya. Probe di bawah memanggil DeriveEncryptionKeys dan mencetak salt yang tersimpan di byte 32 sampai 47 entri /U. Nilai itu tertulis terang-terangan di setiap berkas terenkripsi, jadi mencetaknya di log CI tidak membocorkan apa pun, padahal ia berasal dari generator yang sama dengan file key:

Test kebetulan lintas proses untuk PDFiumPas: program SaltProbe memanggil DeriveEncryptionKeys dan mencetak hex byte /U 32 sampai 47, job menjalankannya dua kali sebagai proses terpisah dan gagal ketika kedua baris cocok, dan PDF yang terkirim dibandingkan lewat 16 byte terakhir string /U mereka
Kebetulan yang konstan tak terlihat di dalam satu proses karena dekripsi mengembalikan key apa pun yang dipilih enkripsi, sehingga properti yang penting hanya teramati dengan membandingkan output lintas proses
program SaltProbe;
{$mode delphi}
uses
  SysUtils, FPdfEncrypt;
var
  Opts: TPdfEncryptOptions;
  Keys: TPdfEncryptionKeys;
  I: Integer;
  Hex: string;
begin
  Opts := TPdfEncryptOptions.Default;
  Opts.UserPassword := 'probe';
  Opts.Revision := erR6;
  DeriveEncryptionKeys(Opts, Keys);
  Hex := '';
  for I := 32 to 47 do          // /U = hash 32 byte + salt 16 byte
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // harus berbeda di setiap run
end.

Pasang probe itu ke build untuk setiap target non-Windows: jalankan dua kali, gagalkan job bila kedua baris cocok. Perbandingan yang sama bekerja pada berkas yang sudah beredar. Ambil dua PDF terenkripsi yang ditulis oleh run berbeda dari aplikasi yang sama, baca string /U dari dictionary Encrypt mereka, dan bandingkan 16 byte terakhirnya; salt yang identik mengidentifikasi build yang terdampak, dan dokumen-dokumennya sebaiknya dienkripsi ulang dari plaintext-nya dengan 3.114.8 atau lebih baru agar masing-masing menerima file key baru. Kebiasaan umumnya adalah menjalankan kode jalur non-Windows di platformnya sendiri alih-alih percaya pada run Windows — nalar yang sama di balik backend timestamp libcurl untuk build non-Windows

PDFiumPas adalah komponen PDF Delphi dan Lazarus yang dibangun di atas engine PDFium, dengan AES-256, AES-GCM, dan token PDF MAC yang diimplementasikan native di Pascal serta key material yang diambil dari generator sistem operasi di setiap platform. Detail dan unduhan ada di halaman komponen Delphi PDFium