up
This commit is contained in:
		@@ -1,6 +1,5 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Security.Cryptography;
 | 
			
		||||
using System.Text;
 | 
			
		||||
using StellaOps.Cryptography;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Plugin.Standard.Security;
 | 
			
		||||
 | 
			
		||||
@@ -18,96 +17,70 @@ internal enum PasswordVerificationResult
 | 
			
		||||
    SuccessRehashNeeded
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
internal sealed class Pbkdf2PasswordHasher : IPasswordHasher
 | 
			
		||||
internal sealed class CryptoPasswordHasher : IPasswordHasher
 | 
			
		||||
{
 | 
			
		||||
    private const int SaltSize = 16;
 | 
			
		||||
    private const int HashSize = 32;
 | 
			
		||||
    private const int Iterations = 210_000;
 | 
			
		||||
    private const string Header = "PBKDF2";
 | 
			
		||||
    private readonly StandardPluginOptions options;
 | 
			
		||||
    private readonly ICryptoProvider cryptoProvider;
 | 
			
		||||
 | 
			
		||||
    public CryptoPasswordHasher(StandardPluginOptions options, ICryptoProvider cryptoProvider)
 | 
			
		||||
    {
 | 
			
		||||
        this.options = options ?? throw new ArgumentNullException(nameof(options));
 | 
			
		||||
        this.cryptoProvider = cryptoProvider ?? throw new ArgumentNullException(nameof(cryptoProvider));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public string Hash(string password)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrEmpty(password))
 | 
			
		||||
        {
 | 
			
		||||
            throw new ArgumentException("Password is required.", nameof(password));
 | 
			
		||||
        }
 | 
			
		||||
        ArgumentException.ThrowIfNullOrEmpty(password);
 | 
			
		||||
 | 
			
		||||
        Span<byte> salt = stackalloc byte[SaltSize];
 | 
			
		||||
        RandomNumberGenerator.Fill(salt);
 | 
			
		||||
        var hashOptions = options.PasswordHashing;
 | 
			
		||||
        hashOptions.Validate();
 | 
			
		||||
 | 
			
		||||
        Span<byte> hash = stackalloc byte[HashSize];
 | 
			
		||||
        var derived = Rfc2898DeriveBytes.Pbkdf2(password, salt.ToArray(), Iterations, HashAlgorithmName.SHA256, HashSize);
 | 
			
		||||
        derived.CopyTo(hash);
 | 
			
		||||
 | 
			
		||||
        var payload = new byte[1 + SaltSize + HashSize];
 | 
			
		||||
        payload[0] = 0x01; // version
 | 
			
		||||
        salt.CopyTo(payload.AsSpan(1));
 | 
			
		||||
        hash.CopyTo(payload.AsSpan(1 + SaltSize));
 | 
			
		||||
 | 
			
		||||
        var builder = new StringBuilder();
 | 
			
		||||
        builder.Append(Header);
 | 
			
		||||
        builder.Append('.');
 | 
			
		||||
        builder.Append(Iterations);
 | 
			
		||||
        builder.Append('.');
 | 
			
		||||
        builder.Append(Convert.ToBase64String(payload));
 | 
			
		||||
        return builder.ToString();
 | 
			
		||||
        var hasher = cryptoProvider.GetPasswordHasher(hashOptions.Algorithm.ToAlgorithmId());
 | 
			
		||||
        return hasher.Hash(password, hashOptions);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public PasswordVerificationResult Verify(string password, string hashedPassword)
 | 
			
		||||
    {
 | 
			
		||||
        if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(hashedPassword))
 | 
			
		||||
        ArgumentException.ThrowIfNullOrEmpty(password);
 | 
			
		||||
        ArgumentException.ThrowIfNullOrEmpty(hashedPassword);
 | 
			
		||||
 | 
			
		||||
        var desired = options.PasswordHashing;
 | 
			
		||||
        desired.Validate();
 | 
			
		||||
 | 
			
		||||
        var primaryHasher = cryptoProvider.GetPasswordHasher(desired.Algorithm.ToAlgorithmId());
 | 
			
		||||
 | 
			
		||||
        if (IsArgon2Hash(hashedPassword))
 | 
			
		||||
        {
 | 
			
		||||
            return PasswordVerificationResult.Failed;
 | 
			
		||||
            if (!primaryHasher.Verify(password, hashedPassword))
 | 
			
		||||
            {
 | 
			
		||||
                return PasswordVerificationResult.Failed;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return primaryHasher.NeedsRehash(hashedPassword, desired)
 | 
			
		||||
                ? PasswordVerificationResult.SuccessRehashNeeded
 | 
			
		||||
                : PasswordVerificationResult.Success;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var parts = hashedPassword.Split('.', StringSplitOptions.RemoveEmptyEntries);
 | 
			
		||||
        if (parts.Length != 3 || !string.Equals(parts[0], Header, StringComparison.Ordinal))
 | 
			
		||||
        if (IsLegacyPbkdf2Hash(hashedPassword))
 | 
			
		||||
        {
 | 
			
		||||
            return PasswordVerificationResult.Failed;
 | 
			
		||||
            var legacyHasher = cryptoProvider.GetPasswordHasher(PasswordHashAlgorithm.Pbkdf2.ToAlgorithmId());
 | 
			
		||||
            if (!legacyHasher.Verify(password, hashedPassword))
 | 
			
		||||
            {
 | 
			
		||||
                return PasswordVerificationResult.Failed;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return desired.Algorithm == PasswordHashAlgorithm.Pbkdf2 &&
 | 
			
		||||
                   !legacyHasher.NeedsRehash(hashedPassword, desired)
 | 
			
		||||
                ? PasswordVerificationResult.Success
 | 
			
		||||
                : PasswordVerificationResult.SuccessRehashNeeded;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!int.TryParse(parts[1], out var iterations))
 | 
			
		||||
        {
 | 
			
		||||
            return PasswordVerificationResult.Failed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        byte[] payload;
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            payload = Convert.FromBase64String(parts[2]);
 | 
			
		||||
        }
 | 
			
		||||
        catch (FormatException)
 | 
			
		||||
        {
 | 
			
		||||
            return PasswordVerificationResult.Failed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (payload.Length != 1 + SaltSize + HashSize)
 | 
			
		||||
        {
 | 
			
		||||
            return PasswordVerificationResult.Failed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var version = payload[0];
 | 
			
		||||
        if (version != 0x01)
 | 
			
		||||
        {
 | 
			
		||||
            return PasswordVerificationResult.Failed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var salt = new byte[SaltSize];
 | 
			
		||||
        Array.Copy(payload, 1, salt, 0, SaltSize);
 | 
			
		||||
 | 
			
		||||
        var expectedHash = new byte[HashSize];
 | 
			
		||||
        Array.Copy(payload, 1 + SaltSize, expectedHash, 0, HashSize);
 | 
			
		||||
 | 
			
		||||
        var actualHash = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, HashSize);
 | 
			
		||||
 | 
			
		||||
        var success = CryptographicOperations.FixedTimeEquals(expectedHash, actualHash);
 | 
			
		||||
        if (!success)
 | 
			
		||||
        {
 | 
			
		||||
            return PasswordVerificationResult.Failed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return iterations < Iterations
 | 
			
		||||
            ? PasswordVerificationResult.SuccessRehashNeeded
 | 
			
		||||
            : PasswordVerificationResult.Success;
 | 
			
		||||
        return PasswordVerificationResult.Failed;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static bool IsArgon2Hash(string value) =>
 | 
			
		||||
        value.StartsWith("$argon2id$", StringComparison.Ordinal);
 | 
			
		||||
 | 
			
		||||
    private static bool IsLegacyPbkdf2Hash(string value) =>
 | 
			
		||||
        value.StartsWith("PBKDF2.", StringComparison.Ordinal);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.IO;
 | 
			
		||||
using StellaOps.Cryptography;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Plugin.Standard;
 | 
			
		||||
 | 
			
		||||
@@ -13,6 +14,8 @@ internal sealed class StandardPluginOptions
 | 
			
		||||
 | 
			
		||||
    public TokenSigningOptions TokenSigning { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    public PasswordHashOptions PasswordHashing { get; set; } = new();
 | 
			
		||||
 | 
			
		||||
    public void Normalize(string configPath)
 | 
			
		||||
    {
 | 
			
		||||
        TokenSigning.Normalize(configPath);
 | 
			
		||||
@@ -23,6 +26,7 @@ internal sealed class StandardPluginOptions
 | 
			
		||||
        BootstrapUser?.Validate(pluginName);
 | 
			
		||||
        PasswordPolicy.Validate(pluginName);
 | 
			
		||||
        Lockout.Validate(pluginName);
 | 
			
		||||
        PasswordHashing.Validate();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,6 +9,8 @@ using StellaOps.Authority.Plugin.Standard.Bootstrap;
 | 
			
		||||
using StellaOps.Authority.Plugin.Standard.Security;
 | 
			
		||||
using StellaOps.Authority.Plugin.Standard.Storage;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Stores;
 | 
			
		||||
using StellaOps.Cryptography;
 | 
			
		||||
using StellaOps.Cryptography.DependencyInjection;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Plugin.Standard;
 | 
			
		||||
 | 
			
		||||
@@ -25,10 +27,11 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
 | 
			
		||||
 | 
			
		||||
        var pluginName = context.Plugin.Manifest.Name;
 | 
			
		||||
 | 
			
		||||
        context.Services.TryAddSingleton<IPasswordHasher, Pbkdf2PasswordHasher>();
 | 
			
		||||
        context.Services.AddSingleton<StandardClaimsEnricher>();
 | 
			
		||||
        context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
 | 
			
		||||
 | 
			
		||||
        context.Services.AddStellaOpsCrypto();
 | 
			
		||||
 | 
			
		||||
        var configPath = context.Plugin.Manifest.ConfigPath;
 | 
			
		||||
 | 
			
		||||
        context.Services.AddOptions<StandardPluginOptions>(pluginName)
 | 
			
		||||
@@ -45,7 +48,8 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
 | 
			
		||||
            var database = sp.GetRequiredService<IMongoDatabase>();
 | 
			
		||||
            var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
 | 
			
		||||
            var pluginOptions = optionsMonitor.Get(pluginName);
 | 
			
		||||
            var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
 | 
			
		||||
            var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
 | 
			
		||||
            var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
 | 
			
		||||
            var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
 | 
			
		||||
 | 
			
		||||
            return new StandardUserCredentialStore(
 | 
			
		||||
@@ -59,7 +63,9 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
 | 
			
		||||
        context.Services.AddSingleton(sp =>
 | 
			
		||||
        {
 | 
			
		||||
            var clientStore = sp.GetRequiredService<IAuthorityClientStore>();
 | 
			
		||||
            return new StandardClientProvisioningStore(pluginName, clientStore);
 | 
			
		||||
            var revocationStore = sp.GetRequiredService<IAuthorityRevocationStore>();
 | 
			
		||||
            var timeProvider = sp.GetRequiredService<TimeProvider>();
 | 
			
		||||
            return new StandardClientProvisioningStore(pluginName, clientStore, revocationStore, timeProvider);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        context.Services.AddSingleton<IIdentityProviderPlugin>(sp =>
 | 
			
		||||
 
 | 
			
		||||
@@ -18,5 +18,7 @@
 | 
			
		||||
    <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
 | 
			
		||||
    <ProjectReference Include="..\..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
 | 
			
		||||
    <ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
 | 
			
		||||
    <ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
 | 
			
		||||
    <ProjectReference Include="..\..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" />
 | 
			
		||||
  </ItemGroup>
 | 
			
		||||
</Project>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using StellaOps.Authority.Plugins.Abstractions;
 | 
			
		||||
using StellaOps.Authority.Storage.Mongo.Documents;
 | 
			
		||||
@@ -9,11 +10,19 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
 | 
			
		||||
{
 | 
			
		||||
    private readonly string pluginName;
 | 
			
		||||
    private readonly IAuthorityClientStore clientStore;
 | 
			
		||||
    private readonly IAuthorityRevocationStore revocationStore;
 | 
			
		||||
    private readonly TimeProvider clock;
 | 
			
		||||
 | 
			
		||||
    public StandardClientProvisioningStore(string pluginName, IAuthorityClientStore clientStore)
 | 
			
		||||
    public StandardClientProvisioningStore(
 | 
			
		||||
        string pluginName,
 | 
			
		||||
        IAuthorityClientStore clientStore,
 | 
			
		||||
        IAuthorityRevocationStore revocationStore,
 | 
			
		||||
        TimeProvider clock)
 | 
			
		||||
    {
 | 
			
		||||
        this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
 | 
			
		||||
        this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
 | 
			
		||||
        this.revocationStore = revocationStore ?? throw new ArgumentNullException(nameof(revocationStore));
 | 
			
		||||
        this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityPluginOperationResult<AuthorityClientDescriptor>> CreateOrUpdateAsync(
 | 
			
		||||
@@ -28,7 +37,7 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var document = await clientStore.FindByClientIdAsync(registration.ClientId, cancellationToken).ConfigureAwait(false)
 | 
			
		||||
            ?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = DateTimeOffset.UtcNow };
 | 
			
		||||
            ?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = clock.GetUtcNow() };
 | 
			
		||||
 | 
			
		||||
        document.Plugin = pluginName;
 | 
			
		||||
        document.ClientType = registration.Confidential ? "confidential" : "public";
 | 
			
		||||
@@ -36,6 +45,7 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
 | 
			
		||||
        document.SecretHash = registration.Confidential && registration.ClientSecret is not null
 | 
			
		||||
            ? AuthoritySecretHasher.ComputeHash(registration.ClientSecret)
 | 
			
		||||
            : null;
 | 
			
		||||
        document.UpdatedAt = clock.GetUtcNow();
 | 
			
		||||
 | 
			
		||||
        document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
 | 
			
		||||
        document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
 | 
			
		||||
@@ -51,6 +61,7 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        await revocationStore.RemoveAsync("client", registration.ClientId, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
        return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Success(ToDescriptor(document));
 | 
			
		||||
    }
 | 
			
		||||
@@ -64,9 +75,39 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
 | 
			
		||||
    public async ValueTask<AuthorityPluginOperationResult> DeleteAsync(string clientId, CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var deleted = await clientStore.DeleteByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        return deleted
 | 
			
		||||
            ? AuthorityPluginOperationResult.Success()
 | 
			
		||||
            : AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
 | 
			
		||||
        if (!deleted)
 | 
			
		||||
        {
 | 
			
		||||
            return AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var now = clock.GetUtcNow();
 | 
			
		||||
        var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
 | 
			
		||||
        {
 | 
			
		||||
            ["plugin"] = pluginName
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        var revocation = new AuthorityRevocationDocument
 | 
			
		||||
        {
 | 
			
		||||
            Category = "client",
 | 
			
		||||
            RevocationId = clientId,
 | 
			
		||||
            ClientId = clientId,
 | 
			
		||||
            Reason = "operator_request",
 | 
			
		||||
            ReasonDescription = $"Client '{clientId}' deleted via plugin '{pluginName}'.",
 | 
			
		||||
            RevokedAt = now,
 | 
			
		||||
            EffectiveAt = now,
 | 
			
		||||
            Metadata = metadata
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        try
 | 
			
		||||
        {
 | 
			
		||||
            await revocationStore.UpsertAsync(revocation, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
        }
 | 
			
		||||
        catch
 | 
			
		||||
        {
 | 
			
		||||
            // Revocation export should proceed even if the metadata write fails.
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return AuthorityPluginOperationResult.Success();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static AuthorityClientDescriptor ToDescriptor(AuthorityClientDocument document)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
using System;
 | 
			
		||||
using System.Collections.Generic;
 | 
			
		||||
using System.Globalization;
 | 
			
		||||
using System.Linq;
 | 
			
		||||
using System.Threading;
 | 
			
		||||
using System.Threading.Tasks;
 | 
			
		||||
@@ -8,6 +9,7 @@ using MongoDB.Bson;
 | 
			
		||||
using MongoDB.Driver;
 | 
			
		||||
using StellaOps.Authority.Plugins.Abstractions;
 | 
			
		||||
using StellaOps.Authority.Plugin.Standard.Security;
 | 
			
		||||
using StellaOps.Cryptography.Audit;
 | 
			
		||||
 | 
			
		||||
namespace StellaOps.Authority.Plugin.Standard.Storage;
 | 
			
		||||
 | 
			
		||||
@@ -43,9 +45,11 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
 | 
			
		||||
        string password,
 | 
			
		||||
        CancellationToken cancellationToken)
 | 
			
		||||
    {
 | 
			
		||||
        var auditProperties = new List<AuthEventProperty>();
 | 
			
		||||
 | 
			
		||||
        if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password))
 | 
			
		||||
        {
 | 
			
		||||
            return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials);
 | 
			
		||||
            return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, auditProperties: auditProperties);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var normalized = NormalizeUsername(username);
 | 
			
		||||
@@ -56,17 +60,24 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
 | 
			
		||||
        if (user is null)
 | 
			
		||||
        {
 | 
			
		||||
            logger.LogWarning("Plugin {PluginName} failed password verification for unknown user {Username}.", pluginName, normalized);
 | 
			
		||||
            return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials);
 | 
			
		||||
            return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, auditProperties: auditProperties);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (options.Lockout.Enabled && user.Lockout.LockoutEnd is { } lockoutEnd && lockoutEnd > DateTimeOffset.UtcNow)
 | 
			
		||||
        {
 | 
			
		||||
            var retryAfter = lockoutEnd - DateTimeOffset.UtcNow;
 | 
			
		||||
            logger.LogWarning("Plugin {PluginName} denied access for {Username} due to lockout (retry after {RetryAfter}).", pluginName, normalized, retryAfter);
 | 
			
		||||
            auditProperties.Add(new AuthEventProperty
 | 
			
		||||
            {
 | 
			
		||||
                Name = "plugin.lockout_until",
 | 
			
		||||
                Value = ClassifiedString.Public(lockoutEnd.ToString("O", CultureInfo.InvariantCulture))
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            return AuthorityCredentialVerificationResult.Failure(
 | 
			
		||||
                AuthorityCredentialFailureCode.LockedOut,
 | 
			
		||||
                "Account is temporarily locked.",
 | 
			
		||||
                retryAfter);
 | 
			
		||||
                retryAfter,
 | 
			
		||||
                auditProperties);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        var verification = passwordHasher.Verify(password, user.PasswordHash);
 | 
			
		||||
@@ -75,8 +86,14 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
 | 
			
		||||
            if (verification == PasswordVerificationResult.SuccessRehashNeeded)
 | 
			
		||||
            {
 | 
			
		||||
                user.PasswordHash = passwordHasher.Hash(password);
 | 
			
		||||
                auditProperties.Add(new AuthEventProperty
 | 
			
		||||
                {
 | 
			
		||||
                    Name = "plugin.rehashed",
 | 
			
		||||
                    Value = ClassifiedString.Public("argon2id")
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var previousFailures = user.Lockout.FailedAttempts;
 | 
			
		||||
            ResetLockout(user);
 | 
			
		||||
            user.UpdatedAt = DateTimeOffset.UtcNow;
 | 
			
		||||
            await users.ReplaceOneAsync(
 | 
			
		||||
@@ -84,8 +101,20 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
 | 
			
		||||
                user,
 | 
			
		||||
                cancellationToken: cancellationToken).ConfigureAwait(false);
 | 
			
		||||
 | 
			
		||||
            if (previousFailures > 0)
 | 
			
		||||
            {
 | 
			
		||||
                auditProperties.Add(new AuthEventProperty
 | 
			
		||||
                {
 | 
			
		||||
                    Name = "plugin.failed_attempts_cleared",
 | 
			
		||||
                    Value = ClassifiedString.Public(previousFailures.ToString(CultureInfo.InvariantCulture))
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            var descriptor = ToDescriptor(user);
 | 
			
		||||
            return AuthorityCredentialVerificationResult.Success(descriptor, descriptor.RequiresPasswordReset ? "Password reset required." : null);
 | 
			
		||||
            return AuthorityCredentialVerificationResult.Success(
 | 
			
		||||
                descriptor,
 | 
			
		||||
                descriptor.RequiresPasswordReset ? "Password reset required." : null,
 | 
			
		||||
                auditProperties);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await RegisterFailureAsync(user, cancellationToken).ConfigureAwait(false);
 | 
			
		||||
@@ -98,10 +127,26 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
 | 
			
		||||
            ? lockoutTime - DateTimeOffset.UtcNow
 | 
			
		||||
            : null;
 | 
			
		||||
 | 
			
		||||
        auditProperties.Add(new AuthEventProperty
 | 
			
		||||
        {
 | 
			
		||||
            Name = "plugin.failed_attempts",
 | 
			
		||||
            Value = ClassifiedString.Public(user.Lockout.FailedAttempts.ToString(CultureInfo.InvariantCulture))
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (user.Lockout.LockoutEnd is { } pendingLockout)
 | 
			
		||||
        {
 | 
			
		||||
            auditProperties.Add(new AuthEventProperty
 | 
			
		||||
            {
 | 
			
		||||
                Name = "plugin.lockout_until",
 | 
			
		||||
                Value = ClassifiedString.Public(pendingLockout.ToString("O", CultureInfo.InvariantCulture))
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return AuthorityCredentialVerificationResult.Failure(
 | 
			
		||||
            code,
 | 
			
		||||
            code == AuthorityCredentialFailureCode.LockedOut ? "Account is temporarily locked." : "Invalid credentials.",
 | 
			
		||||
            retry);
 | 
			
		||||
            retry,
 | 
			
		||||
            auditProperties);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(
 | 
			
		||||
 
 | 
			
		||||
@@ -2,14 +2,14 @@
 | 
			
		||||
 | 
			
		||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
 | 
			
		||||
|----|--------|----------|------------|-------------|---------------|
 | 
			
		||||
| PLG6.DOC | DONE (2025-10-11) | BE-Auth Plugin, Docs Guild | PLG1–PLG5 | Final polish + diagrams for plugin developer guide. | Docs team delivers copy-edit + exported diagrams; PR merged. |
 | 
			
		||||
| SEC1.PLG | TODO | Security Guild, BE-Auth Plugin | SEC1.A (StellaOps.Cryptography) | Swap Standard plugin hashing to Argon2id via `StellaOps.Cryptography` abstractions; keep PBKDF2 verification for legacy. | ✅ `StandardUserCredentialStore` uses `ICryptoProvider` to hash/check; ✅ Transparent rehash on success; ✅ Unit tests cover tamper + legacy rehash. |
 | 
			
		||||
| SEC1.OPT | TODO | Security Guild | SEC1.PLG | Expose password hashing knobs in `StandardPluginOptions` (`memoryKiB`, `iterations`, `parallelism`, `algorithm`) with validation. | ✅ Options bound from YAML; ✅ Invalid configs throw; ✅ Docs include tuning guidance. |
 | 
			
		||||
| PLG6.DOC | DONE (2025-10-11) | BE-Auth Plugin, Docs Guild | PLG1–PLG5 | Final polish + diagrams for plugin developer guide (AUTHPLUG-DOCS-01-001). | Docs team delivers copy-edit + exported diagrams; PR merged. |
 | 
			
		||||
| SEC1.PLG | DONE (2025-10-11) | Security Guild, BE-Auth Plugin | SEC1.A (StellaOps.Cryptography) | Swap Standard plugin hashing to Argon2id via `StellaOps.Cryptography` abstractions; keep PBKDF2 verification for legacy. | ✅ `StandardUserCredentialStore` uses `ICryptoProvider` to hash/check; ✅ Transparent rehash on success; ✅ Unit tests cover tamper + legacy rehash. |
 | 
			
		||||
| SEC1.OPT | DONE (2025-10-11) | Security Guild | SEC1.PLG | Expose password hashing knobs in `StandardPluginOptions` (`memoryKiB`, `iterations`, `parallelism`, `algorithm`) with validation. | ✅ Options bound from YAML; ✅ Invalid configs throw; ✅ Docs include tuning guidance. |
 | 
			
		||||
| SEC2.PLG | TODO | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
 | 
			
		||||
| SEC3.PLG | TODO | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
 | 
			
		||||
| SEC4.PLG | TODO | Security Guild | SEC4.A (revocation schema) | Provide plugin hooks so revoked users/clients write reasons for revocation bundle export. | ✅ Revocation exporter consumes plugin data; ✅ Tests cover revoked user/client output. |
 | 
			
		||||
| SEC4.PLG | DONE (2025-10-12) | Security Guild | SEC4.A (revocation schema) | Provide plugin hooks so revoked users/clients write reasons for revocation bundle export. | ✅ Revocation exporter consumes plugin data; ✅ Tests cover revoked user/client output. |
 | 
			
		||||
| SEC5.PLG | TODO | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
 | 
			
		||||
| PLG4-6.CAPABILITIES | DOING (2025-10-10) | BE-Auth Plugin, Docs Guild | PLG1–PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. |
 | 
			
		||||
| PLG4-6.CAPABILITIES | BLOCKED (2025-10-12) | BE-Auth Plugin, Docs Guild | PLG1–PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. <br>⛔ Blocked awaiting Authority rate-limiter stream (CORE8/SEC3) to resume so doc updates reflect final limiter behaviour. |
 | 
			
		||||
| PLG7.RFC | REVIEW | BE-Auth Plugin, Security Guild | PLG4 | Socialize LDAP plugin RFC (`docs/rfcs/authority-plugin-ldap.md`) and capture guild feedback. | ✅ Guild review sign-off recorded; ✅ Follow-up issues filed in module boards. |
 | 
			
		||||
| PLG6.DIAGRAM | TODO | Docs Guild | PLG6.DOC | Export final sequence/component diagrams for the developer guide and add offline-friendly assets under `docs/assets/authority`. | ✅ Mermaid sources committed; ✅ Rendered SVG/PNG linked from Section 2 + Section 9; ✅ Docs build preview shared with Plugin + Docs guilds. |
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user