4563940792
User decision: save, Test Connect, live browse and the deploy gate keep refusing out-of-namespace credential references, but an ALREADY-DEPLOYED driver must not fault at the upgrade restart or (Galaxy) at the next gateway reconnect, since ReopenAsync re-resolves Gateway.ApiKeySecretRef. - LegacyCredentialReferenceTracker (Core.Abstractions): the one, deprecated, call-site-chosen compatibility path. Running Galaxy (InitializeAsync, ReopenAsync, ack fallback) and OpcUaClient (session open) pass it and keep the pre-#815 semantics (case-insensitive prefixes, Galaxy file:, OpcUaClient literal pass-through) with a rate-limited Warning (driver/field/allowed form, never the value) and a credential-compat:<field> diagnostics entry (both drivers now implement IDriverDiagnostics). Browsers and probes pass none and stay hard-fail; Heidenhain has none. - Deploy-gate message names the migration. - Upgrade preflight: CredentialReferencePreflightService (admin nodes, once after ApplicationStarted, ConfigDb only, resolves nothing) logs a Warning per Draft row / Deployed-artifact finding; the /raw banner shows them plus Running drivers reporting warn-only use (driver-health diagnostics). - docs/security.md: exact replacement table (incl. the old form default env:MX_API_KEY, file:, un-namespaced secret:, casing changes), the ReopenAsync reconnect behaviour, the warn-only window and its removal. Co-Authored-By: Claude <noreply@anthropic.com>
180 lines
7.7 KiB
C#
180 lines
7.7 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
|
|
|
/// <summary>
|
|
/// G-2b — pins <see cref="OpcUaClientSecretResolution.ResolveSecretRefsAsync"/>, the Layer-B
|
|
/// arm that resolves <c>secret:</c>-prefixed <see cref="OpcUaClientDriverOptions.Password"/>
|
|
/// and <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> through the shared
|
|
/// <see cref="ISecretResolver"/> just before the async session-open consumes them. The
|
|
/// <c>secret:</c> arm is fail-closed — an absent secret throws rather than leaving the
|
|
/// <c>secret:</c> literal in place (which would be sent verbatim as the password).
|
|
/// Non-secret literals and null/empty pass through unchanged.
|
|
/// </summary>
|
|
public sealed class OpcUaClientSecretResolutionTests
|
|
{
|
|
private const string KnownPwName = "opcuaclient/inst/password";
|
|
private const string KnownPwValue = "resolved-password-plaintext";
|
|
private const string KnownCertPwName = "opcuaclient/inst/cert-pw";
|
|
private const string KnownCertPwValue = "resolved-cert-pw-plaintext";
|
|
|
|
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
|
|
private static ISecretResolver FakeResolver() => new StubSecretResolver();
|
|
|
|
/// <summary>(a) A <c>secret:</c> Password resolves to the store's plaintext.</summary>
|
|
[Fact]
|
|
public async Task Secret_password_resolves_to_store_plaintext()
|
|
{
|
|
var options = new OpcUaClientDriverOptions { Password = $"secret:{KnownPwName}" };
|
|
|
|
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
|
options, FakeResolver(), CancellationToken.None);
|
|
|
|
resolved.Password.ShouldBe(KnownPwValue);
|
|
}
|
|
|
|
/// <summary>(b) A <c>secret:</c> UserCertificatePassword resolves to the store's plaintext.</summary>
|
|
[Fact]
|
|
public async Task Secret_cert_password_resolves_to_store_plaintext()
|
|
{
|
|
var options = new OpcUaClientDriverOptions { UserCertificatePassword = $"secret:{KnownCertPwName}" };
|
|
|
|
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
|
options, FakeResolver(), CancellationToken.None);
|
|
|
|
resolved.UserCertificatePassword.ShouldBe(KnownCertPwValue);
|
|
}
|
|
|
|
/// <summary>(c) A non-secret literal Password and a null field pass through unchanged.</summary>
|
|
[Fact]
|
|
public async Task Literal_and_null_pass_through_unchanged()
|
|
{
|
|
var options = new OpcUaClientDriverOptions
|
|
{
|
|
Password = "plainpw",
|
|
UserCertificatePassword = null,
|
|
};
|
|
|
|
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
|
options, FakeResolver(), CancellationToken.None);
|
|
|
|
resolved.Password.ShouldBe("plainpw");
|
|
resolved.UserCertificatePassword.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>(d) An unknown <c>secret:</c> ref (resolver returns null) is fail-closed — it throws
|
|
/// and never leaves the <c>secret:</c> literal in place.</summary>
|
|
[Fact]
|
|
public async Task Unknown_secret_ref_is_fail_closed()
|
|
{
|
|
var options = new OpcUaClientDriverOptions { Password = "secret:opcuaclient/inst/absent" };
|
|
|
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
|
OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
|
options, FakeResolver(), CancellationToken.None));
|
|
|
|
ex.Message.ShouldContain("Password");
|
|
ex.Message.ShouldContain("opcuaclient/inst/absent");
|
|
}
|
|
|
|
/// <summary>
|
|
/// lmxopcua#815: the password is sent to the operator-authored endpoint, so a reference outside the
|
|
/// OpcUaClient namespace throws BEFORE the store or the environment is read — it is never resolved, never sent.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("secret:otopcua/historian/api-key")]
|
|
[InlineData("secret:galaxy/inst1/apikey")]
|
|
[InlineData("secret:OT-MESH-PSK-MAIN")]
|
|
[InlineData("env:ConnectionStrings__ConfigDb")]
|
|
[InlineData("env:ServerHistorian__ApiKey")]
|
|
[InlineData("Secret:opcuaclient/inst/password")]
|
|
[InlineData("file:appsettings.json")]
|
|
public async Task Out_of_namespace_reference_is_refused_without_reading(string reference)
|
|
{
|
|
var store = new CountingResolver();
|
|
var envReads = 0;
|
|
foreach (var options in new[]
|
|
{
|
|
new OpcUaClientDriverOptions { Password = reference },
|
|
new OpcUaClientDriverOptions { UserCertificatePassword = reference },
|
|
})
|
|
{
|
|
var ex = await Should.ThrowAsync<CredentialReferenceException>(() =>
|
|
OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
|
|
options, store, CancellationToken.None, _ => { envReads++; return "LEAKED"; }));
|
|
ex.Message.ShouldContain("secret:opcuaclient/<name>");
|
|
ex.Message.ShouldNotContain("LEAKED");
|
|
}
|
|
|
|
store.Calls.ShouldBe(0);
|
|
envReads.ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>A namespaced environment reference resolves.</summary>
|
|
[Fact]
|
|
public async Task Namespaced_environment_reference_resolves()
|
|
{
|
|
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
|
|
new OpcUaClientDriverOptions { Password = "env:OTOPCUA_OPCUACLIENT_PLANT_PW" }, FakeResolver(),
|
|
CancellationToken.None, n => n == "OTOPCUA_OPCUACLIENT_PLANT_PW" ? "pw" : null);
|
|
resolved.Password.ShouldBe("pw");
|
|
}
|
|
|
|
/// <summary>
|
|
/// lmxopcua#815 warn-only compatibility (deprecated): the RUNNING driver's session open keeps the old semantics
|
|
/// for an out-of-namespace value — a case-insensitive <c>secret:</c> resolves, anything else is a literal — and
|
|
/// warns once; the browser (no tracker) refuses the same value (see the theory above).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task The_running_driver_keeps_old_semantics_for_a_legacy_value_and_warns()
|
|
{
|
|
var warnings = new List<string>();
|
|
var tracker = new LegacyCredentialReferenceTracker(DriverCredentialPolicy.OpcUaClientScope, "ouc", warnings.Add);
|
|
var legacyOptions = new OpcUaClientDriverOptions
|
|
{
|
|
Password = $"SECRET:{KnownPwName}", // pre-#815: case-insensitive secret: resolved
|
|
UserCertificatePassword = "env:NOT_A_REF", // pre-#815: only secret: resolved — a literal password
|
|
};
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
var resolved = await OpcUaClientSecretResolution.ResolveSecretRefsAsync(
|
|
legacyOptions, FakeResolver(), CancellationToken.None, tracker);
|
|
resolved.Password.ShouldBe(KnownPwValue);
|
|
resolved.UserCertificatePassword.ShouldBe("env:NOT_A_REF");
|
|
}
|
|
|
|
warnings.Count.ShouldBe(2); // one per field, rate-limited across the three session opens
|
|
warnings.ShouldAllBe(w => !w.Contains(KnownPwValue));
|
|
tracker.Diagnostics.Count.ShouldBe(2);
|
|
}
|
|
|
|
private sealed class CountingResolver : ISecretResolver
|
|
{
|
|
public int Calls { get; private set; }
|
|
|
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct)
|
|
{
|
|
Calls++;
|
|
return Task.FromResult<string?>("LEAKED");
|
|
}
|
|
}
|
|
|
|
/// <summary>A fake resolver: returns known values for two known names, null otherwise.</summary>
|
|
private sealed class StubSecretResolver : ISecretResolver
|
|
{
|
|
/// <inheritdoc />
|
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
|
Task.FromResult<string?>(name.Value switch
|
|
{
|
|
KnownPwName => KnownPwValue,
|
|
KnownCertPwName => KnownCertPwValue,
|
|
_ => null,
|
|
});
|
|
}
|
|
}
|