9.9 KiB
AdminUI Certificate Store Actions — Design
Date: 2026-06-18 Status: Approved Backlog item: AdminUI Certificates page actions (reconciled ranked-OPEN list)
Goal
Turn the read-only /certificates AdminUI page into an operator surface that can
trust, untrust, and delete peer certificates in the OPC UA server's
PKI directory stores. Changes are honored live by the running server's
certificate validator — no restart.
Background — current state
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor
(@page "/certificates", [Authorize], @rendermode InteractiveServer)
reads four PKI directory stores directly from the filesystem in
OnInitialized() and renders a table per store (subject / issuer / thumbprint /
validity). It is display-only — zero action buttons, no backend service.
The four stores live under {OpcUa:PkiStoreRoot} (default pki), each with a
certs/ subdir holding .der/.cer/.crt files:
| Store label | Subdir | Actionable? |
|---|---|---|
| Own | own/certs |
no (read-only) |
| Trusted peers | trusted/certs |
yes |
| Trusted issuers | issuer/certs |
no (read-only) |
| Rejected | rejected/certs |
yes |
The running server (OpcUaApplicationHost.BuildConfigurationAsync) points its
SecurityConfiguration trust lists (TrustedPeerCertificates,
RejectedCertificateStore, …) at these same on-disk paths. The SDK's
DirectoryStore re-enumerates certs/ on each validation, so a file moved into
trusted/certs is trusted live, and one removed is no longer trusted —
no process restart required.
Action set (approved)
Symmetric trust/untrust + delete:
- Rejected rows:
[Trust](→trusted) and[Delete]. - Trusted rows:
[Untrust](→rejected) and[Delete]. - Own / Issuer rows: unchanged (read-only).
Upload-to-trust is explicitly deferred (would add file-upload plumbing + an
upload approval gate; the primary operator workflow — approve a peer that already
tried to connect and landed in rejected — does not need it).
Architecture
Why an in-process service called directly (not a minimal-API endpoint)
A Blazor Server component cannot reliably dial its own HTTP endpoint
server-side behind Traefik (durable lesson: project_blazor_server_self_hubconnection).
The page already reads the stores via direct in-process filesystem access in
OnInitialized; the write actions follow the same seam — the component calls an
injected CertificateStoreManager directly, server-side, within the
authenticated circuit. No HttpClient self-dial, no new endpoint.
Why pure filesystem (not the OPC UA SDK store API)
A directory-store cert is just a .der/.cer/.crt file in {store}/certs/.
Trust = move the file rejected/certs → trusted/certs; delete = remove it. The
filesystem approach:
- adds no new dependency (the page already uses BCL
X509CertificateLoader); - is identical to the existing read path, so behavior is predictable;
- is trivially unit-testable on a temp directory;
- finds the file by enumerate-and-match-thumbprint — never by building a path from caller input — so there is no path-injection surface;
- is honored live because the SDK
DirectoryStorere-enumeratescerts/.
The SDK store API would add an async-store dependency for no behavioral gain on Directory stores.
Components
1. CertificateStoreManager (NEW)
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Certificates/CertificateStoreManager.cs
public sealed record CertActionResult(bool Success, string? Error)
{
public static CertActionResult Ok() => new(true, null);
public static CertActionResult Fail(string error) => new(false, error);
}
public sealed class CertificateStoreManager
{
private readonly string _pkiRoot;
public CertificateStoreManager(IConfiguration config)
=> _pkiRoot = config.GetValue<string?>("OpcUa:PkiStoreRoot") ?? "pki";
// test seam — explicit root
internal CertificateStoreManager(string pkiRoot) => _pkiRoot = pkiRoot;
public CertActionResult Trust(string thumbprint) => Move("rejected", "trusted", thumbprint);
public CertActionResult Untrust(string thumbprint) => Move("trusted", "rejected", thumbprint);
public CertActionResult Delete(string store, string thumbprint) { … }
private CertActionResult Move(string fromSub, string toSub, string thumbprint) { … }
}
- Operations are synchronous filesystem moves/deletes (fast, local). Returning
CertActionResultkeeps the UI exception-free. Delete(store, …)accepts only"trusted"or"rejected"(elseFail("unknown store")).- Thumbprint is validated as hex of length 40 (SHA-1) or 64 (SHA-256) before use — a cheap guard; path safety comes from enumerate-and-match, not from validation.
Move: validate thumbprint → enumerate{root}/{fromSub}/certs/*.{der,cer,crt}, load each, match.Thumbprint(case-insensitive); not found →Fail("certificate not found in {fromSub}"). Ensure{root}/{toSub}/certsexists;File.Move(src, dest)preserving the SDK filename, with a thumbprint suffix on name collision. If a cert with the same thumbprint already exists in the destination, the source is removed and the op is idempotent success.- All
IOException/UnauthorizedAccessExceptioncaught →Fail(ex.Message).
2. Certificates.razor (MODIFY)
- Tag each
StoreViewwith aCertStoreKind(Own/Trusted/Issuer/Rejected) so the table knows which actions to render. - Refactor
OnInitialized→ a reusableLoadAll()(re-run after each action). - Add an Actions column rendered only for
Trusted/Rejected, wrapped in<AuthorizeView Policy="FleetAdmin">(Administrator role — the most-privileged existing policy, matching the ScriptAnalysis endpoints). Buttons set a_pendingaction record(kind, thumbprint, subject, verb). - Inline Blazor confirmation — a banner "Confirm {verb} of {subject}?
[Confirm] [Cancel]". Nowindow.confirm(a JS modal dialog would block browser-automation live-verify and is disallowed by the harness). - On
Confirm: call the injectedCertificateStoreManager, set a status banner (green success / red error),LoadAll(), clear_pending.
3. DI registration (MODIFY)
AddAdminUI(IServiceCollection) in EndpointRouteBuilderExtensions.cs:
services.AddSingleton<Certificates.CertificateStoreManager>(); (stateless bar
config → singleton).
Authorization
The page stays [Authorize] for viewing. The action buttons and handler are
gated by <AuthorizeView Policy="FleetAdmin"> (= RequireRole("Administrator")).
On the docker-dev rig (DisableLogin=true) the auto-admin satisfies the policy,
so the buttons render and the actions are live-provable.
Data flow
operator clicks [Trust] on a rejected cert
→ component sets _pending=(Rejected, thumbprint, subject, "trust")
→ operator clicks [Confirm]
→ component (server circuit) calls CertificateStoreManager.Trust(thumbprint)
→ File.Move rejected/certs/<f>.der → trusted/certs/<f>.der
→ live OPC UA CertificateValidator now enumerates the cert under trusted
→ component LoadAll() → cert now shows under "Trusted peers", gone from "Rejected"
Error handling
| Condition | Result |
|---|---|
| invalid/missing thumbprint | Fail → red banner, no change |
| cert not in source store (concurrent admin already moved it) | Fail("not found") |
| dest already has the thumbprint | source removed, idempotent success |
Delete with store ∉ {trusted, rejected} |
Fail("unknown store") |
IOException / access denied |
caught → Fail(message) |
Testing
xUnit + Shouldly in tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Certificates/,
driving CertificateStoreManager against a temp PkiStoreRoot seeded with
ephemeral self-signed DER certs (generated in-test via
CertificateRequest/X509Certificate2, written to rejected/certs):
Trustmoves rejected → trusted (file gone from source, present in dest).Untrustmoves trusted → rejected.Delete("rejected", …)/Delete("trusted", …)removes the file.- unknown thumbprint →
Fail, no change. - path-traversal thumbprint (
"../../x") → rejected by hex validation, nothing touched. Delete("own", …)(disallowed store) →Fail("unknown store").- idempotent re-trust (thumbprint already in trusted) →
Success, source removed.
No bUnit — the Razor changes are proven only by live /run.
Live-verify (/run)
docker-dev rig (login disabled → auto-admin). Seed a DER into a central node's
rejected/certs/, open http://localhost:9200/certificates, click [Trust] →
[Confirm], verify the cert appears under Trusted peers and is gone from
Rejected; click [Delete] on a cert, verify removal.
Rig caveat (durable): :9200 is Traefik-round-robined across central-1 and
central-2, and each node has its own on-disk PKI dir. Pin the verify to a
single node — seed the rejected cert into one container and drive that same
container — so the read and the write line up. (docker exec into the chosen
central container to seed + confirm the file move on disk.)
Constraints honored
No EF migration, no Commons/proto/wire change, no bUnit. Stage by explicit path
(never git add .); never stage the never-stage files. Finish = merge to master
- push.
Touched code
- NEW
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Certificates/CertificateStoreManager.cs - MODIFY
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Certificates.razor - MODIFY
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs(DI) - NEW
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Certificates/CertificateStoreManagerTests.cs - (optional) one-line note in
docs/security.mdcert-store section