part #2
This commit is contained in:
23
src/__Libraries/StellaOps.AdvisoryAI.Attestation/AGENTS.md
Normal file
23
src/__Libraries/StellaOps.AdvisoryAI.Attestation/AGENTS.md
Normal 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.
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()}";
|
||||
}
|
||||
}
|
||||
@@ -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()}";
|
||||
}
|
||||
}
|
||||
@@ -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()}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()}";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()}";
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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()}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using StellaOps.AuditPack.Models;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.AuditPack.Models;
|
||||
|
||||
namespace StellaOps.AuditPack.Services;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using StellaOps.AuditPack.Models;
|
||||
using System.Linq;
|
||||
using StellaOps.AuditPack.Models;
|
||||
|
||||
namespace StellaOps.AuditPack.Services;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using StellaOps.AuditPack.Models;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.AuditPack.Models;
|
||||
|
||||
namespace StellaOps.AuditPack.Services;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using StellaOps.AuditPack.Models;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using StellaOps.AuditPack.Models;
|
||||
|
||||
namespace StellaOps.AuditPack.Services;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using StellaOps.AuditPack.Models;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.AuditPack.Models;
|
||||
|
||||
namespace StellaOps.AuditPack.Services;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using StellaOps.AuditPack.Models;
|
||||
using System.Diagnostics;
|
||||
using StellaOps.AuditPack.Models;
|
||||
|
||||
namespace StellaOps.AuditPack.Services;
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.Cryptography.DependencyInjection;
|
||||
|
||||
internal sealed class CryptoPluginDirectoryOptions
|
||||
{
|
||||
public CryptoPluginDirectoryOptions(string? directory)
|
||||
{
|
||||
Directory = directory;
|
||||
}
|
||||
|
||||
public string? Directory { get; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}'."),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}'."),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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."),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user