مقال تقني

مفاتيح تشفير PDF مكررة على FPC: إصلاح RNG في PDFiumPas

قبل الإصدار 3.114.8 كانت PDFiumPas تولد مادة مفاتيح تشفير PDF على الوجهات غير Windows بدالة Random في مكتبة زمن التشغيل، ولما لم يستدعِ شيء Randomize أنتجت كل عملية المتتالية البايتية نفسها. لذا كتبت بنيات Free Pascal على Linux وmacOS مفاتيح تشفير ملفات وملحًا وIVs لـ CBC وبادئات nonces لـ AES-GCM متطابقة تشغيلًا بعد تشغيل. ويقرأ الإصدار 3.114.8 ‏/dev/urandom بدلًا من ذلك ويرفع استثناء حين يعجز

العيب نفسه حلقة من أربعة أسطر. والدرس الأكثر فائدة هو لماذا بقي جناح اختبارات يشفر ويفك تشفير مئات المستندات، بـ AESV3 وAESV4، مع PDF MAC وبدونه، أخضر طوال الوقت. العشوائية الثابتة لكل عملية غير مرئية لأي اختبار يجري داخل عملية واحدة، وهذا بالضبط كيف تكتب اختبارات التشفير عادة

أين يحتاج PDFiumPas بايتات عشوائية؟

كل بايت عشوائي في مكدس التشفير في PDFiumPas يأتي من إجراء واحد، AesGenerateRandomBytes في وحدة FPdfAes، فمصدر واحد فاسد يلوث الكل. ومعالج الأمان القياسي في ISO 32000-2 §7.6.4 وامتداد AESV4 في ISO/TS 32003 يستهلكان تلك البايتات في هذه المواضع:

  • مفتاح تشفير الملف ذو 32 بايتة، الذي يولد DeriveEncryptionKeys من جديد لكل مستند ثم يلف في /UE و/OE تحت مفاتيح مشتقة من كلمة السر
  • ملحان ذوا 16 بايتة، أحدهما مخزن في آخر 16 بايتة من /U والآخر في آخر 16 بايتة من /O، وكل منهما مقسوم إلى ملح تحقق من 8 بايتات وملح مفتاح من 8 بايتات
  • البايتات من 12 إلى 15 من النص الصريح خلف /Perms، التي تملؤها ISO 32000-2 ببيانات عشوائية قبل تشفير الكتلة تحت مفتاح الملف
  • IV لـ CBC بطول 16 بايتة يسبق كل سلسلة وتدفق مشفرين في مستند AESV3
  • بادئة nonce بطول 8 بايتات لمستندات AESV4، يليها عدّاد لكل كائن من 4 بايتات يبدأ من الصفر
  • ‏/KDFSalt ذو 32 بايتة ومفتاح MAC عند ضبط EnableIntegrityProtection
كل بايت عشوائي في مكدس التشفير في PDFiumPas يجري من AesGenerateRandomBytes في FPdfAes إلى ستة مستهلكين: مفتاح تشفير الملف ذو 32 بايتة الملفوف في /UE و/OE، وملحي /U و/O، وبايتات الحشو في /Perms، وIV لـ CBC في AESV3، وبادئة nonce للـ GCM في AESV4، وملح KDF ومفتاح MAC
مولد مشترك واحد يعني أن مصدرًا فاسدًا واحدًا يلوث مادة المفاتيح في كل مكان دفعة واحدة، ولهذا حط الإصلاح في إجراء واحد لا عند كل موقع استدعاء

لماذا أنتجت كل عملية المفتاح نفسه؟

استخدم AesGenerateRandomBytes مولد نظام التشغيل على Windows فقط؛ وفي كل مكان آخر ملأ المخزن من المولد الزائف العشوائي في RTL، وذلك المولد يبدأ من RandSeed = 0 ما لم يستدع البرنامج Randomize. والتعليق فوق الحلقة قال إن المولد مبذور من GetTickCount64. لم يفعل ذلك سطر كود قط، فصار التعليق الموضع الوحيد الذي وُجد فيه البذر:

// فرع AesGenerateRandomBytes غير Windows قبل 3.114.8
// (التعليق فوقه وعد ببذر GetTickCount64 لم يطبق قط)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

تعيد المتتالية البدء مع كل عملية وتتقدم داخلها، فيتقاسم أول مستند تشفره أي عملية مفتاح ملفه مع أول مستند لكل عملية أخرى تشغل البنية نفسها، والثاني مع الثاني، وهكذا. ومفتاح الملف في R5 وR6 وR7 لا يعتمد على كلمة السر إطلاقًا، لأن كلمة السر تلفه فقط، أي أن أي شخص قادر على إعادة إنتاج المتتالية يحوز المفتاح دون معرفة كلمة سر. ويضيف AESV4 فشلًا ثانيًا: المفتاح نفسه مع بادئة الـ 8 بايتات نفسها وعدّاد يعيد البدء من الصفر يكرر nonces الـ GCM، وهو ما يحرمه NIST SP 800-38D §8 منعًا قاطعًا. وnonce مكرر للـ GCM تحت مفتاح واحد يكشف XOR للنصين الصريحين ويكشف المفتاح الفرعي للمصادقة، فتتوقف الوسوم التي تعتمد عليها تشفير AESV4-GCM ورمز PDF MAC عن معنى أي شيء، وتذهب السرية والسلامة في وقت واحد

مولد RTL غير مبذور في PDFiumPas على بنيات FPC غير Windows: مع RandSeed 0 تصدر كل عملية المتتالية نفسها، فيحمل المستند الأول في العملية A مفتاح الملف نفسه كالمستند الأول في العملية B، ويكرر AESV4 nonces الـ GCM لأن المفتاح نفسه يلاقي البادئة نفسها والعدّاد يعيد البدء من الصفر
لمفتاح الملف لا يعتمد على كلمة السر قط، يحوز المفتاح أي من يستطيع إعادة إنتاج المتتالية بلا قيد، وتدمر nonces الـ GCM المكررة السرية والسلامة معًا

النطاق أضيق مما قد توحي به تلك الفقرة. بنيات Windows لم تتأثر قط، لأن فرع Windows كان دائمًا يستدعي CryptGenRandom عبر advapi32 مع CRYPT_VERIFYCONTEXT ويرفع استثناء عند فشل ذلك. وما انكشف هو مخرجات بنيات غير Windows الأقدم من 3.114.8، وهذا عمليًا يعني تطبيقات Lazarus وFree Pascal على Linux وmacOS، مدخلًا آخر لقائمة فخاخ Delphi مقابل FPC في بنيات PDFium

لماذا لم يكن Randomize الإصلاح الصحيح أبدًا؟

استدعاء Randomize كان سيخفي العرَض دون إصلاح المصدر، لأن RandSeed قيمة من 32 بت ويشتقها Randomize من الساعة. ذلك يسقف عدد تدفقات المفاتيح الممكنة عند 2^32، ومعرفة الوقت التقريبي لكتابة ملف تهبط بالبحث إلى ما دون ذلك بكثير، وهو لا شيء أمام مفتاح AES من 256 بت. مادة المفاتيح يجب أن تأتي من مخزن الإنتروبيا في النواة، لذا يقرأ AesGenerateRandomBytes في 3.114.8 ‏/dev/urandom، ويحلّق فوق القراءات القصيرة، ويرفع استثناء إن عجز المخزن عن تسليم كل بايت مطلوب:

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;               // فشل أو نهاية تدفق غير متوقعة
    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');

الرفض مقصود، ويطابق ما كان يفعله فرع Windows دائمًا حين يكون CryptGenRandom غير متاح. حفظ مشفر فاشل حادثة تنتبه لها في اليوم نفسه؛ وحفظ ناجح بمفاتيح يمكن التنبؤ بها حادثة تعرفها من غيرك. تترتب على ذلك نتيجتان عمليتان. حاوية دنيا أو chroot بلا /dev ممتلئة يفشل تشفيرها الآن بدل التدهور بهدوء، فركّبها. ولأن الاستثناء ينتشر خارج TPdf.SaveAsEncrypted بعد فتح الملف الهدف بـ fmCreate، يبقى ملف إخراج فارغ خلفه لمعالج الأخطاء لديك ليحذفه

لماذا لم تصطد اختبارات الذهاب والإياب به قط؟

اختبار الذهاب والإياب لا يرى عشوائية ثابتة، لأن فك التشفير يسترد مفتاح الملف الذي اختاره التشفير مهما كان. يشفّر الاختبار مستندًا، ويفتحه من جديد بكلمة السر، ويفك لف المفتاح من /UE، ويفك تشفير كل كائن؛ ومفتاح يمكن التنبؤ به يفك لفه ويفك تشفيره بنفس جودة مفتاح عشوائي، وتتحقق وسوم GCM لأنها حسبت بالمفتاح نفسه. وحتى اختبار يشفّر مرتين ويؤكد اختلاف المخرجين ينجح، لأن الاستدعاء الثاني في العملية نفسها يسحب البايتات التالية من المتتالية. الخاصية المهمة، مفتاح مختلف في كل عملية، لا ترى إلا بمقارنة المخرجات عبر العمليات. ومتى كان الكود نفسه ينتج قيمة ويستهلكها، عميت الاختبارات عن أصناف كاملة من العيوب، والعشوائية أنقى مثال على ذلك

كيف تختبر عشوائية المفاتيح عبر العمليات؟

شغّل مسبارًا صغيرًا مرتين بوصفه عمليتين منفصلتين على الوجهة المستهدفة وقارن المخرج. يستدعي المسبار أدناه DeriveEncryptionKeys ويطبع الملح المخزن في البايتات من 32 إلى 47 من مدخل /U. تلك القيمة تكتب على مرأى من الجميع في كل ملف مشفر، فطباعتها في سجلات CI لا تفشي شيئًا، ومع ذلك تأتي من المولد نفسه الذي يأتي منه مفتاح الملف:

اختبار عشوائية عبر العمليات لـ PDFiumPas: يستدعي برنامج SaltProbe الدالة DeriveEncryptionKeys ويطبع ست عشريًا بايتات /U من 32 إلى 47، وتشغل الوظيفة البرنامج مرتين بعمليتين منفصلتين وتفشل حين تتطابق السطران، وتقارن ملفات PDF المشحونة بآخر 16 بايتة من سلاسل /U فيها
العشوائية الثابتة غير مرئية داخل عملية واحدة لأن فك التشفير يسترد أي مفتاح اختاره التشفير، فالخاصية المهمة لا ترى إلا بمقارنة المخرجات عبر العمليات
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 = بصمة 32 بايتة + ملح 16 بايتة
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // يجب أن يختلف في كل تشغيل
end.

وصّل المسبار في البنية لكل وجهة غير Windows: شغّله مرتين، وأفشل الوظيفة إن تطابق السطران. والمقارنة نفسها تعمل على ملفات متداولة سلفًا. خذ ملفي PDF مشفرين كتبهما تشغيلان مختلفان للتطبيق نفسه، واقرأ سلسلتي /U من قاموسي Encrypt فيهما، وقارن آخر 16 بايتة؛ الملحان المتطابقان يحددان بنية متأثرة، وينبغي تشفير المستندين من نصيهما الصريحين من جديد بـ 3.114.8 أو أحدث ليحصل كل منهما على مفتاح ملف جديد. والعادة العامة أن تمارس مسارات كود غير Windows على المنصة ذاتها لا أن تثق بالتشغيل على Windows، وهو المنطق نفسه وراء خلفية الطوابع الزمنية libcurl للبنيات غير Windows

‏PDFiumPas مكوّن PDF لدلفي وLazarus مبني على محرك PDFium، مع AES-256 وAES-GCM ورمز PDF MAC منفذة أصلًا في Pascal ومادة مفاتيح تسحب من مولد نظام التشغيل على كل منصة. التفاصيل والتنزيلات على صفحة مكوّن PDFium لدلفي