up
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Build Test Deploy / docs (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / deploy (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / build-test (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / authority-container (push) Has been cancelled
				
			
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Build Test Deploy / docs (push) Has been cancelled
				
			Build Test Deploy / deploy (push) Has been cancelled
				
			Build Test Deploy / build-test (push) Has been cancelled
				
			Build Test Deploy / authority-container (push) Has been cancelled
				
			Docs CI / lint-and-preview (push) Has been cancelled
				
			This commit is contained in:
		| @@ -0,0 +1,28 @@ | ||||
| #if !STELLAOPS_CRYPTO_SODIUM | ||||
| using System; | ||||
| using System.Text; | ||||
| using Konscious.Security.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Managed Argon2id implementation powered by Konscious.Security.Cryptography. | ||||
| /// </summary> | ||||
| public sealed partial class Argon2idPasswordHasher | ||||
| { | ||||
|     private static partial byte[] DeriveHashCore(string password, ReadOnlySpan<byte> salt, PasswordHashOptions options) | ||||
|     { | ||||
|         var passwordBytes = Encoding.UTF8.GetBytes(password); | ||||
|  | ||||
|         using var argon2 = new Argon2id(passwordBytes) | ||||
|         { | ||||
|             Salt = salt.ToArray(), | ||||
|             DegreeOfParallelism = options.Parallelism, | ||||
|             Iterations = options.Iterations, | ||||
|             MemorySize = options.MemorySizeInKib | ||||
|         }; | ||||
|  | ||||
|         return argon2.GetBytes(HashLengthBytes); | ||||
|     } | ||||
| } | ||||
| #endif | ||||
							
								
								
									
										30
									
								
								src/StellaOps.Cryptography/Argon2idPasswordHasher.Sodium.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/StellaOps.Cryptography/Argon2idPasswordHasher.Sodium.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| #if STELLAOPS_CRYPTO_SODIUM | ||||
| using System; | ||||
| using System.Text; | ||||
| using Konscious.Security.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Placeholder for libsodium-backed Argon2id implementation. | ||||
| /// Falls back to the managed Konscious variant until native bindings land. | ||||
| /// </summary> | ||||
| public sealed partial class Argon2idPasswordHasher | ||||
| { | ||||
|     private static partial byte[] DeriveHashCore(string password, ReadOnlySpan<byte> salt, PasswordHashOptions options) | ||||
|     { | ||||
|         // TODO(SEC1.B follow-up): replace with libsodium/core bindings and managed pinning logic. | ||||
|         var passwordBytes = Encoding.UTF8.GetBytes(password); | ||||
|  | ||||
|         using var argon2 = new Argon2id(passwordBytes) | ||||
|         { | ||||
|             Salt = salt.ToArray(), | ||||
|             DegreeOfParallelism = options.Parallelism, | ||||
|             Iterations = options.Iterations, | ||||
|             MemorySize = options.MemorySizeInKib | ||||
|         }; | ||||
|  | ||||
|         return argon2.GetBytes(HashLengthBytes); | ||||
|     } | ||||
| } | ||||
| #endif | ||||
							
								
								
									
										173
									
								
								src/StellaOps.Cryptography/Argon2idPasswordHasher.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										173
									
								
								src/StellaOps.Cryptography/Argon2idPasswordHasher.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,173 @@ | ||||
| using System; | ||||
| using System.Globalization; | ||||
| using System.Security.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Argon2id password hasher that emits PHC-compliant encoded strings. | ||||
| /// </summary> | ||||
| public sealed partial class Argon2idPasswordHasher : IPasswordHasher | ||||
| { | ||||
|     private const int SaltLengthBytes = 16; | ||||
|     private const int HashLengthBytes = 32; | ||||
|  | ||||
|     public string Hash(string password, PasswordHashOptions options) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrEmpty(password); | ||||
|         ArgumentNullException.ThrowIfNull(options); | ||||
|  | ||||
|         options.Validate(); | ||||
|  | ||||
|         if (options.Algorithm != PasswordHashAlgorithm.Argon2id) | ||||
|         { | ||||
|             throw new InvalidOperationException("Argon2idPasswordHasher only supports the Argon2id algorithm."); | ||||
|         } | ||||
|  | ||||
|         Span<byte> salt = stackalloc byte[SaltLengthBytes]; | ||||
|         RandomNumberGenerator.Fill(salt); | ||||
|  | ||||
|         var hash = DeriveHash(password, salt, options); | ||||
|  | ||||
|         return BuildEncodedHash(salt, hash, options); | ||||
|     } | ||||
|  | ||||
|     public bool Verify(string password, string encodedHash) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrEmpty(password); | ||||
|         ArgumentException.ThrowIfNullOrEmpty(encodedHash); | ||||
|  | ||||
|         if (!TryParse(encodedHash, out var parsed)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var computed = DeriveHash(password, parsed.Salt, parsed.Options); | ||||
|         return CryptographicOperations.FixedTimeEquals(computed, parsed.Hash); | ||||
|     } | ||||
|  | ||||
|     public bool NeedsRehash(string encodedHash, PasswordHashOptions desired) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(desired); | ||||
|  | ||||
|         if (!TryParse(encodedHash, out var parsed)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (desired.Algorithm != PasswordHashAlgorithm.Argon2id) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (!parsed.Options.Algorithm.Equals(desired.Algorithm)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return parsed.Options.MemorySizeInKib != desired.MemorySizeInKib | ||||
|             || parsed.Options.Iterations != desired.Iterations | ||||
|             || parsed.Options.Parallelism != desired.Parallelism; | ||||
|     } | ||||
|  | ||||
|     private static byte[] DeriveHash(string password, ReadOnlySpan<byte> salt, PasswordHashOptions options) | ||||
|         => DeriveHashCore(password, salt, options); | ||||
|  | ||||
|     private static partial byte[] DeriveHashCore(string password, ReadOnlySpan<byte> salt, PasswordHashOptions options); | ||||
|  | ||||
|     private static string BuildEncodedHash(ReadOnlySpan<byte> salt, ReadOnlySpan<byte> hash, PasswordHashOptions options) | ||||
|     { | ||||
|         var saltEncoded = Convert.ToBase64String(salt); | ||||
|         var hashEncoded = Convert.ToBase64String(hash); | ||||
|  | ||||
|         return $"$argon2id$v=19$m={options.MemorySizeInKib},t={options.Iterations},p={options.Parallelism}${saltEncoded}${hashEncoded}"; | ||||
|     } | ||||
|  | ||||
|     private static bool TryParse(string encodedHash, out Argon2HashParameters parsed) | ||||
|     { | ||||
|         parsed = default; | ||||
|  | ||||
|         if (!encodedHash.StartsWith("$argon2id$", StringComparison.Ordinal)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var segments = encodedHash.Split('$', StringSplitOptions.RemoveEmptyEntries); | ||||
|         if (segments.Length != 5) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // segments: 0=argon2id, 1=v=19, 2=m=...,t=...,p=..., 3=salt, 4=hash | ||||
|         if (!segments[1].StartsWith("v=19", StringComparison.Ordinal)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var parameterParts = segments[2].Split(',', StringSplitOptions.RemoveEmptyEntries); | ||||
|         if (parameterParts.Length != 3) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!TryParseInt(parameterParts[0], "m", out var memory) || | ||||
|             !TryParseInt(parameterParts[1], "t", out var iterations) || | ||||
|             !TryParseInt(parameterParts[2], "p", out var parallelism)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         byte[] saltBytes; | ||||
|         byte[] hashBytes; | ||||
|         try | ||||
|         { | ||||
|             saltBytes = Convert.FromBase64String(segments[3]); | ||||
|             hashBytes = Convert.FromBase64String(segments[4]); | ||||
|         } | ||||
|         catch (FormatException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (saltBytes.Length != SaltLengthBytes || hashBytes.Length != HashLengthBytes) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var options = new PasswordHashOptions | ||||
|         { | ||||
|             Algorithm = PasswordHashAlgorithm.Argon2id, | ||||
|             MemorySizeInKib = memory, | ||||
|             Iterations = iterations, | ||||
|             Parallelism = parallelism | ||||
|         }; | ||||
|  | ||||
|         parsed = new Argon2HashParameters(options, saltBytes, hashBytes); | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private static bool TryParseInt(string component, string key, out int value) | ||||
|     { | ||||
|         value = 0; | ||||
|         if (!component.StartsWith(key + "=", StringComparison.Ordinal)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return int.TryParse(component.AsSpan(key.Length + 1), NumberStyles.None, CultureInfo.InvariantCulture, out value); | ||||
|     } | ||||
|  | ||||
|     private readonly struct Argon2HashParameters | ||||
|     { | ||||
|         public Argon2HashParameters(PasswordHashOptions options, byte[] salt, byte[] hash) | ||||
|         { | ||||
|             Options = options; | ||||
|             Salt = salt; | ||||
|             Hash = hash; | ||||
|         } | ||||
|  | ||||
|         public PasswordHashOptions Options { get; } | ||||
|         public byte[] Salt { get; } | ||||
|         public byte[] Hash { get; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										258
									
								
								src/StellaOps.Cryptography/Audit/AuthEventRecord.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										258
									
								
								src/StellaOps.Cryptography/Audit/AuthEventRecord.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,258 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
|  | ||||
| namespace StellaOps.Cryptography.Audit; | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents a structured security event emitted by the Authority host and plugins. | ||||
| /// </summary> | ||||
| public sealed record AuthEventRecord | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Canonical event identifier (e.g. <c>authority.password.grant</c>). | ||||
|     /// </summary> | ||||
|     public required string EventType { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// UTC timestamp captured when the event occurred. | ||||
|     /// </summary> | ||||
|     public DateTimeOffset OccurredAt { get; init; } = DateTimeOffset.UtcNow; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Stable correlation identifier that links the event across logs, traces, and persistence. | ||||
|     /// </summary> | ||||
|     public string? CorrelationId { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Outcome classification for the audited operation. | ||||
|     /// </summary> | ||||
|     public AuthEventOutcome Outcome { get; init; } = AuthEventOutcome.Unknown; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Optional human-readable reason or failure descriptor. | ||||
|     /// </summary> | ||||
|     public string? Reason { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Identity of the end-user (subject) involved in the event, when applicable. | ||||
|     /// </summary> | ||||
|     public AuthEventSubject? Subject { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// OAuth/OIDC client metadata associated with the event, when applicable. | ||||
|     /// </summary> | ||||
|     public AuthEventClient? Client { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Granted or requested scopes tied to the event. | ||||
|     /// </summary> | ||||
|     public IReadOnlyList<string> Scopes { get; init; } = Array.Empty<string>(); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Network attributes (remote IP, forwarded headers, user agent) captured for the request. | ||||
|     /// </summary> | ||||
|     public AuthEventNetwork? Network { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Additional classified properties carried with the event. | ||||
|     /// </summary> | ||||
|     public IReadOnlyList<AuthEventProperty> Properties { get; init; } = Array.Empty<AuthEventProperty>(); | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Describes the outcome of an audited flow. | ||||
| /// </summary> | ||||
| public enum AuthEventOutcome | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Outcome has not been set. | ||||
|     /// </summary> | ||||
|     Unknown = 0, | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Operation succeeded. | ||||
|     /// </summary> | ||||
|     Success, | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Operation failed (invalid credentials, configuration issues, etc.). | ||||
|     /// </summary> | ||||
|     Failure, | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Operation failed due to a lockout policy. | ||||
|     /// </summary> | ||||
|     LockedOut, | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Operation was rejected due to rate limiting or throttling. | ||||
|     /// </summary> | ||||
|     RateLimited, | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Operation encountered an unexpected error. | ||||
|     /// </summary> | ||||
|     Error | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents a string value enriched with a data classification tag. | ||||
| /// </summary> | ||||
| public readonly record struct ClassifiedString(string? Value, AuthEventDataClassification Classification) | ||||
| { | ||||
|     /// <summary> | ||||
|     /// An empty classified string. | ||||
|     /// </summary> | ||||
|     public static ClassifiedString Empty => new(null, AuthEventDataClassification.None); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Indicates whether the classified string carries a non-empty value. | ||||
|     /// </summary> | ||||
|     public bool HasValue => !string.IsNullOrWhiteSpace(Value); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Creates a classified string for public/non-sensitive data. | ||||
|     /// </summary> | ||||
|     public static ClassifiedString Public(string? value) => Create(value, AuthEventDataClassification.None); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Creates a classified string tagged as personally identifiable information (PII). | ||||
|     /// </summary> | ||||
|     public static ClassifiedString Personal(string? value) => Create(value, AuthEventDataClassification.Personal); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Creates a classified string tagged as sensitive (e.g. credentials, secrets). | ||||
|     /// </summary> | ||||
|     public static ClassifiedString Sensitive(string? value) => Create(value, AuthEventDataClassification.Sensitive); | ||||
|  | ||||
|     private static ClassifiedString Create(string? value, AuthEventDataClassification classification) | ||||
|     { | ||||
|         return new ClassifiedString(Normalize(value), classification); | ||||
|     } | ||||
|  | ||||
|     private static string? Normalize(string? value) | ||||
|     { | ||||
|         return string.IsNullOrWhiteSpace(value) ? null : value; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Supported classifications for audit data values. | ||||
| /// </summary> | ||||
| public enum AuthEventDataClassification | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Data is not considered sensitive. | ||||
|     /// </summary> | ||||
|     None = 0, | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Personally identifiable information (PII) that warrants redaction in certain sinks. | ||||
|     /// </summary> | ||||
|     Personal, | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Highly sensitive information (credentials, secrets, tokens). | ||||
|     /// </summary> | ||||
|     Sensitive | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Captures subject metadata for an audit event. | ||||
| /// </summary> | ||||
| public sealed record AuthEventSubject | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Stable subject identifier (PII). | ||||
|     /// </summary> | ||||
|     public ClassifiedString SubjectId { get; init; } = ClassifiedString.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Username or login name (PII). | ||||
|     /// </summary> | ||||
|     public ClassifiedString Username { get; init; } = ClassifiedString.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Optional display name (PII). | ||||
|     /// </summary> | ||||
|     public ClassifiedString DisplayName { get; init; } = ClassifiedString.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Optional plugin or tenant realm controlling the subject namespace. | ||||
|     /// </summary> | ||||
|     public ClassifiedString Realm { get; init; } = ClassifiedString.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Additional classified attributes (e.g. email, phone). | ||||
|     /// </summary> | ||||
|     public IReadOnlyList<AuthEventProperty> Attributes { get; init; } = Array.Empty<AuthEventProperty>(); | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Captures OAuth/OIDC client metadata for an audit event. | ||||
| /// </summary> | ||||
| public sealed record AuthEventClient | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Client identifier (PII for confidential clients). | ||||
|     /// </summary> | ||||
|     public ClassifiedString ClientId { get; init; } = ClassifiedString.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Friendly client name (may be public). | ||||
|     /// </summary> | ||||
|     public ClassifiedString Name { get; init; } = ClassifiedString.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Identity provider/plugin originating the client. | ||||
|     /// </summary> | ||||
|     public ClassifiedString Provider { get; init; } = ClassifiedString.Empty; | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Captures network metadata for an audit event. | ||||
| /// </summary> | ||||
| public sealed record AuthEventNetwork | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Remote address observed for the request (PII). | ||||
|     /// </summary> | ||||
|     public ClassifiedString RemoteAddress { get; init; } = ClassifiedString.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Forwarded address supplied by proxies (PII). | ||||
|     /// </summary> | ||||
|     public ClassifiedString ForwardedFor { get; init; } = ClassifiedString.Empty; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// User agent string associated with the request. | ||||
|     /// </summary> | ||||
|     public ClassifiedString UserAgent { get; init; } = ClassifiedString.Empty; | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents an additional classified property associated with the audit event. | ||||
| /// </summary> | ||||
| public sealed record AuthEventProperty | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Property name (canonical snake-case identifier). | ||||
|     /// </summary> | ||||
|     public required string Name { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Classified value assigned to the property. | ||||
|     /// </summary> | ||||
|     public ClassifiedString Value { get; init; } = ClassifiedString.Empty; | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| /// Sink that receives completed audit event records. | ||||
| /// </summary> | ||||
| public interface IAuthEventSink | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Persists the supplied audit event. | ||||
|     /// </summary> | ||||
|     ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken); | ||||
| } | ||||
| @@ -29,6 +29,32 @@ public interface ICryptoProvider | ||||
|     bool Supports(CryptoCapability capability, string algorithmId); | ||||
|  | ||||
|     IPasswordHasher GetPasswordHasher(string algorithmId); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Retrieves a signer for the supplied algorithm and key reference. | ||||
|     /// </summary> | ||||
|     /// <param name="algorithmId">Signing algorithm identifier (e.g., ES256).</param> | ||||
|     /// <param name="keyReference">Key reference.</param> | ||||
|     /// <returns>Signer instance.</returns> | ||||
|     ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Adds or replaces signing key material managed by this provider. | ||||
|     /// </summary> | ||||
|     /// <param name="signingKey">Key material descriptor.</param> | ||||
|     void UpsertSigningKey(CryptoSigningKey signingKey); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Removes signing key material by key identifier. | ||||
|     /// </summary> | ||||
|     /// <param name="keyId">Identifier to remove.</param> | ||||
|     /// <returns><c>true</c> if the key was removed.</returns> | ||||
|     bool RemoveSigningKey(string keyId); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Lists signing key descriptors managed by this provider. | ||||
|     /// </summary> | ||||
|     IReadOnlyCollection<CryptoSigningKey> GetSigningKeys(); | ||||
| } | ||||
|  | ||||
| /// <summary> | ||||
| @@ -41,4 +67,18 @@ public interface ICryptoProviderRegistry | ||||
|     bool TryResolve(string preferredProvider, out ICryptoProvider provider); | ||||
|  | ||||
|     ICryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Resolves a signer for the supplied algorithm and key reference using registry policy. | ||||
|     /// </summary> | ||||
|     /// <param name="capability">Capability required (typically <see cref="CryptoCapability.Signing"/>).</param> | ||||
|     /// <param name="algorithmId">Algorithm identifier.</param> | ||||
|     /// <param name="keyReference">Key reference.</param> | ||||
|     /// <param name="preferredProvider">Optional provider hint.</param> | ||||
|     /// <returns>Resolved signer.</returns> | ||||
|     ICryptoSigner ResolveSigner( | ||||
|         CryptoCapability capability, | ||||
|         string algorithmId, | ||||
|         CryptoKeyReference keyReference, | ||||
|         string? preferredProvider = null); | ||||
| } | ||||
|   | ||||
							
								
								
									
										112
									
								
								src/StellaOps.Cryptography/CryptoProviderRegistry.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								src/StellaOps.Cryptography/CryptoProviderRegistry.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,112 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.ObjectModel; | ||||
| using System.Linq; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Default implementation of <see cref="ICryptoProviderRegistry"/> with deterministic provider ordering. | ||||
| /// </summary> | ||||
| public sealed class CryptoProviderRegistry : ICryptoProviderRegistry | ||||
| { | ||||
|     private readonly ReadOnlyCollection<ICryptoProvider> providers; | ||||
|     private readonly IReadOnlyDictionary<string, ICryptoProvider> providersByName; | ||||
|     private readonly IReadOnlyList<string> preferredOrder; | ||||
|     private readonly HashSet<string> preferredOrderSet; | ||||
|  | ||||
|     public CryptoProviderRegistry( | ||||
|         IEnumerable<ICryptoProvider> providers, | ||||
|         IEnumerable<string>? preferredProviderOrder = null) | ||||
|     { | ||||
|         if (providers is null) | ||||
|         { | ||||
|             throw new ArgumentNullException(nameof(providers)); | ||||
|         } | ||||
|  | ||||
|         var providerList = providers.ToList(); | ||||
|         if (providerList.Count == 0) | ||||
|         { | ||||
|             throw new ArgumentException("At least one crypto provider must be registered.", nameof(providers)); | ||||
|         } | ||||
|  | ||||
|         providersByName = providerList.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase); | ||||
|         this.providers = new ReadOnlyCollection<ICryptoProvider>(providerList); | ||||
|  | ||||
|         preferredOrder = preferredProviderOrder? | ||||
|             .Where(name => providersByName.ContainsKey(name)) | ||||
|             .Select(name => providersByName[name].Name) | ||||
|             .ToArray() ?? Array.Empty<string>(); | ||||
|         preferredOrderSet = new HashSet<string>(preferredOrder, StringComparer.OrdinalIgnoreCase); | ||||
|     } | ||||
|  | ||||
|     public IReadOnlyCollection<ICryptoProvider> Providers => providers; | ||||
|  | ||||
|     public bool TryResolve(string preferredProvider, out ICryptoProvider provider) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(preferredProvider)) | ||||
|         { | ||||
|             provider = default!; | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return providersByName.TryGetValue(preferredProvider, out provider!); | ||||
|     } | ||||
|  | ||||
|     public ICryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(algorithmId)) | ||||
|         { | ||||
|             throw new ArgumentException("Algorithm identifier is required.", nameof(algorithmId)); | ||||
|         } | ||||
|  | ||||
|         foreach (var provider in EnumerateCandidates()) | ||||
|         { | ||||
|             if (provider.Supports(capability, algorithmId)) | ||||
|             { | ||||
|                 return provider; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         throw new InvalidOperationException( | ||||
|             $"No crypto provider is registered for capability '{capability}' and algorithm '{algorithmId}'."); | ||||
|     } | ||||
|  | ||||
|     public ICryptoSigner ResolveSigner( | ||||
|         CryptoCapability capability, | ||||
|         string algorithmId, | ||||
|         CryptoKeyReference keyReference, | ||||
|         string? preferredProvider = null) | ||||
|     { | ||||
|         if (!string.IsNullOrWhiteSpace(preferredProvider) && | ||||
|             providersByName.TryGetValue(preferredProvider!, out var hinted)) | ||||
|         { | ||||
|             if (!hinted.Supports(capability, algorithmId)) | ||||
|             { | ||||
|                 throw new InvalidOperationException( | ||||
|                     $"Provider '{preferredProvider}' does not support capability '{capability}' and algorithm '{algorithmId}'."); | ||||
|             } | ||||
|  | ||||
|             return hinted.GetSigner(algorithmId, keyReference); | ||||
|         } | ||||
|  | ||||
|         var provider = ResolveOrThrow(capability, algorithmId); | ||||
|         return provider.GetSigner(algorithmId, keyReference); | ||||
|     } | ||||
|  | ||||
|     private IEnumerable<ICryptoProvider> EnumerateCandidates() | ||||
|     { | ||||
|         foreach (var name in preferredOrder) | ||||
|         { | ||||
|             yield return providersByName[name]; | ||||
|         } | ||||
|  | ||||
|         foreach (var provider in providers) | ||||
|         { | ||||
|             if (!preferredOrderSet.Contains(provider.Name)) | ||||
|             { | ||||
|                 yield return provider; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										105
									
								
								src/StellaOps.Cryptography/CryptoSigningKey.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								src/StellaOps.Cryptography/CryptoSigningKey.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.ObjectModel; | ||||
| using System.Linq; | ||||
| using System.Security.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents asymmetric signing key material managed by a crypto provider. | ||||
| /// </summary> | ||||
| public sealed class CryptoSigningKey | ||||
| { | ||||
|     private static readonly ReadOnlyDictionary<string, string?> EmptyMetadata = | ||||
|         new(new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)); | ||||
|  | ||||
|     public CryptoSigningKey( | ||||
|         CryptoKeyReference reference, | ||||
|         string algorithmId, | ||||
|         in ECParameters privateParameters, | ||||
|         DateTimeOffset createdAt, | ||||
|         DateTimeOffset? expiresAt = null, | ||||
|         IReadOnlyDictionary<string, string?>? metadata = null) | ||||
|     { | ||||
|         Reference = reference ?? throw new ArgumentNullException(nameof(reference)); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(algorithmId)) | ||||
|         { | ||||
|             throw new ArgumentException("Algorithm identifier is required.", nameof(algorithmId)); | ||||
|         } | ||||
|  | ||||
|         if (privateParameters.D is null || privateParameters.D.Length == 0) | ||||
|         { | ||||
|             throw new ArgumentException("Private key parameters must include the scalar component.", nameof(privateParameters)); | ||||
|         } | ||||
|  | ||||
|         AlgorithmId = algorithmId; | ||||
|         CreatedAt = createdAt; | ||||
|         ExpiresAt = expiresAt; | ||||
|  | ||||
|         PrivateParameters = CloneParameters(privateParameters, includePrivate: true); | ||||
|         PublicParameters = CloneParameters(privateParameters, includePrivate: false); | ||||
|         Metadata = metadata is null | ||||
|             ? EmptyMetadata | ||||
|             : new ReadOnlyDictionary<string, string?>(metadata.ToDictionary( | ||||
|                 static pair => pair.Key, | ||||
|                 static pair => pair.Value, | ||||
|                 StringComparer.OrdinalIgnoreCase)); | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the key reference (id + provider hint). | ||||
|     /// </summary> | ||||
|     public CryptoKeyReference Reference { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the algorithm identifier (e.g., ES256). | ||||
|     /// </summary> | ||||
|     public string AlgorithmId { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the private EC parameters (cloned). | ||||
|     /// </summary> | ||||
|     public ECParameters PrivateParameters { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the public EC parameters (cloned, no private scalar). | ||||
|     /// </summary> | ||||
|     public ECParameters PublicParameters { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the timestamp when the key was created/imported. | ||||
|     /// </summary> | ||||
|     public DateTimeOffset CreatedAt { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the optional expiry timestamp for the key. | ||||
|     /// </summary> | ||||
|     public DateTimeOffset? ExpiresAt { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets arbitrary metadata entries associated with the key. | ||||
|     /// </summary> | ||||
|     public IReadOnlyDictionary<string, string?> Metadata { get; } | ||||
|  | ||||
|     private static ECParameters CloneParameters(ECParameters source, bool includePrivate) | ||||
|     { | ||||
|         var clone = new ECParameters | ||||
|         { | ||||
|             Curve = source.Curve, | ||||
|             Q = new ECPoint | ||||
|             { | ||||
|                 X = source.Q.X is null ? null : (byte[])source.Q.X.Clone(), | ||||
|                 Y = source.Q.Y is null ? null : (byte[])source.Q.Y.Clone() | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         if (includePrivate && source.D is not null) | ||||
|         { | ||||
|             clone.D = (byte[])source.D.Clone(); | ||||
|         } | ||||
|  | ||||
|         return clone; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										129
									
								
								src/StellaOps.Cryptography/DefaultCryptoProvider.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								src/StellaOps.Cryptography/DefaultCryptoProvider.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,129 @@ | ||||
| using System; | ||||
| using System.Collections.Concurrent; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Security.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Default in-process crypto provider exposing password hashing capabilities. | ||||
| /// </summary> | ||||
| public sealed class DefaultCryptoProvider : ICryptoProvider | ||||
| { | ||||
|     private readonly ConcurrentDictionary<string, IPasswordHasher> passwordHashers; | ||||
|     private readonly ConcurrentDictionary<string, CryptoSigningKey> signingKeys; | ||||
|     private static readonly HashSet<string> SupportedSigningAlgorithms = new(StringComparer.OrdinalIgnoreCase) | ||||
|     { | ||||
|         SignatureAlgorithms.Es256 | ||||
|     }; | ||||
|  | ||||
|     public DefaultCryptoProvider() | ||||
|     { | ||||
|         passwordHashers = new ConcurrentDictionary<string, IPasswordHasher>(StringComparer.OrdinalIgnoreCase); | ||||
|         signingKeys = new ConcurrentDictionary<string, CryptoSigningKey>(StringComparer.Ordinal); | ||||
|  | ||||
|         var argon = new Argon2idPasswordHasher(); | ||||
|         var pbkdf2 = new Pbkdf2PasswordHasher(); | ||||
|  | ||||
|         passwordHashers.TryAdd(PasswordHashAlgorithm.Argon2id.ToString(), argon); | ||||
|         passwordHashers.TryAdd(PasswordHashAlgorithms.Argon2id, argon); | ||||
|         passwordHashers.TryAdd(PasswordHashAlgorithm.Pbkdf2.ToString(), pbkdf2); | ||||
|         passwordHashers.TryAdd(PasswordHashAlgorithms.Pbkdf2Sha256, pbkdf2); | ||||
|     } | ||||
|  | ||||
|     public string Name => "default"; | ||||
|  | ||||
|     public bool Supports(CryptoCapability capability, string algorithmId) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(algorithmId)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return capability switch | ||||
|         { | ||||
|             CryptoCapability.PasswordHashing => passwordHashers.ContainsKey(algorithmId), | ||||
|             CryptoCapability.Signing or CryptoCapability.Verification => SupportedSigningAlgorithms.Contains(algorithmId), | ||||
|             _ => false | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     public IPasswordHasher GetPasswordHasher(string algorithmId) | ||||
|     { | ||||
|         if (!Supports(CryptoCapability.PasswordHashing, algorithmId)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Password hashing algorithm '{algorithmId}' is not supported by provider '{Name}'."); | ||||
|         } | ||||
|  | ||||
|         return passwordHashers[algorithmId]; | ||||
|     } | ||||
|  | ||||
|     public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(keyReference); | ||||
|  | ||||
|         if (!Supports(CryptoCapability.Signing, algorithmId)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider '{Name}'."); | ||||
|         } | ||||
|  | ||||
|         if (!signingKeys.TryGetValue(keyReference.KeyId, out var signingKey)) | ||||
|         { | ||||
|             throw new KeyNotFoundException($"Signing key '{keyReference.KeyId}' is not registered with provider '{Name}'."); | ||||
|         } | ||||
|  | ||||
|         if (!string.Equals(signingKey.AlgorithmId, algorithmId, StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             throw new InvalidOperationException( | ||||
|                 $"Signing key '{keyReference.KeyId}' is registered for algorithm '{signingKey.AlgorithmId}', not '{algorithmId}'."); | ||||
|         } | ||||
|  | ||||
|         return EcdsaSigner.Create(signingKey); | ||||
|     } | ||||
|  | ||||
|     public void UpsertSigningKey(CryptoSigningKey signingKey) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(signingKey); | ||||
|         EnsureSigningSupported(signingKey.AlgorithmId); | ||||
|         ValidateSigningKey(signingKey); | ||||
|  | ||||
|         signingKeys.AddOrUpdate(signingKey.Reference.KeyId, signingKey, (_, _) => signingKey); | ||||
|     } | ||||
|  | ||||
|     public bool RemoveSigningKey(string keyId) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(keyId)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         return signingKeys.TryRemove(keyId, out _); | ||||
|     } | ||||
|  | ||||
|     public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys() | ||||
|         => signingKeys.Values.ToArray(); | ||||
|  | ||||
|     private static void EnsureSigningSupported(string algorithmId) | ||||
|     { | ||||
|         if (!SupportedSigningAlgorithms.Contains(algorithmId)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider 'default'."); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static void ValidateSigningKey(CryptoSigningKey signingKey) | ||||
|     { | ||||
|         if (!string.Equals(signingKey.AlgorithmId, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase)) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Only ES256 signing keys are currently supported by provider 'default'."); | ||||
|         } | ||||
|  | ||||
|         var expected = ECCurve.NamedCurves.nistP256; | ||||
|         var curve = signingKey.PrivateParameters.Curve; | ||||
|         if (!curve.IsNamed || !string.Equals(curve.Oid.Value, expected.Oid.Value, StringComparison.Ordinal)) | ||||
|         { | ||||
|             throw new InvalidOperationException("ES256 signing keys must use the NIST P-256 curve."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										82
									
								
								src/StellaOps.Cryptography/EcdsaSigner.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								src/StellaOps.Cryptography/EcdsaSigner.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| using System; | ||||
| using System.Security.Cryptography; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| internal sealed class EcdsaSigner : ICryptoSigner | ||||
| { | ||||
|     private static readonly string[] DefaultKeyOps = { "sign", "verify" }; | ||||
|     private readonly CryptoSigningKey signingKey; | ||||
|  | ||||
|     private EcdsaSigner(CryptoSigningKey signingKey) | ||||
|         => this.signingKey = signingKey ?? throw new ArgumentNullException(nameof(signingKey)); | ||||
|  | ||||
|     public string KeyId => signingKey.Reference.KeyId; | ||||
|  | ||||
|     public string AlgorithmId => signingKey.AlgorithmId; | ||||
|  | ||||
|     public static ICryptoSigner Create(CryptoSigningKey signingKey) => new EcdsaSigner(signingKey); | ||||
|  | ||||
|     public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|         using var ecdsa = ECDsa.Create(signingKey.PrivateParameters); | ||||
|         var hashAlgorithm = ResolveHashAlgorithm(signingKey.AlgorithmId); | ||||
|         var signature = ecdsa.SignData(data.Span, hashAlgorithm); | ||||
|         return ValueTask.FromResult(signature); | ||||
|     } | ||||
|  | ||||
|     public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default) | ||||
|     { | ||||
|         cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|         using var ecdsa = ECDsa.Create(signingKey.PublicParameters); | ||||
|         var hashAlgorithm = ResolveHashAlgorithm(signingKey.AlgorithmId); | ||||
|         var verified = ecdsa.VerifyData(data.Span, signature.Span, hashAlgorithm); | ||||
|         return ValueTask.FromResult(verified); | ||||
|     } | ||||
|  | ||||
|     public JsonWebKey ExportPublicJsonWebKey() | ||||
|     { | ||||
|         var jwk = new JsonWebKey | ||||
|         { | ||||
|             Kid = signingKey.Reference.KeyId, | ||||
|             Alg = signingKey.AlgorithmId, | ||||
|             Kty = JsonWebAlgorithmsKeyTypes.EllipticCurve, | ||||
|             Use = JsonWebKeyUseNames.Sig, | ||||
|             Crv = ResolveCurve(signingKey.AlgorithmId) | ||||
|         }; | ||||
|  | ||||
|         foreach (var op in DefaultKeyOps) | ||||
|         { | ||||
|             jwk.KeyOps.Add(op); | ||||
|         } | ||||
|  | ||||
|         jwk.X = Base64UrlEncoder.Encode(signingKey.PublicParameters.Q.X ?? Array.Empty<byte>()); | ||||
|         jwk.Y = Base64UrlEncoder.Encode(signingKey.PublicParameters.Q.Y ?? Array.Empty<byte>()); | ||||
|  | ||||
|         return jwk; | ||||
|     } | ||||
|  | ||||
|     private static HashAlgorithmName ResolveHashAlgorithm(string algorithmId) => | ||||
|         algorithmId switch | ||||
|         { | ||||
|             { } alg when string.Equals(alg, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA256, | ||||
|             { } alg when string.Equals(alg, SignatureAlgorithms.Es384, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA384, | ||||
|             { } alg when string.Equals(alg, SignatureAlgorithms.Es512, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA512, | ||||
|             _ => throw new InvalidOperationException($"Unsupported ECDSA signing algorithm '{algorithmId}'.") | ||||
|         }; | ||||
|  | ||||
|     private static string ResolveCurve(string algorithmId) | ||||
|         => algorithmId switch | ||||
|         { | ||||
|             { } alg when string.Equals(alg, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase) => JsonWebKeyECTypes.P256, | ||||
|             { } alg when string.Equals(alg, SignatureAlgorithms.Es384, StringComparison.OrdinalIgnoreCase) => JsonWebKeyECTypes.P384, | ||||
|             { } alg when string.Equals(alg, SignatureAlgorithms.Es512, StringComparison.OrdinalIgnoreCase) => JsonWebKeyECTypes.P521, | ||||
|             _ => throw new InvalidOperationException($"Unsupported ECDSA curve mapping for algorithm '{algorithmId}'.") | ||||
|         }; | ||||
| } | ||||
							
								
								
									
										45
									
								
								src/StellaOps.Cryptography/ICryptoSigner.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/StellaOps.Cryptography/ICryptoSigner.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| using System; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents an asymmetric signer capable of producing and verifying detached signatures. | ||||
| /// </summary> | ||||
| public interface ICryptoSigner | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Gets the key identifier associated with this signer. | ||||
|     /// </summary> | ||||
|     string KeyId { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets the signing algorithm identifier (e.g., ES256). | ||||
|     /// </summary> | ||||
|     string AlgorithmId { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Signs the supplied payload bytes. | ||||
|     /// </summary> | ||||
|     /// <param name="data">Payload to sign.</param> | ||||
|     /// <param name="cancellationToken">Cancellation token.</param> | ||||
|     /// <returns>Signature bytes.</returns> | ||||
|     ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Verifies a previously produced signature over the supplied payload bytes. | ||||
|     /// </summary> | ||||
|     /// <param name="data">Payload that was signed.</param> | ||||
|     /// <param name="signature">Signature to verify.</param> | ||||
|     /// <param name="cancellationToken">Cancellation token.</param> | ||||
|     /// <returns><c>true</c> when the signature is valid; otherwise <c>false</c>.</returns> | ||||
|     ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Exports the public representation of the key material as a JSON Web Key (JWK). | ||||
|     /// </summary> | ||||
|     /// <returns>Public JWK for distribution (no private components).</returns> | ||||
|     JsonWebKey ExportPublicJsonWebKey(); | ||||
| } | ||||
							
								
								
									
										23
									
								
								src/StellaOps.Cryptography/PasswordHashAlgorithms.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/StellaOps.Cryptography/PasswordHashAlgorithms.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| using System; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Well-known identifiers for password hashing algorithms supported by StellaOps. | ||||
| /// </summary> | ||||
| public static class PasswordHashAlgorithms | ||||
| { | ||||
|     public const string Argon2id = "argon2id"; | ||||
|     public const string Pbkdf2Sha256 = "pbkdf2-sha256"; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Converts the enum value into the canonical algorithm identifier string. | ||||
|     /// </summary> | ||||
|     public static string ToAlgorithmId(this PasswordHashAlgorithm algorithm) => | ||||
|         algorithm switch | ||||
|         { | ||||
|             PasswordHashAlgorithm.Argon2id => Argon2id, | ||||
|             PasswordHashAlgorithm.Pbkdf2 => Pbkdf2Sha256, | ||||
|             _ => throw new ArgumentOutOfRangeException(nameof(algorithm), algorithm, "Unsupported password hash algorithm.") | ||||
|         }; | ||||
| } | ||||
							
								
								
									
										137
									
								
								src/StellaOps.Cryptography/Pbkdf2PasswordHasher.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								src/StellaOps.Cryptography/Pbkdf2PasswordHasher.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,137 @@ | ||||
| using System; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
|  | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// PBKDF2-SHA256 password hasher for legacy credentials. | ||||
| /// </summary> | ||||
| public sealed class Pbkdf2PasswordHasher : IPasswordHasher | ||||
| { | ||||
|     private const int SaltLengthBytes = 16; | ||||
|     private const int HashLengthBytes = 32; | ||||
|     private const string Prefix = "PBKDF2"; | ||||
|  | ||||
|     public string Hash(string password, PasswordHashOptions options) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrEmpty(password); | ||||
|         ArgumentNullException.ThrowIfNull(options); | ||||
|  | ||||
|         if (options.Algorithm != PasswordHashAlgorithm.Pbkdf2) | ||||
|         { | ||||
|             throw new InvalidOperationException("Pbkdf2PasswordHasher only supports the PBKDF2 algorithm."); | ||||
|         } | ||||
|  | ||||
|         if (options.Iterations <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException("PBKDF2 requires a positive iteration count."); | ||||
|         } | ||||
|  | ||||
|         Span<byte> salt = stackalloc byte[SaltLengthBytes]; | ||||
|         RandomNumberGenerator.Fill(salt); | ||||
|  | ||||
|         var hash = Derive(password, salt, options.Iterations); | ||||
|  | ||||
|         var payload = new byte[1 + SaltLengthBytes + HashLengthBytes]; | ||||
|         payload[0] = 0x01; | ||||
|         salt.CopyTo(payload.AsSpan(1)); | ||||
|         hash.CopyTo(payload.AsSpan(1 + SaltLengthBytes)); | ||||
|  | ||||
|         return $"{Prefix}.{options.Iterations}.{Convert.ToBase64String(payload)}"; | ||||
|     } | ||||
|  | ||||
|     public bool Verify(string password, string encodedHash) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrEmpty(password); | ||||
|         ArgumentException.ThrowIfNullOrEmpty(encodedHash); | ||||
|  | ||||
|         if (!TryParse(encodedHash, out var parsed)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var computed = Derive(password, parsed.Salt, parsed.Iterations); | ||||
|         return CryptographicOperations.FixedTimeEquals(parsed.Hash, computed); | ||||
|     } | ||||
|  | ||||
|     public bool NeedsRehash(string encodedHash, PasswordHashOptions desired) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(desired); | ||||
|  | ||||
|         if (!TryParse(encodedHash, out var parsed)) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (desired.Algorithm != PasswordHashAlgorithm.Pbkdf2) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return parsed.Iterations != desired.Iterations; | ||||
|     } | ||||
|  | ||||
|     private static byte[] Derive(string password, ReadOnlySpan<byte> salt, int iterations) | ||||
|     { | ||||
|         return Rfc2898DeriveBytes.Pbkdf2( | ||||
|             Encoding.UTF8.GetBytes(password), | ||||
|             salt.ToArray(), | ||||
|             iterations, | ||||
|             HashAlgorithmName.SHA256, | ||||
|             HashLengthBytes); | ||||
|     } | ||||
|  | ||||
|     private static bool TryParse(string encodedHash, out Pbkdf2Parameters parsed) | ||||
|     { | ||||
|         parsed = default; | ||||
|  | ||||
|         var parts = encodedHash.Split('.', StringSplitOptions.RemoveEmptyEntries); | ||||
|         if (parts.Length != 3 || !string.Equals(parts[0], Prefix, StringComparison.Ordinal)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (!int.TryParse(parts[1], out var iterations) || iterations <= 0) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         byte[] payload; | ||||
|         try | ||||
|         { | ||||
|             payload = Convert.FromBase64String(parts[2]); | ||||
|         } | ||||
|         catch (FormatException) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if (payload.Length != 1 + SaltLengthBytes + HashLengthBytes || payload[0] != 0x01) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var salt = new byte[SaltLengthBytes]; | ||||
|         var hash = new byte[HashLengthBytes]; | ||||
|         Array.Copy(payload, 1, salt, 0, SaltLengthBytes); | ||||
|         Array.Copy(payload, 1 + SaltLengthBytes, hash, 0, HashLengthBytes); | ||||
|  | ||||
|         parsed = new Pbkdf2Parameters(iterations, salt, hash); | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private readonly struct Pbkdf2Parameters | ||||
|     { | ||||
|         public Pbkdf2Parameters(int iterations, byte[] salt, byte[] hash) | ||||
|         { | ||||
|             Iterations = iterations; | ||||
|             Salt = salt; | ||||
|             Hash = hash; | ||||
|         } | ||||
|  | ||||
|         public int Iterations { get; } | ||||
|         public byte[] Salt { get; } | ||||
|         public byte[] Hash { get; } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										11
									
								
								src/StellaOps.Cryptography/SignatureAlgorithms.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/StellaOps.Cryptography/SignatureAlgorithms.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| namespace StellaOps.Cryptography; | ||||
|  | ||||
| /// <summary> | ||||
| /// Known signature algorithm identifiers. | ||||
| /// </summary> | ||||
| public static class SignatureAlgorithms | ||||
| { | ||||
|     public const string Es256 = "ES256"; | ||||
|     public const string Es384 = "ES384"; | ||||
|     public const string Es512 = "ES512"; | ||||
| } | ||||
| @@ -6,4 +6,11 @@ | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <PropertyGroup Condition="'$(StellaOpsCryptoSodium)' == 'true'"> | ||||
|     <DefineConstants>$(DefineConstants);STELLAOPS_CRYPTO_SODIUM</DefineConstants> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" /> | ||||
|     <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.5.1" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
|   | ||||
| @@ -2,16 +2,23 @@ | ||||
|  | ||||
| | ID | Status | Owner | Description | Dependencies | Exit Criteria | | ||||
| |----|--------|-------|-------------|--------------|---------------| | ||||
| | SEC1.A | TODO | Security Guild | Introduce `Argon2idPasswordHasher` backed by Konscious defaults. Wire options into `StandardPluginOptions` (`PasswordHashOptions`) and `StellaOpsAuthorityOptions.Security.PasswordHashing`. | PLG3, CORE3 | ✅ Hashes emit PHC string `$argon2id$v=19$m=19456,t=2,p=1$...`; ✅ `NeedsRehash` promotes PBKDF2 → Argon2; ✅ Integration tests cover tamper, legacy rehash, perf p95 < 250 ms. | | ||||
| | SEC1.B | TODO | Security Guild | Add compile-time switch to enable libsodium/Core variants later (`STELLAOPS_CRYPTO_SODIUM`). Document build variable. | SEC1.A | ✅ Conditional compilation path compiles; ✅ README snippet in `docs/security/password-hashing.md`. | | ||||
| | SEC1.A | DONE (2025-10-11) | Security Guild | Introduce `Argon2idPasswordHasher` backed by Konscious defaults. Wire options into `StandardPluginOptions` (`PasswordHashOptions`) and `StellaOpsAuthorityOptions.Security.PasswordHashing`. | PLG3, CORE3 | ✅ Hashes emit PHC string `$argon2id$v=19$m=19456,t=2,p=1$...`; ✅ `NeedsRehash` promotes PBKDF2 → Argon2; ✅ Integration tests cover tamper, legacy rehash, perf p95 < 250 ms. | | ||||
| | SEC1.B | DONE (2025-10-12) | Security Guild | Add compile-time switch to enable libsodium/Core variants later (`STELLAOPS_CRYPTO_SODIUM`). Document build variable. | SEC1.A | ✅ Conditional compilation path compiles; ✅ README snippet in `docs/security/password-hashing.md`. | | ||||
| | SEC2.A | TODO | Security Guild + Core | Define audit event contract (`AuthEventRecord`) with subject/client/scope/IP/outcome/correlationId and PII tags. | CORE5–CORE7 | ✅ Contract shipped in `StellaOps.Cryptography` (or shared abstractions); ✅ Docs in `docs/security/audit-events.md`. | | ||||
| | SEC2.B | TODO | Security Guild | Emit audit records from OpenIddict handlers (password + client creds) and bootstrap APIs. Persist via `IAuthorityLoginAttemptStore`. | SEC2.A | ✅ Tests assert three flows (success/failure/lockout); ✅ Serilog output contains correlationId + PII tagging; ✅ Mongo store holds summary rows. | | ||||
| | SEC3.A | BLOCKED (CORE8) | Security Guild + Core | Configure ASP.NET rate limiter (`AddRateLimiter`) with fixed-window policy keyed by IP + `client_id`. Apply to `/token` and `/internal/*`. | CORE8 completion | ✅ Middleware active; ✅ Configurable limits via options; ✅ Integration test hits 429. | | ||||
| | SEC3.B | TODO | Security Guild | Document lockout + rate-limit tuning guidance and escalation thresholds. | SEC3.A | ✅ Section in `docs/security/rate-limits.md`; ✅ Includes SOC alert recommendations. | | ||||
| | SEC4.A | TODO | Security Guild + DevOps | Define revocation JSON schema (`revocation_bundle.schema.json`) and detached JWS workflow. | CORE9, OPS3 | ✅ Schema + sample committed; ✅ CLI command `stellaops auth revoke export` scaffolded with acceptance tests; ✅ Verification script + docs. | | ||||
| | SEC4.B | TODO | Security Guild | Integrate signing keys with crypto provider abstraction (initially ES256 via BCL). | SEC4.A, D5 | ✅ `ICryptoProvider.GetSigner` stub + default BCL signer; ✅ Unit tests verifying signature roundtrip. | | ||||
| | SEC5.A | TODO | Security Guild | Author STRIDE threat model (`docs/security/authority-threat-model.md`) covering token, bootstrap, revocation, CLI, plugin surfaces. | All SEC1–SEC4 in progress | ✅ DFDs + trust boundaries drawn; ✅ Risk table with owners/actions; ✅ Follow-up backlog issues created. | | ||||
| | D5.A | TODO | Security Guild | Flesh out `StellaOps.Cryptography` provider registry, policy, and DI helpers enabling sovereign crypto selection. | SEC1.A, SEC4.B | ✅ `ICryptoProviderRegistry` implementation with provider selection rules; ✅ `StellaOps.Cryptography.DependencyInjection` extensions; ✅ Tests covering fallback ordering. | | ||||
| | SEC4.A | DONE (2025-10-12) | Security Guild + DevOps | Define revocation JSON schema (`revocation_bundle.schema.json`) and detached JWS workflow. | CORE9, OPS3 | ✅ Schema + sample committed; ✅ CLI command `stellaops auth revoke export` scaffolded with acceptance tests; ✅ Verification script + docs. | | ||||
| | SEC4.B | DONE (2025-10-12) | Security Guild | Integrate signing keys with crypto provider abstraction (initially ES256 via BCL). | SEC4.A, D5 | ✅ `ICryptoProvider.GetSigner` stub + default BCL signer; ✅ Unit tests verifying signature roundtrip. | | ||||
| | SEC5.A | DONE (2025-10-12) | Security Guild | Author STRIDE threat model (`docs/security/authority-threat-model.md`) covering token, bootstrap, revocation, CLI, plugin surfaces. | All SEC1–SEC4 in progress | ✅ DFDs + trust boundaries drawn; ✅ Risk table with owners/actions; ✅ Follow-up backlog issues created. | | ||||
| | SEC5.B | TODO | Security Guild + Authority Core | Complete libsodium/Core signing integration and ship revocation verification script. | SEC4.A, SEC4.B, SEC4.HOST | ✅ libsodium/Core signing provider wired; ✅ `stellaops auth revoke verify` script published; ✅ Revocation docs updated with verification workflow. | | ||||
| | SEC5.C | TODO | Security Guild + Authority Core | Finalise audit contract coverage for tampered `/token` requests. | SEC2.A, SEC2.B | ✅ Tamper attempts logged with correlationId/PII tags; ✅ SOC runbook updated; ✅ Threat model status reviewed. | | ||||
| | SEC5.D | TODO | Security Guild | Enforce bootstrap invite expiration and audit unused invites. | SEC5.A | ✅ Bootstrap tokens auto-expire; ✅ Audit entries emitted for expiration/reuse attempts; ✅ Operator docs updated. | | ||||
| | SEC5.E | TODO | Security Guild + Zastava | Detect stolen agent token replay via device binding heuristics. | SEC4.A | ✅ Device binding guidance published; ✅ Alerting pipeline raises stale revocation acknowledgements; ✅ Tests cover replay detection. | | ||||
| | SEC5.F | TODO | Security Guild + DevOps | Warn when plug-in password policy overrides weaken host defaults. | SEC1.A, PLG3 | ✅ Static analyser flags weaker overrides; ✅ Runtime warning surfaced; ✅ Docs call out mitigation. | | ||||
| | SEC5.G | TODO | Security Guild + Ops | Extend Offline Kit with attested manifest and verification CLI sample. | OPS3 | ✅ Offline Kit build signs manifest with detached JWS; ✅ Verification CLI documented; ✅ Supply-chain attestation recorded. | | ||||
| | SEC5.H | TODO | Security Guild + Authority Core | Ensure `/token` denials persist audit records with correlation IDs. | SEC2.A, SEC2.B | ✅ Audit store captures denials; ✅ Tests cover success/failure/lockout; ✅ Threat model review updated. | | ||||
| | D5.A | DONE (2025-10-12) | Security Guild | Flesh out `StellaOps.Cryptography` provider registry, policy, and DI helpers enabling sovereign crypto selection. | SEC1.A, SEC4.B | ✅ `ICryptoProviderRegistry` implementation with provider selection rules; ✅ `StellaOps.Cryptography.DependencyInjection` extensions; ✅ Tests covering fallback ordering. | | ||||
|  | ||||
| ## Notes | ||||
| - Target Argon2 parameters follow OWASP Cheat Sheet (memory ≈ 19 MiB, iterations 2, parallelism 1). Allow overrides via configuration. | ||||
|   | ||||
		Reference in New Issue
	
	Block a user