Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/CredentialReferences.cs
T
Joseph Doherty a224fd6391 fix(security): scope operator-authored credential references per driver (#815)
A Designer could point a Galaxy / OpcUaClient endpoint at a host they control,
set a credential field to any reference the node can resolve
(env:ConnectionStrings__ConfigDb, env:ServerHistorian__ApiKey, secret:<any>,
Galaxy file:<any path>) and click Test Connect / live browse; the node resolved
the value and sent it to that host.

One shared rule in Core.Abstractions (CredentialReferences +
DriverCredentialPolicy) generalises the #803 Heidenhain namespace:
- Galaxy: secret:galaxy/... | secret:otopcua/galaxy/... | env:OTOPCUA_GALAXY_... |
  legacy env:GALAXY_MXGW_...; file: refused; dev:/literal API key unchanged.
- OpcUaClient: secret:opcuaclient/... | env:OTOPCUA_OPCUACLIENT_...; literals unchanged.
- Heidenhain adopts the shared parser/resolver (namespace unchanged).
Enforced at the AdminUI save path (DriverConfigSaveGate), the deploy gate
(CredentialReferenceDeployChecks, code CredentialReferenceOutOfScope), Test
Connect (AdminOperationsActor, before any probe), live browse
(BrowserSessionService, before any browser) and again at resolve time in every
driver/browser (fail closed, before any store/env read).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 01:28:58 -05:00

303 lines
15 KiB
C#

namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// The namespace a driver's operator-authored credential references are confined to (lmxopcua#815): the
/// secret-store prefix a <c>secret:</c> reference must start with, and the environment-variable prefixes an
/// <c>env:</c> reference must start with.
/// </summary>
/// <remarks>
/// 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 —
/// <c>env:ConnectionStrings__ConfigDb</c>, <c>env:ServerHistorian__ApiKey</c>, 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.
/// </remarks>
/// <param name="Label">Driver label used in messages (e.g. <c>Galaxy</c>).</param>
/// <param name="SecretNamespaces">
/// The ONLY secret-store prefixes allowed, each ending in <c>/</c> (e.g. <c>galaxy/</c>). The first entry is the
/// documented form.
/// </param>
/// <param name="EnvVariablePrefixes">
/// The ONLY environment-variable prefixes allowed (exact, upper-case, e.g. <c>OTOPCUA_GALAXY_</c>); the remainder
/// must be <c>[A-Z0-9_]+</c>. The first entry is the documented form.
/// </param>
public sealed record CredentialScope(string Label, IReadOnlyList<string> SecretNamespaces, IReadOnlyList<string> EnvVariablePrefixes);
/// <summary>Whether a credential field may carry a literal (non-reference) value.</summary>
public enum CredentialLiteralPolicy
{
/// <summary>A literal is refused — the field must be blank or a namespaced reference.</summary>
Refused,
/// <summary>A literal is accepted and used verbatim (the author already knows it; nothing is resolved).</summary>
Allowed,
/// <summary>Only the explicit <c>dev:VALUE</c> literal is accepted (Galaxy's opt-in cleartext marker).</summary>
DevPrefixOnly,
}
/// <summary>The kind of a parsed credential field value.</summary>
public enum CredentialReferenceKind
{
/// <summary>Null or empty.</summary>
Blank,
/// <summary>A <c>secret:</c> (or <c>${secret:…}</c>) reference inside the scope's namespace.</summary>
Secret,
/// <summary>An <c>env:</c> reference inside the scope's prefixes.</summary>
Environment,
/// <summary>Not a reference: a literal value (acceptability depends on the field's <see cref="CredentialLiteralPolicy"/>).</summary>
Literal,
/// <summary>
/// A reference (<c>secret:</c>, <c>${secret:…}</c>, <c>env:</c>, <c>file:</c>) that is outside the scope or
/// malformed. Never resolved; always refused.
/// </summary>
OutOfScope,
}
/// <summary>A credential reference that may not (or cannot) be resolved. The message never carries a resolved value.</summary>
/// <param name="message">The message.</param>
public sealed class CredentialReferenceException(string message) : InvalidOperationException(message);
/// <summary>
/// The one parser + resolver of operator-authored credential references (<c>secret:</c> / <c>env:</c>), 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.
/// </summary>
public static class CredentialReferences
{
/// <summary>Prefix of a secret-store reference.</summary>
public const string SecretPrefix = "secret:";
/// <summary>Prefix of an environment-variable reference.</summary>
public const string EnvPrefix = "env:";
/// <summary>Prefix of a file reference — never allowed in operator-authored config (it reads any node file).</summary>
public const string FilePrefix = "file:";
/// <summary>Galaxy's explicit cleartext marker.</summary>
public const string DevPrefix = "dev:";
private const string TokenSecretPrefix = "${secret:";
/// <summary>
/// Parses <paramref name="value"/> against <paramref name="scope"/>. A prefix in another casing
/// (<c>SECRET:x</c>) is <see cref="CredentialReferenceKind.OutOfScope"/> — never a literal sent verbatim and
/// never resolved; the prefix, the namespace and the name are matched exactly.
/// </summary>
/// <param name="value">The authored field value.</param>
/// <param name="scope">The driver's scope.</param>
/// <param name="name">The full secret name (incl. the namespace) or the environment variable; empty otherwise.</param>
/// <returns>The kind.</returns>
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;
}
/// <summary>True when <paramref name="value"/> may be stored/deployed in a field with the given literal policy.</summary>
/// <param name="value">The authored value.</param>
/// <param name="scope">The driver's scope.</param>
/// <param name="literals">The field's literal policy.</param>
/// <returns>Whether it is acceptable.</returns>
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,
};
/// <summary>The one wording of the rule — names the allowed forms, never the value.</summary>
/// <param name="field">The field.</param>
/// <param name="scope">The driver's scope.</param>
/// <param name="literals">The field's literal policy.</param>
/// <returns>The message.</returns>
public static string RuleMessage(string field, CredentialScope scope, CredentialLiteralPolicy literals)
{
ArgumentNullException.ThrowIfNull(scope);
var env = string.Join(" or ", scope.EnvVariablePrefixes.Select(p => $"'env:{p}<NAME>'"));
var secrets = string.Join(", ", scope.SecretNamespaces.Select(n => $"'secret:{n}<name>'"));
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:<value>' 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).",
};
}
/// <summary>
/// Resolves a field value, fail-closed. Blank returns <c>""</c>; an acceptable literal returns verbatim; an
/// out-of-scope reference or a refused literal throws <see cref="CredentialReferenceException"/> BEFORE any
/// store or environment read; an absent secret / unset variable returns <see langword="null"/>.
/// </summary>
/// <param name="value">The authored value.</param>
/// <param name="scope">The driver's scope.</param>
/// <param name="field">Field label for messages.</param>
/// <param name="literals">The field's literal policy.</param>
/// <param name="secretLookup">Secret-store lookup (by full name); null ⇒ a <c>secret:</c> reference throws.</param>
/// <param name="environment">Environment reader; null ⇒ <see cref="System.Environment.GetEnvironmentVariable(string)"/>.</param>
/// <param name="ct">Cancellation.</param>
/// <returns>The value, or null when the referenced value is absent.</returns>
public static async ValueTask<string?> ResolveOrNullAsync(
string? value,
CredentialScope scope,
string field,
CredentialLiteralPolicy literals,
Func<string, CancellationToken, ValueTask<string?>>? secretLookup,
Func<string, string?>? 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)");
}
}
/// <summary>
/// As <see cref="ResolveOrNullAsync"/>, but an absent secret / unset (or empty) variable throws
/// <see cref="CredentialReferenceException"/> naming the secret or variable (never a value).
/// </summary>
/// <param name="value">The authored value.</param>
/// <param name="scope">The driver's scope.</param>
/// <param name="field">Field label for messages.</param>
/// <param name="literals">The field's literal policy.</param>
/// <param name="secretLookup">Secret-store lookup; null ⇒ a <c>secret:</c> reference throws.</param>
/// <param name="environment">Environment reader; null ⇒ the process environment.</param>
/// <param name="ct">Cancellation.</param>
/// <returns>The value (<c>""</c> for blank).</returns>
public static async ValueTask<string> ResolveAsync(
string? value,
CredentialScope scope,
string field,
CredentialLiteralPolicy literals,
Func<string, CancellationToken, ValueTask<string?>>? secretLookup,
Func<string, string?>? 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).");
}
/// <summary>A print-safe form of a value: the reference itself, or a redaction for a literal / dev value.</summary>
/// <param name="value">The authored value.</param>
/// <returns>A string safe to log.</returns>
public static string Describe(string? value)
{
if (string.IsNullOrEmpty(value)) return "<blank>";
var v = value.Trim();
if (v.StartsWith(DevPrefix, StringComparison.OrdinalIgnoreCase)) return "dev:<redacted>";
return v.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase)
|| v.StartsWith(TokenSecretPrefix, StringComparison.OrdinalIgnoreCase)
|| v.StartsWith(EnvPrefix, StringComparison.OrdinalIgnoreCase)
|| v.StartsWith(FilePrefix, StringComparison.OrdinalIgnoreCase)
? v
: "<literal redacted>";
}
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;
}
}