/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Globalization;
using System.Text;
namespace Opc.Ua.Server
{
///
/// Validates and normalises instances
/// per OPC UA Part 18 §4.4.3.
///
internal static class IdentityRuleValidator
{
///
/// Validates a rule. Returns Good if the rule is acceptable,
/// Bad_InvalidArgument with a diagnostic message otherwise.
///
public static ServiceResult Validate(IdentityMappingRuleType rule)
{
if (rule == null)
{
return new ServiceResult(StatusCodes.BadInvalidArgument,
new LocalizedText("Rule is null."));
}
string criteria = rule.Criteria ?? string.Empty;
switch (rule.CriteriaType)
{
case IdentityCriteriaType.Anonymous:
case IdentityCriteriaType.AuthenticatedUser:
case IdentityCriteriaType.TrustedApplication:
if (criteria.Length > 0)
{
return new ServiceResult(StatusCodes.BadInvalidArgument,
new LocalizedText($"Criteria must be empty for {rule.CriteriaType}."));
}
return ServiceResult.Good;
case IdentityCriteriaType.UserName:
case IdentityCriteriaType.Role:
case IdentityCriteriaType.GroupId:
case IdentityCriteriaType.Application:
if (criteria.Length == 0)
{
return new ServiceResult(StatusCodes.BadInvalidArgument,
new LocalizedText($"Criteria must be non-empty for {rule.CriteriaType}."));
}
return ServiceResult.Good;
case IdentityCriteriaType.Thumbprint:
if (!IsValidThumbprint(criteria))
{
return new ServiceResult(StatusCodes.BadInvalidArgument,
new LocalizedText(
"Thumbprint must be an upper-case hexadecimal string with no spaces."));
}
return ServiceResult.Good;
case IdentityCriteriaType.X509Subject:
if (!IsValidX509Subject(criteria))
{
return new ServiceResult(StatusCodes.BadInvalidArgument,
new LocalizedText(
"X509 subject must match the format Name=\"Value\"/Name=\"Value\"... per Part 18 §4.4.3."));
}
return ServiceResult.Good;
default:
return new ServiceResult(StatusCodes.BadInvalidArgument,
new LocalizedText($"Unknown criteriaType {rule.CriteriaType}."));
}
}
///
/// Returns true if two rules are equivalent for the purposes of
/// duplicate-detection (Part 18 §4.4.5 mandates Bad_AlreadyExists
/// when an equivalent rule already exists).
///
public static bool AreEquivalent(IdentityMappingRuleType a, IdentityMappingRuleType b)
{
if (a.CriteriaType != b.CriteriaType)
{
return false;
}
string criteriaA = a.Criteria ?? string.Empty;
string criteriaB = b.Criteria ?? string.Empty;
StringComparison comparison = a.CriteriaType == IdentityCriteriaType.Thumbprint
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
return string.Equals(criteriaA, criteriaB, comparison);
}
private static bool IsValidThumbprint(string criteria)
{
if (criteria.Length == 0 || criteria.Length % 2 != 0)
{
return false;
}
foreach (char c in criteria)
{
if (c is >= '0' and <= '9')
{
continue;
}
if (c is >= 'A' and <= 'F')
{
continue;
}
return false;
}
return true;
}
private static bool IsValidX509Subject(string criteria)
{
// Grammar: /Name="Value"(/Name="Value")*
// Names: CN, O, OU, DC, L, S, C, dnQualifier, serialNumber.
if (criteria.Length == 0)
{
return false;
}
int index = 0;
while (index < criteria.Length)
{
// Read name.
int nameStart = index;
while (index < criteria.Length && criteria[index] != '=')
{
index++;
}
if (index == nameStart || index >= criteria.Length)
{
return false;
}
string name = criteria[nameStart..index];
if (!IsKnownSubjectName(name))
{
return false;
}
index++; // skip '='
if (index >= criteria.Length || criteria[index] != '"')
{
return false;
}
index++; // skip opening quote
while (index < criteria.Length && criteria[index] != '"')
{
index++;
}
if (index >= criteria.Length)
{
return false;
}
index++; // skip closing quote
if (index < criteria.Length)
{
if (criteria[index] != '/')
{
return false;
}
index++; // skip separator
if (index >= criteria.Length)
{
return false;
}
}
}
return true;
}
private static bool IsKnownSubjectName(string name)
{
return name switch
{
"CN" or "O" or "OU" or "DC" or "L" or "S" or "C"
or "dnQualifier" or "serialNumber" => true,
_ => false
};
}
///
/// Normalises an X.509 subject name read from a certificate into the
/// canonical Part 18 §4.4.3 form used by the
/// criteria, so comparisons against rule criteria are deterministic.
/// Unknown names are skipped per spec.
///
public static string NormaliseX509Subject(string? subject)
{
if (string.IsNullOrEmpty(subject))
{
return string.Empty;
}
// The .NET X509 subject is comma-separated "Name=Value" pairs in
// reverse order of the certificate; first split into segments.
var pairs = new System.Collections.Generic.List<(string Name, string Value)>();
int i = 0;
while (i < subject!.Length)
{
// Skip whitespace and separators.
while (i < subject.Length && (subject[i] == ',' || subject[i] == ' '))
{
i++;
}
int nameStart = i;
while (i < subject.Length && subject[i] != '=')
{
i++;
}
if (i >= subject.Length)
{
break;
}
string name = subject[nameStart..i].Trim();
i++; // skip '='
string value;
if (i < subject.Length && subject[i] == '"')
{
i++; // skip opening quote
int vs = i;
while (i < subject.Length && subject[i] != '"')
{
i++;
}
value = subject[vs..i];
if (i < subject.Length)
{
i++; // skip closing quote
}
}
else
{
int vs = i;
while (i < subject.Length && subject[i] != ',')
{
i++;
}
value = subject[vs..i].Trim();
}
if (name.Length > 0 && IsKnownSubjectName(name))
{
pairs.Add((name, value));
}
}
// Spec order: CN, O, OU, DC, L, S, C, dnQualifier, serialNumber.
// For names appearing multiple times preserve relative order of
// appearance within the certificate.
string[] order =
[
"CN", "O", "OU", "DC", "L", "S", "C", "dnQualifier", "serialNumber"
];
var sb = new StringBuilder();
foreach (string name in order)
{
foreach ((string Name, string Value) in pairs)
{
if (!string.Equals(Name, name, StringComparison.Ordinal))
{
continue;
}
if (sb.Length > 0)
{
sb.Append('/');
}
sb.Append(Name)
.Append('=')
.Append('"')
.Append(Value)
.Append('"');
}
}
return sb.ToString();
}
///
/// Returns an upper-case hex thumbprint with no separators.
///
public static string NormaliseThumbprint(string? thumbprint)
{
if (string.IsNullOrEmpty(thumbprint))
{
return string.Empty;
}
return thumbprint!.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpper(CultureInfo.InvariantCulture);
}
}
}