Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/LegacyCredentialReferenceTracker.cs
T
Joseph Doherty 63331b0416 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>
2026-11-11 03:52:48 -05:00

105 lines
6.1 KiB
C#

namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// ⚠️ DEPRECATED compatibility path (lmxopcua#815) — the ONE switch between "enforce" and "warn-only" credential
/// resolution, and it is chosen by CALL SITE, never by a global flag: a deployed, running driver (Galaxy
/// <c>InitializeAsync</c>/<c>ReopenAsync</c> + ack fallback, OpcUaClient session open) passes its instance's tracker
/// and so keeps resolving a pre-#815 out-of-namespace reference with its OLD semantics, warning instead of
/// faulting; every admin-node path (Test Connect probe, AdminUI live browse) and Heidenhain pass
/// <see langword="null"/> and stay hard-fail.
/// </summary>
/// <remarks>
/// <para>
/// Why it is safe to keep for running drivers: the AdminUI save path and the deploy gate refuse every
/// out-of-namespace reference, so a Designer can no longer CREATE one — what remains are configs deployed
/// before the upgrade, whose endpoint and reference an administrator already deployed. Such a config runs
/// only until its next deploy (which the gate refuses until it is migrated).
/// </para>
/// <para>
/// Removing it (the planned hard-fail release): delete this type, the per-driver <c>Legacy…</c> resolve
/// methods it gates and the <c>legacy</c> parameters; every caller then takes the enforce branch it already
/// has. Nothing else changes.
/// </para>
/// <para>
/// Each use is reported through <paramref name="warn"/> at most once per <see cref="WarnInterval"/> per field
/// (a Galaxy gateway reconnect re-resolves every time), and is exposed as <see cref="Diagnostics"/> so the
/// driver's <see cref="IDriverDiagnostics"/> bag carries it to the AdminUI (<c>/hosts</c> and the <c>/raw</c>
/// credential banner). Messages name the driver, field and allowed forms — never the value.
/// </para>
/// </remarks>
/// <param name="scope">The driver's scope (for the allowed forms).</param>
/// <param name="driverInstanceId">The driver instance (for messages).</param>
/// <param name="warn">Sink for a rate-limited warning line.</param>
/// <param name="clock">Clock (tests); null ⇒ <see cref="DateTime.UtcNow"/>.</param>
public sealed class LegacyCredentialReferenceTracker(
CredentialScope scope, string driverInstanceId, Action<string> warn, Func<DateTime>? clock = null)
{
/// <summary>The <see cref="Diagnostics"/> key prefix (one entry per field).</summary>
public const string DiagnosticsKeyPrefix = "credential-compat:";
/// <summary>Minimum spacing between two warnings for the same field.</summary>
public static readonly TimeSpan WarnInterval = TimeSpan.FromHours(1);
private readonly object _gate = new();
private readonly Dictionary<string, DateTime> _lastWarned = new(StringComparer.Ordinal);
private readonly SortedDictionary<string, string> _diagnostics = new(StringComparer.Ordinal);
/// <summary>The scope this tracker reports against.</summary>
public CredentialScope Scope => scope;
/// <summary>The current legacy uses, keyed <c>credential-compat:&lt;field&gt;</c>.</summary>
public IReadOnlyDictionary<string, string> Diagnostics
{
get
{
lock (_gate) return new Dictionary<string, string>(_diagnostics, StringComparer.Ordinal);
}
}
/// <summary>Records that <paramref name="field"/> was resolved through the legacy path (warns, rate-limited).</summary>
/// <param name="field">The field label.</param>
public void Report(string field)
{
var forms = $"secret:{scope.SecretNamespaces[0]}<name> or env:{scope.EnvVariablePrefixes[0]}<NAME>";
Record(
field,
$"out-of-namespace ref still resolved (#815 warn-only, deprecated); next deploy refuses it — use {forms}",
$"{scope.Label} driver '{driverInstanceId}': {field} is a credential reference outside the {scope.Label} "
+ $"namespace. It is still resolved for this already-deployed driver (lmxopcua#815 warn-only "
+ $"compatibility — deprecated, a later release fails closed) but the deploy gate refuses it: migrate "
+ $"it to {forms} before the next deploy. See docs/security.md §\"Credential references are namespaced per driver\".");
}
/// <summary>
/// Records that the key-material file field <paramref name="field"/> (lmxopcua#836, e.g. OpcUaClient
/// <c>UserCertificatePath</c>) was loaded from a path outside the node's certificate directory through the same
/// warn-only window (warns, rate-limited). Never carries the path.
/// </summary>
/// <param name="field">The field label.</param>
/// <param name="directoryDescription">How the operator configures the directory.</param>
public void ReportPath(string field, string directoryDescription) =>
Record(
field,
$"path outside the certificate directory still loaded (#836 warn-only, deprecated); next deploy refuses it — "
+ $"move the file under {directoryDescription} and author it as a relative path",
$"{scope.Label} driver '{driverInstanceId}': {field} is not a relative path inside the node's certificate "
+ $"directory ({directoryDescription}). It is still loaded for this already-deployed driver (lmxopcua#836 "
+ "warn-only compatibility — deprecated, a later release fails closed) but the deploy gate refuses it: move "
+ "the file into that directory and set the field to its relative path before the next deploy. See "
+ "docs/security.md §\"Key-material file paths are confined to a node directory\".");
private void Record(string field, string diagnostic, string warning)
{
var now = (clock ?? (() => DateTime.UtcNow))();
bool log;
lock (_gate)
{
_diagnostics[DiagnosticsKeyPrefix + field] = diagnostic;
log = !_lastWarned.TryGetValue(field, out var last) || now - last >= WarnInterval;
if (log) _lastWarned[field] = now;
}
if (log) warn(warning);
}
}