fix(opcua-client): send UserName passwords in the legacy RSA format so 65-char API keys log in to SDK 1.5 servers (#880)
SDK 2.0.1's UserNameIdentityTokenHandler encrypts a password longer than 64 bytes
(a private RsaEncryptedSecretPasswordThreshold, no setting) as an RsaEncryptedSecret
with EncryptionAlgorithm=null. SDK 1.5.378 (.106 and .182) decrypts only the legacy
format under RSA policies; its SecurityPolicies.Decrypt answers a null algorithm with
BadIdentityTokenInvalid ("Unexpected encryption algorithm") before any key check.
Every production API key is 65 chars, so no SDK 2 OtOpcUa client could use one
against QA (b8b4c18f), a rollback target or an older third-party server.
LegacyRsaUserNameIdentityTokenHandler (Driver.OpcUaClient.Session, plugged in through
the public UserIdentity(IUserIdentityTokenHandler) ctor) always uses the legacy
format, RSA-OAEP(len|password|serverNonce) under the server certificate, under an RSA
policy. That is what SDK 2 sends for passwords of 64 bytes or less and what SDK 1.5
clients always send. The legacy format has no size limit (the plaintext is
length-framed over as many RSA blocks as it needs), and an SDK 2 server reads both
formats. Policy None, the ECC policies and a missing certificate go to the SDK
handler unchanged.
OpcUaUserIdentityFactory.UserName is now the one UserName identity the driver, the
AdminUI browse and Client.Shared (CLI/UI) present. Client.Shared references .Session
for it.
Tests: handler unit tests (all three RSA policies, the 1.5-required algorithm URI,
SDK 2 server-side decrypt, the session Copy() keeps the type, None unchanged, and an
SDK control pinning the 64/65-byte switch); a Client.Shared identity test; and an
SDK2<->SDK2 integration test where a 65-char key is admitted in both formats and a
wrong 65-char key reaches the key check (BadIdentityTokenRejected). The #800 Windows
gate's SDK 2 CLI 65-char case now gates against the 1.5 server too.
Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
@@ -338,6 +338,12 @@ thresholds or count absent environments as passed.
|
||||
Rebind namespaces by URI. Never replay ambiguous non-idempotent writes or alarm
|
||||
acknowledgements. Confirm API-key writes/ack, subscriptions, alarm counts and history
|
||||
continuity with the same positive and negative checks used at rollout.
|
||||
**Tooling against a rolled-back (SDK 1.5) node (#880):** an SDK 2 client logs in to
|
||||
it with a 65-character API key only if it forces the legacy UserName password
|
||||
format. The OtOpcUa CLI/UI, the OpcUaClient driver and the AdminUI browse do so from
|
||||
the #880 fix onward, but SDK 2 builds older than that fix are refused with
|
||||
`BadIdentityTokenInvalid`. Generic SDK 2 tools that use the SDK's default UserName
|
||||
handler are refused the same way. Use SDK 1.5 tooling or a post-fix CLI.
|
||||
5. Record recovery time, checks, data reconciliation and final hold/go decision.
|
||||
If rolling source back, revert package and application commits together; never
|
||||
mix old package binaries with SDK 2 application assemblies. Keep candidate evidence
|
||||
|
||||
@@ -39,8 +39,9 @@ param(
|
||||
[int]$OpcUaPort = 14841,
|
||||
[int]$AkkaPort = 18801,
|
||||
[switch]$AllowNotReady,
|
||||
# The server under test is the SDK 1.5 known-good build. An SDK 2 client sends a password over 64 bytes (every
|
||||
# production 65-character API key) as RsaEncryptedSecret, which that server rejects; see README.md.
|
||||
# The server under test is the SDK 1.5 known-good build. The SDK 2 probe's own UserName handler sends a password over
|
||||
# 64 bytes (every production 65-character API key) as RsaEncryptedSecret, which that server rejects; the production
|
||||
# SDK 2 CLI forces the legacy format since lmxopcua#880 and must be admitted. See README.md.
|
||||
[switch]$BaselineServer,
|
||||
[switch]$PatchedIsolation
|
||||
)
|
||||
@@ -393,15 +394,14 @@ switch ($Phase) {
|
||||
Invoke-Cli 'cli-sdk1-encrypt-key-65' $LegacyCliExe $s.ApiKey 'encrypt'
|
||||
Invoke-Cli 'cli-sdk2-encrypt-key-54' $CliExe $s.ShortKey 'encrypt'
|
||||
)
|
||||
$cli65 = Invoke-Cli 'cli-sdk2-encrypt-key-65' $CliExe $s.ApiKey 'encrypt'
|
||||
if (-not $BaselineServer) { $clis += $cli65 }
|
||||
# Findings recorded but not gating here (pre-existing; see README.md): the advertised endpoint set, a session
|
||||
# over the None endpoint, and the SDK 2 CLI's 65-character key against the SDK 1.5 server.
|
||||
# lmxopcua#880: gating against both servers. A CLI built before the fix fails this against -BaselineServer.
|
||||
$clis += Invoke-Cli 'cli-sdk2-encrypt-key-65' $CliExe $s.ApiKey 'encrypt'
|
||||
# Findings recorded but not gating here (pre-existing; see README.md): the advertised endpoint set and a session
|
||||
# over the None endpoint.
|
||||
$findings = @(
|
||||
Invoke-Probe 'endpoints-advertised' @('endpoints', $client, $Url) ''
|
||||
Invoke-Probe 'none-endpoint-valid-key-54' @('probe', $client, $Url, 'none', 'allow') $s.ShortKey
|
||||
)
|
||||
if ($BaselineServer) { $findings += $cli65 }
|
||||
$failed = @($cases | Where-Object { $_.exit -ne 0 }) + @($clis | Where-Object { $_.exit -ne 0 -or -not $_.good })
|
||||
$leaks = @(@($cases) + @($clis) + @($findings) | Where-Object { $_.secretOccurrencesInOutput -ne 0 })
|
||||
Write-Evidence "checks-$Label" ([ordered]@{ label = $Label; baselineServer = [bool]$BaselineServer; utc = (Get-Date).ToUniversalTime().ToString('o')
|
||||
|
||||
@@ -58,9 +58,12 @@ task secret, and the evidence keeps only the redacted copies and the occurrence
|
||||
server the client does not trust must be refused with `BadCertificateUntrusted`.
|
||||
- **Credentials.** These are gating cases. The 65-character production-format key and the 54-character key must both be
|
||||
admitted. A wrong secret, an unknown key id, a disabled key and anonymous must all be refused.
|
||||
- **Against the SDK 1.5 server** (`-BaselineServer`), the SDK 2 client's 65-character key is expected to be refused
|
||||
(lmxopcua#880). An SDK 2 client sends any password over 64 bytes as `RsaEncryptedSecret`, which 1.5.378 rejects with
|
||||
`BadIdentityTokenInvalid`. The 54-character key and the SDK 1.5 CLI with the 65-character key are the positive controls.
|
||||
- **Against the SDK 1.5 server** (`-BaselineServer`), the probe's 65-character key is expected to be refused. The probe
|
||||
uses the SDK's own UserName handler, and SDK 2.0.1 sends any password over 64 bytes as `RsaEncryptedSecret`, which
|
||||
1.5.378 rejects with `BadIdentityTokenInvalid` (lmxopcua#880). That makes the probe the SDK-default negative control.
|
||||
The production SDK 2 CLI forces the legacy format since #880, so `cli-sdk2-encrypt-key-65` gates against both servers.
|
||||
A CLI built before the fix fails it against the baseline; the 2026-11-12 evidence predates the fix. The 54-character
|
||||
key and the SDK 1.5 CLI with the 65-character key remain the positive controls.
|
||||
- **CLIs.** The CLI that matches each SDK line reads `ServerStatus` over SignAndEncrypt with an API key. The CLIs auto-accept
|
||||
the server certificate by design, so these reads do not count as strict-peer coverage.
|
||||
- **ScriptWorker isolation.** `IsolationInstall`/`IsolationUninstall` run `Install-ScriptWorkerIsolation.ps1`. The script
|
||||
|
||||
@@ -790,7 +790,9 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
var endpoint = await _endpointDiscovery.SelectEndpointAsync(config, endpointUrl, requestedMode, ct);
|
||||
|
||||
var identity = settings.Username != null
|
||||
? new UserIdentity(settings.Username, Encoding.UTF8.GetBytes(settings.Password ?? ""))
|
||||
// #880: the driver's identity factory, so a 65-char API key reaches an SDK 1.5 server in the legacy
|
||||
// encrypted format instead of SDK 2's RsaEncryptedSecret (BadIdentityTokenInvalid there).
|
||||
? ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.OpcUaUserIdentityFactory.UserName(settings.Username, settings.Password ?? "")
|
||||
: new UserIdentity();
|
||||
|
||||
var sessionTimeoutMs = (uint)(settings.SessionTimeoutSeconds * 1000);
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
NuGet packages, no OPC UA SDK; pinned by ContractsIsDependencyLightTests), so this adds two small
|
||||
first-party assemblies and nothing else to the CLI/UI. -->
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
|
||||
<!-- #880: OpcUaUserIdentityFactory.UserName, the one UserName identity the driver, the AdminUI browse and the
|
||||
CLI/UI all present (legacy RSA password encryption, so a 65-char API key logs in to SDK 1.5 servers).
|
||||
.Session is the thin SDK-typed leaf; beyond the OPC UA client SDK this already carries, it adds only
|
||||
ZB.MOM.WW.Secrets.Abstractions and Core.Abstractions (already reached through .Contracts). -->
|
||||
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Security.Certificates;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// UserName token handler that always encrypts the password in the legacy RSA format under an RSA security
|
||||
/// policy, whatever the password's length (lmxopcua#880).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// SDK 2.0.1's <see cref="UserNameIdentityTokenHandler"/> encrypts a password longer than 64 bytes (a private
|
||||
/// <c>RsaEncryptedSecretPasswordThreshold</c> constant with no setting) as an <c>RsaEncryptedSecret</c>, with
|
||||
/// <see cref="UserNameIdentityToken.EncryptionAlgorithm"/> left null. A 64-byte or shorter password is sent in
|
||||
/// the legacy format: <c>RSA-OAEP(len ‖ password ‖ serverNonce)</c> under the server certificate, with the
|
||||
/// algorithm URI set. An SDK 1.5.378 server decrypts only the legacy format under an RSA policy. A null
|
||||
/// algorithm reaches its <c>SecurityPolicies.Decrypt</c> as "Unexpected encryption algorithm", so
|
||||
/// <c>BadIdentityTokenInvalid</c> is returned before any credential check. Every production OtOpcUa API key
|
||||
/// is 65 characters long (<c>otua_</c> + 16 hex + <c>_</c> + 43), so an SDK 2 client could not use one
|
||||
/// against an SDK 1.5 server: QA, any rollback target, or an older third-party stack.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The legacy format needs no size limit, because the SDK frames the plaintext with a length and spreads it
|
||||
/// over as many RSA blocks as it needs. An SDK 2 server accepts both formats. It uses the legacy decryptor
|
||||
/// whenever the algorithm URI is set. This handler therefore sends what SDK 1.5 clients (ScadaBridge
|
||||
/// included) always send, and what SDK 2 itself sends for every password up to 64 bytes. The crypto is the
|
||||
/// same RSA-OAEP under the server certificate, bound to the server nonce. The only thing lost is
|
||||
/// <c>RsaEncryptedSecret</c>'s block padding of the secret's length. Policy <c>None</c> and the ECC policies
|
||||
/// are handed to the SDK handler unchanged, so this type never sends anything in a weaker form than the SDK
|
||||
/// would.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LegacyRsaUserNameIdentityTokenHandler : IUserIdentityTokenHandler
|
||||
{
|
||||
private readonly UserNameIdentityTokenHandler _inner;
|
||||
private readonly ISecurityPolicyRegistry _policies;
|
||||
|
||||
/// <summary>Create the handler for a user name and its plaintext password.</summary>
|
||||
/// <param name="username">The user name.</param>
|
||||
/// <param name="password">The password bytes (UTF-8 for an API key).</param>
|
||||
/// <param name="securityPolicies">The policy registry, or null for <see cref="SecurityPolicies.Default"/>.</param>
|
||||
public LegacyRsaUserNameIdentityTokenHandler(string username, ReadOnlySpan<byte> password,
|
||||
ISecurityPolicyRegistry? securityPolicies = null)
|
||||
: this(new UserNameIdentityTokenHandler(username, password, securityPolicies),
|
||||
securityPolicies ?? SecurityPolicies.Default)
|
||||
{
|
||||
}
|
||||
|
||||
private LegacyRsaUserNameIdentityTokenHandler(UserNameIdentityTokenHandler inner, ISecurityPolicyRegistry policies)
|
||||
{
|
||||
_inner = inner;
|
||||
_policies = policies;
|
||||
}
|
||||
|
||||
/// <summary>The user name in the token.</summary>
|
||||
public string UserName => _inner.UserName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public UserIdentityToken Token => _inner.Token;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => _inner.DisplayName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public UserTokenType TokenType => _inner.TokenType;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UpdatePolicy(UserTokenPolicy userTokenPolicy) => _inner.UpdatePolicy(userTokenPolicy);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask EncryptAsync(Certificate receiverCertificate, byte[] receiverNonce, string securityPolicyUri,
|
||||
IServiceMessageContext context, Nonce? receiverEphemeralKey = null, Certificate? senderCertificate = null,
|
||||
CertificateCollection? senderIssuerCertificates = null, bool doNotEncodeSenderCertificate = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var password = _inner.DecryptedPassword;
|
||||
var policy = password is null || string.IsNullOrEmpty(securityPolicyUri)
|
||||
? null
|
||||
: _policies.GetInfo(securityPolicyUri);
|
||||
|
||||
// Only an RSA policy (no ephemeral key) with a receiver certificate is ours. None, ECC, an unknown policy
|
||||
// and a missing certificate get the SDK's own handling, and its own errors.
|
||||
if (policy is null || policy.Uri == SecurityPolicies.None
|
||||
|| policy.EphemeralKeyAlgorithm != CertificateKeyAlgorithm.None || receiverCertificate is null)
|
||||
{
|
||||
return _inner.EncryptAsync(receiverCertificate!, receiverNonce, securityPolicyUri, context,
|
||||
receiverEphemeralKey, senderCertificate, senderIssuerCertificates, doNotEncodeSenderCertificate, ct);
|
||||
}
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var token = (UserNameIdentityToken)_inner.Token;
|
||||
var plaintext = Utils.Append(password, receiverNonce);
|
||||
try
|
||||
{
|
||||
var encrypted = _policies.Encrypt(receiverCertificate, policy.Uri, plaintext);
|
||||
token.Password = encrypted.Data.ToByteString();
|
||||
token.EncryptionAlgorithm = encrypted.Algorithm;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Array.Clear(plaintext);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DecryptAsync(Certificate certificate, Nonce receiverNonce, string securityPolicyUri,
|
||||
IServiceMessageContext context, Nonce? ephemeralKey = null, Certificate? senderCertificate = null,
|
||||
CertificateCollection? senderIssuerCertificates = null, ICertificateValidatorEx? validator = null,
|
||||
CancellationToken ct = default) =>
|
||||
_inner.DecryptAsync(certificate, receiverNonce, securityPolicyUri, context, ephemeralKey, senderCertificate,
|
||||
senderIssuerCertificates, validator, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<SignatureData> SignAsync(byte[] dataToSign, string securityPolicyUri, CancellationToken ct = default) =>
|
||||
_inner.SignAsync(dataToSign, securityPolicyUri, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> VerifyAsync(byte[] dataToVerify, SignatureData signatureData, string securityPolicyUri,
|
||||
CancellationToken ct = default) =>
|
||||
_inner.VerifyAsync(dataToVerify, signatureData, securityPolicyUri, ct);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>The SDK session encrypts a <c>Copy()</c> of the handler, so the clone must keep this type.</remarks>
|
||||
public object Clone() => new LegacyRsaUserNameIdentityTokenHandler((UserNameIdentityTokenHandler)_inner.Clone(), _policies);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(IUserIdentityTokenHandler? other) =>
|
||||
other is LegacyRsaUserNameIdentityTokenHandler legacy ? _inner.Equals(legacy._inner) : _inner.Equals(other);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj) => obj is IUserIdentityTokenHandler other && Equals(other);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(UserName ?? string.Empty);
|
||||
}
|
||||
+23
-3
@@ -24,13 +24,33 @@ public static class OpcUaUserIdentityFactory
|
||||
options.AuthType switch
|
||||
{
|
||||
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
|
||||
OpcUaAuthType.Username => new UserIdentity(
|
||||
options.Username ?? string.Empty,
|
||||
System.Text.Encoding.UTF8.GetBytes(options.Password ?? string.Empty)),
|
||||
OpcUaAuthType.Username => UserName(options.Username ?? string.Empty, options.Password ?? string.Empty),
|
||||
OpcUaAuthType.Certificate => FromCertificate(options),
|
||||
_ => new UserIdentity(new AnonymousIdentityToken()),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The UserName identity every OtOpcUa OPC UA client presents: the driver, the AdminUI browse and the Client
|
||||
/// CLI/UI (through <c>Client.Shared</c>). Under an RSA policy the password always travels in the legacy
|
||||
/// encrypted format, so a 65-character API key also logs in to an SDK 1.5 server (lmxopcua#880). See
|
||||
/// <see cref="LegacyRsaUserNameIdentityTokenHandler"/>.
|
||||
/// </summary>
|
||||
/// <param name="username">The user name (<c>apikey</c> for an OtOpcUa API key).</param>
|
||||
/// <param name="password">The plaintext password; sent UTF-8 encoded.</param>
|
||||
/// <returns>The <see cref="UserIdentity"/> for session activation.</returns>
|
||||
public static UserIdentity UserName(string username, string password)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(password);
|
||||
try
|
||||
{
|
||||
return new UserIdentity(new LegacyRsaUserNameIdentityTokenHandler(username, bytes));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Array.Clear(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="UserIdentity"/> carrying a client user-authentication certificate loaded from
|
||||
/// <see cref="OpcUaClientDriverOptions.UserCertificatePath"/>. Fails fast if the path is missing, the file
|
||||
|
||||
@@ -16,6 +16,8 @@ internal sealed class FakeSessionFactory : ISessionFactory
|
||||
public string? LastEndpointUrl { get; private set; }
|
||||
/// <summary>Gets the host-name-validation flag from the last CreateSessionAsync call (#816).</summary>
|
||||
public bool? LastValidateServerHostName { get; private set; }
|
||||
/// <summary>Gets the user identity from the last CreateSessionAsync call (#880).</summary>
|
||||
public UserIdentity? LastIdentity { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional gate that, when set, blocks <see cref="CreateSessionAsync" /> until completed.
|
||||
@@ -45,6 +47,7 @@ internal sealed class FakeSessionFactory : ISessionFactory
|
||||
CreateCallCount++;
|
||||
LastEndpointUrl = endpoint.EndpointUrl ?? string.Empty;
|
||||
LastValidateServerHostName = validateServerHostName;
|
||||
LastIdentity = identity;
|
||||
|
||||
// One-shot: the gate holds only the call that observed it, so a later call (e.g. a user's Connect while a
|
||||
// failover is held) runs freely.
|
||||
|
||||
@@ -81,6 +81,35 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
_configFactory.LastSettings!.ValidateServerHostName.ShouldBe(validate ?? false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #880: the CLI/UI present the driver's UserName identity, whose RSA-policy encryption is the legacy format,
|
||||
/// so a 65-character API key also logs in to an SDK 1.5 server. SDK 2's own handler would send that key as an
|
||||
/// RsaEncryptedSecret, which the 1.5 server rejects as BadIdentityTokenInvalid. The format itself is pinned in
|
||||
/// Driver.OpcUaClient.Tests (LegacyRsaUserNameIdentityTokenHandlerTests); this pins that Client.Shared uses it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConnectAsync_presents_the_legacy_format_UserName_identity()
|
||||
{
|
||||
var settings = ValidSettings();
|
||||
settings.Username = "apikey";
|
||||
settings.Password = "otua_" + new string('a', 16) + "_" + new string('b', 43);
|
||||
|
||||
await _service.ConnectAsync(settings, TestContext.Current.CancellationToken);
|
||||
|
||||
var handler = _sessionFactory.LastIdentity.ShouldNotBeNull().TokenHandler
|
||||
.ShouldBeOfType<ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.LegacyRsaUserNameIdentityTokenHandler>();
|
||||
handler.UserName.ShouldBe("apikey");
|
||||
}
|
||||
|
||||
/// <summary>Without a user name the session stays anonymous (#880 changed only the UserName path).</summary>
|
||||
[Fact]
|
||||
public async Task ConnectAsync_without_a_username_stays_anonymous()
|
||||
{
|
||||
await _service.ConnectAsync(ValidSettings(), TestContext.Current.CancellationToken);
|
||||
|
||||
_sessionFactory.LastIdentity.ShouldNotBeNull().TokenType.ShouldBe(UserTokenType.Anonymous);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that invalid connection settings fail validation before any OPC UA session is created.
|
||||
/// </summary>
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Security.Certificates;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#880: a production 65-character API key must reach an SDK 1.5.378 server in the legacy RSA format.
|
||||
/// SDK 2.0.1 sends any password over 64 bytes as an <c>RsaEncryptedSecret</c> (null EncryptionAlgorithm). The 1.5
|
||||
/// server's <c>SecurityPolicies.Decrypt</c> answers that with <c>BadIdentityTokenInvalid</c> ("Unexpected encryption
|
||||
/// algorithm"), because under an RSA policy it accepts only the algorithm URI each policy names below.
|
||||
/// </summary>
|
||||
public sealed class LegacyRsaUserNameIdentityTokenHandlerTests
|
||||
{
|
||||
// What SDK 1.5.378.182's SecurityPolicies.Decrypt demands, per policy, before it will decrypt a UserName password.
|
||||
private const string RsaOaep = "http://www.w3.org/2001/04/xmlenc#rsa-oaep";
|
||||
private const string RsaOaepSha256 = "http://opcfoundation.org/UA/security/rsa-oaep-sha2-256";
|
||||
|
||||
// The ApiKeySecret.Generate shape: otua_ + 16 hex + _ + 43 base64url = 65 bytes. Random per run, never logged.
|
||||
private static string ProductionLengthKey()
|
||||
{
|
||||
var key = $"otua_{Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant()}_"
|
||||
+ Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
key.Length.ShouldBe(65);
|
||||
return key;
|
||||
}
|
||||
|
||||
private static Certificate ServerCertificate() =>
|
||||
CertificateBuilder.Create("CN=lmxopcua#880 receiver").SetRSAKeySize(2048).CreateForRSA();
|
||||
|
||||
private static async Task<UserNameIdentityToken> EncryptAsync(IUserIdentityTokenHandler handler, Certificate cert,
|
||||
string policyUri, byte[] serverNonce)
|
||||
{
|
||||
var context = ServiceMessageContext.CreateEmpty(DefaultTelemetry.Create(_ => { }));
|
||||
await handler.EncryptAsync(cert, serverNonce, policyUri, context, ct: TestContext.Current.CancellationToken);
|
||||
return handler.Token.ShouldBeOfType<UserNameIdentityToken>();
|
||||
}
|
||||
|
||||
// The SDK 2 server path, unmodified: what an SDK 2 OtOpcUa server does with the token on ActivateSession.
|
||||
private static async Task<byte[]?> ServerDecryptAsync(UserNameIdentityToken token, Certificate cert, string policyUri,
|
||||
byte[] serverNonce)
|
||||
{
|
||||
var context = ServiceMessageContext.CreateEmpty(DefaultTelemetry.Create(_ => { }));
|
||||
var policy = SecurityPolicies.Default.GetInfo(policyUri)!;
|
||||
var server = new UserNameIdentityTokenHandler(token);
|
||||
await server.DecryptAsync(cert, Nonce.CreateNonce(policy, serverNonce), policyUri, context,
|
||||
ct: TestContext.Current.CancellationToken);
|
||||
return server.DecryptedPassword;
|
||||
}
|
||||
|
||||
private static byte[] ServerNonce(string policyUri) =>
|
||||
Nonce.CreateNonce(SecurityPolicies.Default.GetInfo(policyUri)!.SecureChannelNonceLength).Data!;
|
||||
|
||||
[Theory]
|
||||
[InlineData(SecurityPolicies.Basic256Sha256, RsaOaep)]
|
||||
[InlineData(SecurityPolicies.Aes128_Sha256_RsaOaep, RsaOaep)]
|
||||
[InlineData(SecurityPolicies.Aes256_Sha256_RsaPss, RsaOaepSha256)]
|
||||
public async Task A_production_API_key_is_sent_in_the_legacy_format_an_SDK_1_5_server_decrypts(
|
||||
string policyUri, string sdk15RequiredAlgorithm)
|
||||
{
|
||||
using var cert = ServerCertificate();
|
||||
var key = ProductionLengthKey();
|
||||
var nonce = ServerNonce(policyUri);
|
||||
var identity = OpcUaUserIdentityFactory.UserName("apikey", key);
|
||||
|
||||
var token = await EncryptAsync(identity.TokenHandler, cert, policyUri, nonce);
|
||||
|
||||
token.UserName.ShouldBe("apikey");
|
||||
token.EncryptionAlgorithm.ShouldBe(sdk15RequiredAlgorithm,
|
||||
"an SDK 1.5 server rejects any other algorithm URI (a null one is RsaEncryptedSecret) as BadIdentityTokenInvalid");
|
||||
token.Password.ToArray().ShouldNotBe(Encoding.UTF8.GetBytes(key), "the key must never travel unencrypted");
|
||||
(await ServerDecryptAsync(token, cert, policyUri, nonce)).ShouldBe(Encoding.UTF8.GetBytes(key),
|
||||
"an SDK 2 server must still read the legacy format");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Control: pins the SDK behaviour this type exists for. If a future SDK stops choosing RsaEncryptedSecret for a
|
||||
/// long password (or makes the threshold configurable), this fails and the workaround can be revisited.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Control_the_SDK_default_handler_sends_RsaEncryptedSecret_above_64_bytes()
|
||||
{
|
||||
using var cert = ServerCertificate();
|
||||
var key = ProductionLengthKey();
|
||||
var nonce = ServerNonce(SecurityPolicies.Basic256Sha256);
|
||||
|
||||
var longToken = await EncryptAsync(new UserIdentity("apikey", Encoding.UTF8.GetBytes(key)).TokenHandler, cert,
|
||||
SecurityPolicies.Basic256Sha256, nonce);
|
||||
longToken.EncryptionAlgorithm.ShouldBeNull("65 bytes: SDK 2.0.1's RsaEncryptedSecret, unreadable by SDK 1.5");
|
||||
|
||||
var shortToken = await EncryptAsync(new UserIdentity("apikey", Encoding.UTF8.GetBytes(key[..64])).TokenHandler,
|
||||
cert, SecurityPolicies.Basic256Sha256, nonce);
|
||||
shortToken.EncryptionAlgorithm.ShouldBe(RsaOaep, "64 bytes: still the legacy format");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Policy_None_is_left_to_the_SDK_unchanged()
|
||||
{
|
||||
using var cert = ServerCertificate();
|
||||
var key = ProductionLengthKey();
|
||||
|
||||
var ours = await EncryptAsync(OpcUaUserIdentityFactory.UserName("apikey", key).TokenHandler, cert,
|
||||
SecurityPolicies.None, []);
|
||||
var sdk = await EncryptAsync(new UserIdentity("apikey", Encoding.UTF8.GetBytes(key)).TokenHandler, cert,
|
||||
SecurityPolicies.None, []);
|
||||
|
||||
ours.EncryptionAlgorithm.ShouldBe(sdk.EncryptionAlgorithm);
|
||||
ours.Password.ToArray().ShouldBe(sdk.Password.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_session_copy_keeps_the_legacy_format()
|
||||
{
|
||||
// The SDK session encrypts identity.TokenHandler.Copy(), never the instance it was given.
|
||||
using var cert = ServerCertificate();
|
||||
var key = ProductionLengthKey();
|
||||
var identity = OpcUaUserIdentityFactory.UserName("apikey", key);
|
||||
|
||||
var copy = identity.TokenHandler.Copy();
|
||||
|
||||
copy.ShouldBeOfType<LegacyRsaUserNameIdentityTokenHandler>().ShouldNotBeSameAs(identity.TokenHandler);
|
||||
copy.Equals(identity.TokenHandler).ShouldBeTrue();
|
||||
(await EncryptAsync(copy, cert, SecurityPolicies.Basic256Sha256, ServerNonce(SecurityPolicies.Basic256Sha256)))
|
||||
.EncryptionAlgorithm.ShouldBe(RsaOaep);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_driver_Username_identity_uses_the_legacy_handler()
|
||||
{
|
||||
var identity = OpcUaUserIdentityFactory.Build(new OpcUaClientDriverOptions
|
||||
{
|
||||
AuthType = OpcUaAuthType.Username, Username = "apikey", Password = ProductionLengthKey(),
|
||||
});
|
||||
|
||||
identity.TokenHandler.ShouldBeOfType<LegacyRsaUserNameIdentityTokenHandler>().UserName.ShouldBe("apikey");
|
||||
identity.TokenType.ShouldBe(UserTokenType.UserName);
|
||||
}
|
||||
}
|
||||
+40
-3
@@ -104,6 +104,37 @@ public sealed class Sdk2TrustedChannelTests
|
||||
rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldAllBe(s => !s.Activated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #880: an SDK 2 server reads a production-length (65-char) API key in both UserName password formats, the
|
||||
/// legacy one OtOpcUa's clients now always send under RSA policies (so SDK 1.5 servers can read it too) and
|
||||
/// SDK 2's default RsaEncryptedSecret. A wrong key in the legacy format reaches the real key check
|
||||
/// (BadIdentityTokenRejected, not a format failure), so the token is decrypted, not just tolerated.
|
||||
/// The SDK 1.5 server half is the separate-process matrix (scripts/validation/opcua-sdk-compatibility).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(OpcUaSecurityProfile.Basic256Sha256Sign)]
|
||||
[InlineData(OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt)]
|
||||
public async Task A_production_length_API_key_is_admitted_in_either_password_format(OpcUaSecurityProfile profile)
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await TrustedRig.StartAsync(profile, ct);
|
||||
TrustedRig.LongKey.Length.ShouldBe(65);
|
||||
|
||||
using (var legacy = await rig.ConnectAsync(ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.OpcUaUserIdentityFactory
|
||||
.UserName("apikey", TrustedRig.LongKey), ct))
|
||||
(await legacy.ReadValueAsync(rig.Node(legacy), ct)).StatusCode.ShouldBe(StatusCodes.Good);
|
||||
using (var sdkDefault = await rig.ConnectAsync(
|
||||
new UserIdentity("apikey", Encoding.UTF8.GetBytes(TrustedRig.LongKey)), ct))
|
||||
(await sdkDefault.ReadValueAsync(rig.Node(sdkDefault), ct)).StatusCode.ShouldBe(StatusCodes.Good);
|
||||
|
||||
var before = rig.AuthCalls;
|
||||
var wrong = TrustedRig.LongKey[..^1] + (TrustedRig.LongKey[^1] == 'A' ? 'B' : 'A');
|
||||
var error = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync(
|
||||
ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.OpcUaUserIdentityFactory.UserName("apikey", wrong), ct));
|
||||
error.StatusCode.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
||||
rig.AuthCalls.ShouldBe(before + 1, "the wrong key must reach the authenticator, i.e. decrypt cleanly");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_untrusted_client_is_rejected_before_application_authentication()
|
||||
{
|
||||
@@ -161,6 +192,9 @@ public sealed class Sdk2TrustedChannelTests
|
||||
internal ConcurrentQueue<(LogLevel Level, string Category, string Text)> SdkLog { get; } = new();
|
||||
private readonly ILoggerFactory _sdkLoggerFactory;
|
||||
private readonly ITelemetryContext _sdkTelemetry;
|
||||
/// <summary>#880: a random key in the production ApiKeySecret.Generate shape (65 chars, over SDK 2's 64-byte threshold).</summary>
|
||||
internal static readonly string LongKey = $"otua_{Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(8)).ToLowerInvariant()}_"
|
||||
+ Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
internal int Writes => Volatile.Read(ref _writes);
|
||||
internal int AuthCalls => Volatile.Read(ref _authCalls);
|
||||
private string ServerPki => System.IO.Path.Combine(_root, "server");
|
||||
@@ -268,7 +302,10 @@ public sealed class Sdk2TrustedChannelTests
|
||||
if (_applications.TryRemove(config, out var app)) await app.DisposeAsync();
|
||||
}
|
||||
|
||||
internal async Task<ISession> ConnectAsync(string credential, CancellationToken ct, bool untrustedClient = false, string? transportHost = null)
|
||||
internal Task<ISession> ConnectAsync(string credential, CancellationToken ct, bool untrustedClient = false, string? transportHost = null) =>
|
||||
ConnectAsync(new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)), ct, untrustedClient, transportHost);
|
||||
|
||||
internal async Task<ISession> ConnectAsync(UserIdentity identity, CancellationToken ct, bool untrustedClient = false, string? transportHost = null)
|
||||
{
|
||||
var pki = untrustedClient ? System.IO.Path.Combine(_root, $"untrusted-{Guid.NewGuid():N}") : ClientPki;
|
||||
var config = await ConfigurationAsync(pki, ct);
|
||||
@@ -285,7 +322,7 @@ public sealed class Sdk2TrustedChannelTests
|
||||
if (transportHost is not null) endpoint.EndpointUrl = $"opc.tcp://{transportHost}:{_port}/OtOpcUa";
|
||||
return await new DefaultSessionFactory(telemetry).CreateAsync(config,
|
||||
new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(config)), false, true,
|
||||
"Sdk2TrustedContract", 60_000, new UserIdentity("apikey", Encoding.UTF8.GetBytes(credential)), default, ct);
|
||||
"Sdk2TrustedContract", 60_000, identity, default, ct);
|
||||
}
|
||||
|
||||
internal NodeId Node(ISession session) => new(PathId, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
||||
@@ -317,7 +354,7 @@ public sealed class Sdk2TrustedChannelTests
|
||||
public Task<OpcUaUserAuthResult> AuthenticateUserNameAsync(string username, string password, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _authCalls);
|
||||
return Task.FromResult(username == "apikey" && (password is "contract-key" or "contract-key-ipv4" or "contract-key-ipv6")
|
||||
return Task.FromResult(username == "apikey" && (password is "contract-key" or "contract-key-ipv4" or "contract-key-ipv6" || password == LongKey)
|
||||
? OpcUaUserAuthResult.AllowKey(Principal, [], KeyId) : OpcUaUserAuthResult.Deny("Invalid credential."));
|
||||
}
|
||||
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
|
||||
|
||||
Reference in New Issue
Block a user