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:
Joseph Doherty
2026-11-11 03:52:48 -05:00
parent 481985e20b
commit 63331b0416
31 changed files with 1555 additions and 49 deletions
@@ -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>&lt;LocalAppData&gt;/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);
}
}
@@ -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(