diff --git a/BuildOutput/bin/Debug/net9.0/certmgr.dll b/BuildOutput/bin/Debug/net9.0/certmgr.dll index 52c1c90..0503a3e 100644 Binary files a/BuildOutput/bin/Debug/net9.0/certmgr.dll and b/BuildOutput/bin/Debug/net9.0/certmgr.dll differ diff --git a/BuildOutput/bin/Debug/net9.0/certmgr.exe b/BuildOutput/bin/Debug/net9.0/certmgr.exe index 2326a6c..327056e 100644 Binary files a/BuildOutput/bin/Debug/net9.0/certmgr.exe and b/BuildOutput/bin/Debug/net9.0/certmgr.exe differ diff --git a/BuildOutput/bin/Debug/net9.0/certmgr.pdb b/BuildOutput/bin/Debug/net9.0/certmgr.pdb index 0df438c..18bafc1 100644 Binary files a/BuildOutput/bin/Debug/net9.0/certmgr.pdb and b/BuildOutput/bin/Debug/net9.0/certmgr.pdb differ diff --git a/certmgr/CertGen/CertificateGeneratorBase.cs b/certmgr/CertGen/CertificateGeneratorBase.cs index 6cadfbc..ea3ee47 100644 --- a/certmgr/CertGen/CertificateGeneratorBase.cs +++ b/certmgr/CertGen/CertificateGeneratorBase.cs @@ -67,11 +67,11 @@ internal abstract class CertificateGeneratorBase : 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 : 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 : 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 : 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 : ICerti if (settings.IsCertificateAuthority) { + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, X509SubjectKeyIdentifierHashAlgorithm.Sha1, false)); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(settings.IsCertificateAuthority, false, 0, false)); } diff --git a/certmgr/CertGen/EcdsaCertificateGenerator.cs b/certmgr/CertGen/EcdsaCertificateGenerator.cs index 32d024c..92aaaed 100644 --- a/certmgr/CertGen/EcdsaCertificateGenerator.cs +++ b/certmgr/CertGen/EcdsaCertificateGenerator.cs @@ -12,19 +12,19 @@ internal sealed class EcdsaCertificateGenerator : CertificateGeneratorBase { + // the list contains most used attributes, but more of them exists. Extend the list if needed. + private static readonly HashSet AllowedAttributes = new HashSet(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 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 { return ValidateAsync(value as string, cancellationToken); } + + private bool EnsureAllowedAttributes(string normalizedSubjectStr, [NotNullWhen(false)] out string? error) + { + error = null; + + ReadOnlySpan 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 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 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 enu = oid.Split('.'); + while (enu.MoveNext()) + { + ReadOnlySpan 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; + } } diff --git a/certmgr/Core/Utils/AsyncResult.cs b/certmgr/Core/Utils/AsyncResultT.cs similarity index 57% rename from certmgr/Core/Utils/AsyncResult.cs rename to certmgr/Core/Utils/AsyncResultT.cs index c8fbaab..2c2f1c2 100644 --- a/certmgr/Core/Utils/AsyncResult.cs +++ b/certmgr/Core/Utils/AsyncResultT.cs @@ -14,6 +14,21 @@ public struct AsyncResult public T Value { [DebuggerStepThrough] get; } + public static AsyncResult Create(bool success, T value) + { + return new AsyncResult(success, value); + } + + public static AsyncResult Success(T value) + { + return new AsyncResult(true, value); + } + + public static AsyncResult Failure(T value) + { + return new AsyncResult(false, value); + } + public override string ToString() { return string.Format("{0}: {1}", IsSuccess ? "Succeeded" : "Failed", Value?.ToString() ?? ""); diff --git a/certmgr/Program.cs b/certmgr/Program.cs index 758ab4e..19a8a48 100644 --- a/certmgr/Program.cs +++ b/certmgr/Program.cs @@ -6,17 +6,6 @@ internal static class Program { private static async Task 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,10 +14,23 @@ 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", - "--validity-period=2d" ]; + "--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)); diff --git a/certmgrTest/SubjectValidatorTest.cs b/certmgrTest/SubjectValidatorTest.cs new file mode 100644 index 0000000..bfd3012 --- /dev/null +++ b/certmgrTest/SubjectValidatorTest.cs @@ -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); + } +} diff --git a/certmgrTest/certmgrTest.csproj b/certmgrTest/certmgrTest.csproj new file mode 100644 index 0000000..b957529 --- /dev/null +++ b/certmgrTest/certmgrTest.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + +