1. 由来
那天我例行随手抓了一个开发版程序的数据包,在查看请求数据的时候,看到了熟悉的Authorization。
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl9hcHBfdXNlc l9rZXkiOiJmNmUxMWExNS00YzdlLTQyZmItYjViYy1jNGJkZGE1YmY2MzkifQ.u0 td-yH1n04gUnNfbtdFklf4VhLKawm6hb1_f4Aco9h0vsuVevHTq2M8V7N5mkLLWb o064918HHLPmQh2hYcFA
已知这是一个Java的后端程序生成的JWT Token。以前用Cookies用的比较多,看到这里我突然有点好奇,你说这Java生成的JWT Token,.Net 可以解出来吗,按理说这是一个规范,应该可以解出来吧。
2. 初次尝试
问人要了JWT的加密密钥,信息如下。
密钥:O9CEs5KknWV0Uz1MJ7FIdxwaT4
祭出世界上最强大的IDE——Visual Studio,新建个Web API项目,添加 JwtBearer 包,在 Program.cs
中熟练地复制-粘贴了如下的初始化代码。
builder.Services.AddAuthentication() .AddJwtBearer( JwtBearerDefaults.AuthenticationScheme, options => { options.RequireHttpsMetadata = false; options.TokenValidationParameters = new TokenValidationParameters() { RequireAudience = false, ValidateAudience = false, ValidateIssuer = false, RequireExpirationTime = false, RequireSignedTokens = true, IssuerSigningKey = new SymmetricSecurityKey(new byte[0]), }; options.Events = new JwtBearerEvents() { OnTokenValidated = context => { if (context.Principal.Identity is ClaimsIdentity identity) { foreach (var claim in identity.Claims) { Console.WriteLine($"Claim: {claim.Type} - {claim.Value}"); } } return Task.CompletedTask; } }; } );
这段代码注册了JWT认证服务,并在认证成功后将认证内容在控制台全部打出来。
在设置加密密钥的key这里,我犹豫了五秒钟。这里的key要的是一个byte[]数组,但是给我的是个字符串,这咋弄。
想了想,先试试直接取对应的字符串编码试试。
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("O9CEs5KknWV0Uz1MJ7FIdxwaT4"))
跑起来!
Failed to validate the token.
Microsoft.IdentityModel.Tokens.SecurityTokenSignatureKeyNotFoundException: IDX10503: Signature validation failed. Token does not have a kid. Keys tried: '[PII of type 'System.Text.StringBuilder' is hidden. For more details, see https://aka.ms/IdentityModel/PII.]'. Number of keys in TokenValidationParameters: '1'.
额……提示签名验证失败,且详细信息隐藏了。但是……这个“Token does not have a kid”是个啥错误啊……
想了想,先去开了日志输出看看到底是啥错误。
IdentityModelEventSource.ShowPII = true; IdentityModelEventSource.LogCompleteSecurityArtifact = true;
重新运行。
'System.ArgumentOutOfRangeException: IDX10720: Unable to create KeyedHashAlgorithm for algorithm 'HS512', the key size must be greater than: '512' bits, key has '208' bits. (Parameter 'keyBytes')
哦……原来这个token的签名算法是HS512,要求有512比特的密钥,但这里很明显没有那么长。
那咋办?要不先大胆假设、小心求证?我先填0把这个填到512比特。
new SymmetricSecurityKey("O9CEs5KknWV0Uz1MJ7FIdxwaT4"u8.ToArray().Concat(Enumerable.Repeat((byte)0, 64)).Take(64).ToArray())
注意:上述写法只是为了简单,但效率并不高,留意留意。
写完继续跑起来!
嗯……还是验签失败,只是没有那个key长度不对的异常了。
3. 所以不是字符串编码?
其实到这里的时候,我有点蛋疼。因为之前写过《如何用C#正确解密Node.js中 crypto.createCipher() 加密的数据?》,留下点心里阴影,觉得没探究过Java这内部怎么搞的,万一也搞个什么哈希算法那就算把我杀了祭天也很难猜出来到底怎么搞的啊。
于是左手掏出来IDEA,右手拿起鼠标嘎嘎一顿乱点。
啊?这是Base64字符串?就密钥……不是很明显啊??
IssuerSigningKey = new SymmetricSecurityKey(Convert.FromBase64String("O9CEs5KknWV0Uz1MJ7FIdxwaT4"))
跑!
我就说啊……这字符串看着也不太像是Base64字符串啊?
这时想起了以前经历过类似的场景,不过对面的语言是世界上最伟大的语言PHP而不是Java,当时遇到的场景是,保存的Base64字符串最后填充“=”号被丢了,导致直接解解不出来,需要手动补上才行。
想起这档子事儿后,便也顺手就把“=”号给补上了。
IssuerSigningKey = new SymmetricSecurityKey(Convert.FromBase64String("O9CEs5KknWV0Uz1MJ7FIdxwaT4=="))
跑!
这下是不出错了,但是验签依然不通过,错误么……密钥长度不对。
'System.ArgumentOutOfRangeException: IDX10720: Unable to create KeyedHashAlgorithm for algorithm 'HS512', the key size must be greater than: '512' bits, key has '152' bits. (Parameter 'keyBytes')
小问题,继续填0!
IssuerSigningKey = new SymmetricSecurityKey(Convert.FromBase64String("O9CEs5KknWV0Uz1MJ7FIdxwaT4==").Concat(Enumerable.Repeat((byte)0, 64)).Take(64).ToArray())
跑!
Bearer was not authenticated. Failure message: IDX10503: Signature validation failed. Token does not have a kid.
这涛声依旧啊。
事已至此,我现在有点怀疑到底是key不对,还是这代码不太对。因为错误信息里有个“Token does not have a kid”的错误,这个信息直译是说Token没有Kid字段,莫非是这个原因导致的?
搜索了一些相关资料并在咨询了AI之后,得到了一些大概信息,就是这个Kid字段确实是Header里可能存在的字段,指定所使用的KEY,但是这是个可选字段。也就是说,没有Kid字段,是不应该导致整个流程走不通的。
在咨询AI的过程中,还发生了一些意外,这像极了考试,不会答的题就多写点,老师总会多给点辛苦分的,AI像极了人类,叹为观止。
回过头来继续看错误信息。
此时错误信息中包含类似以下这段内容。
Keys tried: 'Microsoft.IdentityModel.Tokens.SymmetricSecurityKey, KeyId: ", InternalId: '4UslXyu9G4P3BT2iPo6_mRIf7en8BUzVO46NhQxMoEw'. , KeyId:
'. Number of keys in TokenValidationParameters: '1'.
Number of keys in Configuration: '0'.
从这段信息看,似乎KEY是尝试了的,只是最终没有能验签,因此又去尝试通过Kid字段查找对应的key,但是没有找到Kid字段,导致最终验证失败。
也就是说,找不到Kid字段应该只是个烟雾弹,实际错误还是验签失败。
那这么说的话……这Java那边签名到底用的是啥Key啊?
4. 还是Java那边跟踪看看吧
于是觉得这样瞎尝试不太科学,便还是让程序的原始作者那边跟踪一下Java的程序,看看签名到底用的是啥Key。
然后作者那边调试了一下,跟我说,“59,-48,-124,-77,-110,-92,-99,101,116,83,61,76,39,-79,72,119,28,26”。
我说确定吗,这看起来也不到64个字节啊?
他说,确定,能跟踪到HmacCore
里了,就这个,没变化过。
我康康啊……
59,208,132,179,146,164,157,101,116,83,61,76,39,177,72,119,28,26,79
Java里的byte是有符号的,C#的是无符号的,所以负数要加256后对比。看了几个字节,没感觉有啥差别。
那这他喵的咋回事,难道Java里的算法和C#里的不一样?这不能够啊?
于是我把这个问题丢到群里面去,七嘴八舌的有人说填充肯定需要的,有的说可能是二次哈希了之类的。
没个答案,我就去搞别的东西了。
5. 啊???
又一天下午,我准备捡起Rust的书来看看Rust,忽然就想起了这个问题,就有一点恶心,觉得这个问题不解决有点难受。
于是群里重新提了此事。也有同学说,你确定这个key是对的吗,和我算出来的签名不对。
当然也有人觉得是token不对的。
我只能说,这私钥key和token都是对的。
话说这里,那到底是啥问题呢。
我盯着代码看。忽然发现……咦不对啊,怎么C#转出来的字节最后一个是79,而Java的是26?这正负号带不来这个差异啊?
仔细逐字节对比数据后,发现C#的序列多了一个字节……瞬间就明白了……这他喵的,Java在这种Base64字符串不足长的情况下不是填充,是直接丢弃啊?
IssuerSigningKey = new SymmetricSecurityKey(Convert.FromBase64String("O9CEs5KknWV0Uz1MJ7FIdxwa").Concat(Enumerable.Repeat((byte)0, 64)).Take(64).ToArray())
注意看啊,最后的T4
被我删掉了,原因是Base64标准长度是3的整数倍,原字符串是26个字节,因此上一个完整的应该是24个字节。
然后……
……这就好了?我这不知道该说啥。
你说要是配置数据不对,你直接报错啊,你这悄咪咪修改了我的设置然后忽略一部分数据,这让我很难办啊。
反正就……挺烂的设计吧。