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; } }