├── tools ├── push.cmd └── build.cmd ├── .gitignore ├── src ├── ACME.Protocol.Model │ ├── Model │ │ ├── IVersioned.cs │ │ ├── AccountStatus.cs │ │ ├── ChallengeStatus.cs │ │ ├── AuthorizationStatus.cs │ │ ├── ChallengeTypes.cs │ │ ├── Exceptions │ │ │ ├── ConcurrencyException.cs │ │ │ ├── BadNonceException.cs │ │ │ ├── BadSignatureAlgorithmException.cs │ │ │ ├── NotInitializedException.cs │ │ │ ├── AcmeException.cs │ │ │ └── MalformedRequestException.cs │ │ ├── GuidString.cs │ │ ├── Nonce.cs │ │ ├── AuthorizationStatusExtensions.cs │ │ ├── CryptoString.cs │ │ ├── OrderStatus.cs │ │ ├── Jwk.cs │ │ ├── Extensions │ │ │ └── SerializationInfoExtension.cs │ │ ├── Identifier.cs │ │ ├── Account.cs │ │ ├── AcmeError.cs │ │ ├── Challenge.cs │ │ ├── Authorization.cs │ │ └── Order.cs │ ├── HttpModel │ │ ├── Requests │ │ │ ├── FinalizeOrder.cs │ │ │ ├── AcmePayload.cs │ │ │ ├── Identifier.cs │ │ │ ├── CreateOrGetAccountRequest.cs │ │ │ ├── CreateOrderRequest.cs │ │ │ ├── AcmeHeader.cs │ │ │ └── AcmeRawPostRequest.cs │ │ ├── OrdersList.cs │ │ ├── DirectoryMetadata.cs │ │ ├── Identifier.cs │ │ ├── Directory.cs │ │ ├── Converters │ │ │ └── JwkConverter.cs │ │ ├── Account.cs │ │ ├── AcmeError.cs │ │ ├── Authorization.cs │ │ ├── Challenge.cs │ │ ├── EnumMappings.cs │ │ └── Order.cs │ └── ACME.Protocol.Model.csproj ├── ACME.Protocol │ ├── AcmeProtocolOptions.cs │ ├── GlobalSuppressions.cs │ ├── Services │ │ ├── DefaultAuthorizationFactory.cs │ │ ├── DefaultNonceService.cs │ │ ├── DefaultChallangeValidatorFactory.cs │ │ ├── Http01ChallangeValidator.cs │ │ ├── TokenChallengeValidator.cs │ │ ├── Dns01ChallangeValidator.cs │ │ ├── DefaultAccountService.cs │ │ └── DefaultOrderService.cs │ ├── ACME.Protocol.csproj │ ├── Workers │ │ ├── IssuanceWorker.cs │ │ └── ValidationWorker.cs │ └── RequestServices │ │ ├── DefaultRequestProvider.cs │ │ └── DefaultRequestValidationService.cs ├── ACME.Protocol.Abstractions │ ├── Services │ │ ├── IChallangeValidatorFactory.cs │ │ ├── IAuthorizationFactory.cs │ │ ├── INonceService.cs │ │ ├── IChallengeValidator.cs │ │ ├── IAccountService.cs │ │ └── IOrderService.cs │ ├── Workers │ │ ├── IIssuanceWorker.cs │ │ └── IValidationWorker.cs │ ├── IssuanceServices │ │ ├── ICsrValidator.cs │ │ └── ICertificateIssuer.cs │ ├── Storage │ │ ├── INonceStore.cs │ │ ├── IAccountStore.cs │ │ └── IOrderStore.cs │ ├── RequestServices │ │ ├── IAcmeRequestProvider.cs │ │ └── IRequestValidationService.cs │ └── ACME.Protocol.Abstractions.csproj ├── ACME.Server │ ├── Configuration │ │ ├── TOSOptions.cs │ │ ├── ACMEServerOptions.cs │ │ └── BackgroundServiceOptions.cs │ ├── Properties │ │ └── launchSettings.json │ ├── Middleware │ │ ├── AcmeMiddlewareExtensions.cs │ │ └── AcmeMiddleware.cs │ ├── Controllers │ │ ├── NonceController.cs │ │ ├── DirectoryController.cs │ │ ├── AccountController.cs │ │ └── OrderController.cs │ ├── ModelBinding │ │ ├── AcmeHeaderBinder.cs │ │ ├── AcmePayloadBinder.cs │ │ └── ModelBindingProvider.cs │ ├── Filters │ │ ├── AcmeIndexLinkFilter.cs │ │ ├── ValidateAcmeRequestFilter.cs │ │ ├── AcmeExceptionFilter.cs │ │ ├── AcmeLocationFilter.cs │ │ └── AddNextNonceFilter.cs │ ├── ACME.Server.csproj │ ├── BackgroundServices │ │ ├── HostedIssuanceService.cs │ │ ├── HostedValidationService.cs │ │ └── TimedHostedService.cs │ └── Extensions │ │ └── ServiceCollectionExtensions.cs └── ACME.Storage.FileStore │ ├── JsonDefaults.cs │ ├── Extensions │ └── ServiceCollectionExtensions.cs │ ├── Configuration │ └── FileStoreOptions.cs │ ├── ACME.Storage.FileStore.csproj │ ├── NonceStore.cs │ ├── AccountStore.cs │ ├── StoreBase.cs │ └── OrderStore.cs ├── NuGet.Config ├── Directory.Build.props ├── test ├── ACME.Protocol.Model.Tests │ ├── HttpModel-Initialization │ │ ├── Order.cs │ │ ├── Identifier.cs │ │ ├── Account.cs │ │ ├── AcmeError.cs │ │ ├── Authorization.cs │ │ └── Challenge.cs │ ├── Model-Initialization │ │ ├── Nonce.cs │ │ ├── GuidString.cs │ │ ├── CryptoString.cs │ │ ├── AcmeError.cs │ │ ├── Account.cs │ │ └── Identifier.cs │ ├── ACME.Protocol.Model.Tests.csproj │ └── StaticTestData.cs └── ACME.Server.Tests │ └── ACME.Server.Tests.csproj ├── .github └── FUNDING.yml ├── .editorconfig ├── LICENSE ├── README.md └── ACME-Server.sln /tools/push.cmd: -------------------------------------------------------------------------------- 1 | dotnet nuget push -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.vs 2 | **/obj/ 3 | **/bin/ 4 | /dist 5 | -------------------------------------------------------------------------------- /tools/build.cmd: -------------------------------------------------------------------------------- 1 | del /S/Q .\dist\* 2 | dotnet build -c Release 3 | dotnet pack -c Release -o .\dist\ -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/IVersioned.cs: -------------------------------------------------------------------------------- 1 | namespace TGIT.ACME.Protocol.Model 2 | { 3 | public interface IVersioned 4 | { 5 | long Version { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /src/ACME.Protocol/AcmeProtocolOptions.cs: -------------------------------------------------------------------------------- 1 | namespace TGIT.ACME.Protocol 2 | { 3 | public class AcmeProtocolOptions 4 | { 5 | public bool AllowCNSuffix { get; set; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /NuGet.Config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/AccountStatus.cs: -------------------------------------------------------------------------------- 1 | namespace TGIT.ACME.Protocol.Model 2 | { 3 | public enum AccountStatus 4 | { 5 | Valid, 6 | Deactivated, 7 | Revoked 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1.0.0-alpha6 4 | Enable 5 | 6 | Thomas Glatzer; GitHub Contributors 7 | 8 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/ChallengeStatus.cs: -------------------------------------------------------------------------------- 1 | namespace TGIT.ACME.Protocol.Model 2 | { 3 | public enum ChallengeStatus 4 | { 5 | Pending, 6 | Processing, 7 | Valid, 8 | Invalid 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/ACME.Protocol.Model.Tests/HttpModel-Initialization/Order.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace TGIT.ACME.Protocol.Model.Tests.HttpModel_Initialization 6 | { 7 | class Order 8 | { 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Abstractions/Services/IChallangeValidatorFactory.cs: -------------------------------------------------------------------------------- 1 | using TGIT.ACME.Protocol.Model; 2 | 3 | namespace TGIT.ACME.Protocol.Services 4 | { 5 | public interface IChallangeValidatorFactory 6 | { 7 | IChallengeValidator GetValidator(Challenge challenge); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Abstractions/Workers/IIssuanceWorker.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | 4 | namespace TGIT.ACME.Protocol.Workers 5 | { 6 | public interface IIssuanceWorker 7 | { 8 | Task RunAsync(CancellationToken cancellationToken); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Abstractions/Workers/IValidationWorker.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | 4 | namespace TGIT.ACME.Protocol.Workers 5 | { 6 | public interface IValidationWorker 7 | { 8 | Task RunAsync(CancellationToken cancellationToken); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Abstractions/Services/IAuthorizationFactory.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using TGIT.ACME.Protocol.Model; 3 | 4 | namespace TGIT.ACME.Protocol.Services 5 | { 6 | public interface IAuthorizationFactory 7 | { 8 | void CreateAuthorizations(Order order); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/Requests/FinalizeOrder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace TGIT.ACME.Protocol.HttpModel.Requests 6 | { 7 | public class FinalizeOrderRequest 8 | { 9 | public string? Csr { get; set; } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/AuthorizationStatus.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | 3 | namespace TGIT.ACME.Protocol.Model 4 | { 5 | public enum AuthorizationStatus 6 | { 7 | Pending, 8 | Valid, 9 | Invalid, 10 | Revoked, 11 | Deactivated, 12 | Expired 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/ACME.Server/Configuration/TOSOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TGIT.ACME.Server.Configuration 4 | { 5 | public class TOSOptions 6 | { 7 | public bool RequireAgreement { get; set; } 8 | public string? Url { get; set; } 9 | 10 | public DateTime? LastUpdate { get; set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/Requests/AcmePayload.cs: -------------------------------------------------------------------------------- 1 | namespace TGIT.ACME.Protocol.HttpModel.Requests 2 | { 3 | public class AcmePayload 4 | { 5 | public AcmePayload(TPayload value) 6 | { 7 | Value = value; 8 | } 9 | 10 | public TPayload Value { get; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/ChallengeTypes.cs: -------------------------------------------------------------------------------- 1 | namespace TGIT.ACME.Protocol.Model 2 | { 3 | public static class ChallengeTypes 4 | { 5 | public const string Http01 = "http-01"; 6 | public const string Dns01 = "dns-01"; 7 | 8 | public static readonly string[] AllTypes = new[] { Http01, Dns01 }; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Abstractions/Services/INonceService.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using TGIT.ACME.Protocol.Model; 4 | 5 | namespace TGIT.ACME.Protocol.Services 6 | { 7 | public interface INonceService 8 | { 9 | Task CreateNonceAsync(CancellationToken cancellationToken); 10 | 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/Exceptions/ConcurrencyException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TGIT.ACME.Protocol.Model.Exceptions 4 | { 5 | public class ConcurrencyException : InvalidOperationException 6 | { 7 | public ConcurrencyException() 8 | : base($"Object has been changed since loading") 9 | { } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/ACME.Server/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "ACME.Server": { 4 | "commandName": "Project", 5 | "launchBrowser": false, 6 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Development" 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/Requests/Identifier.cs: -------------------------------------------------------------------------------- 1 | namespace TGIT.ACME.Protocol.HttpModel.Requests 2 | { 3 | /// 4 | /// Defines an identifier as used in orders or authorizations 5 | /// 6 | public class Identifier 7 | { 8 | public string? Type { get; set; } 9 | public string? Value { get; set; } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/Exceptions/BadNonceException.cs: -------------------------------------------------------------------------------- 1 | namespace TGIT.ACME.Protocol.Model.Exceptions 2 | { 3 | public class BadNonceException : AcmeException 4 | { 5 | private const string Detail = "The nonce could not be accepted."; 6 | 7 | public BadNonceException() : base(Detail) { } 8 | 9 | public override string ErrorType => "badNonce"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/ACME.Server/Configuration/ACMEServerOptions.cs: -------------------------------------------------------------------------------- 1 | namespace TGIT.ACME.Server.Configuration 2 | { 3 | public class ACMEServerOptions 4 | { 5 | public BackgroundServiceOptions HostedWorkers { get; set; } = new BackgroundServiceOptions(); 6 | 7 | public string? WebsiteUrl { get; set; } 8 | 9 | public TOSOptions TOS { get; set; } = new TOSOptions(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/Requests/CreateOrGetAccountRequest.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TGIT.ACME.Protocol.HttpModel.Requests 4 | { 5 | public class CreateOrGetAccount 6 | { 7 | public List? Contact { get; set; } 8 | 9 | public bool TermsOfServiceAgreed { get; set; } 10 | public bool OnlyReturnExisting { get; set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Abstractions/IssuanceServices/ICsrValidator.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using TGIT.ACME.Protocol.Model; 4 | 5 | namespace TGIT.ACME.Protocol.IssuanceServices 6 | { 7 | public interface ICsrValidator 8 | { 9 | Task<(bool isValid, AcmeError? error)> ValidateCsrAsync(Order order, string csr, CancellationToken cancellationToken); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Abstractions/IssuanceServices/ICertificateIssuer.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using TGIT.ACME.Protocol.Model; 4 | 5 | namespace TGIT.ACME.Protocol.IssuanceServices 6 | { 7 | public interface ICertificateIssuer 8 | { 9 | Task<(byte[]? certificate, AcmeError? error)> IssueCertificate(string csr, CancellationToken cancellationToken); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Abstractions/Services/IChallengeValidator.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using TGIT.ACME.Protocol.Model; 4 | 5 | namespace TGIT.ACME.Protocol.Services 6 | { 7 | public interface IChallengeValidator 8 | { 9 | Task<(bool IsValid, AcmeError? error)> ValidateChallengeAsync(Challenge challenge, Account account, CancellationToken cancellationToken); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/Requests/CreateOrderRequest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace TGIT.ACME.Protocol.HttpModel.Requests 5 | { 6 | public class CreateOrderRequest 7 | { 8 | public List? Identifiers { get; set; } 9 | 10 | public DateTimeOffset? NotBefore { get; set; } 11 | public DateTimeOffset? NotAfter { get; set; } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Abstractions/Storage/INonceStore.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using TGIT.ACME.Protocol.Model; 4 | 5 | namespace TGIT.ACME.Protocol.Storage 6 | { 7 | public interface INonceStore 8 | { 9 | Task SaveNonceAsync(Nonce nonce, CancellationToken cancellationToken); 10 | Task TryRemoveNonceAsync(Nonce nonce, CancellationToken cancellationToken); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/Exceptions/BadSignatureAlgorithmException.cs: -------------------------------------------------------------------------------- 1 | namespace TGIT.ACME.Protocol.Model.Exceptions 2 | { 3 | public class BadSignatureAlgorithmException : AcmeException 4 | { 5 | private const string Detail = "The ALG is not supported."; 6 | 7 | public BadSignatureAlgorithmException() : base(Detail) { } 8 | 9 | public override string ErrorType => "badSignatureAlgorithm"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/ACME.Server/Configuration/BackgroundServiceOptions.cs: -------------------------------------------------------------------------------- 1 | namespace TGIT.ACME.Server.Configuration 2 | { 3 | public class BackgroundServiceOptions 4 | { 5 | public bool EnableValidationService { get; set; } = true; 6 | public bool EnableIssuanceService { get; set; } = true; 7 | 8 | public int ValidationCheckInterval { get; set; } = 60; 9 | public int IssuanceCheckInterval { get; set; } = 60; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/ACME.Protocol.Model.Tests/Model-Initialization/Nonce.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | 3 | namespace TGIT.ACME.Protocol.Model.Tests.Model_Initialization 4 | { 5 | public class Nonce 6 | { 7 | [Fact] 8 | public void Ctor_Populates_All_Properties() 9 | { 10 | var token = "ABC"; 11 | var sut = new Model.Nonce(token); 12 | 13 | Assert.Equal(token, sut.Token); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Abstractions/Storage/IAccountStore.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using TGIT.ACME.Protocol.Model; 4 | 5 | namespace TGIT.ACME.Protocol.Storage 6 | { 7 | public interface IAccountStore 8 | { 9 | Task SaveAccountAsync(Account account, CancellationToken cancellationToken); 10 | Task LoadAccountAsync(string accountId, CancellationToken cancellationToken); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/ACME.Server/Middleware/AcmeMiddlewareExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using TGIT.ACME.Server.Middleware; 3 | 4 | namespace Microsoft.AspNetCore.Builder 5 | { 6 | public static class AcmeMiddlewareExtensions 7 | { 8 | public static IApplicationBuilder UseAcmeServer(this IApplicationBuilder builder) 9 | { 10 | return builder.UseMiddleware(); 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Abstractions/RequestServices/IAcmeRequestProvider.cs: -------------------------------------------------------------------------------- 1 | using TGIT.ACME.Protocol.HttpModel.Requests; 2 | 3 | namespace TGIT.ACME.Protocol.RequestServices 4 | { 5 | public interface IAcmeRequestProvider 6 | { 7 | void Initialize(AcmeRawPostRequest rawPostRequest); 8 | 9 | AcmeRawPostRequest GetRequest(); 10 | 11 | AcmeHeader GetHeader(); 12 | 13 | TPayload GetPayload(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/GuidString.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.IdentityModel.Tokens; 2 | using System; 3 | 4 | namespace TGIT.ACME.Protocol.Model 5 | { 6 | public class GuidString 7 | { 8 | private GuidString() 9 | { 10 | Value = Base64UrlEncoder.Encode(Guid.NewGuid().ToByteArray()); 11 | } 12 | 13 | private string Value { get; } 14 | 15 | public static string NewValue() => new GuidString().Value; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Abstractions/RequestServices/IRequestValidationService.cs: -------------------------------------------------------------------------------- 1 | 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using TGIT.ACME.Protocol.HttpModel.Requests; 5 | 6 | namespace TGIT.ACME.Protocol.RequestServices 7 | { 8 | public interface IRequestValidationService 9 | { 10 | Task ValidateRequestAsync(AcmeRawPostRequest request, AcmeHeader header, 11 | string requestUrl, CancellationToken cancellationToken); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/Exceptions/NotInitializedException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.CompilerServices; 3 | 4 | namespace TGIT.ACME.Protocol.Model.Exceptions 5 | { 6 | public class NotInitializedException : InvalidOperationException 7 | { 8 | public NotInitializedException([CallerMemberName]string caller = null!) 9 | :base($"{caller} has been accessed before being initialized.") 10 | { 11 | 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/ACME.Protocol.Model.Tests/Model-Initialization/GuidString.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | 3 | namespace TGIT.ACME.Protocol.Model.Tests.Model_Initialization 4 | { 5 | public class GuidString 6 | { 7 | [Fact] 8 | public void GuidString_Seems_Filled() 9 | { 10 | var sut = Model.GuidString.NewValue(); 11 | 12 | Assert.False(string.IsNullOrWhiteSpace(sut)); 13 | Assert.Equal(22, sut.Length); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/ACME.Protocol.Model.Tests/Model-Initialization/CryptoString.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | 3 | namespace TGIT.ACME.Protocol.Model.Tests.Model_Initialization 4 | { 5 | public class CryptoString 6 | { 7 | [Fact] 8 | public void CryptoString_Seems_Filled() 9 | { 10 | var sut = Model.CryptoString.NewValue(48); 11 | 12 | Assert.False(string.IsNullOrWhiteSpace(sut)); 13 | Assert.Equal(64, sut.Length); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/OrdersList.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | 4 | namespace TGIT.ACME.Protocol.HttpModel 5 | { 6 | /// 7 | /// Represents a list of order urls 8 | /// 9 | public class OrdersList 10 | { 11 | public OrdersList(IEnumerable orders) 12 | { 13 | Orders = orders.ToList(); 14 | } 15 | 16 | public List Orders { get; set; } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/ACME.Protocol/GlobalSuppressions.cs: -------------------------------------------------------------------------------- 1 | // This file is used by Code Analysis to maintain SuppressMessage 2 | // attributes that are applied to this project. 3 | // Project-level suppressions either have no target or are given 4 | // a specific target and scoped to a namespace, type, member, etc. 5 | 6 | using System.Diagnostics.CodeAnalysis; 7 | 8 | [assembly: SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "", Scope = "member", Target = "~P:TGIT.ACME.Protocol.Model.Order.Certificate")] 9 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/Nonce.cs: -------------------------------------------------------------------------------- 1 | using TGIT.ACME.Protocol.Model.Exceptions; 2 | 3 | namespace TGIT.ACME.Protocol.Model 4 | { 5 | public class Nonce 6 | { 7 | private string? _token; 8 | 9 | private Nonce() { } 10 | 11 | public Nonce(string token) 12 | { 13 | Token = token; 14 | } 15 | 16 | public string Token { 17 | get => _token ?? throw new NotInitializedException(); 18 | private set => _token = value; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/DirectoryMetadata.cs: -------------------------------------------------------------------------------- 1 | namespace TGIT.ACME.Protocol.HttpModel 2 | { 3 | /// 4 | /// Describes the HTTP-Response-Model for ACME DirectoryMetadata 5 | /// https://tools.ietf.org/html/rfc8555#section-7.1.1 6 | /// 7 | public class DirectoryMetadata 8 | { 9 | public string? TermsOfService { get; set; } 10 | public string? Website { get; set; } 11 | public string? CAAIdentities { get; set; } 12 | public bool? ExternalAccountRequired { get; set; } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/ACME.Server.Tests/ACME.Server.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/Identifier.cs: -------------------------------------------------------------------------------- 1 | namespace TGIT.ACME.Protocol.HttpModel 2 | { 3 | /// 4 | /// Defines an identifier as used in orders or authorizations 5 | /// 6 | public class Identifier 7 | { 8 | public Identifier(Model.Identifier model) 9 | { 10 | if (model is null) 11 | throw new System.ArgumentNullException(nameof(model)); 12 | 13 | Type = model.Type; 14 | Value = model.Value; 15 | } 16 | 17 | public string Type { get; } 18 | public string Value { get; } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/Exceptions/AcmeException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TGIT.ACME.Protocol.Model.Exceptions 4 | { 5 | public abstract class AcmeException : Exception 6 | { 7 | protected AcmeException(string message) 8 | : base(message) { } 9 | 10 | public string UrnBase { get; protected set; } = "urn:ietf:params:acme:error"; 11 | public abstract string ErrorType { get; } 12 | 13 | public virtual HttpModel.AcmeError GetHttpError() 14 | { 15 | return new HttpModel.AcmeError($"{UrnBase}:{ErrorType}", Message); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/ACME.Server/Controllers/NonceController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.AspNetCore.Mvc; 3 | using TGIT.ACME.Server.Filters; 4 | 5 | namespace TGIT.ACME.Server.Controllers 6 | { 7 | [ApiController] 8 | [AddNextNonce] 9 | public class NonceController : ControllerBase 10 | { 11 | [Route("/new-nonce", Name = "NewNonce")] 12 | [HttpGet, HttpHead] 13 | public ActionResult GetNewNonce() 14 | { 15 | if (HttpMethods.IsGet(HttpContext.Request.Method)) 16 | return NoContent(); 17 | else 18 | return Ok(); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Abstractions/Storage/IOrderStore.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using TGIT.ACME.Protocol.Model; 5 | 6 | namespace TGIT.ACME.Protocol.Storage 7 | { 8 | public interface IOrderStore 9 | { 10 | Task LoadOrderAsync(string orderId, CancellationToken cancellationToken); 11 | 12 | Task SaveOrderAsync(Order order, CancellationToken cancellationToken); 13 | 14 | Task> GetValidatableOrders(CancellationToken cancellationToken); 15 | Task> GetFinalizableOrders(CancellationToken cancellationToken); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/AuthorizationStatusExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | 3 | namespace TGIT.ACME.Protocol.Model 4 | { 5 | public static class AuthorizationStatusExtensions 6 | { 7 | private static readonly AuthorizationStatus[] _invalidStatus = new[] 8 | { 9 | AuthorizationStatus.Invalid, 10 | AuthorizationStatus.Deactivated, 11 | AuthorizationStatus.Expired, 12 | AuthorizationStatus.Revoked 13 | }; 14 | 15 | public static bool IsInvalid(this AuthorizationStatus status) 16 | { 17 | return _invalidStatus.Contains(status); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/ACME.Protocol.Model.Tests/HttpModel-Initialization/Identifier.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Xunit; 5 | 6 | namespace TGIT.ACME.Protocol.Model.Tests.HttpModel_Initialization 7 | { 8 | public class Identifier 9 | { 10 | [Fact] 11 | public void Ctor_Intializes_All_Properties() 12 | { 13 | var identifier = new Model.Identifier("dns", "www.example.com"); 14 | var sut = new HttpModel.Identifier(identifier); 15 | 16 | Assert.Equal(identifier.Type, sut.Type); 17 | Assert.Equal(identifier.Value, sut.Value); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/CryptoString.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.IdentityModel.Tokens; 2 | 3 | namespace TGIT.ACME.Protocol.Model 4 | { 5 | public class CryptoString 6 | { 7 | private CryptoString(int byteCount) 8 | { 9 | var bytes = new byte[byteCount]; 10 | 11 | using (var cryptoRng = System.Security.Cryptography.RandomNumberGenerator.Create()) 12 | cryptoRng.GetBytes(bytes); 13 | 14 | Value = Base64UrlEncoder.Encode(bytes); 15 | } 16 | 17 | private string Value { get; } 18 | public static string NewValue(int byteCount = 48) => new CryptoString(byteCount).Value; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [glatzert] 4 | #patreon: # Replace with a single Patreon username 5 | #open_collective: # Replace with a single Open Collective username 6 | #ko_fi: # Replace with a single Ko-fi username 7 | #tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | #community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | #liberapay: # Replace with a single Liberapay username 10 | #issuehunt: # Replace with a single IssueHunt username 11 | #otechie: # Replace with a single Otechie username 12 | #custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/Directory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TGIT.ACME.Protocol.HttpModel 4 | { 5 | /// 6 | /// Describes the HTTP-Response-Model for an ACME Directory 7 | /// https://tools.ietf.org/html/rfc8555#section-7.1.1 8 | /// 9 | public class Directory 10 | { 11 | public string? NewNonce { get; set; } 12 | public string? NewAccount { get; set; } 13 | public string? NewOrder { get; set; } 14 | public string? NewAuthz { get; set; } 15 | public string? RevokeCert { get; set; } 16 | public string? KeyChange { get; set; } 17 | 18 | public DirectoryMetadata? Meta { get; set; } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/ACME.Storage.FileStore/JsonDefaults.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using Newtonsoft.Json.Converters; 3 | 4 | namespace TGIT.ACME.Storage.FileStore 5 | { 6 | internal static class JsonDefaults 7 | { 8 | static JsonDefaults() 9 | { 10 | var settings = new JsonSerializerSettings 11 | { 12 | PreserveReferencesHandling = PreserveReferencesHandling.All, 13 | NullValueHandling = NullValueHandling.Include, 14 | }; 15 | 16 | settings.Converters.Add(new StringEnumConverter()); 17 | Settings = settings; 18 | } 19 | 20 | public static readonly JsonSerializerSettings Settings; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/ACME.Protocol.Model.Tests/ACME.Protocol.Model.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /test/ACME.Protocol.Model.Tests/StaticTestData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace TGIT.ACME.Protocol.Model.Tests 6 | { 7 | static class StaticTestData 8 | { 9 | public static readonly string JwkJson = @"{ ""kty"" : ""RSA"", ""kid"" : ""cc34c0a0-bd5a-4a3c-a50d-a2a7db7643df"", ""n"" : ""pjdss8ZaDfEH6K6U7GeW2nxDqR4IP049fk1fK0lndimbMMVBdPv_hSpm8T8EtBDxrUdi1OHZfMhUixGaut-3nQ4GG9nM249oxhCtxqqNvEXrmQRGqczyLxuh-fKn9Fg--hS9UpazHpfVAFnB5aCfXoNhPuI8oByyFKMKaOVgHNqP5NBEqabiLftZD3W_lsFCPGuzr4Vp0YS7zS2hDYScC2oOMu4rGU1LcMZf39p3153Cq7bS2Xh6Y-vw5pwzFYZdjQxDn8x8BG3fJ6j8TGLXQsbKH1218_HcUJRvMwdpbUQG5nvA2GXVqLqdwp054Lzk9_B_f1lVrmOKuHjTNHq48w"", ""e"" : ""AQAB"" }"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Abstractions/Services/IAccountService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using TGIT.ACME.Protocol.Model; 5 | 6 | namespace TGIT.ACME.Protocol.Services 7 | { 8 | public interface IAccountService 9 | { 10 | Task CreateAccountAsync(Jwk jwk, List? contact, 11 | bool termsOfServiceAgreed, CancellationToken cancellationToken); 12 | 13 | Task FindAccountAsync(Jwk jwk, CancellationToken cancellationToken); 14 | 15 | Task LoadAcountAsync(string accountId, CancellationToken cancellationToken); 16 | 17 | Task FromRequestAsync(CancellationToken cancellationToken); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/Converters/JwkConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | using TGIT.ACME.Protocol.Model; 5 | 6 | namespace TGIT.ACME.Protocol.HttpModel.Converters 7 | { 8 | public class JwkConverter : JsonConverter 9 | { 10 | public override Jwk Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 11 | { 12 | var jwkJson = JsonSerializer.Deserialize(ref reader).ToString(); 13 | return new Jwk(jwkJson); 14 | } 15 | 16 | public override void Write(Utf8JsonWriter writer, Jwk value, JsonSerializerOptions options) 17 | { 18 | throw new NotImplementedException(); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/OrderStatus.cs: -------------------------------------------------------------------------------- 1 | namespace TGIT.ACME.Protocol.Model 2 | { 3 | public enum OrderStatus 4 | { 5 | /// 6 | /// The order waits for notification on authorization / challenge readiness. 7 | /// 8 | Pending, 9 | 10 | /// 11 | /// The order is ready to receive a CSR. 12 | /// 13 | Ready, 14 | 15 | /// 16 | /// The order processes the CSR. 17 | /// 18 | Processing, 19 | 20 | /// 21 | /// A certificate has been issued. 22 | /// 23 | Valid, 24 | 25 | /// 26 | /// The order got invalid. See Errors for reasons. 27 | /// 28 | Invalid 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/Requests/AcmeHeader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Text.Json.Serialization; 4 | using TGIT.ACME.Protocol.HttpModel.Converters; 5 | using TGIT.ACME.Protocol.Model; 6 | 7 | namespace TGIT.ACME.Protocol.HttpModel.Requests 8 | { 9 | public class AcmeHeader 10 | { 11 | public string? Nonce { get; set; } 12 | public string? Url { get; set; } 13 | 14 | public string? Alg { get; set; } 15 | public string? Kid { get; set; } 16 | 17 | [JsonConverter(typeof(JwkConverter))] 18 | public Jwk? Jwk { get; set; } 19 | 20 | public string GetAccountId() 21 | { 22 | if (Kid == null) 23 | throw new InvalidOperationException(); 24 | 25 | return Kid.Split('/').Last(); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cs] 2 | 3 | # CA2007: Consider calling ConfigureAwait on the awaited task 4 | dotnet_diagnostic.CA2007.severity = none 5 | 6 | # CA1303: Do not pass literals as localized parameters 7 | dotnet_diagnostic.CA1303.severity = none 8 | 9 | # CA1032: Implement standard exception constructors 10 | dotnet_diagnostic.CA1032.severity = none 11 | 12 | # CA1056: Uri properties should not be strings 13 | dotnet_diagnostic.CA1056.severity = none 14 | 15 | # CA2227: Collection properties should be read only 16 | dotnet_diagnostic.CA2227.severity = silent 17 | 18 | # CA1308: Normalize strings to uppercase 19 | dotnet_diagnostic.CA1308.severity = none 20 | 21 | # CA1054: Uri parameters should not be strings 22 | dotnet_diagnostic.CA1054.severity = none 23 | 24 | # IDE0063: Use simple 'using' statement 25 | csharp_prefer_simple_using_statement = false:suggestion 26 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/ACME.Protocol.Model.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | TGIT.ACME.Protocol 6 | TGIT.ACME.Protocol.Model 7 | Library 8 | 9 | ACME Protocol Model 10 | https://github.com/PKISharp/ACME-Server/ 11 | Basic model and http-model classes for building ACME servers and clients. 12 | MIT 13 | ACME;RFC 8555 14 | true 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Abstractions/ACME.Protocol.Abstractions.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | TGIT.ACME.Protocol 6 | TGIT.ACME.Protocol.Abstractions 7 | Library 8 | 9 | ACME Protocol Service Abstractions 10 | https://github.com/PKISharp/ACME-Server/ 11 | Basic interface definitions for TGIT.ACME.Protocol.Core. 12 | MIT 13 | ACME;RFC 8555 14 | true 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/Requests/AcmeRawPostRequest.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using TGIT.ACME.Protocol.Model.Exceptions; 3 | 4 | namespace TGIT.ACME.Protocol.HttpModel.Requests 5 | { 6 | public class AcmeRawPostRequest 7 | { 8 | private string? _header; 9 | private string? _signature; 10 | 11 | private AcmeRawPostRequest() { } 12 | 13 | [JsonPropertyName("protected")] 14 | public string Header { 15 | get => _header ?? throw new NotInitializedException(); 16 | set => _header = value; 17 | } 18 | 19 | [JsonPropertyName("payload")] 20 | public string? Payload { get; set; } 21 | 22 | [JsonPropertyName("signature")] 23 | public string Signature { 24 | get => _signature ?? throw new NotInitializedException(); 25 | set => _signature = value; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/ACME.Storage.FileStore/Extensions/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | using TGIT.ACME.Protocol.Storage; 3 | using TGIT.ACME.Storage.FileStore; 4 | using TGIT.ACME.Storage.FileStore.Configuration; 5 | 6 | namespace Microsoft.Extensions.DependencyInjection 7 | { 8 | public static class ServiceCollectionExtensions 9 | { 10 | public static IServiceCollection AddACMEFileStore(this IServiceCollection services, IConfiguration configuration, string sectionName) 11 | { 12 | services.AddScoped(); 13 | services.AddScoped(); 14 | services.AddScoped(); 15 | 16 | services.AddOptions() 17 | .Bind(configuration.GetSection(sectionName)) 18 | .ValidateDataAnnotations(); 19 | 20 | return services; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/ACME.Server/ModelBinding/AcmeHeaderBinder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.ModelBinding; 2 | using System; 3 | using System.Threading.Tasks; 4 | using TGIT.ACME.Protocol.RequestServices; 5 | 6 | namespace TGIT.ACME.Server.ModelBinding 7 | { 8 | public class AcmeHeaderBinder : IModelBinder 9 | { 10 | private readonly IAcmeRequestProvider _requestProvider; 11 | 12 | public AcmeHeaderBinder(IAcmeRequestProvider requestProvider) 13 | { 14 | _requestProvider = requestProvider; 15 | } 16 | 17 | public Task BindModelAsync(ModelBindingContext bindingContext) 18 | { 19 | if (bindingContext is null) 20 | throw new ArgumentNullException(nameof(bindingContext)); 21 | 22 | var acmeHeader = _requestProvider.GetHeader(); 23 | bindingContext.Result = ModelBindingResult.Success(acmeHeader); 24 | 25 | return Task.CompletedTask; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/ACME.Server/Filters/AcmeIndexLinkFilter.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.AspNetCore.Mvc.Filters; 3 | using Microsoft.AspNetCore.Mvc.Routing; 4 | 5 | namespace TGIT.ACME.Server.Filters 6 | { 7 | public class AcmeIndexLinkFilter : IActionFilter 8 | { 9 | private readonly IUrlHelperFactory _urlHelperFactory; 10 | 11 | public AcmeIndexLinkFilter(IUrlHelperFactory urlHelperFactory) 12 | { 13 | _urlHelperFactory = urlHelperFactory; 14 | } 15 | 16 | public void OnActionExecuted(ActionExecutedContext context) { } 17 | 18 | public void OnActionExecuting(ActionExecutingContext context) 19 | { 20 | var urlHelper = _urlHelperFactory.GetUrlHelper(context); 21 | 22 | var linkHeaderUrl = urlHelper.RouteUrl("Directory", null, "https"); 23 | var linkHeader = $"<{linkHeaderUrl}>;rel=\"index\""; 24 | 25 | context.HttpContext.Response.Headers.Add("Link", linkHeader); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/ACME.Storage.FileStore/Configuration/FileStoreOptions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.ComponentModel.DataAnnotations; 3 | using System.IO; 4 | 5 | namespace TGIT.ACME.Storage.FileStore.Configuration 6 | { 7 | public class FileStoreOptions : IValidatableObject 8 | { 9 | public string BasePath { get; set; } = null!; 10 | 11 | public string NoncePath => Path.Combine(BasePath, "Nonces"); 12 | public string AccountPath => Path.Combine(BasePath, "Accounts"); 13 | public string OrderPath => Path.Combine(BasePath, "Orders"); 14 | public string WorkingPath => Path.Combine(BasePath, "_work"); 15 | 16 | public IEnumerable Validate(ValidationContext validationContext) 17 | { 18 | if (string.IsNullOrWhiteSpace(BasePath) || !Directory.Exists(BasePath)) 19 | yield return new ValidationResult($"FileStore BasePath ({BasePath}) was empty or did not exist.", new[] { nameof(BasePath) }); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/ACME.Protocol.Model.Tests/Model-Initialization/AcmeError.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | 3 | namespace TGIT.ACME.Protocol.Model.Tests.Model_Initialization 4 | { 5 | public class AcmeError 6 | { 7 | [Fact] 8 | public void Ctor_Populates_All_Properties() 9 | { 10 | var type = "custom:error"; 11 | var detail = "detail"; 12 | var identifier = new Model.Identifier("dns", "www.example.com"); 13 | 14 | var sut = new Model.AcmeError(type, detail, identifier); 15 | 16 | Assert.Equal(type, sut.Type); 17 | Assert.Equal(detail, sut.Detail); 18 | Assert.Equal(identifier, sut.Identifier); 19 | } 20 | 21 | [Fact] 22 | public void Ctor_Adds_IETF_Error_Urn() 23 | { 24 | var type = "test"; 25 | var detail = "detail"; 26 | 27 | var sut = new Model.AcmeError(type, detail); 28 | 29 | Assert.Equal("urn:ietf:params:acme:error:test", sut.Type); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ACME.Protocol/Services/DefaultAuthorizationFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using TGIT.ACME.Protocol.Model; 3 | 4 | namespace TGIT.ACME.Protocol.Services 5 | { 6 | public class DefaultAuthorizationFactory : IAuthorizationFactory 7 | { 8 | public void CreateAuthorizations(Order order) 9 | { 10 | if (order is null) 11 | throw new ArgumentNullException(nameof(order)); 12 | 13 | foreach(var identifier in order.Identifiers) 14 | { 15 | //TODO : set useful expiry; 16 | var authorization = new Authorization(order, identifier, DateTimeOffset.UtcNow.AddDays(2)); 17 | CreateChallenges(authorization); 18 | } 19 | } 20 | 21 | private static void CreateChallenges(Authorization authorization) 22 | { 23 | _ = new Challenge(authorization, ChallengeTypes.Dns01); 24 | if (!authorization.IsWildcard) 25 | _ = new Challenge(authorization, ChallengeTypes.Http01); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/Jwk.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.IdentityModel.Tokens; 2 | using TGIT.ACME.Protocol.Model.Exceptions; 3 | 4 | namespace TGIT.ACME.Protocol.Model 5 | { 6 | public class Jwk 7 | { 8 | private JsonWebKey? _jsonWebKey; 9 | 10 | private string? _jsonKeyHash; 11 | private string? _json; 12 | 13 | private Jwk() { } 14 | 15 | public Jwk(string json) 16 | { 17 | if (string.IsNullOrWhiteSpace(json)) 18 | throw new System.ArgumentNullException(nameof(json)); 19 | 20 | Json = json; 21 | } 22 | 23 | public string Json { 24 | get => _json ?? throw new NotInitializedException(); 25 | set => _json = value; 26 | } 27 | 28 | public JsonWebKey SecurityKey 29 | => _jsonWebKey ??= JsonWebKey.Create(Json); 30 | 31 | public string KeyHash 32 | => _jsonKeyHash ??= Base64UrlEncoder.Encode( 33 | SecurityKey.ComputeJwkThumbprint() 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Abstractions/Services/IOrderService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using TGIT.ACME.Protocol.Model; 6 | 7 | namespace TGIT.ACME.Protocol.Services 8 | { 9 | public interface IOrderService 10 | { 11 | Task CreateOrderAsync(Account account, 12 | IEnumerable identifiers, 13 | DateTimeOffset? notBefore, DateTimeOffset? notAfter, 14 | CancellationToken cancellationToken); 15 | 16 | Task GetOrderAsync(Account account, string orderId, CancellationToken cancellationToken); 17 | 18 | Task ProcessCsr(Account account, string orderId, string? csr, CancellationToken cancellationToken); 19 | Task GetCertificate(Account account, string orderId, CancellationToken cancellationToken); 20 | 21 | 22 | Task ProcessChallengeAsync(Account account, string orderId, string authId, string challengeId, CancellationToken cancellationToken); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/ACME.Server/ModelBinding/AcmePayloadBinder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.ModelBinding; 2 | using System; 3 | using System.Threading.Tasks; 4 | using TGIT.ACME.Protocol.HttpModel.Requests; 5 | using TGIT.ACME.Protocol.RequestServices; 6 | 7 | namespace TGIT.ACME.Server.ModelBinding 8 | { 9 | public class AcmePayloadBinder : IModelBinder 10 | { 11 | private readonly IAcmeRequestProvider _requestProvider; 12 | 13 | public AcmePayloadBinder(IAcmeRequestProvider requestProvider) 14 | { 15 | _requestProvider = requestProvider; 16 | } 17 | 18 | public Task BindModelAsync(ModelBindingContext bindingContext) 19 | { 20 | if (bindingContext is null) 21 | throw new ArgumentNullException(nameof(bindingContext)); 22 | 23 | var acmePayload = new AcmePayload(_requestProvider.GetPayload()); 24 | bindingContext.Result = ModelBindingResult.Success(acmePayload); 25 | 26 | return Task.CompletedTask; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/ACME.Server/ACME.Server.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | TGIT.ACME.Server 6 | TGIT.ACME.Server.Core 7 | Library 8 | 9 | ACME Protocol Server Core Implementation 10 | https://github.com/PKISharp/ACME-Server/ 11 | 12 | Basic implementation of an ACME (RFC 8555) Server with ASP.Net Core. 13 | You'll need to implement Issuance and Storage services or pick an available implementation to use it. 14 | 15 | MIT 16 | ACME;RFC 8555 17 | true 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/ACME.Protocol/Services/DefaultNonceService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using System; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using TGIT.ACME.Protocol.Model; 6 | using TGIT.ACME.Protocol.Storage; 7 | 8 | namespace TGIT.ACME.Protocol.Services 9 | { 10 | public class DefaultNonceService : INonceService 11 | { 12 | private readonly INonceStore _nonceStore; 13 | private readonly ILogger _logger; 14 | 15 | public DefaultNonceService(INonceStore nonceStore, ILogger logger) 16 | { 17 | _nonceStore = nonceStore; 18 | _logger = logger; 19 | } 20 | 21 | public async Task CreateNonceAsync(CancellationToken cancellationToken) 22 | { 23 | var nonce = new Nonce(GuidString.NewValue()); 24 | 25 | await _nonceStore.SaveNonceAsync(nonce, cancellationToken); 26 | _logger.LogInformation($"Created and saved new nonce: {nonce.Token}."); 27 | 28 | return nonce; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/Account.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TGIT.ACME.Protocol.HttpModel 4 | { 5 | /// 6 | /// Represents the data of an ACME account 7 | /// https://tools.ietf.org/html/rfc8555#section-7.1.2 8 | /// 9 | public class Account 10 | { 11 | public Account(Model.Account model, string ordersUrl) 12 | { 13 | if (model is null) 14 | throw new System.ArgumentNullException(nameof(model)); 15 | 16 | Status = EnumMappings.GetEnumString(model.Status); 17 | 18 | Contact = model.Contacts; 19 | TermsOfServiceAgreed = model.TOSAccepted.HasValue; 20 | 21 | ExternalAccountBinding = null; 22 | Orders = ordersUrl; 23 | } 24 | 25 | public string Status { get; set; } 26 | public string? Orders { get; set; } 27 | 28 | public List? Contact { get; set; } 29 | public bool? TermsOfServiceAgreed { get; set; } 30 | 31 | public object? ExternalAccountBinding { get; set; } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/ACME.Server/ModelBinding/ModelBindingProvider.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.ModelBinding; 2 | using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; 3 | using System; 4 | using TGIT.ACME.Protocol.HttpModel.Requests; 5 | 6 | namespace TGIT.ACME.Server.ModelBinding 7 | { 8 | public class AcmeModelBindingProvider : IModelBinderProvider 9 | { 10 | public IModelBinder? GetBinder(ModelBinderProviderContext context) 11 | { 12 | if (context is null) 13 | throw new ArgumentNullException(nameof(context)); 14 | 15 | var modelType = context.Metadata.ModelType; 16 | if (modelType == typeof(AcmeHeader)) 17 | return new BinderTypeModelBinder(typeof(AcmeHeaderBinder)); 18 | 19 | if (modelType.IsGenericType && modelType.GetGenericTypeDefinition() == typeof(AcmePayload<>)) { 20 | var type = typeof(AcmePayloadBinder<>).MakeGenericType(modelType.GetGenericArguments()); 21 | return new BinderTypeModelBinder(type); 22 | } 23 | 24 | return null; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Thomas Glatzer (https://github.com/glatzert) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /test/ACME.Protocol.Model.Tests/Model-Initialization/Account.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Xunit; 5 | 6 | namespace TGIT.ACME.Protocol.Model.Tests.Model_Initialization 7 | { 8 | public class Account 9 | { 10 | [Fact] 11 | public void Ctor_Populates_All_Properties() 12 | { 13 | var jwk = new Model.Jwk(StaticTestData.JwkJson); 14 | var contacts = new List { "some@example.com" }; 15 | var tosAccepted = DateTimeOffset.UtcNow; 16 | 17 | var sut = new Model.Account(jwk, contacts, tosAccepted); 18 | 19 | Assert.Equal(jwk, sut.Jwk); 20 | Assert.Equal(contacts, sut.Contacts); 21 | Assert.Equal(tosAccepted, sut.TOSAccepted); 22 | 23 | Assert.True(sut.AccountId.Length > 0); 24 | Assert.Equal(AccountStatus.Valid, sut.Status); 25 | } 26 | } 27 | 28 | public class Authorization 29 | { 30 | 31 | } 32 | 33 | public class Challenge 34 | { 35 | 36 | } 37 | 38 | public class Order 39 | { 40 | 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/ACME.Protocol/Services/DefaultChallangeValidatorFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using System; 3 | using TGIT.ACME.Protocol.Model; 4 | 5 | namespace TGIT.ACME.Protocol.Services 6 | { 7 | public class DefaultChallangeValidatorFactory : IChallangeValidatorFactory 8 | { 9 | private readonly IServiceProvider _services; 10 | 11 | public DefaultChallangeValidatorFactory(IServiceProvider services) 12 | { 13 | _services = services; 14 | } 15 | 16 | public IChallengeValidator GetValidator(Challenge challenge) 17 | { 18 | if (challenge is null) 19 | throw new ArgumentNullException(nameof(challenge)); 20 | 21 | IChallengeValidator validator = challenge.Type switch 22 | { 23 | ChallengeTypes.Http01 => _services.GetRequiredService(), 24 | ChallengeTypes.Dns01 => _services.GetRequiredService(), 25 | _ => throw new InvalidOperationException("Unknown Challenge Type") 26 | }; 27 | 28 | return validator; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/ACME.Protocol.Model.Tests/Model-Initialization/Identifier.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | 3 | namespace TGIT.ACME.Protocol.Model.Tests.Model_Initialization 4 | { 5 | public class Identifier 6 | { 7 | [Fact] 8 | public void Ctor_Populates_All_Properties() 9 | { 10 | var type = "dns"; 11 | var value = "www.example.com"; 12 | 13 | var sut = new Model.Identifier(type, value); 14 | 15 | Assert.Equal(type, sut.Type); 16 | Assert.Equal(value, sut.Value); 17 | Assert.False(sut.IsWildcard); 18 | } 19 | 20 | [Fact] 21 | public void Ctor_Normalizes_All_Properties() 22 | { 23 | var type = " DNS "; 24 | var value = " www.EXAMPLE.com "; 25 | 26 | var sut = new Model.Identifier(type, value); 27 | 28 | Assert.Equal("dns", sut.Type); 29 | Assert.Equal("www.example.com", sut.Value); 30 | } 31 | 32 | [Fact] 33 | public void Ctor_Sets_Wildcard() 34 | { 35 | var sut = new Model.Identifier("dns", "*.example.com"); 36 | 37 | Assert.True(sut.IsWildcard); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/AcmeError.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | 4 | namespace TGIT.ACME.Protocol.HttpModel 5 | { 6 | /// 7 | /// Represents an error object for ACME operations. 8 | /// https://tools.ietf.org/html/rfc8555#section-6.7 9 | /// 10 | public class AcmeError 11 | { 12 | public AcmeError(Model.AcmeError model) 13 | { 14 | if (model is null) 15 | throw new System.ArgumentNullException(nameof(model)); 16 | 17 | Type = model.Type; 18 | Detail = model.Detail; 19 | 20 | if (model.Identifier != null) 21 | { 22 | Identifier = new Identifier(model.Identifier); 23 | } 24 | 25 | Subproblems = model.SubErrors? 26 | .Select(x => new AcmeError(x)) 27 | .ToList(); 28 | } 29 | 30 | public AcmeError(string type, string detail) 31 | { 32 | Type = type; 33 | Detail = detail; 34 | } 35 | 36 | public string Type { get; set; } 37 | public string Detail { get; set; } 38 | 39 | public List? Subproblems { get; set; } 40 | public Identifier? Identifier { get; set; } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/Authorization.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Globalization; 3 | 4 | namespace TGIT.ACME.Protocol.HttpModel 5 | { 6 | /// 7 | /// Represents an ACME authorization object 8 | /// https://tools.ietf.org/html/rfc8555#section-7.1.4 9 | /// 10 | public class Authorization 11 | { 12 | public Authorization(Model.Authorization model, IEnumerable challenges) 13 | { 14 | if (model is null) 15 | throw new System.ArgumentNullException(nameof(model)); 16 | 17 | if (challenges is null) 18 | throw new System.ArgumentNullException(nameof(challenges)); 19 | 20 | Status = EnumMappings.GetEnumString(model.Status); 21 | 22 | Expires = model.Expires.ToString("o", CultureInfo.InvariantCulture); 23 | Wildcard = model.IsWildcard; 24 | 25 | Identifier = new Identifier(model.Identifier); 26 | Challenges = new List(challenges); 27 | } 28 | 29 | public string Status { get; } 30 | 31 | public Identifier Identifier { get; } 32 | public string? Expires { get; } 33 | public bool? Wildcard { get; } 34 | 35 | public IEnumerable Challenges { get; } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/ACME.Protocol.Model.Tests/HttpModel-Initialization/Account.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Xunit; 4 | 5 | namespace TGIT.ACME.Protocol.Model.Tests.HttpModel_Initialization 6 | { 7 | public class Account 8 | { 9 | [Fact] 10 | public void Ctor_Initializes_All_Properties() 11 | { 12 | var account = new Model.Account(new Model.Jwk(StaticTestData.JwkJson), new List { "some@example.com", "other@example.com" }, DateTimeOffset.UtcNow); 13 | var ordersUrl = "https://orders.example.org/"; 14 | 15 | var sut = new HttpModel.Account(account, ordersUrl); 16 | 17 | Assert.Equal(account.Contacts, sut.Contact); 18 | Assert.Equal(ordersUrl, sut.Orders); 19 | Assert.Equal("valid", sut.Status); 20 | Assert.True(sut.TermsOfServiceAgreed); 21 | } 22 | 23 | [Fact] 24 | public void Empty_TOSAccepted_Will_Yield_False() 25 | { 26 | var account = new Model.Account(new Model.Jwk(StaticTestData.JwkJson), new List { "some@example.com", "other@example.com" }, null); 27 | var ordersUrl = "https://orders.example.org/"; 28 | 29 | var sut = new HttpModel.Account(account, ordersUrl); 30 | 31 | Assert.False(sut.TermsOfServiceAgreed); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/Challenge.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using TGIT.ACME.Protocol.Model.Exceptions; 3 | 4 | namespace TGIT.ACME.Protocol.HttpModel 5 | { 6 | /// 7 | /// Represents an ACME challenge 8 | /// https://tools.ietf.org/html/rfc8555#section-7.1.5 9 | /// 10 | public class Challenge 11 | { 12 | public Challenge(Model.Challenge model, string challengeUrl) 13 | { 14 | if (model is null) 15 | throw new System.ArgumentNullException(nameof(model)); 16 | 17 | if (string.IsNullOrEmpty(challengeUrl)) 18 | throw new System.ArgumentNullException(nameof(challengeUrl)); 19 | 20 | Type = model.Type; 21 | Token = model.Token; 22 | 23 | Status = EnumMappings.GetEnumString(model.Status); 24 | Url = challengeUrl; 25 | 26 | Validated = model.Validated?.ToString("o", CultureInfo.InvariantCulture); 27 | Error = model.Error != null ? new AcmeError(model.Error) : null; 28 | } 29 | 30 | 31 | public string Type { get; } 32 | public string Token { get; } 33 | 34 | public string Status { get; } 35 | 36 | public string? Validated { get; } 37 | public AcmeError? Error { get; } 38 | 39 | public string Url { get; } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/ACME.Server/BackgroundServices/HostedIssuanceService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Logging; 3 | using Microsoft.Extensions.Options; 4 | using System; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using TGIT.ACME.Protocol.Workers; 8 | using TGIT.ACME.Server.Configuration; 9 | 10 | namespace TGIT.ACME.Server.BackgroundServices 11 | { 12 | public class HostedIssuanceService : TimedHostedService 13 | { 14 | private readonly IOptions _options; 15 | 16 | public HostedIssuanceService(IOptions options, 17 | IServiceProvider services, ILogger logger) 18 | : base(services, logger) 19 | { 20 | _options = options; 21 | } 22 | 23 | protected override bool EnableService => _options.Value.HostedWorkers?.EnableIssuanceService == true; 24 | protected override TimeSpan TimerInterval => TimeSpan.FromSeconds(_options.Value.HostedWorkers!.ValidationCheckInterval); 25 | 26 | protected override async Task DoWork(IServiceProvider services, CancellationToken cancellationToken) 27 | { 28 | var issuanceWorker = services.GetRequiredService(); 29 | await issuanceWorker.RunAsync(cancellationToken); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ACME.Storage.FileStore/ACME.Storage.FileStore.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | TGIT.ACME.Protocol.Storage.FileStore 6 | TGIT.ACME.Protocol.Storage.FileStore 7 | Library 8 | 9 | File based ACME Protocol Storage 10 | https://github.com/PKISharp/ACME-Server/ 11 | File based ACME Protocol Storage implementation for TGIT.ACME.Protocol 12 | MIT 13 | ACME;RFC 8555 14 | true 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /test/ACME.Protocol.Model.Tests/HttpModel-Initialization/AcmeError.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Xunit; 5 | 6 | namespace TGIT.ACME.Protocol.Model.Tests.HttpModel_Initialization 7 | { 8 | public class AcmeError 9 | { 10 | [Fact] 11 | public void Ctor_Initializes_Type_And_Detail() 12 | { 13 | var acmeError = new Model.AcmeError("type", "detail"); 14 | var sut = new HttpModel.AcmeError(acmeError); 15 | 16 | Assert.Equal(acmeError.Type, sut.Type); 17 | Assert.Equal(acmeError.Detail, sut.Detail); 18 | Assert.Null(sut.Identifier); 19 | Assert.Null(sut.Subproblems); 20 | } 21 | 22 | [Fact] 23 | public void Ctor_Intializes_All_Properties() 24 | { 25 | var acmeError = new Model.AcmeError("type", "detail", new Model.Identifier("dns", "www.example.com"), new List { new Model.AcmeError("innerType", "innerDetail") }); 26 | var sut = new HttpModel.AcmeError(acmeError); 27 | 28 | Assert.Equal(acmeError.Type, sut.Type); 29 | Assert.Equal(acmeError.Detail, sut.Detail); 30 | 31 | Assert.NotNull(sut.Identifier); 32 | 33 | Assert.NotNull(sut.Subproblems); 34 | Assert.Single(sut.Subproblems); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/ACME.Server/Filters/ValidateAcmeRequestFilter.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.AspNetCore.Http.Extensions; 3 | using Microsoft.AspNetCore.Mvc.Filters; 4 | using System.Threading.Tasks; 5 | using TGIT.ACME.Protocol.RequestServices; 6 | 7 | namespace TGIT.ACME.Server.Filters 8 | { 9 | public class ValidateAcmeRequestFilter : IAsyncActionFilter 10 | { 11 | private readonly IAcmeRequestProvider _requestProvider; 12 | private readonly IRequestValidationService _validationService; 13 | 14 | public ValidateAcmeRequestFilter(IAcmeRequestProvider requestProvider, IRequestValidationService validationService) 15 | { 16 | _requestProvider = requestProvider; 17 | _validationService = validationService; 18 | } 19 | 20 | 21 | public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) 22 | { 23 | if (HttpMethods.IsPost(context.HttpContext.Request.Method)) 24 | { 25 | var acmeRequest = _requestProvider.GetRequest(); 26 | var acmeHeader = _requestProvider.GetHeader(); 27 | await _validationService.ValidateRequestAsync(acmeRequest, acmeHeader, 28 | context.HttpContext.Request.GetDisplayUrl(), context.HttpContext.RequestAborted); 29 | } 30 | 31 | await next(); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/ACME.Server/BackgroundServices/HostedValidationService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Logging; 3 | using Microsoft.Extensions.Options; 4 | using System; 5 | using System.Collections; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using TGIT.ACME.Protocol.Storage; 9 | using TGIT.ACME.Protocol.Workers; 10 | using TGIT.ACME.Server.Configuration; 11 | 12 | namespace TGIT.ACME.Server.BackgroundServices 13 | { 14 | 15 | public class HostedValidationService : TimedHostedService 16 | { 17 | private readonly IOptions _options; 18 | 19 | public HostedValidationService(IOptions options, 20 | IServiceProvider services, ILogger logger) 21 | : base(services, logger) 22 | { 23 | _options = options; 24 | } 25 | 26 | protected override bool EnableService => _options.Value.HostedWorkers?.EnableValidationService == true; 27 | protected override TimeSpan TimerInterval => TimeSpan.FromSeconds(_options.Value.HostedWorkers!.ValidationCheckInterval); 28 | 29 | 30 | protected override async Task DoWork(IServiceProvider services, CancellationToken cancellationToken) 31 | { 32 | var validationWorker = services.GetRequiredService(); 33 | await validationWorker.RunAsync(cancellationToken); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/ACME.Server/Controllers/DirectoryController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.Extensions.Options; 3 | using TGIT.ACME.Server.Configuration; 4 | 5 | namespace TGIT.ACME.Server.Controllers 6 | { 7 | public class DirectoryController : ControllerBase 8 | { 9 | private readonly IOptions _options; 10 | 11 | public DirectoryController(IOptions options) 12 | { 13 | _options = options; 14 | } 15 | 16 | [Route("/", Name = "Directory")] 17 | public ActionResult GetDirectory() 18 | { 19 | var options = _options.Value; 20 | 21 | return new Protocol.HttpModel.Directory 22 | { 23 | NewNonce = Url.RouteUrl("NewNonce", null, "https"), 24 | NewAccount = Url.RouteUrl("NewAccount", null, "https"), 25 | NewOrder = Url.RouteUrl("NewOrder", null, "https"), 26 | NewAuthz = null, 27 | RevokeCert = null, 28 | KeyChange = Url.RouteUrl("KeyChange", null, "https"), 29 | 30 | Meta = new Protocol.HttpModel.DirectoryMetadata 31 | { 32 | ExternalAccountRequired = false, 33 | CAAIdentities = null, 34 | TermsOfService = options.TOS.RequireAgreement ? options.TOS.Url : null, 35 | Website = options.WebsiteUrl 36 | } 37 | }; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/ACME.Protocol.Model.Tests/HttpModel-Initialization/Authorization.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using Xunit; 6 | 7 | namespace TGIT.ACME.Protocol.Model.Tests.HttpModel_Initialization 8 | { 9 | public class Authorization 10 | { 11 | private (Model.Authorization authorization, List challenges) CreateTestModel() 12 | { 13 | var account = new Model.Account(new Model.Jwk(StaticTestData.JwkJson), new List { "some@example.com" }, null); 14 | var order = new Model.Order(account, new List { new Model.Identifier("dns", "*.example.com") }); 15 | var authorization = new Model.Authorization(order, order.Identifiers.First(), DateTimeOffset.UtcNow); 16 | var challenge = new Model.Challenge(authorization, "http-01"); 17 | 18 | return (authorization, new List { new HttpModel.Challenge(challenge, "https://challenge.example.com/") }); 19 | } 20 | 21 | [Fact] 22 | public void Ctor_Intializes_All_Properties() 23 | { 24 | var (authorization, challenges) = CreateTestModel(); 25 | var sut = new HttpModel.Authorization(authorization, challenges); 26 | 27 | Assert.NotNull(sut.Challenges); 28 | Assert.Single(sut.Challenges); 29 | 30 | Assert.Equal(authorization.Expires.ToString("o"), sut.Expires); 31 | Assert.Equal("*.example.com", sut.Identifier.Value); 32 | Assert.Equal("pending", sut.Status); 33 | Assert.True(sut.Wildcard); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/ACME.Storage.FileStore/NonceStore.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Options; 2 | using System; 3 | using System.Globalization; 4 | using System.IO; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using TGIT.ACME.Protocol.Model; 8 | using TGIT.ACME.Protocol.Storage; 9 | using TGIT.ACME.Storage.FileStore.Configuration; 10 | 11 | namespace TGIT.ACME.Storage.FileStore 12 | { 13 | public class NonceStore : INonceStore 14 | { 15 | private readonly IOptions _options; 16 | 17 | public NonceStore(IOptions options) 18 | { 19 | _options = options; 20 | Directory.CreateDirectory(_options.Value.NoncePath); 21 | } 22 | 23 | public async Task SaveNonceAsync(Nonce nonce, CancellationToken cancellationToken) 24 | { 25 | if (nonce is null) 26 | throw new ArgumentNullException(nameof(nonce)); 27 | 28 | var noncePath = Path.Combine(_options.Value.NoncePath, nonce.Token); 29 | await File.WriteAllTextAsync(noncePath, DateTime.Now.ToString("o", CultureInfo.InvariantCulture), cancellationToken); 30 | } 31 | 32 | public Task TryRemoveNonceAsync(Nonce nonce, CancellationToken cancellationToken) 33 | { 34 | if (nonce is null) 35 | throw new ArgumentNullException(nameof(nonce)); 36 | 37 | var noncePath = Path.Combine(_options.Value.NoncePath, nonce.Token); 38 | if (!File.Exists(noncePath)) 39 | return Task.FromResult(false); 40 | 41 | File.Delete(noncePath); 42 | return Task.FromResult(true); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/ACME.Protocol/ACME.Protocol.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | TGIT.ACME.Protocol 6 | TGIT.ACME.Protocol.Impl 7 | Library 8 | 9 | TGIT.ACME.Protocol 10 | ACME Protocol Service Implementation 11 | https://github.com/PKISharp/ACME-Server/ 12 | Default Implementation of TGIT.ACME.Protocol.Abstractions 13 | MIT 14 | ACME;RFC 8555 15 | true 16 | 17 | 18 | 19 | 20 | 21 | all 22 | runtime; build; native; contentfiles; analyzers; buildtransitive 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/ACME.Server/Filters/AcmeExceptionFilter.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.AspNetCore.Mvc.Filters; 3 | using Microsoft.Extensions.Logging; 4 | using TGIT.ACME.Protocol.Model.Exceptions; 5 | 6 | namespace TGIT.ACME.Server.Filters 7 | { 8 | public class AcmeExceptionFilter : IExceptionFilter 9 | { 10 | private readonly ILogger _logger; 11 | 12 | public AcmeExceptionFilter(ILogger logger) 13 | { 14 | _logger = logger; 15 | } 16 | 17 | public void OnException(ExceptionContext context) 18 | { 19 | if (context.Exception is AcmeException acmeException) 20 | { 21 | _logger.LogDebug($"Detected {acmeException.GetType()}. Converting to BadRequest."); 22 | #if DEBUG 23 | _logger.LogError(context.Exception, "AcmeException detected."); 24 | #endif 25 | 26 | ObjectResult result; 27 | if (acmeException is ConflictRequestException) 28 | result = new ConflictObjectResult(acmeException.GetHttpError()); 29 | else if (acmeException is NotAllowedException) 30 | result = new UnauthorizedObjectResult(acmeException.GetHttpError()); 31 | else if (acmeException is NotFoundException) 32 | result = new NotFoundObjectResult(acmeException.GetHttpError()); 33 | else 34 | result = new BadRequestObjectResult(acmeException.GetHttpError()); 35 | 36 | result.ContentTypes.Add("application/problem+json"); 37 | context.Result = result; 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/ACME.Server/Filters/AcmeLocationFilter.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.AspNetCore.Mvc.Filters; 3 | using Microsoft.AspNetCore.Mvc.Routing; 4 | using System; 5 | using System.Linq; 6 | 7 | namespace TGIT.ACME.Server.Filters 8 | { 9 | [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] 10 | public class AcmeLocationAttribute : Attribute, IFilterMetadata 11 | { 12 | public AcmeLocationAttribute(string routeName) 13 | { 14 | RouteName = routeName; 15 | } 16 | 17 | public string RouteName { get; } 18 | } 19 | 20 | public class AcmeLocationFilter : IActionFilter 21 | { 22 | private readonly IUrlHelperFactory _urlHelperFactory; 23 | 24 | public AcmeLocationFilter(IUrlHelperFactory urlHelperFactory) 25 | { 26 | _urlHelperFactory = urlHelperFactory; 27 | } 28 | 29 | public void OnActionExecuted(ActionExecutedContext context) 30 | { 31 | var locationAttribute = context.ActionDescriptor.FilterDescriptors 32 | .Select(x => x.Filter) 33 | .OfType() 34 | .FirstOrDefault(); 35 | 36 | if (locationAttribute == null) 37 | return; 38 | 39 | var urlHelper = _urlHelperFactory.GetUrlHelper(context); 40 | 41 | var locationHeaderUrl = urlHelper.RouteUrl(locationAttribute.RouteName, context.RouteData.Values, "https"); 42 | var locationHeader = $"{locationHeaderUrl}"; 43 | 44 | context.HttpContext.Response.Headers.Add("Location", locationHeader); 45 | } 46 | 47 | public void OnActionExecuting(ActionExecutingContext context) 48 | { } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/ACME.Server/Middleware/AcmeMiddleware.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Reflection.Metadata.Ecma335; 6 | using System.Text.Json; 7 | using System.Text.Json.Serialization; 8 | using System.Threading.Tasks; 9 | using TGIT.ACME.Protocol.HttpModel.Requests; 10 | using TGIT.ACME.Protocol.RequestServices; 11 | 12 | namespace TGIT.ACME.Server.Middleware 13 | { 14 | public class AcmeMiddleware 15 | { 16 | private readonly RequestDelegate _next; 17 | private readonly AcmeRequestReader _requestReader; 18 | 19 | public AcmeMiddleware(RequestDelegate next, AcmeRequestReader requestReader) 20 | { 21 | _next = next; 22 | _requestReader = requestReader; 23 | } 24 | 25 | public async Task InvokeAsync(HttpContext context, IAcmeRequestProvider requestProvider) 26 | { 27 | if (context is null) 28 | throw new ArgumentNullException(nameof(context)); 29 | 30 | if (requestProvider is null) 31 | throw new ArgumentNullException(nameof(requestProvider)); 32 | 33 | if(HttpMethods.IsPost(context.Request.Method)) 34 | { 35 | var result = await _requestReader.ReadAcmeRequest(context.Request); 36 | requestProvider.Initialize(result); 37 | } 38 | 39 | await _next(context); 40 | } 41 | } 42 | 43 | public class AcmeRequestReader 44 | { 45 | public async Task ReadAcmeRequest(HttpRequest request) 46 | { 47 | var result = await JsonSerializer.DeserializeAsync(request.Body); 48 | return result; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/ACME.Protocol/Workers/IssuanceWorker.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using TGIT.ACME.Protocol.IssuanceServices; 4 | using TGIT.ACME.Protocol.Model; 5 | using TGIT.ACME.Protocol.Storage; 6 | 7 | namespace TGIT.ACME.Protocol.Workers 8 | { 9 | public class IssuanceWorker : IIssuanceWorker 10 | { 11 | private readonly IOrderStore _orderStore; 12 | private readonly ICertificateIssuer _issuer; 13 | 14 | public IssuanceWorker(IOrderStore orderStore, ICertificateIssuer issuer) 15 | { 16 | _orderStore = orderStore; 17 | _issuer = issuer; 18 | } 19 | 20 | public async Task RunAsync(CancellationToken cancellationToken) 21 | { 22 | var orders = await _orderStore.GetFinalizableOrders(cancellationToken); 23 | 24 | var tasks = new Task[orders.Count]; 25 | for (int i = 0; i < orders.Count; ++i) 26 | tasks[i] = IssueCertificate(orders[i], cancellationToken); 27 | 28 | Task.WaitAll(tasks, cancellationToken); 29 | } 30 | 31 | private async Task IssueCertificate(Order order, CancellationToken cancellationToken) 32 | { 33 | var (certificate, error) = await _issuer.IssueCertificate(order.CertificateSigningRequest!, cancellationToken); 34 | 35 | if (certificate == null) 36 | { 37 | order.SetStatus(OrderStatus.Invalid); 38 | order.Error = error; 39 | } 40 | else if (certificate != null) 41 | { 42 | order.Certificate = certificate; 43 | order.SetStatus(OrderStatus.Valid); 44 | } 45 | 46 | await _orderStore.SaveOrderAsync(order, cancellationToken); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/ACME.Server/Filters/AddNextNonceFilter.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.AspNetCore.Mvc.Filters; 4 | using Microsoft.Extensions.Logging; 5 | using System.Threading.Tasks; 6 | using TGIT.ACME.Protocol.Services; 7 | 8 | namespace TGIT.ACME.Server.Filters 9 | { 10 | public class AddNextNonceAttribute : ServiceFilterAttribute 11 | { 12 | public AddNextNonceAttribute() 13 | : base(typeof(AddNextNonceFilter)) 14 | { } 15 | } 16 | 17 | public class AddNextNonceFilter : IAsyncActionFilter, IAsyncExceptionFilter 18 | { 19 | private readonly INonceService _nonceService; 20 | private readonly ILogger _logger; 21 | 22 | public AddNextNonceFilter(INonceService nonceService, ILogger logger) 23 | { 24 | _nonceService = nonceService; 25 | _logger = logger; 26 | } 27 | 28 | public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) 29 | { 30 | await next.Invoke(); 31 | await AddNonceHeader(context.HttpContext); 32 | } 33 | 34 | public async Task OnExceptionAsync(ExceptionContext context) 35 | { 36 | await AddNonceHeader(context.HttpContext); 37 | } 38 | 39 | private async Task AddNonceHeader(HttpContext httpContext) 40 | { 41 | if (httpContext.Response.Headers.ContainsKey("Replay-Nonce")) 42 | return; 43 | 44 | var newNonce = await _nonceService.CreateNonceAsync(httpContext.RequestAborted); 45 | httpContext.Response.Headers.Add("Replay-Nonce", newNonce.Token); 46 | 47 | _logger.LogInformation($"Added Replay-Nonce: {newNonce.Token}"); 48 | } 49 | 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/Extensions/SerializationInfoExtension.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Runtime.Serialization; 4 | 5 | namespace TGIT.ACME.Protocol.Model.Extensions 6 | { 7 | public static class SerializationInfoExtension 8 | { 9 | public static string GetRequiredString(this SerializationInfo info, string name) 10 | { 11 | if (info is null) 12 | throw new ArgumentNullException(nameof(info)); 13 | 14 | var value = info.GetString(name); 15 | if (string.IsNullOrWhiteSpace(value)) 16 | throw new InvalidOperationException($"Could not deserialize required value '{name}'"); 17 | 18 | return value; 19 | } 20 | 21 | [return: NotNull] 22 | public static T GetRequiredValue(this SerializationInfo info, string name) 23 | { 24 | if (info is null) 25 | throw new ArgumentNullException(nameof(info)); 26 | 27 | var value = info.GetValue(name, typeof(T)); 28 | if(value is null) 29 | throw new InvalidOperationException($"Could not deserialize required value '{name}'"); 30 | 31 | return (T)value; 32 | } 33 | 34 | [return: MaybeNull] 35 | public static T GetValue(this SerializationInfo info, string name) 36 | { 37 | if (info is null) 38 | throw new ArgumentNullException(nameof(info)); 39 | 40 | return (T)info.GetValue(name, typeof(T)); 41 | } 42 | 43 | [return: MaybeNull] 44 | public static T TryGetValue(this SerializationInfo info, string name) 45 | { 46 | if (info is null) 47 | throw new ArgumentNullException(nameof(info)); 48 | 49 | try 50 | { 51 | return (T)info.GetValue(name, typeof(T)); 52 | } 53 | catch (SerializationException) 54 | { 55 | return default; 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/ACME.Protocol/Services/Http01ChallangeValidator.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.IdentityModel.Tokens; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Net.Http; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using TGIT.ACME.Protocol.Model; 8 | 9 | namespace TGIT.ACME.Protocol.Services 10 | { 11 | public sealed class Http01ChallangeValidator : TokenChallengeValidator 12 | { 13 | private readonly HttpClient _httpClient; 14 | 15 | public Http01ChallangeValidator(HttpClient httpClient) 16 | { 17 | _httpClient = httpClient; 18 | } 19 | 20 | protected override string GetExpectedContent(Challenge challenge, Account account) 21 | { 22 | var thumbprintBytes = account.Jwk.SecurityKey.ComputeJwkThumbprint(); 23 | var thumbprint = Base64UrlEncoder.Encode(thumbprintBytes); 24 | 25 | var expectedContent = $"{challenge.Token}.{thumbprint}"; 26 | return expectedContent; 27 | } 28 | 29 | protected override async Task<(List? Contents, AcmeError? Error)> LoadChallengeResponseAsync(Challenge challenge, CancellationToken cancellationToken) 30 | { 31 | var challengeUrl = $"http://{challenge.Authorization.Identifier.Value}/.well-known/acme-challenge/{challenge.Token}"; 32 | 33 | try 34 | { 35 | var response = await _httpClient.GetAsync(new Uri(challengeUrl), cancellationToken); 36 | if (response.StatusCode != System.Net.HttpStatusCode.OK) 37 | { 38 | var error = new AcmeError("incorrectResponse", $"Got non 200 status code: {response.StatusCode}", challenge.Authorization.Identifier); 39 | return (null, error); 40 | } 41 | 42 | var content = await response.Content.ReadAsStringAsync(); 43 | return (new List { content }, null); 44 | } 45 | catch (HttpRequestException ex) 46 | { 47 | var error = new AcmeError("connection", ex.Message, challenge.Authorization.Identifier); 48 | return (null, error); 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/EnumMappings.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using TGIT.ACME.Protocol.Model; 3 | 4 | namespace TGIT.ACME.Protocol.HttpModel 5 | { 6 | /// 7 | /// Defines response texts for enum fields. 8 | /// 9 | public static class EnumMappings 10 | { 11 | public static string GetEnumString(AccountStatus status) 12 | => status switch 13 | { 14 | AccountStatus.Valid => "valid", 15 | AccountStatus.Deactivated => "deactivated", 16 | AccountStatus.Revoked => "revoked", 17 | _ => throw new InvalidOperationException("Unknown AccountStatus") 18 | }; 19 | 20 | internal static string GetEnumString(AuthorizationStatus status) 21 | => status switch 22 | { 23 | AuthorizationStatus.Pending => "pending", 24 | AuthorizationStatus.Valid => "valid", 25 | AuthorizationStatus.Invalid => "invalid", 26 | AuthorizationStatus.Revoked => "revoked", 27 | AuthorizationStatus.Deactivated => "deactivated", 28 | AuthorizationStatus.Expired => "expired", 29 | _ => throw new InvalidOperationException("Unknown AuthorizationStatus") 30 | }; 31 | 32 | internal static string GetEnumString(ChallengeStatus status) 33 | => status switch 34 | { 35 | ChallengeStatus.Pending => "pending", 36 | ChallengeStatus.Processing => "processing", 37 | ChallengeStatus.Valid => "valid", 38 | ChallengeStatus.Invalid => "invalid", 39 | _ => throw new InvalidOperationException("Unknown ChallengeStatus") 40 | }; 41 | 42 | internal static string GetEnumString(OrderStatus status) 43 | => status switch 44 | { 45 | OrderStatus.Pending => "pending", 46 | OrderStatus.Ready => "ready", 47 | OrderStatus.Processing => "processing", 48 | OrderStatus.Valid => "valid", 49 | OrderStatus.Invalid => "invalid", 50 | _ => throw new InvalidOperationException("Unknown OrderStatus") 51 | }; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/ACME.Storage.FileStore/AccountStore.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Options; 2 | using Newtonsoft.Json; 3 | using System; 4 | using System.IO; 5 | using System.Text; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using TGIT.ACME.Protocol.Model; 9 | using TGIT.ACME.Protocol.Model.Exceptions; 10 | using TGIT.ACME.Protocol.Storage; 11 | using TGIT.ACME.Storage.FileStore.Configuration; 12 | 13 | namespace TGIT.ACME.Storage.FileStore 14 | { 15 | public class AccountStore : StoreBase, IAccountStore 16 | { 17 | public AccountStore(IOptions options) 18 | : base(options) 19 | { 20 | Directory.CreateDirectory(Options.Value.AccountPath); 21 | } 22 | 23 | private string GetPath(string accountId) 24 | => Path.Combine(Options.Value.AccountPath, accountId, "account.json"); 25 | 26 | public async Task LoadAccountAsync(string accountId, CancellationToken cancellationToken) 27 | { 28 | if (string.IsNullOrWhiteSpace(accountId) || !IdentifierRegex.IsMatch(accountId)) 29 | throw new MalformedRequestException("AccountId does not match expected format."); 30 | 31 | var accountPath = GetPath(accountId); 32 | 33 | var account = await LoadFromPath(accountPath, cancellationToken); 34 | return account; 35 | } 36 | 37 | public async Task SaveAccountAsync(Account setAccount, CancellationToken cancellationToken) 38 | { 39 | cancellationToken.ThrowIfCancellationRequested(); 40 | 41 | if (setAccount is null) 42 | throw new ArgumentNullException(nameof(setAccount)); 43 | 44 | var accountPath = GetPath(setAccount.AccountId); 45 | Directory.CreateDirectory(Path.GetDirectoryName(accountPath)); 46 | 47 | using (var fileStream = File.Open(accountPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read)) 48 | { 49 | var existingAccount = await LoadFromStream(fileStream, cancellationToken); 50 | HandleVersioning(existingAccount, setAccount); 51 | 52 | await ReplaceFileStreamContent(fileStream, setAccount, cancellationToken); 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /test/ACME.Protocol.Model.Tests/HttpModel-Initialization/Challenge.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using Xunit; 6 | 7 | namespace TGIT.ACME.Protocol.Model.Tests.HttpModel_Initialization 8 | { 9 | public class Challenge 10 | { 11 | private (Model.Challenge challenge, string challengeUrl) CreateTestModel() 12 | { 13 | var account = new Model.Account(new Model.Jwk(StaticTestData.JwkJson), new List { "some@example.com" }, null); 14 | var order = new Model.Order(account, new List { new Model.Identifier("dns", "www.example.com") }); 15 | var authorization = new Model.Authorization(order, order.Identifiers.First(), DateTimeOffset.UtcNow); 16 | var challenge = new Model.Challenge(authorization, "http-01"); 17 | 18 | return (challenge, "https://challenge.example.com"); 19 | } 20 | 21 | [Fact] 22 | public void Ctor_Intializes_All_Properties() 23 | { 24 | var (challenge, challengeUrl) = CreateTestModel(); 25 | var sut = new HttpModel.Challenge(challenge, challengeUrl); 26 | 27 | Assert.Equal(challenge.Status.ToString().ToLowerInvariant(), sut.Status); 28 | Assert.Equal(challenge.Token, sut.Token); 29 | Assert.Equal(challenge.Type, sut.Type); 30 | Assert.Equal(challengeUrl, sut.Url); 31 | 32 | Assert.Null(sut.Error); 33 | Assert.Null(sut.Validated); 34 | } 35 | 36 | [Fact] 37 | public void Ctor_Initializes_Validated() 38 | { 39 | var (challenge, challengeUrl) = CreateTestModel(); 40 | challenge.Validated = DateTimeOffset.UtcNow; 41 | 42 | var sut = new HttpModel.Challenge(challenge, challengeUrl); 43 | 44 | Assert.Equal(challenge.Validated.Value.ToString("o"), sut.Validated); 45 | } 46 | 47 | [Fact] 48 | public void Ctor_Initializes_Error() 49 | { 50 | var (challenge, challengeUrl) = CreateTestModel(); 51 | challenge.Error = new Model.AcmeError("type", "detail"); 52 | 53 | var sut = new HttpModel.Challenge(challenge, challengeUrl); 54 | 55 | Assert.NotNull(sut.Error); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/Identifier.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Linq; 5 | using System.Runtime.Serialization; 6 | using TGIT.ACME.Protocol.Model.Exceptions; 7 | using TGIT.ACME.Protocol.Model.Extensions; 8 | 9 | namespace TGIT.ACME.Protocol.Model 10 | { 11 | [Serializable] 12 | public class Identifier : ISerializable 13 | { 14 | private static readonly string[] _supportedTypes = new[] { "dns" }; 15 | 16 | private string? _type; 17 | private string? _value; 18 | 19 | public Identifier(string type, string value) 20 | { 21 | Type = type; 22 | Value = value; 23 | } 24 | 25 | public string Type 26 | { 27 | get => _type ?? throw new NotInitializedException(); 28 | set 29 | { 30 | var normalizedType = value?.Trim().ToLowerInvariant(); 31 | if (!_supportedTypes.Contains(normalizedType)) 32 | throw new MalformedRequestException($"Unsupported identifier type: {normalizedType}"); 33 | 34 | _type = normalizedType; 35 | } 36 | } 37 | 38 | public string Value 39 | { 40 | get => _value ?? throw new NotInitializedException(); 41 | set => _value = value?.Trim().ToLowerInvariant(); 42 | } 43 | 44 | public bool IsWildcard 45 | => Value.StartsWith("*", StringComparison.InvariantCulture); 46 | 47 | 48 | 49 | 50 | // --- Serialization Methods --- // 51 | 52 | protected Identifier(SerializationInfo info, StreamingContext streamingContext) 53 | { 54 | if (info is null) 55 | throw new ArgumentNullException(nameof(info)); 56 | 57 | Type = info.GetRequiredString(nameof(Type)); 58 | Value = info.GetRequiredString(nameof(Value)); 59 | } 60 | 61 | public void GetObjectData(SerializationInfo info, StreamingContext context) 62 | { 63 | if (info is null) 64 | throw new ArgumentNullException(nameof(info)); 65 | 66 | info.AddValue("SerializationVersion", 1); 67 | 68 | info.AddValue(nameof(Type), Type); 69 | info.AddValue(nameof(Value), Value); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/ACME.Protocol/Services/TokenChallengeValidator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using TGIT.ACME.Protocol.Model; 6 | 7 | namespace TGIT.ACME.Protocol.Services 8 | { 9 | public abstract class TokenChallengeValidator : IChallengeValidator 10 | { 11 | protected abstract Task<(List? Contents, AcmeError? Error)> LoadChallengeResponseAsync(Challenge challenge, CancellationToken cancellationToken); 12 | protected abstract string GetExpectedContent(Challenge challenge, Account account); 13 | 14 | public async Task<(bool IsValid, AcmeError? error)> ValidateChallengeAsync(Challenge challenge, Account account, CancellationToken cancellationToken) 15 | { 16 | if (challenge is null) 17 | throw new ArgumentNullException(nameof(challenge)); 18 | if (account is null) 19 | throw new ArgumentNullException(nameof(account)); 20 | 21 | if (account.Status != AccountStatus.Valid) 22 | return (false, new AcmeError("unauthorized", "Account invalid", challenge.Authorization.Identifier)); 23 | 24 | if (challenge.Authorization.Expires < DateTimeOffset.UtcNow) 25 | { 26 | challenge.Authorization.SetStatus(AuthorizationStatus.Expired); 27 | return (false, new AcmeError("custom:authExpired", "Authorization expired", challenge.Authorization.Identifier)); 28 | } 29 | if (challenge.Authorization.Order.Expires < DateTimeOffset.UtcNow) 30 | { 31 | challenge.Authorization.Order.SetStatus(OrderStatus.Invalid); 32 | return (false, new AcmeError("custom:orderExpired", "Order expired")); 33 | } 34 | 35 | var (challengeContent, error) = await LoadChallengeResponseAsync(challenge, cancellationToken); 36 | if (error != null) 37 | return (false, error); 38 | 39 | var expectedResponse = GetExpectedContent(challenge, account); 40 | if(challengeContent?.Contains(expectedResponse) != true) 41 | return (false, new AcmeError("incorrectResponse", "Challenge response dod not contain the expected content.", challenge.Authorization.Identifier)); 42 | 43 | return (true, null); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ACME-Server 2 | 3 | This repository provides base libraries to implement an ACME-compliant (RFC 8555) server. 4 | It consists of 4 base nuget packages and one storage implementation. 5 | This is not a runnable product and it needs an implementation for certificate issuance (separately available). 6 | 7 | ## Known implementations 8 | 9 | If you are looking for a prebuild server, this list provides some runnable products for your needs: 10 | 11 | ### ACME-ACDS 12 | Made for Entities running Active Directory Certificate Services (ACDS), wanting to issue certificates via ACME. 13 | *License*: free for non-profit, proprietary for commercial use. 14 | https://github.com/glatzert/ACME-Server-ACDS 15 | 16 | ## Want to build your own? 17 | 18 | The libraries are distributed via NuGet.org. 19 | 20 | ### TGIT.ACME.Server.Core 21 | 22 | https://www.nuget.org/packages/TGIT.ACME.Server.Core 23 | Contains nearly everything neccessary to run an acme server on asp.net core. 24 | Reference this, if you want to provide your own deployable server, but do not want to implement API endpoints. 25 | 26 | ### TGIT.ACME.Protocol.Storage.FileStore 27 | 28 | https://www.nuget.org/packages/TGIT.ACME.Protocol.Storage.FileStore 29 | Contains a storage provider based on files. 30 | Reference this, if you want to provide your own deployable server and do not want to implement the storage layer. 31 | 32 | 33 | ### TGIT.ACME.Model 34 | 35 | https://www.nuget.org/packages/TGIT.ACME.Protocol.Model 36 | Contains model classes for internal use of the implementations as well as http-model classes for use with ACME servers. 37 | 38 | ### TGIT.ACME.Protocol.Abstractions 39 | 40 | https://www.nuget.org/packages/TGIT.ACME.Protocol.Abstractions 41 | Contains interfaces uses by the core server and protocol implementations. 42 | Reference this, if you want to create own storage or issuance providers. 43 | 44 | ### TGIT.ACME.Protocol 45 | 46 | https://www.nuget.org/packages/TGIT.ACME.Protocol 47 | Contains a default implementation for all services (besides storage and issuance) defined in Protocol.Abstractions. 48 | Reference this, if you want to create an own implementation of the http layer, but do not want to create the service implementations. 49 | 50 | ### Do I need something else? 51 | 52 | Yes. To make it a fully runnable server, you need to Implement ICertificateIssuer and register it in your source code. -------------------------------------------------------------------------------- /src/ACME.Protocol/Services/Dns01ChallangeValidator.cs: -------------------------------------------------------------------------------- 1 | using DnsClient; 2 | using DnsClient.Internal; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.IdentityModel.Tokens; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using System.Security.Cryptography; 9 | using System.Text; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | using TGIT.ACME.Protocol.Model; 13 | 14 | namespace TGIT.ACME.Protocol.Services 15 | { 16 | public sealed class Dns01ChallangeValidator : TokenChallengeValidator 17 | { 18 | private readonly ILogger _logger; 19 | 20 | public Dns01ChallangeValidator(ILogger logger) 21 | { 22 | _logger = logger; 23 | } 24 | 25 | protected override string GetExpectedContent(Challenge challenge, Account account) 26 | { 27 | using var sha256 = SHA256.Create(); 28 | 29 | var thumbprintBytes = account.Jwk.SecurityKey.ComputeJwkThumbprint(); 30 | var thumbprint = Base64UrlEncoder.Encode(thumbprintBytes); 31 | 32 | var keyAuthBytes = Encoding.UTF8.GetBytes($"{challenge.Token}.{thumbprint}"); 33 | var digestBytes = sha256.ComputeHash(keyAuthBytes); 34 | 35 | var digest = Base64UrlEncoder.Encode(digestBytes); 36 | return digest; 37 | } 38 | 39 | protected override async Task<(List? Contents, AcmeError? Error)> LoadChallengeResponseAsync(Challenge challenge, CancellationToken cancellationToken) 40 | { 41 | try 42 | { 43 | var dnsClient = new LookupClient(); 44 | var dnsBaseUrl = challenge.Authorization.Identifier.Value.Replace("*.", "", StringComparison.OrdinalIgnoreCase); 45 | var dnsRecordName = $"_acme-challenge.{dnsBaseUrl}"; 46 | 47 | var dnsResponse = await dnsClient.QueryAsync(dnsRecordName, QueryType.TXT, cancellationToken: cancellationToken); 48 | var contents = new List(dnsResponse.Answers.TxtRecords().SelectMany(x => x.Text)); 49 | 50 | return (contents, null); 51 | } 52 | catch (DnsResponseException) 53 | { 54 | return (null, new AcmeError("dns", "Could not read from DNS")); 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/HttpModel/Order.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Globalization; 3 | using System.Linq; 4 | 5 | namespace TGIT.ACME.Protocol.HttpModel 6 | { 7 | /// 8 | /// Represents an ACME order 9 | /// https://tools.ietf.org/html/rfc8555#section-7.1.3 10 | /// 11 | public class Order 12 | { 13 | public Order(Model.Order model, 14 | IEnumerable authorizationUrls, string finalizeUrl, string certificateUrl) 15 | { 16 | if (model is null) 17 | throw new System.ArgumentNullException(nameof(model)); 18 | 19 | if (authorizationUrls is null) 20 | throw new System.ArgumentNullException(nameof(authorizationUrls)); 21 | 22 | if (string.IsNullOrEmpty(finalizeUrl)) 23 | throw new System.ArgumentNullException(nameof(finalizeUrl)); 24 | 25 | if (string.IsNullOrEmpty(certificateUrl)) 26 | throw new System.ArgumentNullException(nameof(certificateUrl)); 27 | 28 | Status = EnumMappings.GetEnumString(model.Status); 29 | 30 | Expires = model.Expires?.ToString("o", CultureInfo.InvariantCulture); 31 | NotBefore = model.NotBefore?.ToString("o", CultureInfo.InvariantCulture); 32 | NotAfter = model.NotAfter?.ToString("o", CultureInfo.InvariantCulture); 33 | 34 | Identifiers = model.Identifiers.Select(x => new Identifier(x)).ToList(); 35 | 36 | Authorizations = new List(authorizationUrls); 37 | 38 | if(model.Status == Model.OrderStatus.Ready) 39 | Finalize = finalizeUrl; 40 | 41 | if(model.Status == Model.OrderStatus.Valid) 42 | Certificate = certificateUrl; 43 | 44 | if(model.Error != null) 45 | Error = new AcmeError(model.Error); 46 | } 47 | 48 | public string Status { get; } 49 | 50 | public List Identifiers { get; } 51 | 52 | public string? Expires { get; } 53 | public string? NotBefore { get; } 54 | public string? NotAfter { get; } 55 | 56 | public AcmeError? Error { get; } 57 | 58 | public List Authorizations { get; } 59 | 60 | public string? Finalize { get; } 61 | public string? Certificate { get; } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/Account.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Runtime.Serialization; 5 | using TGIT.ACME.Protocol.Model.Extensions; 6 | 7 | namespace TGIT.ACME.Protocol.Model 8 | { 9 | [Serializable] 10 | public class Account : IVersioned, ISerializable 11 | { 12 | public Account(Jwk jwk, IEnumerable? contacts, DateTimeOffset? tosAccepted) 13 | { 14 | AccountId = GuidString.NewValue(); 15 | 16 | Jwk = jwk; 17 | Contacts = contacts?.ToList(); 18 | TOSAccepted = tosAccepted; 19 | } 20 | 21 | public string AccountId { get; } 22 | public AccountStatus Status { get; private set; } 23 | 24 | public Jwk Jwk { get; } 25 | 26 | public List? Contacts { get; private set; } 27 | public DateTimeOffset? TOSAccepted { get; private set; } 28 | 29 | /// 30 | /// Concurrency Token 31 | /// 32 | public long Version { get; set; } 33 | 34 | 35 | 36 | // --- Serialization Methods --- // 37 | 38 | protected Account(SerializationInfo info, StreamingContext streamingContext) 39 | { 40 | if (info is null) 41 | throw new ArgumentNullException(nameof(info)); 42 | 43 | AccountId = info.GetRequiredString(nameof(AccountId)); 44 | Status = (AccountStatus)info.GetInt32(nameof(Status)); 45 | Jwk = info.GetRequiredValue(nameof(Jwk)); 46 | 47 | Contacts = info.GetValue>(nameof(Contacts)); 48 | TOSAccepted = info.TryGetValue(nameof(TOSAccepted)); 49 | 50 | Version = info.GetInt64(nameof(Version)); 51 | } 52 | 53 | public void GetObjectData(SerializationInfo info, StreamingContext context) 54 | { 55 | if (info is null) 56 | throw new ArgumentNullException(nameof(info)); 57 | 58 | info.AddValue("SerializationVersion", 1); 59 | 60 | info.AddValue(nameof(AccountId), AccountId); 61 | info.AddValue(nameof(Status), Status); 62 | info.AddValue(nameof(Jwk), Jwk); 63 | 64 | info.AddValue(nameof(Contacts), Contacts); 65 | info.AddValue(nameof(TOSAccepted), TOSAccepted); 66 | 67 | info.AddValue(nameof(Version), Version); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/AcmeError.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Runtime.Serialization; 5 | using TGIT.ACME.Protocol.Model.Exceptions; 6 | using TGIT.ACME.Protocol.Model.Extensions; 7 | 8 | namespace TGIT.ACME.Protocol.Model 9 | { 10 | [Serializable] 11 | public class AcmeError : ISerializable 12 | { 13 | private string? _type; 14 | private string? _detail; 15 | 16 | private AcmeError() { } 17 | 18 | public AcmeError(string type, string detail, Identifier? identifier = null, IEnumerable? subErrors = null) 19 | { 20 | Type = type; 21 | 22 | if (!type.Contains(":")) 23 | Type = "urn:ietf:params:acme:error:" + type; 24 | 25 | Detail = detail; 26 | Identifier = identifier; 27 | SubErrors = subErrors?.ToList(); 28 | } 29 | 30 | public string Type { 31 | get => _type ?? throw new NotInitializedException(); 32 | private set => _type = value; 33 | } 34 | 35 | public string Detail { 36 | get => _detail ?? throw new NotInitializedException(); 37 | set => _detail = value; 38 | } 39 | 40 | public Identifier? Identifier { get; } 41 | 42 | public List? SubErrors { get; } 43 | 44 | 45 | 46 | // --- Serialization Methods --- // 47 | 48 | protected AcmeError(SerializationInfo info, StreamingContext streamingContext) 49 | { 50 | if (info is null) 51 | throw new ArgumentNullException(nameof(info)); 52 | 53 | Type = info.GetRequiredString(nameof(Type)); 54 | Detail = info.GetRequiredString(nameof(Detail)); 55 | 56 | Identifier = info.TryGetValue(nameof(Identifier)); 57 | SubErrors = info.TryGetValue>(nameof(SubErrors)); 58 | } 59 | 60 | public void GetObjectData(SerializationInfo info, StreamingContext context) 61 | { 62 | if (info is null) 63 | throw new ArgumentNullException(nameof(info)); 64 | 65 | info.AddValue("SerializationVersion", 1); 66 | 67 | info.AddValue(nameof(Type), Type); 68 | info.AddValue(nameof(Detail), Detail); 69 | 70 | if(Identifier != null) 71 | info.AddValue(nameof(Identifier), Identifier); 72 | 73 | if(SubErrors != null) 74 | info.AddValue(nameof(SubErrors), SubErrors); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/ACME.Protocol/Services/DefaultAccountService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using TGIT.ACME.Protocol.Model; 6 | using TGIT.ACME.Protocol.Model.Exceptions; 7 | using TGIT.ACME.Protocol.RequestServices; 8 | using TGIT.ACME.Protocol.Storage; 9 | 10 | namespace TGIT.ACME.Protocol.Services 11 | { 12 | public class DefaultAccountService : IAccountService 13 | { 14 | private readonly IAcmeRequestProvider _requestProvider; 15 | private readonly IAccountStore _accountStore; 16 | 17 | public DefaultAccountService(IAcmeRequestProvider requestProvider, IAccountStore accountStore) 18 | { 19 | _requestProvider = requestProvider; 20 | _accountStore = accountStore; 21 | } 22 | 23 | public async Task CreateAccountAsync(Jwk jwk, List? contacts, 24 | bool termsOfServiceAgreed, CancellationToken cancellationToken) 25 | { 26 | var newAccount = new Account(jwk, contacts, termsOfServiceAgreed ? DateTimeOffset.UtcNow : (DateTimeOffset?)null); 27 | 28 | await _accountStore.SaveAccountAsync(newAccount, cancellationToken); 29 | return newAccount; 30 | } 31 | 32 | public Task FindAccountAsync(Jwk jwk, CancellationToken cancellationToken) 33 | { 34 | throw new NotImplementedException(); 35 | } 36 | 37 | public async Task FromRequestAsync(CancellationToken cancellationToken) 38 | { 39 | var requestHeader = _requestProvider.GetHeader(); 40 | 41 | if (string.IsNullOrEmpty(requestHeader.Kid)) 42 | throw new MalformedRequestException("Kid header is missing"); 43 | 44 | //TODO: Get accountId from Kid? 45 | var accountId = requestHeader.GetAccountId(); 46 | var account = await LoadAcountAsync(accountId, cancellationToken); 47 | ValidateAccount(account); 48 | 49 | return account!; 50 | } 51 | 52 | public async Task LoadAcountAsync(string accountId, CancellationToken cancellationToken) 53 | { 54 | return await _accountStore.LoadAccountAsync(accountId, cancellationToken); 55 | } 56 | 57 | private static void ValidateAccount(Account? account) 58 | { 59 | if (account == null) 60 | throw new NotFoundException(); 61 | 62 | if (account.Status != AccountStatus.Valid) 63 | throw new ConflictRequestException(AccountStatus.Valid, account.Status); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/ACME.Server/Extensions/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.Extensions.Configuration; 3 | using TGIT.ACME.Protocol.Services; 4 | using TGIT.ACME.Protocol.RequestServices; 5 | using TGIT.ACME.Protocol.Workers; 6 | using TGIT.ACME.Server.BackgroundServices; 7 | using TGIT.ACME.Server.Configuration; 8 | using TGIT.ACME.Server.Filters; 9 | using TGIT.ACME.Server.Middleware; 10 | using TGIT.ACME.Server.ModelBinding; 11 | 12 | namespace Microsoft.Extensions.DependencyInjection 13 | { 14 | public static class ServiceCollectionExtensions 15 | { 16 | public static IServiceCollection AddACMEServer(this IServiceCollection services, IConfiguration configuration, 17 | string sectionName = "AcmeServer") 18 | { 19 | services.AddControllers(); 20 | 21 | services.AddTransient(); 22 | 23 | services.AddScoped(); 24 | 25 | services.AddScoped(); 26 | services.AddScoped(); 27 | services.AddScoped(); 28 | services.AddScoped(); 29 | 30 | services.AddScoped(); 31 | 32 | services.AddScoped(); 33 | services.AddScoped(); 34 | 35 | services.AddHttpClient(); 36 | services.AddScoped(); 37 | services.AddScoped(); 38 | 39 | services.AddScoped(); 40 | 41 | services.AddHostedService(); 42 | services.AddHostedService(); 43 | 44 | services.Configure(opt => 45 | { 46 | opt.Filters.Add(typeof(AcmeExceptionFilter)); 47 | opt.Filters.Add(typeof(ValidateAcmeRequestFilter)); 48 | opt.Filters.Add(typeof(AcmeIndexLinkFilter)); 49 | 50 | opt.ModelBinderProviders.Insert(0, new AcmeModelBindingProvider()); 51 | }); 52 | 53 | var acmeServerConfig = configuration.GetSection(sectionName); 54 | var acmeServerOptions = new ACMEServerOptions(); 55 | acmeServerConfig.Bind(acmeServerOptions); 56 | 57 | services.Configure(acmeServerConfig); 58 | 59 | return services; 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ACME.Storage.FileStore/StoreBase.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Options; 2 | using Newtonsoft.Json; 3 | using System; 4 | using System.IO; 5 | using System.Text; 6 | using System.Text.RegularExpressions; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using TGIT.ACME.Protocol.Model; 10 | using TGIT.ACME.Protocol.Model.Exceptions; 11 | using TGIT.ACME.Storage.FileStore.Configuration; 12 | 13 | namespace TGIT.ACME.Storage.FileStore 14 | { 15 | public class StoreBase 16 | { 17 | protected IOptions Options { get; } 18 | protected Regex IdentifierRegex { get; } 19 | 20 | public StoreBase(IOptions options) 21 | { 22 | Options = options; 23 | IdentifierRegex = new Regex("[\\w\\d_-]+", RegexOptions.Compiled); 24 | } 25 | 26 | protected static async Task LoadFromPath(string filePath, CancellationToken cancellationToken) 27 | where T : class 28 | { 29 | if (!File.Exists(filePath)) 30 | return null; 31 | 32 | using (var fileStream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.Read)) 33 | { 34 | return await LoadFromStream(fileStream, cancellationToken); 35 | } 36 | } 37 | 38 | protected static async Task LoadFromStream(FileStream fileStream, CancellationToken cancellationToken) 39 | where T : class 40 | { 41 | if (fileStream.Length == 0) 42 | return null; 43 | 44 | fileStream.Seek(0, SeekOrigin.Begin); 45 | 46 | var utf8Bytes = new byte[fileStream.Length]; 47 | await fileStream.ReadAsync(utf8Bytes, cancellationToken); 48 | var result = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(utf8Bytes), JsonDefaults.Settings); 49 | 50 | return result; 51 | } 52 | 53 | protected static async Task ReplaceFileStreamContent(FileStream fileStream, T content, CancellationToken cancellationToken) 54 | { 55 | if (fileStream.Length > 0) 56 | fileStream.SetLength(0); 57 | 58 | var utf8Bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(content, JsonDefaults.Settings)); 59 | await fileStream.WriteAsync(utf8Bytes, cancellationToken); 60 | } 61 | 62 | protected static void HandleVersioning(IVersioned? existingContent, IVersioned newContent) 63 | { 64 | if (existingContent != null && existingContent.Version != newContent.Version) 65 | throw new ConcurrencyException(); 66 | 67 | newContent.Version = DateTime.UtcNow.Ticks; 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/ACME.Server/Controllers/AccountController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using System; 3 | using System.Threading.Tasks; 4 | using TGIT.ACME.Protocol.HttpModel.Requests; 5 | using TGIT.ACME.Protocol.Model.Exceptions; 6 | using TGIT.ACME.Protocol.Services; 7 | using TGIT.ACME.Server.Filters; 8 | 9 | namespace TGIT.ACME.Server.Controllers 10 | { 11 | [AddNextNonce] 12 | public class AccountController : ControllerBase 13 | { 14 | private readonly IAccountService _accountService; 15 | 16 | public AccountController(IAccountService accountService) 17 | { 18 | _accountService = accountService; 19 | } 20 | 21 | [Route("/new-account", Name = "NewAccount")] 22 | [HttpPost] 23 | public async Task> CreateOrGetAccount(AcmeHeader header, AcmePayload payload) 24 | { 25 | if(payload.Value.OnlyReturnExisting) 26 | return await FindAccountAsync(payload); 27 | 28 | return await CreateAccountAsync(header, payload); 29 | } 30 | 31 | private async Task> CreateAccountAsync(AcmeHeader header, AcmePayload payload) 32 | { 33 | if (payload == null) 34 | throw new MalformedRequestException("Payload was empty or could not be read."); 35 | 36 | var account = await _accountService.CreateAccountAsync( 37 | header.Jwk!, //Post requests are validated, JWK exists. 38 | payload.Value.Contact, 39 | payload.Value.TermsOfServiceAgreed, 40 | HttpContext.RequestAborted); 41 | 42 | var ordersUrl = Url.RouteUrl("OrderList", new { accountId = account.AccountId }, "https"); 43 | var accountResponse = new Protocol.HttpModel.Account(account, ordersUrl); 44 | 45 | var accountUrl = Url.RouteUrl("Account", new { accountId = account.AccountId }, "https"); 46 | return new CreatedResult(accountUrl, accountResponse); 47 | } 48 | 49 | private Task> FindAccountAsync(AcmePayload payload) 50 | { 51 | throw new NotImplementedException(); 52 | } 53 | 54 | [Route("/account/{accountId}", Name = "Account")] 55 | [HttpPost, HttpPut] 56 | public Task> SetAccount(string accountId) 57 | { 58 | throw new NotImplementedException(); 59 | } 60 | 61 | [Route("/account/{accountId}/orders", Name = "OrderList")] 62 | [HttpPost] 63 | public Task> GetOrdersList(string accountId, AcmePayload payload) 64 | { 65 | throw new NotImplementedException(); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/ACME.Protocol/RequestServices/DefaultRequestProvider.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.IdentityModel.Tokens; 2 | using System; 3 | using System.Text.Json; 4 | using TGIT.ACME.Protocol.HttpModel.Requests; 5 | using TGIT.ACME.Protocol.Model.Exceptions; 6 | 7 | namespace TGIT.ACME.Protocol.RequestServices 8 | { 9 | public class DefaultRequestProvider : IAcmeRequestProvider 10 | { 11 | private AcmeRawPostRequest? _request; 12 | private AcmeHeader? _header; 13 | 14 | private Type? _payloadType; 15 | private object? _payload; 16 | 17 | 18 | public void Initialize(AcmeRawPostRequest rawPostRequest) 19 | { 20 | if (rawPostRequest is null) 21 | throw new ArgumentNullException(nameof(rawPostRequest)); 22 | 23 | _request = rawPostRequest; 24 | _header = ReadHeader(_request); 25 | } 26 | 27 | public AcmeHeader GetHeader() 28 | { 29 | if (_request is null || _header is null) 30 | throw new NotInitializedException(); 31 | 32 | return _header; 33 | } 34 | 35 | public T GetPayload() 36 | { 37 | if (_request is null) 38 | throw new NotInitializedException(); 39 | 40 | if (_payload != null) 41 | { 42 | if (_payloadType != typeof(T)) 43 | throw new InvalidOperationException("Cannot change types during request"); 44 | 45 | return (T)_payload; 46 | } 47 | 48 | _payloadType = typeof(T); 49 | 50 | var payload = ReadPayload(_request); 51 | _payload = payload; 52 | 53 | return payload; 54 | } 55 | 56 | public AcmeRawPostRequest GetRequest() 57 | { 58 | if (_request is null) 59 | throw new NotInitializedException(); 60 | 61 | return _request; 62 | } 63 | 64 | 65 | private static readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; 66 | 67 | private static AcmeHeader ReadHeader(AcmeRawPostRequest rawRequest) 68 | { 69 | if (rawRequest is null) 70 | throw new ArgumentNullException(nameof(rawRequest)); 71 | 72 | var headerJson = Base64UrlEncoder.Decode(rawRequest.Header); 73 | var header = JsonSerializer.Deserialize(headerJson, _jsonOptions); 74 | 75 | return header; 76 | } 77 | 78 | private static TPayload ReadPayload(AcmeRawPostRequest rawRequest) 79 | { 80 | if (rawRequest is null) 81 | throw new ArgumentNullException(nameof(rawRequest)); 82 | 83 | var payloadJson = Base64UrlEncoder.Decode(rawRequest.Payload); 84 | var payload = JsonSerializer.Deserialize(payloadJson, _jsonOptions); 85 | 86 | return payload; 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/Exceptions/MalformedRequestException.cs: -------------------------------------------------------------------------------- 1 | namespace TGIT.ACME.Protocol.Model.Exceptions 2 | { 3 | public class MalformedRequestException : AcmeException 4 | { 5 | public MalformedRequestException(string message) 6 | : base(message) 7 | { } 8 | 9 | public override string ErrorType => "malformed"; 10 | } 11 | 12 | public class NotAuthorizedException : MalformedRequestException 13 | { 14 | public NotAuthorizedException() 15 | :base("The request could not be authorized.") 16 | { } 17 | } 18 | 19 | public class NotFoundException : MalformedRequestException 20 | { 21 | public NotFoundException() 22 | :base("The requested resource could not be found.") 23 | { } 24 | } 25 | 26 | public class NotAllowedException : MalformedRequestException 27 | { 28 | public NotAllowedException() 29 | : base("The requested resoruce may not be accessed.") 30 | { } 31 | } 32 | 33 | public class ConflictRequestException : MalformedRequestException 34 | { 35 | private ConflictRequestException(string resourceType, string attemptedStatus) 36 | : base($"The {resourceType} could not be set to the status of '{attemptedStatus}'") 37 | { } 38 | 39 | private ConflictRequestException(string resourceType, string expectedStatus, string actualStatus) 40 | : base($"The {resourceType} used in this request did not have the expected status '{expectedStatus}' but had '{actualStatus}'.") 41 | { } 42 | 43 | public ConflictRequestException(AccountStatus attemptedStatus) 44 | : this("account", $"{attemptedStatus}") 45 | { } 46 | 47 | public ConflictRequestException(OrderStatus attemptedStatus) 48 | : this("order", $"{attemptedStatus}") 49 | { } 50 | 51 | public ConflictRequestException(AuthorizationStatus attemptedStatus) 52 | : this("authorization", $"{attemptedStatus}") 53 | { } 54 | 55 | public ConflictRequestException(ChallengeStatus attemptedStatus) 56 | : this("challenge", $"{attemptedStatus}") 57 | { } 58 | 59 | public ConflictRequestException(AccountStatus expectedStatus, AccountStatus actualStatus) 60 | : this("account", $"{expectedStatus}", $"{actualStatus}") 61 | { } 62 | 63 | public ConflictRequestException(OrderStatus expectedStatus, OrderStatus actualStatus) 64 | : this("order", $"{expectedStatus}", $"{actualStatus}") 65 | { } 66 | 67 | public ConflictRequestException(AuthorizationStatus expectedStatus, AuthorizationStatus actualStatus) 68 | : this("authorization", $"{expectedStatus}", $"{actualStatus}") 69 | { } 70 | 71 | public ConflictRequestException(ChallengeStatus expectedStatus, ChallengeStatus actualStatus) 72 | : this("challenge", $"{expectedStatus}", $"{actualStatus}") 73 | { } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/ACME.Server/BackgroundServices/TimedHostedService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Hosting; 3 | using Microsoft.Extensions.Logging; 4 | using System; 5 | using System.Linq.Expressions; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | 9 | namespace TGIT.ACME.Server.BackgroundServices 10 | { 11 | public abstract class TimedHostedService : IHostedService, IDisposable 12 | { 13 | private readonly IServiceProvider _services; 14 | private readonly ILogger _logger; 15 | 16 | protected abstract bool EnableService { get; } 17 | protected abstract TimeSpan TimerInterval { get; } 18 | 19 | private Timer? _timer; 20 | private readonly SemaphoreSlim _interlock; 21 | private readonly CancellationTokenSource _cancellationTokenSource; 22 | 23 | public TimedHostedService(IServiceProvider services, ILogger logger) 24 | { 25 | _services = services ?? throw new ArgumentNullException(nameof(services)); 26 | _logger = logger; 27 | 28 | _interlock = new SemaphoreSlim(1); 29 | _cancellationTokenSource = new CancellationTokenSource(); 30 | } 31 | 32 | public Task StartAsync(CancellationToken stoppingToken) 33 | { 34 | if (EnableService) 35 | { 36 | _logger.LogInformation("Timed Hosted Service running."); 37 | _timer = new Timer(DoWorkCallback, null, TimeSpan.FromSeconds(15), TimerInterval); 38 | } 39 | 40 | return Task.CompletedTask; 41 | } 42 | 43 | protected async void DoWorkCallback(object? state) 44 | { 45 | if(! await _interlock.WaitAsync(TimerInterval / 2, _cancellationTokenSource.Token)) 46 | { 47 | _logger.LogInformation("Waited half an execution time, but did not get execution lock."); 48 | return; 49 | } 50 | 51 | try 52 | { 53 | using var scopedServices = _services.CreateScope(); 54 | await DoWork(scopedServices.ServiceProvider, _cancellationTokenSource.Token); 55 | } 56 | catch (Exception ex) 57 | { 58 | _logger.LogError(ex, "TimedHostedService failed with exception."); 59 | } 60 | finally { 61 | _interlock.Release(); 62 | } 63 | } 64 | 65 | protected abstract Task DoWork(IServiceProvider services, CancellationToken cancellationToken); 66 | 67 | public Task StopAsync(CancellationToken stoppingToken) 68 | { 69 | _logger.LogInformation("Timed Hosted Service is stopping."); 70 | 71 | _timer?.Change(Timeout.Infinite, Timeout.Infinite); 72 | _cancellationTokenSource.Cancel(); 73 | 74 | return Task.CompletedTask; 75 | } 76 | 77 | public void Dispose() 78 | { 79 | _timer?.Dispose(); 80 | _interlock?.Dispose(); 81 | _cancellationTokenSource?.Dispose(); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/ACME.Protocol/Workers/ValidationWorker.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using TGIT.ACME.Protocol.Model; 6 | using TGIT.ACME.Protocol.Services; 7 | using TGIT.ACME.Protocol.Storage; 8 | 9 | namespace TGIT.ACME.Protocol.Workers 10 | { 11 | 12 | public class ValidationWorker : IValidationWorker 13 | { 14 | private readonly IOrderStore _orderStore; 15 | private readonly IAccountStore _accountStore; 16 | private readonly IChallangeValidatorFactory _challangeValidatorFactory; 17 | 18 | public ValidationWorker(IOrderStore orderStore, IAccountStore accountStore, 19 | IChallangeValidatorFactory challangeValidatorFactory) 20 | { 21 | _orderStore = orderStore; 22 | _accountStore = accountStore; 23 | _challangeValidatorFactory = challangeValidatorFactory; 24 | } 25 | 26 | public async Task RunAsync(CancellationToken cancellationToken) 27 | { 28 | var orders = await _orderStore.GetValidatableOrders(cancellationToken); 29 | 30 | var tasks = new Task[orders.Count]; 31 | for(int i = 0; i < orders.Count; ++i) 32 | tasks[i] = ValidateOrder(orders[i], cancellationToken); 33 | 34 | Task.WaitAll(tasks, cancellationToken); 35 | } 36 | 37 | private async Task ValidateOrder(Order order, CancellationToken cancellationToken) 38 | { 39 | var account = await _accountStore.LoadAccountAsync(order.AccountId, cancellationToken); 40 | if (account == null) 41 | { 42 | order.SetStatus(OrderStatus.Invalid); 43 | order.Error = new AcmeError("TODO", "Account could not be located. Order will be marked invalid."); 44 | await _orderStore.SaveOrderAsync(order, cancellationToken); 45 | 46 | return; 47 | } 48 | 49 | var pendingAuthZs = order.Authorizations.Where(a => a.Challenges.Any(c => c.Status == ChallengeStatus.Processing)); 50 | 51 | foreach(var pendingAuthZ in pendingAuthZs) 52 | { 53 | if(pendingAuthZ.Expires <= DateTimeOffset.UtcNow) 54 | { 55 | pendingAuthZ.ClearChallenges(); 56 | pendingAuthZ.SetStatus(AuthorizationStatus.Expired); 57 | continue; 58 | } 59 | 60 | var challenge = pendingAuthZ.Challenges[0]; 61 | 62 | var validator = _challangeValidatorFactory.GetValidator(challenge); 63 | var (isValid, error) = await validator.ValidateChallengeAsync(challenge, account, cancellationToken); 64 | 65 | if (isValid) 66 | { 67 | challenge.SetStatus(ChallengeStatus.Valid); 68 | pendingAuthZ.SetStatus(AuthorizationStatus.Valid); 69 | } else 70 | { 71 | challenge.Error = error!; 72 | challenge.SetStatus(ChallengeStatus.Invalid); 73 | pendingAuthZ.SetStatus(AuthorizationStatus.Invalid); 74 | } 75 | } 76 | 77 | order.SetStatusFromAuthorizations(); 78 | await _orderStore.SaveOrderAsync(order, cancellationToken); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/Challenge.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Runtime.Serialization; 5 | using TGIT.ACME.Protocol.HttpModel; 6 | using TGIT.ACME.Protocol.Model.Exceptions; 7 | using TGIT.ACME.Protocol.Model.Extensions; 8 | 9 | namespace TGIT.ACME.Protocol.Model 10 | { 11 | [Serializable] 12 | public class Challenge : ISerializable 13 | { 14 | private static readonly Dictionary _validStatusTransitions = 15 | new Dictionary 16 | { 17 | { ChallengeStatus.Pending, new [] { ChallengeStatus.Processing } }, 18 | { ChallengeStatus.Processing, new [] { ChallengeStatus.Processing, ChallengeStatus.Invalid, ChallengeStatus.Valid } } 19 | }; 20 | 21 | private Authorization? _authorization; 22 | 23 | public Challenge(Authorization authorization, string type) 24 | { 25 | if (!ChallengeTypes.AllTypes.Contains(type)) 26 | throw new InvalidOperationException($"Unknown ChallengeType {type}"); 27 | 28 | ChallengeId = GuidString.NewValue(); 29 | 30 | Type = type; 31 | Token = CryptoString.NewValue(); 32 | 33 | Authorization = authorization; 34 | Authorization.Challenges.Add(this); 35 | } 36 | 37 | public string ChallengeId { get; } 38 | public ChallengeStatus Status { get; set; } 39 | 40 | public string Type { get; } 41 | public string Token { get; } 42 | 43 | public Authorization Authorization { 44 | get => _authorization ?? throw new NotInitializedException(); 45 | internal set => _authorization = value; 46 | } 47 | 48 | public DateTimeOffset? Validated { get; set; } 49 | public bool IsValid => Validated.HasValue; 50 | 51 | public AcmeError? Error { get; set; } 52 | 53 | 54 | public void SetStatus(ChallengeStatus nextStatus) 55 | { 56 | if (!_validStatusTransitions.ContainsKey(Status)) 57 | throw new ConflictRequestException(nextStatus); 58 | if (!_validStatusTransitions[Status].Contains(nextStatus)) 59 | throw new ConflictRequestException(nextStatus); 60 | 61 | Status = nextStatus; 62 | } 63 | 64 | 65 | 66 | // --- Serialization Methods --- // 67 | 68 | protected Challenge(SerializationInfo info, StreamingContext streamingContext) 69 | { 70 | if (info is null) 71 | throw new ArgumentNullException(nameof(info)); 72 | 73 | ChallengeId = info.GetRequiredString(nameof(ChallengeId)); 74 | Status = (ChallengeStatus)info.GetInt32(nameof(Status)); 75 | 76 | Type = info.GetRequiredString(nameof(Type)); 77 | Token = info.GetRequiredString(nameof(Token)); 78 | 79 | Validated = info.TryGetValue(nameof(Validated)); 80 | Error = info.TryGetValue(nameof(Error)); 81 | } 82 | 83 | public void GetObjectData(SerializationInfo info, StreamingContext context) 84 | { 85 | if (info is null) 86 | throw new ArgumentNullException(nameof(info)); 87 | 88 | info.AddValue("SerializationVersion", 1); 89 | 90 | info.AddValue(nameof(ChallengeId), ChallengeId); 91 | info.AddValue(nameof(Status), Status); 92 | 93 | info.AddValue(nameof(Type), Type); 94 | info.AddValue(nameof(Token), Token); 95 | 96 | info.AddValue(nameof(Validated), Validated); 97 | info.AddValue(nameof(Error), Error); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/Authorization.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Runtime.Serialization; 5 | using TGIT.ACME.Protocol.Model.Exceptions; 6 | using TGIT.ACME.Protocol.Model.Extensions; 7 | 8 | namespace TGIT.ACME.Protocol.Model 9 | { 10 | [Serializable] 11 | public class Authorization : ISerializable 12 | { 13 | private static readonly Dictionary _validStatusTransitions = 14 | new Dictionary 15 | { 16 | { AuthorizationStatus.Pending, new [] { AuthorizationStatus.Invalid, AuthorizationStatus.Expired, AuthorizationStatus.Valid } }, 17 | { AuthorizationStatus.Valid, new [] { AuthorizationStatus.Revoked, AuthorizationStatus.Deactivated, AuthorizationStatus.Expired } } 18 | }; 19 | 20 | private Order? _order; 21 | 22 | public Authorization(Order order, Identifier identifier, DateTimeOffset expires) 23 | { 24 | AuthorizationId = GuidString.NewValue(); 25 | Challenges = new List(); 26 | 27 | Order = order ?? throw new ArgumentNullException(nameof(order)); 28 | Order.Authorizations.Add(this); 29 | 30 | Identifier = identifier ?? throw new ArgumentNullException(nameof(identifier)); 31 | Expires = expires; 32 | } 33 | 34 | public string AuthorizationId { get; } 35 | public AuthorizationStatus Status { get; set; } 36 | 37 | public Order Order { 38 | get => _order ?? throw new NotInitializedException(); 39 | internal set => _order = value; 40 | } 41 | 42 | public Identifier Identifier { get; } 43 | public bool IsWildcard => Identifier.IsWildcard; 44 | 45 | public DateTimeOffset Expires { get; set; } 46 | 47 | public List Challenges { get; private set; } 48 | 49 | 50 | public Challenge? GetChallenge(string challengeId) 51 | => Challenges.FirstOrDefault(x => x.ChallengeId == challengeId); 52 | 53 | public void SelectChallenge(Challenge challenge) 54 | => Challenges.RemoveAll(c => c != challenge); 55 | 56 | public void ClearChallenges() 57 | => Challenges.Clear(); 58 | 59 | 60 | public void SetStatus(AuthorizationStatus nextStatus) 61 | { 62 | if (!_validStatusTransitions.ContainsKey(Status)) 63 | throw new InvalidOperationException($"Cannot do challenge status transition from '{Status}'."); 64 | 65 | if (!_validStatusTransitions[Status].Contains(nextStatus)) 66 | throw new InvalidOperationException($"Cannot do challenge status transition from '{Status}' to {nextStatus}."); 67 | 68 | Status = nextStatus; 69 | } 70 | 71 | 72 | 73 | // --- Serialization Methods --- // 74 | 75 | protected Authorization(SerializationInfo info, StreamingContext streamingContext) 76 | { 77 | if (info is null) 78 | throw new ArgumentNullException(nameof(info)); 79 | 80 | AuthorizationId = info.GetRequiredString(nameof(AuthorizationId)); 81 | Status = (AuthorizationStatus)info.GetInt32(nameof(Status)); 82 | 83 | Identifier = info.GetRequiredValue(nameof(Identifier)); 84 | Expires = info.GetValue(nameof(Expires)); 85 | 86 | Challenges = info.GetRequiredValue>(nameof(Challenges)); 87 | foreach (var challenge in Challenges) 88 | challenge.Authorization = this; 89 | } 90 | 91 | public void GetObjectData(SerializationInfo info, StreamingContext context) 92 | { 93 | if (info is null) 94 | throw new ArgumentNullException(nameof(info)); 95 | 96 | info.AddValue("SerializationVersion", 1); 97 | 98 | info.AddValue(nameof(AuthorizationId), AuthorizationId); 99 | info.AddValue(nameof(Status), Status); 100 | 101 | info.AddValue(nameof(Identifier), Identifier); 102 | info.AddValue(nameof(Expires), Expires); 103 | 104 | info.AddValue(nameof(Challenges), Challenges); 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/ACME.Protocol/Services/DefaultOrderService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using TGIT.ACME.Protocol.IssuanceServices; 6 | using TGIT.ACME.Protocol.Model; 7 | using TGIT.ACME.Protocol.Model.Exceptions; 8 | using TGIT.ACME.Protocol.Storage; 9 | 10 | namespace TGIT.ACME.Protocol.Services 11 | { 12 | public class DefaultOrderService : IOrderService 13 | { 14 | private readonly IOrderStore _orderStore; 15 | private readonly IAuthorizationFactory _authorizationFactory; 16 | private readonly ICsrValidator _csrValidator; 17 | 18 | public DefaultOrderService(IOrderStore orderStore, IAuthorizationFactory authorizationFactory, ICsrValidator csrValidator) 19 | { 20 | _orderStore = orderStore; 21 | _authorizationFactory = authorizationFactory; 22 | _csrValidator = csrValidator; 23 | } 24 | 25 | public async Task CreateOrderAsync(Account account, 26 | IEnumerable identifiers, 27 | DateTimeOffset? notBefore, DateTimeOffset? notAfter, 28 | CancellationToken cancellationToken) 29 | { 30 | ValidateAccount(account); 31 | 32 | var order = new Order(account, identifiers) 33 | { 34 | NotBefore = notBefore, 35 | NotAfter = notAfter 36 | }; 37 | 38 | _authorizationFactory.CreateAuthorizations(order); 39 | 40 | await _orderStore.SaveOrderAsync(order, cancellationToken); 41 | 42 | return order; 43 | } 44 | 45 | public async Task GetCertificate(Account account, string orderId, CancellationToken cancellationToken) 46 | { 47 | ValidateAccount(account); 48 | var order = await HandleLoadOrderAsync(account, orderId, OrderStatus.Valid, cancellationToken); 49 | 50 | return order.Certificate!; 51 | } 52 | 53 | public async Task GetOrderAsync(Account account, string orderId, CancellationToken cancellationToken) 54 | { 55 | ValidateAccount(account); 56 | var order = await HandleLoadOrderAsync(account, orderId, null, cancellationToken); 57 | 58 | return order; 59 | } 60 | 61 | public async Task ProcessChallengeAsync(Account account, string orderId, string authId, string challengeId, CancellationToken cancellationToken) 62 | { 63 | ValidateAccount(account); 64 | var order = await HandleLoadOrderAsync(account, orderId, OrderStatus.Pending, cancellationToken); 65 | 66 | var authZ = order.GetAuthorization(authId); 67 | var challenge = authZ?.GetChallenge(challengeId); 68 | 69 | if (authZ == null || challenge == null) 70 | throw new NotFoundException(); 71 | 72 | if (authZ.Status != AuthorizationStatus.Pending) 73 | throw new ConflictRequestException(AuthorizationStatus.Pending, authZ.Status); 74 | if (challenge.Status != ChallengeStatus.Pending) 75 | throw new ConflictRequestException(ChallengeStatus.Pending, challenge.Status); 76 | 77 | challenge.SetStatus(ChallengeStatus.Processing); 78 | authZ.SelectChallenge(challenge); 79 | 80 | await _orderStore.SaveOrderAsync(order, cancellationToken); 81 | 82 | return challenge; 83 | } 84 | 85 | public async Task ProcessCsr(Account account, string orderId, string? csr, CancellationToken cancellationToken) 86 | { 87 | ValidateAccount(account); 88 | var order = await HandleLoadOrderAsync(account, orderId, OrderStatus.Ready, cancellationToken); 89 | 90 | if (string.IsNullOrWhiteSpace(csr)) 91 | throw new MalformedRequestException("CSR may not be empty."); 92 | 93 | var (isValid, error) = await _csrValidator.ValidateCsrAsync(order, csr, cancellationToken); 94 | 95 | if (isValid) 96 | { 97 | order.CertificateSigningRequest = csr; 98 | order.SetStatus(OrderStatus.Processing); 99 | } else 100 | { 101 | order.Error = error; 102 | order.SetStatus(OrderStatus.Invalid); 103 | } 104 | 105 | await _orderStore.SaveOrderAsync(order, cancellationToken); 106 | return order; 107 | } 108 | 109 | private static void ValidateAccount(Account? account) 110 | { 111 | if (account == null) 112 | throw new NotAllowedException(); 113 | 114 | if (account.Status != AccountStatus.Valid) 115 | throw new ConflictRequestException(AccountStatus.Valid, account.Status); 116 | } 117 | 118 | private async Task HandleLoadOrderAsync(Account account, string orderId, OrderStatus? expectedStatus, CancellationToken cancellationToken) 119 | { 120 | var order = await _orderStore.LoadOrderAsync(orderId, cancellationToken); 121 | if (order == null) 122 | throw new NotFoundException(); 123 | 124 | if (expectedStatus.HasValue && order.Status != expectedStatus) 125 | throw new ConflictRequestException(expectedStatus.Value, order.Status); 126 | 127 | if (order.AccountId != account.AccountId) 128 | throw new NotAllowedException(); 129 | 130 | return order; 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/ACME.Protocol/RequestServices/DefaultRequestValidationService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.IdentityModel.Tokens; 7 | using TGIT.ACME.Protocol.HttpModel.Requests; 8 | using TGIT.ACME.Protocol.Model; 9 | using TGIT.ACME.Protocol.Model.Exceptions; 10 | using TGIT.ACME.Protocol.Services; 11 | using TGIT.ACME.Protocol.Storage; 12 | 13 | namespace TGIT.ACME.Protocol.RequestServices 14 | { 15 | public class DefaultRequestValidationService : IRequestValidationService 16 | { 17 | private readonly IAccountService _accountService; 18 | private readonly INonceStore _nonceStore; 19 | 20 | private readonly ILogger _logger; 21 | 22 | private readonly string[] _supportedAlgs = new[] { "RS256" }; 23 | 24 | public DefaultRequestValidationService(IAccountService accountService, INonceStore nonceStore, 25 | ILogger logger) 26 | { 27 | _accountService = accountService; 28 | _nonceStore = nonceStore; 29 | _logger = logger; 30 | } 31 | 32 | public async Task ValidateRequestAsync(AcmeRawPostRequest request, AcmeHeader header, 33 | string requestUrl, CancellationToken cancellationToken) 34 | { 35 | if (request is null) 36 | throw new ArgumentNullException(nameof(request)); 37 | if (header is null) 38 | throw new ArgumentNullException(nameof(header)); 39 | if (string.IsNullOrWhiteSpace(requestUrl)) 40 | throw new ArgumentNullException(nameof(requestUrl)); 41 | 42 | ValidateRequestHeader(header, requestUrl); 43 | await ValidateNonceAsync(header.Nonce, cancellationToken); 44 | await ValidateSignatureAsync(request, header, cancellationToken); 45 | } 46 | 47 | private void ValidateRequestHeader(AcmeHeader header, string requestUrl) 48 | { 49 | if (header is null) 50 | throw new ArgumentNullException(nameof(header)); 51 | 52 | _logger.LogDebug("Attempting to validate AcmeHeader ..."); 53 | 54 | if (!Uri.IsWellFormedUriString(header.Url, UriKind.RelativeOrAbsolute)) 55 | throw new MalformedRequestException("Header Url is not well-formed."); 56 | 57 | if (header.Url != requestUrl) 58 | throw new NotAuthorizedException(); 59 | 60 | if (!_supportedAlgs.Contains(header.Alg)) 61 | throw new BadSignatureAlgorithmException(); 62 | 63 | if (header.Jwk != null && header.Kid != null) 64 | throw new MalformedRequestException("Do not provide both Jwk and Kid."); 65 | if (header.Jwk == null && header.Kid == null) 66 | throw new MalformedRequestException("Provide either Jwk or Kid."); 67 | 68 | _logger.LogDebug("successfully validated AcmeHeader."); 69 | } 70 | 71 | private async Task ValidateNonceAsync(string? nonce, CancellationToken cancellationToken) 72 | { 73 | _logger.LogDebug("Attempting to validate replay nonce ..."); 74 | if (string.IsNullOrWhiteSpace(nonce)) 75 | { 76 | _logger.LogDebug($"Nonce was empty."); 77 | throw new BadNonceException(); 78 | } 79 | 80 | if (!await _nonceStore.TryRemoveNonceAsync(new Nonce(nonce), cancellationToken)) 81 | { 82 | _logger.LogDebug($"Nonce was invalid."); 83 | throw new BadNonceException(); 84 | } 85 | 86 | _logger.LogDebug("successfully validated replay nonce."); 87 | } 88 | 89 | private async Task ValidateSignatureAsync(AcmeRawPostRequest request, AcmeHeader header, CancellationToken cancellationToken) 90 | { 91 | if (request is null) 92 | throw new ArgumentNullException(nameof(request)); 93 | if (header is null) 94 | throw new ArgumentNullException(nameof(header)); 95 | 96 | _logger.LogDebug("Attempting to validate signature ..."); 97 | 98 | var jwk = header.Jwk; 99 | if(jwk == null) 100 | { 101 | try 102 | { 103 | var accountId = header.GetAccountId(); 104 | var account = await _accountService.LoadAcountAsync(accountId, cancellationToken); 105 | jwk = account?.Jwk; 106 | } 107 | catch (InvalidOperationException) 108 | { 109 | throw new MalformedRequestException("KID could not be found."); 110 | } 111 | } 112 | 113 | if(jwk == null) 114 | throw new MalformedRequestException("Could not load JWK."); 115 | 116 | var securityKey = jwk.SecurityKey; 117 | 118 | using var signatureProvider = new AsymmetricSignatureProvider(securityKey, header.Alg); 119 | var plainText = System.Text.Encoding.UTF8.GetBytes($"{request.Header}.{request.Payload ?? ""}"); 120 | var signature = Base64UrlEncoder.DecodeBytes(request.Signature); 121 | 122 | if (!signatureProvider.Verify(plainText, signature)) 123 | throw new MalformedRequestException("The signature could not be verified"); 124 | 125 | _logger.LogDebug("successfully validated signature."); 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/ACME.Protocol.Model/Model/Order.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Runtime.Serialization; 5 | using TGIT.ACME.Protocol.Model.Extensions; 6 | 7 | namespace TGIT.ACME.Protocol.Model 8 | { 9 | [Serializable] 10 | public class Order : IVersioned, ISerializable 11 | { 12 | private static readonly Dictionary _validStatusTransitions = 13 | new Dictionary 14 | { 15 | { OrderStatus.Pending, new [] { OrderStatus.Ready, OrderStatus.Invalid } }, 16 | { OrderStatus.Ready, new [] { OrderStatus.Processing, OrderStatus.Invalid } }, 17 | { OrderStatus.Processing, new [] { OrderStatus.Valid, OrderStatus.Invalid } }, 18 | }; 19 | 20 | public Order(Account account, IEnumerable identifiers) 21 | { 22 | OrderId = GuidString.NewValue(); 23 | Status = OrderStatus.Pending; 24 | 25 | AccountId = account.AccountId; 26 | 27 | Identifiers = new List(identifiers); 28 | Authorizations = new List(); 29 | } 30 | 31 | public string OrderId { get; } 32 | public string AccountId { get; } 33 | 34 | public OrderStatus Status { get; private set; } 35 | 36 | public List Identifiers { get; private set; } 37 | public List Authorizations { get; private set; } 38 | 39 | public DateTimeOffset? NotBefore { get; set; } 40 | public DateTimeOffset? NotAfter { get; set; } 41 | public DateTimeOffset? Expires { get; set; } 42 | 43 | public AcmeError? Error { get; set; } 44 | 45 | public string? CertificateSigningRequest { get; set; } 46 | public byte[]? Certificate { get; set; } 47 | 48 | 49 | /// 50 | /// Concurrency Token 51 | /// 52 | public long Version { get; set; } 53 | 54 | public Authorization? GetAuthorization(string authId) 55 | => Authorizations.FirstOrDefault(x => x.AuthorizationId == authId); 56 | 57 | public void SetStatus(OrderStatus nextStatus) 58 | { 59 | if (!_validStatusTransitions.ContainsKey(Status)) 60 | throw new InvalidOperationException($"Cannot do challenge status transition from '{Status}'."); 61 | 62 | if (!_validStatusTransitions[Status].Contains(nextStatus)) 63 | throw new InvalidOperationException($"Cannot do challenge status transition from '{Status}' to {nextStatus}."); 64 | 65 | Status = nextStatus; 66 | } 67 | 68 | public void SetStatusFromAuthorizations() 69 | { 70 | if (Authorizations.All(a => a.Status == AuthorizationStatus.Valid)) 71 | SetStatus(OrderStatus.Ready); 72 | 73 | if (Authorizations.Any(a => a.Status.IsInvalid())) 74 | SetStatus(OrderStatus.Invalid); 75 | } 76 | 77 | 78 | 79 | // --- Serialization Methods --- // 80 | 81 | protected Order(SerializationInfo info, StreamingContext streamingContext) 82 | { 83 | if (info is null) 84 | throw new ArgumentNullException(nameof(info)); 85 | 86 | OrderId = info.GetRequiredString(nameof(OrderId)); 87 | AccountId = info.GetRequiredString(nameof(AccountId)); 88 | 89 | Status = (OrderStatus)info.GetInt32(nameof(Status)); 90 | 91 | Identifiers = info.GetRequiredValue>(nameof(Identifiers)); 92 | Authorizations = info.GetRequiredValue>(nameof(Authorizations)); 93 | 94 | foreach (var auth in Authorizations) 95 | auth.Order = this; 96 | 97 | NotBefore = info.TryGetValue(nameof(NotBefore)); 98 | NotAfter = info.TryGetValue(nameof(NotAfter)); 99 | Expires = info.TryGetValue(nameof(Expires)); 100 | 101 | Error = info.TryGetValue(nameof(Error)); 102 | Version = info.GetInt64(nameof(Version)); 103 | 104 | CertificateSigningRequest = info.TryGetValue(nameof(CertificateSigningRequest)); 105 | Certificate = info.TryGetValue(nameof(Certificate)); 106 | } 107 | 108 | public void GetObjectData(SerializationInfo info, StreamingContext context) 109 | { 110 | if (info is null) 111 | throw new ArgumentNullException(nameof(info)); 112 | 113 | info.AddValue("SerializationVersion", 1); 114 | 115 | info.AddValue(nameof(OrderId), OrderId); 116 | info.AddValue(nameof(AccountId), AccountId); 117 | 118 | info.AddValue(nameof(Status), Status); 119 | 120 | info.AddValue(nameof(Identifiers), Identifiers); 121 | info.AddValue(nameof(Authorizations), Authorizations); 122 | 123 | info.AddValue(nameof(NotBefore), NotBefore); 124 | info.AddValue(nameof(NotAfter), NotAfter); 125 | info.AddValue(nameof(Expires), Expires); 126 | 127 | info.AddValue(nameof(Error), Error); 128 | info.AddValue(nameof(Version), Version); 129 | 130 | if (CertificateSigningRequest != null) 131 | info.AddValue(nameof(CertificateSigningRequest), CertificateSigningRequest); 132 | if (Certificate != null) 133 | info.AddValue(nameof(Certificate), Certificate); 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /ACME-Server.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.29613.14 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{3B1444E7-4308-45DA-86C7-81E09E9DEBD7}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{4A7478FE-7DC4-4340-BFBE-5462BCD7D82D}" 9 | ProjectSection(SolutionItems) = preProject 10 | .editorconfig = .editorconfig 11 | .gitignore = .gitignore 12 | Directory.Build.props = Directory.Build.props 13 | LICENSE = LICENSE 14 | NuGet.Config = NuGet.Config 15 | README.md = README.md 16 | EndProjectSection 17 | EndProject 18 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ACME.Server", "src\ACME.Server\ACME.Server.csproj", "{F36B50AD-A744-4D38-B7CE-54985FB7EDA2}" 19 | EndProject 20 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ACME.Storage.FileStore", "src\ACME.Storage.FileStore\ACME.Storage.FileStore.csproj", "{50D889A9-0928-42E0-B726-8CE9CF3DBF41}" 21 | EndProject 22 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ACME.Protocol", "src\ACME.Protocol\ACME.Protocol.csproj", "{8F90EA50-F781-4ADA-94DD-C5D065316318}" 23 | EndProject 24 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ACME.Protocol.Model", "src\ACME.Protocol.Model\ACME.Protocol.Model.csproj", "{026ACE0F-AAC7-4AB0-B2CF-0722044552F0}" 25 | EndProject 26 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ACME.Protocol.Abstractions", "src\ACME.Protocol.Abstractions\ACME.Protocol.Abstractions.csproj", "{7483E442-7D3D-4C0F-96BB-5766BB466B2D}" 27 | EndProject 28 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{5FEF1B53-A7EA-42F8-8FC3-AE07BEA5B8DF}" 29 | ProjectSection(SolutionItems) = preProject 30 | tools\build.cmd = tools\build.cmd 31 | EndProjectSection 32 | EndProject 33 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{29229610-F00E-4D34-B63E-866AE5B36F49}" 34 | EndProject 35 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ACME.Server.Tests", "test\ACME.Server.Tests\ACME.Server.Tests.csproj", "{6D8BBD56-E2A3-442B-8B08-191790593AD5}" 36 | EndProject 37 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ACME.Protocol.Model.Tests", "test\ACME.Protocol.Model.Tests\ACME.Protocol.Model.Tests.csproj", "{19B36832-45F6-4ED8-A292-5AAD3D2A8002}" 38 | EndProject 39 | Global 40 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 41 | Debug|Any CPU = Debug|Any CPU 42 | Release|Any CPU = Release|Any CPU 43 | EndGlobalSection 44 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 45 | {F36B50AD-A744-4D38-B7CE-54985FB7EDA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 46 | {F36B50AD-A744-4D38-B7CE-54985FB7EDA2}.Debug|Any CPU.Build.0 = Debug|Any CPU 47 | {F36B50AD-A744-4D38-B7CE-54985FB7EDA2}.Release|Any CPU.ActiveCfg = Release|Any CPU 48 | {F36B50AD-A744-4D38-B7CE-54985FB7EDA2}.Release|Any CPU.Build.0 = Release|Any CPU 49 | {50D889A9-0928-42E0-B726-8CE9CF3DBF41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 50 | {50D889A9-0928-42E0-B726-8CE9CF3DBF41}.Debug|Any CPU.Build.0 = Debug|Any CPU 51 | {50D889A9-0928-42E0-B726-8CE9CF3DBF41}.Release|Any CPU.ActiveCfg = Release|Any CPU 52 | {50D889A9-0928-42E0-B726-8CE9CF3DBF41}.Release|Any CPU.Build.0 = Release|Any CPU 53 | {8F90EA50-F781-4ADA-94DD-C5D065316318}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 54 | {8F90EA50-F781-4ADA-94DD-C5D065316318}.Debug|Any CPU.Build.0 = Debug|Any CPU 55 | {8F90EA50-F781-4ADA-94DD-C5D065316318}.Release|Any CPU.ActiveCfg = Release|Any CPU 56 | {8F90EA50-F781-4ADA-94DD-C5D065316318}.Release|Any CPU.Build.0 = Release|Any CPU 57 | {026ACE0F-AAC7-4AB0-B2CF-0722044552F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 58 | {026ACE0F-AAC7-4AB0-B2CF-0722044552F0}.Debug|Any CPU.Build.0 = Debug|Any CPU 59 | {026ACE0F-AAC7-4AB0-B2CF-0722044552F0}.Release|Any CPU.ActiveCfg = Release|Any CPU 60 | {026ACE0F-AAC7-4AB0-B2CF-0722044552F0}.Release|Any CPU.Build.0 = Release|Any CPU 61 | {7483E442-7D3D-4C0F-96BB-5766BB466B2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 62 | {7483E442-7D3D-4C0F-96BB-5766BB466B2D}.Debug|Any CPU.Build.0 = Debug|Any CPU 63 | {7483E442-7D3D-4C0F-96BB-5766BB466B2D}.Release|Any CPU.ActiveCfg = Release|Any CPU 64 | {7483E442-7D3D-4C0F-96BB-5766BB466B2D}.Release|Any CPU.Build.0 = Release|Any CPU 65 | {6D8BBD56-E2A3-442B-8B08-191790593AD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 66 | {6D8BBD56-E2A3-442B-8B08-191790593AD5}.Debug|Any CPU.Build.0 = Debug|Any CPU 67 | {6D8BBD56-E2A3-442B-8B08-191790593AD5}.Release|Any CPU.ActiveCfg = Release|Any CPU 68 | {6D8BBD56-E2A3-442B-8B08-191790593AD5}.Release|Any CPU.Build.0 = Release|Any CPU 69 | {19B36832-45F6-4ED8-A292-5AAD3D2A8002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 70 | {19B36832-45F6-4ED8-A292-5AAD3D2A8002}.Debug|Any CPU.Build.0 = Debug|Any CPU 71 | {19B36832-45F6-4ED8-A292-5AAD3D2A8002}.Release|Any CPU.ActiveCfg = Release|Any CPU 72 | {19B36832-45F6-4ED8-A292-5AAD3D2A8002}.Release|Any CPU.Build.0 = Release|Any CPU 73 | EndGlobalSection 74 | GlobalSection(SolutionProperties) = preSolution 75 | HideSolutionNode = FALSE 76 | EndGlobalSection 77 | GlobalSection(NestedProjects) = preSolution 78 | {F36B50AD-A744-4D38-B7CE-54985FB7EDA2} = {3B1444E7-4308-45DA-86C7-81E09E9DEBD7} 79 | {50D889A9-0928-42E0-B726-8CE9CF3DBF41} = {3B1444E7-4308-45DA-86C7-81E09E9DEBD7} 80 | {8F90EA50-F781-4ADA-94DD-C5D065316318} = {3B1444E7-4308-45DA-86C7-81E09E9DEBD7} 81 | {026ACE0F-AAC7-4AB0-B2CF-0722044552F0} = {3B1444E7-4308-45DA-86C7-81E09E9DEBD7} 82 | {7483E442-7D3D-4C0F-96BB-5766BB466B2D} = {3B1444E7-4308-45DA-86C7-81E09E9DEBD7} 83 | {6D8BBD56-E2A3-442B-8B08-191790593AD5} = {29229610-F00E-4D34-B63E-866AE5B36F49} 84 | {19B36832-45F6-4ED8-A292-5AAD3D2A8002} = {29229610-F00E-4D34-B63E-866AE5B36F49} 85 | EndGlobalSection 86 | GlobalSection(ExtensibilityGlobals) = postSolution 87 | SolutionGuid = {330785C1-6C3F-413B-9FF2-3AEAB1B70379} 88 | EndGlobalSection 89 | EndGlobal 90 | -------------------------------------------------------------------------------- /src/ACME.Storage.FileStore/OrderStore.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using Microsoft.Extensions.Options; 3 | using Newtonsoft.Json; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Globalization; 7 | using System.IO; 8 | using System.Linq; 9 | using System.Text; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | using TGIT.ACME.Protocol.Model; 13 | using TGIT.ACME.Protocol.Model.Exceptions; 14 | using TGIT.ACME.Protocol.Storage; 15 | using TGIT.ACME.Storage.FileStore.Configuration; 16 | 17 | namespace TGIT.ACME.Storage.FileStore 18 | { 19 | public class OrderStore : StoreBase, IOrderStore 20 | { 21 | private readonly ILogger _logger; 22 | 23 | public OrderStore(IOptions options, ILogger logger) 24 | : base(options) 25 | { 26 | _logger = logger; 27 | Directory.CreateDirectory(Options.Value.OrderPath); 28 | } 29 | 30 | private string GetOrderPath(string orderId) 31 | => Path.Combine(Options.Value.OrderPath, $"{orderId}.json"); 32 | 33 | public async Task LoadOrderAsync(string orderId, CancellationToken cancellationToken) 34 | { 35 | if (string.IsNullOrWhiteSpace(orderId) || !IdentifierRegex.IsMatch(orderId)) 36 | throw new MalformedRequestException("OrderId does not match expected format."); 37 | 38 | var orderFilePath = GetOrderPath(orderId); 39 | 40 | var order = await LoadFromPath(orderFilePath, cancellationToken); 41 | return order; 42 | } 43 | 44 | public async Task SaveOrderAsync(Order setOrder, CancellationToken cancellationToken) 45 | { 46 | cancellationToken.ThrowIfCancellationRequested(); 47 | 48 | if (setOrder is null) 49 | throw new ArgumentNullException(nameof(setOrder)); 50 | 51 | var orderFilePath = GetOrderPath(setOrder.OrderId); 52 | 53 | Directory.CreateDirectory(Path.GetDirectoryName(orderFilePath)); 54 | 55 | await CreateOwnerFileAsync(setOrder, cancellationToken); 56 | await WriteWorkFilesAsync(setOrder, cancellationToken); 57 | 58 | using (var fileStream = File.Open(orderFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read)) 59 | { 60 | var existingOrder = await LoadFromStream(fileStream, cancellationToken); 61 | 62 | HandleVersioning(existingOrder, setOrder); 63 | await ReplaceFileStreamContent(fileStream, setOrder, cancellationToken); 64 | } 65 | } 66 | 67 | private async Task CreateOwnerFileAsync(Order order, CancellationToken cancellationToken) 68 | { 69 | var ownerDirectory = Path.Combine(Options.Value.AccountPath, order.AccountId, "orders"); 70 | Directory.CreateDirectory(ownerDirectory); 71 | 72 | var ownerFilePath = Path.Combine(ownerDirectory, order.OrderId); 73 | if (!File.Exists(ownerFilePath)) { 74 | await File.WriteAllTextAsync(ownerFilePath, 75 | order.Expires?.ToString("o", CultureInfo.InvariantCulture), 76 | cancellationToken); 77 | } 78 | } 79 | 80 | private async Task WriteWorkFilesAsync(Order order, CancellationToken cancellationToken) 81 | { 82 | var validationDirectory = Path.Combine(Options.Value.WorkingPath, "validate"); 83 | Directory.CreateDirectory(validationDirectory); 84 | 85 | var validationFilePath = Path.Combine(validationDirectory, order.OrderId); 86 | if (order.Authorizations.Any(a => a.Challenges.Any(c => c.Status == ChallengeStatus.Processing))) 87 | { 88 | if (!File.Exists(validationFilePath)) { 89 | await File.WriteAllTextAsync(validationFilePath, 90 | order.Authorizations.Min(a => a.Expires).ToString("o", CultureInfo.InvariantCulture), 91 | cancellationToken); 92 | } 93 | } 94 | else if (File.Exists(validationFilePath)) 95 | { 96 | File.Delete(validationFilePath); 97 | } 98 | 99 | var processDirectory = Path.Combine(Options.Value.WorkingPath!, "process"); 100 | Directory.CreateDirectory(processDirectory); 101 | 102 | var processingFilePath = Path.Combine(processDirectory, order.OrderId); 103 | if(order.Status == OrderStatus.Processing) 104 | { 105 | if (!File.Exists(processingFilePath)) { 106 | await File.WriteAllTextAsync(processingFilePath, 107 | order.Expires?.ToString("o", CultureInfo.InvariantCulture), 108 | cancellationToken); 109 | } 110 | } 111 | else if (File.Exists(processingFilePath)) 112 | { 113 | File.Delete(processingFilePath); 114 | } 115 | } 116 | 117 | public async Task> GetValidatableOrders(CancellationToken cancellationToken) 118 | { 119 | var result = new List(); 120 | 121 | var workPath = Path.Combine(Options.Value.WorkingPath, "validate"); 122 | if (!Directory.Exists(workPath)) 123 | return result; 124 | 125 | var files = Directory.EnumerateFiles(workPath); 126 | foreach(var filePath in files) 127 | { 128 | try 129 | { 130 | var orderId = Path.GetFileName(filePath); 131 | var order = await LoadOrderAsync(orderId, cancellationToken); 132 | 133 | if(order != null) 134 | result.Add(order); 135 | } 136 | catch (Exception ex) { 137 | _logger.LogError(ex, "Could not load validatable orders."); 138 | } 139 | } 140 | 141 | return result; 142 | } 143 | 144 | public async Task> GetFinalizableOrders(CancellationToken cancellationToken) 145 | { 146 | var result = new List(); 147 | 148 | var workPath = Path.Combine(Options.Value.WorkingPath, "process"); 149 | if (!Directory.Exists(workPath)) 150 | return result; 151 | 152 | var files = Directory.EnumerateFiles(workPath); 153 | foreach (var filePath in files) 154 | { 155 | try 156 | { 157 | var orderId = Path.GetFileName(filePath); 158 | var order = await LoadOrderAsync(orderId, cancellationToken); 159 | 160 | if (order != null) 161 | result.Add(order); 162 | } 163 | catch (Exception ex) 164 | { 165 | _logger.LogError(ex, "Could not load finalizable orders."); 166 | } 167 | } 168 | 169 | return result; 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/ACME.Server/Controllers/OrderController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using TGIT.ACME.Protocol.HttpModel.Requests; 6 | using TGIT.ACME.Protocol.Model; 7 | using TGIT.ACME.Protocol.Model.Exceptions; 8 | using TGIT.ACME.Protocol.Services; 9 | using TGIT.ACME.Server.Filters; 10 | 11 | namespace TGIT.ACME.Server.Controllers 12 | { 13 | [AddNextNonce] 14 | public class OrderController : ControllerBase 15 | { 16 | private readonly IOrderService _orderService; 17 | private readonly IAccountService _accountService; 18 | 19 | public OrderController(IOrderService orderService, IAccountService accountService) 20 | { 21 | _orderService = orderService; 22 | _accountService = accountService; 23 | } 24 | 25 | [Route("/new-order", Name = "NewOrder")] 26 | [HttpPost] 27 | public async Task> CreateOrder(AcmePayload payload) 28 | { 29 | var account = await _accountService.FromRequestAsync(HttpContext.RequestAborted); 30 | 31 | var orderRequest = payload.Value; 32 | 33 | if (orderRequest.Identifiers?.Any() != true) 34 | throw new MalformedRequestException("No identifiers submitted"); 35 | 36 | foreach (var i in orderRequest.Identifiers) 37 | if(string.IsNullOrWhiteSpace(i.Type) || string.IsNullOrWhiteSpace(i.Value)) 38 | throw new MalformedRequestException($"Malformed identifier: (Type: {i.Type}, Value: {i.Value})"); 39 | 40 | var identifiers = orderRequest.Identifiers.Select(x => 41 | new Protocol.Model.Identifier(x.Type!, x.Value!) 42 | ); 43 | 44 | var order = await _orderService.CreateOrderAsync( 45 | account, identifiers, 46 | orderRequest.NotBefore, orderRequest.NotAfter, 47 | HttpContext.RequestAborted); 48 | 49 | GetOrderUrls(order, out var authorizationUrls, out var finalizeUrl, out var certificateUrl); 50 | var orderResponse = new Protocol.HttpModel.Order(order, authorizationUrls, finalizeUrl, certificateUrl); 51 | 52 | var orderUrl = Url.RouteUrl("GetOrder", new { orderId = order.OrderId }, "https"); 53 | return new CreatedResult(orderUrl, orderResponse); 54 | } 55 | 56 | private void GetOrderUrls(Order order, out IEnumerable authorizationUrls, out string finalizeUrl, out string certificateUrl) 57 | { 58 | authorizationUrls = order.Authorizations 59 | .Select(x => Url.RouteUrl("GetAuthorization", new { orderId = order.OrderId, authId = x.AuthorizationId }, "https")); 60 | finalizeUrl = Url.RouteUrl("FinalizeOrder", new { orderId = order.OrderId }, "https"); 61 | certificateUrl = Url.RouteUrl("GetCertificate", new { orderId = order.OrderId }, "https"); 62 | } 63 | 64 | [Route("/order/{orderId}", Name = "GetOrder")] 65 | [HttpPost] 66 | public async Task> GetOrder(string orderId) 67 | { 68 | var account = await _accountService.FromRequestAsync(HttpContext.RequestAborted); 69 | var order = await _orderService.GetOrderAsync(account, orderId, HttpContext.RequestAborted); 70 | 71 | if (order == null) 72 | return NotFound(); 73 | 74 | GetOrderUrls(order, out var authorizationUrls, out var finalizeUrl, out var certificateUrl); 75 | var orderResponse = new Protocol.HttpModel.Order(order, authorizationUrls, finalizeUrl, certificateUrl); 76 | 77 | return orderResponse; 78 | } 79 | 80 | [Route("/order/{orderId}/auth/{authId}", Name = "GetAuthorization")] 81 | [HttpPost] 82 | public async Task> GetAuthorization(string orderId, string authId) 83 | { 84 | var account = await _accountService.FromRequestAsync(HttpContext.RequestAborted); 85 | var order = await _orderService.GetOrderAsync(account, orderId, HttpContext.RequestAborted); 86 | 87 | if (order == null) 88 | return NotFound(); 89 | 90 | var authZ = order.GetAuthorization(authId); 91 | if (authZ == null) 92 | return NotFound(); 93 | 94 | var challenges = authZ.Challenges 95 | .Select(challenge => 96 | { 97 | var challengeUrl = GetChallengeUrl(challenge); 98 | 99 | return new Protocol.HttpModel.Challenge(challenge, challengeUrl); 100 | }); 101 | 102 | var authZResponse = new Protocol.HttpModel.Authorization(authZ, challenges); 103 | 104 | return authZResponse; 105 | } 106 | 107 | private string GetChallengeUrl(Challenge challenge) 108 | { 109 | return Url.RouteUrl("AcceptChallenge", 110 | new { 111 | orderId = challenge.Authorization.Order.OrderId, 112 | authId = challenge.Authorization.AuthorizationId, 113 | challengeId = challenge.ChallengeId }, 114 | "https"); 115 | } 116 | 117 | [Route("/order/{orderId}/auth/{authId}/chall/{challengeId}", Name = "AcceptChallenge")] 118 | [HttpPost] 119 | [AcmeLocation("GetOrder")] 120 | public async Task> AcceptChallenge(string orderId, string authId, string challengeId) 121 | { 122 | var account = await _accountService.FromRequestAsync(HttpContext.RequestAborted); 123 | var challenge = await _orderService.ProcessChallengeAsync(account, orderId, authId, challengeId, HttpContext.RequestAborted); 124 | 125 | if (challenge == null) 126 | throw new NotFoundException(); 127 | 128 | var challengeResponse = new Protocol.HttpModel.Challenge(challenge, GetChallengeUrl(challenge)); 129 | return challengeResponse; 130 | } 131 | 132 | [Route("/order/{orderId}/finalize", Name = "FinalizeOrder")] 133 | [HttpPost] 134 | [AcmeLocation("GetOrder")] 135 | public async Task> FinalizeOrder(string orderId, AcmePayload payload) 136 | { 137 | var account = await _accountService.FromRequestAsync(HttpContext.RequestAborted); 138 | var order = await _orderService.ProcessCsr(account, orderId, payload.Value.Csr, HttpContext.RequestAborted); 139 | 140 | GetOrderUrls(order, out var authorizationUrls, out var finalizeUrl, out var certificateUrl); 141 | 142 | var orderResponse = new Protocol.HttpModel.Order(order, authorizationUrls, finalizeUrl, certificateUrl); 143 | return orderResponse; 144 | } 145 | 146 | [Route("/order/{orderId}/certificate", Name = "GetCertificate")] 147 | [HttpPost] 148 | [AcmeLocation("GetOrder")] 149 | public async Task GetCertificate(string orderId) 150 | { 151 | var account = await _accountService.FromRequestAsync(HttpContext.RequestAborted); 152 | var certificate = await _orderService.GetCertificate(account, orderId, HttpContext.RequestAborted); 153 | 154 | return File(certificate, "application/pem-certificate-chain"); 155 | } 156 | } 157 | } 158 | --------------------------------------------------------------------------------