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 Http01Responses { get; set; } = new Dictionary(); public IHandler Parent { get; init; } public IAsyncEnumerable GetContentAsync(IRequest request) { throw new NotImplementedException(); } public ValueTask 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(response.Build()); } public ValueTask PrepareAsync() { return new ValueTask(); } public IEnumerable GetContent(IRequest request) { return Enumerable.Empty(); } 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 { private readonly List _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 { 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(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(File.ReadAllText(path)) ?? default; } protected static void Save(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(ServiceDirectoryFile); Account = Load(AccountFile); AccountKey = Load(AccountKeyFile); Order = Load(OrderFile); Authorizations = Load>(AuthorizationsFile); var certRaw = Load(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(); } } private async Task 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 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 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 ResolveChallenges(AcmeProtocolClient acme) { if (AcmeState.PendingStatus == Order?.Payload?.Status) { Logger.Send(LogType.Information, "", "Order is pending, resolving Authorizations"); if (Authorizations == null) Authorizations = new Dictionary(); 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 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(); // 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 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(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(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(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? Authorizations; private X509Certificate2? Certificate; private IEnumerable DnsNames; private IEnumerable AccountContactEmails; #endregion }