خطأ invalid signature في JWT: كل الأسباب وطرق إصلاحها
خطأ invalid signature في JWT يعني شيئاً واحداً: التوقيع الذي حسبه المُتحقِّق لديك لا يساوي التوقيع المحمول داخل الرمز، وهذا كل ما تقوله الرسالة. لا علاقة له بانتهاء صلاحية الرمز ولا بصلاحيات المستخدم، ولا يعني أن مكتبة JWT لديك معطوبة. هناك اختلاف بين طرف التوقيع وطرف التحقّق، إمّا في البايتات الداخلة إلى HMAC وإمّا في المفتاح العام المُمرَّر إلى نداء التحقّق.
في معظم الحالات يكون الجاني هو مادة المفتاح، لا الرمز نفسه. استخدم هذه الشجرة لتحديد نقطة البداية:
ما الخوارزمية المذكورة في الترويسة؟
├─ HS256 / HS384 / HS512 → المشكلة في السرّ غالباً
│ ├─ المُوقِّع والمُتحقِّق بلغتين مختلفتين؟ → القسم 3
│ └─ اللغة نفسها، يعمل محلياً ويفشل في الإنتاج؟ → القسم 4
└─ RS256 / ES256 / PS256 → المشكلة في صيغة المفتاح أو في اختيار مفتاح خاطئ غالباً
└─ → القسم 7
مرّ الرمز عبر بوّابة أو وسيط أو نسخ ولصق؟ → القسم 6
لا يظهر الخطأ إلا بعد ساعات أو على جهاز واحد؟ → القسم 8
وأسرع خطوة أولى ممكنة هي أن تلصق الرمز في محلّل JWT وتقرأ حقل alg؛ نصف فروع الشجرة أعلاه ينهار في اللحظة التي تعرفه فيها.
1. ما الذي يعنيه invalid signature بالضبط
تطبع المكتبات المختلفة سلاسل مختلفة للفشل نفسه، فابحث عن سلسلتك هنا:
- Node، مكتبة
jsonwebtoken:JsonWebTokenError: invalid signature - Python، مكتبة
PyJWT:InvalidSignatureError: Signature verification failed - Java، مكتبة
jjwt:SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.
الثلاثة تُطلَق في اللحظة نفسها وعلى مسار الكود نفسه. تأخذ المكتبة أول مقطعين من رمزك، وتعيد حساب التوقيع بالمفتاح الذي سلّمتها إياه، ثم تقارن الناتج بايتاً ببايت مع المقطع الثالث. وإن لم يتطابقا، تُلقي الخطأ.
المقارنة تامّة، ولا تحمل أي معلومة عن مقدار الفرق بين القيمتين. فاختلاف بايت واحد في السرّ ومفتاحٌ خاطئ تماماً ينتجان رسالة الخطأ نفسها حرفياً. ولهذا فالتقدّم الوحيد الممكن هو تضييق فضاء المدخلات؛ إعادة قراءة نصّ الخطأ بعناية أكبر لن تضيف معلومة واحدة.
انتبه إلى ما لم يحدث بعد حين يُطلَق هذا الخطأ. فالتحقّق من المطالبات يجري بعد التحقّق من التوقيع، أي أن exp وnbf وaud وiss لم يُنظَر إليها أصلاً. وإذا فشل التحقّق من توقيع JWT لديك، فمحتوى الرمز لا صلة له بالتشخيص، مع أنه يبقى مقروءاً لأن JWT مُرمَّز لا مُشفَّر. فكّ ترميز الترويسة والحمولة لا يحتاج مفتاحاً على الإطلاق؛ طالع كيفية فكّ ترميز رمز JWT إن أردت الشرح مقطعاً مقطعاً.
حقلان في الترويسة يحدّدان وجهتك التالية: alg يخبرك إن كنت تطارد سرّاً مشتركاً أم زوج مفاتيح، وkid يخبرك أي مفتاح ظنّ المُوقِّع أنه يستخدمه.
2. التوقيع يغطّي السلسلة المُرمَّزة، لا كائنك البرمجي
تُعرِّف RFC 7515، أي مواصفة JSON Web Signature، مُدخَل التوقيع (JWS Signing Input) بأنه سلسلة ASCII التالية:
BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload)
يُحسَب HMAC فوق تلك السلسلة. لا فوق خريطة claims لديك، ولا فوق كائن JSON، ولا فوق أي شيء تعتبره لغتك بيانات مُهيكَلة. وهذا هو مُدخَل التوقيع المستخدَم في هذا المقال كلّه، مأخوذاً من الحمولة المثالية القياسية:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
ومعظم المطوّرين يفهمون هذه النقطة مقلوبة، فتُوقِعهم النتيجة في الفخّ باستمرار: أي طبقة تفكّ ترميز الحمولة ثم تعيد ترميزها تدمّر التوقيع. فتسلسل JSON ليس قانونياً موحّداً. ترتيب المفاتيح يتغيّر حين تمرّ الخريطة ذهاباً وإياباً عبر معظم اللغات. والمسافات البيضاء تظهر أو تختفي. والمحارف خارج ASCII يهرّبها مُسلسِل على هيئة \uXXXX بينما يُخرِجها آخر حرفياً. والأرقام تُعاد صياغتها، فقد يعود 1516239022 على هيئة 1516239022.0. كل واحدة من هذه ينتج عنها سلسلة base64url مختلفة، ومن ثمّ مُدخَل توقيع مختلف، ومن ثمّ توقيع مختلف.
مُحفِّزات واقعية صادفناها:
- بوّابة API تحلّل رمز JWT لتُثريه بمُعرِّف مستأجر ثم تُصدِر الرمز من جديد.
- وسيط تسجيل أو تتبّع «يُوحِّد» الترويسات ويعيد كتابة قيمة Authorization.
- مطوّر نسّق الرمز تنسيقاً جميلاً ليقرأه، ثم أعاد لصق النسخة المنسّقة مكان الأصل.
إن كان أي مكوّن بين المُوقِّع والمُتحقِّق قادراً على إعادة كتابة الرمز، فهو المشتبه به الأول. الرموز سلاسل معتمة أثناء النقل؛ والعمليات الآمنة الوحيدة عليها هي التخزين والنسخ والمقارنة.
3. السرّ نفسه، بايتات مختلفة
هذا هو السبب الذي يقف خلف البلاغات التي تقول «السرّ متطابق حرفياً، وقد قارنته بنفسي».
HMAC لا يستهلك سلسلة نصية. بل يستهلك بايتات. أمّا ملف الإعدادات لديك، ومدير الأسرار، ومتغيّرات البيئة، فجميعها تخزّن سلاسل نصية. لا بدّ إذاً من شيء يحوّل الأولى إلى الثانية، وهذا التحويل غير موحَّد بين مكتبات JWT. يمكن لخدمتين أن تحملا سرّين متطابقين محرفاً بمحرف وتحسبا مع ذلك توقيعين مختلفين.
والبرهان محسوب محلياً على مُدخَل التوقيع الوارد في القسم 2، بسلسلة سرّية من 36 محرفاً:
c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm
| طريقة تفسير البايتات | عدد البايتات | ما هو المفتاح فعلياً | توقيع HS256 الناتج |
|---|---|---|---|
| يُعامَل نصّاً بترميز UTF-8 | 36 | المحارف الـ36 المرئية نفسها | tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak |
| يُفكّ ترميزه بـbase64 أولاً | 27 | secret-key-1234567890abcdef | 53ISDuciq-ov8YF1Ezwy6zo6KUO-1tpwz2oW7gVPuIM |
السلسلة السرّية والخوارزمية والحمولة واحدة في الحالتين، والتوقيعان لا يجمعهما شيء. والطرف الذي «أخطأ» أيّاً كان هو الذي يُبلِّغ عن invalid signature، ولن تكشف لك مقارنة ملف الإعدادات شيئاً مهما طالت، لأن ملفَّي الإعدادات متطابقان.
والرمز الكامل وفق قراءة UTF-8، إن أردت إعادة إنتاج هذا بنفسك:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.tUQobLFxHSIQqURPqGT59pkBnqQ95sZ0-JC1hE_4Zak
الصقه في محلّل JWT مع السرّ أعلاه فيجتاز التحقّق. وفُكّ ترميز السرّ بـbase64 أولاً فلن يجتازه.
كيف تحوّل كل مكتبة السلسلة النصية إلى بايتات مفتاح
التزم بما هو موثَّق، والعمود الأخير في الجدول أهمّ من الأول.
| بيئة التشغيل / المكتبة | سلوك تحويل السلسلة إلى بايتات | من يقرّر |
|---|---|---|
Node — jsonwebtoken | بايتات UTF-8 للسلسلة | المكتبة |
Python — PyJWT | بايتات UTF-8 للسلسلة | المكتبة |
Java — jjwt، التحميل الزائد القديم الذي يستقبل String | مُرمِّز base64 الخاص بالمنصّة، بحسب jwtk/jjwt#204 | المكتبة |
Go — golang-jwt | يستقبل []byte مباشرةً | أنت، في موضع النداء |
| .NET | يستقبل byte[] مباشرةً | أنت، في موضع النداء |
صفّ Java هو المصدر التاريخي لهذا الألم العابر للمنصّات. ففي إصدارات jjwt القديمة كانت signWith(SignatureAlgorithm, String) وأخواتها تمرّر السلسلة عبر مُرمِّز base64 بدل أخذ بايتاتها الخام، بينما كانت التحميلات الزائدة التي تستقبل byte[] تستخدم البايتات كما هي. ولهذا كانت خدمة Node وخدمة Java تتشاركان سرّاً واحداً ثم تختلفان. وقد أُهمِلت واجهة String تلك منذ الإصدار 0.10 من jjwt، والصيغة الحديثة صريحة:
SecretKey key = Keys.hmacShaKeyFor(secretBytes);
ولا يصحّ تعميم هذا على أنه «طريقة Java في التعامل مع JWT»؛ فهو تحميل زائد قديم في مكتبة واحدة، وكود jjwt الحديث الذي يمرّر byte[] لا يحمل أي التباس. والبلاغ المقابل على جانب Node هو auth0/node-jsonwebtoken#208، حيث كانت الرموز الموقَّعة في Java لا تجتاز التحقّق في Node. وثمّة بلاغات مشابهة على مكتبة firebase/php-jwt في PHP (انظر firebase/php-jwt#153)، غير أننا لم نتحقّق بأنفسنا من كيفية تعامل تلك المكتبة مع البايتات، فخُذها بوصفها خيطاً للتحرّي يحتاج تأكيداً.
أمّا Go و.NET فينتميان إلى فئة أخرى. فأيٌّ من المكتبتين لا تقرّر نيابةً عنك؛ كلتاهما تسلّمك المعامل []byte أو byte[] وتتنحّى. فـ[]byte(secret) وEncoding.UTF8.GetBytes(secret) تعطيان UTF-8، بينما تعطي Convert.FromBase64String(secret) بايتات مفكوكة الترميز. والعلّة حين تقع تسكن في موضع النداء لديك، وهذا خبر سارّ: فهي مرئية في الفارق البرمجي الذي تكتبه بنفسك.
هل سرّ JWT لديّ بترميز base64 أم UTF-8؟
لا توجد راية داخل الرمز تخبرك بذلك. عليك أن تستنتج من السلسلة نفسها:
- هل تستخدم فقط
A–Z a–z 0–9 + / =(أو-و_)؟ إن كان كذلك، فهي قد تكون base64. أمّا السرّ الذي يحتوي مسافة أو!أو#فلا يمكن أن يكون كذلك. - هل طولها من مضاعفات 4، أم تنتهي بحشو
=؟ كلاهما دليل قوي على أن شيئاً ما رمّزها بـbase64 في الطريق. - هل ينتج عن فكّ ترميزها بـbase64 بايتات معقولة؟ مرّرها عبر أداة فكّ ترميز Base64. فظهور نصّ ASCII مقروء أو 32 بايتاً تبدو عشوائية تماماً يرجّح base64. أمّا الرموز المشوّهة فترجّح أن السلسلة لم تُرمَّز قط.
سرّ مثل c2VjcmV0LWtleS0xMjM0NTY3ODkwYWJjZGVm يجتاز الاختبارات الثلاثة جميعاً، وهذا بالضبط ما يجعله خطراً: فهو ملتبس، وكلتا القراءتين معقولة. أمّا الأسرار التي تحتوي - أو _ فالتباسها أخبث، لأنها صالحة بوصفها base64url وغير صالحة بوصفها base64 القياسي.
وحين يعجز الاستنتاج عن إيصالك إلى جواب، احسب الاثنين. خذ مُدخَل التوقيع ومرّره عبر HMAC-SHA256 مرّتين في مولّد HMAC: مرّة والسرّ نصّ، ومرّة بالبايتات المفكوكة، ثم قارن كل ناتج بالمقطع الثالث من الرمز. سيتطابق أحدهما، وهذا يخبرك أيّ طرف من نظامك على صواب.
المحارف ليست بايتات
والفخّ ذو الصلة هو العدّ بالمحارف بينما الاشتراط منصوص عليه بالبايتات. تنصّ RFC 7518 §3.2 على الحدّ الأدنى لطول مفتاح HMAC-SHA بالبِتّات لا بالمحارف، والنصّ المُرمَّز يتمدّد:
| طريقة الكتابة | العشوائية | البايتات المكافئة | بالنسبة لـHS256 (يحتاج ≥256 بِت) |
|---|---|---|---|
| 32 محرفاً بترميز hex | 128 بِت | 16 بايتاً | ❌ دون الحدّ الأدنى |
| 32 محرفاً بترميز base64 | 192 بِت | 24 بايتاً | ❌ دون الحدّ الأدنى |
| 32 بايتاً عشوائياً | 256 بِت | 32 بايتاً | ✅ يفي بالحدّ (64 محرفاً بترميز hex، و44 بترميز base64 مع الحشو) |
«السرّ المكوّن من 32 محرفاً» قد يكون في أي مكان بين 128 و256 بِتّاً تبعاً للأبجدية المستخدمة. وهذه مسألة متعامدة على مشكلة تفسير البايتات أعلاه، لكنها تعضّ الأشخاص أنفسهم، لأن الفريق الذي يقيس بالمحارف هو عادةً الفريق الذي لم ينظر إلى البايتات قط. أمّا قواعد الاختيار الفعلية، أي الطول والترميز ودورية التدوير، فتجدها في الملاحظات المرجعية لـمولّد مفتاح JWT السري.
4. السرّ نفسه أصابه التلوّث
خدمتاك متّفقتان على تفسير البايتات. ومع ذلك ما زال التوقيع يفشل. تحقّق الآن ممّا إذا كان السرّ الذي حمّله كل طرف هو السرّ الذي تظنّ أنك كتبته، فسباكة البيئة بارعة إلى حدّ مدهش في إضافة بايت.
سطر جديد زائد في .env. السطر JWT_SECRET=abc متبوعاً بفاصل أسطر قد تحمّله بعض القارئات على أنه abc\n. بايت واحد زائد، ويُنتِج HMAC مخرَجاً لا صلة له بالأصل إطلاقاً. ولا يوجد تشابه جزئي يمكنك ملاحظته.
علامات الاقتباس تُقرأ بيانات. فـJWT_SECRET="abc" تعني abc عند بعض المُحمِّلات و"abc" عند غيرها، وخصوصاً حين تُحمِّل الملفَّ صدفةٌ مقابل أن تحلّله مكتبة. وقد يختلف env_file في Docker Compose مع مُحلِّل .env على الملف نفسه.
محارف غير مرئية من النسخ واللصق. نسخ سرّ من Slack أو من ويكي أو من ملف PDF قد يجرّ معه مسافة صفرية العرض (U+200B، بايتاتها e2 80 8b) أو مسافة غير فاصلة (U+00A0، بايتاتها c2 a0). كلتاهما غير مرئية في كل المحرّرات، وكلتاهما تغيّر HMAC.
تشويه من CI والحاويات. الأسرار التي تمرّ عبر استبدال الصدفة تُوسَّع فيها $ أو تُبتلَع الشرطات المائلة العكسية. وبعض أنظمة CI تقتطع المسافات من القيم وبعضها لا يفعل. وأسرار Kubernetes تكون base64 في ملف الوصف وخاماً داخل الحاوية، وهذا فخّ فكّ ترميز مزدوج قائم بذاته.
والعلاج هو الكفّ عن النظر إلى السرّ والبدء بقياسه. على كل طرف، اطبع الطول وبصمةً، ولا تطبع القيمة أبداً:
printf '%s' "$JWT_SECRET" | wc -c
printf '%s' "$JWT_SECRET" | shasum -a 256 | cut -c1-16
شغّل الأمرين على المُوقِّع وعلى المُتحقِّق وقارن المخرَجين. تطابق الطول وتطابق البصمة يعني أن السرّ ليس مشكلتك، فعُد إذاً إلى القسم 3. أمّا طول يزيد بواحد عمّا تتوقّع فهو السطر الجديد الزائد. وطول يزيد باثنين فهو علامتا الاقتباس.
وحين يكون الطول مختلاً وتريد أن ترى ما بداخله بالضبط، أفرِغه ست عشرياً في صدفة محلية مقابل سرّ تطوير:
printf '%s' "$JWT_SECRET" | xxd
وجود 0a في النهاية يعني سطراً جديداً. ووجود 22 في البداية والنهاية يعني زوجاً من علامتي الاقتباس. ووجود c2 a0 أو e2 80 8b في الوسط هو حالة المحرف غير المرئي. ولا تشغّل هذا على سرّ إنتاجي في جهاز يُرسِل مخرَجات طرفيّته إلى أي مكان.
والفحص المكافئ داخل عملية Node أو Python قيد التشغيل:
const s = process.env.JWT_SECRET ?? '';
console.log(Buffer.byteLength(s, 'utf8'), JSON.stringify(s.slice(-3)));
import os
s = os.environ["JWT_SECRET"]
print(len(s), len(s.encode("utf-8")), repr(s[-3:]))
في Python، إن كان len(s) أصغر من len(s.encode("utf-8")) فهذا يخبرك أن في السرّ محارف خارج ASCII، وهو سرّ كان يُفترض أن يكون ASCII بالكامل.
5. الخوارزمية ونوع المفتاح غير متطابقين
ترويسة alg والمفتاح الذي تمرّره يجب أن ينتميا إلى العائلة نفسها. فـHS256 يريد سرّاً مشتركاً، وهو سلسلة بايتات. وRS256 وES256 يريدان مفتاحاً غير متماثل، وهو PEM أو JWK. وحين تتقاطع الأسلاك تحصل على إخفاقات تتراوح بين خطأ نوع واضح وinvalid signature مجرّد، تبعاً لمدى تسامح المكتبة.
الصور الشائعة لهذا:
- الترويسة تقول
HS256، والمُتحقِّق يسلّم المكتبة مفتاحاً عاماً بصيغة PEM. بعض المكتبات تحسب HMAC فوق نصّ PEM نفسه ثم تُبلِّغ عن عدم تطابق التوقيع. - الترويسة تقول
RS256، والمُتحقِّق يسلّمها سلسلة سرّ HMAC. - المُتحقِّق لا يمرّر قائمة خوارزميات إطلاقاً ويترك المكتبة تستنتجها من
alg، فيغيّر انحرافُ إعدادٍ على جانب التوقيع سلوكَ المُتحقِّق في صمت.
هذه الأخيرة هي النقطة التي تتحوّل فيها علّة إعداد إلى علّة أمنية، فثبّت الخوارزمية صراحةً في كل نداء تحقّق:
jwt.verify(token, key, { algorithms: ['HS256'] });
jwt.decode(token, key, algorithms=["HS256"])
والتثبيت يحوّل كذلك أخطاء التوقيع الغامضة إلى أخطاء دقيقة. فإن وصل رمز يحمل alg: RS256 وقائمة السماح لديك تقول HS256، فستحصل على خطأ خوارزمية صريح يذكر القيمتين معاً.
وما يصفه هذا القسم هو سوء إعداد: مكوّنان من مكوّناتك يختلفان، دون خصم في الصورة. وثمّة إخفاق قريب له الشكل نفسه، حيث يعيد مهاجم كتابة alg من RS256 إلى HS256 ويوقّع مستخدماً مفتاحك العام سرّاً لـHMAC. ذاك هو خلط الخوارزميات، وهو هجوم لا علّة برمجية، وقد تناولناه في أفضل ممارسات أمان JWT مع بقية نموذج التهديد. والحماية واحدة في الحالتين (قائمة سماح صريحة)، وهذه حجّة جيدة لتطبيقها حتى وأنت تطارد علّة برمجية فقط.
6. الرمز تغيّر أثناء النقل
قبل أن تلوم المفاتيح، تأكّد أن المُتحقِّق استلم السلسلة نفسها التي أنتجها المُوقِّع. فرمز JWT هشّ تماماً بالطرق التي تكون بها السلاسل النصية هشّة.
البادئة Bearer. إن Authorization: Bearer eyJhbGci... قيمة ترويسة، لا رمزاً. والتقسيم على الفاصل الخاطئ، أو التقسيم مرّة واحدة والاحتفاظ بالنصف الخاطئ، يتركك تتحقّق من Bearer eyJhbGci... أو من سلسلة فارغة. أزِلها عن قصد:
const token = req.headers.authorization?.replace(/^Bearer\s+/i, '').trim();
المسافات البيضاء وفواصل الأسطر. الرموز المنسوخة من الطرفية تلتفّ على أسطر. والرموز المخزّنة في YAML تُطوى. ووجود \n واحد مدسوس داخل المقطع الثالث يُنتِج عدم تطابق توقيع، لا خطأ تحليل، لأن مُفكِّكات base64url كثيراً ما تتخطّى المسافات البيضاء بينما مقارنة السلاسل لا تتخطّاها.
ترميز URL. الرمز الذي سافر معاملَ استعلام قد يعود وقد صارت . فيه %2E، أو وقد تُرجِمت - و_ بفعل مُرمِّز مفرط الحماس. فُكّ الترميز مرّة واحدة، مرّة واحدة بالضبط.
البتر. ملفات تعريف الارتباط محدودة بنحو 4 كيلوبايت لكلٍّ منها، ورموز RS256 ذات المطالبات القليلة تتجاوز ذلك بشكل روتيني. والرمز المبتور يفشل عادةً في فكّ ترميز base64، لكنه إن قُطِع عند حدّ يقبل القسمة على 4 محارف فستحصل بدل ذلك على رمز يبدو سليماً بتوقيع خاطئ.
أمران يحسمان هذا. فرمز JWT سليم البنية يحتوي نقطتين بالضبط:
printf '%s' "$TOKEN" | tr -cd '.' | wc -c
وكل محرف فيه يجب أن ينتمي إلى أبجدية base64url، ولذا يجب ألّا يطبع هذا الأمر شيئاً على الإطلاق:
printf '%s' "$TOKEN" | tr -d 'A-Za-z0-9._-' | xxd
وأي مخرَج من الأمر الثاني يسمّي لك المشكلة: 3d هو حشو = لا ينبغي وجوده، و2b أو 2f هما + و/ من base64 القياسي في موضع تتوقّع فيه base64url علامتَي - و_، و20 مسافة شاردة.
7. إخفاقات خاصة بـRS256 وES256
الخوارزميات غير المتماثلة تستبدل بمشكلة السرّ مشكلةَ إدارة المفاتيح، وأنماط الإخفاق فيها مختلفة تماماً عمّا سبق.
PKCS#1 مقابل PKCS#8. هاتان صيغتا حاوية لمفتاح RSA نفسه، ويمكن التمييز بينهما بصرياً بكلمة واحدة في سطر الترويسة:
-----BEGIN RSA PRIVATE KEY----- ← PKCS#1
-----BEGIN PRIVATE KEY----- ← PKCS#8
تتفاوت المكتبات في أيّهما تقبل. فحين ترفض إحداها الصيغة رفضاً صريحاً تحصل على خطأ واضح؛ وحين تحلّلها نصف تحليل قد تحصل على توقيع لا يجتاز التحقّق أبداً. حوّل الصيغة بدل مصارعتها:
openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem
المفتاحان مقلوبان. التوقيع بالمفتاح العام، أو التحقّق بالمفتاح الخاص. بديهي من حيث المبدأ، وسهل الوقوع فيه حين يجلس الملفّان في المجلد نفسه باسمين لا يفرق بينهما سوى أربعة محارف. تحقّق أيّهما أيّ:
openssl rsa -in key.pem -noout -text | head -1
المفتاح الخاص يطبع حجم معامله بوصفه مفتاحاً خاصاً؛ أمّا المفتاح العام فيُخرِج خطأً ما لم تُضِف -pubin.
انحراف JWKS وkid. مع نقطة JWKS، يختار المُتحقِّق مفتاحاً بمطابقة kid الرمز مع مجموعة المفاتيح. وثلاثة أمور تختلّ هنا: أن يكون المُوقِّع قد دوّر المفاتيح ونسخة JWKS المخبَّأة لدى المُتحقِّق قديمة؛ أو ألّا يحمل الرمز kid فيختار المُتحقِّق أول مفتاح في المجموعة؛ أو أن تنشر بيئتان قيم kid متداخلة. وحين تشتبه في ذلك، اجلب JWKS جلباً طازجاً وتأكّد أن قيمة kid الواردة في ترويسة الرمز موجودة فيه بالضبط.
ترميز توقيع ES256. توقيعات ECDSA زوج من الأعداد الصحيحة، r وs، ولتسلسلهما طريقتان. فأطقم التعمية عامّة الغرض كثيراً ما تُخرِج DER، وهي بنية ASN.1 متغيّرة الطول. بينما تشترط RFC 7518 §3.4 صيغة JOSE بدلاً منها: r وs كلٌّ منهما محشوّ إلى طول ثابت ثم يُوصَلان، وهو ما يساوي 64 بايتاً في منحنى P-256. وتوقيع DER المدسوس داخل JWT ليس خاطئاً وحسب، بل مختلف الطول. ولذا فرمز ES256 الذي لا يُفكّ مقطعه الثالث إلى 64 بايتاً بالضبط بناه شيء تخطّى عملية التحويل.
ولعزل ما إذا كانت المشكلة في مفتاحك أم في خطّ الإنتاج لديك، وقّع الحمولة نفسها توقيعاً مستقلاً في أداة ترميز JWT وقارن المخرَج بما أنتجته خدمتك. فتطابق التوقيعين يشير إلى النقل أو إلى معالجة المطالبات. واختلافهما يشير إلى المفتاح.
8. أخطاء تبدو إخفاقات توقيع وليست كذلك
بعض هذه الأخطاء تسمّيها المكتبات نفسها تسمية خاطئة، وهكذا تنتهي في بلاغ العلّة الخطأ.
| العَرَض | ما هو فعلياً | أين تبحث |
|---|---|---|
ExpiredSignatureError في PyJWT | exp في الماضي. الاسم يقول توقيع؛ والسبب مطالبة. | انحراف الساعة بين الأجهزة، أو مدّة صلاحية قصيرة جداً |
ImmatureSignatureError في PyJWT | nbf في المستقبل | ساعة المُوقِّع متقدّمة على ساعة المُتحقِّق |
TokenExpiredError في Node | exp في الماضي | كما سبق |
| خطأ 401 عام بلا تفصيل | إطار العمل طوى كل إخفاقات التحقّق في استجابة واحدة | فعّل تسجيل الأخطاء على مستوى المكتبة |
| يعمل بضع دقائق ثم يفشل | انتهاء صلاحية الرمز، لا التوقيع | قارن iat وexp بساعتَي الجهازين |
| يفشل مع جمهور واحد فقط | عدم تطابق aud أو iss | قائمة الجمهور المتوقَّعة لدى المُتحقِّق |
تسمية PyJWT هي الفخّ الأبرز. فـExpiredSignatureError تحتوي كلمة «توقيع» لكنها تُرفَع أثناء التحقّق من المطالبات، بعد وقت طويل من نجاح التحقّق من التوقيع. والبحث بنصّ الخطأ يقودك مباشرة إلى مواد استكشاف أخطاء التوقيع، فتتبخّر ساعات في القسم الخطأ من المشكلة.
وانحراف الساعة يُنتِج أكثر الأنماط إرباكاً: إخفاقات متقطّعة لا ترتبط بأي شيء في كودك. فإن انجرفت ساعة أحد الأجهزة إلى الأمام، فشلت الرموز الصادرة حديثاً في التحقّق من nbf أو iat لحظة وصولها، وتاهت الإخفاقات كلما اتّسع الانجراف. قارن date -u على الجهازين أولاً. ومعظم المكتبات تقبل معامل تسامح زمني، وهو العلاج الصحيح لانحراف لا تستطيع إزالته، والعلاج الخاطئ لساعة معطوبة فعلاً.
والقاعدة العامة: إن كان الإخفاق مرهوناً بالوقت أو بالجهاز أو بالجمهور، فهو ليس مشكلة توقيع. فإخفاقات التوقيع حتمية: الرمز نفسه والمفتاح نفسه يفشلان بالطريقة نفسها إلى الأبد.
9. مسار عمل قابل للتكرار لاستكشاف الأخطاء
نفّذ هذه الخطوات بالترتيب. كل خطوة إمّا تعثر على العلّة وإمّا تشطب فرعاً، والتوقّف مبكراً هو المقصد.
- فُكّ ترميز الترويسة. الصق الرمز في محلّل JWT وسجّل
algوkid. هذه الخطوة تحدّد كل ما يليها ولا تحتاج مفتاحاً. - افحص شكل الرمز. نقطتان بالضبط، ومحارف base64url فقط، ولا بادئة
Bearer، ولا مسافات بيضاء. استخدم الأمرين الواردين في القسم 6. هذه الخطوة تشطب فساد النقل. - ثبّت الخوارزمية في نداء التحقّق. فإن وُجد عدم تطابق بين
algوقائمة السماح لديك، فستحصل الآن على خطأ صريح يذكر الاثنين بدل خطأ عام. - ابصم المفتاح على الطرفين. اطبع طول البايتات وSHA-256 مقتطعاً على المُوقِّع وعلى المُتحقِّق، كما في القسم 4. واختلاف القيمتين يعني أن السباكة هي المذنبة، فلا تصل إلى الخطوة 5 أصلاً.
- إن كان الطرفان بلغتين مختلفتين، فاحسم تفسير البايتات. راجع الجدول في القسم 3، وقرّر صراحةً إن كان السرّ نصّاً أم base64، واجعل الطرفين يصرّحان بذلك في الكود بدل الاعتماد على السلوك الافتراضي.
- أعِد توقيع الحمولة نفسها توقيعاً مستقلاً. استخدم أداة ترميز JWT بالمفتاح الذي تعتقد أنه الصحيح وقارن مقطعها الثالث بمقطع رمزك. فالتطابق يعني أن جانب التوقيع لديك سليم وأن المشكلة في المُتحقِّق.
- تحقّق من HMAC يدوياً تحقّقاً متقاطعاً. مرّر مُدخَل التوقيع عبر مولّد HMAC بتفسيرَي البايتات كليهما. وأيّهما طابق الرمز يخبرك أيّ طرف يجب أن تغيّره.
وإن اجتزت السبع كلّها وما زلت بحاجة إلى مساعدة، فمعظم بلاغات العلل تتعطّل لأنها تُغفِل الحقائق التي تحدّد الجواب. أدرِج ما يلي:
- قيمة
algمن الترويسة، وهل يوجدkidأم لا - اللغة والمكتبة والإصدار بالضبط على كلا جانبَي التوقيع والتحقّق
- طول السرّ بالبايتات على الجانبين، وأول 16 محرفاً ست عشرياً من SHA-256 الخاص به (لا السرّ نفسه أبداً)
- هل يُخزَّن السرّ نصّاً أم بترميز base64، وكيف يحوّله كل طرف
- مُدخَل التوقيع كاملاً. فالمقطعان الأولان ليسا حسّاسين؛ وكل من يملك الرمز يستطيع قراءتهما على أي حال
- لـRS256 وES256: سطر ترويسة PEM حرفياً
هذه القائمة تحوّل سؤالاً لا جواب له من نوع «توقيع JWT لديّ لا يتطابق» إلى سؤال يستطيع أحدهم حلّه فعلاً، وغالباً في ردّ واحد.
الأسئلة الشائعة
لماذا يعمل السرّ نفسه في لغة ويفشل في أخرى؟
لأن المكتبات تختلف في كيفية تحويل سلسلة السرّ إلى بايتات مفتاح. فـjsonwebtoken في Node وPyJWT في Python يستخدمان UTF-8؛ والتحميل الزائد القديم الذي يستقبل String في jjwt كان يستخدم مُرمِّز base64 (jwtk/jjwt#204)؛ أمّا Go و.NET فيتركان القرار لموضع النداء لديك. المحارف نفسها، بايتات مختلفة، ومن ثمّ HMAC مختلف.
هل يغطّي التوقيع الحمولة بعد فكّ ترميزها أم السلسلة المُرمَّزة؟
السلسلة المُرمَّزة. تُعرِّف RFC 7515 مُدخَل التوقيع بأنه base64url(header) + "." + base64url(payload) بوصفه نصّ ASCII حرفياً. وأي طبقة تفكّ تسلسل الحمولة ثم تعيد تسلسلها تغيّر ترتيب المفاتيح أو المسافات البيضاء أو صياغة الأرقام، فتُنتِج سلسلة مختلفة ومن ثمّ توقيعاً مختلفاً.
سرّي يبدو بترميز base64، فهل أفكّ ترميزه قبل التوقيع؟
فقط إن كان الطرف الآخر يفعل ذلك أيضاً. لا يوجد جواب صحيح بمعزل عن السياق؛ فالمطلوب أن يتّفق الطرفان. افحص هل تستخدم السلسلة محارف base64 فقط وهل طولها من مضاعفات الأربعة، ثم اجعل الاختيار صريحاً في الكود على الجانبين بدل الاتّكال على السلوك الافتراضي.
هل يمكن لسطر جديد زائد في .env أن يكسر التوقيع فعلاً؟
نعم. HMAC يستهلك بايتات، وabc\n أربعة بايتات بينما abc ثلاثة. والتوقيع الناتج لا يشترك في شيء مع التوقيع الصحيح. اطبع printf '%s' "$JWT_SECRET" | wc -c على الجهازين؛ وطولٌ يزيد بواحد عن المتوقَّع يكون هذا سببه في الغالب الأعمّ.
كيف أعرف إن كانت المشكلة في السرّ أم في الخوارزمية؟
اقرأ alg من الترويسة أولاً. فإن بدأت بـHS فأنت تحتاج سرّاً مشتركاً، وسيفشل معها PEM. وإن بدأت بـRS أو PS أو ES فأنت تحتاج زوج مفاتيح، وستفشل معها سلسلة سرّ. وما إن ينتمِ alg ونوع المفتاح إلى العائلة نفسها، تصبح الإخفاقات المتبقّية مشكلات في محتوى المفتاح.
لماذا يقول jwt.io إن التوقيع صالح بينما يرفضه خادمي؟
لأن الأداة الشبكية وخادمك قد يفسّران السرّ تفسيرين مختلفين: أحدهما نصّاً بترميز UTF-8 والآخر base64. فالأداة تتحقّق مقابل البايتات التي اشتقّتها هي، لا البايتات التي اشتقّها خادمك. ثم إيّاك أن تلصق أسرار الإنتاج في موقع طرف ثالث؛ استخدم مفتاح تطوير.
هل يسبّب رمزٌ منتهي الصلاحية خطأ invalid signature يوماً؟
لا. فالتحقّق من التوقيع يجري قبل التحقّق من المطالبات، ولذا لا يكون انتهاء الصلاحية سبباً أبداً. وانتهاء الصلاحية يظهر بشكل منفصل على هيئة TokenExpiredError في Node أو ExpiredSignatureError في PyJWT، واسم الأخير مضلّل لأن التوقيع اجتاز التحقّق بنجاح ولم يفشل سوى exp.
الخلاصة
عدم تطابق التوقيع لا يكاد يكون مشكلة تعمية أبداً؛ فـHMAC-SHA256 وRSA يعملان كما ينبغي. ما يفشل هو الحدّ الذي تتحوّل عنده السلسلة النصية إلى بايتات: مُرمِّز base64 على جانب وUTF-8 على الجانب الآخر، وسطر جديد احتفظ به مُحمِّل الإعدادات، وحمولة أعادت بوّابة تسلسلها «خدمةً لك». كل سبب في هذا الدليل هو خلاف حول البايتات.
فاجعل البايتات صريحة وكُفّ عن الاتّكال على السلوك الافتراضي. دوّن في توثيق فريقك هل يُخزَّن السرّ المشترك نصّاً خاماً أم بترميز base64، واجعل كل خدمة تحوّله بالطريقة المُعلَنة بدل أن ترث ما افترضته مكتبتها. وفي الأنظمة الممتدّة عبر عدّة لغات، خزّن الأسرار بترميز hex أو base64 وفُكّ ترميزها صراحةً في كل موضع نداء: سطر واحد لكل خدمة، ويزول الالتباس. ثم أضِف بصمة طول البايتات من القسم 4 إلى فحص السلامة لديك، حتى يظهر عدم التطابق التالي تحذيراً عند الإقلاع لا خطأ 401 في الإنتاج.