Click Pay 文档Click Pay 文档
  • en-US
  • 简体中文
  • en-US
  • 简体中文
  • C# 签名与验签示例

C# 签名与验签示例

运行环境

.NET 8+,使用 System.Security.Cryptography 和 HttpClient。

统一规则

这些示例用于默认商户 API 和默认平台回调,不适用于另行约定的定制签名格式。中英文页面引用同一份可执行代码。

有请求体的 API 请求,使用商户私钥签名:

path + lowercaseHex(SHA512(rawBody)) + Timestamp

无请求体的 API 请求,使用商户私钥签名:

path + Timestamp

默认异步回调,使用平台公钥验签:

lowercaseHex(SHA512(rawBody)) + Timestamp

API 同步响应封装,使用平台公钥验签:

lowercaseHex(SHA512(平台序列化的 data 字节)) + timestamp
  • 算法统一为 RSA PKCS#1 v1.5 + SHA-512,签名使用标准 Base64,不是 RSA-PSS,也不是对原文做 RSA 加密。
  • Timestamp 使用秒级 Unix 时间戳;验签使用收到的时间戳,不要重新生成。Header 名称不区分大小写,但 Base64 签名值区分大小写。
  • 请求 path 是接口路径,不包含协议、域名或查询字符串。回调原文不拼路径、URL 或分隔符。
  • JSON 只序列化一次:签名和发送必须使用同一份 UTF-8 字节。字段顺序不要求预先排序,但空格、转义、换行、数字表示和顺序必须与发送内容完全一致。
  • 无请求体使用 null / nil / None;它与“存在但长度为零的请求体”不同。回调始终对收到的完整 body 求摘要。
  • 私钥支持未加密 PKCS#1、PKCS#8;公钥支持 X.509 SPKI、PKCS#1;支持 PEM、纯 Base64 和转义换行。证书、加密私钥、SSH 格式及非 RSA 密钥不在本示例支持范围内。
  • 商户请求用商户私钥;验签必须使用后台配置的 平台公钥。不能使用商户自己的公钥,也不能信任请求中携带的公钥。不要发送私钥给技术支持;泄露后应更换。

失败处理

签名失败抛出异常或返回 error,调用方必须停止发送,不能用空签名继续请求。验签成功正常返回,失败记录 [payment-verify] FAILED 并抛出异常或返回 error;不得忽略失败后继续入账。

日志包含阶段、字节长度和错误原因,不记录原始请求体、完整签名或密钥。生产环境不要打开 HTTP 正文日志;不要把内部诊断错误原样返回给调用方,并对失败请求和日志做限流。

HTTPS 必须校验证书与主机名。网络超时不能证明订单创建失败,不能盲目自动重新下单;应按商户订单号查询或人工核查。

回调接入

先限制请求体大小,读取原始字节,读取 Timestamp、Signature,验签成功后再解析 JSON。检查订单归属、金额、币种和允许的状态变化,通过事务做持久化幂等处理。只有完成可靠处理后才按协议返回 SUCCESS。重复通知不得重复产生资金变动。

验签函数只负责密码学校验,不等于防重放。时间窗口应结合平台重试规则设置;重放检测、持久化幂等、回调请求限流仍由接入方实现。不要因固定短时间窗口而拒绝正常重试。

同步响应不能当作回调

API 同步响应从 JSON 的 timestamp、sign 字段取值,验签对象是 data 的平台序列化字节,不是整个响应体;回调则从 Header 取值,验签对象是完整 HTTP body。

不要把 data 解析后重新序列化再验签:大整数、1.00、转义或字段顺序可能改变。使用能保留 JSON 原始值文本的解析器提取 data(例如 Go json.RawMessage、C# JsonElement.GetRawText()),并把这些 UTF-8 字节传给示例的 verifyPlatform / VerifyPlatform / verify_platform。data 是字符串时保留 JSON 引号与转义,是 null 时保留字面量 null;不要自行添加换行或空格。缺少签名、字段异常或验签失败必须拒绝作为可信业务结果。

接口路径

同一请求函数可用于以下路径,参数以相应币种文档为准:

方法路径用途
POST/v1/merchant/pay代收下单
POST/v1/merchant/pay/order/info代收查单
POST/v1/merchant/payout代付下单
POST/v1/merchant/payout/order/info代付查单
GET/v1/merchant/account余额查询,无请求体

签名与验签完整代码

SignUtil.cs

using System;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;

public static class SignUtil
{
    private static string Timestamp(string text)
    {
        if (text == null || !Regex.IsMatch(text, @"\A[1-9][0-9]{0,18}\z") || !long.TryParse(text, out _))
            throw new ArgumentException("invalid Timestamp: use Unix seconds");
        return text;
    }

    private static byte[] Base64(string text, string label)
    {
        try
        {
            var data = Convert.FromBase64String(text);
            if (data.Length == 0 || Convert.ToBase64String(data) != text) throw new FormatException();
            return data;
        }
        catch (Exception e) when (e is FormatException || e is ArgumentNullException)
        { throw new ArgumentException(label + ": invalid Base64"); }
    }

    private static RSA Key(string text, bool privateKey)
    {
        if (text == null || text.Length > 16384) throw new ArgumentException("key: missing or too large");
        text = text.Replace(@"\r\n", "\n").Replace(@"\n", "\n").Trim();
        if (text.StartsWith("-----BEGIN"))
        {
            var m = Regex.Match(text, @"\A-----BEGIN ([A-Z ]+)-----\s*([A-Za-z0-9+/=\s]+)\s*-----END \1-----\z");
            string label = m.Groups[1].Value;
            bool allowed = privateKey ? label == "PRIVATE KEY" || label == "RSA PRIVATE KEY"
                : label == "PUBLIC KEY" || label == "RSA PUBLIC KEY";
            if (!m.Success || !allowed) throw new ArgumentException("key: expected one unencrypted RSA PEM block of the correct kind");
            text = m.Groups[2].Value;
        }
        byte[] der = Base64(Regex.Replace(text, @"\s", ""), "key");
        for (int format = 0; format < 2; format++)
        {
            var rsa = RSA.Create();
            try
            {
                int read;
                if (privateKey && format == 0) rsa.ImportPkcs8PrivateKey(der, out read);
                else if (privateKey) rsa.ImportRSAPrivateKey(der, out read);
                else if (format == 0) rsa.ImportSubjectPublicKeyInfo(der, out read);
                else rsa.ImportRSAPublicKey(der, out read);
                if (read != der.Length) throw new CryptographicException();
                return rsa;
            }
            catch (CryptographicException) { rsa.Dispose(); }
        }
        throw new ArgumentException("key: invalid/truncated, encrypted or non-RSA key");
    }

    private static string Hash(byte[] body)
    {
        if (body == null) throw new ArgumentException("body: expected raw bytes");
        return Convert.ToHexString(SHA512.HashData(body)).ToLowerInvariant();
    }

    private static void Log(string operation, byte[] body, Exception error)
    {
        // Only locally constructed errors are emitted; never log payloads or key material.
        string reason = error is ArgumentException ? error.Message : "RSA operation failed (" + error.GetType().Name + ")";
        Console.Error.WriteLine($"[payment-{operation}] FAILED body_bytes={body?.Length ?? 0} error={reason}");
    }

    // null means no request body; Array.Empty<byte>() means a present, empty body.
    public static string SignRequest(string path, byte[] body, string timestamp, string merchantPrivateKey)
    {
        try
        {
            if (path == null || !path.StartsWith("/")) throw new ArgumentException("path: expected API path");
            string origin = path + (body == null ? "" : Hash(body)) + Timestamp(timestamp);
            using var rsa = Key(merchantPrivateKey, true);
            return Convert.ToBase64String(rsa.SignData(Encoding.UTF8.GetBytes(origin), HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1));
        }
        catch (Exception e) { Log("sign", body, e); throw; }
    }

    // Exact callback bytes, or exact signed data bytes for API envelopes. NO path.
    public static void VerifyPlatform(byte[] body, string timestamp, string signature, string platformPublicKey)
    {
        try
        {
            string origin = Hash(body) + Timestamp(timestamp);
            using var rsa = Key(platformPublicKey, false);
            byte[] sig = Base64(signature, "Signature");
            int expected = (rsa.KeySize + 7) / 8;
            if (sig.Length != expected) throw new ArgumentException($"Signature length: got {sig.Length}, expected {expected} bytes");
            if (!rsa.VerifyData(Encoding.UTF8.GetBytes(origin), sig, HashAlgorithmName.SHA512, RSASignaturePadding.Pkcs1))
                throw new ArgumentException("RSA SHA512 verification failed: check platform key, raw bytes, timestamp and formula");
        }
        catch (Exception e) { Log("verify", body, e); throw; }
    }
}

HTTP 请求工具

HttpUtil.cs

using System;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

public static class HttpUtil
{
    private static readonly HttpClient Client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
        { Timeout = TimeSpan.FromSeconds(60) };

    public static async Task<byte[]> Send(string baseUrl, string path, string merchantCode, byte[] rawBody,
        string merchantPrivateKey, CancellationToken cancellation = default)
    {
        if (!baseUrl.StartsWith("https://")) throw new ArgumentException("HTTPS is required");
        string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture);
        string signature = SignUtil.SignRequest(path, rawBody, timestamp, merchantPrivateKey);
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
        timeout.CancelAfter(TimeSpan.FromSeconds(60));
        using var request = new HttpRequestMessage(rawBody == null ? HttpMethod.Get : HttpMethod.Post, baseUrl + path);
        request.Headers.Add("Merchant-Code", merchantCode);
        request.Headers.Add("Timestamp", timestamp);
        request.Headers.Add("Signature", signature);
        if (rawBody != null)
        {
            request.Content = new ByteArrayContent(rawBody);
            request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
        }
        using var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token);
        if (!response.IsSuccessStatusCode) throw new HttpRequestException($"HTTP {(int)response.StatusCode}; reconcile before retrying");
        using var stream = await response.Content.ReadAsStreamAsync(timeout.Token);
        using var result = new MemoryStream();
        byte[] buffer = new byte[65536];
        int read;
        while ((read = await stream.ReadAsync(buffer.AsMemory(), timeout.Token)) != 0)
        {
            if (result.Length + read > 8 * 1024 * 1024) throw new IOException("Response exceeds 8 MiB");
            result.Write(buffer, 0, read);
        }
        return result.ToArray(); // Preserve raw response for envelope verification.
    }
}

接入示例

以下片段放在接入方应用中使用,payload、可信配置和事务业务处理函数由接入方提供;示例不会自动发起真实下单。

// .NET 8+, imports System.Text.Json.
byte[] body = JsonSerializer.SerializeToUtf8Bytes(payload);
byte[] responseBytes = await HttpUtil.Send(baseUrl, "/v1/merchant/pay", merchantCode, body, merchantPrivateKey);
// GET: await HttpUtil.Send(baseUrl, "/v1/merchant/account", merchantCode, null, merchantPrivateKey);
// Verify the signed response data before using it. Do not treat HTTP 200 as payment success.
// ASP.NET Core minimal API; configure Kestrel/ingress request body limits too.
app.MapPost("/callback", async (HttpRequest request, CancellationToken cancellation) => {
    using var buffer = new MemoryStream();
    byte[] chunk = new byte[65536];
    int read;
    while ((read = await request.Body.ReadAsync(chunk.AsMemory(), cancellation)) != 0) {
        if (buffer.Length + read > (1 << 20)) return Results.StatusCode(413);
        buffer.Write(chunk, 0, read);
    }
    byte[] rawBody = buffer.ToArray();
    try {
        SignUtil.VerifyPlatform(rawBody, request.Headers["Timestamp"].ToString(),
            request.Headers["Signature"].ToString(), configuredPlatformPublicKey);
    } catch (Exception) { return Results.Unauthorized(); }
    await ValidateAndApplyIdempotently(rawBody, cancellation); // Your durable business transaction.
    return Results.Text("SUCCESS");
});
最近更新: 2026/9/22 14:41
Contributors: aa, aaa