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

View File

@@ -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. |

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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,
});
}
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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()}";
}
}

View File

@@ -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)}");
}
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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; }
}

View File

@@ -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()}";
}
}

View File

@@ -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();
}
}

View File

@@ -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,
};
}
}
}

View File

@@ -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();
}
}

View File

@@ -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; }
}

View File

@@ -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,
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -4,5 +4,5 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/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. |

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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
}

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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; }
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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);

View File

@@ -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
}

View File

@@ -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}");
}
}

View File

@@ -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);
}
}
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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}");
}
}

View File

@@ -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
};
}
}

View File

@@ -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
};
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
};
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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");
}
}

View File

@@ -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];
}
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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];
}
}
}

View File

@@ -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; }
}

View File

@@ -4,5 +4,5 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/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. |

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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"));
}
}

View File

@@ -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"));
}
}
}

View File

@@ -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)
{

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -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
};
}

View File

@@ -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;
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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];
}
}
}

View File

@@ -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 };
}
}

View File

@@ -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
};
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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. |

View File

@@ -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()
{

View File

@@ -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()
{

View File

@@ -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);
}
}
}

View File

@@ -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.

View File

@@ -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>

View File

@@ -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). |

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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()
};
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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.

View File

@@ -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);
}
}

View File

@@ -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