mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2026-02-14 09:37:18 +01:00
(refactor): codebase update
This commit is contained in:
parent
9cf7ec9012
commit
6aae71b7ac
14
src/Core/Core.csproj
Normal file
14
src/Core/Core.csproj
Normal 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>
|
||||||
37
src/Core/Extensions/ObjectExtensions.cs
Normal file
37
src/Core/Extensions/ObjectExtensions.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/Core/Extensions/StringExtensions.cs
Normal file
40
src/Core/Extensions/StringExtensions.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/Core/OperatingSystem.cs
Normal file
14
src/Core/OperatingSystem.cs
Normal 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
37
src/LetsEncrypt.sln
Normal 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
|
||||||
@ -1,10 +1,8 @@
|
|||||||
// https://tools.ietf.org/html/rfc7517
|
// https://tools.ietf.org/html/rfc7517
|
||||||
|
|
||||||
using System;
|
using System.Text.Json.Serialization;
|
||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
|
namespace MaksIT.LetsEncrypt.Entities.Jws
|
||||||
namespace LetsEncrypt.Entities
|
|
||||||
{
|
{
|
||||||
public class Jwk
|
public class Jwk
|
||||||
{
|
{
|
||||||
@ -15,8 +13,8 @@ namespace LetsEncrypt.Entities
|
|||||||
/// family used with the key, such as "RSA" or "EC".
|
/// family used with the key, such as "RSA" or "EC".
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("kty")]
|
[JsonPropertyName("kty")]
|
||||||
public string KeyType { get; set; }
|
public string? KeyType { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// "kid" (Key ID) Parameter
|
/// "kid" (Key ID) Parameter
|
||||||
@ -27,8 +25,8 @@ namespace LetsEncrypt.Entities
|
|||||||
/// unspecified.
|
/// unspecified.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("kid")]
|
[JsonPropertyName("kid")]
|
||||||
public string KeyId { get; set; }
|
public string? KeyId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// "use" (Public Key Use) Parameter
|
/// "use" (Public Key Use) Parameter
|
||||||
@ -39,62 +37,62 @@ namespace LetsEncrypt.Entities
|
|||||||
/// on data.
|
/// on data.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("use")]
|
[JsonPropertyName("use")]
|
||||||
public string Use { get; set; }
|
public string? Use { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The the modulus value for the public RSA key. It is represented as the Base64URL encoding of value's big endian representation.
|
/// The the modulus value for the public RSA key. It is represented as the Base64URL encoding of value's big endian representation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("n")]
|
[JsonPropertyName("n")]
|
||||||
public string Modulus { get; set; }
|
public string? Modulus { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The exponent value for the public RSA key. It is represented as the Base64URL encoding of value's big endian representation.
|
/// The exponent value for the public RSA key. It is represented as the Base64URL encoding of value's big endian representation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("e")]
|
[JsonPropertyName("e")]
|
||||||
public string Exponent { get; set; }
|
public string? Exponent { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The private exponent. It is represented as the Base64URL encoding of the value's big endian representation.
|
/// The private exponent. It is represented as the Base64URL encoding of the value's big endian representation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("d")]
|
[JsonPropertyName("d")]
|
||||||
public string D { get; set; }
|
public string? D { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The first prime factor. It is represented as the Base64URL encoding of the value's big endian representation.
|
/// The first prime factor. It is represented as the Base64URL encoding of the value's big endian representation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("p")]
|
[JsonPropertyName("p")]
|
||||||
public string P { get; set; }
|
public string? P { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The second prime factor. It is represented as the Base64URL encoding of the value's big endian representation.
|
/// The second prime factor. It is represented as the Base64URL encoding of the value's big endian representation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("q")]
|
[JsonPropertyName("q")]
|
||||||
public string Q { get; set; }
|
public string? Q { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The first factor Chinese Remainder Theorem exponent. It is represented as the Base64URL encoding of the value's big endian representation.
|
/// The first factor Chinese Remainder Theorem exponent. It is represented as the Base64URL encoding of the value's big endian representation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("dp")]
|
[JsonPropertyName("dp")]
|
||||||
public string DP { get; set; }
|
public string? DP { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The second factor Chinese Remainder Theorem exponent. It is represented as the Base64URL encoding of the value's big endian representation.
|
/// The second factor Chinese Remainder Theorem exponent. It is represented as the Base64URL encoding of the value's big endian representation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("dq")]
|
[JsonPropertyName("dq")]
|
||||||
public string DQ { get; set; }
|
public string? DQ { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The first Chinese Remainder Theorem coefficient. It is represented as the Base64URL encoding of the value's big endian representation.
|
/// The first Chinese Remainder Theorem coefficient. It is represented as the Base64URL encoding of the value's big endian representation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("qi")]
|
[JsonPropertyName("qi")]
|
||||||
public string InverseQ { get; set; }
|
public string? InverseQ { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The other primes information, should they exist, null or an empty list if not specified.
|
/// The other primes information, should they exist, null or an empty list if not specified.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("oth")]
|
[JsonPropertyName("oth")]
|
||||||
public string OthInf { get; set; }
|
public string? OthInf { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// "alg" (Algorithm) Parameter
|
/// "alg" (Algorithm) Parameter
|
||||||
@ -103,7 +101,7 @@ namespace LetsEncrypt.Entities
|
|||||||
/// use with the key.
|
/// use with the key.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonProperty("alg")]
|
[JsonPropertyName("alg")]
|
||||||
public string Algorithm { get; set; }
|
public string? Algorithm { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
40
src/LetsEncrypt/Entities/Jws/JwsMessage.cs
Normal file
40
src/LetsEncrypt/Entities/Jws/JwsMessage.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
18
src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs
Normal file
18
src/LetsEncrypt/Entities/LetsEncrypt/RegistrationCache.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/LetsEncrypt/Exceptions/LetsEncrytException.cs
Normal file
25
src/LetsEncrypt/Exceptions/LetsEncrytException.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/LetsEncrypt/Extensions/ServiceCollectionExtensions.cs
Normal file
13
src/LetsEncrypt/Extensions/ServiceCollectionExtensions.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/LetsEncrypt/LetsEncrypt.csproj
Normal file
21
src/LetsEncrypt/LetsEncrypt.csproj
Normal 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>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
namespace MaksIT.LetsEncrypt.Models.Interfaces {
|
||||||
|
public interface IHasLocation {
|
||||||
|
Uri? Location { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/LetsEncrypt/Models/Requests/FinalizeRequest.cs
Normal file
7
src/LetsEncrypt/Models/Requests/FinalizeRequest.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace MaksIT.LetsEncrypt.Models.Requests
|
||||||
|
{
|
||||||
|
public class FinalizeRequest
|
||||||
|
{
|
||||||
|
public string? Csr { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/LetsEncrypt/Models/Responses/Account.cs
Normal file
38
src/LetsEncrypt/Models/Responses/Account.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,43 +1,29 @@
|
|||||||
using System;
|
using System;
|
||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace ACMEv2
|
namespace MaksIT.LetsEncrypt.Models.Responses
|
||||||
{
|
{
|
||||||
public class AcmeDirectory
|
public class AcmeDirectory
|
||||||
{
|
{
|
||||||
//New nonce
|
|
||||||
[JsonProperty("newNonce")]
|
|
||||||
public Uri NewNonce { get; set; }
|
public Uri NewNonce { get; set; }
|
||||||
|
|
||||||
//New account
|
|
||||||
[JsonProperty("newAccount")]
|
|
||||||
public Uri NewAccount { get; set; }
|
public Uri NewAccount { get; set; }
|
||||||
|
|
||||||
//New order
|
|
||||||
[JsonProperty("newOrder")]
|
|
||||||
public Uri NewOrder { get; set; }
|
public Uri NewOrder { get; set; }
|
||||||
|
|
||||||
// New authorization If the ACME server does not implement pre-authorization
|
// New authorization If the ACME server does not implement pre-authorization
|
||||||
// (Section 7.4.1) it MUST omit the "newAuthz" field of the directory.
|
// (Section 7.4.1) it MUST omit the "newAuthz" field of the directory.
|
||||||
// [JsonProperty("newAuthz")]
|
// [JsonProperty("newAuthz")]
|
||||||
// public Uri NewAuthz { get; set; }
|
// public Uri NewAuthz { get; set; }
|
||||||
|
|
||||||
//Revoke certificate
|
|
||||||
[JsonProperty("revokeCert")]
|
|
||||||
public Uri RevokeCertificate { get; set; }
|
public Uri RevokeCertificate { get; set; }
|
||||||
|
|
||||||
//Key change
|
|
||||||
[JsonProperty("keyChange")]
|
|
||||||
public Uri KeyChange { get; set; }
|
public Uri KeyChange { get; set; }
|
||||||
|
|
||||||
//Metadata object
|
|
||||||
[JsonProperty("meta")]
|
|
||||||
public AcmeDirectoryMeta Meta { get; set; }
|
public AcmeDirectoryMeta Meta { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AcmeDirectoryMeta
|
public class AcmeDirectoryMeta
|
||||||
{
|
{
|
||||||
[JsonProperty("termsOfService")]
|
|
||||||
public string TermsOfService { get; set; }
|
public string TermsOfService { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/LetsEncrypt/Models/Responses/Order.cs
Normal file
34
src/LetsEncrypt/Models/Responses/Order.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/LetsEncrypt/Services/JwsService.cs
Normal file
114
src/LetsEncrypt/Services/JwsService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
478
src/LetsEncrypt/Services/LetsEncryptService.cs
Normal file
478
src/LetsEncrypt/Services/LetsEncryptService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
287
src/LetsEncryptConsole/App.cs
Normal file
287
src/LetsEncryptConsole/App.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/LetsEncryptConsole/Configuration.cs
Normal file
74
src/LetsEncryptConsole/Configuration.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/LetsEncryptConsole/LetsEncryptConsole.csproj
Normal file
43
src/LetsEncryptConsole/LetsEncryptConsole.csproj
Normal 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>
|
||||||
70
src/LetsEncryptConsole/Program.cs
Normal file
70
src/LetsEncryptConsole/Program.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -81,3 +81,42 @@ Lines with labels in quotes indicate HTTP link relations.
|
|||||||
| |
|
| |
|
||||||
V V
|
V V
|
||||||
valid invalid
|
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 | |
|
||||||
|
+-------------------+--------------------------------+--------------+
|
||||||
151
src/LetsEncryptConsole/Services/KeyService.cs
Normal file
151
src/LetsEncryptConsole/Services/KeyService.cs
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
using System;
|
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
|
||||||
namespace LetsEncrypt {
|
namespace MaksIT.LetsEncryptConsole.Services {
|
||||||
|
|
||||||
public interface ITerminalService {
|
public interface ITerminalService {
|
||||||
void Exec(string cmd);
|
void Exec(string cmd);
|
||||||
94
src/LetsEncryptConsole/appsettings.json
Normal file
94
src/LetsEncryptConsole/appsettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
27
v1.0/LetsEncrypt/.vscode/launch.json
vendored
27
v1.0/LetsEncrypt/.vscode/launch.json
vendored
@ -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}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace ACMEv2
|
|
||||||
{
|
|
||||||
|
|
||||||
public class AuthorizeChallenge
|
|
||||||
{
|
|
||||||
[JsonProperty("keyAuthorization")]
|
|
||||||
public string KeyAuthorization { get; set; }
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
|
|
||||||
namespace ACMEv2
|
|
||||||
{
|
|
||||||
public class CachedCertificateResult
|
|
||||||
{
|
|
||||||
public RSACryptoServiceProvider PrivateKey;
|
|
||||||
public string Certificate;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
namespace ACMEv2
|
|
||||||
{
|
|
||||||
public class CertificateCache
|
|
||||||
{
|
|
||||||
public string Cert;
|
|
||||||
public byte[] Private;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace ACMEv2
|
|
||||||
{
|
|
||||||
public class FinalizeRequest
|
|
||||||
{
|
|
||||||
[JsonProperty("csr")]
|
|
||||||
public string CSR { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace ACMEv2
|
|
||||||
{
|
|
||||||
interface IHasLocation
|
|
||||||
{
|
|
||||||
Uri Location { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
@ -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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
25
v2.0/.vscode/launch.json
vendored
25
v2.0/.vscode/launch.json
vendored
@ -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}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
42
v2.0/.vscode/tasks.json
vendored
42
v2.0/.vscode/tasks.json
vendored
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
36
v2.0/LetsEncrypt/.vscode/tasks.json
vendored
36
v2.0/LetsEncrypt/.vscode/tasks.json
vendored
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
|
|
||||||
namespace LetsEncrypt.Entities
|
|
||||||
{
|
|
||||||
public class CachedCertificateResult
|
|
||||||
{
|
|
||||||
public RSACryptoServiceProvider PrivateKey;
|
|
||||||
public string Certificate;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
namespace LetsEncrypt.Entities
|
|
||||||
{
|
|
||||||
public class CertificateCache
|
|
||||||
{
|
|
||||||
public string Cert;
|
|
||||||
public byte[] Private;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace LetsEncrypt.Entities
|
|
||||||
{
|
|
||||||
public class FinalizeRequest
|
|
||||||
{
|
|
||||||
[JsonProperty("csr")]
|
|
||||||
public string CSR { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
@ -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>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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": [
|
|
||||||
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user