Een ES256-migrasie, drie maniere om 'n geldige handtekening te verknoei
Die algoritme-migrasie self het goed verloop. Die drie lewens van my JWT-ondertekensleutel vertel daardie storie: RS256 na ES256 op 'n lewende uitreiker, albei sleutels gedurende die oorvleueling in die JWKS gepubliseer, 'n aktiewe-sleutel-kieser wat homself van die verouderde RSA-sleutel af wegroteer. Sleutels, rotasie, ontdekking: al die dele wat moeilik klink, het met die eerste probeerslag gewerk.
Hierdie is die storie van die deel wat nie gewerk het nie. Drie afsonderlike kere gedurende daardie migrasie het ons stawingbediener 'n token gemunt waarvan die handtekening kriptografies geldig was (die wiskunde het geklop, die regte sleutel het die regte grepe onderteken), en geen valideerder op aarde sou dit aanvaar nie. Drie verskillende lae het elk die grepe op sy eie manier verknoei, en elke mislukking het van buite identies gelyk: invalid signature. Nie "verkeerde formaat" nie, nie "onverwagte lengte" nie. Net: ongeldig.
As jy midde-in 'n migrasie na daardie fout staar, spring na die veldgids aan die einde. Die lengte van die gedekodeerde handtekening wys byna elke keer die skuldige laag uit.
RSA het ons bederf: een heelgetal, een enkodering
Hier is die ding wat niemand jou voor 'n ECDSA-migrasie vertel nie: RSA het nooit hierdie probleem gehad nie, so nie een van jou instinkte dra oor nie.
'n RSA-handtekening is 'n enkele heelgetal. Die draadformaat is bloot daardie heelgetal, big-endian, opgevul tot die sleutelgrootte (256 grepe vir RSA-2048), en daar is in wese net een manier om dit neer te skryf. Onderteken met enige biblioteek, verifieer met enige ander, en die grepe beteken dieselfde ding.
'n ECDSA-handtekening is 'n paar heelgetalle, (r, s). 'n Paar het 'n enkodering nodig, en die ekosisteem het op twee onversoenbares besluit:
- ASN.1 DER: 'n
SEQUENCEvan tweeINTEGERs. Veranderlike lengte (tipies 70 tot 72 grepe vir P-256), en begin altyd met die greep0x30. Dit is wat OpenSSL, X.509 en TLS praat. - Rou R‖S (IEEE P1363):
rensword elk links opgevul tot presies 32 grepe en aanmekaar geheg. Altyd presies 64 grepe. Dit is wat JWS vir ES256 voorskryf (RFC 7518 §3.4).
Albei enkodeer dieselfde handtekening. Albei is "korrek." Gee 'n valideerder die een wat hy nie verwag nie, en hy sê nie "verkeerde houer" nie: hy dekodeer gemorsheelgetalle en rapporteer die enigste ding wat hy kan: die handtekening verifieer nie.
Elke laag tussen jou sleutel en die draad het 'n mening oor watter een van dié hy praat. Ons het drie lae gehad, en ons het al drie verkeerd gehad, een na die ander.
Slagyster een: die KMS antwoord in DER
Ons private sleutel woon in HashiCorp Vault se transit-enjin en verlaat dit nooit: die toepassing stuur grepe om onderteken te word en kry 'n handtekening terug. Toe ons die transit-sleutel van rsa-2048 na ecdsa-p256 oorskakel, het die ondertekenversoek steeds 'n oorblyfsel uit die RSA-era gedra: signature_algorithm=pkcs1v15.
Dit is 'n RSA-opvulling-parameter. Dit is betekenisloos vir 'n ECDSA-sleutel. Vault het nie 'n fout gegee nie. Dit het die irrelevante parameter geïgnoreer, met die kurwesleutel onderteken, en 200 teruggegee met 'n volkome geldige ECDSA-handtekening, in sy verstek-marshaling, wat DER is.
Die gelukkige pad het dus van end tot end groen gebly: versoek aanvaar, handtekening teruggestuur, openssl dgst -verify sou dit geseën het. En elke JWT-biblioteek het elke token verwerp, want nadat dit die derde segment base64url-gedekodeer het, het dit 64 grepe R‖S verwag en 71 grepe gevind wat met 0x30 begin.
Die regstelling is een versoekparameter: Vault se transit-ondertekeneindpunt neem marshaling_algorithm=jws, wat rou R‖S direk teruggee. Maar let op die vorm van die strik: 'n parameter wat verkeerd is vir die sleuteltipe en stilweg geïgnoreer word. Die versoek het verkeerd gelyk, was verkeerd, en het in elk geval gewerk, vir die verkeerde definisie van gewerk.
Slagyster twee: "heg net die heelgetalle aanmekaar" faal een token uit 128
As jou KMS nie self R‖S kan lewer nie, sal jy in die versoeking kom om DER self om te skakel: ontleed die twee INTEGERs, heg hulle aanmekaar, klaar. Dit is die naarste slagyster van die drie, want dit is met tussenposes korrek.
DER-heelgetalle is minimaal in lengte en het 'n tekenbit. Dit beteken:
- 'n
rwat toevallig numeries klein is (hoë greep nul) enkodeer in 31 grepe of minder, want DER stroop voorste nulle; - 'n
rwaarvan die hoë bit gestel is, tel 'n0x00-tekengreep op en enkodeer in 33 grepe.
Die JWS-formaat wil nie een van die twee hê nie. Dit wil elke waarde links opgevul tot presies 32 grepe hê, altyd. 'n Naïewe aanmekaarheg van die DER-heelgetalle lewer handtekeninge van 63, 64, 65 of 66 grepe, afhangend van die trekking, en net die 64-greep-trekkings verifieer. Hanteer die tekengreep maar vergeet die opvulling, en jy sit met 'n omskakelaar wat faal wanneer r of s met 'n nulgreep begin: elk is 'n 1-uit-256-gebeurtenis, so ongeveer een token uit 128 kom te kort uit.
Daardie weergawe slaag jou rooktoets, slaag kodehersiening, word uitgerol, en faal dan een aanmelding per uur in produksie sonder enige patroon wat jy kan sien. Ons het dit vrygespring deur Vault die omskakeling te laat doen (marshaling_algorithm=jws weer eens: laat die party wat die ondertekenbewerking besit, die formaat besit), maar dieselfde strik het 'n suiwer plaaslike variant in .NET: ECDsa.SignData/VerifyData neem 'n DSASignatureFormat, en verskillende hoeke van die raamwerk het verskillende verstekke: die SignedXml/sertifikaat-wêreld dink in Rfc3279DerSequence, terwyl die rou ECDsa-API's na P1363 verstek. Ons plaaslike verifikasiepad sê nou eksplisiet DSASignatureFormat.IeeeP1363FixedFieldConcatenation. Ná hierdie migrasie laat ons geen handtekening-API enige plek sy formaat by verstek kies nie.
Slagyster drie: die regstelling het die base64-dialek onder ons verander
Vault vou elke handtekening in 'n koevert toe: vault:v1:<encoded-bytes>. Met die verstek-DER-marshaling is die geënkodeerde deel standaard base64, en ons kliënt het pligsgetrou Convert.FromBase64String daarop geroep.
Skakel oor na marshaling_algorithm=jws, en Vault enkodeer die handtekeninggedeelte (redelikerwys, volgens die spesifikasie wat dit nou praat) in base64url: die -/_-alfabet, geen opvulling nie. Een versoekvlag het twee dinge gelyktydig verander: die greepformaat waarvoor ons gevra het, en die teksenkodering waarin dit aangekom het.
Convert.FromBase64String wil opgevulde standaard base64 hê. 'n 64-greep-handtekening is 86 base64url-karakters (nie 'n veelvoud van vier nie), so die dekodering het 'n FormatException op elke enkele token gegooi. In 'n sekere sin was dit die vriendelikste van die drie mislukkings: dit het ten minste omgeval, hard, op die presiese reël wat verkeerd was, in plaas daarvan om geldig-lykende grepe te lewer wat stilweg elders faal. Die regstelling is om met 'n base64url-kodek te dekodeer (en, op die verifieer-rondreis, weer te enkodeer).
Tel die ironie: die handtekening was uiteindelik in die regte greepformaat, en nou het die teks-laag dit verknoei. Drie enkoderings, opmekaar gestapel: die heelgetalpaar-enkodering, die vaste-veld-opvulling, en die karakterenkodering. Elkeen op sy eie in staat om 'n geldige handtekening in 'n verwerpte token te verander.
Die veldgids: die lengte wys die skuldige uit
Wanneer 'n JWT midde-in 'n algoritme-migrasie validasie faal, base64url-dekodeer die derde segment van die token en kyk wat jy het:
| Gedekodeerde handtekening | Watter laag het dit verknoei |
|---|---|
| Presies 64 grepe | Die draadformaat is reg: kyk elders (kid-wanpassing, verkeerde sleutel in JWKS, alg-vaspenning) |
70 tot 72 grepe, eerste greep 0x30 |
DER het deurgelek: die KMS of kripto-biblioteek lewer ASN.1 |
| 63 of 65 grepe, en net sommige tokens faal | 'n Handgemaakte DER→rou-omskakeling sonder vaste-veld-opvulling |
| Dekodering gooi 'n fout of lewer gemors | base64 vs base64url-wanpassing een laag laer |
En een kruiskontrole wat formaatfoute binne sekondes van sleutelfoute skei: verifieer dieselfde handtekening met OpenSSL, wat DER praat. As OpenSSL sê geldig terwyl elke JWT-biblioteek sê ongeldig, het jy 'n enkoderingsprobleem. As albei sê ongeldig, het jy 'n sleutelprobleem.
Die les
"Kriptografies geldig" is 'n eienskap van die wiskunde. "Verifieerbaar" is 'n eienskap van elke enkoderingsgrens tussen die wiskunde en die verbruiker, en ons handtekening het vier daarvan oorgesteek: die KMS se marshaling, sy vervoerkoevert, ons kliënt se dekodering, en die JWS-draadformaat. Drie van daardie grense het menings gehad, en elkeen het presies een keer van sy buurman verskil.
Die toetsles is die een waaroor ons werklik ons proses verander het: bewys nooit 'n ondertekenpad deur met jou eie kode te verifieer nie. Ons ondertekenpad en ons verifieerpad het elkeen van hierdie aannames gedeel, so onderteken-dan-verifieer-rondreise het geslaag terwyl elke werklike verbruiker ons verwerp het. Die toets wat uiteindelik al drie foute vasgepen het, munt 'n werklike token, valideer dit met die platform se standaard JWT-biblioteek teen die gepubliseerde JWKS (iemand anders se aannames, nie ons s'n nie), en beweer dan een brutaal spesifieke ding boonop: die gedekodeerde handtekening is presies 64 grepe. Die wiskunde sal na homself omsien. Dit is die grepe waarna jy moet oplet.