Skip to content

Instantly share code, notes, and snippets.

@mjsabby
Created April 10, 2024 03:26
Show Gist options
  • Save mjsabby/7696fb209e58d8dfbdb8df3d9c195517 to your computer and use it in GitHub Desktop.
Save mjsabby/7696fb209e58d8dfbdb8df3d9c195517 to your computer and use it in GitHub Desktop.
C# program that implements ACME protocol to get certificates from Let's Encrypt using DNS Challenge for Cloudflare
/*
Example appsettings.json
{
"AzureManagedIdentityClientId": "YOURGUID",
"AzureKeyVaultAADScope": "https://vault.azure.net",
"AzureKeyVaultUrl": "https://YOURAKV.vault.azure.net/",
"AzureKeyVaultCertificateSecret": "akv_secret_for_cert",
"AzureKeyVaultCloudeflareApiKeySecret": "akv_secret_forcloudflareapikey",
"AzureKeyVaultLetsEncryptAccountSecret": "akv_secret_for_letsencryptaccountpem",
"LetsEncryptACMEDirectoryUrl": "https://acme-v02.api.letsencrypt.org/directory",
"LetsEncryptAccountEmail": "email@domain.com",
"CloudflareZoneApiUrl": "https://api.cloudflare.com/client/v4/zones/YOURGUID/dns_records",
"CloudflareTxtRecordName": "_acme-challenge.domain.com",
"DomainName": "*.domain.com",
"OrganizationName": "DomainOrg",
"LocalityName": "DomainCity",
"StateName": "DomainState",
"CountryName": "DomainCountry"
}
*/
namespace CertificateUpdater
{
using System;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
internal static class Program
{
public static async Task<int> Main()
{
var config = CertificateUpdaterConfig.Parse(await File.ReadAllTextAsync(Path.Combine(Directory.GetParent(Environment.ProcessPath).FullName, "appsettings.json")).ConfigureAwait(false));
Console.Write("Fetching managed identity access token ... ");
var accessToken = await GetAccessTokenAsync(config.AzureManagedIdentityClientId, config.AzureKeyVaultAADScope).ConfigureAwait(false);
Console.WriteLine("Done.");
Console.Write("Fetching account private key from key vault ... ");
var accountPem = await GetSecretAsync(config.AzureKeyVaultUrl, config.AzureKeyVaultLetsEncryptAccountSecret, accessToken).ConfigureAwait(false);
Console.WriteLine("Done.");
Console.Write("Fetching directory ... ");
var directory = await GetACMEDirectory(config.LetsEncryptACMEDirectoryUrl).ConfigureAwait(false);
Console.WriteLine("Done.");
Console.Write("Fetching nonce for new account request ... ");
var nonce = await GetNonce(directory.NewNonce).ConfigureAwait(false);
Console.WriteLine("Done.");
using ECDsa ecdsa = ECDsa.Create();
ecdsa.ImportFromPem(accountPem);
var ec = ecdsa.ExportParameters(includePrivateParameters: false);
var x = Base64UrlEncode(ec.Q.X);
var y = Base64UrlEncode(ec.Q.Y);
Console.Write("Creating/Fetching new account request ... ");
var (rr, accountKid) = await CreateNewEntity(directory.NewAccount, CreateNewAccountPayload(directory.NewAccount, nonce, config.LetsEncryptAccountEmail, ecdsa, x, y)).ConfigureAwait(false);
Console.WriteLine("Done.");
Console.Write("Fetching nonce for new domain order ... ");
nonce = await GetNonce(directory.NewNonce).ConfigureAwait(false);
Console.WriteLine("Done.");
Console.Write("Submitting new domain order ... ");
var (responseNewOrder, orderUrl) = await CreateNewEntity(directory.NewOrder, CreateNewOrderPayload(directory.NewOrder, accountKid, nonce, config.DomainName, ecdsa)).ConfigureAwait(false);
Console.WriteLine("Done.");
Console.WriteLine();
Console.WriteLine($"Order Url: {orderUrl}");
if (!TryGetAuthorizationAndFinalizeUrl(responseNewOrder, out var finalizeUrl, out var authorizationUrl))
{
Console.WriteLine("[ERROR] Failed to get finalize and authorization URLs");
Console.WriteLine("[RESPNOSE]");
Console.WriteLine(responseNewOrder);
return -1;
}
Console.WriteLine($"Authorization Url: {authorizationUrl}");
Console.WriteLine($"Finalize Url: {finalizeUrl}");
Console.WriteLine();
Console.Write("Fetching challenge information ... ");
var responseAuthorization = await GetUrlContentsAsString(authorizationUrl).ConfigureAwait(false);
if (!ExtractChallengeInfo(responseAuthorization, out var token, out var status, out var challengeUrl))
{
Console.WriteLine("[ERROR] Failed to get challenge information");
Console.WriteLine("[RESPNOSE]");
Console.WriteLine(responseAuthorization);
return -2;
}
Console.WriteLine("Done.");
Console.Write("Fetching Cloudflare api key ... ");
var apiKey = await GetSecretAsync(config.AzureKeyVaultUrl, config.AzureKeyVaultCloudeflareApiKeySecret, accessToken).ConfigureAwait(false);
Console.WriteLine("Done.");
Console.Write("Fetching Cloudflare zone record id ... ");
var recordId = await GetRecordId(config.CloudflareZoneApiUrl, apiKey, config.CloudflareTxtRecordName).ConfigureAwait(false);
Console.WriteLine("Done.");
Console.Write("Patching Cloudflare zone record ... ");
await PatchRecord($"{config.CloudflareZoneApiUrl}/{recordId}", apiKey, config.CloudflareTxtRecordName, CreateTxtRecord(token, x, y)).ConfigureAwait(false);
Console.WriteLine("Done.");
Console.WriteLine();
Console.Write("Sleeping for 60 seconds to allow DNS propogation ... ");
await Task.Delay(60 * 1000).ConfigureAwait(false);
Console.WriteLine("Done.");
Console.Write("Generating nonce for challenge validation ... ");
nonce = await GetNonce(directory.NewNonce).ConfigureAwait(false);
Console.WriteLine("Done.");
Console.Write("Requesting challenge validation ... ");
var (responseChallenge, _) = await CreateNewEntity(challengeUrl, CreateChallengePayload(challengeUrl, accountKid, nonce, ecdsa)).ConfigureAwait(false);
Console.WriteLine("Done.");
int attempt = 1;
while (true)
{
Console.Write($"Verifying challenge has been validated ... ");
responseAuthorization = await GetUrlContentsAsString(authorizationUrl).ConfigureAwait(false);
if (ExtractChallengeInfo(responseAuthorization, out _, out status, out _))
{
if (string.Equals(status, "invalid", StringComparison.Ordinal))
{
Console.WriteLine("Failed.");
Console.WriteLine("[ERROR]");
Console.WriteLine($"{responseAuthorization}");
return -4;
}
if (string.Equals(status, "valid", StringComparison.Ordinal))
{
Console.WriteLine("Done.");
break;
}
if (string.Equals(status, "pending", StringComparison.Ordinal))
{
Console.Write("Pending.");
if (attempt++ < 5)
{
Console.WriteLine(" Retrying in 5 seconds ...");
await Task.Delay(5000).ConfigureAwait(false);
}
else
{
Console.WriteLine(" Retries Exhaused. Failed.");
return -5;
}
}
}
}
Console.Write("Fetching nonce for certificate signing request ... ");
nonce = await GetNonce(directory.NewNonce).ConfigureAwait(false);
Console.WriteLine("Done.");
using RSA rsa = RSA.Create();
Console.Write("Submitting certificate signing request ... ");
var csrPayload = CreateCertificateSigningRequestPayload(finalizeUrl, accountKid, nonce, $"CN={config.DomainName}, O={config.OrganizationName}, L={config.LocalityName}, ST={config.StateName}, C={config.CountryName}", ecdsa, rsa);
(responseNewOrder, orderUrl) = await CreateNewEntity(finalizeUrl, csrPayload).ConfigureAwait(false);
Console.WriteLine("Done.");
if (!TryGetNewOrderCertificateUrl(responseNewOrder, out var certificateUrl))
{
attempt = 1;
while (true)
{
Console.Write($"Polling Attempt #{attempt} ... ");
responseNewOrder = await GetUrlContentsAsString(orderUrl).ConfigureAwait(false);
if (TryGetNewOrderCertificateUrl(responseNewOrder, out certificateUrl))
{
break;
}
if (attempt++ == 6)
{
Console.WriteLine("[ERROR] Failed to get valid status information to proceed");
Console.WriteLine("[RESPNOSE]");
Console.WriteLine($"{responseNewOrder}");
return -6;
}
await Task.Delay(1000).ConfigureAwait(false);
}
}
Console.Write("Fetching certificate ... ");
var certificate = await GetUrlContentsAsString(certificateUrl).ConfigureAwait(false);
Console.WriteLine("Done.");
var cert = X509Certificate2.CreateFromPem(certificate);
var certWithKey = cert.CopyWithPrivateKey(rsa);
var pfx = Convert.ToBase64String(certWithKey.Export(X509ContentType.Pfx));
Console.Write("Storing certificate in key vault ... ");
var secretId = await PutSecretAsync(config.AzureKeyVaultUrl, config.AzureKeyVaultCertificateSecret, pfx, accessToken).ConfigureAwait(false);
Console.WriteLine("Done.");
Console.WriteLine();
Console.WriteLine($"SecretId: {secretId}");
return 0;
}
private static async Task<ACMEDirectory> GetACMEDirectory(string directoryUrl)
{
using var client = new HttpClient();
HttpResponseMessage directoryResponse = await client.GetAsync(new Uri(directoryUrl)).ConfigureAwait(false);
directoryResponse.EnsureSuccessStatusCode();
string directoryJson = await directoryResponse.Content.ReadAsStringAsync().ConfigureAwait(false);
using JsonDocument doc = JsonDocument.Parse(directoryJson);
var root = doc.RootElement;
var acmeDirectory = new ACMEDirectory();
foreach (var prop in root.EnumerateObject())
{
if (prop.NameEquals("newAccount"))
{
acmeDirectory.NewAccount = prop.Value.GetString();
}
if (prop.NameEquals("newOrder"))
{
acmeDirectory.NewOrder = prop.Value.GetString();
}
if (prop.NameEquals("newNonce"))
{
acmeDirectory.NewNonce = prop.Value.GetString();
}
}
return acmeDirectory;
}
private static async Task<string> GetNonce(string newNonceUrl)
{
using var client = new HttpClient();
using var request = new HttpRequestMessage(HttpMethod.Head, newNonceUrl);
HttpResponseMessage response = await client.SendAsync(request).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var nonceHeaders = response.Headers.GetValues("Replay-Nonce");
foreach (var nonceHeader in nonceHeaders)
{
return nonceHeader;
}
throw new Exception("No nonce found in response headers");
}
private static async Task<string> GetUrlContentsAsString(string url)
{
using var client = new HttpClient();
HttpResponseMessage response = await client.GetAsync(new Uri(url)).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}
private static async Task<(string response, string location)> CreateNewEntity(string newEntityUrl, string payload)
{
using var client = new HttpClient();
using var request = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = new Uri(newEntityUrl),
Content = new StringContent(payload, new MediaTypeHeaderValue("application/jose+json"))
};
HttpResponseMessage response = await client.SendAsync(request).ConfigureAwait(false);
var data = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return (data, response.Headers.Location.ToString());
}
private static bool TryGetNewOrderCertificateUrl(string responseNewOrder, out string certificateUrl)
{
certificateUrl = null;
using JsonDocument doc = JsonDocument.Parse(responseNewOrder);
foreach (var item in doc.RootElement.EnumerateObject())
{
if (item.NameEquals("certificate"))
{
certificateUrl = item.Value.GetString();
}
}
if (certificateUrl is null)
{
return false;
}
return true;
}
private static bool TryGetAuthorizationAndFinalizeUrl(string responseNewOrder, out string finalizeUrl, out string authorizationUrl)
{
finalizeUrl = null;
authorizationUrl = null;
using JsonDocument doc = JsonDocument.Parse(responseNewOrder);
foreach (var item in doc.RootElement.EnumerateObject())
{
if (item.NameEquals("finalize"))
{
finalizeUrl = item.Value.GetString();
}
if (item.NameEquals("authorizations"))
{
foreach (var elem in item.Value.EnumerateArray())
{
authorizationUrl = elem.GetString();
}
}
}
if (finalizeUrl is null || authorizationUrl is null)
{
return false;
}
return true;
}
private static bool ExtractChallengeInfo(string responseAuthorization, out string token, out string status, out string url)
{
token = null;
status = null;
url = null;
using JsonDocument doc = JsonDocument.Parse(responseAuthorization);
foreach (var item in doc.RootElement.EnumerateObject())
{
if (item.NameEquals("challenges"))
{
foreach (var e in item.Value.EnumerateArray())
{
foreach (var p in e.EnumerateObject())
{
if (p.NameEquals("token"))
{
token = p.Value.GetString();
}
if (p.NameEquals("status"))
{
status = p.Value.GetString();
}
if (p.NameEquals("url"))
{
url = p.Value.GetString();
}
}
}
}
}
if (token is null || status is null || url is null)
{
return false;
}
return true;
}
private static async Task<string> GetAccessTokenAsync(string clientId, string resource)
{
using var client = new HttpClient();
if (Environment.GetEnvironmentVariable("IDENTITY_ENDPOINT") is not string identityEndpoint)
{
identityEndpoint = $"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&client_id={clientId}&resource={resource}";
client.DefaultRequestHeaders.Add("Metadata", "true");
}
else
{
identityEndpoint = $"{identityEndpoint.TrimEnd('/')}?api-version=2019-08-01&client_id={clientId}&resource={resource}/.default"; // TODO: why is this /.default needed?
}
if (Environment.GetEnvironmentVariable("IDENTITY_HEADER") is string identityHeader)
{
client.DefaultRequestHeaders.Add("X-IDENTITY-HEADER", identityHeader);
}
using var request = new HttpRequestMessage(HttpMethod.Get, identityEndpoint);
var responseMessage = await client.SendAsync(request).ConfigureAwait(false);
responseMessage.EnsureSuccessStatusCode();
var response = await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false);
string accessToken = null;
using JsonDocument doc = JsonDocument.Parse(response);
foreach (var e in doc.RootElement.EnumerateObject())
{
if (e.NameEquals("access_token"))
{
accessToken = e.Value.GetString();
}
}
return accessToken;
}
private static async Task<string> GetSecretAsync(string keyVaultUrl, string secretName, string accessToken)
{
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var responseMessage = await client.GetAsync(new Uri($"{keyVaultUrl}/secrets/{secretName}?api-version=7.1")).ConfigureAwait(false);
responseMessage.EnsureSuccessStatusCode();
var response = await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false);
string value = null;
using JsonDocument doc = JsonDocument.Parse(response);
foreach (var e in doc.RootElement.EnumerateObject())
{
if (e.NameEquals("value"))
{
value = e.Value.GetString();
}
}
return value;
}
private static async Task<string> PutSecretAsync(string keyVaultUrl, string secretName, string secretValue, string accessToken)
{
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var sc = new StringContent($"{{\"value\":\"{secretValue}\"}}", Encoding.UTF8, "application/json");
var responseMessage = await client.PutAsync(new Uri($"{keyVaultUrl}/secrets/{secretName}?api-version=7.1"), sc).ConfigureAwait(false);
responseMessage.EnsureSuccessStatusCode();
var response = await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false);
string value = null;
using JsonDocument doc = JsonDocument.Parse(response);
foreach (var e in doc.RootElement.EnumerateObject())
{
if (e.NameEquals("id"))
{
value = e.Value.GetString();
}
}
return value;
}
private static async Task<string> GetRecordId(string apiUrl, string apiKey, string recordName)
{
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
var response = await client.GetAsync(new Uri(apiUrl)).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
using JsonDocument doc = JsonDocument.Parse(json);
foreach (var p in doc.RootElement.EnumerateObject())
{
if (p.NameEquals("result"))
{
foreach (var o in p.Value.EnumerateArray())
{
string id = null;
string name = null;
foreach (var r in o.EnumerateObject())
{
if (r.NameEquals("name"))
{
name = r.Value.GetString();
}
if (r.NameEquals("id"))
{
id = r.Value.GetString();
}
}
if (string.Equals(name, recordName, StringComparison.OrdinalIgnoreCase))
{
return id;
}
}
}
}
throw new Exception("Record not found");
}
private static async Task PatchRecord(string apiUrl, string apiKey, string recordName, string recordValue)
{
string jsonPayload = $$"""{"type":"TXT","name":"{{recordName}}","content":"{{recordValue}}"}""";
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
using var sc = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
var response = await client.PatchAsync(new Uri(apiUrl), sc).ConfigureAwait(false);
var data = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
private static string CreateNewAccountPayload(string newAccountUrl, string nonce, string email, ECDsa ecdsa, string x, string y)
{
var header = $$"""{"alg":"ES256","jwk":{"alg":"ES256","crv":"P-256","kty":"EC","use":"sig","x":"{{x}}","y":"{{y}}"},"nonce":"{{nonce}}","url":"{{newAccountUrl}}"}""";
var payload = $$"""{"termsOfServiceAgreed":true,"contact":["mailto:{{email}}"]}""";
return CreateJWSCompactSerialization(header, payload, ecdsa);
}
private static string CreateNewOrderPayload(string newOrderUrl, string kid, string nonce, string domain, ECDsa ecdsa)
{
var header = $$"""{"alg":"ES256","kid":"{{kid}}","nonce":"{{nonce}}","url":"{{newOrderUrl}}"}""";
var payload = $$"""{"identifiers":[{"type":"dns","value":"{{domain}}"}]}""";
return CreateJWSCompactSerialization(header, payload, ecdsa);
}
private static string CreateCertificateSigningRequestPayload(string finalizeUrl, string kid, string nonce, string distinguishedName, ECDsa ecdsa, RSA rsa)
{
var header = $$"""{"alg":"ES256","kid":"{{kid}}","nonce":"{{nonce}}","url":"{{finalizeUrl}}"}""";
var payload = $$"""{"csr":"{{CreateCertificateSigningRequest(distinguishedName, rsa)}}"}""";
return CreateJWSCompactSerialization(header, payload, ecdsa);
}
private static string CreateChallengePayload(string challengeUrl, string kid, string nonce, ECDsa ecdsa)
{
var header = $$"""{"alg":"ES256","kid":"{{kid}}","nonce":"{{nonce}}","url":"{{challengeUrl}}"}""";
var payload = $$"""{}""";
return CreateJWSCompactSerialization(header, payload, ecdsa);
}
private static string CreateJWSCompactSerialization(string header, string payload, ECDsa ecdsa)
{
string base64UrlEncodedHeader = Base64UrlEncode(Encoding.UTF8.GetBytes(header));
string base64UrlEncodedPayload = Base64UrlEncode(Encoding.UTF8.GetBytes(payload));
string base64UrlEncodedSignature = Base64UrlEncode(ecdsa.SignData(Encoding.UTF8.GetBytes($"{base64UrlEncodedHeader}.{base64UrlEncodedPayload}"), HashAlgorithmName.SHA256));
return $$"""{"protected":"{{base64UrlEncodedHeader}}","payload":"{{base64UrlEncodedPayload}}","signature":"{{base64UrlEncodedSignature}}"}""";
}
private static string CreateTxtRecord(string token, string x, string y)
{
var jwkThumbprint = SHA256.HashData(Encoding.UTF8.GetBytes($$"""{"crv":"P-256","kty":"EC","x":"{{x}}","y":"{{y}}"}"""));
return Base64UrlEncode(SHA256.HashData(Encoding.UTF8.GetBytes($"{token}.{Base64UrlEncode(jwkThumbprint)}")));
}
private static string CreateCertificateSigningRequest(string distinguishedName, RSA rsa) => Base64UrlEncode(new CertificateRequest(new X500DistinguishedName(distinguishedName), rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1).CreateSigningRequest());
private static string Base64UrlEncode(byte[] input) => Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
internal sealed class ACMEDirectory
{
public string NewAccount { get; set; }
public string NewOrder { get; set; }
public string NewNonce { get; set; }
}
internal sealed class CertificateUpdaterConfig
{
public string AzureManagedIdentityClientId { get; set; }
public string AzureKeyVaultAADScope { get; set; }
public string AzureKeyVaultUrl { get; set; }
public string AzureKeyVaultCertificateSecret { get; set; }
public string AzureKeyVaultCloudeflareApiKeySecret { get; set; }
public string AzureKeyVaultLetsEncryptAccountSecret { get; set; }
public string LetsEncryptACMEDirectoryUrl { get; set; }
public string LetsEncryptAccountEmail { get; set; }
public string CloudflareZoneApiUrl { get; set; }
public string CloudflareTxtRecordName { get; set; }
public string DomainName { get; set; }
public string OrganizationName { get; set; }
public string LocalityName { get; set; }
public string StateName { get; set; }
public string CountryName { get; set; }
public static CertificateUpdaterConfig Parse(string json)
{
using JsonDocument doc = JsonDocument.Parse(json);
JsonElement root = doc.RootElement;
return new CertificateUpdaterConfig
{
AzureManagedIdentityClientId = root.GetProperty(nameof(AzureManagedIdentityClientId)).GetString(),
AzureKeyVaultAADScope = root.GetProperty(nameof(AzureKeyVaultAADScope)).GetString(),
AzureKeyVaultUrl = root.GetProperty(nameof(AzureKeyVaultUrl)).GetString(),
AzureKeyVaultCertificateSecret = root.GetProperty(nameof(AzureKeyVaultCertificateSecret)).GetString(),
AzureKeyVaultCloudeflareApiKeySecret = root.GetProperty(nameof(AzureKeyVaultCloudeflareApiKeySecret)).GetString(),
AzureKeyVaultLetsEncryptAccountSecret = root.GetProperty(nameof(AzureKeyVaultLetsEncryptAccountSecret)).GetString(),
LetsEncryptACMEDirectoryUrl = root.GetProperty(nameof(LetsEncryptACMEDirectoryUrl)).GetString(),
LetsEncryptAccountEmail = root.GetProperty(nameof(LetsEncryptAccountEmail)).GetString(),
CloudflareZoneApiUrl = root.GetProperty(nameof(CloudflareZoneApiUrl)).GetString(),
CloudflareTxtRecordName = root.GetProperty(nameof(CloudflareTxtRecordName)).GetString(),
DomainName = root.GetProperty(nameof(DomainName)).GetString(),
OrganizationName = root.GetProperty(nameof(OrganizationName)).GetString(),
LocalityName = root.GetProperty(nameof(LocalityName)).GetString(),
StateName = root.GetProperty(nameof(StateName)).GetString(),
CountryName = root.GetProperty(nameof(CountryName)).GetString()
};
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment