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