version 2.0 release based on Dipendency Injection and appsettings.json

This commit is contained in:
Maksym Sadovnychyy 2019-11-01 01:15:07 +01:00
parent 414d05079a
commit ccda9417d0
54 changed files with 1839 additions and 3 deletions

1
.gitignore vendored
View File

@ -194,6 +194,7 @@ ClientBin/
*.publishsettings
node_modules/
orleans.codegen.cs
.directory
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)

View File

@ -2,9 +2,7 @@
"_StagingV2": "https://acme-staging-v02.api.letsencrypt.org/directory",
"_ProductionV2": "https://acme-v02.api.letsencrypt.org/directory",
"url": "https://acme-v02.api.letsencrypt.org/directory",
"cache": "/home/maksym/Desktop/LetsEncrypt_Cache/cache",
"url": "https://acme-staging-v02.api.letsencrypt.org/directory",
"www": "/var/www",
"acme": ".well-known/acme-challenge",

25
v2.0/LetsEncrypt.sln Normal file
View File

@ -0,0 +1,25 @@

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

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

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

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

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

184
v2.0/LetsEncrypt/App.cs Normal file
View File

@ -0,0 +1,184 @@
using System;
using System.IO;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Linq;
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;
public App(IOptions<AppSettings> appSettings, ILetsEncryptService letsEncryptService, IKeyService keyService) {
_appSettings = appSettings.Value;
_letsEncryptService = letsEncryptService;
_keyService = keyService;
}
public void Run() {
try
{
LetsEncrypt.Helpers.Environment env = _appSettings.environments.First(x => (x.name == _appSettings.active));
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) {
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);
}
//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.");
}
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())
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]);
}
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());
}
}
}
catch (Exception ex) {
Console.WriteLine(ex.Message.ToString());
}
}
}
}

View File

@ -0,0 +1,88 @@
/*
* https://tools.ietf.org/html/draft-ietf-acme-acme-18#section-7.3
*/
using System;
using Newtonsoft.Json;
using LetsEncrypt.Exceptions;
namespace LetsEncrypt.Entities
{
interface IHasLocation
{
Uri Location { get; set; }
}
public class Account : IHasLocation
{
[JsonProperty("termsOfServiceAgreed")]
public bool TermsOfServiceAgreed { get; set; }
/*
onlyReturnExisting (optional, boolean): If this field is present
with the value "true", then the server MUST NOT create a new
account if one does not already exist. This allows a client to
look up an account URL based on an account key
*/
[JsonProperty("onlyReturnExisting")]
public bool OnlyReturnExisting { get; set; }
[JsonProperty("contact")]
public string[] Contacts { get; set; }
[JsonProperty("status")]
public string Status { get; set; }
[JsonProperty("id")]
public string Id { get; set; }
[JsonProperty("createdAt")]
public DateTime CreatedAt { get; set; }
[JsonProperty("key")]
public Jwk Key { get; set; }
[JsonProperty("initialIp")]
public string InitialIp { get; set; }
[JsonProperty("orders")]
public Uri Orders { get; set; }
public Uri Location { get; set; }
}
public class Order : IHasLocation
{
public Uri Location { get; set; }
[JsonProperty("status")]
public string Status { get; set; }
[JsonProperty("expires")]
public DateTime? Expires { get; set; }
[JsonProperty("identifiers")]
public OrderIdentifier[] Identifiers { get; set; }
[JsonProperty("notBefore")]
public DateTime? NotBefore { get; set; }
[JsonProperty("notAfter")]
public DateTime? NotAfter { get; set; }
[JsonProperty("error")]
public Problem Error { get; set; }
[JsonProperty("authorizations")]
public Uri[] Authorizations { get; set; }
[JsonProperty("finalize")]
public Uri Finalize { get; set; }
[JsonProperty("certificate")]
public Uri Certificate { get; set; }
}
}

View File

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

View File

@ -0,0 +1,22 @@
using System;
using Newtonsoft.Json;
namespace LetsEncrypt.Entities
{
public class AuthorizationChallenge
{
[JsonProperty("url")]
public Uri Url { get; set; }
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("status")]
public string Status { get; set; }
[JsonProperty("token")]
public string Token { get; set; }
}
}

View File

@ -0,0 +1,32 @@
using System;
using Newtonsoft.Json;
namespace LetsEncrypt.Entities
{
public class AuthorizationChallengeResponse
{
[JsonProperty("identifier")]
public OrderIdentifier Identifier { get; set; }
[JsonProperty("status")]
public string Status { get; set; }
[JsonProperty("expires")]
public DateTime? Expires { get; set; }
[JsonProperty("wildcard")]
public bool Wildcard { get; set; }
[JsonProperty("challenges")]
public AuthorizationChallenge[] Challenges { get; set; }
}
public class AuthorizeChallenge
{
[JsonProperty("keyAuthorization")]
public string KeyAuthorization { get; set; }
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,107 @@
using System;
using Newtonsoft.Json;
namespace LetsEncrypt.Entities
{
public class Jwk
{
/// <summary>
/// "kty" (Key Type) Parameter
/// <para>
/// The "kty" (key type) parameter identifies the cryptographic algorithm
/// family used with the key, such as "RSA" or "EC".
/// </para>
/// </summary>
[JsonProperty("kty")]
public string KeyType { get; set; }
/// <summary>
/// "kid" (Key ID) Parameter
/// <para>
/// The "kid" (key ID) parameter is used to match a specific key. This
/// is used, for instance, to choose among a set of keys within a JWK Set
/// during key rollover. The structure of the "kid" value is
/// unspecified.
/// </para>
/// </summary>
[JsonProperty("kid")]
public string KeyId { get; set; }
/// <summary>
/// "use" (Public Key Use) Parameter
/// <para>
/// The "use" (public key use) parameter identifies the intended use of
/// the public key. The "use" parameter is employed to indicate whether
/// a public key is used for encrypting data or verifying the signature
/// on data.
/// </para>
/// </summary>
[JsonProperty("use")]
public string Use { get; set; }
/// <summary>
/// The the modulus value for the public RSA key. It is represented as the Base64URL encoding of value's big endian representation.
/// </summary>
[JsonProperty("n")]
public string Modulus { get; set; }
/// <summary>
/// The exponent value for the public RSA key. It is represented as the Base64URL encoding of value's big endian representation.
/// </summary>
[JsonProperty("e")]
public string Exponent { get; set; }
/// <summary>
/// The private exponent. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("d")]
public string D { get; set; }
/// <summary>
/// The first prime factor. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("p")]
public string P { get; set; }
/// <summary>
/// The second prime factor. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("q")]
public string Q { get; set; }
/// <summary>
/// The first factor Chinese Remainder Theorem exponent. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("dp")]
public string DP { get; set; }
/// <summary>
/// The second factor Chinese Remainder Theorem exponent. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("dq")]
public string DQ { get; set; }
/// <summary>
/// The first Chinese Remainder Theorem coefficient. It is represented as the Base64URL encoding of the value's big endian representation.
/// </summary>
[JsonProperty("qi")]
public string InverseQ { get; set; }
/// <summary>
/// The other primes information, should they exist, null or an empty list if not specified.
/// </summary>
[JsonProperty("oth")]
public string OthInf { get; set; }
/// <summary>
/// "alg" (Algorithm) Parameter
/// <para>
/// The "alg" (algorithm) parameter identifies the algorithm intended for
/// use with the key.
/// </para>
/// </summary>
[JsonProperty("alg")]
public string Algorithm { get; set; }
}
}

View File

@ -0,0 +1,61 @@
using System;
using Newtonsoft.Json;
namespace LetsEncrypt.Entities
{
public class JwsMessage
{
[JsonProperty("header")]
public JwsHeader Header { get; set; }
[JsonProperty("protected")]
public string Protected { get; set; }
[JsonProperty("payload")]
public string Payload { get; set; }
[JsonProperty("signature")]
public string Signature { get; set; }
}
public class JwsHeader
{
//public JwsHeader()
//{
//}
//public JwsHeader(string algorithm, Jwk key)
//{
// Algorithm = algorithm;
// Key = key;
//}
[JsonProperty("alg")]
public string Algorithm { get; set; }
[JsonProperty("jwk")]
public Jwk Key { get; set; }
[JsonProperty("kid")]
public string KeyId { get; set; }
[JsonProperty("nonce")]
public string Nonce { get; set; }
[JsonProperty("url")]
public Uri Url { get; set; }
[JsonProperty("Host")]
public string Host { get; set; }
}
}

View File

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

View File

@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
namespace LetsEncrypt.Entities
{
public class RegistrationCache
{
public readonly Dictionary<string, CertificateCache> CachedCerts = new Dictionary<string, CertificateCache>(StringComparer.OrdinalIgnoreCase);
public byte[] AccountKey;
public string Id;
public Jwk Key;
public Uri Location;
}
}

View File

@ -0,0 +1,33 @@
using System;
using System.Net.Http;
using Newtonsoft.Json;
namespace LetsEncrypt.Exceptions
{
public class LetsEncrytException : Exception
{
public LetsEncrytException(Problem problem, HttpResponseMessage response)
: base($"{problem.Type}: {problem.Detail}")
{
Problem = problem;
Response = response;
}
public Problem Problem { get; }
public HttpResponseMessage Response { get; }
}
public class Problem
{
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("detail")]
public string Detail { get; set; }
public string RawJson { get; set; }
}
}

View File

@ -0,0 +1,31 @@
namespace LetsEncrypt.Helpers
{
public class AppSettings {
public string active { get; set; }
public Environment [] environments { get; set; }
public Customer [] customers { get; set;}
}
public class Environment {
public string name { get; set; }
public string url { 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 string root { get; set; }
public string name { get; set; }
public string [] hosts { get; set; }
public string challenge { get; set; }
}
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.2</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="Newtonsoft.JSON" Version="12.0.2" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,54 @@
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>();
// add app
services.AddTransient<App>();
}
}
}

View File

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

View File

@ -0,0 +1,110 @@
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 = 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 string Base64UrlEncoded(string s)
{
return Base64UrlEncoded(Encoding.UTF8.GetBytes(s));
}
public string Base64UrlEncoded(byte[] arg)
{
var s = Convert.ToBase64String(arg); // Regular base64 encoder
s = s.Split('=')[0]; // Remove any trailing '='s
s = s.Replace('+', '-'); // 62nd char of encoding
s = s.Replace('/', '_'); // 63rd char of encoding
return s;
}
public void SetKeyId(Account account)
{
_jwk.KeyId = account.Id;
}
}
}

View File

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

View File

@ -0,0 +1,567 @@
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 (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);
_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 (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 = _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);
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
_jwsService.Init(_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 = _jwsService.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 = _jwsService.Encode(message, new JwsHeader
{
Nonce = _nonce,
Url = uri,
});
var json = JsonConvert.SerializeObject(encodedMessage, jsonSettings);
request.Content = new StringContent(json);
var requestType = "application/json";
if (method == HttpMethod.Post)
requestType = "application/jose+json";
request.Content.Headers.Remove("Content-Type");
request.Content.Headers.Add("Content-Type", requestType);
}
var response = await _client.SendAsync(request, token).ConfigureAwait(false);
if (method == HttpMethod.Post)
_nonce = response.Headers.GetValues("Replay-Nonce").First();
if (response.Content.Headers.ContentType.MediaType == "application/problem+json")
{
var problemJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var problem = JsonConvert.DeserializeObject<Problem>(problemJson);
problem.RawJson = problemJson;
throw new LetsEncrytException(problem, response);
}
var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (typeof(TResult) == typeof(string) && response.Content.Headers.ContentType.MediaType == "application/pem-certificate-chain")
{
return ((TResult)(object)responseText, null);
}
var responseContent = JObject.Parse(responseText).ToObject<TResult>();
if (responseContent is IHasLocation ihl)
{
if (response.Headers.Location != null)
ihl.Location = response.Headers.Location;
}
return (responseContent, responseText);
}
/// <summary>
///
/// </summary>
/// <param name="hosts"></param>
/// <param name="value"></param>
/// <returns></returns>
public bool TryGetCachedCertificate(string subject, out CachedCertificateResult value)
{
value = null;
if (_cache.CachedCerts.TryGetValue(subject, out var cache) == false)
{
return false;
}
var cert = new X509Certificate2(Encoding.ASCII.GetBytes(cache.Cert));
// if it is about to expire, we need to refresh
if ((cert.NotAfter - DateTime.UtcNow).TotalDays < 30)
return false;
var rsa = new RSACryptoServiceProvider(4096);
rsa.ImportCspBlob(cache.Private);
value = new CachedCertificateResult
{
Certificate = cache.Cert,
PrivateKey = rsa
};
return true;
}
/// <summary>
///
/// </summary>
/// <param name="hostsToRemove"></param>
public void ResetCachedCertificate(IEnumerable<string> hostsToRemove)
{
foreach (var host in hostsToRemove)
{
_cache.CachedCerts.Remove(host);
}
}
private Dictionary<string, HttpClient> _cachedClients = new Dictionary<string, HttpClient>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// In our scenario, we assume a single single wizard progressing
/// and the locking is basic to the wizard progress. Adding explicit
/// locking to be sure that we are not corrupting disk state if user
/// is explicitly calling stuff concurrently (running the setup wizard
/// from two tabs?)
/// </summary>
private readonly object Locker = new object();
private HttpClient GetCachedClient(string url) {
if (_cachedClients.TryGetValue(url, out var value)) {
return value;
}
lock (Locker) {
if (_cachedClients.TryGetValue(url, out value)) {
return value;
}
value = new HttpClient {
BaseAddress = new Uri(url)
};
_cachedClients = new Dictionary<string, HttpClient>(_cachedClients, StringComparer.OrdinalIgnoreCase) {
[url] = value
};
return value;
}
}
}
}

View File

@ -0,0 +1,74 @@
{
"AppSettings": {
"active": "ProductionV2",
"environments": [
{
"name": "StagingV2",
"url": "https://acme-staging-v02.api.letsencrypt.org/directory",
"www": "/var/www",
"acme": ".well-known/acme-challenge",
"ssl": "/etc/nginx/ssl"
},
{
"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"
}
]
}
]
}
}