مقال تقني

مرشحات تشفير PDF في Delphi: سياسات StmF وStrF وEFF

ينفذ مكوّن HotPDF PDF لـDelphi نموذج مرشح التشفير في ISO 32000-1 §7.6.5 على شكل ثلاث سياسات مستقلة لا مفتاح واحد: إذ يعيّن ConfigureCryptFilterDefaults مرشح السلاسل /StrF ومرشح التدفقات /StmF ومرشح الملفات المضمّنة /EFF كلًا على حدة، بينما يتجاوز SetStreamCryptFilter تدفقًا واحدًا، ويبلغ GetLoadedCryptFilterInfo عما يصرح به الملف الوارد. وتعيش معظم أخطاء التوافق بين ملفات PDF المشفرة في الفجوات بين هذه السياسات الثلاث

إليك الفشل الذي يدفع الناس إلى هذه الطبقة. تشحن فريقًا مستندًا يجب أن يظل محتوى صفحته قابلًا للقراءة لدى أداة لاحقة، بينما يجب ألا تكون الحمولة المرفقة كذلك، فيضبط /EFF /StdCF ويترك /StmF /Identity. يفتح Acrobat الملف بلا مشكلة. لكن قارئًا تابعًا ملتزمًا بالمواصفة يعيد المرفق على شكل بيانات مشفرة تالفة، لأن /EFF سياسة من جهة المنتج تحدد المرشح الذي ينطبق على الملفات المضمّنة، بينما يظل القارئ العام يحل التدفق غير المعلم عبر /StmF. وليس الإصلاح قيمة مختلفة لـ/EFF، بل مرشح /Crypt صريح في تدفق الملف المضمّن نفسه

ما الذي تتحكم فيه طبقة مرشح التشفير فعلًا؟

تقع مرشحات التشفير بين خوارزمية التشفير ورسم الكائنات، وهي تقرر أي كائنات تلمسها الخوارزمية لا كيف تعمل الخوارزمية. يربط قاموس /CF داخل قاموس التشفير الأسماء بتعريفات المرشحات، ويحمل كل تعريف طريقة /CFM و/Length اختياريًا و/AuthEvent. ثم تختار الإدخالات الثلاثة العليا /StrF و/StmF و/EFF أي مرشح مسمى ينطبق على السلاسل، وعلى التدفقات التي لا تملك مرشحًا صريحًا، وعلى الملفات المضمّنة. وتقيّد HotPDF عمدًا ما تكتبه معالجاتها المضمنة. فلا يقبل ConfigureCryptFilterDefaults إلا الأسماء المحجوزة للمعالج النشط: إذ يصدر معالج الأمان القياسي /StdCF أو /Identity، ويصدر معالج المفتاح العام /DefaultCryptFilter أو /Identity، بينما يؤدي أي شيء آخر إلى رفع EArgumentException عند موضع الاستدعاء. ومع ذلك تُحفظ المرشحات التي كتبها منتجون خارجيون بأسماء أخرى في مسارات التحميل والفحص وإعادة الكتابة التوافقية، ولذلك تكون HotPDF محافظة ككاتبة ومتساهلة كقارئة. وينطبق حارسان إضافيان: يرفع الاستدعاء EInvalidOpException بعد بدء تسلسل المستند، ويرفعه مرة أخرى إذا كان المستند في تحديث تزايدي، لأن سياسة التشفير لا يمكن أن تتغير بين مراجعات الملف نفسه

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'wrapper.pdf';
    Pdf.OwnerPassword := 'owner-secret';
    Pdf.UserPassword := 'open-secret';
    Pdf.CryptKeyLength := aes128;
    // السلاسل مشفرة، وتدفقات الصفحات بنص صريح، والمرفقات مشفرة
    Pdf.ConfigureCryptFilterDefaults('StdCF', 'Identity', 'StdCF');
    Pdf.ActivateProtection := True;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(50, 50, 0, 'Visible stream operators');
    Pdf.AddDocumentAttachment('payload.bin', 'Encrypted payload');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

هناك قيد يستحق ذكره مقدمًا، لأنه يُفحص متأخرًا ويفاجئ الناس. تتطلب مرشحات التشفير المسماة في HotPDF تشفير المستند باستخدام aes128 أو aes256 أو aesgcm. فإذا ضبطت سياسة مرشح فوق RC4 من نوع k40 أو k128، ترفع تمريرة التحقق التي تعمل عند تفعيل التشفير استثناءً بدل ترقية نوع المفتاح بصمت. وهذا هو موقف التصميم نفسه في بقية مسار تشفير PDF بـAES-256 في Delphi: ارفض الإعداد الملتبس بدل تخمين ما قصده المستدعي

لماذا يعني الإدخال /Length شيئين مختلفين؟

لأن المواصفة تحدده بوحدتين مختلفتين بحسب معالج الأمان، وعلى HotPDF احترام كليهما. ففي قاموس مرشح تشفير تكون قيمة /CFM فيه /V2، يُعبّر عن إدخال /Length بالبايتات تحت معالج الأمان القياسي، وبـالبتات تحت معالج المفتاح العام. أما /Length في قاموس التشفير الموجود بجانب /V (ISO 32000-1 §7.6.2) فيكون دائمًا بالبتات. اقرأ قاموس مرشح يحمل /Length 16، وستجد مفتاحًا بطول 128 بت في ملف معالج قياسي وملفًا مرفوضًا في ملف معالج مفتاح عام. وتطبّع HotPDF هذا عند التقاط الإعداد المحمل. فهي تضرب /Length الخاص بمرشح /V2 في ثمانية فقط عندما لا يكون الملف مشفرًا بمفتاح عام، وتعود إلى /Length على مستوى المستند عندما يحذف المرشح قيمته الخاصة، ثم تخزن الناتج في THPDFCryptFilterInfo.KeyLengthBits. ويُثبت AESV2 على 128 بت، وAESV3 وAESV4 على 256، لأن هذه الطرق لا تتيح حجم مفتاح قابلًا للتفاوض. ثم يأتي الجزء الصارم: لا تُقبل إلا قيمتا 40 بت و128 بت لـ/V2. وأي مرشح يحل إلى طول آخر يُبلغ عنه بأنه غير متاح وتفشل العملية، بدل تقريبه إلى 128 بحجة أن معظم المنتجين قصدوا 128. فتطبيع طول المفتاح بصمت هو طريق شحن ملف يفك التشفير على جهازك ولا يفك التشفير في أي مكان آخر

var
  Reader: THotPDF;
  Info: THPDFCryptFilterInfo;
  I: Integer;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    if Reader.LoadFromFile('incoming.pdf', 'open-secret') <> 1 then
      Exit;
    // يكون /StrF و/StmF افتراضيًا Identity، بينما يكون /EFF افتراضيًا /StmF
    WriteLn(Reader.LoadedStringCryptFilterName);        // StdCF
    WriteLn(Reader.LoadedStreamCryptFilterName);        // Identity
    WriteLn(Reader.LoadedEmbeddedFileCryptFilterName);  // StdCF
    for I := 0 to Reader.GetLoadedCryptFilterCount - 1 do
      if Reader.GetLoadedCryptFilterInfo(I, Info) then
        if (Info.Method = hcfmV2) and
           not (Info.KeyLengthBits in [40, 128]) then
          raise Exception.CreateFmt(
            'crypt filter /%s: unsupported V2 key length %d',
            [String(Info.Name), Info.KeyLengthBits]);
  finally
    Reader.Free;
  end;
end;

ما ضمانة /CFM /None، وكيف يختلف /Identity؟

يصلان إلى النتيجة نفسها بطريقين مختلفين، وخلطهما يفسد عمليات البحث. فالمرشح المسمى الذي تكون قيمة /CFM فيه /None، والمرشح المسمى الذي يحذف /CFM بالكامل، يعنيان معًا أن هذا المرشح لا ينفذ تشفيرًا ولا فك تشفير — إذ تحوّل HotPDF الإدخال المفقود إلى None قبل الحل، ولذلك ينتهيان إلى hcfmNone مع طول مفتاح مسجل يساوي صفرًا. أما /Identity فمختلف من حيث النوع: فهو الاسم المحجوز الذي يتجاوز البحث في /CF بالكامل، ولذلك يجوز للمستند الإشارة إلى /Identity من دون تعريفه في أي موضع داخل /CF. وأسماء PDF حساسة لحالة الأحرف، وهو ما يجعل تفصيلًا تنفيذيًا آخر غير قابل للتفاوض: لا يجوز لأي بحث عن مرشح تشفير أن يتجاهل حالة الأحرف. تحل HotPDF أسماء القاموس الفرعي /CF وإدخال /Length الخاص بالمرشح وفحص /Type الخاص بالتدفق عبر عمليات بحث حساسة لحالة الأحرف. والملف الذي يعرّف /stdcf بينما يشير /StmF إلى /StdCF ملف مشوه، ومعاملة المفتاحين كأنهما واحد يحوّل خطأ تأليف يمكن اكتشافه إلى مفتاح خاطئ يُطبق بصمت على كل تدفق في المستند

جعل /EFF ثابتًا في تدفقات الملفات المضمّنة

عندما يختلف /EFF عن /StmF، يحتاج تدفق الملف المضمّن إلى إدخال /Crypt صريح في مقدمة /Filter الخاصة به، وإلى قاموس /DecodeParms مطابق يحمل /Name في الموضع نفسه من المصفوفة. وتحسب HotPDF هذا لكل تدفق وقت الحفظ: تكتشف /Type /EmbeddedFile، وترث مرشح الملفات المضمّنة المكوّن، ولا تصدر علامة /Crypt الصريحة إلا عندما يختلف الاسم الموروث عن الافتراضي الفعال للتدفق. وعندما يتفق /EFF و/StmF، لا تُكتب العلامة لأن القارئ سيحل المرشح نفسه على أي حال. ثم يصبح موضع المصفوفة مهمًا بقدر أهمية الاسم. فعند قراءة HotPDF لتدفق، تفحص /Filter بحثًا عن إدخال /Crypt، وتسجل فهرسه، ثم تبحث عن الفهرس نفسه في مصفوفة /DecodeParms للعثور على /Name. ويُحل /Crypt في الفهرس 0 المقترن بمعاملات في الفهرس 1 إلى /Identity لا إلى مرشحك. وهذا هو سبب ملء الكاتب مصفوفة المعاملات بقيمة null عندما كان التدفق يملك /Filter سابقًا من دون /DecodeParms: يجب أن تبقى المواضع متراصفة

وتحت ذلك فخ أشد حدة. فإذا كان /Filter أو /DecodeParms الموجود كائنًا غير مباشر — وهو شائع في الملفات التي تولدها مولدات تشارك مصفوفة مرشح واحدة بين تدفقات عديدة — فإن إدخال /Crypt في مكانه سيعدل رسم مرشح مشتركًا ويفسد كل تدفق آخر يشير إليه. تحل HotPDF الكائن غير المباشر وتنسخه أولًا إلى كائن مباشر خاص بالتدفق، وتمحو أرقام الكائن والجيل حتى لا يُضم الجذر غير المباشر الأصلي داخل المصفوفة الجديدة. وبالنسبة إلى تدفق كان يستخدم ASCIIHexDecode أصلًا، تكون النتيجة المتسلسلة /Filter [ /Crypt /ASCIIHexDecode ] مع /DecodeParms [ << /Type /CryptFilterDecodeParms /Name /StdCF >> ... ]. وينطبق الانضباط الموضعي نفسه على كل سلسلة مرشحات أخرى، بما فيها السلاسل التي تمر بها عند استخراج الصور من PDF محمل عبر مرشحات فكها

// يحتفظ Editor بمستند محمل، وContentStream هو
// THPDFStreamObject يحمل /Filter غير مباشر من نوع /ASCIIHexDecode
Editor.OwnerPassword := 'owner-secret';
Editor.UserPassword := 'open-secret';
Editor.CryptKeyLength := aes128;
Editor.ConfigureCryptFilterDefaults('StdCF', 'Identity');
Editor.SetStreamCryptFilter(ContentStream, 'StdCF');
Editor.ActivateProtection := True;
Editor.SaveLoadedDocument('out.pdf');

// يمحو الاسم الفارغ التجاوز ويزيل /Crypt القديم
// مع معاملات فك الترميز الخاصة به عند الحفظ التالي
Editor.SetStreamCryptFilter(ContentStream, '');
Editor.SaveLoadedDocument('cleared.pdf');

هل ترث تدفقات الكائنات سياسة /Encrypt الخاصة بالمستند؟

لا، وافتراض ذلك طريقة موثوقة لإنتاج بيانات تالفة. يجب أن يتبع تدفق الكائن سياسة /StmF الفعلية أو علامته الصريحة /Crypt، فوجود قاموس /Encrypt وحده لا يجعل كل حاوية /ObjStm نصًا مشفرًا. ويمتلك مستند ذو /StmF /Identity تدفقات كائنات بنص صريح رغم أن سلاسله مشفرة بالكامل، بينما يمرر مزيل التشفير الذي يفكها على أي حال إلى مرحلة فك الضغط إدخالًا لم يكن ناتجًا عن deflate أصلًا

والنتيجة المتعلقة بالكائنات الأعضاء هي الجزء الذي يستحق القراءة مرتين. فوفق ISO 32000-1 §7.5.7، تكون السلاسل داخل تدفق كائن مشفّر بنص صريح أصلًا بعد فك تشفير الحاوية نفسها، ولذلك سيؤدي فك تشفيرها مرة أخرى إلى فك مزدوج. تحمي HotPDF من ذلك بالاستعلام عن تشفير حاوية كل كائن من النوع 2، وتجاوز الكائن عندما تكون مشفرة، وتسجيل عمليات التجاوز في XRefProbeDecryptObjStmSkips بوصفها دليلًا مباشرًا على عمل الحارس. وعندما تكون الحاوية بنص صريح، لم تكن سلاسل الأعضاء مغطاة بأي شيء، ولذلك تجسد HotPDF هذه الأعضاء وتطبق /StrF على كل منها منفردًا — وتستخدم، كما ينفذ التطبيق فعلًا، رقم كائن العضو ورقم الجيل لا رقم كائن /ObjStm المحتوي. اعكس هذا في ملف ذي سياسة مختلطة، وستتحول كل سلسلة في كل كائن مضغوط إلى ضجيج. وتغطي الملاحظات على تدفقات كائنات PDF والتحديثات التزايدية قواعد مستوى الحاوية هذه بمزيد من التفصيل

أين ترفض HotPDF التخمين؟

لا توجد دلالات لمرشحات التشفير تحت /V 4، ولذلك ترفض HotPDF أي تجاوز لكل تدفق في مثل هذا الملف بخطأ صريح، بدل كتابة علامة /Crypt لن يحترمها أي قارئ ملتزم. وينطبق الأمر نفسه في جانب القراءة: فقالب تشفير بقيمة /V أقل من 4 يمحو أسماء المرشحات المحملة الثلاثة، لأنه لا يوجد شيء يمكن الإبلاغ عنه. وتُفرض ثلاثة حدود إضافية عن قصد:

  • يُرفض مرشح لكل تدفق غير Identity في مستند مشفر بمفتاح عام، لأن سياسة خاصة بتدفق تحت معالج المفتاح العام تحتاج إلى غلاف مستلم خاص بالتدفق لا تصدره HotPDF بعد
  • تُرفض الملفات المضمّنة المشفرة بمفتاح عام عندما يختلف /EFF عن /StmF الفعال للسبب نفسه، بدل كتابتها في شكل لا يستطيع أحد فك تشفيره
  • ينطبق المسار السريع للملف المباشر AES-256 فقط عندما تحل السلاسل والتدفقات والملفات المضمّنة كلها إلى طريقة مرشح التشفير نفسها ولا يحمل أي كائن في الملف علامة /Crypt صريحة؛ أما السياسة المختلطة أو البيانات الوصفية بنص صريح فتجبر على الرجوع إلى مسار رسم الكائنات الكامل

ولا يتعلق أي من هذه الحدود بالأداء. فهي تحدد المواضع التي ينتج فيها التخمين الخاطئ ملف PDF يفتح في عارض واحد، ويفشل في آخر، ولا يعطي المطور أي إشارة حتى يبلغ العميل عن المشكلة. فرفض الإعداد عند ConfigureCryptFilterDefaults أو وقت الحفظ يكلف استثناءً واحدًا، بينما يكلف تشفير ملف مضمّن بمفتاح خاطئ بصمت دورة دعم كاملة. وإذا كنت تبني برنامج Delphi أو C++Builder ينتج PDF مشفرًا أو يستهلكه — من محتوى صفحات بنص صريح انتقائي مع مرفقات مشفرة، إلى أغلفة حمولة مشفرة في PDF 2.0، أو توافق مع ملفات لم تختر سياسات مرشحاتها — فإن API مرشحات التشفير الموضحة هنا تأتي في مكوّن HotPDF PDF لـDelphi الحالي، إلى جانب مسارات التشفير وتدفقات الكائنات والتحديث التزايدي التي تعتمد عليها