PAYATHON 2026

支付网关报错:Could not create SSL/TLS secure channel

支付阿杰

结论

错误出现在 GetRequestStream(),不代表请求正文写入失败。HTTPS 连接会在写入正文前进行 TLS 握手,真正失败的是这个阶段。

这段代码最值得怀疑的是客户端证书的加载方式。代码加载了 tbcCert.pem,但支付网关很可能要求双向 TLS(mTLS)。普通 .pem 文件往往只有客户端证书,没有匹配的私钥,而客户端没有私钥就无法完成身份认证。

另一个问题是代码同时启用了 Ssl3、TLS 1.0 和 TLS 1.1。这些协议已经过时,可能引发协议协商失败或触发系统安全策略。

应先确认以下事项:

  1. 客户端证书是否包含私钥。
  2. 证书是否由支付网关签发或认可,以及是否仍在有效期内。
  3. 网关是否要求 TLS 1.2、特定密码套件或来源 IP 白名单。
  4. 运行 ASP.NET 的应用池身份是否有权读取私钥。
  5. 操作系统和 .NET Framework 是否支持网关要求的 TLS 配置。

为什么错误出现在 GetRequestStream()

HttpWebRequest 通常会延迟建立连接。调用 HttpWebRequest.Create() 时,它不会立即连接服务器。DNS 查询、TCP 连接和 TLS 握手一般会在以下任一调用中发生:

request.GetRequestStream();
request.GetResponse();

所以,异常出现在 GetRequestStream() 并不反常。此时请求正文还没有发送,问题仍在 TLS 层,通常与 postData 中的具体参数无关。

首先检查客户端证书

当前代码使用:

X509Certificate certificate =
    new X509Certificate(myPath, "----", X509KeyStorageFlags.Exportable);

这里有两个问题:

  • 应改用 X509Certificate2,这样才能检查私钥、证书用途和证书链。
  • 如果 tbcCert.pem 中只有 -----BEGIN CERTIFICATE-----,它通常不包含私钥。

支付网关要求 mTLS 时,客户端必须持有:

  • 客户端证书;
  • 与证书公钥匹配的私钥;
  • 必要的中间证书链。

部署时,最简单可靠的做法通常是使用同时包含证书和私钥的 .pfx 或 .p12 文件。

string certificatePath = Server.MapPath("~/App_Data/tbcCert.pfx");

var certificate = new X509Certificate2(
    certificatePath,
    "pfx-password",
    X509KeyStorageFlags.MachineKeySet |
    X509KeyStorageFlags.PersistKeySet
);

if (!certificate.HasPrivateKey)
{
    throw new InvalidOperationException("客户端证书不包含私钥,无法完成双向 TLS 认证。");
}

如果支付机构分别提供了 PEM 证书和私钥,应按照其部署说明导入。不要将私钥文件放在 Web 可以直接访问的目录中。

较新的 .NET 可以组合 PEM 证书和私钥:

X509Certificate2 certificate =
    X509Certificate2.CreateFromPemFile(
        @"C:\certs\client-cert.pem",
        @"C:\certs\client-key.pem"
    );

不过,目标 .NET 版本决定了 CreateFromPemFile 是否可用。对于传统的 ASP.NET/.NET Framework 项目,通常更适合先生成或获取 .pfx 文件。

只启用网关支持的 TLS 版本

不要显式启用 Ssl3。除非网关明确要求,否则也不应启用 TLS 1.0 和 TLS 1.1。

如果支付网关要求 TLS 1.2,可以这样设置:

ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;

不要使用:

SecurityProtocolType.Tls |
SecurityProtocolType.Tls11 |
SecurityProtocolType.Tls12 |
SecurityProtocolType.Ssl3

同时启用多个旧协议并不能保证兼容。实际可用的 TLS 版本和密码套件还会受到 Windows Schannel、系统安全策略以及 .NET Framework 版本的限制。

较新的运行环境支持系统默认 TLS 配置时,通常可以让操作系统选择协议,但具体配置仍取决于项目使用的 .NET Framework 版本。旧版 .NET Framework 可能需要显式指定 Tls12,同时还要确认系统注册表和补丁状态支持 TLS 1.2。

建议的请求代码

下面的示例假设网关要求 TLS 1.2,并使用 PFX 客户端证书:

public ActionResult WebRequest()
{
    ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;
    ServicePointManager.Expect100Continue = false;

    string certificatePath =
        Server.MapPath("~/App_Data/tbcCert.pfx");

    var certificate = new X509Certificate2(
        certificatePath,
        "pfx-password",
        X509KeyStorageFlags.MachineKeySet |
        X509KeyStorageFlags.PersistKeySet
    );

    if (!certificate.HasPrivateKey)
    {
        throw new InvalidOperationException(
            "客户端证书不包含私钥。"
        );
    }

    var request = (HttpWebRequest)WebRequest.Create(
        "https://ecommerce.ufc.ge:18443/ecomm2/MerchantHandler"
    );

    request.Method = "POST";
    request.ContentType = "application/x-www-form-urlencoded";
    request.ClientCertificates.Add(certificate);

    string postData =
        "command=v" +
        "&amount=" + Uri.EscapeDataString("实际金额") +
        "&currency=" + Uri.EscapeDataString("实际币种") +
        "&client_ip_addr=" + Uri.EscapeDataString("实际客户端IP") +
        "&description=" + Uri.EscapeDataString("实际描述") +
        "&msg_type=SMS";

    byte[] body = Encoding.UTF8.GetBytes(postData);
    request.ContentLength = body.Length;

    using (Stream requestStream = request.GetRequestStream())
    {
        requestStream.Write(body, 0, body.Length);
    }

    using (var response = (HttpWebResponse)request.GetResponse())
    using (Stream responseStream = response.GetResponseStream())
    using (var reader = new StreamReader(responseStream))
    {
        string responseBody = reader.ReadToEnd();

        ViewBag.Status = response.StatusDescription;
        ViewBag.Response = responseBody;
    }

    return View();
}

PreAuthenticate 用于 HTTP 身份验证,无法解决 TLS 客户端证书问题,因此这里不需要设置:

request.PreAuthenticate = true;

检查证书本身

可以先通过 X509Certificate2 输出以下非敏感信息:

var certificate = new X509Certificate2(
    certificatePath,
    "pfx-password",
    X509KeyStorageFlags.MachineKeySet |
    X509KeyStorageFlags.PersistKeySet
);

Debug.WriteLine(certificate.Subject);
Debug.WriteLine(certificate.Issuer);
Debug.WriteLine(certificate.NotBefore);
Debug.WriteLine(certificate.NotAfter);
Debug.WriteLine(certificate.Thumbprint);
Debug.WriteLine(certificate.HasPrivateKey);

检查时注意:

  • HasPrivateKey 必须为 true;
  • 当前时间必须处于 NotBefore 和 NotAfter 之间;
  • 证书主题、序列号或指纹应与支付机构登记的信息一致;
  • 证书用途应允许客户端身份验证;
  • 私钥必须与证书匹配;
  • 中间 CA 证书应正确安装;
  • 测试环境和生产环境不能混用证书。

应用服务器的系统时间也需要检查。时间偏差过大时,证书可能会被判断为尚未生效或已经过期。

检查私钥权限

如果代码在开发机上可以运行,部署到 IIS 后却失败,常见原因是应用池身份没有读取私钥的权限。

使用证书存储部署时,应将证书和私钥导入:

Local Computer\Personal

然后向对应的 IIS 应用池身份授予私钥读取权限,例如:

IIS AppPool\YourApplicationPool

如果直接从 PFX 文件加载,应用池还需要:

  • PFX 文件的读取权限;
  • 在密钥存储位置创建或读取私钥材料的权限。

在 ASP.NET/IIS 环境中,MachineKeySet 通常比默认的用户密钥存储更稳定。是否还需要 PersistKeySet,应根据具体部署方式决定。

检查服务端证书和证书链

客户端还要信任支付网关返回的服务端证书。需要核对:

  • URL 主机名是否与服务端证书匹配;
  • 服务器是否信任对应的根 CA 和中间 CA;
  • 证书链中是否缺少中间证书;
  • HTTPS 流量是否被代理、防火墙或安全软件拦截,并由它们重新签发了证书。

不要用下面的代码规避问题:

ServicePointManager.ServerCertificateValidationCallback =
    (sender, cert, chain, errors) => true;

这段代码会关闭服务端证书验证,使支付请求可能遭受中间人攻击。即便只用于临时诊断,也不应将它放入生产代码或提交到代码库。

从服务器环境直接测试

测试应在实际运行应用的服务器上进行。开发机和服务器的 TLS 配置、证书存储、防火墙规则及出口 IP 都可能不同。

先检查端口是否可达:

Test-NetConnection ecommerce.ufc.ge -Port 18443

如果服务器安装了 OpenSSL,可以查看服务端握手过程和证书链:

openssl s_client \
  -connect ecommerce.ufc.ge:18443 \
  -servername ecommerce.ufc.ge \
  -tls1_2 \
  -showcerts

也可以带上客户端证书和私钥测试 mTLS:

openssl s_client \
  -connect ecommerce.ufc.ge:18443 \
  -servername ecommerce.ufc.ge \
  -tls1_2 \
  -cert client-cert.pem \
  -key client-key.pem

这些测试只能反映 TLS 层的情况。网关是否接受具体业务请求,还要看请求参数和网关协议。

获取更具体的失败原因

Could not create SSL/TLS secure channel 只是一个概括性异常。排查时还应查看:

  • Windows 事件查看器中的 Schannel 日志;
  • IIS 应用池身份和证书私钥权限;
  • WebException.InnerException;
  • 支付机构服务端的握手日志;
  • 网关登记的证书指纹和出口公网 IP。

捕获异常时,可以记录状态和内部异常,但不要记录证书密码、私钥或完整支付数据:

catch (WebException ex)
{
    string details =
        $"Status: {ex.Status}; " +
        $"Message: {ex.Message}; " +
        $"Inner: {ex.InnerException?.Message}";

    // 写入受保护的应用日志
    throw;
}

相比 .NET 抛出的顶层异常,Schannel 日志通常更接近实际原因,例如协议版本不匹配、没有共同密码套件、证书不受信任,或者客户端证书被拒绝。

其他代码问题

下面这些问题一般不会直接导致 TLS 握手失败,但也需要修正:

  • currentIp 获取的是服务器本机网卡地址,不一定是用户 IP,通常也不是服务器的出口公网 IP。
  • 如果遍历到多个 IPv4 地址,代码会保留最后一个地址,结果并不可靠。
  • 示例中的 amount、currency 和 client_ip_addr 都是空值。TLS 修复后,网关仍可能拒绝请求。
  • 表单值必须进行 URL 编码,不能直接拼接任意描述文本。
  • Server.MapPath("//tbcCert.pem") 的路径写法可疑,应改用 ~/App_Data/... 或明确的受保护路径。
  • Expect100Continue 和客户端证书握手不是同一个问题。如果网关不支持 100-continue,可以将它关闭。
  • 非标准端口 18443 必须允许出站访问,支付机构也可能要求将出口公网 IP 加入白名单。

推荐排查顺序

  1. 将证书类型改为 X509Certificate2,确认 HasPrivateKey == true。
  2. 核对证书有效期、指纹、客户端认证用途,以及测试和生产环境是否匹配。
  3. 只启用支付机构明确要求的协议,通常可以先测试 TLS 1.2。
  4. 在实际应用服务器上测试 18443 端口和 TLS 握手。
  5. 检查 IIS 应用池是否有权读取私钥。
  6. 查看 Windows Schannel 日志。
  7. 向支付机构确认客户端证书的登记状态、密码套件要求和出口 IP 白名单。
  8. TLS 握手成功后,再检查金额、币种、签名和返回报文等业务参数。

现有代码最先需要修正的是客户端证书的加载方式。请确认加载的是包含私钥的客户端证书,而不是只有公钥证书内容的 .pem 文件。

备注:内容仅供参考。