Automate Certificate Issuance with C#

code
csharp
acme
Author

XU HUI

Published

October 9, 2025

While I work on a self-hosted web application, I came across the need to automate the issuance of SSL certificates. We have so many tools to do this: acme.sh, certbot, and so on. However, only nginx is not enough.

Then, I found some projects on github’s acme topic, which are written in different languages. Some golang projects are very good, but I prefer to use C#. And since I know nothing about ACME, I decided to implement an ACME client myself to gain a deeper understanding.

RFC8555

RFC8555 is the specification of ACME, which is my most important reference to implement the client.

The RFC define 3 important things for the client:

  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

Code Implementation

account and JWS

First, it’s necessary to get an account to interact with the ACME server. According to the RFC, an account is just a JWK. It can be created by RSA.

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 is the directory object, which contains the URLs of the ACME server’s resources. _kid is the account/jwk identifier generated by ACME server. For account related operations, embed jwk is required, and for other operations, use kid.

After jwk created, we need tp register it to the ACME server. JWS, Post-As-Get and nonce are used to send the request.

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();
}

Now, we can create an account.

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))!;
}

steps to get a certificate

ACME server will return account url(also called kid) in the response header. Similarly. newOrder resource returns the order url in the response body.

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))!;
}

After newOrder, we need complete it’s authorizations which correspond one-to-one with the domains. In each authorization, there are some challenges to complete. The most common one is dns-01, which requires to add a TXT record to the domain.

/// <summary>
/// 
/// </summary>
/// <param name="url">order's Authorizations property</param>
/// <returns></returns>
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)));
}

After the DNS record is added, we can acknowledge the challenge, finalize the order and download the certificates.

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..]);
}

Only core logic is pasted here. Full code is available at GIT.