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

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

View File

@@ -0,0 +1,28 @@
#if !STELLAOPS_CRYPTO_SODIUM
using System;
using System.Text;
using Konscious.Security.Cryptography;
namespace StellaOps.Cryptography;
/// <summary>
/// Managed Argon2id implementation powered by Konscious.Security.Cryptography.
/// </summary>
public sealed partial class Argon2idPasswordHasher
{
private static partial byte[] DeriveHashCore(string password, ReadOnlySpan<byte> salt, PasswordHashOptions options)
{
var passwordBytes = Encoding.UTF8.GetBytes(password);
using var argon2 = new Argon2id(passwordBytes)
{
Salt = salt.ToArray(),
DegreeOfParallelism = options.Parallelism,
Iterations = options.Iterations,
MemorySize = options.MemorySizeInKib
};
return argon2.GetBytes(HashLengthBytes);
}
}
#endif

View File

@@ -0,0 +1,30 @@
#if STELLAOPS_CRYPTO_SODIUM
using System;
using System.Text;
using Konscious.Security.Cryptography;
namespace StellaOps.Cryptography;
/// <summary>
/// Placeholder for libsodium-backed Argon2id implementation.
/// Falls back to the managed Konscious variant until native bindings land.
/// </summary>
public sealed partial class Argon2idPasswordHasher
{
private static partial byte[] DeriveHashCore(string password, ReadOnlySpan<byte> salt, PasswordHashOptions options)
{
// TODO(SEC1.B follow-up): replace with libsodium/core bindings and managed pinning logic.
var passwordBytes = Encoding.UTF8.GetBytes(password);
using var argon2 = new Argon2id(passwordBytes)
{
Salt = salt.ToArray(),
DegreeOfParallelism = options.Parallelism,
Iterations = options.Iterations,
MemorySize = options.MemorySizeInKib
};
return argon2.GetBytes(HashLengthBytes);
}
}
#endif

View File

@@ -0,0 +1,173 @@
using System;
using System.Globalization;
using System.Security.Cryptography;
namespace StellaOps.Cryptography;
/// <summary>
/// Argon2id password hasher that emits PHC-compliant encoded strings.
/// </summary>
public sealed partial class Argon2idPasswordHasher : IPasswordHasher
{
private const int SaltLengthBytes = 16;
private const int HashLengthBytes = 32;
public string Hash(string password, PasswordHashOptions options)
{
ArgumentException.ThrowIfNullOrEmpty(password);
ArgumentNullException.ThrowIfNull(options);
options.Validate();
if (options.Algorithm != PasswordHashAlgorithm.Argon2id)
{
throw new InvalidOperationException("Argon2idPasswordHasher only supports the Argon2id algorithm.");
}
Span<byte> salt = stackalloc byte[SaltLengthBytes];
RandomNumberGenerator.Fill(salt);
var hash = DeriveHash(password, salt, options);
return BuildEncodedHash(salt, hash, options);
}
public bool Verify(string password, string encodedHash)
{
ArgumentException.ThrowIfNullOrEmpty(password);
ArgumentException.ThrowIfNullOrEmpty(encodedHash);
if (!TryParse(encodedHash, out var parsed))
{
return false;
}
var computed = DeriveHash(password, parsed.Salt, parsed.Options);
return CryptographicOperations.FixedTimeEquals(computed, parsed.Hash);
}
public bool NeedsRehash(string encodedHash, PasswordHashOptions desired)
{
ArgumentNullException.ThrowIfNull(desired);
if (!TryParse(encodedHash, out var parsed))
{
return true;
}
if (desired.Algorithm != PasswordHashAlgorithm.Argon2id)
{
return true;
}
if (!parsed.Options.Algorithm.Equals(desired.Algorithm))
{
return true;
}
return parsed.Options.MemorySizeInKib != desired.MemorySizeInKib
|| parsed.Options.Iterations != desired.Iterations
|| parsed.Options.Parallelism != desired.Parallelism;
}
private static byte[] DeriveHash(string password, ReadOnlySpan<byte> salt, PasswordHashOptions options)
=> DeriveHashCore(password, salt, options);
private static partial byte[] DeriveHashCore(string password, ReadOnlySpan<byte> salt, PasswordHashOptions options);
private static string BuildEncodedHash(ReadOnlySpan<byte> salt, ReadOnlySpan<byte> hash, PasswordHashOptions options)
{
var saltEncoded = Convert.ToBase64String(salt);
var hashEncoded = Convert.ToBase64String(hash);
return $"$argon2id$v=19$m={options.MemorySizeInKib},t={options.Iterations},p={options.Parallelism}${saltEncoded}${hashEncoded}";
}
private static bool TryParse(string encodedHash, out Argon2HashParameters parsed)
{
parsed = default;
if (!encodedHash.StartsWith("$argon2id$", StringComparison.Ordinal))
{
return false;
}
var segments = encodedHash.Split('$', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length != 5)
{
return false;
}
// segments: 0=argon2id, 1=v=19, 2=m=...,t=...,p=..., 3=salt, 4=hash
if (!segments[1].StartsWith("v=19", StringComparison.Ordinal))
{
return false;
}
var parameterParts = segments[2].Split(',', StringSplitOptions.RemoveEmptyEntries);
if (parameterParts.Length != 3)
{
return false;
}
if (!TryParseInt(parameterParts[0], "m", out var memory) ||
!TryParseInt(parameterParts[1], "t", out var iterations) ||
!TryParseInt(parameterParts[2], "p", out var parallelism))
{
return false;
}
byte[] saltBytes;
byte[] hashBytes;
try
{
saltBytes = Convert.FromBase64String(segments[3]);
hashBytes = Convert.FromBase64String(segments[4]);
}
catch (FormatException)
{
return false;
}
if (saltBytes.Length != SaltLengthBytes || hashBytes.Length != HashLengthBytes)
{
return false;
}
var options = new PasswordHashOptions
{
Algorithm = PasswordHashAlgorithm.Argon2id,
MemorySizeInKib = memory,
Iterations = iterations,
Parallelism = parallelism
};
parsed = new Argon2HashParameters(options, saltBytes, hashBytes);
return true;
}
private static bool TryParseInt(string component, string key, out int value)
{
value = 0;
if (!component.StartsWith(key + "=", StringComparison.Ordinal))
{
return false;
}
return int.TryParse(component.AsSpan(key.Length + 1), NumberStyles.None, CultureInfo.InvariantCulture, out value);
}
private readonly struct Argon2HashParameters
{
public Argon2HashParameters(PasswordHashOptions options, byte[] salt, byte[] hash)
{
Options = options;
Salt = salt;
Hash = hash;
}
public PasswordHashOptions Options { get; }
public byte[] Salt { get; }
public byte[] Hash { get; }
}
}

View File

@@ -0,0 +1,258 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cryptography.Audit;
/// <summary>
/// Represents a structured security event emitted by the Authority host and plugins.
/// </summary>
public sealed record AuthEventRecord
{
/// <summary>
/// Canonical event identifier (e.g. <c>authority.password.grant</c>).
/// </summary>
public required string EventType { get; init; }
/// <summary>
/// UTC timestamp captured when the event occurred.
/// </summary>
public DateTimeOffset OccurredAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Stable correlation identifier that links the event across logs, traces, and persistence.
/// </summary>
public string? CorrelationId { get; init; }
/// <summary>
/// Outcome classification for the audited operation.
/// </summary>
public AuthEventOutcome Outcome { get; init; } = AuthEventOutcome.Unknown;
/// <summary>
/// Optional human-readable reason or failure descriptor.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Identity of the end-user (subject) involved in the event, when applicable.
/// </summary>
public AuthEventSubject? Subject { get; init; }
/// <summary>
/// OAuth/OIDC client metadata associated with the event, when applicable.
/// </summary>
public AuthEventClient? Client { get; init; }
/// <summary>
/// Granted or requested scopes tied to the event.
/// </summary>
public IReadOnlyList<string> Scopes { get; init; } = Array.Empty<string>();
/// <summary>
/// Network attributes (remote IP, forwarded headers, user agent) captured for the request.
/// </summary>
public AuthEventNetwork? Network { get; init; }
/// <summary>
/// Additional classified properties carried with the event.
/// </summary>
public IReadOnlyList<AuthEventProperty> Properties { get; init; } = Array.Empty<AuthEventProperty>();
}
/// <summary>
/// Describes the outcome of an audited flow.
/// </summary>
public enum AuthEventOutcome
{
/// <summary>
/// Outcome has not been set.
/// </summary>
Unknown = 0,
/// <summary>
/// Operation succeeded.
/// </summary>
Success,
/// <summary>
/// Operation failed (invalid credentials, configuration issues, etc.).
/// </summary>
Failure,
/// <summary>
/// Operation failed due to a lockout policy.
/// </summary>
LockedOut,
/// <summary>
/// Operation was rejected due to rate limiting or throttling.
/// </summary>
RateLimited,
/// <summary>
/// Operation encountered an unexpected error.
/// </summary>
Error
}
/// <summary>
/// Represents a string value enriched with a data classification tag.
/// </summary>
public readonly record struct ClassifiedString(string? Value, AuthEventDataClassification Classification)
{
/// <summary>
/// An empty classified string.
/// </summary>
public static ClassifiedString Empty => new(null, AuthEventDataClassification.None);
/// <summary>
/// Indicates whether the classified string carries a non-empty value.
/// </summary>
public bool HasValue => !string.IsNullOrWhiteSpace(Value);
/// <summary>
/// Creates a classified string for public/non-sensitive data.
/// </summary>
public static ClassifiedString Public(string? value) => Create(value, AuthEventDataClassification.None);
/// <summary>
/// Creates a classified string tagged as personally identifiable information (PII).
/// </summary>
public static ClassifiedString Personal(string? value) => Create(value, AuthEventDataClassification.Personal);
/// <summary>
/// Creates a classified string tagged as sensitive (e.g. credentials, secrets).
/// </summary>
public static ClassifiedString Sensitive(string? value) => Create(value, AuthEventDataClassification.Sensitive);
private static ClassifiedString Create(string? value, AuthEventDataClassification classification)
{
return new ClassifiedString(Normalize(value), classification);
}
private static string? Normalize(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value;
}
}
/// <summary>
/// Supported classifications for audit data values.
/// </summary>
public enum AuthEventDataClassification
{
/// <summary>
/// Data is not considered sensitive.
/// </summary>
None = 0,
/// <summary>
/// Personally identifiable information (PII) that warrants redaction in certain sinks.
/// </summary>
Personal,
/// <summary>
/// Highly sensitive information (credentials, secrets, tokens).
/// </summary>
Sensitive
}
/// <summary>
/// Captures subject metadata for an audit event.
/// </summary>
public sealed record AuthEventSubject
{
/// <summary>
/// Stable subject identifier (PII).
/// </summary>
public ClassifiedString SubjectId { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Username or login name (PII).
/// </summary>
public ClassifiedString Username { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Optional display name (PII).
/// </summary>
public ClassifiedString DisplayName { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Optional plugin or tenant realm controlling the subject namespace.
/// </summary>
public ClassifiedString Realm { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Additional classified attributes (e.g. email, phone).
/// </summary>
public IReadOnlyList<AuthEventProperty> Attributes { get; init; } = Array.Empty<AuthEventProperty>();
}
/// <summary>
/// Captures OAuth/OIDC client metadata for an audit event.
/// </summary>
public sealed record AuthEventClient
{
/// <summary>
/// Client identifier (PII for confidential clients).
/// </summary>
public ClassifiedString ClientId { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Friendly client name (may be public).
/// </summary>
public ClassifiedString Name { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Identity provider/plugin originating the client.
/// </summary>
public ClassifiedString Provider { get; init; } = ClassifiedString.Empty;
}
/// <summary>
/// Captures network metadata for an audit event.
/// </summary>
public sealed record AuthEventNetwork
{
/// <summary>
/// Remote address observed for the request (PII).
/// </summary>
public ClassifiedString RemoteAddress { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Forwarded address supplied by proxies (PII).
/// </summary>
public ClassifiedString ForwardedFor { get; init; } = ClassifiedString.Empty;
/// <summary>
/// User agent string associated with the request.
/// </summary>
public ClassifiedString UserAgent { get; init; } = ClassifiedString.Empty;
}
/// <summary>
/// Represents an additional classified property associated with the audit event.
/// </summary>
public sealed record AuthEventProperty
{
/// <summary>
/// Property name (canonical snake-case identifier).
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Classified value assigned to the property.
/// </summary>
public ClassifiedString Value { get; init; } = ClassifiedString.Empty;
}
/// <summary>
/// Sink that receives completed audit event records.
/// </summary>
public interface IAuthEventSink
{
/// <summary>
/// Persists the supplied audit event.
/// </summary>
ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken);
}

View File

@@ -29,6 +29,32 @@ public interface ICryptoProvider
bool Supports(CryptoCapability capability, string algorithmId);
IPasswordHasher GetPasswordHasher(string algorithmId);
/// <summary>
/// Retrieves a signer for the supplied algorithm and key reference.
/// </summary>
/// <param name="algorithmId">Signing algorithm identifier (e.g., ES256).</param>
/// <param name="keyReference">Key reference.</param>
/// <returns>Signer instance.</returns>
ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference);
/// <summary>
/// Adds or replaces signing key material managed by this provider.
/// </summary>
/// <param name="signingKey">Key material descriptor.</param>
void UpsertSigningKey(CryptoSigningKey signingKey);
/// <summary>
/// Removes signing key material by key identifier.
/// </summary>
/// <param name="keyId">Identifier to remove.</param>
/// <returns><c>true</c> if the key was removed.</returns>
bool RemoveSigningKey(string keyId);
/// <summary>
/// Lists signing key descriptors managed by this provider.
/// </summary>
IReadOnlyCollection<CryptoSigningKey> GetSigningKeys();
}
/// <summary>
@@ -41,4 +67,18 @@ public interface ICryptoProviderRegistry
bool TryResolve(string preferredProvider, out ICryptoProvider provider);
ICryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId);
/// <summary>
/// Resolves a signer for the supplied algorithm and key reference using registry policy.
/// </summary>
/// <param name="capability">Capability required (typically <see cref="CryptoCapability.Signing"/>).</param>
/// <param name="algorithmId">Algorithm identifier.</param>
/// <param name="keyReference">Key reference.</param>
/// <param name="preferredProvider">Optional provider hint.</param>
/// <returns>Resolved signer.</returns>
ICryptoSigner ResolveSigner(
CryptoCapability capability,
string algorithmId,
CryptoKeyReference keyReference,
string? preferredProvider = null);
}

View File

@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
namespace StellaOps.Cryptography;
/// <summary>
/// Default implementation of <see cref="ICryptoProviderRegistry"/> with deterministic provider ordering.
/// </summary>
public sealed class CryptoProviderRegistry : ICryptoProviderRegistry
{
private readonly ReadOnlyCollection<ICryptoProvider> providers;
private readonly IReadOnlyDictionary<string, ICryptoProvider> providersByName;
private readonly IReadOnlyList<string> preferredOrder;
private readonly HashSet<string> preferredOrderSet;
public CryptoProviderRegistry(
IEnumerable<ICryptoProvider> providers,
IEnumerable<string>? preferredProviderOrder = null)
{
if (providers is null)
{
throw new ArgumentNullException(nameof(providers));
}
var providerList = providers.ToList();
if (providerList.Count == 0)
{
throw new ArgumentException("At least one crypto provider must be registered.", nameof(providers));
}
providersByName = providerList.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase);
this.providers = new ReadOnlyCollection<ICryptoProvider>(providerList);
preferredOrder = preferredProviderOrder?
.Where(name => providersByName.ContainsKey(name))
.Select(name => providersByName[name].Name)
.ToArray() ?? Array.Empty<string>();
preferredOrderSet = new HashSet<string>(preferredOrder, StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyCollection<ICryptoProvider> Providers => providers;
public bool TryResolve(string preferredProvider, out ICryptoProvider provider)
{
if (string.IsNullOrWhiteSpace(preferredProvider))
{
provider = default!;
return false;
}
return providersByName.TryGetValue(preferredProvider, out provider!);
}
public ICryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId)
{
if (string.IsNullOrWhiteSpace(algorithmId))
{
throw new ArgumentException("Algorithm identifier is required.", nameof(algorithmId));
}
foreach (var provider in EnumerateCandidates())
{
if (provider.Supports(capability, algorithmId))
{
return provider;
}
}
throw new InvalidOperationException(
$"No crypto provider is registered for capability '{capability}' and algorithm '{algorithmId}'.");
}
public ICryptoSigner ResolveSigner(
CryptoCapability capability,
string algorithmId,
CryptoKeyReference keyReference,
string? preferredProvider = null)
{
if (!string.IsNullOrWhiteSpace(preferredProvider) &&
providersByName.TryGetValue(preferredProvider!, out var hinted))
{
if (!hinted.Supports(capability, algorithmId))
{
throw new InvalidOperationException(
$"Provider '{preferredProvider}' does not support capability '{capability}' and algorithm '{algorithmId}'.");
}
return hinted.GetSigner(algorithmId, keyReference);
}
var provider = ResolveOrThrow(capability, algorithmId);
return provider.GetSigner(algorithmId, keyReference);
}
private IEnumerable<ICryptoProvider> EnumerateCandidates()
{
foreach (var name in preferredOrder)
{
yield return providersByName[name];
}
foreach (var provider in providers)
{
if (!preferredOrderSet.Contains(provider.Name))
{
yield return provider;
}
}
}
}

View File

@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Security.Cryptography;
namespace StellaOps.Cryptography;
/// <summary>
/// Represents asymmetric signing key material managed by a crypto provider.
/// </summary>
public sealed class CryptoSigningKey
{
private static readonly ReadOnlyDictionary<string, string?> EmptyMetadata =
new(new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
public CryptoSigningKey(
CryptoKeyReference reference,
string algorithmId,
in ECParameters privateParameters,
DateTimeOffset createdAt,
DateTimeOffset? expiresAt = null,
IReadOnlyDictionary<string, string?>? metadata = null)
{
Reference = reference ?? throw new ArgumentNullException(nameof(reference));
if (string.IsNullOrWhiteSpace(algorithmId))
{
throw new ArgumentException("Algorithm identifier is required.", nameof(algorithmId));
}
if (privateParameters.D is null || privateParameters.D.Length == 0)
{
throw new ArgumentException("Private key parameters must include the scalar component.", nameof(privateParameters));
}
AlgorithmId = algorithmId;
CreatedAt = createdAt;
ExpiresAt = expiresAt;
PrivateParameters = CloneParameters(privateParameters, includePrivate: true);
PublicParameters = CloneParameters(privateParameters, includePrivate: false);
Metadata = metadata is null
? EmptyMetadata
: new ReadOnlyDictionary<string, string?>(metadata.ToDictionary(
static pair => pair.Key,
static pair => pair.Value,
StringComparer.OrdinalIgnoreCase));
}
/// <summary>
/// Gets the key reference (id + provider hint).
/// </summary>
public CryptoKeyReference Reference { get; }
/// <summary>
/// Gets the algorithm identifier (e.g., ES256).
/// </summary>
public string AlgorithmId { get; }
/// <summary>
/// Gets the private EC parameters (cloned).
/// </summary>
public ECParameters PrivateParameters { get; }
/// <summary>
/// Gets the public EC parameters (cloned, no private scalar).
/// </summary>
public ECParameters PublicParameters { get; }
/// <summary>
/// Gets the timestamp when the key was created/imported.
/// </summary>
public DateTimeOffset CreatedAt { get; }
/// <summary>
/// Gets the optional expiry timestamp for the key.
/// </summary>
public DateTimeOffset? ExpiresAt { get; }
/// <summary>
/// Gets arbitrary metadata entries associated with the key.
/// </summary>
public IReadOnlyDictionary<string, string?> Metadata { get; }
private static ECParameters CloneParameters(ECParameters source, bool includePrivate)
{
var clone = new ECParameters
{
Curve = source.Curve,
Q = new ECPoint
{
X = source.Q.X is null ? null : (byte[])source.Q.X.Clone(),
Y = source.Q.Y is null ? null : (byte[])source.Q.Y.Clone()
}
};
if (includePrivate && source.D is not null)
{
clone.D = (byte[])source.D.Clone();
}
return clone;
}
}

View File

@@ -0,0 +1,129 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
namespace StellaOps.Cryptography;
/// <summary>
/// Default in-process crypto provider exposing password hashing capabilities.
/// </summary>
public sealed class DefaultCryptoProvider : ICryptoProvider
{
private readonly ConcurrentDictionary<string, IPasswordHasher> passwordHashers;
private readonly ConcurrentDictionary<string, CryptoSigningKey> signingKeys;
private static readonly HashSet<string> SupportedSigningAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
SignatureAlgorithms.Es256
};
public DefaultCryptoProvider()
{
passwordHashers = new ConcurrentDictionary<string, IPasswordHasher>(StringComparer.OrdinalIgnoreCase);
signingKeys = new ConcurrentDictionary<string, CryptoSigningKey>(StringComparer.Ordinal);
var argon = new Argon2idPasswordHasher();
var pbkdf2 = new Pbkdf2PasswordHasher();
passwordHashers.TryAdd(PasswordHashAlgorithm.Argon2id.ToString(), argon);
passwordHashers.TryAdd(PasswordHashAlgorithms.Argon2id, argon);
passwordHashers.TryAdd(PasswordHashAlgorithm.Pbkdf2.ToString(), pbkdf2);
passwordHashers.TryAdd(PasswordHashAlgorithms.Pbkdf2Sha256, pbkdf2);
}
public string Name => "default";
public bool Supports(CryptoCapability capability, string algorithmId)
{
if (string.IsNullOrWhiteSpace(algorithmId))
{
return false;
}
return capability switch
{
CryptoCapability.PasswordHashing => passwordHashers.ContainsKey(algorithmId),
CryptoCapability.Signing or CryptoCapability.Verification => SupportedSigningAlgorithms.Contains(algorithmId),
_ => false
};
}
public IPasswordHasher GetPasswordHasher(string algorithmId)
{
if (!Supports(CryptoCapability.PasswordHashing, algorithmId))
{
throw new InvalidOperationException($"Password hashing algorithm '{algorithmId}' is not supported by provider '{Name}'.");
}
return passwordHashers[algorithmId];
}
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
{
ArgumentNullException.ThrowIfNull(keyReference);
if (!Supports(CryptoCapability.Signing, algorithmId))
{
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider '{Name}'.");
}
if (!signingKeys.TryGetValue(keyReference.KeyId, out var signingKey))
{
throw new KeyNotFoundException($"Signing key '{keyReference.KeyId}' is not registered with provider '{Name}'.");
}
if (!string.Equals(signingKey.AlgorithmId, algorithmId, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Signing key '{keyReference.KeyId}' is registered for algorithm '{signingKey.AlgorithmId}', not '{algorithmId}'.");
}
return EcdsaSigner.Create(signingKey);
}
public void UpsertSigningKey(CryptoSigningKey signingKey)
{
ArgumentNullException.ThrowIfNull(signingKey);
EnsureSigningSupported(signingKey.AlgorithmId);
ValidateSigningKey(signingKey);
signingKeys.AddOrUpdate(signingKey.Reference.KeyId, signingKey, (_, _) => signingKey);
}
public bool RemoveSigningKey(string keyId)
{
if (string.IsNullOrWhiteSpace(keyId))
{
return false;
}
return signingKeys.TryRemove(keyId, out _);
}
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys()
=> signingKeys.Values.ToArray();
private static void EnsureSigningSupported(string algorithmId)
{
if (!SupportedSigningAlgorithms.Contains(algorithmId))
{
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider 'default'.");
}
}
private static void ValidateSigningKey(CryptoSigningKey signingKey)
{
if (!string.Equals(signingKey.AlgorithmId, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Only ES256 signing keys are currently supported by provider 'default'.");
}
var expected = ECCurve.NamedCurves.nistP256;
var curve = signingKey.PrivateParameters.Curve;
if (!curve.IsNamed || !string.Equals(curve.Oid.Value, expected.Oid.Value, StringComparison.Ordinal))
{
throw new InvalidOperationException("ES256 signing keys must use the NIST P-256 curve.");
}
}
}

View File

@@ -0,0 +1,82 @@
using System;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Cryptography;
internal sealed class EcdsaSigner : ICryptoSigner
{
private static readonly string[] DefaultKeyOps = { "sign", "verify" };
private readonly CryptoSigningKey signingKey;
private EcdsaSigner(CryptoSigningKey signingKey)
=> this.signingKey = signingKey ?? throw new ArgumentNullException(nameof(signingKey));
public string KeyId => signingKey.Reference.KeyId;
public string AlgorithmId => signingKey.AlgorithmId;
public static ICryptoSigner Create(CryptoSigningKey signingKey) => new EcdsaSigner(signingKey);
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
using var ecdsa = ECDsa.Create(signingKey.PrivateParameters);
var hashAlgorithm = ResolveHashAlgorithm(signingKey.AlgorithmId);
var signature = ecdsa.SignData(data.Span, hashAlgorithm);
return ValueTask.FromResult(signature);
}
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
using var ecdsa = ECDsa.Create(signingKey.PublicParameters);
var hashAlgorithm = ResolveHashAlgorithm(signingKey.AlgorithmId);
var verified = ecdsa.VerifyData(data.Span, signature.Span, hashAlgorithm);
return ValueTask.FromResult(verified);
}
public JsonWebKey ExportPublicJsonWebKey()
{
var jwk = new JsonWebKey
{
Kid = signingKey.Reference.KeyId,
Alg = signingKey.AlgorithmId,
Kty = JsonWebAlgorithmsKeyTypes.EllipticCurve,
Use = JsonWebKeyUseNames.Sig,
Crv = ResolveCurve(signingKey.AlgorithmId)
};
foreach (var op in DefaultKeyOps)
{
jwk.KeyOps.Add(op);
}
jwk.X = Base64UrlEncoder.Encode(signingKey.PublicParameters.Q.X ?? Array.Empty<byte>());
jwk.Y = Base64UrlEncoder.Encode(signingKey.PublicParameters.Q.Y ?? Array.Empty<byte>());
return jwk;
}
private static HashAlgorithmName ResolveHashAlgorithm(string algorithmId) =>
algorithmId switch
{
{ } alg when string.Equals(alg, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA256,
{ } alg when string.Equals(alg, SignatureAlgorithms.Es384, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA384,
{ } alg when string.Equals(alg, SignatureAlgorithms.Es512, StringComparison.OrdinalIgnoreCase) => HashAlgorithmName.SHA512,
_ => throw new InvalidOperationException($"Unsupported ECDSA signing algorithm '{algorithmId}'.")
};
private static string ResolveCurve(string algorithmId)
=> algorithmId switch
{
{ } alg when string.Equals(alg, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase) => JsonWebKeyECTypes.P256,
{ } alg when string.Equals(alg, SignatureAlgorithms.Es384, StringComparison.OrdinalIgnoreCase) => JsonWebKeyECTypes.P384,
{ } alg when string.Equals(alg, SignatureAlgorithms.Es512, StringComparison.OrdinalIgnoreCase) => JsonWebKeyECTypes.P521,
_ => throw new InvalidOperationException($"Unsupported ECDSA curve mapping for algorithm '{algorithmId}'.")
};
}

View File

@@ -0,0 +1,45 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Cryptography;
/// <summary>
/// Represents an asymmetric signer capable of producing and verifying detached signatures.
/// </summary>
public interface ICryptoSigner
{
/// <summary>
/// Gets the key identifier associated with this signer.
/// </summary>
string KeyId { get; }
/// <summary>
/// Gets the signing algorithm identifier (e.g., ES256).
/// </summary>
string AlgorithmId { get; }
/// <summary>
/// Signs the supplied payload bytes.
/// </summary>
/// <param name="data">Payload to sign.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Signature bytes.</returns>
ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a previously produced signature over the supplied payload bytes.
/// </summary>
/// <param name="data">Payload that was signed.</param>
/// <param name="signature">Signature to verify.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns><c>true</c> when the signature is valid; otherwise <c>false</c>.</returns>
ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default);
/// <summary>
/// Exports the public representation of the key material as a JSON Web Key (JWK).
/// </summary>
/// <returns>Public JWK for distribution (no private components).</returns>
JsonWebKey ExportPublicJsonWebKey();
}

View File

@@ -0,0 +1,23 @@
using System;
namespace StellaOps.Cryptography;
/// <summary>
/// Well-known identifiers for password hashing algorithms supported by StellaOps.
/// </summary>
public static class PasswordHashAlgorithms
{
public const string Argon2id = "argon2id";
public const string Pbkdf2Sha256 = "pbkdf2-sha256";
/// <summary>
/// Converts the enum value into the canonical algorithm identifier string.
/// </summary>
public static string ToAlgorithmId(this PasswordHashAlgorithm algorithm) =>
algorithm switch
{
PasswordHashAlgorithm.Argon2id => Argon2id,
PasswordHashAlgorithm.Pbkdf2 => Pbkdf2Sha256,
_ => throw new ArgumentOutOfRangeException(nameof(algorithm), algorithm, "Unsupported password hash algorithm.")
};
}

View File

@@ -0,0 +1,137 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Cryptography;
/// <summary>
/// PBKDF2-SHA256 password hasher for legacy credentials.
/// </summary>
public sealed class Pbkdf2PasswordHasher : IPasswordHasher
{
private const int SaltLengthBytes = 16;
private const int HashLengthBytes = 32;
private const string Prefix = "PBKDF2";
public string Hash(string password, PasswordHashOptions options)
{
ArgumentException.ThrowIfNullOrEmpty(password);
ArgumentNullException.ThrowIfNull(options);
if (options.Algorithm != PasswordHashAlgorithm.Pbkdf2)
{
throw new InvalidOperationException("Pbkdf2PasswordHasher only supports the PBKDF2 algorithm.");
}
if (options.Iterations <= 0)
{
throw new InvalidOperationException("PBKDF2 requires a positive iteration count.");
}
Span<byte> salt = stackalloc byte[SaltLengthBytes];
RandomNumberGenerator.Fill(salt);
var hash = Derive(password, salt, options.Iterations);
var payload = new byte[1 + SaltLengthBytes + HashLengthBytes];
payload[0] = 0x01;
salt.CopyTo(payload.AsSpan(1));
hash.CopyTo(payload.AsSpan(1 + SaltLengthBytes));
return $"{Prefix}.{options.Iterations}.{Convert.ToBase64String(payload)}";
}
public bool Verify(string password, string encodedHash)
{
ArgumentException.ThrowIfNullOrEmpty(password);
ArgumentException.ThrowIfNullOrEmpty(encodedHash);
if (!TryParse(encodedHash, out var parsed))
{
return false;
}
var computed = Derive(password, parsed.Salt, parsed.Iterations);
return CryptographicOperations.FixedTimeEquals(parsed.Hash, computed);
}
public bool NeedsRehash(string encodedHash, PasswordHashOptions desired)
{
ArgumentNullException.ThrowIfNull(desired);
if (!TryParse(encodedHash, out var parsed))
{
return true;
}
if (desired.Algorithm != PasswordHashAlgorithm.Pbkdf2)
{
return true;
}
return parsed.Iterations != desired.Iterations;
}
private static byte[] Derive(string password, ReadOnlySpan<byte> salt, int iterations)
{
return Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password),
salt.ToArray(),
iterations,
HashAlgorithmName.SHA256,
HashLengthBytes);
}
private static bool TryParse(string encodedHash, out Pbkdf2Parameters parsed)
{
parsed = default;
var parts = encodedHash.Split('.', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 3 || !string.Equals(parts[0], Prefix, StringComparison.Ordinal))
{
return false;
}
if (!int.TryParse(parts[1], out var iterations) || iterations <= 0)
{
return false;
}
byte[] payload;
try
{
payload = Convert.FromBase64String(parts[2]);
}
catch (FormatException)
{
return false;
}
if (payload.Length != 1 + SaltLengthBytes + HashLengthBytes || payload[0] != 0x01)
{
return false;
}
var salt = new byte[SaltLengthBytes];
var hash = new byte[HashLengthBytes];
Array.Copy(payload, 1, salt, 0, SaltLengthBytes);
Array.Copy(payload, 1 + SaltLengthBytes, hash, 0, HashLengthBytes);
parsed = new Pbkdf2Parameters(iterations, salt, hash);
return true;
}
private readonly struct Pbkdf2Parameters
{
public Pbkdf2Parameters(int iterations, byte[] salt, byte[] hash)
{
Iterations = iterations;
Salt = salt;
Hash = hash;
}
public int Iterations { get; }
public byte[] Salt { get; }
public byte[] Hash { get; }
}
}

View File

@@ -0,0 +1,11 @@
namespace StellaOps.Cryptography;
/// <summary>
/// Known signature algorithm identifiers.
/// </summary>
public static class SignatureAlgorithms
{
public const string Es256 = "ES256";
public const string Es384 = "ES384";
public const string Es512 = "ES512";
}

View File

@@ -6,4 +6,11 @@
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup Condition="'$(StellaOpsCryptoSodium)' == 'true'">
<DefineConstants>$(DefineConstants);STELLAOPS_CRYPTO_SODIUM</DefineConstants>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.5.1" />
</ItemGroup>
</Project>

View File

@@ -2,16 +2,23 @@
| ID | Status | Owner | Description | Dependencies | Exit Criteria |
|----|--------|-------|-------------|--------------|---------------|
| SEC1.A | TODO | Security Guild | Introduce `Argon2idPasswordHasher` backed by Konscious defaults. Wire options into `StandardPluginOptions` (`PasswordHashOptions`) and `StellaOpsAuthorityOptions.Security.PasswordHashing`. | PLG3, CORE3 | ✅ Hashes emit PHC string `$argon2id$v=19$m=19456,t=2,p=1$...`; ✅ `NeedsRehash` promotes PBKDF2 → Argon2; ✅ Integration tests cover tamper, legacy rehash, perf p95 &lt; 250ms. |
| SEC1.B | TODO | Security Guild | Add compile-time switch to enable libsodium/Core variants later (`STELLAOPS_CRYPTO_SODIUM`). Document build variable. | SEC1.A | ✅ Conditional compilation path compiles; ✅ README snippet in `docs/security/password-hashing.md`. |
| SEC1.A | DONE (2025-10-11) | Security Guild | Introduce `Argon2idPasswordHasher` backed by Konscious defaults. Wire options into `StandardPluginOptions` (`PasswordHashOptions`) and `StellaOpsAuthorityOptions.Security.PasswordHashing`. | PLG3, CORE3 | ✅ Hashes emit PHC string `$argon2id$v=19$m=19456,t=2,p=1$...`; ✅ `NeedsRehash` promotes PBKDF2 → Argon2; ✅ Integration tests cover tamper, legacy rehash, perf p95 &lt; 250ms. |
| SEC1.B | DONE (2025-10-12) | Security Guild | Add compile-time switch to enable libsodium/Core variants later (`STELLAOPS_CRYPTO_SODIUM`). Document build variable. | SEC1.A | ✅ Conditional compilation path compiles; ✅ README snippet in `docs/security/password-hashing.md`. |
| SEC2.A | TODO | Security Guild + Core | Define audit event contract (`AuthEventRecord`) with subject/client/scope/IP/outcome/correlationId and PII tags. | CORE5CORE7 | ✅ Contract shipped in `StellaOps.Cryptography` (or shared abstractions); ✅ Docs in `docs/security/audit-events.md`. |
| SEC2.B | TODO | Security Guild | Emit audit records from OpenIddict handlers (password + client creds) and bootstrap APIs. Persist via `IAuthorityLoginAttemptStore`. | SEC2.A | ✅ Tests assert three flows (success/failure/lockout); ✅ Serilog output contains correlationId + PII tagging; ✅ Mongo store holds summary rows. |
| SEC3.A | BLOCKED (CORE8) | Security Guild + Core | Configure ASP.NET rate limiter (`AddRateLimiter`) with fixed-window policy keyed by IP + `client_id`. Apply to `/token` and `/internal/*`. | CORE8 completion | ✅ Middleware active; ✅ Configurable limits via options; ✅ Integration test hits 429. |
| SEC3.B | TODO | Security Guild | Document lockout + rate-limit tuning guidance and escalation thresholds. | SEC3.A | ✅ Section in `docs/security/rate-limits.md`; ✅ Includes SOC alert recommendations. |
| SEC4.A | TODO | Security Guild + DevOps | Define revocation JSON schema (`revocation_bundle.schema.json`) and detached JWS workflow. | CORE9, OPS3 | ✅ Schema + sample committed; ✅ CLI command `stellaops auth revoke export` scaffolded with acceptance tests; ✅ Verification script + docs. |
| SEC4.B | TODO | Security Guild | Integrate signing keys with crypto provider abstraction (initially ES256 via BCL). | SEC4.A, D5 | ✅ `ICryptoProvider.GetSigner` stub + default BCL signer; ✅ Unit tests verifying signature roundtrip. |
| SEC5.A | TODO | Security Guild | Author STRIDE threat model (`docs/security/authority-threat-model.md`) covering token, bootstrap, revocation, CLI, plugin surfaces. | All SEC1SEC4 in progress | ✅ DFDs + trust boundaries drawn; ✅ Risk table with owners/actions; ✅ Follow-up backlog issues created. |
| D5.A | TODO | Security Guild | Flesh out `StellaOps.Cryptography` provider registry, policy, and DI helpers enabling sovereign crypto selection. | SEC1.A, SEC4.B | ✅ `ICryptoProviderRegistry` implementation with provider selection rules; ✅ `StellaOps.Cryptography.DependencyInjection` extensions; ✅ Tests covering fallback ordering. |
| SEC4.A | DONE (2025-10-12) | Security Guild + DevOps | Define revocation JSON schema (`revocation_bundle.schema.json`) and detached JWS workflow. | CORE9, OPS3 | ✅ Schema + sample committed; ✅ CLI command `stellaops auth revoke export` scaffolded with acceptance tests; ✅ Verification script + docs. |
| SEC4.B | DONE (2025-10-12) | Security Guild | Integrate signing keys with crypto provider abstraction (initially ES256 via BCL). | SEC4.A, D5 | ✅ `ICryptoProvider.GetSigner` stub + default BCL signer; ✅ Unit tests verifying signature roundtrip. |
| SEC5.A | DONE (2025-10-12) | Security Guild | Author STRIDE threat model (`docs/security/authority-threat-model.md`) covering token, bootstrap, revocation, CLI, plugin surfaces. | All SEC1SEC4 in progress | ✅ DFDs + trust boundaries drawn; ✅ Risk table with owners/actions; ✅ Follow-up backlog issues created. |
| SEC5.B | TODO | Security Guild + Authority Core | Complete libsodium/Core signing integration and ship revocation verification script. | SEC4.A, SEC4.B, SEC4.HOST | ✅ libsodium/Core signing provider wired; ✅ `stellaops auth revoke verify` script published; ✅ Revocation docs updated with verification workflow. |
| SEC5.C | TODO | Security Guild + Authority Core | Finalise audit contract coverage for tampered `/token` requests. | SEC2.A, SEC2.B | ✅ Tamper attempts logged with correlationId/PII tags; ✅ SOC runbook updated; ✅ Threat model status reviewed. |
| SEC5.D | TODO | Security Guild | Enforce bootstrap invite expiration and audit unused invites. | SEC5.A | ✅ Bootstrap tokens auto-expire; ✅ Audit entries emitted for expiration/reuse attempts; ✅ Operator docs updated. |
| SEC5.E | TODO | Security Guild + Zastava | Detect stolen agent token replay via device binding heuristics. | SEC4.A | ✅ Device binding guidance published; ✅ Alerting pipeline raises stale revocation acknowledgements; ✅ Tests cover replay detection. |
| SEC5.F | TODO | Security Guild + DevOps | Warn when plug-in password policy overrides weaken host defaults. | SEC1.A, PLG3 | ✅ Static analyser flags weaker overrides; ✅ Runtime warning surfaced; ✅ Docs call out mitigation. |
| SEC5.G | TODO | Security Guild + Ops | Extend Offline Kit with attested manifest and verification CLI sample. | OPS3 | ✅ Offline Kit build signs manifest with detached JWS; ✅ Verification CLI documented; ✅ Supply-chain attestation recorded. |
| SEC5.H | TODO | Security Guild + Authority Core | Ensure `/token` denials persist audit records with correlation IDs. | SEC2.A, SEC2.B | ✅ Audit store captures denials; ✅ Tests cover success/failure/lockout; ✅ Threat model review updated. |
| D5.A | DONE (2025-10-12) | Security Guild | Flesh out `StellaOps.Cryptography` provider registry, policy, and DI helpers enabling sovereign crypto selection. | SEC1.A, SEC4.B | ✅ `ICryptoProviderRegistry` implementation with provider selection rules; ✅ `StellaOps.Cryptography.DependencyInjection` extensions; ✅ Tests covering fallback ordering. |
## Notes
- Target Argon2 parameters follow OWASP Cheat Sheet (memory ≈ 19MiB, iterations 2, parallelism 1). Allow overrides via configuration.