如何在 C# 中计算与支付服务商示例一致的 HMAC-SHA256
结论
两种实现失败的原因不同:
- Method 1 把 HMAC 的“密钥”和“消息”传反了。
HMACSHA256构造函数接收密钥,ComputeHash接收消息。 - Method 2 把内层
SHA256的结果转换成 64 个十六进制字符后,再参与外层哈希。正确做法是直接拼接内层 SHA-256 返回的 32 个原始字节。 hexDecode应直接返回byte[],不要先转换成string,再通过Encoding.Default转回字节。Encoding.Default取决于运行环境。消息编码应明确指定,例如Encoding.UTF8。本例中的消息全部由 ASCII 字符组成,所以 UTF-8 和 ASCII 的结果相同。
全部改为按字节处理后,两种方法都会得到:
b436e3e86cb3800b3864aeecc8d06c126f005e7645803461717a8e4b2de3a905
Method 1 的参数顺序问题
原方法定义如下:
private static string CalcHMACSHA256Hash(string plaintext, string salt)
调用方式是:
CalcHMACSHA256Hash(HexDecode(key), mm)
方法内部执行的却是:
new HMACSHA256(baSalt);
hasher.ComputeHash(baText2BeHashed);
因此,代码实际计算的是:
HMAC-SHA256(key = message, data = decodedKey)
服务商要求的则是:
HMAC-SHA256(key = decodedKey, data = message)
参数名 plaintext 和 salt 也容易让人误解。这里的 HMAC 并不使用所谓的 salt,将参数命名为 key 和 message 更准确。
Method 1 的正确实现
以下写法适用于 .NET 5 及更高版本:
using System;
using System.Security.Cryptography;
using System.Text;
internal static class Program
{
private static void Main()
{
const string keyHex =
"57617b5d2349434b34734345635073433835777e2d244c31715535255a36677375" +
"5a4d70532a5879793238235f707c4f7865753f3f446e633a21575643303f66";
const string message = "amount=100¤cy=EUR";
byte[] key = Convert.FromHexString(keyHex);
byte[] messageBytes = Encoding.UTF8.GetBytes(message);
using var hmac = new HMACSHA256(key);
byte[] mac = hmac.ComputeHash(messageBytes);
string result = Convert.ToHexString(mac).ToLowerInvariant();
Console.WriteLine(result);
}
}
输出为:
b436e3e86cb3800b3864aeecc8d06c126f005e7645803461717a8e4b2de3a905
整个数据流如下:
keyHex --hex decode--> 64 字节密钥
message --UTF-8-----> 消息字节
HMACSHA256(key).ComputeHash(messageBytes)
Method 2 的中间结果问题
HMAC-SHA256 可以展开为:
SHA256(Ko || SHA256(Ki || message))
其中,|| 表示字节拼接。
内层计算:
SHA256(Ki || message)
返回的是 32 个原始摘要字节。原代码中的 CalcSha256Hash 却先把摘要转换成了十六进制字符串:
CalcSha256Hash(HexDecode(ki) + mm)
这样一来,32 字节摘要就变成了 64 个字符,例如:
"a1b2..."
外层实际处理的内容也就变成了:
Ko || UTF8("a1b2...")
正确内容应该是:
Ko || { 0xA1, 0xB2, ... }
这两种数据的字节内容和长度都不同,计算结果当然不会一致。
Method 2 的正确实现
using System;
using System.Security.Cryptography;
using System.Text;
internal static class Program
{
private static void Main()
{
const string kiHex =
"61574d6b157f757d02457573556645750e0341481b127a07476303136c00514543" +
"6c7b46651c6e4f4f040e1569464a794e534309097258550c17616075060950";
const string koHex =
"0b3d27017f151f17682f1f193f0c2f1f64692b227178106d2d096979066a3b2f" +
"2906112c0f760425256e647f032c2013243929636318323f667d0b0a1f6c633a";
const string message = "amount=100¤cy=EUR";
byte[] ki = Convert.FromHexString(kiHex);
byte[] ko = Convert.FromHexString(koHex);
byte[] messageBytes = Encoding.UTF8.GetBytes(message);
// innerInput = Ki || message
byte[] innerInput = new byte[ki.Length + messageBytes.Length];
Buffer.BlockCopy(ki, 0, innerInput, 0, ki.Length);
Buffer.BlockCopy(
messageBytes, 0,
innerInput, ki.Length,
messageBytes.Length);
byte[] innerHash = SHA256.HashData(innerInput);
// outerInput = Ko || raw innerHash
byte[] outerInput = new byte[ko.Length + innerHash.Length];
Buffer.BlockCopy(ko, 0, outerInput, 0, ko.Length);
Buffer.BlockCopy(
innerHash, 0,
outerInput, ko.Length,
innerHash.Length);
byte[] mac = SHA256.HashData(outerInput);
string result = Convert.ToHexString(mac).ToLowerInvariant();
Console.WriteLine(result);
}
}
输出同样是:
b436e3e86cb3800b3864aeecc8d06c126f005e7645803461717a8e4b2de3a905
兼容旧版 .NET 的十六进制转换
如果目标框架没有 Convert.FromHexString 和 Convert.ToHexString,可以改用下面的辅助方法:
using System;
using System.Globalization;
using System.Text;
private static byte[] HexToBytes(string hex)
{
if (hex == null)
throw new ArgumentNullException(nameof(hex));
if (hex.Length % 2 != 0)
throw new FormatException("Hex 字符串长度必须是偶数。");
byte[] bytes = new byte[hex.Length / 2];
for (int i = 0; i < bytes.Length; i++)
{
bytes[i] = byte.Parse(
hex.Substring(i * 2, 2),
NumberStyles.HexNumber,
CultureInfo.InvariantCulture);
}
return bytes;
}
private static string BytesToHex(byte[] bytes)
{
var result = new StringBuilder(bytes.Length * 2);
foreach (byte value in bytes)
result.Append(value.ToString("x2"));
return result.ToString();
}
在旧版框架中,Method 1 可以这样写:
byte[] key = HexToBytes(keyHex);
byte[] messageBytes = Encoding.UTF8.GetBytes(message);
using (var hmac = new HMACSHA256(key))
{
byte[] mac = hmac.ComputeHash(messageBytes);
string result = BytesToHex(mac);
Console.WriteLine(result);
}
注意事项
Hex 字符串不是普通文本
下面两种写法的含义完全不同:
Encoding.UTF8.GetBytes("5761")
这会得到字符 5、7、6、1 对应的 4 个字节:
35 37 36 31
而下面的写法:
Convert.FromHexString("5761")
得到的是 2 个字节:
57 61
服务商说明密钥采用 hex 格式,因此必须使用第二种方式。
不要使用 Encoding.Default
Encoding.Default 的具体编码可能随操作系统和运行时环境而变化。应按照协议明确指定编码:
Encoding.UTF8.GetBytes(message)
如果服务商明确要求 ASCII,也可以写成:
Encoding.ASCII.GetBytes(message)
本例消息只包含 ASCII 范围内的字符,因此 UTF-8 和 ASCII 会生成相同的字节。如果消息包含中文、货币符号或其他非 ASCII 字符,则必须严格使用服务商规定的字符编码。
只在最终阶段转为十六进制
哈希计算过程中应始终使用 byte[]:
输入字节 → SHA-256 → 原始摘要字节 → 下一层 SHA-256
只有在最终展示或传输 authentication code 时,才把结果转换成十六进制:
string result = Convert.ToHexString(mac).ToLowerInvariant();
实际支付请求还要保证消息完全一致
HMAC 对每一个字节都很敏感。即使代码已经修正,以下差异仍会产生完全不同的结果:
- 参数顺序不同;
- 大小写不同;
- URL 编码方式不同;
- 多出空格或换行;
- 使用
&代替&; - 金额格式不同,例如
100与100.00; - 字符编码不同;
- 对消息重复进行 URL 编码。
实际接入时,应对最终发送的原始消息字节计算 HMAC,不要使用一个看起来相同、实际序列化方式却不同的字符串。
备注:内容仅供参考。
