190 lines
7.9 KiB
C#
190 lines
7.9 KiB
C#
using System.Security.Cryptography.X509Certificates;
|
|
using Akka.Actor;
|
|
using Akka.Event;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
|
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
|
|
|
/// <summary>
|
|
/// Per-node OPC UA certificate-store actor (T17 / D6). Runs on EVERY site
|
|
/// node (NOT a singleton) at a well-known name so the Deployment Manager
|
|
/// singleton can address it on each node via <see cref="ActorSelection"/>.
|
|
///
|
|
/// Cert trust is site-local: the trusted-peer PKI store is a directory on each
|
|
/// node's file system. A trust/remove decision must therefore reach BOTH site
|
|
/// nodes or the two stores diverge across failover (D6). The singleton fans the
|
|
/// per-node <see cref="WriteCertToLocalStore"/> / <see cref="RemoveCertFromLocalStore"/>
|
|
/// message out; this actor performs the actual file write/delete/enumerate.
|
|
///
|
|
/// NOTE on the script-trust forbidden-IO rule: that rule applies only to USER
|
|
/// scripts. This is a framework/DCL actor doing legitimate PKI store file I/O,
|
|
/// exactly as the underlying OPC UA stack does when it persists rejected certs.
|
|
/// </summary>
|
|
public class CertStoreActor : ReceiveActor
|
|
{
|
|
/// <summary>
|
|
/// Well-known actor name. The Deployment Manager singleton addresses each
|
|
/// site node's instance via <c>{member.Address}/user/{WellKnownName}</c>.
|
|
/// </summary>
|
|
public const string WellKnownName = "cert-store";
|
|
|
|
private readonly ILoggingAdapter _log = Context.GetLogger();
|
|
private readonly string _trustedStoreDir;
|
|
private readonly string _rejectedStoreDir;
|
|
|
|
/// <summary>
|
|
/// Initializes the actor with the deployment-wide OPC UA options used to
|
|
/// resolve the trusted-peer and rejected store directories.
|
|
/// </summary>
|
|
/// <param name="opcUaGlobalOptions">Deployment-wide OPC UA options (store paths).</param>
|
|
public CertStoreActor(OpcUaGlobalOptions opcUaGlobalOptions)
|
|
{
|
|
// Resolve store directories with the SAME fallback logic
|
|
// RealOpcUaClient.ResolveStorePath uses, so a cert trusted here lands in
|
|
// the exact store the OPC UA client validates against. That helper is a
|
|
// private static method on RealOpcUaClient; the tiny logic is replicated
|
|
// here rather than widening its visibility.
|
|
_trustedStoreDir = ResolveStorePath(opcUaGlobalOptions.TrustedPeerStorePath, "trusted");
|
|
_rejectedStoreDir = ResolveStorePath(opcUaGlobalOptions.RejectedCertificateStorePath, "rejected");
|
|
|
|
Receive<WriteCertToLocalStore>(HandleWrite);
|
|
Receive<RemoveCertFromLocalStore>(HandleRemove);
|
|
Receive<ListLocalCerts>(_ => HandleList());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replicates <c>RealOpcUaClient.ResolveStorePath</c>: an empty configured
|
|
/// path falls back to <c>%TEMP%/ScadaBridge/pki/<leaf></c>.
|
|
/// </summary>
|
|
private static string ResolveStorePath(string configured, string fallbackLeaf) =>
|
|
string.IsNullOrWhiteSpace(configured)
|
|
? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf)
|
|
: configured;
|
|
|
|
private void HandleWrite(WriteCertToLocalStore msg)
|
|
{
|
|
if (!IsSafeThumbprint(msg.Thumbprint))
|
|
{
|
|
_log.Warning("Rejecting write for invalid thumbprint {Thumbprint}", msg.Thumbprint);
|
|
Sender.Tell(new LocalCertOpAck(false, "invalid thumbprint", null));
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
Directory.CreateDirectory(_trustedStoreDir);
|
|
var der = Convert.FromBase64String(msg.DerBase64);
|
|
var path = Path.Combine(_trustedStoreDir, FileNameFor(msg.Thumbprint));
|
|
File.WriteAllBytes(path, der);
|
|
_log.Info("Trusted server certificate {Thumbprint} written to {Path}", msg.Thumbprint, path);
|
|
Sender.Tell(new LocalCertOpAck(true, null, null));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.Warning(ex, "Failed to write trusted server certificate {Thumbprint}", msg.Thumbprint);
|
|
Sender.Tell(new LocalCertOpAck(false, ex.Message, null));
|
|
}
|
|
}
|
|
|
|
private void HandleRemove(RemoveCertFromLocalStore msg)
|
|
{
|
|
if (!IsSafeThumbprint(msg.Thumbprint))
|
|
{
|
|
_log.Warning("Rejecting remove for invalid thumbprint {Thumbprint}", msg.Thumbprint);
|
|
Sender.Tell(new LocalCertOpAck(false, "invalid thumbprint", null));
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var path = Path.Combine(_trustedStoreDir, FileNameFor(msg.Thumbprint));
|
|
if (File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
_log.Info("Trusted server certificate {Thumbprint} removed from {Path}", msg.Thumbprint, path);
|
|
}
|
|
else
|
|
{
|
|
_log.Info("Trusted server certificate {Thumbprint} not present; remove is a no-op", msg.Thumbprint);
|
|
}
|
|
Sender.Tell(new LocalCertOpAck(true, null, null));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.Warning(ex, "Failed to remove trusted server certificate {Thumbprint}", msg.Thumbprint);
|
|
Sender.Tell(new LocalCertOpAck(false, ex.Message, null));
|
|
}
|
|
}
|
|
|
|
private void HandleList()
|
|
{
|
|
try
|
|
{
|
|
var certs = new List<TrustedCertInfo>();
|
|
certs.AddRange(EnumerateStore(_trustedStoreDir, rejected: false));
|
|
certs.AddRange(EnumerateStore(_rejectedStoreDir, rejected: true));
|
|
Sender.Tell(new LocalCertOpAck(true, null, certs));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.Warning(ex, "Failed to list certificates from PKI stores");
|
|
Sender.Tell(new LocalCertOpAck(false, ex.Message, null));
|
|
}
|
|
}
|
|
|
|
private IEnumerable<TrustedCertInfo> EnumerateStore(string storeDir, bool rejected)
|
|
{
|
|
if (!Directory.Exists(storeDir))
|
|
{
|
|
yield break;
|
|
}
|
|
|
|
foreach (var file in Directory.EnumerateFiles(storeDir)
|
|
.Where(f => f.EndsWith(".der", StringComparison.OrdinalIgnoreCase)
|
|
|| f.EndsWith(".crt", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
TrustedCertInfo? info = null;
|
|
try
|
|
{
|
|
// The TrustedCertInfo projection copies all needed strings/dates
|
|
// out before this scope ends, so disposing the cert is safe and
|
|
// releases the native handle held per loaded certificate.
|
|
using var cert = X509CertificateLoader.LoadCertificate(File.ReadAllBytes(file));
|
|
info = new TrustedCertInfo(
|
|
cert.Thumbprint,
|
|
cert.Subject,
|
|
cert.Issuer,
|
|
cert.NotBefore.ToUniversalTime(),
|
|
cert.NotAfter.ToUniversalTime(),
|
|
rejected);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// A malformed file in the store should not abort the whole listing.
|
|
_log.Warning(ex, "Skipping unreadable certificate file {File}", file);
|
|
}
|
|
|
|
if (info is not null)
|
|
{
|
|
yield return info;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string FileNameFor(string thumbprint) => $"{thumbprint}.der";
|
|
|
|
/// <summary>
|
|
/// Defense-in-depth path-traversal guard. In production a thumbprint is a
|
|
/// hex SHA1 (always safe), but <c>RemoveServerCertCommand</c> is CLI-exposed
|
|
/// and this actor accepts the string unchecked, so a thumbprint such as
|
|
/// <c>../../etc/foo</c> would otherwise resolve OUTSIDE the trusted-store
|
|
/// directory once combined into a <c>.der</c> file name. Rejects empty,
|
|
/// separator-bearing, or dot-dot thumbprints before any filesystem touch.
|
|
/// </summary>
|
|
private static bool IsSafeThumbprint(string thumbprint) =>
|
|
!string.IsNullOrWhiteSpace(thumbprint)
|
|
&& thumbprint.IndexOfAny(Path.GetInvalidFileNameChars()) < 0
|
|
&& !thumbprint.Contains("..", StringComparison.Ordinal);
|
|
}
|