commit 43fba99802e1926525915334ee0dcfc10796b9ce Author: grim Date: Sat Oct 18 10:26:43 2025 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4f34c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.vs +certmgr/obj/* +certmgr/Example.cs +certmgr/Obsolete/* \ No newline at end of file diff --git a/BuildOutput/bin/Debug/net9.0/certmgr.deps.json b/BuildOutput/bin/Debug/net9.0/certmgr.deps.json new file mode 100644 index 0000000..d1a48d2 --- /dev/null +++ b/BuildOutput/bin/Debug/net9.0/certmgr.deps.json @@ -0,0 +1,23 @@ +{ + "runtimeTarget": { + "name": ".NETCoreApp,Version=v9.0", + "signature": "" + }, + "compilationOptions": {}, + "targets": { + ".NETCoreApp,Version=v9.0": { + "certmgr/1.0.0": { + "runtime": { + "certmgr.dll": {} + } + } + } + }, + "libraries": { + "certmgr/1.0.0": { + "type": "project", + "serviceable": false, + "sha512": "" + } + } +} \ No newline at end of file diff --git a/BuildOutput/bin/Debug/net9.0/certmgr.dll b/BuildOutput/bin/Debug/net9.0/certmgr.dll new file mode 100644 index 0000000..e305eb8 Binary files /dev/null and b/BuildOutput/bin/Debug/net9.0/certmgr.dll differ diff --git a/BuildOutput/bin/Debug/net9.0/certmgr.exe b/BuildOutput/bin/Debug/net9.0/certmgr.exe new file mode 100644 index 0000000..91561b7 Binary files /dev/null and b/BuildOutput/bin/Debug/net9.0/certmgr.exe differ diff --git a/BuildOutput/bin/Debug/net9.0/certmgr.pdb b/BuildOutput/bin/Debug/net9.0/certmgr.pdb new file mode 100644 index 0000000..7e6392b Binary files /dev/null and b/BuildOutput/bin/Debug/net9.0/certmgr.pdb differ diff --git a/BuildOutput/bin/Debug/net9.0/certmgr.runtimeconfig.json b/BuildOutput/bin/Debug/net9.0/certmgr.runtimeconfig.json new file mode 100644 index 0000000..b19c3c8 --- /dev/null +++ b/BuildOutput/bin/Debug/net9.0/certmgr.runtimeconfig.json @@ -0,0 +1,12 @@ +{ + "runtimeOptions": { + "tfm": "net9.0", + "framework": { + "name": "Microsoft.NETCore.App", + "version": "9.0.0" + }, + "configProperties": { + "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false + } + } +} \ No newline at end of file diff --git a/certmgr.sln b/certmgr.sln new file mode 100644 index 0000000..eb36b9d --- /dev/null +++ b/certmgr.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36511.14 d17.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "certmgr", "certmgr\certmgr.csproj", "{EB5C73A6-8EBF-4C9C-845F-4828C4985B64}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {EB5C73A6-8EBF-4C9C-845F-4828C4985B64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB5C73A6-8EBF-4C9C-845F-4828C4985B64}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB5C73A6-8EBF-4C9C-845F-4828C4985B64}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB5C73A6-8EBF-4C9C-845F-4828C4985B64}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {AFA87038-4B40-4962-A801-DA2E96351533} + EndGlobalSection +EndGlobal diff --git a/certmgr/CertGen/CertGenException.cs b/certmgr/CertGen/CertGenException.cs new file mode 100644 index 0000000..a3042de --- /dev/null +++ b/certmgr/CertGen/CertGenException.cs @@ -0,0 +1,17 @@ +namespace CertMgr.CertGen; + +public class CertGenException : Exception +{ + public CertGenException(string message) + : base(message) + { + } + public CertGenException(string message, params object?[] args) + : base(string.Format(message, args)) + { + } + public CertGenException(Exception innerException, string message) + : base(message, innerException) + { + } +} diff --git a/certmgr/CertGen/CertificateAlgorithm.cs b/certmgr/CertGen/CertificateAlgorithm.cs new file mode 100644 index 0000000..88b611a --- /dev/null +++ b/certmgr/CertGen/CertificateAlgorithm.cs @@ -0,0 +1,7 @@ +namespace CertMgr.CertGen; + +public enum CertificateAlgorithm +{ + RSA = 1, + ECDsa +} diff --git a/certmgr/CertGen/CertificateGeneratorBase.cs b/certmgr/CertGen/CertificateGeneratorBase.cs new file mode 100644 index 0000000..d89ad83 --- /dev/null +++ b/certmgr/CertGen/CertificateGeneratorBase.cs @@ -0,0 +1,238 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +using CertMgr.Core.Exceptions; + +namespace CertMgr.CertGen; + +internal abstract class CertificateGeneratorBase : ICertificateGenerator + where TAlgorithm : AsymmetricAlgorithm + where TSettings : GeneratorSettings +{ + internal CertificateGeneratorBase(TSettings settings) + { + Settings = settings; + } + + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + protected TSettings Settings { [DebuggerStepThrough] get; } + + public async Task CreateAsync(CertificateSettings settings, CancellationToken cancellationToken) + { + ValidateSettings(settings); + + X509Certificate2 cert = await CreateInternalAsync(settings, cancellationToken); + return cert; + } + + protected abstract bool IsEphemeral(TAlgorithm key); + + protected virtual void ValidateSettings(CertificateSettings settings) + { + if (settings.Issuer != null) + { + if (!settings.Issuer.HasPrivateKey) + { + throw new CertGenException("Certificate without private key cannot be used for signing"); + } + } + } + + protected HashAlgorithmName GetHashAlgorithm() + { + HashAlgorithmName han; + + switch (Settings.HashAlgorithm) + { + case HashAlgorithm.Sha256: + han = HashAlgorithmName.SHA256; + break; + case HashAlgorithm.Sha384: + han = HashAlgorithmName.SHA384; + break; + case HashAlgorithm.Sha512: + han = HashAlgorithmName.SHA512; + break; + default: + throw new UnsupportedValueException(Settings.HashAlgorithm); + } + + return han; + } + + protected abstract TAlgorithm CreatePrivateKey(); + + protected abstract CertificateRequest DoCreateRequest(string subjectName, TAlgorithm privateKey); + + protected abstract X509Certificate2 JoinPrivateKey(X509Certificate2 publicOnlyCert, TAlgorithm privateKey); + + protected abstract string? GetContainerUniqueName(TAlgorithm privateKey); + + protected abstract TAlgorithm? GetPrivateKey(X509Certificate2 cert); + + private CertificateRequest CreateRequest(CertificateSettings settings, TAlgorithm privateKey) + { + string commonName = CreateCommonName(settings.SubjectName); + CertificateRequest request = DoCreateRequest(commonName, privateKey); + + SubjectAlternativeNameBuilder altNames = new SubjectAlternativeNameBuilder(); + foreach (string subjName in settings.SubjectAlternateNames) + { + altNames.AddDnsName(subjName); + } + request.CertificateExtensions.Add(altNames.Build()); + + if (settings.IsCertificateAuthority) + { + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(settings.IsCertificateAuthority, false, 0, false)); + } + + if (settings.KeyUsage != X509KeyUsageFlags.None) + { + request.CertificateExtensions.Add(new X509KeyUsageExtension(settings.KeyUsage, false)); + } + + return request; + } + + private Task CreateInternalAsync(CertificateSettings settings, CancellationToken cancellationToken) + { + X509Certificate2 cert; + + using (TAlgorithm privateKey = CreatePrivateKey()) + { + CertificateRequest request = CreateRequest(settings, privateKey); + + DateTimeOffset notBefore = DateTimeOffset.UtcNow.AddSeconds(-1); + DateTimeOffset notAfter = DateTimeOffset.UtcNow.Add(settings.ValidityPeriod); + if (settings.Issuer != null) + { + byte[] serial = GenerateSerial(); + X509SignatureGenerator sgen = GetSignatureGenerator(settings.Issuer); + using (X509Certificate2 publicOnlyCert = request.Create(settings.Issuer.SubjectName, sgen, notBefore, notAfter, serial)) + { + cert = JoinPrivateKey(publicOnlyCert, privateKey); + // using (X509Certificate2 temp = JoinPrivateKey(publicOnlyCert, privateKey)) + // { + // // Generated instance of the cert can't be added to cert-store (private key is missing) if requested by the caller. + // // To avoid this recreate the cert + // cert = Recreate(temp, settings); + // } + } + } + else + { + cert = request.CreateSelfSigned(notBefore, notAfter); + // using (X509Certificate2 temp = request.CreateSelfSigned(notBefore, notAfter)) + // { + // // Generated instance of the cert can't be added to cert-store (private key is missing) if requested by the caller. + // // To avoid this recreate the cert + // cert = Recreate(temp, settings); + // } + } + } + + return Task.FromResult(cert); + } + + private X509SignatureGenerator GetSignatureGenerator(X509Certificate2 issuerCertificate) + { + X509SignatureGenerator? sgen = null; + + ECDsa? ecdsa = issuerCertificate.GetECDsaPrivateKey(); + if (ecdsa != null) + { + sgen = X509SignatureGenerator.CreateForECDsa(ecdsa); + } + + if (sgen == null) + { + RSA? rsa = issuerCertificate.GetRSAPrivateKey(); + if (rsa != null) + { + sgen = X509SignatureGenerator.CreateForRSA(rsa, RSASignaturePadding.Pkcs1); + } + } + + if (sgen == null) + { + DSA? dsa = issuerCertificate.GetDSAPrivateKey(); + if (dsa != null) + { + throw new CertGenException("Unsupported type of DSA private-key: '{0}'", dsa.GetType().FullName); + } + else + { + try + { + AsymmetricAlgorithm pk = issuerCertificate.PrivateKey; + throw new CertGenException("Unsupported type of private-key: '{0}'", pk?.GetType().FullName ?? ""); + } + catch (CertGenException) + { + throw; + } + catch + { + throw new CertGenException("Unsupported type of private-key"); + } + + throw new CertGenException("Unsupported type of private-key (no DSA or CAPI)"); + } + } + + return sgen; + } + + private static byte[] GenerateSerial() + { + byte[] serial = new byte[12]; + + using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(serial); + } + + return serial; + } + + private X509Certificate2 Recreate(X509Certificate2 source, CertificateSettings settings) + { + byte[] data = source.Export(X509ContentType.Pfx, string.Empty); + X509KeyStorageFlags flags = X509KeyStorageFlags.Exportable | X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet; + if (!settings.ExportableKeys) + { + flags &= ~X509KeyStorageFlags.Exportable; + } + + X509Certificate2 target = X509CertificateLoader.LoadPkcs12(data, string.Empty); + target.FriendlyName = settings.FriendlyName; + return target; + } + + private string CreateCommonName(string? subjectName) + { + string cn; + + if (subjectName.StartsWith("CN=", StringComparison.OrdinalIgnoreCase)) + { + cn = subjectName; + } + else + { + cn = string.Format("CN={0}", subjectName); + } + + return cn; + } + + public override string ToString() + { + return string.Format("Type = {0}", this is RsaCertificateGenerator ? "RSA" : this is EcdsaCertificateGenerator ? "ECDSA" : ""); + } +} diff --git a/certmgr/CertGen/CertificateManager.cs b/certmgr/CertGen/CertificateManager.cs new file mode 100644 index 0000000..a2343b3 --- /dev/null +++ b/certmgr/CertGen/CertificateManager.cs @@ -0,0 +1,29 @@ +using System.Security.Cryptography.X509Certificates; + +using CertMgr.Core.Exceptions; + +namespace CertMgr.CertGen; + +public sealed class CertificateManager +{ + public async Task CreateAsync(CertificateSettings cs, GeneratorSettings gs, CancellationToken cancellationToken) + { + ICertificateGenerator generator; + + if (gs is EcdsaGeneratorSettings egs) + { + generator = new EcdsaCertificateGenerator(egs); + } + else if (gs is RsaGeneratorSettings rgs) + { + generator = new RsaCertificateGenerator(rgs); + } + else + { + throw new UnsupportedValueException(gs.Type); + } + + X509Certificate2 cert = await generator.CreateAsync(cs, cancellationToken); + return cert; + } +} diff --git a/certmgr/CertGen/CertificateSettings.cs b/certmgr/CertGen/CertificateSettings.cs new file mode 100644 index 0000000..98945d4 --- /dev/null +++ b/certmgr/CertGen/CertificateSettings.cs @@ -0,0 +1,61 @@ +using System.Diagnostics; +using System.Security.Cryptography.X509Certificates; + +using CertMgr.Core.Utils; +using CertMgr.Core.Validation; + +namespace CertMgr.CertGen; + +public sealed class CertificateSettings +{ + internal CertificateSettings() + { + SubjectAlternateNames = new SubjectAlternateNames([NetUtils.MachineName]); + + SubjectName = NetUtils.MachineName; + Issuer = null; + FriendlyName = NetUtils.MachineName; + ValidityPeriod = TimeSpan.FromDays(3650 + 2); + ExportableKeys = true; + IsCertificateAuthority = false; + KeyUsage = X509KeyUsageFlags.None; + } + + public SubjectAlternateNames SubjectAlternateNames { [DebuggerStepThrough] get; } + + public string? SubjectName { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + public X509Certificate2? Issuer { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + public string? FriendlyName { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + public TimeSpan ValidityPeriod { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + public bool ExportableKeys { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + public bool IsCertificateAuthority { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + public X509KeyUsageFlags KeyUsage { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + public Task ValidateAsync(CancellationToken cancellationToken) + { + ValidationResults results = new ValidationResults(); + + if (string.IsNullOrEmpty(SubjectName)) + { + results.AddInvalid(nameof(SubjectName), "must not be empty"); + } + + if (ValidityPeriod < TimeSpan.FromSeconds(1)) + { + results.AddInvalid(nameof(ValidityPeriod), "must be greater than 1sec"); + } + + return Task.FromResult(results); + } + + public override string ToString() + { + return string.Format("issuer = {0}, subject-name = '{1}', validity-period = {2} days, exportable = {3}, is-ca = {4}", Issuer?.Subject ?? "", SubjectAlternateNames.First(), ValidityPeriod.TotalDays, ExportableKeys ? "yes" : "no", IsCertificateAuthority ? "yes" : "no"); + } +} diff --git a/certmgr/CertGen/EcdsaCertificateGenerator.cs b/certmgr/CertGen/EcdsaCertificateGenerator.cs new file mode 100644 index 0000000..85dbd13 --- /dev/null +++ b/certmgr/CertGen/EcdsaCertificateGenerator.cs @@ -0,0 +1,67 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +using CertMgr.Core.Exceptions; + +namespace CertMgr.CertGen; + +internal sealed class EcdsaCertificateGenerator : CertificateGeneratorBase +{ + internal EcdsaCertificateGenerator(EcdsaGeneratorSettings settings) + : base(settings) + { + } + + protected override ECDsa CreatePrivateKey() + { + return ECDsa.Create(GetCurve()); + } + + protected override CertificateRequest DoCreateRequest(string subjectName, ECDsa privateKey) + { + return new CertificateRequest(subjectName, privateKey, GetHashAlgorithm()); + } + + protected override X509Certificate2 JoinPrivateKey(X509Certificate2 publicOnlyCert, ECDsa privateKey) + { + return publicOnlyCert.CopyWithPrivateKey(privateKey); + } + + protected override ECDsa? GetPrivateKey(X509Certificate2 cert) + { + ECDsa? privateKey = cert.GetECDsaPrivateKey(); + return privateKey; + } + + protected override string? GetContainerUniqueName(ECDsa privateKey) + { + return ((ECDsaCng)privateKey).Key.UniqueName; + } + + protected override bool IsEphemeral(ECDsa key) + { + return ((ECDsaCng)key).Key.IsEphemeral; + } + + private ECCurve GetCurve() + { + ECCurve curve; + + switch (Settings.Curve) + { + case EcdsaCurve.P256: + curve = ECCurve.NamedCurves.nistP256; + break; + case EcdsaCurve.P384: + curve = ECCurve.NamedCurves.nistP384; + break; + case EcdsaCurve.P521: + curve = ECCurve.NamedCurves.nistP521; + break; + default: + throw new UnsupportedValueException(Settings.Curve); + } + + return curve; + } +} diff --git a/certmgr/CertGen/EcdsaCurve.cs b/certmgr/CertGen/EcdsaCurve.cs new file mode 100644 index 0000000..b4baf94 --- /dev/null +++ b/certmgr/CertGen/EcdsaCurve.cs @@ -0,0 +1,8 @@ +namespace CertMgr.CertGen; + +public enum EcdsaCurve +{ + P256 = 1, + P384, + P521 +} diff --git a/certmgr/CertGen/EcdsaGeneratorSettings.cs b/certmgr/CertGen/EcdsaGeneratorSettings.cs new file mode 100644 index 0000000..d56e0f3 --- /dev/null +++ b/certmgr/CertGen/EcdsaGeneratorSettings.cs @@ -0,0 +1,44 @@ +using System.Diagnostics; + +using CertMgr.Core.Exceptions; + +namespace CertMgr.CertGen; + +public sealed class EcdsaGeneratorSettings : GeneratorSettings +{ + public EcdsaGeneratorSettings(EcdsaCurve curve) + : base(GeneratorType.Ecdsa) + { + Curve = curve; + HashAlgorithm = GetHashAlgorithm(curve); + } + + public EcdsaCurve Curve { [DebuggerStepThrough] get; } + + private HashAlgorithm GetHashAlgorithm(EcdsaCurve curve) + { + HashAlgorithm ha; + + switch (Curve) + { + case EcdsaCurve.P256: + ha = HashAlgorithm.Sha256; + break; + case EcdsaCurve.P384: + ha = HashAlgorithm.Sha384; + break; + case EcdsaCurve.P521: + ha = HashAlgorithm.Sha512; + break; + default: + throw new UnsupportedValueException(Curve); + } + + return ha; + } + + public override string ToString() + { + return string.Format("type = '{0}', curve = '{1}', hash-algorithm = '{2}'", Type, Curve, HashAlgorithm); + } +} diff --git a/certmgr/CertGen/GeneratorSettings.cs b/certmgr/CertGen/GeneratorSettings.cs new file mode 100644 index 0000000..d56b111 --- /dev/null +++ b/certmgr/CertGen/GeneratorSettings.cs @@ -0,0 +1,20 @@ +using System.Diagnostics; + +namespace CertMgr.CertGen; + +public abstract class GeneratorSettings +{ + protected GeneratorSettings(GeneratorType type) + { + Type = type; + } + + public GeneratorType Type { [DebuggerStepThrough] get; } + + public HashAlgorithm HashAlgorithm { [DebuggerStepThrough] get; [DebuggerStepThrough] protected set; } + + public override string ToString() + { + return string.Format("type = '{0}', hash-algorithm = '{1}'", Type, HashAlgorithm); + } +} diff --git a/certmgr/CertGen/GeneratorType.cs b/certmgr/CertGen/GeneratorType.cs new file mode 100644 index 0000000..ac140f1 --- /dev/null +++ b/certmgr/CertGen/GeneratorType.cs @@ -0,0 +1,7 @@ +namespace CertMgr.CertGen; + +public enum GeneratorType +{ + Ecdsa = 1, + Rsa +} diff --git a/certmgr/CertGen/HashAlgorithm.cs b/certmgr/CertGen/HashAlgorithm.cs new file mode 100644 index 0000000..81f4407 --- /dev/null +++ b/certmgr/CertGen/HashAlgorithm.cs @@ -0,0 +1,8 @@ +namespace CertMgr.CertGen; + +public enum HashAlgorithm +{ + Sha256 = 1, + Sha384, + Sha512 +} diff --git a/certmgr/CertGen/ICertificateGenerator.cs b/certmgr/CertGen/ICertificateGenerator.cs new file mode 100644 index 0000000..2ebbd96 --- /dev/null +++ b/certmgr/CertGen/ICertificateGenerator.cs @@ -0,0 +1,8 @@ +using System.Security.Cryptography.X509Certificates; + +namespace CertMgr.CertGen; + +public interface ICertificateGenerator : IAsyncDisposable +{ + Task CreateAsync(CertificateSettings settings, CancellationToken cancellationToken); +} diff --git a/certmgr/CertGen/RsaCertificateGenerator.cs b/certmgr/CertGen/RsaCertificateGenerator.cs new file mode 100644 index 0000000..7c83930 --- /dev/null +++ b/certmgr/CertGen/RsaCertificateGenerator.cs @@ -0,0 +1,67 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +using CertMgr.Core.Exceptions; + +namespace CertMgr.CertGen; + +internal sealed class RsaCertificateGenerator : CertificateGeneratorBase +{ + internal RsaCertificateGenerator(RsaGeneratorSettings settings) + : base(settings) + { + } + + protected override RSA CreatePrivateKey() + { + return RSA.Create(GetKeySize()); + } + + protected override CertificateRequest DoCreateRequest(string subjectName, RSA privateKey) + { + return new CertificateRequest(subjectName, privateKey, GetHashAlgorithm(), RSASignaturePadding.Pkcs1); + } + + protected override X509Certificate2 JoinPrivateKey(X509Certificate2 publicOnlyCert, RSA privateKey) + { + return publicOnlyCert.CopyWithPrivateKey(privateKey); + } + + protected override RSA? GetPrivateKey(X509Certificate2 cert) + { + RSA? privateKey = cert.GetRSAPrivateKey(); + return privateKey; + } + + protected override string? GetContainerUniqueName(RSA privateKey) + { + return ((RSACng)privateKey).Key.UniqueName; + } + + protected override bool IsEphemeral(RSA key) + { + return ((RSACng)key).Key.IsEphemeral; + } + + private int GetKeySize() + { + int keySize; + + switch (Settings.KeySize) + { + case RsaKeySize.KeySize2048: + keySize = 2048; + break; + case RsaKeySize.KeySize4096: + keySize = 4096; + break; + case RsaKeySize.KeySize8192: + keySize = 8192; + break; + default: + throw new UnsupportedValueException(Settings.KeySize); + } + + return keySize; + } +} diff --git a/certmgr/CertGen/RsaGeneratorSettings.cs b/certmgr/CertGen/RsaGeneratorSettings.cs new file mode 100644 index 0000000..2ad7d89 --- /dev/null +++ b/certmgr/CertGen/RsaGeneratorSettings.cs @@ -0,0 +1,45 @@ +using System.Diagnostics; + +using CertMgr.Core.Exceptions; + +namespace CertMgr.CertGen; + +public sealed class RsaGeneratorSettings : GeneratorSettings +{ + public RsaGeneratorSettings(RsaKeySize keySize) + : base(GeneratorType.Rsa) + { + KeySize = keySize; + + HashAlgorithm = GetHashAlgorithm(keySize); + } + + public RsaKeySize KeySize { [DebuggerStepThrough] get; } + + private HashAlgorithm GetHashAlgorithm(RsaKeySize keySize) + { + HashAlgorithm ha; + + switch (keySize) + { + case RsaKeySize.KeySize2048: + ha = HashAlgorithm.Sha256; + break; + case RsaKeySize.KeySize4096: + ha = HashAlgorithm.Sha384; + break; + case RsaKeySize.KeySize8192: + ha = HashAlgorithm.Sha512; + break; + default: + throw new UnsupportedValueException(keySize); + } + + return ha; + } + + public override string ToString() + { + return string.Format("type = '{0}', key-size = '{1}', hash-algorithm = '{2}'", Type, KeySize, HashAlgorithm); + } +} diff --git a/certmgr/CertGen/RsaKeySize.cs b/certmgr/CertGen/RsaKeySize.cs new file mode 100644 index 0000000..87e6bad --- /dev/null +++ b/certmgr/CertGen/RsaKeySize.cs @@ -0,0 +1,8 @@ +namespace CertMgr.CertGen; + +public enum RsaKeySize +{ + KeySize2048 = 1, + KeySize4096, + KeySize8192 +} diff --git a/certmgr/CertGen/SubjectAlternateNames.cs b/certmgr/CertGen/SubjectAlternateNames.cs new file mode 100644 index 0000000..73da242 --- /dev/null +++ b/certmgr/CertGen/SubjectAlternateNames.cs @@ -0,0 +1,41 @@ +using System.Collections; + +namespace CertMgr.CertGen; + +public sealed class SubjectAlternateNames : IReadOnlyCollection +{ + private readonly HashSet _items; + + public SubjectAlternateNames() + { + _items = new HashSet(StringComparer.OrdinalIgnoreCase); + } + + public SubjectAlternateNames(IReadOnlyCollection items) + { + _items = new HashSet(items, StringComparer.OrdinalIgnoreCase); + } + + public int Count => _items.Count; + + public bool Add(string name) + { + bool added = _items.Add(name); + return added; + } + + public IEnumerator GetEnumerator() + { + return _items.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public override string ToString() + { + return string.Format("count = {0}", _items.Count); + } +} diff --git a/certmgr/CertGen/Utils/CollectionEquivalencyComparer.cs b/certmgr/CertGen/Utils/CollectionEquivalencyComparer.cs new file mode 100644 index 0000000..1708e80 --- /dev/null +++ b/certmgr/CertGen/Utils/CollectionEquivalencyComparer.cs @@ -0,0 +1,26 @@ +using CertMgr.Core.Utils; + +namespace CertMgr.CertGen.Utils; + +public sealed class CollectionEquivalencyComparer : IEqualityComparer> +{ + public bool Equals(IEnumerable? x, IEnumerable? y) + { + if (x == y) + { + return true; + } + if (x == null || y == null) + { + return false; + } + + bool equivalent = x.Equivalent(y); + return equivalent; + } + + public int GetHashCode(IEnumerable obj) + { + return obj?.Sum(it => it?.GetHashCode() ?? 0) ?? 0; + } +} diff --git a/certmgr/CertGen/Utils/StorageToX509CertificateAdapter.cs b/certmgr/CertGen/Utils/StorageToX509CertificateAdapter.cs new file mode 100644 index 0000000..89c9567 --- /dev/null +++ b/certmgr/CertGen/Utils/StorageToX509CertificateAdapter.cs @@ -0,0 +1,49 @@ +/*using System.Security.Cryptography.X509Certificates; + +using CertMgr.Core.Storage; + +namespace CertMgr.CertGen.Utils; + +public sealed class StorageToX509CertificateAdapter : StorageAdapter +{ + private readonly X509Certificate2? _cert; + private readonly string _password; + private readonly X509KeyStorageFlags? _flags; + + public StorageToX509CertificateAdapter(X509Certificate2 cert, string? password) + { + _cert = cert; + _password = password ?? string.Empty; + } + + public StorageToX509CertificateAdapter(string? password, X509KeyStorageFlags flags) + { + _password = password ?? string.Empty; + _flags = flags; + } + + protected override async Task DoReadAsync(IStorage source, CancellationToken cancellationToken) + { + X509Certificate2 cert; + + using (MemoryStream ms = new MemoryStream()) + { + await source.ReadAsync(ms, cancellationToken); + cert = X509CertificateLoader.LoadPkcs12(ms.GetBuffer(), _password, _flags.Value); + } + + return cert; + } + + protected override async Task DoWriteAsync(IStorage target, CancellationToken cancellationToken) + { + byte[] data = _cert.Export(X509ContentType.Pfx, _password); + using (MemoryStream ms = new MemoryStream()) + { + await ms.WriteAsync(data, cancellationToken); + ms.Position = 0; + return await target.WriteAsync(ms, cancellationToken).ConfigureAwait(false); + } + } +} +*/ \ No newline at end of file diff --git a/certmgr/Core/Attributes/SettingAttribute.cs b/certmgr/Core/Attributes/SettingAttribute.cs new file mode 100644 index 0000000..5736a00 --- /dev/null +++ b/certmgr/Core/Attributes/SettingAttribute.cs @@ -0,0 +1,29 @@ +using System.Diagnostics; + +namespace CertMgr.Core.Attributes; + +public sealed class SettingAttribute : Attribute +{ + public SettingAttribute(string name) + { + Name = name; + IsMandatory = false; + } + + public string Name { [DebuggerStepThrough] get; } + + public string[] AlternateNames { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + public bool IsMandatory { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + public Type? Validator { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + public Type? Converter { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + public object? Default { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + public override string ToString() + { + return string.Format("name = '{0}', is-mandatory = {1}, validator = {2}, converter = {3}", Name, IsMandatory ? "yes" : "no", Validator?.GetType().Name ?? "", Converter?.GetType().Name ?? ""); + } +} diff --git a/certmgr/Core/CertMgrException.cs b/certmgr/Core/CertMgrException.cs new file mode 100644 index 0000000..d8c6d6e --- /dev/null +++ b/certmgr/Core/CertMgrException.cs @@ -0,0 +1,15 @@ +using CertMgr.Core.Utils; + +namespace CertMgr.Core; + +public class CertMgrException : Exception +{ + internal CertMgrException(string messageFormat, params object[] messageArgs) + : base(StringFormatter.Format(messageFormat, messageArgs)) + { + } + internal CertMgrException(Exception innerException, string messageFormat, params object[] messageArgs) + : base(StringFormatter.Format(messageFormat, messageArgs), innerException) + { + } +} diff --git a/certmgr/Core/Cli/CliException.cs b/certmgr/Core/Cli/CliException.cs new file mode 100644 index 0000000..9244ab0 --- /dev/null +++ b/certmgr/Core/Cli/CliException.cs @@ -0,0 +1,11 @@ +using CertMgr.Core.Utils; + +namespace CertMgr.Core.Cli; + +public class CliException : Exception +{ + public CliException(string messageFormat, params object[] messageArgs) + : base(StringFormatter.Format(messageFormat, messageArgs)) + { + } +} diff --git a/certmgr/Core/Cli/CliParser.cs b/certmgr/Core/Cli/CliParser.cs new file mode 100644 index 0000000..b59edbb --- /dev/null +++ b/certmgr/Core/Cli/CliParser.cs @@ -0,0 +1,61 @@ +using CertMgr.Core.Log; + +namespace CertMgr.Core.Cli; + +public sealed class CliParser +{ + public Task ParseAsync(string[] args, CancellationToken cancellationToken) + { + CLog.Info("parsing arguments..."); + + RawArguments rawArgs = new RawArguments(args.Length); + + foreach (string arg in args) + { + int nameStartIndex = -1; + if (arg.StartsWith("--")) + { + nameStartIndex = 2; + } + else if (arg.StartsWith("-")) + { + nameStartIndex = 1; + } + + if (nameStartIndex < 0) + { + continue; + } + + int separatorIndex = arg.IndexOf('=', nameStartIndex); + if (separatorIndex < 0) + { + // --my-value + string name = arg.Substring(nameStartIndex); + rawArgs.Add(new RawArgument(name)); + } + else if (separatorIndex == nameStartIndex + 1) + { + // --= or --=xxx => name missing + } + else + { + // --myvalue= or --my-value=xxx + string name = arg.Substring(nameStartIndex, separatorIndex - nameStartIndex); + if (separatorIndex < arg.Length) + { + string value = arg.Substring(separatorIndex + 1); + rawArgs.Add(new RawArgument(name, value)); + } + else + { + rawArgs.Add(new RawArgument(name)); + } + } + } + + CLog.Info("parsing arguments... done (found {0} arguments)", rawArgs.Count); + + return Task.FromResult(rawArgs); + } +} diff --git a/certmgr/Core/Cli/RawArgument.cs b/certmgr/Core/Cli/RawArgument.cs new file mode 100644 index 0000000..3f7b877 --- /dev/null +++ b/certmgr/Core/Cli/RawArgument.cs @@ -0,0 +1,58 @@ +using System.Diagnostics; + +namespace CertMgr.Core.Cli; + +public sealed class RawArgument +{ + internal RawArgument(string name) + { + Name = name; + Values = new List(0); + } + + internal RawArgument(string name, string value) + { + Name = name; + Values = new List { value }; + } + + internal RawArgument(string name, IReadOnlyList values, string value) + { + Name = name; + List tmp = new List(values.Count + 1); + Values = tmp; + tmp.AddRange(values); + tmp.Add(value); + } + + internal RawArgument(string name, IReadOnlyList values1, IReadOnlyList values2) + { + Name = name; + List tmp = new List(values1.Count + values2.Count); + Values = tmp; + tmp.AddRange(values1); + tmp.AddRange(values2); + } + + public string Name { [DebuggerStepThrough] get; } + + public IReadOnlyList Values { [DebuggerStepThrough] get; } + + public override string ToString() + { + string result; + switch (Values.Count) + { + case 0: + result = string.Format("name = '{0}', no-values", Name); + break; + case 1: + result = string.Format("name = '{0}', value = '{1}'", Name, Values.First() ?? ""); + break; + default: + result = string.Format("name = '{0}', first-value = '{1}', values-count = '{2}'", Name, Values[0] ?? "", Values.Count); + break; + } + return result; + } +} diff --git a/certmgr/Core/Cli/RawArguments.cs b/certmgr/Core/Cli/RawArguments.cs new file mode 100644 index 0000000..da343d1 --- /dev/null +++ b/certmgr/Core/Cli/RawArguments.cs @@ -0,0 +1,61 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; + +namespace CertMgr.Core.Cli; + +public sealed class RawArguments : IReadOnlyCollection +{ + private Dictionary _items; + + internal RawArguments() + { + _items = new Dictionary(); + } + + internal RawArguments(int initialCapacity) + { + _items = new Dictionary(initialCapacity); + } + + public int Count => _items.Count; + + public IReadOnlyList Names => _items.Keys.ToList(); + + public RawArgument this[string name] + { + get => _items[name]; + set => _items[name] = value; + } + + internal void Add(RawArgument rawArg) + { + if (_items.Remove(rawArg.Name, out RawArgument? existing)) + { + _items.Add(rawArg.Name, new RawArgument(rawArg.Name, existing.Values, rawArg.Values)); + } + else + { + _items.Add(rawArg.Name, rawArg); + } + } + + public bool TryGet(string name, [NotNullWhen(true)] out RawArgument? rawArg) + { + return _items.TryGetValue(name, out rawArg); + } + + public IEnumerator GetEnumerator() + { + return _items.Values.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public override string ToString() + { + return string.Format("count = {0}", _items.Count); + } +} diff --git a/certmgr/Core/Converters/ConverterContext.cs b/certmgr/Core/Converters/ConverterContext.cs new file mode 100644 index 0000000..7c230b4 --- /dev/null +++ b/certmgr/Core/Converters/ConverterContext.cs @@ -0,0 +1,6 @@ +namespace CertMgr.Core.Converters; + +public class ConverterContext +{ + public static readonly ConverterContext Empty = new ConverterContext(); +} diff --git a/certmgr/Core/Converters/ConverterException.cs b/certmgr/Core/Converters/ConverterException.cs new file mode 100644 index 0000000..9baa568 --- /dev/null +++ b/certmgr/Core/Converters/ConverterException.cs @@ -0,0 +1,15 @@ +using CertMgr.Core.Utils; + +namespace CertMgr.Core.Converters; + +public sealed class ConverterException : Exception +{ + internal ConverterException(string messageFormat, params object?[] messageArgs) + : base(StringFormatter.Format(messageFormat, messageArgs)) + { + } + internal ConverterException(Exception? innerException, string messageFormat, params object?[] messageArgs) + : base(StringFormatter.Format(messageFormat, messageArgs), innerException) + { + } +} diff --git a/certmgr/Core/Converters/ConverterFactory.cs b/certmgr/Core/Converters/ConverterFactory.cs new file mode 100644 index 0000000..7143f27 --- /dev/null +++ b/certmgr/Core/Converters/ConverterFactory.cs @@ -0,0 +1,19 @@ +using CertMgr.Core.Converters.Impl; + +namespace CertMgr.Core.Converters; + +public sealed class ConverterFactory +{ + internal ConverterFactory() + { + } + + public ConverterStash CreateDefault() + { + ConverterStash stash = new ConverterStash(); + stash.Set(typeof(int), typeof(IntConverter)); + stash.Set(typeof(string), typeof(StringConverter)); + stash.Set(typeof(Enum), typeof(EnumConverter)); + return stash; + } +} diff --git a/certmgr/Core/Converters/ConverterStash.cs b/certmgr/Core/Converters/ConverterStash.cs new file mode 100644 index 0000000..24dc7eb --- /dev/null +++ b/certmgr/Core/Converters/ConverterStash.cs @@ -0,0 +1,102 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace CertMgr.Core.Converters; + +public sealed class ConverterStash +{ + private readonly Dictionary _items; + + internal ConverterStash() + { + _items = new Dictionary(); + } + + public bool Set(Type conversionType, Type converterType, bool replace = false) + { + bool added = false; + + if (_items.ContainsKey(conversionType)) + { + if (replace) + { + _items[conversionType] = new ConverterDescriptor(converterType); + added = true; + } + } + else + { + _items.Add(conversionType, new ConverterDescriptor(converterType)); + added = true; + } + + return added; + } + + public bool TryGet(Type type, [NotNullWhen(true)] out IValueConverter? converter) + { + converter = null; + + if (_items.TryGetValue(type, out ConverterDescriptor? descriptor)) + { + converter = descriptor.Instance; + } + else if (type.IsEnum) + { + if (_items.TryGetValue(typeof(Enum), out descriptor)) + { + converter = descriptor.Instance; + } + } + + return converter != null; + } + + public override string ToString() + { + return string.Format("count = {0}", _items.Count); + } + + private sealed class ConverterDescriptor + { + private IValueConverter? _converter; + + public ConverterDescriptor(Type converterType) + { + ConverterType = converterType; + } + + public Type ConverterType { [DebuggerStepThrough] get; } + + public IValueConverter Instance + { + get + { + if (_converter == null) + { + lock (this) + { + if (_converter == null) + { + try + { + _converter = (IValueConverter?)Activator.CreateInstance(ConverterType); + } + catch (Exception e) + { + throw new ConverterException(e, "Failed to create instance of converter of type '{0}'", ConverterType.Name); + } + } + } + } + + if (_converter == null) + { + throw new ConverterException("Failed to create instance of converter of type '{0}'", ConverterType.Name); + } + + return _converter; + } + } + } +} diff --git a/certmgr/Core/Converters/EnumConverterContext.cs b/certmgr/Core/Converters/EnumConverterContext.cs new file mode 100644 index 0000000..eab1eb3 --- /dev/null +++ b/certmgr/Core/Converters/EnumConverterContext.cs @@ -0,0 +1,13 @@ +using System.Diagnostics; + +namespace CertMgr.Core.Converters; + +public class EnumConverterContext : ConverterContext +{ + internal EnumConverterContext(Type targetType) + { + TargetType = targetType; + } + + public Type TargetType { [DebuggerStepThrough] get; } +} diff --git a/certmgr/Core/Converters/IValueConverter.cs b/certmgr/Core/Converters/IValueConverter.cs new file mode 100644 index 0000000..e741b77 --- /dev/null +++ b/certmgr/Core/Converters/IValueConverter.cs @@ -0,0 +1,6 @@ +namespace CertMgr.Core.Converters; + +public interface IValueConverter +{ + Task ConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken); +} diff --git a/certmgr/Core/Converters/IValueConverterT.cs b/certmgr/Core/Converters/IValueConverterT.cs new file mode 100644 index 0000000..d3d1d3e --- /dev/null +++ b/certmgr/Core/Converters/IValueConverterT.cs @@ -0,0 +1,6 @@ +namespace CertMgr.Core.Converters; + +public interface IValueConverter : IValueConverter +{ + new Task ConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken); +} diff --git a/certmgr/Core/Converters/Impl/EnumConverter.cs b/certmgr/Core/Converters/Impl/EnumConverter.cs new file mode 100644 index 0000000..fe8c81c --- /dev/null +++ b/certmgr/Core/Converters/Impl/EnumConverter.cs @@ -0,0 +1,43 @@ +using CertMgr.Core.Utils; + +namespace CertMgr.Core.Converters.Impl; + +public sealed class EnumConverter : ValueConverter +{ + protected override Task DoConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken) + { + Type resultType; + + bool isNullable = targetType.IsGenericType && targetType.GetGenericTypeDefinition() == typeof(Nullable<>); + if (isNullable) + { + Type[] args = targetType.GetGenericArguments(); + if (args.Length != 1) + { + throw new ConverterException("Cannot convert nullable type '{0}' to enum", targetType.ToString(false)); + } + if (args[0].IsEnum) + { + resultType = args[0]; + } + else + { + throw new ConverterException("Cannot convert nullable type '{0}' as enum as it is not enum", targetType.ToString(false)); + } + } + else + { + if (!targetType.IsEnum) + { + resultType = targetType; + } + else + { + throw new ConverterException("Cannot convert type '{0}' as enum as it is not enum", targetType.ToString(false)); + } + } + + object? result = Enum.Parse(resultType, rawValue, true); + return Task.FromResult((Enum?)result); + } +} diff --git a/certmgr/Core/Converters/Impl/EnumNullableConverter.cs b/certmgr/Core/Converters/Impl/EnumNullableConverter.cs new file mode 100644 index 0000000..bed3c59 --- /dev/null +++ b/certmgr/Core/Converters/Impl/EnumNullableConverter.cs @@ -0,0 +1,15 @@ +namespace CertMgr.Core.Converters.Impl; + +public sealed class EnumNullableConverter : ValueConverter +{ + protected override Task DoConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken) + { + if (!targetType.IsEnum) + { + throw new ConverterException("Cannot convert type '{0}' as enum as it is not enum", targetType.Name); + } + + object? result = Enum.Parse(targetType, rawValue, true); + return Task.FromResult((Enum?)result); + } +} diff --git a/certmgr/Core/Converters/Impl/IntConverter.cs b/certmgr/Core/Converters/Impl/IntConverter.cs new file mode 100644 index 0000000..bf9d79e --- /dev/null +++ b/certmgr/Core/Converters/Impl/IntConverter.cs @@ -0,0 +1,10 @@ +namespace CertMgr.Core.Converters.Impl; + +public sealed class IntConverter : ValueConverter +{ + protected override Task DoConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken) + { + int result = int.Parse(rawValue); + return Task.FromResult(result); + } +} diff --git a/certmgr/Core/Converters/Impl/StorageConverter.cs b/certmgr/Core/Converters/Impl/StorageConverter.cs new file mode 100644 index 0000000..bc334d8 --- /dev/null +++ b/certmgr/Core/Converters/Impl/StorageConverter.cs @@ -0,0 +1,121 @@ +using System.Diagnostics.CodeAnalysis; + +using CertMgr.Core.Log; +using CertMgr.Core.Storage; + +namespace CertMgr.Core.Converters.Impl; + +internal sealed class StorageConverter : ValueConverter +{ + private const char Separator = '|'; + + protected override Task DoConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken) + { + ReadOnlySpan rawSpan = rawValue.AsSpan(); + + int storageTypeSplitIndex = rawSpan.IndexOf(Separator); + if (storageTypeSplitIndex == -1) + { + return Task.FromResult((IStorage?)EmptyStorage.Empty); + } + + IStorage? storage; + + ReadOnlySpan storageTypeSpan = rawSpan.Slice(0, storageTypeSplitIndex); + ReadOnlySpan storageDefinition = rawSpan.Slice(storageTypeSplitIndex + 1); + switch (storageTypeSpan) + { + case "file": + if (!TryGetFileStore(storageDefinition, out storage)) + { + storage = EmptyStorage.Empty; + } + break; + default: + storage = EmptyStorage.Empty; + break; + } + + return Task.FromResult((IStorage?)storage); + } + + private bool TryGetFileStore(ReadOnlySpan storageDefinition, [NotNullWhen(true)] out IStorage? storage) + { + // expecting that 'storageDefinition' is something like: + // | + // or + // | + // or + // + // + // where can be: + // 'o' or 'overwrite' or 'overwriteornew' + // or + // 'a' or 'append' or 'appendornew' + // or + // 'c' or 'create' or 'createnew' + // + // where can be: + // 'c:\path\myfile.txt' + // or + // '/path/myfile.txt' + // or + // './path/myfile.txt' + // in addition - can be either quoted or double-quoted + + storage = null; + + FileStoreMode defaultStoreMode = FileStoreMode.OverwriteOrNew; + FileStoreMode storeMode; + ReadOnlySpan filename; + + int firstSplitIndex = storageDefinition.IndexOf(Separator); + if (firstSplitIndex != -1) + { + // there is a splitter. On the left side of it there must be . On the right there is a + + ReadOnlySpan firstPart = storageDefinition.Slice(0, firstSplitIndex); + filename = storageDefinition.Slice(firstSplitIndex + 1); + + if (firstPart.Length == 0) + { + storeMode = defaultStoreMode; + } + else if (Enum.TryParse(firstPart, true, out FileStoreMode tmpMode)) + { + storeMode = tmpMode; + } + else if (firstPart.Equals("w", StringComparison.OrdinalIgnoreCase) || firstPart.Equals("overwrite", StringComparison.OrdinalIgnoreCase)) + { + storeMode = FileStoreMode.OverwriteOrNew; + } + else if (firstPart.Equals("c", StringComparison.OrdinalIgnoreCase) || firstPart.Equals("createnew", StringComparison.OrdinalIgnoreCase)) + { + storeMode = FileStoreMode.Create; + } + else if (firstPart.Equals("a", StringComparison.OrdinalIgnoreCase) || firstPart.Equals("append", StringComparison.OrdinalIgnoreCase)) + { + storeMode = FileStoreMode.AppendOrNew; + } + else if (firstPart.Equals("o", StringComparison.OrdinalIgnoreCase) || firstPart.Equals("open", StringComparison.OrdinalIgnoreCase)) + { + storeMode = FileStoreMode.Open; + } + else + { + // it is not store-mode or there is a typo or unsupported value + CLog.Error(string.Concat("Unsupported store-mode '", firstPart, "'")); + storeMode = defaultStoreMode; + } + } + else + { + // no split-char => just filename + storeMode = defaultStoreMode; + filename = storageDefinition; + } + + storage = new FileStorage(filename.ToString(), storeMode); + return true; + } +} diff --git a/certmgr/Core/Converters/Impl/StringConverter.cs b/certmgr/Core/Converters/Impl/StringConverter.cs new file mode 100644 index 0000000..4f66aa9 --- /dev/null +++ b/certmgr/Core/Converters/Impl/StringConverter.cs @@ -0,0 +1,10 @@ +namespace CertMgr.Core.Converters.Impl; + +public sealed class StringConverter : ValueConverter +{ + protected override Task DoConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken) + { + return Task.FromResult(rawValue); + } +} + diff --git a/certmgr/Core/Converters/Impl/TimeSpanConverter.cs b/certmgr/Core/Converters/Impl/TimeSpanConverter.cs new file mode 100644 index 0000000..9b54015 --- /dev/null +++ b/certmgr/Core/Converters/Impl/TimeSpanConverter.cs @@ -0,0 +1,49 @@ +namespace CertMgr.Core.Converters.Impl; + +public sealed class TimeSpanConverter : ValueConverter +{ + protected override Task DoConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(rawValue) || rawValue.Length == 1) + { + return Task.FromResult((TimeSpan?)null); + } + + ReadOnlySpan rawSpan = rawValue.AsSpan(); + ReadOnlySpan valueSpan = rawSpan.Slice(0, rawSpan.Length - 1); + char unit = char.ToLower(rawValue[rawValue.Length - 1]); + + TimeSpan? result; + + if (int.TryParse(valueSpan, out int value)) + { + switch (unit) + { + case 's': + result = TimeSpan.FromSeconds(value); + break; + case 'm': + result = TimeSpan.FromMinutes(value); + break; + case 'h': + result = TimeSpan.FromHours(value); + break; + case 'd': + result = TimeSpan.FromDays(value); + break; + case 'y': + result = TimeSpan.FromDays(value * 365); + break; + default: + result = null; + break; + } + } + else + { + result = null; + } + + return Task.FromResult(result); + } +} diff --git a/certmgr/Core/Converters/ValueConverter.cs b/certmgr/Core/Converters/ValueConverter.cs new file mode 100644 index 0000000..8444135 --- /dev/null +++ b/certmgr/Core/Converters/ValueConverter.cs @@ -0,0 +1,11 @@ +namespace CertMgr.Core.Converters; + +public abstract class ValueConverter : IValueConverter +{ + public Task ConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken) + { + return DoConvertAsync(rawValue, targetType, cancellationToken); + } + + protected abstract Task DoConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken); +} diff --git a/certmgr/Core/Converters/ValueConverterT.cs b/certmgr/Core/Converters/ValueConverterT.cs new file mode 100644 index 0000000..52ce739 --- /dev/null +++ b/certmgr/Core/Converters/ValueConverterT.cs @@ -0,0 +1,30 @@ +namespace CertMgr.Core.Converters; + +public abstract class ValueConverter : IValueConverter +{ + public Task ConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken) + { + if (typeof(T) != targetType) + { + throw new ConverterException("This converter ('{0}') converts string value to type '{1}' but type '{2}' is expected this time", GetType().Name, typeof(T), targetType.Name); + } + + try + { + return DoConvertAsync(rawValue, targetType, cancellationToken); + } + catch (Exception e) + { + throw new ConverterException(e, "Failed to convert value '{0}' to type '{1}'", rawValue ?? "", typeof(T).Name); + } + + } + + async Task IValueConverter.ConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken) + { + T? typedValue = await DoConvertAsync(rawValue, targetType, cancellationToken); + return typedValue; + } + + protected abstract Task DoConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken); +} diff --git a/certmgr/Core/Exceptions/UnsupportedValueException.cs b/certmgr/Core/Exceptions/UnsupportedValueException.cs new file mode 100644 index 0000000..22c927a --- /dev/null +++ b/certmgr/Core/Exceptions/UnsupportedValueException.cs @@ -0,0 +1,9 @@ +namespace CertMgr.Core.Exceptions; + +public class UnsupportedValueException : Exception +{ + public UnsupportedValueException(object unsupportedValue) + : base(string.Format("Unsupported value '{0}' of type '{1}'", unsupportedValue != null ? unsupportedValue.ToString() : "", unsupportedValue != null ? unsupportedValue.GetType().FullName : "")) + { + } +} diff --git a/certmgr/Core/JobDescriptor.cs b/certmgr/Core/JobDescriptor.cs new file mode 100644 index 0000000..b32983c --- /dev/null +++ b/certmgr/Core/JobDescriptor.cs @@ -0,0 +1,24 @@ +using System.Diagnostics; + +namespace CertMgr.Core; + +internal sealed class JobDescriptor +{ + internal JobDescriptor(Type jobType, Type settingsType, Type? resultType) + { + JobType = jobType; + SettingsType = settingsType; + ResultType = resultType; + } + + public Type JobType { [DebuggerStepThrough] get; } + + public Type SettingsType { [DebuggerStepThrough] get; } + + public Type? ResultType { [DebuggerStepThrough] get; } + + public override string ToString() + { + return string.Format("job-type = '{0}', settings-type = '{1}', result-type = '{2}'", JobType.Name, SettingsType.Name, ResultType?.Name ?? ""); + } +} \ No newline at end of file diff --git a/certmgr/Core/JobExecutor.cs b/certmgr/Core/JobExecutor.cs new file mode 100644 index 0000000..479f1c7 --- /dev/null +++ b/certmgr/Core/JobExecutor.cs @@ -0,0 +1,125 @@ +using System.Reflection; +using System.Text; + +using CertMgr.Core.Cli; +using CertMgr.Core.Jobs; +using CertMgr.Core.Log; +using CertMgr.Core.Utils; +using CertMgr.Core.Validation; + +namespace CertMgr.Core; + +internal sealed class JobExecutor +{ + public async Task ExecuteAsync(string[] args, CancellationToken cancellationToken) + { + int errorLevel = JobResult.Failure; + + CliParser parser = new CliParser(); + RawArguments rawArgs = await parser.ParseAsync(args, cancellationToken).ConfigureAwait(false); + if (rawArgs.TryGet("job", out RawArgument? rawArg)) + { + JobRegistry jobs = new JobRegistry(); + await jobs.LoadAsync(cancellationToken).ConfigureAwait(false); + + if (jobs.TryGet(rawArg.Values.First(), out JobDescriptor? descriptor)) + { + IJob job = await CreateJobAsync(descriptor, rawArgs, cancellationToken).ConfigureAwait(false); + + JobResult result = await job.ExecuteAsync(cancellationToken).ConfigureAwait(false); + errorLevel = result.ErrorLevel; + } + else + { + CLog.Error("Unknown job '{0}'", rawArg.Values.First()); + errorLevel = JobResult.Failure; + } + } + else + { + CLog.Error("Argument '{0}' must be specified", "job"); + errorLevel = JobResult.Failure; + } + + return errorLevel; + } + + private async Task CreateJobAsync(JobDescriptor descriptor, RawArguments rawArgs, CancellationToken cancellationToken) + { + IJob? job; + + try + { + job = (IJob?)Activator.CreateInstance(descriptor.JobType); + } + catch (Exception e) + { + throw new CertMgrException(e, "Failed to instantiate job '{0}'", descriptor.JobType.Name); + } + if (job == null) + { + throw new CertMgrException("Failed to instantiate job '{0}' (Possibly missing parameterless ctor?)", descriptor.JobType.Name); + } + + SettingsBuilder settingsBuilder = new SettingsBuilder(rawArgs, descriptor.SettingsType); + JobSettings settings = await settingsBuilder.LoadAsync(cancellationToken).ConfigureAwait(false); + + if (!settings.ValidationResults.IsValid) + { + StringBuilderCache.ScopedBuilder lease = StringBuilderCache.AcquireScoped(); + StringBuilder sb = lease.Builder; + + sb.AppendFormat("Validation of settings for job '{0}' found {1} error(s):", job.Name, settings.ValidationResults.Count(vr => !vr.IsValid)); + sb.AppendLine(); + foreach (ValidationResult vr in settings.ValidationResults) + { + if (vr.IsValid) + { + continue; + } + sb.AppendFormat("\t- {0}: {1}", vr.PropertyName, vr.Justification); + sb.AppendLine(); + } + throw new CertMgrException(sb.ToString()); + } + + MethodInfo? settingsSetter = GetPropertyWithPrivateSetter(descriptor.JobType, "Settings"); + if (settingsSetter != null) + { + settingsSetter.Invoke(job, [settings]); + } + else + { + throw new CertMgrException("Failed to initialize job '{0}'. Missing property 'Settings' (Possibly the job doesn't inherit from '{1}'?)", descriptor.JobType.Name, typeof(Job<>).Name); + } + + return job; + } + + private static MethodInfo? GetPropertyWithPrivateSetter(Type type, string name) + { + MethodInfo? setter = null; + + for (Type currentType = type; currentType != null; currentType = currentType.BaseType) + { + if (currentType.ContainsGenericParameters) + { + continue; + } + + PropertyInfo? property = currentType.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly); + if (property == null) + { + continue; + } + + setter = property.GetSetMethod(true); + if (setter != null) + { + break; + } + } + + return setter; + } +} diff --git a/certmgr/Core/JobRegistry.cs b/certmgr/Core/JobRegistry.cs new file mode 100644 index 0000000..29ae281 --- /dev/null +++ b/certmgr/Core/JobRegistry.cs @@ -0,0 +1,153 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text; + +using CertMgr.Core.Jobs; +using CertMgr.Core.Log; +using CertMgr.Core.Utils; + +namespace CertMgr.Core; + +internal sealed class JobRegistry +{ + private readonly Dictionary _items; + + public JobRegistry() + { + _items = new Dictionary(); + } + + public IReadOnlyList Names => _items.Keys.ToList(); + + public Task LoadAsync(CancellationToken cancellationToken) + { + CLog.Info("Loading job registry..."); + + if (TryGetAssemblyTypes(Assembly.GetExecutingAssembly(), out Type[] types)) + { + foreach (Type type in types) + { + if (type.IsAbstract) + { + continue; + } + + JobUtils.TryGetJobName(type, out string? jobName, out string? errorMessage); + TryGetGenericTypes(type, out Type? settingsType, out Type? resultType); + if (jobName != null && settingsType != null) + { + if (_items.TryGetValue(jobName, out JobDescriptor? alreadyRegisteredType)) + { + CLog.Error("Job '{0}' has multiple implementations. Types '{0}' and '{1}'. First win.", jobName, alreadyRegisteredType.JobType.ToString(false), type.ToString(false)); + } + else + { + _items.Add(jobName, new JobDescriptor(type, settingsType, resultType)); + } + } + else + { + if (jobName == null && settingsType != null) + { + CLog.Error(errorMessage); + } + if (jobName != null && settingsType == null) + { + CLog.Error("Job '{0}' has job-name '{1}' but the class doesn't inherit from '{2}'", type.ToString(false), jobName, typeof(Job<>).ToString(false)); + } + } + } + } + + CLog.Info("Loading job registry... done (registered {0} jobs)", _items.Count); + + return Task.CompletedTask; + } + + public bool TryGet(string name, [NotNullWhen(true)] out JobDescriptor? jobDescriptor) + { + jobDescriptor = null; + + if (_items.TryGetValue(name, out JobDescriptor? descriptor)) + { + jobDescriptor = descriptor; + } + + return jobDescriptor != null; + } + + private bool TryGetGenericTypes(Type type, [NotNullWhen(true)] out Type? settingType, out Type? resultType) + { + settingType = null; + resultType = null; + + for (Type t = type; t != null && type != typeof(object); t = t.BaseType!) + { + if (t.IsGenericType) + { + Type[] genericTypes = t.GetGenericArguments(); + Type currentGenericType = t.GetGenericTypeDefinition(); + if (currentGenericType == typeof(Job<,>)) + { + if (typeof(JobSettings).IsAssignableFrom(genericTypes[0])) + { + settingType = genericTypes[0]; + resultType = genericTypes[1]; + } + else + { + settingType = genericTypes[1]; + resultType = genericTypes[0]; + } + } + else if (currentGenericType == typeof(JobBase<>)) + { + if (typeof(JobSettings).IsAssignableFrom(genericTypes[0])) + { + settingType = genericTypes[0]; + break; + } + } + } + } + + return settingType != null; + } + + private bool TryGetAssemblyTypes(Assembly assembly, out Type[] types) + { + bool succeeded; + + try + { + types = assembly.GetTypes(); + succeeded = true; + } + catch (ReflectionTypeLoadException e) + { + using StringBuilderCache.ScopedBuilder lease = StringBuilderCache.AcquireScoped(256); + StringBuilder sb = lease.Builder; + sb.AppendFormat("Failed to load assembly types. {0}: {1} (exceptions-count = {2}).{3}", e.GetType().ToString(false), e.Message, e.LoaderExceptions?.Length ?? -1, Environment.NewLine); + foreach (Exception? le in e.LoaderExceptions ?? Array.Empty()) + { + if (le == null) + { + continue; + } + sb.AppendFormat("\t{0}: {1}{2}", le.GetType().ToString(false), le.Message, Environment.NewLine); + } + CLog.Error(StringBuilderCache.GetStringAndRelease(sb)); + + types = Array.Empty(); + succeeded = false; + } + + return succeeded; + } + + public override string ToString() + { + return string.Format("count = {0}", _items.Count); + } +} + diff --git a/certmgr/Core/Jobs/IJob.cs b/certmgr/Core/Jobs/IJob.cs new file mode 100644 index 0000000..79ea3a8 --- /dev/null +++ b/certmgr/Core/Jobs/IJob.cs @@ -0,0 +1,10 @@ +using System.Diagnostics; + +namespace CertMgr.Core.Jobs; + +public interface IJob +{ + string Name { [DebuggerStepThrough] get; } + + Task ExecuteAsync(CancellationToken cancellationToken); +} diff --git a/certmgr/Core/Jobs/IJobT.cs b/certmgr/Core/Jobs/IJobT.cs new file mode 100644 index 0000000..b6f136b --- /dev/null +++ b/certmgr/Core/Jobs/IJobT.cs @@ -0,0 +1,6 @@ +namespace CertMgr.Core.Jobs; + +public interface IJob : IJob +{ + new Task> ExecuteAsync(CancellationToken cancellationToken); +} diff --git a/certmgr/Core/Jobs/JobBase.cs b/certmgr/Core/Jobs/JobBase.cs new file mode 100644 index 0000000..420f191 --- /dev/null +++ b/certmgr/Core/Jobs/JobBase.cs @@ -0,0 +1,20 @@ +using System.Diagnostics; + +namespace CertMgr.Core.Jobs; + +public abstract class JobBase where TSettings : JobSettings, new() +{ + protected JobBase() + { + Name = JobUtils.GetJobName(GetType()); + } + + public string Name { [DebuggerStepThrough] get; } + + public TSettings Settings { [DebuggerStepThrough] get; [DebuggerStepThrough] private set; } + + public override string ToString() + { + return string.Format("name = '{0}', settings-type = '{1}'", Name, Settings?.GetType().Name ?? ""); + } +} diff --git a/certmgr/Core/Jobs/JobException.cs b/certmgr/Core/Jobs/JobException.cs new file mode 100644 index 0000000..e531b14 --- /dev/null +++ b/certmgr/Core/Jobs/JobException.cs @@ -0,0 +1,15 @@ +using CertMgr.Core.Utils; + +namespace CertMgr.Core.Jobs; + +public class JobException : Exception +{ + internal JobException(string messageFormat, params object?[] messageArgs) + : base(StringFormatter.Format(messageFormat, messageArgs)) + { + } + internal JobException(Exception? innerException, string messageFormat, params object?[] messageArgs) + : base(StringFormatter.Format(messageFormat, messageArgs), innerException) + { + } +} diff --git a/certmgr/Core/Jobs/JobRS.cs b/certmgr/Core/Jobs/JobRS.cs new file mode 100644 index 0000000..c3eefd6 --- /dev/null +++ b/certmgr/Core/Jobs/JobRS.cs @@ -0,0 +1,41 @@ + +namespace CertMgr.Core.Jobs; + +public abstract class Job : JobBase, IJob where TSettings : JobSettings, new() +{ + public async Task> ExecuteAsync(CancellationToken cancellationToken) + { + try + { + JobResult result = await DoExecuteAsync(cancellationToken).ConfigureAwait(false); + return result; + } + catch (Exception e) + { + return CreateFailure(default, e, "Failed to execute job '{0}'", Name); + } + } + + protected abstract Task> DoExecuteAsync(CancellationToken cancellationToken); + + async Task IJob.ExecuteAsync(CancellationToken cancellationToken) + { + JobResult result = await ExecuteAsync(cancellationToken); + return result; + } + + protected JobResult CreateSuccess(TResult? result, string messageFormat, params object[] messageArgs) + { + return new JobResult(this, result, JobResult.Success, messageFormat, messageArgs); + } + + protected JobResult CreateFailure(TResult? result, string messageFormat, params object[] messageArgs) + { + return new JobResult(this, result, JobResult.Success, messageFormat, messageArgs); + } + + protected JobResult CreateFailure(TResult? result, Exception exception, string messageFormat, params object[] messageArgs) + { + return new JobResult(this, result, JobResult.Success, messageFormat, messageArgs); + } +} diff --git a/certmgr/Core/Jobs/JobResult.cs b/certmgr/Core/Jobs/JobResult.cs new file mode 100644 index 0000000..8ca29c5 --- /dev/null +++ b/certmgr/Core/Jobs/JobResult.cs @@ -0,0 +1,41 @@ +using System.Diagnostics; + +using CertMgr.Core.Utils; + +namespace CertMgr.Core.Jobs; + +public class JobResult +{ + public const int Success = 0; + public const int Failure = 1; + + internal JobResult(IJob job, int errorLevel, string messageFormat, params object[] messageArgs) + { + Job = job; + ErrorLevel = errorLevel; + Message = StringFormatter.Format(messageFormat, messageArgs); + } + + internal JobResult(IJob job, int errorLevel, Exception? exception, string messageFormat, params object[] messageArgs) + { + Job = job; + ErrorLevel = errorLevel; + Message = StringFormatter.Format(messageFormat, messageArgs); + Exception = exception; + } + + public IJob Job { [DebuggerStepThrough] get; } + + public int ErrorLevel { [DebuggerStepThrough] get; } + + public string Message { [DebuggerStepThrough] get; } + + public Exception? Exception { [DebuggerStepThrough] get; } + + public bool IsSuccess => ErrorLevel == 0; + + public override string ToString() + { + return string.Format("result = {0}, message = '{1}'", IsSuccess ? "success" : "failure", Message); + } +} diff --git a/certmgr/Core/Jobs/JobResultT.cs b/certmgr/Core/Jobs/JobResultT.cs new file mode 100644 index 0000000..aca82a6 --- /dev/null +++ b/certmgr/Core/Jobs/JobResultT.cs @@ -0,0 +1,20 @@ +using System.Diagnostics; + +namespace CertMgr.Core.Jobs; + +public class JobResult : JobResult +{ + internal JobResult(IJob job, TResult? result, int errorLevel, string messageFormat, params object[] messageArgs) + : base(job, errorLevel, messageFormat, messageArgs) + { + Result = result; + } + + internal JobResult(IJob job, TResult? result, int errorLevel, Exception? exception, string messageFormat, params object[] messageArgs) + : base(job, errorLevel, exception, messageFormat, messageArgs) + { + Result = result; + } + + public TResult? Result { [DebuggerStepThrough] get; } +} diff --git a/certmgr/Core/Jobs/JobS.cs b/certmgr/Core/Jobs/JobS.cs new file mode 100644 index 0000000..48ae56b --- /dev/null +++ b/certmgr/Core/Jobs/JobS.cs @@ -0,0 +1,36 @@ +namespace CertMgr.Core.Jobs; + +public abstract class Job : JobBase, IJob where TSettings : JobSettings, new() +{ + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + JobResult result = await DoExecuteAsync(cancellationToken).ConfigureAwait(false); + return result; + } + catch (Exception e) + { + return CreateFailure(e, "Failed to execute job '{0}'", Name); + } + } + + protected abstract Task DoExecuteAsync(CancellationToken cancellationToken); + + protected JobResult CreateSuccess(string messageFormat, params object[] messageArgs) + { + return new JobResult(this, JobResult.Success, messageFormat, messageArgs); + } + + protected JobResult CreateFailure(string messageFormat, params object[] messageArgs) + { + return new JobResult(this, JobResult.Failure, messageFormat, messageArgs); + } + + protected JobResult CreateFailure(Exception exception, string messageFormat, params object[] messageArgs) + { + return new JobResult(this, JobResult.Failure, exception, messageFormat, messageArgs); + } +} diff --git a/certmgr/Core/Jobs/JobSettings.cs b/certmgr/Core/Jobs/JobSettings.cs new file mode 100644 index 0000000..f11cbe1 --- /dev/null +++ b/certmgr/Core/Jobs/JobSettings.cs @@ -0,0 +1,25 @@ +using System.Diagnostics; + +using CertMgr.Core.Validation; + +namespace CertMgr.Core.Jobs; + +public abstract class JobSettings +{ + protected JobSettings() + { + ValidationResults = new ValidationResults(); + } + + public ValidationResults ValidationResults { [DebuggerStepThrough] get; } + + public async Task ValidateAsync(CancellationToken cancellationToken) + { + await DoValidateAsync(ValidationResults, cancellationToken); + } + + protected virtual Task DoValidateAsync(ValidationResults results, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} diff --git a/certmgr/Core/Jobs/JobUtils.cs b/certmgr/Core/Jobs/JobUtils.cs new file mode 100644 index 0000000..d20d6ff --- /dev/null +++ b/certmgr/Core/Jobs/JobUtils.cs @@ -0,0 +1,54 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +using CertMgr.Core.Cli; + +namespace CertMgr.Core.Jobs; + +internal static class JobUtils +{ + private const string IdFieldName = "ID"; + + internal static string GetJobName(Type jobType) + { + if (!TryGetJobName(jobType, out string? name, out string? errorMessage)) + { + throw new CliException(errorMessage); + } + + return name; + } + + internal static bool TryGetJobName(Type jobType, [NotNullWhen(true)] out string? name, [NotNullWhen(false)] out string? errorMessage) + { + name = null; + errorMessage = null; + + FieldInfo? finfo = jobType.GetField(IdFieldName, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly); + if (finfo != null) + { + if (finfo.FieldType == typeof(string)) + { + string? value = finfo.GetValue(null) as string; + if (!string.IsNullOrEmpty(value)) + { + name = value; + } + else + { + errorMessage = string.Format("Value of field '{0}' in class '{1}' must not be null or empty (actual = '{2}')", IdFieldName, jobType.Name, value == null ? "" : ""); + } + } + else + { + errorMessage = string.Format("Type of field '{0}' in class '{1}' must be '{2}' (actual = '{3})", IdFieldName, jobType.Name, typeof(string), finfo.FieldType.Name); + } + } + else + { + errorMessage = string.Format("Class '{0}' doesn't have field '{1}' of type '{2}'", jobType.Name, IdFieldName, typeof(string)); + } + + return name != null; + } +} diff --git a/certmgr/Core/Log/CLog.cs b/certmgr/Core/Log/CLog.cs new file mode 100644 index 0000000..5c18353 --- /dev/null +++ b/certmgr/Core/Log/CLog.cs @@ -0,0 +1,117 @@ +using System.Diagnostics; +using System.Text; + +using CertMgr.Core.Utils; + +namespace CertMgr.Core.Log; + +public static class CLog +{ + public const string TimestampFormat = "HH:mm:ss,fff"; + private const string ConsoleHeaderFormat = "({0})({1})(@{2})(#{3}) "; + + private static readonly object _syncObj = new object(); + + public static void Debug(string messageFormat, params object?[] messageArgs) + { + Write(LogLevel.Debug, null, messageFormat, messageArgs); + } + + public static void Info(string messageFormat, params object?[] messageArgs) + { + Write(LogLevel.Info, null, messageFormat, messageArgs); + } + + public static void Warning(string messageFormat, params object?[] messageArgs) + { + Write(LogLevel.Warning, null, messageFormat, messageArgs); + } + + public static void Error(string messageFormat, params object?[] messageArgs) + { + Write(LogLevel.Error, null, messageFormat, messageArgs); + } + + public static void Error(Exception? exception, string messageFormat, params object?[] messageArgs) + { + Write(LogLevel.Error, exception, messageFormat, messageArgs); + } + + public static void Write(LogLevel level, string messageFormat, params object?[] messageArgs) + { + Write(level, null, messageFormat, messageArgs); + } + + public static void Write(LogLevel level, Exception? exception, string messageFormat, params object?[] messageArgs) + { + using StringBuilderCache.ScopedBuilder lease = StringBuilderCache.AcquireScoped(exception != null ? 256 : 128); + StringBuilder sb = lease.Builder; + + AppendHeader(sb, ConsoleHeaderFormat, level, DateTime.Now, Process.GetCurrentProcess().Id, Thread.CurrentThread.ManagedThreadId); + AppendMessage(sb, exception, messageFormat, messageArgs); + + WriteColorized(level, sb.ToString()); + } + + public static void WriteNoHeader(LogLevel level, string message) + { + WriteColorized(level, message); + } + + private static void AppendHeader(StringBuilder sb, string headerFormat, LogLevel level, DateTime timestamp, int processId, int threadId) + { + char levelChar = level.ToString()[0]; + sb.AppendFormat(headerFormat, levelChar, timestamp.ToString(TimestampFormat), processId.ToString().PadLeft(5), threadId.ToString().PadLeft(3)); + } + + private static void AppendMessage(StringBuilder sb, Exception? exception, string messageFormat, params object?[] messageArgs) + { + sb.AppendFormat(messageFormat, messageArgs); + + if (exception != null) + { + sb.AppendLine(); + sb.AppendLine(exception.ToString()); + } + } + + internal static void WriteColorized(LogLevel level, string message) + { + ConsoleColor foreColor = GetForeColor(level); + lock (_syncObj) + { + Console.ForegroundColor = foreColor; + Console.WriteLine(message); + Console.ResetColor(); + } + } + + private static ConsoleColor GetForeColor(LogLevel level) + { + ConsoleColor color; + + switch (level) + { + case LogLevel.Debug: + color = ConsoleColor.Gray; + break; + case LogLevel.Info: + color = ConsoleColor.White; + break; + case LogLevel.Warning: + color = ConsoleColor.DarkYellow; + break; + case LogLevel.Error: + color = ConsoleColor.DarkRed; + break; + case LogLevel.Critical: + color = ConsoleColor.DarkMagenta; + break; + default: + color = ConsoleColor.DarkCyan; + break; + } + + return color; + } +} diff --git a/certmgr/Core/Log/LogLevel.cs b/certmgr/Core/Log/LogLevel.cs new file mode 100644 index 0000000..f171022 --- /dev/null +++ b/certmgr/Core/Log/LogLevel.cs @@ -0,0 +1,10 @@ +namespace CertMgr.Core.Log; + +public enum LogLevel +{ + Debug = 1, + Info, + Warning, + Error, + Critical +} diff --git a/certmgr/Core/SettingsBuilder.cs b/certmgr/Core/SettingsBuilder.cs new file mode 100644 index 0000000..75ad264 --- /dev/null +++ b/certmgr/Core/SettingsBuilder.cs @@ -0,0 +1,332 @@ +using System.Collections; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; + +using CertMgr.Core.Attributes; +using CertMgr.Core.Cli; +using CertMgr.Core.Converters; +using CertMgr.Core.Jobs; +using CertMgr.Core.Log; +using CertMgr.Core.Validation; + +namespace CertMgr.Core; + +internal sealed class SettingsBuilder +{ + private readonly RawArguments _rawArgs; + private readonly Type _settingsType; + + internal SettingsBuilder(RawArguments rawArgs, Type settingsType) + { + _rawArgs = rawArgs; + _settingsType = settingsType; + } + + public async Task LoadAsync(CancellationToken cancellationToken) + { + JobSettings settings = CreateSettingsInstance(); + + foreach ((PropertyInfo propertyInfo, SettingAttribute settingAttribute) in GetPropertiesWithSettingAttribute()) + { + if (TryGetRawArgument(settingAttribute, out RawArgument? rawArg)) + { + (bool isCollection, Type elementType) = GetValueType(propertyInfo); + + (bool converted, object? convertedValue) = await ConvertRawValueAsync(settingAttribute, rawArg, isCollection, elementType, cancellationToken).ConfigureAwait(false); + if (converted) + { + propertyInfo.SetValue(settings, convertedValue); + + if (settingAttribute.Validator != null) + { + ISettingValidator? validator = (ISettingValidator?)Activator.CreateInstance(settingAttribute.Validator, [settingAttribute.Name]); + if (validator != null) + { + ValidationResult valres = await validator.ValidateAsync(convertedValue, cancellationToken).ConfigureAwait(false); + settings.ValidationResults.Add(valres); + } + } + } + } + else if (settingAttribute.IsMandatory) + { + ValidationResult valres = new ValidationResult(settingAttribute.Name, false, "Mandatory argument is missing"); + settings.ValidationResults.Add(valres); + CLog.Error("mandatory argument '{0}' is missing", settingAttribute.Name); + } + else if (settingAttribute.Default != null) + { + (bool isCollection, Type elementType) = GetValueType(propertyInfo); + if (settingAttribute.Default.GetType() == elementType) + { + propertyInfo.SetValue(settings, settingAttribute.Default); + } + else + { + CLog.Error("Default value for argument '{0}' is specified, but its type is '{1}' instead of expected '{2}'", settingAttribute.Name, settingAttribute.Default?.GetType().Name ?? "", elementType); + } + } + } + + await settings.ValidateAsync(cancellationToken).ConfigureAwait(false); + + return settings; + } + + private bool TryGetRawArgument(SettingAttribute settingAttribute, [NotNullWhen(true)] out RawArgument? rawArg) + { + rawArg = null; + + if (!_rawArgs.TryGet(settingAttribute.Name, out rawArg)) + { + if (settingAttribute.AlternateNames != null) + { + foreach (string altName in settingAttribute.AlternateNames) + { + if (_rawArgs.TryGet(altName, out rawArg)) + { + break; + } + } + } + } + + return rawArg != null; + } + + private (bool isCollection, Type elementType) GetValueType(PropertyInfo propertyInfo) + { + (bool isCollection, Type? elemType) = UnwrapCollection(propertyInfo.PropertyType); + Type targetType = isCollection ? elemType! : propertyInfo.PropertyType; + return (isCollection, targetType); + } + + private IEnumerable<(PropertyInfo, SettingAttribute)> GetPropertiesWithSettingAttribute() + { + foreach (PropertyInfo propertyInfo in _settingsType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + SettingAttribute? settingAttribute = propertyInfo.GetCustomAttribute(); + if (settingAttribute is null) + { + continue; + } + + yield return (propertyInfo, settingAttribute); + } + } + + private JobSettings CreateSettingsInstance() + { + object? instance; + try + { + instance = Activator.CreateInstance(_settingsType); + if (instance == null) + { + throw new CertMgrException("Failed to create instance for settings of type '{0}'", _settingsType.Name); + } + } + catch (Exception e) + { + throw new CertMgrException(e, "Failed to create instance for settings of type '{0}'", _settingsType.Name); + } + + if (instance is not JobSettings settings) + { + throw new CertMgrException("Failed to create instance for settings of type '{0}'. The type is not of type '{1}'", _settingsType.Name, typeof(JobSettings).Name); + } + + return settings; + } + + private async Task<(bool success, object? convertedValue)> ConvertRawValueAsync(SettingAttribute settingAttribute, RawArgument rawArg, bool isCollection, Type targetType, CancellationToken cancellationToken) + { + bool success = false; + object? convertedValue = null; + + if (isCollection) + { + if (TryGetCustomConverter(settingAttribute, out IValueConverter? customConverter)) + { + List values = new List(); + foreach (string rawValue in rawArg.Values) + { + convertedValue = await customConverter.ConvertAsync(rawValue, targetType, cancellationToken).ConfigureAwait(false); + values.Add(convertedValue); + } + convertedValue = values; + success = true; + } + else + { + List values = new List(); + foreach (string rawValue in rawArg.Values) + { + if (TryConvertValue(rawValue, targetType, out convertedValue)) + { + values.Add(convertedValue); + } + } + convertedValue = values; + success = true; + } + } + else + { + if (TryGetCustomConverter(settingAttribute, out IValueConverter? customConverter)) + { + convertedValue = await customConverter.ConvertAsync(rawArg.Values.First(), targetType, cancellationToken).ConfigureAwait(false); + success = true; + } + else if (TryConvertValue(rawArg.Values.First(), targetType, out convertedValue)) + { + success = true; + } + else + { + } + } + + return (success, convertedValue); + } + + private bool TryGetCustomConverter(SettingAttribute settingAttribute, [NotNullWhen(true)] out IValueConverter? customConverter) + { + customConverter = null; + + Type? valueConverter = settingAttribute.Converter; + if (valueConverter != null) + { + if (typeof(IValueConverter).IsAssignableFrom(valueConverter)) + { + customConverter = (IValueConverter?)Activator.CreateInstance(valueConverter); + } + else + { + CLog.Error("Argument '{0}' has converter specified but its type doesn't implement '{1}' and cannot be used", settingAttribute.Name, typeof(IValueConverter)); + } + } + + return customConverter != null; + } + + private bool TryConvertValue(string rawValue, Type targetType, out object? convertedValue) + { + convertedValue = null; + + if (targetType.IsEnum) + { + if (Enum.TryParse(targetType, rawValue, true, out object? result)) + { + convertedValue = result; + } + } + else + { + TypeConverter converter = TypeDescriptor.GetConverter(targetType); + if (converter.CanConvertFrom(typeof(string))) + { + convertedValue = converter.ConvertFrom(null, CultureInfo.InvariantCulture, rawValue)!; + } + else if (targetType == typeof(string)) + { + convertedValue = rawValue; + } + else + { + convertedValue = null; + } + } + + return convertedValue != null; + } + + private static (bool isCollection, Type? elementType) UnwrapCollection(Type type) + { + if (type == typeof(string)) + { + return (false, null); + } + + Type? underlying = Nullable.GetUnderlyingType(type); + if (underlying != null) + { + type = underlying; + } + + if (type.IsArray) + { + return (true, type.GetElementType()!); + } + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + return (true, type.GetGenericArguments()[0]); + } + + if (type.GetInterfaces().Any(i => + i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>))) + { + return (true, type.GetGenericArguments()[0]); + } + + // if (type.IsGenericType && typeof(IEnumerable<>).IsAssignableFrom(type.GetGenericTypeDefinition())) + // { + // return (true, type.GetGenericArguments()[0]); + // } + // if (t.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ICollection<>))) + // { + // return (true, t.GetInterfaces().First(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ICollection<>)).GetGenericArguments()[0]); + // } + // if (t.IsGenericType && (t.GetGenericTypeDefinition() == typeof(List<>))) + // { + // return (true, t.GetGenericArguments()[0]); + // } + + return (false, null); + } + + private static bool IsEnumerableType(Type type) + { + if (type == null) + { + return false; + } + + if (type == typeof(string)) + { + return false; + } + + if (type.IsArray) + { + return true; + } + + if (typeof(IEnumerable).IsAssignableFrom(type)) + { + return true; + } + + if (type.GetInterfaces().Any(i => + i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>))) + { + return true; + } + + return false; + } + + private static Type UnwrapNullable(Type type) + { + Type? underlying = Nullable.GetUnderlyingType(type); + if (underlying != null) + { + return underlying; + } + + return type; + } +} diff --git a/certmgr/Core/Storage/EmptyStorage.cs b/certmgr/Core/Storage/EmptyStorage.cs new file mode 100644 index 0000000..f0bf940 --- /dev/null +++ b/certmgr/Core/Storage/EmptyStorage.cs @@ -0,0 +1,24 @@ +namespace CertMgr.Core.Storage; + +public sealed class EmptyStorage : Storage +{ + public static readonly IStorage Empty = new EmptyStorage(); + + private EmptyStorage() + { + } + + public override bool CanWrite => true; + + public override bool CanRead => true; + + protected override Task DoWriteAsync(Stream source, CancellationToken cancellationToken) + { + return Task.FromResult(StoreResult.CreateSuccess()); + } + + protected override Task DoReadAsync(Stream target, CancellationToken cancellationToken) + { + return Task.FromResult(StoreResult.CreateSuccess()); + } +} diff --git a/certmgr/Core/Storage/FileStorage.cs b/certmgr/Core/Storage/FileStorage.cs new file mode 100644 index 0000000..ad3a9b7 --- /dev/null +++ b/certmgr/Core/Storage/FileStorage.cs @@ -0,0 +1,44 @@ +using CertMgr.Core.Utils; + +namespace CertMgr.Core.Storage; + +public sealed class FileStorage : Storage +{ + private readonly string _fileFullPath; + private readonly FileStoreMode _mode; + + public FileStorage(string fileFullPath, FileStoreMode mode) + { + _fileFullPath = fileFullPath; + _mode = mode; + } + + public override bool CanWrite => _mode.IsAnyOf(FileStoreMode.AppendOrNew, FileStoreMode.Create, FileStoreMode.OverwriteOrNew); + + public override bool CanRead => _mode == FileStoreMode.Open; + + protected override async Task DoWriteAsync(Stream source, CancellationToken cancellationToken) + { + using (FileStream fs = new FileStream(_fileFullPath, (FileMode)_mode, FileAccess.Write, FileShare.None)) + { + await source.CopyToAsync(fs, cancellationToken).ConfigureAwait(false); + } + + return StoreResult.CreateSuccess(); + } + + protected override async Task DoReadAsync(Stream target, CancellationToken cancellationToken) + { + using (FileStream fs = new FileStream(_fileFullPath, (FileMode)_mode, FileAccess.Read, FileShare.Read)) + { + await fs.CopyToAsync(target, cancellationToken).ConfigureAwait(false); + } + + return StoreResult.CreateSuccess(); + } + + public override string ToString() + { + return string.Format("{0}: mode = '{1}', path = '{2}'", GetType().Name, _mode, _fileFullPath); + } +} diff --git a/certmgr/Core/Storage/FileStoreMode.cs b/certmgr/Core/Storage/FileStoreMode.cs new file mode 100644 index 0000000..2fdd7ae --- /dev/null +++ b/certmgr/Core/Storage/FileStoreMode.cs @@ -0,0 +1,16 @@ +namespace CertMgr.Core.Storage; + +public enum FileStoreMode +{ + /// Create new file or append to existing. + AppendOrNew = FileMode.Append, + + /// Create new file or overwrite existing. + OverwriteOrNew = FileMode.Create, + + /// Create new file. Throw if already exists. + Create = FileMode.CreateNew, + + /// Open existing file. Throw if doesn't exist. + Open = FileMode.Open, +} diff --git a/certmgr/Core/Storage/IStorage.cs b/certmgr/Core/Storage/IStorage.cs new file mode 100644 index 0000000..2869b77 --- /dev/null +++ b/certmgr/Core/Storage/IStorage.cs @@ -0,0 +1,12 @@ +namespace CertMgr.Core.Storage; + +public interface IStorage +{ + Task WriteAsync(Stream source, CancellationToken cancellationToken); + + // Task WriteFromAsync(StorageAdapter adapter, CancellationToken cancellationToken); + + Task ReadAsync(Stream target, CancellationToken cancellationToken); + + // Task> ReadToAsync(StorageAdapter adapter, CancellationToken cancellationToken); +} diff --git a/certmgr/Core/Storage/Storage.cs b/certmgr/Core/Storage/Storage.cs new file mode 100644 index 0000000..a82756a --- /dev/null +++ b/certmgr/Core/Storage/Storage.cs @@ -0,0 +1,83 @@ +using CertMgr.Core.Utils; + +namespace CertMgr.Core.Storage; + +public abstract class Storage : IStorage +{ + + public virtual bool CanRead => false; + + public virtual bool CanWrite => false; + + public async Task WriteAsync(Stream source, CancellationToken cancellationToken) + { + if (!CanWrite) + { + throw new StorageException("Cannot write. Storage not writable"); + } + + try + { + return await DoWriteAsync(source, cancellationToken); + } + catch (Exception e) + { + StorageException ex = new StorageException(e, "Failed to write to storage of type '{0}' from stream of type '{1}'", GetType().ToString(false), source.GetType().ToString(false)); + return StoreResult.CreateFailure(ex); + } + } + + protected abstract Task DoWriteAsync(Stream source, CancellationToken cancellationToken); + + public async Task ReadAsync(Stream target, CancellationToken cancellationToken) + { + if (!CanRead) + { + throw new StorageException("Cannot read. Storage not readable"); + } + + try + { + return await DoReadAsync(target, cancellationToken); + } + catch (Exception e) + { + StorageException ex = new StorageException(e, "Failed to read from storage of type '{0}' to stream of type '{1}'", GetType().ToString(false), target.GetType().ToString(false)); + return StoreResult.CreateFailure(ex); + } + } + + protected abstract Task DoReadAsync(Stream target, CancellationToken cancellationToken); + + /*public async Task WriteFromAsync(StorageAdapter adapter, CancellationToken cancellationToken) + { + try + { + return await adapter.WriteAsync(this, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + StorageException ex = new StorageException(e, "Failed to write to storage of type '{0}' adapter of type '{1}'", GetType().ToString(false), adapter.GetType().ToString(false)); + return StoreResult.CreateFailure(ex); + } + }*/ + + /*public async Task> ReadToAsync(StorageAdapter adapter, CancellationToken cancellationToken) + { + try + { + TResult result = await adapter.ReadAsync(this, cancellationToken).ConfigureAwait(false); + return new StoreResult(result, true); + } + catch (Exception e) + { + StorageException ex = new StorageException(e, "Failed to read as type '{0}' from storage of type '{1}' using adapter of type '{1}'", typeof(TResult).ToString(false), GetType().ToString(false), adapter.GetType().ToString(false)); + return new StoreResult(default, false, ex); + } + }*/ + + public override string ToString() + { + return string.Format("{0}: can-read = {1}, can-write = {2}", GetType().Name, CanRead ? "yes" : "no", CanWrite ? "yes" : "no"); + } +} diff --git a/certmgr/Core/Storage/StorageException.cs b/certmgr/Core/Storage/StorageException.cs new file mode 100644 index 0000000..97bab02 --- /dev/null +++ b/certmgr/Core/Storage/StorageException.cs @@ -0,0 +1,15 @@ +using CertMgr.Core.Utils; + +namespace CertMgr.Core.Storage; + +public class StorageException : Exception +{ + internal StorageException(string messageFormat, params object?[] messageArgs) + : base(StringFormatter.Format(messageFormat, messageArgs)) + { + } + internal StorageException(Exception? innerException, string messageFormat, params object?[] messageArgs) + : base(StringFormatter.Format(messageFormat, messageArgs), innerException) + { + } +} diff --git a/certmgr/Core/Storage/StorageType.cs b/certmgr/Core/Storage/StorageType.cs new file mode 100644 index 0000000..25e2487 --- /dev/null +++ b/certmgr/Core/Storage/StorageType.cs @@ -0,0 +1,6 @@ +namespace CertMgr.Core.Storage; + +public enum StorageType +{ + File = 1 +} diff --git a/certmgr/Core/Storage/StoreResult.cs b/certmgr/Core/Storage/StoreResult.cs new file mode 100644 index 0000000..e8856d8 --- /dev/null +++ b/certmgr/Core/Storage/StoreResult.cs @@ -0,0 +1,39 @@ +using System.Diagnostics; + +namespace CertMgr.Core.Storage; + +public class StoreResult +{ + private static readonly StoreResult Success = new StoreResult(true); + private static readonly StoreResult Failure = new StoreResult(false); + + public StoreResult(bool isSuccess) + { + IsSuccess = isSuccess; + } + + public StoreResult(bool isSuccess, Exception? exception) + { + IsSuccess = isSuccess; + Exception = exception; + } + + public bool IsSuccess { [DebuggerStepThrough] get; } + + public Exception? Exception { [DebuggerStepThrough] get; } + + public static StoreResult CreateSuccess() + { + return Success; + } + + public static StoreResult CreateFailure() + { + return Failure; + } + + public static StoreResult CreateFailure(Exception? exception) + { + return exception != null ? new StoreResult(false, exception) : Failure; + } +} diff --git a/certmgr/Core/Storage/StoreResultT.cs b/certmgr/Core/Storage/StoreResultT.cs new file mode 100644 index 0000000..9fdd1d7 --- /dev/null +++ b/certmgr/Core/Storage/StoreResultT.cs @@ -0,0 +1,30 @@ +using System.Diagnostics; + +namespace CertMgr.Core.Storage; + +public sealed class StoreResult : StoreResult +{ + public StoreResult(TResult? result, bool isSuccess) + : base(isSuccess) + { + Result = result; + } + + public StoreResult(TResult? result, bool isSuccess, Exception? exception) + : base(isSuccess, exception) + { + Result = result; + } + + public TResult? Result { [DebuggerStepThrough] get; } + + public static StoreResult CreateFailure(TResult? result, Exception? exception) + { + return exception != null ? new StoreResult(result, false, exception) : new StoreResult(result, false); + } + + public static StoreResult CreateSuccess(TResult? result) + { + return new StoreResult(result, true); + } +} diff --git a/certmgr/Core/Utils/AsyncLock.cs b/certmgr/Core/Utils/AsyncLock.cs new file mode 100644 index 0000000..1f43787 --- /dev/null +++ b/certmgr/Core/Utils/AsyncLock.cs @@ -0,0 +1,45 @@ +namespace CertMgr.Core.Utils; + +using System; +using System.Threading; +using System.Threading.Tasks; + +public sealed class AsyncLock +{ + private readonly SemaphoreSlim _semaphore; + private readonly Task _disposer; + + public AsyncLock() + { + _semaphore = new SemaphoreSlim(1, 1); + _disposer = Task.FromResult((IDisposable)new AsyncLockDisposer(this)); + } + + public Task LockAsync(CancellationToken cancellation = default) + { + Task wait = _semaphore.WaitAsync(); + if (wait.IsCompleted) + { + return _disposer; + } + else + { + return wait.ContinueWith((_, state) => (IDisposable)state!, _disposer.Result, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + } + + private sealed class AsyncLockDisposer : IDisposable + { + private readonly AsyncLock _owner; + + internal AsyncLockDisposer(AsyncLock owner) + { + _owner = owner; + } + + public void Dispose() + { + _owner._semaphore.Release(); + } + } +} diff --git a/certmgr/Core/Utils/Extenders.cs b/certmgr/Core/Utils/Extenders.cs new file mode 100644 index 0000000..194e06e --- /dev/null +++ b/certmgr/Core/Utils/Extenders.cs @@ -0,0 +1,248 @@ +using System.Reflection; +using System.Text; + +namespace CertMgr.Core.Utils; + +internal static class Extenders +{ + private static readonly Dictionary _typeAliases = new Dictionary + { + { typeof(long), "long" }, + { typeof(ulong), "ulong" }, + { typeof(int), "int" }, + { typeof(uint), "uint" }, + { typeof(short), "short" }, + { typeof(ushort), "ushort" }, + { typeof(byte), "byte" }, + { typeof(sbyte), "sbyte" }, + { typeof(double), "double" }, + { typeof(float), "float" }, + { typeof(decimal), "decimal" }, + { typeof(char), "char" }, + { typeof(string), "string" }, + { typeof(void), "void" }, + { typeof(bool), "bool" }, + { typeof(object), "object" }, + }; + + public static string ToSeparatedList(this IEnumerable items, Func formatter, string itemSeparator, string? lastItemSeparator = null) + { + using StringBuilderCache.ScopedBuilder lease = StringBuilderCache.AcquireScoped(); + StringBuilder sb = lease.Builder; + + ToSeparatedList(items, sb, formatter, itemSeparator, lastItemSeparator); + + return sb.ToString(); + } + + public static void ToSeparatedList(this IEnumerable items, StringBuilder sb, Func formatter, string itemSeparator, string? lastItemSeparator = null) + { + if (!items.Any()) + { + return; + } + + if (formatter == null) + { + formatter = item => item?.ToString() ?? ""; + } + + lastItemSeparator = lastItemSeparator ?? itemSeparator; + + using (IEnumerator enu = items.GetEnumerator()) + { + if (enu.MoveNext()) + { + sb.Append(formatter(enu.Current)); + bool hasNext = enu.MoveNext(); + while (hasNext) + { + T current = enu.Current; + hasNext = enu.MoveNext(); + + sb.Append(hasNext ? itemSeparator : lastItemSeparator); + sb.Append(formatter(current)); + } + } + } + } + + public static string ToString(this Type type, bool includeNamespace) + { + if (type == null) + { + return null; + } + + using StringBuilderCache.ScopedBuilder lease = StringBuilderCache.AcquireScoped(); + StringBuilder sb = lease.Builder; + type.AppendStringType(sb, includeNamespace); + string typeString = sb.ToString(); + return typeString; + } + + private static void AppendStringType(this Type type, StringBuilder sb, bool includeNamespace) + { + if (type == null) + { + return; + } + + if (type.IsGenericType) + { + if (includeNamespace) + { + sb.Append(type.Namespace); + sb.Append('.'); + } + sb.Append(type.Name.Substring(0, type.Name.IndexOf('`'))); + sb.Append('<'); + Type[] genericArguments = type.GenericTypeArguments; + for (int i = 0; i < genericArguments.Length; i++) + { + if (i > 0) + { + sb.Append(','); + } + genericArguments[i].AppendStringType(sb, includeNamespace); + } + sb.Append('>'); + } + else + { + if (_typeAliases.TryGetValue(type, out string? alias)) + { + sb.Append(alias); + } + else + { + if (includeNamespace) + { + sb.Append(type.Namespace); + sb.Append('.'); + } + sb.Append(type.Name); + } + } + } + + public static bool Equivalent(this IEnumerable left, IEnumerable right, IEqualityComparer? comparer = null) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + if (left == null || right == null) + { + return false; + } + + if (left.TryGetCount(out int lcount) && right.TryGetCount(out int rcount)) + { + if (lcount != rcount) + { + return false; + } + } + + int nullCount = 0; + Dictionary cnt = new Dictionary(comparer ?? EqualityComparer.Default); + foreach (T? item in left) + { + if (item == null) + { + nullCount++; + continue; + } + if (cnt.TryGetValue(item, out int value)) + { + cnt[item] = ++value; + } + else + { + cnt.Add(item, 1); + } + } + + foreach (T? item in right) + { + if (item == null) + { + nullCount--; + continue; + } + if (cnt.ContainsKey(item)) + { + cnt[item]--; + } + else + { + return false; + } + } + + bool equals = nullCount == 0 && cnt.Values.All(c => c == 0); + return equals; + } + + private static bool TryGetCount(this IEnumerable items, out int count) + { + count = -1; + + bool success = false; + + if (items is ICollection colt) + { + count = colt.Count; + success = true; + } + else if (items is System.Collections.ICollection col) + { + count = col.Count; + success = true; + } + else if (items is IReadOnlyCollection rocol) + { + count = rocol.Count; + success = true; + } + + return success; + } + + public static bool IsAnyOf(this T value, params T[] availableValues) where T : struct + { + if (!typeof(T).IsEnum) + { + throw new Exception(string.Format("Expected that the type is enum, got '{0}'", typeof(T).ToString(false))); + } + + bool isOneOf = false; + + Attribute? flags = typeof(T).GetCustomAttribute(typeof(FlagsAttribute)); + if (flags == null) + { + if (availableValues != null && availableValues.Length > 0) + { + isOneOf = availableValues.Any(av => value.Equals(av)); + } + } + else + { + ulong v = Convert.ToUInt64(value); + foreach (T availableValue in availableValues) + { + ulong av = Convert.ToUInt64(availableValue); + if ((v & av) == v) + { + isOneOf = true; + break; + } + } + } + + return isOneOf; + } + +} diff --git a/certmgr/Core/Utils/MachineNameFormat.cs b/certmgr/Core/Utils/MachineNameFormat.cs new file mode 100644 index 0000000..5f8a16a --- /dev/null +++ b/certmgr/Core/Utils/MachineNameFormat.cs @@ -0,0 +1,17 @@ +namespace CertMgr.Core.Utils; + +public enum MachineNameFormat +{ + /// NetBIOS name of the local computer. Limited to 15 characters and may be a truncated version of DNS host name. + NetBios = 1, + + /// DNS name of the local computer. + Hostname = 2, + + /// Fully qualified DNS name of the local computer. Combination of DNS hostname and DNS domain using form <HostName>.<DomainName> + FullyQualified = 3, + + /// Name of the DNS domain assigned to the local computer. + Domain = 4 +} + diff --git a/certmgr/Core/Utils/NetUtils.cs b/certmgr/Core/Utils/NetUtils.cs new file mode 100644 index 0000000..6440d66 --- /dev/null +++ b/certmgr/Core/Utils/NetUtils.cs @@ -0,0 +1,37 @@ +using System.Net; +using System.Net.NetworkInformation; + +using CertMgr.Core.Exceptions; + +namespace CertMgr.Core.Utils; + +public static class NetUtils +{ + /// Returns DNS name of the local computer. + public static string MachineName => GetMachineName(MachineNameFormat.Hostname); + + public static string GetMachineName(MachineNameFormat format) + { + string name; + + switch (format) + { + case MachineNameFormat.NetBios: + name = Environment.MachineName; + break; + case MachineNameFormat.Hostname: + name = Dns.GetHostName(); + break; + case MachineNameFormat.FullyQualified: + name = Dns.GetHostEntry("localhost").HostName; + break; + case MachineNameFormat.Domain: + name = IPGlobalProperties.GetIPGlobalProperties().DomainName; + break; + default: + throw new UnsupportedValueException(format); + } + + return name; + } +} diff --git a/certmgr/Core/Utils/StringBuilderCache.cs b/certmgr/Core/Utils/StringBuilderCache.cs new file mode 100644 index 0000000..447975f --- /dev/null +++ b/certmgr/Core/Utils/StringBuilderCache.cs @@ -0,0 +1,172 @@ +using System.Collections.Concurrent; +using System.Text; + +namespace CertMgr.Core.Utils; + +internal static class StringBuilderCache +{ + private static readonly ConcurrentStack _cache = new ConcurrentStack(); + private static readonly int _maxCapacityOfCachedItem = 8; + private static readonly int _maxCountOfItemsInCache = 4 * 1024; + + private static int _pooledCount; + + [ThreadStatic] + private static StringBuilder? _hotSlot; + + public static StringBuilder Acquire(int initialCapacity = 16) + { + StringBuilder? sb = Interlocked.Exchange(ref _hotSlot, null); + if (sb != null) + { + Prepare(sb, initialCapacity); + return sb; + } + + if (_cache.TryPop(out sb)) + { + Interlocked.Decrement(ref _pooledCount); + Prepare(sb, initialCapacity); + return sb; + } + + return new StringBuilder(initialCapacity); + } + + public static ScopedBuilder AcquireScoped(int initialCapacity = 16) + { + return new ScopedBuilder(Acquire(initialCapacity)); + } + + public static void Release(StringBuilder sb) + { + if (sb == null) + { + return; + } + + if (sb.Capacity > _maxCountOfItemsInCache) + { + return; + } + + sb.Clear(); + + if (Interlocked.CompareExchange(ref _hotSlot, sb, null) == null) + { + return; + } + + int newCount = Interlocked.Increment(ref _pooledCount); + if (newCount <= _maxCapacityOfCachedItem) + { + _cache.Push(sb); + return; + } + + Interlocked.Decrement(ref _pooledCount); + } + + public static string GetStringAndRelease(StringBuilder sb) + { + string s = sb.ToString(); + Release(sb); + return s; + } + + private static void Prepare(StringBuilder sb, int minCapacity) + { + sb.Clear(); + if (sb.Capacity < minCapacity) + { + sb.EnsureCapacity(minCapacity); + } + } + + public sealed class ScopedBuilder : IDisposable, IAsyncDisposable + { + private StringBuilder _sb; + + private int _disposed; + + internal ScopedBuilder(StringBuilder sb) + { + _sb = sb; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + StringBuilder? sb = _sb; + _sb = null; + Release(sb); + } + } + + public ValueTask DisposeAsync() + { + Dispose(); + return new ValueTask(); + } + + public string GetStringAndRelease() + { + if (Interlocked.CompareExchange(ref _disposed, 1, 1) == 1) + { + throw new ObjectDisposedException(string.Format("Cannot get string from this instance of StringBuilderCache as it has already been disposed")); + } + string s = _sb.ToString(); + Dispose(); + return s; + } + + public StringBuilder Builder => _sb; + + public static implicit operator StringBuilder(ScopedBuilder b) + { + return b.Builder; + } + } +} + + +/*internal static class StringBuilderCache +{ + internal const int MaxBuilderSize = 360; + internal const int DefaultCapacity = 16; + + [ThreadStatic] + private static StringBuilder? CachedInstance; + + public static StringBuilder Acquire(int capacity = DefaultCapacity) + { + if (capacity <= 360) + { + StringBuilder? cachedInstance = CachedInstance; + if (cachedInstance != null && capacity <= cachedInstance.Capacity) + { + CachedInstance = null; + cachedInstance.Clear(); + return cachedInstance; + } + } + + return new StringBuilder(capacity); + } + + public static void Release(StringBuilder sb) + { + if (sb.Capacity <= 360) + { + CachedInstance = sb; + } + } + + public static string GetStringAndRelease(StringBuilder sb) + { + string result = sb.ToString(); + Release(sb); + return result; + } +}*/ diff --git a/certmgr/Core/Utils/StringFormatter.cs b/certmgr/Core/Utils/StringFormatter.cs new file mode 100644 index 0000000..fbc0071 --- /dev/null +++ b/certmgr/Core/Utils/StringFormatter.cs @@ -0,0 +1,30 @@ +using System.Text; + +namespace CertMgr.Core.Utils; + +internal static class StringFormatter +{ + public static string Format(string messageFormat, params object?[] messageArgs) + { + try + { + return string.Format(messageFormat, messageArgs); + } + catch (Exception e) + { + using StringBuilderCache.ScopedBuilder lease = StringBuilderCache.AcquireScoped(); + StringBuilder sb = lease.Builder; + + sb.AppendFormat("Failed to format message: '{0}'. Arguments (count = {1}) = ", messageFormat, messageArgs?.Length ?? -1); + if (messageArgs == null) + { + sb.Append(""); + } + else + { + messageArgs.ToSeparatedList(sb, obj => obj?.ToString() ?? "", ","); + } + return sb.ToString(); + } + } +} diff --git a/certmgr/Core/Validation/ISettingValidator.cs b/certmgr/Core/Validation/ISettingValidator.cs new file mode 100644 index 0000000..23a8888 --- /dev/null +++ b/certmgr/Core/Validation/ISettingValidator.cs @@ -0,0 +1,10 @@ +using System.Diagnostics; + +namespace CertMgr.Core.Validation; + +public interface ISettingValidator +{ + string SettingName { [DebuggerStepThrough] get; } + + Task ValidateAsync(object? value, CancellationToken cancellationToken); +} diff --git a/certmgr/Core/Validation/ISettingValidatorT.cs b/certmgr/Core/Validation/ISettingValidatorT.cs new file mode 100644 index 0000000..d69668a --- /dev/null +++ b/certmgr/Core/Validation/ISettingValidatorT.cs @@ -0,0 +1,6 @@ +namespace CertMgr.Core.Validation; + +public interface ISettingValidator : ISettingValidator +{ + Task ValidateAsync(T? settingValue, CancellationToken cancellationToken); +} diff --git a/certmgr/Core/Validation/StringValidator.cs b/certmgr/Core/Validation/StringValidator.cs new file mode 100644 index 0000000..a77e89b --- /dev/null +++ b/certmgr/Core/Validation/StringValidator.cs @@ -0,0 +1,27 @@ +using System.Diagnostics; + +namespace CertMgr.Core.Validation; + +public sealed class StringValidator +{ + public sealed class IsNotNull : ISettingValidator + { + public IsNotNull(string settingName) + { + SettingName = settingName; + } + + public string SettingName { [DebuggerStepThrough] get; } + + public Task ValidateAsync(string? value, CancellationToken cancellationToken) + { + ValidationResult result = new ValidationResult(SettingName, value != null, "value is null"); + return Task.FromResult(result); + } + + public Task ValidateAsync(object? value, CancellationToken cancellationToken) + { + return ValidateAsync(value as string, cancellationToken); + } + } +} diff --git a/certmgr/Core/Validation/ValidationResult.cs b/certmgr/Core/Validation/ValidationResult.cs new file mode 100644 index 0000000..0b1e311 --- /dev/null +++ b/certmgr/Core/Validation/ValidationResult.cs @@ -0,0 +1,26 @@ +using System.Diagnostics; + +using CertMgr.Core.Utils; + +namespace CertMgr.Core.Validation; + +public sealed class ValidationResult +{ + public ValidationResult(string propertyName, bool isValid, string justificationFormat, params object?[] justificationArgs) + { + PropertyName = propertyName; + Justification = StringFormatter.Format(justificationFormat, justificationArgs); + IsValid = isValid; + } + + public string PropertyName { [DebuggerStepThrough] get; } + + public string Justification { [DebuggerStepThrough] get; } + + public bool IsValid { [DebuggerStepThrough] get; } + + public override string ToString() + { + return string.Format("{0}: {1} => {2}", PropertyName, IsValid ? "valid" : "not valid", Justification); + } +} diff --git a/certmgr/Core/Validation/ValidationResults.cs b/certmgr/Core/Validation/ValidationResults.cs new file mode 100644 index 0000000..c3df78c --- /dev/null +++ b/certmgr/Core/Validation/ValidationResults.cs @@ -0,0 +1,52 @@ +using System.Collections; + +namespace CertMgr.Core.Validation; + +public sealed class ValidationResults : IReadOnlyCollection +{ + private readonly List _results; + + internal ValidationResults() + { + _results = new List(); + } + + public int Count => _results.Count; + + public bool IsValid => _results.All(res => res.IsValid); + + internal void Add(ValidationResult result) + { + _results.Add(result); + } + + internal void Add(IReadOnlyCollection results) + { + _results.AddRange(results); + } + + internal void AddValid(string propertyName, string justificationFormat, params object?[] justificationArgs) + { + Add(new ValidationResult(propertyName, true, justificationFormat, justificationArgs)); + } + + internal void AddInvalid(string propertyName, string justificationFormat, params object?[] justificationArgs) + { + Add(new ValidationResult(propertyName, false, justificationFormat, justificationArgs)); + } + + public IEnumerator GetEnumerator() + { + return _results.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public override string ToString() + { + return string.Format("is-valid = {0}, total-count = {1}, invalid-count = {2}", IsValid ? "yes" : "no", _results.Count, _results.Where(r => !r.IsValid).Count()); + } +} diff --git a/certmgr/Jobs/CertificateSettings.cs b/certmgr/Jobs/CertificateSettings.cs new file mode 100644 index 0000000..b7148bb --- /dev/null +++ b/certmgr/Jobs/CertificateSettings.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; + +using CertMgr.CertGen; +using CertMgr.Core.Attributes; +using CertMgr.Core.Converters.Impl; +using CertMgr.Core.Jobs; +using CertMgr.Core.Storage; +using CertMgr.Core.Validation; + +namespace CertMgr.Jobs; + +public sealed class CertificateSettings : JobSettings +{ + public CertificateSettings() + { + Algorithm = CertificateAlgorithm.ECDsa; + Curve = EcdsaCurve.P384; + HashAlgorithm = CertGen.HashAlgorithm.Sha384; + ValidityPeriod = TimeSpan.FromDays(365); + } + + [Setting("subject", IsMandatory = true, Validator = typeof(StringValidator.IsNotNull))] + public string? Subject { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + [Setting("subject-alternate-name", AlternateNames = ["san"])] + public IReadOnlyCollection? SubjectAlternateNames { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + [Setting("algorithm", Default = CertificateAlgorithm.ECDsa, Converter = typeof(EnumConverter))] + public CertificateAlgorithm? Algorithm { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + [Setting("ecdsa-curve")] + public EcdsaCurve? Curve { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + [Setting("rsa-key-size")] + public RsaKeySize? RsaKeySize { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + [Setting("hash-algorithm", AlternateNames = ["ha"])] + public HashAlgorithm? HashAlgorithm { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + [Setting("is-certificate-authority", Default = false, AlternateNames = ["isca"])] + public bool IsCertificateAuthority { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + [Setting("issuer", Converter = typeof(StorageConverter))] + public IStorage? Issuer { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + [Setting("issuer-password")] + public string? IssuerPassword { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + [Setting("storage", IsMandatory = true, Converter = typeof(StorageConverter))] + public IStorage? Storage { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + [Setting("password")] + public string? Password { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + [Setting("validity-period", Default = "365d", Converter = typeof(TimeSpanConverter))] + public TimeSpan? ValidityPeriod { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } + + protected override Task DoValidateAsync(ValidationResults results, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(Subject)) + { + results.AddInvalid(nameof(Subject), "must not be empty"); + } + if (Algorithm == CertificateAlgorithm.ECDsa) + { + if (!Curve.HasValue || !Enum.IsDefined(Curve.Value)) + { + results.AddInvalid(nameof(Curve), "valid value must be specified: '{0}'", Curve?.ToString() ?? ""); + } + } + else if (Algorithm == CertificateAlgorithm.RSA) + { + if (!RsaKeySize.HasValue || !Enum.IsDefined(RsaKeySize.Value)) + { + results.AddInvalid(nameof(RsaKeySize), "value value must be specified: '{0}'", RsaKeySize?.ToString() ?? ""); + } + } + if (!HashAlgorithm.HasValue || !Enum.IsDefined(HashAlgorithm.Value)) + { + results.AddInvalid(nameof(HashAlgorithm), "value value must be specified: '{0}'", HashAlgorithm?.ToString() ?? ""); + } + + if (Storage == null) + { + results.AddInvalid(nameof(Storage), "must be specified"); + } + + return Task.CompletedTask; + } +} diff --git a/certmgr/Jobs/CreateCertificateJob.cs b/certmgr/Jobs/CreateCertificateJob.cs new file mode 100644 index 0000000..22a4c86 --- /dev/null +++ b/certmgr/Jobs/CreateCertificateJob.cs @@ -0,0 +1,95 @@ +using System.Security.Cryptography.X509Certificates; + +using CertMgr.CertGen; +using CertMgr.Core.Exceptions; +using CertMgr.Core.Jobs; +using CertMgr.Core.Log; +using CertMgr.Core.Storage; +using CertMgr.Core.Utils; + +namespace CertMgr.Jobs; + +public sealed class CreateCertificateJob : Job +{ + public const string ID = "create-certificate"; + + protected override async Task DoExecuteAsync(CancellationToken cancellationToken) + { + CertificateSettings cs = Settings; + CLog.Info("creating certificate using settings: subject = '{0}', algorithm = '{1}', curve = '{2}'", cs.Subject, cs.Algorithm?.ToString() ?? "", cs.Curve); + + GeneratorSettings gs = CreateGeneratorSettings(); + CertGen.CertificateSettings cgcs = await CreateCertificateSettingsAsync(cancellationToken).ConfigureAwait(false); + + CertificateManager cm = new CertificateManager(); + using (X509Certificate2 cert = await cm.CreateAsync(cgcs, gs, cancellationToken).ConfigureAwait(false)) + { + if (Settings.Storage != null) + { + byte[] data = cert.Export(X509ContentType.Pfx, Settings.Password); + using (MemoryStream ms = new MemoryStream()) + { + await ms.WriteAsync(data, cancellationToken); + ms.Position = 0; + StoreResult writeResult = await Settings.Storage.WriteAsync(ms, cancellationToken).ConfigureAwait(false); + + if (!writeResult.IsSuccess) + { + throw new JobException(writeResult.Exception, "Failed to write create certificate to target storage (type = '{0}', storage = '{1}')", Settings.Storage.GetType().ToString(false), Settings.Storage.ToString()); + } + } + + } + } + return CreateSuccess("Certificate was successfully created"); + } + + private GeneratorSettings CreateGeneratorSettings() + { + GeneratorSettings gs; + + switch (Settings.Algorithm) + { + case CertificateAlgorithm.ECDsa: + gs = new EcdsaGeneratorSettings(Settings.Curve.Value); + break; + case CertificateAlgorithm.RSA: + gs = new RsaGeneratorSettings(Settings.RsaKeySize.Value); + break; + default: + throw new UnsupportedValueException(Settings.Algorithm); + } + + return gs; + } + + private async Task CreateCertificateSettingsAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + CertGen.CertificateSettings cgcs = new CertGen.CertificateSettings(); + + X509KeyStorageFlags flags = X509KeyStorageFlags.Exportable | X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet; + cgcs.ValidityPeriod = Settings.ValidityPeriod.HasValue ? Settings.ValidityPeriod.Value : TimeSpan.FromDays(365); + cgcs.ExportableKeys = true; + cgcs.FriendlyName = "I'm your friend"; + if (Settings.Issuer != null) + { + using (MemoryStream ms = new MemoryStream()) + { + await Settings.Issuer.ReadAsync(ms, cancellationToken).ConfigureAwait(false); + cgcs.Issuer = X509CertificateLoader.LoadPkcs12(ms.GetBuffer(), Settings.IssuerPassword, flags); + } + } + cgcs.SubjectName = Settings.Subject; + if (Settings.SubjectAlternateNames != null) + { + foreach (string altName in Settings.SubjectAlternateNames) + { + cgcs.SubjectAlternateNames.Add(altName); + } + } + + return cgcs; + } +} diff --git a/certmgr/Program.cs b/certmgr/Program.cs new file mode 100644 index 0000000..68e563f --- /dev/null +++ b/certmgr/Program.cs @@ -0,0 +1,17 @@ +using CertMgr.Core; + +namespace CertMgr; + +internal static class Program +{ + private static async Task Main(string[] args) + { + args = ["--job=create-certificate", "--issuer=file|o|c:\\friend2.pfx", "--issuer-password=aaa", "--subject=hello", "--san=world", "--algorithm=ecdsa", "--ecdsa-curve=p384", "--storage=file|w|c:\\mycert.pfx", "--validity-period=2d"]; + // args = ["--job=create-certificate", "--subject=hello", "--algorithm=ecdsa", "--curve=p384"]; + using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + + JobExecutor executor = new JobExecutor(); + int errorLevel = await executor.ExecuteAsync(args, cts.Token).ConfigureAwait(false); + return errorLevel; + } +} \ No newline at end of file diff --git a/certmgr/certmgr.csproj b/certmgr/certmgr.csproj new file mode 100644 index 0000000..dff6c33 --- /dev/null +++ b/certmgr/certmgr.csproj @@ -0,0 +1,12 @@ + + + + Exe + net9.0 + enable + enable + ..\BuildOutput\bin + CertMgr + + +