Created
January 11, 2018 22:26
-
-
Save ayende/c2bb440bb448dc290132956c6a9fff3b to your computer and use it in GitHub Desktop.
ACME v2 client for Let's Encrypt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} | |
} | |
} |
Hi Ayende,
we are looking for a similar solution and found your code here. But, when we tried the code, we are getting header was not found error message when we try to call the Init method. Please help us resolve the issue.
What is the actual error you are getting?
…On Sun, Jun 9, 2019 at 9:35 PM drksvenkatesh ***@***.***> wrote:
Hi Ayende,
we are looking for a similar solution and found your code here. But, when
we tried the code, we are getting header was not found error message when
we try to call the Init method. Please help us resolve the issue.
—
You are receiving this because you authored the thread.
Reply to this email directly, view it on GitHub
<https://gist.github.com/c2bb440bb448dc290132956c6a9fff3b?email_source=notifications&email_token=AAA4RM7QVTXG76SNBADCB6DPZVEQTA5CNFSM4HWMMUA2YY3PNVWWK3TUL52HS4DFVNDWS43UINXW23LFNZ2KUY3PNVWWK3TUL5UWJTQAFTMNQ#gistcomment-2939096>,
or mute the thread
<https://github.com/notifications/unsubscribe-auth/AAA4RM5T2OTXBHZJFF2YEX3PZVEQTANCNFSM4HWMMUAQ>
.
--
<https://ravendb.net/> *Oren Eini*
*CEO / Hibernating Rhinos LTD <https://hibernatingrhinos.com/>*
Mobile: +972-52-548-6969
Sales: [email protected]
Skype: ayenderahien
Support: [email protected]
request.Content = new StringContent(json, Encoding.UTF8, "
application/jose+json")
{
Headers =
{
ContentType =
{
CharSet = string.Empty
}
}
};
…On Wed, Jun 26, 2019 at 7:38 PM Maks-IT.com ***@***.***> wrote:
Hello. I confirm the same issue with, while I'm trying to make Init with
my email. Inside your code you're using just "application/json", but
according to the documentation i found
https://tools.ietf.org/html/draft-ietf-acme-acme-09 it's necessary to use
"application/jose+json" with Post requests. In addition, always according
to this guide I'm making NewNonce request just before NewAccount request:
//New Nonce request
_nonce = (await _client.SendAsync(new HttpRequestMessage(HttpMethod.Get,
_directory.NewNonce)).ConfigureAwait(false)).Headers.GetValues("Replay-Nonce").First();
Unfortunatelly even with "application/jose+json" Content type I get
"Unsupported Media Type" 415 response.
I've set breakpoints on lines 121 and 166, according to the code you have
published here.
Kind regards,
Max
—
You are receiving this because you authored the thread.
Reply to this email directly, view it on GitHub
<https://gist.github.com/c2bb440bb448dc290132956c6a9fff3b?email_source=notifications&email_token=AAA4RM4CA7AJPXFDHX72D23P4OLR5A5CNFSM4HWMMUA2YY3PNVWWK3TUL52HS4DFVNDWS43UINXW23LFNZ2KUY3PNVWWK3TUL5UWJTQAFUK2S#gistcomment-2954665>,
or mute the thread
<https://github.com/notifications/unsubscribe-auth/AAA4RM4KUM3KVWVCIHCJE2LP4OLR5ANCNFSM4HWMMUAQ>
.
--
<https://ravendb.net/> *Oren Eini*
*CEO / Hibernating Rhinos LTD <https://hibernatingrhinos.com/>*
Mobile: +972-52-548-6969
Sales: [email protected]
Skype: ayenderahien
Support: [email protected]
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I am trying to call Init method and getting following error. Please suggest: