فشل فك تشفير AES: المفتاح ومتجه التهيئة والوضع والحشو
حين يظهر فشل فكّ تشفير AES في سجلّاتك، فالرسالة التي وصلتك تصف غالباً مشكلة غير مشكلتك. أربعة أعطال لا رابط بينها تُنتج أعراضاً تكاد تكون متطابقة، وأشيعها، أي المفتاح الخاطئ، يظهر على هيئة خطأ في الحشو (padding).
وهذا أسرع ترتيب للفحص، انطلاقاً من عملية فكّ تشفير بوضع CBC تُلقي BadPaddingException:
- بايتات المفتاح مختلفة بين الطرفين. السبب الأرجح بفارق واسع.
- اشتقاق المفتاح مختلف. عبارة المرور نفسها، لكن دالة اشتقاق مختلفة أو عدد تكرارات مختلف، فتخرج بايتات مفتاح مختلفة.
- النصّ المشفَّر تضرّر أثناء النقل: اقتُطع، أو تشوّه ترميز base64، أو مرّ ذهاباً وإياباً عبر ترميز نصّي.
- متجه التهيئة خاطئ. سبب حقيقي، لكنه لا يُلقي خطأ حشو. يُتلف ستة عشر بايتاً ويصمت.
الترتيب هنا بنيويّ. يفحص CBC الحشو في الخطوة الأخيرة من فكّ التشفير، بعد تطبيق المفتاح وفكّ السلسلة، فيكون الحشو بمثابة مجموع تحقّق على كل ما سبقه، ويفشل بصوت عالٍ أيّاً كان العنصر الذي انكسر قبله. وإن أردت اختصار الطريق، فالصق نصّك المشفَّر في أداة فكّ تشفير AES ونفّذ التنصيف الوارد في القسم 9.
كل ما يلي مُقاس على java 1.8.0_162 وnode v25.8.2 وopenssl 3.6.2. والقيم الافتراضية تتغيّر بين الإصدارات، فاعتبر أرقام الإصدارات جزءاً من النتيجة.
1. ابدأ بما يستبعده خطؤك فعلياً
رسالة فشل AES لا تكاد تقول شيئاً عن السبب، لكنها تقول الكثير عمّا لا يمكن أن يكون السبب. استخدمها لحذف الفروع، لا لاختيار واحد منها.
| ما تراه | ما يستبعده | ما يبقى قائماً |
|---|---|---|
BadPaddingException أو bad decrypt أو wrong final block length | GCM؛ خطأ متجه تهيئة خالص؛ فشل في فكّ الترميز | مفتاح خاطئ، اشتقاق مفتاح خاطئ، نصّ مشفَّر مقتطع، بايتات متجه التهيئة أُكلت على أنها نصّ مشفَّر، عدم تطابق الوضع، عدم تطابق نظام الحشو |
Authentication failed أو Unsupported state or unable to authenticate data في GCM | الحشو؛ أي فرضية تتضمّن مخرجات جزئية | مفتاح خاطئ، رقم مرّة واحدة خاطئ، وسم منفصل أو في غير موضعه، طول وسم خاطئ، بيانات مُصادَق عليها إضافية غير متطابقة |
| لا استثناء، والمخرجات نفاية | كل الأوضاع المُصادَق عليها | ECB، CTR، CBC حالفه الحظّ، عدم تطابق الوضع، متجه تهيئة خاطئ |
BadPaddingException وbad decrypt وwrong final block length
الحدث نفسه في ثلاث منظومات: Java وOpenSSL و.NET. يُطلَق في نهاية فكّ التشفير بوضع CBC أو ECB، حين لا تنتهي كتلة النصّ الصريح الأخيرة بنمط PKCS#7 صالح.
والجزء المفيد هو النفي: وصولك إلى هذه النقطة يعني أن ترميز base64 أو hex فُكّ بنجاح، وأن عدد البايتات كان مضاعفاً غير صفري للرقم 16، أي أن النقل لم يمزّق البيانات وأنك لست في GCM. والاستثناء هو wrong final block length؛ فهناك لم يكن العدد مضاعفاً للرقم 16، وهذا يشير إلى اقتطاع لا إلى المفتاح، فانتقل مباشرةً إلى القسم 8.
Authentication failed وأخواتها في GCM
يقارن GCM الوسم قبل أن يُطلق بايتاً واحداً من النصّ الصريح، وفق ما تشترطه NIST SP 800-38D. وهذا يجعله صادقاً بطريقة لا يبلغها خطأ الحشو: شيء ما في المجموعة (المفتاح، رقم المرّة الواحدة، النصّ المشفَّر، البيانات المُصادَق عليها الإضافية، الوسم) لا يطابق ما استخدمه طرف التشفير. ولا يستطيع أن يخبرك أيّ عنصر منها، لأن تضييق ذلك خارج نطاق عمل الخوارزمية عن قصد. ويتناول القسم 6 العنصر الأكثر انكساراً بين اللغات: موضع الوسم، لا قيمته.
لا خطأ، لكن المخرجات نفاية
هذه هي النتيجة الخطرة، لأن لوحة المتابعة تسجّلها نجاحاً. CTR لا يُلقي خطأً أبداً، وECB كذلك. أما CBC فلا يُلقي خطأً إلا حين يفشل نمط البايت الأخير في فحص الحشو، ومع مفتاح خاطئ يكون ذلك البايت عشوائياً عملياً، فتصيب محاولة واحدة من كل 256 تقريباً القيمة 0x01 فتُقبَل. أي أن ما يقلّ قليلاً عن 0.4% من عمليات فكّ التشفير بوضع CBC بمفتاح خاطئ «تنجح». لكن للنفاية شكلاً، والشكل يسمّي العطل، والقسمان 4 و5 يعرضان البصمتين.
2. أكثر الأخطاء تضليلاً في AES
المفتاح 0123456789abcdef، ومتجه تهيئة أصفار بالكامل، وAES/CBC/PKCS5Padding، والنصّ الصريح hello world، على java 1.8.0_162 مع مزوّد SunJCE المدمج في JDK:
| السيناريو | التغيير | النتيجة المقاسة |
|---|---|---|
| A | المفتاح خاطئ بمقدار بايت واحد (آخر محرف f ← X) | يُلقي javax.crypto.BadPaddingException: Given final block not properly padded. والحشو نفسه لم يكن مشوّهاً قطّ؛ فالخطأ مضلّل تماماً |
| B | المفتاح صحيح، ومتجه التهيئة خاطئ ببايت واحد | لا استثناء، وعاد النصّ الصريح hello world على هيئة iello world. لم يتضرّر سوى البايت المقابل في الكتلة الأولى |
| C | المفتاح صحيح، وفُكّ تشفير نصّ CBC المشفَّر باستخدام AES/ECB | نجح بصمت، بلا استثناء. عدم تطابق الوضع ليس مضطراً لإطلاق أي شيء |
السيناريو A يبدّد فترات بعد ظهر كاملة في الاتجاه الخاطئ. والسيناريو C يشحن بيانات فاسدة إلى الإنتاج.
لماذا يُنتج المفتاح الخاطئ خطأ حشو
لا شيء في الحشو كان خاطئاً. أضاف طرف التشفير خمسة بايتات 0x05 ليصل بـhello world إلى ستة عشر بايتاً، وشفّر تلك الكتلة، وهي لا تزال في نصّك المشفَّر سليمة.
ويقع الفشل في طريق الخروج. فكّ التشفير بوضع CBC يشغّل شفرة الكتل بالعكس، ويُجري XOR بين كل ناتج وكتلة النصّ المشفَّر السابقة، وعندئذ فقط يقرأ ذيل الكتلة الأخيرة ليقرّر كم بايتاً يقتطع. ومع مفتاح خاطئ تُنتج الشفرة ستة عشر بايتاً من الضجيج، والضجيج لا ينتهي بنمط PKCS#7 صالح إلا نادراً جداً. فتُبلِّغ المكتبة بما رأته، أي حشو فاسد، وهو صحيح وعديم الفائدة.
اقرأ BadPaddingException على أنها: «النصّ الصريح الذي أعدت بناءه لا ينتهي كما ينتهي نصّ صريح محشوّ». والسبب الأرجح لخطأ إعادة البناء هو المفتاح، ولهذا يقودك البحث عن aes decrypt wrong key والبحث عن استثناء الحشو الفاسد إلى المواضيع نفسها: العَرَضان عَرَضٌ واحد. وملاحظة تصميمية على الهامش: لا تكشف هذا التمييز لمُستدعٍ خارجي أبداً، لأن التفريق بين «الحشو غير صالح» و«الحشو صالح والمحتوى خاطئ» هو ما يتغذّى عليه هجوم عرّاف الحشو (Vaudenay, EUROCRYPT 2002).
ما الذي يفعله GCM على نحو مختلف
يقلب GCM الترتيب، فيتحقّق من الوسم قبل إنتاج أي نصّ صريح، فلا توجد نافذة تعيش فيها بايتات صحيحة جزئياً. ولا يتركك فشل GCM متسائلاً عمّا إذا كانت المخرجات حقيقية، لأنه لا مخرجات أصلاً. كما أن GCM بلا حشو إطلاقاً، لأنه في الأصل وضع عدّاد، فطول النصّ المشفَّر يساوي طول النصّ الصريح. ومن ثمّ فخطأ حشو في نظام ظننته GCM يثبت أن النظام ليس GCM، وغالباً ما يكون إعداداً ارتدّ إلى CBC.
3. هل يستخدم الطرفان بايتات المفتاح نفسها؟
لا يرى AES سلسلة مفتاحك النصّية. إنما يرى 16 أو 24 أو 32 بايتاً. ويمكن لنظامين أن يحملا مادة مفتاح متطابقة في ملفّ إعدادات ويختلفا مع ذلك، لأن «التطابق» صفة للنصّ لا للبايتات.
الطرق الثلاث لتحويل سلسلة المفتاح إلى بايتات
سلّم السلسلة الحرفية 0123456789abcdef إلى ثلاث مكتبات مختلفة:
as hex -> 8 bytes (invalid AES key length)
as base64 -> 12 bytes (invalid AES key length)
as raw UTF-8 -> 16 bytes (valid AES-128)
ستة عشر محرفاً، وثلاثة أعداد بايتات. وهذه حالة خبيثة تحديداً لأنها صالحة وفق القراءات الثلاث جميعاً: فكل محرف فيها موجود في أبجدية hex وأبجدية base64 معاً، وستة عشر محرفاً طول قانوني لكلا المفكّكين، فلا شيء يُخطئ في وقت التحليل.
ويحتوي دليل استكشاف خطأ invalid signature في JWT على المصفوفة الكاملة لكيفية تفسير كل منظومة لسلسلة السرّ؛ والنسخة المختصرة الخاصة بـAES هي أن تدوّن الترميز الذي تكون فيه مادة مفتاحك وتجعل الطرفين يفكّان ترميزها صراحةً. والصورة المكافئة من العطل نفسه في HMAC تعضّ مستقبِلات الـwebhook، وهو ما يتناوله دليل فشل التحقّق من توقيع Webhook.
AES صارم: 16 أو 24 أو 32 بايتاً بالضبط
هنا يختلف AES عن البدائية التي يلتقيها معظم المطوّرين أولاً. يقبل HMAC أي طول مفتاح: إذ يُجزّئ RFC 2104 كل ما يزيد عن حجم الكتلة ويحشو بالأصفار كل ما ينقص عنه، فيأخذ مولّد HMAC سرّاً بطول 7 بايتات أو 700 بايت دون اعتراض. أما AES فله ثلاثة أطوال مفاتيح قانونية بالضبط، ويرفض ما عداها قبل معالجة كتلة واحدة.
وهذه الصرامة نعمة، لأن خطأ الطول هو فشل AES الوحيد الذي يسمّي سببه بدل الاختباء خلف الحشو. وأداتنا تصوغه هكذا: Key must be 16, 24, or 32 bytes (AES-128/192/256). والمصائد التي تنتج طولاً خاطئاً:
- سطر جديد زائد من
KEY=$(cat key.txt)أوecho "$KEY". استخدمprintfوecho -n. والمسافة الزائدة المنسوخة من واجهة مدير أسرار تفعل الشيء نفسه. - بادئة
0xمنسوخة من مُنقِّح: أربعة وثلاثون محرفاً لم تعد hex صالحاً. - محارف خارج ASCII تشغل أكثر من بايت. فكلمة
contraseñaعشرة محارف وأحد عشر بايتاً بترميز UTF-8، وعبارة مرور من «32 محرفاً» تحوي حرفاً واحداً بعلامة تشكيل لاتينية تصير 33 بايتاً.
SecretKeySpec ومجموعة محارف المنصّة الافتراضية
لدى Java نسخة من هذا لا تظهر إلا بعد النشر. فالنداء "my secret".getBytes() بلا وسيط يستخدم مجموعة محارف المنصّة الافتراضية، وهي قبل JDK 18 تأتي من الخاصية file.encoding، ومن ثمّ من نظام تشغيل الجهاز ومحلّيته. فحاسوب محمول على UTF-8 وحاوية على ANSI_X3.4-1968 يُنتجان بايتات مختلفة لأي محرف خارج ASCII. وقد جعل JEP 400 ترميز UTF-8 هو الافتراضي في JDK 18، وهذا يصلح الكود الجديد ولا شيء غيره.
// خطأ: البايتات تعتمد على الجهاز
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(), "AES");
// صواب: البايتات لا تعتمد على شيء
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "AES");
إن كان كودك يعمل محلياً ويفشل على الخادم بخطأ حشو، وكانت عبارة المرور تحوي أي شيء خارج ASCII، فافحص هذا أولاً.
4. متجه التهيئة: أين يوضع وكيف يبدو الخاطئ منه
عدم تطابق متجه التهيئة (aes iv mismatch) هو الفشل الذي يشكّ فيه الناس أولاً ويشخّصونه أخيراً، لأنه لا يتصرّف كغيره. فهو هادئ وموضعيّ.
متجه التهيئة الخاطئ يُتلف كتلة واحدة بالضبط
انظر مجدداً إلى السيناريو B. المفتاح صحيح، ومتجه التهيئة خاطئ ببايت واحد:
hello world -> iello world
لا استثناء، ومحرف واحد. اكتب خطوة CBC للكتلة الأولى ويتّضح الأمر: P1 = D(C1) XOR IV. يدخل متجه التهيئة في عملية XOR مباشرة مع كتلة النصّ الصريح الأولى ولا يمسّ سواها، فقلب بِت واحد في متجه التهيئة يقلب البِت نفسه من النصّ الصريح في الموضع نفسه. وهنا صار h (0x68) هو i (0x69)، أي أن البايت الأول من متجه التهيئة تحرّك بمقدار 0x01 بالضبط.
والبصمة سهلة القراءة: في CBC، أول 16 بايتاً نفاية وكل ما بعدها سليم يعني أن متجه التهيئة خاطئ وأن المفتاح صحيح. وكل الكتل نفاية يعني أن المفتاح خاطئ. هذه الملاحظة وحدها تفصل بين أشيع سببين دون تغيير سطر واحد من الكود، وأداة فكّ تشفير AES تعرض البايتات بعد فكّ الترميز فتقرأها مباشرةً.
ولماذا لم يُطلَق أي خطأ؟ طول hello world أحد عشر بايتاً، أي كتلة واحدة، وحشو PKCS#7 يسكن البايتات من 11 إلى 15 منها. والبايت الذي تغيّر في متجه التهيئة كان البايت 0، فبقيت منطقة الحشو سليمة واجتازت الفحص. أفسِد بايتاً من متجه التهيئة في الموضع 11 أو بعده وستحصل على خطأ حشو بدلاً من ذلك، وهذا طريق آخر يكذب عليك فيه خطأ الحشو.
ثلاثة أعراف للنقل
لا يوجد معيار لموضع متجه التهيئة، بل ثلاث عادات تتعامل فيما بينها تعاملاً سيئاً.
وأشيعها وضع متجه التهيئة في المقدّمة، أي الشكل iv || ciphertext، وهو الافتراضي في أدواتنا. وعلى الطرفين الاتفاق على المقدار الذي يُقتطع: 16 بايتاً لـCBC وCTR، و12 لـGCM. والعطل المرآتي هو منتِج يضع البادئة ومستهلك لا يقتطعها. فتصير أول 16 بايتاً من «النصّ المشفَّر» هي متجه التهيئة، وتنزاح كل كتلة، وتحصل على خطأ حشو.
والعُرف الثاني حقل منفصل، {"iv": "...", "ciphertext": "..."}، وهو أنظف من حيث المبدأ لكنه يضاعف المواضع التي يمكن أن يختلف فيها الترميز، لأن لمتجه التهيئة الآن سؤاله الخاص: base64 أم hex؟
والثالث متجه تهيئة ثابت مكتوب في الكود، أصفار بالكامل عادةً، لأن أحدهم احتاج نتيجة حتمية. وهو يتعامل بين الأنظمة تعاملاً مثالياً، وهذا بالضبط ما يجعله الخطر: ففي CBC يُسرّب متجه التهيئة الثابت تساوي السجلّات، وفي GCM تكشف إعادة استخدام رقم المرّة الواحدة تحت مفتاح واحد ناتج XOR بين النصّين الصريحين، وقد تكشف مفتاح GHASH الفرعي الذي يصادق على الوسم. وSP 800-38D صريحة بشأن التفرّد.
ومفتاح «النصّ المشفَّر المجرّد» في الأداة مع تجاوز صريح لمتجه التهيئة يختبر الأعراف الثلاثة كلّها على البايتات نفسها.
متجه تهيئة GCM طوله 12 بايتاً لا 16
الفرق التي تتبنّى GCM بتعديل مسار CBC قائم تنقل معها متجه التهيئة ذا الـ16 بايتاً، فتفشل النتيجة بلا أي دليل.
تُقرّ SP 800-38D متجه تهيئة بطول 96 بِتاً معياراً. والأطوال الأخرى مسموحة، لكنها ليست مجرّد «متجه تهيئة أطول»: فحين لا يكون طول المتجه 96 بِتاً، يشتقّ GCM كتلة العدّاد الابتدائية بتمرير المتجه عبر GHASH بدل استخدامه مباشرةً. ولذلك تُنتج البايتات الـ16 نفسها، حين تُستخدم رقمَ مرّة واحدة، سيل مفاتيح ووسماً مختلفَين تماماً عمّا كانت ستنتجه الـ12 الأولى، فتحصل على فشل مصادقة عامّ. وإن كان النصّ المشفَّر آتياً من مكان آخر وأنت تخمّن التخطيط، فعُدّ من الخلف: الوسم هو آخر 16 بايتاً، ورقم المرّة الواحدة هو أول 12 في الغالب الأعمّ.
5. عدم تطابق الوضع، بما فيه النوع الصامت
Cipher.getInstance("AES") هو ECB
تتيح لك Java تسمية شفرة دون تسمية وضع ولا نظام حشو. لا ترفض ولا تحذّر. وتحت مزوّد SunJCE المدمج في JDK تملأ الفراغين بـECB وPKCS5Padding.
وإثبات ذلك يحتاج التجربة الصحيحة: شفّر 32 بايتاً متطابقاً (كتلتان من A) بالمفتاح 0123456789abcdef، ثم افحص هل تتطابق كتلتا النصّ المشفَّر. على java 1.8.0_162:
getInstance("AES") ciphertext = 3bfd04cc0d7ed55358e2cbe19de213833bfd04cc0d7ed55358e2cbe19de21383377222e061a924c591cd9c27ea163ed4
block1 = 3bfd04cc0d7ed55358e2cbe19de21383
block2 = 3bfd04cc0d7ed55358e2cbe19de21383 <- identical blocks = the ECB fingerprint (plaintext structure leaks)
getInstance("AES/CBC/PKCS5Padding") blocks differ = chaining is active
متطابقتان بايتاً ببايت. تلك هي بصمة ECB، وهي الخاصية نفسها التي تجعل صورة البطريق المشفَّرة الشهيرة تبدو بطريقاً رغم التشفير. والتجربة لا تنجح إلا بكتل نصّ صريح متطابقة: فستة عشر بايتاً من A تليها ستة عشر بايتاً من B تُنتج كتلتَي نصّ مشفَّر مختلفتين تحت ECB أيضاً، فتستنتج خطأً أن الافتراضي هو CBC.
وحدّد نطاق النتيجة بدقّة: فهي تصف مزوّد SunJCE المدمج في JDK على الإصدار المذكور أعلاه. والتحويل الافتراضي قرار يخصّ المزوّد، فمزوّد خارجي مثل BouncyCastle قد يحلّ الاختصار نفسه حلاً مختلفاً. والتعميم ليس «Java تعني ECB» بل «سلسلة تحويل غير مؤهَّلة تعني ما يقرّره مزوّدك، ولهذا لا تكتب واحدة أبداً».
قد لا يُطلق الوضع الخاطئ أي خطأ
فكّ السيناريو C تشفير نصّ CBC المشفَّر باستخدام AES/ECB وأعاد النصّ الصريح الصحيح بلا استثناء. يبدو ذلك مستحيلاً حتى تكتب الحساب. فتشفير الكتلة الأولى في CBC هو C1 = E(P1 XOR IV)، وفكّ تشفير تلك الكتلة في ECB هو D(C1) = P1 XOR IV. وكان متجه التهيئة هنا أصفاراً بالكامل، فيكون P1 XOR 0 = P1 وتُفكّ الكتلة الأولى فكّاً مثالياً. وطول hello world كتلة واحدة، فكانت «الكتلة الأولى» هي الرسالة كلّها.
والقاعدة العامة: مع متجه تهيئة صفري، يتّفق ECB وCBC على الكتلة الأولى ويختلفان على كل كتلة بعدها. فكّ تشفير رسالة CBC طويلة على أنها ECB يعطيك ستة عشر بايتاً سليمة يتبعها ضجيج، وهو المعكوس التامّ لبصمة متجه التهيئة الخاطئ. شكلان متعاكسان لعطلين مختلفين، ولا يُطلق أيّ منهما رسالة خطأ. ومتجهات التهيئة الصفرية المكتوبة في الكود شائعة بما يكفي لألّا يكون هذا فضولاً مخبرياً.
ماذا يعطيك أدنى نداء ممكن في كل لغة
| المنظومة | أدنى نداء | الوضع الذي تحصل عليه فعلاً |
|---|---|---|
| Java (SunJCE) | Cipher.getInstance("AES") | ECB مع PKCS5Padding، بصمت |
Node، وحدة crypto | createDecipheriv('aes-256-cbc', key, iv) | ما تقوله سلسلة الخوارزمية؛ ولا وجود لافتراضي |
| Web Crypto | crypto.subtle.decrypt({ name: 'AES-CBC', iv }, ...) | مُسمّى صراحةً؛ وECB غير مُنفَّذ أصلاً |
Python، مكتبة cryptography | Cipher(algorithms.AES(key), modes.CBC(iv)) | كائن الوضع إلزامي |
| PyCryptodome | AES.new(key, AES.MODE_ECB) | وسيط إلزامي، لكن ECB حاضر أمامك في الإكمال التلقائي |
Go، حزمة crypto/aes | aes.NewCipher(key) يعيد cipher.Block خاماً | استدعاء Decrypt على تلك الكتلة هو ECB؛ غلّفه بـcipher.NewCBCDecrypter أو cipher.NewGCM |
| CryptoJS | CryptoJS.AES.decrypt(ct, "passphrase") | CBC، وPKCS#7، وEVP_BytesToKey مع MD5 (انظر القسم 7) |
المنظومات التي يعيش فيها الوضع داخل سلسلة أو كائن لا تفاجئك أبداً. أما الاثنتان اللتان تقدّمان نداء «AES فقط»، أي Java وGo، فمنهما تأتي بلاغات ECB العرَضي. وإن ورثت كوداً لا تعرف أيّ وضع يشغّله فعلاً، جرّب الأوضاع واحداً واحداً على النصّ المشفَّر نفسه قبل أن تتّهم المفتاح.
6. GCM: البايتات نفسها، وواجهات مختلفة
معظم إخفاقات aes gcm auth tag العابرة للغات ليست تشفيرية أصلاً. فالطرفان حسبا البايتات الـ16 نفسها، ويختلفان على موضع سكن تلك البايتات.
القياس
المفتاح = 32 بايتاً 0123456789abcdef0123456789abcdef، ومتجه التهيئة = 12 بايتاً صفرياً، والنصّ الصريح hello world، على node v25.8.2 وjava 1.8.0_162:
Node ciphertext = a616cd6d7d2328379d41e5 (11 B) <- update+final
authTag = c87af9f8ad7148e873fa797292c0af3f (16 B) <- fetched separately via getAuthTag()
Java doFinal() = a616cd6d7d2328379d41e5c87af9f8ad7148e873fa797292c0af3f (27 B) <- ciphertext and tag already concatenated
الناتج Node ciphertext || authTag هو بالضبط Java doFinal()، بكامل بايتاته الـ27. والفرق ليس في الترميز بل في التسليم: Node يسلّمك القطعتين منفصلتين، وJava تسلّمهما ملتصقتين. ولاحظ أيضاً أن 11 بايتاً من النصّ الصريح أعطت 11 بايتاً من النصّ المشفَّر، لأن GCM لا يضيف حشواً. ولهذا لا يمكن لخطأ حشو أن يأتي من مسار GCM حقيقي أبداً.
ملتصق أم منفصل، بحسب بيئة التشغيل
| بيئة التشغيل | واجهة التشفير | أين ينتهي الوسم |
|---|---|---|
Node، وحدة crypto | update() + final()، ثم getAuthTag() | منفصل |
Java (SunJCE، AES/GCM/NoPadding) | doFinal() | ملحق |
Go، واجهة cipher.AEAD | Seal() | ملحق |
Python، مكتبة cryptography، صنف AESGCM | encrypt() | ملحق |
Python، مكتبة cryptography، Cipher مع modes.GCM | finalize()، ثم encryptor.tag | منفصل |
| Web Crypto | crypto.subtle.encrypt | ملحق |
Node هو الشاذّ بين الواجهات عالية المستوى، ولهذا فاتجاه Node إلى أي شيء آخر هو الاتجاه الأكثر إبلاغاً عن الفشل. وتعبئة مخرجات Node لمستهلك بلغة Java أو Go أو Python أو للمتصفّح:
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const packed = Buffer.concat([ct, cipher.getAuthTag()]); // يطابق doFinal() الآن
وتفكيك كتلة ملتصقة لأجل Node:
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(packed.subarray(packed.length - 16)); // يجب أن يأتي قبل final()
const pt = Buffer.concat([
decipher.update(packed.subarray(0, packed.length - 16)),
decipher.final(),
]);
وقيد الترتيب حقيقي: استدعِ setAuthTag() بعد final() وسيُلقي Node الخطأ Unsupported state or unable to authenticate data حتى لو كان كل بايت صحيحاً. وإن لم تعرف أيّ التخطيطين أنتج نصّك المشفَّر، تحقّق من موضع الوسم على البايتات نفسها قبل أن تغيّر سطراً واحداً.
طول الوسم متغيّر، والوحدة ليست كذلك
يسمح GCM بأوسمة بطول 128 أو 120 أو 112 أو 104 أو 96 بِتاً، مع حجز 64 و32 للتطبيقات المقيَّدة (SP 800-38D، الملحق C). ويستخدم الجميع تقريباً 128، والمشكلة في طريقة طلب كل واجهة له:
- Java:
new GCMParameterSpec(128, iv). الوسيط الأول بالـبِت. - Web Crypto:
{ name: 'AES-GCM', iv, tagLength: 128 }. بالـبِت أيضاً، والافتراضي 128. - Node:
createCipheriv(algo, key, iv, { authTagLength: 16 }). بالـبايت.
وnew GCMParameterSpec(16, iv) سطر Java يبدو قانونياً لكنه يطلب وسماً بطول 16 بِتاً؛ وبعض إصدارات JDK ترفضه، وحيث يُقبَل تكون قد استبدلت بضمان السلامة رمية عملة احتمالها واحد من 65,536. وحين يختلف الطرفان على طول الوسم تختلف الأطوال المعبَّأة أيضاً، فيقطع المستقبِل عند الحدّ الخاطئ ويحصل على فشل مصادقة لا علاقة له بالمفتاح.
7. لديك عبارة مرور، لا مفتاح
إن كان أحد الطرفين يستقبل سلسلة يكتبها إنسان، فهناك دالة اشتقاق مفاتيح بين تلك السلسلة وAES، وعدم تطابق دالة الاشتقاق غير مرئي. فهي لا تُخطئ أبداً. تعيد 32 بايتاً سليمة تماماً، لكنها صدفةً الـ32 بايتاً الخاطئة، ويطفو الفشل طبقةً أدنى على هيئة (تعرف هذه) خطأ حشو.
يحتاج PBKDF2 إلى تطابق أربعة أشياء
- الملح. في صيغة
Salted__الخاصة بـOpenSSL هو 8 بايتات داخل النصّ المشفَّر؛ وفي صيغة عبارة المرور في أدواتنا هو بادئة بطول 16 بايتاً؛ وفي المخطّطات المصنوعة يدوياً كثيراً ما يكون ثابتاً مكتوباً في الكود. - عدد التكرارات. الأمر
openssl enc -pbkdf2يفترض 10,000. وتوصي OWASP حالياً بـ600,000 لـPBKDF2-HMAC-SHA256، وهو ما يستخدمه وضع عبارة المرور لدينا. والأُطر تختار أرقامها الخاصة. - دالة التجزئة. SHA-1 مقابل SHA-256 مقابل SHA-512. ولا يزال الكود الأقدم وبعض حِزم تطوير الهواتف يفترض SHA-1.
- طول المخرجات. اثنان وثلاثون بايتاً لـAES-256، وستة عشر لـAES-128. وبعض المخطّطات تشتقّ المفتاح ومتجه التهيئة معاً من نداء واحد أطول، وهذا لا يطابق أبداً اشتقاقاً بسيطاً بطول 32 بايتاً.
EVP_BytesToKey، ولماذا يستمرّ CryptoJS في عدم العمل
عبارة cryptojs aes decrypt not working تعني عادةً عدم تطابق واحداً بعينه. فالنداء CryptoJS.AES.encrypt(text, "passphrase") لا يستخدم PBKDF2، بل يستخدم EVP_BytesToKey، وهو اشتقاق OpenSSL السابق للإصدار 1.1، مع MD5 وتكرار واحد.
ويفعل EVP_BytesToKey أيضاً ما لا يفعله PBKDF2: يشتقّ المفتاح ومتجه التهيئة من عبارة المرور والملح في تمريرة واحدة. ولهذا لا يحمل ملفّ Salted__ الخاص بـOpenSSL حقل متجه تهيئة منفصلاً، ولهذا فمحاولة إعادة إنتاج مخرجات CryptoJS بـPBKDF2 مع متجه تهيئة عشوائي خطأ مضاعف.
والصيغة تُعرَف بالنظر: البايتات الثمانية ASCII Salted__ يتبعها ملح بطول 8 بايتات، وحين تُرمَّز بـbase64 تبدأ دائماً بـU2FsdGVkX1. فإن بدأ نصّك المشفَّر هكذا فهو مشتقّ من عبارة مرور وعليك أن تعرف أيّ اشتقاق؛ وأداة فكّ تشفير AES تكشف البادئة وتنتقل بين الثلاثة دون تغيير الكود.
لماذا تعطي كلمة المرور نفسها مفاتيح مختلفة
لا وجود لشيء اسمه «كلمة مرور AES». فكل مكتبة اخترعت مسارها الخاص من السلسلة إلى المفتاح:
| المنتِج | الاشتقاق | الناتج لعبارة مرور واحدة |
|---|---|---|
CryptoJS، النداء AES.encrypt(text, pass) | EVP_BytesToKey، MD5، تكرار واحد | المفتاح A |
openssl enc 1.0.2 وما قبله | EVP_BytesToKey، MD5، تكرار واحد | المفتاح A |
openssl enc 1.1+ بدون -pbkdf2 | EVP_BytesToKey، SHA-256، تكرار واحد | المفتاح B |
openssl enc -pbkdf2 | PBKDF2-HMAC-SHA256، 10,000 تكرار | المفتاح C |
| وضع عبارة المرور لدينا | PBKDF2-HMAC-SHA256، 600,000 تكرار | المفتاح D |
| Java وPython وGo | لا افتراضي إطلاقاً؛ أنت من يكتب الاشتقاق | ما كتبتَه أنت |
أربعة مفاتيح من كلمة مرور واحدة قبل أن يرتكب أحد أي خطأ. وقد غيّرت الترقية من 1.0.2 إلى 1.1 دالة التجزئة الافتراضية من MD5 إلى SHA-256، ولهذا توقّف النصّ المشفَّر الآتي من سكربتات قديمة عن الانفكاك بالأمر نفسه على جهاز أحدث. وإن ورثت بيانات ولا أحد يتذكّر سلسلة الأدوات، فجرّب الاشتقاقات بذلك الترتيب. الاحتمالات ثلاثة تُستنفَد في دقائق.
8. ماذا فعل النقل ببايتاتك
النصّ المشفَّر بيانات ثنائية عشوائية بانتظام، وهذا يجعله لا يحتمل أي طبقة تعامل البايتات على أنها نصّ. وحصّة كبيرة من إخفاقات AES لا تمسّ الشفرة أصلاً. ومصطلحات العربية تزيد الالتباس هنا: «فكّ التشفير» و«فكّ الترميز» عمليتان مختلفتان تماماً، لكن كثيرين يستعملونهما بالتناوب، فيصلك بلاغ عنوانه «فشل فكّ الترميز» والعطل الحقيقي في المفتاح، أو العكس. ويتقاسم مصطلح padding ترجمتين شائعتين، «الحشو» و«التبطين»، وكلتاهما تعني نمط PKCS#7 ذاته، فأرفق المصطلح الإنجليزي متى شككت في أن قارئك يقصد شيئاً آخر.
أنواع base64 والحشو المفقود
يستخدم base64 القياسي (RFC 4648 §4) المحرفين + و/؛ ويستخدم النوع الآمن لعناوين الويب (§5) المحرفين - و_. وسلسلة آمنة لعناوين الويب تُسلَّم إلى مفكّك قياسي إمّا تُلقي خطأً، وإمّا، في المفكّكات المتساهلة، تُهمل المحارف المخالفة بصمت وتعيد بايتات قصيرة غير محاذية، ولهذا تشحن Java الكائنين Base64.getUrlDecoder() وBase64.getDecoder() منفصلين. كما تُسقط بعض المرمِّزات علامة = الأخيرة، وتصرّ عليها بعض المفكّكات، ومسارات الكود المجاورة لـJWT تقتطعها افتراضياً.
وقبل أن تشكّ في المفتاح، فُكّ ترميز النصّ المشفَّر وافحص طوله في ضوء الوضع:
- CBC وECB: مضاعف غير صفري للرقم 16. وأي شيء غير ذلك اقتطاع أو مشكلة في فكّ الترميز، لا مشكلة مفتاح.
- GCM: طول النصّ المشفَّر يساوي طول النصّ الصريح، زائد 16 للوسم، زائد 12 في المقدّمة إن كان رقم المرّة الواحدة مُسبَقاً.
- CTR: أي طول، فهذا الفحص لا يخبرك بشيء.
وأداة فكّ ترميز Base64 تعطيك عدد البايتات بلصقة واحدة، وهو غالباً أسرع قياس في التحقيق كلّه.
الأسطر الجديدة وعلامات الاقتباس الذكية ودورة UTF-8
يلفّ openssl base64 المخرجات عند 64 عموداً ما لم تمرّر -A، وبعض المفكّكات تتخطّى الأسطر الجديدة المدمجة بينما ترفضها أخرى، فيُفكّ الملفّ نفسه على جهاز ويفشل على آخر. والنسخ عبر عميل محادثة أو محرّر مستندات يحوّل علامات الاقتباس المستقيمة إلى مقوّسة والشُّرَط القصيرة إلى شُرَط متوسطة، والفرق يكاد لا يُرى في الطرفية.
والحالة التي لا استرداد منها هي دورة UTF-8 ذهاباً وإياباً. فإن حُفظت مخرجات AES الخام يوماً على هيئة سلسلة نصّية دون ترميزها أولاً (new String(cipherBytes) في Java، أو bytes.decode('utf-8', errors='replace') في Python، أو TextDecoder في أي مكان)، فكل تسلسل بايتات ليس UTF-8 صالحاً ينهار إلى U+FFFD، وإعادة ترميزه تعطيك EF BF BD مكان بياناتك. وبما أن نصف البايتات العشوائية تقريباً خارج ASCII، فمعظم النصّ المشفَّر يُدمَّر ولا مفتاح يستعيده؛ ويشرح دليل ترميز UTF-8 وUTF-16 لماذا تكون الخسارة باتجاه واحد. النصّ المشفَّر الثنائي ينتقل بترميز base64 أو hex، أو ينتقل ثنائياً، ولا ينتقل سلسلةً نصّية أبداً.
أعمدة قواعد البيانات
يُوقِع التخزين الضرر نفسه بهدوء أكبر. فالنصّ المشفَّر المكتوب في عمود VARCHAR(255) ويزيد عنه بكتلة واحدة يُقصّ، وMySQL خارج الوضع الصارم يفعل ذلك بلا خطأ. والذيل هو حيث تسكن كتلة الحشو ووسم GCM، فصفّ كُتب «بنجاح» قبل أشهر يفشل الآن، وإن وقع القصّ على حدّ 16 بايتاً فلن يلتقطه فحص الطول أعلاه أيضاً. وتحويل مجموعة المحارف يتمّ الباقي: فعمود latin1 يستقبل بايتات UTF-8 يعيد كتابة بياناتك عند الدخول.
خزّن النصّ المشفَّر في VARBINARY أو BLOB أو bytea، أو خزّن base64 في عمود نصّي بمتّسع كافٍ.
9. مسار تنصيف يجد العطل في خمس دقائق
كل قسم أعلاه يضيّق متغيّراً واحداً. وتشغيل هذه الفحوص بالترتيب مقابل تطبيق مرجعي تتحكّم فيه يضيّق الاحتمالات سريعاً، وأدوات المتصفّح تصلح مرجعاً كهذا لأنها تعمل بالكامل داخل متصفّحك، فمفتاحك ونصّك المشفَّر لا يغادران هذه الصفحة ولا يُرفَعان إلى أي خادم، ولأنك تستطيع تغيير إعداد واحد في كل مرّة ورؤية البايتات.
-
الخطوة 0: قِس الشكل. فُكّ ترميز النصّ المشفَّر وسجّل عدد البايتات، وأول بضعة بايتات، وهل يبدأ بـ
U2FsdGVkX1. وافحص العدد في ضوء القسم 8. فإن لم يكن مضاعفاً للرقم 16 وأنت تظنّ نفسك في CBC، فتوقّف: هذا عطل نقل. -
الخطوة 1: شفّر نصّاً صريحاً معلوماً. في أداة تشفير AES، شفّر سلسلة قصيرة معلومة بالمعاملات التي تعتقد أن الإنتاج يستخدمها، ثم قارن شكل المخرجين لا قيمتيهما: الطول الكلّي، وبايتات البادئة، ووجود ترويسة ملح. وعدم التطابق يعني أن افتراضك عن الصيغة أو عن دالة الاشتقاق خاطئ، ولا قدر من العبث بالمفتاح يصلحه.
-
الخطوة 2: دوّر على الاشتقاقات. للبيانات المشتقّة من عبارة مرور، شغّل PBKDF2 بعدد التكرارات الدقيق، ثم EVP-SHA256، ثم EVP-MD5 في أداة فكّ تشفير AES. واحد منها فقط يمكن أن يكون صحيحاً. وإن لم ينجح أيّ منها، فالعطل فوق دالة الاشتقاق.
-
الخطوة 3: أزل كل عُرف. انتقل إلى مفتاح خام، وفعّل النصّ المشفَّر المجرّد، وزوّد متجه التهيئة صراحةً. أنت الآن تُصرّح بالضبط بأيّ البايتات مفتاح وأيّها متجه تهيئة وأيّها نصّ مشفَّر، دون استنتاج أي شيء. فإن انفكّ التشفير هنا ولم ينفكّ في كودك، فعطلك عطل تأطير (بادئة متجه تهيئة لم تُقتطع، أو وسم في الموضع الخاطئ) لا عطل تشفيري.
-
الخطوة 4: اقلب الوضع. جرّب CBC، ثم CTR، ثم GCM على البايتات نفسها. وعودة CTR بنصّ مقروء حيث فشل CBC تعني عدم تطابق في الوضع، وانتهى الأمر.
-
الخطوة 5: اقرأ النفاية. الكتلة الأولى فاسدة والباقي سليم يعني متجه التهيئة. والكتلة الأولى سليمة والباقي فاسد يعني أنك فككت CBC على أنه ECB بمتجه تهيئة صفري. وكل شيء فاسد يعني المفتاح أو الاشتقاق.
10. الأسئلة الشائعة
لماذا يعمل كود AES لديّ محلياً ويفشل في الإنتاج؟
يعمل الكود محلياً ويفشل في الإنتاج لأن البيئة غيّرت شيئاً غير موجود في نظام إدارة الشيفرة. والمشتبه بهم المعتادون، بالترتيب: وصل المفتاح من متغيّر بيئة أو مدير أسرار ومعه سطر جديد زائد؛ أو اختلفت مجموعة محارف Java الافتراضية بين الحاسوب المحمول والحاوية، فأنتج getBytes() بايتات مختلفة (القسم 3)؛ أو كان OpenSSL في الإنتاج 1.1 فما فوق بينما استهدفت سكربتاتك المحلية 1.0.2، فتغيّرت دالة تجزئة EVP_BytesToKey من MD5 إلى SHA-256؛ أو اقتطع عمود في قاعدة البيانات النصّ المشفَّر في بيئة واحدة فقط. اطبع طول المفتاح وطول النصّ المشفَّر بالبايت على الطرفين أولاً، فهذان الرقمان يحسمان الأمر عادةً.
شفّرت في Node ولا أستطيع فكّ التشفير في Java. من أين أبدأ؟
في مسار Node إلى Java ابدأ بوسم GCM، فهو السبب الأشيع والأقلّ وضوحاً. يعيد Node النصّ المشفَّر والوسم منفصلين، بينما يتوقّع doFinal() في Java أن يكونا ملتصقين على هيئة ciphertext || tag، والقسم 6 يبيّن أن البايتات متطابقة فيما عدا ذلك. وإن كنت على CBC بدلاً من ذلك، فابدأ بعُرف متجه التهيئة: هل وضعه Node بادئةً، وهل يقتطع طرف Java 16 بايتاً قبل فكّ التشفير؟ والثالث هو المفتاح نفسه، حيث يُنتج Buffer.from(k, 'hex') وk.getBytes(StandardCharsets.UTF_8) طولين مختلفين من السلسلة نفسها.
هل PKCS5Padding في Java هو نفسه PKCS#7؟
بالنسبة إلى AES، يتطابق PKCS5Padding مع PKCS#7 من حيث الأثر. فـPKCS#5 (RFC 8018) معرَّف لكتل بطول 8 بايتات فقط؛ وPKCS#7 (RFC 5652) يعمّم المخطّط على أحجام كتل من 1 إلى 255 بايتاً. وPKCS5Padding في Java، مطبَّقاً على شفرة كتل بطول 16 بايتاً، يُنفّذ سلوك PKCS#7، والاسم بقيّة تاريخية، فهذا ليس عطلك أبداً. أما NoPadding فهو كذلك: يشترط نصّاً صريحاً مضاعفاً للرقم 16 أصلاً، وعند فكّ التشفير يسلّمك الحشو على أنه بيانات، فترى نصّاً معقولاً بذيل من بايتات مثل \x05\x05\x05\x05\x05.
مفتاحي 32 محرفاً لكن AES يقول إن طول المفتاح غير صالح. لماذا؟
خطأ الطول يعني أن المكتبة تلقّت عدد بايتات ليس 16 ولا 24 ولا 32. ومع سلسلة من 32 محرفاً يكون السبب عادةً سطراً جديداً زائداً (33 بايتاً)، أو بادئة 0x تجعل السلسلة hex غير صالح، أو محرفاً خارج ASCII يشغل بايتين أو ثلاثة بترميز UTF-8. والنوع الأخطر هو ألّا تحصل على خطأ إطلاقاً: فـ32 محرف hex تُفكّ إلى 16 بايتاً صالحاً، و32 محرف base64 تُفكّ إلى 24 بايتاً صالحاً، وكلا الطولين قانوني في AES. فتقبلها المكتبة، وتستخدم المفتاح الخاطئ، وتسلّمك فشل حشو بدلاً من ذلك. افحص عدد البايتات لا عدد المحارف.
فكّ التشفير «نجح» لكن المخرجات نفاية. ما الذي حدث؟
فكّ التشفير «نجح» والمخرجات نفاية لأنك في وضع لا يتحقّق من شيء. فـCTR وECB لا يُلقيان خطأً أبداً، وCBC لا يُلقي خطأً إلا حين يفشل نمط البايت الأخير في فحص الحشو، وهو ما يجتازه مفتاح خاطئ فيما يقلّ قليلاً عن 0.4% من المرّات. اقرأ الشكل: أول 16 بايتاً فاسدة والباقي سليم يعني متجه التهيئة؛ وأول 16 سليمة والباقي فاسد يعني أنك فككت نصّ CBC المشفَّر على أنه ECB بمتجه تهيئة صفري؛ والفساد المنتظم يعني المفتاح أو الاشتقاق. والنصّ المقروء مع بضعة بايتات ذيلية غريبة يعني NoPadding على بيانات محشوّة. والإصلاح طويل الأمد هو GCM، حتى يعني «نجح» شيئاً.
هل أستطيع فكّ التشفير إن فقدت متجه التهيئة؟
دون متجه التهيئة يظلّ بإمكانك فكّ تشفير كل شيء في CBC عدا أول 16 بايتاً. فالكتل من الثانية فصاعداً تُستعاد بالعلاقة D(C_i) XOR C_{i-1}، وكل مُدخلات تلك العلاقة موجودة أصلاً في النصّ المشفَّر، فالكتلة الأولى وحدها هي التي تحتاج متجه التهيئة. وإن كنت تعرف أيضاً كيف يبدأ النصّ الصريح، مثل كائن JSON يبدأ بـ{"userId":، فبإمكانك استعادة متجه التهيئة كاملاً بالعلاقة D(C1) XOR P1. أما في CTR فمتجه التهيئة يبذر سيل المفاتيح كلّه، ففقدُه فقدٌ لكل شيء. وفي GCM يغذّي رقم المرّة الواحدة العدّاد والوسم معاً، فلا استعادة جزئية.
هل أستطيع استعادة النصّ الصريح إن اقتُطع وسم GCM أو سقط؟
استعادة النصّ الصريح بعد اقتطاع وسم GCM أو سقوطه ممكنة رياضياً، وعملياً بجهد. فـGCM مبنيّ على وضع CTR، فالمفتاح ورقم المرّة الواحدة وحدهما يعيدان إنتاج سيل المفاتيح. ولن تفعل ذلك لك أيّ مكتبة سائدة: فـJava وGo وPython وWeb Crypto ترفض جميعها إطلاق النصّ الصريح دون وسم صالح، وهذا بالتصميم. والحلّ الالتفافي هو فكّ تشفير البايتات نفسها على أنها AES-CTR مع ضبط كتلة العدّاد الابتدائية على رقم المرّة الواحدة ذي الـ12 بايتاً متبوعاً بـ00000002، وهو موضع بدء كتلة البيانات الأولى في GCM. تستعيد البيانات وتتخلّى عن كل ضمان سلامة، فعامل النتيجة على أنها غير موثوقة. وإن كانت بايتات الوسم الـ16 كاملة لديك وفشلت المصادقة رغم ذلك، فالوسم ليس مفقوداً وعطلك شيء آخر في هذه الصفحة. خذه إلى أداة فكّ تشفير AES وابدأ من الخطوة 0.