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