From 34197a71fe3feb9aadecfe4d7f628d96e21d5488 Mon Sep 17 00:00:00 2001 From: Roman Ettlinger Date: Fri, 18 Sep 2026 11:41:26 +0200 Subject: [PATCH] Update to SDK 2.0.334.14261-preview Bump the OPC UA .NET Standard preview pin from 2.0.312.3680-preview to 2.0.334.14261-preview, the newest package set published from the SDK's master branch. ComplexTypeSystem is now IDisposable, so the three type system loaders dispose it; disposing only releases the resolver it owns, the loaded types stay registered on the session. The last use of the obsolete Variant.Value goes to AsBoxedObject(). SDK #4477 made Thumbprint and X509Subject identity criteria match the user certificate of an X509IdentityToken, as Part 18 specifies, instead of the client application certificate. The RoleManagement workstation relied on the old reading, so it now signs in with its own application certificate as an X509 user token: the server offers a Certificate user token policy, user certificate trust stores and the stack's X509Authenticator; the client offers a "Workstation certificate" sign in built by the new SampleIdentities helper; the tests connect the same way and also prove an anonymous Session with the workstation certificate on its channel earns nothing. Co-Authored-By: Claude Opus 5 --- Samples/Client.Common/README.md | 4 + Samples/Client.Common/SampleConnection.cs | 2 +- Samples/Client.Common/SampleIdentities.cs | 81 +++++++++++++++++++ .../Controls.Net4/Sessions/SessionOpenDlg.cs | 2 +- .../RoleManagementClientModelTests.cs | 2 +- .../RoleManagementClientTests.cs | 40 ++++++--- .../RoleManagementNodeManagerTests.cs | 57 +++++++------ Tests/Samples.Tests.Common/TestClient.cs | 55 ++++++++----- .../Client/Model/DataTypesClientModel.cs | 2 +- .../Client/Model/AuditEventStream.cs | 2 +- Workshop/RoleManagement/Client/MainForm.cs | 26 ++++-- .../Client/Model/RoleManagementClientModel.cs | 53 +++++++++--- Workshop/RoleManagement/README.md | 45 +++++++---- .../RoleManagement/Server/ModelDesign.xml | 2 +- ...uickstarts.RoleManagementServer.Config.xml | 25 ++++++ .../Server/RoleManagementNodeManager.cs | 4 +- .../Server/RoleManagementServerHosting.cs | 12 ++- Workshop/RoleManagement/Server/SampleUsers.cs | 23 +++--- .../Server/WorkstationEndpoints.cs | 6 +- targets.props | 2 +- 20 files changed, 333 insertions(+), 112 deletions(-) create mode 100644 Samples/Client.Common/SampleIdentities.cs diff --git a/Samples/Client.Common/README.md b/Samples/Client.Common/README.md index a5167119a..87ba98ef4 100644 --- a/Samples/Client.Common/README.md +++ b/Samples/Client.Common/README.md @@ -138,6 +138,10 @@ The Boiler client is the reference implementation; the Empty client is the templ answered. - `SampleSessionFactory`: opens the managed session the connect control opens, for callers without a window (the model tests). +- `SampleIdentities`: an X.509 user identity made from the client's own application + certificate, for a client which earns a Part 18 Role with a `Thumbprint` or `X509Subject` + rule. Those rules match the user certificate of a Session, never the certificate of its + secure channel. - `SampleConnection`: the connection of a sample client without the tool bar - discovery, the session, the reconnect it reports, the bounded close, the complex type load. `ConnectServerCtrl` is now the window half of it: two input fields, a status strip and diff --git a/Samples/Client.Common/SampleConnection.cs b/Samples/Client.Common/SampleConnection.cs index dbfb06f0b..2dac2bd71 100644 --- a/Samples/Client.Common/SampleConnection.cs +++ b/Samples/Client.Common/SampleConnection.cs @@ -388,7 +388,7 @@ private async Task OpenAsync( { ReportStatus(false, DateTime.Now, "Connected, loading complex type system."); - var typeSystem = ComplexTypeSystemClientExtensions.Create(m_session, m_telemetry); + using var typeSystem = ComplexTypeSystemClientExtensions.Create(m_session, m_telemetry); await typeSystem.LoadAsync(ct: ct).ConfigureAwait(false); } diff --git a/Samples/Client.Common/SampleIdentities.cs b/Samples/Client.Common/SampleIdentities.cs new file mode 100644 index 000000000..4da3dbea8 --- /dev/null +++ b/Samples/Client.Common/SampleIdentities.cs @@ -0,0 +1,81 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.Samples.Client +{ + /// + /// The user identities a sample client builds from its own configuration. + /// + public static class SampleIdentities + { + /// + /// An X.509 user identity made from the application instance certificate of the + /// client itself. + /// + /// + /// + /// OPC UA Part 18 4.4.3 matches the Thumbprint and X509Subject identity criteria + /// against the certificate of the user, which only reaches the server in an + /// X509IdentityToken. A Session opened with an anonymous or a user name token has no + /// user certificate at all, however good the certificate of its secure channel is. + /// So a client which is to earn a Role for the machine it runs on - a maintenance + /// workstation, say - signs in with the certificate that machine already holds. + /// + /// + /// The server still has to trust the certificate as a user certificate: that is a + /// trust list of its own, separate from the one it trusts application + /// certificates in. + /// + /// + /// The RSA certificate is used because a user token policy which names no security + /// policy of its own is signed with the one of the endpoint, and every sample server + /// offers an RSA endpoint. + /// + /// + /// The configuration of the client, which holds its certificate. + /// The cancellation token. + /// The client has no application certificate + /// with a private key. + public static async Task FromApplicationCertificateAsync( + ApplicationConfiguration configuration, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(configuration); + + SecurityConfiguration security = configuration.SecurityConfiguration; + + CertificateIdentifier certificateId = security.ApplicationCertificates.ToArray()? + .FirstOrDefault(id => + id.CertificateType == ObjectTypeIds.RsaSha256ApplicationCertificateType || + id.CertificateType.IsNull); + + if (certificateId == null) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The client has no RSA application certificate to sign in with."); + } + + // the identity loads the private key once, synchronously, while it is built; off + // the calling thread, because that is the UI thread of a window + return await Task.Run( + () => UserIdentity.CreateAsync( + certificateId, + security.CertificatePasswordProvider ?? new CertificatePasswordProvider(), + configuration.CertificateManager.CertificateProvider, + ct), + ct).ConfigureAwait(false); + } + } +} diff --git a/Samples/Controls.Net4/Sessions/SessionOpenDlg.cs b/Samples/Controls.Net4/Sessions/SessionOpenDlg.cs index ad85098da..e6e98f53e 100644 --- a/Samples/Controls.Net4/Sessions/SessionOpenDlg.cs +++ b/Samples/Controls.Net4/Sessions/SessionOpenDlg.cs @@ -391,7 +391,7 @@ private async Task OpenAsync(string sessionName, IUserIdentity identity, bool ch m_preferredLocales?.ToArray(), ct); - var typeSystemLoader = ComplexTypeSystemClientExtensions.Create(session, m_telemetry); + using var typeSystemLoader = ComplexTypeSystemClientExtensions.Create(session, m_telemetry); _ = await typeSystemLoader.LoadAsync(ct: ct); OpenComplete(session); diff --git a/Tests/SampleClientModels.Tests/RoleManagementClientModelTests.cs b/Tests/SampleClientModels.Tests/RoleManagementClientModelTests.cs index e2c0433a4..2d730652b 100644 --- a/Tests/SampleClientModels.Tests/RoleManagementClientModelTests.cs +++ b/Tests/SampleClientModels.Tests/RoleManagementClientModelTests.cs @@ -513,7 +513,7 @@ private static double NumberOf(string value) private async Task SignInAsync(string account, bool encrypted, CancellationToken ct) { TestClient client = await ConnectAsync( - RoleManagementClientModel.IdentityFor(account), + await RoleManagementClientModel.IdentityForAsync(account, null, ct).ConfigureAwait(false), encrypted, $"{account}{(encrypted ? " encrypted" : string.Empty)}", ct).ConfigureAwait(false); diff --git a/Tests/SampleClients.Tests/RoleManagementClientTests.cs b/Tests/SampleClients.Tests/RoleManagementClientTests.cs index f1664c772..23cb1d4c7 100644 --- a/Tests/SampleClients.Tests/RoleManagementClientTests.cs +++ b/Tests/SampleClients.Tests/RoleManagementClientTests.cs @@ -62,6 +62,12 @@ public class RoleManagementClientTests /// private const string kAnonymous = "Anonymous"; + /// + /// The entry of the identity drop down which signs in with the certificate of the + /// client application. + /// + private const string kWorkstation = "Workstation certificate"; + /// /// The columns of the node list, as the sample orders them. /// @@ -158,8 +164,9 @@ await WinFormsHarness.GetConnectControl(form) /// The server maps the subject name of the application instance certificate the /// sample client creates for itself onto the ConfigureAdmin Role, restricted to its /// encrypted endpoints (Part 18 4.4.3 X509Subject and the 4.4.1 Endpoints filter). - /// So an anonymous Session from this client holds a Role which no account of - /// the sample can earn, and the service code is in its address space. + /// The criteria is matched against the user certificate of a Session, so the client + /// signs in with that certificate as the workstation, and then holds a Role which no + /// account of the sample can earn: the service code is in its address space. /// /// /// This is the fixture which holds the server's hard coded criteria to the @@ -172,7 +179,7 @@ await WinFormsHarness.GetConnectControl(form) public async Task TheCertificateOfThisClientEarnsTheServiceCode(CancellationToken ct) { await RunAsync(async (form, token) => { - await ConnectAsAsync(form, kAnonymous, useSecurity: true, token).ConfigureAwait(true); + await ConnectAsAsync(form, kWorkstation, useSecurity: true, token).ConfigureAwait(true); bool listed = await SampleFormDriver .PumpUntilAsync(() => RowOf(form, "ServiceCode") != null, s_actionTimeout, token) @@ -181,9 +188,9 @@ await RunAsync(async (form, token) => { Assert.That( listed, Is.True, - "An anonymous Session on an encrypted endpoint has to hold the ConfigureAdmin " + - "Role, because the server maps the subject of this client's certificate onto " + - "it. " + Seen(form)); + "A Session signed in with this client's certificate on an encrypted endpoint " + + "has to hold the ConfigureAdmin Role, because the server maps the subject of " + + "that certificate onto it. " + Seen(form)); Assert.That( ColumnOf(form, "ServiceCode", kStatusColumn), @@ -272,10 +279,23 @@ private static async Task ConnectAsAsync( ConnectServerCtrl connect = WinFormsHarness.GetConnectControl(form); - Assert.That( - connect.UserIdentity?.DisplayName, - Is.EqualTo(string.Equals(account, kAnonymous, StringComparison.Ordinal) ? null : account), - "Choosing an account did not reach the connect control."); + // the workstation certificate is loaded asynchronously, and its token is named + // after the certificate rather than after the entry of the drop down + bool workstation = string.Equals(account, kWorkstation, StringComparison.Ordinal); + + bool chosen = await SampleFormDriver + .PumpUntilAsync( + () => workstation + ? connect.UserIdentity?.TokenType == UserTokenType.Certificate + : string.Equals( + connect.UserIdentity?.DisplayName, + string.Equals(account, kAnonymous, StringComparison.Ordinal) ? null : account, + StringComparison.Ordinal), + s_actionTimeout, + ct) + .ConfigureAwait(true); + + Assert.That(chosen, Is.True, "Choosing an account did not reach the connect control."); ISession session = await connect .ConnectAsync(NullTelemetry.Instance, s_endpointUrl, useSecurity, 30_000, ct) diff --git a/Tests/SampleNodeManagers.Tests/RoleManagementNodeManagerTests.cs b/Tests/SampleNodeManagers.Tests/RoleManagementNodeManagerTests.cs index bb0920ca8..65c442c4a 100644 --- a/Tests/SampleNodeManagers.Tests/RoleManagementNodeManagerTests.cs +++ b/Tests/SampleNodeManagers.Tests/RoleManagementNodeManagerTests.cs @@ -650,23 +650,22 @@ await BrowseStatusAsync(secure, secureNoteId, ct).ConfigureAwait(false), } /// - /// A Role earned by the certificate of the client application, on one endpoint only. + /// A Role earned by the certificate a workstation signs in with, on one endpoint only. /// /// /// /// Two Part 18 features at once, because the sample configures them on the same Role /// and each one is the negative case of the other. The X509Subject identity criteria - /// of 4.4.3 matches the subject of the application instance certificate the client - /// sent in CreateSession - so the Role belongs to the software on that workstation - /// and an anonymous Session from it holds the Role. The Endpoints filter of 4.4.1 is - /// evaluated before any identity rule is, so the same certificate on the unsecured - /// endpoint earns nothing. + /// of 4.4.3 matches the subject of the user certificate of the Session - the one in its + /// X509IdentityToken - so the workstation signs in with its own application instance + /// certificate and holds the Role. The Endpoints filter of 4.4.1 is evaluated before + /// any identity rule is, so the same sign in on the unsecured endpoint earns nothing. /// /// - /// Note which certificate this is. The criteria is named after X.509 and Part 18 - /// allows reading it as a user certificate, but the stack matches it against the - /// client's application instance certificate, which is also the only one it - /// has on a Session opened with an anonymous or a user name token. + /// Note which certificate this is not: the application instance certificate of the + /// secure channel. An anonymous Session from the very same client holds no Role, + /// because it presents no user certificate. Up to SDK 2.0.312 the stack matched the + /// channel certificate instead, which the sample relied on. /// /// [Test] @@ -677,7 +676,7 @@ public async Task TheWorkstationCertificateEarnsARoleOnTheEncryptedEndpointOnly( await using TestClient workstation = await TestClient .ConnectWithCertificateAsync( - EndpointUrl, "maintenance workstation", null, WorkstationSubject, encrypted: true, ct) + EndpointUrl, "maintenance workstation", WorkstationSubject, encrypted: true, ct: ct) .ConfigureAwait(false); await TestContext.Out @@ -695,8 +694,8 @@ await TestContext.Out Assert.That( asWorkstation, Does.Contain("ServiceCode"), - "An anonymous Session whose client certificate matches the X509Subject rule of " + - "the ConfigureAdmin Role has to hold that Role. Compare the two subjects above: " + + "A Session signed in with a user certificate which matches the X509Subject rule " + + "of the ConfigureAdmin Role has to hold that Role. Compare the two subjects above: " + "the criteria is a normalised subject and has to match the certificate exactly."); NodeId serviceCodeId = await SessionOps @@ -712,9 +711,9 @@ await TestContext.Out Is.True, $"The ConfigureAdmin Role carries Write on the service code: {write}"); - // a different client on the same encrypted endpoint: right endpoint, wrong subject + // a different client signed in with its own certificate: right endpoint, wrong subject await using TestClient stranger = await TestClient - .ConnectEncryptedAsync(EndpointUrl, "another client", null, ct) + .ConnectWithCertificateAsync(EndpointUrl, "another client", null, encrypted: true, ct: ct) .ConfigureAwait(false); Assert.That( @@ -722,12 +721,24 @@ await SessionOps.BrowseNamesAsync(stranger.Session, machineId, ct).ConfigureAwai Does.Not.Contain("ServiceCode"), "A client whose certificate carries a different subject earns nothing."); - // the workstation certificate on the unsecured endpoint: right subject, wrong - // endpoint - and on an unsecured channel the server has no client certificate to - // match in the first place + // the workstation subject on the secure channel of an anonymous Session: the rule + // matches the user certificate, and an anonymous Session has none + await using TestClient anonymous = await TestClient + .ConnectWithCertificateAsync( + EndpointUrl, "anonymous workstation", WorkstationSubject, encrypted: true, signIn: false, ct: ct) + .ConfigureAwait(false); + + Assert.That( + await SessionOps.BrowseNamesAsync(anonymous.Session, machineId, ct).ConfigureAwait(false), + Does.Not.Contain("ServiceCode"), + "An anonymous Session presents no user certificate, so it cannot match an X509Subject rule."); + + // the workstation signed in on the unsecured endpoint: right certificate, wrong + // endpoint. The certificate token policy names its own security policy, so the + // sign in itself succeeds there and only the Endpoints filter refuses the Role. await using TestClient offEndpoint = await TestClient .ConnectWithCertificateAsync( - EndpointUrl, "workstation without encryption", null, WorkstationSubject, encrypted: false, ct) + EndpointUrl, "workstation without encryption", WorkstationSubject, encrypted: false, ct: ct) .ConfigureAwait(false); Assert.That( @@ -741,8 +752,8 @@ await SessionOps.BrowseNamesAsync(offEndpoint.Session, machineId, ct).ConfigureA /// /// /// The counterpart of the X509Subject rule the server configures at startup: both - /// clients below carry the same subject, and only the one whose thumbprint the - /// SecurityAdmin registered earns the Role. This also exercises the criteria on the + /// clients below sign in with a certificate of the same subject, and only the one + /// whose thumbprint the SecurityAdmin registered earns the Role. This also exercises the criteria on the /// write path, because the rule is added over OPC UA through the AddIdentity Method /// of the Role rather than in the configuration of the server. /// @@ -757,11 +768,11 @@ public async Task AThumbprintCriteriaGrantsTheRoleToOneCertificate(CancellationT .ConfigureAwait(false); await using TestClient registered = await TestClient - .ConnectEncryptedAsync(EndpointUrl, "the registered client", null, ct) + .ConnectWithCertificateAsync(EndpointUrl, "the registered client", null, encrypted: true, ct: ct) .ConfigureAwait(false); await using TestClient sibling = await TestClient - .ConnectEncryptedAsync(EndpointUrl, "a client with the same subject", null, ct) + .ConnectWithCertificateAsync(EndpointUrl, "a client with the same subject", null, encrypted: true, ct: ct) .ConfigureAwait(false); Assert.That( diff --git a/Tests/Samples.Tests.Common/TestClient.cs b/Tests/Samples.Tests.Common/TestClient.cs index 56f169d3f..0c582d167 100644 --- a/Tests/Samples.Tests.Common/TestClient.cs +++ b/Tests/Samples.Tests.Common/TestClient.cs @@ -59,8 +59,9 @@ private TestClient(ISession session, ApplicationInstance application, TemporaryP /// /// /// A server which maps OPC UA Part 18 X509Subject or Thumbprint identity criteria - /// matches them against this certificate - the one the client sends in CreateSession - - /// so a test which asserts such a mapping has to know what it sent. + /// matches them against the user certificate of a Session, and a client opened by + /// ConnectWithCertificateAsync signs in with this one, so a test which asserts such a + /// mapping has to know what it sent. /// public string ApplicationCertificateSubject { get; private set; } @@ -126,7 +127,7 @@ public static async Task ConnectAsync( CancellationToken ct = default) { return await ConnectCoreAsync( - endpointUrl, sessionName, identity, EndpointChoice.Any, null, ct) + endpointUrl, sessionName, identity, EndpointChoice.Any, null, false, ct) .ConfigureAwait(false); } @@ -142,7 +143,7 @@ public static async Task ConnectAsync( CancellationToken ct = default) { return await ConnectCoreAsync( - endpointUrl, sessionName, null, EndpointChoice.Any, null, ct) + endpointUrl, sessionName, null, EndpointChoice.Any, null, false, ct) .ConfigureAwait(false); } @@ -162,7 +163,7 @@ public static async Task ConnectWithIdentityAsync( CancellationToken ct = default) { return await ConnectCoreAsync( - endpointUrl, sessionName, identity, EndpointChoice.UnsecuredOnly, null, ct) + endpointUrl, sessionName, identity, EndpointChoice.UnsecuredOnly, null, false, ct) .ConfigureAwait(false); } @@ -182,47 +183,55 @@ public static async Task ConnectEncryptedAsync( CancellationToken ct = default) { return await ConnectCoreAsync( - endpointUrl, sessionName, identity, EndpointChoice.EncryptedOnly, null, ct) + endpointUrl, sessionName, identity, EndpointChoice.EncryptedOnly, null, false, ct) .ConfigureAwait(false); } /// - /// Opens a session whose application instance certificate carries the given subject. + /// Opens a session signed in with an X.509 user token made from the client's own + /// application instance certificate, created with the given subject. /// /// - /// For servers which grant a Role for the certificate of the client application, as - /// OPC UA Part 18 4.4.3 X509Subject and Thumbprint identity criteria do. Every test - /// client creates its own certificate in its own throw away PKI, so two clients built - /// with the same subject still differ by thumbprint - which is exactly the contrast + /// For servers which grant a Role for a certificate, as the OPC UA Part 18 4.4.3 + /// X509Subject and Thumbprint identity criteria do. Those are matched against the + /// user certificate of the Session, which only an X509IdentityToken carries, so the + /// client signs in with the certificate it already holds - the way a sample client + /// does, see . Every test client + /// creates its own certificate in its own throw away PKI, so two clients built with + /// the same subject still differ by thumbprint - which is exactly the contrast /// between the two criteria. /// /// The endpoint to connect to. /// The name of the session, for readable server logs. - /// The user to open the session for. Null for anonymous. /// - /// The subject name to create the application instance certificate with. - /// DC=localhost in it is replaced by the host name by the stack. + /// The subject name to create the application instance certificate with, or null for + /// the default one. DC=localhost in it is replaced by the host name by the stack. /// /// - /// True for an encrypted endpoint, false for the unsecured one. A client certificate - /// only reaches the server over a secured channel, so the same certificate on the two - /// endpoints is how a test tells an Endpoints filter from an identity criteria. + /// True for an encrypted endpoint, false for the unsecured one. The same certificate + /// on the two endpoints is how a test tells an Endpoints filter from an identity + /// criteria. + /// + /// + /// False to open an anonymous Session instead, with the same certificate on its secure + /// channel only - which is how a test shows that the channel certificate earns nothing. /// /// The cancellation token. public static async Task ConnectWithCertificateAsync( string endpointUrl, string sessionName, - IUserIdentity identity, string certificateSubject, bool encrypted, + bool signIn = true, CancellationToken ct = default) { return await ConnectCoreAsync( endpointUrl, sessionName, - identity, + null, encrypted ? EndpointChoice.EncryptedOnly : EndpointChoice.UnsecuredOnly, certificateSubject, + signIn, ct) .ConfigureAwait(false); } @@ -307,6 +316,7 @@ private static async Task ConnectCoreAsync( IUserIdentity identity, EndpointChoice choice, string certificateSubject, + bool signInWithCertificate, CancellationToken ct) { TemporaryPki pki = null; @@ -325,6 +335,13 @@ private static async Task ConnectCoreAsync( await CreateConfigurationAsync(application, pki, certificateSubject, ct) .ConfigureAwait(false); + if (signInWithCertificate) + { + identity = await Opc.Ua.Samples.Client.SampleIdentities + .FromApplicationCertificateAsync(configuration, ct) + .ConfigureAwait(false); + } + (string subject, string thumbprint) = await DescribeCertificateAsync(configuration, ct) .ConfigureAwait(false); diff --git a/Workshop/DataTypes/Client/Model/DataTypesClientModel.cs b/Workshop/DataTypes/Client/Model/DataTypesClientModel.cs index d0195be94..e97c01e79 100644 --- a/Workshop/DataTypes/Client/Model/DataTypesClientModel.cs +++ b/Workshop/DataTypes/Client/Model/DataTypesClientModel.cs @@ -176,7 +176,7 @@ protected override async Task OnAttachedAsync(CancellationToken ct) // can be decoded. Both arguments are named: the first one is // onlyEnumTypes, and passing true there leaves every structure of the // server undecoded - which looks like a working client until it reads one. - var typeSystem = ComplexTypeSystemClientExtensions.Create(session, Telemetry); + using var typeSystem = ComplexTypeSystemClientExtensions.Create(session, Telemetry); await typeSystem .LoadAsync(onlyEnumTypes: false, throwOnError: true, ct) diff --git a/Workshop/HistoricalAccess/Client/Model/AuditEventStream.cs b/Workshop/HistoricalAccess/Client/Model/AuditEventStream.cs index 10df74eb0..a309dd34b 100644 --- a/Workshop/HistoricalAccess/Client/Model/AuditEventStream.cs +++ b/Workshop/HistoricalAccess/Client/Model/AuditEventStream.cs @@ -248,7 +248,7 @@ private static int CountOf(Variant field) return structures.Count; } - if (field.Value is System.Collections.ICollection collection) + if (field.AsBoxedObject() is System.Collections.ICollection collection) { return collection.Count; } diff --git a/Workshop/RoleManagement/Client/MainForm.cs b/Workshop/RoleManagement/Client/MainForm.cs index ec5ac9535..a2e7e74e8 100644 --- a/Workshop/RoleManagement/Client/MainForm.cs +++ b/Workshop/RoleManagement/Client/MainForm.cs @@ -88,7 +88,7 @@ public MainForm(ApplicationConfiguration configuration, ITelemetryContext teleme } IdentityCB.SelectedIndex = 0; - IdentityCB.SelectedIndexChanged += IdentityCB_SelectedIndexChanged; + IdentityCB.SelectedIndexChanged += IdentityCB_SelectedIndexChangedAsync; // the three Part 18 4.4.3 identity criteria this sample can produce; the model // fills the text box with what this client would present for each of them @@ -191,15 +191,24 @@ private void Server_DiscoverMI_Click(object sender, EventArgs e) /// /// The whole of the identity handling of this window is these few lines. Everything /// the rest of the form shows follows from which token was sent, because the server - /// resolves the Roles of the Session from it. + /// resolves the Roles of the Session from it. The workstation certificate is loaded + /// from the store of this client, which is why it is awaited. /// - private void IdentityCB_SelectedIndexChanged(object sender, EventArgs e) + private async void IdentityCB_SelectedIndexChangedAsync(object sender, EventArgs e) { try { - ConnectServerCTRL.UserIdentity = RoleManagementClientModel.IdentityFor(IdentityCB.SelectedItem as string); + string account = IdentityCB.SelectedItem as string; UpdateIdentityHint(); + + IUserIdentity identity = await RoleManagementClientModel.IdentityForAsync(account, m_configuration); + + // a later choice made while the certificate was loading wins + if (string.Equals(account, IdentityCB.SelectedItem as string, StringComparison.Ordinal)) + { + ConnectServerCTRL.UserIdentity = identity; + } } catch (Exception exception) { @@ -211,10 +220,11 @@ private void IdentityCB_SelectedIndexChanged(object sender, EventArgs e) /// Fills the criteria box with something the chosen criteria type accepts. /// /// - /// The two certificate criteria are matched against the application instance - /// certificate of the client, so the model fills them in from this client's - /// own configuration. Granting a Role for one of them and reconnecting is how the - /// sample shows a Role which belongs to a machine rather than to a person. + /// The two certificate criteria are matched against the user certificate of a + /// Session, and this client signs in as the workstation with its own application + /// instance certificate, so the model fills them in from this client's own + /// configuration. Granting a Role for one of them and reconnecting as the workstation + /// is how the sample shows a Role which belongs to a machine rather than to a person. /// private async void CriteriaCB_SelectedIndexChangedAsync(object sender, EventArgs e) { diff --git a/Workshop/RoleManagement/Client/Model/RoleManagementClientModel.cs b/Workshop/RoleManagement/Client/Model/RoleManagementClientModel.cs index 94ce38a1b..761f7f91e 100644 --- a/Workshop/RoleManagement/Client/Model/RoleManagementClientModel.cs +++ b/Workshop/RoleManagement/Client/Model/RoleManagementClientModel.cs @@ -160,6 +160,12 @@ public sealed class RoleManagementClientModel : SampleClientModel /// public const string Anonymous = "Anonymous"; + /// + /// The account which signs in with the certificate of this client application: the + /// maintenance workstation of the sample. + /// + public const string Workstation = "Workstation certificate"; + /// /// The account offers for a UserName rule. /// @@ -178,6 +184,7 @@ public sealed class RoleManagementClientModel : SampleClientModel "supervisor1", "secadmin", "guest", + Workstation, }; /// @@ -185,9 +192,10 @@ public sealed class RoleManagementClientModel : SampleClientModel /// /// /// UserName is what the server maps its demonstration accounts with; the other two - /// are matched against the application instance certificate this client sends in - /// CreateSession, which is why can fill them in from - /// the client's own configuration. + /// are matched against the user certificate of a Session, which this client presents + /// when it signs in as the - its own application instance + /// certificate. That is why can fill them in from the + /// client's own configuration. /// public static IReadOnlyList CriteriaTypes { get; } = new[] { IdentityCriteriaType.UserName, @@ -245,12 +253,30 @@ public RoleManagementClientModel(ITelemetryContext telemetry) /// is its user name. /// /// One of the . + /// The configuration of the client, which holds the + /// certificate the signs in with. + /// The cancellation token. /// The identity, or null for an anonymous Session. - public static IUserIdentity IdentityFor(string account) + public static async Task IdentityForAsync( + string account, + ApplicationConfiguration configuration, + CancellationToken ct = default) { - return string.IsNullOrEmpty(account) || string.Equals(account, Anonymous, StringComparison.Ordinal) - ? null - : new UserIdentity(account, Encoding.UTF8.GetBytes(account)); + if (string.IsNullOrEmpty(account) || string.Equals(account, Anonymous, StringComparison.Ordinal)) + { + return null; + } + + // the workstation is not an account: Part 18 matches its Role against the user + // certificate of the Session, so it signs in with the certificate it already holds + if (string.Equals(account, Workstation, StringComparison.Ordinal)) + { + return await SampleIdentities + .FromApplicationCertificateAsync(configuration, ct) + .ConfigureAwait(false); + } + + return new UserIdentity(account, Encoding.UTF8.GetBytes(account)); } /// @@ -266,8 +292,10 @@ public static string HintFor(string account) "supervisor1" => "Supervisor: writes the maintenance note, over an encrypted channel.", "secadmin" => "SecurityAdmin: manages the RoleSet, over an encrypted channel.", "guest" => "No Role beyond AuthenticatedUser: sees the machine, may change nothing.", - _ => "Anonymous: browses the machine - and with Use Security on, this workstation " + - "still earns ConfigureAdmin from its certificate.", + Workstation => "Signs in with this client's certificate: with Use Security on, the " + + "workstation earns ConfigureAdmin and the service code. The server has to " + + "trust the certificate as a user certificate.", + _ => "Anonymous: browses the machine, may change nothing.", }; } @@ -327,9 +355,10 @@ public static string Part18Subject(string subject) /// A criteria string of the given type which this client could be matched by. /// /// - /// The two certificate criteria are matched against the application instance - /// certificate of the client, so this client can fill them in from its own - /// configuration: a Thumbprint has to be upper case hexadecimal without separators, + /// The two certificate criteria are matched against the user certificate of a + /// Session, and the certificate this client signs in with as the + /// is its own application instance certificate, so it can + /// fill them in from its own configuration: a Thumbprint has to be upper case hexadecimal without separators, /// and an X509Subject the normalised form of . Granting a /// Role for one of them and reconnecting is how the sample shows a Role which belongs /// to a machine rather than to a person. Needs no session. diff --git a/Workshop/RoleManagement/README.md b/Workshop/RoleManagement/README.md index ff6148b2e..d2130c5f1 100644 --- a/Workshop/RoleManagement/README.md +++ b/Workshop/RoleManagement/README.md @@ -51,17 +51,29 @@ not its business. person. Its configuration uses two more parts of Part 18: * an identity mapping rule of criteria type **`X509Subject`** whose criteria is the subject - name of the application instance certificate the sample **client** creates for itself. Any - Session that client opens holds the Role, anonymous or signed in, and no Session from any - other client does — however it signs in. + name of the application instance certificate the sample **client** creates for itself. The + client signs in with that certificate when **Sign in as** is set to *Workstation + certificate*, and that Session holds the Role. An anonymous or user name Session from the + same client does not, and neither does a Session from any other client, however it signs in. * an **`Endpoints` filter** (§4.4.1), which is evaluated *before* any identity rule, so the - Role is refused on the unsecured endpoint. On an unsecured channel there is no client - certificate to judge in the first place, which makes the two halves consistent. - -> The criteria is matched against the **application instance certificate of the client** — -> the one it sends in `CreateSession` — not against a user certificate. Part 18 §4.4.3 reads -> either way; this is what the stack does, and it is also the only certificate a Session -> opened with an anonymous or a user name token has. + Role is refused on the unsecured endpoint. The certificate token policy names a security + policy of its own, so the workstation can sign in on the unsecured endpoint too; it is the + filter, not a missing certificate, that refuses it the Role there. + +> The criteria is matched against the **user certificate** of the Session — the one in its +> `X509IdentityToken` — not against the application instance certificate of the secure +> channel. A Session opened with an anonymous or a user name token has no user certificate +> and cannot match a `Thumbprint` or `X509Subject` rule. The workstation presents its +> application instance certificate as a user token, which is why the rule names that +> certificate's subject (`SampleIdentities.FromApplicationCertificateAsync` in the shared +> client library builds the identity). +> +> The server has to **trust the certificate as a user certificate**. User certificates have a +> trust list of their own (`TrustedUserCertificates`, `pki\trustedUser`), separate from the one +> application certificates are trusted in, and the sample registers the stack's +> `X509Authenticator` to validate against it. Copy the client's certificate from its +> `pki\own\certs` folder into `pki\trustedUser` before signing in as the workstation; until +> then the server answers `BadIdentityTokenRejected`. > > The criteria string is a normalised subject: `Name="Value"` pairs separated by slashes, in > the order CN, O, OU, DC, L, S, C. The sample writes it out in @@ -136,9 +148,9 @@ Role manager, so the whole Part 18 §4.2/§4.4 API is available over OPC UA: * `RoleSet.AddRole` / `RemoveRole` — create and delete a Role of the server's own * `.AddIdentity` / `RemoveIdentity` — grant and revoke a Role for a user name, or for - the certificate of a client application: the drop down beside the criteria box picks - `UserName`, `Thumbprint` or `X509Subject`, and fills the box with what *this* client would - present for the last two + a user certificate: the drop down beside the criteria box picks `UserName`, `Thumbprint` + or `X509Subject`, and fills the box with what *this* client presents for the last two when + it signs in as the workstation * `.CustomConfiguration` — the *Toggle CustomConfiguration* button writes the Property * `.AddApplication`, `AddEndpoint`, and the `…Exclude` properties @@ -178,9 +190,10 @@ In the client, pick an account in **Sign in as**, then **Server → Connect**. T the machine as that Session sees it, the middle list is the RoleSet, and the lower list is the audit trail. Reconnect as a different account to see the first two change — and reconnect with the **Use Security** box cleared to see what the channel decides rather than the account: -`Calibration` and `MaintenanceNote` stop giving up their values, and `ServiceCode` disappears -because the `ConfigureAdmin` Role this workstation earns from its certificate is only granted -on the encrypted endpoints. +`Calibration` and `MaintenanceNote` stop giving up their values. Sign in as *Workstation +certificate* (after trusting it, see 1b) to see `ServiceCode` appear, and clear **Use +Security** again to see it disappear: the `ConfigureAdmin` Role the workstation earns from its +certificate is only granted on the encrypted endpoints. ## Notes for implementers diff --git a/Workshop/RoleManagement/Server/ModelDesign.xml b/Workshop/RoleManagement/Server/ModelDesign.xml index 7f17bcce1..20a5a6146 100644 --- a/Workshop/RoleManagement/Server/ModelDesign.xml +++ b/Workshop/RoleManagement/Server/ModelDesign.xml @@ -43,7 +43,7 @@ The maintenance log. Written by a Supervisor, and not part of the address space of an unencrypted channel at all. - The service unlock code. Read by an Engineer, written by the maintenance workstation, which earns its Role from the certificate of the client application rather than from a user. + The service unlock code. Read by an Engineer, written by the maintenance workstation, which earns its Role from the certificate it signs in with rather than from a user name. Returns the set point to its default. Only an Operator or an Engineer may call it. diff --git a/Workshop/RoleManagement/Server/Quickstarts.RoleManagementServer.Config.xml b/Workshop/RoleManagement/Server/Quickstarts.RoleManagementServer.Config.xml index e9c652e65..79effce16 100644 --- a/Workshop/RoleManagement/Server/Quickstarts.RoleManagementServer.Config.xml +++ b/Workshop/RoleManagement/Server/Quickstarts.RoleManagementServer.Config.xml @@ -35,6 +35,21 @@ Directory %CommonApplicationData%\OPC Foundation\pki\rejected + + + + Directory + %CommonApplicationData%\OPC Foundation\pki\issuerUser + + + Directory + %CommonApplicationData%\OPC Foundation\pki\trustedUser + @@ -87,6 +102,16 @@ UserName_1 + + + Certificate_2 + http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256 + false diff --git a/Workshop/RoleManagement/Server/RoleManagementNodeManager.cs b/Workshop/RoleManagement/Server/RoleManagementNodeManager.cs index f4b528c2d..5a9140f4c 100644 --- a/Workshop/RoleManagement/Server/RoleManagementNodeManager.cs +++ b/Workshop/RoleManagement/Server/RoleManagementNodeManager.cs @@ -156,8 +156,8 @@ partial void Configure(IRoleManagementNodeManagerBuilder builder) AccessRestrictionType.ApplyRestrictionsToBrowse); // The service code belongs to the maintenance workstation rather than to a user: - // the ConfigureAdmin Role which owns it is granted by the certificate of the - // client application and only on the encrypted endpoint, which is configured in + // the ConfigureAdmin Role which owns it is granted by the certificate the + // workstation signs in with and only on the encrypted endpoint, which is configured in // SampleUsers.ConfigureRoles and WorkstationEndpoints. The node itself carries no // restriction, so what a Session may do with it is decided by that Role // configuration alone. diff --git a/Workshop/RoleManagement/Server/RoleManagementServerHosting.cs b/Workshop/RoleManagement/Server/RoleManagementServerHosting.cs index 1f8d99d1b..acb0028b2 100644 --- a/Workshop/RoleManagement/Server/RoleManagementServerHosting.cs +++ b/Workshop/RoleManagement/Server/RoleManagementServerHosting.cs @@ -21,8 +21,10 @@ namespace Microsoft.Extensions.DependencyInjection /// /// /// Everything is registered with the server builder of the stack: the role - /// mappings configure the role manager the stack installs on the server, the - /// authenticator joins its identity registry once the server has started, and the + /// mappings configure the role manager the stack installs on the server, the two + /// authenticators - one for the accounts, one for the certificate of the maintenance + /// workstation, judged against the user trust list of the configuration file - join + /// its identity registry once the server has started, and the /// startup task finishes the one part of the role configuration which cannot be /// written down before the server knows its own endpoints. The sample has no server /// class of its own. The entry point of the sample and the tests which host it share @@ -57,7 +59,11 @@ public static IServiceCollection AddRoleManagementServer( .ConfigureRoles(SampleUsers.ConfigureRoles) .AddStartupTask() .AddIdentityAuthenticator( - (_, _) => new UserNamePasswordAuthenticator(SampleUsers.AuthenticateAsync)), + (_, _) => new UserNamePasswordAuthenticator(SampleUsers.AuthenticateAsync)) + .AddIdentityAuthenticator( + (_, validator) => new X509Authenticator( + validator ?? throw new InvalidOperationException( + "The server has no certificate validator to judge user certificates with."))), configure); } } diff --git a/Workshop/RoleManagement/Server/SampleUsers.cs b/Workshop/RoleManagement/Server/SampleUsers.cs index c43ff7307..f71ff75be 100644 --- a/Workshop/RoleManagement/Server/SampleUsers.cs +++ b/Workshop/RoleManagement/Server/SampleUsers.cs @@ -51,12 +51,15 @@ public static class SampleUsers /// /// /// - /// The certificate the Role manager matches against is the application instance - /// certificate of the client - the one the client sends in CreateSession - not a - /// user certificate. That is worth saying out loud, because the criteria is named - /// after X.509 and Part 18 4.4.3 allows either reading: a Role granted this way - /// belongs to the software on that workstation, and every Session it opens holds it, - /// signed in or not. + /// The certificate the Role manager matches against is the user certificate + /// of the Session - the one in an X509IdentityToken - never the application instance + /// certificate of the secure channel. A Session opened with an anonymous or a user + /// name token has no user certificate, so it earns nothing from this rule, however the + /// client that opened it is called. The maintenance workstation of the sample signs + /// in with the certificate it already holds, its application instance certificate, + /// presented as a user token: the Role belongs to that machine, and only a Session + /// which proves it holds the private key earns it. The server has to trust the + /// certificate as a user certificate, in the user trust list of its configuration. /// /// /// The criteria is a normalised subject: Name="Value" pairs separated by @@ -120,10 +123,10 @@ public static void ConfigureRoles(RoleConfigurationOptions roles) }); } - // The Role which belongs to the maintenance workstation rather than to a user: - // it is granted for the certificate the client application presented, not for a - // user name, so an anonymous Session from the sample client holds it and a - // signed in Session from any other client does not. + // The Role which belongs to the maintenance workstation rather than to a person: + // it is granted for the certificate the workstation signs in with, not for a user + // name, so a Session of the sample client signed in with its own certificate holds + // it, and an anonymous or user name Session - from any client - does not. // // Two things this cannot say here. The Endpoints filter which goes with it is // applied by WorkstationEndpoints once the server knows its own endpoints. And diff --git a/Workshop/RoleManagement/Server/WorkstationEndpoints.cs b/Workshop/RoleManagement/Server/WorkstationEndpoints.cs index 338829f9c..2ff462e32 100644 --- a/Workshop/RoleManagement/Server/WorkstationEndpoints.cs +++ b/Workshop/RoleManagement/Server/WorkstationEndpoints.cs @@ -25,8 +25,10 @@ namespace Quickstarts.RoleManagement.Server /// identity mapping rules are even looked at: the Applications it may be granted on and /// the Endpoints it may be granted on. With this one in place the ConfigureAdmin Role of /// the sample is refused to a Session which arrived on the unsecured endpoint however - /// good its certificate is - and on an unsecured channel there is no client certificate - /// to judge in the first place. + /// good its certificate is. The certificate token policy of the sample names a security + /// policy of its own, so the workstation can sign in on the unsecured endpoint and prove + /// it holds the certificate there too: it is this filter, not a missing certificate, + /// which refuses it the Role. /// /// /// Part 18 4.4.2 says a field of an EndpointType which is left at its default value is diff --git a/targets.props b/targets.props index cba87dcb9..da791d09e 100644 --- a/targets.props +++ b/targets.props @@ -4,7 +4,7 @@ latest - 2.0.312.3680-preview + 2.0.334.14261-preview