SubjectValidator validates for supported attributes and OID
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -67,11 +67,11 @@ internal abstract class CertificateGeneratorBase<TAlgorithm, TSettings> : ICerti
|
||||
return han;
|
||||
}
|
||||
|
||||
protected abstract TAlgorithm CreatePrivateKey();
|
||||
protected abstract TAlgorithm CreateKeyPair();
|
||||
|
||||
protected abstract CertificateRequest DoCreateRequest(string subjectName, TAlgorithm privateKey);
|
||||
protected abstract CertificateRequest DoCreateRequest(string subjectName, TAlgorithm keypair);
|
||||
|
||||
protected abstract X509Certificate2 JoinPrivateKey(X509Certificate2 publicOnlyCert, TAlgorithm privateKey);
|
||||
protected abstract X509Certificate2 JoinPrivateKey(X509Certificate2 publicOnlyCert, TAlgorithm keypair);
|
||||
|
||||
protected abstract TAlgorithm? GetPrivateKey(X509Certificate2 cert);
|
||||
|
||||
@@ -81,9 +81,9 @@ internal abstract class CertificateGeneratorBase<TAlgorithm, TSettings> : ICerti
|
||||
|
||||
X509Certificate2 cert;
|
||||
|
||||
using (TAlgorithm privateKey = CreatePrivateKey())
|
||||
using (TAlgorithm keypair = CreateKeyPair())
|
||||
{
|
||||
CertificateRequest request = CreateRequest(settings, privateKey);
|
||||
CertificateRequest request = CreateRequest(settings, keypair);
|
||||
|
||||
DateTimeOffset notBefore = DateTimeOffset.UtcNow.AddSeconds(-1);
|
||||
DateTimeOffset notAfter = DateTimeOffset.UtcNow.Add(settings.ValidityPeriod);
|
||||
@@ -93,7 +93,7 @@ internal abstract class CertificateGeneratorBase<TAlgorithm, TSettings> : ICerti
|
||||
X509SignatureGenerator sgen = GetSignatureGenerator(settings.Issuer);
|
||||
using (X509Certificate2 publicOnlyCert = request.Create(settings.Issuer.SubjectName, sgen, notBefore, notAfter, serial))
|
||||
{
|
||||
cert = JoinPrivateKey(publicOnlyCert, privateKey);
|
||||
cert = JoinPrivateKey(publicOnlyCert, keypair);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -105,10 +105,10 @@ internal abstract class CertificateGeneratorBase<TAlgorithm, TSettings> : ICerti
|
||||
return Task.FromResult(cert);
|
||||
}
|
||||
|
||||
private CertificateRequest CreateRequest(CertificateSettings settings, TAlgorithm privateKey)
|
||||
private CertificateRequest CreateRequest(CertificateSettings settings, TAlgorithm keys)
|
||||
{
|
||||
string commonName = CreateCommonName(settings.SubjectName);
|
||||
CertificateRequest request = DoCreateRequest(commonName, privateKey);
|
||||
CertificateRequest request = DoCreateRequest(commonName, keys);
|
||||
|
||||
SubjectAlternativeNameBuilder altNames = new SubjectAlternativeNameBuilder();
|
||||
string subj = commonName.Substring("CN=".Length);
|
||||
@@ -138,6 +138,8 @@ internal abstract class CertificateGeneratorBase<TAlgorithm, TSettings> : ICerti
|
||||
|
||||
if (settings.IsCertificateAuthority)
|
||||
{
|
||||
request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, X509SubjectKeyIdentifierHashAlgorithm.Sha1, false));
|
||||
|
||||
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(settings.IsCertificateAuthority, false, 0, false));
|
||||
}
|
||||
|
||||
|
||||
@@ -12,19 +12,19 @@ internal sealed class EcdsaCertificateGenerator : CertificateGeneratorBase<ECDsa
|
||||
{
|
||||
}
|
||||
|
||||
protected override ECDsa CreatePrivateKey()
|
||||
protected override ECDsa CreateKeyPair()
|
||||
{
|
||||
return ECDsa.Create(GetCurve());
|
||||
}
|
||||
|
||||
protected override CertificateRequest DoCreateRequest(string subjectName, ECDsa privateKey)
|
||||
protected override CertificateRequest DoCreateRequest(string subjectName, ECDsa keypair)
|
||||
{
|
||||
return new CertificateRequest(subjectName, privateKey, GetHashAlgorithm());
|
||||
return new CertificateRequest(subjectName, keypair, GetHashAlgorithm());
|
||||
}
|
||||
|
||||
protected override X509Certificate2 JoinPrivateKey(X509Certificate2 publicOnlyCert, ECDsa privateKey)
|
||||
protected override X509Certificate2 JoinPrivateKey(X509Certificate2 publicOnlyCert, ECDsa keypair)
|
||||
{
|
||||
return publicOnlyCert.CopyWithPrivateKey(privateKey);
|
||||
return publicOnlyCert.CopyWithPrivateKey(keypair);
|
||||
}
|
||||
|
||||
protected override ECDsa? GetPrivateKey(X509Certificate2 cert)
|
||||
|
||||
@@ -12,19 +12,19 @@ internal sealed class RsaCertificateGenerator : CertificateGeneratorBase<RSA, Rs
|
||||
{
|
||||
}
|
||||
|
||||
protected override RSA CreatePrivateKey()
|
||||
protected override RSA CreateKeyPair()
|
||||
{
|
||||
return RSA.Create(GetKeySize());
|
||||
}
|
||||
|
||||
protected override CertificateRequest DoCreateRequest(string subjectName, RSA privateKey)
|
||||
protected override CertificateRequest DoCreateRequest(string subjectName, RSA keypair)
|
||||
{
|
||||
return new CertificateRequest(subjectName, privateKey, GetHashAlgorithm(), RSASignaturePadding.Pkcs1);
|
||||
return new CertificateRequest(subjectName, keypair, GetHashAlgorithm(), RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
|
||||
protected override X509Certificate2 JoinPrivateKey(X509Certificate2 publicOnlyCert, RSA privateKey)
|
||||
protected override X509Certificate2 JoinPrivateKey(X509Certificate2 publicOnlyCert, RSA keypair)
|
||||
{
|
||||
return publicOnlyCert.CopyWithPrivateKey(privateKey);
|
||||
return publicOnlyCert.CopyWithPrivateKey(keypair);
|
||||
}
|
||||
|
||||
protected override RSA? GetPrivateKey(X509Certificate2 cert)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
using CertMgr.Core.Validation;
|
||||
@@ -7,6 +8,15 @@ namespace CertMgr.CertGen.Utils;
|
||||
|
||||
public sealed class SubjectValidator : ISettingValidator<string>
|
||||
{
|
||||
// the list contains most used attributes, but more of them exists. Extend the list if needed.
|
||||
private static readonly HashSet<string> AllowedAttributes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"cn", "o", "ou", "c", "l", "st", "s",
|
||||
"street", "sn", "gn", "givenName", "surname",
|
||||
"title", "initials", "serialNumber",
|
||||
"dc", "uid", "emailAddress", "postalCode", "poBox"
|
||||
};
|
||||
|
||||
public SubjectValidator(string settingName)
|
||||
{
|
||||
SettingName = settingName;
|
||||
@@ -23,7 +33,14 @@ public sealed class SubjectValidator : ISettingValidator<string>
|
||||
|
||||
try
|
||||
{
|
||||
X500DistinguishedName dn = new X500DistinguishedName(settingValue);
|
||||
X500DistinguishedName dn = new X500DistinguishedName(settingValue, X500DistinguishedNameFlags.UseCommas | X500DistinguishedNameFlags.DoNotUseQuotes | X500DistinguishedNameFlags.UseUTF8Encoding);
|
||||
|
||||
string normalizedSubject = dn.Decode(X500DistinguishedNameFlags.UseCommas | X500DistinguishedNameFlags.DoNotUseQuotes | X500DistinguishedNameFlags.UseUTF8Encoding);
|
||||
|
||||
if (!EnsureAllowedAttributes(normalizedSubject, out string? error))
|
||||
{
|
||||
return Task.FromResult(new ValidationResult(SettingName, false, error));
|
||||
}
|
||||
|
||||
return Task.FromResult(new ValidationResult(SettingName, true, "success"));
|
||||
}
|
||||
@@ -37,4 +54,196 @@ public sealed class SubjectValidator : ISettingValidator<string>
|
||||
{
|
||||
return ValidateAsync(value as string, cancellationToken);
|
||||
}
|
||||
|
||||
private bool EnsureAllowedAttributes(string normalizedSubjectStr, [NotNullWhen(false)] out string? error)
|
||||
{
|
||||
error = null;
|
||||
|
||||
ReadOnlySpan<char> normalizedSubject = normalizedSubjectStr.AsSpan();
|
||||
int currentIndex = 0;
|
||||
int totalLength = normalizedSubject.Length;
|
||||
|
||||
while (currentIndex < totalLength)
|
||||
{
|
||||
// skip whitespaces and commas
|
||||
while (currentIndex < totalLength && (normalizedSubject[currentIndex] == ' ' || normalizedSubject[currentIndex] == ','))
|
||||
{
|
||||
currentIndex++;
|
||||
}
|
||||
|
||||
if (currentIndex >= totalLength)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// reached start or RDN (relative distinguished name)
|
||||
// note that single RDN might contain multiple values (rare cases), in such a case they are split by '+' (e.g. 'CN=pankrac + OU=machinists')
|
||||
while (currentIndex < totalLength)
|
||||
{
|
||||
int equalsIndex = normalizedSubject[currentIndex..].IndexOf('=');
|
||||
if (equalsIndex < 0)
|
||||
{
|
||||
error = string.Format("Name-value separator (char '=') not found. Search started at index {0}, searched substring = '{1}'", currentIndex, normalizedSubject[currentIndex..].ToString());
|
||||
return false;
|
||||
}
|
||||
|
||||
equalsIndex += currentIndex;
|
||||
|
||||
ReadOnlySpan<char> attrName = normalizedSubject[currentIndex..equalsIndex].Trim();
|
||||
if (attrName.IsEmpty)
|
||||
{
|
||||
error = string.Format("Name of attribute is empty (index = {0}, equals-index = {1})", currentIndex, equalsIndex);
|
||||
return false;
|
||||
}
|
||||
|
||||
// validate name of attribute:
|
||||
if (attrName.StartsWith("OID.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// explicit OID
|
||||
if (attrName.Length < 4)
|
||||
{
|
||||
error = string.Format("Name of attribute '{0}' is too short (length = {1}, index = {2})", attrName.ToString(), attrName.Length, currentIndex);
|
||||
return false;
|
||||
}
|
||||
if (!IsValidOid(attrName.Slice(4), out string? oidError))
|
||||
{
|
||||
error = string.Format("OID of attribute '{0}' is not valid. Error = '{1}' (with OID. prefix)", attrName.ToString(), oidError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (char.IsLetter(attrName[0]))
|
||||
{
|
||||
// regular name (like 'cn', 'o', 'ou' etc) - validate against list of allowed attributes:
|
||||
bool allowed = false;
|
||||
|
||||
foreach (string allowedAttr in AllowedAttributes)
|
||||
{
|
||||
if (attrName.Equals(allowedAttr, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowed)
|
||||
{
|
||||
error = string.Format("Attribute '{0}' is not supported", attrName.ToString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (char.IsDigit(attrName[0]))
|
||||
{
|
||||
// explicit OID without prefix. Not according to the spec, but used by some tools
|
||||
if (!IsValidOid(attrName, out string? oidError))
|
||||
{
|
||||
error = string.Format("OID of attribute '{0}' is not valid. Error = '{1}' (without OID. prefix)", attrName.ToString(), oidError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// invalid attribute name
|
||||
error = string.Format("Attribute '{0}' is not well formed", attrName.ToString());
|
||||
return false;
|
||||
}
|
||||
|
||||
// we do not care for a value here, so find its end. It is marked by '+' (rare cases) or ',' or by end of span/string/value
|
||||
currentIndex = equalsIndex + 1;
|
||||
while (currentIndex < totalLength && normalizedSubject[currentIndex] != '+' && normalizedSubject[currentIndex] != ',')
|
||||
{
|
||||
currentIndex++;
|
||||
}
|
||||
|
||||
if (currentIndex < totalLength && normalizedSubject[currentIndex] == '+')
|
||||
{
|
||||
// current RDN is multi-value RDN, continue to validate next part in inner while-loop (e.g.: CN=pankrac + OU=machinists)
|
||||
currentIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// end of RDN, break to proceed with outer while-loop on next RDN (if available)
|
||||
if (currentIndex < totalLength && normalizedSubject[currentIndex] == ',')
|
||||
{
|
||||
currentIndex++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsValidOid(ReadOnlySpan<char> oid, [NotNullWhen(false)] out string? errorMessage)
|
||||
{
|
||||
// OID must have at least three parts ('arcs')
|
||||
// arcs are separated by dot char ('.')
|
||||
// the type of arc is ulong, so the value must be in ulong's range
|
||||
|
||||
errorMessage = null;
|
||||
|
||||
if (oid.IsEmpty)
|
||||
{
|
||||
errorMessage = "OID must not be empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
int currentArcIndex = 0;
|
||||
ulong firstArc = 0;
|
||||
ulong secondArc = 0;
|
||||
|
||||
MemoryExtensions.SpanSplitEnumerator<char> enu = oid.Split('.');
|
||||
while (enu.MoveNext())
|
||||
{
|
||||
ReadOnlySpan<char> currentArc = oid[enu.Current].Trim();
|
||||
|
||||
if (currentArc.IsEmpty)
|
||||
{
|
||||
errorMessage = string.Format("Arc must not be empty (arc index = {0})", currentArcIndex);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentArc.Length > 1 && currentArc[0] == '0')
|
||||
{
|
||||
errorMessage = string.Format("Arc must not have leading zeros (value = '{0}', arc index = {1})", currentArc.ToString(), currentArcIndex);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ulong.TryParse(currentArc, out ulong value))
|
||||
{
|
||||
errorMessage = string.Format("Value of arc must be in ulong's range (value = '{0}', arc index = {1})", currentArc.ToString(), currentArcIndex);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentArcIndex == 0)
|
||||
{
|
||||
firstArc = value;
|
||||
}
|
||||
else if (currentArcIndex == 1)
|
||||
{
|
||||
secondArc = value;
|
||||
}
|
||||
|
||||
currentArcIndex++;
|
||||
}
|
||||
|
||||
if (currentArcIndex < 3)
|
||||
{
|
||||
errorMessage = string.Format("OID must have at least three arcs (value = '{0}', arcs count = {1})", oid.ToString(), currentArcIndex);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (firstArc > 2)
|
||||
{
|
||||
errorMessage = string.Format("Value of first arc must be from range [0;2] (first arc value = '{0}')", firstArc);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (firstArc < 2 && secondArc > 39)
|
||||
{
|
||||
errorMessage = string.Format("If first arc has value < 2 then second arc must be from range [0..39] (first arc value = '{0}', second arc value = '{1}')", firstArc, secondArc);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,21 @@ public struct AsyncResult<T>
|
||||
|
||||
public T Value { [DebuggerStepThrough] get; }
|
||||
|
||||
public static AsyncResult<T> Create(bool success, T value)
|
||||
{
|
||||
return new AsyncResult<T>(success, value);
|
||||
}
|
||||
|
||||
public static AsyncResult<T> Success(T value)
|
||||
{
|
||||
return new AsyncResult<T>(true, value);
|
||||
}
|
||||
|
||||
public static AsyncResult<T> Failure(T value)
|
||||
{
|
||||
return new AsyncResult<T>(false, value);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return string.Format("{0}: {1}", IsSuccess ? "Succeeded" : "Failed", Value?.ToString() ?? "<null>");
|
||||
@@ -6,17 +6,6 @@ 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",
|
||||
@@ -25,11 +14,24 @@ internal static class Program
|
||||
"--san=world",
|
||||
"--san=DNS:zdrastvujte",
|
||||
"--san=IP:192.168.131.1",
|
||||
"--algorithm=rsa",
|
||||
"--rsa-key-size=2048",
|
||||
"--storage=file|w|c:\\friend-rsa.pfx",
|
||||
"--algorithm=ecdsa",
|
||||
"--ecdsa-curve=p384",
|
||||
"--storage=file|w|c:\\mycert-ecdsa.pfx",
|
||||
"--validity-period=2d" ];
|
||||
|
||||
// args = [
|
||||
// "--job=create-certificate",
|
||||
// "--issuer-certificate=file|o|c:\\friend2.pfx",
|
||||
// "--issuer-password=aaa",
|
||||
// "--subject=CN=hello",
|
||||
// "--san=world",
|
||||
// "--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(3));
|
||||
|
||||
JobExecutor executor = new JobExecutor();
|
||||
|
||||
21
certmgrTest/SubjectValidatorTest.cs
Normal file
21
certmgrTest/SubjectValidatorTest.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using CertMgr.CertGen.Utils;
|
||||
using CertMgr.Core.Validation;
|
||||
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace certmgrTest;
|
||||
|
||||
public class SubjectValidatorTest
|
||||
{
|
||||
[TestCase("CN=www.example.com, O=Example Corp, OU=IT Department, L=Prague, ST=Czech Republic, C=CZ", true)]
|
||||
[TestCase("CN=John Doe + OU=Engineering", true)]
|
||||
[TestCase("1.2.3.4=John Doe", true)]
|
||||
[TestCase("OID.1.2.3.4=John Doe", true)]
|
||||
[TestCase("OID.1.2=John Doe", false)]
|
||||
public async Task First(string subj, bool expectedSuccess)
|
||||
{
|
||||
SubjectValidator validator = new SubjectValidator("TestSetting");
|
||||
ValidationResult result = await validator.ValidateAsync(subj, CancellationToken.None);
|
||||
Assert.That(result.IsValid, Is.EqualTo(expectedSuccess), result.Justification);
|
||||
}
|
||||
}
|
||||
19
certmgrTest/certmgrTest.csproj
Normal file
19
certmgrTest/certmgrTest.csproj
Normal file
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
|
||||
<PackageReference Include="NUnit" Version="4.4.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="5.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\certmgr\certmgr.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user