(refactor): codebase update

This commit is contained in:
Maksym Sadovnychyy 2023-07-27 00:02:24 +02:00
parent 9cf7ec9012
commit 6aae71b7ac
81 changed files with 1768 additions and 3581 deletions

14
src/Core/Core.csproj Normal file
View File

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>MaksIT.$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Folder Include="Abstractions\" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,37 @@
using System.Text.Json.Serialization;
using System.Text.Json;
namespace MaksIT.Core.Extensions {
public static class ObjectExtensions {
/// <summary>
///
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="obj"></param>
/// <returns></returns>
public static string ToJson<T>(this T? obj) => obj.ToJson(null);
/// <summary>
///
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="obj"></param>
/// <param name="converters"></param>
/// <returns></returns>
public static string ToJson<T>(this T? obj, List<JsonConverter>? converters) {
if (obj == null)
return "{}";
var options = new JsonSerializerOptions {
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true
};
converters?.ForEach(x => options.Converters.Add(x));
return JsonSerializer.Serialize(obj, options);
}
}
}

View File

@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Text.Json;
using System.Threading.Tasks;
namespace MaksIT.Core.Extensions {
public static class StringExtensions {
/// <summary>
/// Converts JSON string to object
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="s"></param>
/// <returns></returns>
public static T? ToObject<T>(this string? s) => ToObjectCore<T>(s, null);
/// <summary>
///
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="s"></param>
/// <param name="converters"></param>
/// <returns></returns>
public static T? ToObject<T>(this string? s, List<JsonConverter> converters) => ToObjectCore<T>(s, converters);
private static T? ToObjectCore<T>(string? s, List<JsonConverter>? converters) {
var options = new JsonSerializerOptions {
PropertyNameCaseInsensitive = true
};
converters?.ForEach(x => options.Converters.Add(x));
return s != null
? JsonSerializer.Deserialize<T>(s, options)
: default;
}
}
}

View File

@ -0,0 +1,14 @@
using System.Runtime.InteropServices;
namespace MaksIT.Core {
public static class OperatingSystem {
public static bool IsWindows() =>
RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
public static bool IsMacOS() =>
RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
public static bool IsLinux() =>
RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
}
}

37
src/LetsEncrypt.sln Normal file
View File

@ -0,0 +1,37 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.6.33815.320
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LetsEncrypt", "LetsEncrypt\LetsEncrypt.csproj", "{7DE431E5-889C-434E-AD02-9F89D7A0ED27}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LetsEncryptConsole", "LetsEncryptConsole\LetsEncryptConsole.csproj", "{2E4BE41E-E442-4CB8-824E-9888FFAA1BEF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "Core\Core.csproj", "{27A58A5F-B52A-44F2-9639-84C6F02EA75D}"
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
{2E4BE41E-E442-4CB8-824E-9888FFAA1BEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2E4BE41E-E442-4CB8-824E-9888FFAA1BEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2E4BE41E-E442-4CB8-824E-9888FFAA1BEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2E4BE41E-E442-4CB8-824E-9888FFAA1BEF}.Release|Any CPU.Build.0 = Release|Any CPU
{27A58A5F-B52A-44F2-9639-84C6F02EA75D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{27A58A5F-B52A-44F2-9639-84C6F02EA75D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{27A58A5F-B52A-44F2-9639-84C6F02EA75D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{27A58A5F-B52A-44F2-9639-84C6F02EA75D}.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

View File

@ -1,10 +1,8 @@
// https://tools.ietf.org/html/rfc7517
using System;
using Newtonsoft.Json;
using System.Text.Json.Serialization;
namespace LetsEncrypt.Entities
namespace MaksIT.LetsEncrypt.Entities.Jws
{
public class Jwk
{
@ -15,8 +13,8 @@ namespace LetsEncrypt.Entities
/// family used with the key, such as "RSA" or "EC".
/// </para>
/// </summary>
[JsonProperty("kty")]
public string KeyType { get; set; }
[JsonPropertyName("kty")]
public string? KeyType { get; set; }
/// <summary>
/// "kid" (Key ID) Parameter
@ -27,8 +25,8 @@ namespace LetsEncrypt.Entities
/// unspecified.
/// </para>
/// </summary>
[JsonProperty("kid")]
public string KeyId { get; set; }
[JsonPropertyName("kid")]
public string? KeyId { get; set; }
/// <summary>
/// "use" (Public Key Use) Parameter
@ -39,62 +37,62 @@ namespace LetsEncrypt.Entities
/// on data.
/// </para>
/// </summary>
[JsonProperty("use")]
public string Use { get; set; }
[JsonPropertyName("use")]
public string? Use { get; set; }
/// <summary>
/// The the modulus value for the public RSA key. It is represented as the Base64URL encoding of value's big endian representation.
/// </summary>
[JsonProperty("n")]
public string Modulus { get; set; }
[JsonPropertyName("n")]
public string? Modulus { get; set; }
/// <summary>
/// The exponent value for the public RSA key. It is represented as the Base64URL encoding of value's big endian representation.
/// </summary>
[JsonProperty("e")]
public string Exponent { get; set; }
[JsonPropertyName("e")]
public string? Exponent { get; set; }
/// <summary>
/// The private exponent. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("d")]
public string D { get; set; }
[JsonPropertyName("d")]
public string? D { get; set; }
/// <summary>
/// The first prime factor. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("p")]
public string P { get; set; }
[JsonPropertyName("p")]
public string? P { get; set; }
/// <summary>
/// The second prime factor. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("q")]
public string Q { get; set; }
[JsonPropertyName("q")]
public string? Q { get; set; }
/// <summary>
/// The first factor Chinese Remainder Theorem exponent. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("dp")]
public string DP { get; set; }
[JsonPropertyName("dp")]
public string? DP { get; set; }
/// <summary>
/// The second factor Chinese Remainder Theorem exponent. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("dq")]
public string DQ { get; set; }
[JsonPropertyName("dq")]
public string? DQ { get; set; }
/// <summary>
/// The first Chinese Remainder Theorem coefficient. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("qi")]
public string InverseQ { get; set; }
[JsonPropertyName("qi")]
public string? InverseQ { get; set; }
/// <summary>
/// The other primes information, should they exist, null or an empty list if not specified.
/// </summary>
[JsonProperty("oth")]
public string OthInf { get; set; }
[JsonPropertyName("oth")]
public string? OthInf { get; set; }
/// <summary>
/// "alg" (Algorithm) Parameter
@ -103,7 +101,7 @@ namespace LetsEncrypt.Entities
/// use with the key.
/// </para>
/// </summary>
[JsonProperty("alg")]
public string Algorithm { get; set; }
[JsonPropertyName("alg")]
public string? Algorithm { get; set; }
}
}

View File

@ -0,0 +1,40 @@
using System;
using System.Text.Json.Serialization;
namespace MaksIT.LetsEncrypt.Entities.Jws
{
public class JwsMessage {
public string? Protected { get; set; }
public string? Payload { get; set; }
public string? Signature { get; set; }
}
public class JwsHeader {
[JsonPropertyName("alg")]
public string? Algorithm { get; set; }
[JsonPropertyName("jwk")]
public Jwk? Key { get; set; }
[JsonPropertyName("kid")]
public string? KeyId { get; set; }
public string? Nonce { get; set; }
public Uri? Url { get; set; }
[JsonPropertyName("Host")]
public string? Host { get; set; }
}
}

View File

@ -0,0 +1,13 @@
using System;
namespace MaksIT.LetsEncrypt.Entities {
public class AuthorizationChallenge {
public Uri? Url { get; set; }
public string? Type { get; set; }
public string? Status { get; set; }
public string? Token { get; set; }
}
}

View File

@ -0,0 +1,11 @@
using System.Security.Cryptography;
namespace MaksIT.LetsEncrypt.Entities
{
public class CachedCertificateResult
{
public RSACryptoServiceProvider? PrivateKey { get; set; }
public string? Certificate { get; set; }
}
}

View File

@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using MaksIT.LetsEncrypt.Entities.Jws;
namespace MaksIT.LetsEncrypt.Entities {
public class CertificateCache {
public string? Cert { get; set; }
public byte[]? Private { get; set; }
}
public class RegistrationCache {
public Dictionary<string, CertificateCache>? CachedCerts { get; set; }
public byte[]? AccountKey { get; set; }
public string? Id { get; set; }
public Jwk? Key { get; set; }
public Uri? Location { get; set; }
}
}

View File

@ -0,0 +1,25 @@
using System;
using System.Net.Http;
namespace MaksIT.LetsEncrypt.Exceptions {
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; }
}
public class Problem {
public string Type { get; set; }
public string Detail { get; set; }
public string RawJson { get; set; }
}
}

View File

@ -0,0 +1,13 @@
using Microsoft.Extensions.DependencyInjection;
using MaksIT.LetsEncrypt.Services;
namespace MaksIT.LetsEncrypt.Extensions {
public static class ServiceCollectionExtensions {
public static void RegisterLetsEncrypt(this IServiceCollection services) {
services.AddHttpClient<ILetsEncryptService, LetsEncryptService>();
services.AddSingleton<IJwsService, JwsService>();
}
}
}

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>MaksIT.$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,5 @@
namespace MaksIT.LetsEncrypt.Models.Interfaces {
public interface IHasLocation {
Uri? Location { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace MaksIT.LetsEncrypt.Models.Requests
{
public class FinalizeRequest
{
public string? Csr { get; set; }
}
}

View File

@ -0,0 +1,38 @@
using MaksIT.LetsEncrypt.Entities.Jws;
using MaksIT.LetsEncrypt.Models.Interfaces;
/*
* https://tools.ietf.org/html/draft-ietf-acme-acme-18#section-7.3
*/
namespace MaksIT.LetsEncrypt.Models.Responses
{
public class Account : IHasLocation {
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
*/
public bool OnlyReturnExisting { get; set; }
public string[]? Contacts { get; set; }
public string? Status { get; set; }
public string? Id { get; set; }
public DateTime CreatedAt { get; set; }
public Jwk? Key { get; set; }
public string? InitialIp { get; set; }
public Uri? Orders { get; set; }
public Uri? Location { get; set; }
}
}

View File

@ -1,43 +1,29 @@
using System;
using Newtonsoft.Json;
namespace ACMEv2
namespace MaksIT.LetsEncrypt.Models.Responses
{
public class AcmeDirectory
{
//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 AcmeDirectoryMeta Meta { get; set; }
}
public class AcmeDirectoryMeta
{
[JsonProperty("termsOfService")]
public string TermsOfService { get; set; }
}
}

View File

@ -0,0 +1,20 @@

using MaksIT.LetsEncrypt.Entities;
namespace MaksIT.LetsEncrypt.Models.Responses {
public class AuthorizationChallengeResponse {
public OrderIdentifier? Identifier { get; set; }
public string? Status { get; set; }
public DateTime? Expires { get; set; }
public bool Wildcard { get; set; }
public AuthorizationChallenge[]? Challenges { get; set; }
}
public class AuthorizeChallenge {
public string? KeyAuthorization { get; set; }
}
}

View File

@ -0,0 +1,34 @@
using MaksIT.LetsEncrypt.Exceptions;
using MaksIT.LetsEncrypt.Models.Interfaces;
namespace MaksIT.LetsEncrypt.Models.Responses {
public class OrderIdentifier {
public string? Type { get; set; }
public string? Value { get; set; }
}
public class Order : IHasLocation {
public Uri? Location { get; set; }
public string? Status { get; set; }
public DateTime? Expires { get; set; }
public OrderIdentifier[]? Identifiers { get; set; }
public DateTime? NotBefore { get; set; }
public DateTime? NotAfter { get; set; }
public Problem? Error { get; set; }
public Uri[]? Authorizations { get; set; }
public Uri? Finalize { get; set; }
public Uri? Certificate { get; set; }
}
}

View File

@ -0,0 +1,114 @@
/**
* https://tools.ietf.org/html/rfc4648
* https://tools.ietf.org/html/rfc4648#section-5
*/
using System.Text;
using System.Security.Cryptography;
using MaksIT.LetsEncrypt.Entities.Jws;
using MaksIT.Core.Extensions;
namespace MaksIT.LetsEncrypt.Services {
public interface IJwsService {
void Init(RSA rsa, string? keyId);
JwsMessage Encode(JwsHeader protectedHeader);
JwsMessage Encode<TPayload>(TPayload payload, JwsHeader protectedHeader);
string GetKeyAuthorization(string token);
string Base64UrlEncoded(byte[] arg);
void SetKeyId(string location);
}
public class JwsService : IJwsService {
public Jwk? _jwk;
private RSA? _rsa;
public void Init(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(JwsHeader protectedHeader) =>
Encode<string>(null, protectedHeader);
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 = "",
Protected = Base64UrlEncoded(protectedHeader.ToJson())
};
if (payload != null) {
if (payload is string) {
string value = payload.ToString();
message.Payload = Base64UrlEncoded(value);
}
else {
message.Payload = Base64UrlEncoded(payload.ToJson());
}
}
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) => $"{token}.{GetSha256Thumbprint()}";
public string Base64UrlEncoded(string s) => Base64UrlEncoded(Encoding.UTF8.GetBytes(s));
// https://tools.ietf.org/html/rfc4648#section-5
public 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;
}
public void SetKeyId(string location) {
_jwk.KeyId = location;
}
}
}

View File

@ -0,0 +1,478 @@
/**
* https://community.letsencrypt.org/t/trying-to-do-post-as-get-but-getting-post-jws-not-signed/108371
* https://tools.ietf.org/html/rfc8555#section-6.2
*
*/
using System.Text;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
using MaksIT.LetsEncrypt.Entities;
using MaksIT.LetsEncrypt.Exceptions;
using MaksIT.Core.Extensions;
using MaksIT.LetsEncrypt.Models.Responses;
using MaksIT.LetsEncrypt.Models.Interfaces;
using MaksIT.LetsEncrypt.Models.Requests;
using MaksIT.LetsEncrypt.Entities.Jws;
using System.Xml;
using System.Diagnostics;
namespace MaksIT.LetsEncrypt.Services {
public interface ILetsEncryptService {
Task ConfigureClient(string url, string[] contacts);
Task Init(RegistrationCache? registrationCache);
string GetTermsOfServiceUri();
Task<Dictionary<string, string>> NewOrder(string[] hostnames, string challengeType);
Task CompleteChallenges();
Task GetOrder(string[] hostnames);
Task<(X509Certificate2 Cert, RSA PrivateKey)> GetCertificate(string subject);
RegistrationCache? GetRegistrationCache();
}
public class LetsEncryptService : ILetsEncryptService {
//private static readonly JsonSerializerSettings jsonSettings = new JsonSerializerSettings {
// NullValueHandling = NullValueHandling.Ignore,
// Formatting = Formatting.Indented
//};
private readonly ILogger<LetsEncryptService> _logger;
private readonly IJwsService _jwsService;
private HttpClient _httpClient;
private string[]? _contacts;
private AcmeDirectory? _directory;
private RegistrationCache? _cache;
private string? _nonce;
private List<AuthorizationChallenge> _challenges = new List<AuthorizationChallenge>();
private Order? _currentOrder;
public LetsEncryptService(
ILogger<LetsEncryptService> logger,
IJwsService jwsService,
HttpClient httpClient
) {
_logger = logger;
_jwsService = jwsService;
_httpClient = httpClient;
}
/// <summary>
///
/// </summary>
/// <param name="url"></param>
/// <param name="contacts"></param>
/// <returns></returns>
public async Task ConfigureClient(string url, string[] contacts) {
_httpClient.BaseAddress ??= new Uri(url);
_contacts = contacts;
(_directory, _) = await SendAsync<AcmeDirectory>(HttpMethod.Get, new Uri("directory", UriKind.Relative), false, null);
}
/// <summary>
/// Account creation or Initialization from cache
/// </summary>
/// <param name="contacts"></param>
/// <param name="token"></param>
/// <returns></returns>
public async Task Init(RegistrationCache? cache) {
if (_contacts == null || _contacts.Length == 0)
throw new ArgumentNullException();
if (_directory == null)
throw new ArgumentNullException();
var accountKey = new RSACryptoServiceProvider(4096);
if (cache != null) {
_cache = cache;
accountKey.ImportCspBlob(_cache.AccountKey);
}
// New Account request
_jwsService.Init(accountKey, null);
var letsEncryptOrder = new Account {
TermsOfServiceAgreed = true,
Contacts = _contacts.Select(contact => $"mailto:{contact}").ToArray()
};
var (account, response) = await SendAsync<Account>(HttpMethod.Post, _directory.NewAccount, false, letsEncryptOrder);
_jwsService.SetKeyId(account.Location.ToString());
if (account.Status != "valid")
throw new InvalidOperationException($"Account status is not valid, was: {account.Status} \r\n {response}");
_cache = new RegistrationCache {
Location = account.Location,
AccountKey = accountKey.ExportCspBlob(true),
Id = account.Id,
Key = account.Key
};
}
/// <summary>
///
/// </summary>
/// <returns></returns>
public RegistrationCache? GetRegistrationCache() =>
_cache;
/// <summary>
/// Just retrive terms of service
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
public string GetTermsOfServiceUri() {
if (_directory == null)
throw new NullReferenceException();
return _directory.Meta.TermsOfService;
}
/// <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) {
_challenges.Clear();
var letsEncryptOrder = new Order {
Expires = DateTime.UtcNow.AddDays(2),
Identifiers = hostnames.Select(hostname => new OrderIdentifier {
Type = "dns",
Value = hostname
}).ToArray()
};
var (order, response) = await SendAsync<Order>(HttpMethod.Post, _directory.NewOrder, false, letsEncryptOrder);
if (order.Status != "pending")
throw new InvalidOperationException($"Created new order and expected status 'pending or ready', but got: {order.Status} \r\n {response}");
_currentOrder = order;
var results = new Dictionary<string, string>();
foreach (var item in order.Authorizations) {
var (challengeResponse, responseText) = await SendAsync<AuthorizationChallengeResponse>(HttpMethod.Post, item, true, null);
if (challengeResponse.Status == "valid")
continue;
if (challengeResponse.Status != "pending")
throw new InvalidOperationException($"Expected autorization status 'pending', but got: {order.Status} \r\n {responseText}");
var challenge = challengeResponse.Challenges.First(x => x.Type == challengeType);
_challenges.Add(challenge);
var keyToken = _jwsService.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 = _jwsService.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] = keyToken;
break;
}
default:
throw new NotImplementedException();
}
}
return results;
}
/// <summary>
///
/// </summary>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public async Task CompleteChallenges() {
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 = _jwsService.GetKeyAuthorization(challenge.Token);
//var (result, responseText) = await SendAsync<AuthorizationChallengeResponse>(HttpMethod.Post, challenge.Url, authorizeChallenge, token);
break;
}
case "http-01": {
break;
}
}
var (result, responseText) = await SendAsync<AuthorizationChallengeResponse>(HttpMethod.Post, challenge.Url, false, "{}");
if (result.Status == "valid")
break;
if (result.Status != "pending")
throw new InvalidOperationException($"Failed autorization of {_currentOrder.Identifiers[index].Value} \r\n {responseText}");
await Task.Delay(1000);
}
}
}
/// <summary>
///
/// </summary>
/// <param name="hostnames"></param>
/// <returns></returns>
public async Task GetOrder(string[] hostnames) {
var letsEncryptOrder = new Order {
Expires = DateTime.UtcNow.AddDays(2),
Identifiers = hostnames.Select(hostname => new OrderIdentifier {
Type = "dns",
Value = hostname
}).ToArray()
};
var (order, response) = await SendAsync<Order>(HttpMethod.Post, _directory.NewOrder, false, letsEncryptOrder);
_currentOrder = order;
}
/// <summary>
///
/// </summary>
/// <param name="subject"></param>
/// <returns>Cert and Private key</returns>
/// <exception cref="InvalidOperationException"></exception>
public async Task<(X509Certificate2 Cert, RSA PrivateKey)> GetCertificate(string subject) {
_logger.LogTrace($"Invoked: {nameof(GetCertificate)}");
if (_currentOrder == null)
throw new ArgumentNullException();
var key = new RSACryptoServiceProvider(4096);
var csr = new CertificateRequest("CN=" + subject,
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 letsEncryptOrder = new FinalizeRequest {
Csr = _jwsService.Base64UrlEncoded(csr.CreateSigningRequest())
};
Uri? certificateUrl = default;
var start = DateTime.UtcNow;
while (certificateUrl == null) {
// https://community.letsencrypt.org/t/breaking-changes-in-asynchronous-order-finalization-api/195882
await GetOrder(_currentOrder.Identifiers.Select(x => x.Value).ToArray());
if (_currentOrder.Status == "ready") {
var (response, responseText) = await SendAsync<Order>(HttpMethod.Post, _currentOrder.Finalize, false, letsEncryptOrder);
if (response.Status == "processing")
(response, responseText) = await SendAsync<Order>(HttpMethod.Post, _currentOrder.Location, true, null);
if (response.Status == "valid") {
certificateUrl = response.Certificate;
}
}
if ((start - DateTime.UtcNow).Seconds > 120)
throw new TimeoutException();
await Task.Delay(1000);
continue;
// throw new InvalidOperationException(/*$"Invalid order status: "*/);
}
var (pem, _) = await SendAsync<string>(HttpMethod.Post, certificateUrl, true, null);
if (_cache == null)
throw new NullReferenceException();
_cache.CachedCerts ??= new Dictionary<string, CertificateCache>();
_cache.CachedCerts[subject] = new CertificateCache {
Cert = pem,
Private = key.ExportCspBlob(true)
};
var cert = new X509Certificate2(Encoding.UTF8.GetBytes(pem));
return (cert, key);
}
/// <summary>
///
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
public Task KeyChange() {
throw new NotImplementedException();
}
/// <summary>
///
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
public Task RevokeCertificate() {
throw new NotImplementedException();
}
/// <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, string)> SendAsync<TResult>(HttpMethod method, Uri uri, bool isPostAsGet, object? message) where TResult : class {
var request = new HttpRequestMessage(method, uri);
_nonce = uri.OriginalString != "directory"
? await NewNonce()
: default;
if (message != null || isPostAsGet) {
var jwsHeader = new JwsHeader {
Url = uri,
};
if (_nonce != null)
jwsHeader.Nonce = _nonce;
var encodedMessage = isPostAsGet
? _jwsService.Encode(jwsHeader)
: _jwsService.Encode(message, jwsHeader);
var json = encodedMessage.ToJson();
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 _httpClient.SendAsync(request);
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();
var problem = problemJson.ToObject<Problem>();
problem.RawJson = problemJson;
throw new LetsEncrytException(problem, response);
}
var responseText = await response.Content.ReadAsStringAsync();
if (typeof(TResult) == typeof(string) && response.Content.Headers.ContentType.MediaType == "application/pem-certificate-chain") {
return ((TResult)(object)responseText, null);
}
var responseContent = responseText.ToObject<TResult>();
if (responseContent is IHasLocation ihl) {
if (response.Headers.Location != null)
ihl.Location = response.Headers.Location;
}
return (responseContent, responseText);
}
/// <summary>
/// Request New Nonce to be able to start POST requests
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
private async Task<string> NewNonce() {
if (_directory == null)
throw new NotImplementedException();
var result = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, _directory.NewNonce));
return result.Headers.GetValues("Replay-Nonce").First();
}
}
}

View File

@ -0,0 +1,287 @@
using System.Text;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MaksIT.LetsEncrypt.Services;
using MaksIT.LetsEncrypt.Entities;
using MaksIT.LetsEncryptConsole.Services;
using MaksIT.Core.Extensions;
using System.Text.Json;
namespace MaksIT.LetsEncryptConsole {
public interface IApp {
Task Run(string[] args);
}
public class App : IApp {
private readonly string _appPath = AppDomain.CurrentDomain.BaseDirectory;
private readonly ILogger<App> _logger;
private readonly Configuration _appSettings;
private readonly ILetsEncryptService _letsEncryptService;
private readonly IJwsService _jwsService;
private readonly IKeyService _keyService;
private readonly ITerminalService _terminalService;
public App(
ILogger<App> logger,
IOptions<Configuration> appSettings,
ILetsEncryptService letsEncryptService,
IJwsService jwsService,
IKeyService keyService,
ITerminalService terminalService
) {
_logger = logger;
_appSettings = appSettings.Value;
_letsEncryptService = letsEncryptService;
_jwsService = jwsService;
_keyService = keyService;
_terminalService = terminalService;
}
public async Task Run(string[] args) {
foreach (var env in _appSettings.Environments?.Where(x => x.Active) ?? new List<LetsEncryptEnvironment>()) {
try {
_logger.LogTrace($"Let's Encrypt C# .Net Core Client, environment: {env.Name}");
//loop all customers
foreach (Customer customer in _appSettings.Customers?.Where(x => x.Active) ?? new List<Customer>()) {
try {
_logger.LogTrace($"Managing customer: {customer.Id} - {customer.Name} {customer.LastName}");
//loop each customer website
foreach (Site site in customer.Sites?.Where(s => s.Active) ?? new List<Site>()) {
_logger.LogTrace($"Managing site: {site.Name}");
try {
//define cache folder
string cacheFolder = Path.Combine(_appPath, env.Cache, customer.Id);
if (!Directory.Exists(cacheFolder)) {
Directory.CreateDirectory(cacheFolder);
}
//1. Client initialization
_logger.LogTrace("1. Client Initialization...");
#region LetsEncrypt client configuration
await _letsEncryptService.ConfigureClient(env.Url, customer.Contacts);
#endregion
#region LetsEncrypt local registration cache initialization
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(site.Name));
var cacheFileName = _jwsService.Base64UrlEncoded(hash) + ".lets-encrypt.cache.json";
var cachePath = Path.Combine(cacheFolder, cacheFileName);
var cacheFile = File.Exists(cachePath)
? File.ReadAllText(cachePath)
: null;
var registrationCache = cacheFile.ToObject<RegistrationCache>();
await _letsEncryptService.Init(registrationCache);
registrationCache = _letsEncryptService.GetRegistrationCache();
#endregion
#region LetsEncrypt terms of service
_logger.LogTrace($"Terms of service: {_letsEncryptService.GetTermsOfServiceUri()}");
#endregion
//create folder for ssl
string ssl = Path.Combine(env.GetSSL(), site.Name);
if (!Directory.Exists(ssl)) {
Directory.CreateDirectory(ssl);
}
// get cached certificate and check if it's valid
// if valid check if cert and key exists otherwise recreate
// else continue with new certificate request
var certRes = new CachedCertificateResult();
if (TryGetCachedCertificate(registrationCache, site.Name, out certRes)) {
string cert = Path.Combine(ssl, $"{site.Name}.crt");
//if(!File.Exists(cert))
File.WriteAllText(cert, certRes.Certificate);
string key = Path.Combine(ssl, $"{site.Name}.key");
//if(!File.Exists(key)) {
using (StreamWriter writer = File.CreateText(key))
_keyService.ExportPrivateKey(certRes.PrivateKey, writer);
//}
_logger.LogTrace("Certificate and Key exists and valid. Restored from cache.");
}
else {
//try to make new order
try {
//create new orders
Console.WriteLine("2. Client New Order...");
#region LetsEncrypt new order
var orders = await _letsEncryptService.NewOrder(site.Hosts, site.Challenge);
#endregion
switch (site.Challenge) {
case "http-01": {
//ensure to enable static file discovery on server in .well-known/acme-challenge
//and listen on 80 port
//check acme directory
var acme = env.GetACME();
if (!Directory.Exists(acme)) {
Directory.CreateDirectory(acme);
}
foreach (FileInfo file in new DirectoryInfo(acme).GetFiles()) {
if (file.LastWriteTimeUtc < DateTime.UtcNow.AddMonths(-3))
file.Delete();
}
foreach (var result in orders) {
Console.WriteLine($"Key: {result.Key}, Value: {result.Value}");
string[] splitToken = result.Value.Split('.');
File.WriteAllText(Path.Combine(env.GetACME(), splitToken[0]), result.Value);
}
if (OperatingSystem.IsLinux()) {
_terminalService.Exec($"chgrp -R nginx {env.GetACME()}");
_terminalService.Exec($"chmod -R g+rwx {env.GetACME()}");
}
break;
}
case "dns-01": {
//Manage DNS server MX record, depends from provider
throw new NotImplementedException();
}
default: {
throw new NotImplementedException();
}
}
#region LetsEncrypt complete challenges
_logger.LogTrace("3. Client Complete Challange...");
await _letsEncryptService.CompleteChallenges();
_logger.LogTrace("Challanges comleted.");
#endregion
await Task.Delay(1000);
// Download new certificate
_logger.LogTrace("4. Download certificate...");
var (cert, key) = await _letsEncryptService.GetCertificate(site.Name);
#region Persist cache
registrationCache = _letsEncryptService.GetRegistrationCache();
File.WriteAllText(cachePath, registrationCache.ToJson());
#endregion
#region Save cert and key to filesystem
certRes = new CachedCertificateResult();
if (TryGetCachedCertificate(registrationCache, site.Name, out certRes)) {
string certPath = Path.Combine(ssl, site.Name + ".crt");
File.WriteAllText(certPath, certRes.Certificate);
string keyPath = Path.Combine(ssl, site.Name + ".key");
using (var writer = File.CreateText(keyPath))
_keyService.ExportPrivateKey(certRes.PrivateKey, writer);
_logger.LogTrace("Certificate saved.");
}
else {
_logger.LogError("Unable to get new cached certificate.");
}
#endregion
}
catch (Exception ex) {
_logger.LogError(ex, "");
await _letsEncryptService.GetOrder(site.Hosts);
}
}
}
catch (Exception ex) {
_logger.LogError(ex, "Customer unhandled error");
}
}
}
catch (Exception ex) {
_logger.LogError(ex, "Environment unhandled error");
}
}
if (env.Name == "ProductionV2") {
_terminalService.Exec("systemctl restart nginx");
}
}
catch (Exception ex) {
_logger.LogError(ex.Message.ToString());
break;
}
}
}
/// <summary>
///
/// </summary>
/// <param name="subject"></param>
/// <param name="value"></param>
/// <returns></returns>
private bool TryGetCachedCertificate(RegistrationCache? registrationCache, string subject, out CachedCertificateResult? value) {
value = null;
if (registrationCache?.CachedCerts == null)
return false;
if (!registrationCache.CachedCerts.TryGetValue(subject, out var cache)) {
return false;
}
var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cache.Cert));
// if it is about to expire, we need to refresh
if ((cert.NotAfter - DateTime.UtcNow).TotalDays < 30)
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 RegistrationCache? ResetCachedCertificate(RegistrationCache? registrationCache, IEnumerable<string> hostsToRemove) {
if (registrationCache != null)
foreach (var host in hostsToRemove)
registrationCache.CachedCerts.Remove(host);
return registrationCache;
}
}
}

View File

@ -0,0 +1,74 @@
using System.Runtime.InteropServices;
namespace MaksIT.LetsEncryptConsole {
public class Configuration {
public LetsEncryptEnvironment[]? Environments { get; set; }
public Customer[]? Customers { get; set; }
}
public class OsDependant {
public string? Windows { get; set; }
public string? Linux { get; set; }
}
public class LetsEncryptEnvironment {
public bool Active { get; set; }
public string? Name { get; set; }
public string? Url { get; set; }
private string? _cache;
public string Cache {
get => _cache ?? "";
set => _cache = value;
}
public OsDependant? ACME { get; set; }
public OsDependant? SSL { get; set; }
public string? GetACME() {
if (OperatingSystem.IsWindows())
return ACME?.Windows;
if (OperatingSystem.IsLinux())
return ACME?.Linux;
return default;
}
public string? GetSSL() {
if (OperatingSystem.IsWindows())
return SSL?.Windows;
if (OperatingSystem.IsLinux())
return SSL?.Linux;
return default;
}
}
public class Customer {
private string? _id;
public string Id {
get => _id ?? string.Empty;
set => _id = value;
}
public bool Active { get; set; }
public string[]? Contacts { get; set; }
public string? Name { get; set; }
public string? LastName { get; set; }
public Site[]? Sites { get; set; }
}
public class Site {
public bool Active { get; set; }
public string? Name { get; set; }
public string[]? Hosts { get; set; }
public string? Challenge { get; set; }
}
}

View File

@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>MaksIT.$(MSBuildProjectName.Replace(" ", "_"))</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="7.0.0" />
<PackageReference Include="Serilog.Enrichers.Span" Version="3.1.0" />
<PackageReference Include="Serilog.Expressions" Version="3.4.1" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="7.0.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="7.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="1.1.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" />
<ProjectReference Include="..\LetsEncrypt\LetsEncrypt.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -0,0 +1,70 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using MaksIT.LetsEncryptConsole.Services;
using MaksIT.LetsEncrypt.Extensions;
namespace MaksIT.LetsEncryptConsole {
class Program {
private static readonly IConfiguration _configuration = InitConfig();
static void Main(string[] args) {
// create service collection
var services = new ServiceCollection();
ConfigureServices(services);
// create service provider
var serviceProvider = services.BuildServiceProvider();
// entry to run app
#pragma warning disable CS8602 // Dereference of a possibly null reference.
var app = serviceProvider.GetService<App>();
app.Run(args).Wait();
#pragma warning restore CS8602 // Dereference of a possibly null reference.
}
public static void ConfigureServices(IServiceCollection services) {
var configurationSection = _configuration.GetSection("Configuration");
services.Configure<Configuration>(configurationSection);
var appSettings = configurationSection.Get<Configuration>();
#region Configure logging
services.AddLogging(configure => {
configure.AddSerilog(new LoggerConfiguration()
.ReadFrom.Configuration(_configuration)
.CreateLogger());
});
#endregion
#region Services
services.RegisterLetsEncrypt();
services.AddSingleton<IKeyService, KeyService>();
services.AddSingleton<ITerminalService, TerminalService>();
#endregion
// add app
services.AddSingleton<App>();
}
private static IConfiguration InitConfig() {
var aspNetCoreEnvironment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddEnvironmentVariables();
if (!string.IsNullOrWhiteSpace(aspNetCoreEnvironment)
&& new FileInfo(Path.Combine(Directory.GetCurrentDirectory(), $"appsettings.{aspNetCoreEnvironment}.json")).Exists
)
configuration.AddJsonFile($"appsettings.{aspNetCoreEnvironment}.json", true);
else
configuration.AddJsonFile($"appsettings.json", true, true);
return configuration.Build();
}
}
}

View File

@ -81,3 +81,42 @@ Lines with labels in quotes indicate HTTP link relations.
| |
V V
valid invalid
https://community.letsencrypt.org/t/acme-client-finalized-order-stuck-on-ready-state/165196
The following table illustrates a typical sequence of requests
required to establish a new account with the server, prove control of
an identifier, issue a certificate, and fetch an updated certificate
some time after issuance. The "->" is a mnemonic for a Location
header field pointing to a created resource.
+-------------------+--------------------------------+--------------+
| 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 | |
+-------------------+--------------------------------+--------------+

View File

@ -0,0 +1,151 @@
using System.Security.Cryptography;
namespace MaksIT.LetsEncryptConsole.Services {
public interface IKeyService {
void ExportPublicKey(RSACryptoServiceProvider csp, TextWriter outputStream);
void ExportPrivateKey(RSACryptoServiceProvider csp, TextWriter outputStream);
}
public class KeyService : IKeyService {
/// <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(X509Certificate2 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();
//}
public void ExportPublicKey(RSACryptoServiceProvider csp, TextWriter outputStream) {
var parameters = csp.ExportParameters(false);
using (var stream = new MemoryStream()) {
var writer = new BinaryWriter(stream);
writer.Write((byte)0x30); // SEQUENCE
using (var innerStream = new MemoryStream()) {
var innerWriter = new BinaryWriter(innerStream);
innerWriter.Write((byte)0x30); // SEQUENCE
EncodeLength(innerWriter, 13);
innerWriter.Write((byte)0x06); // OBJECT IDENTIFIER
var rsaEncryptionOid = new byte[] { 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01 };
EncodeLength(innerWriter, rsaEncryptionOid.Length);
innerWriter.Write(rsaEncryptionOid);
innerWriter.Write((byte)0x05); // NULL
EncodeLength(innerWriter, 0);
innerWriter.Write((byte)0x03); // BIT STRING
using (var bitStringStream = new MemoryStream()) {
var bitStringWriter = new BinaryWriter(bitStringStream);
bitStringWriter.Write((byte)0x00); // # of unused bits
bitStringWriter.Write((byte)0x30); // SEQUENCE
using (var paramsStream = new MemoryStream()) {
var paramsWriter = new BinaryWriter(paramsStream);
EncodeIntegerBigEndian(paramsWriter, parameters.Modulus); // Modulus
EncodeIntegerBigEndian(paramsWriter, parameters.Exponent); // Exponent
var paramsLength = (int)paramsStream.Length;
EncodeLength(bitStringWriter, paramsLength);
bitStringWriter.Write(paramsStream.GetBuffer(), 0, paramsLength);
}
var bitStringLength = (int)bitStringStream.Length;
EncodeLength(innerWriter, bitStringLength);
innerWriter.Write(bitStringStream.GetBuffer(), 0, bitStringLength);
}
var length = (int)innerStream.Length;
EncodeLength(writer, length);
writer.Write(innerStream.GetBuffer(), 0, length);
}
var base64 = Convert.ToBase64String(stream.GetBuffer(), 0, (int)stream.Length).ToCharArray();
outputStream.WriteLine("-----BEGIN PUBLIC KEY-----");
for (var i = 0; i < base64.Length; i += 64) {
outputStream.WriteLine(base64, i, Math.Min(64, base64.Length - i));
}
outputStream.WriteLine("-----END PUBLIC KEY-----");
}
}
public void ExportPrivateKey(RSACryptoServiceProvider csp, TextWriter outputStream) {
if (csp.PublicOnly) throw new ArgumentException("CSP does not contain a private key", "csp");
var parameters = csp.ExportParameters(true);
using (var stream = new MemoryStream()) {
var writer = new BinaryWriter(stream);
writer.Write((byte)0x30); // SEQUENCE
using (var innerStream = new MemoryStream()) {
var innerWriter = new BinaryWriter(innerStream);
EncodeIntegerBigEndian(innerWriter, new byte[] { 0x00 }); // Version
EncodeIntegerBigEndian(innerWriter, parameters.Modulus);
EncodeIntegerBigEndian(innerWriter, parameters.Exponent);
EncodeIntegerBigEndian(innerWriter, parameters.D);
EncodeIntegerBigEndian(innerWriter, parameters.P);
EncodeIntegerBigEndian(innerWriter, parameters.Q);
EncodeIntegerBigEndian(innerWriter, parameters.DP);
EncodeIntegerBigEndian(innerWriter, parameters.DQ);
EncodeIntegerBigEndian(innerWriter, parameters.InverseQ);
var length = (int)innerStream.Length;
EncodeLength(writer, length);
writer.Write(innerStream.GetBuffer(), 0, length);
}
var base64 = Convert.ToBase64String(stream.GetBuffer(), 0, (int)stream.Length).ToCharArray();
outputStream.WriteLine("-----BEGIN RSA PRIVATE KEY-----");
// Output as Base64 with lines chopped at 64 characters
for (var i = 0; i < base64.Length; i += 64) {
outputStream.WriteLine(base64, i, Math.Min(64, base64.Length - i));
}
outputStream.WriteLine("-----END RSA PRIVATE KEY-----");
}
}
private void EncodeLength(BinaryWriter stream, int length) {
if (length < 0) throw new ArgumentOutOfRangeException("length", "Length must be non-negative");
if (length < 0x80) {
// Short form
stream.Write((byte)length);
}
else {
// Long form
var temp = length;
var bytesRequired = 0;
while (temp > 0) {
temp >>= 8;
bytesRequired++;
}
stream.Write((byte)(bytesRequired | 0x80));
for (var i = bytesRequired - 1; i >= 0; i--) {
stream.Write((byte)(length >> (8 * i) & 0xff));
}
}
}
private void EncodeIntegerBigEndian(BinaryWriter stream, byte[] value, bool forceUnsigned = true) {
stream.Write((byte)0x02); // INTEGER
var prefixZeros = 0;
for (var i = 0; i < value.Length; i++) {
if (value[i] != 0) break;
prefixZeros++;
}
if (value.Length - prefixZeros == 0) {
EncodeLength(stream, 1);
stream.Write((byte)0);
}
else {
if (forceUnsigned && value[prefixZeros] > 0x7f) {
// Add a prefix zero to force unsigned if the MSB is 1
EncodeLength(stream, value.Length - prefixZeros + 1);
stream.Write((byte)0);
}
else {
EncodeLength(stream, value.Length - prefixZeros);
}
for (var i = prefixZeros; i < value.Length; i++) {
stream.Write(value[i]);
}
}
}
}
}

View File

@ -1,7 +1,6 @@
using System;
using System.Diagnostics;
namespace LetsEncrypt {
namespace MaksIT.LetsEncryptConsole.Services {
public interface ITerminalService {
void Exec(string cmd);

View File

@ -0,0 +1,94 @@
{
"Serilog": {
"Using": [ "Serilog.Settings.Configuration", "Serilog.Expressions", "Serilog.Sinks.Console" ],
"MinimumLevel": "Information",
"Enrich": [ "FromLogContext", "WithMachineName", "WithProcessId", "WithThreadId" ],
"WriteTo": [
{
"Name": "Console",
"Args": {
"restrictedToMinimumLevel": "Information",
"formatter": "Serilog.Formatting.Compact.RenderedCompactJsonFormatter, Serilog.Formatting.Compact"
}
}
]
},
"Configuration": {
"Environments": [
{
"Active": false,
"Name": "StagingV2",
"Url": "https://acme-staging-v02.api.letsencrypt.org/directory",
"Cache": "staging_cache",
"ACME": {
"Linux": "/var/www/html/.well-known/acme-challenge",
"Windows": "C:\\Windows\\Temp\\www\\html\\.well-known\\acme-challenge"
},
"SSL": {
"Linux": "/var/www/ssl",
"Windows": "C:\\Windows\\Temp\\www\\ssl"
}
},
{
"Active": true,
"Name": "ProductionV2",
"Url": "https://acme-v02.api.letsencrypt.org/directory",
"Cache": "production_cache",
"ACME": {
"Linux": "/var/www/html/.well-known/acme-challenge",
"Windows": "C:\\Windows\\Temp\\www\\html\\.well-known\\acme-challenge"
},
"SSL": {
"Linux": "/var/www/ssl",
"Windows": "C:\\Windows\\Temp\\www\\ssl"
}
}
],
"Customers": [
{
"Id": "9b4c8584-dc83-4388-b45f-2942e34dca9d",
"Active": true,
"Contacts": [ "maksym.sadovnychyy@gmail.com" ],
"Name": "Maksym",
"LastName": "Sadovnychyy",
"Sites": [
{
"Active": false,
"Name": "maks-it.com",
"Hosts": [
"maks-it.com",
"www.maks-it.com"
],
"Challenge": "http-01"
}
]
},
{
"Id": "46337ef5-d69b-4332-b6ef-67959dfb3c2c",
"Active": true,
"Contacts": [
"maksym.sadovnychyy@gmail.com",
"anastasiia.pavlovskaia@gmail.com"
],
"Name": "Anastasiia",
"LastName": "Pavlovskaia",
"Sites": [
{
"Active": true,
"Name": "nastyarey.com",
"Hosts": [
"nastyarey.com",
"www.nastyarey.com"
],
"Challenge": "http-01"
}
]
}
]
}
}

View File

@ -1,25 +0,0 @@

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

View File

@ -1,27 +0,0 @@
{
// 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}"
}
]
}

View File

@ -1,48 +0,0 @@
/*
* https://tools.ietf.org/html/draft-ietf-acme-acme-18#section-7.3
*/
using System;
using Newtonsoft.Json;
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

@ -1,22 +0,0 @@
using System;
using Newtonsoft.Json;
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

@ -1,25 +0,0 @@
using System;
using Newtonsoft.Json;
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

@ -1,16 +0,0 @@
using Newtonsoft.Json;
namespace ACMEv2
{
public class AuthorizeChallenge
{
[JsonProperty("keyAuthorization")]
public string KeyAuthorization { get; set; }
}
}

View File

@ -1,11 +0,0 @@
using System.Security.Cryptography;
namespace ACMEv2
{
public class CachedCertificateResult
{
public RSACryptoServiceProvider PrivateKey;
public string Certificate;
}
}

View File

@ -1,11 +0,0 @@
namespace ACMEv2
{
public class CertificateCache
{
public string Cert;
public byte[] Private;
}
}

View File

@ -1,11 +0,0 @@
using Newtonsoft.Json;
namespace ACMEv2
{
public class FinalizeRequest
{
[JsonProperty("csr")]
public string CSR { get; set; }
}
}

View File

@ -1,10 +0,0 @@
using System;
namespace ACMEv2
{
interface IHasLocation
{
Uri Location { get; set; }
}
}

View File

@ -1,114 +0,0 @@
/*
* JSON Web Key (JWK)
* https://tools.ietf.org/html/rfc7517
* https://www.gnupg.org/documentation/manuals/gcrypt-devel/RSA-key-parameters.html
* https://static.javadoc.io/com.nimbusds/nimbus-jose-jwt/2.15.2/com/nimbusds/jose/jwk/RSAKey.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>
/// The the modulus value for the public RSA key. It is represented as the Base64URL encoding of value's big endian representation.
/// </summary>
[JsonProperty("n")]
public string Modulus { get; set; }
/// <summary>
/// The exponent value for the public RSA key. It is represented as the Base64URL encoding of value's big endian representation.
/// </summary>
[JsonProperty("e")]
public string Exponent { get; set; }
/// <summary>
/// The private exponent. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("d")]
public string D { get; set; }
/// <summary>
/// The first prime factor. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("p")]
public string P { get; set; }
/// <summary>
/// The second prime factor. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("q")]
public string Q { get; set; }
/// <summary>
/// The first factor Chinese Remainder Theorem exponent. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("dp")]
public string DP { get; set; }
/// <summary>
/// The second factor Chinese Remainder Theorem exponent. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("dq")]
public string DQ { get; set; }
/// <summary>
/// The first Chinese Remainder Theorem coefficient. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("qi")]
public string InverseQ { get; set; }
/// <summary>
/// The other primes information, should they exist, null or an empty list if not specified.
/// </summary>
[JsonProperty("oth")]
public string OthInf { 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; }
}
}

View File

@ -1,89 +0,0 @@
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

@ -1,40 +0,0 @@
using System;
using Newtonsoft.Json;
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

@ -1,22 +0,0 @@
using Newtonsoft.Json;
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

@ -1,545 +0,0 @@
/*
* 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 AcmeDirectory _directory;
private List<AuthorizationChallenge> _challenges = new List<AuthorizationChallenge>();
private Order _currentOrder;
/// <summary>
/// Let's encrypt client object
/// </summary>
/// <param name="url"></param>
/// <param name="home"></param>
public LetsEncryptClient(string url, string home, string siteName)
{
_url = url ?? throw new ArgumentNullException(nameof(url));
var hash = SHA256.Create().ComputeHash(Encoding.UTF8.GetBytes(siteName));
_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<AcmeDirectory>(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(string subject, CancellationToken token = default(CancellationToken))
{
var key = new RSACryptoServiceProvider(4096);
var csr = new CertificateRequest("CN=" + subject,
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[subject] = 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(string subject, out CachedCertificateResult value)
{
value = null;
if (_cache.CachedCerts.TryGetValue(subject, out var cache) == false)
{
return false;
}
var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cache.Cert));
// if it is about to expire, we need to refresh
if ((cert.NotAfter - DateTime.UtcNow).TotalDays < 30)
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

@ -1,19 +0,0 @@
using System;
using System.Net.Http;
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

@ -1,39 +0,0 @@
using System;
using Newtonsoft.Json;
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

@ -1,16 +0,0 @@
using Newtonsoft.Json;
namespace ACMEv2
{
public class OrderIdentifier
{
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("value")]
public string Value { get; set; }
}
}

View File

@ -1,16 +0,0 @@
using Newtonsoft.Json;
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

@ -1,17 +0,0 @@
using System;
using System.Collections.Generic;
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

@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.2</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
</ItemGroup>
<ItemGroup>
<None Update="settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -1,177 +0,0 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Security.Cryptography;
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(X509Certificate2 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();
//}
public static void ExportPublicKey(RSACryptoServiceProvider csp, TextWriter outputStream)
{
var parameters = csp.ExportParameters(false);
using (var stream = new MemoryStream())
{
var writer = new BinaryWriter(stream);
writer.Write((byte)0x30); // SEQUENCE
using (var innerStream = new MemoryStream())
{
var innerWriter = new BinaryWriter(innerStream);
innerWriter.Write((byte)0x30); // SEQUENCE
EncodeLength(innerWriter, 13);
innerWriter.Write((byte)0x06); // OBJECT IDENTIFIER
var rsaEncryptionOid = new byte[] { 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01 };
EncodeLength(innerWriter, rsaEncryptionOid.Length);
innerWriter.Write(rsaEncryptionOid);
innerWriter.Write((byte)0x05); // NULL
EncodeLength(innerWriter, 0);
innerWriter.Write((byte)0x03); // BIT STRING
using (var bitStringStream = new MemoryStream())
{
var bitStringWriter = new BinaryWriter(bitStringStream);
bitStringWriter.Write((byte)0x00); // # of unused bits
bitStringWriter.Write((byte)0x30); // SEQUENCE
using (var paramsStream = new MemoryStream())
{
var paramsWriter = new BinaryWriter(paramsStream);
EncodeIntegerBigEndian(paramsWriter, parameters.Modulus); // Modulus
EncodeIntegerBigEndian(paramsWriter, parameters.Exponent); // Exponent
var paramsLength = (int)paramsStream.Length;
EncodeLength(bitStringWriter, paramsLength);
bitStringWriter.Write(paramsStream.GetBuffer(), 0, paramsLength);
}
var bitStringLength = (int)bitStringStream.Length;
EncodeLength(innerWriter, bitStringLength);
innerWriter.Write(bitStringStream.GetBuffer(), 0, bitStringLength);
}
var length = (int)innerStream.Length;
EncodeLength(writer, length);
writer.Write(innerStream.GetBuffer(), 0, length);
}
var base64 = Convert.ToBase64String(stream.GetBuffer(), 0, (int)stream.Length).ToCharArray();
outputStream.WriteLine("-----BEGIN PUBLIC KEY-----");
for (var i = 0; i < base64.Length; i += 64)
{
outputStream.WriteLine(base64, i, Math.Min(64, base64.Length - i));
}
outputStream.WriteLine("-----END PUBLIC KEY-----");
}
}
public static void ExportPrivateKey(RSACryptoServiceProvider csp, TextWriter outputStream)
{
if (csp.PublicOnly) throw new ArgumentException("CSP does not contain a private key", "csp");
var parameters = csp.ExportParameters(true);
using (var stream = new MemoryStream())
{
var writer = new BinaryWriter(stream);
writer.Write((byte)0x30); // SEQUENCE
using (var innerStream = new MemoryStream())
{
var innerWriter = new BinaryWriter(innerStream);
EncodeIntegerBigEndian(innerWriter, new byte[] { 0x00 }); // Version
EncodeIntegerBigEndian(innerWriter, parameters.Modulus);
EncodeIntegerBigEndian(innerWriter, parameters.Exponent);
EncodeIntegerBigEndian(innerWriter, parameters.D);
EncodeIntegerBigEndian(innerWriter, parameters.P);
EncodeIntegerBigEndian(innerWriter, parameters.Q);
EncodeIntegerBigEndian(innerWriter, parameters.DP);
EncodeIntegerBigEndian(innerWriter, parameters.DQ);
EncodeIntegerBigEndian(innerWriter, parameters.InverseQ);
var length = (int)innerStream.Length;
EncodeLength(writer, length);
writer.Write(innerStream.GetBuffer(), 0, length);
}
var base64 = Convert.ToBase64String(stream.GetBuffer(), 0, (int)stream.Length).ToCharArray();
outputStream.WriteLine("-----BEGIN RSA PRIVATE KEY-----");
// Output as Base64 with lines chopped at 64 characters
for (var i = 0; i < base64.Length; i += 64)
{
outputStream.WriteLine(base64, i, Math.Min(64, base64.Length - i));
}
outputStream.WriteLine("-----END RSA PRIVATE KEY-----");
}
}
private static void EncodeLength(BinaryWriter stream, int length)
{
if (length < 0) throw new ArgumentOutOfRangeException("length", "Length must be non-negative");
if (length < 0x80)
{
// Short form
stream.Write((byte)length);
}
else
{
// Long form
var temp = length;
var bytesRequired = 0;
while (temp > 0)
{
temp >>= 8;
bytesRequired++;
}
stream.Write((byte)(bytesRequired | 0x80));
for (var i = bytesRequired - 1; i >= 0; i--)
{
stream.Write((byte)(length >> (8 * i) & 0xff));
}
}
}
private static void EncodeIntegerBigEndian(BinaryWriter stream, byte[] value, bool forceUnsigned = true)
{
stream.Write((byte)0x02); // INTEGER
var prefixZeros = 0;
for (var i = 0; i < value.Length; i++)
{
if (value[i] != 0) break;
prefixZeros++;
}
if (value.Length - prefixZeros == 0)
{
EncodeLength(stream, 1);
stream.Write((byte)0);
}
else
{
if (forceUnsigned && value[prefixZeros] > 0x7f)
{
// Add a prefix zero to force unsigned if the MSB is 1
EncodeLength(stream, value.Length - prefixZeros + 1);
stream.Write((byte)0);
}
else
{
EncodeLength(stream, value.Length - prefixZeros);
}
for (var i = prefixZeros; i < value.Length; i++)
{
stream.Write(value[i]);
}
}
}
}
}

View File

@ -1,170 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using ACMEv2;
namespace LetsEncrypt
{
class Program
{
private static readonly string AppPath = AppDomain.CurrentDomain.BaseDirectory;
static void Main(string[] args)
{
try
{
Console.WriteLine("Let's Encrypt C# .Net Core Client");
Settings settings = (new SettingsProvider(null)).settings;
//loop all customers
foreach(Customer customer in settings.customers) {
try {
Console.WriteLine(string.Format("Managing customer: {0} - {1} {2}", customer.id, customer.name, customer.lastname));
//loop each customer website
foreach(Site site in customer.sites) {
Console.WriteLine(string.Format("Managing site: {0}", site.name));
try {
//define cache folder
string cache = Path.Combine(AppPath, "cache", customer.id);
if(!Directory.Exists(cache)) {
Directory.CreateDirectory(cache);
}
LetsEncryptClient client = new LetsEncryptClient(settings.url, cache, site.name);
//1. Client initialization
Console.WriteLine("1. Client Initialization...");
client.Init(customer.contacts).Wait();
Console.WriteLine(string.Format("Terms of service: {0}", client.GetTermsOfServiceUri()));
//create folder for ssl
string ssl = Path.Combine(settings.ssl, site.name);
if(!Directory.Exists(ssl)) {
Directory.CreateDirectory(ssl);
}
// get cached certificate and check if it's valid
// if valid check if cert and key exists otherwise recreate
// else continue with new certificate request
CachedCertificateResult certRes = new CachedCertificateResult();
if (client.TryGetCachedCertificate(site.name, out certRes))
{
string cert = Path.Combine(ssl, site.name + ".crt");
if(!File.Exists(cert))
File.WriteAllText(cert, certRes.Certificate);
string key = Path.Combine(ssl, site.name + ".key");
if(!File.Exists(key)) {
using (StreamWriter writer = File.CreateText(key))
Library.ExportPrivateKey(certRes.PrivateKey, writer);
}
Console.WriteLine("Certificate and Key exists and valid.");
}
else {
//new nonce
client.NewNonce().Wait();
//try to make new order
try
{
//create new orders
Console.WriteLine("2. Client New Order...");
Task<Dictionary<string, string>> orders = client.NewOrder(site.hosts, site.challenge);
orders.Wait();
switch(site.challenge) {
case "http-01": {
//ensure to enable static file discovery on server in .well-known/acme-challenge
//and listen on 80 port
//check acme directory
string acme = Path.Combine(settings.www, settings.acme);
if(!Directory.Exists(acme)) {
throw new DirectoryNotFoundException(string.Format("Directory {0} wasn't created", acme));
}
foreach (FileInfo file in new DirectoryInfo(acme).GetFiles())
file.Delete();
foreach (var result in orders.Result)
{
Console.WriteLine("Key: " + result.Key + Environment.NewLine + "Value: " + result.Value);
string[] splitToken = result.Value.Split('~');
string token = Path.Combine(acme, splitToken[0]);
File.WriteAllText(token, splitToken[1]);
}
break;
}
case "dns-01": {
//Manage DNS server MX record, depends from provider
break;
}
default: {
break;
}
}
//complete challanges
Console.WriteLine("3. Client Complete Challange...");
client.CompleteChallenges().Wait();
Console.WriteLine("Challanges comleted.");
}
catch (Exception ex)
{
Console.WriteLine(ex.Message.ToString());
client.GetOrder(site.hosts).Wait();
}
// Download new certificate
Console.WriteLine("4. Download certificate...");
client.GetCertificate(site.name).Wait();
// Write to filesystem
certRes = new CachedCertificateResult();
if (client.TryGetCachedCertificate(site.name, out certRes)) {
string cert = Path.Combine(ssl, site.name + ".crt");
File.WriteAllText(cert, certRes.Certificate);
string key = Path.Combine(ssl, site.name + ".key");
using (StreamWriter writer = File.CreateText(key))
Library.ExportPrivateKey(certRes.PrivateKey, writer);
Console.WriteLine("Certificate saved.");
}
else {
Console.WriteLine("Unable to get new cached certificate.");
}
}
}
catch (Exception ex) {
Console.WriteLine(ex.Message.ToString());
}
}
}
catch (Exception ex) {
Console.WriteLine(ex.Message.ToString());
}
}
}
catch (Exception ex) {
Console.WriteLine(ex.Message.ToString());
}
}
}
}

View File

@ -1,83 +0,0 @@
#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

View File

@ -1,49 +0,0 @@
using System;
using System.IO;
using Newtonsoft.Json;
namespace LetsEncrypt
{
public class SettingsProvider
{
private readonly string _path;
public Settings settings;
public SettingsProvider(string path) {
_path = path ?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "settings.json");
if(!File.Exists(_path))
throw new FileNotFoundException(string.Format("Settings file \"{0}\" not found."), _path);
settings = JsonConvert.DeserializeObject<Settings>(File.ReadAllText(_path));
}
}
public class Settings {
public string url { get; set; }
public string www { get; set; }
public string acme { get; set; }
public string ssl { get; set; }
public Customer [] customers { get; set;}
}
public class Customer {
public string id { get; set; }
public string [] contacts { get; set; }
public string name { get; set; }
public string lastname { get; set; }
public Site [] sites { get; set; }
}
public class Site {
public string root { get; set; }
public string name { get; set; }
public string [] hosts { get; set; }
public string challenge { get; set; }
}
}

View File

@ -1,59 +0,0 @@
{
"_StagingV2": "https://acme-staging-v02.api.letsencrypt.org/directory",
"_ProductionV2": "https://acme-v02.api.letsencrypt.org/directory",
"url": "https://acme-staging-v02.api.letsencrypt.org/directory",
"www": "/var/www",
"acme": ".well-known/acme-challenge",
"ssl": "/etc/nginx/ssl",
"customers": [
{
"id": "9b4c8584-dc83-4388-b45f-2942e34dca9d",
"contacts": [ "maksym.sadovnychyy@gmail.com" ],
"name": "Maksym",
"lastname": "Sadovnychyy",
"sites": [
{
"name": "maks-it.com",
"hosts": [
"maks-it.com",
"www.maks-it.com",
"it.maks-it.com",
"www.it.maks-it.com",
"ru.maks-it.com",
"www.ru.maks-it.com",
"api.maks-it.com",
"www.api.maks-it.com"
],
"challenge": "http-01"
}
]
},
{
"id": "d6be989c-3b68-480d-9f4f-b7317674847a",
"contacts": [ "anastasiia.pavlovskaia@gmail.com" ],
"name": "Anastasiia",
"lastname": "Pavlovskaia",
"sites": [
{
"name": "nastyarey.com",
"hosts": [
"nastyarey.com",
"www.nastyarey.com",
"it.nastyarey.com",
"www.it.nastyarey.com",
"ru.nastyarey.com",
"www.ru.nastyarey.com"
],
"challenge": "http-01"
}
]
}
]
}

View File

@ -1,25 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": ".NET Core Launch (console)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/LetsEncrypt/bin/Debug/netcoreapp3.1/LetsEncrypt.dll",
"args": [],
"cwd": "${workspaceFolder}/LetsEncrypt",
"console": "internalConsole",
"stopAtEntry": false
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach",
"processId": "${command:pickProcess}"
}
]
}

View File

@ -1,42 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/LetsEncrypt/LetsEncrypt.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/LetsEncrypt/LetsEncrypt.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"${workspaceFolder}/LetsEncrypt/LetsEncrypt.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
}
]
}

View File

@ -1,25 +0,0 @@

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

View File

@ -1,36 +0,0 @@
{
"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

@ -1,200 +0,0 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
using Microsoft.Extensions.Options;
using LetsEncrypt.Services;
using LetsEncrypt.Helpers;
using LetsEncrypt.Entities;
namespace LetsEncrypt
{
public class App {
private readonly string AppPath = AppDomain.CurrentDomain.BaseDirectory;
private readonly AppSettings _appSettings;
private readonly ILetsEncryptService _letsEncryptService;
private readonly IKeyService _keyService;
private readonly ITerminalService _terminalService;
public App(IOptions<AppSettings> appSettings, ILetsEncryptService letsEncryptService, IKeyService keyService, ITerminalService terminalService) {
_appSettings = appSettings.Value;
_letsEncryptService = letsEncryptService;
_keyService = keyService;
_terminalService = terminalService;
}
public void Run() {
foreach(var env in _appSettings.environments.Where(env => env.active)) {
try {
Console.WriteLine(string.Format("Let's Encrypt C# .Net Core Client, environment: {0}", env.name));
//loop all customers
foreach(Customer customer in _appSettings.customers) {
try {
Console.WriteLine(string.Format("Managing customer: {0} - {1} {2}", customer.id, customer.name, customer.lastname));
//loop each customer website
foreach(Site site in customer.sites.Where(s => s.active)) {
Console.WriteLine(string.Format("Managing site: {0}", site.name));
try {
//define cache folder
string cache = Path.Combine(AppPath, env.cache, customer.id);
if(!Directory.Exists(cache)) {
Directory.CreateDirectory(cache);
}
//1. Client initialization
Console.WriteLine("1. Client Initialization...");
_letsEncryptService.Init(env.url, cache, site.name, customer.contacts).Wait();
Console.WriteLine(string.Format("Terms of service: {0}", _letsEncryptService.GetTermsOfServiceUri()));
//create folder for ssl
string ssl = Path.Combine(env.ssl, site.name);
if(!Directory.Exists(ssl)) {
Directory.CreateDirectory(ssl);
}
// get cached certificate and check if it's valid
// if valid check if cert and key exists otherwise recreate
// else continue with new certificate request
CachedCertificateResult certRes = new CachedCertificateResult();
if (_letsEncryptService.TryGetCachedCertificate(site.name, out certRes)) {
string cert = Path.Combine(ssl, site.name + ".crt");
//if(!File.Exists(cert))
File.WriteAllText(cert, certRes.Certificate);
string key = Path.Combine(ssl, site.name + ".key");
//if(!File.Exists(key)) {
using (StreamWriter writer = File.CreateText(key))
_keyService.ExportPrivateKey(certRes.PrivateKey, writer);
//}
Console.WriteLine("Certificate and Key exists and valid. Restored from cache.");
}
else {
//new nonce
_letsEncryptService.NewNonce().Wait();
//try to make new order
try {
//create new orders
Console.WriteLine("2. Client New Order...");
Task<Dictionary<string, string>> orders = _letsEncryptService.NewOrder(site.hosts, site.challenge);
orders.Wait();
switch(site.challenge) {
case "http-01": {
//ensure to enable static file discovery on server in .well-known/acme-challenge
//and listen on 80 port
//check acme directory
string acme = Path.Combine(env.www, env.acme);
if(!Directory.Exists(acme)) {
throw new DirectoryNotFoundException(string.Format("Directory {0} wasn't created", acme));
}
foreach (FileInfo file in new DirectoryInfo(acme).GetFiles()) {
if(file.LastWriteTimeUtc < DateTime.UtcNow.AddMonths(-3))
file.Delete();
}
foreach (var result in orders.Result)
{
Console.WriteLine("Key: " + result.Key + System.Environment.NewLine + "Value: " + result.Value);
string[] splitToken = result.Value.Split('~');
string token = Path.Combine(acme, splitToken[0]);
File.WriteAllText(token, splitToken[1]);
}
_terminalService.Exec("chgrp -R nginx /var/www");
_terminalService.Exec("chmod -R g+rwx /var/www");
break;
}
case "dns-01": {
//Manage DNS server MX record, depends from provider
break;
}
default: {
break;
}
}
//complete challanges
Console.WriteLine("3. Client Complete Challange...");
_letsEncryptService.CompleteChallenges().Wait();
Console.WriteLine("Challanges comleted.");
}
catch (Exception ex) {
Console.WriteLine(ex.Message.ToString());
_letsEncryptService.GetOrder(site.hosts).Wait();
}
// Download new certificate
Console.WriteLine("4. Download certificate...");
_letsEncryptService.GetCertificate(site.name).Wait();
// Write to filesystem
certRes = new CachedCertificateResult();
if (_letsEncryptService.TryGetCachedCertificate(site.name, out certRes)) {
string cert = Path.Combine(ssl, site.name + ".crt");
File.WriteAllText(cert, certRes.Certificate);
string key = Path.Combine(ssl, site.name + ".key");
using (StreamWriter writer = File.CreateText(key))
_keyService.ExportPrivateKey(certRes.PrivateKey, writer);
Console.WriteLine("Certificate saved.");
}
else {
Console.WriteLine("Unable to get new cached certificate.");
}
}
}
catch (Exception ex) {
Console.WriteLine(ex.Message.ToString());
}
}
}
catch (Exception ex) {
Console.WriteLine(ex.Message.ToString());
}
}
if(env.name == "ProductionV2") {
_terminalService.Exec("systemctl restart nginx");
}
}
catch (Exception ex) {
Console.WriteLine(ex.Message.ToString());
break;
}
}
}
}
}

View File

@ -1,88 +0,0 @@
/*
* https://tools.ietf.org/html/draft-ietf-acme-acme-18#section-7.3
*/
using System;
using Newtonsoft.Json;
using LetsEncrypt.Exceptions;
namespace LetsEncrypt.Entities
{
interface IHasLocation
{
Uri Location { get; set; }
}
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; }
}
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

@ -1,43 +0,0 @@
using System;
using Newtonsoft.Json;
namespace LetsEncrypt.Entities
{
public class AcmeDirectory
{
//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 AcmeDirectoryMeta Meta { get; set; }
}
public class AcmeDirectoryMeta
{
[JsonProperty("termsOfService")]
public string TermsOfService { get; set; }
}
}

View File

@ -1,22 +0,0 @@
using System;
using Newtonsoft.Json;
namespace LetsEncrypt.Entities
{
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

@ -1,32 +0,0 @@
using System;
using Newtonsoft.Json;
namespace LetsEncrypt.Entities
{
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; }
}
public class AuthorizeChallenge
{
[JsonProperty("keyAuthorization")]
public string KeyAuthorization { get; set; }
}
}

View File

@ -1,11 +0,0 @@
using System.Security.Cryptography;
namespace LetsEncrypt.Entities
{
public class CachedCertificateResult
{
public RSACryptoServiceProvider PrivateKey;
public string Certificate;
}
}

View File

@ -1,11 +0,0 @@
namespace LetsEncrypt.Entities
{
public class CertificateCache
{
public string Cert;
public byte[] Private;
}
}

View File

@ -1,11 +0,0 @@
using Newtonsoft.Json;
namespace LetsEncrypt.Entities
{
public class FinalizeRequest
{
[JsonProperty("csr")]
public string CSR { get; set; }
}
}

View File

@ -1,61 +0,0 @@
using System;
using Newtonsoft.Json;
namespace LetsEncrypt.Entities
{
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; }
}
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

@ -1,16 +0,0 @@
using Newtonsoft.Json;
namespace LetsEncrypt.Entities
{
public class OrderIdentifier
{
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("value")]
public string Value { get; set; }
}
}

View File

@ -1,17 +0,0 @@
using System;
using System.Collections.Generic;
namespace LetsEncrypt.Entities
{
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

@ -1,33 +0,0 @@
using System;
using System.Net.Http;
using Newtonsoft.Json;
namespace LetsEncrypt.Exceptions
{
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; }
}
public class Problem
{
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("detail")]
public string Detail { get; set; }
public string RawJson { get; set; }
}
}

View File

@ -1,32 +0,0 @@
namespace LetsEncrypt.Helpers
{
public class AppSettings {
public Environment [] environments { get; set; }
public Customer [] customers { get; set;}
}
public class Environment {
public bool active { get; set; }
public string name { get; set; }
public string url { get; set; }
public string cache { get; set; }
public string www { get; set; }
public string acme { get; set; }
public string ssl { get; set; }
}
public class Customer {
public string id { get; set; }
public string [] contacts { get; set; }
public string name { get; set; }
public string lastname { get; set; }
public Site [] sites { get; set; }
}
public class Site {
public bool active { get; set; }
public string name { get; set; }
public string [] hosts { get; set; }
public string challenge { get; set; }
}
}

View File

@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="3.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="3.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="3.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="3.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="3.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="3.0.0" />
<PackageReference Include="Mono.Posix.NETStandard" Version="1.0.0" />
<PackageReference Include="Newtonsoft.JSON" Version="12.0.2" />
</ItemGroup>
</Project>

View File

@ -1,55 +0,0 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.IO;
using LetsEncrypt.Helpers;
using LetsEncrypt.Services;
namespace LetsEncrypt
{
class Program
{
public IConfiguration Configuration { get; }
static void Main(string[] args) {
// create service collection
var services = new ServiceCollection();
ConfigureServices(services);
// create service provider
var serviceProvider = services.BuildServiceProvider();
// entry to run app
serviceProvider.GetService<App>().Run();
}
public static void ConfigureServices(IServiceCollection services) {
// build configuration
IConfiguration Configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", false)
.Build();
// configure strongly typed settings objects
var appSettingsSection = Configuration.GetSection("AppSettings");
services.Configure<AppSettings>(appSettingsSection);
// Dependency Injection
services.AddScoped<IKeyService, KeyService>();
services.AddScoped<IJwsService, JwsService>();
services.AddScoped<ILetsEncryptService, LetsEncryptService>();
services.AddScoped<ITerminalService,TerminalService>();
// add app
services.AddTransient<App>();
}
}
}

View File

@ -1,136 +0,0 @@
/**
* https://tools.ietf.org/html/rfc4648
* https://tools.ietf.org/html/rfc4648#section-5
*/
using System;
using System.Security.Cryptography;
using System.Text;
using Newtonsoft.Json;
using LetsEncrypt.Entities;
namespace LetsEncrypt.Services
{
public interface IJwsService {
void Init(RSA rsa, string keyId);
JwsMessage Encode<TPayload>(TPayload payload, JwsHeader protectedHeader);
string GetKeyAuthorization(string token);
string Base64UrlEncoded(byte[] arg);
void SetKeyId(Account account);
}
public class JwsService : IJwsService {
public Jwk _jwk;
private RSA _rsa;
public JwsService() {
}
public void Init(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 = "",
Protected = Base64UrlEncoded(JsonConvert.SerializeObject(protectedHeader))
};
if(payload != null) {
if(payload is String) {
string value = payload.ToString();
switch(value) {
case "POST-as-GET":
message.Payload = string.Empty;
break;
default:
message.Payload = Base64UrlEncoded(value);
break;
}
} else {
message.Payload = Base64UrlEncoded(JsonConvert.SerializeObject(payload));
}
}
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 string Base64UrlEncoded(string s)
{
return Base64UrlEncoded(Encoding.UTF8.GetBytes(s));
}
// https://tools.ietf.org/html/rfc4648#section-5
public 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;
}
public void SetKeyId(Account account)
{
_jwk.KeyId = account.Id;
}
}
}

View File

@ -1,175 +0,0 @@
using System;
using System.IO;
using System.Security.Cryptography;
namespace LetsEncrypt.Services {
public interface IKeyService {
void ExportPublicKey(RSACryptoServiceProvider csp, TextWriter outputStream);
void ExportPrivateKey(RSACryptoServiceProvider csp, TextWriter outputStream);
}
public class KeyService : IKeyService {
/// <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(X509Certificate2 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();
//}
public void ExportPublicKey(RSACryptoServiceProvider csp, TextWriter outputStream)
{
var parameters = csp.ExportParameters(false);
using (var stream = new MemoryStream())
{
var writer = new BinaryWriter(stream);
writer.Write((byte)0x30); // SEQUENCE
using (var innerStream = new MemoryStream())
{
var innerWriter = new BinaryWriter(innerStream);
innerWriter.Write((byte)0x30); // SEQUENCE
EncodeLength(innerWriter, 13);
innerWriter.Write((byte)0x06); // OBJECT IDENTIFIER
var rsaEncryptionOid = new byte[] { 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01 };
EncodeLength(innerWriter, rsaEncryptionOid.Length);
innerWriter.Write(rsaEncryptionOid);
innerWriter.Write((byte)0x05); // NULL
EncodeLength(innerWriter, 0);
innerWriter.Write((byte)0x03); // BIT STRING
using (var bitStringStream = new MemoryStream())
{
var bitStringWriter = new BinaryWriter(bitStringStream);
bitStringWriter.Write((byte)0x00); // # of unused bits
bitStringWriter.Write((byte)0x30); // SEQUENCE
using (var paramsStream = new MemoryStream())
{
var paramsWriter = new BinaryWriter(paramsStream);
EncodeIntegerBigEndian(paramsWriter, parameters.Modulus); // Modulus
EncodeIntegerBigEndian(paramsWriter, parameters.Exponent); // Exponent
var paramsLength = (int)paramsStream.Length;
EncodeLength(bitStringWriter, paramsLength);
bitStringWriter.Write(paramsStream.GetBuffer(), 0, paramsLength);
}
var bitStringLength = (int)bitStringStream.Length;
EncodeLength(innerWriter, bitStringLength);
innerWriter.Write(bitStringStream.GetBuffer(), 0, bitStringLength);
}
var length = (int)innerStream.Length;
EncodeLength(writer, length);
writer.Write(innerStream.GetBuffer(), 0, length);
}
var base64 = Convert.ToBase64String(stream.GetBuffer(), 0, (int)stream.Length).ToCharArray();
outputStream.WriteLine("-----BEGIN PUBLIC KEY-----");
for (var i = 0; i < base64.Length; i += 64)
{
outputStream.WriteLine(base64, i, Math.Min(64, base64.Length - i));
}
outputStream.WriteLine("-----END PUBLIC KEY-----");
}
}
public void ExportPrivateKey(RSACryptoServiceProvider csp, TextWriter outputStream)
{
if (csp.PublicOnly) throw new ArgumentException("CSP does not contain a private key", "csp");
var parameters = csp.ExportParameters(true);
using (var stream = new MemoryStream())
{
var writer = new BinaryWriter(stream);
writer.Write((byte)0x30); // SEQUENCE
using (var innerStream = new MemoryStream())
{
var innerWriter = new BinaryWriter(innerStream);
EncodeIntegerBigEndian(innerWriter, new byte[] { 0x00 }); // Version
EncodeIntegerBigEndian(innerWriter, parameters.Modulus);
EncodeIntegerBigEndian(innerWriter, parameters.Exponent);
EncodeIntegerBigEndian(innerWriter, parameters.D);
EncodeIntegerBigEndian(innerWriter, parameters.P);
EncodeIntegerBigEndian(innerWriter, parameters.Q);
EncodeIntegerBigEndian(innerWriter, parameters.DP);
EncodeIntegerBigEndian(innerWriter, parameters.DQ);
EncodeIntegerBigEndian(innerWriter, parameters.InverseQ);
var length = (int)innerStream.Length;
EncodeLength(writer, length);
writer.Write(innerStream.GetBuffer(), 0, length);
}
var base64 = Convert.ToBase64String(stream.GetBuffer(), 0, (int)stream.Length).ToCharArray();
outputStream.WriteLine("-----BEGIN RSA PRIVATE KEY-----");
// Output as Base64 with lines chopped at 64 characters
for (var i = 0; i < base64.Length; i += 64)
{
outputStream.WriteLine(base64, i, Math.Min(64, base64.Length - i));
}
outputStream.WriteLine("-----END RSA PRIVATE KEY-----");
}
}
private void EncodeLength(BinaryWriter stream, int length)
{
if (length < 0) throw new ArgumentOutOfRangeException("length", "Length must be non-negative");
if (length < 0x80)
{
// Short form
stream.Write((byte)length);
}
else
{
// Long form
var temp = length;
var bytesRequired = 0;
while (temp > 0)
{
temp >>= 8;
bytesRequired++;
}
stream.Write((byte)(bytesRequired | 0x80));
for (var i = bytesRequired - 1; i >= 0; i--)
{
stream.Write((byte)(length >> (8 * i) & 0xff));
}
}
}
private void EncodeIntegerBigEndian(BinaryWriter stream, byte[] value, bool forceUnsigned = true)
{
stream.Write((byte)0x02); // INTEGER
var prefixZeros = 0;
for (var i = 0; i < value.Length; i++)
{
if (value[i] != 0) break;
prefixZeros++;
}
if (value.Length - prefixZeros == 0)
{
EncodeLength(stream, 1);
stream.Write((byte)0);
}
else
{
if (forceUnsigned && value[prefixZeros] > 0x7f)
{
// Add a prefix zero to force unsigned if the MSB is 1
EncodeLength(stream, value.Length - prefixZeros + 1);
stream.Write((byte)0);
}
else
{
EncodeLength(stream, value.Length - prefixZeros);
}
for (var i = prefixZeros; i < value.Length; i++)
{
stream.Write(value[i]);
}
}
}
}
}

View File

@ -1,588 +0,0 @@
/**
* https://community.letsencrypt.org/t/trying-to-do-post-as-get-but-getting-post-jws-not-signed/108371
* https://tools.ietf.org/html/rfc8555#section-6.2
*
*/
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Net.Http;
using System.IO;
using System.Text;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using LetsEncrypt.Entities;
using LetsEncrypt.Exceptions;
namespace LetsEncrypt.Services {
public interface ILetsEncryptService {
Task Init(string url, string home, string siteName, string[] contacts, CancellationToken token = default(CancellationToken));
string GetTermsOfServiceUri(CancellationToken token = default(CancellationToken));
bool TryGetCachedCertificate(string subject, out CachedCertificateResult value);
Task NewNonce(CancellationToken token = default(CancellationToken));
Task<Dictionary<string, string>> NewOrder(string[] hostnames, string challengeType, CancellationToken token = default(CancellationToken));
Task CompleteChallenges(CancellationToken token = default(CancellationToken));
Task GetOrder(string[] hostnames, CancellationToken token = default(CancellationToken));
Task<(X509Certificate2 Cert, RSA PrivateKey)> GetCertificate(string subject, CancellationToken token = default(CancellationToken));
}
public class LetsEncryptService: ILetsEncryptService {
private readonly string AppPath = AppDomain.CurrentDomain.BaseDirectory;
private static readonly JsonSerializerSettings jsonSettings = new JsonSerializerSettings {
NullValueHandling = NullValueHandling.Ignore,
Formatting = Formatting.Indented
};
private readonly IJwsService _jwsService;
private string _path;
private string _url;
private string _home;
private string _nonce;
private RSACryptoServiceProvider _accountKey;
private RegistrationCache _cache;
private HttpClient _client;
private AcmeDirectory _directory;
private List<AuthorizationChallenge> _challenges = new List<AuthorizationChallenge>();
private Order _currentOrder;
public LetsEncryptService(IJwsService jwsService) {
_jwsService = jwsService;
}
/// <summary>
/// Account creation or Initialization from cache
/// </summary>
/// <param name="contacts"></param>
/// <param name="token"></param>
/// <returns></returns>
public async Task Init(string url, string home, string siteName, string[] contacts, CancellationToken token = default(CancellationToken)) {
// old Letsencrypt constructor
_url = url ?? throw new ArgumentNullException(nameof(url));
var hash = SHA256.Create().ComputeHash(Encoding.UTF8.GetBytes(siteName));
_home = home ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData, Environment.SpecialFolderOption.Create);
var file = _jwsService.Base64UrlEncoded(hash) + ".lets-encrypt.cache.json";
_path = Path.Combine(_home, file);
// originally Init part was here
_accountKey = new RSACryptoServiceProvider(4096);
_client = GetCachedClient(_url);
// 1 - Get directory
(_directory, _) = await SendAsync<AcmeDirectory>(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
_jwsService.Init(_accountKey, null);
var letsEncryptOrder = 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()
};
var (account, response) = await SendAsync<Account>(HttpMethod.Post, _directory.NewAccount, letsEncryptOrder, token);
_jwsService.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))
{
var result = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Head, _directory.NewNonce)).ConfigureAwait(false);
_nonce = result.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
_jwsService.Init(_accountKey, _cache.Location.ToString());
var letsEncryptOrder = new Order
{
Expires = DateTime.UtcNow.AddDays(2),
Identifiers = hostnames.Select(hostname => new OrderIdentifier
{
Type = "dns",
Value = hostname
}).ToArray()
};
var (order, response) = await SendAsync<Order>(HttpMethod.Post, _directory.NewOrder, letsEncryptOrder, 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.Post, item, "POST-as-GET", 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 = _jwsService.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 = _jwsService.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))
{
_jwsService.Init(_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 = _jwsService.GetKeyAuthorization(challenge.Token);
//var (result, responseText) = await SendAsync<AuthorizationChallengeResponse>(HttpMethod.Post, challenge.Url, authorizeChallenge, token);
break;
}
case "http-01": {
break;
}
}
var (result, responseText) = await SendAsync<AuthorizationChallengeResponse>(HttpMethod.Post, challenge.Url, "{}", 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(1000);
}
}
}
public async Task GetOrder(string[] hostnames, CancellationToken token = default(CancellationToken))
{
//update jws
_jwsService.Init(_accountKey, _cache.Location.ToString());
var letsEncryptOrder = new Order
{
Expires = DateTime.UtcNow.AddDays(2),
Identifiers = hostnames.Select(hostname => new OrderIdentifier
{
Type = "dns",
Value = hostname
}).ToArray()
};
var (order, response) = await SendAsync<Order>(HttpMethod.Post, _directory.NewOrder, letsEncryptOrder, token);
_currentOrder = order;
}
/// <summary>
///
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
public async Task<(X509Certificate2 Cert, RSA PrivateKey)> GetCertificate(string subject, CancellationToken token = default(CancellationToken))
{
var key = new RSACryptoServiceProvider(4096);
var csr = new CertificateRequest("CN=" + subject,
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 letsEncryptOrder = new FinalizeRequest
{
CSR = _jwsService.Base64UrlEncoded(csr.CreateSigningRequest())
};
var (response, responseText) = await SendAsync<Order>(HttpMethod.Post, _currentOrder.Finalize, letsEncryptOrder, token);
while (response.Status != "valid")
{
(response, responseText) = await SendAsync<Order>(HttpMethod.Post, response.Location, "POST-as-GET", 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.Post, response.Certificate, "POST-as-GET", token);
var cert = new X509Certificate2(Encoding.UTF8.GetBytes(pem));
_cache.CachedCerts[subject] = 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 = _jwsService.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(string subject, out CachedCertificateResult value)
{
value = null;
if (_cache.CachedCerts.TryGetValue(subject, out var cache) == false)
{
return false;
}
var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cache.Cert));
// if it is about to expire, we need to refresh
if ((cert.NotAfter - DateTime.UtcNow).TotalDays < 30)
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);
}
}
private Dictionary<string, HttpClient> _cachedClients = new Dictionary<string, HttpClient>(StringComparer.OrdinalIgnoreCase);
/// <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 readonly object Locker = new object();
private 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;
}
}
}
}

View File

@ -1,74 +0,0 @@
{
"AppSettings": {
"active": "StagingV2",
"environments": [
{
"name": "StagingV2",
"url": "https://acme-staging-v02.api.letsencrypt.org/directory",
"www": "/var/www",
"acme": ".well-known/acme-challenge",
"ssl": "/home/maksym/source/temp"
},
{
"name": "ProductionV2",
"url": "https://acme-v02.api.letsencrypt.org/directory",
"www": "/var/www",
"acme": ".well-known/acme-challenge",
"ssl": "/etc/nginx/ssl"
}
],
"customers": [
{
"id": "9b4c8584-dc83-4388-b45f-2942e34dca9d",
"contacts": [ "maksym.sadovnychyy@gmail.com" ],
"name": "Maksym",
"lastname": "Sadovnychyy",
"sites": [
{
"name": "maks-it.com",
"hosts": [
"maks-it.com",
"www.maks-it.com",
"it.maks-it.com",
"www.it.maks-it.com",
"ru.maks-it.com",
"www.ru.maks-it.com",
"api.maks-it.com",
"www.api.maks-it.com"
],
"challenge": "http-01"
}
]
},
{
"id": "d6be989c-3b68-480d-9f4f-b7317674847a",
"contacts": [ "anastasiia.pavlovskaia@gmail.com" ],
"name": "Anastasiia",
"lastname": "Pavlovskaia",
"sites": [
{
"name": "nastyarey.com",
"hosts": [
"nastyarey.com",
"www.nastyarey.com",
"it.nastyarey.com",
"www.it.nastyarey.com",
"ru.nastyarey.com",
"www.ru.nastyarey.com"
],
"challenge": "http-01"
}
]
}
]
}
}

View File

@ -1,101 +0,0 @@
{
"AppSettings": {
"environments": [
{
"active": true,
"name": "StagingV2",
"url": "https://acme-staging-v02.api.letsencrypt.org/directory",
"cache": "staging_cache",
"www": "/var/www",
"acme": ".well-known/acme-challenge",
"ssl": "/home/maksym/source/temp"
},
{
"active": true,
"name": "ProductionV2",
"url": "https://acme-v02.api.letsencrypt.org/directory",
"cache": "production_cache",
"www": "/var/www",
"acme": ".well-known/acme-challenge",
"ssl": "/etc/nginx/ssl/"
}
],
"customers": [
{
"id": "9b4c8584-dc83-4388-b45f-2942e34dca9d",
"contacts": [ "maksym.sadovnychyy@gmail.com" ],
"name": "Maksym",
"lastname": "Sadovnychyy",
"sites": [
{
"active": true,
"name": "maks-it.com",
"hosts": [
"maks-it.com",
"www.maks-it.com",
"it.maks-it.com",
"www.it.maks-it.com",
"ru.maks-it.com",
"www.ru.maks-it.com",
"api.maks-it.com",
"www.api.maks-it.com",
"git.maks-it.com",
"demo.maks-it.com"
],
"challenge": "http-01"
}
]
},
{
"id": "46337ef5-d69b-4332-b6ef-67959dfb3c2c",
"contacts": [ "maksym.sadovnychyy@gmail.com" ],
"name": "Anastasiia",
"lastname": "Pavlovskaia",
"sites": [
{
"active": true,
"name": "nastyarey.com",
"hosts": [
"nastyarey.com",
"www.nastyarey.com",
"api.nastyarey.com",
"www.api.nastyarey.com"
],
"challenge": "http-01"
}
]
},
{
"id": "341ebe34-e2b3-4645-9f54-aa4fe8eb0250",
"contacts": [ "maksym.sadovnychyy@gmail.com" ],
"name": "Antonio",
"lastname": "Di Franco",
"sites": [
{
"active": false,
"name": "aerusitalia.it",
"hosts": [
"aerusitalia.it",
"www.aerusitalia.it",
"api.aerusitalia.it",
"www.api.aerusitalia.it"
],
"challenge": "http-01"
}
]
}
],
"_customers": [
]
}
}