ff8354478c
Sign/SignAndEncrypt sessions failed before activation with "ApplicationCertificate
for the security profile ... cannot be found" (BadConfigurationError): both
RealOpcUaClient.ConnectAsync and the verify probe built an empty CertificateIdentifier.
- New OpcUaApplicationIdentity builds the client ApplicationConfiguration for both
paths. Secured modes load the application certificate from a configurable own
store (subject + ApplicationUri), auto-creating a self-signed RSA-2048 cert only
when none exists. A persisted cert is reused across reconnect/restart and never
silently rotated: one that no longer validates (e.g. ApplicationUri mismatch)
fails closed. First-time creation is serialised per own store and logged at
Warning with the thumbprint. None mode is unchanged and never touches the store.
- New OpcUaGlobalOptions: ApplicationUri, ApplicationCertificateSubject,
OwnCertificateStorePath, AutoCreateApplicationCertificate,
ApplicationCertificateLifetimeMonths (validated).
- Owned resources: the private-key cert is disposed on disconnect, failed connect
and at probe end; sessions are now disposed after close, and a reconnect on the
same instance closes the previous session first.
- CertStoreActor wrote trusted server certs to the store ROOT, which the SDK never
reads (it uses {store}/certs), so an operator Trust never affected validation and
SDK-rejected certs never listed. It now uses certs/ and migrates legacy root files
on start.
- Rig: all site nodes (docker/, docker-env2/) put the four PKI stores on the
persistent /app/data volume with a pinned ApplicationUri.
- Tests: in-process strict-trust StandardServer (test-only Opc.Ua.Server 1.5.378.106)
exercising the real adapter through ConnectAsync and VerifyEndpointAsync for Sign
and SignAndEncrypt: allow + read + reconnect + restart on one identity, deny when
the client doesn't trust the server (BadCertificateUntrusted), deny when the server
doesn't trust the client (BadSecurityChecksFailed), None untouched, concurrent
provisioning creates one cert, URI mismatch fails without rotating, auto-create off.
Against the pre-fix client these tests fail with the exact #121 error.
237 lines
9.8 KiB
C#
237 lines
9.8 KiB
C#
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using FluentAssertions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
|
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
|
|
|
/// <summary>
|
|
/// T17 / D6: per-node CertStoreActor write/list/remove round-trip against a
|
|
/// temp trusted-peer PKI store. The cluster broadcast (singleton → every node's
|
|
/// CertStoreActor) is validated live at integration E1; here we cover the
|
|
/// testable per-node core.
|
|
/// </summary>
|
|
public class CertStoreActorTests : TestKit, IDisposable
|
|
{
|
|
private readonly string _trustedDir;
|
|
private readonly string _rejectedDir;
|
|
private readonly OpcUaGlobalOptions _options;
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="CertStoreActorTests"/> class.</summary>
|
|
public CertStoreActorTests()
|
|
{
|
|
var root = Path.Combine(Path.GetTempPath(), "ScadaBridge-CertStoreTests", Guid.NewGuid().ToString("N"));
|
|
_trustedDir = Path.Combine(root, "trusted");
|
|
_rejectedDir = Path.Combine(root, "rejected");
|
|
_options = new OpcUaGlobalOptions
|
|
{
|
|
TrustedPeerStorePath = _trustedDir,
|
|
RejectedCertificateStorePath = _rejectedDir,
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
void IDisposable.Dispose()
|
|
{
|
|
Shutdown();
|
|
TryDeleteDir(Path.GetDirectoryName(_trustedDir)!);
|
|
}
|
|
|
|
private static void TryDeleteDir(string dir)
|
|
{
|
|
try
|
|
{
|
|
if (Directory.Exists(dir))
|
|
{
|
|
Directory.Delete(dir, recursive: true);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Best-effort temp cleanup.
|
|
}
|
|
}
|
|
|
|
private static (string DerBase64, string Thumbprint) BuildSelfSignedCert()
|
|
{
|
|
using var rsa = RSA.Create(2048);
|
|
var req = new CertificateRequest(
|
|
"CN=ScadaBridge-CertStoreTest",
|
|
rsa,
|
|
HashAlgorithmName.SHA256,
|
|
RSASignaturePadding.Pkcs1);
|
|
using var cert = req.CreateSelfSigned(
|
|
DateTimeOffset.UtcNow.AddDays(-1),
|
|
DateTimeOffset.UtcNow.AddDays(365));
|
|
var der = cert.Export(X509ContentType.Cert);
|
|
return (Convert.ToBase64String(der), cert.Thumbprint);
|
|
}
|
|
|
|
/// <summary>Verifies the case: write, then List, then Remove, round Trips.</summary>
|
|
[Fact]
|
|
public void Write_ThenList_ThenRemove_RoundTrips()
|
|
{
|
|
var (derBase64, thumbprint) = BuildSelfSignedCert();
|
|
var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options)));
|
|
|
|
// Write
|
|
actor.Tell(new WriteCertToLocalStore(derBase64, thumbprint));
|
|
var writeAck = ExpectMsg<LocalCertOpAck>();
|
|
writeAck.Success.Should().BeTrue();
|
|
writeAck.Error.Should().BeNull();
|
|
|
|
// Gitea #121: the SDK's directory store reads {store}/certs — a DER in the root is inert.
|
|
var expectedFile = Path.Combine(_trustedDir, "certs", $"{thumbprint}.der");
|
|
File.Exists(expectedFile).Should().BeTrue("the cert is written where the OPC UA SDK validates against");
|
|
|
|
// List — the cert appears, not rejected
|
|
actor.Tell(new ListLocalCerts());
|
|
var listAck = ExpectMsg<LocalCertOpAck>();
|
|
listAck.Success.Should().BeTrue();
|
|
listAck.Certs.Should().NotBeNull();
|
|
listAck.Certs!.Should().ContainSingle(c => c.Thumbprint == thumbprint)
|
|
.Which.Rejected.Should().BeFalse();
|
|
|
|
// Remove — ack + file gone
|
|
actor.Tell(new RemoveCertFromLocalStore(thumbprint));
|
|
var removeAck = ExpectMsg<LocalCertOpAck>();
|
|
removeAck.Success.Should().BeTrue();
|
|
File.Exists(expectedFile).Should().BeFalse("the cert file is deleted from the trusted-peer store");
|
|
|
|
// List again — empty
|
|
actor.Tell(new ListLocalCerts());
|
|
var emptyAck = ExpectMsg<LocalCertOpAck>();
|
|
emptyAck.Success.Should().BeTrue();
|
|
emptyAck.Certs.Should().NotBeNull();
|
|
emptyAck.Certs!.Should().NotContain(c => c.Thumbprint == thumbprint);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gitea #121: a cert a pre-fix build trusted into the store ROOT is moved into certs/ when
|
|
/// the actor starts, so already-granted trust becomes effective on upgrade.
|
|
/// </summary>
|
|
[Fact]
|
|
public void LegacyRootTrustedCert_IsMovedIntoCertsOnStart()
|
|
{
|
|
var (derBase64, thumbprint) = BuildSelfSignedCert();
|
|
Directory.CreateDirectory(_trustedDir);
|
|
var legacy = Path.Combine(_trustedDir, $"{thumbprint}.der");
|
|
File.WriteAllBytes(legacy, Convert.FromBase64String(derBase64));
|
|
|
|
var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options)));
|
|
actor.Tell(new ListLocalCerts());
|
|
var listAck = ExpectMsg<LocalCertOpAck>();
|
|
|
|
File.Exists(legacy).Should().BeFalse();
|
|
File.Exists(Path.Combine(_trustedDir, "certs", $"{thumbprint}.der")).Should().BeTrue();
|
|
listAck.Certs!.Should().ContainSingle(c => c.Thumbprint == thumbprint).Which.Rejected.Should().BeFalse();
|
|
}
|
|
|
|
/// <summary>Gitea #121: certificates the SDK rejected (written to rejected/certs) are listed as rejected.</summary>
|
|
[Fact]
|
|
public void SdkRejectedCert_IsListedAsRejected()
|
|
{
|
|
var (derBase64, thumbprint) = BuildSelfSignedCert();
|
|
var rejectedCerts = Path.Combine(_rejectedDir, "certs");
|
|
Directory.CreateDirectory(rejectedCerts);
|
|
File.WriteAllBytes(Path.Combine(rejectedCerts, $"{thumbprint}.der"), Convert.FromBase64String(derBase64));
|
|
|
|
var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options)));
|
|
actor.Tell(new ListLocalCerts());
|
|
|
|
ExpectMsg<LocalCertOpAck>().Certs!.Should().ContainSingle(c => c.Thumbprint == thumbprint)
|
|
.Which.Rejected.Should().BeTrue();
|
|
}
|
|
|
|
/// <summary>Verifies the case: export Local Trusted Certs, returns Thumbprint And Der Bytes.</summary>
|
|
[Fact]
|
|
public void ExportLocalTrustedCerts_ReturnsThumbprintAndDerBytes()
|
|
{
|
|
var (derBase64, thumbprint) = BuildSelfSignedCert();
|
|
var expectedDer = Convert.FromBase64String(derBase64);
|
|
var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options)));
|
|
|
|
// Seed one trusted cert via the normal write path.
|
|
actor.Tell(new WriteCertToLocalStore(derBase64, thumbprint));
|
|
ExpectMsg<LocalCertOpAck>().Success.Should().BeTrue();
|
|
|
|
// Export — thumbprint + raw DER bytes come back for reconciliation.
|
|
actor.Tell(new ExportLocalTrustedCerts());
|
|
var export = ExpectMsg<LocalCertExport>();
|
|
export.Success.Should().BeTrue();
|
|
export.Error.Should().BeNull();
|
|
var entry = export.Certs.Should().ContainSingle().Subject;
|
|
entry.Thumbprint.Should().Be(thumbprint);
|
|
entry.DerBytes.Should().Equal(expectedDer);
|
|
}
|
|
|
|
/// <summary>Verifies the case: write, invalid Base64, returns Failure Ack.</summary>
|
|
[Fact]
|
|
public void Write_InvalidBase64_ReturnsFailureAck()
|
|
{
|
|
var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options)));
|
|
|
|
actor.Tell(new WriteCertToLocalStore("not-valid-base64!!", "ABC123"));
|
|
var ack = ExpectMsg<LocalCertOpAck>();
|
|
ack.Success.Should().BeFalse();
|
|
ack.Error.Should().NotBeNullOrEmpty();
|
|
}
|
|
|
|
/// <summary>Verifies the case: remove, missing Cert, is No Op Success.</summary>
|
|
[Fact]
|
|
public void Remove_MissingCert_IsNoOpSuccess()
|
|
{
|
|
var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options)));
|
|
|
|
actor.Tell(new RemoveCertFromLocalStore("NONEXISTENTTHUMBPRINT"));
|
|
var ack = ExpectMsg<LocalCertOpAck>();
|
|
ack.Success.Should().BeTrue("removing an absent cert is an idempotent no-op");
|
|
}
|
|
|
|
/// <summary>Verifies the case: write, path Traversal Thumbprint, rejected Without Touching Filesystem.</summary>
|
|
[Fact]
|
|
public void Write_PathTraversalThumbprint_RejectedWithoutTouchingFilesystem()
|
|
{
|
|
var (derBase64, _) = BuildSelfSignedCert();
|
|
var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options)));
|
|
|
|
// A "../escape" thumbprint would otherwise resolve to "<root>/escape.der",
|
|
// OUTSIDE the trusted-store dir, once combined into a ".der" file name.
|
|
actor.Tell(new WriteCertToLocalStore(derBase64, "../escape"));
|
|
var ack = ExpectMsg<LocalCertOpAck>();
|
|
ack.Success.Should().BeFalse("a path-traversal thumbprint must be rejected");
|
|
ack.Error.Should().Be("invalid thumbprint");
|
|
|
|
// No file escaped above the trusted-store dir (the shared temp root).
|
|
var escapeRoot = Path.GetDirectoryName(_trustedDir)!;
|
|
File.Exists(Path.Combine(escapeRoot, "escape.der")).Should().BeFalse(
|
|
"the actor must not write outside the trusted-store directory");
|
|
}
|
|
|
|
/// <summary>Verifies the case: remove, path Traversal Thumbprint, rejected Without Touching Filesystem.</summary>
|
|
[Fact]
|
|
public void Remove_PathTraversalThumbprint_RejectedWithoutTouchingFilesystem()
|
|
{
|
|
// Seed a decoy file one level up to prove the rejected remove never
|
|
// reaches the filesystem and therefore cannot delete it.
|
|
var escapeRoot = Path.GetDirectoryName(_trustedDir)!;
|
|
Directory.CreateDirectory(escapeRoot);
|
|
var decoy = Path.Combine(escapeRoot, "escape.der");
|
|
File.WriteAllBytes(decoy, new byte[] { 0x01, 0x02, 0x03 });
|
|
|
|
var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options)));
|
|
|
|
actor.Tell(new RemoveCertFromLocalStore("../escape"));
|
|
var ack = ExpectMsg<LocalCertOpAck>();
|
|
ack.Success.Should().BeFalse("a path-traversal thumbprint must be rejected");
|
|
ack.Error.Should().Be("invalid thumbprint");
|
|
|
|
File.Exists(decoy).Should().BeTrue(
|
|
"the actor must not delete files outside the trusted-store directory");
|
|
}
|
|
}
|