mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-21 00:56:28 +08:00
d552634258
Closes #4510. ## Summary OPC UA Part 6 feeds the **raw** ECDH shared secret into HKDF. .NET Framework only exposes `ECDiffieHellman.DeriveKeyMaterial`, which always hashes it, so the ECC SecureChannel and user-token policies were unavailable on `net48` (`SecurityPolicies.GetInfo` returned `null`). This adds a BouncyCastle polyfill for just that primitive: - **`Opc.Ua.Security.Certificates/Org.BouncyCastle/EcdhRawAgreement.cs`** (`net48` only): an internal `DeriveRawSecretAgreement(this ECDiffieHellman, ECDiffieHellmanPublicKey)` extension using `ECDHBasicAgreement`. It returns the X coordinate padded to the field size, exactly like the .NET 8 BCL method. Keys stay CNG keys; ECDSA signatures and HKDF stay on the platform — only the agreement step is managed code. The point is validated (on curve, not infinity) and mismatched curves are rejected. - Because the extension mirrors the .NET 8 signature, `Nonce.GenerateSecret` now has one code path on every target. - `SupportsRawEccSecretAgreement()` is removed; the CBC-based ECC policies (`ECC_nistP256`, `ECC_nistP384`, `ECC_brainpoolP256r1`, `ECC_brainpoolP384r1`) are offered on every target (still subject to OS curve support). - BouncyCastle was already a `net48` dependency of `Opc.Ua.Security.Certificates`; no new package. (Only `net48` is downlevel now — `netstandard2.1`/`net472` from the issue text are no longer targets.) **Not in scope:** the `_AesGcm` / `_ChaChaPoly` ECC variants (and PubSub DTLS) still need .NET 8 because .NET Framework has no AEAD ciphers — follow-up stacked on this branch. ## Docs `EccProfiles.md` (Known limitations), `migrate/2.0.x/certificates.md`, `ProductionChecklist.md`, `Profiles.md`, `WhatsNewIn2.0.md`. Calls out that on `net48` the agreement step does not run in a FIPS/validated module. ## Tests (local) | Suite | Result | |---|---| | `Opc.Ua.Core.Tests` full, net48 | 5956 passed, 0 failed | | `EccInteropTests` net48 vs 1.5.378 peer (net8.0, native raw ECDH) | 26 passed; 28 skipped (AEAD + curve25519/448) — all four CBC curves, both directions, UserName tokens, token renewal | | `Opc.Ua.Sessions.Tests` ECC filter, net48 | 52 passed, 0 failed | | Server `SessionCoverageTests`/`EphemeralNonce*`, Configuration builder tests, net48 | all pass | | Nonce/ECC/EncryptedSecret + session tests, net10.0 | all pass | New tests: symmetric agreement on all four curves; on net48 an oracle against CNG (`SHA256(raw) == DeriveKeyFromHash`); curve mismatch and off-curve rejection. Tests that asserted the old net48 limitation were updated. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
302 lines
14 KiB
C#
302 lines
14 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System.Threading.Tasks;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Client.TestFramework;
|
|
using Opc.Ua.Identity;
|
|
|
|
namespace Opc.Ua.Client.Tests.Identity
|
|
{
|
|
[TestFixture]
|
|
[Category("Session")]
|
|
[Category("Identity")]
|
|
[SetCulture("en-us")]
|
|
[SetUICulture("en-us")]
|
|
public sealed class UpdateIdentityAsyncTests : ClientTestFramework
|
|
{
|
|
public UpdateIdentityAsyncTests()
|
|
: base(Utils.UriSchemeOpcTcp)
|
|
{
|
|
m_secretStore = new InMemorySecretStore();
|
|
m_secretRegistry = new SecretRegistry(m_secretStore);
|
|
SingleSession = false;
|
|
}
|
|
|
|
[OneTimeSetUp]
|
|
public override Task OneTimeSetUpAsync()
|
|
{
|
|
SingleSession = false;
|
|
return base.OneTimeSetUpAsync();
|
|
}
|
|
|
|
[OneTimeTearDown]
|
|
public override Task OneTimeTearDownAsync()
|
|
{
|
|
return base.OneTimeTearDownAsync();
|
|
}
|
|
|
|
[Test]
|
|
public async Task UpdateIdentityAsyncSwitchesUserNameIdentity()
|
|
{
|
|
Endpoints = await ClientFixture.GetEndpointsAsync(ServerUrl).ConfigureAwait(false);
|
|
ConfiguredEndpoint endpoint = await ClientFixture
|
|
.GetEndpointAsync(ServerUrl, SecurityPolicies.Basic256Sha256, Endpoints)
|
|
.ConfigureAwait(false);
|
|
var userIdentity = new UserIdentity("user1", "password"u8);
|
|
UserTokenPolicy identityPolicy = endpoint.Description.FindUserTokenPolicy(
|
|
userIdentity.TokenType,
|
|
userIdentity.IssuedTokenType,
|
|
endpoint.Description.SecurityPolicyUri!)!;
|
|
if (identityPolicy == null)
|
|
{
|
|
Assert.Ignore("The test server endpoint does not advertise UserName tokens.");
|
|
}
|
|
|
|
using ISession session = await ClientFixture
|
|
.ConnectAsync(endpoint, userIdentity)
|
|
.ConfigureAwait(false);
|
|
var rawSession = (Session)session;
|
|
SecretIdentifier passwordId = await CreatePasswordAsync("password1"u8.ToArray())
|
|
.ConfigureAwait(false);
|
|
var provider = new UserNamePasswordIdentityProvider(
|
|
"user2",
|
|
m_secretRegistry,
|
|
passwordId);
|
|
|
|
await rawSession.UpdateIdentityAsync(provider).ConfigureAwait(false);
|
|
|
|
Assert.That(session.Identity.DisplayName, Is.EqualTo("user2"));
|
|
ServerStatusDataType status = await session
|
|
.ReadValueAsync<ServerStatusDataType>(VariableIds.Server_ServerStatus)
|
|
.ConfigureAwait(false);
|
|
Assert.That(status, Is.Not.Null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// L1-4: switching a UserName identity to an ECC user-token policy
|
|
/// cleared the ephemeral key before the activation that needed it,
|
|
/// so the token could never be encrypted. The key for the new policy
|
|
/// must be requested first (Part 6 6.8.2).
|
|
/// </summary>
|
|
[Test]
|
|
public async Task UpdateIdentityAsyncSwitchesToEccUserTokenPolicy()
|
|
{
|
|
Endpoints = await ClientFixture.GetEndpointsAsync(ServerUrl).ConfigureAwait(false);
|
|
ConfiguredEndpoint endpoint = await ClientFixture
|
|
.GetEndpointAsync(ServerUrl, SecurityPolicies.Basic256Sha256, Endpoints)
|
|
.ConfigureAwait(false);
|
|
var userIdentity = new UserIdentity("user1", "password"u8);
|
|
UserTokenPolicy identityPolicy = endpoint.Description.FindUserTokenPolicy(
|
|
userIdentity.TokenType,
|
|
userIdentity.IssuedTokenType,
|
|
endpoint.Description.SecurityPolicyUri!)!;
|
|
string? eccPolicyUri = null;
|
|
foreach (UserTokenPolicy policy in endpoint.Description.UserIdentityTokens)
|
|
{
|
|
if (policy.TokenType == UserTokenType.UserName &&
|
|
policy.SecurityPolicyUri != null &&
|
|
policy.SecurityPolicyUri.StartsWith(
|
|
SecurityPolicies.ECC_nistP256,
|
|
System.StringComparison.Ordinal))
|
|
{
|
|
eccPolicyUri = policy.SecurityPolicyUri;
|
|
break;
|
|
}
|
|
}
|
|
if (identityPolicy == null || eccPolicyUri == null)
|
|
{
|
|
Assert.Ignore("The test server endpoint does not advertise UserName tokens with an ECC policy.");
|
|
}
|
|
|
|
using ISession session = await ClientFixture
|
|
.ConnectAsync(endpoint, userIdentity)
|
|
.ConfigureAwait(false);
|
|
var rawSession = (Session)session;
|
|
SecretIdentifier passwordId = await CreatePasswordAsync("password1"u8.ToArray())
|
|
.ConfigureAwait(false);
|
|
var provider = new UserNamePasswordIdentityProvider(
|
|
"user2",
|
|
m_secretRegistry,
|
|
passwordId);
|
|
|
|
// The reference server only honours ECDHPolicyUri on CreateSession,
|
|
// so it may not hand out the key for the new policy; and with an RSA
|
|
// instance certificate the ECC token policy cannot be satisfied at
|
|
// all. Either way the switch must fail cleanly (not with a null
|
|
// ephemeral key, nor by silently encrypting under the channel
|
|
// policy) and leave the current identity active.
|
|
string expectedUser = "user2";
|
|
try
|
|
{
|
|
await rawSession.UpdateIdentityAsync(provider, eccPolicyUri).ConfigureAwait(false);
|
|
}
|
|
catch (ServiceResultException sre) when (
|
|
sre.StatusCode == StatusCodes.BadSecurityPolicyRejected ||
|
|
sre.StatusCode == StatusCodes.BadIdentityTokenRejected)
|
|
{
|
|
expectedUser = "user1";
|
|
}
|
|
|
|
Assert.That(session.Identity.DisplayName, Is.EqualTo(expectedUser));
|
|
ServerStatusDataType status = await session
|
|
.ReadValueAsync<ServerStatusDataType>(VariableIds.Server_ServerStatus)
|
|
.ConfigureAwait(false);
|
|
Assert.That(status, Is.Not.Null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// G4 (review of L1-4): an ECC-to-ECC override fetches the ephemeral
|
|
/// key for the new policy by reactivating the current identity, which
|
|
/// replaces its key. When the new identity is then rejected, the
|
|
/// current identity must keep an ephemeral key: without one every
|
|
/// later reconnect fails before it reaches the server.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task FailedEccToEccOverrideKeepsSessionReconnectable()
|
|
{
|
|
Endpoints = await ClientFixture.GetEndpointsAsync(ServerUrl).ConfigureAwait(false);
|
|
ConfiguredEndpoint endpoint = await ClientFixture
|
|
.GetEndpointAsync(ServerUrl, SecurityPolicies.ECC_nistP256, Endpoints)
|
|
.ConfigureAwait(false);
|
|
var userIdentity = new UserIdentity("user1", "password"u8);
|
|
UserTokenPolicy identityPolicy = endpoint.Description.FindUserTokenPolicy(
|
|
userIdentity.TokenType,
|
|
userIdentity.IssuedTokenType,
|
|
endpoint.Description.SecurityPolicyUri!)!;
|
|
string currentPolicyUri = (string.IsNullOrEmpty(identityPolicy?.SecurityPolicyUri)
|
|
? endpoint.Description.SecurityPolicyUri
|
|
: identityPolicy.SecurityPolicyUri)!;
|
|
string? eccPolicyUri = null;
|
|
foreach (UserTokenPolicy policy in endpoint.Description.UserIdentityTokens)
|
|
{
|
|
if (policy.TokenType == UserTokenType.UserName &&
|
|
policy.SecurityPolicyUri != null &&
|
|
CryptoUtils.IsEccPolicy(policy.SecurityPolicyUri) &&
|
|
policy.SecurityPolicyUri != currentPolicyUri)
|
|
{
|
|
eccPolicyUri = policy.SecurityPolicyUri;
|
|
break;
|
|
}
|
|
}
|
|
if (identityPolicy == null || eccPolicyUri == null)
|
|
{
|
|
Assert.Ignore("The test server endpoint does not advertise two ECC UserName token policies.");
|
|
}
|
|
TestContext.Out.WriteLine($"Token policy {currentPolicyUri}, override {eccPolicyUri}.");
|
|
|
|
using ISession session = await ClientFixture
|
|
.ConnectAsync(endpoint, userIdentity)
|
|
.ConfigureAwait(false);
|
|
var rawSession = (Session)session;
|
|
SecretIdentifier passwordId = await CreatePasswordAsync("wrong-password"u8.ToArray())
|
|
.ConfigureAwait(false);
|
|
var provider = new UserNamePasswordIdentityProvider(
|
|
"user2",
|
|
m_secretRegistry,
|
|
passwordId);
|
|
|
|
Assert.That(
|
|
async () => await rawSession.UpdateIdentityAsync(provider, eccPolicyUri).ConfigureAwait(false),
|
|
Throws.InstanceOf<ServiceResultException>());
|
|
Assert.That(session.Identity.DisplayName, Is.EqualTo("user1"));
|
|
|
|
await session.ReconnectAsync(null, null, default).ConfigureAwait(false);
|
|
|
|
ServerStatusDataType status = await session
|
|
.ReadValueAsync<ServerStatusDataType>(VariableIds.Server_ServerStatus)
|
|
.ConfigureAwait(false);
|
|
Assert.That(status, Is.Not.Null);
|
|
Assert.That(session.Identity.DisplayName, Is.EqualTo("user1"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// An override to an ECC policy on another curve than the client's
|
|
/// P-256 instance certificate cannot be satisfied. It must be refused
|
|
/// up front rather than fall back to a policy that inherits the
|
|
/// channel policy, which would encrypt the token for the wrong curve.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task UnsatisfiableEccOverrideIsRejectedWithoutChangingTheSession()
|
|
{
|
|
Endpoints = await ClientFixture.GetEndpointsAsync(ServerUrl).ConfigureAwait(false);
|
|
ConfiguredEndpoint endpoint = await ClientFixture
|
|
.GetEndpointAsync(ServerUrl, SecurityPolicies.ECC_nistP256, Endpoints)
|
|
.ConfigureAwait(false);
|
|
const string overridePolicyUri = SecurityPolicies.ECC_nistP384;
|
|
bool offered = false;
|
|
foreach (UserTokenPolicy policy in endpoint.Description.UserIdentityTokens)
|
|
{
|
|
offered |= policy.TokenType == UserTokenType.UserName &&
|
|
policy.SecurityPolicyUri == overridePolicyUri;
|
|
}
|
|
if (!offered)
|
|
{
|
|
Assert.Ignore("The test server endpoint does not advertise an ECC_nistP384 UserName token policy.");
|
|
}
|
|
|
|
using ISession session = await ClientFixture
|
|
.ConnectAsync(endpoint, new UserIdentity("user1", "password"u8))
|
|
.ConfigureAwait(false);
|
|
var rawSession = (Session)session;
|
|
SecretIdentifier passwordId = await CreatePasswordAsync("password"u8.ToArray())
|
|
.ConfigureAwait(false);
|
|
var provider = new UserNamePasswordIdentityProvider("user2", m_secretRegistry, passwordId);
|
|
|
|
Assert.That(
|
|
async () => await rawSession.UpdateIdentityAsync(provider, overridePolicyUri).ConfigureAwait(false),
|
|
Throws.InstanceOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode))
|
|
.EqualTo(StatusCodes.BadIdentityTokenRejected));
|
|
Assert.That(session.Identity.DisplayName, Is.EqualTo("user1"));
|
|
|
|
await session.ReconnectAsync(null, null, default).ConfigureAwait(false);
|
|
|
|
ServerStatusDataType status = await session
|
|
.ReadValueAsync<ServerStatusDataType>(VariableIds.Server_ServerStatus)
|
|
.ConfigureAwait(false);
|
|
Assert.That(status, Is.Not.Null);
|
|
Assert.That(session.Identity.DisplayName, Is.EqualTo("user1"));
|
|
}
|
|
|
|
private async Task<SecretIdentifier> CreatePasswordAsync(byte[] password)
|
|
{
|
|
var id = new SecretIdentifier(
|
|
"password-" + m_secretCounter++,
|
|
InMemorySecretStore.DefaultStoreType);
|
|
await m_secretStore.SetAsync(id, password).ConfigureAwait(false);
|
|
return id;
|
|
}
|
|
|
|
private readonly InMemorySecretStore m_secretStore;
|
|
private readonly SecretRegistry m_secretRegistry;
|
|
private int m_secretCounter;
|
|
}
|
|
}
|