Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Stack/Opc.Ua.Core/Security/Certificates/CertificateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,11 @@ private static void SetSuitableDefaults(
subjectName = Utils.Format("CN={0}", subjectName);
}

if (!subjectName.Contains("O=", StringComparison.Ordinal))
{
subjectName += Utils.Format(", O={0}", "OPC Foundation");
}

if (domainNames != null && domainNames.Count > 0)
{
if (!subjectName.Contains("DC=", StringComparison.Ordinal) &&
Expand Down
125 changes: 116 additions & 9 deletions Stack/Opc.Ua.Core/Security/Certificates/CertificateIdentifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
Expand Down Expand Up @@ -364,6 +365,62 @@ private static string GetDisplayName(X509Certificate2 certificate)
return buffer.ToString();
}

/// <summary>
/// Picks the best certificate from the collection.
/// Ignores not-yet-valid certificates (NotBefore > now) as they have never been used.
/// Selection criteria in order of priority:
/// 1. Valid certificates preferred over expired certificates
/// 2. CA-signed certificates preferred over self-signed (within same validity status)
/// 3. Longest remaining validity if valid, or least expired if all expired
/// </summary>
/// <param name="collection">The collection of certificates to evaluate.</param>
/// <returns>The best matching certificate, or null if the collection is empty or all are not-yet-valid.</returns>
static X509Certificate2 PickBestCertificate(X509Certificate2Collection collection)
{
if (collection == null || collection.Count == 0)
{
return null;
}

X509Certificate2 bestMatch = null;
TimeSpan bestRemainingValidity = TimeSpan.MinValue;
bool bestIsCASigned = false;
bool bestIsValid = false;

DateTime now = DateTime.UtcNow;

foreach (X509Certificate2 certificate in collection)
{
// Skip not-yet-valid certificates (they have never been used)
if (certificate.NotBefore > now)
{
continue;
}

TimeSpan remainingValidity = certificate.NotAfter - now;
bool isCASigned = !X509Utils.IsSelfSigned(certificate);
bool isValid = certificate.NotAfter >= now; // NotBefore already checked above

// Prioritize:
// 1. Valid over expired
// 2. CA-signed over self-signed (within same validity status)
// 3. Longest remaining validity (or least expired if all expired)
bool isBetter = (isValid && !bestIsValid) ||
(isValid == bestIsValid && isCASigned && !bestIsCASigned) ||
(isValid == bestIsValid && isCASigned == bestIsCASigned && remainingValidity > bestRemainingValidity);

if (isBetter)
{
bestMatch = certificate;
bestRemainingValidity = remainingValidity;
bestIsCASigned = isCASigned;
bestIsValid = isValid;
}
}

return bestMatch;
}

/// <summary>
/// Finds a certificate in the specified collection.
/// </summary>
Expand Down Expand Up @@ -406,31 +463,77 @@ public static X509Certificate2 Find(

return null;
}

X509Certificate2Collection matchesOnCriteria = null;

// find by subject name.
if (!string.IsNullOrEmpty(subjectName))
{
List<string> subjectName2 = X509Utils.ParseDistinguishedName(subjectName);
List<string> parsedSubjectName = X509Utils.ParseDistinguishedName(subjectName);

foreach (X509Certificate2 certificate in collection)
{
if (ValidateCertificateType(certificate, certificateType) &&
X509Utils.CompareDistinguishedName(certificate, subjectName2))
X509Utils.CompareDistinguishedName(certificate, parsedSubjectName))
{
if (!needPrivateKey || certificate.HasPrivateKey)
{
return certificate;
(matchesOnCriteria ??= new X509Certificate2Collection()).Add(certificate);
}
}
}
if (matchesOnCriteria?.Count > 0)
{
return PickBestCertificate(matchesOnCriteria);
}

collection = collection.Find(X509FindType.FindBySubjectName, subjectName, false);
bool hasCommonName = subjectName.IndexOf("CN=", StringComparison.OrdinalIgnoreCase) >= 0;

foreach (X509Certificate2 certificate in collection)
if (hasCommonName)
{
if (ValidateCertificateType(certificate, certificateType) &&
(!needPrivateKey || certificate.HasPrivateKey))
string commonNameEntry = parsedSubjectName
.FirstOrDefault(s => s.StartsWith("CN=", StringComparison.OrdinalIgnoreCase));
string commonName = commonNameEntry?.Length > 3
? commonNameEntry.Substring(3).Trim()
: null;

if (!string.IsNullOrEmpty(commonName))
{
foreach (X509Certificate2 certificate in collection)
{
if (ValidateCertificateType(certificate, certificateType) &&
(!needPrivateKey || certificate.HasPrivateKey) &&
string.Equals(
certificate.GetNameInfo(X509NameType.SimpleName, false),
commonName,
StringComparison.Ordinal))
{
(matchesOnCriteria ??= new X509Certificate2Collection()).Add(certificate);
}
}
if (matchesOnCriteria?.Count > 0)
{
return PickBestCertificate(matchesOnCriteria);
}
}
}
else
{
X509Certificate2Collection fuzzyMatches = collection.Find(
X509FindType.FindBySubjectName,
subjectName,
false);
foreach (X509Certificate2 certificate in fuzzyMatches)
{
return certificate;
if (ValidateCertificateType(certificate, certificateType) &&
(!needPrivateKey || certificate.HasPrivateKey))
{
(matchesOnCriteria ??= new X509Certificate2Collection()).Add(certificate);
}
}
if (matchesOnCriteria?.Count > 0)
{
return PickBestCertificate(matchesOnCriteria);
}
}
}
Expand All @@ -444,9 +547,13 @@ public static X509Certificate2 Find(
ValidateCertificateType(certificate, certificateType) &&
(!needPrivateKey || certificate.HasPrivateKey))
{
return certificate;
(matchesOnCriteria ??= new X509Certificate2Collection()).Add(certificate);
}
}
if (matchesOnCriteria?.Count > 0)
{
return PickBestCertificate(matchesOnCriteria);
}
}

// certificate not found.
Expand Down
64 changes: 64 additions & 0 deletions Tests/Opc.Ua.Configuration.Tests/ApplicationInstanceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,70 @@ await applicationInstance
Assert.IsTrue(storedCertificates.Contains(cert));
}

/// <summary>
/// This tests that instantiating two application instances the second with a SubjectName being a substring of the first one's CN,
/// succeeds without throwing exceptions.
/// </summary>
/// <returns></returns>
[Test]
public async Task TestAddTwoAppCertificatesToTrustedStoreAsync()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();

var subjectName = SubjectName;
//Arrange Application Instance
var applicationInstance = new ApplicationInstance(telemetry) { ApplicationName = ApplicationName };
ApplicationConfiguration configuration = await applicationInstance
.Build(ApplicationUri, ProductUri)
.AsClient()
.AddSecurityConfigurationStores(subjectName,
$"{m_pkiRoot}/pki/own",
$"{m_pkiRoot}/pki/trusted",
$"{m_pkiRoot}/pki/issuer",
$"{m_pkiRoot}/pki/rejected")
.CreateAsync()
.ConfigureAwait(false);

Assert.DoesNotThrowAsync(async () => await applicationInstance.CheckApplicationInstanceCertificatesAsync(true).ConfigureAwait(false));

subjectName = "UA";// UA is a substring of the previous certificate SubjectName CN
var applicationInstance2 = new ApplicationInstance(telemetry) { ApplicationName = ApplicationName };
ApplicationConfiguration configuration2 = await applicationInstance2
.Build(ApplicationUri + "2", ProductUri + "2")
.AsClient()
.AddSecurityConfigurationStores(subjectName,
$"{m_pkiRoot}/pki/own",
$"{m_pkiRoot}/pki/trusted",
$"{m_pkiRoot}/pki/issuer",
$"{m_pkiRoot}/pki/rejected")
.CreateAsync()
.ConfigureAwait(false);

// Since the SubjectName is a substring of the first one's CN,
// the matching algorithm will find the first certificate because a fuzzy match is done on the SubjectName when SubjectName does not contain CN=.
// However, since the ApplicationUri is different, the certificate will be considered invalid
ServiceResultException exception = NUnit.Framework.Assert
.ThrowsAsync<ServiceResultException>(async () =>
await applicationInstance2.CheckApplicationInstanceCertificatesAsync(true)
.ConfigureAwait(false));
Assert.AreEqual(StatusCodes.BadConfigurationError, exception.StatusCode);
subjectName = "CN=UA";// UA is a substring of the previous certificate SubjectName CN
var applicationInstance3 = new ApplicationInstance(telemetry) { ApplicationName = ApplicationName };
var configuration3 = await applicationInstance3
.Build(ApplicationUri + "3", ProductUri + "3")
.AsClient()
.AddSecurityConfigurationStores(subjectName,
$"{m_pkiRoot}/pki/own",
$"{m_pkiRoot}/pki/trusted",
$"{m_pkiRoot}/pki/issuer",
$"{m_pkiRoot}/pki/rejected")
.CreateAsync()
.ConfigureAwait(false);

// Since the SubjectName contains CN=UA, the matching algorithm will not do a fuzzy match and will not find the first certificate.
Assert.DoesNotThrowAsync(async () => await applicationInstance3.CheckApplicationInstanceCertificatesAsync(true).ConfigureAwait(false));
}

/// <summary>
/// Test to verify that a new cert is not recreated/replaced if DisableCertificateAutoCreation is set.
/// </summary>
Expand Down
Loading
Loading