This commit is contained in:
master
2026-02-04 19:59:20 +02:00
parent 557feefdc3
commit 5548cf83bf
1479 changed files with 53557 additions and 40339 deletions

View File

@@ -0,0 +1,23 @@
# AdvisoryAI Attestation Charter
## Mission
- Provide deterministic, offline-safe attestation models and helpers for Advisory AI workflows.
## Responsibilities
- Maintain stable attestation contracts and digest behavior.
- Keep in-memory implementations deterministic and test-friendly.
- Update local `TASKS.md` and sprint status when starting or finishing work.
## Required Reading
- `docs/README.md`
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
- `docs/modules/platform/architecture-overview.md`
- `docs/modules/concelier/advisory-ai-api.md`
## Working Directory & Scope
- Primary: `src/__Libraries/StellaOps.AdvisoryAI.Attestation`
- Allowed shared projects: `src/__Libraries/__Tests/StellaOps.AdvisoryAI.Attestation.Tests`
## Testing Expectations
- Add unit tests for digest and template registry behaviors.
- Keep tests deterministic and offline-friendly.

View File

@@ -0,0 +1,25 @@
namespace StellaOps.AdvisoryAI.Attestation;
/// <summary>
/// Result of creating an attestation.
/// </summary>
public sealed record AiAttestationResult
{
/// <summary>Attestation ID.</summary>
public required string AttestationId { get; init; }
/// <summary>Content digest.</summary>
public required string Digest { get; init; }
/// <summary>Whether the attestation was signed.</summary>
public bool Signed { get; init; }
/// <summary>DSSE envelope if signed.</summary>
public string? DsseEnvelope { get; init; }
/// <summary>Storage URI.</summary>
public string? StorageUri { get; init; }
/// <summary>Creation timestamp.</summary>
public required DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,95 @@
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation.Models;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.Attestation;
public sealed partial class AiAttestationService
{
/// <inheritdoc/>
public Task<AiAttestationResult> CreateRunAttestationAsync(
AiRunAttestation attestation,
bool sign = true,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
var digest = attestation.ComputeDigest();
var json = JsonSerializer.Serialize(attestation, AiAttestationJsonContext.Default.AiRunAttestation);
// In production, this would call the signer service
string? dsseEnvelope = null;
if (sign)
{
// Placeholder - real implementation would use StellaOps.Signer
dsseEnvelope = CreateMockDsseEnvelope(AiRunAttestation.PredicateType, json);
}
var stored = new StoredAttestation(
attestation.RunId,
AiRunAttestation.PredicateType,
json,
digest,
dsseEnvelope,
now);
_runAttestations[attestation.RunId] = stored;
_logger.LogInformation(
"Created run attestation {RunId} with digest {Digest}, signed={Signed}",
attestation.RunId,
digest,
sign);
return Task.FromResult(new AiAttestationResult
{
AttestationId = attestation.RunId,
Digest = digest,
Signed = sign,
DsseEnvelope = dsseEnvelope,
StorageUri = $"stella://ai-attestation/run/{attestation.RunId}",
CreatedAt = now
});
}
/// <inheritdoc/>
public Task<AiAttestationResult> CreateClaimAttestationAsync(
AiClaimAttestation attestation,
bool sign = true,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
var digest = attestation.ComputeDigest();
var json = JsonSerializer.Serialize(attestation, AiAttestationJsonContext.Default.AiClaimAttestation);
string? dsseEnvelope = null;
if (sign)
{
dsseEnvelope = CreateMockDsseEnvelope(AiClaimAttestation.PredicateType, json);
}
var stored = new StoredAttestation(
attestation.ClaimId,
AiClaimAttestation.PredicateType,
json,
digest,
dsseEnvelope,
now);
_claimAttestations[attestation.ClaimId] = stored;
_logger.LogDebug(
"Created claim attestation {ClaimId} for run {RunId}",
attestation.ClaimId,
attestation.RunId);
return Task.FromResult(new AiAttestationResult
{
AttestationId = attestation.ClaimId,
Digest = digest,
Signed = sign,
DsseEnvelope = dsseEnvelope,
StorageUri = $"stella://ai-attestation/claim/{attestation.ClaimId}",
CreatedAt = now
});
}
}

View File

@@ -0,0 +1,25 @@
namespace StellaOps.AdvisoryAI.Attestation;
public sealed partial class AiAttestationService
{
private static string CreateMockDsseEnvelope(string predicateType, string payload)
{
// Mock DSSE envelope - real implementation would use StellaOps.Signer
var payloadBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload));
return $$"""
{
"payloadType": "{{predicateType}}",
"payload": "{{payloadBase64}}",
"signatures": [{"sig": "mock-signature"}]
}
""";
}
private sealed record StoredAttestation(
string Id,
string PredicateType,
string Json,
string Digest,
string? DsseEnvelope,
DateTimeOffset CreatedAt);
}

View File

@@ -0,0 +1,55 @@
using StellaOps.AdvisoryAI.Attestation.Models;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.Attestation;
public sealed partial class AiAttestationService
{
/// <inheritdoc/>
public Task<AiRunAttestation?> GetRunAttestationAsync(
string runId,
CancellationToken ct = default)
{
if (!_runAttestations.TryGetValue(runId, out var stored))
{
return Task.FromResult<AiRunAttestation?>(null);
}
var attestation = JsonSerializer.Deserialize(
stored.Json,
AiAttestationJsonContext.Default.AiRunAttestation);
return Task.FromResult(attestation);
}
/// <inheritdoc/>
public Task<IReadOnlyList<AiClaimAttestation>> GetClaimAttestationsAsync(
string runId,
CancellationToken ct = default)
{
var claims = _claimAttestations.Values
.Select(s => JsonSerializer.Deserialize(s.Json, AiAttestationJsonContext.Default.AiClaimAttestation))
.Where(c => c != null && c.RunId == runId)
.Cast<AiClaimAttestation>()
.ToList();
return Task.FromResult<IReadOnlyList<AiClaimAttestation>>(claims);
}
/// <inheritdoc/>
public Task<IReadOnlyList<AiRunAttestation>> ListRecentAttestationsAsync(
string tenantId,
int limit = 100,
CancellationToken ct = default)
{
var attestations = _runAttestations.Values
.OrderByDescending(s => s.CreatedAt)
.Select(s => JsonSerializer.Deserialize(s.Json, AiAttestationJsonContext.Default.AiRunAttestation))
.Where(a => a != null && a.TenantId == tenantId)
.Cast<AiRunAttestation>()
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<AiRunAttestation>>(attestations);
}
}

View File

@@ -0,0 +1,92 @@
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation.Models;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.Attestation;
public sealed partial class AiAttestationService
{
/// <inheritdoc/>
public Task<AiAttestationVerificationResult> VerifyRunAttestationAsync(
string runId,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
if (!_runAttestations.TryGetValue(runId, out var stored))
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
$"Run attestation {runId} not found"));
}
// Verify digest
var attestation = JsonSerializer.Deserialize(
stored.Json,
AiAttestationJsonContext.Default.AiRunAttestation);
if (attestation == null)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Failed to deserialize attestation"));
}
var computedDigest = attestation.ComputeDigest();
if (computedDigest != stored.Digest)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Digest mismatch",
digestValid: false));
}
// In production, verify signature via signer service
bool? signatureValid = stored.DsseEnvelope != null ? true : null;
_logger.LogDebug("Verified run attestation {RunId}", runId);
return Task.FromResult(AiAttestationVerificationResult.Success(
now,
stored.DsseEnvelope != null ? "ai-attestation-key" : null));
}
/// <inheritdoc/>
public Task<AiAttestationVerificationResult> VerifyClaimAttestationAsync(
string claimId,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
if (!_claimAttestations.TryGetValue(claimId, out var stored))
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
$"Claim attestation {claimId} not found"));
}
var attestation = JsonSerializer.Deserialize(
stored.Json,
AiAttestationJsonContext.Default.AiClaimAttestation);
if (attestation == null)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Failed to deserialize attestation"));
}
var computedDigest = attestation.ComputeDigest();
if (computedDigest != stored.Digest)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Digest mismatch",
digestValid: false));
}
return Task.FromResult(AiAttestationVerificationResult.Success(
now,
stored.DsseEnvelope != null ? "ai-attestation-key" : null));
}
}

View File

@@ -2,11 +2,8 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation.Models;
using System.Collections.Concurrent;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.Attestation;
@@ -18,7 +15,7 @@ namespace StellaOps.AdvisoryAI.Attestation;
/// This implementation stores attestations in memory. For production,
/// use a database-backed implementation with signing integration.
/// </remarks>
public sealed class AiAttestationService : IAiAttestationService
public sealed partial class AiAttestationService : IAiAttestationService
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<AiAttestationService> _logger;
@@ -32,244 +29,4 @@ public sealed class AiAttestationService : IAiAttestationService
_timeProvider = timeProvider;
_logger = logger;
}
/// <inheritdoc/>
public Task<AiAttestationResult> CreateRunAttestationAsync(
AiRunAttestation attestation,
bool sign = true,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
var digest = attestation.ComputeDigest();
var json = JsonSerializer.Serialize(attestation, AiAttestationJsonContext.Default.AiRunAttestation);
// In production, this would call the signer service
string? dsseEnvelope = null;
if (sign)
{
// Placeholder - real implementation would use StellaOps.Signer
dsseEnvelope = CreateMockDsseEnvelope(AiRunAttestation.PredicateType, json);
}
var stored = new StoredAttestation(
attestation.RunId,
AiRunAttestation.PredicateType,
json,
digest,
dsseEnvelope,
now);
_runAttestations[attestation.RunId] = stored;
_logger.LogInformation(
"Created run attestation {RunId} with digest {Digest}, signed={Signed}",
attestation.RunId,
digest,
sign);
return Task.FromResult(new AiAttestationResult
{
AttestationId = attestation.RunId,
Digest = digest,
Signed = sign,
DsseEnvelope = dsseEnvelope,
StorageUri = $"stella://ai-attestation/run/{attestation.RunId}",
CreatedAt = now
});
}
/// <inheritdoc/>
public Task<AiAttestationResult> CreateClaimAttestationAsync(
AiClaimAttestation attestation,
bool sign = true,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
var digest = attestation.ComputeDigest();
var json = JsonSerializer.Serialize(attestation, AiAttestationJsonContext.Default.AiClaimAttestation);
string? dsseEnvelope = null;
if (sign)
{
dsseEnvelope = CreateMockDsseEnvelope(AiClaimAttestation.PredicateType, json);
}
var stored = new StoredAttestation(
attestation.ClaimId,
AiClaimAttestation.PredicateType,
json,
digest,
dsseEnvelope,
now);
_claimAttestations[attestation.ClaimId] = stored;
_logger.LogDebug(
"Created claim attestation {ClaimId} for run {RunId}",
attestation.ClaimId,
attestation.RunId);
return Task.FromResult(new AiAttestationResult
{
AttestationId = attestation.ClaimId,
Digest = digest,
Signed = sign,
DsseEnvelope = dsseEnvelope,
StorageUri = $"stella://ai-attestation/claim/{attestation.ClaimId}",
CreatedAt = now
});
}
/// <inheritdoc/>
public Task<AiAttestationVerificationResult> VerifyRunAttestationAsync(
string runId,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
if (!_runAttestations.TryGetValue(runId, out var stored))
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
$"Run attestation {runId} not found"));
}
// Verify digest
var attestation = JsonSerializer.Deserialize(
stored.Json,
AiAttestationJsonContext.Default.AiRunAttestation);
if (attestation == null)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Failed to deserialize attestation"));
}
var computedDigest = attestation.ComputeDigest();
if (computedDigest != stored.Digest)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Digest mismatch",
digestValid: false));
}
// In production, verify signature via signer service
bool? signatureValid = stored.DsseEnvelope != null ? true : null;
_logger.LogDebug("Verified run attestation {RunId}", runId);
return Task.FromResult(AiAttestationVerificationResult.Success(
now,
stored.DsseEnvelope != null ? "ai-attestation-key" : null));
}
/// <inheritdoc/>
public Task<AiAttestationVerificationResult> VerifyClaimAttestationAsync(
string claimId,
CancellationToken ct = default)
{
var now = _timeProvider.GetUtcNow();
if (!_claimAttestations.TryGetValue(claimId, out var stored))
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
$"Claim attestation {claimId} not found"));
}
var attestation = JsonSerializer.Deserialize(
stored.Json,
AiAttestationJsonContext.Default.AiClaimAttestation);
if (attestation == null)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Failed to deserialize attestation"));
}
var computedDigest = attestation.ComputeDigest();
if (computedDigest != stored.Digest)
{
return Task.FromResult(AiAttestationVerificationResult.Failure(
now,
"Digest mismatch",
digestValid: false));
}
return Task.FromResult(AiAttestationVerificationResult.Success(
now,
stored.DsseEnvelope != null ? "ai-attestation-key" : null));
}
/// <inheritdoc/>
public Task<AiRunAttestation?> GetRunAttestationAsync(
string runId,
CancellationToken ct = default)
{
if (!_runAttestations.TryGetValue(runId, out var stored))
{
return Task.FromResult<AiRunAttestation?>(null);
}
var attestation = JsonSerializer.Deserialize(
stored.Json,
AiAttestationJsonContext.Default.AiRunAttestation);
return Task.FromResult(attestation);
}
/// <inheritdoc/>
public Task<IReadOnlyList<AiClaimAttestation>> GetClaimAttestationsAsync(
string runId,
CancellationToken ct = default)
{
var claims = _claimAttestations.Values
.Select(s => JsonSerializer.Deserialize(s.Json, AiAttestationJsonContext.Default.AiClaimAttestation))
.Where(c => c != null && c.RunId == runId)
.Cast<AiClaimAttestation>()
.ToList();
return Task.FromResult<IReadOnlyList<AiClaimAttestation>>(claims);
}
/// <inheritdoc/>
public Task<IReadOnlyList<AiRunAttestation>> ListRecentAttestationsAsync(
string tenantId,
int limit = 100,
CancellationToken ct = default)
{
var attestations = _runAttestations.Values
.OrderByDescending(s => s.CreatedAt)
.Select(s => JsonSerializer.Deserialize(s.Json, AiAttestationJsonContext.Default.AiRunAttestation))
.Where(a => a != null && a.TenantId == tenantId)
.Cast<AiRunAttestation>()
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<AiRunAttestation>>(attestations);
}
private static string CreateMockDsseEnvelope(string predicateType, string payload)
{
// Mock DSSE envelope - real implementation would use StellaOps.Signer
var payloadBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload));
return $$"""
{
"payloadType": "{{predicateType}}",
"payload": "{{payloadBase64}}",
"signatures": [{"sig": "mock-signature"}]
}
""";
}
private sealed record StoredAttestation(
string Id,
string PredicateType,
string Json,
string Digest,
string? DsseEnvelope,
DateTimeOffset CreatedAt);
}

View File

@@ -0,0 +1,60 @@
namespace StellaOps.AdvisoryAI.Attestation;
/// <summary>
/// Result of verifying an attestation.
/// </summary>
public sealed record AiAttestationVerificationResult
{
/// <summary>Whether verification succeeded.</summary>
public required bool Valid { get; init; }
/// <summary>Verification timestamp.</summary>
public required DateTimeOffset VerifiedAt { get; init; }
/// <summary>Signing key ID if signed.</summary>
public string? SigningKeyId { get; init; }
/// <summary>Key expiration if applicable.</summary>
public DateTimeOffset? KeyExpiresAt { get; init; }
/// <summary>Digest verification result.</summary>
public bool DigestValid { get; init; }
/// <summary>Signature verification result.</summary>
public bool? SignatureValid { get; init; }
/// <summary>Verification failure reason if invalid.</summary>
public string? FailureReason { get; init; }
/// <summary>
/// Creates a successful verification result.
/// </summary>
public static AiAttestationVerificationResult Success(
DateTimeOffset verifiedAt,
string? signingKeyId = null,
DateTimeOffset? keyExpiresAt = null) => new()
{
Valid = true,
VerifiedAt = verifiedAt,
SigningKeyId = signingKeyId,
KeyExpiresAt = keyExpiresAt,
DigestValid = true,
SignatureValid = signingKeyId != null ? true : null
};
/// <summary>
/// Creates a failed verification result.
/// </summary>
public static AiAttestationVerificationResult Failure(
DateTimeOffset verifiedAt,
string reason,
bool digestValid = false,
bool? signatureValid = null) => new()
{
Valid = false,
VerifiedAt = verifiedAt,
DigestValid = digestValid,
SignatureValid = signatureValid,
FailureReason = reason
};
}

View File

@@ -88,86 +88,3 @@ public interface IAiAttestationService
int limit = 100,
CancellationToken ct = default);
}
/// <summary>
/// Result of creating an attestation.
/// </summary>
public sealed record AiAttestationResult
{
/// <summary>Attestation ID.</summary>
public required string AttestationId { get; init; }
/// <summary>Content digest.</summary>
public required string Digest { get; init; }
/// <summary>Whether the attestation was signed.</summary>
public bool Signed { get; init; }
/// <summary>DSSE envelope if signed.</summary>
public string? DsseEnvelope { get; init; }
/// <summary>Storage URI.</summary>
public string? StorageUri { get; init; }
/// <summary>Creation timestamp.</summary>
public required DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Result of verifying an attestation.
/// </summary>
public sealed record AiAttestationVerificationResult
{
/// <summary>Whether verification succeeded.</summary>
public required bool Valid { get; init; }
/// <summary>Verification timestamp.</summary>
public required DateTimeOffset VerifiedAt { get; init; }
/// <summary>Signing key ID if signed.</summary>
public string? SigningKeyId { get; init; }
/// <summary>Key expiration if applicable.</summary>
public DateTimeOffset? KeyExpiresAt { get; init; }
/// <summary>Digest verification result.</summary>
public bool DigestValid { get; init; }
/// <summary>Signature verification result.</summary>
public bool? SignatureValid { get; init; }
/// <summary>Verification failure reason if invalid.</summary>
public string? FailureReason { get; init; }
/// <summary>
/// Creates a successful verification result.
/// </summary>
public static AiAttestationVerificationResult Success(
DateTimeOffset verifiedAt,
string? signingKeyId = null,
DateTimeOffset? keyExpiresAt = null) => new()
{
Valid = true,
VerifiedAt = verifiedAt,
SigningKeyId = signingKeyId,
KeyExpiresAt = keyExpiresAt,
DigestValid = true,
SignatureValid = signingKeyId != null ? true : null
};
/// <summary>
/// Creates a failed verification result.
/// </summary>
public static AiAttestationVerificationResult Failure(
DateTimeOffset verifiedAt,
string reason,
bool digestValid = false,
bool? signatureValid = null) => new()
{
Valid = false,
VerifiedAt = verifiedAt,
DigestValid = digestValid,
SignatureValid = signatureValid,
FailureReason = reason
};
}

View File

@@ -0,0 +1,51 @@
// <copyright file="IPromptTemplateRegistry.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using StellaOps.AdvisoryAI.Attestation.Models;
namespace StellaOps.AdvisoryAI.Attestation;
/// <summary>
/// Interface for prompt template registry.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-004
/// </summary>
public interface IPromptTemplateRegistry
{
/// <summary>
/// Registers a prompt template with version.
/// </summary>
/// <param name="name">Template name.</param>
/// <param name="version">Template version.</param>
/// <param name="template">Template content.</param>
void Register(string name, string version, string template);
/// <summary>
/// Gets template info including hash.
/// </summary>
/// <param name="name">Template name.</param>
/// <returns>Template info or null if not found.</returns>
PromptTemplateInfo? GetTemplateInfo(string name);
/// <summary>
/// Gets template info for a specific version.
/// </summary>
/// <param name="name">Template name.</param>
/// <param name="version">Template version.</param>
/// <returns>Template info or null if not found.</returns>
PromptTemplateInfo? GetTemplateInfo(string name, string version);
/// <summary>
/// Verifies a template hash matches registered version.
/// </summary>
/// <param name="name">Template name.</param>
/// <param name="expectedHash">Expected hash.</param>
/// <returns>True if hash matches.</returns>
bool VerifyHash(string name, string expectedHash);
/// <summary>
/// Gets all registered templates.
/// </summary>
/// <returns>All template info records.</returns>
IReadOnlyList<PromptTemplateInfo> GetAllTemplates();
}

View File

@@ -0,0 +1,18 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.Attestation.Models;
public sealed partial record AiClaimAttestation
{
/// <summary>
/// Computes the content digest for this attestation.
/// </summary>
public string ComputeDigest()
{
var json = JsonSerializer.Serialize(this, AiAttestationJsonContext.Default.AiClaimAttestation);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,48 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.AdvisoryAI.Attestation.Models;
public sealed partial record AiClaimAttestation
{
/// <summary>
/// Creates a claim attestation from a claim evidence.
/// </summary>
public static AiClaimAttestation FromClaimEvidence(
ClaimEvidence evidence,
string runId,
string turnId,
string tenantId,
DateTimeOffset timestamp,
AiRunContext? context = null)
{
var claimDigest = ComputeClaimDigest(evidence.Text);
var claimId = $"claim-{Guid.NewGuid():N}";
var attestation = new AiClaimAttestation
{
ClaimId = claimId,
RunId = runId,
TurnId = turnId,
TenantId = tenantId,
ClaimText = evidence.Text,
ClaimDigest = claimDigest,
Category = evidence.Category,
GroundedBy = evidence.GroundedBy,
GroundingScore = evidence.GroundingScore,
Verified = evidence.Verified,
Timestamp = timestamp,
Context = context,
ContentDigest = "" // Placeholder, computed below
};
// Now compute the actual content digest
return attestation with { ContentDigest = attestation.ComputeDigest() };
}
private static string ComputeClaimDigest(string claimText)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(claimText));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -3,9 +3,6 @@
// </copyright>
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
@@ -21,7 +18,7 @@ namespace StellaOps.AdvisoryAI.Attestation.Models;
/// - Claim-specific evidence linkage
/// - Selective claim citation in reports
/// </remarks>
public sealed record AiClaimAttestation
public sealed partial record AiClaimAttestation
{
/// <summary>Attestation type URI.</summary>
public const string PredicateType = "https://stellaops.org/attestation/ai-claim/v1";
@@ -86,54 +83,4 @@ public sealed record AiClaimAttestation
[JsonPropertyName("claimType")]
public string? ClaimType { get; init; }
/// <summary>
/// Computes the content digest for this attestation.
/// </summary>
public string ComputeDigest()
{
var json = JsonSerializer.Serialize(this, AiAttestationJsonContext.Default.AiClaimAttestation);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
/// <summary>
/// Creates a claim attestation from a claim evidence.
/// </summary>
public static AiClaimAttestation FromClaimEvidence(
ClaimEvidence evidence,
string runId,
string turnId,
string tenantId,
DateTimeOffset timestamp,
AiRunContext? context = null)
{
var claimDigest = ComputeClaimDigest(evidence.Text);
var claimId = $"claim-{Guid.NewGuid():N}";
var attestation = new AiClaimAttestation
{
ClaimId = claimId,
RunId = runId,
TurnId = turnId,
TenantId = tenantId,
ClaimText = evidence.Text,
ClaimDigest = claimDigest,
Category = evidence.Category,
GroundedBy = evidence.GroundedBy,
GroundingScore = evidence.GroundingScore,
Verified = evidence.Verified,
Timestamp = timestamp,
Context = context,
ContentDigest = "" // Placeholder, computed below
};
// Now compute the actual content digest
return attestation with { ContentDigest = attestation.ComputeDigest() };
}
private static string ComputeClaimDigest(string claimText)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(claimText));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,18 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.AdvisoryAI.Attestation.Models;
public sealed partial record AiRunAttestation
{
/// <summary>
/// Computes the content digest for this attestation.
/// </summary>
public string ComputeDigest()
{
var json = JsonSerializer.Serialize(this, AiAttestationJsonContext.Default.AiRunAttestation);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -3,9 +3,6 @@
// </copyright>
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
@@ -23,7 +20,7 @@ namespace StellaOps.AdvisoryAI.Attestation.Models;
/// - What was said (content digests)
/// - What claims were made and their grounding evidence
/// </remarks>
public sealed record AiRunAttestation
public sealed partial record AiRunAttestation
{
/// <summary>Attestation type URI.</summary>
public const string PredicateType = "https://stellaops.org/attestation/ai-run/v1";
@@ -84,35 +81,4 @@ public sealed record AiRunAttestation
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; init; }
/// <summary>
/// Computes the content digest for this attestation.
/// </summary>
public string ComputeDigest()
{
var json = JsonSerializer.Serialize(this, AiAttestationJsonContext.Default.AiRunAttestation);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}
/// <summary>
/// AI run status.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<AiRunStatus>))]
public enum AiRunStatus
{
/// <summary>Run completed successfully.</summary>
Completed,
/// <summary>Run failed.</summary>
Failed,
/// <summary>Run was cancelled.</summary>
Cancelled,
/// <summary>Run timed out.</summary>
TimedOut,
/// <summary>Run was blocked by guardrails.</summary>
Blocked
}

View File

@@ -0,0 +1,25 @@
using System.Text.Json.Serialization;
namespace StellaOps.AdvisoryAI.Attestation.Models;
/// <summary>
/// AI run status.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<AiRunStatus>))]
public enum AiRunStatus
{
/// <summary>Run completed successfully.</summary>
Completed,
/// <summary>Run failed.</summary>
Failed,
/// <summary>Run was cancelled.</summary>
Cancelled,
/// <summary>Run timed out.</summary>
TimedOut,
/// <summary>Run was blocked by guardrails.</summary>
Blocked
}

View File

@@ -0,0 +1,13 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.AdvisoryAI.Attestation;
public sealed partial class PromptTemplateRegistry
{
private static string ComputeDigest(string content)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,48 @@
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation.Models;
namespace StellaOps.AdvisoryAI.Attestation;
public sealed partial class PromptTemplateRegistry
{
/// <inheritdoc/>
public PromptTemplateInfo? GetTemplateInfo(string name)
{
return _latestVersions.TryGetValue(name, out var info) ? info : null;
}
/// <inheritdoc/>
public PromptTemplateInfo? GetTemplateInfo(string name, string version)
{
return _allVersions.TryGetValue((name, version), out var info) ? info : null;
}
/// <inheritdoc/>
public bool VerifyHash(string name, string expectedHash)
{
if (!_latestVersions.TryGetValue(name, out var info))
{
_logger.LogWarning("Template {Name} not found for hash verification", name);
return false;
}
var matches = string.Equals(info.Digest, expectedHash, StringComparison.OrdinalIgnoreCase);
if (!matches)
{
_logger.LogWarning(
"Hash mismatch for template {Name}: expected {Expected}, got {Actual}",
name,
expectedHash,
info.Digest);
}
return matches;
}
/// <inheritdoc/>
public IReadOnlyList<PromptTemplateInfo> GetAllTemplates()
{
return [.. _latestVersions.Values];
}
}

View File

@@ -2,65 +2,17 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation.Models;
using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.AdvisoryAI.Attestation;
/// <summary>
/// Interface for prompt template registry.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-004
/// </summary>
public interface IPromptTemplateRegistry
{
/// <summary>
/// Registers a prompt template with version.
/// </summary>
/// <param name="name">Template name.</param>
/// <param name="version">Template version.</param>
/// <param name="template">Template content.</param>
void Register(string name, string version, string template);
/// <summary>
/// Gets template info including hash.
/// </summary>
/// <param name="name">Template name.</param>
/// <returns>Template info or null if not found.</returns>
PromptTemplateInfo? GetTemplateInfo(string name);
/// <summary>
/// Gets template info for a specific version.
/// </summary>
/// <param name="name">Template name.</param>
/// <param name="version">Template version.</param>
/// <returns>Template info or null if not found.</returns>
PromptTemplateInfo? GetTemplateInfo(string name, string version);
/// <summary>
/// Verifies a template hash matches registered version.
/// </summary>
/// <param name="name">Template name.</param>
/// <param name="expectedHash">Expected hash.</param>
/// <returns>True if hash matches.</returns>
bool VerifyHash(string name, string expectedHash);
/// <summary>
/// Gets all registered templates.
/// </summary>
/// <returns>All template info records.</returns>
IReadOnlyList<PromptTemplateInfo> GetAllTemplates();
}
/// <summary>
/// In-memory implementation of prompt template registry.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-004
/// </summary>
public sealed class PromptTemplateRegistry : IPromptTemplateRegistry
public sealed partial class PromptTemplateRegistry : IPromptTemplateRegistry
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<PromptTemplateRegistry> _logger;
@@ -101,51 +53,4 @@ public sealed class PromptTemplateRegistry : IPromptTemplateRegistry
version,
digest);
}
/// <inheritdoc/>
public PromptTemplateInfo? GetTemplateInfo(string name)
{
return _latestVersions.TryGetValue(name, out var info) ? info : null;
}
/// <inheritdoc/>
public PromptTemplateInfo? GetTemplateInfo(string name, string version)
{
return _allVersions.TryGetValue((name, version), out var info) ? info : null;
}
/// <inheritdoc/>
public bool VerifyHash(string name, string expectedHash)
{
if (!_latestVersions.TryGetValue(name, out var info))
{
_logger.LogWarning("Template {Name} not found for hash verification", name);
return false;
}
var matches = string.Equals(info.Digest, expectedHash, StringComparison.OrdinalIgnoreCase);
if (!matches)
{
_logger.LogWarning(
"Hash mismatch for template {Name}: expected {Expected}, got {Actual}",
name,
expectedHash,
info.Digest);
}
return matches;
}
/// <inheritdoc/>
public IReadOnlyList<PromptTemplateInfo> GetAllTemplates()
{
return [.. _latestVersions.Values];
}
private static string ComputeDigest(string content)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
}

View File

@@ -0,0 +1,73 @@
using StellaOps.AdvisoryAI.Attestation.Models;
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Attestation.Storage;
public partial interface IAiAttestationStore
{
/// <summary>
/// Get a run attestation by run ID.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The attestation or null if not found.</returns>
Task<AiRunAttestation?> GetRunAttestationAsync(string runId, CancellationToken ct);
/// <summary>
/// Get all claim attestations for a run.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of claim attestations.</returns>
Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsAsync(string runId, CancellationToken ct);
/// <summary>
/// Get claim attestations for a specific turn.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="turnId">The turn ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of claim attestations for the turn.</returns>
Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsByTurnAsync(
string runId,
string turnId,
CancellationToken ct);
/// <summary>
/// Get the signed envelope for a run.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The signed envelope or null if not found.</returns>
Task<object?> GetSignedEnvelopeAsync(string runId, CancellationToken ct);
/// <summary>
/// Check if a run attestation exists.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if the attestation exists.</returns>
Task<bool> ExistsAsync(string runId, CancellationToken ct);
/// <summary>
/// Get attestations by tenant within a time range.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="from">Start time.</param>
/// <param name="to">End time.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of run attestations.</returns>
Task<ImmutableArray<AiRunAttestation>> GetByTenantAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken ct);
/// <summary>
/// Get attestation by content digest.
/// </summary>
/// <param name="contentDigest">The content digest.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The claim attestation or null if not found.</returns>
Task<AiClaimAttestation?> GetByContentDigestAsync(string contentDigest, CancellationToken ct);
}

View File

@@ -12,7 +12,7 @@ namespace StellaOps.AdvisoryAI.Attestation.Storage;
/// Interface for storing and retrieving AI attestations.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-006
/// </summary>
public interface IAiAttestationStore
public partial interface IAiAttestationStore
{
/// <summary>
/// Store a run attestation.
@@ -35,70 +35,4 @@ public interface IAiAttestationStore
/// <param name="attestation">The attestation to store.</param>
/// <param name="ct">Cancellation token.</param>
Task StoreClaimAttestationAsync(AiClaimAttestation attestation, CancellationToken ct);
/// <summary>
/// Get a run attestation by run ID.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The attestation or null if not found.</returns>
Task<AiRunAttestation?> GetRunAttestationAsync(string runId, CancellationToken ct);
/// <summary>
/// Get all claim attestations for a run.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of claim attestations.</returns>
Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsAsync(string runId, CancellationToken ct);
/// <summary>
/// Get claim attestations for a specific turn.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="turnId">The turn ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of claim attestations for the turn.</returns>
Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsByTurnAsync(
string runId,
string turnId,
CancellationToken ct);
/// <summary>
/// Get the signed envelope for a run.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The signed envelope or null if not found.</returns>
Task<object?> GetSignedEnvelopeAsync(string runId, CancellationToken ct);
/// <summary>
/// Check if a run attestation exists.
/// </summary>
/// <param name="runId">The run ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if the attestation exists.</returns>
Task<bool> ExistsAsync(string runId, CancellationToken ct);
/// <summary>
/// Get attestations by tenant within a time range.
/// </summary>
/// <param name="tenantId">The tenant ID.</param>
/// <param name="from">Start time.</param>
/// <param name="to">End time.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of run attestations.</returns>
Task<ImmutableArray<AiRunAttestation>> GetByTenantAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken ct);
/// <summary>
/// Get attestation by content digest.
/// </summary>
/// <param name="contentDigest">The content digest.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The claim attestation or null if not found.</returns>
Task<AiClaimAttestation?> GetByContentDigestAsync(string contentDigest, CancellationToken ct);
}

View File

@@ -0,0 +1,28 @@
namespace StellaOps.AdvisoryAI.Attestation.Storage;
public sealed partial class InMemoryAiAttestationStore
{
/// <summary>
/// Clear all stored attestations. Useful for testing.
/// </summary>
public void Clear()
{
_runAttestations.Clear();
_signedEnvelopes.Clear();
_claimAttestations.Clear();
_digestIndex.Clear();
}
/// <summary>
/// Get count of run attestations. Useful for testing.
/// </summary>
public int RunAttestationCount => _runAttestations.Count;
/// <summary>
/// Get count of all claim attestations. Useful for testing.
/// </summary>
public int ClaimAttestationCount => _claimAttestations.Values.Sum(c =>
{
lock (c) { return c.Count; }
});
}

View File

@@ -0,0 +1,85 @@
using StellaOps.AdvisoryAI.Attestation.Models;
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Attestation.Storage;
public sealed partial class InMemoryAiAttestationStore
{
/// <inheritdoc/>
public Task<AiRunAttestation?> GetRunAttestationAsync(string runId, CancellationToken ct)
{
_runAttestations.TryGetValue(runId, out var attestation);
return Task.FromResult(attestation);
}
/// <inheritdoc/>
public Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsAsync(string runId, CancellationToken ct)
{
if (_claimAttestations.TryGetValue(runId, out var claims))
{
lock (claims)
{
return Task.FromResult(claims.ToImmutableArray());
}
}
return Task.FromResult(ImmutableArray<AiClaimAttestation>.Empty);
}
/// <inheritdoc/>
public Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsByTurnAsync(
string runId,
string turnId,
CancellationToken ct)
{
if (_claimAttestations.TryGetValue(runId, out var claims))
{
lock (claims)
{
var filtered = claims
.Where(c => c.TurnId == turnId)
.ToImmutableArray();
return Task.FromResult(filtered);
}
}
return Task.FromResult(ImmutableArray<AiClaimAttestation>.Empty);
}
/// <inheritdoc/>
public Task<object?> GetSignedEnvelopeAsync(string runId, CancellationToken ct)
{
_signedEnvelopes.TryGetValue(runId, out var envelope);
return Task.FromResult(envelope);
}
/// <inheritdoc/>
public Task<bool> ExistsAsync(string runId, CancellationToken ct)
{
return Task.FromResult(_runAttestations.ContainsKey(runId));
}
/// <inheritdoc/>
public Task<ImmutableArray<AiRunAttestation>> GetByTenantAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken ct)
{
var results = _runAttestations.Values
.Where(a => a.TenantId == tenantId &&
a.StartedAt >= from &&
a.StartedAt <= to)
.OrderBy(a => a.StartedAt)
.ToImmutableArray();
return Task.FromResult(results);
}
/// <inheritdoc/>
public Task<AiClaimAttestation?> GetByContentDigestAsync(string contentDigest, CancellationToken ct)
{
_digestIndex.TryGetValue(contentDigest, out var attestation);
return Task.FromResult(attestation);
}
}

View File

@@ -0,0 +1,42 @@
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation.Models;
namespace StellaOps.AdvisoryAI.Attestation.Storage;
public sealed partial class InMemoryAiAttestationStore
{
/// <inheritdoc/>
public Task StoreRunAttestationAsync(AiRunAttestation attestation, CancellationToken ct)
{
_runAttestations[attestation.RunId] = attestation;
_logger.LogDebug("Stored run attestation for RunId {RunId}", attestation.RunId);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task StoreSignedEnvelopeAsync(string runId, object envelope, CancellationToken ct)
{
_signedEnvelopes[runId] = envelope;
_logger.LogDebug("Stored signed envelope for RunId {RunId}", runId);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task StoreClaimAttestationAsync(AiClaimAttestation attestation, CancellationToken ct)
{
var claims = _claimAttestations.GetOrAdd(attestation.RunId, _ => []);
lock (claims)
{
claims.Add(attestation);
}
_digestIndex[attestation.ContentDigest] = attestation;
_logger.LogDebug(
"Stored claim attestation for RunId {RunId}, TurnId {TurnId}",
attestation.RunId,
attestation.TurnId);
return Task.CompletedTask;
}
}

View File

@@ -2,11 +2,9 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Attestation.Models;
using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace StellaOps.AdvisoryAI.Attestation.Storage;
@@ -15,7 +13,7 @@ namespace StellaOps.AdvisoryAI.Attestation.Storage;
/// Useful for testing and development.
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-006
/// </summary>
public sealed class InMemoryAiAttestationStore : IAiAttestationStore
public sealed partial class InMemoryAiAttestationStore : IAiAttestationStore
{
private readonly ConcurrentDictionary<string, AiRunAttestation> _runAttestations = new();
private readonly ConcurrentDictionary<string, object> _signedEnvelopes = new();
@@ -27,141 +25,4 @@ public sealed class InMemoryAiAttestationStore : IAiAttestationStore
{
_logger = logger;
}
/// <inheritdoc/>
public Task StoreRunAttestationAsync(AiRunAttestation attestation, CancellationToken ct)
{
_runAttestations[attestation.RunId] = attestation;
_logger.LogDebug("Stored run attestation for RunId {RunId}", attestation.RunId);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task StoreSignedEnvelopeAsync(string runId, object envelope, CancellationToken ct)
{
_signedEnvelopes[runId] = envelope;
_logger.LogDebug("Stored signed envelope for RunId {RunId}", runId);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task StoreClaimAttestationAsync(AiClaimAttestation attestation, CancellationToken ct)
{
var claims = _claimAttestations.GetOrAdd(attestation.RunId, _ => []);
lock (claims)
{
claims.Add(attestation);
}
_digestIndex[attestation.ContentDigest] = attestation;
_logger.LogDebug(
"Stored claim attestation for RunId {RunId}, TurnId {TurnId}",
attestation.RunId,
attestation.TurnId);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task<AiRunAttestation?> GetRunAttestationAsync(string runId, CancellationToken ct)
{
_runAttestations.TryGetValue(runId, out var attestation);
return Task.FromResult(attestation);
}
/// <inheritdoc/>
public Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsAsync(string runId, CancellationToken ct)
{
if (_claimAttestations.TryGetValue(runId, out var claims))
{
lock (claims)
{
return Task.FromResult(claims.ToImmutableArray());
}
}
return Task.FromResult(ImmutableArray<AiClaimAttestation>.Empty);
}
/// <inheritdoc/>
public Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsByTurnAsync(
string runId,
string turnId,
CancellationToken ct)
{
if (_claimAttestations.TryGetValue(runId, out var claims))
{
lock (claims)
{
var filtered = claims
.Where(c => c.TurnId == turnId)
.ToImmutableArray();
return Task.FromResult(filtered);
}
}
return Task.FromResult(ImmutableArray<AiClaimAttestation>.Empty);
}
/// <inheritdoc/>
public Task<object?> GetSignedEnvelopeAsync(string runId, CancellationToken ct)
{
_signedEnvelopes.TryGetValue(runId, out var envelope);
return Task.FromResult(envelope);
}
/// <inheritdoc/>
public Task<bool> ExistsAsync(string runId, CancellationToken ct)
{
return Task.FromResult(_runAttestations.ContainsKey(runId));
}
/// <inheritdoc/>
public Task<ImmutableArray<AiRunAttestation>> GetByTenantAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken ct)
{
var results = _runAttestations.Values
.Where(a => a.TenantId == tenantId &&
a.StartedAt >= from &&
a.StartedAt <= to)
.OrderBy(a => a.StartedAt)
.ToImmutableArray();
return Task.FromResult(results);
}
/// <inheritdoc/>
public Task<AiClaimAttestation?> GetByContentDigestAsync(string contentDigest, CancellationToken ct)
{
_digestIndex.TryGetValue(contentDigest, out var attestation);
return Task.FromResult(attestation);
}
/// <summary>
/// Clear all stored attestations. Useful for testing.
/// </summary>
public void Clear()
{
_runAttestations.Clear();
_signedEnvelopes.Clear();
_claimAttestations.Clear();
_digestIndex.Clear();
}
/// <summary>
/// Get count of run attestations. Useful for testing.
/// </summary>
public int RunAttestationCount => _runAttestations.Count;
/// <summary>
/// Get count of all claim attestations. Useful for testing.
/// </summary>
public int ClaimAttestationCount => _claimAttestations.Values.Sum(c =>
{
lock (c) { return c.Count; }
});
}

View File

@@ -4,5 +4,5 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/__Libraries/StellaOps.AdvisoryAI.Attestation/StellaOps.AdvisoryAI.Attestation.md. |
| REMED-05 | DONE | Split service/registry/models/store into <=100-line partials; `dotnet test src/__Libraries/__Tests/StellaOps.AdvisoryAI.Attestation.Tests/StellaOps.AdvisoryAI.Attestation.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` passed (58 tests) 2026-02-04. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |

View File

@@ -1,5 +1,5 @@
using StellaOps.AuditPack.Models;
using System.Collections.Immutable;
using StellaOps.AuditPack.Models;
namespace StellaOps.AuditPack.Services;

View File

@@ -1,5 +1,5 @@
using StellaOps.AuditPack.Models;
using System.Linq;
using StellaOps.AuditPack.Models;
namespace StellaOps.AuditPack.Services;

View File

@@ -1,5 +1,5 @@
using StellaOps.AuditPack.Models;
using System.Collections.Immutable;
using StellaOps.AuditPack.Models;
using AuditPackRecord = StellaOps.AuditPack.Models.AuditPack;
namespace StellaOps.AuditPack.Services;

View File

@@ -1,5 +1,5 @@
using StellaOps.AuditPack.Models;
using System.Collections.Immutable;
using StellaOps.AuditPack.Models;
namespace StellaOps.AuditPack.Services;

View File

@@ -1,5 +1,5 @@
using StellaOps.AuditPack.Models;
using System.Text;
using StellaOps.AuditPack.Models;
using AuditPackRecord = StellaOps.AuditPack.Models.AuditPack;
namespace StellaOps.AuditPack.Services;

View File

@@ -1,5 +1,5 @@
using StellaOps.AuditPack.Models;
using System.Text.Json;
using StellaOps.AuditPack.Models;
using AuditPackRecord = StellaOps.AuditPack.Models.AuditPack;
namespace StellaOps.AuditPack.Services;

View File

@@ -1,6 +1,6 @@
using StellaOps.AuditPack.Models;
using System.Security.Cryptography;
using System.Text.Json;
using StellaOps.AuditPack.Models;
namespace StellaOps.AuditPack.Services;

View File

@@ -1,6 +1,6 @@
using StellaOps.AuditPack.Models;
using System.Security.Cryptography;
using System.Text;
using StellaOps.AuditPack.Models;
namespace StellaOps.AuditPack.Services;

View File

@@ -1,5 +1,5 @@
using StellaOps.AuditPack.Models;
using System.Diagnostics;
using StellaOps.AuditPack.Models;
namespace StellaOps.AuditPack.Services;

View File

@@ -10,3 +10,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0044-A | TODO | Requires MAINT/TEST + approval. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-07 | DONE | CSProj remediation complete (ReplayExecutor, ReplayAttestationService, VerdictReplayPredicate split; ConfigureAwait added; ReplayExecutor/VerdictReplayPredicate tests added). |
| REMED-05 | DONE | Sorted System-first usings for AuditPack builders/importer/replay helpers; ArchiveUtilities extraction tests added; dotnet test passed 2026-02-04. |

View File

@@ -10,3 +10,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0045-A | TODO | Requires MAINT/TEST + approval. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-07 | DONE | DPoP remediation (validator/stores split <= 100 lines, private fields renamed, nonce store tests added). |
| REMED-05 | DONE | Added DpopValidationOptions unit coverage; dotnet test passed 2026-02-04. |

View File

@@ -0,0 +1,28 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.DependencyInjection;
internal sealed class CryptoComplianceOptionsConfiguration : IConfigureOptions<CryptoComplianceOptions>
{
private readonly IConfiguration? _configuration;
public CryptoComplianceOptionsConfiguration(IConfiguration? configuration = null)
{
_configuration = configuration;
}
public void Configure(CryptoComplianceOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (_configuration is null)
{
return;
}
_configuration.GetSection(CryptoComplianceOptions.SectionKey).Bind(options);
}
}

View File

@@ -0,0 +1,14 @@
using System;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.DependencyInjection;
internal sealed class CryptoComplianceOptionsPostConfigure : IPostConfigureOptions<CryptoComplianceOptions>
{
public void PostConfigure(string? name, CryptoComplianceOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.ApplyEnvironmentOverrides();
}
}

View File

@@ -0,0 +1,21 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.DependencyInjection;
internal static class CryptoComplianceOptionsRegistration
{
internal static void Register(IServiceCollection services, bool bindFromConfiguration)
{
services.AddOptions<CryptoComplianceOptions>();
if (bindFromConfiguration)
{
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<CryptoComplianceOptions>, CryptoComplianceOptionsConfiguration>());
}
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<CryptoComplianceOptions>, CryptoComplianceOptionsPostConfigure>());
}
}

View File

@@ -0,0 +1,10 @@
using StellaOps.Cryptography.Plugin.SimRemote;
using StellaOps.Cryptography.Plugin.SmRemote;
namespace StellaOps.Cryptography.DependencyInjection;
internal static class CryptoHttpClientNames
{
internal const string SimRemote = nameof(SimRemoteHttpClient);
internal const string SmRemote = nameof(SmRemoteHttpClient);
}

View File

@@ -0,0 +1,15 @@
using System;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography.PluginLoader;
namespace StellaOps.Cryptography.DependencyInjection;
internal sealed class CryptoPluginConfigurationOptions : IOptions<CryptoPluginConfiguration>
{
public CryptoPluginConfigurationOptions(CryptoPluginConfiguration configuration)
{
Value = configuration ?? throw new ArgumentNullException(nameof(configuration));
}
public CryptoPluginConfiguration Value { get; }
}

View File

@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Cryptography.PluginLoader;
namespace StellaOps.Cryptography.DependencyInjection;
internal sealed class CryptoPluginConfigurationRegistry : ICryptoProviderRegistry
{
private readonly CryptoProviderRegistry _registry;
public CryptoPluginConfigurationRegistry(
IReadOnlyList<ICryptoProvider> providers,
IOptions<CryptoPluginConfiguration> configuration,
ILogger<CryptoPluginConfigurationRegistry>? logger = null)
{
ArgumentNullException.ThrowIfNull(providers);
ArgumentNullException.ThrowIfNull(configuration);
var config = configuration.Value;
var preferredProviderNames = providers
.OrderByDescending(provider => GetProviderPriority(provider, config))
.Select(provider => provider.Name)
.ToList();
logger?.LogInformation(
"Loaded {Count} crypto provider(s) with preferred order: {Providers}",
providers.Count,
string.Join(", ", preferredProviderNames));
_registry = new CryptoProviderRegistry(providers, preferredProviderNames);
}
public IReadOnlyCollection<ICryptoProvider> Providers => _registry.Providers;
public bool TryResolve(string preferredProvider, out ICryptoProvider provider)
=> _registry.TryResolve(preferredProvider, out provider);
public ICryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId)
=> _registry.ResolveOrThrow(capability, algorithmId);
public CryptoSignerResolution ResolveSigner(
CryptoCapability capability,
string algorithmId,
CryptoKeyReference keyReference,
string? preferredProvider = null)
=> _registry.ResolveSigner(capability, algorithmId, keyReference, preferredProvider);
public CryptoHasherResolution ResolveHasher(string algorithmId, string? preferredProvider = null)
=> _registry.ResolveHasher(algorithmId, preferredProvider);
private static int GetProviderPriority(ICryptoProvider provider, CryptoPluginConfiguration config)
{
var enabledEntry = config.Enabled.FirstOrDefault(entry =>
entry.Id.Equals(provider.Name, StringComparison.OrdinalIgnoreCase));
return enabledEntry?.Priority ?? 50;
}
}

View File

@@ -0,0 +1,11 @@
namespace StellaOps.Cryptography.DependencyInjection;
internal sealed class CryptoPluginDirectoryOptions
{
public CryptoPluginDirectoryOptions(string? directory)
{
Directory = directory;
}
public string? Directory { get; }
}

View File

@@ -0,0 +1,19 @@
using System;
using System.Collections;
using System.Collections.Generic;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.DependencyInjection;
internal sealed class CryptoPluginProviderEnumerable : IEnumerable<ICryptoProvider>
{
private readonly IReadOnlyList<ICryptoProvider> _providers;
public CryptoPluginProviderEnumerable(IReadOnlyList<ICryptoProvider> providers)
{
_providers = providers ?? throw new ArgumentNullException(nameof(providers));
}
public IEnumerator<ICryptoProvider> GetEnumerator() => _providers.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

View File

@@ -0,0 +1,44 @@
using System;
using System.Collections;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Cryptography.PluginLoader;
namespace StellaOps.Cryptography.DependencyInjection;
internal sealed class CryptoPluginProviderList : IReadOnlyList<ICryptoProvider>
{
private readonly IReadOnlyList<ICryptoProvider> _providers;
public CryptoPluginProviderList(
IOptions<CryptoPluginConfiguration> configuration,
ILogger<CryptoPluginLoader>? logger = null,
CryptoPluginDirectoryOptions? directory = null)
{
ArgumentNullException.ThrowIfNull(configuration);
var loader = new CryptoPluginLoader(configuration.Value, logger, directory?.Directory);
try
{
_providers = loader.LoadProviders();
}
catch (CryptoPluginLoadException ex)
{
logger?.LogCritical(ex, "Failed to load crypto plugins: {Message}", ex.Message);
throw;
}
if (_providers.Count == 0)
{
throw new InvalidOperationException(
"No crypto providers were loaded. Check plugin configuration and manifest.");
}
}
public int Count => _providers.Count;
public ICryptoProvider this[int index] => _providers[index];
public IEnumerator<ICryptoProvider> GetEnumerator() => _providers.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

View File

@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.DependencyInjection;
internal sealed class CryptoPluginProviderRegistry : ICryptoProviderRegistry
{
private readonly CryptoProviderRegistry _registry;
public CryptoPluginProviderRegistry(
IReadOnlyList<ICryptoProvider> providers,
IOptionsMonitor<CryptoProviderRegistryOptions>? optionsMonitor = null)
{
ArgumentNullException.ThrowIfNull(providers);
var preferred = optionsMonitor?.CurrentValue?.ResolvePreferredProviders();
_registry = new CryptoProviderRegistry(providers, preferred);
}
public IReadOnlyCollection<ICryptoProvider> Providers => _registry.Providers;
public bool TryResolve(string preferredProvider, out ICryptoProvider provider)
=> _registry.TryResolve(preferredProvider, out provider);
public ICryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId)
=> _registry.ResolveOrThrow(capability, algorithmId);
public CryptoSignerResolution ResolveSigner(
CryptoCapability capability,
string algorithmId,
CryptoKeyReference keyReference,
string? preferredProvider = null)
=> _registry.ResolveSigner(capability, algorithmId, keyReference, preferredProvider);
public CryptoHasherResolution ResolveHasher(string algorithmId, string? preferredProvider = null)
=> _registry.ResolveHasher(algorithmId, preferredProvider);
}

View File

@@ -0,0 +1,37 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cryptography;
using StellaOps.Cryptography.PluginLoader;
namespace StellaOps.Cryptography.DependencyInjection;
public static partial class CryptoPluginServiceCollectionExtensions
{
/// <summary>
/// Registers crypto providers with plugin loading and compliance profile configuration.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Application configuration.</param>
/// <param name="configurePlugins">Optional plugin configuration.</param>
/// <param name="configureCompliance">Optional compliance configuration.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddStellaOpsCryptoWithPluginsAndCompliance(
this IServiceCollection services,
IConfiguration configuration,
Action<CryptoPluginConfiguration>? configurePlugins = null,
Action<CryptoComplianceOptions>? configureCompliance = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.AddStellaOpsCryptoWithPlugins(configuration, configurePlugins);
services.Configure<CryptoComplianceOptions>(options =>
{
configuration.GetSection(CryptoComplianceOptions.SectionKey).Bind(options);
configureCompliance?.Invoke(options);
});
return services;
}
}

View File

@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Cryptography.PluginLoader;
@@ -11,7 +11,7 @@ namespace StellaOps.Cryptography.DependencyInjection;
/// <summary>
/// DI extension methods for configuration-driven crypto plugin loading.
/// </summary>
public static class CryptoPluginServiceCollectionExtensions
public static partial class CryptoPluginServiceCollectionExtensions
{
/// <summary>
/// Registers crypto providers using configuration-driven plugin loading.
@@ -29,112 +29,25 @@ public static class CryptoPluginServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// Bind plugin configuration from appsettings
CryptoComplianceOptionsRegistration.Register(services, bindFromConfiguration: false);
services.Configure<CryptoComplianceOptions>(options =>
{
configuration.GetSection(CryptoComplianceOptions.SectionKey).Bind(options);
});
services.Configure<CryptoPluginConfiguration>(options =>
{
configuration.GetSection("StellaOps:Crypto:Plugins").Bind(options);
configurePlugins?.Invoke(options);
});
// Register compliance options (reuse existing code)
services.TryAddSingleton<IOptionsMonitor<CryptoComplianceOptions>>(sp =>
{
var config = sp.GetService<IConfiguration>();
var options = new CryptoComplianceOptions();
config?.GetSection(CryptoComplianceOptions.SectionKey).Bind(options);
options.ApplyEnvironmentOverrides();
return new StaticComplianceOptionsMonitor(options);
});
services.TryAddSingleton<ICryptoComplianceService, CryptoComplianceService>();
services.TryAddSingleton<ICryptoHash, DefaultCryptoHash>();
services.TryAddSingleton<ICryptoHmac, DefaultCryptoHmac>();
// Register plugin loader and load providers dynamically
services.TryAddSingleton<CryptoPluginLoader>(sp =>
{
var pluginConfig = sp.GetRequiredService<IOptions<CryptoPluginConfiguration>>().Value;
var logger = sp.GetService<ILogger<CryptoPluginLoader>>();
return new CryptoPluginLoader(pluginConfig, logger);
});
// Load all configured crypto providers
services.TryAddSingleton(sp =>
{
var loader = sp.GetRequiredService<CryptoPluginLoader>();
return loader.LoadProviders();
});
// Register each loaded provider as ICryptoProvider
services.TryAddSingleton<IEnumerable<ICryptoProvider>>(sp =>
{
return sp.GetRequiredService<IReadOnlyList<ICryptoProvider>>();
});
// Register crypto provider registry with loaded providers
services.TryAddSingleton<ICryptoProviderRegistry>(sp =>
{
var providers = sp.GetRequiredService<IReadOnlyList<ICryptoProvider>>();
var options = sp.GetService<IOptionsMonitor<CryptoProviderRegistryOptions>>();
IEnumerable<string>? preferred = options?.CurrentValue?.ResolvePreferredProviders();
return new CryptoProviderRegistry(providers, preferred);
});
services.TryAddSingleton<IReadOnlyList<ICryptoProvider>, CryptoPluginProviderList>();
services.TryAddSingleton<IEnumerable<ICryptoProvider>, CryptoPluginProviderEnumerable>();
services.TryAddSingleton<ICryptoProviderRegistry, CryptoPluginProviderRegistry>();
return services;
}
/// <summary>
/// Registers crypto providers with plugin loading and compliance profile configuration.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Application configuration.</param>
/// <param name="configurePlugins">Optional plugin configuration.</param>
/// <param name="configureCompliance">Optional compliance configuration.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddStellaOpsCryptoWithPluginsAndCompliance(
this IServiceCollection services,
IConfiguration configuration,
Action<CryptoPluginConfiguration>? configurePlugins = null,
Action<CryptoComplianceOptions>? configureCompliance = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// Bind compliance options from configuration
services.Configure<CryptoComplianceOptions>(options =>
{
configuration.GetSection(CryptoComplianceOptions.SectionKey).Bind(options);
configureCompliance?.Invoke(options);
options.ApplyEnvironmentOverrides();
});
// Register base crypto services with plugin loading
services.AddStellaOpsCryptoWithPlugins(configuration, configurePlugins);
return services;
}
/// <summary>
/// Helper class for static options monitoring.
/// </summary>
private sealed class StaticComplianceOptionsMonitor : IOptionsMonitor<CryptoComplianceOptions>
{
private readonly CryptoComplianceOptions _options;
public StaticComplianceOptionsMonitor(CryptoComplianceOptions options)
=> _options = options;
public CryptoComplianceOptions CurrentValue => _options;
public CryptoComplianceOptions Get(string? name) => _options;
public IDisposable OnChange(Action<CryptoComplianceOptions, string> listener)
=> NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose() { }
}
}
}

View File

@@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace StellaOps.Cryptography.DependencyInjection;
public sealed class CryptoProviderProfileOptions
{
/// <summary>
/// Ordered list of preferred provider names for the profile.
/// </summary>
public IList<string> PreferredProviders { get; } = new List<string>();
}

View File

@@ -74,11 +74,3 @@ public sealed class CryptoProviderRegistryOptions
return Array.Empty<string>();
}
}
public sealed class CryptoProviderProfileOptions
{
/// <summary>
/// Ordered list of preferred provider names for the profile.
/// </summary>
public IList<string> PreferredProviders { get; } = new List<string>();
}

View File

@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Cryptography.DependencyInjection;
public static partial class CryptoProviderRegistryValidator
{
private static bool GetEnvFlag(string name, bool defaultValue)
{
var raw = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrWhiteSpace(raw))
{
return defaultValue;
}
return raw.Equals("1", StringComparison.OrdinalIgnoreCase) ||
raw.Equals("true", StringComparison.OrdinalIgnoreCase) ||
raw.Equals("yes", StringComparison.OrdinalIgnoreCase);
}
private static void EnsureBaselineProfiles(CryptoProviderRegistryOptions options)
{
if (!options.PreferredProviders.Any())
{
options.PreferredProviders.Add("default");
}
if (!options.Profiles.TryGetValue("ru-offline", out var ruOffline))
{
ruOffline = new CryptoProviderProfileOptions();
options.Profiles["ru-offline"] = ruOffline;
}
if (!options.Profiles.ContainsKey("ru-linux-soft"))
{
options.Profiles["ru-linux-soft"] = new CryptoProviderProfileOptions();
}
}
private static void EnsureDefaultPreferred(
IList<string> providers,
bool enableOpenSsl,
bool enablePkcs11,
bool enableWineCsp
#if STELLAOPS_CRYPTO_PRO
, bool enableCryptoPro
#endif
)
{
InsertIfMissing(providers, "default");
if (enableOpenSsl)
{
InsertIfMissing(providers, "ru.openssl.gost");
}
if (enablePkcs11)
{
InsertIfMissing(providers, "ru.pkcs11");
}
if (enableWineCsp)
{
InsertIfMissing(providers, "ru.winecsp.http");
}
#if STELLAOPS_CRYPTO_PRO
if (enableCryptoPro && OperatingSystem.IsWindows())
{
InsertIfMissing(providers, "ru.cryptopro.csp");
}
#endif
}
private static void InsertIfMissing(IList<string> providers, string name)
{
for (var i = 0; i < providers.Count; i++)
{
if (string.Equals(providers[i], name, StringComparison.OrdinalIgnoreCase))
{
return;
}
}
providers.Insert(0, name);
}
}

View File

@@ -7,9 +7,9 @@ namespace StellaOps.Cryptography.DependencyInjection;
/// <summary>
/// Validates and normalises crypto provider registry options for RU/GOST baselines.
/// </summary>
public static class CryptoProviderRegistryValidator
public static partial class CryptoProviderRegistryValidator
{
private static readonly StringComparer OrdinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
private static readonly StringComparer _ordinalIgnoreCase = StringComparer.OrdinalIgnoreCase;
public static void EnforceRuLinuxDefaults(CryptoProviderRegistryOptions options)
{
@@ -50,7 +50,7 @@ public static class CryptoProviderRegistryValidator
}
if (OperatingSystem.IsLinux() && enableOpenSsl &&
!resolved.Contains("ru.openssl.gost", OrdinalIgnoreCase))
!resolved.Contains("ru.openssl.gost", _ordinalIgnoreCase))
{
throw new InvalidOperationException("Linux RU baseline requires provider 'ru.openssl.gost' (set STELLAOPS_CRYPTO_ENABLE_RU_OPENSSL=0 to override explicitly).");
}
@@ -60,84 +60,4 @@ public static class CryptoProviderRegistryValidator
throw new InvalidOperationException("RU Linux baseline is misconfigured: both ru.openssl.gost and ru.pkcs11 are disabled via environment. Enable at least one provider.");
}
}
private static bool GetEnvFlag(string name, bool defaultValue)
{
var raw = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrWhiteSpace(raw))
{
return defaultValue;
}
return raw.Equals("1", StringComparison.OrdinalIgnoreCase) ||
raw.Equals("true", StringComparison.OrdinalIgnoreCase) ||
raw.Equals("yes", StringComparison.OrdinalIgnoreCase);
}
private static void EnsureBaselineProfiles(CryptoProviderRegistryOptions options)
{
if (!options.PreferredProviders.Any())
{
options.PreferredProviders.Add("default");
}
if (!options.Profiles.TryGetValue("ru-offline", out var ruOffline))
{
ruOffline = new CryptoProviderProfileOptions();
options.Profiles["ru-offline"] = ruOffline;
}
if (!options.Profiles.ContainsKey("ru-linux-soft"))
{
options.Profiles["ru-linux-soft"] = new CryptoProviderProfileOptions();
}
}
private static void EnsureDefaultPreferred(
IList<string> providers,
bool enableOpenSsl,
bool enablePkcs11,
bool enableWineCsp
#if STELLAOPS_CRYPTO_PRO
, bool enableCryptoPro
#endif
)
{
InsertIfMissing(providers, "default");
if (enableOpenSsl)
{
InsertIfMissing(providers, "ru.openssl.gost");
}
if (enablePkcs11)
{
InsertIfMissing(providers, "ru.pkcs11");
}
if (enableWineCsp)
{
InsertIfMissing(providers, "ru.winecsp.http");
}
#if STELLAOPS_CRYPTO_PRO
if (enableCryptoPro && OperatingSystem.IsWindows())
{
InsertIfMissing(providers, "ru.cryptopro.csp");
}
#endif
}
private static void InsertIfMissing(IList<string> providers, string name)
{
for (var i = 0; i < providers.Count; i++)
{
if (string.Equals(providers[i], name, StringComparison.OrdinalIgnoreCase))
{
return;
}
}
providers.Insert(0, name);
}
}

View File

@@ -0,0 +1,35 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.DependencyInjection;
public static partial class CryptoServiceCollectionExtensions
{
/// <summary>
/// Registers crypto services with compliance profile configuration.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <param name="configureCompliance">Optional compliance configuration.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddStellaOpsCryptoWithCompliance(
this IServiceCollection services,
IConfiguration configuration,
Action<CryptoComplianceOptions>? configureCompliance = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.AddStellaOpsCrypto();
services.Configure<CryptoComplianceOptions>(options =>
{
configuration.GetSection(CryptoComplianceOptions.SectionKey).Bind(options);
configureCompliance?.Invoke(options);
});
return services;
}
}

View File

@@ -0,0 +1,37 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Plugin.PqSoft;
using StellaOps.Cryptography.Plugin.SmRemote;
using StellaOps.Cryptography.Plugin.SmSoft;
namespace StellaOps.Cryptography.DependencyInjection;
public static partial class CryptoServiceCollectionExtensions
{
private static void RegisterDefaultProviders(
IServiceCollection services,
Action<DefaultCryptoProvider>? configureProvider)
{
var defaultProvider = new DefaultCryptoProvider();
configureProvider?.Invoke(defaultProvider);
services.TryAddSingleton(defaultProvider);
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider>(defaultProvider));
#if STELLAOPS_CRYPTO_SODIUM
services.TryAddSingleton<LibsodiumCryptoProvider>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, LibsodiumCryptoProvider>());
#endif
services.TryAddSingleton<ICryptoHash, DefaultCryptoHash>();
services.TryAddSingleton<ICryptoHmac, DefaultCryptoHmac>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, SmSoftCryptoProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, SmRemoteHttpProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, PqSoftCryptoProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, FipsSoftCryptoProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, EidasSoftCryptoProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, KcmvpHashOnlyProvider>());
}
}

View File

@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Cryptography.PluginLoader;
namespace StellaOps.Cryptography.DependencyInjection;
public static partial class CryptoServiceCollectionExtensions
{
/// <summary>
/// Registers crypto services using configuration-driven plugin loading.
/// This is the recommended method for production deployments with regional compliance requirements.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <param name="pluginDirectory">Optional custom plugin directory path. Defaults to application base directory.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddStellaOpsCryptoFromConfiguration(
this IServiceCollection services,
IConfiguration configuration,
string? pluginDirectory = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var pluginConfig = new CryptoPluginConfiguration();
configuration.GetSection("StellaOps:Crypto:Plugins").Bind(pluginConfig);
var complianceConfig = new CryptoComplianceConfiguration();
configuration.GetSection("StellaOps:Crypto:Compliance").Bind(complianceConfig);
pluginConfig.Compliance = complianceConfig;
services.AddSingleton(pluginConfig);
services.TryAddSingleton<IOptions<CryptoPluginConfiguration>, CryptoPluginConfigurationOptions>();
services.TryAddSingleton(new CryptoPluginDirectoryOptions(pluginDirectory));
CryptoComplianceOptionsRegistration.Register(services, bindFromConfiguration: false);
services.Configure<CryptoComplianceOptions>(options =>
{
configuration.GetSection(CryptoComplianceOptions.SectionKey).Bind(options);
});
services.TryAddSingleton<ICryptoComplianceService, CryptoComplianceService>();
services.TryAddSingleton<IReadOnlyList<ICryptoProvider>, CryptoPluginProviderList>();
services.TryAddSingleton<IEnumerable<ICryptoProvider>, CryptoPluginProviderEnumerable>();
services.TryAddSingleton<ICryptoProviderRegistry, CryptoPluginConfigurationRegistry>();
return services;
}
/// <summary>
/// Registers crypto services using configuration-driven plugin loading with explicit compliance profile.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <param name="complianceProfileId">Compliance profile identifier (e.g., "gost", "fips", "eidas", "sm").</param>
/// <param name="strictValidation">Enable strict compliance validation.</param>
/// <param name="pluginDirectory">Optional custom plugin directory path.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddStellaOpsCryptoFromConfiguration(
this IServiceCollection services,
IConfiguration configuration,
string complianceProfileId,
bool strictValidation = true,
string? pluginDirectory = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(complianceProfileId);
services.AddStellaOpsCryptoFromConfiguration(configuration, pluginDirectory);
services.Configure<CryptoComplianceOptions>(options =>
{
options.ProfileId = complianceProfileId;
options.StrictValidation = strictValidation;
});
return services;
}
}

View File

@@ -0,0 +1,13 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.DependencyInjection;
public static partial class CryptoServiceCollectionExtensions
{
private static void RegisterRegistry(IServiceCollection services)
{
services.TryAddSingleton<ICryptoProviderRegistry, ServiceRegisteredCryptoProviderRegistry>();
}
}

View File

@@ -0,0 +1,48 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cryptography.Plugin.OpenSslGost;
using StellaOps.Cryptography.Plugin.Pkcs11Gost;
using StellaOps.Cryptography.Plugin.WineCsp;
#if STELLAOPS_CRYPTO_PRO
using StellaOps.Cryptography.Plugin.CryptoPro;
#endif
namespace StellaOps.Cryptography.DependencyInjection;
public static partial class CryptoServiceCollectionExtensions
{
public static IServiceCollection AddStellaOpsCryptoRu(
this IServiceCollection services,
IConfiguration configuration,
Action<CryptoProviderRegistryOptions>? configureRegistry = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var baseSection = configuration.GetSection("StellaOps:Crypto");
services.Configure<StellaOpsCryptoOptions>(baseSection);
services.Configure<CryptoProviderRegistryOptions>(baseSection.GetSection("Registry"));
#if STELLAOPS_CRYPTO_PRO
services.Configure<CryptoProGostProviderOptions>(baseSection.GetSection("CryptoPro"));
#endif
services.Configure<Pkcs11GostProviderOptions>(baseSection.GetSection("Pkcs11"));
services.Configure<OpenSslGostProviderOptions>(baseSection.GetSection("OpenSsl"));
services.Configure<WineCspProviderOptions>(baseSection.GetSection("WineCsp"));
services.AddStellaOpsCrypto(configureRegistry);
services.AddOpenSslGostProvider();
services.AddPkcs11GostProvider();
services.AddWineCspProvider();
#if STELLAOPS_CRYPTO_PRO
if (OperatingSystem.IsWindows())
{
services.AddCryptoProGostProvider();
}
#endif
services.PostConfigure<CryptoProviderRegistryOptions>(CryptoProviderRegistryValidator.EnforceRuLinuxDefaults);
return services;
}
}

View File

@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Plugin.SimRemote;
namespace StellaOps.Cryptography.DependencyInjection;
public static partial class CryptoServiceCollectionExtensions
{
private static void RegisterSimRemote(IServiceCollection services)
{
services.AddOptions<SimRemoteProviderOptions>();
services.AddHttpClient<SimRemoteHttpClient>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<SimRemoteProviderOptions>, SimRemoteProviderOptionsConfiguration>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<SimRemoteProviderOptions>, SimRemoteProviderOptionsPostConfigure>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<HttpClientFactoryOptions>, SimRemoteHttpClientOptionsConfiguration>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, SimRemoteProvider>());
}
private static void RegisterSimRemoteRegistryProfile(IServiceCollection services)
{
services.PostConfigure<CryptoProviderRegistryOptions>(options =>
{
var enableSimEnv = Environment.GetEnvironmentVariable("STELLAOPS_CRYPTO_ENABLE_SIM");
var enableSim = string.Equals(enableSimEnv, "1", StringComparison.OrdinalIgnoreCase) ||
string.Equals(enableSimEnv, "true", StringComparison.OrdinalIgnoreCase);
if (!enableSim)
{
return;
}
void AddIfMissing(IList<string> list, string provider)
{
if (!list.Contains(provider, StringComparer.OrdinalIgnoreCase))
{
list.Add(provider);
}
}
if (!string.IsNullOrWhiteSpace(options.ActiveProfile) &&
options.Profiles.TryGetValue(options.ActiveProfile, out var profile))
{
AddIfMissing(profile.PreferredProviders, "sim.crypto.remote");
}
else
{
AddIfMissing(options.PreferredProviders, "sim.crypto.remote");
}
});
}
}

View File

@@ -0,0 +1,17 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography.Plugin.SmRemote;
namespace StellaOps.Cryptography.DependencyInjection;
public static partial class CryptoServiceCollectionExtensions
{
private static void RegisterSmRemote(IServiceCollection services)
{
services.AddOptions<SmRemoteProviderOptions>();
services.AddHttpClient<SmRemoteHttpClient>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<HttpClientFactoryOptions>, SmRemoteHttpClientOptionsConfiguration>());
}
}

View File

@@ -1,29 +1,13 @@
using StellaOps.Cryptography.Plugin.SimRemote;
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Cryptography.PluginLoader;
#if STELLAOPS_CRYPTO_PRO
using StellaOps.Cryptography.Plugin.CryptoPro;
#endif
using StellaOps.Cryptography.Plugin.Pkcs11Gost;
using StellaOps.Cryptography.Plugin.OpenSslGost;
using StellaOps.Cryptography.Plugin.SmRemote;
using StellaOps.Cryptography.Plugin.SmSoft;
using StellaOps.Cryptography.Plugin.PqSoft;
using StellaOps.Cryptography.Plugin.WineCsp;
using Microsoft.Extensions.Logging;
namespace StellaOps.Cryptography.DependencyInjection;
/// <summary>
/// Dependency injection helpers for registering StellaOps cryptography services.
/// </summary>
public static class CryptoServiceCollectionExtensions
public static partial class CryptoServiceCollectionExtensions
{
/// <summary>
/// Registers the default crypto provider and registry.
@@ -39,328 +23,19 @@ public static class CryptoServiceCollectionExtensions
{
ArgumentNullException.ThrowIfNull(services);
CryptoComplianceOptionsRegistration.Register(services, bindFromConfiguration: true);
if (configureRegistry is not null)
{
services.Configure(configureRegistry);
}
// Register compliance options with default profile
services.TryAddSingleton<IOptionsMonitor<CryptoComplianceOptions>>(sp =>
{
var configuration = sp.GetService<IConfiguration>();
var options = new CryptoComplianceOptions();
configuration?.GetSection(CryptoComplianceOptions.SectionKey).Bind(options);
options.ApplyEnvironmentOverrides();
return new StaticComplianceOptionsMonitor(options);
});
// Register compliance service
services.TryAddSingleton<ICryptoComplianceService, CryptoComplianceService>();
services.TryAddSingleton<DefaultCryptoProvider>(sp =>
{
var provider = new DefaultCryptoProvider();
configureProvider?.Invoke(provider);
return provider;
});
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, DefaultCryptoProvider>());
#if STELLAOPS_CRYPTO_SODIUM
services.TryAddSingleton<LibsodiumCryptoProvider>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, LibsodiumCryptoProvider>());
#endif
services.TryAddSingleton<ICryptoHash, DefaultCryptoHash>();
services.TryAddSingleton<ICryptoHmac, DefaultCryptoHmac>();
services.AddOptions<SmRemoteProviderOptions>();
services.AddHttpClient<SmRemoteHttpClient>((sp, httpClient) =>
{
var opts = sp.GetService<IOptions<SmRemoteProviderOptions>>()?.Value;
if (opts is not null && !string.IsNullOrWhiteSpace(opts.BaseAddress))
{
httpClient.BaseAddress = new Uri(opts.BaseAddress);
}
});
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, SmSoftCryptoProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, StellaOps.Cryptography.Plugin.SmRemote.SmRemoteHttpProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, PqSoftCryptoProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, FipsSoftCryptoProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, EidasSoftCryptoProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, KcmvpHashOnlyProvider>());
// Unified simulation provider (sim-crypto-service)
services.AddOptions<SimRemoteProviderOptions>()
.Configure<IConfiguration>((opts, config) =>
{
config?.GetSection("StellaOps:Crypto:Sim").Bind(opts);
})
.PostConfigure(opts =>
{
var simUrl = Environment.GetEnvironmentVariable("STELLAOPS_CRYPTO_SIM_URL");
if (!string.IsNullOrWhiteSpace(simUrl))
{
opts.BaseAddress = simUrl;
}
});
services.AddHttpClient<SimRemoteHttpClient>((sp, httpClient) =>
{
var opts = sp.GetService<IOptions<SimRemoteProviderOptions>>()?.Value;
if (opts is not null && !string.IsNullOrWhiteSpace(opts.BaseAddress))
{
httpClient.BaseAddress = new Uri(opts.BaseAddress);
}
});
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, SimRemoteProvider>());
services.PostConfigure<CryptoProviderRegistryOptions>(opts =>
{
var enableSimEnv = Environment.GetEnvironmentVariable("STELLAOPS_CRYPTO_ENABLE_SIM");
var enableSim = string.Equals(enableSimEnv, "1", StringComparison.OrdinalIgnoreCase) ||
string.Equals(enableSimEnv, "true", StringComparison.OrdinalIgnoreCase);
if (!enableSim)
{
return;
}
void AddIfMissing(IList<string> list, string provider)
{
if (!list.Contains(provider, StringComparer.OrdinalIgnoreCase))
{
list.Add(provider);
}
}
if (!string.IsNullOrWhiteSpace(opts.ActiveProfile) &&
opts.Profiles.TryGetValue(opts.ActiveProfile, out var profile))
{
AddIfMissing(profile.PreferredProviders, "sim.crypto.remote");
}
else
{
AddIfMissing(opts.PreferredProviders, "sim.crypto.remote");
}
});
services.TryAddSingleton<ICryptoProviderRegistry>(sp =>
{
var providers = sp.GetServices<ICryptoProvider>();
var options = sp.GetService<IOptionsMonitor<CryptoProviderRegistryOptions>>();
IEnumerable<string>? preferred = options?.CurrentValue?.ResolvePreferredProviders();
return new CryptoProviderRegistry(providers, preferred);
});
RegisterDefaultProviders(services, configureProvider);
RegisterSmRemote(services);
RegisterSimRemote(services);
RegisterSimRemoteRegistryProfile(services);
RegisterRegistry(services);
return services;
}
/// <summary>
/// Registers crypto services with compliance profile configuration.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <param name="configureCompliance">Optional compliance configuration.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddStellaOpsCryptoWithCompliance(
this IServiceCollection services,
IConfiguration configuration,
Action<CryptoComplianceOptions>? configureCompliance = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// Bind compliance options from configuration
services.Configure<CryptoComplianceOptions>(options =>
{
configuration.GetSection(CryptoComplianceOptions.SectionKey).Bind(options);
configureCompliance?.Invoke(options);
options.ApplyEnvironmentOverrides();
});
// Register compliance service with options monitor
services.TryAddSingleton<ICryptoComplianceService, CryptoComplianceService>();
// Register base crypto services
services.AddStellaOpsCrypto();
return services;
}
/// <summary>
/// Helper class for static options monitoring.
/// </summary>
private sealed class StaticComplianceOptionsMonitor : IOptionsMonitor<CryptoComplianceOptions>
{
private readonly CryptoComplianceOptions _options;
public StaticComplianceOptionsMonitor(CryptoComplianceOptions options)
=> _options = options;
public CryptoComplianceOptions CurrentValue => _options;
public CryptoComplianceOptions Get(string? name) => _options;
public IDisposable OnChange(Action<CryptoComplianceOptions, string> listener)
=> NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose() { }
}
}
public static IServiceCollection AddStellaOpsCryptoRu(
this IServiceCollection services,
IConfiguration configuration,
Action<CryptoProviderRegistryOptions>? configureRegistry = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var baseSection = configuration.GetSection("StellaOps:Crypto");
services.Configure<StellaOpsCryptoOptions>(baseSection);
services.Configure<CryptoProviderRegistryOptions>(baseSection.GetSection("Registry"));
#if STELLAOPS_CRYPTO_PRO
services.Configure<CryptoProGostProviderOptions>(baseSection.GetSection("CryptoPro"));
#endif
services.Configure<Pkcs11GostProviderOptions>(baseSection.GetSection("Pkcs11"));
services.Configure<OpenSslGostProviderOptions>(baseSection.GetSection("OpenSsl"));
services.Configure<WineCspProviderOptions>(baseSection.GetSection("WineCsp"));
services.AddStellaOpsCrypto(configureRegistry);
services.AddOpenSslGostProvider();
services.AddPkcs11GostProvider();
services.AddWineCspProvider();
#if STELLAOPS_CRYPTO_PRO
if (OperatingSystem.IsWindows())
{
services.AddCryptoProGostProvider();
}
#endif
services.PostConfigure<CryptoProviderRegistryOptions>(CryptoProviderRegistryValidator.EnforceRuLinuxDefaults);
return services;
}
/// <summary>
/// Registers crypto services using configuration-driven plugin loading.
/// This is the recommended method for production deployments with regional compliance requirements.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <param name="pluginDirectory">Optional custom plugin directory path. Defaults to application base directory.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddStellaOpsCryptoFromConfiguration(
this IServiceCollection services,
IConfiguration configuration,
string? pluginDirectory = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// Bind plugin configuration from appsettings
var pluginConfig = new CryptoPluginConfiguration();
configuration.GetSection("StellaOps:Crypto:Plugins").Bind(pluginConfig);
// Bind compliance configuration
var complianceConfig = new CryptoComplianceConfiguration();
configuration.GetSection("StellaOps:Crypto:Compliance").Bind(complianceConfig);
pluginConfig.Compliance = complianceConfig;
// Register plugin configuration as singleton
services.AddSingleton(pluginConfig);
// Register compliance options with configuration binding
services.Configure<CryptoComplianceOptions>(options =>
{
configuration.GetSection(CryptoComplianceOptions.SectionKey).Bind(options);
options.ApplyEnvironmentOverrides();
});
// Register compliance service
services.TryAddSingleton<ICryptoComplianceService, CryptoComplianceService>();
// Load crypto providers using plugin loader
services.TryAddSingleton<ICryptoProviderRegistry>(sp =>
{
var logger = sp.GetService<ILoggerFactory>()?.CreateLogger<CryptoPluginLoader>();
var loader = new CryptoPluginLoader(pluginConfig, logger, pluginDirectory);
IReadOnlyList<ICryptoProvider> providers;
try
{
providers = loader.LoadProviders();
}
catch (CryptoPluginLoadException ex)
{
logger?.LogCritical(ex, "Failed to load crypto plugins: {Message}", ex.Message);
throw;
}
if (providers.Count == 0)
{
throw new InvalidOperationException(
"No crypto providers were loaded. Check plugin configuration and manifest.");
}
// Extract provider names for preferred ordering (uses priority from manifest/config)
var preferredProviderNames = providers
.OrderByDescending(p => GetProviderPriority(p, pluginConfig))
.Select(p => p.Name)
.ToList();
logger?.LogInformation(
"Loaded {Count} crypto provider(s) with preferred order: {Providers}",
providers.Count,
string.Join(", ", preferredProviderNames));
return new CryptoProviderRegistry(providers, preferredProviderNames);
});
return services;
}
/// <summary>
/// Registers crypto services using configuration-driven plugin loading with explicit compliance profile.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <param name="complianceProfileId">Compliance profile identifier (e.g., "gost", "fips", "eidas", "sm").</param>
/// <param name="strictValidation">Enable strict compliance validation.</param>
/// <param name="pluginDirectory">Optional custom plugin directory path.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddStellaOpsCryptoFromConfiguration(
this IServiceCollection services,
IConfiguration configuration,
string complianceProfileId,
bool strictValidation = true,
string? pluginDirectory = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(complianceProfileId);
// Override compliance configuration with explicit profile
services.Configure<CryptoComplianceOptions>(options =>
{
configuration.GetSection(CryptoComplianceOptions.SectionKey).Bind(options);
options.ProfileId = complianceProfileId;
options.StrictValidation = strictValidation;
options.ApplyEnvironmentOverrides();
});
return services.AddStellaOpsCryptoFromConfiguration(configuration, pluginDirectory);
}
private static int GetProviderPriority(ICryptoProvider provider, CryptoPluginConfiguration config)
{
// Check if priority was overridden in configuration
var enabledEntry = config.Enabled.FirstOrDefault(e =>
e.Id.Equals(provider.Name, StringComparison.OrdinalIgnoreCase));
return enabledEntry?.Priority ?? 50; // Default priority
}
}

View File

@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.DependencyInjection;
internal sealed class ServiceRegisteredCryptoProviderRegistry : ICryptoProviderRegistry
{
private readonly CryptoProviderRegistry _registry;
public ServiceRegisteredCryptoProviderRegistry(
IEnumerable<ICryptoProvider> providers,
IOptionsMonitor<CryptoProviderRegistryOptions>? optionsMonitor = null)
{
ArgumentNullException.ThrowIfNull(providers);
var preferred = optionsMonitor?.CurrentValue?.ResolvePreferredProviders();
_registry = new CryptoProviderRegistry(providers, preferred);
}
public IReadOnlyCollection<ICryptoProvider> Providers => _registry.Providers;
public bool TryResolve(string preferredProvider, out ICryptoProvider provider)
=> _registry.TryResolve(preferredProvider, out provider);
public ICryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId)
=> _registry.ResolveOrThrow(capability, algorithmId);
public CryptoSignerResolution ResolveSigner(
CryptoCapability capability,
string algorithmId,
CryptoKeyReference keyReference,
string? preferredProvider = null)
=> _registry.ResolveSigner(capability, algorithmId, keyReference, preferredProvider);
public CryptoHasherResolution ResolveHasher(string algorithmId, string? preferredProvider = null)
=> _registry.ResolveHasher(algorithmId, preferredProvider);
}

View File

@@ -0,0 +1,38 @@
using System;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography.Plugin.SimRemote;
namespace StellaOps.Cryptography.DependencyInjection;
internal sealed class SimRemoteHttpClientOptionsConfiguration : IConfigureNamedOptions<HttpClientFactoryOptions>
{
private readonly IOptionsMonitor<SimRemoteProviderOptions> _options;
public SimRemoteHttpClientOptionsConfiguration(IOptionsMonitor<SimRemoteProviderOptions> options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public void Configure(HttpClientFactoryOptions options)
{
Configure(Options.DefaultName, options);
}
public void Configure(string? name, HttpClientFactoryOptions options)
{
if (!string.Equals(name, CryptoHttpClientNames.SimRemote, StringComparison.Ordinal))
{
return;
}
options.HttpClientActions.Add(client =>
{
var current = _options.CurrentValue;
if (!string.IsNullOrWhiteSpace(current.BaseAddress))
{
client.BaseAddress = new Uri(current.BaseAddress);
}
});
}
}

View File

@@ -0,0 +1,28 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography.Plugin.SimRemote;
namespace StellaOps.Cryptography.DependencyInjection;
internal sealed class SimRemoteProviderOptionsConfiguration : IConfigureOptions<SimRemoteProviderOptions>
{
private readonly IConfiguration? _configuration;
public SimRemoteProviderOptionsConfiguration(IConfiguration? configuration = null)
{
_configuration = configuration;
}
public void Configure(SimRemoteProviderOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (_configuration is null)
{
return;
}
_configuration.GetSection("StellaOps:Crypto:Sim").Bind(options);
}
}

View File

@@ -0,0 +1,19 @@
using System;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography.Plugin.SimRemote;
namespace StellaOps.Cryptography.DependencyInjection;
internal sealed class SimRemoteProviderOptionsPostConfigure : IPostConfigureOptions<SimRemoteProviderOptions>
{
public void PostConfigure(string? name, SimRemoteProviderOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var simUrl = Environment.GetEnvironmentVariable("STELLAOPS_CRYPTO_SIM_URL");
if (!string.IsNullOrWhiteSpace(simUrl))
{
options.BaseAddress = simUrl;
}
}
}

View File

@@ -0,0 +1,38 @@
using System;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography.Plugin.SmRemote;
namespace StellaOps.Cryptography.DependencyInjection;
internal sealed class SmRemoteHttpClientOptionsConfiguration : IConfigureNamedOptions<HttpClientFactoryOptions>
{
private readonly IOptionsMonitor<SmRemoteProviderOptions> _options;
public SmRemoteHttpClientOptionsConfiguration(IOptionsMonitor<SmRemoteProviderOptions> options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public void Configure(HttpClientFactoryOptions options)
{
Configure(Options.DefaultName, options);
}
public void Configure(string? name, HttpClientFactoryOptions options)
{
if (!string.Equals(name, CryptoHttpClientNames.SmRemote, StringComparison.Ordinal))
{
return;
}
options.HttpClientActions.Add(client =>
{
var current = _options.CurrentValue;
if (!string.IsNullOrWhiteSpace(current.BaseAddress))
{
client.BaseAddress = new Uri(current.BaseAddress);
}
});
}
}

View File

@@ -1,8 +1,8 @@
using StellaOps.Cryptography.Plugin.OpenSslGost;
using StellaOps.Cryptography.Plugin.Pkcs11Gost;
#if STELLAOPS_CRYPTO_PRO
using StellaOps.Cryptography.Plugin.CryptoPro;
#endif
using StellaOps.Cryptography.Plugin.OpenSslGost;
using StellaOps.Cryptography.Plugin.Pkcs11Gost;
namespace StellaOps.Cryptography.DependencyInjection;

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0050-T | DONE | Revalidated 2026-01-08. |
| AUDIT-0050-A | TODO | Revalidated 2026-01-08 (open findings). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| REMED-05 | DONE | Removed service locator usage, split DI files/options/validator helpers, added DI ordering/plugin-loading tests; `dotnet test src/__Libraries/__Tests/StellaOps.Cryptography.Tests/StellaOps.Cryptography.Tests.csproj -p:BuildInParallel=false -p:UseSharedCompilation=false` passed (326 tests). |

View File

@@ -0,0 +1,36 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class AwsKmsClient
{
private async Task<AwsKeyMetadata> GetCachedMetadataAsync(string keyId, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (_metadataCache.TryGetValue(keyId, out var cached) && cached.ExpiresAt > now)
{
return cached.Metadata;
}
var metadata = await _facade.GetMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var entry = new CachedMetadata(metadata, now.Add(_metadataCacheDuration));
_metadataCache[keyId] = entry;
return metadata;
}
private async Task<AwsPublicKeyMaterial> GetCachedPublicKeyAsync(string resource, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (_publicKeyCache.TryGetValue(resource, out var cached) && cached.ExpiresAt > now)
{
return cached.Material;
}
var material = await _facade.GetPublicKeyAsync(resource, cancellationToken).ConfigureAwait(false);
var entry = new CachedPublicKey(material, now.Add(_publicKeyCacheDuration));
_publicKeyCache[resource] = entry;
return material;
}
}

View File

@@ -0,0 +1,68 @@
using Microsoft.IdentityModel.Tokens;
using System;
using System.Security.Cryptography;
namespace StellaOps.Cryptography.Kms;
public sealed partial class AwsKmsClient
{
private static byte[] ComputeSha256(ReadOnlyMemory<byte> data)
{
var digest = new byte[32];
if (!SHA256.TryHashData(data.Span, digest, out _))
{
throw new InvalidOperationException("Failed to hash payload with SHA-256.");
}
return digest;
}
private static string ResolveResource(string keyId, string? version)
=> string.IsNullOrWhiteSpace(version) ? keyId : version;
private static string ResolveCurveName(string curve)
{
if (string.Equals(curve, "ECC_NIST_P256", StringComparison.OrdinalIgnoreCase) ||
string.Equals(curve, "P-256", StringComparison.OrdinalIgnoreCase))
{
return JsonWebKeyECTypes.P256;
}
if (string.Equals(curve, "ECC_NIST_P384", StringComparison.OrdinalIgnoreCase) ||
string.Equals(curve, "P-384", StringComparison.OrdinalIgnoreCase))
{
return JsonWebKeyECTypes.P384;
}
if (string.Equals(curve, "ECC_NIST_P521", StringComparison.OrdinalIgnoreCase) ||
string.Equals(curve, "P-521", StringComparison.OrdinalIgnoreCase))
{
return JsonWebKeyECTypes.P521;
}
if (string.Equals(curve, "SECP256K1", StringComparison.OrdinalIgnoreCase) ||
string.Equals(curve, "ECC_SECG_P256K1", StringComparison.OrdinalIgnoreCase))
{
return "secp256k1";
}
return curve;
}
private static KmsKeyState MapState(AwsKeyStatus status)
=> status switch
{
AwsKeyStatus.Enabled => KmsKeyState.Active,
AwsKeyStatus.PendingImport or AwsKeyStatus.PendingUpdate => KmsKeyState.PendingRotation,
AwsKeyStatus.Disabled or AwsKeyStatus.PendingDeletion or AwsKeyStatus.Unavailable => KmsKeyState.Revoked,
_ => KmsKeyState.Active,
};
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(AwsKmsClient));
}
}
}

View File

@@ -0,0 +1,63 @@
using System;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class AwsKmsClient
{
public async Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var metadata = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var publicKey = await GetCachedPublicKeyAsync(metadata.KeyId, cancellationToken).ConfigureAwait(false);
var versionState = MapState(metadata.Status);
var versionMetadata = ImmutableArray.Create(
new KmsKeyVersionMetadata(
publicKey.VersionId,
versionState,
metadata.CreatedAt,
null,
Convert.ToBase64String(publicKey.SubjectPublicKeyInfo),
ResolveCurveName(publicKey.Curve)));
return new KmsKeyMetadata(
metadata.KeyId,
KmsAlgorithms.Es256,
versionState,
metadata.CreatedAt,
versionMetadata);
}
public async Task<KmsKeyMaterial> ExportAsync(
string keyId,
string? keyVersion,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var metadata = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var resource = ResolveResource(metadata.KeyId, keyVersion);
var publicKey = await GetCachedPublicKeyAsync(resource, cancellationToken).ConfigureAwait(false);
using var ecdsa = ECDsa.Create();
ecdsa.ImportSubjectPublicKeyInfo(publicKey.SubjectPublicKeyInfo, out _);
var parameters = ecdsa.ExportParameters(false);
return new KmsKeyMaterial(
metadata.KeyId,
publicKey.VersionId,
KmsAlgorithms.Es256,
ResolveCurveName(publicKey.Curve),
Array.Empty<byte>(),
parameters.Q.X ?? throw new InvalidOperationException("Public key missing X coordinate."),
parameters.Q.Y ?? throw new InvalidOperationException("Public key missing Y coordinate."),
metadata.CreatedAt);
}
}

View File

@@ -0,0 +1,10 @@
using System;
namespace StellaOps.Cryptography.Kms;
public sealed partial class AwsKmsClient
{
private sealed record CachedMetadata(AwsKeyMetadata Metadata, DateTimeOffset ExpiresAt);
private sealed record CachedPublicKey(AwsPublicKeyMaterial Material, DateTimeOffset ExpiresAt);
}

View File

@@ -0,0 +1,66 @@
using System;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class AwsKmsClient
{
public async Task<KmsSignResult> SignAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (data.IsEmpty)
{
throw new ArgumentException("Signing payload cannot be empty.", nameof(data));
}
var digest = ComputeSha256(data);
try
{
var resource = ResolveResource(keyId, keyVersion);
var result = await _facade.SignAsync(resource, digest, cancellationToken).ConfigureAwait(false);
return new KmsSignResult(
keyId,
string.IsNullOrWhiteSpace(result.VersionId) ? resource : result.VersionId,
KmsAlgorithms.Es256,
result.Signature);
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public async Task<bool> VerifyAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
ReadOnlyMemory<byte> signature,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (data.IsEmpty || signature.IsEmpty)
{
return false;
}
var digest = ComputeSha256(data);
try
{
var resource = ResolveResource(keyId, keyVersion);
return await _facade.VerifyAsync(resource, digest, signature, cancellationToken).ConfigureAwait(false);
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
}

View File

@@ -1,21 +1,19 @@
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
/// <summary>
/// AWS KMS implementation of <see cref="IKmsClient"/>.
/// </summary>
public sealed class AwsKmsClient : IKmsClient, IDisposable
public sealed partial class AwsKmsClient : IKmsClient, IDisposable
{
private readonly IAwsKmsFacade _facade;
private readonly TimeProvider _timeProvider;
private readonly TimeSpan _metadataCacheDuration;
private readonly TimeSpan _publicKeyCacheDuration;
private readonly ConcurrentDictionary<string, CachedMetadata> _metadataCache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, CachedPublicKey> _publicKeyCache = new(StringComparer.Ordinal);
private bool _disposed;
@@ -30,114 +28,9 @@ public sealed class AwsKmsClient : IKmsClient, IDisposable
_publicKeyCacheDuration = options.PublicKeyCacheDuration;
}
public async Task<KmsSignResult> SignAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken = default)
public AwsKmsClient(IAwsKmsFacade facade, IOptions<AwsKmsOptions> options, TimeProvider timeProvider)
: this(facade, options?.Value ?? new AwsKmsOptions(), timeProvider)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (data.IsEmpty)
{
throw new ArgumentException("Signing payload cannot be empty.", nameof(data));
}
var digest = ComputeSha256(data);
try
{
var resource = ResolveResource(keyId, keyVersion);
var result = await _facade.SignAsync(resource, digest, cancellationToken).ConfigureAwait(false);
return new KmsSignResult(
keyId,
string.IsNullOrWhiteSpace(result.VersionId) ? resource : result.VersionId,
KmsAlgorithms.Es256,
result.Signature);
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public async Task<bool> VerifyAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
ReadOnlyMemory<byte> signature,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (data.IsEmpty || signature.IsEmpty)
{
return false;
}
var digest = ComputeSha256(data);
try
{
var resource = ResolveResource(keyId, keyVersion);
return await _facade.VerifyAsync(resource, digest, signature, cancellationToken).ConfigureAwait(false);
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public async Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var metadata = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var publicKey = await GetCachedPublicKeyAsync(metadata.KeyId, cancellationToken).ConfigureAwait(false);
var versionState = MapState(metadata.Status);
var versionMetadata = ImmutableArray.Create(
new KmsKeyVersionMetadata(
publicKey.VersionId,
versionState,
metadata.CreatedAt,
null,
Convert.ToBase64String(publicKey.SubjectPublicKeyInfo),
ResolveCurveName(publicKey.Curve)));
return new KmsKeyMetadata(
metadata.KeyId,
KmsAlgorithms.Es256,
versionState,
metadata.CreatedAt,
versionMetadata);
}
public async Task<KmsKeyMaterial> ExportAsync(
string keyId,
string? keyVersion,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var metadata = await GetCachedMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var resource = ResolveResource(metadata.KeyId, keyVersion);
var publicKey = await GetCachedPublicKeyAsync(resource, cancellationToken).ConfigureAwait(false);
using var ecdsa = ECDsa.Create();
ecdsa.ImportSubjectPublicKeyInfo(publicKey.SubjectPublicKeyInfo, out _);
var parameters = ecdsa.ExportParameters(false);
return new KmsKeyMaterial(
metadata.KeyId,
publicKey.VersionId,
KmsAlgorithms.Es256,
ResolveCurveName(publicKey.Curve),
Array.Empty<byte>(),
parameters.Q.X ?? throw new InvalidOperationException("Public key missing X coordinate."),
parameters.Q.Y ?? throw new InvalidOperationException("Public key missing Y coordinate."),
metadata.CreatedAt);
}
public Task<KmsKeyMetadata> RotateAsync(string keyId, CancellationToken cancellationToken = default)
@@ -156,96 +49,4 @@ public sealed class AwsKmsClient : IKmsClient, IDisposable
_disposed = true;
_facade.Dispose();
}
private async Task<AwsKeyMetadata> GetCachedMetadataAsync(string keyId, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (_metadataCache.TryGetValue(keyId, out var cached) && cached.ExpiresAt > now)
{
return cached.Metadata;
}
var metadata = await _facade.GetMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
var entry = new CachedMetadata(metadata, now.Add(_metadataCacheDuration));
_metadataCache[keyId] = entry;
return metadata;
}
private async Task<AwsPublicKeyMaterial> GetCachedPublicKeyAsync(string resource, CancellationToken cancellationToken)
{
var now = _timeProvider.GetUtcNow();
if (_publicKeyCache.TryGetValue(resource, out var cached) && cached.ExpiresAt > now)
{
return cached.Material;
}
var material = await _facade.GetPublicKeyAsync(resource, cancellationToken).ConfigureAwait(false);
var entry = new CachedPublicKey(material, now.Add(_publicKeyCacheDuration));
_publicKeyCache[resource] = entry;
return material;
}
private static byte[] ComputeSha256(ReadOnlyMemory<byte> data)
{
var digest = new byte[32];
if (!SHA256.TryHashData(data.Span, digest, out _))
{
throw new InvalidOperationException("Failed to hash payload with SHA-256.");
}
return digest;
}
private static string ResolveResource(string keyId, string? version)
=> string.IsNullOrWhiteSpace(version) ? keyId : version;
private static string ResolveCurveName(string curve)
{
if (string.Equals(curve, "ECC_NIST_P256", StringComparison.OrdinalIgnoreCase) ||
string.Equals(curve, "P-256", StringComparison.OrdinalIgnoreCase))
{
return JsonWebKeyECTypes.P256;
}
if (string.Equals(curve, "ECC_NIST_P384", StringComparison.OrdinalIgnoreCase) ||
string.Equals(curve, "P-384", StringComparison.OrdinalIgnoreCase))
{
return JsonWebKeyECTypes.P384;
}
if (string.Equals(curve, "ECC_NIST_P521", StringComparison.OrdinalIgnoreCase) ||
string.Equals(curve, "P-521", StringComparison.OrdinalIgnoreCase))
{
return JsonWebKeyECTypes.P521;
}
if (string.Equals(curve, "SECP256K1", StringComparison.OrdinalIgnoreCase) ||
string.Equals(curve, "ECC_SECG_P256K1", StringComparison.OrdinalIgnoreCase))
{
return "secp256k1";
}
return curve;
}
private static KmsKeyState MapState(AwsKeyStatus status)
=> status switch
{
AwsKeyStatus.Enabled => KmsKeyState.Active,
AwsKeyStatus.PendingImport or AwsKeyStatus.PendingUpdate => KmsKeyState.PendingRotation,
AwsKeyStatus.Disabled or AwsKeyStatus.PendingDeletion or AwsKeyStatus.Unavailable => KmsKeyState.Revoked,
_ => KmsKeyState.Active,
};
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(AwsKmsClient));
}
}
private sealed record CachedMetadata(AwsKeyMetadata Metadata, DateTimeOffset ExpiresAt);
private sealed record CachedPublicKey(AwsPublicKeyMaterial Material, DateTimeOffset ExpiresAt);
}
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public interface IAwsKmsFacade : IDisposable
{
Task<AwsSignResult> SignAsync(string keyResource, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken);
Task<bool> VerifyAsync(string keyResource, ReadOnlyMemory<byte> digest, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken);
Task<AwsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken);
Task<AwsPublicKeyMaterial> GetPublicKeyAsync(string keyResource, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,14 @@
using System;
namespace StellaOps.Cryptography.Kms;
internal sealed partial class AwsKmsFacade
{
public void Dispose()
{
if (_ownsClient && _client is IDisposable disposable)
{
disposable.Dispose();
}
}
}

View File

@@ -0,0 +1,43 @@
using Amazon.KeyManagementService;
using Amazon.KeyManagementService.Model;
using System;
namespace StellaOps.Cryptography.Kms;
internal sealed partial class AwsKmsFacade
{
private static AwsKeyStatus MapStatus(KeyState? state)
{
var name = state?.ToString();
return name switch
{
"Enabled" => AwsKeyStatus.Enabled,
"Disabled" => AwsKeyStatus.Disabled,
"PendingDeletion" => AwsKeyStatus.PendingDeletion,
"PendingImport" => AwsKeyStatus.PendingImport,
"Unavailable" => AwsKeyStatus.Unavailable,
_ => AwsKeyStatus.Unspecified,
};
}
private static string ResolveCurve(GetPublicKeyResponse response)
{
if (response.KeySpec is not null)
{
var keySpecName = response.KeySpec.ToString();
if (!string.IsNullOrWhiteSpace(keySpecName))
{
return keySpecName switch
{
"ECC_NIST_P256" => "P-256",
"ECC_SECG_P256K1" => "secp256k1",
"ECC_NIST_P384" => "P-384",
"ECC_NIST_P521" => "P-521",
_ => keySpecName,
};
}
}
return "P-256";
}
}

View File

@@ -0,0 +1,44 @@
using Amazon.KeyManagementService.Model;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
internal sealed partial class AwsKmsFacade
{
public async Task<AwsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var response = await _client.DescribeKeyAsync(new DescribeKeyRequest
{
KeyId = keyId,
}, cancellationToken).ConfigureAwait(false);
var metadata = response.KeyMetadata ?? throw new InvalidOperationException($"Key '{keyId}' was not found.");
var createdAt = metadata.CreationDate?.ToUniversalTime() ?? _timeProvider.GetUtcNow();
return new AwsKeyMetadata(
metadata.KeyId ?? keyId,
metadata.Arn ?? metadata.KeyId ?? keyId,
createdAt,
MapStatus(metadata.KeyState));
}
public async Task<AwsPublicKeyMaterial> GetPublicKeyAsync(string keyResource, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyResource);
var response = await _client.GetPublicKeyAsync(new GetPublicKeyRequest
{
KeyId = keyResource,
}, cancellationToken).ConfigureAwait(false);
var keyId = response.KeyId ?? keyResource;
var versionId = response.KeyId ?? keyResource;
var curve = ResolveCurve(response);
return new AwsPublicKeyMaterial(keyId, versionId, curve, response.PublicKey.ToArray());
}
}

View File

@@ -0,0 +1,20 @@
using System;
namespace StellaOps.Cryptography.Kms;
public sealed record AwsSignResult(string KeyResource, string VersionId, byte[] Signature);
public sealed record AwsKeyMetadata(string KeyId, string Arn, DateTimeOffset CreatedAt, AwsKeyStatus Status);
public enum AwsKeyStatus
{
Unspecified = 0,
Enabled = 1,
Disabled = 2,
PendingDeletion = 3,
PendingImport = 4,
PendingUpdate = 5,
Unavailable = 6,
}
public sealed record AwsPublicKeyMaterial(string KeyId, string VersionId, string Curve, byte[] SubjectPublicKeyInfo);

View File

@@ -0,0 +1,52 @@
using Amazon.KeyManagementService;
using Amazon.KeyManagementService.Model;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
internal sealed partial class AwsKmsFacade
{
public async Task<AwsSignResult> SignAsync(string keyResource, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyResource);
using var messageStream = new MemoryStream(digest.ToArray(), writable: false);
var request = new SignRequest
{
KeyId = keyResource,
SigningAlgorithm = SigningAlgorithmSpec.ECDSA_SHA_256,
MessageType = MessageType.DIGEST,
Message = messageStream,
};
var response = await _client.SignAsync(request, cancellationToken).ConfigureAwait(false);
var keyId = response.KeyId ?? keyResource;
return new AwsSignResult(keyId, keyId, response.Signature.ToArray());
}
public async Task<bool> VerifyAsync(string keyResource, ReadOnlyMemory<byte> digest, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyResource);
if (digest.IsEmpty || signature.IsEmpty)
{
return false;
}
using var messageStream = new MemoryStream(digest.ToArray(), writable: false);
using var signatureStream = new MemoryStream(signature.ToArray(), writable: false);
var request = new VerifyRequest
{
KeyId = keyResource,
SigningAlgorithm = SigningAlgorithmSpec.ECDSA_SHA_256,
MessageType = MessageType.DIGEST,
Message = messageStream,
Signature = signatureStream,
};
var response = await _client.VerifyAsync(request, cancellationToken).ConfigureAwait(false);
return response.SignatureValid ?? false;
}
}

View File

@@ -1,40 +1,11 @@
using Amazon;
using Amazon.KeyManagementService;
using Amazon.KeyManagementService.Model;
using System.IO;
using Microsoft.Extensions.Options;
using System;
namespace StellaOps.Cryptography.Kms;
public interface IAwsKmsFacade : IDisposable
{
Task<AwsSignResult> SignAsync(string keyResource, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken);
Task<bool> VerifyAsync(string keyResource, ReadOnlyMemory<byte> digest, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken);
Task<AwsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken);
Task<AwsPublicKeyMaterial> GetPublicKeyAsync(string keyResource, CancellationToken cancellationToken);
}
public sealed record AwsSignResult(string KeyResource, string VersionId, byte[] Signature);
public sealed record AwsKeyMetadata(string KeyId, string Arn, DateTimeOffset CreatedAt, AwsKeyStatus Status);
public enum AwsKeyStatus
{
Unspecified = 0,
Enabled = 1,
Disabled = 2,
PendingDeletion = 3,
PendingImport = 4,
PendingUpdate = 5,
Unavailable = 6,
}
public sealed record AwsPublicKeyMaterial(string KeyId, string VersionId, string Curve, byte[] SubjectPublicKeyInfo);
internal sealed class AwsKmsFacade : IAwsKmsFacade
internal sealed partial class AwsKmsFacade : IAwsKmsFacade
{
private readonly IAmazonKeyManagementService _client;
private readonly bool _ownsClient;
@@ -62,129 +33,15 @@ internal sealed class AwsKmsFacade : IAwsKmsFacade
_ownsClient = true;
}
public AwsKmsFacade(IOptions<AwsKmsOptions> options, TimeProvider timeProvider)
: this(options?.Value ?? new AwsKmsOptions(), timeProvider)
{
}
public AwsKmsFacade(IAmazonKeyManagementService client, TimeProvider? timeProvider = null)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_timeProvider = timeProvider ?? TimeProvider.System;
_ownsClient = false;
}
public async Task<AwsSignResult> SignAsync(string keyResource, ReadOnlyMemory<byte> digest, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyResource);
using var messageStream = new MemoryStream(digest.ToArray(), writable: false);
var request = new SignRequest
{
KeyId = keyResource,
SigningAlgorithm = SigningAlgorithmSpec.ECDSA_SHA_256,
MessageType = MessageType.DIGEST,
Message = messageStream,
};
var response = await _client.SignAsync(request, cancellationToken).ConfigureAwait(false);
var keyId = response.KeyId ?? keyResource;
return new AwsSignResult(keyId, keyId, response.Signature.ToArray());
}
public async Task<bool> VerifyAsync(string keyResource, ReadOnlyMemory<byte> digest, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyResource);
if (digest.IsEmpty || signature.IsEmpty)
{
return false;
}
using var messageStream = new MemoryStream(digest.ToArray(), writable: false);
using var signatureStream = new MemoryStream(signature.ToArray(), writable: false);
var request = new VerifyRequest
{
KeyId = keyResource,
SigningAlgorithm = SigningAlgorithmSpec.ECDSA_SHA_256,
MessageType = MessageType.DIGEST,
Message = messageStream,
Signature = signatureStream,
};
var response = await _client.VerifyAsync(request, cancellationToken).ConfigureAwait(false);
return response.SignatureValid ?? false;
}
public async Task<AwsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
var response = await _client.DescribeKeyAsync(new DescribeKeyRequest
{
KeyId = keyId,
}, cancellationToken).ConfigureAwait(false);
var metadata = response.KeyMetadata ?? throw new InvalidOperationException($"Key '{keyId}' was not found.");
var createdAt = metadata.CreationDate?.ToUniversalTime() ?? _timeProvider.GetUtcNow();
return new AwsKeyMetadata(
metadata.KeyId ?? keyId,
metadata.Arn ?? metadata.KeyId ?? keyId,
createdAt,
MapStatus(metadata.KeyState));
}
public async Task<AwsPublicKeyMaterial> GetPublicKeyAsync(string keyResource, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyResource);
var response = await _client.GetPublicKeyAsync(new GetPublicKeyRequest
{
KeyId = keyResource,
}, cancellationToken).ConfigureAwait(false);
var keyId = response.KeyId ?? keyResource;
var versionId = response.KeyId ?? keyResource;
var curve = ResolveCurve(response);
return new AwsPublicKeyMaterial(keyId, versionId, curve, response.PublicKey.ToArray());
}
private static AwsKeyStatus MapStatus(KeyState? state)
{
var name = state?.ToString();
return name switch
{
"Enabled" => AwsKeyStatus.Enabled,
"Disabled" => AwsKeyStatus.Disabled,
"PendingDeletion" => AwsKeyStatus.PendingDeletion,
"PendingImport" => AwsKeyStatus.PendingImport,
"Unavailable" => AwsKeyStatus.Unavailable,
_ => AwsKeyStatus.Unspecified,
};
}
private static string ResolveCurve(GetPublicKeyResponse response)
{
if (response.KeySpec is not null)
{
var keySpecName = response.KeySpec.ToString();
if (!string.IsNullOrWhiteSpace(keySpecName))
{
return keySpecName switch
{
"ECC_NIST_P256" => "P-256",
"ECC_SECG_P256K1" => "secp256k1",
"ECC_NIST_P384" => "P-384",
"ECC_NIST_P521" => "P-521",
_ => keySpecName,
};
}
}
return "P-256";
}
public void Dispose()
{
if (_ownsClient && _client is IDisposable disposable)
{
disposable.Dispose();
}
}
}
}

View File

@@ -1,5 +1,3 @@
using System.Diagnostics.CodeAnalysis;
namespace StellaOps.Cryptography.Kms;
/// <summary>
@@ -7,8 +5,8 @@ namespace StellaOps.Cryptography.Kms;
/// </summary>
public sealed class AwsKmsOptions
{
private TimeSpan metadataCacheDuration = TimeSpan.FromMinutes(5);
private TimeSpan publicKeyCacheDuration = TimeSpan.FromMinutes(10);
private TimeSpan _metadataCacheDuration = TimeSpan.FromMinutes(5);
private TimeSpan _publicKeyCacheDuration = TimeSpan.FromMinutes(10);
/// <summary>
/// Gets or sets the AWS region identifier (e.g. <c>us-east-1</c>).
@@ -30,8 +28,8 @@ public sealed class AwsKmsOptions
/// </summary>
public TimeSpan MetadataCacheDuration
{
get => metadataCacheDuration;
set => metadataCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(5));
get => _metadataCacheDuration;
set => _metadataCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(5));
}
/// <summary>
@@ -39,16 +37,10 @@ public sealed class AwsKmsOptions
/// </summary>
public TimeSpan PublicKeyCacheDuration
{
get => publicKeyCacheDuration;
set => publicKeyCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(10));
get => _publicKeyCacheDuration;
set => _publicKeyCacheDuration = EnsurePositive(value, TimeSpan.FromMinutes(10));
}
/// <summary>
/// Gets or sets an optional factory that can provide a custom AWS facade. Primarily used for testing.
/// </summary>
public Func<IServiceProvider, IAwsKmsFacade>? FacadeFactory { get; set; }
private static TimeSpan EnsurePositive(TimeSpan value, TimeSpan @default)
=> value <= TimeSpan.Zero ? @default : value;
}

View File

@@ -0,0 +1,25 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class Fido2KmsClient
{
public async Task<KmsKeyMaterial> ExportAsync(string keyId, string? keyVersion, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
var metadata = await GetMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
return new KmsKeyMaterial(
metadata.KeyId,
metadata.KeyId,
metadata.Algorithm,
_curveName,
Array.Empty<byte>(),
_publicParameters.Q.X ?? throw new InvalidOperationException("FIDO2 public key missing X coordinate."),
_publicParameters.Q.Y ?? throw new InvalidOperationException("FIDO2 public key missing Y coordinate."),
_options.CreatedAt ?? _timeProvider.GetUtcNow());
}
}

View File

@@ -0,0 +1,39 @@
using Microsoft.IdentityModel.Tokens;
using System;
using System.Security.Cryptography;
namespace StellaOps.Cryptography.Kms;
public sealed partial class Fido2KmsClient
{
private static byte[] ComputeSha256(ReadOnlyMemory<byte> data)
{
var digest = new byte[32];
if (!SHA256.TryHashData(data.Span, digest, out _))
{
throw new InvalidOperationException("Failed to hash payload with SHA-256.");
}
return digest;
}
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(Fido2KmsClient));
}
}
private static string ResolveCurveName(ECCurve curve)
{
var oid = curve.Oid?.Value;
return oid switch
{
"1.2.840.10045.3.1.7" => JsonWebKeyECTypes.P256,
"1.3.132.0.34" => JsonWebKeyECTypes.P384,
"1.3.132.0.35" => JsonWebKeyECTypes.P521,
_ => throw new InvalidOperationException($"Unsupported FIDO2 curve OID '{oid}'."),
};
}
}

View File

@@ -0,0 +1,19 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class Fido2KmsClient
{
public Task<KmsKeyMetadata> RotateAsync(string keyId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("FIDO2 credential rotation requires new enrolment.");
public Task RevokeAsync(string keyId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("FIDO2 credential revocation must be managed in the relying party.");
public void Dispose()
{
_disposed = true;
}
}

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class Fido2KmsClient
{
public Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
var now = _timeProvider.GetUtcNow();
if (_cachedMetadata is not null && _metadataExpiresAt > now)
{
return Task.FromResult(_cachedMetadata);
}
var createdAt = _options.CreatedAt ?? _timeProvider.GetUtcNow();
var version = new KmsKeyVersionMetadata(
_options.CredentialId,
KmsKeyState.Active,
createdAt,
null,
Convert.ToBase64String(_subjectPublicKeyInfo),
_curveName);
_cachedMetadata = new KmsKeyMetadata(
_options.CredentialId,
KmsAlgorithms.Es256,
KmsKeyState.Active,
createdAt,
ImmutableArray.Create(version));
_metadataExpiresAt = now.Add(_metadataCacheDuration);
return Task.FromResult(_cachedMetadata);
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class Fido2KmsClient
{
public async Task<KmsSignResult> SignAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (data.IsEmpty)
{
throw new ArgumentException("Signing payload cannot be empty.", nameof(data));
}
var digest = ComputeSha256(data);
try
{
var signature = await _authenticator.SignAsync(_options.CredentialId, digest, cancellationToken).ConfigureAwait(false);
return new KmsSignResult(_options.CredentialId, _options.CredentialId, KmsAlgorithms.Es256, signature);
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public Task<bool> VerifyAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
ReadOnlyMemory<byte> signature,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (data.IsEmpty || signature.IsEmpty)
{
return Task.FromResult(false);
}
var digest = ComputeSha256(data);
try
{
using var ecdsa = ECDsa.Create(_publicParameters);
return Task.FromResult(ecdsa.VerifyHash(digest, signature.ToArray()));
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
}

View File

@@ -1,6 +1,5 @@
using Microsoft.IdentityModel.Tokens;
using System.Collections.Immutable;
using Microsoft.Extensions.Options;
using System;
using System.Security.Cryptography;
namespace StellaOps.Cryptography.Kms;
@@ -8,7 +7,7 @@ namespace StellaOps.Cryptography.Kms;
/// <summary>
/// FIDO2-backed KMS client suitable for high-assurance interactive workflows.
/// </summary>
public sealed class Fido2KmsClient : IKmsClient
public sealed partial class Fido2KmsClient : IKmsClient
{
private readonly IFido2Authenticator _authenticator;
private readonly Fido2Options _options;
@@ -49,141 +48,8 @@ public sealed class Fido2KmsClient : IKmsClient
_curveName = ResolveCurveName(_publicParameters.Curve);
}
public async Task<KmsSignResult> SignAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken = default)
public Fido2KmsClient(IFido2Authenticator authenticator, IOptions<Fido2Options> options, TimeProvider timeProvider)
: this(authenticator, options?.Value ?? new Fido2Options(), timeProvider)
{
ThrowIfDisposed();
if (data.IsEmpty)
{
throw new ArgumentException("Signing payload cannot be empty.", nameof(data));
}
var digest = ComputeSha256(data);
try
{
var signature = await _authenticator.SignAsync(_options.CredentialId, digest, cancellationToken).ConfigureAwait(false);
return new KmsSignResult(_options.CredentialId, _options.CredentialId, KmsAlgorithms.Es256, signature);
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public Task<bool> VerifyAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
ReadOnlyMemory<byte> signature,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (data.IsEmpty || signature.IsEmpty)
{
return Task.FromResult(false);
}
var digest = ComputeSha256(data);
try
{
using var ecdsa = ECDsa.Create(_publicParameters);
return Task.FromResult(ecdsa.VerifyHash(digest, signature.ToArray()));
}
finally
{
CryptographicOperations.ZeroMemory(digest.AsSpan());
}
}
public Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
var now = _timeProvider.GetUtcNow();
if (_cachedMetadata is not null && _metadataExpiresAt > now)
{
return Task.FromResult(_cachedMetadata);
}
var createdAt = _options.CreatedAt ?? _timeProvider.GetUtcNow();
var version = new KmsKeyVersionMetadata(
_options.CredentialId,
KmsKeyState.Active,
createdAt,
null,
Convert.ToBase64String(_subjectPublicKeyInfo),
_curveName);
_cachedMetadata = new KmsKeyMetadata(
_options.CredentialId,
KmsAlgorithms.Es256,
KmsKeyState.Active,
createdAt,
ImmutableArray.Create(version));
_metadataExpiresAt = now.Add(_metadataCacheDuration);
return Task.FromResult(_cachedMetadata);
}
public async Task<KmsKeyMaterial> ExportAsync(string keyId, string? keyVersion, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
var metadata = await GetMetadataAsync(keyId, cancellationToken).ConfigureAwait(false);
return new KmsKeyMaterial(
metadata.KeyId,
metadata.KeyId,
metadata.Algorithm,
_curveName,
Array.Empty<byte>(),
_publicParameters.Q.X ?? throw new InvalidOperationException("FIDO2 public key missing X coordinate."),
_publicParameters.Q.Y ?? throw new InvalidOperationException("FIDO2 public key missing Y coordinate."),
_options.CreatedAt ?? _timeProvider.GetUtcNow());
}
public Task<KmsKeyMetadata> RotateAsync(string keyId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("FIDO2 credential rotation requires new enrolment.");
public Task RevokeAsync(string keyId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("FIDO2 credential revocation must be managed in the relying party.");
public void Dispose()
{
_disposed = true;
}
private static byte[] ComputeSha256(ReadOnlyMemory<byte> data)
{
var digest = new byte[32];
if (!SHA256.TryHashData(data.Span, digest, out _))
{
throw new InvalidOperationException("Failed to hash payload with SHA-256.");
}
return digest;
}
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(Fido2KmsClient));
}
}
private static string ResolveCurveName(ECCurve curve)
{
var oid = curve.Oid?.Value;
return oid switch
{
"1.2.840.10045.3.1.7" => JsonWebKeyECTypes.P256,
"1.3.132.0.34" => JsonWebKeyECTypes.P384,
"1.3.132.0.35" => JsonWebKeyECTypes.P521,
_ => throw new InvalidOperationException($"Unsupported FIDO2 curve OID '{oid}'."),
};
}
}

View File

@@ -5,7 +5,7 @@ namespace StellaOps.Cryptography.Kms;
/// </summary>
public sealed class Fido2Options
{
private TimeSpan metadataCacheDuration = TimeSpan.FromMinutes(5);
private TimeSpan _metadataCacheDuration = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the relying party identifier (rpId) used when registering the credential.
@@ -33,13 +33,8 @@ public sealed class Fido2Options
/// </summary>
public TimeSpan MetadataCacheDuration
{
get => metadataCacheDuration;
set => metadataCacheDuration = value <= TimeSpan.Zero ? TimeSpan.FromMinutes(5) : value;
get => _metadataCacheDuration;
set => _metadataCacheDuration = value <= TimeSpan.Zero ? TimeSpan.FromMinutes(5) : value;
}
/// <summary>
/// Gets or sets an optional authenticator factory hook (mainly for testing or custom integrations).
/// </summary>
public Func<IServiceProvider, IFido2Authenticator>? AuthenticatorFactory { get; set; }
}

View File

@@ -0,0 +1,94 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
private KeyEnvelope EncryptPrivateKey(ReadOnlySpan<byte> privateKey)
{
var salt = RandomNumberGenerator.GetBytes(16);
var nonce = RandomNumberGenerator.GetBytes(12);
var key = DeriveKey(salt);
try
{
var ciphertext = new byte[privateKey.Length];
var tag = new byte[16];
var plaintextCopy = privateKey.ToArray();
using var aesGcm = new AesGcm(key, tag.Length);
try
{
aesGcm.Encrypt(nonce, plaintextCopy, ciphertext, tag);
}
finally
{
CryptographicOperations.ZeroMemory(plaintextCopy);
}
return new KeyEnvelope(
Ciphertext: Convert.ToBase64String(ciphertext),
Nonce: Convert.ToBase64String(nonce),
Tag: Convert.ToBase64String(tag),
Salt: Convert.ToBase64String(salt));
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
}
private byte[] DecryptPrivateKey(KeyEnvelope envelope)
{
var salt = Convert.FromBase64String(envelope.Salt);
var nonce = Convert.FromBase64String(envelope.Nonce);
var tag = Convert.FromBase64String(envelope.Tag);
var ciphertext = Convert.FromBase64String(envelope.Ciphertext);
var key = DeriveKey(salt);
try
{
var plaintext = new byte[ciphertext.Length];
using var aesGcm = new AesGcm(key, tag.Length);
aesGcm.Decrypt(nonce, ciphertext, tag, plaintext);
return plaintext;
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
}
private byte[] DeriveKey(byte[] salt)
{
var key = new byte[32];
try
{
var passwordBytes = Encoding.UTF8.GetBytes(_options.Password);
try
{
var derived = Rfc2898DeriveBytes.Pbkdf2(
passwordBytes,
salt,
_options.KeyDerivationIterations,
HashAlgorithmName.SHA256,
key.Length);
derived.CopyTo(key.AsSpan());
CryptographicOperations.ZeroMemory(derived);
return key;
}
finally
{
CryptographicOperations.ZeroMemory(passwordBytes);
}
}
catch
{
CryptographicOperations.ZeroMemory(key);
throw;
}
}
}

View File

@@ -0,0 +1,50 @@
using System;
using System.Security.Cryptography;
using System.Text.Json;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
private EcdsaKeyData CreateKeyMaterial(string algorithm)
{
if (!string.Equals(algorithm, KmsAlgorithms.Es256, StringComparison.OrdinalIgnoreCase))
{
throw new NotSupportedException($"Algorithm '{algorithm}' is not supported by the file KMS driver.");
}
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var parameters = ecdsa.ExportParameters(true);
var keyRecord = new EcdsaPrivateKeyRecord
{
Curve = "nistP256",
D = Convert.ToBase64String(parameters.D ?? Array.Empty<byte>()),
Qx = Convert.ToBase64String(parameters.Q.X ?? Array.Empty<byte>()),
Qy = Convert.ToBase64String(parameters.Q.Y ?? Array.Empty<byte>()),
};
var privateBlob = JsonSerializer.SerializeToUtf8Bytes(keyRecord, _jsonOptions);
var qx = parameters.Q.X ?? Array.Empty<byte>();
var qy = parameters.Q.Y ?? Array.Empty<byte>();
var publicKey = new byte[qx.Length + qy.Length];
Buffer.BlockCopy(qx, 0, publicKey, 0, qx.Length);
Buffer.BlockCopy(qy, 0, publicKey, qx.Length, qy.Length);
return new EcdsaKeyData(privateBlob, Convert.ToBase64String(publicKey), keyRecord.Curve);
}
private static byte[] CombinePublicCoordinates(ReadOnlySpan<byte> qx, ReadOnlySpan<byte> qy)
{
if (qx.IsEmpty || qy.IsEmpty)
{
return Array.Empty<byte>();
}
var publicKey = new byte[qx.Length + qy.Length];
qx.CopyTo(publicKey);
qy.CopyTo(publicKey.AsSpan(qx.Length));
return publicKey;
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Security.Cryptography;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
private byte[] SignData(EcdsaPrivateKeyRecord privateKey, ReadOnlySpan<byte> data)
{
var parameters = new ECParameters
{
Curve = ResolveCurve(privateKey.Curve),
D = Convert.FromBase64String(privateKey.D),
Q = new ECPoint
{
X = Convert.FromBase64String(privateKey.Qx),
Y = Convert.FromBase64String(privateKey.Qy),
},
};
using var ecdsa = ECDsa.Create();
ecdsa.ImportParameters(parameters);
return ecdsa.SignData(data, HashAlgorithmName.SHA256);
}
private bool VerifyData(string curveName, string publicKeyBase64, ReadOnlySpan<byte> data, ReadOnlySpan<byte> signature)
{
var publicKey = Convert.FromBase64String(publicKeyBase64);
if (publicKey.Length % 2 != 0)
{
return false;
}
var half = publicKey.Length / 2;
var qx = publicKey[..half];
var qy = publicKey[half..];
var parameters = new ECParameters
{
Curve = ResolveCurve(curveName),
Q = new ECPoint
{
X = qx,
Y = qy,
},
};
using var ecdsa = ECDsa.Create();
ecdsa.ImportParameters(parameters);
return ecdsa.VerifyData(data, signature, HashAlgorithmName.SHA256);
}
private static ECCurve ResolveCurve(string curveName) => curveName switch
{
"nistP256" or "P-256" or "ES256" => ECCurve.NamedCurves.nistP256,
_ => throw new NotSupportedException($"Curve '{curveName}' is not supported."),
};
}

View File

@@ -0,0 +1,105 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
public async Task<KmsKeyMetadata> ImportAsync(
string keyId,
KmsKeyMaterial material,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
ArgumentNullException.ThrowIfNull(material);
if (material.D is null || material.D.Length == 0)
{
throw new ArgumentException("Key material must include private key bytes.", nameof(material));
}
if (material.Qx is null || material.Qx.Length == 0 || material.Qy is null || material.Qy.Length == 0)
{
throw new ArgumentException("Key material must include public key coordinates.", nameof(material));
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var record = await LoadOrCreateMetadataAsync(keyId, cancellationToken, createIfMissing: true).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to create or load key metadata.");
if (!string.Equals(record.Algorithm, material.Algorithm, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Algorithm mismatch. Expected '{record.Algorithm}', received '{material.Algorithm}'.");
}
var versionId = string.IsNullOrWhiteSpace(material.VersionId)
? $"{_timeProvider.GetUtcNow():yyyyMMddTHHmmssfffZ}"
: material.VersionId;
if (record.Versions.Any(v => string.Equals(v.VersionId, versionId, StringComparison.Ordinal)))
{
throw new InvalidOperationException($"Key version '{versionId}' already exists for key '{record.KeyId}'.");
}
var curveName = string.IsNullOrWhiteSpace(material.Curve) ? "nistP256" : material.Curve;
ResolveCurve(curveName); // validate supported curve
var privateKeyRecord = new EcdsaPrivateKeyRecord
{
Curve = curveName,
D = Convert.ToBase64String(material.D),
Qx = Convert.ToBase64String(material.Qx),
Qy = Convert.ToBase64String(material.Qy),
};
var privateBlob = JsonSerializer.SerializeToUtf8Bytes(privateKeyRecord, _jsonOptions);
try
{
var envelope = EncryptPrivateKey(privateBlob);
var fileName = $"{versionId}.key.json";
var keyPath = Path.Combine(GetKeyDirectory(keyId), fileName);
await WriteJsonAsync(keyPath, envelope, cancellationToken).ConfigureAwait(false);
foreach (var existing in record.Versions.Where(v => v.State == KmsKeyState.Active))
{
existing.State = KmsKeyState.PendingRotation;
}
var createdAt = material.CreatedAt == default ? _timeProvider.GetUtcNow() : material.CreatedAt;
var publicKey = CombinePublicCoordinates(material.Qx, material.Qy);
record.Versions.Add(new KeyVersionRecord
{
VersionId = versionId,
State = KmsKeyState.Active,
CreatedAt = createdAt,
PublicKey = Convert.ToBase64String(publicKey),
CurveName = curveName,
FileName = fileName,
});
record.CreatedAt ??= createdAt;
record.State = KmsKeyState.Active;
record.ActiveVersion = versionId;
await SaveMetadataAsync(record, cancellationToken).ConfigureAwait(false);
return ToMetadata(record);
}
finally
{
CryptographicOperations.ZeroMemory(privateBlob);
}
}
finally
{
_mutex.Release();
}
}
}

View File

@@ -0,0 +1,25 @@
using System;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
private static KmsKeyMetadata ToMetadata(KeyMetadataRecord record)
{
var versions = record.Versions
.Select(v => new KmsKeyVersionMetadata(
v.VersionId,
v.State,
v.CreatedAt,
v.DeactivatedAt,
v.PublicKey,
v.CurveName))
.ToImmutableArray();
var createdAt = record.CreatedAt
?? (versions.Length > 0 ? versions.Min(v => v.CreatedAt) : TimeProvider.System.GetUtcNow());
return new KmsKeyMetadata(record.KeyId, record.Algorithm, record.State, createdAt, versions);
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
public async Task<KmsKeyMetadata> GetMetadataAsync(string keyId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var record = await LoadOrCreateMetadataAsync(keyId, cancellationToken, createIfMissing: false).ConfigureAwait(false)
?? throw new InvalidOperationException($"Key '{keyId}' does not exist.");
return ToMetadata(record);
}
finally
{
_mutex.Release();
}
}
public async Task<KmsKeyMaterial> ExportAsync(string keyId, string? keyVersion, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var record = await LoadOrCreateMetadataAsync(keyId, cancellationToken, createIfMissing: false).ConfigureAwait(false)
?? throw new InvalidOperationException($"Key '{keyId}' does not exist.");
var version = ResolveVersion(record, keyVersion);
if (string.IsNullOrWhiteSpace(version.PublicKey))
{
throw new InvalidOperationException($"Key '{keyId}' version '{version.VersionId}' does not have public key material.");
}
var privateKey = await LoadPrivateKeyAsync(record, version, cancellationToken).ConfigureAwait(false);
return new KmsKeyMaterial(
record.KeyId,
version.VersionId,
record.Algorithm,
version.CurveName,
Convert.FromBase64String(privateKey.D),
Convert.FromBase64String(privateKey.Qx),
Convert.FromBase64String(privateKey.Qy),
version.CreatedAt);
}
finally
{
_mutex.Release();
}
}
}

View File

@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
private sealed class KeyMetadataRecord
{
public string KeyId { get; set; } = string.Empty;
public string Algorithm { get; set; } = KmsAlgorithms.Es256;
public KmsKeyState State { get; set; } = KmsKeyState.Active;
public DateTimeOffset? CreatedAt { get; set; }
public string? ActiveVersion { get; set; }
public List<KeyVersionRecord> Versions { get; set; } = new();
}
private sealed class KeyVersionRecord
{
public string VersionId { get; set; } = string.Empty;
public KmsKeyState State { get; set; } = KmsKeyState.Active;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? DeactivatedAt { get; set; }
public string PublicKey { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public string CurveName { get; set; } = string.Empty;
}
private sealed record KeyEnvelope(
string Ciphertext,
string Nonce,
string Tag,
string Salt);
private sealed record EcdsaKeyData(byte[] PrivateBlob, string PublicKey, string Curve);
private sealed class EcdsaPrivateKeyRecord
{
public string Curve { get; set; } = string.Empty;
public string D { get; set; } = string.Empty;
public string Qx { get; set; } = string.Empty;
public string Qy { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.IO;
using System.Linq;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
private static string GetMetadataPath(string root, string keyId)
=> Path.Combine(root, keyId, "metadata.json");
private string GetKeyDirectory(string keyId)
{
var path = Path.Combine(_options.RootPath, keyId);
Directory.CreateDirectory(path);
return path;
}
private static KeyVersionRecord ResolveVersion(KeyMetadataRecord record, string? keyVersion)
{
KeyVersionRecord? version = null;
if (!string.IsNullOrWhiteSpace(keyVersion))
{
version = record.Versions.SingleOrDefault(v => string.Equals(v.VersionId, keyVersion, StringComparison.Ordinal));
if (version is null)
{
throw new InvalidOperationException($"Key version '{keyVersion}' does not exist for key '{record.KeyId}'.");
}
}
else if (!string.IsNullOrWhiteSpace(record.ActiveVersion))
{
version = record.Versions.SingleOrDefault(v => string.Equals(v.VersionId, record.ActiveVersion, StringComparison.Ordinal));
}
version ??= record.Versions
.Where(v => v.State == KmsKeyState.Active)
.OrderByDescending(v => v.CreatedAt)
.FirstOrDefault();
if (version is null)
{
throw new InvalidOperationException($"Key '{record.KeyId}' does not have an active version.");
}
return version;
}
}

View File

@@ -0,0 +1,97 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
private async Task<KeyMetadataRecord?> LoadOrCreateMetadataAsync(
string keyId,
CancellationToken cancellationToken,
bool createIfMissing)
{
var metadataPath = GetMetadataPath(_options.RootPath, keyId);
if (!File.Exists(metadataPath))
{
if (!createIfMissing)
{
return null;
}
var record = new KeyMetadataRecord
{
KeyId = keyId,
Algorithm = _options.Algorithm,
State = KmsKeyState.Active,
CreatedAt = _timeProvider.GetUtcNow(),
};
await SaveMetadataAsync(record, cancellationToken).ConfigureAwait(false);
return record;
}
await using var stream = File.Open(metadataPath, FileMode.Open, FileAccess.Read, FileShare.Read);
var loadedRecord = await JsonSerializer.DeserializeAsync<KeyMetadataRecord>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false);
if (loadedRecord is null)
{
return null;
}
if (string.IsNullOrWhiteSpace(loadedRecord.Algorithm))
{
loadedRecord.Algorithm = KmsAlgorithms.Es256;
}
foreach (var version in loadedRecord.Versions)
{
if (string.IsNullOrWhiteSpace(version.CurveName))
{
version.CurveName = "nistP256";
}
}
return loadedRecord;
}
private async Task SaveMetadataAsync(KeyMetadataRecord record, CancellationToken cancellationToken)
{
var metadataPath = GetMetadataPath(_options.RootPath, record.KeyId);
Directory.CreateDirectory(Path.GetDirectoryName(metadataPath)!);
await using var stream = File.Open(metadataPath, FileMode.Create, FileAccess.Write, FileShare.None);
await JsonSerializer.SerializeAsync(stream, record, _jsonOptions, cancellationToken).ConfigureAwait(false);
}
private async Task<EcdsaPrivateKeyRecord> LoadPrivateKeyAsync(KeyMetadataRecord record, KeyVersionRecord version, CancellationToken cancellationToken)
{
var keyPath = Path.Combine(GetKeyDirectory(record.KeyId), version.FileName);
if (!File.Exists(keyPath))
{
throw new InvalidOperationException($"Key material for version '{version.VersionId}' was not found.");
}
await using var stream = File.Open(keyPath, FileMode.Open, FileAccess.Read, FileShare.Read);
var envelope = await JsonSerializer.DeserializeAsync<KeyEnvelope>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Key envelope could not be deserialized.");
var payload = DecryptPrivateKey(envelope);
try
{
return JsonSerializer.Deserialize<EcdsaPrivateKeyRecord>(payload, _jsonOptions)
?? throw new InvalidOperationException("Key payload could not be deserialized.");
}
finally
{
CryptographicOperations.ZeroMemory(payload);
}
}
private static async Task WriteJsonAsync<T>(string path, T value, CancellationToken cancellationToken)
{
await using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
await JsonSerializer.SerializeAsync(stream, value, _jsonOptions, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,99 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
public async Task<KmsKeyMetadata> RotateAsync(string keyId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var record = await LoadOrCreateMetadataAsync(keyId, cancellationToken, createIfMissing: true).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to create or load key metadata.");
if (record.State == KmsKeyState.Revoked)
{
throw new InvalidOperationException($"Key '{keyId}' has been revoked and cannot be rotated.");
}
var timestamp = _timeProvider.GetUtcNow();
var versionId = $"{timestamp:yyyyMMddTHHmmssfffZ}";
var keyData = CreateKeyMaterial(record.Algorithm);
try
{
var envelope = EncryptPrivateKey(keyData.PrivateBlob);
var fileName = $"{versionId}.key.json";
var keyPath = Path.Combine(GetKeyDirectory(keyId), fileName);
await WriteJsonAsync(keyPath, envelope, cancellationToken).ConfigureAwait(false);
foreach (var existing in record.Versions.Where(v => v.State == KmsKeyState.Active))
{
existing.State = KmsKeyState.PendingRotation;
}
record.Versions.Add(new KeyVersionRecord
{
VersionId = versionId,
State = KmsKeyState.Active,
CreatedAt = timestamp,
PublicKey = keyData.PublicKey,
CurveName = keyData.Curve,
FileName = fileName,
});
record.CreatedAt ??= timestamp;
record.State = KmsKeyState.Active;
record.ActiveVersion = versionId;
await SaveMetadataAsync(record, cancellationToken).ConfigureAwait(false);
return ToMetadata(record);
}
finally
{
CryptographicOperations.ZeroMemory(keyData.PrivateBlob);
}
}
finally
{
_mutex.Release();
}
}
public async Task RevokeAsync(string keyId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var record = await LoadOrCreateMetadataAsync(keyId, cancellationToken, createIfMissing: false).ConfigureAwait(false)
?? throw new InvalidOperationException($"Key '{keyId}' does not exist.");
var timestamp = _timeProvider.GetUtcNow();
record.State = KmsKeyState.Revoked;
foreach (var version in record.Versions)
{
if (version.State != KmsKeyState.Revoked)
{
version.State = KmsKeyState.Revoked;
version.DeactivatedAt = timestamp;
}
}
await SaveMetadataAsync(record, cancellationToken).ConfigureAwait(false);
}
finally
{
_mutex.Release();
}
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Cryptography.Kms;
public sealed partial class FileKmsClient
{
public async Task<KmsSignResult> SignAsync(
string keyId,
string? keyVersion,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
if (data.IsEmpty)
{
throw new ArgumentException("Data cannot be empty.", nameof(data));
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var record = await LoadOrCreateMetadataAsync(keyId, cancellationToken, createIfMissing: false).ConfigureAwait(false)
?? throw new InvalidOperationException($"Key '{keyId}' does not exist.");
if (record.State == KmsKeyState.Revoked)
{
throw new InvalidOperationException($"Key '{keyId}' is revoked and cannot be used for signing.");
}
var version = ResolveVersion(record, keyVersion);
if (version.State != KmsKeyState.Active)
{
throw new InvalidOperationException($"Key version '{version.VersionId}' is not active. Current state: {version.State}");
}
var privateKey = await LoadPrivateKeyAsync(record, version, cancellationToken).ConfigureAwait(false);
var signature = SignData(privateKey, data.Span);
return new KmsSignResult(record.KeyId, version.VersionId, record.Algorithm, signature);
}
finally
{
_mutex.Release();
}
}
}

Some files were not shown because too many files have changed in this diff Show More