Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Samples/Client.Common/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Samples/Client.Common/SampleConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ private async Task<ISession> 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);
}
Expand Down
81 changes: 81 additions & 0 deletions Samples/Client.Common/SampleIdentities.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// The user identities a sample client builds from its own configuration.
/// </summary>
public static class SampleIdentities
{
/// <summary>
/// An X.509 user identity made from the application instance certificate of the
/// client itself.
/// </summary>
/// <remarks>
/// <para>
/// OPC UA Part 18 4.4.3 matches the Thumbprint and X509Subject identity criteria
/// against the certificate of the <b>user</b>, 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
/// <param name="configuration">The configuration of the client, which holds its certificate.</param>
/// <param name="ct">The cancellation token.</param>
/// <exception cref="ServiceResultException">The client has no application certificate
/// with a private key.</exception>
public static async Task<IUserIdentity> 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);
}
}
}
2 changes: 1 addition & 1 deletion Samples/Controls.Net4/Sessions/SessionOpenDlg.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ private static double NumberOf(string value)
private async Task<SignedIn> 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);
Expand Down
40 changes: 30 additions & 10 deletions Tests/SampleClients.Tests/RoleManagementClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ public class RoleManagementClientTests
/// </summary>
private const string kAnonymous = "Anonymous";

/// <summary>
/// The entry of the identity drop down which signs in with the certificate of the
/// client application.
/// </summary>
private const string kWorkstation = "Workstation certificate";

/// <summary>
/// The columns of the node list, as the sample orders them.
/// </summary>
Expand Down Expand Up @@ -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 <b>anonymous</b> 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.
/// </para>
/// <para>
/// This is the fixture which holds the server's hard coded criteria to the
Expand All @@ -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)
Expand All @@ -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),
Expand Down Expand Up @@ -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)
Expand Down
57 changes: 34 additions & 23 deletions Tests/SampleNodeManagers.Tests/RoleManagementNodeManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -650,23 +650,22 @@ await BrowseStatusAsync(secure, secureNoteId, ct).ConfigureAwait(false),
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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 <b>application instance</b> 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.
/// </para>
/// </remarks>
[Test]
Expand All @@ -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
Expand All @@ -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
Expand All @@ -712,22 +711,34 @@ 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(
await SessionOps.BrowseNamesAsync(stranger.Session, machineId, ct).ConfigureAwait(false),
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(
Expand All @@ -741,8 +752,8 @@ await SessionOps.BrowseNamesAsync(offEndpoint.Session, machineId, ct).ConfigureA
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
Expand All @@ -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(
Expand Down
Loading
Loading