Instantly share code, notes, and snippets.

Embed
What would you like to do?
ACME v2 client for Let's Encrypt
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Raven.LetsEncrypt
{
public class LetsEncryptClient
{
public const string StagingV2 = "https://acme-staging-v02.api.letsencrypt.org/directory";
private static readonly JsonSerializerSettings jsonSettings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore,
Formatting = Formatting.Indented
};
private static Dictionary<string, HttpClient> _cachedClients = new Dictionary<string, HttpClient>(StringComparer.OrdinalIgnoreCase);
private static HttpClient GetCachedClient(string url)
{
if (_cachedClients.TryGetValue(url, out var value))
{
return value;
}
lock (Locker)
{
if (_cachedClients.TryGetValue(url, out value))
{
return value;
}
value = new HttpClient
{
BaseAddress = new Uri(url)
};
_cachedClients = new Dictionary<string, HttpClient>(_cachedClients, StringComparer.OrdinalIgnoreCase)
{
[url] = value
};
return value;
}
}
/// <summary>
/// In our scenario, we assume a single single wizard progressing
/// and the locking is basic to the wizard progress. Adding explicit
/// locking to be sure that we are not corrupting disk state if user
/// is explicitly calling stuff concurrently (running the setup wizard
/// from two tabs?)
/// </summary>
private static readonly object Locker = new object();
private Jws _jws;
private readonly string _path;
private readonly string _url;
private string _nonce;
private RSACryptoServiceProvider _accountKey;
private RegistrationCache _cache;
private HttpClient _client;
private Directory _directory;
private List<AuthorizationChallenge> _challenges = new List<AuthorizationChallenge>();
private Order _currentOrder;
public LetsEncryptClient(string url)
{
_url = url ?? throw new ArgumentNullException(nameof(url));
var home = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData,
Environment.SpecialFolderOption.Create);
var hash = SHA256.Create().ComputeHash(Encoding.UTF8.GetBytes(url));
var file = Jws.Base64UrlEncoded(hash) + ".lets-encrypt.cache.json";
_path = Path.Combine(home, file);
}
public async Task Init(string email, CancellationToken token = default(CancellationToken))
{
_accountKey = new RSACryptoServiceProvider(4096);
_client = GetCachedClient(_url);
(_directory, _) = await SendAsync<Directory>(HttpMethod.Get, new Uri("directory", UriKind.Relative), null, token);
if (File.Exists(_path))
{
bool success;
try
{
lock (Locker)
{
_cache = JsonConvert.DeserializeObject<RegistrationCache>(File.ReadAllText(_path));
}
_accountKey.ImportCspBlob(_cache.AccountKey);
_jws = new Jws(_accountKey, _cache.Id);
success = true;
}
catch
{
success = false;
// if we failed for any reason, we'll just
// generate a new registration
}
if (success)
{
return;
}
}
_jws = new Jws(_accountKey, null);
var (account, response) = await SendAsync<Account>(HttpMethod.Post, _directory.NewAccount, new Account
{
// we validate this in the UI before we get here, so that is fine
TermsOfServiceAgreed = true,
Contacts = new[] { "mailto:" + email },
}, token);
_jws.SetKeyId(account);
if (account.Status != "valid")
throw new InvalidOperationException("Account status is not valid, was: " + account.Status + Environment.NewLine + response);
lock (Locker)
{
_cache = new RegistrationCache
{
Location = account.Location,
AccountKey = _accountKey.ExportCspBlob(true),
Id = account.Id,
Key = account.Key
};
File.WriteAllText(_path,
JsonConvert.SerializeObject(_cache, Formatting.Indented));
}
}
private async Task<(TResult Result, string Response)> SendAsync<TResult>(HttpMethod method, Uri uri, object message, CancellationToken token) where TResult : class
{
var request = new HttpRequestMessage(method, uri);
if (message != null)
{
var encodedMessage = _jws.Encode(message, new JwsHeader
{
Nonce = _nonce,
Url = uri
});
var json = JsonConvert.SerializeObject(encodedMessage, jsonSettings);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
}
var response = await _client.SendAsync(request, token).ConfigureAwait(false);
_nonce = response.Headers.GetValues("Replay-Nonce").First();
if (response.Content.Headers.ContentType.MediaType == "application/problem+json")
{
var problemJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var problem = JsonConvert.DeserializeObject<Problem>(problemJson);
problem.RawJson = problemJson;
throw new LetsEncrytException(problem, response);
}
var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (typeof(TResult) == typeof(string)
&& response.Content.Headers.ContentType.MediaType == "application/pem-certificate-chain")
{
return ((TResult)(object)responseText, null);
}
var responseContent = JObject.Parse(responseText).ToObject<TResult>();
if (responseContent is IHasLocation ihl)
{
if (response.Headers.Location != null)
ihl.Location = response.Headers.Location;
}
return (responseContent, responseText);
}
public async Task<Dictionary<string, string>> NewOrder(string[] hostnames, CancellationToken token = default(CancellationToken))
{
_challenges.Clear();
var (order, response) = await SendAsync<Order>(HttpMethod.Post, _directory.NewOrder, new Order
{
Expires = DateTime.UtcNow.AddDays(2),
Identifiers = hostnames.Select(hostname => new OrderIdentifier
{
Type = "dns",
Value = hostname
}).ToArray()
}, token);
if (order.Status != "pending")
throw new InvalidOperationException("Created new order and expected status 'pending', but got: " + order.Status + Environment.NewLine +
response);
_currentOrder = order;
var results = new Dictionary<string, string>();
foreach (var item in order.Authorizations)
{
var (challengeResponse, responseText) = await SendAsync<AuthorizationChallengeResponse>(HttpMethod.Get, item, null, token);
if (challengeResponse.Status == "valid")
continue;
if (challengeResponse.Status != "pending")
throw new InvalidOperationException("Expected autorization status 'pending', but got: " + order.Status +
Environment.NewLine + responseText);
var challenge = challengeResponse.Challenges.First(x => x.Type == "dns-01");
_challenges.Add(challenge);
var keyToken = _jws.GetKeyAuthorization(challenge.Token);
using (var sha256 = SHA256.Create())
{
var dnsToken = Jws.Base64UrlEncoded(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken)));
results[challengeResponse.Identifier.Value] = dnsToken;
}
}
return results;
}
public async Task CompleteChallenges(CancellationToken token = default(CancellationToken))
{
for (var index = 0; index < _challenges.Count; index++)
{
var challenge = _challenges[index];
while (true)
{
var (result, responseText) = await SendAsync<AuthorizationChallengeResponse>(HttpMethod.Post, challenge.Url, new AuthorizeChallenge
{
KeyAuthorization = _jws.GetKeyAuthorization(challenge.Token)
}, token);
if (result.Status == "valid")
break;
if (result.Status != "pending")
throw new InvalidOperationException("Failed autorization of " + _currentOrder.Identifiers[index].Value + Environment.NewLine + responseText);
await Task.Delay(500);
}
}
}
public async Task<(X509Certificate2 Cert, RSA PrivateKey)> GetCertificate(CancellationToken token = default(CancellationToken))
{
var key = new RSACryptoServiceProvider(4096);
var csr = new CertificateRequest("CN=" + _currentOrder.Identifiers[0].Value,
key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var san = new SubjectAlternativeNameBuilder();
foreach (var host in _currentOrder.Identifiers)
san.AddDnsName(host.Value);
csr.CertificateExtensions.Add(san.Build());
var (response, responseText) = await SendAsync<Order>(HttpMethod.Post, _currentOrder.Finalize, new FinalizeRequest
{
CSR = Jws.Base64UrlEncoded(csr.CreateSigningRequest())
}, token);
while (response.Status != "valid")
{
(response, responseText) = await SendAsync<Order>(HttpMethod.Get, response.Location, null, token);
if(response.Status == "processing")
{
await Task.Delay(500);
continue;
}
throw new InvalidOperationException("Invalid order status: " + response.Status + Environment.NewLine +
responseText);
}
var (pem, _) = await SendAsync<string>(HttpMethod.Get, response.Certificate, null, token);
var cert = new X509Certificate2(Encoding.UTF8.GetBytes(pem));
_cache.CachedCerts[_currentOrder.Identifiers[0].Value] = new CertificateCache
{
Cert = pem,
Private = key.ExportCspBlob(true)
};
lock (Locker)
{
File.WriteAllText(_path,
JsonConvert.SerializeObject(_cache, Formatting.Indented));
}
return (cert, key);
}
public class CachedCertificateResult
{
public RSA PrivateKey;
public string Certificate;
}
public bool TryGetCachedCertificate(List<string> hosts, out CachedCertificateResult value)
{
value = null;
if (_cache.CachedCerts.TryGetValue(hosts[0], out var cache) == false)
{
return false;
}
var cert = new X509Certificate2(cache.Cert);
// if it is about to expire, we need to refresh
if ((cert.NotAfter - DateTime.UtcNow).TotalDays < 14)
return false;
var rsa = new RSACryptoServiceProvider(4096);
rsa.ImportCspBlob(cache.Private);
value = new CachedCertificateResult
{
Certificate = cache.Cert,
PrivateKey = rsa
};
return true;
}
public string GetTermsOfServiceUri(CancellationToken token = default(CancellationToken))
{
return _directory.Meta.TermsOfService;
}
public void ResetCachedCertificate(IEnumerable<string> hostsToRemove)
{
foreach (var host in hostsToRemove)
{
_cache.CachedCerts.Remove(host);
}
}
private class RegistrationCache
{
public readonly Dictionary<string, CertificateCache> CachedCerts = new Dictionary<string, CertificateCache>(StringComparer.OrdinalIgnoreCase);
public byte[] AccountKey;
public string Id;
public Jwk Key;
public Uri Location;
}
private class CertificateCache
{
public string Cert;
public byte[] Private;
}
private class AuthorizationChallengeResponse
{
[JsonProperty("identifier")]
public OrderIdentifier Identifier { get; set; }
[JsonProperty("status")]
public string Status { get; set; }
[JsonProperty("expires")]
public DateTime? Expires { get; set; }
[JsonProperty("wildcard")]
public bool Wildcard { get; set; }
[JsonProperty("challenges")]
public AuthorizationChallenge[] Challenges { get; set; }
}
private class AuthorizeChallenge
{
[JsonProperty("keyAuthorization")]
public string KeyAuthorization { get; set; }
}
private class AuthorizationChallenge
{
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("status")]
public string Status { get; set; }
[JsonProperty("url")]
public Uri Url { get; set; }
[JsonProperty("token")]
public string Token { get; set; }
}
private class Jwk
{
[JsonProperty("kty")]
public string KeyType { get; set; }
[JsonProperty("kid")]
public string KeyId { get; set; }
[JsonProperty("use")]
public string Use { get; set; }
[JsonProperty("n")]
public string Modulus { get; set; }
[JsonProperty("e")]
public string Exponent { get; set; }
[JsonProperty("d")]
public string D { get; set; }
[JsonProperty("p")]
public string P { get; set; }
[JsonProperty("q")]
public string Q { get; set; }
[JsonProperty("dp")]
public string DP { get; set; }
[JsonProperty("dq")]
public string DQ { get; set; }
[JsonProperty("qi")]
public string InverseQ { get; set; }
[JsonProperty("alg")]
public string Algorithm { get; set; }
}
private class Directory
{
[JsonProperty("keyChange")]
public Uri KeyChange { get; set; }
[JsonProperty("newNonce")]
public Uri NewNonce { get; set; }
[JsonProperty("newAccount")]
public Uri NewAccount { get; set; }
[JsonProperty("newOrder")]
public Uri NewOrder { get; set; }
[JsonProperty("revokeCert")]
public Uri RevokeCertificate { get; set; }
[JsonProperty("meta")]
public DirectoryMeta Meta { get; set; }
}
private class DirectoryMeta
{
[JsonProperty("termsOfService")]
public string TermsOfService { get; set; }
}
public class Problem
{
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("detail")]
public string Detail { get; set; }
public string RawJson { get; set; }
}
public class LetsEncrytException : Exception
{
public LetsEncrytException(Problem problem, HttpResponseMessage response)
: base($"{problem.Type}: {problem.Detail}")
{
Problem = problem;
Response = response;
}
public Problem Problem { get; }
public HttpResponseMessage Response { get; }
}
private class JwsMessage
{
[JsonProperty("header")]
public JwsHeader Header { get; set; }
[JsonProperty("protected")]
public string Protected { get; set; }
[JsonProperty("payload")]
public string Payload { get; set; }
[JsonProperty("signature")]
public string Signature { get; set; }
}
private class JwsHeader
{
public JwsHeader()
{
}
public JwsHeader(string algorithm, Jwk key)
{
Algorithm = algorithm;
Key = key;
}
[JsonProperty("alg")]
public string Algorithm { get; set; }
[JsonProperty("jwk")]
public Jwk Key { get; set; }
[JsonProperty("kid")]
public string KeyId { get; set; }
[JsonProperty("nonce")]
public string Nonce { get; set; }
[JsonProperty("url")]
public Uri Url { get; set; }
}
private interface IHasLocation
{
Uri Location { get; set; }
}
private class Order : IHasLocation
{
public Uri Location { get; set; }
[JsonProperty("status")]
public string Status { get; set; }
[JsonProperty("expires")]
public DateTime? Expires { get; set; }
[JsonProperty("identifiers")]
public OrderIdentifier[] Identifiers { get; set; }
[JsonProperty("notBefore")]
public DateTime? NotBefore { get; set; }
[JsonProperty("notAfter")]
public DateTime? NotAfter { get; set; }
[JsonProperty("error")]
public Problem Error { get; set; }
[JsonProperty("authorizations")]
public Uri[] Authorizations { get; set; }
[JsonProperty("finalize")]
public Uri Finalize { get; set; }
[JsonProperty("certificate")]
public Uri Certificate { get; set; }
}
private class OrderIdentifier
{
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("value")]
public string Value { get; set; }
}
private class Account : IHasLocation
{
[JsonProperty("termsOfServiceAgreed")]
public bool TermsOfServiceAgreed { get; set; }
[JsonProperty("contact")]
public string[] Contacts { get; set; }
[JsonProperty("status")]
public string Status { get; set; }
[JsonProperty("id")]
public string Id { get; set; }
[JsonProperty("createdAt")]
public DateTime CreatedAt { get; set; }
[JsonProperty("key")]
public Jwk Key { get; set; }
[JsonProperty("initialIp")]
public string InitialIp { get; set; }
[JsonProperty("orders")]
public Uri Orders { get; set; }
public Uri Location { get; set; }
}
private class FinalizeRequest
{
[JsonProperty("csr")]
public string CSR { get; set; }
}
private class Jws
{
private readonly Jwk _jwk;
private readonly RSA _rsa;
public Jws(RSA rsa, string keyId)
{
_rsa = rsa ?? throw new ArgumentNullException(nameof(rsa));
var publicParameters = rsa.ExportParameters(false);
_jwk = new Jwk
{
KeyType = "RSA",
Exponent = Base64UrlEncoded(publicParameters.Exponent),
Modulus = Base64UrlEncoded(publicParameters.Modulus),
KeyId = keyId
};
}
public JwsMessage Encode<TPayload>(TPayload payload, JwsHeader protectedHeader)
{
protectedHeader.Algorithm = "RS256";
if (_jwk.KeyId != null)
{
protectedHeader.KeyId = _jwk.KeyId;
}
else
{
protectedHeader.Key = _jwk;
}
var message = new JwsMessage
{
Payload = Base64UrlEncoded(JsonConvert.SerializeObject(payload)),
Protected = Base64UrlEncoded(JsonConvert.SerializeObject(protectedHeader))
};
message.Signature = Base64UrlEncoded(
_rsa.SignData(Encoding.ASCII.GetBytes(message.Protected + "." + message.Payload),
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1));
return message;
}
private string GetSha256Thumbprint()
{
var json = "{\"e\":\"" + _jwk.Exponent + "\",\"kty\":\"RSA\",\"n\":\"" + _jwk.Modulus + "\"}";
using (var sha256 = SHA256.Create())
{
return Base64UrlEncoded(sha256.ComputeHash(Encoding.UTF8.GetBytes(json)));
}
}
public string GetKeyAuthorization(string token)
{
return token + "." + GetSha256Thumbprint();
}
public static string Base64UrlEncoded(string s)
{
return Base64UrlEncoded(Encoding.UTF8.GetBytes(s));
}
public static string Base64UrlEncoded(byte[] arg)
{
var s = Convert.ToBase64String(arg); // Regular base64 encoder
s = s.Split('=')[0]; // Remove any trailing '='s
s = s.Replace('+', '-'); // 62nd char of encoding
s = s.Replace('/', '_'); // 63rd char of encoding
return s;
}
internal void SetKeyId(Account account)
{
_jwk.KeyId = account.Id;
}
}
}
}
@troyfulton

This comment has been minimized.

Show comment
Hide comment
@troyfulton

troyfulton Feb 6, 2018

Hello, i'm trying to use this in Visual Studio Web Site targeting .net 4.7 and i'm getting lots of errors....however, if I use this class in a .net core project everything works great...however, I really need to use this in a VS Web Site project...do you have a version that works in VS Web Site project?

troyfulton commented Feb 6, 2018

Hello, i'm trying to use this in Visual Studio Web Site targeting .net 4.7 and i'm getting lots of errors....however, if I use this class in a .net core project everything works great...however, I really need to use this in a VS Web Site project...do you have a version that works in VS Web Site project?

@iamsunny

This comment has been minimized.

Show comment
Hide comment
@iamsunny

iamsunny May 18, 2018

many thanks for sharing this. I was looking for exactly something like this. Can you please add some references to any demo or Documentation on How to use it?

The sequence I assume is:

  • Init
  • NewOrder
  • CompleteChallenge
  • GetCertificate

am I getting it right?

iamsunny commented May 18, 2018

many thanks for sharing this. I was looking for exactly something like this. Can you please add some references to any demo or Documentation on How to use it?

The sequence I assume is:

  • Init
  • NewOrder
  • CompleteChallenge
  • GetCertificate

am I getting it right?

@mgamache

This comment has been minimized.

Show comment
Hide comment
@mgamache

mgamache Sep 14, 2018

Hello, i'm trying to use this in Visual Studio Web Site targeting .net 4.7 and i'm getting lots of errors....however, if I use this class in a .net core project everything works great...however, I really need to use this in a VS Web Site project...do you have a version that works in VS Web Site project?
Make sure you've installed:

Newtonsoft.Json using NuGet

mgamache commented Sep 14, 2018

Hello, i'm trying to use this in Visual Studio Web Site targeting .net 4.7 and i'm getting lots of errors....however, if I use this class in a .net core project everything works great...however, I really need to use this in a VS Web Site project...do you have a version that works in VS Web Site project?
Make sure you've installed:

Newtonsoft.Json using NuGet

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment