用C#自动管理证书

csharp
acme
Author

XU HUI

Published

October 10, 2025

虽说项目只需要往cloudflare后一塞就可以获得HTTPS,但还是想自己研究下证书的签发和自动化,就从实现ACME客户端开始吧。

RFC8555

RFC8555 就是ACME规范了,这里边定义了ACME客户端和服务器之间的交互方式。

  1. Section 4 Protocol Overview - Four major steps the client needs to take to get a certificate:

    1. Submit an order for a certificate to be issued

    2. Prove control of any identifiers requested in the certificate

    3. Finalize the order by submitting a CSR

    4. Await issuance and download the issued certificate

  2. Section 6 Message Transport - How to send request and receive data from ACME server:

    1. Mandatory HTTPS and anti-replay mechanism

    2. Use JWS for POST method, Post-As-Get for GET method

    3. If request is rejected, server will return a error message formatted in RFC7807 which presented as class ProblemDetails

  3. Section 7.1 Resources - This section defines some types we will use in the client.

    1. Directory - The directory object contains the URLs of the ACME server’s resources

    2. Order - The order object contains the information of the order

    3. Authorization - The authorization object contains the information of the authorization

    4. Challenge - The challenge object contains the information of the challenge

section 4描述了大致的流程:下单、验证、确认验证、获取证书

section 6描述了交互方式:使用HTTPS、JWS、Post-As-Get

section 7.1描述了需要用到的资源类型

代码实现

account and JWS

首先要做的就是获取一个账号用于跟服务器交互了,根据RFC,账号就是一个JWK,可以通过RSA生成。

说到JWK,那就然就会想到JWT,微软有第一方的实现Microsoft.IdentityModel.Tokens,我这里就直接使用了。

public class AcmeClient
{
    private readonly HttpClient _client;
    private readonly RSA _privateKey;
    private readonly JsonWebKey _jwk;
    private AcmeDirectory _directory = null!;
    private string _kid = null!;

    public AcmeClient(HttpClient client, RSA privateKey)
    {
        _client = client;
        _privateKey = privateKey;
        var rsaParams = privateKey.ExportParameters(false);
        _jwk = new JsonWebKey()
        {
            Kty = "RSA",
            E = Base64Url.EncodeToString(rsaParams.Exponent),
            N = Base64Url.EncodeToString(rsaParams.Modulus),
            Alg = "RS256",
            Use = "sig"
        };
    }

    public async Task InitiateAsync(string url, string[] emails)
    {
        if (_directory != null && _kid != null) return;
        _directory = (await _client.GetFromJsonAsync(url, AcmeJsonSerializerContext.Default.AcmeDirectory))!;
        (_kid, _) = await CreateAccountAsync(emails);
    }
}

_directory 这个对象是用来描述服务器提供资源及其url的,可以直接请求不需要账号。

_kid 则是key id的缩写,标识我们的账号的JWK,是从服务器生成的,需要记录下来,这就是另外数据库的事了,与我要实现的工具无关。在账号接口,需要直接发送JWK,其他接口则用kid。

在注册账号之前,还需要一步,实现section 6中的Post-As-Get和反replay机制。

private async Task<HttpResponseMessage> PostAsGetAsync(string url)
{
    var nonce = await GetNonceAsync();
    var header = new AcmeMessageHeader(nonce, url, _kid);
    var message = new PostAsGetMessage(header);
    return await _client.SendAsync(message.GetRequestMessage(_privateKey));
}

private async Task<HttpResponseMessage> PostAsync<T>(string url, T request)
{
    var nonce = await GetNonceAsync();
    var header = new AcmeMessageHeader(nonce, url, _kid);
    var message = new AcmeMessage<T>(header, request).GetRequestMessage(_privateKey);
    return await _client.SendAsync(message);
}

private async Task<AcmeMessageHeader> GetAcmeMessageHeader(string url, bool useEmbedKey = false)
{
    if (_directory == null)
    {
        throw new InvalidOperationException("AcmeClient not initiated");
    }

    var nonce = await GetNonceAsync();

    return useEmbedKey
        ? new AcmeMessageHeader(nonce, url, _jwk)
        : new AcmeMessageHeader(nonce, url, _kid);
}

public async Task<string> GetNonceAsync()
{
    var request = new HttpRequestMessage(HttpMethod.Head, _directory.NewNonce);
    using var response = await _client.SendAsync(request);
    return response.Headers.GetValues("Replay-Nonce").First();
}

PostAsGet和Nonce都实现了现在终于可以注册账号了。

private async Task<(string, AcmeAccount)> CreateAccountAsync(string[] emails)
{
    var header = await GetAcmeMessageHeader(_directory.NewAccount, true);
    var message = new AcmeMessage<CreateAccountRequest>(
        header,
        new() { Contact = [.. emails.Select(x => $"mailto:{x}")] }
    ).GetRequestMessage(_privateKey);

    using var response = await _client.SendAsync(message);

    await EnsureStatusCodeAsync(response);

    var kid = response.Headers.GetValues("Location").First();
    var account = await response.Content.ReadFromJsonAsync(AcmeJsonSerializerContext.Default.AcmeAccount);

    return (kid, account!);
}

public async Task<AcmeAccount> GetAccountAsync()
{
    using var response = await PostAsync<CreateAccountRequest>(_directory.NewAccount, new() { OnlyReturnExisting = true });

    await EnsureStatusCodeAsync(response);

    return (await response.Content.ReadFromJsonAsync(AcmeJsonSerializerContext.Default.AcmeAccount))!;
}

获取证书

ACME 服务器会在响应头中返回账号的url(也叫做kid),同样,newOrder资源会在响应体中返回订单的url。

public async Task<(string, AcmeOrder)> NewOrderAsync(string[] domains)
{
    using var response = await PostAsync<NewOrderRequest>(_directory.NewOrder, new()
    {
        Identifiers = [.. domains.Select(x => new Identifier { Value = x })]
    });

    await EnsureStatusCodeAsync(response);

    var order = await response.Content.ReadFromJsonAsync(AcmeJsonSerializerContext.Default.AcmeOrder);
    var location = response.Headers.GetValues("Location").First();

    return (location, order!);
}

public async Task<AcmeOrder> GetOrderAsync(string url)
{
    using var response = await PostAsGetAsync(url);
    response.EnsureSuccessStatusCode();

    return (await response.Content.ReadFromJsonAsync(AcmeJsonSerializerContext.Default.AcmeOrder))!;
}

在newOrder之后,我们需要完成它的授权,与域名一一对应。在每一个授权中,都有一些challenges需要完成。最常见的是dns-01,需要向域名添加一个TXT记录。

public async Task<AcmeAuthorization> GetAuthorizationAsync(string url)
{
    using var response = await PostAsGetAsync(url);

    await EnsureStatusCodeAsync(response);

    return (await response.Content.ReadFromJsonAsync(AcmeJsonSerializerContext.Default.AcmeAuthorization))!;
}

public async Task<AcmeChallenge> GetChallengeAsync(string url)
{
    using var response = await PostAsGetAsync(url);

    await EnsureStatusCodeAsync(response);

    return (await response.Content.ReadFromJsonAsync(AcmeJsonSerializerContext.Default.AcmeChallenge))!;
}

/// <summary>
/// https://datatracker.ietf.org/doc/html/rfc8555#section-8.4
/// <br/><br/>
/// TXT _acme-challenge.{host} "{value}"
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
public string GetDnsChallengeSha256Digest(string token)
{
    var hash = SHA256.HashData(Encoding.UTF8.GetBytes($$"""{"e":"{{_jwk.E}}","kty":"RSA","n":"{{_jwk.N}}"}"""));
    var thumbprint = Base64Url.EncodeToString(hash);

    string result = token + "." + thumbprint;

    return Base64Url.EncodeToString(SHA256.HashData(Encoding.UTF8.GetBytes(result)));
}

在DNS记录添加之后,就可以确认challenge,完成订单并下载证书。

public async Task<AcmeChallenge> AcknowledgeChallengeAsync(string url)
{
    using var response = await PostAsync<AcknowledgeChallengeRequest>(url, new());

    await EnsureStatusCodeAsync(response);

    return (await response.Content.ReadFromJsonAsync(AcmeJsonSerializerContext.Default.AcmeChallenge))!;
}

public async Task<AcmeOrder> FinalizeOrderAsync(string url, string[] domains, RSA rsa)
{
    var subject = new X500DistinguishedName($"CN={domains[0]}");
    var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
    var sanBuilder = new SubjectAlternativeNameBuilder();

    foreach (var domainName in domains)
    {
        if (Uri.CheckHostName(domainName) == UriHostNameType.Dns)
        {
            sanBuilder.AddDnsName(domainName);
        }
    }

    request.CertificateExtensions.Add(sanBuilder.Build());
    var csr = Base64Url.EncodeToString(request.CreateSigningRequest());

    using var response = await PostAsync<FinalizeOrderRequest>(url, new() { Csr = csr });

    await EnsureStatusCodeAsync(response);

    return (await response.Content.ReadFromJsonAsync(AcmeJsonSerializerContext.Default.AcmeOrder))!;
}

/// <summary>
/// 
/// </summary>
/// <param name="url"></param>
/// <returns>Leaf Certificate, Intermediate Certificate</returns>
public async Task<(string, string)> DownloadCertificateAsync(string url)
{
    using var response = await PostAsGetAsync(url);

    await EnsureStatusCodeAsync(response);

    var text = await response.Content.ReadAsStringAsync();
    var reader = new StringReader(text);

    var leafEnd = 0;
    var flag = 0;

    while (reader.ReadLine() is string line)
    {
        leafEnd += line.Length + 1;

        if (line.EndsWith('-') && ++flag > 1) break;
    }

    return (text[..leafEnd], text[leafEnd..]);
}

完整代码请查看 GIT.