Files
lmxopcua/scripts/validation/opcua-lifecycle-memory/native-retention/ReloadControl.cs
T

177 lines
12 KiB
C#

using System.Diagnostics;
using System.Collections;
using System.Reflection;
using System.Runtime;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using Opc.Ua;
using Opc.Ua.Configuration;
using Opc.Ua.Security.Certificates;
internal static class ReloadControl
{
const string Uri = "urn:localhost:NativeRetention.Control";
const int Repetitions = 200;
static readonly byte[] Challenge = Encoding.UTF8.GetBytes("native-retention-diagnostic-only");
static string Root = "";
static string Hash(Certificate cert) => Convert.ToHexString(SHA256.HashData(cert.RawData)).ToLowerInvariant();
static void Require(bool yes, string message) { if (!yes) throw new InvalidOperationException(message); }
static ApplicationConfiguration Configuration(string pki, ITelemetryContext telemetry) => new(telemetry)
{
ApplicationName = "NativeRetention.Control", ApplicationUri = Uri, ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pki, "own"), SubjectName = "CN=NativeRetention.Control" },
TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "trusted") },
TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "issuers") },
RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "rejected") },
AutoAcceptUntrustedCertificates = false,
},
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 15000 },
};
static async Task<ApplicationInstance> Open(string pki, ITelemetryContext telemetry, CancellationToken ct)
{
var app = new ApplicationInstance(telemetry) { ApplicationType = ApplicationType.Client,
ApplicationName = "NativeRetention.Control", ApplicationConfiguration = Configuration(pki, telemetry) };
try
{
await app.ApplicationConfiguration!.ValidateAsync(ApplicationType.Client, ct);
Require(await app.CheckApplicationInstanceCertificatesAsync(true, null, ct), "Normal application certificate check failed");
return app;
}
catch { await app.DisposeAsync(); throw; }
}
static bool ManagerDisposed(ICertificateManager manager)
{
Require(manager.GetType().FullName == "Opc.Ua.CertificateManager" && manager.GetType().Assembly.GetName().Version?.ToString() == "2.0.1.0", "Exact SDK diagnostic layout changed");
var field = manager.GetType().GetField("m_disposed", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly);
Require(field?.FieldType == typeof(bool), "Exact SDK disposal bool missing");
return (bool)field!.GetValue(manager)!;
}
static int RuntimeTemporaryKeychainCount()
{
var assembly = typeof(X509Certificate2).Assembly;
Require(assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion == "10.0.14+0427b9abbead3fa64c6655eb4290c537397131fa", "Exact runtime diagnostic build changed");
var type = assembly.GetType("System.Security.Cryptography.Apple.SafeTemporaryKeychainHandle", throwOnError: true)!;
var field = type.GetField("s_lookup", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.DeclaredOnly);
Require(field is not null && field.FieldType.IsGenericType && field.FieldType.GetGenericTypeDefinition() == typeof(Dictionary<,>)
&& field.FieldType.GetGenericArguments()[0] == typeof(IntPtr) && field.FieldType.GetGenericArguments()[1] == type,
"Exact runtime keychain dictionary layout changed");
var lookup = (IDictionary)(field!.GetValue(null) ?? throw new InvalidOperationException("Runtime keychain dictionary absent"));
Require(Monitor.TryEnter(lookup, TimeSpan.FromSeconds(1)), "Runtime keychain monitor did not become available");
try { return lookup.Count; }
finally { Monitor.Exit(lookup); }
}
static void ValidateKey(Certificate certificate, string expected, bool sign)
{
Require(certificate.HasPrivateKey && Hash(certificate) == expected, "Wrong/missing private certificate");
if (!sign) return;
using var privateKey = certificate.GetRSAPrivateKey();
using var publicKey = certificate.GetRSAPublicKey();
Require(privateKey is not null && publicKey is not null, "Expected normal SDK RSA application certificate");
var signature = privateKey!.SignData(Challenge, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
Require(publicKey!.VerifyData(Challenge, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1), "Private key mismatch");
}
static async Task Stage(string mode, string stage, int operations, bool disposed, CancellationToken ct)
{
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
await Task.Delay(250, ct);
using var process = Process.GetCurrentProcess(); process.Refresh();
Console.WriteLine(JsonSerializer.Serialize(new { kind = "checkpoint", mode, stage, operations, processId = Environment.ProcessId,
utc = DateTimeOffset.UtcNow, managerDisposed = disposed, managedBytes = GC.GetTotalMemory(false), rssBytes = process.WorkingSet64,
privateBytes = process.PrivateMemorySize64, allocatedBytes = GC.GetTotalAllocatedBytes(), compiledMethods = JitInfo.GetCompiledMethodCount(),
gc = new[] { GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2) },
certificateCoresCreated = Certificate.InstancesCreated, certificateCoresDisposed = Certificate.InstancesDisposed,
certificateCoresOutstanding = Certificate.InstancesLeaked,
runtimeTrackedTemporaryKeychains = RuntimeTemporaryKeychainCount() }));
Console.Out.Flush();
while (!File.Exists(Path.Combine(Root, "continue-" + stage))) await Task.Delay(25, ct);
}
static async Task Arm(string mode, CancellationToken ct)
{
var telemetry = DefaultTelemetry.Create(_ => { });
var pki = Path.Combine(Root, "pki");
var before = Directory.GetFiles(pki, "*.der", SearchOption.AllDirectories).Where(p => p.Contains(Path.DirectorySeparatorChar + "own" + Path.DirectorySeparatorChar + "certs" + Path.DirectorySeparatorChar, StringComparison.Ordinal))
.Select(p => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(p)))).Order().ToArray();
Require(before.Length == 1, "Expected one copied own DER certificate");
ApplicationInstance? app = null;
try
{
app = await Open(pki, telemetry, ct);
var config = app.ApplicationConfiguration!;
var manager = config.CertificateManager;
string expected;
using (var entry = manager.AcquireApplicationCertificateBySecurityPolicy(SecurityPolicies.Basic256Sha256))
{
Require(entry is not null, "Normal initialization did not populate registry");
expected = Hash(entry!.Certificate); ValidateKey(entry.Certificate, expected, true);
}
await Stage(mode, "ready", 0, ManagerDisposed(manager), ct);
for (var i = 1; i <= Repetitions; i++)
{
if (mode == "store")
{
using var certificate = await config.SecurityConfiguration.FindApplicationCertificateAsync(
SecurityPolicies.Basic256Sha256, true, telemetry, ct).WaitAsync(ct);
Require(certificate is not null, "Configured-store acquisition failed");
ValidateKey(certificate!, expected, i is 1 or Repetitions);
}
else
{
using var entry = manager.AcquireApplicationCertificateBySecurityPolicy(SecurityPolicies.Basic256Sha256);
Require(entry is not null, "Registry acquisition failed");
ValidateKey(entry!.Certificate, expected, i is 1 or Repetitions);
}
if (i is 1 or Repetitions) await Stage(mode, i == 1 ? "first" : "repeated", i, ManagerDisposed(manager), ct);
}
await app.DisposeAsync().AsTask().WaitAsync(ct); app = null;
await Stage(mode, "disposed", Repetitions, ManagerDisposed(manager), ct);
await Task.Delay(TimeSpan.FromSeconds(5), ct);
await Stage(mode, "idle", Repetitions, ManagerDisposed(manager), ct);
// Preserve managed and native post-disposal observations before any ownership assertion rejects the arm.
Require(ManagerDisposed(manager), "Application manager not disposed");
Require(Certificate.InstancesLeaked == 0, "SDK certificate cores remain after owner disposal");
Require(RuntimeTemporaryKeychainCount() == 0, "Runtime tracked temporary keychain owners remain after application disposal");
var after = Directory.GetFiles(pki, "*.der", SearchOption.AllDirectories).Where(p => p.Contains(Path.DirectorySeparatorChar + "own" + Path.DirectorySeparatorChar + "certs" + Path.DirectorySeparatorChar, StringComparison.Ordinal))
.Select(p => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(p)))).Order().ToArray();
Require(after.SequenceEqual(before), "Own application certificate changed");
Console.WriteLine(JsonSerializer.Serialize(new { kind = "complete", mode, operations = Repetitions, sameLeafSha256 = expected,
certificateUnchanged = true, managerDisposed = true, certificateCoresOutstanding = Certificate.InstancesLeaked, fullSoakGate = false }));
}
finally { if (app is not null) await app.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10)); }
}
public static async Task<int> Main(string[] args)
{
Root = Path.GetFullPath(args[1]); Directory.CreateDirectory(Root);
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(180));
try
{
Require(OperatingSystem.IsMacOS(), "This control requires macOS native inventory");
Require(Environment.Version.ToString() == "10.0.14", "Matched runtime must be .NET 10.0.14");
if (args[0] == "seed")
{
await using var app = await Open(Path.Combine(Root, "pki"), DefaultTelemetry.Create(_ => { }), deadline.Token);
using var entry = app.ApplicationConfiguration!.CertificateManager.AcquireApplicationCertificateBySecurityPolicy(SecurityPolicies.Basic256Sha256);
Require(entry is not null, "Seed registry not populated");
Console.WriteLine(JsonSerializer.Serialize(new { kind = "seed", leafSha256 = Hash(entry!.Certificate), runtime = Environment.Version.ToString(),
identifierPublicProperties = typeof(CertificateIdentifier).GetProperties(BindingFlags.Public | BindingFlags.Instance).Select(p => p.Name).Order().ToArray(),
identifierPublicConstructors = typeof(CertificateIdentifier).GetConstructors().Select(c => c.ToString()).ToArray(),
cryptographyRuntime = new { file = typeof(X509Certificate2).Assembly.Location,
informationalVersion = typeof(X509Certificate2).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion,
sha256 = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(typeof(X509Certificate2).Assembly.Location))).ToLowerInvariant() } }));
return 0;
}
Require(args[0] is "store" or "registry", "Unknown control mode");
await Arm(args[0], deadline.Token); return 0;
}
catch (Exception error)
{
Console.WriteLine(JsonSerializer.Serialize(new { kind = "failed", errorType = error.GetType().FullName, message = error.Message }));
return 1;
}
}
}