sprints work.
This commit is contained in:
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user