improvements

This commit is contained in:
2025-11-02 15:52:04 +01:00
parent b45a65dfa0
commit 85c0354d2a
33 changed files with 977 additions and 119 deletions

View File

@@ -0,0 +1,125 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography.X509Certificates;
using CertMgr.Core.Log;
using CertMgr.Core.Storage;
using CertMgr.Core.Utils;
namespace CertMgr.Core.Converters.Impl;
internal sealed class CertStoreStorageContextConverter : StorageContextConverter<CertStoreStorageContext>
{
protected override Task<CertStoreStorageContext?> DoConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken)
{
ReadOnlySpan<char> storageDefinition = rawValue.AsSpan();
TryGetCertStore(storageDefinition, out CertStoreStorageContext? context);
return Task.FromResult(context);
}
private bool TryGetCertStore(ReadOnlySpan<char> storageDefinition, [NotNullWhen(true)] out CertStoreStorageContext? context)
{
// expecting 'storageDefinition' is something like:
// <store-location>|<store-name>|<flags>
// where <store-location> can be:
// - 'user'
// - 'machine'
// where '<store-name> can be:
// - 'my'
// - 'root'
// where <flags> can be:
// - 'default'
// - 'exportable'
int firstSplitIndex = storageDefinition.IndexOf(Separator);
if (firstSplitIndex != -1)
{
// there is a splitter. On the left side of it there must be <store-location>. On the right there is a <store-name>
ReadOnlySpan<char> locationSpan = storageDefinition.Slice(0, firstSplitIndex);
int secondSplitIndex = storageDefinition.Slice(firstSplitIndex + 1).IndexOf(Separator) + locationSpan.Length + 1;
ReadOnlySpan<char> nameSpan = storageDefinition.Slice(firstSplitIndex + 1, secondSplitIndex - firstSplitIndex - 1);
ReadOnlySpan<char> flagsSpan = storageDefinition.Slice(secondSplitIndex + 1);
CertStoreLocation? storeLocation = null;
CertStoreName? storeName = null;
X509KeyStorageFlags? flags = null;
if (locationSpan.Equals("user", StringComparison.OrdinalIgnoreCase))
{
storeLocation = CertStoreLocation.User;
}
else if (locationSpan.Equals("machine", StringComparison.OrdinalIgnoreCase))
{
storeLocation = CertStoreLocation.Machine;
}
else
{
CLog.Error("{0}: Unsupported store-location '{1}' for storage '{2}'", GetType().ToString(false), locationSpan.ToString(), typeof(CertStoreStorage).ToString(false));
}
if (nameSpan.Equals("my", StringComparison.OrdinalIgnoreCase))
{
storeName = CertStoreName.My;
}
else if (nameSpan.Equals("root", StringComparison.OrdinalIgnoreCase))
{
storeName = CertStoreName.Root;
}
else
{
CLog.Error("{0}: Unsupported store-name '{1}' for storage '{2}'", GetType().ToString(false), nameSpan.ToString(), typeof(CertStoreStorage).ToString(false));
}
if (flagsSpan.Length > 0)
{
MemoryExtensions.SpanSplitEnumerator<char> enu = flagsSpan.Split(',');
while (enu.MoveNext())
{
ReadOnlySpan<char> currentExt = flagsSpan[enu.Current].Trim();
if (currentExt.Equals("default", StringComparison.OrdinalIgnoreCase))
{
if (flags.HasValue)
{
flags |= X509KeyStorageFlags.DefaultKeySet;
}
else
{
flags = X509KeyStorageFlags.DefaultKeySet;
}
}
else if (currentExt.Equals("exportable", StringComparison.OrdinalIgnoreCase))
{
if (flags.HasValue)
{
flags |= X509KeyStorageFlags.Exportable;
}
else
{
flags = X509KeyStorageFlags.Exportable;
}
}
}
}
else
{
CLog.Error("{0}: cert storage-flags not defined", GetType().ToString(false));
}
if (storeLocation.HasValue && storeName.HasValue)
{
context = new CertStoreStorageContext(storeLocation.Value, storeName.Value, flags.Value);
}
else
{
context = null;
}
}
else
{
context = null;
}
return context != null;
}
}

View File

@@ -0,0 +1,99 @@
using System.Diagnostics.CodeAnalysis;
using CertMgr.Core.Log;
using CertMgr.Core.Storage;
using CertMgr.Core.Utils;
namespace CertMgr.Core.Converters.Impl;
internal class FileStorageContextConverter : StorageContextConverter<FileStorageContext>
{
protected override Task<FileStorageContext?> DoConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken)
{
ReadOnlySpan<char> storageDefinition = rawValue.AsSpan();
TryGetFileStore(storageDefinition, out FileStorageContext? context);
return Task.FromResult(context);
}
private bool TryGetFileStore(ReadOnlySpan<char> storageDefinition, [NotNullWhen(true)] out FileStorageContext? context)
{
// expecting that 'storageDefinition' is something like:
// <store-mode>|<file-path>
// or
// |<file-path>
// or
// <file-path>
//
// where <store-mode> can be:
// 'o' or 'overwrite' or 'overwriteornew'
// or
// 'a' or 'append' or 'appendornew'
// or
// 'c' or 'create' or 'createnew'
//
// where <file-path> can be:
// 'c:\path\myfile.txt'
// or
// '/path/myfile.txt'
// or
// './path/myfile.txt'
// in addition - <file-path> can be either quoted or double-quoted
context = null;
FileStoreMode defaultStoreMode = FileStoreMode.OverwriteOrNew;
FileStoreMode storeMode;
ReadOnlySpan<char> filename;
int firstSplitIndex = storageDefinition.IndexOf(Separator);
if (firstSplitIndex != -1)
{
// there is a splitter. On the left side of it there must be <store-mode>. On the right there is a <file-path>
ReadOnlySpan<char> firstPart = storageDefinition.Slice(0, firstSplitIndex);
filename = storageDefinition.Slice(firstSplitIndex + 1);
if (firstPart.Length == 0)
{
storeMode = defaultStoreMode;
}
else if (Enum.TryParse(firstPart, true, out FileStoreMode tmpMode))
{
storeMode = tmpMode;
}
else if (firstPart.Equals("w", StringComparison.OrdinalIgnoreCase) || firstPart.Equals("overwrite", StringComparison.OrdinalIgnoreCase))
{
storeMode = FileStoreMode.OverwriteOrNew;
}
else if (firstPart.Equals("c", StringComparison.OrdinalIgnoreCase) || firstPart.Equals("createnew", StringComparison.OrdinalIgnoreCase))
{
storeMode = FileStoreMode.Create;
}
else if (firstPart.Equals("a", StringComparison.OrdinalIgnoreCase) || firstPart.Equals("append", StringComparison.OrdinalIgnoreCase))
{
storeMode = FileStoreMode.AppendOrNew;
}
else if (firstPart.Equals("o", StringComparison.OrdinalIgnoreCase) || firstPart.Equals("open", StringComparison.OrdinalIgnoreCase))
{
storeMode = FileStoreMode.Open;
}
else
{
// it is not store-mode or there is a typo or unsupported value
CLog.Error(string.Format("{0}: Unsupported store-mode '{1}' for storage '{2}'", GetType().ToString(false), firstPart.ToString()), typeof(FileStorage).ToString(false));
storeMode = defaultStoreMode;
}
}
else
{
// no split-char => just filename
storeMode = defaultStoreMode;
filename = storageDefinition;
}
context = new FileStorageContext(filename.ToString(), storeMode);
return true;
}
}

View File

@@ -0,0 +1,8 @@
using CertMgr.Core.Storage;
namespace CertMgr.Core.Converters.Impl;
public abstract class StorageContextConverter<T> : ValueConverter<T> where T : StorageContext
{
protected const char Separator = '|';
}

View File

@@ -1,7 +1,9 @@
using System.Diagnostics.CodeAnalysis;
/*using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography.X509Certificates;
using CertMgr.Core.Log;
using CertMgr.Core.Storage;
using CertMgr.Core.Utils;
namespace CertMgr.Core.Converters.Impl;
@@ -31,6 +33,13 @@ internal sealed class StorageConverter : ValueConverter<IStorage>
storage = EmptyStorage.Empty;
}
break;
case "certstore":
case "cert-store":
if (!TryGetCertStore(storageDefinition, out storage))
{
storage = EmptyStorage.Empty;
}
break;
default:
storage = EmptyStorage.Empty;
break;
@@ -104,7 +113,7 @@ internal sealed class StorageConverter : ValueConverter<IStorage>
else
{
// it is not store-mode or there is a typo or unsupported value
CLog.Error(string.Concat("Unsupported store-mode '", firstPart, "'"));
CLog.Error(string.Format("{0}: Unsupported store-mode '{1}' for storage '{2}'", GetType().ToString(false), firstPart.ToString()), typeof(FileStorage).ToString(false));
storeMode = defaultStoreMode;
}
}
@@ -118,4 +127,69 @@ internal sealed class StorageConverter : ValueConverter<IStorage>
storage = new FileStorage(filename.ToString(), storeMode);
return true;
}
private bool TryGetCertStore(ReadOnlySpan<char> storageDefinition, [NotNullWhen(true)] out IStorage? storage)
{
// expecting 'storageDefinition' is something like:
// <store-location>|<store-name>
// where <store-location> can be:
// - 'user'
// - 'machine'
// where '<store-name> can be:
// - 'my'
// - 'root'
int firstSplitIndex = storageDefinition.IndexOf(Separator);
if (firstSplitIndex != -1)
{
// there is a splitter. On the left side of it there must be <store-location>. On the right there is a <store-name>
ReadOnlySpan<char> locationSpan = storageDefinition.Slice(0, firstSplitIndex);
ReadOnlySpan<char> nameSpan = storageDefinition.Slice(firstSplitIndex + 1);
StoreLocation? storeLocation = null;
StoreName? storeName = null;
if (locationSpan.Equals("user", StringComparison.OrdinalIgnoreCase))
{
storeLocation = StoreLocation.CurrentUser;
}
else if (locationSpan.Equals("machine", StringComparison.OrdinalIgnoreCase))
{
storeLocation = StoreLocation.LocalMachine;
}
else
{
CLog.Error(string.Format("{0}: Unsupported store-location '{1}' for storage '{2}'", GetType().ToString(false), locationSpan.ToString()), typeof(CertStoreStorage).ToString(false));
}
if (nameSpan.Equals("my", StringComparison.OrdinalIgnoreCase))
{
storeName = StoreName.My;
}
else if (nameSpan.Equals("root", StringComparison.OrdinalIgnoreCase))
{
storeName = StoreName.Root;
}
else
{
CLog.Error(string.Format("{0}: Unsupported store-name '{1}' for storage '{2}'", GetType().ToString(false), nameSpan.ToString()), typeof(CertStoreStorage).ToString(false));
}
if (storeLocation.HasValue && storeName.HasValue)
{
storage = new CertStoreStorage(storeLocation.Value, storeName.Value);
}
else
{
storage = null;
}
}
else
{
storage = null;
}
return storage != null;
}
}
*/

View File

@@ -0,0 +1,47 @@
using CertMgr.Core.Storage;
namespace CertMgr.Core.Converters.Impl;
internal class StorageKindConverter : ValueConverter<IStorage>
{
// private const char Separator = '|';
protected override Task<IStorage?> DoConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken)
{
ReadOnlySpan<char> storageTypeSpan = rawValue.AsSpan();
// int storageTypeSplitIndex = rawSpan.IndexOf(Separator);
// if (storageTypeSplitIndex == -1)
// {
// return Task.FromResult((IStorage?)EmptyStorage.Empty);
// }
IStorage? storage;
// ReadOnlySpan<char> storageTypeSpan = rawSpan.Slice(0, storageTypeSplitIndex);
// ReadOnlySpan<char> storageDefinition = rawSpan.Slice(storageTypeSplitIndex + 1);
switch (storageTypeSpan)
{
case "file":
storage = new FileStorage();
// if (!TryGetFileStore(storageDefinition, out storage))
// {
// storage = EmptyStorage.Empty;
// }
break;
case "certstore":
case "cert-store":
storage = new CertStoreStorage();
//if (!TryGetCertStore(storageDefinition, out storage))
//{
// storage = EmptyStorage.Empty;
//}
break;
default:
storage = EmptyStorage.Empty;
break;
}
return Task.FromResult((IStorage?)storage);
}
}

View File

@@ -36,7 +36,7 @@ public sealed class TimeSpanConverter : ValueConverter<TimeSpan?>
result = TimeSpan.FromDays(value);
break;
case 'y':
result = TimeSpan.FromDays(value * 365);
result = TimeSpan.FromDays(value * 365 + (value / 4));
break;
default:
result = null;

View File

@@ -4,8 +4,11 @@ namespace CertMgr.Core.Jobs;
public abstract class JobBase<TSettings> where TSettings : JobSettings, new()
{
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
protected JobBase()
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
{
// 'Settings' is set via reflection
Name = JobUtils.GetJobName(GetType());
}

View File

@@ -105,16 +105,44 @@ internal sealed class PropertyDescriptor
_propertyInfo.SetValue(settings, _settingAttribute.Default);
succeeded = true;
}
else if (typeInfo.IsElementTypeNullable)
{
_propertyInfo.SetValue(settings, _settingAttribute.Default);
succeeded = true;
}
else
{
CLog.Error("Default value for argument '{0}' is specified, but its type is '{1}' instead of expected '{2}'", SettingName, _settingAttribute.Default?.GetType().ToString(false) ?? "<null>", typeInfo.ElementType.ToString(false));
}
}
return succeeded;
}
public AsyncResult<object?> GetDefaultValue(JobSettings settings)
{
bool success = false;
if (_settingAttribute.Default != null)
{
Utils.TypeInfo typeInfo = TypeUtils.UnwrapCollection(_propertyInfo.PropertyType);
if (_settingAttribute.Default.GetType() == typeInfo.ElementType)
{
success = true;
}
else if (typeInfo.IsElementTypeNullable)
{
success = true;
}
else
{
CLog.Error("Default value for argument '{0}' is specified, but its type is '{1}' instead of expected '{2}'", SettingName, _settingAttribute.Default?.GetType().ToString(false) ?? "<null>", typeInfo.ElementType.ToString(false));
}
}
return new AsyncResult<object?>(success, _settingAttribute.Default);
}
public override string ToString()
{
return string.Format("property '{0}', type = {1}, is-mandatory = {2}, validator = {3}, converter = {4}", PropertyName, PropertyTypeInfo.SourceType.ToString(false), IsMandatory ? "yes" : "no", CustomValidator?.GetType().ToString(false) ?? "<null>", CustomConverter?.GetType().ToString(false) ?? "<null>");

View File

@@ -31,24 +31,20 @@ internal sealed class SettingsBuilder
foreach (PropertyDescriptor descriptor in GetPropertiesWithSettingAttribute())
{
AsyncResult<object?> setPropertyResult = await SetPropertyValueAsync(settings, descriptor, cancellationToken).ConfigureAwait(false);
if (setPropertyResult.IsSuccess)
AsyncResult<object?> result = await GetValueAsync(descriptor, settings, cancellationToken);
if (result.IsSuccess)
{
try
{
IValueValidator? validator = descriptor.CustomValidator;
if (validator != null)
{
IValidationResult valres = await validator.ValidateAsync(setPropertyResult.Value, cancellationToken).ConfigureAwait(false);
settings.ValidationResults.Add(valres);
}
}
catch (Exception e)
{
CLog.Error(e, "Failed to validate property '{0}' (of type '{1}') in settings of type '{2}'", descriptor.PropertyName, descriptor.PropertyTypeInfo.SourceType.ToString(false), _settingsType.ToString(false));
throw new ConsoleToolsException(e, "Failed to process property '{0}' (of type '{1}') in settings of type '{2}'", descriptor.PropertyName, descriptor.PropertyTypeInfo.SourceType.ToString(false), _settingsType.ToString(false));
}
await ValidatePropertyAsync(result.Value, descriptor, settings, cancellationToken);
descriptor.SetValue(settings, result.Value);
}
// AsyncResult<object?> setPropertyResult = await SetPropertyValueAsync(settings, descriptor, cancellationToken).ConfigureAwait(false);
// if (setPropertyResult.IsSuccess)
// {
// await ValidatePropertyAsync(setPropertyResult.Value, descriptor, settings, cancellationToken);
// }
}
return settings;
@@ -91,6 +87,37 @@ internal sealed class SettingsBuilder
return new AsyncResult<object?>(valueSet, convertedValue);
}
private async Task<AsyncResult<object?>> GetValueAsync(PropertyDescriptor descriptor, JobSettings settings, CancellationToken cancellationToken)
{
AsyncResult<object?> result;
if (TryGetRawArgument(descriptor, out RawArgument? rawArg))
{
try
{
result = await ConvertRawValueAsync(descriptor, rawArg, cancellationToken).ConfigureAwait(false);
}
catch (Exception e)
{
CLog.Error(e, "Failed to process property '{0}' (of type '{1}') in settings of type '{2}'", descriptor.PropertyName, descriptor.PropertyTypeInfo.SourceType.ToString(false), _settingsType.ToString(false));
throw new ConsoleToolsException(e, "Failed to process property '{0}' (of type '{1}') in settings of type '{2}'", descriptor.PropertyName, descriptor.PropertyTypeInfo.SourceType.ToString(false), _settingsType.ToString(false));
}
}
else if (descriptor.IsMandatory)
{
ValidationResult valres = new ValidationResult(descriptor.SettingName, false, "Mandatory argument is missing");
settings.ValidationResults.Add(valres);
CLog.Error("mandatory argument '{0}' is missing", descriptor.SettingName);
result = new AsyncResult<object?>(false, null);
}
else
{
result = descriptor.GetDefaultValue(settings);
}
return result;
}
private bool TryGetRawArgument(PropertyDescriptor descriptor, [NotNullWhen(true)] out RawArgument? rawArg)
{
rawArg = null;
@@ -243,4 +270,22 @@ internal sealed class SettingsBuilder
return convertedValue != null;
}
private async Task ValidatePropertyAsync(object? value, PropertyDescriptor descriptor, JobSettings settings, CancellationToken cancellationToken)
{
try
{
IValueValidator? validator = descriptor.CustomValidator;
if (validator != null)
{
IValidationResult valres = await validator.ValidateAsync(value, cancellationToken).ConfigureAwait(false);
settings.ValidationResults.Add(valres);
}
}
catch (Exception e)
{
CLog.Error(e, "Failed to validate property '{0}' (of type '{1}') in settings of type '{2}'", descriptor.PropertyName, descriptor.PropertyTypeInfo.SourceType.ToString(false), _settingsType.ToString(false));
throw new ConsoleToolsException(e, "Failed to process property '{0}' (of type '{1}') in settings of type '{2}'", descriptor.PropertyName, descriptor.PropertyTypeInfo.SourceType.ToString(false), _settingsType.ToString(false));
}
}
}

View File

@@ -0,0 +1,9 @@
using System.Security.Cryptography.X509Certificates;
namespace CertMgr.Core.Storage;
public enum CertStoreLocation
{
User = StoreLocation.CurrentUser,
Machine = StoreLocation.LocalMachine
}

View File

@@ -0,0 +1,9 @@
using System.Security.Cryptography.X509Certificates;
namespace CertMgr.Core.Storage;
public enum CertStoreName
{
My = StoreName.My,
Root = StoreName.Root,
}

View File

@@ -0,0 +1,100 @@
using System.Security.Cryptography.X509Certificates;
using CertMgr.Core.Utils;
namespace CertMgr.Core.Storage;
public sealed class CertStoreStorage : Storage<CertStoreStorageContext>
{
private static StoreResult<X509Certificate2> NotFoundResult = new StoreResult<X509Certificate2>(null, false);
protected override async Task<StoreResult> DoReadAsync(Stream target, CertStoreStorageContext context, CancellationToken cancellationToken)
{
StoreResult<X509Certificate2> result = NotFoundResult;
using (X509Store store = OpenCertStore(context, OpenFlags.ReadWrite))
{
foreach (X509Certificate2 cert in store.Certificates)
{
if (await context.Filter.IsMatchAsync(cert, cancellationToken))
{
result = new StoreResult<X509Certificate2>(cert, true);
break;
}
}
}
return result;
}
protected override async Task<StoreResult> DoWriteAsync(Stream source, CertStoreStorageContext context, CancellationToken cancellationToken)
{
X509KeyStorageFlags flags = X509KeyStorageFlags.EphemeralKeySet;
await using (MemoryStream ms = await CopyAsync(source, cancellationToken))
{
using (X509Certificate2 cert = LoadCertificate(ms.GetBuffer(), context.Password, flags))
{
using (X509Store store = OpenCertStore(context, OpenFlags.ReadWrite))
{
AddToCertStore(store, cert);
}
}
}
return new StoreResult(true);
}
private async Task<MemoryStream> CopyAsync(Stream source, CancellationToken cancellationToken)
{
try
{
MemoryStream ms = new MemoryStream();
await source.CopyToAsync(ms).ConfigureAwait(false);
return ms;
}
catch (Exception e)
{
throw new StorageException(e, "{0}: Failure while reading from stream (source stream type = '{1}')", GetType().ToString(false), source.GetType().ToString(false));
}
}
private X509Certificate2 LoadCertificate(byte[] data, string password, X509KeyStorageFlags flags)
{
try
{
X509Certificate2 cert = X509CertificateLoader.LoadPkcs12(data, password, flags);
return cert;
}
catch (Exception e)
{
throw new StorageException(e, "{0}: Failure while loading certificate from specified source", GetType().ToString(false));
}
}
private X509Store OpenCertStore(CertStoreStorageContext context, OpenFlags flags)
{
try
{
X509Store store = new X509Store((StoreName)context.Name, (StoreLocation)context.Location);
store.Open(flags);
return store;
}
catch (Exception e)
{
throw new StorageException(e, "{0}: Failure while opening cert-store (name = '{0}', location = '{1}', open-mode = '{2}'", context.Name, context.Location, flags);
}
}
private void AddToCertStore(X509Store store, X509Certificate2 cert)
{
try
{
store.Add(cert);
}
catch (Exception e)
{
throw new StorageException(e, "{0}: Failure while adding cert to cert-store (name = '{0}', location = '{1}', cert-subj = '{2}', cert-thumbprint = '{3}'", store.Name, store.Location, cert.Subject, cert.Thumbprint);
}
}
}

View File

@@ -0,0 +1,42 @@
using System.Diagnostics;
using System.Security.Cryptography.X509Certificates;
using CertMgr.Core.Utils;
namespace CertMgr.Core.Storage;
public sealed class CertStoreStorageContext : StorageContext
{
public CertStoreStorageContext(CertStoreLocation storeLocation, CertStoreName storeName, X509KeyStorageFlags flags)
{
Location = storeLocation;
Name = storeName;
Flags = flags;
Filter = Filter<X509Certificate2>.NotMatch;
Password = string.Empty;
}
public CertStoreStorageContext(CertStoreLocation storeLocation, CertStoreName storeName, X509KeyStorageFlags flags, IFilter<X509Certificate2> filter)
{
Location = storeLocation;
Name = storeName;
Flags = flags;
Filter = filter;
Password = string.Empty;
}
public CertStoreLocation Location { [DebuggerStepThrough] get; }
public CertStoreName Name { [DebuggerStepThrough] get; }
public X509KeyStorageFlags Flags { [DebuggerStepThrough] get; }
public IFilter<X509Certificate2> Filter { [DebuggerStepThrough] get; }
public string Password { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
public override string ToString()
{
return string.Format("{0}/{1}, flags = {2}, filter-type = {3}", Location, Name, Flags, Filter?.GetType().ToString(false) ?? "<null>");
}
}

View File

@@ -0,0 +1,27 @@
using System.Diagnostics;
using System.Security.Cryptography.X509Certificates;
namespace CertMgr.Core.Storage;
public static class CertificateFilter
{
private abstract class CertFilter : Filter<X509Certificate2>
{
}
private sealed class ByThumbprintFilter : CertFilter
{
public ByThumbprintFilter(string thumbprint)
{
Thumbprint = thumbprint;
}
public string Thumbprint { [DebuggerStepThrough] get; }
protected override Task<bool> DoIsMatchAsync(X509Certificate2 value, CancellationToken cancellationToken)
{
bool result = string.Equals(value.Thumbprint, Thumbprint, StringComparison.OrdinalIgnoreCase);
return Task.FromResult(result);
}
}
}

View File

@@ -1,6 +1,6 @@
namespace CertMgr.Core.Storage;
public sealed class EmptyStorage : Storage
public sealed class EmptyStorage : Storage<StorageContext>
{
public static readonly IStorage Empty = new EmptyStorage();
@@ -8,16 +8,12 @@ public sealed class EmptyStorage : Storage
{
}
public override bool CanWrite => true;
public override bool CanRead => true;
protected override Task<StoreResult> DoWriteAsync(Stream source, CancellationToken cancellationToken)
protected override Task<StoreResult> DoWriteAsync(Stream source, StorageContext context, CancellationToken cancellationToken)
{
return Task.FromResult(StoreResult.CreateSuccess());
}
protected override Task<StoreResult> DoReadAsync(Stream target, CancellationToken cancellationToken)
protected override Task<StoreResult> DoReadAsync(Stream target, StorageContext context, CancellationToken cancellationToken)
{
return Task.FromResult(StoreResult.CreateSuccess());
}

View File

@@ -1,25 +1,10 @@
using CertMgr.Core.Utils;
namespace CertMgr.Core.Storage;
namespace CertMgr.Core.Storage;
public sealed class FileStorage : Storage
public sealed class FileStorage : Storage<FileStorageContext>
{
private readonly string _fileFullPath;
private readonly FileStoreMode _mode;
public FileStorage(string fileFullPath, FileStoreMode mode)
protected override async Task<StoreResult> DoWriteAsync(Stream source, FileStorageContext context, CancellationToken cancellationToken)
{
_fileFullPath = fileFullPath;
_mode = mode;
}
public override bool CanWrite => _mode.IsAnyOf(FileStoreMode.AppendOrNew, FileStoreMode.Create, FileStoreMode.OverwriteOrNew);
public override bool CanRead => _mode == FileStoreMode.Open;
protected override async Task<StoreResult> DoWriteAsync(Stream source, CancellationToken cancellationToken)
{
using (FileStream fs = new FileStream(_fileFullPath, (FileMode)_mode, FileAccess.Write, FileShare.None))
using (FileStream fs = new FileStream(context.Path, (FileMode)context.Mode, FileAccess.Write, FileShare.None))
{
await source.CopyToAsync(fs, cancellationToken).ConfigureAwait(false);
}
@@ -27,18 +12,13 @@ public sealed class FileStorage : Storage
return StoreResult.CreateSuccess();
}
protected override async Task<StoreResult> DoReadAsync(Stream target, CancellationToken cancellationToken)
protected override async Task<StoreResult> DoReadAsync(Stream target, FileStorageContext context, CancellationToken cancellationToken)
{
using (FileStream fs = new FileStream(_fileFullPath, (FileMode)_mode, FileAccess.Read, FileShare.Read))
using (FileStream fs = new FileStream(context.Path, (FileMode)context.Mode, FileAccess.Read, FileShare.Read))
{
await fs.CopyToAsync(target, cancellationToken).ConfigureAwait(false);
}
return StoreResult.CreateSuccess();
}
public override string ToString()
{
return string.Format("{0}: mode = '{1}', path = '{2}'", GetType().Name, _mode, _fileFullPath);
}
}

View File

@@ -0,0 +1,16 @@
using System.Diagnostics;
namespace CertMgr.Core.Storage;
public sealed class FileStorageContext : StorageContext
{
public FileStorageContext(string fileFullPath, FileStoreMode mode)
{
Path = fileFullPath;
Mode = mode;
}
public string Path { [DebuggerStepThrough] get; }
public FileStoreMode Mode { [DebuggerStepThrough] get; }
}

View File

@@ -0,0 +1,38 @@
using System.Diagnostics;
using CertMgr.Core.Utils;
namespace CertMgr.Core.Storage;
public abstract class Filter<T> : IFilter<T>
{
public static readonly IFilter<T> IsMatch = new EmptyFilter(true);
public static readonly IFilter<T> NotMatch = new EmptyFilter(false);
public Task<bool> IsMatchAsync(T value, CancellationToken cancellationToken)
{
return DoIsMatchAsync(value, cancellationToken);
}
protected abstract Task<bool> DoIsMatchAsync(T value, CancellationToken cancellationToken);
private sealed class EmptyFilter : Filter<T>
{
internal EmptyFilter(bool result)
{
Result = result;
}
private bool Result { [DebuggerStepThrough] get; }
protected override Task<bool> DoIsMatchAsync(T value, CancellationToken cancellationToken)
{
return Task.FromResult(Result);
}
public override string ToString()
{
return string.Format("{0}: is-match = {1}", GetType().ToString(false), Result ? "yes" : "no");
}
}
}

View File

@@ -0,0 +1,6 @@
namespace CertMgr.Core.Storage;
public interface IFilter<T>
{
Task<bool> IsMatchAsync(T value, CancellationToken cancellationToken);
}

View File

@@ -2,11 +2,7 @@
public interface IStorage
{
Task<StoreResult> WriteAsync(Stream source, CancellationToken cancellationToken);
Task<StoreResult> WriteAsync(Stream source, StorageContext context, CancellationToken cancellationToken);
// Task<StoreResult> WriteFromAsync<TResult>(StorageAdapter<TResult> adapter, CancellationToken cancellationToken);
Task<StoreResult> ReadAsync(Stream target, CancellationToken cancellationToken);
// Task<StoreResult<T>> ReadToAsync<T>(StorageAdapter<T> adapter, CancellationToken cancellationToken);
Task<StoreResult> ReadAsync(Stream target, StorageContext context, CancellationToken cancellationToken);
}

View File

@@ -2,23 +2,15 @@
namespace CertMgr.Core.Storage;
public abstract class Storage : IStorage
public abstract class Storage<T> : IStorage where T : StorageContext
{
public virtual bool CanRead => false;
public virtual bool CanWrite => false;
public async Task<StoreResult> WriteAsync(Stream source, CancellationToken cancellationToken)
public async Task<StoreResult> WriteAsync(Stream source, StorageContext context, CancellationToken cancellationToken)
{
if (!CanWrite)
{
throw new StorageException("Cannot write. Storage not writable");
}
T typedContext = GetTypedContext(context);
try
{
return await DoWriteAsync(source, cancellationToken);
return await DoWriteAsync(source, typedContext, cancellationToken);
}
catch (Exception e)
{
@@ -27,18 +19,15 @@ public abstract class Storage : IStorage
}
}
protected abstract Task<StoreResult> DoWriteAsync(Stream source, CancellationToken cancellationToken);
protected abstract Task<StoreResult> DoWriteAsync(Stream source, T context, CancellationToken cancellationToken);
public async Task<StoreResult> ReadAsync(Stream target, CancellationToken cancellationToken)
public async Task<StoreResult> ReadAsync(Stream target, StorageContext context, CancellationToken cancellationToken)
{
if (!CanRead)
{
throw new StorageException("Cannot read. Storage not readable");
}
T typedContext = GetTypedContext(context);
try
{
return await DoReadAsync(target, cancellationToken);
return await DoReadAsync(target, typedContext, cancellationToken);
}
catch (Exception e)
{
@@ -47,37 +36,26 @@ public abstract class Storage : IStorage
}
}
protected abstract Task<StoreResult> DoReadAsync(Stream target, CancellationToken cancellationToken);
protected abstract Task<StoreResult> DoReadAsync(Stream target, T context, CancellationToken cancellationToken);
/*public async Task<StoreResult> WriteFromAsync<TResult>(StorageAdapter<TResult> adapter, CancellationToken cancellationToken)
private T GetTypedContext(StorageContext context)
{
T typedContext;
try
{
return await adapter.WriteAsync(this, cancellationToken).ConfigureAwait(false);
typedContext = (T)context;
}
catch (Exception e)
{
StorageException ex = new StorageException(e, "Failed to write to storage of type '{0}' adapter of type '{1}'", GetType().ToString(false), adapter.GetType().ToString(false));
return StoreResult.CreateFailure(ex);
throw new StorageException(e, "Faile to convert type {0} to {1} in storage of type {2}", context.GetType().ToString(false), typeof(T).ToString(false), GetType().ToString(false));
}
}*/
/*public async Task<StoreResult<TResult>> ReadToAsync<TResult>(StorageAdapter<TResult> adapter, CancellationToken cancellationToken)
{
try
{
TResult result = await adapter.ReadAsync(this, cancellationToken).ConfigureAwait(false);
return new StoreResult<TResult>(result, true);
return typedContext;
}
catch (Exception e)
{
StorageException ex = new StorageException(e, "Failed to read as type '{0}' from storage of type '{1}' using adapter of type '{1}'", typeof(TResult).ToString(false), GetType().ToString(false), adapter.GetType().ToString(false));
return new StoreResult<TResult>(default, false, ex);
}
}*/
public override string ToString()
{
return string.Format("{0}: can-read = {1}, can-write = {2}", GetType().Name, CanRead ? "yes" : "no", CanWrite ? "yes" : "no");
return string.Format("{0}", GetType().ToString(false));
}
}

View File

@@ -0,0 +1,5 @@
namespace CertMgr.Core.Storage;
public class StorageContext
{
}

View File

@@ -9,6 +9,8 @@ public sealed class TypeInfo
SourceType = sourceType;
IsCollection = isCollection;
ElementType = elementType;
IsElementTypeNullable = Nullable.GetUnderlyingType(ElementType) != null;
}
public Type SourceType { [DebuggerStepThrough] get; }
@@ -17,6 +19,8 @@ public sealed class TypeInfo
public Type ElementType { [DebuggerStepThrough] get; }
public bool IsElementTypeNullable { [DebuggerStepThrough] get; }
public override string ToString()
{
return string.Format("is-collection = {0}, source-type = '{1}', element-type = '{2}'", IsCollection ? "yes" : "no", SourceType.ToString(false), ElementType.ToString(false));

View File

@@ -6,6 +6,7 @@ using CertMgr.Core.Attributes;
using CertMgr.Core.Converters.Impl;
using CertMgr.Core.Jobs;
using CertMgr.Core.Storage;
using CertMgr.Core.Utils;
using CertMgr.Core.Validation;
namespace CertMgr.Jobs;
@@ -52,20 +53,102 @@ public sealed class CertificateSettings : JobSettings
[Setting("is-certificate-authority", Default = false, AlternateNames = ["isca"])]
public bool IsCertificateAuthority { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
[Setting("issuer-certificate", Converter = typeof(StorageConverter))]
[Setting("issuer-kind", Converter = typeof(StorageKindConverter))]
public IStorage? Issuer { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
[Setting("issuer-file", Converter = typeof(FileStorageContextConverter))]
public FileStorageContext? IssuerFileContext { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
[Setting("issuer-certstore", Converter = typeof(CertStoreStorageContextConverter))]
public CertStoreStorageContext? IssuerCertStoreContext { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
// [Setting("issuer-certificate", Converter = typeof(StorageConverter))]
// public IStorage? Issuer { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
[Setting("issuer-password", IsSecret = true)]
public string? IssuerPassword { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
[Setting("storage", IsMandatory = true, Converter = typeof(StorageConverter))]
public IStorage? Storage { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
[Setting("validity-period", Default = "1y", Converter = typeof(TimeSpanConverter))]
public TimeSpan? ValidityPeriod { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
[Setting("password", IsSecret = true)]
// [Setting("certificate-target", IsMandatory = true, Converter = typeof(StorageConverter))]
// public IStorage? Storage { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
[Setting("target-password", IsSecret = true)]
public string? Password { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
[Setting("validity-period", Default = "365d", Converter = typeof(TimeSpanConverter))]
public TimeSpan? ValidityPeriod { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
[Setting("target-kind", Converter = typeof(StorageKindConverter))]
public IStorage? Target { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
[Setting("target-file", Converter = typeof(FileStorageContextConverter))]
public FileStorageContext? TargetFileContext { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
[Setting("target-certstore", Converter = typeof(CertStoreStorageContextConverter))]
public CertStoreStorageContext? TargetCertStoreContext { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
public StorageContext GetTargetContext()
{
if (Target == null)
{
throw new JobException("Target storage is not properly configured");
}
StorageContext? ctx;
switch (Target)
{
case FileStorage:
ctx = TargetFileContext;
break;
case CertStoreStorage:
if (TargetCertStoreContext != null && Password != null)
{
TargetCertStoreContext.Password = Password;
}
ctx = TargetCertStoreContext;
break;
default:
ctx = null;
break;
}
if (ctx == null)
{
throw new JobException("context for target storage of type '{0}' is not properly set", Target.GetType().ToString(false));
}
return ctx;
}
public StorageContext GetIssuerContext()
{
if (Issuer == null)
{
throw new JobException("Issuer storage is not properly configured");
}
StorageContext? ctx;
switch (Issuer)
{
case FileStorage:
ctx = IssuerFileContext;
break;
case CertStoreStorage:
ctx = IssuerCertStoreContext;
break;
default:
ctx = null;
break;
}
if (ctx == null)
{
throw new JobException("context for issuer storage of type '{0}' is not properly set", Issuer.GetType().ToString(false));
}
return ctx;
}
protected override Task DoValidateAsync(ValidationResults results, CancellationToken cancellationToken)
{
@@ -92,9 +175,24 @@ public sealed class CertificateSettings : JobSettings
results.AddInvalid(nameof(HashAlgorithm), "value value must be specified: '{0}'", HashAlgorithm?.ToString() ?? "<null>");
}
if (Storage == null)
if (Target == null)
{
results.AddInvalid(nameof(Storage), "must be specified");
results.AddInvalid(nameof(Target), "must be specified");
}
if (Target is FileStorage)
{
if (TargetFileContext is null)
{
results.AddInvalid(nameof(TargetFileContext), "value must be specified when target is '{0}'", Target.GetType().ToString(false));
}
}
else if (Target is CertStoreStorage)
{
if (TargetCertStoreContext is null)
{
results.AddInvalid(nameof(TargetCertStoreContext), "value must be specified when target is '{0}'", Target.GetType().ToString(false));
}
}
return Task.CompletedTask;

View File

@@ -24,18 +24,20 @@ public sealed class CreateCertificateJob : Job<CertificateSettings>
CertificateManager cm = new CertificateManager();
using (X509Certificate2 cert = await cm.CreateAsync(cgcs, gs, cancellationToken).ConfigureAwait(false))
{
if (Settings.Storage != null)
if (Settings.Target != null)
{
StorageContext ctx = Settings.GetTargetContext();
byte[] data = cert.Export(X509ContentType.Pfx, Settings.Password);
using (MemoryStream ms = new MemoryStream())
{
await ms.WriteAsync(data, cancellationToken);
ms.Position = 0;
StoreResult writeResult = await Settings.Storage.WriteAsync(ms, cancellationToken).ConfigureAwait(false);
StoreResult writeResult = await Settings.Target.WriteAsync(ms, ctx, cancellationToken).ConfigureAwait(false);
if (!writeResult.IsSuccess)
{
throw new JobException(writeResult.Exception, "Failed to write create certificate to target storage (type = '{0}', storage = '{1}')", Settings.Storage.GetType().ToString(false), Settings.Storage.ToString());
throw new JobException(writeResult.Exception, "Failed to write create certificate to target storage (type = '{0}', context = '{1}')", Settings.Target.GetType().ToString(false), ctx.ToString());
}
}
}
@@ -80,9 +82,10 @@ public sealed class CreateCertificateJob : Job<CertificateSettings>
{
using (MemoryStream ms = new MemoryStream())
{
StorageContext ctx = Settings.GetIssuerContext();
// X509KeyStorageFlags flags = X509KeyStorageFlags.Exportable | X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet;
X509KeyStorageFlags flags = X509KeyStorageFlags.DefaultKeySet;
await Settings.Issuer.ReadAsync(ms, cancellationToken).ConfigureAwait(false);
await Settings.Issuer.ReadAsync(ms, ctx, cancellationToken).ConfigureAwait(false);
cgcs.Issuer = X509CertificateLoader.LoadPkcs12(ms.GetBuffer(), Settings.IssuerPassword, flags);
}
}

View File

@@ -8,7 +8,8 @@ internal static class Program
{
args = [
"--job=create-certificate",
"--issuer-certificate=file|o|c:\\friend2.pfx",
"--issuer-kind=file",
"--issuer-file=o|c:\\friend2.pfx",
"--issuer-password=aaa",
"--subject=CN=hello",
"--san=world",
@@ -16,8 +17,21 @@ internal static class Program
"--san=IP:192.168.131.1",
"--algorithm=ecdsa",
"--ecdsa-curve=p384",
"--storage=file|w|c:\\mycert-ecdsa.pfx",
"--validity-period=2d" ];
"--validity-period=2d",
// "--certificate-target=file|w|c:\\mycert-ecdsa.pfx",
// "--certificate-password=aaa",
// "--target-kind=file",
// "--target-file=w|c:\\mycert-ecdsa.pfx",
// "--target-password=aaa",
"--target-kind=certstore",
"--target-certstore=machine|my|exportable",
"--target-password=aaa"
];
// --certificate-target=certstore|machine|my
// args = [
// "--job=create-certificate",
@@ -29,10 +43,12 @@ internal static class Program
// "--san=IP:192.168.131.1",
// "--algorithm=rsa",
// "--rsa-key-size=2048",
// "--storage=file|w|c:\\friend-rsa.pfx",
// "--validity-period=2d" ];
// "--validity-period=2d",
// "--certificate-target=file|w|c:\\friend-rsa.pfx",
// "--certificate-password=aaa"
// ];
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromMinutes(3));
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
JobExecutor executor = new JobExecutor();
int errorLevel = await executor.ExecuteAsync(args, cts.Token).ConfigureAwait(false);

View File

@@ -9,4 +9,8 @@
<RootNamespace>CertMgr</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Folder Include="Core\Storage\Filters\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,20 @@
using CertMgr.Core.Storage;
using NUnit.Framework;
namespace certmgrTest;
public class CertStoreStorageTest
{
[Test]
public async Task First()
{
using (MemoryStream target = new MemoryStream())
{
CertStoreStorage storage = new CertStoreStorage();
CertStoreStorageContext context = new CertStoreStorageContext(CertStoreLocation.User, CertStoreName.My, System.Security.Cryptography.X509Certificates.X509KeyStorageFlags.DefaultKeySet);
context.Password = "aaa";
await storage.ReadAsync(target, context, CancellationToken.None);
}
}
}

View File

@@ -0,0 +1,82 @@

using System.Diagnostics;
using System.Reflection;
using CertMgr.Core;
using CertMgr.Core.Attributes;
using CertMgr.Core.Jobs;
using CertMgr.Core.Validation;
using NUnit.Framework;
namespace certmgrTest;
public class PropertyDescriptorTest
{
[Test]
public void First()
{
PropertyDescriptor? descriptor = GetProperty(typeof(TestSettings), nameof(TestSettings.StringItems));
if (descriptor == null)
{
throw new Exception(string.Format("property not found"));
}
IValueValidator? validator = descriptor.CustomValidator;
}
private static PropertyDescriptor? GetProperty(Type settingsType, string name)
{
PropertyDescriptor? result = null;
foreach (PropertyInfo propertyInfo in settingsType.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (propertyInfo.Name != name)
{
continue;
}
SettingAttribute? settingAttribute = propertyInfo.GetCustomAttribute<SettingAttribute>();
if (settingAttribute is null)
{
continue;
}
result = new PropertyDescriptor(propertyInfo, settingAttribute, settingsType);
break;
}
return result;
}
private sealed class TestSettings : JobSettings
{
[Setting("string-names", Validator = typeof(StringItemValidator))]
public IReadOnlyCollection<string>? StringItems { get; set; }
}
private sealed class StringItemValidator : IValueValidator<string>, IValueValidator<IEnumerable<string>>
{
public StringItemValidator(string settingName)
{
ValueName = settingName;
}
public string ValueName { [DebuggerStepThrough] get; }
Task<IValidationResult> IValueValidator<string>.ValidateAsync(string? settingValue, CancellationToken cancellationToken)
{
return Task.FromResult((IValidationResult)new ValidationResult(ValueName, true, "OK"));
}
Task<IValidationResult> IValueValidator.ValidateAsync(object? value, CancellationToken cancellationToken)
{
return Task.FromResult((IValidationResult)new ValidationResult(ValueName, true, "OK"));
}
Task<IValidationResult> IValueValidator<IEnumerable<string>>.ValidateAsync(IEnumerable<string>? settingValue, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}
}
}

View File

@@ -15,7 +15,7 @@ public class SubjectValidatorTest
public async Task First(string subj, bool expectedSuccess)
{
SubjectValidator validator = new SubjectValidator("TestSetting");
ValidationResult result = await validator.ValidateAsync(subj, CancellationToken.None);
IValidationResult result = await validator.ValidateAsync(subj, CancellationToken.None);
Assert.That(result.IsValid, Is.EqualTo(expectedSuccess), result.Justification);
}
}