123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693 |
- using System;
- using System.Collections.Generic;
- using System.Formats.Asn1;
- using System.IO;
- using System.Linq;
- using System.Reflection;
- using System.Security.Cryptography.X509Certificates;
- using System.Threading;
- using System.Threading.Tasks;
- using System.Timers;
- using ACMESharp.Authorizations;
- using ACMESharp.Protocol;
- using ACMESharp.Protocol.Resources;
- using GenHTTP.Api.Content;
- using GenHTTP.Api.Infrastructure;
- using GenHTTP.Api.Protocol;
- using GenHTTP.Engine;
- using GenHTTP.Modules.IO;
- using GenHTTP.Modules.Practices;
- using InABox.Core;
- using Microsoft.VisualBasic;
- using Newtonsoft.Json;
- using PRSServices;
- using Timer = System.Timers.Timer;
- namespace PRSServer;
- public class CertificateHandler : IHandler
- {
- private static readonly string AcmeHttp01PathPrefix =
- Http01ChallengeValidationDetails.HttpPathPrefix.Trim('/');
- public CertificateHandler(IHandler parent)
- {
- Parent = parent;
- }
- public IDictionary<string, Http01ChallengeValidationDetails> Http01Responses { get; set; }
- = new Dictionary<string, Http01ChallengeValidationDetails>();
- public IHandler Parent { get; init; }
- public IAsyncEnumerable<ContentElement> GetContentAsync(IRequest request)
- {
- throw new NotImplementedException();
- }
- public ValueTask<IResponse?> HandleAsync(IRequest request)
- {
- var fullPath = request.Target.Path.ToString().Trim('/');
- Logger.Send(LogType.Information, "", "Running ACME Challenge Request Handler");
- IResponseBuilder response;
- if (Http01Responses.TryGetValue(fullPath, out var httpDetails))
- {
- Logger.Send(LogType.Information, "",
- string.Format("Found match on [{0}] with response [{1}]", fullPath, httpDetails.HttpResourceValue));
- response = request.Respond()
- .Type(new FlexibleContentType(httpDetails.HttpResourceContentType))
- .Content(Resource.FromString(httpDetails.HttpResourceValue).Build());
- }
- else
- {
- Logger.Send(LogType.Information, "", string.Format("NO MATCH FOUND ON [{0}]", fullPath));
- response = request.Respond()
- .Status(ResponseStatus.NotFound)
- .Content(Resource.FromString("No matching ACME response path").Build());
- }
- return new ValueTask<IResponse?>(response.Build());
- }
- public ValueTask PrepareAsync()
- {
- return new ValueTask();
- }
- public IEnumerable<ContentElement> GetContent(IRequest request)
- {
- return Enumerable.Empty<ContentElement>();
- }
- public bool AddChallengeHandling(IChallengeValidationDetails chlngDetails)
- {
- if (chlngDetails is not Http01ChallengeValidationDetails httpDetails)
- {
- Logger.Send(LogType.Information, "", "Unable to handle non-Http01 Challenge details");
- return false;
- }
- var fullPath = httpDetails.HttpResourcePath.Trim('/');
- Logger.Send(LogType.Information, "", string.Format("Handling Challenges with HTTP full path of [{0}]", fullPath));
- Http01Responses[fullPath] = httpDetails;
- return true;
- }
- }
- public class CertificateHandlerBuilder : IHandlerBuilder<CertificateHandlerBuilder>
- {
- private readonly List<IConcernBuilder> _Concerns = new();
- public CertificateHandlerBuilder(CertificateEngine engine)
- {
- Engine = engine;
- }
- private CertificateEngine Engine { get; }
- public CertificateHandlerBuilder Add(IConcernBuilder concern)
- {
- _Concerns.Add(concern);
- return this;
- }
- public IHandler Build(IHandler parent)
- {
- return Concerns.Chain(parent, _Concerns, p =>
- {
- Engine.Handler = new CertificateHandler(p);
- return Engine.Handler;
- });
- }
- }
- public class CertificateEngine : Engine<CertificateEngineProperties>
- {
- private readonly DateTime _scheduleTime;
- public CertificateHandler? Handler;
- private IServerHost? host;
- private static string ChallengeType = AcmeState.Http01ChallengeType;
- static CertificateEngine()
- {
- CertificateFolder = Path.Combine(
- Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location ?? "") ?? "",
- "certs"
- );
- }
- private static T? Load<T>(string path)
- {
- if (!File.Exists(path))
- return default;
- if (typeof(T) == typeof(Stream)) return (T)(object)new FileStream(path, FileMode.Open);
- if (typeof(T) == typeof(byte[])) return (T)(object)File.ReadAllBytes(path);
- return JsonConvert.DeserializeObject<T>(File.ReadAllText(path)) ?? default;
- }
- protected static void Save<T>(string path, T value)
- {
- if (typeof(Stream).IsAssignableFrom(typeof(T)))
- {
- var rs = (Stream)(object)value;
- using (var ws = new FileStream(path, FileMode.Create))
- {
- rs.CopyTo(ws);
- }
- }
- else if (typeof(T) == typeof(byte[]))
- {
- var ba = (byte[])(object)value;
- File.WriteAllBytes(path, ba);
- }
- else
- {
- File.WriteAllText(path,
- JsonConvert.SerializeObject(value, Formatting.Indented));
- }
- }
- public void DeleteFile(string filename)
- {
- try
- {
- File.Delete(filename);
- }
- catch (Exception) { }
- }
- public void RefreshCertificateFile(CancellationToken cancellationToken)
- {
- Logger.Send(LogType.Information, "", "Updating HTTPS certificate...");
- if (!Directory.Exists(CertificateFolder))
- {
- Logger.Send(LogType.Information, "", "Creating certificate folder...");
- Directory.CreateDirectory(CertificateFolder);
- }
- DeleteFile(OrderFile);
- DeleteFile(CertificateFile);
- //DeleteFile(AuthorizationsFile);
- ServiceDirectory = Load<ServiceDirectory>(ServiceDirectoryFile);
- Account = Load<AccountDetails>(AccountFile);
- AccountKey = Load<ExamplesAccountKey>(AccountKeyFile);
- Order = Load<OrderDetails>(OrderFile);
- Authorizations = Load<Dictionary<string, Authorization>>(AuthorizationsFile);
- var certRaw = Load<byte[]>(CertificateFile);
- if (certRaw?.Length > 0)
- Certificate = new X509Certificate2(certRaw);
- DnsNames = Properties.ParseDomainNames();
- AccountContactEmails = Properties.AccountContactEmails.Split(',');
- Logger.Send(LogType.Information, "", "Initializing HTTP Handler");
- host = Host.Create()
- .Handler(new CertificateHandlerBuilder(this))
- .Defaults()
- .Port(80)
- .Start();
- // We delay for 5 seconds just to give other parts of
- // the service (like request handling) to get in place
- Task.Delay(5 * 1000, cancellationToken);
- if (DoTheWork())
- {
- Logger.Send(LogType.Information, "", "HTTPS Refresh Complete!");
- }
- else
- {
- Logger.Send(LogType.Information, "", "HTTPS Refresh Failed");
- }
- host.Stop();
- }
- private bool DoTheWork()
- {
- try
- {
- Logger.Send(LogType.Information, "", "** DOING WORKING *****************************************");
- Logger.Send(LogType.Information, "", $"DNS Names: {Properties.DomainNames}");
- var acmeUrl = new Uri(Properties.CaUrl);
- using var acme = new AcmeProtocolClient(acmeUrl, usePostAsGet: true);
- var task = DoTheWorkAsync(acme);
- task.Wait();
- return task.Result;
- }
- catch(Exception e)
- {
- Logger.Send(LogType.Error, "", CoreUtils.FormatException(e));
- return false;
- }
- }
- private async Task ClearAuthorizations(AcmeProtocolClient acme)
- {
- if(Authorizations != null)
- {
- foreach (var (url, authorization) in Authorizations)
- {
- if (authorization.Status != AcmeState.ValidStatus)
- {
- try
- {
- await acme.DeactivateAuthorizationAsync(url);
- }
- catch(Exception e)
- {
- Logger.Send(LogType.Error, "", $"Could not deactivate authorization: {CoreUtils.FormatException(e)}");
- }
- }
- }
- Authorizations.Clear();
- Save(AuthorizationsFile, Authorizations);
- }
- else
- {
- Authorizations = new Dictionary<string, Authorization>();
- }
- }
- private async Task<bool> DoTheWorkAsync(AcmeProtocolClient acme)
- {
- ServiceDirectory = await acme.GetDirectoryAsync();
- Save(ServiceDirectoryFile, ServiceDirectory);
- acme.Directory = ServiceDirectory;
- Save(TermsOfServiceFile, await acme.GetTermsOfServiceAsync());
- // This line basically has to be called before all ACME things.
- await acme.GetNonceAsync();
- ClearAuthorizations(acme).Wait();
- if (!await ResolveAccount(acme))
- return false;
- if (!await ResolveOrder(acme))
- return false;
- if (!await ResolveChallenges(acme))
- return false;
- if (!await ResolveAuthorizations(acme))
- return false;
- if (!await ResolveCertificate(acme))
- return false;
- return true;
- }
- private async Task<bool> ResolveAccount(AcmeProtocolClient acme)
- {
- // TODO: All this ASSUMES a fixed key type/size for now
- if (Account == null || AccountKey == null)
- {
- var contacts = AccountContactEmails.Where(x => !string.IsNullOrEmpty(x)).Select(x => $"mailto:{x}");
- Logger.Send(LogType.Information, "", "Creating ACME Account");
- Account = await acme.CreateAccountAsync(
- contacts,
- Properties.AcceptTermsOfService);
- AccountKey = new ExamplesAccountKey
- {
- KeyType = acme.Signer.JwsAlg,
- KeyExport = acme.Signer.Export()
- };
- Save(AccountFile, Account);
- Save(AccountKeyFile, AccountKey);
- acme.Account = Account;
- }
- else
- {
- acme.Account = Account;
- acme.Signer.Import(AccountKey.KeyExport);
- }
- return true;
- }
- private async Task<bool> ResolveOrder(AcmeProtocolClient acme)
- {
- var now = DateTime.Now;
- if (!string.IsNullOrEmpty(Order?.OrderUrl))
- {
- Logger.Send(LogType.Information, "", "Existing Order found; refreshing");
- Order = await acme.GetOrderDetailsAsync(Order.OrderUrl, Order);
- }
- if (Order?.Payload?.Error != null)
- {
- Logger.Send(LogType.Information, "", "Existing Order reported an Error:");
- Logger.Send(LogType.Information, "", JsonConvert.SerializeObject(Order.Payload.Error));
- Logger.Send(LogType.Information, "", "Resetting existing order");
- Order = null;
- }
- if (AcmeState.InvalidStatus == Order?.Payload.Status)
- {
- Logger.Send(LogType.Information, "", "Existing Order is INVALID; resetting");
- Order = null;
- }
- if (!DateTime.TryParse(Order?.Payload?.Expires, out var orderExpires)
- || orderExpires < now)
- {
- Logger.Send(LogType.Information, "", "Existing Order is EXPIRED; resetting");
- Order = null;
- }
- if (DateTime.TryParse(Order?.Payload?.NotAfter, out var orderNotAfter)
- && orderNotAfter < now)
- {
- Logger.Send(LogType.Information, "", "Existing Order is OUT-OF-DATE; resetting");
- Order = null;
- }
- if (Order?.Payload == null)
- {
- Logger.Send(LogType.Information, "", "Creating NEW Order");
- Order = await acme.CreateOrderAsync(DnsNames);
- }
- Save(OrderFile, Order);
- return true;
- }
- private async Task<bool> ResolveChallenges(AcmeProtocolClient acme)
- {
- if (AcmeState.PendingStatus == Order?.Payload?.Status)
- {
- Logger.Send(LogType.Information, "", "Order is pending, resolving Authorizations");
- if (Authorizations == null)
- Authorizations = new Dictionary<string, Authorization>();
- foreach (var authzUrl in Order.Payload.Authorizations)
- {
- var authz = await acme.GetAuthorizationDetailsAsync(authzUrl);
- Authorizations[authzUrl] = authz;
- if (AcmeState.PendingStatus == authz.Status)
- foreach (var chlng in authz.Challenges)
- if (string.IsNullOrEmpty(ChallengeType) || ChallengeType == chlng.Type)
- {
- var chlngValidation = AuthorizationDecoder.DecodeChallengeValidation(
- authz, chlng.Type, acme.Signer);
- if (Handler.AddChallengeHandling(chlngValidation))
- {
- Logger.Send(LogType.Information, "", "Challenge Handler has handled challenge:");
- Logger.Send(LogType.Information, "", JsonConvert.SerializeObject(chlngValidation, Formatting.Indented));
- var chlngUpdated = await acme.AnswerChallengeAsync(chlng.Url);
- if (chlngUpdated.Error != null)
- {
- Logger.Send(LogType.Error, "", "Submitting Challenge Answer reported an error:");
- Logger.Send(LogType.Error, "", JsonConvert.SerializeObject(chlngUpdated.Error));
- }
- }
- Logger.Send(LogType.Information, "", "Refreshing Authorization status");
- authz = await acme.GetAuthorizationDetailsAsync(authzUrl);
- if (AcmeState.PendingStatus != authz.Status)
- break;
- }
- }
- Save(AuthorizationsFile, Authorizations);
- Logger.Send(LogType.Information, "", "Refreshing Order status");
- Order = await acme.GetOrderDetailsAsync(Order.OrderUrl, Order);
- Save(OrderFile, Order);
- }
- return true;
- }
- private async Task<bool> ResolveAuthorizations(AcmeProtocolClient acme)
- {
- if (AcmeState.InvalidStatus == Order?.Payload?.Status)
- {
- Logger.Send(LogType.Information, "", "Current Order is INVALID; aborting");
- return false;
- }
- if (AcmeState.ValidStatus == Order?.Payload?.Status)
- {
- Logger.Send(LogType.Information, "", "Current Order is already VALID; skipping");
- return true;
- }
- var now = DateTime.Now;
- do
- {
- if (Authorizations == null)
- Authorizations = new Dictionary<string, Authorization>();
- // Wait for all Authorizations to be valid or any one to go invalid
- var validCount = 0;
- var invalidCount = 0;
- foreach (var authz in Authorizations)
- switch (authz.Value.Status)
- {
- case AcmeState.ValidStatus:
- ++validCount;
- break;
- case AcmeState.InvalidStatus:
- ++invalidCount;
- break;
- }
- if (validCount == Authorizations.Count)
- {
- Logger.Send(LogType.Information, "", string.Format("All Authorizations ({0}) are valid", validCount));
- break;
- }
- if (invalidCount > 0)
- {
- Logger.Send(LogType.Error, "", string.Format("Found {0} invalid Authorization(s); ABORTING", invalidCount));
- return false;
- }
- Logger.Send(LogType.Information, "", string.Format("Found {0} Authorization(s) NOT YET valid", Authorizations.Count - validCount));
- if (now.AddSeconds(Properties.WaitForAuthorizations) < DateTime.Now)
- {
- Logger.Send(LogType.Error, "", "Timed out waiting for Authorizations; ABORTING");
- return false;
- }
- // We wait in 5s increments
- await Task.Delay(5000);
- foreach (var authzUrl in Order.Payload.Authorizations)
- // Update all the Authorizations still pending
- if (AcmeState.PendingStatus == Authorizations[authzUrl].Status)
- Authorizations[authzUrl] = await acme.GetAuthorizationDetailsAsync(authzUrl);
- } while (true);
- Save(AuthorizationsFile, Authorizations);
- return true;
- }
- private async Task<bool> ResolveCertificate(AcmeProtocolClient acme)
- {
- if (Certificate != null)
- {
- Logger.Send(LogType.Information, "", "Certificate is already resolved");
- return true;
- }
- CertPrivateKey? key = null;
- Logger.Send(LogType.Information, "", "Refreshing Order status");
- Order = await acme.GetOrderDetailsAsync(Order.OrderUrl, Order);
- Save(OrderFile, Order);
- if (AcmeState.PendingStatus == Order.Payload.Status || AcmeState.ReadyStatus == Order.Payload.Status)
- {
- Logger.Send(LogType.Information, "", "Generating CSR");
- byte[] csr;
- switch (Properties.CertificateKeyAlgor)
- {
- case "rsa":
- key = CertHelper.GenerateRsaPrivateKey(
- Properties.CertificateKeySize ?? CertificateEngineProperties.DefaultRsaKeySize);
- csr = CertHelper.GenerateRsaCsr(DnsNames, key);
- break;
- case "ec":
- key = CertHelper.GenerateEcPrivateKey(
- Properties.CertificateKeySize ?? CertificateEngineProperties.DefaultEcKeySize);
- csr = CertHelper.GenerateEcCsr(DnsNames, key);
- break;
- default:
- throw new Exception("Unknown Certificate Key Algorithm: "
- + Properties.CertificateKeyAlgor);
- }
- using (var keyPem = new MemoryStream())
- {
- CertHelper.ExportPrivateKey(key, EncodingFormat.PEM, keyPem);
- keyPem.Position = 0L;
- Save(CertificateKeysFile, keyPem);
- }
- Save(CertificateRequestFile, csr);
- Logger.Send(LogType.Information, "", "Finalizing Order");
- Order = await acme.FinalizeOrderAsync(Order.Payload.Finalize, csr);
- Save(OrderFile, Order);
- }
- if (string.IsNullOrEmpty(Order.Payload.Certificate))
- {
- Logger.Send(LogType.Information, "", "Order Certificate is NOT READY YET");
- var now = DateTime.Now;
- do
- {
- Logger.Send(LogType.Information, "", "Waiting...");
- // We wait in 5s increments
- await Task.Delay(5000);
- Order = await acme.GetOrderDetailsAsync(Order.OrderUrl, Order);
- Save(OrderFile, Order);
- if (!string.IsNullOrEmpty(Order.Payload.Certificate))
- break;
- if (DateTime.Now < now.AddSeconds(Properties.WaitForCertificate))
- {
- Logger.Send(LogType.Information, "", "Timed Out!");
- return false;
- }
- } while (true);
- }
- if (AcmeState.ValidStatus != Order.Payload.Status)
- {
- Logger.Send(LogType.Information, "", "Order is NOT VALID");
- return false;
- }
- Logger.Send(LogType.Information, "", "Retreiving Certificate");
- var certBytes = await acme.GetOrderCertificateAsync(Order);
- Save(CertificateChainFile, certBytes);
- if (key == null)
- {
- Logger.Send(LogType.Information, "", "Loading private key");
- key = CertHelper.ImportPrivateKey(EncodingFormat.PEM, Load<Stream>(CertificateKeysFile));
- }
- using (var crtStream = new MemoryStream(certBytes))
- using (var pfxStream = new MemoryStream())
- {
- Logger.Send(LogType.Information, "", "Reading in Certificate chain (PEM)");
- var cert = CertHelper.ImportCertificate(EncodingFormat.PEM, crtStream);
- Logger.Send(LogType.Information, "", "Writing out Certificate archive (PKCS12)");
- CertHelper.ExportArchive(key, new[] { cert }, ArchiveFormat.PKCS12, pfxStream);
- pfxStream.Position = 0L;
- Save(CertificateFile, pfxStream);
- }
- Logger.Send(LogType.Information, "", "Loading PKCS12 archive as active certificate");
- Certificate = new X509Certificate2(Load<byte[]>(CertificateFile));
- return true;
- }
- private void CheckForRefresh()
- {
- Logger.Send(LogType.Information, "", "Checking for refresh...");
- if (!Directory.Exists(CertificateFolder))
- {
- Logger.Send(LogType.Information, "", "Creating certificate folder...");
- Directory.CreateDirectory(CertificateFolder);
- }
- var certRaw = Load<byte[]>(CertificateFile);
- if (certRaw?.Length > 0)
- {
- Certificate = new X509Certificate2(certRaw);
- }
- if(Certificate == null)
- {
- RefreshCertificateFile(CancellationToken.None);
- return;
- }
- DateTime now = DateTime.Now;
- TimeSpan startDiff = now - Certificate.NotBefore;
- TimeSpan endDiff = Certificate.NotAfter - now;
- // If the certificate will expire in less than 30 days or if the certificate is not yet valid
- if (startDiff < TimeSpan.Zero || endDiff < TimeSpan.FromDays(30))
- {
- RefreshCertificateFile(CancellationToken.None);
- return;
- }
- var names = GetDnsNames(Certificate).ToHashSet();
- var requiredNames = Properties.ParseDomainNames();
- if(requiredNames.Any(x => !names.Contains(x)))
- {
- RefreshCertificateFile(CancellationToken.None);
- return;
- }
- Logger.Send(LogType.Information, "", "Refresh not required!");
- }
- public override void Run()
- {
- System.Threading.Timer timer = new System.Threading.Timer(
- (o) => { CheckForRefresh(); },
- null,
- 3000,
- 1000 * 60 * 60 * 24
- );
- Thread.Sleep(Timeout.Infinite);
- }
- public override void Stop()
- {
- host?.Stop();
- }
- #region File Names
- public static string CertificateFolder { get; }
- private string ServiceDirectoryFile => Path.Combine(CertificateFolder, "00-ServiceDirectory.json");
- private string TermsOfServiceFile => Path.Combine(CertificateFolder, "05-TermsOfService");
- private string AccountFile => Path.Combine(CertificateFolder, "10-Account.json");
- private string AccountKeyFile => Path.Combine(CertificateFolder, "15-AccountKey.json");
- private string OrderFile => Path.Combine(CertificateFolder, "50-Order.json");
- private string AuthorizationsFile => Path.Combine(CertificateFolder, "52-Authorizations.json");
- private string CertificateKeysFile => Path.Combine(CertificateFolder, "70-CertificateKeys.pem");
- private string CertificateRequestFile => Path.Combine(CertificateFolder, "72-CertificateRequest.der");
- private string CertificateChainFile => Path.Combine(CertificateFolder, "74-CertificateChain.pem");
- public static string CertificateFile => Path.Combine(CertificateFolder, "80-Certificate.pfx");
- #endregion
- #region Extra State Properties
- private ServiceDirectory? ServiceDirectory;
- private AccountDetails? Account;
- private ExamplesAccountKey? AccountKey;
- private OrderDetails? Order;
- private Dictionary<string, Authorization>? Authorizations;
- private X509Certificate2? Certificate;
- private IEnumerable<string> DnsNames;
- private IEnumerable<string> AccountContactEmails;
- #endregion
- }
|