232 lines
9.7 KiB
C#
232 lines
9.7 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. 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. 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());
|
|
Receive<ExportLocalTrustedCerts>(_ => HandleExport());
|
|
}
|
|
|
|
/// <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));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exports the trusted-peer store (thumbprint + raw DER) so the Deployment
|
|
/// Manager singleton can reconcile the trusted set onto a (re)joining node.
|
|
/// Trusted store only — rejected certs are never pushed to peers.
|
|
/// </summary>
|
|
private void HandleExport()
|
|
{
|
|
try
|
|
{
|
|
var certs = new List<ExportedCert>();
|
|
if (Directory.Exists(_trustedStoreDir))
|
|
{
|
|
foreach (var file in Directory.EnumerateFiles(_trustedStoreDir)
|
|
.Where(f => f.EndsWith(".der", StringComparison.OrdinalIgnoreCase)
|
|
|| f.EndsWith(".crt", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
try
|
|
{
|
|
var der = File.ReadAllBytes(file);
|
|
// Load only to derive the canonical thumbprint (the store
|
|
// filename); the DER bytes shipped are the raw file bytes.
|
|
using var cert = X509CertificateLoader.LoadCertificate(der);
|
|
certs.Add(new ExportedCert(cert.Thumbprint, der));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// A malformed file must not abort the whole export.
|
|
_log.Warning(ex, "Skipping unreadable certificate file {File} during export", file);
|
|
}
|
|
}
|
|
}
|
|
|
|
Sender.Tell(new LocalCertExport(true, null, certs));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.Warning(ex, "Failed to export trusted certificates from PKI store");
|
|
Sender.Tell(new LocalCertExport(false, ex.Message, Array.Empty<ExportedCert>()));
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|