mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 14:43:08 +08:00
2966f693b2
Introduces `OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer` — a single NuGet package that ships **three Roslyn components + a runtime shim** to make the 1.5.378 → 2.0 upgrade as close to "install package, run `dotnet format`, remove package" as possible: | DLL | Where | Purpose | |-----|-------|---------| | `Opc.Ua.MigrationAnalyzer.dll` | `analyzers/dotnet/cs/` | 19 Roslyn analyzers (`UA0001`–`UA0022`) covering every breaking-change pattern in [`Docs/MigrationGuide.md`](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/Docs/MigrationGuide.md). Built against the stable Roslyn 4.14 analyzer API — loads cleanly in csc.exe (no `Microsoft.CodeAnalysis.Workspaces` reference). | | `Opc.Ua.MigrationAnalyzer.CodeFixer.dll` | `analyzers/dotnet/cs/` | 14 companion `CodeFixProvider`s; loaded only by Workspaces-aware hosts (Visual Studio, `dotnet format analyzers`). | | `Opc.Ua.MigrationAnalyzer.Generator.dll` | `analyzers/dotnet/cs/` | Roslyn `IIncrementalGenerator` that emits per-consumer `internal sealed [Obsolete] class <Name>Collection : List<TElement>` shims for every `<Type>Collection` wrapper the consumer references — including **arbitrary model-compiled `<UserType>Collection` patterns**, not just the 30 well-known built-in ones. | | `Opc.Ua.MigrationAnalyzer.Core.dll` | `lib/<tfm>/` for `net472, net48, netstandard2.1, net8.0, net9.0, net10.0` | 1.5.378 → 2.0 runtime compatibility shim. Re-exposes the obsolete extension surface so 1.5.378-style call sites keep compiling against 2.0 with warnings instead of errors. | ### Migration workflow the package enables 1. **Bump every `OPCFoundation.NetStandard.Opc.Ua.*` PackageReference to `2.0.*-*`.** 2. **Add the migration package** as a build-only dependency: ```xml <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer" Version="2.0.*-*" PrivateAssets="all" /> ``` 3. **Run `dotnet build`.** The Core shim DLL re-supplies obsolete extension members; the source generator covers `<Type>Collection` references; what remains are `[Obsolete]` warnings + `UA00xx` analyzer diagnostics + (occasionally) `MIG01` for unresolvable model-compiled element types. 4. **Apply auto-fixes** in the IDE or from the command line: ```bash dotnet format analyzers <solution>.sln \ --diagnostics UA0002 UA0003 UA0004 UA0005 UA0006 UA0007 UA0008 \ UA0009 UA0010 UA0012 UA0014 UA0019 UA0020 UA0022 \ --severity warn ``` 5. **Walk the residual** `UA0001` / `UA0011` / `UA0015` / `UA0018` / `UA0021` diagnostics manually — they cover patterns that require human judgement. 6. **Remove the migration package.** You are now on clean 2.0 with no shim dependency. ### Source generator (`Opc.Ua.MigrationAnalyzer.Generator.dll`) Per-consumer Roslyn `IIncrementalGenerator` that emits `.g.cs` files for every `<Name>Collection` reference in the consumer's compilation that fails to bind: - **Catalog override (30 entries)** — pins element types that renamed across the 1.5.378 → 2.0 boundary: `DateTime`→`DateTimeUtc`, `Guid`→`Uuid`, `byte[]`→`ByteString`, `XmlElement`→`Opc.Ua.XmlElement`, plus the primitives and built-in opc types whose element name didn't rename. - **Semantic lookup** — for model-compiled `<UserType>Collection` patterns the model compiler historically emitted for any user-defined complex type. Strips the `Collection` suffix, runs `Compilation.GetSymbolsWithName` against the unique element short name, emits the shim with the fully-qualified type reference. - **`MIG01` diagnostic** — fires when the element type can't be uniquely resolved (zero or multiple candidates). User adds the missing `using` or migrates the site manually. Each generated file emits a class shaped exactly like the 1.5.378 wrapper: ```csharp [global::System.Obsolete("'Int32Collection' was removed in 2.0. Use 'List<int>' or 'ArrayOf<int>' instead. (UA0002)")] internal sealed class Int32Collection : global::System.Collections.Generic.List<int> { public Int32Collection() { } public Int32Collection(int capacity) : base(capacity) { } public Int32Collection(global::System.Collections.Generic.IEnumerable<int> collection) : base(collection) { } public static implicit operator global::Opc.Ua.ArrayOf<int>(Int32Collection? value) => value is null ? default : value.ToArrayOf(); } ``` **Design choice — `internal` (not `public`):** the generated shim never leaks through the consumer's public API surface. Consumers with *public* methods or properties returning a `<Type>Collection` will hit `CS0050: Inconsistent accessibility` — the intended signal that the public surface must migrate to `List<T>` / `ArrayOf<T>` first; internal call sites keep compiling under the shim so iteration is incremental. ### 19-rule analyzer reference | Rule | Replaces | Default | Auto-fix | |------|----------|---------|----------| | `UA0001` | `Utils.Trace` / `Utils.LogX` | Info | No (telemetry plumbing requires manual review) | | `UA0002` | Removed `<Type>Collection` wrappers | Warning | Yes (→ `List<T>` / `ArrayOf<T>`); the source generator keeps the call site compiling in the meantime | | `UA0003` | `x == null` on now-struct built-ins | Warning | Yes (→ `x.IsNull`) | | `UA0004` | `?.` on now-struct built-ins | Warning | Yes (drop the `?`) | | `UA0005` | `byte[]` where `ByteString` is expected | Warning | Yes (append `.ToByteString()`) | | `UA0006` | `new Variant(object\|DateTime\|Guid\|byte[])` | Warning | Yes (→ `Variant.From(...)`) | | `UA0007` | `new NodeId(string)` / `new ExpandedNodeId(string)` | Warning | Yes (→ `Parse`) | | `UA0008` | `Session.Call(..., params object[])` | Warning | Yes (wrap with `Variant.From`) | | `UA0009` | `[DataContract]` / `[DataMember]` on config extensions | Warning | Yes (→ `[DataType]` / `[DataTypeField]`) | | `UA0010` | `using` / `Dispose` on `CertificateIdentifier` / `UserIdentity` / `IUserIdentityTokenHandler` | Warning | Yes (drop the disposable) | | `UA0011` | Sync `IUserIdentityTokenHandler.{Encrypt,Decrypt,Sign,Verify}` | Info | No (async promotion required) | | `UA0012` | `CertificateFactory.*` static helpers | Warning | Yes (→ instance methods) | | `UA0014` | `DataValue.IsGood(dv)` static helper | Warning | Yes (→ `dv.IsGood`) | | `UA0015` | Sync / APM members on GDS / LDS clients | Info | No (async promotion required) | | `UA0018` | `CertificateIdentifier.Certificate` getter | Info | No (→ `LoadCertificate2Async`) | | `UA0019` | `new DataValue(StatusCode[, ts])` | Warning | Yes (→ object-initializer form) | | `UA0020` | `EncodeableFactory.GlobalFactory` / `Create()` | Warning | Yes (→ `ServiceMessageContext.Factory` / `Fork()`) | | `UA0021` | `CertificateValidator` / `CertificateValidationEventArgs` (structural rename) | Info | No (see migration guide §Certificate validation) | | `UA0022` | `ApplicationConfiguration.CertificateValidator` / `ServerBase.CertificateValidator` property rename | Warning | Yes (→ `.CertificateManager`) | Plus one generator-only diagnostic: | Rule | Source | Default | Triggers | |------|--------|---------|----------| | `MIG01` | Generator | Warning | Generator can't uniquely resolve the element type for a `<Foo>Collection` reference (zero or multiple candidates). The user adds a `using` for the namespace defining `Foo`, or migrates the call site manually. | ### Runtime shim coverage (`Opc.Ua.MigrationAnalyzer.Core.dll`) C# 14 `extension` members so 1.5.378 call sites continue to compile: - **Moved obsolete extensions** the 2.0 libraries no longer carry inline: `NodeId` / `Variant` / `DataValue` null-check helpers, `Session` sync helpers, `Subscription` sync helpers, `ApplicationInstance` helpers, `ServerBase.Start` / `Stop`, `TransportChannel` APM, `ChannelBase` static factory methods, etc. - **New shims for genuinely-removed members**: `EncodeableFactory.GlobalFactory`, `CertificateIdentifier.Certificate` (throws), sync wrappers for `IUserIdentityTokenHandler.{Encrypt,Decrypt,Sign,Verify}`, sync + APM wrappers for the GDS / LDS client APIs, `GlobalDiscoverySampleServer` 1.5.378-shape ctor (in-tree `Libraries/Opc.Ua.Gds.Server.Common/`). > ⚠ The sync shims wrap their `*Async` counterparts via `Task.Run(…).GetAwaiter().GetResult()`. Intended as a **migration aid only** — port to `await` before shipping. ### Repo layout ``` Tools/Opc.Ua.MigrationAnalyzer/ # analyzer DLL Analyzers/UA00xxAnalyzer.cs # 19 DiagnosticAnalyzer impls Diagnostics/{DiagnosticIds,Descriptors,WellKnownProperties}.cs Helpers/{SymbolExtensions,UaSymbols}.cs Opc.Ua.MigrationAnalyzer.nuspec # ships all 3 analyzer DLLs + 6-TFM Core OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer.props Tools/Opc.Ua.MigrationAnalyzer.CodeFixer/ # code-fix DLL CodeFixes/UA00xxCodeFix.cs # 14 CodeFixProvider impls Tools/Opc.Ua.MigrationAnalyzer.Generator/ # source generator DLL (NEW) MigrationGenerator.cs # IIncrementalGenerator CollectionShimCatalog.cs # 30-entry element-type overrides Diagnostics/GeneratorDiagnostics.cs # MIG01 Tools/Opc.Ua.MigrationAnalyzer.Core/ # runtime shim (multi-TFM) Client/, Configuration/, Core/, Gds.Client.Common/, Types/, Marker/ Tools/Roslyn.slnx # source-gen + migration projects Tests/Opc.Ua.MigrationAnalyzer.Tests/ # 142 tests Analyzers/UA00xxTests.cs # 107 analyzer + code-fix tests Generators/MigrationGeneratorTests.cs # 35 generator tests (NEW) Tests/Opc.Ua.MigrationAnalyzer.Core.Tests/ # 13 runtime-shim tests ``` ### Documentation - [`Docs/MigrationGuide.md`](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/Docs/MigrationGuide.md) — §3 Collection Type Migration gains a tip pointing at the generator so CS0246 is replaced with `[Obsolete]` warnings + UA0002. - [`.github/agents/opcua-v20-migration.agent.md`](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/.github/agents/opcua-v20-migration.agent.md) — Strategy section mentions the three Roslyn components + runtime shim explicitly, plus the MIG01 diagnostic and the "internal-by-design" CS0050 hint. ### Central package pinning `Directory.Packages.props` pins `Microsoft.CodeAnalysis.*` (Common, CSharp, CSharp.Workspaces, Workspaces.Common) to **4.14.0** — the stable analyzer API surface. The earlier 5.3 pin was the *csc-internal* version, which is not safe for analyzer hosts. ### Dogfood story The full 42-sample dogfood against `OPCFoundation/UA-.NETStandard-Samples` surfaced a **critical analyzer-loader bug** in an earlier iteration: the analyzer DLL initially co-shipped its code-fix providers in one assembly, which transitively referenced `Microsoft.CodeAnalysis.Workspaces.dll`. csc.exe's analyzer host ships only `Microsoft.CodeAnalysis.dll` + `CSharp.dll` in its bincore and **silently swallows** load failures — net result: zero diagnostics across all samples. Fix landed in commit `861fa6ee1`: 1. **Split** the assembly: analyzer in `Opc.Ua.MigrationAnalyzer.dll` (Workspaces-free, csc-safe), code-fixes in `Opc.Ua.MigrationAnalyzer.CodeFixer.dll` (Workspaces-aware, IDE-only). 2. **Downgrade** to Roslyn 4.14 — the stable analyzer API. 3. `/p:ReportAnalyzer=true` confirms all 19 analyzers initialise and execute on real consumer code. This PR's new generator was smoke-tested against a minimal consumer that exercises all 30 catalog entries + a model-compiled element type + the unresolvable case: - 30 catalog patterns + model-compiled `MyThingCollection` → all emit shims, fire CS0618 with `(UA0002)` marker - Element-type renames verified: `DateTimeCollection` → `DateTimeUtc`, `ByteStringCollection` → `ByteString`, `GuidCollection` → `Uuid`, etc. - `NeverHeardOfItCollection` → `MIG01` fires with help link to the migration guide Known compatibility gaps (documented in the migration agent): - Legacy WinForms `.Net4` sample projects use pre-SDK MSBuild XML and **ignore** `Directory.Build.targets` `<PackageReference>` injection — consumers in this format must add the migration package to their csproj inline. ### Packaging design notes - Hand-authored nuspec ships 3 DLLs in `analyzers/dotnet/cs/` + 6 TFMs of Core (shim) DLLs under `lib/`. All 6 OPC UA runtime package dependencies are declared per-TFM-group. - `build/OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer.props` is auto-imported by NuGet (file name must match package id exactly) and injects all three analyzer DLLs as `<Analyzer>` items. - `DiagnosticIds.cs` / `DiagnosticDescriptors.cs` / `WellKnownProperties.cs` / `Helpers/*.cs` are **shared via `<Compile Link=…>`** between the analyzer and code-fixer projects to break what would otherwise be a NuGet restore cycle. The generator has no compile dependency on the analyzer; the 30-entry override table is duplicated in `CollectionShimCatalog.cs` (the analyzer table also drives diagnostic messages, so the duplication is small and intentional). - `Opc.Ua.MigrationAnalyzer.Core` is multi-TFM (`net472, net48, netstandard2.1, net8.0, net9.0, net10.0`) and **non-packable on its own**; the parent migration package's nuspec consumes each TFM's DLL by path.
1270 lines
51 KiB
C#
1270 lines
51 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.IO;
|
|
using System.Net;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua.Security.Certificates;
|
|
|
|
namespace Opc.Ua.Configuration
|
|
{
|
|
/// <inheritdoc/>
|
|
public class ApplicationInstance : IApplicationInstance
|
|
{
|
|
/// <inheritdoc/>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (Server != null)
|
|
{
|
|
try
|
|
{
|
|
await StopAsync().ConfigureAwait(false);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
m_logger.LogError(e, "Error stopping server during dispose.");
|
|
}
|
|
Server.Dispose();
|
|
Server = null;
|
|
}
|
|
|
|
CertificateManager? localManager = CertificateManager;
|
|
localManager?.Dispose();
|
|
if (ApplicationConfiguration?.CertificateManager is CertificateManager configManager && !ReferenceEquals(configManager, localManager))
|
|
{
|
|
configManager.Dispose();
|
|
}
|
|
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Obsolete constructor
|
|
/// </summary>
|
|
[Obsolete("Use ApplicationInstance(ITelemetryContext) instead.")]
|
|
public ApplicationInstance()
|
|
: this((ITelemetryContext?)null)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Obsolete constructor
|
|
/// </summary>
|
|
[Obsolete("Use ApplicationInstance(ApplicationConfiguration, ITelemetryContext) instead.")]
|
|
public ApplicationInstance(ApplicationConfiguration applicationConfiguration)
|
|
: this(applicationConfiguration, null)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="ApplicationInstance"/> class.
|
|
/// </summary>
|
|
public ApplicationInstance(ITelemetryContext? telemetry)
|
|
{
|
|
m_telemetry = telemetry;
|
|
m_logger = telemetry?.CreateLogger<ApplicationInstance>() ?? NullLogger<ApplicationInstance>.Instance;
|
|
DisableCertificateAutoCreation = false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="ApplicationInstance"/> class.
|
|
/// </summary>
|
|
/// <param name="applicationConfiguration">The application configuration.</param>
|
|
/// <param name="telemetry">The telemetry context to use to create obvservability instruments</param>
|
|
public ApplicationInstance(
|
|
ApplicationConfiguration applicationConfiguration,
|
|
ITelemetryContext? telemetry)
|
|
: this(telemetry)
|
|
{
|
|
ApplicationConfiguration = applicationConfiguration;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public string? ApplicationName { get; set; }
|
|
|
|
/// <inheritdoc/>
|
|
public ApplicationType ApplicationType { get; set; }
|
|
|
|
/// <inheritdoc/>
|
|
public string? ConfigSectionName { get; set; }
|
|
|
|
/// <inheritdoc/>
|
|
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
|
|
public Type? ConfigurationType { get; set; }
|
|
|
|
/// <inheritdoc/>
|
|
public IServerBase? Server { get; private set; }
|
|
|
|
/// <inheritdoc/>
|
|
public ApplicationConfiguration? ApplicationConfiguration { get; set; }
|
|
|
|
/// <summary>
|
|
/// Get or set the message dialog.
|
|
/// </summary>
|
|
public static IApplicationMessageDlg? MessageDlg { get; set; }
|
|
|
|
/// <inheritdoc/>
|
|
public ICertificatePasswordProvider? CertificatePasswordProvider { get; set; }
|
|
|
|
/// <inheritdoc/>
|
|
public bool DisableCertificateAutoCreation { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets the certificate manager for this application instance.
|
|
/// </summary>
|
|
public CertificateManager? CertificateManager { get; private set; }
|
|
|
|
/// <inheritdoc/>
|
|
public async Task StartAsync(IServerBase server, CancellationToken ct = default)
|
|
{
|
|
Server = server;
|
|
|
|
ApplicationConfiguration configuration = ApplicationConfiguration
|
|
?? await LoadApplicationConfigurationAsync(false, ct).ConfigureAwait(false);
|
|
|
|
await server.StartAsync(configuration, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public ValueTask StopAsync(CancellationToken ct = default)
|
|
{
|
|
return Server?.StopAsync(ct) ?? default;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops the UA server.
|
|
/// </summary>
|
|
[Obsolete("Use StopAsync")]
|
|
public void Stop()
|
|
{
|
|
Server?.StopAsync().AsTask().GetAwaiter().GetResult();
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task<ApplicationConfiguration> LoadApplicationConfigurationAsync(
|
|
Stream stream,
|
|
bool silent,
|
|
CancellationToken ct = default)
|
|
{
|
|
ApplicationConfiguration? configuration = null;
|
|
|
|
try
|
|
{
|
|
configuration = await LoadAppConfigAsync(
|
|
silent,
|
|
stream,
|
|
ApplicationType,
|
|
ConfigurationType,
|
|
true,
|
|
CertificatePasswordProvider,
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (Exception) when (silent)
|
|
{
|
|
}
|
|
|
|
if (configuration == null)
|
|
{
|
|
throw ServiceResultException.ConfigurationError("Could not load configuration.");
|
|
}
|
|
|
|
ApplicationConfiguration = FixupAppConfig(configuration);
|
|
|
|
return configuration;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask<ApplicationConfiguration> LoadApplicationConfigurationAsync(
|
|
string filePath,
|
|
bool silent,
|
|
CancellationToken ct = default)
|
|
{
|
|
ApplicationConfiguration? configuration = null;
|
|
|
|
try
|
|
{
|
|
configuration = await LoadAppConfigAsync(
|
|
silent,
|
|
filePath,
|
|
ApplicationType,
|
|
ConfigurationType,
|
|
true,
|
|
CertificatePasswordProvider,
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (Exception) when (silent)
|
|
{
|
|
}
|
|
|
|
if (configuration == null)
|
|
{
|
|
throw ServiceResultException.ConfigurationError("Could not load configuration file.");
|
|
}
|
|
|
|
ApplicationConfiguration = FixupAppConfig(configuration);
|
|
|
|
return configuration;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public ValueTask<ApplicationConfiguration> LoadApplicationConfigurationAsync(
|
|
bool silent,
|
|
CancellationToken ct = default)
|
|
{
|
|
// GetFilePathFromAppConfig tolerates a null section name (string.Concat handles null operands).
|
|
string filePath = ApplicationConfiguration.GetFilePathFromAppConfig(ConfigSectionName!, m_logger);
|
|
return LoadApplicationConfigurationAsync(filePath, silent, ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Helper to replace localhost with the hostname
|
|
/// in the application uri and base addresses of the
|
|
/// configuration.
|
|
/// </summary>
|
|
public static ApplicationConfiguration FixupAppConfig(
|
|
ApplicationConfiguration configuration)
|
|
{
|
|
configuration.ApplicationUri = Utils.ReplaceLocalhost(configuration.ApplicationUri);
|
|
if (configuration.ServerConfiguration != null)
|
|
{
|
|
string[] baseAddresses = new string[configuration.ServerConfiguration.BaseAddresses.Count];
|
|
for (int i = 0; i < configuration.ServerConfiguration.BaseAddresses.Count; i++)
|
|
{
|
|
// Base address strings in the collection are non-null; ReplaceLocalhost only returns
|
|
// null when its input is null, so the result here is non-null.
|
|
baseAddresses[i] =
|
|
Utils.ReplaceLocalhost(configuration.ServerConfiguration.BaseAddresses[i])!;
|
|
}
|
|
configuration.ServerConfiguration.BaseAddresses = baseAddresses;
|
|
}
|
|
return configuration;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public IApplicationConfigurationBuilderTypes Build(string applicationUri, string productUri)
|
|
{
|
|
// ApplicationConfiguration's ctor requires a non-null ITelemetryContext while this class
|
|
// tolerates a null telemetry context; passing null falls through to default telemetry.
|
|
ApplicationConfiguration = new ApplicationConfiguration(m_telemetry!)
|
|
{
|
|
ApplicationName = ApplicationName,
|
|
ApplicationType = ApplicationType,
|
|
ApplicationUri = applicationUri,
|
|
ProductUri = productUri,
|
|
TraceConfiguration = new TraceConfiguration { TraceMasks = Utils.TraceMasks.None },
|
|
TransportQuotas = new TransportQuotas()
|
|
};
|
|
|
|
// Trace off — legacy Utils pipeline; kept for 1.5.378 -> 2.0 migration.
|
|
#pragma warning disable CS0618 // Type or member is obsolete
|
|
ApplicationConfiguration.TraceConfiguration.ApplySettings();
|
|
#pragma warning restore CS0618 // Type or member is obsolete
|
|
|
|
return new ApplicationConfigurationBuilder(this);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask DeleteApplicationInstanceCertificateAsync(
|
|
string[]? profileIds = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
// TODO: delete only selected profiles
|
|
if (ApplicationConfiguration == null)
|
|
{
|
|
throw new ArgumentException("Missing configuration.");
|
|
}
|
|
|
|
ArrayOf<CertificateIdentifier> applicationCerts = ApplicationConfiguration
|
|
.SecurityConfiguration.ApplicationCertificates;
|
|
for (int ii = 0; ii < applicationCerts.Count; ii++)
|
|
{
|
|
CertificateIdentifier id = applicationCerts[ii];
|
|
await DeleteApplicationInstanceCertificateAsync(ApplicationConfiguration, id, ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async ValueTask<bool> CheckApplicationInstanceCertificatesAsync(
|
|
bool silent,
|
|
ushort? lifeTimeInMonths = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
lifeTimeInMonths ??= CertificateFactory.DefaultLifeTime;
|
|
m_logger.LogInformation("Checking application instance certificate.");
|
|
|
|
ApplicationConfiguration configuration = ApplicationConfiguration
|
|
?? await LoadApplicationConfigurationAsync(silent, ct).ConfigureAwait(false);
|
|
|
|
// find the existing certificates.
|
|
SecurityConfiguration securityConfiguration = configuration.SecurityConfiguration;
|
|
|
|
if (securityConfiguration.ApplicationCertificates.Count == 0)
|
|
{
|
|
throw ServiceResultException.ConfigurationError("Need at least one Application Certificate.");
|
|
}
|
|
|
|
// Initialize CertificateManager early so CheckApplicationInstanceCertificateAsync
|
|
// can use the new ICertificateValidatorEx pipeline (with CertificateValidationOptions.AcceptError)
|
|
// for per-certificate validation below.
|
|
CertificateManager ??= CertificateManagerFactory.Create(
|
|
securityConfiguration,
|
|
m_telemetry!);
|
|
|
|
// Make the manager visible via the configuration so consumers
|
|
// that only see ApplicationConfiguration (e.g. Session) can
|
|
// route validation through the new pipeline.
|
|
ApplicationConfiguration!.CertificateManager = CertificateManager;
|
|
|
|
// Note: The FindAsync method searches certificates in this order: thumbprint, subjectName, then applicationUri.
|
|
// When SubjectName or Thumbprint is specified, certificates may be loaded even if their ApplicationUri
|
|
// doesn't match ApplicationConfiguration.ApplicationUri, however each certificate is validated individually
|
|
// in CheckApplicationInstanceCertificateAsync (called via CheckOrCreateCertificateAsync) to ensure it contains
|
|
// the configuration's ApplicationUri.
|
|
bool result = true;
|
|
for (int ii = 0; ii < securityConfiguration.ApplicationCertificates.Count; ii++)
|
|
{
|
|
CertificateIdentifier certId = securityConfiguration.ApplicationCertificates[ii];
|
|
ushort minimumKeySize = certId.GetMinKeySize(securityConfiguration);
|
|
bool nextResult = await CheckOrCreateCertificateAsync(
|
|
configuration,
|
|
certId,
|
|
silent,
|
|
minimumKeySize,
|
|
lifeTimeInMonths.Value,
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
result = result && nextResult;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks, validates, and optionally creates an application certificate.
|
|
/// Loads the certificate, validates it against configured requirements (ApplicationUri, key size, domains),
|
|
/// and creates a new certificate if none exists and auto-creation is enabled.
|
|
/// Note: FindAsync searches certificates in order: thumbprint, subjectName, applicationUri.
|
|
/// The applicationUri parameter is only used if thumbprint and subjectName don't find a match.
|
|
/// </summary>
|
|
/// <exception cref="ServiceResultException"></exception>
|
|
private async Task<bool> CheckOrCreateCertificateAsync(
|
|
ApplicationConfiguration configuration,
|
|
CertificateIdentifier id,
|
|
bool silent,
|
|
ushort minimumKeySize,
|
|
ushort lifeTimeInMonths,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (id == null)
|
|
{
|
|
throw ServiceResultException.ConfigurationError(
|
|
"Configuration file does not specify a certificate.");
|
|
}
|
|
|
|
// load the certificate (with private key if available).
|
|
ICertificatePasswordProvider? passwordProvider = configuration
|
|
.SecurityConfiguration
|
|
.CertificatePasswordProvider;
|
|
|
|
Certificate? certificate = await CertificateIdentifierResolver
|
|
.LoadPrivateKeyAsync(
|
|
id,
|
|
passwordProvider,
|
|
configuration.ApplicationUri,
|
|
m_telemetry,
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
|
|
try
|
|
{
|
|
// check that it is ok.
|
|
if (certificate != null)
|
|
{
|
|
m_logger.LogInformation("Check certificate: {Certificate}", certificate);
|
|
bool certificateValid = await CheckApplicationInstanceCertificateAsync(
|
|
configuration,
|
|
id,
|
|
certificate,
|
|
silent,
|
|
minimumKeySize,
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
|
|
if (!certificateValid)
|
|
{
|
|
throw ServiceResultException.ConfigurationError(
|
|
"The certificate with subject {0} in the configuration is invalid.\n" +
|
|
" Please update or delete the certificate from this location: {1}",
|
|
id.SubjectName!,
|
|
Utils.ReplaceSpecialFolderNames(id.StorePath)!);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// check for missing private key.
|
|
certificate = await CertificateIdentifierResolver
|
|
.ResolveAsync(
|
|
id,
|
|
registry: null,
|
|
needPrivateKey: false,
|
|
configuration.ApplicationUri,
|
|
m_telemetry,
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
|
|
if (certificate != null)
|
|
{
|
|
throw ServiceResultException.ConfigurationError(
|
|
"Cannot access private key for certificate with thumbprint={0}",
|
|
certificate.Thumbprint);
|
|
}
|
|
|
|
// check for missing thumbprint.
|
|
if (!string.IsNullOrEmpty(id.Thumbprint))
|
|
{
|
|
if (!string.IsNullOrEmpty(id.SubjectName))
|
|
{
|
|
var id2 = new CertificateIdentifier
|
|
{
|
|
StoreType = id.StoreType,
|
|
StorePath = id.StorePath,
|
|
SubjectName = id.SubjectName
|
|
};
|
|
certificate = await CertificateIdentifierResolver
|
|
.LoadPrivateKeyAsync(
|
|
id2,
|
|
passwordProvider,
|
|
configuration.ApplicationUri,
|
|
m_telemetry,
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
if (certificate != null)
|
|
{
|
|
var message = new StringBuilder();
|
|
message.AppendLine(
|
|
"Thumbprint was explicitly specified in the configuration.")
|
|
.AppendLine("Another certificate with the same subject name was found.")
|
|
.AppendLine("Use it instead?")
|
|
.AppendLine("Requested: {0}")
|
|
.AppendLine("Found: {1}");
|
|
if (!await ApproveMessageAsync(
|
|
Utils.Format(message.ToString(), id.SubjectName!, certificate.Subject), silent)
|
|
.ConfigureAwait(false))
|
|
{
|
|
throw ServiceResultException.ConfigurationError(
|
|
"Thumbprint for {0} was explicitly specified in the configuration but\n" +
|
|
"another certificate with the same subject name {1} was found.",
|
|
id.SubjectName!,
|
|
certificate.Subject);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
throw ServiceResultException.ConfigurationError(
|
|
"Thumbprint was explicitly specified in the configuration. Cannot generate a new certificate.");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (certificate == null)
|
|
{
|
|
if (!DisableCertificateAutoCreation)
|
|
{
|
|
certificate = await CreateApplicationInstanceCertificateAsync(
|
|
configuration,
|
|
id,
|
|
minimumKeySize,
|
|
lifeTimeInMonths,
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
m_logger.LogWarning("Application Instance certificate auto creation is disabled.");
|
|
}
|
|
|
|
if (certificate == null)
|
|
{
|
|
throw ServiceResultException.ConfigurationError(
|
|
"There is no cert with subject {0} in the configuration.\n" +
|
|
"Please generate a cert for your application, then copy the new cert to this location: {1}",
|
|
id.SubjectName!,
|
|
id.StorePath!);
|
|
}
|
|
}
|
|
else if (configuration.SecurityConfiguration.AddAppCertToTrustedStore)
|
|
{
|
|
// ensure it is trusted.
|
|
await AddToTrustedStoreAsync(configuration, certificate, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
finally
|
|
{
|
|
// The local 'certificate' variable is only used for validation /
|
|
// approval / trust-store warmup inside this method. Any cert that
|
|
// needs to outlive this call is reloaded via the CertificateManager
|
|
// registry by the caller. Dispose it here to balance the AddRef in
|
|
// Certificate.From / DirectoryCertificateStore.LoadPrivateKeyAsync.
|
|
certificate?.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task AddOwnCertificateToTrustedStoreAsync(
|
|
Certificate certificate,
|
|
CancellationToken ct)
|
|
{
|
|
// ApplicationConfiguration may be null; AddToTrustedStoreAsync checks defensively and
|
|
// logs a warning if no trusted store is configured, preserving prior behavior.
|
|
await AddToTrustedStoreAsync(ApplicationConfiguration!, certificate, ct).ConfigureAwait(
|
|
false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads the configuration.
|
|
/// </summary>
|
|
internal async ValueTask<ApplicationConfiguration?> LoadAppConfigAsync(
|
|
bool silent,
|
|
string filePath,
|
|
ApplicationType applicationType,
|
|
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type? configurationType,
|
|
bool applyTraceSettings,
|
|
ICertificatePasswordProvider? certificatePasswordProvider = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
m_logger.LogInformation("Loading application configuration file. {FilePath}", filePath);
|
|
|
|
try
|
|
{
|
|
// load the configuration file.
|
|
return await ApplicationConfiguration
|
|
.LoadAsync(
|
|
new FileInfo(filePath),
|
|
applicationType,
|
|
configurationType,
|
|
applyTraceSettings,
|
|
m_telemetry,
|
|
certificatePasswordProvider,
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
m_logger.LogError(e, "Could not load configuration file. {FilePath}", filePath);
|
|
|
|
// warn user.
|
|
if (!silent)
|
|
{
|
|
if (MessageDlg != null)
|
|
{
|
|
MessageDlg.Message("Load Application Configuration: " + e.Message);
|
|
await MessageDlg.ShowAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
throw;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads the configuration.
|
|
/// </summary>
|
|
internal async ValueTask<ApplicationConfiguration?> LoadAppConfigAsync(
|
|
bool silent,
|
|
Stream stream,
|
|
ApplicationType applicationType,
|
|
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type? configurationType,
|
|
bool applyTraceSettings,
|
|
ICertificatePasswordProvider? certificatePasswordProvider = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
m_logger.LogInformation("Loading application from stream.");
|
|
|
|
try
|
|
{
|
|
// load the configuration file.
|
|
return await ApplicationConfiguration
|
|
.LoadAsync(
|
|
stream,
|
|
applicationType,
|
|
configurationType,
|
|
applyTraceSettings,
|
|
m_telemetry,
|
|
certificatePasswordProvider,
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
m_logger.LogError(e, "Could not load configuration from stream.");
|
|
|
|
// warn user.
|
|
if (!silent)
|
|
{
|
|
if (MessageDlg != null)
|
|
{
|
|
MessageDlg.Message("Load Application Configuration: " + e.Message);
|
|
await MessageDlg.ShowAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
throw;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates an application instance certificate if one does not already exist.
|
|
/// </summary>
|
|
/// <exception cref="ServiceResultException"></exception>
|
|
private async Task<bool> CheckApplicationInstanceCertificateAsync(
|
|
ApplicationConfiguration configuration,
|
|
CertificateIdentifier id,
|
|
Certificate certificate,
|
|
bool silent,
|
|
ushort minimumKeySize,
|
|
CancellationToken ct)
|
|
{
|
|
if (certificate == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// set suppressible errors
|
|
HashSet<StatusCode> approvedCodes =
|
|
[
|
|
StatusCodes.BadCertificateUntrusted,
|
|
StatusCodes.BadCertificateTimeInvalid,
|
|
StatusCodes.BadCertificateIssuerTimeInvalid,
|
|
StatusCodes.BadCertificateHostNameInvalid,
|
|
StatusCodes.BadCertificateRevocationUnknown,
|
|
StatusCodes.BadCertificateIssuerRevocationUnknown
|
|
];
|
|
|
|
m_logger.LogInformation(
|
|
"Check application instance certificate {Certificate}.",
|
|
certificate);
|
|
|
|
try
|
|
{
|
|
// validate certificate via the new CertificateManager pipeline,
|
|
// suppressing the same set of errors that the legacy
|
|
// CertificateValidation event handler used to accept.
|
|
var options = new Security.Certificates.CertificateValidationOptions
|
|
{
|
|
AcceptError = (cert, error) =>
|
|
{
|
|
if (approvedCodes.Contains(error.StatusCode))
|
|
{
|
|
m_logger.LogWarning(
|
|
"Application Certificate Validation suppressed {ErrorMessage}",
|
|
error.StatusCode);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
};
|
|
|
|
using Certificate? publicKeyCert = certificate.HasPrivateKey
|
|
? Certificate.FromRawData(certificate.RawData)
|
|
: null;
|
|
using var chain = new CertificateCollection { publicKeyCert ?? certificate };
|
|
|
|
CertificateValidationResult result = await CertificateManager!
|
|
.ValidateAsync(
|
|
chain,
|
|
trustList: null,
|
|
options: options,
|
|
ct: ct)
|
|
.ConfigureAwait(false);
|
|
|
|
if (!result.IsValid)
|
|
{
|
|
throw new ServiceResultException(result.StatusCode);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
string message = Utils.Format(
|
|
"Error validating certificate. Exception: {0}. Use certificate anyway?",
|
|
ex.Message);
|
|
if (!await ApproveMessageAsync(message, silent).ConfigureAwait(false))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// check key size
|
|
int keySize = X509Utils.GetPublicKeySize(certificate);
|
|
if (minimumKeySize > keySize)
|
|
{
|
|
string message = Utils.Format(
|
|
"The key size ({0}) in the certificate is less than the minimum provided ({1}). Use certificate anyway?",
|
|
keySize,
|
|
minimumKeySize);
|
|
|
|
if (!await ApproveMessageAsync(message, silent).ConfigureAwait(false))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// check domains.
|
|
if (configuration.ApplicationType != ApplicationType.Client &&
|
|
!await CheckDomainsInCertificateAsync(configuration, certificate, silent, ct)
|
|
.ConfigureAwait(false))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Validate that the certificate contains the configuration's ApplicationUri
|
|
// ApplicationUri may be null; CompareApplicationUriWithCertificate's contract is non-null
|
|
// but its implementation returns false on null/empty, preserving prior behavior.
|
|
if (!X509Utils.CompareApplicationUriWithCertificate(
|
|
certificate,
|
|
configuration.ApplicationUri!,
|
|
out IReadOnlyList<string> certificateUris))
|
|
{
|
|
if (certificateUris.Count == 0)
|
|
{
|
|
const string message =
|
|
"The Application URI could not be found in the certificate. Use certificate anyway?";
|
|
if (!await ApproveMessageAsync(message, silent).ConfigureAwait(false))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
string message = Utils.Format(
|
|
"The certificate with subject '{0}' does not contain the ApplicationUri '{1}' " +
|
|
"from the configuration. Certificate contains: {2}. Use certificate anyway?",
|
|
certificate.Subject,
|
|
configuration.ApplicationUri!,
|
|
string.Join(", ", certificateUris));
|
|
|
|
if (!await ApproveMessageAsync(message, silent).ConfigureAwait(false))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
m_logger.LogInformation(
|
|
"Certificate {Certificate} validated for ApplicationUri: {ApplicationUri}",
|
|
certificate,
|
|
configuration.ApplicationUri);
|
|
|
|
// Sync the identifier metadata so subsequent resolver lookups
|
|
// by Thumbprint find the validated cert (the configured XML
|
|
// identifier may carry no Thumbprint).
|
|
id.Thumbprint = certificate.Thumbprint;
|
|
id.SubjectName = certificate.Subject;
|
|
if (id.CertificateType.IsNull)
|
|
{
|
|
id.CertificateType = CertificateIdentifier.GetCertificateType(certificate);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks that the domains in the server addresses match the domains in the certificates.
|
|
/// </summary>
|
|
private async Task<bool> CheckDomainsInCertificateAsync(
|
|
ApplicationConfiguration configuration,
|
|
Certificate certificate,
|
|
bool silent,
|
|
CancellationToken ct)
|
|
{
|
|
m_logger.LogInformation("Check domains in certificate.");
|
|
|
|
bool valid = true;
|
|
ArrayOf<string> serverDomainNames = configuration.GetServerDomainNames();
|
|
ArrayOf<string> certificateDomainNames = X509Utils.GetDomainsFromCertificate(certificate);
|
|
|
|
m_logger.LogInformation("Server Domain names:");
|
|
foreach (string name in serverDomainNames)
|
|
{
|
|
m_logger.LogInformation(" {ServerDomainName}", name);
|
|
}
|
|
|
|
m_logger.LogInformation("Certificate Domain names:");
|
|
foreach (string name in certificateDomainNames)
|
|
{
|
|
m_logger.LogInformation(" {ClientDomainName}", name);
|
|
}
|
|
|
|
// get computer name.
|
|
string computerName = Utils.GetHostName();
|
|
|
|
// get IP addresses.
|
|
IPAddress[]? addresses = null;
|
|
|
|
for (int ii = 0; ii < serverDomainNames.Count; ii++)
|
|
{
|
|
if (certificateDomainNames.Contains(serverDomainNames[ii], StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (string.Equals(
|
|
serverDomainNames[ii],
|
|
"localhost",
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (certificateDomainNames.Contains(computerName, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// check for aliases.
|
|
bool found = false;
|
|
|
|
// get IP addresses only if necessary.
|
|
addresses ??= await Utils.GetHostAddressesAsync(computerName, ct).ConfigureAwait(false);
|
|
|
|
// check for ip addresses.
|
|
for (int jj = 0; jj < addresses.Length; jj++)
|
|
{
|
|
if (certificateDomainNames.Contains(addresses[jj].ToString(), StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (found)
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
|
|
string message = Utils.Format(
|
|
"The server is configured to use domain '{0}' which does not appear in the certificate. Use certificate anyway?",
|
|
serverDomainNames[ii]);
|
|
|
|
valid = false;
|
|
|
|
if (await ApproveMessageAsync(message, silent).ConfigureAwait(false))
|
|
{
|
|
valid = true;
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
return valid;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the application instance certificate.
|
|
/// </summary>
|
|
/// <param name="configuration">The configuration.</param>
|
|
/// <param name="id">The certificate identifier.</param>
|
|
/// <param name="minimumKeySize">Minimum RSA key size to use when creating the certificate.</param>
|
|
/// <param name="lifeTimeInMonths">The lifetime in months.</param>
|
|
/// <param name="ct">Cancellation token to cancel operation with</param>
|
|
/// <returns>The new certificate</returns>
|
|
/// <exception cref="ServiceResultException"></exception>
|
|
private async Task<Certificate?> CreateApplicationInstanceCertificateAsync(
|
|
ApplicationConfiguration configuration,
|
|
CertificateIdentifier id,
|
|
ushort minimumKeySize,
|
|
ushort lifeTimeInMonths,
|
|
CancellationToken ct)
|
|
{
|
|
// delete any existing certificate.
|
|
await DeleteApplicationInstanceCertificateAsync(configuration, id, ct).ConfigureAwait(
|
|
false);
|
|
|
|
m_logger.LogInformation("Creating application instance certificate.");
|
|
|
|
// get the domains from the configuration file.
|
|
ArrayOf<string> serverDomainNames = configuration.GetServerDomainNames();
|
|
|
|
if (serverDomainNames.IsEmpty)
|
|
{
|
|
serverDomainNames = [Utils.GetHostName()];
|
|
}
|
|
|
|
// ensure the certificate store directory exists.
|
|
if (id.StoreType == CertificateStoreType.Directory)
|
|
{
|
|
// GetAbsoluteDirectoryPath requires non-null; StorePath is checked above by StoreType comparison.
|
|
Utils.GetAbsoluteDirectoryPath(id.StorePath!, true, true, true);
|
|
}
|
|
|
|
ICertificateBuilder builder = DefaultCertificateFactory.Instance
|
|
.CreateApplicationCertificate(
|
|
configuration.ApplicationUri!,
|
|
configuration.ApplicationName!,
|
|
id.SubjectName!,
|
|
serverDomainNames.ToList())
|
|
.SetLifeTime(lifeTimeInMonths);
|
|
|
|
Certificate newCertificate;
|
|
if (id.CertificateType.IsNull ||
|
|
id.CertificateType == ObjectTypeIds.ApplicationCertificateType ||
|
|
id.CertificateType == ObjectTypeIds.RsaMinApplicationCertificateType ||
|
|
id.CertificateType == ObjectTypeIds.RsaSha256ApplicationCertificateType)
|
|
{
|
|
ushort keySize = minimumKeySize == 0
|
|
? CertificateFactory.DefaultKeySize
|
|
: minimumKeySize;
|
|
|
|
newCertificate = builder.SetRSAKeySize(keySize).CreateForRSA();
|
|
|
|
m_logger.LogInformation(
|
|
"Certificate {Certificate} created for RSA with key size {KeySize} bits.",
|
|
newCertificate,
|
|
keySize);
|
|
}
|
|
else
|
|
{
|
|
ECCurve? curve =
|
|
CryptoUtils.GetCurveFromCertificateTypeId(id.CertificateType)
|
|
?? throw ServiceResultException.ConfigurationError(
|
|
"The Ecc certificate type is not supported.");
|
|
|
|
newCertificate = builder.SetECCurve(curve.Value).CreateForECDsa();
|
|
|
|
m_logger.LogInformation(
|
|
"Certificate {Certificate} created for {Curve}.",
|
|
newCertificate,
|
|
curve.Value.Oid.FriendlyName);
|
|
}
|
|
|
|
// Update the identifier metadata so subsequent resolver lookups
|
|
// (which key by Thumbprint / SubjectName) match the freshly
|
|
// generated cert. The identifier itself remains pure metadata.
|
|
id.SubjectName = newCertificate.Subject;
|
|
id.Thumbprint = newCertificate.Thumbprint;
|
|
if (id.CertificateType.IsNull)
|
|
{
|
|
id.CertificateType = CertificateIdentifier.GetCertificateType(newCertificate);
|
|
}
|
|
|
|
ICertificatePasswordProvider? passwordProvider = configuration
|
|
.SecurityConfiguration
|
|
.CertificatePasswordProvider;
|
|
await newCertificate.AddToStoreAsync(
|
|
id.StoreType!,
|
|
id.StorePath!,
|
|
passwordProvider?.GetPassword(id),
|
|
m_telemetry,
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
|
|
// ensure the certificate is trusted.
|
|
if (configuration.SecurityConfiguration.AddAppCertToTrustedStore)
|
|
{
|
|
await AddToTrustedStoreAsync(configuration, newCertificate, ct).ConfigureAwait(
|
|
false);
|
|
}
|
|
|
|
// reload the certificate from disk to get the durable on-disk
|
|
// private-key handle (the in-memory cert from CreateForXxx is a
|
|
// builder-produced ephemeral instance).
|
|
Certificate? reloaded = await CertificateIdentifierResolver
|
|
.LoadPrivateKeyAsync(
|
|
id,
|
|
passwordProvider,
|
|
configuration.ApplicationUri,
|
|
m_telemetry,
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
if (reloaded != null)
|
|
{
|
|
newCertificate.Dispose();
|
|
newCertificate = reloaded;
|
|
}
|
|
|
|
// Refresh CertificateManager so newly-created certificates are
|
|
// visible to subsequent ValidateAsync / GetInstanceCertificate
|
|
// callers (replaces the legacy validator.UpdateAsync hot-update
|
|
// path).
|
|
if (configuration.CertificateManager != null)
|
|
{
|
|
await configuration.CertificateManager.UpdateAsync(
|
|
configuration.SecurityConfiguration,
|
|
configuration.ApplicationUri,
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
m_logger.LogInformation(
|
|
"Certificate {Certificate} created for {ApplicationUri}.",
|
|
newCertificate,
|
|
configuration.ApplicationUri);
|
|
|
|
// do not dispose temp cert, or X509Store certs become unusable
|
|
|
|
return newCertificate;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes an existing application instance certificate.
|
|
/// </summary>
|
|
/// <param name="configuration">The configuration instance that stores the configurable information for a UA application.</param>
|
|
/// <param name="id">The certificate identifier.</param>
|
|
/// <param name="ct">Cancellation token to cancel operation with</param>
|
|
private async Task DeleteApplicationInstanceCertificateAsync(
|
|
ApplicationConfiguration configuration,
|
|
CertificateIdentifier id,
|
|
CancellationToken ct)
|
|
{
|
|
if (id == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// delete certificate and private key.
|
|
Certificate? certificate = await CertificateIdentifierResolver
|
|
.ResolveAsync(
|
|
id,
|
|
registry: null,
|
|
needPrivateKey: false,
|
|
configuration.ApplicationUri,
|
|
m_telemetry,
|
|
ct)
|
|
.ConfigureAwait(false);
|
|
|
|
if (certificate != null)
|
|
{
|
|
m_logger.LogInformation(
|
|
Utils.TraceMasks.Security,
|
|
"Deleting application instance certificate {Certificate} and private key.",
|
|
certificate);
|
|
}
|
|
|
|
// delete trusted peer certificate.
|
|
if (configuration.SecurityConfiguration != null &&
|
|
configuration.SecurityConfiguration.TrustedPeerCertificates != null)
|
|
{
|
|
string? thumbprint = id.Thumbprint;
|
|
|
|
if (certificate != null)
|
|
{
|
|
thumbprint = certificate.Thumbprint;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(thumbprint))
|
|
{
|
|
using ICertificateStore store = configuration.SecurityConfiguration
|
|
.TrustedPeerCertificates
|
|
.OpenStore(m_telemetry!);
|
|
if (store != null)
|
|
{
|
|
bool deleted = await store.DeleteAsync(thumbprint!, ct)
|
|
.ConfigureAwait(false);
|
|
if (deleted)
|
|
{
|
|
m_logger.LogInformation(
|
|
Utils.TraceMasks.Security,
|
|
"Application Instance Certificate [{Thumbprint}] deleted from trusted store.",
|
|
thumbprint);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// delete certificate and private key from owner store.
|
|
if (certificate != null)
|
|
{
|
|
using ICertificateStore? store = CertificateIdentifierResolver
|
|
.OpenStore(id, m_telemetry);
|
|
if (store != null)
|
|
{
|
|
bool deleted = await store.DeleteAsync(certificate.Thumbprint, ct)
|
|
.ConfigureAwait(false);
|
|
if (deleted)
|
|
{
|
|
m_logger.LogInformation(
|
|
Utils.TraceMasks.Security,
|
|
"Application certificate {Certificate} and private key deleted.",
|
|
certificate);
|
|
}
|
|
}
|
|
certificate.Dispose();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds the certificate to the Trusted Certificate Store
|
|
/// </summary>
|
|
/// <param name="configuration">The application's configuration which specifies the location of the TrustedStore.</param>
|
|
/// <param name="certificate">The certificate to register.</param>
|
|
/// <param name="ct">The cancellation token.</param>
|
|
/// <exception cref="ArgumentNullException"><paramref name="certificate"/> is <c>null</c>.</exception>
|
|
private async Task AddToTrustedStoreAsync(
|
|
ApplicationConfiguration configuration,
|
|
Certificate certificate,
|
|
CancellationToken ct)
|
|
{
|
|
if (certificate == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(certificate));
|
|
}
|
|
|
|
if (configuration == null ||
|
|
configuration.SecurityConfiguration == null ||
|
|
configuration.SecurityConfiguration.TrustedPeerCertificates == null ||
|
|
string.IsNullOrEmpty(configuration.SecurityConfiguration.TrustedPeerCertificates.StorePath))
|
|
{
|
|
m_logger.LogWarning("WARNING: Trusted peer store not specified.");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
using ICertificateStore? store = configuration.SecurityConfiguration
|
|
.TrustedPeerCertificates
|
|
.OpenStore(m_telemetry!);
|
|
|
|
if (store == null)
|
|
{
|
|
m_logger.LogWarning("Could not open trusted peer store.");
|
|
return;
|
|
}
|
|
|
|
// check if it already exists.
|
|
using CertificateCollection existingCertificates = await store
|
|
.FindByThumbprintAsync(certificate.Thumbprint, ct)
|
|
.ConfigureAwait(false);
|
|
|
|
if (existingCertificates.Count > 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
m_logger.LogInformation(
|
|
"Adding application certificate {Certificate} to trusted peer store.",
|
|
certificate);
|
|
|
|
List<string> subjectName = X509Utils.ParseDistinguishedName(
|
|
certificate.Subject);
|
|
|
|
// check for old certificate.
|
|
using CertificateCollection certificates = await store.EnumerateAsync(ct)
|
|
.ConfigureAwait(false);
|
|
|
|
for (int ii = 0; ii < certificates.Count; ii++)
|
|
{
|
|
if (X509Utils.CompareDistinguishedName(certificates[ii], subjectName))
|
|
{
|
|
if (certificates[ii].Thumbprint == certificate.Thumbprint)
|
|
{
|
|
return;
|
|
}
|
|
|
|
bool deleteCert = false;
|
|
if (X509Utils.IsECDsaSignature(certificates[ii]) &&
|
|
X509Utils.IsECDsaSignature(certificate))
|
|
{
|
|
if (X509Utils
|
|
.GetECDsaQualifier(certificates[ii])
|
|
.Equals(
|
|
X509Utils.GetECDsaQualifier(certificate),
|
|
StringComparison.Ordinal))
|
|
{
|
|
deleteCert = true;
|
|
}
|
|
}
|
|
else if (!X509Utils.IsECDsaSignature(certificates[ii]) &&
|
|
!X509Utils.IsECDsaSignature(certificate))
|
|
{
|
|
deleteCert = true;
|
|
}
|
|
|
|
if (deleteCert)
|
|
{
|
|
m_logger.LogInformation(
|
|
"Delete Certificate {Certificate} from trusted store.",
|
|
certificate);
|
|
await store.DeleteAsync(certificates[ii].Thumbprint, ct)
|
|
.ConfigureAwait(false);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// add new certificate.
|
|
using var publicKey = Certificate.FromRawData(certificate.RawData);
|
|
await store.AddAsync(publicKey, ct: ct).ConfigureAwait(false);
|
|
|
|
m_logger.LogInformation("Added application certificate to trusted peer store.");
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
m_logger.LogError(
|
|
"Could not add certificate to trusted peer store: {ErrorMessage}",
|
|
Redaction.Redact.Create(e));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Show a message for approval and return result.
|
|
/// </summary>
|
|
/// <returns>True if approved, false otherwise.</returns>
|
|
private async Task<bool> ApproveMessageAsync(string message, bool silent)
|
|
{
|
|
if (!silent && MessageDlg != null)
|
|
{
|
|
MessageDlg.Message(message, true);
|
|
return await MessageDlg.ShowAsync().ConfigureAwait(false);
|
|
}
|
|
m_logger.LogError("Approve Message prompt: {Message} -> Rejected", message);
|
|
return false;
|
|
}
|
|
|
|
private readonly ITelemetryContext? m_telemetry;
|
|
private readonly ILogger m_logger;
|
|
}
|
|
}
|