diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs
new file mode 100644
index 000000000..5badc1b7f
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/CredentialReferenceDeployChecks.cs
@@ -0,0 +1,46 @@
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
+
+///
+/// Deploy gate for credential references (lmxopcua#815): every DriverConfig and DeviceConfig of a driver type
+/// listed in must carry its credential fields as blank, an acceptable
+/// literal, or a reference inside that driver's namespace (e.g. Galaxy secret:galaxy/… /
+/// env:OTOPCUA_GALAXY_…). The same table the AdminUI save path, Test Connect, live browse and every
+/// driver's resolve-time check read — so the gate and the runtime cannot disagree.
+///
+///
+/// Heidenhain is excluded here only because already reports the same rule
+/// (as ) through the shared parser; reporting it twice would
+/// duplicate every error. The message names the field and the allowed forms, never the value.
+///
+public static class CredentialReferenceDeployChecks
+{
+ /// A credential field holding a reference outside the driver's namespace (or a refused literal).
+ public const string Code = "CredentialReferenceOutOfScope";
+
+ /// Runs the gate.
+ /// The draft.
+ /// The errors.
+ public static IReadOnlyList Errors(DraftSnapshot draft)
+ {
+ var errors = new List();
+ foreach (var driver in draft.DriverInstances)
+ {
+ if (string.Equals(driver.DriverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase)) continue;
+ if (!DriverCredentialPolicy.TryGet(driver.DriverType, out _)) continue;
+
+ foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, driver.DriverConfig))
+ errors.Add(new(Code, $"{driver.DriverType} driver '{driver.Name}': {problem}", driver.DriverInstanceId));
+
+ foreach (var device in draft.Devices.Where(d => string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal)))
+ {
+ foreach (var problem in DriverCredentialPolicy.DescribeProblems(driver.DriverType, device.DeviceConfig))
+ errors.Add(new(Code, $"{driver.DriverType} device '{device.Name}': {problem}", device.DeviceId));
+ }
+ }
+
+ return errors;
+ }
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
index 5f9ed4f6e..c2880f739 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
@@ -47,6 +47,7 @@ public static class DraftValidator
errors.AddRange(SqlTagDeployChecks.WriteBlockErrors(draft));
ValidateMTConnectSingleDevice(draft, errors);
errors.AddRange(HeidenhainDeployChecks.Errors(draft));
+ errors.AddRange(CredentialReferenceDeployChecks.Errors(draft));
ValidateNodeAclScopes(draft, errors);
return errors;
}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/CredentialReferences.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/CredentialReferences.cs
new file mode 100644
index 000000000..725e3ab82
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/CredentialReferences.cs
@@ -0,0 +1,302 @@
+namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+///
+/// The namespace a driver's operator-authored credential references are confined to (lmxopcua#815): the
+/// secret-store prefix a secret: reference must start with, and the environment-variable prefixes an
+/// env: reference must start with.
+///
+///
+/// The scope is the exfiltration guard. A Designer authors the endpoint AND the credential reference, and the
+/// node resolves the reference and sends the value to that endpoint (Test Connect, live browse, deploy). An
+/// unscoped reference could name any secret or process variable the node can read —
+/// env:ConnectionStrings__ConfigDb, env:ServerHistorian__ApiKey, the mesh PSK — and send it in
+/// cleartext to a host the Designer controls. Inside its own namespace a reference can only name values that
+/// were provisioned to be sent to that driver's kind of endpoint.
+///
+/// Driver label used in messages (e.g. Galaxy).
+///
+/// The ONLY secret-store prefixes allowed, each ending in / (e.g. galaxy/). The first entry is the
+/// documented form.
+///
+///
+/// The ONLY environment-variable prefixes allowed (exact, upper-case, e.g. OTOPCUA_GALAXY_); the remainder
+/// must be [A-Z0-9_]+. The first entry is the documented form.
+///
+public sealed record CredentialScope(string Label, IReadOnlyList SecretNamespaces, IReadOnlyList EnvVariablePrefixes);
+
+/// Whether a credential field may carry a literal (non-reference) value.
+public enum CredentialLiteralPolicy
+{
+ /// A literal is refused — the field must be blank or a namespaced reference.
+ Refused,
+
+ /// A literal is accepted and used verbatim (the author already knows it; nothing is resolved).
+ Allowed,
+
+ /// Only the explicit dev:VALUE literal is accepted (Galaxy's opt-in cleartext marker).
+ DevPrefixOnly,
+}
+
+/// The kind of a parsed credential field value.
+public enum CredentialReferenceKind
+{
+ /// Null or empty.
+ Blank,
+
+ /// A secret: (or ${secret:…}) reference inside the scope's namespace.
+ Secret,
+
+ /// An env: reference inside the scope's prefixes.
+ Environment,
+
+ /// Not a reference: a literal value (acceptability depends on the field's ).
+ Literal,
+
+ ///
+ /// A reference (secret:, ${secret:…}, env:, file:) that is outside the scope or
+ /// malformed. Never resolved; always refused.
+ ///
+ OutOfScope,
+}
+
+/// A credential reference that may not (or cannot) be resolved. The message never carries a resolved value.
+/// The message.
+public sealed class CredentialReferenceException(string message) : InvalidOperationException(message);
+
+///
+/// The one parser + resolver of operator-authored credential references (secret: / env:), shared by
+/// every driver, probe, browser, the AdminUI save path and the deploy gate (lmxopcua#815, generalising #803).
+/// Resolution is fail-closed and re-checks the scope itself, so a reference that skipped every earlier gate is
+/// still never looked up.
+///
+public static class CredentialReferences
+{
+ /// Prefix of a secret-store reference.
+ public const string SecretPrefix = "secret:";
+
+ /// Prefix of an environment-variable reference.
+ public const string EnvPrefix = "env:";
+
+ /// Prefix of a file reference — never allowed in operator-authored config (it reads any node file).
+ public const string FilePrefix = "file:";
+
+ /// Galaxy's explicit cleartext marker.
+ public const string DevPrefix = "dev:";
+
+ private const string TokenSecretPrefix = "${secret:";
+
+ ///
+ /// Parses against . A prefix in another casing
+ /// (SECRET:x) is — never a literal sent verbatim and
+ /// never resolved; the prefix, the namespace and the name are matched exactly.
+ ///
+ /// The authored field value.
+ /// The driver's scope.
+ /// The full secret name (incl. the namespace) or the environment variable; empty otherwise.
+ /// The kind.
+ public static CredentialReferenceKind Parse(string? value, CredentialScope scope, out string name)
+ {
+ ArgumentNullException.ThrowIfNull(scope);
+ name = "";
+ if (string.IsNullOrEmpty(value)) return CredentialReferenceKind.Blank;
+
+ // A prefix in any casing is a reference (never a literal sent verbatim), but only the exact lower-case
+ // spelling is ever resolved — 'SECRET:galaxy/x' is refused, not silently honoured.
+ if (HasPrefixOtherCase(value, TokenSecretPrefix) || HasPrefixOtherCase(value, SecretPrefix)
+ || HasPrefixOtherCase(value, EnvPrefix))
+ return CredentialReferenceKind.OutOfScope;
+
+ string? secret = null;
+ if (value.StartsWith(TokenSecretPrefix, StringComparison.Ordinal))
+ {
+ if (!value.EndsWith('}')) return CredentialReferenceKind.OutOfScope;
+ secret = value[TokenSecretPrefix.Length..^1];
+ }
+ else if (value.StartsWith(SecretPrefix, StringComparison.Ordinal))
+ {
+ secret = value[SecretPrefix.Length..];
+ }
+
+ if (secret is not null)
+ {
+ if (!IsScopedSecretName(secret, scope)) return CredentialReferenceKind.OutOfScope;
+ name = secret;
+ return CredentialReferenceKind.Secret;
+ }
+
+ if (value.StartsWith(EnvPrefix, StringComparison.Ordinal))
+ {
+ var variable = value[EnvPrefix.Length..];
+ if (!IsScopedVariable(variable, scope)) return CredentialReferenceKind.OutOfScope;
+ name = variable;
+ return CredentialReferenceKind.Environment;
+ }
+
+ return value.StartsWith(FilePrefix, StringComparison.OrdinalIgnoreCase)
+ ? CredentialReferenceKind.OutOfScope
+ : CredentialReferenceKind.Literal;
+ }
+
+ /// True when may be stored/deployed in a field with the given literal policy.
+ /// The authored value.
+ /// The driver's scope.
+ /// The field's literal policy.
+ /// Whether it is acceptable.
+ public static bool IsAcceptable(string? value, CredentialScope scope, CredentialLiteralPolicy literals) =>
+ Parse(value, scope, out _) switch
+ {
+ CredentialReferenceKind.Blank or CredentialReferenceKind.Secret or CredentialReferenceKind.Environment => true,
+ CredentialReferenceKind.Literal => literals switch
+ {
+ CredentialLiteralPolicy.Allowed => true,
+ CredentialLiteralPolicy.DevPrefixOnly => value!.StartsWith(DevPrefix, StringComparison.OrdinalIgnoreCase),
+ _ => false,
+ },
+ _ => false,
+ };
+
+ /// The one wording of the rule — names the allowed forms, never the value.
+ /// The field.
+ /// The driver's scope.
+ /// The field's literal policy.
+ /// The message.
+ public static string RuleMessage(string field, CredentialScope scope, CredentialLiteralPolicy literals)
+ {
+ ArgumentNullException.ThrowIfNull(scope);
+ var env = string.Join(" or ", scope.EnvVariablePrefixes.Select(p => $"'env:{p}'"));
+ var secrets = string.Join(", ", scope.SecretNamespaces.Select(n => $"'secret:{n}'"));
+ var forms = $"{secrets} or {env}";
+ return literals switch
+ {
+ CredentialLiteralPolicy.Allowed =>
+ $"{field} must be a literal value, {forms} — a secret:/env:/file: reference outside the {scope.Label} "
+ + "namespace is refused (lmxopcua#815).",
+ CredentialLiteralPolicy.DevPrefixOnly =>
+ $"{field} must be blank, {forms} (or 'dev:' on a dev rig) — a literal, a file: reference, or a "
+ + $"reference outside the {scope.Label} namespace is refused (lmxopcua#815).",
+ _ =>
+ $"{field} must be blank, {forms} — a literal password, or a reference outside the {scope.Label} "
+ + "namespace, is refused (lmxopcua#815).",
+ };
+ }
+
+ ///
+ /// Resolves a field value, fail-closed. Blank returns ""; an acceptable literal returns verbatim; an
+ /// out-of-scope reference or a refused literal throws BEFORE any
+ /// store or environment read; an absent secret / unset variable returns .
+ ///
+ /// The authored value.
+ /// The driver's scope.
+ /// Field label for messages.
+ /// The field's literal policy.
+ /// Secret-store lookup (by full name); null ⇒ a secret: reference throws.
+ /// Environment reader; null ⇒ .
+ /// Cancellation.
+ /// The value, or null when the referenced value is absent.
+ public static async ValueTask ResolveOrNullAsync(
+ string? value,
+ CredentialScope scope,
+ string field,
+ CredentialLiteralPolicy literals,
+ Func>? secretLookup,
+ Func? environment,
+ CancellationToken ct)
+ {
+ var kind = Parse(value, scope, out var name);
+ if (!IsAcceptable(value, scope, literals))
+ throw new CredentialReferenceException(RuleMessage(field, scope, literals) + " (fail-closed)");
+ switch (kind)
+ {
+ case CredentialReferenceKind.Blank:
+ return "";
+ case CredentialReferenceKind.Literal:
+ return value;
+ case CredentialReferenceKind.Environment:
+ return (environment ?? System.Environment.GetEnvironmentVariable)(name);
+ case CredentialReferenceKind.Secret:
+ if (secretLookup is null)
+ throw new CredentialReferenceException(
+ $"{field} references secret '{name}' but no secret store is available (fail-closed).");
+ return await secretLookup(name, ct).ConfigureAwait(false);
+ default:
+ throw new CredentialReferenceException(RuleMessage(field, scope, literals) + " (fail-closed)");
+ }
+ }
+
+ ///
+ /// As , but an absent secret / unset (or empty) variable throws
+ /// naming the secret or variable (never a value).
+ ///
+ /// The authored value.
+ /// The driver's scope.
+ /// Field label for messages.
+ /// The field's literal policy.
+ /// Secret-store lookup; null ⇒ a secret: reference throws.
+ /// Environment reader; null ⇒ the process environment.
+ /// Cancellation.
+ /// The value ("" for blank).
+ public static async ValueTask ResolveAsync(
+ string? value,
+ CredentialScope scope,
+ string field,
+ CredentialLiteralPolicy literals,
+ Func>? secretLookup,
+ Func? environment,
+ CancellationToken ct)
+ {
+ var resolved = await ResolveOrNullAsync(value, scope, field, literals, secretLookup, environment, ct).ConfigureAwait(false);
+ var kind = Parse(value, scope, out var name);
+ if (!string.IsNullOrEmpty(resolved) || kind is CredentialReferenceKind.Blank or CredentialReferenceKind.Literal)
+ return resolved ?? "";
+ throw kind == CredentialReferenceKind.Environment
+ ? new CredentialReferenceException($"{field} environment variable '{name}' is not set (fail-closed).")
+ : new CredentialReferenceException($"{field} secret '{name}' is absent from the store (fail-closed).");
+ }
+
+ /// A print-safe form of a value: the reference itself, or a redaction for a literal / dev value.
+ /// The authored value.
+ /// A string safe to log.
+ public static string Describe(string? value)
+ {
+ if (string.IsNullOrEmpty(value)) return "";
+ var v = value.Trim();
+ if (v.StartsWith(DevPrefix, StringComparison.OrdinalIgnoreCase)) return "dev:";
+ return v.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase)
+ || v.StartsWith(TokenSecretPrefix, StringComparison.OrdinalIgnoreCase)
+ || v.StartsWith(EnvPrefix, StringComparison.OrdinalIgnoreCase)
+ || v.StartsWith(FilePrefix, StringComparison.OrdinalIgnoreCase)
+ ? v
+ : "";
+ }
+
+ private static bool HasPrefixOtherCase(string value, string prefix) =>
+ value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && !value.StartsWith(prefix, StringComparison.Ordinal);
+
+ private static bool IsScopedSecretName(string secret, CredentialScope scope)
+ {
+ foreach (var ns in scope.SecretNamespaces)
+ {
+ if (!secret.StartsWith(ns, StringComparison.Ordinal)) continue;
+ var rest = secret[ns.Length..];
+ if (rest.Length is 0 or > 200) continue;
+ if (!rest.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '/')) continue;
+ if (rest.Split('/').Any(seg => seg.Length == 0 || seg is "." or "..")) continue;
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool IsScopedVariable(string variable, CredentialScope scope)
+ {
+ foreach (var prefix in scope.EnvVariablePrefixes)
+ {
+ if (!variable.StartsWith(prefix, StringComparison.Ordinal)) continue;
+ var rest = variable[prefix.Length..];
+ if (rest.Length is > 0 and <= 100 && rest.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c) || c == '_'))
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCredentialPolicy.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCredentialPolicy.cs
new file mode 100644
index 000000000..2b3eeb745
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverCredentialPolicy.cs
@@ -0,0 +1,148 @@
+using System.Text.Json;
+
+namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+/// One credential field of a driver config and whether it may carry a literal.
+/// The JSON property name (matched case-insensitively, as every driver binds).
+/// The literal policy.
+public sealed record CredentialField(string Name, CredentialLiteralPolicy Literals);
+
+/// The credential scope + fields of one driver type.
+/// The value.
+/// The namespace its references are confined to.
+/// The credential fields its DriverConfig/DeviceConfig may carry.
+public sealed record DriverCredentialRules(string DriverType, CredentialScope Scope, IReadOnlyList Fields);
+
+///
+/// The per-driver credential namespaces (lmxopcua#815) — the single table the AdminUI save path, the deploy gate,
+/// Test Connect, live browse and every driver's resolve-time check read.
+///
+///
+/// Every driver that RESOLVES a secret:/env: reference from operator-authored config and sends the
+/// value to an operator-chosen endpoint is listed here. Drivers that resolve nothing (literal credentials only —
+/// MQTT, MTConnect URI user-info) or whose resolved value carries its own endpoint (Sql's
+/// Sql__ConnectionStrings__<ref>, already a fixed prefix) are not.
+///
+public static class DriverCredentialPolicy
+{
+ /// Heidenhain LSV2 passwords: secret:heidenhain/… / env:OTOPCUA_HEIDENHAIN_… (#803).
+ public static readonly CredentialScope HeidenhainScope = new("Heidenhain", ["heidenhain/"], ["OTOPCUA_HEIDENHAIN_"]);
+
+ ///
+ /// Galaxy gateway API key + fallback ack password: secret:galaxy/… (or secret:otopcua/galaxy/…, the
+ /// form the ack-fallback docs used) / env:OTOPCUA_GALAXY_…, plus the
+ /// legacy env:GALAXY_MXGW_… prefix the docker-dev rig and the parity-rig docs already use
+ /// (GALAXY_MXGW_API_KEY) — a variable provisioned to be sent to an mxaccessgw.
+ ///
+ public static readonly CredentialScope GalaxyScope = new("Galaxy", ["galaxy/", "otopcua/galaxy/"], ["OTOPCUA_GALAXY_", "GALAXY_MXGW_"]);
+
+ /// OPC UA Client user / certificate passwords: secret:opcuaclient/… / env:OTOPCUA_OPCUACLIENT_….
+ public static readonly CredentialScope OpcUaClientScope = new("OpcUaClient", ["opcuaclient/"], ["OTOPCUA_OPCUACLIENT_"]);
+
+ /// Galaxy Gateway.ApiKeySecretRef (literal accepted for back-compat, with a warning at resolve).
+ public static readonly CredentialField GalaxyApiKeyField = new("ApiKeySecretRef", CredentialLiteralPolicy.Allowed);
+
+ /// Galaxy AlarmAck.FallbackPasswordSecretRef (reference or explicit dev: only).
+ public static readonly CredentialField GalaxyAckPasswordField = new("FallbackPasswordSecretRef", CredentialLiteralPolicy.DevPrefixOnly);
+
+ private static readonly IReadOnlyDictionary Rules =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [DriverTypeNames.Heidenhain] = new(DriverTypeNames.Heidenhain, HeidenhainScope,
+ [
+ new("InspectPassword", CredentialLiteralPolicy.Refused),
+ new("DncPassword", CredentialLiteralPolicy.Refused),
+ new("DataPassword", CredentialLiteralPolicy.Refused),
+ ]),
+ [DriverTypeNames.Galaxy] = new(DriverTypeNames.Galaxy, GalaxyScope, [GalaxyApiKeyField, GalaxyAckPasswordField]),
+ [DriverTypeNames.OpcUaClient] = new(DriverTypeNames.OpcUaClient, OpcUaClientScope,
+ [
+ new("Password", CredentialLiteralPolicy.Allowed),
+ new("UserCertificatePassword", CredentialLiteralPolicy.Allowed),
+ ]),
+ };
+
+ /// The rules for a driver type, if it resolves credential references.
+ /// The driver type.
+ /// The rules.
+ /// Whether the type has rules.
+ public static bool TryGet(string? driverType, out DriverCredentialRules rules)
+ {
+ if (driverType is not null && Rules.TryGetValue(driverType, out var r))
+ {
+ rules = r;
+ return true;
+ }
+
+ rules = null!;
+ return false;
+ }
+
+ ///
+ /// Scans a RAW DriverConfig / DeviceConfig / merged config document for credential fields of
+ /// whose value is not acceptable — at ANY depth (a field is bound wherever the
+ /// driver's merge places it), keys matched case-insensitively. Messages name the field path, never the value.
+ /// Unreadable JSON is reported for a type with rules (it cannot be checked); other types return empty.
+ ///
+ /// The owning driver's type.
+ /// The config JSON.
+ /// The problems; empty when acceptable or the type has no credential rules.
+ public static IReadOnlyList DescribeProblems(string? driverType, string? json)
+ {
+ var problems = new List();
+ if (!TryGet(driverType, out var rules) || string.IsNullOrWhiteSpace(json)) return problems;
+ JsonDocument doc;
+ try
+ {
+ doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
+ }
+ catch (JsonException)
+ {
+ problems.Add($"{rules.Scope.Label} config is not valid JSON, so its credential fields cannot be checked.");
+ return problems;
+ }
+
+ using (doc)
+ {
+ Scan(doc.RootElement, "", rules, problems, 0);
+ }
+
+ return problems;
+ }
+
+ private static void Scan(JsonElement element, string path, DriverCredentialRules rules, List problems, int depth)
+ {
+ if (depth > 32) return;
+ if (element.ValueKind == JsonValueKind.Array)
+ {
+ var i = 0;
+ foreach (var item in element.EnumerateArray())
+ {
+ Scan(item, $"{path}[{i}]", rules, problems, depth + 1);
+ i++;
+ }
+
+ return;
+ }
+
+ if (element.ValueKind != JsonValueKind.Object) return;
+ foreach (var p in element.EnumerateObject())
+ {
+ var fieldPath = path.Length == 0 ? p.Name : $"{path}.{p.Name}";
+ var field = rules.Fields.FirstOrDefault(f => string.Equals(f.Name, p.Name, StringComparison.OrdinalIgnoreCase));
+ if (field is null)
+ {
+ Scan(p.Value, fieldPath, rules, problems, depth + 1);
+ continue;
+ }
+
+ var ok = p.Value.ValueKind switch
+ {
+ JsonValueKind.Null => true,
+ JsonValueKind.String => CredentialReferences.IsAcceptable(p.Value.GetString(), rules.Scope, field.Literals),
+ _ => false,
+ };
+ if (!ok) problems.Add(CredentialReferences.RuleMessage(fieldPath, rules.Scope, field.Literals));
+ }
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs
index d7bca47f3..5e685ad6d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs
@@ -1,33 +1,24 @@
using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
///
-/// Resolves Gateway.ApiKeySecretRef to the actual API-key string. Five
-/// forms supported, evaluated in order:
+/// Resolves Gateway.ApiKeySecretRef to the actual API-key string. The reference is resolved on the node
+/// and sent to the operator-authored Gateway.Endpoint, so references are confined to the Galaxy
+/// namespace (lmxopcua#815, ) and the check is repeated here, at
+/// resolve time, before any store or environment read:
///
-/// env:NAME — reads Environment.GetEnvironmentVariable(NAME).
-/// Throws when the variable is unset, so a misconfigured deployment fails
-/// fast rather than silently sending an empty key.
-/// file:PATH — reads UTF-8 text from PATH, trimming
-/// whitespace. Lets operators stash the key in an ACL'd file outside the
-/// repo (the same pattern as the legacy .local/galaxy-host-secret.txt).
-/// dev:KEY — explicit cleartext literal. The dev: prefix
-/// is a deliberate opt-in signal (dev box, parity rig) so the resolver
-/// doesn't emit a warning; production should never use this arm.
-/// secret:NAME — resolves NAME through the shared
-/// ZB.MOM.WW.Secrets (the encrypted-at-rest
-/// store). Fail-closed: a secret: ref whose secret is absent/tombstoned
-/// throws rather than falling through to the literal arm — the production path
-/// that retires the cleartext dev:/literal-in-DB model.
-/// Anything else — used as the literal API key for back-compat with
-/// configs that pre-date this resolver. When a logger is supplied the
-/// resolver emits a startup warning so an operator who accidentally
-/// committed a cleartext key sees it.
+/// secret:galaxy/NAME (or ${secret:galaxy/NAME}) — resolved through the shared
+/// ZB.MOM.WW.Secrets; fail-closed when absent.
+/// env:OTOPCUA_GALAXY_NAME / legacy env:GALAXY_MXGW_NAME — the process environment;
+/// fail-closed when unset.
+/// dev:KEY — explicit cleartext literal (dev box, parity rig); no warning.
+/// Any other unprefixed string — the literal key (back-compat), with a warning when a logger is supplied.
+/// A secret:/env: reference OUTSIDE the namespace, and every file: reference (it
+/// read any file the node could open) — refused with .
///
-/// A future PR can swap any of these arms for a different backing store without
-/// changing the call site.
///
///
/// Lives in the Contracts project so both the runtime GalaxyDriver and the
@@ -37,80 +28,50 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
public static class GalaxySecretRef
{
///
- /// Resolves the supplied secret reference. The secret:NAME arm resolves
- /// through and is fail-closed (throws when the secret
- /// is absent). When the ref falls through to the back-compat literal arm (an
- /// unprefixed cleartext API key in DriverConfig JSON) and a
- /// is supplied, emits a .
- /// The dev: prefix is the explicit opt-in path that doesn't warn.
+ /// Resolves the supplied API-key reference (see the type remarks). Fail-closed for an out-of-namespace
+ /// reference, a file: reference, an absent secret and an unset variable.
///
/// The secret reference string to resolve.
/// The shared secret resolver used by the secret: arm.
/// Optional logger for warning on cleartext keys.
/// Cancellation token for the async secret: resolution.
+ /// Environment reader (tests); null ⇒ the process environment.
/// The resolved API-key string.
public static async Task ResolveApiKeyAsync(
string secretRef,
ISecretResolver resolver,
ILogger? logger = null,
- CancellationToken ct = default)
+ CancellationToken ct = default,
+ Func? environment = null)
{
ArgumentException.ThrowIfNullOrEmpty(secretRef);
ArgumentNullException.ThrowIfNull(resolver);
- if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
+ var scope = DriverCredentialPolicy.GalaxyScope;
+ if (CredentialReferences.Parse(secretRef, scope, out _) == CredentialReferenceKind.Literal)
{
- var name = secretRef[4..];
- var value = Environment.GetEnvironmentVariable(name);
- return !string.IsNullOrEmpty(value)
- ? value
- : throw new InvalidOperationException(
- $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
- }
-
- if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
- {
- var path = secretRef[5..];
- if (!File.Exists(path))
+ if (secretRef.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase))
{
- throw new InvalidOperationException(
- $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist.");
+ // Explicit dev opt-in — no warning, the operator deliberately chose a cleartext literal.
+ return secretRef[CredentialReferences.DevPrefix.Length..];
}
- var contents = File.ReadAllText(path).Trim();
- return !string.IsNullOrEmpty(contents)
- ? contents
- : throw new InvalidOperationException(
- $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
+
+ // Back-compat literal arm. Warn so an operator who accidentally committed a cleartext key sees it.
+ logger?.LogWarning(
+ "Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
+ "Prefer secret:galaxy/ or env:OTOPCUA_GALAXY_, or the explicit dev:KEY prefix for dev rigs — " +
+ "a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
+ return secretRef;
}
- if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
- {
- // Explicit dev opt-in — no warning, the operator deliberately chose a
- // cleartext literal (dev box, parity rig).
- return secretRef[4..];
- }
-
- if (secretRef.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
- {
- // Production path: resolve the name through the shared encrypted secret store.
- // Fail-closed — an absent/tombstoned secret throws rather than falling through
- // to the literal arm (which would silently treat the ref string as the key).
- var name = secretRef["secret:".Length..];
- var value = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
- return !string.IsNullOrEmpty(value)
- ? value
- : throw new InvalidOperationException(
- $"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves secret '{name}', but it is absent from the store (fail-closed).");
- }
-
- // Back-compat literal arm. An unprefixed string is treated as the literal
- // API key — but emit a warning so an operator who accidentally committed a
- // cleartext key into DriverConfig sees it. Use the dev: prefix to suppress
- // this warning when the literal is intentional.
- logger?.LogWarning(
- "Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
- "Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " +
- "a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
- return secretRef;
+ // secret:/env: inside the namespace resolve; anything else (out of scope, file:) throws before any read.
+ return await CredentialReferences.ResolveAsync(
+ secretRef,
+ scope,
+ "Galaxy.Gateway.ApiKeySecretRef",
+ DriverCredentialPolicy.GalaxyApiKeyField.Literals,
+ async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false),
+ environment,
+ ct).ConfigureAwait(false);
}
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs
index c20295d3c..77b2ad751 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Runtime/GalaxyAckCredentialResolver.cs
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.Secrets.Abstractions;
@@ -20,15 +21,19 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
private readonly GalaxyAlarmAckOptions _options;
private readonly ISecretResolver _secrets;
private readonly ILogger _logger;
+ private readonly Func? _environment;
/// Initializes a new instance of the class.
/// The owning driver instance id (for messages).
/// The driver's ack-credential options.
/// The shared secret resolver.
/// Logger; never receives a secret.
+ /// Environment reader (tests); null ⇒ the process environment.
public GalaxyAckCredentialResolver(
- string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger)
+ string driverInstanceId, GalaxyAlarmAckOptions? options, ISecretResolver secrets, ILogger logger,
+ Func? environment = null)
{
+ _environment = environment;
_driverInstanceId = driverInstanceId;
_options = options ?? new GalaxyAlarmAckOptions();
_secrets = secrets ?? throw new ArgumentNullException(nameof(secrets));
@@ -142,56 +147,28 @@ internal sealed class GalaxyAckCredentialResolver : IGalaxyAckCredentialResolver
}
///
- /// Resolves a password reference. Accepts secret:NAME, ${secret:NAME}, env:NAME,
- /// file:PATH and dev:VALUE; returns null when the referenced value is absent; throws
- /// for a bare literal (a cleartext password in DriverConfig).
+ /// Resolves the fallback password reference through the shared lmxopcua#815 resolver: secret:galaxy/NAME
+ /// (or secret:otopcua/galaxy/NAME, ${secret:…}), env:OTOPCUA_GALAXY_NAME /
+ /// env:GALAXY_MXGW_NAME, or dev:VALUE; returns null when the referenced value is absent; throws
+ /// — before any store/environment read — for a bare literal, a
+ /// file: reference, or a reference outside the Galaxy namespace (the password is sent to the
+ /// operator-authored gateway endpoint, so it must never be able to name another subsystem's secret).
///
internal async Task ResolvePasswordRefAsync(string reference, CancellationToken ct)
{
- if (reference.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase) && reference.EndsWith('}'))
- {
- reference = "secret:" + reference["${secret:".Length..^1];
- }
-
- if (reference.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
- {
- return await _secrets.GetAsync(new SecretName(reference["secret:".Length..]), ct).ConfigureAwait(false);
- }
-
- if (reference.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
- {
- return Environment.GetEnvironmentVariable(reference[4..]);
- }
-
- if (reference.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
- {
- var path = reference[5..];
- return File.Exists(path)
- ? (await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)).TrimEnd('\r', '\n')
- : null;
- }
-
- if (reference.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
- {
- return reference[4..];
- }
-
- throw new FormatException("AlarmAck.FallbackPasswordSecretRef must be a secret:/env:/file:/dev: reference, not a literal.");
+ var resolved = await CredentialReferences.ResolveOrNullAsync(
+ reference,
+ DriverCredentialPolicy.GalaxyScope,
+ "AlarmAck.FallbackPasswordSecretRef",
+ DriverCredentialPolicy.GalaxyAckPasswordField.Literals,
+ async (name, c) => await _secrets.GetAsync(new SecretName(name), c).ConfigureAwait(false),
+ _environment,
+ ct).ConfigureAwait(false);
+ return resolved is not null && reference.StartsWith(CredentialReferences.DevPrefix, StringComparison.OrdinalIgnoreCase)
+ ? resolved[CredentialReferences.DevPrefix.Length..]
+ : resolved;
}
// Safe-to-print form of a reference: the kind + name for secret/env/file, never the dev: value or a literal.
- internal static string DescribeRef(string reference)
- {
- var r = reference.Trim();
- if (r.StartsWith("dev:", StringComparison.OrdinalIgnoreCase)) return "dev:";
- if (r.StartsWith("${secret:", StringComparison.OrdinalIgnoreCase)
- || r.StartsWith("secret:", StringComparison.OrdinalIgnoreCase)
- || r.StartsWith("env:", StringComparison.OrdinalIgnoreCase)
- || r.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
- {
- return r;
- }
-
- return "";
- }
+ internal static string DescribeRef(string reference) => CredentialReferences.Describe(reference);
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs
index 925b4735c..bd1aa3899 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainOptionsParser.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
@@ -21,14 +22,14 @@ public static class HeidenhainOptionsParser
/// exfiltration guard: the reference is resolved on the node and sent in cleartext in A_LG to a Host
/// the author chose, so it must never be able to name another subsystem's secret.
///
- public const string SecretNamespace = "heidenhain/";
+ public static readonly string SecretNamespace = DriverCredentialPolicy.HeidenhainScope.SecretNamespaces[0];
///
/// The ONLY environment variables a Heidenhain password may name: env:OTOPCUA_HEIDENHAIN_<NAME>
/// (exact upper-case prefix; NAME is [A-Z0-9_]+) — never ConnectionStrings__*,
/// ServerHistorian__ApiKey or any other process variable.
///
- public const string EnvVariablePrefix = "OTOPCUA_HEIDENHAIN_";
+ public static readonly string EnvVariablePrefix = DriverCredentialPolicy.HeidenhainScope.EnvVariablePrefixes[0];
/// The password fields a Heidenhain DriverConfig/DeviceConfig may carry.
public static readonly IReadOnlyList PasswordFields = ["InspectPassword", "DncPassword", "DataPassword"];
@@ -164,105 +165,41 @@ public static class HeidenhainOptionsParser
/// True for blank or a namespaced reference.
public static bool TryParseCredentialReference(string? value, out CredentialKind kind, out string name)
{
- kind = CredentialKind.None;
- name = "";
- if (string.IsNullOrEmpty(value)) return true;
- if (value.StartsWith(SecretPrefix, StringComparison.Ordinal))
+ // The shared lmxopcua#815 parser — one rule for every driver that resolves credential references.
+ switch (CredentialReferences.Parse(value, DriverCredentialPolicy.HeidenhainScope, out name))
{
- var secret = value[SecretPrefix.Length..];
- if (!secret.StartsWith(SecretNamespace, StringComparison.Ordinal)) return false;
- var rest = secret[SecretNamespace.Length..];
- if (rest.Length is 0 or > 200) return false;
- foreach (var c in rest)
- if (!(char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.' or '/')) return false;
- if (rest.Split('/').Any(seg => seg.Length == 0 || seg is "." or "..")) return false;
- kind = CredentialKind.Secret;
- name = secret;
- return true;
- }
-
- if (value.StartsWith(EnvPrefix, StringComparison.Ordinal))
- {
- var variable = value[EnvPrefix.Length..];
- if (!variable.StartsWith(EnvVariablePrefix, StringComparison.Ordinal)) return false;
- var rest = variable[EnvVariablePrefix.Length..];
- if (rest.Length is 0 or > 100 || !rest.All(c => char.IsAsciiLetterUpper(c) || char.IsAsciiDigit(c) || c == '_'))
+ case CredentialReferenceKind.Blank:
+ kind = CredentialKind.None;
+ return true;
+ case CredentialReferenceKind.Secret:
+ kind = CredentialKind.Secret;
+ return true;
+ case CredentialReferenceKind.Environment:
+ kind = CredentialKind.Environment;
+ return true;
+ default:
+ kind = CredentialKind.None;
+ name = "";
return false;
- kind = CredentialKind.Environment;
- name = variable;
- return true;
}
-
- return false;
}
///
/// Scans a RAW DriverConfig or DeviceConfig document (before it is stored) for password fields that are not
- /// namespaced references — at the top level (a device / merged config), under Access, and under every
- /// Devices[] entry; keys matched case-insensitively, as the parser binds them. The messages name the
- /// field, never the value. Unreadable JSON is reported, since it cannot be checked.
+ /// namespaced references — at any depth (top level, Access, Devices[]…); keys matched
+ /// case-insensitively, as the parser binds them. The messages name the field, never the value. Unreadable
+ /// JSON is reported, since it cannot be checked. Delegates to the shared .
///
/// The config JSON.
/// The problems; empty when every password field is acceptable.
- public static IReadOnlyList DescribeCredentialProblems(string? json)
- {
- var problems = new List();
- if (string.IsNullOrWhiteSpace(json)) return problems;
- JsonDocument doc;
- try
- {
- doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true });
- }
- catch (JsonException)
- {
- problems.Add("Heidenhain config is not valid JSON, so its credential fields cannot be checked.");
- return problems;
- }
-
- using (doc)
- {
- if (doc.RootElement.ValueKind != JsonValueKind.Object) return problems;
- ScanObject(doc.RootElement, "", problems);
- foreach (var p in doc.RootElement.EnumerateObject())
- {
- if (string.Equals(p.Name, "Access", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Object)
- ScanObject(p.Value, "Access.", problems);
- if (string.Equals(p.Name, "Devices", StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Array)
- {
- var i = 0;
- foreach (var d in p.Value.EnumerateArray())
- {
- if (d.ValueKind == JsonValueKind.Object) ScanObject(d, $"Devices[{i}].", problems);
- i++;
- }
- }
- }
- }
-
- return problems;
- }
+ public static IReadOnlyList DescribeCredentialProblems(string? json) =>
+ DriverCredentialPolicy.DescribeProblems(DriverTypeNames.Heidenhain, json);
/// The one wording of the credential rule (never includes the value).
/// The field.
/// The message.
public static string CredentialRuleMessage(string field) =>
- $"{field} must be blank, 'secret:{SecretNamespace}' or 'env:{EnvVariablePrefix}' — a literal "
- + "password, or a reference outside the Heidenhain namespace, is refused.";
-
- private static void ScanObject(JsonElement obj, string prefix, List problems)
- {
- foreach (var p in obj.EnumerateObject())
- {
- if (!PasswordFields.Any(f => string.Equals(f, p.Name, StringComparison.OrdinalIgnoreCase))) continue;
- var ok = p.Value.ValueKind switch
- {
- JsonValueKind.Null => true,
- JsonValueKind.String => IsCredentialReference(p.Value.GetString()),
- _ => false,
- };
- if (!ok) problems.Add(CredentialRuleMessage(prefix + p.Name));
- }
- }
+ CredentialReferences.RuleMessage(field, DriverCredentialPolicy.HeidenhainScope, CredentialLiteralPolicy.Refused);
private static void CheckRef(List errors, string field, string? value)
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs
index f441dfcb1..a83518677 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainCredentials.cs
@@ -1,3 +1,4 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
@@ -31,26 +32,23 @@ internal sealed class HeidenhainCredentials(ISecretResolver? resolver, FuncResolves one reference.
+ /// Resolves one reference through the shared lmxopcua#815 resolver (scope re-checked before any read).
public async ValueTask ResolveReferenceAsync(string? reference, string role, CancellationToken ct)
{
- if (!HeidenhainOptionsParser.TryParseCredentialReference(reference, out var kind, out var name))
- throw new HeidenhainCredentialException(HeidenhainOptionsParser.CredentialRuleMessage($"{role} password") + " (fail-closed)");
- switch (kind)
+ try
{
- case HeidenhainOptionsParser.CredentialKind.None:
- return "";
- case HeidenhainOptionsParser.CredentialKind.Environment:
- return _environment(name) is { Length: > 0 } value
- ? value
- : throw new HeidenhainCredentialException($"{role} password environment variable '{name}' is not set (fail-closed).");
- default:
- if (resolver is null)
- throw new HeidenhainCredentialException($"{role} password references secret '{name}' but no secret store is available (fail-closed).");
- var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
- return !string.IsNullOrEmpty(resolved)
- ? resolved
- : throw new HeidenhainCredentialException($"{role} password secret '{name}' is absent from the store (fail-closed).");
+ return await CredentialReferences.ResolveAsync(
+ reference,
+ DriverCredentialPolicy.HeidenhainScope,
+ $"{role} password",
+ CredentialLiteralPolicy.Refused,
+ resolver is null ? null : async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false),
+ _environment,
+ ct).ConfigureAwait(false);
+ }
+ catch (CredentialReferenceException ex)
+ {
+ throw new HeidenhainCredentialException(ex.Message);
}
}
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs
index 20605be4e..f1433c253 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/OpcUaUserIdentityFactory.cs
@@ -1,5 +1,6 @@
using Opc.Ua;
using Opc.Ua.Security.Certificates;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
@@ -12,7 +13,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
///
public static class OpcUaUserIdentityFactory
{
- private const string SecretPrefix = "secret:";
///
/// Build the user-identity token from options whose credential fields are already resolved plaintext
@@ -103,25 +103,29 @@ public static class OpcUaUserIdentityFactory
}
///
- /// Return a copy of with any secret:-prefixed
- /// /
- /// resolved through . Non-secret / null / empty fields pass through unchanged (the
- /// reference-equal original is returned when nothing changed). Fail-closed: an absent secret, or a
- /// secret: ref with no resolver available, throws rather than sending the literal as a credential.
+ /// Return a copy of with a secret:opcuaclient/… or
+ /// env:OTOPCUA_OPCUACLIENT_… /
+ /// resolved (lmxopcua#815 namespace). Literal /
+ /// null / empty fields pass through unchanged (the reference-equal original is returned when nothing changed).
+ /// Fail-closed: a reference outside the namespace (or file:) throws before any read, and an absent
+ /// secret, an unset variable, or a secret: ref with no resolver available throws rather than sending
+ /// the reference string as a credential.
///
/// The raw options (credential fields may carry secret: refs).
/// The shared secret resolver, or null when none is available.
/// Cancellation token.
+ /// Environment reader (tests); null ⇒ the process environment.
/// An options copy whose credential fields carry resolved plaintext.
public static async Task ResolveSecretRefsAsync(
- OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct)
+ OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct,
+ Func? environment = null)
{
ArgumentNullException.ThrowIfNull(options);
- var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
+ var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct, environment)
.ConfigureAwait(false);
var certPassword = await ResolveFieldAsync(
- options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
+ options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct, environment)
.ConfigureAwait(false);
if (ReferenceEquals(password, options.Password)
@@ -134,24 +138,22 @@ public static class OpcUaUserIdentityFactory
}
private static async Task ResolveFieldAsync(
- string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct)
+ string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct,
+ Func? environment)
{
- if (string.IsNullOrEmpty(value)
- || !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
- {
- return value;
- }
+ // A literal (or blank) passes through verbatim, reference-equal — the author already knows it.
+ var kind = CredentialReferences.Parse(value, DriverCredentialPolicy.OpcUaClientScope, out _);
+ if (kind is CredentialReferenceKind.Blank or CredentialReferenceKind.Literal) return value;
- var name = value[SecretPrefix.Length..];
- if (resolver is null)
- throw new InvalidOperationException(
- $"OpcUaClientDriverOptions.{fieldName}='{value}' references secret '{name}', but no secret store is " +
- "available here (fail-closed).");
- var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
- return !string.IsNullOrEmpty(resolved)
- ? resolved
- : throw new InvalidOperationException(
- $"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " +
- "absent from the store (fail-closed).");
+ // lmxopcua#815: the value is sent to the operator-authored endpoint, so only secret:opcuaclient/… and
+ // env:OTOPCUA_OPCUACLIENT_… resolve; anything else (out of scope, file:) throws BEFORE any store/env read.
+ return await CredentialReferences.ResolveAsync(
+ value,
+ DriverCredentialPolicy.OpcUaClientScope,
+ $"OpcUaClientDriverOptions.{fieldName}",
+ CredentialLiteralPolicy.Allowed,
+ resolver is null ? null : async (name, c) => await resolver.GetAsync(new SecretName(name), c).ConfigureAwait(false),
+ environment,
+ ct).ConfigureAwait(false);
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs
index 2121697ff..f3861c768 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
@@ -36,6 +37,13 @@ public sealed class BrowserSessionService(
///
public async Task OpenAsync(string driverType, string configJson, CancellationToken ct)
{
+ // lmxopcua#815: a live browse resolves the config's credential references and sends them to the
+ // author-chosen endpoint — refuse an out-of-namespace reference before any browser (or the
+ // Discover-backed universal browser) is opened. Nothing is read from the store or environment here.
+ var credentialProblems = DriverCredentialPolicy.DescribeProblems(driverType, configJson);
+ if (credentialProblems.Count > 0)
+ return new(false, "Browse refused: " + string.Join(" ", credentialProblems), Guid.Empty);
+
if (!_browsersByType.TryGetValue(driverType, out var browser))
return await OpenUniversalAsync(driverType, configJson, ct).ConfigureAwait(false);
try
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor
index 656cdf383..865fd7339 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/GalaxyDriverForm.razor
@@ -21,8 +21,8 @@
-
Forms: env:NAME, file:PATH, dev:KEY. Cleartext is accepted but triggers a startup warning.
+ placeholder="env:OTOPCUA_GALAXY_API_KEY" />
+
Forms: secret:galaxy/NAME, env:OTOPCUA_GALAXY_NAME (or legacy env:GALAXY_MXGW_NAME), dev:KEY. Cleartext is accepted but triggers a startup warning. References outside the Galaxy namespace and file: are refused (#815).
@@ -107,7 +107,7 @@
-
Forms: secret:NAME (production), env:NAME, file:PATH, dev:VALUE. A bare literal is refused.
+
Forms: secret:galaxy/NAME or secret:otopcua/galaxy/NAME (production), env:OTOPCUA_GALAXY_NAME, dev:VALUE. A bare literal, file:, or a reference outside the Galaxy namespace is refused (#815).
@@ -220,7 +220,7 @@
}
private static GalaxyDriverOptions CreateDefaultOptions() => new(
- Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"),
+ Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:OTOPCUA_GALAXY_API_KEY"),
MxAccess: new GalaxyMxAccessOptions("OtOpcUa"),
Repository: new GalaxyRepositoryOptions(),
Reconnect: new GalaxyReconnectOptions());
@@ -241,7 +241,7 @@
{
// GalaxyGatewayOptions
public string GatewayEndpoint { get; set; } = "https://localhost:5001";
- public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY";
+ public string GatewayApiKeySecretRef { get; set; } = "env:OTOPCUA_GALAXY_API_KEY";
public bool GatewayUseTls { get; set; } = true;
public string? GatewayCaCertificatePath { get; set; }
public int GatewayConnectTimeoutSeconds { get; set; } = 10;
@@ -273,7 +273,7 @@
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
{
- var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
+ var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:OTOPCUA_GALAXY_API_KEY");
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
var repo = r.Repository ?? new GalaxyRepositoryOptions();
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor
index 2b0f68094..bd35b42d2 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/OpcUaClientDriverForm.razor
@@ -138,6 +138,7 @@
+
Literal, secret:opcuaclient/NAME or env:OTOPCUA_OPCUACLIENT_NAME. Other references are refused (#815).