Restructure solution layout by module
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Auth.Security.Dpop;
|
||||
|
||||
public interface IDpopReplayCache
|
||||
{
|
||||
ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
3
src/__Libraries/StellaOps.Auth.Security/README.md
Normal file
3
src/__Libraries/StellaOps.Auth.Security/README.md
Normal 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.
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1005
src/__Libraries/StellaOps.Configuration/StellaOpsAuthorityOptions.cs
Normal file
1005
src/__Libraries/StellaOps.Configuration/StellaOpsAuthorityOptions.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Cryptography.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Options controlling crypto provider registry ordering and selection.
|
||||
/// </summary>
|
||||
public sealed class CryptoProviderRegistryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Ordered list of preferred provider names. Providers appearing here are consulted first.
|
||||
/// </summary>
|
||||
public IList<string> PreferredProviders { get; } = new List<string>();
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
14
src/__Libraries/StellaOps.Cryptography.Kms/AGENTS.md
Normal file
14
src/__Libraries/StellaOps.Cryptography.Kms/AGENTS.md
Normal 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.
|
||||
13
src/__Libraries/StellaOps.Cryptography.Kms/TASKS.md
Normal file
13
src/__Libraries/StellaOps.Cryptography.Kms/TASKS.md
Normal 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. |
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
22
src/__Libraries/StellaOps.Cryptography/AGENTS.md
Normal file
22
src/__Libraries/StellaOps.Cryptography/AGENTS.md
Normal 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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,30 @@
|
||||
#if STELLAOPS_CRYPTO_SODIUM
|
||||
using System;
|
||||
using System.Text;
|
||||
using Konscious.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder for libsodium-backed Argon2id implementation.
|
||||
/// Falls back to the managed Konscious variant until native bindings land.
|
||||
/// </summary>
|
||||
public sealed partial class Argon2idPasswordHasher
|
||||
{
|
||||
private static partial byte[] DeriveHashCore(string password, ReadOnlySpan<byte> salt, PasswordHashOptions options)
|
||||
{
|
||||
// TODO(SEC1.B follow-up): replace with libsodium/core bindings and managed pinning logic.
|
||||
var passwordBytes = Encoding.UTF8.GetBytes(password);
|
||||
|
||||
using var argon2 = new Argon2id(passwordBytes)
|
||||
{
|
||||
Salt = salt.ToArray(),
|
||||
DegreeOfParallelism = options.Parallelism,
|
||||
Iterations = options.Iterations,
|
||||
MemorySize = options.MemorySizeInKib
|
||||
};
|
||||
|
||||
return argon2.GetBytes(HashLengthBytes);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
173
src/__Libraries/StellaOps.Cryptography/Argon2idPasswordHasher.cs
Normal file
173
src/__Libraries/StellaOps.Cryptography/Argon2idPasswordHasher.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Argon2id password hasher that emits PHC-compliant encoded strings.
|
||||
/// </summary>
|
||||
public sealed partial class Argon2idPasswordHasher : IPasswordHasher
|
||||
{
|
||||
private const int SaltLengthBytes = 16;
|
||||
private const int HashLengthBytes = 32;
|
||||
|
||||
public string Hash(string password, PasswordHashOptions options)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(password);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
options.Validate();
|
||||
|
||||
if (options.Algorithm != PasswordHashAlgorithm.Argon2id)
|
||||
{
|
||||
throw new InvalidOperationException("Argon2idPasswordHasher only supports the Argon2id algorithm.");
|
||||
}
|
||||
|
||||
Span<byte> salt = stackalloc byte[SaltLengthBytes];
|
||||
RandomNumberGenerator.Fill(salt);
|
||||
|
||||
var hash = DeriveHash(password, salt, options);
|
||||
|
||||
return BuildEncodedHash(salt, hash, options);
|
||||
}
|
||||
|
||||
public bool Verify(string password, string encodedHash)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(password);
|
||||
ArgumentException.ThrowIfNullOrEmpty(encodedHash);
|
||||
|
||||
if (!TryParse(encodedHash, out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var computed = DeriveHash(password, parsed.Salt, parsed.Options);
|
||||
return CryptographicOperations.FixedTimeEquals(computed, parsed.Hash);
|
||||
}
|
||||
|
||||
public bool NeedsRehash(string encodedHash, PasswordHashOptions desired)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(desired);
|
||||
|
||||
if (!TryParse(encodedHash, out var parsed))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (desired.Algorithm != PasswordHashAlgorithm.Argon2id)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!parsed.Options.Algorithm.Equals(desired.Algorithm))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return parsed.Options.MemorySizeInKib != desired.MemorySizeInKib
|
||||
|| parsed.Options.Iterations != desired.Iterations
|
||||
|| parsed.Options.Parallelism != desired.Parallelism;
|
||||
}
|
||||
|
||||
private static byte[] DeriveHash(string password, ReadOnlySpan<byte> salt, PasswordHashOptions options)
|
||||
=> DeriveHashCore(password, salt, options);
|
||||
|
||||
private static partial byte[] DeriveHashCore(string password, ReadOnlySpan<byte> salt, PasswordHashOptions options);
|
||||
|
||||
private static string BuildEncodedHash(ReadOnlySpan<byte> salt, ReadOnlySpan<byte> hash, PasswordHashOptions options)
|
||||
{
|
||||
var saltEncoded = Convert.ToBase64String(salt);
|
||||
var hashEncoded = Convert.ToBase64String(hash);
|
||||
|
||||
return $"$argon2id$v=19$m={options.MemorySizeInKib},t={options.Iterations},p={options.Parallelism}${saltEncoded}${hashEncoded}";
|
||||
}
|
||||
|
||||
private static bool TryParse(string encodedHash, out Argon2HashParameters parsed)
|
||||
{
|
||||
parsed = default;
|
||||
|
||||
if (!encodedHash.StartsWith("$argon2id$", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var segments = encodedHash.Split('$', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length != 5)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// segments: 0=argon2id, 1=v=19, 2=m=...,t=...,p=..., 3=salt, 4=hash
|
||||
if (!segments[1].StartsWith("v=19", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parameterParts = segments[2].Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parameterParts.Length != 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryParseInt(parameterParts[0], "m", out var memory) ||
|
||||
!TryParseInt(parameterParts[1], "t", out var iterations) ||
|
||||
!TryParseInt(parameterParts[2], "p", out var parallelism))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] saltBytes;
|
||||
byte[] hashBytes;
|
||||
try
|
||||
{
|
||||
saltBytes = Convert.FromBase64String(segments[3]);
|
||||
hashBytes = Convert.FromBase64String(segments[4]);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (saltBytes.Length != SaltLengthBytes || hashBytes.Length != HashLengthBytes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var options = new PasswordHashOptions
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Argon2id,
|
||||
MemorySizeInKib = memory,
|
||||
Iterations = iterations,
|
||||
Parallelism = parallelism
|
||||
};
|
||||
|
||||
parsed = new Argon2HashParameters(options, saltBytes, hashBytes);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseInt(string component, string key, out int value)
|
||||
{
|
||||
value = 0;
|
||||
if (!component.StartsWith(key + "=", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return int.TryParse(component.AsSpan(key.Length + 1), NumberStyles.None, CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
|
||||
private readonly struct Argon2HashParameters
|
||||
{
|
||||
public Argon2HashParameters(PasswordHashOptions options, byte[] salt, byte[] hash)
|
||||
{
|
||||
Options = options;
|
||||
Salt = salt;
|
||||
Hash = hash;
|
||||
}
|
||||
|
||||
public PasswordHashOptions Options { get; }
|
||||
public byte[] Salt { get; }
|
||||
public byte[] Hash { get; }
|
||||
}
|
||||
}
|
||||
268
src/__Libraries/StellaOps.Cryptography/Audit/AuthEventRecord.cs
Normal file
268
src/__Libraries/StellaOps.Cryptography/Audit/AuthEventRecord.cs
Normal 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);
|
||||
}
|
||||
86
src/__Libraries/StellaOps.Cryptography/CryptoProvider.cs
Normal file
86
src/__Libraries/StellaOps.Cryptography/CryptoProvider.cs
Normal 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);
|
||||
114
src/__Libraries/StellaOps.Cryptography/CryptoProviderRegistry.cs
Normal file
114
src/__Libraries/StellaOps.Cryptography/CryptoProviderRegistry.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
176
src/__Libraries/StellaOps.Cryptography/CryptoSigningKey.cs
Normal file
176
src/__Libraries/StellaOps.Cryptography/CryptoSigningKey.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
133
src/__Libraries/StellaOps.Cryptography/DefaultCryptoProvider.cs
Normal file
133
src/__Libraries/StellaOps.Cryptography/DefaultCryptoProvider.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
82
src/__Libraries/StellaOps.Cryptography/EcdsaSigner.cs
Normal file
82
src/__Libraries/StellaOps.Cryptography/EcdsaSigner.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
internal sealed class EcdsaSigner : ICryptoSigner
|
||||
{
|
||||
private static readonly string[] DefaultKeyOps = { "sign", "verify" };
|
||||
private readonly CryptoSigningKey signingKey;
|
||||
|
||||
private EcdsaSigner(CryptoSigningKey signingKey)
|
||||
=> this.signingKey = signingKey ?? throw new ArgumentNullException(nameof(signingKey));
|
||||
|
||||
public string KeyId => signingKey.Reference.KeyId;
|
||||
|
||||
public string AlgorithmId => signingKey.AlgorithmId;
|
||||
|
||||
public static ICryptoSigner Create(CryptoSigningKey signingKey) => new EcdsaSigner(signingKey);
|
||||
|
||||
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
using var ecdsa = ECDsa.Create(signingKey.PrivateParameters);
|
||||
var hashAlgorithm = ResolveHashAlgorithm(signingKey.AlgorithmId);
|
||||
var signature = ecdsa.SignData(data.Span, hashAlgorithm);
|
||||
return ValueTask.FromResult(signature);
|
||||
}
|
||||
|
||||
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
using var ecdsa = ECDsa.Create(signingKey.PublicParameters);
|
||||
var hashAlgorithm = ResolveHashAlgorithm(signingKey.AlgorithmId);
|
||||
var verified = ecdsa.VerifyData(data.Span, signature.Span, hashAlgorithm);
|
||||
return ValueTask.FromResult(verified);
|
||||
}
|
||||
|
||||
public JsonWebKey ExportPublicJsonWebKey()
|
||||
{
|
||||
var jwk = new JsonWebKey
|
||||
{
|
||||
Kid = signingKey.Reference.KeyId,
|
||||
Alg = signingKey.AlgorithmId,
|
||||
Kty = JsonWebAlgorithmsKeyTypes.EllipticCurve,
|
||||
Use = JsonWebKeyUseNames.Sig,
|
||||
Crv = ResolveCurve(signingKey.AlgorithmId)
|
||||
};
|
||||
|
||||
foreach (var op in DefaultKeyOps)
|
||||
{
|
||||
jwk.KeyOps.Add(op);
|
||||
}
|
||||
|
||||
jwk.X = Base64UrlEncoder.Encode(signingKey.PublicParameters.Q.X ?? Array.Empty<byte>());
|
||||
jwk.Y = Base64UrlEncoder.Encode(signingKey.PublicParameters.Q.Y ?? Array.Empty<byte>());
|
||||
|
||||
return jwk;
|
||||
}
|
||||
|
||||
private static HashAlgorithmName ResolveHashAlgorithm(string algorithmId) =>
|
||||
algorithmId switch
|
||||
{
|
||||
{ } alg when string.Equals(alg, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA256,
|
||||
{ } alg when string.Equals(alg, SignatureAlgorithms.Es384, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA384,
|
||||
{ } alg when string.Equals(alg, SignatureAlgorithms.Es512, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA512,
|
||||
_ => throw new InvalidOperationException($"Unsupported ECDSA signing algorithm '{algorithmId}'.")
|
||||
};
|
||||
|
||||
private static string ResolveCurve(string algorithmId)
|
||||
=> algorithmId switch
|
||||
{
|
||||
{ } alg when string.Equals(alg, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase) => JsonWebKeyECTypes.P256,
|
||||
{ } alg when string.Equals(alg, SignatureAlgorithms.Es384, StringComparison.OrdinalIgnoreCase) => JsonWebKeyECTypes.P384,
|
||||
{ } alg when string.Equals(alg, SignatureAlgorithms.Es512, StringComparison.OrdinalIgnoreCase) => JsonWebKeyECTypes.P521,
|
||||
_ => throw new InvalidOperationException($"Unsupported ECDSA curve mapping for algorithm '{algorithmId}'.")
|
||||
};
|
||||
}
|
||||
45
src/__Libraries/StellaOps.Cryptography/ICryptoSigner.cs
Normal file
45
src/__Libraries/StellaOps.Cryptography/ICryptoSigner.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an asymmetric signer capable of producing and verifying detached signatures.
|
||||
/// </summary>
|
||||
public interface ICryptoSigner
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the key identifier associated with this signer.
|
||||
/// </summary>
|
||||
string KeyId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the signing algorithm identifier (e.g., ES256).
|
||||
/// </summary>
|
||||
string AlgorithmId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Signs the supplied payload bytes.
|
||||
/// </summary>
|
||||
/// <param name="data">Payload to sign.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Signature bytes.</returns>
|
||||
ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a previously produced signature over the supplied payload bytes.
|
||||
/// </summary>
|
||||
/// <param name="data">Payload that was signed.</param>
|
||||
/// <param name="signature">Signature to verify.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns><c>true</c> when the signature is valid; otherwise <c>false</c>.</returns>
|
||||
ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Exports the public representation of the key material as a JSON Web Key (JWK).
|
||||
/// </summary>
|
||||
/// <returns>Public JWK for distribution (no private components).</returns>
|
||||
JsonWebKey ExportPublicJsonWebKey();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.")
|
||||
};
|
||||
}
|
||||
81
src/__Libraries/StellaOps.Cryptography/PasswordHashing.cs
Normal file
81
src/__Libraries/StellaOps.Cryptography/PasswordHashing.cs
Normal 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);
|
||||
}
|
||||
137
src/__Libraries/StellaOps.Cryptography/Pbkdf2PasswordHasher.cs
Normal file
137
src/__Libraries/StellaOps.Cryptography/Pbkdf2PasswordHasher.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// PBKDF2-SHA256 password hasher for legacy credentials.
|
||||
/// </summary>
|
||||
public sealed class Pbkdf2PasswordHasher : IPasswordHasher
|
||||
{
|
||||
private const int SaltLengthBytes = 16;
|
||||
private const int HashLengthBytes = 32;
|
||||
private const string Prefix = "PBKDF2";
|
||||
|
||||
public string Hash(string password, PasswordHashOptions options)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(password);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (options.Algorithm != PasswordHashAlgorithm.Pbkdf2)
|
||||
{
|
||||
throw new InvalidOperationException("Pbkdf2PasswordHasher only supports the PBKDF2 algorithm.");
|
||||
}
|
||||
|
||||
if (options.Iterations <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("PBKDF2 requires a positive iteration count.");
|
||||
}
|
||||
|
||||
Span<byte> salt = stackalloc byte[SaltLengthBytes];
|
||||
RandomNumberGenerator.Fill(salt);
|
||||
|
||||
var hash = Derive(password, salt, options.Iterations);
|
||||
|
||||
var payload = new byte[1 + SaltLengthBytes + HashLengthBytes];
|
||||
payload[0] = 0x01;
|
||||
salt.CopyTo(payload.AsSpan(1));
|
||||
hash.CopyTo(payload.AsSpan(1 + SaltLengthBytes));
|
||||
|
||||
return $"{Prefix}.{options.Iterations}.{Convert.ToBase64String(payload)}";
|
||||
}
|
||||
|
||||
public bool Verify(string password, string encodedHash)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(password);
|
||||
ArgumentException.ThrowIfNullOrEmpty(encodedHash);
|
||||
|
||||
if (!TryParse(encodedHash, out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var computed = Derive(password, parsed.Salt, parsed.Iterations);
|
||||
return CryptographicOperations.FixedTimeEquals(parsed.Hash, computed);
|
||||
}
|
||||
|
||||
public bool NeedsRehash(string encodedHash, PasswordHashOptions desired)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(desired);
|
||||
|
||||
if (!TryParse(encodedHash, out var parsed))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (desired.Algorithm != PasswordHashAlgorithm.Pbkdf2)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return parsed.Iterations != desired.Iterations;
|
||||
}
|
||||
|
||||
private static byte[] Derive(string password, ReadOnlySpan<byte> salt, int iterations)
|
||||
{
|
||||
return Rfc2898DeriveBytes.Pbkdf2(
|
||||
Encoding.UTF8.GetBytes(password),
|
||||
salt.ToArray(),
|
||||
iterations,
|
||||
HashAlgorithmName.SHA256,
|
||||
HashLengthBytes);
|
||||
}
|
||||
|
||||
private static bool TryParse(string encodedHash, out Pbkdf2Parameters parsed)
|
||||
{
|
||||
parsed = default;
|
||||
|
||||
var parts = encodedHash.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 3 || !string.Equals(parts[0], Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!int.TryParse(parts[1], out var iterations) || iterations <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = Convert.FromBase64String(parts[2]);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.Length != 1 + SaltLengthBytes + HashLengthBytes || payload[0] != 0x01)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var salt = new byte[SaltLengthBytes];
|
||||
var hash = new byte[HashLengthBytes];
|
||||
Array.Copy(payload, 1, salt, 0, SaltLengthBytes);
|
||||
Array.Copy(payload, 1 + SaltLengthBytes, hash, 0, HashLengthBytes);
|
||||
|
||||
parsed = new Pbkdf2Parameters(iterations, salt, hash);
|
||||
return true;
|
||||
}
|
||||
|
||||
private readonly struct Pbkdf2Parameters
|
||||
{
|
||||
public Pbkdf2Parameters(int iterations, byte[] salt, byte[] hash)
|
||||
{
|
||||
Iterations = iterations;
|
||||
Salt = salt;
|
||||
Hash = hash;
|
||||
}
|
||||
|
||||
public int Iterations { get; }
|
||||
public byte[] Salt { get; }
|
||||
public byte[] Hash { get; }
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
51
src/__Libraries/StellaOps.Cryptography/TASKS.md
Normal file
51
src/__Libraries/StellaOps.Cryptography/TASKS.md
Normal 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 < 250 ms. |
|
||||
| SEC1.B | DONE (2025-10-12) | Security Guild | Add compile-time switch to enable libsodium/Core variants later (`STELLAOPS_CRYPTO_SODIUM`). Document build variable. | SEC1.A | ✅ Conditional compilation path compiles; ✅ README snippet in `docs/security/password-hashing.md`. |
|
||||
| SEC2.A | DONE (2025-10-13) | Security Guild + Core | Define audit event contract (`AuthEventRecord`) with subject/client/scope/IP/outcome/correlationId and PII tags. | CORE5–CORE7 | ✅ Contract shipped in `StellaOps.Cryptography` (or shared abstractions); ✅ Docs in `docs/security/audit-events.md`. |
|
||||
| SEC2.B | 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 SEC1–SEC4 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 ≈ 19 MiB, 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.
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.DependencyInjection;
|
||||
|
||||
public interface IDependencyInjectionRoutine
|
||||
{
|
||||
IServiceCollection Register(
|
||||
IServiceCollection services,
|
||||
IConfiguration configuration);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
21
src/__Libraries/StellaOps.Plugin/Hosting/PluginAssembly.cs
Normal file
21
src/__Libraries/StellaOps.Plugin/Hosting/PluginAssembly.cs
Normal 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;
|
||||
}
|
||||
219
src/__Libraries/StellaOps.Plugin/Hosting/PluginHost.cs
Normal file
219
src/__Libraries/StellaOps.Plugin/Hosting/PluginHost.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
26
src/__Libraries/StellaOps.Plugin/Hosting/PluginHostResult.cs
Normal file
26
src/__Libraries/StellaOps.Plugin/Hosting/PluginHostResult.cs
Normal 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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
172
src/__Libraries/StellaOps.Plugin/PluginContracts.cs
Normal file
172
src/__Libraries/StellaOps.Plugin/PluginContracts.cs
Normal 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)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Plugin.Tests")]
|
||||
20
src/__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj
Normal file
20
src/__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj
Normal 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>
|
||||
9
src/__Libraries/StellaOps.Plugin/TASKS.md
Normal file
9
src/__Libraries/StellaOps.Plugin/TASKS.md
Normal 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:00–16:05 UTC; 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`.|
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using StellaOps.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
public class Argon2idPasswordHasherTests
|
||||
{
|
||||
private readonly Argon2idPasswordHasher hasher = new();
|
||||
|
||||
[Fact]
|
||||
public void Hash_ProducesPhcEncodedString()
|
||||
{
|
||||
var options = new PasswordHashOptions();
|
||||
var encoded = hasher.Hash("s3cret", options);
|
||||
|
||||
Assert.StartsWith("$argon2id$", encoded, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ReturnsTrue_ForCorrectPassword()
|
||||
{
|
||||
var options = new PasswordHashOptions();
|
||||
var encoded = hasher.Hash("s3cret", options);
|
||||
|
||||
Assert.True(hasher.Verify("s3cret", encoded));
|
||||
Assert.False(hasher.Verify("wrong", encoded));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeedsRehash_ReturnsTrue_WhenParametersChange()
|
||||
{
|
||||
var options = new PasswordHashOptions();
|
||||
var encoded = hasher.Hash("s3cret", options);
|
||||
|
||||
var updated = options with { Iterations = options.Iterations + 1 };
|
||||
|
||||
Assert.True(hasher.NeedsRehash(encoded, updated));
|
||||
Assert.False(hasher.NeedsRehash(encoded, options));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
public class DefaultCryptoProviderSigningTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task UpsertSigningKey_AllowsSignAndVerifyEs256()
|
||||
{
|
||||
var provider = new DefaultCryptoProvider();
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
||||
|
||||
var signingKey = new CryptoSigningKey(
|
||||
new CryptoKeyReference("revocation-key"),
|
||||
SignatureAlgorithms.Es256,
|
||||
privateParameters: in parameters,
|
||||
createdAt: DateTimeOffset.UtcNow);
|
||||
|
||||
provider.UpsertSigningKey(signingKey);
|
||||
|
||||
var signer = provider.GetSigner(SignatureAlgorithms.Es256, signingKey.Reference);
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes("hello-world");
|
||||
var signature = await signer.SignAsync(payload);
|
||||
|
||||
Assert.NotNull(signature);
|
||||
Assert.True(signature.Length > 0);
|
||||
|
||||
var verified = await signer.VerifyAsync(payload, signature);
|
||||
Assert.True(verified);
|
||||
|
||||
var jwk = signer.ExportPublicJsonWebKey();
|
||||
Assert.Equal(signingKey.Reference.KeyId, jwk.Kid);
|
||||
Assert.Equal(SignatureAlgorithms.Es256, jwk.Alg);
|
||||
Assert.Equal(JsonWebAlgorithmsKeyTypes.EllipticCurve, jwk.Kty);
|
||||
Assert.Equal(JsonWebKeyUseNames.Sig, jwk.Use);
|
||||
Assert.Equal(JsonWebKeyECTypes.P256, jwk.Crv);
|
||||
Assert.False(string.IsNullOrWhiteSpace(jwk.X));
|
||||
Assert.False(string.IsNullOrWhiteSpace(jwk.Y));
|
||||
|
||||
var tampered = (byte[])signature.Clone();
|
||||
tampered[^1] ^= 0xFF;
|
||||
var tamperedResult = await signer.VerifyAsync(payload, tampered);
|
||||
Assert.False(tamperedResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveSigningKey_PreventsRetrieval()
|
||||
{
|
||||
var provider = new DefaultCryptoProvider();
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var parameters = ecdsa.ExportParameters(true);
|
||||
var signingKey = new CryptoSigningKey(new CryptoKeyReference("key-to-remove"), SignatureAlgorithms.Es256, in parameters, DateTimeOffset.UtcNow);
|
||||
|
||||
provider.UpsertSigningKey(signingKey);
|
||||
Assert.True(provider.RemoveSigningKey(signingKey.Reference.KeyId));
|
||||
|
||||
Assert.Throws<KeyNotFoundException>(() => provider.GetSigner(SignatureAlgorithms.Es256, signingKey.Reference));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using StellaOps.Cryptography;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Cryptography.Tests;
|
||||
|
||||
public class Pbkdf2PasswordHasherTests
|
||||
{
|
||||
private readonly Pbkdf2PasswordHasher hasher = new();
|
||||
|
||||
[Fact]
|
||||
public void Hash_ProducesLegacyFormat()
|
||||
{
|
||||
var options = new PasswordHashOptions
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Pbkdf2,
|
||||
Iterations = 210_000
|
||||
};
|
||||
|
||||
var encoded = hasher.Hash("s3cret", options);
|
||||
|
||||
Assert.StartsWith("PBKDF2.", encoded, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_Succeeds_ForCorrectPassword()
|
||||
{
|
||||
var options = new PasswordHashOptions
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Pbkdf2,
|
||||
Iterations = 210_000
|
||||
};
|
||||
|
||||
var encoded = hasher.Hash("s3cret", options);
|
||||
|
||||
Assert.True(hasher.Verify("s3cret", encoded));
|
||||
Assert.False(hasher.Verify("other", encoded));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeedsRehash_DetectsIterationChange()
|
||||
{
|
||||
var options = new PasswordHashOptions
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Pbkdf2,
|
||||
Iterations = 100_000
|
||||
};
|
||||
|
||||
var encoded = hasher.Hash("s3cret", options);
|
||||
|
||||
var higher = options with { Iterations = 150_000 };
|
||||
|
||||
Assert.True(hasher.NeedsRehash(encoded, higher));
|
||||
Assert.False(hasher.NeedsRehash(encoded, options));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user