diff --git a/src/ClientApp/app/page.tsx b/src/ClientApp/app/page.tsx index fd3904d..8297366 100644 --- a/src/ClientApp/app/page.tsx +++ b/src/ClientApp/app/page.tsx @@ -55,10 +55,6 @@ export default function Page() { init.current = true }, []) - useEffect(() => { - console.log(editingAccount) - }, [editingAccount]) - const handleAccountUpdate = (updatedAccount: CacheAccount) => { setAccounts( accounts.map((account) => @@ -70,15 +66,8 @@ export default function Page() { } const deleteAccount = (accountId: string) => { - httpService - .delete(GetApiRoute(ApiRoutes.ACCOUNT_ID, accountId)) - .then((response) => { - if (response.isSuccess) { - setAccounts( - accounts.filter((account) => account.accountId !== accountId) - ) - } - }) + setAccounts(accounts.filter((account) => account.accountId !== accountId)) + setEditingAccount(null) } return ( diff --git a/src/ClientApp/partials/accoutEdit.tsx b/src/ClientApp/partials/accoutEdit.tsx index 5e5ce4e..e3128a0 100644 --- a/src/ClientApp/partials/accoutEdit.tsx +++ b/src/ClientApp/partials/accoutEdit.tsx @@ -255,7 +255,18 @@ const AccountEdit: React.FC = (props) => { } const handleDelete = (accountId: string) => { - onDelete?.(accountId) + httpService + .delete(GetApiRoute(ApiRoutes.ACCOUNT_ID, accountId)) + .then((response) => { + if (response.isSuccess) { + onDelete?.(accountId) + } else { + // Optionally, handle the error case, e.g., show an error message + dispatch( + showToast({ message: 'Failed to detele account.', type: 'error' }) + ) + } + }) } return ( diff --git a/src/ClientApp/services/HttpService.tsx b/src/ClientApp/services/HttpService.tsx index 9650222..86fc3be 100644 --- a/src/ClientApp/services/HttpService.tsx +++ b/src/ClientApp/services/HttpService.tsx @@ -212,7 +212,8 @@ class HttpService { ): ProblemDetails { return { Title: title, - Detail: detail instanceof Error ? detail.message : String(detail), + Detail: + detail instanceof Error ? detail.message : JSON.parse(detail)?.detail, Status: status } } @@ -252,6 +253,8 @@ class HttpService { ): Promise> { // Clean the data before sending the patch request const cleanedData = this.cleanObject(data) + + console.log('Cleaned patch data:', cleanedData) return await this.request('PATCH', url, cleanedData) } diff --git a/src/LetsEncrypt/Entities/LetsEncrypt/RevokeReason.cs b/src/LetsEncrypt/Entities/LetsEncrypt/RevokeReason.cs new file mode 100644 index 0000000..7ca53cd --- /dev/null +++ b/src/LetsEncrypt/Entities/LetsEncrypt/RevokeReason.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MaksIT.LetsEncrypt.Entities.LetsEncrypt { + public enum RevokeReason { + Unspecified = 0, + KeyCompromise = 1, + CaCompromise = 2, + AffiliationChanged = 3, + Superseded = 4, + CessationOfOperation = 5, + PrivilegeWithdrawn = 6, + AaCompromise = 7 + } +} diff --git a/src/LetsEncrypt/Models/Responses/AcmeDirectory.cs b/src/LetsEncrypt/Models/Responses/AcmeDirectory.cs index 48bf7f8..8391f25 100644 --- a/src/LetsEncrypt/Models/Responses/AcmeDirectory.cs +++ b/src/LetsEncrypt/Models/Responses/AcmeDirectory.cs @@ -7,7 +7,7 @@ public class AcmeDirectory { public Uri NewNonce { get; set; } public Uri NewOrder { get; set; } public Uri RenewalInfo { get; set; } - public Uri RevokeCertificate { get; set; } + public Uri RevokeCert { get; set; } } public class AcmeDirectoryMeta { diff --git a/src/LetsEncrypt/Models/Responses/RevokeRequest.cs b/src/LetsEncrypt/Models/Responses/RevokeRequest.cs new file mode 100644 index 0000000..7742f8a --- /dev/null +++ b/src/LetsEncrypt/Models/Responses/RevokeRequest.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MaksIT.LetsEncrypt.Models.Responses { + public class RevokeRequest { + public string Certificate { get; set; } = string.Empty; + public int Reason { get; set; } + } +} diff --git a/src/LetsEncrypt/Services/LetsEncryptService.cs b/src/LetsEncrypt/Services/LetsEncryptService.cs index 326cd33..8605e1e 100644 --- a/src/LetsEncrypt/Services/LetsEncryptService.cs +++ b/src/LetsEncrypt/Services/LetsEncryptService.cs @@ -1,3 +1,9 @@ +/** + * https://datatracker.ietf.org/doc/html/rfc8555 + * https://datatracker.ietf.org/doc/html/draft-ietf-acme-acme-12 + */ + + using System.Text; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -16,6 +22,7 @@ using MaksIT.LetsEncrypt.Models.Interfaces; using MaksIT.LetsEncrypt.Models.Requests; using MaksIT.LetsEncrypt.Entities.Jws; using MaksIT.LetsEncrypt.Entities.LetsEncrypt; +using System.Net.Mime; namespace MaksIT.LetsEncrypt.Services; @@ -28,6 +35,7 @@ public interface ILetsEncryptService { Task CompleteChallenges(Guid sessionId); Task GetOrder(Guid sessionId, string[] hostnames); Task GetCertificate(Guid sessionId, string subject); + Task RevokeCertificate(Guid sessionId, string subject, RevokeReason reason); } public class LetsEncryptService : ILetsEncryptService { @@ -278,7 +286,7 @@ public class LetsEncryptService : ILetsEncryptService { _logger.LogInformation($"Executing {nameof(CompleteChallenges)}..."); if (state.CurrentOrder?.Identifiers == null) { - return IDomainResult.Failed(); + return IDomainResult.Failed("Current order identifiers are null"); } for (var index = 0; index < state.Challenges.Count; index++) { @@ -449,10 +457,61 @@ public class LetsEncryptService : ILetsEncryptService { throw new NotImplementedException(); } - public Task RevokeCertificate(Guid sessionId) { - throw new NotImplementedException(); + public async Task RevokeCertificate(Guid sessionId, string subject, RevokeReason reason) { + try { + var state = GetOrCreateState(sessionId); + + _logger.LogInformation($"Executing {nameof(RevokeCertificate)}..."); + + if (state.Cache == null || state.Cache.CachedCerts == null || !state.Cache.CachedCerts.TryGetValue(subject, out var certificateCache)) { + _logger.LogError("Certificate not found in cache"); + return IDomainResult.Failed("Certificate not found"); + } + + + + string Base64UrlEncode(byte[] input) { + return Convert.ToBase64String(input) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + + // Load the certificate from PEM format and convert it to DER format + var certificate = new X509Certificate2(Encoding.UTF8.GetBytes(certificateCache.Cert)); + var derEncodedCert = certificate.Export(X509ContentType.Cert); + var base64UrlEncodedCert = Base64UrlEncode(derEncodedCert); + + // Convert the certificate to DER format and Base64 encode it + var base64Cert = Convert.ToBase64String(certificate.Export(X509ContentType.Cert)); + + var revokeRequest = new RevokeRequest { + Certificate = certificateCache.Cert, + Reason = (int)reason + }; + + var (revokeResult, domainResult) = await SendAsync(sessionId, HttpMethod.Post, state.Directory.RevokeCert, false, revokeRequest); + if (!domainResult.IsSuccess) { + return domainResult; + } + + // Remove the certificate from the cache after successful revocation + state.Cache.CachedCerts.Remove(subject); + + _logger.LogInformation("Certificate revoked successfully"); + return IDomainResult.Success(); + } + catch (Exception ex) { + var message = "Let's Encrypt client unhandled exception"; + _logger.LogError(ex, message); + return IDomainResult.CriticalDependencyError(message); + } } + + + #region SendAsync /// /// @@ -563,6 +622,7 @@ public class LetsEncryptService : ILetsEncryptService { var jwsHeader = CreateJwsHeader(uri, state.Nonce); var json = EncodeMessage(isPostAsGet, requestModel, state, jwsHeader); PrepareRequestContent(request, json, method); + } var response = await _httpClient.SendAsync(request); diff --git a/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs b/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs index 6e6e79e..c8462fe 100644 --- a/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs +++ b/src/LetsEncryptServer/BackgroundServices/AutoRenewal.cs @@ -47,8 +47,6 @@ namespace MaksIT.LetsEncryptServer.BackgroundServices { private async Task ProcessAccountAsync(RegistrationCache cache) { - - var hostnames = cache.GetHostsWithUpcomingSslExpiry(); if (hostnames == null) { _logger.LogError("Unexpected hostnames null"); @@ -61,7 +59,7 @@ namespace MaksIT.LetsEncryptServer.BackgroundServices { return IDomainResult.Success(); } - var renewResult = await RenewCertificatesForHostnames(cache.AccountId, cache.Description, cache.Contacts, hostnames, cache.ChallengeType, cache.IsStaging); + var (_, renewResult) = await _certsFlowService.FullFlow(cache.IsStaging, cache.AccountId, cache.Description, cache.Contacts, cache.ChallengeType, hostnames); if (!renewResult.IsSuccess) return renewResult; @@ -70,53 +68,7 @@ namespace MaksIT.LetsEncryptServer.BackgroundServices { return IDomainResult.Success(); } - private async Task RenewCertificatesForHostnames(Guid accountId, string description, string[] contacts, string[] hostnames, string challengeType, bool isStaging) { - var (sessionId, configureClientResult) = await _certsFlowService.ConfigureClientAsync(isStaging); - if (!configureClientResult.IsSuccess || sessionId == null) { - LogErrors(configureClientResult.Errors); - return configureClientResult; - } - - var sessionIdValue = sessionId.Value; - - var (_, initResult) = await _certsFlowService.InitAsync(sessionIdValue, accountId, description, contacts); - if (!initResult.IsSuccess) { - LogErrors(initResult.Errors); - return initResult; - } - - var (_, newOrderResult) = await _certsFlowService.NewOrderAsync(sessionIdValue, hostnames, challengeType); - if (!newOrderResult.IsSuccess) { - LogErrors(newOrderResult.Errors); - return newOrderResult; - } - - var challengeResult = await _certsFlowService.CompleteChallengesAsync(sessionIdValue); - if (!challengeResult.IsSuccess) { - LogErrors(challengeResult.Errors); - return challengeResult; - } - - var getOrderResult = await _certsFlowService.GetOrderAsync(sessionIdValue, hostnames); - if (!getOrderResult.IsSuccess) { - LogErrors(getOrderResult.Errors); - return getOrderResult; - } - - var certs = await _certsFlowService.GetCertificatesAsync(sessionIdValue, hostnames); - if (!certs.IsSuccess) { - LogErrors(certs.Errors); - return certs; - } - - var (_, applyCertsResult) = await _certsFlowService.ApplyCertificatesAsync(sessionIdValue, hostnames); - if (!applyCertsResult.IsSuccess) { - LogErrors(applyCertsResult.Errors); - return applyCertsResult; - } - - return IDomainResult.Success(); - } + private void LogErrors(IEnumerable errors) { foreach (var error in errors) { diff --git a/src/LetsEncryptServer/Controllers/AccountController.cs b/src/LetsEncryptServer/Controllers/AccountController.cs index c90c981..47863e2 100644 --- a/src/LetsEncryptServer/Controllers/AccountController.cs +++ b/src/LetsEncryptServer/Controllers/AccountController.cs @@ -42,7 +42,7 @@ public class AccountController : ControllerBase { return result.ToActionResult(); } - [HttpDelete("account/{accountd:guid}")] + [HttpDelete("account/{accountId:guid}")] public async Task DeleteAccount(Guid accountId) { var result = await _accountService.DeleteAccountAsync(accountId); return result.ToActionResult(); diff --git a/src/LetsEncryptServer/Services/AccoutService.cs b/src/LetsEncryptServer/Services/AccoutService.cs index 79a64d7..51597ae 100644 --- a/src/LetsEncryptServer/Services/AccoutService.cs +++ b/src/LetsEncryptServer/Services/AccoutService.cs @@ -133,10 +133,6 @@ public class AccountService : IAccountService { cache.Contacts = contacts.ToArray(); } - - - - var hostnamesToAdd = new List(); var hostnamesToRemove = new List(); @@ -174,13 +170,11 @@ public class AccountService : IAccountService { } } - var saveResult = await _cacheService.SaveToCacheAsync(accountId, cache); if (!saveResult.IsSuccess) { return (null, saveResult); } - if (hostnamesToAdd.Count > 0) { var (_, newCertsResult) = await _certsFlowService.FullFlow( cache.IsStaging, @@ -195,13 +189,18 @@ public class AccountService : IAccountService { return (null, newCertsResult); } - if (hostnamesToRemove.Count > 0) { - hostnamesToRemove.ForEach(hostname => { - cache.CachedCerts?.Remove(hostname); - }); - } + var revokeResult = await _certsFlowService.FullRevocationFlow( + cache.IsStaging, + cache.AccountId, + cache.Description, + cache.Contacts, + hostnamesToRemove.ToArray() + ); + if (!revokeResult.IsSuccess) + return (null, revokeResult); + } (cache, loadResult) = await _cacheService.LoadAccountFromCacheAsync(accountId); if (!loadResult.IsSuccess || cache == null) { diff --git a/src/LetsEncryptServer/Services/CertsFlowService.cs b/src/LetsEncryptServer/Services/CertsFlowService.cs index 1ee5dbb..a3114d8 100644 --- a/src/LetsEncryptServer/Services/CertsFlowService.cs +++ b/src/LetsEncryptServer/Services/CertsFlowService.cs @@ -6,6 +6,7 @@ using MaksIT.LetsEncrypt.Entities; using MaksIT.LetsEncrypt.Services; using MaksIT.Models.LetsEncryptServer.CertsFlow.Requests; using System.Security.Cryptography; +using MaksIT.LetsEncrypt.Entities.LetsEncrypt; namespace MaksIT.LetsEncryptServer.Services; @@ -22,8 +23,11 @@ public interface ICertsInternalService : ICertsCommonService { Task<(List?, IDomainResult)> NewOrderAsync(Guid sessionId, string[] hostnames, string challengeType); Task GetOrderAsync(Guid sessionId, string[] hostnames); Task GetCertificatesAsync(Guid sessionId, string[] hostnames); + Task RevokeCertificatesAsync(Guid sessionId, string[] hostnames); Task<(Dictionary?, IDomainResult)> ApplyCertificatesAsync(Guid sessionId, string[] hostnames); Task<(Guid?, IDomainResult)> FullFlow(bool isStaging, Guid? accountId, string description, string[] contacts, string challengeType, string[] hostnames); + Task FullRevocationFlow(bool isStaging, Guid accountId, string description, string[] contacts, string[] hostnames); + } public interface ICertsRestService : ICertsCommonService { @@ -145,6 +149,7 @@ public class CertsFlowService : ICertsFlowService { Thread.Sleep(1000); } + // TODO: Move to separate method // Persist the cache var (cache, getCacheResult) = _letsEncryptService.GetRegistrationCache(sessionId); if (!getCacheResult.IsSuccess || cache == null) @@ -161,6 +166,33 @@ public class CertsFlowService : ICertsFlowService { return await _letsEncryptService.GetOrder(sessionId, hostnames); } + public async Task RevokeCertificatesAsync(Guid sessionId, string[] hostnames) { + foreach (var hostname in hostnames) { + var result = await _letsEncryptService.RevokeCertificate(sessionId, hostname, RevokeReason.Unspecified); + if (!result.IsSuccess) + return result; + } + + // TODO: Move to separate method + // Persist the cache + var (cache, getCacheResult) = _letsEncryptService.GetRegistrationCache(sessionId); + if (!getCacheResult.IsSuccess || cache == null) + return getCacheResult; + + var saveResult = await _cacheService.SaveToCacheAsync(cache.AccountId, cache); + if (!saveResult.IsSuccess) + return saveResult; + + return IDomainResult.Success(); + } + + + + + + + + public async Task<(Dictionary?, IDomainResult)> ApplyCertificatesAsync(Guid sessionId, string[] hostnames) { var (cache, getCacheResult) = _letsEncryptService.GetRegistrationCache(sessionId); @@ -221,6 +253,22 @@ public class CertsFlowService : ICertsFlowService { return IDomainResult.Success(accountId); } + public async Task FullRevocationFlow(bool isStaging, Guid accountId, string description, string[] contacts, string[] hostnames) { + var (sessionId, configureClientResult) = await ConfigureClientAsync(isStaging); + if (!configureClientResult.IsSuccess || sessionId == null) + return configureClientResult; + + var (_, initResult) = await InitAsync(sessionId.Value, accountId, description, contacts); + if (!initResult.IsSuccess) + return initResult; + + var revokeResult = await RevokeCertificatesAsync(sessionId.Value, hostnames); + if (!revokeResult.IsSuccess) + return revokeResult; + + return IDomainResult.Success(); + } + #endregion diff --git a/src/Models/LetsEncryptServer/Account/Requests/PatchHostnameRequest.cs b/src/Models/LetsEncryptServer/Account/Requests/PatchHostnameRequest.cs index a9d36e3..12e3f0c 100644 --- a/src/Models/LetsEncryptServer/Account/Requests/PatchHostnameRequest.cs +++ b/src/Models/LetsEncryptServer/Account/Requests/PatchHostnameRequest.cs @@ -2,8 +2,8 @@ namespace MaksIT.Models.LetsEncryptServer.Account.Requests; public class PatchHostnameRequest { - public PatchAction Hostname { get; set; } + public PatchAction? Hostname { get; set; } - public PatchAction IsDisabled { get; set; } + public PatchAction? IsDisabled { get; set; } }