PAYATHON 2026

Azure App Service 使用客户端证书 POST 时出现 Security Error

支付小周

结论

问题不在 POST 内容或 HttpClient,而是双向 TLS(mTLS)握手失败。

从日志看,客户端证书和私钥已经成功加载,证书消息也已发送给 Swish。服务器随后立即返回 TLS fatal alert 40(handshake_failure)。这通常表示 Swish 无法接受 Azure 实例发送的客户端证书链,常见原因包括:

  • Azure App Service 中只有终端客户端证书,缺少对应的中间 CA 或自签名根 CA;
  • App Service 的证书存储无法正确构建证书链;
  • 上传的 PFX 没有包含完整链,或者链中的证书被放入了错误的存储区;
  • 使用了错误环境的证书,例如用测试证书请求生产端点,或用生产证书请求测试端点;
  • Swish 不认可该证书,或者证书用途不正确、已经过期、尚未生效。

ServerCertificateCustomValidationCallback 只控制应用如何验证 Swish 的服务器证书。它不能让 Swish 信任客户端证书,也无法修复客户端证书链。

为什么本地可以、Azure App Service 不可以

本地计算机很可能已经安装了 Swish 测试 CA 的根证书和中间证书,所以 Windows Schannel 能够构建完整的客户端证书链。

Azure App Service 是隔离且受限制的托管环境。把客户端 PFX 上传到 App Service,通常只能保证终端证书和私钥可以加载到 CurrentUser\My。这并不表示根 CA 和中间 CA 已经进入正确的信任存储。

因此,握手过程可能是:

  1. certCollection[0].HasPrivateKey 为 true;
  2. TLS 客户端发送证书;
  3. Swish 无法验证收到的证书链;
  4. Swish 返回 TLS alert 40;
  5. Windows 将错误报告为 IllegalMessage、Security Error 或 Could not create SSL/TLS secure channel。

跟踪日志中的这些内容说明证书已经发出,错误发生在远端处理证书之后:

Certificate is of type X509Certificate2 and contains the private key.
...
BeginSend()
...
15 03 03 00 02
02 28
...
returned code=IllegalMessage

其中,02 28 对应 fatal handshake_failure。因此,仅仅能从 CurrentUser\My 找到证书,并不能证明证书配置正确。

正确处理证书和证书链

1. 核对证书是否属于正确的 Swish 环境

逐项确认以下信息:

  • Swish 测试客户端证书只能用于测试端点;
  • Swish 生产客户端证书只能用于生产端点;
  • 证书主题、组织号或 payee alias 与 Swish 注册信息一致;
  • 证书当前有效;
  • 证书包含私钥;
  • Key Usage 和 Extended Key Usage 允许客户端身份验证,通常应包含 Client Authentication;
  • PFX 密码正确,App Service 进程能够访问私钥。

问题日志中的证书有效期是 2015 至 2017。如果这就是当前使用的证书,它已经过期,必须更换。如果日志只是历史记录,则应检查当前证书的实际有效期。

2. 准备完整的证书链

证书链中有三类证书:

  • 终端客户端证书,必须带私钥;
  • 中间 CA 证书,只包含公钥;
  • 根 CA 证书,通常是只包含公钥的自签名证书。

PFX 至少应包含终端证书、私钥和所需的中间证书。不要认为上传一个只包含终端证书的 PFX 后,Azure 会自动下载或安装缺少的 CA 证书。

可以使用 OpenSSL 检查 PFX 的内容:

openssl pkcs12 -in swish-client.pfx -info -nodes

可以使用类似下面的命令,重新生成包含证书链的 PFX:

openssl pkcs12 -export \
  -out swish-client-fullchain.pfx \
  -inkey client.key \
  -in client.crt \
  -certfile ca-chain.crt

ca-chain.crt 应按照实际颁发关系包含中间 CA。是否还要把根 CA 打入 PFX,需要根据 Swish 提供的证书包和部署平台的行为判断。TLS 通常不要求客户端把根证书发送给服务器,但本地环境仍需要相关证书来构建证书链。

3. 在 App Service 中加载证书

在 Windows App Service 中上传 PFX 后,设置以下应用配置:

WEBSITE_LOAD_CERTIFICATES=客户端证书指纹

也可以设置为:

WEBSITE_LOAD_CERTIFICATES=*

生产环境更适合明确列出需要加载的证书指纹。修改配置后,应重启应用。

随后验证应用实际加载的证书:

using System;
using System.Linq;
using System.Security.Cryptography.X509Certificates;

static X509Certificate2 FindClientCertificate(string thumbprint)
{
    string normalized = thumbprint
        .Replace(" ", string.Empty)
        .ToUpperInvariant();

    using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser))
    {
        store.Open(OpenFlags.ReadOnly);

        var certificate = store.Certificates
            .Cast<X509Certificate2>()
            .FirstOrDefault(c =>
                string.Equals(
                    c.Thumbprint?.Replace(" ", string.Empty),
                    normalized,
                    StringComparison.OrdinalIgnoreCase));

        if (certificate == null)
            throw new InvalidOperationException("Client certificate was not found.");

        if (!certificate.HasPrivateKey)
            throw new InvalidOperationException("Client certificate has no private key.");

        return certificate;
    }
}

如果没有找到证书,不要继续发送请求。原代码在 certCollection.Count == 0 时仍会调用 PostAsync,这会掩盖真正的配置错误。

4. 检查 Azure 环境能否构建证书链

发送请求前,可以执行证书链检查,并记录每个链元素及其错误:

static void ValidateChain(X509Certificate2 certificate)
{
    using (var chain = new X509Chain())
    {
        chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
        chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;

        bool valid = chain.Build(certificate);

        foreach (X509ChainElement element in chain.ChainElements)
        {
            Console.WriteLine(
                $"{element.Certificate.Subject} | {element.Certificate.Thumbprint}");

            foreach (X509ChainStatus status in element.ChainElementStatus)
            {
                Console.WriteLine(
                    $"{status.Status}: {status.StatusInformation}");
            }
        }

        if (!valid)
            throw new InvalidOperationException(
                "The client certificate chain cannot be built.");
    }
}

如果出现以下状态,应先处理证书链,而不是继续调整 HTTP 代码:

PartialChain
UntrustedRoot
NotTimeValid
NotValidForUsage
RevocationStatusUnknown

需要注意的是,把根证书添加到 X509ChainPolicy.ExtraStore,只能帮助这一次手动链验证。它不会自动改变 Windows Schannel 在 mTLS 握手期间选择和发送证书链的方式。

5. 无法安装私有根 CA 时更换托管方式

部分 App Service 配置或历史运行环境不允许应用把私有根证书安装到所需的受信任证书存储。代码能够打开 CurrentUser\My,并不代表应用有权修改系统信任根。

如果当前 App Service 环境无法正确加载 Swish 的私有 CA 链,可以考虑:

  • 使用 App Service 当前支持的公共证书上传和加载机制,把 Swish 提供的 CA 证书加载到正确的存储区;
  • 使用包含完整中间链的 PFX;
  • 申请或重新生成符合 Swish 要求的客户端证书;
  • 把服务部署到能够管理系统信任存储的 Windows VM、容器或其他受控环境。

可用方案取决于 App Service 的操作系统、套餐和当前平台能力。不能假定所有版本的 App Service 都允许应用修改根证书存储。

请求代码示例

证书链配置正确后,请求代码可以保持简单:

var certificate = FindClientCertificate(
    "800CDE7BC219D53812FC164DFF0FE73DF8A8D7B6");

ValidateChain(certificate);

var handler = new HttpClientHandler
{
    ClientCertificateOptions = ClientCertificateOption.Manual,
    AutomaticDecompression =
        DecompressionMethods.Deflate | DecompressionMethods.GZip
};

handler.ClientCertificates.Add(certificate);

using (var client = new HttpClient(handler))
using (var content = new StringContent(
    JsonConvert.SerializeObject(post),
    Encoding.UTF8,
    "application/json"))
{
    HttpResponseMessage response = await client.PostAsync(
        "https://mss.swicpc.bankgirot.se/swish-cpcapi/api/v1/paymentrequests/",
        content);

    string responseBody = await response.Content.ReadAsStringAsync();

    if (response.StatusCode != HttpStatusCode.Created)
    {
        throw new HttpRequestException(
            $"Swish returned {(int)response.StatusCode} " +
            $"{response.ReasonPhrase}: {responseBody}");
    }
}

如果运行时支持,可以只启用 TLS 1.2,也可以让操作系统选择安全协议:

handler.SslProtocols = SslProtocols.Tls12;

除非目标系统存在明确且无法避免的兼容要求,否则不要显式启用已经淘汰的 TLS 1.0 和 TLS 1.1。

不要用回调绕过验证

下面的代码不能解决这个问题:

handler.ServerCertificateCustomValidationCallback =
    (message, certificate, chain, errors) => true;

原因有两个:

  1. 它只会绕过客户端对服务器证书的验证,无法影响 Swish 对客户端证书的验证;
  2. 它会让应用接受伪造或被中间人替换的服务器证书,在生产环境中存在严重的安全风险。

应删除该回调,让系统正常验证 Swish 的服务器证书。如果测试环境使用私有服务器 CA,应正确安装该 CA。另一种做法是在回调中严格校验证书链、主机名和固定指纹,但不能无条件返回 true。

需要修复的是证书身份和证书链。应确保使用正确、有效且带私钥的 Swish 客户端证书,提供完整的中间链,并让 Azure 运行环境能够构建这条链。调整 PostAsync、重试请求或关闭服务器证书验证,都无法解决 TLS alert 40。

备注:内容仅供参考。