← All posts

एक ES256 माइग्रेशन, एक वैध सिग्नेचर को बिगाड़ने के तीन तरीके

Authagonal·July 20, 2026
jwtecdsaes256cryptovaultjwsdotnet

एल्गोरिद्म माइग्रेशन अपने आप में ठीक-ठाक निपट गया। मेरी JWT signing key के तीन जीवन वह कहानी बताता है: एक लाइव issuer पर RS256 से ES256, ओवरलैप के दौरान दोनों keys JWKS में प्रकाशित, और एक active-key picker जो खुद को लेगेसी RSA key से हटा लेता है। Keys, rotation, discovery, यानी वे सारे हिस्से जो सुनने में कठिन लगते हैं, पहली ही कोशिश में चल गए।

यह उस हिस्से की कहानी है जो नहीं चला। उस माइग्रेशन के दौरान तीन अलग-अलग बार हमारे auth सर्वर ने ऐसा टोकन जारी किया जिसका सिग्नेचर क्रिप्टोग्राफ़िक रूप से वैध था (गणित सही बैठ रहा था, सही key ने सही बाइट्स पर हस्ताक्षर किए थे) और फिर भी दुनिया का कोई validator उसे स्वीकार नहीं करता। तीन अलग-अलग लेयरों ने बाइट्स को अपने-अपने ढंग से बिगाड़ा था, और हर विफलता बाहर से एक जैसी दिखती थी: invalid signature। "wrong format" नहीं, "unexpected length" नहीं। बस: invalid.

अगर आप माइग्रेशन के बीच में इसी एरर को घूर रहे हैं, तो सीधे अंत में दी गई फ़ील्ड गाइड पर जाइए। डिकोड किए गए सिग्नेचर की लंबाई लगभग हर बार दोषी लेयर का नाम बता देती है।

RSA ने हमें बिगाड़ रखा था: एक integer, एक encoding

ECDSA माइग्रेशन से पहले कोई आपको यह बात नहीं बताता: RSA में यह समस्या कभी थी ही नहीं, इसलिए आपकी कोई भी सहज समझ यहाँ काम नहीं आती।

एक RSA सिग्नेचर होता ही एक अकेला integer है। wire format बस वही integer है, big-endian, key के आकार तक padded (RSA-2048 के लिए 256 बाइट्स), और उसे लिखने का व्यावहारिक रूप से एक ही तरीका है। किसी भी लाइब्रेरी से sign कीजिए, किसी भी दूसरी से verify कीजिए, बाइट्स का मतलब वही रहता है।

एक ECDSA सिग्नेचर integers की एक जोड़ी है, (r, s)। जोड़ी के लिए एक encoding चाहिए, और इकोसिस्टम दो असंगत encodings पर जा टिका:

  • ASN.1 DER: दो INTEGER वाला एक SEQUENCE। परिवर्तनशील लंबाई (P-256 के लिए आम तौर पर 70–72 बाइट्स), हमेशा 0x30 बाइट से शुरू। OpenSSL, X.509 और TLS यही बोलते हैं।
  • Raw R‖S (IEEE P1363): r और s दोनों को ठीक 32 बाइट्स तक left-pad करके जोड़ दिया जाता है। हमेशा ठीक 64 बाइट्स। JWS ES256 के लिए यही अनिवार्य करता है (RFC 7518 §3.4)।

दोनों एक ही सिग्नेचर को encode करते हैं। दोनों "सही" हैं। validator को वह थमा दीजिए जिसकी उसे उम्मीद नहीं है, और वह "wrong container" नहीं कहता: वह कचरा integers डिकोड करता है और वही एक बात रिपोर्ट करता है जो कर सकता है, कि सिग्नेचर verify नहीं होता।

आपकी key और wire के बीच की हर लेयर की अपनी राय है कि वह इनमें से कौन-सी बोलती है। हमारी तीन लेयरें थीं, और हमने तीनों को, एक के बाद एक, गलत किया।

पहला footgun: KMS जवाब DER में देता है

हमारी private key HashiCorp Vault के transit engine में रहती है और उससे कभी बाहर नहीं निकलती: एप्लिकेशन sign करने के लिए बाइट्स भेजता है और बदले में सिग्नेचर पाता है। जब हमने transit key को rsa-2048 से ecdsa-p256 में बदला, तो sign request में RSA युग का एक अवशेष अब भी मौजूद था: signature_algorithm=pkcs1v15

यह एक RSA padding पैरामीटर है। ECDSA key के लिए यह अर्थहीन है। Vault ने कोई एरर नहीं दिया। उसने अप्रासंगिक पैरामीटर को अनदेखा किया, curve key से sign किया, और 200 के साथ एक बिलकुल वैध ECDSA सिग्नेचर लौटाया, अपनी डिफ़ॉल्ट marshaling में, जो DER है।

तो happy path सिरे से सिरे तक हरा ही रहा: request स्वीकार हुई, सिग्नेचर लौटा, openssl dgst -verify उसे आशीर्वाद दे देता। और हर JWT लाइब्रेरी ने हर टोकन को अस्वीकार किया, क्योंकि तीसरे सेगमेंट को base64url-decode करने के बाद उसे 64 बाइट्स का R‖S चाहिए था और मिले 71 बाइट्स, जो 0x30 से शुरू होते थे।

समाधान एक request पैरामीटर है: Vault का transit sign endpoint marshaling_algorithm=jws लेता है, जो सीधे raw R‖S लौटाता है। लेकिन इस जाल की बनावट पर ध्यान दीजिए: एक ऐसा पैरामीटर जो key type के लिए गलत है और चुपचाप अनदेखा कर दिया जाता है। request गलत दिखती थी, गलत थी, और फिर भी काम कर गई, बस "काम करने" की गलत परिभाषा से।

दूसरा footgun: "बस integers को जोड़ दो" हर 128 में से एक टोकन पर फेल होता है

अगर आपका KMS मूल रूप से R‖S नहीं दे सकता, तो आप DER को खुद बदलने के लिए ललचाएँगे: दोनों INTEGER निकालिए, जोड़िए, हो गया। यह तीनों में सबसे खतरनाक footgun है, क्योंकि यह रुक-रुक कर सही होता है।

DER integers minimal-length और signed होते हैं। इसका मतलब:

  • ऐसा r जो संयोगवश संख्यात्मक रूप से छोटा है (उच्च बाइट शून्य) 31 बाइट्स या उससे कम में encode होता है, क्योंकि DER आगे के शून्य हटा देता है;
  • ऐसा r जिसका उच्च bit सेट है, एक 0x00 sign बाइट पा लेता है और 33 बाइट्स में encode होता है।

JWS format को इनमें से कुछ नहीं चाहिए। उसे चाहिए कि हर value ठीक 32 बाइट्स तक left-padded हो, हमेशा। DER integers का सीधा-सादा जोड़ किस्मत के हिसाब से 63, 64, 65 या 66 बाइट्स के सिग्नेचर बनाता है, और केवल 64 बाइट वाले ही verify होते हैं। sign बाइट सँभाल लीजिए लेकिन padding भूल जाइए, और आपके पास ऐसा converter बचता है जो तब-तब फेल होता है जब r या s शून्य बाइट से शुरू होता है: दोनों में से हर एक 1-in-256 की घटना है, इसलिए लगभग हर 128 में से एक टोकन छोटा निकलता है।

वह संस्करण आपका smoke test पास करता है, code review पास करता है, ship होता है, और फिर production में घंटे भर में एक login फेल करता है, बिना किसी ऐसे पैटर्न के जो आपको दिखे। हम इससे इस तरह बचे कि conversion Vault से करवाया (फिर वही marshaling_algorithm=jws: जो पक्ष signing operation का मालिक है, वही format का भी मालिक हो), लेकिन इसी जाल का एक विशुद्ध लोकल रूप .NET में भी है: ECDsa.SignData/VerifyData एक DSASignatureFormat लेते हैं, और framework के अलग-अलग कोने अलग-अलग डिफ़ॉल्ट रखते हैं: SignedXml/certificate की दुनिया Rfc3279DerSequence में सोचती है जबकि raw ECDsa APIs का डिफ़ॉल्ट P1363 है। हमारा लोकल verification path अब स्पष्ट रूप से DSASignatureFormat.IeeeP1363FixedFieldConcatenation कहता है। इस माइग्रेशन के बाद हम किसी भी signature API को कहीं भी अपना format डिफ़ॉल्ट नहीं करने देते।

तीसरा footgun: fix ने हमारे पैरों तले base64 की बोली बदल दी

Vault हर सिग्नेचर को एक envelope में लपेटता है: vault:v1:<encoded-bytes>। डिफ़ॉल्ट DER marshaling के साथ encoded हिस्सा standard base64 होता है, और हमारा client उस पर कर्तव्यनिष्ठा से Convert.FromBase64String चलाता था।

marshaling_algorithm=jws पर जाइए और Vault (जायज़ तौर पर, उस spec के अनुसार जो अब वह बोल रहा है) सिग्नेचर वाले हिस्से को base64url में encode करता है: -/_ वाली वर्णमाला, कोई padding नहीं। एक request flag ने एक साथ दो चीज़ें बदल दी थीं: वह byte format जो हमने माँगा था, और वह text encoding जिसमें वह पहुँचा।

Convert.FromBase64String को padded standard base64 चाहिए। 64 बाइट का सिग्नेचर 86 base64url अक्षरों का होता है (चार का गुणज नहीं), इसलिए decode हर एक टोकन पर FormatException फेंकता था। एक तरह से यह तीनों में सबसे दयालु विफलता थी: यह कम से कम ज़ोर से, ठीक उसी लाइन पर क्रैश हुई जो गलत थी, बजाय इसके कि वैध दिखने वाले बाइट्स बनाकर कहीं और चुपचाप फेल होती। समाधान है base64url codec से decode (और verify round-trip पर re-encode) करना।

विडंबना गिनिए: सिग्नेचर आख़िरकार सही byte format में था, और अब text लेयर ने उसे बिगाड़ दिया। तीन encodings, एक के ऊपर एक: integer-pair encoding, fixed-field padding, और character encoding। हर एक अकेले दम पर एक वैध सिग्नेचर को अस्वीकृत टोकन में बदलने में सक्षम।

फ़ील्ड गाइड: लंबाई ही दोषी का नाम बता देती है

जब algorithm माइग्रेशन के बीच कोई JWT validation में फेल हो, तो टोकन के तीसरे सेगमेंट को base64url-decode कीजिए और देखिए आपके पास क्या है:

डिकोड किया गया सिग्नेचर किस लेयर ने बिगाड़ा
ठीक 64 बाइट्स wire format सही है, कहीं और देखिए (kid mismatch, JWKS में गलत key, alg pinning)
70–72 बाइट्स, पहला बाइट 0x30 DER रिसकर आ गया: KMS या crypto लाइब्रेरी ASN.1 निकाल रही है
63 या 65 बाइट्स, और केवल कुछ टोकन फेल होते हैं fixed-field padding के बिना हाथ से लिखा DER→raw conversion
decode एरर फेंकता है या कचरा देता है एक लेयर नीचे base64 बनाम base64url का mismatch

और एक cross-check जो सेकंडों में format bugs को key bugs से अलग कर देता है: उसी सिग्नेचर को OpenSSL से verify कीजिए, जो DER बोलता है। अगर OpenSSL valid कहे जबकि हर JWT लाइब्रेरी invalid कहे, तो आपके पास encoding की समस्या है। अगर दोनों invalid कहें, तो key की समस्या है।

सबक

"क्रिप्टोग्राफ़िक रूप से वैध" गणित का गुण है। "verify हो सकने योग्य" गणित और consumer के बीच की हर encoding सीमा का गुण है, और हमारा सिग्नेचर ऐसी चार सीमाएँ पार करता था: KMS की marshaling, उसका transport envelope, हमारे client का decode, और JWS का wire format। उनमें से तीन सीमाओं की अपनी-अपनी राय थी, और हर एक ने ठीक एक-एक बार अपने पड़ोसी से असहमति जताई।

testing का वह सबक जिस पर हमने वाकई अपनी प्रक्रिया बदली: signing path को कभी अपने ही कोड से verify करके साबित मत कीजिए। हमारे sign path और verify path इन सभी मान्यताओं को साझा करते थे, इसलिए sign-then-verify round-trips पास होते रहे जबकि हर असली consumer हमें अस्वीकार करता रहा। जिस टेस्ट ने आख़िरकार तीनों bugs को पकड़ा, वह एक असली टोकन mint करता है, उसे प्लेटफ़ॉर्म की stock JWT लाइब्रेरी से published JWKS के विरुद्ध validate करता है (किसी और की मान्यताएँ, हमारी नहीं), और फिर ऊपर से एक बेरहमी से specific बात assert करता है: डिकोड किया गया सिग्नेचर ठीक 64 बाइट्स है। गणित अपना ख्याल खुद रख लेगा। नज़र आपको बाइट्स पर रखनी है।