PDF AES-256 yang dienkripsi dengan password non-ASCII terbuka di program yang menulisnya dan tidak di tempat lain. Penyebabnya hampir selalu langkah persiapan yang hilang: ISO 32000-2 §7.6.4.3.3 mewajibkan password diproses dengan profil SASLprep dari stringprep sebelum di-encode UTF-8 dan di-hash. PDFlibPas, pustaka PDF untuk Delphi dan C++Builder, melakukan persiapan itu di dalam Encrypt, EncryptFile dan DecryptFile
Ini bukan cerita password salah dan bukan cerita bit izin. Jika pengguna Anda mengetikkan password yang tidak pernah Anda terbitkan, mesin retry pada artikel tentang retry password PDF terenkripsi adalah yang Anda perlukan, dan jika Anda mencoba mencari tahu apa yang sebenarnya diberlakukan oleh file yang sudah ada, audit enkripsi dan izin membahas hal itu. Artikel ini lebih sempit dan lebih aneh: passwordnya benar, pengguna mengetiknya dengan benar, dan filenya tetap menolak terbuka di tempat lain
Kenapa password non-ASCII terbuka di satu reader tapi tidak di reader lain?
Karena kedua program itu meng-hash urutan byte yang berbeda dari keystroke yang sama. Turunan kunci revisi 6 di ISO 32000-2 §7.6.4.3.3 mengambil password sebagai byte UTF-8, memangkas hingga 127 byte, menambahkan salt, dan menjalankan hash yang diperkeras; hasilnya diperiksa terhadap entri /U dan /O di encryption dictionary. Tidak ada yang samar dalam rantai itu. Satu byte berbeda saja di mana pun dalam input menghasilkan digest yang sama sekali berbeda, validasi gagal, dan reader hanya bisa mengatakan satu hal: password salah
Byte-nya berbeda karena Unicode menawarkan beberapa cara untuk mengetik apa yang tampak seperti password yang sama. Password bahasa Mandarin bisa datang sebagai karakter precomposed dari satu metode input dan sebagai bentuk compatibility dari metode lain. Password bahasa Jerman atau Prancis yang disalin dari word processor bisa membawa NO-BREAK SPACE (U+00A0) di mana pengguna mengira ada spasi biasa, atau SOFT HYPHEN (U+00AD) yang ter-render sebagai tidak ada apa-apa. SASLprep ada untuk melipat semua itu menjadi satu bentuk kanonik sebelum siapa pun meng-hash apa pun, sehingga setiap implementasi yang patuh menurunkan kunci yang sama dari intent yang sama
Apa sebenarnya yang diubah SASLprep pada sebuah password?
RFC 4013 mendefinisikan SASLprep sebagai profil dari framework stringprep di RFC 3454, dan itu empat langkah berurutan, bukan satu transformasi. Mapping datang pertama: tabel C.1.2 RFC 3454 (spasi non-ASCII) dipetakan ke U+0020, dan tabel B.1 (karakter yang umum dipetakan ke tidak ada) dihapus sepenuhnya. Normalisasi ke Unicode NFKC menyusul, yang merupakan langkah yang melipat karakter compatibility dan urutan combining. Lalu pemeriksaan prohibited-output menolak apa pun di tabel C.2.1 hingga C.9. Terakhir aturan bidirectional dari RFC 3454 bagian 6 diterapkan pada string yang sudah dinormalisasi
PDFlibPas mengimplementasikan seluruh profil itu dalam unit PDFlibSASLprep, yang mengekspos satu entry point. PLSASLprepPassword mengambil password mentah, menulis bentuk yang sudah disiapkan ke var parameter, dan mengembalikan False saat password harus ditolak. Fungsi ini sengaja bersifat total pada happy path: password ASCII saja kembali identik byte demi byte, jadi tidak ada yang berubah pada deployment yang sudah ada
uses
PDFlibSASLprep;
var
Prepared: WideString;
begin
// RFC 4013: mapping, then NFKC, then prohibited output, then the bidi rule
PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared); // -> 'IX' B.1 deletes SOFT HYPHEN
PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared); // -> 'a b' C.1.2 maps NBSP to U+0020
PLSASLprepPassword(WideString(WideChar($00AA)), Prepared); // -> 'a' NFKC folds ORDINAL INDICATOR
PLSASLprepPassword(WideString(WideChar($2168)), Prepared); // -> 'IX' NFKC folds ROMAN NUMERAL NINE
PLSASLprepPassword('user', Prepared); // -> 'user' ASCII is never touched
end;
Ambiguitas U+200B yang tidak diselesaikan oleh tabel-tabel itu
Satu code point mendarat di dua tabel RFC 3454 sekaligus, dan kedua tabel itu tidak sepakat. ZERO WIDTH SPACE (U+200B) jatuh di dalam rentang C.1.2 U+2000 hingga U+200B, di mana aturannya bilang petakan ke U+0020, dan juga jatuh di dalam rentang B.1 U+200B hingga U+200D, di mana aturannya bilang hapus. Baca langkah mapping dalam urutan mana pun dan Anda mendapat byte berbeda dari password yang sama: a+U+200B+b disiapkan menjadi a b di bawah C.1.2 dan menjadi ab di bawah B.1. RFC 4013 menyebutkan kedua tabel dan tidak mengatakan mana yang menang, jadi ini adalah ambiguitas nyata dalam spesifikasi, bukan kesalahan baca. PDFlibPas menguji keanggotaan C.1.2 lebih dulu dan karena itu memetakan U+200B ke spasi, yang merupakan perilaku yang disepakati implementasi stringprep lain yang banyak dipakai; mencocokkan mereka adalah satu-satunya yang penting di sini, karena tujuannya adalah kesepakatan byte dengan reader mana pun yang kebetulan dipakai pelanggan
Membaca file lama: yang sudah disiapkan dulu, mentah kedua
Perbaikan ini menciptakan masalah kompatibilitasnya sendiri. Setiap file AES-256 yang ditulis sebelum perubahan ini meng-hash password UTF-8 mentah, jadi membuat reader benar-benar patuh spesifikasi akan mengunci pelanggan dari arsip mereka sendiri. PDFlibPas menyelesaikan ini di sisi baca dengan mencoba dua kandidat secara berurutan. TPDFDocument.SetPassword membangun daftar kandidat yang dimulai dari bentuk yang sudah disiapkan dan jatuh kembali ke bentuk mentah, dan hanya menambahkan entri yang sudah disiapkan saat dokumennya memang AES-256 dan kedua bentuk itu berbeda. Untuk password ASCII, kedua bentuk itu identik, daftarnya hanya berisi satu entri, dan biaya seluruh mekanisme ini hanya satu perbandingan string. DecryptFile melakukan hal yang sama di sepanjang jalur penulisan ulang AES-256 langsungnya, memanggil PLDirectDecryptFileAES256 dengan password yang sudah disiapkan lebih dulu
var
Lib: TPDFlib;
Bytes: AnsiString;
begin
Lib := TPDFlib.Create;
try
Lib.SetOrigin(1);
Lib.DrawText(100, 100, 'saslprep roundtrip');
// Strength 3 and 4 are the two AES-256 values; both are prepared before hashing
Lib.Encrypt('ow' + WideChar($00AD) + 'ner', 'pa' + WideChar($00AD) + 'ss', 4,
Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1));
Bytes := Lib.SaveToString;
finally
Lib.Free;
end;
Lib := TPDFlib.Create;
try
// 'pass' is what SASLprep produced and what any conforming reader computes,
// so the plain ASCII form opens a file created with the soft-hyphen form
if Lib.LoadFromString(Bytes, 'pass') = 1 then
Caption := IntToStr(Lib.PageCount);
finally
Lib.Free;
end;
end;
Fallback ini membawa satu pengaman yang layak ditiru. Percobaan kedua di DecryptFile hanya berjalan saat bentuk yang sudah disiapkan dan bentuk mentah berbeda dan percobaan pertama tidak melaporkan kode error keras. Kegagalan struktural berarti inputnya rusak atau bukan revisi enkripsi yang Anda asumsikan, dan mencoba ulang file yang rusak dengan password lain hanya membakar satu parse penuh lagi atas input yang berbahaya; alasan di balik refleks itu dijelaskan pada catatan tentang parsing PDF tidak tepercaya dengan aman. Perlu dicatat juga bahwa tidak ada fallback di sisi tulis, dan asimetri itu disengaja. Membaca menoleransi sejarah, menulis tidak: setiap file AES-256 baru mendapat byte yang patuh spesifikasi
Password mana yang ditolak langsung, dan apa itu error 604?
SASLprep bisa menolak sebuah password sepenuhnya, dan saat itu terjadi, enkripsi harus gagal dengan jelas alih-alih diam-diam mengganti dengan sesuatu yang lain. Encrypt dan EncryptFile menyiapkan password owner dan user kapan pun Strength bernilai 3 atau 4, mengembalikan 0 saat ditolak, dan mengatur LastErrorCode ke PDFLIB_ERROR_PASSWORD_SASLPREP, yaitu 604. Dua kelompok input memicunya. Tabel prohibited-output menolak karakter kontrol (C.2.1 dan C.2.2), code point private-use (C.3), non-character (C.4), lone surrogate (C.5), U+FFFD (C.6), karakter ideographic description (C.7), dan rentang display-control serta tagging (C.8 dan C.9). Secara terpisah, aturan bidi RFC 3454 bagian 6 menolak string apa pun yang mengandung karakter RandALCat dari tabel D.1 kecuali string tersebut sekaligus dimulai dan diakhiri dengan karakter itu dan sama sekali tidak mengandung huruf left-to-right
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
// U+0007 is a C.2.1 control character, so preparation refuses the password
if Lib.Encrypt('owner', 'bad' + WideChar($0007), 4,
Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1)) = 0 then
begin
if Lib.LastErrorCode = PDFLIB_ERROR_PASSWORD_SASLPREP then // 604
ShowMessage('The password contains characters that PDF encryption does not permit.');
end;
finally
Lib.Free;
end;
end;
Aturan bidi itulah yang akan mengejutkan tim support Anda. Password Arab atau Ibrani yang diakhiri angka Barat, atau yang punya satu huruf Latin nyempil di tengah, ditolak oleh spesifikasi meskipun terlihat sepenuhnya wajar di kolom entri. Tampilkan 604 sebagai pesan tentang karakter password, bukan sebagai kegagalan enkripsi generik, atau seseorang akan menghabiskan satu sore mencari bug di turunan kunci Anda
Batasan jujur: NFKC, LCat perkiraan, dan satu jebakan Delphi
Dua bagian dari implementasi ini adalah pendekatan (approximation), dan keduanya layak dinyatakan secara jelas alih-alih dikubur. Normalisasi NFKC dilakukan oleh API NormalizeString Windows, dimuat secara dinamis dari Normaliz.dll. Saat pustaka itu tidak tersedia, string yang sudah dipetakan dipakai tanpa dinormalisasi, artinya langkah mapping dan prohibition tetap berjalan tapi folding compatibility tidak. Dalam praktiknya, DLL itu sudah hadir di setiap rilis Windows sejak Vista, jadi jalur terdegradasi ini adalah perhatian untuk pra-Vista dan non-Windows, bukan yang aktif berlaku, tapi password yang mengandalkan folding NFKC akan menghasilkan byte berbeda di sana dan itu divergensi nyata meski jarang terjadi. Pemeriksaan bidi adalah pendekatan kedua: mendeteksi karakter LCat memakai rentang huruf umum alih-alih tabel D.2 RFC 3454 yang lengkap, dan arah kesalahannya itulah yang membuatnya bisa diterima. Karakter LCat yang terlewat hanya bisa membuat aturan bidi lolos di tempat spesifikasi seharusnya menolak, tidak pernah sebaliknya, dan itu tidak pernah menyentuh langkah mapping atau normalisasi, jadi urutan byte yang sudah disiapkan dari password yang diterima tetap tidak berubah. Risiko sisanya karena itu adalah divergensi kebijakan, bukan divergensi byte: password skrip eksotis yang implementasi lebih ketat akan menolak sama sekali. Setiap password yang diterima kedua sisi meng-hash secara identik, yang merupakan properti yang sesungguhnya dibutuhkan interoperabilitas
Terakhir, jebakan sintaks Delphi yang menghabiskan satu jam jika Anda belum pernah mengalaminya. Saat sebuah fungsi mengembalikan tipe prosedural, menugaskannya tanpa tanda kurung tidak memanggilnya. Compiler membaca Proc := GetNormalizeProc; sebagai mengambil alamat dari GetNormalizeProc itu sendiri, lalu melaporkan E2009 dengan keluhan yang tidak membantu bahwa calling convention berbeda, karena accessor-nya memakai convention default sementara tipe API yang diimpor adalah stdcall. Tanda kurung kosong itu wajib
type
TNormalizeString = function(NormForm: Integer; SrcString: PWideChar; SrcLength: Integer;
DstString: PWideChar; DstLength: Integer): Integer; stdcall;
function GetNormalizeProc: TNormalizeString; // loads Normaliz.dll on first use
...
var
Proc: TNormalizeString;
begin
// Proc := GetNormalizeProc; // E2009: reads as @GetNormalizeProc, conventions differ
Proc := GetNormalizeProc(); // correct: calls the accessor and assigns its result
if not Assigned(Proc) then
Exit; // no NFKC available, mapped string is used as-is
end;
Persiapan password adalah salah satu detail yang tidak pernah muncul di daftar fitur tapi menentukan apakah dokumen terenkripsi selamat berhadapan dengan pelanggan di locale lain. Entry point Encrypt, EncryptFile, DecryptFile dan SetPassword yang dijelaskan di sini adalah bagian dari losLab PDF Developer Library Pascal Edition untuk Delphi dan C++Builder, yang halaman produknya membawa referensi enkripsi lengkap dan tabel kode error lengkap