C#中使用证书进行SSL客户端身份验证

8
我需要创建一个c#应用程序,必须使用SSL向服务器发送API请求。我需要创建客户端认证。我已经拥有服务器CA证书、客户端证书(cer)、客户端私钥(pem)和密码。我找不到关于如何创建客户端连接的示例。有人能够建议我从哪里开始编写一个简单而清晰的代码吗?我手头上有客户端证书(PEM)、客户端私钥和客户端密钥的口令。我不知道从哪里开始编写代码,以便向服务器发送请求。

1
你应该使用TLS 1.2/1.3来进行SSL。连接时不需要特别处理。当URL包含HTTPS时,HTTP请求发出前会自动执行TLS身份验证。因此,你可以使用任何HTTP客户端。在某些情况下,如果你的操作系统没有自动尝试1.2/1.3,则必须明确添加一条指令以指定TLS版本。 - jdweng
1
请看这里:https://dev59.com/auk6XIcBkEYKwwoYC_n6。 - DerSchnitz
请参考以下链接:https://dev59.com/f1kS5IYBdhLWcg3wVlXC#48243930 - Arne Klein
嗨Lorenzo,关于客户端私钥,您是否正在尝试对要通过HTTPS发送到服务器的数据进行签名?CA证书是否为自签名,并且它是否用于创建X509 CA证书以及客户端证书? - ivnext
@ivnext 是的,CA证书是自签名的,并用于创建其他证书。数据已签名。 - Lorenzo
2个回答

5

一段时间以前,我使用 .Net Core 创建了 这个 POC 来进行客户端证书认证。它使用了现在已经 内置于 .Net Coreidunno.Authentication 包。我的 POC 可能有些过时,但可以成为你的好起点。

首先创建一个扩展方法将证书添加到 HttpClientHandler

public static class HttpClientHandlerExtensions
{
    public static HttpClientHandler AddClientCertificate(this HttpClientHandler handler, X509Certificate2 certificate)
    {
        handler.ClientCertificateOptions = ClientCertificateOption.Manual;
        handler.ClientCertificates.Add(certificate);

        return handler;
    }
}

接下来是另一种将证书添加到IHttpClientBuilder的扩展方法。

    public static IHttpClientBuilder AddClientCertificate(this IHttpClientBuilder httpClientBuilder, X509Certificate2 certificate)
    {
        httpClientBuilder.ConfigureHttpMessageHandlerBuilder(builder =>
        {
            if (builder.PrimaryHandler is HttpClientHandler handler)
            {
                handler.AddClientCertificate(certificate);
            }
            else
            {
                throw new InvalidOperationException($"Only {typeof(HttpClientHandler).FullName} handler type is supported. Actual type: {builder.PrimaryHandler.GetType().FullName}");
            }
        });

        return httpClientBuilder;
    }

然后加载证书并在HttpClientFactory中注册HttpClient。
        var cert = CertificateFinder.FindBySubject("your-subject");
        services
            .AddHttpClient("ClientWithCertificate", client => { client.BaseAddress = new Uri(ServerUrl); })
            .AddClientCertificate(cert);

现在,当您使用工厂创建的客户端时,它将自动发送您的证书与请求;
public async Task SendRequest()
{
    var client = _httpClientFactory.CreateClient("ClientWithCertificate");
    ....
}

我很快会检查! - Lorenzo
官方文档中的(以及接下来的)部分展示了如何在请求中发送证书,所有之前的部分都描述了如何配置服务器端以要求和验证它。 - Artur
我会在几天后检查一下,如果可以的话,我会感谢你。 - Lorenzo

3

这里有很多选择,所以我不确定根据问题的简洁性应该选择哪一种。我创建了一个基本的aspnet.core WebApi项目,它有一个“天气预报”控制器作为测试。这里没有展示很多错误检查,并且对于密钥和证书的存储方式以及用于什么操作系统(尽管操作系统并不那么重要,但是密钥库是不同的)存在许多假设。

另外请注意,使用OpenSsl创建的证书不包含Web服务器的私钥。您需要将证书和私钥合并到Pkcs12 / PFX格式中。

例如(适用于Web服务器,而不一定是客户端,但您确实可以在任何地方使用PFX...)。

openssl pkcs12 -export -out so-selfsigned-ca-root-x509.pfx -inkey so-root-ca-rsa-private-key.pem -in so-selfsigned-ca-root-x509.pem

考虑以下控制台应用程序中的主方法。我只添加了 Portable.BouncyCastle 这个非BCL软件包(用于私有PEM密钥)。如果您使用的是 .NET Core 5.0(刚刚发布几天),则会有 PEM 选项。假设您还没有升级,该示例将使用 NetCoreApp 3.1。
The appSettings.json example file:
{
  "HttpClientRsaArtifacts": {
    "ClientCertificateFilename": "so-x509-client-cert.pem",
    "ClientPrivateKeyFilename": "so-client-private-key.pem"
  }
}


private static async Task Main(string[] args)
{
    IConfiguration config = new ConfigurationBuilder().AddJsonFile("appSettings.json").Build();

    const string mainAppSettingsKey = "HttpClientRsaArtifacts";
    var clientCertificateFileName = config[$"{mainAppSettingsKey}:ClientCertificateFilename"];
    var clientPrivKeyFileName = config[$"{mainAppSettingsKey}:ClientPrivateKeyFilename"];

    var clientCertificate = new X509Certificate2(clientCertificateFileName);
    var httpClientHandler = new HttpClientHandler();
    httpClientHandler.ClientCertificates.Add(clientCertificate);
    httpClientHandler.ClientCertificateOptions = ClientCertificateOption.Manual;
    httpClientHandler.ServerCertificateCustomValidationCallback = ByPassCertErrorsForTestPurposesDoNotDoThisInTheWild;
    httpClientHandler.CheckCertificateRevocationList = false;

    var httpClient = new HttpClient(httpClientHandler);
    httpClient.BaseAddress = new Uri("https://localhost:5001/");

    var httpRequestMessage = new HttpRequestMessage(
        HttpMethod.Get,
        "weatherforecast");

    // This is "the connection" (and API call)
    using var response = await httpClient.SendAsync(
        httpRequestMessage,
        HttpCompletionOption.ResponseHeadersRead);

    var stream = await response.Content.ReadAsStreamAsync();
    var jsonDocument = await JsonDocument.ParseAsync(stream);

    var options = new JsonSerializerOptions
    {
        WriteIndented = true,
    };

    Console.WriteLine(
        JsonSerializer.Serialize(
            jsonDocument,
            options));
}


private static bool ByPassCertErrorsForTestPurposesDoNotDoThisInTheWild(
    HttpRequestMessage httpRequestMsg,
    X509Certificate2 certificate,
    X509Chain x509Chain,
    SslPolicyErrors policyErrors)
{
    var certificateIsTestCert = certificate.Subject.Equals("O=Internet Widgits Pty Ltd, S=Silicon Valley, C=US");

    return certificateIsTestCert && x509Chain.ChainElements.Count == 1 &&
           x509Chain.ChainStatus[0].Status == X509ChainStatusFlags.UntrustedRoot;
}

如果您想从PEM文件中加载私钥,可以使用Bouncy Castle轻松完成。例如,要从PEM文件导入私钥,然后使用它创建RSA实例以签署数据或哈希,请按以下方式获取RSA实例:
private static RSA LoadClientPrivateKeyFromPemFile(string clientPrivateKeyFileName)
{
    if (!File.Exists(clientPrivateKeyFileName))
    {
        throw new FileNotFoundException(
            "The client private key PEM file could not be found",
            clientPrivateKeyFileName);
    }

    var clientPrivateKeyPemText = File.ReadAllText(clientPrivateKeyFileName);
    using var reader = new StringReader(clientPrivateKeyPemText);

    var pemReader = new PemReader(reader);
    var keyParam = pemReader.ReadObject();

    // GET THE PRIVATE KEY PARAMETERS
    RsaPrivateCrtKeyParameters privateKeyParams = null;

    // This is the case if the PEM file has is a "traditional" RSA PKCS#1 content
    // The private key file with begin and end with -----BEGIN RSA PRIVATE KEY----- and -----END RSA PRIVATE KEY-----
    if (keyParam is AsymmetricCipherKeyPair asymmetricCipherKeyPair)
    {
        privateKeyParams = (RsaPrivateCrtKeyParameters)asymmetricCipherKeyPair.Private;
    }

    // This is to check if it is a Pkcs#8 PRIVATE KEY ONLY or a public key (-----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY-----)
    if (keyParam is AsymmetricKeyParameter asymmetricKeyParameter)
    {
        privateKeyParams = (RsaPrivateCrtKeyParameters)asymmetricKeyParameter;
    }

    var rsaPrivateKeyParameters = DotNetUtilities.ToRSAParameters(privateKeyParams);

    // CREATE A NEW RSA INSTANCE WITH THE PRIVATE KEY PARAMETERS (THIS IS THE PRIVATE KEY)
    return RSA.Create(rsaPrivateKeyParameters);
}

最后,如果您想使用私钥(从 PEM 文件中使用上面的示例获得)对数据进行签名,可以使用 System.Security.Cryptography.RSA 类的标准加密和签名方法。例如:

var signedData = rsaInstanceWithPrivateKey.SignData(
    data,
    HashAlgorithmName.SHA256,
    RSASignaturePadding.Pkcs1);

...并将其作为ByteArrayContent添加到HttpRequestMessage中,然后再通过调用SendAsync与HttpRequestMessage一起使用。

var byteArrayContent = new ByteArrayContent(signedData);

var httpRequestMessage = new HttpRequestMessage(
    HttpMethod.Post,
    "/myapiuri");

httpRequestMessage.Content = byteArrayContent;

您提到您使用相同的私钥创建所有内容,因此如果在Web服务器端是这种情况,您将能够验证签名并解密您在此示例中从客户端发送的内容。

再次强调,这里有很多选项和细微差别。

使用Bouncy Castle PEM阅读器,您可以注入一个包含密码的IPasswordFinder实现。

例如:

/// <summary>
/// Required when using the Bouncy Castle PEM reader for PEM artifacts with passwords.
/// </summary>
class BcPemPasswordFinder : IPasswordFinder
{
    private readonly string m_password;

    public BcPemPasswordFinder(string password)
    {
        m_password = password;
    }

    /// <summary>
    /// Required by the IPasswordFinder interface
    /// </summary>
    /// <returns>System.Char[].</returns>
    public char[] GetPassword()
    {
        return m_password.ToCharArray();
    }
}

这是我最初发布的LoadClientPrivateKeyFromPemFile的修改版本(为了简洁起见,密码已硬编码在此示例中),您可以将IPasswordFinder注入到实例中。
private static RSA LoadClientPrivateKeyFromPemFile(string clientPrivateKeyFileName)
{
    if (!File.Exists(clientPrivateKeyFileName))
    {
        throw new FileNotFoundException(
            "The client private key PEM file could not be found",
            clientPrivateKeyFileName);
    }

    var clientPrivateKeyPemText = File.ReadAllText(clientPrivateKeyFileName);
    using var reader = new StringReader(clientPrivateKeyPemText);

    // Instantiate password finder here
    var passwordFinder = new BcPemPasswordFinder("P@ssword");

    // Pass the IPasswordFinder instance into the PEM PemReader...
    var pemReader = new PemReader(reader, passwordFinder);
    var keyParam = pemReader.ReadObject();

    // GET THE PRIVATE KEY PARAMETERS
    RsaPrivateCrtKeyParameters privateKeyParams = null;

    // This is the case if the PEM file has is a "traditional" RSA PKCS#1 content
    // The private key file with begin and end with -----BEGIN RSA PRIVATE KEY----- and -----END RSA PRIVATE KEY-----
    if (keyParam is AsymmetricCipherKeyPair asymmetricCipherKeyPair)
    {
        privateKeyParams = (RsaPrivateCrtKeyParameters)asymmetricCipherKeyPair.Private;
    }

    // This is to check if it is a Pkcs#8 PRIVATE KEY ONLY or a public key (-----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY-----)
    if (keyParam is AsymmetricKeyParameter asymmetricKeyParameter)
    {
        privateKeyParams = (RsaPrivateCrtKeyParameters)asymmetricKeyParameter;
    }

    var rsaPrivateKeyParameters = DotNetUtilities.ToRSAParameters(privateKeyParams);

    // CREATE A NEW RSA INSTANCE WITH THE PRIVATE KEY PARAMETERS (THIS IS THE PRIVATE KEY)
    return RSA.Create(rsaPrivateKeyParameters);
}

目前我正在使用Curl测试API,它可以正常工作,我正在使用以下curl命令:curl -k --key LMIS_JPN_CLIENT_CERT_KEY.pem --cert LMIS_JPN_CLIENT_CERT_PEM.cer:pass1556677 -H "Accept: application/json" https://127.0.0.1/SiteInfo - Lorenzo
那行代码只是从示例顶部显示的appSettings.json中提取私钥文件名。您也可以硬编码私钥文件名(只是一种配置选项)。在高层次上,私钥用于[签署]数据(或其哈希值),这使得具有密钥对相应公钥的接收方确信(例如)数据是由私钥所有者发送的。它是加密的反面,通常使用公钥加密数据,只有私钥才能解密它。 - ivnext
我感到沮丧...使用Python,我可以用一行代码搞定所有事情(在这种情况下,我删除了密码)。C#中不存在类似的东西吗?requests.get('https://127.0.0.1/SiteInfo', cert=('cer.cer', 'pem.pem'), verify=False) - Lorenzo
C# 可以说是比较 "接近底层" 的语言。通用编程语言的优点在于,如果开箱即用的功能不满足您的需求,您可以随意创建几乎任何自定义 API(例如,类似于您所表达的东西)。但这需要一些努力。我不知道是否有类似于您示例中展示的类似 Python 的 API,但可能会有人编写的 NUGET 包接近该语法。 - ivnext
抱歉如果我回到clientPrivKeyFileName。现在,经过几个小时的努力 :-) ,我已经删除了密码...所以现在我应该使用证书文件和私钥文件。在您的第一个示例中,clientPrivKeyFileName被分配但从未在代码中使用。 - Lorenzo
我不确定您想如何使用私钥(例如签名),所以我只能添加代码和方法,以便成功导入它以满足您的需求。SO建议将此移至聊天。 - ivnext

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接