9cad9ed0fc
v2-ci / build (push) Failing after 41s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
Adds <summary>/<param>/<returns>/<inheritdoc> where missing and removes project bookkeeping IDs (task/tracking refs) from shipped code comments, so the docs read cleanly and CommentChecker is quiet except for known false positives (PLC/protocol terms, event/IEqualityComparer inheritdoc). Doc/comment-only; no logic changed; solution builds clean.
178 lines
8.1 KiB
C#
178 lines
8.1 KiB
C#
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using Microsoft.Extensions.Configuration;
|
|
using ZB.MOM.WW.Audit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Audit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Certificates;
|
|
|
|
/// <summary>Result of a certificate-store mutation; carries a friendly error instead of throwing.</summary>
|
|
public sealed record CertActionResult(bool Success, string? Error)
|
|
{
|
|
/// <summary>A successful result.</summary>
|
|
/// <returns>A <see cref="CertActionResult"/> indicating success.</returns>
|
|
public static CertActionResult Ok() => new(true, null);
|
|
/// <summary>A failed result carrying the given error message.</summary>
|
|
/// <param name="error">The friendly error message describing the failure.</param>
|
|
/// <returns>A <see cref="CertActionResult"/> indicating failure with <paramref name="error"/>.</returns>
|
|
public static CertActionResult Fail(string error) => new(false, error);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Trusts / untrusts / deletes peer certificates by moving or removing files in the
|
|
/// OPC UA server's PKI directory stores. The running server's DirectoryStore re-enumerates
|
|
/// <c>certs/</c> on each validation, so changes are honored live without a restart.
|
|
/// </summary>
|
|
public sealed class CertificateStoreManager
|
|
{
|
|
private static readonly string[] CertExtensions = [".der", ".cer", ".crt"];
|
|
private static readonly HashSet<string> MutableStores =
|
|
new(StringComparer.OrdinalIgnoreCase) { "trusted", "rejected" };
|
|
|
|
private readonly string _pkiRoot;
|
|
private readonly IAuditWriter _audit;
|
|
|
|
/// <summary>
|
|
/// The resolved PKI store root directory (e.g. <c>pki</c>). Derived once from
|
|
/// <c>OpcUa:PkiStoreRoot</c> in the production ctor; callers should read this property
|
|
/// rather than re-resolving the config key independently.
|
|
/// </summary>
|
|
public string PkiRoot => _pkiRoot;
|
|
|
|
/// <summary>Production ctor — reads <c>OpcUa:PkiStoreRoot</c> (default <c>pki</c>).</summary>
|
|
/// <param name="config">App configuration.</param>
|
|
/// <param name="audit">The audit writer that persists Trust/Untrust/Delete actions to <c>ConfigAuditLog</c>.</param>
|
|
public CertificateStoreManager(IConfiguration config, IAuditWriter audit)
|
|
{
|
|
_pkiRoot = config.GetValue<string?>("OpcUa:PkiStoreRoot") ?? "pki";
|
|
_audit = audit;
|
|
}
|
|
|
|
/// <summary>Test ctor — explicit PKI root.</summary>
|
|
/// <param name="pkiRoot">The PKI store root directory.</param>
|
|
/// <param name="audit">Optional audit writer; defaults to a no-op writer when omitted.</param>
|
|
internal CertificateStoreManager(string pkiRoot, IAuditWriter? audit = null)
|
|
{
|
|
_pkiRoot = pkiRoot;
|
|
_audit = audit ?? NoOpAuditWriter.Instance;
|
|
}
|
|
|
|
/// <summary>Moves a rejected peer cert into the trusted store.</summary>
|
|
/// <param name="thumbprint">The cert thumbprint.</param>
|
|
/// <param name="actor">The identity performing the action; recorded in the audit log.</param>
|
|
/// <returns>The action result.</returns>
|
|
public CertActionResult Trust(string thumbprint, string actor)
|
|
{
|
|
var r = Move("rejected", "trusted", thumbprint);
|
|
Audit("Trust", "rejected→trusted", thumbprint, actor, r);
|
|
return r;
|
|
}
|
|
|
|
/// <summary>Moves a trusted peer cert back into the rejected store.</summary>
|
|
/// <param name="thumbprint">The cert thumbprint.</param>
|
|
/// <param name="actor">The identity performing the action; recorded in the audit log.</param>
|
|
/// <returns>The action result.</returns>
|
|
public CertActionResult Untrust(string thumbprint, string actor)
|
|
{
|
|
var r = Move("trusted", "rejected", thumbprint);
|
|
Audit("Untrust", "trusted→rejected", thumbprint, actor, r);
|
|
return r;
|
|
}
|
|
|
|
/// <summary>Deletes a cert from the named mutable store (<c>trusted</c> or <c>rejected</c>).</summary>
|
|
/// <param name="store">The store name.</param>
|
|
/// <param name="thumbprint">The cert thumbprint.</param>
|
|
/// <param name="actor">The identity performing the action; recorded in the audit log.</param>
|
|
/// <returns>The action result.</returns>
|
|
public CertActionResult Delete(string store, string thumbprint, string actor)
|
|
{
|
|
var r = DeleteCore(store, thumbprint);
|
|
Audit("Delete", store, thumbprint, actor, r);
|
|
return r;
|
|
}
|
|
|
|
private CertActionResult DeleteCore(string store, string thumbprint)
|
|
{
|
|
if (!MutableStores.Contains(store)) return CertActionResult.Fail($"unknown store '{store}'");
|
|
if (!IsValidThumbprint(thumbprint)) return CertActionResult.Fail("invalid thumbprint");
|
|
try
|
|
{
|
|
var file = FindByThumbprint(CertsDir(store), thumbprint);
|
|
if (file is null) return CertActionResult.Fail($"certificate not found in {store}");
|
|
File.Delete(file);
|
|
return CertActionResult.Ok();
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
|
{
|
|
return CertActionResult.Fail(ex.Message);
|
|
}
|
|
}
|
|
|
|
// Fire-and-forget: the writer enqueues synchronously and returns a completed task, so the
|
|
// store mutation stays synchronous. Every return path (success + every failure) is audited
|
|
// because Trust/Untrust/Delete each route their single result through here.
|
|
private void Audit(string action, string store, string thumbprint, string actor, CertActionResult r)
|
|
=> _ = _audit.WriteAsync(CertAuditEvents.Build(action, store, thumbprint, actor, r.Success, r.Error));
|
|
|
|
private CertActionResult Move(string fromSub, string toSub, string thumbprint)
|
|
{
|
|
if (!IsValidThumbprint(thumbprint)) return CertActionResult.Fail("invalid thumbprint");
|
|
try
|
|
{
|
|
var src = FindByThumbprint(CertsDir(fromSub), thumbprint);
|
|
if (src is null) return CertActionResult.Fail($"certificate not found in {fromSub}");
|
|
|
|
var destDir = CertsDir(toSub);
|
|
Directory.CreateDirectory(destDir);
|
|
|
|
// Idempotent: if dest already holds this thumbprint, just drop the source.
|
|
if (FindByThumbprint(destDir, thumbprint) is not null)
|
|
{
|
|
File.Delete(src);
|
|
return CertActionResult.Ok();
|
|
}
|
|
|
|
var dest = Path.Combine(destDir, Path.GetFileName(src));
|
|
if (File.Exists(dest))
|
|
dest = Path.Combine(destDir,
|
|
$"{Path.GetFileNameWithoutExtension(src)}_{thumbprint}{Path.GetExtension(src)}");
|
|
// Narrow TOCTOU window: if a concurrent admin action created `dest` between the
|
|
// check above and here, File.Move throws — we surface that as a Fail (no data loss,
|
|
// no overwrite of a different cert). Cert-store edits are rare manual operations.
|
|
File.Move(src, dest);
|
|
return CertActionResult.Ok();
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
|
{
|
|
return CertActionResult.Fail(ex.Message);
|
|
}
|
|
}
|
|
|
|
private string CertsDir(string sub) => Path.Combine(_pkiRoot, sub, "certs");
|
|
|
|
private static string? FindByThumbprint(string certsDir, string thumbprint)
|
|
{
|
|
if (!Directory.Exists(certsDir)) return null;
|
|
foreach (var file in Directory.EnumerateFiles(certsDir))
|
|
{
|
|
if (!CertExtensions.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase)) continue;
|
|
try
|
|
{
|
|
using var cert = X509CertificateLoader.LoadCertificateFromFile(file);
|
|
if (string.Equals(cert.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase))
|
|
return file;
|
|
}
|
|
catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException)
|
|
{
|
|
/* ignore unreadable/corrupt entries — a bad DER must not abort enumeration */
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static bool IsValidThumbprint(string thumbprint) =>
|
|
!string.IsNullOrEmpty(thumbprint)
|
|
&& (thumbprint.Length == 40 || thumbprint.Length == 64)
|
|
&& thumbprint.All(char.IsAsciiHexDigit);
|
|
}
|