SubjectValidator validates for supported attributes and OID

This commit is contained in:
2025-10-22 20:14:50 +02:00
parent 5da328208c
commit cc7fe89330
11 changed files with 302 additions and 34 deletions

View File

@@ -67,11 +67,11 @@ internal abstract class CertificateGeneratorBase<TAlgorithm, TSettings> : ICerti
return han; 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); protected abstract TAlgorithm? GetPrivateKey(X509Certificate2 cert);
@@ -81,9 +81,9 @@ internal abstract class CertificateGeneratorBase<TAlgorithm, TSettings> : ICerti
X509Certificate2 cert; 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 notBefore = DateTimeOffset.UtcNow.AddSeconds(-1);
DateTimeOffset notAfter = DateTimeOffset.UtcNow.Add(settings.ValidityPeriod); DateTimeOffset notAfter = DateTimeOffset.UtcNow.Add(settings.ValidityPeriod);
@@ -93,7 +93,7 @@ internal abstract class CertificateGeneratorBase<TAlgorithm, TSettings> : ICerti
X509SignatureGenerator sgen = GetSignatureGenerator(settings.Issuer); X509SignatureGenerator sgen = GetSignatureGenerator(settings.Issuer);
using (X509Certificate2 publicOnlyCert = request.Create(settings.Issuer.SubjectName, sgen, notBefore, notAfter, serial)) using (X509Certificate2 publicOnlyCert = request.Create(settings.Issuer.SubjectName, sgen, notBefore, notAfter, serial))
{ {
cert = JoinPrivateKey(publicOnlyCert, privateKey); cert = JoinPrivateKey(publicOnlyCert, keypair);
} }
} }
else else
@@ -105,10 +105,10 @@ internal abstract class CertificateGeneratorBase<TAlgorithm, TSettings> : ICerti
return Task.FromResult(cert); return Task.FromResult(cert);
} }
private CertificateRequest CreateRequest(CertificateSettings settings, TAlgorithm privateKey) private CertificateRequest CreateRequest(CertificateSettings settings, TAlgorithm keys)
{ {
string commonName = CreateCommonName(settings.SubjectName); string commonName = CreateCommonName(settings.SubjectName);
CertificateRequest request = DoCreateRequest(commonName, privateKey); CertificateRequest request = DoCreateRequest(commonName, keys);
SubjectAlternativeNameBuilder altNames = new SubjectAlternativeNameBuilder(); SubjectAlternativeNameBuilder altNames = new SubjectAlternativeNameBuilder();
string subj = commonName.Substring("CN=".Length); string subj = commonName.Substring("CN=".Length);
@@ -138,6 +138,8 @@ internal abstract class CertificateGeneratorBase<TAlgorithm, TSettings> : ICerti
if (settings.IsCertificateAuthority) if (settings.IsCertificateAuthority)
{ {
request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, X509SubjectKeyIdentifierHashAlgorithm.Sha1, false));
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(settings.IsCertificateAuthority, false, 0, false)); request.CertificateExtensions.Add(new X509BasicConstraintsExtension(settings.IsCertificateAuthority, false, 0, false));
} }

View File

@@ -12,19 +12,19 @@ internal sealed class EcdsaCertificateGenerator : CertificateGeneratorBase<ECDsa
{ {
} }
protected override ECDsa CreatePrivateKey() protected override ECDsa CreateKeyPair()
{ {
return ECDsa.Create(GetCurve()); 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) protected override ECDsa? GetPrivateKey(X509Certificate2 cert)

View File

@@ -12,19 +12,19 @@ internal sealed class RsaCertificateGenerator : CertificateGeneratorBase<RSA, Rs
{ {
} }
protected override RSA CreatePrivateKey() protected override RSA CreateKeyPair()
{ {
return RSA.Create(GetKeySize()); 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) protected override RSA? GetPrivateKey(X509Certificate2 cert)

View File

@@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using CertMgr.Core.Validation; using CertMgr.Core.Validation;
@@ -7,6 +8,15 @@ namespace CertMgr.CertGen.Utils;
public sealed class SubjectValidator : ISettingValidator<string> 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) public SubjectValidator(string settingName)
{ {
SettingName = settingName; SettingName = settingName;
@@ -23,7 +33,14 @@ public sealed class SubjectValidator : ISettingValidator<string>
try 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")); return Task.FromResult(new ValidationResult(SettingName, true, "success"));
} }
@@ -37,4 +54,196 @@ public sealed class SubjectValidator : ISettingValidator<string>
{ {
return ValidateAsync(value as string, cancellationToken); 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;
}
} }

View File

@@ -14,6 +14,21 @@ public struct AsyncResult<T>
public T Value { [DebuggerStepThrough] get; } 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() public override string ToString()
{ {
return string.Format("{0}: {1}", IsSuccess ? "Succeeded" : "Failed", Value?.ToString() ?? "<null>"); return string.Format("{0}: {1}", IsSuccess ? "Succeeded" : "Failed", Value?.ToString() ?? "<null>");

View File

@@ -6,17 +6,6 @@ internal static class Program
{ {
private static async Task<int> Main(string[] args) 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 = [ args = [
"--job=create-certificate", "--job=create-certificate",
"--issuer-certificate=file|o|c:\\friend2.pfx", "--issuer-certificate=file|o|c:\\friend2.pfx",
@@ -25,10 +14,23 @@ internal static class Program
"--san=world", "--san=world",
"--san=DNS:zdrastvujte", "--san=DNS:zdrastvujte",
"--san=IP:192.168.131.1", "--san=IP:192.168.131.1",
"--algorithm=rsa", "--algorithm=ecdsa",
"--rsa-key-size=2048", "--ecdsa-curve=p384",
"--storage=file|w|c:\\friend-rsa.pfx", "--storage=file|w|c:\\mycert-ecdsa.pfx",
"--validity-period=2d" ]; "--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)); using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromMinutes(3));

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

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