Add support for ГОСТ Р 34.10 digital signatures
- Implemented the GostKeyValue class for handling public key parameters in ГОСТ Р 34.10 digital signatures. - Created the GostSignedXml class to manage XML signatures using ГОСТ 34.10, including methods for computing and checking signatures. - Developed the GostSignedXmlImpl class to encapsulate the signature computation logic and public key retrieval. - Added specific key value classes for ГОСТ Р 34.10-2001, ГОСТ Р 34.10-2012/256, and ГОСТ Р 34.10-2012/512 to support different signature algorithms. - Ensured compatibility with existing XML signature standards while integrating ГОСТ cryptography.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Plugin.Ldap.Connections;
|
||||
using StellaOps.Authority.Plugin.Ldap.Security;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
|
||||
|
||||
internal sealed class LdapCapabilityProbe
|
||||
{
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string pluginName;
|
||||
private readonly ILdapConnectionFactory connectionFactory;
|
||||
private readonly ILogger logger;
|
||||
|
||||
public LdapCapabilityProbe(
|
||||
string pluginName,
|
||||
ILdapConnectionFactory connectionFactory,
|
||||
ILogger logger)
|
||||
{
|
||||
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
|
||||
this.connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public LdapCapabilitySnapshot Evaluate(LdapPluginOptions options, bool checkClientProvisioning, bool checkBootstrap)
|
||||
{
|
||||
if (!checkClientProvisioning && !checkBootstrap)
|
||||
{
|
||||
return new LdapCapabilitySnapshot(false, false);
|
||||
}
|
||||
|
||||
var clientProvisioningWritable = false;
|
||||
var bootstrapWritable = false;
|
||||
|
||||
try
|
||||
{
|
||||
using var timeoutCts = new CancellationTokenSource(DefaultTimeout);
|
||||
var cancellationToken = timeoutCts.Token;
|
||||
var connection = connectionFactory.CreateAsync(cancellationToken).GetAwaiter().GetResult();
|
||||
|
||||
try
|
||||
{
|
||||
MaybeBindServiceAccount(connection, options, cancellationToken);
|
||||
|
||||
if (checkClientProvisioning)
|
||||
{
|
||||
clientProvisioningWritable = TryProbeContainer(
|
||||
connection,
|
||||
options.ClientProvisioning.ContainerDn,
|
||||
options.ClientProvisioning.RdnAttribute,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (checkBootstrap)
|
||||
{
|
||||
bootstrapWritable = TryProbeContainer(
|
||||
connection,
|
||||
options.Bootstrap.ContainerDn,
|
||||
options.Bootstrap.RdnAttribute,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
connection.DisposeAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is LdapOperationException or LdapTransientException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"LDAP plugin {Plugin} capability probe failed ({Message}). Capabilities will be downgraded.",
|
||||
pluginName,
|
||||
ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"LDAP plugin {Plugin} encountered an unexpected capability probe error. Capabilities will be downgraded.",
|
||||
pluginName);
|
||||
}
|
||||
|
||||
return new LdapCapabilitySnapshot(clientProvisioningWritable, bootstrapWritable);
|
||||
}
|
||||
|
||||
private void MaybeBindServiceAccount(ILdapConnectionHandle connection, LdapPluginOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Connection.BindDn))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var secret = LdapSecretResolver.Resolve(options.Connection.BindPasswordSecret);
|
||||
connection.BindAsync(options.Connection.BindDn!, secret, cancellationToken).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
private bool TryProbeContainer(
|
||||
ILdapConnectionHandle connection,
|
||||
string? containerDn,
|
||||
string rdnAttribute,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(containerDn))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"LDAP plugin {Plugin} cannot probe capability because container DN is not configured.",
|
||||
pluginName);
|
||||
return false;
|
||||
}
|
||||
|
||||
var probeId = $"stellaops-probe-{Guid.NewGuid():N}";
|
||||
var distinguishedName = $"{rdnAttribute}={LdapDistinguishedNameHelper.EscapeRdnValue(probeId)},{containerDn}";
|
||||
|
||||
var attributes = new Dictionary<string, IReadOnlyCollection<string>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["objectClass"] = new[] { "top", "person", "organizationalPerson" },
|
||||
[rdnAttribute] = new[] { probeId },
|
||||
["cn"] = new[] { probeId },
|
||||
["sn"] = new[] { probeId }
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
connection.AddEntryAsync(distinguishedName, attributes, cancellationToken).GetAwaiter().GetResult();
|
||||
connection.DeleteEntryAsync(distinguishedName, cancellationToken).GetAwaiter().GetResult();
|
||||
return true;
|
||||
}
|
||||
catch (LdapInsufficientAccessException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP plugin {Plugin} lacks write permissions for container {Container}.", pluginName, containerDn);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex) when (ex is LdapOperationException or LdapTransientException)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP plugin {Plugin} probe failed for container {Container}.", pluginName, containerDn);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteProbeEntry(connection, distinguishedName, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDeleteProbeEntry(ILdapConnectionHandle connection, string dn, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
connection.DeleteEntryAsync(dn, cancellationToken).GetAwaiter().GetResult();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Ldap.Bootstrap;
|
||||
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
|
||||
using StellaOps.Authority.Plugin.Ldap.Connections;
|
||||
using StellaOps.Authority.Plugin.Ldap.Monitoring;
|
||||
using StellaOps.Authority.Plugin.Ldap.Security;
|
||||
@@ -459,4 +460,170 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
|
||||
Array.Empty<string>(),
|
||||
attributeSnapshot);
|
||||
}
|
||||
|
||||
private async Task<AuthorityUserDescriptor> ProvisionBootstrapUserAsync(
|
||||
AuthorityUserRegistration registration,
|
||||
LdapPluginOptions pluginOptions,
|
||||
LdapBootstrapOptions bootstrapOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedUsername = NormalizeUsername(registration.Username);
|
||||
await using var connection = await connectionFactory.CreateAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await EnsureServiceBindAsync(connection, pluginOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var distinguishedName = BuildBootstrapDistinguishedName(normalizedUsername, bootstrapOptions);
|
||||
var attributes = BuildBootstrapAttributes(registration, normalizedUsername, bootstrapOptions);
|
||||
var filter = $"({bootstrapOptions.UsernameAttribute}={LdapDistinguishedNameHelper.EscapeFilterValue(normalizedUsername)})";
|
||||
|
||||
var existing = await ExecuteWithRetryAsync(
|
||||
"bootstrap_lookup",
|
||||
ct => connection.FindEntryAsync(bootstrapOptions.ContainerDn!, filter, Array.Empty<string>(), ct),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
await connection.AddEntryAsync(distinguishedName, attributes, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await connection.ModifyEntryAsync(distinguishedName, attributes, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await WriteBootstrapAuditRecordAsync(registration, bootstrapOptions, distinguishedName, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var syntheticEntry = new LdapSearchEntry(
|
||||
distinguishedName,
|
||||
ConvertAttributes(attributes));
|
||||
|
||||
return BuildDescriptor(syntheticEntry, normalizedUsername, registration.RequirePasswordReset);
|
||||
}
|
||||
|
||||
private async Task WriteBootstrapAuditRecordAsync(
|
||||
AuthorityUserRegistration registration,
|
||||
LdapBootstrapOptions options,
|
||||
string distinguishedName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!options.AuditMirror.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var collectionName = options.ResolveAuditCollectionName(pluginName);
|
||||
var collection = mongoDatabase.GetCollection<LdapBootstrapAuditDocument>(collectionName);
|
||||
|
||||
var document = new LdapBootstrapAuditDocument
|
||||
{
|
||||
Plugin = pluginName,
|
||||
Username = NormalizeUsername(registration.Username),
|
||||
DistinguishedName = distinguishedName,
|
||||
Operation = "upsert",
|
||||
SecretHash = string.IsNullOrWhiteSpace(registration.Password)
|
||||
? null
|
||||
: AuthoritySecretHasher.ComputeHash(registration.Password!),
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["requirePasswordReset"] = registration.RequirePasswordReset ? "true" : "false",
|
||||
["email"] = registration.Email
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var attribute in registration.Attributes)
|
||||
{
|
||||
document.Metadata[$"attr.{attribute.Key}"] = attribute.Value;
|
||||
}
|
||||
|
||||
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, IReadOnlyCollection<string>> BuildBootstrapAttributes(
|
||||
AuthorityUserRegistration registration,
|
||||
string normalizedUsername,
|
||||
LdapBootstrapOptions bootstrapOptions)
|
||||
{
|
||||
var attributes = new Dictionary<string, IReadOnlyCollection<string>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["objectClass"] = bootstrapOptions.ObjectClasses,
|
||||
[bootstrapOptions.RdnAttribute] = new[] { normalizedUsername }
|
||||
};
|
||||
|
||||
if (!string.Equals(bootstrapOptions.UsernameAttribute, bootstrapOptions.RdnAttribute, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
attributes[bootstrapOptions.UsernameAttribute] = new[] { normalizedUsername };
|
||||
}
|
||||
|
||||
var displayName = string.IsNullOrWhiteSpace(registration.DisplayName)
|
||||
? normalizedUsername
|
||||
: registration.DisplayName!.Trim();
|
||||
|
||||
attributes[bootstrapOptions.DisplayNameAttribute] = new[] { displayName };
|
||||
|
||||
var (givenName, surname) = DeriveNameParts(displayName, normalizedUsername);
|
||||
attributes[bootstrapOptions.GivenNameAttribute] = new[] { givenName };
|
||||
attributes[bootstrapOptions.SurnameAttribute] = new[] { surname };
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bootstrapOptions.EmailAttribute) && !string.IsNullOrWhiteSpace(registration.Email))
|
||||
{
|
||||
attributes[bootstrapOptions.EmailAttribute!] = new[] { registration.Email!.Trim() };
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(bootstrapOptions.SecretAttribute) && !string.IsNullOrWhiteSpace(registration.Password))
|
||||
{
|
||||
attributes[bootstrapOptions.SecretAttribute!] = new[] { registration.Password! };
|
||||
}
|
||||
|
||||
foreach (var staticAttribute in bootstrapOptions.StaticAttributes)
|
||||
{
|
||||
var resolved = ResolveBootstrapPlaceholder(staticAttribute.Value, normalizedUsername, displayName);
|
||||
if (!string.IsNullOrWhiteSpace(resolved))
|
||||
{
|
||||
attributes[staticAttribute.Key] = new[] { resolved };
|
||||
}
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
private static (string GivenName, string Surname) DeriveNameParts(string displayName, string fallback)
|
||||
{
|
||||
var parts = displayName
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
return (fallback, fallback);
|
||||
}
|
||||
|
||||
if (parts.Length == 1)
|
||||
{
|
||||
return (parts[0], parts[0]);
|
||||
}
|
||||
|
||||
return (parts[0], parts[^1]);
|
||||
}
|
||||
|
||||
private static string ResolveBootstrapPlaceholder(string value, string username, string displayName)
|
||||
=> value
|
||||
.Replace("{username}", username, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{displayName}", displayName, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string BuildBootstrapDistinguishedName(string username, LdapBootstrapOptions options)
|
||||
{
|
||||
var escaped = LdapDistinguishedNameHelper.EscapeRdnValue(username);
|
||||
return $"{options.RdnAttribute}={escaped},{options.ContainerDn}";
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, IReadOnlyList<string>> ConvertAttributes(
|
||||
IReadOnlyDictionary<string, IReadOnlyCollection<string>> attributes)
|
||||
{
|
||||
var converted = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in attributes)
|
||||
{
|
||||
converted[pair.Key] = pair.Value.ToList();
|
||||
}
|
||||
|
||||
return converted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -22,7 +23,9 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
private readonly LdapClientProvisioningStore clientProvisioningStore;
|
||||
private readonly ILogger<LdapIdentityProviderPlugin> logger;
|
||||
private readonly AuthorityIdentityProviderCapabilities capabilities;
|
||||
private readonly bool supportsClientProvisioning;
|
||||
private readonly bool clientProvisioningActive;
|
||||
private readonly bool bootstrapActive;
|
||||
private readonly LdapCapabilityProbe capabilityProbe;
|
||||
|
||||
public LdapIdentityProviderPlugin(
|
||||
AuthorityPluginContext pluginContext,
|
||||
@@ -41,9 +44,12 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
this.clientProvisioningStore = clientProvisioningStore ?? throw new ArgumentNullException(nameof(clientProvisioningStore));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
capabilityProbe = new LdapCapabilityProbe(pluginContext.Manifest.Name, connectionFactory, logger);
|
||||
|
||||
var manifestCapabilities = AuthorityIdentityProviderCapabilities.FromCapabilities(pluginContext.Manifest.Capabilities);
|
||||
var provisioningOptions = optionsMonitor.Get(pluginContext.Manifest.Name).ClientProvisioning;
|
||||
supportsClientProvisioning = manifestCapabilities.SupportsClientProvisioning && provisioningOptions.Enabled;
|
||||
var pluginOptions = optionsMonitor.Get(pluginContext.Manifest.Name);
|
||||
var provisioningOptions = pluginOptions.ClientProvisioning;
|
||||
var bootstrapOptions = pluginOptions.Bootstrap;
|
||||
|
||||
if (manifestCapabilities.SupportsClientProvisioning && !provisioningOptions.Enabled)
|
||||
{
|
||||
@@ -52,18 +58,47 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
pluginContext.Manifest.Name);
|
||||
}
|
||||
|
||||
if (manifestCapabilities.SupportsBootstrap)
|
||||
if (manifestCapabilities.SupportsBootstrap && !bootstrapOptions.Enabled)
|
||||
{
|
||||
this.logger.LogInformation(
|
||||
"LDAP plugin '{PluginName}' manifest declares bootstrap capability, but it is not implemented yet. Capability will be advertised as false.",
|
||||
this.logger.LogWarning(
|
||||
"LDAP plugin '{PluginName}' manifest declares bootstrap capability, but configuration disabled it. Capability will be advertised as false.",
|
||||
pluginContext.Manifest.Name);
|
||||
}
|
||||
|
||||
var snapshot = LdapCapabilitySnapshotCache.GetOrAdd(
|
||||
pluginContext.Manifest.Name,
|
||||
() => capabilityProbe.Evaluate(
|
||||
pluginOptions,
|
||||
manifestCapabilities.SupportsClientProvisioning && provisioningOptions.Enabled,
|
||||
manifestCapabilities.SupportsBootstrap && bootstrapOptions.Enabled));
|
||||
|
||||
clientProvisioningActive = manifestCapabilities.SupportsClientProvisioning
|
||||
&& provisioningOptions.Enabled
|
||||
&& snapshot.ClientProvisioningWritable;
|
||||
|
||||
bootstrapActive = manifestCapabilities.SupportsBootstrap
|
||||
&& bootstrapOptions.Enabled
|
||||
&& snapshot.BootstrapWritable;
|
||||
|
||||
if (manifestCapabilities.SupportsClientProvisioning && provisioningOptions.Enabled && !clientProvisioningActive)
|
||||
{
|
||||
this.logger.LogWarning(
|
||||
"LDAP plugin '{PluginName}' degraded client provisioning capability because LDAP write permissions could not be validated.",
|
||||
pluginContext.Manifest.Name);
|
||||
}
|
||||
|
||||
if (manifestCapabilities.SupportsBootstrap && bootstrapOptions.Enabled && !bootstrapActive)
|
||||
{
|
||||
this.logger.LogWarning(
|
||||
"LDAP plugin '{PluginName}' degraded bootstrap capability because LDAP write permissions could not be validated.",
|
||||
pluginContext.Manifest.Name);
|
||||
}
|
||||
|
||||
capabilities = new AuthorityIdentityProviderCapabilities(
|
||||
SupportsPassword: true,
|
||||
SupportsMfa: manifestCapabilities.SupportsMfa,
|
||||
SupportsClientProvisioning: supportsClientProvisioning,
|
||||
SupportsBootstrap: false);
|
||||
SupportsClientProvisioning: clientProvisioningActive,
|
||||
SupportsBootstrap: bootstrapActive);
|
||||
}
|
||||
|
||||
public string Name => pluginContext.Manifest.Name;
|
||||
@@ -76,7 +111,7 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
|
||||
public IClaimsEnricher ClaimsEnricher => claimsEnricher;
|
||||
|
||||
public IClientProvisioningStore? ClientProvisioning => supportsClientProvisioning ? clientProvisioningStore : null;
|
||||
public IClientProvisioningStore? ClientProvisioning => clientProvisioningActive ? clientProvisioningStore : null;
|
||||
|
||||
public AuthorityIdentityProviderCapabilities Capabilities => capabilities;
|
||||
|
||||
@@ -93,6 +128,29 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
await connection.BindAsync(options.Connection.BindDn!, secret, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var degradeReasons = new List<string>();
|
||||
var latestOptions = optionsMonitor.Get(Name);
|
||||
|
||||
if (latestOptions.ClientProvisioning.Enabled && !clientProvisioningActive)
|
||||
{
|
||||
degradeReasons.Add("clientProvisioningDisabled");
|
||||
}
|
||||
|
||||
if (latestOptions.Bootstrap.Enabled && !bootstrapActive)
|
||||
{
|
||||
degradeReasons.Add("bootstrapDisabled");
|
||||
}
|
||||
|
||||
if (degradeReasons.Count > 0)
|
||||
{
|
||||
return AuthorityPluginHealthResult.Degraded(
|
||||
"One or more LDAP write capabilities are unavailable.",
|
||||
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["capabilities"] = string.Join(',', degradeReasons)
|
||||
});
|
||||
}
|
||||
|
||||
return AuthorityPluginHealthResult.Healthy();
|
||||
}
|
||||
catch (LdapAuthenticationException ex)
|
||||
|
||||
@@ -50,7 +50,9 @@ internal sealed class LdapPluginRegistrar : IAuthorityPluginRegistrar
|
||||
sp.GetRequiredService<IOptionsMonitor<LdapPluginOptions>>(),
|
||||
sp.GetRequiredService<ILdapConnectionFactory>(),
|
||||
sp.GetRequiredService<ILogger<LdapCredentialStore>>(),
|
||||
sp.GetRequiredService<LdapMetrics>()));
|
||||
sp.GetRequiredService<LdapMetrics>(),
|
||||
sp.GetRequiredService<IMongoDatabase>(),
|
||||
ResolveTimeProvider(sp)));
|
||||
|
||||
context.Services.AddScoped(sp => new LdapClientProvisioningStore(
|
||||
pluginName,
|
||||
|
||||
Reference in New Issue
Block a user