diff --git a/docs/v2/lmx-followups.md b/docs/v2/lmx-followups.md index 878e681..47c729f 100644 --- a/docs/v2/lmx-followups.md +++ b/docs/v2/lmx-followups.md @@ -41,17 +41,22 @@ can't write a `Tune` attribute unless it also carries `WriteTune`. See `feedback_acl_at_server_layer.md` in memory for the architectural directive that authz stays at the server layer and never delegates to driver-specific auth. -## 3. Admin UI client-cert trust management +## 3. Admin UI client-cert trust management — **DONE (PR 28)** -**Status**: Server side auto-accepts untrusted client certs when the -`AutoAcceptUntrustedClientCertificates` option is true (dev default). -Production deployments want operator-controlled trust via the Admin UI. +PR 28 shipped `/certificates` in the Admin UI. `CertTrustService` reads the OPC +UA server's PKI store root (`OpcUaServerOptions.PkiStoreRoot` — default +`%ProgramData%\OtOpcUa\pki`) and lists rejected + trusted certs by parsing the +`.der` files directly, so it has no `Opc.Ua` dependency and runs on any +Admin host that can reach the shared PKI directory. -**To do**: -- Surface the server's rejected-certificate store in the Admin UI. -- Page to move certs between `rejected` / `trusted`. -- Flip `AutoAcceptUntrustedClientCertificates` to false once Admin UI is the - trust gate. +Operator actions: Trust (moves `rejected/certs/*.der` → `trusted/certs/*.der`), +Delete rejected, Revoke trust. The OPC UA stack re-reads the trusted store on +each new client handshake, so no explicit reload signal is needed — +operators retry the rejected client's connection after trusting. + +Deferred: flipping `AutoAcceptUntrustedClientCertificates` to `false` as the +deployment default. That's a production-hardening config change, not a code +gap — the Admin UI is now ready to be the trust gate. ## 4. Live-LDAP integration test diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor index 922da9c..f93b85f 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor @@ -8,6 +8,7 @@ +
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor new file mode 100644 index 0000000..8a2ee6b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor @@ -0,0 +1,154 @@ +@page "/certificates" +@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = AdminRoles.FleetAdmin)] +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@inject CertTrustService Certs +@inject AuthenticationStateProvider AuthState +@inject ILogger Log + +

Certificate trust

+ +
+ PKI store root @Certs.PkiStoreRoot. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake, so operators should retry the rejected client's connection after trusting. +
+ +@if (_status is not null) +{ +
+ @_status + +
+} + +

Rejected (@_rejected.Count)

+@if (_rejected.Count == 0) +{ +

No rejected certificates. Clients that fail to handshake with an untrusted cert land here.

+} +else +{ + + + + @foreach (var c in _rejected) + { + + + + + + + + } + +
SubjectIssuerThumbprintValidActions
@c.Subject@c.Issuer@c.Thumbprint@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd") + + +
+} + +

Trusted (@_trusted.Count)

+@if (_trusted.Count == 0) +{ +

No client certs have been explicitly trusted. The server's own application cert lives in own/ and is not listed here.

+} +else +{ + + + + @foreach (var c in _trusted) + { + + + + + + + + } + +
SubjectIssuerThumbprintValidActions
@c.Subject@c.Issuer@c.Thumbprint@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd") + +
+} + +@code { + private IReadOnlyList _rejected = []; + private IReadOnlyList _trusted = []; + private string? _status; + private string _statusKind = "success"; + + protected override void OnInitialized() => Reload(); + + private void Reload() + { + _rejected = Certs.ListRejected(); + _trusted = Certs.ListTrusted(); + } + + private async Task TrustAsync(CertInfo c) + { + if (Certs.TrustRejected(c.Thumbprint)) + { + await LogActionAsync("cert.trust", c); + Set($"Trusted cert {c.Subject} ({Short(c.Thumbprint)}).", "success"); + } + else + { + Set($"Could not trust {Short(c.Thumbprint)} — file missing; another admin may have already handled it.", "warning"); + } + Reload(); + } + + private async Task DeleteRejectedAsync(CertInfo c) + { + if (Certs.DeleteRejected(c.Thumbprint)) + { + await LogActionAsync("cert.delete.rejected", c); + Set($"Deleted rejected cert {c.Subject} ({Short(c.Thumbprint)}).", "success"); + } + else + { + Set($"Could not delete {Short(c.Thumbprint)} — file missing.", "warning"); + } + Reload(); + } + + private async Task UntrustAsync(CertInfo c) + { + if (Certs.UntrustCert(c.Thumbprint)) + { + await LogActionAsync("cert.untrust", c); + Set($"Revoked trust for {c.Subject} ({Short(c.Thumbprint)}).", "success"); + } + else + { + Set($"Could not revoke {Short(c.Thumbprint)} — file missing.", "warning"); + } + Reload(); + } + + private async Task LogActionAsync(string action, CertInfo c) + { + // Cert trust changes are operator-initiated and security-sensitive — Serilog captures the + // user + thumbprint trail. CertTrustService also logs at Information on each filesystem + // move/delete; this line ties the action to the authenticated admin user so the two logs + // correlate. DB-level ConfigAuditLog persistence is deferred — its schema is + // cluster-scoped and cert actions are cluster-agnostic. + var state = await AuthState.GetAuthenticationStateAsync(); + var user = state.User.Identity?.Name ?? "(anonymous)"; + Log.LogInformation("Admin cert action: user={User} action={Action} thumbprint={Thumbprint} subject={Subject}", + user, action, c.Thumbprint, c.Subject); + } + + private void Set(string message, string kind) + { + _status = message; + _statusKind = kind; + } + + private void ClearStatus() => _status = null; + + private static string Short(string thumbprint) => + thumbprint.Length > 12 ? thumbprint[..12] + "…" : thumbprint; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs index 0e37fa3..00c7c25 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs @@ -48,6 +48,12 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs +// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just +// filesystem operations. +builder.Services.Configure(builder.Configuration.GetSection(CertTrustOptions.SectionName)); +builder.Services.AddSingleton(); + // LDAP auth — parity with ScadaLink's LdapAuthService (decision #102). builder.Services.Configure( builder.Configuration.GetSection("Authentication:Ldap")); diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustOptions.cs new file mode 100644 index 0000000..b1f3072 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustOptions.cs @@ -0,0 +1,22 @@ +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +/// +/// Points the Admin UI at the OPC UA Server's PKI store root so +/// can list and move certs between the +/// rejected/ and trusted/ directories the server maintains. Must match the +/// OpcUaServer:PkiStoreRoot the Server process is configured with. +/// +public sealed class CertTrustOptions +{ + public const string SectionName = "CertTrust"; + + /// + /// Absolute path to the PKI root. Defaults to + /// %ProgramData%\OtOpcUa\pki — matches OpcUaServerOptions.PkiStoreRoot's + /// default so a standard side-by-side install needs no override. + /// + public string PkiStoreRoot { get; init; } = + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "OtOpcUa", "pki"); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustService.cs new file mode 100644 index 0000000..016852b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustService.cs @@ -0,0 +1,135 @@ +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +/// +/// Metadata for a certificate file found in one of the OPC UA server's PKI stores. The +/// is the absolute path of the DER/CRT file the stack created when it +/// rejected the cert (for ) or when an operator trusted +/// it (for ). +/// +public sealed record CertInfo( + string Thumbprint, + string Subject, + string Issuer, + DateTime NotBefore, + DateTime NotAfter, + string FilePath, + CertStoreKind Store); + +public enum CertStoreKind +{ + Rejected, + Trusted, +} + +/// +/// Filesystem-backed view over the OPC UA server's PKI store. The Opc.Ua stack uses a +/// Directory-typed store — each cert is a .der file under {root}/{store}/certs/ +/// 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). +/// +/// +/// 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. +/// +public sealed class CertTrustService +{ + private readonly CertTrustOptions _options; + private readonly ILogger _logger; + + public CertTrustService(IOptions options, ILogger logger) + { + _options = options.Value; + _logger = logger; + } + + public string PkiStoreRoot => _options.PkiStoreRoot; + + public IReadOnlyList ListRejected() => ListStore(CertStoreKind.Rejected); + public IReadOnlyList ListTrusted() => ListStore(CertStoreKind.Trusted); + + /// + /// Move the cert with 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. + /// + 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 ListStore(CertStoreKind store) + { + var dir = CertsDir(store); + if (!Directory.Exists(dir)) return []; + + var results = new List(); + 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"); +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/CertTrustServiceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/CertTrustServiceTests.cs new file mode 100644 index 0000000..a7a1fea --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/CertTrustServiceTests.cs @@ -0,0 +1,153 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Admin.Services; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; + +[Trait("Category", "Unit")] +public sealed class CertTrustServiceTests : IDisposable +{ + private readonly string _root; + + public CertTrustServiceTests() + { + _root = Path.Combine(Path.GetTempPath(), $"otopcua-cert-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Path.Combine(_root, "rejected", "certs")); + Directory.CreateDirectory(Path.Combine(_root, "trusted", "certs")); + } + + public void Dispose() + { + if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); + } + + private CertTrustService Service() => new( + Options.Create(new CertTrustOptions { PkiStoreRoot = _root }), + NullLogger.Instance); + + private X509Certificate2 WriteTestCert(CertStoreKind kind, string subject) + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest($"CN={subject}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); + var dir = Path.Combine(_root, kind == CertStoreKind.Rejected ? "rejected" : "trusted", "certs"); + var path = Path.Combine(dir, $"{subject} [{cert.Thumbprint}].der"); + File.WriteAllBytes(path, cert.Export(X509ContentType.Cert)); + return cert; + } + + [Fact] + public void ListRejected_returns_parsed_cert_info_for_each_der_in_rejected_certs_dir() + { + var c = WriteTestCert(CertStoreKind.Rejected, "test-client-A"); + + var rows = Service().ListRejected(); + + rows.Count.ShouldBe(1); + rows[0].Thumbprint.ShouldBe(c.Thumbprint); + rows[0].Subject.ShouldContain("test-client-A"); + rows[0].Store.ShouldBe(CertStoreKind.Rejected); + } + + [Fact] + public void ListTrusted_is_separate_from_rejected() + { + WriteTestCert(CertStoreKind.Rejected, "rej"); + WriteTestCert(CertStoreKind.Trusted, "trust"); + + var svc = Service(); + svc.ListRejected().Count.ShouldBe(1); + svc.ListTrusted().Count.ShouldBe(1); + svc.ListRejected()[0].Subject.ShouldContain("rej"); + svc.ListTrusted()[0].Subject.ShouldContain("trust"); + } + + [Fact] + public void TrustRejected_moves_file_from_rejected_to_trusted() + { + var c = WriteTestCert(CertStoreKind.Rejected, "promoteme"); + var svc = Service(); + + svc.TrustRejected(c.Thumbprint).ShouldBeTrue(); + + svc.ListRejected().ShouldBeEmpty(); + var trusted = svc.ListTrusted(); + trusted.Count.ShouldBe(1); + trusted[0].Thumbprint.ShouldBe(c.Thumbprint); + } + + [Fact] + public void TrustRejected_returns_false_when_thumbprint_not_in_rejected() + { + var svc = Service(); + svc.TrustRejected("00DEADBEEF00DEADBEEF00DEADBEEF00DEADBEEF").ShouldBeFalse(); + } + + [Fact] + public void DeleteRejected_removes_the_file() + { + var c = WriteTestCert(CertStoreKind.Rejected, "killme"); + var svc = Service(); + + svc.DeleteRejected(c.Thumbprint).ShouldBeTrue(); + svc.ListRejected().ShouldBeEmpty(); + } + + [Fact] + public void UntrustCert_removes_from_trusted_only() + { + var c = WriteTestCert(CertStoreKind.Trusted, "revoke"); + var svc = Service(); + + svc.UntrustCert(c.Thumbprint).ShouldBeTrue(); + svc.ListTrusted().ShouldBeEmpty(); + } + + [Fact] + public void Thumbprint_match_is_case_insensitive() + { + var c = WriteTestCert(CertStoreKind.Rejected, "case"); + var svc = Service(); + + // X509Certificate2.Thumbprint is upper-case hex; operators pasting from logs often + // lowercase it. IsAllowed-style case-insensitive match keeps the UX forgiving. + svc.TrustRejected(c.Thumbprint.ToLowerInvariant()).ShouldBeTrue(); + } + + [Fact] + public void Missing_store_directories_produce_empty_lists_not_exceptions() + { + // Fresh root with no certs subfolder — service should tolerate a pristine install. + var altRoot = Path.Combine(Path.GetTempPath(), $"otopcua-cert-empty-{Guid.NewGuid():N}"); + try + { + var svc = new CertTrustService( + Options.Create(new CertTrustOptions { PkiStoreRoot = altRoot }), + NullLogger.Instance); + svc.ListRejected().ShouldBeEmpty(); + svc.ListTrusted().ShouldBeEmpty(); + } + finally + { + if (Directory.Exists(altRoot)) Directory.Delete(altRoot, recursive: true); + } + } + + [Fact] + public void Malformed_file_is_skipped_not_fatal() + { + // Drop junk bytes that don't parse as a cert into the rejected/certs directory. The + // service must skip it and still return the valid certs — one bad file can't take the + // whole management page offline. + File.WriteAllText(Path.Combine(_root, "rejected", "certs", "junk.der"), "not a cert"); + var c = WriteTestCert(CertStoreKind.Rejected, "valid"); + + var rows = Service().ListRejected(); + rows.Count.ShouldBe(1); + rows[0].Thumbprint.ShouldBe(c.Thumbprint); + } +}