up
Some checks failed
Build Test Deploy / docs (push) Has been cancelled
Build Test Deploy / deploy (push) Has been cancelled
Build Test Deploy / build-test (push) Has been cancelled
Build Test Deploy / authority-container (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
2025-10-12 20:37:18 +03:00
parent b97fc7685a
commit 607e72e2a1
306 changed files with 21409 additions and 4449 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
using System;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Cryptography;
namespace StellaOps.Authority.Plugin.Standard.Security;
@@ -18,96 +17,70 @@ internal enum PasswordVerificationResult
SuccessRehashNeeded
}
internal sealed class Pbkdf2PasswordHasher : IPasswordHasher
internal sealed class CryptoPasswordHasher : IPasswordHasher
{
private const int SaltSize = 16;
private const int HashSize = 32;
private const int Iterations = 210_000;
private const string Header = "PBKDF2";
private readonly StandardPluginOptions options;
private readonly ICryptoProvider cryptoProvider;
public CryptoPasswordHasher(StandardPluginOptions options, ICryptoProvider cryptoProvider)
{
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.cryptoProvider = cryptoProvider ?? throw new ArgumentNullException(nameof(cryptoProvider));
}
public string Hash(string password)
{
if (string.IsNullOrEmpty(password))
{
throw new ArgumentException("Password is required.", nameof(password));
}
ArgumentException.ThrowIfNullOrEmpty(password);
Span<byte> salt = stackalloc byte[SaltSize];
RandomNumberGenerator.Fill(salt);
var hashOptions = options.PasswordHashing;
hashOptions.Validate();
Span<byte> hash = stackalloc byte[HashSize];
var derived = Rfc2898DeriveBytes.Pbkdf2(password, salt.ToArray(), Iterations, HashAlgorithmName.SHA256, HashSize);
derived.CopyTo(hash);
var payload = new byte[1 + SaltSize + HashSize];
payload[0] = 0x01; // version
salt.CopyTo(payload.AsSpan(1));
hash.CopyTo(payload.AsSpan(1 + SaltSize));
var builder = new StringBuilder();
builder.Append(Header);
builder.Append('.');
builder.Append(Iterations);
builder.Append('.');
builder.Append(Convert.ToBase64String(payload));
return builder.ToString();
var hasher = cryptoProvider.GetPasswordHasher(hashOptions.Algorithm.ToAlgorithmId());
return hasher.Hash(password, hashOptions);
}
public PasswordVerificationResult Verify(string password, string hashedPassword)
{
if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(hashedPassword))
ArgumentException.ThrowIfNullOrEmpty(password);
ArgumentException.ThrowIfNullOrEmpty(hashedPassword);
var desired = options.PasswordHashing;
desired.Validate();
var primaryHasher = cryptoProvider.GetPasswordHasher(desired.Algorithm.ToAlgorithmId());
if (IsArgon2Hash(hashedPassword))
{
return PasswordVerificationResult.Failed;
if (!primaryHasher.Verify(password, hashedPassword))
{
return PasswordVerificationResult.Failed;
}
return primaryHasher.NeedsRehash(hashedPassword, desired)
? PasswordVerificationResult.SuccessRehashNeeded
: PasswordVerificationResult.Success;
}
var parts = hashedPassword.Split('.', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 3 || !string.Equals(parts[0], Header, StringComparison.Ordinal))
if (IsLegacyPbkdf2Hash(hashedPassword))
{
return PasswordVerificationResult.Failed;
var legacyHasher = cryptoProvider.GetPasswordHasher(PasswordHashAlgorithm.Pbkdf2.ToAlgorithmId());
if (!legacyHasher.Verify(password, hashedPassword))
{
return PasswordVerificationResult.Failed;
}
return desired.Algorithm == PasswordHashAlgorithm.Pbkdf2 &&
!legacyHasher.NeedsRehash(hashedPassword, desired)
? PasswordVerificationResult.Success
: PasswordVerificationResult.SuccessRehashNeeded;
}
if (!int.TryParse(parts[1], out var iterations))
{
return PasswordVerificationResult.Failed;
}
byte[] payload;
try
{
payload = Convert.FromBase64String(parts[2]);
}
catch (FormatException)
{
return PasswordVerificationResult.Failed;
}
if (payload.Length != 1 + SaltSize + HashSize)
{
return PasswordVerificationResult.Failed;
}
var version = payload[0];
if (version != 0x01)
{
return PasswordVerificationResult.Failed;
}
var salt = new byte[SaltSize];
Array.Copy(payload, 1, salt, 0, SaltSize);
var expectedHash = new byte[HashSize];
Array.Copy(payload, 1 + SaltSize, expectedHash, 0, HashSize);
var actualHash = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, HashSize);
var success = CryptographicOperations.FixedTimeEquals(expectedHash, actualHash);
if (!success)
{
return PasswordVerificationResult.Failed;
}
return iterations < Iterations
? PasswordVerificationResult.SuccessRehashNeeded
: PasswordVerificationResult.Success;
return PasswordVerificationResult.Failed;
}
private static bool IsArgon2Hash(string value) =>
value.StartsWith("$argon2id$", StringComparison.Ordinal);
private static bool IsLegacyPbkdf2Hash(string value) =>
value.StartsWith("PBKDF2.", StringComparison.Ordinal);
}

View File

@@ -1,5 +1,6 @@
using System;
using System.IO;
using StellaOps.Cryptography;
namespace StellaOps.Authority.Plugin.Standard;
@@ -13,6 +14,8 @@ internal sealed class StandardPluginOptions
public TokenSigningOptions TokenSigning { get; set; } = new();
public PasswordHashOptions PasswordHashing { get; set; } = new();
public void Normalize(string configPath)
{
TokenSigning.Normalize(configPath);
@@ -23,6 +26,7 @@ internal sealed class StandardPluginOptions
BootstrapUser?.Validate(pluginName);
PasswordPolicy.Validate(pluginName);
Lockout.Validate(pluginName);
PasswordHashing.Validate();
}
}

View File

@@ -9,6 +9,8 @@ using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
namespace StellaOps.Authority.Plugin.Standard;
@@ -25,10 +27,11 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
var pluginName = context.Plugin.Manifest.Name;
context.Services.TryAddSingleton<IPasswordHasher, Pbkdf2PasswordHasher>();
context.Services.AddSingleton<StandardClaimsEnricher>();
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
context.Services.AddStellaOpsCrypto();
var configPath = context.Plugin.Manifest.ConfigPath;
context.Services.AddOptions<StandardPluginOptions>(pluginName)
@@ -45,7 +48,8 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
var database = sp.GetRequiredService<IMongoDatabase>();
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var pluginOptions = optionsMonitor.Get(pluginName);
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
return new StandardUserCredentialStore(
@@ -59,7 +63,9 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
context.Services.AddSingleton(sp =>
{
var clientStore = sp.GetRequiredService<IAuthorityClientStore>();
return new StandardClientProvisioningStore(pluginName, clientStore);
var revocationStore = sp.GetRequiredService<IAuthorityRevocationStore>();
var timeProvider = sp.GetRequiredService<TimeProvider>();
return new StandardClientProvisioningStore(pluginName, clientStore, revocationStore, timeProvider);
});
context.Services.AddSingleton<IIdentityProviderPlugin>(sp =>

View File

@@ -18,5 +18,7 @@
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Linq;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
@@ -9,11 +10,19 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
{
private readonly string pluginName;
private readonly IAuthorityClientStore clientStore;
private readonly IAuthorityRevocationStore revocationStore;
private readonly TimeProvider clock;
public StandardClientProvisioningStore(string pluginName, IAuthorityClientStore clientStore)
public StandardClientProvisioningStore(
string pluginName,
IAuthorityClientStore clientStore,
IAuthorityRevocationStore revocationStore,
TimeProvider clock)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.revocationStore = revocationStore ?? throw new ArgumentNullException(nameof(revocationStore));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
public async ValueTask<AuthorityPluginOperationResult<AuthorityClientDescriptor>> CreateOrUpdateAsync(
@@ -28,7 +37,7 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
}
var document = await clientStore.FindByClientIdAsync(registration.ClientId, cancellationToken).ConfigureAwait(false)
?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = DateTimeOffset.UtcNow };
?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = clock.GetUtcNow() };
document.Plugin = pluginName;
document.ClientType = registration.Confidential ? "confidential" : "public";
@@ -36,6 +45,7 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
document.SecretHash = registration.Confidential && registration.ClientSecret is not null
? AuthoritySecretHasher.ComputeHash(registration.ClientSecret)
: null;
document.UpdatedAt = clock.GetUtcNow();
document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
@@ -51,6 +61,7 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
}
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
await revocationStore.RemoveAsync("client", registration.ClientId, cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Success(ToDescriptor(document));
}
@@ -64,9 +75,39 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
public async ValueTask<AuthorityPluginOperationResult> DeleteAsync(string clientId, CancellationToken cancellationToken)
{
var deleted = await clientStore.DeleteByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
return deleted
? AuthorityPluginOperationResult.Success()
: AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
if (!deleted)
{
return AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
}
var now = clock.GetUtcNow();
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["plugin"] = pluginName
};
var revocation = new AuthorityRevocationDocument
{
Category = "client",
RevocationId = clientId,
ClientId = clientId,
Reason = "operator_request",
ReasonDescription = $"Client '{clientId}' deleted via plugin '{pluginName}'.",
RevokedAt = now,
EffectiveAt = now,
Metadata = metadata
};
try
{
await revocationStore.UpsertAsync(revocation, cancellationToken).ConfigureAwait(false);
}
catch
{
// Revocation export should proceed even if the metadata write fails.
}
return AuthorityPluginOperationResult.Success();
}
private static AuthorityClientDescriptor ToDescriptor(AuthorityClientDocument document)

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -8,6 +9,7 @@ using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Standard.Storage;
@@ -43,9 +45,11 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
string password,
CancellationToken cancellationToken)
{
var auditProperties = new List<AuthEventProperty>();
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password))
{
return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials);
return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, auditProperties: auditProperties);
}
var normalized = NormalizeUsername(username);
@@ -56,17 +60,24 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
if (user is null)
{
logger.LogWarning("Plugin {PluginName} failed password verification for unknown user {Username}.", pluginName, normalized);
return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials);
return AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials, auditProperties: auditProperties);
}
if (options.Lockout.Enabled && user.Lockout.LockoutEnd is { } lockoutEnd && lockoutEnd > DateTimeOffset.UtcNow)
{
var retryAfter = lockoutEnd - DateTimeOffset.UtcNow;
logger.LogWarning("Plugin {PluginName} denied access for {Username} due to lockout (retry after {RetryAfter}).", pluginName, normalized, retryAfter);
auditProperties.Add(new AuthEventProperty
{
Name = "plugin.lockout_until",
Value = ClassifiedString.Public(lockoutEnd.ToString("O", CultureInfo.InvariantCulture))
});
return AuthorityCredentialVerificationResult.Failure(
AuthorityCredentialFailureCode.LockedOut,
"Account is temporarily locked.",
retryAfter);
retryAfter,
auditProperties);
}
var verification = passwordHasher.Verify(password, user.PasswordHash);
@@ -75,8 +86,14 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
{
user.PasswordHash = passwordHasher.Hash(password);
auditProperties.Add(new AuthEventProperty
{
Name = "plugin.rehashed",
Value = ClassifiedString.Public("argon2id")
});
}
var previousFailures = user.Lockout.FailedAttempts;
ResetLockout(user);
user.UpdatedAt = DateTimeOffset.UtcNow;
await users.ReplaceOneAsync(
@@ -84,8 +101,20 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
user,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (previousFailures > 0)
{
auditProperties.Add(new AuthEventProperty
{
Name = "plugin.failed_attempts_cleared",
Value = ClassifiedString.Public(previousFailures.ToString(CultureInfo.InvariantCulture))
});
}
var descriptor = ToDescriptor(user);
return AuthorityCredentialVerificationResult.Success(descriptor, descriptor.RequiresPasswordReset ? "Password reset required." : null);
return AuthorityCredentialVerificationResult.Success(
descriptor,
descriptor.RequiresPasswordReset ? "Password reset required." : null,
auditProperties);
}
await RegisterFailureAsync(user, cancellationToken).ConfigureAwait(false);
@@ -98,10 +127,26 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
? lockoutTime - DateTimeOffset.UtcNow
: null;
auditProperties.Add(new AuthEventProperty
{
Name = "plugin.failed_attempts",
Value = ClassifiedString.Public(user.Lockout.FailedAttempts.ToString(CultureInfo.InvariantCulture))
});
if (user.Lockout.LockoutEnd is { } pendingLockout)
{
auditProperties.Add(new AuthEventProperty
{
Name = "plugin.lockout_until",
Value = ClassifiedString.Public(pendingLockout.ToString("O", CultureInfo.InvariantCulture))
});
}
return AuthorityCredentialVerificationResult.Failure(
code,
code == AuthorityCredentialFailureCode.LockedOut ? "Account is temporarily locked." : "Invalid credentials.",
retry);
retry,
auditProperties);
}
public async ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(

View File

@@ -2,14 +2,14 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| PLG6.DOC | DONE (2025-10-11) | BE-Auth Plugin, Docs Guild | PLG1PLG5 | Final polish + diagrams for plugin developer guide. | Docs team delivers copy-edit + exported diagrams; PR merged. |
| SEC1.PLG | TODO | Security Guild, BE-Auth Plugin | SEC1.A (StellaOps.Cryptography) | Swap Standard plugin hashing to Argon2id via `StellaOps.Cryptography` abstractions; keep PBKDF2 verification for legacy. | ✅ `StandardUserCredentialStore` uses `ICryptoProvider` to hash/check; ✅ Transparent rehash on success; ✅ Unit tests cover tamper + legacy rehash. |
| SEC1.OPT | TODO | Security Guild | SEC1.PLG | Expose password hashing knobs in `StandardPluginOptions` (`memoryKiB`, `iterations`, `parallelism`, `algorithm`) with validation. | ✅ Options bound from YAML; ✅ Invalid configs throw; ✅ Docs include tuning guidance. |
| PLG6.DOC | DONE (2025-10-11) | BE-Auth Plugin, Docs Guild | PLG1PLG5 | Final polish + diagrams for plugin developer guide (AUTHPLUG-DOCS-01-001). | Docs team delivers copy-edit + exported diagrams; PR merged. |
| SEC1.PLG | DONE (2025-10-11) | Security Guild, BE-Auth Plugin | SEC1.A (StellaOps.Cryptography) | Swap Standard plugin hashing to Argon2id via `StellaOps.Cryptography` abstractions; keep PBKDF2 verification for legacy. | ✅ `StandardUserCredentialStore` uses `ICryptoProvider` to hash/check; ✅ Transparent rehash on success; ✅ Unit tests cover tamper + legacy rehash. |
| SEC1.OPT | DONE (2025-10-11) | Security Guild | SEC1.PLG | Expose password hashing knobs in `StandardPluginOptions` (`memoryKiB`, `iterations`, `parallelism`, `algorithm`) with validation. | ✅ Options bound from YAML; ✅ Invalid configs throw; ✅ Docs include tuning guidance. |
| SEC2.PLG | TODO | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
| SEC3.PLG | TODO | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
| SEC4.PLG | TODO | Security Guild | SEC4.A (revocation schema) | Provide plugin hooks so revoked users/clients write reasons for revocation bundle export. | ✅ Revocation exporter consumes plugin data; ✅ Tests cover revoked user/client output. |
| SEC4.PLG | DONE (2025-10-12) | Security Guild | SEC4.A (revocation schema) | Provide plugin hooks so revoked users/clients write reasons for revocation bundle export. | ✅ Revocation exporter consumes plugin data; ✅ Tests cover revoked user/client output. |
| SEC5.PLG | TODO | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
| PLG4-6.CAPABILITIES | DOING (2025-10-10) | BE-Auth Plugin, Docs Guild | PLG1PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. |
| PLG4-6.CAPABILITIES | BLOCKED (2025-10-12) | BE-Auth Plugin, Docs Guild | PLG1PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. <br>⛔ Blocked awaiting Authority rate-limiter stream (CORE8/SEC3) to resume so doc updates reflect final limiter behaviour. |
| PLG7.RFC | REVIEW | BE-Auth Plugin, Security Guild | PLG4 | Socialize LDAP plugin RFC (`docs/rfcs/authority-plugin-ldap.md`) and capture guild feedback. | ✅ Guild review sign-off recorded; ✅ Follow-up issues filed in module boards. |
| PLG6.DIAGRAM | TODO | Docs Guild | PLG6.DOC | Export final sequence/component diagrams for the developer guide and add offline-friendly assets under `docs/assets/authority`. | ✅ Mermaid sources committed; ✅ Rendered SVG/PNG linked from Section 2 + Section 9; ✅ Docs build preview shared with Plugin + Docs guilds. |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
using System;
namespace StellaOps.Authority.Revocation;
internal sealed record RevocationBundleBuildResult(
RevocationBundleModel Bundle,
byte[] CanonicalJson,
string Sha256,
long Sequence,
DateTimeOffset IssuedAt);

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
namespace StellaOps.Authority.Revocation;
internal sealed record RevocationBundleSignature(string Algorithm, string KeyId, string Value);

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
namespace StellaOps.Authority.Revocation;
internal sealed record RevocationExportPackage(
RevocationBundleBuildResult Bundle,
RevocationBundleSignature Signature);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
using StellaOps.Cryptography;
namespace StellaOps.Authority.Signing;
internal interface IAuthoritySigningKeySource
{
bool CanLoad(string source);
CryptoSigningKey Load(AuthoritySigningKeyRequest request);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}
}

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
};
}
}

View File

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

View File

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

View File

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

View 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

View 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; }
}
}

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

View File

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

View 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;
}
}
}
}

View 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;
}
}

View 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.");
}
}
}

View 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}'.")
};
}

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

View 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.")
};
}

View 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; }
}
}

View 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";
}

View File

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

View File

@@ -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 &lt; 250ms. |
| 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 &lt; 250ms. |
| 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. | CORE5CORE7 | ✅ 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 SEC1SEC4 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 SEC1SEC4 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 ≈ 19MiB, iterations 2, parallelism 1). Allow overrides via configuration.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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