fszlin / certes

A client implementation for the Automated Certificate Management Environment (ACME) protocol
MIT License
548 stars 121 forks source link

When placing an order for 2 sites, do I need to create + validate 2 DNS challenges? #283

Closed StefH closed 2 years ago

StefH commented 2 years ago
// I place this order:
var order = await acme.NewOrder(new[] { "example.com", "www.example.com" });

// And get 2 Authorizations:
var authorizationContexts = await order.Authorizations();

// And I generate 2 DNS challenges:
var dnsChallenge1 = await x.authorizationContext[0].Dns();
var dnsChallenge2 = await x.authorizationContext[1].Dns();

Example:

Add a DNS TXT record to _acme-challenge.example.com with dnsTxt value : SkP-5-tJdHCVhksNXCb6nz9_dvbb6RUULY_15FSwJNw
Add a DNS TXT record to _acme-challenge.www.example.com with dnsTxt value : 2DFLK1uBXfsCBpDSVwt9HYygmS6OgMm_MwHSsB3YEr8

But when I successfully verify the first dns challenge (returns ChallengeStatus.Valid):

var result1 = await dnsChallenge1.Validate();

Do I still need to call the Validate on the second challenge?

var result2 = await dnsChallenge2.Validate();

Because it seems that this fails:

Certes.AcmeRequestException: 'Fail to load resource from 'https://acme-v02.api.letsencrypt.org/acme/chall-v3/xxx/yyy'.
urn:ietf:params:acme:error:malformed: Unable to update challenge :: authorization must be pending'
StefH commented 2 years ago

In case someone needs this information, see my working example code below:

using Certes;
using Certes.Acme;
using Certes.Acme.Resource;

Console.SetWindowSize(160, 40);

string currentDateTime = DateTime.Now.ToString("s").Replace(':', '_');

const int maxRetry = 30;
const int challengeRetryTimeInSeconds = 30;

const string path = @"C:\Users\Me\certes";
string accountPemPath = Path.Combine(path, "account.pem");
string passwordPath = Path.Combine(path, "password.txt");

string pemPath = Path.Combine(path, $"{currentDateTime}_chained.pem");
string pfxPath = Path.Combine(path, $"{currentDateTime}_pfx.pfx");

IAccountContext? account;
AcmeContext acme;
if (!File.Exists(accountPemPath))
{
    // Create new
    acme = new AcmeContext(WellKnownServers.LetsEncryptV2);
    account = await acme.NewAccount("test@email.com", true);
    var pemKey = acme.AccountKey.ToPem();
    File.WriteAllText(accountPemPath, pemKey);
}
else
{
    // Load the saved account key
    var pemKey = File.ReadAllText(accountPemPath);
    var accountKey = KeyFactory.FromPem(pemKey);
    acme = new AcmeContext(WellKnownServers.LetsEncryptV2, accountKey);
    account = await acme.Account();
}

var orderListContext = await account.Orders();
var orders = await orderListContext.Orders();

if (!orders.Any())
{
    var sites = new[] { "example.com", "www.example.com" };

    Console.WriteLine("Creating new Order for sites: {0}", string.Join(", ", sites));

    var order = await acme.NewOrder(sites);
    var authorizationContexts = await order.Authorizations();

    var dnsChallenges = new Dictionary<string, IChallengeContext>();
    foreach (var x in authorizationContexts.Select((authorizationContext, index) => new { authorizationContext, index }))
    {
        var dnsChallenge = await x.authorizationContext.Dns();

        dnsChallenges.Add(sites[x.index], dnsChallenge);

        var dnsTxt = acme.AccountKey.DnsTxt(dnsChallenge.Token);
        Console.WriteLine("Add a DNS TXT record to _acme-challenge.{0} with dnsTxt value : {1}", sites[x.index], dnsTxt);
    }

    int retry = 0;
    int ok;
    do
    {
        await Delay(challengeRetryTimeInSeconds);

        ok = 0;
        foreach (var dnsChallenge in dnsChallenges)
        {
            var result = await dnsChallenge.Value.Validate();
            Console.WriteLine("{0} ChallengeStatus for site {1} is {2}.", DateTime.Now, dnsChallenge.Key, result.Status);

            ok += result.Status == ChallengeStatus.Valid ? 1 : 0;
        }

        retry++;
    } while (retry < maxRetry && ok != sites.Length);

    if (retry >= maxRetry)
    {
        Console.WriteLine("Unable to Validate after {0} retries. Exiting now.", retry);
    }

    // Download the certificate once validation is done
    var privateKey = KeyFactory.NewKey(KeyAlgorithm.ES256);
    var cert = await order.Generate(new CsrInfo
    {
        CountryName = "NL",
        State = "...",
        Locality = "...",
        Organization = "Example",
        OrganizationUnit = string.Empty
    }, privateKey);

    // Export full chain certification
    var certPem = cert.ToPem();
    await File.WriteAllTextAsync(pemPath, certPem);

    // Export PFX
    var pfxBuilder = cert.ToPfx(privateKey);
    var pfx = pfxBuilder.Build($"{sites[0]}-certificate-{currentDateTime}", File.ReadAllText(passwordPath));

    await File.WriteAllBytesAsync(pfxPath, pfx);

    Console.WriteLine("All done");
}

static async Task Delay(int seconds)
{
    Console.WriteLine("Waiting {0} second{1}...", seconds, seconds == 1 ? "" : "s");
    await Task.Delay(TimeSpan.FromSeconds(seconds));
}
InteXX commented 1 year ago

This is exactly what I needed. Good job!

I injected some of my own code to update my Azure DNS and I'm now off and running. Thanks 🙂