مقال تقني

كلمات مرور SASLprep وAES-256 لملفات PDF في Delphi عبر PDFlibPas

ملف PDF مشفّر بـ AES-256 بكلمة مرور غير ASCII يُفتح في البرنامج الذي كتبه ولا يُفتح في أي مكان آخر. السبب غالبًا خطوة تحضير مفقودة: تشترط الفقرة §7.6.4.3.3 من ISO 32000-2 معالجة كلمة المرور بملف تعريف SASLprep من stringprep قبل ترميزها بـ UTF-8 وتجزئتها. تُنفّذ PDFlibPas، مكتبة PDF لـ Delphi وC++Builder، ذلك التحضير داخل Encrypt وEncryptFile وDecryptFile

هذه ليست قصة كلمة المرور الخاطئة، ولا قصة بتات الأذونات. إذا كان مستخدموك يكتبون كلمة مرور لم تُصدرها أنت قط، فآلية إعادة المحاولة في مقال إعادة محاولة كلمات مرور PDF المشفّرة هي ما تحتاجه، وإذا كنت تحاول معرفة ما يفرضه ملف موجود بالفعل، فإن تدقيق التشفير والأذونات يغطي ذلك المجال. هذا المقال أضيق وأغرب: كلمة المرور صحيحة، والمستخدم كتبها بشكل صحيح، والملف لا يزال يرفض الفتح في مكان آخر

لماذا تُفتح كلمة مرور غير ASCII في قارئ واحد ولا تُفتح في آخر؟

لأن البرنامجين يُجزّئان تسلسلات بايت مختلفة من نفس ضغطات المفاتيح. اشتقاق المفتاح للمراجعة 6 في الفقرة §7.6.4.3.3 من ISO 32000-2 يأخذ كلمة المرور كبايتات UTF-8، ويقتطعها إلى 127 بايت، ويُلحق ملحًا، ويُشغّل التجزئة المُصلَّبة؛ وتُفحص النتيجة مقابل مدخلي /U و/O في قاموس التشفير. لا شيء في تلك السلسلة ضبابي. بايت واحد مختلف في أي مكان من المدخل يُنتج ملخصًا مختلفًا تمامًا، فيفشل التحقق، ولا يملك القارئ إلا شيئًا واحدًا يقوله: كلمة مرور خاطئة

تختلف البايتات لأن يونيكود يوفّر عدة طرق لكتابة ما يبدو أنه نفس كلمة المرور. قد تصل كلمة مرور صينية كأحرف مُركَّبة مسبقًا من طريقة إدخال واحدة وكأشكال توافقية من أخرى. كلمة مرور ألمانية أو فرنسية مُنسوخة من معالج نصوص قد تحمل مسافة غير فاصلة (U+00A0) حيث يعتقد المستخدم أن هناك مسافة عادية، أو واصلة ناعمة (U+00AD) تُعرض كلا شيء إطلاقًا. توجد SASLprep لطي كل هذه إلى شكل قانوني واحد قبل أن يُجزّئ أحد أي شيء، بحيث يشتق كل تنفيذ مطابق للمواصفة نفس المفتاح من نفس النية

ما الذي تُغيّره SASLprep فعليًا في كلمة المرور؟

تُعرّف RFC 4013 معيار SASLprep كملف تعريف لإطار عمل stringprep في RFC 3454، وهو أربع خطوات مرتبة وليس تحويلًا واحدًا. التخطيط يأتي أولًا: يُخطَّط الجدول C.1.2 من RFC 3454 (المسافات غير ASCII) إلى U+0020، ويُحذف الجدول B.1 (الأحرف المُخطَّطة عادةً إلى لا شيء) تمامًا. يتبع ذلك التطبيع إلى NFKC في يونيكود، وهي الخطوة التي تطوي الأحرف التوافقية وتسلسلات الدمج. ثم يرفض فحص المخرجات المحظورة أي شيء في الجداول من C.2.1 حتى C.9. أخيرًا تُطبَّق قاعدة الاتجاه الثنائي من القسم 6 من RFC 3454 على السلسلة المُطبَّعة

تُنفّذ PDFlibPas ملف التعريف بأكمله في وحدة PDFlibSASLprep، التي تعرض نقطة دخول واحدة. تأخذ PLSASLprepPassword كلمة المرور الخام، وتكتب الشكل المُحضَّر في وسيط var، وتعيد False عندما يجب رفض كلمة المرور. الدالة كاملة عمدًا في المسار السعيد: كلمة مرور ASCII فقط تعود متطابقة بايتًا، لذا لا يتغير شيء في عمليات النشر الحالية

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;

غموض U+200B الذي لا تحلّه الجداول

نقطة كود واحدة تقع في جدولين من RFC 3454 في آن واحد، والجدولان يتعارضان. المسافة صفرية العرض (U+200B) تقع ضمن نطاق C.1.2 من U+2000 إلى U+200B، حيث تقول القاعدة أن تُخطَّط إلى U+0020، وتقع أيضًا ضمن نطاق B.1 من U+200B إلى U+200D، حيث تقول القاعدة أن تُحذف. اقرأ خطوة التخطيط بأي ترتيب وستحصل على بايتات مختلفة من نفس كلمة المرور: a+U+200B+b تُحضَّر إلى a b بموجب C.1.2 وإلى ab بموجب B.1. تُسمّي RFC 4013 كلا الجدولين ولا تقول أيهما يفوز، لذا هذا غموض حقيقي في المواصفة وليس خطأ قراءة. تختبر PDFlibPas عضوية C.1.2 أولًا وبالتالي تُخطّط U+200B إلى مسافة، وهو السلوك الذي استقرت عليه تنفيذات stringprep الأخرى واسعة الانتشار؛ ومطابقتها هي الشيء الوحيد المهم هنا، لأن الهدف هو اتفاق البايتات مع أي قارئ يستخدمه العميل صدفةً

قراءة الملفات القديمة: المُحضَّر أولًا، الخام ثانيًا

الإصلاح يخلق مشكلة توافق خاصة به. كل ملف AES-256 كُتب قبل التغيير جزّأ كلمة مرور UTF-8 الخام، لذا فإن جعل القارئ مطابقًا صارمًا للمواصفة سيقفل العملاء خارج أرشيفاتهم الخاصة. تحلّ PDFlibPas هذا في جانب القراءة بتجربة مرشَّحين بالترتيب. تبني TPDFDocument.SetPassword قائمة مرشحين تبدأ بالشكل المُحضَّر وتتراجع إلى الشكل الخام، ولا تضيف المدخل المُحضَّر إلا عندما يكون المستند فعليًا AES-256 والشكلان مختلفان. لكلمة مرور ASCII تكون الأشكال متطابقة، والقائمة تحمل مدخلًا واحدًا، وتكلفة الآلية بأكملها هي مقارنة سلسلة واحدة. تفعل DecryptFile الشيء نفسه على طول مسار إعادة الكتابة المباشرة لـ AES-256، مستدعيةً PLDirectDecryptFileAES256 بكلمة المرور المُحضَّرة أولًا

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;

مسار التراجع يحمل احتياطًا واحدًا يستحق النسخ. المحاولة الثانية في DecryptFile تعمل فقط عندما يختلف الشكلان المُحضَّر والخام و تكون المحاولة الأولى قد أبلغت عن عدم وجود رمز خطأ حرج. الفشل البنيوي يعني أن المدخل تالف أو ليس مراجعة التشفير التي افترضتها، وإعادة محاولة ملف معطوب بكلمة مرور أخرى تُحرق ببساطة مسحًا كاملًا ثانيًا لمدخل عدائي؛ المنطق وراء هذا الاحتراس مشروح في ملاحظة تحليل ملفات PDF غير الموثوقة بأمان. لاحظ أيضًا أنه لا يوجد تراجع في جانب الكتابة، وهذا عدم التناظر مقصود. القراءة تتسامح مع التاريخ، أما الكتابة فلا: كل ملف AES-256 جديد يحصل على البايتات المطابقة للمواصفة

أي كلمات المرور تُرفض تمامًا، وما هو الخطأ 604؟

يمكن لـ SASLprep رفض كلمة مرور تمامًا، وعندما تفعل، يجب أن يفشل التشفير بصوت عالٍ لا أن يستبدل شيئًا بصمت. تُحضّر Encrypt وEncryptFile كلمتي مرور المالك والمستخدم كلما كانت Strength تساوي 3 أو 4، وتعيدان 0 عند الرفض، وتضبطان LastErrorCode على PDFLIB_ERROR_PASSWORD_SASLPREP، وهو 604. عائلتان من المدخلات تُطلقانه. جداول المخرجات المحظورة ترفض أحرف التحكم (C.2.1 وC.2.2)، ونقاط كود الاستخدام الخاص (C.3)، وغير الأحرف (C.4)، والبدائل المنفردة (C.5)، وU+FFFD (C.6)، وأحرف الوصف الأيديوغرافي (C.7)، ونطاقات التحكم بالعرض والوسم (C.8 وC.9). بشكل منفصل، ترفض قاعدة الاتجاه الثنائي من القسم 6 في RFC 3454 أي سلسلة تحتوي على حرف RandALCat من الجدول D.1 ما لم تبدأ السلسلة وتنتهي بحرف من ذلك النوع ولا تحتوي على أي أحرف من اليسار إلى اليمين إطلاقًا

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;

قاعدة الاتجاه الثنائي هذه هي التي ستفاجئ فريق الدعم لديك. كلمة مرور عربية أو عبرية تنتهي برقم غربي، أو واحدة بحرف لاتيني شارد في المنتصف، تُرفض بموجب المواصفة حتى وإن بدت معقولة تمامًا في حقل الإدخال. اعرض 604 كرسالة عن أحرف كلمة المرور، لا كفشل تشفير عام، وإلا فسيقضي أحدهم بعد ظهر كاملًا بحثًا عن خلل في اشتقاق مفتاحك

حدود صادقة: NFKC، وLCat تقريبية، وفخ واحد في Delphi

جزءان من التنفيذ تقريبيان، وكلاهما يستحق أن يُذكر بوضوح بدلًا من دفنه. تطبيع NFKC يُنفَّذ بواسطة واجهة Windows البرمجية NormalizeString، المُحمَّلة ديناميكيًا من Normaliz.dll. عندما تكون تلك المكتبة غير متاحة، تُستخدم السلسلة المُخطَّطة دون تطبيع، مما يعني أن خطوتي التخطيط والحظر لا تزالان تعملان لكن الطي التوافقي لا يعمل. عمليًا، شُحنت هذه المكتبة الديناميكية مع كل إصدار من Windows منذ Vista، لذا المسار المتدهور هو مصدر قلق يخص ما قبل Vista وغير Windows لا مصدر قلق حي، لكن كلمة مرور تعتمد على طي NFKC ستُنتج بايتات مختلفة هناك، وهذا تباين حقيقي وإن كان بعيدًا. فحص الاتجاه الثنائي هو التقريب الثاني: كشف أحرف LCat يستخدم نطاقات الأحرف الشائعة بدلًا من الجدول الكامل D.2 من RFC 3454، واتجاه ذلك الخطأ هو ما يجعله مقبولًا. حرف LCat مفقود يمكن أن يجعل قاعدة الاتجاه الثنائي تنجح فقط حيث كانت المواصفة سترفض، لا العكس أبدًا، ولا يمس أبدًا خطوتي التخطيط والتطبيع، لذا فإن تسلسل البايتات المُحضَّر لكلمة مرور مقبولة يبقى دون تغيير. المخاطرة المتبقية إذن هي تباين سياسة لا تباين بايتات: كلمة مرور بنص غريب قد يرفضها تنفيذ أكثر صرامة تمامًا. أي كلمة مرور يقبلها الطرفان تُجزَّأ بشكل متطابق، وهذه هي الخاصية التي تعتمد عليها قابلية التشغيل البيني فعليًا

أخيرًا، فخ صياغي في Delphi يُكلّف ساعة إن لم تصادفه من قبل. عندما تُعيد دالة نوعًا إجرائيًا، فإن إسنادها دون أقواس لا يستدعيها. يقرأ المترجم Proc := GetNormalizeProc; على أنه أخذ عنوان GetNormalizeProc نفسها، ثم يُبلغ عن E2009 بشكوى غير مفيدة مفادها أن اصطلاحات الاستدعاء تختلف، لأن الوصول يستخدم الاصطلاح الافتراضي بينما نوع الواجهة المستوردة هو stdcall. الأقواس الفارغة إلزامية

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;

تحضير كلمة المرور واحد من تلك التفاصيل التي لا تظهر أبدًا في قائمة ميزات وتقرر ما إذا كان مستند مشفّر سيصمد أمام اتصال بعميل في لغة مختلفة. نقاط الدخول Encrypt وEncryptFile وDecryptFile وSetPassword الموضحة هنا جزء من مكتبة losLab PDF Developer Library إصدار Pascal لـ Delphi وC++Builder، التي تحمل صفحة منتجها المرجع الكامل للتشفير وجدول رموز الخطأ الكامل