This commit is contained in:
master
2025-10-12 20:37:18 +03:00
parent 016c5a3fe7
commit d3a98326d1
306 changed files with 21409 additions and 4449 deletions

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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 =>

View File

@@ -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>

View File

@@ -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)

View File

@@ -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(

View File

@@ -2,14 +2,14 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| PLG6.DOC | DONE (2025-10-11) | BE-Auth Plugin, Docs Guild | PLG1PLG5 | 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 | PLG1PLG5 | 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 | PLG1PLG3 | 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 | PLG1PLG3 | 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. |