up
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