Add project files.

This commit is contained in:
Maksym Sadovnychyy 2019-06-29 18:07:02 +02:00
parent 5c31b7f5dc
commit 75de1897d8
27 changed files with 1589 additions and 0 deletions

25
LetsEncrypt.sln Normal file
View File

@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
VisualStudioVersion = 15.0.28307.572
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LetsEncrypt", "LetsEncrypt/LetsEncrypt.csproj", "{7DE431E5-889C-434E-AD02-9F89D7A0ED27}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7DE431E5-889C-434E-AD02-9F89D7A0ED27}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {B78BD325-B2C1-456C-8EA8-42F9B89E0351}
EndGlobalSection
EndGlobal

27
LetsEncrypt/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,27 @@
{
// Use IntelliSense to find out which attributes exist for C# debugging
// Use hover for the description of the existing attributes
// For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
"version": "0.2.0",
"configurations": [
{
"name": ".NET Core Launch (console)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/bin/Debug/netcoreapp2.2/LetsEncrypt.dll",
"args": [],
"cwd": "${workspaceFolder}",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
"console": "internalConsole",
"stopAtEntry": false
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach",
"processId": "${command:pickProcess}"
}
]
}

36
LetsEncrypt/.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,36 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/LetsEncrypt.csproj"
],
"problemMatcher": "$tsc"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/LetsEncrypt.csproj"
],
"problemMatcher": "$tsc"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"${workspaceFolder}/LetsEncrypt.csproj"
],
"problemMatcher": "$tsc"
}
]
}

View File

@ -0,0 +1,58 @@
/*
* https://tools.ietf.org/html/draft-ietf-acme-acme-18#section-7.3
*/
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 ACMEv2
{
public class Account : IHasLocation
{
[JsonProperty("termsOfServiceAgreed")]
public bool TermsOfServiceAgreed { get; set; }
/*
onlyReturnExisting (optional, boolean): If this field is present
with the value "true", then the server MUST NOT create a new
account if one does not already exist. This allows a client to
look up an account URL based on an account key
*/
[JsonProperty("onlyReturnExisting")]
public bool OnlyReturnExisting { 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; }
}
}

View File

@ -0,0 +1,32 @@
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 ACMEv2
{
public class AuthorizationChallenge
{
[JsonProperty("url")]
public Uri Url { get; set; }
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("status")]
public string Status { get; set; }
[JsonProperty("token")]
public string Token { get; set; }
}
}

View File

@ -0,0 +1,35 @@
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 ACMEv2
{
public 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; }
}
}

View File

@ -0,0 +1,27 @@
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 ACMEv2
{
public class AuthorizeChallenge
{
[JsonProperty("keyAuthorization")]
public string KeyAuthorization { get; set; }
}
}

View File

@ -0,0 +1,22 @@
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 ACMEv2
{
public class CachedCertificateResult
{
public RSA PrivateKey;
public string Certificate;
}
}

View File

@ -0,0 +1,24 @@
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 ACMEv2
{
public class CertificateCache
{
public string Cert;
public byte[] Private;
}
}

View File

@ -0,0 +1,37 @@
using System;
using Newtonsoft.Json;
namespace ACMEv2
{
public class Directory
{
//New nonce
[JsonProperty("newNonce")]
public Uri NewNonce { get; set; }
//New account
[JsonProperty("newAccount")]
public Uri NewAccount { get; set; }
//New order
[JsonProperty("newOrder")]
public Uri NewOrder { get; set; }
// New authorization If the ACME server does not implement pre-authorization
// (Section 7.4.1) it MUST omit the "newAuthz" field of the directory.
// [JsonProperty("newAuthz")]
// public Uri NewAuthz { get; set; }
//Revoke certificate
[JsonProperty("revokeCert")]
public Uri RevokeCertificate { get; set; }
//Key change
[JsonProperty("keyChange")]
public Uri KeyChange { get; set; }
//Metadata object
[JsonProperty("meta")]
public DirectoryMeta Meta { get; set; }
}
}

View File

@ -0,0 +1,23 @@
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 ACMEv2
{
public class DirectoryMeta
{
[JsonProperty("termsOfService")]
public string TermsOfService { get; set; }
}
}

View File

@ -0,0 +1,22 @@
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 ACMEv2
{
public class FinalizeRequest
{
[JsonProperty("csr")]
public string CSR { get; set; }
}
}

View File

@ -0,0 +1,20 @@
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 ACMEv2
{
interface IHasLocation
{
Uri Location { get; set; }
}
}

98
LetsEncrypt/ACMEv2/Jwk.cs Normal file
View File

@ -0,0 +1,98 @@
/*
* JSON Web Key (JWK)
* https://tools.ietf.org/html/rfc7517
* https://www.gnupg.org/documentation/manuals/gcrypt-devel/RSA-key-parameters.html
*
*/
using Newtonsoft.Json;
namespace ACMEv2
{
public class Jwk
{
/// <summary>
/// "kty" (Key Type) Parameter
/// <para>
/// The "kty" (key type) parameter identifies the cryptographic algorithm
/// family used with the key, such as "RSA" or "EC".
/// </para>
/// </summary>
[JsonProperty("kty")]
public string KeyType { get; set; }
/// <summary>
/// "kid" (Key ID) Parameter
/// <para>
/// The "kid" (key ID) parameter is used to match a specific key. This
/// is used, for instance, to choose among a set of keys within a JWK Set
/// during key rollover. The structure of the "kid" value is
/// unspecified.
/// </para>
/// </summary>
[JsonProperty("kid")]
public string KeyId { get; set; }
/// <summary>
/// "use" (Public Key Use) Parameter
/// <para>
/// The "use" (public key use) parameter identifies the intended use of
/// the public key. The "use" parameter is employed to indicate whether
/// a public key is used for encrypting data or verifying the signature
/// on data.
/// </para>
/// </summary>
[JsonProperty("use")]
public string Use { get; set; }
/// <summary>
/// RSA public modulus n.
/// </summary>
[JsonProperty("n")]
public string Modulus { get; set; }
/// <summary>
/// RSA public exponent e.
/// </summary>
[JsonProperty("e")]
public string Exponent { get; set; }
/// <summary>
/// RSA secret exponent d = e^-1 \bmod (p-1)(q-1).
/// </summary>
[JsonProperty("d")]
public string D { get; set; }
/// <summary>
/// RSA secret prime p.
/// </summary>
[JsonProperty("p")]
public string P { get; set; }
/// <summary>
/// RSA secret prime q with p < q.
/// </summary>
[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; }
/// <summary>
/// "alg" (Algorithm) Parameter
/// <para>
/// The "alg" (algorithm) parameter identifies the algorithm intended for
/// use with the key.
/// </para>
/// </summary>
[JsonProperty("alg")]
public string Algorithm { get; set; }
}
}

89
LetsEncrypt/ACMEv2/Jws.cs Normal file
View File

@ -0,0 +1,89 @@
using System;
using System.Security.Cryptography;
using System.Text;
using Newtonsoft.Json;
namespace ACMEv2
{
public class Jws
{
public 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;
}
}
}

View File

@ -0,0 +1,50 @@
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 ACMEv2
{
public 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; }
[JsonProperty("Host")]
public string Host { get; set; }
}
}

View File

@ -0,0 +1,33 @@
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 ACMEv2
{
public 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; }
}
}

View File

@ -0,0 +1,542 @@
/*
* Author: Maksym Sadovnychyy
* Updated according https://tools.ietf.org/html/draft-ietf-acme-acme-18
*/
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 ACMEv2
{
public class LetsEncryptClient
{
public const string StagingV2 = "https://acme-staging-v02.api.letsencrypt.org/directory";
public const string ProductionV2 = "https://acme-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 readonly string _home;
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;
/// <summary>
/// Let's encrypt client object
/// </summary>
/// <param name="url"></param>
public LetsEncryptClient(string url, string home)
{
_url = url ?? throw new ArgumentNullException(nameof(url));
var hash = SHA256.Create().ComputeHash(Encoding.UTF8.GetBytes(url));
_home = home ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData,
Environment.SpecialFolderOption.Create);
var file = Jws.Base64UrlEncoded(hash) + ".lets-encrypt.cache.json";
_path = Path.Combine(_home, file);
}
/// <summary>
/// Account creation or Initialization from cache
/// </summary>
/// <param name="contacts"></param>
/// <param name="token"></param>
/// <returns></returns>
public async Task Init(string[] contacts, CancellationToken token = default(CancellationToken))
{
_accountKey = new RSACryptoServiceProvider(4096);
_client = GetCachedClient(_url);
// 1 - Get directory
(_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;
}
}
await NewNonce();
//New Account request
_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 = contacts.Select(contact =>
string.Format("mailto:{0}", contact)
).ToArray()
}, 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));
}
}
/// <summary>
/// Just retrive terms of service
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
public string GetTermsOfServiceUri(CancellationToken token = default(CancellationToken))
{
return _directory.Meta.TermsOfService;
}
/// <summary>
/// Request New Nonce to be able to start POST requests
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
public async Task NewNonce(CancellationToken token = default(CancellationToken))
{
_nonce = (await _client.SendAsync(new HttpRequestMessage(HttpMethod.Head, _directory.NewNonce)).ConfigureAwait(false)).Headers.GetValues("Replay-Nonce").First();
}
/// <summary>
/// Create new Certificate Order. In case you want the wildcard-certificate you must select dns-01 challange.
/// <para>
/// Available challange types:
/// <list type="number">
/// <item>dns-01</item>
/// <item>http-01</item>
/// <item>tls-alpn-01</item>
/// </list>
/// </para>
/// </summary>
/// <param name="hostnames"></param>
/// <param name="challengeType"></param>
/// <param name="token"></param>
/// <returns></returns>
public async Task<Dictionary<string, string>> NewOrder(string[] hostnames, string challengeType, CancellationToken token = default(CancellationToken))
{
_challenges.Clear();
//update jws with account url
_jws = new Jws(_accountKey, _cache.Location.ToString());
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 == challengeType);
_challenges.Add(challenge);
var keyToken = _jws.GetKeyAuthorization(challenge.Token);
switch (challengeType) {
/*
* A client fulfills this challenge by constructing a key authorization
* from the "token" value provided in the challenge and the client's
* account key. The client then computes the SHA-256 digest [FIPS180-4]
* of the key authorization.
*
* The record provisioned to the DNS contains the base64url encoding of
* this digest.
*/
case "dns-01": {
using (var sha256 = SHA256.Create())
{
var dnsToken = Jws.Base64UrlEncoded(sha256.ComputeHash(Encoding.UTF8.GetBytes(keyToken)));
results[challengeResponse.Identifier.Value] = dnsToken;
}
break;
}
/*
* A client fulfills this challenge by constructing a key authorization
* from the "token" value provided in the challenge and the client's
* account key. The client then provisions the key authorization as a
* resource on the HTTP server for the domain in question.
*
* The path at which the resource is provisioned is comprised of the
* fixed prefix "/.well-known/acme-challenge/", followed by the "token"
* value in the challenge. The value of the resource MUST be the ASCII
* representation of the key authorization.
*/
case "http-01": {
results[challengeResponse.Identifier.Value] = challenge.Token + "~" + keyToken;
break;
}
}
}
return results;
}
public async Task CompleteChallenges(CancellationToken token = default(CancellationToken))
{
_jws = new Jws(_accountKey, _cache.Location.ToString());
for (var index = 0; index < _challenges.Count; index++)
{
var challenge = _challenges[index];
while (true)
{
AuthorizeChallenge authorizeChallenge = new AuthorizeChallenge();
switch (challenge.Type) {
case "dns-01": {
authorizeChallenge.KeyAuthorization = _jws.GetKeyAuthorization(challenge.Token);
break;
}
case "http-01": {
break;
}
}
var (result, responseText) = await SendAsync<AuthorizationChallengeResponse>(HttpMethod.Post, challenge.Url, authorizeChallenge, 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 GetOrder(string[] hostnames, CancellationToken token = default(CancellationToken))
{
//update jws
_jws = new Jws(_accountKey, _cache.Location.ToString());
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);
_currentOrder = order;
}
/// <summary>
///
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
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);
}
/// <summary>
///
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
public async Task KeyChange(CancellationToken token = default(CancellationToken))
{
}
/// <summary>
///
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
public async Task RevokeCertificate(CancellationToken token = default(CancellationToken))
{
}
/// <summary>
/// Main method used to send data to LetsEncrypt
/// </summary>
/// <typeparam name="TResult"></typeparam>
/// <param name="method"></param>
/// <param name="uri"></param>
/// <param name="message"></param>
/// <param name="token"></param>
/// <returns></returns>
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)
{
JwsMessage encodedMessage = _jws.Encode(message, new JwsHeader
{
Nonce = _nonce,
Url = uri,
});
var json = JsonConvert.SerializeObject(encodedMessage, jsonSettings);
request.Content = new StringContent(json);
var requestType = "application/json";
if (method == HttpMethod.Post)
requestType = "application/jose+json";
request.Content.Headers.Remove("Content-Type");
request.Content.Headers.Add("Content-Type", requestType);
}
var response = await _client.SendAsync(request, token).ConfigureAwait(false);
if (method == HttpMethod.Post)
_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);
}
/// <summary>
///
/// </summary>
/// <param name="hosts"></param>
/// <param name="value"></param>
/// <returns></returns>
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;
}
/// <summary>
///
/// </summary>
/// <param name="hostsToRemove"></param>
public void ResetCachedCertificate(IEnumerable<string> hostsToRemove)
{
foreach (var host in hostsToRemove)
{
_cache.CachedCerts.Remove(host);
}
}
}
}

View File

@ -0,0 +1,29 @@
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 ACMEv2
{
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; }
}
}

View File

@ -0,0 +1,49 @@
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 ACMEv2
{
public 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; }
}
}

View File

@ -0,0 +1,26 @@
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 ACMEv2
{
public class OrderIdentifier
{
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("value")]
public string Value { get; set; }
}
}

View File

@ -0,0 +1,27 @@
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 ACMEv2
{
public class Problem
{
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("detail")]
public string Detail { get; set; }
public string RawJson { get; set; }
}
}

View File

@ -0,0 +1,27 @@
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 ACMEv2
{
public 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;
}
}

View File

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.2</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
</ItemGroup>
</Project>

26
LetsEncrypt/Library.cs Normal file
View File

@ -0,0 +1,26 @@
using System;
using System.Security.Cryptography.X509Certificates;
using System.Text;
namespace LetsEncrypt
{
class Library
{
/// <summary>
/// Export a certificate to a PEM format string
/// </summary>
/// <param name="cert">The certificate to export</param>
/// <returns>A PEM encoded string</returns>
public static string ExportToPEM(X509Certificate cert)
{
StringBuilder builder = new StringBuilder();
builder.AppendLine("-----BEGIN CERTIFICATE-----");
builder.AppendLine(Convert.ToBase64String(cert.Export(X509ContentType.Cert), Base64FormattingOptions.InsertLineBreaks));
builder.AppendLine("-----END CERTIFICATE-----");
return builder.ToString();
}
}
}

110
LetsEncrypt/Program.cs Normal file
View File

@ -0,0 +1,110 @@
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;
using ACMEv2;
using FS = System.IO;
namespace LetsEncrypt
{
class Program
{
static void Main(string[] args)
{
// save to http://<YOUR_DOMAIN>/.well-known/acme-challenge/<TOKEN>
var tokensPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ".well-known/acme-challenge");
if (!FS.Directory.Exists(tokensPath))
FS.Directory.CreateDirectory(tokensPath);
foreach (FileInfo file in new DirectoryInfo(tokensPath).GetFiles())
file.Delete();
var certsPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "certs");
if (!FS.Directory.Exists(certsPath))
FS.Directory.CreateDirectory(certsPath);
List<string> contacts = new List<string>();
contacts.Add("maksym.sadovnychyy@gmail.com");
List<string> hosts = new List<string>();
hosts.Add("maks-it.com");
hosts.Add("www.maks-it.com");
Console.WriteLine("Let's Encrypt C# .Net Core Client");
try
{
LetsEncryptClient client = new LetsEncryptClient(LetsEncryptClient.StagingV2, AppDomain.CurrentDomain.BaseDirectory);
Console.WriteLine("1. Client Initialization...");
// 1
client.Init(contacts.ToArray()).Wait();
Console.WriteLine(string.Format("Terms of service: {0}",client.GetTermsOfServiceUri()));
client.NewNonce().Wait();
// 2
try
{
Console.WriteLine("2. Client New Order...");
Task<Dictionary<string, string>> orders = client.NewOrder(hosts.ToArray(), "http-01");
orders.Wait();
foreach (var result in orders.Result)
{
Console.WriteLine("Key: " + result.Key + Environment.NewLine + "Value: " + result.Value);
string[] splitToken = result.Value.Split('~');
File.WriteAllText(FS.Path.Combine(tokensPath, splitToken[0]), splitToken[1]);
}
// 3
Console.WriteLine("3. Client Complete Challange...");
client.CompleteChallenges().Wait();
Console.WriteLine("Challanges comleted.");
}
catch (Exception ex) {
Console.WriteLine(ex.Message.ToString());
client.GetOrder(hosts.ToArray()).Wait();
}
// 4 Download certificate
Console.WriteLine("4. Download certificate...");
Task<(X509Certificate2 Cert, RSA PrivateKey)> certificate = client.GetCertificate();
certificate.Wait();
File.WriteAllText(Path.Combine(certsPath, "maks-it.com.crt"), Library.ExportToPEM(certificate.Result.Cert));
Console.WriteLine("Certificate saved.");
}
catch (Exception ex) {
Console.WriteLine(ex.Message.ToString());
}
Console.Read();
}
}
}

83
LetsEncrypt/README.md Normal file
View File

@ -0,0 +1,83 @@
#ACMEv2 Client library
https://tools.ietf.org/html/draft-ietf-acme-acme-18
The following diagram illustrates the relations between resources on
an ACME server. For the most part, these relations are expressed by
URLs provided as strings in the resources' JSON representations.
Lines with labels in quotes indicate HTTP link relations.
directory
|
+--> new-nonce
|
+----------+----------+-----+-----+------------+
| | | | |
| | | | |
V V V V V
newAccount newAuthz newOrder revokeCert keyChange
| | |
| | |
V | V
account | order -----> cert
| |
| |
| V
+------> authz
| ^
| | "up"
V |
challenge
+-------------------+--------------------------------+--------------+
| Action | Request | Response |
+-------------------+--------------------------------+--------------+
| Get directory | GET directory | 200 |
| | | |
| Get nonce | HEAD newNonce | 200 |
| | | |
| Create account | POST newAccount | 201 -> |
| | | account |
| | | |
| Submit order | POST newOrder | 201 -> order |
| | | |
| Fetch challenges | POST-as-GET order's | 200 |
| | authorization urls | |
| | | |
| Respond to | POST authorization challenge | 200 |
| challenges | urls | |
| | | |
| Poll for status | POST-as-GET order | 200 |
| | | |
| Finalize order | POST order's finalize url | 200 |
| | | |
| Poll for status | POST-as-GET order | 200 |
| | | |
| Download | POST-as-GET order's | 200 |
| certificate | certificate url | |
+-------------------+--------------------------------+--------------+
pending
|
| Receive
| response
V
processing <-+
| | | Server retry or
| | | client retry request
| +----+
|
|
Successful | Failed
validation | validation
+---------+---------+
| |
V V
valid invalid