part #2
This commit is contained in:
@@ -8,4 +8,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0086-M | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0086-T | DONE | Revalidated 2026-01-06 (coverage reviewed). |
|
||||
| AUDIT-0086-A | TODO | Reopened 2026-01-06: remove Guid.NewGuid default and switch digest to canonical JSON. |
|
||||
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Authority/__Libraries/StellaOps.Authority.Core/StellaOps.Authority.Core.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
/// <summary>
|
||||
/// Provides verdict evaluation capability for replay verification.
|
||||
/// </summary>
|
||||
public interface IVerdictEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate a verdict using the specified inputs and policy context.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="assetDigest">Asset being evaluated.</param>
|
||||
/// <param name="vulnerabilityId">Vulnerability being evaluated.</param>
|
||||
/// <param name="inputs">Pinned inputs for evaluation.</param>
|
||||
/// <param name="policyHash">Policy hash to use.</param>
|
||||
/// <param name="latticeVersion">Lattice version to use.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verdict result.</returns>
|
||||
Task<VerdictResult> EvaluateAsync(
|
||||
string tenant,
|
||||
string assetDigest,
|
||||
string vulnerabilityId,
|
||||
VerdictInputs inputs,
|
||||
string policyHash,
|
||||
string latticeVersion,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
/// <summary>
|
||||
@@ -85,18 +83,3 @@ public interface IVerdictManifestStore
|
||||
/// <returns>True if deleted, false if not found.</returns>
|
||||
Task<bool> DeleteAsync(string tenant, string manifestId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paginated result for manifest list queries.
|
||||
/// </summary>
|
||||
public sealed record VerdictManifestPage
|
||||
{
|
||||
/// <summary>Manifests in this page.</summary>
|
||||
public required ImmutableArray<VerdictManifest> Manifests { get; init; }
|
||||
|
||||
/// <summary>Token for retrieving the next page, or null if no more pages.</summary>
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
/// <summary>Total count if available.</summary>
|
||||
public int? TotalCount { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for replaying verdicts to verify determinism.
|
||||
/// </summary>
|
||||
public interface IVerdictReplayVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that a verdict can be replayed to produce identical results.
|
||||
/// </summary>
|
||||
/// <param name="manifestId">Manifest ID to verify.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification result with differences if any.</returns>
|
||||
Task<ReplayVerificationResult> VerifyAsync(string manifestId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify that a verdict can be replayed to produce identical results.
|
||||
/// </summary>
|
||||
/// <param name="manifest">Manifest to verify.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification result with differences if any.</returns>
|
||||
Task<ReplayVerificationResult> VerifyAsync(VerdictManifest manifest, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
public sealed partial class InMemoryVerdictManifestStore
|
||||
{
|
||||
public Task<bool> DeleteAsync(string tenant, string manifestId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestId);
|
||||
|
||||
var key = (tenant, manifestId);
|
||||
return Task.FromResult(_manifests.TryRemove(key, out _));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all stored manifests (for testing).
|
||||
/// </summary>
|
||||
public void Clear() => _manifests.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Get count of stored manifests (for testing).
|
||||
/// </summary>
|
||||
public int Count => _manifests.Count;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
public sealed partial class InMemoryVerdictManifestStore
|
||||
{
|
||||
public Task<VerdictManifestPage> ListByPolicyAsync(
|
||||
string tenant,
|
||||
string policyHash,
|
||||
string latticeVersion,
|
||||
int limit = 100,
|
||||
string? pageToken = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyHash);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(latticeVersion);
|
||||
|
||||
var offset = 0;
|
||||
if (!string.IsNullOrWhiteSpace(pageToken) && int.TryParse(pageToken, out var parsed))
|
||||
{
|
||||
offset = parsed;
|
||||
}
|
||||
|
||||
var query = _manifests.Values
|
||||
.Where(m => m.Tenant == tenant
|
||||
&& m.PolicyHash == policyHash
|
||||
&& m.LatticeVersion == latticeVersion)
|
||||
.OrderByDescending(m => m.EvaluatedAt)
|
||||
.ThenBy(m => m.ManifestId, StringComparer.Ordinal)
|
||||
.Skip(offset)
|
||||
.Take(limit + 1)
|
||||
.ToList();
|
||||
|
||||
var hasMore = query.Count > limit;
|
||||
var manifests = query.Take(limit).ToImmutableArray();
|
||||
|
||||
return Task.FromResult(new VerdictManifestPage
|
||||
{
|
||||
Manifests = manifests,
|
||||
NextPageToken = hasMore ? (offset + limit).ToString() : null,
|
||||
});
|
||||
}
|
||||
|
||||
public Task<VerdictManifestPage> ListByAssetAsync(
|
||||
string tenant,
|
||||
string assetDigest,
|
||||
int limit = 100,
|
||||
string? pageToken = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(assetDigest);
|
||||
|
||||
var offset = 0;
|
||||
if (!string.IsNullOrWhiteSpace(pageToken) && int.TryParse(pageToken, out var parsed))
|
||||
{
|
||||
offset = parsed;
|
||||
}
|
||||
|
||||
var query = _manifests.Values
|
||||
.Where(m => m.Tenant == tenant && m.AssetDigest == assetDigest)
|
||||
.OrderByDescending(m => m.EvaluatedAt)
|
||||
.ThenBy(m => m.ManifestId, StringComparer.Ordinal)
|
||||
.Skip(offset)
|
||||
.Take(limit + 1)
|
||||
.ToList();
|
||||
|
||||
var hasMore = query.Count > limit;
|
||||
var manifests = query.Take(limit).ToImmutableArray();
|
||||
|
||||
return Task.FromResult(new VerdictManifestPage
|
||||
{
|
||||
Manifests = manifests,
|
||||
NextPageToken = hasMore ? (offset + limit).ToString() : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of verdict manifest store for testing and development.
|
||||
/// </summary>
|
||||
public sealed class InMemoryVerdictManifestStore : IVerdictManifestStore
|
||||
public sealed partial class InMemoryVerdictManifestStore : IVerdictManifestStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string Tenant, string ManifestId), VerdictManifest> _manifests = new();
|
||||
|
||||
@@ -19,7 +18,10 @@ public sealed class InMemoryVerdictManifestStore : IVerdictManifestStore
|
||||
return Task.FromResult(manifest);
|
||||
}
|
||||
|
||||
public Task<VerdictManifest?> GetByIdAsync(string tenant, string manifestId, CancellationToken ct = default)
|
||||
public Task<VerdictManifest?> GetByIdAsync(
|
||||
string tenant,
|
||||
string manifestId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestId);
|
||||
@@ -61,95 +63,4 @@ public sealed class InMemoryVerdictManifestStore : IVerdictManifestStore
|
||||
|
||||
return Task.FromResult(latest);
|
||||
}
|
||||
|
||||
public Task<VerdictManifestPage> ListByPolicyAsync(
|
||||
string tenant,
|
||||
string policyHash,
|
||||
string latticeVersion,
|
||||
int limit = 100,
|
||||
string? pageToken = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyHash);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(latticeVersion);
|
||||
|
||||
var offset = 0;
|
||||
if (!string.IsNullOrWhiteSpace(pageToken) && int.TryParse(pageToken, out var parsed))
|
||||
{
|
||||
offset = parsed;
|
||||
}
|
||||
|
||||
var query = _manifests.Values
|
||||
.Where(m => m.Tenant == tenant
|
||||
&& m.PolicyHash == policyHash
|
||||
&& m.LatticeVersion == latticeVersion)
|
||||
.OrderByDescending(m => m.EvaluatedAt)
|
||||
.ThenBy(m => m.ManifestId, StringComparer.Ordinal)
|
||||
.Skip(offset)
|
||||
.Take(limit + 1)
|
||||
.ToList();
|
||||
|
||||
var hasMore = query.Count > limit;
|
||||
var manifests = query.Take(limit).ToImmutableArray();
|
||||
|
||||
return Task.FromResult(new VerdictManifestPage
|
||||
{
|
||||
Manifests = manifests,
|
||||
NextPageToken = hasMore ? (offset + limit).ToString() : null,
|
||||
});
|
||||
}
|
||||
|
||||
public Task<VerdictManifestPage> ListByAssetAsync(
|
||||
string tenant,
|
||||
string assetDigest,
|
||||
int limit = 100,
|
||||
string? pageToken = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(assetDigest);
|
||||
|
||||
var offset = 0;
|
||||
if (!string.IsNullOrWhiteSpace(pageToken) && int.TryParse(pageToken, out var parsed))
|
||||
{
|
||||
offset = parsed;
|
||||
}
|
||||
|
||||
var query = _manifests.Values
|
||||
.Where(m => m.Tenant == tenant && m.AssetDigest == assetDigest)
|
||||
.OrderByDescending(m => m.EvaluatedAt)
|
||||
.ThenBy(m => m.ManifestId, StringComparer.Ordinal)
|
||||
.Skip(offset)
|
||||
.Take(limit + 1)
|
||||
.ToList();
|
||||
|
||||
var hasMore = query.Count > limit;
|
||||
var manifests = query.Take(limit).ToImmutableArray();
|
||||
|
||||
return Task.FromResult(new VerdictManifestPage
|
||||
{
|
||||
Manifests = manifests,
|
||||
NextPageToken = hasMore ? (offset + limit).ToString() : null,
|
||||
});
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string tenant, string manifestId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestId);
|
||||
|
||||
var key = (tenant, manifestId);
|
||||
return Task.FromResult(_manifests.TryRemove(key, out _));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all stored manifests (for testing).
|
||||
/// </summary>
|
||||
public void Clear() => _manifests.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Get count of stored manifests (for testing).
|
||||
/// </summary>
|
||||
public int Count => _manifests.Count;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
/// <summary>
|
||||
/// Result of replay verification.
|
||||
/// </summary>
|
||||
public sealed record ReplayVerificationResult
|
||||
{
|
||||
/// <summary>True if replay produced identical results.</summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>The original manifest being verified.</summary>
|
||||
public required VerdictManifest OriginalManifest { get; init; }
|
||||
|
||||
/// <summary>The manifest produced by replay (if successful).</summary>
|
||||
public VerdictManifest? ReplayedManifest { get; init; }
|
||||
|
||||
/// <summary>List of differences between original and replayed manifests.</summary>
|
||||
public ImmutableArray<string>? Differences { get; init; }
|
||||
|
||||
/// <summary>True if signature verification passed.</summary>
|
||||
public bool SignatureValid { get; init; }
|
||||
|
||||
/// <summary>Error message if replay failed.</summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>Duration of the replay operation.</summary>
|
||||
public TimeSpan? ReplayDuration { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
/// <summary>
|
||||
/// Explanation of how a single VEX source contributed to the verdict.
|
||||
/// </summary>
|
||||
public sealed record VerdictExplanation
|
||||
{
|
||||
/// <summary>Identifier of the VEX source.</summary>
|
||||
public required string SourceId { get; init; }
|
||||
|
||||
/// <summary>Human-readable reason for this contribution.</summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>Provenance score component [0, 1].</summary>
|
||||
public required double ProvenanceScore { get; init; }
|
||||
|
||||
/// <summary>Coverage score component [0, 1].</summary>
|
||||
public required double CoverageScore { get; init; }
|
||||
|
||||
/// <summary>Replayability score component [0, 1].</summary>
|
||||
public required double ReplayabilityScore { get; init; }
|
||||
|
||||
/// <summary>Claim strength multiplier.</summary>
|
||||
public required double StrengthMultiplier { get; init; }
|
||||
|
||||
/// <summary>Freshness decay multiplier.</summary>
|
||||
public required double FreshnessMultiplier { get; init; }
|
||||
|
||||
/// <summary>Final computed claim score.</summary>
|
||||
public required double ClaimScore { get; init; }
|
||||
|
||||
/// <summary>VEX status this source asserted.</summary>
|
||||
public required VexStatus AssertedStatus { get; init; }
|
||||
|
||||
/// <summary>True if this source's claim was accepted as the winner.</summary>
|
||||
public bool Accepted { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
/// <summary>
|
||||
/// All inputs required to replay a verdict deterministically.
|
||||
/// </summary>
|
||||
public sealed record VerdictInputs
|
||||
{
|
||||
/// <summary>SBOM digests used in evaluation.</summary>
|
||||
public required ImmutableArray<string> SbomDigests { get; init; }
|
||||
|
||||
/// <summary>Vulnerability feed snapshot identifiers.</summary>
|
||||
public required ImmutableArray<string> VulnFeedSnapshotIds { get; init; }
|
||||
|
||||
/// <summary>VEX document digests considered.</summary>
|
||||
public required ImmutableArray<string> VexDocumentDigests { get; init; }
|
||||
|
||||
/// <summary>Reachability graph IDs if reachability analysis was used.</summary>
|
||||
public required ImmutableArray<string> ReachabilityGraphIds { get; init; }
|
||||
|
||||
/// <summary>Clock cutoff for deterministic time-based evaluation.</summary>
|
||||
public required DateTimeOffset ClockCutoff { get; init; }
|
||||
}
|
||||
@@ -1,29 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
/// <summary>
|
||||
/// VEX verdict status enumeration per OpenVEX specification.
|
||||
/// </summary>
|
||||
public enum VexStatus
|
||||
{
|
||||
[JsonPropertyName("affected")]
|
||||
Affected,
|
||||
|
||||
[JsonPropertyName("not_affected")]
|
||||
NotAffected,
|
||||
|
||||
[JsonPropertyName("fixed")]
|
||||
Fixed,
|
||||
|
||||
[JsonPropertyName("under_investigation")]
|
||||
UnderInvestigation,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures all inputs and outputs of a VEX verdict for deterministic replay.
|
||||
/// </summary>
|
||||
@@ -65,135 +41,3 @@ public sealed record VerdictManifest
|
||||
/// <summary>Optional Rekor transparency log ID.</summary>
|
||||
public string? RekorLogId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// All inputs required to replay a verdict deterministically.
|
||||
/// </summary>
|
||||
public sealed record VerdictInputs
|
||||
{
|
||||
/// <summary>SBOM digests used in evaluation.</summary>
|
||||
public required ImmutableArray<string> SbomDigests { get; init; }
|
||||
|
||||
/// <summary>Vulnerability feed snapshot identifiers.</summary>
|
||||
public required ImmutableArray<string> VulnFeedSnapshotIds { get; init; }
|
||||
|
||||
/// <summary>VEX document digests considered.</summary>
|
||||
public required ImmutableArray<string> VexDocumentDigests { get; init; }
|
||||
|
||||
/// <summary>Reachability graph IDs if reachability analysis was used.</summary>
|
||||
public required ImmutableArray<string> ReachabilityGraphIds { get; init; }
|
||||
|
||||
/// <summary>Clock cutoff for deterministic time-based evaluation.</summary>
|
||||
public required DateTimeOffset ClockCutoff { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The computed verdict result with confidence and explanations.
|
||||
/// </summary>
|
||||
public sealed record VerdictResult
|
||||
{
|
||||
/// <summary>Final VEX status determination.</summary>
|
||||
public required VexStatus Status { get; init; }
|
||||
|
||||
/// <summary>Confidence score [0, 1].</summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>Detailed explanations from contributing VEX sources.</summary>
|
||||
public required ImmutableArray<VerdictExplanation> Explanations { get; init; }
|
||||
|
||||
/// <summary>References to supporting evidence.</summary>
|
||||
public required ImmutableArray<string> EvidenceRefs { get; init; }
|
||||
|
||||
/// <summary>True if conflicting claims were detected.</summary>
|
||||
public bool HasConflicts { get; init; }
|
||||
|
||||
/// <summary>True if reachability proof was required and present.</summary>
|
||||
public bool RequiresReplayProof { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explanation of how a single VEX source contributed to the verdict.
|
||||
/// </summary>
|
||||
public sealed record VerdictExplanation
|
||||
{
|
||||
/// <summary>Identifier of the VEX source.</summary>
|
||||
public required string SourceId { get; init; }
|
||||
|
||||
/// <summary>Human-readable reason for this contribution.</summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>Provenance score component [0, 1].</summary>
|
||||
public required double ProvenanceScore { get; init; }
|
||||
|
||||
/// <summary>Coverage score component [0, 1].</summary>
|
||||
public required double CoverageScore { get; init; }
|
||||
|
||||
/// <summary>Replayability score component [0, 1].</summary>
|
||||
public required double ReplayabilityScore { get; init; }
|
||||
|
||||
/// <summary>Claim strength multiplier.</summary>
|
||||
public required double StrengthMultiplier { get; init; }
|
||||
|
||||
/// <summary>Freshness decay multiplier.</summary>
|
||||
public required double FreshnessMultiplier { get; init; }
|
||||
|
||||
/// <summary>Final computed claim score.</summary>
|
||||
public required double ClaimScore { get; init; }
|
||||
|
||||
/// <summary>VEX status this source asserted.</summary>
|
||||
public required VexStatus AssertedStatus { get; init; }
|
||||
|
||||
/// <summary>True if this source's claim was accepted as the winner.</summary>
|
||||
public bool Accepted { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialization helper for canonical JSON output.
|
||||
/// </summary>
|
||||
public static class VerdictManifestSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions s_options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Serialize manifest to deterministic JSON (stable naming policy, no indentation).
|
||||
/// </summary>
|
||||
public static string Serialize(VerdictManifest manifest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
return JsonSerializer.Serialize(manifest, s_options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize from JSON.
|
||||
/// </summary>
|
||||
public static VerdictManifest? Deserialize(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<VerdictManifest>(json, s_options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute SHA256 digest of the canonical JSON representation.
|
||||
/// </summary>
|
||||
public static string ComputeDigest(VerdictManifest manifest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
// Create a copy without the digest field for hashing
|
||||
var forHashing = manifest with { ManifestDigest = string.Empty, SignatureBase64 = null, RekorLogId = null };
|
||||
var json = Serialize(forHashing);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
public sealed partial class VerdictManifestBuilder
|
||||
{
|
||||
public VerdictManifest Build()
|
||||
{
|
||||
Validate();
|
||||
|
||||
var manifestId = _idGenerator();
|
||||
var manifest = new VerdictManifest
|
||||
{
|
||||
ManifestId = manifestId,
|
||||
Tenant = _tenant!,
|
||||
AssetDigest = _assetDigest!,
|
||||
VulnerabilityId = _vulnerabilityId!,
|
||||
Inputs = _inputs!,
|
||||
Result = _result!,
|
||||
PolicyHash = _policyHash!,
|
||||
LatticeVersion = _latticeVersion!,
|
||||
EvaluatedAt = _evaluatedAt,
|
||||
ManifestDigest = string.Empty, // Will be computed
|
||||
};
|
||||
|
||||
var digest = VerdictManifestSerializer.ComputeDigest(manifest);
|
||||
return manifest with { ManifestDigest = digest };
|
||||
}
|
||||
|
||||
private void Validate()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_tenant))
|
||||
{
|
||||
errors.Add("Tenant is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_assetDigest))
|
||||
{
|
||||
errors.Add("Asset digest is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_vulnerabilityId))
|
||||
{
|
||||
errors.Add("Vulnerability ID is required.");
|
||||
}
|
||||
|
||||
if (_inputs is null)
|
||||
{
|
||||
errors.Add("Inputs are required.");
|
||||
}
|
||||
|
||||
if (_result is null)
|
||||
{
|
||||
errors.Add("Result is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_policyHash))
|
||||
{
|
||||
errors.Add("Policy hash is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_latticeVersion))
|
||||
{
|
||||
errors.Add("Lattice version is required.");
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException($"VerdictManifest validation failed: {string.Join("; ", errors)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
public sealed partial class VerdictManifestBuilder
|
||||
{
|
||||
public VerdictManifestBuilder WithTenant(string tenant)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
throw new ArgumentException("Tenant must be provided.", nameof(tenant));
|
||||
}
|
||||
|
||||
_tenant = tenant.Trim();
|
||||
return this;
|
||||
}
|
||||
|
||||
public VerdictManifestBuilder WithAsset(string assetDigest, string vulnerabilityId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetDigest))
|
||||
{
|
||||
throw new ArgumentException("Asset digest must be provided.", nameof(assetDigest));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityId))
|
||||
{
|
||||
throw new ArgumentException("Vulnerability ID must be provided.", nameof(vulnerabilityId));
|
||||
}
|
||||
|
||||
_assetDigest = assetDigest.Trim();
|
||||
_vulnerabilityId = vulnerabilityId.Trim().ToUpperInvariant();
|
||||
return this;
|
||||
}
|
||||
|
||||
public VerdictManifestBuilder WithInputs(VerdictInputs inputs)
|
||||
{
|
||||
_inputs = inputs ?? throw new ArgumentNullException(nameof(inputs));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
public sealed partial class VerdictManifestBuilder
|
||||
{
|
||||
public VerdictManifestBuilder WithInputs(
|
||||
IEnumerable<string> sbomDigests,
|
||||
IEnumerable<string> vulnFeedSnapshotIds,
|
||||
IEnumerable<string> vexDocumentDigests,
|
||||
IEnumerable<string>? reachabilityGraphIds = null,
|
||||
DateTimeOffset? clockCutoff = null)
|
||||
{
|
||||
_inputs = new VerdictInputs
|
||||
{
|
||||
SbomDigests = SortedImmutable(sbomDigests),
|
||||
VulnFeedSnapshotIds = SortedImmutable(vulnFeedSnapshotIds),
|
||||
VexDocumentDigests = SortedImmutable(vexDocumentDigests),
|
||||
ReachabilityGraphIds = SortedImmutable(reachabilityGraphIds ?? Enumerable.Empty<string>()),
|
||||
ClockCutoff = clockCutoff ?? _timeProvider.GetUtcNow(),
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> SortedImmutable(IEnumerable<string> items)
|
||||
=> items
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s.Trim())
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
public sealed partial class VerdictManifestBuilder
|
||||
{
|
||||
public VerdictManifestBuilder WithPolicy(string policyHash, string latticeVersion)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(policyHash))
|
||||
{
|
||||
throw new ArgumentException("Policy hash must be provided.", nameof(policyHash));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(latticeVersion))
|
||||
{
|
||||
throw new ArgumentException("Lattice version must be provided.", nameof(latticeVersion));
|
||||
}
|
||||
|
||||
_policyHash = policyHash.Trim();
|
||||
_latticeVersion = latticeVersion.Trim();
|
||||
return this;
|
||||
}
|
||||
|
||||
public VerdictManifestBuilder WithClock(DateTimeOffset evaluatedAt)
|
||||
{
|
||||
_evaluatedAt = evaluatedAt.ToUniversalTime();
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
public sealed partial class VerdictManifestBuilder
|
||||
{
|
||||
public VerdictManifestBuilder WithResult(VerdictResult result)
|
||||
{
|
||||
_result = result ?? throw new ArgumentNullException(nameof(result));
|
||||
return this;
|
||||
}
|
||||
|
||||
public VerdictManifestBuilder WithResult(
|
||||
VexStatus status,
|
||||
double confidence,
|
||||
IEnumerable<VerdictExplanation> explanations,
|
||||
IEnumerable<string>? evidenceRefs = null,
|
||||
bool hasConflicts = false,
|
||||
bool requiresReplayProof = false)
|
||||
{
|
||||
if (confidence < 0 || confidence > 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(confidence), "Confidence must be between 0 and 1.");
|
||||
}
|
||||
|
||||
var sortedExplanations = explanations
|
||||
.OrderByDescending(e => e.ClaimScore)
|
||||
.ThenByDescending(e => e.ProvenanceScore)
|
||||
.ThenBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
_result = new VerdictResult
|
||||
{
|
||||
Status = status,
|
||||
Confidence = confidence,
|
||||
Explanations = sortedExplanations,
|
||||
EvidenceRefs = SortedImmutable(evidenceRefs ?? Enumerable.Empty<string>()),
|
||||
HasConflicts = hasConflicts,
|
||||
RequiresReplayProof = requiresReplayProof,
|
||||
};
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
/// <summary>
|
||||
/// Fluent builder for constructing VerdictManifest instances with deterministic ordering.
|
||||
/// </summary>
|
||||
public sealed class VerdictManifestBuilder
|
||||
public sealed partial class VerdictManifestBuilder
|
||||
{
|
||||
private string? _tenant;
|
||||
private string? _assetDigest;
|
||||
@@ -34,194 +32,4 @@ public sealed class VerdictManifestBuilder
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_evaluatedAt = _timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
public VerdictManifestBuilder WithTenant(string tenant)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
throw new ArgumentException("Tenant must be provided.", nameof(tenant));
|
||||
}
|
||||
|
||||
_tenant = tenant.Trim();
|
||||
return this;
|
||||
}
|
||||
|
||||
public VerdictManifestBuilder WithAsset(string assetDigest, string vulnerabilityId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assetDigest))
|
||||
{
|
||||
throw new ArgumentException("Asset digest must be provided.", nameof(assetDigest));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(vulnerabilityId))
|
||||
{
|
||||
throw new ArgumentException("Vulnerability ID must be provided.", nameof(vulnerabilityId));
|
||||
}
|
||||
|
||||
_assetDigest = assetDigest.Trim();
|
||||
_vulnerabilityId = vulnerabilityId.Trim().ToUpperInvariant();
|
||||
return this;
|
||||
}
|
||||
|
||||
public VerdictManifestBuilder WithInputs(VerdictInputs inputs)
|
||||
{
|
||||
_inputs = inputs ?? throw new ArgumentNullException(nameof(inputs));
|
||||
return this;
|
||||
}
|
||||
|
||||
public VerdictManifestBuilder WithInputs(
|
||||
IEnumerable<string> sbomDigests,
|
||||
IEnumerable<string> vulnFeedSnapshotIds,
|
||||
IEnumerable<string> vexDocumentDigests,
|
||||
IEnumerable<string>? reachabilityGraphIds = null,
|
||||
DateTimeOffset? clockCutoff = null)
|
||||
{
|
||||
_inputs = new VerdictInputs
|
||||
{
|
||||
SbomDigests = SortedImmutable(sbomDigests),
|
||||
VulnFeedSnapshotIds = SortedImmutable(vulnFeedSnapshotIds),
|
||||
VexDocumentDigests = SortedImmutable(vexDocumentDigests),
|
||||
ReachabilityGraphIds = SortedImmutable(reachabilityGraphIds ?? Enumerable.Empty<string>()),
|
||||
ClockCutoff = clockCutoff ?? _timeProvider.GetUtcNow(),
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
public VerdictManifestBuilder WithResult(VerdictResult result)
|
||||
{
|
||||
_result = result ?? throw new ArgumentNullException(nameof(result));
|
||||
return this;
|
||||
}
|
||||
|
||||
public VerdictManifestBuilder WithResult(
|
||||
VexStatus status,
|
||||
double confidence,
|
||||
IEnumerable<VerdictExplanation> explanations,
|
||||
IEnumerable<string>? evidenceRefs = null,
|
||||
bool hasConflicts = false,
|
||||
bool requiresReplayProof = false)
|
||||
{
|
||||
if (confidence < 0 || confidence > 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(confidence), "Confidence must be between 0 and 1.");
|
||||
}
|
||||
|
||||
// Sort explanations deterministically by source ID
|
||||
var sortedExplanations = explanations
|
||||
.OrderByDescending(e => e.ClaimScore)
|
||||
.ThenByDescending(e => e.ProvenanceScore)
|
||||
.ThenBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
_result = new VerdictResult
|
||||
{
|
||||
Status = status,
|
||||
Confidence = confidence,
|
||||
Explanations = sortedExplanations,
|
||||
EvidenceRefs = SortedImmutable(evidenceRefs ?? Enumerable.Empty<string>()),
|
||||
HasConflicts = hasConflicts,
|
||||
RequiresReplayProof = requiresReplayProof,
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
public VerdictManifestBuilder WithPolicy(string policyHash, string latticeVersion)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(policyHash))
|
||||
{
|
||||
throw new ArgumentException("Policy hash must be provided.", nameof(policyHash));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(latticeVersion))
|
||||
{
|
||||
throw new ArgumentException("Lattice version must be provided.", nameof(latticeVersion));
|
||||
}
|
||||
|
||||
_policyHash = policyHash.Trim();
|
||||
_latticeVersion = latticeVersion.Trim();
|
||||
return this;
|
||||
}
|
||||
|
||||
public VerdictManifestBuilder WithClock(DateTimeOffset evaluatedAt)
|
||||
{
|
||||
_evaluatedAt = evaluatedAt.ToUniversalTime();
|
||||
return this;
|
||||
}
|
||||
|
||||
public VerdictManifest Build()
|
||||
{
|
||||
Validate();
|
||||
|
||||
var manifestId = _idGenerator();
|
||||
var manifest = new VerdictManifest
|
||||
{
|
||||
ManifestId = manifestId,
|
||||
Tenant = _tenant!,
|
||||
AssetDigest = _assetDigest!,
|
||||
VulnerabilityId = _vulnerabilityId!,
|
||||
Inputs = _inputs!,
|
||||
Result = _result!,
|
||||
PolicyHash = _policyHash!,
|
||||
LatticeVersion = _latticeVersion!,
|
||||
EvaluatedAt = _evaluatedAt,
|
||||
ManifestDigest = string.Empty, // Will be computed
|
||||
};
|
||||
|
||||
// Compute digest over the complete manifest
|
||||
var digest = VerdictManifestSerializer.ComputeDigest(manifest);
|
||||
return manifest with { ManifestDigest = digest };
|
||||
}
|
||||
|
||||
private void Validate()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_tenant))
|
||||
{
|
||||
errors.Add("Tenant is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_assetDigest))
|
||||
{
|
||||
errors.Add("Asset digest is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_vulnerabilityId))
|
||||
{
|
||||
errors.Add("Vulnerability ID is required.");
|
||||
}
|
||||
|
||||
if (_inputs is null)
|
||||
{
|
||||
errors.Add("Inputs are required.");
|
||||
}
|
||||
|
||||
if (_result is null)
|
||||
{
|
||||
errors.Add("Result is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_policyHash))
|
||||
{
|
||||
errors.Add("Policy hash is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_latticeVersion))
|
||||
{
|
||||
errors.Add("Lattice version is required.");
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException($"VerdictManifest validation failed: {string.Join("; ", errors)}");
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> SortedImmutable(IEnumerable<string> items)
|
||||
=> items
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s.Trim())
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
/// <summary>
|
||||
/// Paginated result for manifest list queries.
|
||||
/// </summary>
|
||||
public sealed record VerdictManifestPage
|
||||
{
|
||||
/// <summary>Manifests in this page.</summary>
|
||||
public required ImmutableArray<VerdictManifest> Manifests { get; init; }
|
||||
|
||||
/// <summary>Token for retrieving the next page, or null if no more pages.</summary>
|
||||
public string? NextPageToken { get; init; }
|
||||
|
||||
/// <summary>Total count if available.</summary>
|
||||
public int? TotalCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
/// <summary>
|
||||
/// Serialization helper for canonical JSON output.
|
||||
/// </summary>
|
||||
public static class VerdictManifestSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions _options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Serialize manifest to deterministic JSON (stable naming policy, no indentation).
|
||||
/// </summary>
|
||||
public static string Serialize(VerdictManifest manifest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
return JsonSerializer.Serialize(manifest, _options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize from JSON.
|
||||
/// </summary>
|
||||
public static VerdictManifest? Deserialize(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<VerdictManifest>(json, _options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute SHA256 digest of the canonical JSON representation.
|
||||
/// </summary>
|
||||
public static string ComputeDigest(VerdictManifest manifest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
// Create a copy without the digest field for hashing
|
||||
var forHashing = manifest with { ManifestDigest = string.Empty, SignatureBase64 = null, RekorLogId = null };
|
||||
var json = Serialize(forHashing);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
public sealed partial class VerdictReplayVerifier
|
||||
{
|
||||
private static ImmutableArray<string> CompareManifests(VerdictManifest original, VerdictManifest replayed)
|
||||
{
|
||||
var diffs = new List<string>();
|
||||
|
||||
if (original.Result.Status != replayed.Result.Status)
|
||||
{
|
||||
diffs.Add($"Status: {original.Result.Status} vs {replayed.Result.Status}");
|
||||
}
|
||||
|
||||
if (Math.Abs(original.Result.Confidence - replayed.Result.Confidence) > 0.0001)
|
||||
{
|
||||
diffs.Add($"Confidence: {original.Result.Confidence:F4} vs {replayed.Result.Confidence:F4}");
|
||||
}
|
||||
|
||||
if (original.Result.HasConflicts != replayed.Result.HasConflicts)
|
||||
{
|
||||
diffs.Add($"HasConflicts: {original.Result.HasConflicts} vs {replayed.Result.HasConflicts}");
|
||||
}
|
||||
|
||||
if (original.Result.Explanations.Length != replayed.Result.Explanations.Length)
|
||||
{
|
||||
diffs.Add($"Explanations count: {original.Result.Explanations.Length} vs {replayed.Result.Explanations.Length}");
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var i = 0; i < original.Result.Explanations.Length; i++)
|
||||
{
|
||||
var origExp = original.Result.Explanations[i];
|
||||
var repExp = replayed.Result.Explanations[i];
|
||||
|
||||
if (origExp.SourceId != repExp.SourceId)
|
||||
{
|
||||
diffs.Add($"Explanation[{i}].SourceId: {origExp.SourceId} vs {repExp.SourceId}");
|
||||
}
|
||||
|
||||
if (Math.Abs(origExp.ClaimScore - repExp.ClaimScore) > 0.0001)
|
||||
{
|
||||
diffs.Add($"Explanation[{i}].ClaimScore: {origExp.ClaimScore:F4} vs {repExp.ClaimScore:F4}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (original.ManifestDigest != replayed.ManifestDigest)
|
||||
{
|
||||
diffs.Add($"ManifestDigest: {original.ManifestDigest} vs {replayed.ManifestDigest}");
|
||||
}
|
||||
|
||||
return diffs.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
public sealed partial class VerdictReplayVerifier
|
||||
{
|
||||
public Task<ReplayVerificationResult> VerifyAsync(string manifestId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestId);
|
||||
|
||||
return Task.FromException<ReplayVerificationResult>(new InvalidOperationException(
|
||||
"Verdict replay requires a full manifest or tenant context; use VerifyAsync(VerdictManifest) instead."));
|
||||
}
|
||||
|
||||
public async Task<ReplayVerificationResult> VerifyAsync(
|
||||
VerdictManifest manifest,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var signatureValid = true;
|
||||
if (!string.IsNullOrWhiteSpace(manifest.SignatureBase64))
|
||||
{
|
||||
var sigResult = await _signer.VerifyAsync(manifest, ct).ConfigureAwait(false);
|
||||
signatureValid = sigResult.Valid;
|
||||
if (!signatureValid)
|
||||
{
|
||||
return new ReplayVerificationResult
|
||||
{
|
||||
Success = false,
|
||||
OriginalManifest = manifest,
|
||||
SignatureValid = false,
|
||||
Error = $"Signature verification failed: {sigResult.Error}",
|
||||
ReplayDuration = stopwatch.Elapsed,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var replayedResult = await _evaluator.EvaluateAsync(
|
||||
manifest.Tenant,
|
||||
manifest.AssetDigest,
|
||||
manifest.VulnerabilityId,
|
||||
manifest.Inputs,
|
||||
manifest.PolicyHash,
|
||||
manifest.LatticeVersion,
|
||||
ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var replayedManifest = new VerdictManifestBuilder(() => manifest.ManifestId)
|
||||
.WithTenant(manifest.Tenant)
|
||||
.WithAsset(manifest.AssetDigest, manifest.VulnerabilityId)
|
||||
.WithInputs(manifest.Inputs)
|
||||
.WithResult(replayedResult)
|
||||
.WithPolicy(manifest.PolicyHash, manifest.LatticeVersion)
|
||||
.WithClock(manifest.Inputs.ClockCutoff)
|
||||
.Build();
|
||||
|
||||
var differences = CompareManifests(manifest, replayedManifest);
|
||||
var success = differences.Length == 0;
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
return new ReplayVerificationResult
|
||||
{
|
||||
Success = success,
|
||||
OriginalManifest = manifest,
|
||||
ReplayedManifest = replayedManifest,
|
||||
Differences = differences,
|
||||
SignatureValid = signatureValid,
|
||||
Error = success ? null : "Replay produced different results",
|
||||
ReplayDuration = stopwatch.Elapsed,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new ReplayVerificationResult
|
||||
{
|
||||
Success = false,
|
||||
OriginalManifest = manifest,
|
||||
Error = $"Replay failed: {ex.Message}",
|
||||
ReplayDuration = stopwatch.Elapsed,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +1,9 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
/// <summary>
|
||||
/// Result of replay verification.
|
||||
/// </summary>
|
||||
public sealed record ReplayVerificationResult
|
||||
{
|
||||
/// <summary>True if replay produced identical results.</summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>The original manifest being verified.</summary>
|
||||
public required VerdictManifest OriginalManifest { get; init; }
|
||||
|
||||
/// <summary>The manifest produced by replay (if successful).</summary>
|
||||
public VerdictManifest? ReplayedManifest { get; init; }
|
||||
|
||||
/// <summary>List of differences between original and replayed manifests.</summary>
|
||||
public ImmutableArray<string>? Differences { get; init; }
|
||||
|
||||
/// <summary>True if signature verification passed.</summary>
|
||||
public bool SignatureValid { get; init; }
|
||||
|
||||
/// <summary>Error message if replay failed.</summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>Duration of the replay operation.</summary>
|
||||
public TimeSpan? ReplayDuration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for replaying verdicts to verify determinism.
|
||||
/// </summary>
|
||||
public interface IVerdictReplayVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that a verdict can be replayed to produce identical results.
|
||||
/// </summary>
|
||||
/// <param name="manifestId">Manifest ID to verify.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification result with differences if any.</returns>
|
||||
Task<ReplayVerificationResult> VerifyAsync(string manifestId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify that a verdict can be replayed to produce identical results.
|
||||
/// </summary>
|
||||
/// <param name="manifest">Manifest to verify.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification result with differences if any.</returns>
|
||||
Task<ReplayVerificationResult> VerifyAsync(VerdictManifest manifest, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides verdict evaluation capability for replay verification.
|
||||
/// </summary>
|
||||
public interface IVerdictEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate a verdict using the specified inputs and policy context.
|
||||
/// </summary>
|
||||
/// <param name="tenant">Tenant identifier.</param>
|
||||
/// <param name="assetDigest">Asset being evaluated.</param>
|
||||
/// <param name="vulnerabilityId">Vulnerability being evaluated.</param>
|
||||
/// <param name="inputs">Pinned inputs for evaluation.</param>
|
||||
/// <param name="policyHash">Policy hash to use.</param>
|
||||
/// <param name="latticeVersion">Lattice version to use.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verdict result.</returns>
|
||||
Task<VerdictResult> EvaluateAsync(
|
||||
string tenant,
|
||||
string assetDigest,
|
||||
string vulnerabilityId,
|
||||
VerdictInputs inputs,
|
||||
string policyHash,
|
||||
string latticeVersion,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of verdict replay verifier.
|
||||
/// </summary>
|
||||
public sealed class VerdictReplayVerifier : IVerdictReplayVerifier
|
||||
public sealed partial class VerdictReplayVerifier : IVerdictReplayVerifier
|
||||
{
|
||||
private readonly IVerdictManifestStore _store;
|
||||
private readonly IVerdictManifestSigner _signer;
|
||||
@@ -95,140 +18,4 @@ public sealed class VerdictReplayVerifier : IVerdictReplayVerifier
|
||||
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
|
||||
_evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator));
|
||||
}
|
||||
|
||||
public async Task<ReplayVerificationResult> VerifyAsync(string manifestId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestId);
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Verdict replay requires a full manifest or tenant context; use VerifyAsync(VerdictManifest) instead.");
|
||||
}
|
||||
|
||||
public async Task<ReplayVerificationResult> VerifyAsync(VerdictManifest manifest, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Verify signature first if present
|
||||
var signatureValid = true;
|
||||
if (!string.IsNullOrWhiteSpace(manifest.SignatureBase64))
|
||||
{
|
||||
var sigResult = await _signer.VerifyAsync(manifest, ct).ConfigureAwait(false);
|
||||
signatureValid = sigResult.Valid;
|
||||
if (!signatureValid)
|
||||
{
|
||||
return new ReplayVerificationResult
|
||||
{
|
||||
Success = false,
|
||||
OriginalManifest = manifest,
|
||||
SignatureValid = false,
|
||||
Error = $"Signature verification failed: {sigResult.Error}",
|
||||
ReplayDuration = stopwatch.Elapsed,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Re-evaluate using pinned inputs
|
||||
var replayedResult = await _evaluator.EvaluateAsync(
|
||||
manifest.Tenant,
|
||||
manifest.AssetDigest,
|
||||
manifest.VulnerabilityId,
|
||||
manifest.Inputs,
|
||||
manifest.PolicyHash,
|
||||
manifest.LatticeVersion,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
// Build replayed manifest
|
||||
var replayedManifest = new VerdictManifestBuilder(() => manifest.ManifestId)
|
||||
.WithTenant(manifest.Tenant)
|
||||
.WithAsset(manifest.AssetDigest, manifest.VulnerabilityId)
|
||||
.WithInputs(manifest.Inputs)
|
||||
.WithResult(replayedResult)
|
||||
.WithPolicy(manifest.PolicyHash, manifest.LatticeVersion)
|
||||
.WithClock(manifest.Inputs.ClockCutoff)
|
||||
.Build();
|
||||
|
||||
// Compare results
|
||||
var differences = CompareManifests(manifest, replayedManifest);
|
||||
var success = differences.Length == 0;
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
return new ReplayVerificationResult
|
||||
{
|
||||
Success = success,
|
||||
OriginalManifest = manifest,
|
||||
ReplayedManifest = replayedManifest,
|
||||
Differences = differences,
|
||||
SignatureValid = signatureValid,
|
||||
Error = success ? null : "Replay produced different results",
|
||||
ReplayDuration = stopwatch.Elapsed,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new ReplayVerificationResult
|
||||
{
|
||||
Success = false,
|
||||
OriginalManifest = manifest,
|
||||
Error = $"Replay failed: {ex.Message}",
|
||||
ReplayDuration = stopwatch.Elapsed,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> CompareManifests(VerdictManifest original, VerdictManifest replayed)
|
||||
{
|
||||
var diffs = new List<string>();
|
||||
|
||||
if (original.Result.Status != replayed.Result.Status)
|
||||
{
|
||||
diffs.Add($"Status: {original.Result.Status} vs {replayed.Result.Status}");
|
||||
}
|
||||
|
||||
if (Math.Abs(original.Result.Confidence - replayed.Result.Confidence) > 0.0001)
|
||||
{
|
||||
diffs.Add($"Confidence: {original.Result.Confidence:F4} vs {replayed.Result.Confidence:F4}");
|
||||
}
|
||||
|
||||
if (original.Result.HasConflicts != replayed.Result.HasConflicts)
|
||||
{
|
||||
diffs.Add($"HasConflicts: {original.Result.HasConflicts} vs {replayed.Result.HasConflicts}");
|
||||
}
|
||||
|
||||
if (original.Result.Explanations.Length != replayed.Result.Explanations.Length)
|
||||
{
|
||||
diffs.Add($"Explanations count: {original.Result.Explanations.Length} vs {replayed.Result.Explanations.Length}");
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var i = 0; i < original.Result.Explanations.Length; i++)
|
||||
{
|
||||
var origExp = original.Result.Explanations[i];
|
||||
var repExp = replayed.Result.Explanations[i];
|
||||
|
||||
if (origExp.SourceId != repExp.SourceId)
|
||||
{
|
||||
diffs.Add($"Explanation[{i}].SourceId: {origExp.SourceId} vs {repExp.SourceId}");
|
||||
}
|
||||
|
||||
if (Math.Abs(origExp.ClaimScore - repExp.ClaimScore) > 0.0001)
|
||||
{
|
||||
diffs.Add($"Explanation[{i}].ClaimScore: {origExp.ClaimScore:F4} vs {repExp.ClaimScore:F4}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compare manifest digest (computed from result)
|
||||
if (original.ManifestDigest != replayed.ManifestDigest)
|
||||
{
|
||||
diffs.Add($"ManifestDigest: {original.ManifestDigest} vs {replayed.ManifestDigest}");
|
||||
}
|
||||
|
||||
return diffs.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
/// <summary>
|
||||
/// The computed verdict result with confidence and explanations.
|
||||
/// </summary>
|
||||
public sealed record VerdictResult
|
||||
{
|
||||
/// <summary>Final VEX status determination.</summary>
|
||||
public required VexStatus Status { get; init; }
|
||||
|
||||
/// <summary>Confidence score [0, 1].</summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>Detailed explanations from contributing VEX sources.</summary>
|
||||
public required ImmutableArray<VerdictExplanation> Explanations { get; init; }
|
||||
|
||||
/// <summary>References to supporting evidence.</summary>
|
||||
public required ImmutableArray<string> EvidenceRefs { get; init; }
|
||||
|
||||
/// <summary>True if conflicting claims were detected.</summary>
|
||||
public bool HasConflicts { get; init; }
|
||||
|
||||
/// <summary>True if reachability proof was required and present.</summary>
|
||||
public bool RequiresReplayProof { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Authority.Core.Verdicts;
|
||||
|
||||
/// <summary>
|
||||
/// VEX verdict status enumeration per OpenVEX specification.
|
||||
/// </summary>
|
||||
public enum VexStatus
|
||||
{
|
||||
[JsonPropertyName("affected")]
|
||||
Affected,
|
||||
|
||||
[JsonPropertyName("not_affected")]
|
||||
NotAffected,
|
||||
|
||||
[JsonPropertyName("fixed")]
|
||||
Fixed,
|
||||
|
||||
[JsonPropertyName("under_investigation")]
|
||||
UnderInvestigation,
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FailoverStrategy.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-001 - Core Abstractions & Models
|
||||
// Description: Strategy for handling multiple TSA providers.
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Strategy for handling multiple TSA providers.
|
||||
/// </summary>
|
||||
public enum FailoverStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Try providers in priority order until one succeeds.
|
||||
/// </summary>
|
||||
Priority,
|
||||
|
||||
/// <summary>
|
||||
/// Try providers in round-robin fashion.
|
||||
/// </summary>
|
||||
RoundRobin,
|
||||
|
||||
/// <summary>
|
||||
/// Use the provider with lowest latency from recent requests.
|
||||
/// </summary>
|
||||
LowestLatency,
|
||||
|
||||
/// <summary>
|
||||
/// Randomly select a provider.
|
||||
/// </summary>
|
||||
Random
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PkiFailureInfo.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-001 - Core Abstractions & Models
|
||||
// Description: RFC 3161 PKIFailureInfo flags.
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// RFC 3161 PKIFailureInfo bit flags.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum PkiFailureInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Unrecognized or unsupported algorithm.
|
||||
/// </summary>
|
||||
BadAlg = 1 << 0,
|
||||
|
||||
/// <summary>
|
||||
/// The request was badly formed.
|
||||
/// </summary>
|
||||
BadRequest = 1 << 2,
|
||||
|
||||
/// <summary>
|
||||
/// The data format is incorrect.
|
||||
/// </summary>
|
||||
BadDataFormat = 1 << 5,
|
||||
|
||||
/// <summary>
|
||||
/// The time source is not available.
|
||||
/// </summary>
|
||||
TimeNotAvailable = 1 << 14,
|
||||
|
||||
/// <summary>
|
||||
/// The requested policy is not supported.
|
||||
/// </summary>
|
||||
UnacceptedPolicy = 1 << 15,
|
||||
|
||||
/// <summary>
|
||||
/// The requested extension is not supported.
|
||||
/// </summary>
|
||||
UnacceptedExtension = 1 << 16,
|
||||
|
||||
/// <summary>
|
||||
/// Additional information is required.
|
||||
/// </summary>
|
||||
AddInfoNotAvailable = 1 << 17,
|
||||
|
||||
/// <summary>
|
||||
/// A system failure occurred.
|
||||
/// </summary>
|
||||
SystemFailure = 1 << 25
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PkiStatus.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-001 - Core Abstractions & Models
|
||||
// Description: RFC 3161 PKIStatus values.
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// RFC 3161 PKIStatus values.
|
||||
/// </summary>
|
||||
public enum PkiStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// The request was granted.
|
||||
/// </summary>
|
||||
Granted = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The request was granted with modifications.
|
||||
/// </summary>
|
||||
GrantedWithMods = 1,
|
||||
|
||||
/// <summary>
|
||||
/// The request was rejected.
|
||||
/// </summary>
|
||||
Rejection = 2,
|
||||
|
||||
/// <summary>
|
||||
/// The request is being processed (async).
|
||||
/// </summary>
|
||||
Waiting = 3,
|
||||
|
||||
/// <summary>
|
||||
/// A revocation warning was issued.
|
||||
/// </summary>
|
||||
RevocationWarning = 4,
|
||||
|
||||
/// <summary>
|
||||
/// A revocation notification was issued.
|
||||
/// </summary>
|
||||
RevocationNotification = 5
|
||||
}
|
||||
@@ -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/Authority/__Libraries/StellaOps.Authority.Timestamping.Abstractions/StellaOps.Authority.Timestamping.Abstractions.md. |
|
||||
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Authority/__Libraries/StellaOps.Authority.Timestamping.Abstractions/StellaOps.Authority.Timestamping.Abstractions.md (2026-02-04). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimeStampExtension.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-001 - Core Abstractions & Models
|
||||
// Description: RFC 3161 TimeStampReq extension wrapper.
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an extension in a timestamp request.
|
||||
/// </summary>
|
||||
/// <param name="Oid">The extension OID.</param>
|
||||
/// <param name="Critical">Whether the extension is critical.</param>
|
||||
/// <param name="Value">The extension value.</param>
|
||||
public sealed record TimeStampExtension(
|
||||
string Oid,
|
||||
bool Critical,
|
||||
ReadOnlyMemory<byte> Value);
|
||||
@@ -0,0 +1,73 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimeStampRequest.Factory.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-001 - Core Abstractions & Models
|
||||
// Description: Factory helpers for RFC 3161 TimeStampReq.
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
public sealed partial record TimeStampRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new TimeStampRequest for the given data.
|
||||
/// </summary>
|
||||
/// <param name="data">The data to timestamp.</param>
|
||||
/// <param name="hashAlgorithm">The hash algorithm to use.</param>
|
||||
/// <param name="includeNonce">Whether to include a random nonce.</param>
|
||||
/// <returns>A new TimeStampRequest.</returns>
|
||||
public static TimeStampRequest Create(
|
||||
ReadOnlySpan<byte> data,
|
||||
HashAlgorithmName hashAlgorithm,
|
||||
bool includeNonce = true)
|
||||
{
|
||||
var hash = ComputeHash(data, hashAlgorithm);
|
||||
return new TimeStampRequest
|
||||
{
|
||||
HashAlgorithm = hashAlgorithm,
|
||||
MessageImprint = hash,
|
||||
Nonce = includeNonce ? GenerateNonce() : (ReadOnlyMemory<byte>?)null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new TimeStampRequest for a pre-computed hash.
|
||||
/// </summary>
|
||||
/// <param name="hash">The pre-computed hash.</param>
|
||||
/// <param name="hashAlgorithm">The hash algorithm used.</param>
|
||||
/// <param name="includeNonce">Whether to include a random nonce.</param>
|
||||
/// <returns>A new TimeStampRequest.</returns>
|
||||
public static TimeStampRequest CreateFromHash(
|
||||
ReadOnlyMemory<byte> hash,
|
||||
HashAlgorithmName hashAlgorithm,
|
||||
bool includeNonce = true)
|
||||
{
|
||||
return new TimeStampRequest
|
||||
{
|
||||
HashAlgorithm = hashAlgorithm,
|
||||
MessageImprint = hash,
|
||||
Nonce = includeNonce ? GenerateNonce() : (ReadOnlyMemory<byte>?)null
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] ComputeHash(ReadOnlySpan<byte> data, HashAlgorithmName algorithm)
|
||||
{
|
||||
using var hasher = algorithm.Name switch
|
||||
{
|
||||
"SHA256" => SHA256.Create() as HashAlgorithm,
|
||||
"SHA384" => SHA384.Create(),
|
||||
"SHA512" => SHA512.Create(),
|
||||
"SHA1" => SHA1.Create(), // Legacy support
|
||||
_ => throw new ArgumentException($"Unsupported hash algorithm: {algorithm.Name}", nameof(algorithm))
|
||||
};
|
||||
return hasher!.ComputeHash(data.ToArray());
|
||||
}
|
||||
|
||||
private static byte[] GenerateNonce()
|
||||
{
|
||||
var nonce = new byte[8];
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
return nonce;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ namespace StellaOps.Authority.Timestamping.Abstractions;
|
||||
/// <summary>
|
||||
/// Represents an RFC 3161 TimeStampReq for requesting a timestamp from a TSA.
|
||||
/// </summary>
|
||||
public sealed record TimeStampRequest
|
||||
public sealed partial record TimeStampRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the version number (always 1 for RFC 3161).
|
||||
@@ -50,75 +50,4 @@ public sealed record TimeStampRequest
|
||||
/// </summary>
|
||||
public IReadOnlyList<TimeStampExtension>? Extensions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new TimeStampRequest for the given data.
|
||||
/// </summary>
|
||||
/// <param name="data">The data to timestamp.</param>
|
||||
/// <param name="hashAlgorithm">The hash algorithm to use.</param>
|
||||
/// <param name="includeNonce">Whether to include a random nonce.</param>
|
||||
/// <returns>A new TimeStampRequest.</returns>
|
||||
public static TimeStampRequest Create(
|
||||
ReadOnlySpan<byte> data,
|
||||
HashAlgorithmName hashAlgorithm,
|
||||
bool includeNonce = true)
|
||||
{
|
||||
var hash = ComputeHash(data, hashAlgorithm);
|
||||
return new TimeStampRequest
|
||||
{
|
||||
HashAlgorithm = hashAlgorithm,
|
||||
MessageImprint = hash,
|
||||
Nonce = includeNonce ? GenerateNonce() : null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new TimeStampRequest for a pre-computed hash.
|
||||
/// </summary>
|
||||
/// <param name="hash">The pre-computed hash.</param>
|
||||
/// <param name="hashAlgorithm">The hash algorithm used.</param>
|
||||
/// <param name="includeNonce">Whether to include a random nonce.</param>
|
||||
/// <returns>A new TimeStampRequest.</returns>
|
||||
public static TimeStampRequest CreateFromHash(
|
||||
ReadOnlyMemory<byte> hash,
|
||||
HashAlgorithmName hashAlgorithm,
|
||||
bool includeNonce = true)
|
||||
{
|
||||
return new TimeStampRequest
|
||||
{
|
||||
HashAlgorithm = hashAlgorithm,
|
||||
MessageImprint = hash,
|
||||
Nonce = includeNonce ? GenerateNonce() : null
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] ComputeHash(ReadOnlySpan<byte> data, HashAlgorithmName algorithm)
|
||||
{
|
||||
using var hasher = algorithm.Name switch
|
||||
{
|
||||
"SHA256" => SHA256.Create() as HashAlgorithm,
|
||||
"SHA384" => SHA384.Create(),
|
||||
"SHA512" => SHA512.Create(),
|
||||
"SHA1" => SHA1.Create(), // Legacy support
|
||||
_ => throw new ArgumentException($"Unsupported hash algorithm: {algorithm.Name}", nameof(algorithm))
|
||||
};
|
||||
return hasher!.ComputeHash(data.ToArray());
|
||||
}
|
||||
|
||||
private static byte[] GenerateNonce()
|
||||
{
|
||||
var nonce = new byte[8];
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
return nonce;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an extension in a timestamp request.
|
||||
/// </summary>
|
||||
/// <param name="Oid">The extension OID.</param>
|
||||
/// <param name="Critical">Whether the extension is critical.</param>
|
||||
/// <param name="Value">The extension value.</param>
|
||||
public sealed record TimeStampExtension(
|
||||
string Oid,
|
||||
bool Critical,
|
||||
ReadOnlyMemory<byte> Value);
|
||||
|
||||
@@ -70,86 +70,3 @@ public sealed record TimeStampResponse
|
||||
StatusString = statusString
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RFC 3161 PKIStatus values.
|
||||
/// </summary>
|
||||
public enum PkiStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// The request was granted.
|
||||
/// </summary>
|
||||
Granted = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The request was granted with modifications.
|
||||
/// </summary>
|
||||
GrantedWithMods = 1,
|
||||
|
||||
/// <summary>
|
||||
/// The request was rejected.
|
||||
/// </summary>
|
||||
Rejection = 2,
|
||||
|
||||
/// <summary>
|
||||
/// The request is being processed (async).
|
||||
/// </summary>
|
||||
Waiting = 3,
|
||||
|
||||
/// <summary>
|
||||
/// A revocation warning was issued.
|
||||
/// </summary>
|
||||
RevocationWarning = 4,
|
||||
|
||||
/// <summary>
|
||||
/// A revocation notification was issued.
|
||||
/// </summary>
|
||||
RevocationNotification = 5
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RFC 3161 PKIFailureInfo bit flags.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum PkiFailureInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Unrecognized or unsupported algorithm.
|
||||
/// </summary>
|
||||
BadAlg = 1 << 0,
|
||||
|
||||
/// <summary>
|
||||
/// The request was badly formed.
|
||||
/// </summary>
|
||||
BadRequest = 1 << 2,
|
||||
|
||||
/// <summary>
|
||||
/// The data format is incorrect.
|
||||
/// </summary>
|
||||
BadDataFormat = 1 << 5,
|
||||
|
||||
/// <summary>
|
||||
/// The time source is not available.
|
||||
/// </summary>
|
||||
TimeNotAvailable = 1 << 14,
|
||||
|
||||
/// <summary>
|
||||
/// The requested policy is not supported.
|
||||
/// </summary>
|
||||
UnacceptedPolicy = 1 << 15,
|
||||
|
||||
/// <summary>
|
||||
/// The requested extension is not supported.
|
||||
/// </summary>
|
||||
UnacceptedExtension = 1 << 16,
|
||||
|
||||
/// <summary>
|
||||
/// Additional information is required.
|
||||
/// </summary>
|
||||
AddInfoNotAvailable = 1 << 17,
|
||||
|
||||
/// <summary>
|
||||
/// A system failure occurred.
|
||||
/// </summary>
|
||||
SystemFailure = 1 << 25
|
||||
}
|
||||
|
||||
@@ -52,113 +52,3 @@ public sealed record TimeStampToken
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the TSTInfo structure from a TimeStampToken.
|
||||
/// </summary>
|
||||
public sealed record TstInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the raw DER-encoded TSTInfo.
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> EncodedTstInfo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the version (always 1).
|
||||
/// </summary>
|
||||
public int Version { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the TSA policy OID.
|
||||
/// </summary>
|
||||
public required string PolicyOid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hash algorithm used for the message imprint.
|
||||
/// </summary>
|
||||
public required HashAlgorithmName HashAlgorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the message imprint hash.
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> MessageImprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the serial number assigned by the TSA.
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> SerialNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the generation time of the timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset GenTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the accuracy of the timestamp (optional).
|
||||
/// </summary>
|
||||
public TstAccuracy? Accuracy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether ordering is guaranteed.
|
||||
/// </summary>
|
||||
public bool Ordering { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the nonce if present.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<byte>? Nonce { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the TSA name if present.
|
||||
/// </summary>
|
||||
public string? TsaName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets any extensions.
|
||||
/// </summary>
|
||||
public IReadOnlyList<TimeStampExtension>? Extensions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective time range considering accuracy.
|
||||
/// </summary>
|
||||
public (DateTimeOffset Earliest, DateTimeOffset Latest) GetTimeRange()
|
||||
{
|
||||
if (Accuracy is null)
|
||||
return (GenTime, GenTime);
|
||||
|
||||
var delta = Accuracy.ToTimeSpan();
|
||||
return (GenTime - delta, GenTime + delta);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the accuracy of a timestamp.
|
||||
/// </summary>
|
||||
public sealed record TstAccuracy
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the seconds component.
|
||||
/// </summary>
|
||||
public int? Seconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the milliseconds component (0-999).
|
||||
/// </summary>
|
||||
public int? Millis { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the microseconds component (0-999).
|
||||
/// </summary>
|
||||
public int? Micros { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts to a TimeSpan.
|
||||
/// </summary>
|
||||
public TimeSpan ToTimeSpan()
|
||||
{
|
||||
var totalMicros = (Seconds ?? 0) * 1_000_000L
|
||||
+ (Millis ?? 0) * 1_000L
|
||||
+ (Micros ?? 0);
|
||||
return TimeSpan.FromMicroseconds(totalMicros);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,151 +97,3 @@ public sealed record TimeStampVerificationResult
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification status codes.
|
||||
/// </summary>
|
||||
public enum VerificationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// The timestamp is valid.
|
||||
/// </summary>
|
||||
Valid,
|
||||
|
||||
/// <summary>
|
||||
/// The signature is invalid.
|
||||
/// </summary>
|
||||
SignatureInvalid,
|
||||
|
||||
/// <summary>
|
||||
/// The message imprint doesn't match.
|
||||
/// </summary>
|
||||
ImprintMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// The nonce doesn't match.
|
||||
/// </summary>
|
||||
NonceMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// Certificate validation failed.
|
||||
/// </summary>
|
||||
CertificateError,
|
||||
|
||||
/// <summary>
|
||||
/// The timestamp is structurally invalid.
|
||||
/// </summary>
|
||||
Invalid
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed verification error information.
|
||||
/// </summary>
|
||||
/// <param name="Code">The error code.</param>
|
||||
/// <param name="Message">Human-readable error message.</param>
|
||||
/// <param name="Details">Additional details.</param>
|
||||
public sealed record VerificationError(
|
||||
VerificationErrorCode Code,
|
||||
string Message,
|
||||
string? Details = null);
|
||||
|
||||
/// <summary>
|
||||
/// Verification error codes.
|
||||
/// </summary>
|
||||
public enum VerificationErrorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Unknown error.
|
||||
/// </summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>
|
||||
/// The token is malformed.
|
||||
/// </summary>
|
||||
MalformedToken,
|
||||
|
||||
/// <summary>
|
||||
/// The CMS signature is invalid.
|
||||
/// </summary>
|
||||
SignatureInvalid,
|
||||
|
||||
/// <summary>
|
||||
/// The message imprint doesn't match the original data.
|
||||
/// </summary>
|
||||
MessageImprintMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// The nonce doesn't match the request.
|
||||
/// </summary>
|
||||
NonceMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// The signer certificate is expired.
|
||||
/// </summary>
|
||||
CertificateExpired,
|
||||
|
||||
/// <summary>
|
||||
/// The signer certificate is revoked.
|
||||
/// </summary>
|
||||
CertificateRevoked,
|
||||
|
||||
/// <summary>
|
||||
/// The certificate chain is invalid.
|
||||
/// </summary>
|
||||
CertificateChainInvalid,
|
||||
|
||||
/// <summary>
|
||||
/// The ESSCertIDv2 binding is invalid.
|
||||
/// </summary>
|
||||
EssCertIdMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// The signing certificate is missing.
|
||||
/// </summary>
|
||||
SignerCertificateMissing,
|
||||
|
||||
/// <summary>
|
||||
/// No trust anchor found for the chain.
|
||||
/// </summary>
|
||||
NoTrustAnchor
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-fatal warning encountered during verification.
|
||||
/// </summary>
|
||||
/// <param name="Code">The warning code.</param>
|
||||
/// <param name="Message">Human-readable warning message.</param>
|
||||
public sealed record VerificationWarning(
|
||||
VerificationWarningCode Code,
|
||||
string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Verification warning codes.
|
||||
/// </summary>
|
||||
public enum VerificationWarningCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Revocation check was skipped.
|
||||
/// </summary>
|
||||
RevocationCheckSkipped,
|
||||
|
||||
/// <summary>
|
||||
/// The timestamp accuracy is large.
|
||||
/// </summary>
|
||||
LargeAccuracy,
|
||||
|
||||
/// <summary>
|
||||
/// The policy OID is not recognized.
|
||||
/// </summary>
|
||||
UnknownPolicy,
|
||||
|
||||
/// <summary>
|
||||
/// The certificate is nearing expiration.
|
||||
/// </summary>
|
||||
CertificateNearingExpiration,
|
||||
|
||||
/// <summary>
|
||||
/// Using weak hash algorithm.
|
||||
/// </summary>
|
||||
WeakHashAlgorithm
|
||||
}
|
||||
|
||||
@@ -52,91 +52,3 @@ public sealed class TsaClientOptions
|
||||
/// </summary>
|
||||
public TimeStampVerificationOptions DefaultVerificationOptions { get; set; } = TimeStampVerificationOptions.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for a single TSA provider.
|
||||
/// </summary>
|
||||
public sealed class TsaProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the provider name.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the TSA endpoint URL.
|
||||
/// </summary>
|
||||
public required Uri Url { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the priority (lower = higher priority).
|
||||
/// </summary>
|
||||
public int Priority { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the request timeout.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of retry attempts.
|
||||
/// </summary>
|
||||
public int RetryCount { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the base delay for exponential backoff.
|
||||
/// </summary>
|
||||
public TimeSpan RetryBaseDelay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the policy OID to request (optional).
|
||||
/// </summary>
|
||||
public string? PolicyOid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets client certificate for mutual TLS (optional).
|
||||
/// </summary>
|
||||
public string? ClientCertificatePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets custom HTTP headers.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Headers { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this provider is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the TSA certificate for verification (optional).
|
||||
/// If not set, certificate is extracted from response.
|
||||
/// </summary>
|
||||
public string? TsaCertificatePath { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strategy for handling multiple TSA providers.
|
||||
/// </summary>
|
||||
public enum FailoverStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Try providers in priority order until one succeeds.
|
||||
/// </summary>
|
||||
Priority,
|
||||
|
||||
/// <summary>
|
||||
/// Try providers in round-robin fashion.
|
||||
/// </summary>
|
||||
RoundRobin,
|
||||
|
||||
/// <summary>
|
||||
/// Use the provider with lowest latency from recent requests.
|
||||
/// </summary>
|
||||
LowestLatency,
|
||||
|
||||
/// <summary>
|
||||
/// Randomly select a provider.
|
||||
/// </summary>
|
||||
Random
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TsaProviderOptions.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-001 - Core Abstractions & Models
|
||||
// Description: Configuration options for a single TSA provider.
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for a single TSA provider.
|
||||
/// </summary>
|
||||
public sealed class TsaProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the provider name.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the TSA endpoint URL.
|
||||
/// </summary>
|
||||
public required Uri Url { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the priority (lower = higher priority).
|
||||
/// </summary>
|
||||
public int Priority { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the request timeout.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of retry attempts.
|
||||
/// </summary>
|
||||
public int RetryCount { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the base delay for exponential backoff.
|
||||
/// </summary>
|
||||
public TimeSpan RetryBaseDelay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the policy OID to request (optional).
|
||||
/// </summary>
|
||||
public string? PolicyOid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets client certificate for mutual TLS (optional).
|
||||
/// </summary>
|
||||
public string? ClientCertificatePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets custom HTTP headers.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Headers { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether this provider is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the TSA certificate for verification (optional).
|
||||
/// If not set, certificate is extracted from response.
|
||||
/// </summary>
|
||||
public string? TsaCertificatePath { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TstAccuracy.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-001 - Core Abstractions & Models
|
||||
// Description: Accuracy metadata for timestamp tokens.
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the accuracy of a timestamp.
|
||||
/// </summary>
|
||||
public sealed record TstAccuracy
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the seconds component.
|
||||
/// </summary>
|
||||
public int? Seconds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the milliseconds component (0-999).
|
||||
/// </summary>
|
||||
public int? Millis { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the microseconds component (0-999).
|
||||
/// </summary>
|
||||
public int? Micros { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Converts to a TimeSpan.
|
||||
/// </summary>
|
||||
public TimeSpan ToTimeSpan()
|
||||
{
|
||||
var totalMicros = (Seconds ?? 0) * 1_000_000L
|
||||
+ (Millis ?? 0) * 1_000L
|
||||
+ (Micros ?? 0);
|
||||
return TimeSpan.FromMicroseconds(totalMicros);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TstInfo.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-001 - Core Abstractions & Models
|
||||
// Description: Parsed TSTInfo metadata for timestamp tokens.
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the TSTInfo structure from a TimeStampToken.
|
||||
/// </summary>
|
||||
public sealed record TstInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the raw DER-encoded TSTInfo.
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> EncodedTstInfo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the version (always 1).
|
||||
/// </summary>
|
||||
public int Version { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the TSA policy OID.
|
||||
/// </summary>
|
||||
public required string PolicyOid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hash algorithm used for the message imprint.
|
||||
/// </summary>
|
||||
public required HashAlgorithmName HashAlgorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the message imprint hash.
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> MessageImprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the serial number assigned by the TSA.
|
||||
/// </summary>
|
||||
public required ReadOnlyMemory<byte> SerialNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the generation time of the timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset GenTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the accuracy of the timestamp (optional).
|
||||
/// </summary>
|
||||
public TstAccuracy? Accuracy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether ordering is guaranteed.
|
||||
/// </summary>
|
||||
public bool Ordering { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the nonce if present.
|
||||
/// </summary>
|
||||
public ReadOnlyMemory<byte>? Nonce { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the TSA name if present.
|
||||
/// </summary>
|
||||
public string? TsaName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets any extensions.
|
||||
/// </summary>
|
||||
public IReadOnlyList<TimeStampExtension>? Extensions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective time range considering accuracy.
|
||||
/// </summary>
|
||||
public (DateTimeOffset Earliest, DateTimeOffset Latest) GetTimeRange()
|
||||
{
|
||||
if (Accuracy is null)
|
||||
return (GenTime, GenTime);
|
||||
|
||||
var delta = Accuracy.ToTimeSpan();
|
||||
return (GenTime - delta, GenTime + delta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerificationError.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-001 - Core Abstractions & Models
|
||||
// Description: Verification error model for timestamp validation.
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Detailed verification error information.
|
||||
/// </summary>
|
||||
/// <param name="Code">The error code.</param>
|
||||
/// <param name="Message">Human-readable error message.</param>
|
||||
/// <param name="Details">Additional details.</param>
|
||||
public sealed record VerificationError(
|
||||
VerificationErrorCode Code,
|
||||
string Message,
|
||||
string? Details = null);
|
||||
@@ -0,0 +1,68 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerificationErrorCode.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-001 - Core Abstractions & Models
|
||||
// Description: Verification error codes for timestamp validation.
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Verification error codes.
|
||||
/// </summary>
|
||||
public enum VerificationErrorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Unknown error.
|
||||
/// </summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>
|
||||
/// The token is malformed.
|
||||
/// </summary>
|
||||
MalformedToken,
|
||||
|
||||
/// <summary>
|
||||
/// The CMS signature is invalid.
|
||||
/// </summary>
|
||||
SignatureInvalid,
|
||||
|
||||
/// <summary>
|
||||
/// The message imprint doesn't match the original data.
|
||||
/// </summary>
|
||||
MessageImprintMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// The nonce doesn't match the request.
|
||||
/// </summary>
|
||||
NonceMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// The signer certificate is expired.
|
||||
/// </summary>
|
||||
CertificateExpired,
|
||||
|
||||
/// <summary>
|
||||
/// The signer certificate is revoked.
|
||||
/// </summary>
|
||||
CertificateRevoked,
|
||||
|
||||
/// <summary>
|
||||
/// The certificate chain is invalid.
|
||||
/// </summary>
|
||||
CertificateChainInvalid,
|
||||
|
||||
/// <summary>
|
||||
/// The ESSCertIDv2 binding is invalid.
|
||||
/// </summary>
|
||||
EssCertIdMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// The signing certificate is missing.
|
||||
/// </summary>
|
||||
SignerCertificateMissing,
|
||||
|
||||
/// <summary>
|
||||
/// No trust anchor found for the chain.
|
||||
/// </summary>
|
||||
NoTrustAnchor
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerificationStatus.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-001 - Core Abstractions & Models
|
||||
// Description: Verification status codes for timestamp validation.
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Verification status codes.
|
||||
/// </summary>
|
||||
public enum VerificationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// The timestamp is valid.
|
||||
/// </summary>
|
||||
Valid,
|
||||
|
||||
/// <summary>
|
||||
/// The signature is invalid.
|
||||
/// </summary>
|
||||
SignatureInvalid,
|
||||
|
||||
/// <summary>
|
||||
/// The message imprint doesn't match.
|
||||
/// </summary>
|
||||
ImprintMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// The nonce doesn't match.
|
||||
/// </summary>
|
||||
NonceMismatch,
|
||||
|
||||
/// <summary>
|
||||
/// Certificate validation failed.
|
||||
/// </summary>
|
||||
CertificateError,
|
||||
|
||||
/// <summary>
|
||||
/// The timestamp is structurally invalid.
|
||||
/// </summary>
|
||||
Invalid
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerificationWarning.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-001 - Core Abstractions & Models
|
||||
// Description: Verification warning model for timestamp validation.
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Non-fatal warning encountered during verification.
|
||||
/// </summary>
|
||||
/// <param name="Code">The warning code.</param>
|
||||
/// <param name="Message">Human-readable warning message.</param>
|
||||
public sealed record VerificationWarning(
|
||||
VerificationWarningCode Code,
|
||||
string Message);
|
||||
@@ -0,0 +1,38 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerificationWarningCode.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-001 - Core Abstractions & Models
|
||||
// Description: Verification warning codes for timestamp validation.
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Verification warning codes.
|
||||
/// </summary>
|
||||
public enum VerificationWarningCode
|
||||
{
|
||||
/// <summary>
|
||||
/// Revocation check was skipped.
|
||||
/// </summary>
|
||||
RevocationCheckSkipped,
|
||||
|
||||
/// <summary>
|
||||
/// The timestamp accuracy is large.
|
||||
/// </summary>
|
||||
LargeAccuracy,
|
||||
|
||||
/// <summary>
|
||||
/// The policy OID is not recognized.
|
||||
/// </summary>
|
||||
UnknownPolicy,
|
||||
|
||||
/// <summary>
|
||||
/// The certificate is nearing expiration.
|
||||
/// </summary>
|
||||
CertificateNearingExpiration,
|
||||
|
||||
/// <summary>
|
||||
/// Using weak hash algorithm.
|
||||
/// </summary>
|
||||
WeakHashAlgorithm
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Asn1/TimeStampReqEncoder.Algorithms.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-002 - ASN.1 Parsing & Generation
|
||||
// Description: Hash algorithm OID mappings.
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Asn1;
|
||||
|
||||
public static partial class TimeStampReqEncoder
|
||||
{
|
||||
// OID mappings for hash algorithms
|
||||
private static readonly Dictionary<string, string> HashAlgorithmOids = new()
|
||||
{
|
||||
["SHA1"] = "1.3.14.3.2.26",
|
||||
["SHA256"] = "2.16.840.1.101.3.4.2.1",
|
||||
["SHA384"] = "2.16.840.1.101.3.4.2.2",
|
||||
["SHA512"] = "2.16.840.1.101.3.4.2.3",
|
||||
["SHA3-256"] = "2.16.840.1.101.3.4.2.8",
|
||||
["SHA3-384"] = "2.16.840.1.101.3.4.2.9",
|
||||
["SHA3-512"] = "2.16.840.1.101.3.4.2.10"
|
||||
};
|
||||
|
||||
private static void WriteAlgorithmIdentifier(AsnWriter writer, HashAlgorithmName algorithm)
|
||||
{
|
||||
var algorithmName = algorithm.Name ?? throw new ArgumentException("Hash algorithm name is required");
|
||||
|
||||
if (!HashAlgorithmOids.TryGetValue(algorithmName, out var oid))
|
||||
{
|
||||
throw new ArgumentException($"Unsupported hash algorithm: {algorithmName}");
|
||||
}
|
||||
|
||||
// AlgorithmIdentifier ::= SEQUENCE {
|
||||
// algorithm OBJECT IDENTIFIER,
|
||||
// parameters ANY DEFINED BY algorithm OPTIONAL
|
||||
// }
|
||||
using (writer.PushSequence())
|
||||
{
|
||||
writer.WriteObjectIdentifier(oid);
|
||||
// SHA-2 family uses NULL parameters
|
||||
writer.WriteNull();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the OID for a hash algorithm.
|
||||
/// </summary>
|
||||
/// <param name="algorithm">The hash algorithm.</param>
|
||||
/// <returns>The OID string.</returns>
|
||||
public static string GetHashAlgorithmOid(HashAlgorithmName algorithm)
|
||||
{
|
||||
var name = algorithm.Name ?? throw new ArgumentException("Hash algorithm name is required");
|
||||
return HashAlgorithmOids.TryGetValue(name, out var oid)
|
||||
? oid
|
||||
: throw new ArgumentException($"Unsupported hash algorithm: {name}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hash algorithm name from an OID.
|
||||
/// </summary>
|
||||
/// <param name="oid">The OID string.</param>
|
||||
/// <returns>The hash algorithm name.</returns>
|
||||
public static HashAlgorithmName GetHashAlgorithmFromOid(string oid)
|
||||
{
|
||||
foreach (var (name, algOid) in HashAlgorithmOids)
|
||||
{
|
||||
if (algOid == oid)
|
||||
{
|
||||
return new HashAlgorithmName(name);
|
||||
}
|
||||
}
|
||||
throw new ArgumentException($"Unknown hash algorithm OID: {oid}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Asn1/TimeStampReqEncoder.Extensions.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-002 - ASN.1 Parsing & Generation
|
||||
// Description: Extension encoding helpers.
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
using System.Formats.Asn1;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Asn1;
|
||||
|
||||
public static partial class TimeStampReqEncoder
|
||||
{
|
||||
private static void WriteExtensions(AsnWriter writer, IReadOnlyList<TimeStampExtension> extensions)
|
||||
{
|
||||
// [0] IMPLICIT Extensions
|
||||
using (writer.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 0)))
|
||||
{
|
||||
foreach (var ext in extensions)
|
||||
{
|
||||
// Extension ::= SEQUENCE {
|
||||
// extnID OBJECT IDENTIFIER,
|
||||
// critical BOOLEAN DEFAULT FALSE,
|
||||
// extnValue OCTET STRING
|
||||
// }
|
||||
using (writer.PushSequence())
|
||||
{
|
||||
writer.WriteObjectIdentifier(ext.Oid);
|
||||
if (ext.Critical)
|
||||
{
|
||||
writer.WriteBoolean(true);
|
||||
}
|
||||
writer.WriteOctetString(ext.Value.Span);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Asn1/TimeStampReqEncoder.MessageImprint.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-002 - ASN.1 Parsing & Generation
|
||||
// Description: Message imprint encoding helpers.
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Asn1;
|
||||
|
||||
public static partial class TimeStampReqEncoder
|
||||
{
|
||||
private static void WriteMessageImprint(AsnWriter writer, HashAlgorithmName algorithm, ReadOnlySpan<byte> hash)
|
||||
{
|
||||
// MessageImprint ::= SEQUENCE {
|
||||
// hashAlgorithm AlgorithmIdentifier,
|
||||
// hashedMessage OCTET STRING
|
||||
// }
|
||||
using (writer.PushSequence())
|
||||
{
|
||||
WriteAlgorithmIdentifier(writer, algorithm);
|
||||
writer.WriteOctetString(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,27 +8,14 @@
|
||||
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Asn1;
|
||||
|
||||
/// <summary>
|
||||
/// Encodes RFC 3161 TimeStampReq to DER format.
|
||||
/// </summary>
|
||||
public static class TimeStampReqEncoder
|
||||
public static partial class TimeStampReqEncoder
|
||||
{
|
||||
// OID mappings for hash algorithms
|
||||
private static readonly Dictionary<string, string> HashAlgorithmOids = new()
|
||||
{
|
||||
["SHA1"] = "1.3.14.3.2.26",
|
||||
["SHA256"] = "2.16.840.1.101.3.4.2.1",
|
||||
["SHA384"] = "2.16.840.1.101.3.4.2.2",
|
||||
["SHA512"] = "2.16.840.1.101.3.4.2.3",
|
||||
["SHA3-256"] = "2.16.840.1.101.3.4.2.8",
|
||||
["SHA3-384"] = "2.16.840.1.101.3.4.2.9",
|
||||
["SHA3-512"] = "2.16.840.1.101.3.4.2.10"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a TimeStampRequest to DER format.
|
||||
/// </summary>
|
||||
@@ -74,93 +61,4 @@ public static class TimeStampReqEncoder
|
||||
|
||||
return writer.Encode();
|
||||
}
|
||||
|
||||
private static void WriteMessageImprint(AsnWriter writer, HashAlgorithmName algorithm, ReadOnlySpan<byte> hash)
|
||||
{
|
||||
// MessageImprint ::= SEQUENCE {
|
||||
// hashAlgorithm AlgorithmIdentifier,
|
||||
// hashedMessage OCTET STRING
|
||||
// }
|
||||
using (writer.PushSequence())
|
||||
{
|
||||
WriteAlgorithmIdentifier(writer, algorithm);
|
||||
writer.WriteOctetString(hash);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteAlgorithmIdentifier(AsnWriter writer, HashAlgorithmName algorithm)
|
||||
{
|
||||
var algorithmName = algorithm.Name ?? throw new ArgumentException("Hash algorithm name is required");
|
||||
|
||||
if (!HashAlgorithmOids.TryGetValue(algorithmName, out var oid))
|
||||
{
|
||||
throw new ArgumentException($"Unsupported hash algorithm: {algorithmName}");
|
||||
}
|
||||
|
||||
// AlgorithmIdentifier ::= SEQUENCE {
|
||||
// algorithm OBJECT IDENTIFIER,
|
||||
// parameters ANY DEFINED BY algorithm OPTIONAL
|
||||
// }
|
||||
using (writer.PushSequence())
|
||||
{
|
||||
writer.WriteObjectIdentifier(oid);
|
||||
// SHA-2 family uses NULL parameters
|
||||
writer.WriteNull();
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteExtensions(AsnWriter writer, IReadOnlyList<TimeStampExtension> extensions)
|
||||
{
|
||||
// [0] IMPLICIT Extensions
|
||||
using (writer.PushSequence(new Asn1Tag(TagClass.ContextSpecific, 0)))
|
||||
{
|
||||
foreach (var ext in extensions)
|
||||
{
|
||||
// Extension ::= SEQUENCE {
|
||||
// extnID OBJECT IDENTIFIER,
|
||||
// critical BOOLEAN DEFAULT FALSE,
|
||||
// extnValue OCTET STRING
|
||||
// }
|
||||
using (writer.PushSequence())
|
||||
{
|
||||
writer.WriteObjectIdentifier(ext.Oid);
|
||||
if (ext.Critical)
|
||||
{
|
||||
writer.WriteBoolean(true);
|
||||
}
|
||||
writer.WriteOctetString(ext.Value.Span);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the OID for a hash algorithm.
|
||||
/// </summary>
|
||||
/// <param name="algorithm">The hash algorithm.</param>
|
||||
/// <returns>The OID string.</returns>
|
||||
public static string GetHashAlgorithmOid(HashAlgorithmName algorithm)
|
||||
{
|
||||
var name = algorithm.Name ?? throw new ArgumentException("Hash algorithm name is required");
|
||||
return HashAlgorithmOids.TryGetValue(name, out var oid)
|
||||
? oid
|
||||
: throw new ArgumentException($"Unsupported hash algorithm: {name}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hash algorithm name from an OID.
|
||||
/// </summary>
|
||||
/// <param name="oid">The OID string.</param>
|
||||
/// <returns>The hash algorithm name.</returns>
|
||||
public static HashAlgorithmName GetHashAlgorithmFromOid(string oid)
|
||||
{
|
||||
foreach (var (name, algOid) in HashAlgorithmOids)
|
||||
{
|
||||
if (algOid == oid)
|
||||
{
|
||||
return new HashAlgorithmName(name);
|
||||
}
|
||||
}
|
||||
throw new ArgumentException($"Unknown hash algorithm OID: {oid}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,8 @@
|
||||
// Task: TSA-002 - ASN.1 Parsing & Generation
|
||||
// Description: ASN.1 DER decoder for RFC 3161 TimeStampResp.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
using System.Formats.Asn1;
|
||||
using System.Numerics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Asn1;
|
||||
|
||||
@@ -83,281 +78,3 @@ public static class TimeStampRespDecoder
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes RFC 3161 TimeStampToken from DER format.
|
||||
/// </summary>
|
||||
public static class TimeStampTokenDecoder
|
||||
{
|
||||
private const string SignedDataOid = "1.2.840.113549.1.7.2";
|
||||
private const string TstInfoOid = "1.2.840.113549.1.9.16.1.4";
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a TimeStampToken from DER-encoded bytes.
|
||||
/// </summary>
|
||||
/// <param name="encoded">The DER-encoded TimeStampToken (ContentInfo).</param>
|
||||
/// <returns>The decoded TimeStampToken.</returns>
|
||||
public static TimeStampToken Decode(ReadOnlyMemory<byte> encoded)
|
||||
{
|
||||
var reader = new AsnReader(encoded, AsnEncodingRules.DER);
|
||||
|
||||
// ContentInfo ::= SEQUENCE { contentType, content [0] EXPLICIT }
|
||||
var contentInfo = reader.ReadSequence();
|
||||
var contentType = contentInfo.ReadObjectIdentifier();
|
||||
|
||||
if (contentType != SignedDataOid)
|
||||
{
|
||||
throw new CryptographicException($"Expected SignedData OID, got: {contentType}");
|
||||
}
|
||||
|
||||
// [0] EXPLICIT SignedData
|
||||
var signedDataTag = contentInfo.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0));
|
||||
var signedData = signedDataTag.ReadSequence();
|
||||
|
||||
// SignedData version
|
||||
signedData.ReadInteger();
|
||||
|
||||
// DigestAlgorithmIdentifiers SET
|
||||
signedData.ReadSetOf();
|
||||
|
||||
// EncapsulatedContentInfo (contains TSTInfo)
|
||||
var encapContent = signedData.ReadSequence();
|
||||
var encapContentType = encapContent.ReadObjectIdentifier();
|
||||
|
||||
if (encapContentType != TstInfoOid)
|
||||
{
|
||||
throw new CryptographicException($"Expected TSTInfo OID, got: {encapContentType}");
|
||||
}
|
||||
|
||||
// [0] EXPLICIT OCTET STRING containing TSTInfo
|
||||
var tstInfoWrapper = encapContent.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0));
|
||||
var tstInfoBytes = tstInfoWrapper.ReadOctetString();
|
||||
var tstInfo = DecodeTstInfo(tstInfoBytes);
|
||||
|
||||
// Extract certificates if present
|
||||
X509Certificate2? signerCert = null;
|
||||
List<X509Certificate2>? certs = null;
|
||||
string? signatureAlgorithmOid = null;
|
||||
|
||||
// [0] IMPLICIT CertificateSet OPTIONAL
|
||||
if (signedData.HasData)
|
||||
{
|
||||
var nextTag = signedData.PeekTag();
|
||||
if (nextTag.TagClass == TagClass.ContextSpecific && nextTag.TagValue == 0)
|
||||
{
|
||||
var certSet = signedData.ReadSetOf(new Asn1Tag(TagClass.ContextSpecific, 0, true));
|
||||
certs = [];
|
||||
while (certSet.HasData)
|
||||
{
|
||||
var certBytes = certSet.PeekEncodedValue().ToArray();
|
||||
certSet.ReadSequence(); // consume
|
||||
try
|
||||
{
|
||||
var cert = X509CertificateLoader.LoadCertificate(certBytes);
|
||||
certs.Add(cert);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip invalid certificates
|
||||
}
|
||||
}
|
||||
signerCert = certs.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// Skip CRLs [1] if present, then parse SignerInfos
|
||||
while (signedData.HasData)
|
||||
{
|
||||
var tag = signedData.PeekTag();
|
||||
if (tag.TagClass == TagClass.ContextSpecific && tag.TagValue == 1)
|
||||
{
|
||||
signedData.ReadSetOf(new Asn1Tag(TagClass.ContextSpecific, 1, true));
|
||||
continue;
|
||||
}
|
||||
|
||||
// SignerInfos SET OF SignerInfo
|
||||
if (tag.TagValue == 17) // SET
|
||||
{
|
||||
var signerInfos = signedData.ReadSetOf();
|
||||
if (signerInfos.HasData)
|
||||
{
|
||||
var signerInfo = signerInfos.ReadSequence();
|
||||
signerInfo.ReadInteger(); // version
|
||||
signerInfo.ReadSequence(); // sid (skip)
|
||||
var digestAlg = signerInfo.ReadSequence();
|
||||
digestAlg.ReadObjectIdentifier(); // skip digest alg
|
||||
|
||||
// Skip signed attributes if present [0]
|
||||
if (signerInfo.HasData && signerInfo.PeekTag().TagClass == TagClass.ContextSpecific)
|
||||
{
|
||||
signerInfo.ReadSetOf(new Asn1Tag(TagClass.ContextSpecific, 0, true));
|
||||
}
|
||||
|
||||
if (signerInfo.HasData)
|
||||
{
|
||||
var sigAlg = signerInfo.ReadSequence();
|
||||
signatureAlgorithmOid = sigAlg.ReadObjectIdentifier();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return new TimeStampToken
|
||||
{
|
||||
EncodedToken = encoded,
|
||||
TstInfo = tstInfo,
|
||||
SignerCertificate = signerCert,
|
||||
Certificates = certs,
|
||||
SignatureAlgorithmOid = signatureAlgorithmOid
|
||||
};
|
||||
}
|
||||
|
||||
private static TstInfo DecodeTstInfo(byte[] encoded)
|
||||
{
|
||||
var reader = new AsnReader(encoded, AsnEncodingRules.DER);
|
||||
var tstInfo = reader.ReadSequence();
|
||||
|
||||
// version INTEGER
|
||||
var version = (int)tstInfo.ReadInteger();
|
||||
|
||||
// policy TSAPolicyId
|
||||
var policyOid = tstInfo.ReadObjectIdentifier();
|
||||
|
||||
// messageImprint MessageImprint
|
||||
var msgImprint = tstInfo.ReadSequence();
|
||||
var algId = msgImprint.ReadSequence();
|
||||
var hashOid = algId.ReadObjectIdentifier();
|
||||
var hashAlgorithm = TimeStampReqEncoder.GetHashAlgorithmFromOid(hashOid);
|
||||
var imprint = msgImprint.ReadOctetString();
|
||||
|
||||
// serialNumber INTEGER
|
||||
var serialNumber = tstInfo.ReadIntegerBytes().ToArray();
|
||||
|
||||
// genTime GeneralizedTime
|
||||
var genTime = tstInfo.ReadGeneralizedTime();
|
||||
|
||||
TstAccuracy? accuracy = null;
|
||||
bool ordering = false;
|
||||
byte[]? nonce = null;
|
||||
string? tsaName = null;
|
||||
List<TimeStampExtension>? extensions = null;
|
||||
|
||||
// Optional fields
|
||||
while (tstInfo.HasData)
|
||||
{
|
||||
var tag = tstInfo.PeekTag();
|
||||
|
||||
// accuracy Accuracy OPTIONAL
|
||||
if (tag.TagValue == 16 && tag.TagClass == TagClass.Universal) // SEQUENCE
|
||||
{
|
||||
accuracy = DecodeAccuracy(tstInfo.ReadSequence());
|
||||
continue;
|
||||
}
|
||||
|
||||
// ordering BOOLEAN DEFAULT FALSE
|
||||
if (tag.TagValue == 1 && tag.TagClass == TagClass.Universal) // BOOLEAN
|
||||
{
|
||||
ordering = tstInfo.ReadBoolean();
|
||||
continue;
|
||||
}
|
||||
|
||||
// nonce INTEGER OPTIONAL
|
||||
if (tag.TagValue == 2 && tag.TagClass == TagClass.Universal) // INTEGER
|
||||
{
|
||||
nonce = tstInfo.ReadIntegerBytes().ToArray();
|
||||
continue;
|
||||
}
|
||||
|
||||
// tsa [0] GeneralName OPTIONAL
|
||||
if (tag.TagClass == TagClass.ContextSpecific && tag.TagValue == 0)
|
||||
{
|
||||
var tsaReader = tstInfo.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0));
|
||||
// Simplified: just read as string if it's a directoryName or other
|
||||
tsaName = "(TSA GeneralName present)";
|
||||
continue;
|
||||
}
|
||||
|
||||
// extensions [1] IMPLICIT Extensions OPTIONAL
|
||||
if (tag.TagClass == TagClass.ContextSpecific && tag.TagValue == 1)
|
||||
{
|
||||
var extSeq = tstInfo.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 1));
|
||||
extensions = [];
|
||||
while (extSeq.HasData)
|
||||
{
|
||||
var ext = extSeq.ReadSequence();
|
||||
var extOid = ext.ReadObjectIdentifier();
|
||||
var critical = false;
|
||||
if (ext.HasData && ext.PeekTag().TagValue == 1) // BOOLEAN
|
||||
{
|
||||
critical = ext.ReadBoolean();
|
||||
}
|
||||
var extValue = ext.ReadOctetString();
|
||||
extensions.Add(new TimeStampExtension(extOid, critical, extValue));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unknown, skip
|
||||
tstInfo.ReadEncodedValue();
|
||||
}
|
||||
|
||||
return new TstInfo
|
||||
{
|
||||
EncodedTstInfo = encoded,
|
||||
Version = version,
|
||||
PolicyOid = policyOid,
|
||||
HashAlgorithm = hashAlgorithm,
|
||||
MessageImprint = imprint,
|
||||
SerialNumber = serialNumber,
|
||||
GenTime = genTime,
|
||||
Accuracy = accuracy,
|
||||
Ordering = ordering,
|
||||
Nonce = nonce,
|
||||
TsaName = tsaName,
|
||||
Extensions = extensions
|
||||
};
|
||||
}
|
||||
|
||||
private static TstAccuracy DecodeAccuracy(AsnReader reader)
|
||||
{
|
||||
int? seconds = null;
|
||||
int? millis = null;
|
||||
int? micros = null;
|
||||
|
||||
while (reader.HasData)
|
||||
{
|
||||
var tag = reader.PeekTag();
|
||||
|
||||
if (tag.TagValue == 2 && tag.TagClass == TagClass.Universal) // INTEGER (seconds)
|
||||
{
|
||||
seconds = (int)reader.ReadInteger();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag.TagClass == TagClass.ContextSpecific && tag.TagValue == 0) // [0] millis
|
||||
{
|
||||
var millisReader = reader.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0));
|
||||
millis = (int)millisReader.ReadInteger();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag.TagClass == TagClass.ContextSpecific && tag.TagValue == 1) // [1] micros
|
||||
{
|
||||
var microsReader = reader.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 1));
|
||||
micros = (int)microsReader.ReadInteger();
|
||||
continue;
|
||||
}
|
||||
|
||||
reader.ReadEncodedValue(); // skip unknown
|
||||
}
|
||||
|
||||
return new TstAccuracy
|
||||
{
|
||||
Seconds = seconds,
|
||||
Millis = millis,
|
||||
Micros = micros
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Asn1/TimeStampTokenDecoder.Accuracy.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-002 - ASN.1 Parsing & Generation
|
||||
// Description: Accuracy field decoding.
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
using System.Formats.Asn1;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Asn1;
|
||||
|
||||
public static partial class TimeStampTokenDecoder
|
||||
{
|
||||
private static TstAccuracy DecodeAccuracy(AsnReader reader)
|
||||
{
|
||||
int? seconds = null;
|
||||
int? millis = null;
|
||||
int? micros = null;
|
||||
|
||||
while (reader.HasData)
|
||||
{
|
||||
var tag = reader.PeekTag();
|
||||
|
||||
if (tag.TagValue == 2 && tag.TagClass == TagClass.Universal) // INTEGER (seconds)
|
||||
{
|
||||
seconds = (int)reader.ReadInteger();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag.TagClass == TagClass.ContextSpecific && tag.TagValue == 0) // [0] millis
|
||||
{
|
||||
var millisReader = reader.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0));
|
||||
millis = (int)millisReader.ReadInteger();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag.TagClass == TagClass.ContextSpecific && tag.TagValue == 1) // [1] micros
|
||||
{
|
||||
var microsReader = reader.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 1));
|
||||
micros = (int)microsReader.ReadInteger();
|
||||
continue;
|
||||
}
|
||||
|
||||
reader.ReadEncodedValue(); // skip unknown
|
||||
}
|
||||
|
||||
return new TstAccuracy
|
||||
{
|
||||
Seconds = seconds,
|
||||
Millis = millis,
|
||||
Micros = micros
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Asn1/TimeStampTokenDecoder.Certificates.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-002 - ASN.1 Parsing & Generation
|
||||
// Description: Certificate and signer info decoding helpers.
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Asn1;
|
||||
|
||||
public static partial class TimeStampTokenDecoder
|
||||
{
|
||||
private static List<X509Certificate2>? ReadCertificates(
|
||||
ref AsnReader signedData,
|
||||
out X509Certificate2? signerCert)
|
||||
{
|
||||
signerCert = null;
|
||||
List<X509Certificate2>? certs = null;
|
||||
|
||||
// [0] IMPLICIT CertificateSet OPTIONAL
|
||||
if (signedData.HasData)
|
||||
{
|
||||
var nextTag = signedData.PeekTag();
|
||||
if (nextTag.TagClass == TagClass.ContextSpecific && nextTag.TagValue == 0)
|
||||
{
|
||||
var certSet = signedData.ReadSetOf(new Asn1Tag(TagClass.ContextSpecific, 0, true));
|
||||
certs = [];
|
||||
while (certSet.HasData)
|
||||
{
|
||||
var certBytes = certSet.PeekEncodedValue().ToArray();
|
||||
certSet.ReadSequence(); // consume
|
||||
try
|
||||
{
|
||||
var cert = X509CertificateLoader.LoadCertificate(certBytes);
|
||||
certs.Add(cert);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip invalid certificates
|
||||
}
|
||||
}
|
||||
signerCert = certs.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
return certs;
|
||||
}
|
||||
|
||||
private static string? ReadSignatureAlgorithmOid(ref AsnReader signedData)
|
||||
{
|
||||
// Skip CRLs [1] if present, then parse SignerInfos
|
||||
while (signedData.HasData)
|
||||
{
|
||||
var tag = signedData.PeekTag();
|
||||
if (tag.TagClass == TagClass.ContextSpecific && tag.TagValue == 1)
|
||||
{
|
||||
signedData.ReadSetOf(new Asn1Tag(TagClass.ContextSpecific, 1, true));
|
||||
continue;
|
||||
}
|
||||
|
||||
// SignerInfos SET OF SignerInfo
|
||||
if (tag.TagValue == 17) // SET
|
||||
{
|
||||
var signerInfos = signedData.ReadSetOf();
|
||||
if (signerInfos.HasData)
|
||||
{
|
||||
var signerInfo = signerInfos.ReadSequence();
|
||||
signerInfo.ReadInteger(); // version
|
||||
signerInfo.ReadSequence(); // sid (skip)
|
||||
var digestAlg = signerInfo.ReadSequence();
|
||||
digestAlg.ReadObjectIdentifier(); // skip digest alg
|
||||
|
||||
// Skip signed attributes if present [0]
|
||||
if (signerInfo.HasData && signerInfo.PeekTag().TagClass == TagClass.ContextSpecific)
|
||||
{
|
||||
signerInfo.ReadSetOf(new Asn1Tag(TagClass.ContextSpecific, 0, true));
|
||||
}
|
||||
|
||||
if (signerInfo.HasData)
|
||||
{
|
||||
var sigAlg = signerInfo.ReadSequence();
|
||||
return sigAlg.ReadObjectIdentifier();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Asn1/TimeStampTokenDecoder.SignedData.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-002 - ASN.1 Parsing & Generation
|
||||
// Description: SignedData and TSTInfo extraction helpers.
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Asn1;
|
||||
|
||||
public static partial class TimeStampTokenDecoder
|
||||
{
|
||||
private static AsnReader ReadSignedData(AsnReader contentInfo)
|
||||
{
|
||||
// [0] EXPLICIT SignedData
|
||||
var signedDataTag = contentInfo.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0));
|
||||
var signedData = signedDataTag.ReadSequence();
|
||||
|
||||
// SignedData version and digest algorithms
|
||||
signedData.ReadInteger();
|
||||
signedData.ReadSetOf();
|
||||
|
||||
return signedData;
|
||||
}
|
||||
|
||||
private static TstInfo ReadTstInfo(ref AsnReader signedData)
|
||||
{
|
||||
// EncapsulatedContentInfo (contains TSTInfo)
|
||||
var encapContent = signedData.ReadSequence();
|
||||
var encapContentType = encapContent.ReadObjectIdentifier();
|
||||
|
||||
if (encapContentType != TstInfoOid)
|
||||
{
|
||||
throw new CryptographicException($"Expected TSTInfo OID, got: {encapContentType}");
|
||||
}
|
||||
|
||||
// [0] EXPLICIT OCTET STRING containing TSTInfo
|
||||
var tstInfoWrapper = encapContent.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0));
|
||||
var tstInfoBytes = tstInfoWrapper.ReadOctetString();
|
||||
return DecodeTstInfo(tstInfoBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Asn1/TimeStampTokenDecoder.TstInfo.OptionalFields.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-002 - ASN.1 Parsing & Generation
|
||||
// Description: Optional TSTInfo field parsing.
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
using System.Formats.Asn1;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Asn1;
|
||||
|
||||
public static partial class TimeStampTokenDecoder
|
||||
{
|
||||
private static (TstAccuracy? Accuracy, bool Ordering, byte[]? Nonce, string? TsaName, List<TimeStampExtension>? Extensions)
|
||||
ReadOptionalFields(ref AsnReader tstInfo)
|
||||
{
|
||||
TstAccuracy? accuracy = null;
|
||||
var ordering = false;
|
||||
byte[]? nonce = null;
|
||||
string? tsaName = null;
|
||||
List<TimeStampExtension>? extensions = null;
|
||||
|
||||
while (tstInfo.HasData)
|
||||
{
|
||||
var tag = tstInfo.PeekTag();
|
||||
|
||||
// accuracy Accuracy OPTIONAL
|
||||
if (tag.TagValue == 16 && tag.TagClass == TagClass.Universal) // SEQUENCE
|
||||
{
|
||||
accuracy = DecodeAccuracy(tstInfo.ReadSequence());
|
||||
continue;
|
||||
}
|
||||
|
||||
// ordering BOOLEAN DEFAULT FALSE
|
||||
if (tag.TagValue == 1 && tag.TagClass == TagClass.Universal) // BOOLEAN
|
||||
{
|
||||
ordering = tstInfo.ReadBoolean();
|
||||
continue;
|
||||
}
|
||||
|
||||
// nonce INTEGER OPTIONAL
|
||||
if (tag.TagValue == 2 && tag.TagClass == TagClass.Universal) // INTEGER
|
||||
{
|
||||
nonce = tstInfo.ReadIntegerBytes().ToArray();
|
||||
continue;
|
||||
}
|
||||
|
||||
// tsa [0] GeneralName OPTIONAL
|
||||
if (tag.TagClass == TagClass.ContextSpecific && tag.TagValue == 0)
|
||||
{
|
||||
_ = tstInfo.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 0));
|
||||
// Simplified: just record that a TSA GeneralName is present.
|
||||
tsaName = "(TSA GeneralName present)";
|
||||
continue;
|
||||
}
|
||||
|
||||
// extensions [1] IMPLICIT Extensions OPTIONAL
|
||||
if (tag.TagClass == TagClass.ContextSpecific && tag.TagValue == 1)
|
||||
{
|
||||
var extSeq = tstInfo.ReadSequence(new Asn1Tag(TagClass.ContextSpecific, 1));
|
||||
extensions = [];
|
||||
while (extSeq.HasData)
|
||||
{
|
||||
var ext = extSeq.ReadSequence();
|
||||
var extOid = ext.ReadObjectIdentifier();
|
||||
var critical = false;
|
||||
if (ext.HasData && ext.PeekTag().TagValue == 1) // BOOLEAN
|
||||
{
|
||||
critical = ext.ReadBoolean();
|
||||
}
|
||||
var extValue = ext.ReadOctetString();
|
||||
extensions.Add(new TimeStampExtension(extOid, critical, extValue));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unknown, skip
|
||||
tstInfo.ReadEncodedValue();
|
||||
}
|
||||
|
||||
return (accuracy, ordering, nonce, tsaName, extensions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Asn1/TimeStampTokenDecoder.TstInfo.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-002 - ASN.1 Parsing & Generation
|
||||
// Description: TSTInfo decoding helpers.
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Asn1;
|
||||
|
||||
public static partial class TimeStampTokenDecoder
|
||||
{
|
||||
private static TstInfo DecodeTstInfo(byte[] encoded)
|
||||
{
|
||||
var reader = new AsnReader(encoded, AsnEncodingRules.DER);
|
||||
var tstInfo = reader.ReadSequence();
|
||||
|
||||
// version INTEGER
|
||||
var version = (int)tstInfo.ReadInteger();
|
||||
|
||||
// policy TSAPolicyId
|
||||
var policyOid = tstInfo.ReadObjectIdentifier();
|
||||
|
||||
// messageImprint MessageImprint
|
||||
var (hashAlgorithm, imprint) = ReadMessageImprint(tstInfo);
|
||||
|
||||
// serialNumber INTEGER
|
||||
var serialNumber = tstInfo.ReadIntegerBytes().ToArray();
|
||||
|
||||
// genTime GeneralizedTime
|
||||
var genTime = tstInfo.ReadGeneralizedTime();
|
||||
|
||||
var (accuracy, ordering, nonce, tsaName, extensions) = ReadOptionalFields(ref tstInfo);
|
||||
|
||||
return new TstInfo
|
||||
{
|
||||
EncodedTstInfo = encoded,
|
||||
Version = version,
|
||||
PolicyOid = policyOid,
|
||||
HashAlgorithm = hashAlgorithm,
|
||||
MessageImprint = imprint,
|
||||
SerialNumber = serialNumber,
|
||||
GenTime = genTime,
|
||||
Accuracy = accuracy,
|
||||
Ordering = ordering,
|
||||
Nonce = nonce,
|
||||
TsaName = tsaName,
|
||||
Extensions = extensions
|
||||
};
|
||||
}
|
||||
|
||||
private static (HashAlgorithmName HashAlgorithm, byte[] Imprint) ReadMessageImprint(AsnReader tstInfo)
|
||||
{
|
||||
var msgImprint = tstInfo.ReadSequence();
|
||||
var algId = msgImprint.ReadSequence();
|
||||
var hashOid = algId.ReadObjectIdentifier();
|
||||
var hashAlgorithm = TimeStampReqEncoder.GetHashAlgorithmFromOid(hashOid);
|
||||
var imprint = msgImprint.ReadOctetString();
|
||||
return (hashAlgorithm, imprint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Asn1/TimeStampTokenDecoder.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-002 - ASN.1 Parsing & Generation
|
||||
// Description: RFC 3161 TimeStampToken decoder entry point.
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Asn1;
|
||||
|
||||
/// <summary>
|
||||
/// Decodes RFC 3161 TimeStampToken from DER format.
|
||||
/// </summary>
|
||||
public static partial class TimeStampTokenDecoder
|
||||
{
|
||||
private const string SignedDataOid = "1.2.840.113549.1.7.2";
|
||||
private const string TstInfoOid = "1.2.840.113549.1.9.16.1.4";
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a TimeStampToken from DER-encoded bytes.
|
||||
/// </summary>
|
||||
/// <param name="encoded">The DER-encoded TimeStampToken (ContentInfo).</param>
|
||||
/// <returns>The decoded TimeStampToken.</returns>
|
||||
public static TimeStampToken Decode(ReadOnlyMemory<byte> encoded)
|
||||
{
|
||||
var reader = new AsnReader(encoded, AsnEncodingRules.DER);
|
||||
|
||||
// ContentInfo ::= SEQUENCE { contentType, content [0] EXPLICIT }
|
||||
var contentInfo = reader.ReadSequence();
|
||||
var contentType = contentInfo.ReadObjectIdentifier();
|
||||
|
||||
if (contentType != SignedDataOid)
|
||||
{
|
||||
throw new CryptographicException($"Expected SignedData OID, got: {contentType}");
|
||||
}
|
||||
|
||||
var signedData = ReadSignedData(contentInfo);
|
||||
var tstInfo = ReadTstInfo(ref signedData);
|
||||
var certs = ReadCertificates(ref signedData, out var signerCert);
|
||||
var signatureAlgorithmOid = ReadSignatureAlgorithmOid(ref signedData);
|
||||
|
||||
return new TimeStampToken
|
||||
{
|
||||
EncodedToken = encoded,
|
||||
TstInfo = tstInfo,
|
||||
SignerCertificate = signerCert,
|
||||
Certificates = certs,
|
||||
SignatureAlgorithmOid = signatureAlgorithmOid
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InMemoryTsaCacheStore.Helpers.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-005 - Provider Configuration & Management
|
||||
// Description: Cache cleanup helpers.
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Caching;
|
||||
|
||||
public sealed partial class InMemoryTsaCacheStore
|
||||
{
|
||||
private void CleanupExpired(object? state)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var expiredKeys = _cache
|
||||
.Where(kvp => kvp.Value.ExpiresAt <= now)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
_cache.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ToKey(ReadOnlyMemory<byte> messageImprint)
|
||||
{
|
||||
return Convert.ToHexString(messageImprint.Span);
|
||||
}
|
||||
|
||||
private sealed record CacheEntry(TimeStampToken Token, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ namespace StellaOps.Authority.Timestamping.Caching;
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ITsaCacheStore"/>.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTsaCacheStore : ITsaCacheStore, IDisposable
|
||||
public sealed partial class InMemoryTsaCacheStore : ITsaCacheStore, IDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
||||
private readonly Timer _cleanupTimer;
|
||||
@@ -97,25 +97,4 @@ public sealed class InMemoryTsaCacheStore : ITsaCacheStore, IDisposable
|
||||
{
|
||||
_cleanupTimer.Dispose();
|
||||
}
|
||||
|
||||
private void CleanupExpired(object? state)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var expiredKeys = _cache
|
||||
.Where(kvp => kvp.Value.ExpiresAt <= now)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
_cache.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ToKey(ReadOnlyMemory<byte> messageImprint)
|
||||
{
|
||||
return Convert.ToHexString(messageImprint.Span);
|
||||
}
|
||||
|
||||
private sealed record CacheEntry(TimeStampToken Token, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// HttpTsaClient.GetTimeStamp.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-003 - HTTP TSA Client
|
||||
// Description: Timestamp request orchestration.
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
public sealed partial class HttpTsaClient
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<TimeStampResponse> GetTimeStampAsync(
|
||||
TimeStampRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var orderedProviders = GetOrderedProviders();
|
||||
|
||||
foreach (var provider in orderedProviders)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await TryGetTimeStampFromProviderAsync(
|
||||
provider, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccess)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Timestamp obtained from provider {Provider} in {Duration}ms",
|
||||
provider.Name,
|
||||
response.RequestDuration?.TotalMilliseconds ?? 0);
|
||||
return response;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Provider {Provider} returned status {Status}: {StatusString}",
|
||||
provider.Name,
|
||||
response.Status,
|
||||
response.StatusString ?? response.FailureInfo?.ToString());
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Provider {Provider} failed, trying next",
|
||||
provider.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return TimeStampResponse.Failure(
|
||||
PkiStatus.Rejection,
|
||||
PkiFailureInfo.SystemFailure,
|
||||
"All TSA providers failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// HttpTsaClient.ProviderOrdering.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-003 - HTTP TSA Client
|
||||
// Description: Provider ordering and failover logic.
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
public sealed partial class HttpTsaClient
|
||||
{
|
||||
private IEnumerable<TsaProviderOptions> GetOrderedProviders()
|
||||
{
|
||||
var enabled = _options.Providers.Where(p => p.Enabled).ToList();
|
||||
|
||||
return _options.FailoverStrategy switch
|
||||
{
|
||||
FailoverStrategy.Priority => enabled.OrderBy(p => p.Priority),
|
||||
FailoverStrategy.RoundRobin => GetRoundRobinOrder(enabled),
|
||||
FailoverStrategy.Random => enabled.OrderBy(_ => Random.Shared.Next()),
|
||||
FailoverStrategy.LowestLatency => enabled.OrderBy(p => p.Priority), // TODO: track latency
|
||||
_ => enabled.OrderBy(p => p.Priority)
|
||||
};
|
||||
}
|
||||
|
||||
private IEnumerable<TsaProviderOptions> GetRoundRobinOrder(List<TsaProviderOptions> providers)
|
||||
{
|
||||
var startIndex = Interlocked.Increment(ref _roundRobinIndex) % providers.Count;
|
||||
for (var i = 0; i < providers.Count; i++)
|
||||
{
|
||||
yield return providers[(startIndex + i) % providers.Count];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// HttpTsaClient.ProviderRequest.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-003 - HTTP TSA Client
|
||||
// Description: Provider request execution.
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
using StellaOps.Authority.Timestamping.Asn1;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
public sealed partial class HttpTsaClient
|
||||
{
|
||||
private async Task<TimeStampResponse> TryGetTimeStampFromProviderAsync(
|
||||
TsaProviderOptions provider,
|
||||
TimeStampRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient($"TSA_{provider.Name}");
|
||||
client.Timeout = provider.Timeout;
|
||||
|
||||
var encodedRequest = TimeStampReqEncoder.Encode(request);
|
||||
var content = new ByteArrayContent(encodedRequest);
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue(TimeStampQueryContentType);
|
||||
|
||||
foreach (var (key, value) in provider.Headers)
|
||||
{
|
||||
content.Headers.TryAddWithoutValidation(key, value);
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var lastException = default(Exception);
|
||||
|
||||
for (var attempt = 0; attempt <= provider.RetryCount; attempt++)
|
||||
{
|
||||
if (attempt > 0)
|
||||
{
|
||||
var delay = TimeSpan.FromTicks(
|
||||
provider.RetryBaseDelay.Ticks * (1L << (attempt - 1)));
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var httpResponse = await client.PostAsync(
|
||||
provider.Url, content, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!httpResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"TSA {Provider} returned HTTP {StatusCode}",
|
||||
provider.Name,
|
||||
httpResponse.StatusCode);
|
||||
continue;
|
||||
}
|
||||
|
||||
var responseContentType = httpResponse.Content.Headers.ContentType?.MediaType;
|
||||
if (responseContentType != TimeStampReplyContentType)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"TSA {Provider} returned unexpected content type: {ContentType}",
|
||||
provider.Name,
|
||||
responseContentType);
|
||||
}
|
||||
|
||||
var responseBytes = await httpResponse.Content.ReadAsByteArrayAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
|
||||
var response = TimeStampRespDecoder.Decode(responseBytes);
|
||||
return response with
|
||||
{
|
||||
ProviderName = provider.Name,
|
||||
RequestDuration = stopwatch.Elapsed
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Attempt {Attempt}/{MaxAttempts} to {Provider} failed",
|
||||
attempt + 1,
|
||||
provider.RetryCount + 1,
|
||||
provider.Name);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastException ?? new InvalidOperationException("No attempts made");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// HttpTsaClient.Verification.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-003 - HTTP TSA Client
|
||||
// Description: Verification and parsing helpers.
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
using StellaOps.Authority.Timestamping.Asn1;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
public sealed partial class HttpTsaClient
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<TimeStampVerificationResult> VerifyAsync(
|
||||
TimeStampToken token,
|
||||
ReadOnlyMemory<byte> originalHash,
|
||||
TimeStampVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _verifier.VerifyAsync(
|
||||
token,
|
||||
originalHash,
|
||||
options ?? _options.DefaultVerificationOptions,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeStampToken ParseToken(ReadOnlyMemory<byte> encodedToken)
|
||||
{
|
||||
return TimeStampTokenDecoder.Decode(encodedToken);
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,13 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
using StellaOps.Authority.Timestamping.Asn1;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP(S) client for RFC 3161 TSA endpoints with multi-provider failover.
|
||||
/// </summary>
|
||||
public sealed class HttpTsaClient : ITimeStampAuthorityClient
|
||||
public sealed partial class HttpTsaClient : ITimeStampAuthorityClient
|
||||
{
|
||||
private const string TimeStampQueryContentType = "application/timestamp-query";
|
||||
private const string TimeStampReplyContentType = "application/timestamp-reply";
|
||||
@@ -54,165 +51,4 @@ public sealed class HttpTsaClient : ITimeStampAuthorityClient
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<TsaProviderInfo> Providers => _providerInfo;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TimeStampResponse> GetTimeStampAsync(
|
||||
TimeStampRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var orderedProviders = GetOrderedProviders();
|
||||
|
||||
foreach (var provider in orderedProviders)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await TryGetTimeStampFromProviderAsync(
|
||||
provider, request, cancellationToken);
|
||||
|
||||
if (response.IsSuccess)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Timestamp obtained from provider {Provider} in {Duration}ms",
|
||||
provider.Name,
|
||||
response.RequestDuration?.TotalMilliseconds ?? 0);
|
||||
return response;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Provider {Provider} returned status {Status}: {StatusString}",
|
||||
provider.Name,
|
||||
response.Status,
|
||||
response.StatusString ?? response.FailureInfo?.ToString());
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Provider {Provider} failed, trying next",
|
||||
provider.Name);
|
||||
}
|
||||
}
|
||||
|
||||
return TimeStampResponse.Failure(
|
||||
PkiStatus.Rejection,
|
||||
PkiFailureInfo.SystemFailure,
|
||||
"All TSA providers failed");
|
||||
}
|
||||
|
||||
private async Task<TimeStampResponse> TryGetTimeStampFromProviderAsync(
|
||||
TsaProviderOptions provider,
|
||||
TimeStampRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient($"TSA_{provider.Name}");
|
||||
client.Timeout = provider.Timeout;
|
||||
|
||||
var encodedRequest = TimeStampReqEncoder.Encode(request);
|
||||
var content = new ByteArrayContent(encodedRequest);
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue(TimeStampQueryContentType);
|
||||
|
||||
foreach (var (key, value) in provider.Headers)
|
||||
{
|
||||
content.Headers.TryAddWithoutValidation(key, value);
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var lastException = default(Exception);
|
||||
|
||||
for (var attempt = 0; attempt <= provider.RetryCount; attempt++)
|
||||
{
|
||||
if (attempt > 0)
|
||||
{
|
||||
var delay = TimeSpan.FromTicks(
|
||||
provider.RetryBaseDelay.Ticks * (1L << (attempt - 1)));
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var httpResponse = await client.PostAsync(
|
||||
provider.Url, content, cancellationToken);
|
||||
|
||||
if (!httpResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"TSA {Provider} returned HTTP {StatusCode}",
|
||||
provider.Name,
|
||||
httpResponse.StatusCode);
|
||||
continue;
|
||||
}
|
||||
|
||||
var responseContentType = httpResponse.Content.Headers.ContentType?.MediaType;
|
||||
if (responseContentType != TimeStampReplyContentType)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"TSA {Provider} returned unexpected content type: {ContentType}",
|
||||
provider.Name,
|
||||
responseContentType);
|
||||
}
|
||||
|
||||
var responseBytes = await httpResponse.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
stopwatch.Stop();
|
||||
|
||||
var response = TimeStampRespDecoder.Decode(responseBytes);
|
||||
return response with
|
||||
{
|
||||
ProviderName = provider.Name,
|
||||
RequestDuration = stopwatch.Elapsed
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Attempt {Attempt}/{MaxAttempts} to {Provider} failed",
|
||||
attempt + 1,
|
||||
provider.RetryCount + 1,
|
||||
provider.Name);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastException ?? new InvalidOperationException("No attempts made");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TimeStampVerificationResult> VerifyAsync(
|
||||
TimeStampToken token,
|
||||
ReadOnlyMemory<byte> originalHash,
|
||||
TimeStampVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _verifier.VerifyAsync(
|
||||
token, originalHash, options ?? _options.DefaultVerificationOptions, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeStampToken ParseToken(ReadOnlyMemory<byte> encodedToken)
|
||||
{
|
||||
return TimeStampTokenDecoder.Decode(encodedToken);
|
||||
}
|
||||
|
||||
private IEnumerable<TsaProviderOptions> GetOrderedProviders()
|
||||
{
|
||||
var enabled = _options.Providers.Where(p => p.Enabled).ToList();
|
||||
|
||||
return _options.FailoverStrategy switch
|
||||
{
|
||||
FailoverStrategy.Priority => enabled.OrderBy(p => p.Priority),
|
||||
FailoverStrategy.RoundRobin => GetRoundRobinOrder(enabled),
|
||||
FailoverStrategy.Random => enabled.OrderBy(_ => Random.Shared.Next()),
|
||||
FailoverStrategy.LowestLatency => enabled.OrderBy(p => p.Priority), // TODO: track latency
|
||||
_ => enabled.OrderBy(p => p.Priority)
|
||||
};
|
||||
}
|
||||
|
||||
private IEnumerable<TsaProviderOptions> GetRoundRobinOrder(List<TsaProviderOptions> providers)
|
||||
{
|
||||
var startIndex = Interlocked.Increment(ref _roundRobinIndex) % providers.Count;
|
||||
for (var i = 0; i < providers.Count; i++)
|
||||
{
|
||||
yield return providers[(startIndex + i) % providers.Count];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,168 +52,3 @@ public interface ITsaProviderRegistry
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task<TsaProviderHealth> CheckHealthAsync(string providerName, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State of a TSA provider including health and statistics.
|
||||
/// </summary>
|
||||
public sealed record TsaProviderState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider options.
|
||||
/// </summary>
|
||||
public required TsaProviderOptions Options { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current health status.
|
||||
/// </summary>
|
||||
public required TsaProviderHealth Health { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the usage statistics.
|
||||
/// </summary>
|
||||
public required TsaProviderStats Stats { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health status of a TSA provider.
|
||||
/// </summary>
|
||||
public sealed record TsaProviderHealth
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the provider is healthy.
|
||||
/// </summary>
|
||||
public bool IsHealthy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the health status.
|
||||
/// </summary>
|
||||
public TsaHealthStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last error message if unhealthy.
|
||||
/// </summary>
|
||||
public string? LastError { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the provider was last checked.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastCheckedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the provider became unhealthy.
|
||||
/// </summary>
|
||||
public DateTimeOffset? UnhealthySince { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the consecutive failure count.
|
||||
/// </summary>
|
||||
public int ConsecutiveFailures { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the provider can be retried (if in backoff).
|
||||
/// </summary>
|
||||
public DateTimeOffset? RetryAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a healthy status.
|
||||
/// </summary>
|
||||
public static TsaProviderHealth Healthy() => new()
|
||||
{
|
||||
IsHealthy = true,
|
||||
Status = TsaHealthStatus.Healthy,
|
||||
LastCheckedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates an unhealthy status.
|
||||
/// </summary>
|
||||
public static TsaProviderHealth Unhealthy(string error, int failures, DateTimeOffset? retryAfter = null) => new()
|
||||
{
|
||||
IsHealthy = false,
|
||||
Status = retryAfter.HasValue ? TsaHealthStatus.InBackoff : TsaHealthStatus.Unhealthy,
|
||||
LastError = error,
|
||||
LastCheckedAt = DateTimeOffset.UtcNow,
|
||||
UnhealthySince = DateTimeOffset.UtcNow,
|
||||
ConsecutiveFailures = failures,
|
||||
RetryAfter = retryAfter
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health status enum for TSA providers.
|
||||
/// </summary>
|
||||
public enum TsaHealthStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Provider is unknown (not yet checked).
|
||||
/// </summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>
|
||||
/// Provider is healthy.
|
||||
/// </summary>
|
||||
Healthy,
|
||||
|
||||
/// <summary>
|
||||
/// Provider is degraded (slow but functional).
|
||||
/// </summary>
|
||||
Degraded,
|
||||
|
||||
/// <summary>
|
||||
/// Provider is unhealthy (failures detected).
|
||||
/// </summary>
|
||||
Unhealthy,
|
||||
|
||||
/// <summary>
|
||||
/// Provider is in backoff period after failures.
|
||||
/// </summary>
|
||||
InBackoff
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Usage statistics for a TSA provider.
|
||||
/// </summary>
|
||||
public sealed record TsaProviderStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the total number of requests.
|
||||
/// </summary>
|
||||
public long TotalRequests { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of successful requests.
|
||||
/// </summary>
|
||||
public long SuccessCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of failed requests.
|
||||
/// </summary>
|
||||
public long FailureCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the success rate as a percentage.
|
||||
/// </summary>
|
||||
public double SuccessRate => TotalRequests > 0
|
||||
? (double)SuccessCount / TotalRequests * 100
|
||||
: 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the average latency in milliseconds.
|
||||
/// </summary>
|
||||
public double AverageLatencyMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the P95 latency in milliseconds.
|
||||
/// </summary>
|
||||
public double P95LatencyMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last successful request time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastSuccessAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last failed request time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastFailureAt { get; init; }
|
||||
}
|
||||
|
||||
@@ -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/Authority/__Libraries/StellaOps.Authority.Timestamping/StellaOps.Authority.Timestamping.md. |
|
||||
| REMED-05 | DONE | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Authority/__Libraries/StellaOps.Authority.Timestamping/StellaOps.Authority.Timestamping.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimeStampTokenVerifier.CertificateChain.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-004 - TST Signature Verification
|
||||
// Description: Certificate chain validation helper.
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
public sealed partial class TimeStampTokenVerifier
|
||||
{
|
||||
private static VerificationError? TryVerifyCertificateChain(
|
||||
TimeStampVerificationOptions options,
|
||||
X509Certificate2? signerCert,
|
||||
List<VerificationWarning> warnings,
|
||||
out X509Chain? chain)
|
||||
{
|
||||
chain = null;
|
||||
|
||||
if (!options.VerifyCertificateChain)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (signerCert is null)
|
||||
{
|
||||
return new VerificationError(
|
||||
VerificationErrorCode.SignerCertificateMissing,
|
||||
"No signer certificate found in timestamp token");
|
||||
}
|
||||
|
||||
chain = new X509Chain();
|
||||
chain.ChainPolicy.RevocationMode = options.CheckRevocation
|
||||
? options.RevocationMode
|
||||
: X509RevocationMode.NoCheck;
|
||||
chain.ChainPolicy.RevocationFlag = options.RevocationFlag;
|
||||
|
||||
if (options.VerificationTime.HasValue)
|
||||
{
|
||||
chain.ChainPolicy.VerificationTime = options.VerificationTime.Value.DateTime;
|
||||
}
|
||||
|
||||
if (options.TrustAnchors is not null)
|
||||
{
|
||||
chain.ChainPolicy.CustomTrustStore.AddRange(options.TrustAnchors);
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
}
|
||||
|
||||
if (options.IntermediateCertificates is not null)
|
||||
{
|
||||
chain.ChainPolicy.ExtraStore.AddRange(options.IntermediateCertificates);
|
||||
}
|
||||
|
||||
if (!chain.Build(signerCert))
|
||||
{
|
||||
var status = chain.ChainStatus.FirstOrDefault();
|
||||
var errorCode = status.Status switch
|
||||
{
|
||||
X509ChainStatusFlags.NotTimeValid => VerificationErrorCode.CertificateExpired,
|
||||
X509ChainStatusFlags.Revoked => VerificationErrorCode.CertificateRevoked,
|
||||
X509ChainStatusFlags.UntrustedRoot => VerificationErrorCode.NoTrustAnchor,
|
||||
_ => VerificationErrorCode.CertificateChainInvalid
|
||||
};
|
||||
|
||||
return new VerificationError(
|
||||
errorCode,
|
||||
$"Certificate chain validation failed: {status.StatusInformation}",
|
||||
string.Join(", ", chain.ChainStatus.Select(s => s.Status)));
|
||||
}
|
||||
|
||||
if (options.CheckRevocation &&
|
||||
chain.ChainStatus.Any(s => s.Status == X509ChainStatusFlags.RevocationStatusUnknown))
|
||||
{
|
||||
warnings.Add(new VerificationWarning(
|
||||
VerificationWarningCode.RevocationCheckSkipped,
|
||||
"Revocation status could not be determined"));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<X509Certificate2>? ExtractChainCertificates(X509Chain? chain)
|
||||
{
|
||||
return chain?.ChainElements.Select(e => e.Certificate).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimeStampTokenVerifier.Signature.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-004 - TST Signature Verification
|
||||
// Description: Signature validation helper.
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.Pkcs;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
public sealed partial class TimeStampTokenVerifier
|
||||
{
|
||||
private static VerificationError? TryVerifySignature(
|
||||
TimeStampToken token,
|
||||
TimeStampVerificationOptions options,
|
||||
out X509Certificate2? signerCert)
|
||||
{
|
||||
var signedCms = new SignedCms();
|
||||
signedCms.Decode(token.EncodedToken.ToArray());
|
||||
|
||||
signerCert = null;
|
||||
try
|
||||
{
|
||||
if (signedCms.SignerInfos.Count > 0)
|
||||
{
|
||||
var signerInfo = signedCms.SignerInfos[0];
|
||||
signerCert = signerInfo.Certificate;
|
||||
signerInfo.CheckSignature(verifySignatureOnly: !options.VerifyCertificateChain);
|
||||
}
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
return new VerificationError(
|
||||
VerificationErrorCode.SignatureInvalid,
|
||||
"CMS signature verification failed",
|
||||
ex.Message);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimeStampTokenVerifier.Validation.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-004 - TST Signature Verification
|
||||
// Description: Validation helpers for imprint, nonce, and hash strength.
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
public sealed partial class TimeStampTokenVerifier
|
||||
{
|
||||
private static VerificationError? ValidateMessageImprint(
|
||||
TimeStampToken token,
|
||||
ReadOnlyMemory<byte> originalHash)
|
||||
{
|
||||
if (token.TstInfo.MessageImprint.Span.SequenceEqual(originalHash.Span))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new VerificationError(
|
||||
VerificationErrorCode.MessageImprintMismatch,
|
||||
"The message imprint in the timestamp does not match the original hash");
|
||||
}
|
||||
|
||||
private static VerificationError? ValidateNonce(
|
||||
TimeStampToken token,
|
||||
TimeStampVerificationOptions options)
|
||||
{
|
||||
if (options.ExpectedNonce is not { Length: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (token.TstInfo.Nonce is null)
|
||||
{
|
||||
return new VerificationError(
|
||||
VerificationErrorCode.NonceMismatch,
|
||||
"Expected nonce but timestamp has no nonce");
|
||||
}
|
||||
|
||||
if (!token.TstInfo.Nonce.Value.Span.SequenceEqual(options.ExpectedNonce.Value.Span))
|
||||
{
|
||||
return new VerificationError(
|
||||
VerificationErrorCode.NonceMismatch,
|
||||
"Timestamp nonce does not match expected nonce");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void AppendWeakHashWarning(
|
||||
TimeStampToken token,
|
||||
TimeStampVerificationOptions options,
|
||||
List<VerificationWarning> warnings)
|
||||
{
|
||||
if (options.AllowWeakHashAlgorithms || token.TstInfo.HashAlgorithm.Name != "SHA1")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
warnings.Add(new VerificationWarning(
|
||||
VerificationWarningCode.WeakHashAlgorithm,
|
||||
"Timestamp uses SHA-1 which is considered weak"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimeStampTokenVerifier.Warnings.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-004 - TST Signature Verification
|
||||
// Description: Warning enrichment for policy, accuracy, and expiry.
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
public sealed partial class TimeStampTokenVerifier
|
||||
{
|
||||
private static void AppendPolicyWarnings(
|
||||
TimeStampToken token,
|
||||
TimeStampVerificationOptions options,
|
||||
List<VerificationWarning> warnings)
|
||||
{
|
||||
if (options.AcceptablePolicies is not { Count: > 0 })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.AcceptablePolicies.Contains(token.TstInfo.PolicyOid))
|
||||
{
|
||||
warnings.Add(new VerificationWarning(
|
||||
VerificationWarningCode.UnknownPolicy,
|
||||
$"Timestamp policy {token.TstInfo.PolicyOid} is not in acceptable policies list"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendAccuracyWarnings(
|
||||
TimeStampToken token,
|
||||
TimeStampVerificationOptions options,
|
||||
List<VerificationWarning> warnings)
|
||||
{
|
||||
if (!options.MaxAccuracySeconds.HasValue || token.TstInfo.Accuracy is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var accuracySpan = token.TstInfo.Accuracy.ToTimeSpan();
|
||||
if (accuracySpan.TotalSeconds > options.MaxAccuracySeconds.Value)
|
||||
{
|
||||
warnings.Add(new VerificationWarning(
|
||||
VerificationWarningCode.LargeAccuracy,
|
||||
$"Timestamp accuracy ({accuracySpan.TotalSeconds}s) exceeds maximum ({options.MaxAccuracySeconds}s)"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendExpiryWarnings(
|
||||
X509Certificate2? signerCert,
|
||||
List<VerificationWarning> warnings)
|
||||
{
|
||||
if (signerCert is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var daysUntilExpiry = (signerCert.NotAfter - DateTime.UtcNow).TotalDays;
|
||||
if (daysUntilExpiry < 30 && daysUntilExpiry > 0)
|
||||
{
|
||||
warnings.Add(new VerificationWarning(
|
||||
VerificationWarningCode.CertificateNearingExpiration,
|
||||
$"TSA certificate expires in {daysUntilExpiry:F0} days"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,15 @@
|
||||
// Task: TSA-004 - TST Signature Verification
|
||||
// Description: Cryptographic verification of TimeStampToken signatures.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.Pkcs;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies TimeStampToken signatures and certificate chains.
|
||||
/// </summary>
|
||||
public sealed class TimeStampTokenVerifier
|
||||
public sealed partial class TimeStampTokenVerifier
|
||||
{
|
||||
private readonly ILogger<TimeStampTokenVerifier> _logger;
|
||||
|
||||
@@ -42,174 +37,46 @@ public sealed class TimeStampTokenVerifier
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Verify message imprint matches
|
||||
if (!token.TstInfo.MessageImprint.Span.SequenceEqual(originalHash.Span))
|
||||
var error = ValidateMessageImprint(token, originalHash);
|
||||
if (error is not null)
|
||||
{
|
||||
return Task.FromResult(TimeStampVerificationResult.Failure(
|
||||
new VerificationError(
|
||||
VerificationErrorCode.MessageImprintMismatch,
|
||||
"The message imprint in the timestamp does not match the original hash")));
|
||||
return Task.FromResult(TimeStampVerificationResult.Failure(error));
|
||||
}
|
||||
|
||||
// Step 2: Verify nonce if expected
|
||||
if (options.ExpectedNonce is { Length: > 0 })
|
||||
error = ValidateNonce(token, options);
|
||||
if (error is not null)
|
||||
{
|
||||
if (token.TstInfo.Nonce is null)
|
||||
{
|
||||
return Task.FromResult(TimeStampVerificationResult.Failure(
|
||||
new VerificationError(
|
||||
VerificationErrorCode.NonceMismatch,
|
||||
"Expected nonce but timestamp has no nonce")));
|
||||
}
|
||||
|
||||
if (!token.TstInfo.Nonce.Value.Span.SequenceEqual(options.ExpectedNonce.Value.Span))
|
||||
{
|
||||
return Task.FromResult(TimeStampVerificationResult.Failure(
|
||||
new VerificationError(
|
||||
VerificationErrorCode.NonceMismatch,
|
||||
"Timestamp nonce does not match expected nonce")));
|
||||
}
|
||||
return Task.FromResult(TimeStampVerificationResult.Failure(error));
|
||||
}
|
||||
|
||||
// Step 3: Check hash algorithm strength
|
||||
if (!options.AllowWeakHashAlgorithms &&
|
||||
token.TstInfo.HashAlgorithm.Name == "SHA1")
|
||||
AppendWeakHashWarning(token, options, warnings);
|
||||
|
||||
error = TryVerifySignature(token, options, out var signerCert);
|
||||
if (error is not null)
|
||||
{
|
||||
warnings.Add(new VerificationWarning(
|
||||
VerificationWarningCode.WeakHashAlgorithm,
|
||||
"Timestamp uses SHA-1 which is considered weak"));
|
||||
return Task.FromResult(TimeStampVerificationResult.Failure(error));
|
||||
}
|
||||
|
||||
// Step 4: Verify CMS signature
|
||||
var signedCms = new SignedCms();
|
||||
signedCms.Decode(token.EncodedToken.ToArray());
|
||||
|
||||
X509Certificate2? signerCert = null;
|
||||
try
|
||||
error = TryVerifyCertificateChain(options, signerCert, warnings, out var chain);
|
||||
if (error is not null)
|
||||
{
|
||||
// Try to find signer certificate
|
||||
if (signedCms.SignerInfos.Count > 0)
|
||||
{
|
||||
var signerInfo = signedCms.SignerInfos[0];
|
||||
signerCert = signerInfo.Certificate;
|
||||
|
||||
// Verify signature
|
||||
signerInfo.CheckSignature(verifySignatureOnly: !options.VerifyCertificateChain);
|
||||
}
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
return Task.FromResult(TimeStampVerificationResult.Failure(
|
||||
new VerificationError(
|
||||
VerificationErrorCode.SignatureInvalid,
|
||||
"CMS signature verification failed",
|
||||
ex.Message)));
|
||||
return Task.FromResult(TimeStampVerificationResult.Failure(error));
|
||||
}
|
||||
|
||||
// Step 5: Verify certificate chain if requested
|
||||
X509Chain? chain = null;
|
||||
if (options.VerifyCertificateChain && signerCert is not null)
|
||||
{
|
||||
chain = new X509Chain();
|
||||
chain.ChainPolicy.RevocationMode = options.CheckRevocation
|
||||
? options.RevocationMode
|
||||
: X509RevocationMode.NoCheck;
|
||||
chain.ChainPolicy.RevocationFlag = options.RevocationFlag;
|
||||
AppendPolicyWarnings(token, options, warnings);
|
||||
AppendAccuracyWarnings(token, options, warnings);
|
||||
AppendExpiryWarnings(signerCert, warnings);
|
||||
|
||||
if (options.VerificationTime.HasValue)
|
||||
{
|
||||
chain.ChainPolicy.VerificationTime = options.VerificationTime.Value.DateTime;
|
||||
}
|
||||
var chainCertificates = ExtractChainCertificates(chain);
|
||||
var warningList = warnings.Count > 0 ? warnings : null;
|
||||
|
||||
if (options.TrustAnchors is not null)
|
||||
{
|
||||
chain.ChainPolicy.CustomTrustStore.AddRange(options.TrustAnchors);
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
}
|
||||
|
||||
if (options.IntermediateCertificates is not null)
|
||||
{
|
||||
chain.ChainPolicy.ExtraStore.AddRange(options.IntermediateCertificates);
|
||||
}
|
||||
|
||||
if (!chain.Build(signerCert))
|
||||
{
|
||||
var status = chain.ChainStatus.FirstOrDefault();
|
||||
var errorCode = status.Status switch
|
||||
{
|
||||
X509ChainStatusFlags.NotTimeValid => VerificationErrorCode.CertificateExpired,
|
||||
X509ChainStatusFlags.Revoked => VerificationErrorCode.CertificateRevoked,
|
||||
X509ChainStatusFlags.UntrustedRoot => VerificationErrorCode.NoTrustAnchor,
|
||||
_ => VerificationErrorCode.CertificateChainInvalid
|
||||
};
|
||||
|
||||
return Task.FromResult(TimeStampVerificationResult.Failure(
|
||||
new VerificationError(
|
||||
errorCode,
|
||||
$"Certificate chain validation failed: {status.StatusInformation}",
|
||||
string.Join(", ", chain.ChainStatus.Select(s => s.Status)))));
|
||||
}
|
||||
|
||||
// Check if revocation check was actually performed
|
||||
if (options.CheckRevocation &&
|
||||
chain.ChainStatus.Any(s => s.Status == X509ChainStatusFlags.RevocationStatusUnknown))
|
||||
{
|
||||
warnings.Add(new VerificationWarning(
|
||||
VerificationWarningCode.RevocationCheckSkipped,
|
||||
"Revocation status could not be determined"));
|
||||
}
|
||||
}
|
||||
else if (options.VerifyCertificateChain && signerCert is null)
|
||||
{
|
||||
return Task.FromResult(TimeStampVerificationResult.Failure(
|
||||
new VerificationError(
|
||||
VerificationErrorCode.SignerCertificateMissing,
|
||||
"No signer certificate found in timestamp token")));
|
||||
}
|
||||
|
||||
// Step 6: Check policy if required
|
||||
if (options.AcceptablePolicies is { Count: > 0 })
|
||||
{
|
||||
if (!options.AcceptablePolicies.Contains(token.TstInfo.PolicyOid))
|
||||
{
|
||||
warnings.Add(new VerificationWarning(
|
||||
VerificationWarningCode.UnknownPolicy,
|
||||
$"Timestamp policy {token.TstInfo.PolicyOid} is not in acceptable policies list"));
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Check accuracy if required
|
||||
if (options.MaxAccuracySeconds.HasValue && token.TstInfo.Accuracy is not null)
|
||||
{
|
||||
var accuracySpan = token.TstInfo.Accuracy.ToTimeSpan();
|
||||
if (accuracySpan.TotalSeconds > options.MaxAccuracySeconds.Value)
|
||||
{
|
||||
warnings.Add(new VerificationWarning(
|
||||
VerificationWarningCode.LargeAccuracy,
|
||||
$"Timestamp accuracy ({accuracySpan.TotalSeconds}s) exceeds maximum ({options.MaxAccuracySeconds}s)"));
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: Check certificate expiration warning
|
||||
if (signerCert is not null)
|
||||
{
|
||||
var daysUntilExpiry = (signerCert.NotAfter - DateTime.UtcNow).TotalDays;
|
||||
if (daysUntilExpiry < 30 && daysUntilExpiry > 0)
|
||||
{
|
||||
warnings.Add(new VerificationWarning(
|
||||
VerificationWarningCode.CertificateNearingExpiration,
|
||||
$"TSA certificate expires in {daysUntilExpiry:F0} days"));
|
||||
}
|
||||
}
|
||||
|
||||
// Success
|
||||
return Task.FromResult(TimeStampVerificationResult.Success(
|
||||
token.TstInfo.GenTime,
|
||||
token.TstInfo.GetTimeRange(),
|
||||
token.TstInfo.PolicyOid,
|
||||
signerCert,
|
||||
chain?.ChainElements.Select(e => e.Certificate).ToList(),
|
||||
warnings.Count > 0 ? warnings : null));
|
||||
chainCertificates,
|
||||
warningList));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimestampingServiceCollectionExtensions.CommonProviders.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-007 - DI Integration
|
||||
// Description: Built-in TSA provider presets.
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
public static partial class TimestampingServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds common free TSA providers.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddCommonTsaProviders(this IServiceCollection services)
|
||||
{
|
||||
// FreeTSA.org
|
||||
services.AddTsaProvider("FreeTSA", "https://freetsa.org/tsr", opts =>
|
||||
{
|
||||
opts.Priority = 100;
|
||||
opts.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Digicert
|
||||
services.AddTsaProvider("Digicert", "http://timestamp.digicert.com", opts =>
|
||||
{
|
||||
opts.Priority = 200;
|
||||
opts.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Sectigo
|
||||
services.AddTsaProvider("Sectigo", "http://timestamp.sectigo.com", opts =>
|
||||
{
|
||||
opts.Priority = 300;
|
||||
opts.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimestampingServiceCollectionExtensions.Provider.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-007 - DI Integration
|
||||
// Description: Provider registration helpers.
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
public static partial class TimestampingServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a TSA provider to the configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="name">Provider name.</param>
|
||||
/// <param name="url">TSA endpoint URL.</param>
|
||||
/// <param name="configure">Additional configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddTsaProvider(
|
||||
this IServiceCollection services,
|
||||
string name,
|
||||
string url,
|
||||
Action<TsaProviderOptions>? configure = null)
|
||||
{
|
||||
services.Configure<TsaClientOptions>(options =>
|
||||
{
|
||||
var provider = new TsaProviderOptions
|
||||
{
|
||||
Name = name,
|
||||
Url = new Uri(url)
|
||||
};
|
||||
configure?.Invoke(provider);
|
||||
options.Providers.Add(provider);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace StellaOps.Authority.Timestamping;
|
||||
/// <summary>
|
||||
/// Extension methods for registering timestamping services.
|
||||
/// </summary>
|
||||
public static class TimestampingServiceCollectionExtensions
|
||||
public static partial class TimestampingServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds RFC-3161 timestamping services to the service collection.
|
||||
@@ -45,63 +45,4 @@ public static class TimestampingServiceCollectionExtensions
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a TSA provider to the configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="name">Provider name.</param>
|
||||
/// <param name="url">TSA endpoint URL.</param>
|
||||
/// <param name="configure">Additional configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddTsaProvider(
|
||||
this IServiceCollection services,
|
||||
string name,
|
||||
string url,
|
||||
Action<TsaProviderOptions>? configure = null)
|
||||
{
|
||||
services.Configure<TsaClientOptions>(options =>
|
||||
{
|
||||
var provider = new TsaProviderOptions
|
||||
{
|
||||
Name = name,
|
||||
Url = new Uri(url)
|
||||
};
|
||||
configure?.Invoke(provider);
|
||||
options.Providers.Add(provider);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds common free TSA providers.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddCommonTsaProviders(this IServiceCollection services)
|
||||
{
|
||||
// FreeTSA.org
|
||||
services.AddTsaProvider("FreeTSA", "https://freetsa.org/tsr", opts =>
|
||||
{
|
||||
opts.Priority = 100;
|
||||
opts.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Digicert
|
||||
services.AddTsaProvider("Digicert", "http://timestamp.digicert.com", opts =>
|
||||
{
|
||||
opts.Priority = 200;
|
||||
opts.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
// Sectigo
|
||||
services.AddTsaProvider("Sectigo", "http://timestamp.sectigo.com", opts =>
|
||||
{
|
||||
opts.Priority = 300;
|
||||
opts.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TsaHealthStatus.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-005 - Provider Configuration & Management
|
||||
// Description: Provider health status flags.
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
/// <summary>
|
||||
/// Health status enum for TSA providers.
|
||||
/// </summary>
|
||||
public enum TsaHealthStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Provider is unknown (not yet checked).
|
||||
/// </summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>
|
||||
/// Provider is healthy.
|
||||
/// </summary>
|
||||
Healthy,
|
||||
|
||||
/// <summary>
|
||||
/// Provider is degraded (slow but functional).
|
||||
/// </summary>
|
||||
Degraded,
|
||||
|
||||
/// <summary>
|
||||
/// Provider is unhealthy (failures detected).
|
||||
/// </summary>
|
||||
Unhealthy,
|
||||
|
||||
/// <summary>
|
||||
/// Provider is in backoff period after failures.
|
||||
/// </summary>
|
||||
InBackoff
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TsaProviderHealth.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-005 - Provider Configuration & Management
|
||||
// Description: Provider health snapshot.
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
/// <summary>
|
||||
/// Health status of a TSA provider.
|
||||
/// </summary>
|
||||
public sealed record TsaProviderHealth
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the provider is healthy.
|
||||
/// </summary>
|
||||
public bool IsHealthy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the health status.
|
||||
/// </summary>
|
||||
public TsaHealthStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last error message if unhealthy.
|
||||
/// </summary>
|
||||
public string? LastError { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the provider was last checked.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastCheckedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the provider became unhealthy.
|
||||
/// </summary>
|
||||
public DateTimeOffset? UnhealthySince { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the consecutive failure count.
|
||||
/// </summary>
|
||||
public int ConsecutiveFailures { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the provider can be retried (if in backoff).
|
||||
/// </summary>
|
||||
public DateTimeOffset? RetryAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a healthy status.
|
||||
/// </summary>
|
||||
public static TsaProviderHealth Healthy() => new()
|
||||
{
|
||||
IsHealthy = true,
|
||||
Status = TsaHealthStatus.Healthy,
|
||||
LastCheckedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates an unhealthy status.
|
||||
/// </summary>
|
||||
public static TsaProviderHealth Unhealthy(string error, int failures, DateTimeOffset? retryAfter = null) => new()
|
||||
{
|
||||
IsHealthy = false,
|
||||
Status = retryAfter.HasValue ? TsaHealthStatus.InBackoff : TsaHealthStatus.Unhealthy,
|
||||
LastError = error,
|
||||
LastCheckedAt = DateTimeOffset.UtcNow,
|
||||
UnhealthySince = DateTimeOffset.UtcNow,
|
||||
ConsecutiveFailures = failures,
|
||||
RetryAfter = retryAfter
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TsaProviderRegistry.HealthCheck.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-005 - Provider Configuration & Management
|
||||
// Description: Health probe logic for providers.
|
||||
// -----------------------------------------------------------------------------
|
||||
using System.Net.Http;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
public sealed partial class TsaProviderRegistry
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<TsaProviderHealth> CheckHealthAsync(
|
||||
string providerName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_states.TryGetValue(providerName, out var state))
|
||||
{
|
||||
return new TsaProviderHealth
|
||||
{
|
||||
Status = TsaHealthStatus.Unknown,
|
||||
LastError = "Provider not found"
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient($"TSA_{providerName}");
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
// Simple connectivity check - just verify the endpoint is reachable
|
||||
_ = await client.SendAsync(
|
||||
new HttpRequestMessage(HttpMethod.Head, state.Options.Url),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Most TSAs don't support HEAD, so any response (even 4xx) means it's reachable
|
||||
var health = TsaProviderHealth.Healthy();
|
||||
|
||||
lock (state)
|
||||
{
|
||||
state.Health = health;
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var health = TsaProviderHealth.Unhealthy(ex.Message, state.ConsecutiveFailures + 1);
|
||||
|
||||
lock (state)
|
||||
{
|
||||
state.Health = health;
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TsaProviderRegistry.ProviderState.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-005 - Provider Configuration & Management
|
||||
// Description: Internal provider state.
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
public sealed partial class TsaProviderRegistry
|
||||
{
|
||||
private sealed class ProviderState
|
||||
{
|
||||
public required TsaProviderOptions Options { get; init; }
|
||||
public TsaProviderHealth Health { get; set; } = new() { Status = TsaHealthStatus.Unknown };
|
||||
public List<double> Latencies { get; init; } = [];
|
||||
public long TotalRequests { get; set; }
|
||||
public long SuccessCount { get; set; }
|
||||
public long FailureCount { get; set; }
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
public string? LastError { get; set; }
|
||||
public DateTimeOffset? LastSuccessAt { get; set; }
|
||||
public DateTimeOffset? LastFailureAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TsaProviderRegistry.Providers.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-005 - Provider Configuration & Management
|
||||
// Description: Provider enumeration and ordering.
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
public sealed partial class TsaProviderRegistry
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<TsaProviderState> GetProviders()
|
||||
{
|
||||
return _states.Values.Select(s => new TsaProviderState
|
||||
{
|
||||
Options = s.Options,
|
||||
Health = s.Health,
|
||||
Stats = ComputeStats(s)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TsaProviderOptions> GetOrderedProviders(bool excludeUnhealthy = true)
|
||||
{
|
||||
var providers = _states.Values
|
||||
.Where(s => s.Options.Enabled)
|
||||
.Where(s => !excludeUnhealthy || IsAvailable(s))
|
||||
.ToList();
|
||||
|
||||
return _options.FailoverStrategy switch
|
||||
{
|
||||
FailoverStrategy.Priority => providers.OrderBy(p => p.Options.Priority).Select(p => p.Options),
|
||||
FailoverStrategy.RoundRobin => GetRoundRobinOrder(providers).Select(p => p.Options),
|
||||
FailoverStrategy.LowestLatency => providers.OrderBy(p => GetAverageLatency(p)).Select(p => p.Options),
|
||||
FailoverStrategy.Random => providers.OrderBy(_ => Random.Shared.Next()).Select(p => p.Options),
|
||||
_ => providers.OrderBy(p => p.Options.Priority).Select(p => p.Options)
|
||||
};
|
||||
}
|
||||
|
||||
private bool IsAvailable(ProviderState state)
|
||||
{
|
||||
if (!state.Health.IsHealthy && state.Health.RetryAfter.HasValue)
|
||||
{
|
||||
return DateTimeOffset.UtcNow >= state.Health.RetryAfter.Value;
|
||||
}
|
||||
return state.Health.Status != TsaHealthStatus.Unhealthy || state.ConsecutiveFailures < 5;
|
||||
}
|
||||
|
||||
private double GetAverageLatency(ProviderState state)
|
||||
{
|
||||
lock (state)
|
||||
{
|
||||
return state.Latencies.Count > 0
|
||||
? state.Latencies.Average()
|
||||
: double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<ProviderState> GetRoundRobinOrder(List<ProviderState> providers)
|
||||
{
|
||||
if (providers.Count == 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var startIndex = Interlocked.Increment(ref _roundRobinIndex) % providers.Count;
|
||||
for (var i = 0; i < providers.Count; i++)
|
||||
{
|
||||
yield return providers[(startIndex + i) % providers.Count];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TsaProviderRegistry.Reporting.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-005 - Provider Configuration & Management
|
||||
// Description: Success/failure reporting and health lookup.
|
||||
// -----------------------------------------------------------------------------
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
public sealed partial class TsaProviderRegistry
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void ReportSuccess(string providerName, TimeSpan latency)
|
||||
{
|
||||
if (!_states.TryGetValue(providerName, out var state))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (state)
|
||||
{
|
||||
state.TotalRequests++;
|
||||
state.SuccessCount++;
|
||||
state.LastSuccessAt = DateTimeOffset.UtcNow;
|
||||
state.ConsecutiveFailures = 0;
|
||||
|
||||
// Keep last 100 latencies for stats
|
||||
state.Latencies.Add(latency.TotalMilliseconds);
|
||||
if (state.Latencies.Count > 100)
|
||||
{
|
||||
state.Latencies.RemoveAt(0);
|
||||
}
|
||||
|
||||
state.Health = TsaProviderHealth.Healthy();
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"TSA {Provider} request succeeded in {Latency}ms",
|
||||
providerName,
|
||||
latency.TotalMilliseconds);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ReportFailure(string providerName, string error)
|
||||
{
|
||||
if (!_states.TryGetValue(providerName, out var state))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (state)
|
||||
{
|
||||
state.TotalRequests++;
|
||||
state.FailureCount++;
|
||||
state.LastFailureAt = DateTimeOffset.UtcNow;
|
||||
state.ConsecutiveFailures++;
|
||||
state.LastError = error;
|
||||
|
||||
// Calculate backoff based on consecutive failures
|
||||
var backoffSeconds = Math.Min(300, Math.Pow(2, state.ConsecutiveFailures));
|
||||
var retryAfter = state.ConsecutiveFailures >= 3
|
||||
? DateTimeOffset.UtcNow.AddSeconds(backoffSeconds)
|
||||
: (DateTimeOffset?)null;
|
||||
|
||||
state.Health = TsaProviderHealth.Unhealthy(
|
||||
error,
|
||||
state.ConsecutiveFailures,
|
||||
retryAfter);
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"TSA {Provider} request failed: {Error} (consecutive failures: {Failures})",
|
||||
providerName,
|
||||
error,
|
||||
state.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public TsaProviderHealth GetHealth(string providerName)
|
||||
{
|
||||
return _states.TryGetValue(providerName, out var state)
|
||||
? state.Health
|
||||
: new TsaProviderHealth { Status = TsaHealthStatus.Unknown };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TsaProviderRegistry.Stats.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-005 - Provider Configuration & Management
|
||||
// Description: Stats aggregation for providers.
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
public sealed partial class TsaProviderRegistry
|
||||
{
|
||||
private static TsaProviderStats ComputeStats(ProviderState state)
|
||||
{
|
||||
lock (state)
|
||||
{
|
||||
var sortedLatencies = state.Latencies.OrderBy(l => l).ToList();
|
||||
var p95Index = (int)(sortedLatencies.Count * 0.95);
|
||||
|
||||
return new TsaProviderStats
|
||||
{
|
||||
TotalRequests = state.TotalRequests,
|
||||
SuccessCount = state.SuccessCount,
|
||||
FailureCount = state.FailureCount,
|
||||
AverageLatencyMs = sortedLatencies.Count > 0 ? sortedLatencies.Average() : 0,
|
||||
P95LatencyMs = sortedLatencies.Count > 0
|
||||
? sortedLatencies[Math.Min(p95Index, sortedLatencies.Count - 1)]
|
||||
: 0,
|
||||
LastSuccessAt = state.LastSuccessAt,
|
||||
LastFailureAt = state.LastFailureAt
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,8 @@
|
||||
// TsaProviderRegistry.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-005 - Provider Configuration & Management
|
||||
// Description: Implementation of TSA provider registry with health tracking.
|
||||
// Description: Provider registry with health tracking.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
@@ -16,7 +14,7 @@ namespace StellaOps.Authority.Timestamping;
|
||||
/// <summary>
|
||||
/// Implementation of <see cref="ITsaProviderRegistry"/> with health tracking and failover.
|
||||
/// </summary>
|
||||
public sealed class TsaProviderRegistry : ITsaProviderRegistry
|
||||
public sealed partial class TsaProviderRegistry : ITsaProviderRegistry
|
||||
{
|
||||
private readonly TsaClientOptions _options;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
@@ -52,212 +50,4 @@ public sealed class TsaProviderRegistry : ITsaProviderRegistry
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<TsaProviderState> GetProviders()
|
||||
{
|
||||
return _states.Values.Select(s => new TsaProviderState
|
||||
{
|
||||
Options = s.Options,
|
||||
Health = s.Health,
|
||||
Stats = ComputeStats(s)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TsaProviderOptions> GetOrderedProviders(bool excludeUnhealthy = true)
|
||||
{
|
||||
var providers = _states.Values
|
||||
.Where(s => s.Options.Enabled)
|
||||
.Where(s => !excludeUnhealthy || IsAvailable(s))
|
||||
.ToList();
|
||||
|
||||
return _options.FailoverStrategy switch
|
||||
{
|
||||
FailoverStrategy.Priority => providers.OrderBy(p => p.Options.Priority).Select(p => p.Options),
|
||||
FailoverStrategy.RoundRobin => GetRoundRobinOrder(providers).Select(p => p.Options),
|
||||
FailoverStrategy.LowestLatency => providers.OrderBy(p => GetAverageLatency(p)).Select(p => p.Options),
|
||||
FailoverStrategy.Random => providers.OrderBy(_ => Random.Shared.Next()).Select(p => p.Options),
|
||||
_ => providers.OrderBy(p => p.Options.Priority).Select(p => p.Options)
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ReportSuccess(string providerName, TimeSpan latency)
|
||||
{
|
||||
if (!_states.TryGetValue(providerName, out var state))
|
||||
return;
|
||||
|
||||
lock (state)
|
||||
{
|
||||
state.TotalRequests++;
|
||||
state.SuccessCount++;
|
||||
state.LastSuccessAt = DateTimeOffset.UtcNow;
|
||||
state.ConsecutiveFailures = 0;
|
||||
|
||||
// Keep last 100 latencies for stats
|
||||
state.Latencies.Add(latency.TotalMilliseconds);
|
||||
if (state.Latencies.Count > 100)
|
||||
{
|
||||
state.Latencies.RemoveAt(0);
|
||||
}
|
||||
|
||||
state.Health = TsaProviderHealth.Healthy();
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"TSA {Provider} request succeeded in {Latency}ms",
|
||||
providerName, latency.TotalMilliseconds);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ReportFailure(string providerName, string error)
|
||||
{
|
||||
if (!_states.TryGetValue(providerName, out var state))
|
||||
return;
|
||||
|
||||
lock (state)
|
||||
{
|
||||
state.TotalRequests++;
|
||||
state.FailureCount++;
|
||||
state.LastFailureAt = DateTimeOffset.UtcNow;
|
||||
state.ConsecutiveFailures++;
|
||||
state.LastError = error;
|
||||
|
||||
// Calculate backoff based on consecutive failures
|
||||
var backoffSeconds = Math.Min(300, Math.Pow(2, state.ConsecutiveFailures));
|
||||
var retryAfter = state.ConsecutiveFailures >= 3
|
||||
? DateTimeOffset.UtcNow.AddSeconds(backoffSeconds)
|
||||
: (DateTimeOffset?)null;
|
||||
|
||||
state.Health = TsaProviderHealth.Unhealthy(
|
||||
error,
|
||||
state.ConsecutiveFailures,
|
||||
retryAfter);
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"TSA {Provider} request failed: {Error} (consecutive failures: {Failures})",
|
||||
providerName, error, state.ConsecutiveFailures);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public TsaProviderHealth GetHealth(string providerName)
|
||||
{
|
||||
return _states.TryGetValue(providerName, out var state)
|
||||
? state.Health
|
||||
: new TsaProviderHealth { Status = TsaHealthStatus.Unknown };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TsaProviderHealth> CheckHealthAsync(
|
||||
string providerName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_states.TryGetValue(providerName, out var state))
|
||||
{
|
||||
return new TsaProviderHealth
|
||||
{
|
||||
Status = TsaHealthStatus.Unknown,
|
||||
LastError = "Provider not found"
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient($"TSA_{providerName}");
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
// Simple connectivity check - just verify the endpoint is reachable
|
||||
var response = await client.SendAsync(
|
||||
new HttpRequestMessage(HttpMethod.Head, state.Options.Url),
|
||||
cancellationToken);
|
||||
|
||||
// Most TSAs don't support HEAD, so any response (even 4xx) means it's reachable
|
||||
var health = TsaProviderHealth.Healthy();
|
||||
|
||||
lock (state)
|
||||
{
|
||||
state.Health = health;
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var health = TsaProviderHealth.Unhealthy(ex.Message, state.ConsecutiveFailures + 1);
|
||||
|
||||
lock (state)
|
||||
{
|
||||
state.Health = health;
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsAvailable(ProviderState state)
|
||||
{
|
||||
if (!state.Health.IsHealthy && state.Health.RetryAfter.HasValue)
|
||||
{
|
||||
return DateTimeOffset.UtcNow >= state.Health.RetryAfter.Value;
|
||||
}
|
||||
return state.Health.Status != TsaHealthStatus.Unhealthy || state.ConsecutiveFailures < 5;
|
||||
}
|
||||
|
||||
private double GetAverageLatency(ProviderState state)
|
||||
{
|
||||
lock (state)
|
||||
{
|
||||
return state.Latencies.Count > 0
|
||||
? state.Latencies.Average()
|
||||
: double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<ProviderState> GetRoundRobinOrder(List<ProviderState> providers)
|
||||
{
|
||||
if (providers.Count == 0)
|
||||
yield break;
|
||||
|
||||
var startIndex = Interlocked.Increment(ref _roundRobinIndex) % providers.Count;
|
||||
for (var i = 0; i < providers.Count; i++)
|
||||
{
|
||||
yield return providers[(startIndex + i) % providers.Count];
|
||||
}
|
||||
}
|
||||
|
||||
private static TsaProviderStats ComputeStats(ProviderState state)
|
||||
{
|
||||
lock (state)
|
||||
{
|
||||
var sortedLatencies = state.Latencies.OrderBy(l => l).ToList();
|
||||
var p95Index = (int)(sortedLatencies.Count * 0.95);
|
||||
|
||||
return new TsaProviderStats
|
||||
{
|
||||
TotalRequests = state.TotalRequests,
|
||||
SuccessCount = state.SuccessCount,
|
||||
FailureCount = state.FailureCount,
|
||||
AverageLatencyMs = sortedLatencies.Count > 0 ? sortedLatencies.Average() : 0,
|
||||
P95LatencyMs = sortedLatencies.Count > 0 ? sortedLatencies[Math.Min(p95Index, sortedLatencies.Count - 1)] : 0,
|
||||
LastSuccessAt = state.LastSuccessAt,
|
||||
LastFailureAt = state.LastFailureAt
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ProviderState
|
||||
{
|
||||
public required TsaProviderOptions Options { get; init; }
|
||||
public TsaProviderHealth Health { get; set; } = new() { Status = TsaHealthStatus.Unknown };
|
||||
public List<double> Latencies { get; init; } = [];
|
||||
public long TotalRequests { get; set; }
|
||||
public long SuccessCount { get; set; }
|
||||
public long FailureCount { get; set; }
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
public string? LastError { get; set; }
|
||||
public DateTimeOffset? LastSuccessAt { get; set; }
|
||||
public DateTimeOffset? LastFailureAt { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TsaProviderState.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-005 - Provider Configuration & Management
|
||||
// Description: Provider state snapshot for registry reporting.
|
||||
// -----------------------------------------------------------------------------
|
||||
using StellaOps.Authority.Timestamping.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
/// <summary>
|
||||
/// State of a TSA provider including health and statistics.
|
||||
/// </summary>
|
||||
public sealed record TsaProviderState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the provider options.
|
||||
/// </summary>
|
||||
public required TsaProviderOptions Options { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current health status.
|
||||
/// </summary>
|
||||
public required TsaProviderHealth Health { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the usage statistics.
|
||||
/// </summary>
|
||||
public required TsaProviderStats Stats { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TsaProviderStats.cs
|
||||
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
|
||||
// Task: TSA-005 - Provider Configuration & Management
|
||||
// Description: Provider usage statistics snapshot.
|
||||
// -----------------------------------------------------------------------------
|
||||
namespace StellaOps.Authority.Timestamping;
|
||||
|
||||
/// <summary>
|
||||
/// Usage statistics for a TSA provider.
|
||||
/// </summary>
|
||||
public sealed record TsaProviderStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the total number of requests.
|
||||
/// </summary>
|
||||
public long TotalRequests { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of successful requests.
|
||||
/// </summary>
|
||||
public long SuccessCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of failed requests.
|
||||
/// </summary>
|
||||
public long FailureCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the success rate as a percentage.
|
||||
/// </summary>
|
||||
public double SuccessRate => TotalRequests > 0
|
||||
? (double)SuccessCount / TotalRequests * 100
|
||||
: 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the average latency in milliseconds.
|
||||
/// </summary>
|
||||
public double AverageLatencyMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the P95 latency in milliseconds.
|
||||
/// </summary>
|
||||
public double P95LatencyMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last successful request time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastSuccessAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last failed request time.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastFailureAt { get; init; }
|
||||
}
|
||||
@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0087-M | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0087-T | DONE | Revalidated 2026-01-06 (coverage reviewed). |
|
||||
| AUDIT-0087-A | DONE | Waived (test project; revalidated 2026-01-06). |
|
||||
| REMED-05 | DONE | Unit coverage expanded for verdict manifest remediation. |
|
||||
|
||||
@@ -77,6 +77,30 @@ public sealed class InMemoryVerdictManifestStoreTests
|
||||
page3.NextPageToken.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListByAsset_Paginates()
|
||||
{
|
||||
var assetDigest = "sha256:asset";
|
||||
var first = CreateManifest("m0", "t", assetDigest: assetDigest, evaluatedAt: BaseTime);
|
||||
var second = CreateManifest("m1", "t", assetDigest: assetDigest, evaluatedAt: BaseTime.AddMinutes(-1));
|
||||
var third = CreateManifest("m2", "t", assetDigest: assetDigest, evaluatedAt: BaseTime.AddMinutes(-2));
|
||||
|
||||
await _store.StoreAsync(first);
|
||||
await _store.StoreAsync(second);
|
||||
await _store.StoreAsync(third);
|
||||
|
||||
var page1 = await _store.ListByAssetAsync("t", assetDigest, limit: 2);
|
||||
page1.Manifests.Should().HaveCount(2);
|
||||
page1.Manifests[0].ManifestId.Should().Be("m0");
|
||||
page1.Manifests[1].ManifestId.Should().Be("m1");
|
||||
page1.NextPageToken.Should().NotBeNull();
|
||||
|
||||
var page2 = await _store.ListByAssetAsync("t", assetDigest, limit: 2, pageToken: page1.NextPageToken);
|
||||
page2.Manifests.Should().HaveCount(1);
|
||||
page2.Manifests[0].ManifestId.Should().Be("m2");
|
||||
page2.NextPageToken.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_RemovesManifest()
|
||||
{
|
||||
|
||||
@@ -34,6 +34,16 @@ public sealed class VerdictManifestSerializerTests
|
||||
deserialized.Result.Confidence.Should().Be(manifest.Result.Confidence);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Deserialize_ReturnsNull_ForEmptyJson(string json)
|
||||
{
|
||||
var deserialized = VerdictManifestSerializer.Deserialize(json);
|
||||
|
||||
deserialized.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_IsDeterministic()
|
||||
{
|
||||
|
||||
@@ -33,9 +33,31 @@ public sealed class VerdictReplayVerifierTests
|
||||
result.Error.Should().Contain("Signature verification failed");
|
||||
}
|
||||
|
||||
private static VerdictManifest CreateManifest()
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ReturnsDifferences_WhenReplayDiffers()
|
||||
{
|
||||
return new VerdictManifest
|
||||
var replayResult = new VerdictResult
|
||||
{
|
||||
Status = VexStatus.Affected,
|
||||
Confidence = 0.2,
|
||||
Explanations = ImmutableArray<VerdictExplanation>.Empty,
|
||||
EvidenceRefs = ImmutableArray<string>.Empty,
|
||||
};
|
||||
|
||||
var verifier = new VerdictReplayVerifier(new NullStore(), new NullVerdictManifestSigner(), new FixedEvaluator(replayResult));
|
||||
var manifest = CreateManifest(includeSignature: false);
|
||||
|
||||
var result = await verifier.VerifyAsync(manifest, CancellationToken.None);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.SignatureValid.Should().BeTrue();
|
||||
result.Differences.Should().NotBeNull();
|
||||
result.Differences!.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
private static VerdictManifest CreateManifest(VerdictResult? result = null, bool includeSignature = true)
|
||||
{
|
||||
var manifest = new VerdictManifest
|
||||
{
|
||||
ManifestId = "manifest-1",
|
||||
Tenant = "tenant-a",
|
||||
@@ -49,7 +71,7 @@ public sealed class VerdictReplayVerifierTests
|
||||
ReachabilityGraphIds = ImmutableArray<string>.Empty,
|
||||
ClockCutoff = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
|
||||
},
|
||||
Result = new VerdictResult
|
||||
Result = result ?? new VerdictResult
|
||||
{
|
||||
Status = VexStatus.NotAffected,
|
||||
Confidence = 0.5,
|
||||
@@ -59,9 +81,12 @@ public sealed class VerdictReplayVerifierTests
|
||||
PolicyHash = "sha256:policy",
|
||||
LatticeVersion = "1.0.0",
|
||||
EvaluatedAt = DateTimeOffset.Parse("2025-01-01T00:00:00Z"),
|
||||
ManifestDigest = "sha256:manifest",
|
||||
SignatureBase64 = "invalid"
|
||||
ManifestDigest = string.Empty,
|
||||
SignatureBase64 = includeSignature ? "invalid" : null
|
||||
};
|
||||
|
||||
var digest = VerdictManifestSerializer.ComputeDigest(manifest);
|
||||
return manifest with { ManifestDigest = digest };
|
||||
}
|
||||
|
||||
private sealed class NullStore : IVerdictManifestStore
|
||||
@@ -122,4 +147,21 @@ public sealed class VerdictReplayVerifierTests
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedEvaluator(VerdictResult result) : IVerdictEvaluator
|
||||
{
|
||||
private readonly VerdictResult _result = result;
|
||||
|
||||
public Task<VerdictResult> EvaluateAsync(
|
||||
string tenant,
|
||||
string assetDigest,
|
||||
string vulnerabilityId,
|
||||
VerdictInputs inputs,
|
||||
string policyHash,
|
||||
string latticeVersion,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Task.FromResult(_result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Authority Timestamping Abstractions Tests AGENTS
|
||||
|
||||
## Purpose & Scope
|
||||
- Working directory: `src/Authority/__Tests/StellaOps.Authority.Timestamping.Abstractions.Tests/`.
|
||||
- Roles: QA automation, backend engineer.
|
||||
- Focus: timestamp request/response models, verification options, and deterministic helpers.
|
||||
|
||||
## Required Reading (treat as read before DOING)
|
||||
- `docs/README.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/authority/architecture.md`
|
||||
- Relevant sprint files.
|
||||
|
||||
## Working Agreements
|
||||
- Keep tests deterministic (fixed inputs, stable ordering).
|
||||
- Avoid live network calls; use in-memory data only.
|
||||
- Update `docs/implplan/SPRINT_*.md` and local `TASKS.md` when starting or completing work.
|
||||
|
||||
## Testing
|
||||
- Use xUnit assertions.
|
||||
- Cover factory helpers, option defaults, and validation mappings.
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Authority.Timestamping.Abstractions.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Authority.Timestamping.Abstractions\StellaOps.Authority.Timestamping.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
# Authority Timestamping Abstractions Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | DONE | Unit test coverage added to remediate Timestamping.Abstractions test gap (2026-02-04). |
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions.Tests;
|
||||
|
||||
public sealed class TimeStampRequestTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateFromHash_UsesProvidedHashAndNonceToggle()
|
||||
{
|
||||
var hash = new byte[] { 0x01, 0x02, 0x03 };
|
||||
|
||||
var request = TimeStampRequest.CreateFromHash(hash, HashAlgorithmName.SHA256, includeNonce: false);
|
||||
|
||||
Assert.Equal(hash, request.MessageImprint.ToArray());
|
||||
Assert.Null(request.Nonce);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ComputesHashAndNonceWhenRequested()
|
||||
{
|
||||
var data = new byte[] { 0x10, 0x20, 0x30 };
|
||||
var expectedHash = SHA256.HashData(data);
|
||||
|
||||
var request = TimeStampRequest.Create(data, HashAlgorithmName.SHA256, includeNonce: true);
|
||||
|
||||
Assert.Equal(expectedHash, request.MessageImprint.ToArray());
|
||||
Assert.NotNull(request.Nonce);
|
||||
Assert.Equal(8, request.Nonce!.Value.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ThrowsForUnsupportedHashAlgorithm()
|
||||
{
|
||||
var act = () => TimeStampRequest.Create([0x01], new HashAlgorithmName("MD5"));
|
||||
|
||||
var exception = Assert.Throws<ArgumentException>(act);
|
||||
Assert.Equal("algorithm", exception.ParamName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions.Tests;
|
||||
|
||||
public sealed class TimeStampResponseTests
|
||||
{
|
||||
[Fact]
|
||||
public void Success_PopulatesTokenAndProvider()
|
||||
{
|
||||
var token = TimestampingTestData.CreateToken();
|
||||
|
||||
var response = TimeStampResponse.Success(token, "tsa-A");
|
||||
|
||||
Assert.True(response.IsSuccess);
|
||||
Assert.Equal(PkiStatus.Granted, response.Status);
|
||||
Assert.Same(token, response.Token);
|
||||
Assert.Equal("tsa-A", response.ProviderName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_PopulatesStatusAndErrorFields()
|
||||
{
|
||||
var response = TimeStampResponse.Failure(PkiStatus.Rejection, PkiFailureInfo.BadAlg, "bad algo");
|
||||
|
||||
Assert.False(response.IsSuccess);
|
||||
Assert.Equal(PkiStatus.Rejection, response.Status);
|
||||
Assert.Equal(PkiFailureInfo.BadAlg, response.FailureInfo);
|
||||
Assert.Equal("bad algo", response.StatusString);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions.Tests;
|
||||
|
||||
public sealed class TimeStampTokenTests
|
||||
{
|
||||
[Fact]
|
||||
public void TstInfoDigest_UsesLowercaseSha256()
|
||||
{
|
||||
var encoded = new byte[] { 0x10, 0x20, 0x30 };
|
||||
var info = TimestampingTestData.CreateTstInfo(encoded: encoded);
|
||||
var token = TimestampingTestData.CreateToken(info);
|
||||
|
||||
var expected = Convert.ToHexString(SHA256.HashData(encoded)).ToLowerInvariant();
|
||||
|
||||
Assert.Equal(expected, token.TstInfoDigest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions.Tests;
|
||||
|
||||
public sealed class TimeStampVerificationOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_AreStable()
|
||||
{
|
||||
var options = TimeStampVerificationOptions.Default;
|
||||
|
||||
Assert.True(options.VerifyCertificateChain);
|
||||
Assert.True(options.CheckRevocation);
|
||||
Assert.False(options.AllowWeakHashAlgorithms);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Strict_EnablesChecks()
|
||||
{
|
||||
var options = TimeStampVerificationOptions.Strict;
|
||||
|
||||
Assert.True(options.VerifyCertificateChain);
|
||||
Assert.True(options.CheckRevocation);
|
||||
Assert.False(options.AllowWeakHashAlgorithms);
|
||||
Assert.Equal(60, options.MaxAccuracySeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Offline_DisablesRevocation()
|
||||
{
|
||||
var options = TimeStampVerificationOptions.Offline;
|
||||
|
||||
Assert.False(options.CheckRevocation);
|
||||
Assert.Equal(X509RevocationMode.NoCheck, options.RevocationMode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions.Tests;
|
||||
|
||||
public sealed class TimeStampVerificationResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Success_SetsValidStatusAndData()
|
||||
{
|
||||
var verifiedTime = DateTimeOffset.UnixEpoch;
|
||||
var timeRange = (verifiedTime, verifiedTime.AddSeconds(1));
|
||||
|
||||
var result = TimeStampVerificationResult.Success(verifiedTime, timeRange, policyOid: "1.2.3");
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(VerificationStatus.Valid, result.Status);
|
||||
Assert.Equal(verifiedTime, result.VerifiedTime);
|
||||
Assert.Equal(timeRange, result.TimeRange);
|
||||
Assert.Equal("1.2.3", result.PolicyOid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_MapsErrorCodeToStatus()
|
||||
{
|
||||
var error = new VerificationError(VerificationErrorCode.SignatureInvalid, "bad signature");
|
||||
|
||||
var result = TimeStampVerificationResult.Failure(error);
|
||||
|
||||
Assert.Equal(VerificationStatus.SignatureInvalid, result.Status);
|
||||
Assert.Same(error, result.Error);
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions.Tests;
|
||||
|
||||
internal static class TimestampingTestData
|
||||
{
|
||||
internal static TstInfo CreateTstInfo(
|
||||
ReadOnlyMemory<byte>? encoded = null,
|
||||
DateTimeOffset? genTime = null,
|
||||
TstAccuracy? accuracy = null)
|
||||
{
|
||||
var encodedValue = encoded ?? new byte[] { 0x01, 0x02, 0x03 };
|
||||
return new TstInfo
|
||||
{
|
||||
EncodedTstInfo = encodedValue,
|
||||
PolicyOid = "1.2.3",
|
||||
HashAlgorithm = HashAlgorithmName.SHA256,
|
||||
MessageImprint = new byte[] { 0xAA },
|
||||
SerialNumber = new byte[] { 0xBB },
|
||||
GenTime = genTime ?? DateTimeOffset.UnixEpoch,
|
||||
Accuracy = accuracy
|
||||
};
|
||||
}
|
||||
|
||||
internal static TimeStampToken CreateToken(TstInfo? info = null)
|
||||
{
|
||||
return new TimeStampToken
|
||||
{
|
||||
EncodedToken = new byte[] { 0x10 },
|
||||
TstInfo = info ?? CreateTstInfo()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions.Tests;
|
||||
|
||||
public sealed class TsaClientOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_AreStable()
|
||||
{
|
||||
var options = new TsaClientOptions();
|
||||
|
||||
Assert.Equal(FailoverStrategy.Priority, options.FailoverStrategy);
|
||||
Assert.True(options.EnableCaching);
|
||||
Assert.Equal(TimeSpan.FromHours(24), options.CacheDuration);
|
||||
Assert.Equal("SHA256", options.DefaultHashAlgorithm);
|
||||
Assert.True(options.IncludeNonceByDefault);
|
||||
Assert.True(options.RequestCertificatesByDefault);
|
||||
Assert.Same(TimeStampVerificationOptions.Default, options.DefaultVerificationOptions);
|
||||
Assert.Empty(options.Providers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProviderDefaults_AreStable()
|
||||
{
|
||||
var provider = new TsaProviderOptions
|
||||
{
|
||||
Name = "tsa-A",
|
||||
Url = new Uri("https://tsa.example")
|
||||
};
|
||||
|
||||
Assert.Equal(100, provider.Priority);
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), provider.Timeout);
|
||||
Assert.Equal(3, provider.RetryCount);
|
||||
Assert.Equal(TimeSpan.FromSeconds(1), provider.RetryBaseDelay);
|
||||
Assert.True(provider.Enabled);
|
||||
Assert.Empty(provider.Headers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions.Tests;
|
||||
|
||||
public sealed class TstAccuracyTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToTimeSpan_ConvertsMicrosAndMillis()
|
||||
{
|
||||
var accuracy = new TstAccuracy { Seconds = 2, Millis = 3, Micros = 4 };
|
||||
|
||||
Assert.Equal(TimeSpan.FromMicroseconds(2_003_004), accuracy.ToTimeSpan());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace StellaOps.Authority.Timestamping.Abstractions.Tests;
|
||||
|
||||
public sealed class TstInfoTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetTimeRange_UsesAccuracyWhenPresent()
|
||||
{
|
||||
var genTime = new DateTimeOffset(2026, 2, 4, 0, 0, 0, TimeSpan.Zero);
|
||||
var accuracy = new TstAccuracy { Seconds = 1, Millis = 500 };
|
||||
var info = TimestampingTestData.CreateTstInfo(genTime: genTime, accuracy: accuracy);
|
||||
|
||||
var (earliest, latest) = info.GetTimeRange();
|
||||
|
||||
Assert.Equal(genTime - TimeSpan.FromSeconds(1.5), earliest);
|
||||
Assert.Equal(genTime + TimeSpan.FromSeconds(1.5), latest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTimeRange_DefaultsToGenTimeWithoutAccuracy()
|
||||
{
|
||||
var genTime = new DateTimeOffset(2026, 2, 4, 1, 0, 0, TimeSpan.Zero);
|
||||
var info = TimestampingTestData.CreateTstInfo(genTime: genTime, accuracy: null);
|
||||
|
||||
var (earliest, latest) = info.GetTimeRange();
|
||||
|
||||
Assert.Equal(genTime, earliest);
|
||||
Assert.Equal(genTime, latest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Authority Timestamping Tests AGENTS
|
||||
|
||||
## Purpose & Scope
|
||||
- Working directory: `src/Authority/__Tests/StellaOps.Authority.Timestamping.Tests/`.
|
||||
- Roles: QA automation, backend engineer.
|
||||
- Focus: timestamping client helpers, registry/cache behavior, and ASN.1 encoding/decoding.
|
||||
|
||||
## Required Reading (treat as read before DOING)
|
||||
- `docs/README.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/authority/architecture.md`
|
||||
- Relevant sprint files.
|
||||
|
||||
## Working Agreements
|
||||
- Keep tests deterministic (fixed inputs, stable ordering).
|
||||
- Avoid live network calls; use in-memory handlers only.
|
||||
- Update `docs/implplan/SPRINT_*.md` and local `TASKS.md` when starting or completing work.
|
||||
|
||||
## Testing
|
||||
- Use xUnit assertions.
|
||||
@@ -0,0 +1,41 @@
|
||||
using StellaOps.Authority.Timestamping.Caching;
|
||||
|
||||
namespace StellaOps.Authority.Timestamping.Tests;
|
||||
|
||||
public sealed class InMemoryTsaCacheStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsToken_WhenPresent()
|
||||
{
|
||||
using var store = new InMemoryTsaCacheStore(TimeSpan.FromHours(1));
|
||||
var token = TimestampingTestData.CreateToken();
|
||||
|
||||
await store.SetAsync(token.TstInfo.MessageImprint, token, TimeSpan.FromMinutes(5));
|
||||
var result = await store.GetAsync(token.TstInfo.MessageImprint);
|
||||
|
||||
Assert.Same(token, result);
|
||||
|
||||
var stats = store.GetStats();
|
||||
Assert.Equal(1, stats.ItemCount);
|
||||
Assert.Equal(1, stats.HitCount);
|
||||
Assert.Equal(0, stats.MissCount);
|
||||
Assert.Equal(token.EncodedToken.Length, stats.ApproximateSizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsNull_WhenExpired()
|
||||
{
|
||||
using var store = new InMemoryTsaCacheStore(TimeSpan.FromHours(1));
|
||||
var token = TimestampingTestData.CreateToken();
|
||||
|
||||
await store.SetAsync(token.TstInfo.MessageImprint, token, TimeSpan.FromSeconds(-1));
|
||||
var result = await store.GetAsync(token.TstInfo.MessageImprint);
|
||||
|
||||
Assert.Null(result);
|
||||
|
||||
var stats = store.GetStats();
|
||||
Assert.Equal(0, stats.ItemCount);
|
||||
Assert.Equal(0, stats.HitCount);
|
||||
Assert.Equal(1, stats.MissCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" ?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>StellaOps.Authority.Timestamping.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Authority.Timestamping\StellaOps.Authority.Timestamping.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Authority.Timestamping.Abstractions\StellaOps.Authority.Timestamping.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user