ترحيل واحد إلى ES256، وثلاث طرق لتشويه توقيع صالح
سار ترحيل الخوارزمية نفسه على ما يرام. يروي مقال الحيوات الثلاث لمفتاح توقيع JWT الخاص بي تلك القصة: الانتقال من RS256 إلى ES256 على مُصدر يعمل مباشرة، ونشر كلا المفتاحين في JWKS طوال فترة التداخل، وآلية اختيار للمفتاح النشط تُبعد نفسها تلقائيًا عن مفتاح RSA القديم. المفاتيح والتدوير والاكتشاف، أي كل الأجزاء التي تبدو صعبة، نجحت من المحاولة الأولى.
هذه قصة الجزء الذي لم ينجح. ثلاث مرات منفصلة خلال ذلك الترحيل، أصدر خادم المصادقة لدينا رمزًا توقيعه صالح تشفيريًا: الحسابات صحيحة، والمفتاح الصحيح وقّع البايتات الصحيحة، ومع ذلك لم يكن أي مدقق على وجه الأرض ليقبله. ثلاث طبقات مختلفة شوّهت البايتات، كل واحدة بطريقتها الخاصة، وبدا كل فشل مطابقًا للآخر من الخارج: invalid signature. ليس "تنسيق خاطئ" ولا "طول غير متوقع". فقط: غير صالح.
إن كنت تحدّق في هذا الخطأ في منتصف عملية ترحيل، فانتقل مباشرة إلى الدليل الميداني في نهاية المقال. طول التوقيع بعد فك ترميزه يحدد الطبقة المذنبة في أغلب الأحيان.
لقد دلّلتنا RSA: عدد صحيح واحد، وترميز واحد
إليك ما لا يخبرك به أحد قبل الترحيل إلى ECDSA: لم تعانِ RSA من هذه المشكلة قط، ولذلك لا ينتقل معك أي من حدسك السابق.
توقيع RSA هو عدد صحيح واحد. تنسيق النقل هو ذلك العدد نفسه، بترتيب البايت الأعلى أولًا (big-endian)، محشوًا إلى حجم المفتاح: 256 بايت لمفتاح RSA-2048، ولا توجد عمليًا سوى طريقة واحدة لكتابته. وقّع بأي مكتبة، وتحقق بأي مكتبة أخرى، وستعني البايتات الشيء نفسه.
أما توقيع ECDSA فهو زوج من الأعداد الصحيحة، (r, s). والزوج يحتاج إلى ترميز، وقد استقر النظام البيئي على ترميزين غير متوافقين:
- ASN.1 DER: بنية
SEQUENCEتضم عنصريINTEGER. طوله متغير (عادةً 70 إلى 72 بايت للمنحنى P-256)، ويبدأ دائمًا بالبايت0x30. هذا ما تتحدثه OpenSSL و X.509 و TLS. - R‖S الخام (IEEE P1363): يُحشى كل من
rوsمن اليسار إلى 32 بايت بالضبط ثم يُدمجان معًا. الطول دائمًا 64 بايت بالضبط. هذا ما يفرضه JWS لخوارزمية ES256 (RFC 7518 §3.4).
كلاهما يرمّز التوقيع نفسه. وكلاهما "صحيح". سلّم المدقق الترميز الذي لا يتوقعه ولن يقول "حاوية خاطئة"، بل سيفك الترميز إلى أعداد صحيحة مشوهة ويبلّغ عن الشيء الوحيد الذي يستطيعه: التوقيع لا يجتاز التحقق.
كل طبقة بين مفتاحك وسلك النقل لها رأي في أي الترميزين تتحدث. كانت لدينا ثلاث طبقات، وأخطأنا في الثلاث جميعها، واحدة تلو الأخرى.
الفخ الأول: نظام KMS يجيب بترميز DER
يعيش مفتاحنا الخاص داخل محرك transit في HashiCorp Vault ولا يغادره أبدًا: يرسل التطبيق البايتات المراد توقيعها ويستلم التوقيع. عندما بدّلنا مفتاح transit من rsa-2048 إلى ecdsa-p256، كان طلب التوقيع لا يزال يحمل بقايا من حقبة RSA: signature_algorithm=pkcs1v15.
هذا معامل حشو خاص بـ RSA، ولا معنى له مع مفتاح ECDSA. لم يُصدر Vault خطأ. تجاهل المعامل غير ذي الصلة، ووقّع بمفتاح المنحنى، وأعاد 200 مع توقيع ECDSA صالح تمامًا، لكن بصيغة التغليف الافتراضية لديه، وهي DER.
وهكذا بقي المسار السعيد أخضر من طرف إلى طرف: طلب مقبول، وتوقيع مُعاد، وكان openssl dgst -verify سيباركه. ومع ذلك رفضت كل مكتبة JWT كل رمز، لأنها بعد فك ترميز base64url للمقطع الثالث توقعت 64 بايت من R‖S فوجدت 71 بايت تبدأ بـ 0x30.
الإصلاح معامل واحد في الطلب: تقبل نقطة نهاية التوقيع في محرك transit لدى Vault المعامل marshaling_algorithm=jws الذي يعيد R‖S الخام مباشرة. لكن لاحظ شكل الفخ: معامل خاطئ لنوع المفتاح ومُتجاهَل بصمت. بدا الطلب خاطئًا، وكان خاطئًا، ونجح رغم ذلك، ولكن بتعريف خاطئ للنجاح.
الفخ الثاني: "فقط ادمج العددين الصحيحين" يفشل في رمز واحد من كل 128
إذا لم يكن نظام KMS لديك قادرًا على إصدار R‖S أصلًا، فستُغريك فكرة تحويل DER بنفسك: استخرج عنصري INTEGER، وادمجهما، وانتهى الأمر. هذا أخبث الفخاخ الثلاثة، لأنه صحيح بشكل متقطع.
الأعداد الصحيحة في DER ذات طول أدنى وذات إشارة. وهذا يعني:
- أن قيمة
rالتي تصادف أنها صغيرة عدديًا، أي بايتها الأعلى صفر، تُرمَّز في 31 بايت أو أقل، لأن DER يحذف الأصفار البادئة؛ - وأن قيمة
rالتي يكون بتّها الأعلى مضبوطًا تكتسب بايت إشارة0x00وتُرمَّز في 33 بايت.
تنسيق JWS لا يريد أيًا من الاثنين. إنه يريد كل قيمة محشوة من اليسار إلى 32 بايت بالضبط، دائمًا. الدمج الساذج لعددي DER الصحيحين ينتج توقيعات بطول 63 أو 64 أو 65 أو 66 بايت حسب الحظ، ولا يجتاز التحقق سوى ما جاء بطول 64 بايت. عالج بايت الإشارة وانسَ الحشو، وستبقى لديك أداة تحويل تفشل كلما بدأ r أو s ببايت صفري، وكل منهما حدث احتماله 1 من 256، أي أن نحو رمز واحد من كل 128 يخرج ناقص الطول.
تلك النسخة تجتاز اختبار الدخان لديك، وتجتاز مراجعة الكود، وتُشحن، ثم تُفشل عملية تسجيل دخول واحدة كل ساعة في الإنتاج دون أي نمط يمكنك رؤيته. تفادينا هذا الفخ بجعل Vault يتولى التحويل (marshaling_algorithm=jws مرة أخرى: دع الطرف الذي يملك عملية التوقيع يملك التنسيق أيضًا)، لكن للفخ نفسه نسخة محلية بحتة في .NET: تأخذ الواجهتان ECDsa.SignData/VerifyData معامل DSASignatureFormat، وتختلف القيم الافتراضية بين أركان إطار العمل، فعالم SignedXml والشهادات يفكر بـ Rfc3279DerSequence بينما واجهات ECDsa الخام تعتمد P1363 افتراضيًا. مسار التحقق المحلي لدينا يذكر الآن DSASignatureFormat.IeeeP1363FixedFieldConcatenation صراحةً. بعد هذا الترحيل، لم نعد نسمح لأي واجهة توقيع بأن تختار تنسيقها افتراضيًا، في أي مكان.
الفخ الثالث: الإصلاح غيّر لهجة base64 من تحت أقدامنا
يغلّف Vault كل توقيع في مظروف: vault:v1:<encoded-bytes>. مع التغليف الافتراضي DER، يكون الجزء المرمّز بترميز base64 القياسي، وكان عميلنا يستدعي عليه Convert.FromBase64String بكل إخلاص.
بدّل إلى marshaling_algorithm=jws وسيقوم Vault، وبشكل معقول وفقًا للمواصفة التي بات يتحدثها، بترميز جزء التوقيع بترميز base64url: أبجدية -/_ وبلا حشو. علم واحد في الطلب غيّر شيئين في آن واحد: تنسيق البايتات الذي طلبناه، والترميز النصي الذي وصل به.
يريد Convert.FromBase64String ترميز base64 قياسيًا محشوًا. التوقيع بطول 64 بايت يساوي 86 حرفًا بترميز base64url، وهو ليس من مضاعفات الأربعة، لذا رمى فك الترميز استثناء FormatException على كل رمز بلا استثناء. وبطريقة ما كان هذا ألطف الإخفاقات الثلاثة: فهو على الأقل انهار بصوت عالٍ وعند السطر الخاطئ بالضبط، بدلًا من إنتاج بايتات تبدو صالحة وتفشل بصمت في مكان آخر. الإصلاح هو فك الترميز (وإعادة الترميز في رحلة التحقق العكسية) بمرمّز base64url.
تأمل المفارقة: صار التوقيع أخيرًا بتنسيق البايتات الصحيح، فجاءت طبقة النص لتشوهه. ثلاثة ترميزات متراكبة: ترميز زوج الأعداد الصحيحة، وحشو الحقول الثابتة، وترميز المحارف. وكل واحد منها قادر وحده على تحويل توقيع صالح إلى رمز مرفوض.
الدليل الميداني: الطول يسمّي الجاني
عندما يفشل التحقق من JWT في منتصف ترحيل خوارزمية، فُك ترميز base64url للمقطع الثالث من الرمز وانظر إلى ما لديك:
| التوقيع بعد فك الترميز | الطبقة التي شوّهته |
|---|---|
| 64 بايت بالضبط | تنسيق النقل صحيح، فابحث في مكان آخر (عدم تطابق kid، أو مفتاح خاطئ في JWKS، أو تثبيت alg) |
70 إلى 72 بايت، والبايت الأول 0x30 |
تسرّب DER: نظام KMS أو مكتبة التشفير تُصدر ASN.1 |
| 63 أو 65 بايت، وبعض الرموز فقط تفشل | تحويل يدوي من DER إلى الخام دون حشو الحقول الثابتة |
| فك الترميز يرمي استثناء أو ينتج بيانات مشوهة | عدم تطابق بين base64 و base64url في طبقة أدنى |
وثمة فحص متقاطع يفصل أخطاء التنسيق عن أخطاء المفاتيح في ثوانٍ: تحقق من التوقيع نفسه باستخدام OpenSSL الذي يتحدث DER. إذا قالت OpenSSL إن التوقيع صالح بينما تقول كل مكتبات JWT إنه غير صالح، فلديك مشكلة ترميز. وإذا قال الاثنان إنه غير صالح، فلديك مشكلة مفتاح.
الدرس
"صالح تشفيريًا" خاصية تخص الرياضيات. أما "قابل للتحقق" فخاصية تخص كل حد ترميزي بين الرياضيات والمستهلك، وقد عبر توقيعنا أربعة منها: تغليف نظام KMS، ومظروف النقل الخاص به، وفك الترميز في عميلنا، وتنسيق النقل في JWS. ثلاثة من تلك الحدود كانت لها آراء، واختلف كل منها مع جاره مرة واحدة بالضبط.
أما العبرة الاختبارية فهي ما غيّرنا عملياتنا فعليًا من أجله: لا تُثبت أبدًا صحة مسار توقيع بالتحقق منه بكودك أنت. كان مسار التوقيع ومسار التحقق لدينا يتشاركان كل واحد من هذه الافتراضات، فنجحت جولات وقّع-ثم-تحقق بينما رفضنا كل مستهلك حقيقي. الاختبار الذي كشف الأخطاء الثلاثة أخيرًا يُصدر رمزًا حقيقيًا، ويتحقق منه بمكتبة JWT القياسية للمنصة مقابل JWKS المنشور، أي بافتراضات طرف آخر لا افتراضاتنا، ثم يؤكد فوق ذلك أمرًا واحدًا محددًا بدقة قاسية: أن التوقيع بعد فك ترميزه يبلغ 64 بايت بالضبط. الرياضيات ستعتني بنفسها. البايتات هي ما يجب أن تراقبه.