ก่อนรุ่น 3.114.8 PDFiumPas สร้าง key material สำหรับเข้ารหัส PDF บนเป้าหมาย non-Windows ด้วยฟังก์ชัน Random ของ runtime library และเพราะไม่มีใครเรียก Randomize ทุกโปรเซสจึงผลิตลำดับ byte ชุดเดียวกัน build ของ Free Pascal บน Linux และ macOS จึงเขียน file encryption key, salt, CBC IV และ nonce prefix ของ AES-GCM เหมือนกันเป๊ะตั้งแต่รอบแรกถึงรอบต่อ ๆ ไป รุ่น 3.114.8 อ่าน /dev/urandom แทน และ raise exception เมื่ออ่านไม่ได้
ตัว defect เองเป็นลูปสี่บรรทัด บทเรียนที่ใช้ประโยชน์กว่าคือทำไมเทสต์ suite ที่เข้ารหัสและถอดรหัสเอกสารร้อยฉบับ ทั้ง AESV3 กับ AESV4, มี PDF MAC หรือไม่มี ถึงยังเขียวขึ้นทั้งหมดตลอดมา randomness ที่คงที่ต่อหนึ่งโปรเซสนั้นมองไม่เห็นจากเทสต์ใด ๆ ที่รันอยู่ในโปรเซสเดียว และนั่นแหละคือวิธีที่เทสต์เข้ารหัสมักถูกเขียน
PDFiumPas ใช้ random byte ที่ไหน
random byte ทุกตัวใน stack เข้ารหัสของ PDFiumPas มาจาก procedure เดียวคือ AesGenerateRandomBytes ใน unit FPdfAes แหล่งกำเนิดที่เน่าตัวเดียวจึงปนเปื้อนทั้งหมด security handler มาตรฐานใน ISO 32000-2 §7.6.4 กับส่วนขยาย AESV4 ใน ISO/TS 32003 ใช้ byte พวกนี้ที่จุดเหล่านี้:
- file encryption key ขนาด 32 byte ที่ DeriveEncryptionKeys สร้างใหม่สด ๆ ให้แต่ละเอกสาร แล้ว wrap เข้า /UE กับ /OE ใต้กุญแจที่ derive จากรหัสผ่าน
- salt ขนาด 16 byte สองตัว หนึ่งเก็บใน 16 byte ท้ายของ /U หนึ่งใน 16 byte ท้ายของ /O แต่ละตัวแยกเป็น validation salt 8 byte กับ key salt 8 byte
- byte ที่ 12 ถึง 15 ของ plaintext ข้างหลัง /Perms ซึ่ง ISO 32000-2 เติมด้วยข้อมูลสุ่มก่อน block ถูกเข้ารหัสใต้ file key
- CBC IV ขนาด 16 byte ที่ต่อหน้าสตริงและ stream เข้ารหัสทุกตัวในเอกสาร AESV3
- nonce prefix ขนาด 8 byte สำหรับเอกสาร AESV4 ตามด้วยตัวนับต่อ object ขนาด 4 byte ที่เริ่มจากศูนย์
- /KDFSalt ขนาด 32 byte กับ MAC key เมื่อ EnableIntegrityProtection ถูกตั้ง
ทำไมทุกโปรเซสถึงผลิตกุญแจซ้ำกัน
AesGenerateRandomBytes ใช้ generator ของ OS เฉพาะบน Windows นอกนั้นมันเติม buffer จาก pseudo-random generator ของ RTL ซึ่ง generator ตัวนี้เริ่มจาก RandSeed = 0 เว้นแต่โปรแกรมจะเรียก Randomize คอมเมนต์เหนือลูปบอกว่า generator ถูก seed จาก GetTickCount64 ไม่มีบรรทัดโค้ดไหนทำแบบนั้นเลย คอมเมนต์จึงกลายเป็นที่เดียวที่ seed มีตัวตน:
// branch ฝั่ง non-Windows ของ AesGenerateRandomBytes ก่อน 3.114.8
// (คอมเมนต์เหนือลูปสัญญาว่า seed จาก GetTickCount64 ซึ่งไม่เคยถูกใส่)
P := PByte(Buffer);
for I := 0 to Count - 1 do
P[I] := Byte(Random(256));
ลำดับนี้เริ่มใหม่ทุกโปรเซสและเดินไปภายในโปรเซสนั้น เอกสารแรกที่โปรเซสใดเข้ารหัสจึงใช้ file key ร่วมกับเอกสารแรกของโปรเซสอื่นทุกตัวที่รัน build เดียวกัน ตัวที่สองก็คู่กับตัวที่สอง ไปเรื่อย ๆ file key ใน R5, R6 และ R7 ไม่พึ่งพารหัสผ่านเลย รหัสผ่านแค่ wrap มันเท่านั้น ใครทำลำดับนี้ซ้ำได้ก็ถือกุญแจโดยไม่ต้องรู้รหัสผ่าน AESV4 เพิ่มความพังข้อที่สอง: key เดิมกับ prefix 8 byte เดิมและตัวนับที่รีเซ็ตเป็นศูนย์ทำให้ GCM nonce ซ้ำ ซึ่ง NIST SP 800-38D §8 ห้ามขาด GCM nonce ที่ซ้ำใต้ key เดียวเผย XOR ของ plaintext สองชุดและเปิดเผย authentication subkey tag ที่การเข้ารหัส AESV4-GCM กับ token PDF MAC พึ่งพาอยู่จึงเลิกมีความหมาย confidentiality กับ integrity หายพร้อมกัน
ขอบเขตแคบกว่าที่ย่อหน้าก่อนอาจทำให้เข้าใจ build บน Windows ไม่เคยโดนกระทบ เพราะ branch ฝั่ง Windows เรียก CryptGenRandom ผ่าน advapi32 พร้อม CRYPT_VERIFYCONTEXT มาตลอด และ raise เมื่อล้มเหลว สิ่งที่ถูกเปิดเผยคือผลลัพธ์จาก build ฝั่ง non-Windows ที่เก่ากว่า 3.114.8 ซึ่งในทางปฏิบัติหมายถึงแอป Lazarus และ Free Pascal บน Linux กับ macOS เพิ่มอีกหนึ่งรายการในลิสต์กับดัก Delphi เทียบ FPC ในการ build PDFium
ทำไม Randomize ถึงไม่เคยเป็นทางแก้ที่ถูก
การเรียก Randomize จะซ่อนอาการโดยไม่แก้ที่ต้นทาง เพราะ RandSeed เป็นค่า 32-bit และ Randomize derive มันจากนาฬิกา ตัวเลข key stream ที่เป็นไปได้จึงถูกจำกัดที่ 2^32 และการรู้โดยประมาณว่าไฟล์ถูกเขียนเมื่อไรตัดการค้นให้ต่ำกว่านั้นมาก ซึ่งไม่มีความหมายเทียบกับ AES key 256 bit key material ต้องมาจาก entropy pool ของเคอร์เนล AesGenerateRandomBytes ใน 3.114.8 จึงอ่าน /dev/urandom, วนจัดการ short read และ raise ถ้า pool ส่ง byte ที่ขอไม่ครบ:
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; // ล้มเหลว หรือ stream จบก่อนกำหนด
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');
การปฏิเสธนี้ตั้งใจให้เป็นแบบนั้น และมันตรงกับสิ่งที่ branch ฝั่ง Windows ทำมาตลอดเมื่อ CryptGenRandom ใช้ไม่ได้ การ save แบบเข้ารหัสที่ล้มเหลวคือเหตุการณ์ที่คุณรู้ตัวภายในวันเดียว ส่วนการ save ที่สำเร็จด้วยกุญแจที่ทายได้คือเรื่องที่คุณจะรู้จากคนอื่นเฉย ๆ ตามมาด้วยผลสองข้อในทางปฏิบัติ container หรือ chroot แบบ minimal ที่ไม่มี /dev ที่เตรียมไว้จะล้มเหลวตอนเข้ารหัสแทนที่จะเสื่อมสภาพเงียบ ๆ จง mount มันให้เรียบร้อย และเพราะ exception ลอยออกมาจาก TPdf.SaveAsEncrypted หลังไฟล์ปลายทางถูกเปิดด้วย fmCreate ไฟล์ผลลัพธ์ว่าง ๆ จะถูกทิ้งไว้ให้ error handler ของคุณลบ
ทำไมเทสต์ round-trip ถึงไม่เคยจับมันได้
เทสต์ round-trip มองไม่เห็น randomness ที่คงที่ เพราะการถอดรหัสกู้ file key ที่ฝั่งเข้ารหัสเลือกไว้กลับมาได้เสมอ เทสต์เข้ารหัสเอกสาร เปิดใหม่ด้วยรหัสผ่าน unwrap กุญแจจาก /UE แล้วถอดรหัสทุก object กุญแจที่ทายได้ก็ unwrap ก็ถอดรหัสได้ดีพอกันกับกุญแจสุ่ม และ tag ของ GCM ก็ verify ผ่านเพราะมันถูกคำนวณด้วย key ตัวเดิมนั้นเอง แม้เทสต์ที่เข้ารหัสสองรอบแล้ว assert ว่าผลลัพธ์สองชุดต่างกันก็ยังผ่าน เพราะการเรียกครั้งที่สองในโปรเซสเดียวกันดึง byte ถัดไปของลำดับมา สมบัติที่สำคัญคือกุญแจต่างกันในแต่ละโปรเซส ซึ่งสังเกตได้เฉพาะการเทียบผลลัพธ์ข้ามโปรเซสเท่านั้น เมื่อโค้ดชุดเดียวกันทั้งผลิตและกินค่าหนึ่ง เทสต์จะตาบอดต่อกลุ่ม defect ทั้งกลุ่ม และ randomness คือตัวอย่างที่บริสุทธิ์ที่สุด
จะทดสอบความสุ่มของกุญแจข้ามโปรเซสอย่างไร
รัน probe ตัวเล็ก ๆ สองครั้งในฐานะสองโปรเซสแยกกันบนแพลตฟอร์มเป้าหมาย แล้วเทียบผลลัพธ์ probe ด้านล่างเรียก DeriveEncryptionKeys และพิมพ์ salt ที่เก็บใน byte 32 ถึง 47 ของ entry /U ค่านี้ถูกเขียนไว้ในตัวต่อหน้าในทุกไฟล์เข้ารหัส การพิมพ์มันใน log ของ CI จึงไม่เปิดเผยอะไรเลย แต่มันมาจาก generator ตัวเดียวกับ file key:
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); // ต้องต่างกันทุกรอบที่รัน
end.
ต่อ probe เข้าไปใน build สำหรับเป้าหมาย non-Windows ทุกตัว: รันสองครั้ง ให้ job fail ถ้าสองบรรทัดตรงกัน การเทียบแบบเดียวกันใช้กับไฟล์ที่อยู่ในวงจรแล้วได้ด้วย เอา PDF เข้ารหัสสองฉบับที่เขียนโดยการรันคนละรอบของแอปเดียวกัน อ่านสตริง /U จาก Encrypt dictionary ของพวกมัน แล้วเทียบ 16 byte ท้าย salt ที่เหมือนกันระบุ build ที่ได้รับผลกระทบได้ และเอกสารควรถูกเข้ารหัสใหม่จาก plaintext ด้วย 3.114.8 ขึ้นไป เพื่อให้แต่ละฉบับได้ file key ใหม่ นิสัยทั่วไปที่ควรทำคือรัน code path ฝั่ง non-Windows บนแพลตฟอร์มจริง แทนที่จะเชื่อการรันบน Windows ด้วยเหตุผลเดียวกับbackend timestamp libcurl สำหรับ build ฝั่ง non-Windows
PDFiumPas เป็น PDF component สำหรับ Delphi และ Lazarus ที่สร้างบนเอนจิน PDFium โดย AES-256, AES-GCM และ token PDF MAC ถูก implement เป็นเนทีฟในภาษา Pascal และ key material ดึงจาก generator ของ OS บนทุกแพลตฟอร์ม รายละเอียดและดาวน์โหลดอยู่ที่หน้า PDFium Delphi component