new job get-certificate-info
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
public class CertGenException : Exception
|
public class CertGenException : Exception
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
public enum CertificateAlgorithm
|
public enum CertificateAlgorithm
|
||||||
{
|
{
|
||||||
@@ -5,7 +5,7 @@ using System.Security.Cryptography.X509Certificates;
|
|||||||
|
|
||||||
using CertMgr.Core.Exceptions;
|
using CertMgr.Core.Exceptions;
|
||||||
|
|
||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
internal abstract class CertificateGeneratorBase<TAlgorithm, TSettings> : ICertificateGenerator
|
internal abstract class CertificateGeneratorBase<TAlgorithm, TSettings> : ICertificateGenerator
|
||||||
where TAlgorithm : AsymmetricAlgorithm
|
where TAlgorithm : AsymmetricAlgorithm
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
using CertMgr.Core.Exceptions;
|
using CertMgr.Core.Exceptions;
|
||||||
|
|
||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
public sealed class CertificateManager
|
public sealed class CertificateManager
|
||||||
{
|
{
|
||||||
@@ -4,7 +4,7 @@ using System.Security.Cryptography.X509Certificates;
|
|||||||
using CertMgr.Core.Utils;
|
using CertMgr.Core.Utils;
|
||||||
using CertMgr.Core.Validation;
|
using CertMgr.Core.Validation;
|
||||||
|
|
||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
public sealed class CertificateSettings
|
public sealed class CertificateSettings
|
||||||
{
|
{
|
||||||
@@ -3,7 +3,7 @@ using System.Security.Cryptography.X509Certificates;
|
|||||||
|
|
||||||
using CertMgr.Core.Exceptions;
|
using CertMgr.Core.Exceptions;
|
||||||
|
|
||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
internal sealed class EcdsaCertificateGenerator : CertificateGeneratorBase<ECDsa, EcdsaGeneratorSettings>
|
internal sealed class EcdsaCertificateGenerator : CertificateGeneratorBase<ECDsa, EcdsaGeneratorSettings>
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
public enum EcdsaCurve
|
public enum EcdsaCurve
|
||||||
{
|
{
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
using CertMgr.Core.Exceptions;
|
using CertMgr.Core.Exceptions;
|
||||||
|
|
||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
public sealed class EcdsaGeneratorSettings : GeneratorSettings
|
public sealed class EcdsaGeneratorSettings : GeneratorSettings
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
|
||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
public abstract class GeneratorSettings
|
public abstract class GeneratorSettings
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
public enum GeneratorType
|
public enum GeneratorType
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
public enum HashAlgorithm
|
public enum HashAlgorithm
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
public interface ICertificateGenerator : IAsyncDisposable
|
public interface ICertificateGenerator : IAsyncDisposable
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
[Flags]
|
[Flags]
|
||||||
public enum KeyUsage
|
public enum KeyUsage
|
||||||
@@ -3,7 +3,7 @@ using System.Security.Cryptography.X509Certificates;
|
|||||||
|
|
||||||
using CertMgr.Core.Exceptions;
|
using CertMgr.Core.Exceptions;
|
||||||
|
|
||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
internal sealed class RsaCertificateGenerator : CertificateGeneratorBase<RSA, RsaGeneratorSettings>
|
internal sealed class RsaCertificateGenerator : CertificateGeneratorBase<RSA, RsaGeneratorSettings>
|
||||||
{
|
{
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
using CertMgr.Core.Exceptions;
|
using CertMgr.Core.Exceptions;
|
||||||
|
|
||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
public sealed class RsaGeneratorSettings : GeneratorSettings
|
public sealed class RsaGeneratorSettings : GeneratorSettings
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
public enum RsaKeySize
|
public enum RsaKeySize
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
public enum SANKind
|
public enum SANKind
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
|
||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
public sealed class SubjectAlternateName : IEquatable<SubjectAlternateName>
|
public sealed class SubjectAlternateName : IEquatable<SubjectAlternateName>
|
||||||
{
|
{
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
using CertMgr.Core.Log;
|
using CertMgr.Core.Log;
|
||||||
using CertMgr.Core.Utils;
|
using CertMgr.Core.Utils;
|
||||||
|
|
||||||
namespace CertMgr.CertGen;
|
namespace CertMgr.Certificates.CertGen;
|
||||||
|
|
||||||
public sealed class SubjectAlternateNames : IReadOnlyCollection<SubjectAlternateName>
|
public sealed class SubjectAlternateNames : IReadOnlyCollection<SubjectAlternateName>
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using CertMgr.Core.Utils;
|
using CertMgr.Core.Utils;
|
||||||
|
|
||||||
namespace CertMgr.CertGen.Utils;
|
namespace CertMgr.Certificates.CertGen.Utils;
|
||||||
|
|
||||||
public sealed class CollectionEquivalencyComparer<T> : IEqualityComparer<IEnumerable<T>> where T : notnull
|
public sealed class CollectionEquivalencyComparer<T> : IEqualityComparer<IEnumerable<T>> where T : notnull
|
||||||
{
|
{
|
||||||
@@ -4,7 +4,7 @@ using System.Security.Cryptography.X509Certificates;
|
|||||||
|
|
||||||
using CertMgr.Core.Validation;
|
using CertMgr.Core.Validation;
|
||||||
|
|
||||||
namespace CertMgr.CertGen.Utils;
|
namespace CertMgr.Certificates.CertGen.Utils;
|
||||||
|
|
||||||
public sealed class SubjectValidator : IValueValidator<string>
|
public sealed class SubjectValidator : IValueValidator<string>
|
||||||
{
|
{
|
||||||
42
certmgr/Certificates/CertStoreSearcher.cs
Normal file
42
certmgr/Certificates/CertStoreSearcher.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
|
using CertMgr.Core.Filters;
|
||||||
|
using CertMgr.Jobs;
|
||||||
|
|
||||||
|
namespace CertMgr.Certificates;
|
||||||
|
|
||||||
|
public sealed class CertStoreSearcher
|
||||||
|
{
|
||||||
|
public CertStoreSearcher(CertStore store)
|
||||||
|
{
|
||||||
|
Store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CertStore Store { [DebuggerStepThrough] get; }
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<X509Certificate2>> SearchAsync(IFilter<X509Certificate2> filter, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
List<X509Certificate2> certificates = new List<X509Certificate2>();
|
||||||
|
|
||||||
|
using (X509Store store = new X509Store((StoreName)Store.Name, (StoreLocation)Store.Location))
|
||||||
|
{
|
||||||
|
store.Open(OpenFlags.ReadOnly);
|
||||||
|
|
||||||
|
foreach (X509Certificate2 cert in store.Certificates)
|
||||||
|
{
|
||||||
|
if (await filter.IsMatchAsync(cert, cancellationToken))
|
||||||
|
{
|
||||||
|
certificates.Add(cert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return certificates;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return string.Format("store = {0}/{1}", Store.Location, Store.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
certmgr/Certificates/Filters/ByThumbprintFilter.cs
Normal file
21
certmgr/Certificates/Filters/ByThumbprintFilter.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
|
namespace CertMgr.Certificates.Filters;
|
||||||
|
|
||||||
|
internal sealed class ByThumbprintFilter : CertificateFilter
|
||||||
|
{
|
||||||
|
internal ByThumbprintFilter(string thumbprint)
|
||||||
|
: base(CertificateFilterType.Thumbprint)
|
||||||
|
{
|
||||||
|
Thumbprint = thumbprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
certmgr/Certificates/Filters/CertificateFilter.cs
Normal file
21
certmgr/Certificates/Filters/CertificateFilter.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
|
using CertMgr.Core.Filters;
|
||||||
|
|
||||||
|
namespace CertMgr.Certificates.Filters;
|
||||||
|
|
||||||
|
public abstract class CertificateFilter : Filter<X509Certificate2>
|
||||||
|
{
|
||||||
|
internal CertificateFilter(CertificateFilterType type)
|
||||||
|
{
|
||||||
|
Type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CertificateFilterType Type { [DebuggerStepThrough] get; }
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return string.Format("type = {0}", Type);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
certmgr/Certificates/Filters/CertificateFilterType.cs
Normal file
6
certmgr/Certificates/Filters/CertificateFilterType.cs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
namespace CertMgr.Certificates.Filters;
|
||||||
|
|
||||||
|
public enum CertificateFilterType
|
||||||
|
{
|
||||||
|
Thumbprint = 1
|
||||||
|
}
|
||||||
16
certmgr/Certificates/Filters/KnownFilters.cs
Normal file
16
certmgr/Certificates/Filters/KnownFilters.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
|
using CertMgr.Core.Filters;
|
||||||
|
|
||||||
|
namespace CertMgr.Certificates.Filters;
|
||||||
|
|
||||||
|
public static class KnownFilters
|
||||||
|
{
|
||||||
|
public static class Certificate
|
||||||
|
{
|
||||||
|
public static IFilter<X509Certificate2> ByThumbprint(string thumbprint)
|
||||||
|
{
|
||||||
|
return new ByThumbprintFilter(thumbprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using CertMgr.Core.Utils;
|
/*using CertMgr.Core.Utils;
|
||||||
|
|
||||||
namespace CertMgr.Core;
|
namespace CertMgr.Core;
|
||||||
|
|
||||||
@@ -13,3 +13,4 @@ public class CertMgrException : Exception
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace CertMgr.Core.Converters;
|
|
||||||
|
|
||||||
public class ConverterContext
|
|
||||||
{
|
|
||||||
public static readonly ConverterContext Empty = new ConverterContext();
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
|
|
||||||
namespace CertMgr.Core.Converters;
|
|
||||||
|
|
||||||
public class EnumConverterContext : ConverterContext
|
|
||||||
{
|
|
||||||
internal EnumConverterContext(Type targetType)
|
|
||||||
{
|
|
||||||
TargetType = targetType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Type TargetType { [DebuggerStepThrough] get; }
|
|
||||||
}
|
|
||||||
@@ -4,38 +4,20 @@ namespace CertMgr.Core.Converters.Impl;
|
|||||||
|
|
||||||
internal class StorageKindConverter : ValueConverter<IStorage>
|
internal class StorageKindConverter : ValueConverter<IStorage>
|
||||||
{
|
{
|
||||||
// private const char Separator = '|';
|
|
||||||
|
|
||||||
protected override Task<IStorage?> DoConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken)
|
protected override Task<IStorage?> DoConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ReadOnlySpan<char> storageTypeSpan = rawValue.AsSpan();
|
ReadOnlySpan<char> storageTypeSpan = rawValue.AsSpan();
|
||||||
|
|
||||||
// int storageTypeSplitIndex = rawSpan.IndexOf(Separator);
|
|
||||||
// if (storageTypeSplitIndex == -1)
|
|
||||||
// {
|
|
||||||
// return Task.FromResult((IStorage?)EmptyStorage.Empty);
|
|
||||||
// }
|
|
||||||
|
|
||||||
IStorage? storage;
|
IStorage? storage;
|
||||||
|
|
||||||
// ReadOnlySpan<char> storageTypeSpan = rawSpan.Slice(0, storageTypeSplitIndex);
|
|
||||||
// ReadOnlySpan<char> storageDefinition = rawSpan.Slice(storageTypeSplitIndex + 1);
|
|
||||||
switch (storageTypeSpan)
|
switch (storageTypeSpan)
|
||||||
{
|
{
|
||||||
case "file":
|
case "file":
|
||||||
storage = new FileStorage();
|
storage = new FileStorage();
|
||||||
// if (!TryGetFileStore(storageDefinition, out storage))
|
|
||||||
// {
|
|
||||||
// storage = EmptyStorage.Empty;
|
|
||||||
// }
|
|
||||||
break;
|
break;
|
||||||
case "certstore":
|
case "certstore":
|
||||||
case "cert-store":
|
case "cert-store":
|
||||||
storage = new CertStoreStorage();
|
storage = new CertStoreStorage();
|
||||||
//if (!TryGetCertStore(storageDefinition, out storage))
|
|
||||||
//{
|
|
||||||
// storage = EmptyStorage.Empty;
|
|
||||||
//}
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
storage = EmptyStorage.Empty;
|
storage = EmptyStorage.Empty;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
using CertMgr.Core.Utils;
|
using CertMgr.Core.Utils;
|
||||||
|
|
||||||
namespace CertMgr.Core.Storage;
|
namespace CertMgr.Core.Filters;
|
||||||
|
|
||||||
public abstract class Filter<T> : IFilter<T>
|
public abstract class Filter<T> : IFilter<T>
|
||||||
{
|
{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace CertMgr.Core.Storage;
|
namespace CertMgr.Core.Filters;
|
||||||
|
|
||||||
public interface IFilter<T>
|
public interface IFilter<T>
|
||||||
{
|
{
|
||||||
@@ -45,6 +45,10 @@ internal sealed class JobExecutor
|
|||||||
errorLevel = result.ErrorLevel;
|
errorLevel = result.ErrorLevel;
|
||||||
|
|
||||||
CLog.Info("Executing job '{0}'... done (finished with error-level {1}, took {2})", job.Name, errorLevel, sw.Elapsed);
|
CLog.Info("Executing job '{0}'... done (finished with error-level {1}, took {2})", job.Name, errorLevel, sw.Elapsed);
|
||||||
|
if (!string.IsNullOrEmpty(result.Message))
|
||||||
|
{
|
||||||
|
CLog.Info(result.Message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
|
using CertMgr.Core.Filters;
|
||||||
using CertMgr.Core.Utils;
|
using CertMgr.Core.Utils;
|
||||||
|
|
||||||
namespace CertMgr.Core.Storage;
|
namespace CertMgr.Core.Storage;
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
namespace CertMgr.Core.Storage;
|
/*namespace CertMgr.Core.Storage;
|
||||||
|
|
||||||
public enum StorageType
|
public enum StorageType
|
||||||
{
|
{
|
||||||
File = 1
|
File = 1
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
@@ -25,7 +25,7 @@ internal static class Extenders
|
|||||||
{ typeof(object), "object" },
|
{ typeof(object), "object" },
|
||||||
};
|
};
|
||||||
|
|
||||||
public static string ToSeparatedList<T>(this IEnumerable<T> items, Func<T, string> formatter, string itemSeparator, string? lastItemSeparator = null)
|
public static string ToSeparatedList<T>(this IEnumerable<T> items, Func<T, string?>? formatter = null, string? itemSeparator = ",", string? lastItemSeparator = null)
|
||||||
{
|
{
|
||||||
using StringBuilderCache.ScopedBuilder lease = StringBuilderCache.AcquireScoped();
|
using StringBuilderCache.ScopedBuilder lease = StringBuilderCache.AcquireScoped();
|
||||||
StringBuilder sb = lease.Builder;
|
StringBuilder sb = lease.Builder;
|
||||||
@@ -35,7 +35,7 @@ internal static class Extenders
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void ToSeparatedList<T>(this IEnumerable<T> items, StringBuilder sb, Func<T, string> formatter, string itemSeparator, string? lastItemSeparator = null)
|
public static void ToSeparatedList<T>(this IEnumerable<T> items, StringBuilder sb, Func<T, string?>? formatter, string? itemSeparator = null, string? lastItemSeparator = null)
|
||||||
{
|
{
|
||||||
if (!items.Any())
|
if (!items.Any())
|
||||||
{
|
{
|
||||||
@@ -245,4 +245,9 @@ internal static class Extenders
|
|||||||
return isOneOf;
|
return isOneOf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void AppendFormatLine(this StringBuilder sb, string format, params object[] args)
|
||||||
|
{
|
||||||
|
sb.AppendFormat(format, args);
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
certmgr/Jobs/CertStore.cs
Normal file
23
certmgr/Jobs/CertStore.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
using CertMgr.Core.Storage;
|
||||||
|
|
||||||
|
namespace CertMgr.Jobs;
|
||||||
|
|
||||||
|
public sealed class CertStore
|
||||||
|
{
|
||||||
|
public CertStore(CertStoreLocation location, CertStoreName name)
|
||||||
|
{
|
||||||
|
Location = location;
|
||||||
|
Name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CertStoreLocation Location { [DebuggerStepThrough] get; }
|
||||||
|
|
||||||
|
public CertStoreName Name { [DebuggerStepThrough] get; }
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return string.Format("{0}/{1}", Location, Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
certmgr/Jobs/CertStoreConverter.cs
Normal file
48
certmgr/Jobs/CertStoreConverter.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using CertMgr.Core.Converters;
|
||||||
|
using CertMgr.Core.Log;
|
||||||
|
using CertMgr.Core.Storage;
|
||||||
|
using CertMgr.Core.Utils;
|
||||||
|
|
||||||
|
namespace CertMgr.Jobs;
|
||||||
|
|
||||||
|
internal sealed class CertStoreConverter : ValueConverter<CertStore>
|
||||||
|
{
|
||||||
|
protected override Task<CertStore?> DoConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
CertStore? store = null;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(rawValue))
|
||||||
|
{
|
||||||
|
return Task.FromResult(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadOnlySpan<char> span = rawValue.AsSpan();
|
||||||
|
int separatorIndex = span.IndexOf('/');
|
||||||
|
if (separatorIndex > -1 && separatorIndex < span.Length - 1)
|
||||||
|
{
|
||||||
|
ReadOnlySpan<char> locationSpan = span.Slice(0, separatorIndex);
|
||||||
|
if (Enum.TryParse(locationSpan, true, out CertStoreLocation location) && Enum.IsDefined(location))
|
||||||
|
{
|
||||||
|
ReadOnlySpan<char> nameSpan = span.Slice(separatorIndex + 1, span.Length - separatorIndex - 1);
|
||||||
|
if (Enum.TryParse(nameSpan, true, out CertStoreName name) && Enum.IsDefined(name))
|
||||||
|
{
|
||||||
|
store = new CertStore(location, name);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CLog.Error("Failed to parse cert-store name from value '{0}'. Value '{1}' is not valid name (available values: {2})", rawValue, nameSpan.ToString(), Enum.GetNames<CertStoreName>().ToSeparatedList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CLog.Error("Failed to parse cert-store location from value '{0}'. Value '{1}' is not valid location (available values: {2})", rawValue, locationSpan.ToString(), Enum.GetNames<CertStoreLocation>().ToSeparatedList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CLog.Error("Cannot parse cert-store from value '{0}'. Value must be '<location>/<name>'", rawValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(store);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
certmgr/Jobs/CertificateFilterConverter.cs
Normal file
52
certmgr/Jobs/CertificateFilterConverter.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
|
using CertMgr.Certificates.Filters;
|
||||||
|
using CertMgr.Core.Converters;
|
||||||
|
using CertMgr.Core.Filters;
|
||||||
|
using CertMgr.Core.Log;
|
||||||
|
using CertMgr.Core.Utils;
|
||||||
|
|
||||||
|
namespace CertMgr.Jobs;
|
||||||
|
|
||||||
|
internal sealed class CertificateFilterConverter : ValueConverter<IFilter<X509Certificate2>>
|
||||||
|
{
|
||||||
|
protected override Task<IFilter<X509Certificate2>?> DoConvertAsync(string rawValue, Type targetType, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
IFilter<X509Certificate2>? filter = null;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(rawValue))
|
||||||
|
{
|
||||||
|
return Task.FromResult(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadOnlySpan<char> span = rawValue.AsSpan();
|
||||||
|
int separatorIndex = span.IndexOf(':');
|
||||||
|
if (separatorIndex > -1 && separatorIndex < span.Length - 1)
|
||||||
|
{
|
||||||
|
ReadOnlySpan<char> filterTypeSpan = span.Slice(0, separatorIndex);
|
||||||
|
if (Enum.TryParse(filterTypeSpan, true, out CertificateFilterType filterType) && Enum.IsDefined(filterType))
|
||||||
|
{
|
||||||
|
ReadOnlySpan<char> nameSpan = span.Slice(separatorIndex + 1, span.Length - separatorIndex - 1);
|
||||||
|
string value = nameSpan.ToString();
|
||||||
|
switch (filterType)
|
||||||
|
{
|
||||||
|
case CertificateFilterType.Thumbprint:
|
||||||
|
filter = KnownFilters.Certificate.ByThumbprint(value);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CLog.Error("Failed to parse filter type from value '{0}'. (available values: {1})", rawValue, Enum.GetNames<CertificateFilterType>().ToSeparatedList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CLog.Error("Cannot parse filter from value '{0}'. Value must be '<filter-type>:<filtering-value>'", rawValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(filter);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
using CertMgr.CertGen;
|
using CertMgr.Certificates.CertGen;
|
||||||
using CertMgr.Core.Exceptions;
|
using CertMgr.Core.Exceptions;
|
||||||
using CertMgr.Core.Jobs;
|
using CertMgr.Core.Jobs;
|
||||||
using CertMgr.Core.Log;
|
using CertMgr.Core.Log;
|
||||||
@@ -9,17 +9,17 @@ using CertMgr.Core.Utils;
|
|||||||
|
|
||||||
namespace CertMgr.Jobs;
|
namespace CertMgr.Jobs;
|
||||||
|
|
||||||
public sealed class CreateCertificateJob : Job<CertificateSettings>
|
public sealed class CreateCertificateJob : Job<CreateCertificateSettings>
|
||||||
{
|
{
|
||||||
public const string ID = "create-certificate";
|
public const string ID = "create-certificate";
|
||||||
|
|
||||||
protected override async Task<JobResult> DoExecuteAsync(CancellationToken cancellationToken)
|
protected override async Task<JobResult> DoExecuteAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
CertificateSettings cs = Settings;
|
CreateCertificateSettings cs = Settings;
|
||||||
CLog.Info("creating certificate using settings: subject = '{0}', algorithm = '{1}', curve = '{2}'", cs.Subject, cs.Algorithm?.ToString() ?? "<null>", cs.Curve);
|
CLog.Info("creating certificate using settings: subject = '{0}', algorithm = '{1}', curve = '{2}'", cs.Subject, cs.Algorithm?.ToString() ?? "<null>", cs.Curve);
|
||||||
|
|
||||||
GeneratorSettings gs = CreateGeneratorSettings();
|
GeneratorSettings gs = CreateGeneratorSettings();
|
||||||
CertGen.CertificateSettings cgcs = await CreateCertificateSettingsAsync(cancellationToken).ConfigureAwait(false);
|
CertificateSettings cgcs = await CreateCertificateSettingsAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
CertificateManager cm = new CertificateManager();
|
CertificateManager cm = new CertificateManager();
|
||||||
using (X509Certificate2 cert = await cm.CreateAsync(cgcs, gs, cancellationToken).ConfigureAwait(false))
|
using (X509Certificate2 cert = await cm.CreateAsync(cgcs, gs, cancellationToken).ConfigureAwait(false))
|
||||||
@@ -65,11 +65,11 @@ public sealed class CreateCertificateJob : Job<CertificateSettings>
|
|||||||
return gs;
|
return gs;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<CertGen.CertificateSettings> CreateCertificateSettingsAsync(CancellationToken cancellationToken)
|
private async Task<CertificateSettings> CreateCertificateSettingsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
CertGen.CertificateSettings cgcs = new CertGen.CertificateSettings();
|
CertificateSettings cgcs = new CertificateSettings();
|
||||||
|
|
||||||
cgcs.SubjectName = Settings.Subject;
|
cgcs.SubjectName = Settings.Subject;
|
||||||
cgcs.ValidityPeriod = Settings.ValidityPeriod.HasValue ? Settings.ValidityPeriod.Value : TimeSpan.FromDays(365);
|
cgcs.ValidityPeriod = Settings.ValidityPeriod.HasValue ? Settings.ValidityPeriod.Value : TimeSpan.FromDays(365);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
|
||||||
using CertMgr.CertGen;
|
using CertMgr.Certificates.CertGen;
|
||||||
using CertMgr.CertGen.Utils;
|
using CertMgr.Certificates.CertGen.Utils;
|
||||||
using CertMgr.Core.Attributes;
|
using CertMgr.Core.Attributes;
|
||||||
using CertMgr.Core.Converters.Impl;
|
using CertMgr.Core.Converters.Impl;
|
||||||
using CertMgr.Core.Jobs;
|
using CertMgr.Core.Jobs;
|
||||||
@@ -11,13 +11,13 @@ using CertMgr.Core.Validation;
|
|||||||
|
|
||||||
namespace CertMgr.Jobs;
|
namespace CertMgr.Jobs;
|
||||||
|
|
||||||
public sealed class CertificateSettings : JobSettings
|
public sealed class CreateCertificateSettings : JobSettings
|
||||||
{
|
{
|
||||||
public CertificateSettings()
|
public CreateCertificateSettings()
|
||||||
{
|
{
|
||||||
Algorithm = CertificateAlgorithm.ECDsa;
|
Algorithm = CertificateAlgorithm.ECDsa;
|
||||||
Curve = EcdsaCurve.P384;
|
Curve = EcdsaCurve.P384;
|
||||||
HashAlgorithm = CertGen.HashAlgorithm.Sha384;
|
HashAlgorithm = Certificates.CertGen.HashAlgorithm.Sha384;
|
||||||
ValidityPeriod = TimeSpan.FromDays(365);
|
ValidityPeriod = TimeSpan.FromDays(365);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ public sealed class CertificateSettings : JobSettings
|
|||||||
[Setting("friendly-name")]
|
[Setting("friendly-name")]
|
||||||
public string? FriendlyName { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
|
public string? FriendlyName { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
|
||||||
|
|
||||||
[Setting("key-usage", Default = CertGen.KeyUsage.None, Converter = typeof(EnumConverter))]
|
[Setting("key-usage", Default = Certificates.CertGen.KeyUsage.None, Converter = typeof(EnumConverter))]
|
||||||
public KeyUsage? KeyUsage { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
|
public KeyUsage? KeyUsage { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
|
||||||
|
|
||||||
[Setting("algorithm", Default = CertificateAlgorithm.ECDsa, Converter = typeof(EnumConverter))]
|
[Setting("algorithm", Default = CertificateAlgorithm.ECDsa, Converter = typeof(EnumConverter))]
|
||||||
107
certmgr/Jobs/GetCertificateInfoJob.cs
Normal file
107
certmgr/Jobs/GetCertificateInfoJob.cs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using CertMgr.Certificates;
|
||||||
|
using CertMgr.Core.Jobs;
|
||||||
|
using CertMgr.Core.Log;
|
||||||
|
using CertMgr.Core.Utils;
|
||||||
|
|
||||||
|
namespace CertMgr.Jobs;
|
||||||
|
|
||||||
|
public sealed class GetCertificateInfoJob : Job<GetCertificateInfoSettings>
|
||||||
|
{
|
||||||
|
public const string ID = "get-certificate-info";
|
||||||
|
|
||||||
|
protected override async Task<JobResult> DoExecuteAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
X509Certificate2? cert = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(Settings.File))
|
||||||
|
{
|
||||||
|
if (File.Exists(Settings.File))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cert = X509CertificateLoader.LoadPkcs12FromFile(Settings.File, Settings.Password);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
throw new Exception(string.Format("Failed to load certificate from file '{0}'", Settings.File), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CLog.Error("File doesn't exist: '{0}'", Settings.File);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (Settings.Store != null)
|
||||||
|
{
|
||||||
|
if (Settings.Filter != null)
|
||||||
|
{
|
||||||
|
CertStoreSearcher searcher = new CertStoreSearcher(Settings.Store);
|
||||||
|
IReadOnlyList<X509Certificate2> certs = await searcher.SearchAsync(Settings.Filter, cancellationToken).ConfigureAwait(false);
|
||||||
|
cert = certs.FirstOrDefault();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CLog.Error("Filter must be specified when info on certificate from cert-store is requested");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CLog.Error("Either file or cert-store must be specified when requesting info on certificate");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cert != null)
|
||||||
|
{
|
||||||
|
using (StringBuilderCache.ScopedBuilder lease = StringBuilderCache.AcquireScoped(256))
|
||||||
|
{
|
||||||
|
int padSize = 24;
|
||||||
|
StringBuilder sb = lease.Builder;
|
||||||
|
sb.AppendLine();
|
||||||
|
AppendWithPadding(sb, "Subject", cert.Subject.StartsWith("CN=") ? cert.Subject.Substring(3) : cert.Subject, padSize);
|
||||||
|
AppendWithPadding(sb, "Issuer", cert.Issuer.StartsWith("CN=") ? cert.Issuer.Substring(3) : cert.Issuer, padSize);
|
||||||
|
AppendWithPadding(sb, "Not Before", cert.NotBefore.ToString("s"), padSize);
|
||||||
|
AppendWithPadding(sb, "Not After", cert.NotAfter.ToString("s"), padSize);
|
||||||
|
AppendWithPadding(sb, "Signature Algorithm", cert.SignatureAlgorithm.FriendlyName, padSize);
|
||||||
|
AppendWithPadding(sb, "Thumbprint", cert.Thumbprint, padSize);
|
||||||
|
if (!string.IsNullOrEmpty(cert.FriendlyName))
|
||||||
|
{
|
||||||
|
AppendWithPadding(sb, "FriendlyName", cert.FriendlyName, padSize);
|
||||||
|
}
|
||||||
|
foreach (X509Extension ext in cert.Extensions)
|
||||||
|
{
|
||||||
|
string[] values = ext.Format(true).TrimEnd(Environment.NewLine.ToCharArray()).Split(Environment.NewLine);
|
||||||
|
if ((values.Length > 1 && !string.IsNullOrEmpty(values[values.Length - 1])) || values.Length > 2)
|
||||||
|
{
|
||||||
|
AppendWithPadding(sb, ext.Oid?.FriendlyName ?? ext.Oid?.Value, string.Empty, padSize);
|
||||||
|
foreach (string value in values)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sb.AppendFormatLine(" - {0}", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AppendWithPadding(sb, ext.Oid?.FriendlyName ?? ext.Oid?.Value, values[0], padSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateSuccess(sb.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return CreateFailure("No certificate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendWithPadding(StringBuilder sb, string name, string value, int padSize)
|
||||||
|
{
|
||||||
|
string padStr = padSize >= name.Length ? new string(' ', padSize - name.Length) : string.Empty;
|
||||||
|
sb.AppendFormatLine("{0}{1}: {2}", name, padStr, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
certmgr/Jobs/GetCertificateInfoSettings.cs
Normal file
23
certmgr/Jobs/GetCertificateInfoSettings.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
|
||||||
|
using CertMgr.Core.Attributes;
|
||||||
|
using CertMgr.Core.Filters;
|
||||||
|
using CertMgr.Core.Jobs;
|
||||||
|
|
||||||
|
namespace CertMgr.Jobs;
|
||||||
|
|
||||||
|
public sealed class GetCertificateInfoSettings : JobSettings
|
||||||
|
{
|
||||||
|
[Setting("file")]
|
||||||
|
public string? File { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
|
||||||
|
|
||||||
|
[Setting("cert-store", Converter = typeof(CertStoreConverter))]
|
||||||
|
public CertStore? Store { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
|
||||||
|
|
||||||
|
[Setting("filter", Converter = typeof(CertificateFilterConverter))]
|
||||||
|
public IFilter<X509Certificate2>? Filter { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
|
||||||
|
|
||||||
|
[Setting("password", IsSecret = true)]
|
||||||
|
public string? Password { [DebuggerStepThrough] get; [DebuggerStepThrough] set; }
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using CertMgr.CertGen;
|
using CertMgr.Certificates.CertGen;
|
||||||
using CertMgr.Core.Converters;
|
using CertMgr.Core.Converters;
|
||||||
|
|
||||||
namespace CertMgr.Jobs;
|
namespace CertMgr.Jobs;
|
||||||
|
|||||||
@@ -6,30 +6,41 @@ internal static class Program
|
|||||||
{
|
{
|
||||||
private static async Task<int> Main(string[] args)
|
private static async Task<int> Main(string[] args)
|
||||||
{
|
{
|
||||||
|
// args = [
|
||||||
|
// "--job=get-certificate-info",
|
||||||
|
// "--file=c:\\mycert-ecdsa.pfx",
|
||||||
|
// "--password="
|
||||||
|
// ];
|
||||||
args = [
|
args = [
|
||||||
"--job=create-certificate",
|
"--job=get-certificate-info",
|
||||||
"--issuer-kind=file",
|
"--cert-store=user/my",
|
||||||
"--issuer-file=o|c:\\friend2.pfx",
|
"--filter=thumbprint:b395149b6079553eea92d9a73ffc97463e8976ff"
|
||||||
"--issuer-password=aaa",
|
];
|
||||||
"--subject=CN=hello",
|
|
||||||
"--san=world",
|
|
||||||
"--san=DNS:zdrastvujte",
|
|
||||||
"--san=IP:192.168.131.1",
|
|
||||||
"--algorithm=ecdsa",
|
|
||||||
"--ecdsa-curve=p384",
|
|
||||||
"--validity-period=2d",
|
|
||||||
|
|
||||||
// "--certificate-target=file|w|c:\\mycert-ecdsa.pfx",
|
// args = [
|
||||||
// "--certificate-password=aaa",
|
// "--job=create-certificate",
|
||||||
|
// "--issuer-kind=file",
|
||||||
// "--target-kind=file",
|
// "--issuer-file=o|c:\\friend2.pfx",
|
||||||
// "--target-file=w|c:\\mycert-ecdsa.pfx",
|
// "--issuer-password=aaa",
|
||||||
// "--target-password=aaa",
|
// "--subject=CN=hello",
|
||||||
|
// "--san=world",
|
||||||
"--target-kind=certstore",
|
// "--san=DNS:zdrastvujte",
|
||||||
"--target-certstore=machine|my|exportable",
|
// "--san=IP:192.168.131.1",
|
||||||
"--target-password=aaa"
|
// "--algorithm=ecdsa",
|
||||||
];
|
// "--ecdsa-curve=p384",
|
||||||
|
// "--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
|
// --certificate-target=certstore|machine|my
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using CertMgr.CertGen.Utils;
|
using CertMgr.Certificates.CertGen.Utils;
|
||||||
using CertMgr.Core.Validation;
|
using CertMgr.Core.Validation;
|
||||||
|
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
|
|||||||
Reference in New Issue
Block a user