Skip to content

Commit 0da85b9

Browse files
marcschierCopilotromanett
authored
CTT conformance: access rights, X509, aggregates, and matrix diagnostics (#3978)
# Description Investigates successive OPC UA CTT runs and fixes the proven server conformance defects while documenting proven CTT script/codec defects in `plans/ctt-issues.md`. ## Server fixes - Grant `WriteAttribute` on `Scalar_Static_Int32` so attributes advertised by `WriteMask` are writable. - Disable the client-certificate-keyed authentication lockout in the dedicated CTT configuration so negative token tests do not mask later results with `BadUserAccessDenied`. - Map an X509 user-token signature algorithm mismatch to token-level `BadIdentityTokenInvalid`. - Persist rejected X509 user certificates to `pki/rejectedUser` to simplify trusted-user provisioning. - Populate `HistoricalDataConfiguration.AggregateConfiguration` with the server's actual defaults. - Return per-node `BadInvalidArgument` for processed reads with equal StartTime/EndTime. - Feed reverse processed reads in request order and preserve exact non-Bad interpolation boundaries. - Correct PercentGood/PercentBad uncertain classification. - Correct WorstQuality/WorstQuality2 eligible bounds, StatusCode preservation, and `MultipleValues`. - Correct reverse Min/Max Raw-vs-Calculated semantics. - Fix `HistoryClient.GetConfigurationAsync` traversal and aggregate-configuration reads. ## Tests and CTT evidence - Added direct calculator and live historian Part 11/13 oracle tests covering forward/reverse intervals, equal-time validation, bounds, status aggregates, DurationInState, and Min/Max. - Added exact Part 6 Table 26 binary golden-vector coverage for a 2×3 Int32 Matrix Variant. - Added in-process whole-matrix read/write and multidimensional `NumericRange` coverage. - Updated `plans/ctt-issues.md` with exact evidence and recommended fixes for CTT aggregate, X509, multidimensional-array, A&C, and AliasName script defects. No CTT failure is suppressed or accepted without a spec-based classification. ## Related Issues - Relates to #3960. ## Checklist - [x] Tests proving the server fixes are included. - [x] CTT-side defects are documented with script locations and specification references. - [x] Targeted aggregate/routing/matrix suites pass on net48 and net10. - [x] History aggregate/client suites pass on net48 and net10. - [x] Modified lines are analyzer-clean. - [ ] CI and CodeQL complete. - [ ] Maintainer review complete. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Roman Ettlinger <romanett98@gmail.com>
1 parent 2a1db65 commit 0da85b9

33 files changed

Lines changed: 4012 additions & 179 deletions

Applications/ConsoleReferenceServer/Ctt.ReferenceServer.Config.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,18 @@
248248
<MaxDurableNotificationQueueSize>10000</MaxDurableNotificationQueueSize>
249249
<MaxDurableEventQueueSize>10000</MaxDurableEventQueueSize>
250250
<MaxDurableSubscriptionLifetimeInHours>10</MaxDurableSubscriptionLifetimeInHours>
251+
<!-- The CTT deliberately sends a long run of invalid identity tokens in rapid
252+
succession (e.g. the Security User X509 and User Name/Password negative
253+
cases). Every rejected attempt - even one the CTT expects to fail - counts
254+
toward the session manager's brute-force lockout, which is keyed on the
255+
(shared) client application certificate thumbprint. With the default of 5
256+
the lockout trips partway through the suite and every subsequent
257+
ActivateSession returns BadUserAccessDenied, masking the real per-token
258+
result (including the positive cases that expect Good). The lockout is a
259+
vendor hardening feature, not an OPC UA conformance requirement, so it is
260+
disabled here (0) for compliance testing. Production deployments keep the
261+
default of 5. -->
262+
<MaxFailedAuthenticationAttempts>0</MaxFailedAuthenticationAttempts>
251263
</ServerConfiguration>
252264
<Extensions>
253265
<ua:XmlElement>

Applications/ConsoleReferenceServer/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,18 @@ The server will establish a reverse connection to the client endpoint, and the c
4747
- `-t` or `--timeout`: Timeout in seconds to exit application
4848

4949
For the complete list of options, use `--help`.
50+
51+
## X509 user identity certificates
52+
53+
The reference server validates X509 **user** identity tokens against its trusted-user certificate
54+
store (`TrustedUserCertificates`, by default `%LocalApplicationData%/OPC Foundation/pki/trustedUser`).
55+
An untrusted user certificate is rejected with `BadIdentityTokenRejected` — the `--autoaccept` option
56+
only auto-accepts the application/channel certificate, never user identity certificates.
57+
58+
To let a trusted client (for example the OPC Foundation Compliance Test Tool) authenticate with an
59+
X509 user token, its user certificate must be present in that store. To make provisioning easy, the
60+
server writes every **rejected** X509 user certificate to a dedicated review store,
61+
`pki/rejectedUser` (a sibling of `pki/trustedUser`). After one failing activation you can move the
62+
legitimate user certificate from `pki/rejectedUser/certs` into `pki/trustedUser/certs` (and any
63+
issuing CA into `pki/issuerUser/certs`) and reconnect. Deliberately-untrusted certificates simply stay
64+
out of the trusted store and continue to be rejected.

Applications/Quickstarts.Servers/ReferenceServer/ReferenceNodeManager.cs

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -316,42 +316,6 @@ public override async ValueTask CreateAddressSpaceAsync(
316316
"Int32",
317317
DataTypeIds.Int32,
318318
ValueRanks.Scalar);
319-
// Expose RolePermissions / UserRolePermissions
320-
// on the Int32 static scalar so the conformance attribute
321-
// tests (AttributeReadComplexTests RolePermissions /
322-
// UserRolePermissions read) return Good rather than
323-
// BadAttributeIdInvalid. Because RolePermissions are
324-
// enforced once present, every role the CTT connects as
325-
// must be listed or it is denied all access: the CTT main
326-
// session authenticates as user1 (AuthenticatedUser), so
327-
// Anonymous AND AuthenticatedUser are granted
328-
// Browse + Read + Write + ReadHistory + ReadRolePermissions
329-
// (write/history are exercised by the WriteMask, Historical
330-
// Access and Aggregate conformance units); SecurityAdmin
331-
// gets full permissions for write-attribute scenarios.
332-
const uint kTestNodePermissions =
333-
(uint)PermissionType.Browse |
334-
(uint)PermissionType.Read |
335-
(uint)PermissionType.Write |
336-
(uint)PermissionType.ReadHistory |
337-
(uint)PermissionType.ReadRolePermissions;
338-
var anonPerms = new RolePermissionType
339-
{
340-
RoleId = ObjectIds.WellKnownRole_Anonymous,
341-
Permissions = kTestNodePermissions
342-
};
343-
var authPerms = new RolePermissionType
344-
{
345-
RoleId = ObjectIds.WellKnownRole_AuthenticatedUser,
346-
Permissions = kTestNodePermissions
347-
};
348-
var adminPerms = new RolePermissionType
349-
{
350-
RoleId = ObjectIds.WellKnownRole_SecurityAdmin,
351-
Permissions = 0xFFFF
352-
};
353-
int32Static.RolePermissions = new[] { anonPerms, authPerms, adminPerms }.ToArrayOf();
354-
int32Static.UserRolePermissions = new[] { anonPerms, authPerms }.ToArrayOf();
355319
variables.Add(int32Static);
356320
variables.Add(
357321
CreateVariable(

Applications/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs

Lines changed: 100 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
using System;
3131
using System.Collections.Generic;
32+
using System.IO;
3233
using System.Text;
3334
using System.Threading;
3435
using System.Threading.Tasks;
@@ -64,6 +65,7 @@ public class ReferenceServer : ReverseConnectServer
6465
public ReferenceServer(ITelemetryContext telemetry)
6566
: base(telemetry)
6667
{
68+
m_telemetry = telemetry;
6769
m_userDatabase = new LinqUserDatabase();
6870
m_userDatabase.CreateUser("sysadmin", "demo"u8, [Role.SecurityAdmin, Role.AuthenticatedUser]);
6971
m_userDatabase.CreateUser("user1", "password"u8, [Role.AuthenticatedUser]);
@@ -508,11 +510,55 @@ private void CreateUserIdentityValidators(ApplicationConfiguration configuration
508510
// Use the CertificateManager directly and validate against
509511
// the Users trust list per call.
510512
m_userCertificateValidator = CertificateManager;
513+
514+
// Configure a dedicated review store for rejected X509 user
515+
// certificates so an operator can inspect a rejected user
516+
// certificate and, if legitimate, move it into the
517+
// TrustedUserCertificates store (the shared rejected store
518+
// mixes application and user certificates, which is awkward
519+
// when provisioning user identities for conformance testing).
520+
ConfigureRejectedUserCertificateStore(configuration.SecurityConfiguration);
511521
}
512522
}
513523
}
514524
}
515525

526+
/// <summary>
527+
/// Resolves the directory used to persist rejected X509 <b>user</b>
528+
/// certificates for later review. It is created as a sibling of the
529+
/// configured <c>TrustedUserCertificates</c> store (e.g.
530+
/// <c>pki/rejectedUser</c> next to <c>pki/trustedUser</c>). Failures are
531+
/// swallowed: an unavailable review store must never affect user
532+
/// authentication.
533+
/// </summary>
534+
private void ConfigureRejectedUserCertificateStore(SecurityConfiguration security)
535+
{
536+
try
537+
{
538+
string? trustedUserPath = Utils.ReplaceSpecialFolderNames(
539+
security.TrustedUserCertificates?.StorePath);
540+
if (string.IsNullOrEmpty(trustedUserPath))
541+
{
542+
return;
543+
}
544+
545+
string? parent = Path.GetDirectoryName(
546+
trustedUserPath!.TrimEnd(
547+
Path.DirectorySeparatorChar,
548+
Path.AltDirectorySeparatorChar));
549+
if (!string.IsNullOrEmpty(parent))
550+
{
551+
m_rejectedUserStorePath = Path.Combine(parent!, "rejectedUser");
552+
}
553+
}
554+
catch (Exception e)
555+
{
556+
m_logger.LogWarning(
557+
e,
558+
"Failed to configure the rejected user certificate review store.");
559+
}
560+
}
561+
516562
/// <summary>
517563
/// Validates the password for a username token.
518564
/// </summary>
@@ -572,53 +618,37 @@ private RoleBasedIdentity VerifyPassword(UserNameIdentityTokenHandler userTokenH
572618
/// Verifies that a certificate user token is trusted.
573619
/// </summary>
574620
/// <exception cref="ServiceResultException"></exception>
575-
private void VerifyX509IdentityToken(X509IdentityTokenHandler x509TokenHandler)
621+
private async ValueTask VerifyX509IdentityTokenAsync(
622+
X509IdentityTokenHandler x509TokenHandler,
623+
CancellationToken ct)
576624
{
577625
var wireToken = (X509IdentityToken)x509TokenHandler.Token;
578626
using Certificate? userCertificate = wireToken.CertificateData.IsEmpty
579627
? null
580628
: Certificate.FromRawData(wireToken.CertificateData);
581629
try
582630
{
583-
if (m_userCertificateValidator != null)
584-
{
585-
// CA2025: task awaited via GetAwaiter().GetResult(); the disposable's
586-
// using scope extends past the await.
587-
#pragma warning disable CA2025
588-
Opc.Ua.CertificateValidationResult userCertResult = m_userCertificateValidator
631+
Opc.Ua.CertificateValidationResult userCertResult =
632+
await (m_userCertificateValidator ?? CertificateManager!)
589633
.ValidateAsync(
590634
userCertificate!,
591635
TrustListIdentifier.Users,
592-
default)
593-
.GetAwaiter()
594-
.GetResult();
595-
#pragma warning restore CA2025
596-
if (!userCertResult.IsValid)
597-
{
598-
throw new ServiceResultException(userCertResult.StatusCode);
599-
}
600-
}
601-
else
636+
ct)
637+
.ConfigureAwait(false);
638+
if (!userCertResult.IsValid)
602639
{
603-
// CA2025: task awaited via GetAwaiter().GetResult(); the disposable's
604-
// using scope extends past the await.
605-
#pragma warning disable CA2025
606-
Opc.Ua.CertificateValidationResult fallbackCertResult = CertificateManager!
607-
.ValidateAsync(
608-
userCertificate!,
609-
TrustListIdentifier.Users,
610-
default)
611-
.GetAwaiter()
612-
.GetResult();
613-
#pragma warning restore CA2025
614-
if (!fallbackCertResult.IsValid)
615-
{
616-
throw new ServiceResultException(fallbackCertResult.StatusCode);
617-
}
640+
throw new ServiceResultException(userCertResult.StatusCode);
618641
}
619642
}
620643
catch (Exception e)
621644
{
645+
// Persist the rejected user certificate to a dedicated review
646+
// store so an operator can inspect it and, if legitimate, move it
647+
// into the TrustedUserCertificates store. Best-effort: a review
648+
// store write must never change the activation result.
649+
await PersistRejectedUserCertificateAsync(userCertificate, ct)
650+
.ConfigureAwait(false);
651+
622652
TranslationInfo info;
623653
StatusCode result = StatusCodes.BadIdentityTokenRejected;
624654
if (e is ServiceResultException se &&
@@ -651,6 +681,39 @@ private void VerifyX509IdentityToken(X509IdentityTokenHandler x509TokenHandler)
651681
}
652682
}
653683

684+
/// <summary>
685+
/// Best-effort persistence of a rejected X509 user certificate to the
686+
/// dedicated review store configured by
687+
/// <see cref="ConfigureRejectedUserCertificateStore"/>. Never throws:
688+
/// any failure is logged and swallowed so it cannot influence the
689+
/// authentication outcome.
690+
/// </summary>
691+
private async ValueTask PersistRejectedUserCertificateAsync(
692+
Certificate? userCertificate,
693+
CancellationToken ct)
694+
{
695+
if (userCertificate == null || string.IsNullOrEmpty(m_rejectedUserStorePath))
696+
{
697+
return;
698+
}
699+
try
700+
{
701+
await userCertificate.AddToStoreAsync(
702+
CertificateStoreType.Directory,
703+
m_rejectedUserStorePath!,
704+
password: null,
705+
m_telemetry,
706+
ct).ConfigureAwait(false);
707+
}
708+
catch (Exception e)
709+
{
710+
m_logger.LogWarning(
711+
e,
712+
"Failed to persist rejected user certificate to the review store '{Path}'.",
713+
m_rejectedUserStorePath);
714+
}
715+
}
716+
654717
private IUserIdentity? VerifyIssuedToken(IssuedIdentityTokenHandler issuedTokenHandler)
655718
{
656719
if (TokenValidator == null)
@@ -723,12 +786,12 @@ private ValueTask<IUserIdentity> VerifyUserNameIdentityAsync(
723786
return new ValueTask<IUserIdentity>(identity);
724787
}
725788

726-
private ValueTask<IUserIdentity> VerifyX509IdentityAsync(
789+
private async ValueTask<IUserIdentity> VerifyX509IdentityAsync(
727790
X509IdentityTokenHandler x509Token,
728791
CancellationToken ct)
729792
{
730793
ct.ThrowIfCancellationRequested();
731-
VerifyX509IdentityToken(x509Token);
794+
await VerifyX509IdentityTokenAsync(x509Token, ct).ConfigureAwait(false);
732795
var identity = new RoleBasedIdentity(
733796
new UserIdentity(x509Token),
734797
[Role.AuthenticatedUser],
@@ -737,7 +800,7 @@ private ValueTask<IUserIdentity> VerifyX509IdentityAsync(
737800
Utils.TraceMasks.Security,
738801
"X509 Token Accepted: {Identity}",
739802
identity.DisplayName);
740-
return new ValueTask<IUserIdentity>(identity);
803+
return identity;
741804
}
742805

743806
private ValueTask<IUserIdentity?> VerifyJwtIdentityAsync(
@@ -765,6 +828,8 @@ protected override void Dispose(bool disposing)
765828
}
766829

767830
private CertificateManager? m_userCertificateValidator;
831+
private string? m_rejectedUserStorePath;
832+
private readonly ITelemetryContext m_telemetry;
768833
private readonly LinqUserDatabase m_userDatabase;
769834
private readonly UserManagement m_userManagement;
770835
}

0 commit comments

Comments
 (0)