up
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Build Test Deploy / docs (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / deploy (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / build-test (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / authority-container (push) Has been cancelled
				
			
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Build Test Deploy / docs (push) Has been cancelled
				
			Build Test Deploy / deploy (push) Has been cancelled
				
			Build Test Deploy / build-test (push) Has been cancelled
				
			Build Test Deploy / authority-container (push) Has been cancelled
				
			Docs CI / lint-and-preview (push) Has been cancelled
				
			This commit is contained in:
		| @@ -0,0 +1,125 @@ | ||||
| using System; | ||||
| using StellaOps.Authority.Plugin.Standard.Security; | ||||
| using StellaOps.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Authority.Plugin.Standard.Tests.Security; | ||||
|  | ||||
| public class CryptoPasswordHasherTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void Hash_EmitsArgon2idByDefault() | ||||
|     { | ||||
|         var options = CreateOptions(); | ||||
|         var hasher = new CryptoPasswordHasher(options, new DefaultCryptoProvider()); | ||||
|  | ||||
|         var encoded = hasher.Hash("Secr3t!"); | ||||
|  | ||||
|         Assert.StartsWith("$argon2id$", encoded, StringComparison.Ordinal); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Verify_ReturnsSuccess_ForCurrentAlgorithm() | ||||
|     { | ||||
|         var options = CreateOptions(); | ||||
|         var provider = new DefaultCryptoProvider(); | ||||
|         var hasher = new CryptoPasswordHasher(options, provider); | ||||
|         var encoded = hasher.Hash("Passw0rd!"); | ||||
|  | ||||
|         var result = hasher.Verify("Passw0rd!", encoded); | ||||
|  | ||||
|         Assert.Equal(PasswordVerificationResult.Success, result); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Verify_FlagsLegacyPbkdf2_ForRehash() | ||||
|     { | ||||
|         var options = CreateOptions(); | ||||
|         var provider = new DefaultCryptoProvider(); | ||||
|         var hasher = new CryptoPasswordHasher(options, provider); | ||||
|  | ||||
|         var legacy = new Pbkdf2PasswordHasher().Hash( | ||||
|             "Passw0rd!", | ||||
|             new PasswordHashOptions | ||||
|             { | ||||
|                 Algorithm = PasswordHashAlgorithm.Pbkdf2, | ||||
|                 Iterations = 150_000 | ||||
|             }); | ||||
|  | ||||
|         var result = hasher.Verify("Passw0rd!", legacy); | ||||
|  | ||||
|         Assert.Equal(PasswordVerificationResult.SuccessRehashNeeded, result); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Verify_RejectsTamperedPayload() | ||||
|     { | ||||
|         var options = CreateOptions(); | ||||
|         var provider = new DefaultCryptoProvider(); | ||||
|         var hasher = new CryptoPasswordHasher(options, provider); | ||||
|  | ||||
|         var legacy = new Pbkdf2PasswordHasher().Hash( | ||||
|             "Passw0rd!", | ||||
|             new PasswordHashOptions | ||||
|             { | ||||
|                 Algorithm = PasswordHashAlgorithm.Pbkdf2, | ||||
|                 Iterations = 160_000 | ||||
|             }); | ||||
|  | ||||
|         var tampered = legacy + "corrupted"; | ||||
|  | ||||
|         var result = hasher.Verify("Passw0rd!", tampered); | ||||
|  | ||||
|         Assert.Equal(PasswordVerificationResult.Failed, result); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Verify_AllowsLegacyAlgorithmWhenConfigured() | ||||
|     { | ||||
|         var options = CreateOptions(); | ||||
|         options.PasswordHashing = options.PasswordHashing with | ||||
|         { | ||||
|             Algorithm = PasswordHashAlgorithm.Pbkdf2, | ||||
|             Iterations = 200_000 | ||||
|         }; | ||||
|  | ||||
|         var provider = new DefaultCryptoProvider(); | ||||
|         var hasher = new CryptoPasswordHasher(options, provider); | ||||
|  | ||||
|         var legacy = new Pbkdf2PasswordHasher().Hash( | ||||
|             "Passw0rd!", | ||||
|             new PasswordHashOptions | ||||
|             { | ||||
|                 Algorithm = PasswordHashAlgorithm.Pbkdf2, | ||||
|                 Iterations = 200_000 | ||||
|             }); | ||||
|  | ||||
|         var result = hasher.Verify("Passw0rd!", legacy); | ||||
|  | ||||
|         Assert.Equal(PasswordVerificationResult.Success, result); | ||||
|     } | ||||
|  | ||||
|     private static StandardPluginOptions CreateOptions() => new() | ||||
|     { | ||||
|         PasswordPolicy = new PasswordPolicyOptions | ||||
|         { | ||||
|             MinimumLength = 8, | ||||
|             RequireDigit = true, | ||||
|             RequireLowercase = true, | ||||
|             RequireUppercase = true, | ||||
|             RequireSymbol = false | ||||
|         }, | ||||
|         Lockout = new LockoutOptions | ||||
|         { | ||||
|             Enabled = true, | ||||
|             MaxAttempts = 5, | ||||
|             WindowMinutes = 15 | ||||
|         }, | ||||
|         PasswordHashing = new PasswordHashOptions | ||||
|         { | ||||
|             Algorithm = PasswordHashAlgorithm.Argon2id, | ||||
|             MemorySizeInKib = 8 * 1024, | ||||
|             Iterations = 2, | ||||
|             Parallelism = 1 | ||||
|         } | ||||
|     }; | ||||
| } | ||||
| @@ -1,6 +1,7 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using StellaOps.Authority.Plugin.Standard; | ||||
| using StellaOps.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Authority.Plugin.Standard.Tests; | ||||
|  | ||||
| @@ -96,4 +97,49 @@ public class StandardPluginOptionsTests | ||||
|  | ||||
|         Assert.Equal(Path.GetFullPath(absolute), options.TokenSigning.KeyDirectory); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Validate_Throws_WhenPasswordHashingMemoryInvalid() | ||||
|     { | ||||
|         var options = new StandardPluginOptions | ||||
|         { | ||||
|             PasswordHashing = new PasswordHashOptions | ||||
|             { | ||||
|                 MemorySizeInKib = 0 | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard")); | ||||
|         Assert.Contains("memory", ex.Message, StringComparison.OrdinalIgnoreCase); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Validate_Throws_WhenPasswordHashingIterationsInvalid() | ||||
|     { | ||||
|         var options = new StandardPluginOptions | ||||
|         { | ||||
|             PasswordHashing = new PasswordHashOptions | ||||
|             { | ||||
|                 Iterations = 0 | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard")); | ||||
|         Assert.Contains("iteration", ex.Message, StringComparison.OrdinalIgnoreCase); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Validate_Throws_WhenPasswordHashingParallelismInvalid() | ||||
|     { | ||||
|         var options = new StandardPluginOptions | ||||
|         { | ||||
|             PasswordHashing = new PasswordHashOptions | ||||
|             { | ||||
|                 Parallelism = 0 | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard")); | ||||
|         Assert.Contains("parallelism", ex.Message, StringComparison.OrdinalIgnoreCase); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -34,6 +34,9 @@ public class StandardPluginRegistrarTests | ||||
|                 ["passwordPolicy:requireDigit"] = "false", | ||||
|                 ["passwordPolicy:requireSymbol"] = "false", | ||||
|                 ["lockout:enabled"] = "false", | ||||
|                 ["passwordHashing:memorySizeInKib"] = "8192", | ||||
|                 ["passwordHashing:iterations"] = "2", | ||||
|                 ["passwordHashing:parallelism"] = "1", | ||||
|                 ["bootstrapUser:username"] = "bootstrap", | ||||
|                 ["bootstrapUser:password"] = "Bootstrap1!", | ||||
|                 ["bootstrapUser:requirePasswordReset"] = "true" | ||||
|   | ||||
| @@ -1,4 +1,6 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| @@ -7,6 +9,7 @@ using MongoDB.Driver; | ||||
| using StellaOps.Authority.Plugins.Abstractions; | ||||
| using StellaOps.Authority.Plugin.Standard.Security; | ||||
| using StellaOps.Authority.Plugin.Standard.Storage; | ||||
| using StellaOps.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Authority.Plugin.Standard.Tests; | ||||
|  | ||||
| @@ -37,13 +40,21 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime | ||||
|                 Enabled = true, | ||||
|                 MaxAttempts = 2, | ||||
|                 WindowMinutes = 1 | ||||
|             }, | ||||
|             PasswordHashing = new PasswordHashOptions | ||||
|             { | ||||
|                 Algorithm = PasswordHashAlgorithm.Argon2id, | ||||
|                 MemorySizeInKib = 8 * 1024, | ||||
|                 Iterations = 2, | ||||
|                 Parallelism = 1 | ||||
|             } | ||||
|         }; | ||||
|         var cryptoProvider = new DefaultCryptoProvider(); | ||||
|         store = new StandardUserCredentialStore( | ||||
|             "standard", | ||||
|             database, | ||||
|             options, | ||||
|             new Pbkdf2PasswordHasher(), | ||||
|             new CryptoPasswordHasher(options, cryptoProvider), | ||||
|             NullLogger<StandardUserCredentialStore>.Instance); | ||||
|     } | ||||
|  | ||||
| @@ -65,6 +76,7 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime | ||||
|         var result = await store.VerifyPasswordAsync("alice", "Password1!", CancellationToken.None); | ||||
|         Assert.True(result.Succeeded); | ||||
|         Assert.Equal("alice", result.User?.Username); | ||||
|         Assert.Empty(result.AuditProperties); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
| @@ -90,6 +102,46 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime | ||||
|         Assert.Equal(AuthorityCredentialFailureCode.LockedOut, second.FailureCode); | ||||
|         Assert.NotNull(second.RetryAfter); | ||||
|         Assert.True(second.RetryAfter.Value > System.TimeSpan.Zero); | ||||
|         Assert.Contains(second.AuditProperties, property => property.Name == "plugin.lockout_until"); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task VerifyPasswordAsync_RehashesLegacyHashesToArgon2() | ||||
|     { | ||||
|         var legacyHash = new Pbkdf2PasswordHasher().Hash( | ||||
|             "Legacy1!", | ||||
|             new PasswordHashOptions | ||||
|             { | ||||
|                 Algorithm = PasswordHashAlgorithm.Pbkdf2, | ||||
|                 Iterations = 160_000 | ||||
|             }); | ||||
|  | ||||
|         var document = new StandardUserDocument | ||||
|         { | ||||
|             Username = "legacy", | ||||
|             NormalizedUsername = "legacy", | ||||
|             PasswordHash = legacyHash, | ||||
|             Roles = new List<string>(), | ||||
|             Attributes = new Dictionary<string, string?>(), | ||||
|             CreatedAt = DateTimeOffset.UtcNow.AddDays(-1), | ||||
|             UpdatedAt = DateTimeOffset.UtcNow.AddDays(-1) | ||||
|         }; | ||||
|  | ||||
|         await database.GetCollection<StandardUserDocument>("authority_users_standard") | ||||
|             .InsertOneAsync(document); | ||||
|  | ||||
|         var result = await store.VerifyPasswordAsync("legacy", "Legacy1!", CancellationToken.None); | ||||
|  | ||||
|         Assert.True(result.Succeeded); | ||||
|         Assert.Equal("legacy", result.User?.Username); | ||||
|         Assert.Contains(result.AuditProperties, property => property.Name == "plugin.rehashed"); | ||||
|  | ||||
|         var updated = await database.GetCollection<StandardUserDocument>("authority_users_standard") | ||||
|             .Find(u => u.NormalizedUsername == "legacy") | ||||
|             .FirstOrDefaultAsync(); | ||||
|  | ||||
|         Assert.NotNull(updated); | ||||
|         Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal); | ||||
|     } | ||||
|  | ||||
|     public Task InitializeAsync() => Task.CompletedTask; | ||||
|   | ||||
| @@ -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. | | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| using System; | ||||
| using StellaOps.Authority.Plugins.Abstractions; | ||||
| using StellaOps.Cryptography.Audit; | ||||
|  | ||||
| namespace StellaOps.Authority.Plugins.Abstractions.Tests; | ||||
|  | ||||
| @@ -10,12 +11,18 @@ public class AuthorityCredentialVerificationResultTests | ||||
|     { | ||||
|         var user = new AuthorityUserDescriptor("subject-1", "user", "User", false); | ||||
|  | ||||
|         var result = AuthorityCredentialVerificationResult.Success(user, "ok"); | ||||
|         var auditProperties = new[] | ||||
|         { | ||||
|             new AuthEventProperty { Name = "test", Value = ClassifiedString.Public("value") } | ||||
|         }; | ||||
|  | ||||
|         var result = AuthorityCredentialVerificationResult.Success(user, "ok", auditProperties); | ||||
|  | ||||
|         Assert.True(result.Succeeded); | ||||
|         Assert.Equal(user, result.User); | ||||
|         Assert.Null(result.FailureCode); | ||||
|         Assert.Equal("ok", result.Message); | ||||
|         Assert.Collection(result.AuditProperties, property => Assert.Equal("test", property.Name)); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
| @@ -27,12 +34,18 @@ public class AuthorityCredentialVerificationResultTests | ||||
|     [Fact] | ||||
|     public void Failure_SetsFailureCode() | ||||
|     { | ||||
|         var result = AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.LockedOut, "locked", TimeSpan.FromMinutes(5)); | ||||
|         var auditProperties = new[] | ||||
|         { | ||||
|             new AuthEventProperty { Name = "reason", Value = ClassifiedString.Public("lockout") } | ||||
|         }; | ||||
|  | ||||
|         var result = AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.LockedOut, "locked", TimeSpan.FromMinutes(5), auditProperties); | ||||
|  | ||||
|         Assert.False(result.Succeeded); | ||||
|         Assert.Null(result.User); | ||||
|         Assert.Equal(AuthorityCredentialFailureCode.LockedOut, result.FailureCode); | ||||
|         Assert.Equal("locked", result.Message); | ||||
|         Assert.Equal(TimeSpan.FromMinutes(5), result.RetryAfter); | ||||
|         Assert.Collection(result.AuditProperties, property => Assert.Equal("reason", property.Name)); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -4,6 +4,7 @@ using System.Linq; | ||||
| using System.Security.Claims; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using StellaOps.Cryptography.Audit; | ||||
|  | ||||
| namespace StellaOps.Authority.Plugins.Abstractions; | ||||
|  | ||||
| @@ -311,13 +312,15 @@ public sealed record AuthorityCredentialVerificationResult | ||||
|         AuthorityUserDescriptor? user, | ||||
|         AuthorityCredentialFailureCode? failureCode, | ||||
|         string? message, | ||||
|         TimeSpan? retryAfter) | ||||
|         TimeSpan? retryAfter, | ||||
|         IReadOnlyList<AuthEventProperty> auditProperties) | ||||
|     { | ||||
|         Succeeded = succeeded; | ||||
|         User = user; | ||||
|         FailureCode = failureCode; | ||||
|         Message = message; | ||||
|         RetryAfter = retryAfter; | ||||
|         AuditProperties = auditProperties ?? Array.Empty<AuthEventProperty>(); | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
| @@ -345,13 +348,19 @@ public sealed record AuthorityCredentialVerificationResult | ||||
|     /// </summary> | ||||
|     public TimeSpan? RetryAfter { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Additional audit properties emitted by the credential store. | ||||
|     /// </summary> | ||||
|     public IReadOnlyList<AuthEventProperty> AuditProperties { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Builds a successful verification result. | ||||
|     /// </summary> | ||||
|     public static AuthorityCredentialVerificationResult Success( | ||||
|         AuthorityUserDescriptor user, | ||||
|         string? message = null) | ||||
|         => new(true, user ?? throw new ArgumentNullException(nameof(user)), null, message, null); | ||||
|         string? message = null, | ||||
|         IReadOnlyList<AuthEventProperty>? auditProperties = null) | ||||
|         => new(true, user ?? throw new ArgumentNullException(nameof(user)), null, message, null, auditProperties ?? Array.Empty<AuthEventProperty>()); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Builds a failed verification result. | ||||
| @@ -359,8 +368,9 @@ public sealed record AuthorityCredentialVerificationResult | ||||
|     public static AuthorityCredentialVerificationResult Failure( | ||||
|         AuthorityCredentialFailureCode failureCode, | ||||
|         string? message = null, | ||||
|         TimeSpan? retryAfter = null) | ||||
|         => new(false, null, failureCode, message, retryAfter); | ||||
|         TimeSpan? retryAfter = null, | ||||
|         IReadOnlyList<AuthEventProperty>? auditProperties = null) | ||||
|         => new(false, null, failureCode, message, retryAfter, auditProperties ?? Array.Empty<AuthEventProperty>()); | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
|   | ||||
| @@ -11,4 +11,7 @@ | ||||
|     <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
|   | ||||
| @@ -20,5 +20,7 @@ public static class AuthorityMongoDefaults | ||||
|         public const string Scopes = "authority_scopes"; | ||||
|         public const string Tokens = "authority_tokens"; | ||||
|         public const string LoginAttempts = "authority_login_attempts"; | ||||
|         public const string Revocations = "authority_revocations"; | ||||
|         public const string RevocationState = "authority_revocation_state"; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -13,6 +13,16 @@ public sealed class AuthorityLoginAttemptDocument | ||||
|     [BsonRepresentation(BsonType.ObjectId)] | ||||
|     public string Id { get; set; } = ObjectId.GenerateNewId().ToString(); | ||||
|  | ||||
|     [BsonElement("eventType")] | ||||
|     public string EventType { get; set; } = "authority.unknown"; | ||||
|  | ||||
|     [BsonElement("outcome")] | ||||
|     public string Outcome { get; set; } = "unknown"; | ||||
|  | ||||
|     [BsonElement("correlationId")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? CorrelationId { get; set; } | ||||
|  | ||||
|     [BsonElement("subjectId")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? SubjectId { get; set; } | ||||
| @@ -32,6 +42,9 @@ public sealed class AuthorityLoginAttemptDocument | ||||
|     [BsonElement("successful")] | ||||
|     public bool Successful { get; set; } | ||||
|  | ||||
|     [BsonElement("scopes")] | ||||
|     public List<string> Scopes { get; set; } = new(); | ||||
|  | ||||
|     [BsonElement("reason")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? Reason { get; set; } | ||||
| @@ -40,6 +53,26 @@ public sealed class AuthorityLoginAttemptDocument | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? RemoteAddress { get; set; } | ||||
|  | ||||
|     [BsonElement("properties")] | ||||
|     public List<AuthorityLoginAttemptPropertyDocument> Properties { get; set; } = new(); | ||||
|  | ||||
|     [BsonElement("occurredAt")] | ||||
|     public DateTimeOffset OccurredAt { get; set; } = DateTimeOffset.UtcNow; | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents an additional classified property captured for an authority login attempt. | ||||
| /// </summary> | ||||
| [BsonIgnoreExtraElements] | ||||
| public sealed class AuthorityLoginAttemptPropertyDocument | ||||
| { | ||||
|     [BsonElement("name")] | ||||
|     public string Name { get; set; } = string.Empty; | ||||
|  | ||||
|     [BsonElement("value")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? Value { get; set; } | ||||
|  | ||||
|     [BsonElement("classification")] | ||||
|     public string Classification { get; set; } = "none"; | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,72 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using MongoDB.Bson; | ||||
| using MongoDB.Bson.Serialization.Attributes; | ||||
|  | ||||
| namespace StellaOps.Authority.Storage.Mongo.Documents; | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents a revocation entry emitted by Authority (subject/client/token/key). | ||||
| /// </summary> | ||||
| [BsonIgnoreExtraElements] | ||||
| public sealed class AuthorityRevocationDocument | ||||
| { | ||||
|     [BsonId] | ||||
|     [BsonRepresentation(BsonType.ObjectId)] | ||||
|     public string Id { get; set; } = ObjectId.GenerateNewId().ToString(); | ||||
|  | ||||
|     [BsonElement("category")] | ||||
|     public string Category { get; set; } = string.Empty; | ||||
|  | ||||
|     [BsonElement("revocationId")] | ||||
|     public string RevocationId { get; set; } = string.Empty; | ||||
|  | ||||
|     [BsonElement("tokenType")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? TokenType { get; set; } | ||||
|  | ||||
|     [BsonElement("subjectId")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? SubjectId { get; set; } | ||||
|  | ||||
|     [BsonElement("clientId")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? ClientId { get; set; } | ||||
|  | ||||
|     [BsonElement("reason")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? Reason { get; set; } | ||||
|  | ||||
|     [BsonElement("reasonDescription")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? ReasonDescription { get; set; } | ||||
|  | ||||
|     [BsonElement("revokedAt")] | ||||
|     public DateTimeOffset RevokedAt { get; set; } | ||||
|  | ||||
|     [BsonElement("effectiveAt")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public DateTimeOffset? EffectiveAt { get; set; } | ||||
|  | ||||
|     [BsonElement("expiresAt")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public DateTimeOffset? ExpiresAt { get; set; } | ||||
|  | ||||
|     [BsonElement("scopes")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public List<string>? Scopes { get; set; } | ||||
|  | ||||
|     [BsonElement("fingerprint")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? Fingerprint { get; set; } | ||||
|  | ||||
|     [BsonElement("metadata")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public Dictionary<string, string?>? Metadata { get; set; } | ||||
|  | ||||
|     [BsonElement("createdAt")] | ||||
|     public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; | ||||
|  | ||||
|     [BsonElement("updatedAt")] | ||||
|     public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; | ||||
| } | ||||
| @@ -0,0 +1,23 @@ | ||||
| using System; | ||||
| using MongoDB.Bson; | ||||
| using MongoDB.Bson.Serialization.Attributes; | ||||
|  | ||||
| namespace StellaOps.Authority.Storage.Mongo.Documents; | ||||
|  | ||||
| [BsonIgnoreExtraElements] | ||||
| public sealed class AuthorityRevocationExportStateDocument | ||||
| { | ||||
|     [BsonId] | ||||
|     public string Id { get; set; } = "state"; | ||||
|  | ||||
|     [BsonElement("sequence")] | ||||
|     public long Sequence { get; set; } | ||||
|  | ||||
|     [BsonElement("lastBundleId")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? LastBundleId { get; set; } | ||||
|  | ||||
|     [BsonElement("lastIssuedAt")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public DateTimeOffset? LastIssuedAt { get; set; } | ||||
| } | ||||
| @@ -1,3 +1,4 @@ | ||||
| using System.Collections.Generic; | ||||
| using MongoDB.Bson; | ||||
| using MongoDB.Bson.Serialization.Attributes; | ||||
|  | ||||
| @@ -51,4 +52,16 @@ public sealed class AuthorityTokenDocument | ||||
|     [BsonElement("revokedAt")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public DateTimeOffset? RevokedAt { get; set; } | ||||
|  | ||||
|     [BsonElement("revokedReason")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? RevokedReason { get; set; } | ||||
|  | ||||
|     [BsonElement("revokedReasonDescription")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? RevokedReasonDescription { get; set; } | ||||
|  | ||||
|     [BsonElement("revokedMetadata")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public Dictionary<string, string?>? RevokedMetadata { get; set; } | ||||
| } | ||||
|   | ||||
| @@ -86,17 +86,32 @@ public static class ServiceCollectionExtensions | ||||
|             return database.GetCollection<AuthorityLoginAttemptDocument>(AuthorityMongoDefaults.Collections.LoginAttempts); | ||||
|         }); | ||||
|  | ||||
|         services.AddSingleton(static sp => | ||||
|         { | ||||
|             var database = sp.GetRequiredService<IMongoDatabase>(); | ||||
|             return database.GetCollection<AuthorityRevocationDocument>(AuthorityMongoDefaults.Collections.Revocations); | ||||
|         }); | ||||
|  | ||||
|         services.AddSingleton(static sp => | ||||
|         { | ||||
|             var database = sp.GetRequiredService<IMongoDatabase>(); | ||||
|             return database.GetCollection<AuthorityRevocationExportStateDocument>(AuthorityMongoDefaults.Collections.RevocationState); | ||||
|         }); | ||||
|  | ||||
|         services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityUserCollectionInitializer>(); | ||||
|         services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityClientCollectionInitializer>(); | ||||
|         services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityScopeCollectionInitializer>(); | ||||
|         services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityTokenCollectionInitializer>(); | ||||
|         services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityLoginAttemptCollectionInitializer>(); | ||||
|         services.TryAddSingleton<IAuthorityCollectionInitializer, AuthorityRevocationCollectionInitializer>(); | ||||
|  | ||||
|         services.TryAddSingleton<IAuthorityUserStore, AuthorityUserStore>(); | ||||
|         services.TryAddSingleton<IAuthorityClientStore, AuthorityClientStore>(); | ||||
|         services.TryAddSingleton<IAuthorityScopeStore, AuthorityScopeStore>(); | ||||
|         services.TryAddSingleton<IAuthorityTokenStore, AuthorityTokenStore>(); | ||||
|         services.TryAddSingleton<IAuthorityLoginAttemptStore, AuthorityLoginAttemptStore>(); | ||||
|         services.TryAddSingleton<IAuthorityRevocationStore, AuthorityRevocationStore>(); | ||||
|         services.TryAddSingleton<IAuthorityRevocationExportStateStore, AuthorityRevocationExportStateStore>(); | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
|   | ||||
| @@ -18,7 +18,11 @@ internal sealed class AuthorityLoginAttemptCollectionInitializer : IAuthorityCol | ||||
|                 new CreateIndexOptions { Name = "login_attempt_subject_time" }), | ||||
|             new CreateIndexModel<AuthorityLoginAttemptDocument>( | ||||
|                 Builders<AuthorityLoginAttemptDocument>.IndexKeys.Descending(a => a.OccurredAt), | ||||
|                 new CreateIndexOptions { Name = "login_attempt_time" }) | ||||
|                 new CreateIndexOptions { Name = "login_attempt_time" }), | ||||
|             new CreateIndexModel<AuthorityLoginAttemptDocument>( | ||||
|                 Builders<AuthorityLoginAttemptDocument>.IndexKeys | ||||
|                     .Ascending(a => a.CorrelationId), | ||||
|                 new CreateIndexOptions { Name = "login_attempt_correlation", Sparse = true }) | ||||
|         }; | ||||
|  | ||||
|         await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false); | ||||
|   | ||||
| @@ -0,0 +1,32 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
|  | ||||
| namespace StellaOps.Authority.Storage.Mongo.Initialization; | ||||
|  | ||||
| internal sealed class AuthorityRevocationCollectionInitializer : IAuthorityCollectionInitializer | ||||
| { | ||||
|     public async ValueTask EnsureIndexesAsync(IMongoDatabase database, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(database); | ||||
|  | ||||
|         var collection = database.GetCollection<AuthorityRevocationDocument>(AuthorityMongoDefaults.Collections.Revocations); | ||||
|         var indexModels = new List<CreateIndexModel<AuthorityRevocationDocument>> | ||||
|         { | ||||
|             new( | ||||
|                 Builders<AuthorityRevocationDocument>.IndexKeys | ||||
|                     .Ascending(d => d.Category) | ||||
|                     .Ascending(d => d.RevocationId), | ||||
|                 new CreateIndexOptions<AuthorityRevocationDocument> { Name = "revocation_identity_unique", Unique = true }), | ||||
|             new( | ||||
|                 Builders<AuthorityRevocationDocument>.IndexKeys.Ascending(d => d.RevokedAt), | ||||
|                 new CreateIndexOptions<AuthorityRevocationDocument> { Name = "revocation_revokedAt" }), | ||||
|             new( | ||||
|                 Builders<AuthorityRevocationDocument>.IndexKeys.Ascending(d => d.ExpiresAt), | ||||
|                 new CreateIndexOptions<AuthorityRevocationDocument> { Name = "revocation_expiresAt" }) | ||||
|         }; | ||||
|  | ||||
|         await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
| } | ||||
| @@ -22,7 +22,12 @@ internal sealed class AuthorityTokenCollectionInitializer : IAuthorityCollection | ||||
|                 new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_subject" }), | ||||
|             new( | ||||
|                 Builders<AuthorityTokenDocument>.IndexKeys.Ascending(t => t.ClientId), | ||||
|                 new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_client" }) | ||||
|                 new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_client" }), | ||||
|             new( | ||||
|                 Builders<AuthorityTokenDocument>.IndexKeys | ||||
|                     .Ascending(t => t.Status) | ||||
|                     .Ascending(t => t.RevokedAt), | ||||
|                 new CreateIndexOptions<AuthorityTokenDocument> { Name = "token_status_revokedAt" }) | ||||
|         }; | ||||
|  | ||||
|         var expirationFilter = Builders<AuthorityTokenDocument>.Filter.Exists(t => t.ExpiresAt, true); | ||||
|   | ||||
| @@ -23,9 +23,10 @@ internal sealed class AuthorityLoginAttemptStore : IAuthorityLoginAttemptStore | ||||
|  | ||||
|         await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false); | ||||
|         logger.LogDebug( | ||||
|             "Recorded login attempt for subject '{SubjectId}' (success={Successful}).", | ||||
|             "Recorded authority audit event {EventType} for subject '{SubjectId}' with outcome {Outcome}.", | ||||
|             document.EventType, | ||||
|             document.SubjectId ?? document.Username ?? "<unknown>", | ||||
|             document.Successful); | ||||
|             document.Outcome); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken) | ||||
|   | ||||
| @@ -0,0 +1,83 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
|  | ||||
| namespace StellaOps.Authority.Storage.Mongo.Stores; | ||||
|  | ||||
| internal sealed class AuthorityRevocationExportStateStore : IAuthorityRevocationExportStateStore | ||||
| { | ||||
|     private const string StateId = "state"; | ||||
|  | ||||
|     private readonly IMongoCollection<AuthorityRevocationExportStateDocument> collection; | ||||
|     private readonly ILogger<AuthorityRevocationExportStateStore> logger; | ||||
|  | ||||
|     public AuthorityRevocationExportStateStore( | ||||
|         IMongoCollection<AuthorityRevocationExportStateDocument> collection, | ||||
|         ILogger<AuthorityRevocationExportStateStore> logger) | ||||
|     { | ||||
|         this.collection = collection ?? throw new ArgumentNullException(nameof(collection)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId); | ||||
|         return await collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync( | ||||
|         long expectedSequence, | ||||
|         long newSequence, | ||||
|         string bundleId, | ||||
|         DateTimeOffset issuedAt, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (newSequence <= 0) | ||||
|         { | ||||
|             throw new ArgumentOutOfRangeException(nameof(newSequence), "Sequence must be positive."); | ||||
|         } | ||||
|  | ||||
|         var filter = Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Id, StateId); | ||||
|  | ||||
|         if (expectedSequence > 0) | ||||
|         { | ||||
|             filter &= Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Sequence, expectedSequence); | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             filter &= Builders<AuthorityRevocationExportStateDocument>.Filter.Or( | ||||
|                 Builders<AuthorityRevocationExportStateDocument>.Filter.Exists(d => d.Sequence, false), | ||||
|                 Builders<AuthorityRevocationExportStateDocument>.Filter.Eq(d => d.Sequence, 0)); | ||||
|         } | ||||
|  | ||||
|         var update = Builders<AuthorityRevocationExportStateDocument>.Update | ||||
|             .Set(d => d.Sequence, newSequence) | ||||
|             .Set(d => d.LastBundleId, bundleId) | ||||
|             .Set(d => d.LastIssuedAt, issuedAt); | ||||
|  | ||||
|         var options = new FindOneAndUpdateOptions<AuthorityRevocationExportStateDocument> | ||||
|         { | ||||
|             IsUpsert = expectedSequence == 0, | ||||
|             ReturnDocument = ReturnDocument.After | ||||
|         }; | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var result = await collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken).ConfigureAwait(false); | ||||
|             if (result is null) | ||||
|             { | ||||
|                 throw new InvalidOperationException("Revocation export state update conflict."); | ||||
|             } | ||||
|  | ||||
|             logger.LogDebug("Updated revocation export state to sequence {Sequence}.", result.Sequence); | ||||
|             return result; | ||||
|         } | ||||
|         catch (MongoCommandException ex) when (string.Equals(ex.CodeName, "DuplicateKey", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Revocation export state update conflict due to concurrent writer.", ex); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,143 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
|  | ||||
| namespace StellaOps.Authority.Storage.Mongo.Stores; | ||||
|  | ||||
| internal sealed class AuthorityRevocationStore : IAuthorityRevocationStore | ||||
| { | ||||
|     private readonly IMongoCollection<AuthorityRevocationDocument> collection; | ||||
|     private readonly ILogger<AuthorityRevocationStore> logger; | ||||
|  | ||||
|     public AuthorityRevocationStore( | ||||
|         IMongoCollection<AuthorityRevocationDocument> collection, | ||||
|         ILogger<AuthorityRevocationStore> logger) | ||||
|     { | ||||
|         this.collection = collection ?? throw new ArgumentNullException(nameof(collection)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(document); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(document.Category)) | ||||
|         { | ||||
|             throw new ArgumentException("Revocation category is required.", nameof(document)); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(document.RevocationId)) | ||||
|         { | ||||
|             throw new ArgumentException("Revocation identifier is required.", nameof(document)); | ||||
|         } | ||||
|  | ||||
|         document.Category = document.Category.Trim(); | ||||
|         document.RevocationId = document.RevocationId.Trim(); | ||||
|         document.Scopes = NormalizeScopes(document.Scopes); | ||||
|         document.Metadata = NormalizeMetadata(document.Metadata); | ||||
|  | ||||
|         var filter = Builders<AuthorityRevocationDocument>.Filter.And( | ||||
|             Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.Category, document.Category), | ||||
|             Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.RevocationId, document.RevocationId)); | ||||
|  | ||||
|         var now = DateTimeOffset.UtcNow; | ||||
|         document.UpdatedAt = now; | ||||
|  | ||||
|         var existing = await collection | ||||
|             .Find(filter) | ||||
|             .FirstOrDefaultAsync(cancellationToken) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         if (existing is null) | ||||
|         { | ||||
|             document.CreatedAt = now; | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             document.Id = existing.Id; | ||||
|             document.CreatedAt = existing.CreatedAt; | ||||
|         } | ||||
|  | ||||
|         await collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false); | ||||
|         logger.LogDebug("Upserted Authority revocation entry {Category}:{RevocationId}.", document.Category, document.RevocationId); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(revocationId)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var filter = Builders<AuthorityRevocationDocument>.Filter.And( | ||||
|             Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.Category, category.Trim()), | ||||
|             Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.RevocationId, revocationId.Trim())); | ||||
|  | ||||
|         var result = await collection.DeleteOneAsync(filter, cancellationToken).ConfigureAwait(false); | ||||
|         if (result.DeletedCount > 0) | ||||
|         { | ||||
|             logger.LogInformation("Removed Authority revocation entry {Category}:{RevocationId}.", category, revocationId); | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var filter = Builders<AuthorityRevocationDocument>.Filter.Or( | ||||
|             Builders<AuthorityRevocationDocument>.Filter.Eq(d => d.ExpiresAt, null), | ||||
|             Builders<AuthorityRevocationDocument>.Filter.Gt(d => d.ExpiresAt, asOf)); | ||||
|  | ||||
|         var documents = await collection | ||||
|             .Find(filter) | ||||
|             .Sort(Builders<AuthorityRevocationDocument>.Sort.Ascending(d => d.Category).Ascending(d => d.RevocationId)) | ||||
|             .ToListAsync(cancellationToken) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         return documents; | ||||
|     } | ||||
|  | ||||
|     private static List<string>? NormalizeScopes(List<string>? scopes) | ||||
|     { | ||||
|         if (scopes is null || scopes.Count == 0) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var distinct = scopes | ||||
|             .Where(scope => !string.IsNullOrWhiteSpace(scope)) | ||||
|             .Select(scope => scope.Trim()) | ||||
|             .Distinct(StringComparer.Ordinal) | ||||
|             .OrderBy(scope => scope, StringComparer.Ordinal) | ||||
|             .ToList(); | ||||
|  | ||||
|         return distinct.Count == 0 ? null : distinct; | ||||
|     } | ||||
|  | ||||
|     private static Dictionary<string, string?>? NormalizeMetadata(Dictionary<string, string?>? metadata) | ||||
|     { | ||||
|         if (metadata is null || metadata.Count == 0) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var result = new SortedDictionary<string, string?>(StringComparer.OrdinalIgnoreCase); | ||||
|         foreach (var pair in metadata) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(pair.Key)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             result[pair.Key.Trim()] = pair.Value; | ||||
|         } | ||||
|  | ||||
|         return result.Count == 0 ? null : new Dictionary<string, string?>(result, StringComparer.OrdinalIgnoreCase); | ||||
|     } | ||||
| } | ||||
| @@ -1,3 +1,4 @@ | ||||
| using System.Collections.Generic; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
| @@ -51,7 +52,14 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore | ||||
|             .ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, CancellationToken cancellationToken) | ||||
|     public async ValueTask UpdateStatusAsync( | ||||
|         string tokenId, | ||||
|         string status, | ||||
|         DateTimeOffset? revokedAt, | ||||
|         string? reason, | ||||
|         string? reasonDescription, | ||||
|         IReadOnlyDictionary<string, string?>? metadata, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(tokenId)) | ||||
|         { | ||||
| @@ -65,7 +73,10 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore | ||||
|  | ||||
|         var update = Builders<AuthorityTokenDocument>.Update | ||||
|             .Set(t => t.Status, status) | ||||
|             .Set(t => t.RevokedAt, revokedAt); | ||||
|             .Set(t => t.RevokedAt, revokedAt) | ||||
|             .Set(t => t.RevokedReason, reason) | ||||
|             .Set(t => t.RevokedReasonDescription, reasonDescription) | ||||
|             .Set(t => t.RevokedMetadata, metadata is null ? null : new Dictionary<string, string?>(metadata, StringComparer.OrdinalIgnoreCase)); | ||||
|  | ||||
|         var result = await collection.UpdateOneAsync( | ||||
|             Builders<AuthorityTokenDocument>.Filter.Eq(t => t.TokenId, tokenId.Trim()), | ||||
| @@ -90,4 +101,24 @@ internal sealed class AuthorityTokenStore : IAuthorityTokenStore | ||||
|  | ||||
|         return result.DeletedCount; | ||||
|     } | ||||
|  | ||||
|     public async ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var filter = Builders<AuthorityTokenDocument>.Filter.Eq(t => t.Status, "revoked"); | ||||
|  | ||||
|         if (issuedAfter is DateTimeOffset threshold) | ||||
|         { | ||||
|             filter = Builders<AuthorityTokenDocument>.Filter.And( | ||||
|                 filter, | ||||
|                 Builders<AuthorityTokenDocument>.Filter.Gt(t => t.RevokedAt, threshold)); | ||||
|         } | ||||
|  | ||||
|         var documents = await collection | ||||
|             .Find(filter) | ||||
|             .Sort(Builders<AuthorityTokenDocument>.Sort.Ascending(t => t.RevokedAt).Ascending(t => t.TokenId)) | ||||
|             .ToListAsync(cancellationToken) | ||||
|             .ConfigureAwait(false); | ||||
|  | ||||
|         return documents; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,18 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
|  | ||||
| namespace StellaOps.Authority.Storage.Mongo.Stores; | ||||
|  | ||||
| public interface IAuthorityRevocationExportStateStore | ||||
| { | ||||
|     ValueTask<AuthorityRevocationExportStateDocument?> GetAsync(CancellationToken cancellationToken); | ||||
|  | ||||
|     ValueTask<AuthorityRevocationExportStateDocument> UpdateAsync( | ||||
|         long expectedSequence, | ||||
|         long newSequence, | ||||
|         string bundleId, | ||||
|         DateTimeOffset issuedAt, | ||||
|         CancellationToken cancellationToken); | ||||
| } | ||||
| @@ -0,0 +1,16 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
|  | ||||
| namespace StellaOps.Authority.Storage.Mongo.Stores; | ||||
|  | ||||
| public interface IAuthorityRevocationStore | ||||
| { | ||||
|     ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken); | ||||
|  | ||||
|     ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken); | ||||
|  | ||||
|     ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken); | ||||
| } | ||||
| @@ -10,7 +10,16 @@ public interface IAuthorityTokenStore | ||||
|  | ||||
|     ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken); | ||||
|  | ||||
|     ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, CancellationToken cancellationToken); | ||||
|     ValueTask UpdateStatusAsync( | ||||
|         string tokenId, | ||||
|         string status, | ||||
|         DateTimeOffset? revokedAt, | ||||
|         string? reason, | ||||
|         string? reasonDescription, | ||||
|         IReadOnlyDictionary<string, string?>? metadata, | ||||
|         CancellationToken cancellationToken); | ||||
|  | ||||
|     ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken); | ||||
|  | ||||
|     ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken); | ||||
| } | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Diagnostics; | ||||
| using System.Security.Claims; | ||||
| using Microsoft.Extensions.Configuration; | ||||
| @@ -12,6 +14,8 @@ using StellaOps.Authority.OpenIddict.Handlers; | ||||
| using StellaOps.Authority.Plugins.Abstractions; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
| using StellaOps.Authority.Storage.Mongo.Stores; | ||||
| using StellaOps.Authority.RateLimiting; | ||||
| using StellaOps.Cryptography.Audit; | ||||
| using Xunit; | ||||
| using static StellaOps.Authority.Tests.OpenIddict.TestHelpers; | ||||
|  | ||||
| @@ -30,7 +34,14 @@ public class ClientCredentialsHandlersTests | ||||
|             allowedScopes: "jobs:read"); | ||||
|  | ||||
|         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); | ||||
|         var handler = new ValidateClientCredentialsHandler(new TestClientStore(clientDocument), registry, TestActivitySource, NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||
|         var handler = new ValidateClientCredentialsHandler( | ||||
|             new TestClientStore(clientDocument), | ||||
|             registry, | ||||
|             TestActivitySource, | ||||
|             new TestAuthEventSink(), | ||||
|             new TestRateLimiterMetadataAccessor(), | ||||
|             TimeProvider.System, | ||||
|             NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:write"); | ||||
|         var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); | ||||
| @@ -51,7 +62,14 @@ public class ClientCredentialsHandlersTests | ||||
|             allowedScopes: "jobs:read jobs:trigger"); | ||||
|  | ||||
|         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); | ||||
|         var handler = new ValidateClientCredentialsHandler(new TestClientStore(clientDocument), registry, TestActivitySource, NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||
|         var handler = new ValidateClientCredentialsHandler( | ||||
|             new TestClientStore(clientDocument), | ||||
|             registry, | ||||
|             TestActivitySource, | ||||
|             new TestAuthEventSink(), | ||||
|             new TestRateLimiterMetadataAccessor(), | ||||
|             TimeProvider.System, | ||||
|             NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); | ||||
|         var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); | ||||
| @@ -78,7 +96,17 @@ public class ClientCredentialsHandlersTests | ||||
|         var descriptor = CreateDescriptor(clientDocument); | ||||
|         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor); | ||||
|         var tokenStore = new TestTokenStore(); | ||||
|         var handler = new HandleClientCredentialsHandler(registry, tokenStore, TimeProvider.System, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance); | ||||
|         var authSink = new TestAuthEventSink(); | ||||
|         var metadataAccessor = new TestRateLimiterMetadataAccessor(); | ||||
|         var handler = new HandleClientCredentialsHandler( | ||||
|             registry, | ||||
|             tokenStore, | ||||
|             TimeProvider.System, | ||||
|             TestActivitySource, | ||||
|             authSink, | ||||
|             metadataAccessor, | ||||
|             NullLogger<HandleClientCredentialsHandler>.Instance); | ||||
|         var persistHandler = new PersistTokensHandler(tokenStore, TimeProvider.System, TestActivitySource, NullLogger<PersistTokensHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreateTokenTransaction(clientDocument.ClientId, secret: null, scope: "jobs:trigger"); | ||||
|         transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(30); | ||||
| @@ -93,12 +121,23 @@ public class ClientCredentialsHandlersTests | ||||
|         Assert.True(context.IsRequestHandled); | ||||
|         Assert.NotNull(context.Principal); | ||||
|  | ||||
|         Assert.Contains(authSink.Events, record => record.EventType == "authority.client_credentials.grant" && record.Outcome == AuthEventOutcome.Success); | ||||
|  | ||||
|         var identityProviderClaim = context.Principal?.GetClaim(StellaOpsClaimTypes.IdentityProvider); | ||||
|         Assert.Equal(clientDocument.Plugin, identityProviderClaim); | ||||
|  | ||||
|         var tokenId = context.Principal?.GetClaim(OpenIddictConstants.Claims.JwtId); | ||||
|         var principal = context.Principal ?? throw new InvalidOperationException("Principal missing"); | ||||
|         var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId); | ||||
|         Assert.False(string.IsNullOrWhiteSpace(tokenId)); | ||||
|  | ||||
|         var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) | ||||
|         { | ||||
|             Principal = principal, | ||||
|             AccessTokenPrincipal = principal | ||||
|         }; | ||||
|  | ||||
|         await persistHandler.HandleAsync(signInContext); | ||||
|  | ||||
|         var persisted = Assert.IsType<AuthorityTokenDocument>(tokenStore.Inserted); | ||||
|         Assert.Equal(tokenId, persisted.TokenId); | ||||
|         Assert.Equal(clientDocument.ClientId, persisted.ClientId); | ||||
| @@ -236,11 +275,14 @@ internal sealed class TestTokenStore : IAuthorityTokenStore | ||||
|     public ValueTask<AuthorityTokenDocument?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken) | ||||
|         => ValueTask.FromResult<AuthorityTokenDocument?>(null); | ||||
|  | ||||
|     public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, CancellationToken cancellationToken) | ||||
|     public ValueTask UpdateStatusAsync(string tokenId, string status, DateTimeOffset? revokedAt, string? reason, string? reasonDescription, IReadOnlyDictionary<string, string?>? metadata, CancellationToken cancellationToken) | ||||
|         => ValueTask.CompletedTask; | ||||
|  | ||||
|     public ValueTask<long> DeleteExpiredAsync(DateTimeOffset threshold, CancellationToken cancellationToken) | ||||
|         => ValueTask.FromResult(0L); | ||||
|  | ||||
|     public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListRevokedAsync(DateTimeOffset? issuedAfter, CancellationToken cancellationToken) | ||||
|         => ValueTask.FromResult<IReadOnlyList<AuthorityTokenDocument>>(Array.Empty<AuthorityTokenDocument>()); | ||||
| } | ||||
|  | ||||
| internal sealed class TestClaimsEnricher : IClaimsEnricher | ||||
| @@ -328,6 +370,30 @@ internal sealed class TestIdentityProviderPlugin : IIdentityProviderPlugin | ||||
|         => ValueTask.FromResult(AuthorityPluginHealthResult.Healthy()); | ||||
| } | ||||
|  | ||||
| internal sealed class TestAuthEventSink : IAuthEventSink | ||||
| { | ||||
|     public List<AuthEventRecord> Events { get; } = new(); | ||||
|  | ||||
|     public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) | ||||
|     { | ||||
|         Events.Add(record); | ||||
|         return ValueTask.CompletedTask; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMetadataAccessor | ||||
| { | ||||
|     private readonly AuthorityRateLimiterMetadata metadata = new(); | ||||
|  | ||||
|     public AuthorityRateLimiterMetadata? GetMetadata() => metadata; | ||||
|  | ||||
|     public void SetClientId(string? clientId) => metadata.ClientId = clientId; | ||||
|  | ||||
|     public void SetSubjectId(string? subjectId) => metadata.SubjectId = subjectId; | ||||
|  | ||||
|     public void SetTag(string name, string? value) => metadata.SetTag(name, value); | ||||
| } | ||||
|  | ||||
| internal static class TestHelpers | ||||
| { | ||||
|     public static AuthorityClientDocument CreateClient( | ||||
|   | ||||
| @@ -0,0 +1,196 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Diagnostics; | ||||
| using System.Globalization; | ||||
| using System.Security.Claims; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Configuration; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using OpenIddict.Abstractions; | ||||
| using OpenIddict.Server; | ||||
| using OpenIddict.Server.AspNetCore; | ||||
| using StellaOps.Authority.OpenIddict; | ||||
| using StellaOps.Authority.OpenIddict.Handlers; | ||||
| using StellaOps.Authority.Plugins.Abstractions; | ||||
| using StellaOps.Authority.RateLimiting; | ||||
| using StellaOps.Cryptography.Audit; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Authority.Tests.OpenIddict; | ||||
|  | ||||
| public class PasswordGrantHandlersTests | ||||
| { | ||||
|     private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests"); | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandlePasswordGrant_EmitsSuccessAuditEvent() | ||||
|     { | ||||
|         var sink = new TestAuthEventSink(); | ||||
|         var metadataAccessor = new TestRateLimiterMetadataAccessor(); | ||||
|         var registry = CreateRegistry(new SuccessCredentialStore()); | ||||
|         var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance); | ||||
|         var handle = new HandlePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreatePasswordTransaction("alice", "Password1!"); | ||||
|  | ||||
|         await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); | ||||
|         await handle.HandleAsync(new OpenIddictServerEvents.HandleTokenRequestContext(transaction)); | ||||
|  | ||||
|         Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandlePasswordGrant_EmitsFailureAuditEvent() | ||||
|     { | ||||
|         var sink = new TestAuthEventSink(); | ||||
|         var metadataAccessor = new TestRateLimiterMetadataAccessor(); | ||||
|         var registry = CreateRegistry(new FailureCredentialStore()); | ||||
|         var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance); | ||||
|         var handle = new HandlePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreatePasswordTransaction("alice", "BadPassword!"); | ||||
|  | ||||
|         await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); | ||||
|         await handle.HandleAsync(new OpenIddictServerEvents.HandleTokenRequestContext(transaction)); | ||||
|  | ||||
|         Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandlePasswordGrant_EmitsLockoutAuditEvent() | ||||
|     { | ||||
|         var sink = new TestAuthEventSink(); | ||||
|         var metadataAccessor = new TestRateLimiterMetadataAccessor(); | ||||
|         var registry = CreateRegistry(new LockoutCredentialStore()); | ||||
|         var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance); | ||||
|         var handle = new HandlePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreatePasswordTransaction("alice", "Locked!"); | ||||
|  | ||||
|         await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); | ||||
|         await handle.HandleAsync(new OpenIddictServerEvents.HandleTokenRequestContext(transaction)); | ||||
|  | ||||
|         Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.LockedOut); | ||||
|     } | ||||
|  | ||||
|     private static AuthorityIdentityProviderRegistry CreateRegistry(IUserCredentialStore store) | ||||
|     { | ||||
|         var plugin = new StubIdentityProviderPlugin("stub", store); | ||||
|         return new AuthorityIdentityProviderRegistry(new[] { plugin }, NullLogger<AuthorityIdentityProviderRegistry>.Instance); | ||||
|     } | ||||
|  | ||||
|     private static OpenIddictServerTransaction CreatePasswordTransaction(string username, string password) | ||||
|     { | ||||
|         var request = new OpenIddictRequest | ||||
|         { | ||||
|             GrantType = OpenIddictConstants.GrantTypes.Password, | ||||
|             Username = username, | ||||
|             Password = password | ||||
|         }; | ||||
|  | ||||
|         return new OpenIddictServerTransaction | ||||
|         { | ||||
|             EndpointType = OpenIddictServerEndpointType.Token, | ||||
|             Options = new OpenIddictServerOptions(), | ||||
|             Request = request | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private sealed class StubIdentityProviderPlugin : IIdentityProviderPlugin | ||||
|     { | ||||
|         public StubIdentityProviderPlugin(string name, IUserCredentialStore store) | ||||
|         { | ||||
|             Name = name; | ||||
|             Type = "stub"; | ||||
|             var manifest = new AuthorityPluginManifest( | ||||
|                 name, | ||||
|                 "stub", | ||||
|                 enabled: true, | ||||
|                 version: null, | ||||
|                 description: null, | ||||
|                 capabilities: new[] { AuthorityPluginCapabilities.Password }, | ||||
|                 configuration: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase), | ||||
|                 configPath: $"{name}.yaml"); | ||||
|             Context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build()); | ||||
|             Credentials = store; | ||||
|             ClaimsEnricher = new NoopClaimsEnricher(); | ||||
|             Capabilities = new AuthorityIdentityProviderCapabilities(SupportsPassword: true, SupportsMfa: false, SupportsClientProvisioning: false); | ||||
|         } | ||||
|  | ||||
|         public string Name { get; } | ||||
|         public string Type { get; } | ||||
|         public AuthorityPluginContext Context { get; } | ||||
|         public IUserCredentialStore Credentials { get; } | ||||
|         public IClaimsEnricher ClaimsEnricher { get; } | ||||
|         public IClientProvisioningStore? ClientProvisioning => null; | ||||
|         public AuthorityIdentityProviderCapabilities Capabilities { get; } | ||||
|  | ||||
|         public ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(AuthorityPluginHealthResult.Healthy()); | ||||
|     } | ||||
|  | ||||
|     private sealed class NoopClaimsEnricher : IClaimsEnricher | ||||
|     { | ||||
|         public ValueTask EnrichAsync(ClaimsIdentity identity, AuthorityClaimsEnrichmentContext context, CancellationToken cancellationToken) | ||||
|             => ValueTask.CompletedTask; | ||||
|     } | ||||
|  | ||||
|     private sealed class SuccessCredentialStore : IUserCredentialStore | ||||
|     { | ||||
|         public ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken) | ||||
|         { | ||||
|             var descriptor = new AuthorityUserDescriptor("subject", username, "User", requiresPasswordReset: false); | ||||
|             return ValueTask.FromResult(AuthorityCredentialVerificationResult.Success(descriptor)); | ||||
|         } | ||||
|  | ||||
|         public ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken) | ||||
|             => throw new NotImplementedException(); | ||||
|  | ||||
|         public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult<AuthorityUserDescriptor?>(null); | ||||
|     } | ||||
|  | ||||
|     private sealed class FailureCredentialStore : IUserCredentialStore | ||||
|     { | ||||
|         public ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, "Invalid username or password.")); | ||||
|  | ||||
|         public ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken) | ||||
|             => throw new NotImplementedException(); | ||||
|  | ||||
|         public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult<AuthorityUserDescriptor?>(null); | ||||
|     } | ||||
|  | ||||
|     private sealed class LockoutCredentialStore : IUserCredentialStore | ||||
|     { | ||||
|         public ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken) | ||||
|         { | ||||
|             var retry = TimeSpan.FromMinutes(5); | ||||
|             var properties = new[] | ||||
|             { | ||||
|                 new AuthEventProperty | ||||
|                 { | ||||
|                     Name = "plugin.lockout_until", | ||||
|                     Value = ClassifiedString.Public(timeProvider.GetUtcNow().Add(retry).ToString("O", CultureInfo.InvariantCulture)) | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             return ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure( | ||||
|                 AuthorityCredentialFailureCode.LockedOut, | ||||
|                 "Account locked.", | ||||
|                 retryAfter: retry, | ||||
|                 auditProperties: properties)); | ||||
|         } | ||||
|  | ||||
|         private static readonly TimeProvider timeProvider = TimeProvider.System; | ||||
|  | ||||
|         public ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken) | ||||
|             => throw new NotImplementedException(); | ||||
|  | ||||
|         public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken) | ||||
|             => ValueTask.FromResult<AuthorityUserDescriptor?>(null); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -17,6 +17,8 @@ using StellaOps.Authority.Storage.Mongo.Extensions; | ||||
| using StellaOps.Authority.Storage.Mongo.Initialization; | ||||
| using StellaOps.Authority.Storage.Mongo.Stores; | ||||
| using StellaOps.Feedser.Testing; | ||||
| using StellaOps.Authority.RateLimiting; | ||||
| using StellaOps.Cryptography.Audit; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Authority.Tests.OpenIddict; | ||||
| @@ -55,7 +57,10 @@ public sealed class TokenPersistenceIntegrationTests | ||||
|             clientDescriptor: TestHelpers.CreateDescriptor(clientDocument)); | ||||
|  | ||||
|         var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||
|         var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance); | ||||
|         var authSink = new TestAuthEventSink(); | ||||
|         var metadataAccessor = new TestRateLimiterMetadataAccessor(); | ||||
|         var handleHandler = new HandleClientCredentialsHandler(registry, TestActivitySource, authSink, metadataAccessor, clock, NullLogger<HandleClientCredentialsHandler>.Instance); | ||||
|         var persistHandler = new PersistTokensHandler(tokenStore, clock, TestActivitySource, NullLogger<PersistTokensHandler>.Instance); | ||||
|  | ||||
|         var transaction = TestHelpers.CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:trigger"); | ||||
|         transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(15); | ||||
| @@ -72,6 +77,14 @@ public sealed class TokenPersistenceIntegrationTests | ||||
|         var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId); | ||||
|         Assert.False(string.IsNullOrWhiteSpace(tokenId)); | ||||
|  | ||||
|         var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) | ||||
|         { | ||||
|             Principal = principal, | ||||
|             AccessTokenPrincipal = principal | ||||
|         }; | ||||
|  | ||||
|         await persistHandler.HandleAsync(signInContext); | ||||
|  | ||||
|         var stored = await tokenStore.FindByTokenIdAsync(tokenId!, CancellationToken.None); | ||||
|         Assert.NotNull(stored); | ||||
|         Assert.Equal(clientDocument.ClientId, stored!.ClientId); | ||||
| @@ -133,7 +146,7 @@ public sealed class TokenPersistenceIntegrationTests | ||||
|         await tokenStore.InsertAsync(refreshToken, CancellationToken.None); | ||||
|  | ||||
|         var revokedAt = now.AddMinutes(1); | ||||
|         await tokenStore.UpdateStatusAsync(revokedTokenId, "revoked", revokedAt, CancellationToken.None); | ||||
|         await tokenStore.UpdateStatusAsync(revokedTokenId, "revoked", revokedAt, "manual", null, null, CancellationToken.None); | ||||
|  | ||||
|         var handler = new ValidateAccessTokenHandler( | ||||
|             tokenStore, | ||||
| @@ -173,7 +186,8 @@ public sealed class TokenPersistenceIntegrationTests | ||||
|         var stored = await tokenStore.FindByTokenIdAsync(revokedTokenId, CancellationToken.None); | ||||
|         Assert.NotNull(stored); | ||||
|         Assert.Equal("revoked", stored!.Status); | ||||
|        Assert.Equal(revokedAt, stored.RevokedAt); | ||||
|         Assert.Equal(revokedAt, stored.RevokedAt); | ||||
|         Assert.Equal("manual", stored.RevokedReason); | ||||
|     } | ||||
|  | ||||
|     private async Task ResetCollectionsAsync() | ||||
| @@ -206,3 +220,27 @@ public sealed class TokenPersistenceIntegrationTests | ||||
|         return provider; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class TestAuthEventSink : IAuthEventSink | ||||
| { | ||||
|     public List<AuthEventRecord> Records { get; } = new(); | ||||
|  | ||||
|     public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) | ||||
|     { | ||||
|         Records.Add(record); | ||||
|         return ValueTask.CompletedTask; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMetadataAccessor | ||||
| { | ||||
|     private readonly AuthorityRateLimiterMetadata metadata = new(); | ||||
|  | ||||
|     public AuthorityRateLimiterMetadata? GetMetadata() => metadata; | ||||
|  | ||||
|     public void SetClientId(string? clientId) => metadata.ClientId = string.IsNullOrWhiteSpace(clientId) ? null : clientId; | ||||
|  | ||||
|     public void SetSubjectId(string? subjectId) => metadata.SubjectId = string.IsNullOrWhiteSpace(subjectId) ? null : subjectId; | ||||
|  | ||||
|     public void SetTag(string name, string? value) => metadata.SetTag(name, value); | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,137 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Security.Cryptography; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.DependencyInjection.Extensions; | ||||
| using Microsoft.Extensions.FileProviders; | ||||
| using Microsoft.Extensions.Hosting; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Authority.Signing; | ||||
| using StellaOps.Configuration; | ||||
| using StellaOps.Cryptography; | ||||
| using StellaOps.Cryptography.DependencyInjection; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Authority.Tests.Signing; | ||||
|  | ||||
| public sealed class AuthoritySigningKeyManagerTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void Rotate_ReplacesActiveKeyAndRetiresPreviousKey() | ||||
|     { | ||||
|         var tempDir = Directory.CreateTempSubdirectory("authority-signing-tests").FullName; | ||||
|         var key1Relative = "key-1.pem"; | ||||
|         var key2Relative = "key-2.pem"; | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             CreateEcPrivateKey(Path.Combine(tempDir, key1Relative)); | ||||
|  | ||||
|             var options = new StellaOpsAuthorityOptions | ||||
|             { | ||||
|                 Issuer = new Uri("https://authority.test"), | ||||
|                 Storage = { ConnectionString = "mongodb://localhost/test" }, | ||||
|                 Signing = | ||||
|                 { | ||||
|                     Enabled = true, | ||||
|                     ActiveKeyId = "key-1", | ||||
|                     KeyPath = key1Relative, | ||||
|                     Algorithm = SignatureAlgorithms.Es256, | ||||
|                     KeySource = "file", | ||||
|                     Provider = "default" | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             using var provider = BuildProvider(tempDir, options); | ||||
|             var manager = provider.GetRequiredService<AuthoritySigningKeyManager>(); | ||||
|             var jwksService = provider.GetRequiredService<AuthorityJwksService>(); | ||||
|  | ||||
|             var initial = jwksService.Build(); | ||||
|             var initialKey = Assert.Single(initial.Keys); | ||||
|             Assert.Equal("key-1", initialKey.Kid); | ||||
|             Assert.Equal(AuthoritySigningKeyStatus.Active, initialKey.Status); | ||||
|  | ||||
|             CreateEcPrivateKey(Path.Combine(tempDir, key2Relative)); | ||||
|  | ||||
|             var result = manager.Rotate(new SigningRotationRequest | ||||
|             { | ||||
|                 KeyId = "key-2", | ||||
|                 Location = key2Relative | ||||
|             }); | ||||
|  | ||||
|             Assert.Equal("key-2", result.ActiveKeyId); | ||||
|             Assert.Equal("key-1", result.PreviousKeyId); | ||||
|             Assert.Contains("key-1", result.RetiredKeyIds); | ||||
|  | ||||
|             Assert.Equal("key-2", options.Signing.ActiveKeyId); | ||||
|             var additional = Assert.Single(options.Signing.AdditionalKeys); | ||||
|             Assert.Equal("key-1", additional.KeyId); | ||||
|             Assert.Equal(key1Relative, additional.Path); | ||||
|             Assert.Equal("file", additional.Source); | ||||
|  | ||||
|             var afterRotation = jwksService.Build(); | ||||
|             Assert.Equal(2, afterRotation.Keys.Count); | ||||
|  | ||||
|             var activeEntry = Assert.Single(afterRotation.Keys.Where(key => key.Status == AuthoritySigningKeyStatus.Active)); | ||||
|             Assert.Equal("key-2", activeEntry.Kid); | ||||
|  | ||||
|             var retiredEntry = Assert.Single(afterRotation.Keys.Where(key => key.Status == AuthoritySigningKeyStatus.Retired)); | ||||
|             Assert.Equal("key-1", retiredEntry.Kid); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 Directory.Delete(tempDir, recursive: true); | ||||
|             } | ||||
|             catch | ||||
|             { | ||||
|                 // ignore cleanup failures | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static ServiceProvider BuildProvider(string basePath, StellaOpsAuthorityOptions options) | ||||
|     { | ||||
|         var services = new ServiceCollection(); | ||||
|         services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         services.AddSingleton<IHostEnvironment>(new TestHostEnvironment(basePath)); | ||||
|         services.AddSingleton(options); | ||||
|         services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(options)); | ||||
|         services.AddStellaOpsCrypto(); | ||||
|         services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthoritySigningKeySource, FileAuthoritySigningKeySource>()); | ||||
|         services.AddSingleton<AuthoritySigningKeyManager>(); | ||||
|         services.AddSingleton<AuthorityJwksService>(); | ||||
|  | ||||
|         return services.BuildServiceProvider(); | ||||
|     } | ||||
|  | ||||
|     private static void CreateEcPrivateKey(string path) | ||||
|     { | ||||
|         Directory.CreateDirectory(Path.GetDirectoryName(path)!); | ||||
|         using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); | ||||
|         var pem = ecdsa.ExportECPrivateKeyPem(); | ||||
|         File.WriteAllText(path, pem); | ||||
|     } | ||||
|  | ||||
|     private sealed class TestHostEnvironment : IHostEnvironment | ||||
|     { | ||||
|         public TestHostEnvironment(string contentRoot) | ||||
|         { | ||||
|             ContentRootPath = contentRoot; | ||||
|             ContentRootFileProvider = new PhysicalFileProvider(contentRoot); | ||||
|             EnvironmentName = Environments.Development; | ||||
|             ApplicationName = "StellaOps.Authority.Tests"; | ||||
|         } | ||||
|  | ||||
|         public string EnvironmentName { get; set; } | ||||
|  | ||||
|         public string ApplicationName { get; set; } | ||||
|  | ||||
|         public string ContentRootPath { get; set; } | ||||
|  | ||||
|         public IFileProvider ContentRootFileProvider { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -53,6 +53,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", ". | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Tests", "..\StellaOps.Cryptography.Tests\StellaOps.Cryptography.Tests.csproj", "{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.DependencyInjection", "..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj", "{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}" | ||||
| EndProject | ||||
| Global | ||||
| 	GlobalSection(SolutionConfigurationPlatforms) = preSolution | ||||
| 		Debug|Any CPU = Debug|Any CPU | ||||
| @@ -363,6 +365,18 @@ Global | ||||
| 		{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Release|x64.Build.0 = Release|Any CPU | ||||
| 		{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Release|x86.ActiveCfg = Release|Any CPU | ||||
| 		{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Release|x86.Build.0 = Release|Any CPU | ||||
| 		{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Debug|x64.ActiveCfg = Debug|Any CPU | ||||
| 		{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Debug|x64.Build.0 = Debug|Any CPU | ||||
| 		{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Debug|x86.ActiveCfg = Debug|Any CPU | ||||
| 		{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Debug|x86.Build.0 = Debug|Any CPU | ||||
| 		{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|x64.ActiveCfg = Release|Any CPU | ||||
| 		{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|x64.Build.0 = Release|Any CPU | ||||
| 		{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|x86.ActiveCfg = Release|Any CPU | ||||
| 		{159A9B4E-61F8-4A82-8F6E-D01E3FB7E18F}.Release|x86.Build.0 = Release|Any CPU | ||||
| 	EndGlobalSection | ||||
| 	GlobalSection(SolutionProperties) = preSolution | ||||
| 		HideSolutionNode = FALSE | ||||
|   | ||||
| @@ -0,0 +1,230 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Globalization; | ||||
| using System.Linq; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
| using StellaOps.Authority.Storage.Mongo.Stores; | ||||
| using StellaOps.Cryptography.Audit; | ||||
|  | ||||
| namespace StellaOps.Authority.Audit; | ||||
|  | ||||
| internal sealed class AuthorityAuditSink : IAuthEventSink | ||||
| { | ||||
|     private static readonly StringComparer OrdinalComparer = StringComparer.Ordinal; | ||||
|  | ||||
|     private readonly IAuthorityLoginAttemptStore loginAttemptStore; | ||||
|     private readonly ILogger<AuthorityAuditSink> logger; | ||||
|  | ||||
|     public AuthorityAuditSink( | ||||
|         IAuthorityLoginAttemptStore loginAttemptStore, | ||||
|         ILogger<AuthorityAuditSink> logger) | ||||
|     { | ||||
|         this.loginAttemptStore = loginAttemptStore ?? throw new ArgumentNullException(nameof(loginAttemptStore)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(record); | ||||
|  | ||||
|         var logState = BuildLogScope(record); | ||||
|         using (logger.BeginScope(logState)) | ||||
|         { | ||||
|             logger.LogInformation( | ||||
|                 "Authority audit event {EventType} emitted with outcome {Outcome}.", | ||||
|                 record.EventType, | ||||
|                 NormalizeOutcome(record.Outcome)); | ||||
|         } | ||||
|  | ||||
|         var document = MapToDocument(record); | ||||
|         await loginAttemptStore.InsertAsync(document, cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     private static AuthorityLoginAttemptDocument MapToDocument(AuthEventRecord record) | ||||
|     { | ||||
|         var document = new AuthorityLoginAttemptDocument | ||||
|         { | ||||
|             EventType = record.EventType, | ||||
|             Outcome = NormalizeOutcome(record.Outcome), | ||||
|             CorrelationId = Normalize(record.CorrelationId), | ||||
|             SubjectId = record.Subject?.SubjectId.Value, | ||||
|             Username = record.Subject?.Username.Value, | ||||
|             ClientId = record.Client?.ClientId.Value, | ||||
|             Plugin = record.Client?.Provider.Value, | ||||
|             Successful = record.Outcome == AuthEventOutcome.Success, | ||||
|             Reason = Normalize(record.Reason), | ||||
|             RemoteAddress = record.Network?.RemoteAddress.Value ?? record.Network?.ForwardedFor.Value, | ||||
|             OccurredAt = record.OccurredAt | ||||
|         }; | ||||
|  | ||||
|         if (record.Scopes is { Count: > 0 }) | ||||
|         { | ||||
|             document.Scopes = record.Scopes | ||||
|                 .Where(static scope => !string.IsNullOrWhiteSpace(scope)) | ||||
|                 .Select(static scope => scope.Trim()) | ||||
|                 .Where(static scope => scope.Length > 0) | ||||
|                 .Distinct(OrdinalComparer) | ||||
|                 .OrderBy(static scope => scope, OrdinalComparer) | ||||
|                 .ToList(); | ||||
|         } | ||||
|  | ||||
|         var properties = new List<AuthorityLoginAttemptPropertyDocument>(); | ||||
|  | ||||
|         if (record.Subject is { } subject) | ||||
|         { | ||||
|             AddProperty(properties, "subject.display_name", subject.DisplayName); | ||||
|             AddProperty(properties, "subject.realm", subject.Realm); | ||||
|  | ||||
|             if (subject.Attributes is { Count: > 0 }) | ||||
|             { | ||||
|                 foreach (var attribute in subject.Attributes) | ||||
|                 { | ||||
|                     AddProperty(properties, $"subject.attr.{attribute.Name}", attribute.Value); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (record.Client is { } client) | ||||
|         { | ||||
|             AddProperty(properties, "client.name", client.Name); | ||||
|         } | ||||
|  | ||||
|         if (record.Network is { } network) | ||||
|         { | ||||
|             AddProperty(properties, "network.remote", network.RemoteAddress); | ||||
|             AddProperty(properties, "network.forwarded_for", network.ForwardedFor); | ||||
|             AddProperty(properties, "network.user_agent", network.UserAgent); | ||||
|         } | ||||
|  | ||||
|         if (record.Properties is { Count: > 0 }) | ||||
|         { | ||||
|             foreach (var property in record.Properties) | ||||
|             { | ||||
|                 AddProperty(properties, property.Name, property.Value); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (properties.Count > 0) | ||||
|         { | ||||
|             document.Properties = properties; | ||||
|         } | ||||
|  | ||||
|         return document; | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyCollection<KeyValuePair<string, object?>> BuildLogScope(AuthEventRecord record) | ||||
|     { | ||||
|         var entries = new List<KeyValuePair<string, object?>> | ||||
|         { | ||||
|             new("audit.event_type", record.EventType), | ||||
|             new("audit.outcome", NormalizeOutcome(record.Outcome)), | ||||
|             new("audit.timestamp", record.OccurredAt.ToString("O", CultureInfo.InvariantCulture)) | ||||
|         }; | ||||
|  | ||||
|         AddValue(entries, "audit.correlation_id", Normalize(record.CorrelationId)); | ||||
|         AddValue(entries, "audit.reason", Normalize(record.Reason)); | ||||
|  | ||||
|         if (record.Subject is { } subject) | ||||
|         { | ||||
|             AddClassified(entries, "audit.subject.id", subject.SubjectId); | ||||
|             AddClassified(entries, "audit.subject.username", subject.Username); | ||||
|             AddClassified(entries, "audit.subject.display_name", subject.DisplayName); | ||||
|             AddClassified(entries, "audit.subject.realm", subject.Realm); | ||||
|         } | ||||
|  | ||||
|         if (record.Client is { } client) | ||||
|         { | ||||
|             AddClassified(entries, "audit.client.id", client.ClientId); | ||||
|             AddClassified(entries, "audit.client.name", client.Name); | ||||
|             AddClassified(entries, "audit.client.provider", client.Provider); | ||||
|         } | ||||
|  | ||||
|         if (record.Network is { } network) | ||||
|         { | ||||
|             AddClassified(entries, "audit.network.remote", network.RemoteAddress); | ||||
|             AddClassified(entries, "audit.network.forwarded_for", network.ForwardedFor); | ||||
|             AddClassified(entries, "audit.network.user_agent", network.UserAgent); | ||||
|         } | ||||
|  | ||||
|         if (record.Scopes is { Count: > 0 }) | ||||
|         { | ||||
|             entries.Add(new KeyValuePair<string, object?>( | ||||
|                 "audit.scopes", | ||||
|                 record.Scopes.Where(static scope => !string.IsNullOrWhiteSpace(scope)).ToArray())); | ||||
|         } | ||||
|  | ||||
|         if (record.Properties is { Count: > 0 }) | ||||
|         { | ||||
|             foreach (var property in record.Properties) | ||||
|             { | ||||
|                 AddClassified(entries, $"audit.property.{property.Name}", property.Value); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return entries; | ||||
|     } | ||||
|  | ||||
|     private static void AddProperty(ICollection<AuthorityLoginAttemptPropertyDocument> properties, string name, ClassifiedString value) | ||||
|     { | ||||
|         if (!value.HasValue || string.IsNullOrWhiteSpace(name)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         properties.Add(new AuthorityLoginAttemptPropertyDocument | ||||
|         { | ||||
|             Name = name, | ||||
|             Value = value.Value, | ||||
|             Classification = NormalizeClassification(value.Classification) | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     private static void AddValue(ICollection<KeyValuePair<string, object?>> entries, string key, string? value) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         entries.Add(new KeyValuePair<string, object?>(key, value)); | ||||
|     } | ||||
|  | ||||
|     private static void AddClassified(ICollection<KeyValuePair<string, object?>> entries, string key, ClassifiedString value) | ||||
|     { | ||||
|         if (!value.HasValue || string.IsNullOrWhiteSpace(key)) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         entries.Add(new KeyValuePair<string, object?>(key, new | ||||
|         { | ||||
|             value.Value, | ||||
|             classification = NormalizeClassification(value.Classification) | ||||
|         })); | ||||
|     } | ||||
|  | ||||
|     private static string NormalizeOutcome(AuthEventOutcome outcome) | ||||
|         => outcome switch | ||||
|         { | ||||
|             AuthEventOutcome.Success => "success", | ||||
|             AuthEventOutcome.Failure => "failure", | ||||
|             AuthEventOutcome.LockedOut => "locked_out", | ||||
|             AuthEventOutcome.RateLimited => "rate_limited", | ||||
|             AuthEventOutcome.Error => "error", | ||||
|             _ => "unknown" | ||||
|         }; | ||||
|  | ||||
|     private static string NormalizeClassification(AuthEventDataClassification classification) | ||||
|         => classification switch | ||||
|         { | ||||
|             AuthEventDataClassification.Personal => "personal", | ||||
|             AuthEventDataClassification.Sensitive => "sensitive", | ||||
|             _ => "none" | ||||
|         }; | ||||
|  | ||||
|     private static string? Normalize(string? value) | ||||
|         => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); | ||||
| } | ||||
| @@ -8,4 +8,11 @@ internal static class AuthorityOpenIddictConstants | ||||
|     internal const string ClientProviderTransactionProperty = "authority:client_provider"; | ||||
|     internal const string ClientGrantedScopesProperty = "authority:client_granted_scopes"; | ||||
|     internal const string TokenTransactionProperty = "authority:token"; | ||||
|     internal const string AuditCorrelationProperty = "authority:audit_correlation_id"; | ||||
|     internal const string AuditClientIdProperty = "authority:audit_client_id"; | ||||
|     internal const string AuditProviderProperty = "authority:audit_provider"; | ||||
|     internal const string AuditConfidentialProperty = "authority:audit_confidential"; | ||||
|     internal const string AuditRequestedScopesProperty = "authority:audit_requested_scopes"; | ||||
|     internal const string AuditGrantedScopesProperty = "authority:audit_granted_scopes"; | ||||
|     internal const string AuditInvalidScopeProperty = "authority:audit_invalid_scope"; | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,7 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Diagnostics; | ||||
| using System.Globalization; | ||||
| using System.Linq; | ||||
| using System.Security.Claims; | ||||
| using System.Security.Cryptography; | ||||
| @@ -12,6 +15,8 @@ using StellaOps.Authority.OpenIddict; | ||||
| using StellaOps.Authority.Plugins.Abstractions; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
| using StellaOps.Authority.Storage.Mongo.Stores; | ||||
| using StellaOps.Authority.RateLimiting; | ||||
| using StellaOps.Cryptography.Audit; | ||||
|  | ||||
| namespace StellaOps.Authority.OpenIddict.Handlers; | ||||
|  | ||||
| @@ -20,17 +25,26 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|     private readonly IAuthorityClientStore clientStore; | ||||
|     private readonly IAuthorityIdentityProviderRegistry registry; | ||||
|     private readonly ActivitySource activitySource; | ||||
|     private readonly IAuthEventSink auditSink; | ||||
|     private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor; | ||||
|     private readonly TimeProvider timeProvider; | ||||
|     private readonly ILogger<ValidateClientCredentialsHandler> logger; | ||||
|  | ||||
|     public ValidateClientCredentialsHandler( | ||||
|         IAuthorityClientStore clientStore, | ||||
|         IAuthorityIdentityProviderRegistry registry, | ||||
|         ActivitySource activitySource, | ||||
|         IAuthEventSink auditSink, | ||||
|         IAuthorityRateLimiterMetadataAccessor metadataAccessor, | ||||
|         TimeProvider timeProvider, | ||||
|         ILogger<ValidateClientCredentialsHandler> logger) | ||||
|     { | ||||
|         this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore)); | ||||
|         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); | ||||
|         this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); | ||||
|         this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink)); | ||||
|         this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor)); | ||||
|         this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
| @@ -48,13 +62,29 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|         activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.ClientCredentials); | ||||
|         activity?.SetTag("authority.client_id", context.ClientId ?? string.Empty); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(context.ClientId)) | ||||
|         ClientCredentialsAuditHelper.EnsureCorrelationId(context.Transaction); | ||||
|  | ||||
|         var metadata = metadataAccessor.GetMetadata(); | ||||
|         var clientId = context.ClientId ?? context.Request.ClientId; | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.AuditClientIdProperty] = clientId; | ||||
|         if (!string.IsNullOrWhiteSpace(clientId)) | ||||
|         { | ||||
|             context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client identifier is required."); | ||||
|             logger.LogWarning("Client credentials validation failed: missing client identifier."); | ||||
|             return; | ||||
|             metadataAccessor.SetClientId(clientId); | ||||
|         } | ||||
|  | ||||
|         var requestedScopeInput = context.Request.GetScopes(); | ||||
|         var requestedScopes = requestedScopeInput.IsDefaultOrEmpty ? Array.Empty<string>() : requestedScopeInput.ToArray(); | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.AuditRequestedScopesProperty] = requestedScopes; | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(context.ClientId)) | ||||
|             { | ||||
|                 context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client identifier is required."); | ||||
|                 logger.LogWarning("Client credentials validation failed: missing client identifier."); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|         var document = await clientStore.FindByClientIdAsync(context.ClientId, context.CancellationToken).ConfigureAwait(false); | ||||
|         if (document is null || document.Disabled) | ||||
|         { | ||||
| @@ -63,6 +93,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.AuditConfidentialProperty] = string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase); | ||||
|  | ||||
|         IIdentityProviderPlugin? provider = null; | ||||
|         if (!string.IsNullOrWhiteSpace(document.Plugin)) | ||||
|         { | ||||
| @@ -73,6 +105,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             context.Transaction.Properties[AuthorityOpenIddictConstants.AuditProviderProperty] = provider.Name; | ||||
|  | ||||
|             if (!provider.Capabilities.SupportsClientProvisioning || provider.ClientProvisioning is null) | ||||
|             { | ||||
|                 context.Reject(OpenIddictConstants.Errors.UnauthorizedClient, "Associated identity provider does not support client provisioning."); | ||||
| @@ -123,11 +157,14 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|  | ||||
|         if (resolvedScopes.InvalidScope is not null) | ||||
|         { | ||||
|             context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = resolvedScopes.InvalidScope; | ||||
|             context.Reject(OpenIddictConstants.Errors.InvalidScope, $"Scope '{resolvedScopes.InvalidScope}' is not allowed for this client."); | ||||
|             logger.LogWarning("Client credentials validation failed for {ClientId}: scope {Scope} not permitted.", document.ClientId, resolvedScopes.InvalidScope); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.AuditGrantedScopesProperty] = resolvedScopes.Scopes; | ||||
|  | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = document; | ||||
|         if (provider is not null) | ||||
|         { | ||||
| @@ -138,6 +175,46 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = resolvedScopes.Scopes; | ||||
|         logger.LogInformation("Client credentials validated for {ClientId}.", document.ClientId); | ||||
|     } | ||||
|         finally | ||||
|         { | ||||
|             var outcome = context.IsRejected ? AuthEventOutcome.Failure : AuthEventOutcome.Success; | ||||
|             var reason = context.IsRejected ? context.ErrorDescription : null; | ||||
|             var auditClientId = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditClientIdProperty, out var clientValue) | ||||
|                 ? clientValue as string | ||||
|                 : clientId; | ||||
|             var providerName = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditProviderProperty, out var providerValue) | ||||
|                 ? providerValue as string | ||||
|                 : null; | ||||
|             var confidentialValue = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditConfidentialProperty, out var confidentialValueObj) && confidentialValueObj is bool conf | ||||
|                 ? (bool?)conf | ||||
|                 : null; | ||||
|             var requested = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditRequestedScopesProperty, out var requestedValue) && requestedValue is string[] requestedArray | ||||
|                 ? (IReadOnlyList<string>)requestedArray | ||||
|                 : requestedScopes; | ||||
|             var granted = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditGrantedScopesProperty, out var grantedValue) && grantedValue is string[] grantedArray | ||||
|                 ? (IReadOnlyList<string>)grantedArray | ||||
|                 : Array.Empty<string>(); | ||||
|             var invalidScope = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditInvalidScopeProperty, out var invalidValue) | ||||
|                 ? invalidValue as string | ||||
|                 : null; | ||||
|  | ||||
|             var record = ClientCredentialsAuditHelper.CreateRecord( | ||||
|                 timeProvider, | ||||
|                 context.Transaction, | ||||
|                 metadata, | ||||
|                 null, | ||||
|                 outcome, | ||||
|                 reason, | ||||
|                 auditClientId, | ||||
|                 providerName, | ||||
|                 confidentialValue, | ||||
|                 requested, | ||||
|                 granted, | ||||
|                 invalidScope); | ||||
|  | ||||
|             await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext> | ||||
|   | ||||
| @@ -1,5 +1,10 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Immutable; | ||||
| using System.Diagnostics; | ||||
| using System.Globalization; | ||||
| using System.Linq; | ||||
| using System.Security.Claims; | ||||
| using Microsoft.AspNetCore.Http; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using OpenIddict.Abstractions; | ||||
| using OpenIddict.Extensions; | ||||
| @@ -7,6 +12,8 @@ using OpenIddict.Server; | ||||
| using OpenIddict.Server.AspNetCore; | ||||
| using StellaOps.Authority.OpenIddict; | ||||
| using StellaOps.Authority.Plugins.Abstractions; | ||||
| using StellaOps.Authority.RateLimiting; | ||||
| using StellaOps.Cryptography.Audit; | ||||
|  | ||||
| namespace StellaOps.Authority.OpenIddict.Handlers; | ||||
|  | ||||
| @@ -14,25 +21,34 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op | ||||
| { | ||||
|     private readonly IAuthorityIdentityProviderRegistry registry; | ||||
|     private readonly ActivitySource activitySource; | ||||
|     private readonly IAuthEventSink auditSink; | ||||
|     private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor; | ||||
|     private readonly TimeProvider timeProvider; | ||||
|     private readonly ILogger<ValidatePasswordGrantHandler> logger; | ||||
|  | ||||
|     public ValidatePasswordGrantHandler( | ||||
|         IAuthorityIdentityProviderRegistry registry, | ||||
|         ActivitySource activitySource, | ||||
|         IAuthEventSink auditSink, | ||||
|         IAuthorityRateLimiterMetadataAccessor metadataAccessor, | ||||
|         TimeProvider timeProvider, | ||||
|         ILogger<ValidatePasswordGrantHandler> logger) | ||||
|     { | ||||
|         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); | ||||
|         this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); | ||||
|         this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink)); | ||||
|         this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor)); | ||||
|         this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) | ||||
|     public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(context); | ||||
|  | ||||
|         if (!context.Request.IsPasswordGrantType()) | ||||
|         { | ||||
|             return default; | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         using var activity = activitySource.StartActivity("authority.token.validate_password_grant", ActivityKind.Internal); | ||||
| @@ -40,25 +56,72 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op | ||||
|         activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.Password); | ||||
|         activity?.SetTag("authority.username", context.Request.Username ?? string.Empty); | ||||
|  | ||||
|         PasswordGrantAuditHelper.EnsureCorrelationId(context.Transaction); | ||||
|  | ||||
|         var metadata = metadataAccessor.GetMetadata(); | ||||
|         var clientId = context.ClientId ?? context.Request.ClientId; | ||||
|         if (!string.IsNullOrWhiteSpace(clientId)) | ||||
|         { | ||||
|             metadataAccessor.SetClientId(clientId); | ||||
|         } | ||||
|  | ||||
|         var requestedScopesInput = context.Request.GetScopes(); | ||||
|         var requestedScopes = requestedScopesInput.IsDefaultOrEmpty ? Array.Empty<string>() : requestedScopesInput.ToArray(); | ||||
|  | ||||
|         var selection = AuthorityIdentityProviderSelector.ResolvePasswordProvider(context.Request, registry); | ||||
|         if (!selection.Succeeded) | ||||
|         { | ||||
|             var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord( | ||||
|                 timeProvider, | ||||
|                 context.Transaction, | ||||
|                 metadata, | ||||
|                 null, | ||||
|                 AuthEventOutcome.Failure, | ||||
|                 selection.Description, | ||||
|                 clientId, | ||||
|                 providerName: null, | ||||
|                 user: null, | ||||
|                 username: context.Request.Username, | ||||
|                 scopes: requestedScopes, | ||||
|                 retryAfter: null, | ||||
|                 failureCode: AuthorityCredentialFailureCode.InvalidCredentials, | ||||
|                 extraProperties: null); | ||||
|  | ||||
|             await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             context.Reject(selection.Error!, selection.Description); | ||||
|             logger.LogWarning("Password grant validation failed for {Username}: {Reason}.", context.Request.Username, selection.Description); | ||||
|             return default; | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(context.Request.Username) || string.IsNullOrEmpty(context.Request.Password)) | ||||
|         { | ||||
|             var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord( | ||||
|                 timeProvider, | ||||
|                 context.Transaction, | ||||
|                 metadata, | ||||
|                 httpContext, | ||||
|                 AuthEventOutcome.Failure, | ||||
|                 "Both username and password must be provided.", | ||||
|                 clientId, | ||||
|                 providerName: selection.Provider?.Name, | ||||
|                 user: null, | ||||
|                 username: context.Request.Username, | ||||
|                 scopes: requestedScopes, | ||||
|                 retryAfter: null, | ||||
|                 failureCode: AuthorityCredentialFailureCode.InvalidCredentials, | ||||
|                 extraProperties: null); | ||||
|  | ||||
|             await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Both username and password must be provided."); | ||||
|             logger.LogWarning("Password grant validation failed: missing credentials for {Username}.", context.Request.Username); | ||||
|             return default; | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.ProviderTransactionProperty] = selection.Provider!.Name; | ||||
|         activity?.SetTag("authority.identity_provider", selection.Provider.Name); | ||||
|         logger.LogInformation("Password grant validation succeeded for {Username} using provider {Provider}.", context.Request.Username, selection.Provider.Name); | ||||
|         return default; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -66,15 +129,24 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
| { | ||||
|     private readonly IAuthorityIdentityProviderRegistry registry; | ||||
|     private readonly ActivitySource activitySource; | ||||
|     private readonly IAuthEventSink auditSink; | ||||
|     private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor; | ||||
|     private readonly TimeProvider timeProvider; | ||||
|     private readonly ILogger<HandlePasswordGrantHandler> logger; | ||||
|  | ||||
|     public HandlePasswordGrantHandler( | ||||
|         IAuthorityIdentityProviderRegistry registry, | ||||
|         ActivitySource activitySource, | ||||
|         IAuthEventSink auditSink, | ||||
|         IAuthorityRateLimiterMetadataAccessor metadataAccessor, | ||||
|         TimeProvider timeProvider, | ||||
|         ILogger<HandlePasswordGrantHandler> logger) | ||||
|     { | ||||
|         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); | ||||
|         this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); | ||||
|         this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink)); | ||||
|         this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor)); | ||||
|         this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
| @@ -92,6 +164,18 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|         activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.Password); | ||||
|         activity?.SetTag("authority.username", context.Request.Username ?? string.Empty); | ||||
|  | ||||
|         PasswordGrantAuditHelper.EnsureCorrelationId(context.Transaction); | ||||
|  | ||||
|         var metadata = metadataAccessor.GetMetadata(); | ||||
|         var clientId = context.ClientId ?? context.Request.ClientId; | ||||
|         if (!string.IsNullOrWhiteSpace(clientId)) | ||||
|         { | ||||
|             metadataAccessor.SetClientId(clientId); | ||||
|         } | ||||
|  | ||||
|         var requestedScopesInput = context.Request.GetScopes(); | ||||
|         var requestedScopes = requestedScopesInput.IsDefaultOrEmpty ? Array.Empty<string>() : requestedScopesInput.ToArray(); | ||||
|  | ||||
|         var providerName = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ProviderTransactionProperty, out var value) | ||||
|             ? value as string | ||||
|             : null; | ||||
| @@ -101,6 +185,23 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|         { | ||||
|             if (!registry.TryGet(providerName!, out var explicitProvider)) | ||||
|             { | ||||
|                 var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord( | ||||
|                     timeProvider, | ||||
|                     context.Transaction, | ||||
|                     metadata, | ||||
|                     AuthEventOutcome.Failure, | ||||
|                     "Unable to resolve the requested identity provider.", | ||||
|                     clientId, | ||||
|                     providerName, | ||||
|                     user: null, | ||||
|                     username: context.Request.Username, | ||||
|                     scopes: requestedScopes, | ||||
|                     retryAfter: null, | ||||
|                     failureCode: AuthorityCredentialFailureCode.UnknownError, | ||||
|                     extraProperties: null); | ||||
|  | ||||
|                 await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|                 context.Reject(OpenIddictConstants.Errors.ServerError, "Unable to resolve the requested identity provider."); | ||||
|                 logger.LogError("Password grant handling failed: provider {Provider} not found for user {Username}.", providerName, context.Request.Username); | ||||
|                 return; | ||||
| @@ -113,12 +214,30 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|             var selection = AuthorityIdentityProviderSelector.ResolvePasswordProvider(context.Request, registry); | ||||
|             if (!selection.Succeeded) | ||||
|             { | ||||
|                 var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord( | ||||
|                     timeProvider, | ||||
|                     context.Transaction, | ||||
|                     metadata, | ||||
|                     AuthEventOutcome.Failure, | ||||
|                     selection.Description, | ||||
|                     clientId, | ||||
|                     providerName: null, | ||||
|                     user: null, | ||||
|                     username: context.Request.Username, | ||||
|                     scopes: requestedScopes, | ||||
|                     retryAfter: null, | ||||
|                     failureCode: AuthorityCredentialFailureCode.InvalidCredentials, | ||||
|                     extraProperties: null); | ||||
|  | ||||
|                 await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|                 context.Reject(selection.Error!, selection.Description); | ||||
|                 logger.LogWarning("Password grant handling rejected {Username}: {Reason}.", context.Request.Username, selection.Description); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             resolvedProvider = selection.Provider; | ||||
|             providerName = selection.Provider?.Name; | ||||
|         } | ||||
|  | ||||
|         var provider = resolvedProvider ?? throw new InvalidOperationException("No identity provider resolved for password grant."); | ||||
| @@ -127,6 +246,24 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|         var password = context.Request.Password; | ||||
|         if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password)) | ||||
|         { | ||||
|             var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord( | ||||
|                 timeProvider, | ||||
|                 context.Transaction, | ||||
|                 metadata, | ||||
|                 httpContext, | ||||
|                 AuthEventOutcome.Failure, | ||||
|                 "Both username and password must be provided.", | ||||
|                 clientId, | ||||
|                 provider.Name, | ||||
|                 user: null, | ||||
|                 username: username, | ||||
|                 scopes: requestedScopes, | ||||
|                 retryAfter: null, | ||||
|                 failureCode: AuthorityCredentialFailureCode.InvalidCredentials, | ||||
|                 extraProperties: null); | ||||
|  | ||||
|             await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Both username and password must be provided."); | ||||
|             logger.LogWarning("Password grant handling rejected: missing credentials for {Username}.", username); | ||||
|             return; | ||||
| @@ -139,6 +276,27 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|  | ||||
|         if (!verification.Succeeded || verification.User is null) | ||||
|         { | ||||
|             var outcome = verification.FailureCode == AuthorityCredentialFailureCode.LockedOut | ||||
|                 ? AuthEventOutcome.LockedOut | ||||
|                 : AuthEventOutcome.Failure; | ||||
|  | ||||
|             var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord( | ||||
|                 timeProvider, | ||||
|                 context.Transaction, | ||||
|                 metadata, | ||||
|                 outcome, | ||||
|                 verification.Message, | ||||
|                 clientId, | ||||
|                 provider.Name, | ||||
|                 verification.User, | ||||
|                 username, | ||||
|                 scopes: requestedScopes, | ||||
|                 retryAfter: verification.RetryAfter, | ||||
|                 failureCode: verification.FailureCode, | ||||
|                 extraProperties: verification.AuditProperties); | ||||
|  | ||||
|             await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             context.Reject( | ||||
|                 OpenIddictConstants.Errors.InvalidGrant, | ||||
|                 verification.Message ?? "Invalid username or password."); | ||||
| @@ -146,6 +304,8 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         metadataAccessor.SetSubjectId(verification.User.SubjectId); | ||||
|  | ||||
|         var identity = new ClaimsIdentity( | ||||
|             OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, | ||||
|             OpenIddictConstants.Claims.Name, | ||||
| @@ -179,9 +339,246 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|         var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, verification.User, null); | ||||
|         await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         var successRecord = PasswordGrantAuditHelper.CreatePasswordGrantRecord( | ||||
|             timeProvider, | ||||
|             context.Transaction, | ||||
|             metadata, | ||||
|             AuthEventOutcome.Success, | ||||
|             verification.Message, | ||||
|             clientId, | ||||
|             provider.Name, | ||||
|             verification.User, | ||||
|             username, | ||||
|             scopes: requestedScopes, | ||||
|             retryAfter: null, | ||||
|             failureCode: null, | ||||
|             extraProperties: verification.AuditProperties); | ||||
|  | ||||
|         await auditSink.WriteAsync(successRecord, context.CancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         context.Principal = principal; | ||||
|         context.HandleRequest(); | ||||
|         activity?.SetTag("authority.subject_id", verification.User.SubjectId); | ||||
|         logger.LogInformation("Password grant issued for {Username} with subject {SubjectId}.", verification.User.Username, verification.User.SubjectId); | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal static class PasswordGrantAuditHelper | ||||
| { | ||||
|     internal static string EnsureCorrelationId(OpenIddictServerTransaction transaction) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(transaction); | ||||
|  | ||||
|         if (transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditCorrelationProperty, out var value) && | ||||
|             value is string existing && !string.IsNullOrWhiteSpace(existing)) | ||||
|         { | ||||
|             return existing; | ||||
|         } | ||||
|  | ||||
|         var correlation = Activity.Current?.TraceId.ToString() ?? | ||||
|                           Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); | ||||
|  | ||||
|         transaction.Properties[AuthorityOpenIddictConstants.AuditCorrelationProperty] = correlation; | ||||
|         return correlation; | ||||
|     } | ||||
|  | ||||
|     internal static AuthEventRecord CreatePasswordGrantRecord( | ||||
|         TimeProvider timeProvider, | ||||
|         OpenIddictServerTransaction transaction, | ||||
|         AuthorityRateLimiterMetadata? metadata, | ||||
|         AuthEventOutcome outcome, | ||||
|         string? reason, | ||||
|         string? clientId, | ||||
|         string? providerName, | ||||
|         AuthorityUserDescriptor? user, | ||||
|         string? username, | ||||
|         IEnumerable<string>? scopes, | ||||
|         TimeSpan? retryAfter, | ||||
|         AuthorityCredentialFailureCode? failureCode, | ||||
|         IEnumerable<AuthEventProperty>? extraProperties) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(timeProvider); | ||||
|         ArgumentNullException.ThrowIfNull(transaction); | ||||
|  | ||||
|         var correlationId = EnsureCorrelationId(transaction); | ||||
|         var normalizedScopes = NormalizeScopes(scopes); | ||||
|         var subject = BuildSubject(user, username, providerName); | ||||
|         var client = BuildClient(clientId, providerName); | ||||
|         var network = BuildNetwork(metadata); | ||||
|         var properties = BuildProperties(user, retryAfter, failureCode, extraProperties); | ||||
|  | ||||
|         return new AuthEventRecord | ||||
|         { | ||||
|             EventType = "authority.password.grant", | ||||
|             OccurredAt = timeProvider.GetUtcNow(), | ||||
|             CorrelationId = correlationId, | ||||
|             Outcome = outcome, | ||||
|             Reason = Normalize(reason), | ||||
|             Subject = subject, | ||||
|             Client = client, | ||||
|             Scopes = normalizedScopes, | ||||
|             Network = network, | ||||
|             Properties = properties | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private static AuthEventSubject? BuildSubject(AuthorityUserDescriptor? user, string? username, string? providerName) | ||||
|     { | ||||
|         var attributes = user?.Attributes; | ||||
|         var normalizedUsername = Normalize(username) ?? Normalize(user?.Username); | ||||
|         var subjectId = Normalize(user?.SubjectId); | ||||
|         var displayName = Normalize(user?.DisplayName); | ||||
|         var attributeProperties = BuildSubjectAttributes(attributes); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(subjectId) && | ||||
|             string.IsNullOrWhiteSpace(normalizedUsername) && | ||||
|             string.IsNullOrWhiteSpace(displayName) && | ||||
|             attributeProperties.Count == 0 && | ||||
|             string.IsNullOrWhiteSpace(providerName)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return new AuthEventSubject | ||||
|         { | ||||
|             SubjectId = ClassifiedString.Personal(subjectId), | ||||
|             Username = ClassifiedString.Personal(normalizedUsername), | ||||
|             DisplayName = ClassifiedString.Personal(displayName), | ||||
|             Realm = ClassifiedString.Public(Normalize(providerName)), | ||||
|             Attributes = attributeProperties | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<AuthEventProperty> BuildSubjectAttributes(IReadOnlyDictionary<string, string?>? attributes) | ||||
|     { | ||||
|         if (attributes is null || attributes.Count == 0) | ||||
|         { | ||||
|             return Array.Empty<AuthEventProperty>(); | ||||
|         } | ||||
|  | ||||
|         var items = new List<AuthEventProperty>(attributes.Count); | ||||
|         foreach (var pair in attributes) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(pair.Key)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             items.Add(new AuthEventProperty | ||||
|             { | ||||
|                 Name = pair.Key, | ||||
|                 Value = ClassifiedString.Personal(Normalize(pair.Value)) | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         return items.Count == 0 ? Array.Empty<AuthEventProperty>() : items; | ||||
|     } | ||||
|  | ||||
|     private static AuthEventClient? BuildClient(string? clientId, string? providerName) | ||||
|     { | ||||
|         var normalizedClientId = Normalize(clientId); | ||||
|         var provider = Normalize(providerName); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(normalizedClientId) && string.IsNullOrWhiteSpace(provider)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return new AuthEventClient | ||||
|         { | ||||
|             ClientId = ClassifiedString.Personal(normalizedClientId), | ||||
|             Name = ClassifiedString.Empty, | ||||
|             Provider = ClassifiedString.Public(provider) | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private static AuthEventNetwork? BuildNetwork(AuthorityRateLimiterMetadata? metadata) | ||||
|     { | ||||
|         var remote = Normalize(metadata?.RemoteIp); | ||||
|         var forwarded = Normalize(metadata?.ForwardedFor); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(remote) && string.IsNullOrWhiteSpace(forwarded)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return new AuthEventNetwork | ||||
|         { | ||||
|             RemoteAddress = ClassifiedString.Personal(remote), | ||||
|             ForwardedFor = ClassifiedString.Personal(forwarded) | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<AuthEventProperty> BuildProperties( | ||||
|         AuthorityUserDescriptor? user, | ||||
|         TimeSpan? retryAfter, | ||||
|         AuthorityCredentialFailureCode? failureCode, | ||||
|         IEnumerable<AuthEventProperty>? extraProperties) | ||||
|     { | ||||
|         var properties = new List<AuthEventProperty>(); | ||||
|  | ||||
|         if (failureCode is { } code) | ||||
|         { | ||||
|             properties.Add(new AuthEventProperty | ||||
|             { | ||||
|                 Name = "failure.code", | ||||
|                 Value = ClassifiedString.Public(code.ToString()) | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         if (retryAfter is { } retry && retry > TimeSpan.Zero) | ||||
|         { | ||||
|             var seconds = Math.Ceiling(retry.TotalSeconds).ToString(CultureInfo.InvariantCulture); | ||||
|             properties.Add(new AuthEventProperty | ||||
|             { | ||||
|                 Name = "policy.retry_after_seconds", | ||||
|                 Value = ClassifiedString.Public(seconds) | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         if (user is not null) | ||||
|         { | ||||
|             properties.Add(new AuthEventProperty | ||||
|             { | ||||
|                 Name = "subject.requires_password_reset", | ||||
|                 Value = ClassifiedString.Public(user.RequiresPasswordReset ? "true" : "false") | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         if (extraProperties is not null) | ||||
|         { | ||||
|             foreach (var property in extraProperties) | ||||
|             { | ||||
|                 if (property is null || string.IsNullOrWhiteSpace(property.Name)) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 properties.Add(property); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return properties.Count == 0 ? Array.Empty<AuthEventProperty>() : properties; | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<string> NormalizeScopes(IEnumerable<string>? scopes) | ||||
|     { | ||||
|         if (scopes is null) | ||||
|         { | ||||
|             return Array.Empty<string>(); | ||||
|         } | ||||
|  | ||||
|         var normalized = scopes | ||||
|             .Where(static scope => !string.IsNullOrWhiteSpace(scope)) | ||||
|             .Select(static scope => scope.Trim()) | ||||
|             .Where(static scope => scope.Length > 0) | ||||
|             .Distinct(StringComparer.Ordinal) | ||||
|             .OrderBy(static scope => scope, StringComparer.Ordinal) | ||||
|             .ToArray(); | ||||
|  | ||||
|         return normalized.Length == 0 ? Array.Empty<string>() : normalized; | ||||
|     } | ||||
|  | ||||
|     private static string? Normalize(string? value) | ||||
|         => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,124 @@ | ||||
| using System; | ||||
| using System.Diagnostics; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using OpenIddict.Abstractions; | ||||
| using OpenIddict.Server; | ||||
| using StellaOps.Authority.Storage.Mongo.Stores; | ||||
|  | ||||
| namespace StellaOps.Authority.OpenIddict.Handlers; | ||||
|  | ||||
| internal sealed class HandleRevocationRequestHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleRevocationRequestContext> | ||||
| { | ||||
|     private readonly IAuthorityTokenStore tokenStore; | ||||
|     private readonly TimeProvider clock; | ||||
|     private readonly ILogger<HandleRevocationRequestHandler> logger; | ||||
|     private readonly ActivitySource activitySource; | ||||
|  | ||||
|     public HandleRevocationRequestHandler( | ||||
|         IAuthorityTokenStore tokenStore, | ||||
|         TimeProvider clock, | ||||
|         ActivitySource activitySource, | ||||
|         ILogger<HandleRevocationRequestHandler> logger) | ||||
|     { | ||||
|         this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore)); | ||||
|         this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); | ||||
|         this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask HandleAsync(OpenIddictServerEvents.HandleRevocationRequestContext context) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(context); | ||||
|  | ||||
|         using var activity = activitySource.StartActivity("authority.token.revoke", ActivityKind.Internal); | ||||
|  | ||||
|         var request = context.Request; | ||||
|         if (request is null || string.IsNullOrWhiteSpace(request.Token)) | ||||
|         { | ||||
|             context.Reject(OpenIddictConstants.Errors.InvalidRequest, "The revocation request is missing the token parameter."); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var token = request.Token.Trim(); | ||||
|         var document = await tokenStore.FindByTokenIdAsync(token, context.CancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         if (document is null) | ||||
|         { | ||||
|             var tokenId = TryExtractTokenId(token); | ||||
|             if (!string.IsNullOrWhiteSpace(tokenId)) | ||||
|             { | ||||
|                 document = await tokenStore.FindByTokenIdAsync(tokenId!, context.CancellationToken).ConfigureAwait(false); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (document is null) | ||||
|         { | ||||
|             logger.LogDebug("Revocation request for unknown token ignored."); | ||||
|             context.HandleRequest(); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (!string.Equals(document.Status, "revoked", StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             await tokenStore.UpdateStatusAsync( | ||||
|                 document.TokenId, | ||||
|                 "revoked", | ||||
|                 clock.GetUtcNow(), | ||||
|                 "client_request", | ||||
|                 null, | ||||
|                 null, | ||||
|                 context.CancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             logger.LogInformation("Token {TokenId} revoked via revocation endpoint.", document.TokenId); | ||||
|             activity?.SetTag("authority.token_id", document.TokenId); | ||||
|         } | ||||
|  | ||||
|         context.HandleRequest(); | ||||
|     } | ||||
|  | ||||
|     private static string? TryExtractTokenId(string token) | ||||
|     { | ||||
|         var parts = token.Split('.'); | ||||
|         if (parts.Length < 2) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var payload = Base64UrlDecode(parts[1]); | ||||
|             using var document = JsonDocument.Parse(payload); | ||||
|             if (document.RootElement.TryGetProperty("jti", out var jti) && jti.ValueKind == JsonValueKind.String) | ||||
|             { | ||||
|                 var value = jti.GetString(); | ||||
|                 return string.IsNullOrWhiteSpace(value) ? null : value; | ||||
|             } | ||||
|         } | ||||
|         catch (JsonException) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|         catch (FormatException) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private static byte[] Base64UrlDecode(string value) | ||||
|     { | ||||
|         var padded = value.Length % 4 switch | ||||
|         { | ||||
|             2 => value + "==", | ||||
|             3 => value + "=", | ||||
|             _ => value | ||||
|         }; | ||||
|  | ||||
|         padded = padded.Replace('-', '+').Replace('_', '/'); | ||||
|         return Convert.FromBase64String(padded); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,135 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Diagnostics; | ||||
| using System.Globalization; | ||||
| using System.Linq; | ||||
| using System.Security.Claims; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using OpenIddict.Abstractions; | ||||
| using OpenIddict.Extensions; | ||||
| using OpenIddict.Server; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
| using StellaOps.Authority.Storage.Mongo.Stores; | ||||
|  | ||||
| namespace StellaOps.Authority.OpenIddict.Handlers; | ||||
|  | ||||
| internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ProcessSignInContext> | ||||
| { | ||||
|     private readonly IAuthorityTokenStore tokenStore; | ||||
|     private readonly TimeProvider clock; | ||||
|     private readonly ActivitySource activitySource; | ||||
|     private readonly ILogger<PersistTokensHandler> logger; | ||||
|  | ||||
|     public PersistTokensHandler( | ||||
|         IAuthorityTokenStore tokenStore, | ||||
|         TimeProvider clock, | ||||
|         ActivitySource activitySource, | ||||
|         ILogger<PersistTokensHandler> logger) | ||||
|     { | ||||
|         this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore)); | ||||
|         this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); | ||||
|         this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask HandleAsync(OpenIddictServerEvents.ProcessSignInContext context) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(context); | ||||
|  | ||||
|         if (context.AccessTokenPrincipal is null && | ||||
|             context.RefreshTokenPrincipal is null && | ||||
|             context.AuthorizationCodePrincipal is null && | ||||
|             context.DeviceCodePrincipal is null) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         using var activity = activitySource.StartActivity("authority.token.persist", ActivityKind.Internal); | ||||
|         var issuedAt = clock.GetUtcNow(); | ||||
|  | ||||
|         if (context.AccessTokenPrincipal is ClaimsPrincipal accessPrincipal) | ||||
|         { | ||||
|             await PersistAsync(accessPrincipal, OpenIddictConstants.TokenTypeHints.AccessToken, issuedAt, context.CancellationToken).ConfigureAwait(false); | ||||
|         } | ||||
|  | ||||
|         if (context.RefreshTokenPrincipal is ClaimsPrincipal refreshPrincipal) | ||||
|         { | ||||
|             await PersistAsync(refreshPrincipal, OpenIddictConstants.TokenTypeHints.RefreshToken, issuedAt, context.CancellationToken).ConfigureAwait(false); | ||||
|         } | ||||
|  | ||||
|         if (context.AuthorizationCodePrincipal is ClaimsPrincipal authorizationPrincipal) | ||||
|         { | ||||
|             await PersistAsync(authorizationPrincipal, OpenIddictConstants.TokenTypeHints.AuthorizationCode, issuedAt, context.CancellationToken).ConfigureAwait(false); | ||||
|         } | ||||
|  | ||||
|         if (context.DeviceCodePrincipal is ClaimsPrincipal devicePrincipal) | ||||
|         { | ||||
|             await PersistAsync(devicePrincipal, OpenIddictConstants.TokenTypeHints.DeviceCode, issuedAt, context.CancellationToken).ConfigureAwait(false); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async ValueTask PersistAsync(ClaimsPrincipal principal, string tokenType, DateTimeOffset issuedAt, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var tokenId = EnsureTokenId(principal); | ||||
|         var scopes = ExtractScopes(principal); | ||||
|         var document = new AuthorityTokenDocument | ||||
|         { | ||||
|             TokenId = tokenId, | ||||
|             Type = tokenType, | ||||
|             SubjectId = principal.GetClaim(OpenIddictConstants.Claims.Subject), | ||||
|             ClientId = principal.GetClaim(OpenIddictConstants.Claims.ClientId), | ||||
|             Scope = scopes, | ||||
|             Status = "valid", | ||||
|             CreatedAt = issuedAt, | ||||
|             ExpiresAt = TryGetExpiration(principal) | ||||
|         }; | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             await tokenStore.InsertAsync(document, cancellationToken).ConfigureAwait(false); | ||||
|             logger.LogDebug("Persisted {Type} token {TokenId} for client {ClientId}.", tokenType, tokenId, document.ClientId ?? "<none>"); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger.LogWarning(ex, "Failed to persist {Type} token {TokenId}.", tokenType, tokenId); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static string EnsureTokenId(ClaimsPrincipal principal) | ||||
|     { | ||||
|         var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId); | ||||
|         if (string.IsNullOrWhiteSpace(tokenId)) | ||||
|         { | ||||
|             tokenId = Guid.NewGuid().ToString("N"); | ||||
|             principal.SetClaim(OpenIddictConstants.Claims.JwtId, tokenId); | ||||
|         } | ||||
|  | ||||
|         return tokenId; | ||||
|     } | ||||
|  | ||||
|     private static List<string> ExtractScopes(ClaimsPrincipal principal) | ||||
|         => principal.GetScopes() | ||||
|             .Where(scope => !string.IsNullOrWhiteSpace(scope)) | ||||
|             .Select(scope => scope.Trim()) | ||||
|             .Distinct(StringComparer.Ordinal) | ||||
|             .OrderBy(scope => scope, StringComparer.Ordinal) | ||||
|             .ToList(); | ||||
|  | ||||
|     private static DateTimeOffset? TryGetExpiration(ClaimsPrincipal principal) | ||||
|     { | ||||
|         var value = principal.GetClaim(OpenIddictConstants.Claims.Exp); | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)) | ||||
|         { | ||||
|             return DateTimeOffset.FromUnixTimeSeconds(seconds); | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
| } | ||||
| @@ -1,9 +1,13 @@ | ||||
| using System; | ||||
| using System.Diagnostics; | ||||
| using System.Globalization; | ||||
| using Microsoft.AspNetCore.Builder; | ||||
| using Microsoft.AspNetCore.Http; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Configuration; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.DependencyInjection.Extensions; | ||||
| using Microsoft.Extensions.Hosting; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using Microsoft.AspNetCore.RateLimiting; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| @@ -14,16 +18,23 @@ using MongoDB.Driver; | ||||
| using Serilog; | ||||
| using Serilog.Events; | ||||
| using StellaOps.Authority; | ||||
| using StellaOps.Authority.Audit; | ||||
| using StellaOps.Authority.Plugins.Abstractions; | ||||
| using StellaOps.Authority.Plugins; | ||||
| using StellaOps.Authority.Bootstrap; | ||||
| using StellaOps.Authority.Storage.Mongo.Extensions; | ||||
| using StellaOps.Authority.Storage.Mongo.Initialization; | ||||
| using StellaOps.Authority.RateLimiting; | ||||
| using StellaOps.Configuration; | ||||
| using StellaOps.Plugin.DependencyInjection; | ||||
| using StellaOps.Plugin.Hosting; | ||||
| using StellaOps.Authority.OpenIddict.Handlers; | ||||
| using System.Linq; | ||||
| using StellaOps.Cryptography.Audit; | ||||
| using StellaOps.Cryptography.DependencyInjection; | ||||
| using StellaOps.Authority.Revocation; | ||||
| using StellaOps.Authority.Signing; | ||||
| using StellaOps.Cryptography; | ||||
|  | ||||
| var builder = WebApplication.CreateBuilder(args); | ||||
|  | ||||
| @@ -68,12 +79,20 @@ var authorityOptions = authorityConfiguration.Options; | ||||
| var issuer = authorityOptions.Issuer ?? throw new InvalidOperationException("Authority issuer configuration is required."); | ||||
| builder.Services.AddSingleton(authorityOptions); | ||||
| builder.Services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(authorityOptions)); | ||||
| builder.Services.AddHttpContextAccessor(); | ||||
| builder.Services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System); | ||||
| builder.Services.TryAddSingleton<IAuthorityRateLimiterMetadataAccessor, AuthorityRateLimiterMetadataAccessor>(); | ||||
| builder.Services.TryAddSingleton<IAuthorityRateLimiterPartitionKeyResolver, DefaultAuthorityRateLimiterPartitionKeyResolver>(); | ||||
|  | ||||
| builder.Services.AddRateLimiter(rateLimiterOptions => | ||||
| { | ||||
|     AuthorityRateLimiter.Configure(rateLimiterOptions, authorityOptions); | ||||
| }); | ||||
|  | ||||
| builder.Services.AddStellaOpsCrypto(); | ||||
| builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthoritySigningKeySource, FileAuthoritySigningKeySource>()); | ||||
| builder.Services.AddSingleton<AuthoritySigningKeyManager>(); | ||||
|  | ||||
| AuthorityPluginContext[] pluginContexts = AuthorityPluginConfigurationLoader | ||||
|     .Load(authorityOptions, builder.Environment.ContentRootPath) | ||||
|     .ToArray(); | ||||
| @@ -93,11 +112,18 @@ builder.Services.AddAuthorityMongoStorage(storageOptions => | ||||
| }); | ||||
|  | ||||
| builder.Services.AddSingleton<IAuthorityIdentityProviderRegistry, AuthorityIdentityProviderRegistry>(); | ||||
| builder.Services.AddSingleton<IAuthEventSink, AuthorityAuditSink>(); | ||||
| builder.Services.AddScoped<ValidatePasswordGrantHandler>(); | ||||
| builder.Services.AddScoped<HandlePasswordGrantHandler>(); | ||||
| builder.Services.AddScoped<ValidateClientCredentialsHandler>(); | ||||
| builder.Services.AddScoped<HandleClientCredentialsHandler>(); | ||||
| builder.Services.AddScoped<ValidateAccessTokenHandler>(); | ||||
| builder.Services.AddScoped<PersistTokensHandler>(); | ||||
| builder.Services.AddScoped<HandleRevocationRequestHandler>(); | ||||
| builder.Services.AddSingleton<RevocationBundleBuilder>(); | ||||
| builder.Services.AddSingleton<RevocationBundleSigner>(); | ||||
| builder.Services.AddSingleton<AuthorityRevocationExportService>(); | ||||
| builder.Services.AddSingleton<AuthorityJwksService>(); | ||||
|  | ||||
| var pluginRegistrationSummary = AuthorityPluginLoader.RegisterPlugins( | ||||
|     builder.Services, | ||||
| @@ -179,6 +205,16 @@ builder.Services.AddOpenIddict() | ||||
|         { | ||||
|             descriptor.UseScopedHandler<ValidateAccessTokenHandler>(); | ||||
|         }); | ||||
|  | ||||
|         options.AddEventHandler<OpenIddictServerEvents.ProcessSignInContext>(descriptor => | ||||
|         { | ||||
|             descriptor.UseScopedHandler<PersistTokensHandler>(); | ||||
|         }); | ||||
|  | ||||
|         options.AddEventHandler<OpenIddictServerEvents.HandleRevocationRequestContext>(descriptor => | ||||
|         { | ||||
|             descriptor.UseScopedHandler<HandleRevocationRequestHandler>(); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
| builder.Services.Configure<OpenIddictServerOptions>(options => | ||||
| @@ -242,12 +278,16 @@ if (authorityOptions.Bootstrap.Enabled) | ||||
|     bootstrapGroup.AddEndpointFilter(new BootstrapApiKeyFilter(authorityOptions)); | ||||
|  | ||||
|     bootstrapGroup.MapPost("/users", async ( | ||||
|         HttpContext httpContext, | ||||
|         BootstrapUserRequest request, | ||||
|         IAuthorityIdentityProviderRegistry registry, | ||||
|         IAuthEventSink auditSink, | ||||
|         TimeProvider timeProvider, | ||||
|         CancellationToken cancellationToken) => | ||||
|     { | ||||
|         if (request is null) | ||||
|         { | ||||
|             await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Request payload is required.", null, null, null, Array.Empty<string>()).ConfigureAwait(false); | ||||
|             return Results.BadRequest(new { error = "invalid_request", message = "Request payload is required." }); | ||||
|         } | ||||
|  | ||||
| @@ -257,16 +297,19 @@ if (authorityOptions.Bootstrap.Enabled) | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(providerName) || !registry.TryGet(providerName!, out var provider)) | ||||
|         { | ||||
|             await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Specified identity provider was not found.", null, request.Username, providerName, request.Roles ?? Array.Empty<string>()).ConfigureAwait(false); | ||||
|             return Results.BadRequest(new { error = "invalid_provider", message = "Specified identity provider was not found." }); | ||||
|         } | ||||
|  | ||||
|         if (!provider.Capabilities.SupportsPassword) | ||||
|         { | ||||
|             await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Selected provider does not support password provisioning.", null, request.Username, provider.Name, request.Roles ?? Array.Empty<string>()).ConfigureAwait(false); | ||||
|             return Results.BadRequest(new { error = "unsupported_provider", message = "Selected provider does not support password provisioning." }); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrEmpty(request.Password)) | ||||
|         { | ||||
|             await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, "Username and password are required.", null, request.Username, provider.Name, request.Roles ?? Array.Empty<string>()).ConfigureAwait(false); | ||||
|             return Results.BadRequest(new { error = "invalid_request", message = "Username and password are required." }); | ||||
|         } | ||||
|  | ||||
| @@ -288,24 +331,88 @@ if (authorityOptions.Bootstrap.Enabled) | ||||
|  | ||||
|         if (!result.Succeeded || result.Value is null) | ||||
|         { | ||||
|             await WriteBootstrapUserAuditAsync(AuthEventOutcome.Failure, result.Message ?? "User provisioning failed.", null, request.Username, provider.Name, roles).ConfigureAwait(false); | ||||
|             return Results.BadRequest(new { error = result.ErrorCode ?? "bootstrap_failed", message = result.Message ?? "User provisioning failed." }); | ||||
|         } | ||||
|  | ||||
|         await WriteBootstrapUserAuditAsync(AuthEventOutcome.Success, null, result.Value.SubjectId, result.Value.Username, provider.Name, roles).ConfigureAwait(false); | ||||
|  | ||||
|         return Results.Ok(new | ||||
|         { | ||||
|             provider = provider.Name, | ||||
|             subjectId = result.Value.SubjectId, | ||||
|             username = result.Value.Username | ||||
|         }); | ||||
|  | ||||
|         async Task WriteBootstrapUserAuditAsync(AuthEventOutcome outcome, string? reason, string? subjectId, string? usernameValue, string? providerValue, IReadOnlyCollection<string> rolesValue) | ||||
|         { | ||||
|             var correlationId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); | ||||
|             AuthEventNetwork? network = null; | ||||
|             var remoteAddress = httpContext.Connection.RemoteIpAddress?.ToString(); | ||||
|             var userAgent = httpContext.Request.Headers.UserAgent.ToString(); | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(remoteAddress) || !string.IsNullOrWhiteSpace(userAgent)) | ||||
|             { | ||||
|                 network = new AuthEventNetwork | ||||
|                 { | ||||
|                     RemoteAddress = ClassifiedString.Personal(remoteAddress), | ||||
|                     UserAgent = ClassifiedString.Personal(string.IsNullOrWhiteSpace(userAgent) ? null : userAgent) | ||||
|                 }; | ||||
|             } | ||||
|  | ||||
|             var subject = subjectId is null && string.IsNullOrWhiteSpace(usernameValue) && string.IsNullOrWhiteSpace(providerValue) | ||||
|                 ? null | ||||
|                 : new AuthEventSubject | ||||
|                 { | ||||
|                     SubjectId = ClassifiedString.Personal(subjectId), | ||||
|                     Username = ClassifiedString.Personal(usernameValue), | ||||
|                     Realm = ClassifiedString.Public(providerValue) | ||||
|                 }; | ||||
|  | ||||
|             var properties = string.IsNullOrWhiteSpace(providerValue) | ||||
|                 ? Array.Empty<AuthEventProperty>() | ||||
|                 : new[] | ||||
|                 { | ||||
|                     new AuthEventProperty | ||||
|                     { | ||||
|                         Name = "bootstrap.provider", | ||||
|                         Value = ClassifiedString.Public(providerValue) | ||||
|                     } | ||||
|                 }; | ||||
|  | ||||
|             var scopes = rolesValue is { Count: > 0 } | ||||
|                 ? rolesValue.ToArray() | ||||
|                 : Array.Empty<string>(); | ||||
|  | ||||
|             var record = new AuthEventRecord | ||||
|             { | ||||
|                 EventType = "authority.bootstrap.user", | ||||
|                 OccurredAt = timeProvider.GetUtcNow(), | ||||
|                 CorrelationId = correlationId, | ||||
|                 Outcome = outcome, | ||||
|                 Reason = reason, | ||||
|                 Subject = subject, | ||||
|                 Client = null, | ||||
|                 Scopes = scopes, | ||||
|                 Network = network, | ||||
|                 Properties = properties | ||||
|             }; | ||||
|  | ||||
|             await auditSink.WriteAsync(record, httpContext.RequestAborted).ConfigureAwait(false); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     bootstrapGroup.MapPost("/clients", async ( | ||||
|         HttpContext httpContext, | ||||
|         BootstrapClientRequest request, | ||||
|         IAuthorityIdentityProviderRegistry registry, | ||||
|         IAuthEventSink auditSink, | ||||
|         TimeProvider timeProvider, | ||||
|         CancellationToken cancellationToken) => | ||||
|     { | ||||
|         if (request is null) | ||||
|         { | ||||
|             await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Request payload is required.", null, null, null, Array.Empty<string>(), null).ConfigureAwait(false); | ||||
|             return Results.BadRequest(new { error = "invalid_request", message = "Request payload is required." }); | ||||
|         } | ||||
|  | ||||
| @@ -315,31 +422,37 @@ if (authorityOptions.Bootstrap.Enabled) | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(providerName) || !registry.TryGet(providerName!, out var provider)) | ||||
|         { | ||||
|             await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Specified identity provider was not found.", request.ClientId, null, providerName, request.AllowedScopes ?? Array.Empty<string>(), request?.Confidential).ConfigureAwait(false); | ||||
|             return Results.BadRequest(new { error = "invalid_provider", message = "Specified identity provider was not found." }); | ||||
|         } | ||||
|  | ||||
|         if (!provider.Capabilities.SupportsClientProvisioning || provider.ClientProvisioning is null) | ||||
|         { | ||||
|             await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Selected provider does not support client provisioning.", request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential).ConfigureAwait(false); | ||||
|             return Results.BadRequest(new { error = "unsupported_provider", message = "Selected provider does not support client provisioning." }); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(request.ClientId)) | ||||
|         { | ||||
|             await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "ClientId is required.", null, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential).ConfigureAwait(false); | ||||
|             return Results.BadRequest(new { error = "invalid_request", message = "ClientId is required." }); | ||||
|         } | ||||
|  | ||||
|         if (request.Confidential && string.IsNullOrWhiteSpace(request.ClientSecret)) | ||||
|         { | ||||
|             await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, "Confidential clients require a client secret.", request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential).ConfigureAwait(false); | ||||
|             return Results.BadRequest(new { error = "invalid_request", message = "Confidential clients require a client secret." }); | ||||
|         } | ||||
|  | ||||
|         if (!TryParseUris(request.RedirectUris, out var redirectUris, out var redirectError)) | ||||
|         { | ||||
|             await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, redirectError, request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential).ConfigureAwait(false); | ||||
|             return Results.BadRequest(new { error = "invalid_request", message = redirectError }); | ||||
|         } | ||||
|  | ||||
|         if (!TryParseUris(request.PostLogoutRedirectUris, out var postLogoutUris, out var postLogoutError)) | ||||
|         { | ||||
|             await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, postLogoutError, request.ClientId, null, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential).ConfigureAwait(false); | ||||
|             return Results.BadRequest(new { error = "invalid_request", message = postLogoutError }); | ||||
|         } | ||||
|  | ||||
| @@ -362,15 +475,151 @@ if (authorityOptions.Bootstrap.Enabled) | ||||
|  | ||||
|         if (!result.Succeeded || result.Value is null) | ||||
|         { | ||||
|             await WriteBootstrapClientAuditAsync(AuthEventOutcome.Failure, result.Message ?? "Client provisioning failed.", request.ClientId, result.Value?.ClientId, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential).ConfigureAwait(false); | ||||
|             return Results.BadRequest(new { error = result.ErrorCode ?? "bootstrap_failed", message = result.Message ?? "Client provisioning failed." }); | ||||
|         } | ||||
|  | ||||
|         await WriteBootstrapClientAuditAsync(AuthEventOutcome.Success, null, request.ClientId, result.Value.ClientId, provider.Name, request.AllowedScopes ?? Array.Empty<string>(), request.Confidential).ConfigureAwait(false); | ||||
|  | ||||
|         return Results.Ok(new | ||||
|         { | ||||
|             provider = provider.Name, | ||||
|             clientId = result.Value.ClientId, | ||||
|             confidential = result.Value.Confidential | ||||
|         }); | ||||
|  | ||||
|         async Task WriteBootstrapClientAuditAsync(AuthEventOutcome outcome, string? reason, string? requestedClientId, string? assignedClientId, string? providerValue, IReadOnlyCollection<string> scopes, bool? confidentialFlag) | ||||
|         { | ||||
|             var correlationId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); | ||||
|             AuthEventNetwork? network = null; | ||||
|             var remoteAddress = httpContext.Connection.RemoteIpAddress?.ToString(); | ||||
|             var userAgent = httpContext.Request.Headers.UserAgent.ToString(); | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(remoteAddress) || !string.IsNullOrWhiteSpace(userAgent)) | ||||
|             { | ||||
|                 network = new AuthEventNetwork | ||||
|                 { | ||||
|                     RemoteAddress = ClassifiedString.Personal(remoteAddress), | ||||
|                     UserAgent = ClassifiedString.Personal(string.IsNullOrWhiteSpace(userAgent) ? null : userAgent) | ||||
|                 }; | ||||
|             } | ||||
|  | ||||
|             var clientIdValue = assignedClientId ?? requestedClientId; | ||||
|             var client = clientIdValue is null && string.IsNullOrWhiteSpace(providerValue) | ||||
|                 ? null | ||||
|                 : new AuthEventClient | ||||
|                 { | ||||
|                     ClientId = ClassifiedString.Personal(clientIdValue), | ||||
|                     Name = ClassifiedString.Empty, | ||||
|                     Provider = ClassifiedString.Public(providerValue) | ||||
|                 }; | ||||
|  | ||||
|             var properties = new List<AuthEventProperty>(); | ||||
|             if (!string.IsNullOrWhiteSpace(requestedClientId) && !string.Equals(requestedClientId, assignedClientId, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 properties.Add(new AuthEventProperty | ||||
|                 { | ||||
|                     Name = "bootstrap.requested_client_id", | ||||
|                     Value = ClassifiedString.Public(requestedClientId) | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             if (confidentialFlag == true) | ||||
|             { | ||||
|                 properties.Add(new AuthEventProperty | ||||
|                 { | ||||
|                     Name = "bootstrap.confidential", | ||||
|                     Value = ClassifiedString.Public("true") | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             var record = new AuthEventRecord | ||||
|             { | ||||
|                 EventType = "authority.bootstrap.client", | ||||
|                 OccurredAt = timeProvider.GetUtcNow(), | ||||
|                 CorrelationId = correlationId, | ||||
|                 Outcome = outcome, | ||||
|                 Reason = reason, | ||||
|                 Subject = null, | ||||
|                 Client = client, | ||||
|                 Scopes = scopes is { Count: > 0 } ? scopes.ToArray() : Array.Empty<string>(), | ||||
|                 Network = network, | ||||
|                 Properties = properties.Count == 0 ? Array.Empty<AuthEventProperty>() : properties.ToArray() | ||||
|             }; | ||||
|  | ||||
|             await auditSink.WriteAsync(record, httpContext.RequestAborted).ConfigureAwait(false); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     bootstrapGroup.MapGet("/revocations/export", async ( | ||||
|         AuthorityRevocationExportService exportService, | ||||
|         CancellationToken cancellationToken) => | ||||
|     { | ||||
|         var package = await exportService.ExportAsync(cancellationToken).ConfigureAwait(false); | ||||
|         var build = package.Bundle; | ||||
|  | ||||
|         var response = new RevocationExportResponse | ||||
|         { | ||||
|             SchemaVersion = build.Bundle.SchemaVersion, | ||||
|             BundleId = build.Bundle.BundleId ?? build.Sha256, | ||||
|             Sequence = build.Sequence, | ||||
|             IssuedAt = build.IssuedAt, | ||||
|             SigningKeyId = package.Signature.KeyId, | ||||
|             Bundle = new RevocationExportPayload | ||||
|             { | ||||
|                 Data = Convert.ToBase64String(build.CanonicalJson) | ||||
|             }, | ||||
|             Signature = new RevocationExportSignature | ||||
|             { | ||||
|                 Algorithm = package.Signature.Algorithm, | ||||
|                 KeyId = package.Signature.KeyId, | ||||
|                 Value = package.Signature.Value | ||||
|             }, | ||||
|             Digest = new RevocationExportDigest | ||||
|             { | ||||
|                 Value = build.Sha256 | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         return Results.Ok(response); | ||||
|     }); | ||||
|  | ||||
|     bootstrapGroup.MapPost("/signing/rotate", ( | ||||
|         SigningRotationRequest? request, | ||||
|         AuthoritySigningKeyManager signingManager, | ||||
|         ILogger<AuthoritySigningKeyManager> signingLogger) => | ||||
|     { | ||||
|         if (request is null) | ||||
|         { | ||||
|             signingLogger.LogWarning("Signing rotation request payload missing."); | ||||
|             return Results.BadRequest(new { error = "invalid_request", message = "Request payload is required." }); | ||||
|         } | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var result = signingManager.Rotate(request); | ||||
|             signingLogger.LogInformation("Signing key rotation completed. Active key {KeyId}.", result.ActiveKeyId); | ||||
|  | ||||
|             return Results.Ok(new | ||||
|             { | ||||
|                 activeKeyId = result.ActiveKeyId, | ||||
|                 provider = result.ActiveProvider, | ||||
|                 source = result.ActiveSource, | ||||
|                 location = result.ActiveLocation, | ||||
|                 previousKeyId = result.PreviousKeyId, | ||||
|                 retiredKeyIds = result.RetiredKeyIds | ||||
|             }); | ||||
|         } | ||||
|         catch (InvalidOperationException ex) | ||||
|         { | ||||
|             signingLogger.LogWarning(ex, "Signing rotation failed due to invalid input."); | ||||
|             return Results.BadRequest(new { error = "rotation_failed", message = ex.Message }); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             signingLogger.LogError(ex, "Unexpected failure rotating signing key."); | ||||
|             return Results.Problem("Failed to rotate signing key."); | ||||
|         } | ||||
|     }); | ||||
| } | ||||
|  | ||||
| @@ -398,6 +647,7 @@ app.UseExceptionHandler(static errorApp => | ||||
| }); | ||||
|  | ||||
| app.UseRouting(); | ||||
| app.UseAuthorityRateLimiterContext(); | ||||
| app.UseRateLimiter(); | ||||
| app.UseAuthentication(); | ||||
| app.UseAuthorization(); | ||||
| @@ -432,6 +682,12 @@ app.MapGet("/ready", (IAuthorityIdentityProviderRegistry registry) => | ||||
|     })) | ||||
|     .WithName("ReadinessCheck"); | ||||
|  | ||||
| app.MapGet("/jwks", (AuthorityJwksService jwksService) => Results.Ok(jwksService.Build())) | ||||
|     .WithName("JsonWebKeySet"); | ||||
|  | ||||
| // Ensure signing key manager initialises key material on startup. | ||||
| app.Services.GetRequiredService<AuthoritySigningKeyManager>(); | ||||
|  | ||||
| app.Run(); | ||||
|  | ||||
| static PluginHostOptions BuildPluginHostOptions(StellaOpsAuthorityOptions options, string basePath) | ||||
|   | ||||
| @@ -0,0 +1,37 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
|  | ||||
| namespace StellaOps.Authority.Revocation; | ||||
|  | ||||
| internal sealed class AuthorityRevocationExportService | ||||
| { | ||||
|     private readonly RevocationBundleBuilder bundleBuilder; | ||||
|     private readonly RevocationBundleSigner signer; | ||||
|     private readonly ILogger<AuthorityRevocationExportService> logger; | ||||
|  | ||||
|     public AuthorityRevocationExportService( | ||||
|         RevocationBundleBuilder bundleBuilder, | ||||
|         RevocationBundleSigner signer, | ||||
|         ILogger<AuthorityRevocationExportService> logger) | ||||
|     { | ||||
|         this.bundleBuilder = bundleBuilder ?? throw new ArgumentNullException(nameof(bundleBuilder)); | ||||
|         this.signer = signer ?? throw new ArgumentNullException(nameof(signer)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async Task<RevocationExportPackage> ExportAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         var buildResult = await bundleBuilder.BuildAsync(cancellationToken).ConfigureAwait(false); | ||||
|         var signature = await signer.SignAsync(buildResult.CanonicalJson, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         logger.LogInformation( | ||||
|             "Generated revocation bundle sequence {Sequence} with {EntryCount} entries (sha256:{Hash}).", | ||||
|             buildResult.Sequence, | ||||
|             buildResult.Bundle.Revocations.Count, | ||||
|             buildResult.Sha256); | ||||
|  | ||||
|         return new RevocationExportPackage(buildResult, signature); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,10 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Authority.Revocation; | ||||
|  | ||||
| internal sealed record RevocationBundleBuildResult( | ||||
|     RevocationBundleModel Bundle, | ||||
|     byte[] CanonicalJson, | ||||
|     string Sha256, | ||||
|     long Sequence, | ||||
|     DateTimeOffset IssuedAt); | ||||
| @@ -0,0 +1,220 @@ | ||||
| using System; | ||||
| using System.Buffers; | ||||
| using System.Collections.Generic; | ||||
| using System.Globalization; | ||||
| using System.Linq; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
| using StellaOps.Authority.Storage.Mongo.Stores; | ||||
| using StellaOps.Configuration; | ||||
|  | ||||
| namespace StellaOps.Authority.Revocation; | ||||
|  | ||||
| internal sealed class RevocationBundleBuilder | ||||
| { | ||||
|     private const string SchemaVersion = "1.0.0"; | ||||
|     private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General) | ||||
|     { | ||||
|         PropertyNamingPolicy = null, | ||||
|         DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, | ||||
|         WriteIndented = true | ||||
|     }; | ||||
|  | ||||
|     private readonly IAuthorityTokenStore tokenStore; | ||||
|     private readonly IAuthorityRevocationStore revocationStore; | ||||
|     private readonly IAuthorityRevocationExportStateStore stateStore; | ||||
|     private readonly StellaOpsAuthorityOptions authorityOptions; | ||||
|     private readonly TimeProvider clock; | ||||
|     private readonly ILogger<RevocationBundleBuilder> logger; | ||||
|  | ||||
|     public RevocationBundleBuilder( | ||||
|         IAuthorityTokenStore tokenStore, | ||||
|         IAuthorityRevocationStore revocationStore, | ||||
|         IAuthorityRevocationExportStateStore stateStore, | ||||
|         IOptions<StellaOpsAuthorityOptions> authorityOptions, | ||||
|         TimeProvider clock, | ||||
|         ILogger<RevocationBundleBuilder> logger) | ||||
|     { | ||||
|         this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore)); | ||||
|         this.revocationStore = revocationStore ?? throw new ArgumentNullException(nameof(revocationStore)); | ||||
|         this.stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore)); | ||||
|         this.authorityOptions = authorityOptions?.Value ?? throw new ArgumentNullException(nameof(authorityOptions)); | ||||
|         this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async Task<RevocationBundleBuildResult> BuildAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         var issuer = authorityOptions.Issuer?.ToString()?.TrimEnd('/') | ||||
|             ?? throw new InvalidOperationException("Authority issuer configuration is required before exporting revocations."); | ||||
|  | ||||
|         var state = await stateStore.GetAsync(cancellationToken).ConfigureAwait(false); | ||||
|         var previousSequence = state?.Sequence ?? 0; | ||||
|         var sequence = previousSequence + 1; | ||||
|         var issuedAt = clock.GetUtcNow(); | ||||
|  | ||||
|         var tokenDocuments = await tokenStore.ListRevokedAsync(null, cancellationToken).ConfigureAwait(false); | ||||
|         var manualDocuments = await revocationStore.GetActiveAsync(issuedAt, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         var entries = new List<RevocationEntryModel>(); | ||||
|         entries.AddRange(BuildTokenEntries(tokenDocuments, issuedAt)); | ||||
|         entries.AddRange(BuildManualEntries(manualDocuments)); | ||||
|  | ||||
|         entries.Sort(static (left, right) => | ||||
|         { | ||||
|             var categoryCompare = string.CompareOrdinal(left.Category, right.Category); | ||||
|             if (categoryCompare != 0) | ||||
|             { | ||||
|                 return categoryCompare; | ||||
|             } | ||||
|  | ||||
|             var idCompare = string.CompareOrdinal(left.Id, right.Id); | ||||
|             if (idCompare != 0) | ||||
|             { | ||||
|                 return idCompare; | ||||
|             } | ||||
|  | ||||
|             return DateTimeOffset.Compare(left.RevokedAt, right.RevokedAt); | ||||
|         }); | ||||
|  | ||||
|         var metadata = new SortedDictionary<string, string?>(StringComparer.OrdinalIgnoreCase) | ||||
|         { | ||||
|             ["entryCount"] = entries.Count.ToString(CultureInfo.InvariantCulture) | ||||
|         }; | ||||
|  | ||||
|         var bundle = new RevocationBundleModel | ||||
|         { | ||||
|             SchemaVersion = SchemaVersion, | ||||
|             Issuer = issuer, | ||||
|             IssuedAt = issuedAt, | ||||
|             ValidFrom = issuedAt, | ||||
|             Sequence = sequence, | ||||
|             SigningKeyId = authorityOptions.Signing?.ActiveKeyId, | ||||
|             Revocations = entries, | ||||
|             Metadata = metadata | ||||
|         }; | ||||
|  | ||||
|         var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(bundle, SerializerOptions); | ||||
|         var sha256 = Convert.ToHexString(SHA256.HashData(jsonBytes)).ToLowerInvariant(); | ||||
|         bundle.BundleId = sha256; | ||||
|  | ||||
|         await PersistStateAsync(previousSequence, sequence, sha256, issuedAt, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         return new RevocationBundleBuildResult(bundle, jsonBytes, sha256, sequence, issuedAt); | ||||
|     } | ||||
|  | ||||
|     private IEnumerable<RevocationEntryModel> BuildTokenEntries(IReadOnlyCollection<AuthorityTokenDocument> documents, DateTimeOffset issuedAt) | ||||
|     { | ||||
|         foreach (var document in documents) | ||||
|         { | ||||
|             if (!string.Equals(document.Status, "revoked", StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (document.ExpiresAt is { } expires && expires <= issuedAt) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var revocationId = document.TokenId; | ||||
|             if (string.IsNullOrWhiteSpace(revocationId)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var scopes = document.Scope.Count > 0 | ||||
|                 ? document.Scope | ||||
|                     .Where(scope => !string.IsNullOrWhiteSpace(scope)) | ||||
|                     .Select(scope => scope.Trim()) | ||||
|                     .Distinct(StringComparer.Ordinal) | ||||
|                     .OrderBy(scope => scope, StringComparer.Ordinal) | ||||
|                     .ToList() | ||||
|                 : null; | ||||
|  | ||||
|             var metadata = document.RevokedMetadata is null | ||||
|                 ? null | ||||
|                 : new SortedDictionary<string, string?>(document.RevokedMetadata, StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|             yield return new RevocationEntryModel | ||||
|             { | ||||
|                 Id = revocationId, | ||||
|                 Category = "token", | ||||
|                 TokenType = document.Type, | ||||
|                 SubjectId = Normalize(document.SubjectId), | ||||
|                 ClientId = Normalize(document.ClientId), | ||||
|                 Reason = NormalizeReason(document.RevokedReason) ?? "unspecified", | ||||
|                 ReasonDescription = Normalize(document.RevokedReasonDescription), | ||||
|                 RevokedAt = document.RevokedAt ?? document.CreatedAt, | ||||
|                 EffectiveAt = document.RevokedAt ?? document.CreatedAt, | ||||
|                 ExpiresAt = document.ExpiresAt, | ||||
|                 Scopes = scopes, | ||||
|                 Metadata = metadata | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static IEnumerable<RevocationEntryModel> BuildManualEntries(IReadOnlyCollection<AuthorityRevocationDocument> documents) | ||||
|     { | ||||
|         foreach (var document in documents) | ||||
|         { | ||||
|             var metadata = document.Metadata is null | ||||
|                 ? null | ||||
|                 : new SortedDictionary<string, string?>(document.Metadata, StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|             var scopes = document.Scopes is null | ||||
|                 ? null | ||||
|                 : document.Scopes | ||||
|                     .Where(scope => !string.IsNullOrWhiteSpace(scope)) | ||||
|                     .Select(scope => scope.Trim()) | ||||
|                     .Distinct(StringComparer.Ordinal) | ||||
|                     .OrderBy(scope => scope, StringComparer.Ordinal) | ||||
|                     .ToList(); | ||||
|  | ||||
|             yield return new RevocationEntryModel | ||||
|             { | ||||
|                 Id = document.RevocationId, | ||||
|                 Category = document.Category, | ||||
|                 TokenType = Normalize(document.TokenType), | ||||
|                 SubjectId = Normalize(document.SubjectId), | ||||
|                 ClientId = Normalize(document.ClientId), | ||||
|                 Reason = NormalizeReason(document.Reason) ?? "unspecified", | ||||
|                 ReasonDescription = Normalize(document.ReasonDescription), | ||||
|                 RevokedAt = document.RevokedAt, | ||||
|                 EffectiveAt = document.EffectiveAt ?? document.RevokedAt, | ||||
|                 ExpiresAt = document.ExpiresAt, | ||||
|                 Scopes = scopes, | ||||
|                 Fingerprint = Normalize(document.Fingerprint), | ||||
|                 Metadata = metadata | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async Task PersistStateAsync(long previousSequence, long newSequence, string bundleId, DateTimeOffset issuedAt, CancellationToken cancellationToken) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             await stateStore.UpdateAsync(previousSequence, newSequence, bundleId, issuedAt, cancellationToken).ConfigureAwait(false); | ||||
|         } | ||||
|         catch (InvalidOperationException ex) | ||||
|         { | ||||
|             logger.LogError(ex, "Failed to update revocation export state (expected sequence {Expected}, new sequence {Sequence}).", previousSequence, newSequence); | ||||
|             throw; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static string? Normalize(string? value) | ||||
|         => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); | ||||
|  | ||||
|     private static string? NormalizeReason(string? reason) | ||||
|     { | ||||
|         var normalized = Normalize(reason); | ||||
|         return normalized?.ToLowerInvariant(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,53 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Authority.Revocation; | ||||
|  | ||||
| internal sealed class RevocationBundleModel | ||||
| { | ||||
|     [JsonPropertyName("schemaVersion")] | ||||
|     [JsonPropertyOrder(1)] | ||||
|     public required string SchemaVersion { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("issuer")] | ||||
|     [JsonPropertyOrder(2)] | ||||
|     public required string Issuer { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("bundleId")] | ||||
|     [JsonPropertyOrder(3)] | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public string? BundleId { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("issuedAt")] | ||||
|     [JsonPropertyOrder(4)] | ||||
|     public required DateTimeOffset IssuedAt { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("validFrom")] | ||||
|     [JsonPropertyOrder(5)] | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public DateTimeOffset? ValidFrom { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("expiresAt")] | ||||
|     [JsonPropertyOrder(6)] | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public DateTimeOffset? ExpiresAt { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("sequence")] | ||||
|     [JsonPropertyOrder(7)] | ||||
|     public required long Sequence { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("signingKeyId")] | ||||
|     [JsonPropertyOrder(8)] | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public string? SigningKeyId { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("revocations")] | ||||
|     [JsonPropertyOrder(9)] | ||||
|     public required List<RevocationEntryModel> Revocations { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("metadata")] | ||||
|     [JsonPropertyOrder(10)] | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public SortedDictionary<string, string?>? Metadata { get; init; } | ||||
| } | ||||
| @@ -0,0 +1,3 @@ | ||||
| namespace StellaOps.Authority.Revocation; | ||||
|  | ||||
| internal sealed record RevocationBundleSignature(string Algorithm, string KeyId, string Value); | ||||
| @@ -0,0 +1,112 @@ | ||||
| using System; | ||||
| using System.Buffers; | ||||
| using System.Collections.Generic; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Configuration; | ||||
| using StellaOps.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Authority.Revocation; | ||||
|  | ||||
| internal sealed class RevocationBundleSigner | ||||
| { | ||||
|     private static readonly JsonSerializerOptions HeaderSerializerOptions = new(JsonSerializerDefaults.General) | ||||
|     { | ||||
|         PropertyNamingPolicy = null, | ||||
|         DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, | ||||
|         WriteIndented = false | ||||
|     }; | ||||
|  | ||||
|     private readonly ICryptoProviderRegistry providerRegistry; | ||||
|     private readonly StellaOpsAuthorityOptions authorityOptions; | ||||
|     private readonly ILogger<RevocationBundleSigner> logger; | ||||
|  | ||||
|     public RevocationBundleSigner( | ||||
|         ICryptoProviderRegistry providerRegistry, | ||||
|         IOptions<StellaOpsAuthorityOptions> authorityOptions, | ||||
|         ILogger<RevocationBundleSigner> logger) | ||||
|     { | ||||
|         this.providerRegistry = providerRegistry ?? throw new ArgumentNullException(nameof(providerRegistry)); | ||||
|         this.authorityOptions = authorityOptions?.Value ?? throw new ArgumentNullException(nameof(authorityOptions)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async Task<RevocationBundleSignature> SignAsync(byte[] payload, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(payload); | ||||
|  | ||||
|         var signing = authorityOptions.Signing ?? throw new InvalidOperationException("Authority signing configuration is required to export revocations."); | ||||
|         if (string.IsNullOrWhiteSpace(signing.ActiveKeyId)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Authority signing configuration requires an active key identifier."); | ||||
|         } | ||||
|  | ||||
|         var algorithm = string.IsNullOrWhiteSpace(signing.Algorithm) | ||||
|             ? SignatureAlgorithms.Es256 | ||||
|             : signing.Algorithm.Trim(); | ||||
|  | ||||
|         var keyReference = new CryptoKeyReference(signing.ActiveKeyId, signing.Provider); | ||||
|         var signer = providerRegistry.ResolveSigner(CryptoCapability.Signing, algorithm, keyReference, signing.Provider); | ||||
|  | ||||
|         var header = new Dictionary<string, object> | ||||
|         { | ||||
|             ["alg"] = algorithm, | ||||
|             ["kid"] = signing.ActiveKeyId, | ||||
|             ["typ"] = "application/vnd.stellaops.revocation-bundle+jws", | ||||
|             ["b64"] = false, | ||||
|             ["crit"] = new[] { "b64" } | ||||
|         }; | ||||
|  | ||||
|         var headerJson = JsonSerializer.Serialize(header, HeaderSerializerOptions); | ||||
|         var protectedHeader = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson)); | ||||
|  | ||||
|         var signingInputLength = protectedHeader.Length + 1 + payload.Length; | ||||
|         var buffer = ArrayPool<byte>.Shared.Rent(signingInputLength); | ||||
|         try | ||||
|         { | ||||
|             var headerBytes = Encoding.ASCII.GetBytes(protectedHeader); | ||||
|             Buffer.BlockCopy(headerBytes, 0, buffer, 0, headerBytes.Length); | ||||
|             buffer[headerBytes.Length] = (byte)'.'; | ||||
|             Buffer.BlockCopy(payload, 0, buffer, headerBytes.Length + 1, payload.Length); | ||||
|  | ||||
|             var signingInput = new ReadOnlyMemory<byte>(buffer, 0, signingInputLength); | ||||
|             var signatureBytes = await signer.SignAsync(signingInput, cancellationToken).ConfigureAwait(false); | ||||
|             var encodedSignature = Base64UrlEncode(signatureBytes); | ||||
|             return new RevocationBundleSignature(algorithm, signing.ActiveKeyId, string.Concat(protectedHeader, "..", encodedSignature)); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             ArrayPool<byte>.Shared.Return(buffer); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static string Base64UrlEncode(ReadOnlySpan<byte> value) | ||||
|     { | ||||
|         var encoded = Convert.ToBase64String(value); | ||||
|         var builder = new StringBuilder(encoded.Length); | ||||
|         foreach (var ch in encoded) | ||||
|         { | ||||
|             switch (ch) | ||||
|             { | ||||
|                 case '+': | ||||
|                     builder.Append('-'); | ||||
|                     break; | ||||
|                 case '/': | ||||
|                     builder.Append('_'); | ||||
|                     break; | ||||
|                 case '=': | ||||
|                     break; | ||||
|                 default: | ||||
|                     builder.Append(ch); | ||||
|                     break; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return builder.ToString(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,70 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Authority.Revocation; | ||||
|  | ||||
| internal sealed class RevocationEntryModel | ||||
| { | ||||
|     [JsonPropertyName("id")] | ||||
|     [JsonPropertyOrder(1)] | ||||
|     public required string Id { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("category")] | ||||
|     [JsonPropertyOrder(2)] | ||||
|     public required string Category { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("tokenType")] | ||||
|     [JsonPropertyOrder(3)] | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public string? TokenType { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("subjectId")] | ||||
|     [JsonPropertyOrder(4)] | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public string? SubjectId { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("clientId")] | ||||
|     [JsonPropertyOrder(5)] | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public string? ClientId { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("reason")] | ||||
|     [JsonPropertyOrder(6)] | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public string? Reason { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("reasonDescription")] | ||||
|     [JsonPropertyOrder(7)] | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public string? ReasonDescription { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("revokedAt")] | ||||
|     [JsonPropertyOrder(8)] | ||||
|     public DateTimeOffset RevokedAt { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("effectiveAt")] | ||||
|     [JsonPropertyOrder(9)] | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public DateTimeOffset? EffectiveAt { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("expiresAt")] | ||||
|     [JsonPropertyOrder(10)] | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public DateTimeOffset? ExpiresAt { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("scopes")] | ||||
|     [JsonPropertyOrder(11)] | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public List<string>? Scopes { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("fingerprint")] | ||||
|     [JsonPropertyOrder(12)] | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public string? Fingerprint { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("metadata")] | ||||
|     [JsonPropertyOrder(13)] | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public SortedDictionary<string, string?>? Metadata { get; init; } | ||||
| } | ||||
| @@ -0,0 +1,5 @@ | ||||
| namespace StellaOps.Authority.Revocation; | ||||
|  | ||||
| internal sealed record RevocationExportPackage( | ||||
|     RevocationBundleBuildResult Bundle, | ||||
|     RevocationBundleSignature Signature); | ||||
| @@ -0,0 +1,70 @@ | ||||
| using System; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Authority.Revocation; | ||||
|  | ||||
| internal sealed class RevocationExportResponse | ||||
| { | ||||
|     [JsonPropertyName("schemaVersion")] | ||||
|     public required string SchemaVersion { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("bundleId")] | ||||
|     public required string BundleId { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("sequence")] | ||||
|     public required long Sequence { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("issuedAt")] | ||||
|     public required DateTimeOffset IssuedAt { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("signingKeyId")] | ||||
|     public string? SigningKeyId { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("bundle")] | ||||
|     public required RevocationExportPayload Bundle { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("signature")] | ||||
|     public required RevocationExportSignature Signature { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("digest")] | ||||
|     public required RevocationExportDigest Digest { get; init; } | ||||
| } | ||||
|  | ||||
| internal sealed class RevocationExportPayload | ||||
| { | ||||
|     [JsonPropertyName("fileName")] | ||||
|     public string FileName { get; init; } = "revocation-bundle.json"; | ||||
|  | ||||
|     [JsonPropertyName("contentType")] | ||||
|     public string ContentType { get; init; } = "application/json"; | ||||
|  | ||||
|     [JsonPropertyName("encoding")] | ||||
|     public string Encoding { get; init; } = "base64"; | ||||
|  | ||||
|     [JsonPropertyName("data")] | ||||
|     public required string Data { get; init; } | ||||
| } | ||||
|  | ||||
| internal sealed class RevocationExportSignature | ||||
| { | ||||
|     [JsonPropertyName("fileName")] | ||||
|     public string FileName { get; init; } = "revocation-bundle.json.jws"; | ||||
|  | ||||
|     [JsonPropertyName("algorithm")] | ||||
|     public required string Algorithm { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("keyId")] | ||||
|     public required string KeyId { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("value")] | ||||
|     public required string Value { get; init; } | ||||
| } | ||||
|  | ||||
| internal sealed class RevocationExportDigest | ||||
| { | ||||
|     [JsonPropertyName("algorithm")] | ||||
|     public string Algorithm { get; init; } = "sha256"; | ||||
|  | ||||
|     [JsonPropertyName("value")] | ||||
|     public required string Value { get; init; } | ||||
| } | ||||
| @@ -0,0 +1,93 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Text.Json.Serialization; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Authority.Signing; | ||||
|  | ||||
| internal sealed class AuthorityJwksService | ||||
| { | ||||
|     private readonly ICryptoProviderRegistry registry; | ||||
|     private readonly ILogger<AuthorityJwksService> logger; | ||||
|  | ||||
|     public AuthorityJwksService(ICryptoProviderRegistry registry, ILogger<AuthorityJwksService> logger) | ||||
|     { | ||||
|         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public AuthorityJwksResponse Build() => new(BuildKeys()); | ||||
|  | ||||
|     private IReadOnlyCollection<JwksKeyEntry> BuildKeys() | ||||
|     { | ||||
|         var keys = new List<JwksKeyEntry>(); | ||||
|         var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|         foreach (var provider in registry.Providers) | ||||
|         { | ||||
|             foreach (var signingKey in provider.GetSigningKeys()) | ||||
|             { | ||||
|                 var keyId = signingKey.Reference.KeyId; | ||||
|                 if (!seen.Add(keyId)) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 try | ||||
|                 { | ||||
|                     var signer = provider.GetSigner(signingKey.AlgorithmId, signingKey.Reference); | ||||
|                     var jwk = signer.ExportPublicJsonWebKey(); | ||||
|                     var entry = new JwksKeyEntry | ||||
|                     { | ||||
|                         Kid = jwk.Kid, | ||||
|                         Kty = jwk.Kty, | ||||
|                         Use = string.IsNullOrWhiteSpace(jwk.Use) ? "sig" : jwk.Use, | ||||
|                         Alg = jwk.Alg, | ||||
|                         Crv = jwk.Crv, | ||||
|                         X = jwk.X, | ||||
|                         Y = jwk.Y, | ||||
|                         Status = signingKey.Metadata.TryGetValue("status", out var status) ? status : "active" | ||||
|                     }; | ||||
|                     keys.Add(entry); | ||||
|                 } | ||||
|                 catch (Exception ex) | ||||
|                 { | ||||
|                     logger.LogWarning(ex, "Failed to export JWKS entry for key {KeyId}.", keyId); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return keys; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed record AuthorityJwksResponse([property: JsonPropertyName("keys")] IReadOnlyCollection<JwksKeyEntry> Keys); | ||||
|  | ||||
| internal sealed class JwksKeyEntry | ||||
| { | ||||
|     [JsonPropertyName("kty")] | ||||
|     public string? Kty { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("use")] | ||||
|     public string? Use { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("kid")] | ||||
|     public string? Kid { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("alg")] | ||||
|     public string? Alg { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("crv")] | ||||
|     public string? Crv { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("x")] | ||||
|     public string? X { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("y")] | ||||
|     public string? Y { get; set; } | ||||
|  | ||||
|     [JsonPropertyName("status")] | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public string? Status { get; set; } | ||||
| } | ||||
| @@ -0,0 +1,392 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using Microsoft.Extensions.Hosting; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Configuration; | ||||
| using StellaOps.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Authority.Signing; | ||||
|  | ||||
| internal sealed class AuthoritySigningKeyManager | ||||
| { | ||||
|     private readonly object syncRoot = new(); | ||||
|     private readonly ICryptoProviderRegistry registry; | ||||
|     private readonly IReadOnlyList<IAuthoritySigningKeySource> keySources; | ||||
|     private readonly StellaOpsAuthorityOptions authorityOptions; | ||||
|     private readonly string basePath; | ||||
|     private readonly ILogger<AuthoritySigningKeyManager> logger; | ||||
|     private RegisteredSigningKey? activeKey; | ||||
|     private readonly Dictionary<string, RegisteredSigningKey> retiredKeys = new(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|     public AuthoritySigningKeyManager( | ||||
|         ICryptoProviderRegistry registry, | ||||
|         IEnumerable<IAuthoritySigningKeySource> keySources, | ||||
|         IOptions<StellaOpsAuthorityOptions> authorityOptions, | ||||
|         IHostEnvironment environment, | ||||
|         ILogger<AuthoritySigningKeyManager> logger) | ||||
|     { | ||||
|         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); | ||||
|         if (keySources is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(keySources)); | ||||
|         } | ||||
|  | ||||
|         this.keySources = keySources.ToArray(); | ||||
|         if (this.keySources.Count == 0) | ||||
|         { | ||||
|             throw new InvalidOperationException("At least one Authority signing key source must be registered."); | ||||
|         } | ||||
|  | ||||
|         this.authorityOptions = authorityOptions?.Value ?? throw new ArgumentNullException(nameof(authorityOptions)); | ||||
|         basePath = environment?.ContentRootPath ?? throw new ArgumentNullException(nameof(environment)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|  | ||||
|         LoadInitialKeys(); | ||||
|     } | ||||
|  | ||||
|     public SigningRotationResult Rotate(SigningRotationRequest request) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(request); | ||||
|  | ||||
|         lock (syncRoot) | ||||
|         { | ||||
|             var signing = authorityOptions.Signing ?? throw new InvalidOperationException("Authority signing configuration is not available."); | ||||
|             if (!signing.Enabled) | ||||
|             { | ||||
|                 throw new InvalidOperationException("Signing is disabled. Enable signing before rotating keys."); | ||||
|             } | ||||
|  | ||||
|             var keyId = (request.KeyId ?? string.Empty).Trim(); | ||||
|             if (string.IsNullOrWhiteSpace(keyId)) | ||||
|             { | ||||
|                 throw new InvalidOperationException("Rotation requires a keyId."); | ||||
|             } | ||||
|  | ||||
|             var location = request.Location?.Trim(); | ||||
|             if (string.IsNullOrWhiteSpace(location)) | ||||
|             { | ||||
|                 throw new InvalidOperationException("Rotation requires a keyPath/location for the new signing key."); | ||||
|             } | ||||
|  | ||||
|             var algorithm = NormaliseAlgorithm(string.IsNullOrWhiteSpace(request.Algorithm) | ||||
|                 ? signing.Algorithm | ||||
|                 : request.Algorithm); | ||||
|             var source = NormaliseSource(string.IsNullOrWhiteSpace(request.Source) | ||||
|                 ? signing.KeySource | ||||
|                 : request.Source); | ||||
|             var providerName = NormaliseProviderName(request.Provider ?? signing.Provider); | ||||
|  | ||||
|             IReadOnlyDictionary<string, string?>? metadata = null; | ||||
|             if (request.Metadata is not null && request.Metadata.Count > 0) | ||||
|             { | ||||
|                 metadata = new Dictionary<string, string?>(request.Metadata, StringComparer.OrdinalIgnoreCase); | ||||
|             } | ||||
|  | ||||
|             var provider = ResolveProvider(providerName, algorithm); | ||||
|             var loader = ResolveSource(source); | ||||
|             var loadRequest = new AuthoritySigningKeyRequest( | ||||
|                 keyId, | ||||
|                 algorithm, | ||||
|                 source, | ||||
|                 location, | ||||
|                 AuthoritySigningKeyStatus.Active, | ||||
|                 basePath, | ||||
|                 provider.Name, | ||||
|                 additionalMetadata: metadata); | ||||
|             var newKey = loader.Load(loadRequest); | ||||
|             provider.UpsertSigningKey(newKey); | ||||
|  | ||||
|             if (retiredKeys.Remove(keyId)) | ||||
|             { | ||||
|                 logger.LogInformation("Promoted retired signing key {KeyId} to active status.", keyId); | ||||
|             } | ||||
|  | ||||
|             string? previousKeyId = null; | ||||
|             if (activeKey is not null) | ||||
|             { | ||||
|                 previousKeyId = activeKey.Key.Reference.KeyId; | ||||
|                 if (!string.Equals(previousKeyId, keyId, StringComparison.OrdinalIgnoreCase)) | ||||
|                 { | ||||
|                     RetireCurrentActive(); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             activeKey = new RegisteredSigningKey(newKey, provider.Name, source, location); | ||||
|             signing.ActiveKeyId = keyId; | ||||
|             signing.KeyPath = location; | ||||
|             signing.KeySource = source; | ||||
|             signing.Provider = provider.Name; | ||||
|  | ||||
|             RemoveAdditionalOption(keyId); | ||||
|  | ||||
|             logger.LogInformation("Authority signing key rotated. Active key is now {KeyId} via provider {Provider}.", keyId, provider.Name); | ||||
|  | ||||
|             return new SigningRotationResult( | ||||
|                 keyId, | ||||
|                 provider.Name, | ||||
|                 source, | ||||
|                 location, | ||||
|                 previousKeyId, | ||||
|                 retiredKeys.Keys.ToArray()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public SigningKeySnapshot Snapshot | ||||
|     { | ||||
|         get | ||||
|         { | ||||
|             lock (syncRoot) | ||||
|             { | ||||
|                 var active = activeKey; | ||||
|                 return new SigningKeySnapshot( | ||||
|                     active?.Key.Reference.KeyId, | ||||
|                     active?.ProviderName, | ||||
|                     active?.Source, | ||||
|                     active?.Location, | ||||
|                     retiredKeys.Values | ||||
|                         .Select(static registration => new SigningKeySnapshot.RetiredKey( | ||||
|                             registration.Key.Reference.KeyId, | ||||
|                             registration.ProviderName, | ||||
|                             registration.Source, | ||||
|                             registration.Location)) | ||||
|                         .ToArray()); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void LoadInitialKeys() | ||||
|     { | ||||
|         var signing = authorityOptions.Signing; | ||||
|         if (signing is null || !signing.Enabled) | ||||
|         { | ||||
|             logger.LogInformation("Authority signing is disabled; JWKS will expose ephemeral keys until signing is enabled."); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var algorithm = NormaliseAlgorithm(signing.Algorithm); | ||||
|         var source = NormaliseSource(signing.KeySource); | ||||
|         var activeRequest = new AuthoritySigningKeyRequest( | ||||
|             signing.ActiveKeyId, | ||||
|             algorithm, | ||||
|             source, | ||||
|             signing.KeyPath, | ||||
|             AuthoritySigningKeyStatus.Active, | ||||
|             basePath, | ||||
|             NormaliseProviderName(signing.Provider)); | ||||
|         activeKey = LoadAndRegister(activeRequest); | ||||
|         signing.KeySource = source; | ||||
|         signing.Provider = activeKey.ProviderName; | ||||
|  | ||||
|         foreach (var additional in signing.AdditionalKeys) | ||||
|         { | ||||
|             var keyId = (additional.KeyId ?? string.Empty).Trim(); | ||||
|             if (string.IsNullOrWhiteSpace(keyId)) | ||||
|             { | ||||
|                 logger.LogWarning("Skipped additional signing key with empty keyId."); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             if (string.Equals(keyId, activeKey.Key.Reference.KeyId, StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var additionalLocation = additional.Path?.Trim(); | ||||
|             if (string.IsNullOrWhiteSpace(additionalLocation)) | ||||
|             { | ||||
|                 logger.LogWarning("Additional signing key {KeyId} is missing a path. Skipping.", keyId); | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var additionalSource = NormaliseSource(additional.Source ?? source); | ||||
|             var request = new AuthoritySigningKeyRequest( | ||||
|                 keyId, | ||||
|                 algorithm, | ||||
|                 additionalSource, | ||||
|                 additionalLocation, | ||||
|                 AuthoritySigningKeyStatus.Retired, | ||||
|                 basePath, | ||||
|                 NormaliseProviderName(signing.Provider)); | ||||
|  | ||||
|             try | ||||
|             { | ||||
|                 var registration = LoadAndRegister(request); | ||||
|                 retiredKeys[registration.Key.Reference.KeyId] = registration; | ||||
|                 additional.Source = additionalSource; | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 logger.LogWarning(ex, "Failed to load retired signing key {KeyId}. It will be ignored for JWKS responses.", keyId); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void RetireCurrentActive() | ||||
|     { | ||||
|         if (activeKey is null) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var previous = activeKey; | ||||
|         var metadata = new Dictionary<string, string?>(previous.Key.Metadata, StringComparer.OrdinalIgnoreCase) | ||||
|         { | ||||
|             ["status"] = AuthoritySigningKeyStatus.Retired | ||||
|         }; | ||||
|  | ||||
|         var retiredKey = new CryptoSigningKey( | ||||
|             previous.Key.Reference, | ||||
|             previous.Key.AlgorithmId, | ||||
|             in previous.Key.PrivateParameters, | ||||
|             previous.Key.CreatedAt, | ||||
|             previous.Key.ExpiresAt, | ||||
|             metadata); | ||||
|  | ||||
|         var provider = ResolveProvider(previous.ProviderName, retiredKey.AlgorithmId); | ||||
|         provider.UpsertSigningKey(retiredKey); | ||||
|  | ||||
|         var registration = new RegisteredSigningKey(retiredKey, provider.Name, previous.Source, previous.Location); | ||||
|         retiredKeys[registration.Key.Reference.KeyId] = registration; | ||||
|         UpsertAdditionalOption(registration); | ||||
|  | ||||
|         logger.LogInformation("Moved signing key {KeyId} to retired set (provider {Provider}).", registration.Key.Reference.KeyId, provider.Name); | ||||
|     } | ||||
|  | ||||
|     private RegisteredSigningKey LoadAndRegister(AuthoritySigningKeyRequest request) | ||||
|     { | ||||
|         var source = ResolveSource(request.Source); | ||||
|         var provider = ResolveProvider(request.Provider, request.Algorithm); | ||||
|         var key = source.Load(request); | ||||
|         provider.UpsertSigningKey(key); | ||||
|  | ||||
|         logger.LogDebug("Loaded signing key {KeyId} (status {Status}) via provider {Provider}.", key.Reference.KeyId, request.Status, provider.Name); | ||||
|  | ||||
|         return new RegisteredSigningKey(key, provider.Name, request.Source, request.Location); | ||||
|     } | ||||
|  | ||||
|     private IAuthoritySigningKeySource ResolveSource(string source) | ||||
|     { | ||||
|         foreach (var loader in keySources) | ||||
|         { | ||||
|             if (loader.CanLoad(source)) | ||||
|             { | ||||
|                 return loader; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         throw new InvalidOperationException($"No signing key source registered for '{source}'."); | ||||
|     } | ||||
|  | ||||
|     private ICryptoProvider ResolveProvider(string? preferredProvider, string algorithm) | ||||
|     { | ||||
|         var normalised = NormaliseProviderName(preferredProvider); | ||||
|         if (!string.IsNullOrWhiteSpace(normalised) && | ||||
|             registry.TryResolve(normalised!, out var provider) && | ||||
|             provider.Supports(CryptoCapability.Signing, algorithm)) | ||||
|         { | ||||
|             return provider; | ||||
|         } | ||||
|  | ||||
|         return registry.ResolveOrThrow(CryptoCapability.Signing, algorithm); | ||||
|     } | ||||
|  | ||||
|     private void UpsertAdditionalOption(RegisteredSigningKey registration) | ||||
|     { | ||||
|         var additional = authorityOptions.Signing.AdditionalKeys; | ||||
|         for (var index = 0; index < additional.Count; index++) | ||||
|         { | ||||
|             var entry = additional[index]; | ||||
|             if (string.Equals(entry.KeyId, registration.Key.Reference.KeyId, StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 entry.Path = registration.Location; | ||||
|                 entry.Source = registration.Source; | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         additional.Add(new AuthoritySigningAdditionalKeyOptions | ||||
|         { | ||||
|             KeyId = registration.Key.Reference.KeyId, | ||||
|             Path = registration.Location, | ||||
|             Source = registration.Source | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     private void RemoveAdditionalOption(string keyId) | ||||
|     { | ||||
|         var additional = authorityOptions.Signing.AdditionalKeys; | ||||
|         for (var index = additional.Count - 1; index >= 0; index--) | ||||
|         { | ||||
|             if (string.Equals(additional[index].KeyId, keyId, StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 additional.RemoveAt(index); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static string NormaliseAlgorithm(string? algorithm) | ||||
|     { | ||||
|         return string.IsNullOrWhiteSpace(algorithm) | ||||
|             ? SignatureAlgorithms.Es256 | ||||
|             : algorithm.Trim(); | ||||
|     } | ||||
|  | ||||
|     private static string NormaliseSource(string? source) | ||||
|     { | ||||
|         return string.IsNullOrWhiteSpace(source) ? "file" : source.Trim(); | ||||
|     } | ||||
|  | ||||
|     private static string? NormaliseProviderName(string? provider) | ||||
|     { | ||||
|         return string.IsNullOrWhiteSpace(provider) ? null : provider.Trim(); | ||||
|     } | ||||
|  | ||||
|     private sealed record RegisteredSigningKey( | ||||
|         CryptoSigningKey Key, | ||||
|         string ProviderName, | ||||
|         string Source, | ||||
|         string Location); | ||||
| } | ||||
|  | ||||
| internal sealed record SigningRotationResult( | ||||
|     string ActiveKeyId, | ||||
|     string ActiveProvider, | ||||
|     string ActiveSource, | ||||
|     string ActiveLocation, | ||||
|     string? PreviousKeyId, | ||||
|     IReadOnlyCollection<string> RetiredKeyIds); | ||||
|  | ||||
| internal sealed class SigningKeySnapshot | ||||
| { | ||||
|     public SigningKeySnapshot( | ||||
|         string? activeKeyId, | ||||
|         string? activeProvider, | ||||
|         string? activeSource, | ||||
|         string? activeLocation, | ||||
|         IReadOnlyCollection<RetiredKey> retired) | ||||
|     { | ||||
|         ActiveKeyId = activeKeyId; | ||||
|         ActiveProvider = activeProvider; | ||||
|         ActiveSource = activeSource; | ||||
|         ActiveLocation = activeLocation; | ||||
|         Retired = retired ?? Array.Empty<RetiredKey>(); | ||||
|     } | ||||
|  | ||||
|     public string? ActiveKeyId { get; } | ||||
|  | ||||
|     public string? ActiveProvider { get; } | ||||
|  | ||||
|     public string? ActiveSource { get; } | ||||
|  | ||||
|     public string? ActiveLocation { get; } | ||||
|  | ||||
|     public IReadOnlyCollection<RetiredKey> Retired { get; } | ||||
|  | ||||
|     public sealed record RetiredKey( | ||||
|         string KeyId, | ||||
|         string Provider, | ||||
|         string Source, | ||||
|         string Location); | ||||
| } | ||||
| @@ -0,0 +1,57 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
|  | ||||
| namespace StellaOps.Authority.Signing; | ||||
|  | ||||
| internal sealed class AuthoritySigningKeyRequest | ||||
| { | ||||
|     public AuthoritySigningKeyRequest( | ||||
|         string keyId, | ||||
|         string algorithm, | ||||
|         string source, | ||||
|         string location, | ||||
|         string status, | ||||
|         string basePath, | ||||
|         string? provider = null, | ||||
|         DateTimeOffset? createdAt = null, | ||||
|         DateTimeOffset? expiresAt = null, | ||||
|         IReadOnlyDictionary<string, string?>? additionalMetadata = null) | ||||
|     { | ||||
|         KeyId = keyId ?? throw new ArgumentNullException(nameof(keyId)); | ||||
|         Algorithm = string.IsNullOrWhiteSpace(algorithm) | ||||
|             ? throw new ArgumentException("Algorithm identifier is required.", nameof(algorithm)) | ||||
|             : algorithm; | ||||
|         Source = string.IsNullOrWhiteSpace(source) | ||||
|             ? throw new ArgumentException("Signing key source is required.", nameof(source)) | ||||
|             : source; | ||||
|         Location = location ?? throw new ArgumentNullException(nameof(location)); | ||||
|         Status = string.IsNullOrWhiteSpace(status) | ||||
|             ? throw new ArgumentException("Signing key status is required.", nameof(status)) | ||||
|             : status; | ||||
|         BasePath = basePath ?? throw new ArgumentNullException(nameof(basePath)); | ||||
|         Provider = provider; | ||||
|         CreatedAt = createdAt; | ||||
|         ExpiresAt = expiresAt; | ||||
|         AdditionalMetadata = additionalMetadata; | ||||
|     } | ||||
|  | ||||
|     public string KeyId { get; } | ||||
|  | ||||
|     public string Algorithm { get; } | ||||
|  | ||||
|     public string Source { get; } | ||||
|  | ||||
|     public string Location { get; } | ||||
|  | ||||
|     public string Status { get; } | ||||
|  | ||||
|     public string BasePath { get; } | ||||
|  | ||||
|     public string? Provider { get; } | ||||
|  | ||||
|     public DateTimeOffset? CreatedAt { get; } | ||||
|  | ||||
|     public DateTimeOffset? ExpiresAt { get; } | ||||
|  | ||||
|     public IReadOnlyDictionary<string, string?>? AdditionalMetadata { get; } | ||||
| } | ||||
| @@ -0,0 +1,8 @@ | ||||
| namespace StellaOps.Authority.Signing; | ||||
|  | ||||
| internal static class AuthoritySigningKeyStatus | ||||
| { | ||||
|     public const string Active = "active"; | ||||
|     public const string Retired = "retired"; | ||||
|     public const string Disabled = "disabled"; | ||||
| } | ||||
| @@ -0,0 +1,99 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Security.Cryptography; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Authority.Signing; | ||||
|  | ||||
| internal sealed class FileAuthoritySigningKeySource : IAuthoritySigningKeySource | ||||
| { | ||||
|     private readonly ILogger<FileAuthoritySigningKeySource> logger; | ||||
|  | ||||
|     public FileAuthoritySigningKeySource(ILogger<FileAuthoritySigningKeySource> logger) | ||||
|     { | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public bool CanLoad(string source) | ||||
|         => string.Equals(source, "file", StringComparison.OrdinalIgnoreCase); | ||||
|  | ||||
|     public CryptoSigningKey Load(AuthoritySigningKeyRequest request) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(request); | ||||
|  | ||||
|         var path = ResolvePath(request.BasePath, request.Location); | ||||
|         if (!File.Exists(path)) | ||||
|         { | ||||
|             throw new FileNotFoundException($"Authority signing key '{request.KeyId}' not found.", path); | ||||
|         } | ||||
|  | ||||
|         var pem = File.ReadAllText(path); | ||||
|  | ||||
|         using var ecdsa = ECDsa.Create(); | ||||
|         try | ||||
|         { | ||||
|             ecdsa.ImportFromPem(pem); | ||||
|         } | ||||
|         catch (CryptographicException ex) | ||||
|         { | ||||
|             logger.LogError(ex, "Failed to load Authority signing key {KeyId} from {Path}.", request.KeyId, path); | ||||
|             throw new InvalidOperationException("Failed to import Authority signing key. Ensure the PEM is an unencrypted EC private key.", ex); | ||||
|         } | ||||
|  | ||||
|         var parameters = ecdsa.ExportParameters(includePrivateParameters: true); | ||||
|  | ||||
|         var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase) | ||||
|         { | ||||
|             ["source"] = Path.GetFullPath(path), | ||||
|             ["loader"] = "file", | ||||
|             ["status"] = request.Status | ||||
|         }; | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(request.Provider)) | ||||
|         { | ||||
|             metadata["provider"] = request.Provider; | ||||
|         } | ||||
|  | ||||
|         if (request.AdditionalMetadata is not null) | ||||
|         { | ||||
|             foreach (var pair in request.AdditionalMetadata) | ||||
|             { | ||||
|                 metadata[pair.Key] = pair.Value; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         metadata["status"] = request.Status; | ||||
|  | ||||
|         logger.LogInformation("Loaded Authority signing key {KeyId} from {Path}.", request.KeyId, path); | ||||
|  | ||||
|         return new CryptoSigningKey( | ||||
|             new CryptoKeyReference(request.KeyId, request.Provider), | ||||
|             request.Algorithm, | ||||
|             in parameters, | ||||
|             request.CreatedAt ?? DateTimeOffset.UtcNow, | ||||
|             request.ExpiresAt, | ||||
|             metadata); | ||||
|     } | ||||
|  | ||||
|     private static string ResolvePath(string basePath, string location) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(location)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Signing key location is required."); | ||||
|         } | ||||
|  | ||||
|         if (Path.IsPathRooted(location)) | ||||
|         { | ||||
|             return location; | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(basePath)) | ||||
|         { | ||||
|             return Path.GetFullPath(location); | ||||
|         } | ||||
|  | ||||
|         return Path.GetFullPath(Path.Combine(basePath, location)); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,10 @@ | ||||
| using StellaOps.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Authority.Signing; | ||||
|  | ||||
| internal interface IAuthoritySigningKeySource | ||||
| { | ||||
|     bool CanLoad(string source); | ||||
|  | ||||
|     CryptoSigningKey Load(AuthoritySigningKeyRequest request); | ||||
| } | ||||
| @@ -0,0 +1,18 @@ | ||||
| using System.Collections.Generic; | ||||
|  | ||||
| namespace StellaOps.Authority.Signing; | ||||
|  | ||||
| public sealed class SigningRotationRequest | ||||
| { | ||||
|     public string KeyId { get; set; } = string.Empty; | ||||
|  | ||||
|     public string? Source { get; set; } | ||||
|  | ||||
|     public string? Location { get; set; } | ||||
|  | ||||
|     public string? Algorithm { get; set; } | ||||
|  | ||||
|     public string? Provider { get; set; } | ||||
|  | ||||
|     public Dictionary<string, string?>? Metadata { get; set; } | ||||
| } | ||||
| @@ -22,6 +22,7 @@ | ||||
|     <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" /> | ||||
|   </ItemGroup> | ||||
|   | ||||
| @@ -2,14 +2,14 @@ | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | CORE5B.DOC | TODO | Authority Core, Docs Guild | CORE5 | Document token persistence, revocation semantics, and enrichment expectations for resource servers/plugins. | ✅ `docs/11_AUTHORITY.md` + plugin guide updated with claims + token store notes; ✅ Samples include revocation sync guidance. | | ||||
| | CORE9.REVOCATION | TODO | Authority Core, Security Guild | CORE5 | Implement revocation list persistence + export hooks (API + CLI). | ✅ Revoked tokens denied; ✅ Export endpoint/CLI returns manifest; ✅ Tests cover offline bundle flow. | | ||||
| | CORE10.JWKS | TODO | Authority Core, DevOps | CORE9.REVOCATION | Provide JWKS rotation with pluggable key loader + documentation. | ✅ Signing/encryption keys rotate without downtime; ✅ JWKS endpoint updates; ✅ Docs describe rotation SOP. | | ||||
| | CORE8.RL | BLOCKED (Team 2) | Authority Core | CORE8 | Deliver ASP.NET rate limiter plumbing (request metadata, dependency injection hooks) needed by Security Guild. | ✅ `/token` & `/authorize` pipelines expose limiter hooks; ✅ Tests cover throttle behaviour baseline. | | ||||
| | CORE5B.DOC | DONE (2025-10-12) | Authority Core, Docs Guild | CORE5 | Document token persistence, revocation semantics, and enrichment expectations for resource servers/plugins. | ✅ `docs/11_AUTHORITY.md` + plugin guide updated with claims + token store notes; ✅ Samples include revocation sync guidance. | | ||||
| | CORE9.REVOCATION | DONE (2025-10-12) | Authority Core, Security Guild | CORE5 | Implement revocation list persistence + export hooks (API + CLI). | ✅ Revoked tokens denied; ✅ Export endpoint/CLI returns manifest; ✅ Tests cover offline bundle flow. | | ||||
| | CORE10.JWKS | DONE (2025-10-12) | Authority Core, DevOps | CORE9.REVOCATION | Provide JWKS rotation with pluggable key loader + documentation. | ✅ Signing/encryption keys rotate without downtime; ✅ JWKS endpoint updates; ✅ Docs describe rotation SOP. | | ||||
| | CORE8.RL | DONE (2025-10-12) | Authority Core | CORE8 | Deliver ASP.NET rate limiter plumbing (request metadata, dependency injection hooks) needed by Security Guild. | ✅ `/token` & `/authorize` pipelines expose limiter hooks; ✅ Tests cover throttle behaviour baseline. | | ||||
| | SEC2.HOST | TODO | Security Guild, Authority Core | SEC2.A (audit contract) | Hook audit logger into OpenIddict handlers and bootstrap endpoints. | ✅ Audit events populated with correlationId, IP, client_id; ✅ Mongo login attempts persisted; ✅ Tests verify on success/failure/lockout. | | ||||
| | SEC3.HOST | DONE (2025-10-11) | Security Guild | CORE8.RL, SEC3.A (rate policy) | Apply rate limiter policies (`AddRateLimiter`) to `/token` and `/internal/*` endpoints with configuration binding. | ✅ Policies configurable via `StellaOpsAuthorityOptions.Security.RateLimiting`; ✅ Integration tests hit 429 after limit; ✅ Docs updated. | | ||||
| | SEC4.HOST | TODO | Security Guild, DevOps | SEC4.A (revocation schema) | Implement CLI/HTTP surface to export revocation bundle + detached JWS using `StellaOps.Cryptography`. | ✅ `stellaops auth revoke export` CLI/endpoint returns JSON + `.jws`; ✅ Verification script passes; ✅ Operator docs updated. | | ||||
| | SEC4.KEY | TODO | Security Guild, DevOps | SEC4.HOST | Integrate signing keys with provider registry (initial ES256). | ✅ Keys loaded via `ICryptoProvider` signer; ✅ Rotation SOP documented. | | ||||
| | SEC4.HOST | DONE (2025-10-12) | Security Guild, DevOps | SEC4.A (revocation schema) | Implement CLI/HTTP surface to export revocation bundle + detached JWS using `StellaOps.Cryptography`. | ✅ `stellaops auth revoke export` CLI/endpoint returns JSON + `.jws`; ✅ Verification script passes; ✅ Operator docs updated. | | ||||
| | SEC4.KEY | DONE (2025-10-12) | Security Guild, DevOps | SEC4.HOST | Integrate signing keys with provider registry (initial ES256). | ✅ Keys loaded via `ICryptoProvider` signer; ✅ Rotation SOP documented. | | ||||
| | SEC5.HOST | TODO | Security Guild | SEC5.A (threat model) | Feed Authority-specific mitigations (rate limiting, audit, revocation) into threat model + backlog. | ✅ Threat model updated; ✅ Backlog issues reference mitigations; ✅ Review sign-off captured. | | ||||
| | SEC3.BUILD | DONE (2025-10-11) | Authority Core, Security Guild | SEC3.HOST, FEEDMERGE-COORD-02-900 | Track normalized-range dependency fallout and restore full test matrix once Feedser range primitives land. | ✅ Feedser normalized range libraries merged; ✅ Authority + Configuration test suites (`dotnet test src/StellaOps.Authority.sln`, `dotnet test src/StellaOps.Configuration.Tests/StellaOps.Configuration.Tests.csproj`) pass without Feedser compile failures; ✅ Status recorded here/Sprints (authority-core broadcast not available). | | ||||
|  | ||||
|   | ||||
| @@ -262,10 +262,51 @@ internal static class CommandFactory | ||||
|             return CommandHandlers.HandleAuthWhoAmIAsync(services, options, verbose, cancellationToken); | ||||
|         }); | ||||
|  | ||||
|         var revoke = new Command("revoke", "Manage revocation exports."); | ||||
|         var export = new Command("export", "Export the revocation bundle and signature to disk."); | ||||
|         var outputOption = new Option<string?>("--output") | ||||
|         { | ||||
|             Description = "Directory to write exported revocation files (defaults to current directory)." | ||||
|         }; | ||||
|         export.Add(outputOption); | ||||
|         export.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var output = parseResult.GetValue(outputOption); | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleAuthRevokeExportAsync(services, options, output, verbose, cancellationToken); | ||||
|         }); | ||||
|         revoke.Add(export); | ||||
|         var verify = new Command("verify", "Verify a revocation bundle against a detached JWS signature."); | ||||
|         var bundleOption = new Option<string>("--bundle") | ||||
|         { | ||||
|             Description = "Path to the revocation-bundle.json file." | ||||
|         }; | ||||
|         var signatureOption = new Option<string>("--signature") | ||||
|         { | ||||
|             Description = "Path to the revocation-bundle.json.jws file." | ||||
|         }; | ||||
|         var keyOption = new Option<string>("--key") | ||||
|         { | ||||
|             Description = "Path to the PEM-encoded public/private key used for verification." | ||||
|         }; | ||||
|         verify.Add(bundleOption); | ||||
|         verify.Add(signatureOption); | ||||
|         verify.Add(keyOption); | ||||
|         verify.SetAction((parseResult, _) => | ||||
|         { | ||||
|             var bundlePath = parseResult.GetValue(bundleOption) ?? string.Empty; | ||||
|             var signaturePath = parseResult.GetValue(signatureOption) ?? string.Empty; | ||||
|             var keyPath = parseResult.GetValue(keyOption) ?? string.Empty; | ||||
|             var verbose = parseResult.GetValue(verboseOption); | ||||
|             return CommandHandlers.HandleAuthRevokeVerifyAsync(bundlePath, signaturePath, keyPath, verbose, cancellationToken); | ||||
|         }); | ||||
|         revoke.Add(verify); | ||||
|  | ||||
|         auth.Add(login); | ||||
|         auth.Add(logout); | ||||
|         auth.Add(status); | ||||
|         auth.Add(whoami); | ||||
|         auth.Add(revoke); | ||||
|         return auth; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,9 +1,12 @@ | ||||
| using System; | ||||
| using System.Buffers; | ||||
| using System.Collections.Generic; | ||||
| using System.Diagnostics; | ||||
| using System.Globalization; | ||||
| using System.IO; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text.Json; | ||||
| using System.Text; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| @@ -15,8 +18,9 @@ using StellaOps.Cli.Prompts; | ||||
| using StellaOps.Cli.Services; | ||||
| using StellaOps.Cli.Services.Models; | ||||
| using StellaOps.Cli.Telemetry; | ||||
|  | ||||
| namespace StellaOps.Cli.Commands; | ||||
| using StellaOps.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Cli.Commands; | ||||
|  | ||||
| internal static class CommandHandlers | ||||
| { | ||||
| @@ -598,6 +602,236 @@ internal static class CommandHandlers | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static async Task HandleAuthRevokeExportAsync( | ||||
|         IServiceProvider services, | ||||
|         StellaOpsCliOptions options, | ||||
|         string? outputDirectory, | ||||
|         bool verbose, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         await using var scope = services.CreateAsyncScope(); | ||||
|         var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("auth-revoke-export"); | ||||
|         Environment.ExitCode = 0; | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             var client = scope.ServiceProvider.GetRequiredService<IAuthorityRevocationClient>(); | ||||
|             var result = await client.ExportAsync(verbose, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             var directory = string.IsNullOrWhiteSpace(outputDirectory) | ||||
|                 ? Directory.GetCurrentDirectory() | ||||
|                 : Path.GetFullPath(outputDirectory); | ||||
|  | ||||
|             Directory.CreateDirectory(directory); | ||||
|  | ||||
|             var bundlePath = Path.Combine(directory, "revocation-bundle.json"); | ||||
|             var signaturePath = Path.Combine(directory, "revocation-bundle.json.jws"); | ||||
|             var digestPath = Path.Combine(directory, "revocation-bundle.json.sha256"); | ||||
|  | ||||
|             await File.WriteAllBytesAsync(bundlePath, result.BundleBytes, cancellationToken).ConfigureAwait(false); | ||||
|             await File.WriteAllTextAsync(signaturePath, result.Signature, cancellationToken).ConfigureAwait(false); | ||||
|             await File.WriteAllTextAsync(digestPath, $"sha256:{result.Digest}", cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             var computedDigest = Convert.ToHexString(SHA256.HashData(result.BundleBytes)).ToLowerInvariant(); | ||||
|             if (!string.Equals(computedDigest, result.Digest, StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 logger.LogError("Digest mismatch. Expected {Expected} but computed {Actual}.", result.Digest, computedDigest); | ||||
|                 Environment.ExitCode = 1; | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             logger.LogInformation( | ||||
|                 "Revocation bundle exported to {Directory} (sequence {Sequence}, issued {Issued:u}, signing key {KeyId}).", | ||||
|                 directory, | ||||
|                 result.Sequence, | ||||
|                 result.IssuedAt, | ||||
|                 string.IsNullOrWhiteSpace(result.SigningKeyId) ? "<unknown>" : result.SigningKeyId); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger.LogError(ex, "Failed to export revocation bundle."); | ||||
|             Environment.ExitCode = 1; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static async Task HandleAuthRevokeVerifyAsync( | ||||
|         string bundlePath, | ||||
|         string signaturePath, | ||||
|         string keyPath, | ||||
|         bool verbose, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         var loggerFactory = LoggerFactory.Create(builder => builder.AddSimpleConsole(options => | ||||
|         { | ||||
|             options.SingleLine = true; | ||||
|             options.TimestampFormat = "HH:mm:ss "; | ||||
|         })); | ||||
|         var logger = loggerFactory.CreateLogger("auth-revoke-verify"); | ||||
|         Environment.ExitCode = 0; | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(bundlePath) || string.IsNullOrWhiteSpace(signaturePath) || string.IsNullOrWhiteSpace(keyPath)) | ||||
|             { | ||||
|                 logger.LogError("Arguments --bundle, --signature, and --key are required."); | ||||
|                 Environment.ExitCode = 1; | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             var bundleBytes = await File.ReadAllBytesAsync(bundlePath, cancellationToken).ConfigureAwait(false); | ||||
|             var signatureContent = (await File.ReadAllTextAsync(signaturePath, cancellationToken).ConfigureAwait(false)).Trim(); | ||||
|             var keyPem = await File.ReadAllTextAsync(keyPath, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             var digest = Convert.ToHexString(SHA256.HashData(bundleBytes)).ToLowerInvariant(); | ||||
|             logger.LogInformation("Bundle digest sha256:{Digest}", digest); | ||||
|  | ||||
|             if (!TryParseDetachedJws(signatureContent, out var encodedHeader, out var encodedSignature)) | ||||
|             { | ||||
|                 logger.LogError("Signature is not in detached JWS format."); | ||||
|                 Environment.ExitCode = 1; | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             var headerJson = Encoding.UTF8.GetString(Base64UrlDecode(encodedHeader)); | ||||
|             using var headerDocument = JsonDocument.Parse(headerJson); | ||||
|             var header = headerDocument.RootElement; | ||||
|  | ||||
|             if (!header.TryGetProperty("b64", out var b64Element) || b64Element.GetBoolean()) | ||||
|             { | ||||
|                 logger.LogError("Detached JWS header must include '\"b64\": false'."); | ||||
|                 Environment.ExitCode = 1; | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             var algorithm = header.TryGetProperty("alg", out var algElement) ? algElement.GetString() : SignatureAlgorithms.Es256; | ||||
|             if (string.IsNullOrWhiteSpace(algorithm)) | ||||
|             { | ||||
|                 algorithm = SignatureAlgorithms.Es256; | ||||
|             } | ||||
|  | ||||
|             var hashAlgorithm = ResolveHashAlgorithm(algorithm); | ||||
|             if (hashAlgorithm is null) | ||||
|             { | ||||
|                 logger.LogError("Unsupported signing algorithm '{Algorithm}'.", algorithm); | ||||
|                 Environment.ExitCode = 1; | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             using var ecdsa = ECDsa.Create(); | ||||
|             try | ||||
|             { | ||||
|                 ecdsa.ImportFromPem(keyPem); | ||||
|             } | ||||
|             catch (CryptographicException ex) | ||||
|             { | ||||
|                 logger.LogError(ex, "Failed to import signing key."); | ||||
|                 Environment.ExitCode = 1; | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             var signingInputLength = encodedHeader.Length + 1 + bundleBytes.Length; | ||||
|             var buffer = ArrayPool<byte>.Shared.Rent(signingInputLength); | ||||
|             try | ||||
|             { | ||||
|                 var headerBytes = Encoding.ASCII.GetBytes(encodedHeader); | ||||
|                 Buffer.BlockCopy(headerBytes, 0, buffer, 0, headerBytes.Length); | ||||
|                 buffer[headerBytes.Length] = (byte)'.'; | ||||
|                 Buffer.BlockCopy(bundleBytes, 0, buffer, headerBytes.Length + 1, bundleBytes.Length); | ||||
|  | ||||
|                 var signatureBytes = Base64UrlDecode(encodedSignature); | ||||
|                 var verified = ecdsa.VerifyData(new ReadOnlySpan<byte>(buffer, 0, signingInputLength), signatureBytes, hashAlgorithm.Value); | ||||
|  | ||||
|                 if (!verified) | ||||
|                 { | ||||
|                     logger.LogError("Signature verification failed."); | ||||
|                     Environment.ExitCode = 1; | ||||
|                     return; | ||||
|                 } | ||||
|             } | ||||
|             finally | ||||
|             { | ||||
|                 ArrayPool<byte>.Shared.Return(buffer); | ||||
|             } | ||||
|  | ||||
|             logger.LogInformation("Signature verified using algorithm {Algorithm}.", algorithm); | ||||
|  | ||||
|             if (verbose) | ||||
|             { | ||||
|                 logger.LogInformation("JWS header: {Header}", headerJson); | ||||
|             } | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             logger.LogError(ex, "Failed to verify revocation bundle."); | ||||
|             Environment.ExitCode = 1; | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             loggerFactory.Dispose(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static bool TryParseDetachedJws(string value, out string encodedHeader, out string encodedSignature) | ||||
|     { | ||||
|         encodedHeader = string.Empty; | ||||
|         encodedSignature = string.Empty; | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(value)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var parts = value.Split('.'); | ||||
|         if (parts.Length != 3) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         encodedHeader = parts[0]; | ||||
|         encodedSignature = parts[2]; | ||||
|         return parts[1].Length == 0; | ||||
|     } | ||||
|  | ||||
|     private static byte[] Base64UrlDecode(string value) | ||||
|     { | ||||
|         var normalized = value.Replace('-', '+').Replace('_', '/'); | ||||
|         var padding = normalized.Length % 4; | ||||
|         if (padding == 2) | ||||
|         { | ||||
|             normalized += "=="; | ||||
|         } | ||||
|         else if (padding == 3) | ||||
|         { | ||||
|             normalized += "="; | ||||
|         } | ||||
|         else if (padding == 1) | ||||
|         { | ||||
|             throw new FormatException("Invalid Base64Url value."); | ||||
|         } | ||||
|  | ||||
|         return Convert.FromBase64String(normalized); | ||||
|     } | ||||
|  | ||||
|     private static HashAlgorithmName? ResolveHashAlgorithm(string algorithm) | ||||
|     { | ||||
|         if (string.Equals(algorithm, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return HashAlgorithmName.SHA256; | ||||
|         } | ||||
|  | ||||
|         if (string.Equals(algorithm, SignatureAlgorithms.Es384, StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return HashAlgorithmName.SHA384; | ||||
|         } | ||||
|  | ||||
|         if (string.Equals(algorithm, SignatureAlgorithms.Es512, StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             return HashAlgorithmName.SHA512; | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private static string FormatDuration(TimeSpan duration) | ||||
|     { | ||||
|         if (duration <= TimeSpan.Zero) | ||||
|   | ||||
| @@ -81,6 +81,15 @@ internal static class Program | ||||
|                 Directory.CreateDirectory(cacheDirectory); | ||||
|                 services.AddStellaOpsFileTokenCache(cacheDirectory); | ||||
|             } | ||||
|  | ||||
|             services.AddHttpClient<IAuthorityRevocationClient, AuthorityRevocationClient>(client => | ||||
|             { | ||||
|                 client.Timeout = TimeSpan.FromMinutes(2); | ||||
|                 if (Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var authorityUri)) | ||||
|                 { | ||||
|                     client.BaseAddress = authorityUri; | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         services.AddHttpClient<IBackendOperationsClient, BackendOperationsClient>(client => | ||||
|   | ||||
							
								
								
									
										213
									
								
								src/StellaOps.Cli/Services/AuthorityRevocationClient.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										213
									
								
								src/StellaOps.Cli/Services/AuthorityRevocationClient.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,213 @@ | ||||
| using System; | ||||
| using System.Buffers.Text; | ||||
| using System.Net.Http; | ||||
| using System.Net.Http.Headers; | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using StellaOps.Auth.Client; | ||||
| using StellaOps.Cli.Configuration; | ||||
| using StellaOps.Cli.Services.Models; | ||||
|  | ||||
| namespace StellaOps.Cli.Services; | ||||
|  | ||||
| internal sealed class AuthorityRevocationClient : IAuthorityRevocationClient | ||||
| { | ||||
|     private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); | ||||
|     private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30); | ||||
|  | ||||
|     private readonly HttpClient httpClient; | ||||
|     private readonly StellaOpsCliOptions options; | ||||
|     private readonly ILogger<AuthorityRevocationClient> logger; | ||||
|     private readonly IStellaOpsTokenClient? tokenClient; | ||||
|     private readonly object tokenSync = new(); | ||||
|  | ||||
|     private string? cachedAccessToken; | ||||
|     private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue; | ||||
|  | ||||
|     public AuthorityRevocationClient( | ||||
|         HttpClient httpClient, | ||||
|         StellaOpsCliOptions options, | ||||
|         ILogger<AuthorityRevocationClient> logger, | ||||
|         IStellaOpsTokenClient? tokenClient = null) | ||||
|     { | ||||
|         this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); | ||||
|         this.options = options ?? throw new ArgumentNullException(nameof(options)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|         this.tokenClient = tokenClient; | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(options.Authority?.Url) && httpClient.BaseAddress is null && Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var authorityUri)) | ||||
|         { | ||||
|             httpClient.BaseAddress = authorityUri; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task<AuthorityRevocationExportResult> ExportAsync(bool verbose, CancellationToken cancellationToken) | ||||
|     { | ||||
|         EnsureAuthorityConfigured(); | ||||
|  | ||||
|         using var request = new HttpRequestMessage(HttpMethod.Get, "internal/revocations/export"); | ||||
|         var accessToken = await AcquireAccessTokenAsync(cancellationToken).ConfigureAwait(false); | ||||
|         if (!string.IsNullOrWhiteSpace(accessToken)) | ||||
|         { | ||||
|             request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); | ||||
|         } | ||||
|  | ||||
|         using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|         if (!response.IsSuccessStatusCode) | ||||
|         { | ||||
|             var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); | ||||
|             var message = $"Authority export request failed with {(int)response.StatusCode} {response.ReasonPhrase}: {body}"; | ||||
|             throw new InvalidOperationException(message); | ||||
|         } | ||||
|  | ||||
|         var payload = await JsonSerializer.DeserializeAsync<ExportResponseDto>( | ||||
|             await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), | ||||
|             SerializerOptions, | ||||
|             cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         if (payload is null) | ||||
|         { | ||||
|             throw new InvalidOperationException("Authority export response payload was empty."); | ||||
|         } | ||||
|  | ||||
|         var bundleBytes = Convert.FromBase64String(payload.Bundle.Data); | ||||
|         var digest = payload.Digest?.Value ?? string.Empty; | ||||
|  | ||||
|         if (verbose) | ||||
|         { | ||||
|             logger.LogInformation("Received revocation export sequence {Sequence} (sha256:{Digest}, signing key {KeyId}).", payload.Sequence, digest, payload.SigningKeyId ?? "<unspecified>"); | ||||
|         } | ||||
|  | ||||
|         return new AuthorityRevocationExportResult | ||||
|         { | ||||
|             BundleBytes = bundleBytes, | ||||
|             Signature = payload.Signature?.Value ?? string.Empty, | ||||
|             Digest = digest, | ||||
|             Sequence = payload.Sequence, | ||||
|             IssuedAt = payload.IssuedAt, | ||||
|             SigningKeyId = payload.SigningKeyId | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private async Task<string?> AcquireAccessTokenAsync(CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (tokenClient is null) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         lock (tokenSync) | ||||
|         { | ||||
|             if (!string.IsNullOrEmpty(cachedAccessToken) && cachedAccessTokenExpiresAt - TokenRefreshSkew > DateTimeOffset.UtcNow) | ||||
|             { | ||||
|                 return cachedAccessToken; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var scope = AuthorityTokenUtilities.ResolveScope(options); | ||||
|         var token = await RequestAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         lock (tokenSync) | ||||
|         { | ||||
|             cachedAccessToken = token.AccessToken; | ||||
|             cachedAccessTokenExpiresAt = token.ExpiresAtUtc; | ||||
|             return cachedAccessToken; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private async Task<StellaOpsTokenResult> RequestAccessTokenAsync(string scope, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (options.Authority is null) | ||||
|         { | ||||
|             throw new InvalidOperationException("Authority credentials are not configured."); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(options.Authority.Username)) | ||||
|         { | ||||
|             if (string.IsNullOrWhiteSpace(options.Authority.Password)) | ||||
|             { | ||||
|                 throw new InvalidOperationException("Authority password must be configured or run 'auth login'."); | ||||
|             } | ||||
|  | ||||
|             return await tokenClient!.RequestPasswordTokenAsync( | ||||
|                 options.Authority.Username, | ||||
|                 options.Authority.Password!, | ||||
|                 scope, | ||||
|                 cancellationToken).ConfigureAwait(false); | ||||
|         } | ||||
|  | ||||
|         return await tokenClient!.RequestClientCredentialsTokenAsync(scope, cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     private void EnsureAuthorityConfigured() | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(options.Authority?.Url)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Authority URL is not configured. Set STELLAOPS_AUTHORITY_URL or update stellaops.yaml."); | ||||
|         } | ||||
|  | ||||
|         if (httpClient.BaseAddress is null) | ||||
|         { | ||||
|             if (!Uri.TryCreate(options.Authority.Url, UriKind.Absolute, out var baseUri)) | ||||
|             { | ||||
|                 throw new InvalidOperationException("Authority URL is invalid."); | ||||
|             } | ||||
|  | ||||
|             httpClient.BaseAddress = baseUri; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class ExportResponseDto | ||||
|     { | ||||
|         [JsonPropertyName("schemaVersion")] | ||||
|         public string SchemaVersion { get; set; } = string.Empty; | ||||
|  | ||||
|         [JsonPropertyName("bundleId")] | ||||
|         public string BundleId { get; set; } = string.Empty; | ||||
|  | ||||
|         [JsonPropertyName("sequence")] | ||||
|         public long Sequence { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("issuedAt")] | ||||
|         public DateTimeOffset IssuedAt { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("signingKeyId")] | ||||
|         public string? SigningKeyId { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("bundle")] | ||||
|         public ExportPayloadDto Bundle { get; set; } = new(); | ||||
|  | ||||
|         [JsonPropertyName("signature")] | ||||
|         public ExportSignatureDto? Signature { get; set; } | ||||
|  | ||||
|         [JsonPropertyName("digest")] | ||||
|         public ExportDigestDto? Digest { get; set; } | ||||
|     } | ||||
|  | ||||
|     private sealed class ExportPayloadDto | ||||
|     { | ||||
|         [JsonPropertyName("data")] | ||||
|         public string Data { get; set; } = string.Empty; | ||||
|     } | ||||
|  | ||||
|     private sealed class ExportSignatureDto | ||||
|     { | ||||
|         [JsonPropertyName("algorithm")] | ||||
|         public string Algorithm { get; set; } = string.Empty; | ||||
|  | ||||
|         [JsonPropertyName("keyId")] | ||||
|         public string KeyId { get; set; } = string.Empty; | ||||
|  | ||||
|         [JsonPropertyName("value")] | ||||
|         public string Value { get; set; } = string.Empty; | ||||
|     } | ||||
|  | ||||
|     private sealed class ExportDigestDto | ||||
|     { | ||||
|         [JsonPropertyName("value")] | ||||
|         public string Value { get; set; } = string.Empty; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										10
									
								
								src/StellaOps.Cli/Services/IAuthorityRevocationClient.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/StellaOps.Cli/Services/IAuthorityRevocationClient.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using StellaOps.Cli.Services.Models; | ||||
|  | ||||
| namespace StellaOps.Cli.Services; | ||||
|  | ||||
| internal interface IAuthorityRevocationClient | ||||
| { | ||||
|     Task<AuthorityRevocationExportResult> ExportAsync(bool verbose, CancellationToken cancellationToken); | ||||
| } | ||||
| @@ -0,0 +1,18 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Cli.Services.Models; | ||||
|  | ||||
| internal sealed class AuthorityRevocationExportResult | ||||
| { | ||||
|     public required byte[] BundleBytes { get; init; } | ||||
|  | ||||
|     public required string Signature { get; init; } | ||||
|  | ||||
|     public required string Digest { get; init; } | ||||
|  | ||||
|     public required long Sequence { get; init; } | ||||
|  | ||||
|     public required DateTimeOffset IssuedAt { get; init; } | ||||
|  | ||||
|     public string? SigningKeyId { get; init; } | ||||
| } | ||||
| @@ -27,6 +27,8 @@ public class StellaOpsAuthorityOptionsTests | ||||
|             SchemaVersion = 1 | ||||
|         }; | ||||
|         options.Storage.ConnectionString = "mongodb://localhost:27017/authority"; | ||||
|         options.Signing.ActiveKeyId = "test-key"; | ||||
|         options.Signing.KeyPath = "/tmp/test-key.pem"; | ||||
|  | ||||
|         options.PluginDirectories.Add("  ./plugins "); | ||||
|         options.PluginDirectories.Add("./plugins"); | ||||
| @@ -51,6 +53,8 @@ public class StellaOpsAuthorityOptionsTests | ||||
|             SchemaVersion = 1 | ||||
|         }; | ||||
|         options.Storage.ConnectionString = "mongodb://localhost:27017/authority"; | ||||
|         options.Signing.ActiveKeyId = "test-key"; | ||||
|         options.Signing.KeyPath = "/tmp/test-key.pem"; | ||||
|  | ||||
|         var descriptor = new AuthorityPluginDescriptorOptions | ||||
|         { | ||||
| @@ -79,6 +83,8 @@ public class StellaOpsAuthorityOptionsTests | ||||
|             Issuer = new Uri("https://authority.stella-ops.test"), | ||||
|             SchemaVersion = 1 | ||||
|         }; | ||||
|         options.Signing.ActiveKeyId = "test-key"; | ||||
|         options.Signing.KeyPath = "/tmp/test-key.pem"; | ||||
|  | ||||
|         var exception = Assert.Throws<InvalidOperationException>(() => options.Validate()); | ||||
|  | ||||
| @@ -107,7 +113,11 @@ public class StellaOpsAuthorityOptionsTests | ||||
|                     ["Authority:Security:RateLimiting:Token:Window"] = "00:00:30", | ||||
|                     ["Authority:Security:RateLimiting:Authorize:Enabled"] = "true", | ||||
|                     ["Authority:Security:RateLimiting:Internal:Enabled"] = "true", | ||||
|                     ["Authority:Security:RateLimiting:Internal:PermitLimit"] = "3" | ||||
|                     ["Authority:Security:RateLimiting:Internal:PermitLimit"] = "3", | ||||
|                     ["Authority:Signing:Enabled"] = "true", | ||||
|                     ["Authority:Signing:ActiveKeyId"] = "authority-signing-dev", | ||||
|                     ["Authority:Signing:KeyPath"] = "../certificates/authority-signing-dev.pem", | ||||
|                     ["Authority:Signing:KeySource"] = "file" | ||||
|                 }); | ||||
|             }; | ||||
|         }); | ||||
| @@ -128,6 +138,10 @@ public class StellaOpsAuthorityOptionsTests | ||||
|         Assert.True(options.Security.RateLimiting.Authorize.Enabled); | ||||
|         Assert.True(options.Security.RateLimiting.Internal.Enabled); | ||||
|         Assert.Equal(3, options.Security.RateLimiting.Internal.PermitLimit); | ||||
|         Assert.True(options.Signing.Enabled); | ||||
|         Assert.Equal("authority-signing-dev", options.Signing.ActiveKeyId); | ||||
|         Assert.Equal("../certificates/authority-signing-dev.pem", options.Signing.KeyPath); | ||||
|         Assert.Equal("file", options.Signing.KeySource); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
| @@ -140,6 +154,8 @@ public class StellaOpsAuthorityOptionsTests | ||||
|         }; | ||||
|         options.Storage.ConnectionString = "mongodb://localhost:27017/authority"; | ||||
|         options.Security.RateLimiting.Token.PermitLimit = 0; | ||||
|         options.Signing.ActiveKeyId = "test-key"; | ||||
|         options.Signing.KeyPath = "/tmp/test-key.pem"; | ||||
|  | ||||
|         var exception = Assert.Throws<InvalidOperationException>(() => options.Validate()); | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,30 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Configuration; | ||||
|  | ||||
| public sealed class AuthoritySigningAdditionalKeyOptions | ||||
| { | ||||
|     public string KeyId { get; set; } = string.Empty; | ||||
|  | ||||
|     public string Path { get; set; } = string.Empty; | ||||
|  | ||||
|     public string? Source { get; set; } | ||||
|  | ||||
|     internal void Validate(string defaultSource) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(KeyId)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Additional signing keys require a keyId."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(Path)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Signing key '{KeyId}' requires a path."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(Source)) | ||||
|         { | ||||
|             Source = defaultSource; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										81
									
								
								src/StellaOps.Configuration/AuthoritySigningOptions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								src/StellaOps.Configuration/AuthoritySigningOptions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using StellaOps.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Configuration; | ||||
|  | ||||
| public sealed class AuthoritySigningOptions | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Determines whether signing is enabled for revocation exports. | ||||
|     /// </summary> | ||||
|     public bool Enabled { get; set; } = true; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Signing algorithm identifier (ES256 by default). | ||||
|     /// </summary> | ||||
|     public string Algorithm { get; set; } = SignatureAlgorithms.Es256; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Identifier for the signing key source (e.g. "file", "vault"). | ||||
|     /// </summary> | ||||
|     public string KeySource { get; set; } = "file"; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Active signing key identifier (kid). | ||||
|     /// </summary> | ||||
|     public string ActiveKeyId { get; set; } = string.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Path to the private key material (PEM-encoded). | ||||
|     /// </summary> | ||||
|     public string KeyPath { get; set; } = string.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Optional provider hint (default provider when null). | ||||
|     /// </summary> | ||||
|     public string? Provider { get; set; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Optional passphrase protecting the private key (not yet supported). | ||||
|     /// </summary> | ||||
|     public string? KeyPassphrase { get; set; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Additional signing keys retained for verification (previous rotations). | ||||
|     /// </summary> | ||||
|     public IList<AuthoritySigningAdditionalKeyOptions> AdditionalKeys { get; } = new List<AuthoritySigningAdditionalKeyOptions>(); | ||||
|  | ||||
|     internal void Validate() | ||||
|     { | ||||
|         if (!Enabled) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(ActiveKeyId)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Authority signing configuration requires signing.activeKeyId."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(KeyPath)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Authority signing configuration requires signing.keyPath."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(Algorithm)) | ||||
|         { | ||||
|             Algorithm = SignatureAlgorithms.Es256; | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(KeySource)) | ||||
|         { | ||||
|             KeySource = "file"; | ||||
|         } | ||||
|  | ||||
|         foreach (var key in AdditionalKeys) | ||||
|         { | ||||
|             key.Validate(KeySource); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -18,6 +18,7 @@ | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
| </Project> | ||||
|   | ||||
| @@ -4,6 +4,7 @@ using System.IO; | ||||
| using System.Linq; | ||||
| using System.Threading.RateLimiting; | ||||
| using StellaOps.Authority.Plugins.Abstractions; | ||||
| using StellaOps.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Configuration; | ||||
|  | ||||
| @@ -80,6 +81,11 @@ public sealed class StellaOpsAuthorityOptions | ||||
|     /// </summary> | ||||
|     public AuthoritySecurityOptions Security { get; } = new(); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Signing options for Authority-generated artefacts (revocation bundles, JWKS). | ||||
|     /// </summary> | ||||
|     public AuthoritySigningOptions Signing { get; } = new(); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Validates configured values and normalises collections. | ||||
|     /// </summary> | ||||
| @@ -116,6 +122,7 @@ public sealed class StellaOpsAuthorityOptions | ||||
|         NormaliseList(bypassNetworks); | ||||
|  | ||||
|         Security.Validate(); | ||||
|         Signing.Validate(); | ||||
|         Plugins.NormalizeAndValidate(); | ||||
|         Storage.Validate(); | ||||
|         Bootstrap.Validate(); | ||||
| @@ -172,9 +179,15 @@ public sealed class AuthoritySecurityOptions | ||||
|     /// </summary> | ||||
|     public AuthorityRateLimitingOptions RateLimiting { get; } = new(); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Default password hashing parameters advertised to Authority plug-ins. | ||||
|     /// </summary> | ||||
|     public PasswordHashOptions PasswordHashing { get; } = new(); | ||||
|  | ||||
|     internal void Validate() | ||||
|     { | ||||
|         RateLimiting.Validate(); | ||||
|         PasswordHashing.Validate(); | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,14 @@ | ||||
| using System.Collections.Generic; | ||||
|  | ||||
| namespace StellaOps.Cryptography.DependencyInjection; | ||||
|  | ||||
| /// <summary> | ||||
| /// Options controlling crypto provider registry ordering and selection. | ||||
| /// </summary> | ||||
| public sealed class CryptoProviderRegistryOptions | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Ordered list of preferred provider names. Providers appearing here are consulted first. | ||||
|     /// </summary> | ||||
|     public IList<string> PreferredProviders { get; } = new List<string>(); | ||||
| } | ||||
| @@ -0,0 +1,53 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.DependencyInjection.Extensions; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Cryptography.DependencyInjection; | ||||
|  | ||||
| /// <summary> | ||||
| /// Dependency injection helpers for registering StellaOps cryptography services. | ||||
| /// </summary> | ||||
| public static class CryptoServiceCollectionExtensions | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Registers the default crypto provider and registry. | ||||
|     /// </summary> | ||||
|     /// <param name="services">Service collection.</param> | ||||
|     /// <param name="configureRegistry">Optional registry ordering configuration.</param> | ||||
|     /// <param name="configureProvider">Optional provider-level configuration (e.g. key registration).</param> | ||||
|     /// <returns>The service collection.</returns> | ||||
|     public static IServiceCollection AddStellaOpsCrypto( | ||||
|         this IServiceCollection services, | ||||
|         Action<CryptoProviderRegistryOptions>? configureRegistry = null, | ||||
|         Action<DefaultCryptoProvider>? configureProvider = null) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(services); | ||||
|  | ||||
|         if (configureRegistry is not null) | ||||
|         { | ||||
|             services.Configure(configureRegistry); | ||||
|         } | ||||
|  | ||||
|         services.TryAddSingleton(sp => | ||||
|         { | ||||
|             var provider = new DefaultCryptoProvider(); | ||||
|             configureProvider?.Invoke(provider); | ||||
|             return provider; | ||||
|         }); | ||||
|  | ||||
|         services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider>(sp => sp.GetRequiredService<DefaultCryptoProvider>())); | ||||
|  | ||||
|         services.TryAddSingleton<ICryptoProviderRegistry>(sp => | ||||
|         { | ||||
|             var providers = sp.GetServices<ICryptoProvider>(); | ||||
|             var options = sp.GetService<IOptions<CryptoProviderRegistryOptions>>(); | ||||
|             IEnumerable<string>? preferred = options?.Value?.PreferredProviders; | ||||
|             return new CryptoProviderRegistry(providers, preferred); | ||||
|         }); | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,14 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| @@ -0,0 +1,41 @@ | ||||
| using System; | ||||
| using StellaOps.Cryptography; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Cryptography.Tests; | ||||
|  | ||||
| public class Argon2idPasswordHasherTests | ||||
| { | ||||
|     private readonly Argon2idPasswordHasher hasher = new(); | ||||
|  | ||||
|     [Fact] | ||||
|     public void Hash_ProducesPhcEncodedString() | ||||
|     { | ||||
|         var options = new PasswordHashOptions(); | ||||
|         var encoded = hasher.Hash("s3cret", options); | ||||
|  | ||||
|         Assert.StartsWith("$argon2id$", encoded, StringComparison.Ordinal); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Verify_ReturnsTrue_ForCorrectPassword() | ||||
|     { | ||||
|         var options = new PasswordHashOptions(); | ||||
|         var encoded = hasher.Hash("s3cret", options); | ||||
|  | ||||
|         Assert.True(hasher.Verify("s3cret", encoded)); | ||||
|         Assert.False(hasher.Verify("wrong", encoded)); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void NeedsRehash_ReturnsTrue_WhenParametersChange() | ||||
|     { | ||||
|         var options = new PasswordHashOptions(); | ||||
|         var encoded = hasher.Hash("s3cret", options); | ||||
|  | ||||
|         var updated = options with { Iterations = options.Iterations + 1 }; | ||||
|  | ||||
|         Assert.True(hasher.NeedsRehash(encoded, updated)); | ||||
|         Assert.False(hasher.NeedsRehash(encoded, options)); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,55 @@ | ||||
| using System; | ||||
| using StellaOps.Cryptography.Audit; | ||||
|  | ||||
| namespace StellaOps.Cryptography.Tests.Audit; | ||||
|  | ||||
| public class AuthEventRecordTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void AuthEventRecord_InitializesCollections() | ||||
|     { | ||||
|         var record = new AuthEventRecord | ||||
|         { | ||||
|             EventType = "authority.test", | ||||
|             Outcome = AuthEventOutcome.Success | ||||
|         }; | ||||
|  | ||||
|         Assert.NotNull(record.Scopes); | ||||
|         Assert.Empty(record.Scopes); | ||||
|         Assert.NotNull(record.Properties); | ||||
|         Assert.Empty(record.Properties); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void ClassifiedString_NormalizesWhitespace() | ||||
|     { | ||||
|         var value = ClassifiedString.Personal("   "); | ||||
|         Assert.Null(value.Value); | ||||
|         Assert.False(value.HasValue); | ||||
|         Assert.Equal(AuthEventDataClassification.Personal, value.Classification); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Subject_DefaultsToEmptyCollections() | ||||
|     { | ||||
|         var subject = new AuthEventSubject(); | ||||
|         Assert.NotNull(subject.Attributes); | ||||
|         Assert.Empty(subject.Attributes); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Record_AssignsTimestamp_WhenNotProvided() | ||||
|     { | ||||
|         var record = new AuthEventRecord | ||||
|         { | ||||
|             EventType = "authority.test", | ||||
|             Outcome = AuthEventOutcome.Success | ||||
|         }; | ||||
|  | ||||
|         Assert.NotEqual(default, record.OccurredAt); | ||||
|         Assert.InRange( | ||||
|             record.OccurredAt, | ||||
|             DateTimeOffset.UtcNow.AddSeconds(-5), | ||||
|             DateTimeOffset.UtcNow.AddSeconds(5)); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										154
									
								
								src/StellaOps.Cryptography.Tests/CryptoProviderRegistryTests.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								src/StellaOps.Cryptography.Tests/CryptoProviderRegistryTests.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,154 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
| using StellaOps.Cryptography; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Cryptography.Tests; | ||||
|  | ||||
| public class CryptoProviderRegistryTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void ResolveOrThrow_RespectsPreferredProviderOrder() | ||||
|     { | ||||
|         var providerA = new FakeCryptoProvider("providerA") | ||||
|             .WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256) | ||||
|             .WithSigner(SignatureAlgorithms.Es256, "key-a"); | ||||
|  | ||||
|         var providerB = new FakeCryptoProvider("providerB") | ||||
|             .WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256) | ||||
|             .WithSigner(SignatureAlgorithms.Es256, "key-b"); | ||||
|  | ||||
|         var registry = new CryptoProviderRegistry(new[] { providerA, providerB }, new[] { "providerB" }); | ||||
|  | ||||
|         var resolved = registry.ResolveOrThrow(CryptoCapability.Signing, SignatureAlgorithms.Es256); | ||||
|  | ||||
|         Assert.Same(providerB, resolved); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void ResolveSigner_UsesPreferredProviderHint() | ||||
|     { | ||||
|         var providerA = new FakeCryptoProvider("providerA") | ||||
|             .WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256) | ||||
|             .WithSigner(SignatureAlgorithms.Es256, "key-a"); | ||||
|  | ||||
|         var providerB = new FakeCryptoProvider("providerB") | ||||
|             .WithSupport(CryptoCapability.Signing, SignatureAlgorithms.Es256) | ||||
|             .WithSigner(SignatureAlgorithms.Es256, "key-b"); | ||||
|  | ||||
|         var registry = new CryptoProviderRegistry(new[] { providerA, providerB }, Array.Empty<string>()); | ||||
|  | ||||
|         var hintSigner = registry.ResolveSigner( | ||||
|             CryptoCapability.Signing, | ||||
|             SignatureAlgorithms.Es256, | ||||
|             new CryptoKeyReference("key-b"), | ||||
|             preferredProvider: "providerB"); | ||||
|  | ||||
|         Assert.Equal("key-b", hintSigner.KeyId); | ||||
|  | ||||
|         var fallbackSigner = registry.ResolveSigner( | ||||
|             CryptoCapability.Signing, | ||||
|             SignatureAlgorithms.Es256, | ||||
|             new CryptoKeyReference("key-a")); | ||||
|  | ||||
|         Assert.Equal("key-a", fallbackSigner.KeyId); | ||||
|     } | ||||
|  | ||||
|     private sealed class FakeCryptoProvider : ICryptoProvider | ||||
|     { | ||||
|         private readonly Dictionary<string, FakeSigner> signers = new(StringComparer.Ordinal); | ||||
|         private readonly HashSet<(CryptoCapability Capability, string Algorithm)> supported; | ||||
|  | ||||
|         public FakeCryptoProvider(string name) | ||||
|         { | ||||
|             Name = name; | ||||
|             supported = new HashSet<(CryptoCapability, string)>(new CapabilityAlgorithmComparer()); | ||||
|         } | ||||
|  | ||||
|         public string Name { get; } | ||||
|  | ||||
|         public FakeCryptoProvider WithSupport(CryptoCapability capability, string algorithm) | ||||
|         { | ||||
|             supported.Add((capability, algorithm)); | ||||
|             return this; | ||||
|         } | ||||
|  | ||||
|         public FakeCryptoProvider WithSigner(string algorithm, string keyId) | ||||
|         { | ||||
|             WithSupport(CryptoCapability.Signing, algorithm); | ||||
|             var signer = new FakeSigner(Name, keyId, algorithm); | ||||
|             signers[keyId] = signer; | ||||
|             return this; | ||||
|         } | ||||
|  | ||||
|         public bool Supports(CryptoCapability capability, string algorithmId) | ||||
|             => supported.Contains((capability, algorithmId)); | ||||
|  | ||||
|         public IPasswordHasher GetPasswordHasher(string algorithmId) | ||||
|             => throw new NotSupportedException(); | ||||
|  | ||||
|         public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference) | ||||
|         { | ||||
|             if (!signers.TryGetValue(keyReference.KeyId, out var signer)) | ||||
|             { | ||||
|                 throw new KeyNotFoundException(); | ||||
|             } | ||||
|  | ||||
|             if (!string.Equals(signer.AlgorithmId, algorithmId, StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 throw new InvalidOperationException("Signer algorithm mismatch."); | ||||
|             } | ||||
|  | ||||
|             return signer; | ||||
|         } | ||||
|  | ||||
|         public void UpsertSigningKey(CryptoSigningKey signingKey) | ||||
|             => signers[signingKey.Reference.KeyId] = new FakeSigner(Name, signingKey.Reference.KeyId, signingKey.AlgorithmId); | ||||
|  | ||||
|         public bool RemoveSigningKey(string keyId) => signers.Remove(keyId); | ||||
|  | ||||
|         public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys() => Array.Empty<CryptoSigningKey>(); | ||||
|  | ||||
|         private sealed class CapabilityAlgorithmComparer : IEqualityComparer<(CryptoCapability Capability, string Algorithm)> | ||||
|         { | ||||
|             public bool Equals((CryptoCapability Capability, string Algorithm) x, (CryptoCapability Capability, string Algorithm) y) | ||||
|                 => x.Capability == y.Capability && string.Equals(x.Algorithm, y.Algorithm, StringComparison.OrdinalIgnoreCase); | ||||
|  | ||||
|             public int GetHashCode((CryptoCapability Capability, string Algorithm) obj) | ||||
|                 => HashCode.Combine(obj.Capability, obj.Algorithm.ToUpperInvariant()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed class FakeSigner : ICryptoSigner | ||||
|     { | ||||
|         public FakeSigner(string provider, string keyId, string algorithmId) | ||||
|         { | ||||
|             Provider = provider; | ||||
|             KeyId = keyId; | ||||
|             AlgorithmId = algorithmId; | ||||
|         } | ||||
|  | ||||
|         public string Provider { get; } | ||||
|  | ||||
|         public string KeyId { get; } | ||||
|  | ||||
|         public string AlgorithmId { get; } | ||||
|  | ||||
|         public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default) | ||||
|             => ValueTask.FromResult(Array.Empty<byte>()); | ||||
|  | ||||
|         public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default) | ||||
|             => ValueTask.FromResult(true); | ||||
|  | ||||
|         public JsonWebKey ExportPublicJsonWebKey() => new() | ||||
|         { | ||||
|             Kid = KeyId, | ||||
|             Alg = AlgorithmId, | ||||
|             Kty = JsonWebAlgorithmsKeyTypes.Octet, | ||||
|             Use = JsonWebKeyUseNames.Sig | ||||
|         }; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,68 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
| using StellaOps.Cryptography; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Cryptography.Tests; | ||||
|  | ||||
| public class DefaultCryptoProviderSigningTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task UpsertSigningKey_AllowsSignAndVerifyEs256() | ||||
|     { | ||||
|         var provider = new DefaultCryptoProvider(); | ||||
|         using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); | ||||
|         var parameters = ecdsa.ExportParameters(includePrivateParameters: true); | ||||
|  | ||||
|         var signingKey = new CryptoSigningKey( | ||||
|             new CryptoKeyReference("revocation-key"), | ||||
|             SignatureAlgorithms.Es256, | ||||
|             privateParameters: in parameters, | ||||
|             createdAt: DateTimeOffset.UtcNow); | ||||
|  | ||||
|         provider.UpsertSigningKey(signingKey); | ||||
|  | ||||
|         var signer = provider.GetSigner(SignatureAlgorithms.Es256, signingKey.Reference); | ||||
|  | ||||
|         var payload = Encoding.UTF8.GetBytes("hello-world"); | ||||
|         var signature = await signer.SignAsync(payload); | ||||
|  | ||||
|         Assert.NotNull(signature); | ||||
|         Assert.True(signature.Length > 0); | ||||
|  | ||||
|         var verified = await signer.VerifyAsync(payload, signature); | ||||
|         Assert.True(verified); | ||||
|  | ||||
|         var jwk = signer.ExportPublicJsonWebKey(); | ||||
|         Assert.Equal(signingKey.Reference.KeyId, jwk.Kid); | ||||
|         Assert.Equal(SignatureAlgorithms.Es256, jwk.Alg); | ||||
|         Assert.Equal(JsonWebAlgorithmsKeyTypes.EllipticCurve, jwk.Kty); | ||||
|         Assert.Equal(JsonWebKeyUseNames.Sig, jwk.Use); | ||||
|         Assert.Equal(JsonWebKeyECTypes.P256, jwk.Crv); | ||||
|         Assert.False(string.IsNullOrWhiteSpace(jwk.X)); | ||||
|         Assert.False(string.IsNullOrWhiteSpace(jwk.Y)); | ||||
|  | ||||
|         var tampered = (byte[])signature.Clone(); | ||||
|         tampered[^1] ^= 0xFF; | ||||
|         var tamperedResult = await signer.VerifyAsync(payload, tampered); | ||||
|         Assert.False(tamperedResult); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void RemoveSigningKey_PreventsRetrieval() | ||||
|     { | ||||
|         var provider = new DefaultCryptoProvider(); | ||||
|         using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); | ||||
|         var parameters = ecdsa.ExportParameters(true); | ||||
|         var signingKey = new CryptoSigningKey(new CryptoKeyReference("key-to-remove"), SignatureAlgorithms.Es256, in parameters, DateTimeOffset.UtcNow); | ||||
|  | ||||
|         provider.UpsertSigningKey(signingKey); | ||||
|         Assert.True(provider.RemoveSigningKey(signingKey.Reference.KeyId)); | ||||
|  | ||||
|         Assert.Throws<KeyNotFoundException>(() => provider.GetSigner(SignatureAlgorithms.Es256, signingKey.Reference)); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,56 @@ | ||||
| using System; | ||||
| using StellaOps.Cryptography; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Cryptography.Tests; | ||||
|  | ||||
| public class Pbkdf2PasswordHasherTests | ||||
| { | ||||
|     private readonly Pbkdf2PasswordHasher hasher = new(); | ||||
|  | ||||
|     [Fact] | ||||
|     public void Hash_ProducesLegacyFormat() | ||||
|     { | ||||
|         var options = new PasswordHashOptions | ||||
|         { | ||||
|             Algorithm = PasswordHashAlgorithm.Pbkdf2, | ||||
|             Iterations = 210_000 | ||||
|         }; | ||||
|  | ||||
|         var encoded = hasher.Hash("s3cret", options); | ||||
|  | ||||
|         Assert.StartsWith("PBKDF2.", encoded, StringComparison.Ordinal); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Verify_Succeeds_ForCorrectPassword() | ||||
|     { | ||||
|         var options = new PasswordHashOptions | ||||
|         { | ||||
|             Algorithm = PasswordHashAlgorithm.Pbkdf2, | ||||
|             Iterations = 210_000 | ||||
|         }; | ||||
|  | ||||
|         var encoded = hasher.Hash("s3cret", options); | ||||
|  | ||||
|         Assert.True(hasher.Verify("s3cret", encoded)); | ||||
|         Assert.False(hasher.Verify("other", encoded)); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void NeedsRehash_DetectsIterationChange() | ||||
|     { | ||||
|         var options = new PasswordHashOptions | ||||
|         { | ||||
|             Algorithm = PasswordHashAlgorithm.Pbkdf2, | ||||
|             Iterations = 100_000 | ||||
|         }; | ||||
|  | ||||
|         var encoded = hasher.Hash("s3cret", options); | ||||
|  | ||||
|         var higher = options with { Iterations = 150_000 }; | ||||
|  | ||||
|         Assert.True(hasher.NeedsRehash(encoded, higher)); | ||||
|         Assert.False(hasher.NeedsRehash(encoded, options)); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,28 @@ | ||||
| #if !STELLAOPS_CRYPTO_SODIUM | ||||
| using System; | ||||
| using System.Text; | ||||
| using Konscious.Security.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Managed Argon2id implementation powered by Konscious.Security.Cryptography. | ||||
| /// </summary> | ||||
| public sealed partial class Argon2idPasswordHasher | ||||
| { | ||||
|     private static partial byte[] DeriveHashCore(string password, ReadOnlySpan<byte> salt, PasswordHashOptions options) | ||||
|     { | ||||
|         var passwordBytes = Encoding.UTF8.GetBytes(password); | ||||
|  | ||||
|         using var argon2 = new Argon2id(passwordBytes) | ||||
|         { | ||||
|             Salt = salt.ToArray(), | ||||
|             DegreeOfParallelism = options.Parallelism, | ||||
|             Iterations = options.Iterations, | ||||
|             MemorySize = options.MemorySizeInKib | ||||
|         }; | ||||
|  | ||||
|         return argon2.GetBytes(HashLengthBytes); | ||||
|     } | ||||
| } | ||||
| #endif | ||||
							
								
								
									
										30
									
								
								src/StellaOps.Cryptography/Argon2idPasswordHasher.Sodium.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/StellaOps.Cryptography/Argon2idPasswordHasher.Sodium.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| #if STELLAOPS_CRYPTO_SODIUM | ||||
| using System; | ||||
| using System.Text; | ||||
| using Konscious.Security.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Placeholder for libsodium-backed Argon2id implementation. | ||||
| /// Falls back to the managed Konscious variant until native bindings land. | ||||
| /// </summary> | ||||
| public sealed partial class Argon2idPasswordHasher | ||||
| { | ||||
|     private static partial byte[] DeriveHashCore(string password, ReadOnlySpan<byte> salt, PasswordHashOptions options) | ||||
|     { | ||||
|         // TODO(SEC1.B follow-up): replace with libsodium/core bindings and managed pinning logic. | ||||
|         var passwordBytes = Encoding.UTF8.GetBytes(password); | ||||
|  | ||||
|         using var argon2 = new Argon2id(passwordBytes) | ||||
|         { | ||||
|             Salt = salt.ToArray(), | ||||
|             DegreeOfParallelism = options.Parallelism, | ||||
|             Iterations = options.Iterations, | ||||
|             MemorySize = options.MemorySizeInKib | ||||
|         }; | ||||
|  | ||||
|         return argon2.GetBytes(HashLengthBytes); | ||||
|     } | ||||
| } | ||||
| #endif | ||||
							
								
								
									
										173
									
								
								src/StellaOps.Cryptography/Argon2idPasswordHasher.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										173
									
								
								src/StellaOps.Cryptography/Argon2idPasswordHasher.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,173 @@ | ||||
| using System; | ||||
| using System.Globalization; | ||||
| using System.Security.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Argon2id password hasher that emits PHC-compliant encoded strings. | ||||
| /// </summary> | ||||
| public sealed partial class Argon2idPasswordHasher : IPasswordHasher | ||||
| { | ||||
|     private const int SaltLengthBytes = 16; | ||||
|     private const int HashLengthBytes = 32; | ||||
|  | ||||
|     public string Hash(string password, PasswordHashOptions options) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrEmpty(password); | ||||
|         ArgumentNullException.ThrowIfNull(options); | ||||
|  | ||||
|         options.Validate(); | ||||
|  | ||||
|         if (options.Algorithm != PasswordHashAlgorithm.Argon2id) | ||||
|         { | ||||
|             throw new InvalidOperationException("Argon2idPasswordHasher only supports the Argon2id algorithm."); | ||||
|         } | ||||
|  | ||||
|         Span<byte> salt = stackalloc byte[SaltLengthBytes]; | ||||
|         RandomNumberGenerator.Fill(salt); | ||||
|  | ||||
|         var hash = DeriveHash(password, salt, options); | ||||
|  | ||||
|         return BuildEncodedHash(salt, hash, options); | ||||
|     } | ||||
|  | ||||
|     public bool Verify(string password, string encodedHash) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrEmpty(password); | ||||
|         ArgumentException.ThrowIfNullOrEmpty(encodedHash); | ||||
|  | ||||
|         if (!TryParse(encodedHash, out var parsed)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var computed = DeriveHash(password, parsed.Salt, parsed.Options); | ||||
|         return CryptographicOperations.FixedTimeEquals(computed, parsed.Hash); | ||||
|     } | ||||
|  | ||||
|     public bool NeedsRehash(string encodedHash, PasswordHashOptions desired) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(desired); | ||||
|  | ||||
|         if (!TryParse(encodedHash, out var parsed)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (desired.Algorithm != PasswordHashAlgorithm.Argon2id) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (!parsed.Options.Algorithm.Equals(desired.Algorithm)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return parsed.Options.MemorySizeInKib != desired.MemorySizeInKib | ||||
|             || parsed.Options.Iterations != desired.Iterations | ||||
|             || parsed.Options.Parallelism != desired.Parallelism; | ||||
|     } | ||||
|  | ||||
|     private static byte[] DeriveHash(string password, ReadOnlySpan<byte> salt, PasswordHashOptions options) | ||||
|         => DeriveHashCore(password, salt, options); | ||||
|  | ||||
|     private static partial byte[] DeriveHashCore(string password, ReadOnlySpan<byte> salt, PasswordHashOptions options); | ||||
|  | ||||
|     private static string BuildEncodedHash(ReadOnlySpan<byte> salt, ReadOnlySpan<byte> hash, PasswordHashOptions options) | ||||
|     { | ||||
|         var saltEncoded = Convert.ToBase64String(salt); | ||||
|         var hashEncoded = Convert.ToBase64String(hash); | ||||
|  | ||||
|         return $"$argon2id$v=19$m={options.MemorySizeInKib},t={options.Iterations},p={options.Parallelism}${saltEncoded}${hashEncoded}"; | ||||
|     } | ||||
|  | ||||
|     private static bool TryParse(string encodedHash, out Argon2HashParameters parsed) | ||||
|     { | ||||
|         parsed = default; | ||||
|  | ||||
|         if (!encodedHash.StartsWith("$argon2id$", StringComparison.Ordinal)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var segments = encodedHash.Split('$', StringSplitOptions.RemoveEmptyEntries); | ||||
|         if (segments.Length != 5) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // segments: 0=argon2id, 1=v=19, 2=m=...,t=...,p=..., 3=salt, 4=hash | ||||
|         if (!segments[1].StartsWith("v=19", StringComparison.Ordinal)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var parameterParts = segments[2].Split(',', StringSplitOptions.RemoveEmptyEntries); | ||||
|         if (parameterParts.Length != 3) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!TryParseInt(parameterParts[0], "m", out var memory) || | ||||
|             !TryParseInt(parameterParts[1], "t", out var iterations) || | ||||
|             !TryParseInt(parameterParts[2], "p", out var parallelism)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         byte[] saltBytes; | ||||
|         byte[] hashBytes; | ||||
|         try | ||||
|         { | ||||
|             saltBytes = Convert.FromBase64String(segments[3]); | ||||
|             hashBytes = Convert.FromBase64String(segments[4]); | ||||
|         } | ||||
|         catch (FormatException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (saltBytes.Length != SaltLengthBytes || hashBytes.Length != HashLengthBytes) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var options = new PasswordHashOptions | ||||
|         { | ||||
|             Algorithm = PasswordHashAlgorithm.Argon2id, | ||||
|             MemorySizeInKib = memory, | ||||
|             Iterations = iterations, | ||||
|             Parallelism = parallelism | ||||
|         }; | ||||
|  | ||||
|         parsed = new Argon2HashParameters(options, saltBytes, hashBytes); | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private static bool TryParseInt(string component, string key, out int value) | ||||
|     { | ||||
|         value = 0; | ||||
|         if (!component.StartsWith(key + "=", StringComparison.Ordinal)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return int.TryParse(component.AsSpan(key.Length + 1), NumberStyles.None, CultureInfo.InvariantCulture, out value); | ||||
|     } | ||||
|  | ||||
|     private readonly struct Argon2HashParameters | ||||
|     { | ||||
|         public Argon2HashParameters(PasswordHashOptions options, byte[] salt, byte[] hash) | ||||
|         { | ||||
|             Options = options; | ||||
|             Salt = salt; | ||||
|             Hash = hash; | ||||
|         } | ||||
|  | ||||
|         public PasswordHashOptions Options { get; } | ||||
|         public byte[] Salt { get; } | ||||
|         public byte[] Hash { get; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										258
									
								
								src/StellaOps.Cryptography/Audit/AuthEventRecord.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										258
									
								
								src/StellaOps.Cryptography/Audit/AuthEventRecord.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,258 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
|  | ||||
| namespace StellaOps.Cryptography.Audit; | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents a structured security event emitted by the Authority host and plugins. | ||||
| /// </summary> | ||||
| public sealed record AuthEventRecord | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Canonical event identifier (e.g. <c>authority.password.grant</c>). | ||||
|     /// </summary> | ||||
|     public required string EventType { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// UTC timestamp captured when the event occurred. | ||||
|     /// </summary> | ||||
|     public DateTimeOffset OccurredAt { get; init; } = DateTimeOffset.UtcNow; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Stable correlation identifier that links the event across logs, traces, and persistence. | ||||
|     /// </summary> | ||||
|     public string? CorrelationId { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Outcome classification for the audited operation. | ||||
|     /// </summary> | ||||
|     public AuthEventOutcome Outcome { get; init; } = AuthEventOutcome.Unknown; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Optional human-readable reason or failure descriptor. | ||||
|     /// </summary> | ||||
|     public string? Reason { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Identity of the end-user (subject) involved in the event, when applicable. | ||||
|     /// </summary> | ||||
|     public AuthEventSubject? Subject { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// OAuth/OIDC client metadata associated with the event, when applicable. | ||||
|     /// </summary> | ||||
|     public AuthEventClient? Client { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Granted or requested scopes tied to the event. | ||||
|     /// </summary> | ||||
|     public IReadOnlyList<string> Scopes { get; init; } = Array.Empty<string>(); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Network attributes (remote IP, forwarded headers, user agent) captured for the request. | ||||
|     /// </summary> | ||||
|     public AuthEventNetwork? Network { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Additional classified properties carried with the event. | ||||
|     /// </summary> | ||||
|     public IReadOnlyList<AuthEventProperty> Properties { get; init; } = Array.Empty<AuthEventProperty>(); | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Describes the outcome of an audited flow. | ||||
| /// </summary> | ||||
| public enum AuthEventOutcome | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Outcome has not been set. | ||||
|     /// </summary> | ||||
|     Unknown = 0, | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Operation succeeded. | ||||
|     /// </summary> | ||||
|     Success, | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Operation failed (invalid credentials, configuration issues, etc.). | ||||
|     /// </summary> | ||||
|     Failure, | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Operation failed due to a lockout policy. | ||||
|     /// </summary> | ||||
|     LockedOut, | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Operation was rejected due to rate limiting or throttling. | ||||
|     /// </summary> | ||||
|     RateLimited, | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Operation encountered an unexpected error. | ||||
|     /// </summary> | ||||
|     Error | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents a string value enriched with a data classification tag. | ||||
| /// </summary> | ||||
| public readonly record struct ClassifiedString(string? Value, AuthEventDataClassification Classification) | ||||
| { | ||||
|     /// <summary> | ||||
|     /// An empty classified string. | ||||
|     /// </summary> | ||||
|     public static ClassifiedString Empty => new(null, AuthEventDataClassification.None); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Indicates whether the classified string carries a non-empty value. | ||||
|     /// </summary> | ||||
|     public bool HasValue => !string.IsNullOrWhiteSpace(Value); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Creates a classified string for public/non-sensitive data. | ||||
|     /// </summary> | ||||
|     public static ClassifiedString Public(string? value) => Create(value, AuthEventDataClassification.None); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Creates a classified string tagged as personally identifiable information (PII). | ||||
|     /// </summary> | ||||
|     public static ClassifiedString Personal(string? value) => Create(value, AuthEventDataClassification.Personal); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Creates a classified string tagged as sensitive (e.g. credentials, secrets). | ||||
|     /// </summary> | ||||
|     public static ClassifiedString Sensitive(string? value) => Create(value, AuthEventDataClassification.Sensitive); | ||||
|  | ||||
|     private static ClassifiedString Create(string? value, AuthEventDataClassification classification) | ||||
|     { | ||||
|         return new ClassifiedString(Normalize(value), classification); | ||||
|     } | ||||
|  | ||||
|     private static string? Normalize(string? value) | ||||
|     { | ||||
|         return string.IsNullOrWhiteSpace(value) ? null : value; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Supported classifications for audit data values. | ||||
| /// </summary> | ||||
| public enum AuthEventDataClassification | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Data is not considered sensitive. | ||||
|     /// </summary> | ||||
|     None = 0, | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Personally identifiable information (PII) that warrants redaction in certain sinks. | ||||
|     /// </summary> | ||||
|     Personal, | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Highly sensitive information (credentials, secrets, tokens). | ||||
|     /// </summary> | ||||
|     Sensitive | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Captures subject metadata for an audit event. | ||||
| /// </summary> | ||||
| public sealed record AuthEventSubject | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Stable subject identifier (PII). | ||||
|     /// </summary> | ||||
|     public ClassifiedString SubjectId { get; init; } = ClassifiedString.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Username or login name (PII). | ||||
|     /// </summary> | ||||
|     public ClassifiedString Username { get; init; } = ClassifiedString.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Optional display name (PII). | ||||
|     /// </summary> | ||||
|     public ClassifiedString DisplayName { get; init; } = ClassifiedString.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Optional plugin or tenant realm controlling the subject namespace. | ||||
|     /// </summary> | ||||
|     public ClassifiedString Realm { get; init; } = ClassifiedString.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Additional classified attributes (e.g. email, phone). | ||||
|     /// </summary> | ||||
|     public IReadOnlyList<AuthEventProperty> Attributes { get; init; } = Array.Empty<AuthEventProperty>(); | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Captures OAuth/OIDC client metadata for an audit event. | ||||
| /// </summary> | ||||
| public sealed record AuthEventClient | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Client identifier (PII for confidential clients). | ||||
|     /// </summary> | ||||
|     public ClassifiedString ClientId { get; init; } = ClassifiedString.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Friendly client name (may be public). | ||||
|     /// </summary> | ||||
|     public ClassifiedString Name { get; init; } = ClassifiedString.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Identity provider/plugin originating the client. | ||||
|     /// </summary> | ||||
|     public ClassifiedString Provider { get; init; } = ClassifiedString.Empty; | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Captures network metadata for an audit event. | ||||
| /// </summary> | ||||
| public sealed record AuthEventNetwork | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Remote address observed for the request (PII). | ||||
|     /// </summary> | ||||
|     public ClassifiedString RemoteAddress { get; init; } = ClassifiedString.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Forwarded address supplied by proxies (PII). | ||||
|     /// </summary> | ||||
|     public ClassifiedString ForwardedFor { get; init; } = ClassifiedString.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// User agent string associated with the request. | ||||
|     /// </summary> | ||||
|     public ClassifiedString UserAgent { get; init; } = ClassifiedString.Empty; | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents an additional classified property associated with the audit event. | ||||
| /// </summary> | ||||
| public sealed record AuthEventProperty | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Property name (canonical snake-case identifier). | ||||
|     /// </summary> | ||||
|     public required string Name { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Classified value assigned to the property. | ||||
|     /// </summary> | ||||
|     public ClassifiedString Value { get; init; } = ClassifiedString.Empty; | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Sink that receives completed audit event records. | ||||
| /// </summary> | ||||
| public interface IAuthEventSink | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Persists the supplied audit event. | ||||
|     /// </summary> | ||||
|     ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken); | ||||
| } | ||||
| @@ -29,6 +29,32 @@ public interface ICryptoProvider | ||||
|     bool Supports(CryptoCapability capability, string algorithmId); | ||||
|  | ||||
|     IPasswordHasher GetPasswordHasher(string algorithmId); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Retrieves a signer for the supplied algorithm and key reference. | ||||
|     /// </summary> | ||||
|     /// <param name="algorithmId">Signing algorithm identifier (e.g., ES256).</param> | ||||
|     /// <param name="keyReference">Key reference.</param> | ||||
|     /// <returns>Signer instance.</returns> | ||||
|     ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Adds or replaces signing key material managed by this provider. | ||||
|     /// </summary> | ||||
|     /// <param name="signingKey">Key material descriptor.</param> | ||||
|     void UpsertSigningKey(CryptoSigningKey signingKey); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Removes signing key material by key identifier. | ||||
|     /// </summary> | ||||
|     /// <param name="keyId">Identifier to remove.</param> | ||||
|     /// <returns><c>true</c> if the key was removed.</returns> | ||||
|     bool RemoveSigningKey(string keyId); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Lists signing key descriptors managed by this provider. | ||||
|     /// </summary> | ||||
|     IReadOnlyCollection<CryptoSigningKey> GetSigningKeys(); | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| @@ -41,4 +67,18 @@ public interface ICryptoProviderRegistry | ||||
|     bool TryResolve(string preferredProvider, out ICryptoProvider provider); | ||||
|  | ||||
|     ICryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Resolves a signer for the supplied algorithm and key reference using registry policy. | ||||
|     /// </summary> | ||||
|     /// <param name="capability">Capability required (typically <see cref="CryptoCapability.Signing"/>).</param> | ||||
|     /// <param name="algorithmId">Algorithm identifier.</param> | ||||
|     /// <param name="keyReference">Key reference.</param> | ||||
|     /// <param name="preferredProvider">Optional provider hint.</param> | ||||
|     /// <returns>Resolved signer.</returns> | ||||
|     ICryptoSigner ResolveSigner( | ||||
|         CryptoCapability capability, | ||||
|         string algorithmId, | ||||
|         CryptoKeyReference keyReference, | ||||
|         string? preferredProvider = null); | ||||
| } | ||||
|   | ||||
							
								
								
									
										112
									
								
								src/StellaOps.Cryptography/CryptoProviderRegistry.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								src/StellaOps.Cryptography/CryptoProviderRegistry.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,112 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.ObjectModel; | ||||
| using System.Linq; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Default implementation of <see cref="ICryptoProviderRegistry"/> with deterministic provider ordering. | ||||
| /// </summary> | ||||
| public sealed class CryptoProviderRegistry : ICryptoProviderRegistry | ||||
| { | ||||
|     private readonly ReadOnlyCollection<ICryptoProvider> providers; | ||||
|     private readonly IReadOnlyDictionary<string, ICryptoProvider> providersByName; | ||||
|     private readonly IReadOnlyList<string> preferredOrder; | ||||
|     private readonly HashSet<string> preferredOrderSet; | ||||
|  | ||||
|     public CryptoProviderRegistry( | ||||
|         IEnumerable<ICryptoProvider> providers, | ||||
|         IEnumerable<string>? preferredProviderOrder = null) | ||||
|     { | ||||
|         if (providers is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(providers)); | ||||
|         } | ||||
|  | ||||
|         var providerList = providers.ToList(); | ||||
|         if (providerList.Count == 0) | ||||
|         { | ||||
|             throw new ArgumentException("At least one crypto provider must be registered.", nameof(providers)); | ||||
|         } | ||||
|  | ||||
|         providersByName = providerList.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase); | ||||
|         this.providers = new ReadOnlyCollection<ICryptoProvider>(providerList); | ||||
|  | ||||
|         preferredOrder = preferredProviderOrder? | ||||
|             .Where(name => providersByName.ContainsKey(name)) | ||||
|             .Select(name => providersByName[name].Name) | ||||
|             .ToArray() ?? Array.Empty<string>(); | ||||
|         preferredOrderSet = new HashSet<string>(preferredOrder, StringComparer.OrdinalIgnoreCase); | ||||
|     } | ||||
|  | ||||
|     public IReadOnlyCollection<ICryptoProvider> Providers => providers; | ||||
|  | ||||
|     public bool TryResolve(string preferredProvider, out ICryptoProvider provider) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(preferredProvider)) | ||||
|         { | ||||
|             provider = default!; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return providersByName.TryGetValue(preferredProvider, out provider!); | ||||
|     } | ||||
|  | ||||
|     public ICryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(algorithmId)) | ||||
|         { | ||||
|             throw new ArgumentException("Algorithm identifier is required.", nameof(algorithmId)); | ||||
|         } | ||||
|  | ||||
|         foreach (var provider in EnumerateCandidates()) | ||||
|         { | ||||
|             if (provider.Supports(capability, algorithmId)) | ||||
|             { | ||||
|                 return provider; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         throw new InvalidOperationException( | ||||
|             $"No crypto provider is registered for capability '{capability}' and algorithm '{algorithmId}'."); | ||||
|     } | ||||
|  | ||||
|     public ICryptoSigner ResolveSigner( | ||||
|         CryptoCapability capability, | ||||
|         string algorithmId, | ||||
|         CryptoKeyReference keyReference, | ||||
|         string? preferredProvider = null) | ||||
|     { | ||||
|         if (!string.IsNullOrWhiteSpace(preferredProvider) && | ||||
|             providersByName.TryGetValue(preferredProvider!, out var hinted)) | ||||
|         { | ||||
|             if (!hinted.Supports(capability, algorithmId)) | ||||
|             { | ||||
|                 throw new InvalidOperationException( | ||||
|                     $"Provider '{preferredProvider}' does not support capability '{capability}' and algorithm '{algorithmId}'."); | ||||
|             } | ||||
|  | ||||
|             return hinted.GetSigner(algorithmId, keyReference); | ||||
|         } | ||||
|  | ||||
|         var provider = ResolveOrThrow(capability, algorithmId); | ||||
|         return provider.GetSigner(algorithmId, keyReference); | ||||
|     } | ||||
|  | ||||
|     private IEnumerable<ICryptoProvider> EnumerateCandidates() | ||||
|     { | ||||
|         foreach (var name in preferredOrder) | ||||
|         { | ||||
|             yield return providersByName[name]; | ||||
|         } | ||||
|  | ||||
|         foreach (var provider in providers) | ||||
|         { | ||||
|             if (!preferredOrderSet.Contains(provider.Name)) | ||||
|             { | ||||
|                 yield return provider; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										105
									
								
								src/StellaOps.Cryptography/CryptoSigningKey.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								src/StellaOps.Cryptography/CryptoSigningKey.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.ObjectModel; | ||||
| using System.Linq; | ||||
| using System.Security.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents asymmetric signing key material managed by a crypto provider. | ||||
| /// </summary> | ||||
| public sealed class CryptoSigningKey | ||||
| { | ||||
|     private static readonly ReadOnlyDictionary<string, string?> EmptyMetadata = | ||||
|         new(new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)); | ||||
|  | ||||
|     public CryptoSigningKey( | ||||
|         CryptoKeyReference reference, | ||||
|         string algorithmId, | ||||
|         in ECParameters privateParameters, | ||||
|         DateTimeOffset createdAt, | ||||
|         DateTimeOffset? expiresAt = null, | ||||
|         IReadOnlyDictionary<string, string?>? metadata = null) | ||||
|     { | ||||
|         Reference = reference ?? throw new ArgumentNullException(nameof(reference)); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(algorithmId)) | ||||
|         { | ||||
|             throw new ArgumentException("Algorithm identifier is required.", nameof(algorithmId)); | ||||
|         } | ||||
|  | ||||
|         if (privateParameters.D is null || privateParameters.D.Length == 0) | ||||
|         { | ||||
|             throw new ArgumentException("Private key parameters must include the scalar component.", nameof(privateParameters)); | ||||
|         } | ||||
|  | ||||
|         AlgorithmId = algorithmId; | ||||
|         CreatedAt = createdAt; | ||||
|         ExpiresAt = expiresAt; | ||||
|  | ||||
|         PrivateParameters = CloneParameters(privateParameters, includePrivate: true); | ||||
|         PublicParameters = CloneParameters(privateParameters, includePrivate: false); | ||||
|         Metadata = metadata is null | ||||
|             ? EmptyMetadata | ||||
|             : new ReadOnlyDictionary<string, string?>(metadata.ToDictionary( | ||||
|                 static pair => pair.Key, | ||||
|                 static pair => pair.Value, | ||||
|                 StringComparer.OrdinalIgnoreCase)); | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the key reference (id + provider hint). | ||||
|     /// </summary> | ||||
|     public CryptoKeyReference Reference { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the algorithm identifier (e.g., ES256). | ||||
|     /// </summary> | ||||
|     public string AlgorithmId { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the private EC parameters (cloned). | ||||
|     /// </summary> | ||||
|     public ECParameters PrivateParameters { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the public EC parameters (cloned, no private scalar). | ||||
|     /// </summary> | ||||
|     public ECParameters PublicParameters { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the timestamp when the key was created/imported. | ||||
|     /// </summary> | ||||
|     public DateTimeOffset CreatedAt { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the optional expiry timestamp for the key. | ||||
|     /// </summary> | ||||
|     public DateTimeOffset? ExpiresAt { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets arbitrary metadata entries associated with the key. | ||||
|     /// </summary> | ||||
|     public IReadOnlyDictionary<string, string?> Metadata { get; } | ||||
|  | ||||
|     private static ECParameters CloneParameters(ECParameters source, bool includePrivate) | ||||
|     { | ||||
|         var clone = new ECParameters | ||||
|         { | ||||
|             Curve = source.Curve, | ||||
|             Q = new ECPoint | ||||
|             { | ||||
|                 X = source.Q.X is null ? null : (byte[])source.Q.X.Clone(), | ||||
|                 Y = source.Q.Y is null ? null : (byte[])source.Q.Y.Clone() | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         if (includePrivate && source.D is not null) | ||||
|         { | ||||
|             clone.D = (byte[])source.D.Clone(); | ||||
|         } | ||||
|  | ||||
|         return clone; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										129
									
								
								src/StellaOps.Cryptography/DefaultCryptoProvider.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								src/StellaOps.Cryptography/DefaultCryptoProvider.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,129 @@ | ||||
| using System; | ||||
| using System.Collections.Concurrent; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Security.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Default in-process crypto provider exposing password hashing capabilities. | ||||
| /// </summary> | ||||
| public sealed class DefaultCryptoProvider : ICryptoProvider | ||||
| { | ||||
|     private readonly ConcurrentDictionary<string, IPasswordHasher> passwordHashers; | ||||
|     private readonly ConcurrentDictionary<string, CryptoSigningKey> signingKeys; | ||||
|     private static readonly HashSet<string> SupportedSigningAlgorithms = new(StringComparer.OrdinalIgnoreCase) | ||||
|     { | ||||
|         SignatureAlgorithms.Es256 | ||||
|     }; | ||||
|  | ||||
|     public DefaultCryptoProvider() | ||||
|     { | ||||
|         passwordHashers = new ConcurrentDictionary<string, IPasswordHasher>(StringComparer.OrdinalIgnoreCase); | ||||
|         signingKeys = new ConcurrentDictionary<string, CryptoSigningKey>(StringComparer.Ordinal); | ||||
|  | ||||
|         var argon = new Argon2idPasswordHasher(); | ||||
|         var pbkdf2 = new Pbkdf2PasswordHasher(); | ||||
|  | ||||
|         passwordHashers.TryAdd(PasswordHashAlgorithm.Argon2id.ToString(), argon); | ||||
|         passwordHashers.TryAdd(PasswordHashAlgorithms.Argon2id, argon); | ||||
|         passwordHashers.TryAdd(PasswordHashAlgorithm.Pbkdf2.ToString(), pbkdf2); | ||||
|         passwordHashers.TryAdd(PasswordHashAlgorithms.Pbkdf2Sha256, pbkdf2); | ||||
|     } | ||||
|  | ||||
|     public string Name => "default"; | ||||
|  | ||||
|     public bool Supports(CryptoCapability capability, string algorithmId) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(algorithmId)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return capability switch | ||||
|         { | ||||
|             CryptoCapability.PasswordHashing => passwordHashers.ContainsKey(algorithmId), | ||||
|             CryptoCapability.Signing or CryptoCapability.Verification => SupportedSigningAlgorithms.Contains(algorithmId), | ||||
|             _ => false | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     public IPasswordHasher GetPasswordHasher(string algorithmId) | ||||
|     { | ||||
|         if (!Supports(CryptoCapability.PasswordHashing, algorithmId)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Password hashing algorithm '{algorithmId}' is not supported by provider '{Name}'."); | ||||
|         } | ||||
|  | ||||
|         return passwordHashers[algorithmId]; | ||||
|     } | ||||
|  | ||||
|     public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(keyReference); | ||||
|  | ||||
|         if (!Supports(CryptoCapability.Signing, algorithmId)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider '{Name}'."); | ||||
|         } | ||||
|  | ||||
|         if (!signingKeys.TryGetValue(keyReference.KeyId, out var signingKey)) | ||||
|         { | ||||
|             throw new KeyNotFoundException($"Signing key '{keyReference.KeyId}' is not registered with provider '{Name}'."); | ||||
|         } | ||||
|  | ||||
|         if (!string.Equals(signingKey.AlgorithmId, algorithmId, StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             throw new InvalidOperationException( | ||||
|                 $"Signing key '{keyReference.KeyId}' is registered for algorithm '{signingKey.AlgorithmId}', not '{algorithmId}'."); | ||||
|         } | ||||
|  | ||||
|         return EcdsaSigner.Create(signingKey); | ||||
|     } | ||||
|  | ||||
|     public void UpsertSigningKey(CryptoSigningKey signingKey) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(signingKey); | ||||
|         EnsureSigningSupported(signingKey.AlgorithmId); | ||||
|         ValidateSigningKey(signingKey); | ||||
|  | ||||
|         signingKeys.AddOrUpdate(signingKey.Reference.KeyId, signingKey, (_, _) => signingKey); | ||||
|     } | ||||
|  | ||||
|     public bool RemoveSigningKey(string keyId) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(keyId)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return signingKeys.TryRemove(keyId, out _); | ||||
|     } | ||||
|  | ||||
|     public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys() | ||||
|         => signingKeys.Values.ToArray(); | ||||
|  | ||||
|     private static void EnsureSigningSupported(string algorithmId) | ||||
|     { | ||||
|         if (!SupportedSigningAlgorithms.Contains(algorithmId)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider 'default'."); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static void ValidateSigningKey(CryptoSigningKey signingKey) | ||||
|     { | ||||
|         if (!string.Equals(signingKey.AlgorithmId, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Only ES256 signing keys are currently supported by provider 'default'."); | ||||
|         } | ||||
|  | ||||
|         var expected = ECCurve.NamedCurves.nistP256; | ||||
|         var curve = signingKey.PrivateParameters.Curve; | ||||
|         if (!curve.IsNamed || !string.Equals(curve.Oid.Value, expected.Oid.Value, StringComparison.Ordinal)) | ||||
|         { | ||||
|             throw new InvalidOperationException("ES256 signing keys must use the NIST P-256 curve."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										82
									
								
								src/StellaOps.Cryptography/EcdsaSigner.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								src/StellaOps.Cryptography/EcdsaSigner.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| using System; | ||||
| using System.Security.Cryptography; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| internal sealed class EcdsaSigner : ICryptoSigner | ||||
| { | ||||
|     private static readonly string[] DefaultKeyOps = { "sign", "verify" }; | ||||
|     private readonly CryptoSigningKey signingKey; | ||||
|  | ||||
|     private EcdsaSigner(CryptoSigningKey signingKey) | ||||
|         => this.signingKey = signingKey ?? throw new ArgumentNullException(nameof(signingKey)); | ||||
|  | ||||
|     public string KeyId => signingKey.Reference.KeyId; | ||||
|  | ||||
|     public string AlgorithmId => signingKey.AlgorithmId; | ||||
|  | ||||
|     public static ICryptoSigner Create(CryptoSigningKey signingKey) => new EcdsaSigner(signingKey); | ||||
|  | ||||
|     public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|         using var ecdsa = ECDsa.Create(signingKey.PrivateParameters); | ||||
|         var hashAlgorithm = ResolveHashAlgorithm(signingKey.AlgorithmId); | ||||
|         var signature = ecdsa.SignData(data.Span, hashAlgorithm); | ||||
|         return ValueTask.FromResult(signature); | ||||
|     } | ||||
|  | ||||
|     public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|         using var ecdsa = ECDsa.Create(signingKey.PublicParameters); | ||||
|         var hashAlgorithm = ResolveHashAlgorithm(signingKey.AlgorithmId); | ||||
|         var verified = ecdsa.VerifyData(data.Span, signature.Span, hashAlgorithm); | ||||
|         return ValueTask.FromResult(verified); | ||||
|     } | ||||
|  | ||||
|     public JsonWebKey ExportPublicJsonWebKey() | ||||
|     { | ||||
|         var jwk = new JsonWebKey | ||||
|         { | ||||
|             Kid = signingKey.Reference.KeyId, | ||||
|             Alg = signingKey.AlgorithmId, | ||||
|             Kty = JsonWebAlgorithmsKeyTypes.EllipticCurve, | ||||
|             Use = JsonWebKeyUseNames.Sig, | ||||
|             Crv = ResolveCurve(signingKey.AlgorithmId) | ||||
|         }; | ||||
|  | ||||
|         foreach (var op in DefaultKeyOps) | ||||
|         { | ||||
|             jwk.KeyOps.Add(op); | ||||
|         } | ||||
|  | ||||
|         jwk.X = Base64UrlEncoder.Encode(signingKey.PublicParameters.Q.X ?? Array.Empty<byte>()); | ||||
|         jwk.Y = Base64UrlEncoder.Encode(signingKey.PublicParameters.Q.Y ?? Array.Empty<byte>()); | ||||
|  | ||||
|         return jwk; | ||||
|     } | ||||
|  | ||||
|     private static HashAlgorithmName ResolveHashAlgorithm(string algorithmId) => | ||||
|         algorithmId switch | ||||
|         { | ||||
|             { } alg when string.Equals(alg, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA256, | ||||
|             { } alg when string.Equals(alg, SignatureAlgorithms.Es384, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA384, | ||||
|             { } alg when string.Equals(alg, SignatureAlgorithms.Es512, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA512, | ||||
|             _ => throw new InvalidOperationException($"Unsupported ECDSA signing algorithm '{algorithmId}'.") | ||||
|         }; | ||||
|  | ||||
|     private static string ResolveCurve(string algorithmId) | ||||
|         => algorithmId switch | ||||
|         { | ||||
|             { } alg when string.Equals(alg, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase) => JsonWebKeyECTypes.P256, | ||||
|             { } alg when string.Equals(alg, SignatureAlgorithms.Es384, StringComparison.OrdinalIgnoreCase) => JsonWebKeyECTypes.P384, | ||||
|             { } alg when string.Equals(alg, SignatureAlgorithms.Es512, StringComparison.OrdinalIgnoreCase) => JsonWebKeyECTypes.P521, | ||||
|             _ => throw new InvalidOperationException($"Unsupported ECDSA curve mapping for algorithm '{algorithmId}'.") | ||||
|         }; | ||||
| } | ||||
							
								
								
									
										45
									
								
								src/StellaOps.Cryptography/ICryptoSigner.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/StellaOps.Cryptography/ICryptoSigner.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents an asymmetric signer capable of producing and verifying detached signatures. | ||||
| /// </summary> | ||||
| public interface ICryptoSigner | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Gets the key identifier associated with this signer. | ||||
|     /// </summary> | ||||
|     string KeyId { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the signing algorithm identifier (e.g., ES256). | ||||
|     /// </summary> | ||||
|     string AlgorithmId { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Signs the supplied payload bytes. | ||||
|     /// </summary> | ||||
|     /// <param name="data">Payload to sign.</param> | ||||
|     /// <param name="cancellationToken">Cancellation token.</param> | ||||
|     /// <returns>Signature bytes.</returns> | ||||
|     ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Verifies a previously produced signature over the supplied payload bytes. | ||||
|     /// </summary> | ||||
|     /// <param name="data">Payload that was signed.</param> | ||||
|     /// <param name="signature">Signature to verify.</param> | ||||
|     /// <param name="cancellationToken">Cancellation token.</param> | ||||
|     /// <returns><c>true</c> when the signature is valid; otherwise <c>false</c>.</returns> | ||||
|     ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Exports the public representation of the key material as a JSON Web Key (JWK). | ||||
|     /// </summary> | ||||
|     /// <returns>Public JWK for distribution (no private components).</returns> | ||||
|     JsonWebKey ExportPublicJsonWebKey(); | ||||
| } | ||||
							
								
								
									
										23
									
								
								src/StellaOps.Cryptography/PasswordHashAlgorithms.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/StellaOps.Cryptography/PasswordHashAlgorithms.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Well-known identifiers for password hashing algorithms supported by StellaOps. | ||||
| /// </summary> | ||||
| public static class PasswordHashAlgorithms | ||||
| { | ||||
|     public const string Argon2id = "argon2id"; | ||||
|     public const string Pbkdf2Sha256 = "pbkdf2-sha256"; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Converts the enum value into the canonical algorithm identifier string. | ||||
|     /// </summary> | ||||
|     public static string ToAlgorithmId(this PasswordHashAlgorithm algorithm) => | ||||
|         algorithm switch | ||||
|         { | ||||
|             PasswordHashAlgorithm.Argon2id => Argon2id, | ||||
|             PasswordHashAlgorithm.Pbkdf2 => Pbkdf2Sha256, | ||||
|             _ => throw new ArgumentOutOfRangeException(nameof(algorithm), algorithm, "Unsupported password hash algorithm.") | ||||
|         }; | ||||
| } | ||||
							
								
								
									
										137
									
								
								src/StellaOps.Cryptography/Pbkdf2PasswordHasher.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								src/StellaOps.Cryptography/Pbkdf2PasswordHasher.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,137 @@ | ||||
| using System; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// PBKDF2-SHA256 password hasher for legacy credentials. | ||||
| /// </summary> | ||||
| public sealed class Pbkdf2PasswordHasher : IPasswordHasher | ||||
| { | ||||
|     private const int SaltLengthBytes = 16; | ||||
|     private const int HashLengthBytes = 32; | ||||
|     private const string Prefix = "PBKDF2"; | ||||
|  | ||||
|     public string Hash(string password, PasswordHashOptions options) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrEmpty(password); | ||||
|         ArgumentNullException.ThrowIfNull(options); | ||||
|  | ||||
|         if (options.Algorithm != PasswordHashAlgorithm.Pbkdf2) | ||||
|         { | ||||
|             throw new InvalidOperationException("Pbkdf2PasswordHasher only supports the PBKDF2 algorithm."); | ||||
|         } | ||||
|  | ||||
|         if (options.Iterations <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException("PBKDF2 requires a positive iteration count."); | ||||
|         } | ||||
|  | ||||
|         Span<byte> salt = stackalloc byte[SaltLengthBytes]; | ||||
|         RandomNumberGenerator.Fill(salt); | ||||
|  | ||||
|         var hash = Derive(password, salt, options.Iterations); | ||||
|  | ||||
|         var payload = new byte[1 + SaltLengthBytes + HashLengthBytes]; | ||||
|         payload[0] = 0x01; | ||||
|         salt.CopyTo(payload.AsSpan(1)); | ||||
|         hash.CopyTo(payload.AsSpan(1 + SaltLengthBytes)); | ||||
|  | ||||
|         return $"{Prefix}.{options.Iterations}.{Convert.ToBase64String(payload)}"; | ||||
|     } | ||||
|  | ||||
|     public bool Verify(string password, string encodedHash) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrEmpty(password); | ||||
|         ArgumentException.ThrowIfNullOrEmpty(encodedHash); | ||||
|  | ||||
|         if (!TryParse(encodedHash, out var parsed)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var computed = Derive(password, parsed.Salt, parsed.Iterations); | ||||
|         return CryptographicOperations.FixedTimeEquals(parsed.Hash, computed); | ||||
|     } | ||||
|  | ||||
|     public bool NeedsRehash(string encodedHash, PasswordHashOptions desired) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(desired); | ||||
|  | ||||
|         if (!TryParse(encodedHash, out var parsed)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (desired.Algorithm != PasswordHashAlgorithm.Pbkdf2) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return parsed.Iterations != desired.Iterations; | ||||
|     } | ||||
|  | ||||
|     private static byte[] Derive(string password, ReadOnlySpan<byte> salt, int iterations) | ||||
|     { | ||||
|         return Rfc2898DeriveBytes.Pbkdf2( | ||||
|             Encoding.UTF8.GetBytes(password), | ||||
|             salt.ToArray(), | ||||
|             iterations, | ||||
|             HashAlgorithmName.SHA256, | ||||
|             HashLengthBytes); | ||||
|     } | ||||
|  | ||||
|     private static bool TryParse(string encodedHash, out Pbkdf2Parameters parsed) | ||||
|     { | ||||
|         parsed = default; | ||||
|  | ||||
|         var parts = encodedHash.Split('.', StringSplitOptions.RemoveEmptyEntries); | ||||
|         if (parts.Length != 3 || !string.Equals(parts[0], Prefix, StringComparison.Ordinal)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!int.TryParse(parts[1], out var iterations) || iterations <= 0) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         byte[] payload; | ||||
|         try | ||||
|         { | ||||
|             payload = Convert.FromBase64String(parts[2]); | ||||
|         } | ||||
|         catch (FormatException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (payload.Length != 1 + SaltLengthBytes + HashLengthBytes || payload[0] != 0x01) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var salt = new byte[SaltLengthBytes]; | ||||
|         var hash = new byte[HashLengthBytes]; | ||||
|         Array.Copy(payload, 1, salt, 0, SaltLengthBytes); | ||||
|         Array.Copy(payload, 1 + SaltLengthBytes, hash, 0, HashLengthBytes); | ||||
|  | ||||
|         parsed = new Pbkdf2Parameters(iterations, salt, hash); | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private readonly struct Pbkdf2Parameters | ||||
|     { | ||||
|         public Pbkdf2Parameters(int iterations, byte[] salt, byte[] hash) | ||||
|         { | ||||
|             Iterations = iterations; | ||||
|             Salt = salt; | ||||
|             Hash = hash; | ||||
|         } | ||||
|  | ||||
|         public int Iterations { get; } | ||||
|         public byte[] Salt { get; } | ||||
|         public byte[] Hash { get; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										11
									
								
								src/StellaOps.Cryptography/SignatureAlgorithms.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/StellaOps.Cryptography/SignatureAlgorithms.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Known signature algorithm identifiers. | ||||
| /// </summary> | ||||
| public static class SignatureAlgorithms | ||||
| { | ||||
|     public const string Es256 = "ES256"; | ||||
|     public const string Es384 = "ES384"; | ||||
|     public const string Es512 = "ES512"; | ||||
| } | ||||
| @@ -6,4 +6,11 @@ | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <PropertyGroup Condition="'$(StellaOpsCryptoSodium)' == 'true'"> | ||||
|     <DefineConstants>$(DefineConstants);STELLAOPS_CRYPTO_SODIUM</DefineConstants> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" /> | ||||
|     <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.5.1" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
|   | ||||
| @@ -2,16 +2,23 @@ | ||||
|  | ||||
| | ID | Status | Owner | Description | Dependencies | Exit Criteria | | ||||
| |----|--------|-------|-------------|--------------|---------------| | ||||
| | SEC1.A | TODO | Security Guild | Introduce `Argon2idPasswordHasher` backed by Konscious defaults. Wire options into `StandardPluginOptions` (`PasswordHashOptions`) and `StellaOpsAuthorityOptions.Security.PasswordHashing`. | PLG3, CORE3 | ✅ Hashes emit PHC string `$argon2id$v=19$m=19456,t=2,p=1$...`; ✅ `NeedsRehash` promotes PBKDF2 → Argon2; ✅ Integration tests cover tamper, legacy rehash, perf p95 < 250 ms. | | ||||
| | SEC1.B | TODO | Security Guild | Add compile-time switch to enable libsodium/Core variants later (`STELLAOPS_CRYPTO_SODIUM`). Document build variable. | SEC1.A | ✅ Conditional compilation path compiles; ✅ README snippet in `docs/security/password-hashing.md`. | | ||||
| | SEC1.A | DONE (2025-10-11) | Security Guild | Introduce `Argon2idPasswordHasher` backed by Konscious defaults. Wire options into `StandardPluginOptions` (`PasswordHashOptions`) and `StellaOpsAuthorityOptions.Security.PasswordHashing`. | PLG3, CORE3 | ✅ Hashes emit PHC string `$argon2id$v=19$m=19456,t=2,p=1$...`; ✅ `NeedsRehash` promotes PBKDF2 → Argon2; ✅ Integration tests cover tamper, legacy rehash, perf p95 < 250 ms. | | ||||
| | SEC1.B | DONE (2025-10-12) | Security Guild | Add compile-time switch to enable libsodium/Core variants later (`STELLAOPS_CRYPTO_SODIUM`). Document build variable. | SEC1.A | ✅ Conditional compilation path compiles; ✅ README snippet in `docs/security/password-hashing.md`. | | ||||
| | SEC2.A | TODO | Security Guild + Core | Define audit event contract (`AuthEventRecord`) with subject/client/scope/IP/outcome/correlationId and PII tags. | CORE5–CORE7 | ✅ Contract shipped in `StellaOps.Cryptography` (or shared abstractions); ✅ Docs in `docs/security/audit-events.md`. | | ||||
| | SEC2.B | TODO | Security Guild | Emit audit records from OpenIddict handlers (password + client creds) and bootstrap APIs. Persist via `IAuthorityLoginAttemptStore`. | SEC2.A | ✅ Tests assert three flows (success/failure/lockout); ✅ Serilog output contains correlationId + PII tagging; ✅ Mongo store holds summary rows. | | ||||
| | SEC3.A | BLOCKED (CORE8) | Security Guild + Core | Configure ASP.NET rate limiter (`AddRateLimiter`) with fixed-window policy keyed by IP + `client_id`. Apply to `/token` and `/internal/*`. | CORE8 completion | ✅ Middleware active; ✅ Configurable limits via options; ✅ Integration test hits 429. | | ||||
| | SEC3.B | TODO | Security Guild | Document lockout + rate-limit tuning guidance and escalation thresholds. | SEC3.A | ✅ Section in `docs/security/rate-limits.md`; ✅ Includes SOC alert recommendations. | | ||||
| | SEC4.A | TODO | Security Guild + DevOps | Define revocation JSON schema (`revocation_bundle.schema.json`) and detached JWS workflow. | CORE9, OPS3 | ✅ Schema + sample committed; ✅ CLI command `stellaops auth revoke export` scaffolded with acceptance tests; ✅ Verification script + docs. | | ||||
| | SEC4.B | TODO | Security Guild | Integrate signing keys with crypto provider abstraction (initially ES256 via BCL). | SEC4.A, D5 | ✅ `ICryptoProvider.GetSigner` stub + default BCL signer; ✅ Unit tests verifying signature roundtrip. | | ||||
| | SEC5.A | TODO | Security Guild | Author STRIDE threat model (`docs/security/authority-threat-model.md`) covering token, bootstrap, revocation, CLI, plugin surfaces. | All SEC1–SEC4 in progress | ✅ DFDs + trust boundaries drawn; ✅ Risk table with owners/actions; ✅ Follow-up backlog issues created. | | ||||
| | D5.A | TODO | Security Guild | Flesh out `StellaOps.Cryptography` provider registry, policy, and DI helpers enabling sovereign crypto selection. | SEC1.A, SEC4.B | ✅ `ICryptoProviderRegistry` implementation with provider selection rules; ✅ `StellaOps.Cryptography.DependencyInjection` extensions; ✅ Tests covering fallback ordering. | | ||||
| | SEC4.A | DONE (2025-10-12) | Security Guild + DevOps | Define revocation JSON schema (`revocation_bundle.schema.json`) and detached JWS workflow. | CORE9, OPS3 | ✅ Schema + sample committed; ✅ CLI command `stellaops auth revoke export` scaffolded with acceptance tests; ✅ Verification script + docs. | | ||||
| | SEC4.B | DONE (2025-10-12) | Security Guild | Integrate signing keys with crypto provider abstraction (initially ES256 via BCL). | SEC4.A, D5 | ✅ `ICryptoProvider.GetSigner` stub + default BCL signer; ✅ Unit tests verifying signature roundtrip. | | ||||
| | SEC5.A | DONE (2025-10-12) | Security Guild | Author STRIDE threat model (`docs/security/authority-threat-model.md`) covering token, bootstrap, revocation, CLI, plugin surfaces. | All SEC1–SEC4 in progress | ✅ DFDs + trust boundaries drawn; ✅ Risk table with owners/actions; ✅ Follow-up backlog issues created. | | ||||
| | SEC5.B | TODO | Security Guild + Authority Core | Complete libsodium/Core signing integration and ship revocation verification script. | SEC4.A, SEC4.B, SEC4.HOST | ✅ libsodium/Core signing provider wired; ✅ `stellaops auth revoke verify` script published; ✅ Revocation docs updated with verification workflow. | | ||||
| | SEC5.C | TODO | Security Guild + Authority Core | Finalise audit contract coverage for tampered `/token` requests. | SEC2.A, SEC2.B | ✅ Tamper attempts logged with correlationId/PII tags; ✅ SOC runbook updated; ✅ Threat model status reviewed. | | ||||
| | SEC5.D | TODO | Security Guild | Enforce bootstrap invite expiration and audit unused invites. | SEC5.A | ✅ Bootstrap tokens auto-expire; ✅ Audit entries emitted for expiration/reuse attempts; ✅ Operator docs updated. | | ||||
| | SEC5.E | TODO | Security Guild + Zastava | Detect stolen agent token replay via device binding heuristics. | SEC4.A | ✅ Device binding guidance published; ✅ Alerting pipeline raises stale revocation acknowledgements; ✅ Tests cover replay detection. | | ||||
| | SEC5.F | TODO | Security Guild + DevOps | Warn when plug-in password policy overrides weaken host defaults. | SEC1.A, PLG3 | ✅ Static analyser flags weaker overrides; ✅ Runtime warning surfaced; ✅ Docs call out mitigation. | | ||||
| | SEC5.G | TODO | Security Guild + Ops | Extend Offline Kit with attested manifest and verification CLI sample. | OPS3 | ✅ Offline Kit build signs manifest with detached JWS; ✅ Verification CLI documented; ✅ Supply-chain attestation recorded. | | ||||
| | SEC5.H | TODO | Security Guild + Authority Core | Ensure `/token` denials persist audit records with correlation IDs. | SEC2.A, SEC2.B | ✅ Audit store captures denials; ✅ Tests cover success/failure/lockout; ✅ Threat model review updated. | | ||||
| | D5.A | DONE (2025-10-12) | Security Guild | Flesh out `StellaOps.Cryptography` provider registry, policy, and DI helpers enabling sovereign crypto selection. | SEC1.A, SEC4.B | ✅ `ICryptoProviderRegistry` implementation with provider selection rules; ✅ `StellaOps.Cryptography.DependencyInjection` extensions; ✅ Tests covering fallback ordering. | | ||||
|  | ||||
| ## Notes | ||||
| - Target Argon2 parameters follow OWASP Cheat Sheet (memory ≈ 19 MiB, iterations 2, parallelism 1). Allow overrides via configuration. | ||||
|   | ||||
| @@ -104,21 +104,32 @@ public sealed class VulnListJsonExportPathResolverTests | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void ResolvesByProvenanceFallback() | ||||
|     { | ||||
|         var provenance = new[] { new AdvisoryProvenance("wolfi", "map", "", DefaultPublished) }; | ||||
|         var advisory = CreateAdvisory("WOLFI-2024-0001", provenance: provenance); | ||||
|         var resolver = new VulnListJsonExportPathResolver(); | ||||
|         var path = resolver.GetRelativePath(advisory); | ||||
|  | ||||
|         Assert.Equal(Path.Combine("wolfi", "WOLFI-2024-0001.json"), path); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void DefaultsToMiscWhenUnmapped() | ||||
|     { | ||||
|         var advisory = CreateAdvisory("CUSTOM-2024-99"); | ||||
|         var resolver = new VulnListJsonExportPathResolver(); | ||||
|     public void ResolvesByProvenanceFallback() | ||||
|     { | ||||
|         var provenance = new[] { new AdvisoryProvenance("wolfi", "map", "", DefaultPublished) }; | ||||
|         var advisory = CreateAdvisory("WOLFI-2024-0001", provenance: provenance); | ||||
|         var resolver = new VulnListJsonExportPathResolver(); | ||||
|         var path = resolver.GetRelativePath(advisory); | ||||
|  | ||||
|         Assert.Equal(Path.Combine("wolfi", "WOLFI-2024-0001.json"), path); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void ResolvesAcscByProvenance() | ||||
|     { | ||||
|         var provenance = new[] { new AdvisoryProvenance("acsc", "mapping", "acsc-2025-010", DefaultPublished) }; | ||||
|         var advisory = CreateAdvisory("acsc-2025-010", provenance: provenance); | ||||
|         var resolver = new VulnListJsonExportPathResolver(); | ||||
|         var path = resolver.GetRelativePath(advisory); | ||||
|  | ||||
|         Assert.Equal(Path.Combine("cert", "au", "acsc-2025-010.json"), path); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void DefaultsToMiscWhenUnmapped() | ||||
|     { | ||||
|         var advisory = CreateAdvisory("CUSTOM-2024-99"); | ||||
|         var resolver = new VulnListJsonExportPathResolver(); | ||||
|         var path = resolver.GetRelativePath(advisory); | ||||
|  | ||||
|         Assert.Equal(Path.Combine("misc", "CUSTOM-2024-99.json"), path); | ||||
|   | ||||
| @@ -43,15 +43,16 @@ public sealed class VulnListJsonExportPathResolver : IJsonExportPathResolver | ||||
|         ["alpine"] = new[] { "alpine" }, | ||||
|         ["wolfi"] = new[] { "wolfi" }, | ||||
|         ["chainguard"] = new[] { "chainguard" }, | ||||
|         ["cert-fr"] = new[] { "cert", "fr" }, | ||||
|         ["cert-in"] = new[] { "cert", "in" }, | ||||
|         ["cert-cc"] = new[] { "cert", "cc" }, | ||||
|         ["cert-bund"] = new[] { "cert", "bund" }, | ||||
|         ["cisa"] = new[] { "ics", "cisa" }, | ||||
|         ["ics-cisa"] = new[] { "ics", "cisa" }, | ||||
|         ["ics-kaspersky"] = new[] { "ics", "kaspersky" }, | ||||
|         ["kaspersky"] = new[] { "ics", "kaspersky" }, | ||||
|     }; | ||||
|         ["cert-fr"] = new[] { "cert", "fr" }, | ||||
|         ["cert-in"] = new[] { "cert", "in" }, | ||||
|         ["cert-cc"] = new[] { "cert", "cc" }, | ||||
|         ["cert-bund"] = new[] { "cert", "bund" }, | ||||
|         ["acsc"] = new[] { "cert", "au" }, | ||||
|         ["cisa"] = new[] { "ics", "cisa" }, | ||||
|         ["ics-cisa"] = new[] { "ics", "cisa" }, | ||||
|         ["ics-kaspersky"] = new[] { "ics", "kaspersky" }, | ||||
|         ["kaspersky"] = new[] { "ics", "kaspersky" }, | ||||
|     }; | ||||
|  | ||||
|     private static readonly Dictionary<string, string> GhsaEcosystemMap = new(StringComparer.OrdinalIgnoreCase) | ||||
|     { | ||||
|   | ||||
| @@ -228,9 +228,252 @@ public sealed class AdvisoryPrecedenceMergerTests | ||||
|         Assert.Contains(merged.Credits, credit => credit.Provenance.Source == "ghsa"); | ||||
|         Assert.Contains(merged.Credits, credit => credit.Provenance.Source == "osv"); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Merge_RespectsConfiguredPrecedenceOverrides() | ||||
|  | ||||
|     [Fact] | ||||
|     public void Merge_AcscActsAsEnrichmentSource() | ||||
|     { | ||||
|         var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero)); | ||||
|         var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider); | ||||
|  | ||||
|         var vendorDocumentProvenance = new AdvisoryProvenance( | ||||
|             source: "vndr-cisco", | ||||
|             kind: "document", | ||||
|             value: "https://vendor.example/advisories/router-critical", | ||||
|             recordedAt: timeProvider.GetUtcNow(), | ||||
|             fieldMask: new[] { ProvenanceFieldMasks.Advisory }); | ||||
|  | ||||
|         var vendorReference = new AdvisoryReference( | ||||
|             "https://vendor.example/advisories/router-critical", | ||||
|             kind: "advisory", | ||||
|             sourceTag: "vendor", | ||||
|             summary: "Vendor advisory", | ||||
|             provenance: new AdvisoryProvenance("vndr-cisco", "reference", "https://vendor.example/advisories/router-critical", timeProvider.GetUtcNow())); | ||||
|  | ||||
|         var vendorPackage = new AffectedPackage( | ||||
|             AffectedPackageTypes.Vendor, | ||||
|             "ExampleCo Router X", | ||||
|             platform: null, | ||||
|             versionRanges: Array.Empty<AffectedVersionRange>(), | ||||
|             statuses: Array.Empty<AffectedPackageStatus>(), | ||||
|             normalizedVersions: Array.Empty<NormalizedVersionRule>(), | ||||
|             provenance: new[] { vendorDocumentProvenance }); | ||||
|  | ||||
|         var vendor = new Advisory( | ||||
|             advisoryKey: "acsc-2025-010", | ||||
|             title: "Vendor Critical Router Advisory", | ||||
|             summary: "Vendor-confirmed exploit.", | ||||
|             language: "en", | ||||
|             published: new DateTimeOffset(2025, 10, 11, 23, 0, 0, TimeSpan.Zero), | ||||
|             modified: new DateTimeOffset(2025, 10, 11, 23, 30, 0, TimeSpan.Zero), | ||||
|             severity: "critical", | ||||
|             exploitKnown: false, | ||||
|             aliases: new[] { "VENDOR-2025-010" }, | ||||
|             references: new[] { vendorReference }, | ||||
|             affectedPackages: new[] { vendorPackage }, | ||||
|             cvssMetrics: Array.Empty<CvssMetric>(), | ||||
|             provenance: new[] { vendorDocumentProvenance }); | ||||
|  | ||||
|         var acscDocumentProvenance = new AdvisoryProvenance( | ||||
|             source: "acsc", | ||||
|             kind: "document", | ||||
|             value: "https://origin.example/feeds/alerts/rss", | ||||
|             recordedAt: timeProvider.GetUtcNow(), | ||||
|             fieldMask: new[] { ProvenanceFieldMasks.Advisory }); | ||||
|  | ||||
|         var acscReference = new AdvisoryReference( | ||||
|             "https://origin.example/advisories/router-critical", | ||||
|             kind: "advisory", | ||||
|             sourceTag: "acsc", | ||||
|             summary: "ACSC alert", | ||||
|             provenance: new AdvisoryProvenance("acsc", "reference", "https://origin.example/advisories/router-critical", timeProvider.GetUtcNow())); | ||||
|  | ||||
|         var acscPackage = new AffectedPackage( | ||||
|             AffectedPackageTypes.Vendor, | ||||
|             "ExampleCo Router X", | ||||
|             platform: null, | ||||
|             versionRanges: Array.Empty<AffectedVersionRange>(), | ||||
|             statuses: Array.Empty<AffectedPackageStatus>(), | ||||
|             normalizedVersions: Array.Empty<NormalizedVersionRule>(), | ||||
|             provenance: new[] { acscDocumentProvenance }); | ||||
|  | ||||
|         var acsc = new Advisory( | ||||
|             advisoryKey: "acsc-2025-010", | ||||
|             title: "ACSC Router Alert", | ||||
|             summary: "ACSC recommends installing vendor update.", | ||||
|             language: "en", | ||||
|             published: new DateTimeOffset(2025, 10, 12, 0, 0, 0, TimeSpan.Zero), | ||||
|             modified: null, | ||||
|             severity: "medium", | ||||
|             exploitKnown: false, | ||||
|             aliases: new[] { "ACSC-2025-010" }, | ||||
|             references: new[] { acscReference }, | ||||
|             affectedPackages: new[] { acscPackage }, | ||||
|             cvssMetrics: Array.Empty<CvssMetric>(), | ||||
|             provenance: new[] { acscDocumentProvenance }); | ||||
|  | ||||
|         var merged = merger.Merge(new[] { acsc, vendor }); | ||||
|  | ||||
|         Assert.Equal("critical", merged.Severity); // ACSC must not override vendor severity | ||||
|         Assert.Equal("Vendor-confirmed exploit.", merged.Summary); | ||||
|  | ||||
|         Assert.Contains("ACSC-2025-010", merged.Aliases); | ||||
|         Assert.Contains("VENDOR-2025-010", merged.Aliases); | ||||
|  | ||||
|         Assert.Contains(merged.References, reference => reference.SourceTag == "vendor" && reference.Url == vendorReference.Url); | ||||
|         Assert.Contains(merged.References, reference => reference.SourceTag == "acsc" && reference.Url == acscReference.Url); | ||||
|  | ||||
|         var enrichedPackage = Assert.Single(merged.AffectedPackages, package => package.Identifier == "ExampleCo Router X"); | ||||
|         Assert.Contains(enrichedPackage.Provenance, provenance => provenance.Source == "vndr-cisco"); | ||||
|         Assert.Contains(enrichedPackage.Provenance, provenance => provenance.Source == "acsc"); | ||||
|  | ||||
|         Assert.Contains(merged.Provenance, provenance => provenance.Source == "acsc"); | ||||
|         Assert.Contains(merged.Provenance, provenance => provenance.Source == "vndr-cisco"); | ||||
|         Assert.Contains(merged.Provenance, provenance => provenance.Source == "merge" && (provenance.Value?.Contains("acsc", StringComparison.OrdinalIgnoreCase) ?? false)); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Merge_RecordsNormalizedRuleMetrics() | ||||
|     { | ||||
|         var now = new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero); | ||||
|         var timeProvider = new FakeTimeProvider(now); | ||||
|         var merger = new AdvisoryPrecedenceMerger(new AffectedPackagePrecedenceResolver(), timeProvider); | ||||
|         using var metrics = new MetricCollector("StellaOps.Feedser.Merge"); | ||||
|  | ||||
|         var normalizedRule = new NormalizedVersionRule( | ||||
|             NormalizedVersionSchemes.SemVer, | ||||
|             NormalizedVersionRuleTypes.Range, | ||||
|             min: "1.0.0", | ||||
|             minInclusive: true, | ||||
|             max: "2.0.0", | ||||
|             maxInclusive: false, | ||||
|             notes: "ghsa:GHSA-xxxx-yyyy"); | ||||
|  | ||||
|         var ghsaProvenance = new AdvisoryProvenance("ghsa", "package", "pkg:npm/example", now); | ||||
|         var ghsaPackage = new AffectedPackage( | ||||
|             AffectedPackageTypes.SemVer, | ||||
|             "pkg:npm/example", | ||||
|             platform: null, | ||||
|             versionRanges: new[] | ||||
|             { | ||||
|                 new AffectedVersionRange( | ||||
|                     NormalizedVersionSchemes.SemVer, | ||||
|                     "1.0.0", | ||||
|                     "2.0.0", | ||||
|                     null, | ||||
|                     ">= 1.0.0 < 2.0.0", | ||||
|                     ghsaProvenance) | ||||
|             }, | ||||
|             statuses: Array.Empty<AffectedPackageStatus>(), | ||||
|             provenance: new[] | ||||
|             { | ||||
|                 ghsaProvenance, | ||||
|             }, | ||||
|             normalizedVersions: new[] { normalizedRule }); | ||||
|  | ||||
|         var nvdPackage = new AffectedPackage( | ||||
|             AffectedPackageTypes.SemVer, | ||||
|             "pkg:npm/example", | ||||
|             platform: null, | ||||
|             versionRanges: new[] | ||||
|             { | ||||
|                 new AffectedVersionRange( | ||||
|                     NormalizedVersionSchemes.SemVer, | ||||
|                     "1.0.0", | ||||
|                     "2.0.0", | ||||
|                     null, | ||||
|                     ">= 1.0.0 < 2.0.0", | ||||
|                     new AdvisoryProvenance("nvd", "cpe_match", "pkg:npm/example", now)) | ||||
|             }, | ||||
|             statuses: Array.Empty<AffectedPackageStatus>(), | ||||
|             provenance: new[] | ||||
|             { | ||||
|                 new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-7000", now), | ||||
|             }, | ||||
|             normalizedVersions: Array.Empty<NormalizedVersionRule>()); | ||||
|  | ||||
|         var nvdExclusivePackage = new AffectedPackage( | ||||
|             AffectedPackageTypes.SemVer, | ||||
|             "pkg:npm/another", | ||||
|             platform: null, | ||||
|             versionRanges: new[] | ||||
|             { | ||||
|                 new AffectedVersionRange( | ||||
|                     NormalizedVersionSchemes.SemVer, | ||||
|                     "3.0.0", | ||||
|                     null, | ||||
|                     null, | ||||
|                     ">= 3.0.0", | ||||
|                     new AdvisoryProvenance("nvd", "cpe_match", "pkg:npm/another", now)) | ||||
|             }, | ||||
|             statuses: Array.Empty<AffectedPackageStatus>(), | ||||
|             provenance: new[] | ||||
|             { | ||||
|                 new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-7000", now), | ||||
|             }, | ||||
|             normalizedVersions: Array.Empty<NormalizedVersionRule>()); | ||||
|  | ||||
|         var ghsaAdvisory = new Advisory( | ||||
|             "CVE-2025-7000", | ||||
|             "GHSA advisory", | ||||
|             "GHSA summary", | ||||
|             "en", | ||||
|             now, | ||||
|             now, | ||||
|             "high", | ||||
|             exploitKnown: false, | ||||
|             aliases: new[] { "CVE-2025-7000", "GHSA-xxxx-yyyy" }, | ||||
|             credits: Array.Empty<AdvisoryCredit>(), | ||||
|             references: Array.Empty<AdvisoryReference>(), | ||||
|             affectedPackages: new[] { ghsaPackage }, | ||||
|             cvssMetrics: Array.Empty<CvssMetric>(), | ||||
|             provenance: new[] | ||||
|             { | ||||
|                 new AdvisoryProvenance("ghsa", "document", "https://github.com/advisories/GHSA-xxxx-yyyy", now), | ||||
|             }); | ||||
|  | ||||
|         var nvdAdvisory = new Advisory( | ||||
|             "CVE-2025-7000", | ||||
|             "NVD entry", | ||||
|             "NVD summary", | ||||
|             "en", | ||||
|             now, | ||||
|             now, | ||||
|             "high", | ||||
|             exploitKnown: false, | ||||
|             aliases: new[] { "CVE-2025-7000" }, | ||||
|             credits: Array.Empty<AdvisoryCredit>(), | ||||
|             references: Array.Empty<AdvisoryReference>(), | ||||
|             affectedPackages: new[] { nvdPackage, nvdExclusivePackage }, | ||||
|             cvssMetrics: Array.Empty<CvssMetric>(), | ||||
|             provenance: new[] | ||||
|             { | ||||
|                 new AdvisoryProvenance("nvd", "document", "https://nvd.nist.gov/vuln/detail/CVE-2025-7000", now), | ||||
|             }); | ||||
|  | ||||
|         var merged = merger.Merge(new[] { nvdAdvisory, ghsaAdvisory }); | ||||
|         Assert.Equal(2, merged.AffectedPackages.Length); | ||||
|  | ||||
|         var normalizedPackage = Assert.Single(merged.AffectedPackages, pkg => pkg.Identifier == "pkg:npm/example"); | ||||
|         Assert.Single(normalizedPackage.NormalizedVersions); | ||||
|  | ||||
|         var missingPackage = Assert.Single(merged.AffectedPackages, pkg => pkg.Identifier == "pkg:npm/another"); | ||||
|         Assert.Empty(missingPackage.NormalizedVersions); | ||||
|         Assert.NotEmpty(missingPackage.VersionRanges); | ||||
|  | ||||
|         var normalizedMeasurements = metrics.Measurements.Where(m => m.Name == "feedser.merge.normalized_rules").ToList(); | ||||
|         Assert.Contains(normalizedMeasurements, measurement => | ||||
|             measurement.Value == 1 | ||||
|                 && measurement.Tags.Any(tag => string.Equals(tag.Key, "scheme", StringComparison.Ordinal) && string.Equals(tag.Value?.ToString(), "semver", StringComparison.Ordinal)) | ||||
|                 && measurement.Tags.Any(tag => string.Equals(tag.Key, "package_type", StringComparison.Ordinal) && string.Equals(tag.Value?.ToString(), "semver", StringComparison.Ordinal))); | ||||
|  | ||||
|         var missingMeasurements = metrics.Measurements.Where(m => m.Name == "feedser.merge.normalized_rules_missing").ToList(); | ||||
|         var missingMeasurement = Assert.Single(missingMeasurements); | ||||
|         Assert.Equal(1, missingMeasurement.Value); | ||||
|         Assert.Contains(missingMeasurement.Tags, tag => string.Equals(tag.Key, "package_type", StringComparison.Ordinal) && string.Equals(tag.Value?.ToString(), "semver", StringComparison.Ordinal)); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Merge_RespectsConfiguredPrecedenceOverrides() | ||||
|     { | ||||
|         var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.Zero)); | ||||
|         var options = new AdvisoryPrecedenceOptions | ||||
|   | ||||
| @@ -23,7 +23,10 @@ Until these blocks land, connectors should stage changes behind a feature flag o | ||||
| | CertBund | BE-Conn-CERTBUND | All tasks still TODO | Ensure canonical mapper emits vendor range primitives plus normalized rules for product firmware. | Needs language/localisation guidance; coordinate with Localization WG for deterministic casing. | | ||||
| | CertCc | BE-Conn-CERTCC | Fetch in progress, mapping TODO | Map VINCE vendor/product data into `RangePrimitives` with `certcc.vendor` extensions; build normalized SemVer ranges when version strings surface. | Follow up on 2025-10-14 to review VINCE payload examples and confirm builder requirements. | | ||||
| | Cve | BE-Conn-CVE | Mapping/tests DONE (legacy SemVer) | Refactor `CveMapper` to call the shared builder and populate `NormalizedVersions` + provenance notes once models land. | Prepare MR behind `ENABLE_NORMALIZED_VERSIONS` flag; regression fixtures already cover version ranges—extend snapshots to cover rule arrays. | | ||||
| | Ghsa | BE-Conn-GHSA | Mapping/tests DONE; normalized rule task TODO | Switch to `SemVerRangeRuleBuilder`, populate `NormalizedVersions`, and extend fixtures with rule/provenance fields. | Target code review window 2025-10-15; needs builder API from Normalization team by 2025-10-13. | | ||||
| | Ghsa | BE-Conn-GHSA | Normalized rules emitted (2025-10-11) | Maintain SemVer builder integration; share regression diffs if schema shifts occur. | Fixtures refreshed with `ghsa:{identifier}` notes; OSV rollout next in queue—await connector handoff update. | | ||||
| | Osv | BE-Conn-OSV | Normalized rules emitted (2025-10-11) | Keep SemVer builder wiring current; extend notes if new ecosystems appear. | npm/PyPI parity snapshots updated with `osv:{ecosystem}:{advisoryId}:{identifier}` notes; merge analytics notified. | | ||||
| | Nvd | BE-Conn-NVD | Normalized rules emitted (2025-10-11) | Maintain SemVer coverage for ecosystem ranges; keep notes aligned with CVE IDs. | CPE ranges now emit semver primitives when versions parse; fixtures refreshed, report sent to FEEDMERGE-COORD-02-900. | | ||||
| | Cve | BE-Conn-CVE | Normalized rules emitted (2025-10-11) | Maintain SemVer notes for vendor ecosystems; backfill additional fixture coverage as CVE payloads expand. | Connector outputs `cve:{cveId}:{identifier}` notes; npm parity test fixtures updated and merge ping acknowledged. | | ||||
| | Ics.Cisa | BE-Conn-ICS-CISA | All tasks TODO | When defining product schema, plan for SemVer or vendor version rules (many advisories use firmware revisions). | Gather sample advisories and confirm whether ranges are SemVer or vendor-specific so we can introduce scheme identifiers early. | | ||||
| | Kisa | BE-Conn-KISA | All tasks TODO | Ensure DTO parsing captures version strings despite localisation; feed into normalized rule builder once ready. | Requires translation samples; request help from Localization WG before mapper implementation. | | ||||
| | Ru.Bdu | BE-Conn-BDU | All tasks TODO | Map product releases into normalized rules; add provenance notes referencing BDU advisory identifiers. | Verify we have UTF-8 safe handling in builder; share sample sanitized inputs. | | ||||
| @@ -32,6 +35,53 @@ Until these blocks land, connectors should stage changes behind a feature flag o | ||||
| | Vndr.Cisco | BE-Conn-Cisco | All tasks TODO | When parser lands, normalise IOS/ASA version strings into SemVer-style or vendor-specific ranges and supply normalized arrays. | Identify whether ranges require custom comparer (maybe `ios.semver` style); escalate to Models if new scheme required. | | ||||
| | Vndr.Msrc | BE-Conn-MSRC | All tasks TODO | Canonical mapper must output product/build coverage as normalized rules (likely `msrc.patch` scheme) with provenance referencing KB IDs. | Sync with Models on adding scheme identifiers for MSRC packages; plan fixture coverage for monthly rollups. | | ||||
|  | ||||
| ## Storage alignment quick reference (2025-10-11) | ||||
| - `NormalizedVersionDocumentFactory` copies each `NormalizedVersionRule` into Mongo with the shape `{ packageId, packageType, scheme, type, style, min, minInclusive, max, maxInclusive, value, notes, decisionReason, constraint, source, recordedAt }`. `style` is currently a direct echo of `type` but reserved for future vendor comparers—no connector action required. | ||||
| - `constraint` is hydrated only when `NormalizedVersionRule` matches a legacy `VersionRange` primitive. Preserve `notes` (e.g., `nvd:cve-2025-1234`) so storage can join rules back to their provenance and carry decision reasoning. | ||||
| - Valid `scheme` values today are `semver`, `nevra`, and `evr`. Raise a Models ticket before introducing additional scheme identifiers (e.g., `apple.build`, `ios.semver`). | ||||
| - Prefer normalized `type` tokens from `NormalizedVersionRuleTypes` (`range`, `exact`, `lt`, `lte`, `gt`, `gte`). Builders already coerce casing/format—avoid custom strings. | ||||
| - Ensure `AffectedPackage.Identifier`/`Type` and `Provenance` collections are populated; storage falls back to package-level provenance if range-level data is absent, but loses traceability if both are empty. | ||||
| - Snapshot of an emitted document (SemVer range) for reference: | ||||
|   ```json | ||||
|   { | ||||
|     "packageId": "pkg:npm/example", | ||||
|     "packageType": "npm", | ||||
|     "scheme": "semver", | ||||
|     "type": "range", | ||||
|     "style": "range", | ||||
|     "min": "1.2.3", | ||||
|     "minInclusive": true, | ||||
|     "max": "2.0.0", | ||||
|     "maxInclusive": false, | ||||
|     "value": null, | ||||
|     "notes": "ghsa:GHSA-xxxx-yyyy", | ||||
|     "decisionReason": "ghsa-precedence-over-nvd", | ||||
|     "constraint": ">= 1.2.3 < 2.0.0", | ||||
|     "source": "ghsa", | ||||
|     "recordedAt": "2025-10-11T00:00:00Z" | ||||
|   } | ||||
|   ``` | ||||
| - For distro sources emitting NEVRA/EVR primitives, expect the same envelope with `scheme` swapped accordingly. Example (`nevra`): | ||||
|   ```json | ||||
|   { | ||||
|     "packageId": "bash", | ||||
|     "packageType": "rpm", | ||||
|     "scheme": "nevra", | ||||
|     "type": "range", | ||||
|     "style": "range", | ||||
|     "min": "0:4.4.18-2.el7", | ||||
|     "minInclusive": true, | ||||
|     "max": "0:4.4.20-1.el7", | ||||
|     "maxInclusive": false, | ||||
|     "value": null, | ||||
|     "notes": "redhat:RHSA-2025:1234", | ||||
|     "decisionReason": "rhel-priority-over-nvd", | ||||
|     "constraint": "<= 0:4.4.20-1.el7", | ||||
|     "source": "redhat", | ||||
|     "recordedAt": "2025-10-11T00:00:00Z" | ||||
|   } | ||||
|   ``` | ||||
|  | ||||
| ## Immediate next steps | ||||
| - Normalization team to share draft `SemVerRangeRuleBuilder` API by **2025-10-13** for review; Merge will circulate feedback within 24 hours. | ||||
| - Connector owners to prepare fixture pull requests demonstrating sample normalized rule arrays (even if feature-flagged) by **2025-10-17**. | ||||
| @@ -39,6 +89,7 @@ Until these blocks land, connectors should stage changes behind a feature flag o | ||||
| - Schedule held for **2025-10-14 14:00 UTC** to review the CERT/CC staging VINCE advisory sample once `enableDetailMapping` is flipped; capture findings in `#feedser-merge` with snapshot diffs. | ||||
|  | ||||
| ## Tracking & follow-up | ||||
| - Capture connector progress updates in stand-ups twice per week; link PRs/issues back to this document. | ||||
| - Capture connector progress updates in stand-ups twice per week; link PRs/issues back to this document and the rollout dashboard (`docs/dev/normalized_versions_rollout.md`). | ||||
| - Monitor merge counters `feedser.merge.normalized_rules` and `feedser.merge.normalized_rules_missing` to spot advisories that still lack normalized arrays after precedence merge. | ||||
| - When a connector is ready to emit normalized rules, update its module `TASKS.md` status and ping Merge in `#feedser-merge` with fixture diff screenshots. | ||||
| - If new schemes or comparer logic is required (e.g., Cisco IOS), open a Models issue referencing `FEEDMODELS-SCHEMA-02-900` before implementing. | ||||
|   | ||||
| @@ -31,10 +31,20 @@ public sealed class AdvisoryPrecedenceMerger | ||||
|         unit: "count", | ||||
|         description: "Number of affected-package range overrides performed during precedence merge."); | ||||
|  | ||||
|     private static readonly Counter<long> ConflictCounter = MergeMeter.CreateCounter<long>( | ||||
|         "feedser.merge.conflicts", | ||||
|         unit: "count", | ||||
|         description: "Number of precedence conflicts detected (severity, rank ties, etc.)."); | ||||
|     private static readonly Counter<long> ConflictCounter = MergeMeter.CreateCounter<long>( | ||||
|         "feedser.merge.conflicts", | ||||
|         unit: "count", | ||||
|         description: "Number of precedence conflicts detected (severity, rank ties, etc.)."); | ||||
|  | ||||
|     private static readonly Counter<long> NormalizedRuleCounter = MergeMeter.CreateCounter<long>( | ||||
|         "feedser.merge.normalized_rules", | ||||
|         unit: "rule", | ||||
|         description: "Number of normalized version rules retained after precedence merge."); | ||||
|  | ||||
|     private static readonly Counter<long> MissingNormalizedRuleCounter = MergeMeter.CreateCounter<long>( | ||||
|         "feedser.merge.normalized_rules_missing", | ||||
|         unit: "package", | ||||
|         description: "Number of affected packages with version ranges but no normalized rules."); | ||||
|  | ||||
|     private static readonly Action<ILogger, MergeOverrideAudit, Exception?> OverrideLogged = LoggerMessage.Define<MergeOverrideAudit>( | ||||
|         LogLevel.Information, | ||||
| @@ -151,8 +161,9 @@ public sealed class AdvisoryPrecedenceMerger | ||||
|             .Distinct() | ||||
|             .ToArray(); | ||||
|  | ||||
|         var packageResult = _packageResolver.Merge(ordered.SelectMany(entry => entry.Advisory.AffectedPackages)); | ||||
|         var affectedPackages = packageResult.Packages; | ||||
|         var packageResult = _packageResolver.Merge(ordered.SelectMany(entry => entry.Advisory.AffectedPackages)); | ||||
|         RecordNormalizedRuleMetrics(packageResult.Packages); | ||||
|         var affectedPackages = packageResult.Packages; | ||||
|         var cvssMetrics = ordered | ||||
|             .SelectMany(entry => entry.Advisory.CvssMetrics) | ||||
|             .Distinct() | ||||
| @@ -186,13 +197,13 @@ public sealed class AdvisoryPrecedenceMerger | ||||
|         LogPackageOverrides(advisoryKey, packageResult.Overrides); | ||||
|         RecordFieldConflicts(advisoryKey, ordered); | ||||
|  | ||||
|         return new Advisory( | ||||
|             advisoryKey, | ||||
|             title, | ||||
|             summary, | ||||
|             language, | ||||
|             published, | ||||
|             modified, | ||||
|         return new Advisory( | ||||
|             advisoryKey, | ||||
|             title, | ||||
|             summary, | ||||
|             language, | ||||
|             published, | ||||
|             modified, | ||||
|             severity, | ||||
|             exploitKnown, | ||||
|             aliases, | ||||
| @@ -201,13 +212,49 @@ public sealed class AdvisoryPrecedenceMerger | ||||
|             affectedPackages, | ||||
|             cvssMetrics, | ||||
|             provenance); | ||||
|     } | ||||
|  | ||||
|     private string? PickString(IEnumerable<AdvisoryEntry> ordered, Func<Advisory, string?> selector) | ||||
|     { | ||||
|         foreach (var entry in ordered) | ||||
|         { | ||||
|             var value = selector(entry.Advisory); | ||||
|     } | ||||
|  | ||||
|     private static void RecordNormalizedRuleMetrics(IReadOnlyList<AffectedPackage> packages) | ||||
|     { | ||||
|         if (packages.Count == 0) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         foreach (var package in packages) | ||||
|         { | ||||
|             var packageType = package.Type ?? string.Empty; | ||||
|             var normalizedVersions = package.NormalizedVersions; | ||||
|             if (normalizedVersions.Length > 0) | ||||
|             { | ||||
|                 foreach (var rule in normalizedVersions) | ||||
|                 { | ||||
|                     var tags = new KeyValuePair<string, object?>[] | ||||
|                     { | ||||
|                         new("package_type", packageType), | ||||
|                         new("scheme", rule.Scheme ?? string.Empty), | ||||
|                     }; | ||||
|  | ||||
|                     NormalizedRuleCounter.Add(1, tags); | ||||
|                 } | ||||
|             } | ||||
|             else if (package.VersionRanges.Length > 0) | ||||
|             { | ||||
|                 var tags = new KeyValuePair<string, object?>[] | ||||
|                 { | ||||
|                     new("package_type", packageType), | ||||
|                 }; | ||||
|  | ||||
|                 MissingNormalizedRuleCounter.Add(1, tags); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private string? PickString(IEnumerable<AdvisoryEntry> ordered, Func<Advisory, string?> selector) | ||||
|     { | ||||
|         foreach (var entry in ordered) | ||||
|         { | ||||
|             var value = selector(entry.Advisory); | ||||
|             if (!string.IsNullOrWhiteSpace(value)) | ||||
|             { | ||||
|                 return value.Trim(); | ||||
|   | ||||
| @@ -60,15 +60,21 @@ public sealed class AffectedPackagePrecedenceResolver | ||||
|                 .Distinct() | ||||
|                 .ToImmutableArray(); | ||||
|  | ||||
|             var statuses = ordered | ||||
|                 .SelectMany(static entry => entry.Package.Statuses) | ||||
|                 .Distinct(AffectedPackageStatusEqualityComparer.Instance) | ||||
|                 .ToImmutableArray(); | ||||
|  | ||||
|             foreach (var candidate in ordered.Skip(1)) | ||||
|             { | ||||
|                 if (candidate.Package.VersionRanges.Length == 0) | ||||
|                 { | ||||
|             var statuses = ordered | ||||
|                 .SelectMany(static entry => entry.Package.Statuses) | ||||
|                 .Distinct(AffectedPackageStatusEqualityComparer.Instance) | ||||
|                 .ToImmutableArray(); | ||||
|  | ||||
|             var normalizedRules = ordered | ||||
|                 .SelectMany(static entry => entry.Package.NormalizedVersions) | ||||
|                 .Distinct(NormalizedVersionRuleEqualityComparer.Instance) | ||||
|                 .OrderBy(static rule => rule, NormalizedVersionRuleComparer.Instance) | ||||
|                 .ToImmutableArray(); | ||||
|  | ||||
|             foreach (var candidate in ordered.Skip(1)) | ||||
|             { | ||||
|                 if (candidate.Package.VersionRanges.Length == 0) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
| @@ -84,16 +90,17 @@ public sealed class AffectedPackagePrecedenceResolver | ||||
|                     candidate.Package.VersionRanges.Length)); | ||||
|             } | ||||
|  | ||||
|             var merged = new AffectedPackage( | ||||
|                 primary.Type, | ||||
|                 primary.Identifier, | ||||
|                 string.IsNullOrWhiteSpace(primary.Platform) ? null : primary.Platform, | ||||
|                 primary.Package.VersionRanges, | ||||
|                 statuses, | ||||
|                 provenance); | ||||
|  | ||||
|             resolved.Add(merged); | ||||
|         } | ||||
|             var merged = new AffectedPackage( | ||||
|                 primary.Type, | ||||
|                 primary.Identifier, | ||||
|                 string.IsNullOrWhiteSpace(primary.Platform) ? null : primary.Platform, | ||||
|                 primary.Package.VersionRanges, | ||||
|                 statuses, | ||||
|                 provenance, | ||||
|                 normalizedRules); | ||||
|  | ||||
|             resolved.Add(merged); | ||||
|         } | ||||
|  | ||||
|         var packagesResult = resolved | ||||
|             .OrderBy(static pkg => pkg.Type, StringComparer.Ordinal) | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user