fix(security): per-instance credential references (#835) and confined OpcUaClient user-certificate path (#836)
#835: inside one driver namespace, a secret:<ns>/<X>/... reference whose first segment names ANOTHER existing same-type driver instance (id or name, case-insensitive; another-instance match beats an own-name match, so renaming one's driver after a victim's unlocks nothing) is refused at save (DriverConfigSaveGate.ValidateAsync), deploy (CredentialReferenceOtherInstance, Heidenhain included), Test Connect (TestDriverConnect.DriverInstanceId, ConfigDb read off the mailbox) and live browse (BrowserSessionService, fail-closed without a ConfigDb). One rule: DriverCredentialPolicy.DescribeOtherInstanceProblems + DriverInstanceIdentities. Shared names and the driver's own id stay valid; env: is not instance-scoped. Running drivers are unchanged (warn-only window: the deploy gate and the upgrade preflight / /raw banner list them). #836: OpcUaClient UserCertificatePath is a relative path inside Drivers:OpcUaClient:UserCertificateDirectory (default <LocalAppData>/OtOpcUa/pki/user-certificates). Syntactic rule (CredentialFilePaths, a DriverCredentialPolicy path field) at every #815 seam; at load time ResolveSecretRefsAsync joins, canonicalises and requires lexical AND link-resolved containment before the PFX password is resolved, and LoadCertificate refuses an unresolved path. A running driver's pre-#836 absolute path keeps loading with a rate-limited warning (LegacyCredentialReferenceTracker.ReportPath). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+7
-2
@@ -234,12 +234,17 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
|
||||
/// <param name="opts">Driver options carrying the form's auth fields.</param>
|
||||
/// <param name="secretResolver">The secret store, or null when none is available.</param>
|
||||
/// <param name="ct">Cancellation token for the secret resolution.</param>
|
||||
/// <param name="userCertificateDirectory">
|
||||
/// Test seam: the user-certificate directory (lmxopcua#836); null ⇒ the node's configured one. The browse never
|
||||
/// passes a legacy tracker, so a pre-#836 absolute <c>UserCertificatePath</c> is refused here (hard-fail).
|
||||
/// </param>
|
||||
/// <returns>The identity the browse session activates with.</returns>
|
||||
internal static async Task<UserIdentity> BuildBrowseUserIdentityAsync(
|
||||
OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct)
|
||||
OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct, string? userCertificateDirectory = null)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(opts, secretResolver, ct).ConfigureAwait(false);
|
||||
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(
|
||||
opts, secretResolver, ct, userCertificateDirectory: userCertificateDirectory).ConfigureAwait(false);
|
||||
return OpcUaUserIdentityFactory.Build(resolved);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// The node's OPC UA client user-certificate directory (lmxopcua#836). <c>UserCertificatePath</c> is authored as a
|
||||
/// path RELATIVE to it and resolved by <see cref="CredentialFilePaths.ResolveWithin"/> (lexical + link-resolved
|
||||
/// containment), so a Designer can no longer make the node present an arbitrary PFX the service account can read.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Configured once per process from <see cref="DriverCredentialPolicy.OpcUaClientUserCertificateDirectoryKey"/>
|
||||
/// (<c>Drivers:OpcUaClient:UserCertificateDirectory</c>, env <c>Drivers__OpcUaClient__UserCertificateDirectory</c>)
|
||||
/// by the host at startup; unset ⇒ <see cref="DefaultDirectory"/>, beside the driver's PKI stores. Both the runtime
|
||||
/// driver and the AdminUI browser read it, so admin and driver nodes must hold the same relative layout.
|
||||
/// </remarks>
|
||||
public static class OpcUaUserCertificatePaths
|
||||
{
|
||||
private static volatile string? _configured;
|
||||
|
||||
/// <summary>The default: <c><LocalAppData>/OtOpcUa/pki/user-certificates</c>.</summary>
|
||||
public static string DefaultDirectory => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OtOpcUa", "pki", "user-certificates");
|
||||
|
||||
/// <summary>The directory in force (configured, else <see cref="DefaultDirectory"/>).</summary>
|
||||
public static string CurrentDirectory => _configured ?? DefaultDirectory;
|
||||
|
||||
/// <summary>Sets the process-wide directory (blank ⇒ the default). Called by the host at startup.</summary>
|
||||
/// <param name="directory">The configured directory.</param>
|
||||
public static void Configure(string? directory) =>
|
||||
_configured = string.IsNullOrWhiteSpace(directory) ? null : Path.GetFullPath(directory);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an authored <c>UserCertificatePath</c> to the file to load, fail-closed
|
||||
/// (<see cref="CredentialReferenceException"/>). A fully-qualified (pre-#836) path is refused unless
|
||||
/// <paramref name="legacy"/> is given (the RUNNING driver only — deprecated warn-only window), in which case it is
|
||||
/// reported and returned verbatim, exactly as before #836. Anything else that is not an acceptable relative path is
|
||||
/// always refused.
|
||||
/// </summary>
|
||||
/// <param name="value">The authored path.</param>
|
||||
/// <param name="directory">The directory; null ⇒ <see cref="CurrentDirectory"/>.</param>
|
||||
/// <param name="legacy">The running driver's tracker; null ⇒ hard-fail.</param>
|
||||
/// <returns>The path to load.</returns>
|
||||
public static string Resolve(string value, string? directory = null, LegacyCredentialReferenceTracker? legacy = null)
|
||||
{
|
||||
var field = DriverCredentialPolicy.OpcUaClientUserCertificatePathField;
|
||||
// Only a fully-qualified path is a pre-#836 value worth keeping alive: a relative one resolved against the
|
||||
// service's working directory, which was never a meaningful configuration.
|
||||
if (legacy is not null && !CredentialFilePaths.IsAcceptableRelative(value) && Path.IsPathFullyQualified(value))
|
||||
{
|
||||
legacy.ReportPath(field.Name, field.DirectoryDescription);
|
||||
return value;
|
||||
}
|
||||
|
||||
return CredentialFilePaths.ResolveWithin(
|
||||
value, directory ?? CurrentDirectory, $"OpcUaClientDriverOptions.{field.Name}", field.DirectoryDescription);
|
||||
}
|
||||
}
|
||||
+23
-3
@@ -81,6 +81,12 @@ public static class OpcUaUserIdentityFactory
|
||||
if (string.IsNullOrWhiteSpace(options.UserCertificatePath))
|
||||
throw new InvalidOperationException(
|
||||
"OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set.");
|
||||
// lmxopcua#836: an authored (relative) path is only ever loaded after ResolveSecretRefsAsync resolved it inside the
|
||||
// node's user-certificate directory — never against the process's working directory.
|
||||
if (!Path.IsPathFullyQualified(options.UserCertificatePath))
|
||||
throw new InvalidOperationException(
|
||||
"OpcUaClientDriverOptions.UserCertificatePath has not been resolved against the node's user-certificate "
|
||||
+ "directory (ResolveSecretRefsAsync) — refusing to load a relative path (fail-closed).");
|
||||
if (!File.Exists(options.UserCertificatePath))
|
||||
throw new FileNotFoundException(
|
||||
$"User certificate not found at '{options.UserCertificatePath}'.",
|
||||
@@ -120,13 +126,26 @@ public static class OpcUaUserIdentityFactory
|
||||
/// out-of-namespace value keeps the old semantics (a case-insensitive <c>secret:</c> resolves, anything else is
|
||||
/// a literal) with a rate-limited warning. The AdminUI browser passes null (hard-fail).
|
||||
/// </param>
|
||||
/// <param name="userCertificateDirectory">
|
||||
/// lmxopcua#836: the directory a <see cref="OpcUaAuthType.Certificate"/> identity's
|
||||
/// <see cref="OpcUaClientDriverOptions.UserCertificatePath"/> is resolved inside (relative path, lexical +
|
||||
/// link-resolved containment); null ⇒ <see cref="OpcUaUserCertificatePaths.CurrentDirectory"/>. The returned
|
||||
/// options carry the resolved full path. An absolute (pre-#836) path is refused unless <paramref name="legacy"/>
|
||||
/// is given, in which case it is loaded as before with a warn-only report.
|
||||
/// </param>
|
||||
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
|
||||
public static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
|
||||
OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct,
|
||||
Func<string, string?>? environment = null, LegacyCredentialReferenceTracker? legacy = null)
|
||||
Func<string, string?>? environment = null, LegacyCredentialReferenceTracker? legacy = null,
|
||||
string? userCertificateDirectory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
// #836 first: a refused certificate path fails before any secret/env read.
|
||||
var certPath = options.AuthType == OpcUaAuthType.Certificate && !string.IsNullOrWhiteSpace(options.UserCertificatePath)
|
||||
? OpcUaUserCertificatePaths.Resolve(options.UserCertificatePath, userCertificateDirectory, legacy)
|
||||
: options.UserCertificatePath;
|
||||
|
||||
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct, environment, legacy)
|
||||
.ConfigureAwait(false);
|
||||
var certPassword = await ResolveFieldAsync(
|
||||
@@ -134,12 +153,13 @@ public static class OpcUaUserIdentityFactory
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (ReferenceEquals(password, options.Password)
|
||||
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
|
||||
&& ReferenceEquals(certPassword, options.UserCertificatePassword)
|
||||
&& string.Equals(certPath, options.UserCertificatePath, StringComparison.Ordinal))
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
return options with { Password = password, UserCertificatePassword = certPassword };
|
||||
return options with { Password = password, UserCertificatePassword = certPassword, UserCertificatePath = certPath };
|
||||
}
|
||||
|
||||
private static async Task<string?> ResolveFieldAsync(
|
||||
|
||||
Reference in New Issue
Block a user