C#使用EC密钥X509证书验证Payconiq回调JWS签名失败问题
问题描述
验证Payconiq支付平台回调接口返回的JWS规范签名时,现有C#代码始终返回false,临时方案为校验请求头后主动调用接口查询支付状态,需要正确的签名验证实现。
该JWS签名以.分隔为三部分:JOSE头、空载荷、JWS签名,签名生成规则为:jws = base64URLEncode(JOSE Header)..base64URLEncode(alg(base64URLEncode(JOSE Header).base64URLEncode(Request Body)))
可用验证数据
- Payconiq官方X509证书Base64内容:
MIIE1zCCBH2gAwIBAgIQHzgeQOjemgrfp6IwTS5XfzAKBggqhkjOPQQDAjCBjzELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQDEy5TZWN0aWdvIEVDQyBEb21haW4gVmFsaWRhdGlvbiBTZWN1cmUgU2VydmVyIENBMB4XDTIxMTEyMzAwMDAwMFoXDTIyMTIyNDIzNTk1OVowKDEmMCQGA1UEAxMdZXMuc2lnbmF0dXJlLmV4dC5wYXljb25pcS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARIpLe02lsuMs6G1lQQRw3Zo4GlBwxi1h7EDD6GC9MxYRkkxOQMrJ1UKD3ni4dXcCZjHyv2GGvWhNICOaCso9Elo4IDHzCCAxswHwYDVR0jBBgwFoAU9oUKOxGG4QR9DqoLLNLuzGR7e64wHQYDVR0OBBYEFHUsvJY0jGLPbsoGZeOmkk09+ADEMA4GA1UdDwEB/wQEAwIHgDAMBgNVHRMBAf8EAjAAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBJBgNVHSAEQjBAMDQGCysGAQQBsjEBAgIHMCUwIwYIKwYBBQUHAgEWF2h0dHBzOi8vc2VjdGlnby5jb20vQ1BTMAgGBmeBDAECATCBhAYIKwYBBQUHAQEEeDB2ME8GCCsGAQUFBzAChkNodHRwOi8vY3J0LnNlY3RpZ28uY29tL1NlY3RpZ29FQ0NEb21haW5WYWxpZGF0aW9uU2VjdXJlU2VydmVyQ0EuY3J0MCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTBLBgNVHREERDBCgh1lcy5zaWduYXR1cmUuZXh0LnBheWNvbmlxLmNvbYIhd3d3LmVzLnNpZ25hdHVyZS5leHQucGF5Y29uaXEuY29tMIIBewYKKwYBBAHWeQIEAgSCAWsEggFnAWUAdQBGpVXrdfqRIDC1oolp9PN9ESxBdL79SbiFq/L8cP5tRwAAAX1MZuRtAAAEAwBGMEQCIErmMHlQjPe/aNTo08NiFGS2hlKeBU5Ubrl9OG7myLWcAiB4bWXL8HOl2oNVci3Cv0RMnNTyMHIrAm8Lw9QQq/UxTQB1AEHIyrHfIkZKEMahOglCh15OMYsbA+vrS8do8JBilgb2AAABfUxm5DUAAAQDAEYwRAIgNEbgqCHIAjLqhRGBmiHRAqNwX5qI1GSlfAbqVq4V/W0CIHRCmucjmXpbVKzPsOfJ6RBPHWSUJJSjiGLf1QTtvliDAHUAKXm+8J45OSHwVnOfY6V35b5XfZxgCvj5TV0mXCVdx4QAAAF9TGbj/QAABAMARjBEAiAlPQGU1X34G+wtrYEpGFodWifIfxfeOwKx9o3qjVr4LAIgUQenz7z8a0zIC5XATCAwEG3uXnbATrl+ss5cu6YqvPowCgYIKoZIzj0EAwIDSAAwRQIhAN5vKyEhzWAj6Wc6bhr8l9YXIGn4e4dNVSYeHcRoK0AkAiAhhXJkG+SzWyp/bFJeCfXbnWw59mww9GOOkoNizKCG6w==
- 回调POST请求JSON体:
{ "PaymentId": "8016ab30f89882a72c6827e6", "TransferAmount": 100, "TippingAmount": 0, "TotalAmount": 100, "Currency": "EUR", "Amount": 100, "Description": "betaling Webshop Patisserie Stefan", "Reference": "5902", "CreatedAt": "2022-06-28T09: 50: 58.298Z", "ExpireAt": "2022-06-28T10: 10: 58.298Z", "Status": "SUCCEEDED", "Debtor": { "Name": "Nathan", "Iban": "***51944" } }
- 回调请求头携带的JWS签名值:
eyJ0eXAiOiJKT1NFK0pTT04iLCJraWQiOiJlcy5zaWduYXR1cmUuZXh0LjIwMjIiLCJhbGciOiJFUzI1NiIsImh0dHBzOi8vcGF5Y29uaXEuY29tL2lhdCI6IjIwMjItMDYtMjhUMDk6NTE6MTQuNzE0MjU0WiIsImh0dHBzOi8vcGF5Y29uaXEuY29tL2p0aSI6ImU0OWIzNmNhM2EzM2I4ODIiLCJodHRwczovL3BheWNvbmlxLmNvbS9wYXRoIjoiaHR0cHM6Ly90ZXN0LnBhdGlzc2VyaWVzdGVmYW4ubmV0L2FwaS93ZWJzaG9wY29udHJvbGxlci9DYWxsYmFja1BheWNvbmlxIiwiaHR0cHM6Ly9wYXljb25pcS5jb20vaXNzIjoiUGF5Y29uaXEiLCJodHRwczovL3BheWNvbmlxLmNvbS9zdWIiOiI2MjVlN2ZmMDFlMjRiNzA0NDI5MWNkYzUiLCJjcml0IjpbImh0dHBzOi8vcGF5Y29uaXEuY29tL2lhdCIsImh0dHBzOi8vcGF5Y29uaXEuY29tL2p0aSIsImh0dHBzOi8vcGF5Y29uaXEuY29tL3BhdGgiLCJodHRwczovL3BheWNvbmlxLmNvbS9pc3MiLCJodHRwczovL3BheWNvbmlxLmNvbS9zdWIiXX0..SIG71tYh8l0rRn7n7Bg3e1goWIloBlSwdkkXhXjIHZlelhNgKM4GJcFbimk-sIpdNl8XEOtKHVx_Tf93P3V-GA
现有问题代码
var verified = false; byte[] dataToBeVerifiedByteArray = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(body)); // 读取上述证书内容 byte[] cerBytes = Convert.FromBase64String(jwk.X5cValues.First().Waarde); X509Certificate2 cer = new(cerBytes); ECDsa ECDKey = cer.GetECDsaPublicKey(); ECParameters ECDsaPublicParam = ECDKey.ExportParameters(false); using (var ecdsa = ECDsa.Create()) { ecdsa.ImportParameters(ECDsaPublicParam); verified = ecdsa.VerifyData(dataToBeVerifiedByteArray, Encoding.UTF8.GetBytes(signature), HashAlgorithmName.SHA256); }; return verified;
错误原因
现有代码存在4个核心问题,直接导致验签失败:
- 待验签原文构造错误:Detached模式JWS的待签名内容不是单独的请求体,是
Base64URL编码后的JOSE头 + "." + Base64URL编码后的原始请求体字节拼接后的字符串,现有代码只取了序列化后的请求体,完全不符合签名规则。 - 请求体处理错误:代码把请求体反序列化后再重新序列化,会因为JSON字段顺序、空格、换行、特殊字符转义规则差异(比如时间字段里冒号后的多余空格会被序列化逻辑清除),导致参与计算的原文和Payconiq服务端计算时的原文不一致。必须直接读取HTTP请求的原始请求体字节,不能做任何序列化/反序列化转换。
- 签名解码错误:JWS第三部分的签名是Base64URL编码的,不能直接转UTF8字节,需要先做Base64URL解码得到原始二进制签名值,还要处理Base64URL和标准Base64的字符差异、补全填充符。
- 签名格式不匹配:ES256算法的JWS签名采用IEEE P1363格式(32字节R值 + 32字节S值固定拼接),而C#的
ECDsa.VerifyData方法默认要求ANS.1 DER编码格式的签名,格式不匹配必然验签失败。
修正后可运行代码
/// <summary> /// 验证Payconiq回调JWS签名 /// </summary> /// <param name="rawRequestBody">HTTP请求读取的原始请求体字符串,不要做任何JSON转换</param> /// <param name="jwsHeaderValue">请求头里拿到的完整JWS字符串</param> /// <param name="certBase64">Payconiq官方X509证书Base64内容</param> /// <returns>验签是否通过</returns> public bool VerifyPayconiqSignature(string rawRequestBody, string jwsHeaderValue, string certBase64) { // 拆分JWS的三部分 var jwsSegments = jwsHeaderValue.Split('.'); if (jwsSegments.Length != 3) throw new ArgumentException("JWS格式不合法,必须包含三个点分隔段"); string encodedHeader = jwsSegments[0]; string encodedSignature = jwsSegments[2]; // 1. 构造符合规则的待验签内容 byte[] requestBodyRawBytes = Encoding.UTF8.GetBytes(rawRequestBody); string encodedPayload = Base64UrlEncode(requestBodyRawBytes); string contentToSign = $"{encodedHeader}.{encodedPayload}"; byte[] contentToSignBytes = Encoding.UTF8.GetBytes(contentToSign); // 2. 解码Base64URL格式的签名 byte[] signatureRawBytes = Base64UrlDecode(encodedSignature); // 3. 加载证书中的ECDSA公钥 byte[] certBytes = Convert.FromBase64String(certBase64); using var certificate = new X509Certificate2(certBytes); using var ecdsaPublicKey = certificate.GetECDsaPublicKey(); if (ecdsaPublicKey == null) throw new InvalidOperationException("当前证书不包含ECDSA公钥"); // 4. 验签,明确指定签名格式为JWS使用的IEEE P1363固定R/S拼接格式,哈希算法为SHA256 return ecdsaPublicKey.VerifyData( contentToSignBytes, signatureRawBytes, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation ); } // Base64URL编码辅助方法 private string Base64UrlEncode(byte[] input) { return Convert.ToBase64String(input) .TrimEnd('=') .Replace('+', '-') .Replace('/', '_'); } // Base64URL解码辅助方法 private byte[] Base64UrlDecode(string input) { string standardBase64 = input.Replace('-', '+').Replace('_', '/'); // 补全填充符 int paddingLength = (4 - standardBase64.Length % 4) % 4; standardBase64 = standardBase64.PadRight(standardBase64.Length + paddingLength, '='); return Convert.FromBase64String(standardBase64); }
注意事项
- 必须在HTTP请求处理管道中开启请求体缓冲,直接读取原始请求体流的内容,不要使用模型绑定后的对象做序列化
- 注意匹配证书和JWS头中
kid字段对应的环境(测试/生产环境证书不通用) - 不要对原始请求体做任何修改,包括去除空格、调整编码、转义字符等操作
内容的提问来源于stack exchange,提问作者Nathan T.

