diff --git a/.gitignore b/.gitignore index 91d2fe6..132a558 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .vs certmgr/obj/* -certmgr/Obsolete/* \ No newline at end of file +certmgr/Obsolete/* +certmgrTest/obj/* \ No newline at end of file diff --git a/BuildOutput/bin/Debug/net9.0/certmgr.dll b/BuildOutput/bin/Debug/net9.0/certmgr.dll index 8fdfb44..52c1c90 100644 Binary files a/BuildOutput/bin/Debug/net9.0/certmgr.dll 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 index f588920..2326a6c 100644 Binary files a/BuildOutput/bin/Debug/net9.0/certmgr.exe 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 index cd7dc15..0df438c 100644 Binary files a/BuildOutput/bin/Debug/net9.0/certmgr.pdb and b/BuildOutput/bin/Debug/net9.0/certmgr.pdb differ diff --git a/certmgr.sln b/certmgr.sln index eb36b9d..4b4fc09 100644 --- a/certmgr.sln +++ b/certmgr.sln @@ -1,10 +1,12 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36511.14 d17.14 +VisualStudioVersion = 17.14.36511.14 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "certmgr", "certmgr\certmgr.csproj", "{EB5C73A6-8EBF-4C9C-845F-4828C4985B64}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "certmgrTest", "certmgrTest\certmgrTest.csproj", "{D6D02CCB-DA1F-4575-9422-FCD632B782B3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {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 + {D6D02CCB-DA1F-4575-9422-FCD632B782B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D6D02CCB-DA1F-4575-9422-FCD632B782B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D6D02CCB-DA1F-4575-9422-FCD632B782B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D6D02CCB-DA1F-4575-9422-FCD632B782B3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/certmgr/CertGen/CertificateGeneratorBase.cs b/certmgr/CertGen/CertificateGeneratorBase.cs index 6a5d68d..6cadfbc 100644 --- a/certmgr/CertGen/CertificateGeneratorBase.cs +++ b/certmgr/CertGen/CertificateGeneratorBase.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Net; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -10,8 +11,9 @@ internal abstract class CertificateGeneratorBase : ICerti where TAlgorithm : AsymmetricAlgorithm where TSettings : GeneratorSettings { - internal CertificateGeneratorBase(TSettings settings) + internal CertificateGeneratorBase(GeneratorType type, TSettings settings) { + Type = type; Settings = settings; } @@ -20,6 +22,8 @@ internal abstract class CertificateGeneratorBase : ICerti return ValueTask.CompletedTask; } + public GeneratorType Type { [DebuggerStepThrough] get; } + protected TSettings Settings { [DebuggerStepThrough] get; } public async Task CreateAsync(CertificateSettings settings, CancellationToken cancellationToken) @@ -30,8 +34,6 @@ internal abstract class CertificateGeneratorBase : ICerti return cert; } - protected abstract bool IsEphemeral(TAlgorithm key); - protected virtual void ValidateSettings(CertificateSettings settings) { if (settings.Issuer != null) @@ -71,37 +73,12 @@ internal abstract class CertificateGeneratorBase : ICerti 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) { + cancellationToken.ThrowIfCancellationRequested(); + X509Certificate2 cert; using (TAlgorithm privateKey = CreatePrivateKey()) @@ -128,6 +105,50 @@ internal abstract class CertificateGeneratorBase : ICerti return Task.FromResult(cert); } + private CertificateRequest CreateRequest(CertificateSettings settings, TAlgorithm privateKey) + { + string commonName = CreateCommonName(settings.SubjectName); + CertificateRequest request = DoCreateRequest(commonName, privateKey); + + SubjectAlternativeNameBuilder altNames = new SubjectAlternativeNameBuilder(); + string subj = commonName.Substring("CN=".Length); + if (!settings.SubjectAlternateNames.Contains(new SubjectAlternateName(SANKind.DNS, subj))) + { + altNames.AddDnsName(subj); + } + + foreach (SubjectAlternateName altName in settings.SubjectAlternateNames) + { + switch (altName.Kind) + { + case SANKind.DNS: + altNames.AddDnsName(altName.Value); + break; + case SANKind.IP: + if (IPAddress.TryParse(altName.Value, out IPAddress? ipAddress)) + { + altNames.AddIpAddress(ipAddress); + } + break; + default: + throw new UnsupportedValueException(altName.Kind); + } + } + 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 X509SignatureGenerator GetSignatureGenerator(X509Certificate2 issuerCertificate) { X509SignatureGenerator? sgen = null; @@ -183,7 +204,7 @@ internal abstract class CertificateGeneratorBase : ICerti using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) { - rng.GetBytes(serial); + rng.GetNonZeroBytes(serial); } return serial; @@ -212,6 +233,6 @@ internal abstract class CertificateGeneratorBase : ICerti public override string ToString() { - return string.Format("Type = {0}", this is RsaCertificateGenerator ? "RSA" : this is EcdsaCertificateGenerator ? "ECDSA" : ""); + return string.Format("Type = {0}", Type); } } diff --git a/certmgr/CertGen/CertificateSettings.cs b/certmgr/CertGen/CertificateSettings.cs index 98945d4..fdd3ee7 100644 --- a/certmgr/CertGen/CertificateSettings.cs +++ b/certmgr/CertGen/CertificateSettings.cs @@ -10,7 +10,7 @@ public sealed class CertificateSettings { internal CertificateSettings() { - SubjectAlternateNames = new SubjectAlternateNames([NetUtils.MachineName]); + SubjectAlternateNames = new SubjectAlternateNames(); SubjectName = NetUtils.MachineName; Issuer = null; diff --git a/certmgr/CertGen/EcdsaCertificateGenerator.cs b/certmgr/CertGen/EcdsaCertificateGenerator.cs index 85dbd13..32d024c 100644 --- a/certmgr/CertGen/EcdsaCertificateGenerator.cs +++ b/certmgr/CertGen/EcdsaCertificateGenerator.cs @@ -8,7 +8,7 @@ namespace CertMgr.CertGen; internal sealed class EcdsaCertificateGenerator : CertificateGeneratorBase { internal EcdsaCertificateGenerator(EcdsaGeneratorSettings settings) - : base(settings) + : base(GeneratorType.Ecdsa, settings) { } @@ -33,16 +33,6 @@ internal sealed class EcdsaCertificateGenerator : CertificateGeneratorBase { internal RsaCertificateGenerator(RsaGeneratorSettings settings) - : base(settings) + : base(GeneratorType.RSA, settings) { } @@ -33,16 +33,6 @@ internal sealed class RsaCertificateGenerator : CertificateGeneratorBase +{ + public SubjectAlternateName(SANKind kind, string value) + { + Kind = kind; + Value = value; + } + + public SubjectAlternateName(SANKind kind, ReadOnlySpan value) + { + Kind = kind; + Value = value.ToString(); + } + + public SANKind Kind { [DebuggerStepThrough] get; } + + public string Value { [DebuggerStepThrough] get; } + + public override string ToString() + { + return string.Format("{0}: '{1}'", Kind, Value); + } + + public override int GetHashCode() + { + return (Kind, Value).GetHashCode(); + } + + public override bool Equals(object? obj) + { + return Equals(obj as SubjectAlternateName); + } + + public bool Equals(SubjectAlternateName? other) + { + if (other is null) + { + return false; + } + + if (Kind != other.Kind) + { + return false; + } + + if (!string.Equals(Value, other.Value, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return true; + } +} diff --git a/certmgr/CertGen/SubjectAlternateNames.cs b/certmgr/CertGen/SubjectAlternateNames.cs index 73da242..95e2a22 100644 --- a/certmgr/CertGen/SubjectAlternateNames.cs +++ b/certmgr/CertGen/SubjectAlternateNames.cs @@ -1,30 +1,119 @@ using System.Collections; +using CertMgr.Core.Log; +using CertMgr.Core.Utils; + namespace CertMgr.CertGen; -public sealed class SubjectAlternateNames : IReadOnlyCollection +public sealed class SubjectAlternateNames : IReadOnlyCollection { - private readonly HashSet _items; + private readonly HashSet _items; public SubjectAlternateNames() { - _items = new HashSet(StringComparer.OrdinalIgnoreCase); + _items = new HashSet(); } public SubjectAlternateNames(IReadOnlyCollection items) { - _items = new HashSet(items, StringComparer.OrdinalIgnoreCase); + _items = new HashSet(); + foreach (string item in items) + { + Add(item); + } } public int Count => _items.Count; public bool Add(string name) { - bool added = _items.Add(name); + bool added = false; + + // any kind of value except for DNS must be prefixed with its kind, + // e.g. "IP:w.x.y.z" or "IP:2001:db8::1" or "DNS:example.com" + // if not prefixed then it must be DNS + + ReadOnlySpan span = name.AsSpan(); + if (span.StartsWith("DNS:", StringComparison.OrdinalIgnoreCase)) + { + added = AddDnsName(span); + } + else if (span.StartsWith("IP:", StringComparison.OrdinalIgnoreCase)) + { + added = AddIPAddress(span); + } + else + { + // fallback to dns name as other alt-names (UPN, URI, email..) are not supported + added = AddDnsName(span); + } + return added; } - public IEnumerator GetEnumerator() + public bool AddDnsName(string name) + { + bool added = AddDnsName(name.AsSpan()); + return added; + } + + public bool AddDnsName(ReadOnlySpan name) + { + bool added = false; + + ReadOnlySpan span = name; + if (span.StartsWith("DNS:", StringComparison.OrdinalIgnoreCase)) + { + span = span.Slice(4); + } + + if (NetUtils.IsValidDns(span)) + { + added = _items.Add(new SubjectAlternateName(SANKind.DNS, span)); + } + else + { + CLog.Error("{0}: Value '{1}' is not valid DNS name", nameof(SubjectAlternateNames), name.ToString()); + } + + return added; + } + + public bool AddIPAddress(string ip) + { + bool added = AddIPAddress(ip.AsSpan()); + return added; + } + + public bool AddIPAddress(ReadOnlySpan ip) + { + bool added = false; + + ReadOnlySpan span = ip; + if (span.StartsWith("IP:", StringComparison.OrdinalIgnoreCase)) + { + span = span.Slice(3); + } + + if (NetUtils.IsValidIPAny(span)) + { + added = _items.Add(new SubjectAlternateName(SANKind.IP, span)); + } + else + { + CLog.Error("{0}: Value '{1}' is not valid IP address", nameof(SubjectAlternateNames), ip.ToString()); + } + + return added; + } + + public bool Contains(SubjectAlternateName name) + { + bool contains = _items.Contains(name); + return contains; + } + + public IEnumerator GetEnumerator() { return _items.GetEnumerator(); } diff --git a/certmgr/CertGen/Utils/StorageToX509CertificateAdapter.cs b/certmgr/CertGen/Utils/StorageToX509CertificateAdapter.cs deleted file mode 100644 index 89c9567..0000000 --- a/certmgr/CertGen/Utils/StorageToX509CertificateAdapter.cs +++ /dev/null @@ -1,49 +0,0 @@ -/*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/CertGen/Utils/SubjectValidator.cs b/certmgr/CertGen/Utils/SubjectValidator.cs new file mode 100644 index 0000000..762f8fa --- /dev/null +++ b/certmgr/CertGen/Utils/SubjectValidator.cs @@ -0,0 +1,40 @@ +using System.Diagnostics; +using System.Security.Cryptography.X509Certificates; + +using CertMgr.Core.Validation; + +namespace CertMgr.CertGen.Utils; + +public sealed class SubjectValidator : ISettingValidator +{ + public SubjectValidator(string settingName) + { + SettingName = settingName; + } + + public string SettingName { [DebuggerStepThrough] get; } + + public Task ValidateAsync(string? settingValue, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(settingValue)) + { + return Task.FromResult(new ValidationResult(SettingName, false, "must not be null")); + } + + try + { + X500DistinguishedName dn = new X500DistinguishedName(settingValue); + + return Task.FromResult(new ValidationResult(SettingName, true, "success")); + } + catch (Exception e) + { + return Task.FromResult(new ValidationResult(SettingName, false, "invalid value: '{0}'. Exception = {1}: {2}", settingValue ?? "", e.GetType().Name, e.Message)); + } + } + + public Task ValidateAsync(object? value, CancellationToken cancellationToken) + { + return ValidateAsync(value as string, cancellationToken); + } +} diff --git a/certmgr/Core/Converters/Impl/EnumConverter.cs b/certmgr/Core/Converters/Impl/EnumConverter.cs index fe8c81c..d403996 100644 --- a/certmgr/Core/Converters/Impl/EnumConverter.cs +++ b/certmgr/Core/Converters/Impl/EnumConverter.cs @@ -8,36 +8,26 @@ public sealed class EnumConverter : ValueConverter { Type resultType; - bool isNullable = targetType.IsGenericType && targetType.GetGenericTypeDefinition() == typeof(Nullable<>); - if (isNullable) + Type? underlying = Nullable.GetUnderlyingType(targetType); + if (underlying != null) { - 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)); - } + resultType = underlying; } else { - if (!targetType.IsEnum) - { - resultType = targetType; - } - else - { - throw new ConverterException("Cannot convert type '{0}' as enum as it is not enum", targetType.ToString(false)); - } + resultType = targetType; } - object? result = Enum.Parse(resultType, rawValue, true); - return Task.FromResult((Enum?)result); + if (!resultType.IsEnum) + { + throw new ConverterException("Cannot convert type '{0}' as enum as it is not enum or nullable", targetType.ToString(false)); + } + + if (Enum.TryParse(resultType, rawValue, true, out object? result) && Enum.IsDefined(resultType, result)) + { + return Task.FromResult((Enum?)result); + } + + return Task.FromResult((Enum?)null); } } diff --git a/certmgr/Core/Converters/Impl/EnumNullableConverter.cs b/certmgr/Core/Converters/Impl/EnumNullableConverter.cs deleted file mode 100644 index bed3c59..0000000 --- a/certmgr/Core/Converters/Impl/EnumNullableConverter.cs +++ /dev/null @@ -1,15 +0,0 @@ -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/SettingsBuilder.cs b/certmgr/Core/SettingsBuilder.cs index dfe1b61..d2b312a 100644 --- a/certmgr/Core/SettingsBuilder.cs +++ b/certmgr/Core/SettingsBuilder.cs @@ -1,5 +1,6 @@ using System.Collections; using System.ComponentModel; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Reflection; @@ -25,62 +26,138 @@ internal sealed class SettingsBuilder _settingsType = settingsType; } + private static IReadOnlyList GetValidatedTypes(Type validatorType) + { + if (validatorType == null) + { + throw new ArgumentNullException(nameof(validatorType)); + } + + List hits = new List(); + + if (validatorType.IsInterface && validatorType.IsGenericType && validatorType.GetGenericTypeDefinition() == typeof(ISettingValidator<>)) + { + Type candidate = validatorType.GetGenericArguments()[0]; + hits.Add(candidate); + } + + foreach (Type iface in validatorType.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(ISettingValidator<>)) + { + Type candidate = iface.GetGenericArguments()[0]; + if (!hits.Contains(candidate)) + { + hits.Add(candidate); + } + } + } + + return hits; + } + + private async Task> SetPropertyValueAsync(JobSettings settings, PropertyInfo propertyInfo, TypeInfo propertyType, SettingAttribute settingAttribute, CancellationToken cancellationToken) + { + object? convertedValue = null; + bool valueSet = false; + + if (TryGetRawArgument(settingAttribute, out RawArgument? rawArg)) + { + try + { + AsyncResult conversionResult = await ConvertRawValueAsync(settingAttribute, rawArg, propertyType.IsCollection, propertyInfo.PropertyType, propertyType.ElementType, cancellationToken).ConfigureAwait(false); + if (conversionResult.IsSuccess) + { + propertyInfo.SetValue(settings, conversionResult.Value); + convertedValue = conversionResult.Value; + valueSet = true; + } + } + catch (Exception e) + { + CLog.Error(e, "Failed to process property '{0}' (of type '{1}') in settings of type '{2}'", propertyInfo.Name, propertyInfo.PropertyType.ToString(false), _settingsType.ToString(false)); + throw new CertMgrException(e, "Failed to process property '{0}' (of type '{1}') in settings of type '{2}'", propertyInfo.Name, propertyInfo.PropertyType.ToString(false), _settingsType.ToString(false)); + } + } + 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) + { + TypeInfo typeInfo = UnwrapCollection(propertyInfo.PropertyType); + if (settingAttribute.Default.GetType() == typeInfo.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().ToString(false) ?? "", typeInfo.ElementType.ToString(false)); + } + + } + return new AsyncResult(valueSet, convertedValue); + } + 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); + TypeInfo propertyType = UnwrapCollection(propertyInfo.PropertyType); + AsyncResult setPropertyResult = await SetPropertyValueAsync(settings, propertyInfo, propertyType, settingAttribute, cancellationToken).ConfigureAwait(false); + if (setPropertyResult.IsSuccess) + { try { - (bool converted, object? convertedValue) = await ConvertRawValueAsync(settingAttribute, rawArg, isCollection, propertyInfo.PropertyType, elementType, cancellationToken).ConfigureAwait(false); - if (converted) + if (settingAttribute.Validator != null) { - propertyInfo.SetValue(settings, convertedValue); + IReadOnlyList validatedTypes = GetValidatedTypes(settingAttribute.Validator); - if (settingAttribute.Validator != null) + ISettingValidator? validatorInst = (ISettingValidator?)Activator.CreateInstance(settingAttribute.Validator, [settingAttribute.Name]); + + if (propertyType.IsCollection) { - ISettingValidator? validator = (ISettingValidator?)Activator.CreateInstance(settingAttribute.Validator, [settingAttribute.Name]); - if (validator != null) + TypeInfo validatedTypeInfo = UnwrapCollection(validatedTypes[0]); + if (validatedTypeInfo.IsCollection) { - ValidationResult valres = await validator.ValidateAsync(convertedValue, cancellationToken).ConfigureAwait(false); + // validator validates collection => send whole collection as argument to ValidateAsync(..) + ValidationResult valres = await validatorInst.ValidateAsync(setPropertyResult.Value, cancellationToken).ConfigureAwait(false); settings.ValidationResults.Add(valres); } + else + { + // validator validates elements => send items one by one to ValidateAsync(..) + if (setPropertyResult.Value is IList list) + { + foreach (object value in list) + { + ValidationResult valres = await validatorInst.ValidateAsync(value, cancellationToken).ConfigureAwait(false); + settings.ValidationResults.Add(valres); + } + } + } + } + else + { + // setting is not a collection. lets assume the validator doesn't validate collection as well + ValidationResult valres = await validatorInst.ValidateAsync(setPropertyResult.Value, cancellationToken).ConfigureAwait(false); + settings.ValidationResults.Add(valres); } } } catch (Exception e) { - CLog.Error(e, "Failed to process property '{0}' (of type '{1}') in settings of type '{2}'", propertyInfo.Name, propertyInfo.PropertyType.ToString(false), _settingsType.ToString(false)); + CLog.Error(e, "Failed to validate property '{0}' (of type '{1}') in settings of type '{2}'", propertyInfo.Name, propertyInfo.PropertyType.ToString(false), _settingsType.ToString(false)); throw new CertMgrException(e, "Failed to process property '{0}' (of type '{1}') in settings of type '{2}'", propertyInfo.Name, propertyInfo.PropertyType.ToString(false), _settingsType.ToString(false)); } } - 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().ToString(false) ?? "", elementType.ToString(false)); - } - } } - await settings.ValidateAsync(cancellationToken).ConfigureAwait(false); - return settings; } @@ -105,13 +182,6 @@ internal sealed class SettingsBuilder 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)) @@ -150,62 +220,65 @@ internal sealed class SettingsBuilder return settings; } - private async Task<(bool success, object? convertedValue)> ConvertRawValueAsync(SettingAttribute settingAttribute, RawArgument rawArg, bool isCollection, Type collectionType, Type elementType, CancellationToken cancellationToken) + private async Task> ConvertRawValueAsync(SettingAttribute settingAttribute, RawArgument rawArg, bool isCollection, Type collectionType, Type elementType, CancellationToken cancellationToken) { bool success = false; object? convertedValue = null; + IValueConverter? customConverter = GetCustomConverter(settingAttribute); + if (isCollection) { - if (TryGetCustomConverter(settingAttribute, out IValueConverter? customConverter)) - { - Type listType = typeof(List<>).MakeGenericType(elementType); - IList values = (IList)Activator.CreateInstance(listType)!; + Type listType = typeof(List<>).MakeGenericType(elementType); + IList values = (IList)Activator.CreateInstance(listType)!; - foreach (string rawValue in rawArg.Values) - { - convertedValue = await customConverter.ConvertAsync(rawValue, elementType, cancellationToken).ConfigureAwait(false); - values.Add(convertedValue); - } - convertedValue = values; - success = true; - } - else + foreach (string rawValue in rawArg.Values) { - Type listType = typeof(List<>).MakeGenericType(elementType); - IList values = (IList)Activator.CreateInstance(listType)!; - - foreach (string rawValue in rawArg.Values) + AsyncResult converted = await ConvertValueAsync(rawValue, customConverter, elementType, cancellationToken).ConfigureAwait(false); + if (converted.IsSuccess) { - if (TryConvertValue(rawValue, elementType, out convertedValue)) - { - values.Add(convertedValue); - } + values.Add(converted.Value); } - convertedValue = values; // BuildCollectionValue(collectionType, elementType, values); - success = true; } + convertedValue = values; + success = true; } else { - if (TryGetCustomConverter(settingAttribute, out IValueConverter? customConverter)) - { - convertedValue = await customConverter.ConvertAsync(rawArg.Values.First(), elementType, cancellationToken).ConfigureAwait(false); - success = true; - } - else if (TryConvertValue(rawArg.Values.First(), elementType, out convertedValue)) + AsyncResult converted = await ConvertValueAsync(rawArg.Values.First(), customConverter, elementType, cancellationToken).ConfigureAwait(false); + if (converted.IsSuccess) { + convertedValue = converted.Value; success = true; } else { + CLog.Error("Cannot convert value '{0}' of argument '{1}' to type '{2}'", rawArg.Values.First(), settingAttribute.Name, elementType.ToString(false)); } } - return (success, convertedValue); + return new AsyncResult(success, convertedValue); } - private static object BuildCollectionValue(Type collectionType, Type elementType, IReadOnlyList items) + private async Task> ConvertValueAsync(string rawValue, IValueConverter? customConverter, Type elementType, CancellationToken cancellationToken) + { + bool success; + object? convertedValue; + if (customConverter != null) + { + convertedValue = await customConverter.ConvertAsync(rawValue, elementType, cancellationToken).ConfigureAwait(false); + success = true; + } + else + { + success = TryConvertValue(rawValue, elementType, out convertedValue); + } + + return new AsyncResult(success, convertedValue); + } + + + /*private static object BuildCollectionValue(Type collectionType, Type elementType, IReadOnlyList items) { // convert source collection with 'items' of type 'object?' to collection with items of requested type: Type listType = typeof(List<>).MakeGenericType(elementType); @@ -255,11 +328,11 @@ internal sealed class SettingsBuilder Array fallback = Array.CreateInstance(elementType, typedList.Count); typedList.CopyTo(fallback, 0); return fallback; - } + }*/ - private bool TryGetCustomConverter(SettingAttribute settingAttribute, [NotNullWhen(true)] out IValueConverter? customConverter) + private IValueConverter? GetCustomConverter(SettingAttribute settingAttribute) { - customConverter = null; + IValueConverter? customConverter = null; Type? valueConverter = settingAttribute.Converter; if (valueConverter != null) @@ -274,16 +347,22 @@ internal sealed class SettingsBuilder } } - return customConverter != null; + return customConverter; } private bool TryConvertValue(string rawValue, Type targetType, out object? convertedValue) { convertedValue = null; + Type? underlying = Nullable.GetUnderlyingType(targetType); + if (underlying != null) + { + targetType = underlying; + } + if (targetType.IsEnum) { - if (Enum.TryParse(targetType, rawValue, true, out object? result)) + if (Enum.TryParse(targetType, rawValue, true, out object? result) && Enum.IsDefined(targetType, result)) { convertedValue = result; } @@ -308,37 +387,51 @@ internal sealed class SettingsBuilder return convertedValue != null; } - private static (bool isCollection, Type? elementType) UnwrapCollection(Type type) + private static TypeInfo UnwrapCollection(Type type) { if (type == typeof(string)) { - return (false, null); + return new TypeInfo(false, type); } + Type testedType = type; Type? underlying = Nullable.GetUnderlyingType(type); if (underlying != null) { - type = underlying; + testedType = underlying; } - if (type.IsArray) + if (testedType.IsArray) { - return (true, type.GetElementType()!); + return new TypeInfo(true, testedType.GetElementType()!); } - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + if (testedType.IsGenericType && testedType.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - return (true, type.GetGenericArguments()[0]); + return new TypeInfo(true, testedType.GetGenericArguments()[0]); } - foreach (Type i in type.GetInterfaces()) + foreach (Type i in testedType.GetInterfaces()) { if (i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - return (true, i.GetGenericArguments()[0]); + return new TypeInfo(true, i.GetGenericArguments()[0]); } } - return (false, null); + return new TypeInfo(false, type); + } + + private sealed class TypeInfo + { + public TypeInfo(bool isCollection, Type elementType) + { + IsCollection = isCollection; + ElementType = elementType; + } + + public bool IsCollection { [DebuggerStepThrough] get; } + + public Type ElementType { [DebuggerStepThrough] get; } } } diff --git a/certmgr/Core/Utils/AsyncResult.cs b/certmgr/Core/Utils/AsyncResult.cs new file mode 100644 index 0000000..c8fbaab --- /dev/null +++ b/certmgr/Core/Utils/AsyncResult.cs @@ -0,0 +1,21 @@ +using System.Diagnostics; + +namespace CertMgr.Core.Utils; + +public struct AsyncResult +{ + public AsyncResult(bool success, T value) + { + IsSuccess = success; + Value = value; + } + + public bool IsSuccess { [DebuggerStepThrough] get; } + + public T Value { [DebuggerStepThrough] get; } + + public override string ToString() + { + return string.Format("{0}: {1}", IsSuccess ? "Succeeded" : "Failed", Value?.ToString() ?? ""); + } +} diff --git a/certmgr/Core/Utils/NetUtils.cs b/certmgr/Core/Utils/NetUtils.cs index 6440d66..6c82cf7 100644 --- a/certmgr/Core/Utils/NetUtils.cs +++ b/certmgr/Core/Utils/NetUtils.cs @@ -1,5 +1,7 @@ using System.Net; using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Text.RegularExpressions; using CertMgr.Core.Exceptions; @@ -7,6 +9,87 @@ namespace CertMgr.Core.Utils; public static class NetUtils { + // following regex validates string as DNS names: + // - every label has up to 63 chars + // - label contains only [a-z][A-Z][0-9]- + // - label cannot start and/or end with dash char ('-') + // - full name has up to 253 chars + // - case insensitive + // private static readonly Regex DNSRegex = new Regex(@"^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)(?:\.[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)*$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + // following regex accepts (in addition to previous one): + // - wildcard certificates (e.g. *.example.com) and + // - 'root label' (terminating dot) (e.g. example.com.) + private static readonly Regex DNSRegex = new Regex(@"^(?=.{1,254}$)(?:\*\.)?(?:[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)(?:\.[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)*\.?$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + /* + * Following code is not used as IPAddress.TryParse(...) seems to be simpler choice to verify input string, but keeping both ipv4 and ipv6 regex just in case + + private const string IPv4octet = "(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])"; + private const string IPv6hextet = "[0-9A-F]"; // should be [0-9A-Fa-f] but since Regex is created with RegexOptions.IgnoreCase it is not necessary + private const string IPv4 = "(?:" + IPv4octet + @"\.){3}" + IPv4octet; + + private static readonly Regex IPv4Regex = new Regex("^" + IPv4 + "$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex IPv6Regex = new Regex( + @"^(?:(?:" + IPv6hextet + @"{1,4}:){7}" + IPv6hextet + @"{1,4}|(?:" + IPv6hextet + @"{1,4}:){1,7}:" + + @"|" + + @"(?:" + IPv6hextet + @"{1,4}:){1,6}:" + IPv6hextet + @"{1,4}" + + @"|" + + @"(?:" + IPv6hextet + @"{1,4}:){1,5}(?::" + IPv6hextet + @"{1,4}){1,2}" + + @"|" + + @"(?:" + IPv6hextet + @"{1,4}:){1,4}(?::" + IPv6hextet + @"{1,4}){1,3}" + + @"|" + + @"(?:" + IPv6hextet + @"{1,4}:){1,3}(?::" + IPv6hextet + @"{1,4}){1,4}" + + @"|" + + @"(?:" + IPv6hextet + @"{1,4}:){1,2}(?::" + IPv6hextet + @"{1,4}){1,5}" + + @"|" + + @"" + IPv6hextet + @"{1,4}:(?:(?::" + IPv6hextet + @"{1,4}){1,6})" + + @"|" + + @":(?:(?::" + IPv6hextet + @"{1,4}){1,7}|:)" + + @"|" + + @"(?:" + IPv6hextet + @"{1,4}:){6}" + IPv4 + + @"|" + + @"(?:" + IPv6hextet + @"{1,4}:){1,5}:" + IPv4 + + @"|" + + @"(?:" + IPv6hextet + @"{1,4}:){1,4}:(?::" + IPv6hextet + @"{1,4})?" + + @"(?::" + IPv6hextet + @"{1,4})?" + @":" + IPv4 + + @"|" + + @"(?:" + IPv6hextet + @"{1,4}:){1,3}(?::" + IPv6hextet + @"{1,4}){1,2}:" + IPv4 + + @"|" + + @"(?:" + IPv6hextet + @"{1,4}:){1,2}(?::" + IPv6hextet + @"{1,4}){1,3}:" + IPv4 + + @"|" + + @"" + IPv6hextet + @"{1,4}(?::" + IPv6hextet + @"{1,4}){1,4}:" + IPv4 + + @"|" + + @"::(?:[0-9A-Fa-f]{1,4}:){1,5}" + IPv4 + + @"|" + + @"::" + IPv4 + + @")$", + RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + + public static bool IsValidIPv4(ReadOnlySpan value) + { + if (value.Length == 0) + { + return false; + } + + bool match = IPv4Regex.IsMatch(value); + return match; + } + + public static bool IsValidIPv6(ReadOnlySpan value) + { + if (value.Length == 0) + { + return false; + } + + bool match = IPv6Regex.IsMatch(value); + return match; + } + */ + /// Returns DNS name of the local computer. public static string MachineName => GetMachineName(MachineNameFormat.Hostname); @@ -34,4 +117,260 @@ public static class NetUtils return name; } + + public static bool IsValidDns(string value) + { + if (string.IsNullOrEmpty(value)) + { + return false; + } + + return IsValidDns(value.AsSpan()); + } + + public static bool IsValidDns(ReadOnlySpan value) + { + if (value.Length == 0) + { + return false; + } + + bool match = DNSRegex.IsMatch(value); + return match; + } + + public static bool IsValidIPv4(string value) + { + if (string.IsNullOrEmpty(value)) + { + return false; + } + + return IsValidIPv4(value.AsSpan()); + } + + public static bool IsValidIPv4(ReadOnlySpan value) + { + bool valid = false; + + if (IPAddress.TryParse(value, out IPAddress? ip)) + { + valid = ip.AddressFamily == AddressFamily.InterNetwork; + } + + return valid; + } + + public static bool IsValidIPv6(string value) + { + if (string.IsNullOrEmpty(value)) + { + return false; + } + + return IsValidIPv6(value.AsSpan()); + } + + public static bool IsValidIPv6(ReadOnlySpan value) + { + bool valid = false; + + // it seems that IPAddress.TryParse doesn't handle zone/scope-id correctly (at least on windows) + // %eth0 or %eth1 and basically all non-numeric strings are "translated" to scope-id = 0 + // (possibly because such a zones/scope-ids doesn't exist on my machine?) + // on the other hand - when ip ends with %5 then the scope-id is kept (even if such a zone/scope-id doesn't exist on my machine + if (IPAddress.TryParse(value, out IPAddress? ip)) + { + valid = ip.AddressFamily == AddressFamily.InterNetworkV6; + } + + return valid; + } + + public static bool IsValidIPAny(string value) + { + if (string.IsNullOrEmpty(value)) + { + return false; + } + + return IsValidIPAny(value.AsSpan()); + } + + public static bool IsValidIPAny(ReadOnlySpan value) + { + if (value.Length == 0) + { + return false; + } + + bool match = IsValidIPv4(value) || IsValidIPv6(value); + return match; + } + + public static bool IsSameMachine(string machineA, string machineB) + { + if (string.IsNullOrWhiteSpace(machineA) || string.IsNullOrWhiteSpace(machineB)) + { + return false; + } + + machineA = machineA.Trim(); + machineB = machineB.Trim(); + + string textualA = machineA.TrimEnd('.').ToLowerInvariant(); + string textualB = machineB.TrimEnd('.').ToLowerInvariant(); + if (textualA == textualB) + { + return true; + } + + try + { + IPAddress[] addrSetA = Dns.GetHostAddresses(machineA); + IPAddress[] addrSetB = Dns.GetHostAddresses(machineB); + + if (addrSetA.Length == 0 || addrSetB.Length == 0) + { + return false; + } + + HashSet normalizedAddrSetA = new HashSet(addrSetA.Select(NormalizeIp)); + foreach (IPAddress addrB in addrSetB) + { + IPAddress normalizedAddrB = NormalizeIp(addrB); + if (normalizedAddrSetA.Contains(normalizedAddrB)) + { + return true; + } + } + + return false; + } + catch (SocketException e) + { + e.GetType(); + return false; + } + catch (ArgumentException e) + { + e.GetType(); + return false; + } + } + + public static bool IsLocalMachine(string machine) + { + if (string.IsNullOrWhiteSpace(machine)) + { + return false; + } + + string normalizedMachine = machine.Trim().TrimEnd('.').ToLowerInvariant(); + + if (normalizedMachine == "localhost" || normalizedMachine == "127.0.0.1" || normalizedMachine == "::1" || normalizedMachine == MachineName.ToLowerInvariant()) + { + return true; + } + + HashSet localAddrSet = GetNormalizedLocalIpSet(true); + + // possibly it is IP - this can be solved without involving the DNS: + if (IPAddress.TryParse(normalizedMachine, out IPAddress? machineIp)) + { + if (IPAddress.IsLoopback(machineIp)) + { + return true; + } + + IPAddress normalizedMachineIp = NormalizeIp(machineIp); + return localAddrSet.Contains(normalizedMachineIp); + } + + // not IP, DNS needed: + try + { + IPAddress[] resolved = Dns.GetHostAddresses(normalizedMachine); + if (resolved.Length > 0) + { + for (int i = 0; i < resolved.Length; i++) + { + IPAddress normalized = NormalizeIp(resolved[i]); + if (localAddrSet.Contains(normalized)) + { + return true; + } + } + } + } + catch (Exception e) + { + e.GetType(); + // DNS failed + } + + return false; + } + + private static HashSet GetNormalizedLocalIpSet(bool includeLoopbacks) + { + HashSet result = new HashSet(); + + foreach (NetworkInterface ni in NetworkInterface.GetAllNetworkInterfaces()) + { + if (ni.OperationalStatus != OperationalStatus.Up) + { + continue; + } + + IPInterfaceProperties? props; + try + { + props = ni.GetIPProperties(); + } + catch + { + continue; + } + + foreach (UnicastIPAddressInformation ua in props.UnicastAddresses) + { + IPAddress address = ua.Address; + + if (!includeLoopbacks && IPAddress.IsLoopback(address)) + { + continue; + } + + result.Add(NormalizeIp(address)); + } + } + + if (includeLoopbacks) + { + result.Add(NormalizeIp(IPAddress.Loopback)); + result.Add(NormalizeIp(IPAddress.IPv6Loopback)); + } + + return result; + } + + private static IPAddress NormalizeIp(IPAddress ip) + { + // convert 'IPv6-mapped IPv4' to IPv4 (e.g. (::ffff:a.b.c.d => a.b.c.d): + if (ip.AddressFamily == AddressFamily.InterNetworkV6 && ip.IsIPv4MappedToIPv6) + { + return ip.MapToIPv4(); + } + + // remove scope-id (e.g. fe80::1%eth0 → fe80::1): + if (ip.AddressFamily == AddressFamily.InterNetworkV6) + { + byte[] bytes = ip.GetAddressBytes(); + IPAddress withoutScope = new IPAddress(bytes); + return withoutScope; + } + + return ip; + } } diff --git a/certmgr/Core/Validation/SettingValidatorT.cs b/certmgr/Core/Validation/SettingValidatorT.cs new file mode 100644 index 0000000..9289401 --- /dev/null +++ b/certmgr/Core/Validation/SettingValidatorT.cs @@ -0,0 +1,32 @@ +using System.Diagnostics; + +using CertMgr.Core.Utils; + +namespace CertMgr.Core.Validation; + +public abstract class SettingValidator : ISettingValidator +{ + protected SettingValidator(string settingName) + { + SettingName = settingName; + } + + public string SettingName { [DebuggerStepThrough] get; } + + public abstract Task ValidateAsync(T? settingValue, CancellationToken cancellationToken); + + public Task ValidateAsync(object? settingValue, CancellationToken cancellationToken) + { + T? typedValue; + try + { + typedValue = (T?)settingValue; + } + catch (Exception e) + { + throw new CertMgrException(e, "SettingValidator of type '{0}' failed to convert value of type '{1}' to type '{2}' (setting-name = '{3}')", GetType().ToString(false), settingValue?.GetType().ToString(false) ?? "", typeof(T).ToString(false), SettingName); + } + + return ValidateAsync(typedValue, cancellationToken); + } +} diff --git a/certmgr/Jobs/CertificateSettings.cs b/certmgr/Jobs/CertificateSettings.cs index 0b7378d..5287bc0 100644 --- a/certmgr/Jobs/CertificateSettings.cs +++ b/certmgr/Jobs/CertificateSettings.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using CertMgr.CertGen; +using CertMgr.CertGen.Utils; using CertMgr.Core.Attributes; using CertMgr.Core.Converters.Impl; using CertMgr.Core.Jobs; @@ -19,10 +20,15 @@ public sealed class CertificateSettings : JobSettings ValidityPeriod = TimeSpan.FromDays(365); } - [Setting("subject", IsMandatory = true, Validator = typeof(StringValidator.IsNotNull))] + [Setting("subject", IsMandatory = true, Validator = typeof(SubjectValidator))] public string? Subject { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } - [Setting("subject-alternate-name", AlternateNames = ["san"])] + // value can be: + // - plain hostname + // - DNS:hostname (i.e. with 'DNS:' prefix) + // - IP:ip-address (i.e. with 'IP:' prefix) + // other types (URI, UPN, email) are not supported + [Setting("subject-alternate-name", AlternateNames = ["san"], Validator = typeof(SubjectAlternateNameValidator))] public IReadOnlyCollection? SubjectAlternateNames { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } [Setting("algorithm", Default = CertificateAlgorithm.ECDsa, Converter = typeof(EnumConverter))] @@ -31,7 +37,7 @@ public sealed class CertificateSettings : JobSettings [Setting("ecdsa-curve")] public EcdsaCurve? Curve { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } - [Setting("rsa-key-size")] + [Setting("rsa-key-size", Converter = typeof(RsaKeySizeConverter))] public RsaKeySize? RsaKeySize { [DebuggerStepThrough] get; [DebuggerStepThrough] set; } [Setting("hash-algorithm", AlternateNames = ["ha"])] @@ -70,7 +76,7 @@ public sealed class CertificateSettings : JobSettings } else if (Algorithm == CertificateAlgorithm.RSA) { - if (!RsaKeySize.HasValue || !Enum.IsDefined(RsaKeySize.Value)) + if (!RsaKeySize.HasValue || !Enum.IsDefined(RsaKeySize.Value)) { results.AddInvalid(nameof(RsaKeySize), "value value must be specified: '{0}'", RsaKeySize?.ToString() ?? ""); } diff --git a/certmgr/Jobs/CreateCertificateJob.cs b/certmgr/Jobs/CreateCertificateJob.cs index 22a4c86..d02fba1 100644 --- a/certmgr/Jobs/CreateCertificateJob.cs +++ b/certmgr/Jobs/CreateCertificateJob.cs @@ -75,10 +75,17 @@ public sealed class CreateCertificateJob : Job cgcs.FriendlyName = "I'm your friend"; if (Settings.Issuer != null) { - using (MemoryStream ms = new MemoryStream()) + try { - await Settings.Issuer.ReadAsync(ms, cancellationToken).ConfigureAwait(false); - cgcs.Issuer = X509CertificateLoader.LoadPkcs12(ms.GetBuffer(), Settings.IssuerPassword, flags); + using (MemoryStream ms = new MemoryStream()) + { + await Settings.Issuer.ReadAsync(ms, cancellationToken).ConfigureAwait(false); + cgcs.Issuer = X509CertificateLoader.LoadPkcs12(ms.GetBuffer(), Settings.IssuerPassword, flags); + } + } + catch (Exception e) + { + throw new CertGenException(e, "Failed to load issuer's certificate"); } } cgcs.SubjectName = Settings.Subject; diff --git a/certmgr/Jobs/RsaKeySizeConverter.cs b/certmgr/Jobs/RsaKeySizeConverter.cs new file mode 100644 index 0000000..0425d27 --- /dev/null +++ b/certmgr/Jobs/RsaKeySizeConverter.cs @@ -0,0 +1,49 @@ +using CertMgr.CertGen; +using CertMgr.Core.Converters; + +namespace CertMgr.Jobs; + +internal sealed class RsaKeySizeConverter : ValueConverter +{ + protected override Task DoConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(rawValue)) + { + return Task.FromResult((RsaKeySize?)null); + } + + RsaKeySize? keySize; + + Type? realType = Nullable.GetUnderlyingType(targetType); + if (realType == null) + { + realType = targetType; + } + + if (Enum.TryParse(rawValue, true, out RsaKeySize tmp) && Enum.IsDefined(realType, tmp)) + { + keySize = tmp; + } + else + { + if (string.Equals(rawValue, "2048")) + { + keySize = RsaKeySize.KeySize2048; + } + else if (string.Equals(rawValue, "4096")) + { + keySize = RsaKeySize.KeySize4096; + } + else if (string.Equals(rawValue, "8192")) + { + keySize = RsaKeySize.KeySize8192; + } + else + { + keySize = null; + } + } + + return Task.FromResult(keySize); + } +} diff --git a/certmgr/Jobs/SubjectAlternateNameValidator.cs b/certmgr/Jobs/SubjectAlternateNameValidator.cs new file mode 100644 index 0000000..0cbd9c3 --- /dev/null +++ b/certmgr/Jobs/SubjectAlternateNameValidator.cs @@ -0,0 +1,46 @@ +using CertMgr.Core.Utils; +using CertMgr.Core.Validation; + +namespace CertMgr.Jobs; + +internal sealed class SubjectAlternateNameValidator : SettingValidator +{ + public SubjectAlternateNameValidator(string settingName) + : base(settingName) + { + } + + public override Task ValidateAsync(string? settingValue, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(settingValue)) + { + return Task.FromResult(new ValidationResult(SettingName, false, "value must not be empty")); + } + + ReadOnlySpan span = settingValue.AsSpan(); + if (span.StartsWith("DNS:", StringComparison.OrdinalIgnoreCase)) + { + if (!NetUtils.IsValidDns(span.Slice(4))) + { + return Task.FromResult(new ValidationResult(SettingName, false, "value '{0}' is not valid DNS name", span.Slice(4).ToString())); + } + } + else if (span.StartsWith("IP:", StringComparison.OrdinalIgnoreCase)) + { + if (!NetUtils.IsValidIPAny(span.Slice(3))) + { + return Task.FromResult(new ValidationResult(SettingName, false, "value '{0}' is not valid IP address", span.Slice(3).ToString())); + } + } + else + { + // fallback to dns name as other alt-names (UPN, email, URI..) are not supported + if (!NetUtils.IsValidDns(span)) + { + return Task.FromResult(new ValidationResult(SettingName, false, "value '{0}' is not valid DNS name (no prefix)", span.Slice(4).ToString())); + } + } + + return Task.FromResult(new ValidationResult(SettingName, true, "valid")); + } +} diff --git a/certmgr/Program.cs b/certmgr/Program.cs index 83a9dc1..758ab4e 100644 --- a/certmgr/Program.cs +++ b/certmgr/Program.cs @@ -6,18 +6,31 @@ internal static class Program { private static async Task Main(string[] args) { + // args = [ + // "--job=create-certificate", + // "--issuer-certificate=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", "--issuer-certificate=file|o|c:\\friend2.pfx", "--issuer-password=aaa", - "--subject=hello", + "--subject=CN=hello", "--san=world", - "--algorithm=ecdsa", - "--ecdsa-curve=p384", - "--storage=file|w|c:\\mycert.pfx", + "--san=DNS:zdrastvujte", + "--san=IP:192.168.131.1", + "--algorithm=rsa", + "--rsa-key-size=2048", + "--storage=file|w|c:\\friend-rsa.pfx", "--validity-period=2d" ]; - using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromMinutes(3)); JobExecutor executor = new JobExecutor(); int errorLevel = await executor.ExecuteAsync(args, cts.Token).ConfigureAwait(false); diff --git a/certmgrTest/NetUtilsTest.cs b/certmgrTest/NetUtilsTest.cs new file mode 100644 index 0000000..a617ad5 --- /dev/null +++ b/certmgrTest/NetUtilsTest.cs @@ -0,0 +1,86 @@ +using CertMgr.Core.Utils; + +using NUnit.Framework; + +namespace certmgrTest +{ + public class NetUtilsTest + { + [Test] + public void IPv6Test() + { + string[] validIPv6Addresses = new[] + { + "2001:0db8:0000:0000:0000:ff00:0042:8329", // full form + "2001:db8:0:0:0:ff00:42:8329", // shortened zeros + "2001:db8::ff00:42:8329", // compressed zeros + "::1", // loopback + "::", // unspecified address + "fe80::1ff:fe23:4567:890a", // link-local address + "2001:db8:1234:ffff:ffff:ffff:ffff:ffff", // maximum range + "fd12:3456:789a:1::1", // unique local address (ULA) + "2001:0:3238:DFE1:63::FEFB", // mixed compression + "0:0:0:0:0:0:0:1", // loopback without compression + "2001:db8::123", // short address + "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", // all bits set to 1 + "2001:db8:85a3::8a2e:370:7334", // example from RFC 4291 + "::ffff:192.0.2.128" // IPv4-mapped IPv6 address + }; + + Assert.Multiple(() => + { + foreach (string ip in validIPv6Addresses) + { + try + { + bool valid = NetUtils.IsValidIPv6(ip); + Assert.That(valid, Is.True, string.Format("expected that IPv6 address '{0}' is valid", ip)); + } + catch (Exception e) + { + Assert.Fail(string.Format("address: '{0}' failed with: {1}: {2}", ip, e.GetType().Name, e.Message)); + } + } + }); + } + + [Test] + public void IPv6_InvalidTest() + { + string[] invalidIPv6Addresses = new[] + { + "2001:::85a3::8a2e:370:7334", // multiple "::" + "2001:db8:85a3:0:0:8a2e:370g:7334", // invalid hex character (g) + "1200::AB00:1234::2552:7777:1313", // two "::" sections + "2001:db8:85a3:z:0:8a2e:370:7334", // invalid hex segment + "2001:db8:85a3:0:0:8a2e:370:7334:1234", // too many segments (9) + "2001:db8:85a3", // too few segments (3) + ":2001:db8::1", // leading colon without pair + "2001:db8::1:", // trailing colon without pair + "2001:db8::1::", // multiple compression points + "2001:db8:85a3:0:0:8a2e:370:7334/64", // contains CIDR mask + // "2001:db8:85a3:0:0:8a2e:370:7334%", // missing interface ID after % <= this is valid (at least on windows) + "::ffff:999.0.2.128", // invalid IPv4-mapped part + "::ffff:192.0.2.256", // IPv4 part out of range + "2001:db8:85a3::8a2e:370:7334:xyz", // trailing junk + "2001:db8:::", // triple colon + }; + + Assert.Multiple(() => + { + foreach (string ip in invalidIPv6Addresses) + { + try + { + bool valid = NetUtils.IsValidIPv6(ip); + Assert.That(valid, Is.False, string.Format("expected that IPv6 address '{0}' is NOT valid", ip)); + } + catch (Exception e) + { + Assert.Fail(string.Format("address: '{0}' failed with: {1}: {2}", ip, e.GetType().Name, e.Message)); + } + } + }); + } + } +}