PAYATHON 2026

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

排障赵师傅

结论

能够从 CurrentUser\My 读取证书,且 HasPrivateKey == true,说明应用已经找到证书和私钥,但无法据此判断 TLS 双向认证是否成功。

日志中的关键字节是:

15 03 03 00 02 02 28

其中,02 28 表示对端返回了 fatal handshake_failure(TLS Alert 40)。根据调用顺序,Azure 实例向 Swish 发送客户端证书后,Swish 终止了握手。问题出在 TLS 握手阶段,此时 HTTP POST 请求尚未发送。

常见原因是 Azure 与本地环境中的证书链不同。App Service 加载了叶证书和私钥,却缺少中间 CA,或者 Swish 不信任该证书的根 CA。如果证书用途、测试或生产环境、私钥提供程序、证书有效期等不符合 Swish 的要求,也会触发相同的 handshake_failure。

ServerCertificateCustomValidationCallback 负责客户端对 Swish 服务器证书的验证,无法让 Swish 接受客户端证书。

为什么本地可以,Azure 不可以

本地计算机的证书存储通常已经安装了完整证书链,包括:

  • 带私钥的客户端叶证书;
  • 一个或多个中间 CA 证书;
  • 根 CA 证书。

将 PFX 上传到 App Service 后,平台通常只保证指定证书可以从 CurrentUser\My 加载。本地计算机上的中间 CA 和根 CA 不一定也存在于 Azure 实例中。

日志显示客户端发送了 TLS Certificate 消息,但无法证明其中包含完整证书链。Swish 可能因为以下原因拒绝握手:

  1. 客户端没有发送必要的中间 CA 证书。
  2. Swish 不信任该客户端证书的签发根。
  3. 使用测试证书访问了生产地址,或使用生产证书访问了测试地址。
  4. 证书已过期、尚未生效或已被吊销。
  5. 证书缺少 Client Authentication EKU,或密钥用途不允许数字签名。
  6. Azure 进程可以看到私钥,但 Schannel 无法使用该私钥完成握手签名。
  7. Swish 绑定了来源 IP、商户号、payeeAlias 或证书,而 Azure 的出站信息尚未登记。

TLS Alert 40 是通用错误。仅凭客户端日志无法判断 Swish 拒绝了哪一项,需要结合 Swish 服务端的握手日志确认具体原因。

Azure App Service 的证书配置

先在 App Service 中上传包含私钥的 PFX,再添加以下应用设置:

WEBSITE_LOAD_CERTIFICATES=800CDE7BC219D53812FC164DFF0FE73DF8A8D7B6

排查时也可以临时使用:

WEBSITE_LOAD_CERTIFICATES=*

修改设置后重启 App Service。排查结束后,生产环境应恢复为明确的指纹列表。

代码应从 StoreLocation.CurrentUser 下的 StoreName.My 查找证书,并在发送请求前检查证书状态:

private static X509Certificate2 LoadClientCertificate(string thumbprint)
{
    string normalizedThumbprint = thumbprint
        .Replace(" ", string.Empty)
        .ToUpperInvariant();

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

        var certificates = store.Certificates.Find(
            X509FindType.FindByThumbprint,
            normalizedThumbprint,
            validOnly: false);

        if (certificates.Count == 0)
        {
            throw new InvalidOperationException(
                $"Client certificate {normalizedThumbprint} was not found.");
        }

        var certificate = certificates[0];

        if (!certificate.HasPrivateKey)
        {
            throw new InvalidOperationException(
                "The client certificate does not contain an accessible private key.");
        }

        var now = DateTime.UtcNow;
        if (now < certificate.NotBefore.ToUniversalTime() ||
            now > certificate.NotAfter.ToUniversalTime())
        {
            throw new InvalidOperationException(
                $"The client certificate is not valid at {now:O}.");
        }

        return certificate;
    }
}

检查证书链

在 Azure 实例上显式构建证书链,可以判断是否缺少中间证书:

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

        bool valid = chain.Build(certificate);

        var status = string.Join(
            Environment.NewLine,
            chain.ChainStatus.Select(x =>
                $"{x.Status}: {x.StatusInformation?.Trim()}"));

        return $"Chain valid: {valid}{Environment.NewLine}{status}";
    }
}

如果结果中出现 PartialChain、UntrustedRoot 等状态,应检查 PFX 是否包含中间证书,以及相关 CA 证书是否通过当前 App Service 环境支持的方式加载到正确的证书存储中。

中间 CA 通常应放入:

CurrentUser\CertificateAuthority

根 CA 对应:

CurrentUser\Root

不要直接将根证书添加到 handler.ClientCertificates。TLS 客户端通常发送叶证书和必要的中间证书,不会发送根证书。根证书必须已经得到 Swish 服务端信任。在客户端安装根证书只会影响客户端自身的证书链验证,无法改变 Swish 的信任配置。

如果当前 App Service 环境无法按需配置 CA 存储,可以考虑改用支持自定义证书存储的 Windows 容器、虚拟机或其他托管环境。

推荐的请求代码

确认链和证书用途正确后,只启用 Swish 支持的 TLS 版本。此类接口应优先使用 TLS 1.2,无需同时启用旧版 Tls 和 Tls11:

var certificate = LoadClientCertificate(
    "800CDE7BC219D53812FC164DFF0FE73DF8A8D7B6");

var handler = new HttpClientHandler
{
    ClientCertificateOptions = ClientCertificateOption.Manual,
    SslProtocols = SslProtocols.Tls12,
    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}");
    }
}

实际项目应复用 HttpClient,避免为每个请求创建并销毁实例。可以在应用启动时加载证书并创建客户端,也可以通过 IHttpClientFactory 管理客户端生命周期。

不要绕过服务器证书验证

以下代码会接受任何服务器证书:

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

这段代码完全关闭了服务器证书验证,会在生产环境中留下中间人攻击风险,也无法解决 Swish 拒绝客户端证书的问题。

如果 Swish 测试环境使用私有 CA,应将相应 CA 正确配置到信任存储中,或者在回调中严格校验证书指纹、名称、有效期和证书链。回调不能直接返回 true。

建议的排查顺序

  1. 确认 WEBSITE_LOAD_CERTIFICATES 已设置,并在重启后生效。
  2. 输出证书的 Thumbprint、Subject、Issuer、NotBefore、NotAfter 和 HasPrivateKey,不要输出私钥或 PFX 密码。
  3. 在 Azure 实例上执行 X509Chain.Build,检查是否缺少中间 CA。
  4. 检查证书是否包含 Client Authentication EKU,其 OID 为 1.3.6.1.5.5.7.3.2。
  5. 确认证书、API 地址、商户号和 payeeAlias 属于同一个 Swish 环境。
  6. 确认 Swish 是否要求登记 Azure App Service 的出站 IP。App Service 可能有多个出站 IP,应按对方规则全部登记。
  7. 将协议限定为 SslProtocols.Tls12,避免旧协议参与协商。
  8. 如果仍收到 fatal Alert 40,请 Swish 根据请求时间、客户端证书序列号或指纹查询服务端 TLS 日志。

原代码还存在一个问题:它在 finally 中释放了 handler,之后又访问 handler.ClientCertificates[0]。这可能引发额外异常。应在释放前保存所需信息,避免将后续错误与 TLS 错误混在一起。

备注:内容仅供参考。