up
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