sprints work.

This commit is contained in:
master
2026-01-20 00:45:38 +02:00
parent b34bde89fa
commit 4903395618
275 changed files with 52785 additions and 79 deletions

View File

@@ -0,0 +1,64 @@
// -----------------------------------------------------------------------------
// ITimeStampAuthorityClient.cs
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
// Task: TSA-001 - Core Abstractions & Models
// Description: Main interface for RFC-3161 timestamping operations.
// -----------------------------------------------------------------------------
namespace StellaOps.Authority.Timestamping.Abstractions;
/// <summary>
/// Client interface for RFC-3161 Time-Stamp Authority operations.
/// Supports timestamping of data hashes and verification of TimeStampTokens.
/// </summary>
public interface ITimeStampAuthorityClient
{
/// <summary>
/// Requests a timestamp token for the given data hash.
/// </summary>
/// <param name="request">The timestamp request containing the message imprint.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The timestamp response containing the TimeStampToken or error.</returns>
Task<TimeStampResponse> GetTimeStampAsync(
TimeStampRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a TimeStampToken against the original data hash.
/// </summary>
/// <param name="token">The TimeStampToken to verify.</param>
/// <param name="originalHash">The original message hash that was timestamped.</param>
/// <param name="options">Verification options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The verification result with detailed status.</returns>
Task<TimeStampVerificationResult> VerifyAsync(
TimeStampToken token,
ReadOnlyMemory<byte> originalHash,
TimeStampVerificationOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Parses a TimeStampToken from its encoded form.
/// </summary>
/// <param name="encodedToken">The DER-encoded TimeStampToken.</param>
/// <returns>The parsed TimeStampToken.</returns>
TimeStampToken ParseToken(ReadOnlyMemory<byte> encodedToken);
/// <summary>
/// Gets the list of configured TSA providers.
/// </summary>
IReadOnlyList<TsaProviderInfo> Providers { get; }
}
/// <summary>
/// Information about a configured TSA provider.
/// </summary>
/// <param name="Name">Provider name for logging and diagnostics.</param>
/// <param name="Url">TSA endpoint URL.</param>
/// <param name="Priority">Provider priority (lower = higher priority).</param>
/// <param name="IsAvailable">Whether the provider is currently reachable.</param>
public sealed record TsaProviderInfo(
string Name,
Uri Url,
int Priority,
bool IsAvailable);

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.Authority.Timestamping.Abstractions</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,123 @@
// -----------------------------------------------------------------------------
// TimeStampRequest.cs
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
// Task: TSA-001 - Core Abstractions & Models
// Description: RFC 3161 TimeStampReq wrapper with builder pattern.
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
namespace StellaOps.Authority.Timestamping.Abstractions;
/// <summary>
/// Represents an RFC 3161 TimeStampReq for requesting a timestamp from a TSA.
/// </summary>
public sealed record TimeStampRequest
{
/// <summary>
/// Gets the version number (always 1 for RFC 3161).
/// </summary>
public int Version { get; init; } = 1;
/// <summary>
/// Gets the hash algorithm used for the message imprint.
/// </summary>
public required HashAlgorithmName HashAlgorithm { get; init; }
/// <summary>
/// Gets the hash of the data to be timestamped (message imprint).
/// </summary>
public required ReadOnlyMemory<byte> MessageImprint { get; init; }
/// <summary>
/// Gets the optional TSA policy OID.
/// </summary>
public string? PolicyOid { get; init; }
/// <summary>
/// Gets the optional nonce for replay protection.
/// </summary>
public ReadOnlyMemory<byte>? Nonce { get; init; }
/// <summary>
/// Gets whether to request the TSA certificate in the response.
/// </summary>
public bool CertificateRequired { get; init; } = true;
/// <summary>
/// Gets optional extensions.
/// </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

@@ -0,0 +1,155 @@
// -----------------------------------------------------------------------------
// TimeStampResponse.cs
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
// Task: TSA-001 - Core Abstractions & Models
// Description: RFC 3161 TimeStampResp wrapper with status and token.
// -----------------------------------------------------------------------------
namespace StellaOps.Authority.Timestamping.Abstractions;
/// <summary>
/// Represents an RFC 3161 TimeStampResp from a TSA.
/// </summary>
public sealed record TimeStampResponse
{
/// <summary>
/// Gets the PKI status of the response.
/// </summary>
public required PkiStatus Status { get; init; }
/// <summary>
/// Gets the status string from the TSA (if any).
/// </summary>
public string? StatusString { get; init; }
/// <summary>
/// Gets the failure info if the request was rejected.
/// </summary>
public PkiFailureInfo? FailureInfo { get; init; }
/// <summary>
/// Gets the TimeStampToken if the request was granted.
/// </summary>
public TimeStampToken? Token { get; init; }
/// <summary>
/// Gets whether the response contains a valid token.
/// </summary>
public bool IsSuccess => Status is PkiStatus.Granted or PkiStatus.GrantedWithMods && Token is not null;
/// <summary>
/// Gets the provider that issued this response.
/// </summary>
public string? ProviderName { get; init; }
/// <summary>
/// Gets the duration of the request.
/// </summary>
public TimeSpan? RequestDuration { get; init; }
/// <summary>
/// Creates a successful response.
/// </summary>
public static TimeStampResponse Success(TimeStampToken token, string? providerName = null) => new()
{
Status = PkiStatus.Granted,
Token = token,
ProviderName = providerName
};
/// <summary>
/// Creates a failed response.
/// </summary>
public static TimeStampResponse Failure(
PkiStatus status,
PkiFailureInfo? failureInfo = null,
string? statusString = null) => new()
{
Status = status,
FailureInfo = failureInfo,
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

@@ -0,0 +1,164 @@
// -----------------------------------------------------------------------------
// TimeStampToken.cs
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
// Task: TSA-001 - Core Abstractions & Models
// Description: RFC 3161 TimeStampToken wrapper with parsed TSTInfo fields.
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
namespace StellaOps.Authority.Timestamping.Abstractions;
/// <summary>
/// Represents an RFC 3161 TimeStampToken containing the signed timestamp.
/// </summary>
public sealed record TimeStampToken
{
/// <summary>
/// Gets the raw DER-encoded TimeStampToken.
/// </summary>
public required ReadOnlyMemory<byte> EncodedToken { get; init; }
/// <summary>
/// Gets the parsed TSTInfo from the token.
/// </summary>
public required TstInfo TstInfo { get; init; }
/// <summary>
/// Gets the signer certificate if included in the token.
/// </summary>
public X509Certificate2? SignerCertificate { get; init; }
/// <summary>
/// Gets any additional certificates from the token.
/// </summary>
public IReadOnlyList<X509Certificate2>? Certificates { get; init; }
/// <summary>
/// Gets the CMS signature algorithm OID.
/// </summary>
public string? SignatureAlgorithmOid { get; init; }
/// <summary>
/// Gets the digest of the TSTInfo (for display/logging).
/// </summary>
public string TstInfoDigest
{
get
{
var hash = SHA256.HashData(TstInfo.EncodedTstInfo.Span);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
}
/// <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

@@ -0,0 +1,97 @@
// -----------------------------------------------------------------------------
// TimeStampVerificationOptions.cs
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
// Task: TSA-001 - Core Abstractions & Models
// Description: Options for timestamp verification behavior.
// -----------------------------------------------------------------------------
using System.Security.Cryptography.X509Certificates;
namespace StellaOps.Authority.Timestamping.Abstractions;
/// <summary>
/// Options for TimeStampToken verification.
/// </summary>
public sealed record TimeStampVerificationOptions
{
/// <summary>
/// Gets or sets whether to verify the certificate chain.
/// </summary>
public bool VerifyCertificateChain { get; init; } = true;
/// <summary>
/// Gets or sets whether to check certificate revocation.
/// </summary>
public bool CheckRevocation { get; init; } = true;
/// <summary>
/// Gets or sets the revocation mode.
/// </summary>
public X509RevocationMode RevocationMode { get; init; } = X509RevocationMode.Online;
/// <summary>
/// Gets or sets the revocation flag.
/// </summary>
public X509RevocationFlag RevocationFlag { get; init; } = X509RevocationFlag.ExcludeRoot;
/// <summary>
/// Gets or sets additional trust anchors.
/// </summary>
public X509Certificate2Collection? TrustAnchors { get; init; }
/// <summary>
/// Gets or sets additional intermediate certificates.
/// </summary>
public X509Certificate2Collection? IntermediateCertificates { get; init; }
/// <summary>
/// Gets or sets the expected nonce (for replay protection).
/// </summary>
public ReadOnlyMemory<byte>? ExpectedNonce { get; init; }
/// <summary>
/// Gets or sets acceptable policy OIDs. If set, verification fails if the policy is not in this list.
/// </summary>
public IReadOnlyList<string>? AcceptablePolicies { get; init; }
/// <summary>
/// Gets or sets the verification time. If null, uses current time.
/// </summary>
public DateTimeOffset? VerificationTime { get; init; }
/// <summary>
/// Gets or sets whether to allow weak hash algorithms (SHA-1).
/// </summary>
public bool AllowWeakHashAlgorithms { get; init; } = false;
/// <summary>
/// Gets or sets the maximum acceptable accuracy in seconds.
/// </summary>
public int? MaxAccuracySeconds { get; init; }
/// <summary>
/// Gets the default verification options.
/// </summary>
public static TimeStampVerificationOptions Default { get; } = new();
/// <summary>
/// Gets strict verification options (all checks enabled, no weak algorithms).
/// </summary>
public static TimeStampVerificationOptions Strict { get; } = new()
{
VerifyCertificateChain = true,
CheckRevocation = true,
AllowWeakHashAlgorithms = false,
MaxAccuracySeconds = 60
};
/// <summary>
/// Gets offline verification options (no revocation checks).
/// </summary>
public static TimeStampVerificationOptions Offline { get; } = new()
{
VerifyCertificateChain = true,
CheckRevocation = false,
RevocationMode = X509RevocationMode.NoCheck
};
}

View File

@@ -0,0 +1,247 @@
// -----------------------------------------------------------------------------
// TimeStampVerificationResult.cs
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
// Task: TSA-001 - Core Abstractions & Models
// Description: Verification result with detailed status and chain info.
// -----------------------------------------------------------------------------
using System.Security.Cryptography.X509Certificates;
namespace StellaOps.Authority.Timestamping.Abstractions;
/// <summary>
/// Result of TimeStampToken verification.
/// </summary>
public sealed record TimeStampVerificationResult
{
/// <summary>
/// Gets the overall verification status.
/// </summary>
public required VerificationStatus Status { get; init; }
/// <summary>
/// Gets the verified generation time (if valid).
/// </summary>
public DateTimeOffset? VerifiedTime { get; init; }
/// <summary>
/// Gets the time range considering accuracy.
/// </summary>
public (DateTimeOffset Earliest, DateTimeOffset Latest)? TimeRange { get; init; }
/// <summary>
/// Gets the policy OID from the timestamp.
/// </summary>
public string? PolicyOid { get; init; }
/// <summary>
/// Gets the signer certificate.
/// </summary>
public X509Certificate2? SignerCertificate { get; init; }
/// <summary>
/// Gets the certificate chain used for validation.
/// </summary>
public IReadOnlyList<X509Certificate2>? CertificateChain { get; init; }
/// <summary>
/// Gets detailed error information if verification failed.
/// </summary>
public VerificationError? Error { get; init; }
/// <summary>
/// Gets any warnings encountered during verification.
/// </summary>
public IReadOnlyList<VerificationWarning>? Warnings { get; init; }
/// <summary>
/// Gets whether the verification was successful.
/// </summary>
public bool IsValid => Status == VerificationStatus.Valid;
/// <summary>
/// Creates a successful verification result.
/// </summary>
public static TimeStampVerificationResult Success(
DateTimeOffset verifiedTime,
(DateTimeOffset, DateTimeOffset)? timeRange = null,
string? policyOid = null,
X509Certificate2? signerCertificate = null,
IReadOnlyList<X509Certificate2>? chain = null,
IReadOnlyList<VerificationWarning>? warnings = null) => new()
{
Status = VerificationStatus.Valid,
VerifiedTime = verifiedTime,
TimeRange = timeRange,
PolicyOid = policyOid,
SignerCertificate = signerCertificate,
CertificateChain = chain,
Warnings = warnings
};
/// <summary>
/// Creates a failed verification result.
/// </summary>
public static TimeStampVerificationResult Failure(VerificationError error) => new()
{
Status = error.Code switch
{
VerificationErrorCode.SignatureInvalid => VerificationStatus.SignatureInvalid,
VerificationErrorCode.CertificateExpired => VerificationStatus.CertificateError,
VerificationErrorCode.CertificateRevoked => VerificationStatus.CertificateError,
VerificationErrorCode.CertificateChainInvalid => VerificationStatus.CertificateError,
VerificationErrorCode.MessageImprintMismatch => VerificationStatus.ImprintMismatch,
VerificationErrorCode.NonceMismatch => VerificationStatus.NonceMismatch,
_ => VerificationStatus.Invalid
},
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

@@ -0,0 +1,142 @@
// -----------------------------------------------------------------------------
// TsaClientOptions.cs
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
// Task: TSA-001 - Core Abstractions & Models
// Description: Configuration options for TSA client and providers.
// -----------------------------------------------------------------------------
namespace StellaOps.Authority.Timestamping.Abstractions;
/// <summary>
/// Global configuration options for the TSA client.
/// </summary>
public sealed class TsaClientOptions
{
/// <summary>
/// Gets or sets the configured TSA providers.
/// </summary>
public List<TsaProviderOptions> Providers { get; set; } = [];
/// <summary>
/// Gets or sets the failover strategy.
/// </summary>
public FailoverStrategy FailoverStrategy { get; set; } = FailoverStrategy.Priority;
/// <summary>
/// Gets or sets whether to cache timestamp responses.
/// </summary>
public bool EnableCaching { get; set; } = true;
/// <summary>
/// Gets or sets the cache duration for successful timestamps.
/// </summary>
public TimeSpan CacheDuration { get; set; } = TimeSpan.FromHours(24);
/// <summary>
/// Gets or sets the default hash algorithm for requests.
/// </summary>
public string DefaultHashAlgorithm { get; set; } = "SHA256";
/// <summary>
/// Gets or sets whether to include nonce by default.
/// </summary>
public bool IncludeNonceByDefault { get; set; } = true;
/// <summary>
/// Gets or sets whether to request certificates by default.
/// </summary>
public bool RequestCertificatesByDefault { get; set; } = true;
/// <summary>
/// Gets or sets the verification options to use by default.
/// </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,165 @@
// -----------------------------------------------------------------------------
// Asn1/TimeStampReqEncoder.cs
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
// Task: TSA-002 - ASN.1 Parsing & Generation
// Description: ASN.1 DER encoder for RFC 3161 TimeStampReq.
// -----------------------------------------------------------------------------
using System.Formats.Asn1;
using System.Security.Cryptography;
using StellaOps.Authority.Timestamping.Abstractions;
namespace StellaOps.Authority.Timestamping.Asn1;
/// <summary>
/// Encodes RFC 3161 TimeStampReq to DER format.
/// </summary>
public static 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>
/// <param name="request">The request to encode.</param>
/// <returns>DER-encoded TimeStampReq.</returns>
public static byte[] Encode(TimeStampRequest request)
{
var writer = new AsnWriter(AsnEncodingRules.DER);
// TimeStampReq ::= SEQUENCE
using (writer.PushSequence())
{
// version INTEGER { v1(1) }
writer.WriteInteger(request.Version);
// messageImprint MessageImprint
WriteMessageImprint(writer, request.HashAlgorithm, request.MessageImprint.Span);
// reqPolicy TSAPolicyId OPTIONAL
if (!string.IsNullOrEmpty(request.PolicyOid))
{
writer.WriteObjectIdentifier(request.PolicyOid);
}
// nonce INTEGER OPTIONAL
if (request.Nonce is { Length: > 0 })
{
writer.WriteIntegerUnsigned(request.Nonce.Value.Span);
}
// certReq BOOLEAN DEFAULT FALSE
if (request.CertificateRequired)
{
writer.WriteBoolean(true);
}
// extensions [0] IMPLICIT Extensions OPTIONAL
if (request.Extensions is { Count: > 0 })
{
WriteExtensions(writer, request.Extensions);
}
}
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

@@ -0,0 +1,362 @@
// -----------------------------------------------------------------------------
// Asn1/TimeStampRespDecoder.cs
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
// Task: TSA-002 - ASN.1 Parsing & Generation
// Description: ASN.1 DER decoder for RFC 3161 TimeStampResp.
// -----------------------------------------------------------------------------
using System.Formats.Asn1;
using System.Numerics;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using StellaOps.Authority.Timestamping.Abstractions;
namespace StellaOps.Authority.Timestamping.Asn1;
/// <summary>
/// Decodes RFC 3161 TimeStampResp from DER format.
/// </summary>
public static class TimeStampRespDecoder
{
/// <summary>
/// Decodes a TimeStampResp from DER-encoded bytes.
/// </summary>
/// <param name="encoded">The DER-encoded TimeStampResp.</param>
/// <returns>The decoded TimeStampResponse.</returns>
public static TimeStampResponse Decode(ReadOnlyMemory<byte> encoded)
{
var reader = new AsnReader(encoded, AsnEncodingRules.DER);
var respSequence = reader.ReadSequence();
// PKIStatusInfo
var statusInfo = respSequence.ReadSequence();
var status = (PkiStatus)(int)statusInfo.ReadInteger();
string? statusString = null;
PkiFailureInfo? failureInfo = null;
// statusString SEQUENCE OF UTF8String OPTIONAL
if (statusInfo.HasData && statusInfo.PeekTag().TagValue == 16) // SEQUENCE
{
var statusStrings = statusInfo.ReadSequence();
var strings = new List<string>();
while (statusStrings.HasData)
{
strings.Add(statusStrings.ReadCharacterString(UniversalTagNumber.UTF8String));
}
statusString = string.Join("; ", strings);
}
// failInfo BIT STRING OPTIONAL
if (statusInfo.HasData)
{
var failBits = statusInfo.ReadBitString(out _);
if (failBits.Length > 0)
{
var failValue = 0;
for (var i = 0; i < Math.Min(failBits.Length * 8, 26); i++)
{
if ((failBits[i / 8] & (1 << (7 - (i % 8)))) != 0)
{
failValue |= 1 << i;
}
}
failureInfo = (PkiFailureInfo)failValue;
}
}
// TimeStampToken ContentInfo OPTIONAL
TimeStampToken? token = null;
if (respSequence.HasData)
{
var contentInfoBytes = respSequence.PeekEncodedValue();
token = TimeStampTokenDecoder.Decode(contentInfoBytes);
}
return new TimeStampResponse
{
Status = status,
StatusString = statusString,
FailureInfo = failureInfo,
Token = token
};
}
}
/// <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,82 @@
// -----------------------------------------------------------------------------
// ITsaCacheStore.cs
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
// Task: TSA-005 - Provider Configuration & Management
// Description: Cache store interface for timestamp tokens.
// -----------------------------------------------------------------------------
using StellaOps.Authority.Timestamping.Abstractions;
namespace StellaOps.Authority.Timestamping.Caching;
/// <summary>
/// Cache store for TimeStampTokens to avoid redundant TSA requests.
/// </summary>
public interface ITsaCacheStore
{
/// <summary>
/// Gets a cached timestamp token for the given hash.
/// </summary>
/// <param name="messageImprint">The hash that was timestamped.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The cached token if found, null otherwise.</returns>
Task<TimeStampToken?> GetAsync(ReadOnlyMemory<byte> messageImprint, CancellationToken cancellationToken = default);
/// <summary>
/// Stores a timestamp token in the cache.
/// </summary>
/// <param name="messageImprint">The hash that was timestamped.</param>
/// <param name="token">The timestamp token.</param>
/// <param name="expiration">How long to cache the token.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task SetAsync(
ReadOnlyMemory<byte> messageImprint,
TimeStampToken token,
TimeSpan expiration,
CancellationToken cancellationToken = default);
/// <summary>
/// Removes a timestamp token from the cache.
/// </summary>
/// <param name="messageImprint">The hash that was timestamped.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task RemoveAsync(ReadOnlyMemory<byte> messageImprint, CancellationToken cancellationToken = default);
/// <summary>
/// Gets statistics about the cache.
/// </summary>
TsaCacheStats GetStats();
}
/// <summary>
/// Statistics about the TSA cache.
/// </summary>
public sealed record TsaCacheStats
{
/// <summary>
/// Gets the number of items in the cache.
/// </summary>
public int ItemCount { get; init; }
/// <summary>
/// Gets the cache hit count since startup.
/// </summary>
public long HitCount { get; init; }
/// <summary>
/// Gets the cache miss count since startup.
/// </summary>
public long MissCount { get; init; }
/// <summary>
/// Gets the hit rate as a percentage.
/// </summary>
public double HitRate => HitCount + MissCount > 0
? (double)HitCount / (HitCount + MissCount) * 100
: 0;
/// <summary>
/// Gets the approximate size in bytes.
/// </summary>
public long ApproximateSizeBytes { get; init; }
}

View File

@@ -0,0 +1,120 @@
// -----------------------------------------------------------------------------
// InMemoryTsaCacheStore.cs
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
// Task: TSA-005 - Provider Configuration & Management
// Description: In-memory cache store implementation.
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using StellaOps.Authority.Timestamping.Abstractions;
namespace StellaOps.Authority.Timestamping.Caching;
/// <summary>
/// In-memory implementation of <see cref="ITsaCacheStore"/>.
/// </summary>
public sealed class InMemoryTsaCacheStore : ITsaCacheStore, IDisposable
{
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
private readonly Timer _cleanupTimer;
private long _hitCount;
private long _missCount;
/// <summary>
/// Initializes a new instance of the <see cref="InMemoryTsaCacheStore"/> class.
/// </summary>
/// <param name="cleanupInterval">How often to run cleanup of expired entries.</param>
public InMemoryTsaCacheStore(TimeSpan? cleanupInterval = null)
{
var interval = cleanupInterval ?? TimeSpan.FromMinutes(5);
_cleanupTimer = new Timer(CleanupExpired, null, interval, interval);
}
/// <inheritdoc />
public Task<TimeStampToken?> GetAsync(
ReadOnlyMemory<byte> messageImprint,
CancellationToken cancellationToken = default)
{
var key = ToKey(messageImprint);
if (_cache.TryGetValue(key, out var entry))
{
if (entry.ExpiresAt > DateTimeOffset.UtcNow)
{
Interlocked.Increment(ref _hitCount);
return Task.FromResult<TimeStampToken?>(entry.Token);
}
// Expired, remove it
_cache.TryRemove(key, out _);
}
Interlocked.Increment(ref _missCount);
return Task.FromResult<TimeStampToken?>(null);
}
/// <inheritdoc />
public Task SetAsync(
ReadOnlyMemory<byte> messageImprint,
TimeStampToken token,
TimeSpan expiration,
CancellationToken cancellationToken = default)
{
var key = ToKey(messageImprint);
var entry = new CacheEntry(token, DateTimeOffset.UtcNow + expiration);
_cache[key] = entry;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task RemoveAsync(
ReadOnlyMemory<byte> messageImprint,
CancellationToken cancellationToken = default)
{
var key = ToKey(messageImprint);
_cache.TryRemove(key, out _);
return Task.CompletedTask;
}
/// <inheritdoc />
public TsaCacheStats GetStats()
{
var now = DateTimeOffset.UtcNow;
var validEntries = _cache.Values.Where(e => e.ExpiresAt > now).ToList();
return new TsaCacheStats
{
ItemCount = validEntries.Count,
HitCount = Interlocked.Read(ref _hitCount),
MissCount = Interlocked.Read(ref _missCount),
ApproximateSizeBytes = validEntries.Sum(e => e.Token.EncodedToken.Length)
};
}
/// <inheritdoc />
public void Dispose()
{
_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,217 @@
// -----------------------------------------------------------------------------
// HttpTsaClient.cs
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
// Task: TSA-003 - HTTP TSA Client
// Description: HTTP(S) client for RFC 3161 TSA endpoints with failover.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using System.Net.Http.Headers;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Timestamping.Abstractions;
using StellaOps.Authority.Timestamping.Asn1;
namespace StellaOps.Authority.Timestamping;
/// <summary>
/// HTTP(S) client for RFC 3161 TSA endpoints with multi-provider failover.
/// </summary>
public sealed class HttpTsaClient : ITimeStampAuthorityClient
{
private const string TimeStampQueryContentType = "application/timestamp-query";
private const string TimeStampReplyContentType = "application/timestamp-reply";
private readonly IHttpClientFactory _httpClientFactory;
private readonly TsaClientOptions _options;
private readonly TimeStampTokenVerifier _verifier;
private readonly ILogger<HttpTsaClient> _logger;
private readonly List<TsaProviderInfo> _providerInfo;
private int _roundRobinIndex;
/// <summary>
/// Initializes a new instance of the <see cref="HttpTsaClient"/> class.
/// </summary>
public HttpTsaClient(
IHttpClientFactory httpClientFactory,
IOptions<TsaClientOptions> options,
TimeStampTokenVerifier verifier,
ILogger<HttpTsaClient> logger)
{
_httpClientFactory = httpClientFactory;
_options = options.Value;
_verifier = verifier;
_logger = logger;
_providerInfo = _options.Providers
.Where(p => p.Enabled)
.OrderBy(p => p.Priority)
.Select(p => new TsaProviderInfo(p.Name, p.Url, p.Priority, true))
.ToList();
}
/// <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

@@ -0,0 +1,219 @@
// -----------------------------------------------------------------------------
// ITsaProviderRegistry.cs
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
// Task: TSA-005 - Provider Configuration & Management
// Description: Registry interface for TSA providers with health tracking.
// -----------------------------------------------------------------------------
using StellaOps.Authority.Timestamping.Abstractions;
namespace StellaOps.Authority.Timestamping;
/// <summary>
/// Registry for managing TSA providers with health tracking.
/// </summary>
public interface ITsaProviderRegistry
{
/// <summary>
/// Gets all registered providers.
/// </summary>
IReadOnlyList<TsaProviderState> GetProviders();
/// <summary>
/// Gets providers ordered by the configured failover strategy.
/// </summary>
/// <param name="excludeUnhealthy">Whether to exclude unhealthy providers.</param>
IEnumerable<TsaProviderOptions> GetOrderedProviders(bool excludeUnhealthy = true);
/// <summary>
/// Reports a successful request to a provider.
/// </summary>
/// <param name="providerName">The provider name.</param>
/// <param name="latency">The request latency.</param>
void ReportSuccess(string providerName, TimeSpan latency);
/// <summary>
/// Reports a failed request to a provider.
/// </summary>
/// <param name="providerName">The provider name.</param>
/// <param name="error">The error message.</param>
void ReportFailure(string providerName, string error);
/// <summary>
/// Gets the health status of a provider.
/// </summary>
/// <param name="providerName">The provider name.</param>
TsaProviderHealth GetHealth(string providerName);
/// <summary>
/// Forces a health check on a provider.
/// </summary>
/// <param name="providerName">The provider name.</param>
/// <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

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.Authority.Timestamping</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="System.Security.Cryptography.Pkcs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Timestamping.Abstractions\StellaOps.Authority.Timestamping.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,223 @@
// -----------------------------------------------------------------------------
// TimeStampTokenVerifier.cs
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
// Task: TSA-004 - TST Signature Verification
// Description: Cryptographic verification of TimeStampToken signatures.
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Security.Cryptography.Pkcs;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
using StellaOps.Authority.Timestamping.Abstractions;
namespace StellaOps.Authority.Timestamping;
/// <summary>
/// Verifies TimeStampToken signatures and certificate chains.
/// </summary>
public sealed class TimeStampTokenVerifier
{
private readonly ILogger<TimeStampTokenVerifier> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="TimeStampTokenVerifier"/> class.
/// </summary>
public TimeStampTokenVerifier(ILogger<TimeStampTokenVerifier> logger)
{
_logger = logger;
}
/// <summary>
/// Verifies a TimeStampToken.
/// </summary>
public Task<TimeStampVerificationResult> VerifyAsync(
TimeStampToken token,
ReadOnlyMemory<byte> originalHash,
TimeStampVerificationOptions options,
CancellationToken cancellationToken = default)
{
var warnings = new List<VerificationWarning>();
try
{
// Step 1: Verify message imprint matches
if (!token.TstInfo.MessageImprint.Span.SequenceEqual(originalHash.Span))
{
return Task.FromResult(TimeStampVerificationResult.Failure(
new VerificationError(
VerificationErrorCode.MessageImprintMismatch,
"The message imprint in the timestamp does not match the original hash")));
}
// Step 2: Verify nonce if expected
if (options.ExpectedNonce is { Length: > 0 })
{
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")));
}
}
// Step 3: Check hash algorithm strength
if (!options.AllowWeakHashAlgorithms &&
token.TstInfo.HashAlgorithm.Name == "SHA1")
{
warnings.Add(new VerificationWarning(
VerificationWarningCode.WeakHashAlgorithm,
"Timestamp uses SHA-1 which is considered weak"));
}
// Step 4: Verify CMS signature
var signedCms = new SignedCms();
signedCms.Decode(token.EncodedToken.ToArray());
X509Certificate2? signerCert = null;
try
{
// 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)));
}
// 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;
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 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));
}
catch (Exception ex)
{
_logger.LogError(ex, "Timestamp verification failed unexpectedly");
return Task.FromResult(TimeStampVerificationResult.Failure(
new VerificationError(
VerificationErrorCode.Unknown,
"Unexpected error during verification",
ex.Message)));
}
}
}

View File

@@ -0,0 +1,107 @@
// -----------------------------------------------------------------------------
// TimestampingServiceCollectionExtensions.cs
// Sprint: SPRINT_20260119_007 RFC-3161 TSA Client
// Task: TSA-007 - DI Integration
// Description: DI registration for timestamping services.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Authority.Timestamping.Abstractions;
using StellaOps.Authority.Timestamping.Caching;
namespace StellaOps.Authority.Timestamping;
/// <summary>
/// Extension methods for registering timestamping services.
/// </summary>
public static class TimestampingServiceCollectionExtensions
{
/// <summary>
/// Adds RFC-3161 timestamping services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Configuration action for TSA options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddTimestamping(
this IServiceCollection services,
Action<TsaClientOptions>? configure = null)
{
services.AddOptions<TsaClientOptions>();
if (configure is not null)
{
services.Configure(configure);
}
// Register HTTP client factory if not already registered
services.AddHttpClient();
// Register core services
services.TryAddSingleton<TimeStampTokenVerifier>();
services.TryAddSingleton<ITsaProviderRegistry, TsaProviderRegistry>();
services.TryAddSingleton<ITsaCacheStore, InMemoryTsaCacheStore>();
services.TryAddSingleton<ITimeStampAuthorityClient, HttpTsaClient>();
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,262 @@
// -----------------------------------------------------------------------------
// 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.
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Timestamping.Abstractions;
namespace StellaOps.Authority.Timestamping;
/// <summary>
/// Implementation of <see cref="ITsaProviderRegistry"/> with health tracking and failover.
/// </summary>
public sealed class TsaProviderRegistry : ITsaProviderRegistry
{
private readonly TsaClientOptions _options;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<TsaProviderRegistry> _logger;
private readonly ConcurrentDictionary<string, ProviderState> _states = new();
private int _roundRobinIndex;
/// <summary>
/// Initializes a new instance of the <see cref="TsaProviderRegistry"/> class.
/// </summary>
public TsaProviderRegistry(
IOptions<TsaClientOptions> options,
IHttpClientFactory httpClientFactory,
ILogger<TsaProviderRegistry> logger)
{
_options = options.Value;
_httpClientFactory = httpClientFactory;
_logger = logger;
// Initialize state for each provider
foreach (var provider in _options.Providers.Where(p => p.Enabled))
{
_states[provider.Name] = new ProviderState
{
Options = provider,
Health = new TsaProviderHealth
{
IsHealthy = true,
Status = TsaHealthStatus.Unknown
},
Latencies = new List<double>()
};
}
}
/// <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; }
}
}