some fixes

This commit is contained in:
2025-10-21 10:49:38 +02:00
parent c442e8ca89
commit 0e0f038403
28 changed files with 1070 additions and 251 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.vs
certmgr/obj/*
certmgr/Obsolete/*
certmgr/Obsolete/*
certmgrTest/obj/*

View File

@@ -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

View File

@@ -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<TAlgorithm, TSettings> : 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<TAlgorithm, TSettings> : ICerti
return ValueTask.CompletedTask;
}
public GeneratorType Type { [DebuggerStepThrough] get; }
protected TSettings Settings { [DebuggerStepThrough] get; }
public async Task<X509Certificate2> CreateAsync(CertificateSettings settings, CancellationToken cancellationToken)
@@ -30,8 +34,6 @@ internal abstract class CertificateGeneratorBase<TAlgorithm, TSettings> : 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<TAlgorithm, TSettings> : 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<X509Certificate2> CreateInternalAsync(CertificateSettings settings, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
X509Certificate2 cert;
using (TAlgorithm privateKey = CreatePrivateKey())
@@ -128,6 +105,50 @@ internal abstract class CertificateGeneratorBase<TAlgorithm, TSettings> : 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<TAlgorithm, TSettings> : ICerti
using (RandomNumberGenerator rng = RandomNumberGenerator.Create())
{
rng.GetBytes(serial);
rng.GetNonZeroBytes(serial);
}
return serial;
@@ -212,6 +233,6 @@ internal abstract class CertificateGeneratorBase<TAlgorithm, TSettings> : ICerti
public override string ToString()
{
return string.Format("Type = {0}", this is RsaCertificateGenerator ? "RSA" : this is EcdsaCertificateGenerator ? "ECDSA" : "<unknown>");
return string.Format("Type = {0}", Type);
}
}

View File

@@ -10,7 +10,7 @@ public sealed class CertificateSettings
{
internal CertificateSettings()
{
SubjectAlternateNames = new SubjectAlternateNames([NetUtils.MachineName]);
SubjectAlternateNames = new SubjectAlternateNames();
SubjectName = NetUtils.MachineName;
Issuer = null;

View File

@@ -8,7 +8,7 @@ namespace CertMgr.CertGen;
internal sealed class EcdsaCertificateGenerator : CertificateGeneratorBase<ECDsa, EcdsaGeneratorSettings>
{
internal EcdsaCertificateGenerator(EcdsaGeneratorSettings settings)
: base(settings)
: base(GeneratorType.Ecdsa, settings)
{
}
@@ -33,16 +33,6 @@ internal sealed class EcdsaCertificateGenerator : CertificateGeneratorBase<ECDsa
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;

View File

@@ -3,5 +3,5 @@
public enum GeneratorType
{
Ecdsa = 1,
Rsa
RSA
}

View File

@@ -8,7 +8,7 @@ namespace CertMgr.CertGen;
internal sealed class RsaCertificateGenerator : CertificateGeneratorBase<RSA, RsaGeneratorSettings>
{
internal RsaCertificateGenerator(RsaGeneratorSettings settings)
: base(settings)
: base(GeneratorType.RSA, settings)
{
}
@@ -33,16 +33,6 @@ internal sealed class RsaCertificateGenerator : CertificateGeneratorBase<RSA, Rs
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;

View File

@@ -7,7 +7,7 @@ namespace CertMgr.CertGen;
public sealed class RsaGeneratorSettings : GeneratorSettings
{
public RsaGeneratorSettings(RsaKeySize keySize)
: base(GeneratorType.Rsa)
: base(GeneratorType.RSA)
{
KeySize = keySize;

View File

@@ -0,0 +1,7 @@
namespace CertMgr.CertGen;
public enum SANKind
{
DNS = 1,
IP
}

View File

@@ -0,0 +1,57 @@
using System.Diagnostics;
namespace CertMgr.CertGen;
public sealed class SubjectAlternateName : IEquatable<SubjectAlternateName>
{
public SubjectAlternateName(SANKind kind, string value)
{
Kind = kind;
Value = value;
}
public SubjectAlternateName(SANKind kind, ReadOnlySpan<char> 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;
}
}

View File

@@ -1,30 +1,119 @@
using System.Collections;
using CertMgr.Core.Log;
using CertMgr.Core.Utils;
namespace CertMgr.CertGen;
public sealed class SubjectAlternateNames : IReadOnlyCollection<string>
public sealed class SubjectAlternateNames : IReadOnlyCollection<SubjectAlternateName>
{
private readonly HashSet<string> _items;
private readonly HashSet<SubjectAlternateName> _items;
public SubjectAlternateNames()
{
_items = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_items = new HashSet<SubjectAlternateName>();
}
public SubjectAlternateNames(IReadOnlyCollection<string> items)
{
_items = new HashSet<string>(items, StringComparer.OrdinalIgnoreCase);
_items = new HashSet<SubjectAlternateName>();
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<char> 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<string> GetEnumerator()
public bool AddDnsName(string name)
{
bool added = AddDnsName(name.AsSpan());
return added;
}
public bool AddDnsName(ReadOnlySpan<char> name)
{
bool added = false;
ReadOnlySpan<char> 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<char> ip)
{
bool added = false;
ReadOnlySpan<char> 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<SubjectAlternateName> GetEnumerator()
{
return _items.GetEnumerator();
}

View File

@@ -1,49 +0,0 @@
/*using System.Security.Cryptography.X509Certificates;
using CertMgr.Core.Storage;
namespace CertMgr.CertGen.Utils;
public sealed class StorageToX509CertificateAdapter : StorageAdapter<X509Certificate2>
{
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<X509Certificate2> 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<StoreResult> 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);
}
}
}
*/

View File

@@ -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<string>
{
public SubjectValidator(string settingName)
{
SettingName = settingName;
}
public string SettingName { [DebuggerStepThrough] get; }
public Task<ValidationResult> 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 ?? "<null>", e.GetType().Name, e.Message));
}
}
public Task<ValidationResult> ValidateAsync(object? value, CancellationToken cancellationToken)
{
return ValidateAsync(value as string, cancellationToken);
}
}

View File

@@ -8,36 +8,26 @@ public sealed class EnumConverter : ValueConverter<Enum>
{
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);
}
}

View File

@@ -1,15 +0,0 @@
namespace CertMgr.Core.Converters.Impl;
public sealed class EnumNullableConverter : ValueConverter<Enum?>
{
protected override Task<Enum?> 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);
}
}

View File

@@ -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<Type> GetValidatedTypes(Type validatorType)
{
if (validatorType == null)
{
throw new ArgumentNullException(nameof(validatorType));
}
List<Type> hits = new List<Type>();
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<AsyncResult<object?>> 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<object?> 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) ?? "<null>", typeInfo.ElementType.ToString(false));
}
}
return new AsyncResult<object?>(valueSet, convertedValue);
}
public async Task<JobSettings> 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<object?> 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<Type> 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) ?? "<null>", 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<AsyncResult<object?>> 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<object?> 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<object?> 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<object?>(success, convertedValue);
}
private static object BuildCollectionValue(Type collectionType, Type elementType, IReadOnlyList<object?> items)
private async Task<AsyncResult<object?>> 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<object?>(success, convertedValue);
}
/*private static object BuildCollectionValue(Type collectionType, Type elementType, IReadOnlyList<object?> 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; }
}
}

View File

@@ -0,0 +1,21 @@
using System.Diagnostics;
namespace CertMgr.Core.Utils;
public struct AsyncResult<T>
{
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() ?? "<null>");
}
}

View File

@@ -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<char> value)
{
if (value.Length == 0)
{
return false;
}
bool match = IPv4Regex.IsMatch(value);
return match;
}
public static bool IsValidIPv6(ReadOnlySpan<char> value)
{
if (value.Length == 0)
{
return false;
}
bool match = IPv6Regex.IsMatch(value);
return match;
}
*/
/// <summary>Returns DNS name of the local computer.</summary>
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<char> 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<char> 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<char> 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<char> 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<IPAddress> normalizedAddrSetA = new HashSet<IPAddress>(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<IPAddress> 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<IPAddress> GetNormalizedLocalIpSet(bool includeLoopbacks)
{
HashSet<IPAddress> result = new HashSet<IPAddress>();
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;
}
}

View File

@@ -0,0 +1,32 @@
using System.Diagnostics;
using CertMgr.Core.Utils;
namespace CertMgr.Core.Validation;
public abstract class SettingValidator<T> : ISettingValidator<T>
{
protected SettingValidator(string settingName)
{
SettingName = settingName;
}
public string SettingName { [DebuggerStepThrough] get; }
public abstract Task<ValidationResult> ValidateAsync(T? settingValue, CancellationToken cancellationToken);
public Task<ValidationResult> 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) ?? "<null>", typeof(T).ToString(false), SettingName);
}
return ValidateAsync(typedValue, cancellationToken);
}
}

View File

@@ -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<string>? 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>(RsaKeySize.Value))
{
results.AddInvalid(nameof(RsaKeySize), "value value must be specified: '{0}'", RsaKeySize?.ToString() ?? "<null>");
}

View File

@@ -75,10 +75,17 @@ public sealed class CreateCertificateJob : Job<CertificateSettings>
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;

View File

@@ -0,0 +1,49 @@
using CertMgr.CertGen;
using CertMgr.Core.Converters;
namespace CertMgr.Jobs;
internal sealed class RsaKeySizeConverter : ValueConverter<RsaKeySize?>
{
protected override Task<RsaKeySize?> 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);
}
}

View File

@@ -0,0 +1,46 @@
using CertMgr.Core.Utils;
using CertMgr.Core.Validation;
namespace CertMgr.Jobs;
internal sealed class SubjectAlternateNameValidator : SettingValidator<string>
{
public SubjectAlternateNameValidator(string settingName)
: base(settingName)
{
}
public override Task<ValidationResult> ValidateAsync(string? settingValue, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(settingValue))
{
return Task.FromResult(new ValidationResult(SettingName, false, "value must not be empty"));
}
ReadOnlySpan<char> 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"));
}
}

View File

@@ -6,18 +6,31 @@ internal static class Program
{
private static async Task<int> 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);

View File

@@ -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));
}
}
});
}
}
}