Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -0,0 +1,50 @@
using System;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Represents the outcome of attempting to consume a DPoP nonce.
/// </summary>
public sealed class DpopNonceConsumeResult
{
private DpopNonceConsumeResult(DpopNonceConsumeStatus status, DateTimeOffset? issuedAt, DateTimeOffset? expiresAt)
{
Status = status;
IssuedAt = issuedAt;
ExpiresAt = expiresAt;
}
/// <summary>
/// Consumption status.
/// </summary>
public DpopNonceConsumeStatus Status { get; }
/// <summary>
/// Timestamp the nonce was originally issued (when available).
/// </summary>
public DateTimeOffset? IssuedAt { get; }
/// <summary>
/// Expiry timestamp for the nonce (when available).
/// </summary>
public DateTimeOffset? ExpiresAt { get; }
public static DpopNonceConsumeResult Success(DateTimeOffset issuedAt, DateTimeOffset expiresAt)
=> new(DpopNonceConsumeStatus.Success, issuedAt, expiresAt);
public static DpopNonceConsumeResult Expired(DateTimeOffset? issuedAt, DateTimeOffset expiresAt)
=> new(DpopNonceConsumeStatus.Expired, issuedAt, expiresAt);
public static DpopNonceConsumeResult NotFound()
=> new(DpopNonceConsumeStatus.NotFound, null, null);
}
/// <summary>
/// Known statuses for nonce consumption attempts.
/// </summary>
public enum DpopNonceConsumeStatus
{
Success,
Expired,
NotFound
}

View File

@@ -0,0 +1,56 @@
using System;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Represents the result of issuing a DPoP nonce.
/// </summary>
public sealed class DpopNonceIssueResult
{
private DpopNonceIssueResult(DpopNonceIssueStatus status, string? nonce, DateTimeOffset? expiresAt, string? error)
{
Status = status;
Nonce = nonce;
ExpiresAt = expiresAt;
Error = error;
}
/// <summary>
/// Issue status.
/// </summary>
public DpopNonceIssueStatus Status { get; }
/// <summary>
/// Issued nonce when <see cref="Status"/> is <see cref="DpopNonceIssueStatus.Success"/>.
/// </summary>
public string? Nonce { get; }
/// <summary>
/// Expiry timestamp for the issued nonce (UTC).
/// </summary>
public DateTimeOffset? ExpiresAt { get; }
/// <summary>
/// Additional failure information, where applicable.
/// </summary>
public string? Error { get; }
public static DpopNonceIssueResult Success(string nonce, DateTimeOffset expiresAt)
=> new(DpopNonceIssueStatus.Success, nonce, expiresAt, null);
public static DpopNonceIssueResult RateLimited(string? error = null)
=> new(DpopNonceIssueStatus.RateLimited, null, null, error);
public static DpopNonceIssueResult Failure(string? error = null)
=> new(DpopNonceIssueStatus.Failure, null, null, error);
}
/// <summary>
/// Known statuses for nonce issuance.
/// </summary>
public enum DpopNonceIssueStatus
{
Success,
RateLimited,
Failure
}

View File

@@ -0,0 +1,66 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Auth.Security.Dpop;
internal static class DpopNonceUtilities
{
private static readonly char[] Base64Padding = { '=' };
internal static string GenerateNonce()
{
Span<byte> buffer = stackalloc byte[32];
RandomNumberGenerator.Fill(buffer);
return Convert.ToBase64String(buffer)
.TrimEnd(Base64Padding)
.Replace('+', '-')
.Replace('/', '_');
}
internal static byte[] ComputeNonceHash(string nonce)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
var bytes = Encoding.UTF8.GetBytes(nonce);
return SHA256.HashData(bytes);
}
internal static string EncodeHash(ReadOnlySpan<byte> hash)
=> Convert.ToHexString(hash);
internal static string ComputeStorageKey(string audience, string clientId, string keyThumbprint)
{
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
return string.Create(
"dpop-nonce:".Length + audience.Length + clientId.Length + keyThumbprint.Length + 2,
(audience.Trim(), clientId.Trim(), keyThumbprint.Trim()),
static (span, parts) =>
{
var index = 0;
const string Prefix = "dpop-nonce:";
Prefix.CopyTo(span);
index += Prefix.Length;
index = Append(span, index, parts.Item1);
span[index++] = ':';
index = Append(span, index, parts.Item2);
span[index++] = ':';
_ = Append(span, index, parts.Item3);
});
static int Append(Span<char> span, int index, string value)
{
if (value.Length == 0)
{
throw new ArgumentException("Value must not be empty after trimming.");
}
value.AsSpan().CopyTo(span[index..]);
return index + value.Length;
}
}
}

View File

@@ -0,0 +1,258 @@
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Validates DPoP proofs following RFC 9449.
/// </summary>
public sealed class DpopProofValidator : IDpopProofValidator
{
private static readonly string ProofType = "dpop+jwt";
private readonly DpopValidationOptions options;
private readonly IDpopReplayCache replayCache;
private readonly TimeProvider timeProvider;
private readonly ILogger<DpopProofValidator>? logger;
private readonly JwtSecurityTokenHandler tokenHandler = new();
public DpopProofValidator(
IOptions<DpopValidationOptions> options,
IDpopReplayCache? replayCache = null,
TimeProvider? timeProvider = null,
ILogger<DpopProofValidator>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
var cloned = options.Value ?? throw new InvalidOperationException("DPoP options must be provided.");
cloned.Validate();
this.options = cloned;
this.replayCache = replayCache ?? NullReplayCache.Instance;
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
public async ValueTask<DpopValidationResult> ValidateAsync(string proof, string httpMethod, Uri httpUri, string? nonce = null, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(proof);
ArgumentException.ThrowIfNullOrWhiteSpace(httpMethod);
ArgumentNullException.ThrowIfNull(httpUri);
var now = timeProvider.GetUtcNow();
if (!TryDecodeSegment(proof, segmentIndex: 0, out var headerElement, out var headerError))
{
logger?.LogWarning("DPoP header decode failure: {Error}", headerError);
return DpopValidationResult.Failure("invalid_header", headerError ?? "Unable to decode header.");
}
if (!headerElement.TryGetProperty("typ", out var typElement) || !string.Equals(typElement.GetString(), ProofType, StringComparison.OrdinalIgnoreCase))
{
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing typ=dpop+jwt header.");
}
if (!headerElement.TryGetProperty("alg", out var algElement))
{
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing alg header.");
}
var algorithm = algElement.GetString()?.Trim().ToUpperInvariant();
if (string.IsNullOrEmpty(algorithm) || !options.NormalizedAlgorithms.Contains(algorithm))
{
return DpopValidationResult.Failure("invalid_header", "Unsupported DPoP algorithm.");
}
if (!headerElement.TryGetProperty("jwk", out var jwkElement))
{
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing jwk header.");
}
JsonWebKey jwk;
try
{
jwk = new JsonWebKey(jwkElement.GetRawText());
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Failed to parse DPoP jwk header.");
return DpopValidationResult.Failure("invalid_header", "DPoP proof jwk header is invalid.");
}
if (!TryDecodeSegment(proof, segmentIndex: 1, out var payloadElement, out var payloadError))
{
logger?.LogWarning("DPoP payload decode failure: {Error}", payloadError);
return DpopValidationResult.Failure("invalid_payload", payloadError ?? "Unable to decode payload.");
}
if (!payloadElement.TryGetProperty("htm", out var htmElement))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htm claim.");
}
var method = httpMethod.Trim().ToUpperInvariant();
if (!string.Equals(htmElement.GetString(), method, StringComparison.Ordinal))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP htm does not match request method.");
}
if (!payloadElement.TryGetProperty("htu", out var htuElement))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htu claim.");
}
var normalizedHtu = NormalizeHtu(httpUri);
if (!string.Equals(htuElement.GetString(), normalizedHtu, StringComparison.Ordinal))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP htu does not match request URI.");
}
if (!payloadElement.TryGetProperty("iat", out var iatElement) || iatElement.ValueKind is not JsonValueKind.Number)
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing iat claim.");
}
if (!payloadElement.TryGetProperty("jti", out var jtiElement) || jtiElement.ValueKind != JsonValueKind.String)
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing jti claim.");
}
long iatSeconds;
try
{
iatSeconds = iatElement.GetInt64();
}
catch (Exception)
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof iat claim is not a valid number.");
}
var issuedAt = DateTimeOffset.FromUnixTimeSeconds(iatSeconds).ToUniversalTime();
if (issuedAt - options.AllowedClockSkew > now)
{
return DpopValidationResult.Failure("invalid_token", "DPoP proof issued in the future.");
}
if (now - issuedAt > options.GetMaximumAge())
{
return DpopValidationResult.Failure("invalid_token", "DPoP proof expired.");
}
string? actualNonce = null;
if (nonce is not null)
{
if (!payloadElement.TryGetProperty("nonce", out var nonceElement) || nonceElement.ValueKind != JsonValueKind.String)
{
return DpopValidationResult.Failure("invalid_token", "DPoP proof missing nonce claim.");
}
actualNonce = nonceElement.GetString();
if (!string.Equals(actualNonce, nonce, StringComparison.Ordinal))
{
return DpopValidationResult.Failure("invalid_token", "DPoP nonce mismatch.");
}
}
else if (payloadElement.TryGetProperty("nonce", out var nonceElement) && nonceElement.ValueKind == JsonValueKind.String)
{
actualNonce = nonceElement.GetString();
}
var jwtId = jtiElement.GetString()!;
try
{
var parameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidateIssuer = false,
ValidateLifetime = false,
ValidateTokenReplay = false,
RequireSignedTokens = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = jwk,
ValidAlgorithms = options.NormalizedAlgorithms.ToArray()
};
tokenHandler.ValidateToken(proof, parameters, out _);
}
catch (Exception ex)
{
logger?.LogWarning(ex, "DPoP proof signature validation failed.");
return DpopValidationResult.Failure("invalid_signature", "DPoP proof signature validation failed.");
}
if (!await replayCache.TryStoreAsync(jwtId, issuedAt + options.ReplayWindow, cancellationToken).ConfigureAwait(false))
{
return DpopValidationResult.Failure("replay", "DPoP proof already used.");
}
return DpopValidationResult.Success(jwk, jwtId, issuedAt, actualNonce);
}
private static string NormalizeHtu(Uri uri)
{
var builder = new UriBuilder(uri)
{
Fragment = null,
Query = null
};
return builder.Uri.ToString();
}
private static bool TryDecodeSegment(string token, int segmentIndex, out JsonElement element, out string? error)
{
element = default;
error = null;
var segments = token.Split('.');
if (segments.Length != 3)
{
error = "Token must contain three segments.";
return false;
}
if (segmentIndex < 0 || segmentIndex > 2)
{
error = "Segment index out of range.";
return false;
}
try
{
var json = Base64UrlEncoder.Decode(segments[segmentIndex]);
using var document = JsonDocument.Parse(json);
element = document.RootElement.Clone();
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
private static class NullReplayCache
{
public static readonly IDpopReplayCache Instance = new Noop();
private sealed class Noop : IDpopReplayCache
{
public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
return ValueTask.FromResult(true);
}
}
}
}
file static class DpopValidationOptionsExtensions
{
public static TimeSpan GetMaximumAge(this DpopValidationOptions options)
=> options.ProofLifetime + options.AllowedClockSkew;
}

View File

@@ -0,0 +1,77 @@
using System.Collections.Immutable;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Configures acceptable algorithms and replay windows for DPoP proof validation.
/// </summary>
public sealed class DpopValidationOptions
{
private readonly HashSet<string> allowedAlgorithms = new(StringComparer.Ordinal);
public DpopValidationOptions()
{
allowedAlgorithms.Add("ES256");
allowedAlgorithms.Add("ES384");
}
/// <summary>
/// Maximum age a proof is considered valid relative to <see cref="IssuedAt"/>.
/// </summary>
public TimeSpan ProofLifetime { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>
/// Allowed clock skew when evaluating <c>iat</c>.
/// </summary>
public TimeSpan AllowedClockSkew { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Duration a successfully validated proof is tracked to prevent replay.
/// </summary>
public TimeSpan ReplayWindow { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Algorithms (JWA) permitted for DPoP proofs.
/// </summary>
public ISet<string> AllowedAlgorithms => allowedAlgorithms;
/// <summary>
/// Normalised, upper-case representation of allowed algorithms.
/// </summary>
public IReadOnlySet<string> NormalizedAlgorithms { get; private set; } = ImmutableHashSet<string>.Empty;
public void Validate()
{
if (ProofLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("DPoP proof lifetime must be greater than zero.");
}
if (AllowedClockSkew < TimeSpan.Zero || AllowedClockSkew > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException("DPoP allowed clock skew must be between 0 seconds and 5 minutes.");
}
if (ReplayWindow < TimeSpan.Zero)
{
throw new InvalidOperationException("DPoP replay window must be greater than or equal to zero.");
}
if (allowedAlgorithms.Count == 0)
{
throw new InvalidOperationException("At least one allowed DPoP algorithm must be configured.");
}
NormalizedAlgorithms = allowedAlgorithms
.Select(static algorithm => algorithm.Trim().ToUpperInvariant())
.Where(static algorithm => algorithm.Length > 0)
.ToImmutableHashSet(StringComparer.Ordinal);
if (NormalizedAlgorithms.Count == 0)
{
throw new InvalidOperationException("Allowed DPoP algorithms cannot be empty after normalization.");
}
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Represents the outcome of DPoP proof validation.
/// </summary>
public sealed class DpopValidationResult
{
private DpopValidationResult(bool success, string? errorCode, string? errorDescription, SecurityKey? key, string? jwtId, DateTimeOffset? issuedAt, string? nonce)
{
IsValid = success;
ErrorCode = errorCode;
ErrorDescription = errorDescription;
PublicKey = key;
JwtId = jwtId;
IssuedAt = issuedAt;
Nonce = nonce;
}
public bool IsValid { get; }
public string? ErrorCode { get; }
public string? ErrorDescription { get; }
public SecurityKey? PublicKey { get; }
public string? JwtId { get; }
public DateTimeOffset? IssuedAt { get; }
public string? Nonce { get; }
public static DpopValidationResult Success(SecurityKey key, string jwtId, DateTimeOffset issuedAt, string? nonce)
=> new(true, null, null, key, jwtId, issuedAt, nonce);
public static DpopValidationResult Failure(string code, string description)
=> new(false, code, description, null, null, null, null);
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Provides persistence and validation for DPoP nonces.
/// </summary>
public interface IDpopNonceStore
{
/// <summary>
/// Issues a nonce tied to the specified audience, client, and DPoP key thumbprint.
/// </summary>
/// <param name="audience">Audience the nonce applies to.</param>
/// <param name="clientId">Client identifier requesting the nonce.</param>
/// <param name="keyThumbprint">Thumbprint of the DPoP public key.</param>
/// <param name="ttl">Time-to-live for the nonce.</param>
/// <param name="maxIssuancePerMinute">Maximum number of nonces that can be issued within a one-minute window for the tuple.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Outcome describing the issued nonce.</returns>
ValueTask<DpopNonceIssueResult> IssueAsync(
string audience,
string clientId,
string keyThumbprint,
TimeSpan ttl,
int maxIssuancePerMinute,
CancellationToken cancellationToken = default);
/// <summary>
/// Attempts to consume a nonce previously issued for the tuple.
/// </summary>
/// <param name="nonce">Nonce supplied by the client.</param>
/// <param name="audience">Audience the nonce should match.</param>
/// <param name="clientId">Client identifier.</param>
/// <param name="keyThumbprint">Thumbprint of the DPoP public key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Outcome describing whether the nonce was accepted.</returns>
ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
string nonce,
string audience,
string clientId,
string keyThumbprint,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Auth.Security.Dpop;
public interface IDpopProofValidator
{
ValueTask<DpopValidationResult> ValidateAsync(string proof, string httpMethod, Uri httpUri, string? nonce = null, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Auth.Security.Dpop;
public interface IDpopReplayCache
{
ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// In-memory implementation of <see cref="IDpopNonceStore"/> suitable for single-host or test environments.
/// </summary>
public sealed class InMemoryDpopNonceStore : IDpopNonceStore
{
private static readonly TimeSpan IssuanceWindow = TimeSpan.FromMinutes(1);
private readonly ConcurrentDictionary<string, StoredNonce> nonces = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, IssuanceBucket> issuanceBuckets = new(StringComparer.Ordinal);
private readonly TimeProvider timeProvider;
private readonly ILogger<InMemoryDpopNonceStore>? logger;
public InMemoryDpopNonceStore(TimeProvider? timeProvider = null, ILogger<InMemoryDpopNonceStore>? logger = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
public ValueTask<DpopNonceIssueResult> IssueAsync(
string audience,
string clientId,
string keyThumbprint,
TimeSpan ttl,
int maxIssuancePerMinute,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
if (ttl <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(ttl), "Nonce TTL must be greater than zero.");
}
if (maxIssuancePerMinute < 1)
{
throw new ArgumentOutOfRangeException(nameof(maxIssuancePerMinute), "Max issuance per minute must be at least 1.");
}
cancellationToken.ThrowIfCancellationRequested();
var now = timeProvider.GetUtcNow();
var bucketKey = BuildBucketKey(audience, clientId, keyThumbprint);
var bucket = issuanceBuckets.GetOrAdd(bucketKey, static _ => new IssuanceBucket());
bool allowed;
lock (bucket.SyncRoot)
{
bucket.Prune(now - IssuanceWindow);
if (bucket.IssuanceTimes.Count >= maxIssuancePerMinute)
{
allowed = false;
}
else
{
bucket.IssuanceTimes.Enqueue(now);
allowed = true;
}
}
if (!allowed)
{
logger?.LogDebug("DPoP nonce issuance throttled for {BucketKey}.", bucketKey);
return ValueTask.FromResult(DpopNonceIssueResult.RateLimited("rate_limited"));
}
var nonce = GenerateNonce();
var nonceKey = BuildNonceKey(audience, clientId, keyThumbprint, nonce);
var expiresAt = now + ttl;
nonces[nonceKey] = new StoredNonce(now, expiresAt);
return ValueTask.FromResult(DpopNonceIssueResult.Success(nonce, expiresAt));
}
public ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
string nonce,
string audience,
string clientId,
string keyThumbprint,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
cancellationToken.ThrowIfCancellationRequested();
var now = timeProvider.GetUtcNow();
var nonceKey = BuildNonceKey(audience, clientId, keyThumbprint, nonce);
if (!nonces.TryRemove(nonceKey, out var stored))
{
logger?.LogDebug("DPoP nonce {NonceKey} not found during consumption.", nonceKey);
return ValueTask.FromResult(DpopNonceConsumeResult.NotFound());
}
if (stored.ExpiresAt <= now)
{
logger?.LogDebug("DPoP nonce {NonceKey} expired at {ExpiresAt:o}.", nonceKey, stored.ExpiresAt);
return ValueTask.FromResult(DpopNonceConsumeResult.Expired(stored.IssuedAt, stored.ExpiresAt));
}
return ValueTask.FromResult(DpopNonceConsumeResult.Success(stored.IssuedAt, stored.ExpiresAt));
}
private static string BuildBucketKey(string audience, string clientId, string keyThumbprint)
=> $"{audience.Trim().ToLowerInvariant()}::{clientId.Trim().ToLowerInvariant()}::{keyThumbprint.Trim().ToLowerInvariant()}";
private static string BuildNonceKey(string audience, string clientId, string keyThumbprint, string nonce)
{
var bucketKey = BuildBucketKey(audience, clientId, keyThumbprint);
var digest = ComputeSha256(nonce);
return $"{bucketKey}::{digest}";
}
private static string ComputeSha256(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
var hash = SHA256.HashData(bytes);
return Base64UrlEncode(hash);
}
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static string GenerateNonce()
{
Span<byte> buffer = stackalloc byte[32];
RandomNumberGenerator.Fill(buffer);
return Base64UrlEncode(buffer);
}
private sealed class StoredNonce
{
internal StoredNonce(DateTimeOffset issuedAt, DateTimeOffset expiresAt)
{
IssuedAt = issuedAt;
ExpiresAt = expiresAt;
}
internal DateTimeOffset IssuedAt { get; }
internal DateTimeOffset ExpiresAt { get; }
}
private sealed class IssuanceBucket
{
internal object SyncRoot { get; } = new();
internal Queue<DateTimeOffset> IssuanceTimes { get; } = new();
internal void Prune(DateTimeOffset threshold)
{
while (IssuanceTimes.Count > 0 && IssuanceTimes.Peek() < threshold)
{
IssuanceTimes.Dequeue();
}
}
}
}

View File

@@ -0,0 +1,66 @@
using System.Collections.Concurrent;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// In-memory replay cache intended for single-process deployments or tests.
/// </summary>
public sealed class InMemoryDpopReplayCache : IDpopReplayCache
{
private readonly ConcurrentDictionary<string, DateTimeOffset> entries = new(StringComparer.Ordinal);
private readonly TimeProvider timeProvider;
public InMemoryDpopReplayCache(TimeProvider? timeProvider = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
var now = timeProvider.GetUtcNow();
RemoveExpired(now);
if (entries.TryAdd(jwtId, expiresAt))
{
return ValueTask.FromResult(true);
}
while (!cancellationToken.IsCancellationRequested)
{
if (!entries.TryGetValue(jwtId, out var existing))
{
if (entries.TryAdd(jwtId, expiresAt))
{
return ValueTask.FromResult(true);
}
continue;
}
if (existing > now)
{
return ValueTask.FromResult(false);
}
if (entries.TryUpdate(jwtId, expiresAt, existing))
{
return ValueTask.FromResult(true);
}
}
return ValueTask.FromResult(false);
}
private void RemoveExpired(DateTimeOffset now)
{
foreach (var entry in entries)
{
if (entry.Value <= now)
{
entries.TryRemove(entry.Key, out _);
}
}
}
}

View File

@@ -0,0 +1,138 @@
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using StackExchange.Redis;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Redis-backed implementation of <see cref="IDpopNonceStore"/> that supports multi-node deployments.
/// </summary>
public sealed class RedisDpopNonceStore : IDpopNonceStore
{
private const string ConsumeScript = @"
local value = redis.call('GET', KEYS[1])
if value ~= false and value == ARGV[1] then
redis.call('DEL', KEYS[1])
return 1
end
return 0";
private readonly IConnectionMultiplexer connection;
private readonly TimeProvider timeProvider;
public RedisDpopNonceStore(IConnectionMultiplexer connection, TimeProvider? timeProvider = null)
{
this.connection = connection ?? throw new ArgumentNullException(nameof(connection));
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public async ValueTask<DpopNonceIssueResult> IssueAsync(
string audience,
string clientId,
string keyThumbprint,
TimeSpan ttl,
int maxIssuancePerMinute,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
if (ttl <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(ttl), "Nonce TTL must be greater than zero.");
}
if (maxIssuancePerMinute < 1)
{
throw new ArgumentOutOfRangeException(nameof(maxIssuancePerMinute), "Max issuance per minute must be at least 1.");
}
cancellationToken.ThrowIfCancellationRequested();
var database = connection.GetDatabase();
var issuedAt = timeProvider.GetUtcNow();
var baseKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
var nonceKey = (RedisKey)baseKey;
var metadataKey = (RedisKey)(baseKey + ":meta");
var rateKey = (RedisKey)(baseKey + ":rate");
var rateCount = await database.StringIncrementAsync(rateKey, flags: CommandFlags.DemandMaster).ConfigureAwait(false);
if (rateCount == 1)
{
await database.KeyExpireAsync(rateKey, TimeSpan.FromMinutes(1), CommandFlags.DemandMaster).ConfigureAwait(false);
}
if (rateCount > maxIssuancePerMinute)
{
return DpopNonceIssueResult.RateLimited("rate_limited");
}
var nonce = DpopNonceUtilities.GenerateNonce();
var hash = (RedisValue)DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
var expiresAt = issuedAt + ttl;
await database.StringSetAsync(nonceKey, hash, ttl, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false);
var metadataValue = FormattableString.Invariant($"{issuedAt.UtcTicks}|{ttl.Ticks}");
await database.StringSetAsync(metadataKey, metadataValue, ttl, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false);
return DpopNonceIssueResult.Success(nonce, expiresAt);
}
public async ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
string nonce,
string audience,
string clientId,
string keyThumbprint,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
cancellationToken.ThrowIfCancellationRequested();
var database = connection.GetDatabase();
var baseKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
var nonceKey = (RedisKey)baseKey;
var metadataKey = (RedisKey)(baseKey + ":meta");
var hash = (RedisValue)DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
var rawResult = await database.ScriptEvaluateAsync(
ConsumeScript,
new[] { nonceKey },
new RedisValue[] { hash }).ConfigureAwait(false);
if (rawResult.IsNull || (long)rawResult != 1)
{
return DpopNonceConsumeResult.NotFound();
}
var metadata = await database.StringGetAsync(metadataKey).ConfigureAwait(false);
await database.KeyDeleteAsync(metadataKey, CommandFlags.DemandMaster).ConfigureAwait(false);
if (!metadata.IsNull)
{
var parts = metadata.ToString()
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 2 &&
long.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var issuedTicks) &&
long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ttlTicks))
{
var issuedAt = new DateTimeOffset(issuedTicks, TimeSpan.Zero);
var expiresAt = issuedAt + TimeSpan.FromTicks(ttlTicks);
return expiresAt <= timeProvider.GetUtcNow()
? DpopNonceConsumeResult.Expired(issuedAt, expiresAt)
: DpopNonceConsumeResult.Success(issuedAt, expiresAt);
}
}
return DpopNonceConsumeResult.Success(timeProvider.GetUtcNow(), timeProvider.GetUtcNow());
}
}

View File

@@ -0,0 +1,3 @@
# StellaOps.Auth.Security
Shared sender-constraint helpers (DPoP proof validation, replay caches, future mTLS utilities) used by Authority, Scanner, Signer, and other StellaOps services. This package centralises primitives so services remain deterministic while honouring proof-of-possession guarantees.

View File

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<Description>Sender-constrained authentication primitives (DPoP, mTLS) shared across StellaOps services.</Description>
<PackageId>StellaOps.Auth.Security</PackageId>
<Authors>StellaOps</Authors>
<Company>StellaOps</Company>
<PackageTags>stellaops;dpop;mtls;oauth2;security</PackageTags>
<PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression>
<PackageProjectUrl>https://stella-ops.org</PackageProjectUrl>
<RepositoryUrl>https://git.stella-ops.org/stella-ops.org/git.stella-ops.org</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PackageReadmeFile>README.md</PackageReadmeFile>
<VersionPrefix>1.0.0-preview.1</VersionPrefix>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.2.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
<PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,28 @@
using System;
namespace StellaOps.Configuration;
/// <summary>
/// Represents a configuration diagnostic emitted while analysing Authority plugin settings.
/// </summary>
public sealed record AuthorityConfigurationDiagnostic(
string PluginName,
AuthorityConfigurationDiagnosticSeverity Severity,
string Message)
{
public string PluginName { get; init; } = PluginName ?? throw new ArgumentNullException(nameof(PluginName));
public AuthorityConfigurationDiagnosticSeverity Severity { get; init; } = Severity;
public string Message { get; init; } = Message ?? throw new ArgumentNullException(nameof(Message));
}
/// <summary>
/// Severity levels for configuration diagnostics.
/// </summary>
public enum AuthorityConfigurationDiagnosticSeverity
{
Info = 0,
Warning = 1,
Error = 2
}

View File

@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Microsoft.Extensions.Configuration;
using StellaOps.Authority.Plugins.Abstractions;
namespace StellaOps.Configuration;
/// <summary>
/// Analyses Authority plugin configurations for common security issues.
/// </summary>
public static class AuthorityPluginConfigurationAnalyzer
{
private const int BaselineMinimumLength = 12;
private const bool BaselineRequireUppercase = true;
private const bool BaselineRequireLowercase = true;
private const bool BaselineRequireDigit = true;
private const bool BaselineRequireSymbol = true;
/// <summary>
/// Evaluates plugin contexts and returns diagnostics describing potential misconfigurations.
/// </summary>
/// <param name="contexts">Plugin contexts produced by <see cref="AuthorityPluginConfigurationLoader"/>.</param>
/// <returns>Diagnostics describing any detected issues.</returns>
public static IReadOnlyList<AuthorityConfigurationDiagnostic> Analyze(IEnumerable<AuthorityPluginContext> contexts)
{
ArgumentNullException.ThrowIfNull(contexts);
var diagnostics = new List<AuthorityConfigurationDiagnostic>();
foreach (var context in contexts)
{
if (context is null)
{
continue;
}
if (string.Equals(context.Manifest.AssemblyName, "StellaOps.Authority.Plugin.Standard", StringComparison.OrdinalIgnoreCase))
{
AnalyzeStandardPlugin(context, diagnostics);
}
}
return diagnostics;
}
private static void AnalyzeStandardPlugin(AuthorityPluginContext context, ICollection<AuthorityConfigurationDiagnostic> diagnostics)
{
var section = context.Configuration.GetSection("passwordPolicy");
if (!section.Exists())
{
return;
}
int minLength = section.GetValue("minimumLength", BaselineMinimumLength);
bool requireUppercase = section.GetValue("requireUppercase", BaselineRequireUppercase);
bool requireLowercase = section.GetValue("requireLowercase", BaselineRequireLowercase);
bool requireDigit = section.GetValue("requireDigit", BaselineRequireDigit);
bool requireSymbol = section.GetValue("requireSymbol", BaselineRequireSymbol);
var deviations = new List<string>();
if (minLength < BaselineMinimumLength)
{
deviations.Add($"minimum length {minLength.ToString(CultureInfo.InvariantCulture)} < {BaselineMinimumLength}");
}
if (!requireUppercase && BaselineRequireUppercase)
{
deviations.Add("uppercase requirement disabled");
}
if (!requireLowercase && BaselineRequireLowercase)
{
deviations.Add("lowercase requirement disabled");
}
if (!requireDigit && BaselineRequireDigit)
{
deviations.Add("digit requirement disabled");
}
if (!requireSymbol && BaselineRequireSymbol)
{
deviations.Add("symbol requirement disabled");
}
if (deviations.Count == 0)
{
return;
}
var message = $"Password policy for plugin '{context.Manifest.Name}' weakens host defaults: {string.Join(", ", deviations)}.";
diagnostics.Add(new AuthorityConfigurationDiagnostic(context.Manifest.Name, AuthorityConfigurationDiagnosticSeverity.Warning, message));
}
}

View File

@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.Extensions.Configuration;
using NetEscapades.Configuration.Yaml;
using StellaOps.Authority.Plugins.Abstractions;
namespace StellaOps.Configuration;
/// <summary>
/// Utility helpers for loading Authority plugin configuration manifests.
/// </summary>
public static class AuthorityPluginConfigurationLoader
{
/// <summary>
/// Loads plugin configuration files based on the supplied Authority options.
/// </summary>
/// <param name="options">Authority configuration containing plugin descriptors.</param>
/// <param name="basePath">Application base path used to resolve relative directories.</param>
/// <param name="configureBuilder">Optional hook to customise per-plugin configuration builder.</param>
public static IReadOnlyList<AuthorityPluginContext> Load(
StellaOpsAuthorityOptions options,
string basePath,
Action<IConfigurationBuilder>? configureBuilder = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(basePath);
var descriptorPairs = options.Plugins.Descriptors
.OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase)
.ToArray();
if (descriptorPairs.Length == 0)
{
return Array.Empty<AuthorityPluginContext>();
}
var configurationDirectory = ResolveConfigurationDirectory(options.Plugins.ConfigurationDirectory, basePath);
var contexts = new List<AuthorityPluginContext>(descriptorPairs.Length);
foreach (var (name, descriptor) in descriptorPairs)
{
var configPath = ResolveConfigPath(configurationDirectory, descriptor.ConfigFile);
var optional = !descriptor.Enabled;
if (!optional && !File.Exists(configPath))
{
throw new FileNotFoundException($"Required Authority plugin configuration '{configPath}' was not found.", configPath);
}
var builder = new ConfigurationBuilder();
var builderBasePath = Path.GetDirectoryName(configPath);
if (!string.IsNullOrEmpty(builderBasePath) && Directory.Exists(builderBasePath))
{
builder.SetBasePath(builderBasePath);
}
configureBuilder?.Invoke(builder);
builder.AddYamlFile(configPath, optional: optional, reloadOnChange: false);
var configuration = builder.Build();
var manifest = descriptor.ToManifest(name, configPath);
contexts.Add(new AuthorityPluginContext(manifest, configuration));
}
return contexts;
}
private static string ResolveConfigurationDirectory(string configurationDirectory, string basePath)
{
if (string.IsNullOrWhiteSpace(configurationDirectory))
{
return Path.GetFullPath(basePath);
}
var directory = configurationDirectory;
if (!Path.IsPathRooted(directory))
{
directory = Path.Combine(basePath, directory);
}
return Path.GetFullPath(directory);
}
private static string ResolveConfigPath(string configurationDirectory, string? configFile)
{
if (string.IsNullOrWhiteSpace(configFile))
{
throw new InvalidOperationException("Authority plugin descriptor must specify a configFile.");
}
if (Path.IsPathRooted(configFile))
{
return Path.GetFullPath(configFile);
}
return Path.GetFullPath(Path.Combine(configurationDirectory, configFile));
}
}

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

@@ -0,0 +1,25 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="NetEscapades.Configuration.Yaml" Version="2.1.0" />
<PackageReference Include="System.Threading.RateLimiting" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Authority.Plugins.Abstractions/StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,57 @@
using System;
using System.Linq;
namespace StellaOps.Configuration;
/// <summary>
/// Helper utilities for bootstrapping StellaOps Authority configuration.
/// </summary>
public static class StellaOpsAuthorityConfiguration
{
private static readonly string[] DefaultAuthorityYamlFiles =
{
"authority.yaml",
"authority.local.yaml",
"etc/authority.yaml",
"etc/authority.local.yaml"
};
/// <summary>
/// Builds <see cref="StellaOpsAuthorityOptions"/> using the shared configuration bootstrapper.
/// </summary>
/// <param name="configure">Optional hook to customise bootstrap behaviour.</param>
public static StellaOpsConfigurationContext<StellaOpsAuthorityOptions> Build(
Action<StellaOpsBootstrapOptions<StellaOpsAuthorityOptions>>? configure = null)
{
return StellaOpsConfigurationBootstrapper.Build<StellaOpsAuthorityOptions>(options =>
{
options.BindingSection ??= "Authority";
options.EnvironmentPrefix ??= "STELLAOPS_AUTHORITY_";
configure?.Invoke(options);
AppendDefaultYamlFiles(options);
var previousPostBind = options.PostBind;
options.PostBind = (authorityOptions, configuration) =>
{
previousPostBind?.Invoke(authorityOptions, configuration);
authorityOptions.Validate();
};
});
}
private static void AppendDefaultYamlFiles(StellaOpsBootstrapOptions<StellaOpsAuthorityOptions> options)
{
foreach (var path in DefaultAuthorityYamlFiles)
{
var alreadyPresent = options.YamlFiles.Any(file =>
string.Equals(file.Path, path, StringComparison.OrdinalIgnoreCase));
if (!alreadyPresent)
{
options.YamlFiles.Add(new YamlConfigurationFile(path, Optional: true));
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Configuration;
public sealed class StellaOpsBootstrapOptions<TOptions>
where TOptions : class, new()
{
public StellaOpsBootstrapOptions()
{
ConfigurationOptions = new StellaOpsConfigurationOptions();
}
internal StellaOpsConfigurationOptions ConfigurationOptions { get; }
public string? BasePath
{
get => ConfigurationOptions.BasePath;
set => ConfigurationOptions.BasePath = value;
}
public bool IncludeJsonFiles
{
get => ConfigurationOptions.IncludeJsonFiles;
set => ConfigurationOptions.IncludeJsonFiles = value;
}
public bool IncludeYamlFiles
{
get => ConfigurationOptions.IncludeYamlFiles;
set => ConfigurationOptions.IncludeYamlFiles = value;
}
public bool IncludeEnvironmentVariables
{
get => ConfigurationOptions.IncludeEnvironmentVariables;
set => ConfigurationOptions.IncludeEnvironmentVariables = value;
}
public string? EnvironmentPrefix
{
get => ConfigurationOptions.EnvironmentPrefix;
set => ConfigurationOptions.EnvironmentPrefix = value;
}
public IList<JsonConfigurationFile> JsonFiles => ConfigurationOptions.JsonFiles;
public IList<YamlConfigurationFile> YamlFiles => ConfigurationOptions.YamlFiles;
public string? BindingSection
{
get => ConfigurationOptions.BindingSection;
set => ConfigurationOptions.BindingSection = value;
}
public Action<IConfigurationBuilder>? ConfigureBuilder
{
get => ConfigurationOptions.ConfigureBuilder;
set => ConfigurationOptions.ConfigureBuilder = value;
}
public Action<TOptions, IConfiguration>? PostBind { get; set; }
}

View File

@@ -0,0 +1,106 @@
using System;
using Microsoft.Extensions.Configuration;
using NetEscapades.Configuration.Yaml;
namespace StellaOps.Configuration;
public static class StellaOpsConfigurationBootstrapper
{
public static StellaOpsConfigurationContext<TOptions> Build<TOptions>(
Action<StellaOpsBootstrapOptions<TOptions>>? configure = null)
where TOptions : class, new()
{
var bootstrapOptions = new StellaOpsBootstrapOptions<TOptions>();
configure?.Invoke(bootstrapOptions);
var configurationOptions = bootstrapOptions.ConfigurationOptions;
var builder = new ConfigurationBuilder();
if (!string.IsNullOrWhiteSpace(configurationOptions.BasePath))
{
builder.SetBasePath(configurationOptions.BasePath!);
}
if (configurationOptions.IncludeJsonFiles)
{
foreach (var file in configurationOptions.JsonFiles)
{
builder.AddJsonFile(file.Path, optional: file.Optional, reloadOnChange: file.ReloadOnChange);
}
}
if (configurationOptions.IncludeYamlFiles)
{
foreach (var file in configurationOptions.YamlFiles)
{
builder.AddYamlFile(file.Path, optional: file.Optional);
}
}
configurationOptions.ConfigureBuilder?.Invoke(builder);
if (configurationOptions.IncludeEnvironmentVariables)
{
builder.AddEnvironmentVariables(configurationOptions.EnvironmentPrefix);
}
var configuration = builder.Build();
IConfiguration bindingSource;
if (string.IsNullOrWhiteSpace(configurationOptions.BindingSection))
{
bindingSource = configuration;
}
else
{
bindingSource = configuration.GetSection(configurationOptions.BindingSection!);
}
var options = new TOptions();
bindingSource.Bind(options);
bootstrapOptions.PostBind?.Invoke(options, configuration);
return new StellaOpsConfigurationContext<TOptions>(configuration, options);
}
public static IConfigurationBuilder AddStellaOpsDefaults(
this IConfigurationBuilder builder,
Action<StellaOpsConfigurationOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(builder);
var options = new StellaOpsConfigurationOptions();
configure?.Invoke(options);
if (!string.IsNullOrWhiteSpace(options.BasePath))
{
builder.SetBasePath(options.BasePath!);
}
if (options.IncludeJsonFiles)
{
foreach (var file in options.JsonFiles)
{
builder.AddJsonFile(file.Path, optional: file.Optional, reloadOnChange: file.ReloadOnChange);
}
}
if (options.IncludeYamlFiles)
{
foreach (var file in options.YamlFiles)
{
builder.AddYamlFile(file.Path, optional: file.Optional);
}
}
options.ConfigureBuilder?.Invoke(builder);
if (options.IncludeEnvironmentVariables)
{
builder.AddEnvironmentVariables(options.EnvironmentPrefix);
}
return builder;
}
}

View File

@@ -0,0 +1,18 @@
using System;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Configuration;
public sealed class StellaOpsConfigurationContext<TOptions>
where TOptions : class, new()
{
public StellaOpsConfigurationContext(IConfigurationRoot configuration, TOptions options)
{
Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
Options = options ?? throw new ArgumentNullException(nameof(options));
}
public IConfigurationRoot Configuration { get; }
public TOptions Options { get; }
}

View File

@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Configuration;
/// <summary>
/// Defines how default StellaOps configuration sources are composed.
/// </summary>
public sealed class StellaOpsConfigurationOptions
{
public string? BasePath { get; set; } = Directory.GetCurrentDirectory();
public bool IncludeJsonFiles { get; set; } = true;
public bool IncludeYamlFiles { get; set; } = true;
public bool IncludeEnvironmentVariables { get; set; } = true;
public string? EnvironmentPrefix { get; set; }
public IList<JsonConfigurationFile> JsonFiles { get; } = new List<JsonConfigurationFile>
{
new("appsettings.json", true, false),
new("appsettings.local.json", true, false)
};
public IList<YamlConfigurationFile> YamlFiles { get; } = new List<YamlConfigurationFile>
{
new("appsettings.yaml", true),
new("appsettings.local.yaml", true)
};
/// <summary>
/// Optional hook to register additional configuration sources (e.g. module-specific YAML files).
/// </summary>
public Action<IConfigurationBuilder>? ConfigureBuilder { get; set; }
/// <summary>
/// Optional configuration section name used when binding strongly typed options.
/// Null or empty indicates the root.
/// </summary>
public string? BindingSection { get; set; }
}
public sealed record JsonConfigurationFile(string Path, bool Optional = true, bool ReloadOnChange = false);
public sealed record YamlConfigurationFile(string Path, bool Optional = true);

View File

@@ -0,0 +1,26 @@
using System;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Configuration;
public static class StellaOpsOptionsBinder
{
public static TOptions BindOptions<TOptions>(
this IConfiguration configuration,
string? section = null,
Action<TOptions, IConfiguration>? postConfigure = null)
where TOptions : class, new()
{
ArgumentNullException.ThrowIfNull(configuration);
var options = new TOptions();
var bindingSource = string.IsNullOrWhiteSpace(section)
? configuration
: configuration.GetSection(section);
bindingSource.Bind(options);
postConfigure?.Invoke(options, configuration);
return options;
}
}

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,58 @@
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<DefaultCryptoProvider>(sp =>
{
var provider = new DefaultCryptoProvider();
configureProvider?.Invoke(provider);
return provider;
});
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, DefaultCryptoProvider>());
#if STELLAOPS_CRYPTO_SODIUM
services.TryAddSingleton<LibsodiumCryptoProvider>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, LibsodiumCryptoProvider>());
#endif
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="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,14 @@
# KMS & Key Management Guild Charter
## Mission
Provide key management abstractions and drivers (file, cloud KMS, HSM, FIDO2) for signing and verification workflows.
## Scope
- Key store interfaces, secure configuration loading, and audit logging.
- Drivers for file-based development keys, cloud KMS providers, PKCS#11 HSMs, and FIDO2 devices.
- Key rotation, revocation, and attestation for keys used in signing.
## Definition of Done
- KMS API supports signing, verification, key metadata, rotation, and revocation.
- Drivers pass integration tests and security review.
- CLI/Console can manage keys using these abstractions.

View File

@@ -0,0 +1,13 @@
# KMS Task Board — Epic 19: Attestor Console
## Sprint 72 Abstractions & File Driver
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| KMS-72-001 | TODO | KMS Guild | — | Implement KMS interface (sign, verify, metadata, rotate, revoke) and file-based key driver with encrypted at-rest storage. | Interface + file driver operational; unit tests cover sign/verify/rotation; lint passes. |
| KMS-72-002 | TODO | KMS Guild | KMS-72-001 | Add CLI support for importing/exporting file-based keys with password protection. | CLI commands functional; docs updated; integration tests pass. |
## Sprint 73 Cloud & HSM Integration
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| KMS-73-001 | TODO | KMS Guild | KMS-72-001 | Add cloud KMS driver (e.g., AWS KMS, GCP KMS) with signing and key metadata retrieval. | Cloud driver tested with mock; configuration documented; security review sign-off. |
| KMS-73-002 | TODO | KMS Guild | KMS-72-001 | Implement PKCS#11/HSM driver plus FIDO2 signing support for high assurance workflows. | HSM/FIDO2 drivers tested with hardware stubs; error handling documented. |

View File

@@ -0,0 +1,21 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.Plugin.BouncyCastle;
/// <summary>
/// Dependency injection helpers for registering the BouncyCastle Ed25519 crypto provider.
/// </summary>
public static class BouncyCastleCryptoServiceCollectionExtensions
{
public static IServiceCollection AddBouncyCastleEd25519Provider(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<BouncyCastleEd25519CryptoProvider>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, BouncyCastleEd25519CryptoProvider>());
return services;
}
}

View File

@@ -0,0 +1,211 @@
using System.Collections.Concurrent;
using Microsoft.IdentityModel.Tokens;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.Plugin.BouncyCastle;
/// <summary>
/// Ed25519 signing provider backed by BouncyCastle primitives.
/// </summary>
public sealed class BouncyCastleEd25519CryptoProvider : ICryptoProvider
{
private static readonly HashSet<string> SupportedAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
SignatureAlgorithms.Ed25519,
SignatureAlgorithms.EdDsa
};
private static readonly string[] DefaultKeyOps = { "sign", "verify" };
private readonly ConcurrentDictionary<string, KeyEntry> signingKeys = new(StringComparer.Ordinal);
public string Name => "bouncycastle.ed25519";
public bool Supports(CryptoCapability capability, string algorithmId)
{
if (string.IsNullOrWhiteSpace(algorithmId))
{
return false;
}
return capability switch
{
CryptoCapability.Signing or CryptoCapability.Verification => SupportedAlgorithms.Contains(algorithmId),
_ => false
};
}
public IPasswordHasher GetPasswordHasher(string algorithmId)
=> throw new NotSupportedException("BouncyCastle provider does not expose password hashing capabilities.");
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
{
ArgumentException.ThrowIfNullOrWhiteSpace(algorithmId);
ArgumentNullException.ThrowIfNull(keyReference);
if (!signingKeys.TryGetValue(keyReference.KeyId, out var entry))
{
throw new KeyNotFoundException($"Signing key '{keyReference.KeyId}' is not registered with provider '{Name}'.");
}
EnsureAlgorithmSupported(algorithmId);
var normalized = NormalizeAlgorithm(algorithmId);
if (!string.Equals(entry.Descriptor.AlgorithmId, normalized, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Signing key '{keyReference.KeyId}' is registered for algorithm '{entry.Descriptor.AlgorithmId}', not '{algorithmId}'.");
}
return new Ed25519SignerWrapper(entry);
}
public void UpsertSigningKey(CryptoSigningKey signingKey)
{
ArgumentNullException.ThrowIfNull(signingKey);
EnsureAlgorithmSupported(signingKey.AlgorithmId);
if (signingKey.Kind != CryptoSigningKeyKind.Raw)
{
throw new InvalidOperationException($"Provider '{Name}' requires raw Ed25519 private key material.");
}
var privateKey = NormalizePrivateKey(signingKey.PrivateKey);
var publicKey = NormalizePublicKey(signingKey.PublicKey, privateKey);
var privateKeyParameters = new Ed25519PrivateKeyParameters(privateKey, 0);
var publicKeyParameters = new Ed25519PublicKeyParameters(publicKey, 0);
var descriptor = new CryptoSigningKey(
signingKey.Reference,
NormalizeAlgorithm(signingKey.AlgorithmId),
privateKey,
signingKey.CreatedAt,
signingKey.ExpiresAt,
publicKey,
signingKey.Metadata);
signingKeys.AddOrUpdate(
signingKey.Reference.KeyId,
_ => new KeyEntry(descriptor, privateKeyParameters, publicKeyParameters),
(_, _) => new KeyEntry(descriptor, privateKeyParameters, publicKeyParameters));
}
public bool RemoveSigningKey(string keyId)
{
if (string.IsNullOrWhiteSpace(keyId))
{
return false;
}
return signingKeys.TryRemove(keyId, out _);
}
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys()
=> signingKeys.Values.Select(static entry => entry.Descriptor).ToArray();
private static void EnsureAlgorithmSupported(string algorithmId)
{
if (!SupportedAlgorithms.Contains(algorithmId))
{
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider 'bouncycastle.ed25519'.");
}
}
private static string NormalizeAlgorithm(string algorithmId)
=> string.Equals(algorithmId, SignatureAlgorithms.EdDsa, StringComparison.OrdinalIgnoreCase)
? SignatureAlgorithms.Ed25519
: SignatureAlgorithms.Ed25519;
private static byte[] NormalizePrivateKey(ReadOnlyMemory<byte> privateKey)
{
var span = privateKey.Span;
return span.Length switch
{
32 => span.ToArray(),
64 => span[..32].ToArray(),
_ => throw new InvalidOperationException("Ed25519 private key must be 32 or 64 bytes.")
};
}
private static byte[] NormalizePublicKey(ReadOnlyMemory<byte> publicKey, byte[] privateKey)
{
if (publicKey.IsEmpty)
{
var privateParams = new Ed25519PrivateKeyParameters(privateKey, 0);
return privateParams.GeneratePublicKey().GetEncoded();
}
if (publicKey.Span.Length != 32)
{
throw new InvalidOperationException("Ed25519 public key must be 32 bytes.");
}
return publicKey.ToArray();
}
private sealed record KeyEntry(
CryptoSigningKey Descriptor,
Ed25519PrivateKeyParameters PrivateKey,
Ed25519PublicKeyParameters PublicKey);
private sealed class Ed25519SignerWrapper : ICryptoSigner
{
private readonly KeyEntry entry;
public Ed25519SignerWrapper(KeyEntry entry)
{
this.entry = entry ?? throw new ArgumentNullException(nameof(entry));
}
public string KeyId => entry.Descriptor.Reference.KeyId;
public string AlgorithmId => entry.Descriptor.AlgorithmId;
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var signer = new Ed25519Signer();
var buffer = data.ToArray();
signer.Init(true, entry.PrivateKey);
signer.BlockUpdate(buffer, 0, buffer.Length);
var signature = signer.GenerateSignature();
return ValueTask.FromResult(signature);
}
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var verifier = new Ed25519Signer();
var buffer = data.ToArray();
verifier.Init(false, entry.PublicKey);
verifier.BlockUpdate(buffer, 0, buffer.Length);
var verified = verifier.VerifySignature(signature.ToArray());
return ValueTask.FromResult(verified);
}
public JsonWebKey ExportPublicJsonWebKey()
{
var jwk = new JsonWebKey
{
Kid = entry.Descriptor.Reference.KeyId,
Alg = SignatureAlgorithms.EdDsa,
Kty = "OKP",
Use = JsonWebKeyUseNames.Sig,
Crv = "Ed25519"
};
foreach (var op in DefaultKeyOps)
{
jwk.KeyOps.Add(op);
}
jwk.X = Base64UrlEncoder.Encode(entry.PublicKey.GetEncoded());
return jwk;
}
}
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,22 @@
# Team 8 — Security Guild (Authority & Shared Crypto)
## Role
Team 8 owns the end-to-end security posture for StellaOps Authority and its consumers. That includes password hashing policy, audit/event hygiene, rate-limit & lockout rules, revocation distribution, and sovereign cryptography abstractions that allow alternative algorithm suites (e.g., GOST) without touching feature code.
## Operational Boundaries
- Primary workspace: `src/__Libraries/StellaOps.Cryptography`, `src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard`, `src/Authority/StellaOps.Authority/StellaOps.Authority.Storage.Mongo`, and Authority host (`src/Authority/StellaOps.Authority/StellaOps.Authority`).
- Coordinate cross-module changes via TASKS.md updates and PR descriptions.
- Never bypass deterministic behaviour (sorted keys, stable timestamps).
- Tests live alongside owning projects (`*.Tests`). Extend goldens instead of rewriting.
## Expectations
- Default to Argon2id (Konscious) for password hashing; PBKDF2 only for legacy verification with transparent rehash on success.
- Emit structured security events with minimal PII and clear correlation IDs.
- Rate-limit `/token` and bootstrap endpoints once CORE8 hooks are available.
- Deliver offline revocation bundles signed with detached JWS and provide a verification script.
- Maintain `docs/security/authority-threat-model.md` and ensure mitigations are tracked.
- All crypto consumption flows through `StellaOps.Cryptography` abstractions to enable sovereign crypto providers.
- Every new cryptographic algorithm, dependency, or acceleration path ships as an `ICryptoProvider` plug-in under `StellaOps.Cryptography.*`; feature code must never bind directly to third-party crypto libraries.

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,268 @@
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>
/// Tenant identifier associated with the authenticated principal or client.
/// </summary>
public ClassifiedString Tenant { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Project identifier associated with the authenticated principal or client (optional).
/// </summary>
public ClassifiedString Project { get; init; } = ClassifiedString.Empty;
/// <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

@@ -0,0 +1,86 @@
using System.Collections.Generic;
namespace StellaOps.Cryptography;
/// <summary>
/// High-level cryptographic capabilities supported by StellaOps providers.
/// </summary>
public enum CryptoCapability
{
PasswordHashing,
Signing,
Verification,
SymmetricEncryption,
KeyDerivation
}
/// <summary>
/// Identifies a stored key or certificate handle.
/// </summary>
public sealed record CryptoKeyReference(string KeyId, string? ProviderHint = null);
/// <summary>
/// Contract implemented by crypto providers (BCL, CryptoPro, OpenSSL, etc.).
/// </summary>
public interface ICryptoProvider
{
string Name { get; }
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>
/// Registry managing provider discovery and policy selection.
/// </summary>
public interface ICryptoProviderRegistry
{
IReadOnlyCollection<ICryptoProvider> Providers { get; }
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>
CryptoSignerResolution ResolveSigner(
CryptoCapability capability,
string algorithmId,
CryptoKeyReference keyReference,
string? preferredProvider = null);
}
public sealed record CryptoSignerResolution(ICryptoSigner Signer, string ProviderName);

View File

@@ -0,0 +1,114 @@
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 CryptoSignerResolution 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}'.");
}
var signer = hinted.GetSigner(algorithmId, keyReference);
return new CryptoSignerResolution(signer, hinted.Name);
}
var provider = ResolveOrThrow(capability, algorithmId);
var resolved = provider.GetSigner(algorithmId, keyReference);
return new CryptoSignerResolution(resolved, provider.Name);
}
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,176 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Security.Cryptography;
namespace StellaOps.Cryptography;
/// <summary>
/// Describes the underlying key material for a <see cref="CryptoSigningKey"/>.
/// </summary>
public enum CryptoSigningKeyKind
{
Ec,
Raw
}
/// <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));
private static readonly byte[] EmptyKey = Array.Empty<byte>();
private readonly byte[] privateKeyBytes;
private readonly byte[] publicKeyBytes;
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;
Kind = CryptoSigningKeyKind.Ec;
privateKeyBytes = EmptyKey;
publicKeyBytes = EmptyKey;
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));
}
public CryptoSigningKey(
CryptoKeyReference reference,
string algorithmId,
ReadOnlyMemory<byte> privateKey,
DateTimeOffset createdAt,
DateTimeOffset? expiresAt = null,
ReadOnlyMemory<byte> publicKey = default,
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 (privateKey.IsEmpty)
{
throw new ArgumentException("Private key material must be provided.", nameof(privateKey));
}
AlgorithmId = algorithmId;
CreatedAt = createdAt;
ExpiresAt = expiresAt;
Kind = CryptoSigningKeyKind.Raw;
privateKeyBytes = privateKey.ToArray();
publicKeyBytes = publicKey.IsEmpty ? EmptyKey : publicKey.ToArray();
PrivateParameters = default;
PublicParameters = default;
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>
/// Indicates the underlying key material representation.
/// </summary>
public CryptoSigningKeyKind Kind { get; }
/// <summary>
/// Gets the raw private key bytes when available (empty for EC-backed keys).
/// </summary>
public ReadOnlyMemory<byte> PrivateKey => privateKeyBytes;
/// <summary>
/// Gets the raw public key bytes when available (empty for EC-backed keys or when not supplied).
/// </summary>
public ReadOnlyMemory<byte> PublicKey => publicKeyBytes;
/// <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,133 @@
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);
if (signingKey.Kind != CryptoSigningKeyKind.Ec)
{
throw new InvalidOperationException($"Provider '{Name}' only accepts EC signing keys.");
}
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,128 @@
#if STELLAOPS_CRYPTO_SODIUM
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Cryptography;
/// <summary>
/// Libsodium-backed crypto provider (ES256) registered when <c>STELLAOPS_CRYPTO_SODIUM</c> is defined.
/// </summary>
public sealed class LibsodiumCryptoProvider : ICryptoProvider
{
private static readonly HashSet<string> SupportedAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
SignatureAlgorithms.Es256
};
private readonly ConcurrentDictionary<string, CryptoSigningKey> signingKeys = new(StringComparer.Ordinal);
public string Name => "libsodium";
public bool Supports(CryptoCapability capability, string algorithmId)
{
if (string.IsNullOrWhiteSpace(algorithmId))
{
return false;
}
return capability switch
{
CryptoCapability.Signing or CryptoCapability.Verification => SupportedAlgorithms.Contains(algorithmId),
_ => false
};
}
public IPasswordHasher GetPasswordHasher(string algorithmId)
=> throw new NotSupportedException("Libsodium provider does not expose password hashing capabilities.");
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
{
ArgumentNullException.ThrowIfNull(keyReference);
EnsureAlgorithmSupported(algorithmId);
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 new LibsodiumEcdsaSigner(signingKey);
}
public void UpsertSigningKey(CryptoSigningKey signingKey)
{
ArgumentNullException.ThrowIfNull(signingKey);
EnsureAlgorithmSupported(signingKey.AlgorithmId);
if (signingKey.Kind != CryptoSigningKeyKind.Ec)
{
throw new InvalidOperationException($"Provider '{Name}' only accepts EC signing keys.");
}
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 EnsureAlgorithmSupported(string algorithmId)
{
if (!SupportedAlgorithms.Contains(algorithmId))
{
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider 'libsodium'.");
}
}
private sealed class LibsodiumEcdsaSigner : ICryptoSigner
{
private readonly CryptoSigningKey signingKey;
private readonly ICryptoSigner fallbackSigner;
public LibsodiumEcdsaSigner(CryptoSigningKey signingKey)
{
this.signingKey = signingKey ?? throw new ArgumentNullException(nameof(signingKey));
fallbackSigner = EcdsaSigner.Create(signingKey);
}
public string KeyId => signingKey.Reference.KeyId;
public string AlgorithmId => signingKey.AlgorithmId;
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
// TODO(SEC5.B1): replace fallback with libsodium bindings once native interop lands.
return fallbackSigner.SignAsync(data, cancellationToken);
}
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return fallbackSigner.VerifyAsync(data, signature, cancellationToken);
}
public JsonWebKey ExportPublicJsonWebKey()
=> fallbackSigner.ExportPublicJsonWebKey();
}
}
#endif

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,81 @@
using System;
namespace StellaOps.Cryptography;
/// <summary>
/// Supported password hashing algorithms.
/// </summary>
public enum PasswordHashAlgorithm
{
Argon2id,
Pbkdf2
}
/// <summary>
/// Options describing password hashing requirements.
/// Values follow OWASP baseline guidance by default.
/// </summary>
public sealed record PasswordHashOptions
{
/// <summary>
/// Algorithm to use when hashing new passwords.
/// </summary>
public PasswordHashAlgorithm Algorithm { get; init; } = PasswordHashAlgorithm.Argon2id;
/// <summary>
/// Memory cost in KiB (default 19 MiB).
/// </summary>
public int MemorySizeInKib { get; init; } = 19 * 1024;
/// <summary>
/// Iteration count / time cost.
/// </summary>
public int Iterations { get; init; } = 2;
/// <summary>
/// Parallelism / degree of concurrency.
/// </summary>
public int Parallelism { get; init; } = 1;
/// <summary>
/// Validates the option values and throws when invalid.
/// </summary>
public void Validate()
{
if (MemorySizeInKib <= 0)
{
throw new InvalidOperationException("Password hashing memory cost must be greater than zero.");
}
if (Iterations <= 0)
{
throw new InvalidOperationException("Password hashing iteration count must be greater than zero.");
}
if (Parallelism <= 0)
{
throw new InvalidOperationException("Password hashing parallelism must be greater than zero.");
}
}
}
/// <summary>
/// Abstraction for password hashing implementations.
/// </summary>
public interface IPasswordHasher
{
/// <summary>
/// Produces an encoded hash for the supplied password.
/// </summary>
string Hash(string password, PasswordHashOptions options);
/// <summary>
/// Verifies the supplied password against a stored hash.
/// </summary>
bool Verify(string password, string encodedHash);
/// <summary>
/// Detects when an existing encoded hash no longer satisfies the desired options.
/// </summary>
bool NeedsRehash(string encodedHash, PasswordHashOptions desired);
}

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,13 @@
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";
public const string Ed25519 = "ED25519";
public const string EdDsa = "EdDSA";
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<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="8.14.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,51 @@
# Team 8 — Security Guild Task Board (UTC 2025-10-10)
| ID | Status | Owner | Description | Dependencies | Exit Criteria |
|----|--------|-------|-------------|--------------|---------------|
| 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 | DONE (2025-10-13) | 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 | DONE (2025-10-13) | 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 | DONE (2025-10-12) | 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 | DONE (2025-10-13) | 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 | 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 | DONE (2025-10-14) | 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.B1 | DONE (2025-10-14) | Security Guild + Authority Core | Introduce `LibsodiumCryptoProvider` implementing ECDSA signing/verification via libsodium, register under feature flag, and validate against existing ES256 fixtures. | SEC5.B | ✅ Provider resolves via `ICryptoProviderRegistry`; ✅ Integration tests cover sign/verify parity with default provider; ✅ Fallback to managed provider documented. |
| SEC5.B2 | DONE (2025-10-14) | Security Guild + DevEx/CLI | Extend `stellaops auth revoke verify` to detect provider metadata, reuse registry for verification, and document CLI workflow. | SEC5.B | ✅ CLI uses registry signers for verification; ✅ End-to-end test invokes verify against sample bundle; ✅ docs/11_AUTHORITY.md references CLI procedure. |
| SEC5.C | DONE (2025-10-14) | 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 | DONE (2025-10-14) | 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. |
> Remark (2025-10-14): Cleanup service wired to store; background sweep + invite audit tests added.
| SEC5.E | DONE (2025-10-14) | 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. |
> Remark (2025-10-14): Token usage metadata persisted with replay audits + handler/unit coverage.
| SEC5.F | DONE (2025-10-14) | 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. |
> Remark (2025-10-14): Analyzer surfaces warnings during CLI load; docs updated with mitigation steps.
| SEC5.G | DONE (2025-10-14) | 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. |
> Remark (2025-10-14): Offline kit docs include manifest verification workflow; attestation artifacts referenced.
| SEC5.H | DONE (2025-10-13) | 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. |
| SEC6.A | DONE (2025-10-19) | Security Guild | Ship BouncyCastle-backed Ed25519 signing as a `StellaOps.Cryptography` plug-in and migrate Scanner WebService signing to consume the provider registry; codify the plug-in rule in AGENTS.<br>2025-10-19: Added `StellaOps.Cryptography.Plugin.BouncyCastle`, updated DI and ReportSigner, captured provider tests (`BouncyCastleEd25519CryptoProviderTests`). | D5.A | ✅ Plug-in registered via DI (`AddStellaOpsCrypto` + `AddBouncyCastleEd25519Provider`); ✅ Report signer resolves keys through registry; ✅ Unit tests cover Ed25519 sign/verify via provider. |
> Remark (2025-10-13, SEC2.B): Coordinated with Authority Core — audit sinks now receive `/token` success/failure events; awaiting host test suite once signing fixture lands.
>
> Remark (2025-10-13, SEC3.B): Pinged Docs & Plugin guilds — rate limit guidance published in `docs/security/rate-limits.md` and flagged for PLG6.DOC copy lift.
>
> Remark (2025-10-13, SEC5.B): Split follow-up into SEC5.B1 (libsodium provider) and SEC5.B2 (CLI verification) after scoping registry integration; work not yet started.
> Remark (2025-10-13, SEC2.B): Coordinated with Authority Core — audit sinks now receive `/token` success/failure events; awaiting host test suite once signing fixture lands.
>
> Remark (2025-10-13, SEC3.B): Pinged Docs & Plugin guilds — rate limit guidance published in `docs/security/rate-limits.md` and flagged for PLG6.DOC copy lift.
>
> Remark (2025-10-13, SEC5.B): Split follow-up into SEC5.B1 (libsodium provider) and SEC5.B2 (CLI verification) after scoping registry integration; work not yet started.
## Notes
- Target Argon2 parameters follow OWASP Cheat Sheet (memory ≈ 19MiB, iterations 2, parallelism 1). Allow overrides via configuration.
- When CORE8 lands, pair with Team 2 to expose request context information required by the rate limiter (client_id enrichment).
- Revocation bundle must be consumable offline; include issue timestamp, signing key metadata, and reasons.
- All crypto usage in Authority code should funnel through the new abstractions (`ICryptoProvider`), enabling future CryptoPro/OpenSSL providers.
## Done Definition
- Code merges include unit/integration tests and documentation updates.
- `TASKS.md` status transitions (TODO → DOING → DONE/BLOCKED) must happen in the same PR as the work.
- Prior to marking DONE: run `dotnet test` for touched solutions and attach excerpt to PR description.

View File

@@ -0,0 +1,11 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.DependencyInjection;
public interface IDependencyInjectionRoutine
{
IServiceCollection Register(
IServiceCollection services,
IConfiguration configuration);
}

View File

@@ -0,0 +1,64 @@
using System;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.DependencyInjection;
/// <summary>
/// Declares how a plug-in type should be registered with the host dependency injection container.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
public sealed class ServiceBindingAttribute : Attribute
{
/// <summary>
/// Creates a binding that registers the decorated type as itself with a singleton lifetime.
/// </summary>
public ServiceBindingAttribute()
: this(null, ServiceLifetime.Singleton)
{
}
/// <summary>
/// Creates a binding that registers the decorated type as itself with the specified lifetime.
/// </summary>
public ServiceBindingAttribute(ServiceLifetime lifetime)
: this(null, lifetime)
{
}
/// <summary>
/// Creates a binding that registers the decorated type as the specified service type with a singleton lifetime.
/// </summary>
public ServiceBindingAttribute(Type serviceType)
: this(serviceType, ServiceLifetime.Singleton)
{
}
/// <summary>
/// Creates a binding that registers the decorated type as the specified service type.
/// </summary>
public ServiceBindingAttribute(Type? serviceType, ServiceLifetime lifetime)
{
ServiceType = serviceType;
Lifetime = lifetime;
}
/// <summary>
/// The service contract that should resolve to the decorated implementation. When <c>null</c>, the implementation registers itself.
/// </summary>
public Type? ServiceType { get; }
/// <summary>
/// The lifetime that should be used when registering the decorated implementation.
/// </summary>
public ServiceLifetime Lifetime { get; }
/// <summary>
/// Indicates whether existing descriptors for the same service type should be removed before this binding is applied.
/// </summary>
public bool ReplaceExisting { get; init; }
/// <summary>
/// When true, the implementation is also registered as itself even if a service type is specified.
/// </summary>
public bool RegisterAsSelf { get; init; }
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,93 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.DependencyInjection;
using StellaOps.Plugin.Hosting;
using StellaOps.Plugin.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Plugin.DependencyInjection;
public static class PluginDependencyInjectionExtensions
{
public static IServiceCollection RegisterPluginRoutines(
this IServiceCollection services,
IConfiguration configuration,
PluginHostOptions options,
ILogger? logger = null)
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
}
if (configuration == null)
{
throw new ArgumentNullException(nameof(configuration));
}
if (options == null)
{
throw new ArgumentNullException(nameof(options));
}
var loadResult = PluginHost.LoadPlugins(options, logger);
foreach (var plugin in loadResult.Plugins)
{
PluginServiceRegistration.RegisterAssemblyMetadata(services, plugin.Assembly, logger);
foreach (var routine in CreateRoutines(plugin.Assembly))
{
logger?.LogDebug(
"Registering DI routine '{RoutineType}' from plugin '{PluginAssembly}'.",
routine.GetType().FullName,
plugin.Assembly.FullName);
routine.Register(services, configuration);
}
}
if (loadResult.MissingOrderedPlugins.Count > 0)
{
logger?.LogWarning(
"Some ordered plugins were not found: {Missing}",
string.Join(", ", loadResult.MissingOrderedPlugins));
}
return services;
}
private static IEnumerable<IDependencyInjectionRoutine> CreateRoutines(System.Reflection.Assembly assembly)
{
foreach (var type in assembly.GetLoadableTypes())
{
if (type is null || type.IsAbstract || type.IsInterface)
{
continue;
}
if (!typeof(IDependencyInjectionRoutine).IsAssignableFrom(type))
{
continue;
}
object? instance;
try
{
instance = Activator.CreateInstance(type);
}
catch
{
continue;
}
if (instance is IDependencyInjectionRoutine routine)
{
yield return routine;
}
}
}
}

View File

@@ -0,0 +1,169 @@
using System;
using System.Linq;
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.DependencyInjection;
using StellaOps.Plugin.Internal;
namespace StellaOps.Plugin.DependencyInjection;
public static class PluginServiceRegistration
{
public static void RegisterAssemblyMetadata(IServiceCollection services, Assembly assembly, ILogger? logger)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(assembly);
foreach (var implementationType in assembly.GetLoadableTypes())
{
if (implementationType is null || !implementationType.IsClass || implementationType.IsAbstract)
{
continue;
}
var attributes = implementationType.GetCustomAttributes<ServiceBindingAttribute>(inherit: false);
if (!attributes.Any())
{
continue;
}
foreach (var attribute in attributes)
{
try
{
ApplyBinding(services, implementationType, attribute, logger);
}
catch (Exception ex)
{
logger?.LogWarning(
ex,
"Failed to register service binding for implementation '{Implementation}' declared in assembly '{Assembly}'.",
implementationType.FullName ?? implementationType.Name,
assembly.FullName ?? assembly.GetName().Name);
}
}
}
}
private static void ApplyBinding(
IServiceCollection services,
Type implementationType,
ServiceBindingAttribute attribute,
ILogger? logger)
{
var serviceType = attribute.ServiceType ?? implementationType;
if (!IsValidBinding(serviceType, implementationType))
{
logger?.LogWarning(
"Service binding metadata ignored: implementation '{Implementation}' is not assignable to service '{Service}'.",
implementationType.FullName ?? implementationType.Name,
serviceType.FullName ?? serviceType.Name);
return;
}
if (attribute.ReplaceExisting)
{
RemoveExistingDescriptors(services, serviceType);
}
AddDescriptorIfMissing(services, serviceType, implementationType, attribute.Lifetime, logger);
if (attribute.RegisterAsSelf && serviceType != implementationType)
{
AddDescriptorIfMissing(services, implementationType, implementationType, attribute.Lifetime, logger);
}
}
private static bool IsValidBinding(Type serviceType, Type implementationType)
{
if (serviceType.IsGenericTypeDefinition)
{
return implementationType.IsGenericTypeDefinition
&& implementationType.IsClass
&& implementationType.IsAssignableToGenericTypeDefinition(serviceType);
}
return serviceType.IsAssignableFrom(implementationType);
}
private static void AddDescriptorIfMissing(
IServiceCollection services,
Type serviceType,
Type implementationType,
ServiceLifetime lifetime,
ILogger? logger)
{
if (services.Any(descriptor =>
descriptor.ServiceType == serviceType &&
descriptor.ImplementationType == implementationType))
{
logger?.LogDebug(
"Skipping duplicate service binding for {ServiceType} -> {ImplementationType}.",
serviceType.FullName ?? serviceType.Name,
implementationType.FullName ?? implementationType.Name);
return;
}
ServiceDescriptor descriptor;
if (serviceType.IsGenericTypeDefinition || implementationType.IsGenericTypeDefinition)
{
descriptor = ServiceDescriptor.Describe(serviceType, implementationType, lifetime);
}
else
{
descriptor = new ServiceDescriptor(serviceType, implementationType, lifetime);
}
services.Add(descriptor);
logger?.LogDebug(
"Registered service binding {ServiceType} -> {ImplementationType} with {Lifetime} lifetime.",
serviceType.FullName ?? serviceType.Name,
implementationType.FullName ?? implementationType.Name,
lifetime);
}
private static void RemoveExistingDescriptors(IServiceCollection services, Type serviceType)
{
for (var i = services.Count - 1; i >= 0; i--)
{
if (services[i].ServiceType == serviceType)
{
services.RemoveAt(i);
}
}
}
private static bool IsAssignableToGenericTypeDefinition(this Type implementationType, Type serviceTypeDefinition)
{
if (!serviceTypeDefinition.IsGenericTypeDefinition)
{
return false;
}
if (implementationType == serviceTypeDefinition)
{
return true;
}
if (implementationType.IsGenericType && implementationType.GetGenericTypeDefinition() == serviceTypeDefinition)
{
return true;
}
var interfaces = implementationType.GetInterfaces();
foreach (var iface in interfaces)
{
if (iface.IsGenericType && iface.GetGenericTypeDefinition() == serviceTypeDefinition)
{
return true;
}
}
var baseType = implementationType.BaseType;
return baseType is not null && baseType.IsGenericTypeDefinition
? baseType.GetGenericTypeDefinition() == serviceTypeDefinition
: baseType is not null && baseType.IsAssignableToGenericTypeDefinition(serviceTypeDefinition);
}
}

View File

@@ -0,0 +1,26 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
namespace StellaOps.Plugin.DependencyInjection;
public static class StellaOpsPluginRegistration
{
public static IServiceCollection RegisterStellaOpsPlugin(
this IServiceCollection services,
IConfiguration configuration)
{
// No-op today but reserved for future plugin infrastructure services.
return services;
}
}
public sealed class DependencyInjectionRoutine : IDependencyInjectionRoutine
{
public IServiceCollection Register(
IServiceCollection services,
IConfiguration configuration)
{
return services.RegisterStellaOpsPlugin(configuration);
}
}

View File

@@ -0,0 +1,21 @@
using System.Reflection;
namespace StellaOps.Plugin.Hosting;
public sealed class PluginAssembly
{
internal PluginAssembly(string assemblyPath, Assembly assembly, PluginLoadContext loadContext)
{
AssemblyPath = assemblyPath;
Assembly = assembly;
LoadContext = loadContext;
}
public string AssemblyPath { get; }
public Assembly Assembly { get; }
internal PluginLoadContext LoadContext { get; }
public override string ToString() => Assembly.FullName ?? AssemblyPath;
}

View File

@@ -0,0 +1,219 @@
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
namespace StellaOps.Plugin.Hosting;
public static class PluginHost
{
private static readonly object Sync = new();
private static readonly Dictionary<string, PluginAssembly> LoadedPlugins = new(StringComparer.OrdinalIgnoreCase);
public static PluginHostResult LoadPlugins(PluginHostOptions options, ILogger? logger = null)
{
if (options == null)
{
throw new ArgumentNullException(nameof(options));
}
var baseDirectory = options.ResolveBaseDirectory();
var pluginDirectory = ResolvePluginDirectory(options, baseDirectory);
if (options.EnsureDirectoryExists && !Directory.Exists(pluginDirectory))
{
Directory.CreateDirectory(pluginDirectory);
}
if (!Directory.Exists(pluginDirectory))
{
logger?.LogWarning("Plugin directory '{PluginDirectory}' does not exist; no plugins will be loaded.", pluginDirectory);
return new PluginHostResult(pluginDirectory, Array.Empty<string>(), Array.Empty<PluginAssembly>(), Array.Empty<string>());
}
var searchPatterns = BuildSearchPatterns(options, pluginDirectory);
var discovered = DiscoverPluginFiles(pluginDirectory, searchPatterns, options.RecursiveSearch, logger);
var orderedFiles = ApplyExplicitOrdering(discovered, options.PluginOrder, out var missingOrderedNames);
var loaded = new List<PluginAssembly>(orderedFiles.Count);
lock (Sync)
{
foreach (var file in orderedFiles)
{
if (LoadedPlugins.TryGetValue(file, out var existing))
{
loaded.Add(existing);
continue;
}
try
{
var loadContext = new PluginLoadContext(file);
var assembly = loadContext.LoadFromAssemblyPath(file);
var descriptor = new PluginAssembly(file, assembly, loadContext);
LoadedPlugins[file] = descriptor;
loaded.Add(descriptor);
logger?.LogInformation("Loaded plugin assembly '{Assembly}' from '{Path}'.", assembly.FullName, file);
}
catch (Exception ex)
{
logger?.LogError(ex, "Failed to load plugin assembly from '{Path}'.", file);
}
}
}
var missingOrdered = new ReadOnlyCollection<string>(missingOrderedNames);
return new PluginHostResult(pluginDirectory, searchPatterns, new ReadOnlyCollection<PluginAssembly>(loaded), missingOrdered);
}
private static string ResolvePluginDirectory(PluginHostOptions options, string baseDirectory)
{
if (string.IsNullOrWhiteSpace(options.PluginsDirectory))
{
var defaultDirectory = !string.IsNullOrWhiteSpace(options.PrimaryPrefix)
? $"{options.PrimaryPrefix}.PluginBinaries"
: "PluginBinaries";
return Path.Combine(baseDirectory, defaultDirectory);
}
if (Path.IsPathRooted(options.PluginsDirectory))
{
return options.PluginsDirectory;
}
return Path.Combine(baseDirectory, options.PluginsDirectory);
}
private static IReadOnlyList<string> BuildSearchPatterns(PluginHostOptions options, string pluginDirectory)
{
var patterns = new List<string>();
if (options.SearchPatterns.Count > 0)
{
patterns.AddRange(options.SearchPatterns);
}
else
{
var prefixes = new List<string>();
if (!string.IsNullOrWhiteSpace(options.PrimaryPrefix))
{
prefixes.Add(options.PrimaryPrefix);
}
else if (System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name is { } entryName)
{
prefixes.Add(entryName);
}
prefixes.AddRange(options.AdditionalPrefixes);
if (prefixes.Count == 0)
{
// Fallback to directory name
prefixes.Add(Path.GetFileName(pluginDirectory));
}
foreach (var prefix in prefixes.Where(p => !string.IsNullOrWhiteSpace(p)))
{
patterns.Add($"{prefix}.Plugin.*.dll");
}
}
return new ReadOnlyCollection<string>(patterns.Distinct(StringComparer.OrdinalIgnoreCase).ToList());
}
private static List<string> DiscoverPluginFiles(
string pluginDirectory,
IReadOnlyList<string> searchPatterns,
bool recurse,
ILogger? logger)
{
var files = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var searchOption = recurse ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
foreach (var pattern in searchPatterns)
{
try
{
foreach (var file in Directory.EnumerateFiles(pluginDirectory, pattern, searchOption))
{
if (IsHiddenPath(file))
{
continue;
}
if (seen.Add(file))
{
files.Add(file);
}
}
}
catch (DirectoryNotFoundException)
{
// Directory could be removed between the existence check and enumeration.
logger?.LogDebug("Plugin directory '{PluginDirectory}' disappeared before enumeration.", pluginDirectory);
}
}
return files;
}
private static List<string> ApplyExplicitOrdering(
List<string> discoveredFiles,
IList<string> pluginOrder,
out List<string> missingNames)
{
if (pluginOrder.Count == 0 || discoveredFiles.Count == 0)
{
missingNames = new List<string>();
discoveredFiles.Sort(StringComparer.OrdinalIgnoreCase);
return discoveredFiles;
}
var configuredSet = new HashSet<string>(pluginOrder, StringComparer.OrdinalIgnoreCase);
var fileLookup = discoveredFiles.ToDictionary(
k => Path.GetFileNameWithoutExtension(k),
StringComparer.OrdinalIgnoreCase);
var specified = new List<string>();
foreach (var name in pluginOrder)
{
if (fileLookup.TryGetValue(name, out var file))
{
specified.Add(file);
}
}
var unspecified = discoveredFiles
.Where(f => !configuredSet.Contains(Path.GetFileNameWithoutExtension(f)))
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
.ToList();
missingNames = pluginOrder
.Where(name => !fileLookup.ContainsKey(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
specified.AddRange(unspecified);
return specified;
}
private static bool IsHiddenPath(string filePath)
{
var directory = Path.GetDirectoryName(filePath);
while (!string.IsNullOrEmpty(directory))
{
var name = Path.GetFileName(directory);
if (name.StartsWith(".", StringComparison.Ordinal))
{
return true;
}
directory = Path.GetDirectoryName(directory);
}
return false;
}
}

View File

@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace StellaOps.Plugin.Hosting;
public sealed class PluginHostOptions
{
private readonly List<string> additionalPrefixes = new();
private readonly List<string> pluginOrder = new();
private readonly List<string> searchPatterns = new();
/// <summary>
/// Optional base directory used for resolving relative plugin paths. Defaults to <see cref="AppContext.BaseDirectory" />.
/// </summary>
public string? BaseDirectory { get; set; }
/// <summary>
/// Directory that contains plugin assemblies. Relative values are resolved against <see cref="BaseDirectory" />.
/// Defaults to <c>{PrimaryPrefix}.PluginBinaries</c> when a primary prefix is provided, otherwise <c>PluginBinaries</c>.
/// </summary>
public string? PluginsDirectory { get; set; }
/// <summary>
/// Primary prefix used to discover plugin assemblies. If not supplied, the entry assembly name is used.
/// </summary>
public string? PrimaryPrefix { get; set; }
/// <summary>
/// Additional prefixes that should be considered when building search patterns.
/// </summary>
public IList<string> AdditionalPrefixes => additionalPrefixes;
/// <summary>
/// Explicit plugin ordering expressed as assembly names without extension.
/// Entries that are not discovered will be reported in <see cref="PluginHostResult.MissingOrderedPlugins" />.
/// </summary>
public IList<string> PluginOrder => pluginOrder;
/// <summary>
/// Optional explicit search patterns. When empty, they are derived from prefix settings.
/// </summary>
public IList<string> SearchPatterns => searchPatterns;
/// <summary>
/// When true (default) the plugin directory will be created if it does not exist.
/// </summary>
public bool EnsureDirectoryExists { get; set; } = true;
/// <summary>
/// Controls whether sub-directories should be scanned. Defaults to true.
/// </summary>
public bool RecursiveSearch { get; set; } = true;
internal string ResolveBaseDirectory()
=> string.IsNullOrWhiteSpace(BaseDirectory)
? AppContext.BaseDirectory
: Path.GetFullPath(BaseDirectory);
}

View File

@@ -0,0 +1,26 @@
using System.Collections.Generic;
namespace StellaOps.Plugin.Hosting;
public sealed class PluginHostResult
{
internal PluginHostResult(
string pluginDirectory,
IReadOnlyList<string> searchPatterns,
IReadOnlyList<PluginAssembly> plugins,
IReadOnlyList<string> missingOrderedPlugins)
{
PluginDirectory = pluginDirectory;
SearchPatterns = searchPatterns;
Plugins = plugins;
MissingOrderedPlugins = missingOrderedPlugins;
}
public string PluginDirectory { get; }
public IReadOnlyList<string> SearchPatterns { get; }
public IReadOnlyList<PluginAssembly> Plugins { get; }
public IReadOnlyList<string> MissingOrderedPlugins { get; }
}

View File

@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
namespace StellaOps.Plugin.Hosting;
internal sealed class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver resolver;
private readonly IEnumerable<Assembly> hostAssemblies;
public PluginLoadContext(string pluginPath)
: base(isCollectible: false)
{
resolver = new AssemblyDependencyResolver(pluginPath);
hostAssemblies = AssemblyLoadContext.Default.Assemblies;
}
protected override Assembly? Load(AssemblyName assemblyName)
{
// Attempt to reuse assemblies that already exist in the default context when versions are compatible.
var existing = hostAssemblies.FirstOrDefault(a => string.Equals(
a.GetName().Name,
assemblyName.Name,
StringComparison.OrdinalIgnoreCase));
if (existing != null && IsCompatible(existing.GetName(), assemblyName))
{
return existing;
}
var assemblyPath = resolver.ResolveAssemblyToPath(assemblyName);
if (!string.IsNullOrEmpty(assemblyPath))
{
return LoadFromAssemblyPath(assemblyPath);
}
return null;
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
var libraryPath = resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
if (!string.IsNullOrEmpty(libraryPath))
{
return LoadUnmanagedDllFromPath(libraryPath);
}
return IntPtr.Zero;
}
private static bool IsCompatible(AssemblyName hostAssembly, AssemblyName pluginAssembly)
{
if (hostAssembly.Version == pluginAssembly.Version)
{
return true;
}
if (hostAssembly.Version is null || pluginAssembly.Version is null)
{
return false;
}
if (hostAssembly.Version.Major == pluginAssembly.Version.Major &&
hostAssembly.Version.Minor >= pluginAssembly.Version.Minor)
{
return true;
}
if (hostAssembly.Version.Major >= pluginAssembly.Version.Major)
{
return true;
}
return false;
}
}

View File

@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace StellaOps.Plugin.Internal;
internal static class ReflectionExtensions
{
public static IEnumerable<Type> GetLoadableTypes(this Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
return ex.Types.Where(static t => t is not null)!;
}
}
}

View File

@@ -0,0 +1,172 @@
using StellaOps.Plugin.Hosting;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Threading;
using System.Linq;
using System.Threading.Tasks;
namespace StellaOps.Plugin;
public interface IAvailabilityPlugin
{
string Name { get; }
bool IsAvailable(IServiceProvider services);
}
public interface IFeedConnector
{
string SourceName { get; }
Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken);
Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken);
Task MapAsync(IServiceProvider services, CancellationToken cancellationToken);
}
public interface IFeedExporter
{
string Name { get; }
Task ExportAsync(IServiceProvider services, CancellationToken cancellationToken);
}
public interface IConnectorPlugin : IAvailabilityPlugin
{
IFeedConnector Create(IServiceProvider services);
}
public interface IExporterPlugin : IAvailabilityPlugin
{
IFeedExporter Create(IServiceProvider services);
}
public sealed class PluginCatalog
{
private readonly List<Assembly> _assemblies = new();
private readonly HashSet<string> _assemblyLocations = new(StringComparer.OrdinalIgnoreCase);
public PluginCatalog AddAssembly(Assembly assembly)
{
if (assembly == null) throw new ArgumentNullException(nameof(assembly));
if (_assemblies.Contains(assembly))
{
return this;
}
_assemblies.Add(assembly);
if (!string.IsNullOrWhiteSpace(assembly.Location))
{
_assemblyLocations.Add(Path.GetFullPath(assembly.Location));
}
return this;
}
public PluginCatalog AddFromDirectory(string directory, string searchPattern = "StellaOps.Concelier.*.dll")
{
if (string.IsNullOrWhiteSpace(directory)) throw new ArgumentException("Directory is required", nameof(directory));
var fullDirectory = Path.GetFullPath(directory);
var options = new PluginHostOptions
{
PluginsDirectory = fullDirectory,
EnsureDirectoryExists = false,
RecursiveSearch = false,
};
options.SearchPatterns.Add(searchPattern);
var result = PluginHost.LoadPlugins(options);
foreach (var plugin in result.Plugins)
{
AddAssembly(plugin.Assembly);
}
return this;
}
public IReadOnlyList<IConnectorPlugin> GetConnectorPlugins() => PluginLoader.LoadPlugins<IConnectorPlugin>(_assemblies);
public IReadOnlyList<IExporterPlugin> GetExporterPlugins() => PluginLoader.LoadPlugins<IExporterPlugin>(_assemblies);
public IReadOnlyList<IConnectorPlugin> GetAvailableConnectorPlugins(IServiceProvider services)
=> FilterAvailable(GetConnectorPlugins(), services);
public IReadOnlyList<IExporterPlugin> GetAvailableExporterPlugins(IServiceProvider services)
=> FilterAvailable(GetExporterPlugins(), services);
private static IReadOnlyList<TPlugin> FilterAvailable<TPlugin>(IEnumerable<TPlugin> plugins, IServiceProvider services)
where TPlugin : IAvailabilityPlugin
{
var list = new List<TPlugin>();
foreach (var plugin in plugins)
{
try
{
if (plugin.IsAvailable(services))
{
list.Add(plugin);
}
}
catch
{
// Treat exceptions as plugin not available.
}
}
return list;
}
}
public static class PluginLoader
{
public static IReadOnlyList<TPlugin> LoadPlugins<TPlugin>(IEnumerable<Assembly> assemblies)
where TPlugin : class
{
if (assemblies == null) throw new ArgumentNullException(nameof(assemblies));
var plugins = new List<TPlugin>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var assembly in assemblies)
{
foreach (var candidate in SafeGetTypes(assembly))
{
if (candidate.IsAbstract || candidate.IsInterface)
{
continue;
}
if (!typeof(TPlugin).IsAssignableFrom(candidate))
{
continue;
}
if (Activator.CreateInstance(candidate) is not TPlugin plugin)
{
continue;
}
var key = candidate.FullName ?? candidate.Name;
if (key is null || !seen.Add(key))
{
continue;
}
plugins.Add(plugin);
}
}
return plugins;
}
private static IEnumerable<Type> SafeGetTypes(Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
return ex.Types.Where(t => t is not null)!;
}
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Plugin.Tests")]

View File

@@ -0,0 +1,20 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,9 @@
# TASKS
| Task | Owner(s) | Depends on | Notes |
|---|---|---|---|
|PLUGIN-DI-08-001 Scoped service support in plugin bootstrap|Plugin Platform Guild (DONE 2025-10-21)|StellaOps.DependencyInjection|Scoped DI metadata primitives landed; dynamic plugin integration tests now verify `RegisterPluginRoutines` honours `[ServiceBinding]` lifetimes and remains idempotent.|
|PLUGIN-DI-08-002.COORD Authority scoped-service handshake|Plugin Platform Guild, Authority Core (DONE 2025-10-20)|PLUGIN-DI-08-001|Workshop held 2025-10-20 15:0016:05UTC; outcomes/notes captured in `docs/dev/authority-plugin-di-coordination.md`, follow-up action items assigned for PLUGIN-DI-08-002 implementation plan.|
|PLUGIN-DI-08-002 Authority plugin integration updates|Plugin Platform Guild, Authority Core (DONE 2025-10-20)|PLUGIN-DI-08-001, PLUGIN-DI-08-002.COORD|Standard registrar now registers scoped credential/provisioning stores + identity-provider plugins, registry Acquire scopes instances, and regression suites (`dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj`, `dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj`) cover scoped lifetimes + handles.|
|PLUGIN-DI-08-003 Authority registry scoped resolution|Plugin Platform Guild, Authority Core (DONE 2025-10-20)|PLUGIN-DI-08-002.COORD|Reworked `IAuthorityIdentityProviderRegistry` to expose metadata + scoped handles, updated OpenIddict flows/Program health endpoints, and added coverage via `AuthorityIdentityProviderRegistryTests`.|
|PLUGIN-DI-08-004 Authority plugin loader DI bridge|Plugin Platform Guild, Authority Core (DONE 2025-10-20)|PLUGIN-DI-08-002.COORD|Authority plugin loader now activates registrars via scoped DI leases, registers `[ServiceBinding]` metadata, and includes regression coverage in `AuthorityPluginLoaderTests`.|
|PLUGIN-DI-08-005 Authority plugin bootstrap scope pattern|Plugin Platform Guild, Authority Core (DONE 2025-10-20)|PLUGIN-DI-08-002.COORD|Standard bootstrapper uses `IServiceScopeFactory` per run; tests updated to validate scoped execution and documentation annotated in `authority-plugin-di-coordination.md`.|

View File

@@ -0,0 +1,184 @@
using System;
using System.IO;
using System.Linq;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Configuration;
using Xunit;
namespace StellaOps.Configuration.Tests;
public class AuthorityPluginConfigurationLoaderTests : IDisposable
{
private readonly string tempRoot;
public AuthorityPluginConfigurationLoaderTests()
{
tempRoot = Path.Combine(Path.GetTempPath(), "authority-plugin-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
}
[Fact]
public void Load_ReturnsConfiguration_ForEnabledPlugin()
{
var pluginDir = Path.Combine(tempRoot, "etc", "authority.plugins");
Directory.CreateDirectory(pluginDir);
var standardConfigPath = Path.Combine(pluginDir, "standard.yaml");
File.WriteAllText(standardConfigPath, "secretKey: value");
var options = CreateOptions();
options.Plugins.ConfigurationDirectory = "etc/authority.plugins";
options.Plugins.Descriptors["standard"] = new AuthorityPluginDescriptorOptions
{
AssemblyName = "StellaOps.Authority.Plugin.Standard",
Enabled = true
};
options.Validate();
var contexts = AuthorityPluginConfigurationLoader.Load(options, tempRoot);
var context = Assert.Single(contexts);
Assert.Equal("standard", context.Manifest.Name);
Assert.Equal("value", context.Configuration["secretKey"]);
Assert.True(context.Manifest.Enabled);
}
[Fact]
public void Load_Throws_WhenEnabledConfigMissing()
{
var options = CreateOptions();
options.Plugins.ConfigurationDirectory = "etc/authority.plugins";
options.Plugins.Descriptors["standard"] = new AuthorityPluginDescriptorOptions
{
AssemblyName = "StellaOps.Authority.Plugin.Standard",
Enabled = true
};
options.Validate();
var ex = Assert.Throws<FileNotFoundException>(() =>
AuthorityPluginConfigurationLoader.Load(options, tempRoot));
Assert.Contains("standard.yaml", ex.FileName, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Load_SkipsMissingFile_ForDisabledPlugin()
{
var options = CreateOptions();
options.Plugins.ConfigurationDirectory = "etc/authority.plugins";
options.Plugins.Descriptors["ldap"] = new AuthorityPluginDescriptorOptions
{
AssemblyName = "StellaOps.Authority.Plugin.Ldap",
Enabled = false,
ConfigFile = "ldap.yaml"
};
options.Validate();
var contexts = AuthorityPluginConfigurationLoader.Load(options, tempRoot);
var context = Assert.Single(contexts);
Assert.False(context.Manifest.Enabled);
Assert.Equal("ldap", context.Manifest.Name);
Assert.Null(context.Configuration["connection:host"]);
}
[Fact]
public void Validate_ThrowsForUnknownCapability()
{
var options = CreateOptions();
options.Plugins.Descriptors["standard"] = new AuthorityPluginDescriptorOptions
{
AssemblyName = "StellaOps.Authority.Plugin.Standard",
Enabled = true
};
options.Plugins.Descriptors["standard"].Capabilities.Add("custom-flow");
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("unknown capability", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Analyze_ReturnsWarning_WhenStandardPasswordPolicyWeaker()
{
var pluginDir = Path.Combine(tempRoot, "etc", "authority.plugins");
Directory.CreateDirectory(pluginDir);
var standardConfigPath = Path.Combine(pluginDir, "standard.yaml");
File.WriteAllText(standardConfigPath, "passwordPolicy:\n minimumLength: 8\n requireSymbol: false\n");
var options = CreateOptions();
options.Plugins.ConfigurationDirectory = "etc/authority.plugins";
options.Plugins.Descriptors["standard"] = new AuthorityPluginDescriptorOptions
{
AssemblyName = "StellaOps.Authority.Plugin.Standard",
Enabled = true
};
options.Validate();
var contexts = AuthorityPluginConfigurationLoader.Load(options, tempRoot);
var diagnostics = AuthorityPluginConfigurationAnalyzer.Analyze(contexts);
var diagnostic = Assert.Single(diagnostics);
Assert.Equal(AuthorityConfigurationDiagnosticSeverity.Warning, diagnostic.Severity);
Assert.Equal("standard", diagnostic.PluginName);
Assert.Contains("minimum length 8", diagnostic.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("symbol requirement disabled", diagnostic.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Analyze_ReturnsNoDiagnostics_WhenPasswordPolicyMatchesBaseline()
{
var pluginDir = Path.Combine(tempRoot, "etc", "authority.plugins");
Directory.CreateDirectory(pluginDir);
var standardConfigPath = Path.Combine(pluginDir, "standard.yaml");
// Baseline configuration (no overrides)
File.WriteAllText(standardConfigPath, "bootstrapUser:\n username: bootstrap\n password: Bootstrap1!\n");
var options = CreateOptions();
options.Plugins.ConfigurationDirectory = "etc/authority.plugins";
options.Plugins.Descriptors["standard"] = new AuthorityPluginDescriptorOptions
{
AssemblyName = "StellaOps.Authority.Plugin.Standard",
Enabled = true
};
options.Validate();
var contexts = AuthorityPluginConfigurationLoader.Load(options, tempRoot);
var diagnostics = AuthorityPluginConfigurationAnalyzer.Analyze(contexts);
Assert.Empty(diagnostics);
}
public void Dispose()
{
try
{
if (Directory.Exists(tempRoot))
{
Directory.Delete(tempRoot, recursive: true);
}
}
catch
{
// ignore cleanup failures in test environment
}
}
private static StellaOpsAuthorityOptions CreateOptions()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority_test";
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/authority-test-key.pem";
return options;
}
}

View File

@@ -0,0 +1,24 @@
using StellaOps.Auth;
using Xunit;
namespace StellaOps.Configuration.Tests;
public class AuthorityTelemetryTests
{
[Fact]
public void ServiceName_AndNamespace_MatchExpectations()
{
Assert.Equal("stellaops-authority", AuthorityTelemetry.ServiceName);
Assert.Equal("stellaops", AuthorityTelemetry.ServiceNamespace);
}
[Fact]
public void BuildDefaultResourceAttributes_ContainsExpectedKeys()
{
var attributes = AuthorityTelemetry.BuildDefaultResourceAttributes();
Assert.Equal("stellaops-authority", attributes["service.name"]);
Assert.Equal("stellaops", attributes["service.namespace"]);
Assert.False(string.IsNullOrWhiteSpace(attributes["service.version"]?.ToString()));
}
}

View File

@@ -0,0 +1,12 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Configuration/StellaOps.Configuration.csproj" />
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,222 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
using StellaOps.Configuration;
using Xunit;
namespace StellaOps.Configuration.Tests;
public class StellaOpsAuthorityOptionsTests
{
[Fact]
public void Validate_Throws_When_IssuerMissing()
{
var options = new StellaOpsAuthorityOptions();
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("issuer", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Normalises_Collections()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
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");
options.PluginDirectories.Add("./other");
options.BypassNetworks.Add(" 10.0.0.0/24 ");
options.BypassNetworks.Add("10.0.0.0/24");
options.BypassNetworks.Add("192.168.0.0/16");
options.Validate();
Assert.Equal(new[] { "./plugins", "./other" }, options.PluginDirectories);
Assert.Equal(new[] { "10.0.0.0/24", "192.168.0.0/16" }, options.BypassNetworks);
}
[Fact]
public void Validate_Normalises_PluginDescriptors()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
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
{
AssemblyName = "StellaOps.Authority.Plugin.Standard",
ConfigFile = " standard.yaml ",
Enabled = true
};
descriptor.Capabilities.Add("password");
descriptor.Capabilities.Add("PASSWORD");
options.Plugins.Descriptors["standard"] = descriptor;
options.Validate();
var normalized = options.Plugins.Descriptors["standard"];
Assert.Equal("standard.yaml", normalized.ConfigFile);
Assert.Single(normalized.Capabilities);
Assert.Equal("password", normalized.Capabilities[0]);
}
[Fact]
public void Validate_Throws_When_StorageConnectionStringMissing()
{
var options = new StellaOpsAuthorityOptions
{
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());
Assert.Contains("Mongo connection string", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Build_Binds_From_Configuration()
{
var context = StellaOpsAuthorityConfiguration.Build(options =>
{
options.ConfigureBuilder = builder =>
{
builder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:SchemaVersion"] = "2",
["Authority:Issuer"] = "https://authority.internal",
["Authority:AccessTokenLifetime"] = "00:30:00",
["Authority:RefreshTokenLifetime"] = "30.00:00:00",
["Authority:Storage:ConnectionString"] = "mongodb://example/stellaops",
["Authority:Storage:DatabaseName"] = "overrideDb",
["Authority:Storage:CommandTimeout"] = "00:01:30",
["Authority:PluginDirectories:0"] = "/var/lib/stellaops/plugins",
["Authority:BypassNetworks:0"] = "127.0.0.1/32",
["Authority:Security:RateLimiting:Token:PermitLimit"] = "25",
["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:Signing:Enabled"] = "true",
["Authority:Signing:ActiveKeyId"] = "authority-signing-dev",
["Authority:Signing:KeyPath"] = "../certificates/authority-signing-dev.pem",
["Authority:Signing:KeySource"] = "file"
});
};
});
var options = context.Options;
Assert.Equal(2, options.SchemaVersion);
Assert.Equal(new Uri("https://authority.internal"), options.Issuer);
Assert.Equal(TimeSpan.FromMinutes(30), options.AccessTokenLifetime);
Assert.Equal(TimeSpan.FromDays(30), options.RefreshTokenLifetime);
Assert.Equal(new[] { "/var/lib/stellaops/plugins" }, options.PluginDirectories);
Assert.Equal(new[] { "127.0.0.1/32" }, options.BypassNetworks);
Assert.Equal("mongodb://example/stellaops", options.Storage.ConnectionString);
Assert.Equal("overrideDb", options.Storage.DatabaseName);
Assert.Equal(TimeSpan.FromMinutes(1.5), options.Storage.CommandTimeout);
Assert.Equal(25, options.Security.RateLimiting.Token.PermitLimit);
Assert.Equal(TimeSpan.FromSeconds(30), options.Security.RateLimiting.Token.Window);
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]
public void Validate_Normalises_ExceptionRoutingTemplates()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
{
Id = " SecOps ",
AuthorityRouteId = " approvals/secops ",
RequireMfa = true,
Description = " Security approvals "
});
options.Validate();
Assert.True(options.Exceptions.RequiresMfaForApprovals);
var template = Assert.Single(options.Exceptions.NormalizedRoutingTemplates);
Assert.Equal("SecOps", template.Key);
Assert.Equal("SecOps", template.Value.Id);
Assert.Equal("approvals/secops", template.Value.AuthorityRouteId);
Assert.Equal("Security approvals", template.Value.Description);
Assert.True(template.Value.RequireMfa);
}
[Fact]
public void Validate_Throws_When_ExceptionRoutingTemplatesDuplicate()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
{
Id = "secops",
AuthorityRouteId = "route/a"
});
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
{
Id = "SecOps",
AuthorityRouteId = "route/b"
});
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("secops", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Throws_When_RateLimitingInvalid()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
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());
Assert.Contains("permitLimit", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}

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,57 @@
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);
Assert.False(record.Tenant.HasValue);
Assert.False(record.Project.HasValue);
}
[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,52 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Cryptography.Plugin.BouncyCastle;
using Xunit;
namespace StellaOps.Cryptography.Tests;
public sealed class BouncyCastleEd25519CryptoProviderTests
{
[Fact]
public async Task SignAndVerify_WithBouncyCastleProvider_Succeeds()
{
var services = new ServiceCollection();
services.AddStellaOpsCrypto();
services.AddBouncyCastleEd25519Provider();
using var provider = services.BuildServiceProvider();
var registry = provider.GetRequiredService<ICryptoProviderRegistry>();
var bcProvider = provider.GetServices<ICryptoProvider>()
.OfType<BouncyCastleEd25519CryptoProvider>()
.Single();
var keyId = "ed25519-unit-test";
var privateKeyBytes = Enumerable.Range(0, 32).Select(i => (byte)(i + 1)).ToArray();
var keyReference = new CryptoKeyReference(keyId, bcProvider.Name);
var signingKey = new CryptoSigningKey(
keyReference,
SignatureAlgorithms.Ed25519,
privateKeyBytes,
createdAt: DateTimeOffset.UtcNow);
bcProvider.UpsertSigningKey(signingKey);
var resolution = registry.ResolveSigner(
CryptoCapability.Signing,
SignatureAlgorithms.Ed25519,
keyReference,
bcProvider.Name);
var payload = new byte[] { 0x01, 0x02, 0x03, 0x04 };
var signature = await resolution.Signer.SignAsync(payload);
Assert.True(await resolution.Signer.VerifyAsync(payload, signature));
var jwk = resolution.Signer.ExportPublicJsonWebKey();
Assert.Equal("OKP", jwk.Kty);
Assert.Equal("Ed25519", jwk.Crv);
Assert.Equal(SignatureAlgorithms.EdDsa, jwk.Alg);
Assert.Equal(keyId, jwk.Kid);
}
}

View File

@@ -0,0 +1,156 @@
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 hintResolution = registry.ResolveSigner(
CryptoCapability.Signing,
SignatureAlgorithms.Es256,
new CryptoKeyReference("key-b"),
preferredProvider: "providerB");
Assert.Equal("providerB", hintResolution.ProviderName);
Assert.Equal("key-b", hintResolution.Signer.KeyId);
var fallbackResolution = registry.ResolveSigner(
CryptoCapability.Signing,
SignatureAlgorithms.Es256,
new CryptoKeyReference("key-a"));
Assert.Equal("providerA", fallbackResolution.ProviderName);
Assert.Equal("key-a", fallbackResolution.Signer.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,38 @@
#if STELLAOPS_CRYPTO_SODIUM
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Xunit;
namespace StellaOps.Cryptography.Tests;
public class LibsodiumCryptoProviderTests
{
[Fact]
public async Task LibsodiumProvider_SignsAndVerifiesEs256()
{
var provider = new LibsodiumCryptoProvider();
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
var signingKey = new CryptoSigningKey(
new CryptoKeyReference("libsodium-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("libsodium-test");
var signature = await signer.SignAsync(payload);
Assert.True(signature.Length > 0);
var verified = await signer.VerifyAsync(payload, signature);
Assert.True(verified);
}
}
#endif

View File

@@ -0,0 +1,24 @@
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.Tests;
public class PasswordHashOptionsTests
{
[Fact]
public void Validate_DoesNotThrow_ForDefaults()
{
var options = new PasswordHashOptions();
options.Validate();
}
[Fact]
public void Validate_Throws_WhenMemoryInvalid()
{
var options = new PasswordHashOptions
{
MemorySizeInKib = 0
};
Assert.Throws<InvalidOperationException>(options.Validate);
}
}

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,17 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<PropertyGroup Condition="'$(StellaOpsCryptoSodium)' == 'true'">
<DefineConstants>$(DefineConstants);STELLAOPS_CRYPTO_SODIUM</DefineConstants>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
<ProjectReference Include="../../StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.DependencyInjection;
using StellaOps.Plugin.DependencyInjection;
using StellaOps.Plugin.Hosting;
using Xunit;
namespace StellaOps.Plugin.Tests.DependencyInjection;
public sealed class PluginDependencyInjectionExtensionsTests
{
[Fact]
public void RegisterPluginRoutines_RegistersServiceBindingsAndHonoursLifetimes()
{
const string source = """
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
namespace SamplePlugin;
public interface IScopedExample {}
public interface ISingletonExample {}
[ServiceBinding(typeof(IScopedExample), ServiceLifetime.Scoped, RegisterAsSelf = true)]
public sealed class ScopedExample : IScopedExample {}
[ServiceBinding(typeof(ISingletonExample), ServiceLifetime.Singleton)]
public sealed class SingletonExample : ISingletonExample {}
""";
using var plugin = TestPluginAssembly.Create(source);
var configuration = new ConfigurationBuilder().Build();
var services = new ServiceCollection();
services.RegisterPluginRoutines(configuration, plugin.Options, NullLogger.Instance);
var scopedDescriptor = Assert.Single(
services,
static d => d.ServiceType.FullName == "SamplePlugin.IScopedExample");
Assert.Equal(ServiceLifetime.Scoped, scopedDescriptor.Lifetime);
Assert.Equal("SamplePlugin.ScopedExample", scopedDescriptor.ImplementationType?.FullName);
var scopedSelfDescriptor = Assert.Single(
services,
static d => d.ServiceType.FullName == "SamplePlugin.ScopedExample");
Assert.Equal(ServiceLifetime.Scoped, scopedSelfDescriptor.Lifetime);
var singletonDescriptor = Assert.Single(
services,
static d => d.ServiceType.FullName == "SamplePlugin.ISingletonExample");
Assert.Equal(ServiceLifetime.Singleton, singletonDescriptor.Lifetime);
using var provider = services.BuildServiceProvider();
object firstScopeInstance;
using (var scope = provider.CreateScope())
{
var resolvedFirst = ServiceProviderServiceExtensions.GetRequiredService(scope.ServiceProvider, scopedDescriptor.ServiceType);
var resolvedSecond = ServiceProviderServiceExtensions.GetRequiredService(scope.ServiceProvider, scopedDescriptor.ServiceType);
Assert.Same(resolvedFirst, resolvedSecond);
firstScopeInstance = resolvedFirst;
}
using (var scope = provider.CreateScope())
{
var resolved = ServiceProviderServiceExtensions.GetRequiredService(scope.ServiceProvider, scopedDescriptor.ServiceType);
Assert.NotSame(firstScopeInstance, resolved);
}
var singletonFirst = ServiceProviderServiceExtensions.GetRequiredService(provider, singletonDescriptor.ServiceType);
var singletonSecond = ServiceProviderServiceExtensions.GetRequiredService(provider, singletonDescriptor.ServiceType);
Assert.Same(singletonFirst, singletonSecond);
services.RegisterPluginRoutines(configuration, plugin.Options, NullLogger.Instance);
var scopedRegistrations = services.Count(d =>
d.ServiceType.FullName == "SamplePlugin.IScopedExample" &&
d.ImplementationType?.FullName == "SamplePlugin.ScopedExample");
Assert.Equal(1, scopedRegistrations);
}
private sealed class TestPluginAssembly : IDisposable
{
private TestPluginAssembly(string directoryPath, string assemblyPath)
{
DirectoryPath = directoryPath;
AssemblyPath = assemblyPath;
Options = new PluginHostOptions
{
PluginsDirectory = directoryPath,
EnsureDirectoryExists = false,
RecursiveSearch = false,
};
Options.SearchPatterns.Add(Path.GetFileName(assemblyPath));
}
public string DirectoryPath { get; }
public string AssemblyPath { get; }
public PluginHostOptions Options { get; }
public static TestPluginAssembly Create(string source)
{
var directoryPath = Path.Combine(Path.GetTempPath(), "stellaops-plugin-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directoryPath);
var assemblyName = "SamplePlugin" + Guid.NewGuid().ToString("N");
var assemblyPath = Path.Combine(directoryPath, assemblyName + ".dll");
var syntaxTree = CSharpSyntaxTree.ParseText(source);
var references = CollectMetadataReferences();
var compilation = CSharpCompilation.Create(
assemblyName,
new[] { syntaxTree },
references,
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release));
var emitResult = compilation.Emit(assemblyPath);
if (!emitResult.Success)
{
var diagnostics = string.Join(Environment.NewLine, emitResult.Diagnostics);
throw new InvalidOperationException("Failed to compile plugin assembly:" + Environment.NewLine + diagnostics);
}
return new TestPluginAssembly(directoryPath, assemblyPath);
}
public void Dispose()
{
try
{
if (Directory.Exists(DirectoryPath))
{
Directory.Delete(DirectoryPath, recursive: true);
}
}
catch
{
// Ignore cleanup failures plugin load contexts may keep files locked on Windows.
}
}
private static IReadOnlyCollection<MetadataReference> CollectMetadataReferences()
{
var referencePaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") is string tpa)
{
foreach (var path in tpa.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
referencePaths.Add(path);
}
}
referencePaths.Add(typeof(object).Assembly.Location);
referencePaths.Add(typeof(ServiceBindingAttribute).Assembly.Location);
referencePaths.Add(typeof(IDependencyInjectionRoutine).Assembly.Location);
referencePaths.Add(typeof(ServiceLifetime).Assembly.Location);
return referencePaths
.Select(path => MetadataReference.CreateFromFile(path))
.ToArray();
}
}
}

View File

@@ -0,0 +1,112 @@
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.DependencyInjection;
using StellaOps.Plugin.DependencyInjection;
using Xunit;
namespace StellaOps.Plugin.Tests.DependencyInjection;
public sealed class PluginServiceRegistrationTests
{
[Fact]
public void RegisterAssemblyMetadata_RegistersScopedDescriptor()
{
var services = new ServiceCollection();
PluginServiceRegistration.RegisterAssemblyMetadata(
services,
typeof(ScopedTestService).Assembly,
NullLogger.Instance);
var descriptor = Assert.Single(services, static d => d.ServiceType == typeof(IScopedService));
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
Assert.Equal(typeof(ScopedTestService), descriptor.ImplementationType);
}
[Fact]
public void RegisterAssemblyMetadata_HonoursRegisterAsSelf()
{
var services = new ServiceCollection();
PluginServiceRegistration.RegisterAssemblyMetadata(
services,
typeof(SelfRegisteringService).Assembly,
NullLogger.Instance);
Assert.Contains(services, static d =>
d.ServiceType == typeof(SelfRegisteringService) &&
d.ImplementationType == typeof(SelfRegisteringService));
}
[Fact]
public void RegisterAssemblyMetadata_ReplacesExistingDescriptorsWhenRequested()
{
var services = new ServiceCollection();
services.AddSingleton<IReplacementService, ExistingReplacementService>();
PluginServiceRegistration.RegisterAssemblyMetadata(
services,
typeof(ReplacementService).Assembly,
NullLogger.Instance);
var descriptor = Assert.Single(
services,
static d => d.ServiceType == typeof(IReplacementService) &&
d.ImplementationType == typeof(ReplacementService));
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
}
[Fact]
public void RegisterAssemblyMetadata_SkipsInvalidAssignments()
{
var services = new ServiceCollection();
PluginServiceRegistration.RegisterAssemblyMetadata(
services,
typeof(InvalidServiceBinding).Assembly,
NullLogger.Instance);
Assert.DoesNotContain(services, static d => d.ServiceType == typeof(IAnotherService));
}
private interface IScopedService
{
}
private interface ISelfContract
{
}
private interface IReplacementService
{
}
private interface IAnotherService
{
}
private sealed class ExistingReplacementService : IReplacementService
{
}
[ServiceBinding(typeof(IScopedService), ServiceLifetime.Scoped)]
private sealed class ScopedTestService : IScopedService
{
}
[ServiceBinding(typeof(ISelfContract), ServiceLifetime.Singleton, RegisterAsSelf = true)]
private sealed class SelfRegisteringService : ISelfContract
{
}
[ServiceBinding(typeof(IReplacementService), ServiceLifetime.Transient, ReplaceExisting = true)]
private sealed class ReplacementService : IReplacementService
{
}
[ServiceBinding(typeof(IAnotherService), ServiceLifetime.Singleton)]
private sealed class InvalidServiceBinding
{
}
}

View File

@@ -0,0 +1,22 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="../../StellaOps.Plugin/StellaOps.Plugin.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Signals.Models;
using StellaOps.Signals.Tests.TestInfrastructure;
using Xunit;
namespace StellaOps.Signals.Tests;
public class CallgraphIngestionTests : IClassFixture<SignalsTestFactory>
{
private readonly SignalsTestFactory factory;
public CallgraphIngestionTests(SignalsTestFactory factory)
{
this.factory = factory;
}
[Theory]
[InlineData("java")]
[InlineData("nodejs")]
[InlineData("python")]
[InlineData("go")]
public async Task Ingest_Callgraph_PersistsDocumentAndArtifact(string language)
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:write");
var component = $"demo-{language}";
var request = CreateRequest(language, component: component);
var response = await client.PostAsJsonAsync("/signals/callgraphs", request);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<CallgraphIngestResponse>();
Assert.NotNull(body);
var database = new MongoClient(factory.MongoRunner.ConnectionString).GetDatabase("signals-tests");
var collection = database.GetCollection<CallgraphDocument>("callgraphs");
var doc = await collection.Find(d => d.Id == body!.CallgraphId).FirstOrDefaultAsync();
Assert.NotNull(doc);
Assert.Equal(language, doc!.Language);
Assert.Equal(component, doc.Component);
Assert.Equal("1.0.0", doc.Version);
Assert.Equal(2, doc.Nodes.Count);
Assert.Equal(1, doc.Edges.Count);
var artifactPath = Path.Combine(factory.StoragePath, body.ArtifactPath);
Assert.True(File.Exists(artifactPath));
Assert.False(string.IsNullOrWhiteSpace(body.ArtifactHash));
}
[Fact]
public async Task Ingest_UnsupportedLanguage_ReturnsBadRequest()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:write");
var request = CreateRequest("ruby");
var response = await client.PostAsJsonAsync("/signals/callgraphs", request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Ingest_InvalidArtifactContent_ReturnsBadRequest()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:write");
var request = CreateRequest("java") with { ArtifactContentBase64 = "not-base64" };
var response = await client.PostAsJsonAsync("/signals/callgraphs", request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Ingest_InvalidGraphStructure_ReturnsUnprocessableEntity()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:write");
var json = "{\"formatVersion\":\"1.0\",\"graph\":{}}";
var request = CreateRequest("java", json);
var response = await client.PostAsJsonAsync("/signals/callgraphs", request);
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
}
[Fact]
public async Task Ingest_SameComponentUpsertsDocument()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:write");
var firstRequest = CreateRequest("python");
var secondJson = "{\"graph\":{\"nodes\":[{\"id\":\"module.entry\",\"name\":\"module.entry\"}],\"edges\":[]}}";
var secondRequest = CreateRequest("python", secondJson);
var firstResponse = await client.PostAsJsonAsync("/signals/callgraphs", firstRequest);
var secondResponse = await client.PostAsJsonAsync("/signals/callgraphs", secondRequest);
Assert.Equal(HttpStatusCode.Accepted, firstResponse.StatusCode);
Assert.Equal(HttpStatusCode.Accepted, secondResponse.StatusCode);
var database = new MongoClient(factory.MongoRunner.ConnectionString).GetDatabase("signals-tests");
var collection = database.GetCollection<CallgraphDocument>("callgraphs");
var count = await collection.CountDocumentsAsync(FilterDefinition<CallgraphDocument>.Empty);
Assert.Equal(1, count);
var doc = await collection.Find(_ => true).FirstAsync();
Assert.Single(doc.Nodes);
Assert.Equal("python", doc.Language);
}
private static CallgraphIngestRequest CreateRequest(string language, string? customJson = null, string component = "demo")
{
var json = customJson ?? "{\"formatVersion\":\"1.0\",\"graph\":{\"nodes\":[{\"id\":\"main.entry\",\"name\":\"main.entry\",\"kind\":\"function\",\"file\":\"main\",\"line\":1},{\"id\":\"helper.run\",\"name\":\"helper.run\",\"kind\":\"function\",\"file\":\"helper\",\"line\":2}],\"edges\":[{\"source\":\"main.entry\",\"target\":\"helper.run\",\"type\":\"call\"}]}}";
var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
return new CallgraphIngestRequest(
Language: language,
Component: component,
Version: "1.0.0",
ArtifactContentType: "application/json",
ArtifactFileName: $"{language}-callgraph.json",
ArtifactContentBase64: base64,
Metadata: new Dictionary<string, string?>
{
["source"] = "unit-test"
});
}
}

View File

@@ -0,0 +1,112 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using StellaOps.Signals.Tests.TestInfrastructure;
using Xunit;
namespace StellaOps.Signals.Tests;
public class SignalsApiTests : IClassFixture<SignalsTestFactory>
{
private readonly SignalsTestFactory factory;
public SignalsApiTests(SignalsTestFactory factory)
{
this.factory = factory;
}
[Fact]
public async Task Healthz_ReturnsOk()
{
using var client = factory.CreateClient();
var response = await client.GetAsync("/healthz");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Readyz_ReturnsOk()
{
using var client = factory.CreateClient();
var response = await client.GetAsync("/readyz");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.NotNull(payload);
Assert.Equal("ready", payload!["status"]);
}
[Fact]
public async Task Ping_WithoutScopeHeader_ReturnsUnauthorized()
{
using var client = factory.CreateClient();
var response = await client.GetAsync("/signals/ping");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Ping_WithMissingScope_ReturnsForbidden()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:write");
var response = await client.GetAsync("/signals/ping");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Ping_WithReadScope_ReturnsNoContent()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:read");
var response = await client.GetAsync("/signals/ping");
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}
[Fact]
public async Task Ping_WithFallbackDisabled_ReturnsUnauthorized()
{
using var app = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Signals:Authority:AllowAnonymousFallback"] = "false"
});
});
});
using var client = app.CreateClient();
var response = await client.GetAsync("/signals/ping");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Status_WithReadScope_ReturnsOk()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:read");
var response = await client.GetAsync("/signals/status");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
Assert.NotNull(payload);
Assert.Equal("signals", payload!["service"]);
}
[Fact]
public async Task Status_WithMissingScope_ReturnsForbidden()
{
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-Scopes", "signals:write");
var response = await client.GetAsync("/signals/status");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}

View File

@@ -0,0 +1,22 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Mongo2Go" Version="4.1.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../Signals/StellaOps.Signals/StellaOps.Signals.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Mongo2Go;
namespace StellaOps.Signals.Tests.TestInfrastructure;
internal sealed class SignalsTestFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly MongoDbRunner mongoRunner;
private readonly string storagePath;
public SignalsTestFactory()
{
mongoRunner = MongoDbRunner.Start(singleNodeReplSet: true);
storagePath = Path.Combine(Path.GetTempPath(), "signals-tests", Guid.NewGuid().ToString());
Directory.CreateDirectory(storagePath);
}
public string StoragePath => storagePath;
public MongoDbRunner MongoRunner => mongoRunner;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((context, configuration) =>
{
var settings = new Dictionary<string, string?>
{
["Signals:Authority:Enabled"] = "false",
["Signals:Authority:AllowAnonymousFallback"] = "true",
["Signals:Mongo:ConnectionString"] = mongoRunner.ConnectionString,
["Signals:Mongo:Database"] = "signals-tests",
["Signals:Mongo:CallgraphsCollection"] = "callgraphs",
["Signals:Storage:RootPath"] = storagePath
};
configuration.AddInMemoryCollection(settings);
});
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await Task.Run(() => mongoRunner.Dispose());
try
{
if (Directory.Exists(storagePath))
{
Directory.Delete(storagePath, recursive: true);
}
}
catch
{
// best effort cleanup.
}
}
}