مقال تقني

ترميز RSASSA-PSS-params وفق RFC 4055 في PDFium لدلفي

يصلح مكوّن PDFium في الإصدار 3.114.20 ترميز RSASSA-PSS-params في خلفيات توقيع PAdES الثلاث كلها: Windows CNG وmacOS Keychain وPKCS#11. يعطي RFC 4055 §3.1 كل حقل من حقول RSASSA-PSS-params وسمًا سياقيًا صريحًا، من [0] إلى [3]، وكانت الخلفيات تصدر saltLength بوصفها INTEGER كونية عارية مع كتابة trailerField المساوية لافتراضيها. كانت بايتات التوقيع صحيحة طوال الوقت. الـ AlgorithmIdentifier الذي يصفها لم يكن كذلك، وذلك وحده كافٍ ليرفض مدققٌ التوقيع

والجزء المحبط هو حيث يختبئ الخطأ. للتوقيع CMS نصفان: العملية التشفيرية، والـ ASN.1 الذي يخبر المدقق كيف أُجريت العملية. أحسِن الأول وأخطئ الثاني، وكانت النتيجة مستندًا لا تستطيع أداة تتبع المواصفات تمييزه عن تزوير. لا تتحدث هذه المقالة إلا عن ذلك النصف الثاني: كيف يجب وسم RSASSA-PSS-params، وكيف أخطأت ثلاث خلفيات بالطريقة نفسها، وكيف يبدو DER المصحح بمصطلحات TDerWriter

لماذا يرفض مدقق توقيع RSASSA-PSS بايتاته صحيحة؟

لأن RSASSA-PSS هو مخطط RSA الوحيد الذي لا يستطيع المدقق استرداد معاملاته من التوقيع ذاته. حشو PKCS#1 v1.5 محدد كليًا بـ OID sha256WithRSAEncryption، فمعاملاته NULL عارية ولا يوجد ما يخطأ فيه. أما PSS فيُمعلى بدالة تلخيص، ودالة توليد قناع لها تلخيصها الخاص، وطول ملح، ويترك RFC 8017 §A.2.3 الثلاثة كلها مفتوحة. يختارها الموقّع، وتحملها الـ AlgorithmIdentifier، وعلى المدقق أن يعيد إنتاجها بالضبط قبل أن يبدأ EMSA-PSS-VERIFY أصلًا

فعندما يوقع مكوّن PDFium بـ SHA-256 وMGF1 فوق SHA-256 وملح بحجم 32 بايتة، يجب أن تنجو تلك الحقائق الثلاث عبر ترميز DER هنا وفك ترميز DER على تنفيذ مختلف. كتلة معاملات لا يستطيع المدقق تحليلها تُنهي التحقق قبل حدوث أي رفع أسّي. وكتلة يحللها بصورة مختلفة أسوأ، لأن RFC 4055 §3.1 يعطي saltLength افتراضيًا 20. محلل يتخطى حقلا لا يعرفه يهبط على ذلك الافتراضي، ويشغل EMSA-PSS-VERIFY بملح من 20 بايتة مقابل توقيع حُسب بـ 32، ويبلّغ عن توقيع سيئ دون أي إشارة إلى أن المشكلة في البيانات الوصفية لا في المفتاح. كلا الناتجين هو ما أنتجه ترميز 3.114.19، بحسب صرامة المدقق، ولا أحد منهما يشير إلى الـ AlgorithmIdentifier

ما يشترطه RFC 4055 §3.1 فعلًا من RSASSA-PSS-params

يعرف RFC 4055 §3.1 بنية RSASSA-PSS-params بوصفها SEQUENCE من أربعة حقول، كل حقل يحمل وسمًا سياقيًا صريحًا وقيمة DEFAULT:

// RSASSA-PSS-params ::= SEQUENCE {
//   hashAlgorithm      [0] HashAlgorithm      DEFAULT sha1,
//   maskGenAlgorithm   [1] MaskGenAlgorithm   DEFAULT mgf1SHA1,
//   saltLength         [2] INTEGER            DEFAULT 20,
//   trailerField       [3] TrailerField       DEFAULT trailerFieldBC
// }

الوسم الصريح في DER يعني أن كل حقل يُغلَّف في TLV سياقي مركب، A0 للـ [0] وA1 للـ [1] وA2 للـ [2] وA3 للـ [3]، مع الترميز الكوني للقيمة متشعبًا داخله. وكل حقل موسوم تحديدًا لأن كل حقل اختياري عبر افتراضه. بلا وسوم لم يستطع محلل أن يميز هل SEQUENCE تحمل AlgorithmIdentifier واحدًا تحمل hashAlgorithm أم maskGenAlgorithm، فكلاهما نوع SEQUENCE؛ وبهما يحدد رقم الوسم الحقل بصرف النظر عن أي الجيران حاضر. والقيم التي يصدرها مكوّن PDFium تلتبع الملف الشخصي في ETSI TS 119 312 §7: SHA-256 وMGF1 بـ SHA-256 وملح يساوي طول الملخص، وهي تعكس بالضبط ما يُقال لكل نداء توقيع منصة: BCRYPT_PSS_PADDING_INFO بقيمة cbSalt تساوي 32 لـ NCryptSignHash، وCK_RSA_PKCS_PSS_PARAMS بقيمة sLen تساوي 32 لآلية PKCS#11، وخوارزمية PSS للتوقيع بالملخص SHA-256 في إطار Security

مخطط مكوّن PDFium لبنية RSASSA-PSS-params وفق RFC 4055: تحمل hashAlgorithm بـ A0 وmaskGenAlgorithm بـ A1 وsaltLength بـ A2 وسومًا سياقية صريحة بقيم DEFAULT، ويصدر الملف الشخصي ETSI قيم SHA-256 وMGF1 بـ SHA-256 وملح 32، وتساوي trailerField بالـ A3 قيمة trailerFieldBC فيحذفها DER كليًا
كل حقل موسوم تحديدًا لأن كل حقل اختياري عبر افتراضه، فيحدد رقم الوسم الحقل مهما ترك المرمّز من جيران

كيف أخطأت ثلاث خلفيات الخطأ نفسه

وسم ترميز 3.114.19 الحقلين الأولين وترك الأخيرين عاريين، بشكل متطابق في TWinCmsSigner وTKeychainCmsSigner وTPkcs11CmsSigner. ذلك التماثل ليس مصادفة: الثلاثة كلها تنفذ واجهة ICmsSigner من FPdfCms.pas، وكُتبت أجسام GetSignatureAlgorithmParams عندها على قالب واحد. كان القالب يقرأ هكذا:

// قبل 3.114.20: [0] و[1] موسومان، و[2] و[3] ليسا
Result := W.Sequence(Concat4(
  W.ContextSpecific(0, W.AlgId(OID_SHA256), True),
  W.ContextSpecific(1, W.Sequence(ConcatBytes(
    W.OID(OID_MGF1), W.AlgId(OID_SHA256))), True),
  W.IntegerOf(32),      // INTEGER عارية حيث كان يلزم [2] EXPLICIT
  W.IntegerOf(1)));     // trailerField، المساوية للـ DEFAULT، يجب أن تكون غائبة

يرى محلل يمشي على تلك الـ SEQUENCE قيمة A0 فيقرأ خوارزمية التلخيص، ويرى A1 فيقرأ دالة توليد القناع، ثم يصطدم بـ 02 01 20. تلك INTEGER كونية، ولا يملك RSASSA-PSS-params أي عضو INTEGER بلا وسم في أي موضع. يتوقف المحلل الصارم هناك. والمتساهل يتخطى العنصر غير المعروف، ولا يجد A2 قط، فيسند saltLength افتراضيها 20، ثم يصطدم بـ INTEGER طائشة ثانية، 02 01 01، وتتكرر المشكلة نفسها. ولا مسار يصل إلى ملح بحجم 32 بايتة. القالب المشترك فعال حين يكون صائبًا وطريقة بنفس الفعالية للخطأ ثلاث مرات حين لا يكون، ولهذا حط الإصلاح في الوحدات الثلاث في commit واحد، وللهذا تبقى أجسام الطرق الثلاث متطابقة بنيويًا بعده. ينبغي لخلفية مستقبلية أن تنسخ الكتلة من إحدى هذه لا أن تشقها من جديد، لأن الاشتقاق هو بالضبط حيث وقع الخطأ

مخطط مكوّن PDFium لخطأ DER في 3.114.19: بعد A0 وA1 صدمت المعاملات قيمة 02 01 20 عارية حيث يقع وسم A2 الصريح، فتوقف محلل صارم ووقع محلل متساهل بالملح الافتراضي 20 بايتة، بينما تغلف 3.114.20 الملح ذا 32 بايتة داخل A2
لا يملك RSASSA-PSS-params أي عضو INTEGER بلا وسم، فكانت البايتات الطائشة إما فشل تحليل وإما سقوط صامت إلى طول الملح الافتراضي، ولا مسار وصل إلى 32 التي وقع بها الموقّع

لماذا يُحذف trailerField بدل وسمه [3]؟

لأن X.690 §11.5 تقول إن مرمّز DER لا يجب أن يرمّز مكوّنًا تساوي قيمته قيمة DEFAULT له، وأن trailerField لها DEFAULT trailerFieldBC وهي العدد الصحيح 1. والتصحيح البديهي للكود القديم، استبدال W.IntegerOf(1) العارية بـ W.ContextSpecific(3, W.IntegerOf(1), True)، يعطي كتلة يقبلها محلل BER متساهل ويحق لمحلل DER صارم أن يرفضها. القيمة ليست خاطئة. حضورها هو الخطأ. والقاعدة نفسها هي سبب حضور الحقول الثلاثة الأخرى فعلًا: SHA-256 ليست الافتراض sha1، وMGF1 بـ SHA-256 ليس الافتراض mgf1SHA1، و32 ليست الافتراض 20. ولو كانت الخلفية توقع بـ SHA-1 وملح من 20 بايتة، لكان RFC 4055 §3.1 يطوي المعاملات إلى SEQUENCE فارغة، 30 00، وتلك الـ SEQUENCE الفارغة لا NULL هي ما يتوقعه مدقق. لا يصدر مكوّن PDFium ذلك الشكل قط لأنه لا يوقع بذات القيم قط، لكنه الحالة التي تصطاد من افترض أن «لا معاملات» تهجئتها دائمًا 05 00

هذا هو التمييز بين DER وBER الذي يهم التوقيعات تحديدًا. تسمح BER لمرمّز بإدراج مكوّن قيمته افتراضية؛ وتمنعه DER، لأن DER وُجدت كي يكون لقيمة واحدة ترميز واحد بالضبط، والتوقيع فوق بنية لها ترميزان مشروعان هو توقيع يمكن الجدال حوله. كل شيء داخل CMS signedAttrs هو DER لهذا السبب، وتسافر كتلة المعاملات داخل signedAttrs عبر سمة cmsAlgorithmProtection وكذلك في signatureAlgorithm الخارجية، فلا تنال إعفاءً

مخطط مكوّن PDFium لقاعدة X.690 11.5 في إصلاح PSS: يجب أن تبقى trailerField المساوية لقيمة DEFAULT وهي trailerFieldBC غائبة لأن غلاف A3 الموسوم هو الترميز الذي يرفضه DER الصارم، بينما تختلف saltLength البالغة 32 عن الافتراض 20 ويجب حضورها بوصفها A2
وُجدت DER كي يكون لقيمة واحدة ترميز واحد بالضبط، والمكوّن المساوي لافتراضه يملك سلفًا أقصر ترميز ممكن، ألا وهو ألا يظهر إطلاقًا

ترميز TDerWriter المصحح

يبني مكوّن PDFium المعاملات الآن بثلاث استدعاءات لـ TDerWriter.ContextSpecific من FPdfAsn1.pas، واحدة لكل حقل غير افتراضي، كل واحدة مع وسيط Constructed مضبوطًا إلى True لإنتاج غلاف الوسم الصريح، وبلا أي سطر لحقل trailerField إطلاقًا. هذا جسم TWinCmsSigner.GetSignatureAlgorithmParams مع كتابة الـ OIDs نصًا؛ وتهجئ وحدتا Keychain وPKCS#11 القيم نفسها بوصفها OID_SHA256 وOID_MGF1 وOID_RSASSA_PSS:

function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
  W: TDerWriter;
begin
  if FPaddingScheme = psRsaPss then
  begin
    W := TDerWriter.Create;
    try
      // يوسم RFC 4055 3.1 الحقول الأربعة كلها. saltLength هي [2]؛
      // INTEGER عارية هنا تُقرأ بدايةَ حقل آخر. وtrailerField
      // هي [3] بقيمة DEFAULT تساوي 1، ويمنع X.690 11.5 ترميز قيمة
      // تساوي الافتراضي، فتُحذف كليًا
      Result := W.Sequence(Concat3(
        W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
        W.ContextSpecific(1, W.Sequence(ConcatBytes(
          W.OID('1.2.840.113549.1.1.8'),          // id-mgf1
          W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
        W.ContextSpecific(2, W.IntegerOf(32), True)));
    finally
      W.Free;
    end;
  end
  else
    Result := nil;   // PKCS#1 v1.5 وECDSA: يكتب AlgIdWithParams قيمة NULL
end;

تفصيلان من الآلية المحيطة يهمّان. ينتج TDerWriter.AlgId عن AlgorithmIdentifier بمعاملات NULL، وهو ما يقوله RFC 4055 §2.1 للمرمّزات أن تولّده للـ hashAlgorithm المتشعبة ولتلخيص MGF1 الداخلي. ويقترن بانِ CMS في FPdfCms.pas OID التوقيع بتلك البايتات عبر TDerWriter.AlgIdWithParams التي تستبدل NULL حين تكون المعاملات nil؛ ولهذا يعيد psRsaPkcs1v15 وpsEcdsa ببساطة nil ولم يتأثرا قط، ولهذا فإن 1.2.840.113549.1.1.10، أي id-RSASSA-PSS، هو OID التوقيع الوحيد من الثلاثة الحامل كتلة معاملات حقيقية. والبايتات الناتجة للملف الشخصي SHA-256 ثابتة وقصيرة بما يكفي للفحص بالعين: SEQUENCE خارجية 30 34 تحمل A0 0F حول AlgorithmIdentifier لـ SHA-256 ذي 15 بايتة، وA1 1C حول AlgorithmIdentifier لـ MGF1 ذي 28 بايتة معاملاته هي AlgorithmIdentifier SHA-256 نفسها، وA2 03 02 01 20 للملح. وإن أظهر dump لـ signatureAlgorithm عندك قيمة 02 01 20 في المستوى الأعلى من SEQUENCE المعاملات لا داخل A2، فأنت تنظر إلى ترميز 3.114.19

لماذا لم يصطد جناح الاختبار بـ AlgorithmIdentifier مشوه؟

لأن اختبارات PAdES تسوق بانِ CMS عبر موقّع مزيف يبلّغ sha256WithRSAEncryption ويعيد nil من GetSignatureAlgorithmParams، فلم تُبنَ كتلة معاملات PSS في اختبار قط. ذلك تصميم معقول لاختبارات يجب أن تشغل دون مخزن شهادات ولا Keychain ولا token، وله نقطة عمياء بشكل دقيق: كل ما ينتجه backend حقيقي فقط لا يمارسه إلا backend حقيقي. والطبقة الثانية أشد إثارة: يضع مكوّن PDFium أيضًا الـ AlgorithmIdentifier للتوقيع، بمعاملاته، داخل سمة cmsAlgorithmProtection الموقعة من RFC 6211، ويقارن مدقق تلك النسخة بـ signatureAlgorithm الخارجية. جاءت النسختان من الاستدعاء نفسه، فتطابقتا تطابقًا تامًا واجتاز كل فحص اتساق داخلي. كان الترميز متسقًا ذاتيًا وخاطئًا، وهو صنف الخطأ الذي لا تكشفه أي مقدار من مقارنة بنية بنفسها، ويُروى الدرس نفسه ببنية مختلفة في CMS signedAttrs وفرز DER SET OF، حيث بدت SET تُلخَّص بترتيب وتُصدر بترتيب آخر سليمة حتى أعاد مدقق أجنبي حساب التلخيص

ما يصطاد هذا الصنف من الأخطاء هو محلل لم يكتبه مؤلف المرمّز، يشتغل على الناتج الفعلي للخلفية الفعلية. يمس مسار التحقق في Windows داخل مكوّن PDFium عبر CryptoAPI لا عبر قارئ المكتبة ذاته، وتوقيع PSS مرفوع هناك هو ما عاد بالبحث إلى المعاملات. أي ASN.1 يصدره تنفيذ لتنفيذات أخرى تقرؤه يستحق على الأقل جولة واحدة عبر محلل لا يسيطر عليه، وكلما زادت الافتراضات والوسوم في البنية ارتفعت قيمة تلك الجولة

أين يقع هذا في قصة PSS الباقية

هذا الإصلاح مستقل عن الموضعين الآخرين الذي قد يخطئ فيه PSS في توقيع PAdES، وفصلهما يقصّر التشخيص. يمكن لخلفية macOS أن تجد أن مفتاحًا معينًا أو نظامًا أقدم يرفض PSS فتنزل إلى PKCS#1 v1.5، وعلى الـ AlgorithmIdentifier أن تلتبع النزول؛ تلك مسألة قدرة، مغطاة في التوقيع بـ PAdES بهوية macOS Keychain. ويمكن لخلفية PKCS#11 أن تسلّم الـ token قيمة CK_RSA_PKCS_PSS_PARAMS يقرأ الـ token تخطيطها بصورة مختلفة بسبب عدم تطابق عرض عدد صحيح؛ تلك مسألة ABI، مغطاة في CK_ULONG وفخ حزم PKCS#11. وتتحدث هذه المقالة عن الفشل الثالث، حيث كان المفتاح مستعدًا، وحسب الـ token البايتات الصحيحة، والـ DER الذي يصف النتيجة لم يكن مطابقًا لـ RFC 4055 §3.1

الموقّع الذي يعلن PSS يتحمل التزامًا لم يكن لموقّع v1.5 قط: أن يصف معاملاته الخاصة بصورة يفك محلل آخر ترميزها إلى القيم الثلاث نفسها. يثبّت RFC 4055 §3.1 الوسوم، ويثبّت X.690 §11.5 أي الحقول يجوز أن تظهر، ويثبّت ETSI TS 119 312 §7 القيم التي تستحق الاختيار. وشحن كلا الخلفيات الثلاث لـ مكوّن PDFium لدلفي بمصدر، فجسم GetSignatureAlgorithmParams أعلاه هو ما تستطيع قراءته وإفراغه ومقارنته بمدققك أنت لا أخذه على الثقة