บทความเทคนิค

กุญแจเข้ารหัส PDF ซ้ำบน FPC: การแก้ RNG ของ PDFiumPas

ก่อนรุ่น 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 ถูกตั้ง
random byte ทุกตัวใน stack เข้ารหัสของ PDFiumPas ไหลจาก AesGenerateRandomBytes ใน FPdfAes ไปยังผู้ใช้หกตัว: file encryption key 32 byte ที่ถูก wrap เข้า /UE กับ /OE, salt ของ /U กับ /O, byte padding ของ /Perms, CBC IV ของ AESV3, nonce prefix ของ GCM ใน AESV4 และ KDF salt กับ MAC key
การใช้ generator ตัวเดียวร่วมกันหมายความว่าแหล่งที่เน่าหนึ่งแห่งปนเปื้อน key material ทุกที่พร้อมกัน การแก้จึงลงที่ procedure เดียวแทนที่จะไปแก้ที่ call site ทีละจุด

ทำไมทุกโปรเซสถึงผลิตกุญแจซ้ำกัน

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 หายพร้อมกัน

generator ของ RTL ที่ไม่เคย seed ใน PDFiumPas บน FPC ฝั่ง non-Windows: ด้วย RandSeed 0 ทุกโปรเซส emit ลำดับเดียวกัน เอกสารที่หนึ่งในโปรเซส A จึงพก file key เดียวกับเอกสารที่หนึ่งในโปรเซส B และ AESV4 ทำ GCM nonce ซ้ำเพราะ key เดิมเจอ prefix เดิมพร้อมตัวนับที่รีเซ็ตเป็นศูนย์
เพราะ file key ไม่เคยพึ่งพารหัสผ่าน ใครทำลำดับนี้ซ้ำได้ก็ถือกุญแจได้ทั้งก้อน และ GCM nonce ที่ซ้ำทำลาย 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:

การทดสอบความสุ่มข้ามโปรเซสสำหรับ PDFiumPas: โปรแกรม SaltProbe เรียก DeriveEncryptionKeys แล้วพิมพ์ hex ของ byte 32 ถึง 47 ของ /U, job รันมันสองครั้งในฐานะสองโปรเซสแยกกันและ fail เมื่อสองบรรทัดตรงกัน ส่วน PDF ที่ส่งมอบแล้วเทียบกันด้วย 16 byte ท้ายของสตริง /U
randomness ที่คงที่มองไม่เห็นภายในโปรเซสเดียว เพราะการถอดรหัสกู้กุญแจที่ฝั่งเข้ารหัสเลือกไว้กลับมาได้เสมอ สมบัติที่สำคัญจึงสังเกตได้เฉพาะการเทียบผลลัพธ์ข้ามโปรเซส
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