PAYATHON 2026

如何在 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&currency=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&currency=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 编码方式不同;
  • 多出空格或换行;
  • 使用 &amp; 代替 &;
  • 金额格式不同,例如 100 与 100.00;
  • 字符编码不同;
  • 对消息重复进行 URL 编码。

实际接入时,应对最终发送的原始消息字节计算 HMAC,不要使用一个看起来相同、实际序列化方式却不同的字符串。

备注:内容仅供参考。