Razor page layout: two tables (Rejected / Trusted) with Subject / Issuer / Thumbprint / Valid-window / Actions columns, status banner after each action with success or warning kind ('file missing' = another admin handled it), FleetAdmin-only via [Authorize(Roles=AdminRoles.FleetAdmin)]. Each action invokes LogActionAsync which Serilog-logs the authenticated admin user + thumbprint + action for an audit trail — DB-level ConfigAuditLog persistence is deferred because its schema is cluster-scoped and cert actions are cluster-agnostic; Serilog + CertTrustService's filesystem-op info logs give the forensic trail in the meantime. Sidebar link added to MainLayout between Reservations and the future Account page.
Tests — CertTrustServiceTests (9 new unit cases): ListRejected parses Subject + Thumbprint + store kind from a self-signed test cert written into rejected/certs/; rejected and trusted stores are kept separate; TrustRejected moves the file and the Rejected list is empty afterwards; TrustRejected with a thumbprint not in rejected returns false without touching trusted; DeleteRejected removes the file; UntrustCert removes from trusted only; thumbprint match is case-insensitive (operator UX); missing store directories produce empty lists instead of throwing DirectoryNotFoundException (pristine-install tolerance); a junk .der in the store is logged + skipped and the valid certs still surface (one bad file doesn't break the page). Full Admin.Tests Unit suite: 23 pass / 0 fail (14 prior + 9 new). Full Admin build clean — 0 errors, 0 warnings.
lmx-followups.md #3 marked DONE with a cross-reference to this PR and a note that flipping AutoAcceptUntrustedClientCertificates to false as the production default is a deployment-config follow-up, not a code gap — the Admin UI is now ready to be the trust gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
136 lines
5.8 KiB
C#
136 lines
5.8 KiB
C#
using System.Security.Cryptography.X509Certificates;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
|
|
|
/// <summary>
|
|
/// Metadata for a certificate file found in one of the OPC UA server's PKI stores. The
|
|
/// <see cref="FilePath"/> is the absolute path of the DER/CRT file the stack created when it
|
|
/// rejected the cert (for <see cref="CertStoreKind.Rejected"/>) or when an operator trusted
|
|
/// it (for <see cref="CertStoreKind.Trusted"/>).
|
|
/// </summary>
|
|
public sealed record CertInfo(
|
|
string Thumbprint,
|
|
string Subject,
|
|
string Issuer,
|
|
DateTime NotBefore,
|
|
DateTime NotAfter,
|
|
string FilePath,
|
|
CertStoreKind Store);
|
|
|
|
public enum CertStoreKind
|
|
{
|
|
Rejected,
|
|
Trusted,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Filesystem-backed view over the OPC UA server's PKI store. The Opc.Ua stack uses a
|
|
/// Directory-typed store — each cert is a <c>.der</c> file under <c>{root}/{store}/certs/</c>
|
|
/// with a filename derived from subject + thumbprint. This service exposes operators for the
|
|
/// Admin UI: list rejected, list trusted, trust a rejected cert (move to trusted), remove a
|
|
/// rejected cert (delete), untrust a previously trusted cert (delete from trusted).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The Admin process is separate from the Server process; this service deliberately has no
|
|
/// Opc.Ua dependency — it works on the on-disk layout directly so it can run on the Admin
|
|
/// host even when the Server isn't installed locally, as long as the PKI root is reachable
|
|
/// (typical deployment has Admin + Server side-by-side on the same machine).
|
|
///
|
|
/// Trust/untrust requires the Server to re-read its trust list. The Opc.Ua stack re-reads
|
|
/// the Directory store on each new incoming connection, so there's no explicit signal
|
|
/// needed — the next client handshake picks up the change. Operators should retry the
|
|
/// rejected client's connection after trusting.
|
|
/// </remarks>
|
|
public sealed class CertTrustService
|
|
{
|
|
private readonly CertTrustOptions _options;
|
|
private readonly ILogger<CertTrustService> _logger;
|
|
|
|
public CertTrustService(IOptions<CertTrustOptions> options, ILogger<CertTrustService> logger)
|
|
{
|
|
_options = options.Value;
|
|
_logger = logger;
|
|
}
|
|
|
|
public string PkiStoreRoot => _options.PkiStoreRoot;
|
|
|
|
public IReadOnlyList<CertInfo> ListRejected() => ListStore(CertStoreKind.Rejected);
|
|
public IReadOnlyList<CertInfo> ListTrusted() => ListStore(CertStoreKind.Trusted);
|
|
|
|
/// <summary>
|
|
/// Move the cert with <paramref name="thumbprint"/> from the rejected store to the
|
|
/// trusted store. No-op returns false if the rejected file doesn't exist (already moved
|
|
/// by another operator, or thumbprint mismatch). Overwrites an existing trusted copy
|
|
/// silently — idempotent.
|
|
/// </summary>
|
|
public bool TrustRejected(string thumbprint)
|
|
{
|
|
var cert = FindInStore(CertStoreKind.Rejected, thumbprint);
|
|
if (cert is null) return false;
|
|
|
|
var trustedDir = CertsDir(CertStoreKind.Trusted);
|
|
Directory.CreateDirectory(trustedDir);
|
|
var destPath = Path.Combine(trustedDir, Path.GetFileName(cert.FilePath));
|
|
File.Move(cert.FilePath, destPath, overwrite: true);
|
|
_logger.LogInformation("Trusted cert {Thumbprint} (subject={Subject}) — moved {From} → {To}",
|
|
cert.Thumbprint, cert.Subject, cert.FilePath, destPath);
|
|
return true;
|
|
}
|
|
|
|
public bool DeleteRejected(string thumbprint) => DeleteFromStore(CertStoreKind.Rejected, thumbprint);
|
|
public bool UntrustCert(string thumbprint) => DeleteFromStore(CertStoreKind.Trusted, thumbprint);
|
|
|
|
private bool DeleteFromStore(CertStoreKind store, string thumbprint)
|
|
{
|
|
var cert = FindInStore(store, thumbprint);
|
|
if (cert is null) return false;
|
|
File.Delete(cert.FilePath);
|
|
_logger.LogInformation("Deleted cert {Thumbprint} (subject={Subject}) from {Store} store",
|
|
cert.Thumbprint, cert.Subject, store);
|
|
return true;
|
|
}
|
|
|
|
private CertInfo? FindInStore(CertStoreKind store, string thumbprint) =>
|
|
ListStore(store).FirstOrDefault(c =>
|
|
string.Equals(c.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase));
|
|
|
|
private IReadOnlyList<CertInfo> ListStore(CertStoreKind store)
|
|
{
|
|
var dir = CertsDir(store);
|
|
if (!Directory.Exists(dir)) return [];
|
|
|
|
var results = new List<CertInfo>();
|
|
foreach (var path in Directory.EnumerateFiles(dir))
|
|
{
|
|
// Skip CRL sidecars + private-key files — trust operations only concern public certs.
|
|
var ext = Path.GetExtension(path);
|
|
if (!ext.Equals(".der", StringComparison.OrdinalIgnoreCase) &&
|
|
!ext.Equals(".crt", StringComparison.OrdinalIgnoreCase) &&
|
|
!ext.Equals(".cer", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
var cert = X509CertificateLoader.LoadCertificateFromFile(path);
|
|
results.Add(new CertInfo(
|
|
cert.Thumbprint, cert.Subject, cert.Issuer,
|
|
cert.NotBefore.ToUniversalTime(), cert.NotAfter.ToUniversalTime(),
|
|
path, store));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// A malformed file in the store shouldn't take down the page. Surface it in logs
|
|
// but skip — operators see the other certs and can clean the bad file manually.
|
|
_logger.LogWarning(ex, "Failed to parse cert at {Path} — skipping", path);
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
private string CertsDir(CertStoreKind store) =>
|
|
Path.Combine(_options.PkiStoreRoot, store == CertStoreKind.Rejected ? "rejected" : "trusted", "certs");
|
|
}
|